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.
This commit is contained in:
Shay 2026-05-16 15:04:43 -07:00
parent 2177492646
commit 57a61749b9
8 changed files with 624 additions and 2 deletions

View file

@ -21,6 +21,8 @@ from core.cognition.trace import compute_trace_hash
from generate.intent import classify_intent from generate.intent import classify_intent
from generate.graph_planner import graph_from_intent, plan_articulation from generate.graph_planner import graph_from_intent, plan_articulation
from generate.realizer import realize_semantic from generate.realizer import realize_semantic
from generate.intent import IntentTag
from generate.operators import WalkResult, transitive_walk
from teaching.correction import CorrectionCandidate, extract_correction from teaching.correction import CorrectionCandidate, extract_correction
from teaching.review import ReviewedTeachingExample, review_correction from teaching.review import ReviewedTeachingExample, review_correction
from teaching.store import PackMutationProposal, TeachingStore from teaching.store import PackMutationProposal, TeachingStore
@ -73,6 +75,16 @@ class CognitiveTurnPipeline:
surface = realized_plan.surface surface = realized_plan.surface
articulation_surface = realized_plan.surface articulation_surface = realized_plan.surface
# 7b. INFER — invoke typed deterministic operators (ADR-0018) when the
# intent is a transitive-query or definition shape and the teaching
# store carries a chain rooted at the subject. The operator's result
# is folded into the surface so chain endpoints become visible.
walk_result: WalkResult | None = self._maybe_transitive_walk(intent)
if walk_result is not None and len(walk_result.path) > 1:
surface, articulation_surface = self._fold_walk_into_surface(
walk_result, surface, articulation_surface,
)
# Track last node id for correction-intent chaining # Track last node id for correction-intent chaining
if graph.nodes: if graph.nodes:
self._last_node_id = graph.nodes[-1].node_id self._last_node_id = graph.nodes[-1].node_id
@ -101,9 +113,11 @@ class CognitiveTurnPipeline:
self._turn_number += 1 self._turn_number += 1
self._prior_surface = surface self._prior_surface = surface
# 11. TRACE — deterministic hash (includes teaching IDs when present) # 11. TRACE — deterministic hash (includes teaching IDs and any
# typed-operator invocation per ADR-0018).
review_hash = reviewed_example.review_hash if reviewed_example is not None else "" review_hash = reviewed_example.review_hash if reviewed_example is not None else ""
proposal_id = proposal.proposal_id if proposal is not None else "" proposal_id = proposal.proposal_id if proposal is not None else ""
operator_invocation = self._serialize_walk(walk_result)
trace_hash = compute_trace_hash( trace_hash = compute_trace_hash(
input_text=text, input_text=text,
filtered_tokens=filtered_tokens, filtered_tokens=filtered_tokens,
@ -116,6 +130,7 @@ class CognitiveTurnPipeline:
intent_tag=intent.tag.value, intent_tag=intent.tag.value,
teaching_review_hash=review_hash, teaching_review_hash=review_hash,
teaching_proposal_id=proposal_id, teaching_proposal_id=proposal_id,
operator_invocation=operator_invocation,
) )
return CognitiveTurnResult( return CognitiveTurnResult(
@ -138,6 +153,7 @@ class CognitiveTurnPipeline:
teaching_candidate=teaching_candidate, teaching_candidate=teaching_candidate,
reviewed_teaching_example=reviewed_example, reviewed_teaching_example=reviewed_example,
pack_mutation_proposal=proposal, pack_mutation_proposal=proposal,
operator_invocation=operator_invocation,
versor_condition=response.versor_condition, versor_condition=response.versor_condition,
trace_hash=trace_hash, trace_hash=trace_hash,
) )
@ -186,6 +202,63 @@ class CognitiveTurnPipeline:
proposal = self.teaching_store.add(reviewed) proposal = self.teaching_store.add(reviewed)
return candidate, reviewed, proposal return candidate, reviewed, proposal
def _maybe_transitive_walk(self, intent) -> WalkResult | None:
"""Invoke ``transitive_walk`` when the intent shape calls for it.
Returns ``None`` when no walk should run (intent doesn't match, no
triples in store, or walk produces a singleton path). Pure dispatch;
the operator itself is the deterministic function (ADR-0018).
"""
triples = self.teaching_store.triples()
if not triples:
return None
if intent.tag is IntentTag.TRANSITIVE_QUERY and intent.relation:
return transitive_walk(triples, intent.subject, intent.relation)
if intent.tag is IntentTag.DEFINITION:
# "What is X?" → walk the "is" relation if any chain exists.
result = transitive_walk(triples, intent.subject, "is")
if len(result.path) > 1:
return result
return None
@staticmethod
def _serialize_walk(walk: WalkResult | None) -> str:
"""Deterministic operator-invocation serialisation for trace_hash."""
if walk is None:
return ""
import json
return json.dumps(walk.as_dict(), sort_keys=True, ensure_ascii=False)
@staticmethod
def _fold_walk_into_surface(
walk: WalkResult,
surface: str,
articulation_surface: str,
) -> tuple[str, str]:
"""Compose a chain-aware surface from a non-trivial walk result.
Deterministic. Replay-safe: identical (walk, prior surfaces) produce
identical output. The chain endpoint is the load-bearing token for
the inference-closure / multi-step-reasoning eval lanes.
"""
chain = " ".join(walk.path)
endpoint = walk.path[-1]
chain_surface = (
f"{walk.head} {walk.relation.replace('_', ' ')} {endpoint} "
f"(via {chain})"
)
# Preserve the prior surface as a prefix for context, when it exists
# and is non-empty; otherwise the chain surface stands alone.
if surface:
new_surface = f"{surface}{chain_surface}"
else:
new_surface = chain_surface
if articulation_surface:
new_articulation = f"{articulation_surface}{chain_surface}"
else:
new_articulation = chain_surface
return new_surface, new_articulation
def _capture_field_state(self) -> FieldState | None: def _capture_field_state(self) -> FieldState | None:
"""Return current session field state, or None if not yet initialised.""" """Return current session field state, or None if not yet initialised."""
try: try:

