File size: 9,629 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
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
"""

Gradient-based adversarial stress-testing for enhanced_dense_healing_hybrid's

Phi-Trigger decision -- a targeted, crafted perturbation instead of the

random NaN/Inf corruption this healing pipeline is normally tested

against, adapted from IGME's chained-differentiable-attack idea

(arXiv:2607.27465, "IGME: Efficient Chained Method Ensemble for

Transferable Semantic Segmentation Attacks") applied to vector sequences

instead of image segmentation.



evaluate_phi_trigger (dense_evolution.healing) makes its keep-vs-median-

replace decision by thresholding |v_dinamic| against NON_STATIC_THRESHOLD_A

-- a hard step, not differentiable at the boundary. But v_dinamic itself

(via calculate_phi_ab -> calculate_vettore_dinamico) is built entirely

from norms, dot products, log, and clip -- all JAX-differentiable. This

crafts a minimal perturbation to a single vector in the sequence, via

gradient ascent/descent on |v_dinamic| (projected back into an epsilon

L2-ball each step, the standard PGD pattern), that flips the trigger's

decision at that point:



- "flip_to_dynamic": push an originally-static point (would be replaced

  by the local median) across the threshold so the trigger keeps it

  as-is instead -- the more security-relevant direction, since it

  represents a worst-case corruption crafted to *evade* the healer by

  looking like genuine motion, rather than obvious noise.

- "flip_to_static": push an originally-dynamic point (would be kept)

  across the threshold so the trigger discards it as noise instead --

  the failure mode of genuine signal getting wrongly suppressed.



This does not attack the median-filter fallback itself, only the

Phi-Trigger's keep-vs-replace decision -- see craft_adversarial_healing_

perturbation's own docstring for what "success" means precisely.

"""

from typing import Optional

import numpy as np
import jax
import jax.numpy as jnp

from dense_evolution.mitigation.healing import calculate_phi_ab, calculate_vettore_dinamico, GLOBAL_CONSTANTS

__all__ = ['craft_adversarial_healing_perturbation']


def _trigger_magnitude(state_B, state_A, ipg_vector):
    """|v_dinamic| -- the continuous quantity evaluate_phi_trigger

    thresholds. Differentiable w.r.t. state_B (the vector being attacked);

    state_A/ipg_vector are treated as fixed context, not attacked."""
    phi_ab = calculate_phi_ab(state_A, state_B, ipg_vector)
    E_A = jnp.linalg.norm(state_A)
    E_B = jnp.linalg.norm(state_B)
    v_dinamic = calculate_vettore_dinamico(E_A, E_B, phi_ab)
    return jnp.abs(v_dinamic)


_trigger_magnitude_grad = jax.grad(_trigger_magnitude, argnums=0)


