feat(adr-0176-ms2): multi-step chain model — text + comparative operands
MS-2 of multi-step composition. Extends the derivation model so a chain mixes text-quantity operands and COMPARATIVE-scalar operands (twice->x2, 'N times'->xN, half->x0.5), self-verifying the whole chain with completeness over body+question and question-target matching. - model.py: Step gains comparative flag. - comparatives.py: ComparativeScalar gains number_token (the '<N> times' number, so completeness counts the consumed body quantity); comparative_step(cs) bridges a scalar into a Step (operand grounded by cue, not a text value token). - verify.py: self_verifies exempts comparative operands from value-grounding (clause 1) — they are cue-grounded (clause 2); completeness (Counter) counts a digit comparative's number_token as consuming the body quantity. Adds target_units to select_self_verified: a chain whose answer_unit isn't the asked unit is dropped (question-target match; empty target_units imposes no constraint). Proves the multi-step shapes from the gold structures: 0024 (text sum then 'three times' scale -> 438), 0033 father-chain (digit-comparative '7 times' + fixed 'half' + text add -> 47). Full 0033 DAG (quantity reuse + the question's 25) deferred. 25 MS-2 tests; full derivation surface 69/69 (3a/3b/comparatives/ms1/ms2); ruff clean; smoke 67. Not wired into serving (model ready for MS-3 target-guided search).
This commit is contained in:
parent
2400bc58e2
commit
5a9454af20
6 changed files with 217 additions and 5 deletions
|
|
@ -9,6 +9,7 @@ from __future__ import annotations
|
|||
|
||||
from generate.derivation.comparatives import (
|
||||
ComparativeScalar,
|
||||
comparative_step,
|
||||
extract_comparative_scalars,
|
||||
)
|
||||
from generate.derivation.extract import extract_quantities
|
||||
|
|
@ -32,6 +33,7 @@ __all__ = [
|
|||
"Step",
|
||||
"Target",
|
||||
"VALID_OPS",
|
||||
"comparative_step",
|
||||
"extract_comparative_scalars",
|
||||
"extract_quantities",
|
||||
"extract_target",
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from functools import lru_cache
|
|||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
from generate.derivation.model import Quantity, Step
|
||||
from generate.math_roundtrip import WORD_NUMBERS
|
||||
|
||||
_PACK_DIR: Final[Path] = (
|
||||
|
|
@ -40,6 +41,10 @@ class ComparativeScalar:
|
|||
scalar: float
|
||||
source_span: str
|
||||
cue: str
|
||||
# The number token of a "<N> times" comparative (e.g. "7" / "three"), or None
|
||||
# for a fixed lexeme (twice/half). Used by completeness so a digit comparative
|
||||
# ("7 times") is counted as consuming the body quantity "7".
|
||||
number_token: str | None = None
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
|
|
@ -88,7 +93,10 @@ def extract_comparative_scalars(text: str) -> tuple[ComparativeScalar, ...]:
|
|||
if n is None or n <= 0:
|
||||
continue
|
||||
found.append(
|
||||
(m.start(), ComparativeScalar("multiply", n, m.group(0), "times"))
|
||||
(
|
||||
m.start(),
|
||||
ComparativeScalar("multiply", n, m.group(0), "times", number_token=m.group(1)),
|
||||
)
|
||||
)
|
||||
|
||||
# Fixed comparative lexemes (word-boundary, case-insensitive).
|
||||
|
|
@ -100,3 +108,20 @@ def extract_comparative_scalars(text: str) -> tuple[ComparativeScalar, ...]:
|
|||
|
||||
found.sort(key=lambda pair: (pair[0], pair[1].cue))
|
||||
return tuple(cs for _, cs in found)
|
||||
|
||||
|
||||
def comparative_step(cs: ComparativeScalar) -> Step:
|
||||
"""Bridge a comparative scalar into a derivation :class:`Step` (ADR-0176 MS-2).
|
||||
|
||||
The step is flagged ``comparative=True``: its operand value is the pack-supplied
|
||||
scalar (grounded by the comparative cue, not by a text value token). Its
|
||||
``source_token`` is the ``<N> times`` number token when present (so completeness
|
||||
counts the consumed body quantity), else the comparative lexeme.
|
||||
"""
|
||||
source = cs.number_token if cs.number_token is not None else cs.cue
|
||||
return Step(
|
||||
op=cs.op,
|
||||
operand=Quantity(value=cs.scalar, unit="", source_token=source),
|
||||
cue=cs.cue,
|
||||
comparative=True,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -36,6 +36,10 @@ class Step:
|
|||
op: str
|
||||
operand: Quantity
|
||||
cue: str
|
||||
# ADR-0176 MS-2: when True the operand is a comparative scalar (twice -> x2,
|
||||
# 'N times' -> xN). It is grounded by ``cue`` (the comparative lexeme), not by a
|
||||
# text value token, and it does not count as a body quantity for completeness.
|
||||
comparative: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.op not in VALID_OPS:
|
||||
|
|
|
|||
|
|
@ -55,8 +55,11 @@ def self_verifies(derivation: GroundedDerivation, problem_text: str) -> SelfVeri
|
|||
tokens = _tokens(problem_text)
|
||||
reasons: list[str] = []
|
||||
|
||||
# 1. operand grounding — every value must be sourced from the text.
|
||||
operands = [derivation.start, *(s.operand for s in derivation.steps)]
|
||||
# 1. operand grounding — every TEXT operand value must be sourced from the
|
||||
# text. Comparative operands (ADR-0176 MS-2: twice -> x2, 'N times' -> xN)
|
||||
# are grounded by their cue (clause 2), not by a text value token, so they
|
||||
# are exempt here — their pack-supplied scalar is not a number in the text.
|
||||
operands = [derivation.start, *(s.operand for s in derivation.steps if not s.comparative)]
|
||||
for q in operands:
|
||||
if not _value_grounds(q.source_token, tokens):
|
||||
reasons.append(f"operand {q.source_token!r} not grounded in text")
|
||||
|
|
@ -99,13 +102,23 @@ def self_verifies(derivation: GroundedDerivation, problem_text: str) -> SelfVeri
|
|||
def select_self_verified(
|
||||
derivations: list[GroundedDerivation],
|
||||
problem_text: str,
|
||||
*,
|
||||
target_units: tuple[str, ...] = (),
|
||||
) -> Resolution | None:
|
||||
"""Among the self-verifying derivations, return the unique answer or refuse.
|
||||
|
||||
Refuse-preferring: ``None`` when zero self-verify (no grounded derivation) or
|
||||
when the self-verifying ones disagree (the multi-branch disagreement rule).
|
||||
|
||||
ADR-0176 MS-2 question-targeting: when ``target_units`` is non-empty (the unit
|
||||
the question asks for), derivations whose ``answer_unit`` is not among them are
|
||||
dropped — a chain that computes the wrong kind of quantity answered a different
|
||||
question. Empty ``target_units`` imposes no constraint (the unit signal may be
|
||||
unavailable, e.g. a superordinate the units pack doesn't yet cover).
|
||||
"""
|
||||
verified = [d for d in derivations if self_verifies(d, problem_text).verified]
|
||||
if target_units:
|
||||
verified = [d for d in verified if d.answer_unit in target_units]
|
||||
if not verified:
|
||||
return None
|
||||
distinct = {round(d.answer, 9) for d in verified}
|
||||
|
|
|
|||
|
|
@ -32,11 +32,11 @@ class TestExtractComparativeScalars:
|
|||
|
||||
def test_word_number_times(self) -> None:
|
||||
cs = extract_comparative_scalars("Brooke does three times as many jumping jacks.")
|
||||
assert cs == (ComparativeScalar("multiply", 3.0, "three times", "times"),)
|
||||
assert cs == (ComparativeScalar("multiply", 3.0, "three times", "times", number_token="three"),)
|
||||
|
||||
def test_digit_times(self) -> None:
|
||||
cs = extract_comparative_scalars("The price is 5 times the cost.")
|
||||
assert cs == (ComparativeScalar("multiply", 5.0, "5 times", "times"),)
|
||||
assert cs == (ComparativeScalar("multiply", 5.0, "5 times", "times", number_token="5"),)
|
||||
|
||||
def test_triple_and_quarter(self) -> None:
|
||||
assert extract_comparative_scalars("output tripled")[0].scalar == 3.0
|
||||
|
|
|
|||
168
tests/test_adr_0176_ms2_chain.py
Normal file
168
tests/test_adr_0176_ms2_chain.py
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
"""ADR-0176 MS-2 — multi-step chain model: text + comparative operands.
|
||||
|
||||
Extends the derivation model so a chain can mix text-quantity operands and
|
||||
**comparative-scalar** operands (twice -> x2, 'N times' -> xN, half -> x0.5),
|
||||
self-verifying the whole chain with completeness over body+question and
|
||||
question-target matching.
|
||||
|
||||
Covers the multi-step shapes the gold structures show:
|
||||
- 0021: all-text multiplicative chain.
|
||||
- 0024: text sum, then a comparative scale ('three times').
|
||||
- 0033 father-chain: digit-comparative ('7 times') + fixed-comparative ('half')
|
||||
+ text add — the mixed chain mechanics (full 0033 DAG with quantity reuse is
|
||||
deferred; here we verify the chain composes and is complete over its own body).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from generate.derivation import (
|
||||
GroundedDerivation,
|
||||
Quantity,
|
||||
Step,
|
||||
comparative_step,
|
||||
extract_comparative_scalars,
|
||||
select_self_verified,
|
||||
self_verifies,
|
||||
)
|
||||
|
||||
|
||||
def _q(v: float, unit: str, tok: str) -> Quantity:
|
||||
return Quantity(value=v, unit=unit, source_token=tok)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# comparative_step bridge
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestComparativeStep:
|
||||
def test_word_times_bridges_to_multiply_step(self) -> None:
|
||||
(cs,) = extract_comparative_scalars("three times as many")
|
||||
step = comparative_step(cs)
|
||||
assert step.op == "multiply" and step.comparative is True
|
||||
assert step.operand.value == 3.0
|
||||
assert step.cue == "times"
|
||||
assert step.operand.source_token == "three" # number_token, for completeness
|
||||
|
||||
def test_digit_times_carries_number_token(self) -> None:
|
||||
(cs,) = extract_comparative_scalars("7 times her age")
|
||||
step = comparative_step(cs)
|
||||
assert step.operand.value == 7.0
|
||||
assert step.operand.source_token == "7" # so completeness counts body "7"
|
||||
|
||||
def test_fixed_lexeme_has_no_number_token(self) -> None:
|
||||
(cs,) = extract_comparative_scalars("half of it")
|
||||
step = comparative_step(cs)
|
||||
assert step.operand.value == 0.5
|
||||
assert step.operand.source_token == "half"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# mixed text + comparative chains self-verify
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMixedChains:
|
||||
def test_0024_sum_then_comparative_scale(self) -> None:
|
||||
# "Sidney does 20, 36, 40, 50 jumping jacks. Brooke does three times as many."
|
||||
text = (
|
||||
"Sidney does 20 jacks on Monday, 36 on Tuesday, 40 on Wednesday, "
|
||||
"and 50 on Thursday. Brooke does three times as many jacks as Sidney."
|
||||
)
|
||||
(cs,) = extract_comparative_scalars(text)
|
||||
chain = GroundedDerivation(
|
||||
start=_q(20, "jacks", "20"),
|
||||
steps=(
|
||||
Step("add", _q(36, "jacks", "36"), cue="and"),
|
||||
Step("add", _q(40, "jacks", "40"), cue="and"),
|
||||
Step("add", _q(50, "jacks", "50"), cue="and"),
|
||||
comparative_step(cs), # x3
|
||||
),
|
||||
)
|
||||
assert chain.answer == 438.0
|
||||
sv = self_verifies(chain, text)
|
||||
assert sv.verified is True, sv.reasons
|
||||
|
||||
def test_0033_father_chain_digit_and_fixed_comparatives(self) -> None:
|
||||
# father's current age: 12 x 7 (= grandfather) / 2 (mother=half) + 5
|
||||
# (the body alone; the full 0033 also uses the question's 25 -> DAG, deferred)
|
||||
body = "Rachel is 12. Her grandfather is 7 times her age. Her mother is half that. Her father is 5 years older."
|
||||
scalars = {c.cue: c for c in extract_comparative_scalars(body)}
|
||||
chain = GroundedDerivation(
|
||||
start=_q(12, "years", "12"),
|
||||
steps=(
|
||||
comparative_step(scalars["times"]), # x7 (digit comparative)
|
||||
comparative_step(scalars["half"]), # x0.5
|
||||
Step("add", _q(5, "years", "5"), cue="older"),
|
||||
),
|
||||
)
|
||||
assert chain.answer == 47.0
|
||||
sv = self_verifies(chain, body)
|
||||
assert sv.verified is True, sv.reasons
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# completeness over body (digit comparative consumes its body quantity)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCompletenessWithComparatives:
|
||||
def test_digit_comparative_consumes_body_quantity(self) -> None:
|
||||
# "7 times" consumes body "7" -> chain using 12 + (x7) is complete over {12,7}
|
||||
body = "Rachel is 12. Her grandfather is 7 times her age."
|
||||
(cs,) = extract_comparative_scalars(body)
|
||||
chain = GroundedDerivation(
|
||||
start=_q(12, "years", "12"),
|
||||
steps=(comparative_step(cs),),
|
||||
)
|
||||
assert self_verifies(chain, body).verified is True
|
||||
|
||||
def test_ignoring_a_body_quantity_is_incomplete(self) -> None:
|
||||
body = "Rachel is 12 years old. Her grandfather is 7 times her age. She has 30 coins."
|
||||
(cs,) = extract_comparative_scalars(body)
|
||||
chain = GroundedDerivation( # ignores the stated "30 coins"
|
||||
start=_q(12, "years", "12"),
|
||||
steps=(comparative_step(cs),),
|
||||
)
|
||||
sv = self_verifies(chain, body)
|
||||
assert sv.verified is False
|
||||
assert any("incomplete" in r for r in sv.reasons)
|
||||
|
||||
def test_comparative_cue_absent_refuses(self) -> None:
|
||||
# a comparative step whose cue is not in the text -> ungrounded
|
||||
chain = GroundedDerivation(
|
||||
start=_q(12, "years", "12"),
|
||||
steps=(Step("multiply", _q(3.0, "", "three"), cue="times", comparative=True),),
|
||||
)
|
||||
sv = self_verifies(chain, "Rachel is 12.") # no "times"
|
||||
assert sv.verified is False
|
||||
assert any("cue" in r for r in sv.reasons)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# question-target matching (MS-2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTargetMatch:
|
||||
def test_target_unit_match_resolves(self) -> None:
|
||||
text = "He has 6 boxes for 50 each."
|
||||
chain = GroundedDerivation(
|
||||
start=_q(6, "boxes", "6"),
|
||||
steps=(Step("multiply", _q(50, "each", "50"), cue="for"),),
|
||||
)
|
||||
res = select_self_verified([chain], text, target_units=("boxes",))
|
||||
assert res is not None and res.answer == 300.0
|
||||
|
||||
def test_target_unit_mismatch_refuses(self) -> None:
|
||||
# chain answers in "boxes" but the question asked for "reps" -> refuse
|
||||
text = "He has 6 boxes for 50 each."
|
||||
chain = GroundedDerivation(
|
||||
start=_q(6, "boxes", "6"),
|
||||
steps=(Step("multiply", _q(50, "each", "50"), cue="for"),),
|
||||
)
|
||||
assert select_self_verified([chain], text, target_units=("reps",)) is None
|
||||
|
||||
def test_empty_target_imposes_no_constraint(self) -> None:
|
||||
text = "He has 6 boxes for 50 each."
|
||||
chain = GroundedDerivation(
|
||||
start=_q(6, "boxes", "6"),
|
||||
steps=(Step("multiply", _q(50, "each", "50"), cue="for"),),
|
||||
)
|
||||
assert select_self_verified([chain], text, target_units=()) is not None
|
||||
Loading…
Reference in a new issue