core/docs/decisions/ADR-0131.G.3-numerics.md
Shay 3011fce268 feat(ADR-0131.G.3): numeric literals — money + hyphenated cardinals (axis lane 20/20, wrong==0)
First capability-axis iteration after ADR-0131.G baseline. Extends the
candidate-graph parser's <value> slot to recognize:

  - Money symbol literals: $N and $N.NN (1-2 decimals); $N.NNN refused
  - Money word forms: N dollars / N cents
  - Hyphenated multi-word cardinals: twenty-five, ninety-nine, ...

All money values normalize to integer cents, unit 'cents' — pack-aligned
with en_units_v1's canonical_unit='cent' for the money dimension.
en_numerics_v1's parse_compound_cardinal handles hyphenated cardinals.

Parser changes (generate/):
  - math_candidate_parser.py: _VALUE alternation widened; _resolve_value
    refactored to return _ResolvedValue|None carrying optional unit
    override; _INITIAL_HAS_RE unit slot made optional; dollar/dollars →
    cents normalization at candidate build.
  - math_roundtrip.py: new _unit_grounds helper (money-aware); _value_grounds
    widened for the three new literal shapes; roundtrip_admissible uses
    _unit_grounds for the unit check.
  - math_candidate_graph.py: _initial_admissible and _question_admissible
    use _unit_grounds.

New axis lane (evals/math_capability_axes/G3_numerics/v1/):
  - 26 curated cases (20 positive across 4 classes + 6 refusal probes)
  - runner.py wraps _score_one_candidate_graph; byte-equal report.json
  - 20/20 positive solved correct; 6/6 refusal probes refused typed;
    solved_wrong == 0; overall_pass == True

Tests: 27/27 in 0.19s. 420 existing candidate-parser/math-parser/pack
tests still green. GSM8K probe safety rail (admitted_wrong == 0)
preserved.

Honest scope-limit (documented in ADR): admission_rate on the GSM8K
probe stays at 0/50 because (a) the probe currently consults the legacy
parser path, not the candidate-graph pipeline G.3 extends, and (b) most
money-bearing GSM8K cases fail first on verb (G.1) or multi-clause (G.4)
shape, not on the money literal. The axis lane is the load-bearing
measurement for this iteration. Reserved follow-up: a small probe-
infra ADR to switch run_coverage_probe.py to the candidate-graph
pipeline.

Out of scope, deferred to G.3.1: fractions end-to-end (resolver supports
N/M but no axis cases), multi-currency (¢ € £ ¥ ₱), space-separated
multi-word cardinals (one hundred), word-number-adjective compositions
(five full boxes).
2026-05-23 14:23:05 -07:00

9.8 KiB
Raw Blame History

ADR-0131.G.3 — Numeric Literals (money + hyphenated cardinals)

Status: Proposed Date: 2026-05-23 Author: CORE main agent (Opus 4.7) Depends on: ADR-0131.G (capability-axis iteration discipline), ADR-0126 (candidate-graph parser), ADR-0127 (units pack), ADR-0128 (numerics pack) Parent: ADR-0131 (composite math-expert promotion gate) Foundation for: ADR-0131.G.3.1 (fractions + multi-currency follow-up)


Context

ADR-0131.G pinned the GSM8K coverage probe as a diff-able admission number with the iteration discipline that each subsequent ADR-0131.G. extends a single capability axis with its own curated coverage cases, independent of GSM8K. G.3 is the first literal-recognition axis: extending the candidate-graph parser's <value> slot to recognize money literals and hyphenated multi-word cardinals by consuming the already-ratified en_units_v1 (ADR-0127) and en_numerics_v1 (ADR-0128) packs.

