core/tests/test_reader_question_frame.py
Shay b3dbde94b4
feat(comprehension/8.2): universal proper_noun_token primitive (#333)
ADR-0164.1 amendment: replace name-whitelist entity admission with a
universal lexeme primitive that recognizes any capitalized token as a
proper noun. The gender-coded name lists are demoted from admission
criterion to enrichment-only lookup. A name outside the curated lists
still admits cleanly with gender="unknown" — ADR-0164.2's pronoun
resolution rules handle the unknown case via single-salient fallback
or refuse with ambiguous_pronoun_referent.

Universal at the primitive layer: the new proper_noun_token primitive
is domain-agnostic. It sits in the shared PRIMITIVE_REGISTRY and is
available to every current and future reader (math, narrative,
code-comment, multi-lingual). The math reader is its first consumer.

Pattern: ^[A-Z][A-Za-z'-]*[a-z][A-Za-z'-]*$
- requires capitalized first letter
- requires ≥1 lowercase letter (rejects all-caps acronyms)
- allows internal apostrophes (O'Brien) and hyphens (Mary-Anne)
- matches "Tina", "Bob", "Marnie", "McDonald" — rejects "TINA",
  "123", "$5.00" (those go to their own primitives)

Sentence-initial lookup-first dispatch (lifecycle._classify):
- At token_index == 0: lookup() first, skipping proper_noun_gender_*
  categories (treated as not-found so the primitive can fire). If
  lookup misses, primitive scan picks up novel names. Inverts the
  question from "is this a name?" to "is this a known common word?"
- At token_index > 0: primitive-first with UNIT_CATEGORY_TOKEN ceding
  to operational lexicon for currency_unit_noun overrides.

Lexicon rename (per-category source files):
- proper_noun_entity_female.jsonl -> proper_noun_gender_female.jsonl
- proper_noun_entity_male.jsonl   -> proper_noun_gender_male.jsonl

Compiled lexicon.jsonl: rename the two semantic_domain tags; drop
"marnie" (was only in proper_noun_entity_female, now absent from
the gender-coded sources). Net: 208 -> 207 entries. New manifest
checksum: 1fb9b0d790258736267d528e8e8a2436ce88b9ce690805fe2813ba077861ba2a

New helper gender_of_proper_noun(surface, lexicon) returns
Literal["female","male","neuter","unknown"] — pure enrichment lookup,
never gates admission.

Measurement (reader_phase1_plus_proper_noun_delta.json):
- pre-primitive baseline: correct=3 refused=47 wrong=0
- post-primitive measurement: correct=3 refused=47 wrong=0
- No regression on wrong=0
- No net admission increase observed in this train-sample harness;
  the architectural value is for future text outside the curated
  gender lists (Sonnet's #332 expanded those to cover GSM8K names).

Tests:
- test_lexeme_primitives.py: registry count 8 -> 9, proper_noun_token
  fires + variants (Bob, Marnie, McDonald, O'Brien, Mary-Anne),
  numeric/all-caps refusals, numeric-literal still wins overlap on "123"
- test_reader_question_frame.py: 5 new tests for sentence-initial
  dispatch + unknown-gender pronoun resolution + novel-name admission
  via primitive (Zelda)
- test_en_core_math_v1_pack.py: category counts updated; mutual-exclusion
  between gender_female and gender_male preserved; total 208 -> 207
- test_lexicon.py: category list + lookup assertion updated to renamed
  proper_noun_gender_female
- test_proper_noun_primitive_universality.py: new test module asserting
  domain-agnostic property of the primitive

Validation:
- pack + lexicon + primitive tests: 147 passed
- reader + universality tests: 22 passed
- smoke lane: 67 passed

Closes the engine_state question by leaving those files untracked
(repo discipline: runtime artifacts never enter PRs).

Refs ADR-0164.1 amendment, ADR-0164.2 §EntityRegistry, ADR-0165
§Legitimate uses (the new primitive passes the three-question test).
2026-05-26 22:16:34 -07:00

373 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_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 known word in the gender-enrichment lexicon.
# Under sentence-initial lookup-first dispatch, gender categories
# do not admit — they are enrichment, not admission. The
# proper_noun_token primitive then admits "Francine" as a name,
# but at sentence position 0 (statement frame opener) 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
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)
assert out.reason == "unexpected_category"
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 still admits
her as a proper_noun_token at sentence-initial position, then
refuses with Phase-2-scope detail (statement frame opener)."""
ps = _empty_problem()
out = apply_word(begin_sentence(ps, 0), ps, "Marnie")
assert isinstance(out, ReaderRefusal)
assert out.reason == "unexpected_category"
assert "Phase-2" in out.detail
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."""
ps = _empty_problem()
state = begin_sentence(ps, 0)
out = apply_word(state, ps, "Zelda")
assert isinstance(out, ReaderRefusal)
assert out.reason == "unexpected_category"
assert "Phase-2" in out.detail
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"])