core/evals/relational_transitive/oracle.py
Shay 1ff06726a6
feat(determine): add transitive strict-order relational inference (#781)
* feat(determine): add transitive strict-order relational inference

Add sound transitive closure for declared strict-order relational predicates —
less_than, greater_than, before_event, after_event, and ONLY these. A query
p(a, c) determines True when a same-predicate chain p(a, b), p(b, c), ... over the
predicate's OWN realized edges is found (BFS reachability) and the proof_chain ROBDD
verifies the transitive entailment (search-then-verify, through the UNCHANGED
evaluate_entailment via a new lower_transitive_chain). Mirrors _determine_subsumption.

Scope / firewall:
- TRANSITIVE_PREDICATES is closed and default-off; sibling_of, parent_of,
  left_of/right_of, inside_of, during_event, overlaps_event are EXCLUDED.
- Same-predicate edges only — no transitive-through-inverse, no cross-predicate
  (triple firewall: recall filter + lowering re-reject + ROBDD re-verify).
- Open-world: asserts only answer=True via the shared _relational_determined
  surface (rule="transitive"), so INV-30 stays at exactly 3 Determined sites and
  no answer=False is added. One-hop inverse/symmetric (#775) is unchanged.
  FrameVerdict / closed-world (ADR-0222) is untouched.

wrong=0 bite (unit + lane + independent oracle): non-transitive-predicate chains,
non-admitted spatial chains, mixed-predicate chains, disjoint chains, reflexive
cycles, and inverse+transitive composition all REFUSE.

Measurement: new evals/relational_transitive lane with an INDEPENDENT BFS
transitive-closure oracle (imports no engine module; INV-25/27), cross-checked both
directions (positives oracle-True, confusers oracle-False). Capability-index breadth
10 -> 11, wrong_total 0, deterministic digest re-frozen.

Cross-PR reconcile: migrated the one-hop confuser rinf-ref-001 (a less_than chain,
which now correctly determines transitively) to a B2 positive; repurposed its unit
test to a non-transitive parent_of chain; corrected a pre-existing stale breadth==9
assertion (#775 added a 10th domain but never updated test_capability_index).

Verified in the worktree: determine + transitive/one-hop lanes + capability
baseline/index + ProofWriter-OWA floor + INV-30/29/21/02 + full smoke — all green.
A 3-skeptic read-only adversarial review found no wrong=0 leak.

* test(determine): B2 hardening — TRANSITIVE_PREDICATES pin + lower_transitive defensive + budget

Add the required B2 hardening tests before merge:

- test_transitive_predicates_closed_and_excludes: the table is EXACTLY
  {less_than, greater_than, before_event, after_event}, every member is in
  RELATIONAL_PREDICATES, and every deliberately-excluded predicate (sibling_of,
  spouse_of, parent_of, child_of, left_of, right_of, inside_of, during_event,
  overlaps_event) is explicitly absent. (Closes the gap where relational.py's
  comment claimed this test existed but it did not.)

- tests/test_composition_lower_transitive.py: direct defensive tests on
  lower_transitive_chain — empty path, mislabeled (cross-predicate) edge, wrong
  arity, non-contiguous path, and path-not-reaching-target all lower to None
  (refuse); plus exact 2-hop and 3-hop theory pins.

- test_edge_budget_exhaustion_refuses: above _TRANSITIVE_EDGE_BUDGET the transitive
  search declines (a safe coverage refusal), never proves.
2026-06-15 14:20:30 -07:00

64 lines
2.9 KiB
Python

"""Independent transitive-closure oracle — BFS reachability over STRUCTURED edges.
Authored DISJOINT from ``generate.determine`` / ``generate.meaning_graph.relational``
(INV-25 / INV-27): it imports NO engine module. It computes the gold from each case's
structured edge set, so the lane's expected labels are an independent check, not the
engine's echo. It carries its OWN declaration of which predicates are transitive (from
order semantics, not imported) and its OWN BFS — so if the engine ever made a different
predicate transitive, or composed inverse/cross-predicate edges, this oracle would still
say those chains are not entailed and the disagreement (wrong>0) would surface.
Scope — same-predicate transitive closure over the declared STRICT ORDERS, the exact
capability B2 implements. It is deliberately NOT a fuller semantic reasoner: it does not
compose inverse/symmetric/cross-predicate edges, so an inverse+transitive query is
``False`` here (matching B2's deliberate scope). The independence is in the SEPARATE
authoring, not in being a richer solver.
"""
from __future__ import annotations
from collections import deque
#: The strict orders whose same-predicate transitive closure is sound. Declared HERE,
#: independently of the engine's ``TRANSITIVE_PREDICATES`` — two independent authorings of
#: the same order-theoretic fact (INV-25/27). A predicate NOT here is non-transitive: its
#: chain is reachable as a graph but NOT entailed.
_TRANSITIVE: frozenset[str] = frozenset(
{"less_than", "greater_than", "before_event", "after_event"}
)
def transitively_entails(
edges: list[list[str]], predicate: str, subject: str, target: str
) -> bool:
"""``True`` iff ``predicate(subject, target)`` is in the same-predicate transitive
closure of ``edges`` — BFS reachability ``subject → … → target`` over the edges whose
predicate is exactly ``predicate``.
``False`` (not entailed) for: a non-transitive ``predicate`` (reachability ≠
entailment), an unreachable ``target`` (a gap or a cross-predicate/disjoint chain), or
a reflexive query (``subject == target`` — strict orders are irreflexive). ``edges`` is
a list of ``[predicate, a, b]`` triples; only ``predicate``'s own edges are walked.
"""
if predicate not in _TRANSITIVE:
return False # reachability is NOT entailment for a non-transitive predicate
if subject == target:
return False # strict orders are irreflexive
adjacency: dict[str, list[str]] = {}
for edge in edges:
p, a, b = edge
if p == predicate:
adjacency.setdefault(a, []).append(b)
seen = {subject}
queue: deque[str] = deque([subject])
while queue:
node = queue.popleft()
for nxt in adjacency.get(node, ()):
if nxt == target:
return True
if nxt not in seen:
seen.add(nxt)
queue.append(nxt)
return False