core/generate/determine/determine.py
Shay 6f70e834ff
feat: one-hop sound relational entailment (inverse/symmetric) + capability-index lane (#775)
* feat(determine): one-hop sound relational entailment — inverse/converse + symmetric

Mastery-v2 Step 3 lead capability (core). DETERMINE could perceive relational
structure (16 predicates, breadth-complete) but could not derive the simplest
entailed relational facts — it read only the stored direction. This adds two
SOUND one-hop rules that read a stored edge in its other lawful direction:

  INVERSE/converse   greater_than(a,b)  <=  told less_than(b,a)
  SYMMETRIC          sibling_of(b,a)    <=  told sibling_of(a,b)

Strictly scoped (per review): OPEN-WORLD (asserts only True, never False — the
answer=False path stays unbuilt, INV-30's firewall holds), ONE hop (NO transitive
chaining), DECLARED rules only. Ontology/metadata-driven, not prose intuition:

- generate/meaning_graph/relational.py: declarative algebra tables — _INVERSE_PAIRS
  (the converse edges; the pack carries no inverse metadata) + INVERSE_OF (derived,
  an involution) + SYMMETRIC_PREDICATES (mirrors the pack's graph.edge.symmetric tag).
  load_relational_pack_symmetric() reads the pack ontology; a test pins the constant
  equal to it (no silent divergence).
- generate/determine/determine.py: _relational_one_hop() between direct entailment
  and transitive subsumption; new Determined.rule provenance field (direct/inverse/
  symmetric/subsumption) — replay-safe (render reads only basis; trace hashes surface).

wrong=0 confuser block proves the rule cannot over-fire: less_than is not
self-inverse, sibling_of does not imply parent_of, greater_than does not imply
equal_to, NO transitive chain (direct or through-inverse), and no answer=False is
ever emitted. An obsolete pin (test_symmetric_converse_is_not_faked, which asserted
the pre-rule direct-only floor) is updated to the new sound behavior and a preserved
asymmetric-converse bite.

Tests: test_determine_relational_inference.py (13, the capability contract) +
updated test_relational_reader.py. Broad relational/determine/grounding +
capability_index baseline sweep: 114 green; baseline digest UNCHANGED (this is a
determination capability; the comprehension lanes are untouched).

REMAINING for the full lead PR (next): the measurement lane — an independent-oracle
evals/relational_inference lane + a capability_index adapter + baseline re-freeze
(breadth 9->10, wrong_total still 0), so the capability registers on the yardstick.

INTEGRATION NOTE (cross-PR): this adds 2 Determined() construction sites (now 4:
direct/inverse/symmetric/subsumption, ALL answer=True). When rebased onto main with
INV-30 (PR #770), update INV-30's test_determine_construction_sites_are_visible count
2 -> 4 and confirm all four assert True. Merge order: #770 -> this.

* feat(capability-index): relational-inference lane — breadth 9->10, wrong=0

Mastery-v2 Step 3 lead, measurement half. Puts the one-hop relational-inference
capability ON the capability index with an independent-oracle lane:

- evals/relational_inference/v1/: cases.jsonl (13 positive: 8 inverse + 5 symmetric,
  authored from the relation algebra INDEPENDENTLY of determine/relational per
  INV-25/27), refusals.jsonl (8 confusers that MUST refuse), provenance.md (incl. the
  honest overlaps_event reader-surface coverage gap).
- evals/comprehension/relational_inference_runner.py: told fact(s) -> determine(query)
  vs gold; refusal = coverage miss, disagreement = wrong (structurally 0 on positives).
- evals/capability_index/adapters.py: comprehension_relational_inference_result added
  to ADAPTERS.
- baseline.json re-frozen: breadth 9 -> 10, capability_score 0.94403 -> 0.949483,
  wrong_total still 0, digest deliberately changed (35dea2b2...).
- tests/test_relational_inference_lane.py: SHA-pinned gold, positive wrong=0 +
  coverage>0, and the confuser wrong=0 BITE (every confuser must refuse).

Also reconciles INV-30 (now in main via #770): determine.py grew from 2 to 3
Determined() construction sites (direct, the shared relational one-hop constructor,
transitive subsumption) — ALL answer=True. test_determine_construction_sites_are_visible
updated 2 -> 3 (correcting the prior commit note's overcount of 4; inverse and symmetric
share the single _relational_determined constructor, so it is ONE new site).

Also fixes a .gitignore gap: ADR-0219 gen-dir checkpoints (engine_state/current,
engine_state/gen-*/) were not ignored (only the old flat engine_state/*.json patterns
were), so runtime checkpoints could be committed by accident.

Verification: new lane 13/0/0 coverage 1.0; capability_baseline digest matches the
re-freeze; INV-30 + architectural invariants green; smoke 99/99.

* docs+test: reconcile stale relational-inference docstrings; assert rule provenance (review #775)

Addresses two review-requested cleanups on #775:

1. Stale docstrings. generate/meaning_graph/relational.py said the symmetric converse
   is "a sound-but-incomplete refusal at DETERMINE" and there is "NO transitive/
   symmetric/rule inference"; generate/determine/determine.py said "symmetric-converse
   questions are Undetermined", "no transitive/symmetric/rule inference", and "asserts
   only answer=True on a direct hit". All false after the one-hop relational algebra
   landed. Updated: the reader does direct-reading only; DETERMINE applies declared
   one-hop inverse/converse + pack-declared symmetric (plus the existing member/subset
   subsumption), still open-world / never answer=False; transitive relational closure,
   negation, and closed-world falsehood remain out of scope.

2. Rule-provenance gold now meaningful. The relational_inference runner compared only
   (answer, predicate, subject, object), ignoring res.rule though the gold carries
   "rule": "inverse"/"symmetric". Added res.rule to the got/gold tuple so a "right
   answer via the wrong rule path" can no longer pass silently. Lane still 13/0/0;
   baseline counts (and digest) unchanged.

Verification: runner 13/0/0; lane + capability_baseline + unit + INV-30 green (22).
2026-06-15 11:39:41 -07:00

349 lines
16 KiB
Python

"""DETERMINE (roadmap Step 4) — reason over realized structure → the honest gear.
A question (a query-bearing ``Comprehension``) is answered ONLY from what the held
self has already REALIZED (R0/R1 structural recall) — never from the field, never
from an LLM, never from absence. The verdict is **as-told, never "verified"**: every
realizable record is SPECULATIVE, and ``ADMISSIBLE_AS_EVIDENCE = {COHERENT}``, so a
determination grounded in SPECULATIVE records carries ``basis="as_told"`` — "based on
what I was told (unverified)". Until COHERENT promotion exists (out of scope), D0
produces only ``as_told`` assertions or ``Undetermined`` refusals. No estimation, no
corpus mutation (teaching stays HITL proposal-only).
wrong=0 / soundness (open-world): D0 asserts an answer ONLY when the asked relation is
SOUNDLY entailed by realized facts — directly, by ONE-HOP relational algebra
(inverse/converse + pack-declared symmetric), or by transitive member/subset
subsumption. Absence of a fact never refutes it (open-world), so D0 never asserts an
answer from missing knowledge — it refuses (``Undetermined``). It asserts only
``answer=True``; it never asserts False.
Supported predicates are a CLOSED set for which DIRECT entailment is sound — a
realized ground fact ``p(subject, target)`` answers the asked ``p(subject, target)``:
the ``member`` relation (subsumption / "Is X a Y?") and the binary relational
predicates of ``en_core_relational_predicates_v1`` (``parent_of``, ``less_than``,
``left_of``, ``before_event`` …; see ``generate.meaning_graph.relational``). The
categorical (``subset``/``disjoint`` …) and propositional (``implies``/``or`` …)
predicates are deliberately EXCLUDED — their truth is not a stored-pair lookup, so
admitting them would be unsound. Negated questions and any predicate outside the closed
set are an honest ``Undetermined``. Beyond direct entailment, D0 applies two SOUND
extensions over the realized facts — ONE-HOP relational algebra (inverse/converse +
pack-declared symmetric; see ``generate.meaning_graph.relational``) and transitive
member/subset SUBSUMPTION — each search-then-verified, never closed-world, never
``answer=False``.
"""
from __future__ import annotations
from dataclasses import dataclass
from generate.composition import LogicChainPlan, lower_logic_chain
from generate.meaning_graph.reader import Comprehension, Refusal
from generate.meaning_graph.relational import (
INVERSE_OF,
RELATIONAL_PREDICATES,
SYMMETRIC_PREDICATES,
)
from generate.realize import RealizedRecord, recall_realized
from session.context import SessionContext
from teaching.epistemic import ADMISSIBLE_AS_EVIDENCE, EpistemicStatus
#: The CLOSED set of query predicates with a SOUND DIRECT-entailment path: ``member``,
#: ``subset`` (a told ``subset(a, b)`` — "all a are b" — directly answers the asked
#: ``subset(a, b)``), plus the ground binary relational predicates. Direct entailment is
#: "a realized fact ``p(s, t)`` directly answers the asked ``p(s, t)``". The OTHER
#: categorical predicates (``disjoint`` / ``intersects`` / ``some_not``) and the
#: propositional ones stay EXCLUDED — their truth is not a stored-pair lookup.
_SUPPORTED_PREDICATES = frozenset({"member", "subset"}) | RELATIONAL_PREDICATES
#: Predicates C can answer by SOUND transitive SUBSUMPTION (is-a) chaining when direct
#: entailment misses. The only sound is-a rules are ``subset ∘ subset → subset`` and
#: ``member ∘ subset → member`` (Description-Logic subsumption). ``member ∘ member`` is
#: DELIBERATELY ABSENT: instance-of is not transitive — "Socrates is a man" + "man is a
#: species" does NOT entail "Socrates is a species". The reader's member/subset split
#: (instance-of vs subclass-of) is exactly what makes the included rules sound.
_SUBSUMPTION_PREDICATES = frozenset({"member", "subset"})
#: Bound on the realized subset-fact count the transitive search will consider. Above
#: it, transitive subsumption declines (a safe, deterministic COVERAGE refusal — never
#: an unsound answer); direct entailment is unaffected. Search-then-verify is cheap
#: (O(V+E) reachability), so this is a generous backstop, not a tight grounding budget.
_SUBSUMPTION_SUBSET_FACT_BUDGET = 4096
@dataclass(frozen=True, slots=True)
class Determined:
"""An answer reasoned from realized structure.
``basis`` is the epistemic standing of the grounding: ``"as_told"`` when the
grounds are SPECULATIVE (candidate memory — the only case today), ``"verified"``
only if every ground is admissible-as-evidence (COHERENT — not yet reachable).
``answer`` is the truth of the asked (possibly negated) question.
"""
answer: bool
basis: str
predicate: str
subject: str
object: str
grounds: tuple[RealizedRecord, ...]
#: Which sound rule produced the answer — provenance for audit/replay. One of
#: ``direct`` (a stored fact of the asked predicate AND direction), ``inverse`` /
#: ``symmetric`` (a one-hop relational-algebra rule reading the stored edge in its
#: other lawful direction), or ``subsumption`` (transitive is-a chaining). It does
#: NOT affect the surface — ``render_determination`` reads only ``basis``.
rule: str = "direct"
@dataclass(frozen=True, slots=True)
class Undetermined:
"""No honest answer (refusal). ``reason`` is for audit, not control."""
reason: str
def _basis(grounds: tuple[RealizedRecord, ...]) -> str:
"""Carry the grounds' epistemic standing forward — never overclaim "verified"."""
statuses = {EpistemicStatus(g.epistemic_status) for g in grounds}
return "verified" if statuses and statuses <= ADMISSIBLE_AS_EVIDENCE else "as_told"
def determine(
question: Comprehension | Refusal, ctx: SessionContext
) -> Determined | Undetermined:
"""Answer a membership-or-relational question from realized structure, or refuse.
Eligibility: a query-bearing ``Comprehension`` with exactly one binary,
non-negated query whose predicate is in the closed direct-entailment set
(``member`` or a relational pack predicate). The answer is asserted ONLY on direct
structural entailment by a realized fact of the SAME predicate; everything else is
a typed ``Undetermined`` (open-world: absence never asserts a positive answer).
"""
if isinstance(question, Refusal):
return Undetermined("refusal")
if not isinstance(question, Comprehension):
return Undetermined("not_a_comprehension")
if len(question.queries) != 1:
return Undetermined("not_single_query") # a determination answers one question
query = question.queries[0]
if query.predicate not in _SUPPORTED_PREDICATES:
return Undetermined("unsupported_query") # honest: closed direct-entailment set
if len(query.arguments) != 2:
return Undetermined("malformed_query") # member is binary by construction
if query.negated:
# Realized facts are all positive (R0/R1 refuse negated relations), so a
# negated question would only ever be answered from the positive's presence.
# D0 declines it explicitly rather than ship an entailment path the reader
# cannot exercise (it refuses negated membership questions upstream anyway).
return Undetermined("negated_query_unsupported")
predicate = query.predicate
subject, target = query.arguments[0], query.arguments[1]
# 1. DIRECT entailment: a realized p(subject, target) holds as-told. Exact,
# deterministic structural recall (R1a) — never a metric call. Symmetric relations
# (sibling_of, equal_to …) read only the stored direction here.
direct = recall_realized(ctx, subject=subject, predicate=predicate)
grounding = next((f for f in direct if f.relation_arguments == (subject, target)), None)
if grounding is not None:
return Determined(
answer=True,
basis=_basis((grounding,)),
predicate=predicate,
subject=subject,
object=target,
grounds=(grounding,),
)
# 1b. RELATIONAL one-hop entailment: a SOUND inverse/converse or symmetric rule
# reads a stored edge in its OTHER lawful direction. Open-world (asserts only True),
# ONE hop (no transitive chaining), structurally sound by construction — the same
# discipline as direct entailment; never False, never an undeclared rule.
relational = _relational_one_hop(ctx, predicate, subject, target)
if relational is not None:
return relational
# 2. TRANSITIVE subsumption (C): when direct entailment misses, a member/subset
# query may still hold by SOUND is-a chaining (member∘subset, subset∘subset) decided
# by the sound+complete proof_chain ROBDD — NEVER member∘member.
if predicate in _SUBSUMPTION_PREDICATES:
chained = _determine_subsumption(ctx, predicate, subject, target)
if chained is not None:
return chained
# 3. Open-world refusal — absence (no direct fact, no sound chain) never asserts a
# positive answer and never asserts False.
return Undetermined("ungrounded" if not direct else "not_entailed")
def _find_relational_edge(
ctx: SessionContext, predicate: str, a: str, b: str
) -> RealizedRecord | None:
"""A realized ``predicate(a, b)`` fact (exact structural recall, R1a), or ``None``."""
facts = recall_realized(ctx, subject=a, predicate=predicate)
return next((f for f in facts if f.relation_arguments == (a, b)), None)
def _relational_one_hop(
ctx: SessionContext, predicate: str, subject: str, target: str
) -> Determined | None:
"""Answer ``predicate(subject, target)`` by ONE sound relational-algebra rule, or
``None`` (the caller then refuses, open-world). Two rules, each reading a stored edge
in its OTHER lawful direction:
INVERSE/converse ``p(subject, target)`` <= stored ``inverse(p)(target, subject)``
SYMMETRIC ``p(subject, target)`` <= stored ``p(target, subject)``
NEVER transitive (one hop only), NEVER False (open-world), NEVER an undeclared rule:
inverse fires only for a declared converse pair and symmetric only for a pack-declared
symmetric predicate — so ``less_than`` is not self-inverse and ``parent_of`` is not
symmetric.
"""
inverse = INVERSE_OF.get(predicate)
if inverse is not None:
edge = _find_relational_edge(ctx, inverse, target, subject)
if edge is not None:
return _relational_determined(predicate, subject, target, edge, "inverse")
if predicate in SYMMETRIC_PREDICATES:
edge = _find_relational_edge(ctx, predicate, target, subject)
if edge is not None:
return _relational_determined(predicate, subject, target, edge, "symmetric")
return None
def _relational_determined(
predicate: str, subject: str, target: str, ground: RealizedRecord, rule: str
) -> Determined:
"""A one-hop relational ``Determined`` — answer True, basis from the ground's
standing (as_told today), the single stored edge as grounds, the rule recorded."""
return Determined(
answer=True,
basis=_basis((ground,)),
predicate=predicate,
subject=subject,
object=target,
grounds=(ground,),
rule=rule,
)
def _subset_path(
start: str, target: str, supers: dict[str, list[tuple[str, RealizedRecord]]]
) -> tuple[RealizedRecord, ...] | None:
"""The realized ``subset`` facts on a path ``start → … → target`` (≥1 edge), or
``None`` if ``target`` is not reachable from ``start`` over the subset edges. BFS,
so the path is shortest; deterministic (neighbours are visited in sorted order)."""
if start == target:
return ()
frontier: list[str] = [start]
came_from: dict[str, tuple[str, RealizedRecord]] = {}
seen = {start}
while frontier:
node = frontier.pop(0)
for nxt, fact in sorted(supers.get(node, ()), key=lambda e: e[0]):
if nxt in seen:
continue
came_from[nxt] = (node, fact)
if nxt == target:
chain: list[RealizedRecord] = []
cur = target
while cur != start:
prev, fact = came_from[cur]
chain.append(fact)
cur = prev
return tuple(reversed(chain))
seen.add(nxt)
frontier.append(nxt)
return None
def _determine_subsumption(
ctx: SessionContext, predicate: str, subject: str, target: str
) -> Determined | None:
"""Decide a member/subset query by SOUND transitive is-a chaining, or ``None`` when
no sound chain entails it (the caller then refuses, open-world).
Search-then-verify. Reachability over the SOUND is-a edges finds a candidate chain —
``subset ∘ subset`` for a subset query, ``member ∘ subset*`` for a member query —
then the proof_chain ROBDD VERIFIES that chain's propositional entailment (O(path)
premises; full O(n³) grounding overruns the canonicalizer, and transitive closure is
reachability, not general SAT). ``member ∘ member`` is NEVER an edge, so the
instance-of fallacy ("Socrates is a man" + "man is a species""Socrates is a
species") is unreachable. wrong=0 is structural: only sound edges are traversed AND
the sound+complete decider confirms the derivation.
"""
subsets = recall_realized(ctx, predicate="subset")
if len(subsets) > _SUBSUMPTION_SUBSET_FACT_BUDGET:
return None # bounded — a safe coverage refusal, never an unsound answer
# subset adjacency: class → [(superclass, fact)], the subclass-of edges.
supers: dict[str, list[tuple[str, RealizedRecord]]] = {}
for f in subsets:
supers.setdefault(f.relation_arguments[0], []).append(
(f.relation_arguments[1], f)
)
if predicate == "subset":
path = _subset_path(subject, target, supers)
if not path: # None (unreachable) or () (start==target, not a real subset claim)
return None
return _verify_subsumption(predicate, subject, target, member_fact=None, subset_path=path)
# member query: a direct membership ``member(subject, b)`` reaches ``target`` iff
# ``b`` subsumes to ``target`` over subset edges (member ∘ subset*).
for m in recall_realized(ctx, subject=subject, predicate="member"):
b = m.relation_arguments[1]
sub_path = _subset_path(b, target, supers)
if sub_path: # ≥1 subset edge from b to target (b == target is the direct case)
verdict = _verify_subsumption(
predicate, subject, target, member_fact=m, subset_path=sub_path
)
if verdict is not None:
return verdict
return None
def _verify_subsumption(
predicate: str,
subject: str,
target: str,
*,
member_fact: RealizedRecord | None,
subset_path: tuple[RealizedRecord, ...],
) -> Determined | None:
"""Verify a found is-a chain with the proof_chain ROBDD and, on ENTAILED, return the
Determined. The propositional theory is LINEAR in the path (the facts on the chain as
true atoms + the sound rule instantiated at each hop), so it scales — unlike the full
closure grounding. Returns ``None`` if the decider does not confirm entailment
(defence in depth: a path-construction bug cannot produce a wrong assertion)."""
# Soundness-by-construction (belt-and-suspenders): the propositional theory below
# labels every ``subset_path`` fact ``S`` and the ``member_fact`` ``M``. Both callers
# (``_determine_subsumption`` and ``consolidate``) build these from predicate-filtered
# recalls, so the labels match — the lowering refuses a mislabeled / wrong-arity
# chain (``member ∘ member`` cannot be laundered through a corrupted path).
plan = LogicChainPlan(
predicate=predicate,
subject=subject,
target=target,
member_fact=member_fact,
subset_path=subset_path,
)
lowered = lower_logic_chain(plan)
if lowered is None:
return None
premises, query_atom = lowered
from generate.proof_chain.entail import Entailment, evaluate_entailment
if evaluate_entailment(premises, query_atom).outcome is not Entailment.ENTAILED:
return None
grounds = ((member_fact,) if member_fact is not None else ()) + subset_path
return Determined(
answer=True,
basis=_basis(grounds),
predicate=predicate,
subject=subject,
object=target,
grounds=grounds,
rule="subsumption",
)