From 4ecc17c5ec6cd4183ae6cd54ce1208b9a79716b1 Mon Sep 17 00:00:00 2001 From: Shay Date: Thu, 28 May 2026 16:18:01 -0700 Subject: [PATCH] feat(adr-0176-ms1): question-targeting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MS-1 of multi-step composition. Turns the question into a Target = what the problem asks for, the search's pruning signal + stopping criterion (MS-3). Lexeme-level only (ADR-0165): the existing question parser returns nothing on these GSM8K questions, and 0165 forbids new question-shape grammar regex. Three robust signals: - quantities: numbers stated IN the question (0033's 'when she is 25') via the body's lexeme extractor — they participate in the derivation. - aggregation: presence of an aggregation lexeme (total/altogether/combined/sum/ 'in all'/'in total') — soft hint the final step is a sum. - units: asked units resolved by INTERSECTION with the body's known units (precise lexeme match, e.g. 'jumping'). Superordinates (weight<->pounds) are NOT faked — deferred to a curated superordinate-units pack; until then the unit signal is precise-but-incomplete and the search leans on completeness. Refuse-preferring: empty target field is not an error, just a weaker prune. generate/derivation/target.py: Target + extract_target(question, known_units=()). 12 MS-1 tests (question-quantity, aggregation, body-unit intersection, superordinate-not-faked, determinism, frozen). Verified: derivation suite 57/57; ruff clean; smoke 67. Not wired into serving (Target ready for MS-2/MS-3). --- generate/derivation/__init__.py | 3 + generate/derivation/target.py | 78 +++++++++++++++++++++ tests/test_adr_0176_ms1_question_target.py | 81 ++++++++++++++++++++++ 3 files changed, 162 insertions(+) create mode 100644 generate/derivation/target.py create mode 100644 tests/test_adr_0176_ms1_question_target.py 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]