The baseline train_sample_coverage_report.json (#181) shows the target shapes appear in real refusals: Tina makes $18.00 an hour, Aaron and his brother Carson each saved up $40, hyphenated forms like 10 one-hour videos. Most refusals fail on structural clause shape first (verb, multi-clause), so admission lift on the probe requires sibling iterations (G.1 verbs, G.4 multi-clause) to land alongside. G.3 alone is necessary but not sufficient for probe lift; the load-bearing measurement for this iteration is the axis lane.

Decision

Scope (v1 — closed set)

  1. Money symbol literal$N and $N.NN (12 decimal places). $N.NNN (3+ decimals) refused.
  2. Money word form<N> dollars and <N> cents. Recognized via existing unit-slot path; normalized at candidate build.
  3. Hyphenated multi-word cardinaltwenty-five, ninety-nine, etc. Resolved via language_packs.numerics_loader.parse_compound_cardinal.

Canonical money normalization (load-bearing)

All money values normalize to integer cents, unit 'cents'. This is pinned by en_units_v1:

{"surface": "money", "canonical_unit": "cent",
 "is_canonical_for_dimension": true, "dimension": "money"}

cent is the canonical surface; lookup_unit("cent").plural is "cents". The parser canonicalizes both money symbols and the word forms N dollars / N cents to plural cents so the question-side canonicalization (How many cents does X have?Unknown.unit='cents') matches by exact equality.

dollar deliberately does not become the surface unit, even though it's more user-natural. Diverging from a ratified pack's canonical_unit because the alternative sounds nicer is the small precedent that erodes the "consult the pack" architecture. The realizer/formatter is the right layer for user-facing display of "$40.00" — that's a separate concern.

Refusal probes (closed set)

  • $N.NNN+ (3+ decimal places) — out-of-scope precision.
  • N/0 — division by zero.
  • Unrecognized hyphenated compositions (five-and-a-half, gobbledy-gook) — not resolvable via parse_compound_cardinal.
  • Percent literals (50%) — out-of-scope.

Out-of-scope, explicitly deferred to G.3.1+

  • Fractions in initial-possession / operations (N/M). The _resolve_value resolver supports N/M token-level (returns Fraction(N,M) → float), but no axis cases exercise it end-to-end because the initial-possession regex's "of " substance-qualifier handling needs widening to admit "Bob has 3/4 of a cup." cleanly. Token-level recognition is ratified now; pipeline-level use is G.3.1.
  • Multi-currency (¢ € £ ¥ ₱). US-only in v1.
  • Word-number compositions with adjective insertion (five full boxes per ADR-0127 substance-qualifier precedent).
  • Multi-token space-separated cardinals (one hundred, two thousand). parse_compound_cardinal supports these; the parser doesn't yet match them in the value slot because they'd span the unit slot boundary. Defer.
  • Scientific notation, locale separators, percentages.

What changed in code

generate/math_candidate_parser.py

  • New constants for the three widened value shapes (_MONEY_SYMBOL, _SLASH_FRACTION, _HYPHENATED_CARDINAL); _VALUE widened to include them in alternation.
  • _resolve_value(token) refactored to return _ResolvedValue | None (was int). Returns unit_override='cents' for money symbols; returns None (refusal) for out-of-scope shapes.
  • _INITIAL_HAS_RE unit slot made optional (money symbols carry their unit implicitly); trailing (?:in|of|for|with) preposition phrases discardable.
  • extract_initial_candidates + _build_op_candidate updated to honor unit_override, apply dollar/dollars → cents normalization via _money_unit_normalization, and emit no candidate when neither the resolver nor the regex provides a unit.

generate/math_roundtrip.py

  • New _unit_grounds(unit_token, source_span, haystack_tokens) helper: word-token containment plus money-aware grounding (when unit is cent/cents, accept $ in source or dollar/dollars in tokens).
  • _value_grounds widened to handle money symbols (digit-parts of $N.NN must each be in source tokens), slash fractions (numerator + denominator both ground), hyphenated cardinals (every component grounds OR the compound's digit form grounds).
  • roundtrip_admissible unit check upgraded from _token_in to _unit_grounds.

generate/math_candidate_graph.py

  • _initial_admissible and _question_admissible unit checks upgraded to _unit_grounds.

New files

  • evals/math_capability_axes/G3_numerics/v1/cases.jsonl — 26 curated cases (5 per positive class + 6 refusal probes).
  • evals/math_capability_axes/G3_numerics/v1/runner.py — pure adapter over evals.gsm8k_math.runner._score_one_candidate_graph; byte-equal report.json across runs.
  • tests/test_adr_0131_G3_numerics.py — 27 tests.

Evidence

  • Axis lane (python3 -m evals.math_capability_axes.G3_numerics.v1.runner):
    • 20/20 positive cases solved correct
    • 6/6 refusal probes refused with typed reason
    • solved_wrong == 0 (safety rail intact)
    • correct_rate_on_positive_cases == 1.0
    • report.json byte-equal across two runs
  • Test suite: 27/27 pass in 0.19s.
  • Regression: 420 existing candidate-parser + math-parser + pack tests pass with the widening.
  • GSM8K probe (evals/gsm8k_math/train_sample/v1/run_coverage_probe.py): unchanged at 0/50 admission (probe uses the legacy parser path, not the candidate-graph pipeline this iteration extends). admitted_wrong == 0 (safety rail) preserved.

Honest scope-limit disclosure

ADR-0131.G's discipline says each iteration's GSM8K-probe gate is "admission_rate strictly increases OR a refused-reason family is deliberately reduced." Neither holds for G.3 in isolation, because:

  1. The run_coverage_probe.py probe goes through evals.gsm8k_math.runner._score_oneparse_problem (the legacy first-match-wins parser, ADR pre-0126), not the candidate-graph pipeline G.3 extends.
  2. Even if the probe consulted the candidate graph, most money-bearing GSM8K cases (Tina makes $18.00 an hour) fail first on the verb (makes, rate-introducing) or multi-clause shape (Aaron and his brother Carson each saved up $40); the money literal is a downstream refusal cause.

G.3's load-bearing gate is therefore the axis lane (full end-to-end correctness on 20 curated cases that do exercise the new capability through the candidate-graph pipeline). Probe lift will accumulate as sibling axes (G.1 verb classes, G.4 multi- clause) land alongside.

Reserved follow-up: a small probe-infrastructure ADR should switch run_coverage_probe.py to call _score_one_candidate_graph instead of _score_one, so future G. iterations show probe lift. That's a one-line change but needs its own scoped PR (it shifts the probe's measurement substrate; the resulting admission_rate delta should be explicitly attributed, not buried inside a capability-axis PR).

Composition with other in-flight work

  • L9 / ADR-0131.G.1 (verb classes): lands the verbs that unlock Tina makes $18.00 an hour shape on the probe; G.3 already handles the $18.00 literal so admission flips when G.1 lands.
  • L10 / ADR-0131.G.2 (comparatives): independent; no literal-class interaction.
  • L12 / ADR-0131.G.4 (multi-clause): unlocks Aaron and his brother Carson each saved up $40 shape; G.3 already handles the $40 literal.
  • ADR-0131.G.3.1 (deferred follow-up): fractions end-to-end, multi-currency (¢ € £ ¥ ₱), space-separated multi-word cardinals (one hundred), word-number-adjective compositions (five full boxes).

CLAUDE.md PR-checklist answers

  • Capability/performance/security added or protected: Adds pack-consuming literal recognition for money symbols, money word forms, and hyphenated multi-word cardinals at the candidate-graph parser layer.
  • Invariant proving the field remains valid: solved_wrong == 0 on the axis lane (27 tests); admitted_wrong == 0 on the GSM8K probe (preserved).
  • CLI/eval lane proving correctness: python3 -m evals.math_capability_axes.G3_numerics.v1.runner and pytest tests/test_adr_0131_G3_numerics.py.
  • Avoided hidden normalization / stochastic fallback / approximate recall / unreviewed mutation: Yes. All lookups are deterministic, pack-driven (en_units_v1, en_numerics_v1). Money normalization is a single fixed rule (cents). Refusal-first preserved at every layer.
  • Trust boundary: Inputs are user-controlled text → parser regex; widened regex stays bounded (closed alternation set, no backreferences, no catastrophic-backtracking-risk patterns). Pack loader paths consult language_packs/data/<pack_id>/ only; no dynamic imports, no filesystem traversal beyond pack root.