* feat(derivation): capability paradigm sprint 10 frontier lift Add Gate A2o affine_comparative_inversion_total (0009) and Gate A2p sequential_comparative_scale (0006). Reject wholesale multiplicative_aggregate and defer 0013 piecewise calendar until month day-count grounds in text. Serving: 21/29/0 → 23/27/0, wrong=0 preserved. report.json and sealed artifacts untouched. * fix(gsm8k): bind affine inversion total question subject * fix(gsm8k): bind sequential scale page question subject * test(gsm8k): cover sprint10 subject binding * fix(gsm8k): license affine inversion aggregate units * fix(gsm8k): bind sequential scale factors to reading chain * test(gsm8k): cover sprint10 aggregate and scale confusers
254 lines
8.6 KiB
Python
254 lines
8.6 KiB
Python
"""Gate A2p — sequential comparative scale (running quantity × scale factors).
|
||
|
||
Sprint 10: train_sample **0006** — an initial quantity plus an ordered chain of
|
||
``N times longer`` / ``times the previous length`` scale factors applied to a
|
||
running state.
|
||
|
||
Chain:
|
||
|
||
answer = initial × scale₁ × scale₂ × … × scaleₙ
|
||
|
||
Narrow organ — not broad ``multiplicative_aggregate``, not age-timeline parsing,
|
||
not generic nearby-number multiplication. Promotion requires:
|
||
|
||
- question asks ``how many pages``;
|
||
- body states an initial ``<N> pages`` anchor;
|
||
- at least two scale clauses with ``times longer`` or ``times the previous length``;
|
||
- hazard refusal (fractions, percent, money, goal language, ``doubled`` surfaces);
|
||
- age/year scaffolding excluded from completeness obligation.
|
||
|
||
Deterministic; sealed module (no ``chat/`` import).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from typing import Final
|
||
|
||
from collections import Counter
|
||
|
||
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
|
||
from generate.math_roundtrip import _token_in, _tokens, _value_grounds
|
||
|
||
_FRACTION_RE: Final[re.Pattern[str]] = re.compile(r"\d+\s*/\s*\d+")
|
||
_INITIAL_PAGES_RE: Final[re.Pattern[str]] = re.compile(
|
||
r"(\d+)\s+pages\b",
|
||
re.IGNORECASE,
|
||
)
|
||
_READER_RE: Final[re.Pattern[str]] = re.compile(
|
||
r"\b(\w+)\s+started\s+reading\b",
|
||
re.IGNORECASE,
|
||
)
|
||
_QUESTION_READER_PATTERNS: Final[tuple[re.Pattern[str], ...]] = (
|
||
re.compile(r"\bbooks\s+(\w+)\s+reads\b", re.IGNORECASE),
|
||
re.compile(r"\b(\w+)'s\s+books\b", re.IGNORECASE),
|
||
re.compile(r"\bdoes\s+(\w+)\s+read\b", re.IGNORECASE),
|
||
)
|
||
_PRONOUN_SUBJECTS: Final[frozenset[str]] = frozenset(
|
||
{"he", "she", "they", "them", "him", "her", "it", "we", "you", "i"}
|
||
)
|
||
_SCALE_LONGER_RE: Final[re.Pattern[str]] = re.compile(
|
||
r"(\d+)\s+times\s+longer\b",
|
||
re.IGNORECASE,
|
||
)
|
||
_SCALE_PREVIOUS_RE: Final[re.Pattern[str]] = re.compile(
|
||
r"(\d+)\s+times\s+the\s+previous\s+length\b",
|
||
re.IGNORECASE,
|
||
)
|
||
_READING_CHAIN_TOKENS: Final[frozenset[str]] = frozenset(
|
||
{"book", "books", "comic", "comics", "novel", "novels", "story", "stories"}
|
||
)
|
||
_TEXT_BLOCKERS: Final[frozenset[str]] = frozenset(
|
||
{
|
||
"doubled",
|
||
"insurance",
|
||
"percent",
|
||
"percentage",
|
||
"profit",
|
||
"profits",
|
||
"weight",
|
||
"weighed",
|
||
"weighs",
|
||
"weighing",
|
||
"pounds",
|
||
"pound",
|
||
}
|
||
)
|
||
_GOAL_INTENT: Final[frozenset[str]] = frozenset(
|
||
{"want", "wants", "wanted", "need", "needs", "goal", "plans"}
|
||
)
|
||
_SCALE_OBLIGATION_UNITS: Final[frozenset[str]] = frozenset({"pages", "times"})
|
||
|
||
|
||
def _asks_page_count(question_clause: str) -> bool:
|
||
tokens = _tokens(question_clause)
|
||
return "how" in tokens and "many" in tokens and "pages" in tokens
|
||
|
||
|
||
def _reader(problem_text: str) -> str | None:
|
||
match = _READER_RE.search(problem_text)
|
||
if match is None:
|
||
return None
|
||
return match.group(1).lower()
|
||
|
||
|
||
def _explicit_page_question_reader(question_clause: str) -> str | None:
|
||
for pattern in _QUESTION_READER_PATTERNS:
|
||
match = pattern.search(question_clause)
|
||
if match is None:
|
||
continue
|
||
subject = match.group(1).lower()
|
||
if subject not in _PRONOUN_SUBJECTS:
|
||
return subject
|
||
return None
|
||
|
||
|
||
def _question_target_matches_reader(problem_text: str, question_clause: str) -> bool:
|
||
explicit_reader = _explicit_page_question_reader(question_clause)
|
||
if explicit_reader is None:
|
||
return True
|
||
body_reader = _reader(problem_text)
|
||
return body_reader is not None and explicit_reader == body_reader
|
||
|
||
|
||
def _clause_around(problem_text: str, start: int, end: int) -> str:
|
||
left_candidates = [problem_text.rfind(mark, 0, start) for mark in ".?!"]
|
||
left = max(left_candidates)
|
||
right_candidates = [
|
||
idx for mark in ".?!" if (idx := problem_text.find(mark, end)) != -1
|
||
]
|
||
right = min(right_candidates) if right_candidates else len(problem_text)
|
||
return problem_text[left + 1 : right]
|
||
|
||
|
||
def _scale_clause_is_reading_chain(problem_text: str, start: int, end: int) -> bool:
|
||
return bool(_tokens(_clause_around(problem_text, start, end)) & _READING_CHAIN_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:
|
||
return True
|
||
if text_tokens & _TEXT_BLOCKERS:
|
||
return True
|
||
if question_tokens & _GOAL_INTENT:
|
||
return True
|
||
if "$" in problem_text:
|
||
return True
|
||
return False
|
||
|
||
|
||
def _initial_pages(problem_text: str) -> Quantity | None:
|
||
match = _INITIAL_PAGES_RE.search(problem_text)
|
||
if match is None:
|
||
return None
|
||
value = float(match.group(1))
|
||
return Quantity(value=value, unit="pages", source_token=match.group(1))
|
||
|
||
|
||
def _scale_factors_in_order(problem_text: str) -> list[tuple[float, str, str]]:
|
||
"""Return ``(value, source_token, cue)`` for each scale clause in narrative order."""
|
||
ordered: list[tuple[float, str, str, int]] = []
|
||
for match in _SCALE_LONGER_RE.finditer(problem_text):
|
||
if _scale_clause_is_reading_chain(problem_text, match.start(), match.end()):
|
||
ordered.append((float(match.group(1)), match.group(1), "longer", match.start()))
|
||
for match in _SCALE_PREVIOUS_RE.finditer(problem_text):
|
||
if _scale_clause_is_reading_chain(problem_text, match.start(), match.end()):
|
||
ordered.append(
|
||
(float(match.group(1)), match.group(1), "previous", match.start())
|
||
)
|
||
if not ordered:
|
||
return []
|
||
ordered.sort(key=lambda item: item[3])
|
||
return [(v, token, cue) for v, token, cue, _ in ordered]
|
||
|
||
|
||
def build_sequential_comparative_scale(problem_text: str) -> GroundedDerivation | None:
|
||
"""Construct the ungated sequential scale chain, or ``None``."""
|
||
question_clause = _question_clause(problem_text)
|
||
if not _asks_page_count(question_clause):
|
||
return None
|
||
if not _question_target_matches_reader(problem_text, question_clause):
|
||
return None
|
||
if _has_hazard_surface(problem_text, question_clause):
|
||
return None
|
||
|
||
initial = _initial_pages(problem_text)
|
||
factors = _scale_factors_in_order(problem_text)
|
||
if initial is None or len(factors) < 2:
|
||
return None
|
||
|
||
steps = tuple(
|
||
Step(
|
||
op="multiply",
|
||
operand=Quantity(value=value, unit="times", source_token=token),
|
||
cue=cue,
|
||
)
|
||
for value, token, cue in factors
|
||
)
|
||
return GroundedDerivation(start=initial, steps=steps)
|
||
|
||
|
||
def _obligation_quantities(problem_text: str) -> Counter[str]:
|
||
return Counter(
|
||
q.source_token
|
||
for q in extract_quantities(problem_text)
|
||
if q.unit in _SCALE_OBLIGATION_UNITS
|
||
)
|
||
|
||
|
||
def _self_verifies_sequential_scale(
|
||
derivation: GroundedDerivation, problem_text: str
|
||
) -> SelfVerification:
|
||
from generate.derivation.verify import _base_reasons
|
||
|
||
tokens = _tokens(problem_text)
|
||
reasons = list(_base_reasons(derivation, tokens))
|
||
|
||
for step in derivation.steps:
|
||
if not _token_in(step.cue, tokens):
|
||
reasons.append(f"operation cue {step.cue!r} not grounded in text")
|
||
|
||
obligation = _obligation_quantities(problem_text)
|
||
used = Counter(
|
||
[
|
||
derivation.start.source_token,
|
||
*(step.operand.source_token for step in derivation.steps),
|
||
]
|
||
)
|
||
unused = obligation - used
|
||
if unused:
|
||
reasons.append(f"incomplete: unused scale quantities {sorted(unused.keys())}")
|
||
|
||
initial = _initial_pages(problem_text)
|
||
if initial is None or not _value_grounds(initial.source_token, tokens):
|
||
reasons.append("missing grounded initial pages anchor")
|
||
|
||
return SelfVerification(verified=not reasons, reasons=tuple(reasons))
|
||
|
||
|
||
def compose_sequential_comparative_scale(problem_text: str) -> Resolution | None:
|
||
"""Gate the typed sequential scale chain through self-verification."""
|
||
derivation = build_sequential_comparative_scale(problem_text)
|
||
if derivation is None:
|
||
return None
|
||
if not _self_verifies_sequential_scale(derivation, problem_text).verified:
|
||
return None
|
||
return Resolution(
|
||
answer=derivation.answer,
|
||
answer_unit=derivation.answer_unit,
|
||
derivation=derivation,
|
||
)
|
||
|
||
|
||
def resolve_promotable_sequential_comparative_scale(
|
||
problem_text: str,
|
||
) -> Resolution | None:
|
||
"""Serving promotion bridge (Gate A2p)."""
|
||
return compose_sequential_comparative_scale(problem_text)
|