Skip to content

fix(inflekt): audit pluralize and singularize against the dictionary in both directions - #114

Merged
pyramation merged 2 commits into
mainfrom
fix/inflekt-pluralize-audit
Aug 6, 2026
Merged

fix(inflekt): audit pluralize and singularize against the dictionary in both directions#114
pyramation merged 2 commits into
mainfrom
fix/inflekt-pluralize-audit

Conversation

@pyramation

@pyramation pyramation commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Summary

pluralize was never audited. singularize had a dictionary-proven exception table in front of its suffix rules; pluralize was raw inflection plus a couple of patches, so it coined non-words (radius -> radius, delta -> delta, chassis -> chasses, bias -> bias) and was not idempotent (pluralize('apis') -> 'apises' — the shape of the bug that broke constructive's GraphQL naming).

Both directions are now the same shape — dictionary table, then rules — and both are idempotent, so all four things a caller can do are correct:

singularize(singular) === singular      pluralize(singular) === plural
singularize(plural)   === singular      pluralize(plural)   === plural

pluralize gained the plural half of that:

export function pluralize(word: string): string {
  if (isPlural(word)) return word;                     // idempotent, incl. coined "apis"
  return applyTable(PLURAL_EXCEPTIONS, word)           // new generated table
      ?? pluralizeByRules(word);
}

isPlural asks the table first and the rules second, so it holds for words no dictionary knows: isRulesPlural(w) is singularizeByRules(w) !== w && pluralizeByRules(singularizeByRules(w)) === w.

The *ByRules functions are new and are the point of the change: the generator must run against rules only, or the table it emits would be validated against itself and mask every rule defect. pluralizeByRules also fixes the three shapes inflection refuses to pluralize at all (radius -> radiuses, forum -> forums, delta -> deltas), keeps -ous adjectives alone rather than coining anxiouses, and stops rewriting non-Greek -is (chassis -> chassises, not chasses).

scripts/generate-exceptions.ts now emits PLURAL_EXCEPTIONS alongside SINGULAR_EXCEPTIONS (766 singular + 298 plural pairs, idempotent across runs). Where the dictionary attests two plurals it picks by corpus frequency (hero -> heroes, not heros).

Two preferences are set against PostGraphile v5's own inflector, which is the pluralize package rather than inflection: -ex/-ix takes the attested Latin plural so the family is self-consistent and matches it (indices, appendices, vertices), and its uncountables are honoured (deer, fish, moose are invariant, though the dictionary lists "deers"/"fishes"). We deliberately diverge from it on -us, where it coins Latin for identifiers (radius -> radii, cactus -> cacti, alumnus -> alumni) and we keep the English plural (radiuses), and on schema, which it pluralizes as schemata.

scripts/audit.ts runs the four properties over every singular/plural pair in /usr/share/dict/words (17,546 singulars, 17,653 attested plurals) and is now committed as pnpm audit:

singularize(singular) != singular:  2 (0.01%)   media, people (plural inputs the audit reads as singular)
pluralize(singular) not attested:  27 (0.15%)   feet/mice/criteria vs the dictionary's "foots"/"mouses"/"criterions"
pluralize(plural)   != plural:      0 (0.00%)
singularize(plural) != singular:    1 (0.01%)   "cons"

Every remaining line was inspected; they are correct answers the dictionary happens to also list a regular variant for, not defects.

__tests__/four-properties.test.ts pins all four properties over ~250 hand-picked pairs: identifiers (api, uuid, mutex, schema, datum, api_key, ALL_CAPS), the English shapes that break suffix rules (irregulars, -f/-ves, -sis, -o, Latin), invariants, casing, and a documented block for the three cases no algorithm can resolve (tries is the plural of try, not trie; tty -> tties; dns -> dn).

Verified downstream: constructive's graphql-naming suite (the one the apis -> apises regression broke) passes 27/27 against this build, and graphql/codegen + graphql/query are unchanged; the postgres-dependent suites fail identically with published 0.8.0.

Link to Devin session: https://app.devin.ai/sessions/c2c27132839a45219a440fd10cbf13a6
Requested by: @pyramation

…in both directions

Both directions now go through a dictionary-proven exception table in front of
suffix rules, and both are idempotent: pluralize of a plural and singularize of
a singular return the word unchanged.
@pyramation pyramation self-assigned this Aug 6, 2026
@devin-ai-integration

Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

PostGraphile v5 inflects with the pluralize package, not inflection: it returns
indices and appendices, and treats deer/fish/moose as invariant. Follow it for
both, while keeping the English plural for -us (radiuses, not radii).
@pyramation
pyramation merged commit 19b1b39 into main Aug 6, 2026
59 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant