receipter-pro / backend /normalize.py
M8ion deploy
Deploy backend
45dc2a1
Raw History Blame Contribute Delete
7.92 kB
"""
Practical, honest normalization β€” not a textbook-complete 5NF engine (see the plan's
"feasibility note": BCNF/4NF/5NF require detecting multi-valued/join dependencies, which has
no safe general algorithm for arbitrary real-world data). What this module actually does:
- 1NF: handled at classification time (backend/databases.py splits delimiter-separated values
only in columns classified as "tag" β€” the one column type where a multi-value cell is
expected by design, not a guess).
- 2NF/3NF: real, mechanical functional-dependency discovery (a determinant column whose every
distinct value maps to exactly one value of another column, and which isn't itself a
candidate key) β€” automatically extracted into a natural-keyed lookup table.
- Beyond 3NF: only ever *suggested* (a plain-language reason attached), never auto-applied β€”
a composite pair of columns that together determine a third is flagged, not restructured.
"""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass
class NormalizedTable:
name: str
columns: list[str]
rows: list[dict]
@dataclass
class NormalizationResult:
root: NormalizedTable
lookups: list[NormalizedTable] = field(default_factory=list)
suggestions: list[str] = field(default_factory=list)
def _distinct_count(rows: list[dict], col: str) -> int:
return len({row.get(col) for row in rows})
def discover_functional_dependencies(
rows: list[dict], headers: list[str], exclude_determinants: set[str] = frozenset(),
) -> dict[str, set[str]]:
"""{determinant: {columns it functionally determines}}. A column only qualifies as a
determinant if it has fewer distinct values than there are rows (otherwise it's a
candidate key, not a useful grouping column), more than one distinct value (otherwise
it's constant and splitting on it is meaningless), and isn't in `exclude_determinants` β€”
a per-row identity column (an email address) can look like a "determinant" purely because
duplicate rows collapse its distinct count, which isn't a real grouping dimension; it's
still fine for such a column to be a *dependent* of something else."""
n = len(rows)
deps: dict[str, set[str]] = {}
if n < 2:
return deps
for a in headers:
if a in exclude_determinants:
continue
distinct_a = _distinct_count(rows, a)
if distinct_a >= n or distinct_a <= 1:
continue
for b in headers:
if a == b:
continue
mapping: dict[object, object] = {}
consistent = True
for row in rows:
va, vb = row.get(a), row.get(b)
if va in mapping and mapping[va] != vb:
consistent = False
break
mapping[va] = vb
if consistent:
deps.setdefault(a, set()).add(b)
return deps
def normalize(
rows: list[dict], headers: list[str], root_name: str = "root", exclude_determinants: set[str] = frozenset(),
auto_split: bool = True,
) -> NormalizationResult:
"""Splits out one lookup table per determinant column (first-match-wins: once a column's
dependents are extracted, it isn't reconsidered as a dependent of a later determinant, to
avoid conflicting/overlapping splits), then scans what's left for a composite-determinant
signal worth flagging but not acting on. `exclude_determinants` β€” see
discover_functional_dependencies β€” keeps identity-like columns (email) from being treated
as a grouping dimension.
auto_split=False keeps every column in the one table and reports nothing. Uploads use that:
"A determines B" found in a few hundred rows is usually coincidence (names are nearly unique),
and moving real columns out of the sheet because of it hid people's data from them."""
if auto_split is False:
return NormalizationResult(
root=NormalizedTable(name=root_name, columns=list(headers), rows=[{c: r.get(c) for c in headers} for r in rows]),
)
deps = discover_functional_dependencies(rows, headers, exclude_determinants)
extracted_as_dependent: set[str] = set()
used_as_determinant: set[str] = set()
lookups: list[NormalizedTable] = []
for determinant, dependents in deps.items():
# A column already folded into another table as a dependent can't turn around and
# become a determinant itself β€” and a two-way dependency (A<->B, common whenever both
# sides happen to have the same distinct-value count, e.g. company <-> company_city)
# would otherwise get extracted TWICE, once from each direction, stripping both
# columns out of root instead of just one.
if determinant in extracted_as_dependent:
continue
dependents = {
d for d in dependents
if d not in extracted_as_dependent and d not in used_as_determinant and d != determinant
}
if not dependents:
continue
lookup_cols = [determinant, *sorted(dependents)]
seen_keys: set[object] = set()
lookup_rows = []
for row in rows:
key = row.get(determinant)
if key in seen_keys:
continue
seen_keys.add(key)
lookup_rows.append({c: row.get(c) for c in lookup_cols})
lookups.append(NormalizedTable(name=f"{determinant}_lookup", columns=lookup_cols, rows=lookup_rows))
used_as_determinant.add(determinant)
extracted_as_dependent.update(dependents)
root_columns = [h for h in headers if h not in extracted_as_dependent]
root_rows = [{c: row.get(c) for c in root_columns} for row in rows]
suggestions = _composite_dependency_suggestions(rows, root_columns, exclude_determinants)
return NormalizationResult(
root=NormalizedTable(name=root_name, columns=root_columns, rows=root_rows),
lookups=lookups,
suggestions=suggestions,
)
def _composite_dependency_suggestions(
rows: list[dict], headers: list[str], exclude_determinants: set[str] = frozenset(),
) -> list[str]:
"""A bounded (O(columns^2)) scan for pairs (a, b) that together determine a third column
c, where neither a nor b alone does β€” a BCNF/4NF-flavored signal. Reported as plain-
language text for a human to accept, never auto-applied."""
n = len(rows)
if n < 3:
return []
single_deps = discover_functional_dependencies(rows, headers, exclude_determinants)
suggestions: list[str] = []
candidate_headers = [h for h in headers if h not in exclude_determinants]
for i, a in enumerate(candidate_headers):
for b in candidate_headers[i + 1 :]:
pair_values = {(row.get(a), row.get(b)) for row in rows}
if len(pair_values) >= n:
continue # (a, b) together are already unique per row β€” not a useful grouping
for c in headers:
if c in (a, b):
continue
if c in single_deps.get(a, set()) or c in single_deps.get(b, set()):
continue # already explained by a single-column dependency
mapping: dict[tuple, object] = {}
consistent = True
for row in rows:
key = (row.get(a), row.get(b))
vc = row.get(c)
if key in mapping and mapping[key] != vc:
consistent = False
break
mapping[key] = vc
if consistent and len(mapping) < n:
suggestions.append(
f"Columns '{a}' and '{b}' together always determine '{c}' β€” "
f"consider extracting a lookup table (not applied automatically)."
)
return suggestions