# ADR-0131.G.2 — Capability axis: comparative operations (additive + multiplicative)

**Status:** Proposed
**Date:** 2026-05-23
**Author:** CORE agents + reviewers
**Parent:** [ADR-0131.G](./ADR-0131.G-gsm8k-coverage-probe.md)
**Depends on:**
[ADR-0123](./ADR-0123-comparison-substrate.md) (Comparison op shape),
[ADR-0126](./ADR-0126-candidate-graph-parser.md) (candidate-graph topology),
[ADR-0131.3](./ADR-0131.3-bounded-grammar.md),
[ADR-0132](./ADR-0132-binding-graph-data-model.md)..[ADR-0134](./ADR-0134-unit-aware-admissibility.md)

---

## Context

The `Comparison` operand shape, the four `direction` literals (`more`,
`fewer`, `times`, `fraction`), and the round-trip anchor tables
(`COMPARE_ADDITIVE_ANCHORS`, `COMPARE_MULTIPLICATIVE_ANCHORS`) all
landed with ADR-0123. The legacy first-match-wins parser
(`generate/math_parser.py`) wires those for the bounded-grammar lane.

The candidate-emitting sentence parser
(`generate/math_candidate_parser.py:30`) explicitly deferred
comparative candidates to a "later phase of ADR-0126". Without that
phase, multi-sentence GSM8K problems that contain a comparative
clause are refused by the candidate-graph topology even when the
comparative is well-formed — the comparative sentence emits zero
candidates, the per-sentence choice space is empty, and the whole
problem refuses with `no admissible candidate for statement: 'X'`.

This ADR is the deferred comparative phase, framed under ADR-0131.G's
capability-first iteration discipline: comparatives are a single
capability axis (not a list of GSM8K templates), exercised by curated
sentences independent of GSM8K, with the probe's admission /
refused-reasons numbers moving only as a side effect.

---

## Decision

Land comparative candidate-emitting extractors in
`generate/math_candidate_parser.py`:

| Function | Closed-set shapes admitted |
|---|---|
| `_compare_additive_candidates` | `<A> has N more <unit> than <B>` / `... N fewer ...` / `... N less ...` |
| `_compare_multiplicative_candidates` | `<A> has twice as many <unit> as <B>` / `... thrice ...` / `... half ...` / `... N times as many ...` / `... a quarter as many ...` / `... a third as many ...` |
| `_compare_nested_candidates` | `<A> has N more <unit> than M times <REF>` — emits *two* flat candidates (one additive, one multiplicative), letting the binding-graph layer (ADR-0134) pick an admissible composition or refuse |

Direction vocabulary is **closed** to the four `Comparison.direction`
literals. `less` is admitted as a surface synonym of `fewer`; its
canonical Comparison value is `direction='fewer'` while
`matched_verb='less'` (so the round-trip filter's verb-registry check
finds `less` in `COMPARE_ADDITIVE_ANCHORS`, and its
"verb appears in source" check succeeds).

`quarter` and `third` are admitted as fraction anchors (both are
registered in `COMPARE_MULTIPLICATIVE_ANCHORS` and in the round-trip
factor-divisor table). They admit an optional article (`"a quarter"`,
`"a third"`) — the article is not captured; `matched_verb` is the
anchor word itself, which is a substring of the source, so the
round-trip verb-presence check passes. `quarter` → `factor=0.25`,
`third` → `factor=1/3` (float). Hyphenated forms (`one-third`) and
`double as many` are explicitly deferred (pinned by refusal
cases `G2-refuse-006` / `G2-refuse-007`).

The reference slot widens beyond proper-nouns / "the X" to admit
"the number/amount of <unit>" mass-noun constructions
(`_resolve_reference_token`), so the GSM8K-style "...four times the
number of chickens" composes. The canonical entity name is the full
noun phrase; binding-graph referential integrity is preserved
(distinct noun phrases stay distinct entities).

### Specificity / precedence

Extractor order in `extract_operation_candidates`:

1. add / subtract / transfer (existing — ADR-0126)
2. **`_compare_nested_candidates`** (most specific — requires both
   `(more|fewer|less) ... than` *and* `N times <REF>` tail)
3. **`_compare_multiplicative_candidates`** (anchor-as-value forms
   `twice/thrice/half`, then `N times as many`)
4. **`_compare_additive_candidates`** (`N more/fewer/less <unit> than
   <REF>`)

