"""ADR-0119.3 — GSM8K math eval lane runner. Composes the Phases 1-4 pipeline (parser → solver → verifier → realizer) into a per-case scoring decision: ``correct`` / ``wrong`` / ``refused`` / ``decoded_unarticulated``. Outcome categorization (ADR-0114a Obligation #4 — the load-bearing "refusal is first-class; misparse rate zero" discipline): | Stage that raised | Outcome | Reason recorded | |---|---|---| | ``parse_problem(text)`` raised ``ParseError`` | refused | typed parser error | | ``solve(graph)`` raised ``SolveError`` | refused | typed solver error | | ``verify(graph, trace)`` returned ``passed=False`` | wrong | verifier reason | | ``realize(graph.initial_state, trace)`` raised ``RealizerError`` after verifier pass | decoded_unarticulated | typed realizer error | | Everything succeeds AND ``trace.answer_value == expected_answer`` AND ``trace.answer_unit == expected_unit`` | correct | empty | | Everything succeeds BUT answer or unit differs | wrong | "answer/unit mismatch" | **`wrong == 0` is the gate** — ADR-0114a Obligation #4 requires CORE to refuse rather than confabulate. A nonzero ``wrong`` count invalidates the lane regardless of ``correct`` rate. A verified trace whose surface realization fails is not a wrong answer; it is counted as ``decoded_unarticulated``. The runner is pure / deterministic: same case set → same :class:`LaneReport.canonical_bytes()`. """ from __future__ import annotations import hashlib import json from dataclasses import dataclass, field from typing import Any from generate.math_candidate_graph import parse_and_solve from generate.math_parser import ParseError, parse_problem from generate.math_problem_graph import MathProblemGraph from generate.math_realizer import RealizerError, realize from generate.math_solver import SolveError, solve from generate.math_verifier import verify DECODED_UNARTICULATED_OUTCOME = "decoded_unarticulated" @dataclass(frozen=True, slots=True) class CaseOutcome: """Per-case scoring decision with full audit trail.""" case_id: str outcome: str # "correct" | "wrong" | "refused" | "decoded_unarticulated" reason: str expected_answer: float expected_unit: str actual_answer: float | None actual_unit: str | None trace_hash: str | None realized_prose: str | None def as_json(self) -> dict[str, Any]: return { "case_id": self.case_id, "outcome": self.outcome, "reason": self.reason, "expected_answer": self.expected_answer, "expected_unit": self.expected_unit, "actual_answer": self.actual_answer, "actual_unit": self.actual_unit, "trace_hash": self.trace_hash, "realized_prose": self.realized_prose, } @dataclass(slots=True) class LaneReport: """Aggregate lane scoring report. Conforms to the framework runner interface (``metrics`` dict + ``case_details`` list). """ metrics: dict[str, Any] = field(default_factory=dict) case_details: list[dict[str, Any]] = field(default_factory=list) def canonical_bytes(self) -> bytes: """Deterministic JSON for hashing/byte-equality comparison.""" payload = {"metrics": self.metrics, "case_details": self.case_details} return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") def _decoded_unarticulated_outcome( *, case_id: str, reason: str, expected_answer: float, expected_unit: str, actual_answer: float, actual_unit: str, trace_hash: str, ) -> CaseOutcome: return CaseOutcome( case_id=case_id, outcome=DECODED_UNARTICULATED_OUTCOME, reason=reason, expected_answer=expected_answer, expected_unit=expected_unit, actual_answer=actual_answer, actual_unit=actual_unit, trace_hash=trace_hash, realized_prose=None, ) def _score_one(case: dict[str, Any]) -> CaseOutcome: """Run the full pipeline against one case and classify the outcome.""" case_id = case["id"] expected_answer = case["expected_answer"] expected_unit = case["expected_unit"] # Stage 1 — parse try: graph: MathProblemGraph = parse_problem(case["problem"]) except ParseError as exc: return CaseOutcome( case_id=case_id, outcome="refused", reason=f"parser: {exc}", expected_answer=expected_answer, expected_unit=expected_unit, actual_answer=None, actual_unit=None, trace_hash=None, realized_prose=None, ) # Stage 2 — solve try: trace = solve(graph) except SolveError as exc: return CaseOutcome( case_id=case_id, outcome="refused", reason=f"solver: {exc}", expected_answer=expected_answer, expected_unit=expected_unit, actual_answer=None, actual_unit=None, trace_hash=None, realized_prose=None, ) # Stage 3 — verify (independent re-derivation) verdict = verify(graph, trace) trace_hash = hashlib.sha256(trace.canonical_bytes()).hexdigest() if not verdict.passed: return CaseOutcome( case_id=case_id, outcome="wrong", reason=f"verifier: {verdict.reason}", expected_answer=expected_answer, expected_unit=expected_unit, actual_answer=trace.answer_value, actual_unit=trace.answer_unit, trace_hash=trace_hash, realized_prose=None, ) # Stage 4 — realize. A failure here happens after replay verification, # so the answer remains DECODED; only the articulation surface failed. try: realized = realize(graph.initial_state, trace) prose = realized.as_prose() except RealizerError as exc: return _decoded_unarticulated_outcome( case_id=case_id, reason=f"realizer: {exc}", expected_answer=expected_answer, expected_unit=expected_unit, actual_answer=trace.answer_value, actual_unit=trace.answer_unit, trace_hash=trace_hash, ) # Stage 5 — compare against expected. # An empty expected_unit ("") means the case carries no unit-level # expectation (e.g. the sealed GSM8K test set under ADR-0119.7 # records pure-number answers without a parsed unit). In that case # the runner skips the unit comparison and grades on answer value # alone. Cases that DO specify expected_unit get the strict check. if expected_unit != "" and trace.answer_unit != expected_unit: return CaseOutcome( case_id=case_id, outcome="wrong", reason=( f"unit mismatch: got {trace.answer_unit!r}, " f"expected {expected_unit!r}" ), expected_answer=expected_answer, expected_unit=expected_unit, actual_answer=trace.answer_value, actual_unit=trace.answer_unit, trace_hash=trace_hash, realized_prose=prose, ) if trace.answer_value != expected_answer: return CaseOutcome( case_id=case_id, outcome="wrong", reason=( f"answer mismatch: got {trace.answer_value!r}, " f"expected {expected_answer!r}" ), expected_answer=expected_answer, expected_unit=expected_unit, actual_answer=trace.answer_value, actual_unit=trace.answer_unit, trace_hash=trace_hash, realized_prose=prose, ) return CaseOutcome( case_id=case_id, outcome="correct", reason="", expected_answer=expected_answer, expected_unit=expected_unit, actual_answer=trace.answer_value, actual_unit=trace.answer_unit, trace_hash=trace_hash, realized_prose=prose, ) # TODO(ADR-future): report.json metrics may not credit candidate-graph admissions # routed through this branch. Aggregation in calling code needs an audit before # the canonical run.honest_runner.json artifact can be trusted for cross-phase comparison. def _score_one_candidate_graph( case: dict[str, Any], ) -> CaseOutcome: """ADR-0126 P4 — score one case via the candidate-graph pipeline. Mirrors :func:`_score_one` end-to-end (parser → solver → verifier → realizer → expected-answer check) but the parse stage uses :func:`generate.math_candidate_graph.parse_and_solve` instead of the first-match-wins :func:`generate.math_parser.parse_problem`. Preserves wrong == 0: any deviation in the new pipeline still routes through the same verifier-replay + answer/unit equality checks. Refusals are first-class — branches with no admissible parse, branches that disagree on the answer, and branches that exceed MAX_TOTAL_BRANCHES all classify as ``refused``. Callers that want to evaluate the candidate-graph topology (e.g. ``evals/gsm8k_math/train_sample/v1/runner.py`` from PR #160) substitute this function for ``_score_one``; the ``CaseOutcome`` shape is identical. Args: case: Case record with keys ``id``, ``problem``, ``expected_answer``, ``expected_unit``. """ case_id = case["id"] expected_answer = case["expected_answer"] expected_unit = case["expected_unit"] # Stage 1 — candidate-graph parse + internal solve + decision rule. cg_result = parse_and_solve(case["problem"]) if not cg_result.is_admitted: return CaseOutcome( case_id=case_id, outcome="refused", reason=f"candidate_graph: {cg_result.refusal_reason}", expected_answer=expected_answer, expected_unit=expected_unit, actual_answer=None, actual_unit=None, trace_hash=None, realized_prose=None, ) graph = cg_result.selected_graph if graph is None: # Fast-path solvers (capacity, earnings) produce an answer directly # without building a MathProblemGraph. Score on value only. numeric_answer = cg_result.answer assert numeric_answer is not None if expected_unit != "" and expected_unit is not None: return CaseOutcome( case_id=case_id, outcome="wrong", reason="fast-path: no unit annotation to compare", expected_answer=expected_answer, expected_unit=expected_unit, actual_answer=numeric_answer, actual_unit=None, trace_hash=None, realized_prose=None, ) tol = 1e-6 if isinstance(numeric_answer, float) else 0 if abs(numeric_answer - expected_answer) <= tol: return CaseOutcome( case_id=case_id, outcome="correct", reason="fast-path", expected_answer=expected_answer, expected_unit=expected_unit, actual_answer=numeric_answer, actual_unit=None, trace_hash=None, realized_prose=None, ) return CaseOutcome( case_id=case_id, outcome="wrong", reason=f"fast-path: got {numeric_answer}, expected {expected_answer}", expected_answer=expected_answer, expected_unit=expected_unit, actual_answer=numeric_answer, actual_unit=None, trace_hash=None, realized_prose=None, ) # Stage 2 — canonical solve for the full SolutionTrace (verifier # needs the trace; parse_and_solve only kept the numeric answer). try: trace = solve(graph) except SolveError as exc: return CaseOutcome( case_id=case_id, outcome="refused", reason=f"solver: {exc}", expected_answer=expected_answer, expected_unit=expected_unit, actual_answer=None, actual_unit=None, trace_hash=None, realized_prose=None, ) # Stage 3 — verify (independent re-derivation, ADR-0117). verdict = verify(graph, trace) trace_hash = hashlib.sha256(trace.canonical_bytes()).hexdigest() if not verdict.passed: return CaseOutcome( case_id=case_id, outcome="wrong", reason=f"verifier: {verdict.reason}", expected_answer=expected_answer, expected_unit=expected_unit, actual_answer=trace.answer_value, actual_unit=trace.answer_unit, trace_hash=trace_hash, realized_prose=None, ) # Stage 4 — realize. A failure here happens after replay verification, # so the answer remains DECODED; only the articulation surface failed. try: realized = realize(graph.initial_state, trace) prose = realized.as_prose() except RealizerError as exc: return _decoded_unarticulated_outcome( case_id=case_id, reason=f"realizer: {exc}", expected_answer=expected_answer, expected_unit=expected_unit, actual_answer=trace.answer_value, actual_unit=trace.answer_unit, trace_hash=trace_hash, ) # Stage 5 — expected-answer comparison (same logic as _score_one). if expected_unit != "" and trace.answer_unit != expected_unit: return CaseOutcome( case_id=case_id, outcome="wrong", reason=( f"unit mismatch: got {trace.answer_unit!r}, " f"expected {expected_unit!r}" ), expected_answer=expected_answer, expected_unit=expected_unit, actual_answer=trace.answer_value, actual_unit=trace.answer_unit, trace_hash=trace_hash, realized_prose=prose, ) if trace.answer_value != expected_answer: return CaseOutcome( case_id=case_id, outcome="wrong", reason=( f"answer mismatch: got {trace.answer_value!r}, " f"expected {expected_answer!r}" ), expected_answer=expected_answer, expected_unit=expected_unit, actual_answer=trace.answer_value, actual_unit=trace.answer_unit, trace_hash=trace_hash, realized_prose=prose, ) return CaseOutcome( case_id=case_id, outcome="correct", reason="", expected_answer=expected_answer, expected_unit=expected_unit, actual_answer=trace.answer_value, actual_unit=trace.answer_unit, trace_hash=trace_hash, realized_prose=prose, ) def run_lane( cases: list[dict[str, Any]], *, config: Any = None, # noqa: ARG001 — framework interface compat ) -> LaneReport: """Score every case and emit aggregate metrics + per-case details. The runner is pure: no globals, no I/O. Returns a :class:`LaneReport` whose ``canonical_bytes()`` is byte-equal across two calls with the same input list. Aggregate metrics: cases_total int correct int wrong int (gate: must == 0) refused int decoded_unarticulated int correct_rate float = correct / total wrong_rate float = wrong / total refused_rate float = refused / total decoded_unarticulated_rate float = decoded_unarticulated / total wrong_count_is_zero bool = wrong == 0 overall_pass bool = wrong == 0 AND correct + refused + decoded_unarticulated == total """ outcomes = [_score_one(c) for c in cases] total = len(outcomes) correct = sum(1 for o in outcomes if o.outcome == "correct") wrong = sum(1 for o in outcomes if o.outcome == "wrong") refused = sum(1 for o in outcomes if o.outcome == "refused") decoded_unarticulated = sum( 1 for o in outcomes if o.outcome == DECODED_UNARTICULATED_OUTCOME ) wrong_count_is_zero = wrong == 0 overall_pass = wrong_count_is_zero and ( correct + refused + decoded_unarticulated == total ) metrics = { "cases_total": total, "correct": correct, "wrong": wrong, "refused": refused, "decoded_unarticulated": decoded_unarticulated, "correct_rate": (correct / total) if total else 0.0, "wrong_rate": (wrong / total) if total else 0.0, "refused_rate": (refused / total) if total else 0.0, "decoded_unarticulated_rate": ( decoded_unarticulated / total ) if total else 0.0, "wrong_count_is_zero": wrong_count_is_zero, "overall_pass": overall_pass, } report = LaneReport() report.metrics = metrics report.case_details = [o.as_json() for o in outcomes] return report