* 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
132 lines
6.6 KiB
Python
132 lines
6.6 KiB
Python
"""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)
|