diff --git a/docs/plans/grammar-unification-2026-07-26.md b/docs/plans/grammar-unification-2026-07-26.md index c76fc778..413cd531 100644 --- a/docs/plans/grammar-unification-2026-07-26.md +++ b/docs/plans/grammar-unification-2026-07-26.md @@ -558,6 +558,12 @@ Deliver: `wolves`→`wolve`, `news`→`new`, `species`→`specy`. Its own comment claims it refuses; it does not. Those five are pinned as current behaviour in `tests/test_lexicon_single_source.py` and this phase must flip them. + + *Delivered differently than written:* the reader no longer holds a table at + all. `generate/morphology.py` became the single owner of the number **rules** + (2A owned the tables), and the reader calls `singularize`. That removed a + second copy of the regular suffix rules, which was the actual reason the two + sides disagreed about coverage. 3. Updated lane SHA pins — **surgical single-line edits only**, never `--update` — with the old and new hash recorded per lane. @@ -571,7 +577,98 @@ Deliver: | hash movement is intended | every moved pin explained; no pin changed that shouldn't move | **Why gated:** this changes what users see. Fixing a defect is still a serving -change, and serving changes are Shay's call. +change, and serving changes are Shay's call. *Authorized 2026-07-26.* + +**RESULT.** + +| criterion | measured | +|---|---| +| surfaces well-formed | **0 malformed of 47** (from 4); `ds-v1-0023/0024/0026/0028` all render plurals | +| readers agree | **20/20**, 0 silently wrong (from 8/20) | +| soundness preserved | `wrong=0` holds on all 7 bands; deductive **403 passed**, smoke **621** | +| round-trip | `s_surface_match_rate` **0.0 → 0.625**, and it now *equals* `s_renderable_rate` — every renderable surface returns its input exactly | +| hash movement is intended | **criterion void — the pins cannot see surfaces.** See below. | + +#### The lane SHA pins are blind to served English + +2B changed 4 of 47 served surfaces and the pins came back **11/11 +byte-identical**. That is not evidence of no change; it is evidence the +instrument cannot see this kind of change. + +Verified two ways rather than argued: + +1. **By inspection.** The `deduction_serve_v1` hashed report contains only + `n`, `counts`, `by_gold`, `correct_by_gold`, `all_cases_correct`, + `mismatch_examples`. **No surface prose at all** — verdict classifications + only. +2. **By sabotage** (the [[feedback-ask-what-if-the-thing-were-absent]] control). + Making `render._display_noun` return `"SABOTAGE_" + …`, so every categorical + clause reads `all SABOTAGE_dogs are SABOTAGE_animals`: + + | guard | caught it? | + |---|---| + | 11 lane SHA pins | **no — 11/11 still byte-identical** | + | `test_deduction_serve_lane` + `_license` (20 tests) | **no — 20 passed** | + | Phase 1 `grammar_roundtrip` | **yes — 3 tests red** | + +⇒ **Correction to #129.** That PR established the arc's arbiter as "make the +change, run the 11 pinned lanes, let byte-identity decide." For *values and +verdicts* that holds. For **surface text it decides nothing**, so the 2A/2B +split it defines is not a partition of "changes users can see." Phase 2A's +11/11 conclusion is still sound — 2A changed no value, confirmed independently +by 24/24 table-equality checks against the pre-migration literals — but the +justification was thinner than claimed, and 2B is what exposed it. + +⇒ **Before Phase 1, no test in the tree would have noticed CORE's served prose +turning into word salad.** That is precisely how `all dog are mammal` survived +on a ratified, flag-ON band with `wrong=0` intact for the whole arc. The +round-trip lane is not a nice-to-have measurement; it is the only guard that +reads what the user reads. + +⇒ **Carried forward:** any future phase touching surfaces must gate on +`grammar_roundtrip`, not on the lane pins. Adding surface text to the pinned +lane payloads would be the durable fix and is *not* done here — it would move +every pin at once and deserves its own unit. + +The Phase 1 instrument moved on its own, having been built with no knowledge of +this fix. That is the whole argument for building the instrument first. + +**The near-miss worth recording — widening a reader can break soundness.** +Routing the reader through the full 29-entry singularizer initially 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`. + +Cause: `_singularize` returning `None` makes the reader *refuse*, and the +serving composer tries the categorical band **first**, falling through to later, +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** and answer wrongly. + +Fix, and it is a principle rather than 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. That simultaneously restores the fall-through *and* is the +honest reading. `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* +(`wolves`→`wolve`) is safe; widening *acceptance* changes which band answers, +and in a first-match composer that can convert a right answer into a wrong one. +Same family as ADR-0261 §5.1 refuse-don't-drop. Bundling the two is how a +plural fix silently becomes a soundness regression. + +**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 table that is not +invertible. Pinned by `test_the_two_number_directions_are_mutual_inverses`. + +**Deliberately not fixed:** mass-noun verb agreement. "all evidence are truth" +should be "all evidence **is** truth", but the copula is fixed text inside the +A/E/I/O templates, so agreeing it changes the template shape rather than a slot. +No mass noun reaches a categorical clause in any serve corpus today. Recorded in +`render.py::_display_noun` rather than silently left. ### Phase 3 — Fix the 9-of-26 agreement defect diff --git a/generate/lexicon.py b/generate/lexicon.py index 22f4df72..bec55f5f 100644 --- a/generate/lexicon.py +++ b/generate/lexicon.py @@ -12,7 +12,7 @@ call sites need different content, this module defines one literal and Naming convention: a name is the linguistic fact, not the consumer. When a consumer needs a narrower view, the narrow name says whose view it is -(``STRUCTURAL``, ``READER_SINGULAR_KEYS``). +(``STRUCTURAL``, ``NEGATION_BEARING_WITH_NOT``). **Deliberate non-goal:** this module does not decide anything. It holds tables and derivations; the refusal/agreement logic stays with each band. @@ -256,6 +256,11 @@ IRREGULAR_PLURALS: Final[dict[str, str]] = { "analysis": "analyses", "axis": "axes", "basis": "bases", "thesis": "theses", "hypothesis": "hypotheses", "mitochondrion": "mitochondria", + # Added in Phase 2B so the two directions are mutual inverses. The + # singularizer knew cacti/fungi/dice; without these the pluralizer + # produced "cactuses"/"funguses"/"dies" and CORE could read a word it + # could not write back. + "cactus": "cacti", "fungus": "fungi", "die": "dice", } #: Plural -> singular, including invariants that map to themselves. The @@ -277,29 +282,43 @@ IRREGULAR_SINGULARS: Final[dict[str, str]] = { "offspring": "offspring", "aircraft": "aircraft", "news": "news", } -#: The 8 keys ``meaning_graph/reader.py`` currently covers. +#: Nouns whose plural equals their singular, **derived** from the invariant +#: rows of :data:`IRREGULAR_SINGULARS` (the rows where key == value). #: -#: **Recorded divergence (Phase 2A decision).** The reader's values are all -#: *correct* — they agree with :data:`IRREGULAR_SINGULARS` entry for entry — -#: so the values are derived below and only the key list is local. What is -#: wrong is the **coverage**, and the consequence is not a refusal: -#: ``reader._singularize`` falls through to a bare ``-s`` strip, so an -#: uncovered plural is silently mis-singularized rather than declined -#: (``wolves`` -> ``wolve``, ``news`` -> ``new``, ``species`` -> ``specy``). -#: The reader's own comment claims it "REFUSES rather than guessing a wrong -#: singular (wrong=0)"; the code does not implement that. -#: -#: Widening this to the full table changes minted entity ids and therefore -#: served surfaces and trace hashes, so it is **Phase 2B** work behind the -#: authorization gate. Recorded here, with the gap named, rather than fixed -#: silently. -READER_SINGULAR_KEYS: Final[tuple[str, ...]] = ( - "people", "men", "women", "children", "feet", "teeth", "mice", "geese", +#: Derived rather than re-listed because a hand-written second list is exactly +#: how the two directions drifted apart in the first place: before Phase 2B the +#: pluralizer lacked ``aircraft``/``means``/``offspring`` and produced +#: "aircrafts", "meanses", "offsprings" while the singularizer knew all three. +#: ``news`` was saved only by coincidence — it is also a mass noun. +INVARIANT_NUMBER: Final[frozenset[str]] = frozenset( + plural for plural, singular in IRREGULAR_SINGULARS.items() if plural == singular +) + +#: Uncountable nouns — they take a quantifier but stay singular, so +#: ``all evidence`` is right and ``all evidences`` is wrong. The verb still +#: agrees singular ("all evidence supports truth"), which the categorical +#: renderer does **not** yet handle; see the Phase 2B note in +#: ``proof_chain/render.py``. +#: +#: Moved here in Phase 2B: it was a single copy in ``templates.py``, but the +#: pluralizer needs it and the pluralizer now serves, so the table has to be +#: reachable from both without a second literal. Covers the abstract/epistemic +#: vocabulary in ``en_core_cognition_v1`` plus common English mass nouns. +MASS_NOUNS: Final[frozenset[str]] = frozenset( + { + # epistemic / abstract (the seed-pack vocabulary) + "evidence", "wisdom", "knowledge", "truth", "light", "darkness", + "information", "data", "music", "art", "literature", "philosophy", + "courage", "patience", "love", "hope", "fear", "grace", + "meaning", "purpose", "beauty", "justice", "freedom", + # physical mass + "water", "air", "fire", "earth", "sand", "rain", "snow", "ice", + "wood", "metal", "gold", "silver", "iron", "stone", + "blood", "flesh", "bone", + # collective / continuous + "weather", "traffic", "furniture", "luggage", "advice", + "equipment", "machinery", "scenery", "money", "news", + "research", "progress", "feedback", + } ) -#: The reader's view of :data:`IRREGULAR_SINGULARS`, restricted to -#: :data:`READER_SINGULAR_KEYS`. Derived, so the reader's 8 values cannot -#: drift from the 29-entry table they are a subset of. -READER_IRREGULAR_SINGULARS: Final[dict[str, str]] = { - key: IRREGULAR_SINGULARS[key] for key in READER_SINGULAR_KEYS -} diff --git a/generate/meaning_graph/reader.py b/generate/meaning_graph/reader.py index ff3dc218..e23ac3c2 100644 --- a/generate/meaning_graph/reader.py +++ b/generate/meaning_graph/reader.py @@ -51,7 +51,7 @@ from __future__ import annotations import re from dataclasses import dataclass, field -from generate.lexicon import READER_IRREGULAR_SINGULARS +from generate.morphology import singularize as _shared_singularize from generate.meaning_graph.model import ( Entity, MeaningGraph, @@ -62,9 +62,17 @@ from generate.meaning_graph.model import ( _ARTICLES = frozenset({"a", "an"}) -# Common irregular plurals the corpus exercises. Conservative + closed; an -# unrecognized plural REFUSES rather than guessing a wrong singular (wrong=0). -_IRREGULAR_PLURALS = READER_IRREGULAR_SINGULARS +# Phase 2B: this reader used to carry its own 8-entry plural table plus a +# private copy of the suffix rules. Both now live in generate/morphology.py, +# which reads lexicon.IRREGULAR_SINGULARS — 29 entries, the widest number +# table CORE has. +# +# The old comment here claimed "an unrecognized plural REFUSES rather than +# guessing a wrong singular (wrong=0)". That was false: the code fell through +# to a bare -s strip, so wolves -> wolve, news -> new, species -> specy, and +# those corrupted ids reached served text ("all wolve are mammal"). The shared +# singularizer treats the table as authoritative and returns None instead of +# guessing, so the comment is now true of the code. # Categorical quantifier -> the MeaningGraph predicate it mints. The predicate # vocabulary is shared between facts and the "therefore" conclusion query, and is @@ -152,15 +160,7 @@ class _Reject(Exception): def _singularize(word: str) -> str | None: """Conservative plural -> singular. None when not confidently a plural.""" - if word in _IRREGULAR_PLURALS: - return _IRREGULAR_PLURALS[word] - if word.endswith("ies") and len(word) > 3: - return word[:-3] + "y" - if word.endswith(("ses", "xes", "zes", "ches", "shes")): - return word[:-2] - if word.endswith("s") and not word.endswith("ss") and len(word) > 1: - return word[:-1] - return None + return _shared_singularize(word) def _chunk(words: list[str], detail: str) -> str: diff --git a/generate/morphology.py b/generate/morphology.py index fdba29fb..58a2b05b 100644 --- a/generate/morphology.py +++ b/generate/morphology.py @@ -1,11 +1,27 @@ """Deterministic English morphology for the realizer. -Handles inflection of predicates for tense, aspect, and negation. +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). @@ -210,5 +226,103 @@ def past_participle(verb_3sg: str) -> str: 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) diff --git a/generate/proof_chain/render.py b/generate/proof_chain/render.py index 0c28be39..c101576f 100644 --- a/generate/proof_chain/render.py +++ b/generate/proof_chain/render.py @@ -11,6 +11,7 @@ renders realized-structure DETERMINE answers — a different gear entirely). from __future__ import annotations +from generate.morphology import pluralize from generate.proof_chain.entail import ( INCONSISTENT_PREMISES, Entailment, @@ -18,6 +19,12 @@ from generate.proof_chain.entail import ( ) #: A/E/I/O categorical form → an English sentence template over (subject, predicate). +#: +#: Every template takes a PLURAL noun on both sides — ``all``/``no``/``some`` +#: with ``are``. The slots are filled from canonical entity ids, which are +#: SINGULAR lowercase lemmas (``dog``, ``mammal``), so filling them raw emitted +#: ``all dog are mammal``. Phase 2B re-inflects at render time; see +#: :func:`_display_noun`. _CATEGORICAL_PHRASE = { "A": "all {s} are {p}", "E": "no {s} are {p}", @@ -208,10 +215,37 @@ def render_entailment_exist( return f"Given: {given}. I can't evaluate {query_text} from that as stated." +def _display_noun(term: str) -> str: + """A canonical entity id → the plural noun phrase a categorical clause needs. + + Two transformations, both required for well-formed output: + + 1. **Number.** Ids are singular lemmas but every A/E/I/O template supplies + ``all``/``no``/``some`` + ``are``, which demand a plural. Mass nouns are + left alone by :func:`generate.morphology.pluralize` ("all evidence", not + "all evidences"). + 2. **Word separator.** Ids join tokens with ``_`` (``guard_dog``), which is + machine syntax, not English. Compounds inflect on the head, so + pluralizing before the swap gives "guard dogs" rather than "guards dog". + + Deliberately NOT done here: mass-noun verb agreement. "all evidence are + truth" should read "all evidence is truth", but the copula is fixed text + inside the templates, and making it agree changes the template shape rather + than the slot. Left as a separate, smaller defect — no mass noun currently + reaches a categorical clause in any serve corpus. + """ + if not term: + return term + return pluralize(term).replace("_", " ") + + def _categorical_clause(prop: dict) -> str: """One categorical proposition dict → its English clause.""" template = _CATEGORICAL_PHRASE.get(prop.get("form", ""), "{s} ~ {p}") - return template.format(s=prop.get("subject", "?"), p=prop.get("predicate", "?")) + return template.format( + s=_display_noun(prop.get("subject", "?")), + p=_display_noun(prop.get("predicate", "?")), + ) def render_syllogism(trace: EntailmentTrace, structure: dict, query: dict) -> str: diff --git a/generate/templates.py b/generate/templates.py index 55dfb046..613cf038 100644 --- a/generate/templates.py +++ b/generate/templates.py @@ -22,62 +22,30 @@ from generate.articulation_legality import ( validate_finite_predicate_legality, ) from generate.graph_planner import RhetoricalMove -from generate.morphology import base_form, past_participle, past_tense, present_participle +from generate.morphology import ( + base_form, + is_mass_noun, + past_participle, + past_tense, + pluralize, + present_participle, +) # Noun pluralisation — used under quantifiers (all/some/many/few/most). # Closes english_fluency_ood gaps.md G2 (plural agreement). +# +# Phase 2B: the rules moved to generate/morphology.py, which now owns number +# in both directions. Re-exported here because this module's public surface +# is consumed by the eval runners. _IRREGULAR_PLURALS: dict[str, str] = IRREGULAR_PLURALS -def pluralize(noun: str) -> str: - if not noun: - return noun - if noun in _IRREGULAR_PLURALS: - return _IRREGULAR_PLURALS[noun] - n = noun - if n.endswith(("s", "sh", "ch", "x", "z")): - return n + "es" - if n.endswith("y") and len(n) > 1 and n[-2] not in "aeiou": - return n[:-1] + "ies" - if n.endswith("fe"): - return n[:-2] + "ves" - if n.endswith("f"): - return n[:-1] + "ves" - return n + "s" - - # Quantifiers that demand plural agreement on the subject + verb. # "the" / "a" stay singular; "every" / "each" are singular by English # rule even though semantically universal. _PLURAL_QUANTIFIERS: frozenset[str] = PLURAL_QUANTIFIERS -# Mass nouns — uncountable in English, so "all evidence", "some wisdom" -# stay singular under quantifiers ("all evidences" is wrong). The -# verb still agrees (singular: "all evidence supports truth"). -# This list covers the abstract/epistemic vocabulary in -# en_core_cognition_v1 + common English mass nouns. -_MASS_NOUNS: frozenset[str] = frozenset({ - # epistemic / abstract (the seed-pack vocabulary) - "evidence", "wisdom", "knowledge", "truth", "light", "darkness", - "information", "data", "music", "art", "literature", "philosophy", - "courage", "patience", "love", "hope", "fear", "grace", - "meaning", "purpose", "beauty", "justice", "freedom", - # physical mass - "water", "air", "fire", "earth", "sand", "rain", "snow", "ice", - "wood", "metal", "gold", "silver", "iron", "stone", - "blood", "flesh", "bone", - # collective / continuous - "weather", "traffic", "furniture", "luggage", "advice", - "equipment", "machinery", "scenery", "money", "news", - "research", "progress", "feedback", -}) - - -def is_mass_noun(noun: str) -> bool: - return noun.lower() in _MASS_NOUNS - - _PREDICATE_DISPLAY: dict[str, str] = PREDICATE_DISPLAY diff --git a/tests/test_grammar_roundtrip.py b/tests/test_grammar_roundtrip.py index 5b8fb96c..2bb38b71 100644 --- a/tests/test_grammar_roundtrip.py +++ b/tests/test_grammar_roundtrip.py @@ -147,28 +147,59 @@ def test_g_roundtrip_baseline_is_zero(report): assert report.metrics["g_exact_rate"] == 0.0 -def test_s_roundtrip_pins_the_categorical_render_defect(report): - """BASELINE PIN (a defect): nothing CORE reads renders back to its input. +def test_s_roundtrip_closes_for_every_renderable_surface(report): + """Phase 2A pinned this at **0.0** — nothing CORE read rendered back to its + input, because the serving renderer interpolated singularized entity ids + into plural templates ("all dog are animal"). - The reader comprehends all 8 positive surfaces, and the serving categorical - renderer reproduces none of them, because it interpolates singularized - entity ids into plural templates. Phase 2B fixes this and must raise this - number. + Phase 2B re-inflects at render time, and every surface the renderer can + express now returns its input exactly. The two rates are equal on purpose: + ``s_surface_match_rate == s_renderable_rate`` says the *only* remaining + round-trip losses are surfaces the categorical renderer cannot express at + all, which is a coverage gap in a later phase, not a grammar defect. + + Asserting equality rather than a literal keeps this honest if the corpus + grows: adding an unrenderable positive lowers both numbers together, while + reintroducing the plural defect lowers only the match rate. """ assert report.metrics["s_read_rate"] == 1.0 - assert report.metrics["s_surface_match_rate"] == 0.0 + assert report.metrics["s_surface_match_rate"] == report.metrics["s_renderable_rate"] + assert report.metrics["s_surface_match_rate"] > 0.0 -def test_the_categorical_render_defect_concretely(): - """The specific defect, stated as an example rather than a rate.""" +def test_the_categorical_render_defect_is_fixed_concretely(): + """The 2A defect stated as an example rather than a rate, now inverted. + + Both sides matter: the graph still carries the SINGULAR canonical id + (``dog``), so the fix is in the renderer's inflection and not in the + comprehension it was built to preserve. + """ result = comprehend("All dogs are animals.", source_id="t") assert isinstance(result, Comprehension) props = sorted(from_meaning_graph(result.meaning_graph)) assert props == [CanonicalProposition("subset", "dog", "animal", False)] rendered = rt_runner._render_categorical("subset", "dog", "animal") - # "all dog are animal" — plural template, singular entity ids. - assert rendered == "all dog are animal" - assert rendered != "all dogs are animals" + assert rendered == "all dogs are animals" + + +def test_irregular_plurals_survive_the_full_round_trip(): + """The reader and renderer now share one number table, so an irregular + plural returns as itself. Before 2B this produced ``all wolve are + mammals`` — the reader's bare ``-s`` strip leaking a corrupted id straight + into served text.""" + cases = ( + ("All wolves are mammals.", "wolf", "all wolves are mammals"), + ("All children are mammals.", "child", "all children are mammals"), + ("All men are mammals.", "man", "all men are mammals"), + ("All knives are tools.", "knife", "all knives are tools"), + ) + for surface, singular, expected_clause in cases: + result = comprehend(surface, source_id="t") + assert isinstance(result, Comprehension), surface + props = sorted(from_meaning_graph(result.meaning_graph)) + assert props[0].subject == singular, f"{surface} -> {props}" + rendered = rt_runner._render_categorical("subset", singular, props[0].obj) + assert rendered == expected_clause # --------------------------------------------------------------------------- # diff --git a/tests/test_lexicon_single_source.py b/tests/test_lexicon_single_source.py index 08043929..c389dc15 100644 --- a/tests/test_lexicon_single_source.py +++ b/tests/test_lexicon_single_source.py @@ -67,9 +67,18 @@ RECORDED_CONSUMERS: dict[str, frozenset[str]] = { "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"}), + # 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"}), } #: The tables a duplicate literal would be a duplicate *of*. Frozen as @@ -295,15 +304,17 @@ def test_the_two_number_tables_are_inverse_directions_not_copies() -> None: 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) +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 # -------------------------------------------------------------------------- @@ -312,41 +323,110 @@ def test_reader_number_table_is_a_derived_subset_that_cannot_drift() -> None: @pytest.mark.parametrize( - ("plural", "current_wrong_singular"), + ("plural", "singular"), [ - ("wolves", "wolve"), - ("leaves", "leave"), - ("knives", "knive"), - ("news", "new"), - ("species", "specy"), + # 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_silently_mis_singularizes_uncovered_plurals( - plural: str, current_wrong_singular: str -) -> None: - """CURRENT BEHAVIOUR, deliberately pinned as wrong. +def test_reader_singularizes_irregulars_correctly(plural: str, singular: str) -> None: + """The defect Phase 2A pinned, now fixed. - ``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. + ``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) == current_wrong_singular + assert _singularize(plural) == 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.""" +@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 - for plural, singular in lexicon.READER_IRREGULAR_SINGULARS.items(): - assert _singularize(plural) == singular + 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"