"""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 `` 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)