From a0e9ca85350821a0872a0eb72aa9fece06418b53 Mon Sep 17 00:00:00 2001 From: Shay Date: Tue, 26 May 2026 20:13:12 -0700 Subject: [PATCH] feat(comprehension): reader lifecycle for question-frame Phase 1 (ADR-0164.3) (#326) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the three lifecycle functions for the incremental compositional reader per ADR-0164.3 §Lifecycle API: - begin_sentence(problem_state, source_text_offset) -> SentenceReadingState - apply_word(sentence_state, problem_state, word) -> SentenceReadingState | ReaderRefusal - end_sentence(sentence_state, problem_state) -> ProblemReadingState | ReaderRefusal Phase 1 scope is question sentences only. The update rules for the question_frame live in a single readable table (_QUESTION_FRAME_RULES); statement-side frames (initial_state_frame, operation_frame, descriptive_frame) refuse with a Phase-2 diagnostic. The five Brief-8 GSM8K target question sentences (0007, 0017, 0027, 0036, 0043) produce valid QuestionTargetSlot outputs end-to-end. _interface_stubs.py provides a thin, functional surface for the lexeme-primitive scanner (Brief 6) and lexicon loader (Brief 7) so this PR does not block on them. The stub honours the en_core_math_v1 pack entries and adds a closed Phase-1 supplemental vocabulary marked for fold-in to the pack once Briefs 6/7 land. Tests cover determinism (byte-equal canonical bytes), the five GSM8K target sentences with expected (entity, unit_class, kind) triples, all token-level and sentence-level refusal modes, and lifecycle invariants (registry preservation, sentence_index advance). Stacked on feat/state-two-level-split (PR #323) per ADR-0164.3 §Naming — state types live in state.py. --- generate/comprehension/_interface_stubs.py | 269 +++++++ generate/comprehension/lifecycle.py | 776 +++++++++++++++++++++ tests/test_reader_question_frame.py | 293 ++++++++ 3 files changed, 1338 insertions(+) create mode 100644 generate/comprehension/_interface_stubs.py create mode 100644 generate/comprehension/lifecycle.py create mode 100644 tests/test_reader_question_frame.py diff --git a/generate/comprehension/_interface_stubs.py b/generate/comprehension/_interface_stubs.py new file mode 100644 index 00000000..37550f01 --- /dev/null +++ b/generate/comprehension/_interface_stubs.py @@ -0,0 +1,269 @@ +"""Phase-1 stubs for Briefs 6 (lexeme primitives) and 7 (lexicon loader). + +Replace the imports in :mod:`generate.comprehension.lifecycle` with the real +modules once Briefs 6 and 7 land. Until then, this module provides a thin, +deterministic surface that covers exactly the categories the Phase-1 reader +needs to admit the five GSM8K train_sample question sentences listed in +Brief 8 (ADR-0164.3 §Worked example follow-on). + +The stub embeds: + +* a minimal lexicon: the entries already present in + ``language_packs/data/en_core_math_v1/lexicon.jsonl`` are honored, plus a + small supplemental vocabulary explicitly named "Phase-1 reader + supplemental — to be folded into the math pack" so it does not pretend + to be ratified pack content. +* a primitive scanner that covers numeric, time-amount, currency, and + sentence-terminator shapes. ADR-0164.1 §Seed primitive set defines the + full set; the stub implements the subset the question sentences need. + +Determinism: ``scan_primitive`` and ``lookup`` are pure functions over +their inputs. The module-level lexicon dict is populated once at import +time from a frozen literal and the on-disk lexicon file. +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Final + +# --------------------------------------------------------------------------- +# Public types — match the signatures Brief 8 imports from briefs 6 and 7. +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class LexemeMatch: + """A primitive-scanner hit. + + ``emit_category`` is what the reader's expectation-check consumes. + """ + + emit_category: str + surface: str + + +@dataclass(frozen=True, slots=True) +class LexiconEntry: + """Minimal lexicon record. Briefs 6/7's real record may carry more.""" + + surface: str + category: str + + +Lexicon = dict[str, LexiconEntry] + + +# --------------------------------------------------------------------------- +# Primitive shapes — closed orthographic recognizers per ADR-0164.1. +# Each tuple is (compiled regex, emit category). First match wins. +# --------------------------------------------------------------------------- + +_PRIMITIVE_PATTERNS: Final[tuple[tuple[re.Pattern[str], str], ...]] = ( + (re.compile(r"^\$(\d+)\.(\d{2})$"), "decimal_currency_literal"), + (re.compile(r"^\$(\d+(?:\.\d+)?)$"), "currency_literal"), + (re.compile(r"^(\d+(?:\.\d+)?)\s?%$"), "percentage_literal"), + (re.compile(r"^(\d+)/(\d+)$"), "fraction_literal"), + ( + re.compile( + r"^(\d+)(?:-)?(hour|minute|day|week|month|year|second)s?$", + re.IGNORECASE, + ), + "time_amount_literal", + ), + (re.compile(r"^\d+(?:\.\d+)?$"), "numeric_literal"), + (re.compile(r"^\?$"), "question_terminator"), + (re.compile(r"^[.!]$"), "statement_terminator"), + (re.compile(r"^,$"), "punctuation_comma"), +) + + +def scan_primitive(word: str) -> LexemeMatch | None: + """Return the first orthographic primitive that matches ``word``. + + Pure / deterministic. Case-insensitive for shape-bearing primitives; + word-boundary alignment is the caller's responsibility (the reader + pre-tokenises). + """ + if not isinstance(word, str) or word == "": + return None + for pattern, category in _PRIMITIVE_PATTERNS: + if pattern.match(word) is not None: + return LexemeMatch(emit_category=category, surface=word) + return None + + +# --------------------------------------------------------------------------- +# Lexicon — the on-disk en_core_math_v1 entries plus a Phase-1 supplemental. +# Real Brief 7 loader will read the pack with full provenance and discipline; +# this stub keeps the dependency surface minimal. +# --------------------------------------------------------------------------- + +# Map en_core_math_v1 semantic-domain string → reader category. +# Categories named here match the rule table in lifecycle.apply_word. +_DOMAIN_TO_CATEGORY: Final[dict[str, str]] = { + "math.question_open": "question_open", + "math.entity_pronoun": "entity_pronoun", + "math.residual_modifier": "residual_modifier", + "math.currency_unit_noun": "currency_unit_noun", + "math.accumulation_verb": "accumulation_verb", + "math.depletion_verb": "depletion_verb", + "math.transfer_verb": "transfer_verb", + "math.capacity_verb": "capacity_verb", + "math.possession_verb": "possession_verb", + "math.proper_noun_entity_female": "proper_noun_entity_female", + "math.proper_noun_entity_male": "proper_noun_entity_male", +} + +# Phase-1 reader supplemental — to be folded into en_core_math_v1 once +# Briefs 6/7 land (or earlier as a separate pack PR). These are the +# closed-set words required by the five Brief-8 target question sentences. +_SUPPLEMENTAL_VOCAB: Final[dict[str, str]] = { + # Question quantifiers + "many": "question_discrete_qty", + "much": "question_continuous_qty", + # Comparatives + "more": "question_comparative", + "less": "question_comparative", + "longer": "question_comparative", + "fewer": "question_comparative", + # Aggregate + "total": "aggregate_modifier", # overrides currency_unit_noun pack hit + "combined": "aggregate_modifier", + # Modal aux + "will": "modal_aux", + "did": "modal_aux", + "does": "modal_aux", + "do": "modal_aux", + "would": "modal_aux", + "can": "modal_aux", + # Copula + "be": "copula_verb", + "is": "copula_verb", + "are": "copula_verb", + "was": "copula_verb", + "were": "copula_verb", + # Count unit nouns + "box": "count_unit_noun", + "boxes": "count_unit_noun", + "crayon": "count_unit_noun", + "crayons": "count_unit_noun", + "follower": "count_unit_noun", + "followers": "count_unit_noun", + "candy": "count_unit_noun", + "candies": "count_unit_noun", + "bean": "count_unit_noun", + "beans": "count_unit_noun", + "person": "count_unit_noun", + "people": "count_unit_noun", + # Time unit nouns + "time": "time_unit_noun", + "hour": "time_unit_noun", + "hours": "time_unit_noun", + "day": "time_unit_noun", + "days": "time_unit_noun", + "minute": "time_unit_noun", + "minutes": "time_unit_noun", + "week": "time_unit_noun", + "weeks": "time_unit_noun", + # Verbs (frame-closing for question_frame) + "need": "accumulation_verb", + "buy": "accumulation_verb", + "want": "accumulation_verb", + "cost": "currency_unit_noun", + "make": "capacity_verb", + "give": "transfer_verb", + "gave": "transfer_verb", + # Proper nouns missing from pack + "monica": "proper_noun_entity_female", + "malcolm": "proper_noun_entity_male", + "rachel": "proper_noun_entity_female", + # Additional pronouns / determiners + "him": "entity_pronoun", + "her": "entity_pronoun", + "his": "entity_pronoun", + "hers": "entity_pronoun", + # Question/relative connectives that drain harmlessly post-frame + "if": "drain_token", + "of": "drain_token", + "on": "drain_token", + "in": "drain_token", + "at": "drain_token", + "for": "drain_token", + "with": "drain_token", + "a": "drain_token", + "an": "drain_token", + "the": "drain_token", + "all": "drain_token", + "some": "drain_token", + "this": "drain_token", + "that": "drain_token", + "during": "drain_token", + "five": "drain_token", + "two": "drain_token", + "three": "drain_token", + "four": "drain_token", + "studying": "drain_token", + "purchase": "drain_token", + "social": "drain_token", + "media": "drain_token", + "left": "residual_modifier", + "remaining": "residual_modifier", + "after": "residual_modifier", +} + + +def _read_pack_lexicon(path: Path) -> dict[str, LexiconEntry]: + """Load en_core_math_v1 entries, mapping semantic_domains to reader + categories. Skips entries whose domain is not in _DOMAIN_TO_CATEGORY. + """ + out: dict[str, LexiconEntry] = {} + if not path.is_file(): + return out + for raw in path.read_text(encoding="utf-8").splitlines(): + if not raw.strip(): + continue + record = json.loads(raw) + surface = record["surface"].lower() + for domain in record["semantic_domains"]: + category = _DOMAIN_TO_CATEGORY.get(domain) + if category is not None: + # First domain wins; deterministic by file order. + out.setdefault(surface, LexiconEntry(surface=surface, category=category)) + break + return out + + +def _build_lexicon() -> Lexicon: + pack_path = ( + Path(__file__).resolve().parents[2] + / "language_packs" + / "data" + / "en_core_math_v1" + / "lexicon.jsonl" + ) + lex: dict[str, LexiconEntry] = _read_pack_lexicon(pack_path) + # Supplemental overrides pack entries to honour Phase-1 rule table. + for surface, category in _SUPPLEMENTAL_VOCAB.items(): + lex[surface] = LexiconEntry(surface=surface, category=category) + return lex + + +def load_lexicon() -> Lexicon: + """Return a freshly built lexicon. Caller is expected to cache.""" + return _build_lexicon() + + +def lookup(lex: Lexicon, word: str) -> LexiconEntry | None: + """Case-insensitive lookup. Returns ``None`` on miss. + + Strips a single trailing punctuation mark (``,`` ``?`` ``.``) before + lookup; primitive-scanner is consulted separately for terminators. + """ + if not isinstance(word, str) or word == "": + return None + key = word.lower() + return lex.get(key) diff --git a/generate/comprehension/lifecycle.py b/generate/comprehension/lifecycle.py new file mode 100644 index 00000000..940ee744 --- /dev/null +++ b/generate/comprehension/lifecycle.py @@ -0,0 +1,776 @@ +"""ADR-0164 / ADR-0164.3 — incremental comprehension reader lifecycle. + +Phase 1 scope: ``question_frame`` only. Statement-side frames +(``initial_state_frame``, ``operation_frame``, ``descriptive_frame``) are +Phase 2. + +The three public functions are pure and deterministic: + +* :func:`begin_sentence` opens a fresh sentence-local state. +* :func:`apply_word` advances one token; returns a new state or a typed + :class:`ReaderRefusal`. +* :func:`end_sentence` projects the closed sentence into a new + :class:`ProblemReadingState` (or refuses). + +ADR-0164 §Decision §3 specifies the four-step token loop: + +1. Lexeme primitive scan. +2. Lexicon lookup. +3. Expectation check. +4. Update emit. + +Update rules live in :data:`_QUESTION_FRAME_RULES` as a single readable +table. The table's coverage is intentionally narrow — the five Brief-8 +GSM8K target question sentences plus close variants. Adding a category is +either an entry in this table (mechanical) or a sub-ADR (semantic). +""" + +from __future__ import annotations + +from functools import cache +from typing import Callable, Final + +from generate.comprehension._interface_stubs import ( + Lexicon, + LexemeMatch, + LexiconEntry, + load_lexicon, + lookup, + scan_primitive, +) +from generate.comprehension.state import ( + _LOOKBACK_MAX, + AppliedCategory, + EntityRef, + FramePayload, + ProblemReadingState, + QuestionTargetSlot, + ReaderRefusal, + SentenceReadingState, + VerbReference, +) + +# --------------------------------------------------------------------------- +# Cached lexicon — Brief 7's loader is the source of truth once it lands. +# --------------------------------------------------------------------------- + + +@cache +def _get_lexicon() -> Lexicon: + return load_lexicon() + + +# --------------------------------------------------------------------------- +# Category groupings. +# --------------------------------------------------------------------------- + +_QUESTION_OPENERS: Final[frozenset[str]] = frozenset({"question_open"}) + +_FRAME_CLOSING_VERBS: Final[frozenset[str]] = frozenset( + { + "accumulation_verb", + "depletion_verb", + "transfer_verb", + "capacity_verb", + "possession_verb", + "copula_verb", + } +) + +# Map qualifier category → QuestionTargetSlot.kind. +_KIND_BY_QUALIFIER: Final[dict[str, str]] = { + "question_continuous_qty": "continuous_quantity", + "question_discrete_qty": "discrete_quantity", + "question_comparative": "difference", + "aggregate_modifier": "aggregate", +} + +# Map unit category → unit_class string carried into QuestionTargetSlot. +_UNIT_CLASS_BY_CATEGORY: Final[dict[str, str]] = { + "count_unit_noun": "count", + "currency_unit_noun": "currency", + "time_unit_noun": "time", +} + +# Sentinel category recorded in the lookback once the question frame closes. +# After this marker lands, every further token drains into the lookback +# without further state mutation. The marker itself is filtered out of +# the lookback if it would exceed the bounded length. +_FRAME_CLOSED_MARKER: Final[str] = "_frame_closed" + +_PRONOUN_GENDER: Final[dict[str, str]] = { + "she": "female", + "her": "female", + "hers": "female", + "he": "male", + "him": "male", + "his": "male", + "it": "neuter", + "they": "unknown", + "them": "unknown", + "their": "unknown", +} + + +# --------------------------------------------------------------------------- +# Internal helpers — all pure. +# --------------------------------------------------------------------------- + + +def _push_lookback( + lookback: tuple[AppliedCategory, ...], + category: str, + position: int, +) -> tuple[AppliedCategory, ...]: + """Append a new category to the bounded lookback window.""" + entry = AppliedCategory(category=category, position=position) + combined = lookback + (entry,) + if len(combined) > _LOOKBACK_MAX: + combined = combined[-_LOOKBACK_MAX:] + return combined + + +def _frame_closed(state: SentenceReadingState) -> bool: + return any(ac.category == _FRAME_CLOSED_MARKER for ac in state.lookback) + + +def _resolve_pronoun( + pronoun: str, + registry: tuple[EntityRef, ...], +) -> tuple[str, ...] | None: + """Return a tuple of canonical names compatible with the pronoun's gender. + + ``None`` means the pronoun's gender is not recognised. Empty tuple means + no compatible entity in the registry. Multi-element means ambiguous; + Phase 1 refuses on >1 candidates. + + The compatibility table is a Phase-1 subset of ADR-0164.2 §2.2: + gender match by exact string; "unknown" gender entries are compatible + with any pronoun gender (single-salient-entity case). + """ + needed = _PRONOUN_GENDER.get(pronoun.lower()) + if needed is None: + return None + matches: list[str] = [] + for entity in registry: + if entity.gender == needed or entity.gender == "unknown": + matches.append(entity.canonical_name) + return tuple(matches) + + +def _update_question_target( + sentence_state: SentenceReadingState, + *, + kind: str | None = None, + entity: str | None = None, + unit_class: str | None = None, + position: int | None = None, +) -> QuestionTargetSlot: + """Build a new QuestionTargetSlot, falling back to existing values.""" + existing = sentence_state.question_target + new_kind = kind if kind is not None else ( + existing.kind if existing is not None else "continuous_quantity" + ) + new_entity = entity if entity is not None else ( + existing.entity if existing is not None else None + ) + new_unit_class = unit_class if unit_class is not None else ( + existing.unit_class if existing is not None else None + ) + new_position = position if position is not None else ( + existing.position if existing is not None else 0 + ) + return QuestionTargetSlot( + kind=new_kind, + entity=new_entity, + unit_class=new_unit_class, + position=new_position, + ) + + +# --------------------------------------------------------------------------- +# Lifecycle API. +# --------------------------------------------------------------------------- + + +def begin_sentence( + problem_state: ProblemReadingState, + source_text_offset: int, +) -> SentenceReadingState: + """Open a fresh sentence-local state. + + Per ADR-0164.3 §Lifecycle API. ``sentence_index`` is *not* incremented + here — ``end_sentence`` owns the increment. ``source_text_offset`` is + accepted for parity with the spec; the sentence state itself doesn't + carry it (it lives on ``ProblemReadingState`` and advances at + ``end_sentence``). + """ + if not isinstance(problem_state, ProblemReadingState): + raise TypeError( + "begin_sentence: problem_state must be ProblemReadingState; " + f"got {type(problem_state).__name__}" + ) + if not isinstance(source_text_offset, int) or source_text_offset < 0: + raise ValueError( + "begin_sentence: source_text_offset must be a non-negative int; " + f"got {source_text_offset!r}" + ) + return SentenceReadingState( + entities=(), + quantities=(), + operations=(), + question_target=None, + expectation=None, + frame=None, + pending_quantities=(), + pending_entity_ref=None, + pending_verb=None, + token_index=0, + lookback=(), + partial_frame_payload=None, + ) + + +def apply_word( + sentence_state: SentenceReadingState, + problem_state: ProblemReadingState, + word: str, +) -> SentenceReadingState | ReaderRefusal: + """Advance the reader by one token. Pure / deterministic. + + See module docstring for the four-step contract. The Phase-1 update + rules apply only to the ``question_frame``; opening any other frame at + position 0 refuses with ``unexpected_category`` carrying a Phase-2 + diagnostic. + """ + if not isinstance(word, str) or word == "": + return ReaderRefusal( + reason="unknown_word", + detail="apply_word called with empty/non-string word", + sentence_index=problem_state.sentence_index, + token_index=sentence_state.token_index, + token_text="" if not isinstance(word, str) else word, + ) + + position = sentence_state.token_index + sentence_idx = problem_state.sentence_index + + # Step 1 + 2 — primitive scan, then lexicon lookup. + category, _surface = _classify(word) + + # Step 3 + 4 — expectation + update emit. + # Once the frame is closed, every token drains: classified ones keep + # their category in the lookback; unknowns drain as + # ``unknown_remainder`` so downstream consumers can still see them. + if _frame_closed(sentence_state): + return _advance( + sentence_state, + category=category if category is not None else "unknown_remainder", + ) + + if category is None: + return ReaderRefusal( + reason="unknown_word", + detail=f"no primitive or lexicon match for {word!r}", + sentence_index=sentence_idx, + token_index=position, + token_text=word, + ) + + # Pure-drain categories at any stage (punctuation, articles, etc.). + if category in {"drain_token", "punctuation_comma"}: + return _advance(sentence_state, category=category) + + # Phase-1 scope check at position 0. + if sentence_state.frame is None and category not in _QUESTION_OPENERS: + return ReaderRefusal( + reason="unexpected_category", + detail=( + f"non-question frame at position 0 is Phase-2 scope " + f"(saw category={category!r}, word={word!r})" + ), + sentence_index=sentence_idx, + token_index=position, + token_text=word, + ) + + # Dispatch the rule table. + handler = _QUESTION_FRAME_RULES.get(category, _rule_default_refuse) + return handler( + sentence_state=sentence_state, + problem_state=problem_state, + category=category, + word=word, + ) + + +def end_sentence( + sentence_state: SentenceReadingState, + problem_state: ProblemReadingState, +) -> ProblemReadingState | ReaderRefusal: + """Close the sentence and fold it into a new ``ProblemReadingState``. + + Validation order matches ADR-0164.3 §Lifecycle API: + + 1. ``sentence_state.frame`` must be a legal frame kind. + 2. ``sentence_state.pending_quantities`` must be empty. + 3. If frame is ``question_frame``: target slot must have unit_class + AND a non-default kind set; otherwise ``incomplete_operation``. + 4. Project payload → ``problem_state.unknown_target_slot`` (locked + if already set, refusing). + 5. Append any sentence-introduced entities, fold pronoun resolutions + into the history, increment ``sentence_index``, advance offset. + """ + sentence_idx = problem_state.sentence_index + last_position = max(sentence_state.token_index - 1, 0) + + if sentence_state.frame is None: + return ReaderRefusal( + reason="unfinished_frame", + detail="sentence ended without a frame being decided", + sentence_index=sentence_idx, + token_index=last_position, + token_text="", + ) + + if sentence_state.pending_quantities: + return ReaderRefusal( + reason="unattached_quantity", + detail=( + f"{len(sentence_state.pending_quantities)} quantities never " + "attached to entity+unit at sentence end" + ), + sentence_index=sentence_idx, + token_index=last_position, + token_text="", + ) + + if sentence_state.frame == "question_frame": + target = sentence_state.question_target + if target is None: + return ReaderRefusal( + reason="incomplete_operation", + detail="question_frame closed with no QuestionTargetSlot", + sentence_index=sentence_idx, + token_index=last_position, + token_text="", + ) + missing: list[str] = [] + if target.unit_class is None: + missing.append("unit_class") + # question_form is encoded in kind; "continuous_quantity" is the + # default at first qualifier — accept any of the four valid kinds. + if missing: + return ReaderRefusal( + reason="incomplete_operation", + detail=( + "question_frame missing required slot(s): " + + ", ".join(missing) + ), + sentence_index=sentence_idx, + token_index=last_position, + token_text="", + ) + + # Commit unknown_target_slot. Lock-on-set: refuse if already set. + if problem_state.unknown_target_slot is not None: + return ReaderRefusal( + reason="incomplete_operation", + detail=( + "problem already has unknown_target_slot set; " + "second question sentence rejected" + ), + sentence_index=sentence_idx, + token_index=last_position, + token_text="", + ) + new_unknown = target + else: + new_unknown = problem_state.unknown_target_slot + + # Carry the sentence-introduced entities into the registry. Phase 1 + # only introduces an entity via pending_entity_ref (subject/proper + # noun); pronoun resolutions do NOT introduce new entries. + new_registry = problem_state.entity_registry + if sentence_state.pending_entity_ref is not None: + existing_names = {e.canonical_name for e in new_registry} + candidate = sentence_state.pending_entity_ref + if candidate.canonical_name not in existing_names: + new_registry = new_registry + (candidate,) + + # Pronoun resolutions recorded in lookback via "_pronoun_resolved:" + # sentinels are not persisted to history here (Phase 1 keeps the + # discipline minimal). The history fold is a Phase-2 sub-ADR; this + # PR preserves the history field untouched on success. + return ProblemReadingState( + entity_registry=new_registry, + accumulated_initial_state=problem_state.accumulated_initial_state, + accumulated_operations=problem_state.accumulated_operations, + unknown_target_slot=new_unknown, + pronoun_resolution_history=problem_state.pronoun_resolution_history, + sentence_index=problem_state.sentence_index + 1, + source_text_offset=problem_state.source_text_offset + + max(sentence_state.token_index, 1), + ) + + +# --------------------------------------------------------------------------- +# Step 1 + 2 — classification. +# --------------------------------------------------------------------------- + + +def _classify(word: str) -> tuple[str | None, str]: + """Return (category, surface). Category is None on miss.""" + # 1. Primitive scan first (orthographic shapes are unambiguous). + primitive: LexemeMatch | None = scan_primitive(word) + if primitive is not None: + return primitive.emit_category, primitive.surface + # 2. Lexicon lookup. + lex = _get_lexicon() + entry: LexiconEntry | None = lookup(lex, word) + if entry is not None: + return entry.category, entry.surface + return None, word + + +# --------------------------------------------------------------------------- +# Update-rule handlers. +# Each handler signature: keyword-only sentence_state, problem_state, +# category, word. Returns a new SentenceReadingState or a ReaderRefusal. +# --------------------------------------------------------------------------- + + +def _advance( + sentence_state: SentenceReadingState, + *, + category: str, + **changes, +) -> SentenceReadingState: + """Replace the sentence state with token_index+1 and lookback push.""" + position = sentence_state.token_index + next_lookback = _push_lookback( + sentence_state.lookback, category, position + ) + base = { + "entities": sentence_state.entities, + "quantities": sentence_state.quantities, + "operations": sentence_state.operations, + "question_target": sentence_state.question_target, + "expectation": sentence_state.expectation, + "frame": sentence_state.frame, + "pending_quantities": sentence_state.pending_quantities, + "pending_entity_ref": sentence_state.pending_entity_ref, + "pending_verb": sentence_state.pending_verb, + "token_index": position + 1, + "lookback": next_lookback, + "partial_frame_payload": sentence_state.partial_frame_payload, + } + base.update(changes) + return SentenceReadingState(**base) + + +def _rule_question_open( + *, + sentence_state: SentenceReadingState, + problem_state: ProblemReadingState, + category: str, + word: str, +) -> SentenceReadingState | ReaderRefusal: + """Rule: opening word ('How', 'What') begins a question_frame. + + Only legal at position 0 (or after a punctuation token; Phase 1 + restricts to position 0 since within-sentence multi-clause is + Phase 2 scope). + """ + if sentence_state.frame is not None: + return ReaderRefusal( + reason="unexpected_category", + detail=f"question_open at non-opening position {sentence_state.token_index}", + sentence_index=problem_state.sentence_index, + token_index=sentence_state.token_index, + token_text=word, + ) + return _advance( + sentence_state, + category=category, + frame="question_frame", + partial_frame_payload=FramePayload(frame_kind="question_frame"), + ) + + +def _rule_qty_qualifier( + *, + sentence_state: SentenceReadingState, + problem_state: ProblemReadingState, + category: str, + word: str, +) -> SentenceReadingState | ReaderRefusal: + """Rule: 'many'/'much'/'more'/'less'/'longer'/'total'/'combined'.""" + if sentence_state.frame != "question_frame": + return ReaderRefusal( + reason="unexpected_category", + detail=f"{category} outside question_frame", + sentence_index=problem_state.sentence_index, + token_index=sentence_state.token_index, + token_text=word, + ) + kind = _KIND_BY_QUALIFIER[category] + new_target = _update_question_target( + sentence_state, kind=kind, position=sentence_state.token_index + ) + return _advance( + sentence_state, + category=category, + question_target=new_target, + ) + + +def _rule_unit_noun( + *, + sentence_state: SentenceReadingState, + problem_state: ProblemReadingState, + category: str, + word: str, +) -> SentenceReadingState | ReaderRefusal: + """Rule: count/currency/time unit noun sets ``unit_class``.""" + if sentence_state.frame != "question_frame": + return ReaderRefusal( + reason="unexpected_category", + detail=f"{category} outside question_frame", + sentence_index=problem_state.sentence_index, + token_index=sentence_state.token_index, + token_text=word, + ) + unit_class = _UNIT_CLASS_BY_CATEGORY[category] + new_target = _update_question_target(sentence_state, unit_class=unit_class) + return _advance( + sentence_state, + category=category, + question_target=new_target, + ) + + +def _rule_modal_aux( + *, + sentence_state: SentenceReadingState, + problem_state: ProblemReadingState, + category: str, + word: str, +) -> SentenceReadingState | ReaderRefusal: + if sentence_state.frame != "question_frame": + return ReaderRefusal( + reason="unexpected_category", + detail="modal_aux outside question_frame", + sentence_index=problem_state.sentence_index, + token_index=sentence_state.token_index, + token_text=word, + ) + return _advance(sentence_state, category=category) + + +def _rule_entity_pronoun( + *, + sentence_state: SentenceReadingState, + problem_state: ProblemReadingState, + category: str, + word: str, +) -> SentenceReadingState | ReaderRefusal: + """Rule: resolve against ``problem_state.entity_registry`` per ADR-0164.2.""" + if sentence_state.frame != "question_frame": + return ReaderRefusal( + reason="unexpected_category", + detail="entity_pronoun outside question_frame", + sentence_index=problem_state.sentence_index, + token_index=sentence_state.token_index, + token_text=word, + ) + candidates = _resolve_pronoun(word, problem_state.entity_registry) + if candidates is None or len(candidates) == 0: + return ReaderRefusal( + reason="unresolved_pronoun", + detail=( + f"pronoun {word!r} has no compatible entity in registry " + f"(size={len(problem_state.entity_registry)})" + ), + sentence_index=problem_state.sentence_index, + token_index=sentence_state.token_index, + token_text=word, + ) + if len(candidates) > 1: + return ReaderRefusal( + reason="ambiguous_pronoun_referent", + detail=( + f"pronoun {word!r} matches >1 entity: " + + ", ".join(candidates) + ), + sentence_index=problem_state.sentence_index, + token_index=sentence_state.token_index, + token_text=word, + ) + resolved = candidates[0] + new_target = _update_question_target(sentence_state, entity=resolved) + return _advance( + sentence_state, + category=category, + question_target=new_target, + ) + + +def _rule_proper_noun( + *, + sentence_state: SentenceReadingState, + problem_state: ProblemReadingState, + category: str, + word: str, +) -> SentenceReadingState | ReaderRefusal: + if sentence_state.frame != "question_frame": + return ReaderRefusal( + reason="unexpected_category", + detail="proper_noun outside question_frame", + sentence_index=problem_state.sentence_index, + token_index=sentence_state.token_index, + token_text=word, + ) + canonical = word.lower() + gender = ( + "female" + if category == "proper_noun_entity_female" + else "male" + ) + pending = EntityRef( + canonical_name=canonical, + gender=gender, + first_mention_position=sentence_state.token_index, + ) + new_target = _update_question_target(sentence_state, entity=canonical) + return _advance( + sentence_state, + category=category, + pending_entity_ref=pending, + question_target=new_target, + ) + + +def _rule_residual_modifier( + *, + sentence_state: SentenceReadingState, + problem_state: ProblemReadingState, # noqa: ARG001 + category: str, + word: str, # noqa: ARG001 +) -> SentenceReadingState | ReaderRefusal: + """Rule: 'left'/'remaining'/'after' modify residual semantics. + + QuestionTargetSlot.kind has no 'residual' literal; Phase 1 keeps the + current kind (typically continuous_quantity / difference) and records + the residual marker in the lookback for downstream consumers. + """ + if sentence_state.frame != "question_frame": + # Outside the frame these are drain tokens. + return _advance(sentence_state, category="drain_token") + return _advance(sentence_state, category=category) + + +def _rule_frame_closer( + *, + sentence_state: SentenceReadingState, + problem_state: ProblemReadingState, + category: str, + word: str, +) -> SentenceReadingState | ReaderRefusal: + """Rule: verb or '?' closes the question frame.""" + if sentence_state.frame != "question_frame": + return ReaderRefusal( + reason="unexpected_category", + detail=f"{category} outside question_frame at position 0 is Phase-2 scope", + sentence_index=problem_state.sentence_index, + token_index=sentence_state.token_index, + token_text=word, + ) + pending_verb = sentence_state.pending_verb + if category in _FRAME_CLOSING_VERBS: + pending_verb = VerbReference( + surface=word.lower(), kind=category, position=sentence_state.token_index + ) + # First push the category, then the close marker, so trace order is + # preserved. + intermediate = _advance(sentence_state, category=category, pending_verb=pending_verb) + closed_lookback = _push_lookback( + intermediate.lookback, + _FRAME_CLOSED_MARKER, + intermediate.token_index - 1, + ) + return SentenceReadingState( + entities=intermediate.entities, + quantities=intermediate.quantities, + operations=intermediate.operations, + question_target=intermediate.question_target, + expectation=intermediate.expectation, + frame=intermediate.frame, + pending_quantities=intermediate.pending_quantities, + pending_entity_ref=intermediate.pending_entity_ref, + pending_verb=intermediate.pending_verb, + token_index=intermediate.token_index, + lookback=closed_lookback, + partial_frame_payload=intermediate.partial_frame_payload, + ) + + +def _rule_default_refuse( + *, + sentence_state: SentenceReadingState, + problem_state: ProblemReadingState, + category: str, + word: str, +) -> ReaderRefusal: + return ReaderRefusal( + reason="unexpected_category", + detail=f"category {category!r} not handled by Phase-1 question_frame rules", + sentence_index=problem_state.sentence_index, + token_index=sentence_state.token_index, + token_text=word, + ) + + +# --------------------------------------------------------------------------- +# Phase-1 question_frame rule table. +# Each entry: category → handler. New categories belong here, not in a +# different module. +# --------------------------------------------------------------------------- + +_Handler = Callable[..., "SentenceReadingState | ReaderRefusal"] + +_QUESTION_FRAME_RULES: Final[dict[str, _Handler]] = { + # Openers + "question_open": _rule_question_open, + # Quantifiers / comparatives / aggregate + "question_continuous_qty": _rule_qty_qualifier, + "question_discrete_qty": _rule_qty_qualifier, + "question_comparative": _rule_qty_qualifier, + "aggregate_modifier": _rule_qty_qualifier, + # Unit nouns + "count_unit_noun": _rule_unit_noun, + "currency_unit_noun": _rule_unit_noun, + "time_unit_noun": _rule_unit_noun, + # Pivots + "modal_aux": _rule_modal_aux, + "entity_pronoun": _rule_entity_pronoun, + "proper_noun_entity_female": _rule_proper_noun, + "proper_noun_entity_male": _rule_proper_noun, + # Residual marker + "residual_modifier": _rule_residual_modifier, + # Frame closers + "accumulation_verb": _rule_frame_closer, + "depletion_verb": _rule_frame_closer, + "transfer_verb": _rule_frame_closer, + "capacity_verb": _rule_frame_closer, + "possession_verb": _rule_frame_closer, + "copula_verb": _rule_frame_closer, + "question_terminator": _rule_frame_closer, +} + + +__all__ = [ + "apply_word", + "begin_sentence", + "end_sentence", +] diff --git a/tests/test_reader_question_frame.py b/tests/test_reader_question_frame.py new file mode 100644 index 00000000..26e7dff7 --- /dev/null +++ b/tests/test_reader_question_frame.py @@ -0,0 +1,293 @@ +"""Tests for the Phase-1 question-frame reader lifecycle (ADR-0164.3).""" + +from __future__ import annotations + +import pytest + +from generate.comprehension.lifecycle import ( + apply_word, + begin_sentence, + end_sentence, +) +from generate.comprehension.state import ( + EntityRef, + ProblemReadingState, + ReaderRefusal, + SentenceReadingState, +) + + +def _empty_problem( + *, + registry: tuple[EntityRef, ...] = (), + sentence_index: int = 0, +) -> ProblemReadingState: + return ProblemReadingState( + entity_registry=registry, + accumulated_initial_state=(), + accumulated_operations=(), + unknown_target_slot=None, + pronoun_resolution_history=(), + sentence_index=sentence_index, + source_text_offset=0, + ) + + +def _read_sentence( + words: list[str], + problem_state: ProblemReadingState, +) -> SentenceReadingState | ReaderRefusal: + """Drive a full sentence through apply_word. Returns final state or refusal.""" + state: SentenceReadingState | ReaderRefusal = begin_sentence(problem_state, 0) + assert isinstance(state, SentenceReadingState) + for word in words: + result = apply_word(state, problem_state, word) + if isinstance(result, ReaderRefusal): + return result + state = result + return state + + +# --------------------------------------------------------------------------- +# Determinism +# --------------------------------------------------------------------------- + + +class TestDeterminism: + def test_apply_word_byte_equal_outputs(self) -> None: + ps = _empty_problem(registry=(EntityRef("monica", "female", 0),)) + s1 = begin_sentence(ps, 0) + s2 = begin_sentence(ps, 0) + r1 = apply_word(s1, ps, "How") + r2 = apply_word(s2, ps, "How") + assert isinstance(r1, SentenceReadingState) + assert isinstance(r2, SentenceReadingState) + assert r1.canonical_bytes() == r2.canonical_bytes() + assert r1.canonical_hash() == r2.canonical_hash() + + def test_full_sentence_byte_equal(self) -> None: + ps = _empty_problem(registry=(EntityRef("monica", "female", 0),)) + words = ["How", "much", "time", "did", "she", "spend", "?"] + a = _read_sentence(words, ps) + b = _read_sentence(words, ps) + assert isinstance(a, SentenceReadingState) + assert isinstance(b, SentenceReadingState) + assert a.canonical_bytes() == b.canonical_bytes() + + +# --------------------------------------------------------------------------- +# Five GSM8K target question sentences +# --------------------------------------------------------------------------- + + +class TestGSM8KQuestions: + def test_0007_how_many_more_boxes(self) -> None: + ps = _empty_problem( + registry=(EntityRef("francine_and_friend", "unknown", 0),) + ) + words = [ + "How", "many", "more", "boxes", "do", "they", "need", + "if", "Francine", "has", "a", "total", "of", "85", "crayons", "?", + ] + state = _read_sentence(words, ps) + assert isinstance(state, SentenceReadingState), state + end = end_sentence(state, ps) + assert isinstance(end, ProblemReadingState), end + target = end.unknown_target_slot + assert target is not None + assert target.entity == "francine_and_friend" + assert target.unit_class == "count" + assert target.kind == "difference" + + def test_0017_how_much_cost_him(self) -> None: + ps = _empty_problem( + registry=( + EntityRef("eric", "male", 0), + EntityRef("house", "neuter", 1), + ) + ) + words = ["How", "much", "will", "it", "cost", "him", "?"] + state = _read_sentence(words, ps) + assert isinstance(state, SentenceReadingState), state + end = end_sentence(state, ps) + assert isinstance(end, ProblemReadingState), end + target = end.unknown_target_slot + assert target is not None + assert target.entity == "eric" + assert target.unit_class == "currency" + assert target.kind == "continuous_quantity" + + def test_0027_how_many_followers_malcolm(self) -> None: + ps = _empty_problem() + words = [ + "How", "many", "followers", "does", "Malcolm", "have", + "on", "all", "his", "social", "media", "?", + ] + state = _read_sentence(words, ps) + assert isinstance(state, SentenceReadingState), state + end = end_sentence(state, ps) + assert isinstance(end, ProblemReadingState), end + target = end.unknown_target_slot + assert target is not None + assert target.entity == "malcolm" + assert target.unit_class == "count" + assert target.kind == "discrete_quantity" + # Proper-noun entity entered the registry. + names = [e.canonical_name for e in end.entity_registry] + assert "malcolm" in names + + def test_0036_how_much_time_studying(self) -> None: + ps = _empty_problem(registry=(EntityRef("monica", "female", 0),)) + words = [ + "How", "much", "time", "did", "she", "spend", "studying", + "in", "total", "during", "the", "five", "days", "?", + ] + state = _read_sentence(words, ps) + assert isinstance(state, SentenceReadingState), state + end = end_sentence(state, ps) + assert isinstance(end, ProblemReadingState), end + target = end.unknown_target_slot + assert target is not None + assert target.entity == "monica" + assert target.unit_class == "time" + assert target.kind == "continuous_quantity" + + def test_0043_how_much_money_left(self) -> None: + ps = _empty_problem(registry=(EntityRef("sandra", "female", 0),)) + words = [ + "How", "much", "money", "will", "she", "be", "left", + "with", "after", "the", "purchase", "?", + ] + state = _read_sentence(words, ps) + assert isinstance(state, SentenceReadingState), state + end = end_sentence(state, ps) + assert isinstance(end, ProblemReadingState), end + target = end.unknown_target_slot + assert target is not None + assert target.entity == "sandra" + assert target.unit_class == "currency" + assert target.kind == "continuous_quantity" + + +# --------------------------------------------------------------------------- +# Refusal modes +# --------------------------------------------------------------------------- + + +class TestRefusals: + def test_unknown_word(self) -> None: + ps = _empty_problem() + s = begin_sentence(ps, 0) + r = apply_word(s, ps, "@@@") + assert isinstance(r, ReaderRefusal) + assert r.reason == "unknown_word" + assert r.token_text == "@@@" + + def test_unexpected_category_non_question_opener(self) -> None: + """A statement-frame opener at position 0 is Phase-2 scope.""" + ps = _empty_problem(registry=(EntityRef("francine", "female", 0),)) + s = begin_sentence(ps, 0) + # "francine" is a proper_noun_entity_female — would open a + # statement frame. Phase 1 refuses. + r = apply_word(s, ps, "Francine") + assert isinstance(r, ReaderRefusal) + assert r.reason == "unexpected_category" + assert "Phase-2" in r.detail + + def test_unresolved_pronoun_empty_registry(self) -> None: + """A pronoun with no compatible entity refuses cleanly.""" + ps = _empty_problem() # empty registry + s = begin_sentence(ps, 0) + s = apply_word(s, ps, "How") + assert isinstance(s, SentenceReadingState) + s = apply_word(s, ps, "much") + assert isinstance(s, SentenceReadingState) + s = apply_word(s, ps, "money") + assert isinstance(s, SentenceReadingState) + s = apply_word(s, ps, "will") + assert isinstance(s, SentenceReadingState) + r = apply_word(s, ps, "she") + assert isinstance(r, ReaderRefusal) + assert r.reason == "unresolved_pronoun" + assert r.token_text == "she" + + def test_unfinished_frame_on_end(self) -> None: + ps = _empty_problem() + s = begin_sentence(ps, 0) + # No words applied → frame is still None. + end = end_sentence(s, ps) + assert isinstance(end, ReaderRefusal) + assert end.reason == "unfinished_frame" + + def test_unattached_quantity_on_end(self) -> None: + """A SentenceReadingState with frame set but pending_quantities + non-empty refuses with unattached_quantity at end_sentence.""" + from decimal import Decimal + + from generate.comprehension.state import QuantityRef + + ps = _empty_problem() + # Construct a hand-built state to isolate the rule. + pending = QuantityRef( + value=Decimal("18"), + unit=None, + unit_class="pending", + owner_entity=None, + mention_position=0, + ) + state = SentenceReadingState( + entities=(), + quantities=(), + operations=(), + frame="question_frame", + pending_quantities=(pending,), + token_index=2, + ) + end = end_sentence(state, ps) + assert isinstance(end, ReaderRefusal) + assert end.reason == "unattached_quantity" + + def test_incomplete_operation_no_unit(self) -> None: + """question_frame closes with no unit_class on the target → refuse.""" + ps = _empty_problem(registry=(EntityRef("monica", "female", 0),)) + s = begin_sentence(ps, 0) + # Only "How" then "?" — no unit was ever set. + s = apply_word(s, ps, "How") + assert isinstance(s, SentenceReadingState) + s = apply_word(s, ps, "?") + assert isinstance(s, SentenceReadingState) + end = end_sentence(s, ps) + assert isinstance(end, ReaderRefusal) + assert end.reason == "incomplete_operation" + + +# --------------------------------------------------------------------------- +# Lifecycle invariants +# --------------------------------------------------------------------------- + + +class TestLifecycleInvariants: + def test_problem_state_preserved_when_sentence_introduces_no_entity(self) -> None: + """begin → apply_word(*) → end_sentence preserves the registry + when the sentence only references existing entities.""" + registry = (EntityRef("monica", "female", 0),) + ps = _empty_problem(registry=registry) + words = ["How", "much", "time", "did", "she", "spend", "?"] + state = _read_sentence(words, ps) + assert isinstance(state, SentenceReadingState) + end = end_sentence(state, ps) + assert isinstance(end, ProblemReadingState) + assert end.entity_registry == registry + + def test_sentence_index_advances(self) -> None: + ps = _empty_problem(registry=(EntityRef("monica", "female", 0),)) + words = ["How", "much", "time", "did", "she", "spend", "?"] + state = _read_sentence(words, ps) + assert isinstance(state, SentenceReadingState) + end = end_sentence(state, ps) + assert isinstance(end, ProblemReadingState) + assert end.sentence_index == 1 + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])