core/teaching/replay.py
Shay e03ab4b609 feat(adr-0057): Phase C2 — TeachingChainProposal + replay gate + review CLI
The only path by which CORE extends its own active teaching corpus.
Closes ADR-0055 Phase C alongside ADR-0056's cognitive surface.

Three load-bearing calls (recorded in ADR-0057):
  1. Replay-equivalence is a precondition, not a permission;
     operator --accept remains required.
  2. Eligibility = polarity in {affirms, falsifies} AND at least
     one source='corpus' evidence pointer AND boundary_clean AND
     claim_domain != evaluative (unless --allow-evaluative) AND
     proposed_chain complete.
  3. Append-only proposal log; corpus history append-only too.

Changes
- teaching/proposals.py — TeachingChainProposal, ReplayEvidence,
  ProposalLog (event-sourced replay → current_state), eligibility
  predicate, propose_from_candidate, accept/reject/withdraw,
  append_chain_to_corpus (the sole corpus-write surface).  Uses
  TYPE_CHECKING guards to break the circular import with
  chat.pack_grounding.
- teaching/replay.py — run_replay_equivalence; swaps _corpus_index
  path to a tmp file, runs cognition lane on the active corpus
  AND a transient copy with the proposed chain appended, returns
  regressed-metrics list; trust-boundary assertion that the active
  corpus bytes are byte-identical pre/post.
- teaching/discovery.py — moved chat.pack_grounding /
  chat.teaching_grounding imports inside extract_discovery_candidates
  to break the cycle (was masked when chat.runtime was the entry
  point; surfaced by CLI entry).
- core/cli.py — three new subcommands:
    core teaching propose <candidate-jsonl-path> [--allow-evaluative]
    core teaching proposals [--state pending|accepted|rejected|withdrawn] [--json]
    core teaching review <proposal_id> --accept --review-date YYYY-MM-DD
    core teaching review <proposal_id> --reject [--note ...]
    core teaching review <proposal_id> --withdraw [--note ...]
- tests/test_teaching_proposals.py — 16 tests covering: every
  eligibility gate, proposal_id idempotency, append-only log,
  replay-equivalent stays pending, regression auto-rejects with
  named regressed metrics, --accept appends one line with typed
  Provenance, --accept refused on non-equivalent, state-machine
  blocks double-accept, real replay gate runs cognition lane
  twice and asserts byte-clean active corpus pre/post.

Invariants preserved
- versor_condition(F) < 1e-6 — C2 touches no algebra path.
- Active corpus bytes byte-identical regardless of replay outcome.
- No clock-time reads, no LLM, no async.
- Proposal-only — accept_proposal is the sole corpus-write path.

Lanes: smoke 67 / cognition 121 / runtime 19 / teaching 17 /
new proposals 16.  Cognition eval unchanged.

Open follow-ups (not in scope):
- supersession via operator review action
- cross-pack falsification arbitration (ADR-0056 Call 2 deferred)
- pack-data migration of frame-dependent connectives

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 10:23:14 -07:00

153 lines
5.1 KiB
Python

