core/tests/test_reader_question_frame.py
Shay a0e9ca8535
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.
2026-05-26 20:13:12 -07:00

293 lines
11 KiB
Python

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