First capability-axis iteration after ADR-0131.G baseline. Extends the
candidate-graph parser's <value> slot to recognize:
- Money symbol literals: $N and $N.NN (1-2 decimals); $N.NNN refused
- Money word forms: N dollars / N cents
- Hyphenated multi-word cardinals: twenty-five, ninety-nine, ...
All money values normalize to integer cents, unit 'cents' — pack-aligned
with en_units_v1's canonical_unit='cent' for the money dimension.
en_numerics_v1's parse_compound_cardinal handles hyphenated cardinals.
Parser changes (generate/):
- math_candidate_parser.py: _VALUE alternation widened; _resolve_value
refactored to return _ResolvedValue|None carrying optional unit
override; _INITIAL_HAS_RE unit slot made optional; dollar/dollars →
cents normalization at candidate build.
- math_roundtrip.py: new _unit_grounds helper (money-aware); _value_grounds
widened for the three new literal shapes; roundtrip_admissible uses
_unit_grounds for the unit check.
- math_candidate_graph.py: _initial_admissible and _question_admissible
use _unit_grounds.
New axis lane (evals/math_capability_axes/G3_numerics/v1/):
- 26 curated cases (20 positive across 4 classes + 6 refusal probes)
- runner.py wraps _score_one_candidate_graph; byte-equal report.json
- 20/20 positive solved correct; 6/6 refusal probes refused typed;
solved_wrong == 0; overall_pass == True
Tests: 27/27 in 0.19s. 420 existing candidate-parser/math-parser/pack
tests still green. GSM8K probe safety rail (admitted_wrong == 0)
preserved.
Honest scope-limit (documented in ADR): admission_rate on the GSM8K
probe stays at 0/50 because (a) the probe currently consults the legacy
parser path, not the candidate-graph pipeline G.3 extends, and (b) most
money-bearing GSM8K cases fail first on verb (G.1) or multi-clause (G.4)
shape, not on the money literal. The axis lane is the load-bearing
measurement for this iteration. Reserved follow-up: a small probe-
infra ADR to switch run_coverage_probe.py to the candidate-graph
pipeline.
Out of scope, deferred to G.3.1: fractions end-to-end (resolver supports
N/M but no axis cases), multi-currency (¢ € £ ¥ ₱), space-separated
multi-word cardinals (one hundred), word-number-adjective compositions
(five full boxes).
226 lines
9.8 KiB
Markdown
226 lines
9.8 KiB
Markdown
# ADR-0131.G.3 — Numeric Literals (money + hyphenated cardinals)
|
||
|
||
**Status:** Proposed
|
||
**Date:** 2026-05-23
|
||
**Author:** CORE main agent (Opus 4.7)
|
||
**Depends on:** ADR-0131.G (capability-axis iteration discipline),
|
||
ADR-0126 (candidate-graph parser), ADR-0127 (units pack),
|
||
ADR-0128 (numerics pack)
|
||
**Parent:** ADR-0131 (composite math-expert promotion gate)
|
||
**Foundation for:** ADR-0131.G.3.1 (fractions + multi-currency follow-up)
|
||
|
||
---
|
||
|
||
## Context
|
||
|
||
ADR-0131.G pinned the GSM8K coverage probe as a diff-able admission
|
||
number with the iteration discipline that *each subsequent
|
||
ADR-0131.G.<n> extends a single capability axis with its own
|
||
curated coverage cases, independent of GSM8K*. G.3 is the first
|
||
literal-recognition axis: extending the candidate-graph parser's
|
||
`<value>` slot to recognize money literals and hyphenated multi-word
|
||
cardinals by consuming the already-ratified `en_units_v1`
|
||
(ADR-0127) and `en_numerics_v1` (ADR-0128) packs.
|
||
|
||
The baseline `train_sample_coverage_report.json` (#181) shows the
|
||
target shapes appear in real refusals: `Tina makes $18.00 an hour`,
|
||
`Aaron and his brother Carson each saved up $40`, hyphenated forms
|
||
like `10 one-hour videos`. Most refusals fail on *structural*
|
||
clause shape first (verb, multi-clause), so admission lift on the
|
||
probe requires sibling iterations (G.1 verbs, G.4 multi-clause) to
|
||
land alongside. G.3 alone is **necessary but not sufficient** for
|
||
probe lift; the load-bearing measurement for this iteration is the
|
||
**axis lane**.
|
||
|
||
## Decision
|
||
|
||
### Scope (v1 — closed set)
|
||
|
||
1. **Money symbol literal** — `$N` and `$N.NN` (1–2 decimal places).
|
||
`$N.NNN` (3+ decimals) refused.
|
||
2. **Money word form** — `<N> dollars` and `<N> cents`. Recognized
|
||
via existing unit-slot path; normalized at candidate build.
|
||
3. **Hyphenated multi-word cardinal** — `twenty-five`, `ninety-nine`,
|
||
etc. Resolved via
|
||
`language_packs.numerics_loader.parse_compound_cardinal`.
|
||
|
||
### Canonical money normalization (load-bearing)
|
||
|
||
All money values normalize to **integer cents, unit `'cents'`**.
|
||
This is pinned by `en_units_v1`:
|
||
|
||
```jsonl
|
||
{"surface": "money", "canonical_unit": "cent",
|
||
"is_canonical_for_dimension": true, "dimension": "money"}
|
||
```
|
||
|
||
`cent` is the canonical surface; `lookup_unit("cent").plural` is
|
||
`"cents"`. The parser canonicalizes both money symbols and the
|
||
word forms `N dollars` / `N cents` to plural `cents` so the
|
||
question-side canonicalization (`How many cents does X have?` →
|
||
`Unknown.unit='cents'`) matches by exact equality.
|
||
|
||
`dollar` deliberately does **not** become the surface unit, even
|
||
though it's more user-natural. Diverging from a ratified pack's
|
||
`canonical_unit` because the alternative sounds nicer is the small
|
||
precedent that erodes the "consult the pack" architecture. The
|
||
realizer/formatter is the right layer for user-facing display of
|
||
"$40.00" — that's a separate concern.
|
||
|
||
### Refusal probes (closed set)
|
||
|
||
- `$N.NNN+` (3+ decimal places) — out-of-scope precision.
|
||
- `N/0` — division by zero.
|
||
- Unrecognized hyphenated compositions (`five-and-a-half`,
|
||
`gobbledy-gook`) — not resolvable via
|
||
`parse_compound_cardinal`.
|
||
- Percent literals (`50%`) — out-of-scope.
|
||
|
||
### Out-of-scope, explicitly deferred to G.3.1+
|
||
|
||
- **Fractions in initial-possession / operations** (`N/M`). The
|
||
`_resolve_value` resolver supports `N/M` token-level (returns
|
||
`Fraction(N,M)` → float), but no axis cases exercise it
|
||
end-to-end because the initial-possession regex's "of <NP>"
|
||
substance-qualifier handling needs widening to admit
|
||
`"Bob has 3/4 of a cup."` cleanly. Token-level recognition is
|
||
ratified now; pipeline-level use is G.3.1.
|
||
- **Multi-currency** (`¢ € £ ¥ ₱`). US-only in v1.
|
||
- **Word-number compositions with adjective insertion** (`five
|
||
full boxes` per ADR-0127 substance-qualifier precedent).
|
||
- **Multi-token space-separated cardinals** (`one hundred`, `two
|
||
thousand`). `parse_compound_cardinal` supports these; the parser
|
||
doesn't yet match them in the value slot because they'd span
|
||
the unit slot boundary. Defer.
|
||
- **Scientific notation, locale separators, percentages.**
|
||
|
||
## What changed in code
|
||
|
||
### `generate/math_candidate_parser.py`
|
||
|
||
- New constants for the three widened value shapes (`_MONEY_SYMBOL`,
|
||
`_SLASH_FRACTION`, `_HYPHENATED_CARDINAL`); `_VALUE` widened to
|
||
include them in alternation.
|
||
- `_resolve_value(token)` refactored to return `_ResolvedValue |
|
||
None` (was `int`). Returns `unit_override='cents'` for money
|
||
symbols; returns `None` (refusal) for out-of-scope shapes.
|
||
- `_INITIAL_HAS_RE` unit slot made optional (money symbols carry
|
||
their unit implicitly); trailing `(?:in|of|for|with)` preposition
|
||
phrases discardable.
|
||
- `extract_initial_candidates` + `_build_op_candidate` updated to
|
||
honor `unit_override`, apply dollar/dollars → cents normalization
|
||
via `_money_unit_normalization`, and emit no candidate when neither
|
||
the resolver nor the regex provides a unit.
|
||
|
||
### `generate/math_roundtrip.py`
|
||
|
||
- New `_unit_grounds(unit_token, source_span, haystack_tokens)`
|
||
helper: word-token containment plus money-aware grounding (when
|
||
unit is `cent`/`cents`, accept `$` in source or `dollar`/`dollars`
|
||
in tokens).
|
||
- `_value_grounds` widened to handle money symbols (digit-parts of
|
||
`$N.NN` must each be in source tokens), slash fractions
|
||
(numerator + denominator both ground), hyphenated cardinals
|
||
(every component grounds OR the compound's digit form grounds).
|
||
- `roundtrip_admissible` unit check upgraded from `_token_in` to
|
||
`_unit_grounds`.
|
||
|
||
### `generate/math_candidate_graph.py`
|
||
|
||
- `_initial_admissible` and `_question_admissible` unit checks
|
||
upgraded to `_unit_grounds`.
|
||
|
||
### New files
|
||
|
||
- `evals/math_capability_axes/G3_numerics/v1/cases.jsonl` — 26
|
||
curated cases (5 per positive class + 6 refusal probes).
|
||
- `evals/math_capability_axes/G3_numerics/v1/runner.py` — pure
|
||
adapter over `evals.gsm8k_math.runner._score_one_candidate_graph`;
|
||
byte-equal `report.json` across runs.
|
||
- `tests/test_adr_0131_G3_numerics.py` — 27 tests.
|
||
|
||
## Evidence
|
||
|
||
- **Axis lane** (`python3 -m
|
||
evals.math_capability_axes.G3_numerics.v1.runner`):
|
||
- 20/20 positive cases solved correct
|
||
- 6/6 refusal probes refused with typed reason
|
||
- `solved_wrong == 0` (safety rail intact)
|
||
- `correct_rate_on_positive_cases == 1.0`
|
||
- report.json byte-equal across two runs
|
||
- **Test suite**: 27/27 pass in 0.19s.
|
||
- **Regression**: 420 existing candidate-parser + math-parser +
|
||
pack tests pass with the widening.
|
||
- **GSM8K probe** (`evals/gsm8k_math/train_sample/v1/run_coverage_probe.py`):
|
||
unchanged at 0/50 admission (probe uses the legacy parser path,
|
||
not the candidate-graph pipeline this iteration extends).
|
||
`admitted_wrong == 0` (safety rail) preserved.
|
||
|
||
## Honest scope-limit disclosure
|
||
|
||
ADR-0131.G's discipline says each iteration's GSM8K-probe gate is
|
||
"`admission_rate` strictly increases OR a refused-reason family is
|
||
deliberately reduced." Neither holds for G.3 in isolation, because:
|
||
|
||
1. The `run_coverage_probe.py` probe goes through
|
||
`evals.gsm8k_math.runner._score_one` → `parse_problem` (the
|
||
legacy first-match-wins parser, ADR pre-0126), not the
|
||
candidate-graph pipeline G.3 extends.
|
||
2. Even if the probe consulted the candidate graph, most
|
||
money-bearing GSM8K cases (`Tina makes $18.00 an hour`) fail
|
||
first on the *verb* (`makes`, rate-introducing) or *multi-clause*
|
||
shape (`Aaron and his brother Carson each saved up $40`); the
|
||
money literal is a *downstream* refusal cause.
|
||
|
||
G.3's load-bearing gate is therefore the **axis lane** (full
|
||
end-to-end correctness on 20 curated cases that *do* exercise the
|
||
new capability through the candidate-graph pipeline). Probe lift
|
||
will accumulate as sibling axes (G.1 verb classes, G.4 multi-
|
||
clause) land alongside.
|
||
|
||
**Reserved follow-up**: a small probe-infrastructure ADR should
|
||
switch `run_coverage_probe.py` to call
|
||
`_score_one_candidate_graph` instead of `_score_one`, so future
|
||
G.<n> iterations show probe lift. That's a one-line change but
|
||
needs its own scoped PR (it shifts the probe's measurement
|
||
substrate; the resulting admission_rate delta should be
|
||
explicitly attributed, not buried inside a capability-axis PR).
|
||
|
||
## Composition with other in-flight work
|
||
|
||
- **L9 / ADR-0131.G.1 (verb classes)**: lands the verbs that unlock
|
||
`Tina makes $18.00 an hour` shape on the probe; G.3 already
|
||
handles the `$18.00` literal so admission flips when G.1 lands.
|
||
- **L10 / ADR-0131.G.2 (comparatives)**: independent; no
|
||
literal-class interaction.
|
||
- **L12 / ADR-0131.G.4 (multi-clause)**: unlocks `Aaron and his
|
||
brother Carson each saved up $40` shape; G.3 already handles
|
||
the `$40` literal.
|
||
- **ADR-0131.G.3.1 (deferred follow-up)**: fractions end-to-end,
|
||
multi-currency (`¢ € £ ¥ ₱`), space-separated multi-word
|
||
cardinals (`one hundred`), word-number-adjective compositions
|
||
(`five full boxes`).
|
||
|
||
## CLAUDE.md PR-checklist answers
|
||
|
||
- **Capability/performance/security added or protected:** Adds
|
||
pack-consuming literal recognition for money symbols, money
|
||
word forms, and hyphenated multi-word cardinals at the
|
||
candidate-graph parser layer.
|
||
- **Invariant proving the field remains valid:** `solved_wrong ==
|
||
0` on the axis lane (27 tests); `admitted_wrong == 0` on the
|
||
GSM8K probe (preserved).
|
||
- **CLI/eval lane proving correctness:** `python3 -m
|
||
evals.math_capability_axes.G3_numerics.v1.runner` and `pytest
|
||
tests/test_adr_0131_G3_numerics.py`.
|
||
- **Avoided hidden normalization / stochastic fallback /
|
||
approximate recall / unreviewed mutation:** Yes. All lookups
|
||
are deterministic, pack-driven (`en_units_v1`,
|
||
`en_numerics_v1`). Money normalization is a single fixed rule
|
||
(cents). Refusal-first preserved at every layer.
|
||
- **Trust boundary:** Inputs are user-controlled text → parser
|
||
regex; widened regex stays bounded (closed alternation set,
|
||
no backreferences, no catastrophic-backtracking-risk
|
||
patterns). Pack loader paths consult
|
||
`language_packs/data/<pack_id>/` only; no dynamic imports, no
|
||
filesystem traversal beyond pack root.
|