#1892·Humanizer

Localized arbitrary-string Pluralize/Singularize with locale-owned grammar

Author: clairernovotnyCreated Jul 29, 2026Updated Aug 3, 2026
Labelsenhancementlocalisation

Purpose

Replace/correct the localized inflection API introduced by #1888 so Humanizer's established Pluralize/Singularize family works directly on arbitrary caller strings in every supported culture.

This issue is the canonical scope, API/data contract, 102-culture ownership ledger, research-evidence policy, review ledger, and merge gate. It revisits and supersedes the unreleased #1888 PluralizationForms.TryPluralize / TrySingularize implementation that closed #197, replacing its ordinary-caller direction with familiar string APIs.

Hard product contract

  • Existing word.Pluralize() and word.Singularize() themselves use the localized engine and invocation-time ambient culture.
  • Ordinary callers do not supply authored forms, dictionaries, options, services, or out parameters.
  • Normal operation always returns a string (or preserves the existing nullable Pluralize contract). Unknown, unsafe, unsupported, or ambiguous input returns the exact original value; it does not return false and does not throw as normal control flow.
  • Every CultureInfo for which Configurator.IsCultureSupported is true supports every public inflection operation at activation. The 102 generated locale files are data-owner roots, not the exhaustive set of supported descendants. Configurator.IsCultureSupported and inflection must share one deterministic resolver; same-language, script-compatible descendant ownership is allowed, while English, cross-language, platform-dependent, and partial fallback are forbidden.
  • English is one locale implementation, never the global fallback.
  • Implement as much productive noun-number grammar as can meet the safety gates, then exact exceptions.
  • For every locale and direction, the exception lexicon must cover irregular nouns responsible for 95% of observed irregular-noun occurrences under the frozen methodology below. This is token/occurrence coverage, not 95% of dictionary types.
  • Quantity-free pluralization selects the locale's conventional dictionary plural. Quantity-aware pluralization selects a number-governed bare display form. Singularization resolves a plural/display form to its preferred lemma.
  • ToQuantity routes through the same engine automatically.
  • Reuse Humanizer's locale YAML, generator, registries, and existing API-specific tests. Do not add a second parity/meta-test framework.

Public API

Keep the existing signatures, parameter names, and nullable-flow annotation, and route them through CultureInfo.CurrentCulture:

csharp
[return: NotNullIfNotNull(nameof(word))]
public static string? Pluralize(
    this string? word,
    bool inputIsKnownToBeSingular = true);

public static string Singularize(
    this string word,
    bool inputIsKnownToBePlural = true,
    bool skipSimpleWords = false);

Add culture-last deterministic forms:

csharp
[return: NotNullIfNotNull(nameof(word))]
public static string? Pluralize(
    this string? word,
    bool inputIsKnownToBeSingular,
    CultureInfo culture);

public static string Singularize(
    this string word,
    bool inputIsKnownToBePlural,
    bool skipSimpleWords,
    CultureInfo culture);

Add exactly one public quantity type:

csharp
[return: NotNullIfNotNull(nameof(word))]
public static string? Pluralize(
    this string? word,
    decimal quantity);

[return: NotNullIfNotNull(nameof(word))]
public static string? Pluralize(
    this string? word,
    decimal quantity,
    CultureInfo culture);

Examples:

csharp
"person".Pluralize();
"person".Pluralize(2m);
"person".Pluralize(2.0m, CultureInfo.GetCultureInfo("en"));
"person".Pluralize(
    inputIsKnownToBeSingular: true,
    culture: CultureInfo.GetCultureInfo("en"));

decimal is deliberate: stored scale preserves CLDR visible-fraction operands, so 1m, 1.0m, and 1.00m may select different categories. Existing ToQuantity(int|long|double, ...) overloads keep their numeric types and use an internal operand representation.

The required terse overload creates an accepted, API-review-blocking v4 source ambiguity:

csharp
word.Pluralize(default);          // bool or decimal
word.Pluralize(default, culture); // bool or decimal

Callers use default(bool), default(decimal), or named inputIsKnownToBeSingular: / quantity: arguments. Added overloads also affect reflection and some untyped method-group binding. These impacts require explicit API approval.

