Functional Weave
Code in Rust

legal.conflict-name-match@1.0.1

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);
}