Skip to content

Name resolution

Polish-aware name resolution is unconditional core in Nerthus.Core. A session mentions Erastera, Tussalowi, or Ithanowi; the resolver maps those inflected forms back to the canonical entities Eraster, Lord Tussal, and Ithan. PU awards, session distribution, and every @tag cross-reference depend on that mapping. Polish is the only language — tags, types, and names are Polish literals in canonical diacritical form (see Entities). The resolver runs in the daemon against an in-memory name index, with the grammar compiled into C# (Nerthus.DeclensionEngine, Nerthus.BKTree). This page specifies the four-stage resolver, the name index that backs it, and the -Type / -ActiveOn / -Within disambiguation contract.

Concepts

  • Token — a single indexed name string: a full name ("Lord Tussal"), a registered @alias, a @slug, a @nazwa_nerthus, a @base_name, a @generyczne_nazwy value, or an individual word split out of a multi-word full name ("Lord", "Tussal").
  • Query — the surface form found in a session body or passed to Resolve-NerthusName. It is frequently inflected: Erastera, Ithanowi, Tussalem.
  • Stem — the suffix-stripped form of a token (IthanowiIthan), used as the bucket key in the StemIndex so declined queries collapse onto their lemma (the dictionary base form).
  • Owner — the entity a token resolves to; resolution returns the owner, never the token.
  • Homonym / Ambiguity — one token carried by several owners, e.g. the bare alias Gildia Teologów shared by the Thuzal and Ithan chapters after an operator split (Locations); the owner filter disambiguates.
  • Declension — Polish case inflection, handled by plain suffix stripping (Stage 2) and consonant-alternation reversal (Stage 2b).

The name index (data model)

Built by the daemon from the entity model, held in memory only:

{
  Index      : Dictionary[string, IndexEntry]    # OrdinalIgnoreCase — Stages 1, 2b, 3 keys
  StemIndex  : Dictionary[string, List[string]]  # OrdinalIgnoreCase — Stage 2 buckets (stem → tokens)
  BKTree     : Nerthus.BKTree                    # fuzzy metric tree — Stage 3
  Declension : Nerthus.DeclensionEngine          # the grammar (see below)
  Stats      : { tokenCount, stemCount }
}

Each IndexEntry (all fields always populated):

Field Type Meaning
Source string The token itself, as indexed
Owners list Every owner of this token, each a { Owner; Type; Priority } wrapper
Primary wrapper The collision-chosen default owner (see below)
Ambiguous bool True as soon as a second distinct owner claims the token

Token sources & priority

Every entity contributes at priority 1: its canonical name plus every @alias, @slug, @nazwa_nerthus, and @base_name value, and each comma-separated @generyczne_nazwy value. At priority 2: each ≥3-character word of a multi-word canonical name. So Lord Tussal indexes "Lord Tussal" (P1) plus "Lord" and "Tussal" (P2). The owner's type is its @typ value (falling back to the ## category heading of its index block).

Collision & the primary owner