Null and ambient semantics

  • No-culture calls read CultureInfo.CurrentCulture once at invocation time. Do not use CurrentUICulture and do not capture ambient culture in static state or a cache.
  • Explicit null CultureInfo throws ArgumentNullException. Explicit overloads validate culture first, so a null word plus null culture throws for culture.
  • Pluralize(null, ...) with a non-null culture returns null.
  • Preserve the existing runtime/nullability behavior of Singularize.
  • Identity/no-op outcomes return the exact original string reference.

Linguistic semantics

The API accepts any string safely. V1 claims transformations only for:

  1. A standalone lexical noun.
  2. A locale-authored exact phrase.
  3. English compound/token behavior already frozen by existing tests.

Anything else is returned unchanged. No operation translates text or claims POS/sense detection.

  • Quantity-free Pluralize requests the locale's preferred dictionary plural.
  • Quantity-aware Pluralize requests the preferred bare display form for the selected cardinal category. It never emits the number, article, classifier, preposition, or surrounding agreement.
  • inputIsKnownToBeSingular: true is the caller's noun/lemma assertion and permits eligible productive forward rules.
  • With that flag false, only a uniquely recognized singular lexeme changes, except for frozen English compatibility behavior.
  • Default Singularize(inputIsKnownToBePlural: true) permits approved productive reverse rules. With the flag false, require a uniquely recognized form.
  • skipSimpleWords uses the selected locale's generated skip/exclusion data, not an English suffix assumption.
  • Homographs, incompatible senses, multiple reverse candidates, unsafe rules, and unknown inputs return the original.
  • Every lexeme has a stable ID, noun POS, countability, optional required sense, accepted variants, and one policy-preferred output per slot.
  • Countability values are count, mass, collective, and plural-only.

CLDR is used only to select an authored display slot. It is not evidence for noun case, countability, phrase grammar, or the spelling of a form.

A public-complete bundle is either:

  • invariant: the operation is linguistically attested to preserve the form, with positive and hostile examples; or
  • display-by-category: every reachable CLDR category has an authored preferred bare display form.

A dictionary-only or missing-category bundle may exist only as inert development data. It cannot activate for a supported culture. Runtime never substitutes other, dictionary plural, another category, another word, or a different bundle after owner selection.

Quantity semantics and ToQuantity

  • Cardinal operands use absolute quantity; -1m and 1m select the same category.
  • Decimal scale is retained exactly.
  • int and long use exact internal operands with v = 0, including long.MinValue.
  • Finite double uses invariant round-trip digits with exponent expansion. Source trailing zeros are unrecoverable (1d and 1.0d are identical).
  • NaN and infinities produce internal Unsupported; the noun remains unchanged.
  • If formatProvider is CultureInfo culture, that culture owns inflection. Otherwise inflection uses the once-captured CurrentCulture, while the original provider continues to format the number.
  • ShowQuantityAs.None still uses the actual quantity for noun selection.
  • Formatting the number must not change category operands.

Unicode, casing, scripts, compounds, and phrases

  • Compare transient NFC values; store preferred forms in NFC.
  • Well-formed NFC inputs take the allocation-free comparison path.
  • Ill-formed UTF-16 returns the exact original without normalization.
  • Do not use NFKC, compatibility folding, mark removal, transliteration, or punctuation stripping.
  • Changed output uses the preferred stored form plus the locale's declared casing projection.
  • If projected output is ordinally identical to input, return the original reference.
  • Casing mode is exact, lower-title-upper, or none; mixed casing is exact-only.
  • Exact phrases are allowed. Productive phrase processing is off outside English in v1.
  • English keeps only compound behavior frozen by current tests; do not add a general phrase parser.
  • Productive rules require a declared compatible script. Common/inherited combining marks are allowed; mixed-script productive matching is not.

Deterministic culture ownership

Generate one reviewed owner graph:

  1. Canonicalize the requested culture/alias without retaining arbitrary culture objects or misses.
  2. Select its exact complete bundle when present.
  3. Invariant culture terminates at identity.
  4. Otherwise follow only an exact generated alias/owner edge with the same primary language and compatible effective script. The graph explicitly lists every accepted culture name and compatibility alias, plus exclusions and regional overrides; it has no implicit platform-derived descendant set.
  5. If no complete owner exists, terminate at identity.

Do not discover ownership through platform CultureInfo.Parent. ICU, NLS, and .NET Framework must select the same owner. At activation, Configurator.IsCultureSupported(culture) delegates to this same resolver and returns true exactly when a complete owner exists; it must no longer promise a culture that inflection cannot serve.

