* 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.
64 lines
2.9 KiB
Python
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
|