core/tests/test_math_frame_ratification.py
Shay d5c91e1ac1 feat(RAT-1): close ratify→runtime gap + first live composition admission
The user's question — "shouldn't we be running it multiple times so
it can learn? or is that part broken?" — exposed that the math
teaching loop's `ratify → admit` closure had been structurally
broken at the connector between operator ratification and runtime
visibility. The handlers wrote source files (compositions/, frames/)
that the runtime loader never read because no compile step
regenerated the runtime artifacts.

This PR fixes the gap end-to-end AND fires the first live composition
admission on the canonical pack.

Modules
-------
- language_packs/compile_pack.py — unified compile step that
  regenerates frames.jsonl + compositions.jsonl + updates
  manifest.{frame,composition}_checksum atomically. Idempotent.

- teaching/math_composition_ratification.py — apply_composition_claim
  now calls compile_pack at end of successful ratification. Closes
  the source-file→runtime-artifact gap.

- teaching/math_frame_ratification.py — same auto-compile wire for
  apply_frame_claim.

- generate/math_candidate_parser.py — CandidateInitial gains optional
  composition_evidence Mapping field. When populated, signals the
  candidate was produced by a registry-gated composition (ADR-0169);
  the value/unit/entity are DERIVED arithmetic over grounded inputs.

- generate/math_candidate_graph.py — new _composed_initial_admissible
  predicate that branches on composition_evidence. Wrong=0 preserved
  by requiring each composition INPUT token (count, amount) to ground
  in source_span literally; the derived value is admitted because the
  arithmetic over grounded inputs is deterministic.

- generate/math_candidate_graph.py — discourse-level prior_subject
  tracking: capture proper-noun subjects from ALL statement sentences
  (including ADR-0136.S.0 context-filler sentences that get filtered
  out before the candidate loop). Without this, "John adopts a dog"
  (no numbers) is dropped and the cross-sentence subject resolver for
  case 0019 sees prior_subject=None.

- generate/recognizer_match.py — all four composition matchers
  (ME-1 currency-per-unit same-sentence, ME-2 cross-sentence, ME-3
  additive, ME-4 subtractive) now populate composition_evidence in
  CandidateInitial. Also added standalone " each " / " apiece " to
  _PER_UNIT_TOKENS so currency_amount detection-only matcher refuses
  per-item costs instead of swallowing them.

CLIs
----
- core teaching compile-pack — explicit operator surface for
  regenerating runtime artifacts. JSON output for CI integration.

- core teaching seed-recognizer — operator surface for seeding a
  RatifiedRecognizer entry in the proposal log for a given
  (shape_category, anchor_kind). Writes created + transition(accepted)
  events directly via ProposalLog._append.

Seeded artifacts (the actual loop closure)
------------------------------------------
- proposals.jsonl: new rat1-seed-48dd2673d6ad673d RatifiedRecognizer
  entry for shape_category=rate_with_currency,
  anchor_kind=currency_per_unit_composition.

- compositions/multiplicative_composition.jsonl: ratified
  "bound(count) × bound(unit_cost)" affirms entry sourced from
  case 0019 evidence.

- compositions.jsonl + manifest.composition_checksum: compiled
  runtime artifact + manifest pin (RAT-1 auto-compile).

