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_nazwyvalue, 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 (
Ithanowi→Ithan), 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ówshared 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:
- Lower priority number wins (a full name beats another entity's word token).
- At equal priority:
PostaćbeatsGracz,LokacjabeatsMapa. - 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:
The alternation pairs (inflected ending → base ending), in engine order:
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 (Erastr → Eraster). 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:
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:
-Typekeeps only owners of the requested type; aLokacjafilter also admitsMapaowners (map-as-location). When both a trueLokacjaand aMapasurvive, the exact requested type is preferred.-ActiveOn <date>(default: now) excludes owners whose@statusat that date isUsunięty— owners with any other status, or no@statusat all, still resolve. A currently soft-deleted entity therefore never resolves by default, but a historical-ActiveOnpredating the deletion still reaches it (temporal value scopes: tag schema).- If multiple owners survive, the resolver picks one — it never returns
$nullfor ambiguity: -Within <city>wins first. The filter picks the owner whose active@outerioror@lokacja(the computed world root and the parent location) at-ActiveOnnormalizes 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 writtenTuzmer/…in sessions. Name normalization is the deterministic folding owned by Locations.- Temporal preference next: owners carrying
@lokacja/@outeriortags 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. - Then the entry's
Primary, then the first survivor. - Whatever was picked, the result reports
Ambiguous = truewhenever more than one owner survived filtering, plusOwnerCandidates— the surviving owner names — so a caller can see that a-Withinor-Typehint 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-Nameswitch to batch resolution. Batch supports only-Type,-ActiveOn, and-NoFuzzy;-Within,-TopN, and-MaxDistanceapply 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 Werbindecline 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
-olemmas. Decision: accept thatLosso-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_sesyjnaclaim ledger, the deterministic name normalization behind-Within