A bundle is atomic: category evaluator, capability, scripts, casing, lexemes, rules, exclusions, and source references all come from one owner. There is no per-word/category/rule retry after selection.

Only the en bundle may consult mutable Vocabularies.Default, and only for compatibility after localized exact handling returns Unknown—never Ambiguous or Unsupported. No non-English owner can reach it.

Internal diagnostic status:

csharp
internal enum InflectionStatus
{
    Exact,
    Productive,
    Invariant,
    Ambiguous,
    Unsupported,
    Unknown
}

Ambiguous, Unsupported, and Unknown map to the exact original public input.

Locale YAML contract

Extend the existing locale YAML rather than introducing another data subsystem:

yaml
# Grammar fragment only. Activation also requires the complete evidence described below.
inflection:
  capability: display-by-category
  scripts: [Latn]
  casing: lower-title-upper
  phrase-mode: exact-only

  lexemes:
    - id: en.noun.cactus.plant
      pos: noun
      countability: count
      sense: plant
      forms:
        singular:
          preferred: cactus
          accepted: [cactus]
        dictionary-plural:
          preferred: cactuses
          accepted: [cactuses, cacti]
        display:
          one:
            preferred: cactus
            accepted: [cactus]
          other:
            preferred: cactuses
            accepted: [cactuses, cacti]
      sources: [humanizer-existing-en]

  rules:
    - id: en.forward.consonant-y
      direction: forward
      priority: 100
      scope:
        pos: noun
        countability: [count]
        token: standalone
        scripts: [Latn]
      match:
        suffix: y
        preceding-not: [a, e, i, o, u]
      output:
        dictionary-plural: "{stem}ies"
        display:
          one: "{stem}y"
          other: "{stem}ies"
      hostile-exclusions:
        lexemes: []
        surfaces: []
      reverse:
        enabled: true
        requires-existing-lexeme: false
      sources: [humanizer-existing-en]

  sources:
    humanizer-existing-en:
      kind: project-history
      locator: tests/Humanizer.Tests/InflectorTests.cs
      revision: repository-commit
      credit: Humanizer contributors

Productive-rule DSL is bounded prefix/suffix replacement, not runtime regex. A reverse rule may set requires-existing-lexeme: false only when its morphotactic constraints, exclusions, ambiguity checks, round trip, and frozen held-out precision support safe arbitrary-lexeme use.

The generator rejects duplicate IDs; ill-formed/non-NFC forms; preferred forms outside the accepted set; missing reachable categories; unsupported POS/countability; dangling source references; invalid evidence arithmetic; unmet activation thresholds; empty/unbounded/duplicate rule matches; conflicting priority/specificity; language/script ownership conflicts; and undeclared reverse collisions.

Forward and reverse resolution

Forward precedence:

  1. Exact accepted whole-string phrase.
  2. Exact accepted standalone surface / invariant lexeme.
  3. Exact lexeme preferred output.
  4. Highest-priority eligible productive rule; specificity/longest affix breaks an otherwise declared ordering.
  5. Unknown.

The generator rejects unresolved ties. There is no global invariant rule layer.

Exact reverse indexes map normalized surface to sets of lexeme IDs. One ID resolves; zero is unknown; more than one is ambiguous unless the records explicitly identify the same lexeme/sense and one preferred reverse. Collision groups are generated, never silently resolved by insertion/sort order.

Productive reverse is an explicitly precision-gated heuristic. It is allowed only when:

  1. Locale and rule enable reverse.
  2. Surface is absent from negative/exclusion data.
  3. Exactly one candidate is generated.
  4. Forward application of the same rule reproduces the surface.
  5. Candidate satisfies the rule's script/scope and, when required by that rule, maps to a compatible known lexeme.
  6. The locale/direction passes its frozen held-out precision gate.

Only canonical/preferred forms are promised to round-trip. Ambiguous accepted alternatives return identity.

Generated runtime representation

Generate one file and one internal data type per locale plus a tiny direct registry:

GeneratedInflection_en.g.cs
GeneratedInflection_fr.g.cs
GeneratedInflection_ru.g.cs
...
GeneratedInflectionRegistry.g.cs

Each locale type owns private arrays, offsets, exact indexes, reverse collision groups, rules, exclusions, and any required comparer in a nested static holder. Selecting one data-bearing owner must not initialize another owner type. Alias/descendant records contain metadata only: accessing an alias initializes exactly its one selected owner bundle, not a second locale-data holder.

