core/generate/derivation/temporal_tariff.py
Shay 80753e13de
fix(gsm8k): harden sprint 9 temporal and affine gates (#822)
Patch post-merge wrong=0 hazards from #820 review without reverting
Sprint 9 organs: bind affine/temporal answers to asked subjects and body
actors, anchor overtime thresholds to per-shift/day surfaces, fix affine
completeness double-counting, narrow percent_partition group skip, and
expand temporal question/rental verb recognition.
2026-06-18 10:57:36 -07:00

467 lines
No EOL
17 KiB
Python
Raw 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 A2m — temporal tariff (overtime shift earnings + bundle overflow tariff).
Experience Flywheel Sprint 9 / microscope ``temporal_tariff:temporal_aggregation``
(train_sample **0001**, **0017**): two narrow tariff shapes, not a generic time
parser.
**Pattern A — overtime shift earnings (0001)**
regular = threshold_hours × hourly_rate × days
overtime_total = (hours_per_day threshold_hours) × (rate + rate × ½) × days
total = regular + overtime_total
**Pattern B — bundle overflow tariff (0017)**
overflow_cost = (rental_days bundle_days) × daily_rate
total = bundle_price + overflow_cost
Promotion requires explicit positive anchors per pattern, question-target binding
(money/cost), and hazard refusal (profit, insurance, percent, multiple tariffs).
Deterministic; sealed module (no ``chat/`` import).
"""
from __future__ import annotations
import re
from typing import Final
from collections import Counter
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 PRONOUNS, leading_subject_token
from generate.derivation.target import _question_clause
from generate.derivation.verify import Resolution, SelfVerification
from generate.math_roundtrip import _token_in, _tokens, _value_grounds
_FRACTION_RE: Final[re.Pattern[str]] = re.compile(r"\d+\s*/\s*\d+")
_HOURLY_RATE_RE: Final[re.Pattern[str]] = re.compile(
r"\$\s*(\d+(?:\.\d+)?)\s*(?:an\s+hour|/hour|per\s+hour)",
re.IGNORECASE,
)
_THRESHOLD_SHIFT_RE: Final[re.Pattern[str]] = re.compile(
r"more\s+than\s+(\d+)\s+hours?\s+(?:a|an|each|per)\s+(?:day|shift|workday)",
re.IGNORECASE,
)
_THRESHOLD_BAD_ANCHOR_RE: Final[re.Pattern[str]] = re.compile(
r"more\s+than\s+\d+\s+hours?\s+(?:per|a|an|each|for)\s+(?:week|month|year|total)",
re.IGNORECASE,
)
_RENT_VERBS: Final[frozenset[str]] = frozenset({"rent", "rents", "rented", "renting"})
_QUESTION_MONEY_SUBJECT_RE: Final[tuple[re.Pattern[str], ...]] = (
re.compile(
r"how\s+much(?:\s+money)?\s+does\s+(\w+)\s+(?:make|earn)",
re.IGNORECASE,
),
re.compile(r"how\s+much\s+will\s+it\s+cost\s+(\w+)", re.IGNORECASE),
re.compile(r"what\s+does\s+it\s+cost\s+(\w+)", re.IGNORECASE),
re.compile(r"how\s+much\s+does\s+(\w+)\s+pay", re.IGNORECASE),
re.compile(r"what\s+is\s+the\s+total\s+cost(?:\s+for\s+(\w+))?", re.IGNORECASE),
)
_BUNDLE_TARIFF_RE: Final[re.Pattern[str]] = re.compile(
r"\$\s*(\d+(?:\.\d+)?)\s+per\s+day\s+or\s+\$\s*(\d+(?:\.\d+)?)\s+for\s+(\d+)\s+days?",
re.IGNORECASE,
)
_EARN_VERBS: Final[frozenset[str]] = frozenset(
{"earn", "earned", "make", "made", "receive", "received", "get", "got"}
)
_COST_VERBS: Final[frozenset[str]] = frozenset({"cost", "costs", "charge", "charges", "pay", "paid"})
_TEXT_BLOCKERS: Final[frozenset[str]] = frozenset(
{
"insurance",
"percent",
"percentage",
"profit",
"ticket",
"tickets",
"vet",
"amusement",
}
)
_QUESTION_BLOCKERS: Final[frozenset[str]] = frozenset({"long", "take"})
def _asks_money_total(question_clause: str) -> bool:
tokens = _tokens(question_clause)
if "money" in tokens and bool(_EARN_VERBS & tokens):
return True
if "how" in tokens and "much" in tokens and bool((_COST_VERBS | _EARN_VERBS) & tokens):
return True
if "what" in tokens and bool({"cost", "costs", "charge", "charges", "price", "pay", "paid"} & tokens):
return True
return False
def _question_money_subject(question_clause: str) -> str | None:
for pattern in _QUESTION_MONEY_SUBJECT_RE:
match = pattern.search(question_clause)
if match is None:
continue
subject = match.group(1)
if subject is None:
continue
return subject.lower()
return None
def _subjects_match(body_actor: str | None, asked_subject: str | None) -> bool:
if body_actor is None or asked_subject is None:
return False
if asked_subject in PRONOUNS:
return True
return asked_subject == body_actor.lower()
def _overtime_worker(problem_text: str, question_clause: str) -> str | None:
for clause in segment_clauses(problem_text):
if clause == question_clause:
continue
if _HOURLY_RATE_RE.search(clause) or re.search(
r"makes\s+\$\s*\d", clause, re.IGNORECASE
):
subject = leading_subject_token(clause)
if subject is not None:
return subject.lower()
for clause in segment_clauses(problem_text):
if clause == question_clause:
continue
tokens = _tokens(clause)
if "hours" not in tokens and "hour" not in tokens:
continue
if "every" not in tokens and "per" not in tokens and "each" not in tokens:
continue
subject = leading_subject_token(clause)
if subject is not None and subject.lower() not in {"if", "when"}:
return subject.lower()
return None
def _bundle_renter(problem_text: str, question_clause: str) -> str | None:
for clause in segment_clauses(problem_text):
if clause == question_clause:
continue
tokens = _tokens(clause)
if not (_RENT_VERBS & tokens):
continue
subject = leading_subject_token(clause)
if subject is not None:
return subject.lower()
return None
def _question_target_matches_actor(
problem_text: str, question_clause: str, *, pattern: str
) -> bool:
asked = _question_money_subject(question_clause)
if pattern == "overtime":
body_actor = _overtime_worker(problem_text, question_clause)
else:
body_actor = _bundle_renter(problem_text, question_clause)
if body_actor is None:
return False
if asked is None:
tokens = _tokens(question_clause)
return "what" in tokens and "total" in tokens and "cost" in tokens
return _subjects_match(body_actor, asked)
def _body_text(problem_text: str) -> str:
question_clause = _question_clause(problem_text)
return problem_text.replace(question_clause, "").strip()
def _has_hazard_surface(problem_text: str, question_clause: str) -> bool:
if _FRACTION_RE.search(problem_text) and "overtime" not in _tokens(problem_text):
return True
text_tokens = _tokens(problem_text)
question_tokens = _tokens(question_clause)
if "%" in problem_text or "percent" in text_tokens:
return True
if text_tokens & _TEXT_BLOCKERS:
return True
if question_tokens & _QUESTION_BLOCKERS:
return True
return False
def _half_fraction_operand(problem_text: str) -> Quantity | None:
match = re.search(r"1\s*/\s*2", problem_text)
if match is None:
return None
return Quantity(value=0.5, unit="", source_token=match.group(0).replace(" ", ""))
def _hourly_rate(problem_text: str) -> Quantity | None:
match = _HOURLY_RATE_RE.search(problem_text)
if match is None:
match = re.search(r"makes\s+\$\s*(\d+(?:\.\d+)?)", problem_text, re.IGNORECASE)
if match is None:
return None
token = match.group(1)
return Quantity(value=float(token), unit="dollars", source_token=token)
def _threshold_hours(problem_text: str) -> Quantity | None:
if _THRESHOLD_BAD_ANCHOR_RE.search(problem_text):
return None
match = _THRESHOLD_SHIFT_RE.search(problem_text)
if match is None:
return None
token = match.group(1)
return Quantity(value=float(token), unit="hours", source_token=token)
def _hours_per_day(problem_text: str, question_clause: str) -> Quantity | None:
threshold = _threshold_hours(problem_text)
for clause in segment_clauses(problem_text):
tokens = _tokens(clause)
if "hours" not in tokens and "hour" not in tokens:
continue
if "more" in tokens and "than" in tokens:
continue
if "every" not in tokens and "per" not in tokens and "each" not in tokens:
continue
quantities = [
q
for q in extract_quantities(clause)
if q.unit in {"hours", "hour"} or q.unit.rstrip("s") == "hour"
]
if len(quantities) != 1:
continue
q = quantities[0]
if threshold is not None and q.value <= threshold.value:
continue
return Quantity(value=q.value, unit="hours", source_token=q.source_token)
return None
def _day_count(problem_text: str, question_clause: str) -> Quantity | None:
for clause in segment_clauses(problem_text):
tokens = _tokens(clause)
if "days" not in tokens and "day" not in tokens:
continue
if clause == question_clause and "hours" not in tokens and "hour" not in tokens:
continue
quantities = [
q
for q in extract_quantities(clause)
if q.unit in {"days", "day"} or q.unit.rstrip("s") == "day"
]
if len(quantities) == 1:
q = quantities[0]
return Quantity(value=q.value, unit="days", source_token=q.source_token)
return None
def _rental_days(problem_text: str, question_clause: str) -> Quantity | None:
for clause in segment_clauses(problem_text):
if clause == question_clause:
continue
tokens = _tokens(clause)
if not (_RENT_VERBS & tokens):
continue
quantities = [
q
for q in extract_quantities(clause)
if q.unit in {"days", "day"} or q.unit.rstrip("s") == "day"
]
if len(quantities) == 1:
q = quantities[0]
return Quantity(value=q.value, unit="days", source_token=q.source_token)
return None
def _bundle_tariff(problem_text: str) -> tuple[Quantity, Quantity, Quantity] | None:
match = _BUNDLE_TARIFF_RE.search(problem_text)
if match is None:
return None
daily_token, bundle_token, bundle_days_token = (
match.group(1),
match.group(2),
match.group(3),
)
return (
Quantity(value=float(daily_token), unit="dollars", source_token=daily_token),
Quantity(value=float(bundle_token), unit="dollars", source_token=bundle_token),
Quantity(value=float(bundle_days_token), unit="days", source_token=bundle_days_token),
)
def build_overtime_shift_earnings(problem_text: str) -> GroundedDerivation | None:
"""Construct overtime shift earnings total, or ``None``."""
question_clause = _question_clause(problem_text)
if not _asks_money_total(question_clause):
return None
if not _question_target_matches_actor(problem_text, question_clause, pattern="overtime"):
return None
if _has_hazard_surface(problem_text, question_clause):
return None
if "overtime" not in _tokens(problem_text):
return None
rate = _hourly_rate(problem_text)
threshold = _threshold_hours(problem_text)
hours_per_day = _hours_per_day(problem_text, question_clause)
days = _day_count(problem_text, question_clause)
half = _half_fraction_operand(problem_text)
if None in (rate, threshold, hours_per_day, days, half):
return None
if hours_per_day.value <= threshold.value:
return None
ot_hours_per_day = hours_per_day.value - threshold.value
ot_rate = rate.value + (rate.value * half.value)
ot_total = ot_hours_per_day * ot_rate * days.value
return GroundedDerivation(
start=threshold,
steps=(
Step(op="multiply", operand=rate, cue="hour"),
Step(op="multiply", operand=days, cue="days"),
Step(
op="add",
operand=Quantity(
value=ot_total,
unit="hours",
source_token=hours_per_day.source_token,
),
cue="overtime",
),
),
)
def build_bundle_overflow_tariff(problem_text: str) -> GroundedDerivation | None:
"""Construct bundle + overflow-day tariff total, or ``None``."""
question_clause = _question_clause(problem_text)
if not _asks_money_total(question_clause):
return None
if not _question_target_matches_actor(problem_text, question_clause, pattern="bundle"):
return None
if _has_hazard_surface(problem_text, question_clause):
return None
if "overtime" in _tokens(problem_text):
return None
tariff = _bundle_tariff(problem_text)
rental_days = _rental_days(problem_text, question_clause)
if tariff is None or rental_days is None:
return None
daily_rate, bundle_price, bundle_days = tariff
if rental_days.value <= bundle_days.value:
return None
overflow_cost = (rental_days.value - bundle_days.value) * daily_rate.value
return GroundedDerivation(
start=bundle_price,
steps=(
Step(
op="add",
operand=Quantity(
value=overflow_cost,
unit="dollars",
source_token=rental_days.source_token,
),
cue="per",
),
),
)
def _self_verifies_temporal_tariff(
derivation: GroundedDerivation, problem_text: str, *, pattern: str
) -> SelfVerification:
reasons: list[str] = []
tokens = _tokens(problem_text)
question_clause = _question_clause(problem_text)
operands = [derivation.start, *(s.operand for s in derivation.steps)]
for q in operands:
if not _value_grounds(q.source_token, tokens):
reasons.append(f"operand {q.source_token!r} not grounded in text")
for step in derivation.steps:
if not _token_in(step.cue, tokens):
reasons.append(f"operation cue {step.cue!r} not grounded in text")
body = _body_text(problem_text)
body_quantities = Counter(q.source_token for q in extract_quantities(body))
used = Counter(
[derivation.start.source_token]
+ [step.operand.source_token for step in derivation.steps]
)
if pattern == "overtime":
half = _half_fraction_operand(problem_text)
if half is not None:
used[half.source_token] += 1
if half.source_token == "1/2":
used["2"] += 1
elif pattern == "bundle":
tariff = _bundle_tariff(problem_text)
if tariff is not None:
daily_rate, _, bundle_days = tariff
used[daily_rate.source_token] += 1
used[bundle_days.source_token] += 1
unused = body_quantities - used
if unused:
reasons.append(f"incomplete: unused body quantities {sorted(unused.elements())}")
if pattern == "overtime":
if _hourly_rate(problem_text) is None:
reasons.append("missing hourly rate anchor")
if _threshold_hours(problem_text) is None:
reasons.append("missing overtime threshold")
if _hours_per_day(problem_text, question_clause) is None:
reasons.append("missing hours-per-day anchor")
if _day_count(problem_text, question_clause) is None:
reasons.append("missing day-count anchor")
if _half_fraction_operand(problem_text) is None:
reasons.append("missing overtime half-fraction anchor")
elif pattern == "bundle":
if _bundle_tariff(problem_text) is None:
reasons.append("missing bundle tariff anchor")
if _rental_days(problem_text, question_clause) is None:
reasons.append("missing rental-day question anchor")
return SelfVerification(verified=not reasons, reasons=tuple(reasons))
def _compose_pattern(
builder, problem_text: str, *, pattern: str
) -> Resolution | None:
derivation = builder(problem_text)
if derivation is None:
return None
if not _self_verifies_temporal_tariff(derivation, problem_text, pattern=pattern).verified:
return None
return Resolution(
answer=derivation.answer,
answer_unit=derivation.answer_unit,
derivation=derivation,
)
def compose_overtime_shift_earnings(problem_text: str) -> Resolution | None:
return _compose_pattern(build_overtime_shift_earnings, problem_text, pattern="overtime")
def compose_bundle_overflow_tariff(problem_text: str) -> Resolution | None:
return _compose_pattern(build_bundle_overflow_tariff, problem_text, pattern="bundle")
def compose_temporal_tariff(problem_text: str) -> Resolution | None:
"""Try overtime then bundle overflow; refuse on ambiguity."""
overtime = compose_overtime_shift_earnings(problem_text)
bundle = compose_bundle_overflow_tariff(problem_text)
if overtime is not None and bundle is not None:
return None
return overtime or bundle
def resolve_promotable_temporal_tariff(problem_text: str) -> Resolution | None:
"""Serving promotion bridge (Gate A2m)."""
return compose_temporal_tariff(problem_text)