core/docs/decisions/ADR-0131.G.2-comparatives.md
Shay b891eb243c feat(ADR-0131.G.2): comparative operations (additive + multiplicative) — admission unchanged, comparative-clause refusals 2→1
Wire compare_additive / compare_multiplicative extractors into the
candidate-emitting sentence parser, closing the deferred phase flagged
at generate/math_candidate_parser.py:30.

Capability axis: comparatives (additive + multiplicative)
- generate/math_candidate_parser.py: new _compare_additive_candidates,
  _compare_multiplicative_candidates, _compare_nested_candidates
  emitting CandidateOperation records keyed to the four
  Comparison.direction literals registered in ADR-0123.
- Closed-set anchor alternation; 'less' admitted as surface synonym of
  'fewer'; reference slot widened to admit "the number/amount of <unit>"
  for nested forms.
- Nested 'A has N more <unit> than M times <REF>' emits two flat
  candidates (additive + multiplicative); binding-graph picks the
  admissible composition or refuses (no solver stub).

Curated axis lane (24 cases)
- evals/math_capability_axes/G2_comparatives/v1/cases.jsonl:
  8 additive / 8 multiplicative / 3 nested / 5 refusal
- evals/math_capability_axes/G2_comparatives/v1/runner.py +
  report.json: deterministic, wrong==0 gate, byte-equal across runs.

Tests (21 new)
- tests/test_adr_0131_G2_comparatives.py: per-direction at-least-one
  passing, nested-both-emitted, closed-set refusal, runner
  byte-equality, GSM8K-probe gate (comparative-clause refusals
  strictly decrease).

GSM8K-probe gate (chosen: comparative-clause refusals ↓)
- evals/gsm8k_math/train_sample/v1/report.json (candidate-graph
  probe): comparative-clause refusal count 2 → 1 (case 0009 'Jen has
  10 more ducks than four times the number of chickens' moves from
  statement-clause refusal to question-layer refusal). admitted_wrong
  remains 0; admission_rate unchanged (downstream composition is a
  follow-up ADR).
- evals/gsm8k_math/train_sample/v1/train_sample_coverage_report.json
  (legacy probe): refreshed, byte-identical (legacy parser untouched).

B3 + candidate-graph + GSM8K probe lanes all pass (90/90). Direction
vocab stays closed to {more, fewer, times, fraction}; wrong==0
preserved everywhere.
2026-05-23 14:15:25 -07:00

265 lines
12 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-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 ...` |
| `_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).
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 | 8 | `times` ×4, `fraction` ×4 |
| nested | 3 | additive + multiplicative both flat-emitted |
| refusal | 5 | paraphrases outside the closed set |
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`.
### 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.