Spaces:
Sleeping
Sleeping
File size: 7,918 Bytes
45dc2a1 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 | """
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
|