core/generate/derivation/duration_segment_total.py
Shay e2f3d37373
feat(derivation): capability paradigm sprint 6 experience-guided lift (#817)
* 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
2026-06-17 22:22:53 -07:00

187 lines
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.

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