Phase 3 of the ADR-0114 expert-capability roadmap. Re-applies every step of a SolutionTrace from the input graph's initial state and asserts byte-equal reproduction of answer_value. Pure function; same (graph, trace) → byte-equal VerifierVerdict. Why this is distinct from the solver ADR-0116's solver enforces correctness at construction. ADR-0117's verifier is a SECOND, INDEPENDENT implementation that re-derives every value the trace claims. The verifier does NOT call solve(). It re-implements the operation semantics from ADR-0116 directly inside _verify_step. If the solver had a bug or was tampered with after the fact, the verifier catches it. Six checks per verdict (named, ordered, audit-logged): 1. graph_canonical_hash_matches 2. pack_id_matches 3. pack_lemmas_resolve 4. step_pack_lemma_ids_match_bindings 5. step_replay_matches_before_after 6. answer_value_reproduces Seven named tamper classes all caught: - mutated before_value / after_value / operand of any step - mutated pack_lemma_id of any step - mutated graph_canonical_hash - mutated answer_value - mutated pack_id - mutated target_before / target_after of transfer step ADR-0114a obligation update #3 Replay-equal trace — now discharged at VERIFIER FIDELITY (was solver-only under ADR-0116). A third party with only (graph, trace, pack) can reproduce the answer byte-equal. Five of ten obligations now load-bearing: #3, #4, #9, #10 plus in-flight #2 (Codex's ADR-0118a OOD generator). Tests: 62/62 verifier suite green; 67/67 smoke green; existing solver + parser + schema suites unaffected. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
7.4 KiB
ADR-0117 — SolutionTrace Verifier
Status: Accepted Date: 2026-05-22 Author: CORE agents + reviewers Depends on: ADR-0114, ADR-0114a, ADR-0115, ADR-0116
Context
ADR-0116 shipped the solver and emitted SolutionTrace records with
per-step before_value / after_value / pack_lemma_id, byte-
deterministic canonical_bytes(). The solver itself enforces
correctness at construction time, but the solver could be buggy,
tampered with after the fact, or replaced by a different
implementation. ADR-0114a Obligation #3 requires that every
correct answer ship with a replay-equal trace, and that requirement
is only load-bearing if a verifier independent of the solver can
reproduce the answer from the trace.
ADR-0117 ships that verifier.
Decision
generate/math_verifier.py
Exposes verify(graph, trace) -> VerifierVerdict. Pure function;
same (graph, trace) always returns a byte-equal verdict.
The verifier runs six named checks in order, accumulating each one's
result in verdict.checks:
| Check name | What it verifies |
|---|---|
graph_canonical_hash_matches |
trace.graph_canonical_hash equals a fresh sha256(graph.canonical_bytes()) |
pack_id_matches |
trace.pack_id == "en_arithmetic_v1" |
pack_lemmas_resolve |
The arithmetic pack loads and provides every required lemma |
step_pack_lemma_ids_match_bindings |
Every step's pack_lemma_id equals the resolved binding for its operation_kind |
step_replay_matches_before_after |
Replaying each step from the graph's initial state reproduces every before_value, after_value, target_before, target_after byte-equal |
answer_value_reproduces |
The verifier's resolved Unknown equals trace.answer_value |
VerifierVerdict.passed is True only if every check held. On
failure, reason names the first failed check; checks holds the
full per-check record for audit.
Independence from the solver
The verifier imports only the operation-semantics constants and
the pack resolver from math_solver. It does NOT call solve(). It
re-derives every value the trace claims using a fresh state machine
that lives in _verify_step. If a solver bug produced a wrong
after_value, the verifier catches it. If a tamperer rewrote
answer_value post-solve, the verifier catches it. If the input
graph's bytes were edited but the trace was not re-signed, the
graph_canonical_hash check catches it.
The verifier deliberately re-implements the operation semantics documented in ADR-0116 rather than importing the solver's apply function. This is belt-and-suspenders for adversarial replacement of the solver.
What a tampered trace looks like
| Tamper | Verdict |
|---|---|
Mutate before_value of step N |
step_replay_matches_before_after: step N declares before_value=X, verifier computed Y |
Mutate after_value of step N |
step_replay_matches_before_after: step N declares after_value=X, verifier computed Y |
Mutate operand.value of step N |
step_replay_matches_before_after (cascades through after_value) |
Mutate pack_lemma_id of step N |
step_pack_lemma_ids_match_bindings: step N declares ... |
Mutate graph_canonical_hash |
graph_canonical_hash_matches: trace declares X but graph hashes to Y |
Mutate answer_value |
answer_value_reproduces: verifier resolved X, trace declared Y |
Mutate pack_id |
pack_id_matches: trace declares X, expected en_arithmetic_v1 |
Mutate target_before / target_after of transfer step |
step_replay_matches_before_after: step N declares target_*=X, verifier computed Y |
Every named tamper class is pinned by a test in
tests/test_math_verifier.py.
Invariants
adr_0117_solver_traces_verify
For every case in evals/gsm8k_parser_dev/cases.jsonl, the
verifier accepts the solver's own trace with verdict.passed=True.
Tested parametrized over all 50 cases.
adr_0117_tampered_trace_rejected
For each named tamper class, a mutated SolutionTrace produces
verdict.passed=False with a reason naming the offending check.
Pinned by seven TestTamperDetection cases.
adr_0117_verifier_independent_of_solver
The verifier does not invoke solve() and re-derives every value
from graph + trace alone. Inspected by import structure: the
verifier imports _resolve_pack_lemmas, REQUIRED_PACK_ID, and the
typed dataclasses, but NOT solve itself.
adr_0117_determinism
Two verify(graph, trace) calls produce byte-equal
VerifierVerdict.canonical_bytes(). Tested directly.
ADR-0114a obligation discharge update
ADR-0116 discharged Obligation #3 at solver fidelity (the solver itself emits a trace that, when replayed in-process, reproduces the answer). ADR-0117 now discharges Obligation #3 at verifier fidelity: a third party with only the graph + trace and a re-installation of the arithmetic pack reproduces the answer byte-equal.
| Obligation | Status |
|---|---|
| #1 Sealed-holdout discipline | Substrate present; per-lane enforcement deferred to ADR-0119 |
| #2 OOD surface variation | In flight (delegated to Codex, ADR-0118a) |
| #3 Replay-equal trace | Discharged at verifier fidelity (was solver-fidelity under ADR-0116) |
| #4 Typed refusal | Discharged at solver layer (ADR-0116) |
| #5 Reasoning-isolation perturbation suite | Future ADR |
| #6 Compositional-depth curve | Measurement-only at promotion |
| #7 Frontier-baseline comparison | Deferred to ADR-0119 |
| #8 Adversarial generation | Deferred to ADR-0119 |
| #9 Determinism | Discharged at solver + verifier layers |
| #10 Operation provenance via pack | Discharged in full (ADR-0116); verifier re-checks |
Five obligations now have load-bearing implementations: #3 (now at verifier fidelity), #4, #9, #10, plus the in-flight #2 from Codex's ADR-0118a work.
Acceptance evidence
Accepted when:
generate/math_verifier.pyexportsverify,VerifierVerdict,VerificationErrortests/test_math_verifier.py(62 cases) is green- Verifier passes all 50 dev-set solver traces
- Every named tamper class is caught by the test suite
- Smoke suite is green
- ADR linked from
docs/decisions/README.mdindex and frontier
Consequences
- The promise "every correct answer reproduces from disk byte-for-byte" is now mechanically verifiable by anyone — including a reviewer who does not trust the solver. The trace + the graph + the pack are sufficient.
- The audit story strengthens: ADR-0114a Obligation #3 is no longer
an in-process invariant; it's a cross-process invariant. A future
expertpromotion (ADR-0120) can require that every "correct" row in its evidence bundle ship with a verifier verdict, not just a solver outcome. - The verifier is the substrate for ADR-0119's GSM8K eval lane:
every case's answer goes through
verify()before scoring. A problem with a wrong trace (replay drift) is treated as awrongoutcome, notcorrect— closing the loophole where a buggy solver could produce coincidentally-correct answers via wrong steps.
Out of scope
- Stepped-realizer prose (ADR-0118) — distinct concern; consumes the same trace.
- GSM8K eval lane (ADR-0119) — uses this verifier as scoring substrate.
- Multi-pack verifier (verifier currently checks
en_arithmetic_v1hardcoded; future domains may have their own operator packs). - Property-based fuzzing the verifier against adversarial traces. Could be a follow-up if real-world traces ever produce surprises.