core/tests/test_reader_question_frame.py
Shay 60043973b0
feat(comprehension/10): Phase 2 statement-frame reader (ADR-0164.4) (#335)
Extend the comprehension reader from question-only scope to whole-
problem scope. Phase 1 (Brief 8 / #326) implemented question_frame;
this brief implements initial_state_frame, operation_frame, and
descriptive_frame, plus finalize() projection into a strict
ADR-0115 MathProblemGraph.

Architecturally correct under ADR-0164.3; not yet productive on
GSM8K train_sample. Below-floor measurement documented; specific
bottlenecks tabled for Phase 2.1 follow-up.

What landed

- Frame-opener dispatch in lifecycle.py for the three new statement
  frames, plus rule handlers (_rule_op_*, _rule_preframe_*,
  _rule_descriptive_*).
- finalize(state) -> MathProblemGraph | ReaderRefusal: pure
  projection with closure checks (entity registry non-empty,
  unknown target bound, every op/initial references a known entity,
  Decimal precision projects losslessly).
- _classify extended to 3-tuple (category, surface, decimal_value)
  with possessive strip retry. Brief 8.2's sentence-initial
  lookup-first + gender-skip preserved AND extended to mid-sentence
  (gender is enrichment everywhere, never admission).
- Whole-problem coexistence dispatch in math_candidate_graph.py
  (config.comprehension_reader_questions=True): reader attempts the
  whole problem; on any ReaderRefusal falls through to existing
  regex parser. All-or-nothing per the brief.
- Lexicon expansion (carried into renamed proper_noun_gender_*
  files): +2 accumulation_verb (adopt, invest), +2 currency_unit_noun
  (dollar, cent), +6 capacity_verb (fill, lift, play, work, finish,
  drive), +5 female names (allison, brooke, jan, marion, sidney),
  +14 male names (bart, fernando, georgie, jake, jed, jeremie, jose,
  orlando, rex, rudolph, steve, troy, xavier, yun), +numerous
  count_unit_noun, drain_token, time_unit_noun.
- ADR-0164.4-phase2-statement-frame-reader.md — the architectural
  rationale and acceptance contract.

Measurement (reader_phase2_delta.json):

  flag-OFF: correct=3 refused=47 wrong=0
  flag-ON:  correct=3 refused=47 wrong=0
  delta:    0/0/0

Below the brief's floor of correct >= 4. Architecture is sound — the
reader admits cases as graphs when the structure resolves, refuses
cleanly otherwise, preserves wrong=0 across both flag states.

Bottleneck table (from per-case attribution):

  count  refusal_class           dominant cause
  -----  ----------------------  ------------------------------------
  18     incomplete_operation    multi-quantity ops; no-quantity op
  11     unknown_word            "hundred", "presently", "one-hour",
                                 non-math verbs (compound numerics,
                                 lexicon gaps)
  6      unexpected_category     fraction / percentage literals;
                                 multi-subject sentences
  6      unresolved_pronoun      "them", "their", "his" with no
                                 compatible entity
  5      unattached_quantity     quantity never bound to a unit
  1      no_question_target     question parsed but slot never set

Closing the gate to mixed-bounded [4, 24] is Phase 2.1 scope: extend
composition rules for multi-quantity ops, add fraction/percentage
primitives (per ADR-0164.1 amendment), expand lexicon for the
remaining unknown_word cases, extend pronoun resolution.

Invariants preserved

- wrong = 0 in both flag states ✓
- flag-OFF byte-identical to today ✓
- determinism (50/50 identical runs) ✓
- Capability axes G1-G5, S1 unchanged ✓
- Reader tests: 19 (Phase 2) + 18 (Phase 1, post-update) + 53 (pack)
  + 76 (lexicon + primitives) = 166 specific to this change; all pass
- core test --suite smoke -q: 67 passed

Rebase note

This PR was authored against an older base; rebased onto current
main to incorporate #333 (Brief 8.2 universal proper_noun_token
primitive) and #334 (ADR-0166 measurement discipline). The rebase
required:
- Lexicon files renamed proper_noun_entity_* -> proper_noun_gender_*
  (with the Phase 2 additions merged into the gender_* files)
- Compiled lexicon.jsonl unchanged from #333's 207-entry state
  (Phase 2's per-category additions are runtime-visible via the
  source loader, not via the compiled file)
- _classify reconciled with Brief 8.2's sentence-initial dispatch +
  Phase 2's 3-tuple decimal-value return
- All dispatch tables and category checks updated to reference
  proper_noun_token (singular) instead of proper_noun_entity_{f,m}
- Three Phase 1 test expectations updated to reflect Phase 2
  behavior (proper noun at position 0 now opens statement pre-frame
  instead of refusing; pronoun resolution applies per ADR-0164.2)

Per ADR-0166's three-question test, this PR is honest measurement:
capability exists, at least one case admits, lane distinguishes
presence from absence — which the bottleneck table demonstrates.

Refs ADR-0164.3 §Phasing Phase 2, ADR-0164.1 amendment (Brief 8.2),
ADR-0166 §"Mixed (notable but not blocking)" — except here, below
floor.
2026-05-27 05:03:56 -07:00

380 lines
15 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_statement_frame_opener_accepted(self) -> None:
"""Phase 2 (ADR-0164.4): proper noun at position 0 opens a statement
pre-frame. After Brief 8.2's gender-enrichment refactor, the lookup
skips proper_noun_gender_* categories and the proper_noun_token
primitive admits "Francine" — Phase 2 then routes it to the
statement-frame pre-frame entity slot instead of refusing.
"""
ps = _empty_problem(registry=(EntityRef("francine", "female", 0),))
s = begin_sentence(ps, 0)
r = apply_word(s, ps, "Francine")
assert isinstance(r, SentenceReadingState)
assert r.frame is None # frame determined on next verb
assert r.pending_entity_ref is not None
assert r.pending_entity_ref.canonical_name == "francine"
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
class TestInitialDispatchAndUnknownGender:
"""ADR-0164.1 amendment (Brief 8.2): sentence-initial lookup-first +
universal proper_noun_token primitive + unknown-gender pronoun
resolution via single-salient fallback (ADR-0164.2)."""
def test_sentence_initial_common_words_lookup_first(self) -> None:
"""Sentence-initial 'The'/'She'/'How' resolve via lexicon, not
the proper_noun_token primitive. 'The' drains; 'She' refuses
(pronoun outside question_frame); 'How' opens question_frame."""
ps = _empty_problem()
state = begin_sentence(ps, 0)
out = apply_word(state, ps, "The")
assert isinstance(out, SentenceReadingState)
assert out.lookback[-1].category == "drain_token"
state = begin_sentence(ps, 0)
out = apply_word(state, ps, "She")
assert isinstance(out, ReaderRefusal)
# Phase 2: pronoun at position 0 attempts resolution; empty
# registry → unresolved_pronoun per ADR-0164.2.
assert out.reason == "unresolved_pronoun"
state = begin_sentence(ps, 0)
out = apply_word(state, ps, "How")
assert isinstance(out, SentenceReadingState)
assert out.frame == "question_frame"
def test_sentence_initial_marnie_is_not_gated_by_gender_list(self) -> None:
"""'Marnie' is not in proper_noun_gender_female (after Brief 8.2
rename dropped marnie from the female list). Reader admits her
as a proper_noun_token at sentence-initial position. Under
Phase 2, this opens a statement pre-frame (not a refusal).
EntityRef carries gender="unknown" because the name is outside
the curated gender lists.
"""
ps = _empty_problem()
out = apply_word(begin_sentence(ps, 0), ps, "Marnie")
assert isinstance(out, SentenceReadingState)
assert out.pending_entity_ref is not None
assert out.pending_entity_ref.gender == "unknown"
def test_sentence_initial_novel_name_uses_primitive_and_unknown_gender(self) -> None:
"""A name not in either gender list (e.g. 'Zelda') still admits
via the universal proper_noun_token primitive. Under Phase 2,
this opens a statement pre-frame with gender='unknown'."""
ps = _empty_problem()
state = begin_sentence(ps, 0)
out = apply_word(state, ps, "Zelda")
assert isinstance(out, SentenceReadingState)
assert out.pending_entity_ref is not None
assert out.pending_entity_ref.canonical_name == "zelda"
assert out.pending_entity_ref.gender == "unknown"
def test_pronoun_single_unknown_entity_resolves(self) -> None:
"""ADR-0164.2 single-salient fallback: one gender-unknown entity
resolves the pronoun cleanly."""
ps = _empty_problem(registry=(EntityRef("Zelda", "unknown", 0),))
state = _read_sentence(
["How", "much", "money", "will", "she", "earn", "?"], ps
)
assert isinstance(state, SentenceReadingState), state
assert state.question_target is not None
assert state.question_target.entity == "Zelda"
def test_pronoun_two_unknown_entities_refuses_ambiguous(self) -> None:
"""ADR-0164.2: two gender-unknown entities + no recency
disambiguation → ambiguous_pronoun_referent."""
ps = _empty_problem(
registry=(
EntityRef("Zelda", "unknown", 0),
EntityRef("Marnie", "unknown", 1),
)
)
state = begin_sentence(ps, 0)
assert isinstance(state, SentenceReadingState)
for token in ["How", "much", "money", "will"]:
state = apply_word(state, ps, token)
assert isinstance(state, SentenceReadingState)
out = apply_word(state, ps, "she")
assert isinstance(out, ReaderRefusal)
assert out.reason == "ambiguous_pronoun_referent"
if __name__ == "__main__":
pytest.main([__file__, "-v"])