Closes the mixed_relation_* (multi-step-reasoning) and composed_predicate
(compositionality) residuals with a single new operator plus a small
intent-classifier loosening. Both residuals shared an underlying shape:
walk any outgoing relation edge from the head, regardless of which
relation predicate appears at each step.
generate/operators.py:
multi_relation_walk(triples, head, *, max_hops=5) -> WalkResult
Walks any outgoing edge from head, accumulating a path across
mixed relation types. Returns WalkResult with relation="<mixed>"
so trace_hash records the cross-relation provenance explicitly.
Deterministic, cycle-safe, first-write-wins on duplicate heads
(across any relation).
generate/intent.py:
_TRANSITIVE_QUERY_RE relaxed from a closed verb enumeration to any
single verb-like word. "What does X (any verb)?" now routes to
TRANSITIVE_QUERY consistently; unrecognised relations are handled
by the pipeline's multi_relation_walk fallback rather than falling
through to UNKNOWN. Verified no regression on 30 intent / realizer
tests.
core/cognition/pipeline.py:
_maybe_transitive_walk now does precision-first dispatch on
TRANSITIVE_QUERY: try transitive_walk(relation) literal-match
first, fall back to multi_relation_walk only when the literal
walk returns a singleton. DEFINITION intents do not fall back
(would be too permissive for "What is X?").
tests/test_inference_operators.py: 6 new TestMultiRelationWalk
tests covering single-relation pass-through, cross-relation walks,
cycle termination, max_hops truncation, and determinism.
Phase 3 v1 re-score:
lane split v1 v2 v3 (now)
inference-closure public 0.0 1.0 1.0 pass
inference-closure holdouts 0.0 1.0 1.0 pass
multi-step-reasoning public 0.0 0.73 1.0 pass
multi-step-reasoning holdouts 0.0 0.80 1.0 pass
compositionality public 0.06 0.31 0.69 pass
compositionality holdouts 0.0 0.30 0.80 pass
cross-domain-transfer public 0.0 1.0 1.0 pass
cross-domain-transfer holdouts 0.0 1.0 1.0 pass
introspection public 0.0 1.0 1.0 pass
introspection holdouts 0.0 1.0 1.0 pass
PHASE 3 v1 IS COMPLETE: 10 of 10 splits passing. Phase 3 exit gate
(>= 2 lanes passing v1 by phase exit) is satisfied five times over.
Foundation guarantees (premises_stored_rate, replay_determinism)
remain 1.0 across all lanes. Trace_hash bit-stability preserved
with operator invocation records folded in per ADR-0018.
Compositionality public at 0.69 / holdouts at 0.80 - the residual
failures are the novel_pair_under_seen_relation / novel_relation_on_seen_pair
cases whose contract authoring is itself ambiguous (the leakage
check in the v1 contract fires by design on those patterns). Those
are contract-refinement candidates for v2 of that lane, not
engineering work. Overall_pass threshold (>= 0.50) is comfortably
met on both splits.
CLI suites smoke / cognition / teaching / packs all pass; 53
operator+teaching+pipeline tests green; no regression.
229 lines
7.6 KiB
Python
229 lines
7.6 KiB
Python
"""Unit tests for the typed deterministic inference operators (ADR-0018)."""
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from generate.operators import (
|
|
WalkResult,
|
|
multi_relation_walk,
|
|
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"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# multi_relation_walk
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMultiRelationWalk:
|
|
def test_single_relation_chain_still_walks(self):
|
|
triples = (("a", "is", "b"), ("b", "is", "c"))
|
|
r = multi_relation_walk(triples, "a")
|
|
assert r.path == ("a", "b", "c")
|
|
assert r.relation == "<mixed>"
|
|
|
|
def test_walks_across_relation_types(self):
|
|
triples = (
|
|
("light", "grounds", "clarity"),
|
|
("clarity", "causes", "recognition"),
|
|
("recognition", "precedes", "naming"),
|
|
)
|
|
r = multi_relation_walk(triples, "light")
|
|
assert r.path == ("light", "clarity", "recognition", "naming")
|
|
assert not r.truncated
|
|
|
|
def test_unrelated_head_returns_singleton(self):
|
|
triples = (("a", "is", "b"),)
|
|
assert multi_relation_walk(triples, "x").path == ("x",)
|
|
|
|
def test_cycle_terminates(self):
|
|
triples = (("a", "is", "b"), ("b", "precedes", "a"))
|
|
r = multi_relation_walk(triples, "a")
|
|
assert r.path == ("a", "b")
|
|
|
|
def test_max_hops_truncates(self):
|
|
triples = (
|
|
("a", "is", "b"),
|
|
("b", "causes", "c"),
|
|
("c", "precedes", "d"),
|
|
)
|
|
r = multi_relation_walk(triples, "a", max_hops=2)
|
|
assert r.path == ("a", "b", "c")
|
|
assert r.truncated
|
|
|
|
def test_deterministic(self):
|
|
triples = (("a", "is", "b"), ("b", "grounds", "c"))
|
|
assert multi_relation_walk(triples, "a") == multi_relation_walk(triples, "a")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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]
|