* 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
228 lines
8 KiB
Python
228 lines
8 KiB
Python
"""Gate A2h — survey rate earnings (survey-count sum × questions/survey × $/question).
|
||
|
||
Experience Flywheel Sprint 6: ``multiplicative_aggregate`` cluster had recognizer
|
||
hits but no sealed-correct lift signal; case **0045** decomposes cleanly as
|
||
``(surveys_mon + surveys_tue) × questions_per_survey × rate_per_question`` with
|
||
explicit question money binding.
|
||
|
||
Narrow organ — not broad product_bridge, not generic multiplication over
|
||
co-occurring numbers. Promotion requires:
|
||
|
||
- question asks ``money`` + ``earn``/``make``/``receive``;
|
||
- body states rate per question (``$`` + ``every``/``per`` + ``question``);
|
||
- body states ``each/every survey has N questions``;
|
||
- exactly two day-indexed survey counts that sum;
|
||
- hazard refusal (fractions, percent, profit, comparative distractors).
|
||
|
||
Question-clause temporal scaffolding (e.g. ``two days``) is excluded from the
|
||
completeness obligation — Mon/Tue survey counts already decompose the window.
|
||
|
||
Deterministic; sealed module (no ``chat/`` import).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from collections import Counter
|
||
import re
|
||
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.target import _question_clause
|
||
from generate.derivation.verify import Resolution, SelfVerification, select_self_verified
|
||
from generate.math_roundtrip import _token_in, _tokens, _value_grounds
|
||
|
||
_FRACTION_RE: Final[re.Pattern[str]] = re.compile(r"\d+/\d+")
|
||
_EARN_VERBS: Final[frozenset[str]] = frozenset(
|
||
{"earn", "earned", "make", "made", "receive", "received", "get", "got"}
|
||
)
|
||
_TEXT_BLOCKERS: Final[frozenset[str]] = frozenset(
|
||
{
|
||
"half",
|
||
"insurance",
|
||
"percent",
|
||
"percentage",
|
||
"profit",
|
||
"twice",
|
||
"thrice",
|
||
}
|
||
)
|
||
_QUESTION_BLOCKERS: Final[frozenset[str]] = frozenset(
|
||
{"left", "remaining", "profit"}
|
||
)
|
||
|
||
|
||
def _asks_money_earned(question_clause: str) -> bool:
|
||
tokens = _tokens(question_clause)
|
||
return "money" in tokens and bool(_EARN_VERBS & 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:
|
||
return True
|
||
if text_tokens & _TEXT_BLOCKERS:
|
||
return True
|
||
if question_tokens & _QUESTION_BLOCKERS:
|
||
return True
|
||
return False
|
||
|
||
|
||
def _body_text(problem_text: str) -> str:
|
||
question_clause = _question_clause(problem_text)
|
||
return problem_text.replace(question_clause, "").strip()
|
||
|
||
|
||
def _clause_mentions_survey(clause: str) -> bool:
|
||
tokens = _tokens(clause)
|
||
return "survey" in tokens or "surveys" in tokens
|
||
|
||
|
||
def _survey_counts(problem_text: str) -> tuple[Quantity, Quantity] | None:
|
||
counts: list[Quantity] = []
|
||
question_clause = _question_clause(problem_text)
|
||
for clause in segment_clauses(problem_text):
|
||
if clause == question_clause:
|
||
continue
|
||
if not _clause_mentions_survey(clause):
|
||
continue
|
||
for q in extract_quantities(clause):
|
||
if q.unit == "surveys" or q.unit.rstrip("s") == "survey":
|
||
counts.append(
|
||
Quantity(value=q.value, unit="surveys", source_token=q.source_token)
|
||
)
|
||
if len(counts) != 2:
|
||
return None
|
||
return counts[0], counts[1]
|
||
|
||
|
||
def _questions_per_survey(problem_text: str) -> Quantity | None:
|
||
question_clause = _question_clause(problem_text)
|
||
for clause in segment_clauses(problem_text):
|
||
if clause == question_clause:
|
||
continue
|
||
if not _clause_mentions_survey(clause):
|
||
continue
|
||
if not ("each" in _tokens(clause) or "every" in _tokens(clause)):
|
||
continue
|
||
if "questions" not in _tokens(clause) and "question" not in _tokens(clause):
|
||
continue
|
||
quantities = extract_quantities(clause)
|
||
if len(quantities) != 1:
|
||
continue
|
||
q = quantities[0]
|
||
if q.unit not in {"questions", "question"} and q.unit.rstrip("s") != "question":
|
||
continue
|
||
return Quantity(value=q.value, unit="questions", source_token=q.source_token)
|
||
return None
|
||
|
||
|
||
def _rate_per_question(problem_text: str) -> tuple[Quantity, str] | None:
|
||
question_clause = _question_clause(problem_text)
|
||
for clause in segment_clauses(problem_text):
|
||
if clause == question_clause:
|
||
continue
|
||
if "$" not in clause and "dollar" not in _tokens(clause):
|
||
continue
|
||
if "question" not in _tokens(clause):
|
||
continue
|
||
rate_cue = next(
|
||
(c for c in ("every", "per", "each") if c in _tokens(clause)),
|
||
None,
|
||
)
|
||
if rate_cue is None:
|
||
continue
|
||
quantities = extract_quantities(clause)
|
||
if len(quantities) != 1:
|
||
continue
|
||
q = quantities[0]
|
||
return Quantity(value=q.value, unit="dollars", source_token=q.source_token), rate_cue
|
||
return None
|
||
|
||
|
||
def build_survey_rate_earnings(problem_text: str) -> GroundedDerivation | None:
|
||
"""Construct the ungated survey earnings chain, or ``None``."""
|
||
question_clause = _question_clause(problem_text)
|
||
if not _asks_money_earned(question_clause):
|
||
return None
|
||
if _has_hazard_surface(problem_text, question_clause):
|
||
return None
|
||
|
||
survey_pair = _survey_counts(problem_text)
|
||
per_survey = _questions_per_survey(problem_text)
|
||
rate_info = _rate_per_question(problem_text)
|
||
if survey_pair is None or per_survey is None or rate_info is None:
|
||
return None
|
||
|
||
count_a, count_b = survey_pair
|
||
rate, rate_cue = rate_info
|
||
sum_cue = "and" if "and" in _tokens(problem_text) else None
|
||
if sum_cue is None:
|
||
return None
|
||
has_cue = next((c for c in ("has", "have", "contains") if c in _tokens(problem_text)), None)
|
||
if has_cue is None:
|
||
return None
|
||
|
||
return GroundedDerivation(
|
||
start=count_a,
|
||
steps=(
|
||
Step(op="add", operand=count_b, cue=sum_cue),
|
||
Step(op="multiply", operand=per_survey, cue=has_cue),
|
||
Step(op="multiply", operand=rate, cue=rate_cue),
|
||
),
|
||
)
|
||
|
||
|
||
def _self_verifies_survey_earnings(
|
||
derivation: GroundedDerivation, problem_text: str
|
||
) -> SelfVerification:
|
||
"""Self-verify with body-scoped completeness (question ``days`` scaffolding)."""
|
||
from generate.derivation.verify import _base_reasons
|
||
|
||
tokens = _tokens(problem_text)
|
||
reasons = list(_base_reasons(derivation, tokens))
|
||
|
||
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),
|
||
]
|
||
)
|
||
unused = body_quantities - used
|
||
if unused:
|
||
reasons.append(f"incomplete: unused body quantities {sorted(unused.elements())}")
|
||
|
||
for step in derivation.steps:
|
||
if not _token_in(step.cue, tokens):
|
||
reasons.append(f"operation cue {step.cue!r} not grounded in text")
|
||
if not _value_grounds(derivation.start.source_token, tokens):
|
||
reasons.append(
|
||
f"operand {derivation.start.source_token!r} not grounded in text"
|
||
)
|
||
|
||
return SelfVerification(verified=not reasons, reasons=tuple(reasons))
|
||
|
||
|
||
def compose_survey_rate_earnings(problem_text: str) -> Resolution | None:
|
||
"""Gate the typed survey earnings chain through self-verification."""
|
||
derivation = build_survey_rate_earnings(problem_text)
|
||
if derivation is None:
|
||
return None
|
||
if not _self_verifies_survey_earnings(derivation, problem_text).verified:
|
||
return None
|
||
return Resolution(
|
||
answer=derivation.answer,
|
||
answer_unit=derivation.answer_unit,
|
||
derivation=derivation,
|
||
)
|
||
|
||
|
||
def resolve_promotable_survey_rate_earnings(problem_text: str) -> Resolution | None:
|
||
"""Serving promotion bridge (Gate A2h)."""
|
||
return compose_survey_rate_earnings(problem_text)
|