Unblocks the four Phase B round-2 exemplar corpora (PR #306) so they can flow through `core teaching propose-from-exemplars`. The corpora were committed in #306 but Phase C's ingest validator + synthesizer were hard-coded to round-1 categories; this PR closes that gap. Extends three modules with the three new categories (discrete_count_statement, multiplicative_aggregation, currency_amount): - teaching/exemplar_ingest.py — per-category validator dispatch + _SUPPORTED_CATEGORIES. The file-stem rule loosens from exact ``<category>_v1`` to ``<category>_v<N>`` so the temporal_aggregation v2 widening from #306 ingests. - teaching/recognizer_synthesis.py — per-category synthesizers following the same observed_*-set + coverage-histogram pattern as round 1. Determinism, narrowness rule (narrower-not-broader), rules-only — same discipline. - generate/recognizer_match.py — per-category matchers shipped as DETECTION-ONLY (return empty parsed_anchors). Consistent with Phase D's current skip-only wiring (PR #302). Real value extraction lands when Phase D.2 plumbs parsed_anchors into the solver; until then, detection-only is the right shape and preserves wrong=0 by construction. graph_intent Literal expanded to include "count" and "amount". Test updates: - tests/test_exemplar_ingest.py: extend _ROUND_1 with _ROUND_2; test_list_corpora_loads_every_round_1_file now asserts every committed corpus (round 1 + round 2) loads. - tests/test_recognizer_registry.py: rename + repair test_live_proposal_log_has_phase_c_pending_proposals → test_live_proposal_log_has_phase_c_proposals. The original asserted state=="pending"; PR #304 ratified the three, so the test now asserts state=="accepted" and registry length matches. Pre-existing failure on main, fixed here. Validation: - 132 passed across exemplar_ingest, recognizer_synthesis, recognizer_match, recognizer_registry, candidate_graph_wiring, admissibility_exemplars, refusal_taxonomy_lane, admissibility_replay_gate - 222 capability-axis tests passed / 2 pre-existing main failures / 3 skipped — G1..G5 + S1 wrong=0 invariant intact - 67 smoke passed - End-to-end CLI sanity check: `core teaching propose-from-exemplars teaching/admissibility_exemplars/discrete_count_statement_v1.jsonl --log /tmp/test.jsonl` produced proposal_id 8c7645b4..., state pending, replay_equivalent=True, wrong_count_delta=0 Empirical projection: of 47 still-refused GSM8K train_sample statements, ~22 match the discrete_count_statement recognizer, ~2 match multiplicative_aggregation, plus 3 rate_with_currency + 3 temporal_aggregation + 18 descriptive_setup_no_quantity recognized under the existing round-1 wiring. After operator ratifies round-2 proposals, the candidate-graph skip-only wiring will drop those sentences from the math state and a meaningful lift is projected. wrong=0 preserved at every level by Phase D's skip-only construction. Scope: enables the round-2 pipeline; does NOT ratify anything; does NOT modify generate/math_candidate_graph.py. Operator runs propose-from-exemplars + review --accept after merge.
223 lines
7.9 KiB
Python
223 lines
7.9 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),
|
|
)
|
|
|
|
# ADR-0163.B.2 — round-2 corpora present on main.
|
|
_ROUND_2 = (
|
|
("discrete_count_statement_v1.jsonl", ShapeCategory.DISCRETE_COUNT_STATEMENT),
|
|
("multiplicative_aggregation_v1.jsonl", ShapeCategory.MULTIPLICATIVE_AGGREGATION),
|
|
("currency_amount_v1.jsonl", ShapeCategory.CURRENCY_AMOUNT),
|
|
("temporal_aggregation_v2.jsonl", ShapeCategory.TEMPORAL_AGGREGATION),
|
|
)
|
|
_ALL_CORPORA = _ROUND_1 + _ROUND_2
|
|
|
|
|
|
@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}
|
|
# After ADR-0163.B.2, round-2 categories also load. The discriminator
|
|
# the test pins is "every committed corpus loads"; round 1 is a subset.
|
|
expected = {cat for _, cat in _ALL_CORPORA}
|
|
assert cats == expected
|
|
# 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"
|
|
)
|