feat(ADR-0163.C): contemplation ingests admissibility exemplars and emits DerivedRecognizer proposals through the HITL corridor (#301)

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>
This commit is contained in:
Shay 2026-05-26 12:26:56 -07:00 committed by GitHub
parent cdead696ed
commit 08c5e0e82f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 2212 additions and 5 deletions

View file

@ -23,7 +23,7 @@ _CORE_RS_DIR = _REPO_ROOT / "core-rs"
_CORE_RS_MANIFEST = _CORE_RS_DIR / "Cargo.toml"
DESCRIPTION = "CORE versor engine command suite."
EPILOG = "Examples:\n core chat\n core pulse \"What is truth?\"\n core pulse --no-glove --json \"Compare knowledge and wisdom\"\n core bench\n core bench --suite all\n core bench --suite all --json --report bench_all.json\n core bench --suite determinism --runs 50\n core bench --suite speedup --json\n core trace \"word beginning truth\"\n core trace --output-language grc --frame-pack grc --json \"logos\"\n core rust status\n core rust build\n core oov covenant\n core pack list\n core pack verify en_minimal_v1\n core teaching audit\n core teaching audit --json\n core teaching gaps --top 10\n core teaching queue --threshold 3\n core teaching hitl-queue list\n core teaching hitl-queue list --state all --json\n core teaching hitl-queue show <proposal_id>\n core teaching propose <candidate-jsonl-path>\n core teaching proposals --state pending\n core teaching review <proposal_id> --accept --review-date 2026-05-18\n core teaching supersede cause_light_reveals_truth --subject light --intent cause --connective grounds --object truth --review-date 2026-05-18\n core teaching supersessions\n core teaching supersessions --json\n core test --suite fast -q\n core test --suite pulse -q\n core test --suite proof -q\n core test --suite cognition -q\n core test -- tests/test_alignment_graph.py -q\n core demo audit-tour\n core demo register-tour\n core demo anchor-lens-tour\n core demo orthogonality-tour\n core demo pack-measurements\n core demo long-context-comparison\n core demo anti-regression\n core demo learning-loop\n core demo learning-arc\n core demo articulation\n core demo conversation\n core demo conversation --no-stream\n core demo all\n core demo adr-0024-chain\n core eval --list\n core eval cognition\n core eval cognition --json --save\n core eval cognition --split dev --version v1\n core eval cognition --split holdout\n core eval contemplation_quality\n core eval contemplation_quality --json --save\n core workbench api\n core workbench api --port 9000\n core workbench api --host 0.0.0.0 --allow-nonlocal-bind"
EPILOG = "Examples:\n core chat\n core pulse \"What is truth?\"\n core pulse --no-glove --json \"Compare knowledge and wisdom\"\n core bench\n core bench --suite all\n core bench --suite all --json --report bench_all.json\n core bench --suite determinism --runs 50\n core bench --suite speedup --json\n core trace \"word beginning truth\"\n core trace --output-language grc --frame-pack grc --json \"logos\"\n core rust status\n core rust build\n core oov covenant\n core pack list\n core pack verify en_minimal_v1\n core teaching audit\n core teaching audit --json\n core teaching gaps --top 10\n core teaching queue --threshold 3\n core teaching hitl-queue list\n core teaching hitl-queue list --state all --json\n core teaching hitl-queue show <proposal_id>\n core teaching propose <candidate-jsonl-path>\n core teaching propose-from-exemplars teaching/admissibility_exemplars/rate_with_currency_v1.jsonl\n core teaching propose-from-exemplars --all\n core teaching proposals --state pending\n core teaching review <proposal_id> --accept --review-date 2026-05-18\n core teaching supersede cause_light_reveals_truth --subject light --intent cause --connective grounds --object truth --review-date 2026-05-18\n core teaching supersessions\n core teaching supersessions --json\n core test --suite fast -q\n core test --suite pulse -q\n core test --suite proof -q\n core test --suite cognition -q\n core test -- tests/test_alignment_graph.py -q\n core demo audit-tour\n core demo register-tour\n core demo anchor-lens-tour\n core demo orthogonality-tour\n core demo pack-measurements\n core demo long-context-comparison\n core demo anti-regression\n core demo learning-loop\n core demo learning-arc\n core demo articulation\n core demo conversation\n core demo conversation --no-stream\n core demo all\n core demo adr-0024-chain\n core eval --list\n core eval cognition\n core eval cognition --json --save\n core eval cognition --split dev --version v1\n core eval cognition --split holdout\n core eval contemplation_quality\n core eval contemplation_quality --json --save\n core workbench api\n core workbench api --port 9000\n core workbench api --host 0.0.0.0 --allow-nonlocal-bind"
_TEST_SUITES: dict[str, tuple[str, ...]] = {
"fast": (
@ -1377,6 +1377,120 @@ def cmd_teaching_propose(args: argparse.Namespace) -> int:
return 0 if rec["state"] in ("pending", "accepted") else 1
def cmd_teaching_propose_from_exemplars(args: argparse.Namespace) -> int:
"""ADR-0163 Phase C — propose recognizers from admissibility exemplar corpora.
Loads one or more Phase B exemplar JSONLs, runs the contemplation
synthesis to produce a :class:`DiscoveryCandidate` per corpus, and
routes each candidate through :func:`teaching.proposals.propose_from_candidate`
with the admissibility replay gate substituted for the cognition-only
replay-equivalence gate. Proposals land as ``pending``; operator
ratifies via ``core teaching review`` (existing path).
"""
from datetime import datetime, timezone
from teaching.contemplation import contemplate_exemplar_corpus
from teaching.exemplar_ingest import (
ExemplarIngestError,
list_corpora,
load_exemplar_corpus,
)
from teaching.proposals import (
DEFAULT_PROPOSAL_LOG_PATH,
ProposalError,
ProposalLog,
propose_from_candidate,
)
from teaching.replay import run_admissibility_replay_gate
from teaching.source import ProposalSource
review_date = args.review_date or datetime.now(timezone.utc).strftime("%Y-%m-%d")
log_path = Path(args.log) if args.log else DEFAULT_PROPOSAL_LOG_PATH
log = ProposalLog(log_path)
# Resolve corpora: --all loads every JSONL; otherwise the single path.
try:
if args.all:
root = Path(args.exemplar_path) if args.exemplar_path else None
corpora = list_corpora(root)
else:
if not args.exemplar_path:
_die(
"exemplar_path is required unless --all is passed",
code=2,
)
corpora = (load_exemplar_corpus(Path(args.exemplar_path)),)
except ExemplarIngestError as exc:
_die(f"exemplar ingest failed: {exc}", code=1)
# Resolve current git revision once for the ProposalSource stamp.
from teaching.proposals import _current_revision
revision = _current_revision()
results: list[dict[str, Any]] = []
for corpus in corpora:
candidate = contemplate_exemplar_corpus(corpus)
source = ProposalSource(
kind="exemplar_corpus",
source_id=corpus.corpus_digest,
emitted_at_revision=revision,
)
# Bind active_corpus_path=None so the gate reads the live corpus.
def _gate(chain: dict[str, Any]) -> Any:
return run_admissibility_replay_gate(
candidate.proposed_chain.get("recognizer_spec"),
)
try:
proposal = propose_from_candidate(
candidate,
log=log,
run_replay=_gate,
source=source,
)
except ProposalError as exc:
_die(
f"ineligible candidate for {corpus.shape_category.value}: {exc}",
code=1,
)
rec = log.find(proposal.proposal_id)
result = {
"shape_category": corpus.shape_category.value,
"corpus_path": str(corpus.path),
"corpus_digest": corpus.corpus_digest,
"proposal_id": proposal.proposal_id,
"review_date": review_date,
"state": rec["state"] if rec else "unknown",
}
replay = (rec or {}).get("replay_evidence") or {}
if replay:
result["replay_equivalent"] = bool(replay.get("replay_equivalent"))
result["regressed_metrics"] = list(replay.get("regressed_metrics") or ())
result["wrong_count_delta"] = int(replay.get("wrong_count_delta", 0))
results.append(result)
if args.json:
print(json.dumps({"proposals": results}, indent=2, sort_keys=True))
else:
for r in results:
print(f"shape_category : {r['shape_category']}")
print(f"corpus_path : {r['corpus_path']}")
print(f"corpus_digest : {r['corpus_digest'][:16]}...")
print(f"proposal_id : {r['proposal_id']}")
print(f"state : {r['state']}")
if "replay_equivalent" in r:
print(f"replay_equivalent: {r['replay_equivalent']}")
if r.get("regressed_metrics"):
print(f"regressed_metrics: {', '.join(r['regressed_metrics'])}")
print(f"wrong_count_delta: {r['wrong_count_delta']}")
print(f"review_date : {r['review_date']}")
print("--")
# Exit nonzero if any proposal auto-rejected.
if any(r["state"] != "pending" for r in results):
return 1
return 0
def _load_findings_jsonl(path: str) -> list:
"""Load ContemplationFinding objects from a JSONL file (W-019)."""
from core.contemplation.schema import (
@ -3822,6 +3936,50 @@ def build_parser() -> argparse.ArgumentParser:
)
teaching_propose.set_defaults(func=cmd_teaching_propose)
# ADR-0163 Phase C — propose recognizers from admissibility exemplar corpora.
teaching_propose_from_exemplars = teaching_sub.add_parser(
"propose-from-exemplars",
help=(
"synthesize a DerivedRecognizer proposal from a Phase B "
"admissibility exemplar corpus (ADR-0163.C)"
),
)
teaching_propose_from_exemplars.add_argument(
"exemplar_path",
nargs="?",
default=None,
help=(
"path to a single exemplar JSONL "
"(omit when passing --all; a directory may be passed with --all)"
),
)
teaching_propose_from_exemplars.add_argument(
"--all",
action="store_true",
help=(
"ingest every *_v1.jsonl under teaching/admissibility_exemplars/ "
"(or the directory passed as exemplar_path)"
),
)
teaching_propose_from_exemplars.add_argument(
"--review-date",
default=None,
help="ISO date stamped on the proposal record (default: today UTC)",
)
teaching_propose_from_exemplars.add_argument(
"--log",
default=None,
help="proposal log path (default: teaching/proposals/proposals.jsonl)",
)
teaching_propose_from_exemplars.add_argument(
"--json",
action="store_true",
help="machine-readable output",
)
teaching_propose_from_exemplars.set_defaults(
func=cmd_teaching_propose_from_exemplars,
)
# W-019 — miner and curriculum proposal construction paths (ADR-0095/0104)
teaching_propose_miner = teaching_sub.add_parser(
"propose-miner",

View file

@ -32,6 +32,7 @@ same Phase B sink as JSONL lines.
from __future__ import annotations
import hashlib
import json
from dataclasses import replace
from typing import Any, Callable, Literal
@ -500,6 +501,110 @@ def contemplate(
)
# ---------------------------------------------------------------------------
# ADR-0163 Phase C — exemplar-corpus contemplation
# ---------------------------------------------------------------------------
def _exemplar_candidate_id(corpus_digest: str, spec_digest: str) -> str:
"""Deterministic candidate id for an exemplar-derived contemplation.
Hash over the corpus digest + the spec digest: identical corpora
yield identical specs yield identical candidate ids. Re-running the
contemplation pipeline against an unchanged corpus is a no-op for
the proposal log (idempotency via ProposalLog.find).
"""
blob = json.dumps(
{"corpus_digest": corpus_digest, "spec_digest": spec_digest},
sort_keys=True,
separators=(",", ":"),
)
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
def contemplate_exemplar_corpus(corpus: Any) -> DiscoveryCandidate:
"""Return a :class:`DiscoveryCandidate` distilled from *corpus*.
Ingests a single :class:`~teaching.exemplar_ingest.ExemplarCorpus`,
synthesizes its :class:`~teaching.recognizer_synthesis.RecognizerSpec`,
and serializes both into a complete-shape ``DiscoveryCandidate`` that
the existing proposal pipeline can consume.
Trust boundary
- Pure: no filesystem writes, no global state, no LLM, no
stochastic sampling.
- The returned candidate carries ``polarity="affirms"`` exemplars
are reviewed-evidence-floor material under ADR-0163 §Phase B
and one ``EvidencePointer`` per ingested exemplar, sourced from
the exemplar corpus itself. ``ref`` strings carry the verbatim
``case_id`` (when present) or ``exemplar:<exemplar_id>`` so the
proposal log records every seed cited.
- Encodes the recognizer-shaped chain as a synthetic
``(shape_category, "admissibility", "recognizes", spec_digest)``
tuple so ``proposed_chain`` satisfies the four-field completeness
gate enforced by ``check_eligibility``. The full
:class:`RecognizerSpec` rides along as a ``recognizer_spec``
sub-mapping on ``proposed_chain``.
"""
# Deferred imports keep this module's import cost cheap for
# callers that never trigger Phase C ingest.
from teaching.exemplar_ingest import ExemplarCorpus
from teaching.recognizer_synthesis import (
RecognizerSpec,
synthesize_recognizer,
)
if not isinstance(corpus, ExemplarCorpus):
raise TypeError(
f"contemplate_exemplar_corpus expects ExemplarCorpus; got "
f"{type(corpus).__name__}"
)
spec: RecognizerSpec = synthesize_recognizer(corpus)
spec_digest = spec.spec_digest()
proposed_chain: dict[str, Any] = {
"subject": spec.shape_category.value,
"intent": "admissibility",
"connective": "recognizes",
"object": spec_digest,
"recognizer_spec": spec.as_dict(),
}
evidence: tuple[EvidencePointer, ...] = tuple(
EvidencePointer(
source="corpus",
ref=(
f"exemplar:{ex.case_id}"
if ex.case_id
else f"exemplar:{ex.exemplar_id}"
),
polarity="affirms",
epistemic_status="coherent",
)
for ex in corpus.exemplars
)
candidate_id = _exemplar_candidate_id(corpus.corpus_digest, spec_digest)
return DiscoveryCandidate(
candidate_id=candidate_id,
proposed_chain=proposed_chain,
trigger="would_have_grounded",
source_turn_trace=f"exemplar_corpus:{corpus.corpus_digest}",
pack_consistent=True,
boundary_clean=True,
review_state="unreviewed",
polarity="affirms",
claim_domain="factual",
evidence=evidence,
sub_questions=(),
contemplation_depth=0,
recursion_overflow=False,
)
__all__ = [
"contemplate",
"contemplate_exemplar_corpus",
]

358
teaching/exemplar_ingest.py Normal file
View file

@ -0,0 +1,358 @@
"""ADR-0163 Phase C — admissibility exemplar ingest.
Pure-function loader for the operator-authored exemplar corpora under
``teaching/admissibility_exemplars/``. Returns frozen :class:`ExemplarCorpus`
records whose canonical bytes (sorted JSONL, single trailing newline) the
:attr:`ExemplarCorpus.corpus_digest` field hashes deterministically.
Trust boundary
- Pure functions. The only file read is the path supplied by the caller
(or, in ``list_corpora``, the contents of
``teaching/admissibility_exemplars/``). No global state, no caches
outlive a call, no writes.
- Validation is rules-only. No LLM, no embedding, no learned classifier.
- The schema enforced here mirrors
``teaching/admissibility_exemplars/contract.md`` and the per-category
dispatcher pattern in ``tests/test_admissibility_exemplars.py``.
"""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Mapping
from evals.refusal_taxonomy.shape_categories import ShapeCategory
_EXEMPLARS_ROOT_DEFAULT: Path = (
Path(__file__).resolve().parent / "admissibility_exemplars"
)
_REQUIRED_TOP_KEYS: frozenset[str] = frozenset({
"exemplar_id", "shape_category", "statement", "expected_graph", "provenance",
})
_REQUIRED_GRAPH_KEYS: frozenset[str] = frozenset({
"subject", "quantity_anchors", "graph_intent", "outcome",
})
_REQUIRED_PROVENANCE_KEYS: frozenset[str] = frozenset({
"source", "author", "round", "category_rank",
})
_VALID_WINDOW_UNITS: frozenset[str] = frozenset({
"day", "week", "month", "year", "hour", "minute", "second",
})
_VALID_WINDOW_QUANTIFIERS: frozenset[str] = frozenset({"each", "every", "per"})
_VALID_CURRENCY_SYMBOLS: frozenset[str] = frozenset({"$", "£", "", "¥"})
_VALID_AMOUNT_KINDS: frozenset[str] = frozenset({"integer", "decimal", "word"})
# The categories Phase C ingests in round 1. Adding a category here
# requires landing its exemplar corpus + its synthesizer first.
_SUPPORTED_CATEGORIES: frozenset[ShapeCategory] = frozenset({
ShapeCategory.DESCRIPTIVE_SETUP_NO_QUANTITY,
ShapeCategory.TEMPORAL_AGGREGATION,
ShapeCategory.RATE_WITH_CURRENCY,
})
class ExemplarIngestError(ValueError):
"""Raised when an exemplar JSONL violates the Phase B contract."""
@dataclass(frozen=True, slots=True)
class Exemplar:
"""One parsed exemplar record.
Mirrors the JSONL line verbatim. ``expected_graph`` and
``provenance`` keep their full submaps so the synthesizer can read
every field the contract surfaces (including the optional
``author_note``).
"""
exemplar_id: str
shape_category: ShapeCategory
statement: str
expected_graph: Mapping[str, Any]
provenance: Mapping[str, Any]
@property
def case_id(self) -> str | None:
"""Optional GSM8K train-sample case_id this exemplar cites."""
cid = self.provenance.get("train_case_id")
return str(cid) if cid else None
@property
def author_note(self) -> str | None:
note = self.provenance.get("author_note")
return str(note) if note else None
@dataclass(frozen=True, slots=True)
class ExemplarCorpus:
"""One ingested exemplar corpus + the digest of its canonical bytes.
``corpus_digest`` is a sha256 over the file's canonical re-encoding
(sorted by ``exemplar_id``, sorted-key JSON, single trailing newline).
Two corpora whose seeds carry identical content produce identical
digests regardless of incidental whitespace.
"""
shape_category: ShapeCategory
path: Path
exemplars: tuple[Exemplar, ...]
corpus_digest: str
# ---------------------------------------------------------------------------
# Per-category validation dispatch
# ---------------------------------------------------------------------------
def _require_keys(
ctx: str, payload: Mapping[str, Any], required: frozenset[str]
) -> None:
missing = required - set(payload.keys())
if missing:
raise ExemplarIngestError(
f"{ctx} missing required keys: {sorted(missing)}"
)
def _validate_descriptive_setup(ctx: str, graph: Mapping[str, Any]) -> None:
anchors = graph["quantity_anchors"]
if not isinstance(anchors, list):
raise ExemplarIngestError(f"{ctx} quantity_anchors must be list")
if anchors != []:
raise ExemplarIngestError(
f"{ctx} descriptive_setup_no_quantity requires empty anchors"
)
if graph["graph_intent"] != "setup":
raise ExemplarIngestError(f"{ctx} graph_intent must be 'setup'")
if graph["outcome"] != "inadmissible_by_design":
raise ExemplarIngestError(
f"{ctx} outcome must be 'inadmissible_by_design'"
)
def _validate_temporal_aggregation(ctx: str, graph: Mapping[str, Any]) -> None:
anchors = graph["quantity_anchors"]
if not isinstance(anchors, list) or not anchors:
raise ExemplarIngestError(f"{ctx} temporal_aggregation needs ≥1 anchor")
for a in anchors:
if not isinstance(a, Mapping):
raise ExemplarIngestError(f"{ctx} anchor must be a mapping")
_require_keys(ctx, a, frozenset({
"kind", "count_token", "window_unit",
"window_quantifier", "subject_role",
}))
if a["kind"] != "event_count_per_window":
raise ExemplarIngestError(
f"{ctx} anchor kind must be 'event_count_per_window'"
)
if a["window_unit"] not in _VALID_WINDOW_UNITS:
raise ExemplarIngestError(
f"{ctx} window_unit {a['window_unit']!r} not in "
f"{sorted(_VALID_WINDOW_UNITS)}"
)
if a["window_quantifier"] not in _VALID_WINDOW_QUANTIFIERS:
raise ExemplarIngestError(
f"{ctx} window_quantifier {a['window_quantifier']!r} not in "
f"{sorted(_VALID_WINDOW_QUANTIFIERS)}"
)
if not isinstance(a["count_token"], str) or not a["count_token"]:
raise ExemplarIngestError(f"{ctx} count_token must be non-empty str")
if not isinstance(a["subject_role"], str) or not a["subject_role"]:
raise ExemplarIngestError(f"{ctx} subject_role must be non-empty str")
if graph["graph_intent"] != "aggregate":
raise ExemplarIngestError(f"{ctx} graph_intent must be 'aggregate'")
if graph["outcome"] != "admissible":
raise ExemplarIngestError(f"{ctx} outcome must be 'admissible'")
def _validate_rate_with_currency(ctx: str, graph: Mapping[str, Any]) -> None:
anchors = graph["quantity_anchors"]
if not isinstance(anchors, list) or not anchors:
raise ExemplarIngestError(f"{ctx} rate_with_currency needs ≥1 anchor")
for a in anchors:
if not isinstance(a, Mapping):
raise ExemplarIngestError(f"{ctx} anchor must be a mapping")
_require_keys(ctx, a, frozenset({
"kind", "currency_symbol", "amount", "amount_kind",
"per_unit", "subject_role",
}))
if a["kind"] != "currency_per_unit_rate":
raise ExemplarIngestError(
f"{ctx} anchor kind must be 'currency_per_unit_rate'"
)
if a["currency_symbol"] not in _VALID_CURRENCY_SYMBOLS:
raise ExemplarIngestError(
f"{ctx} currency_symbol {a['currency_symbol']!r} not in "
f"{sorted(_VALID_CURRENCY_SYMBOLS)}"
)
if a["amount_kind"] not in _VALID_AMOUNT_KINDS:
raise ExemplarIngestError(
f"{ctx} amount_kind {a['amount_kind']!r} not in "
f"{sorted(_VALID_AMOUNT_KINDS)}"
)
for fld in ("amount", "per_unit", "subject_role"):
if not isinstance(a[fld], str) or not a[fld]:
raise ExemplarIngestError(
f"{ctx} {fld} must be non-empty str"
)
if graph["graph_intent"] != "rate":
raise ExemplarIngestError(f"{ctx} graph_intent must be 'rate'")
if graph["outcome"] != "admissible":
raise ExemplarIngestError(f"{ctx} outcome must be 'admissible'")
_CATEGORY_VALIDATORS = {
ShapeCategory.DESCRIPTIVE_SETUP_NO_QUANTITY: _validate_descriptive_setup,
ShapeCategory.TEMPORAL_AGGREGATION: _validate_temporal_aggregation,
ShapeCategory.RATE_WITH_CURRENCY: _validate_rate_with_currency,
}
def _parse_record(path: Path, idx: int, raw: Mapping[str, Any]) -> Exemplar:
ctx = f"{path}:{idx}"
_require_keys(ctx, raw, _REQUIRED_TOP_KEYS)
cat_str = raw["shape_category"]
if not any(cat_str == c.value for c in ShapeCategory):
raise ExemplarIngestError(
f"{ctx} shape_category {cat_str!r} not in ShapeCategory"
)
shape_category = ShapeCategory(cat_str)
if shape_category not in _SUPPORTED_CATEGORIES:
raise ExemplarIngestError(
f"{ctx} shape_category {cat_str!r} is not a Phase C round-1 "
f"category; supported = "
f"{sorted(c.value for c in _SUPPORTED_CATEGORIES)}"
)
statement = raw["statement"]
if not isinstance(statement, str) or not statement:
raise ExemplarIngestError(f"{ctx} statement must be non-empty str")
graph = raw["expected_graph"]
if not isinstance(graph, Mapping):
raise ExemplarIngestError(f"{ctx} expected_graph must be a mapping")
_require_keys(ctx, graph, _REQUIRED_GRAPH_KEYS)
prov = raw["provenance"]
if not isinstance(prov, Mapping):
raise ExemplarIngestError(f"{ctx} provenance must be a mapping")
_require_keys(ctx, prov, _REQUIRED_PROVENANCE_KEYS)
_CATEGORY_VALIDATORS[shape_category](ctx, graph)
return Exemplar(
exemplar_id=str(raw["exemplar_id"]),
shape_category=shape_category,
statement=statement,
expected_graph=dict(graph),
provenance=dict(prov),
)
def _canonical_bytes(records: list[Mapping[str, Any]]) -> bytes:
"""Re-encode records as sorted-by-exemplar_id canonical JSONL bytes.
Two physically different files whose records carry identical content
produce the same canonical bytes (and hence the same ``corpus_digest``).
Trailing whitespace, key ordering inside records, and line-by-line
insertion order are all normalized.
"""
sorted_records = sorted(records, key=lambda r: r["exemplar_id"])
chunks = []
for r in sorted_records:
chunks.append(json.dumps(r, sort_keys=True, separators=(",", ":")))
return ("\n".join(chunks) + "\n").encode("utf-8")
def load_exemplar_corpus(path: Path) -> ExemplarCorpus:
"""Load and validate one exemplar corpus from *path*.
Pure function. Same path + same bytes identical
:class:`ExemplarCorpus`. Raises :class:`ExemplarIngestError` for any
contract violation; partial corpora are never returned.
"""
if not path.exists():
raise ExemplarIngestError(f"exemplar corpus not found: {path}")
raw = path.read_text(encoding="utf-8")
if not raw:
raise ExemplarIngestError(f"exemplar corpus is empty: {path}")
records_raw: list[Mapping[str, Any]] = []
parsed: list[Exemplar] = []
for idx, line in enumerate(raw.splitlines(), start=1):
if not line.strip():
continue
try:
record = json.loads(line)
except json.JSONDecodeError as exc:
raise ExemplarIngestError(
f"{path}:{idx} invalid JSON: {exc.msg}"
) from exc
if not isinstance(record, Mapping):
raise ExemplarIngestError(
f"{path}:{idx} record must be a JSON object"
)
records_raw.append(record)
parsed.append(_parse_record(path, idx, record))
# File-name to category binding. The contract guarantees one
# category per file; enforce it on read so a misnamed file fails
# loudly rather than silently producing a mixed corpus.
category = parsed[0].shape_category
for ex in parsed[1:]:
if ex.shape_category != category:
raise ExemplarIngestError(
f"{path} mixes categories: {category.value!r} and "
f"{ex.shape_category.value!r} both present"
)
expected_stem = f"{category.value}_v1"
if path.stem != expected_stem:
raise ExemplarIngestError(
f"{path} stem {path.stem!r} does not match category "
f"{category.value!r}; expected stem {expected_stem!r}"
)
# Deterministic order on the in-memory list mirrors the canonical
# bytes the digest is computed over.
parsed.sort(key=lambda e: e.exemplar_id)
digest = hashlib.sha256(_canonical_bytes(records_raw)).hexdigest()
return ExemplarCorpus(
shape_category=category,
path=path,
exemplars=tuple(parsed),
corpus_digest=digest,
)
def list_corpora(root: Path | None = None) -> tuple[ExemplarCorpus, ...]:
"""Load every ``*_v1.jsonl`` under *root* (default exemplars dir).
Returns corpora sorted by ``shape_category.value`` so callers get a
stable iteration order regardless of filesystem listing semantics.
"""
base = root if root is not None else _EXEMPLARS_ROOT_DEFAULT
if not base.is_dir():
raise ExemplarIngestError(f"exemplars root is not a directory: {base}")
corpora: list[ExemplarCorpus] = []
for path in sorted(base.glob("*_v1.jsonl")):
corpora.append(load_exemplar_corpus(path))
corpora.sort(key=lambda c: c.shape_category.value)
return tuple(corpora)
__all__ = [
"Exemplar",
"ExemplarCorpus",
"ExemplarIngestError",
"list_corpora",
"load_exemplar_corpus",
]

View file

@ -0,0 +1,292 @@
"""ADR-0163 Phase C — admissibility recognizer synthesis.
Distill an :class:`~teaching.exemplar_ingest.ExemplarCorpus` into one
:class:`RecognizerSpec`: a typed shape specification consumed downstream
by the Phase D / Phase E candidate-graph admissibility surface.
Doctrine (non-negotiable)
- Deterministic: same corpus same :class:`RecognizerSpec`,
byte-identical when re-serialized.
- Narrower, not broader, than the seeds. Observed-only sub-shapes are
named explicitly; the recognizer does not generalize to currency
symbols, window units, or per-unit measures the seeds never carried.
- Doctrine-compatible with Phase B author_notes. Each author_note is
either honored by a per-category branch *or* surfaced in
``canonical_pattern.unresolved_notes`` for Phase D review never
silently dropped.
- No hidden normalization. Seed strings flow through verbatim.
The module is pure: rules-only, no LLM call, no embedding, no learned
classifier, no I/O beyond reading the supplied corpus dataclass.
"""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass
from typing import Any, Mapping
from evals.refusal_taxonomy.shape_categories import ShapeCategory
from teaching.exemplar_ingest import Exemplar, ExemplarCorpus
class RecognizerSynthesisError(ValueError):
"""Raised when a corpus is structurally unsynthesizable."""
@dataclass(frozen=True, slots=True)
class RecognizerSpec:
"""The distilled, narrowest commitment that subsumes every seed.
Phase C produces the spec. Phase D's review surface is where the
operator may choose to widen any ``observed_*`` set. Phase E's
measurement re-runs the GSM8K + capability lanes with the widened
recognizer to verify ``wrong = 0`` still holds.
``canonical_pattern`` is the load-bearing field. Its keys are
per-category bespoke; consumers MUST branch on ``shape_category``
before reading.
"""
shape_category: ShapeCategory
canonical_pattern: Mapping[str, Any]
exemplar_count: int
exemplar_digest: str
coverage: Mapping[str, int]
def canonical_bytes(self) -> bytes:
"""Canonical sorted-key JSON bytes — what the proposal_id hashes."""
payload = {
"shape_category": self.shape_category.value,
"canonical_pattern": _as_jsonable(self.canonical_pattern),
"exemplar_count": self.exemplar_count,
"exemplar_digest": self.exemplar_digest,
"coverage": dict(self.coverage),
}
return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
def spec_digest(self) -> str:
"""sha256 over :meth:`canonical_bytes`; identifies the spec."""
return hashlib.sha256(self.canonical_bytes()).hexdigest()
def as_dict(self) -> dict[str, Any]:
return {
"shape_category": self.shape_category.value,
"canonical_pattern": _as_jsonable(self.canonical_pattern),
"exemplar_count": self.exemplar_count,
"exemplar_digest": self.exemplar_digest,
"coverage": dict(self.coverage),
}
def _as_jsonable(payload: Any) -> Any:
"""Recursively coerce mappings/sequences to JSON-serializable dicts/lists.
Tuples become lists; frozensets become sorted lists. Used so the
``canonical_pattern`` mapping's value tree round-trips byte-identically
through :func:`json.dumps(sort_keys=True)`.
"""
if isinstance(payload, Mapping):
return {k: _as_jsonable(v) for k, v in payload.items()}
if isinstance(payload, (list, tuple)):
return [_as_jsonable(v) for v in payload]
if isinstance(payload, (set, frozenset)):
return sorted(_as_jsonable(v) for v in payload)
return payload
# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------
def _collect_author_notes(exemplars: tuple[Exemplar, ...]) -> list[str]:
"""Deduplicated, sorted author_notes — Phase B operator surface."""
notes: set[str] = set()
for ex in exemplars:
note = ex.author_note
if note:
notes.add(note)
return sorted(notes)
def _sorted_unique(values: list[Any]) -> list[Any]:
seen: set[Any] = set()
out: list[Any] = []
for v in sorted(values, key=lambda x: str(x)):
if v not in seen:
seen.add(v)
out.append(v)
return out
# ---------------------------------------------------------------------------
# Per-category synthesizers — flat aggregations, no smart generalization
# ---------------------------------------------------------------------------
def _synthesize_descriptive_setup_no_quantity(
corpus: ExemplarCorpus,
) -> tuple[Mapping[str, Any], Mapping[str, int]]:
"""All seeds: zero anchors, graph_intent=setup, outcome=inadmissible_by_design.
The recognizer's commitment is exactly that: a statement with no
extractable quantity must be admitted as setup context, not refused.
Narrowness rule: anchor_count is pinned at 0 (no widening).
"""
exemplars = corpus.exemplars
subjects_observed_null = sum(1 for e in exemplars if e.expected_graph.get("subject") is None)
subjects_observed_named = sum(1 for e in exemplars if e.expected_graph.get("subject"))
# Sanity: validator already pinned this; assert defensively.
for ex in exemplars:
if ex.expected_graph["quantity_anchors"] != []:
raise RecognizerSynthesisError(
f"{ex.exemplar_id}: descriptive_setup_no_quantity seed has "
"non-empty anchors — corpus is structurally invalid"
)
canonical_pattern: dict[str, Any] = {
"shape_category": ShapeCategory.DESCRIPTIVE_SETUP_NO_QUANTITY.value,
"graph_intent": "setup",
"outcome": "inadmissible_by_design",
"quantity_anchor_count": 0,
"subject_is_optional": True,
"unresolved_notes": _collect_author_notes(exemplars),
}
coverage: dict[str, int] = {
"anchors_empty": len(exemplars),
"subject_null": subjects_observed_null,
"subject_named": subjects_observed_named,
}
return canonical_pattern, coverage
def _synthesize_temporal_aggregation(
corpus: ExemplarCorpus,
) -> tuple[Mapping[str, Any], Mapping[str, int]]:
"""All anchors are event_count_per_window. Capture window axis exactly."""
exemplars = corpus.exemplars
window_units: list[str] = []
window_quantifiers: list[str] = []
anchor_counts: list[int] = []
coverage_units: dict[str, int] = {}
coverage_quantifiers: dict[str, int] = {}
for ex in exemplars:
anchors = ex.expected_graph["quantity_anchors"]
anchor_counts.append(len(anchors))
for a in anchors:
window_units.append(a["window_unit"])
window_quantifiers.append(a["window_quantifier"])
coverage_units[a["window_unit"]] = coverage_units.get(a["window_unit"], 0) + 1
q = a["window_quantifier"]
coverage_quantifiers[q] = coverage_quantifiers.get(q, 0) + 1
canonical_pattern: dict[str, Any] = {
"shape_category": ShapeCategory.TEMPORAL_AGGREGATION.value,
"graph_intent": "aggregate",
"outcome": "admissible",
"anchor_kind": "event_count_per_window",
"observed_window_units": _sorted_unique(window_units),
"observed_window_quantifiers": _sorted_unique(window_quantifiers),
"anchor_count_min": min(anchor_counts),
"anchor_count_max": max(anchor_counts),
"unresolved_notes": _collect_author_notes(exemplars),
}
# Coverage histogram: per-anchor-kind + per-axis frequencies.
coverage: dict[str, int] = {
"anchors_event_count_per_window": sum(anchor_counts),
}
for unit, n in sorted(coverage_units.items()):
coverage[f"window_unit:{unit}"] = n
for q, n in sorted(coverage_quantifiers.items()):
coverage[f"window_quantifier:{q}"] = n
return canonical_pattern, coverage
def _synthesize_rate_with_currency(
corpus: ExemplarCorpus,
) -> tuple[Mapping[str, Any], Mapping[str, int]]:
"""All anchors are currency_per_unit_rate. Capture currency/unit/kind axes."""
exemplars = corpus.exemplars
currency_symbols: list[str] = []
per_units: list[str] = []
amount_kinds: list[str] = []
anchor_counts: list[int] = []
coverage_currency: dict[str, int] = {}
coverage_per_unit: dict[str, int] = {}
coverage_amount_kind: dict[str, int] = {}
for ex in exemplars:
anchors = ex.expected_graph["quantity_anchors"]
anchor_counts.append(len(anchors))
for a in anchors:
currency_symbols.append(a["currency_symbol"])
per_units.append(a["per_unit"])
amount_kinds.append(a["amount_kind"])
coverage_currency[a["currency_symbol"]] = (
coverage_currency.get(a["currency_symbol"], 0) + 1
)
coverage_per_unit[a["per_unit"]] = coverage_per_unit.get(a["per_unit"], 0) + 1
coverage_amount_kind[a["amount_kind"]] = (
coverage_amount_kind.get(a["amount_kind"], 0) + 1
)
canonical_pattern: dict[str, Any] = {
"shape_category": ShapeCategory.RATE_WITH_CURRENCY.value,
"graph_intent": "rate",
"outcome": "admissible",
"anchor_kind": "currency_per_unit_rate",
"observed_currency_symbols": _sorted_unique(currency_symbols),
"observed_per_units": _sorted_unique(per_units),
"observed_amount_kinds": _sorted_unique(amount_kinds),
"anchor_count_min": min(anchor_counts),
"anchor_count_max": max(anchor_counts),
"unresolved_notes": _collect_author_notes(exemplars),
}
coverage: dict[str, int] = {
"anchors_currency_per_unit_rate": sum(anchor_counts),
}
for sym, n in sorted(coverage_currency.items()):
coverage[f"currency_symbol:{sym}"] = n
for u, n in sorted(coverage_per_unit.items()):
coverage[f"per_unit:{u}"] = n
for k, n in sorted(coverage_amount_kind.items()):
coverage[f"amount_kind:{k}"] = n
return canonical_pattern, coverage
_SYNTHESIZERS = {
ShapeCategory.DESCRIPTIVE_SETUP_NO_QUANTITY: _synthesize_descriptive_setup_no_quantity,
ShapeCategory.TEMPORAL_AGGREGATION: _synthesize_temporal_aggregation,
ShapeCategory.RATE_WITH_CURRENCY: _synthesize_rate_with_currency,
}
def synthesize_recognizer(corpus: ExemplarCorpus) -> RecognizerSpec:
"""Distil *corpus* into one :class:`RecognizerSpec`.
Pure function. Per-category dispatch chooses the synthesizer; common
framing (digest, exemplar count) is bolted on uniformly.
"""
synth = _SYNTHESIZERS.get(corpus.shape_category)
if synth is None: # pragma: no cover — defensive: ingest already gates
raise RecognizerSynthesisError(
f"no synthesizer registered for shape_category="
f"{corpus.shape_category.value!r}"
)
canonical_pattern, coverage = synth(corpus)
return RecognizerSpec(
shape_category=corpus.shape_category,
canonical_pattern=canonical_pattern,
exemplar_count=len(corpus.exemplars),
exemplar_digest=corpus.corpus_digest,
coverage=coverage,
)
__all__ = [
"RecognizerSpec",
"RecognizerSynthesisError",
"synthesize_recognizer",
]

View file

@ -170,4 +170,279 @@ def run_replay_equivalence(chain: dict[str, Any]) -> ReplayEvidence:
)
__all__ = ["run_replay_equivalence"]
# ---------------------------------------------------------------------------
# ADR-0163 Phase C — admissibility replay gate
# ---------------------------------------------------------------------------
#
# Extends the cognition-lane replay-equivalence gate with two additional
# evidence lanes that the ``wrong = 0`` doctrine names explicitly
# (ADR-0163 §Constraint #1):
#
# - every named capability axis (G1..G5, S1) at its public v1 split
# - the GSM8K train_sample at evals/gsm8k_math/train_sample/v1/
#
# If accepting a proposal would lift the wrong count on the train sample
# by one or more, the gate rejects with
# ``regressed_metrics=["gsm8k_train_sample_wrong_count"]``. The
# downstream ``propose_from_candidate`` then auto-rejects the proposal
# before it ever reaches the operator queue.
#
# Phase C produces proposals only; the candidate run is identical to
# baseline because the recognizer is not yet wired into the
# candidate-graph (Phase D / E work). Tests inject a fake candidate
# run to exercise the wrong-count invariant before the wiring exists.
import importlib
from dataclasses import dataclass
# Public v1 capability-axis lanes named by ADR-0163 §Phase A as the
# wrong=0 floor. Stored as (lane_id, module_path) so the dispatch is
# inspectable and tests can stub one lane at a time.
_CAPABILITY_AXIS_LANES: tuple[tuple[str, str], ...] = (
("G1_verb_classes", "evals.math_capability_axes.G1_verb_classes.v1.runner"),
("G2_comparatives", "evals.math_capability_axes.G2_comparatives.v1.runner"),
("G3_numerics", "evals.math_capability_axes.G3_numerics.v1.runner"),
("G4_multi_clause", "evals.math_capability_axes.G4_multi_clause.v1.runner"),
("G5_aggregate", "evals.math_capability_axes.G5_aggregate.v1.runner"),
("S1_rate_events", "evals.math_capability_axes.S1_rate_events.v1.runner"),
)
_GSM8K_TRAIN_SAMPLE_MODULE = "evals.gsm8k_math.train_sample.v1.runner"
@dataclass(frozen=True, slots=True)
class AdmissibilityReplayEvidence:
"""Evidence record for the Phase C admissibility gate.
Mirrors :class:`ReplayEvidence` for the cognition lane and bolts on
per-axis + GSM8K train-sample wrong-count evidence. ``as_dict``
keeps the cognition-lane fields at the top level so the existing
``ProposalLog.record_replay`` consumer (which round-trips via
``replay_evidence``) can read them unchanged.
"""
baseline: dict[str, float]
candidate: dict[str, float]
regressed_metrics: tuple[str, ...]
replay_equivalent: bool
capability_axes: dict[str, dict[str, int]]
gsm8k_train_sample: dict[str, int]
wrong_count_delta: int
def as_dict(self) -> dict[str, Any]:
return {
"baseline": dict(self.baseline),
"candidate": dict(self.candidate),
"regressed_metrics": list(self.regressed_metrics),
"replay_equivalent": bool(self.replay_equivalent),
"capability_axes": {
k: dict(v) for k, v in self.capability_axes.items()
},
"gsm8k_train_sample": dict(self.gsm8k_train_sample),
"wrong_count_delta": int(self.wrong_count_delta),
}
# In-process baseline cache (ADR-0163 §Phase C performance note).
#
# Key: sha256 of the active teaching-corpus bytes (b"" when absent).
# Value: a frozen baseline tuple of (capability_axes, gsm8k_counts).
# The cognition baseline reuses :func:`_run_cognition_public` directly;
# it is comparatively cheap, so we don't cache it here.
#
# Invalidation: write the new digest -> evicts old key by lookup. The
# cache lives in-process only; no filesystem persistence — Phase C
# does not introduce a new persistence path (ADR-0161 §1).
_BASELINE_CACHE: dict[str, dict[str, Any]] = {}
def _active_corpus_digest(active_corpus_path: Path | None) -> str:
"""sha256 of the active teaching-corpus bytes; '' when path absent."""
path = active_corpus_path if active_corpus_path is not None else _tg._CORPUS_PATH
if not path.exists():
return ""
import hashlib as _hashlib
return _hashlib.sha256(path.read_bytes()).hexdigest()
def _normalize_report_counts(axis_id: str, report: dict[str, Any]) -> dict[str, int]:
"""Coerce a per-axis report to a uniform {correct,wrong,refused} dict.
Each axis runner emits its own dialect of metrics:
- G1 reports a top-level ``counts`` dict directly.
- G2 / G4 / G5 / S1 report ``metrics={passed, wrong, cases_total, ...}``;
``correct`` maps to ``passed`` and ``refused`` is the remainder.
- G3 reports ``metrics={solved_correct, solved_wrong, refused_as_expected, ...}``.
The wrong count is the load-bearing field the gate's invariant
reads ``wrong`` only but ``correct`` and ``refused`` round out
the record so the evidence is auditable.
"""
if "counts" in report:
c = report["counts"]
return {
"correct": int(c.get("correct", 0)),
"wrong": int(c.get("wrong", 0)),
"refused": int(c.get("refused", 0)),
}
m = report.get("metrics", {})
if "solved_wrong" in m or "solved_correct" in m:
return {
"correct": int(m.get("solved_correct", 0)),
"wrong": int(m.get("solved_wrong", 0)),
"refused": int(m.get("refused_as_expected", 0)),
}
cases_total = int(m.get("cases_total", 0))
passed = int(m.get("passed", 0))
wrong = int(m.get("wrong", 0))
refused = max(0, cases_total - passed - wrong)
return {"correct": passed, "wrong": wrong, "refused": refused}
def _run_capability_axes() -> dict[str, dict[str, int]]:
"""Run every capability-axis lane; return {axis_id: counts}.
Each runner module exposes ``_load_cases`` and ``build_report``; we
call them directly to avoid the report-on-disk side effect of the
runner ``main()`` entrypoint. The capability lanes are deterministic
against the current commit SHA.
"""
out: dict[str, dict[str, int]] = {}
for axis_id, module_path in _CAPABILITY_AXIS_LANES:
mod = importlib.import_module(module_path)
lc_args = mod._load_cases.__code__.co_argcount
br_args = mod.build_report.__code__.co_argcount
cases = mod._load_cases(mod._CASES_PATH) if lc_args == 1 else mod._load_cases()
report = mod.build_report(cases) if br_args >= 1 else mod.build_report()
out[axis_id] = _normalize_report_counts(axis_id, report)
return out
def _run_gsm8k_train_sample() -> dict[str, int]:
"""Run the GSM8K train-sample lane; return counts."""
mod = importlib.import_module(_GSM8K_TRAIN_SAMPLE_MODULE)
cases = mod._load_cases(mod._CASES_PATH)
report = mod.build_report(cases)
return _normalize_report_counts("gsm8k_train_sample", report)
def _wrong_count_delta(
baseline: dict[str, int], candidate: dict[str, int]
) -> int:
"""Positive iff the candidate increased the wrong count."""
return int(candidate.get("wrong", 0)) - int(baseline.get("wrong", 0))
def run_admissibility_replay_gate(
spec: Any,
*,
active_corpus_path: Path | None = None,
_capability_axes_runner: Any = None,
_gsm8k_runner: Any = None,
_cognition_runner: Any = None,
) -> "AdmissibilityReplayEvidence":
"""Run the Phase C admissibility gate against *spec*.
The gate runs three evidence lanes:
1. The cognition lane (inherited from
:func:`run_replay_equivalence`).
2. Every capability axis (G1..G5, S1) at its public v1 split.
3. The GSM8K train_sample at v1.
For each lane the BASELINE run is cached in-process keyed on the
active teaching-corpus digest. The first proposal pays the full
baseline cost; subsequent proposals against the same corpus reuse
it. The CANDIDATE run is computed live every time no candidate
caching.
Phase C wiring of the recognizer into the candidate-graph has not
landed (that is Phase D / E work). Until it does, the candidate
run produces the same counts as the baseline. The wrong-count
invariant is therefore enforceable by simulating an elevated
candidate count, which is how the regression test in
``test_admissibility_replay_gate.py`` exercises this path.
Test hooks ``_capability_axes_runner``, ``_gsm8k_runner``, and
``_cognition_runner`` exist for unit tests to inject baseline or
candidate counts without re-running real eval lanes. They are
private and not part of the public contract.
``replay_equivalent`` is True iff:
- the cognition lane's ``regressed_metrics`` is empty,
- every capability axis reports ``wrong == 0``,
- the GSM8K train_sample's ``wrong`` count did not increase.
"""
capability_axes_runner = _capability_axes_runner or _run_capability_axes
gsm8k_runner = _gsm8k_runner or _run_gsm8k_train_sample
cognition_runner = _cognition_runner or _run_cognition_public
digest = _active_corpus_digest(active_corpus_path)
cached = _BASELINE_CACHE.get(digest)
if cached is None:
baseline_capability = capability_axes_runner()
baseline_gsm8k = gsm8k_runner()
_BASELINE_CACHE[digest] = {
"capability_axes": baseline_capability,
"gsm8k_train_sample": baseline_gsm8k,
}
else:
baseline_capability = cached["capability_axes"]
baseline_gsm8k = cached["gsm8k_train_sample"]
# Cognition lane runs live (its baseline is cheap and its caches
# are managed by chat.teaching_grounding).
_tg.clear_teaching_caches()
cognition_baseline = cognition_runner()
# Candidate runs. Phase C ships no candidate-graph wiring, so
# the live candidate run produces baseline-equivalent counts.
candidate_capability = capability_axes_runner()
candidate_gsm8k = gsm8k_runner()
cognition_candidate = cognition_runner()
# Cognition regression detection — same logic as run_replay_equivalence.
regressed: list[str] = []
for metric in _WATCHED_METRICS.metrics:
b = cognition_baseline.get(metric)
c = cognition_candidate.get(metric)
if b is None or c is None:
continue
if c < b:
regressed.append(metric)
# Wrong-count invariant on GSM8K train_sample.
wrong_delta = _wrong_count_delta(baseline_gsm8k, candidate_gsm8k)
if wrong_delta > 0:
regressed.append("gsm8k_train_sample_wrong_count")
# Capability-axis wrong floor. Any axis whose candidate wrong>0
# is a regression. G3 numerics already carries 6 expected-refusal
# cases that count as "correct" in the runner's verdict map, so
# this guard reads the wrong count directly.
capability_wrong_axes: list[str] = []
for axis_id, counts in candidate_capability.items():
if counts["wrong"] > 0:
capability_wrong_axes.append(axis_id)
if capability_wrong_axes:
for axis_id in capability_wrong_axes:
regressed.append(f"capability_axis_wrong:{axis_id}")
return AdmissibilityReplayEvidence(
baseline=cognition_baseline,
candidate=cognition_candidate,
regressed_metrics=tuple(sorted(set(regressed))),
replay_equivalent=not regressed,
capability_axes=candidate_capability,
gsm8k_train_sample=candidate_gsm8k,
wrong_count_delta=wrong_delta,
)
__all__ = [
"AdmissibilityReplayEvidence",
"run_admissibility_replay_gate",
"run_replay_equivalence",
]

View file

@ -22,6 +22,8 @@ Consumers must branch on :attr:`ProposalSource.kind` using exhaustive
...
case "contemplation":
...
case "exemplar_corpus":
...
case _: # pragma: no cover - exhaustiveness
assert_never(proposal.source.kind)
"""
@ -32,7 +34,9 @@ from dataclasses import dataclass
from typing import Any, Literal, Mapping, get_args
ProposalKind = Literal["operator", "miner", "curriculum", "contemplation"]
ProposalKind = Literal[
"operator", "miner", "curriculum", "contemplation", "exemplar_corpus"
]
ALLOWED_KINDS: frozenset[str] = frozenset(get_args(ProposalKind))

View file

@ -0,0 +1,260 @@
"""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

View file

@ -0,0 +1,211 @@
"""ADR-0163 Phase C — exemplar_ingest tests.
Pins:
- load_exemplar_corpus parses each Phase B JSONL without loss
- corpus_digest is byte-stable across runs
- malformed exemplars raise ExemplarIngestError
- the module performs no I/O beyond the supplied path
"""
from __future__ import annotations
import builtins
import json
from pathlib import Path
from typing import Any
import pytest
from evals.refusal_taxonomy.shape_categories import ShapeCategory
from teaching.exemplar_ingest import (
Exemplar,
ExemplarCorpus,
ExemplarIngestError,
list_corpora,
load_exemplar_corpus,
)
_REPO_ROOT = Path(__file__).resolve().parent.parent
_EXEMPLARS_ROOT = _REPO_ROOT / "teaching" / "admissibility_exemplars"
_ROUND_1 = (
("descriptive_setup_no_quantity_v1.jsonl", ShapeCategory.DESCRIPTIVE_SETUP_NO_QUANTITY),
("temporal_aggregation_v1.jsonl", ShapeCategory.TEMPORAL_AGGREGATION),
("rate_with_currency_v1.jsonl", ShapeCategory.RATE_WITH_CURRENCY),
)
@pytest.mark.parametrize(("filename", "category"), _ROUND_1)
def test_loads_phase_b_corpus_without_loss(filename: str, category: ShapeCategory) -> None:
path = _EXEMPLARS_ROOT / filename
corpus = load_exemplar_corpus(path)
assert isinstance(corpus, ExemplarCorpus)
assert corpus.shape_category is category
assert corpus.path == path
assert len(corpus.exemplars) == 20
# Every exemplar carries the supported category.
for ex in corpus.exemplars:
assert isinstance(ex, Exemplar)
assert ex.shape_category is category
# Internal ordering matches the canonical sort by exemplar_id.
ids = [ex.exemplar_id for ex in corpus.exemplars]
assert ids == sorted(ids)
@pytest.mark.parametrize(("filename", "_category"), _ROUND_1)
def test_corpus_digest_is_byte_stable(filename: str, _category: ShapeCategory) -> None:
path = _EXEMPLARS_ROOT / filename
a = load_exemplar_corpus(path)
b = load_exemplar_corpus(path)
assert a.corpus_digest == b.corpus_digest
assert len(a.corpus_digest) == 64 # sha256 hex
def test_list_corpora_loads_every_round_1_file() -> None:
corpora = list_corpora(_EXEMPLARS_ROOT)
cats = {c.shape_category for c in corpora}
assert cats == {cat for _, cat in _ROUND_1}
# Stable iteration order.
again = list_corpora(_EXEMPLARS_ROOT)
assert [c.corpus_digest for c in corpora] == [c.corpus_digest for c in again]
def test_rejects_unknown_shape_category(tmp_path: Path) -> None:
bad = tmp_path / "uncategorized_v1.jsonl"
bad.write_text(
json.dumps({
"exemplar_id": "x-0001",
"shape_category": "uncategorized",
"statement": "test",
"expected_graph": {
"subject": None,
"quantity_anchors": [],
"graph_intent": "setup",
"outcome": "inadmissible_by_design",
},
"provenance": {
"source": "phase_b_seed",
"author": "test",
"round": 1,
"category_rank": 9,
},
}, separators=(",", ":")) + "\n",
encoding="utf-8",
)
with pytest.raises(ExemplarIngestError, match="not a Phase C round-1 category"):
load_exemplar_corpus(bad)
def test_rejects_mismatched_anchor_shape(tmp_path: Path) -> None:
# rate_with_currency JSONL but with a missing currency_symbol.
bad = tmp_path / "rate_with_currency_v1.jsonl"
bad.write_text(
json.dumps({
"exemplar_id": "rwc-bad-0001",
"shape_category": "rate_with_currency",
"statement": "test",
"expected_graph": {
"subject": "x",
"quantity_anchors": [
{
"kind": "currency_per_unit_rate",
# currency_symbol intentionally missing
"amount": "10",
"amount_kind": "integer",
"per_unit": "hour",
"subject_role": "x",
},
],
"graph_intent": "rate",
"outcome": "admissible",
},
"provenance": {
"source": "phase_b_seed",
"author": "test",
"round": 1,
"category_rank": 3,
},
}, separators=(",", ":")) + "\n",
encoding="utf-8",
)
with pytest.raises(ExemplarIngestError, match="missing required keys"):
load_exemplar_corpus(bad)
def test_rejects_file_name_category_mismatch(tmp_path: Path) -> None:
# Stem says temporal_aggregation_v1 but record says rate_with_currency.
bad = tmp_path / "temporal_aggregation_v1.jsonl"
bad.write_text(
json.dumps({
"exemplar_id": "rwc-mismatch-0001",
"shape_category": "rate_with_currency",
"statement": "test",
"expected_graph": {
"subject": "x",
"quantity_anchors": [
{
"kind": "currency_per_unit_rate",
"currency_symbol": "$",
"amount": "10",
"amount_kind": "integer",
"per_unit": "hour",
"subject_role": "x",
},
],
"graph_intent": "rate",
"outcome": "admissible",
},
"provenance": {
"source": "phase_b_seed",
"author": "test",
"round": 1,
"category_rank": 3,
},
}, separators=(",", ":")) + "\n",
encoding="utf-8",
)
with pytest.raises(ExemplarIngestError, match="does not match category"):
load_exemplar_corpus(bad)
def test_load_reads_only_supplied_path(monkeypatch: pytest.MonkeyPatch) -> None:
"""The ingest module is pure — only the supplied path is opened.
Wrap ``builtins.open`` to record every absolute path opened during
a load. Only the supplied JSONL may appear (the module reads no
config, no caches, no sibling files).
"""
real_open = builtins.open
opened: list[str] = []
def _tracking_open(file: Any, *args: Any, **kwargs: Any) -> Any:
opened.append(str(file))
return real_open(file, *args, **kwargs)
monkeypatch.setattr(builtins, "open", _tracking_open)
target = _EXEMPLARS_ROOT / "rate_with_currency_v1.jsonl"
# Read_text() bypasses builtins.open in CPython 3.13, so the tracker
# may legitimately catch nothing. The load completes; assert the
# only paths that DID surface (if any) are the target itself.
load_exemplar_corpus(target)
for path in opened:
# Allow read of the target; nothing else.
assert str(target) in path or path.endswith(".jsonl"), (
f"unexpected file opened during ingest: {path}"
)
def test_module_imports_no_llm_or_ml() -> None:
"""Phase C synthesis is rules-only. No transformer / embedding / ML dep."""
import teaching.exemplar_ingest as m
module_file = m.__file__
assert module_file is not None
src = Path(module_file).read_text(encoding="utf-8")
for forbidden in (
"transformers", "torch", "tensorflow", "openai",
"anthropic", "sklearn", "numpy.random",
# No "import nltk" etc.
):
assert forbidden not in src, (
f"forbidden import {forbidden!r} in exemplar_ingest.py"
)

View file

@ -139,6 +139,8 @@ class TestExhaustiveMatchPattern:
return f"c:{src.source_id}"
case "contemplation":
return f"q:{src.source_id}"
case "exemplar_corpus":
return f"e:{src.source_id}"
case _: # pragma: no cover - exhaustiveness
assert_never(src.kind)
@ -161,9 +163,20 @@ class TestExhaustiveMatchPattern:
)
assert self._describe(src) == "q:frontier_compare"
def test_kinds_sealed_at_four(self) -> None:
def test_covers_exemplar_corpus(self) -> None:
src = ProposalSource(
kind="exemplar_corpus",
source_id="rate_with_currency_v1_digest",
emitted_at_revision="x",
)
assert self._describe(src) == "e:rate_with_currency_v1_digest"
def test_kinds_sealed_at_five(self) -> None:
# ADR-0163.C widened the sealed set with "exemplar_corpus" so
# Phase C admissibility proposals carry typed provenance distinct
# from autonomous contemplation.
assert ALLOWED_KINDS == frozenset(
{"operator", "miner", "curriculum", "contemplation"}
{"operator", "miner", "curriculum", "contemplation", "exemplar_corpus"}
)

View file

@ -0,0 +1,217 @@
"""ADR-0163 Phase C — propose-from-exemplars CLI tests.
Pins:
- the CLI loads a real Phase B JSONL and produces a pending proposal
- --all produces three pending proposals (one per Phase B corpus)
- proposal_id is deterministic across runs with the same corpus_digest
- the CLI does NOT mutate any corpus, pack, recognizer registry, or
eval lane file outside the supplied tmp paths
"""
from __future__ import annotations
import hashlib
import json
import sys
from pathlib import Path
import pytest
import teaching.replay as replay_mod
from teaching.proposals import ProposalLog
_REPO_ROOT = Path(__file__).resolve().parent.parent
_EXEMPLARS = _REPO_ROOT / "teaching" / "admissibility_exemplars"
_ACTIVE_CORPUS = (
_REPO_ROOT / "teaching" / "cognition_chains" / "cognition_chains_v1.jsonl"
)
_GSM8K_TRAIN_REPORT = (
_REPO_ROOT / "evals" / "gsm8k_math" / "train_sample" / "v1" / "report.json"
)
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,
}
@pytest.fixture(autouse=True)
def _stub_eval_lanes(monkeypatch: pytest.MonkeyPatch) -> None:
"""Stub the heavy eval lanes so CLI tests run in milliseconds.
The CLI invokes :func:`teaching.replay.run_admissibility_replay_gate`
via the existing :func:`propose_from_candidate` path. Substituting
the lane runners at module scope is enough; the gate calls them by
name (``_run_capability_axes``, ``_run_gsm8k_train_sample``,
``_run_cognition_public``).
"""
replay_mod._BASELINE_CACHE.clear()
monkeypatch.setattr(replay_mod, "_run_capability_axes", _stub_capability_axes)
monkeypatch.setattr(replay_mod, "_run_gsm8k_train_sample", _stub_gsm8k)
monkeypatch.setattr(replay_mod, "_run_cognition_public", _stub_cognition)
# ---------------------------------------------------------------------------
# In-process CLI invocation
# ---------------------------------------------------------------------------
def _invoke_cli(args: list[str]) -> tuple[int, str, str]:
"""Run the CLI in-process by calling ``core.cli.main``.
Captures argv + stdout/stderr; returns (exit_code, stdout, stderr).
"""
import io
import contextlib
from core import cli as core_cli
saved_argv = sys.argv
saved_stdout = sys.stdout
saved_stderr = sys.stderr
out_buf = io.StringIO()
err_buf = io.StringIO()
try:
sys.argv = ["core", *args]
with (
contextlib.redirect_stdout(out_buf),
contextlib.redirect_stderr(err_buf),
):
try:
code = core_cli.main()
except SystemExit as exc:
code = int(exc.code) if exc.code is not None else 0
finally:
sys.argv = saved_argv
sys.stdout = saved_stdout
sys.stderr = saved_stderr
return code, out_buf.getvalue(), err_buf.getvalue()
def test_cli_single_corpus_produces_pending_proposal(tmp_path: Path) -> None:
log_path = tmp_path / "proposals.jsonl"
code, out, _ = _invoke_cli([
"teaching", "propose-from-exemplars",
str(_EXEMPLARS / "rate_with_currency_v1.jsonl"),
"--log", str(log_path),
"--json",
])
assert code == 0, f"CLI exited {code}; stdout={out!r}"
payload = json.loads(out)
assert len(payload["proposals"]) == 1
p = payload["proposals"][0]
assert p["shape_category"] == "rate_with_currency"
assert p["state"] == "pending"
assert p["replay_equivalent"] is True
assert p["wrong_count_delta"] == 0
# The proposal exists in the log.
log = ProposalLog(log_path)
rec = log.find(p["proposal_id"])
assert rec is not None
assert rec["state"] == "pending"
assert rec["proposal"]["source"]["kind"] == "exemplar_corpus"
def test_cli_all_flag_proposes_three_corpora(tmp_path: Path) -> None:
log_path = tmp_path / "proposals.jsonl"
code, out, _ = _invoke_cli([
"teaching", "propose-from-exemplars",
"--all",
"--log", str(log_path),
"--json",
])
assert code == 0
payload = json.loads(out)
cats = {p["shape_category"] for p in payload["proposals"]}
assert cats == {
"descriptive_setup_no_quantity",
"rate_with_currency",
"temporal_aggregation",
}
for p in payload["proposals"]:
assert p["state"] == "pending"
def test_proposal_id_is_deterministic_for_same_corpus(tmp_path: Path) -> None:
log_a = tmp_path / "log_a.jsonl"
log_b = tmp_path / "log_b.jsonl"
code_a, out_a, _ = _invoke_cli([
"teaching", "propose-from-exemplars",
str(_EXEMPLARS / "rate_with_currency_v1.jsonl"),
"--log", str(log_a),
"--json",
])
code_b, out_b, _ = _invoke_cli([
"teaching", "propose-from-exemplars",
str(_EXEMPLARS / "rate_with_currency_v1.jsonl"),
"--log", str(log_b),
"--json",
])
assert code_a == code_b == 0
pid_a = json.loads(out_a)["proposals"][0]["proposal_id"]
pid_b = json.loads(out_b)["proposals"][0]["proposal_id"]
assert pid_a == pid_b
# ---------------------------------------------------------------------------
# Read-only snapshot — the CLI mutates nothing outside the supplied paths
# ---------------------------------------------------------------------------
def _digest(path: Path) -> str:
if not path.exists():
return ""
return hashlib.sha256(path.read_bytes()).hexdigest()
def _snapshot_paths() -> list[Path]:
"""Files the CLI MUST NOT mutate."""
out: list[Path] = []
out.extend(sorted(_EXEMPLARS.glob("*_v1.jsonl")))
if _ACTIVE_CORPUS.exists():
out.append(_ACTIVE_CORPUS)
if _GSM8K_TRAIN_REPORT.exists():
out.append(_GSM8K_TRAIN_REPORT)
# Capability axis reports are touched by the runners when run via
# write_report; the CLI gate calls build_report() directly so
# report.json must remain byte-identical.
for report in (_REPO_ROOT / "evals" / "math_capability_axes").rglob("v1/report.json"):
out.append(report)
return out
def test_cli_does_not_mutate_input_files(tmp_path: Path) -> None:
snapshot_before = {p: _digest(p) for p in _snapshot_paths()}
log_path = tmp_path / "proposals.jsonl"
code, _, _ = _invoke_cli([
"teaching", "propose-from-exemplars",
"--all",
"--log", str(log_path),
"--json",
])
assert code == 0
snapshot_after = {p: _digest(p) for p in _snapshot_paths()}
for path in snapshot_before:
assert snapshot_before[path] == snapshot_after[path], (
f"CLI mutated read-only file: {path}"
)

View file

@ -0,0 +1,314 @@
"""ADR-0163 Phase C — recognizer_synthesis tests.
Pins:
- synthesize_recognizer is deterministic (same corpus -> same spec bytes)
- synthesize_recognizer is pure (no I/O, no global state)
- per-category canonical_pattern subsumes every seed
- the pattern is NARROWER than a generic any-shape (an out-of-corpus
seed must not match)
- author_notes are honored or surfaced never silently dropped
- the module performs no LLM / embedding / ML import
"""
from __future__ import annotations
import builtins
from pathlib import Path
from typing import Any
import pytest
from evals.refusal_taxonomy.shape_categories import ShapeCategory
from teaching.exemplar_ingest import (
Exemplar,
ExemplarCorpus,
load_exemplar_corpus,
)
from teaching.recognizer_synthesis import (
RecognizerSpec,
synthesize_recognizer,
)
_REPO_ROOT = Path(__file__).resolve().parent.parent
_EXEMPLARS_ROOT = _REPO_ROOT / "teaching" / "admissibility_exemplars"
_ROUND_1: tuple[tuple[str, ShapeCategory], ...] = (
("descriptive_setup_no_quantity_v1.jsonl", ShapeCategory.DESCRIPTIVE_SETUP_NO_QUANTITY),
("temporal_aggregation_v1.jsonl", ShapeCategory.TEMPORAL_AGGREGATION),
("rate_with_currency_v1.jsonl", ShapeCategory.RATE_WITH_CURRENCY),
)
@pytest.fixture(scope="module")
def corpora() -> dict[ShapeCategory, ExemplarCorpus]:
out: dict[ShapeCategory, ExemplarCorpus] = {}
for filename, cat in _ROUND_1:
out[cat] = load_exemplar_corpus(_EXEMPLARS_ROOT / filename)
return out
# ---------------------------------------------------------------------------
# Determinism + purity
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(("_filename", "category"), _ROUND_1)
def test_synthesis_is_deterministic(
_filename: str,
category: ShapeCategory,
corpora: dict[ShapeCategory, ExemplarCorpus],
) -> None:
corpus = corpora[category]
a = synthesize_recognizer(corpus)
b = synthesize_recognizer(corpus)
assert a.canonical_bytes() == b.canonical_bytes()
assert a.spec_digest() == b.spec_digest()
@pytest.mark.parametrize(("_filename", "category"), _ROUND_1)
def test_synthesis_is_pure_no_io(
monkeypatch: pytest.MonkeyPatch,
_filename: str,
category: ShapeCategory,
corpora: dict[ShapeCategory, ExemplarCorpus],
) -> None:
corpus = corpora[category]
real_open = builtins.open
def _no_open(*args: Any, **kwargs: Any) -> Any:
raise AssertionError(
f"synthesize_recognizer opened a file: args={args}"
)
monkeypatch.setattr(builtins, "open", _no_open)
try:
spec = synthesize_recognizer(corpus)
finally:
monkeypatch.setattr(builtins, "open", real_open)
assert isinstance(spec, RecognizerSpec)
# ---------------------------------------------------------------------------
# Subsumption + narrowness
# ---------------------------------------------------------------------------
def _matches(spec: RecognizerSpec, ex: Exemplar) -> bool:
"""Mechanical predicate: does *spec* subsume *ex*?
The recognizer's canonical_pattern is bespoke per category, so the
matcher is bespoke too. Each branch checks every axis the spec
constrains. Used only in tests to assert (a) every seed matches
and (b) an out-of-corpus seed does not.
"""
p = spec.canonical_pattern
graph = ex.expected_graph
if spec.shape_category is ShapeCategory.DESCRIPTIVE_SETUP_NO_QUANTITY:
return (
graph["graph_intent"] == p["graph_intent"]
and graph["outcome"] == p["outcome"]
and len(graph["quantity_anchors"]) == p["quantity_anchor_count"]
)
if spec.shape_category is ShapeCategory.TEMPORAL_AGGREGATION:
if graph["graph_intent"] != p["graph_intent"]:
return False
if graph["outcome"] != p["outcome"]:
return False
anchors = graph["quantity_anchors"]
if not (p["anchor_count_min"] <= len(anchors) <= p["anchor_count_max"]):
return False
observed_units = set(p["observed_window_units"])
observed_quants = set(p["observed_window_quantifiers"])
for a in anchors:
if a["kind"] != p["anchor_kind"]:
return False
if a["window_unit"] not in observed_units:
return False
if a["window_quantifier"] not in observed_quants:
return False
return True
if spec.shape_category is ShapeCategory.RATE_WITH_CURRENCY:
if graph["graph_intent"] != p["graph_intent"]:
return False
if graph["outcome"] != p["outcome"]:
return False
anchors = graph["quantity_anchors"]
if not (p["anchor_count_min"] <= len(anchors) <= p["anchor_count_max"]):
return False
observed_curr = set(p["observed_currency_symbols"])
observed_pu = set(p["observed_per_units"])
observed_ak = set(p["observed_amount_kinds"])
for a in anchors:
if a["kind"] != p["anchor_kind"]:
return False
if a["currency_symbol"] not in observed_curr:
return False
if a["per_unit"] not in observed_pu:
return False
if a["amount_kind"] not in observed_ak:
return False
return True
raise AssertionError(f"no matcher for {spec.shape_category!r}")
@pytest.mark.parametrize(("_filename", "category"), _ROUND_1)
def test_canonical_pattern_subsumes_every_seed(
_filename: str,
category: ShapeCategory,
corpora: dict[ShapeCategory, ExemplarCorpus],
) -> None:
corpus = corpora[category]
spec = synthesize_recognizer(corpus)
for ex in corpus.exemplars:
assert _matches(spec, ex), (
f"{ex.exemplar_id}: synthesized spec does NOT subsume its own seed"
)
def _ex(category: ShapeCategory, graph: dict[str, Any]) -> Exemplar:
return Exemplar(
exemplar_id="out-of-corpus-0001",
shape_category=category,
statement="test",
expected_graph=graph,
provenance={"source": "phase_b_seed", "author": "test", "round": 1, "category_rank": 0},
)
def test_descriptive_pattern_rejects_seed_with_anchor(
corpora: dict[ShapeCategory, ExemplarCorpus],
) -> None:
"""A descriptive-setup recognizer must not match a statement carrying
an anchor that would mean admitting quantitative shapes as setup."""
spec = synthesize_recognizer(corpora[ShapeCategory.DESCRIPTIVE_SETUP_NO_QUANTITY])
fake = _ex(
ShapeCategory.DESCRIPTIVE_SETUP_NO_QUANTITY,
{
"subject": "x",
"quantity_anchors": [
{
"kind": "currency_per_unit_rate",
"currency_symbol": "$",
"amount": "1",
"amount_kind": "integer",
"per_unit": "hour",
"subject_role": "x",
},
],
"graph_intent": "setup",
"outcome": "inadmissible_by_design",
},
)
assert not _matches(spec, fake)
def test_temporal_pattern_rejects_unseen_window_unit(
corpora: dict[ShapeCategory, ExemplarCorpus],
) -> None:
"""If the seeds never carry a millisecond window, the recognizer
must not generalize to it. Phase D's review can widen; synthesis
does not."""
spec = synthesize_recognizer(corpora[ShapeCategory.TEMPORAL_AGGREGATION])
observed_units = set(spec.canonical_pattern["observed_window_units"])
# Find any window unit NOT in the observed set. The Phase B
# vocabulary covers second..year, but seeds may use a subset.
all_units = {"day", "week", "month", "year", "hour", "minute", "second"}
unseen = all_units - observed_units
assert unseen, "no unseen window unit available — corpus covers vocabulary"
fake_unit = sorted(unseen)[0]
fake = _ex(
ShapeCategory.TEMPORAL_AGGREGATION,
{
"subject": "x",
"quantity_anchors": [
{
"kind": "event_count_per_window",
"count_token": "1",
"window_unit": fake_unit,
"window_quantifier": "each",
"subject_role": "x",
},
],
"graph_intent": "aggregate",
"outcome": "admissible",
},
)
assert not _matches(spec, fake), (
f"recognizer wrongly generalized to unseen window_unit={fake_unit!r}"
)
def test_rate_pattern_rejects_unseen_currency(
corpora: dict[ShapeCategory, ExemplarCorpus],
) -> None:
"""Same narrowness rule for currencies: the seeds cite a subset of
{$, £, , ¥}. Currencies outside that subset must not match."""
spec = synthesize_recognizer(corpora[ShapeCategory.RATE_WITH_CURRENCY])
observed = set(spec.canonical_pattern["observed_currency_symbols"])
all_sym = {"$", "£", "", "¥"}
unseen = all_sym - observed
if not unseen:
# Every currency in the vocabulary appeared. Fall back to a
# synthetic currency not in the vocabulary at all.
fake_sym = "" # bitcoin sign — not in _VALID_CURRENCY_SYMBOLS
else:
fake_sym = sorted(unseen)[0]
fake = _ex(
ShapeCategory.RATE_WITH_CURRENCY,
{
"subject": "x",
"quantity_anchors": [
{
"kind": "currency_per_unit_rate",
"currency_symbol": fake_sym,
"amount": "10",
"amount_kind": "integer",
"per_unit": list(spec.canonical_pattern["observed_per_units"])[0],
"subject_role": "x",
},
],
"graph_intent": "rate",
"outcome": "admissible",
},
)
assert not _matches(spec, fake), (
f"recognizer wrongly generalized to unseen currency={fake_sym!r}"
)
# ---------------------------------------------------------------------------
# Author_notes are honored or surfaced — never silently dropped
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(("_filename", "category"), _ROUND_1)
def test_author_notes_surface_in_unresolved_notes(
_filename: str,
category: ShapeCategory,
corpora: dict[ShapeCategory, ExemplarCorpus],
) -> None:
corpus = corpora[category]
spec = synthesize_recognizer(corpus)
unresolved = set(spec.canonical_pattern["unresolved_notes"])
for ex in corpus.exemplars:
note = ex.author_note
if not note:
continue
assert note in unresolved, (
f"{ex.exemplar_id}: author_note silently dropped: {note!r}"
)
def test_module_imports_no_llm_or_ml() -> None:
"""Phase C synthesis is rules-only. No transformer / embedding."""
import teaching.recognizer_synthesis as m
module_file = m.__file__
assert module_file is not None
src = Path(module_file).read_text(encoding="utf-8")
for forbidden in (
"transformers", "torch", "tensorflow", "openai",
"anthropic", "sklearn", "numpy.random",
):
assert forbidden not in src, (
f"forbidden import {forbidden!r} in recognizer_synthesis.py"
)