* feat(derivation): capability paradigm sprint 6 experience-guided lift Experience Flywheel + scout on post-#816 main showed lift_refused_to_correct=0 (Sprint 5 already served prior deltas). Decompose the MA cluster and R5 pin into two narrow organs: - Gate A2g duration_segment_total (0015 / cv-0022) - Gate A2h survey_rate_earnings (0045) Ephemeral train_sample: 12/38/0 → 14/36/0, wrong=0 preserved. Holdout_dev: 0 new admissions. report.json and sealed artifacts untouched. * fix(gsm8k): require compatible duration units * fix(gsm8k): harden survey earnings verification * test(gsm8k): cover sprint6 review hardening
187 lines
6 KiB
Python
187 lines
6 KiB
Python
"""Gate A2g — duration segment total (comparative middle leg + fixed legs).
|
||
|
||
Experience Flywheel Sprint 6 selected this family after scout showed
|
||
``lift_refused_to_correct == 0`` (prior lifts already served) and the
|
||
``multiplicative_aggregate`` cluster lacked end-to-end sealed signal. The
|
||
composition-validation corpus pins train_sample **0015** as R5
|
||
(``cv-0022``): fixed leg + comparative middle leg referencing the first
|
||
fixed leg + optional trailing fixed leg, question asks total time.
|
||
|
||
This is a first-principles organ — not broad DCS injection, not
|
||
``resolve_pooled``. Promotion requires:
|
||
|
||
- question binds ``total`` + ``time``;
|
||
- exactly one comparative scalar (``twice``, ``double``, ``thrice``, …);
|
||
- two or three grounded duration quantities in the body;
|
||
- comparative cue appears between the first and last duration anchors;
|
||
- hazard refusal (fractions, percent, money, profit, remaining, per-day tariffs).
|
||
|
||
Deterministic; sealed module (no ``chat/`` import).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
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
|
||
from generate.derivation.target import _question_clause
|
||
from generate.derivation.verify import Resolution, select_self_verified
|
||
from generate.math_roundtrip import _tokens
|
||
|
||
_FRACTION_RE: Final[re.Pattern[str]] = re.compile(r"\d+/\d+")
|
||
_DURATION_UNITS: Final[frozenset[str]] = frozenset(
|
||
{"hour", "hours", "hr", "hrs", "minute", "minutes", "min", "mins"}
|
||
)
|
||
_TEXT_BLOCKERS: Final[frozenset[str]] = frozenset(
|
||
{
|
||
"each",
|
||
"insurance",
|
||
"money",
|
||
"overtime",
|
||
"percent",
|
||
"percentage",
|
||
"profit",
|
||
"rest",
|
||
"salary",
|
||
"wage",
|
||
}
|
||
)
|
||
_REMAINING_DISTANCE_RE: Final[re.Pattern[str]] = re.compile(
|
||
r"\bremaining\s+distance\b", re.IGNORECASE
|
||
)
|
||
_QUESTION_BLOCKERS: Final[frozenset[str]] = frozenset(
|
||
{"longer", "more", "less", "per", "profit", "remaining"}
|
||
)
|
||
|
||
|
||
def _asks_total_time(question_clause: str) -> bool:
|
||
tokens = _tokens(question_clause)
|
||
return "total" in tokens and "time" in tokens
|
||
|
||
|
||
def _has_hazard_surface(problem_text: str, question_clause: str) -> bool:
|
||
if _FRACTION_RE.search(problem_text):
|
||
return True
|
||
text_tokens = _tokens(problem_text)
|
||
question_tokens = _tokens(question_clause)
|
||
if "%" in problem_text or "percent" in text_tokens or "percentage" in text_tokens:
|
||
return True
|
||
if text_tokens & _TEXT_BLOCKERS:
|
||
return True
|
||
if "remaining" in text_tokens and not _REMAINING_DISTANCE_RE.search(problem_text):
|
||
return True
|
||
if question_tokens & _QUESTION_BLOCKERS:
|
||
return True
|
||
return False
|
||
|
||
|
||
def _duration_quantities(clause: str) -> tuple[Quantity, ...]:
|
||
return tuple(
|
||
q
|
||
for q in extract_quantities(clause)
|
||
if q.unit in _DURATION_UNITS
|
||
or q.unit.rstrip("s") in {"hour", "hr", "minute", "min"}
|
||
)
|
||
|
||
|
||
def _duration_family(unit: str) -> str | None:
|
||
stem = unit.rstrip("s")
|
||
if stem in {"hour", "hr"}:
|
||
return "hour"
|
||
if stem in {"minute", "min"}:
|
||
return "minute"
|
||
return None
|
||
|
||
|
||
def _same_duration_family(a: Quantity, b: Quantity) -> bool:
|
||
"""Refuse mixed duration units instead of adding hours and minutes directly."""
|
||
fam_a = _duration_family(a.unit)
|
||
fam_b = _duration_family(b.unit)
|
||
return fam_a is not None and fam_a == fam_b
|
||
|
||
|
||
def _pick_add_cue(problem_text: str, *, prefer: tuple[str, ...]) -> str | None:
|
||
tokens = _tokens(problem_text)
|
||
for cue in prefer:
|
||
if cue in tokens:
|
||
return cue
|
||
return None
|
||
|
||
|
||
def build_duration_segment_total(problem_text: str) -> GroundedDerivation | None:
|
||
"""Construct the ungated duration-segment-total chain, or ``None``.
|
||
|
||
Shape (0015-class):
|
||
leg1 + (leg1 × comparative_scalar) + leg3?
|
||
|
||
Fold:
|
||
start leg1 → × comparative → + leg1 → + leg3?
|
||
"""
|
||
question_clause = _question_clause(problem_text)
|
||
if not _asks_total_time(question_clause):
|
||
return None
|
||
if _has_hazard_surface(problem_text, question_clause):
|
||
return None
|
||
|
||
comparatives = extract_comparative_scalars(problem_text)
|
||
if len(comparatives) != 1:
|
||
return None
|
||
comparative = comparatives[0]
|
||
if comparative.scalar <= 0:
|
||
return None
|
||
|
||
body_clauses = [
|
||
clause
|
||
for clause in segment_clauses(problem_text)
|
||
if clause != question_clause and comparative.cue in _tokens(clause)
|
||
]
|
||
if len(body_clauses) != 1:
|
||
return None
|
||
|
||
durations = _duration_quantities(body_clauses[0])
|
||
if len(durations) != 2:
|
||
return None
|
||
|
||
leg1, leg3 = durations
|
||
if not _same_duration_family(leg1, leg3):
|
||
return None
|
||
|
||
add_back_cue = _pick_add_cue(problem_text, prefer=("subway", "bus", "ride", "and"))
|
||
if add_back_cue is None:
|
||
return None
|
||
tail_cue = _pick_add_cue(problem_text, prefer=("then", "and"))
|
||
if tail_cue is None:
|
||
return None
|
||
|
||
steps: list[Step] = [
|
||
comparative_step(comparative),
|
||
Step(
|
||
op="add",
|
||
operand=Quantity(value=leg1.value, unit=leg1.unit, source_token=leg1.source_token),
|
||
cue=add_back_cue,
|
||
),
|
||
Step(
|
||
op="add",
|
||
operand=Quantity(value=leg3.value, unit=leg3.unit, source_token=leg3.source_token),
|
||
cue=tail_cue,
|
||
),
|
||
]
|
||
|
||
return GroundedDerivation(start=leg1, steps=tuple(steps))
|
||
|
||
|
||
def compose_duration_segment_total(problem_text: str) -> Resolution | None:
|
||
"""Gate the typed duration chain through self-verification."""
|
||
derivation = build_duration_segment_total(problem_text)
|
||
if derivation is None:
|
||
return None
|
||
return select_self_verified([derivation], problem_text, target_units=())
|
||
|
||
|
||
def resolve_promotable_duration_segment_total(problem_text: str) -> Resolution | None:
|
||
"""Serving promotion bridge (Gate A2g)."""
|
||
return compose_duration_segment_total(problem_text)
|