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

226 lines
9.8 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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` (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 cardinal**`twenty-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`:
```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
`language_packs/data/<pack_id>/` only; no dynamic imports, no
filesystem traversal beyond pack root.