core/tests/test_exemplar_ingest.py
Shay 08c5e0e82f
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>
2026-05-26 12:26:56 -07:00

211 lines
7.4 KiB
Python

"""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"
)