core/docs/decisions/ADR-0117-solution-trace-verifier.md
Shay 4336490731 feat: ADR-0117 — SolutionTrace verifier (solver-independent)
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>
2026-05-22 16:40:38 -07:00

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.