Skip to content

Commit e5bac8a

Browse files
Merge pull request #452 from FreshCode-Org/fix/guard-explain-edge
fix(guard,explain): survive duplicate index labels and unhashable cells
2 parents 3a34f9d + c1441d6 commit e5bac8a

4 files changed

Lines changed: 114 additions & 1 deletion

File tree

‎src/freshdata/explain.py‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,19 @@ def _require_distinct_label_names(df: pd.DataFrame, func: str) -> None:
3939
)
4040

4141

42+
def _nunique(series: pd.Series) -> int:
43+
"""Distinct non-null values, counting unhashable cells by their text form.
44+
45+
``nunique`` hashes every value, so a list or dict cell raised
46+
``TypeError`` although ``fd.clean`` and ``fd.profile`` accept the same
47+
frame (#450).
48+
"""
49+
try:
50+
return int(series.nunique(dropna=True))
51+
except TypeError:
52+
return int(series.dropna().map(repr).nunique())
53+
54+
4255
def _column_stats(df: pd.DataFrame) -> dict[str, dict[str, Any]]:
4356
stats: dict[str, dict[str, Any]] = {}
4457
for col in df.columns:
@@ -47,7 +60,7 @@ def _column_stats(df: pd.DataFrame) -> dict[str, dict[str, Any]]:
4760
"dtype": str(s.dtype),
4861
"null_count": int(s.isna().sum()),
4962
"null_pct": round(float(s.isna().mean()), 4),
50-
"nunique": int(s.nunique(dropna=True)),
63+
"nunique": _nunique(s),
5164
}
5265
if is_numeric_dtype(s):
5366
nonnull = s.dropna()

‎src/freshdata/guard.py‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from __future__ import annotations
2828

2929
from collections.abc import Iterable, Mapping
30+
from typing import Any
3031

3132
import pandas as pd
3233

