# 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 <unit>` 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
`<Entity> has <N/M> of [a/an] <unit> [of <substance>]?`. 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=<canonicalized>`.
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<unit>\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 `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
  `<Entity> has <WORD_CARDINAL+> [<adjective>?] <unit>` 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<value>…)` slot and
the `(?P<unit>\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] <unit>`.
- `_MULTI_WORD_CARDINAL_RE`: new regex for `<Entity> has <WORD_CARDINAL+>
  [adj?] <unit>`.
- `_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<unit>\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).
