← back to Unclaimed Property Platform
services/matching/entity_match.py
150 lines
"""Entity resolution — probabilistic linkage, DISTINCT from public search ranking.
Search ranking favors recall (help a person find a record). This favors precision (decide
whether two records refer to the same real owner) and keeps a manual-review band. A score
here NEVER auto-approves a claim.
The weights below are an ILLUSTRATIVE logistic model. Production replaces them with a
Fellegi-Sunter model whose agreement/disagreement weights are frequency-adjusted (rare-name
agreement counts more than a common surname) and calibrated against state-adjudicated pairs,
with per-jurisdiction error analysis and protected-class fairness testing.
Stdlib-only: uses difflib.SequenceMatcher for string similarity in place of rapidfuzz.
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from difflib import SequenceMatcher
from services.common.normalize import (
normalize_business, normalize_postal, normalize_text, phonetic_key,
)
@dataclass(frozen=True)
class MatchInput:
name: str
address: str | None = None
city: str | None = None
region: str | None = None
postal_code: str | None = None
is_business: bool = False
@dataclass(frozen=True)
class MatchResult:
probability: float
disposition: str # likely_duplicate_candidate | manual_review | distinct
features: dict = field(default_factory=dict)
def _ratio(a: str, b: str) -> float:
if not a or not b:
return 0.0
return SequenceMatcher(None, a, b).ratio()
def _token_set_ratio(a: str, b: str) -> float:
"""Order-independent token overlap (Jaccard-ish), a cheap token_set_ratio stand-in."""
ta, tb = set(a.split()), set(b.split())
if not ta or not tb:
return 0.0
return len(ta & tb) / len(ta | tb)
def similarity(left: str | None, right: str | None) -> float:
return _ratio(normalize_text(left), normalize_text(right))
def blocking_keys(inp: MatchInput) -> list[str]:
"""Return index/lookup keys for candidate reduction (blocking step).
A candidate pair is only scored by entity_match if the two records share ≥1
blocking key — this reduces the O(n²) all-pairs comparison to O(n × k) where k
is the average blocking-bucket size (typically O(1) to O(100)).
Index pattern: build a {key → list[record_id]} map at ingest time; at query time
generate the query record's keys, union the candidate sets across all matching
keys, then call entity_match on each candidate. Sharded by jurisdiction for
scalability.
Fairness note: phonetic keys must cover international name distributions.
The current phonetic_key uses a Soundex-like scheme — adequate for English
but degrades on Polish/Vietnamese/Arabic names. Production should add a
Double Metaphone or Metaphone 3 path and test recall across name corpora
(see tests/test_cycle4_fairness.py).
"""
norm = normalize_business(inp.name) if inp.is_business else normalize_text(inp.name)
keys: list[str] = []
# Name-derived keys only when the name yields latin-script tokens. A name that
# normalizes to empty (pure CJK/Arabic script under the Soundex-era normalizer) must
# STILL fall through to the geo keys below — otherwise those owners are un-indexable
# even with a valid address, a coverage/fairness gap (see tests/test_cycle4_fairness.py).
if norm:
pk = phonetic_key(norm)
if pk:
keys.append(f"ph:{pk}")
toks = norm.split()
if toks:
keys.append(f"tok0:{toks[0]}")
if len(toks) > 1:
keys.append(f"tok01:{toks[0]}_{toks[1]}")
if inp.postal_code:
z5 = (normalize_postal(inp.postal_code) or "")[:5]
if z5:
keys.append(f"zip:{z5}")
if inp.region:
nr = normalize_text(inp.region)
if nr:
keys.append(f"region:{nr}")
return keys
def entity_match(left: MatchInput, right: MatchInput) -> MatchResult:
# A person and a business are never the same entity.
if left.is_business != right.is_business:
return MatchResult(0.0, "distinct", {"type_conflict": 1.0})
ln = normalize_business(left.name) if left.is_business else normalize_text(left.name)
rn = normalize_business(right.name) if right.is_business else normalize_text(right.name)
features = {
"name_edit": _ratio(ln, rn),
"name_token": _token_set_ratio(ln, rn),
"phonetic": _ratio(phonetic_key(ln), phonetic_key(rn)),
"address": similarity(left.address, right.address),
"city": similarity(left.city, right.city),
"region_exact": float(
bool(left.region and right.region)
and normalize_text(left.region) == normalize_text(right.region)
),
"postal_exact": float(
bool(left.postal_code and right.postal_code)
and (normalize_postal(left.postal_code) or "")[:5]
== (normalize_postal(right.postal_code) or "")[:5]
),
}
# ILLUSTRATIVE weights — train + calibrate before production use.
log_odds = (
-8.0
+ 3.6 * features["name_edit"]
+ 2.8 * features["name_token"]
+ 2.2 * features["phonetic"]
+ 2.3 * features["address"]
+ 1.1 * features["city"]
+ 1.0 * features["region_exact"]
+ 1.5 * features["postal_exact"]
)
probability = 1.0 / (1.0 + math.exp(-log_odds))
if probability >= 0.995:
disposition = "likely_duplicate_candidate"
elif probability >= 0.90:
disposition = "manual_review"
else:
disposition = "distinct"
return MatchResult(round(probability, 6), disposition, features)