A token the same entity claims twice deduplicates. A different entity claiming an existing token joins Owners and marks the entry Ambiguous — regardless of priority. Priority influences only which owner becomes Primary, the default pick when no filter disambiguates:

  1. Lower priority number wins (a full name beats another entity's word token).
  2. At equal priority: Postać beats Gracz, Lokacja beats Mapa.
  3. Otherwise the first-seen owner stays primary (stable).

The Postać-beats-Gracz rule is why the separate Gracz and Postać entity types (linked by @należy_do, see Players) coexist without a token war: a shared name defaults to the in-fiction character, and -Type Gracz still reaches the roster entry.

What feeds the index

Nerthus.MarkdownScanner (compiled C#, single-pass line scanner) is the one Nerthus parser. It emits entity blocks (Category, Name, and a flat Tags list with raw temporal values — operator prose is skipped) and raw session blocks (header + verbatim body); the index builder consumes the entity blocks. There are no scan caches on disk — scanner output, like the index, lives only in daemon memory (Architecture).

Build and rebuild

The index is built with the entity model and rebuilt whole whenever the daemon's fingerprint self-heal detects a source change (Architecture). POST /v1/api/name-index/rebuild forces the same full model rebuild on demand and reports buildMs plus the index tokenCount / stemCount; use it to pre-warm before a bulk session import.

Nerthus.DeclensionEngine

A compiled C# class (lib/, namespace Nerthus), constructed once at daemon start from three parallel tables — the declension suffixes (longest-first), the alternation inflected endings, and their base replacements. It exposes exactly two methods, both EndsWith/Substring with OrdinalIgnoreCase:

Method Used by Behavior
GetStem(text) index build + Stage 2 Strips the first matching inflection suffix (input order = longest-first); returns the input unchanged if none matches or the stem would fall below 3 characters (text.Length > suffix.Length + 2).
GetAlternationCandidates(text) Stage 2b Returns 0..N base-form candidates by reversing consonant mutations; the same minimum-stem guard applies; empty array if none apply.

The suffix table, in engine order:

ego  emu  ymi  ych  owi  ami  ach  iem     ej  ym  em  om  ie     ą  ę  a  u  y  i

The alternation pairs (inflected ending → base ending), in engine order:

dzie→da  ście→sta  dzi→da  rze→ra  dze→ga  ście→ść  le→ła  ce→ka  ni→ń  si→ś  zi→ź  ci→ć

Example: the locative Anwardzie triggers dzie→da and yields the candidate Anwarda — a candidate is accepted only on an actual index hit (Stage 2b below). -ście appears twice — both readings (-sta, -ść) are emitted as candidates.

The four-stage pipeline (behavior)

Resolve-NerthusName runs up to four stages in order; each stage funnels its candidate token through the same -Type / -ActiveOn / -Within owner filter (below), and the first stage whose filtered candidate survives wins:

Query "Anwardzie"
  ├─ Stage 1   Exact O(1) dictionary lookup ........... miss
  ├─ Stage 2   Declension suffix-strip → StemIndex .... miss (stem "Anwardz" hits no bucket)
  ├─ Stage 2b  Consonant-alternation reversal ......... miss (-dzie → -da → "Anwarda", not indexed)
  └─ Stage 3   Levenshtein fuzzy (BKTree) ............. HIT  distance 3 → "Anward"
                                                         → Owner: Anward (Gracz)

Each result records the stage that matched (exact / declension / alternation / fuzzy) and a confidence score: 1.0, 0.95, 0.9, or 1 − distance/|query| respectively — so an operator can see why Anwardzie resolved to Anward.

Stage 1 — Exact lookup (O(1))

Case-insensitive dictionary hit against Index. Catches canonical names, aliases, slugs, and @nazwa_nerthus values verbatim: "Ithan", "Lord Tussal", "rezydencja-tussal" all land here.

Stage 2 — Declension suffix-strip → StemIndex (O(1))

For queries of ≥3 characters, GetStem(query) strips the first matching suffix and the result is looked up in the StemIndex, whose buckets were filled at index time by stemming every token once. Suffixes are tried longest-first so -ami is removed before -i; the 3-character minimum stem keeps short names intact.

Examples: Ithanowi → Ithan (bucket Ithan holds Ithan), Rezydencji → Rezydencj (bucket holds Rezydencja), Perrinie → Perrin (-ie stripped; bucket holds Perrin), query Tussalem → Tussal (bucket holds Tussal). Both sides must stem to the same key. A masculine -o lemma like Losso stems to itself (-o is not a listed suffix), so the instrumental Lossem → Loss misses the Losso bucket and falls through; Lossem actually resolves at Stage 3, distance 2 (a recorded design decision — see below).

Stage 2b — Consonant-alternation reversal (O(1) per candidate)

When a Polish suffix mutates the stem-final consonant, plain stripping cannot recover the lemma. GetAlternationCandidates(query) strips a known inflected ending and re-appends the corresponding base ending (table above), producing candidate lemmas that are each looked up directly in Index. A candidate may be wrong — the reversal is heuristic — so only an actual index hit accepts it; a miss falls through harmlessly. Anwardzie illustrates both halves: -dzie → -da produces Anwarda, which is not indexed, so the pipeline continues and Stage 3 finds Anward at distance 3.

Stage 3 — Levenshtein fuzzy (BKTree)

The last resort, for typos (ErastrEraster). The index holds a Nerthus.BKTree metric tree over all tokens; Search(query, threshold) prunes subtrees that cannot contain a within-threshold match, giving effective O(log N) lookup over the few-thousand-token index. Distance is two-row, case-insensitive Levenshtein. The threshold is dynamic and length-based, overridable with -MaxDistance:

threshold = MaxDistance >= 0 ? MaxDistance
          : query.Length < 5 ? 1
          : floor(query.Length / 3)

Matches are sorted by distance, then name; each is funneled through the owner filter and the first survivor is the answer. -TopN caps how many alternatives the daemon collects internally, but fuzzy near-misses are not part of the wire response — the response's candidate list carries homonym owners of the matched token.

Structured-value routing always disables fuzzy

This is the rule's normative home: PU character names, @Transfer endpoints, distribution participants, Discord delivery targets, and game-log segment/speaker resolution all resolve with -NoFuzzy — a wrong guess would silently misroute value. Declension and alternation still apply; only the edit-distance stage is off, so an unmatched name returns $null instead of a near-miss. There is no caching of misses.

A query that survives no stage is a hard miss; the PU monthly batch treats any unresolved character name as fatal and aborts with no partial writes (PU model).

The owner filter — -Type, -ActiveOn, -Within

Every stage's candidate token passes through one owner-selection filter:

  1. -Type keeps only owners of the requested type; a Lokacja filter also admits Mapa owners (map-as-location). When both a true Lokacja and a Mapa survive, the exact requested type is preferred.
  2. -ActiveOn <date> (default: now) excludes owners whose @status at that date is Usunięty — owners with any other status, or no @status at all, still resolve. A currently soft-deleted entity therefore never resolves by default, but a historical -ActiveOn predating the deletion still reaches it (temporal value scopes: tag schema).
  3. If multiple owners survive, the resolver picks one — it never returns $null for ambiguity:
  4. -Within <city> wins first. The filter picks the owner whose active @outerior or @lokacja (the computed world root and the parent location) at -ActiveOn normalizes to the given city. Failing that, it accepts an owner whose @forma_sesyjna (a literal session route form) references that city in the exterior segment before the leaf or the first path segment. This catches a guild canonically seated in Port Tuzmer but written Tuzmer/… in sessions. Name normalization is the deterministic folding owned by Locations.
  5. Temporal preference next: owners carrying @lokacja/@outerior tags with no value active at -ActiveOn (a guild not yet founded) are dropped from the pool. Owners with no containment tags at all are always kept, so Postać/Gracz collisions are never disturbed.
  6. Then the entry's Primary, then the first survivor.
  7. Whatever was picked, the result reports Ambiguous = true whenever more than one owner survived filtering, plus OwnerCandidates — the surviving owner names — so a caller can see that a -Within or -Type hint would make the pick deliberate.

Zero survivors make the stage miss (the pipeline continues); zero survivors at every stage yield $null.

-Within and -ActiveOn compose. With Gildia Teologów in Ithan since 2023-01 and a new chapter in Thuzal since 2025-03, -Within Ithan -ActiveOn 2024-01-15 reaches the Ithan chapter and -Within Thuzal -ActiveOn 2025-06-01 the Thuzal one. Without -Within, in 2024 the temporal preference alone picks Ithan, while in 2026 both exist and the pick is flagged Ambiguous.

Cmdlet surface

  • Resolve-NerthusName — the resolver. One name resolves singly; several names in -Name switch to batch resolution. Batch supports only -Type, -ActiveOn, and -NoFuzzy; -Within, -TopN, and -MaxDistance apply to single-name resolution.

Routes, request body fields, response envelopes, capabilities, and the diagnostic GET /v1/api/name-index/lookup/{token} and POST /v1/api/name-index/rebuild (no cmdlet wrappers) are specified in the API reference.

Examples (real Nerthus data)

# Consonant alternation: -dzie reversed to -da; the candidate misses, fuzzy completes
Resolve-NerthusName 'Anwardzie'
#   stage: fuzzy         →  Anward (Gracz), confidence 0.667

# Plain declension suffix strip: -owi
Resolve-NerthusName 'Ithanowi' -Type Lokacja
#   stage: declension    →  Ithan (Lokacja), confidence 0.95

# An -o lemma: the stems don't align, so fuzzy catches it at distance 2
Resolve-NerthusName 'Lossem'
#   stage: fuzzy         →  Losso Minewit (Postać), confidence 0.667

# Homonym + era: three filters composing
Resolve-NerthusName 'Gildia Teologów' -Type Grupa -Within 'Ithan' -ActiveOn 2024-01-15
#   stage: exact         →  Gildia Teologów (Ithan)
#   ambiguous: true; candidates: Gildia Teologów (Ithan), Gildia Teologów (Thuzal)

# Strict resolution for value routing — never guess
Resolve-NerthusName 'Ithn' -NoFuzzy
#   → $null   (exact + declension only; no fuzzy false positive)

# Batch resolution during session distribution
Resolve-NerthusName -Name 'Erastera','Tussalowi','Opat Perrin' -ActiveOn 2026-07-01
#   → one { token, resolved, typ, stage } item per query; stage 'miss' on failure
# Diagnose an ambiguous token without touching daemon internals
curl -s 127.0.0.1:$PORT/v1/api/name-index/lookup/Eraster \
  -H "Authorization: Bearer $TOK"
# → { "source":"Eraster", "ambiguous":true,
#     "owners":[ {"name":"Eraster","type":"Gracz","priority":1}, {"name":"Eraster","type":"Postać","priority":1} ] }

Design decisions

  • Adjectival multi-word inflection. Both words of Stary Werbin decline independently. Decision: index each ≥3-char word at priority 2 and resolve per-token; whole-phrase declension is not modeled.
  • Alternation before fuzzy. Decision: keep Stage 2b ahead of Stage 3 — a validated grammar reversal beats an edit-distance guess.
  • Masculine -o lemmas. Decision: accept that Losso-class inflections (Lossem) resolve at Stage 3 rather than Stage 2 (mechanics under the pipeline above).
  • Fuzzy alternatives on the wire. Open: surfacing Stage-3 near-misses with distances would need a wire extension.
  • ASCII legacy reads. Decision: import-only — the live index holds diacritical canon only (adoption).

See also

  • Architecture — the daemon-as-data-owner contract, the fingerprint self-heal, the Nerthus.* C# substrate
  • API reference — routes, request/response envelopes, capabilities
  • Entities model and the tag schema — token sources (@alias, @slug, @nazwa_nerthus, @base_name, @generyczne_nazwy), @status, temporal value scopes
  • Sessions model — mention extraction and distribution that consume the resolver
  • Players model — the Gracz/Postać split behind the primary-owner rule
  • PU model — fail-early on any unresolved character name
  • Locations model — homonym splits, the @forma_sesyjna claim ledger, the deterministic name normalization behind -Within