diff --git a/generate/derivation/__init__.py b/generate/derivation/__init__.py index 84302248..6953cfc9 100644 --- a/generate/derivation/__init__.py +++ b/generate/derivation/__init__.py @@ -14,6 +14,7 @@ from generate.derivation.comparatives import ( from generate.derivation.extract import extract_quantities from generate.derivation.model import GroundedDerivation, Quantity, Step, VALID_OPS from generate.derivation.search import MULTIPLICATIVE_CUES, search_multiplicative +from generate.derivation.target import Target, extract_target from generate.derivation.verify import ( Resolution, SelfVerification, @@ -29,9 +30,11 @@ __all__ = [ "Resolution", "SelfVerification", "Step", + "Target", "VALID_OPS", "extract_comparative_scalars", "extract_quantities", + "extract_target", "search_multiplicative", "select_self_verified", "self_verifies", diff --git a/generate/derivation/target.py b/generate/derivation/target.py new file mode 100644 index 00000000..8f9c474a --- /dev/null +++ b/generate/derivation/target.py @@ -0,0 +1,78 @@ +"""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 + +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") + + +@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) diff --git a/tests/test_adr_0176_ms1_question_target.py b/tests/test_adr_0176_ms1_question_target.py new file mode 100644 index 00000000..c2793d0c --- /dev/null +++ b/tests/test_adr_0176_ms1_question_target.py @@ -0,0 +1,81 @@ +"""ADR-0176 MS-1 — question-targeting. + +The Target is the multi-step search's pruning signal + stopping criterion. MS-1 +extracts it from lexeme-level signals only (ADR-0165): question-stated quantities, +an aggregation hint, and asked units resolved by intersection with the body's +known units. Refuse-preferring: no signal -> empty field, never a guess. +""" + +from __future__ import annotations + +from generate.derivation import Target, extract_target + + +class TestQuestionQuantities: + def test_extracts_quantity_stated_in_question(self) -> None: + # 0033: "when she is 25 years old" -> 25 participates in the derivation + t = extract_target("How old will the father be when she is 25 years old?") + assert isinstance(t, Target) + assert [(q.value, q.unit) for q in t.quantities] == [(25.0, "years")] + + def test_no_question_quantity(self) -> None: + t = extract_target("How many jumping jacks did Brooke do?") + assert t.quantities == () + + +class TestAggregationHint: + def test_total(self) -> None: + assert extract_target("How much total weight does he move?").aggregation == "total" + + def test_altogether_and_combined(self) -> None: + assert extract_target("How many altogether?").aggregation == "altogether" + assert extract_target("What is the combined cost?").aggregation == "combined" + + def test_in_all_phrase(self) -> None: + assert extract_target("How many does he have in all?").aggregation == "in all" + + def test_no_aggregation(self) -> None: + assert extract_target("How many jumping jacks did Brooke do?").aggregation is None + + +class TestAskedUnits: + def test_unit_named_in_question_intersects_body(self) -> None: + # body has "jumping"; the question names it -> precise target unit + t = extract_target( + "How many jumping jacks did Brooke do?", known_units=("jumping", "reps") + ) + assert t.units == ("jumping",) + + def test_superordinate_unit_not_faked(self) -> None: + # question says "weight"; body unit is "pounds" -> no exact match -> empty + # (superordinate resolution is a deferred pack, not faked here) + t = extract_target( + "How much total weight does he move?", known_units=("pounds", "reps", "sets") + ) + assert t.units == () + + def test_no_known_units_yields_empty(self) -> None: + assert extract_target("How much money?").units == () + + def test_units_deduped_and_ordered(self) -> None: + t = extract_target( + "how many apples and apples?", known_units=("apples", "apples", "oranges") + ) + assert t.units == ("apples",) + + +class TestDeterminism: + def test_deterministic(self) -> None: + q = "How much total weight when she is 25 years old?" + assert extract_target(q, known_units=("pounds",)) == extract_target( + q, known_units=("pounds",) + ) + + def test_target_is_frozen(self) -> None: + import dataclasses + + import pytest + + t = extract_target("How many?") + with pytest.raises(dataclasses.FrozenInstanceError): + t.aggregation = "total" # type: ignore[misc]