core/docs/decisions/ADR-0131.G.2-comparatives.md
Shay 657c74102b
fix(ADR-0131.G.2): rebase + mastery hardening — quarter/third fraction anchors, gate regex, boundary refusals (#196)
Rebases onto current main (dec98ea, post-G.1/G.3.1/G.4/promotion).

Parser:
- Extend _COMPARE_MULT_ANCHOR_RE anchor alternation to include 'quarter'
  and 'third'; add optional 'a\s+' article prefix so "a quarter as many"
  and "a third as many" parse. Both anchors are in COMPARE_MULTIPLICATIVE_ANCHORS
  and the round-trip factor-divisor table ("quarter":4, "third":3), so
  round-trip checks pass. quarter→0.25 (exact), third→1/3 (float).
- Add _ANCHOR_TO_FACTOR entries for quarter and third.

Gate regex (test_adr_0131_G2_comparatives.py):
- Widen _COMPARATIVE_STATEMENT_PATTERNS multiplicative pattern from
  '\d+\s+times' to '\w+\s+times' to match word-number forms ("four times")
  that would be missed by the digit-only pattern if a future GSM8K case
  contains one in a still-refused statement.

Cases (31 total, was 24):
- G2-mul-frac-005/006: two 'quarter' cases (fraction direction now has
  half×4 + quarter×2 + third×1 = 7 cases, was 4 all-half).
- G2-mul-frac-007: 'third' case.
- G2-refuse-006: hyphenated 'one-third' pins the closed-anchor boundary.
- G2-refuse-007: 'double as many' pins the deferred grammar shape.

Tests (25, was 21):
- Add quarter and third parametric entries to test_multiplicative_direction_admits.
- Add one-third and double-as-many refusal params to test_refusal_cases.
- Add quarter/third to test_direction_literals_closed_set.
- Update test_runner_per_category_minima comment to reflect new counts.

ADR: document quarter/third admission, updated case table, deferred list.
report.json: refreshed to 31 cases, wrong==0 preserved.
2026-05-23 19:28:09 -07:00

282 lines
13 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.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.