From 981d764810f2e317917f062a9f62c2181a8e532d Mon Sep 17 00:00:00 2001 From: Shay Date: Wed, 27 May 2026 12:25:49 -0700 Subject: [PATCH] feat(ADR-0172/W1): MathReaderRefusalShapeProposal schema (#380) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New module `teaching/math_contemplation_proposal.py` defines the `MathReaderRefusalShapeProposal` dataclass — the math-domain analog of `TeachingChainProposal` for the Tier-1 contemplation corridor. - `build_proposal` enforces all seven invariants: math domain, ShapeCategory enum membership, ≥2 evidence pointers, valid ChangeKind Literal, JSON- serializable payload, ≥40-char wrong_zero_assertion, and non-None reasoning_trace with a non-empty trace_id. - `canonical_bytes` / `compute_proposal_id` produce stable sha256-based IDs; evidence reduced to evidence_hash, trace to trace_id for stability. - `ReasoningTrace` imported under TYPE_CHECKING only (W0/A1 not yet merged); duck-typed at runtime via trace_id attribute. - 16 tests cover all eight brief obligations plus freeze and sensitivity checks. - `core test --suite teaching -q` green (17 passed). --- teaching/math_contemplation_proposal.py | 305 +++++++++++++++++++++++ tests/test_adr_0172_w1_shape_proposal.py | 251 +++++++++++++++++++ 2 files changed, 556 insertions(+) create mode 100644 teaching/math_contemplation_proposal.py create mode 100644 tests/test_adr_0172_w1_shape_proposal.py diff --git a/teaching/math_contemplation_proposal.py b/teaching/math_contemplation_proposal.py new file mode 100644 index 00000000..ee127190 --- /dev/null +++ b/teaching/math_contemplation_proposal.py @@ -0,0 +1,305 @@ +"""ADR-0172 Tier 1 / W1 — MathReaderRefusalShapeProposal schema. + +Mirror of :class:`teaching.proposals.TeachingChainProposal` for the +math-domain contemplation corridor. Each proposal carries: + +- a :class:`~evals.refusal_taxonomy.shape_categories.ShapeCategory` drawn + from the refusal taxonomy; +- ≥2 :class:`~teaching.math_evidence.MathReaderRefusalEvidence` pointers as + evidence floor; +- a ``proposed_change_kind`` discriminating the structural change class; +- a ``wrong_zero_assertion`` (≥40 chars) pinning the non-regression claim; +- a ``reasoning_trace`` (W0 :class:`ReasoningTrace`) carrying the + contemplation chain. + +Trust boundary: schema-only module. No filesystem I/O, no teaching-store +writes, no runtime pipeline hooks. All validation is in :func:`build_proposal`. + +W0 dependency: ``ReasoningTrace`` is imported under ``TYPE_CHECKING`` until +``teaching/math_reasoning_trace.py`` (A1 branch) merges. At runtime the +field is accepted as an opaque object; ``build_proposal`` validates it is +not ``None`` and carries a non-empty ``trace_id`` attribute. +""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Literal + +from evals.refusal_taxonomy.shape_categories import ShapeCategory +from teaching.math_evidence import MathReaderRefusalEvidence + +if TYPE_CHECKING: + from teaching.math_reasoning_trace import ReasoningTrace + + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +ChangeKind = Literal[ + "matcher_extension", + "injector_sub_shape", + "vocabulary_addition", + "frame_reclassification", +] + +_VALID_CHANGE_KINDS: frozenset[str] = frozenset({ + "matcher_extension", + "injector_sub_shape", + "vocabulary_addition", + "frame_reclassification", +}) + +_WRONG_ZERO_MIN_LEN: int = 40 + + +# --------------------------------------------------------------------------- +# Schema +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class MathReaderRefusalShapeProposal: + """One proposed structural change for a math-reader refusal shape. + + Construct via :func:`build_proposal` — do not instantiate directly + (proposal_id is content-derived and requires hash computation). + + Fields + ------ + proposal_id: + ``sha256(canonical_bytes(...)).hexdigest()`` over all other fields. + domain: + Always ``"math"`` — Literal pin enforced by :func:`build_proposal`. + shape_category: + A ratified :class:`ShapeCategory` enum member. + structural_commonality: + Human-readable description of the shared structural pattern among + the evidence rows. + evidence_pointers: + ≥2 :class:`MathReaderRefusalEvidence` records; evidence floor mirrors + ADR-0057's corpus-pointer requirement. + proposed_change_kind: + One of the four Tier-1 change classes. + proposed_change_payload: + JSON-serializable payload discriminated by ``proposed_change_kind``. + wrong_zero_assertion: + ≥40-char natural-language statement pinning the wrong=0 invariant. + replay_equivalence_hash: + ``sha256`` digest of the replay-equivalence gate output (mirrors + :class:`teaching.proposals.ReplayEvidence` contract). + reasoning_trace: + W0 :class:`ReasoningTrace` carrying the contemplation derivation. + Mandatory — ``None`` is rejected by :func:`build_proposal`. + """ + + proposal_id: str + domain: Literal["math"] + shape_category: ShapeCategory + structural_commonality: str + evidence_pointers: tuple[MathReaderRefusalEvidence, ...] + proposed_change_kind: ChangeKind + proposed_change_payload: object + wrong_zero_assertion: str + replay_equivalence_hash: str + reasoning_trace: Any # ReasoningTrace; symbolic until A1 merges + + +# --------------------------------------------------------------------------- +# Canonical-bytes serialization +# --------------------------------------------------------------------------- + + +def _serialise_evidence_pointer(ev: MathReaderRefusalEvidence) -> str: + """Reduce an evidence record to its stable content hash.""" + return ev.evidence_hash + + +def _serialise_shape_category(sc: ShapeCategory) -> str: + return sc.value + + +def _serialise_reasoning_trace(trace: Any) -> str: + """Extract trace_id from any object that carries it.""" + trace_id = getattr(trace, "trace_id", None) + if not isinstance(trace_id, str) or not trace_id: + raise ValueError( + "reasoning_trace must have a non-empty str trace_id attribute" + ) + return trace_id + + +def canonical_bytes(proposal: MathReaderRefusalShapeProposal) -> bytes: + """Return deterministic canonical bytes over all fields except proposal_id. + + Stable JSON (sorted keys, no whitespace, UTF-8). evidence_pointers are + reduced to their evidence_hash digests; reasoning_trace is reduced to its + trace_id. proposed_change_payload must be JSON-serializable (validated + by :func:`build_proposal` before this function is called). + """ + payload: dict[str, Any] = { + "domain": proposal.domain, + "evidence_pointers": sorted( + _serialise_evidence_pointer(ev) for ev in proposal.evidence_pointers + ), + "proposed_change_kind": proposal.proposed_change_kind, + "proposed_change_payload": proposal.proposed_change_payload, + "reasoning_trace_id": _serialise_reasoning_trace(proposal.reasoning_trace), + "replay_equivalence_hash": proposal.replay_equivalence_hash, + "shape_category": _serialise_shape_category(proposal.shape_category), + "structural_commonality": proposal.structural_commonality, + "wrong_zero_assertion": proposal.wrong_zero_assertion, + } + return json.dumps( + payload, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + + +def compute_proposal_id( + *, + domain: Literal["math"], + shape_category: ShapeCategory, + structural_commonality: str, + evidence_pointers: tuple[MathReaderRefusalEvidence, ...], + proposed_change_kind: ChangeKind, + proposed_change_payload: object, + wrong_zero_assertion: str, + replay_equivalence_hash: str, + reasoning_trace: Any, +) -> str: + """Hash all content fields to produce a stable proposal_id. + + Uses a temporary placeholder proposal_id so that :func:`canonical_bytes` + can operate on a fully-formed dataclass without the chicken-and-egg + problem of hashing the id field itself. + """ + placeholder = MathReaderRefusalShapeProposal( + proposal_id="", + domain=domain, + shape_category=shape_category, + structural_commonality=structural_commonality, + evidence_pointers=evidence_pointers, + proposed_change_kind=proposed_change_kind, + proposed_change_payload=proposed_change_payload, + wrong_zero_assertion=wrong_zero_assertion, + replay_equivalence_hash=replay_equivalence_hash, + reasoning_trace=reasoning_trace, + ) + raw = canonical_bytes(placeholder) + return hashlib.sha256(raw).hexdigest() + + +# --------------------------------------------------------------------------- +# Factory +# --------------------------------------------------------------------------- + + +def build_proposal( + *, + domain: Literal["math"] = "math", + shape_category: ShapeCategory, + structural_commonality: str, + evidence_pointers: tuple[MathReaderRefusalEvidence, ...], + proposed_change_kind: str, + proposed_change_payload: object, + wrong_zero_assertion: str, + replay_equivalence_hash: str, + reasoning_trace: Any, +) -> MathReaderRefusalShapeProposal: + """Build a :class:`MathReaderRefusalShapeProposal` with all invariants enforced. + + Raises ``ValueError`` on any violation; the caller must fix the inputs. + + Invariants + ---------- + 1. ``domain`` must be ``"math"``. + 2. ``shape_category`` must be a :class:`ShapeCategory` enum member. + 3. ``len(evidence_pointers) >= 2`` (evidence floor). + 4. ``proposed_change_kind`` in :data:`_VALID_CHANGE_KINDS`. + 5. ``proposed_change_payload`` must be JSON-serializable. + 6. ``len(wrong_zero_assertion.strip()) >= _WRONG_ZERO_MIN_LEN``. + 7. ``reasoning_trace`` is not ``None`` and carries a non-empty ``trace_id``. + """ + if domain != "math": + raise ValueError(f"domain must be 'math'; got {domain!r}") + + if not isinstance(shape_category, ShapeCategory): + raise ValueError( + f"shape_category must be a ShapeCategory enum member; got {shape_category!r}" + ) + + if len(evidence_pointers) < 2: + raise ValueError( + f"evidence_pointers requires ≥2 entries; got {len(evidence_pointers)}" + ) + + if proposed_change_kind not in _VALID_CHANGE_KINDS: + raise ValueError( + f"proposed_change_kind {proposed_change_kind!r} is not a valid ChangeKind; " + f"allowed: {sorted(_VALID_CHANGE_KINDS)}" + ) + + try: + json.dumps(proposed_change_payload, ensure_ascii=False, separators=(",", ":")) + except (TypeError, ValueError) as exc: + raise ValueError( + f"proposed_change_payload is not JSON-serializable: {exc}" + ) from exc + + if not wrong_zero_assertion or len(wrong_zero_assertion.strip()) < _WRONG_ZERO_MIN_LEN: + raise ValueError( + f"wrong_zero_assertion must be ≥{_WRONG_ZERO_MIN_LEN} chars (non-empty); " + f"got {len(wrong_zero_assertion)!r}" + ) + + if reasoning_trace is None: + raise ValueError("reasoning_trace is required and must not be None") + + # Validate trace_id accessibility (also validates the serialisation path). + trace_id = getattr(reasoning_trace, "trace_id", None) + if not isinstance(trace_id, str) or not trace_id: + raise ValueError( + "reasoning_trace must carry a non-empty str trace_id attribute" + ) + + resolved_kind: ChangeKind = proposed_change_kind # type: ignore[assignment] + + pid = compute_proposal_id( + domain=domain, + shape_category=shape_category, + structural_commonality=structural_commonality, + evidence_pointers=evidence_pointers, + proposed_change_kind=resolved_kind, + proposed_change_payload=proposed_change_payload, + wrong_zero_assertion=wrong_zero_assertion, + replay_equivalence_hash=replay_equivalence_hash, + reasoning_trace=reasoning_trace, + ) + + return MathReaderRefusalShapeProposal( + proposal_id=pid, + domain=domain, + shape_category=shape_category, + structural_commonality=structural_commonality, + evidence_pointers=tuple(evidence_pointers), + proposed_change_kind=resolved_kind, + proposed_change_payload=proposed_change_payload, + wrong_zero_assertion=wrong_zero_assertion, + replay_equivalence_hash=replay_equivalence_hash, + reasoning_trace=reasoning_trace, + ) + + +__all__ = [ + "ChangeKind", + "MathReaderRefusalShapeProposal", + "build_proposal", + "canonical_bytes", + "compute_proposal_id", +] diff --git a/tests/test_adr_0172_w1_shape_proposal.py b/tests/test_adr_0172_w1_shape_proposal.py new file mode 100644 index 00000000..68446356 --- /dev/null +++ b/tests/test_adr_0172_w1_shape_proposal.py @@ -0,0 +1,251 @@ +"""ADR-0172 Tier 1 / W1 — MathReaderRefusalShapeProposal schema tests. + +Verifies the eight obligations specified in the ADR-0172 brief: + +1. evidence floor: ≥2 evidence rows required. +2. canonical_bytes stability across independent calls. +3. proposal_id determinism. +4. change_kind Literal enforced. +5. JSON-serializable payload enforced. +6. wrong_zero_assertion non-empty (≥40 chars) enforced. +7. reasoning_trace required (None rejected). +8. all four change_kinds round-trip cleanly. + +W0 dependency: ``teaching/math_reasoning_trace.py`` (A1 branch) has not +landed yet. Tests use a minimal stub that exposes the ``trace_id`` duck- +typing contract used by :func:`build_proposal`. +""" + +from __future__ import annotations + +import dataclasses +import hashlib +import json +from typing import Any + +import pytest + +from evals.refusal_taxonomy.shape_categories import ShapeCategory +from generate.comprehension.audit import AuditRow +from teaching.math_evidence import MathReaderRefusalEvidence, from_audit_row +from teaching.math_contemplation_proposal import ( + MathReaderRefusalShapeProposal, + build_proposal, + canonical_bytes, +) + + +# --------------------------------------------------------------------------- +# Stubs — W0 ReasoningTrace not yet merged; duck-type the trace_id contract. +# --------------------------------------------------------------------------- + + +class _StubReasoningTrace: + """Minimal stand-in for teaching.math_reasoning_trace.ReasoningTrace. + + Carries only the attributes accessed by :func:`build_proposal` and + :func:`canonical_bytes`. Replaced by the real type when A1 lands. + """ + + def __init__(self, trace_id: str) -> None: + self.trace_id = trace_id + + +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + + +def _make_audit_row(case_id: str = "test-001", token_text: str = "stub") -> AuditRow: + return AuditRow( + case_id=case_id, + sentence_index=0, + token_index=0, + token_text=token_text, + recognized_terms=(), + skipped_frame=None, + missing_operator="lexicon_entry", + refusal_reason="unknown_word", + refusal_detail=f"unknown word: {token_text}", + ) + + +def _make_evidence( + case_id: str = "test-001", + token_text: str = "stub", +) -> MathReaderRefusalEvidence: + return from_audit_row(_make_audit_row(case_id=case_id, token_text=token_text), "lexical") + + +def _two_evidences() -> tuple[MathReaderRefusalEvidence, ...]: + return ( + _make_evidence("ev-001", "alpha"), + _make_evidence("ev-002", "beta"), + ) + + +_VALID_TRACE = _StubReasoningTrace("a" * 64) +_VALID_ASSERTION = "This injector does not admit case 0050 or any ambiguous shape, preserving wrong=0." + + +def _build(**overrides: Any) -> MathReaderRefusalShapeProposal: + """Build a valid proposal, optionally overriding fields.""" + kwargs: dict[str, Any] = dict( + shape_category=ShapeCategory.RATE_WITH_CURRENCY, + structural_commonality="subject earns currency per time-unit", + evidence_pointers=_two_evidences(), + proposed_change_kind="injector_sub_shape", + proposed_change_payload={"narrow_form": "$ per "}, + wrong_zero_assertion=_VALID_ASSERTION, + replay_equivalence_hash="b" * 64, + reasoning_trace=_VALID_TRACE, + ) + kwargs.update(overrides) + return build_proposal(**kwargs) + + +# --------------------------------------------------------------------------- +# Test 1 — evidence floor +# --------------------------------------------------------------------------- + + +def test_minimum_two_evidence_rows() -> None: + """Passing a single evidence row raises ValueError.""" + single = (_make_evidence("ev-only", "x"),) + with pytest.raises(ValueError, match="≥2"): + _build(evidence_pointers=single) + + +# --------------------------------------------------------------------------- +# Test 2 — canonical_bytes stability +# --------------------------------------------------------------------------- + + +def test_canonical_bytes_stable() -> None: + """Same inputs produce byte-identical canonical_bytes across two calls.""" + p1 = _build() + p2 = _build() + assert canonical_bytes(p1) == canonical_bytes(p2) + + +# --------------------------------------------------------------------------- +# Test 3 — proposal_id determinism +# --------------------------------------------------------------------------- + + +def test_proposal_id_determinism() -> None: + """Two independently built proposals from identical inputs share a proposal_id.""" + p1 = _build() + p2 = _build() + assert p1.proposal_id == p2.proposal_id + assert p1.proposal_id == hashlib.sha256(canonical_bytes(p1)).hexdigest() + + +# --------------------------------------------------------------------------- +# Test 4 — change_kind Literal enforced +# --------------------------------------------------------------------------- + + +def test_change_kind_literal_enforced() -> None: + """An unrecognised change_kind string raises ValueError.""" + with pytest.raises(ValueError, match="proposed_change_kind"): + _build(proposed_change_kind="totally_invented_kind") + + +# --------------------------------------------------------------------------- +# Test 5 — JSON-serializable payload enforced +# --------------------------------------------------------------------------- + + +def test_change_payload_must_be_json_serializable() -> None: + """A set() payload is not JSON-serializable and must raise ValueError.""" + with pytest.raises(ValueError, match="JSON-serializable"): + _build(proposed_change_payload={1, 2, 3}) + + +# --------------------------------------------------------------------------- +# Test 6 — wrong_zero_assertion required +# --------------------------------------------------------------------------- + + +def test_wrong_zero_assertion_required_empty() -> None: + """Empty wrong_zero_assertion raises ValueError.""" + with pytest.raises(ValueError, match="wrong_zero_assertion"): + _build(wrong_zero_assertion="") + + +def test_wrong_zero_assertion_too_short() -> None: + """An assertion shorter than 40 chars raises ValueError.""" + with pytest.raises(ValueError, match="wrong_zero_assertion"): + _build(wrong_zero_assertion="too short") + + +# --------------------------------------------------------------------------- +# Test 7 — reasoning_trace required +# --------------------------------------------------------------------------- + + +def test_reasoning_trace_required_none() -> None: + """Passing reasoning_trace=None raises ValueError.""" + with pytest.raises(ValueError, match="reasoning_trace"): + _build(reasoning_trace=None) + + +def test_reasoning_trace_required_missing_trace_id() -> None: + """An object without a trace_id attribute raises ValueError.""" + + class _BadTrace: + pass + + with pytest.raises(ValueError, match="trace_id"): + _build(reasoning_trace=_BadTrace()) + + +def test_reasoning_trace_required_empty_trace_id() -> None: + """An object with an empty string trace_id raises ValueError.""" + with pytest.raises(ValueError, match="trace_id"): + _build(reasoning_trace=_StubReasoningTrace("")) + + +# --------------------------------------------------------------------------- +# Test 8 — all four change_kinds round-trip +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("kind", [ + "matcher_extension", + "injector_sub_shape", + "vocabulary_addition", + "frame_reclassification", +]) +def test_all_four_change_kinds_round_trip(kind: str) -> None: + """Every valid change_kind is accepted and round-trips through the schema.""" + p = _build(proposed_change_kind=kind) + assert p.proposed_change_kind == kind + assert p.domain == "math" + assert isinstance(p.shape_category, ShapeCategory) + assert p.proposal_id != "" + + +# --------------------------------------------------------------------------- +# Bonus: proposal is frozen +# --------------------------------------------------------------------------- + + +def test_proposal_is_frozen() -> None: + """MathReaderRefusalShapeProposal instances are immutable.""" + p = _build() + with pytest.raises(dataclasses.FrozenInstanceError): + p.proposal_id = "mutated" # type: ignore[misc] + + +# --------------------------------------------------------------------------- +# Bonus: proposal_id changes when shape_category changes +# --------------------------------------------------------------------------- + + +def test_proposal_id_sensitive_to_shape_category() -> None: + """Different shape_category values produce different proposal_ids.""" + p1 = _build(shape_category=ShapeCategory.RATE_WITH_CURRENCY) + p2 = _build(shape_category=ShapeCategory.CURRENCY_AMOUNT) + assert p1.proposal_id != p2.proposal_id