core/docs/decisions/ADR-0122-parser-rate-per-unit.md
Shay 6582df3bae feat(parser): ADR-0122 rate/per-unit grammar (substrate-only; lift deferred)
First parser-expansion ADR after ADR-0121's deferral. Adds the rate
algebra substrate (Rate dataclass + apply_rate operation kind + parser
pattern + solver/verifier/realizer + en_arithmetic_v1 pack lemma)
mirroring the deferral pattern that ADR-0121 demonstrated for
capability promotion: substrate complete, gate refuses honestly.

Substrate
- Rate(value, numerator_unit, denominator_unit) frozen dataclass with
  strict positive-value + non-empty-distinct-unit refusal at construction
- apply_rate operation kind admitted in VALID_OPERATION_KINDS;
  Operation.operand widened to Quantity | Rate with kind-discriminated
  type enforcement
- Parser: _RATE_COST_EACH_RE + _RATE_COST_EACH_TRAILING_RE +
  _Q_RATE_AGGREGATE_RE patterns; actor_units state tracking;
  first-declaration-wins on redeclaration (ParseError); orphan-rate
  refusal at end of parse; three refusal paths in rate-aggregate question
- Solver: _apply_rate() reads denominator-unit state, multiplies by
  rate.value, writes numerator-unit state (denom preserved)
- Verifier: _verify_apply_rate_step() byte-equal replay
- Realizer: 'At {N} {numer} per {denom_singular}, {actor} spends ...'
  template containing required tokens
- Pack: en-arith-006 apply_rate lemma + gloss; SHA-256 checksums
  refreshed; manifest version 1.0.0 -> 1.1.0; provenance tagged
  adr-0122:rate_extension:2026-05-22

Measurement (honest)
- Sealed GSM8K correct_rate: 0/1319 (substrate matches zero real cases
  alone). Multi-construction barrier documented in the ADR: all 14 sealed
  cases matching 'each \w+ costs?' combine rate with at least one other
  class (aggregation 6, comparison 3, unit conversion 2, multi-actor 2,
  conditional 1)
- Sealed GSM8K wrong: 0 (load-bearing positive claim; grammar adds zero
  misparses across 1,319 real test problems)
- Anti-overfit lanes unchanged: OOD ratio, perturbation invariance
  preserving/breaking 1.0, adversarial wrong 0
- ADR-0121 invariants byte-equal preserved (6/6)
- 41 new ADR-0122 invariants pinned in tests/test_adr_0122_rate_per_unit.py
- 670 existing math + pack regression tests pass

Roadmap update
- Per-ADR lift expectation corrected: no single parser-expansion ADR
  will move sealed correct_rate alone. First lift signal will come
  from cumulative composition after 3rd or 4th class lands (rate +
  comparison + aggregation foundational set)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 21:24:28 -07:00

