diff --git a/generate/derivation/__init__.py b/generate/derivation/__init__.py index 04ed1794..4bc4b77a 100644 --- a/generate/derivation/__init__.py +++ b/generate/derivation/__init__.py @@ -12,6 +12,7 @@ from generate.derivation.clauses import ( clause_local_results, segment_clauses, ) +from generate.derivation.compose import compose_sequential from generate.derivation.comparatives import ( ComparativeScalar, comparative_step, @@ -41,6 +42,7 @@ __all__ = [ "Target", "VALID_OPS", "clause_local_results", + "compose_sequential", "comparative_step", "extract_comparative_scalars", "extract_quantities", diff --git a/generate/derivation/compose.py b/generate/derivation/compose.py new file mode 100644 index 00000000..ed089855 --- /dev/null +++ b/generate/derivation/compose.py @@ -0,0 +1,84 @@ +"""ADR-0178 GB-2 — sequential composition: list-structure + comparative-scale. + +GB-1 read the problem into clauses; GB-2 begins combining structure the blunt MS-3 +shapes could not reach. The first increment adds the **same-unit-list → sum** shape +(like quantities joined by an additive cue sum) and **always applies trailing +comparative scalars** (×N / half / doubled) — the `sum-then-scale` family +(0024-class: `(6+4)×2`). The op for each step comes from the text's structure (list +⇒ add; comparative ⇒ scale), not a single blunt op. + +All operands are text quantities (grounded) + comparative steps (cue-grounded), so +no derived-intermediate model is needed — the running value is the intermediate. +A stated comparative is part of the problem, so it is always applied (no +bare-vs-scaled alternative, which would self-disagree). Each licensed base shape +(list-sum, product) is one candidate; routed through the proven gate (grounding ∧ +cue ∧ unit ∧ completeness ∧ uniqueness). When two bases self-verify and disagree +(e.g. a same-unit list that also has a multiplicative cue), uniqueness refuses — +cue precision (ADR-0177) is what later breaks such ties. Refuse-preferring; sealed. + +Branch/DAG structures (0033's `25−12`) and richer relational ops (per/each, more/ +older) are later GB increments. +""" + +from __future__ import annotations + +from typing import Final + +from generate.derivation.comparatives import comparative_step, extract_comparative_scalars +from generate.derivation.extract import extract_quantities +from generate.derivation.model import GroundedDerivation, Quantity, Step +from generate.derivation.multistep import MAX_QUANTITIES +from generate.derivation.search import MULTIPLICATIVE_CUES +from generate.derivation.verify import Resolution, select_self_verified +from generate.math_roundtrip import _tokens + +# Additive cues that license summing a same-unit list (lexeme-level, ADR-0165). +_ADDITIVE_CUES: Final[tuple[str, ...]] = ("and", "plus", "altogether", "total") + + +def _same_unit(quantities: list[Quantity]) -> bool: + return len({q.unit for q in quantities}) == 1 + + +def compose_sequential(problem_text: str) -> Resolution | None: + """GB-2 composer — the same-unit **list-sum-then-scale** structure. + + Scope (deliberately narrow): only same-unit quantity *lists*. The list sums + (additive cue) and any stated comparative scales the sum (sum-then-scale). A + product base over the same list is added *without* a comparative tail purely as + a **disagreement-safety** candidate — so a same-unit list that also carries a + multiplicative cue (ambiguous: sum vs product) refuses rather than guessing. + + Product-of-all / cross-unit products are **not** this composer's job (that is + MS-3 ``search_chain``); a non-same-unit problem yields no candidate here and + refuses. This keeps GB-2 to the one structure it adds and avoids the + product×comparative blowups a blunt all-bases composer produced. + + Refuse-preferring; deterministic; sealed. + """ + quantities = list(extract_quantities(problem_text)) + if not 2 <= len(quantities) <= MAX_QUANTITIES or not _same_unit(quantities): + return None + + tokens = _tokens(problem_text) + tail = tuple(comparative_step(cs) for cs in extract_comparative_scalars(problem_text)) + start, *rest = quantities + + candidates: list[GroundedDerivation] = [] + add_cue = next((c for c in _ADDITIVE_CUES if c in tokens), None) + if add_cue is not None: # list-sum (+ applied comparative scale) + candidates.append( + GroundedDerivation( + start=start, + steps=tuple(Step(op="add", operand=q, cue=add_cue) for q in rest) + tail, + ) + ) + mult_cue = next((c for c in MULTIPLICATIVE_CUES if c in tokens), None) + if mult_cue is not None: # product (no tail) — disagreement-safety only + candidates.append( + GroundedDerivation( + start=start, + steps=tuple(Step(op="multiply", operand=q, cue=mult_cue) for q in rest), + ) + ) + return select_self_verified(candidates, problem_text, target_units=()) diff --git a/tests/test_adr_0178_gb2_compose.py b/tests/test_adr_0178_gb2_compose.py new file mode 100644 index 00000000..669b8272 --- /dev/null +++ b/tests/test_adr_0178_gb2_compose.py @@ -0,0 +1,55 @@ +"""ADR-0178 GB-2 — sequential composition: list-structure + comparative-scale. + +First GB-2 increment: the `sum-then-scale` family the blunt MS-3 shapes couldn't +reach. A same-unit list (additive cue) sums; trailing comparatives scale. Gated by +self-verification + uniqueness; refuse-preferring on ambiguity. +""" + +from __future__ import annotations + +from generate.derivation import compose_sequential + + +class TestListSum: + def test_same_unit_list_sums(self) -> None: + # a list of like quantities joined by "and" -> sum + res = compose_sequential("She picked 6 apples and 4 apples.") + assert res is not None and res.answer == 10.0 + + def test_three_item_list(self) -> None: + res = compose_sequential("He has 2 coins and 3 coins and 5 coins.") + assert res is not None and res.answer == 10.0 + + +class TestListThenScale: + def test_sum_then_double(self) -> None: + # 0024-family: list sums, then a comparative scales it + res = compose_sequential("She picked 6 apples and 4 apples, then doubled her apples.") + assert res is not None and res.answer == 20.0 # (6+4)*2 + + def test_sum_then_triple(self) -> None: + # use "tripled" (a fixed comparative, not a 'times' that also reads as a + # multiplicative cue) so the base op is unambiguous + res = compose_sequential("He ran 2 miles and 3 miles, then tripled it.") + assert res is not None and res.answer == 15.0 # (2+3)*3 + + +class TestRefusePreferring: + def test_mixed_units_no_list_sum(self) -> None: + # different units -> not a same-unit list -> no list-sum candidate + # (no other licensed shape either) -> refuse + assert compose_sequential("He has 6 boxes and 50 apples.") is None + + def test_ambiguous_disagreement_refuses(self) -> None: + # same-unit list (sum=10) AND a multiplicative cue "each" (product=24) both + # self-verify and disagree -> uniqueness refuses (cue precision resolves later) + assert compose_sequential("He has 6 apples and 4 apples in each basket.") is None + + def test_too_few_quantities(self) -> None: + assert compose_sequential("She has 6 apples.") is None + + +class TestDeterminism: + def test_deterministic(self) -> None: + t = "She picked 6 apples and 4 apples, then doubled her apples." + assert compose_sequential(t) == compose_sequential(t)