core/tests/test_inference_operators.py
Shay 57a61749b9 feat(phase3): transitive_walk + path_recall operator bundle (ADR-0018)
Implements the Phase 3 v2 inference-depth bundle per ADR-0018:
typed deterministic operators over CORE's typed state. Closes the
inference-closure / multi-step-reasoning / cross-domain-transfer
v1 gaps; partial close on compositionality.

New modules:
  teaching/relation_parse.py - parse_triple(correction_text) lifts
    a correction utterance into a typed (head, relation, tail) over
    the en_core_cognition_v1 relation vocabulary. Pure regex,
    deterministic, no learned classifier.
  generate/operators.py - transitive_walk(triples, head, relation,
    *, max_hops=5) walks single-relation chains. path_recall walks
    a relation-chain tuple (e.g. ("is", "precedes")). Both bounded,
    cycle-safe, case-insensitive, first-write-wins on duplicates.

Schema extensions:
  teaching.store.PackMutationProposal gains optional triple field,
    populated by TeachingStore.add via parse_triple. Plus new
    TeachingStore.triples() helper returning all parsed triples.
  generate.intent.IntentTag gains TRANSITIVE_QUERY plus a relation
    field on DialogueIntent. New regex rules for "What does X R?"
    and "Where does X belong?" forms with relation normalisation.
  core.cognition.result.CognitiveTurnResult gains operator_invocation
    field (deterministic serialisation of any operator that ran).
  core.cognition.trace.compute_trace_hash gains operator_invocation
    kwarg; trace_hash_from_result threads it through. Operator
    invocation is now load-bearing for replay equality.

Pipeline wiring:
  CognitiveTurnPipeline.run dispatches transitive_walk after
  runtime.chat() when the intent is TRANSITIVE_QUERY (with the
  parsed relation) or DEFINITION (implicit "is"). Non-trivial walks
  fold the chain endpoint into surface and articulation_surface.

Verification:
  tests/test_inference_operators.py - 27 unit tests covering
  parser, transitive_walk (cycles, max_hops, case-insensitivity,
  determinism, first-write-wins), path_recall, and WalkResult shape.

Re-score on Phase 3 v1 case sets:

  lane                       split        v1     after bundle
  inference-closure          public/v1    0.0    1.0  pass
  inference-closure          holdouts/v1  0.0    1.0  pass
  multi-step-reasoning       public/v1    0.0    0.7333  pass
  multi-step-reasoning       holdouts/v1  0.0    0.8  pass
  cross-domain-transfer      public/v1    0.0    1.0  pass
  cross-domain-transfer      holdouts/v1  0.0    1.0  pass
  compositionality           public/v1    0.0625 0.3125  partial
  compositionality           holdouts/v1  0.0    0.3  partial

Six of eight splits now pass v1. Foundation guarantees
(premises_stored, replay_determinism) remain 1.0 across all lanes.
Trace_hash determinism preserved (operator records fold in
deterministically).

Residuals (filed as Phase 3 v2 follow-up):
  - multi-step-reasoning mixed_relation_3/4 patterns need path_recall
    wired into the pipeline for multi-relation probes; the operator
    exists but the pipeline only invokes transitive_walk today.
  - compositionality novel-combination patterns need a genuinely
    new operator shape (composed_relation_walk) - the literal
    transitive walk does not synthesise novel pairs by construction.

CLI suites smoke / cognition / teaching pass; no regression. 47
pipeline + teaching + operator tests all green.
2026-05-16 15:04:43 -07:00

179 lines
6 KiB
Python

