core/teaching/store.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

126 lines
4.4 KiB
Python

"""Teaching store — bounded persistence for reviewed teaching examples.
TeachingStore is an append-only, bounded collection of accepted
teaching examples. It emits PackMutationProposal objects rather than
mutating the vocabulary manifold directly — external review is required
before any pack change takes effect.
"""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass
from teaching.correction import CorrectionCandidate
from teaching.review import ReviewedTeachingExample
@dataclass(frozen=True, slots=True)
class PackMutationProposal:
"""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
candidate_id: str
subject: str
correction_text: str
prior_surface: str
applied: bool = False
triple: tuple[str, str, str] | None = None
def as_dict(self) -> dict[str, object]:
return {
"proposal_id": self.proposal_id,
"candidate_id": self.candidate_id,
"subject": self.subject,
"correction_text": self.correction_text,
"prior_surface": self.prior_surface,
"applied": self.applied,
"triple": list(self.triple) if self.triple is not None else None,
}
def _proposal_id(candidate: CorrectionCandidate) -> str:
payload = json.dumps(
{"candidate_id": candidate.candidate_id, "subject": candidate.intent.subject},
sort_keys=True,
ensure_ascii=False,
)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
class TeachingStore:
"""Bounded, append-only store for reviewed teaching examples.
Capacity is fixed at construction. When full, the oldest example is
evicted (FIFO). Only accepted examples are stored; rejected examples
are silently dropped.
"""
def __init__(self, capacity: int = 256) -> None:
self._capacity = capacity
self._examples: list[ReviewedTeachingExample] = []
self._proposals: list[PackMutationProposal] = []
@property
def capacity(self) -> int:
return self._capacity
def add(self, example: ReviewedTeachingExample) -> PackMutationProposal | None:
"""Store an accepted example and return a mutation proposal.
Rejected examples are dropped silently. Returns None if the
example was not accepted.
"""
if not example.accepted:
return None
if len(self._examples) >= self._capacity:
self._examples.pop(0)
self._examples.append(example)
from teaching.relation_parse import parse_triple
triple = parse_triple(example.candidate.correction_text)
proposal = PackMutationProposal(
proposal_id=_proposal_id(example.candidate),
candidate_id=example.candidate.candidate_id,
subject=example.candidate.intent.subject,
correction_text=example.candidate.correction_text,
prior_surface=example.candidate.prior_surface,
triple=triple,
)
self._proposals.append(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, ...]:
"""Retrieve all stored examples matching a subject (case-insensitive)."""
lower = subject.lower()
return tuple(
ex for ex in self._examples
if lower in ex.candidate.intent.subject.lower()
)
def pending_proposals(self) -> tuple[PackMutationProposal, ...]:
"""Return all proposals that have not been applied."""
return tuple(p for p in self._proposals if not p.applied)
def __len__(self) -> int:
return len(self._examples)