from typing import List, Sequence from .legal_conflict_name_match_types import NameMatch from .math_round_div import round_div from .text_normalise_name import normalise_name _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