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