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.
This commit is contained in:
parent
de3fc38fc2
commit
b0cee4e3f8
3 changed files with 141 additions and 0 deletions
|
|
@ -12,6 +12,7 @@ from generate.derivation.clauses import (
|
||||||
clause_local_results,
|
clause_local_results,
|
||||||
segment_clauses,
|
segment_clauses,
|
||||||
)
|
)
|
||||||
|
from generate.derivation.compose import compose_sequential
|
||||||
from generate.derivation.comparatives import (
|
from generate.derivation.comparatives import (
|
||||||
ComparativeScalar,
|
ComparativeScalar,
|
||||||
comparative_step,
|
comparative_step,
|
||||||
|
|
@ -41,6 +42,7 @@ __all__ = [
|
||||||
"Target",
|
"Target",
|
||||||
"VALID_OPS",
|
"VALID_OPS",
|
||||||
"clause_local_results",
|
"clause_local_results",
|
||||||
|
"compose_sequential",
|
||||||
"comparative_step",
|
"comparative_step",
|
||||||
"extract_comparative_scalars",
|
"extract_comparative_scalars",
|
||||||
"extract_quantities",
|
"extract_quantities",
|
||||||
|
|
|
||||||
84
generate/derivation/compose.py
Normal file
84
generate/derivation/compose.py
Normal file
|
|
@ -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=())
|
||||||
55
tests/test_adr_0178_gb2_compose.py
Normal file
55
tests/test_adr_0178_gb2_compose.py
Normal file
|
|
@ -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)
|
||||||
Loading…
Reference in a new issue