core/generate/derivation/target.py
Shay 4ecc17c5ec 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).
2026-05-28 16:21:40 -07:00

78 lines
3.4 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
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)