""" Entity resolution (docs/06): block, score, and link records across sources so the same company from GLEIF + Brreg + CVR collapses to one record with corroborated confidence. rapidfuzz only; deterministic; no external calls. """ from __future__ import annotations from typing import Any from rapidfuzz import fuzz def block_key(rec: dict[str, Any]) -> tuple[str, str]: """Cheap blocking key — same market + first initial — to avoid O(n^2).""" return (rec.get("market") or "", (rec.get("name") or "")[:1].upper()) def resolve(records: list[dict[str, Any]], threshold: int = 92) -> list[dict[str, Any]]: out: list[dict[str, Any]] = [] for r in records: match = None for o in out: if block_key(o) == block_key(r) and fuzz.token_sort_ratio(o.get("name", ""), r.get("name", "")) >= threshold: match = o break if match: match.setdefault("merged_from", []).append(r.get("source")) # Merge the losing record's per-field provenance regardless of source # novelty below, so the surviving record's provenance always reflects # every contributing record's fields. existing_provenance = match.setdefault("provenance", []) seen_provenance = {(p.get("field_name"), p.get("source_id")) for p in existing_provenance} for p in r.get("provenance", []): key = (p.get("field_name"), p.get("source_id")) if key not in seen_provenance: existing_provenance.append(p) seen_provenance.add(key) # Corroboration (confidence/agreed_sources bump) requires an # *independent* source — two hits from the same connector must not # count as two agreeing sources. seen_sources = match.setdefault("_sources_seen", {match.get("source")} - {None}) src = r.get("source") if src is not None and src not in seen_sources: match["confidence"] = min(0.99, match.get("confidence", 0.0) + 0.1) match["agreed_sources"] = match.get("agreed_sources", 1) + 1 seen_sources.add(src) else: out.append({**r, "agreed_sources": 1, "_sources_seen": {r.get("source")} - {None}}) for o in out: o.pop("_sources_seen", None) return out