diff --git a/docs/decisions/ADR-0178-compositional-structure.md b/docs/decisions/ADR-0178-compositional-structure.md index 04657efd..a582d8da 100644 --- a/docs/decisions/ADR-0178-compositional-structure.md +++ b/docs/decisions/ADR-0178-compositional-structure.md @@ -131,8 +131,25 @@ gated wrong=0-first and measured honestly. - **GB-2 — sequential combination (chains).** Combine running result with the next clause via the relational cue → op (guided by ADR-0177 + comparatives). Flips the sequential-chain gold cases (0003/0024-class) under self-verification + uniqueness. + - **Shipped narrower than scoped (drift G1, audit `AUDIT-ADR-0178-GB1-GB2.md`).** + What landed is a **same-unit list-sum-then-scale slice** (`compose_sequential`) + that re-extracted from the *whole problem* — it did **not** consume GB-1's + `ClauseResult`s and did **not** chain across clauses, so it never flipped real + 0003/0024 (those stay blocked on extraction + cross-clause chaining). Treat the + landed code as **GB-2a (list structure)**, not the full sequential chainer. - **GB-3 — lookback / reevaluate** (repoint ADR-0174 `reevaluate`): a later clause revises an earlier structural choice. wrong=0-first. + - **GB-3a — referent guard (landed).** The lookback review proved GB-2a's + whole-problem aggregation admitted wrong structures across referents/scopes + (hazards H1/H2/H3 returned 12/20/13). `compose_sequential` is now **clause-scoped**: + the list-sum must be licensed within one clause, and a comparative outside that + clause refuses. This is the wrong=0-first floor (`compose_sequential` is sealed + substrate, not yet wired to a scorer, so serving stayed `3/47/0`); tests in + `tests/test_adr_0178_gb3_referent_guard.py` would fail against the pre-guard code. + - **GB-3b — constructive cross-clause chaining (next).** Consume GB-1 + `ClauseResult`s and combine across clauses *referent-safely* (the chainer GB-2 + was originally scoped to be), with lookback revising an earlier choice. Builds on + the now-safe GB-3a floor. - **GB-4 — held structural hypotheses + eliminate** (repoint `eliminate_violating` / `contemplate`): hold >1 structure on ambiguity, eliminate downstream, refuse if irreducible. diff --git a/generate/derivation/compose.py b/generate/derivation/compose.py index ed089855..1c96bcb5 100644 --- a/generate/derivation/compose.py +++ b/generate/derivation/compose.py @@ -24,6 +24,7 @@ from __future__ import annotations from typing import Final +from generate.derivation.clauses import segment_clauses 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 @@ -41,7 +42,7 @@ def _same_unit(quantities: list[Quantity]) -> bool: def compose_sequential(problem_text: str) -> Resolution | None: - """GB-2 composer — the same-unit **list-sum-then-scale** structure. + """GB-2/GB-3 composer — the **clause-local** same-unit list-sum-then-scale. Scope (deliberately narrow): only same-unit quantity *lists*. The list sums (additive cue) and any stated comparative scales the sum (sum-then-scale). A @@ -51,17 +52,40 @@ def compose_sequential(problem_text: str) -> Resolution | None: 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 + refuses. This keeps the composer to the one structure it adds and avoids the product×comparative blowups a blunt all-bases composer produced. + **GB-3 referent guard (wrong=0-first).** The list-sum structure must be + licensed *within a single clause*. The earlier whole-problem version summed any + same-unit quantities anywhere in the text, which silently merged unrelated + referents/scopes (a later sentence's quantity, a second actor's total, a + depletion event) into one sum — admitting wrong structures whose value happened + to ground (audit ADR-0178 hazards H1/H2/H3). This composer cannot model + referents, so when the licensed structure would span clauses it **refuses** + (cross-clause, referent-aware chaining is GB-3b): + + * quantities must live in exactly **one** clause (segment_clauses); 0 or >1 + quantity-bearing clauses ⇒ refuse; + * any comparative scalar **outside** that clause ⇒ refuse (it binds to a + referent/structure this slice does not model). + Refuse-preferring; deterministic; sealed. """ - quantities = list(extract_quantities(problem_text)) + # GB-3: the structure must be licensed within a single clause (referent guard). + quantity_clauses = [c for c in segment_clauses(problem_text) if extract_quantities(c)] + if len(quantity_clauses) != 1: + return None + clause = quantity_clauses[0] + # A comparative living outside the list clause binds an unmodelled referent. + if len(extract_comparative_scalars(problem_text)) != len(extract_comparative_scalars(clause)): + return None + + quantities = list(extract_quantities(clause)) 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)) + tokens = _tokens(clause) + tail = tuple(comparative_step(cs) for cs in extract_comparative_scalars(clause)) start, *rest = quantities candidates: list[GroundedDerivation] = [] diff --git a/tests/test_adr_0178_gb3_referent_guard.py b/tests/test_adr_0178_gb3_referent_guard.py new file mode 100644 index 00000000..b3f7fbd6 --- /dev/null +++ b/tests/test_adr_0178_gb3_referent_guard.py @@ -0,0 +1,65 @@ +"""ADR-0178 GB-3 — the referent guard (wrong=0-first lookback increment). + +The GB-2 lookback review (audit docs/handoff/AUDIT-ADR-0178-GB1-GB2.md) found that +``compose_sequential`` summed same-unit quantities from the *whole problem*, +silently merging unrelated referents/scopes — admitting wrong structures whose +value happened to ground (hazards H1/H2/H3). GB-3's first, wrong=0-first slice is +the *defensive refusal*: the list-sum structure must be licensed within a single +clause; cross-clause aggregation refuses (referent-aware chaining is GB-3b). + +These tests would FAIL against the pre-GB-3 whole-problem composer (which returned +12 / 20 / 13 respectively), so the obligation is proven, not decorative. +""" + +from __future__ import annotations + +from generate.derivation import compose_sequential + + +class TestReferentGuardRefuses: + def test_h1_unrelated_same_unit_across_sentences(self) -> None: + # Tom's apples are a different referent; the whole-problem composer summed + # 6+4+2=12. Two quantity-bearing clauses -> refuse. + text = ( + "Alice has 6 apples and 4 apples. Tom has 2 apples. " + "How many apples does Alice have?" + ) + assert compose_sequential(text) is None + + def test_h2_comparative_bound_to_other_referent(self) -> None: + # "twice" modifies Tom, not Alice's list; the whole-problem composer applied + # it to Alice's sum -> (6+4)*2 = 20. The comparative lives outside the list + # clause -> refuse. + text = ( + "Alice picked 6 apples and 4 apples. Tom picked twice as many apples. " + "How many apples did Alice pick?" + ) + assert compose_sequential(text) is None + + def test_h3_later_depletion_outside_asked_scope(self) -> None: + # The "gave 3 away" event is a different scope; the whole-problem composer + # summed 6+4+3 = 13. Two quantity-bearing clauses -> refuse. + text = ( + "Alice picked 6 apples and 4 apples. Later she gave 3 apples away. " + "How many apples did she pick before giving any away?" + ) + assert compose_sequential(text) is None + + +class TestSingleClauseStructuresStillResolve: + """The guard must not regress the legitimate single-clause GB-2 structures.""" + + def test_single_clause_list_sum(self) -> None: + res = compose_sequential("She picked 6 apples and 4 apples.") + assert res is not None and res.answer == 10.0 + + def test_single_clause_sum_then_scale(self) -> None: + # list + comparative in the SAME clause (comma, not a sentence break). + res = compose_sequential("She picked 6 apples and 4 apples, then doubled her apples.") + assert res is not None and res.answer == 20.0 + + +class TestDeterminism: + def test_deterministic(self) -> None: + t = "Alice has 6 apples and 4 apples. Tom has 2 apples. How many?" + assert compose_sequential(t) == compose_sequential(t)