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 (
|
from generate.derivation.comparatives import (
|
||||||
ComparativeScalar,
|
ComparativeScalar,
|
||||||
|
comparative_step,
|
||||||
extract_comparative_scalars,
|
extract_comparative_scalars,
|
||||||
)
|
)
|
||||||
from generate.derivation.extract import extract_quantities
|
from generate.derivation.extract import extract_quantities
|
||||||
|
|
@ -32,6 +33,7 @@ __all__ = [
|
||||||
"Step",
|
"Step",
|
||||||
"Target",
|
"Target",
|
||||||
"VALID_OPS",
|
"VALID_OPS",
|
||||||
|
"comparative_step",
|
||||||
"extract_comparative_scalars",
|
"extract_comparative_scalars",
|
||||||
"extract_quantities",
|
"extract_quantities",
|
||||||
"extract_target",
|
"extract_target",
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ from functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
|
from generate.derivation.model import Quantity, Step
|
||||||
from generate.math_roundtrip import WORD_NUMBERS
|
from generate.math_roundtrip import WORD_NUMBERS
|
||||||
|
|
||||||
_PACK_DIR: Final[Path] = (
|
_PACK_DIR: Final[Path] = (
|
||||||
|
|
@ -40,6 +41,10 @@ class ComparativeScalar:
|
||||||
scalar: float
|
scalar: float
|
||||||
source_span: str
|
source_span: str
|
||||||
cue: 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)
|
@lru_cache(maxsize=1)
|
||||||
|
|
@ -88,7 +93,10 @@ def extract_comparative_scalars(text: str) -> tuple[ComparativeScalar, ...]:
|
||||||
if n is None or n <= 0:
|
if n is None or n <= 0:
|
||||||
continue
|
continue
|
||||||
found.append(
|
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).
|
# 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))
|
found.sort(key=lambda pair: (pair[0], pair[1].cue))
|
||||||
return tuple(cs for _, cs in found)
|
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
|
op: str
|
||||||
operand: Quantity
|
operand: Quantity
|
||||||
cue: str
|
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:
|
def __post_init__(self) -> None:
|
||||||
if self.op not in VALID_OPS:
|
if self.op not in VALID_OPS:
|
||||||
|
|
|
||||||
|
|
@ -55,8 +55,11 @@ def self_verifies(derivation: GroundedDerivation, problem_text: str) -> SelfVeri
|
||||||
tokens = _tokens(problem_text)
|
tokens = _tokens(problem_text)
|
||||||
reasons: list[str] = []
|
reasons: list[str] = []
|
||||||
|
|
||||||
# 1. operand grounding — every value must be sourced from the text.
|
# 1. operand grounding — every TEXT operand value must be sourced from the
|
||||||
operands = [derivation.start, *(s.operand for s in derivation.steps)]
|
# 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:
|
for q in operands:
|
||||||
if not _value_grounds(q.source_token, tokens):
|
if not _value_grounds(q.source_token, tokens):
|
||||||
reasons.append(f"operand {q.source_token!r} not grounded in text")
|
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(
|
def select_self_verified(
|
||||||
derivations: list[GroundedDerivation],
|
derivations: list[GroundedDerivation],
|
||||||
problem_text: str,
|
problem_text: str,
|
||||||
|
*,
|
||||||
|
target_units: tuple[str, ...] = (),
|
||||||
) -> Resolution | None:
|
) -> Resolution | None:
|
||||||
"""Among the self-verifying derivations, return the unique answer or refuse.
|
"""Among the self-verifying derivations, return the unique answer or refuse.
|
||||||
|
|
||||||
Refuse-preferring: ``None`` when zero self-verify (no grounded derivation) or
|
Refuse-preferring: ``None`` when zero self-verify (no grounded derivation) or
|
||||||
when the self-verifying ones disagree (the multi-branch disagreement rule).
|
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]
|
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:
|
if not verified:
|
||||||
return None
|
return None
|
||||||
distinct = {round(d.answer, 9) for d in verified}
|
distinct = {round(d.answer, 9) for d in verified}
|
||||||
|
|
|
||||||
|
|
@ -32,11 +32,11 @@ class TestExtractComparativeScalars:
|
||||||
|
|
||||||
def test_word_number_times(self) -> None:
|
def test_word_number_times(self) -> None:
|
||||||
cs = extract_comparative_scalars("Brooke does three times as many jumping jacks.")
|
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:
|
def test_digit_times(self) -> None:
|
||||||
cs = extract_comparative_scalars("The price is 5 times the cost.")
|
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:
|
def test_triple_and_quarter(self) -> None:
|
||||||
assert extract_comparative_scalars("output tripled")[0].scalar == 3.0
|
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