feat(ADR-0172/W1): MathReaderRefusalShapeProposal schema (#380)
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).
This commit is contained in:
parent
f16ac96fb7
commit
981d764810
2 changed files with 556 additions and 0 deletions
305
teaching/math_contemplation_proposal.py
Normal file
305
teaching/math_contemplation_proposal.py
Normal file
|
|
@ -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",
|
||||
]
|
||||
251
tests/test_adr_0172_w1_shape_proposal.py
Normal file
251
tests/test_adr_0172_w1_shape_proposal.py
Normal file
|
|
@ -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": "$<amount> per <unit>"},
|
||||
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
|
||||
Loading…
Reference in a new issue