Multiplicative anchors that overlap lexically with additive are
disjoint at the lexical level: `WORD_NUMBERS` registers `two`/`three`
but **not** `twice`/`thrice`, so the additive VALUE alternation cannot
swallow a multiplicative anchor. Nested matching consumes its
trailing `than N times <REF>` greedily, so a sentence the nested
regex matches cannot also be claimed by the bare additive pattern
(the bare additive regex requires the `than <REF>` tail with `<REF>`
matching `_COMPARE_REF`, but the nested regex's `<REF>` is preceded
by `N times`, which the bare additive regex's `<REF>` slot does not
admit). Documented + tested in
`tests/test_adr_0131_G2_comparatives.py`.

### Curated coverage cases (G.2 axis lane)

`evals/math_capability_axes/G2_comparatives/v1/cases.jsonl` —
**24 cases**, exercised by an axis-specific runner
(`evals/math_capability_axes/G2_comparatives/v1/runner.py`) that
emits a deterministic `report.json`:

| Category       | Cases | Direction coverage         |
|----------------|-------|----------------------------|
| additive       | 8     | `more` ×4, `fewer` ×4 (incl. one `less` surface)|
| multiplicative | 11    | `times` ×4, `fraction` ×7 (`half`×4, `quarter`×2, `third`×1) |
| nested         | 3     | additive + multiplicative both flat-emitted |
| refusal        | 7     | paraphrases + hyphenated + `double as many` boundary |

Per-direction at-least-one-passing, nested-both-emitted, refusal
zero-admitted, and `wrong == 0` are pinned by
`tests/test_adr_0131_G2_comparatives.py`. Report is byte-equal across
runs.

### Deferred (out of scope for G.2)

Documented refusal classes — these have no entry in
`COMPARE_*_ANCHORS`, so admitting them would breach the round-trip
filter's verb-registry check anyway:

