The "before/left" reading-rule lever. The microscope showed only the question-time
reading is cleanly achievable: "for N money = spend" is cue-precision-blocked (the
`for` cue is overloaded across train_sample -- `for $2`, `for 14 days`, `for 10 reps`
-- so a spend rule risks regressing train-0021/0003 and is the overfitting trap),
and the disguised-polarity cases already refuse via pooling. "left" is already handled
by loss verbs.
What ships: a question-scope guard. A question asking for a state *before* a stated
change ("How much did Lisa have before lunch?", gold 50) asks for a temporal point
the forward composers do not compute -- they derive the final/net state (50-20=30).
Until a question-time reader exists that is a refusal, never a guess at the wrong
point. target.asks_prior_state detects before/initially/originally/at first/to begin
with/to start with/at the start in the QUESTION CLAUSE only (the last `?`-sentence),
so body narrative does not trip it -- verified safe against train-0003 ("sells before
school starts"), 0010 ("had 20 initially, then lost 12"), 0028. `used to` is excluded
(the purpose infinitive "beads used to make a bracelet" is a false positive).
resolve_pooled refuses when asks_prior_state holds.
Result (sealed lane; chat/ does not import these -> serving 3/47/0 frozen):
- confuser wrong 2 -> 1 (only 0016 distractor-anchor-skip remains). temporal-scope
category cleared (0 wrong / 2 refused). pair-tells 1 -> 0: 0020 ("before lunch")
refuses while its minimal-pair twin 0021 ("left", gold 30) still solves the net --
the discrimination the corpus is built to measure.
- genuine positives still 7 solved, 0 wrong.
- train_sample 3/47/0 and practice 3/47/0 byte-identical.
- 205 derivation/target/pool tests + 40 architectural invariants green.
Tests:
- test_adr_0182_pool.py TestPriorStateQuestionGuard: detector true/false edges
(before-in-question vs before-in-body vs `used to make` false positive vs the
`left` twin) + resolve_pooled refuses the before-question while the left-twin
resolves forward to 30.
- test_adr_0163_f2_confusers.py: baseline wrong 2->1, pair_tells 1->0; new
test_temporal_scope_does_not_misfire + test_before_left_minimal_pair_discriminated.
Stacked on #476 (pooling); merge #476 first. 0016 anchor-skip is the next lever.
Pairs with ADR-0163-F2 graduation amendment (#478).
113 lines
5.2 KiB
Python
113 lines
5.2 KiB
Python
"""ADR-0176 MS-1 — question-targeting.
|
|
|
|
Turns the question sentence into a :class:`Target` — what the problem is asking
|
|
for. The target is the multi-step search's pruning signal and stopping criterion
|
|
(MS-3): a chain is a candidate answer only when it matches the target.
|
|
|
|
Lexeme-level only (ADR-0165 — no question-shape grammar regex, which 0165
|
|
forbids; the existing question parser does shape-matching but returns nothing on
|
|
these GSM8K questions). The three robust signals:
|
|
|
|
- **quantities** — numbers stated *in the question* (e.g. 0033's "when she is 25"),
|
|
via the same lexeme extractor the body uses. These participate in the derivation.
|
|
- **aggregation** — presence of an aggregation lexeme ("total", "altogether",
|
|
"combined", "in all") — a soft hint that the final step is a sum.
|
|
- **units** — the asked unit(s), resolved by **intersection with the body's known
|
|
units** (a precise lexeme match where the question names a body unit, e.g.
|
|
"jumping jacks"). Superordinate units the question may use instead (weight↔pounds,
|
|
money↔dollars) are NOT resolved here — that needs a curated superordinate-units
|
|
pack (a future irreducible-world-fact pack, like comparatives); until then the
|
|
unit signal is precise-but-incomplete, and the search falls back to completeness.
|
|
|
|
Refuse-preferring: an empty target unit is not an error — the search simply has a
|
|
weaker prune and leans on completeness, or refuses.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass
|
|
from typing import Final
|
|
|
|
from generate.derivation.extract import extract_quantities
|
|
from generate.derivation.model import Quantity
|
|
from generate.math_roundtrip import _tokens
|
|
|
|
# Aggregation-hint lexemes (soft signal that the final op is a sum). Single-word
|
|
# entries match by word token; multi-word entries match by substring.
|
|
_AGG_WORDS: Final[tuple[str, ...]] = ("total", "altogether", "combined", "sum")
|
|
_AGG_PHRASES: Final[tuple[str, ...]] = ("in all", "in total")
|
|
|
|
# ADR-0182 — prior-state question markers. A question that asks for a state *before*
|
|
# a stated change ("how much did Lisa have **before** lunch?", "...**initially**?")
|
|
# is asking for a temporal point the forward reader does not compute — it derives
|
|
# the final/net state. Detected on the **question clause only**: "before"/"initially"
|
|
# in the problem *body* is narrative ("sells before school starts", "had 20
|
|
# initially, then lost 12") and must not trip this. ``used to`` is excluded — the
|
|
# purpose infinitive ("beads used to make a bracelet") is a false positive, not a
|
|
# prior-state cue. Lexeme-level (ADR-0165): it names markers, it does not parse the
|
|
# question's grammar.
|
|
_PRIOR_STATE_RE: Final[re.Pattern[str]] = re.compile(
|
|
r"\b(before|initially|originally|at first|to begin with|to start with|at the start)\b",
|
|
re.IGNORECASE,
|
|
)
|
|
_SENTENCE_SPLIT: Final[re.Pattern[str]] = re.compile(r"(?<=[.?!])\s+")
|
|
|
|
|
|
def _question_clause(problem_text: str) -> str:
|
|
"""The interrogative clause: the last ``?``-terminated sentence, else the last
|
|
sentence. Deterministic."""
|
|
sentences = [s for s in _SENTENCE_SPLIT.split(problem_text.strip()) if s.strip()]
|
|
if not sentences:
|
|
return problem_text
|
|
questions = [s for s in sentences if s.rstrip().endswith("?")]
|
|
return questions[-1] if questions else sentences[-1]
|
|
|
|
|
|
def asks_prior_state(problem_text: str) -> bool:
|
|
"""True iff the *question* asks for a state before a stated change (ADR-0182).
|
|
|
|
Question-clause-scoped so body narrative ("before school starts") does not trip
|
|
it. The forward reader computes the final state, so a prior-state question is a
|
|
refusal until a question-time reader exists — never a guess at the wrong point."""
|
|
return bool(_PRIOR_STATE_RE.search(_question_clause(problem_text)))
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Target:
|
|
"""What the question asks for (ADR-0176 MS-1)."""
|
|
|
|
quantities: tuple[Quantity, ...] # numbers stated in the question
|
|
aggregation: str | None # aggregation-hint lexeme/phrase, or None
|
|
units: tuple[str, ...] # asked units = body units named in the question
|
|
|
|
|
|
def extract_target(question_text: str, *, known_units: tuple[str, ...] = ()) -> Target:
|
|
"""Build the :class:`Target` for ``question_text``.
|
|
|
|
``known_units`` are the units extracted from the problem body; the asked
|
|
unit(s) are the subset of them that appear as tokens in the question. Pass
|
|
``()`` (default) when body units are unavailable -> ``units`` is empty and the
|
|
search leans on completeness. Deterministic.
|
|
"""
|
|
quantities: tuple[Quantity, ...] = extract_quantities(question_text)
|
|
|
|
lowered = question_text.lower()
|
|
tokens = _tokens(question_text)
|
|
aggregation: str | None = None
|
|
for word in _AGG_WORDS:
|
|
if word in tokens:
|
|
aggregation = word
|
|
break
|
|
if aggregation is None:
|
|
for phrase in _AGG_PHRASES:
|
|
if phrase in lowered:
|
|
aggregation = phrase
|
|
break
|
|
|
|
# Asked units = body units named in the question (precise lexeme match).
|
|
units = tuple(
|
|
u for u in dict.fromkeys(known_units) if u and u.lower() in tokens
|
|
)
|
|
|
|
return Target(quantities=quantities, aggregation=aggregation, units=units)
|