File size: 8,095 Bytes
2a87ede
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
"""

Tests for ia_utils.adversarial_vector_attack -- the gradient-based

Phi-Trigger stress test (arXiv:2607.27465-inspired) for

enhanced_dense_healing_hybrid. Verifies both the isolated

craft_adversarial_healing_perturbation call AND the crafted

perturbation's effect on the real end-to-end healing pipeline, not

just the isolated trigger-magnitude proxy.

"""
import numpy as np
import pytest

from ia_utils.adversarial_vector_attack import craft_adversarial_healing_perturbation
from ia_utils.vector_healing import enhanced_dense_healing_hybrid


def _static_segment(seed, n=30, dim=8):
    """A near-constant sequence -- the Phi-Trigger should classify this

    as static (median-replaced) almost everywhere."""
    rng = np.random.default_rng(seed)
    base = rng.normal(size=dim) * 0.3
    return np.tile(base, (n, 1)) + rng.normal(size=(n, dim)) * 0.001


def _dynamic_segment(seed, n=30, dim=8):
    """Ordinary iid noise at a scale that keeps calculate_phi_ab's

    distance term within MAX_SEMANTIC_DISTANCE (avoids the clip-

    saturation edge case verified separately below) -- the Phi-Trigger

    should classify this as dynamic (kept as-is) almost everywhere."""
    rng = np.random.default_rng(seed)
    return rng.normal(size=(n, dim)) * 0.3


class TestInputValidation:

    def test_rejects_target_idx_below_2(self):
        vettori = _dynamic_segment(0)
        with pytest.raises(ValueError, match="target_idx"):
            craft_adversarial_healing_perturbation(vettori, 1)

    def test_rejects_target_idx_at_or_beyond_length(self):
        vettori = _dynamic_segment(0)
        with pytest.raises(ValueError, match="target_idx"):
            craft_adversarial_healing_perturbation(vettori, len(vettori))

    def test_rejects_unknown_direction(self):
        vettori = _dynamic_segment(0)
        with pytest.raises(ValueError, match="direction"):
            craft_adversarial_healing_perturbation(vettori, 15, direction="sideways")

    def test_rejects_non_positive_epsilon(self):
        vettori = _dynamic_segment(0)
        with pytest.raises(ValueError, match="epsilon"):
            craft_adversarial_healing_perturbation(vettori, 15, epsilon=0.0)


class TestEpsilonBudget:

    def test_perturbation_norm_never_exceeds_epsilon(self):
        vettori = _dynamic_segment(0)
        for epsilon in (0.05, 0.2, 1.0, 3.0):
            result = craft_adversarial_healing_perturbation(
                vettori, 15, epsilon=epsilon, direction="flip_to_static")
            assert result["perturbation_norm"] <= epsilon + 1e-9

    def test_larger_epsilon_is_never_worse_than_smaller(self):
        # BUG FIX regression: step_size used to scale with epsilon
        # (2*epsilon/n_steps), which made a LARGER budget overshoot and
        # converge to a WORSE (higher, for flip_to_static) final
        # magnitude than a smaller budget -- the opposite of what any
        # correct optimizer should do (more room to move should never
        # hurt). Verified directly: final_magnitude must be
        # non-increasing as epsilon grows, for a fixed seed/target.
        vettori = _dynamic_segment(0)
        epsilons = [0.1, 0.3, 0.5, 1.0, 2.0, 3.0]
        magnitudes = []
        for epsilon in epsilons:
            result = craft_adversarial_healing_perturbation(
                vettori, 15, epsilon=epsilon, n_steps=50, direction="flip_to_static")
            magnitudes.append(result["final_magnitude"])
        for earlier, later in zip(magnitudes, magnitudes[1:]):
            assert later <= earlier + 1e-9, (
                f"larger epsilon gave a worse (higher) final magnitude: {magnitudes}")


