diff --git a/tests/test_policy_guard_mutants.py b/tests/test_policy_guard_mutants.py new file mode 100644 index 0000000..99e0c13 --- /dev/null +++ b/tests/test_policy_guard_mutants.py @@ -0,0 +1,155 @@ +"""Properties the semantic decision core had no test for. + +Found by mutation testing the two modules where a surviving mutant is most +alarming: the policy gate that decides whether a repair is applied, and the +guard that enforces protected-column byte-identity. Nineteen targeted mutants +(comparison flips, boolean-operator swaps, constant flips) produced a **63.2% +kill rate**, and seven survived the entire 7120-test suite -- four of them in +the identifier carve-out and column protection. + +Each test below kills a specific confirmed survivor. The mutation it defeats is +named, so if the assertion is ever weakened the reason is on the page. +""" + +from __future__ import annotations + +import pandas as pd +import pytest + +from freshdata.guard import _cell_equal, snapshot_protected +from freshdata.semantic.policy import _payload_preserving, _protection +from freshdata.semantic.types import SemanticColumnInfo, SemanticContext, SemanticProposal + + +def _proposal(column="code", raw="A-1", proposed="A1", issue_type="format_alignment"): + return SemanticProposal( + column=column, raw_value=raw, proposed_value=proposed, + issue_type=issue_type, expert="t", confidence=0.99, risk="low", rationale="t", + ) + + +def _info(**kw) -> SemanticColumnInfo: + base = { + "name": "code", "role": "categorical", "n_nonnull": 3, "nunique": 3, + "high_cardinality": False, "preserve": False, "free_text": False, + "numeric_like": False, "boolean_like": False, "money_like": False, + "unit_like": False, "identifier_like": False, + } + base.update(kw) + return SemanticColumnInfo(**base) + + +def _ctx(columns=None, *, id_columns=(), preserve_columns=(), target=None): + return SemanticContext( + dataset=None, columns=columns or {}, auto_threshold=0.95, review_threshold=0.70, + max_distinct_values=50, sample_size=1000, privacy_policy="none", mode="auto", + id_columns=frozenset(id_columns), preserve_columns=frozenset(preserve_columns), + target_column=target, + ) + + +# -- the identifier carve-out cannot smuggle a content change --------------- + + +def test_a_payload_changing_alignment_is_not_payload_preserving(): + """Kills policy bool#4 (`and` -> `or`). + + With `or`, any non-empty raw payload satisfied the check and a proposal + that *changed* the identifying content would pass the identifier + carve-out -- exactly what the docstring says it prevents. + """ + assert _payload_preserving(_proposal(raw="A-1", proposed="A-2")) is False + assert _payload_preserving(_proposal(raw="007", proposed="7")) is False + + +def test_a_genuinely_payload_preserving_alignment_still_passes(): + """The carve-out must keep working; separators and composition may change.""" + assert _payload_preserving(_proposal(raw="A-1", proposed="A1")) is True + + +@pytest.mark.parametrize( + ("raw", "proposed"), [(7, "7"), ("7", 7), (None, "7"), (7.0, 7.0)] +) +def test_a_non_string_pair_is_never_payload_preserving(raw, proposed): + """Kills policy bool#1 (`or` -> `and`) and const_bool#2 (`False` -> `True`). + + Both mutants let a non-string pair reach the payload comparison, where the + early return exists precisely to stop it. + """ + assert _payload_preserving(_proposal(raw=raw, proposed=proposed)) is False + + +def test_an_empty_payload_is_not_preserving(): + """`bool(raw_payload)` guards the all-punctuation case.""" + assert _payload_preserving(_proposal(raw="---", proposed="___")) is False + + +# -- column protection needs only one reason, not both ---------------------- + + +def test_the_target_column_is_protected_by_name_alone(): + """Kills policy bool#8 (`or` -> `and`). + + A column named as `target_column` must be protected even when its inferred + role is not "target"; requiring both dropped the protection. + """ + ctx = _ctx(columns={"code": _info(role="categorical")}, target="code") + assert _protection(_proposal(), ctx) == "target column is never modified" + + +def test_the_target_column_is_protected_by_inferred_role_alone(): + ctx = _ctx(columns={"code": _info(role="target")}, target=None) + assert _protection(_proposal(), ctx) == "target column is never modified" + + +def test_preserve_columns_protects_by_name_alone(): + """Kills policy bool#12 (`or` -> `and`). + + A column listed in `preserve_columns` must be protected even when the + column info carries no `preserve` flag. + """ + ctx = _ctx(columns={"code": _info(preserve=False)}, preserve_columns=("code",)) + assert _protection(_proposal(), ctx) == "column is in preserve_columns" + + +def test_preserve_columns_protects_by_column_info_alone(): + ctx = _ctx(columns={"code": _info(preserve=True)}, preserve_columns=()) + assert _protection(_proposal(), ctx) == "column is in preserve_columns" + + +# -- the protected-column snapshot must be independent ---------------------- + + +def test_the_protected_snapshot_does_not_share_data_with_the_frame(): + """Kills guard const_bool#4 (`deep=True` -> `deep=False`). + + The snapshot is what `verify_protected` compares against. If it shared the + frame's buffer, an in-place mutation would change the snapshot too and the + violation it exists to catch would go unnoticed. + """ + df = pd.DataFrame({"keep": [1, 2, 3], "other": [4, 5, 6]}) + snapshot = snapshot_protected(df, ["keep"]) + df.loc[0, "keep"] = 999 + assert snapshot["keep"].tolist() == [1, 2, 3], "snapshot tracked an in-place edit" + + +# -- documented behaviour of the missing-aware comparison ------------------- + + +@pytest.mark.parametrize( + ("left", "right", "equal"), + [(None, None, True), (float("nan"), float("nan"), True), + (float("nan"), None, True), (float("nan"), 5, False), (5, 5, True), (5, 6, False)], +) +def test_cell_equality_treats_two_missing_values_as_equal(left, right, equal): + """Note: `guard bool#5` (`if left_na or right_na` -> `and`) is an + **equivalent mutant** and is deliberately not chased. + + Flipping that operator only changes the one-sided-missing branch, and there + the fall-through comparison returns False anyway -- the same answer the + early return gives. Verified across 289 input pairs (None, nan, NaT, pd.NA, + ints, strings, bools, lists, dicts, ndarray, inf, bytes): **zero** + behavioural differences. It cannot be killed because it does not change + behaviour, so a test written to chase it would be asserting nothing. + """ + assert _cell_equal(left, right) is equal