core/docs/decisions/ADR-0126-candidate-graph-parser.md
Shay 5a987ed577 docs(ADR-0126): candidate-graph parser + round-trip verifier-filter (proposed)
Architectural pivot from per-axis grammar expansion (ADR-0122/0123/0123a/
0123b, all 0/1319 sealed lift) to candidate-graph topology with round-trip
admissibility filter. Converts compound-gap failure from multiplicative
p^k to parallel 1-(1-p)^k.

Adds new invariant: round-trip admissibility (op slots must reconstruct
to byte-equal source sentence under whitespace/case normalization).
Preserves wrong==0, trace_hash byte-equality, pack-binding, replay
determinism, no stochastic sampling.

Exit criterion: 50-case GSM8K train-split sample (unsealed) must show
correct >=10/50 with wrong==0 before any sealed-holdout run. If miss,
escalate to Path B (benchmark re-selection).

Supersedes ADR-0123b (never opened as PR).
2026-05-23 06:36:13 -07:00

259 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ADR-0126 — Candidate-Graph Parser with Round-Trip Verifier-Filter
**Status:** Proposed
**Date:** 2026-05-23
**Author:** CORE agents + reviewers
**Depends on:**
- ADR-0115 (parser substrate),
- ADR-0116 (deterministic solver),
- ADR-0117 (`SolutionTrace` verifier),
- ADR-0118 (stepped realizer),
- ADR-0119 (+ all 8 sub-phases — GSM8K-math lane substrate),
- ADR-0119.7 (sealed GSM8K test),
- ADR-0120 (expert promotion contract),
- ADR-0121 (math expert promotion DEFERRED with named blocker
`correct_rate = 0/1319` on sealed holdout).
**Supersedes:** ADR-0123b (`feat/adr-0123b-upstream-shape-gaps`,
commit `8c4070e`, never opened as PR). Substrate of ADR-0122 /
ADR-0123 / ADR-0123a remains in main and is consumed by this ADR.
---
## Context
ADR-0121 deferred the `mathematics_logic``expert` promotion with
the named blocker `correct_rate = 0/1319 on sealed GSM8K holdout`
and proposed a parser-expansion arc of 48 construction classes.
Three ADRs of that arc landed (ADR-0122 rate / per-unit, ADR-0123
comparison-phrasing, ADR-0123a comparison-shape-expansion) and a
fourth was prepared (ADR-0123b upstream-shape-gaps). All four:
- preserved every invariant (`wrong == 0`, byte-equal trace,
pack-binding, replay determinism),
- passed their author-drafted dev-set exit criteria,
- **measured 0/1319 sealed-holdout lift** (Gemini Tasks 6 and 8).
Two independent external assessments (Kimi K2.6, Grok 4.2) and an
internal review converged on the same diagnosis:
1. **The dev set is hermetically sealed from the real GSM8K
distribution.** It was authored by the people who authored the
parser, against the parser's grammar. Dev-set success is
uncorrelated (in expectation, anti-correlated) with sealed-holdout
lift.
2. **Per-axis vocabulary expansion is structurally optimistic.**
Each sealed case lives in the *intersection* of grammar gaps
(verb × number-word × question-shape × ellipsis × multi-step),
not the union. If each gap has independent coverage `p`, joint
pass rate is `p^k`. Adding one verb raises `p` infinitesimally;
joint pass rate barely moves.
3. **The current parser fails hard at the first unmatched
construction.** A single brittle regex (e.g., an ambiguous verb
match that steals the slot from a correct one) refuses an entire
problem with `ParseError`. This makes vocabulary expansion
adversarial against itself — adding new patterns can *lose*
coverage on already-passing problems.
The architectural error is the *topology* of failure, not the
*size* of vocabulary. Four zero-lift ADRs are the empirical
evidence that more grammar in the same shape will not move the
sealed-holdout score.
---
## Decision
Replace the **first-match-wins, single-graph, fail-hard** parser
topology with a **candidate-graph + verifier-filter** topology
that preserves every existing invariant while converting
compound-gap failure from multiplicative (`p^k`) to parallel
(`1 - (1-p)^k`).
### Pipeline change
```
OLD: surface → ONE graph → solve → verify(trace_replay)
NEW: surface → [graph_1, graph_2, … graph_n] → solve each
→ round-trip filter → admissibility filter → decision rule
```
### Per-sentence change
Each sentence emits `list[CandidateOperation]` (possibly empty)
instead of one `Operation` or `ParseError`. Multiple matching
patterns no longer race for a single slot; they all emit
candidates and the verifier filters wrong ones downstream.
Hard bound: `MAX_CANDIDATES_PER_SENTENCE = 4`. Exceeding raises
`ParseError` (preserves determinism cost ceiling).
### Graph assembly
Cartesian product of per-sentence candidate lists, enumerated
depth-first in canonical pattern-priority order
(most-specific-first). Hard bound: `MAX_TOTAL_BRANCHES = 64`.
Exceeding refuses the entire problem (preserves runtime ceiling).
### Round-trip filter (load-bearing new invariant)
A candidate operation is *round-trip admissible* iff
reconstructing the source sentence from its parsed slots produces
a string byte-equal to the original sentence under normalization
N (lowercase + collapse whitespace + strip terminal punctuation).
Formally, for source sentence `s` and candidate operation `op`:
```
admissible(op, s) ⟺ N(reconstruct(op)) == N(s)
```
This is the **wrong-answer firewall**. A regex that misreads
"gives" as `subtract` instead of `transfer` will reconstruct a
sentence that doesn't byte-equal the source; the candidate is
rejected before it can produce a wrong number.
### Decision rule (final emit)
For the set `A` of admissible candidate graphs:
| `|A|` | answer distribution | decision |
|-------|--------------------|----------|
| 0 | — | **refuse** (preserves `wrong == 0`) |
| 1 | — | **emit** the single answer |
| ≥2 | all identical | **emit** the common answer |
| ≥2 | non-identical | **refuse** (genuine ambiguity) |
### Invariant preservation
| Invariant | How preserved |
|-----------|---------------|
| `wrong == 0` | Round-trip filter rejects mis-parses; ambiguity refuses. |
| Trace determinism | Candidate enumeration canonical (lexical pattern key); branch order canonical (depth-first by sentence). |
| `trace_hash` byte-equality | Selected graph alone enters `SolutionTrace`; non-selected branches are not hashed. |
| Pack-binding | Candidate generators consume the same pack lemmas as today. |
| Replay equivalence | Replay re-runs candidate enumeration + filter; same input → same selected graph. |
| Adversarial `wrong == 0` | Adversarial suite gate runs unchanged; round-trip filter is *stricter* than today's gates. |
### New invariant added
**Round-trip admissibility:** any operation that emits in a final
`SolutionTrace` must satisfy `N(reconstruct(op)) == N(source_span)`
for the source span it claims to parse. This is a stricter
contract than `trace_hash` byte-equality across runs (which only
guards determinism) — it guards against semantic mis-parse.
---
## Exit criterion
**Inner-loop signal (new):** draw 50 cases from GSM8K *train*
split (deterministic seed, committed unsealed at
`evals/gsm8k_math/train_sample/v1/cases.jsonl`). Run candidate-graph
pipeline. The architecture is validated iff:
```
correct ≥ 10 / 50 (20% absolute)
wrong == 0
```
This is the first non-synthetic gradient signal in the GSM8K
lane. If the architecture is structurally sound, ≥20% lift on a
random sample is achievable without any new vocabulary work — the
gain comes purely from converting fail-hard to filter-and-pick.
**Path-B trigger:** if 50-case train sample shows `correct < 10`
after the candidate-graph pipeline is integrated, the parser-by-rule
architecture (in any topology) is the wrong abstraction for GSM8K
coverage. ADR-0126 itself is deferred and the work pivots to
benchmark re-selection (see "Alternatives considered → Path B"
below).
**Sealed-holdout protocol unchanged:** if the train-sample gate
passes, the sealed holdout runs exactly once and the number is
frozen in ADR-0126-results. The sealed `.age` ciphertext is never
modified.
---
## Alternatives considered
### Path A.1 — More vocabulary (status-quo continuation)
Continue the ADR-0122/0123/0123a/0123b cadence: catalog refusal
modes, add verbs/numbers/patterns, ship per-axis ADRs.
**Rejected** because four consecutive ADRs in this shape produced
0/1319 lift. The compound-gap math (`p^k`) explains why; no
amount of `p` improvement at the rate we can produce ADRs moves
joint pass rate meaningfully.
### Path B — Benchmark re-selection
Demote GSM8K to a stress test we honestly refuse on; re-target
the math-expert promotion to a benchmark where exact-recall,
determinism, and provenance are the discriminators (formal-math
symbolic equivalence, CORE-native teaching-corpus eval, MATH
symbolic subset).
**Held as fallback** triggered by the Path-B condition above. Not
chosen first because it forecloses the GSM8K target without
having tried the architectural fix that the compound-gap diagnosis
actually implies.
### Path C — Learned (LLM-aided) parser
Replace the rule-based parser with an LLM-assisted candidate
generator.
**Rejected** as a contract violation. ADR-0114a and the
project-wide stance (CLAUDE.md "no opaque LLM fallbacks, no
stochastic sampling, no hidden normalization") forbid this. The
candidate-graph approach in this ADR is the deterministic
analogue: parallel hypotheses with a verifier filter, no
sampling, no learned scoring.
---
## Implementation plan
| Phase | Module | Description |
|-------|--------|-------------|
| P1 | `generate/math_roundtrip.py` (new) | Standalone `roundtrip_matches(op, source) -> bool`. Unit tests over every existing op kind. This is the load-bearing primitive; nothing else matters if it doesn't work. |
| P2 | `generate/math_parser.py` (refactor) | `_process_statement` / `_process_question` return `list[CandidateOperation]`. Verb tables widened permissively. |
| P3 | `generate/math_candidate_graph.py` (new) | Branch enumerator + filter + decision rule. |
| P4 | `evals/gsm8k_math/runner.py` (wire) | Replace single-graph call with candidate-graph call. Preserve all current passing cases. |
| P5 | `evals/gsm8k_math/train_sample/v1/cases.jsonl` (new) | 50-case deterministic train-split sample (unsealed). |
| P6 | `evals/gsm8k_math/train_sample_runner.py` (new) | Run candidate-graph on train sample; emit `correct_rate`, `wrong_count`. |
Regression gates (must remain green at every phase):
- `core test --suite smoke`
- `core test --suite math` (existing 507/507)
- `core test --suite algebra` (82/82)
- 150/150 public split
- 200/200 dev set
- adversarial suite (`wrong == 0`)
---
## PR checklist (when proposing for merge)
```
What capability did this add?
→ Candidate-graph parser topology + round-trip verifier-filter.
What invariant proves the field remains valid?
→ Round-trip admissibility (new); wrong == 0 (preserved);
trace_hash byte-equality (preserved).
Which CLI suite/eval proves the lane?
→ smoke + math + algebra + 200/200 dev + train_sample_runner.
Did this avoid hidden normalization, stochastic fallback,
approximate recall, unreviewed mutation?
→ Yes. Candidate enumeration is deterministic + bounded.
Round-trip filter is deterministic byte comparison.
No learned scoring, no sampling.
If it touches user input, what trust boundary was enforced?
→ No new user-input surfaces. Round-trip reconstruction does
not echo unvalidated user content into logs.
```