From 94d8137ad1440ed0b66a00937899679f237863e3 Mon Sep 17 00:00:00 2001 From: Shay Date: Wed, 3 Jun 2026 21:50:59 -0700 Subject: [PATCH] =?UTF-8?q?feat(r4):=20goal-residual=20production=20?= =?UTF-8?q?=E2=80=94=20ADR-0207=20=C2=A75=20step=202=20(sealed=20lane)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First composition lift-target built end-to-end: cv-0005 (train_sample 0037) now resolves in the sealed pool as goal - Σprogress = 10 - 3 - 4 = 3. - generate/derivation/goal_residual.py: new R4 production. Reads a GOAL anchor (goal-intent lexeme) + a residual question, subtracts each same-referent progress quantity (progress reduces the residual regardless of world-polarity). Gated by the unchanged self-verification gate. - wrong=0 firewall (test_reads_goal_not_possession): on a gain goal the goal-residual (20-5-6=9) DIVERGES from possession-accumulation (20+5+6=31); the production gives 9 and is all-subtract -> it reads the goal, not the possession. This is the coincidental-correctness trap cv-0005 alone hides (10-3-4 == 10-(3+4)). - pool.py: goal_residual added to pooled_candidates (sealed). Verified: fires on exactly one train_sample case (0037, correct), zero new pool wrong-commits (the 8 are pre-existing, gated off serving by product_bridge). Does NOT move the serving metric: train_sample stays 6/44/0 byte-identical (serving = candidate-graph; product_bridge promotes only pure products, never a subtract chain). The serving promotion gate for goal-residual is the next, separately-gated step (needs the sealed 1,319 verdict). Smoke 73, math 4, derivation/pool/practice 196, corpus, completeness-guard all green. --- generate/derivation/goal_residual.py | 111 +++++++++++++++++++++++++++ generate/derivation/pool.py | 3 + tests/test_r4_goal_residual.py | 73 ++++++++++++++++++ 3 files changed, 187 insertions(+) create mode 100644 generate/derivation/goal_residual.py create mode 100644 tests/test_r4_goal_residual.py diff --git a/generate/derivation/goal_residual.py b/generate/derivation/goal_residual.py new file mode 100644 index 00000000..0882ceef --- /dev/null +++ b/generate/derivation/goal_residual.py @@ -0,0 +1,111 @@ +"""ADR-0207 §5 step 2 / R4 — goal-residual reading (single-referent). + +The first **residual-to-target** comprehension reading, distinct from GB-3b.1 +accumulation (:mod:`generate.derivation.accumulate`): + +- accumulation reads a **possession** that changes — ``start ± changes`` (``Sam has + 14 apples. He buys 9 more.`` -> ``14 + 9``); +- this reads a **goal** and the **progress** toward it — ``goal − Σprogress`` + (``Michael wants to lose 10 pounds. He lost 3, then 4. How much more to meet his + goal?`` -> ``10 − 3 − 4``). + +**Critical wrong=0 distinction — the coincidental-correctness trap.** Goal-residual is +*not* possession-accumulation. For a **loss** goal the two arithmetics coincide +(``10 − 3 − 4`` == ``10 − (3+4)``), so a naive "make accumulation fire" change would +pass ``cv-0005`` for the wrong reason — reading the goal ``10`` as a possession start. +For a **gain/save** goal they **diverge**: ``wants to save 20, saved 5, saved 6`` -> +residual ``20 − 5 − 6 = 9``, whereas possession-accumulation gives ``20 + 5 + 6 = 31``. +This production fires **only** on goal-language and **always subtracts** progress +(progress reduces the residual regardless of its world-polarity), so it reads the goal, +never the possession. The gain-goal divergence is the wrong=0 firewall test. + +Lexeme-level (ADR-0165): goal-intent and residual-question cues are closed token sets, +not sentence-shape grammar. The constructed chain runs through the unchanged +self-verification gate (grounding ∧ unit ∧ completeness ∧ uniqueness) — refuse- +preferring; this only proposes a structurally-licensed candidate. + +Sealed (no ``chat/`` import); deterministic. +""" + +from __future__ import annotations + +from typing import Final + +from generate.derivation.clauses import segment_clauses +from generate.derivation.extract import extract_quantities +from generate.derivation.model import GroundedDerivation, Quantity, Step +from generate.derivation.state.bind import continues_anchor_referent, leading_subject_token +from generate.derivation.state.change import classify_change_polarity, select_change_cue +from generate.derivation.target import _question_clause +from generate.derivation.verify import Resolution, select_self_verified +from generate.math_roundtrip import _tokens + +# Goal-intent lexemes: the anchor quantity is a *target*, not a possession. Closed set. +_GOAL_INTENT: Final[frozenset[str]] = frozenset( + {"want", "wants", "wanted", "need", "needs", "hoping", "hopes", "plans", "aims", "goal"} +) +# Residual-question lexemes: the question asks the remaining distance to the goal. +_RESIDUAL_CUES: Final[frozenset[str]] = frozenset({"more", "left", "remaining"}) +_GOAL_REACH: Final[frozenset[str]] = frozenset({"meet", "reach", "hit", "achieve"}) + + +def _asks_residual(question_clause: str) -> bool: + """The question asks the remaining distance to a goal (lexeme-level).""" + q = _tokens(question_clause) + return bool(_RESIDUAL_CUES & q) or ("goal" in q and bool(_GOAL_REACH & q)) + + +def build_goal_residual(problem_text: str) -> GroundedDerivation | None: + """Construct the ungated goal-residual chain ``goal − Σprogress``, or ``None``. + + Fires only when: (a) the question asks a residual-to-goal; (b) the first + quantity-clause carries a goal-intent lexeme and establishes exactly one goal + quantity; (c) every later quantity-clause stays on the same referent and carries a + licensed change cue (its quantities are *progress*). Progress is **subtracted** + (it reduces the residual) regardless of the change's world-polarity — this is what + makes the reading goal-residual, not possession-accumulation. + """ + if not _asks_residual(_question_clause(problem_text)): + return None + + clauses = segment_clauses(problem_text) + quantity_clauses = [c for c in clauses if extract_quantities(c)] + if len(quantity_clauses) < 2: + return None + + anchor_clause, *progress_clauses = quantity_clauses + if not (_GOAL_INTENT & _tokens(anchor_clause)): + return None # the anchor must be goal-language, not a possession + anchor_quantities = extract_quantities(anchor_clause) + if len(anchor_quantities) != 1: + return None # exactly one goal quantity + goal = anchor_quantities[0] + anchor_subject = leading_subject_token(anchor_clause) + + steps: list[Step] = [] + for clause in progress_clauses: + if not continues_anchor_referent(clause, anchor_subject): + return None # new named actor -> referent hazard -> refuse + polarity = classify_change_polarity(clause) + if polarity is None: + return None # progress must carry a licensed change cue + cue = select_change_cue(clause, polarity) + progress = [q for q in extract_quantities(clause) if (not q.unit) or q.unit == goal.unit] + if not progress: + return None # no same-unit progress quantity -> refuse + for q in progress: + operand = Quantity(value=q.value, unit=goal.unit, source_token=q.source_token) + steps.append(Step(op="subtract", operand=operand, cue=cue)) + + if not steps: + return None + return GroundedDerivation(start=goal, steps=tuple(steps)) + + +def compose_goal_residual(problem_text: str) -> Resolution | None: + """R4 goal-residual composer. Refuse-preferring: gates the chain through the + unchanged self-verification gate (grounding ∧ unit ∧ completeness ∧ uniqueness).""" + derivation = build_goal_residual(problem_text) + if derivation is None: + return None + return select_self_verified([derivation], problem_text, target_units=()) diff --git a/generate/derivation/pool.py b/generate/derivation/pool.py index cd21712c..4424f09d 100644 --- a/generate/derivation/pool.py +++ b/generate/derivation/pool.py @@ -30,6 +30,7 @@ never invoked here). from __future__ import annotations from generate.derivation.accumulate import accumulation_candidates +from generate.derivation.goal_residual import build_goal_residual from generate.derivation.model import GroundedDerivation from generate.derivation.multistep import candidate_chains from generate.derivation.search import multiplicative_candidates @@ -42,10 +43,12 @@ def pooled_candidates(problem_text: str) -> list[GroundedDerivation]: order (accumulation, then multiplicative, then chain).""" seen: set[tuple[object, ...]] = set() pooled: list[GroundedDerivation] = [] + _goal_residual = build_goal_residual(problem_text) for derivation in ( *accumulation_candidates(problem_text), *multiplicative_candidates(problem_text), *candidate_chains(problem_text), + *((_goal_residual,) if _goal_residual is not None else ()), ): key = ( round(derivation.answer, 9), diff --git a/tests/test_r4_goal_residual.py b/tests/test_r4_goal_residual.py new file mode 100644 index 00000000..bf580593 --- /dev/null +++ b/tests/test_r4_goal_residual.py @@ -0,0 +1,73 @@ +"""ADR-0207 §5 step 2 / R4 — goal-residual production + its wrong=0 firewall. + +The corpus lift-target cv-0005 (train_sample 0037) must read `goal − Σprogress`. +The load-bearing test is the **gain-goal divergence** (`test_reads_goal_not_possession`): +it proves the production reads the goal, not the possession, on a case where the two +arithmetics differ — the coincidental-correctness trap cv-0005 alone cannot catch. +""" +from __future__ import annotations + +from generate.derivation.goal_residual import build_goal_residual, compose_goal_residual + +# cv-0005 / train_sample 0037 (loss goal — residual and possession coincide at 3). +CV0005 = ( + "Michael wants to lose 10 pounds by June. He lost 3 pounds in March and 4 pounds " + "in April. How much weight does he have to lose in May to meet his goal?" +) +# Adversarial gain goal — residual (20-5-6=9) DIVERGES from possession (20+5+6=31). +# Uses a recognized gain verb ("earned") so the progress cue is licensed. +SAVE_GOAL = ( + "Maria wants to earn 20 dollars for a gift. She earned 5 dollars in May and 6 dollars " + "in June. How much more does she need to earn to reach her goal?" +) + + +def _answer(text): + res = compose_goal_residual(text) + return None if res is None else res.answer + + +def test_cv0005_goal_residual_solves() -> None: + """cv-0005: goal 10 − (3 + 4) = 3, read as a goal (start=10, all-subtract).""" + deriv = build_goal_residual(CV0005) + assert deriv is not None + assert deriv.start.value == 10.0 + assert all(s.op == "subtract" for s in deriv.steps) + assert _answer(CV0005) == 3.0 + + +def test_reads_goal_not_possession() -> None: + """WRONG=0 FIREWALL. On a gain goal, goal-residual (9) diverges from possession- + accumulation (31). The production must give 9 (reads the goal) — never 31, and the + chain must be all-subtract (progress reduces the residual regardless of polarity).""" + deriv = build_goal_residual(SAVE_GOAL) + assert deriv is not None + assert _answer(SAVE_GOAL) == 9.0, "must read goal-residual, not possession 31" + assert all(s.op == "subtract" for s in deriv.steps), "progress always subtracts" + assert deriv.start.value == 20.0 + + +def test_no_goal_language_does_not_fire() -> None: + """A possession case (no goal-intent lexeme) must NOT fire this production — + it belongs to accumulation, not goal-residual.""" + possession = "Sam has 14 apples. He gives away 3 apples and 2 apples. How many are left?" + assert build_goal_residual(possession) is None + + +def test_no_residual_question_does_not_fire() -> None: + """Goal language but no residual question → does not fire.""" + no_residual = ( + "Michael wants to lose 10 pounds by June. He lost 3 pounds in March and 4 pounds " + "in April. How much weight did he lose in total?" + ) + assert build_goal_residual(no_residual) is None + + +def test_incomplete_reading_refuses() -> None: + """A progress clause with a new named actor (referent hazard) must refuse — the + same-referent guard is inherited, never weakened.""" + cross_referent = ( + "Michael wants to lose 10 pounds by June. Sarah lost 3 pounds in March. " + "How much more does Michael need to lose to meet his goal?" + ) + assert build_goal_residual(cross_referent) is None