core/generate/math_completeness.py
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

210 lines
8.2 KiB
Python

"""Completeness leg of the candidate-graph reader's admissibility gate.
ADR-0191 — the candidate-graph reader checked *grounding* (every claimed
slot traces to a source token) and *round-trip* (the parsed candidate
re-realizes), but had no *completeness* obligation. A problem whose
later clauses failed to parse into operations still emitted whatever
partial graph remained — the classic confabulation the derivation
reader's ``verify.py`` already refuses (grounding ∧ cue ∧ unit ∧
**completeness** ∧ uniqueness).
This module supplies the missing leg as a pure, side-effect-free check:
Collect every numeric / multiplier quantity present in the source
(all statement sentences + the question). Collect every quantity the
chosen reading actually CONSUMED (candidate provenance). If a source
quantity is not consumed, the reading is incomplete → the reader must
refuse.
Design properties (why this preserves wrong==0 and cannot regress):
- **Refusal-only.** The check only ever flips an emitted answer to a
refusal; it never invents an answer. So it can only *remove* wrong
answers, never create one.
- **Set semantics, not multiset.** ``uncovered = required - consumed``
over value SETS. This deliberately tolerates a source quantity echoed
in the question (avoids false refusals) while still catching a clause
whose distinct quantity was dropped — which is what every observed
confabulation does.
- **Pack-authoritative number-sense.** Quantities are resolved through
the ``en_numerics_v1`` pack (``parse_compound_cardinal``) and the
parser's own ``_resolve_value`` — the same machinery the extractors
use. Identical surface forms (``$40``, ``twenty-five``, ``one
hundred``, ``3/4``) therefore resolve to identical values on both the
required and the consumed side and cancel exactly; the guard never
disagrees with the engine about what a number is.
- **Conservative multiplier set.** Only the unambiguous multiplier
anchors ``twice / thrice / half`` count as standalone quantity signals
(these are not cardinals). Ordinal-ambiguous words (``third`` /
``quarter`` — usually "the third day") are excluded to avoid spurious
refusals.
"""
from __future__ import annotations
import re
from typing import TYPE_CHECKING
from generate.math_candidate_parser import _CURRENCY_SYMBOLS, _resolve_value
from language_packs.numerics_loader import (
lookup_cardinal,
lookup_multiplier,
parse_compound_cardinal,
)
if TYPE_CHECKING: # pragma: no cover - typing only
from generate.math_candidate_parser import CandidateInitial
from generate.math_roundtrip import CandidateOperation
# Multiplier-anchor quantity signals (``twice``/``double``/``half`` ...) are
# read from the en_numerics_v1 pack via ``lookup_multiplier`` — NOT hardcoded
# — so the guard never drifts from the pack lexicon (it carries twice,
# thrice, half, double, triple, quadruple, quintuple). Ordinal-ambiguous
# words (``third`` / ``quarter``) are not multipliers in the pack, so they are
# excluded automatically rather than by a hand-maintained denylist.
def _multiplier_value(token: str) -> float | None:
entry = lookup_multiplier(token)
return float(entry.factor) if entry is not None else None
# Currency-symbol character class, taken from the parser's pinned symbol set
# (``$ ¢ € £ ¥ ₱``) so symbol-prefixed amounts tokenize as one span and
# resolve identically to the consumed candidate token.
_CURRENCY_CLASS = "".join(re.escape(c) for c in _CURRENCY_SYMBOLS)
# One pass that yields, in order: currency/digit/decimal/slash-fraction
# literals, and word tokens (incl. hyphenated cardinals like "twenty-five").
# Word runs are re-joined below so multi-word cardinals ("one hundred",
# "two thousand five hundred") resolve as a single quantity.
_TOKEN_RE = re.compile(
rf"[{_CURRENCY_CLASS}]?\d[\d,]*(?:\.\d+)?(?:/\d+)?" # $40 / 18.00 / 3/4
r"|[A-Za-z]+(?:-[A-Za-z]+)*" # words incl. hyphenated
)
def _numeric_token_value(token: str) -> float | None:
"""Value of a single non-cardinal token (digit/currency/fraction)."""
resolved = _resolve_value(token)
return float(resolved.value) if resolved is not None else None
def _token_value(token: str) -> float | None:
"""Canonical numeric value of a single quantity token, or None.
Multiplier anchors first, then compound cardinals (pack), then the
parser's value resolver for digit / currency / fraction surface
forms. Used to normalize CONSUMED candidate tokens identically to
the required scan.
"""
if not token:
return None
t = token.strip()
mult = _multiplier_value(t)
if mult is not None:
return mult
cardinal = parse_compound_cardinal(t)
if cardinal is not None:
return float(cardinal)
return _numeric_token_value(t)
def quantity_values_in_text(text: str) -> set[float]:
"""Every numeric / multiplier quantity value present in ``text``.
Greedily merges runs of cardinal words (joined by hyphens or "and")
so "two thousand five hundred" is one quantity, not five. Digit /
currency / fraction literals and multiplier anchors are resolved per
token. Pack-authoritative throughout.
"""
if not text:
return set()
values: set[float] = set()
tokens = _TOKEN_RE.findall(text)
i = 0
n = len(tokens)
while i < n:
tok = tokens[i]
low = tok.lower()
# Multiplier anchor (standalone quantity signal), per the pack.
mult = _multiplier_value(low)
if mult is not None:
values.add(mult)
i += 1
continue
# Cardinal-word run: extend across adjacent cardinal words and
# interior "and" connectors ("three hundred and fifty").
if lookup_cardinal(low) is not None:
run = [tok]
j = i + 1
while j < n:
nxt = tokens[j].lower()
if lookup_cardinal(nxt) is not None:
run.append(tokens[j])
j += 1
elif nxt == "and" and j + 1 < n and lookup_cardinal(
tokens[j + 1].lower()
) is not None:
run.append(tokens[j])
j += 1
else:
break
v = parse_compound_cardinal(" ".join(run))
if v is not None:
values.add(float(v))
i = j
continue
# Digit / currency / fraction literal.
v = _numeric_token_value(tok)
if v is not None:
values.add(v)
i += 1
return values
def _candidate_consumed_tokens(
choice: "CandidateInitial | CandidateOperation",
) -> tuple[str, ...]:
"""Source quantity tokens a single candidate consumed.
Aggregating initials (day-enumeration, embedded-quantifier,
multi-word-cardinal) collapse several source tokens into one derived
value; they expose every consumed token via ``consumed_value_tokens``.
Every other candidate consumes exactly its ``matched_value_token``.
"""
consumed = getattr(choice, "consumed_value_tokens", ())
if consumed:
return tuple(consumed)
tok = getattr(choice, "matched_value_token", "")
return (tok,) if tok else ()
def consumed_values(branch: tuple[object, ...]) -> set[float]:
"""Canonical quantity values consumed by a chosen reading (branch)."""
values: set[float] = set()
for choice in branch:
for tok in _candidate_consumed_tokens(choice): # type: ignore[arg-type]
v = _token_value(tok)
if v is not None:
values.add(v)
return values
def uncovered_quantities(
*,
statement_sentences: list[str],
question_text: str,
branch: tuple[object, ...],
) -> set[float]:
"""Source quantities the chosen reading failed to consume.
A non-empty result means the reading is incomplete: the source
carries a quantity the solved graph never accounts for, so emitting
its answer would confabulate. The reader must refuse.
"""
required: set[float] = set()
for s in statement_sentences:
required |= quantity_values_in_text(s)
required |= quantity_values_in_text(question_text)
return required - consumed_values(branch)