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).
373 lines
15 KiB
Python
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"])
|