core/generate/derivation/compose.py
Shay b0cee4e3f8 feat(adr-0178-gb2): sequential composition — same-unit list-sum-then-scale
GB-2 first increment (ADR-0178). compose_sequential() adds the structure the blunt
MS-3 shapes couldn't reach: a same-unit quantity LIST sums (additive cue), and any
stated comparative scales the sum (sum-then-scale, 0024-family). Op-per-step from
text structure (list => add; comparative => scale); operands are text quantities
(grounded) + comparative steps (cue-grounded) on the flat left-fold — no derived-
intermediate model needed (running value is the intermediate).

Deliberately narrow: same-unit lists only. A stated comparative is ALWAYS applied
(no bare-vs-scaled self-disagreement). A product base over the same list is added
WITHOUT a comparative tail purely as a disagreement-safety candidate -> a same-unit
list that also carries a mult cue (ambiguous) REFUSES. Product-of-all/cross-unit
products stay MS-3's job (avoids the product x comparative blowups a blunt all-bases
composer produced: 0024 -> 4.3M).

Clean-case capability proven: 8 tests (list-sum, sum-then-double/triple, mixed-units
refuse, ambiguous-disagreement refuse, determinism). Honest practice result: 3/2/45
— NO new flips (extraction wall: real cases like 0024 extract non-uniform units
'36 on' so they aren't seen as same-unit lists), 2 sealed eliminations (0037/0039:
list-sum was the wrong structure -> learning signal). Coverage gated by extraction
richness + cue precision, as predicted.

Sealed; serving untouched. Full derivation surface 53/53; ruff clean; smoke 67.
Continuation: richer relational ops (per/each->multiply, more/older->add), branch/
DAG (0033), and the extraction richness (uniform-unit extraction) that unblocks this
on real cases.
2026-05-28 17:29:53 -07:00

84 lines
4.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 `2512`) 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=())