feat(ADR-0172/W4): workbench math-proposals integration + e2e tests (#385)

Wires teaching/math_proposals/proposals.jsonl into the CORE Workbench
API (ADR-0160) alongside the existing cognition proposal queue:

workbench/schemas.py
  - MathReasoningStep, MathProposalSummary, MathProposalDetail,
    MathRatifyResult schemas

workbench/readers.py
  - MATH_PROPOSALS_JSONL + _DEFAULT_MATH_AUDIT_PATH constants
  - teaching/math_proposals added to ALLOWED_ARTIFACT_ROOTS
  - _HANDLER_DISPATCH table (vocabulary_addition→LexicalClaim; all
    others not yet implemented)
  - list_math_proposals(), read_math_proposal(), ratify_math_proposal()
  - read_math_proposal() re-runs decompose_audit() to recover full
    4-step reasoning trace (canonical_bytes only carries trace_id)
  - ratify_math_proposal() raises NotImplementedError with clear
    "handler not yet implemented: {change_kind}" for unhandled kinds

workbench/api.py
  - GET /math-proposals, GET /math-proposals/{id}
  - POST /math-proposals/{id}/ratify → _math_ratify()
    (vocabulary_addition→200/routed; unhandled→501 with loud message)

tests/test_adr_0172_w4_workbench_e2e.py — 6 tests:
  1. loads from JSONL
  2. renders domain:math badge (distinct from cognition /proposals)
  3. ratify-vocabulary_addition routes to LexicalClaim (200)
  4. ratify-matcher_extension fails loudly (501 "handler not yet
     implemented")
  5. all 4 trace steps visible in detail response
  6. no cross-contamination between math and cognition queues

teaching + runtime suites green (28 + 20 passed).

Brief-gap note: canonical_bytes() excludes proposal_id and serialises
evidence pointers as hashes only. D1 loader derives proposal_id via
sha256(line_bytes) and re-runs decompose_audit() to recover full trace
for read_math_proposal(). This works but means the JSONL cannot be
loaded without the original audit file. If a future wave needs
standalone JSONL loading, C1 should emit a richer format.
This commit is contained in:
Shay 2026-05-27 13:16:23 -07:00 committed by GitHub
parent fbbc57edff
commit 93d244f4bf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 614 additions and 1 deletions

View file

@ -0,0 +1,360 @@
"""ADR-0172 W4 — Workbench math-proposals e2e tests.
Six tests:
1. Loads from JSONL: GET /math-proposals returns items when proposals.jsonl exists.
2. Renders domain badge: items carry domain='math', distinct from cognition /proposals.
3. ratify-vocabulary_addition routes to LexicalClaim handler (200, routing_status=routed).
4. ratify-matcher_extension fails loudly (501, not_implemented).
5. All 4 trace steps visible: GET /math-proposals/{id} includes 4-step trace.
6. No cross-contamination: cognition /proposals and math /math-proposals are independent.
"""
from __future__ import annotations
import hashlib
import json
from pathlib import Path
import pytest
from workbench.api import WorkbenchApi
from workbench.readers import (
MATH_PROPOSALS_JSONL,
_DEFAULT_MATH_AUDIT_PATH,
_load_math_proposals_raw,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
REAL_AUDIT_PATH = _DEFAULT_MATH_AUDIT_PATH
def _write_jsonl(tmp_path: Path, proposals_jsonl_bytes: bytes) -> Path:
p = tmp_path / "proposals.jsonl"
p.write_bytes(proposals_jsonl_bytes)
return p
def _api(jsonl_path: Path | None = None, audit_path: Path | None = None) -> WorkbenchApi:
"""Return a WorkbenchApi with its readers patched to use tmp paths."""
import workbench.readers as _r
api = WorkbenchApi()
_orig_list = _r.list_math_proposals
_orig_read = _r.read_math_proposal
_orig_ratify = _r.ratify_math_proposal
if jsonl_path is not None:
import functools
api._list_math_proposals = functools.partial(_r.list_math_proposals, jsonl_path=jsonl_path)
api._read_math_proposal = functools.partial(
_r.read_math_proposal, jsonl_path=jsonl_path, audit_path=audit_path or REAL_AUDIT_PATH
)
api._ratify_math_proposal = functools.partial(
_r.ratify_math_proposal, jsonl_path=jsonl_path
)
# Patch the module-level functions temporarily
_r.list_math_proposals = api._list_math_proposals # type: ignore[assignment]
_r.read_math_proposal = api._read_math_proposal # type: ignore[assignment]
_r.ratify_math_proposal = api._ratify_math_proposal # type: ignore[assignment]
yield api
# Restore
_r.list_math_proposals = _orig_list
_r.read_math_proposal = _orig_read
_r.ratify_math_proposal = _orig_ratify
def _get(api: WorkbenchApi, path: str) -> dict:
resp = api.handle("GET", path)
return {"status": resp.status, "payload": resp.payload}
def _post(api: WorkbenchApi, path: str, body: bytes = b"{}") -> dict:
resp = api.handle("POST", path, body)
return {"status": resp.status, "payload": resp.payload}
def _make_synthetic_proposals_jsonl(tmp_path: Path, change_kinds: list[str]) -> tuple[Path, list[str]]:
"""Build a synthetic proposals.jsonl with one proposal per change_kind.
Returns (jsonl_path, list_of_proposal_ids).
"""
from evals.refusal_taxonomy.shape_categories import ShapeCategory
from teaching.math_contemplation import decompose_audit
from teaching.math_contemplation_proposal import build_proposal, canonical_bytes
from teaching.math_evidence import MathReaderRefusalEvidence, SUB_TYPE_FOR_OPERATOR
from teaching.math_reasoning_trace import ReasoningStep, build_trace
from generate.comprehension.audit import AuditRow
def _ev(case_id: str, missing_op: str) -> MathReaderRefusalEvidence:
from teaching.math_evidence import from_audit_row
row = AuditRow(
case_id=case_id,
sentence_index=0,
token_index=0,
token_text="weight",
recognized_terms=(),
skipped_frame=None,
missing_operator=missing_op,
refusal_reason="lexicon_entry",
refusal_detail="",
)
return from_audit_row(row, "lexical", claim_signature="")
lines: list[bytes] = []
proposal_ids: list[str] = []
for i, ck in enumerate(change_kinds):
ev1 = _ev(f"c{i:02d}a", "drain_token")
ev2 = _ev(f"c{i:02d}b", "drain_token")
obs = ReasoningStep(
step_index=0, step_kind="observation", input_pointers=(ev1.case_id, ev2.case_id),
claim=f"synthetic observation for {ck}", justification="test",
output_payload={"evidence_count": 2},
)
grp = ReasoningStep(
step_index=1, step_kind="grouping", input_pointers=(ev1.case_id, ev2.case_id),
claim="group key", justification="test", output_payload={"k": "v"},
)
hyp = ReasoningStep(
step_index=2, step_kind="hypothesis", input_pointers=(ev1.case_id, ev2.case_id),
claim=f"change_kind={ck}", justification="test", output_payload={"ck": ck},
)
con = ReasoningStep(
step_index=3, step_kind="conclusion", input_pointers=(ev1.case_id, ev2.case_id),
claim="conclude", justification="test", output_payload={"done": 1},
)
trace = build_trace((obs, grp, hyp, con))
p = build_proposal(
shape_category=ShapeCategory.UNCATEGORIZED,
structural_commonality=f"synthetic {ck} test proposal — sufficient length for wrong_zero_assertion",
evidence_pointers=(ev1, ev2),
proposed_change_kind=ck, # type: ignore[arg-type]
proposed_change_payload={"evidence_count": 2, "group_key": {"k": "v"}, "modal_sub_type": "lexical"},
wrong_zero_assertion=(
"synthetic proposal for test; ratification handler enforces wrong=0"
),
replay_equivalence_hash=hashlib.sha256(f"replay-{ck}-{i}".encode()).hexdigest(),
reasoning_trace=trace,
)
cb = canonical_bytes(p)
lines.append(cb + b"\n")
proposal_ids.append(p.proposal_id)
jsonl_path = tmp_path / "proposals.jsonl"
jsonl_path.write_bytes(b"".join(lines))
return jsonl_path, proposal_ids
# ---------------------------------------------------------------------------
# Test 1: Loads from JSONL
# ---------------------------------------------------------------------------
def test_1_loads_from_jsonl(tmp_path: Path) -> None:
"""GET /math-proposals returns items from proposals.jsonl when it exists."""
from teaching.math_contemplation import decompose_audit
from teaching.math_contemplation_proposal import canonical_bytes as cb
proposals = decompose_audit(REAL_AUDIT_PATH)
jsonl_bytes = b"".join(cb(p) + b"\n" for p in proposals)
jsonl = _write_jsonl(tmp_path, jsonl_bytes)
import workbench.readers as _r
orig = _r.MATH_PROPOSALS_JSONL
_r.MATH_PROPOSALS_JSONL = jsonl
try:
api = WorkbenchApi()
resp = api.handle("GET", "/math-proposals")
finally:
_r.MATH_PROPOSALS_JSONL = orig
assert resp.status == 200
data = resp.payload.get("data", {})
items = data.get("items", [])
assert len(items) == len(proposals)
# ---------------------------------------------------------------------------
# Test 2: Renders domain:math badge, distinct from cognition domain
# ---------------------------------------------------------------------------
def test_2_renders_domain_math_badge(tmp_path: Path) -> None:
"""Items from /math-proposals have domain='math'; /proposals returns cognition (no domain field)."""
from teaching.math_contemplation import decompose_audit
from teaching.math_contemplation_proposal import canonical_bytes as cb
proposals = decompose_audit(REAL_AUDIT_PATH)
jsonl_bytes = b"".join(cb(p) + b"\n" for p in proposals)
jsonl = _write_jsonl(tmp_path, jsonl_bytes)
import workbench.readers as _r
orig = _r.MATH_PROPOSALS_JSONL
_r.MATH_PROPOSALS_JSONL = jsonl
try:
api = WorkbenchApi()
resp = api.handle("GET", "/math-proposals")
finally:
_r.MATH_PROPOSALS_JSONL = orig
items = resp.payload["data"]["items"]
assert all(item["domain"] == "math" for item in items), (
"all math proposals must carry domain='math'"
)
# Cognition /proposals carries ProposalSummary — no domain field at all
cog_resp = WorkbenchApi().handle("GET", "/proposals")
cog_items = cog_resp.payload.get("data", {}).get("items", [])
for cog_item in cog_items:
assert cog_item.get("domain") != "math", (
"cognition proposals must not carry domain='math'"
)
# ---------------------------------------------------------------------------
# Test 3: ratify-vocabulary_addition routes to LexicalClaim handler
# ---------------------------------------------------------------------------
def test_3_ratify_vocabulary_addition_routes_to_lexical_claim(tmp_path: Path) -> None:
"""POST /math-proposals/{id}/ratify routes vocabulary_addition to LexicalClaim (200)."""
jsonl_path, proposal_ids = _make_synthetic_proposals_jsonl(tmp_path, ["vocabulary_addition"])
vocab_pid = proposal_ids[0]
import workbench.readers as _r
orig = _r.MATH_PROPOSALS_JSONL
_r.MATH_PROPOSALS_JSONL = jsonl_path
try:
api = WorkbenchApi()
resp = api.handle("POST", f"/math-proposals/{vocab_pid}/ratify")
finally:
_r.MATH_PROPOSALS_JSONL = orig
assert resp.status == 200, f"expected 200, got {resp.status}: {resp.payload}"
data = resp.payload.get("data", {})
assert data.get("handler_name") == "LexicalClaim", (
f"vocabulary_addition must route to LexicalClaim, got: {data.get('handler_name')!r}"
)
assert data.get("routing_status") == "routed"
assert data.get("change_kind") == "vocabulary_addition"
# ---------------------------------------------------------------------------
# Test 4: ratify-matcher_extension fails loudly
# ---------------------------------------------------------------------------
def test_4_ratify_matcher_extension_fails_loudly(tmp_path: Path) -> None:
"""POST /math-proposals/{id}/ratify returns 501 for matcher_extension with clear message."""
jsonl_path, proposal_ids = _make_synthetic_proposals_jsonl(tmp_path, ["matcher_extension"])
matcher_pid = proposal_ids[0]
import workbench.readers as _r
orig = _r.MATH_PROPOSALS_JSONL
_r.MATH_PROPOSALS_JSONL = jsonl_path
try:
api = WorkbenchApi()
resp = api.handle("POST", f"/math-proposals/{matcher_pid}/ratify")
finally:
_r.MATH_PROPOSALS_JSONL = orig
assert resp.status == 501, f"expected 501 for unimplemented handler, got {resp.status}"
err = resp.payload.get("error", {})
message = err.get("message", "")
assert "handler not yet implemented" in message, (
f"expected 'handler not yet implemented' in message, got: {message!r}"
)
assert "matcher_extension" in message, (
f"expected change_kind in error message, got: {message!r}"
)
# ---------------------------------------------------------------------------
# Test 5: All 4 trace steps visible
# ---------------------------------------------------------------------------
def test_5_all_4_trace_steps_visible(tmp_path: Path) -> None:
"""GET /math-proposals/{id} includes reasoning_trace_steps with all 4 steps."""
from teaching.math_contemplation import decompose_audit
from teaching.math_contemplation_proposal import canonical_bytes as cb
proposals = decompose_audit(REAL_AUDIT_PATH)
assert proposals, "real audit must produce at least one proposal"
jsonl_bytes = b"".join(cb(p) + b"\n" for p in proposals)
jsonl = _write_jsonl(tmp_path, jsonl_bytes)
first_id = proposals[0].proposal_id
import workbench.readers as _r
orig = _r.MATH_PROPOSALS_JSONL
orig_audit = _r._DEFAULT_MATH_AUDIT_PATH
_r.MATH_PROPOSALS_JSONL = jsonl
_r._DEFAULT_MATH_AUDIT_PATH = REAL_AUDIT_PATH
try:
api = WorkbenchApi()
resp = api.handle("GET", f"/math-proposals/{first_id}")
finally:
_r.MATH_PROPOSALS_JSONL = orig
_r._DEFAULT_MATH_AUDIT_PATH = orig_audit
assert resp.status == 200, f"expected 200, got {resp.status}: {resp.payload}"
detail = resp.payload.get("data", {})
steps = detail.get("reasoning_trace_steps", [])
assert len(steps) == 4, f"expected 4 trace steps, got {len(steps)}"
step_kinds = [s["step_kind"] for s in steps]
assert "observation" in step_kinds
assert "grouping" in step_kinds
assert "hypothesis" in step_kinds
assert "conclusion" in step_kinds
# ---------------------------------------------------------------------------
# Test 6: No cross-contamination between math and cognition queues
# ---------------------------------------------------------------------------
def test_6_no_cross_contamination(tmp_path: Path) -> None:
"""Math /math-proposals and cognition /proposals are independent queues."""
from teaching.math_contemplation import decompose_audit
from teaching.math_contemplation_proposal import canonical_bytes as cb
proposals = decompose_audit(REAL_AUDIT_PATH)
jsonl_bytes = b"".join(cb(p) + b"\n" for p in proposals)
jsonl = _write_jsonl(tmp_path, jsonl_bytes)
import workbench.readers as _r
orig = _r.MATH_PROPOSALS_JSONL
_r.MATH_PROPOSALS_JSONL = jsonl
try:
api = WorkbenchApi()
math_resp = api.handle("GET", "/math-proposals")
cog_resp = api.handle("GET", "/proposals")
finally:
_r.MATH_PROPOSALS_JSONL = orig
math_items = math_resp.payload.get("data", {}).get("items", [])
cog_items = cog_resp.payload.get("data", {}).get("items", [])
# Math queue carries domain:math; cognition queue has no domain:math entries
assert all(item.get("domain") == "math" for item in math_items)
for cog_item in cog_items:
assert cog_item.get("domain") != "math", (
"cognition proposal leaked into math domain"
)
# proposal_ids from the two queues are disjoint
math_ids = {item["proposal_id"] for item in math_items}
cog_ids = {item["proposal_id"] for item in cog_items}
assert math_ids.isdisjoint(cog_ids), (
f"proposal IDs overlap between math and cognition queues: {math_ids & cog_ids}"
)

View file

@ -18,7 +18,7 @@ from core.epistemic_state import (
)
from workbench import readers
from workbench.readers import ArtifactTooLargeError
from workbench.schemas import ChatTurnResult, ProposalRef, TurnVerdict, error, ok
from workbench.schemas import ChatTurnResult, MathRatifyResult, ProposalRef, TurnVerdict, error, ok
MAX_CHAT_BODY_BYTES = 64 * 1024
@ -75,6 +75,14 @@ class WorkbenchApi:
if method == "GET" and path.startswith("/proposals/"):
proposal_id = unquote(path.removeprefix("/proposals/"))
return ApiResponse(200, ok(readers.read_proposal(proposal_id)))
if method == "GET" and path == "/math-proposals":
return ApiResponse(200, ok({"items": readers.list_math_proposals()}))
if method == "POST" and path.endswith("/ratify") and path.startswith("/math-proposals/"):
proposal_id = unquote(path.removeprefix("/math-proposals/").removesuffix("/ratify"))
return self._math_ratify(proposal_id)
if method == "GET" and path.startswith("/math-proposals/"):
proposal_id = unquote(path.removeprefix("/math-proposals/"))
return ApiResponse(200, ok(readers.read_math_proposal(proposal_id)))
if method == "GET" and path == "/evals":
return ApiResponse(200, ok({"lanes": readers.list_eval_lanes()}))
if method == "GET" and path.startswith("/evals/"):
@ -103,6 +111,14 @@ class WorkbenchApi:
return ApiResponse(501, error("unsupported", "route is deferred beyond W-026"))
return ApiResponse(404, error("not_found", f"route not found: {method} {path}"))
def _math_ratify(self, proposal_id: str) -> ApiResponse:
"""Route ratification by change_kind; 501 for unimplemented handlers."""
try:
result: MathRatifyResult = readers.ratify_math_proposal(proposal_id)
except NotImplementedError as exc:
return ApiResponse(501, error("unsupported", str(exc)))
return ApiResponse(200, ok(result))
def _chat_turn(self, body: bytes) -> ApiResponse:
"""Execute one live runtime turn.

View file

@ -17,6 +17,10 @@ from workbench.schemas import (
ArtifactRef,
EvalLaneSummary,
EvalRunResult,
MathProposalDetail,
MathProposalSummary,
MathRatifyResult,
MathReasoningStep,
ProposalDetail,
ProposalSummary,
RuntimeStatus,
@ -31,10 +35,27 @@ _REVIEW_STATES = frozenset(get_args(ReviewState))
ALLOWED_ARTIFACT_ROOTS = (
REPO_ROOT / "engine_state",
REPO_ROOT / "teaching" / "proposals",
REPO_ROOT / "teaching" / "math_proposals",
REPO_ROOT / "evals",
REPO_ROOT / "contemplation" / "runs",
)
MATH_PROPOSALS_JSONL = REPO_ROOT / "teaching" / "math_proposals" / "proposals.jsonl"
_DEFAULT_MATH_AUDIT_PATH = (
REPO_ROOT
/ "evals"
/ "gsm8k_math"
/ "train_sample"
/ "v1"
/ "audit_brief_11.json"
)
# Dispatch table: proposed_change_kind → handler name.
# Handlers not listed here are not yet implemented.
_HANDLER_DISPATCH: dict[str, str] = {
"vocabulary_addition": "LexicalClaim",
}
class ArtifactTooLargeError(OSError):
"""Raised when an artifact is too large for direct Workbench reads."""
@ -268,6 +289,175 @@ def read_eval_lane(lane_name: str) -> EvalLaneSummary:
)
def _load_math_proposals_raw(jsonl_path: Path) -> list[dict[str, Any]]:
"""Parse proposals.jsonl; derive proposal_id = sha256(canonical_line_bytes)."""
if not jsonl_path.exists():
return []
results: list[dict[str, Any]] = []
for raw_line in jsonl_path.read_bytes().splitlines():
stripped = raw_line.strip()
if not stripped:
continue
proposal_id = hashlib.sha256(stripped).hexdigest()
data: dict[str, Any] = json.loads(stripped)
data["proposal_id"] = proposal_id
results.append(data)
return results
def _math_proposal_summary(record: dict[str, Any]) -> MathProposalSummary:
payload = record.get("proposed_change_payload") or {}
evidence_count = int(payload.get("evidence_count", 0)) if isinstance(payload, dict) else 0
return MathProposalSummary(
proposal_id=str(record.get("proposal_id", "")),
domain="math",
shape_category=str(record.get("shape_category", "")),
proposed_change_kind=str(record.get("proposed_change_kind", "")),
structural_commonality=str(record.get("structural_commonality", "")),
evidence_count=evidence_count,
replay_equivalence_hash=str(record.get("replay_equivalence_hash", "")),
)
def list_math_proposals(*, jsonl_path: Path | None = None) -> list[MathProposalSummary]:
path = jsonl_path or MATH_PROPOSALS_JSONL
records = _load_math_proposals_raw(path)
return [_math_proposal_summary(r) for r in records]
def _math_trace_steps_from_proposal(proposal: Any) -> list[MathReasoningStep]:
"""Extract 4 ReasoningStep objects from a MathReaderRefusalShapeProposal."""
trace = getattr(proposal, "reasoning_trace", None)
if trace is None:
return []
steps_raw = getattr(trace, "steps", ())
steps: list[MathReasoningStep] = []
for step in steps_raw:
steps.append(
MathReasoningStep(
step_index=int(getattr(step, "step_index", 0)),
step_kind=str(getattr(step, "step_kind", "")),
claim=str(getattr(step, "claim", "")),
justification=str(getattr(step, "justification", "")),
input_pointers=list(getattr(step, "input_pointers", ())),
output_payload=getattr(step, "output_payload", {}),
)
)
return steps
def read_math_proposal(
proposal_id: str,
*,
jsonl_path: Path | None = None,
audit_path: Path | None = None,
) -> MathProposalDetail:
"""Return full proposal detail including 4-step reasoning trace.
Re-runs :func:`teaching.math_contemplation.decompose_audit` to recover
the full :class:`MathReaderRefusalShapeProposal` (canonical bytes only
carry the trace_id, not the full steps). Deterministic: same audit
same proposals.
"""
from teaching.math_contemplation import decompose_audit
# Verify the proposal_id exists in the JSONL first (fast path).
path = jsonl_path or MATH_PROPOSALS_JSONL
records = _load_math_proposals_raw(path)
record = next((r for r in records if r.get("proposal_id") == proposal_id), None)
if record is None:
raise FileNotFoundError(proposal_id)
# Re-run decomposer to get the full proposal with trace steps.
apath = audit_path or _DEFAULT_MATH_AUDIT_PATH
full_proposals = decompose_audit(apath)
full = next((p for p in full_proposals if p.proposal_id == proposal_id), None)
if full is None:
raise FileNotFoundError(f"{proposal_id} (not found in decomposer output)")
change_kind = str(record.get("proposed_change_kind", ""))
handler_name = _HANDLER_DISPATCH.get(change_kind)
trace_id = str(record.get("reasoning_trace_id", ""))
trace_steps = _math_trace_steps_from_proposal(full)
evidence_pointers = record.get("evidence_pointers", [])
evidence_hashes = list(evidence_pointers) if isinstance(evidence_pointers, list) else []
suggested_cli: str | None = None
if handler_name == "LexicalClaim":
suggested_cli = (
f"# ratify via Python REPL:\n"
f"from teaching.math_lexical_ratification import apply_lexical_claim\n"
f"# apply_lexical_claim(claim=<evidence>, category='drain_token', reviewer='<you>')"
)
return MathProposalDetail(
proposal_id=proposal_id,
domain="math",
shape_category=str(record.get("shape_category", "")),
proposed_change_kind=change_kind,
structural_commonality=str(record.get("structural_commonality", "")),
evidence_count=len(evidence_hashes),
replay_equivalence_hash=str(record.get("replay_equivalence_hash", "")),
wrong_zero_assertion=str(record.get("wrong_zero_assertion", "")),
proposed_change_payload=record.get("proposed_change_payload"),
reasoning_trace_id=trace_id,
reasoning_trace_steps=trace_steps,
evidence_hashes=evidence_hashes,
handler_name=handler_name,
suggested_ratify_cli=suggested_cli,
)
def ratify_math_proposal(
proposal_id: str,
*,
jsonl_path: Path | None = None,
) -> MathRatifyResult:
"""Dispatch ratification by change_kind; fail loudly for unimplemented handlers.
ADR-0160 "Proposal before mutation" doctrine: this function validates
routing and returns the handler name + suggested CLI without applying
the change. Mutation requires an explicit operator action outside the
workbench (e.g. calling apply_lexical_claim() directly).
Raises FileNotFoundError if proposal_id not found.
Raises NotImplementedError with a clear message for unhandled change_kinds.
"""
path = jsonl_path or MATH_PROPOSALS_JSONL
records = _load_math_proposals_raw(path)
record = next((r for r in records if r.get("proposal_id") == proposal_id), None)
if record is None:
raise FileNotFoundError(proposal_id)
change_kind = str(record.get("proposed_change_kind", ""))
handler_name = _HANDLER_DISPATCH.get(change_kind)
if handler_name is None:
raise NotImplementedError(
f"handler not yet implemented for change_kind={change_kind!r}; "
f"see docs/handoff/ADR-0167-FOLLOWUPS.md §1 for the scoping ADR required "
f"before this change_kind can be ratified"
)
suggested_cli: str | None = None
if handler_name == "LexicalClaim":
suggested_cli = (
f"from teaching.math_lexical_ratification import apply_lexical_claim\n"
f"# apply_lexical_claim(claim=<evidence>, category='drain_token', reviewer='<you>')"
)
return MathRatifyResult(
proposal_id=proposal_id,
change_kind=change_kind,
handler_name=handler_name,
routing_status="routed",
message=f"routed to {handler_name} handler",
suggested_cli=suggested_cli,
)
def run_safe_eval_lane(
lane_name: str,
*,

View file

@ -196,3 +196,50 @@ class ReplayComparison:
replay_hash: str | None
equivalent: bool
divergences: list[ReplayDivergence] = field(default_factory=list)
# ---------------------------------------------------------------------------
# ADR-0172 W4 — Math proposal schemas
# ---------------------------------------------------------------------------
@dataclass(frozen=True, slots=True)
class MathReasoningStep:
step_index: int
step_kind: str
claim: str
justification: str
input_pointers: list[str]
output_payload: Any
@dataclass(frozen=True, slots=True)
class MathProposalSummary:
proposal_id: str
domain: Literal["math"]
shape_category: str
proposed_change_kind: str
structural_commonality: str
evidence_count: int
replay_equivalence_hash: str
@dataclass(frozen=True, slots=True)
class MathProposalDetail(MathProposalSummary):
wrong_zero_assertion: str
proposed_change_payload: Any
reasoning_trace_id: str
reasoning_trace_steps: list[MathReasoningStep]
evidence_hashes: list[str]
handler_name: str | None
suggested_ratify_cli: str | None
@dataclass(frozen=True, slots=True)
class MathRatifyResult:
proposal_id: str
change_kind: str
handler_name: str
routing_status: Literal["routed", "not_implemented"]
message: str
suggested_cli: str | None = None