@@ -143,6 +144,46 @@ def snapshot_protected(
143144
return snapshot
144145

145146

147+
def _cell_equal(left: Any, right: Any) -> bool:
148+
"""Value equality that treats two missing values as equal."""
149+
left_na, right_na = _is_missing(left), _is_missing(right)
150+
if left_na or right_na:
151+
return left_na and right_na
152+
try:
153+
return bool(left == right)
154+
except Exception: # noqa: BLE001 - exotic cell types compare however they like
155+
return left is right
156+
157+
158+
def _is_missing(value: Any) -> bool:
159+
try:
160+
return bool(pd.isna(value))
161+
except (TypeError, ValueError): # containers: never missing
162+
return False
163+
164+
165+
def _kept_in_order(before: pd.Series, after: pd.Series) -> str | None:
166+
"""Check surviving values positionally when index labels cannot align them.
167+
168+
With repeated labels ``before.loc[after.index]`` multiplies rows instead of
169+
selecting them, so the comparison could never succeed (#449). Row-level
170+
steps only drop rows and never reorder or rewrite a protected column, so
171+
every surviving value must still appear, in order, in the original column.
172+
"""
173+
values = list(before)
174+
position = 0
175+
for n, value in enumerate(after):
176+
while position < len(values) and not _cell_equal(values[position], value):
177+
position += 1
178+
if position == len(values):
179+
return (
180+
"surviving cell values do not match the original column "
181+
f"(first at position {n})"
182+
)
183+
position += 1
184+
return None
185+
186+
146187
def _series_identical(before: pd.Series, after: pd.Series) -> str | None:
147188
"""Return a human explanation of the first difference, or ``None`` if none."""
148189
if str(before.dtype) != str(after.dtype):
@@ -153,6 +194,8 @@ def _series_identical(before: pd.Series, after: pd.Series) -> str | None:
153194
# Row-level steps (dedupe, empty-row drops) legitimately remove rows;
154195
# surviving rows must still hold their original values, aligned by
155196
# index label.
197+
if not before.index.is_unique or not after.index.is_unique:
198+
return _kept_in_order(before, after)
156199
try:
157200
before = before.loc[after.index]
158201
except KeyError:

‎tests/test_explain.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,14 @@ def test_explain_clean_dedupe_reports_zero_cell_changes(messy=None):
110110
rep = fd.explain_clean(df, drop_duplicates=True)
111111
assert rep.rows_after < rep.rows_before
112112
assert all(v == 0 for v in rep.cell_changes.values()), rep.cell_changes
113+
114+
115+
def test_explain_clean_accepts_unhashable_cells():
116+
# Regression (#450): nunique() hashes every value, so a list cell raised
117+
# TypeError although fd.clean and fd.profile accept the same frame.
118+
df = pd.DataFrame({"payload": [[1], [1], {"k": 2}, None], "n": [1, 2, 3, 4]})
119+
report = fd.explain_clean(df)
120+
assert report is not None
121+
stats = report.to_dict()["before_stats"]["payload"]
122+
assert stats["nunique"] == 2 # [1] twice, {"k": 2} once
123+
assert stats["null_count"] == 1

‎tests/test_guard_protected.py‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88
import pytest
99

1010
import freshdata as fd
11+
from freshdata.context.types import ColumnConstraint, ContextPolicy
1112
from freshdata.guard import (
1213
ProtectedColumnError,
14+
_series_identical,
1315
hard_protected_columns,
1416
protected_column_set,
1517
snapshot_protected,
@@ -181,3 +183,47 @@ def test_guard_report_metadata_names_protected_columns():
181183
)
182184
guard_actions = [a for a in report if a.step == "guard"]
183185
assert guard_actions and guard_actions[0].metadata["protected_columns"] == ["rev"]
186+
187+
188+
# ── #449: duplicate index labels ────────────────────────────────────────────────
189+
190+
191+
def _dup_frame() -> pd.DataFrame:
192+
return pd.DataFrame(
193+
{"a": pd.array([None, None, None, 0], dtype="Int64"),
194+
"b": pd.array([None] * 4, dtype="Int64")},
195+
index=[0, 0, 0, 0],
196+
)
197+
198+
199+
def _never_modify_policy(column: str) -> ContextPolicy:
200+
return ContextPolicy(constraints=(ColumnConstraint(
201+
id="c1", column=column, resolved_from=column, resolution_confidence=1.0,
202+
rule="protected", action="never_modify", enforcement="hard"),))
203+
204+
205+
def test_protected_column_survives_a_duplicate_index():
206+
# Regression (#449): the guard aligned the snapshot with before.loc[after.index],
207+
# which multiplies rows when labels repeat, so dropping any row made the
208+
# comparison impossible and raised on an unmodified column.
209+
df = _dup_frame()
210+
expected = fd.clean(df.copy(), verbose=False)["a"].tolist()
211+
out = fd.clean(df.copy(), policy=_never_modify_policy("a"), verbose=False)
212+
assert out["a"].tolist() == expected
213+
214+
215+
def test_guard_still_detects_a_modified_cell_on_a_duplicate_index():
216+
before = pd.Series([1.0, 2.0, 3.0, 4.0], index=[0, 0, 0, 0])
217+
assert _series_identical(before, pd.Series([1.0, 4.0], index=[0, 0])) is None
218+
for broken in (
219+
pd.Series([1.0, 9.0], index=[0, 0]), # value rewritten
220+
pd.Series([4.0, 1.0], index=[0, 0]), # rows reordered
221+
pd.Series([1.0, 2.0, 3.0, 4.0, 5.0], index=[0] * 5), # row gained
222+
):
223+
assert _series_identical(before, broken) is not None
224+
225+
226+
def test_guard_treats_two_missing_values_as_equal_on_a_duplicate_index():
227+
before = pd.Series([1.0, None, 3.0], index=[0, 0, 0])
228+
assert _series_identical(before, pd.Series([None, 3.0], index=[0, 0])) is None
229+
assert _series_identical(before, pd.Series([7.0, 3.0], index=[0, 0])) is not None

0 commit comments

Comments
 (0)