# 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.<n> 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` (1–2 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 cardinal** — `twenty-five`, `ninety-nine`,
   etc. Resolved via
   `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`:

```jsonl
{"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 <NP>"
  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_one` → `parse_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.<n> 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
  `packs/data/<pack_id>/` only; no dynamic imports, no
  filesystem traversal beyond pack root.
