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>
314 lines
11 KiB
Python
314 lines
11 KiB
Python
"""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"
|
|
)
|