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:
Shay 2026-05-28 16:27:17 -07:00
parent 2400bc58e2
commit 5a9454af20
6 changed files with 217 additions and 5 deletions

View file

@ -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",

View file

@ -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,
)

View file

@ -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:

View file

@ -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}

View file

@ -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

View 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