Skip to content

Commit 3a34f9d

Browse files
fix(contracts): profile awkward cells, compare storage-independently, drop phantom drift (#446)
Four defects found by fuzzing, all on entry points documented as read-only profiling of any pandas frame: * list and dict cells crashed profiling, because nunique/unique hash every value. They are ordinary JSON-derived data, so such cells are counted by their text form instead. * non-UTF-8 bytes crashed the categorical profile on pandas 2, whose string cast decodes strictly, and lone surrogates crashed the keyed-label HMAC. Bytes now decode with backslash escapes, and the HMAC encodes with "surrogatepass". Valid UTF-8 strings keep their previous label, so existing baselines still match. * allowed_values raised a raw pyarrow error on pyarrow-backed columns, while the same rule reported a violation on numpy or masked storage. The membership test now falls back to object comparison, so the finding no longer depends on how the column is stored. * a frame could fail PSI drift against a baseline built from that same frame. A quantile interpolated between two observed values sits in a gap where the baseline CDF is flat, so its nominal probability overstates the share of rows at or below it, and the bin between two such knots claimed mass no data could fill. Stored probabilities are now read as the baseline's own empirical CDF, (floor(p*(n-1)) + 1)/n. Closes #434 Closes #435 Closes #439 Closes #441
1 parent 915dd7b commit 3a34f9d

2 files changed

Lines changed: 198 additions & 7 deletions

File tree

‎src/freshdata/enterprise/contracts.py‎

Lines changed: 97 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -157,9 +157,61 @@ def _resolve_label_key(label_key: str | bytes | None) -> bytes | None:
157157
return key
158158

159159

160+
def _cell_text(value: Any) -> str:
161+
"""Text for one cell; undecodable bytes become ``\\xNN`` escapes.
162+
163+
pandas 2 casts bytes to ``string`` by strict UTF-8 decoding, so a single
164+
invalid byte raised ``UnicodeDecodeError`` while profiling (#435). Decoding
165+
here is deterministic and works the same on every pandas version.
166+
"""
167+
if isinstance(value, (bytes, bytearray)):
168+
return bytes(value).decode("utf-8", errors="backslashreplace")
169+
return str(value)
170+
171+
172+
def _text_series(series: pd.Series) -> pd.Series:
173+
"""``series.astype("string")`` that cannot fail on bytes cells (#435)."""
174+
try:
175+
return series.astype("string")
176+
except (UnicodeDecodeError, TypeError, ValueError):
177+
return pd.Series(
178+
[_cell_text(v) for v in series], index=series.index, name=series.name, dtype="string"
179+
)
180+
181+
182+
def _hashable_series(series: pd.Series) -> pd.Series:
183+
"""*series* with unhashable cells replaced by their text form (#434).
184+
185+
List and dict cells are ordinary JSON-derived data, but they cannot go
186+
through ``nunique``/``unique``/``value_counts``, which hash every value.
187+
"""
188+
return pd.Series(
189+
[_cell_text(v) if isinstance(v, (list, dict, set, bytearray)) else v for v in series],
190+
index=series.index,
191+
name=series.name,
192+
dtype=object,
193+
)
194+
195+
196+
def _unhashable_safe(series: pd.Series) -> pd.Series:
197+
"""*series* if its cells can be hashed, else :func:`_hashable_series`."""
198+
if series.dtype != object:
199+
return series
200+
try:
201+
set(series)
202+
except TypeError:
203+
return _hashable_series(series)
204+
return series
205+
206+
160207
def _keyed_label(key: bytes, value: str) -> str:
161208
"""Pseudonymous category label: ``k:`` + HMAC-SHA256 (128 bits, hex)."""
162-
digest = hmac.new(key, _LABEL_DOMAIN + value.encode("utf-8"), hashlib.sha256)
209+
# "surrogatepass" keeps lone surrogates (from surrogateescape file reads)
210+
# encodable; every valid UTF-8 string encodes exactly as before, so
211+
# existing baselines keep matching (#435).
212+
digest = hmac.new(
213+
key, _LABEL_DOMAIN + value.encode("utf-8", "surrogatepass"), hashlib.sha256
214+
)
163215
return "k:" + digest.hexdigest()[:32]
164216

165217

@@ -849,7 +901,7 @@ def _profile_column(
849901
dtype = str(series.dtype)
850902
n_missing = int(series.isna().sum())
851903
missing_ratio = (n_missing / n_rows) if n_rows else 0.0
852-
non_null = series.dropna()
904+
non_null = _unhashable_safe(series.dropna())
853905
n_unique = int(non_null.nunique())
854906

855907
cb = ColumnBaseline(
@@ -864,7 +916,7 @@ def _profile_column(
864916

865917
if include_samples and len(non_null):
866918
uniques = pd.unique(non_null)[:_MAX_SAMPLE_VALUES]
867-
cb.sample_values = tuple(str(v) for v in uniques)
919+
cb.sample_values = tuple(_cell_text(v) for v in uniques)
868920

869921
if family in ("int", "float") and len(non_null):
870922
# float: Arrow decimals come back as Decimal objects, which can't take std().
@@ -883,7 +935,7 @@ def _profile_column(
883935
cb.min_timestamp = ts.min().isoformat()
884936
cb.max_timestamp = ts.max().isoformat()
885937
elif len(non_null): # categorical / string / bool / other
886-
counts = non_null.astype("string").value_counts()
938+
counts = _text_series(non_null).value_counts()
887939
top = counts.head(_MAX_TOP_CATEGORIES)
888940
total = int(counts.sum())
889941
# Category labels can themselves be PII. Unless the caller opted into
@@ -1058,6 +1110,31 @@ def _cdf_between_knots(
10581110
return out
10591111

10601112

1113+
def _baseline_non_null(cb: ColumnBaseline) -> int:
1114+
"""How many non-null rows the baseline was built from."""
1115+
n = cb.n_rows * (1.0 - (cb.missing_ratio or 0.0))
1116+
return int(round(n)) if n >= 1 else 0
1117+
1118+
1119+
def _empirical_cut(probs: np.ndarray, cb: ColumnBaseline) -> np.ndarray:
1120+
"""Stored quantile probabilities as the baseline's own CDF at those values.
1121+
1122+
A quantile interpolated between two observed values sits in a gap where the
1123+
baseline CDF is flat, so its nominal probability overstates the share of
1124+
rows at or below it, and bins between two such knots claim mass that no
1125+
data can fill — a frame then drifted against its own baseline (#441). With
1126+
*n* baseline rows, numpy's linear quantile for probability *p* lies between
1127+
order statistics *k* and *k+1* with ``k = floor(p * (n - 1))``, so the share
1128+
at or below it is ``(k + 1) / n``. Ties on a knot only raise that share, and
1129+
the caller already clips the current share into the observed range.
1130+
"""
1131+
n = _baseline_non_null(cb)
1132+
if n < 2:
1133+
return probs
1134+
snapped = (np.floor(probs * (n - 1) + 1e-9) + 1.0) / n
1135+
return np.maximum.accumulate(np.clip(snapped, 0.0, 1.0))
1136+
1137+
10611138
def _psi_numeric(cb: ColumnBaseline, current: pd.Series) -> float | None:
10621139
"""PSI over numeric bins defined by the baseline quantile edges."""
10631140
pts = cb.cdf_points()
@@ -1076,7 +1153,7 @@ def _psi_numeric(cb: ColumnBaseline, current: pd.Series) -> float | None:
10761153
# across it the same way, so a point mass on a quantile isn't counted
10771154
# wholly in the upper bin.
10781155
inner = knots[1:-1]
1079-
cut = p_lo[1:-1]
1156+
cut = _empirical_cut(p_lo[1:-1], cb)
10801157
cur = np.sort(vals)
10811158
below = np.searchsorted(cur, inner, side="left") / n
10821159
at = np.searchsorted(cur, inner, side="right") / n
@@ -1919,12 +1996,26 @@ def _contract_lengths(
19191996
return bool(ok)
19201997

19211998

1999+
def _isin(series: pd.Series, values: list[Any]) -> pd.Series:
2000+
"""``series.isin(values)`` that works on pyarrow-backed columns (#439).
2001+
2002+
A pyarrow-backed column raises ``ArrowTypeError``/``ArrowInvalid`` when the
2003+
values have another type, so the same rule reported a violation on an
2004+
``Int64`` column and a raw pyarrow error on ``int64[pyarrow]``. Comparing as
2005+
Python objects gives the storage-independent result.
2006+
"""
2007+
try:
2008+
return series.isin(values)
2009+
except Exception: # noqa: BLE001 - pyarrow raises its own error types
2010+
return series.astype(object).isin(values)
2011+
2012+
19222013
def _contract_values(findings: list[DriftFinding], cc: ColumnContract, series: pd.Series) -> bool:
19232014
ok = True
19242015
non_null = series.dropna()
19252016
n_total = len(non_null)
19262017
if cc.allowed_values:
1927-
bad_mask = ~non_null.isin(list(cc.allowed_values))
2018+
bad_mask = ~_isin(non_null, list(cc.allowed_values))
19282019
n_bad = int(bad_mask.sum())
19292020
if n_bad:
19302021
offenders = sorted({str(v) for v in non_null[bad_mask].unique()})

‎tests/test_contracts_input_robustness.py‎

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,16 @@
88

99
from __future__ import annotations
1010

11+
import hashlib
12+
import hmac
1113
import warnings
1214

1315
import numpy as np
1416
import pandas as pd
1517
import pytest
1618

1719
import freshdata as fd
18-
from freshdata.enterprise.contracts import _resolve_label
20+
from freshdata.enterprise.contracts import _LABEL_DOMAIN, _keyed_label, _resolve_label
1921

2022

2123
def _contract(*columns: fd.ColumnContract, **kwargs: object) -> fd.DataContract:
@@ -295,3 +297,101 @@ def test_validate_suite_rejects_duplicate_labels(duplicated):
295297
suite = fd.ValidationSuite(name="s", rules=[fd.ColumnRule("id", min_value=0)])
296298
with pytest.raises(ValueError, match="duplicated label"):
297299
fd.validate(duplicated, suite=suite)
300+
301+
302+
# ── #434: list / dict cells are data, not hash keys ─────────────────────────────
303+
304+
305+
@pytest.mark.parametrize("cell", [[1], {"k": 1}, {1, 2}, bytearray(b"ab")])
306+
def test_profiling_accepts_unhashable_cells(cell):
307+
df = pd.DataFrame({"payload": [cell, "x", None]})
308+
baseline = fd.build_baseline(df, name="b")
309+
assert baseline.columns["payload"].cardinality == 2
310+
311+
suite = fd.ValidationSuite(name="s", rules=[fd.ColumnRule("payload", nullable=True)])
312+
assert fd.validate(df, suite=suite) is not None
313+
assert fd.enforce_contract(df, _contract(fd.ColumnContract("payload"))) is not None
314+
315+
316+
def test_unhashable_cells_keep_their_samples():
317+
df = pd.DataFrame({"payload": [[1], [1], {"k": 2}]})
318+
baseline = fd.build_baseline(df, name="b", include_samples=True)
319+
assert set(baseline.columns["payload"].sample_values) == {"[1]", "{'k': 2}"}
320+
321+
322+
# ── #435: bytes and surrogate strings profile without raising ───────────────────
323+
324+
325+
def test_baseline_accepts_non_utf8_bytes():
326+
df = pd.DataFrame({"blob": [b"\xff", "x", None, b"ok"]})
327+
baseline = fd.build_baseline(df, name="b", include_samples=True)
328+
values = baseline.columns["blob"].sample_values
329+
assert any("\\xff" in v for v in values)
330+
assert "ok" in values
331+
332+
333+
def test_baseline_accepts_surrogate_strings_with_a_label_key():
334+
df = pd.DataFrame({"name": ["x\udcff", "y", None]})
335+
baseline = fd.build_baseline(df, name="b", label_key="k")
336+
assert baseline.columns["name"].top_values
337+
assert all(v.startswith("k:") for v in baseline.columns["name"].top_values)
338+
339+
340+
def test_keyed_labels_are_unchanged_for_valid_utf8():
341+
# Existing baselines must keep matching: the surrogate fix may not move
342+
# the HMAC of any string that already encoded (#435).
343+
for value in ["alice", "Ünicode ✓", "", '"quoted"', "日本語"]:
344+
expected = hmac.new(
345+
b"secret", _LABEL_DOMAIN + value.encode("utf-8"), hashlib.sha256
346+
).hexdigest()[:32]
347+
assert _keyed_label(b"secret", value) == "k:" + expected
348+
349+
350+
# ── #439: allowed_values must not depend on column storage ──────────────────────
351+
352+
353+
@pytest.mark.parametrize("dtype", ["int64", "Int64", "int64[pyarrow]", "double[pyarrow]"])
354+
def test_allowed_values_reports_violations_on_any_storage(dtype):
355+
pytest.importorskip("pyarrow")
356+
df = pd.DataFrame({"code": pd.array([1, 2, 3], dtype=dtype)})
357+
contract = _contract(fd.ColumnContract("code", allowed_values=("a", "b")))
358+
report = fd.enforce_contract(df, contract)
359+
assert "contract.allowed_values" in _check_ids(report)
360+
361+
362+
def test_allowed_values_passes_on_pyarrow_backed_matches():
363+
pytest.importorskip("pyarrow")
364+
df = pd.DataFrame({"code": pd.array([1, 2], dtype="int64[pyarrow]")})
365+
contract = _contract(fd.ColumnContract("code", allowed_values=(1, 2)))
366+
assert "contract.allowed_values" not in _check_ids(fd.enforce_contract(df, contract))
367+
368+
369+
# ── #441: a frame never drifts against its own baseline ─────────────────────────
370+
371+
372+
@pytest.mark.parametrize("kind", ["wide_int_with_na", "float_with_nan", "zero_inflated",
373+
"constant", "few_rows", "all_null", "heavy_ties"])
374+
def test_frame_does_not_drift_against_its_own_baseline(kind):
375+
rng = np.random.default_rng(20260916)
376+
columns = {
377+
"wide_int_with_na": pd.array(
378+
[-(2**63) + 200, -(2**63) + 1, None, 2, -2, 0, 3, None, 1, -1] * 3, dtype="Int64"
379+
),
380+
"float_with_nan": pd.array([1e18, -1e18, np.nan, 0.5, 2.5] * 6, dtype="float64"),
381+
"zero_inflated": pd.array([0.0] * 25 + [7.5, 9.0, 0.0, 12.0, 0.0], dtype="float64"),
382+
"constant": pd.array([4.0] * 30, dtype="float64"),
383+
"few_rows": pd.array([1, 5, None], dtype="Int64"),
384+
"all_null": pd.array([None] * 12, dtype="Int64"),
385+
"heavy_ties": pd.array(list(rng.integers(0, 3, 60)), dtype="Int64"),
386+
}
387+
df = pd.DataFrame({"f": columns[kind]})
388+
report = fd.compare_to_baseline(df, fd.build_baseline(df, name="b"))
389+
assert report.passed, [f.message for f in report.errors]
390+
391+
392+
def test_real_drift_is_still_reported():
393+
rng = np.random.default_rng(7)
394+
baseline = fd.build_baseline(pd.DataFrame({"f": rng.normal(0, 1, 400)}), name="b")
395+
report = fd.compare_to_baseline(pd.DataFrame({"f": rng.normal(4, 1, 400)}), baseline)
396+
assert not report.passed
397+
assert "drift.psi" in _check_ids(report)

0 commit comments

Comments
 (0)