Live result on train_sample
---------------------------
- wrong == 0 preserved (3 correct / 47 refused / 0 wrong)
- Case 0050 hazard pin holds (refused)
- public split 150/150 preserved
- Case 0019 sentence 1 ("requires 3 vet appointments, which cost
  $400 each") NOW ADMITS via composition. Previously refused with
  "recognizer matched but produced no injection". The refusal moved
  downstream to sentence 2 (a different currency_amount detection
  bottleneck that is its own follow-up).

This is the first time a composition ratification on the canonical
pack actually reaches the runtime. The flywheel turned one
revolution.

Tests
-----
- tests/test_rat1_end_to_end_admission.py — 4 new live tests:
  composition statement admits on isolated synthetic problem, case
  0019 cross-sentence admission, wrong=0 preserved on train_sample,
  case 0050 hazard pin.

- tests/test_consumption_empty_registry_no_op.py — refactored to use
  isolated synthetic packs (the canonical pack may now carry ratified
  entries).

- tests/test_math_{frame,composition}_ratification.py — updated
  "manifest checksum unchanged" tests to "lexicon checksum
  preserved" semantics: RAT-1 auto-compile may add the new optional
  checksum fields; pre-existing lexicon checksum stays untouched.

Suite results: teaching 93, packs 131 (+4), runtime 20. All green.
2026-05-27 20:09:47 -07:00

472 lines
16 KiB
Python

"""ADR-0168 — FrameClaim ratification handler tests.
Mirrors :mod:`tests.test_math_lexical_ratification` (W2-D) and pins:
1. SAFE_FRAME_CATEGORIES is exactly the ADR-0168 allowlist (4 entries).
2. apply_frame_claim writes a frame entry for a safe category.
3. receipt records before/after sha + evidence_hash.
4. idempotent same-evidence → AlreadyRatified.
5. rejects non-frame sub_type.
6. rejects categories outside SAFE_FRAME_CATEGORIES (wrong=0 hazard).
7. rejects invalid polarity (must be affirms|falsifies).
8. rejects evidence tampering.
9. rejects evidence laundering (source='corpus' is forbidden).
10. case 0050 hazard pin — after ratification, case 0050 still refuses.
11. polarity=falsifies branch records non-opener without admitting.
12. duplicate evidence on second apply appends evidence_hash, not new row.
13. manifest.json checksum is unchanged by frame ratification.
14. alphabetical sort by surface_form preserved across writes.
ADR-0168 §"Decision" + ADR-0168.1 §"Evidence floor" + hazard pins.
"""
from __future__ import annotations
import functools
import hashlib
import json
import shutil
from pathlib import Path
import pytest
from generate.comprehension import lexicon as comprehension_lexicon
from generate.comprehension import lifecycle
from generate.comprehension.audit import AuditRow, audit_problem
from generate.comprehension.state import ReaderRefusal
from teaching.math_evidence import from_audit_row
from teaching.math_frame_ratification import (
AlreadyRatified,
EvidenceLaundering,
EvidenceTampering,
InvalidPolarity,
SAFE_FRAME_CATEGORIES,
WrongClaimSubType,
WrongZeroViolationCandidate,
apply_frame_claim,
)
REPO_ROOT = Path(__file__).resolve().parents[1]
PACK_ROOT = REPO_ROOT / "language_packs" / "data" / "en_core_math_v1"
CASES_PATH = REPO_ROOT / "evals" / "gsm8k_math" / "train_sample" / "v1" / "cases.jsonl"
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def pack_copy(tmp_path: Path) -> Path:
target = tmp_path / "en_core_math_v1"
shutil.copytree(PACK_ROOT, target)
# Ensure the frames/ scaffold exists in the copy.
(target / "frames").mkdir(exist_ok=True)
comprehension_lexicon._CACHE.clear()
lifecycle._get_lexicon.cache_clear()
yield target
comprehension_lexicon._CACHE.clear()
lifecycle._get_lexicon.cache_clear()
def _row(
surface: str,
*,
missing_operator: str = "pre_frame_filler_sentence",
refusal_reason: str = "unexpected_category",
) -> AuditRow:
return AuditRow(
case_id=f"case-{surface}",
sentence_index=0,
token_index=2,
token_text=surface,
recognized_terms=("Mark", "does"),
skipped_frame=None,
missing_operator=missing_operator,
refusal_reason=refusal_reason,
refusal_detail=f"unexpected category for '{surface}'",
)
def _claim(surface: str, *, sub_type: str = "frame"):
return from_audit_row(_row(surface), sub_type) # type: ignore[arg-type]
def _entries(path: Path) -> list[dict]:
if not path.exists():
return []
return [
json.loads(line)
for line in path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
def _manifest_sha(pack_root: Path) -> str:
return hashlib.sha256((pack_root / "manifest.json").read_bytes()).hexdigest()
def _use_pack_for_reader(monkeypatch: pytest.MonkeyPatch, pack_root: Path) -> None:
comprehension_lexicon._CACHE.clear()
@functools.cache
def _tmp_lexicon():
return comprehension_lexicon.load_lexicon(pack_root)
monkeypatch.setattr(lifecycle, "_get_lexicon", _tmp_lexicon)
# ---------------------------------------------------------------------------
# Test 1 — SAFE_FRAME_CATEGORIES is the ADR-0168 allowlist
# ---------------------------------------------------------------------------
def test_safe_frame_categories_is_adr_0168_allowlist() -> None:
"""The four ADR-0168 categories, no more, no less."""
assert SAFE_FRAME_CATEGORIES == frozenset(
{"increment_frame", "decrement_frame", "transfer_frame", "remainder_frame"}
)
# ---------------------------------------------------------------------------
# Test 2 — apply writes a frame entry for a safe category
# ---------------------------------------------------------------------------
def test_apply_frame_claim_writes_entry(pack_copy: Path) -> None:
receipt = apply_frame_claim(
claim=_claim("gives"),
frame_category="transfer_frame",
polarity="affirms",
reviewer="Ada",
pack_root=pack_copy,
)
assert receipt.surface_form == "gives"
assert receipt.frame_category == "transfer_frame"
assert receipt.polarity == "affirms"
assert receipt.is_duplicate_evidence is False
entry = next(
e
for e in _entries(pack_copy / "frames" / "transfer_frame.jsonl")
if e["surface_form"] == "gives"
)
assert entry["frame_category"] == "transfer_frame"
assert entry["polarity"] == "affirms"
assert receipt.evidence_hash in entry["evidence_hashes"]
# ---------------------------------------------------------------------------
# Test 3 — receipt records before/after sha
# ---------------------------------------------------------------------------
def test_receipt_records_before_after_sha(pack_copy: Path) -> None:
receipt = apply_frame_claim(
claim=_claim("gives"),
frame_category="transfer_frame",
polarity="affirms",
reviewer="Ada",
pack_root=pack_copy,
)
assert len(receipt.file_sha256_before) == 64
assert len(receipt.file_sha256_after) == 64
assert receipt.file_sha256_before != receipt.file_sha256_after
assert receipt.evidence_hash == _claim("gives").evidence_hash
# ---------------------------------------------------------------------------
# Test 4 — idempotent same-evidence raises AlreadyRatified
# ---------------------------------------------------------------------------
def test_idempotent_same_evidence_raises_already_ratified(pack_copy: Path) -> None:
claim = _claim("gives")
apply_frame_claim(
claim=claim,
frame_category="transfer_frame",
polarity="affirms",
reviewer="Ada",
pack_root=pack_copy,
)
with pytest.raises(AlreadyRatified, match="already ratified"):
apply_frame_claim(
claim=claim,
frame_category="transfer_frame",
polarity="affirms",
reviewer="Ada",
pack_root=pack_copy,
)
# ---------------------------------------------------------------------------
# Test 5 — rejects non-frame sub_type
# ---------------------------------------------------------------------------
def test_rejects_non_frame_sub_type(pack_copy: Path) -> None:
with pytest.raises(WrongClaimSubType):
apply_frame_claim(
claim=_claim("gives", sub_type="lexical"),
frame_category="transfer_frame",
polarity="affirms",
reviewer="Ada",
pack_root=pack_copy,
)
# ---------------------------------------------------------------------------
# Test 6 — rejects categories outside SAFE_FRAME_CATEGORIES (wrong=0 hazard)
# ---------------------------------------------------------------------------
def test_rejects_unsafe_frame_category(pack_copy: Path) -> None:
with pytest.raises(
WrongZeroViolationCandidate, match="SAFE_FRAME_CATEGORIES"
):
apply_frame_claim(
claim=_claim("gives"),
frame_category="comparison_frame", # not in allowlist
polarity="affirms",
reviewer="Ada",
pack_root=pack_copy,
)
# ---------------------------------------------------------------------------
# Test 7 — rejects invalid polarity
# ---------------------------------------------------------------------------
def test_rejects_invalid_polarity(pack_copy: Path) -> None:
with pytest.raises(InvalidPolarity, match="polarity must be one of"):
apply_frame_claim(
claim=_claim("gives"),
frame_category="transfer_frame",
polarity="maybe", # not affirms/falsifies
reviewer="Ada",
pack_root=pack_copy,
)
# ---------------------------------------------------------------------------
# Test 8 — rejects evidence tampering
# ---------------------------------------------------------------------------
def test_rejects_evidence_tampering(pack_copy: Path) -> None:
claim = _claim("gives")
object.__setattr__(claim, "evidence_hash", "0" * 64)
with pytest.raises(EvidenceTampering):
apply_frame_claim(
claim=claim,
frame_category="transfer_frame",
polarity="affirms",
reviewer="Ada",
pack_root=pack_copy,
)
# ---------------------------------------------------------------------------
# Test 9 — rejects evidence laundering as source='corpus'
# ---------------------------------------------------------------------------
def test_rejects_evidence_laundered_as_corpus(pack_copy: Path) -> None:
"""ADR-0168.1 §'Evidence floor': source='corpus' MUST be rejected."""
with pytest.raises(EvidenceLaundering, match="MUST NOT be laundered"):
apply_frame_claim(
claim=_claim("gives"),
frame_category="transfer_frame",
polarity="affirms",
reviewer="Ada",
pack_root=pack_copy,
evidence_source="corpus", # forbidden
)
# ---------------------------------------------------------------------------
# Test 10 — case 0050 hazard pin: still refused after ratification
# ---------------------------------------------------------------------------
def test_case_0050_remains_refused_after_frame_ratification(
pack_copy: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""After ratifying a safe transfer-frame verb, case 0050 must still refuse.
Case 0050 ("Mark does a gig every other day for 2 weeks. ..." — the
period token at sentence_index=0 with missing_operator=
pre_frame_filler_sentence) is the prototype wrong=0 hazard: a
speculative frame opener here would admit a partial graph. The new
frames/ entries must not open that admission path.
"""
apply_frame_claim(
claim=_claim("gives"),
frame_category="transfer_frame",
polarity="affirms",
reviewer="Ada",
pack_root=pack_copy,
)
_use_pack_for_reader(monkeypatch, pack_copy)
cases = [json.loads(line) for line in CASES_PATH.read_text().splitlines()]
case = next(c for c in cases if c["case_id"] == "gsm8k-train-sample-v1-0050")
result, _rows = audit_problem(case["question"], case_id=case["case_id"])
assert isinstance(result, ReaderRefusal), (
"case 0050 must remain refused after FrameClaim ratification"
)
assert result.sentence_index == 0
# ---------------------------------------------------------------------------
# Test 11 — polarity=falsifies branch records non-opener
# ---------------------------------------------------------------------------
def test_polarity_falsifies_records_non_opener(pack_copy: Path) -> None:
"""A 'falsifies' ratification records the surface as NOT opening the frame.
This is the negative-evidence path: an operator reviewed the audit row
and decided the surface does not in fact open the frame category. The
entry is written to the same source file but with polarity=falsifies
so downstream registry builds can mark the surface as a known non-opener.
"""
receipt = apply_frame_claim(
claim=_claim("along"), # not actually a transfer verb
frame_category="transfer_frame",
polarity="falsifies",
reviewer="Ada",
pack_root=pack_copy,
)
assert receipt.polarity == "falsifies"
entry = next(
e
for e in _entries(pack_copy / "frames" / "transfer_frame.jsonl")
if e["surface_form"] == "along"
)
assert entry["polarity"] == "falsifies"
# affirms + falsifies of the same surface produce distinct entries
receipt2 = apply_frame_claim(
claim=_claim("gave"),
frame_category="transfer_frame",
polarity="affirms",
reviewer="Ada",
pack_root=pack_copy,
)
assert receipt2.polarity == "affirms"
polarities = {
e["polarity"]
for e in _entries(pack_copy / "frames" / "transfer_frame.jsonl")
}
assert {"affirms", "falsifies"} <= polarities
# ---------------------------------------------------------------------------
# Test 12 — duplicate evidence appends to existing row
# ---------------------------------------------------------------------------
def test_duplicate_surface_polarity_with_new_evidence_appends_hash(
pack_copy: Path,
) -> None:
"""ADR-0168.1 §'Idempotency': same claim + new evidence appends hash, not row."""
# First ratification
apply_frame_claim(
claim=_claim("gives"),
frame_category="transfer_frame",
polarity="affirms",
reviewer="Ada",
pack_root=pack_copy,
)
# Second piece of evidence for the same claim — different case_id, so
# the evidence_hash differs even though surface+polarity+category match.
second_row = AuditRow(
case_id="case-gives-2",
sentence_index=1,
token_index=4,
token_text="gives",
recognized_terms=("Bob", "buys"),
skipped_frame=None,
missing_operator="pre_frame_filler_sentence",
refusal_reason="unexpected_category",
refusal_detail="duplicate surface evidence",
)
second_claim = from_audit_row(second_row, "frame")
receipt = apply_frame_claim(
claim=second_claim,
frame_category="transfer_frame",
polarity="affirms",
reviewer="Ada",
pack_root=pack_copy,
)
assert receipt.is_duplicate_evidence is True
rows = [
e
for e in _entries(pack_copy / "frames" / "transfer_frame.jsonl")
if e["surface_form"] == "gives"
]
assert len(rows) == 1, "duplicate evidence must not create a second row"
assert len(rows[0]["evidence_hashes"]) == 2
# ---------------------------------------------------------------------------
# Test 13 — manifest.json checksum unchanged
# ---------------------------------------------------------------------------
def test_lexicon_checksum_preserved_by_frame_ratification(pack_copy: Path) -> None:
"""RAT-1 — the lexicon ``checksum`` field is preserved across frame
ratification. The manifest may gain a ``frame_checksum`` field
(auto-compile per RAT-1), but the pre-existing lexicon checksum
bytes are untouched.
"""
manifest_before = json.loads((pack_copy / "manifest.json").read_bytes())
declared_before = manifest_before["checksum"]
apply_frame_claim(
claim=_claim("gives"),
frame_category="transfer_frame",
polarity="affirms",
reviewer="Ada",
pack_root=pack_copy,
)
manifest_after = json.loads((pack_copy / "manifest.json").read_bytes())
assert manifest_after["checksum"] == declared_before, (
"lexicon checksum must not change during frame ratification"
)
# RAT-1: frame_checksum may now be present (auto-compile).
if "frame_checksum" in manifest_after:
assert isinstance(manifest_after["frame_checksum"], str)
# ---------------------------------------------------------------------------
# Test 14 — alphabetical sort preserved across multiple writes
# ---------------------------------------------------------------------------
def test_alphabetical_sort_preserved(pack_copy: Path) -> None:
for surface in ("transfers", "gives", "ceded"):
apply_frame_claim(
claim=_claim(surface),
frame_category="transfer_frame",
polarity="affirms",
reviewer="Ada",
pack_root=pack_copy,
)
surfaces = [
e["surface_form"]
for e in _entries(pack_copy / "frames" / "transfer_frame.jsonl")
]
assert surfaces == sorted(surfaces)