# 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. 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 `` 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** — ` dollars` and ` 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 " 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. 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//` only; no dynamic imports, no filesystem traversal beyond pack root.