npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

node-name-matching

v0.3.0

Published

Precise name-matching algorithm using OpenSanctions fingerprinting

Readme

Name Matching

CI License: MIT

A precise name-matching algorithm for compliance use cases (KYC/AML). Given a verified identity name and a candidate name, it returns a 0–100 similarity score and a categorical verdict (EXACT / HIGH / POSSIBLE / LOW).

Install

npm install node-name-matching

Sample usage

import { matchNames } from 'node-name-matching';

// Reordering, honorifics, and truncation are all transparent to the algorithm
matchNames('EDWARD', 'HAWTHORNE', 'HAWTHORNE');
// { score: 99.99, verdict: 'HIGH', fpIdentity: 'edward hawthorne', fpCandidate: 'hawthorne', candidateEntity: 'PERSON' }

matchNames('JOHN', 'DOE', 'DOE JOHN');
// { score: 100, verdict: 'EXACT', fpIdentity: 'doe john', fpCandidate: 'doe john', candidateEntity: 'PERSON' }

// A common given name alone isn't strong evidence — gated to POSSIBLE, not auto-approved
matchNames('PATRICIA', 'JONES', 'PATRICIA');
// { score: 79.99, verdict: 'POSSIBLE', ... }

// A company candidate is capped, never auto-approved as a person match
matchNames('JOHN', 'DOE', 'JOHN DOE LTD');
// { score: 70, verdict: 'POSSIBLE', candidateEntity: 'ORG', ... }

// Tune the gates for your own deployment population via the optional 4th argument
matchNames('PATRICIA', 'JONES', 'PATRICIA', {
  commonTokens: ['patricia', 'charlotte'], // extend the built-in common-token list
});
// { score: 79.99, verdict: 'POSSIBLE', ... } — same result, now backed by your own calibration too

result.verdict is the actionable output: treat EXACT/HIGH as auto-approve, POSSIBLE as route-to-human-review, LOW as reject/flag. result.score, result.fpIdentity/fpCandidate (the normalized fingerprints), and result.candidateEntity are there for logging/audit trails, not for building your own threshold logic on top — the verdict already encodes that decision.

Also exports detectEntity, fingerprint, THRESHOLDS, and the MatchResult/MatchOptions/Verdict/EntityType types.


How This Compares to Other Tools

Most name-matching tools give you a similarity number; you have to invent the threshold logic yourself. This gives you a verdict (EXACT/HIGH/POSSIBLE/LOW) with the reasoning behind it exposed — designed to sit at a decision point ("auto-approve, or does a human need to look at it?"), not just produce a distance metric.

| | This library | nomenklatura | Splink | RapidFuzz/jellyfish/dedupe | Commercial CoP/AML vendors | |---|---|---|---|---|---| | Ecosystem | Node/TypeScript | Python | Python | Python | Proprietary SaaS | | Output | Score + verdict | Score, no fixed ladder | Match probability | Raw distance number | Match/close/no-match (closed-source) | | Token reordering, initials, typos | ✅ | ✅/⚠️ partial | ✅ (configured) | ❌ (primitives only) | ✅ | | Common-name discrimination | ✅ two gates, one needs no calibration data | ⚠️ needs labeled training data | ⚠️ needs configuration | ❌ | ✅ (proprietary) | | Entity gating (ORG/ACCOUNT vs PERSON) | ✅ | ✅ | ❌ (out of scope) | ❌ | ✅ | | Configurable per-deployment | ✅ MatchOptions | ⚠️ retraining | ⚠️ reconfiguring | n/a | ✅ (vendor config) | | Deterministic & explainable | ✅ every phase named | ✅/⚠️ (trained variant) | ⚠️ probabilistic | ✅ | ⚠️ (ML layers opaque) | | License | MIT | MIT | MIT | MIT | Proprietary | | Turnkey "reference vs. candidate → decision" | ✅ the whole point | ❌ dedup toolkit | ❌ linkage toolkit | ❌ primitives only | ✅ but closed-source, enterprise-priced |

