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
|