* fix(adr-0191): candidate-graph completeness guard — real-corpus wrong 5->0 The candidate-graph reader (serving) checked grounding + round-trip but had no completeness obligation, so problems whose later clauses failed to parse emitted a partial reading. Over the full 7,473-question real GSM8K train split this confabulated 5 answers (wrong!=0) the 47-case train_sample hid; 2 were regressions from #488. Add the missing admissibility leg (mirrors the derivation reader's verify.py): every source quantity (all statements + question) must be consumed by the chosen reading, else refuse. Refusal-only -> cannot create a wrong answer. Number-sense is pack-authoritative (en_numerics_v1 parse_compound_cardinal + lookup_multiplier + all 6 currency symbols) so it never disagrees with the engine; aggregating initials expose consumed_value_tokens provenance. Evidence: real-corpus wrong 5->0, correct held at 4; train_sample byte- identical 4/46/0; G1-G5+S1+G3.1 green; smoke 67 passed; math_teaching_corpus lane byte-identical. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(adr-0191): committed full-corpus GSM8K microscope (standing wrong=0 + coverage instrument) Promotes the throwaway tmp/ microscope that found the 5 confabulations into a committed tool. Runs the canonical serving reader over any GSM8K corpus and reports failures-first: correct/wrong/refused, every wrong answer by name, refusal families, and the no-injection per-category coverage map that ranks which injector to build next by real frequency. Default corpus is the committed 47-case train_sample (always available); --corpus path runs the full real split. This is the ADR-0191 follow-up: re-run after every capability PR, not just train_sample — a flip is only real if it does not widen the confabulation surface. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
7.6 KiB
ADR-0191 — Candidate-graph completeness guard (the missing wrong=0 leg)
Status: Proposed (implemented in this PR). Hardens the ADR-0123 candidate-graph reader's admissibility gate. Serving-path firewall fix; landed wrong=0-proven on the full real GSM8K train split, not just the 47-case sample.
One line. The candidate-graph reader checked grounding and round-trip but had no completeness obligation, so a problem whose later clauses failed to parse still emitted whatever partial graph remained. Over the full 7,473-question real GSM8K train split this confabulated 5 answers (wrong≠0). This adds the completeness leg the derivation reader's
verify.pyalready has: every source quantity must be consumed by the chosen reading, else refuse. Result: real-corpus wrong 5 → 0;train_samplebyte-identical 4/46/0.
1. The gap (full-corpus microscope finding, 2026-05-30)
The 47-case train_sample reports wrong=0. Running the canonical serving
reader (generate.math_candidate_graph.parse_and_solve) over the entire
real GSM8K train split (7,473 questions) revealed the sample was hiding a
firewall breach:
correct 4 · wrong 5 · refused 7,464 (origin/main @ #488)
The 5 confabulations (deterministic, 3× reproduced):
| idx | problem (abridged) | reader | gold |
|---|---|---|---|
| 553 | Emma buys 2/school-day … in 3 weeks? | 2 | 30 |
| 605 | Ivan 20 dice; Jerry twice as many; altogether? | 20 | 60 |
| 693 | Ian 20 roses; gave 6/9/4; kept rest? | 20 | 1 |
| 6172 | Jimmy 18 cards; gives 3; Mary twice that; left? | 15 | 9 |
| 7369 | Wilfred 4 Tue, 6 Wed; total 15 Tue–Thu; Thu? | -4 | 5 |
Two of these (693, 7369) were regressions introduced by #488 (ADR-0189/0189a): they refused correctly before that PR and confabulated after. The 47-case gate could not see it — exactly the lookback hazard CLAUDE.md §Lookback Review warns about.
Root cause — one structural hole, not five anecdotes. A graph is admitted when its present elements ground and round-trip. Nothing checks that every question-relevant source quantity is represented. When later clauses fail to parse into operations, the residual partial graph still solves and is emitted:
605buildsinitial=(Ivan:20), operations=()— a zero-operation graph answering "altogether"; the "twice as many" and the aggregate vanished.693the "He gave …" subtractions don't bind to "Ian" (live ADR-0174 pronoun hazard) and 2 of 3 are dropped → bare initial survives.
The derivation reader already refuses this (verify.py: grounding ∧ cue ∧
unit ∧ completeness ∧ uniqueness). The candidate-graph reader was missing
the completeness leg.
2. Decision
Add a completeness guard as the final admissibility check in
parse_and_solve, in a dedicated module generate/math_completeness.py:
Collect every numeric / multiplier quantity in the source (all statement sentences before the numeric-only filter, plus the question). Collect every quantity the chosen reading actually consumed (candidate provenance). If a source quantity is not consumed, the reading is incomplete → refuse.
uncovered = quantity_values(all_statements + question) − consumed_values(chosen_branch)
if uncovered: refuse("incomplete reading: …")
Why this preserves wrong=0 and cannot regress
- Refusal-only. The guard only ever flips an emitted answer to a
refusal. It can never invent an answer, so it can only remove wrong
answers — never create one. Its entire regression surface is the
graph-path correct set, which is exactly
{train_sample 0024}/{real-train 3343}(the same Sidney/Brooke day-enum + comparative shape). Both still solve (438). - Set semantics, not multiset.
required − consumedover value SETS tolerates a quantity echoed in the question (no false refusal) while still catching a clause whose distinct quantity was dropped — which is what every observed confabulation does. - Short-circuits are immune. Capacity / earnings / conditional / embedded short-circuits return before the graph decision rule, so the guard never touches them.
Pack-authoritative number-sense (no hand-rolled lexicon)
Both the required scan and the consumed normalization resolve quantities
through the en_numerics_v1 pack and the parser's own _resolve_value, so
identical surface forms cancel exactly and the guard never disagrees with the
engine about what a number is:
- Compound cardinals via
parse_compound_cardinal(one hundred→ 100,two thousand five hundred→ 2500,twenty-five→ 25). - Multiplier anchors via
lookup_multiplier— read from the pack, not hardcoded, so the guard automatically coverstwice, thrice, half, double, triple, quadruple, quintupleand excludes ordinal-ambiguousthird/quarter(which are not multipliers in the pack). - All six currency symbols (
$ ¢ € £ ¥ ₱) tokenize as whole spans.
Provenance for aggregating extractors
Aggregating initials collapse several source tokens into one derived value, so
they now expose every consumed token via a new
CandidateInitial.consumed_value_tokens field (default () → falls back to
matched_value_token, preserving all existing behavior):
- day-enumeration → every per-day count;
- embedded-quantifier → both
NandMofN×M; - conjoined-embedded → all four factors;
- multi-word cardinal → the full phrase.
3. Evidence
- Real GSM8K train (7,473): wrong 5 → 0; correct held at 4. Firewall
HOLDS. The 5 confabulations now refuse with
incomplete reading: …. train_sample(official metric): byte-identical 4/46/0, set{0014, 0018, 0024, 0042}.- Capability axes: G1–G5 + S1 + numerics extensions (G3.1) all green; the
first guard draft over-refused 20 G3 numerics cases (currency/decimal/
hyphenated/compound-cardinal mis-parse) — fixed by making the number-sense
pack-authoritative. Two pre-existing failures
(
test_committed_report_matches_fresh_run,test_full_session_round_trips) fail identically onorigin/mainand are unrelated. - Smoke suite: 67 passed.
- Pinned serving lanes:
math_teaching_corpus_v1report byte-identical; no pinned lane exercisesparse_and_solve. - New tests:
tests/test_candidate_graph_completeness_guard.py(5 confabulations refuse; Sidney/Brooke still solves; refusal-only invariant).
4. Consequences
- The candidate-graph reader now refuses partial readings instead of confabulating — the wrong=0 firewall holds on real data, not just the sample. This is the prerequisite for any further capability work: a flip is only real if it does not also widen the confabulation surface.
- The guard makes some genuinely-incomplete shapes refuse that previously emitted a (wrong) answer. That is the point. It never blocks a complete reading; once a future capability consumes the dropped quantity, coverage is satisfied and the case admits.
- Limitation (documented, not load-bearing): fraction words beyond the
multiplier set (e.g.
two-thirds) are not yet recognized as required quantities; this is conservative (can only under-refuse, never confabulate) and is future work when a fraction capability lands.
5. Follow-ups
- Re-run the full-corpus microscope after each future capability PR as a
standing wrong=0 regression check (not just
train_sample). - The 693 confabulation also exposed the live ADR-0174 multi-actor pronoun hazard; the completeness guard now refuses it, but the pronoun-binding fix remains the proper long-term resolution.