View file

@ -63,6 +63,13 @@ class CognitiveTurnResult:
reviewed_teaching_example: ReviewedTeachingExample | None = None reviewed_teaching_example: ReviewedTeachingExample | None = None
pack_mutation_proposal: PackMutationProposal | None = None pack_mutation_proposal: PackMutationProposal | None = None
# --- inference operators (ADR-0018) ---
# Deterministic serialisation of any typed operator invoked during the
# turn (e.g. transitive_walk over the teaching-store typed-relation
# graph). Empty string when no operator ran. Folded into trace_hash
# so operator invocation is a load-bearing part of replay equality.
operator_invocation: str = ""
# --- invariant bookkeeping --- # --- invariant bookkeeping ---
versor_condition: float = 0.0 # must be < 1e-6 versor_condition: float = 0.0 # must be < 1e-6
trace_hash: str = "" # SHA-256 over deterministic key fields trace_hash: str = "" # SHA-256 over deterministic key fields

View file

@ -36,11 +36,18 @@ def compute_trace_hash(
intent_tag: str = "unknown", intent_tag: str = "unknown",
teaching_review_hash: str = "", teaching_review_hash: str = "",
teaching_proposal_id: str = "", teaching_proposal_id: str = "",
operator_invocation: str = "",
) -> str: ) -> str:
"""Return a deterministic SHA-256 hex digest over the turn's key outputs. """Return a deterministic SHA-256 hex digest over the turn's key outputs.
Parameters match the subset of CognitiveTurnResult that is both Parameters match the subset of CognitiveTurnResult that is both
semantically meaningful and stable across hardware. semantically meaningful and stable across hardware.
``operator_invocation`` is the deterministic serialisation of any typed
deterministic operator (ADR-0018) invoked during the turn empty
string when no operator ran. Folding it explicitly makes operator
invocation a load-bearing part of replay equality, not just an
indirect consequence of surface-change.
""" """
payload = { payload = {
"input_text": input_text, "input_text": input_text,
@ -54,6 +61,7 @@ def compute_trace_hash(
"intent_tag": intent_tag, "intent_tag": intent_tag,
"teaching_review_hash": teaching_review_hash, "teaching_review_hash": teaching_review_hash,
"teaching_proposal_id": teaching_proposal_id, "teaching_proposal_id": teaching_proposal_id,
"operator_invocation": operator_invocation,
} }
serialized = json.dumps(payload, sort_keys=True, ensure_ascii=False) serialized = json.dumps(payload, sort_keys=True, ensure_ascii=False)
return hashlib.sha256(serialized.encode("utf-8")).hexdigest() return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
@ -84,4 +92,5 @@ def trace_hash_from_result(result: "CognitiveTurnResult") -> str:
intent_tag=intent_tag, intent_tag=intent_tag,
teaching_review_hash=review_hash, teaching_review_hash=review_hash,
teaching_proposal_id=proposal_id, teaching_proposal_id=proposal_id,
operator_invocation=result.operator_invocation,
) )