- `"as many ... as"` without a direction word ("Alice has as many
  apples as Bob") — equality is not in
  `{more, fewer, times, fraction}`.
- `"compared to <B>, <A> ..."` and `"in comparison with <B>, <A> ..."`
  — prefix paraphrases; no anchor verb is consumed.
- `"the same number of ... as"` — equality, see above.
- `"N times more"` — legacy parser already refuses as ambiguous
  (ADR-0123 line 797); G.2 preserves that refusal.
- Pronoun resolution in actor / reference slots — needs per-branch
  state (ADR-0126 P3 future work).
- Past-tense / lemma widening (`had`, `gets`, `took`, `buys`) — adds
  surface variants without growing the capability axis; deferred to a
  separate axis to keep precedence narrow.
- Unit ellipsis (`"twice as many as Bob"` without a unit) — without a
  unit the binding graph cannot pick a dimension to compare under
  ADR-0134's unit-aware admissibility; refusal preserves `wrong == 0`.
- Hyphenated fractions (`one-third`, `one-quarter`) — not in the closed
  anchor alternation; the regex requires a bare token. Deferred to the
  numerics axis (G.3 / G.3.1) where the hyphenated-compound form is the
  natural extension domain.
- `"double as many"` / `"triple as many"` — these anchors exist in
  `COMPARE_MULTIPLICATIVE_ANCHORS` (for the `double the X` sentence
  shape) but that shape requires different grammar. Deferred.

### Nested composition is *parser-side only*

The parser emits two flat candidates for nested forms; it does **not**
introduce a `Comparison ∋ Comparison` operand shape (which the data
model doesn't support today). The binding-graph layer (ADR-0134)
picks the admissible composition or refuses. **Refusal of nested
forms is the safe outcome and is what we observe today on
gsm8k-train-sample-v1-0009**: the comparative clause now parses (it
no longer refuses at the statement layer), but the downstream
composition has not yet been wired, so the case continues to refuse
— at the question layer instead. That downstream gap is a follow-up
ADR (`compare_additive ∘ compare_multiplicative` graph composition);
this ADR explicitly does **not** stub the solver to bypass it.

### GSM8K-probe gate (chosen)

G.2 gates on:

> **Comparative-clause refusals in the candidate-graph probe report
> (`evals/gsm8k_math/train_sample/v1/report.json`) strictly
> decrease.**

Test: `test_gsm8k_candidate_graph_comparative_clause_refusals_decreased`
(measures count of refused cases whose reason cites a statement
clause containing either additive `more/fewer/less ... than` or
multiplicative `twice/N times/half as many` anchors).

| Probe report                                | Baseline (origin/main 481e0c3) | After G.2 | Δ  |
|---|---|---|---|
| Comparative-clause refusals (`report.json`) | 2                              | 1         | −1 |
| `admitted_wrong` (`report.json`)            | 0                              | 0         | 0  |
| `admission_rate` (`report.json`)            | 0/50                           | 0/50      | 0  |

The baseline-2 cases are `gsm8k-train-sample-v1-0009` ("Jen has 10
more ducks than four times the number of chickens.") and
`-0016` ("Rudolph traveled 2 more than 5 miles"). G.2's extractor
parses the comparative clause in 0009; 0016's "2 more than 5" is an
arithmetic expression between numbers, not a between-entity
comparison, and is deliberately out of G.2 scope (the additive shape
requires an entity reference, not a numeric reference).

`admission_rate` does **not** rise because the downstream
binding-graph composition for nested comparatives + the question
layer's compatibility with comparison-derived state are out of G.2
scope. The honest claim is that the parser admits one more shape,
not that the engine solves more GSM8K problems.

### Legacy probe report (refreshed, byte-identical)

`evals/gsm8k_math/train_sample/v1/train_sample_coverage_report.json`
runs through `generate.math_parser.parse_problem` (legacy
first-match-wins), which already supported ADR-0123 comparative
patterns. G.2 does not touch the legacy parser, so the legacy
probe report is byte-identical to the baseline. Refreshed and
pinned via `test_gsm8k_legacy_probe_safety_rail_intact`.

---

## Invariants

- **`compare_direction_closed_set`** — every emitted comparative
  candidate has `op.operand.direction ∈ {more, fewer, times,
  fraction}`. Pinned by
  `test_direction_literals_closed_set`.
- **`compare_additive_wrong_zero`** / **`compare_multiplicative_wrong_zero`**
  — every additive / multiplicative coverage case passes through
  `roundtrip_admissible`. Pinned by the per-direction parametric
  tests.
- **`nested_emits_both_flat`** — the nested-composition extractor
  emits exactly the two flat candidates (additive + multiplicative);
  whether either composes is the binding-graph layer's call. Pinned
  by `test_nested_emits_both_flat_candidates`.
- **`closed_set_refusal_holds`** — none of the five documented
  refusal paraphrases admit any comparative candidate. Pinned by
  `test_refusal_cases_emit_no_admitted_comparative`.
- **`gsm8k_safety_rail_intact`** — `admitted_wrong == 0` on both the
  legacy and candidate-graph GSM8K probe reports.
- **`gsm8k_comparative_refusal_strictly_decreased`** — chosen G.2
  gate (see above).
- **`report_deterministic`** — `report.json` is byte-equal across two
  back-to-back runs.

---

## Acceptance evidence

- `evals/math_capability_axes/G2_comparatives/v1/runner.py` exits 0
  with `wrong == 0` on all 24 curated cases (8 additive / 8
  multiplicative / 3 nested / 5 refusal).
- `tests/test_adr_0131_G2_comparatives.py` (21 tests): per-direction
  at-least-one-passing, nested-both-emitted, refusal-set zero-admit,
  closed-set discipline, runner byte-equality, GSM8K-probe gate.
- `evals/gsm8k_math/train_sample/v1/report.json` shows
  comparative-clause refusal count drop from 2 → 1.
- `evals/gsm8k_math/train_sample/v1/train_sample_coverage_report.json`
  refreshed and byte-identical (legacy parser untouched).
- B3 lane (`tests/test_adr_0131_3_bounded_grammar_lane.py`) +
  candidate-graph runner tests + GSM8K probe tests all pass.

---

## Consequences

- The candidate-graph topology can finally see comparative
  statements as first-class operations. The downstream composition
  layer (ADR-0134 unit-aware admissibility + a future "graph composes
  nested comparison" ADR) becomes the next honest unblock.
- `gsm8k-train-sample-v1-0009` moves from "refuse at comparative
  clause" to "refuse at question layer" — a refusal-class shift,
  not an admission. That is the correct, honest classification: the
  case is still refused; the reason it's refused has changed.
- Future axes (G.3 rate-verbs, G.4 multi-clause distributive
  subjects) inherit the same axis-lane harness shape under
  `evals/math_capability_axes/`.
- Capability-first discipline is preserved: no GSM8K-specific
  templates were added; the new shapes are exercised by 24
  hand-authored sentences plus whatever GSM8K has incidentally.

---

## Out of scope

- **Solver changes.** If a comparative parses but doesn't solve,
  that's a binding-graph composition gap, deferred to a follow-up
  ADR (no solver stubs).
- **Implicit-comparison shapes** ("Alice has the same number of
  apples as Bob") — equality is not in
  `{more, fewer, times, fraction}`; admitting it requires extending
  the closed direction set, which is its own ADR.
- **Probe runner contract.** ADR-0131.G pinned `run_lane` as the
  legacy probe's contract; G.2 does not change that. The
  candidate-graph probe (`report.json` via `runner.py`) is the
  measurement surface that actually moves under G.2.
- **Frontier head-to-head on comparative accuracy.** Deferred until
  the composition layer admits at least one nested form end-to-end.
