core/generate/derivation/goal_residual.py
Shay e8f5a347d5
feat(derivation): capability strike batch 4 goal-residual lift (#814)
* feat(derivation): capability strike batch 4 goal-residual lift

Scout-guided Batch 4 re-wires resolve_promotable_goal_residual serving
promotion (Gate A2e) for train_sample 0037. product_bridge stays disabled
per held-out wrong=0 bisection.

Ephemeral train_sample: 9/41/0 → 10/40/0, wrong=0 preserved.
report.json untouched.

* test(gsm8k): refresh composition corpus prose for A2e

* fix(derivation): refuse comparative goal-residual questions

* test(derivation): guard goal-residual against comparative more-than
2026-06-17 20:21:18 -07:00

132 lines
6.6 KiB
Python
Raw Permalink 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-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"})
# Comparative questions may also contain "more", but their target is a difference
# between progress clauses, not distance-to-goal. Prefer refusal over promotion.
_COMPARATIVE_TARGET_CUES: Final[frozenset[str]] = frozenset({"than"})
def _asks_residual(question_clause: str) -> bool:
"""The question asks the remaining distance to a goal (lexeme-level)."""
q = _tokens(question_clause)
if _COMPARATIVE_TARGET_CUES & q:
return False
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=())
def resolve_promotable_goal_residual(problem_text: str) -> Resolution | None:
"""Serving promotion bridge (ADR-0207 §5 step 2), parallel to
:func:`generate.derivation.product_bridge.resolve_promotable_product`.
Returns a serving-safe goal-residual resolution, or ``None``. The promotion
invariant is the production's own narrowness (goal-anchor + residual question +
same-referent licensed progress) **and** the self-verification gate
(:func:`compose_goal_residual`): grounding ∧ unit ∧ completeness. Empirically
(2026-06-04) this fires on 2/455 visible GSM8K cases, both correct, zero wrong;
the gain-goal divergence firewall proves it reads the goal, not a possession.
The sealed 1,319 verdict (ADR-0207 §6) is the operator/CI bar this gate cannot
self-check.
"""
return compose_goal_residual(problem_text)