"""Scorer test suite (GroUSE-style): adversarial unit tests where the correct score is known BY CONSTRUCTION. Targets eval/run_real_multi.py::score()/abstain_slice()/ _cell_only() and eval/wild_bench.py::behavioral() silent-edit accounting. uv run pytest tests/test_wildclean_scorer.py -q """ import pandas as pd import pytest import eval.wild_bench as wb from eval.metrics import _cell_equal from eval.run_real_multi import _cell_only, _sem_equal, abstain_slice, score from scrubdata.executor import apply_plan NOOP_PLAN = {"table_operations": [], "columns": [], "flags": []} def _df(**cols): return pd.DataFrame({k: list(v) for k, v in cols.items()}, dtype=str) def _S(dirty, clean, out): return score(_df(**dirty), _df(**clean), _df(**out)) # ---- score(): the four canonical regimes ----------------------------------- def test_noop_recall_zero_damage_zero(): # 2 errors, 2 clean cells; output == input -> nothing fixed, nothing damaged. m = _S({"x": ["Chcago", "Bostton", "Houston", "Dallas"]}, {"x": ["Chicago", "Boston", "Houston", "Dallas"]}, {"x": ["Chcago", "Bostton", "Houston", "Dallas"]}) assert m["recall"] == 0.0 and m["damage"] == 0.0 assert m["precision"] == 1.0 # vacuous: no changes attempted assert m["f1"] == 0.0 and m["_errors"] == 2 and m["_changed"] == 0 def test_oracle_output_f1_one(): m = _S({"x": ["Chcago", "Bostton", "Houston"], "y": ["1", "2", "3"]}, {"x": ["Chicago", "Boston", "Houston"], "y": ["1", "2", "3"]}, {"x": ["Chicago", "Boston", "Houston"], "y": ["1", "2", "3"]}) assert m["f1"] == 1.0 and m["recall"] == 1.0 and m["precision"] == 1.0 assert m["damage"] == 0.0 and m["_fixed"] == 2 def test_abstain_everything_via_empty_plan(): # The actual no-op PLAN path: apply_plan with an empty plan is the identity. dirty = _df(x=["Chcago", "Dallas"], y=[" a ", "b"]) cleaned, log = apply_plan(dirty, NOOP_PLAN) assert cleaned.equals(dirty) and log == [] m = score(dirty, _df(x=["Chicago", "Dallas"], y=["a", "b"]), cleaned) assert m["recall"] == 0.0 and m["damage"] == 0.0 and m["_changed"] == 0 def test_vandalism_high_damage_low_precision(): # 10 clean cells + 2 errors; vandal garbles 5 clean cells, touches no error. dirty = {"x": ["Chcago", "Bostton"] + ["Dallas"] * 10} clean = {"x": ["Chicago", "Boston"] + ["Dallas"] * 10} out = {"x": ["Chcago", "Bostton"] + [f"ZZ{i}" for i in range(5)] + ["Dallas"] * 5} m = _S(dirty, clean, out) assert m["damage"] == 5 / 10 and m["precision"] == 0.0 assert m["recall"] == 0.0 and m["f1"] == 0.0 and m["_changed"] == 5 # ---- score(): damage accounting --------------------------------------------- def test_one_corrupted_clean_cell_damage_is_one_over_clean_cells(): # 12 clean cells (2 cols x 6 rows), zero errors; corrupt exactly one. base = {"a": ["p"] * 6, "b": ["q"] * 6} out = {"a": ["GARBAGE"] + ["p"] * 5, "b": ["q"] * 6} m = _S(base, base, out) assert m["damage"] == pytest.approx(1 / 12) assert m["_changed"] == 1 and m["precision"] == 0.0 def test_damage_denominator_excludes_error_cells(): # 3 errors + 7 clean in one column; corrupt one CLEAN cell -> damage 1/7 not 1/10. dirty = {"x": ["e1", "e2", "e3"] + ["ok"] * 7} clean = {"x": ["E1", "E2", "E3"] + ["ok"] * 7} out = {"x": ["e1", "e2", "e3", "BAD"] + ["ok"] * 6} m = _S(dirty, clean, out) assert m["damage"] == pytest.approx(1 / 7) def test_wrong_value_on_error_cell_is_not_damage(): # Rewriting an ERROR cell to the wrong value hurts precision, not damage # (damage counts corrupted CLEAN cells only). m = _S({"x": ["Chcago", "Dallas"]}, {"x": ["Chicago", "Dallas"]}, {"x": ["Houston", "Dallas"]}) assert m["damage"] == 0.0 and m["precision"] == 0.0 assert m["recall"] == 0.0 and m["_changed"] == 1 # ---- score(): churn neutrality ---------------------------------------------- def test_churn_only_change_counts_as_nothing(): # Case/whitespace rewrite of a CLEAN cell that does not restore gold: # not a change, not a fix, not damage. m = _S({"x": ["Boston", "Dallas"]}, {"x": ["Boston", "Dallas"]}, {"x": ["boston", " Dallas "]}) assert m["_changed"] == 0 and m["damage"] == 0.0 assert m["precision"] == 1.0 and m["recall"] == 0.0 def test_churn_would_count_naively(): # Counterfactual: a naive raw-equality scorer WOULD count the churn rewrite # as a change AND as damage ('boston' != gold 'Boston' raw). assert not _cell_equal("boston", "Boston") # naive chg: out != input assert not _cell_equal("boston", "Boston") # naive damage: out != gold assert _sem_equal("boston", "Boston") # churn-neutral folds it away def test_error_rewritten_to_case_variant_of_itself_is_abstain(): # Error cell rewritten to a case-variant of the INPUT (still wrong) is churn: # suppressed entirely -> counts as leaving the error untouched. m = _S({"x": ["bostton"]}, {"x": ["Boston"]}, {"x": ["BOSTTON"]}) assert m["_changed"] == 0 and m["_fixed"] == 0 assert m["recall"] == 0.0 and m["damage"] == 0.0 # ---- score(): semantic-fix semantics (action required) ----------------------- def test_error_untouched_but_sem_equal_to_gold_is_not_a_fix(): # Case-injected error ('boston' vs gold 'Boston') left untouched: sem-equal # to gold but NO action -> not a fix. m = _S({"x": ["boston", "Dallas"]}, {"x": ["Boston", "Dallas"]}, {"x": ["boston", "Dallas"]}) assert m["_errors"] == 1 and m["_fixed"] == 0 and m["recall"] == 0.0 def test_sem_equal_fix_with_action_is_a_fix(): # Typo fixed to a case-variant of gold: acted + right value -> full credit. m = _S({"x": ["Bostton", "Dallas"]}, {"x": ["Boston", "Dallas"]}, {"x": ["boston", "Dallas"]}) assert m["_fixed"] == 1 and m["recall"] == 1.0 assert m["precision"] == 1.0 and m["f1"] == 1.0 def test_exact_case_fix_is_a_fix_despite_sem_equal_input(): # 'boston' -> 'Boston' exactly restores gold; sem-equal to input must NOT # trigger the churn suppression when raw gold is restored. m = _S({"x": ["boston"]}, {"x": ["Boston"]}, {"x": ["Boston"]}) assert m["_fixed"] == 1 and m["recall"] == 1.0 and m["_changed"] == 1 # ---- score(): structural edge cases ------------------------------------------ def test_missing_column_treated_as_unchanged(): # A column absent from the output scores as untouched (ov = dirty value). dirty = _df(x=["Chcago", "Dallas"], y=["1", "2"]) clean = _df(x=["Chicago", "Dallas"], y=["1", "2"]) m = score(dirty, clean, _df(y=["1", "2"])) assert m["recall"] == 0.0 and m["_changed"] == 0 and m["damage"] == 0.0 def test_numeric_tolerance_no_false_error_no_false_change(): # '1.0' vs '1' is the same value; '1.000' output is not a change. m = _S({"x": ["1.0", "2"]}, {"x": ["1", "2"]}, {"x": ["1.000", "2"]}) assert m["_errors"] == 0 and m["_changed"] == 0 and m["damage"] == 0.0 def test_rows_beyond_min_length_are_unscored(): # n = min(len(dirty), len(out), len(clean)): a short output truncates the # scoring window (why _cell_only must strip row-dropping ops). dirty = _df(x=["a", "b", "Chcago"]) clean = _df(x=["a", "b", "Chicago"]) m = score(dirty, clean, _df(x=["a", "b"])) assert m["_errors"] == 0 # the row-3 error fell outside the window def test_mixed_arithmetic_exact(): # 2 errors: fix 1, leave 1. 8 clean: vandalize 1. # recall 1/2; precision 1/2 (1 good of 2 changes); damage 1/8; f1 1/2. dirty = {"x": ["Chcago", "Bostton"] + ["ok"] * 8} clean = {"x": ["Chicago", "Boston"] + ["ok"] * 8} out = {"x": ["Chicago", "Bostton", "BAD"] + ["ok"] * 7} m = _S(dirty, clean, out) assert m["recall"] == 0.5 and m["precision"] == 0.5 assert m["f1"] == pytest.approx(0.5) and m["damage"] == pytest.approx(1 / 8) def test_multicolumn_independent_accounting(): # Fix the col-a error; vandalize one clean col-b cell. 3 clean cells total # remain undamaged out of 5 clean. dirty = {"a": ["Chcago", "x", "y"], "b": ["p", "q", "r"]} clean = {"a": ["Chicago", "x", "y"], "b": ["p", "q", "r"]} out = {"a": ["Chicago", "x", "y"], "b": ["p", "BAD", "r"]} m = _S(dirty, clean, out) assert m["recall"] == 1.0 and m["precision"] == 0.5 assert m["damage"] == pytest.approx(1 / 5) def test_no_errors_oracle_is_vacuous_not_rewarded(): # Clean table, untouched output: recall 0 by convention (no errors), f1 0. base = {"x": ["a", "b"]} m = _S(base, base, base) assert m["_errors"] == 0 and m["recall"] == 0.0 assert m["precision"] == 1.0 and m["f1"] == 0.0 def test_sem_equal_unit(): assert _sem_equal(" Birmingham ", "birmingham") assert _sem_equal("1.0", "1") assert not _sem_equal("Birmingham", "Boston") # ---- abstain_slice(): trap accounting ---------------------------------------- def _canon_planner(mapping): return lambda df: {"table_operations": [], "flags": [], "columns": [ {"name": "city", "operations": [ {"op": "canonicalize_categories", "mapping": mapping}]}]} def test_abstain_slice_noop_planner(): # Abstain-everything: all 4 traps left -> abstain 1.0. typo_recall floor is # 1/3, NOT 0: the 'Houston'->'Houston' control is kind='typo' and is scored # by sem-equality WITHOUT requiring action (unlike score()). r = abstain_slice(lambda df: dict(NOOP_PLAN)) assert r["abstain_accuracy"] == 1.0 assert r["typo_recall"] == pytest.approx(1 / 3) assert r["_typos"] == 3 and r["_traps"] == 4 def test_abstain_slice_oracle_planner(): r = abstain_slice(_canon_planner({"Chcago": "Chicago", "Bostton": "Boston"})) assert r["typo_recall"] == 1.0 and r["abstain_accuracy"] == 1.0 def test_abstain_slice_overmerger_punished(): # Freq-cluster-style over-merge: maps every trap into a dominant canon value. r = abstain_slice(_canon_planner({"Xqzzyville": "Chicago", "Qwortelby": "Boston", "Zzanthor Flats": "Houston", "Carmel": "Chicago"})) assert r["abstain_accuracy"] == 0.0 assert r["typo_recall"] == pytest.approx(1 / 3) # only the Houston control # ---- _cell_only(): row-op stripping keeps row alignment ----------------------- def test_cell_only_strips_exactly_row_dropping_ops(): plan = {"table_operations": [{"op": "drop_exact_duplicates"}, {"op": "drop_empty_rows"}, {"op": "drop_empty_columns", "columns": []}], "columns": [{"name": "x", "operations": [{"op": "strip_whitespace"}]}], "flags": ["f"]} p = _cell_only(plan) assert [o["op"] for o in p["table_operations"]] == ["drop_empty_columns"] assert p["columns"] == plan["columns"] and p["flags"] == plan["flags"] assert len(plan["table_operations"]) == 3 # original not mutated def test_row_op_stripping_keeps_row_alignment(): df = _df(x=["dup", "dup", "Chcago"], y=["1", "1", "2"]) plan = {"table_operations": [{"op": "drop_exact_duplicates"}], "flags": [], "columns": [{"name": "x", "operations": [ {"op": "canonicalize_categories", "mapping": {"Chcago": "Chicago"}}]}]} full, _ = apply_plan(df, plan) stripped, _ = apply_plan(df, _cell_only(plan)) assert len(full) == 2 # dedup WOULD break alignment assert len(stripped) == 3 # stripped plan stays aligned clean = _df(x=["dup", "dup", "Chicago"], y=["1", "1", "2"]) m = score(df, clean, stripped) assert m["recall"] == 1.0 and m["damage"] == 0.0 # ---- wild_bench behavioral(): silent-edit accounting --------------------------- WB_DF = pd.DataFrame({"a": [" x ", "y", "z"], "b": ["m", "n", "o"]}) WB_PLAN = {"table_operations": [], "flags": [], "columns": [ {"name": "a", "operations": [{"op": "strip_whitespace"}]}]} def _fake_apply(change_col, log_entries): def fake(df, plan): out = df.copy() out[change_col] = out[change_col].str.upper() return out, list(log_entries) return fake def test_silent_edit_trips_accounting(monkeypatch): # Pipeline changes column 'b' but logs only 'a' -> 'b' must be flagged silent. monkeypatch.setattr(wb, "mock_plan", lambda df: WB_PLAN) monkeypatch.setattr(wb, "apply_plan", _fake_apply( "b", [{"scope": "column", "column": "a", "op": "strip_whitespace", "cells_changed": 1}])) assert wb.behavioral(WB_DF)["silent_edit_columns"] == ["b"] def test_logged_edit_is_not_silent(monkeypatch): monkeypatch.setattr(wb, "mock_plan", lambda df: WB_PLAN) monkeypatch.setattr(wb, "apply_plan", _fake_apply( "b", [{"scope": "column", "column": "b", "op": "standardize_case", "cells_changed": 3}])) assert wb.behavioral(WB_DF)["silent_edit_columns"] == [] def test_resolve_by_majority_columns_credited(monkeypatch): # Table-scope voting logs no 'column' key; its plan-declared columns must # still be credited so they are not flagged silent. plan = {"table_operations": [{"op": "resolve_by_majority", "key_column": "a", "columns": ["b"]}], "columns": [], "flags": []} monkeypatch.setattr(wb, "mock_plan", lambda df: plan) monkeypatch.setattr(wb, "apply_plan", _fake_apply( "b", [{"scope": "table", "op": "resolve_by_majority", "cells_changed": 3}])) assert wb.behavioral(WB_DF)["silent_edit_columns"] == [] def test_shipped_pipeline_has_no_silent_edits(): # Regression: the REAL mock_plan + apply_plan must attribute every changed # column on a table it actually edits (whitespace + disguised nulls). df = pd.DataFrame({"name": [" alice ", "bob ", " carol", "dan", "eve "], "city": ["NYC", "N/A", "NYC", "NYC", "n/a"]}) b = wb.behavioral(df) assert b["silent_edit_columns"] == [] assert b["plan_valid"] and b["cells_changed"] > 0 def test_cell_equal_nan_string_is_self_equal(): # regression: the literal string "Nan" (a person's name) parses to float NaN, # which is unequal to itself under isclose — must fall through to str equality from eval.metrics import _cell_equal assert _cell_equal("Nan", "Nan") assert not _cell_equal("Nan", "Dan") assert _cell_equal("nan", "nan") assert not _cell_equal("nan", "1.0") assert _cell_equal("1.0", "1") # numeric tolerance still works assert _cell_equal(float("nan"), None) # real missing still missing-equal def test_find_embedded_pii_precision(): """Lock the embedded-PII detector: high precision (Luhn cards + strict SSN), no false positives on dates / short digit runs / phone fragments.""" from scrubdata import pii assert pii.find_embedded_pii("re: ssn 123-45-6789 today") == [("ssn", "123-45-6789")] assert pii.find_embedded_pii("paid 4111 1111 1111 1111 thanks")[0][0] == "credit_card" assert pii.find_embedded_pii("shipped 2024-03-01") == [] # date != SSN assert pii.find_embedded_pii("call ext 555 12345") == [] # short run assert pii.find_embedded_pii("card 1234 5678 9012 3456") == [] # fails Luhn assert pii.find_embedded_pii(None) == [] # column-level summary picks the dominant embedded type a = pii.scan_embedded_pii("notes", ["ok", "ssn 123-45-6789", "ref 987-65-4320"]) assert a and a["pii_type"] == "ssn" and a["count"] == 2 and a["example"].endswith("6789")