ADR-0064 is the corpus-layer sibling of ADR-0063. The teaching-grounded
surface composer was hardcoded to cognition_chains_v1, so kinship CAUSE/
VERIFICATION prompts fell through to the universal disclosure even though
en_core_relations_v1 was mounted on the live runtime (ADR-0063).
Architectural change in chat/teaching_grounding.py:
- New TeachingCorpusSpec dataclass (corpus_id, path, pack_id).
- TEACHING_CORPORA tuple registers every active corpus. Each
corpus is 1:1-bound to one lexicon pack — cross-domain triples
deferred per docs/teaching_order.md §5.
- _load_corpus(spec) loads one corpus with pack-residency scoped
to its declared pack.
- _all_chains_index() aggregates across all registered corpora
(first-match-wins; cognition first preserves byte-identity).
- _pack_for_corpus(corpus_id) → bound pack lexicon.
- clear_teaching_caches() atomic cache invalidation.
- TeachingChain gains corpus_id field → surface tag follows resolving corpus.
Wiring updates:
- teaching_grounded_surface + teaching_grounded_surface_composed
consult _all_chains_index; surface tag follows chain.corpus_id.
- teaching/discovery.py gate uses chat.pack_resolver.is_resolvable
(any mounted pack) + _all_chains_index (any registered corpus).
- teaching/replay.py _swap_corpus_path rewrites the registry path
+ clears all teaching caches during the gate's transient phase.
Active corpus bytes unchanged (replay invariant preserved).
- evals/learning_loop/run_demo.py scene-5 swap mirrors the new
pattern so the demo still grounds against transient corpora.
Back-compat preserved: _corpus_index, _CORPUS_PATH, TEACHING_CORPUS_ID
remain cognition-corpus-specific for audit/replay consumers.
Phase 1.4 — relations_chains_v1 seeded with 7 reviewed kinship chains:
cause_parent_precedes_child
cause_child_follows_parent
cause_ancestor_precedes_descendant
cause_descendant_follows_ancestor
cause_family_grounds_parent
verification_child_requires_parent
verification_descendant_requires_ancestor
5 of 8 relations lemmas covered. All connectives already humanised.
Strict pack-internal to en_core_relations_v1 (no cross-domain in v1).
Seed pattern matches cognition_chains_v1's original pre-ADR-0055 seed.
Live verification:
> Why does parent exist?
parent — teaching-grounded (relations_chains_v1):
kinship.ascendant.direct; kinship.parent.
parent precedes child (kinship.descendant.direct).
grounding_source = teaching
Cognition eval byte-identical to pre-ADR baseline:
public: intent 100% / surface 100% / term 91.7% / closure 100%
holdout: intent 100% / surface 100% / term 83.3% / closure 100%
Lanes green: smoke 67 / cognition 121 / teaching 17 / packs 6 /
runtime 19 / algebra 132 / full 1933 passed.
169 lines
5.8 KiB
Python
169 lines
5.8 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
|
|
# ADR-0064 — the cognition corpus is one of several registered
|
|
# teaching corpora. When we swap it for replay, we must also
|
|
# rewrite the registry entry's path AND invalidate the aggregated
|
|
# index so surface composers re-read the swapped corpus.
|
|
original_specs = _tg.TEACHING_CORPORA
|
|
swapped_specs = tuple(
|
|
_tg.TeachingCorpusSpec(
|
|
corpus_id=s.corpus_id,
|
|
path=temp_path if s.corpus_id == _tg.TEACHING_CORPUS_ID else s.path,
|
|
pack_id=s.pack_id,
|
|
)
|
|
for s in original_specs
|
|
)
|
|
try:
|
|
_tg._CORPUS_PATH = temp_path # type: ignore[assignment]
|
|
_tg.TEACHING_CORPORA = swapped_specs # type: ignore[misc]
|
|
_tg.clear_teaching_caches()
|
|
yield
|
|
finally:
|
|
_tg._CORPUS_PATH = real_path # type: ignore[assignment]
|
|
_tg.TEACHING_CORPORA = original_specs # type: ignore[misc]
|
|
_tg.clear_teaching_caches()
|
|
|
|
|
|
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. Caches are
|
|
# cleared to make sure we read the current state of disk for
|
|
# every registered teaching corpus (ADR-0064).
|
|
_tg.clear_teaching_caches()
|
|
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"]
|