Phase C is the first phase where operator-authored exemplar corpora
become engine-derived recognizer proposals automatically. The math
thesis ("decodes, not generates") manifests in the math lane here.
Modules
- teaching/exemplar_ingest.py — pure-function loader for Phase B
exemplar JSONLs. ExemplarCorpus carries a sha256 digest over its
canonical (sorted-by-exemplar_id, sort-keyed) bytes.
- teaching/recognizer_synthesis.py — per-category synthesizers
(_synthesize_descriptive_setup_no_quantity / _temporal_aggregation /
_rate_with_currency) distil a corpus into one RecognizerSpec.
Determinism: same corpus -> byte-identical spec. Narrowness: the
spec records only observed sub-shapes; an out-of-corpus currency
symbol or window unit does not match. Phase B author_notes surface
in canonical_pattern.unresolved_notes — never silently dropped.
- teaching/contemplation.py — contemplate_exemplar_corpus(corpus)
returns a DiscoveryCandidate whose proposed_chain encodes the
RecognizerSpec as a synthetic four-field chain plus the full
recognizer_spec submap. Evidence cites every exemplar's case_id.
- teaching/replay.py — run_admissibility_replay_gate(spec, *,
active_corpus_path=None) runs cognition + G1..G5+S1 + GSM8K
train_sample. In-process baseline cache keyed on the active
corpus digest. WRONG-COUNT INVARIANT: if a candidate run lifts
the GSM8K train_sample wrong count, gate returns
replay_equivalent=False with
regressed_metrics=["gsm8k_train_sample_wrong_count"].
- teaching/source.py — ProposalKind widened with "exemplar_corpus";
exhaustive-match docs + tests updated.
CLI
- core teaching propose-from-exemplars <path> [--all] [--review-date]
[--log] [--json]. Routes the candidate through the existing
propose_from_candidate path with the admissibility gate substituted
for the cognition-only run_replay_equivalence. Never auto-accepts;
proposals land as pending for operator review.
Tests (38 new)
- tests/test_exemplar_ingest.py (12) — load, digest stability,
malformed-record rejection, file-name binding, read-only purity.
- tests/test_recognizer_synthesis.py (16) — determinism, purity,
per-category subsumption, narrowness (out-of-corpus seeds rejected),
author_notes surfaced.
- tests/test_admissibility_replay_gate.py (6) — happy path, cache
hit/invalidation, WRONG-COUNT INVARIANT regression, capability-axis
regression, cognition regression.
- tests/test_propose_from_exemplars_cli.py (4) — single corpus, --all,
determinism, read-only snapshot.
Acceptance evidence (dry run)
- All three Phase B corpora produce replay_equivalent=true,
wrong_count_delta=0. Proposal IDs:
descriptive_setup_no_quantity: 59223f13722f906a1cf9b65d9b01c990
rate_with_currency: 46ce297f797ff16da12db5de422ca3c9
temporal_aggregation: a3b892546977c5f0f64c578d6052adbd
- G1..G5+S1 wrong=0 unchanged; GSM8K train_sample 3/47/0 unchanged.
- core test --suite smoke -q: 67 passed.
- uv run core eval refusal_taxonomy: case_digest
d030f826cb0f4088771d90c52c8be2ff75054ab27c7d47eae8dbfe1225b2eea1
unchanged.
Cross-refs: ADR-0163 (Phase C), ADR-0057 (gating discipline),
ADR-0151 (auto-proposal), ADR-0152 (learning-arc), ADR-0149/0154
(recognizer pipeline), ADR-0094 (ProposalSource), Phase A PR #297,
Phase B PR #298.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
260 lines
8.4 KiB
Python
260 lines
8.4 KiB
Python
"""ADR-0163 Phase C — admissibility replay-gate tests.
|
|
|
|
Pins:
|
|
- the helper runs the cognition + capability-axis + GSM8K train_sample lanes
|
|
- baseline cache hit: a second call against the same corpus digest does NOT
|
|
re-run the baselines
|
|
- cache invalidation: changing the corpus digest re-runs baselines
|
|
- WRONG-COUNT INVARIANT: a candidate run that lifts the GSM8K train_sample
|
|
wrong count is rejected with the typed regressed_metrics entry
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from teaching.exemplar_ingest import load_exemplar_corpus
|
|
from teaching.recognizer_synthesis import synthesize_recognizer
|
|
import teaching.replay as replay_mod
|
|
from teaching.replay import (
|
|
AdmissibilityReplayEvidence,
|
|
run_admissibility_replay_gate,
|
|
)
|
|
|
|
|
|
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
_EXEMPLAR = (
|
|
_REPO_ROOT / "teaching" / "admissibility_exemplars" / "rate_with_currency_v1.jsonl"
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clean_baseline_cache() -> Any:
|
|
"""Each test starts with a clean baseline cache."""
|
|
replay_mod._BASELINE_CACHE.clear()
|
|
yield
|
|
replay_mod._BASELINE_CACHE.clear()
|
|
|
|
|
|
def _stub_capability_axes() -> dict[str, dict[str, int]]:
|
|
return {
|
|
"G1_verb_classes": {"correct": 20, "wrong": 0, "refused": 0},
|
|
"G2_comparatives": {"correct": 29, "wrong": 0, "refused": 0},
|
|
"G3_numerics": {"correct": 20, "wrong": 0, "refused": 6},
|
|
"G4_multi_clause": {"correct": 32, "wrong": 0, "refused": 0},
|
|
"G5_aggregate": {"correct": 20, "wrong": 0, "refused": 0},
|
|
"S1_rate_events": {"correct": 20, "wrong": 0, "refused": 0},
|
|
}
|
|
|
|
|
|
def _stub_gsm8k() -> dict[str, int]:
|
|
return {"correct": 3, "wrong": 0, "refused": 47}
|
|
|
|
|
|
def _stub_cognition() -> dict[str, float]:
|
|
return {
|
|
"intent_accuracy": 1.0,
|
|
"surface_groundedness": 1.0,
|
|
"term_capture_rate": 1.0,
|
|
"versor_closure_rate": 1.0,
|
|
}
|
|
|
|
|
|
def _spec() -> Any:
|
|
return synthesize_recognizer(load_exemplar_corpus(_EXEMPLAR))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Happy path: every lane wrong=0 → replay_equivalent=True
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_gate_passes_when_no_lane_regresses() -> None:
|
|
ev = run_admissibility_replay_gate(
|
|
_spec(),
|
|
_capability_axes_runner=_stub_capability_axes,
|
|
_gsm8k_runner=_stub_gsm8k,
|
|
_cognition_runner=_stub_cognition,
|
|
)
|
|
assert isinstance(ev, AdmissibilityReplayEvidence)
|
|
assert ev.replay_equivalent is True
|
|
assert ev.regressed_metrics == ()
|
|
assert ev.wrong_count_delta == 0
|
|
assert ev.capability_axes["G1_verb_classes"]["wrong"] == 0
|
|
assert ev.gsm8k_train_sample == {"correct": 3, "wrong": 0, "refused": 47}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cache hit + invalidation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_baseline_cache_hit_on_second_call(tmp_path: Path) -> None:
|
|
"""Second call with the same active corpus digest reuses baselines."""
|
|
active = tmp_path / "active_corpus.jsonl"
|
|
active.write_text("{}\n", encoding="utf-8")
|
|
|
|
cap_calls: list[int] = []
|
|
gsm_calls: list[int] = []
|
|
|
|
def _cap() -> dict[str, dict[str, int]]:
|
|
cap_calls.append(1)
|
|
return _stub_capability_axes()
|
|
|
|
def _gsm() -> dict[str, int]:
|
|
gsm_calls.append(1)
|
|
return _stub_gsm8k()
|
|
|
|
run_admissibility_replay_gate(
|
|
_spec(),
|
|
active_corpus_path=active,
|
|
_capability_axes_runner=_cap,
|
|
_gsm8k_runner=_gsm,
|
|
_cognition_runner=_stub_cognition,
|
|
)
|
|
first_cap = len(cap_calls)
|
|
first_gsm = len(gsm_calls)
|
|
# Each first call runs the baseline AND the candidate -> 2 runs each.
|
|
assert first_cap >= 2 and first_gsm >= 2
|
|
|
|
run_admissibility_replay_gate(
|
|
_spec(),
|
|
active_corpus_path=active,
|
|
_capability_axes_runner=_cap,
|
|
_gsm8k_runner=_gsm,
|
|
_cognition_runner=_stub_cognition,
|
|
)
|
|
# On the second call only the CANDIDATE run fires; baseline is cached.
|
|
assert len(cap_calls) == first_cap + 1
|
|
assert len(gsm_calls) == first_gsm + 1
|
|
|
|
|
|
def test_baseline_cache_invalidates_on_corpus_change(tmp_path: Path) -> None:
|
|
active_a = tmp_path / "corpus_a.jsonl"
|
|
active_a.write_text("a\n", encoding="utf-8")
|
|
active_b = tmp_path / "corpus_b.jsonl"
|
|
active_b.write_text("b\n", encoding="utf-8")
|
|
|
|
cap_calls: list[int] = []
|
|
gsm_calls: list[int] = []
|
|
|
|
def _cap() -> dict[str, dict[str, int]]:
|
|
cap_calls.append(1)
|
|
return _stub_capability_axes()
|
|
|
|
def _gsm() -> dict[str, int]:
|
|
gsm_calls.append(1)
|
|
return _stub_gsm8k()
|
|
|
|
run_admissibility_replay_gate(
|
|
_spec(),
|
|
active_corpus_path=active_a,
|
|
_capability_axes_runner=_cap,
|
|
_gsm8k_runner=_gsm,
|
|
_cognition_runner=_stub_cognition,
|
|
)
|
|
a_cap, a_gsm = len(cap_calls), len(gsm_calls)
|
|
# Different corpus digest -> baseline re-runs.
|
|
run_admissibility_replay_gate(
|
|
_spec(),
|
|
active_corpus_path=active_b,
|
|
_capability_axes_runner=_cap,
|
|
_gsm8k_runner=_gsm,
|
|
_cognition_runner=_stub_cognition,
|
|
)
|
|
# The second call runs baseline + candidate again because the cache
|
|
# was invalidated by the digest change.
|
|
assert len(cap_calls) >= a_cap + 2
|
|
assert len(gsm_calls) >= a_gsm + 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WRONG-COUNT INVARIANT (the load-bearing test for ADR-0163 §Constraint #1)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_wrong_count_invariant_auto_rejects_gsm8k_regression() -> None:
|
|
"""If the candidate lifts the GSM8K wrong count by ≥ 1, the gate
|
|
rejects with the typed regressed_metrics entry — Phase D / E's
|
|
wiring never reaches the operator review."""
|
|
|
|
baseline_gsm = {"correct": 3, "wrong": 0, "refused": 47}
|
|
candidate_gsm = {"correct": 3, "wrong": 1, "refused": 46}
|
|
|
|
# Pre-populate the baseline cache so the runner returns the
|
|
# candidate's elevated counts. This mirrors a Phase D wiring that
|
|
# mis-admits one previously-refused case as a wrong answer.
|
|
call_count = {"n": 0}
|
|
|
|
def _alternating_gsm() -> dict[str, int]:
|
|
# First call: baseline. Second call (live candidate): elevated.
|
|
call_count["n"] += 1
|
|
return baseline_gsm if call_count["n"] == 1 else candidate_gsm
|
|
|
|
ev = run_admissibility_replay_gate(
|
|
_spec(),
|
|
_capability_axes_runner=_stub_capability_axes,
|
|
_gsm8k_runner=_alternating_gsm,
|
|
_cognition_runner=_stub_cognition,
|
|
)
|
|
assert ev.replay_equivalent is False
|
|
assert "gsm8k_train_sample_wrong_count" in ev.regressed_metrics
|
|
assert ev.wrong_count_delta == 1
|
|
|
|
|
|
def test_capability_axis_wrong_count_also_rejects() -> None:
|
|
"""Any capability axis whose candidate wrong>0 is a regression.
|
|
|
|
G1..G5+S1 are wrong=0 today; a candidate that flips any to >0
|
|
must be rejected.
|
|
"""
|
|
elevated = _stub_capability_axes()
|
|
elevated["G3_numerics"] = {"correct": 19, "wrong": 1, "refused": 6}
|
|
|
|
call_count = {"n": 0}
|
|
|
|
def _alt_caps() -> dict[str, dict[str, int]]:
|
|
call_count["n"] += 1
|
|
return _stub_capability_axes() if call_count["n"] == 1 else elevated
|
|
|
|
ev = run_admissibility_replay_gate(
|
|
_spec(),
|
|
_capability_axes_runner=_alt_caps,
|
|
_gsm8k_runner=_stub_gsm8k,
|
|
_cognition_runner=_stub_cognition,
|
|
)
|
|
assert ev.replay_equivalent is False
|
|
assert any(
|
|
m.startswith("capability_axis_wrong:") for m in ev.regressed_metrics
|
|
)
|
|
|
|
|
|
def test_cognition_lane_regression_also_rejects() -> None:
|
|
"""The cognition-lane regression detection from the older
|
|
run_replay_equivalence path is preserved verbatim."""
|
|
|
|
baseline = {
|
|
"intent_accuracy": 1.0,
|
|
"surface_groundedness": 1.0,
|
|
"term_capture_rate": 1.0,
|
|
"versor_closure_rate": 1.0,
|
|
}
|
|
candidate = {**baseline, "intent_accuracy": 0.9}
|
|
|
|
call_count = {"n": 0}
|
|
|
|
def _alt_cog() -> dict[str, float]:
|
|
call_count["n"] += 1
|
|
return baseline if call_count["n"] == 1 else candidate
|
|
|
|
ev = run_admissibility_replay_gate(
|
|
_spec(),
|
|
_capability_axes_runner=_stub_capability_axes,
|
|
_gsm8k_runner=_stub_gsm8k,
|
|
_cognition_runner=_alt_cog,
|
|
)
|
|
assert ev.replay_equivalent is False
|
|
assert "intent_accuracy" in ev.regressed_metrics
|