Merge pull request #439 from AssetOverflow/feat/adr-0176-ms1-question-target
ADR-0176 MS-1: question-targeting (stacked on #438)
This commit is contained in:
commit
2400bc58e2
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.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",
|
||||
|
|
|
|||
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