core/tests/test_lexicon_single_source.py
Shay df6332fcb5 refactor(generate): one owner per linguistic fact (Phase 2A)
Collapses the duplicated closed English tables into generate/lexicon.py.
Measured on main @ 0948f7cd: the same facts were encoded up to five times
across the reading and writing paths, each copy written independently as a
successive deduction band landed.

The 2A/2B split is decided empirically per #129 — make the change, run the
11 pinned lanes, let byte-identity rule. Result: 11/11 byte-identical, so
this entire unit is 2A and nothing spilled into the authorization-gated 2B.

Collapsed to one object:
  - connectives    3 identical copies (member, verb, cond_member)
  - predicate display  2 identical 26-entry copies (semantic_templates,
                       templates)
  - be-form inventory  5 copies, not the 2 §1.3 counted. The structural
                       test found realizer_guard._BE_AUX and
                       chat/runtime._BE_FORMS, which a COPULA-shaped grep
                       could never see.

Derived rather than duplicated, so they can no longer drift:
  - STRUCTURAL = CONNECTIVES | {therefore}
  - QUANTIFIER_TOKENS = QUANTIFIER_LEAD | QUANTIFIER_NON_LEAD
  - NEGATION_BEARING_WITH_NOT = NEGATION_BEARING | {not}
  - READER_IRREGULAR_SINGULARS = IRREGULAR_SINGULARS restricted to 8 keys

§1.3 re-measured while doing this, and it overstated the disease (plan
§1.9). Almost nothing contradicts:
  - the "3 divergent plural tables" are 1 pluralizer + 2 singularizers,
    i.e. inverse directions; comparing them as copies is a category error
  - reader's 8 singulars are a STRICT SUBSET of member's 29 with the
    difference in reader's favour empty, so its values are all correct and
    only its coverage is short
  - the "3 divergent quantifier sets" are 3 distinct facts; every/each lead
    a clause but take singular nouns, so their absence from
    PLURAL_QUANTIFIERS is correct
  - english's negation set is a subset of member's, and the difference is
    principled: v2-EN normalizes "<copula> not", v3-MEM refuses it

Exactly one genuine contradiction exists in the whole inventory:
discourse_planner maps is_defined_as -> "is" where the others map it to
"is defined as". Preserved as a register choice, pinned by test.

Found while reading, pinned as current behaviour for 2B to flip:
reader._singularize does NOT refuse uncovered plurals despite a comment
claiming it does — it falls through to a bare -s strip, so news -> new and
species -> specy, exactly the corruptions member.py's table comment says
its table exists to prevent.

Two 2A exit criteria were unmeasurable as written and are replaced:
  - "Jaccard -> 1.00" cannot work. measure_grammar_seam.py scans source
    literals, so unifying a fact removes it from those files and Jaccard
    FELL, 0.083 -> 0.023 — the success direction reported by a metric
    shaped to read like failure. Replaced with an object-identity count:
    3 distinct objects behind 8 names, from 8-behind-8. Value comparison
    cannot tell a shared object from two equal copies.
  - "one table per fact" presumed §1.3's inventory was right. The report now
    separates must-be-one-object (all UNIFIED) from related-but-distinct
    (6 relations, all HOLD).

ADR-0258 §5 already decided morphology's destination is a ratified pack,
triggered by a grc/he member band. No such band exists, so the promotion is
correctly deferred and lexicon.py is a staging area, not a rival home — no
new ADR. Destination measured for whoever picks it up: packs/en/morphology
.jsonl has 9 records and zero irregular plurals, features.number has no
consumer, and _pack_morph_roots_for reads a top-level "root" key that 0 of
31 records across all four packs define, so it returns {} every call.

[Verification]: in-worktree on CPython 3.12.13, uv sync --locked —
smoke 621 unchanged; deductive 383 (364 + 19 new);
scripts/verify_lane_shas.py 11/11 byte-identical.
2026-07-26 17:45:27 -07:00

