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>
292 lines
11 KiB
Python
292 lines
11 KiB
Python
"""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",
|
|
]
|