class TestFlipToStatic:
    """Suppression direction: push an originally-dynamic (kept-as-is)

    point across the threshold so the trigger discards it as noise."""

    def test_flips_the_isolated_trigger_decision(self):
        vettori = _dynamic_segment(0)
        result = craft_adversarial_healing_perturbation(
            vettori, 15, epsilon=1.0, n_steps=50, direction="flip_to_static")
        assert result["original_trigger_active"] is True
        assert result["success"] is True
        assert result["final_trigger_active"] is False
        assert result["final_magnitude"] < result["original_magnitude"]

    def test_flips_the_real_end_to_end_healing_decision(self):
        # The point of this utility: the crafted perturbation must
        # change what the REAL enhanced_dense_healing_hybrid pipeline
        # does, not just an isolated proxy computation.
        vettori = _dynamic_segment(0)
        target_idx = 15
        result = craft_adversarial_healing_perturbation(
            vettori, target_idx, epsilon=1.0, n_steps=50, direction="flip_to_static")
        assert result["success"] is True

        healed_orig, _ = enhanced_dense_healing_hybrid(vettori.copy())
        healed_pert, _ = enhanced_dense_healing_hybrid(result["perturbed_vettori"].copy())

        # Originally kept as-is (dynamic): healed output equals raw input.
        assert np.allclose(healed_orig[target_idx], vettori[target_idx])
        # After the attack: median-replaced instead, so it must differ
        # from the (perturbed) input that was fed in.
        assert not np.allclose(healed_pert[target_idx], result["perturbed_vettori"][target_idx])


class TestFlipToDynamic:
    """Evasion direction: push an originally-static (median-replaced)

    point across the threshold so the trigger keeps it as-is instead."""

    def test_flips_the_isolated_trigger_decision(self):
        vettori = _static_segment(1)
        result = craft_adversarial_healing_perturbation(
            vettori, 15, epsilon=1.0, n_steps=50, direction="flip_to_dynamic")
        assert result["original_trigger_active"] is False
        assert result["success"] is True
        assert result["final_trigger_active"] is True
        assert result["final_magnitude"] > result["original_magnitude"]

    def test_flips_the_real_end_to_end_healing_decision(self):
        vettori = _static_segment(1)
        target_idx = 15
        result = craft_adversarial_healing_perturbation(
            vettori, target_idx, epsilon=1.0, n_steps=50, direction="flip_to_dynamic")
        assert result["success"] is True

        healed_orig, _ = enhanced_dense_healing_hybrid(vettori.copy())
        healed_pert, _ = enhanced_dense_healing_hybrid(result["perturbed_vettori"].copy())

        # Originally median-replaced (static): healed output must NOT
        # equal the raw (noisy) input at that index.
        assert not np.allclose(healed_orig[target_idx], vettori[target_idx])
        # After the attack: evades the healer, passes through unhealed,
        # so the healed output must equal the (perturbed) input exactly.
        assert np.allclose(healed_pert[target_idx], result["perturbed_vettori"][target_idx])


class TestGradientSaturationEdgeCase:
    """calculate_phi_ab clips to [0, 1] -- when the clip saturates (the

    unclipped weighted sum falls outside that range), the local

    gradient is exactly zero, so a gradient-based attack cannot move at

    all from that starting point. This is a real property of the

    underlying formula (see dense_evolution.healing), not a bug in the

    attack -- verified directly that it's detectable via

    perturbation_norm == 0 with success == False, not silently

    misreported as a "found nothing better" result."""

    def test_saturated_phi_ab_gives_zero_perturbation_and_no_success(self):
        rng = np.random.default_rng(0)
        n, dim = 30, 8
        # Unscaled noise: distance_A_B regularly exceeds
        # MAX_SEMANTIC_DISTANCE=sqrt(2), saturating phi_ab's clip to 0.
        vettori = rng.normal(size=(n, dim))
        result = craft_adversarial_healing_perturbation(
            vettori, 15, epsilon=0.15, n_steps=30, direction="flip_to_dynamic")
        assert result["perturbation_norm"] == 0.0
        assert result["success"] is False