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.
12 KiB
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:
- Fractions end-to-end —
N/M of a <unit>initial possession. - Multi-currency —
¢ € ¥ ₱symbols (£ deferred; see below). - Multi-token space-separated cardinals —
one hundred,two thousand five hundred. - Word-number-adjective compositions —
five full boxesper 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→Ncents (1:1; ¢ face value IS the canonical unit).€N/€N.NN→N/N.NNeuros (factor=1; no sub-unit in pack).¥N→Nyen (integer-only; yen has no sub-unit in the pack or in common usage — decimal ¥ form refused).₱N/₱N.NN→N/N.NNpesos (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
_VALUEto greedily match cardinal-word sequences would require look-ahead to distinguish the last cardinal word from the first unit word (e.g. inone 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_VALUEunchanged 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_SYMBOLwidened to six-currency alternation._CURRENCY_SYMBOLSconstant: 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 forN/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:
.valueunwrap 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-equalreport.jsonacross 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 == 0on the axis lane proves no wrong answers were introduced. - Invariant proving field remains valid:
solved_wrong == 0on the v1_1 axis lane;admitted_wrong == 0on the GSM8K probe; no changes toalgebra/,chat/, orcore/. - CLI/eval lane:
PYTHONPATH=. python3 evals/math_capability_axes/G3_numerics/v1_1/runner.pyandpytest 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_idsanitiser (ADR-0051).