View file

@ -21,6 +21,7 @@ class IntentTag(Enum):
CORRECTION = "correction" CORRECTION = "correction"
RECALL = "recall" RECALL = "recall"
VERIFICATION = "verification" VERIFICATION = "verification"
TRANSITIVE_QUERY = "transitive_query"
UNKNOWN = "unknown" UNKNOWN = "unknown"
@ -29,6 +30,7 @@ class DialogueIntent:
tag: IntentTag tag: IntentTag
subject: str subject: str
secondary_subject: str | None = None secondary_subject: str | None = None
relation: str | None = None # populated for TRANSITIVE_QUERY (ADR-0018)
def requires_prior_turn(self) -> bool: def requires_prior_turn(self) -> bool:
return self.tag is IntentTag.CORRECTION return self.tag is IntentTag.CORRECTION
@ -39,6 +41,37 @@ _COMPARE_RE = re.compile(
re.IGNORECASE, re.IGNORECASE,
) )
# Transitive-query forms (ADR-0018):
# "What does X precede/cause/ground/reveal/mean/follow?" -> (X, R)
# "Where does X belong?" -> (X, belongs_to)
# The trailing-?-and-optional-trailing-tokens form keeps the pattern total.
_TRANSITIVE_QUERY_RE = re.compile(
r"^what\s+does\s+(?P<subject>[a-z][a-z\-]*(?:\s+[a-z][a-z\-]*)?)\s+"
r"(?P<relation>precede|precedes|cause|causes|ground|grounds|reveal|reveals|"
r"mean|means|follow|follows|contrast(?:_with|s_with|s\s+with)?|"
r"produce|produces)\b",
re.IGNORECASE,
)
_BELONG_QUERY_RE = re.compile(
r"^where\s+does\s+(?P<subject>[a-z][a-z\-]*(?:\s+[a-z][a-z\-]*)?)\s+"
r"belong(?:s?)\b",
re.IGNORECASE,
)
# Normalisation of the relation surface form back to the bare relation
# vocabulary the teaching store carries (matches en_core_cognition_v1).
_RELATION_NORMALIZE: dict[str, str] = {
"precede": "precedes", "precedes": "precedes",
"cause": "causes", "causes": "causes",
"ground": "grounds", "grounds": "grounds",
"reveal": "reveals", "reveals": "reveals",
"mean": "means", "means": "means",
"follow": "follows", "follows": "follows",
"contrast": "contrasts_with", "contrast_with": "contrasts_with",
"contrasts_with": "contrasts_with", "contrasts with": "contrasts_with",
"produce": "produces", "produces": "produces",
}
_RULES: tuple[tuple[re.Pattern[str], IntentTag], ...] = ( _RULES: tuple[tuple[re.Pattern[str], IntentTag], ...] = (
(re.compile(r"^what\s+(?:is|are)\s+", re.IGNORECASE), IntentTag.DEFINITION), (re.compile(r"^what\s+(?:is|are)\s+", re.IGNORECASE), IntentTag.DEFINITION),
(re.compile(r"^why\s+", re.IGNORECASE), IntentTag.CAUSE), (re.compile(r"^why\s+", re.IGNORECASE), IntentTag.CAUSE),
@ -62,6 +95,24 @@ def classify_intent(prompt: str) -> DialogueIntent:
secondary_subject=compare_match.group(2).strip(), secondary_subject=compare_match.group(2).strip(),
) )
transitive_match = _TRANSITIVE_QUERY_RE.match(text)
if transitive_match:
raw_relation = transitive_match.group("relation").lower().strip()
relation = _RELATION_NORMALIZE.get(raw_relation, raw_relation)
return DialogueIntent(
tag=IntentTag.TRANSITIVE_QUERY,
subject=transitive_match.group("subject").strip(),
relation=relation,
)
belong_match = _BELONG_QUERY_RE.match(text)
if belong_match:
return DialogueIntent(
tag=IntentTag.TRANSITIVE_QUERY,
subject=belong_match.group("subject").strip(),
relation="belongs_to",
)
for pattern, tag in _RULES: for pattern, tag in _RULES:
match = pattern.match(text) match = pattern.match(text)
if match: if match:

