Phase 3 fixed two of the twelve branches of `_inflect_predicate` by hand and
pinned them with a hand-written oracle. The other ten kept handing whole
predicate phrases to single-verb functions, so the same root cause was still
live on **57 of 96** (branch x multi-word predicate) pairs, 9 of 12 branches:
"belongs to" --perfective--> "has belongs toed"
"belongs to" --imperfective--> "is belongs toing"
"belongs to" --past--> "belongs toed"
"is defined as" --future--> "will is defined a"
"contrasts with" --negated--> "does not contrasts with"
Every branch now routes through `morphology.inflect_phrase_head`, which applies
a single-verb inflection to the finite verb and carries tokens 2..n through
byte-identically. Two closed tables were needed because `_base_form` is a
suffix stripper and the head of every copular predicate is a form of BE:
`base_form("is")` returned "i" and `present_participle("is")` returned "iing".
Also fixes predicate-nominal object agreement. `render_step` pluralized the
subject and never the object, so it wrote "all dogs are a mammal". The object
agrees only for a predicate nominal -- a closed set of two -- because a
prepositional object carries its own number and a productive rule would write
"all claims are grounded in evidences".
Measured
--------
tail-mangling pairs 57/96 -> 0/96
g_read_rate (round-trip) 0.0 -> 0.003413 first non-zero in the arc
grammatical_coverage v1 49/49 (2 cases corrected, see below)
english_fluency_ood 117/117 + 39/39 + 13/13 unchanged
discourse_paragraph 12/12 + 6/6 + 5/5 + 1/1 unchanged
zero_code_domain_acquisition 18/18 + 30/30 + 21/21 unchanged
`gram_C14_p01` and `gram_C14_p10` expected "...defined as compound", which is
not English. Corrected, and the old string moved into `reject_surfaces` so the
case now actively rejects what it used to accept. The correction is not my
judgment: the reader independently READS "all molecules are defined as
compounds" and REFUSES the singular form, and that is precisely what took
g_read_rate off zero.
Why the invariant instead of a bigger oracle
--------------------------------------------
A per-branch oracle has to be extended by hand for each new branch, and a
branch added without one is invisible -- which is how eight survived Phase 3.
The pin is structural instead: English marks tense, number and aspect on the
finite verb, so inflection must leave tokens 2..n byte-identical. It needs no
oracle and covers branches nobody has written yet.
It is necessary, not sufficient: "does not contrasts with" preserves its tail
perfectly and is still wrong. `test_do_support_puts_the_head_in_the_bare_
infinitive` is the sufficiency half.
Mutation
--------
baseline 84 pass
inflect_phrase_head applied to the whole phrase 39 FAIL
_IRREGULAR_BASE emptied 2 FAIL
_IRREGULAR_PARTICIPLE auxiliaries removed 2 FAIL
PREDICATIVE_NOMINAL emptied 3 FAIL
PREDICATIVE_NOMINAL widened to every copular predicate 2 FAIL
The last row is the one that matters: the plausible-but-wrong fix -- "pluralize
the object under a plural subject" -- is caught by the mass-noun control.
Not fixed here, deliberately: "has not the following steps" is archaic rather
than wrong, and modernizing it to do-support is a separate judgment call.
[Verification]: smoke 621, deductive 406, lane pins 11/11 unchanged
(grammatical_coverage is not a pinned lane; no pin was edited).
439 lines
19 KiB
Python
439 lines
19 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"}),
|
|
# Phase 4: the closed set of predicates whose object is a predicate nominal
|
|
# and therefore agrees in number with the subject. Closed, not productive —
|
|
# a rule would pluralize "grounded in evidence" into "evidences".
|
|
"PREDICATIVE_NOMINAL": frozenset({"generate.templates"}),
|
|
"PREDICATE_DISPLAY": frozenset({
|
|
"generate.templates",
|
|
"generate.semantic_templates",
|
|
}),
|
|
"DISCOURSE_PREDICATE_DISPLAY": frozenset({"generate.discourse_planner"}),
|
|
# Phase 2B: generate.morphology became the single owner of the number
|
|
# RULES, so it is now the consumer of the number tables. The reader no
|
|
# longer imports a table of its own — it calls morphology.singularize —
|
|
# which is why READER_IRREGULAR_SINGULARS/READER_SINGULAR_KEYS were deleted
|
|
# rather than left behind documenting a coverage gap that no longer exists.
|
|
"IRREGULAR_PLURALS": frozenset({"generate.morphology", "generate.templates"}),
|
|
"IRREGULAR_SINGULARS": frozenset({
|
|
"generate.morphology",
|
|
"generate.proof_chain.member",
|
|
}),
|
|
"INVARIANT_NUMBER": frozenset({"generate.morphology"}),
|
|
"MASS_NOUNS": frozenset({"generate.morphology"}),
|
|
# Phase 3: the closed f/fe -> ves set, derived from the singularizer's own
|
|
# ves-rows so the rule cannot claim a word the table does not know.
|
|
"VES_PLURAL_SINGULARS": frozenset({"generate.morphology"}),
|
|
}
|
|
|
|
#: 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_invariant_number_is_derived_from_the_singularizer() -> None:
|
|
"""Invariants must not be a second hand-written list.
|
|
|
|
A hand-written list is exactly how the directions drifted: the pluralizer
|
|
lacked ``aircraft``/``means``/``offspring`` and produced "aircrafts",
|
|
"meanses", "offsprings" while the singularizer knew all three. Deriving
|
|
from the ``key == value`` rows makes that class of gap unrepresentable."""
|
|
expected = {p for p, s in lexicon.IRREGULAR_SINGULARS.items() if p == s}
|
|
assert lexicon.INVARIANT_NUMBER == expected
|
|
for word in ("sheep", "aircraft", "means", "offspring", "species", "series"):
|
|
assert word in lexicon.INVARIANT_NUMBER
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 4. Defects Phase 2B must fix — pinned so the fix cannot land silently
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("plural", "singular"),
|
|
[
|
|
# Phase 2A pinned these as WRONG (wolve / leave / knive). Phase 2B
|
|
# flipped them by routing the reader through the shared 29-entry
|
|
# singularizer.
|
|
("wolves", "wolf"),
|
|
("leaves", "leaf"),
|
|
("knives", "knife"),
|
|
("halves", "half"),
|
|
("thieves", "thief"),
|
|
("children", "child"),
|
|
("men", "man"),
|
|
# Genuinely inflected irregulars the reader could not read before.
|
|
("cacti", "cactus"),
|
|
("fungi", "fungus"),
|
|
("oxen", "ox"),
|
|
],
|
|
)
|
|
def test_reader_singularizes_irregulars_correctly(plural: str, singular: str) -> None:
|
|
"""The defect Phase 2A pinned, now fixed.
|
|
|
|
``reader.py``'s comment always claimed an unrecognized plural "REFUSES
|
|
rather than guessing a wrong singular (wrong=0)". Until 2B the code did
|
|
not implement it — ``_singularize`` fell through to a bare ``-s`` strip
|
|
and minted corrupted ids that reached served text ("all wolve are
|
|
mammal"). The comment is now true of the code.
|
|
"""
|
|
from generate.meaning_graph.reader import _singularize
|
|
|
|
assert _singularize(plural) == singular
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"invariant", ["fish", "sheep", "deer", "species", "series", "news", "means"]
|
|
)
|
|
def test_reader_declines_number_invariant_forms(invariant: str) -> None:
|
|
"""Invariants are AMBIGUOUS in number and must be declined, not resolved.
|
|
|
|
"fish are mammals" is plural; "a fish is a mammal" is singular; the token
|
|
cannot tell you which. Two independent reasons this must decline:
|
|
|
|
1. **Honesty** — resolving it is a guess about number, and 2A measured what
|
|
guessing costs: the old ``-s`` strip turned ``news`` into ``new`` and
|
|
``species`` into ``specy``.
|
|
2. **Soundness** — the serving composer tries the categorical band (v1b)
|
|
first. Resolving an invariant makes v1b *accept* a sentence it cannot
|
|
decide, stealing the case from a band that can. Measured: resolving
|
|
``fish`` made ds-ex-0012 ("No fish are mammals. Therefore some fish are
|
|
mammals.") answer ``invalid`` instead of ``refuted`` — **wrong=1 on a
|
|
ratified band.** Declining restores the fall-through.
|
|
"""
|
|
from generate.meaning_graph.reader import _singularize
|
|
|
|
assert _singularize(invariant) is None
|
|
|
|
|
|
@pytest.mark.parametrize("not_a_plural", ["child", "evidence", "wolf", "", "ss"])
|
|
def test_reader_declines_rather_than_guessing(not_a_plural: str) -> None:
|
|
"""The other half of wrong=0: a singular, a mass noun, or anything the
|
|
closed rules do not confidently cover returns ``None`` so the caller can
|
|
refuse instead of minting a corrupted id. Without this, ``child`` would
|
|
become ``chil``."""
|
|
from generate.meaning_graph.reader import _singularize
|
|
|
|
assert _singularize(not_a_plural) is None
|
|
|
|
|
|
def test_regular_plurals_still_singularize() -> None:
|
|
"""Control: widening the irregular table must not break the regular rule
|
|
that handles the overwhelming majority of real input."""
|
|
from generate.meaning_graph.reader import _singularize
|
|
|
|
assert _singularize("cars") == "car"
|
|
assert _singularize("glasses") == "glass"
|
|
assert _singularize("cities") == "city"
|
|
|
|
|
|
def test_the_two_number_directions_are_mutual_inverses() -> None:
|
|
"""Every irregular the singularizer knows, the pluralizer can produce.
|
|
|
|
This is the law that makes read/write agreement possible at all: if CORE
|
|
can read ``cacti`` but writes ``cactuses``, no round trip can close. Phase
|
|
2B added the three missing inverses (cactus/fungus/die) and derived
|
|
:data:`lexicon.INVARIANT_NUMBER` from the singularizer's own invariant
|
|
rows, so ``aircraft``/``means``/``offspring`` stop becoming "aircrafts",
|
|
"meanses", "offsprings".
|
|
"""
|
|
from generate.morphology import pluralize
|
|
|
|
broken = {
|
|
singular: (pluralize(singular), plural)
|
|
for plural, singular in lexicon.IRREGULAR_SINGULARS.items()
|
|
if pluralize(singular) != plural
|
|
}
|
|
assert not broken, f"singular -> plural does not round-trip: {broken}"
|
|
|
|
|
|
def test_mass_nouns_and_compounds_inflect_correctly() -> None:
|
|
"""Two cases the categorical renderer depends on: mass nouns must not take
|
|
a plural ("all evidence", not "all evidences"), and an English compound
|
|
inflects on its HEAD ("guard dogs", not "guards dog")."""
|
|
from generate.morphology import pluralize
|
|
|
|
assert pluralize("evidence") == "evidence"
|
|
assert pluralize("knowledge") == "knowledge"
|
|
assert pluralize("guard_dog") == "guard_dogs"
|
|
assert pluralize("guard dog") == "guard dogs"
|