core/docs/decisions/ADR-0131.G.3.1-numerics-extensions.md
Shay 5853b189b2 feat(ADR-0131.G.3.1): numerics extensions — fractions + multi-currency + multi-token cardinals + word-num-adjective
Four axes deferred from ADR-0131.G.3 (PR #183):

1. Fractions end-to-end: new _INITIAL_FRACTION_OF_RE extractor handles
   `N/M of [a/an] <unit>` shape; _resolve_value already handles N/M arithmetic.

2. Multi-currency: _MONEY_SYMBOL widened to six symbols; _CURRENCY_SYMBOLS table
   + _resolve_currency dispatcher; ¢/€/¥/₱ wired end-to-end. £/pound sterling
   deferred to G.3.2 (question extractor's single-token unit slot cannot parse
   two-word surface "pounds sterling").

3. Multi-token cardinals: dedicated _MULTI_WORD_CARDINAL_RE extractor (approach a)
   delegates to parse_compound_cardinal; avoids greedy unit-slot boundary ambiguity
   from widening _VALUE.

4. Word-num-adjective: optional adjective group added to _INITIAL_HAS_RE and
   _MULTI_WORD_CARDINAL_RE; closed adjective list identical to _CONJ_OBJECT_RE.

Also fixes six pre-existing G4 type bugs where _resolve_value() result was used
directly as a numeric operand (TypeError: _ResolvedValue is not a number).

Axis lane v1_1: 20/20 solved_correct, 0 wrong, 8/8 refusals, overall_pass=True.
GSM8K probe: 0/50 admission_rate unchanged, admitted_wrong=0 (safety rail intact).
42/42 new tests pass; parent v1 lane (26/26) unaffected.
2026-05-23 15:16:46 -07:00

273 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 `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
`<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).