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.
198 lines
No EOL
6.7 KiB
Python
198 lines
No EOL
6.7 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,
|
||
)
|
||
_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,
|
||
)
|
||
_TEXT_BLOCKERS: Final[frozenset[str]] = frozenset(
|
||
{
|
||
"doubled",
|
||
"insurance",
|
||
"percent",
|
||
"percentage",
|
||
"profit",
|
||
"weight",
|
||
"weighed",
|
||
"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 _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 & _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."""
|
||
factors: list[tuple[float, str, str]] = []
|
||
for match in _SCALE_LONGER_RE.finditer(problem_text):
|
||
factors.append((float(match.group(1)), match.group(1), "longer"))
|
||
for match in _SCALE_PREVIOUS_RE.finditer(problem_text):
|
||
factors.append((float(match.group(1)), match.group(1), "previous"))
|
||
if not factors:
|
||
return []
|
||
# Re-sort by position in text to preserve narrative order when both patterns appear.
|
||
ordered: list[tuple[float, str, str, int]] = []
|
||
for match in _SCALE_LONGER_RE.finditer(problem_text):
|
||
ordered.append((float(match.group(1)), match.group(1), "longer", match.start()))
|
||
for match in _SCALE_PREVIOUS_RE.finditer(problem_text):
|
||
ordered.append(
|
||
(float(match.group(1)), match.group(1), "previous", match.start())
|
||
)
|
||
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 _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) |