The underlying token-matching technique (OpenSanctions-style fingerprinting → phased alignment → coverage-weighted scoring) is adapted from nomenklatura, not novel — the contribution is the fixed verdict ladder mapping directly onto an operational decision, and shipping the validation openly (see Performance below) rather than just claiming accuracy. Where this doesn't fit: large-scale probabilistic linkage (use Splink), cross-script/phonetic-heavy matching (use nomenklatura or a commercial vendor), or nickname resolution (BOBROBERT — deliberately out of scope; see Limitations).


Use Cases

The two names passed to matchNames are generically a reference name (from a source you trust — a KYC provider, an existing database record, a screening list) and a candidate name (free text you need to check against it — user-entered, scraped, OCR'd, or from an inbound transaction). That shape shows up in several places:

KYC/AML identity verification — does an inbound candidate's name match the identity your provider verified at onboarding?

matchNames(kycRecord.firstName, kycRecord.lastName, inboundPayment.payerName);

Confirmation of Payee / payee verification — before releasing a transfer, does the entered payee name match the actual account holder?

matchNames(accountHolder.firstName, accountHolder.lastName, transferRequest.payeeName);
// verdict === 'EXACT' | 'HIGH'  -> release
// verdict === 'POSSIBLE'        -> hold for review
// verdict === 'LOW'             -> reject

Sanctions/PEP screening triage — cut down false positives an analyst has to review by pre-filtering candidate hits from a screening list against the verified identity, rather than passing every fuzzy hit through untriaged.

const hits = screeningResults.filter(hit =>
  matchNames(subject.firstName, subject.lastName, hit.listedName).verdict !== 'LOW',
);

Customer record deduplication — a new signup or lead enters a name that might already exist in your CRM under slightly different spelling/order.

const possibleDuplicate = existingCustomers.find(c =>
  ['EXACT', 'HIGH'].includes(matchNames(c.firstName, c.lastName, newLead.fullName).verdict),
);

Support/helpdesk identity lookup — verify a caller's stated name against the account they're claiming, without requiring an exact string match.

Each of these is "legal name vs. legal name" — see Limitations for why informally-entered nicknames aren't handled, and adjust MatchOptions (commonTokens, requireSurnameEvidenceForHigh) if your population's common-name distribution differs from the built-in calibration data.


How It Works

1. OpenSanctions Fingerprinting

Both names are reduced to a canonical fingerprint before any comparison:

  1. NFKD normalize — decomposes accented characters (é → e + combining accent)
  2. Strip diacritics — removes combining marks (U+0300–U+036F), so Müller → Muller
  3. Lowercase
  4. Remove punctuation — hyphens, apostrophes, dots become spaces, so AL-HASSAN → AL HASSAN
  5. Drop honorifics — MR, MRS, DR, PROF, REV, etc., plus Nigerian honorifics confirmed present in the source dataset (ALHAJI, ALHAJA, CHIEF, PASTOR) are removed. Some superficially similar words (PRINCE, PRINCESS, OTUNBA) are deliberately not on this list — sampling the dataset showed they're frequently literal given-name/surname components (e.g. OTUNBA appears as an actual surname), not titles, so stripping them caused real false-negative regressions.
  6. Drop unambiguous org suffixes — LTD, INC, LLC, CORP, GmbH, etc. are removed. Two-letter suffixes that collide with plausible combined-initials (CO, SA, NV, BV, AG, SE) are deliberately kept in the fingerprint — see Entity Detection below.
  7. Sort tokens alphabeticallyJOHN DOE and DOE JOHN produce the same fingerprint

This technique was pioneered by OpenSanctions for entity deduplication across multilingual sanctions lists. The result: reordered names, honorifics, and diacritic differences are all transparent to the algorithm.

"MR. MÜLLER HANS"  →  "hans muller"
"HANS MÜLLER"      →  "hans muller"   ← same fingerprint → EXACT match

2. Entity Detection

The candidate name is classified before matching so the algorithm can apply appropriate rules:

| Type | Detection rule | Behaviour | |---|---|---| | PERSON | default | Full scoring | | ORG | any token is an unambiguous org suffix (LTD, INC, CORP…) | Score capped at 70 | | ACCOUNT | matches /^[A-Za-z]{0,6}\d{4,}$/ (e.g. X10974574, GBNG0967982388, or a bare digits-only reference like 204653580) | Score forced to 0 |

This prevents a candidate named "JOHN DOE LTD" from being treated as a person named John Doe.

Two-letter org suffixes (CO, SA, NV, BV, AG, SE) are deliberately excluded from ORG detection on their own — they collide with plausible combined-initials candidate tokens. LAWSON CO against an identity name like CHARLES OLIVER LAWSON should resolve as initials (C + O) via Phase 4, not get force-capped as an organization. A genuine org name is still caught as long as it also carries an unambiguous suffix (ABC INVESTMENTS CO → ORG, because of INVESTMENTS).

3. Token Matching Phases

After fingerprinting, tokens are matched in priority order so that strong matches are claimed before weaker ones:

Phase 1 — Exact match Each candidate token is paired with an identical identity token. This runs first so that e.g. osbourne pairs with osbourne before a single-letter initial can claim it.

Phase 2 — Prefix match Tokens of ≥ 4 characters where one is a prefix of the other (within 3 characters of length) are matched. Catches truncated names: SMITSMITH, LANNELANNES. Marked as fuzzy (caps verdict at HIGH).

Phase 2.5 — Typo tolerance (added 2026-07-29) Tokens of ≥ 4 characters within Damerau-Levenshtein distance 1 (a single substitution, insertion, deletion, or adjacent transposition) are matched. Catches ordinary misspellings: SMITH/SMYTH, ALLISON/ALLYSON, MICHAEL/MICHEAL. Runs after prefix matching and before initials, so a typo isn't mistaken for an unrelated single-letter initial. Marked as fuzzy.

Phase 3 — Single-char initial expansion A single-character token matches any token starting with that letter, and vice-versa. So J matches JOHN or JAMES. Also marked as fuzzy.

Phase 4 — Combined-initial expansion Short all-alpha candidate tokens (2–4 characters) are split into individual characters, each of which must match the first letter of a distinct unmatched identity token. Handles patterns like ACA + C matching ALICE + CHARLOTTE. All-or-nothing: every character must find a unique match, or the whole token is left unmatched. Also marked as fuzzy.

Each token can only be consumed once per side, and the phase ordering is critical: exact before prefix before typo before initial ensures greedy matching doesn't create wrong pairings.

4. Dual-Coverage Scoring

The algorithm tracks two separate coverage metrics to prevent false positives:

  • identityMatched: number of identity tokens accounted for (combined initials count per-character)
  • candidateMatched: number of candidate tokens consumed (one per token, NOT multiplied)

This separation is critical because Phase 4 can match one candidate token "AC" against two identity tokens "alice" + "charlotte" — we must not let the inflated identityMatched mask an unrelated leftover candidate token.

The primary signal is candidate coverage: how much of the candidate name is found in the identity name, not symmetric overlap (Jaccard). This is the right default for compliance: when a candidate provides only their surname (HAWTHORNE), it being fully contained in EDWARD HAWTHORNE is a strong match signal, not a 50% match.

candidateCoverage = candidateMatched / total_candidate_tokens
identityCoverage = identityMatched / total_identity_tokens

score = candidateCoverage × 100 × 0.70
      + mongeElkanJaroWinkler × 100 × 0.30

Jaro-Winkler (via wink-distance) provides a fuzzy character-level backstop for partial coverage cases — applied per token pair (Monge-Elkan style: each candidate token takes its best-matching identity token, then averaged), not to the whole concatenated fingerprint string. Applying JW to the whole sorted string made the backstop depend on where a token happened to sort alphabetically — "HAWTHORNE" vs "EDWARD HAWTHORNE" scored lower than "JOHN" vs "JOHN SMITH" purely because hawthorne sorts after edward while john sorts before smith. Per-token aggregation removes that artifact: both cases now get equal credit for their one matching token, and it's the common-token gate (below) that correctly tells them apart.

5. Common-Token Gate and Surname-Evidence Gate

A candidate name whose only matched evidence is a common given name (e.g. JOHN) can reach full candidate coverage without saying much — common first names aren't discriminative. A rare surname (HAWTHORNE) is much stronger evidence for the same coverage. Two independent gates catch this; either can downgrade what would otherwise be a partial-coverage HIGH to POSSIBLE:

  • Common-token gate: the algorithm builds a static list of the ~300 most frequent name tokens from the training split (src/common-tokens.ts, regenerate with yarn build:common-tokens) and downgrades when every matched candidate token is on that list. This needs calibration data, so it's only as good as the population it was built from.
  • Surname-evidence gate: downgrades when none of the matched evidence is the surname specifically — regardless of whether that token happens to be on the common-token list. This is population-agnostic: it works the same for a population the common-token list was never calibrated on (e.g. "PATRICIA JONES" vs candidate "PATRICIA" gates correctly even though "Patricia" isn't Nigerian-population-common and so isn't in common-tokens.ts — confirmed against public US Census name data).

Both gates only affect the partial-coverage path — full two-sided coverage (see below) is unaffected, since a candidate name that fully matches a short identity isn't missing anything, and it already implies surname coverage.

Configuring the gatesmatchNames takes an optional 4th options argument:

matchNames(identityFirst, identityLast, candidateName, {
  requireSurnameEvidenceForHigh: false,      // disable the surname-evidence gate (default: true)
  commonTokens: ['patricia', 'charlotte'],   // extend the built-in common-token list for your population
  replaceCommonTokens: true,                 // use only your list instead of the built-in one (default: false)
});

6. Full-Coverage Shortcuts

| Condition | Score | Verdict | |---|---|---| | Fingerprints identical | 100 | EXACT | | All tokens matched on both sides, no fuzz | 100 | EXACT | | All tokens matched on both sides, via prefix/typo/initial | 97 | HIGH |

The distinction matters: matching via initials or a tolerated typo is ambiguous (J could be JAMES or JOHN), so the algorithm reserves EXACT for unambiguous full matches only.

7. Verdicts

| Verdict | Score range | Meaning | |---|---|---| | EXACT | 100 | Certain match — all tokens account for each other | | HIGH | ≥ 80 | Strong match — minor ambiguity (initials, prefixes) | | POSSIBLE | ≥ 60 | Partial match — human review recommended | | LOW | < 60 | Unlikely match |


Performance

A note on measurement, read this before the numbers below: the result column in the source dataset is the output of a legacy scorer, not human-verified ground truth. That legacy scorer is demonstrably wrong on cases this algorithm gets right — e.g. it scores MONSURU OLUFEMI DADA vs DADA MONSURU O (a pure reordering) at 29/100. That means the Pearson r / MAE figures below measure agreement with a known-imperfect baseline, not correctness — low disagreement is not necessarily bad, and high agreement is not necessarily good. Treat them as a continuity check across versions, not a quality claim. Real precision/recall numbers require human-labeled pairs — see below.

Unit Tests

45 unit tests covering:

  • 8 core algorithm tests (exact match, reorder, honorifics, initials, ORG cap, account detection, negative cases)
  • 17 combined-initials positive tests (doubles, triples, same-letter pairs, single-char identity tokens)
  • 4 typo-tolerance tests (substitution, transposition, and a negative case)
  • 5 gate tests (common given name alone vs. rare surname alone; a given name outside the common-token calibration data still gated via the surname-evidence gate; disabling that gate via MatchOptions; a custom commonTokens override)
  • 2 org-suffix/combined-initials collision tests
  • 2 honorific tests (a confirmed Nigerian honorific stripped, and a confirmed non-honorific like PRINCE correctly not stripped)
  • 2 broadened ACCOUNT detection tests
  • 5 combined-initials negative tests (wrong letters, partial matches, leftover tokens, non-alpha tokens)

These are useful as regression tests for specific documented behaviors, but they are not a substitute for a real evaluation set — several were written specifically because the algorithm handles them, which is a form of selection bias. The "100%" below describes agreement with these 45 cases, not real-world accuracy.

Classification metrics on unit cases (positive = names refer to same person, negative = different person):

| Metric | Value | |---|---| | Accuracy | 100% | | Precision | 100% | | Recall | 100% | | F1 Score | 100% |

Real evaluation: human-labeled sample

yarn sample:labeling    # draws a stratified sample to data/labeling-sample.csv
# ... fill in the "human_label" column: MATCH / NO_MATCH / UNSURE ...
yarn evaluate:labeled   # per-verdict precision, recall of auto-pass, threshold sensitivity

yarn sample:labeling stratifies ~700 pairs across verdict (EXACT/HIGH/POSSIBLE/LOW) crossed with agreement/disagreement against the legacy column, so the sample deliberately over-represents the disagreements that are most informative. A first pass over all 722 sampled pairs has been done — AI-assisted, not yet independently human-verified: labeled via a holistic reasoning process (token-set correspondence, surname-vs-given-name priority, joint-account/company detection) deliberately kept separate from matchNames itself, so it isn't the algorithm grading its own homework, but it is a first pass pending full human review, not final ground truth.

Results against that first-pass label set (705 of 722 rows; 17 flagged UNSURE and excluded):

| Verdict | n | Precision | |---|---|---| | EXACT | 122 | 100% | | HIGH | 199 | 100% | | POSSIBLE | 199 | 94.5% | | LOW | 185 | 22.7% |

Recall of true matches captured by the EXACT/HIGH auto-pass tier: 58.3% (321/551). That's the headline finding so far: auto-pass precision is excellent, but the current thresholds (80/60) are conservative enough that ~42% of genuine matches land in POSSIBLE or LOW rather than auto-passing. A threshold-sensitivity sweep (also produced by yarn evaluate:labeled) shows T=55 would hit ~95% recall at ~94% precision — a concrete, evidence-backed case for retuning the HIGH threshold, once the labels themselves are confirmed. No threshold changes have been made based on this yet — that's a deliberate next step, not an oversight.

Legacy-column agreement (continuity metric only — see caveat above)

| Dataset | Pearson r | MAE | Verdict distribution | |---|---|---|---| | Test set (6,112 pairs) | 0.65 | 29.8 | EXACT 12.7% · HIGH 71.7% · POSSIBLE 8.8% · LOW 6.7% |

Regenerate with yarn split && yarn test.


Repository Layout & Local Development

This section covers the source repository, not the published package — none of it is needed to use node-name-matching as a dependency. The published package is just matchNames/detectEntity/fingerprint and their types (see Install above). Everything else here — the batch/evaluation scripts, the training dataset they read, the repo's directory layout — is internal tooling for maintaining and calibrating the library, and depends on a private dataset (real identity data) that is deliberately not included in this repository or the published package.

See CONTRIBUTING.md for the repository layout, the local dev scripts (yarn split, yarn build:common-tokens, yarn analyze, yarn sample:labeling/yarn evaluate:labeled), and how to work with the (private, un-shipped) source dataset.


Algorithm Design Decisions

Why candidate coverage instead of Jaccard? Jaccard penalises unmatched tokens symmetrically. For compliance name matching, the candidate often provides a partial name (just a surname, or a name without middle initials). Jaccard would score HAWTHORNE vs EDWARD HAWTHORNE at 50% — LOW — even though the candidate's name is fully found in the verified identity. Candidate coverage correctly treats this as a strong signal.

Why sort tokens (fingerprint) rather than align them? Many real-world name mismatches are pure reorderings (ABDULLAHI ALIU vs ALIU ABDULLAHI). Sorting tokens makes these transparent with zero false-negative cost. The OpenSanctions approach has been validated against millions of real-world entity deduplication cases across multiple languages and scripts.

Why four phases (not just fuzzy matching everywhere)? Allowing initials and prefixes to match freely causes greedy pairing errors. For example, in O OSBOURNE (candidate) vs OLIVER OSBOURNE (identity): if initials fire first, O consumes osbourne (starts with o), leaving osbourne in the candidate unmatched — wrong result. Running exact matches first ensures strong pairings are locked in before looser ones are tried.

Why separate identityMatched and candidateMatched? Phase 4 can match one candidate token "AC" against two identity tokens "alice" and "charlotte". If a single matched counter were used, the inflated count could mask an unrelated leftover candidate token (e.g., candidate "ABC XYZ" where "ABC" matches 3 identity tokens but "XYZ" is unrelated). Tracking them separately ensures coverage is computed accurately on both sides.

Why gate the verdict instead of folding token rarity into the score? An IDF-style weighted score would fold rarity into a single opaque number. Gating only the verdict (partial-coverage HIGH → POSSIBLE when every matched token is common) keeps the score's meaning legible — a reviewer can still see the raw coverage/JW numbers — while fixing the specific failure mode (a common given name alone reaching HIGH) without changing how every other case is scored.

Why exclude PRINCE/PRINCESS/OTUNBA from honorifics despite them looking title-like? They were on the initial candidate list because words like ALHAJI and CHIEF are genuine honorifics in this dataset's population. But sampling raw rows showed OTUNBA appears as an actual last name and PRINCE/PRINCESS as literal given-name tokens (e.g. a real record with PRINCE as a middle name, like "EDWARD PRINCE","HAMILTON"), not prefixed titles. Stripping them produced measurable false-negative regressions on the test set. This is a reminder that honorifics/common-token lists are population-specific and should be checked against sampled data, not assumed from general knowledge of what "sounds like" a title.


Limitations

Known false-positive patterns

| Pattern | Example | Root cause | |---------|---------|------------| | Single-char identity middle initial matching unrelated candidate start | "A HARTLEY" vs "ANDREW BAILEY H THOMPSON" scores 54.4/LOW — the per-token Monge-Elkan JW change (see Scoring) keeps unrelated pairs like this below HIGH, but the underlying cause below is still present | Phase 3: identity token "a" (a bare initial) matches an unrelated candidate token via startsWith, and the identity surname coincidentally also gets consumed via the candidate's own middle initial. Neither identity token is actually part of the candidate's real name. |

Known false-negative patterns

| Pattern | Example | Root cause | |---------|---------|------------| | Concatenated tokens with large length difference | "ANDREW WILLIAM PEMBERTON ASHFORD" vs "A PEMBERTONASHFORD" (a two-word surname concatenated without a space) — score 59.9/LOW | Phase 2: "pembertonashford" (16 chars) vs "pemberton" (9 chars): length diff exceeds MAX_PREFIX_DIFF (3). Phase 2.5's typo tolerance doesn't help either — the edit distance from a multi-character concatenation gap is well above the distance-1 threshold. | | Candidate initial doesn't match any identity token | "MORRISON O" vs "ANDREW MORRISON" — score 60.6/POSSIBLE, "PATTERSON O" vs "GEORGE HENRY PATTERSON" — score 60.8/POSSIBLE | Phase 3: single-char "o" has no matching identity token (andrew starts with a, george/henry start with g/h). The initial may be a different name component not captured in the identity data. | | Numeric tokens in candidate name | "BAXTER 8" vs "EDWARD HARRIET O BAXTER" — score 50.0/LOW | The digit "8" is fingerprinted as-is and doesn't match any text token. If "8" is a reference number or branch code, the algorithm cannot use it. | | Wrong single initial | "HOLLOWAY K" vs "STEPHEN THOMAS HOLLOWAY" — score 50.0/LOW | "k" doesn't match any identity token (stephen, thomas, holloway). The initial may correspond to a middle name not in the identity record. |

Structural limitations

  1. Combined initials require distinct identity tokens per character"OO" needs two different identity tokens starting with o. If only one exists (e.g., identity has only one o-starting token), the decomposition fails even though "OO" could mean both first and middle names start with the same letter.

  2. Combined initials can't reference the last-name token — If the candidate token "DAM" needs D(orothy), A(shford — the last name), M(argaret), but the last name ashford was already consumed by exact match in Phase 1, the "A" has no remaining match even though it's a valid initial. This is by design to prevent ambiguous double-counting.

  3. Phase 3 middle-initial bleed — When an identity name contains a single-character token (e.g., a middle initial like "O"), it can match the start of any unrelated candidate token via startsWith. This is inherent to single-char initial expansion and affects both directions (identity→candidate and candidate→identity).

  4. Language dependence — The algorithm is English/Latin-script oriented. Honorifics (MR, DR, PROF, plus the Nigerian honorifics confirmed in the source dataset) and org suffixes (LTD, INC, GmbH) are Latin-script only. Names in non-Latin scripts (Cyrillic, Arabic, CJK) are not specifically handled. Any new deployment population should have its own honorifics/common-tokens lists checked against sampled data rather than assuming this dataset's lists transfer — see the PRINCE/PRINCESS/OTUNBA case above, where words that look like titles turned out to be literal name components for this population.

  5. No token-rarity weighting beyond the two gates — Both the common-token gate and the surname-evidence gate are binary (on the list or not; the surname or not), not a continuous IDF weighting. A borderline case (e.g. a moderately common surname with no other evidence) gets no partial credit either way.

  6. No nickname/diminutive handlingBOB JOHNSON vs ROBERT JOHNSON is not recognized as a match. This is a deliberate non-goal, not an oversight: the intended use case is matching against legal names (bank account holder names, KYC-verified identities), where nicknames don't appear on the record being matched against in the first place. Some CoP-grade systems handle this because their input side can be informally entered; ours assumes both sides are formal/legal name text.


Version History

| Date | Change | |------|--------| | 2026-07-30 | Breaking: renamed the internal payer terminology to candidate throughout the public API and docs (matches the already-generic language used elsewhere — this library isn't payment-specific). Added a Use Cases section to the README. Removed private-dataset-dependent dev-script instructions from the README (moved to CONTRIBUTING.md, which is repo-only and never ships in the npm package). | | 2026-07-30 | Added a surname-evidence gate (population-agnostic complement to the common-token gate — downgrades a partial-coverage HIGH unless the surname specifically is part of the matched evidence) and a MatchOptions 4th parameter on matchNames to configure it and extend/replace the common-token list per deployment. Confirmed against public US Census name data that the common-token gate alone missed common non-Nigerian given names (e.g. "Patricia", "Charlotte"); the new gate catches them without needing any calibration data. Re-evaluated against the 705 human-labeled pairs: HIGH-tier precision improved 99.5% → 100% with no recall cost. | | 2026-07-29 | Added Phase 2.5 typo tolerance (Damerau-Levenshtein distance ≤ 1), a common-token gate to stop first-name-only candidates from auto-passing HIGH, replaced whole-fingerprint Jaro-Winkler with per-token Monge-Elkan aggregation to remove an alphabetical-sort-order scoring artifact, fixed an org-suffix/combined-initials collision (LAWSON CO), added confirmed Nigerian honorifics, and broadened ACCOUNT detection to digits-only and multi-letter-prefix references. Added stratified human-labeling tooling (sample-for-labeling.ts, evaluate-labeled.ts) since the dataset's result column is a legacy scorer's output, not verified ground truth. | | 2026-07-16 | Added Phase 4 combined-initials expansion — handles LastName AB patterns where each character is a separate initial. Fixed coverage calculation (separate identityMatched/candidateMatched counters). Added classification metrics to test suite. | | 2026-07-15 | Initial release. Three-phase token matching with OpenSanctions fingerprinting, entity detection, and candidate-coverage scoring. |