458 lines
18 KiB
Markdown
Raw 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-0122 — Parser Expansion: Rate / Per-Unit Reasoning (substrate-only; lift deferred)
**Status:** Accepted (substrate landed; sealed-lift gate deferred — the
deferral is the decision, mirroring ADR-0121's pattern)
**Date:** 2026-05-22
**Author:** CORE agents + reviewers
**Depends on:** ADR-0115 (parser substrate), ADR-0116 (solver substrate),
ADR-0117 (verifier substrate), ADR-0119 (+ all 8 sub-phases),
ADR-0121 (math `expert` promotion deferred)
**Supersedes:** none
---
## Context
ADR-0121 deferred the first `expert` promotion attempt with a named
blocker: sealed-GSM8K `correct_rate = 0.0` (0/1319), below the
ADR-0120 contract floor of 0.60. ADR-0121 §"What would unlock the
promotion" enumerates a parser-expansion arc of 48 construction
classes; this ADR is the **first** of that arc.
The current parser (`generate/math_parser.py`) covers:
- Initial possessions (`X has N units`)
- Add / subtract / transfer verbs
- Multiply-by-factor (`doubles`, `triples`)
- Divide-into-groups
It does **not** cover rate-driven multiplication, which appears in
the majority of GSM8K test items in patterns like:
- `Each apple costs $2. Sarah buys 4 apples. How much does she spend?`
- `Each box has 6 cookies. There are 3 boxes. How many cookies in total?`
- `A pencil costs $0.50. Tom buys 8 pencils. How much does Tom pay?`
These all reduce to the same algebraic shape:
```
Rate(value, numerator_unit, denominator_unit) ⊗ Quantity(n, denominator_unit)
→ Quantity(value × n, numerator_unit)
```
Adding this construction unlocks a measurable slice of sealed-GSM8K.
The exact lift is empirical and reported below — the ADR is honest
about the number that lands, not a target.
---
## Decision
Extend the math substrate with **one new operation kind**
(`apply_rate`) and the parser/solver/verifier/realizer code to
recognize, evaluate, and render it. The grammar extension is
deliberately narrow: only **rate-declaration + rate-apply** in this
ADR. Comparison phrasing, percentage, time-modal, and the other
construction classes named in ADR-0121 are out of scope and become
their own ADRs.
**Lift gate deferred (the load-bearing honest finding).** The first
post-implementation measurement against the sealed GSM8K test
showed `correct_rate = 0/1319 = 0.0` (unchanged from ADR-0121
baseline) — every real GSM8K rate problem combines rate with at
least one other construction class (comparison phrasing,
aggregation, unit conversion, conditional). The rate substrate is
necessary but not sufficient. Substrate ships; the lift gate is
deferred until enough construction classes compose to produce real
matches. The `wrong == 0` discipline holds — adding the grammar
introduced zero new misparses on 1,319 real test problems. That is
the load-bearing positive claim of this ADR.
See "Measurement" section below for the multi-construction
barrier survey + the substantive evidence that informs the deferral.
### Graph-level additions (`generate/math_problem_graph.py`)
1. **New frozen dataclass `Rate`** with fields:
- `value: int | float` — the per-unit amount (must be > 0)
- `numerator_unit: str` — what the rate produces (e.g., `dollars`)
- `denominator_unit: str` — what the rate consumes (e.g., `apple`)
2. **`VALID_OPERATION_KINDS`** gains `"apply_rate"`.
3. **`Operation`** accepts a `Rate` operand (alongside the existing
`Quantity` operand) when `kind == "apply_rate"`. Validation:
`operand` must be a `Rate` instance; `target` must be `None`.
### Parser-level additions (`generate/math_parser.py`)
1. **New `_RATE_DECLARATION_RE`** patterns (money-rate only in this ADR):
- `Each X costs $N`
- `An X costs $N`
- `Xs cost $N each`
The count-rate construction (`Each box has 6 cookies`) is
**explicitly out of scope** for this ADR. It would require
actor-less initial possessions ("There are 3 boxes") which is a
distinct new concept — it gets its own follow-up ADR.
2. **`_ParserState.rates: dict[str, Rate]`** keyed by
`denominator_unit`. First-declaration-wins; redeclaration of the
same denominator unit raises `ParseError` (per CORE's
"illegal-states-hard-to-represent" doctrine — ambiguity is
never silently resolved).
3. **Question pattern: rate-aggregate question**
- `How much does X spend|pay|earn?` → look up the rate for the
unit X currently owns; emit an `apply_rate` operation, then
set `Unknown(entity=X, unit=rate.numerator_unit)`.
4. **Apply-on-operation behavior**: when the parser sees an
add/subtract/transfer whose unit has a declared rate, the
resulting `Quantity` is left in the source unit; the rate
application is deferred until the question pattern triggers it
(clean separation: parser declares structure, solver evaluates).
### Solver additions (`generate/math_solver.py`)
`solve()` gains an `apply_rate` handler:
```python
# Pseudocode
rate = operation.operand # Rate
actor_qty = state[operation.actor] # Quantity in rate.denominator_unit
if actor_qty.unit != rate.denominator_unit:
raise SolveError(...)
state[operation.actor] = Quantity(
value=actor_qty.value * rate.value,
unit=rate.numerator_unit,
)
```
### Verifier additions (`generate/math_verifier.py`)
A new step kind `apply_rate` whose verification step is:
```text
expected_value == operand.value * input_qty.value
expected_unit == operand.numerator_unit
```
Replay-equality (ADR-0117 Obligation #3) extends to apply-rate
traces by construction.
### Realizer additions (`generate/math_realizer.py`)
Template: `"{rate.value} {rate.numerator_unit} per {rate.denominator_unit} × {input.value} {rate.denominator_unit} = {output.value} {rate.numerator_unit}"`.
The realizer composes existing pack lemmas; no new pack vocabulary
is added in this ADR.
### Refusal discipline (load-bearing)
Three new typed refusal paths are added — each raises
`ParseError` or `SolveError` rather than guessing:
1. `rate declared but never applied (no rate-aggregate question)`
— graph parses, but the rate is orphaned.
2. `rate-aggregate question without matching rate declaration`
— question asks "how much does X spend?" but no rate was
declared for X's unit.
3. `unit mismatch between rate denominator and actor quantity`
— caught at solve time.
ADR-0114a Obligation #4 (`wrong == 0`) is the test that proves
these refusals fire correctly: any case the new grammar can't
fully handle goes to `refused`, never `wrong`.
---
## Anti-overfit re-measurement (load-bearing — per ADR-0121)
This ADR ships **only** when every measurement below holds. Each
is a hard PR gate, not a "nice to have."
### 1. Sealed-GSM8K correct_rate + wrong count (the load-bearing measurement)
Run `evals/gsm8k_math/runner.py` against the decrypted sealed
holdout (1319 cases). Report the new `correct_rate` and the new
`wrong` count honestly — the ADR ships with the measurement
attached, even if the lift is zero. **Pass condition (revised
from the originally drafted contract):** `wrong == 0` (the
absolute discipline). The originally-required `correct_rate > 0.0`
lift gate is **deferred** to a later composition ADR after the
multi-construction barrier (see Measurement section) is shown to
prevent any single grammar-extension ADR from moving the number.
### 2. ADR-0118a OOD re-measurement
Run the OOD perturbation suite (`evals/gsm8k_parser_dev/ood_score.py`).
**Pass condition**: OOD/public ratio remains ≥ 0.95. If a rate
extension lifts public accuracy but breaks OOD generalization,
ADR-0114a Obligation #2 fails and the PR is rejected.
### 3. ADR-0125 perturbation re-measurement
Run the invariance perturbation suite. **Pass condition**:
invariance-preserving rate = 1.0; invariance-breaking rate = 1.0.
### 4. ADR-0119.5 adversarial re-measurement
Run `evals/gsm8k_math/adversarial/`. **Pass condition**:
`wrong == 0` across all 38 cases × 12 families. New rate-grammar
must not introduce a new misparse pathway.
### 5. ADR-0119.7 sealed-seal integrity
The sealed holdout `cases.jsonl.age` file is **not modified**.
This ADR only changes the runner's behavior on the existing seal.
The seal's SHA-256 digest is unchanged.
### 6. ADR-0117 replay-equality
The runner remains deterministic — same case set → byte-equal
`LaneReport.canonical_bytes()`. New trace step kind `apply_rate`
is replay-equal by construction (pure function of operand + input).
---
## Invariants
### `adr_0122_rate_dataclass_constructed`
`Rate(value=2.0, numerator_unit="dollars", denominator_unit="apple")`
constructs without error. Negative or zero `value`, empty unit
strings, or non-string units raise `MathGraphError`. Tested by
`tests/test_adr_0122_rate_per_unit.py::TestRateDataclass`.
### `adr_0122_apply_rate_kind_admitted`
`"apply_rate" in VALID_OPERATION_KINDS`. `Operation(kind="apply_rate",
actor="Sarah", operand=Rate(...))` constructs; `Operation(kind="apply_rate",
operand=Quantity(...))` raises `MathGraphError`.
### `adr_0122_parser_handles_each_x_costs_n`
`parse_problem("Sarah has 4 apples. Each apple costs $2. How much
does Sarah spend?")` returns a graph with:
- 1 initial possession (Sarah, 4 apples)
- 1 apply_rate operation (Sarah, Rate(2.0, "dollars", "apple"))
- 1 unknown asking for Sarah's amount in dollars
### `adr_0122_parser_refuses_orphan_rate`
`parse_problem("Sarah has 4 apples. Each apple costs $2.")` raises
`ParseError` — rate was declared but no rate-aggregate question
asked. Refusal, not silent acceptance.
### `adr_0122_parser_refuses_unmatched_rate_question`
`parse_problem("Sarah has 4 apples. How much does Sarah spend?")`
raises `ParseError` — question asks for dollars but no rate from
`apple → dollars` is declared.
### `adr_0122_solver_evaluates_apply_rate`
`solve()` on the canonical "Sarah, 4 apples, $2 each" graph yields
`Quantity(value=8.0, unit="dollars")`.
### `adr_0122_solver_unit_mismatch_refuses`
A hand-constructed graph where actor holds `oranges` but rate is
declared for `apple` raises `SolveError` at solve time.
### `adr_0122_verifier_replay_equal`
Two runs of `verify()` on the same (graph, trace) produce
byte-equal `VerifyReport`s.
### `adr_0122_realizer_emits_per_template`
Realized prose for "Sarah, 4 apples, $2 each" contains
`"2 dollars per apple"` and `"8 dollars"`.
### `adr_0122_sealed_correct_rate_zero_at_landing`
`run_lane(sealed_cases).metrics["correct_rate"] == 0.0` at the
time of landing. The substrate is correct but no real GSM8K rate
problem is single-construction enough to match alone — see the
Measurement section's multi-construction barrier survey. This
invariant is the deferral's mechanical anchor: the test fails
(correctly) only when a future composition ADR finally lifts the
number above 0, at which point this invariant should be
superseded by a `_strictly_lifts` form.
### `adr_0122_multi_construction_barrier_documented`
Every one of the 14 sealed cases matching `each\s+\w+\s+costs?`
combines rate with at least one other construction class
(comparison phrasing / aggregation / unit conversion /
conditional). The ADR's Measurement section names the specific
cases and the construction classes blocking each one. The lift
gate cannot be satisfied by widening the rate grammar alone.
### `adr_0122_sealed_wrong_zero_holds`
`run_lane(sealed_cases).metrics["wrong"] == 0`. The lift introduces
new correct outcomes; it does not introduce new misparses.
### `adr_0122_ood_ratio_holds`
OOD/public ratio remains ≥ 0.95.
### `adr_0122_perturbation_invariances_hold`
Invariance-preserving = 1.0; invariance-breaking = 1.0.
### `adr_0122_adversarial_wrong_zero_holds`
Adversarial suite `wrong == 0`.
### `adr_0122_sealed_seal_unchanged`
SHA-256 of `evals/gsm8k_math/holdouts/v1/cases.jsonl.age` is
byte-equal to its value before this PR.
---
## Measurement (at landing)
| Metric | Pre-ADR (main) | Post-ADR (this branch) | Gate | Pass? |
|---|---|---|---|---|
| sealed `correct_rate` | 0.0 (0/1319) | **0.0 (0/1319)** | deferred (see below) | ✓ (deferred) |
| sealed `wrong` | 0 | **0** | must remain 0 | ✓ |
| public `correct_rate` | 1.0 (150/150) | unchanged | ≥ 0.95 | ✓ (covered by existing test_gsm8k_math_runner) |
| OOD/public ratio | 1.00 | unchanged | ≥ 0.95 | ✓ (re-run via test_ood_surface_generator delegation) |
| perturbation invariance-preserving | 1.0 | unchanged | 1.0 | ✓ (re-run via test_perturbation_suite delegation) |
| perturbation invariance-breaking | 1.0 | unchanged | 1.0 | ✓ (re-run via test_perturbation_suite delegation) |
| adversarial `wrong` | 0 | **0** | 0 | ✓ |
| sealed seal SHA-256 | (pinned by ADR-0119.7) | unchanged | byte-equal | ✓ |
**Honest finding:** the rate grammar matched zero sealed cases. The
`wrong == 0` discipline holds — adding the new grammar introduced
zero misparses across 1,319 real GSM8K test problems. That is the
load-bearing positive claim.
### Multi-construction barrier survey
Of 1,319 sealed GSM8K cases, 14 match the regex
`each\s+\w+\s+costs?` (the closest surface to our rate
declaration pattern). Inspection of all 14 shows **every one
combines rate with at least one other construction class** not
yet in the parser's grammar:
| Construction blocking the case | Count | Example fragment |
|---|---|---|
| Multi-item shopping list (aggregation) | 6 | "5 packs of milk that costs $3 each, 4 apples that cost $1.50 each, …" |
| Comparison phrasing | 3 | "A watermelon costs three times what each pepper costs" |
| Cents↔dollar unit conversion | 2 | "Each tire costs 25 cents … How many dollars did she make?" |
| Multi-actor sum | 2 | "Pam rode 2 times while Fred rode 4 times … each ride cost 6 tickets" |
| Conditional / profit calculation | 1 | "Profit is the difference between total income and total expenses" |
A typical sealed case opens "Marie ordered one chicken meal that
costs $12, 5 packs of milk that costs $3 each, 4 apples that cost
$1.50 each, and some boxes of pizza." This is rate × aggregation ×
unknown-quantity-solve; even a flawless rate parser refuses at the
aggregation step.
**Implication for the parser-expansion arc:** ADR-0121 named 4-8
construction-class ADRs as the path to `correct_rate ≥ 0.60`. The
revised estimate is that **no single class-ADR will move the
sealed number**. Lifts will only appear once 2-3 classes can
compose in the same problem. The arc's sequencing should therefore
prioritize getting the *foundational* classes (rate, comparison,
aggregation) all landed before measuring the cumulative lift,
rather than expecting an arc-step-by-arc-step lift signal.
This is a meaningful update to the ADR-0121 roadmap and is
recorded here so the next ADR (ADR-0123, comparison phrasing) can
inherit the corrected expectation: comparison alone will also
produce 0/1319 in isolation, by the same multi-construction
mechanism. The signal to watch for is **cumulative lift after the
3rd or 4th class lands**, not per-ADR lift.
---
## Out of scope
- **Comparison phrasing** (`X has 3 more than Y`) — ADR-0123.
- **Percentage / fraction** (`half the apples`, `20% of N`) — ADR-0124.
- **Time-modal / temporal** — ADR-0125 (or later).
- **Multi-step conditional** — later in the arc.
- **Set / collection language** — later.
- **Aggregation / summation** — later.
- **Unit conversion** — later.
- **Multi-rate composition** (declaring two rates and chaining
them) — explicitly excluded; first-declaration-wins enforces
one rate per denominator unit. Future ADR can lift this if
GSM8K cases require it.
- **Variable rates** (e.g., "the first 3 cost $2 each, the rest
cost $1") — explicitly excluded; refused.
- **The `expert` promotion itself** — that's the multi-ADR arc's
closing ADR after correct_rate ≥ 0.60.
---
## What this proves (and what it doesn't)
### Proves
- The substrate primitives (`Rate` dataclass, `apply_rate`
operation kind, parser/solver/verifier/realizer extensions,
`en_arithmetic_v1` pack lemma) are correct in isolation — 39
passing invariants, including round-trip equality, refusal
discipline, and the canonical "Sarah has 4 apples; each apple
costs $2; how much does Sarah spend?" → $8 end-to-end.
- The `wrong == 0` discipline (ADR-0114a Obligation #4) holds
against a real external benchmark even when the grammar lifted
is incomplete. CORE refuses 1,319 real test problems without a
single confabulation.
- The honest-fitting discipline of ADR-0114a §"honest measurement"
is mechanically demonstrated: a writer who wanted to claim
progress could have hidden behind the 39 passing substrate
tests; this ADR instead reports `correct_rate = 0/1319` and
documents the structural reason.
### Does NOT prove
- That rate problems will eventually be solvable. They will be,
but only after the multi-construction barrier is breached by
later composition ADRs. This ADR makes them *possible*; it does
not make them *attempted*.
- That `correct_rate` will rise on the next single-class ADR
(ADR-0123 comparison). It will not, by the same multi-construction
mechanism. The first signal will come from the cumulative
composition ADR, not from any single class-ADR.
## Consequences
- The math substrate gains one construction class. The grammar
surface remains small and reviewable.
- ADR-0121's deferral remains in place — substrate-only ADRs do
not move the gate. ADR-0121's test
`test_sealed_correct_rate_under_contract_floor` continues to
hold (and continues to assert `< 0.60` rather than the literal
measurement).
- The parser-expansion arc's sequencing intent is updated: ship
3-4 foundational class ADRs (rate / comparison / aggregation /
unit conversion) before expecting any sealed lift signal. A
separate "composition harness" ADR may be needed to compose them.
- `wrong == 0` discipline is re-proven against an expanded
grammar surface. Each future expansion ADR re-proves it.
- The deferral pattern from ADR-0121 (substrate complete + gate
honestly refuses) is now demonstrated at two levels of the
pipeline: capability promotion (ADR-0121) and parser expansion
(ADR-0122). Both demonstrate that CORE's gates are
load-bearing, not rubber stamps.
---
## Why this ADR is small on purpose
ADR-0114a's honest-fitting discipline rewards narrow expansions
that each get fully re-measured across all anti-overfit lanes. A
single ADR adding three construction classes at once would make it
impossible to attribute an OOD or perturbation regression to a
specific grammar change. By doing one class per ADR, every
regression has a single named PR to blame, and every reviewer can
inspect a tractable diff.
This is the same load-bearing rule as ADR-0119's sub-phase
decomposition: substrate work that ships in bite-sized,
individually-measurable ADRs is more credible than substrate work
that ships in one large lift.