legal.conflict-name-match@1.0.0
impl/typescript.ts
4,011 bytes · the TypeScript implementation · view raw
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { roundDiv } from "./math_round_div.ts"; ← from math.round-div ^1.0.0 · built alongside by fune
import { normaliseName } from "./text_normalise_name.ts"; ← from text.normalise-name ^1.0.0 · built alongside by fune
import { type NameMatch } from "./legal_conflict_name_match_types.ts";
const COMPANY_FORMS = new Set(["ltd", "limited", "plc", "llp", "llc", "inc"]);
const DROPPED = new Set(["'", "’", "."]);
const MAX_KEY = 500;
function lowerOne(ch: string): string {
// One-to-one case mappings only, as text.normalise-name, so all three languages agree.
const mapped = ch.toLowerCase();
return Array.from(mapped).length === 1 ? mapped : ch;
}
function isAsciiPunctuation(cp: number): boolean {
return (cp >= 0x21 && cp <= 0x2f) || (cp >= 0x3a && cp <= 0x40) || (cp >= 0x5b && cp <= 0x60) || (cp >= 0x7b && cp <= 0x7e);
}
function compareCodePoints(a: string, b: string): number {
const x = Array.from(a).map((c) => c.codePointAt(0) as number);
const y = Array.from(b).map((c) => c.codePointAt(0) as number);
for (let i = 0; i < Math.min(x.length, y.length); i++) {
if (x[i] !== y[i]) return x[i] - y[i];
}
return x.length - y.length;
}
/** The normalised, punctuation-free, company-form-free, token-sorted key. */
function matchKey(value: string): string {
let text = "";
for (const ch of normaliseName(value)) {
const cp = ch.codePointAt(0) as number;
if (DROPPED.has(ch)) continue;
if (ch === "&") text += " and ";
else if (isAsciiPunctuation(cp) || ch === " ") text += " ";
else text += lowerOne(ch);
}
const tokens = text.split(" ").filter((t) => t !== "" && !COMPANY_FORMS.has(t));
const key = tokens.sort(compareCodePoints).join(" ");
if (Array.from(key).length > MAX_KEY) {
throw new RangeError(`names must be at most ${MAX_KEY} characters after normalising`);
}
return key;
}
/** Jaro-Winkler similarity in basis points, from exact integer fractions. */
function jaroWinkler(s1: string[], s2: string[]): number {
const a = s1.length;
const b = s2.length;
if (a === 0 || b === 0) return 0;
const window = Math.max(0, Math.floor(Math.max(a, b) / 2) - 1);
const used = new Array<boolean>(b).fill(false);
const order1: string[] = [];
const matched1 = new Array<boolean>(a).fill(false);
for (let i = 0; i < a; i++) {
for (let j = Math.max(0, i - window); j <= Math.min(b - 1, i + window); j++) {
if (!used[j] && s1[i] === s2[j]) {
used[j] = true;
matched1[i] = true;
break;
}
}
}
for (let i = 0; i < a; i++) if (matched1[i]) order1.push(s1[i]);
const m = order1.length;
if (m === 0) return 0;
let k = 0;
let outOfOrder = 0;
for (let j = 0; j < b; j++) {
if (!used[j]) continue;
if (s2[j] !== order1[k]) outOfOrder += 1;
k += 1;
}
// Jaro = N / D exactly, with t = outOfOrder / 2.
const n = 2 * m * m * (a + b) + a * b * (2 * m - outOfOrder);
const d = 6 * a * b * m;
let prefix = 0;
while (prefix < 4 && prefix < a && prefix < b && s1[prefix] === s2[prefix]) prefix += 1;
if (10 * n <= 7 * d) return roundDiv(10000 * n, d, "half-up");
return roundDiv(10000 * (n * (10 - prefix) + prefix * d), 10 * d, "half-up");
}
/** Candidates similar to a name, best first, for a conflict check. */
export function conflictNameMatch(name: string, candidates: readonly string[], thresholdBasisPoints: number): readonly NameMatch[] {
if (!Number.isInteger(thresholdBasisPoints) || thresholdBasisPoints < 0 || thresholdBasisPoints > 10000) {
throw new RangeError(`thresholdBasisPoints must be between 0 and 10000, received ${thresholdBasisPoints}`);
}
const key = matchKey(name);
if (key === "") {
throw new RangeError("name is empty after normalising");
}
const s1 = Array.from(key);
const matches: NameMatch[] = [];
candidates.forEach((candidate, index) => {
const other = matchKey(candidate);
const score = jaroWinkler(s1, Array.from(other));
if (score >= thresholdBasisPoints) matches.push({ index, candidate, matchKey: other, score });
});
return matches.sort((x, y) => y.score - x.score || x.index - y.index);
}