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>
181 lines
7.4 KiB
Markdown
181 lines
7.4 KiB
Markdown
# 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.py` exports `verify`, `VerifierVerdict`,
|
|
`VerificationError`
|
|
- `tests/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.md` index 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
|
|
`expert` promotion (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 a `wrong`
|
|
outcome, not `correct` — 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_v1`
|
|
hardcoded; 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.
|