Skip to content

Commit 7316feb

Browse files
fix: report undetectable duplicates and uniqueness as unknown instead of clean (#414)
* fix(quality): report undetectable duplicates as unknown instead of clean _score_debt counted remaining duplicates with cleaned.duplicated() and, when that raised (TypeError for object columns of lists or dicts, and since #412 NotImplementedError for nested Arrow list/struct/map columns), set dup_remaining = 0. "Cannot check" was therefore scored as "no duplicates": the dimension read 0.0 with "0 duplicate row(s) detected", counted as clean evidence toward the gate, and was written to the ledger as a real 0.0, so a later measured run could be compared against a score that was never measured. DebtItem gains assessed (default True). When detection is impossible and nothing was removed, the duplicates dimension is unassessed: it is never over threshold or worsening, adds nothing to total_score, never drives the gate status, is skipped when writing the ledger (escalation compares against the last run that measured it), serialises with score and over_threshold as None, and is listed in QualityDebtGate.unassessed and in a "? duplicates: not assessed" summary line. The detail names the columns that block detection. If rows were already removed through a hashable duplicate_subset, that count stays a known lower bound. Frames where duplicates can be detected take the same code path and produce identical scores, dicts, frames and summaries. * fix(enterprise): report undetectable uniqueness as unknown in trust scores compute_trust_score computed uniqueness from frame.duplicated() and, when that raised (TypeError for object columns of lists or dicts, NotImplementedError for nested Arrow list/struct/map columns, caught since #413), set uniqueness = 100.0. "Cannot check" was scored as "no duplicates": a perfect dimension blended into overall at its full weight, lifting the trust score of frames whose duplicates were never checked. It also disagreed with the quality-debt duplicates dimension, which now reports the same situation as not assessed. When duplicate rows cannot be checked, uniqueness is now NaN (TrustScore.uniqueness_assessed is False) and overall blends completeness, validity and consistency with their weights renormalised to sum to 1 (equal weights if all of the weight was on uniqueness). to_dict() gives uniqueness None, str() and both Markdown tables show n/a, and each column that blocks the check carries an "unhashable values: duplicate rows not checked" issue. Frames where duplicates can be detected keep the original four-way expression and produce identical scores, dicts and renderings. * fix(render): show unknown trust uniqueness as n/a in the report summary TrustScore.uniqueness is NaN when duplicate rows could not be checked. The report summary formatted it with :.0f and printed 'uniqueness nan'; it now prints 'uniqueness n/a', matching TrustScore's own text and Markdown output. * fix(quality): compare debt against each dimension's last recorded score Unassessed dimensions are no longer written to the ledger, but _previous_scores read only the latest run's items. A run after an unassessed one therefore lost its previous score and could not be marked worsening. Read each dimension's score from the last run that recorded it; when every run records every dimension the result is unchanged.
1 parent 4af1cb8 commit 7316feb

9 files changed

Lines changed: 450 additions & 50 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,21 @@ adheres to [Semantic Versioning](https://semver.org/).
243243
no column, instead of masking nothing and exiting 0 (#251).
244244

245245
### Fixed
246+
- `fd.evaluate_quality_debt` reports the `duplicates` dimension as not assessed
247+
when duplicate rows cannot be checked (object columns of lists or dicts,
248+
nested Arrow list/struct/map columns), instead of scoring it a clean 0.0 with
249+
"0 duplicate row(s) detected". The item serialises with `score` and
250+
`over_threshold` as `None`, its detail names the unhashable columns, it is
251+
listed in `QualityDebtGate.unassessed`, and it never counts toward the total,
252+
the gate status or the ledger history. Frames where duplicates can be
253+
detected score exactly as before.
254+
- `compute_trust_score` reports uniqueness as unknown when duplicate rows cannot
255+
be checked (the same unhashable columns), instead of a perfect 100.
256+
`TrustScore.uniqueness` is `NaN` with `uniqueness_assessed` False, `to_dict()`
257+
gives `None`, `str()` and the Markdown tables show `n/a`, the blocking columns
258+
carry an "unhashable values: duplicate rows not checked" issue, and `overall`
259+
blends the other three dimensions with their weights renormalised. Frames
260+
where duplicates can be detected score exactly as before.
246261
- Trust-gate integrations now validate `on_low_score` policies at configuration
247262
boundaries, rejecting typos instead of silently skipping failure handling (#345).
248263
- `QualityReport.to_markdown()` now escapes every cell in the Actions table, so

‎docs/decision-workflow.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,16 @@ instability, outlier spikes, PII risk, category churn, failed repairs, human-
7575
review backlog), persists the history to SQLite, and **escalates warn→fail when
7676
an issue repeats or worsens** across runs.
7777

78+
The duplicates dimension counts duplicate rows left in the cleaned output (or
79+
the rows removed, if more). When duplicates cannot be checked because a column
80+
holds unhashable values (lists, dicts, or nested Arrow list/struct/map columns),
81+
the dimension is **not assessed** rather than scored clean: `to_dict()` gives
82+
`score` and `over_threshold` as `None`, the detail names the columns,
83+
`gate.unassessed` lists it, and `summary()` prints a `? duplicates: not
84+
assessed` line. An unassessed dimension adds nothing to the total, never
85+
changes the gate status on its own, and is not written to the ledger, so
86+
escalation compares against the last run that measured it.
87+
7888
## Dirty-join assistant
7989

8090
Reviewable fuzzy joins for messy keys — never a silent low-confidence join.

‎docs/feature-overview.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ dependencies stay lazy, so a plain `import freshdata` is unaffected.
3131
| Capability | API |
3232
|---|---|
3333
| Full enterprise pipeline | `clean_enterprise(df, *, enterprise=…)` → `EnterpriseResult` |
34-
| Data Trust Score (0–100) | `compute_trust_score(df)` → completeness / validity / uniqueness / consistency |
34+
| Data Trust Score (0–100) | `compute_trust_score(df)` → completeness / validity / uniqueness / consistency (uniqueness is unknown and left out of the overall when a column holds unhashable values such as lists) |
3535
| Fuzzy value clustering | `merge_clusters(df, cols)` / `cluster_column(df, col)` |
3636
| PII masking | `mask_dataframe(df, rules)` — hash / redact / partial / regex-scrub / drop |
3737
| Semantic validation | `run_semantic_validation(df, configs)` — reference / regex / API checks |

‎src/freshdata/enterprise/metrics.py‎

Lines changed: 71 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@
66
- **validity** — share of *present* cells already in a clean, parseable form
77
(no stray whitespace, no sentinel-as-value, parses to its inferred dtype, not
88
a statistical outlier),
9-
- **uniqueness** — share of rows that are not exact duplicates,
9+
- **uniqueness** — share of rows that are not exact duplicates. When duplicate
10+
rows cannot be checked (a column holds lists, dicts or nested Arrow
11+
list/struct/map values) it is *unknown* (``NaN``, ``None`` in
12+
:meth:`TrustScore.to_dict`) and the overall score is blended from the other
13+
three dimensions with their weights renormalised,
1014
- **consistency** — share of columns free of structural defects that
1115
corruption can introduce (mixed types, duplicate labels). Constant columns
1216
are surfaced as per-column issues instead of lowering this dimension:
@@ -21,7 +25,8 @@
2125
from __future__ import annotations
2226

2327
import json
24-
from dataclasses import dataclass, field
28+
import math
29+
from dataclasses import dataclass, field, replace
2530
from datetime import datetime, timezone
2631
from typing import TYPE_CHECKING, Any
2732

@@ -42,6 +47,9 @@
4247
#: Minimum non-null count before outliers are scored (matches profile.py).
4348
_MIN_FOR_OUTLIERS = 20
4449

50+
#: Per-column issue for values that make duplicate rows impossible to check.
51+
_UNHASHABLE_ISSUE = "unhashable values: duplicate rows not checked"
52+
4553

4654
@dataclass(frozen=True)
4755
class ColumnTrust:
@@ -60,6 +68,9 @@ class TrustScore:
6068
6169
Render with ``print(score)``, export with :meth:`to_dict` /
6270
:meth:`to_markdown`. ``grade`` maps the overall score to an A-F letter.
71+
72+
``uniqueness`` is ``NaN`` when duplicate rows could not be checked (see
73+
:attr:`uniqueness_assessed`); ``overall`` then excludes it.
6374
"""
6475

6576
overall: float
@@ -71,6 +82,14 @@ class TrustScore:
7182
n_cols: int
7283
columns: tuple[ColumnTrust, ...] = ()
7384

85+
@property
86+
def uniqueness_assessed(self) -> bool:
87+
"""False when duplicate rows could not be checked (unhashable values)."""
88+
return not math.isnan(self.uniqueness)
89+
90+
def _uniqueness_text(self, fmt: str) -> str:
91+
return format(self.uniqueness, fmt) if self.uniqueness_assessed else "n/a"
92+
7493
@property
7594
def grade(self) -> str:
7695
"""Letter grade: A (>=90), B (>=80), C (>=70), D (>=60), else F."""
@@ -86,7 +105,7 @@ def to_dict(self) -> dict[str, Any]:
86105
"dimensions": {
87106
"completeness": round(self.completeness, 2),
88107
"validity": round(self.validity, 2),
89-
"uniqueness": round(self.uniqueness, 2),
108+
"uniqueness": round(self.uniqueness, 2) if self.uniqueness_assessed else None,
90109
"consistency": round(self.consistency, 2),
91110
},
92111
"n_rows": self.n_rows,
@@ -106,12 +125,12 @@ def to_dict(self) -> dict[str, Any]:
106125
def to_markdown(self) -> str:
107126
"""Markdown table of the score and its dimensions."""
108127
rows = [
109-
("Completeness", self.completeness),
110-
("Validity", self.validity),
111-
("Uniqueness", self.uniqueness),
112-
("Consistency", self.consistency),
128+
("Completeness", f"{self.completeness:.1f}"),
129+
("Validity", f"{self.validity:.1f}"),
130+
("Uniqueness", self._uniqueness_text(".1f")),
131+
("Consistency", f"{self.consistency:.1f}"),
113132
]
114-
body = [_md_table_row((dim, f"{val:.1f}")) for dim, val in rows]
133+
body = [_md_table_row(row) for row in rows]
115134
lines = [
116135
f"### Data Trust Score: **{self.overall:.1f} / 100** (grade {self.grade})",
117136
"",
@@ -126,7 +145,7 @@ def __str__(self) -> str:
126145
return (
127146
f"Data Trust Score {self.overall:.1f}/100 (grade {self.grade}) — "
128147
f"completeness {self.completeness:.0f}, validity {self.validity:.0f}, "
129-
f"uniqueness {self.uniqueness:.0f}, consistency {self.consistency:.0f}"
148+
f"uniqueness {self._uniqueness_text('.0f')}, consistency {self.consistency:.0f}"
130149
)
131150

132151
def __repr__(self) -> str:
@@ -211,6 +230,21 @@ def _is_constant(s: pd.Series, n_rows: int) -> bool:
211230
return False
212231

213232

233+
def _unhashable_positions(frame: pd.DataFrame) -> list[int]:
234+
"""Positions of columns that make ``DataFrame.duplicated`` impossible.
235+
236+
``duplicated`` factorizes every column, which is what raises; a lone
237+
``Series.duplicated()`` on a list column does not.
238+
"""
239+
positions = []
240+
for i in range(frame.shape[1]):
241+
try:
242+
pd.factorize(frame.iloc[:, i])
243+
except (TypeError, NotImplementedError):
244+
positions.append(i)
245+
return positions
246+
247+
214248
def compute_trust_score(
215249
df: pd.DataFrame,
216250
*,
@@ -219,7 +253,10 @@ def compute_trust_score(
219253
) -> TrustScore:
220254
"""Profile *df* (pandas or polars) and compute its Data Trust Score.
221255
222-
Read-only: the input is never modified.
256+
Read-only: the input is never modified. If duplicate rows cannot be
257+
checked, uniqueness is ``NaN`` and the overall score uses the remaining
258+
dimensions with their weights renormalised (equal weights if all of the
259+
weight was on uniqueness).
223260
"""
224261
frame = to_pandas(df)
225262
cfg = config or CleanConfig()
@@ -274,18 +311,33 @@ def compute_trust_score(
274311
dup_rows = int(frame.duplicated().sum())
275312
uniqueness = 100.0 * (1.0 - dup_rows / n_rows) if n_rows else 100.0
276313
except (TypeError, NotImplementedError): # lists/dicts, nested Arrow: undetectable
277-
uniqueness = 100.0
314+
# "Cannot check" is not "no duplicates": leave uniqueness unknown and
315+
# name the columns that block the check.
316+
uniqueness = math.nan
317+
for i in _unhashable_positions(frame):
318+
col_trust[i] = replace(col_trust[i], issues=(*col_trust[i].issues, _UNHASHABLE_ISSUE))
278319

279320
dup_labels = int(frame.columns.duplicated().sum())
280321
flagged = min(n_cols, inconsistent_cols + dup_labels)
281322
consistency = 100.0 * (1.0 - flagged / n_cols) if n_cols else 100.0
282323

283-
overall = (
284-
w["completeness"] * completeness
285-
+ w["validity"] * validity
286-
+ w["uniqueness"] * uniqueness
287-
+ w["consistency"] * consistency
288-
)
324+
if not math.isnan(uniqueness):
325+
overall = (
326+
w["completeness"] * completeness
327+
+ w["validity"] * validity
328+
+ w["uniqueness"] * uniqueness
329+
+ w["consistency"] * consistency
330+
)
331+
else:
332+
measured = w["completeness"] + w["validity"] + w["consistency"]
333+
if measured > 0:
334+
overall = (
335+
w["completeness"] * completeness
336+
+ w["validity"] * validity
337+
+ w["consistency"] * consistency
338+
) / measured
339+
else: # all weight was on uniqueness: fall back to equal weights
340+
overall = (completeness + validity + consistency) / 3.0
289341
return TrustScore(
290342
overall=overall,
291343
completeness=completeness,
@@ -358,8 +410,8 @@ def to_markdown(self) -> str:
358410
_md_table_row(("Completeness", f"{before.completeness:.1f}",
359411
f"{after.completeness:.1f}")),
360412
_md_table_row(("Validity", f"{before.validity:.1f}", f"{after.validity:.1f}")),
361-
_md_table_row(("Uniqueness", f"{before.uniqueness:.1f}",
362-
f"{after.uniqueness:.1f}")),
413+
_md_table_row(("Uniqueness", before._uniqueness_text(".1f"),
414+
after._uniqueness_text(".1f"))),
363415
_md_table_row(("Consistency", f"{before.consistency:.1f}",
364416
f"{after.consistency:.1f}")),
365417
_md_table_row(("**Overall**", f"**{before.overall:.1f}**",

0 commit comments

Comments
 (0)