diff --git a/.github/workflows/gauntlet.yml b/.github/workflows/gauntlet.yml
new file mode 100644
index 0000000..ad0b2fb
--- /dev/null
+++ b/.github/workflows/gauntlet.yml
@@ -0,0 +1,59 @@
+# Validation Gauntlet: gold-labelled disposition benchmark for the validation,
+# domain and text-cleaning surfaces. Runs the lightweight fixtures on every PR
+# and gates on the absolute thresholds plus no-regression vs the stored
+# baseline (benchmarks/gauntlet/baseline.json). Heavier sizes stay manual.
+name: Validation Gauntlet
+
+on:
+ pull_request:
+ paths-ignore:
+ - "docs/**"
+ - "*.md"
+ workflow_dispatch:
+ inputs:
+ rows:
+ description: "rows per fixture"
+ default: "300"
+ update_baseline:
+ description: "re-pin baseline.json from this run (commit it manually)"
+ type: boolean
+ default: false
+
+permissions:
+ contents: read
+
+jobs:
+ gauntlet:
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ cache: pip
+ - name: Install
+ run: |
+ python -m pip install --upgrade pip
+ pip install -e ".[dev]"
+ - name: Run gauntlet with gates
+ run: |
+ ROWS="${{ github.event.inputs.rows || '300' }}"
+ EXTRA=""
+ if [ "${{ github.event.inputs.update_baseline }}" = "true" ]; then
+ EXTRA="--update-baseline"
+ fi
+ python -m benchmarks.gauntlet run --rows "$ROWS" --check $EXTRA
+ - name: Job summary
+ if: always()
+ run: |
+ if [ -f benchmarks/gauntlet/results/gauntlet.md ]; then
+ cat benchmarks/gauntlet/results/gauntlet.md >> "$GITHUB_STEP_SUMMARY"
+ fi
+ - name: Upload results
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: gauntlet-results
+ path: benchmarks/gauntlet/results/
+ if-no-files-found: warn
diff --git a/.gitignore b/.gitignore
index 06efe6b..1efa0b6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -34,3 +34,4 @@ crates/freshcore/target/
# dev artifact (regenerated by running teacher tasks), not committed content.
training/cache/*
!training/cache/.gitkeep
+benchmarks/gauntlet/results/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 698314b..f5f2886 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,7 +6,63 @@ adheres to [Semantic Versioning](https://semver.org/).
## [Unreleased]
+### Added
+- **Validation Gauntlet** (`benchmarks/gauntlet/`, `docs/validation-gauntlet.md`):
+ a gold-labelled disposition benchmark for the validation, domain and
+ text-cleaning surfaces. Five deterministic fixtures (finance, healthcare,
+ CRM, e-commerce, adversarial text) label every injected defect with the
+ disposition FreshData should choose (preserve / repair / flag / review) and
+ the harness scores detection P/R/F1, repair accuracy, review routing,
+ preservation, corruption, escapes, false positives, audit completeness,
+ determinism, trust monotonicity and runtime/memory. Runs on every PR
+ (`gauntlet.yml`) with absolute gates plus no-regression checks against the
+ stored `baseline.json`.
+- `CleanReport.coerced_cells`: per-cell record (`{column: {row: original}}`)
+ of values that `fix_dtypes` nulled because they did not parse as the
+ column's inferred type — the recovery source for quarantined cells, also
+ included in `report.to_dict()`.
+- Date-field range validation in `fd.validate_fields`: `FieldSpec.min_value`
+ / `max_value` now accept a date string or timestamp for `date`/`datetime`
+ fields, so a future date of birth or an 1875 admission date is flagged as a
+ `domain_mismatch` (gauntlet finding).
+- Case-variant vocabulary suggestions in `fd.validate_fields`: a value that
+ matches an `allowed_values` entry except for case (`ACTIVE` vs `active`) is
+ no longer silently accepted — it gets a warning-severity issue with the
+ canonical form as `suggestion` and action `accept_with_warning` (gauntlet
+ finding).
+
### Fixed
+- **Unparseable values are quarantined, never fabricated** (gauntlet finding,
+ the `'apple'`-in-a-price-column case): when `fix_dtypes` converts a
+ mostly-numeric (or datetime) text column, cells that fail to parse used to
+ become `NaN` and then be silently imputed by the auto engine — turning
+ junk into a fabricated median. They now stay missing, are excluded from
+ auto-imputation, keep their originals in `report.coerced_cells`, and the
+ decision is a `human_review` action in the audit trail. Genuine missing
+ values (true `NaN`, sentinels like `"N/A"`) keep the documented
+ auto-impute behaviour, and an explicit `impute=` request still fills
+ everything.
+- Formatted-number stragglers (`"$1,234.56"`, `"1,200,500.00"`) in a
+ mostly-plain numeric column are now parsed by the existing locale-aware
+ rescue instead of being coerced to missing — the rescue previously only
+ engaged when the plain parse failed the threshold entirely (gauntlet
+ finding).
+- `fd.validate_fields` consensus inference now honours the same
+ contamination boundary as the `fix_dtypes` warning that points users at it
+ (dominant share ≥ 60% with at most a handful of stragglers). Previously
+ the warning fired from a 60% parse share but the consensus gate required
+ 80%, so the exact frame the warning named sailed through
+ `validate_fields` silently (gauntlet finding).
+- Explicitly allowed values are no longer swallowed by null-marker
+ heuristics in `fd.validate_fields`: with
+ `FieldSpec(allowed_values={"US", "DE", "NA"})`, `"NA"` is Namibia, not a
+ missing value (gauntlet finding).
+- `clean_text` / `validate_fields` text normalization no longer rewrites
+ typography in content-bearing fields: for `free_text`, `text` and entity
+ name types, the punctuation→ASCII mapping (curly quotes, em-dashes, prime
+ marks — `12″` became `12"`) is withheld, matching the field-aware safety
+ contract. Untyped columns keep the existing behaviour (gauntlet finding).
+
- `anonymize()` called with no `rules` and no `detection_config` now emits
a `UserWarning` instead of silently returning the data unchanged — a
privacy call that does nothing must say so. Behavior is otherwise
diff --git a/benchmarks/README.md b/benchmarks/README.md
index fadd5a6..c8ae4dd 100644
--- a/benchmarks/README.md
+++ b/benchmarks/README.md
@@ -9,6 +9,12 @@ pyjanitor baselines.
> The harness calls FreshData exactly as a user would. It never modifies library
> internals.
+> Sibling harness: `benchmarks/gauntlet/` (the **Validation Gauntlet**)
+> scores per-cell dispositions — preserve / repair / flag / review — for
+> the validation, domain and text-cleaning surfaces against gold labels,
+> and gates every PR via `.github/workflows/gauntlet.yml`. See
+> `docs/validation-gauntlet.md`.
+
## Layout
```
diff --git a/benchmarks/gauntlet/__init__.py b/benchmarks/gauntlet/__init__.py
new file mode 100644
index 0000000..a845793
--- /dev/null
+++ b/benchmarks/gauntlet/__init__.py
@@ -0,0 +1,28 @@
+"""FreshData Validation Gauntlet.
+
+Gold-labelled adversarial fixtures plus a harness that measures how
+FreshData's validation surfaces (``fd.clean``, ``fd.validate_fields``,
+``fd.clean_text``, domain packs, the semantic layer, PII detection) treat
+each labelled cell: preserve, repair, flag, or route to review.
+
+Unlike CleanBench (which scores whole-frame repair fidelity against a clean
+oracle), the gauntlet scores *dispositions*: every injected defect carries the
+disposition FreshData should choose, and every adversarial trap is a valid
+value that must survive cleaning untouched.
+
+Run ``python -m benchmarks.gauntlet run`` from the repo root.
+"""
+
+from .fixtures import FIXTURES, GauntletFixture, GoldCell, build_fixture
+from .metrics import compute_metrics
+from .runner import run_fixture, run_gauntlet
+
+__all__ = [
+ "FIXTURES",
+ "GauntletFixture",
+ "GoldCell",
+ "build_fixture",
+ "compute_metrics",
+ "run_fixture",
+ "run_gauntlet",
+]
diff --git a/benchmarks/gauntlet/__main__.py b/benchmarks/gauntlet/__main__.py
new file mode 100644
index 0000000..0ee2dd1
--- /dev/null
+++ b/benchmarks/gauntlet/__main__.py
@@ -0,0 +1,67 @@
+"""Validation Gauntlet CLI.
+
+Run from the repo root::
+
+ python -m benchmarks.gauntlet run # run + write results/
+ python -m benchmarks.gauntlet run --check # also gate (CI mode)
+ python -m benchmarks.gauntlet run --update-baseline
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from pathlib import Path
+
+from .fixtures import DEFAULT_ROWS, DEFAULT_SEED, FIXTURES
+from .metrics import compute_metrics
+from .report import check_gates, render_markdown, results_payload, write_json
+from .runner import run_gauntlet
+
+RESULTS_DIR = Path(__file__).parent / "results"
+BASELINE_PATH = Path(__file__).parent / "baseline.json"
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(prog="python -m benchmarks.gauntlet")
+ sub = parser.add_subparsers(dest="command", required=True)
+ run_p = sub.add_parser("run", help="run the gauntlet and write JSON + Markdown")
+ run_p.add_argument("--rows", type=int, default=DEFAULT_ROWS)
+ run_p.add_argument("--seed", type=int, default=DEFAULT_SEED)
+ run_p.add_argument("--fixtures", nargs="*", choices=sorted(FIXTURES))
+ run_p.add_argument("--check", action="store_true",
+ help="exit 1 when a gate fails or the baseline regresses")
+ run_p.add_argument("--update-baseline", action="store_true",
+ help="write this run as the stored baseline")
+ args = parser.parse_args(argv)
+
+ runs = run_gauntlet(n_rows=args.rows, seed=args.seed, fixtures=args.fixtures)
+ metrics = {name: compute_metrics(r) for name, r in runs.items()}
+ payload = results_payload(metrics, n_rows=args.rows, seed=args.seed)
+
+ write_json(payload, RESULTS_DIR / "gauntlet.json")
+ markdown = render_markdown(payload)
+ (RESULTS_DIR / "gauntlet.md").write_text(markdown)
+ print(markdown)
+ print(f"results: {RESULTS_DIR / 'gauntlet.json'}")
+
+ if args.update_baseline:
+ write_json(payload, BASELINE_PATH)
+ print(f"baseline updated: {BASELINE_PATH}")
+
+ if args.check:
+ baseline = (json.loads(BASELINE_PATH.read_text())
+ if BASELINE_PATH.exists() else None)
+ problems = check_gates(payload, baseline)
+ if problems:
+ print("\nGATE FAILURES:", file=sys.stderr)
+ for p in problems:
+ print(f" - {p}", file=sys.stderr)
+ return 1
+ print("all gates passed")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/benchmarks/gauntlet/baseline.json b/benchmarks/gauntlet/baseline.json
new file mode 100644
index 0000000..f22eed5
--- /dev/null
+++ b/benchmarks/gauntlet/baseline.json
@@ -0,0 +1,228 @@
+{
+ "schema_version": 1,
+ "generated_at": "2026-07-13T04:14:15+00:00",
+ "n_rows": 300,
+ "seed": 42,
+ "fixtures": {
+ "crm": {
+ "fixture": "crm",
+ "n_rows": 304,
+ "labelled_cells": 19,
+ "detection": {
+ "precision": 1.0,
+ "recall": 0.9167,
+ "f1": 0.9565,
+ "true_positives": 11,
+ "false_negatives": 1,
+ "false_positives": 0
+ },
+ "repair_accuracy": 1.0,
+ "repair_sources": {
+ "clean": 3,
+ "clean_text_opt_in": 1
+ },
+ "review_routing": 1.0,
+ "preservation_rate": 1.0,
+ "corruption_count": 0,
+ "escape_rate": 0.0833,
+ "false_positive_rate": 0.0,
+ "duplicates": {
+ "expected": 4,
+ "removed": 4,
+ "ok": true
+ },
+ "audit_completeness": 1.0,
+ "deterministic": true,
+ "trust": {
+ "pristine_frame": 100.0,
+ "dirty_frame": 99.688,
+ "monotonic": true
+ },
+ "performance": {
+ "clean_seconds": 0.2098,
+ "validate_seconds": 0.0133,
+ "peak_memory_mb": 0.269
+ },
+ "failures": [
+ {
+ "row": 24,
+ "column": "full_name",
+ "kind": "injection_text",
+ "expect": "flag",
+ "detected": false,
+ "dirty": "\"ROBERT'); DROP TABLE users;--\"",
+ "verdict": "escaped"
+ }
+ ],
+ "detected_only": []
+ },
+ "ecommerce": {
+ "fixture": "ecommerce",
+ "n_rows": 303,
+ "labelled_cells": 15,
+ "detection": {
+ "precision": 1.0,
+ "recall": 1.0,
+ "f1": 1.0,
+ "true_positives": 11,
+ "false_negatives": 0,
+ "false_positives": 0
+ },
+ "repair_accuracy": 1.0,
+ "repair_sources": {
+ "clean": 2,
+ "clean_text_opt_in": 1
+ },
+ "review_routing": 1.0,
+ "preservation_rate": 1.0,
+ "corruption_count": 0,
+ "escape_rate": 0.0,
+ "false_positive_rate": 0.0,
+ "duplicates": {
+ "expected": 3,
+ "removed": 3,
+ "ok": true
+ },
+ "audit_completeness": 1.0,
+ "deterministic": true,
+ "trust": {
+ "pristine_frame": 100.0,
+ "dirty_frame": 94.74,
+ "monotonic": true
+ },
+ "performance": {
+ "clean_seconds": 0.1883,
+ "validate_seconds": 0.0189,
+ "peak_memory_mb": 0.157
+ },
+ "failures": [],
+ "detected_only": []
+ },
+ "finance": {
+ "fixture": "finance",
+ "n_rows": 303,
+ "labelled_cells": 25,
+ "detection": {
+ "precision": 1.0,
+ "recall": 1.0,
+ "f1": 1.0,
+ "true_positives": 21,
+ "false_negatives": 0,
+ "false_positives": 0
+ },
+ "repair_accuracy": 1.0,
+ "repair_sources": {
+ "clean": 10
+ },
+ "review_routing": 1.0,
+ "preservation_rate": 1.0,
+ "corruption_count": 0,
+ "escape_rate": 0.0,
+ "false_positive_rate": 0.0,
+ "duplicates": {
+ "expected": 3,
+ "removed": 3,
+ "ok": true
+ },
+ "audit_completeness": 1.0,
+ "deterministic": true,
+ "trust": {
+ "pristine_frame": 100.0,
+ "dirty_frame": 92.178,
+ "monotonic": true
+ },
+ "performance": {
+ "clean_seconds": 0.223,
+ "validate_seconds": 0.021,
+ "peak_memory_mb": 0.181
+ },
+ "failures": [],
+ "detected_only": []
+ },
+ "healthcare": {
+ "fixture": "healthcare",
+ "n_rows": 303,
+ "labelled_cells": 16,
+ "detection": {
+ "precision": 1.0,
+ "recall": 1.0,
+ "f1": 1.0,
+ "true_positives": 15,
+ "false_negatives": 0,
+ "false_positives": 0
+ },
+ "repair_accuracy": 1.0,
+ "repair_sources": {
+ "clean": 1,
+ "validate_fields": 1
+ },
+ "review_routing": 1.0,
+ "preservation_rate": 1.0,
+ "corruption_count": 0,
+ "escape_rate": 0.0,
+ "false_positive_rate": 0.0,
+ "duplicates": {
+ "expected": 3,
+ "removed": 3,
+ "ok": true
+ },
+ "audit_completeness": 1.0,
+ "deterministic": true,
+ "trust": {
+ "pristine_frame": 100.0,
+ "dirty_frame": 94.74,
+ "monotonic": true
+ },
+ "performance": {
+ "clean_seconds": 0.2001,
+ "validate_seconds": 0.0217,
+ "peak_memory_mb": 0.16
+ },
+ "failures": [],
+ "detected_only": []
+ },
+ "text": {
+ "fixture": "text",
+ "n_rows": 302,
+ "labelled_cells": 16,
+ "detection": {
+ "precision": 1.0,
+ "recall": 1.0,
+ "f1": 1.0,
+ "true_positives": 10,
+ "false_negatives": 0,
+ "false_positives": 0
+ },
+ "repair_accuracy": 1.0,
+ "repair_sources": {
+ "clean": 1,
+ "clean_text_opt_in": 2,
+ "validate_fields": 4
+ },
+ "review_routing": null,
+ "preservation_rate": 1.0,
+ "corruption_count": 0,
+ "escape_rate": 0.0,
+ "false_positive_rate": 0.0,
+ "duplicates": {
+ "expected": 2,
+ "removed": 2,
+ "ok": true
+ },
+ "audit_completeness": 1.0,
+ "deterministic": true,
+ "trust": {
+ "pristine_frame": 100.0,
+ "dirty_frame": 99.801,
+ "monotonic": true
+ },
+ "performance": {
+ "clean_seconds": 0.0689,
+ "validate_seconds": 0.0054,
+ "peak_memory_mb": 0.103
+ },
+ "failures": [],
+ "detected_only": []
+ }
+ }
+}
diff --git a/benchmarks/gauntlet/fixtures.py b/benchmarks/gauntlet/fixtures.py
new file mode 100644
index 0000000..b1c8702
--- /dev/null
+++ b/benchmarks/gauntlet/fixtures.py
@@ -0,0 +1,520 @@
+"""Gold-labelled gauntlet fixtures.
+
+Every fixture is deterministic for a given seed: a base frame of valid
+records, plus injected problem cells whose *expected disposition* is known.
+
+Dispositions (``GoldCell.expect``):
+
+- ``preserve`` — valid (often unusual) data; must survive every cleaning
+ surface byte-identical and must not draw an error-severity issue.
+- ``repair`` — a safe deterministic repair exists; ``repaired`` is the gold
+ value. Silently leaving it is an escape; changing it to anything else is a
+ corruption.
+- ``flag`` — must be *detected* (issue / warning / finding) but never
+ auto-changed by the default pipeline.
+- ``review`` — ambiguous; must be routed to a quarantine / manual-review
+ action, never auto-deleted or auto-accepted.
+
+Whole-row duplicate injections are tracked separately in ``dup_row_count``
+because row removal is a row-level (not cell-level) disposition.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Any, Callable
+
+import numpy as np
+import pandas as pd
+
+from freshdata import FieldSpec
+
+DEFAULT_ROWS = 300
+DEFAULT_SEED = 42
+
+
+@dataclass(frozen=True)
+class GoldCell:
+ row: int
+ column: str
+ kind: str #: defect / trap family, e.g. "text_in_numeric"
+ dirty: Any #: the value placed in the frame
+ expect: str #: preserve | repair | flag | review
+ repaired: Any = None
+ #: the cell may legitimately end up imputed by the audited missing-value
+ #: engine after its repair-to-missing (sentinels, empty strings): both
+ #: "left missing" and "audited fill" count as the gold repair.
+ accept_impute: bool = False
+ pii: str | None = None #: expected PII entity_type, when the cell is PII
+ note: str = ""
+ replaced: Any = None #: the valid value the injection overwrote
+
+
+@dataclass
+class GauntletFixture:
+ name: str
+ df: pd.DataFrame
+ cells: list[GoldCell]
+ schema: dict[str, FieldSpec | str]
+ field_types: dict[str, str] = field(default_factory=dict)
+ domain: str | None = None
+ dup_row_count: int = 0
+ n_rows: int = 0
+
+ def labelled(self, *expects: str) -> list[GoldCell]:
+ return [c for c in self.cells if not expects or c.expect in expects]
+
+ def pristine(self) -> pd.DataFrame:
+ """The base frame before injection: labelled cells restored, dups gone."""
+ base = self.df.iloc[: self.n_rows].copy()
+ for c in self.cells:
+ base.iloc[c.row, base.columns.get_loc(c.column)] = c.replaced
+ return base
+
+
+class _Injector:
+ """Places labelled values on distinct (row, column) slots, deterministically."""
+
+ def __init__(self, df: pd.DataFrame, seed: int) -> None:
+ self.df = df
+ self.cells: list[GoldCell] = []
+ self._order = {
+ col: list(np.random.default_rng(seed + i).permutation(len(df)))
+ for i, col in enumerate(df.columns)
+ }
+
+ def place(self, column: str, dirty: Any, kind: str, expect: str, *,
+ repaired: Any = None, accept_impute: bool = False,
+ pii: str | None = None, note: str = "") -> int:
+ row = self._order[column].pop()
+ loc = self.df.columns.get_loc(column)
+ replaced = self.df.iloc[row, loc]
+ self.df.iloc[row, loc] = dirty
+ self.cells.append(GoldCell(row=row, column=column, kind=kind, dirty=dirty,
+ expect=expect, repaired=repaired,
+ accept_impute=accept_impute, pii=pii, note=note,
+ replaced=replaced))
+ return row
+
+
+def _rng(seed: int) -> np.random.Generator:
+ return np.random.default_rng(seed)
+
+
+# ---------------------------------------------------------------------------
+# finance
+# ---------------------------------------------------------------------------
+
+_TICKERS = ("AAPL", "MSFT", "TSLA", "GOOG", "AMZN", "NVDA", "JPM", "V")
+_COMPANIES = ("Apple", "Microsoft", "Tesla", "Alphabet", "Amazon",
+ "Nvidia", "JPMorgan", "Visa")
+_CURRENCIES = frozenset({"USD", "EUR", "GBP", "JPY"})
+
+
+def _finance(n: int, seed: int) -> GauntletFixture:
+ r = _rng(seed)
+ idx = r.integers(0, len(_TICKERS), n)
+ df = pd.DataFrame({
+ "txn_id": [f"TXN{100000 + i}" for i in range(n)],
+ "company": [_COMPANIES[i] for i in idx],
+ "ticker": [_TICKERS[i] for i in idx],
+ "price": np.round(r.uniform(10, 900, n), 2).astype(object),
+ "revenue": np.round(r.uniform(1e4, 5e6, n), 2).astype(object),
+ "currency": r.choice(sorted(_CURRENCIES), n),
+ "trade_date": pd.to_datetime("2025-01-01")
+ + pd.to_timedelta(r.integers(0, 365, n), unit="D"),
+ "pct_change": np.round(r.uniform(-9, 9, n), 3).astype(object),
+ })
+ df["trade_date"] = df["trade_date"].dt.strftime("%Y-%m-%d")
+ inj = _Injector(df, seed)
+
+ # -- the flagship case: 'apple' across financial columns -------------------
+ inj.place("price", "apple", "text_in_numeric", "flag",
+ note="company name in a price column; never auto-delete")
+ inj.place("revenue", "apple", "text_in_numeric", "flag")
+ inj.place("company", "Apple", "valid_company_name", "preserve")
+ inj.place("ticker", "AAPL", "valid_ticker", "preserve")
+ inj.place("ticker", "apple", "lowercase_ticker", "review",
+ note="structurally invalid ticker; plausible fix exists -> review")
+
+ # numeric problems
+ inj.place("price", -12.5, "impossible_range", "flag", note="negative price")
+ inj.place("price", 9_999_999.0, "extreme_outlier", "flag")
+ inj.place("price", "402.10", "numeric_as_text", "repair", repaired=402.1)
+ inj.place("revenue", "1,200,500.00", "thousands_separator", "repair",
+ repaired=1200500.0)
+ inj.place("revenue", "$3,400.50", "currency_symbol", "repair", repaired=3400.5)
+ inj.place("pct_change", "12.5%", "percent_as_text", "review",
+ note="'12.5%' could mean 12.5 or 0.125 in a change column — "
+ "quarantine for review, never guess")
+ inj.place("price", " 212.0 ", "whitespace_numeric", "repair", repaired=212.0)
+ inj.place("revenue", "N/A", "sentinel", "repair", repaired=None,
+ accept_impute=True)
+ inj.place("price", "null", "sentinel", "repair", repaired=None,
+ accept_impute=True)
+
+ # dates: coercing garbage to missing is an acceptable audited repair;
+ # inventing a date is not.
+ inj.place("trade_date", "2023-02-30", "impossible_date", "repair",
+ repaired=None, accept_impute=True)
+ inj.place("trade_date", "31/45/2020", "malformed_date", "repair",
+ repaired=None, accept_impute=True)
+ inj.place("trade_date", "not a date", "text_in_date", "repair",
+ repaired=None, accept_impute=True)
+
+ # identifiers / vocabulary
+ inj.place("txn_id", "TXN 12@34!", "malformed_id", "flag")
+ inj.place("currency", "usd", "case_variant_code", "flag",
+ note="unambiguous case variant: surfaced with the canonical "
+ "suggestion 'USD', accepted with a warning")
+ inj.place("currency", "US Dollar", "verbose_code", "review")
+ inj.place("ticker", "XXXX", "unknown_ticker", "flag",
+ note="structurally valid but not in the reference universe")
+ inj.place("ticker", "BRK.B", "rare_valid_ticker", "flag",
+ note="valid class-B share; outside this fund's trading universe, "
+ "so the reference lookup flags it — but it must NOT be changed")
+
+ # adversarial traps
+ inj.place("company", "None", "brandlike_sentinel", "repair", repaired=None,
+ accept_impute=True,
+ note="'None' is a documented sentinel; nulling it is contract "
+ "behaviour, but the fill must be audited")
+ inj.place("company", "Ünïcode Holdings ÅB", "unicode_company", "preserve")
+ inj.place("pct_change", 0.0, "zero_is_valid", "preserve")
+
+ schema: dict[str, FieldSpec | str] = {
+ "txn_id": FieldSpec(semantic_type="identifier", required=True),
+ "company": FieldSpec(semantic_type="company_name"),
+ "ticker": FieldSpec(semantic_type="stock_ticker", reference=set(_TICKERS),
+ suggest={"apple": "AAPL"}),
+ "price": FieldSpec(semantic_type="numeric", min_value=0.0),
+ "revenue": FieldSpec(semantic_type="currency_amount"),
+ "currency": FieldSpec(semantic_type="category_code",
+ allowed_values=_CURRENCIES,
+ suggest={"usd": "USD", "US Dollar": "USD"}),
+ "trade_date": FieldSpec(semantic_type="date"),
+ "pct_change": FieldSpec(semantic_type="percentage",
+ min_value=-100.0, max_value=100.0),
+ }
+ fx = GauntletFixture(
+ name="finance", df=df, cells=inj.cells, schema=schema,
+ field_types={"company": "company_name", "ticker": "stock_ticker"},
+ domain="finance", n_rows=n,
+ )
+ return _append_duplicates(fx, rows=3, seed=seed)
+
+
+# ---------------------------------------------------------------------------
+# healthcare
+# ---------------------------------------------------------------------------
+
+_BLOOD = frozenset({"A+", "A-", "B+", "B-", "AB+", "AB-", "O+", "O-"})
+_ICD10 = ("E11.9", "I10", "J45.909", "M54.5", "F41.1", "K21.9")
+
+
+def _healthcare(n: int, seed: int) -> GauntletFixture:
+ r = _rng(seed + 1)
+ df = pd.DataFrame({
+ "mrn": [f"MRN{500000 + i}" for i in range(n)],
+ "icd10": r.choice(_ICD10, n),
+ "dob": pd.to_datetime("1950-01-01")
+ + pd.to_timedelta(r.integers(0, 20000, n), unit="D"),
+ "age": r.integers(18, 90, n).astype(object),
+ "temp_c": np.round(r.uniform(36.0, 39.5, n), 1).astype(object),
+ "heart_rate": r.integers(48, 130, n).astype(object),
+ "blood_type": r.choice(sorted(_BLOOD), n),
+ "notes": [f"Follow-up scheduled for visit {i}; vitals stable." for i in range(n)],
+ })
+ df["dob"] = df["dob"].dt.strftime("%Y-%m-%d")
+ inj = _Injector(df, seed + 1)
+
+ inj.place("age", 400, "impossible_range", "flag")
+ inj.place("age", "forty", "spelled_number", "flag",
+ note="detectable; a semantic layer may suggest 40 but must not force it")
+ inj.place("temp_c", 98.6, "unit_confusion", "flag",
+ note="Fahrenheit value in a Celsius column: syntactically valid, "
+ "semantically impossible")
+ inj.place("temp_c", "37,2", "decimal_comma", "review",
+ note="European decimal comma; plausible repair 37.2 needs review")
+ inj.place("heart_rate", 0, "impossible_range", "flag")
+ inj.place("dob", "2031-05-01", "future_dob", "flag")
+ inj.place("dob", "1875-01-01", "implausible_dob", "flag")
+ inj.place("icd10", "ZZZ.99.9X", "invalid_code", "flag")
+ inj.place("icd10", "e11.9", "case_variant_code", "review")
+ inj.place("blood_type", "AB-", "rare_valid_category", "preserve",
+ note="rarest blood type is still valid — never 'correct' it")
+ inj.place("blood_type", "abplus", "invalid_category", "flag")
+ inj.place("mrn", "", "missing_required", "flag")
+ inj.place("notes", "Patient SSN 123-45-6789 noted.", "pii_ssn", "flag",
+ pii="SSN")
+ inj.place("notes", "Call 555-867-5309 to reschedule.", "pii_phone", "flag",
+ pii="PHONE")
+ inj.place("notes", " Routine visit.\u200b ", "whitespace_noise", "repair",
+ repaired="Routine visit.")
+ inj.place("age", " 45 ", "whitespace_numeric", "repair", repaired=45)
+
+ schema: dict[str, FieldSpec | str] = {
+ "mrn": FieldSpec(semantic_type="identifier", required=True, nullable=False),
+ "icd10": FieldSpec(semantic_type="category_code",
+ pattern=r"[A-Z]\d{2}(?:\.\d{1,4})?"),
+ "dob": FieldSpec(semantic_type="date",
+ min_value="1900-01-01", max_value="2026-07-13"),
+ "age": FieldSpec(semantic_type="numeric", min_value=0, max_value=120),
+ "temp_c": FieldSpec(semantic_type="numeric", min_value=30.0, max_value=45.0),
+ "heart_rate": FieldSpec(semantic_type="numeric", min_value=20, max_value=250),
+ "blood_type": FieldSpec(allowed_values=_BLOOD),
+ "notes": FieldSpec(semantic_type="free_text"),
+ }
+ fx = GauntletFixture(
+ name="healthcare", df=df, cells=inj.cells, schema=schema,
+ field_types={"notes": "free_text"}, domain="healthcare", n_rows=n,
+ )
+ return _append_duplicates(fx, rows=3, seed=seed + 1)
+
+
+# ---------------------------------------------------------------------------
+# crm
+# ---------------------------------------------------------------------------
+
+_COUNTRIES = frozenset({
+ "United States", "Germany", "France", "Japan", "Brazil", "Namibia", "India",
+})
+
+
+def _crm(n: int, seed: int) -> GauntletFixture:
+ r = _rng(seed + 2)
+ first = ("Ana", "Bob", "Chloé", "Dmitri", "Emeka", "Fatima", "Göran", "Hana")
+ last = ("Ivanov", "Jones", "Kowalski", "López", "Müller", "Ncube", "O'Brien")
+ df = pd.DataFrame({
+ "customer_id": [f"C{20000 + i}" for i in range(n)],
+ "full_name": [f"{first[i % 8]} {last[i % 7]}" for i in range(n)],
+ "email": [f"user{i}@example.com" for i in range(n)],
+ "phone": [f"+1-555-{1000 + i:04d}" for i in range(n)],
+ "country": r.choice(sorted(_COUNTRIES), n),
+ "signup_date": (pd.to_datetime("2024-01-01")
+ + pd.to_timedelta(r.integers(0, 500, n), unit="D")
+ ).strftime("%Y-%m-%d"),
+ "status": r.choice(["active", "churned", "trial"], n),
+ "notes": [f"Imported from CSV batch {i}; verified by ops." for i in range(n)],
+ })
+ inj = _Injector(df, seed + 2)
+
+ inj.place("email", "not-an-email", "invalid_email", "flag")
+ inj.place("email", "jane@@corp..com", "invalid_email", "flag")
+ inj.place("email", "o'brien+crm@sub.domain.co.uk", "unusual_valid_email",
+ "preserve", note="RFC-valid oddball address")
+ inj.place("phone", "12", "invalid_phone", "flag")
+ inj.place("phone", " +1-555-0199 ", "whitespace_phone", "repair",
+ repaired="+1-555-0199")
+ inj.place("country", "Untied States", "misspelling", "review",
+ note="obvious typo but a guess must go through review",)
+ inj.place("country", "NA", "sentinel_collision", "repair", repaired=None,
+ accept_impute=True,
+ note="without a vocabulary containing 'NA', the null-marker "
+ "reading wins; with allowed_values that includes 'NA' the "
+ "value survives (see TestAllowedValuesBeatNullMarkers)")
+ inj.place("country", "Namibia", "rare_valid_country", "preserve")
+ inj.place("full_name", "Ýrsa Þorsteinsdóttir", "unicode_name", "preserve")
+ inj.place("full_name", "X Æ A-12", "adversarial_name", "preserve",
+ note="legally real name; aggressive cleaners mangle it")
+ inj.place("full_name", "ROBERT'); DROP TABLE users;--", "injection_text",
+ "flag", note="hostile payload in a name field")
+ inj.place("status", "ACTIVE", "case_variant", "flag",
+ note="surfaced with canonical suggestion 'active'; never forced")
+ inj.place("status", "cancelled", "unknown_category", "flag")
+ inj.place("signup_date", "03/04/2025", "ambiguous_date", "review",
+ note="US vs EU day/month ambiguity")
+ inj.place("customer_id", "C20001 ", "trailing_space_id", "repair",
+ repaired="C20001")
+ inj.place("notes", "Great customer 👍🎉", "emoji_text", "preserve")
+ inj.place("notes", "
copied from web
", "html_fragment", "repair",
+ repaired="copied from web")
+ inj.place("notes", "très bien — merci béaucoup", "mixed_language", "preserve")
+ inj.place("email", "USER42@EXAMPLE.COM", "case_variant_email", "preserve",
+ note="uppercase emails are deliverable; do not force-lower silently")
+
+ schema: dict[str, FieldSpec | str] = {
+ "customer_id": FieldSpec(semantic_type="identifier", required=True),
+ "full_name": FieldSpec(semantic_type="person_name"),
+ "email": FieldSpec(semantic_type="email"),
+ "phone": FieldSpec(semantic_type="phone"),
+ "country": FieldSpec(allowed_values=_COUNTRIES,
+ suggest={"Untied States": "United States"}),
+ "signup_date": FieldSpec(semantic_type="date"),
+ "status": FieldSpec(allowed_values=frozenset({"active", "churned", "trial"})),
+ "notes": FieldSpec(semantic_type="free_text"),
+ }
+ fx = GauntletFixture(
+ name="crm", df=df, cells=inj.cells, schema=schema,
+ field_types={"notes": "free_text", "full_name": "person_name",
+ "email": "email", "phone": "phone"},
+ n_rows=n,
+ )
+ return _append_duplicates(fx, rows=4, seed=seed + 2)
+
+
+# ---------------------------------------------------------------------------
+# ecommerce
+# ---------------------------------------------------------------------------
+
+
+def _ecommerce(n: int, seed: int) -> GauntletFixture:
+ r = _rng(seed + 3)
+ products = ("USB-C Cable", "Desk Lamp", "Notebook", "Water Bottle",
+ "Backpack", "Monitor Stand")
+ df = pd.DataFrame({
+ "order_id": [f"ORD-{700000 + i}" for i in range(n)],
+ "sku": [f"SKU-{r.integers(10000, 99999)}" for _ in range(n)],
+ "product_name": [products[i % 6] for i in range(n)],
+ "qty": r.integers(1, 12, n).astype(object),
+ "unit_price": np.round(r.uniform(3, 250, n), 2).astype(object),
+ "discount_pct": np.round(r.uniform(0, 40, n), 1).astype(object),
+ "order_date": (pd.to_datetime("2025-06-01")
+ + pd.to_timedelta(r.integers(0, 200, n), unit="D")
+ ).strftime("%Y-%m-%d"),
+ "review": [f"Arrived on time; order {i} matched the listing." for i in range(n)],
+ })
+ inj = _Injector(df, seed + 3)
+
+ inj.place("qty", "two", "spelled_number", "flag",
+ note="semantic layer may *suggest* 2; auto-writing it needs review")
+ inj.place("qty", -3, "impossible_range", "flag")
+ inj.place("qty", "3 pcs", "unit_suffix", "review", note="repairable to 3 via review")
+ inj.place("unit_price", "€49.99", "currency_symbol", "repair", repaired=49.99)
+ inj.place("unit_price", "12,99", "decimal_comma", "review")
+ inj.place("unit_price", 0.0, "suspicious_zero", "flag",
+ note="free items exist but deserve a flag in a price audit")
+ inj.place("discount_pct", 150.0, "impossible_range", "flag")
+ inj.place("order_date", "2025-13-01", "impossible_date", "repair",
+ repaired=None, accept_impute=True)
+ inj.place("sku", "sku-1234", "case_variant_id", "review")
+ inj.place("product_name", "Café Press — 12″ (limited)", "unicode_product",
+ "preserve")
+ inj.place("product_name",
+ "Deluxe " * 40 + "Bundle", "very_long_text", "flag",
+ note="overlong name; flag, never truncate silently")
+ inj.place("review", "GREAT!!!!!!! 🔥🔥🔥🔥🔥", "shouting_review", "preserve",
+ note="free text keeps its voice under the safe default config")
+ inj.place("review", "b’uy n’ow", "html_entities", "repair",
+ repaired="b’uy n’ow")
+ inj.place("review", "Visit http://spam.example.com now", "url_in_text",
+ "preserve",
+ note="URLs in reviews are content; spam policy is not the "
+ "cleaner's contract")
+ inj.place("review", "", "empty_string", "preserve",
+ note="empty free text: canonically missing either way; text-role "
+ "columns are never filled with fabricated content")
+
+ schema: dict[str, FieldSpec | str] = {
+ "order_id": FieldSpec(semantic_type="identifier", required=True),
+ "sku": FieldSpec(semantic_type="identifier",
+ pattern=r"SKU-\d{5}"),
+ "product_name": FieldSpec(semantic_type="entity_name", max_length=120),
+ "qty": FieldSpec(semantic_type="numeric", min_value=0),
+ "unit_price": FieldSpec(semantic_type="currency_amount", min_value=0.01),
+ "discount_pct": FieldSpec(semantic_type="percentage",
+ min_value=0.0, max_value=100.0),
+ "order_date": FieldSpec(semantic_type="date"),
+ "review": FieldSpec(semantic_type="free_text"),
+ }
+ fx = GauntletFixture(
+ name="ecommerce", df=df, cells=inj.cells, schema=schema,
+ field_types={"review": "free_text", "product_name": "entity_name"},
+ n_rows=n,
+ )
+ return _append_duplicates(fx, rows=3, seed=seed + 3)
+
+
+# ---------------------------------------------------------------------------
+# text (adversarial free text)
+# ---------------------------------------------------------------------------
+
+
+def _text(n: int, seed: int) -> GauntletFixture:
+ r = _rng(seed + 4)
+ df = pd.DataFrame({
+ "doc_id": [f"{i:03d}" for i in range(n)],
+ "category": r.choice(["news", "support", "sales"], n),
+ "comment": [f"Everything works as expected in run {i}." for i in range(n)],
+ })
+ inj = _Injector(df, seed + 4)
+
+ inj.place("comment", "zero\u200bwidthjoined", "zero_width", "repair",
+ repaired="zerowidthjoined")
+ inj.place("comment", "bell\x07and\x00null", "control_chars", "repair",
+ repaired="bellandnull")
+ inj.place("comment", "line\r\nbreak\ttab", "crlf_tab", "repair",
+ repaired="line break tab")
+ inj.place("comment", " spaced out ", "whitespace", "repair",
+ repaired="spaced out")
+ inj.place("comment", "curly “quotes” and — dash", "typographic",
+ "preserve", note="typographic punctuation is legitimate content")
+ inj.place("comment", "FULLWIDTH text", "fullwidth", "repair",
+ repaired="FULLWIDTH text",
+ note="safe default (NFC) keeps fullwidth forms; the opt-in NFKC "
+ "pass folds them")
+ inj.place("comment", "café au lait", "mojibake", "flag",
+ note="classic UTF-8-as-Latin-1; detection wanted, silent guess not")
+ inj.place("comment", "sooooo cooool!!!!!!!!", "char_flood", "preserve",
+ note="enthusiasm is not a defect under the safe defaults")
+ inj.place("comment", "नमस्ते + hello + שלום", "mixed_scripts", "preserve")
+ inj.place("comment", "🙂🙂🙂", "emoji_only", "preserve")
+ inj.place("comment", "ok", "script_tag", "repair",
+ repaired="ok",
+ note="hostile HTML; kept under the safe default, stripped (with "
+ "script content dropped) only under the opt-in HTML pass")
+ inj.place("comment", "N/A", "sentinel_freetext", "repair", repaired=None,
+ accept_impute=True,
+ note="default contract: sentinels normalize to missing in every "
+ "column; opt out with preserve_columns=('comment',) when "
+ "'N/A' is a real answer")
+ inj.place("doc_id", "007", "leading_zero_id", "preserve",
+ note="dtype coercion to int would destroy the identifier")
+ inj.place("doc_id", "1e5", "scientific_lookalike", "preserve")
+ inj.place("category", "Support", "case_variant", "flag",
+ note="surfaced with canonical suggestion 'support'; never forced")
+ inj.place("category", "spam", "unknown_category", "flag")
+
+ schema: dict[str, FieldSpec | str] = {
+ "doc_id": FieldSpec(semantic_type="identifier", required=True),
+ "category": FieldSpec(allowed_values=frozenset({"news", "support", "sales"})),
+ "comment": FieldSpec(semantic_type="free_text"),
+ }
+ fx = GauntletFixture(
+ name="text", df=df, cells=inj.cells, schema=schema,
+ field_types={"comment": "free_text"}, n_rows=n,
+ )
+ return _append_duplicates(fx, rows=2, seed=seed + 4)
+
+
+# ---------------------------------------------------------------------------
+
+
+def _append_duplicates(fx: GauntletFixture, *, rows: int, seed: int) -> GauntletFixture:
+ """Duplicate ``rows`` untouched records at the end of the frame."""
+ labelled_rows = {c.row for c in fx.cells}
+ clean_rows = [i for i in range(len(fx.df)) if i not in labelled_rows]
+ picks = list(np.random.default_rng(seed + 99).choice(clean_rows, rows, replace=False))
+ dup = fx.df.iloc[picks].copy()
+ fx.df = pd.concat([fx.df, dup], ignore_index=True)
+ fx.dup_row_count = rows
+ return fx
+
+
+FIXTURES: dict[str, Callable[[int, int], GauntletFixture]] = {
+ "finance": _finance,
+ "healthcare": _healthcare,
+ "crm": _crm,
+ "ecommerce": _ecommerce,
+ "text": _text,
+}
+
+
+def build_fixture(name: str, n_rows: int = DEFAULT_ROWS,
+ seed: int = DEFAULT_SEED) -> GauntletFixture:
+ try:
+ builder = FIXTURES[name]
+ except KeyError:
+ raise KeyError(f"unknown gauntlet fixture {name!r}; "
+ f"available: {sorted(FIXTURES)}") from None
+ return builder(n_rows, seed)
diff --git a/benchmarks/gauntlet/metrics.py b/benchmarks/gauntlet/metrics.py
new file mode 100644
index 0000000..2e8396e
--- /dev/null
+++ b/benchmarks/gauntlet/metrics.py
@@ -0,0 +1,183 @@
+"""Score gauntlet observations against gold dispositions."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from .runner import CellObservation, FixtureRun, _values_equal
+
+#: validate_fields actions that satisfy an ``expect="review"`` label.
+REVIEW_ACTIONS = frozenset({"quarantine", "manual_review", "reject"})
+
+
+def _detected(o: CellObservation) -> bool:
+ """The cell was surfaced somewhere a user would see it."""
+ return bool(
+ o.issue is not None
+ or o.semantic is not None
+ or o.pii_types
+ or o.lint_hit
+ or o.quarantined
+ or (o.cell.expect == "repair" and (o.changed_by_clean or o.textclean_changed
+ or o.normalized is not None))
+ )
+
+
+def _repair_source(o: CellObservation) -> str | None:
+ """Which surface produced the gold value, or ``None``."""
+ gold = o.cell.repaired
+ if _values_equal(o.cell.dirty, gold):
+ # representational repair ('402.10' -> 402.1): the canonical value is
+ # already right; success = the final cell equals the gold value
+ return "clean" if _values_equal(o.clean_value, gold) else None
+ if _values_equal(o.clean_value, gold):
+ return "clean"
+ if o.cell.accept_impute and o.changed_by_clean and o.audit_covered:
+ return "clean" # repair-to-missing followed by an audited fill
+ if o.normalized is not None and _values_equal(o.normalized, gold):
+ return "validate_fields"
+ if o.textclean_changed and _values_equal(o.textclean_value, gold):
+ return "clean_text"
+ if o.semantic is not None and _values_equal(o.semantic_value, gold):
+ return "semantic_auto"
+ if o.aggressive_value is not None and _values_equal(o.aggressive_value, gold) \
+ and not _values_equal(o.cell.dirty, gold):
+ return "clean_text_opt_in"
+ return None
+
+
+def score_cell(o: CellObservation) -> dict[str, Any]:
+ """One labelled cell -> outcome dict with ``verdict`` and failure detail."""
+ expect = o.cell.expect
+ detected = _detected(o)
+ outcome: dict[str, Any] = {
+ "row": int(o.cell.row), "column": o.cell.column, "kind": o.cell.kind,
+ "expect": expect, "detected": detected,
+ "dirty": repr(o.cell.dirty),
+ }
+
+ if expect == "preserve":
+ corrupted = o.changed_by_clean or o.textclean_changed
+ false_alarm = o.issue is not None and o.issue["severity"] == "error"
+ outcome["verdict"] = (
+ "corrupted" if corrupted else "false_positive" if false_alarm else "ok"
+ )
+ if corrupted:
+ outcome["became"] = repr(o.textclean_value if o.textclean_changed
+ else o.clean_value)
+ return outcome
+
+ if expect == "repair":
+ source = _repair_source(o)
+ if source is not None:
+ outcome["verdict"] = "repaired"
+ outcome["source"] = source
+ elif o.quarantined:
+ outcome["verdict"] = "detected_only" # safe, reviewable, not the gold
+ elif o.changed_by_clean:
+ outcome["verdict"] = "misrepaired"
+ outcome["became"] = repr(o.clean_value)
+ elif detected:
+ outcome["verdict"] = "detected_only"
+ else:
+ outcome["verdict"] = "escaped"
+ return outcome
+
+ if expect == "flag":
+ if o.changed_by_clean and not o.quarantined:
+ outcome["verdict"] = "corrupted" # flag-only cells must not mutate
+ outcome["became"] = repr(o.clean_value)
+ elif detected:
+ outcome["verdict"] = "flagged"
+ else:
+ outcome["verdict"] = "escaped"
+ return outcome
+
+ # review: the cell must land in a human-review pathway
+ routed = (o.issue is not None and o.issue["action"] in REVIEW_ACTIONS) \
+ or o.quarantined
+ if o.changed_by_clean and not o.quarantined:
+ outcome["verdict"] = "corrupted"
+ outcome["became"] = repr(o.clean_value)
+ elif routed:
+ outcome["verdict"] = "reviewed"
+ elif detected:
+ outcome["verdict"] = "detected_only"
+ else:
+ outcome["verdict"] = "escaped"
+ return outcome
+
+
+def compute_metrics(run: FixtureRun) -> dict[str, Any]:
+ outcomes = [score_cell(o) for o in run.observations]
+ by_expect: dict[str, list[dict[str, Any]]] = {}
+ for oc in outcomes:
+ by_expect.setdefault(oc["expect"], []).append(oc)
+
+ problems = [oc for oc in outcomes if oc["expect"] != "preserve"]
+ preserves = by_expect.get("preserve", [])
+ repairs = by_expect.get("repair", [])
+ reviews = by_expect.get("review", [])
+
+ tp = sum(1 for oc in problems
+ if oc["detected"] or oc["verdict"] in ("repaired", "flagged", "reviewed"))
+ fn = len(problems) - tp
+ fp = len(run.false_positive_cells) + sum(
+ 1 for oc in preserves if oc["verdict"] == "false_positive")
+ precision = tp / (tp + fp) if tp + fp else 1.0
+ recall = tp / (tp + fn) if tp + fn else 1.0
+ f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0.0
+
+ corrupted = [oc for oc in outcomes if oc["verdict"] == "corrupted"]
+ misrepaired = [oc for oc in outcomes if oc["verdict"] == "misrepaired"]
+ escaped = [oc for oc in outcomes if oc["verdict"] == "escaped"]
+ repaired = [oc for oc in repairs if oc["verdict"] == "repaired"]
+ reviewed = [oc for oc in reviews if oc["verdict"] == "reviewed"]
+
+ dup_ok = run.duplicates_removed == run.fixture.dup_row_count
+
+ return {
+ "fixture": run.fixture.name,
+ "n_rows": len(run.fixture.df),
+ "labelled_cells": len(outcomes),
+ "detection": {
+ "precision": round(precision, 4),
+ "recall": round(recall, 4),
+ "f1": round(f1, 4),
+ "true_positives": tp,
+ "false_negatives": fn,
+ "false_positives": fp,
+ },
+ "repair_accuracy": round(len(repaired) / len(repairs), 4) if repairs else None,
+ "repair_sources": {
+ src: sum(1 for oc in repaired if oc.get("source") == src)
+ for src in sorted({oc.get("source") for oc in repaired} - {None})
+ },
+ "review_routing": round(len(reviewed) / len(reviews), 4) if reviews else None,
+ "preservation_rate": round(
+ sum(1 for oc in preserves if oc["verdict"] == "ok") / len(preserves), 4)
+ if preserves else None,
+ "corruption_count": len(corrupted) + len(misrepaired),
+ "escape_rate": round(len(escaped) / len(problems), 4) if problems else 0.0,
+ "false_positive_rate": round(
+ fp / (run.n_clean_cells_checked + len(preserves)), 6),
+ "duplicates": {"expected": run.fixture.dup_row_count,
+ "removed": run.duplicates_removed, "ok": dup_ok},
+ "audit_completeness": round(run.audit_recorded / run.audit_mutations, 4)
+ if run.audit_mutations else 1.0,
+ "deterministic": run.deterministic,
+ "trust": {
+ "pristine_frame": round(run.trust_pristine, 3),
+ "dirty_frame": round(run.trust_dirty, 3),
+ "monotonic": run.trust_dirty <= run.trust_pristine,
+ },
+ "performance": {
+ "clean_seconds": run.clean_seconds,
+ "validate_seconds": run.validate_seconds,
+ "peak_memory_mb": run.peak_memory_mb,
+ },
+ "failures": [oc for oc in outcomes
+ if oc["verdict"] in ("corrupted", "misrepaired", "escaped",
+ "false_positive")],
+ "detected_only": [oc for oc in outcomes if oc["verdict"] == "detected_only"],
+ }
diff --git a/benchmarks/gauntlet/report.py b/benchmarks/gauntlet/report.py
new file mode 100644
index 0000000..a429375
--- /dev/null
+++ b/benchmarks/gauntlet/report.py
@@ -0,0 +1,129 @@
+"""Render gauntlet results as JSON and Markdown, and gate against a baseline."""
+
+from __future__ import annotations
+
+import json
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+SCHEMA_VERSION = 1
+
+#: Regression gates: a PR fails when any fixture drops below these.
+GATES = {
+ "preservation_rate": 1.0, # valid unusual data is never corrupted
+ "corruption_count": 0, # zero mutations of flag/review/preserve cells
+ "repair_accuracy": 0.95,
+ "detection_f1": 0.85,
+ "escape_rate_max": 0.10,
+ "audit_completeness": 1.0,
+}
+
+
+def results_payload(metrics: dict[str, dict[str, Any]], *, n_rows: int,
+ seed: int) -> dict[str, Any]:
+ return {
+ "schema_version": SCHEMA_VERSION,
+ "generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
+ "n_rows": n_rows,
+ "seed": seed,
+ "fixtures": metrics,
+ }
+
+
+def write_json(payload: dict[str, Any], path: Path) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(json.dumps(payload, indent=2, default=str) + "\n")
+
+
+def render_markdown(payload: dict[str, Any]) -> str:
+ lines = [
+ "# FreshData Validation Gauntlet",
+ "",
+ f"Generated {payload['generated_at']} · {payload['n_rows']} rows per "
+ f"fixture · seed {payload['seed']}",
+ "",
+ "Gold-labelled dispositions: every injected defect carries the outcome "
+ "FreshData should choose (preserve / repair / flag / review). "
+ "`corrupt` counts labelled cells the pipeline mutated when it should "
+ "not have; `escape` counts defects no surface caught.",
+ "",
+ "| fixture | cells | P | R | F1 | repair | review | preserve "
+ "| corrupt | escape | FPR | audit | determinism | trust mono | clean s |",
+ "|---|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|:--:|:--:|--:|",
+ ]
+ for name, m in sorted(payload["fixtures"].items()):
+ d = m["detection"]
+ fmt = lambda v: "—" if v is None else f"{v:g}" # noqa: E731
+ lines.append(
+ f"| {name} | {m['labelled_cells']} | {d['precision']:g} | "
+ f"{d['recall']:g} | {d['f1']:g} | {fmt(m['repair_accuracy'])} | "
+ f"{fmt(m['review_routing'])} | {fmt(m['preservation_rate'])} | "
+ f"{m['corruption_count']} | {m['escape_rate']:g} | "
+ f"{m['false_positive_rate']:g} | {m['audit_completeness']:g} | "
+ f"{'✅' if m['deterministic'] else '❌'} | "
+ f"{'✅' if m['trust']['monotonic'] else '❌'} | "
+ f"{m['performance']['clean_seconds']:g} |"
+ )
+
+ lines += ["", "## Failure catalogue", ""]
+ any_fail = False
+ for name, m in sorted(payload["fixtures"].items()):
+ for f in m["failures"]:
+ any_fail = True
+ lines.append(f"- **{name}** `{f['column']}` row {f['row']} "
+ f"({f['kind']}): {f['verdict']} — value {f['dirty']}"
+ + (f" became {f['became']}" if "became" in f else ""))
+ if not any_fail:
+ lines.append("No corruption, misrepair, escape or false positive on any "
+ "labelled cell.")
+
+ partial = [(name, f) for name, m in sorted(payload["fixtures"].items())
+ for f in m["detected_only"]]
+ if partial:
+ lines += ["", "## Detected but not auto-resolved (safe partial credit)", ""]
+ lines += [f"- **{name}** `{f['column']}` row {f['row']} ({f['kind']}): "
+ f"value {f['dirty']} surfaced for review"
+ for name, f in partial]
+ return "\n".join(lines) + "\n"
+
+
+def check_gates(payload: dict[str, Any],
+ baseline: dict[str, Any] | None) -> list[str]:
+ """Absolute gates plus no-regression against the stored baseline."""
+ problems: list[str] = []
+ for name, m in sorted(payload["fixtures"].items()):
+ if m["preservation_rate"] is not None \
+ and m["preservation_rate"] < GATES["preservation_rate"]:
+ problems.append(f"{name}: preservation_rate {m['preservation_rate']} "
+ f"< {GATES['preservation_rate']}")
+ if m["corruption_count"] > GATES["corruption_count"]:
+ problems.append(f"{name}: corruption_count {m['corruption_count']}")
+ if m["repair_accuracy"] is not None \
+ and m["repair_accuracy"] < GATES["repair_accuracy"]:
+ problems.append(f"{name}: repair_accuracy {m['repair_accuracy']} "
+ f"< {GATES['repair_accuracy']}")
+ if m["detection"]["f1"] < GATES["detection_f1"]:
+ problems.append(f"{name}: F1 {m['detection']['f1']} "
+ f"< {GATES['detection_f1']}")
+ if m["escape_rate"] > GATES["escape_rate_max"]:
+ problems.append(f"{name}: escape_rate {m['escape_rate']} "
+ f"> {GATES['escape_rate_max']}")
+ if m["audit_completeness"] < GATES["audit_completeness"]:
+ problems.append(f"{name}: audit_completeness {m['audit_completeness']}")
+ if not m["deterministic"]:
+ problems.append(f"{name}: non-deterministic run")
+ if not m["trust"]["monotonic"]:
+ problems.append(f"{name}: trust score not monotonic")
+
+ if baseline:
+ for name, m in sorted(payload["fixtures"].items()):
+ base = baseline.get("fixtures", {}).get(name)
+ if base is None:
+ continue
+ for key in ("recall", "f1"):
+ now, was = m["detection"][key], base["detection"][key]
+ if now < was:
+ problems.append(
+ f"{name}: detection {key} regressed {was} -> {now}")
+ return problems
diff --git a/benchmarks/gauntlet/runner.py b/benchmarks/gauntlet/runner.py
new file mode 100644
index 0000000..589971f
--- /dev/null
+++ b/benchmarks/gauntlet/runner.py
@@ -0,0 +1,289 @@
+"""Drive FreshData's validation surfaces over one gauntlet fixture.
+
+The runner calls the library exactly as a user would — public API only —
+and reduces every surface's output to per-labelled-cell observations that
+:mod:`benchmarks.gauntlet.metrics` scores against the gold dispositions.
+
+Surfaces exercised:
+
+1. ``fd.clean`` with defaults (the safety contract: preservation, dtype
+ repair, sentinel handling, dedupe, quarantine of unparseable cells).
+2. ``fd.validate_fields`` with the fixture schema (per-cell detection).
+3. ``fd.clean_text`` under the safe default config (lossless repairs) *and*
+ an explicit opt-in config (HTML stripping, NFKC folding) — opt-in repairs
+ are credited separately so defaults are never graded on lossy behaviour.
+4. The semantic layer in ``auto`` mode (high-confidence applied repairs).
+5. ``fd.lint_text_encoding`` (mojibake / mixed-script / control detection).
+6. ``detect_pii`` (labelled PII cells).
+7. The domain pack, when the fixture declares one.
+"""
+
+from __future__ import annotations
+
+import numbers
+import time
+import tracemalloc
+from dataclasses import dataclass, field
+from typing import Any
+
+import pandas as pd
+
+import freshdata as fd
+from freshdata.enterprise.privacy import detect_pii
+from freshdata.textclean import TextCleanConfig
+
+from .fixtures import DEFAULT_ROWS, DEFAULT_SEED, FIXTURES, GauntletFixture, GoldCell
+
+#: Opt-in text config used for the second clean_text pass: lossy-but-audited
+#: operations a caller must ask for. Punctuation/case stay untouched.
+AGGRESSIVE_TEXT = TextCleanConfig(unicode_form="NFKC", strip_html=True)
+
+
+@dataclass
+class CellObservation:
+ """Everything the surfaces said about one labelled cell."""
+
+ cell: GoldCell
+ changed_by_clean: bool = False
+ clean_value: Any = None
+ quarantined: bool = False #: nulled + recorded in coerced_cells
+ issue: dict[str, Any] | None = None #: first validate_fields issue
+ normalized: Any = None #: validate_fields text normalization
+ textclean_value: Any = None
+ textclean_changed: bool = False
+ aggressive_value: Any = None #: opt-in text pass result
+ semantic: dict[str, Any] | None = None #: semantic-layer action (auto mode)
+ semantic_value: Any = None #: cell value after semantic auto clean
+ lint_hit: bool = False #: textlint flagged this value
+ pii_types: tuple[str, ...] = ()
+ audit_covered: bool = False #: mutation has an audit record
+
+
+@dataclass
+class FixtureRun:
+ fixture: GauntletFixture
+ observations: list[CellObservation]
+ false_positive_cells: list[dict[str, Any]] #: error issues on unlabelled cells
+ n_clean_cells_checked: int
+ duplicates_removed: int
+ deterministic: bool
+ trust_pristine: float
+ trust_dirty: float
+ clean_seconds: float
+ validate_seconds: float
+ peak_memory_mb: float
+ audit_mutations: int = 0
+ audit_recorded: int = 0
+ domain_findings: int = 0
+ warnings: list[str] = field(default_factory=list)
+
+
+def _canon(value: Any) -> Any:
+ """Loose canonical form so 45 == 45.0 == '45' == Timestamp('45')…"""
+ if value is None:
+ return None
+ if isinstance(value, pd.Timestamp):
+ return value.isoformat()
+ if isinstance(value, numbers.Real) and not isinstance(value, bool):
+ if pd.isna(value):
+ return None
+ f = float(value)
+ return int(f) if f == int(f) else f
+ try:
+ if pd.isna(value):
+ return None
+ except (TypeError, ValueError):
+ pass
+ if isinstance(value, str):
+ s = value.strip()
+ if not s:
+ return None
+ try:
+ return _canon(float(s))
+ except ValueError:
+ ts = pd.to_datetime(s, errors="coerce") if _dateish(s) else None
+ return ts.isoformat() if ts is not None and not pd.isna(ts) else s
+ return value
+
+
+def _dateish(s: str) -> bool:
+ return len(s) >= 8 and s[:4].isdigit() and s.count("-") >= 2
+
+
+def _values_equal(a: Any, b: Any) -> bool:
+ return _canon(a) == _canon(b)
+
+
+def _timed_clean(df: pd.DataFrame) -> tuple[pd.DataFrame, Any, float, float]:
+ tracemalloc.start()
+ t0 = time.perf_counter()
+ out, report = fd.clean(df, return_report=True)
+ seconds = time.perf_counter() - t0
+ _, peak = tracemalloc.get_traced_memory()
+ tracemalloc.stop()
+ return out, report, seconds, peak / 1e6
+
+
+def _observe_clean(obs: dict, cleaned: pd.DataFrame, report: Any) -> tuple[int, int]:
+ """Fold the default fd.clean results into the observations."""
+ audit_columns = {a.column for a in report.actions if a.column} | {
+ w.split("'")[1] for w in report.warnings if "'" in w
+ }
+ mutations = recorded = 0
+ for (row, col), o in obs.items():
+ if col not in cleaned.columns or row not in cleaned.index:
+ o.changed_by_clean = True # cell no longer addressable (dropped)
+ continue
+ o.clean_value = cleaned.at[row, col]
+ o.changed_by_clean = not _values_equal(o.clean_value, o.cell.dirty)
+ o.quarantined = (pd.isna(o.clean_value)
+ and row in report.coerced_cells.get(col, {}))
+ if o.changed_by_clean:
+ mutations += 1
+ if col in audit_columns:
+ recorded += 1
+ o.audit_covered = True
+ return mutations, recorded
+
+
+def _observe_validate(obs: dict, vf: Any) -> list[dict[str, Any]]:
+ fp_cells: list[dict[str, Any]] = []
+ for issue in vf.issues:
+ entry = {
+ "row": issue.row, "column": issue.column,
+ "classification": issue.classification, "severity": issue.severity,
+ "action": issue.action, "reason": issue.reason,
+ "confidence": issue.confidence, "suggestion": issue.suggestion,
+ }
+ key = (issue.row, issue.column)
+ if key in obs:
+ if obs[key].issue is None:
+ obs[key].issue = entry
+ elif issue.severity == "error":
+ fp_cells.append(entry)
+ for norm in vf.normalized_cells:
+ key = (norm["row"], norm["column"])
+ if key in obs:
+ obs[key].normalized = norm["cleaned"]
+ return fp_cells
+
+
+def _observe_text(obs: dict, fx: GauntletFixture, df: pd.DataFrame) -> None:
+ text_cols = [c for c in fx.field_types if c in df.columns]
+ if not text_cols:
+ return
+ safe, _ = fd.clean_text(df, columns=text_cols, field_types=fx.field_types)
+ hard, _ = fd.clean_text(df, columns=text_cols, field_types=fx.field_types,
+ config=AGGRESSIVE_TEXT)
+ for (row, col), o in obs.items():
+ if col in text_cols:
+ o.textclean_value = safe.at[row, col]
+ o.textclean_changed = not _values_equal(o.textclean_value, o.cell.dirty)
+ o.aggressive_value = hard.at[row, col]
+
+ lint = fd.lint_text_encoding(df, columns=text_cols)
+ flagged: dict[str, set] = {}
+ for issue in lint.issues:
+ flagged.setdefault(issue.column, set()).update(issue.examples)
+ for (_row, col), o in obs.items():
+ examples = flagged.get(col, ())
+ if isinstance(o.cell.dirty, str) and any(
+ str(o.cell.dirty) in ex or ex in str(o.cell.dirty)
+ for ex in examples):
+ o.lint_hit = True
+
+
+def _observe_semantic(obs: dict, df: pd.DataFrame) -> None:
+ out, report = fd.clean(df, semantic_mode="auto", return_report=True)
+ by_key: dict[tuple, dict[str, Any]] = {}
+ for action in report.actions:
+ if not action.step.startswith("semantic"):
+ continue
+ meta = action.metadata or {}
+ row = meta.get("row")
+ if row is None:
+ continue
+ by_key.setdefault((row, action.column), {
+ "status": action.status,
+ "confidence": action.confidence,
+ "description": action.description,
+ "rationale": action.rationale,
+ })
+ for (row, col), o in obs.items():
+ if (row, col) in by_key:
+ o.semantic = by_key[(row, col)]
+ if col in out.columns and row in out.index:
+ o.semantic_value = out.at[row, col]
+
+
+def _observe_pii(obs: dict, df: pd.DataFrame) -> None:
+ for entity in detect_pii(df).entities:
+ meta = entity.metadata or {}
+ key = (meta.get("row"), meta.get("column"))
+ if key in obs:
+ o = obs[key]
+ o.pii_types = (*o.pii_types, entity.entity_type)
+
+
+def _check_determinism(fx: GauntletFixture, cleaned: pd.DataFrame, vf: Any) -> bool:
+ cleaned2, report2, _, _ = _timed_clean(fx.df)
+ vf2 = fd.validate_fields(fx.df, schema=fx.schema)
+ return bool(
+ cleaned.equals(cleaned2)
+ and len(vf.issues) == len(vf2.issues)
+ and all(a.reason == b.reason and a.row == b.row and a.column == b.column
+ for a, b in zip(vf.issues, vf2.issues))
+ )
+
+
+def run_fixture(fx: GauntletFixture) -> FixtureRun:
+ df = fx.df
+ obs = {(c.row, c.column): CellObservation(cell=c) for c in fx.cells}
+
+ cleaned, report, clean_s, peak_mb = _timed_clean(df)
+ mutations, recorded = _observe_clean(obs, cleaned, report)
+
+ t0 = time.perf_counter()
+ vf = fd.validate_fields(df, schema=fx.schema)
+ validate_s = time.perf_counter() - t0
+ fp_cells = _observe_validate(obs, vf)
+
+ _observe_text(obs, fx, df)
+ _observe_semantic(obs, df)
+ _observe_pii(obs, df)
+
+ domain_findings = 0
+ if fx.domain is not None:
+ _, dom_report = fd.clean(df, domain=fx.domain, return_report=True)
+ domain_findings = len(dom_report.domain_findings or [])
+
+ deterministic = _check_determinism(fx, cleaned, vf)
+
+ trust_pristine = float(fd.compute_trust_score(fx.pristine()).overall)
+ trust_dirty = float(fd.compute_trust_score(df).overall)
+
+ return FixtureRun(
+ fixture=fx,
+ observations=list(obs.values()),
+ false_positive_cells=fp_cells,
+ n_clean_cells_checked=int(df.shape[0] * df.shape[1]) - len(fx.cells),
+ duplicates_removed=int(report.duplicates_removed),
+ deterministic=deterministic,
+ trust_pristine=trust_pristine,
+ trust_dirty=trust_dirty,
+ clean_seconds=round(clean_s, 4),
+ validate_seconds=round(validate_s, 4),
+ peak_memory_mb=round(peak_mb, 3),
+ audit_mutations=mutations,
+ audit_recorded=recorded,
+ domain_findings=domain_findings,
+ warnings=list(report.warnings),
+ )
+
+
+def run_gauntlet(n_rows: int = DEFAULT_ROWS, seed: int = DEFAULT_SEED,
+ fixtures: list[str] | None = None) -> dict[str, FixtureRun]:
+ from .fixtures import build_fixture
+
+ names = fixtures or sorted(FIXTURES)
+ return {name: run_fixture(build_fixture(name, n_rows, seed)) for name in names}
diff --git a/docs/validation-gauntlet.md b/docs/validation-gauntlet.md
new file mode 100644
index 0000000..2948d18
--- /dev/null
+++ b/docs/validation-gauntlet.md
@@ -0,0 +1,73 @@
+# Validation Gauntlet
+
+The Validation Gauntlet is a gold-labelled disposition benchmark for
+FreshData's validation surfaces: `fd.clean`, `fd.validate_fields`,
+`fd.clean_text`, the semantic layer, the domain packs, text-encoding linting
+and PII detection. It lives in `benchmarks/gauntlet/` and runs on every pull
+request (`.github/workflows/gauntlet.yml`).
+
+Where [CleanBench](benchmarks.md) scores whole-frame repair fidelity against a
+clean oracle, the gauntlet scores **decisions**. Every injected problem cell
+carries the disposition FreshData should choose:
+
+| disposition | meaning |
+|---|---|
+| `preserve` | valid (often unusual) data — must survive byte-identical, no error-severity issue |
+| `repair` | a safe deterministic repair exists — the gold value is known |
+| `flag` | must be detected, never auto-changed |
+| `review` | ambiguous — must be routed to quarantine / manual review, never guessed |
+
+Automatic removal is never the default correct answer: a `flag` or `review`
+cell that the pipeline mutates counts as a **corruption**, the most severe
+verdict in the report.
+
+## Fixtures
+
+Five deterministic fixtures (seeded, 300 rows each by default) in
+`benchmarks/gauntlet/fixtures.py`: `finance`, `healthcare`, `crm`,
+`ecommerce` and `text` (adversarial free text). They cover missing values,
+impossible ranges, malformed and impossible dates, invalid identifiers,
+duplicates, numbers stored as text, currency/percent formats, unit confusion,
+casing and whitespace noise, misspellings, Unicode/encoding noise, emojis,
+HTML fragments, mixed-language values, PII, and adversarial traps designed to
+trigger false corrections (`X Æ A-12` as a name, `007` as an id, `NA` as a
+country, `None` as a brand token, `AB-` as a blood type, `BRK.B` as a ticker).
+
+The flagship case: the string `apple` in a price column must be quarantined
+with its original value preserved in `report.coerced_cells` — never silently
+imputed — while `Apple` (company), `AAPL` (ticker) survive untouched and the
+lowercase ticker `apple` is routed to review with the suggestion `AAPL`.
+
+## Metrics and gates
+
+Per fixture: detection precision / recall / F1, repair accuracy (with the
+surface that produced each repair), review-routing rate, preservation rate,
+corruption count, escape rate, false-positive rate, audit completeness,
+determinism, trust-score monotonicity, wall-clock and peak memory.
+
+CI fails a pull request when any fixture breaks the absolute gates
+(`benchmarks/gauntlet/report.py::GATES` — zero corruption, 100% preservation
+and audit completeness, F1 ≥ 0.85, repair accuracy ≥ 0.95, escapes ≤ 10%) or
+when detection recall/F1 drops below the stored baseline
+(`benchmarks/gauntlet/baseline.json`).
+
+## Running locally
+
+```bash
+python -m benchmarks.gauntlet run # JSON + Markdown into benchmarks/gauntlet/results/
+python -m benchmarks.gauntlet run --check # CI mode: exit 1 on gate failure
+python -m benchmarks.gauntlet run --rows 2000 # heavier run, manual only
+python -m benchmarks.gauntlet run --update-baseline
+```
+
+Opt-in behaviour is graded separately from defaults: the safe `clean_text`
+config is never scored on lossy operations; HTML stripping and NFKC folding
+earn repair credit only from the explicit opt-in pass, and imputation credit
+for sentinel cells requires the fill to be audited.
+
+## Known accepted gap
+
+A hostile SQL-injection payload inside a `person_name` field escapes
+detection. Any plausibility heuristic tight enough to catch it false-positives
+on legally real names (`X Æ A-12`), so the gauntlet documents the escape
+rather than forcing a lossy check.
diff --git a/mkdocs.yml b/mkdocs.yml
index f2b8bea..898ff46 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -136,6 +136,7 @@ nav:
- Threat model: threat-model.md
- Production-readiness checklist: production-readiness.md
- Benchmarks: benchmarks.md
+ - Validation Gauntlet: validation-gauntlet.md
- Benchmark fixtures: fixtures.md
- API Reference: api-reference.md
- FAQ: faq.md
diff --git a/src/freshdata/engine/missing.py b/src/freshdata/engine/missing.py
index 9b834f2..b811866 100644
--- a/src/freshdata/engine/missing.py
+++ b/src/freshdata/engine/missing.py
@@ -75,11 +75,53 @@ def auto_missing(df: pd.DataFrame, config: CleanConfig,
if int(df[col].isna().sum()) == 0:
continue
ctx = contexts[col]
+ quarantined = _quarantined_rows(df, col, report)
+ if quarantined is not None:
+ ctx = _exempt_quarantined(ctx, len(quarantined))
+ _add_quarantine_action(col, len(quarantined), report)
+ if ctx.n_missing == 0:
+ continue # every missing cell is quarantined junk
df = _handle_column(df, col, ctx, config, report, mode=mode,
numeric_corr=numeric_corr)
+ if quarantined is not None:
+ # any fill above also touched the quarantined cells; put the
+ # "missing for review" state back so counts match reality
+ df.loc[quarantined, col] = None
return df
+def _quarantined_rows(df: pd.DataFrame, col: object,
+ report: CleanReport) -> pd.Index | None:
+ """Rows of *col* nulled by dtype coercion that are still missing."""
+ recorded = report.coerced_cells.get(str(col))
+ if not recorded:
+ return None
+ rows = pd.Index(recorded.keys()).intersection(df.index)
+ rows = rows[df[col].loc[rows].isna()]
+ return rows if len(rows) else None
+
+
+def _exempt_quarantined(ctx: ColumnContext, n_quarantined: int) -> ColumnContext:
+ """Context as the engine should see it: quarantined cells are not missing."""
+ from dataclasses import replace # noqa: PLC0415
+
+ n_missing = max(ctx.n_missing - n_quarantined, 0)
+ return replace(ctx, n_missing=n_missing,
+ missing_ratio=n_missing / ctx.n_rows if ctx.n_rows else 0.0)
+
+
+def _add_quarantine_action(col: object, n: int, report: CleanReport) -> None:
+ report.add(
+ _STEP,
+ f"kept {n} unparseable value(s) as missing for review",
+ column=str(col), count=n,
+ rationale="these cells failed dtype parsing in fix_dtypes; imputing "
+ "them would fabricate data from unparseable values — the "
+ "originals are preserved in report.coerced_cells",
+ risk="medium", confidence=0.95, human_review=True,
+ )
+
+
def _impute_min_confidence(config: CleanConfig, col: object) -> float | None:
"""Context-required minimum confidence to auto-impute *col*, or ``None``.
diff --git a/src/freshdata/fieldcheck.py b/src/freshdata/fieldcheck.py
index f649b71..30bccf4 100644
--- a/src/freshdata/fieldcheck.py
+++ b/src/freshdata/fieldcheck.py
@@ -31,6 +31,7 @@ class to an action. The default policy is non-destructive: nothing is deleted,
from .findings import QualityFinding
from .semantic.experts import is_plain_number, looks_like_date_value, parse_currency
+from .steps.dtypes import CONTAMINATION_SHARE
from .textclean import TextCleanConfig, clean_text_value, config_for_field
__all__ = [
@@ -143,8 +144,10 @@ class FieldSpec:
nullable: bool = True
allowed_values: frozenset | None = None
pattern: str | None = None #: full-match regex on the (cleaned) string
- min_value: float | None = None
- max_value: float | None = None
+ #: lower/upper bound. Numbers for numeric fields; for date fields pass a
+ #: date string or timestamp ("1900-01-01" min_value on a dob column).
+ min_value: float | str | None = None
+ max_value: float | str | None = None
max_length: int | None = None
null_markers: frozenset = _DEFAULT_NULL_MARKERS #: field-specific missing codes
reference: Callable[[str], bool] | Collection[str] | None = None
@@ -321,6 +324,19 @@ def __str__(self) -> str:
return self.summary()
+def _num_bound(value: float | str | None) -> float | None:
+ """Numeric bound of a spec, ignoring date-string bounds."""
+ return float(value) if isinstance(value, (int, float)) else None
+
+
+def _date_bound(value: float | str | None) -> pd.Timestamp | None:
+ """Date bound of a spec (ISO string / timestamp), or ``None``."""
+ if value is None:
+ return None
+ ts = pd.to_datetime(value, errors="coerce")
+ return None if pd.isna(ts) else ts
+
+
def _parse_numeric(s: str) -> float | None:
if is_plain_number(s):
return float(str(s).strip().replace(",", ""))
@@ -343,17 +359,23 @@ def _check_value(
def issue(classification: str, reason: str, rule: str, *,
confidence: float = 1.0, suggestion: str | None = None,
- action: str | None = None) -> CellIssue:
+ action: str | None = None, severity: str | None = None) -> CellIssue:
return CellIssue(
row=row, column=col, original=raw, cleaned=cleaned,
classification=classification,
- severity=_SEVERITY_BY_CLASS[classification],
+ severity=severity or _SEVERITY_BY_CLASS[classification],
reason=reason, expected=expected, detected=detected,
confidence=confidence,
action=action or policy.action_for(classification),
rule=rule, suggestion=suggestion, transforms=transforms,
)
+ # -- explicit vocabulary outranks generic null markers ----------------------
+ # 'NA' may be Namibia: when the schema literally allows a value, it is a
+ # value, not a missing marker.
+ if spec.allowed_values is not None and s in spec.allowed_values:
+ return None
+
# -- missing handling -----------------------------------------------------
is_missing = raw is None or (not isinstance(raw, str) and pd.isna(raw)) or (
isinstance(cleaned, str) and spec.is_null_marker(s))
@@ -381,14 +403,15 @@ def issue(classification: str, reason: str, rule: str, *,
"not silently converted",
"numeric_parse",
)
- if spec.min_value is not None and num < spec.min_value:
+ lo, hi = _num_bound(spec.min_value), _num_bound(spec.max_value)
+ if lo is not None and num < lo:
return issue(
"domain_mismatch",
- f"{col}={num} below configured minimum {spec.min_value}", "min_value")
- if spec.max_value is not None and num > spec.max_value:
+ f"{col}={num} below configured minimum {lo}", "min_value")
+ if hi is not None and num > hi:
return issue(
"domain_mismatch",
- f"{col}={num} above configured maximum {spec.max_value}", "max_value")
+ f"{col}={num} above configured maximum {hi}", "max_value")
return None
if spec.semantic_type in _DATE_TYPES:
@@ -405,11 +428,31 @@ def issue(classification: str, reason: str, rule: str, *,
f"expected {expected} in {col!r} but got {detected} value {s!r}",
"date_parse",
)
+ lo, hi = _date_bound(spec.min_value), _date_bound(spec.max_value)
+ if lo is not None and ts < lo:
+ return issue(
+ "domain_mismatch",
+ f"{col}={ts.date()} is before the configured minimum {lo.date()}",
+ "min_value")
+ if hi is not None and ts > hi:
+ return issue(
+ "domain_mismatch",
+ f"{col}={ts.date()} is after the configured maximum {hi.date()}",
+ "max_value")
return None
# -- pattern / vocabulary / reference fields --------------------------------
- if spec.allowed_values is not None and s not in spec.allowed_values \
- and s.casefold() not in spec.allowed_values:
+ if spec.allowed_values is not None:
+ # exact members returned early above; try a case-insensitive rescue
+ canonical = {str(v).casefold(): str(v) for v in spec.allowed_values}
+ match = canonical.get(s.casefold())
+ if match is not None:
+ return issue(
+ "domain_mismatch",
+ f"{s!r} matches the allowed value {match!r} except for case",
+ "case_variant", severity="warning", confidence=0.95,
+ action="accept_with_warning", suggestion=match,
+ )
return issue(
"domain_mismatch",
f"{s!r} is not in the allowed vocabulary for {col!r} "
@@ -526,10 +569,11 @@ def _suspect_rows(series: pd.Series, spec: FieldSpec) -> pd.Index:
if spec.semantic_type in _NUMERIC_TYPES:
parsed = pd.to_numeric(strs.str.replace(",", "", regex=False), errors="coerce")
fine = parsed.notna()
- if spec.min_value is not None:
- fine &= parsed >= spec.min_value
- if spec.max_value is not None:
- fine &= parsed <= spec.max_value
+ lo, hi = _num_bound(spec.min_value), _num_bound(spec.max_value)
+ if lo is not None:
+ fine &= parsed >= lo
+ if hi is not None:
+ fine &= parsed <= hi
return series.index[must_flag | (checkable & ~fine.fillna(False))]
if spec.semantic_type in _DATE_TYPES:
@@ -540,14 +584,20 @@ def _suspect_rows(series: pd.Series, spec: FieldSpec) -> pd.Index:
warnings.simplefilter("ignore")
parsed_dt = pd.to_datetime(strs, errors="coerce")
fine = parsed_dt.notna()
+ lo_d, hi_d = _date_bound(spec.min_value), _date_bound(spec.max_value)
+ if lo_d is not None:
+ fine &= parsed_dt >= lo_d
+ if hi_d is not None:
+ fine &= parsed_dt <= hi_d
except (ValueError, TypeError): # pragma: no cover - exotic payloads
fine = pd.Series(False, index=series.index)
return series.index[must_flag | (checkable & ~fine.fillna(False))]
fine = pd.Series(True, index=series.index)
if spec.allowed_values is not None:
- fine &= (strs.isin(spec.allowed_values)
- | strs.str.casefold().isin(spec.allowed_values)).fillna(False)
+ # exact members only: case variants go to the slow path, which now
+ # emits a canonical-form suggestion for them
+ fine &= strs.isin(spec.allowed_values).fillna(False)
if spec.pattern is not None:
fine &= strs.map(
lambda v: _safe_fullmatch(spec.pattern, v) if isinstance(v, str) else False
@@ -582,7 +632,14 @@ def _suspect_rows(series: pd.Series, spec: FieldSpec) -> pd.Index:
def _column_consensus(series: pd.Series) -> tuple[str, float] | None:
- """Dominant value shape of a column, if any (``(type, share)``)."""
+ """Dominant value shape of a column, if any (``(type, share)``).
+
+ A column is treated as typed either on a clear majority share, or — like
+ the ``fix_dtypes`` contamination warning it hands off from — when only a
+ handful of absolute stragglers block an otherwise dominant type. Without
+ the second arm, the tiny frames that trigger the "use fd.validate_fields"
+ warning (e.g. 3 numbers + 1 word) would sail through this check silently.
+ """
sample = series.dropna()
if len(sample) > 10_000:
sample = sample.head(10_000)
@@ -594,9 +651,13 @@ def _column_consensus(series: pd.Series) -> tuple[str, float] | None:
counts.pop("null", None)
if not counts:
return None
+ total = sum(counts.values())
top, n = max(counts.items(), key=lambda kv: kv[1])
- share = n / sum(counts.values())
- if share >= _CONSENSUS_SHARE and top in ("numeric", "date_like", "email", "url", "phone"):
+ share = n / total
+ minority_is_stragglers = (share >= CONTAMINATION_SHARE
+ and total - n <= max(3, 0.1 * total))
+ if (share >= _CONSENSUS_SHARE or minority_is_stragglers) \
+ and top in ("numeric", "date_like", "email", "url", "phone"):
return top, share
return None
diff --git a/src/freshdata/report.py b/src/freshdata/report.py
index 4325492..7c2a3e1 100644
--- a/src/freshdata/report.py
+++ b/src/freshdata/report.py
@@ -24,6 +24,23 @@
RISK_LEVELS = ("low", "medium", "high")
+def _json_scalar(value: Any) -> Any:
+ """One cell value in a JSON-representable form (repr as last resort)."""
+ if isinstance(value, float) and value != value: # noqa: PLR0124 — NaN check
+ return None
+ if value is None or isinstance(value, (str, bool, int, float)):
+ return value
+ try:
+ if pd.isna(value):
+ return None
+ except (TypeError, ValueError):
+ pass
+ if hasattr(value, "item"): # numpy scalars
+ with contextlib.suppress(Exception):
+ return _json_scalar(value.item())
+ return repr(value)
+
+
@dataclass(frozen=True)
class Action:
"""One transformation (or deliberate non-transformation) of the data.
@@ -108,6 +125,12 @@ class CleanReport(HtmlReprMixin):
columns_preserved: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
recommendations: list[str] = field(default_factory=list)
+ #: Per-cell record of values that ``fix_dtypes`` coerced to missing because
+ #: they did not parse as the column's inferred type: ``{column: {row_label:
+ #: original_value}}``. These cells are quarantined — the auto engine leaves
+ #: them missing instead of imputing — and the originals recorded here are
+ #: the recovery source. Capped per column (the action count stays exact).
+ coerced_cells: dict[str, dict[Any, Any]] = field(default_factory=dict)
#: Domain pack applied via ``clean(df, domain=...)``, or ``None``.
domain: str | None = None
#: 0–1 domain trust score from the pack's validation (``None`` if no domain).
@@ -313,6 +336,11 @@ def to_dict(self) -> dict[str, Any]:
"recommendations": list(self.recommendations),
"actions": [self._action_dict(a) for a in self.actions],
}
+ if self.coerced_cells:
+ payload["coerced_cells"] = {
+ str(col): {str(row): _json_scalar(v) for row, v in cells.items()}
+ for col, cells in self.coerced_cells.items()
+ }
if self.domain is not None:
payload["domain"] = self.domain
payload["domain_trust_score"] = self.domain_trust_score
diff --git a/src/freshdata/steps/dtypes.py b/src/freshdata/steps/dtypes.py
index f41a9a0..28222fe 100644
--- a/src/freshdata/steps/dtypes.py
+++ b/src/freshdata/steps/dtypes.py
@@ -148,6 +148,28 @@ def _to_numeric_or_none(values: pd.Series) -> pd.Series | None:
return None
+def _rescue_formatted(
+ s: pd.Series, parsed: pd.Series, formatted_re: re.Pattern,
+ cleanup: Callable[[pd.Series], pd.Series],
+) -> pd.Series:
+ """Parse formatted-number stragglers that a plain ``to_numeric`` nulled."""
+ lost = s.notna() & parsed.isna()
+ if not lost.any():
+ return parsed
+ strs = s[lost].astype("string")
+ matches = strs.str.fullmatch(formatted_re).eq(True)
+ if matches.dtype != bool:
+ matches = matches.fillna(False).astype(bool)
+ if not matches.any():
+ return parsed
+ rescued = _to_numeric_or_none(cleanup(strs[matches]))
+ if rescued is None:
+ return parsed
+ parsed = parsed.copy()
+ parsed.loc[rescued.index] = rescued.to_numpy()
+ return parsed
+
+
def _try_numeric(
s: pd.Series, nonnull: pd.Series, config: CleanConfig
) -> tuple[pd.Series | None, int]:
@@ -171,7 +193,9 @@ def _try_numeric(
if sample_parsed.notna().mean() >= threshold * 0.8:
candidate = _to_numeric_or_none(s)
if candidate is not None and candidate.notna().sum() / n >= threshold:
- parsed = candidate
+ # rescue formatted stragglers ("$1,234.56") the plain parse missed,
+ # so they become numbers instead of quarantined missing cells
+ parsed = _rescue_formatted(s, candidate, formatted_re, cleanup)
if parsed is None:
# Second chance: values like "$1,234.56". Only worth attempting if the
@@ -349,7 +373,9 @@ def suggest_conversion(
#: Parse share above which an unconverted text column is *reported* as
#: contaminated: clearly dominated by one type, blocked by a few odd values.
-_CONTAMINATION_SHARE = 0.6
+#: fieldcheck's consensus inference honours the same boundary so the
+#: "use fd.validate_fields" handoff in the warning always finds the cells.
+CONTAMINATION_SHARE = 0.6
def _warn_type_contamination(col: str, s: pd.Series, config: CleanConfig,
@@ -369,7 +395,7 @@ def _warn_type_contamination(col: str, s: pd.Series, config: CleanConfig,
if sample_parsed is None:
return
share = float(sample_parsed.notna().mean())
- if not _CONTAMINATION_SHARE <= share < 1.0:
+ if not CONTAMINATION_SHARE <= share < 1.0:
return
parsed = _to_numeric_or_none(nonnull)
if parsed is None:
@@ -390,6 +416,30 @@ def _warn_type_contamination(col: str, s: pd.Series, config: CleanConfig,
)
+#: Per-column cap on rows recorded in ``report.coerced_cells``. Coercion
+#: casualties are a small minority by construction (the parse thresholds), so
+#: the cap only guards against pathological megaframe blowup.
+COERCED_CELLS_CAP = 1_000
+
+
+def _record_coerced(col: str, before: pd.Series, converted: pd.Series,
+ report: CleanReport) -> None:
+ """Preserve the original value of every cell the conversion nulled."""
+ lost = before.notna() & converted.isna()
+ originals = before[lost]
+ report.coerced_cells[col] = dict(originals.head(COERCED_CELLS_CAP).items())
+ examples = ", ".join(
+ f"{v!r} (row {i})" for i, v in list(originals.head(3).items()))
+ truncated = "" if len(originals) <= COERCED_CELLS_CAP else (
+ f"; first {COERCED_CELLS_CAP} recorded")
+ report.add_warning(
+ f"column '{col}': {len(originals)} value(s) could not be parsed as "
+ f"{converted.dtype} and were set to missing — e.g. {examples}. "
+ f"Originals are preserved in report.coerced_cells{truncated}; these "
+ "cells stay missing (never auto-imputed) so they can be reviewed."
+ )
+
+
def fix_dtypes(df: pd.DataFrame, config: CleanConfig, report: CleanReport) -> pd.DataFrame:
"""Apply :func:`suggest_conversion` to every object/string column."""
from ..guard import hard_protected_columns # noqa: PLC0415 — cycle-safe lazy import
@@ -405,6 +455,7 @@ def fix_dtypes(df: pd.DataFrame, config: CleanConfig, report: CleanReport) -> pd
description = f"converted to {converted.dtype}"
if n_coerced:
description += f" ({n_coerced} unparseable value(s) set to missing)"
+ _record_coerced(str(col), df[col], converted, report)
report.add("fix_dtypes", description, column=str(col),
count=int(converted.notna().sum()) + n_coerced)
df[col] = converted
diff --git a/src/freshdata/textclean.py b/src/freshdata/textclean.py
index fa1847e..9ff1726 100644
--- a/src/freshdata/textclean.py
+++ b/src/freshdata/textclean.py
@@ -128,6 +128,10 @@ def __post_init__(self) -> None:
_ENTITY_TYPES = frozenset({
"person_name", "company_name", "entity_name", "city", "country", "address",
})
+#: Content-bearing types where typography *is* content: an em-dash, a curly
+#: quote or a prime mark (12″) in a product name or a comment carries meaning,
+#: so the punctuation→ASCII mapping is withheld for them.
+_CONTENT_TYPES = frozenset({"free_text", "text"}) | _ENTITY_TYPES
def config_for_field(
@@ -138,7 +142,9 @@ def config_for_field(
Structural types (numbers, identifiers, emails, dates, tickers…) keep only
lossless normalizations; entity names additionally never get punctuation
- stripped or case-folded to lower/upper. Free text passes ``base`` through.
+ stripped or case-folded to lower/upper. Free text and entity names also
+ keep their typography (em-dashes, curly quotes, primes) — the punctuation
+ mapping only runs on untyped or structural fields.
"""
cfg = base or TextCleanConfig()
if semantic_type in _STRUCTURAL_TYPES:
@@ -148,7 +154,10 @@ def config_for_field(
)
if semantic_type in _ENTITY_TYPES:
case = cfg.case if cfg.case == "title" else None
- return replace(cfg, remove_punctuation=False, case=case)
+ return replace(cfg, remove_punctuation=False, case=case,
+ normalize_punctuation=False)
+ if semantic_type in _CONTENT_TYPES:
+ return replace(cfg, normalize_punctuation=False)
return cfg
diff --git a/tests/benchmark/test_gauntlet.py b/tests/benchmark/test_gauntlet.py
new file mode 100644
index 0000000..7ec181e
--- /dev/null
+++ b/tests/benchmark/test_gauntlet.py
@@ -0,0 +1,55 @@
+"""Smoke tests for the Validation Gauntlet harness itself."""
+
+from __future__ import annotations
+
+import pandas as pd
+from benchmarks.gauntlet import build_fixture, compute_metrics, run_fixture
+from benchmarks.gauntlet.fixtures import FIXTURES
+from benchmarks.gauntlet.report import check_gates, render_markdown, results_payload
+
+SMOKE_ROWS = 80
+
+
+def test_fixtures_are_deterministic():
+ for name in FIXTURES:
+ a = build_fixture(name, SMOKE_ROWS, seed=7)
+ b = build_fixture(name, SMOKE_ROWS, seed=7)
+ pd.testing.assert_frame_equal(a.df, b.df)
+ assert a.cells == b.cells
+
+
+def test_fixture_labels_are_well_formed():
+ for name in FIXTURES:
+ fx = build_fixture(name, SMOKE_ROWS)
+ seen = set()
+ for c in fx.cells:
+ assert c.expect in ("preserve", "repair", "flag", "review")
+ key = (c.row, c.column)
+ assert key not in seen, f"{name}: duplicate label at {key}"
+ seen.add(key)
+ assert fx.df.columns.get_loc(c.column) >= 0
+ assert fx.dup_row_count > 0
+ assert len(fx.df) == fx.n_rows + fx.dup_row_count
+ # pristine() restores every labelled cell
+ pristine = fx.pristine()
+ for c in fx.cells:
+ got = pristine.iloc[c.row, pristine.columns.get_loc(c.column)]
+ assert (got == c.replaced) or (pd.isna(got) and pd.isna(c.replaced))
+
+
+def test_runner_and_gates_on_two_fixtures():
+ metrics = {}
+ for name in ("finance", "text"):
+ run = run_fixture(build_fixture(name, SMOKE_ROWS))
+ m = compute_metrics(run)
+ metrics[name] = m
+ assert m["corruption_count"] == 0
+ assert m["preservation_rate"] in (None, 1.0)
+ assert m["deterministic"]
+ assert m["audit_completeness"] == 1.0
+ assert m["detection"]["f1"] >= 0.85
+
+ payload = results_payload(metrics, n_rows=SMOKE_ROWS, seed=42)
+ assert check_gates(payload, baseline=None) == []
+ md = render_markdown(payload)
+ assert "| finance |" in md and "| text |" in md
diff --git a/tests/test_gauntlet_regressions.py b/tests/test_gauntlet_regressions.py
new file mode 100644
index 0000000..c552c8f
--- /dev/null
+++ b/tests/test_gauntlet_regressions.py
@@ -0,0 +1,229 @@
+"""Regression tests for defects surfaced by the Validation Gauntlet.
+
+Each test class documents one defect found by ``benchmarks/gauntlet`` and
+pins the corrected behaviour. See docs/validation-gauntlet.md.
+"""
+
+from __future__ import annotations
+
+import json
+
+import numpy as np
+import pandas as pd
+import pytest
+
+import freshdata as fd
+from freshdata import FieldSpec
+from freshdata.textclean import config_for_field
+
+
+def _numeric_with_stragglers(n: int = 200) -> pd.DataFrame:
+ """A mostly-numeric text price column plus a control column."""
+ rng = np.random.default_rng(0)
+ price = [f"{v:.2f}" for v in rng.uniform(10, 500, n)]
+ price[7] = "apple" # unparseable text — information, not noise
+ price[90] = "$1,200.50" # unparseable by pd.to_numeric
+ control = rng.uniform(0, 1, n).astype(object)
+ control[5] = None # one genuinely missing value
+ return pd.DataFrame({"price": price, "control": control})
+
+
+class TestCoercedCellsAreNotImputed:
+ """Defect: fix_dtypes coerced unparseable text to NaN and the auto engine
+ then imputed the median — 'apple' in a price column silently became a
+ fabricated number with no per-cell trace."""
+
+ def test_unparseable_values_stay_missing_for_review(self):
+ df = _numeric_with_stragglers()
+ out, report = fd.clean(df, return_report=True)
+ assert str(out["price"].dtype) in ("float64", "Float64")
+ assert pd.isna(out.loc[7, "price"]), "coerced junk must not be imputed"
+
+ def test_true_missing_values_are_still_imputed(self):
+ df = _numeric_with_stragglers()
+ out, _ = fd.clean(df, return_report=True)
+ assert not pd.isna(out.loc[5, "control"]), (
+ "genuine missing values keep the documented auto-impute behaviour")
+
+ def test_report_preserves_original_values_per_cell(self):
+ df = _numeric_with_stragglers()
+ _, report = fd.clean(df, return_report=True)
+ cells = report.coerced_cells.get("price")
+ assert cells, "report.coerced_cells must record the quarantined cells"
+ assert cells[7] == "apple"
+ assert list(cells) == [7], "the parseable '$1,200.50' is repaired, not quarantined"
+
+ def test_review_action_is_recorded(self):
+ df = _numeric_with_stragglers()
+ _, report = fd.clean(df, return_report=True)
+ review = [a for a in report.actions
+ if a.column == "price" and "unparseable" in a.description
+ and a.human_review]
+ assert review, "quarantine decision must appear in the audit trail"
+ assert review[0].rationale
+ assert review[0].count == 1
+
+ def test_sentinels_are_not_quarantined(self):
+ # 'N/A' is a documented null marker: it is *missing*, not junk, and the
+ # imputation contract for it is unchanged.
+ rng = np.random.default_rng(1)
+ df = pd.DataFrame({"v": [f"{x:.1f}" for x in rng.uniform(1, 9, 100)],
+ "pad": range(100)})
+ df.loc[3, "v"] = "N/A"
+ out, report = fd.clean(df, return_report=True)
+ assert not pd.isna(out.loc[3, "v"])
+ assert "v" not in report.coerced_cells
+
+ def test_invalid_dates_stay_nat(self):
+ rng = np.random.default_rng(2)
+ dates = [f"2025-01-{d:02d}" for d in rng.integers(1, 28, 100)]
+ dates[11] = "2023-02-30"
+ df = pd.DataFrame({"d": dates, "pad": range(100)})
+ out, report = fd.clean(df, return_report=True)
+ assert str(out["d"].dtype).startswith("datetime64")
+ assert pd.isna(out.loc[11, "d"])
+ assert report.coerced_cells["d"][11] == "2023-02-30"
+
+ def test_coerced_cells_serialize(self):
+ df = _numeric_with_stragglers()
+ _, report = fd.clean(df, return_report=True)
+ as_dict = report.to_dict()
+ assert as_dict["coerced_cells"]["price"]["7"] == "apple"
+ assert json.dumps(as_dict) # row labels / values must be JSON-safe
+
+ def test_explicit_impute_still_fills_everything(self):
+ df = _numeric_with_stragglers()
+ out = fd.clean(df, impute="median")
+ assert not out["price"].isna().any(), (
+ "an explicit impute= request overrides the quarantine default")
+
+
+class TestFormattedNumberRescue:
+ """Defect: the '$1,234.56' rescue in _try_numeric only ran when the plain
+ parse failed the threshold — formatted stragglers in a mostly-plain
+ numeric column were coerced to missing instead of parsed."""
+
+ def test_formatted_stragglers_are_parsed_not_quarantined(self):
+ df = _numeric_with_stragglers()
+ out, report = fd.clean(df, return_report=True)
+ assert out.loc[90, "price"] == pytest.approx(1200.50)
+ assert 90 not in report.coerced_cells.get("price", {})
+
+ def test_unparseable_text_is_still_quarantined(self):
+ df = _numeric_with_stragglers()
+ out, report = fd.clean(df, return_report=True)
+ assert pd.isna(out.loc[7, "price"])
+ assert report.coerced_cells["price"][7] == "apple"
+
+
+class TestValidateFieldsHandoff:
+ """Defect: fd.clean's contamination warning points users at
+ fd.validate_fields, but the consensus gate needed an 80% share while the
+ warning fires from 60% — the documented handoff found nothing."""
+
+ def test_readme_handoff_frame_reports_the_bad_cell(self):
+ df = pd.DataFrame({
+ "company": ["Apple", "Microsoft", "apple", "Tesla"],
+ "ticker": ["AAPL", "MSFT", "AAPL", "TSLA"],
+ "price": ["189.5", "402.1", "apple", "212.0"],
+ })
+ report = fd.validate_fields(df)
+ bad = [i for i in report.issues if i.column == "price"]
+ assert len(bad) == 1
+ assert bad[0].row == 2
+ assert bad[0].classification == "semantic_mismatch"
+
+ def test_large_minority_still_blocks_consensus(self):
+ # 60/40 mixed content is a legitimately mixed column, not contamination.
+ df = pd.DataFrame({"x": ["1", "2", "3", "a", "b", "4", "c", "5", "d", "6"]})
+ report = fd.validate_fields(df)
+ assert not [i for i in report.issues if i.column == "x"]
+
+
+class TestAllowedValuesBeatNullMarkers:
+ """Defect: a value explicitly present in allowed_values ('NA' = Namibia)
+ was swallowed by the generic null-marker heuristic before the vocabulary
+ was ever consulted."""
+
+ def test_na_in_vocabulary_is_a_value_not_a_null(self):
+ spec = FieldSpec(allowed_values=frozenset({"US", "DE", "NA"}),
+ required=True, nullable=False)
+ df = pd.DataFrame({"country": ["US", "NA", "DE", "US"]})
+ report = fd.validate_fields(df, schema={"country": spec})
+ assert not report.issues, (
+ "'NA' is explicitly allowed here and must not be treated as missing")
+
+ def test_na_outside_vocabulary_is_still_a_null_marker(self):
+ spec = FieldSpec(allowed_values=frozenset({"United States", "Germany"}),
+ required=True, nullable=False)
+ df = pd.DataFrame({"country": ["United States", "NA", "Germany", "Germany"]})
+ report = fd.validate_fields(df, schema={"country": spec})
+ assert [i for i in report.issues
+ if i.row == 1 and i.classification == "schema_violation"]
+
+
+class TestCaseVariantSuggestions:
+ """Gap: 'ACTIVE' against allowed {'active'} was silently accepted by the
+ casefold comparison; the canonical form should be suggested."""
+
+ def test_case_variant_gets_warning_and_suggestion(self):
+ spec = FieldSpec(allowed_values=frozenset({"active", "churned"}))
+ df = pd.DataFrame({"status": ["active", "ACTIVE", "churned"]})
+ report = fd.validate_fields(df, schema={"status": spec})
+ issues = [i for i in report.issues if i.row == 1]
+ assert len(issues) == 1
+ assert issues[0].severity == "warning"
+ assert issues[0].suggestion == "active"
+ assert issues[0].action == "accept_with_warning"
+
+ def test_exact_matches_stay_silent(self):
+ spec = FieldSpec(allowed_values=frozenset({"active", "churned"}))
+ df = pd.DataFrame({"status": ["active", "churned", "active"]})
+ report = fd.validate_fields(df, schema={"status": spec})
+ assert not report.issues
+
+
+class TestDateBounds:
+ """Gap: date fields had no range checks, so a future date of birth or an
+ 1875 admission date validated cleanly."""
+
+ def test_future_date_flagged(self):
+ spec = FieldSpec(semantic_type="date", max_value="2026-07-12")
+ df = pd.DataFrame({"dob": ["1980-02-01", "2031-05-01"]})
+ report = fd.validate_fields(df, schema={"dob": spec})
+ issues = [i for i in report.issues if i.row == 1]
+ assert len(issues) == 1
+ assert issues[0].classification == "domain_mismatch"
+
+ def test_min_bound(self):
+ spec = FieldSpec(semantic_type="date", min_value="1900-01-01")
+ df = pd.DataFrame({"admitted": ["1875-01-01", "2020-06-01"]})
+ report = fd.validate_fields(df, schema={"admitted": spec})
+ assert [i for i in report.issues if i.row == 0]
+
+ def test_in_range_dates_pass(self):
+ spec = FieldSpec(semantic_type="date",
+ min_value="1900-01-01", max_value="2026-12-31")
+ df = pd.DataFrame({"d": ["1980-02-01", "2020-06-01"]})
+ report = fd.validate_fields(df, schema={"d": spec})
+ assert not report.issues
+
+
+class TestContentTypesKeepTypography:
+ """Defect: normalize_punctuation rewrote em-dashes, curly quotes and prime
+ marks in free-text and entity-name fields — content, not noise."""
+
+ @pytest.mark.parametrize("ftype", ["free_text", "entity_name", "company_name"])
+ def test_field_config_withholds_punctuation_mapping(self, ftype):
+ assert config_for_field(ftype).normalize_punctuation is False
+
+ def test_free_text_column_keeps_typography(self):
+ df = pd.DataFrame({"notes": ["très bien — merci", "curly “quotes”",
+ 'Café Press — 12″ (limited)']})
+ out, _ = fd.clean_text(df, field_types={"notes": "free_text"})
+ assert out["notes"].tolist() == df["notes"].tolist()
+
+ def test_untyped_columns_keep_normalizing(self):
+ df = pd.DataFrame({"c": ["a — b"]})
+ out, _ = fd.clean_text(df)
+ assert out["c"].tolist() == ["a - b"]