152
generate/operators.py Normal file
View file

@ -0,0 +1,152 @@
"""Typed deterministic operators over CORE's typed state (ADR-0018).
Two operators land here as the Phase 3 v2 inference-depth bundle. Both
are pure functions; both are bounded by a ``max_hops`` cap so they
cannot diverge; both produce outputs that round-trip through the
existing pipeline (entities, vault entries).
Operator-invocation records are folded into ``trace_hash`` (see
``core/cognition/trace.py``) so any turn that calls an operator stays
bit-for-bit replay-deterministic.
"""
from __future__ import annotations
from dataclasses import dataclass
_DEFAULT_MAX_HOPS = 5
@dataclass(frozen=True, slots=True)
class WalkResult:
"""A typed relation-walk result.
``path`` is the sequence of entities visited, starting from the head
and ending at the deepest entity reachable under the requested
relation. Length 1 means no edges were found. Length > 1 means a
chain was traversed.
``relation`` and ``head`` are echoed back so the result is self-
describing for downstream wiring and trace_hash inclusion.
``truncated`` is True when the walk hit the max_hops bound before
exhausting the path; consumers should treat that as a soft signal
that a longer chain may exist in the underlying store.
"""
head: str
relation: str
path: tuple[str, ...]
truncated: bool
def as_dict(self) -> dict[str, object]:
return {
"head": self.head,
"relation": self.relation,
"path": list(self.path),
"truncated": self.truncated,
}
def _normalize(token: str) -> str:
return token.strip().lower()
def transitive_walk(
triples: tuple[tuple[str, str, str], ...],
head: str,
relation: str,
*,
max_hops: int = _DEFAULT_MAX_HOPS,
) -> WalkResult:
"""Deterministic traversal of typed (head, relation, tail) triples.
Starting from ``head``, follow only edges labelled ``relation`` for
up to ``max_hops`` steps. Returns a ``WalkResult`` whose ``path``
is the chain of visited entities.
The triple substrate is supplied directly (no global state); callers
pass ``teaching_store.triples()`` or any equivalent. Comparisons are
case-insensitive and whitespace-trimmed.
Cycle handling: if a node would be revisited, the walk stops at the
previous node. This keeps the operator total over arbitrary
teaching-store contents.
Determinism: pure function over its arguments; no hidden state.
"""
if max_hops < 1:
return WalkResult(head=head, relation=relation, path=(head,), truncated=False)
head_lc = _normalize(head)
relation_lc = _normalize(relation)
edges: dict[str, str] = {}
for h, r, t in triples:
if _normalize(r) != relation_lc:
continue
h_lc = _normalize(h)
t_lc = _normalize(t)
# First-write-wins keeps the operator deterministic when the same
# head appears more than once under the same relation.
edges.setdefault(h_lc, t_lc)
path: list[str] = [head_lc]
visited = {head_lc}
cursor = head_lc
truncated = False
for _ in range(max_hops):
nxt = edges.get(cursor)
if nxt is None:
break
if nxt in visited:
break
path.append(nxt)
visited.add(nxt)
cursor = nxt
else:
# Loop exhausted without break; a deeper hop may exist.
truncated = edges.get(cursor) is not None
return WalkResult(
head=head_lc,
relation=relation_lc,
path=tuple(path),
truncated=truncated,
)
def path_recall(
triples: tuple[tuple[str, str, str], ...],
entity: str,
relation_chain: tuple[str, ...],
*,
max_hops: int = _DEFAULT_MAX_HOPS,
) -> tuple[str, ...]:
"""Recall the sequence of entities along a named relation chain.
A single-element ``relation_chain`` (e.g. ``("is",)``) reduces to
``transitive_walk``. A multi-element chain walks one hop per element
so callers can pose questions like "X is Y; Y precedes Z" by passing
``("is", "precedes")``.
Returns the path of entities visited. Empty chain returns just the
starting entity. Determinism and case-insensitivity inherit from
``transitive_walk``.
"""
cursor = entity
path: list[str] = [_normalize(cursor)]
visited = {_normalize(cursor)}
hops_left = max_hops
for relation in relation_chain:
if hops_left <= 0:
break
result = transitive_walk(triples, cursor, relation, max_hops=1)
if len(result.path) < 2:
break
next_entity = result.path[1]
if next_entity in visited:
break
path.append(next_entity)
visited.add(next_entity)
cursor = next_entity
hops_left -= 1
return tuple(path)

