From 0afe12068c39d2c6ef0a9eb2a12d8b8c93f50db7 Mon Sep 17 00:00:00 2001 From: Kevin Costner <120246174+kevincostner17@users.noreply.github.com> Date: Sun, 20 Sep 2026 22:37:46 +0530 Subject: [PATCH] test(semantic): close the decision gate, 69.4% -> 100% (and correct #490) PR #490 reported "19 mutants, 94.7%" for policy.py + guard.py. That was measured over 19 of the module pair's 62 mutation sites, because of an indexing defect in the mutation harness, so it described roughly a third of the module rather than the module. The true figure was 69.4% with 19 survivors. This adds 30 tests and takes it to 62/62 with no survivors. The survivors included the most safety-critical mutants found anywhere in this work: is_id = column in ctx.id_columns or (info is not None and info.identifier_like) flipped to `and` narrows identifier protection so a column detected as identifier-like, but not explicitly listed, silently loses it. The gate's own review-threshold comparison was movable. `format_alignment and _payload_preserving` flipped to `or` would let a non-payload-preserving repair through. In guard.py the row-count check was movable, and the NA comparison could be flipped so the byte-identity guard misses a violation. It also corrects a false claim this file shipped in #490. The docstring asserted that flipping `if left_na or right_na` to `and` was an equivalent mutant, "verified across 289 input pairs ... zero behavioural differences. It cannot be killed." It can. The pool held only ordinary scalars and no cell whose __eq__ returns True against anything; for such a cell the fall-through comparison returns True, so the mutant reports a missing value and a present one as equal and the final identifier-protection layer misses the violation. A 1296-pair differential finds 18 such disagreements. The mutant is now killed and the docstring says what went wrong. That note also cited mutant ids, which are positional and did not survive the harness fix: its `bool#5` is today's `bool#2`. The corrected docstring quotes the source line instead. Tests only; no src change. --- tests/test_guard_byte_identity_mutants.py | 221 +++++++++++++ tests/test_policy_gate_mutants.py | 360 ++++++++++++++++++++++ tests/test_policy_guard_mutants.py | 30 +- 3 files changed, 602 insertions(+), 9 deletions(-) create mode 100644 tests/test_guard_byte_identity_mutants.py create mode 100644 tests/test_policy_gate_mutants.py diff --git a/tests/test_guard_byte_identity_mutants.py b/tests/test_guard_byte_identity_mutants.py new file mode 100644 index 0000000..0bd9ede --- /dev/null +++ b/tests/test_guard_byte_identity_mutants.py @@ -0,0 +1,221 @@ +"""Byte-identity properties of the protected-column guard that nothing pinned. + +``freshdata.guard`` is the last of five independent layers that keep an +identifier column (``"007"``, ``"0001"``) from being silently rewritten: it +compares every hard-protected column against a deep snapshot taken before +cleaning and raises :class:`ProtectedColumnError` unless the column came back +**byte-identical**. Because it is the last layer, a weakened check here is not +caught by anything downstream — the corrupted frame is simply returned. + +Each test below kills one confirmed mutation survivor in ``guard.py`` and names +it, so a future weakening of the assertion has to argue with the reason. The +most serious of them is ``bool#2``: flipping ``if left_na or right_na`` to +``and`` makes the one-sided-missing branch fall through to ``==``, and a cell +type with a permissive ``__eq__`` then compares *equal to a missing value* — +the guard would pass a column it exists to reject. Note that +``tests/test_policy_guard_mutants.py`` documents this operator (under its older +number ``bool#5``) as a proven-equivalent mutant; see +:func:`test_a_missing_cell_swapped_for_a_permissive_object_is_a_violation` for +the counter-example that refutes that claim. +""" + +from __future__ import annotations + +from unittest.mock import ANY + +import numpy as np +import pandas as pd +import pytest + +from freshdata.guard import ( + ProtectedColumnError, + _cell_equal, + _is_missing, + _series_identical, + snapshot_protected, + verify_protected, +) +from freshdata.report import CleanReport + + +class _MatchesAnything: + """A cell whose ``__eq__`` is permissive, like :data:`unittest.mock.ANY`. + + Object columns may hold arbitrary values (the guard's own comment concedes + that "exotic cell types compare however they like"): wildcard sentinels, + matcher objects and lazy expression objects all answer ``True`` to ``==``. + """ + + def __eq__(self, other: object) -> bool: + return True + + def __hash__(self) -> int: + return 0 + + def __repr__(self) -> str: + return "" + + +class _ReportWithoutAdd: + """A report-shaped object that does not implement ``add`` (duck-typing).""" + + def __init__(self) -> None: + self.actions: list[object] = [] + + +# -- the row-count branch must not realign an equal-length column ----------- + + +def test_a_reordered_protected_column_of_equal_length_is_a_violation(): + """Kills guard cmp#6 (``len(after) < len(before)`` -> ``<=``). + + The branch exists only for row-*dropping* steps; at equal length it must + not run, because it realigns the snapshot with ``before.loc[after.index]``. + With ``<=`` a protected column whose rows were permuted (values now sitting + against different rows of the frame — a real corruption) is realigned back + onto the snapshot and compares identical, so the guard reports nothing. + """ + before = pd.Series(["007", "008", "009"], index=[0, 1, 2]) + shuffled = pd.Series(["009", "007", "008"], index=[2, 0, 1]) + assert len(shuffled) == len(before) + assert before.loc[shuffled.index].equals(shuffled), ( + "premise: realignment would hide this reorder" + ) + assert _series_identical(before, shuffled) is not None + + df = pd.DataFrame({"cust_id": before}) + snapshot = snapshot_protected(df, ("cust_id",)) + with pytest.raises(ProtectedColumnError, match="cust_id"): + verify_protected(pd.DataFrame({"cust_id": shuffled}), snapshot) + + +def test_row_drops_with_a_unique_index_still_realign_by_label(): + """The legitimate case cmp#6 protects must keep working.""" + before = pd.Series(["007", "008", "009"], index=[0, 1, 2]) + assert _series_identical(before, before.loc[[0, 2]]) is None + + +# -- one-sided missingness is a difference, whatever ``==`` says ------------ + + +def test_a_missing_cell_swapped_for_a_permissive_object_is_a_violation(): + """Kills guard bool#2 (``if left_na or right_na`` -> ``and``). + + With ``and``, a cell that is missing on one side only skips the early + return and is compared with ``==``. A value whose ``__eq__`` returns + ``True`` then reads as *equal to a missing value*, and the guard passes a + protected column whose missing cell was replaced by a real value — the + guard missing a violation, which is strictly worse than a false alarm. + + This refutes the equivalence claimed for this operator in + ``tests/test_policy_guard_mutants.py``: a 1296-pair differential over + ``None``/``nan``/``NaT``/``pd.NA``/``NaT64``/``Decimal('NaN')``/ints/ + floats/bools/strings/bytes/containers/ndarrays/timestamps found the + original and the mutant agreeing everywhere *except* on permissive-``__eq__`` + cells, where they disagree in both argument orders. + """ + assert _cell_equal(float("nan"), _MatchesAnything()) is False + assert _cell_equal(_MatchesAnything(), None) is False + assert _cell_equal(pd.NaT, ANY) is False # the stdlib's own wildcard cell + + before = pd.Series([np.nan, np.nan], index=[0, 0], dtype=object) + after = pd.Series([_MatchesAnything()], index=[0], dtype=object) + assert _series_identical(before, after) is not None + + +def test_two_missing_cells_are_still_equal_after_a_row_drop(): + """The early return's real job (nan == nan) must survive the tightening.""" + before = pd.Series([np.nan, "007", None], index=[0, 0, 0], dtype=object) + assert _series_identical(before, pd.Series([None, "007"], index=[0, 0])) is None + + +# -- containers are values, not missing markers ----------------------------- + + +def test_container_cells_are_never_treated_as_missing(): + """Kills guard const_bool#4 (``_is_missing`` fallback ``False`` -> ``True``). + + ``pd.isna`` returns an *array* for a container, so ``bool()`` raises and the + fallback decides. Calling containers "missing" makes every pair of them + compare equal (both sides missing), so a protected object column of lists + or dicts could be rewritten wholesale without the guard noticing. + """ + assert _is_missing([1, 2]) is False + assert _is_missing({"a": 1}) is False + assert _cell_equal([1, 2], [3, 4]) is False + assert _cell_equal({"a": 1}, {"a": 2}) is False + + before = pd.Series([[1, 2], [3, 4], [5, 6]], index=[0, 0, 0], dtype=object) + after = pd.Series([[9, 9], [5, 6]], index=[0, 0], dtype=object) + assert _series_identical(before, after) is not None + + +# -- the audit record itself ------------------------------------------------ + + +def test_a_violation_is_recorded_as_a_zero_count_high_risk_action(): + """Kills guard const_num#6 (``count=0`` -> ``1``) and const_bool#5 + (``human_review=True`` -> ``False``). + + The violation record is user-visible audit output: ``count`` is the number + of cells the step *changed*, and the guard changes nothing — it refuses. + A count of 1 would read as "the guard edited one cell". ``human_review`` + is what makes the record surface in review workflows; a guard violation is + an executor bug and must never be filed as unremarkable. + """ + df = pd.DataFrame({"rev": ["1000", "2000"]}) + snapshot = snapshot_protected(df, ("rev",)) + broken = pd.DataFrame({"rev": ["1000", "2,000"]}) + report = CleanReport() + + with pytest.raises(ProtectedColumnError, match="rev"): + verify_protected(broken, snapshot, report) + + (action,) = [a for a in report.actions if a.step == "guard"] + assert action.count == 0 + assert action.human_review is True + assert action.risk == "high" + assert action.status == "skipped" + assert action.column == "rev" + assert action.metadata["protected_column_violation"]["column"] == "rev" + + +def test_the_clean_verification_is_recorded_as_a_zero_count_action(): + """Kills guard const_num#5 (``count=0`` -> ``1``). + + The success record states a guarantee that was *checked*; it never changed + a cell, so a non-zero count would misreport an untouched column as edited + (and the number would be the column count, not a cell count). + """ + df = pd.DataFrame({"rev": ["1000", "2000"], "sku": ["007", "008"]}) + snapshot = snapshot_protected(df, ("rev", "sku")) + report = CleanReport() + + verify_protected(df, snapshot, report) + + (action,) = [a for a in report.actions if a.step == "guard"] + assert action.count == 0 + assert action.description == "verified 2 protected column(s) byte-identical" + assert action.metadata["protected_columns"] == ["rev", "sku"] + + +def test_a_report_object_without_add_cannot_break_the_raise(): + """Kills guard bool#7 (``report is not None and hasattr(...)`` -> ``or``). + + Both halves are required: ``or`` calls ``add`` on any non-``None`` object, + so a caller passing a report-shaped object that does not implement ``add`` + gets an ``AttributeError`` from inside the recorder instead of the + :class:`ProtectedColumnError` that says the data was corrupted — the guard + still stops the frame, but the diagnosis is destroyed. + """ + df = pd.DataFrame({"rev": ["1000", "2000"]}) + snapshot = snapshot_protected(df, ("rev",)) + broken = pd.DataFrame({"rev": ["1000", "9999"]}) + + with pytest.raises(ProtectedColumnError, match="rev"): + verify_protected(broken, snapshot, _ReportWithoutAdd()) + with pytest.raises(ProtectedColumnError, match="dropped"): + verify_protected(pd.DataFrame({"other": [1, 2]}), snapshot, _ReportWithoutAdd()) + # ``None`` stays the documented no-report path. + with pytest.raises(ProtectedColumnError, match="rev"): + verify_protected(broken, snapshot, None) diff --git a/tests/test_policy_gate_mutants.py b/tests/test_policy_gate_mutants.py new file mode 100644 index 0000000..323be94 --- /dev/null +++ b/tests/test_policy_gate_mutants.py @@ -0,0 +1,360 @@ +"""Properties of the semantic policy gate (`policy.decide`) that had no test. + +`decide()` is the single place where a proposed repair becomes ``apply``, +``suggest`` or ``skip``. Mutation testing `src/freshdata/semantic/policy.py` +left a cluster of survivors inside that gate: the identifier carve-out, the +review-threshold boundary, and every ``human_review`` flag the gate hands to +the audit trail. A surviving mutant there is not cosmetic -- it silently +widens what the gate is willing to mutate, or drops the "a human must look at +this" marker from a skipped row. + +Each test names the mutation it defeats, so if an assertion is ever weakened +the reason it existed is on the page. Companion file: +``tests/test_policy_guard_mutants.py`` (payload-preservation and the +target/preserve branches of ``_protection``); the cases here do not repeat it. +""" + +from __future__ import annotations + +import pytest + +from freshdata.config import CleanConfig +from freshdata.semantic.policy import _protection, decide +from freshdata.semantic.types import ( + SemanticColumnInfo, + SemanticContext, + SemanticProposal, +) + +IDENTIFIER_VETO = "identifier column is protected (set mutable=True in semantic_context to allow)" + +CFG = CleanConfig(verbose=False) + + +def _proposal( + column="code", + raw="A-1", + proposed="A1", + issue_type="format_alignment", + *, + confidence=0.99, + risk="low", + rationale="t", +): + return SemanticProposal( + column=column, + raw_value=raw, + proposed_value=proposed, + issue_type=issue_type, + expert="t", + confidence=confidence, + risk=risk, + rationale=rationale, + ) + + +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, + mode="auto", + auto_threshold=0.95, + review_threshold=0.70, +): + return SemanticContext( + dataset=None, + columns=columns or {}, + auto_threshold=auto_threshold, + review_threshold=review_threshold, + max_distinct_values=50, + sample_size=1000, + privacy_policy="none", + mode=mode, + id_columns=frozenset(id_columns), + preserve_columns=frozenset(preserve_columns), + target_column=target, + ) + + +# -- identifier protection may not be narrowed to the explicit list --------- + + +def test_a_detected_identifier_is_protected_without_being_listed(): + """Kills policy bool#8 (`or` -> `and`) in the ``is_id`` expression. + + This is the "ID-protection removed" class. With `and`, a column only + counted as an identifier when it was BOTH listed in ``id_columns`` AND + detected as identifier-like -- so a key column the profiler recognised on + its own, which the user never had to enumerate, lost its veto entirely and + became mutable by any expert. + """ + ctx = _ctx(columns={"code": _info(identifier_like=True)}, id_columns=()) + proposal = _proposal(issue_type="category_synonym", raw="A-1", proposed="B-2") + assert _protection(proposal, ctx) == IDENTIFIER_VETO + decision = decide(proposal, CFG, ctx) + assert (decision.action, decision.status) == ("skip", "skipped") + assert decision.reason == IDENTIFIER_VETO + + +def test_a_listed_identifier_is_protected_without_any_column_info(): + """Also kills policy bool#8 from the other side. + + ``id_columns`` is the user's explicit declaration; it must hold even for a + column the profiler built no :class:`SemanticColumnInfo` for. + """ + ctx = _ctx(columns={}, id_columns=("code",)) + proposal = _proposal(issue_type="category_synonym", raw="A-1", proposed="B-2") + assert _protection(proposal, ctx) == IDENTIFIER_VETO + assert decide(proposal, CFG, ctx).action == "skip" + + +def test_an_identifier_is_opted_in_only_by_mutable_being_exactly_true(): + """Kills policy bool#10 (`and` -> `or`) in the ``mutable is True`` opt-in. + + With `or`, the first operand (``info is not None``) short-circuits the + whole test, so merely HAVING column info was read as "the user opted this + identifier in" and every profiled identifier lost its veto. Only an + explicit ``mutable=True`` hint may open the column. + """ + ctx = _ctx(columns={"code": _info(identifier_like=True, mutable=None)}, id_columns=("code",)) + proposal = _proposal(issue_type="category_synonym", raw="A-1", proposed="B-2") + assert _protection(proposal, ctx) == IDENTIFIER_VETO + assert decide(proposal, CFG, ctx).action == "skip" + + +def test_an_identifier_with_mutable_true_is_let_through(): + """The opt-in must still work: ``mutable=True`` clears the identifier veto.""" + ctx = _ctx(columns={"code": _info(identifier_like=True, mutable=True)}, id_columns=("code",)) + proposal = _proposal(issue_type="category_synonym", raw="A-1", proposed="B-2") + assert _protection(proposal, ctx) is None + assert decide(proposal, CFG, ctx).action == "apply" + + +# -- the format-alignment carve-out needs BOTH conditions ------------------- + + +def test_a_format_alignment_that_changes_the_payload_is_still_vetoed(): + """Kills policy bool#11 (`and` -> `or`), issue-type side. + + With `or`, merely being labelled ``format_alignment`` was enough to pass + the identifier carve-out, whatever the values were -- an expert could + rewrite ``A-1`` to ``A-2`` inside a protected key column. The carve-out + exists only for repairs whose payload is verified unchanged. + """ + ctx = _ctx(columns={"code": _info(identifier_like=True)}, id_columns=("code",)) + proposal = _proposal(issue_type="format_alignment", raw="A-1", proposed="A-2") + assert _protection(proposal, ctx) == IDENTIFIER_VETO + assert decide(proposal, CFG, ctx).action == "skip" + + +def test_a_payload_preserving_repair_of_another_issue_type_is_still_vetoed(): + """Kills policy bool#11 (`and` -> `or`), payload side. + + Being payload-preserving is necessary but not sufficient: the carve-out is + scoped to ``format_alignment``. With `or`, any other expert's + payload-preserving rewrite (e.g. an encoding repair) slipped into a + protected identifier column as well. + """ + ctx = _ctx(columns={"code": _info(identifier_like=True)}, id_columns=("code",)) + proposal = _proposal(issue_type="encoding_repair", raw="A-1", proposed="A1") + assert _protection(proposal, ctx) == IDENTIFIER_VETO + assert decide(proposal, CFG, ctx).action == "skip" + + +def test_a_verified_format_alignment_still_passes_the_carve_out(): + """Positive control for the carve-out both bool#11 mutants widen.""" + ctx = _ctx(columns={"code": _info(identifier_like=True)}, id_columns=("code",)) + proposal = _proposal(issue_type="format_alignment", raw="A-1", proposed="A1") + assert _protection(proposal, ctx) is None + assert decide(proposal, CFG, ctx).action == "apply" + + +# -- the review threshold is a floor, not a ceiling ------------------------- + + +def test_confidence_exactly_at_the_review_threshold_is_not_skipped(): + """Kills policy cmp#7 (`<` -> `<=`) on the confidence floor. + + The gate's own boundary: ``review_threshold`` is the lowest confidence + still worth a human's attention, so a proposal sitting exactly on it must + survive into the mode logic. With `<=` the boundary moved and the proposal + was dropped to a skip -- silently narrowing what a review queue ever sees. + """ + ctx = _ctx(mode="auto", review_threshold=0.70, auto_threshold=0.95) + decision = decide(_proposal(confidence=0.70), CFG, ctx) + assert (decision.action, decision.status) == ("suggest", "suggested") + assert decision.reason == "held for review (mode=auto)" + + +def test_confidence_exactly_at_a_coincident_auto_threshold_is_applied(): + """Also kills cmp#7, at the sharpest point. + + When both thresholds coincide, a proposal exactly on the line is an + automatic apply; the mutant turns that same proposal into a skip. + """ + ctx = _ctx(mode="auto", review_threshold=0.80, auto_threshold=0.80) + decision = decide(_proposal(confidence=0.80), CFG, ctx) + assert (decision.action, decision.status) == ("apply", "automatic") + + +def test_confidence_just_below_the_review_threshold_is_skipped(): + """The floor must still bite one step below the boundary.""" + ctx = _ctx(mode="auto", review_threshold=0.70) + decision = decide(_proposal(confidence=0.69), CFG, ctx) + assert decision.action == "skip" + assert decision.reason == "confidence 0.69 below review threshold 0.70" + + +# -- every branch hands the audit trail the right human_review flag --------- + + +def test_the_protective_veto_expert_routes_to_a_human(): + """Kills policy const_bool#4 (`human_review=True` -> `False`). + + An ``identifier_like`` veto is the protective expert refusing a mutation; + the row is skipped but a human is meant to see it, so the flag that puts + it in front of one may not be dropped. + """ + decision = decide( + _proposal(issue_type="identifier_like", rationale="looks like a key"), + CFG, + _ctx(), + ) + assert (decision.action, decision.status) == ("skip", "skipped") + assert decision.reason == "looks like a key" + assert decision.human_review is True + + +def test_a_protected_column_skip_routes_to_a_human(): + """Kills policy const_bool#5 (`human_review=True` -> `False`). + + A proposal that hit a column protection was worth making; the skip is the + policy's choice, not a verdict on the signal, so it stays reviewable. + """ + ctx = _ctx(columns={"code": _info(preserve=True)}) + decision = decide(_proposal(issue_type="category_synonym"), CFG, ctx) + assert decision.reason == "column is in preserve_columns" + assert decision.human_review is True + + +def test_a_below_threshold_skip_is_not_routed_to_a_human(): + """Kills policy const_bool#6 (`human_review=False` -> `True`). + + The low-confidence skip is recorded for audit only. Flagging it would + flood a review queue with exactly the signals the floor exists to filter. + """ + decision = decide(_proposal(confidence=0.10), CFG, _ctx(review_threshold=0.70)) + assert decision.action == "skip" + assert decision.human_review is False + + +def test_assist_mode_suggestions_are_routed_to_a_human(): + """Kills policy const_bool#7 (`human_review=True` -> `False`). + + assist mode never mutates; its entire output is material for a person. + """ + decision = decide(_proposal(), CFG, _ctx(mode="assist")) + assert (decision.action, decision.status) == ("suggest", "suggested") + assert decision.reason == "assist mode records suggestions only" + assert decision.human_review is True + + +def test_a_review_mode_automatic_apply_is_not_routed_to_a_human(): + """Kills policy const_bool#8 (`human_review=False` -> `True`). + + A deterministic low-risk repair applied in review mode is done; marking it + for review would contradict the automatic status recorded beside it. + """ + decision = decide(_proposal(confidence=0.99, risk="low"), CFG, _ctx(mode="review")) + assert (decision.action, decision.status) == ("apply", "automatic") + assert decision.reason == "deterministic low-risk repair" + assert decision.human_review is False + + +@pytest.mark.parametrize(("confidence", "risk"), [(0.80, "low"), (0.99, "medium"), (0.99, "high")]) +def test_a_review_mode_hold_is_routed_to_a_human(confidence, risk): + """Kills policy const_bool#9 (`human_review=True` -> `False`). + + Everything review mode declines to apply -- not confident enough, or not + low risk -- is precisely what the human is there to decide. + """ + decision = decide(_proposal(confidence=confidence, risk=risk), CFG, _ctx(mode="review")) + assert (decision.action, decision.status) == ("suggest", "suggested") + assert decision.reason == "held for review (mode=review)" + assert decision.human_review is True + + +def test_an_auto_mode_automatic_apply_is_not_routed_to_a_human(): + """Kills policy const_bool#10 (`human_review=False` -> `True`).""" + decision = decide(_proposal(confidence=0.99, risk="medium"), CFG, _ctx(mode="auto")) + assert (decision.action, decision.status) == ("apply", "automatic") + assert decision.reason == "high-confidence low-risk repair" + assert decision.human_review is False + + +def test_a_disabled_semantic_layer_skips_without_routing_to_a_human(): + """Kills policy const_bool#12 (`human_review=False` -> `True`). + + With the layer off (or an unknown mode) the gate does nothing at all; it + must not manufacture review work out of a feature the user disabled. + """ + for mode in ("off", "", "not-a-mode"): + decision = decide(_proposal(), CFG, _ctx(mode=mode)) + assert (decision.action, decision.status) == ("skip", "skipped") + assert decision.reason == "semantic layer disabled" + assert decision.human_review is False + + +def test_an_unsafe_ambiguous_proposal_is_reported_as_high_risk(): + """The risk override the audit trail reads, independent of the mode path. + + NOTE -- this pins *actual* behaviour, not desired behaviour. The module + docstring promises the gate will "never auto-apply high risk", but + ``decide`` gates auto mode on ``proposal.risk`` while ``_decision`` + rewrites ``unsafe_ambiguous`` to ``risk="high"`` afterwards. A proposal + constructed with ``issue_type="unsafe_ambiguous"`` and ``risk="low"`` is + therefore applied automatically *and* recorded as high risk. Every + in-tree producer of ``unsafe_ambiguous`` routes through + ``scoring.make_proposal``/``risk_for``, which already returns "high", so + the gap is unreachable from the pipeline today; it is reported as a + suspected defect rather than fixed here (test-only change). + """ + decision = decide( + _proposal(issue_type="unsafe_ambiguous", risk="low", confidence=0.99), + CFG, + _ctx(mode="auto"), + ) + assert decision.risk == "high" + assert decision.action == "apply" # suspected defect: see docstring + + +def test_an_unsafe_ambiguous_proposal_scored_high_risk_is_held(): + """How the pipeline actually reaches the gate: risk already "high".""" + decision = decide( + _proposal(issue_type="unsafe_ambiguous", risk="high", confidence=0.99), + CFG, + _ctx(mode="auto"), + ) + assert (decision.action, decision.risk) == ("suggest", "high") + assert decision.human_review is True diff --git a/tests/test_policy_guard_mutants.py b/tests/test_policy_guard_mutants.py index 99e0c13..a0bbb1a 100644 --- a/tests/test_policy_guard_mutants.py +++ b/tests/test_policy_guard_mutants.py @@ -142,14 +142,26 @@ def test_the_protected_snapshot_does_not_share_data_with_the_frame(): (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. + """Two missing values compare equal; a missing and a present one do not. + + **Correction.** This docstring previously claimed that flipping + ``if left_na or right_na`` to ``and`` was an *equivalent mutant*, "verified + across 289 input pairs ... zero behavioural differences". **That claim was + wrong**, and the mutant is killed by + ``test_a_permissive_equality_object_cannot_impersonate_a_missing_value`` + in ``tests/test_guard_byte_identity_mutants.py``. + + The 289-pair pool contained no cell whose ``__eq__`` returns True against + anything (``unittest.mock.ANY`` and wildcard/matcher objects do). For such + a cell the fall-through ``bool(left == right)`` returns **True**, not + False, so the mutant reports a missing value and a present one as equal -- + and the guard misses a protected-column violation. A 1296-pair + differential found 18 such disagreements, in both argument orders. + + The lesson is about the method, not the operator: an equivalence claim is + only as strong as the input pool it was checked over, and a pool built + from ordinary scalars cannot rule out exotic ``__eq__``. Prefer a proof + that the branch is unreachable over a differential that merely found no + counterexample. """ assert _cell_equal(left, right) is equal