Functional Weave
Code in Rust

legal.conflict-name-match@1.0.1

impl/python.py

3,489 bytes · the Python 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.

from typing import List, Sequence

from .legal_conflict_name_match_types import NameMatch
from .math_round_div import round_div  ← from math.round-div ^1.0.0 · built alongside by fune
from .text_normalise_name import normalise_name  ← from text.normalise-name ^1.0.0 · built alongside by fune

_COMPANY_FORMS = frozenset(["ltd", "limited", "plc", "llp", "llc", "inc"])
_DROPPED = frozenset(["'", "’", "."])
_MAX_KEY = 500


def _lower_one(ch: str) -> str:
    # One-to-one case mappings only, as text.normalise-name, so all three languages agree.
    mapped = ch.lower()
    return mapped if len(mapped) == 1 else ch


def _is_ascii_punctuation(cp: int) -> bool:
    return 0x21 <= cp <= 0x2F or 0x3A <= cp <= 0x40 or 0x5B <= cp <= 0x60 or 0x7B <= cp <= 0x7E


def _match_key(value: str) -> str:
    """The normalised, punctuation-free, company-form-free, token-sorted key."""
    parts: List[str] = []
    for ch in normalise_name(value):
        if ch in _DROPPED:
            continue
        if ch == "&":
            parts.append(" and ")
        elif _is_ascii_punctuation(ord(ch)) or ch == " ":
            parts.append(" ")
        else:
            parts.append(_lower_one(ch))
    tokens = [t for t in "".join(parts).split(" ") if t != "" and t not in _COMPANY_FORMS]
    key = " ".join(sorted(tokens))
    if len(key) > _MAX_KEY:
        raise ValueError("names must be at most %d characters after normalising" % (_MAX_KEY,))
    return key


def _jaro_winkler(s1: str, s2: str) -> int:
    """Jaro-Winkler similarity in basis points, from exact integer fractions."""
    a = len(s1)
    b = len(s2)
    if a == 0 or b == 0:
        return 0
    window = max(0, max(a, b) // 2 - 1)
    used = [False] * b
    order1: List[str] = []
    for i in range(a):
        for j in range(max(0, i - window), min(b - 1, i + window) + 1):
            if not used[j] and s1[i] == s2[j]:
                used[j] = True
                order1.append(s1[i])
                break
    m = len(order1)
    if m == 0:
        return 0
    k = 0
    out_of_order = 0
    for j in range(b):
        if not used[j]:
            continue
        if s2[j] != order1[k]:
            out_of_order += 1
        k += 1
    # Jaro = n / d exactly, with t = out_of_order / 2.
    n = 2 * m * m * (a + b) + a * b * (2 * m - out_of_order)
    d = 6 * a * b * m
    prefix = 0
    while prefix < 4 and prefix < a and prefix < b and s1[prefix] == s2[prefix]:
        prefix += 1
    if 10 * n <= 7 * d:
        return round_div(10000 * n, d, "half-up")
    return round_div(10000 * (n * (10 - prefix) + prefix * d), 10 * d, "half-up")


def conflict_name_match(name: str, candidates: Sequence[str], threshold_basis_points: int) -> List[NameMatch]:
    """Candidates similar to a name, best first, for a conflict check."""
    if (
        isinstance(threshold_basis_points, bool)
        or not isinstance(threshold_basis_points, int)
        or threshold_basis_points < 0
        or threshold_basis_points > 10000
    ):
        raise ValueError("thresholdBasisPoints must be between 0 and 10000, received %s" % (threshold_basis_points,))
    key = _match_key(name)
    if key == "":
        raise ValueError("name is empty after normalising")
    matches: List[NameMatch] = []
    for index, candidate in enumerate(candidates):
        other = _match_key(candidate)
        score = _jaro_winkler(key, other)
        if score >= threshold_basis_points:
            matches.append(NameMatch(index=index, candidate=candidate, match_key=other, score=score))
    matches.sort(key=lambda x: (-x.score, x.index))
    return matches