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

12 KiB
Raw Permalink Blame History

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-endN/M of a <unit> initial possession.
  2. Multi-currency¢ € ¥ ₱ symbols (£ deferred; see below).
  3. Multi-token space-separated cardinalsone hundred, two thousand five hundred.
  4. Word-number-adjective compositionsfive 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:

  • ¢NN cents (1:1; ¢ face value IS the canonical unit).
  • €N / €N.NNN / N.NN euros (factor=1; no sub-unit in pack).
  • ¥NN yen (integer-only; yen has no sub-unit in the pack or in common usage — decimal ¥ form refused).
  • ₱N / ₱N.NNN / 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).