Records the reader-arc recalibration: halt bespoke-per-case regex work, verified clean-base reset to main@854ce023, and eradication of feat/reader-inc2-caseband's (PR #80) debt (loosened _token_in, empty proof file, bundled provenance) without merging that branch. Preserves the genuinely-general knowledge before the branch is retired: the has_numeric_token multiplier-blindness bug (root cause + minimal fix idea, deliberately not landed) and case 0148's correct-for-the-right-reason decomposition, both independently re-verified against source rather than copied from the branch's own claims (which included a stale narrative about a graph-dump proof file that was actually committed empty). Adds a file:line-cited overfit inventory (31 bespoke/single-case surface patterns) as inventory only -- nothing removed, the existing regex reader keeps serving unmodified. ADR-0251 proposes (not builds) a geometric surface->canonical normalization spike using existing off-serving primitives (conformal_procrustes, VocabManifold, the cognition pipeline's Phase C anti-unification telemetry), wrong=0-gated against holdout_dev/v1, with Smith-chart algebra and any Fibonacci "dimensional cascade" explicitly disclaimed. Awaiting ruling -- no implementation started.
223 lines
12 KiB
Markdown
223 lines
12 KiB
Markdown
# Reader-arc recalibration — preserved knowledge from `feat/reader-inc2-caseband` (PR #80)
|
||
|
||
**Date**: 2026-07-19
|
||
**Status**: Reference notes — validation targets for a future increment, not a build
|
||
**Origin**: Extracted before abandoning `feat/reader-inc2-caseband` (PR #80) per the recalibration
|
||
decision (see ADR-0251). The branch is **not merged**; nothing below authorizes landing its code.
|
||
Everything in this note was re-verified directly against source on `main @ 854ce023` and against the
|
||
abandoned branch's tip (`origin/feat/reader-inc2-caseband @ e3da148d`), not copied from the branch's own
|
||
claims — see §3 for a place where the branch's own narrative overstated what it verified.
|
||
|
||
---
|
||
|
||
## 1. The `has_numeric_token` multiplier-blindness bug (real, general, verified)
|
||
|
||
### 1.1 Root cause
|
||
|
||
`classify_sentence()` in `generate/math_candidate_parser.py:2385` (wrapping `has_numeric_token()` at
|
||
`:2374`) decides whether a statement sentence is skippable "context" or must carry numeric state:
|
||
|
||
```python
|
||
def has_numeric_token(sentence: str) -> bool:
|
||
if re.search(r"\d", sentence):
|
||
return True
|
||
return bool(_WORD_NUMBER_RE.search(sentence))
|
||
```
|
||
|
||
`_WORD_NUMBER_RE` is built from `WORD_NUMBERS` (`generate/math_roundtrip.py:218-228`) — the closed
|
||
cardinal set `zero..ninety, hundred, thousand` plus three ordinal-factor words `half/third/quarter`.
|
||
**It does not include multiplicative comparatives**: `twice`, `thrice`, `double`, `triple`,
|
||
`quadruple`, etc. A statement sentence whose *only* quantity signal is one of these words — no digit,
|
||
no cardinal word — is misclassified `"context"` and therefore treated as safe to drop.
|
||
|
||
Verified directly on `main`:
|
||
|
||
```python
|
||
>>> from generate.math_candidate_parser import classify_sentence
|
||
>>> classify_sentence("Jerry has twice as many apples as Ivan.")
|
||
'context'
|
||
```
|
||
|
||
This is the *exact* sentence the existing ADR-0191 comment in `generate/math_candidate_graph.py:670-673`
|
||
already names as the risk case ("`Jerry has twice as many … as Ivan` has no digit but a multiplier the
|
||
reading must account for") — the comment anticipated the failure mode; `has_numeric_token` does not yet
|
||
satisfy it.
|
||
|
||
### 1.2 Where it bites (and where it doesn't)
|
||
|
||
`classify_sentence` feeds a filter at `generate/math_candidate_graph.py:704-707`:
|
||
|
||
```python
|
||
numeric_statement_sentences = [s for s in statement_sentences if classify_sentence(s) == "numeric_state"]
|
||
if numeric_statement_sentences or not statement_sentences:
|
||
statement_sentences = numeric_statement_sentences
|
||
```
|
||
|
||
The guard (`numeric_statement_sentences or not statement_sentences`) only *replaces* `statement_sentences`
|
||
when the numeric-only subset is non-empty. Consequence, verified both ways:
|
||
|
||
- **Single-statement cases (e.g. case 0148 itself) are NOT affected** — if the multiplier-only sentence
|
||
is the *only* statement sentence, the numeric-only subset is empty, the guard is false, and the
|
||
original sentence (including the multiplier-only one) survives unfiltered. Confirmed: 0148's refusal on
|
||
`main` is **not** caused by this filter (see §2 — it's an actor-binding/surface gap instead).
|
||
- **Multi-statement cases ARE affected** — if at least one *other* statement sentence has a literal
|
||
digit/cardinal, the multiplier-only sentence is silently dropped from `statement_sentences` before any
|
||
recognizer/extractor sees it. Verified with a synthetic two-sentence case reproducing the ADR-0191
|
||
comment's own example:
|
||
```
|
||
'Ivan has 5 apples.' -> numeric_state
|
||
'Jerry has twice as many apples as Ivan.' -> context (dropped)
|
||
```
|
||
- **Real-corpus incidence, measured against `evals/gsm8k_math/holdout_dev/v1/cases.jsonl` (500 cases)**:
|
||
23 cases have a statement sentence containing only `twice/thrice/double/triple/quadruple` (no digit, no
|
||
cardinal) co-occurring with another statement sentence that does have a literal number — the exact shape
|
||
that triggers the drop. Examples: `0032`, `0060`, `0096`, `0112`, `0161`, `0165`, `0207` (several of
|
||
these — e.g. `0112` "Tim bought twice as many clowns as Rick bought guppies" — are load-bearing compare
|
||
clauses, not incidental frequency phrases like `0012`/`0127`/`0196`'s "twice a day"). This list is
|
||
**not triaged** — some of the 23 may already refuse/resolve correctly for unrelated reasons, or may be
|
||
masked by the completeness guard (§1.3). It is a candidate list for whoever picks this up next, not a
|
||
claimed fix count.
|
||
|
||
### 1.3 Why this has NOT been a `wrong=0` hazard (verified, not assumed)
|
||
|
||
`uncovered_quantities()` in `generate/math_completeness.py:194` (the ADR-0191 completeness guard) is a
|
||
**separate, already-correct** mechanism: it resolves multiplier anchors via the `en_numerics_v1` pack's
|
||
`lookup_multiplier` (`math_completeness.py:36-68`), independent of `has_numeric_token`. So even when the
|
||
primary filter silently drops a multiplier-only statement, the completeness guard still knows a "twice"
|
||
was present in the source, sees the chosen graph never consumed it, and **refuses** ("incomplete reading")
|
||
rather than emitting a wrong answer. This is refusal-only by construction (`math_completeness.py:19-23`),
|
||
so the bug's practical effect is **missed conversions (false refusals), not wrong answers**.
|
||
|
||
### 1.4 Minimal fix idea (NOT landed — do not cherry-pick without full-500 validation)
|
||
|
||
The abandoned branch's own fix (`generate/math_candidate_parser.py`, diff against `main`) was:
|
||
|
||
```python
|
||
_COMPARE_MULT_ANCHOR_TOKENS_RE: Final[re.Pattern[str]] = re.compile(
|
||
r"\b(?:" + "|".join(re.escape(k) for k in _ANCHOR_TO_FACTOR.keys()) + r")\b",
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
def has_numeric_token(sentence: str) -> bool:
|
||
if re.search(r"\d", sentence):
|
||
return True
|
||
if _COMPARE_MULT_ANCHOR_TOKENS_RE.search(sentence):
|
||
return True
|
||
return bool(_WORD_NUMBER_RE.search(sentence))
|
||
```
|
||
|
||
i.e. extend `has_numeric_token` to also recognize the closed `_ANCHOR_TO_FACTOR` multiplier-word set
|
||
(`twice, thrice, half, quarter, third, ...` — the same closed set the compare-multiplicative compiler
|
||
already trusts). This is a **general, shared-primitive fix** (unlike the mass-noun regex in §2, which is
|
||
case-bespoke) and is plausibly the right minimal change. **It is deliberately not being re-landed now**:
|
||
per the recalibration guardrails, a shared-grounding-primitive change must be proven coverage-neutral-or-
|
||
positive AND `wrong=0` on the full 500 before it lands, and it belongs together with whatever compare op
|
||
actually consumes the newly-admitted statements (landing the classifier fix alone, with nothing downstream
|
||
able to extract the now-visible sentence, only changes *which* refusal reason a case gets — not its
|
||
correct/wrong/refused bucket). Whoever revisits this should re-run the full 500 with only this isolated
|
||
change and confirm zero `correct → refused/wrong` regressions before considering it a candidate for main.
|
||
|
||
---
|
||
|
||
## 2. Case 0148 — solves correct-for-the-right-reason (verified target, not landed)
|
||
|
||
**Case**: `gsm8k-holdout-dev-v1-0148` — *"At a people counting station, the number of people counted on
|
||
the first day was twice the total number counted on the second day. If 500 people were counted on the
|
||
second day, how many people were counted on the two days?"* — expected answer **1500**.
|
||
|
||
**Why it refuses on `main` today** (verified): the statement sentence *does* survive the
|
||
`has_numeric_token` filter (§1.2 — single-statement case), and the recognizer does match its shape, but
|
||
produces no injection:
|
||
|
||
```
|
||
refusal_reason: "recognizer matched but produced no injection for statement: 'At a people counting
|
||
station, the number of people counted on the first day was twice the total number counted on the second
|
||
day.' (category=descriptive_setup_no_quantity)"
|
||
```
|
||
|
||
The real gap is that `main`'s compare-multiplicative extractor requires a `ProperName` actor
|
||
(`extract_proper_noun_subject`), and 0148's actors are mass-noun/temporal descriptions ("the first day" /
|
||
"the second day") — not proper nouns, and not covered by any pattern on `main`. This is a genuine surface
|
||
gap, not a solver gap.
|
||
|
||
**Decomposition (the correct-for-the-right-reason target)**:
|
||
- seed: **second day = 500** (bound from the conditional clause "If 500 people were counted on the second
|
||
day")
|
||
- compare: **first day = 2 × second day** (forward multiplicative compare)
|
||
- question: summation over both registers → **500 + 1000 = 1500**
|
||
|
||
**Verified by running the abandoned branch's code** (`origin/feat/reader-inc2-caseband @ e3da148d`, via a
|
||
throwaway detached worktree, `uv run python -c 'from generate.math_candidate_graph import
|
||
parse_and_solve; ...'`, worktree since removed) — this is the actual graph dump, not the empty file the
|
||
branch committed (see §3):
|
||
|
||
```json
|
||
{
|
||
"answer": 1500.0,
|
||
"is_admitted": true,
|
||
"refusal_reason": null,
|
||
"branches_enumerated": 1,
|
||
"branches_admissible": 1,
|
||
"selected_graph": {
|
||
"entities": ["the first day", "the second day"],
|
||
"initial_state": [
|
||
{"entity": "the second day", "quantity": {"value": 500, "unit": "people"}}
|
||
],
|
||
"operations": [
|
||
{
|
||
"actor": "the first day",
|
||
"kind": "compare_multiplicative",
|
||
"operand": {"reference_actor": "the second day", "delta": null, "factor": 2.0, "direction": "times"},
|
||
"target": null
|
||
}
|
||
],
|
||
"unknown": {"entity": null, "unit": "people"}
|
||
}
|
||
}
|
||
```
|
||
|
||
This confirms the 3-part correct-for-the-right-reason standard: the conditional-seed bound the *named*
|
||
reference (second day = 500, not a nearest/other entity), the compare op reads **forward** (first =
|
||
2×second, matching the source's "first day was twice the ... second day" — not an inverted reading), and
|
||
the unknown resolves via summation over exactly these two registers — 1500 from *this* graph, not a
|
||
coincidental path. On `main` (no mass-noun frame), the same case correctly refuses rather than guessing.
|
||
|
||
**What produced this conversion on the branch** (for context — not being landed): a bespoke
|
||
`_COMPARE_MASSNOUN_RE` regex family (`generate/math_candidate_parser.py`), a relaxation of the
|
||
proper-noun-only actor gate in `inject_comparative_multiplicative`
|
||
(`generate/recognizer_anchor_inject.py`) to allow mass-noun actors when that regex matches, and Gate A1
|
||
quantity-marker widening (`_COMPARATIVE_TOKENS` in `generate/recognizer_match.py` and
|
||
`evals/refusal_taxonomy/shape_categories.py`). All of this is bespoke to the 0148 surface shape and is the
|
||
kind of pattern the geometric-normalization direction (ADR-0251) is meant to supersede rather than
|
||
multiply — see the overfit inventory.
|
||
|
||
**Full-500 measurement on the branch** (`evals/gsm8k_math/holdout_dev/v1/report.json` diff): exactly one
|
||
case flipped, `0148: refused → correct`; counts `correct 5→6, refused 495→494, wrong=0→0` (held). Clean
|
||
single-case signature, no compensating regressions — this is genuine evidence, reproduced here for the
|
||
record since it lived only in a diff on an abandoned branch.
|
||
|
||
---
|
||
|
||
## 3. A correction to the branch's own narrative (why "verify, don't claim" matters here)
|
||
|
||
The branch's session-break summary (`docs/sessions/session-break-summary-2026-07-19-1310.md`, branch tip)
|
||
claimed: *"Graph dump for case 0148 committed to `docs/research/0148-first-conversion-proof.md` as proof
|
||
of correct-for-the-right-reason binding."* This is false — the committed file is **0 bytes**
|
||
(`git cat-file -s origin/feat/reader-inc2-caseband:docs/research/0148-first-conversion-proof.md` → `0`).
|
||
The actual graph dump was never captured to disk on the branch; only the narrative claimed it was. The
|
||
real artifact now lives in `docs/research/0148-first-conversion-proof.md` on this recalibration branch
|
||
(§2 above, generated fresh from the branch's code and independently re-verified against `main`'s refusal
|
||
behavior for the same case).
|
||
|
||
This is also why the human reviewer's second PR-review pass (PR #80 comment) flagged "commit evidence
|
||
before merge" as a blocker rather than accepting the summary's claim at face value — the discrepancy
|
||
between claimed and actual state is exactly the failure mode "verify, don't claim" exists to catch.
|
||
|
||
---
|
||
|
||
## 4. Disposition
|
||
|
||
Nothing in this note authorizes building on it. It exists so that a future, non-bespoke increment (the
|
||
geometric-normalization spike proposed in ADR-0251, or whatever a human ruling selects) has real,
|
||
re-verified targets — the 0148 decomposition and the `has_numeric_token` fix idea — instead of having to
|
||
rediscover them from an abandoned branch.
|