352 lines
15 KiB
Python

"""Phase 2A pins: ``generate/lexicon.py`` is the only owner of its tables.
Three kinds of test here, and the distinction matters:
1. **Structural** — no module outside the lexicon may define a second copy of
an owned table. Goes red if a duplicate is reintroduced.
2. **Caller provenance** — the set of modules importing each owned symbol is
recorded. Goes red when a new consumer appears, forcing a decision rather
than a silent widening.
3. **Recorded decisions** — every divergence the lexicon preserves on purpose
is pinned here with its reason, so "unify these" becomes a test change
someone has to justify.
A fourth group pins **current defective behaviour** that Phase 2B will fix.
Those tests are *supposed* to change; they exist so the fix cannot land
silently.
Why provenance is recorded rather than computed: PR #129 measured the serving
import closure at 227 first-party modules containing **all 13** table-owning
modules, so a static import test returns "everything is serving" and
discriminates nothing. Import-reachable is not call-reachable. The empirical
arbiter for behaviour is the 11 pinned lane SHAs, not this file.
"""
from __future__ import annotations
import ast
from pathlib import Path
import pytest
from generate import lexicon
REPO_ROOT = Path(__file__).resolve().parents[1]
#: Packages scanned for duplicate table literals. ``evals/`` is deliberately
#: excluded: ``evals/grammar_roundtrip/projection.py`` keeps its own copy of
#: the quantifier map so the lane never consumes the thing it measures, and
#: that copy is pinned by its own test.
SCANNED_PACKAGES = ("generate", "chat", "core", "teaching")
#: Owned table -> the modules that may import it. Curated, not computed.
RECORDED_CONSUMERS: dict[str, frozenset[str]] = {
"CONNECTIVES": frozenset({
"generate.proof_chain.member",
"generate.proof_chain.verb",
"generate.proof_chain.cond_member",
}),
"STRUCTURAL": frozenset({"generate.proof_chain.english"}),
"COPULAS": frozenset({"generate.proof_chain.english"}),
"COPULA_FORMS": frozenset({"generate.proof_chain.member"}),
# Auxiliary-role consumers of the same be-form inventory. Found by the
# duplicate scanner below, not by grepping for COPULA-ish names — which is
# precisely why the structural test exists.
"BE_FINITE": frozenset({"generate.realizer_guard", "chat.runtime"}),
"SENTENTIAL_NOT": frozenset({
"generate.proof_chain.english",
"generate.proof_chain.member",
}),
"NEGATION_BEARING": frozenset({"generate.proof_chain.english"}),
"NEGATION_BEARING_WITH_NOT": frozenset({"generate.proof_chain.member"}),
"QUANTIFIER_LEAD": frozenset({"generate.proof_chain.english"}),
"QUANTIFIER_TOKENS": frozenset({"generate.proof_chain.member"}),
"PLURAL_QUANTIFIERS": frozenset({"generate.templates"}),
"PREDICATE_DISPLAY": frozenset({
"generate.templates",
"generate.semantic_templates",
}),
"DISCOURSE_PREDICATE_DISPLAY": frozenset({"generate.discourse_planner"}),
"IRREGULAR_PLURALS": frozenset({"generate.templates"}),
"IRREGULAR_SINGULARS": frozenset({"generate.proof_chain.member"}),
"READER_IRREGULAR_SINGULARS": frozenset({"generate.meaning_graph.reader"}),
}
#: The tables a duplicate literal would be a duplicate *of*. Frozen as
#: comparable values (dicts -> sorted item tuples, sets -> frozensets).
OWNED_TABLES: dict[str, object] = {
"CONNECTIVES": lexicon.CONNECTIVES,
"STRUCTURAL": lexicon.STRUCTURAL,
# COPULAS / COPULA_FORMS / BE_FINITE are all views of one inventory, so a
# single entry covers them; the report names the fact, not every alias.
"BE_FINITE_FORMS": lexicon.BE_FINITE,
"NEGATION_BEARING": lexicon.NEGATION_BEARING,
"NEGATION_BEARING_WITH_NOT": lexicon.NEGATION_BEARING_WITH_NOT,
"QUANTIFIER_LEAD": lexicon.QUANTIFIER_LEAD,
"QUANTIFIER_TOKENS": lexicon.QUANTIFIER_TOKENS,
"PLURAL_QUANTIFIERS": lexicon.PLURAL_QUANTIFIERS,
"PREDICATE_DISPLAY": tuple(sorted(lexicon.PREDICATE_DISPLAY.items())),
"IRREGULAR_PLURALS": tuple(sorted(lexicon.IRREGULAR_PLURALS.items())),
"IRREGULAR_SINGULARS": tuple(sorted(lexicon.IRREGULAR_SINGULARS.items())),
}
def _python_files() -> list[Path]:
out: list[Path] = []
for pkg in SCANNED_PACKAGES:
root = REPO_ROOT / pkg
if root.is_dir():
out.extend(p for p in root.rglob("*.py") if "__pycache__" not in p.parts)
return out
def _module_name(path: Path) -> str:
return ".".join(path.relative_to(REPO_ROOT).with_suffix("").parts)
def _frozen_literal(node: ast.AST) -> object | None:
"""A comparable value for a set/dict/frozenset(...) literal, else None."""
target = node
if (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Name)
and node.func.id == "frozenset"
and len(node.args) == 1
):
target = node.args[0]
if not isinstance(target, (ast.Dict, ast.Set)):
return None
try:
value = ast.literal_eval(target)
except (ValueError, SyntaxError, TypeError):
return None
if isinstance(value, dict):
if not all(isinstance(k, str) for k in value):
return None
return tuple(sorted(value.items()))
if isinstance(value, (set, frozenset)):
return frozenset(value)
return None
# --------------------------------------------------------------------------
# 1. Structural — one definition per fact
# --------------------------------------------------------------------------
def test_no_module_outside_the_lexicon_defines_an_owned_table() -> None:
"""A second copy of an owned table anywhere in the scanned packages is a
regression of the whole phase. Mutation check: paste any owned literal
back into its old home and this goes red."""
lexicon_path = REPO_ROOT / "generate" / "lexicon.py"
# Keyed by (file, line) so one literal is reported once — a
# ``frozenset({...})`` call and its inner set are two AST nodes.
hits: dict[tuple[str, object], set[str]] = {}
for path in _python_files():
if path == lexicon_path:
continue
tree = ast.parse(path.read_text(encoding="utf-8"))
for node in ast.walk(tree):
frozen = _frozen_literal(node)
if frozen is None:
continue
for name, owned in OWNED_TABLES.items():
if frozen == owned:
key = (str(path.relative_to(REPO_ROOT)), getattr(node, "lineno", "?"))
hits.setdefault(key, set()).add(name)
duplicates = [
f"{file}:{line} redefines lexicon.{'/'.join(sorted(names))}"
for (file, line), names in sorted(hits.items())
]
assert not duplicates, "duplicate linguistic tables:\n " + "\n ".join(duplicates)
def test_the_duplicate_scanner_can_actually_find_a_duplicate(tmp_path: Path) -> None:
"""The scanner above is worthless if it cannot detect the thing it forbids.
Feed it a known duplicate and require a hit."""
planted = tmp_path / "planted.py"
planted.write_text(
'X = frozenset({"if", "then", "or", "and", "either"})\n', encoding="utf-8"
)
tree = ast.parse(planted.read_text(encoding="utf-8"))
hits = [
name
for node in ast.walk(tree)
for name, owned in OWNED_TABLES.items()
if (frozen := _frozen_literal(node)) is not None and frozen == owned
]
assert "CONNECTIVES" in hits
# --------------------------------------------------------------------------
# 2. Caller provenance
# --------------------------------------------------------------------------
def _actual_consumers() -> dict[str, set[str]]:
found: dict[str, set[str]] = {}
for path in _python_files():
tree = ast.parse(path.read_text(encoding="utf-8"))
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and node.module == "generate.lexicon":
for alias in node.names:
found.setdefault(alias.name, set()).add(_module_name(path))
return found
def test_every_lexicon_consumer_is_recorded() -> None:
"""A new importer of an owned table must be added to RECORDED_CONSUMERS.
That is the point: widening a table's reach becomes a decision with a
diff, not a side effect."""
actual = _actual_consumers()
unexpected = {
name: sorted(mods - RECORDED_CONSUMERS.get(name, frozenset()))
for name, mods in actual.items()
if mods - RECORDED_CONSUMERS.get(name, frozenset())
}
assert not unexpected, f"unrecorded lexicon consumers: {unexpected}"
def test_every_recorded_consumer_still_imports_what_it_claims() -> None:
"""The other direction — a stale record is also a defect, because it makes
the provenance table lie about who depends on what."""
actual = _actual_consumers()
stale = {
name: sorted(mods - actual.get(name, set()))
for name, mods in RECORDED_CONSUMERS.items()
if mods - actual.get(name, set())
}
assert not stale, f"recorded consumers that no longer import: {stale}"
# --------------------------------------------------------------------------
# 3. Recorded decisions — derivations that can no longer drift
# --------------------------------------------------------------------------
def test_structural_is_connectives_plus_therefore() -> None:
assert lexicon.STRUCTURAL == lexicon.CONNECTIVES | {"therefore"}
assert "therefore" not in lexicon.CONNECTIVES
def test_copulas_is_exactly_the_set_view_of_copula_forms() -> None:
assert lexicon.COPULAS == frozenset(lexicon.COPULA_FORMS)
assert lexicon.COPULA_FORMS == ("is", "are", "was", "were"), "order is load-bearing"
def test_the_two_negation_sets_differ_by_exactly_bare_not() -> None:
"""v2-EN normalizes ``<copula> not`` and so must let ``not`` reach that
rule; v3-MEM refuses every non-copular ``not``. Both bands serve, so
unifying them changes what CORE refuses — Phase 2B, not 2A."""
assert lexicon.NEGATION_BEARING_WITH_NOT - lexicon.NEGATION_BEARING == {"not"}
assert "not" not in lexicon.NEGATION_BEARING
def test_quantifier_lead_is_a_strict_subset_of_quantifier_tokens() -> None:
"""Measured before the migration: member's 25 tokens were a strict
superset of english's 8, agreeing on all 8. Derivation preserves that."""
assert lexicon.QUANTIFIER_LEAD < lexicon.QUANTIFIER_TOKENS
assert lexicon.QUANTIFIER_TOKENS - lexicon.QUANTIFIER_LEAD == lexicon.QUANTIFIER_NON_LEAD
def test_plural_quantifiers_excludes_the_singular_taking_leads() -> None:
"""``every``/``each`` lead a categorical reading but take a SINGULAR noun
("each dog is"), so their absence from PLURAL_QUANTIFIERS is correct, not
a coverage gap. This is why the two sets are different facts rather than
divergent copies."""
for singular_taking in ("every", "each"):
assert singular_taking in lexicon.QUANTIFIER_LEAD
assert singular_taking not in lexicon.PLURAL_QUANTIFIERS
def test_discourse_display_diverges_from_the_shared_table_on_exactly_one_key() -> None:
"""``is_defined_as`` -> "is" is a deliberate register choice (the short
copula reads as prose mid-paragraph). ``belongs_to`` is derived, so it
cannot drift."""
shared, discourse = lexicon.PREDICATE_DISPLAY, lexicon.DISCOURSE_PREDICATE_DISPLAY
differing = {k for k in discourse if shared.get(k) != discourse[k]}
assert differing == {"is_defined_as"}
assert discourse["is_defined_as"] == "is"
assert discourse["belongs_to"] == shared["belongs_to"]
def test_discourse_display_scope_is_two_entries_on_purpose() -> None:
"""Widening this to the full 26 would silently change paragraph rendering
for 24 predicates that currently fall through to
``predicate.replace("_", " ")``. Scope is preserved exactly."""
assert set(lexicon.DISCOURSE_PREDICATE_DISPLAY) == {"is_defined_as", "belongs_to"}
assert len(lexicon.PREDICATE_DISPLAY) == 26
def test_the_two_number_tables_are_inverse_directions_not_copies() -> None:
"""§1.3 counted "3 copies of irregular plurals, all diverging." Measured,
one is a PLURALIZER (singular -> plural) and the others SINGULARIZERS
(plural -> singular). Comparing them as copies is a category error."""
assert lexicon.IRREGULAR_PLURALS["child"] == "children"
assert lexicon.IRREGULAR_SINGULARS["children"] == "child"
# Non-invariant entries present in both go opposite ways.
round_trips = [
(sing, plur)
for sing, plur in lexicon.IRREGULAR_PLURALS.items()
if sing != plur and plur in lexicon.IRREGULAR_SINGULARS
]
assert round_trips, "expected overlap between the two directions"
for sing, plur in round_trips:
assert lexicon.IRREGULAR_SINGULARS[plur] == sing, f"{sing}/{plur} not inverse"
def test_reader_number_table_is_a_derived_subset_that_cannot_drift() -> None:
"""The reader's 8 values were measured to AGREE with the 29-entry table
entry for entry — the set difference in the reader's favour was empty. So
only its key list is local; the values are derived."""
assert set(lexicon.READER_IRREGULAR_SINGULARS) == set(lexicon.READER_SINGULAR_KEYS)
assert len(lexicon.READER_SINGULAR_KEYS) == 8
for key, value in lexicon.READER_IRREGULAR_SINGULARS.items():
assert lexicon.IRREGULAR_SINGULARS[key] == value
assert set(lexicon.READER_IRREGULAR_SINGULARS) < set(lexicon.IRREGULAR_SINGULARS)
# --------------------------------------------------------------------------
# 4. Defects Phase 2B must fix — pinned so the fix cannot land silently
# --------------------------------------------------------------------------
@pytest.mark.parametrize(
("plural", "current_wrong_singular"),
[
("wolves", "wolve"),
("leaves", "leave"),
("knives", "knive"),
("news", "new"),
("species", "specy"),
],
)
def test_reader_silently_mis_singularizes_uncovered_plurals(
plural: str, current_wrong_singular: str
) -> None:
"""CURRENT BEHAVIOUR, deliberately pinned as wrong.
``reader.py``'s comment claims an unrecognized plural "REFUSES rather
than guessing a wrong singular (wrong=0)". The code does not implement
that: ``_singularize`` falls through to a bare ``-s`` strip, so uncovered
plurals mint corrupted entity ids. ``news`` -> ``new`` and ``species`` ->
``specy`` are exactly the corruptions ``member.py``'s table comment says
its table exists to prevent.
Phase 2B replaces the reader's 8-key view with the full singularizer and
must flip these to ``wolf``/``leaf``/``knife``/``news``/``species``.
Changing minted ids moves trace hashes, which is why it is gated.
"""
from generate.meaning_graph.reader import _singularize
assert _singularize(plural) == current_wrong_singular
def test_reader_covered_plurals_are_already_correct() -> None:
"""The control for the test above: where the reader HAS an entry it is
right. So the defect is coverage, not wrong values — which is why 2A
could derive the values safely and leave coverage to 2B."""
from generate.meaning_graph.reader import _singularize
for plural, singular in lexicon.READER_IRREGULAR_SINGULARS.items():
assert _singularize(plural) == singular