"""Unit tests for the typed deterministic inference operators (ADR-0018)."""
from __future__ import annotations
import pytest
from generate.operators import WalkResult, path_recall, transitive_walk
from teaching.relation_parse import parse_triple
# ---------------------------------------------------------------------------
# relation_parse
# ---------------------------------------------------------------------------
class TestRelationParse:
def test_basic_is_triple(self):
assert parse_triple("Actually wisdom is judgment.") == (
"wisdom", "is", "judgment",
)
def test_precedes_triple(self):
assert parse_triple("No, creation precedes order.") == (
"creation", "precedes", "order",
)
def test_grounds_triple(self):
assert parse_triple("Actually truth grounds knowledge.") == (
"truth", "grounds", "knowledge",
)
def test_belongs_to_triple(self):
assert parse_triple("Actually question belongs_to inquiry.") == (
"question", "belongs_to", "inquiry",
)
def test_causes_triple(self):
assert parse_triple("Actually light causes clarity.") == (
"light", "causes", "clarity",
)
def test_articles_stripped(self):
assert parse_triple("Actually the wisdom is the judgment.") == (
"wisdom", "is", "judgment",
)
def test_no_relation_returns_none(self):
assert parse_triple("Actually that's an interesting point.") is None
def test_empty_returns_none(self):
assert parse_triple("") is None
def test_compound_relation_not_split(self):
# "belongs_to" must not be parsed as "belongs" leaving "_to" behind
result = parse_triple("Actually X belongs_to Y.")
assert result == ("x", "belongs_to", "y")
# ---------------------------------------------------------------------------
# transitive_walk
# ---------------------------------------------------------------------------
class TestTransitiveWalk:
def test_single_hop(self):
triples = (("a", "is", "b"),)
r = transitive_walk(triples, "a", "is")
assert r.path == ("a", "b")
assert not r.truncated
def test_two_hop_chain(self):
triples = (("a", "is", "b"), ("b", "is", "c"))
r = transitive_walk(triples, "a", "is")
assert r.path == ("a", "b", "c")
assert not r.truncated
def test_three_hop_chain(self):
triples = (
("a", "is", "b"),
("b", "is", "c"),
("c", "is", "d"),
)
r = transitive_walk(triples, "a", "is")
assert r.path == ("a", "b", "c", "d")
def test_relation_filter_excludes_other_relations(self):
triples = (
("a", "is", "b"),
("b", "precedes", "c"), # different relation, must be skipped
)
r = transitive_walk(triples, "a", "is")
assert r.path == ("a", "b")
def test_unrelated_head_returns_singleton(self):
triples = (("a", "is", "b"),)
r = transitive_walk(triples, "x", "is")
assert r.path == ("x",)
assert not r.truncated
def test_empty_triples_returns_singleton(self):
r = transitive_walk((), "a", "is")
assert r.path == ("a",)
def test_cycle_terminates(self):
triples = (("a", "is", "b"), ("b", "is", "a"))
r = transitive_walk(triples, "a", "is")
assert r.path == ("a", "b")
assert not r.truncated
def test_max_hops_truncates(self):
triples = (
("a", "is", "b"),
("b", "is", "c"),
("c", "is", "d"),
)
r = transitive_walk(triples, "a", "is", max_hops=2)
assert r.path == ("a", "b", "c")
assert r.truncated
def test_case_insensitive(self):
triples = (("A", "Is", "B"),)
r = transitive_walk(triples, "a", "is")
assert r.path == ("a", "b")
def test_deterministic_under_repeated_calls(self):
triples = (("a", "is", "b"), ("b", "is", "c"))
r1 = transitive_walk(triples, "a", "is")
r2 = transitive_walk(triples, "a", "is")
assert r1 == r2
def test_first_write_wins_on_duplicate_head(self):
triples = (("a", "is", "b"), ("a", "is", "z"))
r = transitive_walk(triples, "a", "is")
# First triple wins; "z" is ignored under "is" from "a"
assert r.path[1] == "b"
# ---------------------------------------------------------------------------
# path_recall
# ---------------------------------------------------------------------------
class TestPathRecall:
def test_single_relation_chain(self):
triples = (("a", "is", "b"), ("b", "is", "c"))
assert path_recall(triples, "a", ("is",)) == ("a", "b")
def test_two_relation_mixed_chain(self):
triples = (
("a", "is", "b"),
("b", "precedes", "c"),
)
assert path_recall(triples, "a", ("is", "precedes")) == ("a", "b", "c")
def test_empty_chain_returns_singleton(self):
assert path_recall((), "a", ()) == ("a",)
def test_broken_chain_stops_early(self):
triples = (("a", "is", "b"),) # second relation absent
assert path_recall(triples, "a", ("is", "precedes")) == ("a", "b")
def test_chain_respects_cycle(self):
triples = (
("a", "is", "b"),
("b", "is", "a"),
)
assert path_recall(triples, "a", ("is", "is")) == ("a", "b")
# ---------------------------------------------------------------------------
# WalkResult shape
# ---------------------------------------------------------------------------
class TestWalkResultShape:
def test_as_dict_round_trip(self):
r = WalkResult(head="a", relation="is", path=("a", "b"), truncated=False)
d = r.as_dict()
assert d == {"head": "a", "relation": "is", "path": ["a", "b"], "truncated": False}
def test_frozen(self):
r = WalkResult(head="a", relation="is", path=("a",), truncated=False)
with pytest.raises(AttributeError):
r.head = "b" # type: ignore[misc]