feat(ADR-0136.S.0): context-sentence classifier — skip no-digit sentences, gsm8k-0018 admits (#202)

- Add classify_sentence() + has_numeric_token() to math_candidate_parser.py.
  Rule: sentence with no digit and no word-number cannot introduce parseable
  numeric state — classify as "context" and skip safely (wrong==0 preserved).

- Add pre-pass in parse_and_solve() (math_candidate_graph.py): strips context
  sentences before extraction; falls through to refusal if none remain numeric.

- Extend capacity patterns for gsm8k-0018:
  - _CAPACITY_INVERTED_RE: "During M <time-unit> <Actor> can <verb> N <unit>"
  - _CAPACITY_Q2_RE: "How many <unit> [on average] is <Actor> able to <verb>,
    when the <event> lasted for T <time-unit>?"

- GSM8K: 1/50 -> 2/50 (gsm8k-0018 admits with answer 16.0); admitted_wrong==0.
- Tests: 47/47 pass (12 new for classifier, inverted patterns, 0018 end-to-end).
This commit is contained in:
Shay 2026-05-23 20:51:47 -07:00 committed by GitHub
parent 52f2bf6f4c
commit 19ac7f94b9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 156 additions and 11 deletions

View file

@ -41,6 +41,7 @@ from typing import Final, Union
from generate.math_candidate_parser import (
CandidateInitial,
CandidateUnknown,
classify_sentence,
extract_capacity_candidates,
extract_capacity_question_candidates,
extract_earnings_candidates,
@ -315,6 +316,15 @@ def parse_and_solve(text: str) -> CandidateGraphResult:
question_sentences = [s for s in sentences if s.rstrip().endswith("?")]
statement_sentences = [s for s in sentences if not s.rstrip().endswith("?")]
# ADR-0136.S.0 — Strip context-filler sentences before any extraction.
# A sentence with no digit and no word-number cannot introduce parseable
# numeric state; skipping it is provably safe for wrong == 0.
numeric_statement_sentences = [
s for s in statement_sentences if classify_sentence(s) == "numeric_state"
]
if numeric_statement_sentences or not statement_sentences:
statement_sentences = numeric_statement_sentences
if len(question_sentences) != 1:
return CandidateGraphResult(
answer=None, selected_graph=None,

View file

@ -1571,6 +1571,38 @@ def _build_conj_embedded_sum(
return []
# ---------------------------------------------------------------------------
# ADR-0136.S.0 — Context-sentence classifier
# ---------------------------------------------------------------------------
_WORD_NUMBER_RE: Final[re.Pattern[str]] = re.compile(
r"\b(?:" + "|".join(re.escape(w) for w in sorted(WORD_NUMBERS, key=len, reverse=True)) + r")\b",
re.IGNORECASE,
)
def has_numeric_token(sentence: str) -> bool:
"""Return True if *sentence* contains any digit or closed-set word-number.
A sentence with no numeric token cannot introduce quantified initial state,
so it is safe to classify as a context filler and skip it.
"""
if re.search(r"\d", sentence):
return True
return bool(_WORD_NUMBER_RE.search(sentence))
def classify_sentence(sentence: str) -> Literal["context", "numeric_state"]:
"""Classify a statement sentence as skippable context or numeric-state-bearing.
Rule: if the sentence contains no digit and no word-number from the closed
set, it cannot introduce any parseable numeric state classify as
``"context"`` (safe to skip). All other sentences are ``"numeric_state"``
and must either parse successfully or cause a refusal.
"""
return "context" if not has_numeric_token(sentence) else "numeric_state"
# ---------------------------------------------------------------------------
# ADR-0136.S.1 — Rate/event statement extractors (capacity + earnings)
# ---------------------------------------------------------------------------
@ -1626,6 +1658,7 @@ class CandidateCapacity:
source_span: str
# Shape A1 (canonical): "<Actor> can <verb> N <unit> in M <time-unit>."
_CAPACITY_RE: Final[re.Pattern[str]] = re.compile(
rf"^(?P<actor>{_ENTITY})\s+can\s+"
rf"(?P<verb>{_CAPACITY_VERB_PATTERN})\s+"
@ -1633,16 +1666,27 @@ _CAPACITY_RE: Final[re.Pattern[str]] = re.compile(
rf"(?P<unit>\w+)\s+in\s+"
rf"(?P<per_count>\d+(?:\.\d+)?)\s+"
rf"(?P<per_unit>{_TIME_UNIT_SET})"
r"(?:\s+on\s+average)?"
r"\s*\.?\s*$",
flags=re.IGNORECASE,
)
# Shape A2 (inverted): "During M <time-unit> <Actor> can <verb> N <unit> [on average]."
_CAPACITY_INVERTED_RE: Final[re.Pattern[str]] = re.compile(
rf"^[Dd]uring\s+"
rf"(?P<per_count>\d+(?:\.\d+)?)\s+"
rf"(?P<per_unit>{_TIME_UNIT_SET})\s+"
rf"(?P<actor>{_ENTITY})\s+can\s+"
rf"(?P<verb>{_CAPACITY_VERB_PATTERN})\s+"
rf"(?P<count>\d+(?:\.\d+)?)\s+"
rf"(?P<unit>\w+)"
r"(?:\s+on\s+average)?"
r"\s*\.?\s*$",
flags=re.IGNORECASE,
)
def extract_capacity_candidates(sentence: str) -> list[CandidateCapacity]:
s = sentence.strip()
m = _CAPACITY_RE.match(s)
if m is None:
return []
def _capacity_from_match(m: re.Match[str], sentence: str) -> list[CandidateCapacity]:
verb = m.group("verb").lower()
if verb not in _CAPACITY_VERBS:
return []
@ -1662,6 +1706,17 @@ def extract_capacity_candidates(sentence: str) -> list[CandidateCapacity]:
]
def extract_capacity_candidates(sentence: str) -> list[CandidateCapacity]:
s = sentence.strip()
m = _CAPACITY_RE.match(s)
if m is not None:
return _capacity_from_match(m, sentence)
m2 = _CAPACITY_INVERTED_RE.match(s)
if m2 is not None:
return _capacity_from_match(m2, sentence)
return []
@dataclass(frozen=True, slots=True)
class CandidateCapacityQuestion:
actor: str | None
@ -1673,6 +1728,7 @@ class CandidateCapacityQuestion:
_PRONOUN_SET: Final[str] = r"(?:he|she|they|it)"
# Q1 (canonical): "How many <unit> can <actor> <verb> in T <time-unit>?"
_CAPACITY_Q_RE: Final[re.Pattern[str]] = re.compile(
r"^How\s+many\s+(?P<unit>\w+)\s+can\s+"
rf"(?P<actor>{_ENTITY}|{_PRONOUN_SET})\s+"
@ -1683,14 +1739,23 @@ _CAPACITY_Q_RE: Final[re.Pattern[str]] = re.compile(
flags=re.IGNORECASE,
)
# Q2 (able-form): "How many <unit> [on average] is <actor> able to <verb>,
# when the [match/game/session] lasted for T <time-unit>?"
_CAPACITY_Q2_RE: Final[re.Pattern[str]] = re.compile(
r"^How\s+many\s+(?P<unit>\w+)(?:\s+on\s+average)?\s+is\s+"
rf"(?P<actor>{_ENTITY}|{_PRONOUN_SET})\s+able\s+to\s+"
rf"(?P<verb>{_CAPACITY_VERB_PATTERN}),?\s+"
r"when\s+the\s+\w+\s+lasted\s+for\s+"
rf"(?P<per_count>\d+(?:\.\d+)?)\s+"
rf"(?P<per_unit>{_TIME_UNIT_SET})"
r"\s*\??\s*$",
flags=re.IGNORECASE,
)
def extract_capacity_question_candidates(
sentence: str,
def _capacity_question_from_match(
m: re.Match[str], sentence: str
) -> list[CandidateCapacityQuestion]:
s = sentence.strip()
m = _CAPACITY_Q_RE.match(s)
if m is None:
return []
verb = m.group("verb").lower()
if verb not in _CAPACITY_VERBS:
return []
@ -1712,6 +1777,19 @@ def extract_capacity_question_candidates(
]
def extract_capacity_question_candidates(
sentence: str,
) -> list[CandidateCapacityQuestion]:
s = sentence.strip()
m = _CAPACITY_Q_RE.match(s)
if m is not None:
return _capacity_question_from_match(m, sentence)
m2 = _CAPACITY_Q2_RE.match(s)
if m2 is not None:
return _capacity_question_from_match(m2, sentence)
return []
# --- Shape B: earnings rate ---
_EARNINGS_VERBS: Final[frozenset[str]] = frozenset({

View file

@ -20,10 +20,12 @@ from generate.math_candidate_parser import (
_EARNINGS_RE,
_EARNINGS_VERBS,
_to_seconds,
classify_sentence,
extract_capacity_candidates,
extract_capacity_question_candidates,
extract_earnings_candidates,
extract_earnings_question_candidates,
has_numeric_token,
)
_REPO = Path(__file__).resolve().parent.parent
@ -230,3 +232,58 @@ def test_gsm8k_post_s1_admission_honest() -> None:
)
assert len(admitted) >= 1, "gsm8k-0014 should admit"
assert "gsm8k-train-sample-v1-0014" in admitted
# ── ADR-0136.S.0 — Context-sentence classifier ───────────────────────
class TestContextClassifier:
@pytest.mark.parametrize("sentence", [
"Jason has a carriage house that he rents out.",
"Xavier plays football with his friends.",
"Marnie makes bead bracelets.",
"John decides to take up illustration.",
"Sandra wants to buy some sweets.",
])
def test_no_digit_sentences_classified_context(self, sentence: str) -> None:
assert not has_numeric_token(sentence)
assert classify_sentence(sentence) == "context"
@pytest.mark.parametrize("sentence", [
"Bob can shuck 10 oysters in 5 minutes.",
"During 15 minutes Xavier can score 2 goals on average.",
"Francine has five full boxes of crayons and 5 loose crayons.",
"Tina makes $18.00 an hour.",
])
def test_numeric_sentences_classified_numeric_state(self, sentence: str) -> None:
assert has_numeric_token(sentence)
assert classify_sentence(sentence) == "numeric_state"
def test_gsm8k_0018_context_sentence_skipped_admits(self) -> None:
"""Context gate removed: gsm8k-0018 admits with answer 16."""
q = (
"Xavier plays football with his friends. "
"During 15 minutes Xavier can score 2 goals on average. "
"How many goals on average is Xavier able to score, "
"when the match lasted for 2 hours?"
)
r = parse_and_solve(q)
assert r.answer == 16.0, f"expected 16.0 got {r.answer} ({r.refusal_reason})"
def test_inverted_capacity_pattern_matches(self) -> None:
"""Shape A2: 'During M <time-unit> <Actor> can <verb> N <unit>'."""
cands = extract_capacity_candidates(
"During 15 minutes Xavier can score 2 goals on average."
)
assert len(cands) == 1
assert cands[0].count == 2.0
assert cands[0].per_count == 15.0
assert cands[0].per_unit == "minutes"
def test_all_context_sentences_refused_when_no_numeric_follows(self) -> None:
"""A problem with only context sentences and no numeric state refuses."""
r = parse_and_solve(
"Jason has a carriage house. How many houses does Jason rent?"
)
assert r.answer is None
assert r.refusal_reason is not None