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.
179 lines
6 KiB
Python
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]
|