128
teaching/relation_parse.py Normal file
View file

@ -0,0 +1,128 @@
"""Typed relation parser — extract (head, relation, tail) triples from corrections.
A correction utterance like "Actually wisdom is judgment." carries a typed
proposition that until now was kept only as opaque text in the teaching
store. This module lifts the proposition into a typed triple so the
inference operators in ``generate/operators.py`` can walk the typed
relation graph that the teaching store represents.
Determinism: pure regex-driven extraction; no learned classifier; no
external IO. The relation vocabulary is drawn from the cognition pack's
relation predicates (see ``language_packs/data/en_core_cognition_v1``).
"""
from __future__ import annotations
import re
from typing import Final
# Relation predicates drawn from en_core_cognition_v1 (entries with
# semantic_domains containing "relation.*" or "predicate.*"). Order matters:
# multi-token forms must be tried before single-token forms so "belongs_to"
# is not split into "belongs" + "to".
_RELATIONS: Final[tuple[str, ...]] = (
"belongs_to",
"contrasts_with",
"is_caused_by",
"is_defined_as",
"is_verified_as",
"has_steps",
"corrects",
"recalls",
"grounds",
"reveals",
"precedes",
"follows",
"produces",
"causes",
"means",
"is",
"has",
)
# Sentence-leading discourse markers that may prefix the proposition.
_LEADING_MARKERS: Final[tuple[str, ...]] = (
"actually",
"no,",
"no",
"indeed",
"really",
"in fact",
"rather",
"instead",
)
_WHITESPACE = re.compile(r"\s+")
_PUNCT_TAIL = re.compile(r"[\.\?!,;:]+$")
def _strip_leading_marker(text: str) -> str:
lower = text.lower()
for marker in _LEADING_MARKERS:
prefix = marker + " "
if lower.startswith(prefix):
return text[len(prefix):]
if lower.startswith(marker + ",") or lower.startswith(marker + ";"):
return text[len(marker) + 1:].lstrip()
return text
def _normalize(text: str) -> str:
text = _strip_leading_marker(text.strip())
text = _WHITESPACE.sub(" ", text)
text = _PUNCT_TAIL.sub("", text)
return text.lower().strip()
def _split_head_relation_tail(text: str) -> tuple[str, str, str] | None:
"""Find the first matching relation predicate; split around it."""
# Word-boundary form for each relation so "is" does not match inside
# "wisdom" or similar. Multi-token relations are matched literally with
# surrounding spaces.
for relation in _RELATIONS:
if "_" in relation or " " in relation:
# Compound predicates use underscore in the lexicon but appear
# with underscores in correction text (per test corpus).
pattern = rf"\b{re.escape(relation)}\b"
else:
pattern = rf"\b{re.escape(relation)}\b"
match = re.search(pattern, text)
if match is None:
continue
head = text[: match.start()].strip()
tail = text[match.end():].strip()
if not head or not tail:
continue
# Drop trailing/leading articles ("a", "an", "the") from head/tail.
head = _strip_articles(head)
tail = _strip_articles(tail)
if not head or not tail:
continue
return head, relation, tail
return None
_ARTICLES: Final[frozenset[str]] = frozenset({"a", "an", "the"})
def _strip_articles(phrase: str) -> str:
tokens = phrase.split()
if tokens and tokens[0] in _ARTICLES:
tokens = tokens[1:]
if tokens and tokens[-1] in _ARTICLES:
tokens = tokens[:-1]
return " ".join(tokens)
def parse_triple(correction_text: str) -> tuple[str, str, str] | None:
"""Return (head, relation, tail) if the text parses cleanly, else None.
Pure function; deterministic. Returns None when no relation predicate
is found or when either side of the predicate is empty. Callers may
treat None as "this correction has no typed-graph content" and fall
back to the existing opaque-text storage path.
"""
if not correction_text:
return None
normalized = _normalize(correction_text)
return _split_head_relation_tail(normalized)