"""ADR-0057 §Replay-equivalence gate.
Given a proposed chain, run the cognition lane against the active
corpus *and* against a transient copy of the active corpus with the
proposed chain appended. Compare metrics: any regression rejects
the proposal mechanically; equivalence makes the proposal eligible
for operator review.
Trust boundary
- The active corpus file bytes are NEVER touched by this gate,
regardless of outcome. The transient candidate corpus is written
to an isolated path; the runtime's ``_corpus_index`` cache is
swapped to load from that path for the candidate measurement,
then restored.
- No background tasks, no async, no clock-time reads. Synchronous
swap-measure-restore.
"""
from __future__ import annotations
import json
import shutil
import tempfile
from contextlib import contextmanager
from pathlib import Path
from typing import Any, Iterator
from chat import teaching_grounding as _tg
from teaching.proposals import ReplayEvidence
# Metrics watched for regression. Any metric whose candidate value
# is strictly less than the baseline value counts as a regression.
_WATCHED_METRICS: tuple[str, ...] = (
"intent_accuracy",
"surface_groundedness",
"term_capture_rate",
"versor_closure_rate",
)
@contextmanager
def _swap_corpus_path(temp_path: Path) -> Iterator[None]:
"""Temporarily point ``_corpus_index`` at *temp_path*.
Clears the lru_cache before and after the swap so the runtime
re-reads the corpus fresh in both directions. The active
corpus on disk is not touched.
"""
real_path = _tg._CORPUS_PATH
try:
_tg._CORPUS_PATH = temp_path # type: ignore[assignment]
_tg._corpus_index.cache_clear()
yield
finally:
_tg._CORPUS_PATH = real_path # type: ignore[assignment]
_tg._corpus_index.cache_clear()
def _run_cognition_public() -> dict[str, float]:
"""Run the public cognition split and return a metrics dict.
Kept inside a function so import time stays cheap for callers
that never trigger replay.
"""
from evals.framework import get_lane, run_lane
lane = get_lane("cognition")
result = run_lane(lane, version="v1", split="public")
out: dict[str, float] = {}
for k in _WATCHED_METRICS:
v = result.metrics.get(k)
if isinstance(v, (int, float)):
out[k] = float(v)
return out
def _build_candidate_corpus(
active_corpus_path: Path, candidate_chain: dict[str, Any], dest: Path
) -> None:
"""Copy active corpus to *dest* and append one candidate line."""
if active_corpus_path.exists():
shutil.copyfile(active_corpus_path, dest)
else:
dest.write_text("", encoding="utf-8")
subject = str(candidate_chain["subject"]).strip().lower()
intent = str(candidate_chain["intent"]).strip().lower()
connective = str(candidate_chain["connective"]).strip()
obj = str(candidate_chain["object"]).strip().lower()
chain_id = f"{intent}_{subject}_{connective}_{obj}_replay"
entry = {
"chain_id": chain_id,
"subject": subject,
"intent": intent,
"connective": connective,
"object": obj,
"domains_subject_k": 2,
"domains_object_k": 1,
"provenance": "adr-0057:discovery_promoted:replay",
}
line = json.dumps(entry, sort_keys=True, separators=(",", ":"))
with dest.open("a", encoding="utf-8") as fh:
fh.write(line + "\n")
def run_replay_equivalence(chain: dict[str, Any]) -> ReplayEvidence:
"""Run the gate. Active corpus bytes byte-identical pre/post.
Returns:
``ReplayEvidence(baseline=..., candidate=..., regressed_metrics=...,
replay_equivalent=...)``
"""
active_path = _tg._CORPUS_PATH
active_bytes_before = active_path.read_bytes() if active_path.exists() else b""
# Baseline: just run against the active corpus. Cache is cleared
# to make sure we read the current state of disk.
_tg._corpus_index.cache_clear()
baseline = _run_cognition_public()
# Candidate: build a transient corpus with the chain appended
# and point ``_corpus_index`` at it.
with tempfile.TemporaryDirectory() as tmpdir:
cand_path = Path(tmpdir) / "candidate_corpus.jsonl"
_build_candidate_corpus(active_path, chain, cand_path)
with _swap_corpus_path(cand_path):
candidate = _run_cognition_public()
regressed: list[str] = []
for metric in _WATCHED_METRICS:
b = baseline.get(metric)
c = candidate.get(metric)
if b is None or c is None:
continue
if c < b:
regressed.append(metric)
# Trust-boundary assertion: active file bytes unchanged.
active_bytes_after = active_path.read_bytes() if active_path.exists() else b""
if active_bytes_after != active_bytes_before: # pragma: no cover — defensive
raise RuntimeError(
"replay gate mutated the active corpus — trust boundary violated"
)
return ReplayEvidence(
baseline=baseline,
candidate=candidate,
regressed_metrics=tuple(sorted(regressed)),
replay_equivalent=not regressed,
)
__all__ = ["run_replay_equivalence"]