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.
This commit is contained in:
parent
481e0c3b9c
commit
b891eb243c
10 changed files with 1398 additions and 2 deletions
265
docs/decisions/ADR-0131.G.2-comparatives.md
Normal file
265
docs/decisions/ADR-0131.G.2-comparatives.md
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
# 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.
|
||||
|
|
@ -53,7 +53,7 @@
|
|||
},
|
||||
{
|
||||
"case_id": "gsm8k-train-sample-v1-0009",
|
||||
"reason": "candidate_graph: no admissible candidate for statement: 'Jen has 10 more ducks than four times the number of chickens.'",
|
||||
"reason": "candidate_graph: no admissible candidate for question: 'If Jen has 150 ducks, how many total birds does she have?'",
|
||||
"verdict": "refused"
|
||||
},
|
||||
{
|
||||
|
|
|
|||
0
evals/math_capability_axes/G2_comparatives/__init__.py
Normal file
0
evals/math_capability_axes/G2_comparatives/__init__.py
Normal file
24
evals/math_capability_axes/G2_comparatives/v1/cases.jsonl
Normal file
24
evals/math_capability_axes/G2_comparatives/v1/cases.jsonl
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{"case_id": "G2-add-more-001", "category": "additive", "direction": "more", "sentence": "Alice has 3 more apples than Bob", "expected": {"kind": "compare_additive", "actor": "Alice", "reference": "Bob", "delta_value": 3, "unit": "apples", "matched_verb": "more"}}
|
||||
{"case_id": "G2-add-more-002", "category": "additive", "direction": "more", "sentence": "James has 7 more marbles than Carter", "expected": {"kind": "compare_additive", "actor": "James", "reference": "Carter", "delta_value": 7, "unit": "marbles", "matched_verb": "more"}}
|
||||
{"case_id": "G2-add-more-003", "category": "additive", "direction": "more", "sentence": "Tom has four more cookies than Sara", "expected": {"kind": "compare_additive", "actor": "Tom", "reference": "Sara", "delta_value": 4, "unit": "cookies", "matched_verb": "more"}}
|
||||
{"case_id": "G2-add-more-004", "category": "additive", "direction": "more", "sentence": "Marion has 12 more paperclips than Yun", "expected": {"kind": "compare_additive", "actor": "Marion", "reference": "Yun", "delta_value": 12, "unit": "paperclips", "matched_verb": "more"}}
|
||||
{"case_id": "G2-add-fewer-001", "category": "additive", "direction": "fewer", "sentence": "Alice has 5 fewer pencils than Bob", "expected": {"kind": "compare_additive", "actor": "Alice", "reference": "Bob", "delta_value": 5, "unit": "pencils", "matched_verb": "fewer"}}
|
||||
{"case_id": "G2-add-fewer-002", "category": "additive", "direction": "fewer", "sentence": "Carter has two fewer pets than James", "expected": {"kind": "compare_additive", "actor": "Carter", "reference": "James", "delta_value": 2, "unit": "pets", "matched_verb": "fewer"}}
|
||||
{"case_id": "G2-add-fewer-003", "category": "additive", "direction": "fewer", "sentence": "Dana has 8 less coins than Eli", "expected": {"kind": "compare_additive", "actor": "Dana", "reference": "Eli", "delta_value": 8, "unit": "coins", "matched_verb": "less"}}
|
||||
{"case_id": "G2-add-fewer-004", "category": "additive", "direction": "fewer", "sentence": "Beth has 3 fewer books than Sam", "expected": {"kind": "compare_additive", "actor": "Beth", "reference": "Sam", "delta_value": 3, "unit": "books", "matched_verb": "fewer"}}
|
||||
{"case_id": "G2-mul-times-001", "category": "multiplicative", "direction": "times", "sentence": "Alice has twice as many apples as Bob", "expected": {"kind": "compare_multiplicative", "actor": "Alice", "reference": "Bob", "factor": 2.0, "unit": "apples", "matched_verb": "twice"}}
|
||||
{"case_id": "G2-mul-times-002", "category": "multiplicative", "direction": "times", "sentence": "Brooke has 3 times as many jacks as Sidney", "expected": {"kind": "compare_multiplicative", "actor": "Brooke", "reference": "Sidney", "factor": 3.0, "unit": "jacks", "matched_verb": "times"}}
|
||||
{"case_id": "G2-mul-times-003", "category": "multiplicative", "direction": "times", "sentence": "Cindy has thrice as many cards as Nicole", "expected": {"kind": "compare_multiplicative", "actor": "Cindy", "reference": "Nicole", "factor": 3.0, "unit": "cards", "matched_verb": "thrice"}}
|
||||
{"case_id": "G2-mul-times-004", "category": "multiplicative", "direction": "times", "sentence": "Erica has 5 times as many fish as Mark", "expected": {"kind": "compare_multiplicative", "actor": "Erica", "reference": "Mark", "factor": 5.0, "unit": "fish", "matched_verb": "times"}}
|
||||
{"case_id": "G2-mul-frac-001", "category": "multiplicative", "direction": "fraction", "sentence": "Monica has half as many hours as Sara", "expected": {"kind": "compare_multiplicative", "actor": "Monica", "reference": "Sara", "factor": 0.5, "unit": "hours", "matched_verb": "half"}}
|
||||
{"case_id": "G2-mul-frac-002", "category": "multiplicative", "direction": "fraction", "sentence": "Rex has half as many cards as Cindy", "expected": {"kind": "compare_multiplicative", "actor": "Rex", "reference": "Cindy", "factor": 0.5, "unit": "cards", "matched_verb": "half"}}
|
||||
{"case_id": "G2-mul-frac-003", "category": "multiplicative", "direction": "fraction", "sentence": "Anna has half as many stickers as Beth", "expected": {"kind": "compare_multiplicative", "actor": "Anna", "reference": "Beth", "factor": 0.5, "unit": "stickers", "matched_verb": "half"}}
|
||||
{"case_id": "G2-mul-frac-004", "category": "multiplicative", "direction": "fraction", "sentence": "Tim has half as many toys as Jack", "expected": {"kind": "compare_multiplicative", "actor": "Tim", "reference": "Jack", "factor": 0.5, "unit": "toys", "matched_verb": "half"}}
|
||||
{"case_id": "G2-nested-001", "category": "nested", "sentence": "Jen has 10 more ducks than four times the number of chickens", "expected": {"emits_flat_candidates": ["compare_additive", "compare_multiplicative"], "actor": "Jen", "reference": "the number of chickens", "delta_value": 10, "factor": 4.0, "unit": "ducks"}}
|
||||
{"case_id": "G2-nested-002", "category": "nested", "sentence": "Jose has 2 more pounds than 2 times the amount of Orlando", "expected": {"emits_flat_candidates": ["compare_additive", "compare_multiplicative"], "actor": "Jose", "reference": "the amount of Orlando", "delta_value": 2, "factor": 2.0, "unit": "pounds"}}
|
||||
{"case_id": "G2-nested-003", "category": "nested", "sentence": "Sara has 5 fewer marbles than 3 times Bob", "expected": {"emits_flat_candidates": ["compare_additive", "compare_multiplicative"], "actor": "Sara", "reference": "Bob", "delta_value": 5, "factor": 3.0, "unit": "marbles"}}
|
||||
{"case_id": "G2-refuse-001", "category": "refusal", "sentence": "Alice has as many apples as Bob", "expected": {"refuse": true, "reason": "implicit-comparison 'as many ... as' without a direction anchor — out of closed set"}}
|
||||
{"case_id": "G2-refuse-002", "category": "refusal", "sentence": "Compared to Bob, Alice has 3 more apples", "expected": {"refuse": true, "reason": "'compared to' prefix — paraphrase outside the closed anchor set"}}
|
||||
{"case_id": "G2-refuse-003", "category": "refusal", "sentence": "In comparison with Bob, Alice has 3 more apples", "expected": {"refuse": true, "reason": "'in comparison with' prefix — paraphrase outside the closed anchor set"}}
|
||||
{"case_id": "G2-refuse-004", "category": "refusal", "sentence": "Alice has the same number of apples as Bob", "expected": {"refuse": true, "reason": "'the same number ... as' — equality is not in {more, fewer, times, fraction}"}}
|
||||
{"case_id": "G2-refuse-005", "category": "refusal", "sentence": "Alice has 3 times more apples than Bob", "expected": {"refuse": true, "reason": "ambiguous 'N times more' — admitted to neither additive nor multiplicative shape (cf. ADR-0123 legacy refusal)"}}
|
||||
267
evals/math_capability_axes/G2_comparatives/v1/report.json
Normal file
267
evals/math_capability_axes/G2_comparatives/v1/report.json
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
{
|
||||
"adr": "0131.G.2",
|
||||
"axis": "comparatives",
|
||||
"cases_path": "evals/math_capability_axes/G2_comparatives/v1/cases.jsonl",
|
||||
"metrics": {
|
||||
"cases_total": 24,
|
||||
"pass_rate": 1.0,
|
||||
"passed": 24,
|
||||
"wrong": 0,
|
||||
"wrong_count_is_zero": true,
|
||||
"wrong_rate": 0.0
|
||||
},
|
||||
"per_case": [
|
||||
{
|
||||
"admitted_count": 1,
|
||||
"admitted_kinds": [
|
||||
"compare_additive"
|
||||
],
|
||||
"case_id": "G2-add-more-001",
|
||||
"category": "additive",
|
||||
"outcome": "pass",
|
||||
"reason": ""
|
||||
},
|
||||
{
|
||||
"admitted_count": 1,
|
||||
"admitted_kinds": [
|
||||
"compare_additive"
|
||||
],
|
||||
"case_id": "G2-add-more-002",
|
||||
"category": "additive",
|
||||
"outcome": "pass",
|
||||
"reason": ""
|
||||
},
|
||||
{
|
||||
"admitted_count": 1,
|
||||
"admitted_kinds": [
|
||||
"compare_additive"
|
||||
],
|
||||
"case_id": "G2-add-more-003",
|
||||
"category": "additive",
|
||||
"outcome": "pass",
|
||||
"reason": ""
|
||||
},
|
||||
{
|
||||
"admitted_count": 1,
|
||||
"admitted_kinds": [
|
||||
"compare_additive"
|
||||
],
|
||||
"case_id": "G2-add-more-004",
|
||||
"category": "additive",
|
||||
"outcome": "pass",
|
||||
"reason": ""
|
||||
},
|
||||
{
|
||||
"admitted_count": 1,
|
||||
"admitted_kinds": [
|
||||
"compare_additive"
|
||||
],
|
||||
"case_id": "G2-add-fewer-001",
|
||||
"category": "additive",
|
||||
"outcome": "pass",
|
||||
"reason": ""
|
||||
},
|
||||
{
|
||||
"admitted_count": 1,
|
||||
"admitted_kinds": [
|
||||
"compare_additive"
|
||||
],
|
||||
"case_id": "G2-add-fewer-002",
|
||||
"category": "additive",
|
||||
"outcome": "pass",
|
||||
"reason": ""
|
||||
},
|
||||
{
|
||||
"admitted_count": 1,
|
||||
"admitted_kinds": [
|
||||
"compare_additive"
|
||||
],
|
||||
"case_id": "G2-add-fewer-003",
|
||||
"category": "additive",
|
||||
"outcome": "pass",
|
||||
"reason": ""
|
||||
},
|
||||
{
|
||||
"admitted_count": 1,
|
||||
"admitted_kinds": [
|
||||
"compare_additive"
|
||||
],
|
||||
"case_id": "G2-add-fewer-004",
|
||||
"category": "additive",
|
||||
"outcome": "pass",
|
||||
"reason": ""
|
||||
},
|
||||
{
|
||||
"admitted_count": 1,
|
||||
"admitted_kinds": [
|
||||
"compare_multiplicative"
|
||||
],
|
||||
"case_id": "G2-mul-times-001",
|
||||
"category": "multiplicative",
|
||||
"outcome": "pass",
|
||||
"reason": ""
|
||||
},
|
||||
{
|
||||
"admitted_count": 1,
|
||||
"admitted_kinds": [
|
||||
"compare_multiplicative"
|
||||
],
|
||||
"case_id": "G2-mul-times-002",
|
||||
"category": "multiplicative",
|
||||
"outcome": "pass",
|
||||
"reason": ""
|
||||
},
|
||||
{
|
||||
"admitted_count": 1,
|
||||
"admitted_kinds": [
|
||||
"compare_multiplicative"
|
||||
],
|
||||
"case_id": "G2-mul-times-003",
|
||||
"category": "multiplicative",
|
||||
"outcome": "pass",
|
||||
"reason": ""
|
||||
},
|
||||
{
|
||||
"admitted_count": 1,
|
||||
"admitted_kinds": [
|
||||
"compare_multiplicative"
|
||||
],
|
||||
"case_id": "G2-mul-times-004",
|
||||
"category": "multiplicative",
|
||||
"outcome": "pass",
|
||||
"reason": ""
|
||||
},
|
||||
{
|
||||
"admitted_count": 1,
|
||||
"admitted_kinds": [
|
||||
"compare_multiplicative"
|
||||
],
|
||||
"case_id": "G2-mul-frac-001",
|
||||
"category": "multiplicative",
|
||||
"outcome": "pass",
|
||||
"reason": ""
|
||||
},
|
||||
{
|
||||
"admitted_count": 1,
|
||||
"admitted_kinds": [
|
||||
"compare_multiplicative"
|
||||
],
|
||||
"case_id": "G2-mul-frac-002",
|
||||
"category": "multiplicative",
|
||||
"outcome": "pass",
|
||||
"reason": ""
|
||||
},
|
||||
{
|
||||
"admitted_count": 1,
|
||||
"admitted_kinds": [
|
||||
"compare_multiplicative"
|
||||
],
|
||||
"case_id": "G2-mul-frac-003",
|
||||
"category": "multiplicative",
|
||||
"outcome": "pass",
|
||||
"reason": ""
|
||||
},
|
||||
{
|
||||
"admitted_count": 1,
|
||||
"admitted_kinds": [
|
||||
"compare_multiplicative"
|
||||
],
|
||||
"case_id": "G2-mul-frac-004",
|
||||
"category": "multiplicative",
|
||||
"outcome": "pass",
|
||||
"reason": ""
|
||||
},
|
||||
{
|
||||
"admitted_count": 2,
|
||||
"admitted_kinds": [
|
||||
"compare_additive",
|
||||
"compare_multiplicative"
|
||||
],
|
||||
"case_id": "G2-nested-001",
|
||||
"category": "nested",
|
||||
"outcome": "pass",
|
||||
"reason": ""
|
||||
},
|
||||
{
|
||||
"admitted_count": 2,
|
||||
"admitted_kinds": [
|
||||
"compare_additive",
|
||||
"compare_multiplicative"
|
||||
],
|
||||
"case_id": "G2-nested-002",
|
||||
"category": "nested",
|
||||
"outcome": "pass",
|
||||
"reason": ""
|
||||
},
|
||||
{
|
||||
"admitted_count": 2,
|
||||
"admitted_kinds": [
|
||||
"compare_additive",
|
||||
"compare_multiplicative"
|
||||
],
|
||||
"case_id": "G2-nested-003",
|
||||
"category": "nested",
|
||||
"outcome": "pass",
|
||||
"reason": ""
|
||||
},
|
||||
{
|
||||
"admitted_count": 0,
|
||||
"admitted_kinds": [],
|
||||
"case_id": "G2-refuse-001",
|
||||
"category": "refusal",
|
||||
"outcome": "pass",
|
||||
"reason": ""
|
||||
},
|
||||
{
|
||||
"admitted_count": 0,
|
||||
"admitted_kinds": [],
|
||||
"case_id": "G2-refuse-002",
|
||||
"category": "refusal",
|
||||
"outcome": "pass",
|
||||
"reason": ""
|
||||
},
|
||||
{
|
||||
"admitted_count": 0,
|
||||
"admitted_kinds": [],
|
||||
"case_id": "G2-refuse-003",
|
||||
"category": "refusal",
|
||||
"outcome": "pass",
|
||||
"reason": ""
|
||||
},
|
||||
{
|
||||
"admitted_count": 0,
|
||||
"admitted_kinds": [],
|
||||
"case_id": "G2-refuse-004",
|
||||
"category": "refusal",
|
||||
"outcome": "pass",
|
||||
"reason": ""
|
||||
},
|
||||
{
|
||||
"admitted_count": 0,
|
||||
"admitted_kinds": [],
|
||||
"case_id": "G2-refuse-005",
|
||||
"category": "refusal",
|
||||
"outcome": "pass",
|
||||
"reason": ""
|
||||
}
|
||||
],
|
||||
"per_category": {
|
||||
"additive": {
|
||||
"pass": 8,
|
||||
"wrong": 0
|
||||
},
|
||||
"multiplicative": {
|
||||
"pass": 8,
|
||||
"wrong": 0
|
||||
},
|
||||
"nested": {
|
||||
"pass": 3,
|
||||
"wrong": 0
|
||||
},
|
||||
"refusal": {
|
||||
"pass": 5,
|
||||
"wrong": 0
|
||||
}
|
||||
},
|
||||
"schema_version": 1
|
||||
}
|
||||
205
evals/math_capability_axes/G2_comparatives/v1/runner.py
Normal file
205
evals/math_capability_axes/G2_comparatives/v1/runner.py
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
"""ADR-0131.G.2 — Capability axis runner for comparative operations.
|
||||
|
||||
Exercises the ``compare_additive`` / ``compare_multiplicative`` extractors
|
||||
in :mod:`generate.math_candidate_parser` against curated coverage cases
|
||||
that are independent of GSM8K.
|
||||
|
||||
Per-case classification:
|
||||
|
||||
| Case category | pass criterion |
|
||||
|-----------------|---------------------------------------------------------|
|
||||
| additive | ≥1 admitted candidate with kind=compare_additive whose |
|
||||
| | direction, actor, reference, delta_value, unit match |
|
||||
| multiplicative | ≥1 admitted candidate with kind=compare_multiplicative |
|
||||
| | whose direction, actor, reference, factor, unit match |
|
||||
| nested | both compare_additive AND compare_multiplicative flat |
|
||||
| | candidates emitted + admitted (binding-graph picks) |
|
||||
| refusal | zero admitted comparative candidates |
|
||||
|
||||
``wrong`` is non-zero only if a refusal-case emits an admitted comparative
|
||||
or a positive-case emits a comparative with the wrong shape. ``wrong == 0``
|
||||
is the load-bearing gate (ADR-0114a Obligation #4).
|
||||
|
||||
Determinism: case order in ``cases.jsonl`` is the report order; same
|
||||
input file → byte-equal report.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from generate.math_candidate_parser import extract_operation_candidates
|
||||
from generate.math_roundtrip import roundtrip_admissible
|
||||
|
||||
_HERE = Path(__file__).resolve().parent
|
||||
_CASES_PATH = _HERE / "cases.jsonl"
|
||||
_REPORT_PATH = _HERE / "report.json"
|
||||
|
||||
|
||||
def _load_cases() -> list[dict[str, Any]]:
|
||||
return [
|
||||
json.loads(line)
|
||||
for line in _CASES_PATH.read_text(encoding="utf-8").splitlines()
|
||||
if line.strip()
|
||||
]
|
||||
|
||||
|
||||
def _admitted(sentence: str) -> list[Any]:
|
||||
cands = extract_operation_candidates(sentence)
|
||||
return [
|
||||
c for c in cands
|
||||
if c.op.kind in ("compare_additive", "compare_multiplicative")
|
||||
and roundtrip_admissible(c)
|
||||
]
|
||||
|
||||
|
||||
def _score_positive_additive(case: dict[str, Any], admitted: list[Any]) -> tuple[str, str]:
|
||||
exp = case["expected"]
|
||||
matches = [
|
||||
c for c in admitted
|
||||
if c.op.kind == "compare_additive"
|
||||
and c.op.operand.direction == case["direction"]
|
||||
and c.op.actor == exp["actor"]
|
||||
and c.op.operand.reference_actor == exp["reference"]
|
||||
and c.op.operand.delta is not None
|
||||
and c.op.operand.delta.value == exp["delta_value"]
|
||||
and c.op.operand.delta.unit == exp["unit"]
|
||||
and c.matched_verb == exp["matched_verb"]
|
||||
]
|
||||
if matches:
|
||||
return "pass", ""
|
||||
if not admitted:
|
||||
return "wrong", "no admissible comparative candidate emitted"
|
||||
return (
|
||||
"wrong",
|
||||
f"admitted comparative did not match expected additive shape; got {[(c.op.kind, c.op.operand.as_json()) for c in admitted]}",
|
||||
)
|
||||
|
||||
|
||||
def _score_positive_multiplicative(case: dict[str, Any], admitted: list[Any]) -> tuple[str, str]:
|
||||
exp = case["expected"]
|
||||
matches = [
|
||||
c for c in admitted
|
||||
if c.op.kind == "compare_multiplicative"
|
||||
and c.op.operand.direction == case["direction"]
|
||||
and c.op.actor == exp["actor"]
|
||||
and c.op.operand.reference_actor == exp["reference"]
|
||||
and c.op.operand.factor is not None
|
||||
and float(c.op.operand.factor) == float(exp["factor"])
|
||||
and c.matched_verb == exp["matched_verb"]
|
||||
]
|
||||
if matches:
|
||||
return "pass", ""
|
||||
if not admitted:
|
||||
return "wrong", "no admissible comparative candidate emitted"
|
||||
return (
|
||||
"wrong",
|
||||
f"admitted comparative did not match expected multiplicative shape; got {[(c.op.kind, c.op.operand.as_json()) for c in admitted]}",
|
||||
)
|
||||
|
||||
|
||||
def _score_nested(_case: dict[str, Any], admitted: list[Any]) -> tuple[str, str]:
|
||||
kinds = {c.op.kind for c in admitted}
|
||||
if kinds == {"compare_additive", "compare_multiplicative"}:
|
||||
return "pass", ""
|
||||
return (
|
||||
"wrong",
|
||||
f"nested case must emit both flat candidate kinds; got {sorted(kinds)}",
|
||||
)
|
||||
|
||||
|
||||
def _score_refusal(_case: dict[str, Any], admitted: list[Any]) -> tuple[str, str]:
|
||||
if not admitted:
|
||||
return "pass", ""
|
||||
return (
|
||||
"wrong",
|
||||
f"refusal case admitted a comparative candidate ({[c.op.kind for c in admitted]}); "
|
||||
f"closed-set boundary breached",
|
||||
)
|
||||
|
||||
|
||||
def _score_case(case: dict[str, Any]) -> dict[str, Any]:
|
||||
sentence = case["sentence"]
|
||||
admitted = _admitted(sentence)
|
||||
category = case["category"]
|
||||
if category == "additive":
|
||||
outcome, reason = _score_positive_additive(case, admitted)
|
||||
elif category == "multiplicative":
|
||||
outcome, reason = _score_positive_multiplicative(case, admitted)
|
||||
elif category == "nested":
|
||||
outcome, reason = _score_nested(case, admitted)
|
||||
elif category == "refusal":
|
||||
outcome, reason = _score_refusal(case, admitted)
|
||||
else:
|
||||
outcome, reason = "wrong", f"unknown case category {category!r}"
|
||||
return {
|
||||
"case_id": case["case_id"],
|
||||
"category": category,
|
||||
"outcome": outcome,
|
||||
"reason": reason,
|
||||
"admitted_count": len(admitted),
|
||||
"admitted_kinds": sorted({c.op.kind for c in admitted}),
|
||||
}
|
||||
|
||||
|
||||
def build_report() -> dict[str, Any]:
|
||||
cases = _load_cases()
|
||||
per_case = [_score_case(c) for c in cases]
|
||||
total = len(per_case)
|
||||
passed = sum(1 for d in per_case if d["outcome"] == "pass")
|
||||
wrong = sum(1 for d in per_case if d["outcome"] == "wrong")
|
||||
by_category: dict[str, dict[str, int]] = {}
|
||||
for d in per_case:
|
||||
slot = by_category.setdefault(d["category"], {"passed": 0, "wrong": 0})
|
||||
slot[d["outcome"] if d["outcome"] == "passed" else d["outcome"]] = (
|
||||
slot.get(d["outcome"], 0) + 1
|
||||
)
|
||||
# Rebuild by_category with normalized keys.
|
||||
by_category = {}
|
||||
for d in per_case:
|
||||
slot = by_category.setdefault(d["category"], {"pass": 0, "wrong": 0})
|
||||
slot[d["outcome"]] = slot.get(d["outcome"], 0) + 1
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"adr": "0131.G.2",
|
||||
"axis": "comparatives",
|
||||
"cases_path": "evals/math_capability_axes/G2_comparatives/v1/cases.jsonl",
|
||||
"metrics": {
|
||||
"cases_total": total,
|
||||
"passed": passed,
|
||||
"wrong": wrong,
|
||||
"pass_rate": (passed / total) if total else 0.0,
|
||||
"wrong_rate": (wrong / total) if total else 0.0,
|
||||
"wrong_count_is_zero": wrong == 0,
|
||||
},
|
||||
"per_category": {
|
||||
k: dict(sorted(v.items())) for k, v in sorted(by_category.items())
|
||||
},
|
||||
"per_case": per_case,
|
||||
}
|
||||
|
||||
|
||||
def write_report(report: dict[str, Any]) -> None:
|
||||
_REPORT_PATH.write_text(
|
||||
json.dumps(report, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
report = build_report()
|
||||
write_report(report)
|
||||
m = report["metrics"]
|
||||
print(
|
||||
f"ADR-0131.G.2 comparatives: passed {m['passed']}/{m['cases_total']} "
|
||||
f"({m['pass_rate']:.1%}); wrong={m['wrong']} (gate: must be 0)"
|
||||
)
|
||||
for cat, counts in report["per_category"].items():
|
||||
print(f" {cat:14s} {counts}")
|
||||
return 0 if m["wrong_count_is_zero"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
0
evals/math_capability_axes/__init__.py
Normal file
0
evals/math_capability_axes/__init__.py
Normal file
|
|
@ -36,9 +36,10 @@ from __future__ import annotations
|
|||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Final
|
||||
from typing import Final, Literal, cast
|
||||
|
||||
from generate.math_problem_graph import (
|
||||
Comparison,
|
||||
InitialPossession,
|
||||
Operation,
|
||||
Quantity,
|
||||
|
|
@ -53,6 +54,11 @@ from generate.math_roundtrip import (
|
|||
)
|
||||
|
||||
|
||||
# Locally re-typed alias mirroring Comparison.direction's literal slot —
|
||||
# used only to satisfy pyright when narrowing surface-direction strings.
|
||||
_CompDirection = Literal["more", "fewer", "times", "fraction"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Initial-possession candidate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -483,4 +489,358 @@ def extract_operation_candidates(sentence: str) -> list[CandidateOperation]:
|
|||
if candidate is not None:
|
||||
out.append(candidate)
|
||||
|
||||
# ADR-0131.G.2 — comparative operations.
|
||||
# Specificity order: nested > multiplicative > additive. Multiplicative
|
||||
# anchors that overlap with additive ("twice" vs "two more") are disjoint
|
||||
# at the lexical level (WORD_NUMBERS has 'two' not 'twice'); nesting
|
||||
# consumes a *trailing* "than N times <REF>" tail so it cannot be confused
|
||||
# with the bare additive pattern. See ADR-0131.G.2 for precedence
|
||||
# rationale.
|
||||
out.extend(_compare_nested_candidates(sentence))
|
||||
out.extend(_compare_multiplicative_candidates(sentence))
|
||||
out.extend(_compare_additive_candidates(sentence))
|
||||
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ADR-0131.G.2 — Comparative operation extractors
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Closed-set anchor alternation, aligned 1:1 with the four
|
||||
# ``Comparison.direction`` literals registered in
|
||||
# :data:`generate.math_roundtrip.COMPARE_ADDITIVE_ANCHORS` /
|
||||
# :data:`COMPARE_MULTIPLICATIVE_ANCHORS`:
|
||||
#
|
||||
# additive — direction ∈ {more, fewer}; "less" admitted as a
|
||||
# surface synonym mapped to direction='fewer'.
|
||||
# ``matched_verb`` = the lowercased direction word
|
||||
# ('more' / 'fewer' / 'less'); these are members of
|
||||
# COMPARE_ADDITIVE_ANCHORS so the round-trip filter's
|
||||
# verb-registry check (math_roundtrip step 1) passes.
|
||||
# multiplicative — direction ∈ {times, fraction}; surface anchors are
|
||||
# 'twice' / 'thrice' / 'N times' / 'half'. The
|
||||
# anchor-as-value-token convention from math_roundtrip
|
||||
# step 4 lets word-form factor anchors skip
|
||||
# value-grounding (the anchor's own appearance in
|
||||
# the source already grounds the factor).
|
||||
#
|
||||
# Out of scope (refused by deliberate non-match): "as many … as" without
|
||||
# a direction anchor, "compared to …", "in comparison with …",
|
||||
# "the same … as". These have no entry in COMPARE_*_ANCHORS — admitting
|
||||
# them would breach the round-trip filter's verb-registry check anyway.
|
||||
|
||||
# Comparative entity slot: proper-noun, "the X" collective, "the number/amount
|
||||
# of <noun>" mass-noun construction. Possessives ("Bob's"/"his") are deferred.
|
||||
_COMPARE_REF: Final[str] = (
|
||||
r"(?:"
|
||||
r"the\s+(?:number|amount)\s+of\s+\w+"
|
||||
r"|[Tt]he\s+\w+"
|
||||
r"|[A-Z]\w+"
|
||||
r")"
|
||||
)
|
||||
|
||||
|
||||
def _resolve_reference_token(raw: str) -> tuple[str, str]:
|
||||
"""Return ``(canonical_entity, head_token_for_grounding)``.
|
||||
|
||||
For "the number of chickens" the head token is "chickens"; the
|
||||
canonical entity uses the full noun-phrase so binding-graph
|
||||
referential-integrity isn't subverted by collapsing different
|
||||
references to the same noun.
|
||||
"""
|
||||
collapsed = re.sub(r"\s+", " ", raw.strip())
|
||||
lowered = collapsed.lower()
|
||||
if lowered.startswith("the number of ") or lowered.startswith("the amount of "):
|
||||
head = collapsed.split()[-1]
|
||||
return collapsed, head
|
||||
if lowered.startswith("the "):
|
||||
head = collapsed[4:].split()[0]
|
||||
return "the " + collapsed[4:], head
|
||||
return collapsed, collapsed
|
||||
|
||||
|
||||
def _comparison_anchor_verb() -> str:
|
||||
# 'has' / 'have' carry the comparator phrase. We don't include 'had/gets'
|
||||
# etc. in P2 — past-tense + lemma-widening are deferred to a later axis
|
||||
# to keep the precedence story narrow.
|
||||
return r"(?:has|have)"
|
||||
|
||||
|
||||
_COMPARE_ADDITIVE_RE: Final[re.Pattern[str]] = re.compile(
|
||||
rf"^(?P<actor>{_ENTITY})\s+{_comparison_anchor_verb()}\s+"
|
||||
rf"(?P<value>{_VALUE})\s+"
|
||||
r"(?P<direction>more|fewer|less)\s+"
|
||||
r"(?P<unit>\w+)\s+than\s+"
|
||||
rf"(?P<reference>{_COMPARE_REF})\s*\.?$"
|
||||
)
|
||||
|
||||
# Multiplicative: anchor-as-value form ("twice"/"thrice"/"half" carry the
|
||||
# factor implicitly). "as many <unit>" required; unit ellipsis ("twice as
|
||||
# many as Bob") is deferred to keep wrong==0 strict — without unit the
|
||||
# binding graph cannot disambiguate which dimension to compare.
|
||||
_COMPARE_MULT_ANCHOR_RE: Final[re.Pattern[str]] = re.compile(
|
||||
rf"^(?P<actor>{_ENTITY})\s+{_comparison_anchor_verb()}\s+"
|
||||
r"(?P<anchor>twice|thrice|half)\s+as\s+many\s+"
|
||||
r"(?P<unit>\w+)\s+as\s+"
|
||||
rf"(?P<reference>{_COMPARE_REF})\s*\.?$"
|
||||
)
|
||||
|
||||
# Multiplicative: explicit "N times as many <unit> as <REF>".
|
||||
_COMPARE_MULT_NTIMES_RE: Final[re.Pattern[str]] = re.compile(
|
||||
rf"^(?P<actor>{_ENTITY})\s+{_comparison_anchor_verb()}\s+"
|
||||
rf"(?P<value>{_VALUE})\s+times\s+as\s+many\s+"
|
||||
r"(?P<unit>\w+)\s+as\s+"
|
||||
rf"(?P<reference>{_COMPARE_REF})\s*\.?$"
|
||||
)
|
||||
|
||||
# Nested: additive over multiplicative — "A has N more <unit> than M times <REF>".
|
||||
# Emits *two* flat candidates so the binding graph (ADR-0134) can decide which
|
||||
# admissible composition (if any) survives. The parser does not commit to a
|
||||
# nested operand shape (Comparison ∋ Comparison is not a supported operand
|
||||
# type today); composition admissibility is the round-trip layer's call.
|
||||
_COMPARE_NESTED_RE: Final[re.Pattern[str]] = re.compile(
|
||||
rf"^(?P<actor>{_ENTITY})\s+{_comparison_anchor_verb()}\s+"
|
||||
rf"(?P<delta_value>{_VALUE})\s+"
|
||||
r"(?P<direction>more|fewer|less)\s+"
|
||||
r"(?P<unit>\w+)\s+than\s+"
|
||||
rf"(?P<factor_value>{_VALUE})\s+times\s+"
|
||||
rf"(?P<reference>{_COMPARE_REF})\s*\.?$"
|
||||
)
|
||||
|
||||
|
||||
_ANCHOR_TO_FACTOR: Final[dict[str, tuple[float, str]]] = {
|
||||
# surface anchor → (factor, direction-literal)
|
||||
"twice": (2.0, "times"),
|
||||
"thrice": (3.0, "times"),
|
||||
"half": (0.5, "fraction"),
|
||||
}
|
||||
|
||||
|
||||
def _direction_to_anchor(direction_raw: str) -> tuple[str, str]:
|
||||
"""Map surface direction word → (canonical Comparison.direction,
|
||||
matched_verb registered in COMPARE_ADDITIVE_ANCHORS).
|
||||
|
||||
'less' is a surface synonym of 'fewer'; the Comparison value uses
|
||||
direction='fewer', but matched_verb retains the source surface
|
||||
('less') so the round-trip filter's "verb appears in source" check
|
||||
succeeds. 'less' is registered in COMPARE_ADDITIVE_ANCHORS, so the
|
||||
verb-registry check also succeeds.
|
||||
"""
|
||||
lowered = direction_raw.lower()
|
||||
if lowered in ("more", "fewer"):
|
||||
return lowered, lowered
|
||||
if lowered == "less":
|
||||
return "fewer", "less"
|
||||
raise ValueError(f"unknown comparative direction surface {direction_raw!r}")
|
||||
|
||||
|
||||
def _build_compare_additive(
|
||||
*,
|
||||
actor_raw: str,
|
||||
delta_value_raw: str,
|
||||
direction_raw: str,
|
||||
unit_raw: str,
|
||||
reference_raw: str,
|
||||
source: str,
|
||||
) -> CandidateOperation | None:
|
||||
if _is_indefinite_quantifier(delta_value_raw):
|
||||
return None
|
||||
direction, matched_verb = _direction_to_anchor(direction_raw)
|
||||
actor = _normalize_entity(actor_raw)
|
||||
reference_canon, reference_head = _resolve_reference_token(reference_raw)
|
||||
if reference_canon == actor:
|
||||
return None # self-reference; constructor would refuse anyway
|
||||
delta_value = _resolve_value(delta_value_raw)
|
||||
unit = _canonicalize_unit(unit_raw)
|
||||
try:
|
||||
op = Operation(
|
||||
actor=actor,
|
||||
kind="compare_additive",
|
||||
operand=Comparison(
|
||||
reference_actor=reference_canon,
|
||||
delta=Quantity(value=delta_value, unit=unit),
|
||||
factor=None,
|
||||
direction=cast(_CompDirection, direction),
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
return CandidateOperation(
|
||||
op=op,
|
||||
source_span=source,
|
||||
matched_verb=matched_verb,
|
||||
matched_value_token=delta_value_raw,
|
||||
matched_unit_token=unit_raw,
|
||||
matched_actor_token=actor_raw,
|
||||
matched_reference_actor_token=reference_head,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _build_compare_multiplicative(
|
||||
*,
|
||||
actor_raw: str,
|
||||
factor: float,
|
||||
matched_verb: str,
|
||||
matched_value_token: str,
|
||||
unit_raw: str,
|
||||
reference_raw: str,
|
||||
source: str,
|
||||
direction: str,
|
||||
) -> CandidateOperation | None:
|
||||
actor = _normalize_entity(actor_raw)
|
||||
reference_canon, reference_head = _resolve_reference_token(reference_raw)
|
||||
if reference_canon == actor:
|
||||
return None
|
||||
_ = _canonicalize_unit(unit_raw) # validation only; multiplicative compares
|
||||
# carry the unit on the source-span side, not the operand
|
||||
try:
|
||||
op = Operation(
|
||||
actor=actor,
|
||||
kind="compare_multiplicative",
|
||||
operand=Comparison(
|
||||
reference_actor=reference_canon,
|
||||
delta=None,
|
||||
factor=factor,
|
||||
direction=cast(_CompDirection, direction),
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
return CandidateOperation(
|
||||
op=op,
|
||||
source_span=source,
|
||||
matched_verb=matched_verb,
|
||||
matched_value_token=matched_value_token,
|
||||
matched_unit_token=unit_raw,
|
||||
matched_actor_token=actor_raw,
|
||||
matched_reference_actor_token=reference_head,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _compare_additive_candidates(sentence: str) -> list[CandidateOperation]:
|
||||
s = sentence.strip()
|
||||
m = _COMPARE_ADDITIVE_RE.match(s)
|
||||
if m is None:
|
||||
return []
|
||||
cand = _build_compare_additive(
|
||||
actor_raw=m.group("actor"),
|
||||
delta_value_raw=m.group("value"),
|
||||
direction_raw=m.group("direction"),
|
||||
unit_raw=m.group("unit"),
|
||||
reference_raw=m.group("reference"),
|
||||
source=sentence,
|
||||
)
|
||||
return [cand] if cand is not None else []
|
||||
|
||||
|
||||
def _compare_multiplicative_candidates(sentence: str) -> list[CandidateOperation]:
|
||||
s = sentence.strip()
|
||||
out: list[CandidateOperation] = []
|
||||
|
||||
m = _COMPARE_MULT_ANCHOR_RE.match(s)
|
||||
if m is not None:
|
||||
anchor = m.group("anchor").lower()
|
||||
factor, direction = _ANCHOR_TO_FACTOR[anchor]
|
||||
cand = _build_compare_multiplicative(
|
||||
actor_raw=m.group("actor"),
|
||||
factor=factor,
|
||||
matched_verb=anchor,
|
||||
matched_value_token=anchor, # anchor-as-value (math_roundtrip step 4)
|
||||
unit_raw=m.group("unit"),
|
||||
reference_raw=m.group("reference"),
|
||||
source=sentence,
|
||||
direction=direction,
|
||||
)
|
||||
if cand is not None:
|
||||
out.append(cand)
|
||||
return out # specificity — don't also try N-times pattern
|
||||
|
||||
m = _COMPARE_MULT_NTIMES_RE.match(s)
|
||||
if m is not None:
|
||||
value_raw = m.group("value")
|
||||
if _is_indefinite_quantifier(value_raw):
|
||||
return out
|
||||
try:
|
||||
factor = float(_resolve_value(value_raw))
|
||||
except KeyError:
|
||||
return out
|
||||
cand = _build_compare_multiplicative(
|
||||
actor_raw=m.group("actor"),
|
||||
factor=factor,
|
||||
matched_verb="times",
|
||||
matched_value_token=value_raw,
|
||||
unit_raw=m.group("unit"),
|
||||
reference_raw=m.group("reference"),
|
||||
source=sentence,
|
||||
direction="times",
|
||||
)
|
||||
if cand is not None:
|
||||
out.append(cand)
|
||||
return out
|
||||
|
||||
|
||||
def _compare_nested_candidates(sentence: str) -> list[CandidateOperation]:
|
||||
"""Emit two flat candidates for nested 'N more <unit> than M times <REF>'.
|
||||
|
||||
The parser does not commit to a composed Comparison-of-Comparison
|
||||
operand (operand type Comparison ∋ Comparison is not modelled
|
||||
today). Both flat candidates are forwarded; the binding-graph /
|
||||
round-trip layer (ADR-0134) picks an admissible composition or
|
||||
refuses. Refusal is the safe outcome — never a wrong answer.
|
||||
"""
|
||||
s = sentence.strip()
|
||||
m = _COMPARE_NESTED_RE.match(s)
|
||||
if m is None:
|
||||
return []
|
||||
out: list[CandidateOperation] = []
|
||||
|
||||
actor_raw = m.group("actor")
|
||||
unit_raw = m.group("unit")
|
||||
reference_raw = m.group("reference")
|
||||
|
||||
# Candidate 1: additive — "A has N more <unit> than <REF>" treating
|
||||
# <REF> as the comparison reference directly. The "M times" multiplier
|
||||
# is dropped on this candidate (the binding-graph composition is
|
||||
# what would re-introduce it).
|
||||
add_cand = _build_compare_additive(
|
||||
actor_raw=actor_raw,
|
||||
delta_value_raw=m.group("delta_value"),
|
||||
direction_raw=m.group("direction"),
|
||||
unit_raw=unit_raw,
|
||||
reference_raw=reference_raw,
|
||||
source=sentence,
|
||||
)
|
||||
if add_cand is not None:
|
||||
out.append(add_cand)
|
||||
|
||||
# Candidate 2: multiplicative — "A has M times as many <unit> as <REF>"
|
||||
# treating the multiplier M and the same <REF> as the multiplicative
|
||||
# comparison. The additive offset N is dropped on this candidate.
|
||||
factor_value_raw = m.group("factor_value")
|
||||
if not _is_indefinite_quantifier(factor_value_raw):
|
||||
try:
|
||||
factor = float(_resolve_value(factor_value_raw))
|
||||
except KeyError:
|
||||
factor = None
|
||||
if factor is not None:
|
||||
mult_cand = _build_compare_multiplicative(
|
||||
actor_raw=actor_raw,
|
||||
factor=factor,
|
||||
matched_verb="times",
|
||||
matched_value_token=factor_value_raw,
|
||||
unit_raw=unit_raw,
|
||||
reference_raw=reference_raw,
|
||||
source=sentence,
|
||||
direction="times",
|
||||
)
|
||||
if mult_cand is not None:
|
||||
out.append(mult_cand)
|
||||
|
||||
return out
|
||||
|
|
|
|||
275
tests/test_adr_0131_G2_comparatives.py
Normal file
275
tests/test_adr_0131_G2_comparatives.py
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
"""ADR-0131.G.2 — comparative operations (additive + multiplicative).
|
||||
|
||||
Pins the curated coverage axis under
|
||||
``evals/math_capability_axes/G2_comparatives/v1/`` plus the
|
||||
``math_candidate_parser`` comparative extractors.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from evals.math_capability_axes.G2_comparatives.v1.runner import (
|
||||
build_report,
|
||||
write_report,
|
||||
_REPORT_PATH,
|
||||
)
|
||||
from generate.math_candidate_parser import (
|
||||
_compare_additive_candidates,
|
||||
_compare_multiplicative_candidates,
|
||||
_compare_nested_candidates,
|
||||
extract_operation_candidates,
|
||||
)
|
||||
from generate.math_roundtrip import (
|
||||
COMPARE_ADDITIVE_ANCHORS,
|
||||
COMPARE_MULTIPLICATIVE_ANCHORS,
|
||||
roundtrip_admissible,
|
||||
)
|
||||
|
||||
|
||||
_REPO = Path(__file__).resolve().parents[1]
|
||||
_GSM8K_LEGACY_REPORT = (
|
||||
_REPO / "evals/gsm8k_math/train_sample/v1/train_sample_coverage_report.json"
|
||||
)
|
||||
_GSM8K_CG_REPORT = _REPO / "evals/gsm8k_math/train_sample/v1/report.json"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-direction at-least-one-passing.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("direction,sentence,actor,reference,delta", [
|
||||
("more", "Alice has 3 more apples than Bob", "Alice", "Bob", 3),
|
||||
("fewer", "Alice has 5 fewer pencils than Bob", "Alice", "Bob", 5),
|
||||
])
|
||||
def test_additive_direction_admits(direction, sentence, actor, reference, delta):
|
||||
cands = [c for c in _compare_additive_candidates(sentence) if roundtrip_admissible(c)]
|
||||
assert len(cands) == 1
|
||||
c = cands[0]
|
||||
assert c.op.kind == "compare_additive"
|
||||
assert c.op.operand.direction == direction
|
||||
assert c.op.actor == actor
|
||||
assert c.op.operand.reference_actor == reference
|
||||
assert c.op.operand.delta.value == delta
|
||||
|
||||
|
||||
def test_additive_less_maps_to_fewer():
|
||||
cands = [c for c in _compare_additive_candidates("Dana has 8 less coins than Eli") if roundtrip_admissible(c)]
|
||||
assert len(cands) == 1
|
||||
c = cands[0]
|
||||
assert c.op.operand.direction == "fewer"
|
||||
assert c.matched_verb == "less"
|
||||
# 'less' is a registered additive anchor (COMPARE_ADDITIVE_ANCHORS).
|
||||
assert "less" in COMPARE_ADDITIVE_ANCHORS
|
||||
|
||||
|
||||
@pytest.mark.parametrize("direction,sentence,actor,reference,factor,anchor", [
|
||||
("times", "Alice has twice as many apples as Bob", "Alice", "Bob", 2.0, "twice"),
|
||||
("times", "Alice has 4 times as many apples as Bob", "Alice", "Bob", 4.0, "times"),
|
||||
("times", "Alice has thrice as many apples as Bob", "Alice", "Bob", 3.0, "thrice"),
|
||||
("fraction", "Alice has half as many apples as Bob", "Alice", "Bob", 0.5, "half"),
|
||||
])
|
||||
def test_multiplicative_direction_admits(direction, sentence, actor, reference, factor, anchor):
|
||||
cands = [c for c in _compare_multiplicative_candidates(sentence) if roundtrip_admissible(c)]
|
||||
assert len(cands) == 1
|
||||
c = cands[0]
|
||||
assert c.op.kind == "compare_multiplicative"
|
||||
assert c.op.operand.direction == direction
|
||||
assert c.op.actor == actor
|
||||
assert c.op.operand.reference_actor == reference
|
||||
assert c.op.operand.factor == factor
|
||||
assert c.matched_verb == anchor
|
||||
assert anchor in COMPARE_MULTIPLICATIVE_ANCHORS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Nested composition emits BOTH flat candidates.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_nested_emits_both_flat_candidates():
|
||||
s = "Jen has 10 more ducks than four times the number of chickens"
|
||||
cands = [c for c in _compare_nested_candidates(s) if roundtrip_admissible(c)]
|
||||
kinds = {c.op.kind for c in cands}
|
||||
assert kinds == {"compare_additive", "compare_multiplicative"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Refusal cases — closed-set boundary holds.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("sentence", [
|
||||
"Alice has as many apples as Bob",
|
||||
"Compared to Bob, Alice has 3 more apples",
|
||||
"In comparison with Bob, Alice has 3 more apples",
|
||||
"Alice has the same number of apples as Bob",
|
||||
"Alice has 3 times more apples than Bob",
|
||||
])
|
||||
def test_refusal_cases_emit_no_admitted_comparative(sentence):
|
||||
cands = extract_operation_candidates(sentence)
|
||||
admitted_comparatives = [
|
||||
c for c in cands
|
||||
if c.op.kind in ("compare_additive", "compare_multiplicative")
|
||||
and roundtrip_admissible(c)
|
||||
]
|
||||
assert admitted_comparatives == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Closed-set anchor discipline — emitter never invents a direction literal.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_direction_literals_closed_set():
|
||||
sentences = [
|
||||
"Alice has 3 more apples than Bob",
|
||||
"Alice has 5 fewer pencils than Bob",
|
||||
"Alice has 2 less books than Bob",
|
||||
"Alice has twice as many apples as Bob",
|
||||
"Alice has 4 times as many apples as Bob",
|
||||
"Alice has half as many apples as Bob",
|
||||
"Alice has thrice as many apples as Bob",
|
||||
"Jen has 10 more ducks than four times the number of chickens",
|
||||
]
|
||||
for s in sentences:
|
||||
for c in extract_operation_candidates(s):
|
||||
if c.op.kind in ("compare_additive", "compare_multiplicative"):
|
||||
assert c.op.operand.direction in ("more", "fewer", "times", "fraction")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runner / report contract.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_runner_wrong_count_is_zero():
|
||||
report = build_report()
|
||||
assert report["metrics"]["wrong"] == 0
|
||||
assert report["metrics"]["wrong_count_is_zero"] is True
|
||||
|
||||
|
||||
def test_runner_per_category_minima():
|
||||
"""Brief §coverage: ≥4 per direction (more, fewer, times, fraction);
|
||||
≥3 nested; ≥4 refusal. We pin direction-level minima via per-case scan
|
||||
rather than per-category (additive merges more+fewer into one bucket
|
||||
in per_category but we want to verify each direction independently)."""
|
||||
report = build_report()
|
||||
direction_counts = {"more": 0, "fewer": 0, "times": 0, "fraction": 0}
|
||||
nested = 0
|
||||
refusal = 0
|
||||
cases_path = _REPO / "evals/math_capability_axes/G2_comparatives/v1/cases.jsonl"
|
||||
for line in cases_path.read_text(encoding="utf-8").splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
c = json.loads(line)
|
||||
if c["category"] in ("additive", "multiplicative"):
|
||||
direction_counts[c["direction"]] += 1
|
||||
elif c["category"] == "nested":
|
||||
nested += 1
|
||||
elif c["category"] == "refusal":
|
||||
refusal += 1
|
||||
for d, n in direction_counts.items():
|
||||
assert n >= 4, f"direction {d!r} has only {n} cases (need ≥4)"
|
||||
assert nested >= 3
|
||||
assert refusal >= 4
|
||||
# And every one of those cases passes (wrong==0 already asserts no
|
||||
# wrong-shaped pass, but verify the positive direction too).
|
||||
assert report["metrics"]["passed"] == report["metrics"]["cases_total"]
|
||||
|
||||
|
||||
def test_report_byte_equal_across_runs():
|
||||
a = json.dumps(build_report(), indent=2, sort_keys=True)
|
||||
b = json.dumps(build_report(), indent=2, sort_keys=True)
|
||||
assert a == b
|
||||
|
||||
|
||||
def test_committed_report_matches_runner_output():
|
||||
report = build_report()
|
||||
written = json.dumps(report, indent=2, sort_keys=True) + "\n"
|
||||
on_disk = _REPORT_PATH.read_text(encoding="utf-8")
|
||||
if written != on_disk:
|
||||
# Re-write to be helpful in local dev (so CI failures point at the
|
||||
# actual delta, not a stale file).
|
||||
write_report(report)
|
||||
assert written == on_disk, "G2 report.json is stale — re-run runner.py"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GSM8K probe gate — chosen gate (per ADR-0131.G.2):
|
||||
# comparative-clause refusals in the candidate-graph probe report.json
|
||||
# strictly decrease.
|
||||
# Legacy train_sample_coverage_report.json is byte-identical (no legacy
|
||||
# parser change); we pin both invariants below.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_COMPARATIVE_STATEMENT_PATTERNS = (
|
||||
# "<N> more <unit> than" / "fewer / less"
|
||||
re.compile(r"\b(?:more|fewer|less)\b[^.'\"]*\bthan\b", re.IGNORECASE),
|
||||
# "twice|thrice|half|N times as many <unit> as"
|
||||
re.compile(r"\b(?:twice|thrice|half|\d+\s+times)\s+as\s+many\b", re.IGNORECASE),
|
||||
)
|
||||
|
||||
|
||||
def _comparative_clause_refusal_count(probe_report_path: Path) -> int:
|
||||
"""Count refused cases whose refusal reason names a statement clause
|
||||
that itself contains a comparative anchor (additive ``more/fewer/less
|
||||
… than`` or multiplicative ``twice/N times/half as many``). We anchor
|
||||
on the embedded statement clause inside the ``candidate_graph`` /
|
||||
``parser`` refusal reason, not on isolated keywords, to avoid
|
||||
false-positives on incidental mentions ('he is more careful than',
|
||||
discourse glue, etc.)."""
|
||||
data = json.loads(probe_report_path.read_text(encoding="utf-8"))
|
||||
per_case = data["per_case"]
|
||||
count = 0
|
||||
for d in per_case:
|
||||
if d.get("verdict", d.get("outcome")) != "refused":
|
||||
continue
|
||||
reason = d["reason"]
|
||||
if "statement" not in reason and "statement clause" not in reason:
|
||||
continue
|
||||
for pat in _COMPARATIVE_STATEMENT_PATTERNS:
|
||||
if pat.search(reason):
|
||||
count += 1
|
||||
break
|
||||
return count
|
||||
|
||||
|
||||
def test_gsm8k_legacy_probe_safety_rail_intact():
|
||||
"""ADR-0131.G invariant: legacy probe still shows admitted_wrong == 0."""
|
||||
data = json.loads(_GSM8K_LEGACY_REPORT.read_text(encoding="utf-8"))
|
||||
assert data["metrics"]["admitted_wrong"] == 0
|
||||
assert data["metrics"]["safety_rail_intact"] is True
|
||||
|
||||
|
||||
def test_gsm8k_candidate_graph_probe_wrong_zero():
|
||||
"""Candidate-graph probe also preserves wrong == 0."""
|
||||
data = json.loads(_GSM8K_CG_REPORT.read_text(encoding="utf-8"))
|
||||
assert data["counts"]["wrong"] == 0
|
||||
|
||||
|
||||
def test_gsm8k_candidate_graph_comparative_clause_refusals_decreased():
|
||||
"""G2 gate: at least one previously-comparative-clause-refused case
|
||||
no longer cites a comparative-clause refusal in the candidate-graph
|
||||
probe. Baseline (pre-G.2) for the train sample had case 0009 ('Jen
|
||||
has 10 more ducks than four times the number of chickens') refused
|
||||
at the comparative statement; with G.2 wired into
|
||||
math_candidate_parser, that comparative clause now parses (the case
|
||||
may still refuse downstream — at the question or solver — but the
|
||||
refusal reason no longer cites the comparative clause)."""
|
||||
current_count = _comparative_clause_refusal_count(_GSM8K_CG_REPORT)
|
||||
# Pre-G.2 baseline (`origin/main` @ 481e0c3): 2 cases refused at a
|
||||
# statement clause whose text contains additive ('more/fewer/less ...
|
||||
# than') or multiplicative ('twice/N times/half as many') anchors —
|
||||
# cases gsm8k-train-sample-v1-0009 ('Jen has 10 more ducks than four
|
||||
# times the number of chickens.') and -0016 (arithmetic 'more than'
|
||||
# in 'Rudolph traveled 2 more than 5 miles'). G.2 wires comparatives
|
||||
# into math_candidate_parser; case 0009 now parses the comparative
|
||||
# clause (refusal moves to the question form), so the count drops to
|
||||
# 1 (case 0016's arithmetic 'N more than M' is *not* a between-entity
|
||||
# comparative and is deliberately out of G.2 scope).
|
||||
baseline_count = 2
|
||||
assert current_count < baseline_count, (
|
||||
f"expected comparative-clause refusal count to drop below "
|
||||
f"baseline {baseline_count}; got {current_count}"
|
||||
)
|
||||
Loading…
Reference in a new issue