View file

@ -18,13 +18,21 @@ from teaching.review import ReviewedTeachingExample
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class PackMutationProposal: class PackMutationProposal:
"""A proposed vocabulary manifold change, not yet applied.""" """A proposed vocabulary manifold change, not yet applied.
When the correction text parses into a typed (head, relation, tail)
triple via ``teaching.relation_parse.parse_triple``, the triple is
stored alongside the opaque text so the inference operators in
``generate.operators`` can walk the typed-relation graph that the
teaching store represents (ADR-0018).
"""
proposal_id: str proposal_id: str
candidate_id: str candidate_id: str
subject: str subject: str
correction_text: str correction_text: str
prior_surface: str prior_surface: str
applied: bool = False applied: bool = False
triple: tuple[str, str, str] | None = None
def as_dict(self) -> dict[str, object]: def as_dict(self) -> dict[str, object]:
return { return {
@ -34,6 +42,7 @@ class PackMutationProposal:
"correction_text": self.correction_text, "correction_text": self.correction_text,
"prior_surface": self.prior_surface, "prior_surface": self.prior_surface,
"applied": self.applied, "applied": self.applied,
"triple": list(self.triple) if self.triple is not None else None,
} }
@ -77,16 +86,30 @@ class TeachingStore:
self._examples.append(example) self._examples.append(example)
from teaching.relation_parse import parse_triple
triple = parse_triple(example.candidate.correction_text)
proposal = PackMutationProposal( proposal = PackMutationProposal(
proposal_id=_proposal_id(example.candidate), proposal_id=_proposal_id(example.candidate),
candidate_id=example.candidate.candidate_id, candidate_id=example.candidate.candidate_id,
subject=example.candidate.intent.subject, subject=example.candidate.intent.subject,
correction_text=example.candidate.correction_text, correction_text=example.candidate.correction_text,
prior_surface=example.candidate.prior_surface, prior_surface=example.candidate.prior_surface,
triple=triple,
) )
self._proposals.append(proposal) self._proposals.append(proposal)
return proposal return proposal
def triples(self) -> tuple[tuple[str, str, str], ...]:
"""Return all typed (head, relation, tail) triples currently stored.
Filters out proposals that did not parse cleanly. Order is
append-order, which is the order corrections were reviewed in.
This is the substrate that ``generate.operators.transitive_walk``
walks (ADR-0018).
"""
return tuple(p.triple for p in self._proposals if p.triple is not None)
def retrieve(self, subject: str) -> tuple[ReviewedTeachingExample, ...]: def retrieve(self, subject: str) -> tuple[ReviewedTeachingExample, ...]:
"""Retrieve all stored examples matching a subject (case-insensitive).""" """Retrieve all stored examples matching a subject (case-insensitive)."""
lower = subject.lower() lower = subject.lower()

View file

@ -0,0 +1,179 @@
"""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]