diff --git a/docs/plans/grammar-unification-2026-07-26.md b/docs/plans/grammar-unification-2026-07-26.md index 788d16c6..95d2619d 100644 --- a/docs/plans/grammar-unification-2026-07-26.md +++ b/docs/plans/grammar-unification-2026-07-26.md @@ -723,6 +723,56 @@ Deliver: | the gap is closed for good | new cases exercise quantifier × copular; reverting either fix goes red | | no regression | `english_fluency_ood` 117/117 + 39/39 holds; smoke/deductive unchanged | +**RESULT — all three met.** Agreement **26/26** (from 17/26). `english_fluency_ood` +**117/117 + 39/39** (+ dev 13/13) unchanged. New construction **C14 +`quantified_copular`**, 13 cases, `grammatical_coverage` v1 **49/49**. + +Root causes, both single-point as §1.4 predicted: + +1. `base_form` is a **single-verb** function and was applied to whole phrases, + stripping the final word's last character-class. Fixed by + `morphology.agree_plural_phrase`, which inflects the **head** (the finite + verb is the first token of every humanized predicate) and carries the rest + through. `base_form` itself is left single-verb and **pinned as still wrong + on a phrase**, so nobody "fixes" the symptom in the wrong place. +2. The plural branch never consulted `copular` — it returned the bare base. Now + it agrees the phrase. The plural **negated** branch had the same defect + (`do not is defined a`) and is fixed with it: a plural copula takes a bare + `not`, everything else takes do-support. + +**Two further defects found while writing the cases**, both genuine and both +fixed here: + +- `base_form("causes")` → **`"caus"`**. The `-es` sibilant rule fired on a + stem ending in a single `s`; it should require a doubled `ss` ("passes" → + "pass") because a single `s` is nearly always a stem ending in `e` that took a + plain `-s` ("causes" → "cause"). +- `pluralize("proof")` → **`"prooves"`**. `f`/`fe` → `ves` is **not productive + in English** (proof→proofs, chief→chiefs, roof→roofs); it applies to a closed + set. Now derived as `lexicon.VES_PLURAL_SINGULARS` from the `ves` rows of + `IRREGULAR_SINGULARS`, so the rule cannot claim a word the table does not + know. **Phase 2B had put `pluralize` on the serving path**, so this one was + live. + +**The first draft of the C14 cases could not fail.** `must_contain` and +`word_order` listed quantifier, subject and object — but *not the verb* — so +`all molecules is defined a compound` passed. Reverting the fix left 47/47 green. +Rewritten with the agreed verb in both constraints plus `reject_surfaces` +carrying the **actual** pre-fix output (computed by running the reverted code, +not guessed). Mutation now: + +| reverted fix | C14 | +|---|---| +| baseline | 13/13 | +| phrase-head plural agreement | **4/13** | +| `-es` stem rule | **12/13** | +| closed `ves` set | **11/13** | + +The 4 survivors of the first mutation are the mass-noun and unchanged controls, +which is the correct behaviour — `all evidence is grounded in truth` must *not* +pluralize. Same lesson as the lane pins in #133: a pin that cannot fail guards +nothing, and the only way to know is to break the thing on purpose. + ### Phase 4 — Resolve `realize_semantic` vs `realize_target` — **authorization gate** *Touches:* `core/cognition/pipeline.py` or the eval lanes. **First phase that can diff --git a/evals/grammar_roundtrip/contract.md b/evals/grammar_roundtrip/contract.md index ffacd703..26f5bfd9 100644 --- a/evals/grammar_roundtrip/contract.md +++ b/evals/grammar_roundtrip/contract.md @@ -65,7 +65,7 @@ this arc's direction (plan §6). | corpus | source | size | |---|---|---| -| positive graphs | committed `english_fluency_ood` + `grammatical_coverage` case files | 280 | +| positive graphs | committed `english_fluency_ood` + `grammatical_coverage` case files | 293 | | positive surfaces | in-module, each verified comprehensible by probe | 8 | | negative surfaces | hand-authored salad + deterministic token shuffles of the positives | 16 | @@ -86,7 +86,7 @@ grammar checking. Shuffling uses a fixed rotation rather than a PRNG so ## v1 baseline — measured on `main` @ `9696443a` ``` -graph_cases 280 +graph_cases 293 g_write_rate 1.000 g_read_rate 0.000 <-- CORE reads 0% of what it writes g_propositions_expected 370 diff --git a/evals/grammatical_coverage/public/v1/cases.jsonl b/evals/grammatical_coverage/public/v1/cases.jsonl index 7328ec2f..0cea81d1 100644 --- a/evals/grammatical_coverage/public/v1/cases.jsonl +++ b/evals/grammatical_coverage/public/v1/cases.jsonl @@ -34,3 +34,16 @@ {"id": "gram_C13_p01", "construction": "C13", "construction_name": "imperfective_aspect", "proposition_graph": {"nodes": [{"node_id": "n1", "subject": "wisdom", "predicate": "follows", "obj": "knowledge", "aspect": "imperfective"}], "edges": []}, "accept_surfaces": ["wisdom is following knowledge"], "constraints": {"must_contain": ["wisdom", "is", "following", "knowledge"], "word_order": ["wisdom", "is", "following", "knowledge"], "max_words": 8}} {"id": "gram_C13_p02", "construction": "C13", "construction_name": "imperfective_aspect", "proposition_graph": {"nodes": [{"node_id": "n1", "subject": "evidence", "predicate": "supports", "obj": "truth", "aspect": "imperfective"}], "edges": []}, "accept_surfaces": ["evidence is supporting truth"], "constraints": {"must_contain": ["evidence", "is", "supporting", "truth"], "word_order": ["evidence", "is", "supporting", "truth"], "max_words": 8}} {"id": "gram_C13_p03", "construction": "C13", "construction_name": "imperfective_aspect", "proposition_graph": {"nodes": [{"node_id": "n1", "subject": "dawn", "predicate": "precedes", "obj": "day", "aspect": "imperfective"}], "edges": []}, "accept_surfaces": ["dawn is preceding day"], "constraints": {"must_contain": ["dawn", "is", "preceding", "day"], "word_order": ["dawn", "is", "preceding", "day"], "max_words": 8}} +{"id": "gram_C14_p01", "construction": "C14", "construction_name": "quantified_copular", "proposition_graph": {"nodes": [{"node_id": "n1", "subject": "molecule", "predicate": "is_defined_as", "obj": "compound", "quantifier": "all"}], "edges": []}, "accept_surfaces": ["all molecules are defined as compound"], "constraints": {"must_contain": ["all", "molecules", "are", "defined", "as", "compound"], "word_order": ["all", "molecules", "are", "defined", "as", "compound"], "max_words": 8}, "reject_surfaces": ["all molecules is defined a compound"]} +{"id": "gram_C14_p02", "construction": "C14", "construction_name": "quantified_copular", "proposition_graph": {"nodes": [{"node_id": "n1", "subject": "theory", "predicate": "is_caused_by", "obj": "observation", "quantifier": "some"}], "edges": []}, "accept_surfaces": ["some theories are caused by observation"], "constraints": {"must_contain": ["some", "theories", "are", "caused", "by", "observation"], "word_order": ["some", "theories", "are", "caused", "by", "observation"], "max_words": 8}, "reject_surfaces": ["some theories is caused by observation"]} +{"id": "gram_C14_p03", "construction": "C14", "construction_name": "quantified_copular", "proposition_graph": {"nodes": [{"node_id": "n1", "subject": "wolf", "predicate": "belongs_to", "obj": "pack", "quantifier": "all"}], "edges": []}, "accept_surfaces": ["all wolves belong to pack"], "constraints": {"must_contain": ["all", "wolves", "belong", "to", "pack"], "word_order": ["all", "wolves", "belong", "to", "pack"], "max_words": 7}, "reject_surfaces": ["all wolfs belongs to pack"]} +{"id": "gram_C14_p04", "construction": "C14", "construction_name": "quantified_copular", "proposition_graph": {"nodes": [{"node_id": "n1", "subject": "claim", "predicate": "is_grounded_in", "obj": "evidence", "quantifier": "most"}], "edges": []}, "accept_surfaces": ["most claims are grounded in evidence"], "constraints": {"must_contain": ["most", "claims", "are", "grounded", "in", "evidence"], "word_order": ["most", "claims", "are", "grounded", "in", "evidence"], "max_words": 8}, "reject_surfaces": ["most claims is grounded in evidence"]} +{"id": "gram_C14_p05", "construction": "C14", "construction_name": "quantified_copular", "proposition_graph": {"nodes": [{"node_id": "n1", "subject": "method", "predicate": "contrasts_with", "obj": "intuition", "quantifier": "few"}], "edges": []}, "accept_surfaces": ["few methods contrast with intuition"], "constraints": {"must_contain": ["few", "methods", "contrast", "with", "intuition"], "word_order": ["few", "methods", "contrast", "with", "intuition"], "max_words": 7}, "reject_surfaces": ["few methods contrasts with intuition"]} +{"id": "gram_C14_p06", "construction": "C14", "construction_name": "quantified_copular", "proposition_graph": {"nodes": [{"node_id": "n1", "subject": "proof", "predicate": "is_verified_as", "obj": "sound", "quantifier": "many"}], "edges": []}, "accept_surfaces": ["many proofs are verified as sound"], "constraints": {"must_contain": ["many", "proofs", "are", "verified", "as", "sound"], "word_order": ["many", "proofs", "are", "verified", "as", "sound"], "max_words": 8}, "reject_surfaces": ["many proofs is verified a sound"]} +{"id": "gram_C14_p07", "construction": "C14", "construction_name": "quantified_copular", "proposition_graph": {"nodes": [{"node_id": "n1", "subject": "axiom", "predicate": "is_distinguished_from", "obj": "theorem", "quantifier": "all"}], "edges": []}, "accept_surfaces": ["all axioms are distinguished from theorem"], "constraints": {"must_contain": ["all", "axioms", "are", "distinguished", "from", "theorem"], "word_order": ["all", "axioms", "are", "distinguished", "from", "theorem"], "max_words": 8}, "reject_surfaces": ["all axioms is distinguished from theorem"]} +{"id": "gram_C14_p08", "construction": "C14", "construction_name": "quantified_copular", "proposition_graph": {"nodes": [{"node_id": "n1", "subject": "evidence", "predicate": "is_grounded_in", "obj": "truth", "quantifier": "all"}], "edges": []}, "accept_surfaces": ["all evidence is grounded in truth"], "constraints": {"must_contain": ["all", "evidence", "is", "grounded", "in", "truth"], "word_order": ["all", "evidence", "is", "grounded", "in", "truth"], "max_words": 8}} +{"id": "gram_C14_p09", "construction": "C14", "construction_name": "quantified_copular", "proposition_graph": {"nodes": [{"node_id": "n1", "subject": "knowledge", "predicate": "is_defined_as", "obj": "justified", "quantifier": "all"}], "edges": []}, "accept_surfaces": ["all knowledge is defined as justified"], "constraints": {"must_contain": ["all", "knowledge", "is", "defined", "as", "justified"], "word_order": ["all", "knowledge", "is", "defined", "as", "justified"], "max_words": 8}} +{"id": "gram_C14_p10", "construction": "C14", "construction_name": "quantified_copular", "proposition_graph": {"nodes": [{"node_id": "n1", "subject": "molecule", "predicate": "is_defined_as", "obj": "compound", "quantifier": "all", "negated": true}], "edges": []}, "accept_surfaces": ["all molecules are not defined as compound"], "constraints": {"must_contain": ["all", "molecules", "are", "not", "defined", "as", "compound"], "word_order": ["all", "molecules", "are", "not", "defined", "as", "compound"], "max_words": 9}, "reject_surfaces": ["all molecules do not is defined a compound"]} +{"id": "gram_C14_p11", "construction": "C14", "construction_name": "quantified_copular", "proposition_graph": {"nodes": [{"node_id": "n1", "subject": "wolf", "predicate": "belongs_to", "obj": "pack", "quantifier": "all", "negated": true}], "edges": []}, "accept_surfaces": ["all wolves do not belong to pack"], "constraints": {"must_contain": ["all", "wolves", "do", "not", "belong", "to", "pack"], "word_order": ["all", "wolves", "do", "not", "belong", "to", "pack"], "max_words": 9}, "reject_surfaces": ["all wolfs do not belongs to pack"]} +{"id": "gram_C14_p12", "construction": "C14", "construction_name": "quantified_copular", "proposition_graph": {"nodes": [{"node_id": "n1", "subject": "molecule", "predicate": "causes", "obj": "reaction", "quantifier": "all"}], "edges": []}, "accept_surfaces": ["all molecules cause reaction"], "constraints": {"must_contain": ["all", "molecules", "cause", "reaction"], "word_order": ["all", "molecules", "cause", "reaction"], "max_words": 6}, "reject_surfaces": ["all molecules caus reaction"]} +{"id": "gram_C14_p13", "construction": "C14", "construction_name": "quantified_copular", "proposition_graph": {"nodes": [{"node_id": "n1", "subject": "observation", "predicate": "evidences", "obj": "claim", "quantifier": "many"}], "edges": []}, "accept_surfaces": ["many observations evidence claim"], "constraints": {"must_contain": ["many", "observations", "evidence", "claim"], "word_order": ["many", "observations", "evidence", "claim"], "max_words": 6}} diff --git a/generate/lexicon.py b/generate/lexicon.py index bec55f5f..d5a07935 100644 --- a/generate/lexicon.py +++ b/generate/lexicon.py @@ -282,6 +282,22 @@ IRREGULAR_SINGULARS: Final[dict[str, str]] = { "offspring": "offspring", "aircraft": "aircraft", "news": "news", } +#: Singulars whose plural is formed by ``f``/``fe`` → ``ves``, **derived** from +#: the rows of :data:`IRREGULAR_SINGULARS` whose plural actually ends in +#: ``ves`` — wolf, knife, life, leaf, half, elf, loaf, thief. +#: +#: The ``f`` → ``ves`` change is **not productive in English**: it applies to a +#: closed set, and the productive form is a plain ``-s`` (proof→proofs, +#: chief→chiefs, roof→roofs, belief→beliefs). Applying it as a suffix rule made +#: ``pluralize("proof")`` return **"prooves"**, which Phase 2B had put on the +#: serving path. Derived rather than hand-listed so the rule cannot claim a +#: word the number table does not actually know. +VES_PLURAL_SINGULARS: Final[frozenset[str]] = frozenset( + singular + for plural, singular in IRREGULAR_SINGULARS.items() + if plural.endswith("ves") and plural != singular +) + #: Nouns whose plural equals their singular, **derived** from the invariant #: rows of :data:`IRREGULAR_SINGULARS` (the rows where key == value). #: diff --git a/generate/morphology.py b/generate/morphology.py index 58a2b05b..78c399f4 100644 --- a/generate/morphology.py +++ b/generate/morphology.py @@ -17,6 +17,7 @@ from __future__ import annotations from generate.lexicon import ( INVARIANT_NUMBER, + VES_PLURAL_SINGULARS, IRREGULAR_PLURALS, IRREGULAR_SINGULARS, MASS_NOUNS, @@ -167,18 +168,65 @@ _IRREGULAR_PAST_PARTICIPLE: dict[str, str] = {v: forms[1] for v, forms in _IRREG _IES_KEEP_IE: frozenset[str] = frozenset({"dies", "lies", "ties", "vies", "pies", "hies"}) +#: Stem endings that take ``-es`` for the 3sg rather than a bare ``-s``. +#: +#: ``ss`` and not ``s``: the sibilant rule fires on a DOUBLED s ("passes" -> +#: "pass"), while a stem ending in a single ``s`` is almost always a stem +#: ending in ``e`` that took a plain ``-s`` ("causes" -> "cause"). Before this +#: distinction, ``base_form("causes")`` returned **"caus"**, which is the +#: single-verb half of the 9-of-26 agreement defect. +_ES_STEM_ENDINGS = ("ss", "sh", "ch", "x", "z", "o") + + 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] + return verb_3sg[:-2] if verb_3sg[:-2].endswith(_ES_STEM_ENDINGS) else verb_3sg[:-1] if verb_3sg.endswith("s"): return verb_3sg[:-1] return verb_3sg +#: 3sg present -> plural present, for the forms where the plural is not simply +#: the bare stem. Everything else pluralizes by dropping the ``-s``. +_PLURAL_PRESENT: dict[str, str] = { + "is": "are", "are": "are", + "has": "have", "have": "have", + "does": "do", "do": "do", + "was": "were", "were": "were", +} + + +def plural_present(verb_3sg: str) -> str: + """A third-person-singular verb → its plural-subject present form.""" + if verb_3sg in _PLURAL_PRESENT: + return _PLURAL_PRESENT[verb_3sg] + return _base_form(verb_3sg) + + +def agree_plural_phrase(phrase: str) -> str: + """Put a whole predicate PHRASE into plural agreement. + + Number is marked on the finite verb, which is the first token of every + humanized predicate ("is defined as", "has the following steps", + "belongs to", "contrasts with"). Only that token inflects; the rest is + carried through untouched. + + This exists because :func:`base_form` is a SINGLE-VERB function and was + being applied to whole phrases, stripping the last character-class of the + final word: "is defined as" -> "is defined a", "has the following steps" + -> "has the following step". Nine of the 26 seed predicates were wrong + that way, and every multi-word one was. + """ + if not phrase: + return phrase + head, sep, rest = phrase.partition(" ") + return plural_present(head) + sep + rest + + def past_tense(verb_3sg: str) -> str: if verb_3sg in _IRREGULAR_PAST: return _IRREGULAR_PAST[verb_3sg] @@ -272,10 +320,12 @@ def pluralize(noun: str) -> str: 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" + # f/fe -> ves is a CLOSED set, not a productive rule: proof->proofs and + # chief->chiefs, but wolf->wolves. The set is derived from the number + # table's own ves-rows, so the rule can never claim a word the table does + # not know. Without this, pluralize("proof") returned "prooves". + if head in VES_PLURAL_SINGULARS: + return prefix + (head[:-2] if head.endswith("fe") else head[:-1]) + "ves" return prefix + head + "s" diff --git a/generate/templates.py b/generate/templates.py index 613cf038..588ac1b2 100644 --- a/generate/templates.py +++ b/generate/templates.py @@ -23,6 +23,7 @@ from generate.articulation_legality import ( ) from generate.graph_planner import RhetoricalMove from generate.morphology import ( + agree_plural_phrase, base_form, is_mass_noun, past_participle, @@ -102,7 +103,17 @@ def _inflect_predicate( case (_, "future", False, _): return f"will {base}" case (_, _, True, True): - return f"do not {base}" + # Plural + negated. Agree the head first, then negate around it: + # a plural copula takes a bare "not" ("are not defined as"), while + # any other verb needs do-support ("do not have the following + # steps", "do not belong to"). Previously this was + # ``f"do not {base}"`` over the whole phrase, which produced + # "do not is defined a". + agreed = agree_plural_phrase(predicate_h) + head, sep, rest = agreed.partition(" ") + if head in ("are", "were"): + return f"{head} not{sep}{rest}" if rest else f"{head} not" + return f"do not {agreed}" case (_, _, True, False) if copular: if predicate_h.startswith("is "): return "is not " + predicate_h[3:] @@ -118,7 +129,11 @@ def _inflect_predicate( case (_, _, True, False): return f"does not {base}" case (_, _, False, True): - return base + # Plural agreement on the whole phrase, not base_form() of it. + # This is the branch the 9-of-26 defect lived in: it returned the + # bare base and never consulted ``copular``, so "is defined as" + # came back "is defined a" instead of "are defined as". + return agree_plural_phrase(predicate_h) case _: return verb diff --git a/tests/test_grammar_roundtrip.py b/tests/test_grammar_roundtrip.py index 2bb38b71..2718b934 100644 --- a/tests/test_grammar_roundtrip.py +++ b/tests/test_grammar_roundtrip.py @@ -141,7 +141,12 @@ def test_g_roundtrip_baseline_is_zero(report): revised **upward**; it must never be revised downward to accommodate a regression. """ - assert report.metrics["graph_cases"] == 280 + # 293, not the original 280: Phase 3 added 13 quantified-copular cases + # (construction C14) to grammatical_coverage/public/v1, and this lane + # harvests its graph corpus from the committed case files rather than + # holding its own copy. An exact count is the point — a corpus that grows + # or shrinks should require a deliberate edit here, not pass silently. + assert report.metrics["graph_cases"] == 293 assert report.metrics["g_write_rate"] == 1.0 assert report.metrics["g_read_rate"] == 0.0 assert report.metrics["g_exact_rate"] == 0.0 @@ -303,5 +308,5 @@ def test_positive_surfaces_are_all_inside_the_reader_envelope(): def test_graph_corpus_is_harvested_from_committed_case_files(): cases = positive_graph_cases() - assert len(cases) == 280 + assert len(cases) == 293 # +13 C14 quantified-copular (Phase 3) assert all(c.nodes for c in cases) diff --git a/tests/test_lexicon_single_source.py b/tests/test_lexicon_single_source.py index c389dc15..5ecf4a3a 100644 --- a/tests/test_lexicon_single_source.py +++ b/tests/test_lexicon_single_source.py @@ -79,6 +79,9 @@ RECORDED_CONSUMERS: dict[str, frozenset[str]] = { }), "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 diff --git a/tests/test_realizer_quantifier_agreement.py b/tests/test_realizer_quantifier_agreement.py index f4c90549..cd663c30 100644 --- a/tests/test_realizer_quantifier_agreement.py +++ b/tests/test_realizer_quantifier_agreement.py @@ -106,3 +106,114 @@ def test_is_mass_noun_known_set() -> None: assert is_mass_noun("water") assert not is_mass_noun("molecule") assert not is_mass_noun("atom") + + +# --------------------------------------------------------------------------- # +# Phase 3 — the 9-of-26 agreement defect +# +# ``_inflect_predicate`` applied ``base_form`` — a SINGLE-VERB function — to +# whole humanized predicate phrases, stripping the last character-class of the +# final word, and its plural branch never consulted ``copular``. Nine of the 26 +# seed predicates came out wrong, and every multi-word one did. +# --------------------------------------------------------------------------- # + +#: Hand-written English, NOT derived from the code under test. Deriving it would +#: make it agree by construction and measure nothing. +PLURAL_AGREEMENT_ORACLE = { + "addresses": "address", + "answers": "answer", + "belongs to": "belong to", + "causes": "cause", + "contrasts with": "contrast with", + "corrects": "correct", + "defines": "define", + "entails": "entail", + "evidences": "evidence", + "follows": "follow", + "grounds": "ground", + "has the following steps": "have the following steps", + "implies": "imply", + "is caused by": "are caused by", + "is defined as": "are defined as", + "is distinguished from": "are distinguished from", + "is grounded in": "are grounded in", + "is verified as": "are verified as", + "means": "mean", + "orders": "order", + "precedes": "precede", + "recalls": "recall", + "requires": "require", + "reveals": "reveal", + "supports": "support", + "verifies": "verify", +} + + +def test_the_oracle_covers_every_seed_predicate() -> None: + """A partial oracle would let a predicate regress unseen.""" + from generate.lexicon import PREDICATE_DISPLAY + + assert set(PLURAL_AGREEMENT_ORACLE) == set(PREDICATE_DISPLAY.values()) + + +@pytest.mark.parametrize( + ("display", "expected"), sorted(PLURAL_AGREEMENT_ORACLE.items()) +) +def test_plural_subject_agreement_matches_english(display: str, expected: str) -> None: + """26/26 against hand-written English (was 17/26). + + The nine that were wrong: belongs to, causes ("caus"), contrasts with, + has the following steps ("has the following step"), and the five + ``is …`` copulars ("is defined as" -> "is defined a"). + """ + from generate.templates import _inflect_predicate + + assert _inflect_predicate(display, plural_subject=True) == expected + + +@pytest.mark.parametrize( + ("display", "expected"), + [ + ("is defined as", "are not defined as"), + ("is grounded in", "are not grounded in"), + ("has the following steps", "do not have the following steps"), + ("belongs to", "do not belong to"), + ("supports", "do not support"), + ], +) +def test_plural_negation_agrees_then_negates(display: str, expected: str) -> None: + """Plural + negated: a plural copula takes a bare ``not``; anything else + needs do-support. Previously ``do not {base_form(phrase)}`` produced + "do not is defined a".""" + from generate.templates import _inflect_predicate + + assert _inflect_predicate(display, negated=True, plural_subject=True) == expected + + +def test_base_form_is_a_single_verb_function_and_says_so() -> None: + """The root cause, pinned directly: ``base_form`` operates on ONE verb. + + It is still wrong on a phrase — that is inherent, not a bug to fix here — + which is exactly why the plural branch calls ``agree_plural_phrase`` + instead. Pinning it stops someone "fixing" the symptom in the wrong place. + """ + from generate.morphology import agree_plural_phrase, base_form + + assert base_form("causes") == "cause" # was "caus" + assert base_form("passes") == "pass" # the sibilant rule still fires + assert base_form("is defined as") == "is defined a" # single-verb fn on a phrase + assert agree_plural_phrase("is defined as") == "are defined as" + + +@pytest.mark.parametrize( + ("singular", "plural"), + [("proof", "proofs"), ("chief", "chiefs"), ("roof", "roofs"), + ("belief", "beliefs"), ("wolf", "wolves"), ("knife", "knives")], +) +def test_f_to_ves_is_a_closed_set_not_a_rule(singular: str, plural: str) -> None: + """``f`` -> ``ves`` is not productive in English. Applying it as a suffix + rule made ``pluralize("proof")`` return "prooves", and Phase 2B had put + ``pluralize`` on the serving path.""" + from generate.morphology import pluralize as _pluralize + + assert _pluralize(singular) == plural