Use each string once in private LexemeEntry[] data plus forward/reverse ushort[] indexes (int only if a locale exceeds 65,535 entries). Case-sensitive NFC keys may use ordinal generated sorting. Any locale-ignore-case index must be built/compared under the same runtime comparer; never binary-search build-time ordering under a different platform comparer.

Do not use a central all-locale initializer, Lazy<T>, immutable/frozen collections, runtime reflection, new generated runtime regex, result caches, dynamic owner caches, or runtime collision discovery. The frozen en compatibility path may invoke the existing regex-based Vocabularies.Default; no other owner may do so.

The generator is incremental per locale. Changing one YAML file regenerates that locale's file/type; the registry regenerates only when ownership metadata changes.

All generated metadata still ships in the monolithic assembly. The lazy guarantee is only that unselected runtime indexes/string objects are not initialized. Satellite packs are a later option only if measured deployment footprint requires them; they are not v1.

#1888 migration

Remote verification against current main 42b876dd85a882e3ebee377d36be388b2fbb0b34 and merged PR #1888 confirms that the unreleased public surface is PluralizationForms, including its constructor, Singular/category properties, Invariant, TryPluralize(decimal, CultureInfo, out string?), and TrySingularize(string, out string?). #1888 did not ship a built-in exact string lexicon.

This design replaces that advanced caller-authored-forms surface for the ordinary use case because callers of arbitrary-string Pluralize / Singularize normally do not possess a complete paradigm. Before public activation:

  • Remove the unreleased public PluralizationForms type and its constructor, properties, Invariant, TryPluralize, and TrySingularize methods, along with their API approvals and public docs.
  • Retain the generated CLDR operand/category evaluators internally.
  • Move behavior coverage to ordinary Pluralize, Singularize, and ToQuantity calls; keep focused internal operand/category tests.
  • Remove caller-authored-form success/failure expectations from LocalizedInflectionTests.
  • Add an unreleased changelog note. The latest tagged release at review time is v3.0.10, which predates #1888; exact-head API review must re-check that no package containing this surface shipped before removal.

This migration does not claim that arbitrary morphology is universally knowable. The familiar methods transform exact lexemes and demonstrably safe locale-owned productive cases; all other inputs preserve identity.

Research evidence and 95% occurrence coverage

Humanizer is self-contained. The NuGet package, source generator, build, tests, and runtime never download or query a corpus, dictionary, language model, or service. Every runtime rule, exception form, exclusion, and owner edge needed by this feature is committed as project-authored locale data and generated into Humanizer. Independently authored expectations remain in the existing API-specific test suites.

External materials are ordinary research inputs only. Maintainers may consult frequency lists, annotated corpora, morphological datasets, national dictionaries, and grammar references to learn factual forms and rules and to measure observed usage. Humanizer does not copy or redistribute definitions, sentences, corpora, models, or wholesale source tables. It commits only project-authored rules/forms, stable citations, and compact exception-level and aggregate evidence counts. This issue requires no pre-access approval, legal-decision ID, source-license manifest, clean-room record, escrow, or external download for activation.

Frozen occurrence methodology

Define the locale grammar, productive rules, API eligibility, token/POS filtering, frequency source, and query/release date before selecting the final exception set. “Irregular” is determined by reviewed locale grammar, not merely by what the current engine misses. Productive-rule defects are fixed as rule defects; invariant lexemes that must block a productive rule count as exceptions.

For locale owner l and direction d, let M(l,d) contain the independently reviewed, API-eligible irregular noun occurrences in the frozen evidence:

  • For Pluralize, weight each eligible dictionary-singular/lemma noun by its observed lemma occurrences. A covered lexeme must contain every preferred dictionary/display slot required by that locale's reachable CLDR categories.
  • For Singularize, weight each eligible plural/display surface by its observed surface occurrences. A covered surface must resolve uniquely to an accepted preferred lemma.
  • Exclude proper names, abbreviations, foreign/code-switched material, malformed tokens, and grammatical forms outside the standalone-noun v1 claim. Record the excluded occurrence mass.

Let c(i) be the observed occurrence count and x(i) = 1 only when the embedded exact lexicon returns an accepted result:

ExceptionCoverage(l,d) = Σ(c(i) × x(i)) / Σ(c(i)), i ∈ M(l,d)

When M(l,d) is nonempty, ExceptionCoverage(l,d) must be at least 0.95. This is occurrence coverage, not dictionary-type coverage, and locales are never pooled to pas