Merge pull request 'fix(generate): CORE writes plural nouns in categorical clauses (Phase 2B) — and the lane pins are blind to served English' (#132) from feat/categorical-plural-render into main
This commit is contained in:
commit
a856687f5e
8 changed files with 478 additions and 135 deletions
|
|
@ -558,6 +558,12 @@ Deliver:
|
||||||
`wolves`→`wolve`, `news`→`new`, `species`→`specy`. Its own comment claims it
|
`wolves`→`wolve`, `news`→`new`, `species`→`specy`. Its own comment claims it
|
||||||
refuses; it does not. Those five are pinned as current behaviour in
|
refuses; it does not. Those five are pinned as current behaviour in
|
||||||
`tests/test_lexicon_single_source.py` and this phase must flip them.
|
`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
|
3. Updated lane SHA pins — **surgical single-line edits only**, never
|
||||||
`--update` — with the old and new hash recorded per lane.
|
`--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 |
|
| 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
|
**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
|
### Phase 3 — Fix the 9-of-26 agreement defect
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
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
|
**Deliberate non-goal:** this module does not decide anything. It holds
|
||||||
tables and derivations; the refusal/agreement logic stays with each band.
|
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",
|
"analysis": "analyses", "axis": "axes", "basis": "bases",
|
||||||
"thesis": "theses", "hypothesis": "hypotheses",
|
"thesis": "theses", "hypothesis": "hypotheses",
|
||||||
"mitochondrion": "mitochondria",
|
"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
|
#: 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",
|
"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
|
#: Derived rather than re-listed because a hand-written second list is exactly
|
||||||
#: *correct* — they agree with :data:`IRREGULAR_SINGULARS` entry for entry —
|
#: how the two directions drifted apart in the first place: before Phase 2B the
|
||||||
#: so the values are derived below and only the key list is local. What is
|
#: pluralizer lacked ``aircraft``/``means``/``offspring`` and produced
|
||||||
#: wrong is the **coverage**, and the consequence is not a refusal:
|
#: "aircrafts", "meanses", "offsprings" while the singularizer knew all three.
|
||||||
#: ``reader._singularize`` falls through to a bare ``-s`` strip, so an
|
#: ``news`` was saved only by coincidence — it is also a mass noun.
|
||||||
#: uncovered plural is silently mis-singularized rather than declined
|
INVARIANT_NUMBER: Final[frozenset[str]] = frozenset(
|
||||||
#: (``wolves`` -> ``wolve``, ``news`` -> ``new``, ``species`` -> ``specy``).
|
plural for plural, singular in IRREGULAR_SINGULARS.items() if plural == singular
|
||||||
#: The reader's own comment claims it "REFUSES rather than guessing a wrong
|
)
|
||||||
#: singular (wrong=0)"; the code does not implement that.
|
|
||||||
#:
|
#: Uncountable nouns — they take a quantifier but stay singular, so
|
||||||
#: Widening this to the full table changes minted entity ids and therefore
|
#: ``all evidence`` is right and ``all evidences`` is wrong. The verb still
|
||||||
#: served surfaces and trace hashes, so it is **Phase 2B** work behind the
|
#: agrees singular ("all evidence supports truth"), which the categorical
|
||||||
#: authorization gate. Recorded here, with the gap named, rather than fixed
|
#: renderer does **not** yet handle; see the Phase 2B note in
|
||||||
#: silently.
|
#: ``proof_chain/render.py``.
|
||||||
READER_SINGULAR_KEYS: Final[tuple[str, ...]] = (
|
#:
|
||||||
"people", "men", "women", "children", "feet", "teeth", "mice", "geese",
|
#: 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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ from __future__ import annotations
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass, field
|
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 (
|
from generate.meaning_graph.model import (
|
||||||
Entity,
|
Entity,
|
||||||
MeaningGraph,
|
MeaningGraph,
|
||||||
|
|
@ -62,9 +62,17 @@ from generate.meaning_graph.model import (
|
||||||
|
|
||||||
_ARTICLES = frozenset({"a", "an"})
|
_ARTICLES = frozenset({"a", "an"})
|
||||||
|
|
||||||
# Common irregular plurals the corpus exercises. Conservative + closed; an
|
# Phase 2B: this reader used to carry its own 8-entry plural table plus a
|
||||||
# unrecognized plural REFUSES rather than guessing a wrong singular (wrong=0).
|
# private copy of the suffix rules. Both now live in generate/morphology.py,
|
||||||
_IRREGULAR_PLURALS = READER_IRREGULAR_SINGULARS
|
# 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
|
# Categorical quantifier -> the MeaningGraph predicate it mints. The predicate
|
||||||
# vocabulary is shared between facts and the "therefore" conclusion query, and is
|
# 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:
|
def _singularize(word: str) -> str | None:
|
||||||
"""Conservative plural -> singular. None when not confidently a plural."""
|
"""Conservative plural -> singular. None when not confidently a plural."""
|
||||||
if word in _IRREGULAR_PLURALS:
|
return _shared_singularize(word)
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def _chunk(words: list[str], detail: str) -> str:
|
def _chunk(words: list[str], detail: str) -> str:
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,27 @@
|
||||||
"""Deterministic English morphology for the realizer.
|
"""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.
|
This is intentionally rule-based and limited to the seed vocabulary.
|
||||||
Irregular forms are listed explicitly; regular forms follow English rules.
|
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 __future__ import annotations
|
||||||
|
|
||||||
|
from generate.lexicon import (
|
||||||
|
INVARIANT_NUMBER,
|
||||||
|
IRREGULAR_PLURALS,
|
||||||
|
IRREGULAR_SINGULARS,
|
||||||
|
MASS_NOUNS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Genuinely irregular English verbs (the previous tables held only
|
# Genuinely irregular English verbs (the previous tables held only
|
||||||
# regular forms that the suffix rules already produce correctly).
|
# 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)
|
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:
|
def base_form(verb_3sg: str) -> str:
|
||||||
return _base_form(verb_3sg)
|
return _base_form(verb_3sg)
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ renders realized-structure DETERMINE answers — a different gear entirely).
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from generate.morphology import pluralize
|
||||||
from generate.proof_chain.entail import (
|
from generate.proof_chain.entail import (
|
||||||
INCONSISTENT_PREMISES,
|
INCONSISTENT_PREMISES,
|
||||||
Entailment,
|
Entailment,
|
||||||
|
|
@ -18,6 +19,12 @@ from generate.proof_chain.entail import (
|
||||||
)
|
)
|
||||||
|
|
||||||
#: A/E/I/O categorical form → an English sentence template over (subject, predicate).
|
#: 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 = {
|
_CATEGORICAL_PHRASE = {
|
||||||
"A": "all {s} are {p}",
|
"A": "all {s} are {p}",
|
||||||
"E": "no {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."
|
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:
|
def _categorical_clause(prop: dict) -> str:
|
||||||
"""One categorical proposition dict → its English clause."""
|
"""One categorical proposition dict → its English clause."""
|
||||||
template = _CATEGORICAL_PHRASE.get(prop.get("form", ""), "{s} ~ {p}")
|
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:
|
def render_syllogism(trace: EntailmentTrace, structure: dict, query: dict) -> str:
|
||||||
|
|
|
||||||
|
|
@ -22,62 +22,30 @@ from generate.articulation_legality import (
|
||||||
validate_finite_predicate_legality,
|
validate_finite_predicate_legality,
|
||||||
)
|
)
|
||||||
from generate.graph_planner import RhetoricalMove
|
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).
|
# Noun pluralisation — used under quantifiers (all/some/many/few/most).
|
||||||
# Closes english_fluency_ood gaps.md G2 (plural agreement).
|
# 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
|
_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.
|
# Quantifiers that demand plural agreement on the subject + verb.
|
||||||
# "the" / "a" stay singular; "every" / "each" are singular by English
|
# "the" / "a" stay singular; "every" / "each" are singular by English
|
||||||
# rule even though semantically universal.
|
# rule even though semantically universal.
|
||||||
_PLURAL_QUANTIFIERS: frozenset[str] = PLURAL_QUANTIFIERS
|
_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
|
_PREDICATE_DISPLAY: dict[str, str] = PREDICATE_DISPLAY
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -147,28 +147,59 @@ def test_g_roundtrip_baseline_is_zero(report):
|
||||||
assert report.metrics["g_exact_rate"] == 0.0
|
assert report.metrics["g_exact_rate"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
def test_s_roundtrip_pins_the_categorical_render_defect(report):
|
def test_s_roundtrip_closes_for_every_renderable_surface(report):
|
||||||
"""BASELINE PIN (a defect): nothing CORE reads renders back to its input.
|
"""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
|
Phase 2B re-inflects at render time, and every surface the renderer can
|
||||||
renderer reproduces none of them, because it interpolates singularized
|
express now returns its input exactly. The two rates are equal on purpose:
|
||||||
entity ids into plural templates. Phase 2B fixes this and must raise this
|
``s_surface_match_rate == s_renderable_rate`` says the *only* remaining
|
||||||
number.
|
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_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():
|
def test_the_categorical_render_defect_is_fixed_concretely():
|
||||||
"""The specific defect, stated as an example rather than a rate."""
|
"""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")
|
result = comprehend("All dogs are animals.", source_id="t")
|
||||||
assert isinstance(result, Comprehension)
|
assert isinstance(result, Comprehension)
|
||||||
props = sorted(from_meaning_graph(result.meaning_graph))
|
props = sorted(from_meaning_graph(result.meaning_graph))
|
||||||
assert props == [CanonicalProposition("subset", "dog", "animal", False)]
|
assert props == [CanonicalProposition("subset", "dog", "animal", False)]
|
||||||
rendered = rt_runner._render_categorical("subset", "dog", "animal")
|
rendered = rt_runner._render_categorical("subset", "dog", "animal")
|
||||||
# "all dog are animal" — plural template, singular entity ids.
|
assert rendered == "all dogs are animals"
|
||||||
assert rendered == "all dog are animal"
|
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
|
||||||
|
|
@ -67,9 +67,18 @@ RECORDED_CONSUMERS: dict[str, frozenset[str]] = {
|
||||||
"generate.semantic_templates",
|
"generate.semantic_templates",
|
||||||
}),
|
}),
|
||||||
"DISCOURSE_PREDICATE_DISPLAY": frozenset({"generate.discourse_planner"}),
|
"DISCOURSE_PREDICATE_DISPLAY": frozenset({"generate.discourse_planner"}),
|
||||||
"IRREGULAR_PLURALS": frozenset({"generate.templates"}),
|
# Phase 2B: generate.morphology became the single owner of the number
|
||||||
"IRREGULAR_SINGULARS": frozenset({"generate.proof_chain.member"}),
|
# RULES, so it is now the consumer of the number tables. The reader no
|
||||||
"READER_IRREGULAR_SINGULARS": frozenset({"generate.meaning_graph.reader"}),
|
# 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
|
#: 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"
|
assert lexicon.IRREGULAR_SINGULARS[plur] == sing, f"{sing}/{plur} not inverse"
|
||||||
|
|
||||||
|
|
||||||
def test_reader_number_table_is_a_derived_subset_that_cannot_drift() -> None:
|
def test_invariant_number_is_derived_from_the_singularizer() -> None:
|
||||||
"""The reader's 8 values were measured to AGREE with the 29-entry table
|
"""Invariants must not be a second hand-written list.
|
||||||
entry for entry — the set difference in the reader's favour was empty. So
|
|
||||||
only its key list is local; the values are derived."""
|
A hand-written list is exactly how the directions drifted: the pluralizer
|
||||||
assert set(lexicon.READER_IRREGULAR_SINGULARS) == set(lexicon.READER_SINGULAR_KEYS)
|
lacked ``aircraft``/``means``/``offspring`` and produced "aircrafts",
|
||||||
assert len(lexicon.READER_SINGULAR_KEYS) == 8
|
"meanses", "offsprings" while the singularizer knew all three. Deriving
|
||||||
for key, value in lexicon.READER_IRREGULAR_SINGULARS.items():
|
from the ``key == value`` rows makes that class of gap unrepresentable."""
|
||||||
assert lexicon.IRREGULAR_SINGULARS[key] == value
|
expected = {p for p, s in lexicon.IRREGULAR_SINGULARS.items() if p == s}
|
||||||
assert set(lexicon.READER_IRREGULAR_SINGULARS) < set(lexicon.IRREGULAR_SINGULARS)
|
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(
|
@pytest.mark.parametrize(
|
||||||
("plural", "current_wrong_singular"),
|
("plural", "singular"),
|
||||||
[
|
[
|
||||||
("wolves", "wolve"),
|
# Phase 2A pinned these as WRONG (wolve / leave / knive). Phase 2B
|
||||||
("leaves", "leave"),
|
# flipped them by routing the reader through the shared 29-entry
|
||||||
("knives", "knive"),
|
# singularizer.
|
||||||
("news", "new"),
|
("wolves", "wolf"),
|
||||||
("species", "specy"),
|
("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(
|
def test_reader_singularizes_irregulars_correctly(plural: str, singular: str) -> None:
|
||||||
plural: str, current_wrong_singular: str
|
"""The defect Phase 2A pinned, now fixed.
|
||||||
) -> None:
|
|
||||||
"""CURRENT BEHAVIOUR, deliberately pinned as wrong.
|
|
||||||
|
|
||||||
``reader.py``'s comment claims an unrecognized plural "REFUSES rather
|
``reader.py``'s comment always claimed an unrecognized plural "REFUSES
|
||||||
than guessing a wrong singular (wrong=0)". The code does not implement
|
rather than guessing a wrong singular (wrong=0)". Until 2B the code did
|
||||||
that: ``_singularize`` falls through to a bare ``-s`` strip, so uncovered
|
not implement it — ``_singularize`` fell through to a bare ``-s`` strip
|
||||||
plurals mint corrupted entity ids. ``news`` -> ``new`` and ``species`` ->
|
and minted corrupted ids that reached served text ("all wolve are
|
||||||
``specy`` are exactly the corruptions ``member.py``'s table comment says
|
mammal"). The comment is now true of the code.
|
||||||
its table exists to prevent.
|
|
||||||
|
|
||||||
Phase 2B replaces the reader's 8-key view with the full singularizer and
|
|
||||||
must flip these to ``wolf``/``leaf``/``knife``/``news``/``species``.
|
|
||||||
Changing minted ids moves trace hashes, which is why it is gated.
|
|
||||||
"""
|
"""
|
||||||
from generate.meaning_graph.reader import _singularize
|
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:
|
@pytest.mark.parametrize(
|
||||||
"""The control for the test above: where the reader HAS an entry it is
|
"invariant", ["fish", "sheep", "deer", "species", "series", "news", "means"]
|
||||||
right. So the defect is coverage, not wrong values — which is why 2A
|
)
|
||||||
could derive the values safely and leave coverage to 2B."""
|
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
|
from generate.meaning_graph.reader import _singularize
|
||||||
|
|
||||||
for plural, singular in lexicon.READER_IRREGULAR_SINGULARS.items():
|
assert _singularize(invariant) is None
|
||||||
assert _singularize(plural) == singular
|
|
||||||
|
|
||||||
|
@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"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue