core/generate/morphology.py
Shay 5ba1359a79 fix(generate): CORE writes plural nouns in categorical clauses (Phase 2B)
The ratified, flag-ON v1b categorical band served "all dog are mammal" for
every noun: the A/E/I/O templates supply all/no/some + are, which demand a
plural, and the slots were filled with singular canonical entity ids. Four
of the 47 ratified corpus cases were affected, and wrong=0 never noticed
because every verdict was correct — only the prose was wrong.

Two fixes that compose into a closed round trip:

1. generate/morphology.py becomes the single owner of the number RULES
   (Phase 2A gave the TABLES one owner). Both directions live there:
   pluralize + singularize, over lexicon.IRREGULAR_PLURALS /
   IRREGULAR_SINGULARS, with mass-noun and compound-head handling.
   templates.pluralize and reader._singularize were two independent copies
   of the regular suffix rules that disagreed about which irregulars they
   knew; both now delegate.

2. render._display_noun re-inflects at render time and swaps _ for a space,
   so compound ids read "guard dogs" rather than "guards dog" or
   "guard_dog".

Result: reader gives wolves -> wolf, renderer gives wolf -> wolves. The
round trip closes.

  0 malformed of 47 (from 4)
  reader-vs-reader singularization 20/20 (from 8/20), 0 silently wrong
  s_surface_match_rate 0.0 -> 0.625, now EQUAL to s_renderable_rate, so the
    only remaining losses are surfaces the renderer cannot express at all

NEAR-MISS — widening a reader broke soundness, caught by the lane:

