feat(adr-0176-ms1): question-targeting
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).
This commit is contained in:
parent
0aaec09059
commit
4ecc17c5ec
3 changed files with 162 additions and 0 deletions
|
|
@ -14,6 +14,7 @@ from generate.derivation.comparatives import (
|
||||||
from generate.derivation.extract import extract_quantities
|
from generate.derivation.extract import extract_quantities
|
||||||
from generate.derivation.model import GroundedDerivation, Quantity, Step, VALID_OPS
|
from generate.derivation.model import GroundedDerivation, Quantity, Step, VALID_OPS
|
||||||
from generate.derivation.search import MULTIPLICATIVE_CUES, search_multiplicative
|
from generate.derivation.search import MULTIPLICATIVE_CUES, search_multiplicative
|
||||||
|
from generate.derivation.target import Target, extract_target
|
||||||
from generate.derivation.verify import (
|
from generate.derivation.verify import (
|
||||||
Resolution,
|
Resolution,
|
||||||
SelfVerification,
|
SelfVerification,
|
||||||
|
|
@ -29,9 +30,11 @@ __all__ = [
|
||||||
"Resolution",
|
"Resolution",
|
||||||
"SelfVerification",
|
"SelfVerification",
|
||||||
"Step",
|
"Step",
|
||||||
|
"Target",
|
||||||
"VALID_OPS",
|
"VALID_OPS",
|
||||||
"extract_comparative_scalars",
|
"extract_comparative_scalars",
|
||||||
"extract_quantities",
|
"extract_quantities",
|
||||||
|
"extract_target",
|
||||||
"search_multiplicative",
|
"search_multiplicative",
|
||||||
"select_self_verified",
|
"select_self_verified",
|
||||||
"self_verifies",
|
"self_verifies",
|
||||||
|
|
|
||||||
78
generate/derivation/target.py
Normal file
78
generate/derivation/target.py
Normal file
|
|
@ -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)
|
||||||
81
tests/test_adr_0176_ms1_question_target.py
Normal file
81
tests/test_adr_0176_ms1_question_target.py
Normal file
|
|
@ -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]
|
||||||
Loading…
Reference in a new issue