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.
352 lines
15 KiB
Python
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
|