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.
273 lines
12 KiB
Markdown
273 lines
12 KiB
Markdown
# 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).
|