core/teaching/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

367 lines
13 KiB
Python

"""ADR-0168 — FrameClaim ratification into reviewed math frame mappings.
This module is the explicit post-review mutation boundary for math-domain
frame-opener evidence. It edits only per-category source files under
``language_packs/data/en_core_math_v1/frames/``; it does not regenerate the
compiled lexicon and therefore does not rewrite the pack manifest checksum.
Mirrors :mod:`teaching.math_lexical_ratification` (W2-D) but lifts the safe
surface from drain-class lexical entries to allowlisted frame categories.
Hard rules (ADR-0168 §"Decision"):
- ``SAFE_FRAME_CATEGORIES`` is the only sanctioned surface — no other
categories may be ratified through this handler.
- case 0050 hazard pin: after any frame ratification, GSM8K train-sample
case 0050 must still refuse (no speculative pre-frame-filler admission).
- evidence pointers MUST carry ``source="math_audit"`` — never ``"corpus"``.
- ``polarity in {"affirms", "falsifies"}``; the falsifies branch records the
surface as a non-opener and never appends an opener lemma.
"""
from __future__ import annotations
import hashlib
import json
import re
import string
from dataclasses import dataclass
from datetime import date
from pathlib import Path
from typing import Final
from teaching.math_evidence import MathReaderRefusalEvidence, from_audit_row
# ---------------------------------------------------------------------------
# Allowlist + constants
# ---------------------------------------------------------------------------
SAFE_FRAME_CATEGORIES: Final[frozenset[str]] = frozenset(
{
"increment_frame",
"decrement_frame",
"transfer_frame",
"remainder_frame",
}
)
_VALID_POLARITIES: Final[frozenset[str]] = frozenset({"affirms", "falsifies"})
_REVIEWER_RE: Final[re.Pattern[str]] = re.compile(r"[^a-z0-9_-]+")
_TRIM_PUNCTUATION: Final[str] = string.punctuation
# ---------------------------------------------------------------------------
# Error hierarchy (mirror W2-D)
# ---------------------------------------------------------------------------
class RatificationError(ValueError):
"""Base class for frame ratification rejections."""
class WrongClaimSubType(RatificationError):
"""Raised when a non-frame evidence record reaches this handler."""
class WrongZeroViolationCandidate(RatificationError):
"""Raised when a ratification could open a wrong>0 admission path."""
class AlreadyRatified(RatificationError):
"""Raised when the target surface is already covered by the source file."""
class EvidenceTampering(RatificationError):
"""Raised when the evidence hash no longer matches canonical bytes."""
class UnknownCategory(RatificationError):
"""Raised when the requested category is not a known frame source."""
class InvalidPolarity(RatificationError):
"""Raised when polarity is not in :data:`_VALID_POLARITIES`."""
class EvidenceLaundering(RatificationError):
"""Raised when audit evidence is presented as cognition corpus evidence.
ADR-0168.1 §"Evidence floor": math-audit evidence must never be
serialised with ``source="corpus"``; that would impersonate ADR-0057's
cognition corpus evidence floor.
"""
# ---------------------------------------------------------------------------
# Receipt
# ---------------------------------------------------------------------------
@dataclass(frozen=True, slots=True)
class FrameRatificationReceipt:
target_file: str
surface_form: str
frame_category: str
polarity: str
provenance: str
file_sha256_before: str
file_sha256_after: str
evidence_hash: str
is_duplicate_evidence: bool
# ---------------------------------------------------------------------------
# Path / file helpers
# ---------------------------------------------------------------------------
def _repo_root() -> Path:
here = Path(__file__).resolve()
for parent in here.parents:
if (parent / "pyproject.toml").exists() or (parent / "setup.cfg").exists():
return parent
return here.parents[1]
def _default_pack_root() -> Path:
return _repo_root() / "language_packs" / "data" / "en_core_math_v1"
def _sha256_file(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def _normalise_surface(surface: str) -> str:
return surface.lower().strip(_TRIM_PUNCTUATION)
def _reviewer_slug(reviewer: str) -> str:
slug = _REVIEWER_RE.sub("_", reviewer.lower()).strip("_-")
if not slug:
raise RatificationError("reviewer must contain at least one safe character")
return slug
def _read_entries(path: Path) -> list[dict[str, object]]:
entries: list[dict[str, object]] = []
for line_number, line in enumerate(
path.read_text(encoding="utf-8").splitlines(), start=1
):
if not line.strip():
continue
record = json.loads(line)
if not isinstance(record.get("surface_form"), str):
raise RatificationError(
f"{path}: line {line_number} missing string surface_form"
)
if not isinstance(record.get("frame_category"), str):
raise RatificationError(
f"{path}: line {line_number} missing string frame_category"
)
if not isinstance(record.get("polarity"), str):
raise RatificationError(
f"{path}: line {line_number} missing string polarity"
)
entries.append(record)
return entries
def _write_entries(path: Path, entries: list[dict[str, object]]) -> None:
ordered = sorted(entries, key=lambda item: str(item["surface_form"]))
lines = [
json.dumps(entry, ensure_ascii=False, sort_keys=False) for entry in ordered
]
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
# ---------------------------------------------------------------------------
# Evidence validation
# ---------------------------------------------------------------------------
def _validate_evidence(claim: MathReaderRefusalEvidence) -> None:
"""Verify sub_type and tamper-resistance of evidence record."""
if claim.sub_type != "frame":
raise WrongClaimSubType(
f"Frame ratification requires sub_type='frame'; got {claim.sub_type!r}"
)
recomputed = from_audit_row(
claim.audit_row,
claim.sub_type,
claim_signature=claim.claim_signature,
)
if recomputed.evidence_hash != claim.evidence_hash:
raise EvidenceTampering(
"MathReaderRefusalEvidence hash does not match canonical audit row bytes"
)
def _check_no_corpus_laundering(evidence_source: str) -> None:
"""ADR-0168.1 §'Evidence floor' — audit evidence must declare math_audit.
Fail loudly if a caller hands us an evidence pointer that claims to be
cognition corpus evidence; that would impersonate the ADR-0057 floor.
"""
if evidence_source == "corpus":
raise EvidenceLaundering(
"audit evidence MUST NOT be laundered as source='corpus'; "
"use source='math_audit' (ADR-0168.1 §'Evidence floor')"
)
# ---------------------------------------------------------------------------
# Apply
# ---------------------------------------------------------------------------
def apply_frame_claim(
*,
claim: MathReaderRefusalEvidence,
frame_category: str,
polarity: str,
reviewer: str,
pack_root: Path | None = None,
evidence_source: str = "math_audit",
) -> FrameRatificationReceipt:
"""Apply a reviewed frame claim to a math frame source file.
Preconditions:
- ``claim.sub_type == "frame"`` (W2-D analog of LexicalClaim sub_type pin).
- ``frame_category`` ∈ :data:`SAFE_FRAME_CATEGORIES`.
- ``polarity`` ∈ ``{"affirms", "falsifies"}``.
- ``evidence_source == "math_audit"``; ``"corpus"`` raises
:class:`EvidenceLaundering` per ADR-0168.1.
- ``claim.evidence_hash`` matches a recomputation from ``claim.audit_row``.
The write target is ``{pack_root}/frames/{frame_category}.jsonl``.
Entries are sorted alphabetically by ``surface_form``. Each entry
records ``(surface_form, frame_category, polarity, provenance,
evidence_hash)`` so the polarity decision is auditable and
falsification can supersede an earlier affirmation only through an
explicit reviewed re-apply.
The compiled ``lexicon.jsonl`` and ``manifest.json`` are intentionally
not regenerated.
"""
# Trust-boundary checks (ADR-0168.1 §'Evidence floor' before any I/O).
_check_no_corpus_laundering(evidence_source)
if evidence_source != "math_audit":
raise EvidenceLaundering(
f"evidence_source must be 'math_audit'; got {evidence_source!r}"
)
if polarity not in _VALID_POLARITIES:
raise InvalidPolarity(
f"polarity must be one of {sorted(_VALID_POLARITIES)!r}; got {polarity!r}"
)
_validate_evidence(claim)
if frame_category not in SAFE_FRAME_CATEGORIES:
raise WrongZeroViolationCandidate(
f"frame_category {frame_category!r} is outside SAFE_FRAME_CATEGORIES="
f"{sorted(SAFE_FRAME_CATEGORIES)!r}; FrameClaim ratification is allowlist-only"
)
root = (pack_root if pack_root is not None else _default_pack_root()).resolve()
source_dir = root / "frames"
if not source_dir.exists():
raise UnknownCategory(
f"frame source directory does not exist: {source_dir}; "
"FrameClaim handler requires a reviewed frames/ tree"
)
target_file = source_dir / f"{frame_category}.jsonl"
if not target_file.exists():
# Initialise as empty source file rather than fail — the .gitkeep
# scaffold guarantees the parent directory; first ratification for a
# never-seen safe category seeds the file deterministically.
target_file.write_text("", encoding="utf-8")
surface_form = _normalise_surface(claim.audit_row.token_text)
if not surface_form:
raise WrongZeroViolationCandidate(
"empty frame surface cannot be ratified"
)
provenance = (
f"adr_0168_frame_ratified_{_reviewer_slug(reviewer)}_"
f"{date.today().isoformat()}"
)
before = _sha256_file(target_file)
entries = _read_entries(target_file)
target_relative = (
str(target_file.relative_to(_repo_root()))
if target_file.is_relative_to(_repo_root())
else str(target_file)
)
# Idempotency: identical (surface_form, frame_category, polarity,
# evidence_hash) is a no-op AlreadyRatified. Evidence-hash-only
# duplication (same surface ratified for a second time by *different*
# evidence) appends evidence per ADR-0168.1 §"Idempotency" path #1.
matching = [
e
for e in entries
if str(e["surface_form"]) == surface_form
and str(e["frame_category"]) == frame_category
and str(e["polarity"]) == polarity
]
is_duplicate_evidence = False
if matching:
existing = matching[0]
existing_evidence: list[str] = list(existing.get("evidence_hashes", [])) # type: ignore[arg-type]
if claim.evidence_hash in existing_evidence:
raise AlreadyRatified(
f"frame claim ({surface_form!r}, {frame_category!r}, {polarity!r}) "
f"is already ratified by evidence_hash={claim.evidence_hash}"
)
existing_evidence.append(claim.evidence_hash)
existing["evidence_hashes"] = sorted(set(existing_evidence))
is_duplicate_evidence = True
else:
entries.append(
{
"surface_form": surface_form,
"frame_category": frame_category,
"polarity": polarity,
"provenance": provenance,
"evidence_hashes": [claim.evidence_hash],
}
)
_write_entries(target_file, entries)
after = _sha256_file(target_file)
# RAT-1 — close the ratify→runtime gap (mirrors composition handler).
from language_packs.compile_pack import compile_pack
compile_pack(root)
return FrameRatificationReceipt(
target_file=target_relative,
surface_form=surface_form,
frame_category=frame_category,
polarity=polarity,
provenance=provenance,
file_sha256_before=before,
file_sha256_after=after,
evidence_hash=claim.evidence_hash,
is_duplicate_evidence=is_duplicate_evidence,
)
__all__ = [
"AlreadyRatified",
"EvidenceLaundering",
"EvidenceTampering",
"FrameRatificationReceipt",
"InvalidPolarity",
"RatificationError",
"SAFE_FRAME_CATEGORIES",
"UnknownCategory",
"WrongClaimSubType",
"WrongZeroViolationCandidate",
"apply_frame_claim",
]