core/docs/decisions/ADR-0191-candidate-graph-completeness-guard.md
Shay 25580e18b0
fix(adr-0191): candidate-graph completeness guard — real-corpus wrong 5→0 (#496)
* 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>
2026-05-30 15:45:07 -07:00

157 lines
7.6 KiB
Markdown
Raw Permalink 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-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 TueThu; 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:** G1G5 + 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.