| """Detector for format/pattern violations in structured columns.
|
|
|
| Many real-world errors are *format* errors: a date in ``DD/MM/YYYY`` inside a
|
| column of ISO ``YYYY-MM-DD`` dates, a zip code with a missing leading zero, a
|
| mis-punctuated phone number. No single hand-written rule catches these across
|
| datasets, so this detector learns each column's dominant value *shape* and
|
| flags the minority shapes.
|
|
|
| Precision guard (the reason this is safe to add broadly): the detector only
|
| considers *structured* columns - those whose dominant shape contains a digit or
|
| an ``@`` (dates, codes, zips, phones, emails). Free-text/prose columns (names,
|
| addresses, descriptions) have no single dominant shape and are never flagged,
|
| which is where naive format detectors generate false positives.
|
|
|
| The detector is pure: no LLM calls, no I/O, no side effects.
|
| """
|
|
|
| from __future__ import annotations
|
|
|
| from collections import Counter
|
|
|
| from dataforge.detectors.base import Issue, Schema, Severity
|
| from dataforge.table import TableLike, column_names, column_values
|
|
|
|
|
| _MIN_VALUES = 8
|
|
|
| _DOMINANCE_THRESHOLD = 0.85
|
|
|
| _MAX_DISTINCT_SHAPES = 8
|
|
|
|
|
| def value_shape(value: str) -> str:
|
| """Return the length-aware structural skeleton of a value.
|
|
|
| Each digit becomes ``9`` and each letter becomes ``A`` (length-preserving);
|
| other characters (separators, punctuation) are kept literally. This captures
|
| both separator format and field width, so fixed-width codes and dates align
|
| while free text fragments into many distinct shapes (and is skipped):
|
|
|
| "2024-01-13" -> "9999-99-99"
|
| "13/01/2024" -> "99/99/9999"
|
| "02134" -> "99999"
|
| "2134" -> "9999"
|
| "john@x.com" -> "AAAA@A.AAA"
|
| """
|
| return "".join("9" if ch.isdigit() else "A" if ch.isalpha() else ch for ch in value)
|
|
|
|
|
| def _is_structured_shape(shape: str) -> bool:
|
| """Return whether a shape is structured enough to flag minorities against.
|
|
|
| Structured = contains a digit run or an email ``@``. Pure-word shapes like
|
| ``"A A"`` (names) are free text and are deliberately excluded.
|
| """
|
| return "9" in shape or "@" in shape
|
|
|
|
|
| class FormatViolationDetector:
|
| """Flags values whose structural shape conflicts with the column's dominant shape.
|
|
|
| Example:
|
| >>> import pandas as pd
|
| >>> detector = FormatViolationDetector()
|
| >>> dates = ["2024-01-%02d" % d for d in range(1, 20)] + ["13/01/2024"]
|
| >>> df = pd.DataFrame({"d": dates})
|
| >>> issues = detector.detect(df)
|
| >>> issues[0].actual
|
| '13/01/2024'
|
| """
|
|
|
| def detect(self, df: TableLike, schema: Schema | None = None) -> list[Issue]:
|
| """Detect format-violation issues across structured columns."""
|
| issues: list[Issue] = []
|
| for col_name in column_names(df):
|
| issues.extend(self._check_column(df, str(col_name)))
|
| return issues
|
|
|
| def _check_column(self, df: TableLike, col_name: str) -> list[Issue]:
|
| """Flag minority-shape values in one column, with precision guards."""
|
| entries: list[tuple[int, str, str]] = []
|
| for row_idx, raw in enumerate(column_values(df, col_name)):
|
| if raw is None:
|
| continue
|
| value = str(raw).strip()
|
| if not value:
|
| continue
|
| entries.append((row_idx, value, value_shape(value)))
|
|
|
| if len(entries) < _MIN_VALUES:
|
| return []
|
|
|
| shape_counts = Counter(shape for _, _, shape in entries)
|
| if len(shape_counts) > _MAX_DISTINCT_SHAPES:
|
| return []
|
|
|
| dominant_shape, dominant_count = shape_counts.most_common(1)[0]
|
| total = len(entries)
|
| dominance = dominant_count / total
|
| if dominance < _DOMINANCE_THRESHOLD:
|
| return []
|
| if not _is_structured_shape(dominant_shape):
|
| return []
|
|
|
| confidence = round(min(0.95, 0.5 + dominance / 2.0), 2)
|
| issues: list[Issue] = []
|
| for row_idx, value, shape in entries:
|
| if shape == dominant_shape:
|
| continue
|
| issues.append(
|
| Issue(
|
| row=row_idx,
|
| column=col_name,
|
| issue_type="format_violation",
|
| severity=Severity.REVIEW,
|
| confidence=confidence,
|
| expected=dominant_shape,
|
| actual=value,
|
| reason=(
|
| f"Value '{value}' has shape '{shape}' but column '{col_name}' is "
|
| f"dominated by shape '{dominant_shape}' ({dominance:.0%})."
|
| ),
|
| )
|
| )
|
| return issues
|
|
|