core/generate/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

152 lines
4.7 KiB
Python

"""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)