Skip to content

IA Utils — Adversarial Vector Attack

enhanced_dense_healing_hybrid decides, per step, whether a change in a vector sequence looks like genuine dynamics or static noise -- this module asks how easy that decision is to fool. A gradient-based (PGD-style) stress test crafts the minimal perturbation, within a fixed budget, that flips the Phi-Trigger's decision either direction -- not random noise thrown at the problem and hoping.

Step 1. Disguise a static point as genuine dynamics

import numpy as np
from ia_utils.adversarial_vector_attack import craft_adversarial_healing_perturbation

vettori = np.array([[1.0, 1.0]] * 10) + np.random.default_rng(0).normal(0, 0.001, (10, 2))
result = craft_adversarial_healing_perturbation(vettori, target_idx=5, direction='flip_to_dynamic')

result['success'], result['original_trigger_active'], result['final_trigger_active']
(True, False, True)

vettori is a nearly flat sequence -- real Phi-Trigger noise, not corrupted at all (original_trigger_active=False, correctly seen as static). craft_adversarial_healing_perturbation( vettori, target_idx, direction='flip_to_dynamic') searches for the smallest perturbation (within an epsilon-ball, default epsilon=0.1) to step 5 that flips the trigger on -- and finds one: final_trigger_active=True, success=True. This is the evade direction: a real attacker's goal would be making corrupted data pass through enhanced_dense_healing_hybrid unhealed by disguising it as legitimate dynamics.

Step 2. The other direction: suppress a genuine trigger

vettori2 = np.array([[1.0, 1.0]] * 10) + np.random.default_rng(0).normal(0, 0.01, (10, 2))
result2 = craft_adversarial_healing_perturbation(vettori2, target_idx=3, direction='flip_to_static')

result2['success'], result2['original_trigger_active'], result2['final_trigger_active']
(True, True, False)

This time step 3 already genuinely triggers (original_trigger_active=True -- real noise at this magnitude, not a hand-picked corruption). direction='flip_to_static' finds the opposite perturbation: one that makes the Phi-Trigger wrongly see this real trigger as noise (final_trigger_active=False) and median-replace it instead -- the suppress direction, a real signal getting wiped out rather than a fake one getting through. Not every target index is this exploitable within the epsilon budget (some points genuinely resist the attack, success=False) -- which points are vulnerable depends on the sequence's own local shape around them, not a fixed property of the attack itself.


Details

Why this is possible at all: evaluate_phi_trigger (healing) thresholds |v_dinamic| with a hard step (not differentiable at the boundary), but v_dinamic itself is built entirely from JAX-differentiable operations (calculate_phi_ab/calculate_vettore_dinamico) -- so a gradient-based search can still find the minimal input change that pushes the pre-threshold value across the boundary, even though the boundary decision itself isn't smooth.

Two real bugs found and fixed during this utility's own verification, not assumed correct: the default step_size used to scale with the epsilon budget -- a larger budget converged to a worse result (verified directly, non-monotonic in epsilon), now a small fixed default independent of epsilon. And calculate_phi_ab's [0,1] clip saturates for inputs whose semantic distance exceeds MAX_SEMANTIC_DISTANCE, giving an exact-zero gradient -- a real property of the formula, not a bug in the attack, but one that used to be silently misreported as "no better point found" rather than detected and reported (perturbation_norm == 0, success == False).

Adapted from IGME's chained-differentiable-attack idea (arXiv:2607.27465, "Efficient Chained Method Ensemble for Transferable Semantic Segmentation Attacks", He & Zhang), applied here to vector sequences instead of image segmentation.

adversarial_vector_attack

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.

craft_adversarial_healing_perturbation

craft_adversarial_healing_perturbation(
    vettori: 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).

Source code in tools/ia_utils/adversarial_vector_attack.py
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,
    }

See also: ia_utils.vector_healing for the function under test, and dense_evolution.healing for the underlying Phi-Trigger primitives.