def craft_adversarial_healing_perturbation(

    vettori: np.ndarray,

    target_idx: int,

    radius_baseline: Optional[int] = None,

    epsilon: float = 0.1,

    n_steps: int = 50,

    step_size: Optional[float] = None,

    direction: str = "flip_to_dynamic",

) -> dict:
    """Crafts a minimal adversarial perturbation to vettori[target_idx],

    within an L2 epsilon-ball, that flips enhanced_dense_healing_hybrid's

    Phi-Trigger decision at that index -- a targeted stress test, not

    random noise.



    Reproduces enhanced_dense_healing_hybrid's own per-step computation

    at target_idx exactly (same baseline_mean window, same adaptive

    radius default, same inter-point-gradient vector) so the crafted

    perturbation is faithful to what the real healing pipeline would

    actually see, not a simplified stand-in.



    BUG FIX: step_size used to default to 2*epsilon/n_steps -- tying the

    per-step move size to the epsilon budget. Verified directly this

    makes LARGER epsilon give WORSE (higher final |v_dinamic|) results,

    not better: a bigger budget means a bigger step, which overshoots

    and oscillates around the minimum instead of converging to it,

    which is the opposite of what a bigger budget should ever do for a

    correct optimizer. step_size is now independent of epsilon (a small

    fixed default, tuned to v_dinamic's typical local scale) -- epsilon

    only bounds where the iterate is allowed to end up (via projection

    after each step), not how big each step is.



    Args:

        vettori: array-like, shape (n_steps, dim), the (unperturbed)

            vector sequence. Not modified in place.

        target_idx: index to attack; must be >= 2 (the healing loop's

            own starting point) and < len(vettori).

        radius_baseline: same meaning as enhanced_dense_healing_hybrid's

            own parameter; None uses the same adaptive default.

        epsilon: L2-norm budget for the perturbation (the attack is

            projected back into this ball after every gradient step).

        n_steps: number of PGD-style gradient steps.

        step_size: per-step move size along the normalized gradient;

            None uses a small fixed default (0.02) independent of

            epsilon -- see the bug-fix note above for why.

        direction: "flip_to_dynamic" (evade -- make static-looking

            input pass through unhealed) or "flip_to_static" (suppress

            -- make dynamic-looking input get median-replaced instead).



    Returns:

        dict with:

            perturbed_vettori: copy of vettori with vettori[target_idx]

                replaced by the crafted perturbation.

            success: bool, True only if the trigger decision actually

                flipped in the requested direction (a small epsilon or

                too few steps can fail to cross the threshold).

            original_trigger_active / final_trigger_active: bool, the

                Phi-Trigger's decision (True = dynamic/kept) before and

                after the perturbation.

            original_magnitude / final_magnitude: float, |v_dinamic|

                before and after.

            perturbation_norm: float, actual L2 norm of the applied

                perturbation (<= epsilon).

    """
    vettori = np.asarray(vettori, dtype=np.float64)
    n, hidden_dim = vettori.shape
    if target_idx < 2 or target_idx >= n:
        raise ValueError(f"target_idx must be in [2, {n - 1}], got {target_idx} (n={n})")
    if direction not in ("flip_to_dynamic", "flip_to_static"):
        raise ValueError(f"direction must be 'flip_to_dynamic' or 'flip_to_static', got {direction!r}")
    if epsilon <= 0:
        raise ValueError(f"epsilon must be positive, got {epsilon}")

    if radius_baseline is None:
        radius_baseline = min(20, max(3, n // 3))
    lo = max(0, target_idx - radius_baseline)

    state_A = jnp.array(np.mean(vettori[lo:target_idx], axis=0))
    ipg_raw = vettori[target_idx - 1] - vettori[target_idx - 2]
    norm_ipg_raw = np.linalg.norm(ipg_raw)
    ipg_vector = jnp.array(ipg_raw / norm_ipg_raw) if norm_ipg_raw > 1e-9 else jnp.array(ipg_raw)

    original_state_B = jnp.array(vettori[target_idx])
    threshold = GLOBAL_CONSTANTS['NON_STATIC_THRESHOLD_A']
    original_magnitude = float(_trigger_magnitude(original_state_B, state_A, ipg_vector))
    original_trigger_active = original_magnitude > threshold

    sign = 1.0 if direction == "flip_to_dynamic" else -1.0
    if step_size is None:
        step_size = 0.02

    # Track the best iterate seen (most favorable to `direction`), not
    # just the last one -- with a fixed small step size the trajectory
    # can overshoot past the optimum and drift back worse on later
    # steps, especially once it's pinned against the epsilon-ball
    # boundary; keeping the best-so-far makes the result robust to
    # exactly how many steps were requested.
    state_B = original_state_B
    best_state_B = original_state_B
    best_magnitude = original_magnitude
    for _ in range(n_steps):
        grad = _trigger_magnitude_grad(state_B, state_A, ipg_vector)
        grad_norm = jnp.linalg.norm(grad)
        step = jnp.where(grad_norm > 1e-12, grad / grad_norm, jnp.zeros_like(grad))
        state_B = state_B + sign * step_size * step
        # Project back into the epsilon L2-ball around the original vector.
        delta = state_B - original_state_B
        delta_norm = jnp.linalg.norm(delta)
        state_B = jnp.where(delta_norm > epsilon, original_state_B + delta / delta_norm * epsilon, state_B)

        current_magnitude = float(_trigger_magnitude(state_B, state_A, ipg_vector))
        is_better = (current_magnitude > best_magnitude) if direction == "flip_to_dynamic" else (current_magnitude < best_magnitude)
        if is_better:
            best_magnitude = current_magnitude
            best_state_B = state_B

    state_B = best_state_B
    final_magnitude = float(_trigger_magnitude(state_B, state_A, ipg_vector))
    final_trigger_active = final_magnitude > threshold
    perturbation_norm = float(jnp.linalg.norm(state_B - original_state_B))

    perturbed_vettori = vettori.copy()
    perturbed_vettori[target_idx] = np.asarray(state_B)

    flipped = final_trigger_active != original_trigger_active
    success = flipped and (
        (direction == "flip_to_dynamic" and final_trigger_active)
        or (direction == "flip_to_static" and not final_trigger_active)
    )

    return {
        "perturbed_vettori": perturbed_vettori,
        "success": bool(success),
        "original_trigger_active": bool(original_trigger_active),
        "final_trigger_active": bool(final_trigger_active),
        "original_magnitude": original_magnitude,
        "final_magnitude": final_magnitude,
        "perturbation_norm": perturbation_norm,
    }