feat(ADR-0136.S.2): conditional-op question — gsm8k-0042 admits, wrong==0 (#203)

Adds CandidateConditionalOpQuestion + extractor for the closed shape:
  "If <Entity> <verb> <N> <unit>, how many <unit2> does <Entity2> <aux> [<qualifier>]?"

In parse_and_solve, when the question yields exactly one such candidate
and exactly one matching InitialPossession exists by (entity, unit) across
all statement sentences, computes initial_value ± operand (verb polarity)
and emits when answer >= 0; refuses otherwise. Structurally identical to
S.1 capacity/earnings short-circuits.

GSM8K probe: 2/50 → 3/50 (+0042, answer=30.0), wrong stays 0.

- generate/math_candidate_parser.py: _COND_SUBTRACT_VERBS / _COND_ADD_VERBS
  closed sets; _COND_OP_Q_RE; extract_conditional_op_question_candidates
- generate/math_candidate_graph.py: short-circuit after earnings path
- tests/test_adr_0136_S2_conditional_op.py: 25 tests (extractor unit tests,
  end-to-end short-circuit, B3 + S.1 regression guards, post-S.2 honest
  admission count)
- docs/decisions/ADR-0136.S.2-conditional-op-question.md
This commit is contained in:
Shay 2026-05-23 21:20:52 -07:00 committed by GitHub
parent 19ac7f94b9
commit e7a1ffb72e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 440 additions and 2 deletions

View file

@ -0,0 +1,106 @@
# ADR-0136.S.2 — Conditional-Op Question (Statement-Layer Corridor)
**Status:** Active
**Date:** 2026-05-23
**Parent:** [ADR-0136](./ADR-0136-statement-layer-corridor.md)
---
## Context
After S.0 (context-sentence classifier) and S.1 (rate/event statement parsing)
landed, the GSM8K train-sample probe sat at **2/50** admitted (`gsm8k-0014`,
`gsm8k-0018`), `wrong == 0`.
A taxonomy pass over the remaining 48 refused cases identified `gsm8k-0042`
as the next single-barrier unlock:
```
Ella has 4 bags with 20 apples in each bag and six bags with 25 apples in
each bag. If Ella sells 200 apples, how many apples does Ella has left?
```
The initial-state sentence already parses via `_CONJ_EMBEDDED_RE` (ADR-0131.G.4
embedded quantifier) to `InitialPossession(entity="Ella", value=230,
unit="apples")`. The **only** barrier is the question form, which neither
`_Q_ENTITY_RE` nor `_Q_TOTAL_RE` cover: the `If <Entity> <verb> <N> <unit>,
how many <unit2> does <Entity2> <aux> [left|...]?` shape combines a
conditional-action operand with the entity-recall question.
---
## Decision
Add a **conditional-op question** extractor and a corresponding short-circuit
in `parse_and_solve` that:
1. Matches the closed shape `If <Entity> <verb> <N> <unit>, how many <unit2>
does <Entity2> <aux> [<qualifier>]?`
2. Classifies `<verb>` against two closed sets:
- `_COND_SUBTRACT_VERBS` (sell/sells/sold, give/gives/gave, eat/eats/ate,
use, lose, spend, donate, remove, take, send, pay, drop, throw)
- `_COND_ADD_VERBS` (buy/buys/bought, get/gets/got, receive, find, add,
collect, pick, earn, gain)
3. Refuses on any of: unknown verb, unit mismatch (`<unit>` vs `<unit2>`
after canonicalization), entity mismatch (`<Entity>` vs `<Entity2>`
case-insensitively), `N <= 0`.
4. In `parse_and_solve`: if the question yields exactly one
`CandidateConditionalOpQuestion`, collect all `extract_initial_candidates`
from every statement sentence and look for **exactly one** matching IC
by `(entity, unit)`. If found, compute `initial_value ± operand` by verb
polarity; emit only when `answer >= 0`. Refuses otherwise.
The short-circuit is structurally identical to the S.1 capacity/earnings
paths: it bypasses graph construction, returns `selected_graph=None`, and
preserves `wrong == 0` by refusing rather than guessing on any ambiguity.
---
## Invariants
- **`admitted_wrong == 0`** preserved by:
- Single-match (entity, unit) requirement before emission
- Non-negative answer gate
- Closed verb sets — no wildcards
- **Context-filler safety rail** unchanged (S.0)
- **No solver/graph/verifier changes** — extractor lives in
`math_candidate_parser.py`; short-circuit lives in `math_candidate_graph.py`
---
## Honest GSM8K delta
| Stage | Admitted | Wrong |
|---|---|---|
| Pre-S.0 | 0/50 | 0 |
| Post-S.1 | 1/50 (`0014`) | 0 |
| Post-S.0 classifier | 2/50 (`+0018`) | 0 |
| **Post-S.2** | **3/50** (`+0042`) | **0** |
`gsm8k-0042` admits with `answer == 30.0` (expected: 30).
---
## Consequences
- The S.x corridor now spans `parser` (S.1 capacity/earnings, S.2 question
shape) and `pre-pass classifier` (S.0). Future phases (S.3 compound
statements, S.4 coreference) extend this pattern.
- The canonical GSM8K runner (`evals/gsm8k_math/runner.py`) still asserts
`selected_graph is not None` on admission and therefore cannot score the
short-circuit admissions. The `report.json` artifact remains stale
(0/50/0); the honest count is asserted via direct `parse_and_solve` test
(`test_gsm8k_post_s2_admission_honest`). Aligning the canonical runner
with the short-circuit paths is deferred (out of S.2 scope).
---
## Deferred
- Conditional-op question forms with **more than one** operand (e.g.
"If she gives away 3 and buys 5, …") — needs two-op composition.
- Question forms without `does <Entity>` aux (e.g. "how many apples
remain?") — needs a sibling regex.
- Conditional questions where the conditional and the question reference
**different** units that are unit-related (e.g. dollars↔cents) — needs
unit-relation taxonomy.

View file

@ -44,6 +44,7 @@ from generate.math_candidate_parser import (
classify_sentence,
extract_capacity_candidates,
extract_capacity_question_candidates,
extract_conditional_op_question_candidates,
extract_earnings_candidates,
extract_earnings_question_candidates,
extract_initial_candidates,
@ -393,6 +394,36 @@ def parse_and_solve(text: str) -> CandidateGraphResult:
branches_enumerated=0, branches_admissible=0,
)
# ADR-0136.S.2 — Conditional-op question short-circuit.
# Shape: "If <Entity> <verb> <N> <unit>, how many <unit2> does <Entity2>
# <aux> [left|...]?" — given exactly one matching initial-state
# candidate for (entity, unit) across all statement sentences, the
# answer is initial_value ± operand by verb polarity. Refuses on any
# ambiguity (multiple matching ICs, no IC, negative answer); preserves
# wrong == 0.
cond_qs = extract_conditional_op_question_candidates(question_sentences[0])
if len(cond_qs) == 1:
cq = cond_qs[0]
all_ic: list[CandidateInitial] = []
for s in statement_sentences:
all_ic.extend(extract_initial_candidates(s))
matching = [
ic for ic in all_ic
if ic.initial.entity.lower() == cq.entity.lower()
and ic.initial.quantity.unit == cq.unit
]
if len(matching) == 1:
val = matching[0].initial.quantity.value
answer = val - cq.operand if cq.op == "subtract" else val + cq.operand
if answer >= 0:
return CandidateGraphResult(
answer=answer,
selected_graph=None,
refusal_reason=None,
branches_enumerated=1,
branches_admissible=1,
)
# Per-sentence choice spaces (after round-trip filter + tiebreaker).
per_sentence_choices: list[list[SentenceChoice]] = []
for s in statement_sentences:

View file

@ -707,13 +707,13 @@ class CandidateUnknown:
_Q_ENTITY_RE: Final[re.Pattern[str]] = re.compile(
r"^How\s+many\s+(?P<unit>\w+)\s+(?:does|do)\s+"
rf"(?P<entity>{_ENTITY})"
r"\s+have(?:\s+(?:left|now|in\s+total|altogether|combined|together)){0,2}\s*\??$",
r"\s+have(?:\s+(?:left|now|in\s+total|altogether|combined|together|in\s+all)){0,2}\s*\??$",
flags=re.IGNORECASE,
)
_Q_TOTAL_RE: Final[re.Pattern[str]] = re.compile(
r"^How\s+many\s+(?P<unit>\w+)\s+do\s+they\s+have"
r"(?:\s+(?:in\s+total|altogether|combined|together|left|now)){0,2}\s*\??$",
r"(?:\s+(?:in\s+total|altogether|combined|together|in\s+all|left|now)){0,2}\s*\??$",
flags=re.IGNORECASE,
)
@ -1897,3 +1897,110 @@ def extract_earnings_question_candidates(
source_span=sentence,
)
]
# ---------------------------------------------------------------------------
# ADR-0136.S.2 — Conditional-op question
# ---------------------------------------------------------------------------
#
# Target shape (gsm8k-0042):
# "If <Entity> <verb> <N> <unit>, how many <unit2> does <Entity2> <verb2> left?"
#
# Routes through the parse_and_solve short-circuit: given a single matching
# initial-state candidate for (entity, unit), the answer is
# initial_value ± operand depending on verb polarity. No graph built;
# refuses on any ambiguity (unit mismatch, entity mismatch, multiple
# matching ICs, negative answer).
_COND_SUBTRACT_VERBS: Final[frozenset[str]] = frozenset({
"sell", "sells", "sold",
"give", "gives", "gave",
"eat", "eats", "ate",
"use", "uses", "used",
"lose", "loses", "lost",
"spend", "spends", "spent",
"donate", "donates", "donated",
"remove", "removes", "removed",
"take", "takes", "took",
"send", "sends", "sent",
"pay", "pays", "paid",
"drop", "drops", "dropped",
"throw", "throws", "threw",
})
_COND_ADD_VERBS: Final[frozenset[str]] = frozenset({
"buy", "buys", "bought",
"get", "gets", "got",
"receive", "receives", "received",
"find", "finds", "found",
"add", "adds", "added",
"collect", "collects", "collected",
"pick", "picks", "picked",
"earn", "earns", "earned",
"gain", "gains", "gained",
})
_COND_VERB_PATTERN: Final[str] = (
r"(?:" + "|".join(
re.escape(v)
for v in sorted(_COND_SUBTRACT_VERBS | _COND_ADD_VERBS, key=len, reverse=True)
) + r")"
)
@dataclass(frozen=True, slots=True)
class CandidateConditionalOpQuestion:
entity: str
op: Literal["add", "subtract"]
operand: float
unit: str
source_span: str
# "If <Entity> <verb> <N> <unit>, how many <unit2> does <Entity2> <aux>[ <qualifier>]?"
_COND_OP_Q_RE: Final[re.Pattern[str]] = re.compile(
rf"^If\s+(?P<entity>{_ENTITY})\s+"
rf"(?P<verb>{_COND_VERB_PATTERN})\s+"
r"(?P<n>\d+(?:\.\d+)?)\s+(?P<unit>\w+),\s+"
r"how\s+many\s+(?P<unit2>\w+)\s+does\s+"
rf"(?P<entity2>{_ENTITY})\s+(?:has|have|had)"
r"(?:\s+(?:left|now|remaining|away|in\s+total|altogether))?"
r"\s*\??\s*$",
flags=re.IGNORECASE,
)
def extract_conditional_op_question_candidates(
sentence: str,
) -> list[CandidateConditionalOpQuestion]:
s = sentence.strip()
m = _COND_OP_Q_RE.match(s)
if m is None:
return []
verb = m.group("verb").lower()
if verb in _COND_SUBTRACT_VERBS:
op: Literal["add", "subtract"] = "subtract"
elif verb in _COND_ADD_VERBS:
op = "add"
else:
return []
unit = _canonicalize_unit(m.group("unit"))
unit2 = _canonicalize_unit(m.group("unit2"))
if unit != unit2:
return []
entity = _normalize_entity(m.group("entity"))
entity2 = _normalize_entity(m.group("entity2"))
if entity.lower() != entity2.lower():
return []
n = float(m.group("n"))
if n <= 0:
return []
return [
CandidateConditionalOpQuestion(
entity=entity,
op=op,
operand=n,
unit=unit,
source_span=sentence,
)
]

View file

@ -0,0 +1,194 @@
"""ADR-0136.S.2 — Conditional-op question tests.
Pins the new `extract_conditional_op_question_candidates` extractor and
the short-circuit path in `parse_and_solve` for the shape:
"If <Entity> <verb> <N> <unit>, how many <unit2> does <Entity2> <aux>
[left|now|remaining|...]?"
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from generate.math_candidate_graph import parse_and_solve
from generate.math_candidate_parser import (
_COND_ADD_VERBS,
_COND_SUBTRACT_VERBS,
extract_conditional_op_question_candidates,
)
_REPO = Path(__file__).resolve().parent.parent
# ── Regex extractor tests ────────────────────────────────────────────
class TestConditionalOpExtractor:
def test_subtract_canonical(self) -> None:
cands = extract_conditional_op_question_candidates(
"If Ella sells 200 apples, how many apples does Ella has left?"
)
assert len(cands) == 1
c = cands[0]
assert c.entity == "Ella"
assert c.op == "subtract"
assert c.operand == 200.0
assert c.unit == "apples"
def test_add_canonical(self) -> None:
cands = extract_conditional_op_question_candidates(
"If Bob buys 5 apples, how many apples does Bob have now?"
)
assert len(cands) == 1
c = cands[0]
assert c.op == "add"
assert c.operand == 5.0
@pytest.mark.parametrize(
"verb", ["sells", "gives", "eats", "uses", "loses", "spends", "donates"]
)
def test_subtract_verbs(self, verb: str) -> None:
cands = extract_conditional_op_question_candidates(
f"If Alice {verb} 3 apples, how many apples does Alice have left?"
)
assert len(cands) == 1
assert cands[0].op == "subtract"
@pytest.mark.parametrize(
"verb", ["buys", "gets", "receives", "finds", "collects", "earns"]
)
def test_add_verbs(self, verb: str) -> None:
cands = extract_conditional_op_question_candidates(
f"If Alice {verb} 3 apples, how many apples does Alice have now?"
)
assert len(cands) == 1
assert cands[0].op == "add"
def test_unit_mismatch_refuses(self) -> None:
cands = extract_conditional_op_question_candidates(
"If Ella sells 200 apples, how many oranges does Ella have left?"
)
assert cands == []
def test_entity_mismatch_refuses(self) -> None:
cands = extract_conditional_op_question_candidates(
"If Ella sells 200 apples, how many apples does Bob have left?"
)
assert cands == []
def test_unknown_verb_refuses(self) -> None:
cands = extract_conditional_op_question_candidates(
"If Ella juggles 200 apples, how many apples does Ella have left?"
)
assert cands == []
def test_zero_operand_refuses(self) -> None:
cands = extract_conditional_op_question_candidates(
"If Ella sells 0 apples, how many apples does Ella have left?"
)
assert cands == []
def test_verb_sets_disjoint(self) -> None:
assert _COND_SUBTRACT_VERBS.isdisjoint(_COND_ADD_VERBS)
# ── End-to-end short-circuit tests ───────────────────────────────────
class TestConditionalOpEndToEnd:
def test_gsm8k_0042_admits_30(self) -> None:
"""The proof case for S.2."""
r = parse_and_solve(
"Ella has 4 bags with 20 apples in each bag and "
"six bags with 25 apples in each bag. "
"If Ella sells 200 apples, how many apples does Ella has left?"
)
assert r.answer == 30.0, f"got {r.answer} ({r.refusal_reason})"
def test_simple_subtract(self) -> None:
r = parse_and_solve(
"Bob has 100 apples. "
"If Bob eats 30 apples, how many apples does Bob have left?"
)
assert r.answer == 70.0
def test_simple_add(self) -> None:
r = parse_and_solve(
"Alice has 12 apples. "
"If Alice buys 8 apples, how many apples does Alice have now?"
)
assert r.answer == 20.0
def test_negative_result_refuses(self) -> None:
"""Selling more than you have must refuse (never produce negative)."""
r = parse_and_solve(
"Bob has 10 apples. "
"If Bob sells 50 apples, how many apples does Bob have left?"
)
assert r.answer is None
def test_no_matching_initial_state_refuses(self) -> None:
"""Question about apples but no initial-state apples → refuse."""
r = parse_and_solve(
"Bob has 10 oranges. "
"If Bob sells 5 apples, how many apples does Bob have left?"
)
assert r.answer is None
def test_no_matching_entity_refuses(self) -> None:
r = parse_and_solve(
"Bob has 100 apples. "
"If Alice sells 30 apples, how many apples does Alice have left?"
)
assert r.answer is None
# ── B3 + S.1 regression guards ───────────────────────────────────────
def test_b3_lane_still_passes() -> None:
from evals.math_bounded_grammar.v1.runner import build_report, load_cases
cases_path = _REPO / "evals" / "math_bounded_grammar" / "v1" / "cases.jsonl"
report = build_report(load_cases(cases_path))
assert report["metrics"]["wrong"] == 0
def test_s1_axis_lane_still_passes() -> None:
from evals.math_capability_axes.S1_rate_events.v1.runner import build_report
report = build_report()
assert report["metrics"]["wrong"] == 0
# ── GSM8K safety rail ────────────────────────────────────────────────
def test_gsm8k_post_s2_admission_honest() -> None:
"""Post-S.2: 3 admissions expected (0014, 0018, 0042); wrong stays 0."""
cases = [
json.loads(line)
for line in (
_REPO / "evals/gsm8k_math/train_sample/v1/cases.jsonl"
).read_text(encoding="utf-8").splitlines()
if line.strip()
]
admitted: list[str] = []
wrong: list[tuple[str, float, float]] = []
for c in cases:
r = parse_and_solve(c["question"])
if r.answer is not None:
if r.answer == c["answer_numeric"]:
admitted.append(c["case_id"])
else:
wrong.append((c["case_id"], r.answer, c["answer_numeric"]))
assert wrong == [], f"wrong admissions: {wrong}"
assert "gsm8k-train-sample-v1-0014" in admitted # S.1 capacity
assert "gsm8k-train-sample-v1-0018" in admitted # S.1 inverted
assert "gsm8k-train-sample-v1-0042" in admitted # S.2 cond-op
assert len(admitted) >= 3