@finbheara/names
v0.20.0
Published
A curated lexicon of personal, school and place names, the surname-particle vocabulary, a phrase classifier and a person-name normalizer
Maintainers
Readme
@finbheara/names
Person names as they are actually printed: parsed, normalized, compared, and told apart from school and place names.
- A curated lexicon (
names.txt): ~15,000 given names, surnames, places, schools and multi-word phrases, each flagged for what it is. - The surname-particle vocabulary:
Mac,Ní,O,van,deand the rest, with one display spelling each. - A person-name normalizer: one printed cell in, a
PersonNameout, with a canonical display form, a filing key and a matching key. - A phrase classifier: is this string a person, a school or a place, and where does the person end and the school begin?
- Measurement tools: run a stream of names through all of the above and see what it does not recognise.
It was built on results sheets from Irish dance competitions, where one dancer's name
arrives as O'RERVTON, MOIRA, Moira ORervton and Moira O Rervton, and a school's name
is fused onto the dancer's. The rules are general to Anglo, Irish and European names.
npm install @finbheara/namesESM, Node ≥ 22 or Bun ≥ 1.4.2. MIT.
Entry points
Import only what you use; /lexicon, /particles, /normalize and /phonetic never load the
classifier's bloom tables.
| import | what |
|---|---|
| @finbheara/names/normalize | PersonName, its factories, compare, compose, lists |
| @finbheara/names/lexicon | names.txt parsed: flags, multi-word phrases, spellings, roles |
| @finbheara/names/particles | the particle vocabulary |
| @finbheara/names/classifier | person / school / location classification and segmentation |
| @finbheara/names/phonetic | Double Metaphone: how a word sounds, as a key |
| @finbheara/names/measure | the census and the classifier evaluation |
| @finbheara/names | all of the above |
| @finbheara/names/names.txt | the raw lexicon |
Normalize a name
import { isNoNamePrint, personName } from "@finbheara/names/normalize";
const n = personName("WYRKOW NÍ MHAOLVARNA");
n.nn2; // "NiMhaolvarna<<Wyrkow" canonical display form
n.nn3; // "NIMHAOLVARNA<<WYRKOW" filing key
n.nn4; // "NIMHAOLVARNA<WYRKOW" order-free matching key
n.surnameKey(); // "NIMHAOLVARNA"
n.givenName(); // "WYRKOW"
n.surname; // { particles: ["NÍ"], base: "MHAOLVARNA", tradition: "i", ... }
n.isPerson(); // true
personName("ORERVTON, MOIRA", "surname-first").nn2; // "O'Rervton<<Moira"
personName("Foireann A").isTeam(); // true: a team, not a person
personName("Kelsey Pearraillina - BN2018").refusal; // { rule: "refuse-digit", ... }
personName("N/A").printsNoName(); // true: a placeholder, no name printed
personName("").isEmpty(); // true: nothing printed at all
isNoNamePrint("Not known"); // true, from the print alone (no parse)Every input gets a PersonName; a cell that names no person is refused, not thrown:
isPerson() is false, refusal says which rule, and every level from NN2 down reads
PersonName.REFUSED ("!!!INVALID"). An empty cell is neither a person nor a refusal:
it answers isEmpty(). printsNoName() is true for an empty cell, a cell with no letter
(???) and a placeholder (N/A, TBA, Unknown, Not known), whichever rule refused it.
The levels
| level | is | case | accents | apostrophes | field order |
|---|---|---|---|---|---|
| nn0 | exactly what the page said | kept | kept | kept | kept |
| nn1 | nn0 with whitespace, quotes and control bytes repaired | kept | kept | normalized | kept |
| nn2 | the canonical display form (Surname<<Given<Middle) | derived | ASCII | kept | kept |
| nn3 | the filing key | upper | ASCII | dropped | kept |
| nn4 | the matching key: are two prints the same name? | upper | ASCII | dropped | dropped |
Persist a level string, never the object: nn2 to display, nn3 as a key. Read a stored
nn2 back with personNameFromIcao(nn2) (or factory.fromIcao). The full contract is in
docs/NAME-NORMALIZATION.md.
Factories
personName is the default factory's shorthand: a caching factory over the committed
names.txt. Make your own when you need other options; a factory's options are fixed
when it is made, and no factory can change another's answers.
import { createCachingPersonNameFactory, createPersonNameFactory } from "@finbheara/names/normalize";
const names = createCachingPersonNameFactory({
lexicon: myLexicon, // a Lexicon or a (key) => spelling lookup; null for rules only
traditions: null, // no tradition lookup
parenthetical: (aside) => // what "( … )" or "\" … \"" in a name cell means
aside === "Ontario" ? { refuse: "refuse-location-alias" } : "strip",
capacity: 100_000, // memo size; 0 disables the memo
});
names.parse("Anna (Ontario) Mailtina").isPerson(); // false
names.parse("Lena (Xiaoyu) Zumock").nn2; // "Zumock<<Lena"
names.stats(); // { hits, misses, evictions, size, capacity }
const uncached = createPersonNameFactory(); // parses every callThe default parenthetical handler strips every aside: Pundeallina "Katie" Foxe is
Foxe<<Pundeallina.
A document's name order
deriveNameFormat reads the name cells of ONE document (a results sheet, a roster, a
column) and says which order it prints them in, as probabilities over First Last,
Last, First and Last First, with "unknown" where none clears the threshold or the
document prints both orders. It also reports hasSchool and hasLocation (do most cells
carry a school or a place after the name?) and each print's reading, so a caller can strip
those parts before parsing.
import { createSegmenter } from "@finbheara/names/classifier";
import { Lexicon } from "@finbheara/names/lexicon";
import { deriveNameFormat, nameFormatOrder, readNameCell } from "@finbheara/names/normalize";
// invented names, in a lexicon of their own
const lexicon = Lexicon.fromText("Zelvine:F\nOramie:F\nTalwyn:F\nQuorrish:L\nVantrell:L\nOspindle:L\nZandrel:G");
const segmenter = createSegmenter(lexicon); // optional: reads the school part; same lexicon
const doc = deriveNameFormat(
["Quorrish, Zelvine", "Vantrell, Oramie - Zandrel", "Ospindle, Talwyn", "Quorrish, Oramie", "Vantrell, Talwyn"],
{ lexicon, segmenter },
);
doc.verdict; // "Last, First"
nameFormatOrder(doc.verdict); // "surname-first": pass it as the document's declared order
doc.readings[1].name; // "Vantrell, Oramie"
doc.readings[1].location; // "Zandrel"
deriveNameFormat(["QUORRISH ZELVINE", "VANTRELL ORAMIE"], { lexicon }).verdict; // "unknown": too few
readNameCell("QUORRISH ZELVINE", { lexicon }).order; // "surname-first:two-token"The verdict is a declaration the caller makes about a document; grouping rows into
documents is the caller's. bun run names order <file> prints the same for a file of
name cells, one per line. The specification is docs/NAME-NORMALIZATION.md.
How two names are related
import {
createGivenFamilyList, createNameIndex, createNameKinship, givenFamilyTable, nameKinship,
personName,
} from "@finbheara/names/normalize";
nameKinship(personName("Kate Smith"), personName("Katherine Smith"));
// { relation: "candidate", via: ["given-family"], reason: "middles-equal", gateSafe: true }
nameKinship(personName("Mary Larson"), personName("Mary Conley-Larson")).via; // ["surname-part"]
nameKinship(personName("Mary Larson"), personName("Mary Carlson")).reason; // "surname-differs"
nameKinship(personName("Kate M Smith"), personName("Katherine Conley-Smith")).via;
// ["surname-part", "given-family", "middle-absent"]
nameKinship(personName("Connor Smith"), personName("Conor Smith")).via; // ["given-variant"]
nameKinship(personName("Evie Smith"), personName("Genevieve Smith"));
// { relation: "candidate", via: ["given-family-not-gate"], reason: "middles-equal", gateSafe: false }
nameKinship(personName("Beth Smith"), personName("Bethany Smith")); // the extended family list
// { relation: "candidate", via: ["given-maybe"], reason: "middles-equal", gateSafe: false }
// the given-name tolerance is a list of providers, asked in order
const tableOnly = createNameKinship({ given: [givenFamilyTable] });
tableOnly(personName("Beth Smith"), personName("Bethany Smith")).reason; // "given-differs"
const common = createNameKinship({
given: [givenFamilyTable, createGivenFamilyList({ maxTier: 1 })], // common forms only
});
common(personName("Maggie Smith"), personName("Marjorie Smith")).reason; // "given-differs"
const index = createNameIndex<number>("nn3"); // a NameMap with a tolerant lookup
index.set(personName("Mary Conley-Larson"), 1).set(personName("Mary Carlson"), 2);
index.get(personName("Mary Larson")); // undefined: get stays exact
index.related(personName("Mary Larson")); // [{ name: Conley-Larson<<Mary, value: 1, kinship: … }]To block a set of RECORDS (two records may print one name), hold them in a blocker and take the pairs; each related pair comes once, without comparing every pair:
import { createNameBlocker, personName } from "@finbheara/names/normalize";
const blocker = createNameBlocker<string>();
blocker
.add("row-1", personName("Mary Larson"))
.add("row-2", personName("Mary Conley-Larson"))
.add("row-3", personName("Mary Larson"))
.add("row-4", personName("Mary Carlson"));
for (const { a, b, kinship } of blocker.pairs()) console.log(a.id, b.id, kinship.relation);
// row-1 row-3 same
// row-1 row-2 candidate
// row-2 row-3 candidatecandidate is never a merge: it says two prints MAY be one person. via lists every
tolerance the pair used, and gateSafe is false when any of them is one a gate must not
admit on its own.
A ? (or U+FFFD) in a print is a letter the capture lost. The levels keep what the print
kept, and nameKinship reads each ? as exactly one letter where the prints disagree
without it:
import { lostLetterForm, nameKinship, personName } from "@finbheara/names/normalize";
nameKinship(personName("Zoë Bérard"), personName("Zo? B?rard"));
// { relation: "candidate", via: ["lost-letter"], reason: "middles-equal", gateSafe: false }
personName("Orlaith N? Varraigh").nn2; // "NiVarraigh<<Orlaith": `N?` can only be Ní
lostLetterForm(personName("Zo? B?rard")).surname; // "B?RARD": NN3's field, the `?` keptA given name alone, against a known set
import { createNameRoster, givenOnlyName, personName } from "@finbheara/names/normalize";
givenOnlyName("Lena").nn2; // "<<Lena": the caller declares the print given-only
personName("Lena").isPerson(); // false: undeclared, one word is still refused
givenOnlyName("Lena Z.").nn2; // "Z<<Lena": a trailing single letter is the surname initial
const roster = createNameRoster(
["Lena Zumock", "Mairéad Rervton", "Lena Brambleton", "Éilis Zumock"].map((p) => personName(p)),
);
roster.match(givenOnlyName("Mairead")); // { kind: "unique", name: Rervton<<Mairead }
roster.match(givenOnlyName("eilis")); // { kind: "unique", name: Zumock<<Eilis }
roster.match(givenOnlyName("Lena")); // { kind: "ambiguous", names: [Brambleton<<Lena, Zumock<<Lena] }
roster.match(givenOnlyName("Lena Z")); // { kind: "unique", name: Zumock<<Lena }
roster.match(givenOnlyName("Orla")); // { kind: "none", reason: "no-candidate" }Only unique is an answer: the roster never picks between candidates. A print is matched
by nameKinship, where a missing surname (surname-absent) or an initial
(surname-initial) cannot rule a pair out and is never gate-safe. The default relation uses
the curated given-name table, so Kate finds Katherine; pass
{ kinship: createNameKinship({ given: [] }) } to match given names exactly. Answers do not
depend on the order the set was given in.
The given-name tables, for another language
The tables behind givenFamilyTable and givenFamilyList print as JSON, for a consumer
that is not JavaScript:
npx @finbheara/names tables given-name > given-name-tables.json # or bunx--output json and --schema 1 are the defaults. The PRINTED form is the contract: a
schema keeps its shape for as long as the package offers it, and a new shape is a new
schema, so a command written today keeps working. The files the tables are stored in are
not part of it. Schema 1 is
{
"$comment": [ /* how to read it */ ],
"table": "given-name", "schema": 1,
"variants": { "CONNOR": ["CONOR"], … }, // canonical -> its other spellings
"families": { "CATHERINE": ["KATE", …], … }, // the curated gate tier: root -> forms
"notAtGate": ["MEGAN", …], // forms a gate must not admit as forms
"extended": { "BETHANY": { "BETH": 1 }, … } // root -> form -> tier (1 common .. 3 rare)
}Read it as the library does, and as $comment says: read each name as its canonical
spelling first; two names relate only when they share a root, a form meets EVERY root it
sits under, and nothing is transitive (EVIE meets EVE and EVA, which do not meet each
other). A shared root is given-family unless one name reaches it only as a notAtGate
form; a name that is the root itself always passes. An extended pair's tier is its rarer
member's, the root counting as 1. To avoid comparing every pair, index each name under
every root it has and compare within a root, never on one root per name.
Phonetic and blocking keys
import { doubleMetaphone } from "@finbheara/names/phonetic";
import { initialsBlock, metaphoneBlocks, personName, surnameBlock } from "@finbheara/names/normalize";
doubleMetaphone("SMITH"); // ["SM0", "XMT"] primary, secondary
doubleMetaphone("KAVANAGH"); // ["KFNK", "KFNK"]
const n = personName("Moira O'Rervton");
surnameBlock(n.surnameKey()); // "ORERVTON" A-Z letters only
metaphoneBlocks(n.surnameKey()); // ["ARRF"]
initialsBlock(n.givenFields()[0] ?? "", n.surnameKey()); // "MO"doubleMetaphone is a general phonetic key: a printed name and a transcribed spoken
word are asked the same question. The three block keys bucket names that are worth
comparing and decide nothing; each answers null (or []) where there is nothing to
bucket on, so a self-join never pools the blanks.
A name as database columns
import { NAME_COLUMNS, nameColumns, personName } from "@finbheara/names/normalize";
NAME_COLUMNS; // ["nn0", …, "nn4", "surname_key", "given_key", "blk_surname", "blk_initials",
// "blk_metaphone", "blk_metaphone_alt"]
nameColumns(personName("Moira O'Rervton"));
// { nn0: "Moira O'Rervton", …, nn3: "ORERVTON<<MOIRA", surname_key: "ORERVTON",
// given_key: "MOIRA", blk_surname: "ORERVTON", blk_initials: "MO", blk_metaphone: "ARRF", … }One encoder for every key a store indexes, with no database dependency. A level column
is the slot createNameMap files a name under, null for a print that names no person,
so a map lookup at a level is one equality on one column.
The lexicon
import { Lexicon } from "@finbheara/names/lexicon";
const lex = Lexicon.default(); // the committed names.txt, parsed on first use
lex.getNameFlags("Murphy"); // "LT": a surname, and a tradename
lex.getNameFlags("New York"); // "BG": a multi-word place
lex.matchBigramContinuations("New"); // [["south","wales"], ["york","city"], ..., ["york"], ...]
lex.displaySpelling("MACDONALD"); // "MacDonald"
Lexicon.load("./my-names.txt"); // your own file, same format
Lexicon.fromText("Aoife:F\nKowalski:L6\n");names.txt
One entry per line, Name:FLAGS. # starts a comment. A line with no flags is a female
given name. Flags are case-sensitive and combine (Kelly:AF, New York:BG).
| flag | meaning |
|---|---|
| F M N | female / male / gender-neutral given name |
| L | surname |
| A | ambiguous: seen as both a given name and a surname |
| G | place |
| T | tradename: a school or a teacher |
| C | concept word (River) |
| B | multi-word entry (bigram or trigram) |
| S | a known typo, kept so it is recognised |
| X | excluded from the display renderer's round-trip check |
| D | a given name a diminutive table holds (Kate:FD); a mark, like S and X |
A digit after L or A is a tradition footnote: 1 Armenian, 2 Germanic,
3 Iberian, 4 Irish/Gaelic, 5 Nordic, 6 Slavic (Kowalski:L6). An index is
permanent once issued; a new tradition takes the next free index.
Classify a phrase
import { phraseClassifier } from "@finbheara/names/classifier";
const pc = phraseClassifier(); // over the committed lexicon
pc.classifyNameNormalized("Quorrish Academy of Irish Dance");
// { scores: { person: 0.00, school: 1.00, location: 0.00 }, confidence: 1.00, category: "school" }
pc.segment("Mary Kate O Trurane Quorrish Academy");
// { type: "PS", person: ["Mary","Kate","O","Trurane"], school: ["Quorrish","Academy"], boundary: 4, ... }A logistic-regression classifier and a segmenter over trained weights and bloom filters
of public name and place lists (data/bloom/MANIFEST.json). It is built for
multi-word phrases; for a single token, ask the lexicon.
A sheet that declares its name order (given-first or surname-first) tells the
classifier so, and the classifier then reads its cells with weights fitted only on
documents declaring that order. On a surname-first sheet, a comma in the name column
separates surname from given name:
pc.getNameCategory(cell, "surname-first"); // the order the document declares
pc.getNameCategory(cell); // no order: the same as "unknown"Pass the order the document declares, never a guess: an order read off the cell itself
(inferNameOrder) is not a declaration, so it is not an input to the classifier. With
no order, or unknown, the answers are exactly what they are without the argument.
classifyName, classifyNameNormalized, getNameCategory, classifyBoundary and the
is… tests (after threshold) take it. The garbage refusal and the place rules answer
first under every order.
The model always picks one of person, school or place. Text that is none of them (page footers, headings) can be declared up front; the whole phrase is matched ignoring case, spacing and punctuation, scores zero everywhere and segments to nothing:
const pc = phraseClassifier(undefined, { garbageVocab: ["Results are provisional (c)"] });
pc.classifyNameNormalized("RESULTS ARE PROVISIONAL (C)");
// { scores: { person: 0, school: 0, location: 0 }, confidence: 0, category: "none" }Measure a stream of names
npx names-census < names.txt-or.jsonl # one name per line, or JSON lines
npx names-census --field=name --order-field=order --classify --limit=50 < rows.jsonlimport { createNameCensus } from "@finbheara/names/measure";
const census = createNameCensus();
for (const row of rows) census.add(row.name, row.order);
const report = census.report();
report.unknownTokens; // words no lexicon entry knows, most printed first, with examples
report.unknownPairs; // adjacent unknown words: candidate multi-word entries
report.refused; // refusals by ruleThe unknown words are the candidate names.txt additions. That is the fix for almost
every misread.
Contributing
See CONTRIBUTING.md: how to add a name, the gates, the classifier methodology and versioning.
Licence
MIT © Finbheara Systems. The bloom tables are built from third-party lists; their
sources are in data/bloom/MANIFEST.json.
