core/generate/derivation/survey_rate_earnings.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

228 lines
8 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 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)