core/tests/test_determine_relational_transitive.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

288 lines
10 KiB
Python

"""DETERMINE — transitive RELATIONAL closure over declared strict orders (B2).
A declared strict-order predicate (``less_than`` / ``greater_than`` / ``before_event`` /
``after_event``) may hold by SOUND transitive closure over its OWN realized edges
(``p(a,b) ∧ p(b,c) ⊨ p(a,c)``) — search (BFS reachability) then verify (proof_chain
ROBDD). Open-world: asserts only ``answer=True``, never False; composes only same-predicate
edges (no transitive-through-inverse, no cross-predicate).
The load-bearing wrong=0 bites: a NON-transitive predicate's chain MUST refuse
(``sibling_of`` / ``parent_of`` / ``left_of`` / ``right_of``), a MIXED-predicate chain
MUST refuse, a chain with a missing middle MUST refuse, and a reflexive / cyclic query MUST
NOT fabricate. Any ``Determined`` on those is an over-fire.
"""
from __future__ import annotations
import pytest
from chat.runtime import ChatRuntime
from generate.determine import Determined, Undetermined, determine
from generate.meaning_graph.relational import (
comprehend_relational,
load_relational_pack_lemmas,
)
from generate.realize import realize_comprehension
from session.context import SessionContext
_HIGH = 10**9
@pytest.fixture(scope="module")
def vocab_persona():
rt = ChatRuntime(no_load_state=True)
return rt._context.vocab, rt._context.persona
@pytest.fixture(scope="module")
def pack():
return load_relational_pack_lemmas()
def _ctx(vocab_persona) -> SessionContext:
vocab, persona = vocab_persona
return SessionContext(vocab=vocab, persona=persona, vault_reproject_interval=_HIGH)
def _tell(text: str, ctx: SessionContext, pack) -> None:
realize_comprehension(comprehend_relational(text, pack), ctx)
def _ask(text: str, ctx: SessionContext, pack):
return determine(comprehend_relational(text, pack), ctx)
def _scenario(facts, query, vocab_persona, pack):
ctx = _ctx(vocab_persona)
for fact in facts:
_tell(fact, ctx, pack)
return _ask(query, ctx, pack)
# --------------------------------------------------------------------------- #
# Positives — the four declared strict orders close transitively
# --------------------------------------------------------------------------- #
def test_less_than_two_hop(vocab_persona, pack) -> None:
res = _scenario(
["Alice is less than Bob.", "Bob is less than Carol."],
"Is Alice less than Carol?",
vocab_persona,
pack,
)
assert isinstance(res, Determined)
assert res.answer is True and res.basis == "as_told" and res.rule == "transitive"
assert res.predicate == "less_than" and res.subject == "alice" and res.object == "carol"
assert len(res.grounds) == 2 # the two edges on the chain
def test_less_than_three_hop_scales(vocab_persona, pack) -> None:
res = _scenario(
[
"Amy is less than Bea.",
"Bea is less than Cleo.",
"Cleo is less than Dana.",
],
"Is Amy less than Dana?",
vocab_persona,
pack,
)
assert isinstance(res, Determined) and res.answer is True and res.rule == "transitive"
assert res.subject == "amy" and res.object == "dana" and len(res.grounds) == 3
def test_greater_than_two_hop(vocab_persona, pack) -> None:
res = _scenario(
["Carol is greater than Bob.", "Bob is greater than Alice."],
"Is Carol greater than Alice?",
vocab_persona,
pack,
)
assert isinstance(res, Determined) and res.answer is True and res.rule == "transitive"
assert res.predicate == "greater_than" and res.subject == "carol" and res.object == "alice"
def test_before_event_chain(vocab_persona, pack) -> None:
res = _scenario(
["Dawn is before noon.", "Noon is before dusk."],
"Is dawn before dusk?",
vocab_persona,
pack,
)
assert isinstance(res, Determined) and res.answer is True and res.rule == "transitive"
assert res.predicate == "before_event" and res.subject == "dawn" and res.object == "dusk"
def test_after_event_chain(vocab_persona, pack) -> None:
res = _scenario(
["Dusk is after noon.", "Noon is after dawn."],
"Is dusk after dawn?",
vocab_persona,
pack,
)
assert isinstance(res, Determined) and res.answer is True and res.rule == "transitive"
assert res.predicate == "after_event" and res.subject == "dusk" and res.object == "dawn"
# --------------------------------------------------------------------------- #
# wrong=0 BITES — non-transitive predicates, mixed chains, gaps, cycles refuse
# --------------------------------------------------------------------------- #
def test_sibling_of_chain_refuses(vocab_persona, pack) -> None:
# sibling_of is SYMMETRIC, not transitive — a sib b, b sib c does NOT give a sib c.
res = _scenario(
["Alice is the sibling of Bob.", "Bob is the sibling of Carol."],
"Is Alice the sibling of Carol?",
vocab_persona,
pack,
)
assert isinstance(res, Undetermined)
def test_parent_of_chain_refuses(vocab_persona, pack) -> None:
# parent ∘ parent = grandparent ≠ parent — must refuse.
res = _scenario(
["Alice is the parent of Bob.", "Bob is the parent of Carol."],
"Is Alice the parent of Carol?",
vocab_persona,
pack,
)
assert isinstance(res, Undetermined)
def test_left_of_chain_refuses(vocab_persona, pack) -> None:
# spatial left_of is NOT admitted as transitive (needs a shared-frame proof).
res = _scenario(
["The box is left of the cup.", "The cup is left of the plate."],
"Is the box left of the plate?",
vocab_persona,
pack,
)
assert isinstance(res, Undetermined)
def test_right_of_chain_refuses(vocab_persona, pack) -> None:
res = _scenario(
["The plate is right of the cup.", "The cup is right of the box."],
"Is the plate right of the box?",
vocab_persona,
pack,
)
assert isinstance(res, Undetermined)
def test_mixed_predicate_chain_refuses(vocab_persona, pack) -> None:
# less_than + before_event must NOT compose: the less_than search sees only the
# alice→bob edge; carol is unreachable over less_than edges.
res = _scenario(
["Alice is less than Bob.", "Bob is before Carol."],
"Is Alice less than Carol?",
vocab_persona,
pack,
)
assert isinstance(res, Undetermined)
def test_missing_middle_refuses(vocab_persona, pack) -> None:
# a<b and c<d with b != c — no contiguous chain from alice to dana.
res = _scenario(
["Alice is less than Bob.", "Carol is less than Dana."],
"Is Alice less than Dana?",
vocab_persona,
pack,
)
assert isinstance(res, Undetermined)
def test_reflexive_query_refuses(vocab_persona, pack) -> None:
# strict orders are irreflexive — a chain never fabricates a < a.
res = _scenario(
["Alice is less than Bob.", "Bob is less than Carol."],
"Is Alice less than Alice?",
vocab_persona,
pack,
)
assert isinstance(res, Undetermined)
def test_cyclic_conflicting_no_fabrication(vocab_persona, pack) -> None:
# a<b and b<a (conflicting). A query for an absent target has no forward path —
# the cycle must not fabricate a reachability claim.
res = _scenario(
["Alice is less than Bob.", "Bob is less than Alice."],
"Is Alice less than Carol?",
vocab_persona,
pack,
)
assert isinstance(res, Undetermined)
def test_transitive_through_inverse_not_composed(vocab_persona, pack) -> None:
# B2 does NOT compose transitive-with-inverse: bob<alice, carol<bob entail carol<alice
# (a less_than chain), but the GREATER_THAN query has no greater_than edges to chain
# and one-hop inverse is a single hop — so it must refuse.
res = _scenario(
["Bob is less than Alice.", "Carol is less than Bob."],
"Is Alice greater than Carol?",
vocab_persona,
pack,
)
assert isinstance(res, Undetermined)
def test_unsupported_predicate_still_undetermined(vocab_persona, pack) -> None:
# a categorical/propositional predicate is not in the supported set at all.
res = _scenario([], "Is Alice less than Bob?", vocab_persona, pack)
assert isinstance(res, Undetermined) # ungrounded — absence never asserts
def test_edge_budget_exhaustion_refuses(vocab_persona, pack, monkeypatch) -> None:
# Above the edge budget the transitive search declines (a safe COVERAGE refusal —
# never an unsound answer): the SAME chain that determines under the normal budget
# refuses when the predicate's realized-edge count exceeds the cap. (Patch the module
# object, not the dotted path — `generate.determine.determine` resolves to the
# re-exported function, not the submodule.)
import importlib
det_mod = importlib.import_module("generate.determine.determine")
monkeypatch.setattr(det_mod, "_TRANSITIVE_EDGE_BUDGET", 1)
res = _scenario(
["Alice is less than Bob.", "Bob is less than Carol."],
"Is Alice less than Carol?",
vocab_persona,
pack,
)
assert isinstance(res, Undetermined) # 2 edges > budget(1) → coverage refusal, not a proof
# --------------------------------------------------------------------------- #
# Regression — one-hop inverse/symmetric and direct entailment are intact
# --------------------------------------------------------------------------- #
def test_one_hop_inverse_still_determines(vocab_persona, pack) -> None:
res = _scenario(
["Bob is less than Alice."], "Is Alice greater than Bob?", vocab_persona, pack
)
assert isinstance(res, Determined) and res.answer is True and res.rule == "inverse"
def test_one_hop_symmetric_still_determines(vocab_persona, pack) -> None:
res = _scenario(
["Alice is the sibling of Bob."],
"Is Bob the sibling of Alice?",
vocab_persona,
pack,
)
assert isinstance(res, Determined) and res.answer is True and res.rule == "symmetric"
def test_direct_strict_order_is_direct_not_transitive(vocab_persona, pack) -> None:
# a single told edge answers directly (rule="direct") — the transitive step is only
# reached when direct and one-hop both miss.
res = _scenario(
["Alice is less than Bob."], "Is Alice less than Bob?", vocab_persona, pack
)
assert isinstance(res, Determined) and res.answer is True and res.rule == "direct"