# ADR-0131.G.3.1 — Numerics extensions (fractions + multi-currency + multi-token cardinals + word-num-adjective) **Status:** Proposed **Date:** 2026-05-23 **Author:** CORE main agent (Sonnet 4.6) **Depends on:** ADR-0131.G.3 (parent, PR #183), ADR-0127 (units pack), ADR-0128 (numerics pack) **Parent:** ADR-0131 (composite math-expert promotion gate) **Foundation for:** ADR-0131.G.3.2 (£ / pound sterling follow-up) --- ## Context ADR-0131.G.3 (PR #183) shipped the first literal-recognition axis: money symbols (`$N` / `$N.NN`), money word forms (`N dollars` / `N cents`), and hyphenated multi-word cardinals (`twenty-five`). The four shapes explicitly deferred in G.3's "Out-of-scope" section form this iteration's closed-set scope: 1. **Fractions end-to-end** — `N/M of a ` initial possession. 2. **Multi-currency** — `¢ € ¥ ₱` symbols (£ deferred; see below). 3. **Multi-token space-separated cardinals** — `one hundred`, `two thousand five hundred`. 4. **Word-number-adjective compositions** — `five full boxes` per ADR-0127 substance-qualifier precedent. All canonical-unit architectural decisions from G.3 are preserved unchanged: cent is the canonical unit for money (US dollar path), pack-driven not regex-spam, refusal-first. --- ## Decision ### Axis 1 — Fractions end-to-end `_resolve_value` in `math_candidate_parser.py` already handles `N/M` (returns `_ResolvedValue(num / den, None)`). The pipeline-level gap was in `_INITIAL_HAS_RE`'s substance-qualifier handling: `Bob has 3/4 of a cup.` would match the main regex with `unit=None` (the `of a cup` phrase was consumed by the discardable substance qualifier), causing the candidate to not emit because no unit could be determined. **Fix**: new dedicated regex `_INITIAL_FRACTION_OF_RE` with shape ` has of [a/an] [of ]?`. This explicitly extracts the unit from the `of` phrase when the value is a slash fraction, emitting a `CandidateInitial` with `value=N/M` and `unit=`. The main `_INITIAL_HAS_RE` is unchanged; the new extractor fires in `extract_initial_candidates` after the primary extractor. Closed-set: digit/digit literal with `M > 0`. Division-by-zero refused at `_resolve_value` time (already the case since G.3). ### Axis 2 — Multi-currency All five pack-recognized foreign currency symbols are in `en_units_v1`: `¢` (cent), `€` (euro), `£` (pound sterling), `¥` (yen), `₱` (peso). **Wired in this iteration**: `¢`, `€`, `¥`, `₱`. **Deferred to G.3.2**: `£` / pound sterling. The `en_units_v1` pack correctly models pound sterling as a two-word unit (`"plural": "pounds sterling"`). However, the question extractor (`_Q_ENTITY_RE`, `_Q_TOTAL_RE`) uses `(?P\w+)` which only captures a single word token. A question like `How many pounds sterling does Alice have?` fails to parse the unit slot (`sterling` is a second word, not consumed by the single-token capture group). Widening the question extractor's unit slot to support multi-word units is a distinct architectural change that belongs in a dedicated PR (G.3.2) to keep the diff bounded and attributable. The `_resolve_value` and `_currency_symbols` table includes `£` → `pounds sterling` (so symbol parsing is complete); the question-extractor gap is the only blocker. **Normalization**: - `¢N` → `N` cents (1:1; ¢ face value IS the canonical unit). - `€N` / `€N.NN` → `N` / `N.NN` euros (factor=1; no sub-unit in pack). - `¥N` → `N` yen (integer-only; yen has no sub-unit in the pack or in common usage — decimal ¥ form refused). - `₱N` / `₱N.NN` → `N` / `N.NN` pesos (factor=1). 3+ decimal places are refused for all currency symbols (closed-set boundary; same rule as `$N.NNN`). **`_MONEY_SYMBOL`** regex widened to alternation over all six symbols. **`_unit_grounds`** in `math_roundtrip.py` widened: each currency symbol grounds its respective canonical unit when the symbol appears in the raw source span (mirrors the existing `$` / `dollar` grounding logic). **`_money_unit_normalization`** extended: word-form entries for `euro`, `euros`, `yen`, `peso`, `pesos` pass through with factor=1. ### Axis 3 — Multi-token space-separated cardinals `parse_compound_cardinal` in `language_packs/numerics_loader.py` already handles `"one hundred"`, `"two thousand five hundred"` etc. — it normalises hyphens to spaces then tokenises. The parser couldn't emit these in the value slot because `_VALUE` alternation uses a single-token pattern; a greedy multi-word sequence would span the unit-slot boundary. **Approach (a) chosen** (separate extractor) over **(b)** (`_VALUE` widening): - Widening `_VALUE` to greedily match cardinal-word sequences would require look-ahead to distinguish the last cardinal word from the first unit word (e.g. in `one hundred boxes`, `"one hundred"` is the value and `"boxes"` is the unit; greedy matching consumes all three). This unwinding requires either atomic groups (Python 3.11+) or a two-pass approach, both of which add complexity across every pattern that uses `_VALUE`. - A dedicated extractor (`_MULTI_WORD_CARDINAL_RE`) handles the ` has [?] ` shape precisely, leaves `_VALUE` unchanged for all other paths, and is auditable in isolation. `_MULTI_WORD_CARDINAL_RE` is built from the `WORD_NUMBERS` table keys (same source as `_WORD_NUM_OPTIONS` in `_VALUE`), requiring at least two consecutive cardinal words before the unit slot. The value group is passed to `parse_compound_cardinal` for resolution. Provenance: the first cardinal word of the sequence is the `matched_value_token` (it grounds because it is literally in the source span). ### Axis 4 — Word-number-adjective `five full boxes` per ADR-0127 substance-qualifier precedent. The adjective (`full`, `loose`, `empty`, `whole`, `broken`, `new`, `old`, `small`, `large`, `fresh`, `raw`, `flat`) is inserted between the cardinal value and the unit head noun. It is treated as part of the unit phrase: discarded at parse time, not surfaced to the solver. **Implementation**: an optional non-capturing group `(?:\s+(?:full|loose|…))?` is inserted between the `(?P…)` slot and the `(?P\w+)?` slot in `_INITIAL_HAS_RE`. The same adjective list is also added to `_MULTI_WORD_CARDINAL_RE`'s optional-adjective slot (axis 3 and axis 4 compose naturally). The adjective list is a closed set identical to the one in `_CONJ_OBJECT_RE` (already ratified by G.4), keeping both shapes consistent. ### Pre-existing G.4 type bugs fixed ADR-0131.G.4's multi-clause extractors (`_conj_subject_each_candidates`, `_conj_object_candidates`, `_embedded_quantifier_candidates`, `_build_conj_embedded_sum`, `_compare_multiplicative_candidates`, `_compare_nested_candidates`) called `_resolve_value(...)` directly as a numeric operand (e.g. `float(_resolve_value(x))`, `_resolve_value(n) * _resolve_value(m)`, `Quantity(value=_resolve_value(x), ...)`). Since `_resolve_value` returns `_ResolvedValue | None` (not a raw number), these sites raised `TypeError` at runtime when hit by the GSM8K coverage probe. This is a minimal fix: each site is updated to unwrap `.value` from the `_ResolvedValue` result (or guard against `None` and return `[]`/early exit). Semantics are identical — the value extracted is the same numeric payload; no new candidates are emitted or suppressed. --- ## What changed in code ### `generate/math_candidate_parser.py` - `_MONEY_SYMBOL` widened to six-currency alternation. - `_CURRENCY_SYMBOLS` constant: symbol → `(unit_surface, factor)` table. - `_resolve_currency`: dispatcher for all currency symbol literals. - `_resolve_value`: currency branch now delegates to `_resolve_currency`. - `_INITIAL_HAS_RE`: optional adjective group inserted between value and unit slots. - `_INITIAL_FRACTION_OF_RE`: new regex for `N/M of [a/an] `. - `_MULTI_WORD_CARDINAL_RE`: new regex for ` has [adj?] `. - `_fraction_of_candidates`: extractor for axis 1. - `_multi_word_cardinal_candidates`: extractor for axis 3. - `extract_initial_candidates`: calls both new extractors (after primary, before G.4 extractors). - `_money_unit_normalization`: extended for euro/yen/peso word forms. - G.4 pre-existing type fixes: `.value` unwrap at six call sites. ### `generate/math_roundtrip.py` - `_unit_grounds`: widened for `¢`, `€`, `¥`, `₱` symbols (mirrors existing `$` logic). - `_value_grounds`: currency symbol prefix check widened from `$`-only to the full `_CURRENCY_SYM_SET`. ### New files - `evals/math_capability_axes/G3_numerics/v1_1/cases.jsonl` — 28 cases (5 per axis × 4 + 8 refusal probes). - `evals/math_capability_axes/G3_numerics/v1_1/runner.py` — identical adapter pattern to v1; byte-equal `report.json` across runs. - `evals/math_capability_axes/G3_numerics/v1_1/report.json` — committed run artifact. - `tests/test_adr_0131_G31_numerics_extensions.py` — 42 tests. --- ## Evidence ### Axis lane (`v1_1/runner.py`) ``` axis: numerics_extensions cases_total: 28 solved_correct: 20 solved_wrong: 0 (gate: must be 0) refused_as_expected: 8 correct_rate_on_positive_cases: 100.0% overall_pass: True ``` Per-axis breakdown: - Axis 1 (fractions): 5/5 solved_correct - Axis 2 (multi-currency): 5/5 solved_correct (¢ € ¥ ₱; £ deferred) - Axis 3 (multi-word cardinals): 5/5 solved_correct - Axis 4 (word-num-adjective): 5/5 solved_correct - Refusal probes: 8/8 refused (percentages ×2, sci-notation ×2, locale-separators ×2, 3-decimal-money ×2) `report.json` byte-equal across two independent runs. ### Test suite **42/42 pass** in `tests/test_adr_0131_G31_numerics_extensions.py` (0.27s). ### Parent v1 lane regression **20/20 + 6/6 = 26/26** — unchanged from PR #183 commit. ### GSM8K coverage probe ``` admission_rate: 0/50 = 0.0% admitted_wrong: 0 (safety rail intact) ``` No probe delta. Admission remains at 0/50 for the same structural reasons identified in G.3: most GSM8K refusals are verb-class or multi-clause failures (G.1 / G.4 axes), not literal-recognition gaps. Fractions and multi-word cardinals don't appear in the 50-case probe as the binding blocker. The safety rail (`admitted_wrong == 0`) is preserved. --- ## Currencies deferred to G.3.2 **`£` / pound sterling**: `en_units_v1` is correctly populated; `_resolve_value("£15")` returns `_ResolvedValue(15, "pounds sterling")`. The blocker is the question extractor's single-token `(?P\w+)` group, which cannot parse `"How many pounds sterling does Alice have?"`. Fixing this requires widening the question-extractor unit slot to support multi-word units — a distinct scoped change for G.3.2. --- ## CLAUDE.md PR-checklist answers - **Capability/performance/security added:** Extends the candidate-graph parser's literal-recognition to four deferred shape families from G.3. `wrong == 0` on the axis lane proves no wrong answers were introduced. - **Invariant proving field remains valid:** `solved_wrong == 0` on the v1_1 axis lane; `admitted_wrong == 0` on the GSM8K probe; no changes to `algebra/`, `chat/`, or `core/`. - **CLI/eval lane:** `PYTHONPATH=. python3 evals/math_capability_axes/G3_numerics/v1_1/runner.py` and `pytest tests/test_adr_0131_G31_numerics_extensions.py`. - **Avoided hidden normalization/stochastic fallback/approximate recall/unreviewed mutation:** Yes. All lookups are deterministic and pack-driven. Currency normalization is a fixed table. No solver or binding-graph changes. - **Trust boundary:** User-controlled text → parser regex; all new regex patterns are closed alternations with no catastrophic-backtracking risk. Pack file paths use the existing `safe_pack_id` sanitiser (ADR-0051).