feat(adr-0178-gb3a): clause-scoped referent guard — refuse cross-clause aggregation (#456)

The mandated lookback review before GB-3 (CLAUDE.md §Lookback Review Discipline)
confirmed the audit's hazards H1/H2/H3 were LIVE: compose_sequential summed
same-unit quantities from the whole problem, merging unrelated referents/scopes
and admitting wrong structures whose value happened to ground:
  H1 (second actor's apples)      -> 6+4+2  = 12
  H2 (comparative on other actor) -> (6+4)*2 = 20
  H3 (later depletion event)      -> 6+4+3  = 13

Root cause is the audit's G1/D2 drift: GB-2a re-extracts from the whole text and
ignores GB-1's clause structure. The fix is the GB-3 increment — make the composer
clause-scoped (consume segment_clauses), refusing when the licensed structure spans
clauses, because this slice cannot model referents:
  - quantities must live in exactly ONE clause (0 or >1 -> refuse);
  - a comparative outside that clause -> refuse (unmodelled referent binding).

All three hazards now refuse; all 7 GB-2 single-clause structures preserved
(list-sum, three-item, sum-then-scale, and the mixed-unit/disagreement/too-few
refusals). tests/test_adr_0178_gb3_referent_guard.py would fail against the
pre-guard code (12/20/13), so the obligation is proven, not decorative.

Scope/safety:
- compose_sequential is sealed substrate, not wired to a scorer -> serving
  byte-identical 3/47/0 (lane-SHA 8/8, generate_claims --check OK); practice
  unchanged 4/1/45. No new test failures (2 pre-existing on main).
- ADR-0178 amended: GB-2 relabelled GB-2a (list slice, drift G1 recorded);
  GB-3 split into GB-3a (this referent guard, landed) and GB-3b (constructive
  cross-clause chaining, next).
This commit is contained in:
Shay 2026-05-29 09:15:52 -07:00 committed by GitHub
parent 38ef66c2a9
commit 5c1e9e7fe4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 111 additions and 5 deletions

View file

@ -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.

View file

@ -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] = []

View file

@ -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)