@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/names
ESM, Node ≥ 22.3 or Bun ≥ 1.4.3. MIT. No entry imports a node: module, so every entry
bundles for a browser or worker as is; only Lexicon.load, which reads a file, needs Node
or Bun at call time.
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, control bytes and a named corruption 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.
A corrupted print
A print whose bytes went wrong on the way (UTF-8 read as Windows 1252, read twice, read as
Mac OS Roman, a dropped byte, a stray control) says so. corruption names each run, and the
levels read its repair, while nn0 keeps the raw print:
const n = personName("Méabh Varane", "given-first");
n.nn0; // "Méabh Varane"
n.nn1; // "Méabh Varane"
n.nn2; // "Varane<<Meabh"
n.corruption?.kind; // "utf8-as-cp1252"
n.corruption?.spans; // [{ kind: "utf8-as-cp1252", at: 1, raw: "é", repair: "é", confidence: 0.98 }]
diagnoseNameCorruption(print, knownNameOf(lexicon)) is the same answer for a bare string. A
dropped byte is read as a wildcard against the lexicon, and a lost letter (U+FFFD) is named and
never filled. The kinds and the confidences are in
docs/NAME-NORMALIZATION.md, A corrupted print.
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 call
The default parenthetical handler strips every aside: Pundeallina "Katie" Foxe is
Foxe<<Pundeallina.
A caching factory holds one entry per print and the order it was READ in: a print read
with the order unknown and again with the order that resolves to is one parse and one
object. Past capacity (default DEFAULT_PERSON_NAME_CAPACITY, 262,144) the least
recently used entry is evicted; stats().evictions counts them.
The shared default factory's capacity can be set once per process, before anything parses through it. The capacity is the only option it takes, since it is the only one that changes no answer:
import { configureDefaultPersonNameFactory, personName } from "@finbheara/names/normalize";
configureDefaultPersonNameFactory({ capacity: 1_000_000 }); // ~1 KB an entry
personName("Moira O'Rervton");
configureDefaultPersonNameFactory({ capacity: 10 }); // throws: already made
It throws once the default factory exists, whether an earlier call made it or a first
personName, personNameFromIcao or defaultPersonNameFactory() did.
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.
A declared order beats the comma; the comma decides only when nothing is declared:
personName("Fennick, Orla", "given-first").nn2; // "Orla<<Fennick": the declaration decides
personName("Fennick, Orla", "unknown").nn2; // "Fennick<<Orla": the comma decides
On a given-first page a comma is far more often punctuation inside a given-first name than
an inversion. A caller that knows a page mixes Surname, Given cells into a given-first
document passes unknown for that page, so the comma decides each cell. deriveNameFormat
reports such a page as unknown (mixed) when it holds many of them (3 among 6 given-first
cells), and nameFormatOrder maps that to unknown; a single stray cell (1 among 6) still
derives First Last. bun run names order <file> prints the same for a file of
name cells, one per line. The specification is docs/NAME-NORMALIZATION.md.
Scoring a splitter against declared prints
A Last, First print names its own surname span. buildAnchorIndex collects those spans from
prints whose document declared its order, anchoredSurname finds the span for a print of the
same name in the other order, and primedVerdict says whether a splitter put the boundary
there. The splitter is an argument, so a deliberately wrong control (edgeOnlySplit,
compoundLessSplit) shows the bar can fail. unprimedTally scores an order guess with the
declaration withheld, and clearsUnprimedFloor compares its balanced rate to UNPRIMED_FLOOR.
import {
anchoredSurname, buildAnchorIndex, edgeOnlySplit, primedVerdict, splitPersonName,
} from "@finbheara/names/normalize";
const prints = [
{ name: "van der Quell, Pealling", declared: "Last, First" as const },
{ name: "Pealling van der Quell", declared: "First Last" as const },
];
const anchor = anchoredSurname(prints[1], buildAnchorIndex(prints)); // "vanderquell"
primedVerdict(prints[1], anchor, splitPersonName).verdict; // "consistent"
primedVerdict(prints[1], anchor, edgeOnlySplit).verdict; // "inconsistent"
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 }
nameKinship(personName("Grace Zumock"), personName("Ailbhe Grace Zumock")); // a middle name used
// { relation: "candidate", via: ["given-middle"], reason: "middles-equal", gateSafe: false }
// four opt-in readings, each off by default: a suffix one side lacks, a run of initials,
// a hyphenated first name with its first half spelt another way, an Irish surname form
const wide = createNameKinship({
suffixAbsent: true, initialRuns: true, joinedVariants: true, surnameForms: true,
});
wide(personName("Lena Zumock"), personName("Lena Zumock II")).via; // ["suffix-absent"]
wide(personName("JR Zumock"), personName("Zumock, Joseph R.")).via; // ["given-initials"]
wide(personName("Ann Marie Zumock"), personName("Zumock, Anne-Marie")).via;
// ["given-joined", "given-family"]
wide(personName("Lena Nolan"), personName("Ó Nualláin, Lena")).via; // ["surname-form"]
// 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 candidate
candidate 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 `?` kept
A name read the Spanish way (given names, a paternal and a maternal surname) is opt-in. Where the
default relation says different, spanishNames: true reads each print every way it may be cut,
keeps a compound joined by a particle or y whole, and relates a full form to a shorter one:
import { createNameIndex, createNameKinship, personName } from "@finbheara/names/normalize";
const spanish = createNameKinship({ spanishNames: true });
spanish(personName("Ana Inés Varrondo Quelmar"), personName("Inés Varrondo"));
// { relation: "candidate", via: ["surname-maternal", "given-middle", "spanish"],
// reason: "middle-absent", gateSafe: false }
spanish(personName("Sofía Ostrevo Landar"), personName("SOFIA OSTREVO")).via;
// ["surname-maternal", "spanish"]
spanish(personName("Ailbhe Grace Zumock"), personName("Ailbhe Grace")).relation;
// "different": `Grace` is a given name, never the first surname
spanish(personName("Renata Ruelas y Terzán"), personName("Renata Ruelas")).relation;
// "different": `Ruelas y Terzán` is one surname
const index = createNameIndex<number>("nn3", { spanishNames: true });
index.set(personName("Ana Inés Varrondo Quelmar"), 1);
index.related(personName("Inés Varrondo")).map((m) => m.value); // [1]
A name read by OCR from a scanned page may carry glyphs the recogniser confused: a lower-case
l for a capital I, rn for m. ocrConfusion(a, b) compares two WORDS and answers the
runs it read as their confusables, [] where the words already agree as NN3 reads them, or
null. It renders nothing and makes no key: the table (OCR_CONFUSABLES, frozen data) is
read glyph for glyph, case included, and the package sets no threshold, so a caller counts the
runs or refuses a class itself. createNameKinship({ ocrConfusables: true }) reads it field by
field where the relation would say different, via ocr-glyph (never gate-safe), and every
answer that used it carries the runs as confusions; createNameIndex and createNameBlocker
take the same option, so each match carries the same array:
import {
OCR_CONFUSABLES, createNameIndex, createNameKinship, ocrConfusion, personName,
} from "@finbheara/names/normalize";
ocrConfusion("Quamby", "Quarnby"); // [{ class: "rn-m", a: "m", b: "rn", at: 3 }]
ocrConfusion("lvarra", "Ivarra"); // [{ class: "I-l", a: "l", b: "I", at: 0 }]
ocrConfusion("LVARRA", "IVARRA"); // null: a capital L is no capital I's confusable
ocrConfusion("Quarnby", "QUARNBY"); // []: equal as NN3 reads them
const ocr = createNameKinship({ ocrConfusables: true });
ocr(personName("Kate Quamby"), personName("Kate Quarnby"));
// { relation: "candidate", via: ["ocr-glyph"], reason: "middles-equal", gateSafe: false,
// confusions: [{ class: "rn-m", a: "m", b: "rn", at: 3, field: "surname", index: 0 }] }
const noRn = createNameKinship({
ocrConfusables: OCR_CONFUSABLES.filter((r) => r.class !== "rn-m"),
});
noRn(personName("Kate Quamby"), personName("Kate Quarnby")).relation; // "different"
const index = createNameIndex<number>("nn3", { ocrConfusables: true });
index.set(personName("Kate Quarnby"), 1);
index.related(personName("Kate Quamby")).map((m) => m.kinship.confusions); // [[{ class: "rn-m", … }]]
A 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 (Node and Bun)
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.jsonl
import { 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 rule
The 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.