← back to Unclaimed Property Platform

services/common/normalize.py

143 lines

"""Shared normalization + masking primitives (stdlib only).

Raw source values are never mutated by these functions — callers store the raw string
alongside the normalized output. Production may swap in ICU/libpostal for parsing, but
the CONTRACT (what 'normalized' means per field) is defined here so ingestion, search,
and matching all agree.
"""
from __future__ import annotations

import re
import unicodedata
from decimal import Decimal, InvalidOperation, ROUND_HALF_EVEN

CORPORATE_SUFFIXES = {
    "INC", "INCORPORATED", "LLC", "LLC.", "L L C", "LTD", "LIMITED",
    "CORP", "CORPORATION", "CO", "COMPANY", "LP", "LLP", "PLLC",
}


def normalize_text(value: str | None) -> str:
    """Uppercase, strip accents, collapse to single-spaced alphanumerics."""
    if not value:
        return ""
    decomposed = unicodedata.normalize("NFKD", value)
    ascii_like = "".join(ch for ch in decomposed if not unicodedata.combining(ch))
    cleaned = re.sub(r"[^A-Z0-9]+", " ", ascii_like.upper())
    return re.sub(r"\s+", " ", cleaned).strip()


def normalize_search(value: str | None) -> str:
    """Fully-collapsed alphanumeric SEARCH key: normalize_text with spaces removed.

    'Catherine O\\'Neil' -> 'CATHERINEONEIL'. Substring-matching a same-collapsed query
    ('Oneil' -> 'ONEIL') then finds it regardless of internal apostrophes/hyphens/spaces —
    fixing the apostrophe/hyphen recall gap without widening normalize_text (which feeds
    masks, blocking keys, and phonetic codes). Production = an OpenSearch char-filter analyzer.
    """
    return normalize_text(value).replace(" ", "")


def normalize_business(value: str | None) -> str:
    """Normalize a business name, dropping legal suffixes but keeping brand tokens."""
    tokens = normalize_text(value).split()
    meaningful = [t for t in tokens if t not in CORPORATE_SUFFIXES]
    return " ".join(meaningful) or normalize_text(value)


_CENTS = Decimal("0.01")


def parse_decimal(value: str | None) -> Decimal | None:
    # m4: quantize to 2 dp so downstream arithmetic stays consistent (Decimal("123")
    # != Decimal("123.00") in comparisons, and string-formatting varies without a fixed
    # precision). Banker's rounding (ROUND_HALF_EVEN) is the financial-calculation default.
    if not value or not value.strip():
        return None
    try:
        raw = Decimal(value.replace("$", "").replace(",", "").strip())
        return raw.quantize(_CENTS, rounding=ROUND_HALF_EVEN)
    except InvalidOperation as exc:
        raise ValueError(f"Invalid amount: {value!r}") from exc


# Soundex digit groups (the classic mapping). We build a phonetic key by coding EVERY
# consonant (including the first letter) and dropping vowels/H/W/Y — so "Catherine" and
# "Kathryn" collapse to the same code (C and K are both group 2), which first-letter
# Soundex would miss. Production swaps this for Double Metaphone; the FEATURE contract
# (phonetic agreement) is what matters here.
_SOUNDEX_MAP = {
    **{c: "1" for c in "BFPV"},
    **{c: "2" for c in "CGJKQSXZ"},
    **{c: "3" for c in "DT"},
    "L": "4",
    **{c: "5" for c in "MN"},
    "R": "6",
}


def phonetic_key(value: str | None) -> str:
    """Space-joined per-token phonetic code (vowel-stripped, adjacent-duplicate-collapsed)."""
    out_tokens = []
    for token in normalize_text(value).split():
        digits: list[str] = []
        for ch in token:
            code = _SOUNDEX_MAP.get(ch)
            if code and (not digits or digits[-1] != code):
                digits.append(code)
            elif not code:
                # a vowel/H/W/Y breaks a run so a real repeated sound isn't over-collapsed
                if digits and digits[-1] == "":
                    continue
                digits.append("")
        joined = "".join(d for d in digits if d)
        if joined:
            out_tokens.append(joined)
    return " ".join(out_tokens)


def normalize_postal(value: str | None) -> str | None:
    if not value:
        return None
    digits = re.sub(r"\D", "", value)[:9]
    return digits or None


# Constant mask width — MUST NOT leak the true token length. Revealing first-initial +
# exact length turns anonymous search into a per-person CONFIRMATION oracle (a third party
# who already knows a name+city can confirm that person has property, and the amount band).
# A fixed-width mask lets a rightful owner recognize their own record without letting a
# stranger confirm it about someone else. (Security audit finding 1.1.)
_MASK_WIDTH = 3


def mask_name(raw_name: str | None) -> str:
    """Public-search masking: first char of each token + a CONSTANT-width mask.

    'CATHERINE ONEILL' -> 'C••• O•••'  (length is NOT revealed).
    Per-jurisdiction policy may mask even more coarsely via jurisdiction_policy.config_json.
    """
    norm = normalize_text(raw_name)
    if not norm:
        return ""
    return " ".join(tok[0] + ("•" * _MASK_WIDTH) for tok in norm.split())


# Coarse public amount bands — never expose the exact figure through anonymous search.
_BANDS = [
    (Decimal("50"), "Under $50"),
    (Decimal("100"), "$50–$100"),
    (Decimal("500"), "$100–$500"),
    (Decimal("1000"), "$500–$1,000"),
    (Decimal("5000"), "$1,000–$5,000"),
]


def amount_band(amount: Decimal | None) -> str | None:
    if amount is None:
        return None
    for ceiling, label in _BANDS:
        if amount < ceiling:
            return label
    return "$5,000+"