diff --git a/generate/derivation/__init__.py b/generate/derivation/__init__.py index 6953cfc9..d9f023fd 100644 --- a/generate/derivation/__init__.py +++ b/generate/derivation/__init__.py @@ -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", diff --git a/generate/derivation/comparatives.py b/generate/derivation/comparatives.py index cb29c571..e8d4be23 100644 --- a/generate/derivation/comparatives.py +++ b/generate/derivation/comparatives.py @@ -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 " 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 `` 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, + ) diff --git a/generate/derivation/model.py b/generate/derivation/model.py index 121de862..1c711b43 100644 --- a/generate/derivation/model.py +++ b/generate/derivation/model.py @@ -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: diff --git a/generate/derivation/verify.py b/generate/derivation/verify.py index 33919243..7696e21c 100644 --- a/generate/derivation/verify.py +++ b/generate/derivation/verify.py @@ -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} diff --git a/tests/test_adr_0176_comparatives_pack.py b/tests/test_adr_0176_comparatives_pack.py index 537966e2..5f14503f 100644 --- a/tests/test_adr_0176_comparatives_pack.py +++ b/tests/test_adr_0176_comparatives_pack.py @@ -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 diff --git a/tests/test_adr_0176_ms2_chain.py b/tests/test_adr_0176_ms2_chain.py new file mode 100644 index 00000000..f9c1371a --- /dev/null +++ b/tests/test_adr_0176_ms2_chain.py @@ -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