diff --git a/generate/math_candidate_graph.py b/generate/math_candidate_graph.py index 0e249972..4f025c86 100644 --- a/generate/math_candidate_graph.py +++ b/generate/math_candidate_graph.py @@ -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, diff --git a/generate/math_candidate_parser.py b/generate/math_candidate_parser.py index 177929c5..c0680b94 100644 --- a/generate/math_candidate_parser.py +++ b/generate/math_candidate_parser.py @@ -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): " can N in M ." _CAPACITY_RE: Final[re.Pattern[str]] = re.compile( rf"^(?P{_ENTITY})\s+can\s+" rf"(?P{_CAPACITY_VERB_PATTERN})\s+" @@ -1633,16 +1666,27 @@ _CAPACITY_RE: Final[re.Pattern[str]] = re.compile( rf"(?P\w+)\s+in\s+" rf"(?P\d+(?:\.\d+)?)\s+" rf"(?P{_TIME_UNIT_SET})" + r"(?:\s+on\s+average)?" + r"\s*\.?\s*$", + flags=re.IGNORECASE, +) + +# Shape A2 (inverted): "During M can N [on average]." +_CAPACITY_INVERTED_RE: Final[re.Pattern[str]] = re.compile( + rf"^[Dd]uring\s+" + rf"(?P\d+(?:\.\d+)?)\s+" + rf"(?P{_TIME_UNIT_SET})\s+" + rf"(?P{_ENTITY})\s+can\s+" + rf"(?P{_CAPACITY_VERB_PATTERN})\s+" + rf"(?P\d+(?:\.\d+)?)\s+" + rf"(?P\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 can in T ?" _CAPACITY_Q_RE: Final[re.Pattern[str]] = re.compile( r"^How\s+many\s+(?P\w+)\s+can\s+" rf"(?P{_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 [on average] is able to , +# when the [match/game/session] lasted for T ?" +_CAPACITY_Q2_RE: Final[re.Pattern[str]] = re.compile( + r"^How\s+many\s+(?P\w+)(?:\s+on\s+average)?\s+is\s+" + rf"(?P{_ENTITY}|{_PRONOUN_SET})\s+able\s+to\s+" + rf"(?P{_CAPACITY_VERB_PATTERN}),?\s+" + r"when\s+the\s+\w+\s+lasted\s+for\s+" + rf"(?P\d+(?:\.\d+)?)\s+" + rf"(?P{_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({ diff --git a/tests/test_adr_0136_S1_rate_events.py b/tests/test_adr_0136_S1_rate_events.py index 8e04e4d4..e1e04f7c 100644 --- a/tests/test_adr_0136_S1_rate_events.py +++ b/tests/test_adr_0136_S1_rate_events.py @@ -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 can N '.""" + 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