Routing the reader through the full 29-entry table first produced wrong=1 on
band v6-EX. ds-ex-0012 ("No fish are mammals. Therefore some fish are
mammals.") answered invalid where gold is refuted.

_singularize returning None makes the reader REFUSE, and the serving
composer tries the categorical band FIRST, falling through to more capable
bands. "fish" previously returned None (it matches no suffix rule), so v6-EX
got the case and decided it correctly. Resolving "fish" made v1b accept a
sentence it cannot decide.

Fixed by a principle, not a patch: a number-INVARIANT form is ambiguous in
number — "fish are mammals" is plural, "a fish is a mammal" is singular, and
the token cannot tell you which — so a reader that must not guess number
declines it. lexicon.INVARIANT_NUMBER is derived from the singularizer's own
key == value rows, so the rule cannot drift from the table.

  => Coverage and correctness are different axes. Fixing a corrupted VALUE is
     safe; widening ACCEPTANCE changes which band answers, and in a
     first-match composer that turns a right answer into a wrong one. Same
     family as ADR-0261 5.1 refuse-don't-drop.

Also fixed, found by testing the inverse law: the two number tables were not
mutual inverses. The pluralizer lacked cactus->cacti, fungus->fungi,
die->dice and the invariants aircraft/means/offspring, so CORE could read
"cacti" but wrote "cactuses", and would have written "aircrafts", "meanses",
"offsprings". No round trip can close across a non-invertible table.

THE LANE SHA PINS ARE BLIND TO SERVED ENGLISH:

2B changed 4 served surfaces and the pins came back 11/11 byte-identical.
The deduction_serve_v1 hashed report holds only n/counts/by_gold/
correct_by_gold/all_cases_correct/mismatch_examples — no prose at all.
Confirmed by sabotage: with _display_noun returning "SABOTAGE_" + ..., so
every clause reads "all SABOTAGE_dogs are SABOTAGE_animals",

  11 lane SHA pins                        -> 11/11 byte-identical, blind
  test_deduction_serve_lane + _license    -> 20 passed, blind
  Phase 1 grammar_roundtrip               -> 3 tests RED, caught it

This corrects #129: byte-identity of the pins is the arbiter for values and
verdicts, NOT for surface text, so the 2A/2B split is not a partition of
"changes users can see." Phase 2A's 11/11 conclusion still stands on its own
independent evidence (24/24 table-equality checks vs the pre-migration
literals), but the justification was thinner than stated.

Before Phase 1 no test in the tree would have noticed CORE's served prose
turning into word salad. That is exactly how "all dog are mammal" survived
on a ratified flag-ON band with wrong=0 intact.

Deliberately not fixed: mass-noun verb agreement ("all evidence ARE truth"
should be "is"). The copula is fixed text inside the templates, so agreeing
it changes the template shape rather than a slot, and no mass noun reaches a
categorical clause in any serve corpus. Recorded in render.py.

No lane pin edited — none moved.

[Verification]: in-worktree on CPython 3.12.13, uv sync --locked —
smoke 621 unchanged; deductive 403 passed (383 + 20 new/rewritten);
scripts/verify_lane_shas.py 11/11 (see blindness note above — gate on
grammar_roundtrip for surface work, not on these pins).
2026-07-26 18:42:42 -07:00

328 lines
12 KiB
Python

"""Deterministic English morphology for the realizer.
Handles inflection of predicates for tense, aspect, and negation, and
(Phase 2B) **noun number** in both directions.
This is intentionally rule-based and limited to the seed vocabulary.
Irregular forms are listed explicitly; regular forms follow English rules.
Phase 2A gave every *table* one owner (``generate/lexicon.py``); this module
is the single owner of the *rules* that read them. Before 2B the regular
number rules were written twice — ``templates.pluralize`` and
``meaning_graph/reader._singularize`` — and the two disagreed about which
irregulars they knew, which is how CORE came to serve ``all wolve are
mammal``.
"""
from __future__ import annotations
from generate.lexicon import (
INVARIANT_NUMBER,
IRREGULAR_PLURALS,
IRREGULAR_SINGULARS,
MASS_NOUNS,
)
# Genuinely irregular English verbs (the previous tables held only
# regular forms that the suffix rules already produce correctly).
# Listed as 3rd-person singular present → (past, past_participle).
# Coverage: ~100 common English irregulars including every verb that
# the seed packs and Phase 5 OOD lanes use whose past form does not
# follow the regular -ed rule. Adding a verb here is the cheap fix
# for english_fluency_ood gaps.md G1.
_IRREGULAR_FORMS: dict[str, tuple[str, str]] = {
# be / have / do
"is": ("was", "been"),
"are": ("were", "been"),
"has": ("had", "had"),
"does": ("did", "done"),
# mental / cognitive
"thinks": ("thought", "thought"),
"knows": ("knew", "known"),
"sees": ("saw", "seen"),
"hears": ("heard", "heard"),
"feels": ("felt", "felt"),
"finds": ("found", "found"),
"loses": ("lost", "lost"),
"means": ("meant", "meant"),
"reads": ("read", "read"),
"tells": ("told", "told"),
"says": ("said", "said"),
"thinks": ("thought", "thought"),
# motion
"goes": ("went", "gone"),
"comes": ("came", "come"),
"runs": ("ran", "run"),
"stands": ("stood", "stood"),
"sits": ("sat", "sat"),
"lies": ("lay", "lain"),
"flies": ("flew", "flown"),
"swims": ("swam", "swum"),
"rides": ("rode", "ridden"),
"drives": ("drove", "driven"),
"rises": ("rose", "risen"),
"falls": ("fell", "fallen"),
# giving / taking / holding
"gives": ("gave", "given"),
"takes": ("took", "taken"),
"brings": ("brought", "brought"),
"buys": ("bought", "bought"),
"sells": ("sold", "sold"),
"holds": ("held", "held"),
"keeps": ("kept", "kept"),
"leaves": ("left", "left"),
"lends": ("lent", "lent"),
"sends": ("sent", "sent"),
"spends": ("spent", "spent"),
"puts": ("put", "put"),
"sets": ("set", "set"),
"lets": ("let", "let"),
# building / making / breaking
"makes": ("made", "made"),
"builds": ("built", "built"),
"breaks": ("broke", "broken"),
"tears": ("tore", "torn"),
"burns": ("burned", "burned"),
"shines": ("shone", "shone"),
"draws": ("drew", "drawn"),
"writes": ("wrote", "written"),
"speaks": ("spoke", "spoken"),
"wins": ("won", "won"),
"loses": ("lost", "lost"),
# binding / connecting
"binds": ("bound", "bound"),
"winds": ("wound", "wound"),
"weaves": ("wove", "woven"),
"flies": ("flew", "flown"),
"spins": ("spun", "spun"),
"sticks": ("stuck", "stuck"),
"swears": ("swore", "sworn"),
# giving form / shape
"becomes": ("became", "become"),
"grows": ("grew", "grown"),
"blows": ("blew", "blown"),
"throws": ("threw", "thrown"),
"shakes": ("shook", "shaken"),
"wakes": ("woke", "woken"),
# eating / drinking / cooking
"eats": ("ate", "eaten"),
"drinks": ("drank", "drunk"),
"feeds": ("fed", "fed"),
"bites": ("bit", "bitten"),
"freezes": ("froze", "frozen"),
# cutting / striking
"cuts": ("cut", "cut"),
"hits": ("hit", "hit"),
"shoots": ("shot", "shot"),
"splits": ("split", "split"),
"strikes": ("struck", "struck"),
"fights": ("fought", "fought"),
"wears": ("wore", "worn"),
# sleeping / rising / etc
"sleeps": ("slept", "slept"),
"wakes": ("woke", "woken"),
"rises": ("rose", "risen"),
# finding / hiding
"hides": ("hid", "hidden"),
"seeks": ("sought", "sought"),
"catches": ("caught", "caught"),
"teaches": ("taught", "taught"),
"thinks": ("thought", "thought"),
"brings": ("brought", "brought"),
# less common / archaic
"begins": ("began", "begun"),
"deals": ("dealt", "dealt"),
"leads": ("led", "led"),
"meets": ("met", "met"),
"sits": ("sat", "sat"),
"swears": ("swore", "sworn"),
"shoots": ("shot", "shot"),
"casts": ("cast", "cast"),
"costs": ("cost", "cost"),
"hurts": ("hurt", "hurt"),
"lets": ("let", "let"),
"quits": ("quit", "quit"),
"shuts": ("shut", "shut"),
}
# Legacy compatibility — historic call sites use these names. All
# regular-verb entries here match the suffix-rule output, so removing
# them is purely cosmetic; new irregulars live in `_IRREGULAR_FORMS`.
_IRREGULAR_PAST: dict[str, str] = {v: forms[0] for v, forms in _IRREGULAR_FORMS.items()}
_IRREGULAR_PARTICIPLE: dict[str, str] = {
# Present-participle (-ing) is almost always regular. Only handle
# the truly weird cases (lie→lying handled by the suffix rule;
# be→being is the one English present-participle that needs a
# special entry, but `is` doesn't normally surface as a content
# predicate in our realizer pipeline).
}
_IRREGULAR_PAST_PARTICIPLE: dict[str, str] = {v: forms[1] for v, forms in _IRREGULAR_FORMS.items()}
# Short -ies verbs whose base is -ie (not -y). English's "ies → y"
# rule (cries→cry, flies→fly) breaks for these short stems where the
# original lemma keeps the -ie (dies→die, lies→lie, ties→tie).
_IES_KEEP_IE: frozenset[str] = frozenset({"dies", "lies", "ties", "vies", "pies", "hies"})
def _base_form(verb_3sg: str) -> str:
if verb_3sg in _IES_KEEP_IE:
return verb_3sg[:-1]
if verb_3sg.endswith("ies"):
return verb_3sg[:-3] + "y"
if verb_3sg.endswith("es"):
return verb_3sg[:-2] if verb_3sg[:-2].endswith(("s", "sh", "ch", "x", "z", "o")) else verb_3sg[:-1]
if verb_3sg.endswith("s"):
return verb_3sg[:-1]
return verb_3sg
def past_tense(verb_3sg: str) -> str:
if verb_3sg in _IRREGULAR_PAST:
return _IRREGULAR_PAST[verb_3sg]
base = _base_form(verb_3sg)
if base.endswith("e"):
return base + "d" # make → made? no — make is irregular; bake → baked
if base.endswith("y") and len(base) > 1 and base[-2] not in "aeiou":
return base[:-1] + "ied" # cry → cried
if _is_cvc_ending(base):
return base + base[-1] + "ed" # stop → stopped, plan → planned
return base + "ed"
_VOWELS = frozenset("aeiou")
def _is_cvc_ending(base: str) -> bool:
"""True if `base` ends in consonant-vowel-consonant (excluding w/x/y),
the trigger pattern for doubling the final consonant before -ing /
-ed in English."""
if len(base) < 3:
return False
c1, v, c2 = base[-3], base[-2], base[-1]
if c2 in {"w", "x", "y"}:
return False
return (c1 not in _VOWELS) and (v in _VOWELS) and (c2 not in _VOWELS)
def present_participle(verb_3sg: str) -> str:
if verb_3sg in _IRREGULAR_PARTICIPLE:
return _IRREGULAR_PARTICIPLE[verb_3sg]
base = _base_form(verb_3sg)
if base.endswith("ie"):
return base[:-2] + "ying" # die → dying, lie → lying
if base.endswith("e") and not base.endswith("ee"):
return base[:-1] + "ing" # make → making
if _is_cvc_ending(base):
return base + base[-1] + "ing" # run → running, swim → swimming
return base + "ing"
def past_participle(verb_3sg: str) -> str:
if verb_3sg in _IRREGULAR_PAST_PARTICIPLE:
return _IRREGULAR_PAST_PARTICIPLE[verb_3sg]
return past_tense(verb_3sg)
_SIBILANT_PLURAL_ENDINGS = ("s", "sh", "ch", "x", "z")
def is_mass_noun(noun: str) -> bool:
"""Uncountable ⇒ never pluralized, even under a quantifier."""
return noun.lower() in MASS_NOUNS
def _head_and_prefix(noun: str) -> tuple[str, str]:
"""Split a canonical id into (everything-before-head, head).
Reader ids are lowercase tokens joined with ``_`` (``guard_dog``), and
number marks the **head**, which in English compounds is the last token:
``guard_dog`` → ``guard_dogs``, never ``guards_dog``. Space-joined input is
handled the same way so display strings and ids behave alike.
"""
for sep in ("_", " "):
if sep in noun:
prefix, _, head = noun.rpartition(sep)
return prefix + sep, head
return "", noun
def pluralize(noun: str) -> str:
"""Singular → plural. Table first, then closed regular rules.
Mass nouns and the empty string are returned unchanged. Compound ids
inflect on the head only.
"""
if not noun:
return noun
if is_mass_noun(noun):
return noun
prefix, head = _head_and_prefix(noun)
if head in IRREGULAR_PLURALS:
return prefix + IRREGULAR_PLURALS[head]
# Invariant number (sheep, aircraft, means, offspring, ...) — derived from
# the singularizer's own invariant rows, so the two directions agree.
if head in INVARIANT_NUMBER:
return noun
if is_mass_noun(head):
return noun
if head.endswith(_SIBILANT_PLURAL_ENDINGS):
return prefix + head + "es"
if head.endswith("y") and len(head) > 1 and head[-2] not in "aeiou":
return prefix + head[:-1] + "ies"
if head.endswith("fe"):
return prefix + head[:-2] + "ves"
if head.endswith("f"):
return prefix + head[:-1] + "ves"
return prefix + head + "s"
def singularize(noun: str) -> str | None:
"""Plural → singular, or ``None`` when not confidently a plural.
The table is consulted first and is **authoritative**: if the token
appears there, its value wins and no suffix rule runs. That is what keeps
``news``/``new`` and ``species``/``specy`` unlinked — a bare ``-s`` strip
corrupts both, and did, in served text.
``None`` (rather than a guess) is returned for anything the closed rules
do not confidently cover, so a caller can refuse instead of minting a
corrupted id.
"""
if not noun:
return None
prefix, head = _head_and_prefix(noun)
# An INVARIANT form is ambiguous in number — "fish are mammals" is plural,
# "a fish is a mammal" is singular, and the token cannot tell you which. A
# reader that must not guess number therefore has to DECLINE these, even
# though the table technically maps fish -> fish.
#
# This is load-bearing for soundness, not tidiness. The serving composer
# tries the categorical band (v1b) FIRST and falls back to later, more
# capable bands. Resolving an invariant makes v1b ACCEPT a sentence it
# cannot decide, which steals the case from the band that can: with
# "No fish are mammals. Therefore some fish are mammals." v1b answers
# "invalid" where v6-EX correctly answers "refuted" (ds-ex-0012). Declining
# keeps the fall-through intact. Same family as ADR-0261 §5.1
# refuse-don't-drop.
if head in INVARIANT_NUMBER:
return None
if head in IRREGULAR_SINGULARS:
return prefix + IRREGULAR_SINGULARS[head]
# A singular that the pluralizer knows is irregular is not a plural at all
# ("child" must not become "chil"), and neither is a known mass noun.
if head in IRREGULAR_PLURALS or is_mass_noun(head):
return None
if head.endswith("ies") and len(head) > 3:
return prefix + head[:-3] + "y"
if head.endswith(("ses", "xes", "zes", "ches", "shes")):
return prefix + head[:-2]
if head.endswith("s") and not head.endswith("ss") and len(head) > 1:
return prefix + head[:-1]
return None
def base_form(verb_3sg: str) -> str:
return _base_form(verb_3sg)