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.
293 lines
11 KiB
Python
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"])
|