* 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>
157 lines
7.6 KiB
Markdown
157 lines
7.6 KiB
Markdown
# ADR-0191 — Candidate-graph completeness guard (the missing wrong=0 leg)
|
||
|
||
**Status:** Proposed (implemented in this PR). Hardens the
|
||
[ADR-0123](./ADR-0123-candidate-graph-reader.md) 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.py` already has: every source quantity must be
|
||
> consumed by the chosen reading, else refuse. Result: real-corpus
|
||
> **wrong 5 → 0**; `train_sample` byte-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:
|
||
|
||
- `605` builds `initial=(Ivan:20), operations=()` — a zero-operation graph
|
||
answering "altogether"; the "twice as many" and the aggregate vanished.
|
||
- `693` the "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**.
|
||
|
||
```text
|
||
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 − consumed` over 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 covers `twice, thrice, half,
|
||
double, triple, quadruple, quintuple` and excludes ordinal-ambiguous
|
||
`third` / `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 `N` and `M` of `N×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 on `origin/main` and are unrelated.
|
||
- **Smoke suite:** 67 passed.
|
||
- **Pinned serving lanes:** `math_teaching_corpus_v1` report byte-identical;
|
||
no pinned lane exercises `parse_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.
|