feat(comprehension): reader lifecycle for question-frame Phase 1 (ADR-0164.3) (#326)
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.
This commit is contained in:
parent
4570c2c70e
commit
a0e9ca8535
3 changed files with 1338 additions and 0 deletions
269
generate/comprehension/_interface_stubs.py
Normal file
269
generate/comprehension/_interface_stubs.py
Normal file
|
|
@ -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)
|
||||
776
generate/comprehension/lifecycle.py
Normal file
776
generate/comprehension/lifecycle.py
Normal file
|
|
@ -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:<name>"
|
||||
# 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",
|
||||
]
|
||||
293
tests/test_reader_question_frame.py
Normal file
293
tests/test_reader_question_frame.py
Normal file
|
|
@ -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"])
|
||||
Loading…
Reference in a new issue