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).
273 lines
9.1 KiB
Python
273 lines
9.1 KiB
Python
"""Tests for generate.comprehension.lexicon (ADR-0164 §Decision §1)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from generate.comprehension.lexicon import (
|
|
Lexicon,
|
|
LexiconEntry,
|
|
LexiconLoadError,
|
|
load_lexicon,
|
|
lookup,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Categories declared in ADR-0164 §Decision §1 that must be present in the
|
|
# en_core_math_v1 pack shipped with PR #322.
|
|
REQUIRED_CATEGORIES = {
|
|
"accumulation_verb",
|
|
"capacity_verb",
|
|
"currency_unit_noun",
|
|
"depletion_verb",
|
|
"entity_pronoun",
|
|
"possession_verb",
|
|
"proper_noun_gender_female",
|
|
"proper_noun_gender_male",
|
|
"question_open",
|
|
"residual_modifier",
|
|
"transfer_verb",
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Load
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestLoad:
|
|
def test_load_succeeds(self) -> None:
|
|
lex = load_lexicon()
|
|
assert isinstance(lex, Lexicon)
|
|
|
|
def test_all_required_categories_present(self) -> None:
|
|
lex = load_lexicon()
|
|
present = set(lex.by_category.keys())
|
|
missing = REQUIRED_CATEGORIES - present
|
|
assert not missing, f"Missing categories: {sorted(missing)}"
|
|
|
|
def test_by_category_values_are_sorted_tuples(self) -> None:
|
|
lex = load_lexicon()
|
|
for cat, entries in lex.by_category.items():
|
|
assert isinstance(entries, tuple), f"{cat}: not a tuple"
|
|
lemmas = [e.lemma for e in entries]
|
|
assert lemmas == sorted(lemmas), f"{cat}: entries not sorted by lemma"
|
|
|
|
def test_by_surface_is_mapping_proxy(self) -> None:
|
|
import types as _types
|
|
lex = load_lexicon()
|
|
assert isinstance(lex.by_surface, _types.MappingProxyType)
|
|
|
|
def test_by_category_is_mapping_proxy(self) -> None:
|
|
import types as _types
|
|
lex = load_lexicon()
|
|
assert isinstance(lex.by_category, _types.MappingProxyType)
|
|
|
|
def test_pack_id_is_math_v1(self) -> None:
|
|
lex = load_lexicon()
|
|
assert lex.source_pack_id == "en_core_math_v1"
|
|
|
|
def test_sha256_field_is_populated(self) -> None:
|
|
lex = load_lexicon()
|
|
assert len(lex.pack_manifest_sha256) == 64
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Checksum
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestChecksum:
|
|
def test_checksum_mismatch_raises(self, tmp_path: Path) -> None:
|
|
"""Tampering the manifest checksum must raise LexiconLoadError."""
|
|
import shutil
|
|
|
|
# Copy the real pack into a temp location.
|
|
real_pack = (
|
|
Path(__file__).resolve().parent.parent
|
|
/ "language_packs" / "data" / "en_core_math_v1"
|
|
)
|
|
fake_pack = tmp_path / "en_core_math_v1"
|
|
shutil.copytree(real_pack, fake_pack)
|
|
|
|
# Corrupt the manifest checksum.
|
|
manifest_path = fake_pack / "manifest.json"
|
|
manifest = json.loads(manifest_path.read_text())
|
|
manifest["checksum"] = "0" * 64
|
|
manifest_path.write_text(json.dumps(manifest))
|
|
|
|
with pytest.raises(LexiconLoadError, match="checksum"):
|
|
load_lexicon(fake_pack)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lookups
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestLookups:
|
|
@pytest.mark.parametrize("lemma", ["buy", "earn", "get", "collect", "save"])
|
|
def test_accumulation_verb_lemmas(self, lemma: str) -> None:
|
|
lex = load_lexicon()
|
|
entry = lookup(lex, lemma)
|
|
assert entry is not None, f"{lemma!r} not found"
|
|
assert entry.category == "accumulation_verb", (
|
|
f"{lemma!r}: expected accumulation_verb, got {entry.category!r}"
|
|
)
|
|
|
|
def test_depletion_verb_lemma(self) -> None:
|
|
lex = load_lexicon()
|
|
entry = lookup(lex, "spend")
|
|
assert entry is not None
|
|
assert entry.category == "depletion_verb"
|
|
|
|
def test_transfer_verb_lemma(self) -> None:
|
|
lex = load_lexicon()
|
|
entry = lookup(lex, "give")
|
|
assert entry is not None
|
|
assert entry.category == "transfer_verb"
|
|
|
|
def test_currency_unit_noun(self) -> None:
|
|
lex = load_lexicon()
|
|
entry = lookup(lex, "money")
|
|
assert entry is not None
|
|
assert entry.category == "currency_unit_noun"
|
|
|
|
def test_entity_pronoun(self) -> None:
|
|
lex = load_lexicon()
|
|
entry = lookup(lex, "she")
|
|
assert entry is not None
|
|
assert entry.category == "entity_pronoun"
|
|
|
|
def test_proper_noun_female(self) -> None:
|
|
lex = load_lexicon()
|
|
entry = lookup(lex, "tina")
|
|
assert entry is not None
|
|
assert entry.category == "proper_noun_gender_female"
|
|
|
|
def test_alias_resolves_to_lemma_entry(self) -> None:
|
|
lex = load_lexicon()
|
|
# "earned" is an alias of "earn" (accumulation_verb)
|
|
entry = lookup(lex, "earned")
|
|
assert entry is not None, "'earned' alias not found"
|
|
assert entry.lemma == "earn"
|
|
assert entry.category == "accumulation_verb"
|
|
|
|
def test_alias_earns_resolves_to_earn(self) -> None:
|
|
lex = load_lexicon()
|
|
entry = lookup(lex, "earns")
|
|
assert entry is not None
|
|
assert entry.lemma == "earn"
|
|
|
|
def test_alias_spent_resolves_to_spend(self) -> None:
|
|
lex = load_lexicon()
|
|
entry = lookup(lex, "spent")
|
|
assert entry is not None
|
|
assert entry.lemma == "spend"
|
|
|
|
def test_unknown_surface_returns_none(self) -> None:
|
|
lex = load_lexicon()
|
|
assert lookup(lex, "xyzzy") is None
|
|
|
|
def test_unknown_proper_noun_returns_none(self) -> None:
|
|
lex = load_lexicon()
|
|
assert lookup(lex, "Zaphod") is None
|
|
|
|
def test_case_insensitive_lookup(self) -> None:
|
|
lex = load_lexicon()
|
|
lower = lookup(lex, "earn")
|
|
upper = lookup(lex, "EARN")
|
|
assert lower is not None
|
|
assert upper is not None
|
|
assert lower.lemma == upper.lemma
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Determinism
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDeterminism:
|
|
def test_two_loads_have_equal_hash(self) -> None:
|
|
lex1 = load_lexicon()
|
|
lex2 = load_lexicon()
|
|
assert hash(lex1) == hash(lex2)
|
|
|
|
def test_two_loads_are_equal(self) -> None:
|
|
lex1 = load_lexicon()
|
|
lex2 = load_lexicon()
|
|
assert lex1 == lex2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cache hit
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCacheHit:
|
|
def test_same_object_returned(self) -> None:
|
|
lex1 = load_lexicon()
|
|
lex2 = load_lexicon()
|
|
assert lex1 is lex2, "Second call must return the cached object"
|
|
|
|
def test_explicit_path_cache_hit(self, tmp_path: Path) -> None:
|
|
import shutil
|
|
real_pack = (
|
|
Path(__file__).resolve().parent.parent
|
|
/ "language_packs" / "data" / "en_core_math_v1"
|
|
)
|
|
fake_pack = tmp_path / "en_core_math_v1"
|
|
shutil.copytree(real_pack, fake_pack)
|
|
|
|
a = load_lexicon(fake_pack)
|
|
b = load_lexicon(fake_pack)
|
|
assert a is b
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Mutual exclusion
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestMutualExclusion:
|
|
def test_surface_in_two_categories_raises(self, tmp_path: Path) -> None:
|
|
"""A surface appearing in two different categories raises LexiconLoadError."""
|
|
import shutil
|
|
|
|
real_pack = (
|
|
Path(__file__).resolve().parent.parent
|
|
/ "language_packs" / "data" / "en_core_math_v1"
|
|
)
|
|
conflict_pack = tmp_path / "en_core_math_v1"
|
|
shutil.copytree(real_pack, conflict_pack)
|
|
|
|
# Inject a conflicting entry: "earn" already exists as accumulation_verb;
|
|
# add it again as depletion_verb in the depletion_verb source file.
|
|
conflict_entry = json.dumps({
|
|
"lemma": "earn",
|
|
"category": "depletion_verb",
|
|
"aliases": [],
|
|
"provenance": "test-conflict",
|
|
})
|
|
depletion_file = conflict_pack / "lexicon" / "depletion_verb.jsonl"
|
|
with open(depletion_file, "a", encoding="utf-8") as f:
|
|
f.write("\n" + conflict_entry + "\n")
|
|
|
|
# Recompute and patch the manifest checksum to match the (unmodified)
|
|
# compiled lexicon.jsonl — the conflict is in the source files only.
|
|
compiled_path = conflict_pack / "lexicon.jsonl"
|
|
actual_sha256 = hashlib.sha256(compiled_path.read_bytes()).hexdigest()
|
|
manifest_path = conflict_pack / "manifest.json"
|
|
manifest = json.loads(manifest_path.read_text())
|
|
manifest["checksum"] = actual_sha256
|
|
manifest_path.write_text(json.dumps(manifest))
|
|
|
|
with pytest.raises(LexiconLoadError, match="[Mm]utual"):
|
|
load_lexicon(conflict_pack)
|