From 83a778e845d5df683a88e08da379a83de3890bfb Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Fri, 15 May 2026 17:35:23 -0400 Subject: [PATCH 01/35] init commit --- pyrit/scenario/__init__.py | 4 + pyrit/scenario/scenarios/adaptive/__init__.py | 26 ++ .../scenario/scenarios/adaptive/dispatcher.py | 162 ++++++++++ pyrit/scenario/scenarios/adaptive/selector.py | 187 ++++++++++++ .../scenarios/adaptive/text_adaptive.py | 275 +++++++++++++++++ .../scenarios/adaptive/test_dispatcher.py | 225 ++++++++++++++ .../scenarios/adaptive/test_selector.py | 188 ++++++++++++ .../scenarios/adaptive/test_text_adaptive.py | 282 ++++++++++++++++++ 8 files changed, 1349 insertions(+) create mode 100644 pyrit/scenario/scenarios/adaptive/__init__.py create mode 100644 pyrit/scenario/scenarios/adaptive/dispatcher.py create mode 100644 pyrit/scenario/scenarios/adaptive/selector.py create mode 100644 pyrit/scenario/scenarios/adaptive/text_adaptive.py create mode 100644 tests/unit/scenario/scenarios/adaptive/test_dispatcher.py create mode 100644 tests/unit/scenario/scenarios/adaptive/test_selector.py create mode 100644 tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py diff --git a/pyrit/scenario/__init__.py b/pyrit/scenario/__init__.py index b665395433..3aac6ea7f7 100644 --- a/pyrit/scenario/__init__.py +++ b/pyrit/scenario/__init__.py @@ -31,17 +31,20 @@ # Import scenario submodules directly and register them as virtual subpackages # This allows: from pyrit.scenario.airt import ContentHarms # without needing separate pyrit/scenario/airt/ directories +from pyrit.scenario.scenarios import adaptive as _adaptive_module from pyrit.scenario.scenarios import airt as _airt_module from pyrit.scenario.scenarios import benchmark as _benchmark_module from pyrit.scenario.scenarios import foundry as _foundry_module from pyrit.scenario.scenarios import garak as _garak_module +sys.modules["pyrit.scenario.adaptive"] = _adaptive_module sys.modules["pyrit.scenario.airt"] = _airt_module sys.modules["pyrit.scenario.benchmark"] = _benchmark_module sys.modules["pyrit.scenario.garak"] = _garak_module sys.modules["pyrit.scenario.foundry"] = _foundry_module # Also expose as attributes for IDE support +adaptive = _adaptive_module airt = _airt_module benchmark = _benchmark_module garak = _garak_module @@ -59,6 +62,7 @@ "ScenarioStrategy", "ScenarioIdentifier", "ScenarioResult", + "adaptive", "airt", "benchmark", "garak", diff --git a/pyrit/scenario/scenarios/adaptive/__init__.py b/pyrit/scenario/scenarios/adaptive/__init__.py new file mode 100644 index 0000000000..e06e166a62 --- /dev/null +++ b/pyrit/scenario/scenarios/adaptive/__init__.py @@ -0,0 +1,26 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""Adaptive scenario classes.""" + +from pyrit.scenario.scenarios.adaptive.dispatcher import ( + BANDIT_CONTEXT_LABEL, + AdaptiveDispatchAttack, +) +from pyrit.scenario.scenarios.adaptive.selector import ( + AdaptiveTechniqueSelector, + ContextExtractor, + global_context, + harm_category_context, +) +from pyrit.scenario.scenarios.adaptive.text_adaptive import TextAdaptive + +__all__ = [ + "AdaptiveDispatchAttack", + "AdaptiveTechniqueSelector", + "BANDIT_CONTEXT_LABEL", + "ContextExtractor", + "TextAdaptive", + "global_context", + "harm_category_context", +] diff --git a/pyrit/scenario/scenarios/adaptive/dispatcher.py b/pyrit/scenario/scenarios/adaptive/dispatcher.py new file mode 100644 index 0000000000..ae1087a143 --- /dev/null +++ b/pyrit/scenario/scenarios/adaptive/dispatcher.py @@ -0,0 +1,162 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +``AdaptiveDispatchAttack`` — an ``AttackStrategy`` that picks which inner +technique to run for each objective using an ``AdaptiveTechniqueSelector``. + +This is the execution-side counterpart to the selector. The selector decides +*which arm to pull*; the dispatcher *runs the arm*, records the outcome, and +loops up to ``max_attempts_per_objective`` times. + +The dispatcher reads a bandit-context key from +``context.memory_labels[BANDIT_CONTEXT_LABEL]``. The scenario is expected to +stamp that label per-objective (computed once at atomic-attack construction +time via a ``ContextExtractor``). When the label is missing, the global +context is used. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from pyrit.executor.attack.core.attack_parameters import AttackParameters +from pyrit.executor.attack.core.attack_strategy import AttackContext, AttackStrategy +from pyrit.models import AttackOutcome, AttackResult +from pyrit.scenario.scenarios.adaptive.selector import ( + GLOBAL_CONTEXT, + AdaptiveTechniqueSelector, +) + +if TYPE_CHECKING: + from pyrit.prompt_target import PromptTarget + +logger = logging.getLogger(__name__) + + +BANDIT_CONTEXT_LABEL: str = "_adaptive_context" +"""Memory-label key whose value is the bandit context string for an objective.""" + +ADAPTIVE_ARM_LABEL: str = "_adaptive_arm" +ADAPTIVE_ATTEMPT_LABEL: str = "_adaptive_attempt" + + +@dataclass +class AdaptiveDispatchContext(AttackContext[AttackParameters]): + """Execution context for ``AdaptiveDispatchAttack``. + + No extra state is needed beyond what ``AttackContext`` provides; the + dispatcher reads the objective and memory labels from the base class. + """ + + +class AdaptiveDispatchAttack(AttackStrategy[AdaptiveDispatchContext, AttackResult]): + """ + Attack that delegates each attempt to one of several inner ``AttackStrategy`` + instances ("arms"), choosing per attempt via an ``AdaptiveTechniqueSelector``. + + For each objective the dispatcher loops up to ``max_attempts_per_objective`` + times. On each iteration it asks the selector which arm to try, executes + the inner attack with the objective, records the outcome on the selector, + and stops early on success. + + The selector instance is **shared by reference** with the scenario, so + learning accumulates across all objectives in a run. + """ + + def __init__( + self, + *, + objective_target: PromptTarget, + arms: dict[str, AttackStrategy[Any, AttackResult]], + selector: AdaptiveTechniqueSelector, + max_attempts_per_objective: int = 3, + ) -> None: + """ + Args: + objective_target (PromptTarget): The target the inner attacks run against. + Stored for identifier/logging parity; the dispatcher does not call + the target directly. + arms (dict[str, AttackStrategy[Any, AttackResult]]): Mapping from + technique name to a pre-built inner attack. Must be non-empty. + selector (AdaptiveTechniqueSelector): Shared bandit state. + max_attempts_per_objective (int): Maximum number of arm attempts + per objective. Must be >= 1. Defaults to 3. + + Raises: + ValueError: If ``arms`` is empty or ``max_attempts_per_objective`` < 1. + """ + if not arms: + raise ValueError("arms must contain at least one technique") + if max_attempts_per_objective < 1: + raise ValueError( + f"max_attempts_per_objective must be >= 1, got {max_attempts_per_objective}" + ) + + super().__init__( + objective_target=objective_target, + context_type=AdaptiveDispatchContext, + params_type=AttackParameters, + logger=logger, + ) + self._arms = arms + self._selector = selector + self._max_attempts = max_attempts_per_objective + + def _validate_context(self, *, context: AdaptiveDispatchContext) -> None: + if not context.objective or context.objective.isspace(): + raise ValueError("Attack objective must be provided and non-empty") + + async def _setup_async(self, *, context: AdaptiveDispatchContext) -> None: + pass + + async def _teardown_async(self, *, context: AdaptiveDispatchContext) -> None: + pass + + async def _perform_async(self, *, context: AdaptiveDispatchContext) -> AttackResult: + bandit_context = context.memory_labels.get(BANDIT_CONTEXT_LABEL, GLOBAL_CONTEXT) + arm_names = list(self._arms.keys()) + + last_result: AttackResult | None = None + trail: list[dict[str, str]] = [] + + for attempt_idx in range(self._max_attempts): + chosen = self._selector.select(context=bandit_context, arms=arm_names) + inner = self._arms[chosen] + attempt_labels = { + **context.memory_labels, + ADAPTIVE_ARM_LABEL: chosen, + ADAPTIVE_ATTEMPT_LABEL: str(attempt_idx + 1), + } + + logger.debug( + "AdaptiveDispatchAttack: attempt %d/%d context=%r arm=%r", + attempt_idx + 1, + self._max_attempts, + bandit_context, + chosen, + ) + + result = await inner.execute_async( + objective=context.objective, + memory_labels=attempt_labels, + ) + success = result.outcome == AttackOutcome.SUCCESS + self._selector.update(context=bandit_context, technique=chosen, success=success) + + trail.append({"technique": chosen, "outcome": result.outcome.value}) + last_result = result + + if success: + break + + # ``max_attempts`` is validated >= 1 above, so the loop always runs at least once. + assert last_result is not None + last_result.metadata = { + **last_result.metadata, + "adaptive_attempts": trail, + "adaptive_context": bandit_context, + } + return last_result diff --git a/pyrit/scenario/scenarios/adaptive/selector.py b/pyrit/scenario/scenarios/adaptive/selector.py new file mode 100644 index 0000000000..ff2794757a --- /dev/null +++ b/pyrit/scenario/scenarios/adaptive/selector.py @@ -0,0 +1,187 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +Adaptive technique selection for the ``TextAdaptive`` scenario. + +This module provides: + - ``AdaptiveTechniqueSelector``: an epsilon-greedy bandit keyed by + ``(context, technique)`` that tracks successes/attempts per arm and + picks the next technique to try. + - ``ContextExtractor``: a callable alias for deriving a context string + from a ``SeedAttackGroup``, plus two ready-made extractors: + ``global_context`` (single bucket) and ``harm_category_context`` + (first harm category, falling back to ``"_uncategorized"``). + +The selector is intentionally I/O-free and synchronous; it holds a small +mutable table that lives for the duration of a single scenario run. +""" + +from __future__ import annotations + +import random +from typing import TYPE_CHECKING, Callable, Sequence + +if TYPE_CHECKING: + from pyrit.models.seeds.seed_attack_group import SeedAttackGroup + + +ContextExtractor = Callable[["SeedAttackGroup"], str] +"""Maps a ``SeedAttackGroup`` to a bandit context key.""" + + +GLOBAL_CONTEXT: str = "_global" +UNCATEGORIZED_CONTEXT: str = "_uncategorized" + + +def global_context(_seed_attack_group: "SeedAttackGroup") -> str: + """Return a constant context so all objectives share one bandit table.""" + return GLOBAL_CONTEXT + + +def harm_category_context(seed_attack_group: "SeedAttackGroup") -> str: + """Return the first harm category on the seed group, or a fallback.""" + categories = seed_attack_group.harm_categories + if not categories: + return UNCATEGORIZED_CONTEXT + return categories[0] + + +class AdaptiveTechniqueSelector: + """ + Epsilon-greedy selector over attack techniques. + + The selector maintains a table of ``(context, technique) -> (successes, attempts)`` + counts. ``select`` returns the next technique to try for a given context, + and ``update`` records the outcome of an attempt. + + Selection uses epsilon-greedy with optimistic initialization: + - With probability ``epsilon``, pick uniformly at random from ``arms``. + - Otherwise, pick the arm with the highest estimated success rate. + The estimate is ``(successes + 1) / (attempts + 1)``, so unseen + arms look like 100% success and are explored first via tiebreak. + + When a ``(context, arm)`` cell has fewer than ``pool_threshold`` attempts, + the estimate falls back to the pooled global rate for that arm across all + contexts. This lets per-context bandits benefit from cross-context data + until they have enough local samples. Set ``pool_threshold=1`` to disable + pooling (use the local estimate as soon as any attempt is recorded). + + Note: + This class is not thread/async safe. It assumes sequential calls, + which matches the base ``Scenario._execute_scenario_async`` loop. + """ + + # Tolerance for tiebreaking in exploitation. Estimates are rational today, + # so equality works, but this guards against future estimators that may + # introduce floating-point drift. + _TIE_TOL: float = 1e-12 + + def __init__( + self, + *, + epsilon: float = 0.2, + pool_threshold: int = 3, + rng: random.Random | None = None, + ) -> None: + """ + Args: + epsilon (float): Exploration probability in [0.0, 1.0]. Defaults to 0.2. + pool_threshold (int): Minimum per-(context, arm) attempts before + the local estimate replaces the pooled-global estimate. Must + be >= 1; set to 1 to disable pooling. Defaults to 3. + rng (random.Random | None): Seedable RNG for deterministic tests. + Defaults to a fresh ``random.Random()``. + + Raises: + ValueError: If ``epsilon`` is outside [0.0, 1.0] or + ``pool_threshold`` is < 1. + """ + if not 0.0 <= epsilon <= 1.0: + raise ValueError(f"epsilon must be in [0.0, 1.0], got {epsilon}") + if pool_threshold < 1: + raise ValueError(f"pool_threshold must be >= 1, got {pool_threshold}") + + self._epsilon = epsilon + self._pool_threshold = pool_threshold + self._rng = rng if rng is not None else random.Random() + self._counts: dict[tuple[str, str], tuple[int, int]] = {} + # Per-arm pooled counts, kept in sync with ``_counts`` in ``update`` so + # ``_estimate``'s pooled-backoff branch is O(1). + self._global_counts: dict[str, tuple[int, int]] = {} + + def select(self, *, context: str, arms: Sequence[str]) -> str: + """ + Pick the next arm to try for ``context``. + + Args: + context (str): The context key (e.g. ``"_global"`` or a harm category). + arms (Sequence[str]): The candidate technique names. + + Returns: + str: The chosen arm name. + + Raises: + ValueError: If ``arms`` is empty. + """ + arm_list = list(arms) + if not arm_list: + raise ValueError("arms must contain at least one technique") + + if self._rng.random() < self._epsilon: + return self._rng.choice(arm_list) + + estimates = {arm: self._estimate(context=context, arm=arm) for arm in arm_list} + best = max(estimates.values()) + winners = [arm for arm, value in estimates.items() if value >= best - self._TIE_TOL] + return self._rng.choice(winners) + + def update(self, *, context: str, technique: str, success: bool) -> None: + """ + Record the outcome of an attempt. + + Args: + context (str): The context key the decision was made under. + technique (str): The arm that was tried. + success (bool): Whether the attempt succeeded. + """ + successes, attempts = self._counts.get((context, technique), (0, 0)) + attempts += 1 + if success: + successes += 1 + self._counts[(context, technique)] = (successes, attempts) + + global_successes, global_attempts = self._global_counts.get(technique, (0, 0)) + global_attempts += 1 + if success: + global_successes += 1 + self._global_counts[technique] = (global_successes, global_attempts) + + def success_rate(self, *, context: str, technique: str) -> float: + """ + Return the smoothed success-rate estimate for an arm in a context. + + This is the same value used internally for exploitation decisions. + """ + return self._estimate(context=context, arm=technique) + + def counts(self, *, context: str, technique: str) -> tuple[int, int]: + """Return raw ``(successes, attempts)`` for a ``(context, technique)`` cell.""" + return self._counts.get((context, technique), (0, 0)) + + def snapshot(self) -> dict[tuple[str, str], tuple[int, int]]: + """Return a shallow copy of the full counts table (for logging/debug).""" + return dict(self._counts) + + def _estimate(self, *, context: str, arm: str) -> float: + """ + Smoothed success-rate estimate for ``(context, arm)``. + + Below ``pool_threshold`` local attempts, the estimate uses the + pooled-global success rate for the arm across all contexts. + """ + local_s, local_n = self._counts.get((context, arm), (0, 0)) + if local_n >= self._pool_threshold: + return (local_s + 1) / (local_n + 1) + global_s, global_n = self._global_counts.get(arm, (0, 0)) + return (global_s + 1) / (global_n + 1) diff --git a/pyrit/scenario/scenarios/adaptive/text_adaptive.py b/pyrit/scenario/scenarios/adaptive/text_adaptive.py new file mode 100644 index 0000000000..2fa97b7061 --- /dev/null +++ b/pyrit/scenario/scenarios/adaptive/text_adaptive.py @@ -0,0 +1,275 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +TextAdaptive scenario — picks attack techniques per-objective using an +epsilon-greedy bandit informed by observed per-run success rates. + +Unlike static scenarios (which run every selected technique against every +objective), TextAdaptive runs **up to** ``max_attempts_per_objective`` +techniques per objective and stops early when one succeeds. Which technique +to try next is decided by an ``AdaptiveTechniqueSelector`` whose Q-values are +updated after every attempt. + +The set of available "arms" comes from the selected scenario strategies, so +``--strategies single_turn`` restricts the bandit to single-turn techniques, +etc. The default selector uses a single global context; pass a different +``context_extractor`` (e.g., ``harm_category_context``) to partition Q-values +per category. +""" + +from __future__ import annotations + +import logging +import random +import uuid +from typing import TYPE_CHECKING, ClassVar, cast + +from pyrit.common import apply_defaults +from pyrit.executor.attack import AttackScoringConfig +from pyrit.registry.tag_query import TagQuery +from pyrit.scenario.core.dataset_configuration import DatasetConfiguration +from pyrit.scenario.core.scenario import BaselinePolicy, Scenario +from pyrit.scenario.core.scenario_strategy import ScenarioStrategy +from pyrit.scenario.scenarios.adaptive.dispatcher import ( + BANDIT_CONTEXT_LABEL, + AdaptiveDispatchAttack, +) +from pyrit.scenario.scenarios.adaptive.selector import ( + AdaptiveTechniqueSelector, + ContextExtractor, + global_context, +) + +if TYPE_CHECKING: + from pyrit.executor.attack.core.attack_strategy import AttackStrategy + from pyrit.models import SeedAttackGroup + from pyrit.scenario.core.atomic_attack import AtomicAttack + from pyrit.score import TrueFalseScorer + +logger = logging.getLogger(__name__) + + +def _build_text_adaptive_strategy() -> type[ScenarioStrategy]: + """Build the strategy enum from the core scenario-techniques catalog.""" + from pyrit.registry.object_registries.attack_technique_registry import ( + AttackTechniqueRegistry, + ) + from pyrit.scenario.core.scenario_techniques import SCENARIO_TECHNIQUES + + return AttackTechniqueRegistry.build_strategy_class_from_specs( # type: ignore[return-value, ty:invalid-return-type] + class_name="TextAdaptiveStrategy", + specs=SCENARIO_TECHNIQUES, + aggregate_tags={ + "default": TagQuery.any_of("default"), + "single_turn": TagQuery.any_of("single_turn"), + "multi_turn": TagQuery.any_of("multi_turn"), + }, + ) + + +class TextAdaptive(Scenario): + """ + Adaptive text-attack scenario that selects techniques per-objective using + an epsilon-greedy bandit over the set of selected strategies. + + The bandit: + - Picks an arm uniformly at random with probability ``epsilon``. + - Otherwise exploits the highest observed success rate. Unseen arms + have an optimistic prior so the first few objectives effectively + round-robin through every available technique. + - Pools across contexts when a context has fewer than + ``pool_threshold`` observations for an arm. + + A baseline ``PromptSendingAttack`` is **not** prepended — every objective + runs through the dispatcher, and ``prompt_sending`` participates as one of + the bandit's arms. + """ + + VERSION: int = 1 + BASELINE_POLICY: ClassVar[BaselinePolicy] = BaselinePolicy.Forbidden + _cached_strategy_class: ClassVar[type[ScenarioStrategy] | None] = None + + # ------------------------------------------------------------------ # + # Required class-method overrides # + # ------------------------------------------------------------------ # + + @classmethod + def get_strategy_class(cls) -> type[ScenarioStrategy]: + if cls._cached_strategy_class is None: + cls._cached_strategy_class = _build_text_adaptive_strategy() + return cls._cached_strategy_class + + @classmethod + def get_default_strategy(cls) -> ScenarioStrategy: + strategy_class = cls.get_strategy_class() + return strategy_class("default") + + @classmethod + def required_datasets(cls) -> list[str]: + return [ + "airt_hate", + "airt_fairness", + "airt_violence", + "airt_sexual", + "airt_harassment", + "airt_misinformation", + "airt_leakage", + ] + + @classmethod + def default_dataset_config(cls) -> DatasetConfiguration: + return DatasetConfiguration(dataset_names=cls.required_datasets(), max_dataset_size=4) + + # ------------------------------------------------------------------ # + # Constructor # + # ------------------------------------------------------------------ # + + @apply_defaults + def __init__( + self, + *, + objective_scorer: TrueFalseScorer | None = None, + epsilon: float = 0.2, + pool_threshold: int = 3, + max_attempts_per_objective: int = 3, + seed: int | None = None, + context_extractor: ContextExtractor = global_context, + scenario_result_id: str | None = None, + ) -> None: + """ + Args: + objective_scorer (TrueFalseScorer | None): Scorer used to judge each + response. Defaults to the composite scorer built from the base class. + epsilon (float): Exploration probability for the bandit. Defaults to 0.2. + pool_threshold (int): Minimum per-(context, arm) attempts before the + local estimate overrides the pooled-global estimate. Set to 1 to + disable pooling. Defaults to 3. + max_attempts_per_objective (int): Maximum techniques tried per + objective before giving up. Defaults to 3. + seed (int | None): RNG seed for deterministic bandit decisions. + Defaults to ``None`` (non-deterministic). + context_extractor (ContextExtractor): Function mapping a + ``SeedAttackGroup`` to a bandit context key. Defaults to + ``global_context`` (one shared bandit table). Use + ``harm_category_context`` to partition Q-values by harm category. + scenario_result_id (str | None): ID of an existing ``ScenarioResult`` + to resume. + """ + if not objective_scorer: + objective_scorer = self._get_default_objective_scorer() + + self._epsilon = epsilon + self._pool_threshold = pool_threshold + self._max_attempts_per_objective = max_attempts_per_objective + self._seed = seed + self._context_extractor = context_extractor + + super().__init__( + version=self.VERSION, + strategy_class=self.get_strategy_class(), + objective_scorer=objective_scorer, + scenario_result_id=scenario_result_id, + ) + + # ------------------------------------------------------------------ # + # Override atomic-attack construction # + # ------------------------------------------------------------------ # + + async def _get_atomic_attacks_async(self) -> list[AtomicAttack]: + """ + Build one ``AtomicAttack`` per objective, all sharing a single + ``AdaptiveDispatchAttack`` (and therefore a single + ``AdaptiveTechniqueSelector``). + + This is the bandit's "single working memory shared across objectives" + plumbing: each per-objective ``AtomicAttack`` consults and updates the + same selector via the same dispatcher instance. + """ + if self._objective_target is None: + raise ValueError( + "Scenario not properly initialized. Call await scenario.initialize_async() before running." + ) + + from pyrit.scenario.core.atomic_attack import AtomicAttack + + selected_arms = sorted({s.value for s in self._scenario_strategies}) + factories = self._get_attack_technique_factories() + + # Build each arm's inner attack once and reuse across all objectives. + scoring_config = AttackScoringConfig(objective_scorer=cast("TrueFalseScorer", self._objective_scorer)) + arms: dict[str, AttackStrategy] = {} + for technique_name in selected_arms: + factory = factories.get(technique_name) + if factory is None: + logger.warning(f"No factory for technique '{technique_name}', skipping.") + continue + technique = factory.create( + objective_target=self._objective_target, + attack_scoring_config=scoring_config, + ) + arms[technique_name] = technique.attack + + if not arms: + raise ValueError( + "TextAdaptive: no usable techniques after resolving strategies. " + "Check the --strategies selection." + ) + + selector = AdaptiveTechniqueSelector( + epsilon=self._epsilon, + pool_threshold=self._pool_threshold, + rng=random.Random(self._seed), + ) + dispatcher = AdaptiveDispatchAttack( + objective_target=self._objective_target, + arms=arms, + selector=selector, + max_attempts_per_objective=self._max_attempts_per_objective, + ) + # Stash for tests / debugging; not part of the public API. + self._selector = selector + self._dispatcher = dispatcher + + seed_groups_by_dataset = self._dataset_config.get_seed_attack_groups() + atomic_attacks: list[AtomicAttack] = [] + for dataset_name, seed_groups in seed_groups_by_dataset.items(): + for seed_group in seed_groups: + atomic_attacks.append( + self._build_atomic_for_seed_group( + dataset_name=dataset_name, + seed_group=seed_group, + dispatcher=dispatcher, + ) + ) + + return atomic_attacks + + def _build_atomic_for_seed_group( + self, + *, + dataset_name: str, + seed_group: SeedAttackGroup, + dispatcher: AdaptiveDispatchAttack, + ) -> AtomicAttack: + from pyrit.scenario.core.atomic_attack import AtomicAttack + from pyrit.scenario.core.attack_technique import AttackTechnique + + bandit_context = self._context_extractor(seed_group) + # Use the objective's id when available so resume keys are stable across + # runs that re-fetch the same seed groups; fall back to a random uuid. + objective_id = seed_group.objective.id if seed_group.objective.id else uuid.uuid4() + atomic_attack_name = f"adaptive_{dataset_name}_{objective_id}" + + memory_labels = { + **self._memory_labels, + BANDIT_CONTEXT_LABEL: bandit_context, + } + return AtomicAttack( + atomic_attack_name=atomic_attack_name, + attack_technique=AttackTechnique(attack=dispatcher), + seed_groups=[seed_group], + objective_scorer=cast("TrueFalseScorer", self._objective_scorer), + memory_labels=memory_labels, + display_group=dataset_name, + ) diff --git a/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py b/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py new file mode 100644 index 0000000000..68051f3d59 --- /dev/null +++ b/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py @@ -0,0 +1,225 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import random +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from pyrit.executor.attack.core.attack_parameters import AttackParameters +from pyrit.models import AttackOutcome, AttackResult +from pyrit.scenario.scenarios.adaptive.dispatcher import ( + ADAPTIVE_ARM_LABEL, + ADAPTIVE_ATTEMPT_LABEL, + BANDIT_CONTEXT_LABEL, + AdaptiveDispatchAttack, + AdaptiveDispatchContext, +) +from pyrit.scenario.scenarios.adaptive.selector import ( + GLOBAL_CONTEXT, + AdaptiveTechniqueSelector, +) + + +def _make_inner_attack(*, name: str, outcomes: list[AttackOutcome]) -> MagicMock: + """Build a mocked inner attack whose execute_async returns the given outcomes in order.""" + inner = MagicMock(name=name) + results = [ + AttackResult( + conversation_id=f"conv-{name}-{i}", + objective="obj", + outcome=outcome, + ) + for i, outcome in enumerate(outcomes) + ] + inner.execute_async = AsyncMock(side_effect=results) + return inner + + +def _make_context(*, objective: str = "obj", labels: dict[str, str] | None = None) -> AdaptiveDispatchContext: + return AdaptiveDispatchContext(params=AttackParameters(objective=objective, memory_labels=labels or {})) + + +@pytest.fixture +def selector() -> AdaptiveTechniqueSelector: + # epsilon=0 makes selection deterministic given the table. + return AdaptiveTechniqueSelector(epsilon=0.0, pool_threshold=1, rng=random.Random(0)) + + +@pytest.fixture +def target() -> MagicMock: + return MagicMock(name="objective_target") + + +class TestInit: + @pytest.mark.usefixtures("patch_central_database") + def test_init_rejects_empty_arms(self, target, selector): + with pytest.raises(ValueError, match="arms"): + AdaptiveDispatchAttack(objective_target=target, arms={}, selector=selector) + + @pytest.mark.parametrize("bad_max", [0, -1]) + @pytest.mark.usefixtures("patch_central_database") + def test_init_rejects_invalid_max_attempts(self, target, selector, bad_max): + with pytest.raises(ValueError, match="max_attempts_per_objective"): + AdaptiveDispatchAttack( + objective_target=target, + arms={"a": _make_inner_attack(name="a", outcomes=[AttackOutcome.SUCCESS])}, + selector=selector, + max_attempts_per_objective=bad_max, + ) + + +@pytest.mark.usefixtures("patch_central_database") +class TestPerform: + async def test_stops_on_first_success(self, target, selector): + a = _make_inner_attack(name="a", outcomes=[AttackOutcome.SUCCESS]) + b = _make_inner_attack(name="b", outcomes=[AttackOutcome.SUCCESS]) + dispatcher = AdaptiveDispatchAttack( + objective_target=target, + arms={"a": a, "b": b}, + selector=selector, + max_attempts_per_objective=5, + ) + + result = await dispatcher._perform_async(context=_make_context()) + + assert result.outcome == AttackOutcome.SUCCESS + total_calls = a.execute_async.call_count + b.execute_async.call_count + assert total_calls == 1 + + async def test_retries_until_max_attempts_on_failure(self, target, selector): + a = _make_inner_attack(name="a", outcomes=[AttackOutcome.FAILURE] * 3) + b = _make_inner_attack(name="b", outcomes=[AttackOutcome.FAILURE] * 3) + dispatcher = AdaptiveDispatchAttack( + objective_target=target, + arms={"a": a, "b": b}, + selector=selector, + max_attempts_per_objective=3, + ) + + result = await dispatcher._perform_async(context=_make_context()) + + assert result.outcome == AttackOutcome.FAILURE + total_calls = a.execute_async.call_count + b.execute_async.call_count + assert total_calls == 3 + + async def test_updates_selector_on_each_attempt(self, target, selector): + a = _make_inner_attack(name="a", outcomes=[AttackOutcome.FAILURE, AttackOutcome.SUCCESS]) + b = _make_inner_attack(name="b", outcomes=[AttackOutcome.SUCCESS]) + dispatcher = AdaptiveDispatchAttack( + objective_target=target, + arms={"a": a, "b": b}, + selector=selector, + max_attempts_per_objective=3, + ) + + await dispatcher._perform_async(context=_make_context()) + + # Total attempts across arms must equal sum of selector counts. + total_attempts = sum( + selector.counts(context=GLOBAL_CONTEXT, technique=t)[1] for t in ("a", "b") + ) + total_calls = a.execute_async.call_count + b.execute_async.call_count + assert total_attempts == total_calls + + async def test_passes_objective_to_inner(self, target, selector): + a = _make_inner_attack(name="a", outcomes=[AttackOutcome.SUCCESS]) + dispatcher = AdaptiveDispatchAttack( + objective_target=target, + arms={"a": a}, + selector=selector, + ) + + await dispatcher._perform_async(context=_make_context(objective="my-goal")) + + kwargs = a.execute_async.call_args.kwargs + assert kwargs["objective"] == "my-goal" + + async def test_attaches_arm_and_attempt_labels(self, target, selector): + a = _make_inner_attack(name="a", outcomes=[AttackOutcome.SUCCESS]) + dispatcher = AdaptiveDispatchAttack( + objective_target=target, + arms={"a": a}, + selector=selector, + ) + + await dispatcher._perform_async(context=_make_context(labels={"foo": "bar"})) + + labels = a.execute_async.call_args.kwargs["memory_labels"] + assert labels["foo"] == "bar" # caller labels preserved + assert labels[ADAPTIVE_ARM_LABEL] == "a" + assert labels[ADAPTIVE_ATTEMPT_LABEL] == "1" + + async def test_uses_bandit_context_from_label(self, target, selector): + # Two arms; one has been heavily rewarded under context "violence" only. + a = _make_inner_attack(name="a", outcomes=[AttackOutcome.SUCCESS]) + b = _make_inner_attack(name="b", outcomes=[AttackOutcome.SUCCESS]) + for _ in range(5): + selector.update(context="violence", technique="b", success=True) + for _ in range(5): + selector.update(context="violence", technique="a", success=False) + + dispatcher = AdaptiveDispatchAttack( + objective_target=target, + arms={"a": a, "b": b}, + selector=selector, + ) + ctx = _make_context(labels={BANDIT_CONTEXT_LABEL: "violence"}) + await dispatcher._perform_async(context=ctx) + + # Exploit should have picked "b" first. + assert b.execute_async.call_count == 1 + assert a.execute_async.call_count == 0 + + async def test_falls_back_to_global_context_when_label_missing(self, target, selector): + a = _make_inner_attack(name="a", outcomes=[AttackOutcome.SUCCESS]) + dispatcher = AdaptiveDispatchAttack( + objective_target=target, + arms={"a": a}, + selector=selector, + ) + await dispatcher._perform_async(context=_make_context(labels={})) + + # The global context bucket received the update. + assert selector.counts(context=GLOBAL_CONTEXT, technique="a") == (1, 1) + + async def test_metadata_records_adaptive_trail(self, target, selector): + # Arm "a" fails on the first attempt then succeeds; verify the trail + # captures both attempts in order. + a = _make_inner_attack(name="a", outcomes=[AttackOutcome.FAILURE, AttackOutcome.SUCCESS]) + dispatcher = AdaptiveDispatchAttack( + objective_target=target, + arms={"a": a}, + selector=selector, + max_attempts_per_objective=3, + ) + result = await dispatcher._perform_async(context=_make_context()) + + trail = result.metadata["adaptive_attempts"] + assert trail == [ + {"technique": "a", "outcome": "failure"}, + {"technique": "a", "outcome": "success"}, + ] + assert result.metadata["adaptive_context"] == GLOBAL_CONTEXT + + +@pytest.mark.usefixtures("patch_central_database") +class TestValidate: + @pytest.mark.parametrize("bad_objective", ["", " ", "\n\t"]) + def test_validate_rejects_empty_objective(self, target, selector, bad_objective): + dispatcher = AdaptiveDispatchAttack( + objective_target=target, + arms={"a": _make_inner_attack(name="a", outcomes=[AttackOutcome.SUCCESS])}, + selector=selector, + ) + with pytest.raises(ValueError, match="objective"): + dispatcher._validate_context(context=_make_context(objective=bad_objective)) + + def test_validate_accepts_normal_objective(self, target, selector): + dispatcher = AdaptiveDispatchAttack( + objective_target=target, + arms={"a": _make_inner_attack(name="a", outcomes=[AttackOutcome.SUCCESS])}, + selector=selector, + ) + # Does not raise. + dispatcher._validate_context(context=_make_context(objective="ok")) diff --git a/tests/unit/scenario/scenarios/adaptive/test_selector.py b/tests/unit/scenario/scenarios/adaptive/test_selector.py new file mode 100644 index 0000000000..7b5c75958b --- /dev/null +++ b/tests/unit/scenario/scenarios/adaptive/test_selector.py @@ -0,0 +1,188 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import random +from unittest.mock import MagicMock + +import pytest + +from pyrit.scenario.scenarios.adaptive.selector import ( + GLOBAL_CONTEXT, + UNCATEGORIZED_CONTEXT, + AdaptiveTechniqueSelector, + global_context, + harm_category_context, +) + + +ARMS = ["a", "b", "c", "d"] + + +def _seeded_selector(*, epsilon: float = 0.0, pool_threshold: int = 3, seed: int = 0) -> AdaptiveTechniqueSelector: + return AdaptiveTechniqueSelector( + epsilon=epsilon, + pool_threshold=pool_threshold, + rng=random.Random(seed), + ) + + +class TestAdaptiveTechniqueSelectorInit: + def test_init_defaults(self): + selector = AdaptiveTechniqueSelector() + assert selector.snapshot() == {} + + @pytest.mark.parametrize("bad_epsilon", [-0.1, 1.1, 2.0, -1.0]) + def test_init_rejects_out_of_range_epsilon(self, bad_epsilon): + with pytest.raises(ValueError, match="epsilon"): + AdaptiveTechniqueSelector(epsilon=bad_epsilon) + + def test_init_rejects_pool_threshold_below_one(self): + with pytest.raises(ValueError, match="pool_threshold"): + AdaptiveTechniqueSelector(pool_threshold=0) + with pytest.raises(ValueError, match="pool_threshold"): + AdaptiveTechniqueSelector(pool_threshold=-1) + + +class TestAdaptiveTechniqueSelectorSelect: + def test_select_empty_arms_raises(self): + selector = _seeded_selector() + with pytest.raises(ValueError, match="arms"): + selector.select(context=GLOBAL_CONTEXT, arms=[]) + + def test_select_all_unseen_ties_resolved_randomly(self): + # With epsilon=0 and an empty table, every arm has estimate 1/1=1.0, + # so the result is the seeded random tiebreak. Different seeds should + # be able to produce different winners. + winners = { + _seeded_selector(seed=s).select(context=GLOBAL_CONTEXT, arms=ARMS) + for s in range(50) + } + assert len(winners) > 1 + assert winners.issubset(set(ARMS)) + + def test_select_exploits_clear_winner(self): + selector = _seeded_selector(pool_threshold=1) + # Give "b" a track record of pure success, others pure failure. + for _ in range(5): + selector.update(context=GLOBAL_CONTEXT, technique="b", success=True) + for arm in ("a", "c", "d"): + for _ in range(5): + selector.update(context=GLOBAL_CONTEXT, technique=arm, success=False) + + # With epsilon=0, every selection must exploit the winner. + for _ in range(20): + assert selector.select(context=GLOBAL_CONTEXT, arms=ARMS) == "b" + + def test_select_epsilon_one_is_pure_random(self): + selector = _seeded_selector(epsilon=1.0) + # Bias the table heavily toward "a"; with epsilon=1 it must still be ignored. + for _ in range(20): + selector.update(context=GLOBAL_CONTEXT, technique="a", success=True) + + picks = [selector.select(context=GLOBAL_CONTEXT, arms=ARMS) for _ in range(200)] + assert set(picks) == set(ARMS) + + def test_select_epsilon_zero_never_explores(self): + selector = _seeded_selector(epsilon=0.0, pool_threshold=1) + for _ in range(3): + selector.update(context=GLOBAL_CONTEXT, technique="a", success=True) + # Make the other arms tried-and-failed so they fall below "a"'s estimate; + # unseen arms would otherwise tie at the optimistic 1.0. + for arm in ("b", "c", "d"): + selector.update(context=GLOBAL_CONTEXT, technique=arm, success=False) + for _ in range(50): + assert selector.select(context=GLOBAL_CONTEXT, arms=ARMS) == "a" + + def test_select_cold_start_round_robins(self): + # Optimistic init + epsilon=0: untried arms tie at 1.0 and beat tried-and-failed + # arms (1/2 = 0.5). So the first failures push each arm to "tried" exactly once + # before any arm gets tried twice. + selector = _seeded_selector(pool_threshold=1) + tried: list[str] = [] + for _ in range(len(ARMS)): + arm = selector.select(context=GLOBAL_CONTEXT, arms=ARMS) + tried.append(arm) + selector.update(context=GLOBAL_CONTEXT, technique=arm, success=False) + assert sorted(tried) == sorted(ARMS) + + +class TestAdaptiveTechniqueSelectorUpdate: + def test_update_accumulates_counts(self): + selector = _seeded_selector() + selector.update(context="ctx", technique="a", success=True) + selector.update(context="ctx", technique="a", success=False) + selector.update(context="ctx", technique="a", success=True) + assert selector.counts(context="ctx", technique="a") == (2, 3) + + def test_update_separate_contexts_are_independent(self): + selector = _seeded_selector() + selector.update(context="x", technique="a", success=True) + selector.update(context="y", technique="a", success=False) + assert selector.counts(context="x", technique="a") == (1, 1) + assert selector.counts(context="y", technique="a") == (0, 1) + + def test_counts_default_zero_for_unseen(self): + selector = _seeded_selector() + assert selector.counts(context="missing", technique="missing") == (0, 0) + + def test_update_keeps_pooled_global_counts_in_sync(self): + # Pooled-global counts back the O(1) pooled-backoff branch in _estimate. + # They must aggregate across contexts for a given arm. + selector = _seeded_selector(pool_threshold=5) + selector.update(context="x", technique="a", success=True) + selector.update(context="y", technique="a", success=False) + selector.update(context="z", technique="a", success=True) + selector.update(context="x", technique="b", success=True) + + # Below the local threshold, _estimate must use the pooled-global rate. + # arm "a": 2 successes / 3 attempts -> (2+1)/(3+1) = 0.75 + assert selector.success_rate(context="new_ctx", technique="a") == pytest.approx(0.75) + # arm "b": 1/1 -> (1+1)/(1+1) = 1.0 + assert selector.success_rate(context="new_ctx", technique="b") == pytest.approx(1.0) + # Unseen arm "c" -> (0+1)/(0+1) = 1.0 + assert selector.success_rate(context="new_ctx", technique="c") == pytest.approx(1.0) + + +class TestAdaptiveTechniqueSelectorEstimate: + def test_success_rate_unseen_is_one(self): + # Optimistic init: (0 + 1) / (0 + 1) = 1.0 + selector = _seeded_selector() + assert selector.success_rate(context="ctx", technique="a") == pytest.approx(1.0) + + def test_success_rate_local_when_above_threshold(self): + selector = _seeded_selector(pool_threshold=2) + for _ in range(3): + selector.update(context="ctx", technique="a", success=True) + # (3 + 1) / (3 + 1) = 1.0 + assert selector.success_rate(context="ctx", technique="a") == pytest.approx(1.0) + + def test_success_rate_pools_when_below_threshold(self): + selector = _seeded_selector(pool_threshold=5) + # Local cell has only 1 attempt (below threshold). + selector.update(context="ctx", technique="a", success=False) + # Other contexts have plenty of data for arm "a". + for _ in range(10): + selector.update(context="other", technique="a", success=True) + # Pooled estimate = (10 + 0 + 1) / (10 + 1 + 1) = 11/12. + assert selector.success_rate(context="ctx", technique="a") == pytest.approx(11 / 12) + + +class TestContextExtractors: + def test_global_context_is_constant(self): + sg = MagicMock() + assert global_context(sg) == GLOBAL_CONTEXT + + def test_harm_category_context_uses_first_category(self): + sg = MagicMock() + sg.harm_categories = ["violence", "hate"] + assert harm_category_context(sg) == "violence" + + def test_harm_category_context_falls_back_when_empty(self): + sg = MagicMock() + sg.harm_categories = [] + assert harm_category_context(sg) == UNCATEGORIZED_CONTEXT + + def test_harm_category_context_falls_back_when_none(self): + sg = MagicMock() + sg.harm_categories = None + assert harm_category_context(sg) == UNCATEGORIZED_CONTEXT diff --git a/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py b/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py new file mode 100644 index 0000000000..82796def42 --- /dev/null +++ b/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py @@ -0,0 +1,282 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""Tests for the ``TextAdaptive`` scenario.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from pyrit.identifiers import ComponentIdentifier +from pyrit.models import SeedAttackGroup, SeedObjective +from pyrit.prompt_target import PromptTarget +from pyrit.registry.object_registries.attack_technique_registry import AttackTechniqueRegistry +from pyrit.scenario.core.dataset_configuration import DatasetConfiguration +from pyrit.scenario.core.scenario import BaselinePolicy +from pyrit.scenario.scenarios.adaptive.dispatcher import ( + BANDIT_CONTEXT_LABEL, + AdaptiveDispatchAttack, +) +from pyrit.scenario.scenarios.adaptive.selector import ( + GLOBAL_CONTEXT, + AdaptiveTechniqueSelector, + harm_category_context, +) +from pyrit.scenario.scenarios.adaptive.text_adaptive import TextAdaptive +from pyrit.score import TrueFalseScorer + + +_MOCK_MANY_SHOT_EXAMPLES = [{"question": f"q{i}", "answer": f"a{i}"} for i in range(100)] + + +def _mock_id(name: str) -> ComponentIdentifier: + return ComponentIdentifier(class_name=name, class_module="test") + + +@pytest.fixture +def mock_objective_target() -> MagicMock: + mock = MagicMock(spec=PromptTarget) + mock.get_identifier.return_value = _mock_id("MockObjectiveTarget") + return mock + + +@pytest.fixture +def mock_objective_scorer() -> MagicMock: + mock = MagicMock(spec=TrueFalseScorer) + mock.get_identifier.return_value = _mock_id("MockObjectiveScorer") + return mock + + +@pytest.fixture(autouse=True) +def reset_technique_registry(): + """Reset registries and the cached strategy class between tests.""" + from pyrit.registry import TargetRegistry + + AttackTechniqueRegistry.reset_instance() + TargetRegistry.reset_instance() + TextAdaptive._cached_strategy_class = None + yield + AttackTechniqueRegistry.reset_instance() + TargetRegistry.reset_instance() + TextAdaptive._cached_strategy_class = None + + +@pytest.fixture(autouse=True) +def patch_many_shot_load(): + with patch( + "pyrit.executor.attack.single_turn.many_shot_jailbreak.load_many_shot_jailbreaking_dataset", + return_value=_MOCK_MANY_SHOT_EXAMPLES, + ): + yield + + +@pytest.fixture +def mock_runtime_env(): + with patch.dict( + "os.environ", + { + "OPENAI_CHAT_ENDPOINT": "https://test.openai.azure.com/", + "OPENAI_CHAT_KEY": "test-key", + "OPENAI_CHAT_MODEL": "gpt-4", + }, + ): + yield + + +def _make_seed_group(*, value: str, harm_categories: list[str] | None = None) -> SeedAttackGroup: + return SeedAttackGroup(seeds=[SeedObjective(value=value, harm_categories=harm_categories)]) + + +FIXTURES = ["patch_central_database", "mock_runtime_env"] + + +@pytest.mark.usefixtures(*FIXTURES) +class TestTextAdaptiveBasics: + def test_version(self): + assert TextAdaptive.VERSION == 1 + + def test_baseline_forbidden(self): + assert TextAdaptive.BASELINE_POLICY is BaselinePolicy.Forbidden + + def test_default_dataset_config(self): + config = TextAdaptive.default_dataset_config() + assert isinstance(config, DatasetConfiguration) + assert config.max_dataset_size == 4 + + def test_required_datasets_non_empty(self): + assert len(TextAdaptive.required_datasets()) > 0 + + def test_get_strategy_class_is_cached(self): + cls_a = TextAdaptive.get_strategy_class() + cls_b = TextAdaptive.get_strategy_class() + assert cls_a is cls_b + + def test_get_default_strategy(self): + strat = TextAdaptive.get_default_strategy() + # The default aggregate must resolve to something runnable. + assert strat is not None + + @patch("pyrit.scenario.core.scenario.Scenario._get_default_objective_scorer") + def test_init_stores_bandit_params(self, mock_get_scorer, mock_objective_scorer): + mock_get_scorer.return_value = mock_objective_scorer + scenario = TextAdaptive( + epsilon=0.4, + pool_threshold=5, + max_attempts_per_objective=7, + seed=42, + ) + assert scenario._epsilon == 0.4 + assert scenario._pool_threshold == 5 + assert scenario._max_attempts_per_objective == 7 + assert scenario._seed == 42 + + +@pytest.mark.usefixtures(*FIXTURES) +class TestTextAdaptiveAtomicAttacks: + """Tests for ``_get_atomic_attacks_async`` overriding.""" + + async def _build_scenario_and_attacks( + self, + *, + mock_objective_target, + mock_objective_scorer, + seed_groups: dict[str, list[SeedAttackGroup]], + **scenario_kwargs, + ): + with patch.object(DatasetConfiguration, "get_seed_attack_groups", return_value=seed_groups): + scenario = TextAdaptive( + objective_scorer=mock_objective_scorer, + **scenario_kwargs, + ) + await scenario.initialize_async( + objective_target=mock_objective_target, + include_baseline=False, + ) + return scenario, await scenario._get_atomic_attacks_async() + + async def test_one_atomic_per_objective(self, mock_objective_target, mock_objective_scorer): + groups = { + "violence": [ + _make_seed_group(value="obj-v1", harm_categories=["violence"]), + _make_seed_group(value="obj-v2", harm_categories=["violence"]), + ], + "hate": [ + _make_seed_group(value="obj-h1", harm_categories=["hate"]), + ], + } + _scenario, attacks = await self._build_scenario_and_attacks( + mock_objective_target=mock_objective_target, + mock_objective_scorer=mock_objective_scorer, + seed_groups=groups, + ) + assert len(attacks) == 3 + for atomic in attacks: + # Each atomic carries exactly one seed group. + assert len(atomic.objectives) == 1 + + async def test_all_atomics_share_one_dispatcher(self, mock_objective_target, mock_objective_scorer): + groups = { + "violence": [ + _make_seed_group(value="obj-v1", harm_categories=["violence"]), + _make_seed_group(value="obj-v2", harm_categories=["violence"]), + ], + } + scenario, attacks = await self._build_scenario_and_attacks( + mock_objective_target=mock_objective_target, + mock_objective_scorer=mock_objective_scorer, + seed_groups=groups, + ) + dispatchers = {atomic._attack_technique.attack for atomic in attacks} + assert len(dispatchers) == 1 + assert isinstance(next(iter(dispatchers)), AdaptiveDispatchAttack) + assert isinstance(scenario._selector, AdaptiveTechniqueSelector) + + async def test_global_context_label_when_using_global_extractor( + self, mock_objective_target, mock_objective_scorer + ): + groups = { + "violence": [_make_seed_group(value="obj-1", harm_categories=["violence"])], + "hate": [_make_seed_group(value="obj-2", harm_categories=["hate"])], + } + _scenario, attacks = await self._build_scenario_and_attacks( + mock_objective_target=mock_objective_target, + mock_objective_scorer=mock_objective_scorer, + seed_groups=groups, + ) + for atomic in attacks: + assert atomic._memory_labels[BANDIT_CONTEXT_LABEL] == GLOBAL_CONTEXT + + async def test_harm_category_extractor_partitions_labels( + self, mock_objective_target, mock_objective_scorer + ): + groups = { + "violence": [_make_seed_group(value="obj-v", harm_categories=["violence"])], + "hate": [_make_seed_group(value="obj-h", harm_categories=["hate"])], + "uncat": [_make_seed_group(value="obj-u", harm_categories=None)], + } + _scenario, attacks = await self._build_scenario_and_attacks( + mock_objective_target=mock_objective_target, + mock_objective_scorer=mock_objective_scorer, + seed_groups=groups, + context_extractor=harm_category_context, + ) + contexts = {atomic._memory_labels[BANDIT_CONTEXT_LABEL] for atomic in attacks} + # Each objective gets its own context bucket from harm_category_context. + assert contexts == {"violence", "hate", "_uncategorized"} + + async def test_atomic_names_are_unique(self, mock_objective_target, mock_objective_scorer): + groups = { + "violence": [ + _make_seed_group(value=f"obj-{i}", harm_categories=["violence"]) for i in range(5) + ], + } + _scenario, attacks = await self._build_scenario_and_attacks( + mock_objective_target=mock_objective_target, + mock_objective_scorer=mock_objective_scorer, + seed_groups=groups, + ) + names = [atomic.atomic_attack_name for atomic in attacks] + assert len(set(names)) == len(names) + + async def test_display_group_is_dataset_name(self, mock_objective_target, mock_objective_scorer): + groups = { + "violence": [_make_seed_group(value="obj-v", harm_categories=["violence"])], + "hate": [_make_seed_group(value="obj-h", harm_categories=["hate"])], + } + _scenario, attacks = await self._build_scenario_and_attacks( + mock_objective_target=mock_objective_target, + mock_objective_scorer=mock_objective_scorer, + seed_groups=groups, + ) + display_groups = {atomic.display_group for atomic in attacks} + assert display_groups == {"violence", "hate"} + + async def test_no_usable_techniques_raises(self, mock_objective_target, mock_objective_scorer): + groups = {"violence": [_make_seed_group(value="obj")]} + with patch.object(DatasetConfiguration, "get_seed_attack_groups", return_value=groups): + scenario = TextAdaptive(objective_scorer=mock_objective_scorer) + await scenario.initialize_async( + objective_target=mock_objective_target, + include_baseline=False, + ) + # Force the factory map to be empty. + with patch.object(scenario, "_get_attack_technique_factories", return_value={}): + with pytest.raises(ValueError, match="no usable techniques"): + await scenario._get_atomic_attacks_async() + + +@pytest.mark.usefixtures(*FIXTURES) +class TestTextAdaptiveBaselinePolicy: + async def test_initialize_async_rejects_explicit_baseline( + self, mock_objective_target, mock_objective_scorer + ): + groups = {"violence": [_make_seed_group(value="obj", harm_categories=["violence"])]} + with patch.object(DatasetConfiguration, "get_seed_attack_groups", return_value=groups): + scenario = TextAdaptive(objective_scorer=mock_objective_scorer) + with pytest.raises(ValueError): + await scenario.initialize_async( + objective_target=mock_objective_target, + include_baseline=True, + ) From 09e3007c47ee9c5b44353c17778557ed2bf86c1f Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Mon, 18 May 2026 11:19:48 -0400 Subject: [PATCH 02/35] merge --- pyrit/scenario/scenarios/adaptive/dispatcher.py | 7 +++---- pyrit/scenario/scenarios/adaptive/selector.py | 7 ++++--- .../scenarios/adaptive/text_adaptive.py | 5 +---- .../scenarios/adaptive/test_dispatcher.py | 4 +--- .../scenarios/adaptive/test_selector.py | 6 +----- .../scenarios/adaptive/test_text_adaptive.py | 17 ++++------------- 6 files changed, 14 insertions(+), 32 deletions(-) diff --git a/pyrit/scenario/scenarios/adaptive/dispatcher.py b/pyrit/scenario/scenarios/adaptive/dispatcher.py index ae1087a143..7b5d6b5f31 100644 --- a/pyrit/scenario/scenarios/adaptive/dispatcher.py +++ b/pyrit/scenario/scenarios/adaptive/dispatcher.py @@ -45,7 +45,8 @@ @dataclass class AdaptiveDispatchContext(AttackContext[AttackParameters]): - """Execution context for ``AdaptiveDispatchAttack``. + """ + Execution context for ``AdaptiveDispatchAttack``. No extra state is needed beyond what ``AttackContext`` provides; the dispatcher reads the objective and memory labels from the base class. @@ -91,9 +92,7 @@ def __init__( if not arms: raise ValueError("arms must contain at least one technique") if max_attempts_per_objective < 1: - raise ValueError( - f"max_attempts_per_objective must be >= 1, got {max_attempts_per_objective}" - ) + raise ValueError(f"max_attempts_per_objective must be >= 1, got {max_attempts_per_objective}") super().__init__( objective_target=objective_target, diff --git a/pyrit/scenario/scenarios/adaptive/selector.py b/pyrit/scenario/scenarios/adaptive/selector.py index ff2794757a..495c13b7c2 100644 --- a/pyrit/scenario/scenarios/adaptive/selector.py +++ b/pyrit/scenario/scenarios/adaptive/selector.py @@ -20,7 +20,8 @@ from __future__ import annotations import random -from typing import TYPE_CHECKING, Callable, Sequence +from collections.abc import Callable, Sequence +from typing import TYPE_CHECKING if TYPE_CHECKING: from pyrit.models.seeds.seed_attack_group import SeedAttackGroup @@ -34,12 +35,12 @@ UNCATEGORIZED_CONTEXT: str = "_uncategorized" -def global_context(_seed_attack_group: "SeedAttackGroup") -> str: +def global_context(_seed_attack_group: SeedAttackGroup) -> str: """Return a constant context so all objectives share one bandit table.""" return GLOBAL_CONTEXT -def harm_category_context(seed_attack_group: "SeedAttackGroup") -> str: +def harm_category_context(seed_attack_group: SeedAttackGroup) -> str: """Return the first harm category on the seed group, or a fallback.""" categories = seed_attack_group.harm_categories if not categories: diff --git a/pyrit/scenario/scenarios/adaptive/text_adaptive.py b/pyrit/scenario/scenarios/adaptive/text_adaptive.py index 2fa97b7061..e9be071be5 100644 --- a/pyrit/scenario/scenarios/adaptive/text_adaptive.py +++ b/pyrit/scenario/scenarios/adaptive/text_adaptive.py @@ -191,8 +191,6 @@ async def _get_atomic_attacks_async(self) -> list[AtomicAttack]: "Scenario not properly initialized. Call await scenario.initialize_async() before running." ) - from pyrit.scenario.core.atomic_attack import AtomicAttack - selected_arms = sorted({s.value for s in self._scenario_strategies}) factories = self._get_attack_technique_factories() @@ -212,8 +210,7 @@ async def _get_atomic_attacks_async(self) -> list[AtomicAttack]: if not arms: raise ValueError( - "TextAdaptive: no usable techniques after resolving strategies. " - "Check the --strategies selection." + "TextAdaptive: no usable techniques after resolving strategies. Check the --strategies selection." ) selector = AdaptiveTechniqueSelector( diff --git a/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py b/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py index 68051f3d59..9e26425cfe 100644 --- a/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py +++ b/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py @@ -116,9 +116,7 @@ async def test_updates_selector_on_each_attempt(self, target, selector): await dispatcher._perform_async(context=_make_context()) # Total attempts across arms must equal sum of selector counts. - total_attempts = sum( - selector.counts(context=GLOBAL_CONTEXT, technique=t)[1] for t in ("a", "b") - ) + total_attempts = sum(selector.counts(context=GLOBAL_CONTEXT, technique=t)[1] for t in ("a", "b")) total_calls = a.execute_async.call_count + b.execute_async.call_count assert total_attempts == total_calls diff --git a/tests/unit/scenario/scenarios/adaptive/test_selector.py b/tests/unit/scenario/scenarios/adaptive/test_selector.py index 7b5c75958b..eaddc32ce0 100644 --- a/tests/unit/scenario/scenarios/adaptive/test_selector.py +++ b/tests/unit/scenario/scenarios/adaptive/test_selector.py @@ -14,7 +14,6 @@ harm_category_context, ) - ARMS = ["a", "b", "c", "d"] @@ -53,10 +52,7 @@ def test_select_all_unseen_ties_resolved_randomly(self): # With epsilon=0 and an empty table, every arm has estimate 1/1=1.0, # so the result is the seeded random tiebreak. Different seeds should # be able to produce different winners. - winners = { - _seeded_selector(seed=s).select(context=GLOBAL_CONTEXT, arms=ARMS) - for s in range(50) - } + winners = {_seeded_selector(seed=s).select(context=GLOBAL_CONTEXT, arms=ARMS) for s in range(50)} assert len(winners) > 1 assert winners.issubset(set(ARMS)) diff --git a/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py b/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py index 82796def42..77176e03e5 100644 --- a/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py +++ b/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py @@ -27,7 +27,6 @@ from pyrit.scenario.scenarios.adaptive.text_adaptive import TextAdaptive from pyrit.score import TrueFalseScorer - _MOCK_MANY_SHOT_EXAMPLES = [{"question": f"q{i}", "answer": f"a{i}"} for i in range(100)] @@ -193,9 +192,7 @@ async def test_all_atomics_share_one_dispatcher(self, mock_objective_target, moc assert isinstance(next(iter(dispatchers)), AdaptiveDispatchAttack) assert isinstance(scenario._selector, AdaptiveTechniqueSelector) - async def test_global_context_label_when_using_global_extractor( - self, mock_objective_target, mock_objective_scorer - ): + async def test_global_context_label_when_using_global_extractor(self, mock_objective_target, mock_objective_scorer): groups = { "violence": [_make_seed_group(value="obj-1", harm_categories=["violence"])], "hate": [_make_seed_group(value="obj-2", harm_categories=["hate"])], @@ -208,9 +205,7 @@ async def test_global_context_label_when_using_global_extractor( for atomic in attacks: assert atomic._memory_labels[BANDIT_CONTEXT_LABEL] == GLOBAL_CONTEXT - async def test_harm_category_extractor_partitions_labels( - self, mock_objective_target, mock_objective_scorer - ): + async def test_harm_category_extractor_partitions_labels(self, mock_objective_target, mock_objective_scorer): groups = { "violence": [_make_seed_group(value="obj-v", harm_categories=["violence"])], "hate": [_make_seed_group(value="obj-h", harm_categories=["hate"])], @@ -228,9 +223,7 @@ async def test_harm_category_extractor_partitions_labels( async def test_atomic_names_are_unique(self, mock_objective_target, mock_objective_scorer): groups = { - "violence": [ - _make_seed_group(value=f"obj-{i}", harm_categories=["violence"]) for i in range(5) - ], + "violence": [_make_seed_group(value=f"obj-{i}", harm_categories=["violence"]) for i in range(5)], } _scenario, attacks = await self._build_scenario_and_attacks( mock_objective_target=mock_objective_target, @@ -269,9 +262,7 @@ async def test_no_usable_techniques_raises(self, mock_objective_target, mock_obj @pytest.mark.usefixtures(*FIXTURES) class TestTextAdaptiveBaselinePolicy: - async def test_initialize_async_rejects_explicit_baseline( - self, mock_objective_target, mock_objective_scorer - ): + async def test_initialize_async_rejects_explicit_baseline(self, mock_objective_target, mock_objective_scorer): groups = {"violence": [_make_seed_group(value="obj", harm_categories=["violence"])]} with patch.object(DatasetConfiguration, "get_seed_attack_groups", return_value=groups): scenario = TextAdaptive(objective_scorer=mock_objective_scorer) From 70d14c427e6bcbd8a74700d92dc047ae6e657ecc Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Mon, 18 May 2026 17:23:52 -0400 Subject: [PATCH 03/35] proofread --- doc/code/scenarios/3_text_adaptive.ipynb | 345 ++++++++++++++++++ doc/code/scenarios/3_text_adaptive.py | 220 +++++++++++ pyrit/scenario/scenarios/adaptive/__init__.py | 4 +- .../scenario/scenarios/adaptive/dispatcher.py | 55 +-- pyrit/scenario/scenarios/adaptive/selector.py | 105 +++--- .../scenarios/adaptive/text_adaptive.py | 74 ++-- .../scenarios/adaptive/test_dispatcher.py | 48 +-- .../scenarios/adaptive/test_selector.py | 96 ++--- .../scenarios/adaptive/test_text_adaptive.py | 8 +- 9 files changed, 771 insertions(+), 184 deletions(-) create mode 100644 doc/code/scenarios/3_text_adaptive.ipynb create mode 100644 doc/code/scenarios/3_text_adaptive.py diff --git a/doc/code/scenarios/3_text_adaptive.ipynb b/doc/code/scenarios/3_text_adaptive.ipynb new file mode 100644 index 0000000000..474c1872d9 --- /dev/null +++ b/doc/code/scenarios/3_text_adaptive.ipynb @@ -0,0 +1,345 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "33fbe4e9", + "metadata": {}, + "source": [ + "# TextAdaptive Scenario\n", + "\n", + "The `TextAdaptive` scenario uses an **epsilon-greedy selector** to intelligently choose\n", + "which attack technique to try for each objective. Unlike static scenarios that run every\n", + "selected technique against every objective, `TextAdaptive` adapts its strategy selection\n", + "based on observed success rates — spending more attempts on techniques that work and\n", + "exploring new ones with a configurable probability.\n", + "\n", + "## How It Works\n", + "\n", + "For each objective (prompt), the selector:\n", + "\n", + "1. **Explores** with probability `epsilon` — picks a technique uniformly at random.\n", + "2. **Exploits** otherwise — picks the technique with the highest observed success rate.\n", + "3. **Stops early** when a technique succeeds, avoiding wasted attempts.\n", + "4. Tries **up to** `max_attempts_per_objective` techniques before moving on.\n", + "\n", + "Unseen techniques start with an optimistic prior (100% success estimate), so the first\n", + "few objectives effectively round-robin through every available technique before the\n", + "selector converges on the best performers.\n", + "\n", + "## Key Differences from Static Scenarios\n", + "\n", + "| Feature | Static Scenarios | TextAdaptive |\n", + "|---------|-----------------|--------------|\n", + "| Technique selection | Run all selected techniques | Selector picks per-objective |\n", + "| Early stopping | No | Yes — stops on first success |\n", + "| Learning | None | Updates success rates after each attempt |\n", + "| Baseline | Prepended automatically | Forbidden — `prompt_sending` is a technique |\n", + "| Efficiency | O(techniques × objectives) | O(max_attempts × objectives) |" + ] + }, + { + "cell_type": "markdown", + "id": "84bce821", + "metadata": {}, + "source": [ + "## Setup" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "62eebc44", + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "\n", + "from pyrit.scenario.scenarios.adaptive import TextAdaptive, harm_category_context\n", + "from pyrit.registry import TargetRegistry\n", + "from pyrit.scenario import DatasetConfiguration\n", + "from pyrit.scenario.printer.console_printer import ConsoleScenarioResultPrinter\n", + "from pyrit.setup import initialize_from_config_async\n", + "\n", + "await initialize_from_config_async(config_path=Path(\"../../scanner/pyrit_conf.yaml\")) # type: ignore\n", + "\n", + "objective_target = TargetRegistry.get_registry_singleton().get_instance_by_name(\"openai_chat\")\n", + "printer = ConsoleScenarioResultPrinter()" + ] + }, + { + "cell_type": "markdown", + "id": "93a67a83", + "metadata": {}, + "source": [ + "## Basic Usage\n", + "\n", + "The simplest way to run `TextAdaptive` uses all defaults: the selector explores with 20%\n", + "probability, tries up to 3 techniques per objective, and uses the default dataset\n", + "(AIRT harm categories)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f74ce854", + "metadata": {}, + "outputs": [], + "source": [ + "scenario = TextAdaptive()\n", + "\n", + "await scenario.initialize_async( # type: ignore\n", + " objective_target=objective_target,\n", + ")\n", + "result = await scenario.run_async() # type: ignore\n", + "await printer.print_summary_async(result) # type: ignore" + ] + }, + { + "cell_type": "markdown", + "id": "4bc43876", + "metadata": {}, + "source": [ + "## Customizing the Selector\n", + "\n", + "### Epsilon (Exploration Rate)\n", + "\n", + "`epsilon` controls how often the selector explores vs. exploits:\n", + "- `epsilon=0.0` — pure exploitation (always pick the best-known technique)\n", + "- `epsilon=1.0` — pure exploration (random selection every time)\n", + "- `epsilon=0.2` (default) — 20% random exploration, 80% exploitation" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ac0326e8", + "metadata": {}, + "outputs": [], + "source": [ + "# More explorative selector — useful when you want broader technique coverage\n", + "explorative_scenario = TextAdaptive(epsilon=0.5)\n", + "\n", + "await explorative_scenario.initialize_async( # type: ignore\n", + " objective_target=objective_target,\n", + " dataset_config=DatasetConfiguration(dataset_names=[\"airt_hate\"], max_dataset_size=4),\n", + ")\n", + "explorative_result = await explorative_scenario.run_async() # type: ignore\n", + "await printer.print_summary_async(explorative_result) # type: ignore" + ] + }, + { + "cell_type": "markdown", + "id": "f0ff26ab", + "metadata": {}, + "source": [ + "### Max Attempts Per Objective\n", + "\n", + "`max_attempts_per_objective` caps how many techniques the selector tries before giving\n", + "up on an objective. Setting this higher gives more chances to succeed but costs more\n", + "API calls." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c638b3e5", + "metadata": {}, + "outputs": [], + "source": [ + "persistent_scenario = TextAdaptive(max_attempts_per_objective=5)\n", + "\n", + "await persistent_scenario.initialize_async( # type: ignore\n", + " objective_target=objective_target,\n", + " dataset_config=DatasetConfiguration(dataset_names=[\"airt_violence\"], max_dataset_size=4),\n", + ")\n", + "persistent_result = await persistent_scenario.run_async() # type: ignore\n", + "await printer.print_summary_async(persistent_result) # type: ignore" + ] + }, + { + "cell_type": "markdown", + "id": "3d9e697f", + "metadata": {}, + "source": [ + "## Context-Aware Selection\n", + "\n", + "By default, the selector shares one global table across all objectives. This means\n", + "a technique that works well on hate-speech objectives also gets boosted for\n", + "violence objectives.\n", + "\n", + "To partition the selector by harm category (so each category learns independently),\n", + "pass `harm_category_context` as the `context_extractor`:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "954e9c65", + "metadata": {}, + "outputs": [], + "source": [ + "contextual_scenario = TextAdaptive(\n", + " context_extractor=harm_category_context,\n", + " pool_threshold=2,\n", + ")\n", + "\n", + "await contextual_scenario.initialize_async( # type: ignore\n", + " objective_target=objective_target,\n", + " dataset_config=DatasetConfiguration(\n", + " dataset_names=[\"airt_hate\", \"airt_violence\"],\n", + " max_dataset_size=4,\n", + " ),\n", + ")\n", + "contextual_result = await contextual_scenario.run_async() # type: ignore\n", + "await printer.print_summary_async(contextual_result) # type: ignore" + ] + }, + { + "cell_type": "markdown", + "id": "c6319923", + "metadata": {}, + "source": [ + "The `pool_threshold` parameter controls how many local observations are needed before\n", + "the per-category estimate overrides the pooled-global estimate. With\n", + "`pool_threshold=2`, the selector uses the global average until it has seen at least 2\n", + "results for a specific (category, technique) pair." + ] + }, + { + "cell_type": "markdown", + "id": "df56a4af", + "metadata": {}, + "source": [ + "## Strategy Selection\n", + "\n", + "`TextAdaptive` builds its strategy enum dynamically from the scenario-techniques\n", + "catalog. You can restrict which techniques participate using the\n", + "`scenario_strategies` parameter:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bbd3b91f", + "metadata": {}, + "outputs": [], + "source": [ + "strategy_class = TextAdaptive.get_strategy_class()\n", + "\n", + "# See all available strategies\n", + "print(\"Available strategies:\")\n", + "for member in strategy_class:\n", + " print(f\" {member.value}\")" + ] + }, + { + "cell_type": "markdown", + "id": "004bea90", + "metadata": {}, + "source": [ + "To limit the selector to only single-turn techniques:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f081a04d", + "metadata": {}, + "outputs": [], + "source": [ + "single_turn_scenario = TextAdaptive()\n", + "\n", + "await single_turn_scenario.initialize_async( # type: ignore\n", + " objective_target=objective_target,\n", + " scenario_strategies=[strategy_class(\"single_turn\")],\n", + " dataset_config=DatasetConfiguration(dataset_names=[\"airt_hate\"], max_dataset_size=4),\n", + ")\n", + "single_turn_result = await single_turn_scenario.run_async() # type: ignore\n", + "await printer.print_summary_async(single_turn_result) # type: ignore" + ] + }, + { + "cell_type": "markdown", + "id": "bd48c512", + "metadata": {}, + "source": [ + "## Deterministic Runs\n", + "\n", + "For reproducibility, pass a `seed` to make the selector's random decisions deterministic:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c022bc5c", + "metadata": {}, + "outputs": [], + "source": [ + "deterministic_scenario = TextAdaptive(seed=42, epsilon=0.3)\n", + "\n", + "await deterministic_scenario.initialize_async( # type: ignore\n", + " objective_target=objective_target,\n", + " dataset_config=DatasetConfiguration(dataset_names=[\"airt_hate\"], max_dataset_size=2),\n", + ")\n", + "deterministic_result = await deterministic_scenario.run_async() # type: ignore\n", + "await printer.print_summary_async(deterministic_result) # type: ignore" + ] + }, + { + "cell_type": "markdown", + "id": "0878f42e", + "metadata": {}, + "source": [ + "## Custom Scorer\n", + "\n", + "By default, `TextAdaptive` uses the standard composite scorer. You can override it\n", + "with any `TrueFalseScorer`:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e251342b", + "metadata": {}, + "outputs": [], + "source": [ + "from pyrit.prompt_target import OpenAIChatTarget\n", + "from pyrit.score import SelfAskRefusalScorer, TrueFalseInverterScorer\n", + "\n", + "refusal_scorer = SelfAskRefusalScorer(chat_target=OpenAIChatTarget())\n", + "inverted_scorer = TrueFalseInverterScorer(scorer=refusal_scorer)\n", + "\n", + "custom_scorer_scenario = TextAdaptive(objective_scorer=inverted_scorer)\n", + "\n", + "await custom_scorer_scenario.initialize_async( # type: ignore\n", + " objective_target=objective_target,\n", + " dataset_config=DatasetConfiguration(dataset_names=[\"airt_hate\"], max_dataset_size=2),\n", + ")\n", + "custom_result = await custom_scorer_scenario.run_async() # type: ignore\n", + "await printer.print_summary_async(custom_result) # type: ignore" + ] + }, + { + "cell_type": "markdown", + "id": "ba2bda21", + "metadata": {}, + "source": [ + "## Notes\n", + "\n", + "- **No baseline**: `TextAdaptive` has `BASELINE_POLICY = Forbidden`. The `prompt_sending`\n", + " technique participates as one of the selector's techniques, so a separate baseline is redundant.\n", + "- **Resumability**: Each atomic attack is keyed by `adaptive_{dataset}_{objective_id}`, so\n", + " re-running a scenario picks up where it left off.\n", + "- **Shared selector**: All objectives in a run share the same `AdaptiveTechniqueSelector`\n", + " instance, so learning from one objective immediately benefits the next." + ] + } + ], + "metadata": { + "jupytext": { + "main_language": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/doc/code/scenarios/3_text_adaptive.py b/doc/code/scenarios/3_text_adaptive.py new file mode 100644 index 0000000000..a5774753a3 --- /dev/null +++ b/doc/code/scenarios/3_text_adaptive.py @@ -0,0 +1,220 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.18.1 +# --- + +# %% [markdown] +# # TextAdaptive Scenario +# +# The `TextAdaptive` scenario uses an **epsilon-greedy selector** to intelligently choose +# which attack technique to try for each objective. Unlike static scenarios that run every +# selected technique against every objective, `TextAdaptive` adapts its strategy selection +# based on observed success rates — spending more attempts on techniques that work and +# exploring new ones with a configurable probability. +# +# ## How It Works +# +# For each objective (prompt), the selector: +# +# 1. **Explores** with probability `epsilon` — picks a technique uniformly at random. +# 2. **Exploits** otherwise — picks the technique with the highest observed success rate. +# 3. **Stops early** when a technique succeeds, avoiding wasted attempts. +# 4. Tries **up to** `max_attempts_per_objective` techniques before moving on. +# +# Unseen techniques start with an optimistic prior (100% success estimate), so the first +# few objectives effectively round-robin through every available technique before the +# selector converges on the best performers. +# +# ## Key Differences from Static Scenarios +# +# | Feature | Static Scenarios | TextAdaptive | +# |---------|-----------------|--------------| +# | Technique selection | Run all selected techniques | Selector picks per-objective | +# | Early stopping | No | Yes — stops on first success | +# | Learning | None | Updates success rates after each attempt | +# | Baseline | Prepended automatically | Forbidden — `prompt_sending` is a technique | +# | Efficiency | O(techniques × objectives) | O(max_attempts × objectives) | + +# %% [markdown] +# ## Setup + +# %% +from pathlib import Path + +from pyrit.scenario.scenarios.adaptive import TextAdaptive, harm_category_context +from pyrit.registry import TargetRegistry +from pyrit.scenario import DatasetConfiguration +from pyrit.scenario.printer.console_printer import ConsoleScenarioResultPrinter +from pyrit.setup import initialize_from_config_async + +await initialize_from_config_async(config_path=Path("../../scanner/pyrit_conf.yaml")) # type: ignore + +objective_target = TargetRegistry.get_registry_singleton().get_instance_by_name("openai_chat") +printer = ConsoleScenarioResultPrinter() + +# %% [markdown] +# ## Basic Usage +# +# The simplest way to run `TextAdaptive` uses all defaults: the selector explores with 20% +# probability, tries up to 3 techniques per objective, and uses the default dataset +# (AIRT harm categories). + +# %% +scenario = TextAdaptive() + +await scenario.initialize_async( # type: ignore + objective_target=objective_target, +) +result = await scenario.run_async() # type: ignore +await printer.print_summary_async(result) # type: ignore + +# %% [markdown] +# ## Customizing the Selector +# +# ### Epsilon (Exploration Rate) +# +# `epsilon` controls how often the selector explores vs. exploits: +# - `epsilon=0.0` — pure exploitation (always pick the best-known technique) +# - `epsilon=1.0` — pure exploration (random selection every time) +# - `epsilon=0.2` (default) — 20% random exploration, 80% exploitation + +# %% +# More explorative selector — useful when you want broader technique coverage +explorative_scenario = TextAdaptive(epsilon=0.5) + +await explorative_scenario.initialize_async( # type: ignore + objective_target=objective_target, + dataset_config=DatasetConfiguration(dataset_names=["airt_hate"], max_dataset_size=4), +) +explorative_result = await explorative_scenario.run_async() # type: ignore +await printer.print_summary_async(explorative_result) # type: ignore + +# %% [markdown] +# ### Max Attempts Per Objective +# +# `max_attempts_per_objective` caps how many techniques the selector tries before giving +# up on an objective. Setting this higher gives more chances to succeed but costs more +# API calls. + +# %% +persistent_scenario = TextAdaptive(max_attempts_per_objective=5) + +await persistent_scenario.initialize_async( # type: ignore + objective_target=objective_target, + dataset_config=DatasetConfiguration(dataset_names=["airt_violence"], max_dataset_size=4), +) +persistent_result = await persistent_scenario.run_async() # type: ignore +await printer.print_summary_async(persistent_result) # type: ignore + +# %% [markdown] +# ## Context-Aware Selection +# +# By default, the selector shares one global table across all objectives. This means +# a technique that works well on hate-speech objectives also gets boosted for +# violence objectives. +# +# To partition the selector by harm category (so each category learns independently), +# pass `harm_category_context` as the `context_extractor`: + +# %% +contextual_scenario = TextAdaptive( + context_extractor=harm_category_context, + pool_threshold=2, +) + +await contextual_scenario.initialize_async( # type: ignore + objective_target=objective_target, + dataset_config=DatasetConfiguration( + dataset_names=["airt_hate", "airt_violence"], + max_dataset_size=4, + ), +) +contextual_result = await contextual_scenario.run_async() # type: ignore +await printer.print_summary_async(contextual_result) # type: ignore + +# %% [markdown] +# The `pool_threshold` parameter controls how many local observations are needed before +# the per-category estimate overrides the pooled-global estimate. With +# `pool_threshold=2`, the selector uses the global average until it has seen at least 2 +# results for a specific (category, technique) pair. + +# %% [markdown] +# ## Strategy Selection +# +# `TextAdaptive` builds its strategy enum dynamically from the scenario-techniques +# catalog. You can restrict which techniques participate using the +# `scenario_strategies` parameter: + +# %% +strategy_class = TextAdaptive.get_strategy_class() + +# See all available strategies +print("Available strategies:") +for member in strategy_class: + print(f" {member.value}") + +# %% [markdown] +# To limit the selector to only single-turn techniques: + +# %% +single_turn_scenario = TextAdaptive() + +await single_turn_scenario.initialize_async( # type: ignore + objective_target=objective_target, + scenario_strategies=[strategy_class("single_turn")], + dataset_config=DatasetConfiguration(dataset_names=["airt_hate"], max_dataset_size=4), +) +single_turn_result = await single_turn_scenario.run_async() # type: ignore +await printer.print_summary_async(single_turn_result) # type: ignore + +# %% [markdown] +# ## Deterministic Runs +# +# For reproducibility, pass a `seed` to make the selector's random decisions deterministic: + +# %% +deterministic_scenario = TextAdaptive(seed=42, epsilon=0.3) + +await deterministic_scenario.initialize_async( # type: ignore + objective_target=objective_target, + dataset_config=DatasetConfiguration(dataset_names=["airt_hate"], max_dataset_size=2), +) +deterministic_result = await deterministic_scenario.run_async() # type: ignore +await printer.print_summary_async(deterministic_result) # type: ignore + +# %% [markdown] +# ## Custom Scorer +# +# By default, `TextAdaptive` uses the standard composite scorer. You can override it +# with any `TrueFalseScorer`: + +# %% +from pyrit.prompt_target import OpenAIChatTarget +from pyrit.score import SelfAskRefusalScorer, TrueFalseInverterScorer + +refusal_scorer = SelfAskRefusalScorer(chat_target=OpenAIChatTarget()) +inverted_scorer = TrueFalseInverterScorer(scorer=refusal_scorer) + +custom_scorer_scenario = TextAdaptive(objective_scorer=inverted_scorer) + +await custom_scorer_scenario.initialize_async( # type: ignore + objective_target=objective_target, + dataset_config=DatasetConfiguration(dataset_names=["airt_hate"], max_dataset_size=2), +) +custom_result = await custom_scorer_scenario.run_async() # type: ignore +await printer.print_summary_async(custom_result) # type: ignore + +# %% [markdown] +# ## Notes +# +# - **No baseline**: `TextAdaptive` has `BASELINE_POLICY = Forbidden`. The `prompt_sending` +# technique participates as one of the selector's techniques, so a separate baseline is redundant. +# - **Resumability**: Each atomic attack is keyed by `adaptive_{dataset}_{objective_id}`, so +# re-running a scenario picks up where it left off. +# - **Shared selector**: All objectives in a run share the same `AdaptiveTechniqueSelector` +# instance, so learning from one objective immediately benefits the next. diff --git a/pyrit/scenario/scenarios/adaptive/__init__.py b/pyrit/scenario/scenarios/adaptive/__init__.py index e06e166a62..2fb58b888c 100644 --- a/pyrit/scenario/scenarios/adaptive/__init__.py +++ b/pyrit/scenario/scenarios/adaptive/__init__.py @@ -4,7 +4,7 @@ """Adaptive scenario classes.""" from pyrit.scenario.scenarios.adaptive.dispatcher import ( - BANDIT_CONTEXT_LABEL, + ADAPTIVE_CONTEXT_LABEL, AdaptiveDispatchAttack, ) from pyrit.scenario.scenarios.adaptive.selector import ( @@ -16,9 +16,9 @@ from pyrit.scenario.scenarios.adaptive.text_adaptive import TextAdaptive __all__ = [ + "ADAPTIVE_CONTEXT_LABEL", "AdaptiveDispatchAttack", "AdaptiveTechniqueSelector", - "BANDIT_CONTEXT_LABEL", "ContextExtractor", "TextAdaptive", "global_context", diff --git a/pyrit/scenario/scenarios/adaptive/dispatcher.py b/pyrit/scenario/scenarios/adaptive/dispatcher.py index 7b5d6b5f31..9f6e99c278 100644 --- a/pyrit/scenario/scenarios/adaptive/dispatcher.py +++ b/pyrit/scenario/scenarios/adaptive/dispatcher.py @@ -6,11 +6,11 @@ technique to run for each objective using an ``AdaptiveTechniqueSelector``. This is the execution-side counterpart to the selector. The selector decides -*which arm to pull*; the dispatcher *runs the arm*, records the outcome, and -loops up to ``max_attempts_per_objective`` times. +*which technique to try*; the dispatcher *runs the technique*, records the +outcome, and loops up to ``max_attempts_per_objective`` times. -The dispatcher reads a bandit-context key from -``context.memory_labels[BANDIT_CONTEXT_LABEL]``. The scenario is expected to +The dispatcher reads an adaptive-context key from +``context.memory_labels[ADAPTIVE_CONTEXT_LABEL]``. The scenario is expected to stamp that label per-objective (computed once at atomic-attack construction time via a ``ContextExtractor``). When the label is missing, the global context is used. @@ -36,10 +36,10 @@ logger = logging.getLogger(__name__) -BANDIT_CONTEXT_LABEL: str = "_adaptive_context" -"""Memory-label key whose value is the bandit context string for an objective.""" +"""Memory-label key whose value is the adaptive context string for an objective.""" +ADAPTIVE_CONTEXT_LABEL: str = "_adaptive_context" -ADAPTIVE_ARM_LABEL: str = "_adaptive_arm" +ADAPTIVE_TECHNIQUE_LABEL: str = "_adaptive_technique" ADAPTIVE_ATTEMPT_LABEL: str = "_adaptive_attempt" @@ -56,10 +56,10 @@ class AdaptiveDispatchContext(AttackContext[AttackParameters]): class AdaptiveDispatchAttack(AttackStrategy[AdaptiveDispatchContext, AttackResult]): """ Attack that delegates each attempt to one of several inner ``AttackStrategy`` - instances ("arms"), choosing per attempt via an ``AdaptiveTechniqueSelector``. + instances ("techniques"), choosing per attempt via an ``AdaptiveTechniqueSelector``. For each objective the dispatcher loops up to ``max_attempts_per_objective`` - times. On each iteration it asks the selector which arm to try, executes + times. On each iteration it asks the selector which technique to try, executes the inner attack with the objective, records the outcome on the selector, and stops early on success. @@ -71,7 +71,7 @@ def __init__( self, *, objective_target: PromptTarget, - arms: dict[str, AttackStrategy[Any, AttackResult]], + techniques: dict[str, AttackStrategy[Any, AttackResult]], selector: AdaptiveTechniqueSelector, max_attempts_per_objective: int = 3, ) -> None: @@ -80,17 +80,20 @@ def __init__( objective_target (PromptTarget): The target the inner attacks run against. Stored for identifier/logging parity; the dispatcher does not call the target directly. - arms (dict[str, AttackStrategy[Any, AttackResult]]): Mapping from + techniques (dict[str, AttackStrategy[Any, AttackResult]]): Mapping from technique name to a pre-built inner attack. Must be non-empty. - selector (AdaptiveTechniqueSelector): Shared bandit state. - max_attempts_per_objective (int): Maximum number of arm attempts + These are constructed by the scenario from registered attack + technique factories. + selector (AdaptiveTechniqueSelector): Shared adaptive selection state + that tracks per-technique success rates across objectives. + max_attempts_per_objective (int): Maximum number of technique attempts per objective. Must be >= 1. Defaults to 3. Raises: - ValueError: If ``arms`` is empty or ``max_attempts_per_objective`` < 1. + ValueError: If ``techniques`` is empty or ``max_attempts_per_objective`` < 1. """ - if not arms: - raise ValueError("arms must contain at least one technique") + if not techniques: + raise ValueError("techniques must contain at least one attack technique") if max_attempts_per_objective < 1: raise ValueError(f"max_attempts_per_objective must be >= 1, got {max_attempts_per_objective}") @@ -100,7 +103,7 @@ def __init__( params_type=AttackParameters, logger=logger, ) - self._arms = arms + self._techniques = techniques self._selector = selector self._max_attempts = max_attempts_per_objective @@ -115,26 +118,26 @@ async def _teardown_async(self, *, context: AdaptiveDispatchContext) -> None: pass async def _perform_async(self, *, context: AdaptiveDispatchContext) -> AttackResult: - bandit_context = context.memory_labels.get(BANDIT_CONTEXT_LABEL, GLOBAL_CONTEXT) - arm_names = list(self._arms.keys()) + adaptive_context = context.memory_labels.get(ADAPTIVE_CONTEXT_LABEL, GLOBAL_CONTEXT) + technique_names = list(self._techniques.keys()) last_result: AttackResult | None = None trail: list[dict[str, str]] = [] for attempt_idx in range(self._max_attempts): - chosen = self._selector.select(context=bandit_context, arms=arm_names) - inner = self._arms[chosen] + chosen = self._selector.select(context=adaptive_context, techniques=technique_names) + inner = self._techniques[chosen] attempt_labels = { **context.memory_labels, - ADAPTIVE_ARM_LABEL: chosen, + ADAPTIVE_TECHNIQUE_LABEL: chosen, ADAPTIVE_ATTEMPT_LABEL: str(attempt_idx + 1), } logger.debug( - "AdaptiveDispatchAttack: attempt %d/%d context=%r arm=%r", + "AdaptiveDispatchAttack: attempt %d/%d context=%r technique=%r", attempt_idx + 1, self._max_attempts, - bandit_context, + adaptive_context, chosen, ) @@ -143,7 +146,7 @@ async def _perform_async(self, *, context: AdaptiveDispatchContext) -> AttackRes memory_labels=attempt_labels, ) success = result.outcome == AttackOutcome.SUCCESS - self._selector.update(context=bandit_context, technique=chosen, success=success) + self._selector.record_outcome(context=adaptive_context, technique=chosen, success=success) trail.append({"technique": chosen, "outcome": result.outcome.value}) last_result = result @@ -156,6 +159,6 @@ async def _perform_async(self, *, context: AdaptiveDispatchContext) -> AttackRes last_result.metadata = { **last_result.metadata, "adaptive_attempts": trail, - "adaptive_context": bandit_context, + "adaptive_context": adaptive_context, } return last_result diff --git a/pyrit/scenario/scenarios/adaptive/selector.py b/pyrit/scenario/scenarios/adaptive/selector.py index 495c13b7c2..5f39b49c09 100644 --- a/pyrit/scenario/scenarios/adaptive/selector.py +++ b/pyrit/scenario/scenarios/adaptive/selector.py @@ -5,8 +5,8 @@ Adaptive technique selection for the ``TextAdaptive`` scenario. This module provides: - - ``AdaptiveTechniqueSelector``: an epsilon-greedy bandit keyed by - ``(context, technique)`` that tracks successes/attempts per arm and + - ``AdaptiveTechniqueSelector``: an epsilon-greedy selector keyed by + ``(context, technique)`` that tracks successes/attempts per technique and picks the next technique to try. - ``ContextExtractor``: a callable alias for deriving a context string from a ``SeedAttackGroup``, plus two ready-made extractors: @@ -28,15 +28,24 @@ ContextExtractor = Callable[["SeedAttackGroup"], str] -"""Maps a ``SeedAttackGroup`` to a bandit context key.""" +"""Maps a ``SeedAttackGroup`` to an adaptive context key (e.g. a harm category).""" +# Sentinel context keys used when no per-objective partitioning is desired +# or when a seed group lacks harm category metadata. GLOBAL_CONTEXT: str = "_global" +"""Default context key: all objectives share one selection table.""" UNCATEGORIZED_CONTEXT: str = "_uncategorized" +"""Fallback context for seed groups with no harm category metadata.""" + + +# Context extractors are module-level functions so they can be passed directly +# as the ``context_extractor`` argument to ``TextAdaptive``. They implement the +# ``ContextExtractor`` callable protocol. def global_context(_seed_attack_group: SeedAttackGroup) -> str: - """Return a constant context so all objectives share one bandit table.""" + """Return a constant context so all objectives share one selection table.""" return GLOBAL_CONTEXT @@ -54,28 +63,29 @@ class AdaptiveTechniqueSelector: The selector maintains a table of ``(context, technique) -> (successes, attempts)`` counts. ``select`` returns the next technique to try for a given context, - and ``update`` records the outcome of an attempt. + and ``record_outcome`` records the outcome of an attempt. Selection uses epsilon-greedy with optimistic initialization: - - With probability ``epsilon``, pick uniformly at random from ``arms``. - - Otherwise, pick the arm with the highest estimated success rate. - The estimate is ``(successes + 1) / (attempts + 1)``, so unseen - arms look like 100% success and are explored first via tiebreak. - - When a ``(context, arm)`` cell has fewer than ``pool_threshold`` attempts, - the estimate falls back to the pooled global rate for that arm across all - contexts. This lets per-context bandits benefit from cross-context data + - With probability ``epsilon``, pick uniformly at random from ``techniques``. + - Otherwise, pick the technique with the highest estimated success rate. + The estimate is ``(successes + 1) / (attempts + 1)`` (Laplace smoothing), + so unseen techniques start at 100% and are explored first via tiebreak. + + When a ``(context, technique)`` cell has fewer than ``pool_threshold`` attempts, + the estimate falls back to the pooled global rate for that technique across all + contexts. This lets per-context selectors benefit from cross-context data until they have enough local samples. Set ``pool_threshold=1`` to disable pooling (use the local estimate as soon as any attempt is recorded). Note: This class is not thread/async safe. It assumes sequential calls, - which matches the base ``Scenario._execute_scenario_async`` loop. + which matches the base ``Scenario._execute_scenario_async`` loop + (same pattern as all other scenarios). """ - # Tolerance for tiebreaking in exploitation. Estimates are rational today, - # so equality works, but this guards against future estimators that may - # introduce floating-point drift. + # Tolerance for floating-point comparison when tiebreaking in exploitation. + # Current estimates are exact rationals, but this guards against future + # estimator changes that may introduce floating-point drift. _TIE_TOL: float = 1e-12 def __init__( @@ -88,11 +98,16 @@ def __init__( """ Args: epsilon (float): Exploration probability in [0.0, 1.0]. Defaults to 0.2. - pool_threshold (int): Minimum per-(context, arm) attempts before - the local estimate replaces the pooled-global estimate. Must - be >= 1; set to 1 to disable pooling. Defaults to 3. - rng (random.Random | None): Seedable RNG for deterministic tests. - Defaults to a fresh ``random.Random()``. + pool_threshold (int): Minimum per-(context, technique) attempts before + the local estimate replaces the pooled-global estimate. Until this + threshold is reached, the selector uses the technique's average + across all contexts. Must be >= 1; set to 1 to disable pooling. + Defaults to 3. + rng (random.Random | None): A ``random.Random`` instance for + reproducible selection decisions. Using a dedicated RNG (rather + than a bare float) enables seeded determinism across the full + sequence of select calls within a run. Defaults to a fresh + unseeded ``random.Random()``. Raises: ValueError: If ``epsilon`` is outside [0.0, 1.0] or @@ -111,39 +126,39 @@ def __init__( # ``_estimate``'s pooled-backoff branch is O(1). self._global_counts: dict[str, tuple[int, int]] = {} - def select(self, *, context: str, arms: Sequence[str]) -> str: + def select(self, *, context: str, techniques: Sequence[str]) -> str: """ - Pick the next arm to try for ``context``. + Pick the next technique to try for ``context``. Args: context (str): The context key (e.g. ``"_global"`` or a harm category). - arms (Sequence[str]): The candidate technique names. + techniques (Sequence[str]): The candidate technique names. Returns: - str: The chosen arm name. + str: The chosen technique name. Raises: - ValueError: If ``arms`` is empty. + ValueError: If ``techniques`` is empty. """ - arm_list = list(arms) - if not arm_list: - raise ValueError("arms must contain at least one technique") + technique_list = list(techniques) + if not technique_list: + raise ValueError("techniques must contain at least one entry") if self._rng.random() < self._epsilon: - return self._rng.choice(arm_list) + return self._rng.choice(technique_list) - estimates = {arm: self._estimate(context=context, arm=arm) for arm in arm_list} + estimates = {t: self._estimate(context=context, technique=t) for t in technique_list} best = max(estimates.values()) - winners = [arm for arm, value in estimates.items() if value >= best - self._TIE_TOL] + winners = [t for t, value in estimates.items() if value >= best - self._TIE_TOL] return self._rng.choice(winners) - def update(self, *, context: str, technique: str, success: bool) -> None: + def record_outcome(self, *, context: str, technique: str, success: bool) -> None: """ - Record the outcome of an attempt. + Record the outcome of an attack attempt for a given technique and context. Args: context (str): The context key the decision was made under. - technique (str): The arm that was tried. + technique (str): The technique that was tried. success (bool): Whether the attempt succeeded. """ successes, attempts = self._counts.get((context, technique), (0, 0)) @@ -160,11 +175,13 @@ def update(self, *, context: str, technique: str, success: bool) -> None: def success_rate(self, *, context: str, technique: str) -> float: """ - Return the smoothed success-rate estimate for an arm in a context. + Return the Laplace-smoothed success-rate estimate for a technique in a context. - This is the same value used internally for exploitation decisions. + The "smoothed" rate is ``(successes + 1) / (attempts + 1)`` — Laplace smoothing + provides an optimistic prior for unseen techniques (estimate = 1.0) and avoids + division by zero. This is the same value used internally for exploitation decisions. """ - return self._estimate(context=context, arm=technique) + return self._estimate(context=context, technique=technique) def counts(self, *, context: str, technique: str) -> tuple[int, int]: """Return raw ``(successes, attempts)`` for a ``(context, technique)`` cell.""" @@ -174,15 +191,15 @@ def snapshot(self) -> dict[tuple[str, str], tuple[int, int]]: """Return a shallow copy of the full counts table (for logging/debug).""" return dict(self._counts) - def _estimate(self, *, context: str, arm: str) -> float: + def _estimate(self, *, context: str, technique: str) -> float: """ - Smoothed success-rate estimate for ``(context, arm)``. + Laplace-smoothed success-rate estimate for ``(context, technique)``. Below ``pool_threshold`` local attempts, the estimate uses the - pooled-global success rate for the arm across all contexts. + pooled-global success rate for the technique across all contexts. """ - local_s, local_n = self._counts.get((context, arm), (0, 0)) + local_s, local_n = self._counts.get((context, technique), (0, 0)) if local_n >= self._pool_threshold: return (local_s + 1) / (local_n + 1) - global_s, global_n = self._global_counts.get(arm, (0, 0)) + global_s, global_n = self._global_counts.get(technique, (0, 0)) return (global_s + 1) / (global_n + 1) diff --git a/pyrit/scenario/scenarios/adaptive/text_adaptive.py b/pyrit/scenario/scenarios/adaptive/text_adaptive.py index e9be071be5..fe33ea8fc4 100644 --- a/pyrit/scenario/scenarios/adaptive/text_adaptive.py +++ b/pyrit/scenario/scenarios/adaptive/text_adaptive.py @@ -3,18 +3,18 @@ """ TextAdaptive scenario — picks attack techniques per-objective using an -epsilon-greedy bandit informed by observed per-run success rates. +epsilon-greedy selector informed by observed per-run success rates. Unlike static scenarios (which run every selected technique against every objective), TextAdaptive runs **up to** ``max_attempts_per_objective`` techniques per objective and stops early when one succeeds. Which technique -to try next is decided by an ``AdaptiveTechniqueSelector`` whose Q-values are +to try next is decided by an ``AdaptiveTechniqueSelector`` whose estimates are updated after every attempt. -The set of available "arms" comes from the selected scenario strategies, so -``--strategies single_turn`` restricts the bandit to single-turn techniques, +The set of available techniques comes from the selected scenario strategies, so +``--strategies single_turn`` restricts the selector to single-turn techniques, etc. The default selector uses a single global context; pass a different -``context_extractor`` (e.g., ``harm_category_context``) to partition Q-values +``context_extractor`` (e.g., ``harm_category_context``) to partition estimates per category. """ @@ -32,7 +32,7 @@ from pyrit.scenario.core.scenario import BaselinePolicy, Scenario from pyrit.scenario.core.scenario_strategy import ScenarioStrategy from pyrit.scenario.scenarios.adaptive.dispatcher import ( - BANDIT_CONTEXT_LABEL, + ADAPTIVE_CONTEXT_LABEL, AdaptiveDispatchAttack, ) from pyrit.scenario.scenarios.adaptive.selector import ( @@ -71,19 +71,19 @@ def _build_text_adaptive_strategy() -> type[ScenarioStrategy]: class TextAdaptive(Scenario): """ Adaptive text-attack scenario that selects techniques per-objective using - an epsilon-greedy bandit over the set of selected strategies. + an epsilon-greedy selector over the set of selected strategies. - The bandit: - - Picks an arm uniformly at random with probability ``epsilon``. - - Otherwise exploits the highest observed success rate. Unseen arms + The selector: + - Picks a technique uniformly at random with probability ``epsilon``. + - Otherwise exploits the highest observed success rate. Unseen techniques have an optimistic prior so the first few objectives effectively round-robin through every available technique. - Pools across contexts when a context has fewer than - ``pool_threshold`` observations for an arm. + ``pool_threshold`` observations for a technique. A baseline ``PromptSendingAttack`` is **not** prepended — every objective runs through the dispatcher, and ``prompt_sending`` participates as one of - the bandit's arms. + the selector's techniques. """ VERSION: int = 1 @@ -141,18 +141,18 @@ def __init__( Args: objective_scorer (TrueFalseScorer | None): Scorer used to judge each response. Defaults to the composite scorer built from the base class. - epsilon (float): Exploration probability for the bandit. Defaults to 0.2. - pool_threshold (int): Minimum per-(context, arm) attempts before the + epsilon (float): Exploration probability for the selector. Defaults to 0.2. + pool_threshold (int): Minimum per-(context, technique) attempts before the local estimate overrides the pooled-global estimate. Set to 1 to disable pooling. Defaults to 3. max_attempts_per_objective (int): Maximum techniques tried per objective before giving up. Defaults to 3. - seed (int | None): RNG seed for deterministic bandit decisions. + seed (int | None): RNG seed for deterministic selection decisions. Defaults to ``None`` (non-deterministic). context_extractor (ContextExtractor): Function mapping a - ``SeedAttackGroup`` to a bandit context key. Defaults to - ``global_context`` (one shared bandit table). Use - ``harm_category_context`` to partition Q-values by harm category. + ``SeedAttackGroup`` to a context key. Defaults to + ``global_context`` (one shared selection table). Use + ``harm_category_context`` to partition estimates by harm category. scenario_result_id (str | None): ID of an existing ``ScenarioResult`` to resume. """ @@ -182,22 +182,23 @@ async def _get_atomic_attacks_async(self) -> list[AtomicAttack]: ``AdaptiveDispatchAttack`` (and therefore a single ``AdaptiveTechniqueSelector``). - This is the bandit's "single working memory shared across objectives" - plumbing: each per-objective ``AtomicAttack`` consults and updates the - same selector via the same dispatcher instance. + Each per-objective ``AtomicAttack`` consults and updates the same + selector via the same dispatcher instance, so learning from one + objective immediately benefits the next. """ if self._objective_target is None: - raise ValueError( - "Scenario not properly initialized. Call await scenario.initialize_async() before running." - ) + raise ValueError("objective_target must be set before creating attacks") - selected_arms = sorted({s.value for s in self._scenario_strategies}) + selected_techniques = sorted({s.value for s in self._scenario_strategies}) factories = self._get_attack_technique_factories() - # Build each arm's inner attack once and reuse across all objectives. + # Build each technique's inner attack once and reuse across all objectives. + # Skip factories that require a seed_technique (e.g. crescendo_simulated) + # since the dispatcher cannot merge technique seeds into the objective's + # seed group at dispatch time. scoring_config = AttackScoringConfig(objective_scorer=cast("TrueFalseScorer", self._objective_scorer)) - arms: dict[str, AttackStrategy] = {} - for technique_name in selected_arms: + techniques: dict[str, AttackStrategy] = {} + for technique_name in selected_techniques: factory = factories.get(technique_name) if factory is None: logger.warning(f"No factory for technique '{technique_name}', skipping.") @@ -206,9 +207,15 @@ async def _get_atomic_attacks_async(self) -> list[AtomicAttack]: objective_target=self._objective_target, attack_scoring_config=scoring_config, ) - arms[technique_name] = technique.attack + if technique.seed_technique is not None: + logger.debug( + "Skipping technique '%s': requires seed_technique which adaptive dispatch cannot handle.", + technique_name, + ) + continue + techniques[technique_name] = technique.attack - if not arms: + if not techniques: raise ValueError( "TextAdaptive: no usable techniques after resolving strategies. Check the --strategies selection." ) @@ -220,13 +227,10 @@ async def _get_atomic_attacks_async(self) -> list[AtomicAttack]: ) dispatcher = AdaptiveDispatchAttack( objective_target=self._objective_target, - arms=arms, + techniques=techniques, selector=selector, max_attempts_per_objective=self._max_attempts_per_objective, ) - # Stash for tests / debugging; not part of the public API. - self._selector = selector - self._dispatcher = dispatcher seed_groups_by_dataset = self._dataset_config.get_seed_attack_groups() atomic_attacks: list[AtomicAttack] = [] @@ -260,7 +264,7 @@ def _build_atomic_for_seed_group( memory_labels = { **self._memory_labels, - BANDIT_CONTEXT_LABEL: bandit_context, + ADAPTIVE_CONTEXT_LABEL: bandit_context, } return AtomicAttack( atomic_attack_name=atomic_attack_name, diff --git a/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py b/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py index 9e26425cfe..87170faa19 100644 --- a/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py +++ b/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py @@ -9,9 +9,9 @@ from pyrit.executor.attack.core.attack_parameters import AttackParameters from pyrit.models import AttackOutcome, AttackResult from pyrit.scenario.scenarios.adaptive.dispatcher import ( - ADAPTIVE_ARM_LABEL, ADAPTIVE_ATTEMPT_LABEL, - BANDIT_CONTEXT_LABEL, + ADAPTIVE_CONTEXT_LABEL, + ADAPTIVE_TECHNIQUE_LABEL, AdaptiveDispatchAttack, AdaptiveDispatchContext, ) @@ -53,9 +53,9 @@ def target() -> MagicMock: class TestInit: @pytest.mark.usefixtures("patch_central_database") - def test_init_rejects_empty_arms(self, target, selector): - with pytest.raises(ValueError, match="arms"): - AdaptiveDispatchAttack(objective_target=target, arms={}, selector=selector) + def test_init_rejects_empty_techniques(self, target, selector): + with pytest.raises(ValueError, match="techniques"): + AdaptiveDispatchAttack(objective_target=target, techniques={}, selector=selector) @pytest.mark.parametrize("bad_max", [0, -1]) @pytest.mark.usefixtures("patch_central_database") @@ -63,7 +63,7 @@ def test_init_rejects_invalid_max_attempts(self, target, selector, bad_max): with pytest.raises(ValueError, match="max_attempts_per_objective"): AdaptiveDispatchAttack( objective_target=target, - arms={"a": _make_inner_attack(name="a", outcomes=[AttackOutcome.SUCCESS])}, + techniques={"a": _make_inner_attack(name="a", outcomes=[AttackOutcome.SUCCESS])}, selector=selector, max_attempts_per_objective=bad_max, ) @@ -76,7 +76,7 @@ async def test_stops_on_first_success(self, target, selector): b = _make_inner_attack(name="b", outcomes=[AttackOutcome.SUCCESS]) dispatcher = AdaptiveDispatchAttack( objective_target=target, - arms={"a": a, "b": b}, + techniques={"a": a, "b": b}, selector=selector, max_attempts_per_objective=5, ) @@ -92,7 +92,7 @@ async def test_retries_until_max_attempts_on_failure(self, target, selector): b = _make_inner_attack(name="b", outcomes=[AttackOutcome.FAILURE] * 3) dispatcher = AdaptiveDispatchAttack( objective_target=target, - arms={"a": a, "b": b}, + techniques={"a": a, "b": b}, selector=selector, max_attempts_per_objective=3, ) @@ -108,7 +108,7 @@ async def test_updates_selector_on_each_attempt(self, target, selector): b = _make_inner_attack(name="b", outcomes=[AttackOutcome.SUCCESS]) dispatcher = AdaptiveDispatchAttack( objective_target=target, - arms={"a": a, "b": b}, + techniques={"a": a, "b": b}, selector=selector, max_attempts_per_objective=3, ) @@ -124,7 +124,7 @@ async def test_passes_objective_to_inner(self, target, selector): a = _make_inner_attack(name="a", outcomes=[AttackOutcome.SUCCESS]) dispatcher = AdaptiveDispatchAttack( objective_target=target, - arms={"a": a}, + techniques={"a": a}, selector=selector, ) @@ -133,11 +133,11 @@ async def test_passes_objective_to_inner(self, target, selector): kwargs = a.execute_async.call_args.kwargs assert kwargs["objective"] == "my-goal" - async def test_attaches_arm_and_attempt_labels(self, target, selector): + async def test_attaches_technique_and_attempt_labels(self, target, selector): a = _make_inner_attack(name="a", outcomes=[AttackOutcome.SUCCESS]) dispatcher = AdaptiveDispatchAttack( objective_target=target, - arms={"a": a}, + techniques={"a": a}, selector=selector, ) @@ -145,24 +145,24 @@ async def test_attaches_arm_and_attempt_labels(self, target, selector): labels = a.execute_async.call_args.kwargs["memory_labels"] assert labels["foo"] == "bar" # caller labels preserved - assert labels[ADAPTIVE_ARM_LABEL] == "a" + assert labels[ADAPTIVE_TECHNIQUE_LABEL] == "a" assert labels[ADAPTIVE_ATTEMPT_LABEL] == "1" - async def test_uses_bandit_context_from_label(self, target, selector): - # Two arms; one has been heavily rewarded under context "violence" only. + async def test_uses_adaptive_context_from_label(self, target, selector): + # Two techniques; one has been heavily rewarded under context "violence" only. a = _make_inner_attack(name="a", outcomes=[AttackOutcome.SUCCESS]) b = _make_inner_attack(name="b", outcomes=[AttackOutcome.SUCCESS]) for _ in range(5): - selector.update(context="violence", technique="b", success=True) + selector.record_outcome(context="violence", technique="b", success=True) for _ in range(5): - selector.update(context="violence", technique="a", success=False) + selector.record_outcome(context="violence", technique="a", success=False) dispatcher = AdaptiveDispatchAttack( objective_target=target, - arms={"a": a, "b": b}, + techniques={"a": a, "b": b}, selector=selector, ) - ctx = _make_context(labels={BANDIT_CONTEXT_LABEL: "violence"}) + ctx = _make_context(labels={ADAPTIVE_CONTEXT_LABEL: "violence"}) await dispatcher._perform_async(context=ctx) # Exploit should have picked "b" first. @@ -173,7 +173,7 @@ async def test_falls_back_to_global_context_when_label_missing(self, target, sel a = _make_inner_attack(name="a", outcomes=[AttackOutcome.SUCCESS]) dispatcher = AdaptiveDispatchAttack( objective_target=target, - arms={"a": a}, + techniques={"a": a}, selector=selector, ) await dispatcher._perform_async(context=_make_context(labels={})) @@ -182,12 +182,12 @@ async def test_falls_back_to_global_context_when_label_missing(self, target, sel assert selector.counts(context=GLOBAL_CONTEXT, technique="a") == (1, 1) async def test_metadata_records_adaptive_trail(self, target, selector): - # Arm "a" fails on the first attempt then succeeds; verify the trail + # Technique "a" fails on the first attempt then succeeds; verify the trail # captures both attempts in order. a = _make_inner_attack(name="a", outcomes=[AttackOutcome.FAILURE, AttackOutcome.SUCCESS]) dispatcher = AdaptiveDispatchAttack( objective_target=target, - arms={"a": a}, + techniques={"a": a}, selector=selector, max_attempts_per_objective=3, ) @@ -207,7 +207,7 @@ class TestValidate: def test_validate_rejects_empty_objective(self, target, selector, bad_objective): dispatcher = AdaptiveDispatchAttack( objective_target=target, - arms={"a": _make_inner_attack(name="a", outcomes=[AttackOutcome.SUCCESS])}, + techniques={"a": _make_inner_attack(name="a", outcomes=[AttackOutcome.SUCCESS])}, selector=selector, ) with pytest.raises(ValueError, match="objective"): @@ -216,7 +216,7 @@ def test_validate_rejects_empty_objective(self, target, selector, bad_objective) def test_validate_accepts_normal_objective(self, target, selector): dispatcher = AdaptiveDispatchAttack( objective_target=target, - arms={"a": _make_inner_attack(name="a", outcomes=[AttackOutcome.SUCCESS])}, + techniques={"a": _make_inner_attack(name="a", outcomes=[AttackOutcome.SUCCESS])}, selector=selector, ) # Does not raise. diff --git a/tests/unit/scenario/scenarios/adaptive/test_selector.py b/tests/unit/scenario/scenarios/adaptive/test_selector.py index eaddc32ce0..4766c16f08 100644 --- a/tests/unit/scenario/scenarios/adaptive/test_selector.py +++ b/tests/unit/scenario/scenarios/adaptive/test_selector.py @@ -14,7 +14,7 @@ harm_category_context, ) -ARMS = ["a", "b", "c", "d"] +TECHNIQUES = ["a", "b", "c", "d"] def _seeded_selector(*, epsilon: float = 0.0, pool_threshold: int = 3, seed: int = 0) -> AdaptiveTechniqueSelector: @@ -43,77 +43,77 @@ def test_init_rejects_pool_threshold_below_one(self): class TestAdaptiveTechniqueSelectorSelect: - def test_select_empty_arms_raises(self): + def test_select_empty_techniques_raises(self): selector = _seeded_selector() - with pytest.raises(ValueError, match="arms"): - selector.select(context=GLOBAL_CONTEXT, arms=[]) + with pytest.raises(ValueError, match="techniques"): + selector.select(context=GLOBAL_CONTEXT, techniques=[]) def test_select_all_unseen_ties_resolved_randomly(self): - # With epsilon=0 and an empty table, every arm has estimate 1/1=1.0, + # With epsilon=0 and an empty table, every technique has estimate 1/1=1.0, # so the result is the seeded random tiebreak. Different seeds should # be able to produce different winners. - winners = {_seeded_selector(seed=s).select(context=GLOBAL_CONTEXT, arms=ARMS) for s in range(50)} + winners = {_seeded_selector(seed=s).select(context=GLOBAL_CONTEXT, techniques=TECHNIQUES) for s in range(50)} assert len(winners) > 1 - assert winners.issubset(set(ARMS)) + assert winners.issubset(set(TECHNIQUES)) def test_select_exploits_clear_winner(self): selector = _seeded_selector(pool_threshold=1) # Give "b" a track record of pure success, others pure failure. for _ in range(5): - selector.update(context=GLOBAL_CONTEXT, technique="b", success=True) - for arm in ("a", "c", "d"): + selector.record_outcome(context=GLOBAL_CONTEXT, technique="b", success=True) + for technique in ("a", "c", "d"): for _ in range(5): - selector.update(context=GLOBAL_CONTEXT, technique=arm, success=False) + selector.record_outcome(context=GLOBAL_CONTEXT, technique=technique, success=False) # With epsilon=0, every selection must exploit the winner. for _ in range(20): - assert selector.select(context=GLOBAL_CONTEXT, arms=ARMS) == "b" + assert selector.select(context=GLOBAL_CONTEXT, techniques=TECHNIQUES) == "b" def test_select_epsilon_one_is_pure_random(self): selector = _seeded_selector(epsilon=1.0) # Bias the table heavily toward "a"; with epsilon=1 it must still be ignored. for _ in range(20): - selector.update(context=GLOBAL_CONTEXT, technique="a", success=True) + selector.record_outcome(context=GLOBAL_CONTEXT, technique="a", success=True) - picks = [selector.select(context=GLOBAL_CONTEXT, arms=ARMS) for _ in range(200)] - assert set(picks) == set(ARMS) + picks = [selector.select(context=GLOBAL_CONTEXT, techniques=TECHNIQUES) for _ in range(200)] + assert set(picks) == set(TECHNIQUES) def test_select_epsilon_zero_never_explores(self): selector = _seeded_selector(epsilon=0.0, pool_threshold=1) for _ in range(3): - selector.update(context=GLOBAL_CONTEXT, technique="a", success=True) - # Make the other arms tried-and-failed so they fall below "a"'s estimate; - # unseen arms would otherwise tie at the optimistic 1.0. - for arm in ("b", "c", "d"): - selector.update(context=GLOBAL_CONTEXT, technique=arm, success=False) + selector.record_outcome(context=GLOBAL_CONTEXT, technique="a", success=True) + # Make the other techniques tried-and-failed so they fall below "a"'s estimate; + # unseen techniques would otherwise tie at the optimistic 1.0. + for technique in ("b", "c", "d"): + selector.record_outcome(context=GLOBAL_CONTEXT, technique=technique, success=False) for _ in range(50): - assert selector.select(context=GLOBAL_CONTEXT, arms=ARMS) == "a" + assert selector.select(context=GLOBAL_CONTEXT, techniques=TECHNIQUES) == "a" def test_select_cold_start_round_robins(self): - # Optimistic init + epsilon=0: untried arms tie at 1.0 and beat tried-and-failed - # arms (1/2 = 0.5). So the first failures push each arm to "tried" exactly once - # before any arm gets tried twice. + # Optimistic init + epsilon=0: untried techniques tie at 1.0 and beat tried-and-failed + # techniques (1/2 = 0.5). So the first failures push each technique to "tried" exactly once + # before any technique gets tried twice. selector = _seeded_selector(pool_threshold=1) tried: list[str] = [] - for _ in range(len(ARMS)): - arm = selector.select(context=GLOBAL_CONTEXT, arms=ARMS) - tried.append(arm) - selector.update(context=GLOBAL_CONTEXT, technique=arm, success=False) - assert sorted(tried) == sorted(ARMS) + for _ in range(len(TECHNIQUES)): + technique = selector.select(context=GLOBAL_CONTEXT, techniques=TECHNIQUES) + tried.append(technique) + selector.record_outcome(context=GLOBAL_CONTEXT, technique=technique, success=False) + assert sorted(tried) == sorted(TECHNIQUES) class TestAdaptiveTechniqueSelectorUpdate: - def test_update_accumulates_counts(self): + def test_record_outcome_accumulates_counts(self): selector = _seeded_selector() - selector.update(context="ctx", technique="a", success=True) - selector.update(context="ctx", technique="a", success=False) - selector.update(context="ctx", technique="a", success=True) + selector.record_outcome(context="ctx", technique="a", success=True) + selector.record_outcome(context="ctx", technique="a", success=False) + selector.record_outcome(context="ctx", technique="a", success=True) assert selector.counts(context="ctx", technique="a") == (2, 3) - def test_update_separate_contexts_are_independent(self): + def test_record_outcome_separate_contexts_are_independent(self): selector = _seeded_selector() - selector.update(context="x", technique="a", success=True) - selector.update(context="y", technique="a", success=False) + selector.record_outcome(context="x", technique="a", success=True) + selector.record_outcome(context="y", technique="a", success=False) assert selector.counts(context="x", technique="a") == (1, 1) assert selector.counts(context="y", technique="a") == (0, 1) @@ -121,21 +121,21 @@ def test_counts_default_zero_for_unseen(self): selector = _seeded_selector() assert selector.counts(context="missing", technique="missing") == (0, 0) - def test_update_keeps_pooled_global_counts_in_sync(self): + def test_record_outcome_keeps_pooled_global_counts_in_sync(self): # Pooled-global counts back the O(1) pooled-backoff branch in _estimate. - # They must aggregate across contexts for a given arm. + # They must aggregate across contexts for a given technique. selector = _seeded_selector(pool_threshold=5) - selector.update(context="x", technique="a", success=True) - selector.update(context="y", technique="a", success=False) - selector.update(context="z", technique="a", success=True) - selector.update(context="x", technique="b", success=True) + selector.record_outcome(context="x", technique="a", success=True) + selector.record_outcome(context="y", technique="a", success=False) + selector.record_outcome(context="z", technique="a", success=True) + selector.record_outcome(context="x", technique="b", success=True) # Below the local threshold, _estimate must use the pooled-global rate. - # arm "a": 2 successes / 3 attempts -> (2+1)/(3+1) = 0.75 + # technique "a": 2 successes / 3 attempts -> (2+1)/(3+1) = 0.75 assert selector.success_rate(context="new_ctx", technique="a") == pytest.approx(0.75) - # arm "b": 1/1 -> (1+1)/(1+1) = 1.0 + # technique "b": 1/1 -> (1+1)/(1+1) = 1.0 assert selector.success_rate(context="new_ctx", technique="b") == pytest.approx(1.0) - # Unseen arm "c" -> (0+1)/(0+1) = 1.0 + # Unseen technique "c" -> (0+1)/(0+1) = 1.0 assert selector.success_rate(context="new_ctx", technique="c") == pytest.approx(1.0) @@ -148,17 +148,17 @@ def test_success_rate_unseen_is_one(self): def test_success_rate_local_when_above_threshold(self): selector = _seeded_selector(pool_threshold=2) for _ in range(3): - selector.update(context="ctx", technique="a", success=True) + selector.record_outcome(context="ctx", technique="a", success=True) # (3 + 1) / (3 + 1) = 1.0 assert selector.success_rate(context="ctx", technique="a") == pytest.approx(1.0) def test_success_rate_pools_when_below_threshold(self): selector = _seeded_selector(pool_threshold=5) # Local cell has only 1 attempt (below threshold). - selector.update(context="ctx", technique="a", success=False) - # Other contexts have plenty of data for arm "a". + selector.record_outcome(context="ctx", technique="a", success=False) + # Other contexts have plenty of data for technique "a". for _ in range(10): - selector.update(context="other", technique="a", success=True) + selector.record_outcome(context="other", technique="a", success=True) # Pooled estimate = (10 + 0 + 1) / (10 + 1 + 1) = 11/12. assert selector.success_rate(context="ctx", technique="a") == pytest.approx(11 / 12) diff --git a/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py b/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py index 77176e03e5..ff9556d65c 100644 --- a/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py +++ b/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py @@ -16,12 +16,11 @@ from pyrit.scenario.core.dataset_configuration import DatasetConfiguration from pyrit.scenario.core.scenario import BaselinePolicy from pyrit.scenario.scenarios.adaptive.dispatcher import ( - BANDIT_CONTEXT_LABEL, + ADAPTIVE_CONTEXT_LABEL, AdaptiveDispatchAttack, ) from pyrit.scenario.scenarios.adaptive.selector import ( GLOBAL_CONTEXT, - AdaptiveTechniqueSelector, harm_category_context, ) from pyrit.scenario.scenarios.adaptive.text_adaptive import TextAdaptive @@ -190,7 +189,6 @@ async def test_all_atomics_share_one_dispatcher(self, mock_objective_target, moc dispatchers = {atomic._attack_technique.attack for atomic in attacks} assert len(dispatchers) == 1 assert isinstance(next(iter(dispatchers)), AdaptiveDispatchAttack) - assert isinstance(scenario._selector, AdaptiveTechniqueSelector) async def test_global_context_label_when_using_global_extractor(self, mock_objective_target, mock_objective_scorer): groups = { @@ -203,7 +201,7 @@ async def test_global_context_label_when_using_global_extractor(self, mock_objec seed_groups=groups, ) for atomic in attacks: - assert atomic._memory_labels[BANDIT_CONTEXT_LABEL] == GLOBAL_CONTEXT + assert atomic._memory_labels[ADAPTIVE_CONTEXT_LABEL] == GLOBAL_CONTEXT async def test_harm_category_extractor_partitions_labels(self, mock_objective_target, mock_objective_scorer): groups = { @@ -217,7 +215,7 @@ async def test_harm_category_extractor_partitions_labels(self, mock_objective_ta seed_groups=groups, context_extractor=harm_category_context, ) - contexts = {atomic._memory_labels[BANDIT_CONTEXT_LABEL] for atomic in attacks} + contexts = {atomic._memory_labels[ADAPTIVE_CONTEXT_LABEL] for atomic in attacks} # Each objective gets its own context bucket from harm_category_context. assert contexts == {"violence", "hate", "_uncategorized"} From 3df57871cadd1d37573f9f1e442b40adb2cdc7d5 Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Mon, 18 May 2026 18:29:56 -0400 Subject: [PATCH 04/35] pr review --- doc/code/scenarios/3_text_adaptive.ipynb | 44 +++++++++---------- doc/code/scenarios/3_text_adaptive.py | 2 +- doc/myst.yml | 1 + pyrit/scenario/scenarios/adaptive/selector.py | 2 +- .../scenarios/adaptive/text_adaptive.py | 10 ++--- .../scenarios/adaptive/test_selector.py | 5 ++- .../scenarios/adaptive/test_text_adaptive.py | 2 +- 7 files changed, 34 insertions(+), 32 deletions(-) diff --git a/doc/code/scenarios/3_text_adaptive.ipynb b/doc/code/scenarios/3_text_adaptive.ipynb index 474c1872d9..3d692cb3ad 100644 --- a/doc/code/scenarios/3_text_adaptive.ipynb +++ b/doc/code/scenarios/3_text_adaptive.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "markdown", - "id": "33fbe4e9", + "id": "0", "metadata": {}, "source": [ "# TextAdaptive Scenario\n", @@ -39,7 +39,7 @@ }, { "cell_type": "markdown", - "id": "84bce821", + "id": "1", "metadata": {}, "source": [ "## Setup" @@ -48,16 +48,16 @@ { "cell_type": "code", "execution_count": null, - "id": "62eebc44", + "id": "2", "metadata": {}, "outputs": [], "source": [ "from pathlib import Path\n", "\n", - "from pyrit.scenario.scenarios.adaptive import TextAdaptive, harm_category_context\n", "from pyrit.registry import TargetRegistry\n", "from pyrit.scenario import DatasetConfiguration\n", "from pyrit.scenario.printer.console_printer import ConsoleScenarioResultPrinter\n", + "from pyrit.scenario.scenarios.adaptive import TextAdaptive, harm_category_context\n", "from pyrit.setup import initialize_from_config_async\n", "\n", "await initialize_from_config_async(config_path=Path(\"../../scanner/pyrit_conf.yaml\")) # type: ignore\n", @@ -68,7 +68,7 @@ }, { "cell_type": "markdown", - "id": "93a67a83", + "id": "3", "metadata": {}, "source": [ "## Basic Usage\n", @@ -81,7 +81,7 @@ { "cell_type": "code", "execution_count": null, - "id": "f74ce854", + "id": "4", "metadata": {}, "outputs": [], "source": [ @@ -96,7 +96,7 @@ }, { "cell_type": "markdown", - "id": "4bc43876", + "id": "5", "metadata": {}, "source": [ "## Customizing the Selector\n", @@ -112,7 +112,7 @@ { "cell_type": "code", "execution_count": null, - "id": "ac0326e8", + "id": "6", "metadata": {}, "outputs": [], "source": [ @@ -129,7 +129,7 @@ }, { "cell_type": "markdown", - "id": "f0ff26ab", + "id": "7", "metadata": {}, "source": [ "### Max Attempts Per Objective\n", @@ -142,7 +142,7 @@ { "cell_type": "code", "execution_count": null, - "id": "c638b3e5", + "id": "8", "metadata": {}, "outputs": [], "source": [ @@ -158,7 +158,7 @@ }, { "cell_type": "markdown", - "id": "3d9e697f", + "id": "9", "metadata": {}, "source": [ "## Context-Aware Selection\n", @@ -174,7 +174,7 @@ { "cell_type": "code", "execution_count": null, - "id": "954e9c65", + "id": "10", "metadata": {}, "outputs": [], "source": [ @@ -196,7 +196,7 @@ }, { "cell_type": "markdown", - "id": "c6319923", + "id": "11", "metadata": {}, "source": [ "The `pool_threshold` parameter controls how many local observations are needed before\n", @@ -207,7 +207,7 @@ }, { "cell_type": "markdown", - "id": "df56a4af", + "id": "12", "metadata": {}, "source": [ "## Strategy Selection\n", @@ -220,7 +220,7 @@ { "cell_type": "code", "execution_count": null, - "id": "bbd3b91f", + "id": "13", "metadata": {}, "outputs": [], "source": [ @@ -234,7 +234,7 @@ }, { "cell_type": "markdown", - "id": "004bea90", + "id": "14", "metadata": {}, "source": [ "To limit the selector to only single-turn techniques:" @@ -243,7 +243,7 @@ { "cell_type": "code", "execution_count": null, - "id": "f081a04d", + "id": "15", "metadata": {}, "outputs": [], "source": [ @@ -260,7 +260,7 @@ }, { "cell_type": "markdown", - "id": "bd48c512", + "id": "16", "metadata": {}, "source": [ "## Deterministic Runs\n", @@ -271,7 +271,7 @@ { "cell_type": "code", "execution_count": null, - "id": "c022bc5c", + "id": "17", "metadata": {}, "outputs": [], "source": [ @@ -287,7 +287,7 @@ }, { "cell_type": "markdown", - "id": "0878f42e", + "id": "18", "metadata": {}, "source": [ "## Custom Scorer\n", @@ -299,7 +299,7 @@ { "cell_type": "code", "execution_count": null, - "id": "e251342b", + "id": "19", "metadata": {}, "outputs": [], "source": [ @@ -321,7 +321,7 @@ }, { "cell_type": "markdown", - "id": "ba2bda21", + "id": "20", "metadata": {}, "source": [ "## Notes\n", diff --git a/doc/code/scenarios/3_text_adaptive.py b/doc/code/scenarios/3_text_adaptive.py index a5774753a3..9a8cbfaa4f 100644 --- a/doc/code/scenarios/3_text_adaptive.py +++ b/doc/code/scenarios/3_text_adaptive.py @@ -46,10 +46,10 @@ # %% from pathlib import Path -from pyrit.scenario.scenarios.adaptive import TextAdaptive, harm_category_context from pyrit.registry import TargetRegistry from pyrit.scenario import DatasetConfiguration from pyrit.scenario.printer.console_printer import ConsoleScenarioResultPrinter +from pyrit.scenario.scenarios.adaptive import TextAdaptive, harm_category_context from pyrit.setup import initialize_from_config_async await initialize_from_config_async(config_path=Path("../../scanner/pyrit_conf.yaml")) # type: ignore diff --git a/doc/myst.yml b/doc/myst.yml index f703d6c8cd..5b48183774 100644 --- a/doc/myst.yml +++ b/doc/myst.yml @@ -168,6 +168,7 @@ project: children: - file: code/scenarios/1_common_scenario_parameters.ipynb - file: code/scenarios/2_custom_scenario_parameters.ipynb + - file: code/scenarios/3_adaptive_scenarios.ipynb - file: code/registry/0_registry.md children: - file: code/registry/1_class_registry.ipynb diff --git a/pyrit/scenario/scenarios/adaptive/selector.py b/pyrit/scenario/scenarios/adaptive/selector.py index 5f39b49c09..c5c9bc6437 100644 --- a/pyrit/scenario/scenarios/adaptive/selector.py +++ b/pyrit/scenario/scenarios/adaptive/selector.py @@ -54,7 +54,7 @@ def harm_category_context(seed_attack_group: SeedAttackGroup) -> str: categories = seed_attack_group.harm_categories if not categories: return UNCATEGORIZED_CONTEXT - return categories[0] + return sorted(categories)[0] class AdaptiveTechniqueSelector: diff --git a/pyrit/scenario/scenarios/adaptive/text_adaptive.py b/pyrit/scenario/scenarios/adaptive/text_adaptive.py index fe33ea8fc4..88885fef95 100644 --- a/pyrit/scenario/scenarios/adaptive/text_adaptive.py +++ b/pyrit/scenario/scenarios/adaptive/text_adaptive.py @@ -23,7 +23,7 @@ import logging import random import uuid -from typing import TYPE_CHECKING, ClassVar, cast +from typing import TYPE_CHECKING, Any, ClassVar, cast from pyrit.common import apply_defaults from pyrit.executor.attack import AttackScoringConfig @@ -43,7 +43,7 @@ if TYPE_CHECKING: from pyrit.executor.attack.core.attack_strategy import AttackStrategy - from pyrit.models import SeedAttackGroup + from pyrit.models import AttackResult, SeedAttackGroup from pyrit.scenario.core.atomic_attack import AtomicAttack from pyrit.score import TrueFalseScorer @@ -197,7 +197,7 @@ async def _get_atomic_attacks_async(self) -> list[AtomicAttack]: # since the dispatcher cannot merge technique seeds into the objective's # seed group at dispatch time. scoring_config = AttackScoringConfig(objective_scorer=cast("TrueFalseScorer", self._objective_scorer)) - techniques: dict[str, AttackStrategy] = {} + techniques: dict[str, AttackStrategy[Any, AttackResult]] = {} for technique_name in selected_techniques: factory = factories.get(technique_name) if factory is None: @@ -256,7 +256,7 @@ def _build_atomic_for_seed_group( from pyrit.scenario.core.atomic_attack import AtomicAttack from pyrit.scenario.core.attack_technique import AttackTechnique - bandit_context = self._context_extractor(seed_group) + adaptive_context = self._context_extractor(seed_group) # Use the objective's id when available so resume keys are stable across # runs that re-fetch the same seed groups; fall back to a random uuid. objective_id = seed_group.objective.id if seed_group.objective.id else uuid.uuid4() @@ -264,7 +264,7 @@ def _build_atomic_for_seed_group( memory_labels = { **self._memory_labels, - ADAPTIVE_CONTEXT_LABEL: bandit_context, + ADAPTIVE_CONTEXT_LABEL: adaptive_context, } return AtomicAttack( atomic_attack_name=atomic_attack_name, diff --git a/tests/unit/scenario/scenarios/adaptive/test_selector.py b/tests/unit/scenario/scenarios/adaptive/test_selector.py index 4766c16f08..ab6aae03e3 100644 --- a/tests/unit/scenario/scenarios/adaptive/test_selector.py +++ b/tests/unit/scenario/scenarios/adaptive/test_selector.py @@ -168,10 +168,11 @@ def test_global_context_is_constant(self): sg = MagicMock() assert global_context(sg) == GLOBAL_CONTEXT - def test_harm_category_context_uses_first_category(self): + def test_harm_category_context_uses_sorted_first_category(self): sg = MagicMock() sg.harm_categories = ["violence", "hate"] - assert harm_category_context(sg) == "violence" + # sorted() ensures deterministic selection regardless of set iteration order + assert harm_category_context(sg) == "hate" def test_harm_category_context_falls_back_when_empty(self): sg = MagicMock() diff --git a/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py b/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py index ff9556d65c..c67abc0fcd 100644 --- a/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py +++ b/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py @@ -117,7 +117,7 @@ def test_get_default_strategy(self): assert strat is not None @patch("pyrit.scenario.core.scenario.Scenario._get_default_objective_scorer") - def test_init_stores_bandit_params(self, mock_get_scorer, mock_objective_scorer): + def test_init_stores_adaptive_params(self, mock_get_scorer, mock_objective_scorer): mock_get_scorer.return_value = mock_objective_scorer scenario = TextAdaptive( epsilon=0.4, From b794db05edd0c4e2b30fabc7cd93fa342e8237ee Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Tue, 19 May 2026 12:59:08 -0400 Subject: [PATCH 05/35] generalize and clean up comments & notebooks --- doc/code/scenarios/3_adaptive_scenarios.ipynb | 260 +++++++++++++ doc/code/scenarios/3_adaptive_scenarios.py | 164 +++++++++ doc/code/scenarios/3_text_adaptive.ipynb | 345 ------------------ doc/code/scenarios/3_text_adaptive.py | 220 ----------- pyrit/scenario/scenarios/adaptive/__init__.py | 2 + .../scenarios/adaptive/adaptive_scenario.py | 278 ++++++++++++++ .../scenario/scenarios/adaptive/dispatcher.py | 93 +++-- pyrit/scenario/scenarios/adaptive/selector.py | 171 ++++----- .../scenarios/adaptive/text_adaptive.py | 212 ++--------- .../scenarios/adaptive/test_dispatcher.py | 33 ++ .../scenarios/adaptive/test_selector.py | 44 ++- .../scenarios/adaptive/test_text_adaptive.py | 119 ++++++ 12 files changed, 1039 insertions(+), 902 deletions(-) create mode 100644 doc/code/scenarios/3_adaptive_scenarios.ipynb create mode 100644 doc/code/scenarios/3_adaptive_scenarios.py delete mode 100644 doc/code/scenarios/3_text_adaptive.ipynb delete mode 100644 doc/code/scenarios/3_text_adaptive.py create mode 100644 pyrit/scenario/scenarios/adaptive/adaptive_scenario.py diff --git a/doc/code/scenarios/3_adaptive_scenarios.ipynb b/doc/code/scenarios/3_adaptive_scenarios.ipynb new file mode 100644 index 0000000000..93938c1d6b --- /dev/null +++ b/doc/code/scenarios/3_adaptive_scenarios.ipynb @@ -0,0 +1,260 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Adaptive Scenarios\n", + "\n", + "An **adaptive scenario** doesn't run every attack technique against every objective.\n", + "Instead, it picks which technique to try next per-objective, learns from what worked,\n", + "and stops as soon as one technique succeeds. This concentrates spend on techniques\n", + "that actually work on your target.\n", + "\n", + "## How it works (high level)\n", + "\n", + "For each objective, the scenario tries up to `max_attempts_per_objective` techniques:\n", + "\n", + "- With probability `epsilon`, it **explores** — picks a random technique.\n", + "- Otherwise it **exploits** — picks the technique with the highest observed success\n", + " rate so far.\n", + "- It records the outcome and stops early on success.\n", + "\n", + "Unseen techniques are tried first, so the first few objectives effectively round-robin\n", + "through every technique before the scenario settles on the best performers.\n", + "\n", + "## Adaptive vs. static scenarios\n", + "\n", + "| Feature | Static scenarios | Adaptive scenarios |\n", + "|---------------------|-----------------------------------|------------------------------------|\n", + "| Technique selection | Run every selected technique | Pick per-objective from outcomes |\n", + "| Early stopping | No | Yes — stops on first success |\n", + "| Cost | O(techniques × objectives) | O(max_attempts × objectives) |\n", + "\n", + "`AdaptiveScenario` is the modality-agnostic base class.\n", + "[`TextAdaptive`](../../../pyrit/scenario/scenarios/adaptive/text_adaptive.py) is the\n", + "text subclass used in the examples below." + ] + }, + { + "cell_type": "markdown", + "id": "1", + "metadata": {}, + "source": [ + "## Setup" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "\n", + "from pyrit.registry import TargetRegistry\n", + "from pyrit.scenario import DatasetConfiguration\n", + "from pyrit.scenario.printer.console_printer import ConsoleScenarioResultPrinter\n", + "from pyrit.scenario.scenarios.adaptive import TextAdaptive, harm_category_context\n", + "from pyrit.setup import initialize_from_config_async\n", + "\n", + "await initialize_from_config_async(config_path=Path(\"../../scanner/pyrit_conf.yaml\")) # type: ignore\n", + "\n", + "objective_target = TargetRegistry.get_registry_singleton().get_instance_by_name(\"openai_chat\")\n", + "printer = ConsoleScenarioResultPrinter()" + ] + }, + { + "cell_type": "markdown", + "id": "3", + "metadata": {}, + "source": [ + "## Basic usage\n", + "\n", + "Defaults: `epsilon=0.2`, `max_attempts_per_objective=3`, the subclass's default datasets." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "scenario = TextAdaptive()\n", + "\n", + "await scenario.initialize_async( # type: ignore\n", + " objective_target=objective_target,\n", + ")\n", + "result = await scenario.run_async() # type: ignore\n", + "await printer.print_summary_async(result) # type: ignore" + ] + }, + { + "cell_type": "markdown", + "id": "5", + "metadata": {}, + "source": [ + "## Tuning exploration (`epsilon`)\n", + "\n", + "- `epsilon=0.0` — pure exploitation (always pick the best-known technique).\n", + "- `epsilon=1.0` — pure exploration (random every time).\n", + "- `epsilon=0.2` (default) — 20% exploration." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6", + "metadata": {}, + "outputs": [], + "source": [ + "explorative_scenario = TextAdaptive(epsilon=0.5)\n", + "\n", + "await explorative_scenario.initialize_async( # type: ignore\n", + " objective_target=objective_target,\n", + " dataset_config=DatasetConfiguration(dataset_names=[\"airt_hate\"], max_dataset_size=4),\n", + ")\n", + "explorative_result = await explorative_scenario.run_async() # type: ignore\n", + "await printer.print_summary_async(explorative_result) # type: ignore" + ] + }, + { + "cell_type": "markdown", + "id": "7", + "metadata": {}, + "source": [ + "## Attempts per objective\n", + "\n", + "`max_attempts_per_objective` caps how many techniques are tried per objective before\n", + "moving on. Higher = more chances to succeed, more API calls." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8", + "metadata": {}, + "outputs": [], + "source": [ + "persistent_scenario = TextAdaptive(max_attempts_per_objective=5)\n", + "\n", + "await persistent_scenario.initialize_async( # type: ignore\n", + " objective_target=objective_target,\n", + " dataset_config=DatasetConfiguration(dataset_names=[\"airt_violence\"], max_dataset_size=4),\n", + ")\n", + "persistent_result = await persistent_scenario.run_async() # type: ignore\n", + "await printer.print_summary_async(persistent_result) # type: ignore" + ] + }, + { + "cell_type": "markdown", + "id": "9", + "metadata": {}, + "source": [ + "## Learning per harm category\n", + "\n", + "By default, the scenario keeps one global success-rate table — what works on hate\n", + "objectives boosts the same technique on violence objectives. Pass `harm_category_context`\n", + "to learn each category independently:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "10", + "metadata": {}, + "outputs": [], + "source": [ + "contextual_scenario = TextAdaptive(context_extractor=harm_category_context)\n", + "\n", + "await contextual_scenario.initialize_async( # type: ignore\n", + " objective_target=objective_target,\n", + " dataset_config=DatasetConfiguration(\n", + " dataset_names=[\"airt_hate\", \"airt_violence\"],\n", + " max_dataset_size=4,\n", + " ),\n", + ")\n", + "contextual_result = await contextual_scenario.run_async() # type: ignore\n", + "await printer.print_summary_async(contextual_result) # type: ignore" + ] + }, + { + "cell_type": "markdown", + "id": "11", + "metadata": {}, + "source": [ + "## Restricting which techniques participate\n", + "\n", + "Use `scenario_strategies` to limit which techniques the scenario can pick from." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "12", + "metadata": {}, + "outputs": [], + "source": [ + "strategy_class = TextAdaptive.get_strategy_class()\n", + "\n", + "single_turn_scenario = TextAdaptive()\n", + "\n", + "await single_turn_scenario.initialize_async( # type: ignore\n", + " objective_target=objective_target,\n", + " scenario_strategies=[strategy_class(\"single_turn\")],\n", + " dataset_config=DatasetConfiguration(dataset_names=[\"airt_hate\"], max_dataset_size=4),\n", + ")\n", + "single_turn_result = await single_turn_scenario.run_async() # type: ignore\n", + "await printer.print_summary_async(single_turn_result) # type: ignore" + ] + }, + { + "cell_type": "markdown", + "id": "13", + "metadata": {}, + "source": [ + "## Reproducible runs\n", + "\n", + "Pass `seed` to make every selection decision deterministic." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "14", + "metadata": {}, + "outputs": [], + "source": [ + "deterministic_scenario = TextAdaptive(seed=42, epsilon=0.3)\n", + "\n", + "await deterministic_scenario.initialize_async( # type: ignore\n", + " objective_target=objective_target,\n", + " dataset_config=DatasetConfiguration(dataset_names=[\"airt_hate\"], max_dataset_size=2),\n", + ")\n", + "deterministic_result = await deterministic_scenario.run_async() # type: ignore\n", + "await printer.print_summary_async(deterministic_result) # type: ignore" + ] + }, + { + "cell_type": "markdown", + "id": "15", + "metadata": {}, + "source": [ + "## Resuming a run\n", + "\n", + "Adaptive scenarios are resumable — pass `scenario_result_id=...` to `initialize_async`\n", + "and the run picks up where it left off, with prior outcomes replayed into the selector." + ] + } + ], + "metadata": { + "jupytext": { + "main_language": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/doc/code/scenarios/3_adaptive_scenarios.py b/doc/code/scenarios/3_adaptive_scenarios.py new file mode 100644 index 0000000000..27a4cffbda --- /dev/null +++ b/doc/code/scenarios/3_adaptive_scenarios.py @@ -0,0 +1,164 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.18.1 +# --- + +# %% [markdown] +# # Adaptive Scenarios +# +# An **adaptive scenario** doesn't run every attack technique against every objective. +# Instead, it picks which technique to try next per-objective, learns from what worked, +# and stops as soon as one technique succeeds. This concentrates spend on techniques +# that actually work on your target. +# +# ## How it works (high level) +# +# For each objective, the scenario tries up to `max_attempts_per_objective` techniques: +# +# - With probability `epsilon`, it **explores** — picks a random technique. +# - Otherwise it **exploits** — picks the technique with the highest observed success +# rate so far. +# - It records the outcome and stops early on success. +# +# Unseen techniques are tried first, so the first few objectives effectively round-robin +# through every technique before the scenario settles on the best performers. +# +# ## Adaptive vs. static scenarios +# +# | Feature | Static scenarios | Adaptive scenarios | +# |---------------------|-----------------------------------|------------------------------------| +# | Technique selection | Run every selected technique | Pick per-objective from outcomes | +# | Early stopping | No | Yes — stops on first success | +# | Cost | O(techniques × objectives) | O(max_attempts × objectives) | +# +# `AdaptiveScenario` is the modality-agnostic base class. +# [`TextAdaptive`](../../../pyrit/scenario/scenarios/adaptive/text_adaptive.py) is the +# text subclass used in the examples below. + +# %% [markdown] +# ## Setup + +# %% +from pathlib import Path + +from pyrit.registry import TargetRegistry +from pyrit.scenario import DatasetConfiguration +from pyrit.scenario.printer.console_printer import ConsoleScenarioResultPrinter +from pyrit.scenario.scenarios.adaptive import TextAdaptive, harm_category_context +from pyrit.setup import initialize_from_config_async + +await initialize_from_config_async(config_path=Path("../../scanner/pyrit_conf.yaml")) # type: ignore + +objective_target = TargetRegistry.get_registry_singleton().get_instance_by_name("openai_chat") +printer = ConsoleScenarioResultPrinter() + +# %% [markdown] +# ## Basic usage +# +# Defaults: `epsilon=0.2`, `max_attempts_per_objective=3`, the subclass's default datasets. + +# %% +scenario = TextAdaptive() + +await scenario.initialize_async( # type: ignore + objective_target=objective_target, +) +result = await scenario.run_async() # type: ignore +await printer.print_summary_async(result) # type: ignore + +# %% [markdown] +# ## Tuning exploration (`epsilon`) +# +# - `epsilon=0.0` — pure exploitation (always pick the best-known technique). +# - `epsilon=1.0` — pure exploration (random every time). +# - `epsilon=0.2` (default) — 20% exploration. + +# %% +explorative_scenario = TextAdaptive(epsilon=0.5) + +await explorative_scenario.initialize_async( # type: ignore + objective_target=objective_target, + dataset_config=DatasetConfiguration(dataset_names=["airt_hate"], max_dataset_size=4), +) +explorative_result = await explorative_scenario.run_async() # type: ignore +await printer.print_summary_async(explorative_result) # type: ignore + +# %% [markdown] +# ## Attempts per objective +# +# `max_attempts_per_objective` caps how many techniques are tried per objective before +# moving on. Higher = more chances to succeed, more API calls. + +# %% +persistent_scenario = TextAdaptive(max_attempts_per_objective=5) + +await persistent_scenario.initialize_async( # type: ignore + objective_target=objective_target, + dataset_config=DatasetConfiguration(dataset_names=["airt_violence"], max_dataset_size=4), +) +persistent_result = await persistent_scenario.run_async() # type: ignore +await printer.print_summary_async(persistent_result) # type: ignore + +# %% [markdown] +# ## Learning per harm category +# +# By default, the scenario keeps one global success-rate table — what works on hate +# objectives boosts the same technique on violence objectives. Pass `harm_category_context` +# to learn each category independently: + +# %% +contextual_scenario = TextAdaptive(context_extractor=harm_category_context) + +await contextual_scenario.initialize_async( # type: ignore + objective_target=objective_target, + dataset_config=DatasetConfiguration( + dataset_names=["airt_hate", "airt_violence"], + max_dataset_size=4, + ), +) +contextual_result = await contextual_scenario.run_async() # type: ignore +await printer.print_summary_async(contextual_result) # type: ignore + +# %% [markdown] +# ## Restricting which techniques participate +# +# Use `scenario_strategies` to limit which techniques the scenario can pick from. + +# %% +strategy_class = TextAdaptive.get_strategy_class() + +single_turn_scenario = TextAdaptive() + +await single_turn_scenario.initialize_async( # type: ignore + objective_target=objective_target, + scenario_strategies=[strategy_class("single_turn")], + dataset_config=DatasetConfiguration(dataset_names=["airt_hate"], max_dataset_size=4), +) +single_turn_result = await single_turn_scenario.run_async() # type: ignore +await printer.print_summary_async(single_turn_result) # type: ignore + +# %% [markdown] +# ## Reproducible runs +# +# Pass `seed` to make every selection decision deterministic. + +# %% +deterministic_scenario = TextAdaptive(seed=42, epsilon=0.3) + +await deterministic_scenario.initialize_async( # type: ignore + objective_target=objective_target, + dataset_config=DatasetConfiguration(dataset_names=["airt_hate"], max_dataset_size=2), +) +deterministic_result = await deterministic_scenario.run_async() # type: ignore +await printer.print_summary_async(deterministic_result) # type: ignore + +# %% [markdown] +# ## Resuming a run +# +# Adaptive scenarios are resumable — pass `scenario_result_id=...` to `initialize_async` +# and the run picks up where it left off, with prior outcomes replayed into the selector. diff --git a/doc/code/scenarios/3_text_adaptive.ipynb b/doc/code/scenarios/3_text_adaptive.ipynb deleted file mode 100644 index 3d692cb3ad..0000000000 --- a/doc/code/scenarios/3_text_adaptive.ipynb +++ /dev/null @@ -1,345 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "0", - "metadata": {}, - "source": [ - "# TextAdaptive Scenario\n", - "\n", - "The `TextAdaptive` scenario uses an **epsilon-greedy selector** to intelligently choose\n", - "which attack technique to try for each objective. Unlike static scenarios that run every\n", - "selected technique against every objective, `TextAdaptive` adapts its strategy selection\n", - "based on observed success rates — spending more attempts on techniques that work and\n", - "exploring new ones with a configurable probability.\n", - "\n", - "## How It Works\n", - "\n", - "For each objective (prompt), the selector:\n", - "\n", - "1. **Explores** with probability `epsilon` — picks a technique uniformly at random.\n", - "2. **Exploits** otherwise — picks the technique with the highest observed success rate.\n", - "3. **Stops early** when a technique succeeds, avoiding wasted attempts.\n", - "4. Tries **up to** `max_attempts_per_objective` techniques before moving on.\n", - "\n", - "Unseen techniques start with an optimistic prior (100% success estimate), so the first\n", - "few objectives effectively round-robin through every available technique before the\n", - "selector converges on the best performers.\n", - "\n", - "## Key Differences from Static Scenarios\n", - "\n", - "| Feature | Static Scenarios | TextAdaptive |\n", - "|---------|-----------------|--------------|\n", - "| Technique selection | Run all selected techniques | Selector picks per-objective |\n", - "| Early stopping | No | Yes — stops on first success |\n", - "| Learning | None | Updates success rates after each attempt |\n", - "| Baseline | Prepended automatically | Forbidden — `prompt_sending` is a technique |\n", - "| Efficiency | O(techniques × objectives) | O(max_attempts × objectives) |" - ] - }, - { - "cell_type": "markdown", - "id": "1", - "metadata": {}, - "source": [ - "## Setup" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2", - "metadata": {}, - "outputs": [], - "source": [ - "from pathlib import Path\n", - "\n", - "from pyrit.registry import TargetRegistry\n", - "from pyrit.scenario import DatasetConfiguration\n", - "from pyrit.scenario.printer.console_printer import ConsoleScenarioResultPrinter\n", - "from pyrit.scenario.scenarios.adaptive import TextAdaptive, harm_category_context\n", - "from pyrit.setup import initialize_from_config_async\n", - "\n", - "await initialize_from_config_async(config_path=Path(\"../../scanner/pyrit_conf.yaml\")) # type: ignore\n", - "\n", - "objective_target = TargetRegistry.get_registry_singleton().get_instance_by_name(\"openai_chat\")\n", - "printer = ConsoleScenarioResultPrinter()" - ] - }, - { - "cell_type": "markdown", - "id": "3", - "metadata": {}, - "source": [ - "## Basic Usage\n", - "\n", - "The simplest way to run `TextAdaptive` uses all defaults: the selector explores with 20%\n", - "probability, tries up to 3 techniques per objective, and uses the default dataset\n", - "(AIRT harm categories)." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4", - "metadata": {}, - "outputs": [], - "source": [ - "scenario = TextAdaptive()\n", - "\n", - "await scenario.initialize_async( # type: ignore\n", - " objective_target=objective_target,\n", - ")\n", - "result = await scenario.run_async() # type: ignore\n", - "await printer.print_summary_async(result) # type: ignore" - ] - }, - { - "cell_type": "markdown", - "id": "5", - "metadata": {}, - "source": [ - "## Customizing the Selector\n", - "\n", - "### Epsilon (Exploration Rate)\n", - "\n", - "`epsilon` controls how often the selector explores vs. exploits:\n", - "- `epsilon=0.0` — pure exploitation (always pick the best-known technique)\n", - "- `epsilon=1.0` — pure exploration (random selection every time)\n", - "- `epsilon=0.2` (default) — 20% random exploration, 80% exploitation" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6", - "metadata": {}, - "outputs": [], - "source": [ - "# More explorative selector — useful when you want broader technique coverage\n", - "explorative_scenario = TextAdaptive(epsilon=0.5)\n", - "\n", - "await explorative_scenario.initialize_async( # type: ignore\n", - " objective_target=objective_target,\n", - " dataset_config=DatasetConfiguration(dataset_names=[\"airt_hate\"], max_dataset_size=4),\n", - ")\n", - "explorative_result = await explorative_scenario.run_async() # type: ignore\n", - "await printer.print_summary_async(explorative_result) # type: ignore" - ] - }, - { - "cell_type": "markdown", - "id": "7", - "metadata": {}, - "source": [ - "### Max Attempts Per Objective\n", - "\n", - "`max_attempts_per_objective` caps how many techniques the selector tries before giving\n", - "up on an objective. Setting this higher gives more chances to succeed but costs more\n", - "API calls." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8", - "metadata": {}, - "outputs": [], - "source": [ - "persistent_scenario = TextAdaptive(max_attempts_per_objective=5)\n", - "\n", - "await persistent_scenario.initialize_async( # type: ignore\n", - " objective_target=objective_target,\n", - " dataset_config=DatasetConfiguration(dataset_names=[\"airt_violence\"], max_dataset_size=4),\n", - ")\n", - "persistent_result = await persistent_scenario.run_async() # type: ignore\n", - "await printer.print_summary_async(persistent_result) # type: ignore" - ] - }, - { - "cell_type": "markdown", - "id": "9", - "metadata": {}, - "source": [ - "## Context-Aware Selection\n", - "\n", - "By default, the selector shares one global table across all objectives. This means\n", - "a technique that works well on hate-speech objectives also gets boosted for\n", - "violence objectives.\n", - "\n", - "To partition the selector by harm category (so each category learns independently),\n", - "pass `harm_category_context` as the `context_extractor`:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "10", - "metadata": {}, - "outputs": [], - "source": [ - "contextual_scenario = TextAdaptive(\n", - " context_extractor=harm_category_context,\n", - " pool_threshold=2,\n", - ")\n", - "\n", - "await contextual_scenario.initialize_async( # type: ignore\n", - " objective_target=objective_target,\n", - " dataset_config=DatasetConfiguration(\n", - " dataset_names=[\"airt_hate\", \"airt_violence\"],\n", - " max_dataset_size=4,\n", - " ),\n", - ")\n", - "contextual_result = await contextual_scenario.run_async() # type: ignore\n", - "await printer.print_summary_async(contextual_result) # type: ignore" - ] - }, - { - "cell_type": "markdown", - "id": "11", - "metadata": {}, - "source": [ - "The `pool_threshold` parameter controls how many local observations are needed before\n", - "the per-category estimate overrides the pooled-global estimate. With\n", - "`pool_threshold=2`, the selector uses the global average until it has seen at least 2\n", - "results for a specific (category, technique) pair." - ] - }, - { - "cell_type": "markdown", - "id": "12", - "metadata": {}, - "source": [ - "## Strategy Selection\n", - "\n", - "`TextAdaptive` builds its strategy enum dynamically from the scenario-techniques\n", - "catalog. You can restrict which techniques participate using the\n", - "`scenario_strategies` parameter:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "13", - "metadata": {}, - "outputs": [], - "source": [ - "strategy_class = TextAdaptive.get_strategy_class()\n", - "\n", - "# See all available strategies\n", - "print(\"Available strategies:\")\n", - "for member in strategy_class:\n", - " print(f\" {member.value}\")" - ] - }, - { - "cell_type": "markdown", - "id": "14", - "metadata": {}, - "source": [ - "To limit the selector to only single-turn techniques:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "15", - "metadata": {}, - "outputs": [], - "source": [ - "single_turn_scenario = TextAdaptive()\n", - "\n", - "await single_turn_scenario.initialize_async( # type: ignore\n", - " objective_target=objective_target,\n", - " scenario_strategies=[strategy_class(\"single_turn\")],\n", - " dataset_config=DatasetConfiguration(dataset_names=[\"airt_hate\"], max_dataset_size=4),\n", - ")\n", - "single_turn_result = await single_turn_scenario.run_async() # type: ignore\n", - "await printer.print_summary_async(single_turn_result) # type: ignore" - ] - }, - { - "cell_type": "markdown", - "id": "16", - "metadata": {}, - "source": [ - "## Deterministic Runs\n", - "\n", - "For reproducibility, pass a `seed` to make the selector's random decisions deterministic:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "17", - "metadata": {}, - "outputs": [], - "source": [ - "deterministic_scenario = TextAdaptive(seed=42, epsilon=0.3)\n", - "\n", - "await deterministic_scenario.initialize_async( # type: ignore\n", - " objective_target=objective_target,\n", - " dataset_config=DatasetConfiguration(dataset_names=[\"airt_hate\"], max_dataset_size=2),\n", - ")\n", - "deterministic_result = await deterministic_scenario.run_async() # type: ignore\n", - "await printer.print_summary_async(deterministic_result) # type: ignore" - ] - }, - { - "cell_type": "markdown", - "id": "18", - "metadata": {}, - "source": [ - "## Custom Scorer\n", - "\n", - "By default, `TextAdaptive` uses the standard composite scorer. You can override it\n", - "with any `TrueFalseScorer`:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "19", - "metadata": {}, - "outputs": [], - "source": [ - "from pyrit.prompt_target import OpenAIChatTarget\n", - "from pyrit.score import SelfAskRefusalScorer, TrueFalseInverterScorer\n", - "\n", - "refusal_scorer = SelfAskRefusalScorer(chat_target=OpenAIChatTarget())\n", - "inverted_scorer = TrueFalseInverterScorer(scorer=refusal_scorer)\n", - "\n", - "custom_scorer_scenario = TextAdaptive(objective_scorer=inverted_scorer)\n", - "\n", - "await custom_scorer_scenario.initialize_async( # type: ignore\n", - " objective_target=objective_target,\n", - " dataset_config=DatasetConfiguration(dataset_names=[\"airt_hate\"], max_dataset_size=2),\n", - ")\n", - "custom_result = await custom_scorer_scenario.run_async() # type: ignore\n", - "await printer.print_summary_async(custom_result) # type: ignore" - ] - }, - { - "cell_type": "markdown", - "id": "20", - "metadata": {}, - "source": [ - "## Notes\n", - "\n", - "- **No baseline**: `TextAdaptive` has `BASELINE_POLICY = Forbidden`. The `prompt_sending`\n", - " technique participates as one of the selector's techniques, so a separate baseline is redundant.\n", - "- **Resumability**: Each atomic attack is keyed by `adaptive_{dataset}_{objective_id}`, so\n", - " re-running a scenario picks up where it left off.\n", - "- **Shared selector**: All objectives in a run share the same `AdaptiveTechniqueSelector`\n", - " instance, so learning from one objective immediately benefits the next." - ] - } - ], - "metadata": { - "jupytext": { - "main_language": "python" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/doc/code/scenarios/3_text_adaptive.py b/doc/code/scenarios/3_text_adaptive.py deleted file mode 100644 index 9a8cbfaa4f..0000000000 --- a/doc/code/scenarios/3_text_adaptive.py +++ /dev/null @@ -1,220 +0,0 @@ -# --- -# jupyter: -# jupytext: -# text_representation: -# extension: .py -# format_name: percent -# format_version: '1.3' -# jupytext_version: 1.18.1 -# --- - -# %% [markdown] -# # TextAdaptive Scenario -# -# The `TextAdaptive` scenario uses an **epsilon-greedy selector** to intelligently choose -# which attack technique to try for each objective. Unlike static scenarios that run every -# selected technique against every objective, `TextAdaptive` adapts its strategy selection -# based on observed success rates — spending more attempts on techniques that work and -# exploring new ones with a configurable probability. -# -# ## How It Works -# -# For each objective (prompt), the selector: -# -# 1. **Explores** with probability `epsilon` — picks a technique uniformly at random. -# 2. **Exploits** otherwise — picks the technique with the highest observed success rate. -# 3. **Stops early** when a technique succeeds, avoiding wasted attempts. -# 4. Tries **up to** `max_attempts_per_objective` techniques before moving on. -# -# Unseen techniques start with an optimistic prior (100% success estimate), so the first -# few objectives effectively round-robin through every available technique before the -# selector converges on the best performers. -# -# ## Key Differences from Static Scenarios -# -# | Feature | Static Scenarios | TextAdaptive | -# |---------|-----------------|--------------| -# | Technique selection | Run all selected techniques | Selector picks per-objective | -# | Early stopping | No | Yes — stops on first success | -# | Learning | None | Updates success rates after each attempt | -# | Baseline | Prepended automatically | Forbidden — `prompt_sending` is a technique | -# | Efficiency | O(techniques × objectives) | O(max_attempts × objectives) | - -# %% [markdown] -# ## Setup - -# %% -from pathlib import Path - -from pyrit.registry import TargetRegistry -from pyrit.scenario import DatasetConfiguration -from pyrit.scenario.printer.console_printer import ConsoleScenarioResultPrinter -from pyrit.scenario.scenarios.adaptive import TextAdaptive, harm_category_context -from pyrit.setup import initialize_from_config_async - -await initialize_from_config_async(config_path=Path("../../scanner/pyrit_conf.yaml")) # type: ignore - -objective_target = TargetRegistry.get_registry_singleton().get_instance_by_name("openai_chat") -printer = ConsoleScenarioResultPrinter() - -# %% [markdown] -# ## Basic Usage -# -# The simplest way to run `TextAdaptive` uses all defaults: the selector explores with 20% -# probability, tries up to 3 techniques per objective, and uses the default dataset -# (AIRT harm categories). - -# %% -scenario = TextAdaptive() - -await scenario.initialize_async( # type: ignore - objective_target=objective_target, -) -result = await scenario.run_async() # type: ignore -await printer.print_summary_async(result) # type: ignore - -# %% [markdown] -# ## Customizing the Selector -# -# ### Epsilon (Exploration Rate) -# -# `epsilon` controls how often the selector explores vs. exploits: -# - `epsilon=0.0` — pure exploitation (always pick the best-known technique) -# - `epsilon=1.0` — pure exploration (random selection every time) -# - `epsilon=0.2` (default) — 20% random exploration, 80% exploitation - -# %% -# More explorative selector — useful when you want broader technique coverage -explorative_scenario = TextAdaptive(epsilon=0.5) - -await explorative_scenario.initialize_async( # type: ignore - objective_target=objective_target, - dataset_config=DatasetConfiguration(dataset_names=["airt_hate"], max_dataset_size=4), -) -explorative_result = await explorative_scenario.run_async() # type: ignore -await printer.print_summary_async(explorative_result) # type: ignore - -# %% [markdown] -# ### Max Attempts Per Objective -# -# `max_attempts_per_objective` caps how many techniques the selector tries before giving -# up on an objective. Setting this higher gives more chances to succeed but costs more -# API calls. - -# %% -persistent_scenario = TextAdaptive(max_attempts_per_objective=5) - -await persistent_scenario.initialize_async( # type: ignore - objective_target=objective_target, - dataset_config=DatasetConfiguration(dataset_names=["airt_violence"], max_dataset_size=4), -) -persistent_result = await persistent_scenario.run_async() # type: ignore -await printer.print_summary_async(persistent_result) # type: ignore - -# %% [markdown] -# ## Context-Aware Selection -# -# By default, the selector shares one global table across all objectives. This means -# a technique that works well on hate-speech objectives also gets boosted for -# violence objectives. -# -# To partition the selector by harm category (so each category learns independently), -# pass `harm_category_context` as the `context_extractor`: - -# %% -contextual_scenario = TextAdaptive( - context_extractor=harm_category_context, - pool_threshold=2, -) - -await contextual_scenario.initialize_async( # type: ignore - objective_target=objective_target, - dataset_config=DatasetConfiguration( - dataset_names=["airt_hate", "airt_violence"], - max_dataset_size=4, - ), -) -contextual_result = await contextual_scenario.run_async() # type: ignore -await printer.print_summary_async(contextual_result) # type: ignore - -# %% [markdown] -# The `pool_threshold` parameter controls how many local observations are needed before -# the per-category estimate overrides the pooled-global estimate. With -# `pool_threshold=2`, the selector uses the global average until it has seen at least 2 -# results for a specific (category, technique) pair. - -# %% [markdown] -# ## Strategy Selection -# -# `TextAdaptive` builds its strategy enum dynamically from the scenario-techniques -# catalog. You can restrict which techniques participate using the -# `scenario_strategies` parameter: - -# %% -strategy_class = TextAdaptive.get_strategy_class() - -# See all available strategies -print("Available strategies:") -for member in strategy_class: - print(f" {member.value}") - -# %% [markdown] -# To limit the selector to only single-turn techniques: - -# %% -single_turn_scenario = TextAdaptive() - -await single_turn_scenario.initialize_async( # type: ignore - objective_target=objective_target, - scenario_strategies=[strategy_class("single_turn")], - dataset_config=DatasetConfiguration(dataset_names=["airt_hate"], max_dataset_size=4), -) -single_turn_result = await single_turn_scenario.run_async() # type: ignore -await printer.print_summary_async(single_turn_result) # type: ignore - -# %% [markdown] -# ## Deterministic Runs -# -# For reproducibility, pass a `seed` to make the selector's random decisions deterministic: - -# %% -deterministic_scenario = TextAdaptive(seed=42, epsilon=0.3) - -await deterministic_scenario.initialize_async( # type: ignore - objective_target=objective_target, - dataset_config=DatasetConfiguration(dataset_names=["airt_hate"], max_dataset_size=2), -) -deterministic_result = await deterministic_scenario.run_async() # type: ignore -await printer.print_summary_async(deterministic_result) # type: ignore - -# %% [markdown] -# ## Custom Scorer -# -# By default, `TextAdaptive` uses the standard composite scorer. You can override it -# with any `TrueFalseScorer`: - -# %% -from pyrit.prompt_target import OpenAIChatTarget -from pyrit.score import SelfAskRefusalScorer, TrueFalseInverterScorer - -refusal_scorer = SelfAskRefusalScorer(chat_target=OpenAIChatTarget()) -inverted_scorer = TrueFalseInverterScorer(scorer=refusal_scorer) - -custom_scorer_scenario = TextAdaptive(objective_scorer=inverted_scorer) - -await custom_scorer_scenario.initialize_async( # type: ignore - objective_target=objective_target, - dataset_config=DatasetConfiguration(dataset_names=["airt_hate"], max_dataset_size=2), -) -custom_result = await custom_scorer_scenario.run_async() # type: ignore -await printer.print_summary_async(custom_result) # type: ignore - -# %% [markdown] -# ## Notes -# -# - **No baseline**: `TextAdaptive` has `BASELINE_POLICY = Forbidden`. The `prompt_sending` -# technique participates as one of the selector's techniques, so a separate baseline is redundant. -# - **Resumability**: Each atomic attack is keyed by `adaptive_{dataset}_{objective_id}`, so -# re-running a scenario picks up where it left off. -# - **Shared selector**: All objectives in a run share the same `AdaptiveTechniqueSelector` -# instance, so learning from one objective immediately benefits the next. diff --git a/pyrit/scenario/scenarios/adaptive/__init__.py b/pyrit/scenario/scenarios/adaptive/__init__.py index 2fb58b888c..d0bd978c22 100644 --- a/pyrit/scenario/scenarios/adaptive/__init__.py +++ b/pyrit/scenario/scenarios/adaptive/__init__.py @@ -3,6 +3,7 @@ """Adaptive scenario classes.""" +from pyrit.scenario.scenarios.adaptive.adaptive_scenario import AdaptiveScenario from pyrit.scenario.scenarios.adaptive.dispatcher import ( ADAPTIVE_CONTEXT_LABEL, AdaptiveDispatchAttack, @@ -18,6 +19,7 @@ __all__ = [ "ADAPTIVE_CONTEXT_LABEL", "AdaptiveDispatchAttack", + "AdaptiveScenario", "AdaptiveTechniqueSelector", "ContextExtractor", "TextAdaptive", diff --git a/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py b/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py new file mode 100644 index 0000000000..b14cc28c43 --- /dev/null +++ b/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py @@ -0,0 +1,278 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""``AdaptiveScenario`` — modality-agnostic base for scenarios that pick attack +techniques per-objective using an ``AdaptiveTechniqueSelector``. + +Owns selector wiring, dispatcher construction, per-objective atomic-attack +emission, and resume rehydration. Concrete subclasses (``TextAdaptive``, +future ``ImageAdaptive`` / ``AudioAdaptive``) only declare strategy class, +default datasets, version, and atomic-attack prefix. + +Baseline policy is ``Forbidden``: ``prompt_sending`` participates as one of +the selector's techniques rather than being prepended. +""" + +from __future__ import annotations + +import logging +import random +import uuid +from typing import TYPE_CHECKING, Any, ClassVar + +from pyrit.executor.attack import AttackScoringConfig +from pyrit.scenario.core.atomic_attack import AtomicAttack +from pyrit.scenario.core.attack_technique import AttackTechnique +from pyrit.scenario.core.scenario import BaselinePolicy, Scenario +from pyrit.scenario.scenarios.adaptive.dispatcher import ( + ADAPTIVE_CONTEXT_LABEL, + AdaptiveDispatchAttack, +) +from pyrit.scenario.scenarios.adaptive.selector import ( + AdaptiveTechniqueSelector, + ContextExtractor, + global_context, +) + +if TYPE_CHECKING: + from pyrit.executor.attack.core.attack_strategy import AttackStrategy + from pyrit.models import AttackResult, SeedAttackGroup + from pyrit.prompt_target import PromptTarget + from pyrit.score import TrueFalseScorer + +logger = logging.getLogger(__name__) + + +class AdaptiveScenario(Scenario): + """Abstract base for adaptive (epsilon-greedy) scenarios. + + Subclasses must implement the standard ``Scenario`` class-method overrides + and declare ``VERSION`` and ``_atomic_attack_prefix``. Selector wiring, + dispatcher construction, per-objective atomic-attack emission, and resume + rehydration are handled here. + """ + + BASELINE_POLICY: ClassVar[BaselinePolicy] = BaselinePolicy.Forbidden + + #: Subclasses must declare a scenario version for memory bookkeeping. + VERSION: ClassVar[int] + + #: Prefix for per-objective atomic-attack names (e.g. ``"adaptive_text"``). + _atomic_attack_prefix: ClassVar[str] = "adaptive" + + def __init__( + self, + *, + objective_scorer: TrueFalseScorer | None = None, + epsilon: float = 0.2, + pool_threshold: int = 3, + max_attempts_per_objective: int = 3, + seed: int | None = None, + context_extractor: ContextExtractor = global_context, + scenario_result_id: str | None = None, + ) -> None: + """ + Args: + objective_scorer (TrueFalseScorer | None): Scorer used to judge each + response. Defaults to the composite scorer from the base class. + epsilon (float): Exploration probability for the selector. Defaults to 0.2. + pool_threshold (int): Minimum per-(context, technique) attempts before + the local estimate overrides the pooled rate. Set to 1 to disable + pooling. Defaults to 3. + max_attempts_per_objective (int): Max techniques per objective. Defaults to 3. + seed (int | None): RNG seed for deterministic selection. Defaults to ``None``. + context_extractor (ContextExtractor): Maps a ``SeedAttackGroup`` to a + context key. Defaults to ``global_context``. + scenario_result_id (str | None): ID of an existing ``ScenarioResult`` to resume. + """ + if not objective_scorer: + objective_scorer = self._get_default_objective_scorer() + self._objective_scorer: TrueFalseScorer = objective_scorer + + self._epsilon = epsilon + self._pool_threshold = pool_threshold + self._max_attempts_per_objective = max_attempts_per_objective + self._seed = seed + self._context_extractor = context_extractor + + super().__init__( + version=self.VERSION, + strategy_class=self.get_strategy_class(), + objective_scorer=objective_scorer, + scenario_result_id=scenario_result_id, + ) + + async def _get_atomic_attacks_async(self) -> list[AtomicAttack]: + """Build one ``AtomicAttack`` per objective, all sharing a single + ``AdaptiveDispatchAttack`` (and therefore a single selector). + """ + if self._objective_target is None: + raise ValueError("objective_target must be set before creating attacks") + + techniques = self._build_techniques_dict(objective_target=self._objective_target) + + selector = AdaptiveTechniqueSelector( + epsilon=self._epsilon, + pool_threshold=self._pool_threshold, + rng=random.Random(self._seed), + ) + # On resume, replay prior attempt outcomes from persisted metadata. + self._rehydrate_selector_from_memory(selector=selector, known_techniques=set(techniques)) + + dispatcher = AdaptiveDispatchAttack( + objective_target=self._objective_target, + techniques=techniques, + selector=selector, + max_attempts_per_objective=self._max_attempts_per_objective, + ) + + seed_groups_by_dataset = self._dataset_config.get_seed_attack_groups() + atomic_attacks: list[AtomicAttack] = [] + for dataset_name, seed_groups in seed_groups_by_dataset.items(): + for seed_group in seed_groups: + atomic_attacks.append( + self._build_atomic_for_seed_group( + dataset_name=dataset_name, + seed_group=seed_group, + dispatcher=dispatcher, + ) + ) + + return atomic_attacks + + def _build_techniques_dict( + self, + *, + objective_target: PromptTarget, + ) -> dict[str, AttackStrategy[Any, AttackResult]]: + """Resolve selected strategies into a ``{name: inner_attack}`` map. + + Skips factories not registered for the current modality, and factories + whose technique requires a ``seed_technique`` (e.g. ``crescendo_simulated``) + — the dispatcher has no hook to merge technique seeds into per-objective + seed groups. + + Raises: + ValueError: If no techniques remain after filtering. Includes the + requested techniques and skip reasons. + """ + selected_techniques = sorted({s.value for s in self._scenario_strategies}) + factories = self._get_attack_technique_factories() + scoring_config = AttackScoringConfig(objective_scorer=self._objective_scorer) + + techniques: dict[str, AttackStrategy[Any, AttackResult]] = {} + skipped_seed_technique: list[str] = [] + skipped_no_factory: list[str] = [] + for technique_name in selected_techniques: + factory = factories.get(technique_name) + if factory is None: + skipped_no_factory.append(technique_name) + logger.warning(f"No factory for technique '{technique_name}', skipping.") + continue + technique = factory.create( + objective_target=objective_target, + attack_scoring_config=scoring_config, + ) + if technique.seed_technique is not None: + skipped_seed_technique.append(technique_name) + logger.warning( + "Skipping technique '%s': it requires a seed_technique which the adaptive " + "dispatcher cannot merge into per-objective seed groups. Use a static " + "scenario (e.g. RapidResponse) to run this technique.", + technique_name, + ) + continue + techniques[technique_name] = technique.attack + + if not techniques: + details: list[str] = [] + if skipped_seed_technique: + details.append(f"skipped (require seed_technique): {sorted(skipped_seed_technique)}") + if skipped_no_factory: + details.append(f"skipped (no factory registered): {sorted(skipped_no_factory)}") + suffix = f" ({'; '.join(details)})" if details else "" + raise ValueError( + f"{type(self).__name__}: no usable techniques after resolving strategies. " + f"Check the --strategies selection.{suffix}" + ) + + return techniques + + def _build_atomic_for_seed_group( + self, + *, + dataset_name: str, + seed_group: SeedAttackGroup, + dispatcher: AdaptiveDispatchAttack, + ) -> AtomicAttack: + adaptive_context = self._context_extractor(seed_group) + # Prefer the objective's id when available so resume keys stay stable + # across re-fetches of the same seed groups. + objective_id = seed_group.objective.id if seed_group.objective.id else uuid.uuid4() + atomic_attack_name = f"{self._atomic_attack_prefix}_{dataset_name}_{objective_id}" + + memory_labels = { + **self._memory_labels, + ADAPTIVE_CONTEXT_LABEL: adaptive_context, + } + return AtomicAttack( + atomic_attack_name=atomic_attack_name, + attack_technique=AttackTechnique(attack=dispatcher), + seed_groups=[seed_group], + objective_scorer=self._objective_scorer, + memory_labels=memory_labels, + display_group=dataset_name, + ) + + def _rehydrate_selector_from_memory( + self, + *, + selector: AdaptiveTechniqueSelector, + known_techniques: set[str], + ) -> None: + """Replay persisted dispatch trails into ``selector`` so resume + preserves learned state. + + Iterates every persisted ``AttackResult`` on the resumed + ``ScenarioResult`` and calls ``record_outcome`` once per attempt in + each ``metadata["adaptive_attempts"]`` trail. + + Args: + selector (AdaptiveTechniqueSelector): A freshly built selector to populate. + known_techniques (set[str]): Techniques available in the current run. + Trails referencing unknown techniques (e.g. after a strategies + change) are skipped so replay can't poison the table. + """ + if not self._scenario_result_id: + return + + try: + scenario_results = self._memory.get_scenario_results(scenario_result_ids=[self._scenario_result_id]) + except Exception as exc: + logger.warning(f"AdaptiveScenario: failed to load prior scenario result for rehydration: {exc}") + return + + if not scenario_results: + return + + replayed = 0 + for results_list in scenario_results[0].attack_results.values(): + for result in results_list: + trail = result.metadata.get("adaptive_attempts") if result.metadata else None + context = result.metadata.get("adaptive_context") if result.metadata else None + if not trail or not context: + continue + for step in trail: + technique = step.get("technique") + outcome = step.get("outcome") + if not technique or technique not in known_techniques: + continue + selector.record_outcome( + context=context, + technique=technique, + success=outcome == "success", + ) + replayed += 1 + + if replayed: + logger.info(f"AdaptiveScenario: rehydrated selector with {replayed} prior attempt(s).") diff --git a/pyrit/scenario/scenarios/adaptive/dispatcher.py b/pyrit/scenario/scenarios/adaptive/dispatcher.py index 9f6e99c278..6f682fdddb 100644 --- a/pyrit/scenario/scenarios/adaptive/dispatcher.py +++ b/pyrit/scenario/scenarios/adaptive/dispatcher.py @@ -1,25 +1,18 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -""" -``AdaptiveDispatchAttack`` — an ``AttackStrategy`` that picks which inner -technique to run for each objective using an ``AdaptiveTechniqueSelector``. - -This is the execution-side counterpart to the selector. The selector decides -*which technique to try*; the dispatcher *runs the technique*, records the -outcome, and loops up to ``max_attempts_per_objective`` times. - -The dispatcher reads an adaptive-context key from -``context.memory_labels[ADAPTIVE_CONTEXT_LABEL]``. The scenario is expected to -stamp that label per-objective (computed once at atomic-attack construction -time via a ``ContextExtractor``). When the label is missing, the global -context is used. +"""``AdaptiveDispatchAttack`` — picks an inner technique per attempt via an +``AdaptiveTechniqueSelector``, runs it, records the outcome, and loops up to +``max_attempts_per_objective`` times. Reads the per-objective context key from +``context.memory_labels[ADAPTIVE_CONTEXT_LABEL]`` (falls back to the global context). """ from __future__ import annotations import logging -from dataclasses import dataclass +import uuid +from dataclasses import dataclass, replace +from datetime import datetime, timezone from typing import TYPE_CHECKING, Any from pyrit.executor.attack.core.attack_parameters import AttackParameters @@ -36,35 +29,30 @@ logger = logging.getLogger(__name__) -"""Memory-label key whose value is the adaptive context string for an objective.""" +# Memory-label keys stamped onto persisted prompt rows so adaptive attempts +# can be filtered/grouped after a run. The scenario stamps the context once +# per objective; the dispatcher stamps technique + attempt index on each try. ADAPTIVE_CONTEXT_LABEL: str = "_adaptive_context" - +"""Per-objective context key (e.g. ``"_global"`` or a harm category).""" ADAPTIVE_TECHNIQUE_LABEL: str = "_adaptive_technique" +"""Technique chosen by the dispatcher for a given attempt.""" ADAPTIVE_ATTEMPT_LABEL: str = "_adaptive_attempt" +"""1-based attempt index within the per-objective loop.""" @dataclass class AdaptiveDispatchContext(AttackContext[AttackParameters]): - """ - Execution context for ``AdaptiveDispatchAttack``. - - No extra state is needed beyond what ``AttackContext`` provides; the - dispatcher reads the objective and memory labels from the base class. - """ + """Execution context for ``AdaptiveDispatchAttack`` (no extra state).""" class AdaptiveDispatchAttack(AttackStrategy[AdaptiveDispatchContext, AttackResult]): - """ - Attack that delegates each attempt to one of several inner ``AttackStrategy`` - instances ("techniques"), choosing per attempt via an ``AdaptiveTechniqueSelector``. + """Attack that delegates each attempt to one of several inner techniques, + choosing per attempt via an ``AdaptiveTechniqueSelector``. - For each objective the dispatcher loops up to ``max_attempts_per_objective`` - times. On each iteration it asks the selector which technique to try, executes - the inner attack with the objective, records the outcome on the selector, - and stops early on success. - - The selector instance is **shared by reference** with the scenario, so - learning accumulates across all objectives in a run. + For each objective, loops up to ``max_attempts_per_objective`` times: + ask the selector, execute the chosen technique, record the outcome, and + stop early on success. The selector is shared by reference with the + scenario so learning accumulates across objectives. """ def __init__( @@ -77,17 +65,13 @@ def __init__( ) -> None: """ Args: - objective_target (PromptTarget): The target the inner attacks run against. - Stored for identifier/logging parity; the dispatcher does not call - the target directly. + objective_target (PromptTarget): The target inner attacks run against. + Stored for identifier/logging parity; not called directly. techniques (dict[str, AttackStrategy[Any, AttackResult]]): Mapping from technique name to a pre-built inner attack. Must be non-empty. - These are constructed by the scenario from registered attack - technique factories. - selector (AdaptiveTechniqueSelector): Shared adaptive selection state - that tracks per-technique success rates across objectives. - max_attempts_per_objective (int): Maximum number of technique attempts - per objective. Must be >= 1. Defaults to 3. + selector (AdaptiveTechniqueSelector): Shared selector state. + max_attempts_per_objective (int): Max attempts per objective; >= 1. + Defaults to 3. Raises: ValueError: If ``techniques`` is empty or ``max_attempts_per_objective`` < 1. @@ -154,11 +138,22 @@ async def _perform_async(self, *, context: AdaptiveDispatchContext) -> AttackRes if success: break - # ``max_attempts`` is validated >= 1 above, so the loop always runs at least once. - assert last_result is not None - last_result.metadata = { - **last_result.metadata, - "adaptive_attempts": trail, - "adaptive_context": adaptive_context, - } - return last_result + # ``max_attempts`` is validated >= 1, so the loop always runs at least + # once. Guard explicitly rather than with ``assert`` (stripped under -O). + if last_result is None: # pragma: no cover - defensive + raise RuntimeError("AdaptiveDispatchAttack ran zero attempts; this should be unreachable.") + # Return a fresh dispatcher-owned ``AttackResult``: the inner attack + # already persisted ``last_result`` via its own post-execute hook, so + # returning it directly would cause a PK conflict on the outer hook. + # ``dataclasses.replace`` copies every field; we override identity + # fields and stamp the trail onto metadata. + return replace( + last_result, + attack_result_id=str(uuid.uuid4()), + timestamp=datetime.now(timezone.utc), + metadata={ + **last_result.metadata, + "adaptive_attempts": trail, + "adaptive_context": adaptive_context, + }, + ) diff --git a/pyrit/scenario/scenarios/adaptive/selector.py b/pyrit/scenario/scenarios/adaptive/selector.py index c5c9bc6437..0add29990b 100644 --- a/pyrit/scenario/scenarios/adaptive/selector.py +++ b/pyrit/scenario/scenarios/adaptive/selector.py @@ -1,25 +1,12 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -""" -Adaptive technique selection for the ``TextAdaptive`` scenario. - -This module provides: - - ``AdaptiveTechniqueSelector``: an epsilon-greedy selector keyed by - ``(context, technique)`` that tracks successes/attempts per technique and - picks the next technique to try. - - ``ContextExtractor``: a callable alias for deriving a context string - from a ``SeedAttackGroup``, plus two ready-made extractors: - ``global_context`` (single bucket) and ``harm_category_context`` - (first harm category, falling back to ``"_uncategorized"``). - -The selector is intentionally I/O-free and synchronous; it holds a small -mutable table that lives for the duration of a single scenario run. -""" +"""Epsilon-greedy selector and context extractors for adaptive scenarios.""" from __future__ import annotations import random +import threading from collections.abc import Callable, Sequence from typing import TYPE_CHECKING @@ -28,64 +15,48 @@ ContextExtractor = Callable[["SeedAttackGroup"], str] -"""Maps a ``SeedAttackGroup`` to an adaptive context key (e.g. a harm category).""" +"""Maps a ``SeedAttackGroup`` to an adaptive context key.""" - -# Sentinel context keys used when no per-objective partitioning is desired -# or when a seed group lacks harm category metadata. GLOBAL_CONTEXT: str = "_global" -"""Default context key: all objectives share one selection table.""" +"""Default context: all objectives share one selection table.""" UNCATEGORIZED_CONTEXT: str = "_uncategorized" """Fallback context for seed groups with no harm category metadata.""" -# Context extractors are module-level functions so they can be passed directly -# as the ``context_extractor`` argument to ``TextAdaptive``. They implement the -# ``ContextExtractor`` callable protocol. - - def global_context(_seed_attack_group: SeedAttackGroup) -> str: - """Return a constant context so all objectives share one selection table.""" + """Return a single shared context for all objectives.""" return GLOBAL_CONTEXT def harm_category_context(seed_attack_group: SeedAttackGroup) -> str: - """Return the first harm category on the seed group, or a fallback.""" + """Return a context keyed by the sorted, ``|``-joined harm categories. + + Multi-category seeds form their own bucket; sorting makes the key deterministic. + Returns ``UNCATEGORIZED_CONTEXT`` when no categories are set. + """ categories = seed_attack_group.harm_categories if not categories: return UNCATEGORIZED_CONTEXT - return sorted(categories)[0] + return "|".join(sorted(categories)) class AdaptiveTechniqueSelector: - """ - Epsilon-greedy selector over attack techniques. - - The selector maintains a table of ``(context, technique) -> (successes, attempts)`` - counts. ``select`` returns the next technique to try for a given context, - and ``record_outcome`` records the outcome of an attempt. - - Selection uses epsilon-greedy with optimistic initialization: - - With probability ``epsilon``, pick uniformly at random from ``techniques``. - - Otherwise, pick the technique with the highest estimated success rate. - The estimate is ``(successes + 1) / (attempts + 1)`` (Laplace smoothing), - so unseen techniques start at 100% and are explored first via tiebreak. - - When a ``(context, technique)`` cell has fewer than ``pool_threshold`` attempts, - the estimate falls back to the pooled global rate for that technique across all - contexts. This lets per-context selectors benefit from cross-context data - until they have enough local samples. Set ``pool_threshold=1`` to disable - pooling (use the local estimate as soon as any attempt is recorded). - - Note: - This class is not thread/async safe. It assumes sequential calls, - which matches the base ``Scenario._execute_scenario_async`` loop - (same pattern as all other scenarios). + """Epsilon-greedy selector over attack techniques. + + Maintains a ``(context, technique) -> (successes, attempts)`` table. With + probability ``epsilon`` picks uniformly at random; otherwise picks the + technique with the highest Laplace-smoothed estimate ``(s + 1) / (n + 1)`` + (unseen techniques start at 1.0). A ``(context, technique)`` cell with + fewer than ``pool_threshold`` attempts falls back to the technique's + pooled rate across all contexts. + + All public methods are guarded by a ``threading.Lock`` so concurrent + callers cannot corrupt the table. The lock makes individual ops atomic, + not the overall select → execute → record sequence. """ - # Tolerance for floating-point comparison when tiebreaking in exploitation. - # Current estimates are exact rationals, but this guards against future - # estimator changes that may introduce floating-point drift. + # Tolerance for tiebreaking on float estimates (current estimates are exact + # rationals; this guards against future estimator changes). _TIE_TOL: float = 1e-12 def __init__( @@ -99,19 +70,13 @@ def __init__( Args: epsilon (float): Exploration probability in [0.0, 1.0]. Defaults to 0.2. pool_threshold (int): Minimum per-(context, technique) attempts before - the local estimate replaces the pooled-global estimate. Until this - threshold is reached, the selector uses the technique's average - across all contexts. Must be >= 1; set to 1 to disable pooling. - Defaults to 3. - rng (random.Random | None): A ``random.Random`` instance for - reproducible selection decisions. Using a dedicated RNG (rather - than a bare float) enables seeded determinism across the full - sequence of select calls within a run. Defaults to a fresh - unseeded ``random.Random()``. + the local estimate replaces the pooled rate. Must be >= 1; set to 1 + to disable pooling. Defaults to 3. + rng (random.Random | None): RNG for reproducible decisions. Defaults + to a fresh unseeded ``random.Random()``. Raises: - ValueError: If ``epsilon`` is outside [0.0, 1.0] or - ``pool_threshold`` is < 1. + ValueError: If ``epsilon`` is outside [0.0, 1.0] or ``pool_threshold`` < 1. """ if not 0.0 <= epsilon <= 1.0: raise ValueError(f"epsilon must be in [0.0, 1.0], got {epsilon}") @@ -122,17 +87,18 @@ def __init__( self._pool_threshold = pool_threshold self._rng = rng if rng is not None else random.Random() self._counts: dict[tuple[str, str], tuple[int, int]] = {} - # Per-arm pooled counts, kept in sync with ``_counts`` in ``update`` so - # ``_estimate``'s pooled-backoff branch is O(1). + # Per-technique pooled counts, kept in sync with ``_counts`` so the + # pooled-backoff branch in ``_estimate`` is O(1). self._global_counts: dict[str, tuple[int, int]] = {} + # Guards _counts, _global_counts, and _rng against concurrent callers. + self._lock = threading.Lock() def select(self, *, context: str, techniques: Sequence[str]) -> str: - """ - Pick the next technique to try for ``context``. + """Pick the next technique to try for ``context``. Args: - context (str): The context key (e.g. ``"_global"`` or a harm category). - techniques (Sequence[str]): The candidate technique names. + context (str): The context key. + techniques (Sequence[str]): Candidate technique names. Returns: str: The chosen technique name. @@ -144,59 +110,56 @@ def select(self, *, context: str, techniques: Sequence[str]) -> str: if not technique_list: raise ValueError("techniques must contain at least one entry") - if self._rng.random() < self._epsilon: - return self._rng.choice(technique_list) + with self._lock: + if self._rng.random() < self._epsilon: + return self._rng.choice(technique_list) - estimates = {t: self._estimate(context=context, technique=t) for t in technique_list} - best = max(estimates.values()) - winners = [t for t, value in estimates.items() if value >= best - self._TIE_TOL] - return self._rng.choice(winners) + estimates = {t: self._estimate(context=context, technique=t) for t in technique_list} + best = max(estimates.values()) + winners = [t for t, value in estimates.items() if value >= best - self._TIE_TOL] + return self._rng.choice(winners) def record_outcome(self, *, context: str, technique: str, success: bool) -> None: - """ - Record the outcome of an attack attempt for a given technique and context. + """Record the outcome of an attempt. Args: context (str): The context key the decision was made under. technique (str): The technique that was tried. success (bool): Whether the attempt succeeded. """ - successes, attempts = self._counts.get((context, technique), (0, 0)) - attempts += 1 - if success: - successes += 1 - self._counts[(context, technique)] = (successes, attempts) - - global_successes, global_attempts = self._global_counts.get(technique, (0, 0)) - global_attempts += 1 - if success: - global_successes += 1 - self._global_counts[technique] = (global_successes, global_attempts) + with self._lock: + successes, attempts = self._counts.get((context, technique), (0, 0)) + attempts += 1 + if success: + successes += 1 + self._counts[(context, technique)] = (successes, attempts) + + global_successes, global_attempts = self._global_counts.get(technique, (0, 0)) + global_attempts += 1 + if success: + global_successes += 1 + self._global_counts[technique] = (global_successes, global_attempts) def success_rate(self, *, context: str, technique: str) -> float: - """ - Return the Laplace-smoothed success-rate estimate for a technique in a context. - - The "smoothed" rate is ``(successes + 1) / (attempts + 1)`` — Laplace smoothing - provides an optimistic prior for unseen techniques (estimate = 1.0) and avoids - division by zero. This is the same value used internally for exploitation decisions. - """ - return self._estimate(context=context, technique=technique) + """Return the Laplace-smoothed estimate ``(s + 1) / (n + 1)`` used for exploitation.""" + with self._lock: + return self._estimate(context=context, technique=technique) def counts(self, *, context: str, technique: str) -> tuple[int, int]: """Return raw ``(successes, attempts)`` for a ``(context, technique)`` cell.""" - return self._counts.get((context, technique), (0, 0)) + with self._lock: + return self._counts.get((context, technique), (0, 0)) def snapshot(self) -> dict[tuple[str, str], tuple[int, int]]: """Return a shallow copy of the full counts table (for logging/debug).""" - return dict(self._counts) + with self._lock: + return dict(self._counts) def _estimate(self, *, context: str, technique: str) -> float: - """ - Laplace-smoothed success-rate estimate for ``(context, technique)``. + """Estimate for ``(context, technique)``; falls back to pooled rate below + ``pool_threshold`` local attempts. - Below ``pool_threshold`` local attempts, the estimate uses the - pooled-global success rate for the technique across all contexts. + Callers must already hold ``self._lock``. """ local_s, local_n = self._counts.get((context, technique), (0, 0)) if local_n >= self._pool_threshold: diff --git a/pyrit/scenario/scenarios/adaptive/text_adaptive.py b/pyrit/scenario/scenarios/adaptive/text_adaptive.py index 88885fef95..554c1ee10f 100644 --- a/pyrit/scenario/scenarios/adaptive/text_adaptive.py +++ b/pyrit/scenario/scenarios/adaptive/text_adaptive.py @@ -1,51 +1,30 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -""" -TextAdaptive scenario — picks attack techniques per-objective using an -epsilon-greedy selector informed by observed per-run success rates. - -Unlike static scenarios (which run every selected technique against every -objective), TextAdaptive runs **up to** ``max_attempts_per_objective`` -techniques per objective and stops early when one succeeds. Which technique -to try next is decided by an ``AdaptiveTechniqueSelector`` whose estimates are -updated after every attempt. +"""``TextAdaptive`` — text adaptive scenario. -The set of available techniques comes from the selected scenario strategies, so -``--strategies single_turn`` restricts the selector to single-turn techniques, -etc. The default selector uses a single global context; pass a different -``context_extractor`` (e.g., ``harm_category_context``) to partition estimates -per category. +Picks attack techniques per-objective using an epsilon-greedy selector +informed by observed success rates. Runs up to ``max_attempts_per_objective`` +techniques per objective and stops early on success. The available techniques +come from the selected scenario strategies (``--strategies single_turn`` +restricts to single-turn techniques, etc.). """ from __future__ import annotations import logging -import random -import uuid -from typing import TYPE_CHECKING, Any, ClassVar, cast +from typing import ClassVar from pyrit.common import apply_defaults -from pyrit.executor.attack import AttackScoringConfig from pyrit.registry.tag_query import TagQuery from pyrit.scenario.core.dataset_configuration import DatasetConfiguration -from pyrit.scenario.core.scenario import BaselinePolicy, Scenario from pyrit.scenario.core.scenario_strategy import ScenarioStrategy -from pyrit.scenario.scenarios.adaptive.dispatcher import ( - ADAPTIVE_CONTEXT_LABEL, - AdaptiveDispatchAttack, -) +from pyrit.scenario.scenarios.adaptive.adaptive_scenario import AdaptiveScenario from pyrit.scenario.scenarios.adaptive.selector import ( - AdaptiveTechniqueSelector, ContextExtractor, global_context, ) - -if TYPE_CHECKING: - from pyrit.executor.attack.core.attack_strategy import AttackStrategy - from pyrit.models import AttackResult, SeedAttackGroup - from pyrit.scenario.core.atomic_attack import AtomicAttack - from pyrit.score import TrueFalseScorer +from pyrit.score import TrueFalseScorer logger = logging.getLogger(__name__) @@ -68,32 +47,18 @@ def _build_text_adaptive_strategy() -> type[ScenarioStrategy]: ) -class TextAdaptive(Scenario): - """ - Adaptive text-attack scenario that selects techniques per-objective using - an epsilon-greedy selector over the set of selected strategies. - - The selector: - - Picks a technique uniformly at random with probability ``epsilon``. - - Otherwise exploits the highest observed success rate. Unseen techniques - have an optimistic prior so the first few objectives effectively - round-robin through every available technique. - - Pools across contexts when a context has fewer than - ``pool_threshold`` observations for a technique. +class TextAdaptive(AdaptiveScenario): + """Adaptive text-attack scenario. - A baseline ``PromptSendingAttack`` is **not** prepended — every objective - runs through the dispatcher, and ``prompt_sending`` participates as one of - the selector's techniques. + Selects techniques per-objective via an epsilon-greedy selector over the + set of selected strategies. ``prompt_sending`` participates as one of the + selector's techniques rather than being prepended as a baseline. """ VERSION: int = 1 - BASELINE_POLICY: ClassVar[BaselinePolicy] = BaselinePolicy.Forbidden + _atomic_attack_prefix: ClassVar[str] = "adaptive" _cached_strategy_class: ClassVar[type[ScenarioStrategy] | None] = None - # ------------------------------------------------------------------ # - # Required class-method overrides # - # ------------------------------------------------------------------ # - @classmethod def get_strategy_class(cls) -> type[ScenarioStrategy]: if cls._cached_strategy_class is None: @@ -121,10 +86,6 @@ def required_datasets(cls) -> list[str]: def default_dataset_config(cls) -> DatasetConfiguration: return DatasetConfiguration(dataset_names=cls.required_datasets(), max_dataset_size=4) - # ------------------------------------------------------------------ # - # Constructor # - # ------------------------------------------------------------------ # - @apply_defaults def __init__( self, @@ -140,137 +101,24 @@ def __init__( """ Args: objective_scorer (TrueFalseScorer | None): Scorer used to judge each - response. Defaults to the composite scorer built from the base class. + response. Defaults to the composite scorer from the base class. epsilon (float): Exploration probability for the selector. Defaults to 0.2. - pool_threshold (int): Minimum per-(context, technique) attempts before the - local estimate overrides the pooled-global estimate. Set to 1 to - disable pooling. Defaults to 3. - max_attempts_per_objective (int): Maximum techniques tried per - objective before giving up. Defaults to 3. - seed (int | None): RNG seed for deterministic selection decisions. - Defaults to ``None`` (non-deterministic). - context_extractor (ContextExtractor): Function mapping a - ``SeedAttackGroup`` to a context key. Defaults to - ``global_context`` (one shared selection table). Use - ``harm_category_context`` to partition estimates by harm category. - scenario_result_id (str | None): ID of an existing ``ScenarioResult`` - to resume. + pool_threshold (int): Minimum per-(context, technique) attempts before + the local estimate overrides the pooled rate. Set to 1 to disable + pooling. Defaults to 3. + max_attempts_per_objective (int): Max techniques per objective. Defaults to 3. + seed (int | None): RNG seed for deterministic selection. Defaults to ``None``. + context_extractor (ContextExtractor): Maps a ``SeedAttackGroup`` to a + context key. Defaults to ``global_context``. Use + ``harm_category_context`` to partition by harm category. + scenario_result_id (str | None): ID of an existing ``ScenarioResult`` to resume. """ - if not objective_scorer: - objective_scorer = self._get_default_objective_scorer() - - self._epsilon = epsilon - self._pool_threshold = pool_threshold - self._max_attempts_per_objective = max_attempts_per_objective - self._seed = seed - self._context_extractor = context_extractor - super().__init__( - version=self.VERSION, - strategy_class=self.get_strategy_class(), objective_scorer=objective_scorer, + epsilon=epsilon, + pool_threshold=pool_threshold, + max_attempts_per_objective=max_attempts_per_objective, + seed=seed, + context_extractor=context_extractor, scenario_result_id=scenario_result_id, ) - - # ------------------------------------------------------------------ # - # Override atomic-attack construction # - # ------------------------------------------------------------------ # - - async def _get_atomic_attacks_async(self) -> list[AtomicAttack]: - """ - Build one ``AtomicAttack`` per objective, all sharing a single - ``AdaptiveDispatchAttack`` (and therefore a single - ``AdaptiveTechniqueSelector``). - - Each per-objective ``AtomicAttack`` consults and updates the same - selector via the same dispatcher instance, so learning from one - objective immediately benefits the next. - """ - if self._objective_target is None: - raise ValueError("objective_target must be set before creating attacks") - - selected_techniques = sorted({s.value for s in self._scenario_strategies}) - factories = self._get_attack_technique_factories() - - # Build each technique's inner attack once and reuse across all objectives. - # Skip factories that require a seed_technique (e.g. crescendo_simulated) - # since the dispatcher cannot merge technique seeds into the objective's - # seed group at dispatch time. - scoring_config = AttackScoringConfig(objective_scorer=cast("TrueFalseScorer", self._objective_scorer)) - techniques: dict[str, AttackStrategy[Any, AttackResult]] = {} - for technique_name in selected_techniques: - factory = factories.get(technique_name) - if factory is None: - logger.warning(f"No factory for technique '{technique_name}', skipping.") - continue - technique = factory.create( - objective_target=self._objective_target, - attack_scoring_config=scoring_config, - ) - if technique.seed_technique is not None: - logger.debug( - "Skipping technique '%s': requires seed_technique which adaptive dispatch cannot handle.", - technique_name, - ) - continue - techniques[technique_name] = technique.attack - - if not techniques: - raise ValueError( - "TextAdaptive: no usable techniques after resolving strategies. Check the --strategies selection." - ) - - selector = AdaptiveTechniqueSelector( - epsilon=self._epsilon, - pool_threshold=self._pool_threshold, - rng=random.Random(self._seed), - ) - dispatcher = AdaptiveDispatchAttack( - objective_target=self._objective_target, - techniques=techniques, - selector=selector, - max_attempts_per_objective=self._max_attempts_per_objective, - ) - - seed_groups_by_dataset = self._dataset_config.get_seed_attack_groups() - atomic_attacks: list[AtomicAttack] = [] - for dataset_name, seed_groups in seed_groups_by_dataset.items(): - for seed_group in seed_groups: - atomic_attacks.append( - self._build_atomic_for_seed_group( - dataset_name=dataset_name, - seed_group=seed_group, - dispatcher=dispatcher, - ) - ) - - return atomic_attacks - - def _build_atomic_for_seed_group( - self, - *, - dataset_name: str, - seed_group: SeedAttackGroup, - dispatcher: AdaptiveDispatchAttack, - ) -> AtomicAttack: - from pyrit.scenario.core.atomic_attack import AtomicAttack - from pyrit.scenario.core.attack_technique import AttackTechnique - - adaptive_context = self._context_extractor(seed_group) - # Use the objective's id when available so resume keys are stable across - # runs that re-fetch the same seed groups; fall back to a random uuid. - objective_id = seed_group.objective.id if seed_group.objective.id else uuid.uuid4() - atomic_attack_name = f"adaptive_{dataset_name}_{objective_id}" - - memory_labels = { - **self._memory_labels, - ADAPTIVE_CONTEXT_LABEL: adaptive_context, - } - return AtomicAttack( - atomic_attack_name=atomic_attack_name, - attack_technique=AttackTechnique(attack=dispatcher), - seed_groups=[seed_group], - objective_scorer=cast("TrueFalseScorer", self._objective_scorer), - memory_labels=memory_labels, - display_group=dataset_name, - ) diff --git a/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py b/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py index 87170faa19..422e3d4314 100644 --- a/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py +++ b/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py @@ -200,6 +200,39 @@ async def test_metadata_records_adaptive_trail(self, target, selector): ] assert result.metadata["adaptive_context"] == GLOBAL_CONTEXT + async def test_returns_fresh_result_distinct_from_inner(self, target, selector): + # The dispatcher must NOT return the inner attack's ``AttackResult`` + # instance — doing so would cause a duplicate-PK insert when both the + # inner and the dispatcher's ``execute_async`` post-execute hooks try + # to persist the same row. Verify the returned result has a fresh + # ``attack_result_id`` while preserving the inner's identifying fields + # and stamping the dispatch trail. + a = _make_inner_attack(name="a", outcomes=[AttackOutcome.SUCCESS]) + dispatcher = AdaptiveDispatchAttack( + objective_target=target, + techniques={"a": a}, + selector=selector, + ) + # Capture the inner result's id by spying on execute_async. + original_execute = a.execute_async + inner_ids: list[str] = [] + + async def _spy(**kwargs): + inner_result = await original_execute(**kwargs) + inner_ids.append(inner_result.attack_result_id) + return inner_result + + a.execute_async = _spy # type: ignore[assignment] + + result = await dispatcher._perform_async(context=_make_context()) + + assert len(inner_ids) == 1 + assert result.attack_result_id != inner_ids[0] + assert result.conversation_id # carried over from inner + assert result.outcome == AttackOutcome.SUCCESS + assert result.metadata["adaptive_attempts"] == [{"technique": "a", "outcome": "success"}] + assert result.metadata["adaptive_context"] == GLOBAL_CONTEXT + @pytest.mark.usefixtures("patch_central_database") class TestValidate: diff --git a/tests/unit/scenario/scenarios/adaptive/test_selector.py b/tests/unit/scenario/scenarios/adaptive/test_selector.py index ab6aae03e3..370430497e 100644 --- a/tests/unit/scenario/scenarios/adaptive/test_selector.py +++ b/tests/unit/scenario/scenarios/adaptive/test_selector.py @@ -171,8 +171,13 @@ def test_global_context_is_constant(self): def test_harm_category_context_uses_sorted_first_category(self): sg = MagicMock() sg.harm_categories = ["violence", "hate"] - # sorted() ensures deterministic selection regardless of set iteration order - assert harm_category_context(sg) == "hate" + # Multi-category seeds form their own bucket; sorting keeps the key deterministic. + assert harm_category_context(sg) == "hate|violence" + + def test_harm_category_context_single_category(self): + sg = MagicMock() + sg.harm_categories = ["violence"] + assert harm_category_context(sg) == "violence" def test_harm_category_context_falls_back_when_empty(self): sg = MagicMock() @@ -183,3 +188,38 @@ def test_harm_category_context_falls_back_when_none(self): sg = MagicMock() sg.harm_categories = None assert harm_category_context(sg) == UNCATEGORIZED_CONTEXT + + +class TestAdaptiveTechniqueSelectorConcurrency: + """Concurrent record_outcome / select calls must not corrupt counts.""" + + def test_concurrent_record_outcome_preserves_total_attempts(self): + import threading + + selector = _seeded_selector(pool_threshold=1) + threads_per_arm = 8 + attempts_per_thread = 100 + techniques = ["a", "b", "c", "d"] + + def worker(technique: str, success_pattern: list[bool]) -> None: + for ok in success_pattern: + selector.record_outcome(context=GLOBAL_CONTEXT, technique=technique, success=ok) + + threads: list[threading.Thread] = [] + expected_successes: dict[str, int] = dict.fromkeys(techniques, 0) + for t in techniques: + for i in range(threads_per_arm): + pattern = [(j + i) % 2 == 0 for j in range(attempts_per_thread)] + expected_successes[t] += sum(pattern) + threads.append(threading.Thread(target=worker, args=(t, pattern))) + + for th in threads: + th.start() + for th in threads: + th.join() + + # Every increment landed: no lost updates from interleaved read-modify-write. + for t in techniques: + successes, attempts = selector.counts(context=GLOBAL_CONTEXT, technique=t) + assert attempts == threads_per_arm * attempts_per_thread + assert successes == expected_successes[t] diff --git a/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py b/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py index c67abc0fcd..c32cab41d6 100644 --- a/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py +++ b/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py @@ -258,6 +258,125 @@ async def test_no_usable_techniques_raises(self, mock_objective_target, mock_obj await scenario._get_atomic_attacks_async() +@pytest.mark.usefixtures(*FIXTURES) +class TestTextAdaptiveSelectorRehydration: + """When resuming, prior dispatch trails should replay into the new selector.""" + + def _build_scenario_no_resume_id(self, *, scorer): + return TextAdaptive(objective_scorer=scorer) + + def test_no_scenario_result_id_is_noop(self, mock_objective_scorer): + from pyrit.scenario.scenarios.adaptive.selector import AdaptiveTechniqueSelector + + scenario = TextAdaptive(objective_scorer=mock_objective_scorer) + selector = AdaptiveTechniqueSelector() + # No scenario_result_id set -> early return, no errors, no replays. + scenario._rehydrate_selector_from_memory(selector=selector, known_techniques={"a", "b"}) + assert selector.snapshot() == {} + + def test_replays_attempts_from_metadata(self, mock_objective_scorer): + from pyrit.models import AttackResult + from pyrit.scenario.scenarios.adaptive.selector import AdaptiveTechniqueSelector + + scenario = TextAdaptive(objective_scorer=mock_objective_scorer, scenario_result_id="rid") + + prior_result = MagicMock() + prior_result.attack_results = { + "adaptive_violence_o1": [ + AttackResult( + conversation_id="c1", + objective="o1", + metadata={ + "adaptive_attempts": [ + {"technique": "a", "outcome": "failure"}, + {"technique": "b", "outcome": "success"}, + ], + "adaptive_context": "violence", + }, + ), + ], + "adaptive_hate_o2": [ + AttackResult( + conversation_id="c2", + objective="o2", + metadata={ + "adaptive_attempts": [{"technique": "a", "outcome": "success"}], + "adaptive_context": "hate", + }, + ), + ], + } + scenario._memory = MagicMock() + scenario._memory.get_scenario_results.return_value = [prior_result] + + selector = AdaptiveTechniqueSelector() + scenario._rehydrate_selector_from_memory(selector=selector, known_techniques={"a", "b"}) + + # Trails replayed verbatim into the per-context table. + assert selector.counts(context="violence", technique="a") == (0, 1) + assert selector.counts(context="violence", technique="b") == (1, 1) + assert selector.counts(context="hate", technique="a") == (1, 1) + + def test_skips_unknown_techniques(self, mock_objective_scorer): + from pyrit.models import AttackResult + from pyrit.scenario.scenarios.adaptive.selector import AdaptiveTechniqueSelector + + scenario = TextAdaptive(objective_scorer=mock_objective_scorer, scenario_result_id="rid") + prior_result = MagicMock() + prior_result.attack_results = { + "x": [ + AttackResult( + conversation_id="c1", + objective="o1", + metadata={ + "adaptive_attempts": [ + {"technique": "removed_technique", "outcome": "success"}, + {"technique": "a", "outcome": "failure"}, + ], + "adaptive_context": "ctx", + }, + ), + ], + } + scenario._memory = MagicMock() + scenario._memory.get_scenario_results.return_value = [prior_result] + + selector = AdaptiveTechniqueSelector() + scenario._rehydrate_selector_from_memory(selector=selector, known_techniques={"a"}) + + # Only the known technique was recorded. + assert selector.counts(context="ctx", technique="a") == (0, 1) + assert selector.counts(context="ctx", technique="removed_technique") == (0, 0) + + def test_ignores_results_without_adaptive_metadata(self, mock_objective_scorer): + from pyrit.models import AttackResult + from pyrit.scenario.scenarios.adaptive.selector import AdaptiveTechniqueSelector + + scenario = TextAdaptive(objective_scorer=mock_objective_scorer, scenario_result_id="rid") + prior_result = MagicMock() + prior_result.attack_results = { + "baseline": [AttackResult(conversation_id="c", objective="o", metadata={})], + } + scenario._memory = MagicMock() + scenario._memory.get_scenario_results.return_value = [prior_result] + + selector = AdaptiveTechniqueSelector() + scenario._rehydrate_selector_from_memory(selector=selector, known_techniques={"a"}) + assert selector.snapshot() == {} + + def test_memory_load_failure_is_swallowed(self, mock_objective_scorer): + from pyrit.scenario.scenarios.adaptive.selector import AdaptiveTechniqueSelector + + scenario = TextAdaptive(objective_scorer=mock_objective_scorer, scenario_result_id="rid") + scenario._memory = MagicMock() + scenario._memory.get_scenario_results.side_effect = RuntimeError("db down") + + selector = AdaptiveTechniqueSelector() + # Must not raise; selector remains empty. + scenario._rehydrate_selector_from_memory(selector=selector, known_techniques={"a"}) + assert selector.snapshot() == {} + + @pytest.mark.usefixtures(*FIXTURES) class TestTextAdaptiveBaselinePolicy: async def test_initialize_async_rejects_explicit_baseline(self, mock_objective_target, mock_objective_scorer): From 2c06a24d006d561c3cedc33827e83fe3135b52bf Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Tue, 19 May 2026 13:07:03 -0400 Subject: [PATCH 06/35] pre-commit --- pyrit/scenario/scenarios/adaptive/dispatcher.py | 6 ++++-- pyrit/scenario/scenarios/adaptive/selector.py | 15 ++++++++++----- .../scenario/scenarios/adaptive/text_adaptive.py | 6 ++++-- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/pyrit/scenario/scenarios/adaptive/dispatcher.py b/pyrit/scenario/scenarios/adaptive/dispatcher.py index 6f682fdddb..d12fdd2f58 100644 --- a/pyrit/scenario/scenarios/adaptive/dispatcher.py +++ b/pyrit/scenario/scenarios/adaptive/dispatcher.py @@ -1,7 +1,8 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -"""``AdaptiveDispatchAttack`` — picks an inner technique per attempt via an +""" +``AdaptiveDispatchAttack`` — picks an inner technique per attempt via an ``AdaptiveTechniqueSelector``, runs it, records the outcome, and loops up to ``max_attempts_per_objective`` times. Reads the per-objective context key from ``context.memory_labels[ADAPTIVE_CONTEXT_LABEL]`` (falls back to the global context). @@ -46,7 +47,8 @@ class AdaptiveDispatchContext(AttackContext[AttackParameters]): class AdaptiveDispatchAttack(AttackStrategy[AdaptiveDispatchContext, AttackResult]): - """Attack that delegates each attempt to one of several inner techniques, + """ + Attack that delegates each attempt to one of several inner techniques, choosing per attempt via an ``AdaptiveTechniqueSelector``. For each objective, loops up to ``max_attempts_per_objective`` times: diff --git a/pyrit/scenario/scenarios/adaptive/selector.py b/pyrit/scenario/scenarios/adaptive/selector.py index 0add29990b..967aee9d1f 100644 --- a/pyrit/scenario/scenarios/adaptive/selector.py +++ b/pyrit/scenario/scenarios/adaptive/selector.py @@ -29,7 +29,8 @@ def global_context(_seed_attack_group: SeedAttackGroup) -> str: def harm_category_context(seed_attack_group: SeedAttackGroup) -> str: - """Return a context keyed by the sorted, ``|``-joined harm categories. + """ + Return a context keyed by the sorted, ``|``-joined harm categories. Multi-category seeds form their own bucket; sorting makes the key deterministic. Returns ``UNCATEGORIZED_CONTEXT`` when no categories are set. @@ -41,7 +42,8 @@ def harm_category_context(seed_attack_group: SeedAttackGroup) -> str: class AdaptiveTechniqueSelector: - """Epsilon-greedy selector over attack techniques. + """ + Epsilon-greedy selector over attack techniques. Maintains a ``(context, technique) -> (successes, attempts)`` table. With probability ``epsilon`` picks uniformly at random; otherwise picks the @@ -94,7 +96,8 @@ def __init__( self._lock = threading.Lock() def select(self, *, context: str, techniques: Sequence[str]) -> str: - """Pick the next technique to try for ``context``. + """ + Pick the next technique to try for ``context``. Args: context (str): The context key. @@ -120,7 +123,8 @@ def select(self, *, context: str, techniques: Sequence[str]) -> str: return self._rng.choice(winners) def record_outcome(self, *, context: str, technique: str, success: bool) -> None: - """Record the outcome of an attempt. + """ + Record the outcome of an attempt. Args: context (str): The context key the decision was made under. @@ -156,7 +160,8 @@ def snapshot(self) -> dict[tuple[str, str], tuple[int, int]]: return dict(self._counts) def _estimate(self, *, context: str, technique: str) -> float: - """Estimate for ``(context, technique)``; falls back to pooled rate below + """ + Estimate for ``(context, technique)``; falls back to pooled rate below ``pool_threshold`` local attempts. Callers must already hold ``self._lock``. diff --git a/pyrit/scenario/scenarios/adaptive/text_adaptive.py b/pyrit/scenario/scenarios/adaptive/text_adaptive.py index 554c1ee10f..bc08edd9cc 100644 --- a/pyrit/scenario/scenarios/adaptive/text_adaptive.py +++ b/pyrit/scenario/scenarios/adaptive/text_adaptive.py @@ -1,7 +1,8 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -"""``TextAdaptive`` — text adaptive scenario. +""" +``TextAdaptive`` — text adaptive scenario. Picks attack techniques per-objective using an epsilon-greedy selector informed by observed success rates. Runs up to ``max_attempts_per_objective`` @@ -48,7 +49,8 @@ def _build_text_adaptive_strategy() -> type[ScenarioStrategy]: class TextAdaptive(AdaptiveScenario): - """Adaptive text-attack scenario. + """ + Adaptive text-attack scenario. Selects techniques per-objective via an epsilon-greedy selector over the set of selected strategies. ``prompt_sending`` participates as one of the From 11b39a0708aef6cdc746979efb763a022e143c71 Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Tue, 19 May 2026 15:34:25 -0400 Subject: [PATCH 07/35] integrate attack technique group --- doc/code/scenarios/3_adaptive_scenarios.ipynb | 11 +- doc/code/scenarios/3_adaptive_scenarios.py | 11 +- .../scenarios/adaptive/adaptive_scenario.py | 126 +++++++---- .../scenario/scenarios/adaptive/dispatcher.py | 106 ++++++++- .../scenarios/adaptive/test_dispatcher.py | 207 +++++++++++------- .../scenarios/adaptive/test_selector.py | 2 +- .../scenarios/adaptive/test_text_adaptive.py | 158 +++++++++++-- 7 files changed, 462 insertions(+), 159 deletions(-) diff --git a/doc/code/scenarios/3_adaptive_scenarios.ipynb b/doc/code/scenarios/3_adaptive_scenarios.ipynb index 93938c1d6b..2067a8896d 100644 --- a/doc/code/scenarios/3_adaptive_scenarios.ipynb +++ b/doc/code/scenarios/3_adaptive_scenarios.ipynb @@ -245,8 +245,15 @@ "source": [ "## Resuming a run\n", "\n", - "Adaptive scenarios are resumable — pass `scenario_result_id=...` to `initialize_async`\n", - "and the run picks up where it left off, with prior outcomes replayed into the selector." + "Adaptive scenarios are resumable — pass `scenario_result_id=...` to the `TextAdaptive`\n", + "constructor and the run picks up where it left off, with prior outcomes replayed into\n", + "the selector.\n", + "\n", + "```python\n", + "resumed_scenario = TextAdaptive(scenario_result_id=\"\")\n", + "await resumed_scenario.initialize_async(objective_target=objective_target)\n", + "resumed_result = await resumed_scenario.run_async()\n", + "```" ] } ], diff --git a/doc/code/scenarios/3_adaptive_scenarios.py b/doc/code/scenarios/3_adaptive_scenarios.py index 27a4cffbda..0385619039 100644 --- a/doc/code/scenarios/3_adaptive_scenarios.py +++ b/doc/code/scenarios/3_adaptive_scenarios.py @@ -160,5 +160,12 @@ # %% [markdown] # ## Resuming a run # -# Adaptive scenarios are resumable — pass `scenario_result_id=...` to `initialize_async` -# and the run picks up where it left off, with prior outcomes replayed into the selector. +# Adaptive scenarios are resumable — pass `scenario_result_id=...` to the `TextAdaptive` +# constructor and the run picks up where it left off, with prior outcomes replayed into +# the selector. +# +# ```python +# resumed_scenario = TextAdaptive(scenario_result_id="") +# await resumed_scenario.initialize_async(objective_target=objective_target) +# resumed_result = await resumed_scenario.run_async() +# ``` diff --git a/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py b/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py index b14cc28c43..6edf753e25 100644 --- a/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py +++ b/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py @@ -1,7 +1,8 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -"""``AdaptiveScenario`` — modality-agnostic base for scenarios that pick attack +""" +``AdaptiveScenario`` — modality-agnostic base for scenarios that pick attack techniques per-objective using an ``AdaptiveTechniqueSelector``. Owns selector wiring, dispatcher construction, per-objective atomic-attack @@ -18,7 +19,7 @@ import logging import random import uuid -from typing import TYPE_CHECKING, Any, ClassVar +from typing import TYPE_CHECKING, ClassVar from pyrit.executor.attack import AttackScoringConfig from pyrit.scenario.core.atomic_attack import AtomicAttack @@ -27,6 +28,7 @@ from pyrit.scenario.scenarios.adaptive.dispatcher import ( ADAPTIVE_CONTEXT_LABEL, AdaptiveDispatchAttack, + TechniqueBundle, ) from pyrit.scenario.scenarios.adaptive.selector import ( AdaptiveTechniqueSelector, @@ -35,8 +37,7 @@ ) if TYPE_CHECKING: - from pyrit.executor.attack.core.attack_strategy import AttackStrategy - from pyrit.models import AttackResult, SeedAttackGroup + from pyrit.models import SeedAttackGroup from pyrit.prompt_target import PromptTarget from pyrit.score import TrueFalseScorer @@ -44,7 +45,8 @@ class AdaptiveScenario(Scenario): - """Abstract base for adaptive (epsilon-greedy) scenarios. + """ + Abstract base for adaptive (epsilon-greedy) scenarios. Subclasses must implement the standard ``Scenario`` class-method overrides and declare ``VERSION`` and ``_atomic_attack_prefix``. Selector wiring, @@ -103,8 +105,14 @@ def __init__( ) async def _get_atomic_attacks_async(self) -> list[AtomicAttack]: - """Build one ``AtomicAttack`` per objective, all sharing a single - ``AdaptiveDispatchAttack`` (and therefore a single selector). + """ + Build one ``AtomicAttack`` per objective. + + Each objective gets a freshly constructed ``AdaptiveDispatchAttack`` + bound to its seed group, but all dispatchers share the same selector + so learning accumulates across objectives. Per-objective, techniques + whose ``seed_technique`` is incompatible with the seed group are + filtered out; objectives left with no compatible techniques are skipped. """ if self._objective_target is None: raise ValueError("objective_target must be set before creating attacks") @@ -119,24 +127,18 @@ async def _get_atomic_attacks_async(self) -> list[AtomicAttack]: # On resume, replay prior attempt outcomes from persisted metadata. self._rehydrate_selector_from_memory(selector=selector, known_techniques=set(techniques)) - dispatcher = AdaptiveDispatchAttack( - objective_target=self._objective_target, - techniques=techniques, - selector=selector, - max_attempts_per_objective=self._max_attempts_per_objective, - ) - seed_groups_by_dataset = self._dataset_config.get_seed_attack_groups() atomic_attacks: list[AtomicAttack] = [] for dataset_name, seed_groups in seed_groups_by_dataset.items(): for seed_group in seed_groups: - atomic_attacks.append( - self._build_atomic_for_seed_group( - dataset_name=dataset_name, - seed_group=seed_group, - dispatcher=dispatcher, - ) + atomic = self._build_atomic_for_seed_group( + dataset_name=dataset_name, + seed_group=seed_group, + techniques=techniques, + selector=selector, ) + if atomic is not None: + atomic_attacks.append(atomic) return atomic_attacks @@ -144,13 +146,13 @@ def _build_techniques_dict( self, *, objective_target: PromptTarget, - ) -> dict[str, AttackStrategy[Any, AttackResult]]: - """Resolve selected strategies into a ``{name: inner_attack}`` map. + ) -> dict[str, TechniqueBundle]: + """ + Resolve selected strategies into a ``{name: TechniqueBundle}`` map. - Skips factories not registered for the current modality, and factories - whose technique requires a ``seed_technique`` (e.g. ``crescendo_simulated``) - — the dispatcher has no hook to merge technique seeds into per-objective - seed groups. + Each bundle carries the inner attack strategy along with the factory's + ``seed_technique`` and ``adversarial_chat`` so the dispatcher can + reproduce the static ``AtomicAttack`` execution path per attempt. Raises: ValueError: If no techniques remain after filtering. Includes the @@ -160,8 +162,7 @@ def _build_techniques_dict( factories = self._get_attack_technique_factories() scoring_config = AttackScoringConfig(objective_scorer=self._objective_scorer) - techniques: dict[str, AttackStrategy[Any, AttackResult]] = {} - skipped_seed_technique: list[str] = [] + techniques: dict[str, TechniqueBundle] = {} skipped_no_factory: list[str] = [] for technique_name in selected_techniques: factory = factories.get(technique_name) @@ -173,24 +174,14 @@ def _build_techniques_dict( objective_target=objective_target, attack_scoring_config=scoring_config, ) - if technique.seed_technique is not None: - skipped_seed_technique.append(technique_name) - logger.warning( - "Skipping technique '%s': it requires a seed_technique which the adaptive " - "dispatcher cannot merge into per-objective seed groups. Use a static " - "scenario (e.g. RapidResponse) to run this technique.", - technique_name, - ) - continue - techniques[technique_name] = technique.attack + techniques[technique_name] = TechniqueBundle( + attack=technique.attack, + seed_technique=technique.seed_technique, + adversarial_chat=factory.adversarial_chat, + ) if not techniques: - details: list[str] = [] - if skipped_seed_technique: - details.append(f"skipped (require seed_technique): {sorted(skipped_seed_technique)}") - if skipped_no_factory: - details.append(f"skipped (no factory registered): {sorted(skipped_no_factory)}") - suffix = f" ({'; '.join(details)})" if details else "" + suffix = f" (skipped, no factory registered: {sorted(skipped_no_factory)})" if skipped_no_factory else "" raise ValueError( f"{type(self).__name__}: no usable techniques after resolving strategies. " f"Check the --strategies selection.{suffix}" @@ -203,14 +194,50 @@ def _build_atomic_for_seed_group( *, dataset_name: str, seed_group: SeedAttackGroup, - dispatcher: AdaptiveDispatchAttack, - ) -> AtomicAttack: + techniques: dict[str, TechniqueBundle], + selector: AdaptiveTechniqueSelector, + ) -> AtomicAttack | None: + """ + Build a single ``AtomicAttack`` for one ``SeedAttackGroup``. + + Filters the technique pool down to those whose ``seed_technique`` (if + any) is compatible with this seed group, then constructs a dedicated + ``AdaptiveDispatchAttack`` bound to this seed group. Returns ``None`` + when no techniques are compatible (caller skips the objective). + """ + if self._objective_target is None: # pragma: no cover - defensive + raise ValueError("objective_target must be set before creating attacks") + + compatible: dict[str, TechniqueBundle] = {} + for name, bundle in techniques.items(): + if bundle.seed_technique is None or seed_group.is_compatible_with_technique( + technique=bundle.seed_technique + ): + compatible[name] = bundle + + if not compatible: + logger.warning( + "AdaptiveScenario: no compatible techniques for seed group in dataset '%s' (objective=%r); skipping.", + dataset_name, + seed_group.objective.value, + ) + return None + adaptive_context = self._context_extractor(seed_group) # Prefer the objective's id when available so resume keys stay stable # across re-fetches of the same seed groups. objective_id = seed_group.objective.id if seed_group.objective.id else uuid.uuid4() atomic_attack_name = f"{self._atomic_attack_prefix}_{dataset_name}_{objective_id}" + dispatcher = AdaptiveDispatchAttack( + objective_target=self._objective_target, + techniques=compatible, + selector=selector, + seed_group=seed_group, + objective_scorer=self._objective_scorer, + max_attempts_per_objective=self._max_attempts_per_objective, + ) + memory_labels = { **self._memory_labels, ADAPTIVE_CONTEXT_LABEL: adaptive_context, @@ -230,7 +257,8 @@ def _rehydrate_selector_from_memory( selector: AdaptiveTechniqueSelector, known_techniques: set[str], ) -> None: - """Replay persisted dispatch trails into ``selector`` so resume + """ + Replay persisted dispatch trails into ``selector`` so resume preserves learned state. Iterates every persisted ``AttackResult`` on the resumed @@ -246,9 +274,11 @@ def _rehydrate_selector_from_memory( if not self._scenario_result_id: return + # Narrow to errors a memory backend would plausibly raise (DB/IO + # failures, integrity issues). Programmer-level errors propagate. try: scenario_results = self._memory.get_scenario_results(scenario_result_ids=[self._scenario_result_id]) - except Exception as exc: + except (RuntimeError, OSError, ValueError) as exc: logger.warning(f"AdaptiveScenario: failed to load prior scenario result for rehydration: {exc}") return diff --git a/pyrit/scenario/scenarios/adaptive/dispatcher.py b/pyrit/scenario/scenarios/adaptive/dispatcher.py index d12fdd2f58..8499102cac 100644 --- a/pyrit/scenario/scenarios/adaptive/dispatcher.py +++ b/pyrit/scenario/scenarios/adaptive/dispatcher.py @@ -6,6 +6,10 @@ ``AdaptiveTechniqueSelector``, runs it, records the outcome, and loops up to ``max_attempts_per_objective`` times. Reads the per-objective context key from ``context.memory_labels[ADAPTIVE_CONTEXT_LABEL]`` (falls back to the global context). + +The dispatcher is bound to a single ``SeedAttackGroup`` at construction time so +it can merge each chosen technique's ``seed_technique`` (when present) into the +seed group before delegating execution to ``AttackExecutor``. """ from __future__ import annotations @@ -16,6 +20,7 @@ from datetime import datetime, timezone from typing import TYPE_CHECKING, Any +from pyrit.executor.attack.core.attack_executor import AttackExecutor from pyrit.executor.attack.core.attack_parameters import AttackParameters from pyrit.executor.attack.core.attack_strategy import AttackContext, AttackStrategy from pyrit.models import AttackOutcome, AttackResult @@ -25,7 +30,9 @@ ) if TYPE_CHECKING: + from pyrit.models import SeedAttackGroup, SeedAttackTechniqueGroup from pyrit.prompt_target import PromptTarget + from pyrit.score import TrueFalseScorer logger = logging.getLogger(__name__) @@ -41,6 +48,21 @@ """1-based attempt index within the per-objective loop.""" +@dataclass(frozen=True) +class TechniqueBundle: + """ + Per-technique bundle consumed by the dispatcher. + + Carries the inner attack strategy alongside the factory-supplied + ``seed_technique`` (if any) and ``adversarial_chat`` (required when the + seed_technique contains a simulated-conversation config). + """ + + attack: AttackStrategy[Any, AttackResult] + seed_technique: SeedAttackTechniqueGroup | None = None + adversarial_chat: PromptTarget | None = None + + @dataclass class AdaptiveDispatchContext(AttackContext[AttackParameters]): """Execution context for ``AdaptiveDispatchAttack`` (no extra state).""" @@ -55,23 +77,43 @@ class AdaptiveDispatchAttack(AttackStrategy[AdaptiveDispatchContext, AttackResul ask the selector, execute the chosen technique, record the outcome, and stop early on success. The selector is shared by reference with the scenario so learning accumulates across objectives. + + The dispatcher is bound to a single ``SeedAttackGroup`` at construction + time. When a chosen technique declares a ``seed_technique``, that group + is merged into the seed group before execution (mirroring the static + ``AtomicAttack`` path). + + On success, the dispatcher returns a fresh ``AttackResult`` copy of the + winning inner result (new ``attack_result_id`` and ``timestamp``) with + the dispatch trail stamped onto ``metadata``. The inner result has + already been persisted by its own post-execute hook, so two rows are + written per successful objective sharing the same ``conversation_id``: + the inner row carries the raw outcome, the outer row carries the + adaptive trail. """ def __init__( self, *, objective_target: PromptTarget, - techniques: dict[str, AttackStrategy[Any, AttackResult]], + techniques: dict[str, TechniqueBundle], selector: AdaptiveTechniqueSelector, + seed_group: SeedAttackGroup, + objective_scorer: TrueFalseScorer | None = None, max_attempts_per_objective: int = 3, ) -> None: """ Args: objective_target (PromptTarget): The target inner attacks run against. Stored for identifier/logging parity; not called directly. - techniques (dict[str, AttackStrategy[Any, AttackResult]]): Mapping from - technique name to a pre-built inner attack. Must be non-empty. + techniques (dict[str, TechniqueBundle]): Mapping from technique name to + its bundle (attack, seed_technique, adversarial_chat). Must be non-empty. selector (AdaptiveTechniqueSelector): Shared selector state. + seed_group (SeedAttackGroup): The seed group bound to this dispatcher. + Each attempt's chosen technique is applied against this group + (merging the technique's ``seed_technique`` when present). + objective_scorer (TrueFalseScorer | None): Scorer passed through to + techniques that generate simulated conversations. max_attempts_per_objective (int): Max attempts per objective; >= 1. Defaults to 3. @@ -91,7 +133,13 @@ def __init__( ) self._techniques = techniques self._selector = selector + self._seed_group = seed_group + self._objective_scorer = objective_scorer self._max_attempts = max_attempts_per_objective + # Attempts are inherently sequential (each one reads the selector + # state updated by the previous), so a single shared executor with + # ``max_concurrency=1`` is reused across attempts. + self._executor = AttackExecutor(max_concurrency=1) def _validate_context(self, *, context: AdaptiveDispatchContext) -> None: if not context.objective or context.objective.isspace(): @@ -103,6 +151,51 @@ async def _setup_async(self, *, context: AdaptiveDispatchContext) -> None: async def _teardown_async(self, *, context: AdaptiveDispatchContext) -> None: pass + async def _run_inner_attack_async( + self, + *, + bundle: TechniqueBundle, + attempt_labels: dict[str, str], + ) -> AttackResult: + """ + Execute the chosen technique against this dispatcher's seed group. + + Merges ``bundle.seed_technique`` into the bound ``seed_group`` (when + present) and delegates execution to ``AttackExecutor``. Isolated as a + method so tests can patch the inner-attack call surface. + + Args: + bundle (TechniqueBundle): The chosen technique's attack + seeds + chat. + attempt_labels (dict[str, str]): Memory labels stamped onto this attempt. + + Returns: + AttackResult: The single result produced for this attempt. + + Raises: + RuntimeError: If the executor returned no completed results and no + propagated exception (should be unreachable). + """ + if bundle.seed_technique is not None: + execution_group = self._seed_group.with_technique(technique=bundle.seed_technique) + else: + execution_group = self._seed_group + + executor_result = await self._executor.execute_attack_from_seed_groups_async( + attack=bundle.attack, + seed_groups=[execution_group], + adversarial_chat=bundle.adversarial_chat, + objective_scorer=self._objective_scorer, + memory_labels=attempt_labels, + ) + + if executor_result.completed_results: + return executor_result.completed_results[0] + if executor_result.incomplete_objectives: + raise executor_result.incomplete_objectives[0][1] + raise RuntimeError( # pragma: no cover - defensive + "AttackExecutor returned neither completed nor incomplete results." + ) + async def _perform_async(self, *, context: AdaptiveDispatchContext) -> AttackResult: adaptive_context = context.memory_labels.get(ADAPTIVE_CONTEXT_LABEL, GLOBAL_CONTEXT) technique_names = list(self._techniques.keys()) @@ -112,7 +205,7 @@ async def _perform_async(self, *, context: AdaptiveDispatchContext) -> AttackRes for attempt_idx in range(self._max_attempts): chosen = self._selector.select(context=adaptive_context, techniques=technique_names) - inner = self._techniques[chosen] + bundle = self._techniques[chosen] attempt_labels = { **context.memory_labels, ADAPTIVE_TECHNIQUE_LABEL: chosen, @@ -127,10 +220,7 @@ async def _perform_async(self, *, context: AdaptiveDispatchContext) -> AttackRes chosen, ) - result = await inner.execute_async( - objective=context.objective, - memory_labels=attempt_labels, - ) + result = await self._run_inner_attack_async(bundle=bundle, attempt_labels=attempt_labels) success = result.outcome == AttackOutcome.SUCCESS self._selector.record_outcome(context=adaptive_context, technique=chosen, success=success) diff --git a/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py b/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py index 422e3d4314..4be4ffbb6c 100644 --- a/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py +++ b/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py @@ -7,13 +7,14 @@ import pytest from pyrit.executor.attack.core.attack_parameters import AttackParameters -from pyrit.models import AttackOutcome, AttackResult +from pyrit.models import AttackOutcome, AttackResult, SeedAttackGroup, SeedObjective from pyrit.scenario.scenarios.adaptive.dispatcher import ( ADAPTIVE_ATTEMPT_LABEL, ADAPTIVE_CONTEXT_LABEL, ADAPTIVE_TECHNIQUE_LABEL, AdaptiveDispatchAttack, AdaptiveDispatchContext, + TechniqueBundle, ) from pyrit.scenario.scenarios.adaptive.selector import ( GLOBAL_CONTEXT, @@ -21,25 +22,52 @@ ) -def _make_inner_attack(*, name: str, outcomes: list[AttackOutcome]) -> MagicMock: - """Build a mocked inner attack whose execute_async returns the given outcomes in order.""" - inner = MagicMock(name=name) - results = [ - AttackResult( - conversation_id=f"conv-{name}-{i}", - objective="obj", - outcome=outcome, - ) - for i, outcome in enumerate(outcomes) - ] - inner.execute_async = AsyncMock(side_effect=results) - return inner +def _make_bundle(*, name: str, outcomes: list[AttackOutcome], seed_technique=None) -> TechniqueBundle: + """Build a TechniqueBundle whose attack stub yields the given outcomes in order. + + The dispatcher routes execution through ``_run_inner_attack_async``; tests + patch that method directly so we only need a placeholder attack here. + """ + attack = MagicMock(name=f"attack-{name}") + attack._outcomes = outcomes + attack._name = name + return TechniqueBundle(attack=attack, seed_technique=seed_technique) def _make_context(*, objective: str = "obj", labels: dict[str, str] | None = None) -> AdaptiveDispatchContext: return AdaptiveDispatchContext(params=AttackParameters(objective=objective, memory_labels=labels or {})) +def _patch_inner( + *, + dispatcher: AdaptiveDispatchAttack, + bundles: dict[str, TechniqueBundle], +) -> AsyncMock: + """Replace ``_run_inner_attack_async`` with a stub backed by per-bundle outcomes. + + Returns the AsyncMock so tests can introspect call history (kwargs include + ``bundle`` and ``attempt_labels``). + """ + # Each call consumes one outcome from the chosen bundle's deque. + name_for_attack = {id(b.attack): name for name, b in bundles.items()} + counters: dict[str, int] = dict.fromkeys(bundles, 0) + + async def _stub(*, bundle: TechniqueBundle, attempt_labels: dict[str, str]) -> AttackResult: + name = name_for_attack[id(bundle.attack)] + idx = counters[name] + counters[name] = idx + 1 + outcome = bundle.attack._outcomes[idx] + return AttackResult( + conversation_id=f"conv-{name}-{idx}", + objective="obj", + outcome=outcome, + ) + + inner_mock = AsyncMock(side_effect=_stub) + dispatcher._run_inner_attack_async = inner_mock # type: ignore[method-assign] + return inner_mock + + @pytest.fixture def selector() -> AdaptiveTechniqueSelector: # epsilon=0 makes selection deterministic given the table. @@ -51,107 +79,117 @@ def target() -> MagicMock: return MagicMock(name="objective_target") +@pytest.fixture +def seed_group() -> SeedAttackGroup: + return SeedAttackGroup(seeds=[SeedObjective(value="obj")]) + + class TestInit: @pytest.mark.usefixtures("patch_central_database") - def test_init_rejects_empty_techniques(self, target, selector): + def test_init_rejects_empty_techniques(self, target, selector, seed_group): with pytest.raises(ValueError, match="techniques"): - AdaptiveDispatchAttack(objective_target=target, techniques={}, selector=selector) + AdaptiveDispatchAttack( + objective_target=target, + techniques={}, + selector=selector, + seed_group=seed_group, + ) @pytest.mark.parametrize("bad_max", [0, -1]) @pytest.mark.usefixtures("patch_central_database") - def test_init_rejects_invalid_max_attempts(self, target, selector, bad_max): + def test_init_rejects_invalid_max_attempts(self, target, selector, seed_group, bad_max): with pytest.raises(ValueError, match="max_attempts_per_objective"): AdaptiveDispatchAttack( objective_target=target, - techniques={"a": _make_inner_attack(name="a", outcomes=[AttackOutcome.SUCCESS])}, + techniques={"a": _make_bundle(name="a", outcomes=[AttackOutcome.SUCCESS])}, selector=selector, + seed_group=seed_group, max_attempts_per_objective=bad_max, ) @pytest.mark.usefixtures("patch_central_database") class TestPerform: - async def test_stops_on_first_success(self, target, selector): - a = _make_inner_attack(name="a", outcomes=[AttackOutcome.SUCCESS]) - b = _make_inner_attack(name="b", outcomes=[AttackOutcome.SUCCESS]) + async def test_stops_on_first_success(self, target, selector, seed_group): + bundles = { + "a": _make_bundle(name="a", outcomes=[AttackOutcome.SUCCESS]), + "b": _make_bundle(name="b", outcomes=[AttackOutcome.SUCCESS]), + } dispatcher = AdaptiveDispatchAttack( objective_target=target, - techniques={"a": a, "b": b}, + techniques=bundles, selector=selector, + seed_group=seed_group, max_attempts_per_objective=5, ) + inner = _patch_inner(dispatcher=dispatcher, bundles=bundles) result = await dispatcher._perform_async(context=_make_context()) assert result.outcome == AttackOutcome.SUCCESS - total_calls = a.execute_async.call_count + b.execute_async.call_count - assert total_calls == 1 + assert inner.call_count == 1 - async def test_retries_until_max_attempts_on_failure(self, target, selector): - a = _make_inner_attack(name="a", outcomes=[AttackOutcome.FAILURE] * 3) - b = _make_inner_attack(name="b", outcomes=[AttackOutcome.FAILURE] * 3) + async def test_retries_until_max_attempts_on_failure(self, target, selector, seed_group): + bundles = { + "a": _make_bundle(name="a", outcomes=[AttackOutcome.FAILURE] * 3), + "b": _make_bundle(name="b", outcomes=[AttackOutcome.FAILURE] * 3), + } dispatcher = AdaptiveDispatchAttack( objective_target=target, - techniques={"a": a, "b": b}, + techniques=bundles, selector=selector, + seed_group=seed_group, max_attempts_per_objective=3, ) + inner = _patch_inner(dispatcher=dispatcher, bundles=bundles) result = await dispatcher._perform_async(context=_make_context()) assert result.outcome == AttackOutcome.FAILURE - total_calls = a.execute_async.call_count + b.execute_async.call_count - assert total_calls == 3 + assert inner.call_count == 3 - async def test_updates_selector_on_each_attempt(self, target, selector): - a = _make_inner_attack(name="a", outcomes=[AttackOutcome.FAILURE, AttackOutcome.SUCCESS]) - b = _make_inner_attack(name="b", outcomes=[AttackOutcome.SUCCESS]) + async def test_updates_selector_on_each_attempt(self, target, selector, seed_group): + bundles = { + "a": _make_bundle(name="a", outcomes=[AttackOutcome.FAILURE, AttackOutcome.SUCCESS]), + "b": _make_bundle(name="b", outcomes=[AttackOutcome.SUCCESS]), + } dispatcher = AdaptiveDispatchAttack( objective_target=target, - techniques={"a": a, "b": b}, + techniques=bundles, selector=selector, + seed_group=seed_group, max_attempts_per_objective=3, ) + inner = _patch_inner(dispatcher=dispatcher, bundles=bundles) await dispatcher._perform_async(context=_make_context()) - # Total attempts across arms must equal sum of selector counts. total_attempts = sum(selector.counts(context=GLOBAL_CONTEXT, technique=t)[1] for t in ("a", "b")) - total_calls = a.execute_async.call_count + b.execute_async.call_count - assert total_attempts == total_calls - - async def test_passes_objective_to_inner(self, target, selector): - a = _make_inner_attack(name="a", outcomes=[AttackOutcome.SUCCESS]) - dispatcher = AdaptiveDispatchAttack( - objective_target=target, - techniques={"a": a}, - selector=selector, - ) + assert total_attempts == inner.call_count - await dispatcher._perform_async(context=_make_context(objective="my-goal")) - - kwargs = a.execute_async.call_args.kwargs - assert kwargs["objective"] == "my-goal" - - async def test_attaches_technique_and_attempt_labels(self, target, selector): - a = _make_inner_attack(name="a", outcomes=[AttackOutcome.SUCCESS]) + async def test_passes_attempt_labels_to_inner(self, target, selector, seed_group): + bundles = {"a": _make_bundle(name="a", outcomes=[AttackOutcome.SUCCESS])} dispatcher = AdaptiveDispatchAttack( objective_target=target, - techniques={"a": a}, + techniques=bundles, selector=selector, + seed_group=seed_group, ) + inner = _patch_inner(dispatcher=dispatcher, bundles=bundles) await dispatcher._perform_async(context=_make_context(labels={"foo": "bar"})) - labels = a.execute_async.call_args.kwargs["memory_labels"] + labels = inner.call_args.kwargs["attempt_labels"] assert labels["foo"] == "bar" # caller labels preserved assert labels[ADAPTIVE_TECHNIQUE_LABEL] == "a" assert labels[ADAPTIVE_ATTEMPT_LABEL] == "1" - async def test_uses_adaptive_context_from_label(self, target, selector): + async def test_uses_adaptive_context_from_label(self, target, selector, seed_group): # Two techniques; one has been heavily rewarded under context "violence" only. - a = _make_inner_attack(name="a", outcomes=[AttackOutcome.SUCCESS]) - b = _make_inner_attack(name="b", outcomes=[AttackOutcome.SUCCESS]) + bundles = { + "a": _make_bundle(name="a", outcomes=[AttackOutcome.SUCCESS]), + "b": _make_bundle(name="b", outcomes=[AttackOutcome.SUCCESS]), + } for _ in range(5): selector.record_outcome(context="violence", technique="b", success=True) for _ in range(5): @@ -159,38 +197,42 @@ async def test_uses_adaptive_context_from_label(self, target, selector): dispatcher = AdaptiveDispatchAttack( objective_target=target, - techniques={"a": a, "b": b}, + techniques=bundles, selector=selector, + seed_group=seed_group, ) + inner = _patch_inner(dispatcher=dispatcher, bundles=bundles) ctx = _make_context(labels={ADAPTIVE_CONTEXT_LABEL: "violence"}) await dispatcher._perform_async(context=ctx) # Exploit should have picked "b" first. - assert b.execute_async.call_count == 1 - assert a.execute_async.call_count == 0 + chosen_bundle = inner.call_args.kwargs["bundle"] + assert chosen_bundle is bundles["b"] - async def test_falls_back_to_global_context_when_label_missing(self, target, selector): - a = _make_inner_attack(name="a", outcomes=[AttackOutcome.SUCCESS]) + async def test_falls_back_to_global_context_when_label_missing(self, target, selector, seed_group): + bundles = {"a": _make_bundle(name="a", outcomes=[AttackOutcome.SUCCESS])} dispatcher = AdaptiveDispatchAttack( objective_target=target, - techniques={"a": a}, + techniques=bundles, selector=selector, + seed_group=seed_group, ) + _patch_inner(dispatcher=dispatcher, bundles=bundles) await dispatcher._perform_async(context=_make_context(labels={})) # The global context bucket received the update. assert selector.counts(context=GLOBAL_CONTEXT, technique="a") == (1, 1) - async def test_metadata_records_adaptive_trail(self, target, selector): - # Technique "a" fails on the first attempt then succeeds; verify the trail - # captures both attempts in order. - a = _make_inner_attack(name="a", outcomes=[AttackOutcome.FAILURE, AttackOutcome.SUCCESS]) + async def test_metadata_records_adaptive_trail(self, target, selector, seed_group): + bundles = {"a": _make_bundle(name="a", outcomes=[AttackOutcome.FAILURE, AttackOutcome.SUCCESS])} dispatcher = AdaptiveDispatchAttack( objective_target=target, - techniques={"a": a}, + techniques=bundles, selector=selector, + seed_group=seed_group, max_attempts_per_objective=3, ) + _patch_inner(dispatcher=dispatcher, bundles=bundles) result = await dispatcher._perform_async(context=_make_context()) trail = result.metadata["adaptive_attempts"] @@ -200,29 +242,32 @@ async def test_metadata_records_adaptive_trail(self, target, selector): ] assert result.metadata["adaptive_context"] == GLOBAL_CONTEXT - async def test_returns_fresh_result_distinct_from_inner(self, target, selector): + async def test_returns_fresh_result_distinct_from_inner(self, target, selector, seed_group): # The dispatcher must NOT return the inner attack's ``AttackResult`` # instance — doing so would cause a duplicate-PK insert when both the # inner and the dispatcher's ``execute_async`` post-execute hooks try # to persist the same row. Verify the returned result has a fresh # ``attack_result_id`` while preserving the inner's identifying fields # and stamping the dispatch trail. - a = _make_inner_attack(name="a", outcomes=[AttackOutcome.SUCCESS]) + bundles = {"a": _make_bundle(name="a", outcomes=[AttackOutcome.SUCCESS])} dispatcher = AdaptiveDispatchAttack( objective_target=target, - techniques={"a": a}, + techniques=bundles, selector=selector, + seed_group=seed_group, ) - # Capture the inner result's id by spying on execute_async. - original_execute = a.execute_async inner_ids: list[str] = [] - async def _spy(**kwargs): - inner_result = await original_execute(**kwargs) + async def _spy(*, bundle, attempt_labels): + inner_result = AttackResult( + conversation_id="conv-a-0", + objective="obj", + outcome=AttackOutcome.SUCCESS, + ) inner_ids.append(inner_result.attack_result_id) return inner_result - a.execute_async = _spy # type: ignore[assignment] + dispatcher._run_inner_attack_async = AsyncMock(side_effect=_spy) # type: ignore[method-assign] result = await dispatcher._perform_async(context=_make_context()) @@ -237,20 +282,22 @@ async def _spy(**kwargs): @pytest.mark.usefixtures("patch_central_database") class TestValidate: @pytest.mark.parametrize("bad_objective", ["", " ", "\n\t"]) - def test_validate_rejects_empty_objective(self, target, selector, bad_objective): + def test_validate_rejects_empty_objective(self, target, selector, seed_group, bad_objective): dispatcher = AdaptiveDispatchAttack( objective_target=target, - techniques={"a": _make_inner_attack(name="a", outcomes=[AttackOutcome.SUCCESS])}, + techniques={"a": _make_bundle(name="a", outcomes=[AttackOutcome.SUCCESS])}, selector=selector, + seed_group=seed_group, ) with pytest.raises(ValueError, match="objective"): dispatcher._validate_context(context=_make_context(objective=bad_objective)) - def test_validate_accepts_normal_objective(self, target, selector): + def test_validate_accepts_normal_objective(self, target, selector, seed_group): dispatcher = AdaptiveDispatchAttack( objective_target=target, - techniques={"a": _make_inner_attack(name="a", outcomes=[AttackOutcome.SUCCESS])}, + techniques={"a": _make_bundle(name="a", outcomes=[AttackOutcome.SUCCESS])}, selector=selector, + seed_group=seed_group, ) # Does not raise. dispatcher._validate_context(context=_make_context(objective="ok")) diff --git a/tests/unit/scenario/scenarios/adaptive/test_selector.py b/tests/unit/scenario/scenarios/adaptive/test_selector.py index 370430497e..2daba3b70c 100644 --- a/tests/unit/scenario/scenarios/adaptive/test_selector.py +++ b/tests/unit/scenario/scenarios/adaptive/test_selector.py @@ -168,7 +168,7 @@ def test_global_context_is_constant(self): sg = MagicMock() assert global_context(sg) == GLOBAL_CONTEXT - def test_harm_category_context_uses_sorted_first_category(self): + def test_harm_category_context_joins_sorted_categories(self): sg = MagicMock() sg.harm_categories = ["violence", "hate"] # Multi-category seeds form their own bucket; sorting keeps the key deterministic. diff --git a/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py b/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py index c32cab41d6..12b1a45e28 100644 --- a/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py +++ b/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py @@ -87,6 +87,21 @@ def _make_seed_group(*, value: str, harm_categories: list[str] | None = None) -> return SeedAttackGroup(seeds=[SeedObjective(value=value, harm_categories=harm_categories)]) +def _make_fake_factory(*, seed_technique=None, adversarial_chat=None) -> MagicMock: + """Return a stub attack-technique factory that produces a fake ``AttackTechnique``. + + Mocks the surface ``AdaptiveScenario._build_techniques_dict`` consumes + (``factory.create(...)`` and ``factory.adversarial_chat``). + """ + fake_technique = MagicMock() + fake_technique.attack = MagicMock(name="fake-attack-strategy") + fake_technique.seed_technique = seed_technique + factory = MagicMock() + factory.create.return_value = fake_technique + factory.adversarial_chat = adversarial_chat + return factory + + FIXTURES = ["patch_central_database", "mock_runtime_env"] @@ -174,21 +189,26 @@ async def test_one_atomic_per_objective(self, mock_objective_target, mock_object # Each atomic carries exactly one seed group. assert len(atomic.objectives) == 1 - async def test_all_atomics_share_one_dispatcher(self, mock_objective_target, mock_objective_scorer): + async def test_atomics_share_one_selector_across_dispatchers(self, mock_objective_target, mock_objective_scorer): groups = { "violence": [ _make_seed_group(value="obj-v1", harm_categories=["violence"]), _make_seed_group(value="obj-v2", harm_categories=["violence"]), ], } - scenario, attacks = await self._build_scenario_and_attacks( + _scenario, attacks = await self._build_scenario_and_attacks( mock_objective_target=mock_objective_target, mock_objective_scorer=mock_objective_scorer, seed_groups=groups, ) - dispatchers = {atomic._attack_technique.attack for atomic in attacks} - assert len(dispatchers) == 1 - assert isinstance(next(iter(dispatchers)), AdaptiveDispatchAttack) + dispatchers = [atomic._attack_technique.attack for atomic in attacks] + # Each objective gets its own dispatcher (bound to its own seed group)... + assert len({id(d) for d in dispatchers}) == len(attacks) + for d in dispatchers: + assert isinstance(d, AdaptiveDispatchAttack) + # ...but they all share the same selector so learning is global. + selectors = {id(d._selector) for d in dispatchers} + assert len(selectors) == 1 async def test_global_context_label_when_using_global_extractor(self, mock_objective_target, mock_objective_scorer): groups = { @@ -257,6 +277,112 @@ async def test_no_usable_techniques_raises(self, mock_objective_target, mock_obj with pytest.raises(ValueError, match="no usable techniques"): await scenario._get_atomic_attacks_async() + async def test_techniques_with_seed_technique_are_kept(self, mock_objective_target, mock_objective_scorer): + """Factories that declare a ``seed_technique`` participate in the pool + (the old behavior silently dropped them with a warning). + """ + groups = {"violence": [_make_seed_group(value="obj")]} + plain_factory = _make_fake_factory(seed_technique=None) + seeded_factory = _make_fake_factory(seed_technique=MagicMock(name="seed_technique")) + + with ( + patch.object(DatasetConfiguration, "get_seed_attack_groups", return_value=groups), + patch.object(SeedAttackGroup, "is_compatible_with_technique", return_value=True), + ): + scenario = TextAdaptive(objective_scorer=mock_objective_scorer) + with patch.object( + scenario, + "_get_attack_technique_factories", + return_value={"prompt_sending": plain_factory, "many_shot": seeded_factory}, + ): + await scenario.initialize_async( + objective_target=mock_objective_target, + include_baseline=False, + ) + attacks = scenario._atomic_attacks + + assert len(attacks) == 1 + dispatcher = attacks[0]._attack_technique.attack + assert isinstance(dispatcher, AdaptiveDispatchAttack) + # Both factories survive; in particular the seeded one is no longer + # silently dropped. + assert "prompt_sending" in dispatcher._techniques + assert "many_shot" in dispatcher._techniques + + async def test_incompatible_seed_technique_is_filtered_per_objective( + self, mock_objective_target, mock_objective_scorer + ): + """Per-objective candidate pool drops techniques whose seed_technique + is incompatible with the seed group; compatible techniques remain. + """ + groups = {"violence": [_make_seed_group(value="obj")]} + plain_factory = _make_fake_factory(seed_technique=None) + incompatible_factory = _make_fake_factory(seed_technique=MagicMock(name="incompatible_seed_technique")) + + with ( + patch.object(DatasetConfiguration, "get_seed_attack_groups", return_value=groups), + patch.object(SeedAttackGroup, "is_compatible_with_technique", return_value=False), + ): + scenario = TextAdaptive(objective_scorer=mock_objective_scorer) + with patch.object( + scenario, + "_get_attack_technique_factories", + return_value={"prompt_sending": plain_factory, "many_shot": incompatible_factory}, + ): + await scenario.initialize_async( + objective_target=mock_objective_target, + include_baseline=False, + ) + attacks = scenario._atomic_attacks + + assert len(attacks) == 1 + dispatcher = attacks[0]._attack_technique.attack + # Only the plain technique survives; the seed_technique-bearing one is filtered out + # because is_compatible_with_technique returned False. + assert "prompt_sending" in dispatcher._techniques + assert "many_shot" not in dispatcher._techniques + + async def test_objective_skipped_when_no_compatible_techniques( + self, mock_objective_target, mock_objective_scorer, caplog + ): + """When every technique requires an incompatible seed_technique, the + objective is dropped with a warning rather than producing an atomic + attack with an empty technique pool. + """ + groups = { + "violence": [_make_seed_group(value="obj-keep")], + "hate": [_make_seed_group(value="obj-skip")], + } + seeded_factory = _make_fake_factory(seed_technique=MagicMock(name="seed_technique")) + + # is_compatible_with_technique returns True for "obj-keep", False for "obj-skip". + def _selective_compat(self_group, *, technique): + return self_group.objective.value == "obj-keep" + + with ( + patch.object(DatasetConfiguration, "get_seed_attack_groups", return_value=groups), + patch.object(SeedAttackGroup, "is_compatible_with_technique", _selective_compat), + ): + scenario = TextAdaptive(objective_scorer=mock_objective_scorer) + with patch.object( + scenario, + "_get_attack_technique_factories", + return_value={"prompt_sending": seeded_factory}, + ): + import logging + + with caplog.at_level(logging.WARNING): + await scenario.initialize_async( + objective_target=mock_objective_target, + include_baseline=False, + ) + attacks = scenario._atomic_attacks + + # Only the compatible objective produced an atomic attack. + assert len(attacks) == 1 + # Skip was logged with the affected objective value. + assert any("obj-skip" in record.getMessage() for record in caplog.records) + @pytest.mark.usefixtures(*FIXTURES) class TestTextAdaptiveSelectorRehydration: @@ -306,11 +432,10 @@ def test_replays_attempts_from_metadata(self, mock_objective_scorer): ), ], } - scenario._memory = MagicMock() - scenario._memory.get_scenario_results.return_value = [prior_result] selector = AdaptiveTechniqueSelector() - scenario._rehydrate_selector_from_memory(selector=selector, known_techniques={"a", "b"}) + with patch.object(scenario._memory, "get_scenario_results", return_value=[prior_result]): + scenario._rehydrate_selector_from_memory(selector=selector, known_techniques={"a", "b"}) # Trails replayed verbatim into the per-context table. assert selector.counts(context="violence", technique="a") == (0, 1) @@ -338,11 +463,10 @@ def test_skips_unknown_techniques(self, mock_objective_scorer): ), ], } - scenario._memory = MagicMock() - scenario._memory.get_scenario_results.return_value = [prior_result] selector = AdaptiveTechniqueSelector() - scenario._rehydrate_selector_from_memory(selector=selector, known_techniques={"a"}) + with patch.object(scenario._memory, "get_scenario_results", return_value=[prior_result]): + scenario._rehydrate_selector_from_memory(selector=selector, known_techniques={"a"}) # Only the known technique was recorded. assert selector.counts(context="ctx", technique="a") == (0, 1) @@ -357,23 +481,21 @@ def test_ignores_results_without_adaptive_metadata(self, mock_objective_scorer): prior_result.attack_results = { "baseline": [AttackResult(conversation_id="c", objective="o", metadata={})], } - scenario._memory = MagicMock() - scenario._memory.get_scenario_results.return_value = [prior_result] selector = AdaptiveTechniqueSelector() - scenario._rehydrate_selector_from_memory(selector=selector, known_techniques={"a"}) + with patch.object(scenario._memory, "get_scenario_results", return_value=[prior_result]): + scenario._rehydrate_selector_from_memory(selector=selector, known_techniques={"a"}) assert selector.snapshot() == {} def test_memory_load_failure_is_swallowed(self, mock_objective_scorer): from pyrit.scenario.scenarios.adaptive.selector import AdaptiveTechniqueSelector scenario = TextAdaptive(objective_scorer=mock_objective_scorer, scenario_result_id="rid") - scenario._memory = MagicMock() - scenario._memory.get_scenario_results.side_effect = RuntimeError("db down") selector = AdaptiveTechniqueSelector() - # Must not raise; selector remains empty. - scenario._rehydrate_selector_from_memory(selector=selector, known_techniques={"a"}) + with patch.object(scenario._memory, "get_scenario_results", side_effect=RuntimeError("db down")): + # Must not raise; selector remains empty. + scenario._rehydrate_selector_from_memory(selector=selector, known_techniques={"a"}) assert selector.snapshot() == {} From 61a1b7d4b8bce980f2c21562809eed6cc9447d07 Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Tue, 19 May 2026 16:02:14 -0400 Subject: [PATCH 08/35] clean up and fix docstrings --- doc/code/scenarios/3_adaptive_scenarios.ipynb | 59 ++++++++++++------- doc/code/scenarios/3_adaptive_scenarios.py | 34 +++++++---- .../scenarios/adaptive/adaptive_scenario.py | 17 ++++++ .../scenario/scenarios/adaptive/dispatcher.py | 26 +++++++- pyrit/scenario/scenarios/adaptive/selector.py | 12 +++- .../scenarios/adaptive/text_adaptive.py | 4 ++ 6 files changed, 113 insertions(+), 39 deletions(-) diff --git a/doc/code/scenarios/3_adaptive_scenarios.ipynb b/doc/code/scenarios/3_adaptive_scenarios.ipynb index 2067a8896d..bd1ddd6644 100644 --- a/doc/code/scenarios/3_adaptive_scenarios.ipynb +++ b/doc/code/scenarios/3_adaptive_scenarios.ipynb @@ -16,8 +16,8 @@ "\n", "For each objective, the scenario tries up to `max_attempts_per_objective` techniques:\n", "\n", - "- With probability `epsilon`, it **explores** — picks a random technique.\n", - "- Otherwise it **exploits** — picks the technique with the highest observed success\n", + "- With probability `epsilon`, it **explores** \u2014 picks a random technique.\n", + "- Otherwise it **exploits** \u2014 picks the technique with the highest observed success\n", " rate so far.\n", "- It records the outcome and stops early on success.\n", "\n", @@ -29,8 +29,8 @@ "| Feature | Static scenarios | Adaptive scenarios |\n", "|---------------------|-----------------------------------|------------------------------------|\n", "| Technique selection | Run every selected technique | Pick per-objective from outcomes |\n", - "| Early stopping | No | Yes — stops on first success |\n", - "| Cost | O(techniques × objectives) | O(max_attempts × objectives) |\n", + "| Early stopping | No | Yes \u2014 stops on first success |\n", + "| Cost | O(techniques \u00d7 objectives) | O(max_attempts \u00d7 objectives) |\n", "\n", "`AdaptiveScenario` is the modality-agnostic base class.\n", "[`TextAdaptive`](../../../pyrit/scenario/scenarios/adaptive/text_adaptive.py) is the\n", @@ -89,7 +89,7 @@ " objective_target=objective_target,\n", ")\n", "result = await scenario.run_async() # type: ignore\n", - "await printer.print_summary_async(result) # type: ignore" + "await printer.write_async(result) # type: ignore" ] }, { @@ -99,9 +99,9 @@ "source": [ "## Tuning exploration (`epsilon`)\n", "\n", - "- `epsilon=0.0` — pure exploitation (always pick the best-known technique).\n", - "- `epsilon=1.0` — pure exploration (random every time).\n", - "- `epsilon=0.2` (default) — 20% exploration." + "- `epsilon=0.0` \u2014 pure exploitation (always pick the best-known technique).\n", + "- `epsilon=1.0` \u2014 pure exploration (random every time).\n", + "- `epsilon=0.2` (default) \u2014 20% exploration." ] }, { @@ -118,7 +118,7 @@ " dataset_config=DatasetConfiguration(dataset_names=[\"airt_hate\"], max_dataset_size=4),\n", ")\n", "explorative_result = await explorative_scenario.run_async() # type: ignore\n", - "await printer.print_summary_async(explorative_result) # type: ignore" + "await printer.write_async(explorative_result) # type: ignore" ] }, { @@ -146,7 +146,7 @@ " dataset_config=DatasetConfiguration(dataset_names=[\"airt_violence\"], max_dataset_size=4),\n", ")\n", "persistent_result = await persistent_scenario.run_async() # type: ignore\n", - "await printer.print_summary_async(persistent_result) # type: ignore" + "await printer.write_async(persistent_result) # type: ignore" ] }, { @@ -156,7 +156,7 @@ "source": [ "## Learning per harm category\n", "\n", - "By default, the scenario keeps one global success-rate table — what works on hate\n", + "By default, the scenario keeps one global success-rate table \u2014 what works on hate\n", "objectives boosts the same technique on violence objectives. Pass `harm_category_context`\n", "to learn each category independently:" ] @@ -178,7 +178,7 @@ " ),\n", ")\n", "contextual_result = await contextual_scenario.run_async() # type: ignore\n", - "await printer.print_summary_async(contextual_result) # type: ignore" + "await printer.write_async(contextual_result) # type: ignore" ] }, { @@ -208,7 +208,7 @@ " dataset_config=DatasetConfiguration(dataset_names=[\"airt_hate\"], max_dataset_size=4),\n", ")\n", "single_turn_result = await single_turn_scenario.run_async() # type: ignore\n", - "await printer.print_summary_async(single_turn_result) # type: ignore" + "await printer.write_async(single_turn_result) # type: ignore" ] }, { @@ -235,7 +235,7 @@ " dataset_config=DatasetConfiguration(dataset_names=[\"airt_hate\"], max_dataset_size=2),\n", ")\n", "deterministic_result = await deterministic_scenario.run_async() # type: ignore\n", - "await printer.print_summary_async(deterministic_result) # type: ignore" + "await printer.write_async(deterministic_result) # type: ignore" ] }, { @@ -245,15 +245,30 @@ "source": [ "## Resuming a run\n", "\n", - "Adaptive scenarios are resumable — pass `scenario_result_id=...` to the `TextAdaptive`\n", + "Adaptive scenarios are resumable \u2014 pass `scenario_result_id=...` to the `TextAdaptive`\n", "constructor and the run picks up where it left off, with prior outcomes replayed into\n", - "the selector.\n", + "the selector. Here we resume the deterministic run from the previous cell." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "14", + "metadata": {}, + "outputs": [], + "source": [ + "resumed_scenario = TextAdaptive(\n", + " seed=42,\n", + " epsilon=0.3,\n", + " scenario_result_id=str(deterministic_result.id),\n", + ")\n", "\n", - "```python\n", - "resumed_scenario = TextAdaptive(scenario_result_id=\"\")\n", - "await resumed_scenario.initialize_async(objective_target=objective_target)\n", - "resumed_result = await resumed_scenario.run_async()\n", - "```" + "await resumed_scenario.initialize_async( # type: ignore\n", + " objective_target=objective_target,\n", + " dataset_config=DatasetConfiguration(dataset_names=[\"airt_hate\"], max_dataset_size=2),\n", + ")\n", + "resumed_result = await resumed_scenario.run_async() # type: ignore\n", + "await printer.write_async(resumed_result) # type: ignore" ] } ], @@ -264,4 +279,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file diff --git a/doc/code/scenarios/3_adaptive_scenarios.py b/doc/code/scenarios/3_adaptive_scenarios.py index 0385619039..1e09d235b5 100644 --- a/doc/code/scenarios/3_adaptive_scenarios.py +++ b/doc/code/scenarios/3_adaptive_scenarios.py @@ -69,7 +69,7 @@ objective_target=objective_target, ) result = await scenario.run_async() # type: ignore -await printer.print_summary_async(result) # type: ignore +await printer.write_async(result) # type: ignore # %% [markdown] # ## Tuning exploration (`epsilon`) @@ -86,7 +86,7 @@ dataset_config=DatasetConfiguration(dataset_names=["airt_hate"], max_dataset_size=4), ) explorative_result = await explorative_scenario.run_async() # type: ignore -await printer.print_summary_async(explorative_result) # type: ignore +await printer.write_async(explorative_result) # type: ignore # %% [markdown] # ## Attempts per objective @@ -102,7 +102,7 @@ dataset_config=DatasetConfiguration(dataset_names=["airt_violence"], max_dataset_size=4), ) persistent_result = await persistent_scenario.run_async() # type: ignore -await printer.print_summary_async(persistent_result) # type: ignore +await printer.write_async(persistent_result) # type: ignore # %% [markdown] # ## Learning per harm category @@ -122,7 +122,7 @@ ), ) contextual_result = await contextual_scenario.run_async() # type: ignore -await printer.print_summary_async(contextual_result) # type: ignore +await printer.write_async(contextual_result) # type: ignore # %% [markdown] # ## Restricting which techniques participate @@ -140,7 +140,7 @@ dataset_config=DatasetConfiguration(dataset_names=["airt_hate"], max_dataset_size=4), ) single_turn_result = await single_turn_scenario.run_async() # type: ignore -await printer.print_summary_async(single_turn_result) # type: ignore +await printer.write_async(single_turn_result) # type: ignore # %% [markdown] # ## Reproducible runs @@ -155,17 +155,25 @@ dataset_config=DatasetConfiguration(dataset_names=["airt_hate"], max_dataset_size=2), ) deterministic_result = await deterministic_scenario.run_async() # type: ignore -await printer.print_summary_async(deterministic_result) # type: ignore +await printer.write_async(deterministic_result) # type: ignore # %% [markdown] # ## Resuming a run # # Adaptive scenarios are resumable — pass `scenario_result_id=...` to the `TextAdaptive` # constructor and the run picks up where it left off, with prior outcomes replayed into -# the selector. -# -# ```python -# resumed_scenario = TextAdaptive(scenario_result_id="") -# await resumed_scenario.initialize_async(objective_target=objective_target) -# resumed_result = await resumed_scenario.run_async() -# ``` +# the selector. Here we resume the deterministic run from the previous cell. + +# %% +resumed_scenario = TextAdaptive( + seed=42, + epsilon=0.3, + scenario_result_id=str(deterministic_result.id), +) + +await resumed_scenario.initialize_async( # type: ignore + objective_target=objective_target, + dataset_config=DatasetConfiguration(dataset_names=["airt_hate"], max_dataset_size=2), +) +resumed_result = await resumed_scenario.run_async() # type: ignore +await printer.write_async(resumed_result) # type: ignore diff --git a/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py b/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py index 6edf753e25..776c46ea58 100644 --- a/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py +++ b/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py @@ -113,6 +113,15 @@ async def _get_atomic_attacks_async(self) -> list[AtomicAttack]: so learning accumulates across objectives. Per-objective, techniques whose ``seed_technique`` is incompatible with the seed group are filtered out; objectives left with no compatible techniques are skipped. + + Returns: + list[AtomicAttack]: One ``AtomicAttack`` per objective with at + least one compatible technique. Empty if every seed group + is incompatible with every selected technique. + + Raises: + ValueError: If ``self._objective_target`` is not set, or if + ``_build_techniques_dict`` finds no usable techniques. """ if self._objective_target is None: raise ValueError("objective_target must be set before creating attacks") @@ -154,6 +163,10 @@ def _build_techniques_dict( ``seed_technique`` and ``adversarial_chat`` so the dispatcher can reproduce the static ``AtomicAttack`` execution path per attempt. + Returns: + dict[str, TechniqueBundle]: Mapping from technique name to its + bundle, in the order selected strategies were resolved. + Raises: ValueError: If no techniques remain after filtering. Includes the requested techniques and skip reasons. @@ -204,6 +217,10 @@ def _build_atomic_for_seed_group( any) is compatible with this seed group, then constructs a dedicated ``AdaptiveDispatchAttack`` bound to this seed group. Returns ``None`` when no techniques are compatible (caller skips the objective). + + Raises: + ValueError: If ``self._objective_target`` is not set (defensive + guard; ``_get_atomic_attacks_async`` enforces this earlier). """ if self._objective_target is None: # pragma: no cover - defensive raise ValueError("objective_target must be set before creating attacks") diff --git a/pyrit/scenario/scenarios/adaptive/dispatcher.py b/pyrit/scenario/scenarios/adaptive/dispatcher.py index 8499102cac..2ff2451712 100644 --- a/pyrit/scenario/scenarios/adaptive/dispatcher.py +++ b/pyrit/scenario/scenarios/adaptive/dispatcher.py @@ -142,14 +142,15 @@ def __init__( self._executor = AttackExecutor(max_concurrency=1) def _validate_context(self, *, context: AdaptiveDispatchContext) -> None: + """Ensure the context carries a non-empty objective string.""" if not context.objective or context.objective.isspace(): raise ValueError("Attack objective must be provided and non-empty") async def _setup_async(self, *, context: AdaptiveDispatchContext) -> None: - pass + """No-op: per-attempt setup is owned by the inner technique's executor.""" async def _teardown_async(self, *, context: AdaptiveDispatchContext) -> None: - pass + """No-op: per-attempt teardown is owned by the inner technique's executor.""" async def _run_inner_attack_async( self, @@ -197,6 +198,27 @@ async def _run_inner_attack_async( ) async def _perform_async(self, *, context: AdaptiveDispatchContext) -> AttackResult: + """ + Run the per-objective adaptive loop. + + Resolves the per-objective context key from ``context.memory_labels`` + (falling back to :data:`GLOBAL_CONTEXT`), then loops up to + ``max_attempts_per_objective`` times: select a technique, execute it, + record the outcome, and stop early on success. + + Args: + context (AdaptiveDispatchContext): Execution context. ``memory_labels`` + may carry :data:`ADAPTIVE_CONTEXT_LABEL` to scope the selector. + + Returns: + AttackResult: A fresh dispatcher-owned copy of the final inner + result with the dispatch trail stamped onto ``metadata`` + (see class docstring for the two-row persistence note). + + Raises: + RuntimeError: If the loop somehow ran zero attempts (unreachable + because ``max_attempts_per_objective`` is validated >= 1). + """ adaptive_context = context.memory_labels.get(ADAPTIVE_CONTEXT_LABEL, GLOBAL_CONTEXT) technique_names = list(self._techniques.keys()) diff --git a/pyrit/scenario/scenarios/adaptive/selector.py b/pyrit/scenario/scenarios/adaptive/selector.py index 967aee9d1f..cd78533019 100644 --- a/pyrit/scenario/scenarios/adaptive/selector.py +++ b/pyrit/scenario/scenarios/adaptive/selector.py @@ -24,7 +24,12 @@ def global_context(_seed_attack_group: SeedAttackGroup) -> str: - """Return a single shared context for all objectives.""" + """ + Return a single shared context for all objectives. + + Returns: + str: Always :data:`GLOBAL_CONTEXT`. + """ return GLOBAL_CONTEXT @@ -33,7 +38,10 @@ def harm_category_context(seed_attack_group: SeedAttackGroup) -> str: Return a context keyed by the sorted, ``|``-joined harm categories. Multi-category seeds form their own bucket; sorting makes the key deterministic. - Returns ``UNCATEGORIZED_CONTEXT`` when no categories are set. + + Returns: + str: The ``|``-joined sorted harm categories, or :data:`UNCATEGORIZED_CONTEXT` + when the seed group has no categories. """ categories = seed_attack_group.harm_categories if not categories: diff --git a/pyrit/scenario/scenarios/adaptive/text_adaptive.py b/pyrit/scenario/scenarios/adaptive/text_adaptive.py index bc08edd9cc..1d8706fa5e 100644 --- a/pyrit/scenario/scenarios/adaptive/text_adaptive.py +++ b/pyrit/scenario/scenarios/adaptive/text_adaptive.py @@ -63,17 +63,20 @@ class TextAdaptive(AdaptiveScenario): @classmethod def get_strategy_class(cls) -> type[ScenarioStrategy]: + """Return the strategy enum for this scenario, building it once on first access.""" if cls._cached_strategy_class is None: cls._cached_strategy_class = _build_text_adaptive_strategy() return cls._cached_strategy_class @classmethod def get_default_strategy(cls) -> ScenarioStrategy: + """Return the default strategy aggregate (resolves to every ``default``-tagged technique).""" strategy_class = cls.get_strategy_class() return strategy_class("default") @classmethod def required_datasets(cls) -> list[str]: + """Return the dataset names this scenario expects when no override is provided.""" return [ "airt_hate", "airt_fairness", @@ -86,6 +89,7 @@ def required_datasets(cls) -> list[str]: @classmethod def default_dataset_config(cls) -> DatasetConfiguration: + """Return the default :class:`DatasetConfiguration` (required datasets, capped at 4 per dataset).""" return DatasetConfiguration(dataset_names=cls.required_datasets(), max_dataset_size=4) @apply_defaults From 32d8b5e52d60fac83ead553088ba67c138e68148 Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Tue, 19 May 2026 16:57:00 -0400 Subject: [PATCH 09/35] simplify notebook and pre-commit --- doc/code/scenarios/3_adaptive_scenarios.ipynb | 202 +++++++----------- doc/code/scenarios/3_adaptive_scenarios.py | 146 ++++++------- .../scenarios/adaptive/adaptive_scenario.py | 18 +- .../scenario/scenarios/adaptive/dispatcher.py | 7 +- pyrit/scenario/scenarios/adaptive/selector.py | 11 +- .../scenarios/adaptive/text_adaptive.py | 15 +- 6 files changed, 185 insertions(+), 214 deletions(-) diff --git a/doc/code/scenarios/3_adaptive_scenarios.ipynb b/doc/code/scenarios/3_adaptive_scenarios.ipynb index bd1ddd6644..7be2b738ec 100644 --- a/doc/code/scenarios/3_adaptive_scenarios.ipynb +++ b/doc/code/scenarios/3_adaptive_scenarios.ipynb @@ -16,8 +16,8 @@ "\n", "For each objective, the scenario tries up to `max_attempts_per_objective` techniques:\n", "\n", - "- With probability `epsilon`, it **explores** \u2014 picks a random technique.\n", - "- Otherwise it **exploits** \u2014 picks the technique with the highest observed success\n", + "- With probability `epsilon`, it **explores** — picks a random technique.\n", + "- Otherwise it **exploits** — picks the technique with the highest observed success\n", " rate so far.\n", "- It records the outcome and stops early on success.\n", "\n", @@ -29,8 +29,8 @@ "| Feature | Static scenarios | Adaptive scenarios |\n", "|---------------------|-----------------------------------|------------------------------------|\n", "| Technique selection | Run every selected technique | Pick per-objective from outcomes |\n", - "| Early stopping | No | Yes \u2014 stops on first success |\n", - "| Cost | O(techniques \u00d7 objectives) | O(max_attempts \u00d7 objectives) |\n", + "| Early stopping | No | Yes — stops on first success |\n", + "| Cost | O(techniques × objectives) | O(max_attempts × objectives) |\n", "\n", "`AdaptiveScenario` is the modality-agnostic base class.\n", "[`TextAdaptive`](../../../pyrit/scenario/scenarios/adaptive/text_adaptive.py) is the\n", @@ -97,178 +97,138 @@ "id": "5", "metadata": {}, "source": [ - "## Tuning exploration (`epsilon`)\n", + "## Configuring a run\n", "\n", - "- `epsilon=0.0` \u2014 pure exploitation (always pick the best-known technique).\n", - "- `epsilon=1.0` \u2014 pure exploration (random every time).\n", - "- `epsilon=0.2` (default) \u2014 20% exploration." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6", - "metadata": {}, - "outputs": [], - "source": [ - "explorative_scenario = TextAdaptive(epsilon=0.5)\n", + "All the knobs below are constructor or `initialize_async` arguments — combine whichever\n", + "you need on a single scenario instance:\n", "\n", - "await explorative_scenario.initialize_async( # type: ignore\n", - " objective_target=objective_target,\n", - " dataset_config=DatasetConfiguration(dataset_names=[\"airt_hate\"], max_dataset_size=4),\n", - ")\n", - "explorative_result = await explorative_scenario.run_async() # type: ignore\n", - "await printer.write_async(explorative_result) # type: ignore" - ] - }, - { - "cell_type": "markdown", - "id": "7", - "metadata": {}, - "source": [ - "## Attempts per objective\n", + "- **`epsilon`** — exploration probability. `0.0` is pure exploit, `1.0` is pure random,\n", + " `0.2` (default) is 20% exploration.\n", + "- **`max_attempts_per_objective`** — caps techniques tried per objective. Higher means\n", + " more chances to succeed and more API calls.\n", + "- **`context_extractor`** — partitions the success-rate table. The default\n", + " `global_context` keeps one shared table; `harm_category_context` learns each harm\n", + " category independently. Custom callables of type `Callable[[SeedAttackGroup], str]`\n", + " are supported.\n", + "- **`seed`** — makes every selection decision deterministic.\n", + "- **`scenario_strategies`** (on `initialize_async`) — restricts which techniques the\n", + " selector can pick from. Use `TextAdaptive.get_strategy_class()` to access the enum.\n", "\n", - "`max_attempts_per_objective` caps how many techniques are tried per objective before\n", - "moving on. Higher = more chances to succeed, more API calls." + "The cell below exercises all of them at once." ] }, { "cell_type": "code", "execution_count": null, - "id": "8", + "id": "6", "metadata": {}, "outputs": [], "source": [ - "persistent_scenario = TextAdaptive(max_attempts_per_objective=5)\n", + "strategy_class = TextAdaptive.get_strategy_class()\n", "\n", - "await persistent_scenario.initialize_async( # type: ignore\n", - " objective_target=objective_target,\n", - " dataset_config=DatasetConfiguration(dataset_names=[\"airt_violence\"], max_dataset_size=4),\n", + "configured_scenario = TextAdaptive(\n", + " epsilon=0.3,\n", + " max_attempts_per_objective=5,\n", + " context_extractor=harm_category_context,\n", + " seed=42,\n", ")\n", - "persistent_result = await persistent_scenario.run_async() # type: ignore\n", - "await printer.write_async(persistent_result) # type: ignore" - ] - }, - { - "cell_type": "markdown", - "id": "9", - "metadata": {}, - "source": [ - "## Learning per harm category\n", - "\n", - "By default, the scenario keeps one global success-rate table \u2014 what works on hate\n", - "objectives boosts the same technique on violence objectives. Pass `harm_category_context`\n", - "to learn each category independently:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "10", - "metadata": {}, - "outputs": [], - "source": [ - "contextual_scenario = TextAdaptive(context_extractor=harm_category_context)\n", "\n", - "await contextual_scenario.initialize_async( # type: ignore\n", + "await configured_scenario.initialize_async( # type: ignore\n", " objective_target=objective_target,\n", + " scenario_strategies=[strategy_class(\"single_turn\")],\n", " dataset_config=DatasetConfiguration(\n", " dataset_names=[\"airt_hate\", \"airt_violence\"],\n", " max_dataset_size=4,\n", " ),\n", ")\n", - "contextual_result = await contextual_scenario.run_async() # type: ignore\n", - "await printer.write_async(contextual_result) # type: ignore" + "configured_result = await configured_scenario.run_async() # type: ignore\n", + "await printer.write_async(configured_result) # type: ignore" ] }, { "cell_type": "markdown", - "id": "11", + "id": "7", "metadata": {}, "source": [ - "## Restricting which techniques participate\n", + "## Resuming a run\n", "\n", - "Use `scenario_strategies` to limit which techniques the scenario can pick from." + "Adaptive scenarios are resumable — pass `scenario_result_id=...` to the `TextAdaptive`\n", + "constructor and the run picks up where it left off, with prior outcomes replayed into\n", + "the selector. Resume must use the same configuration as the original run." ] }, { "cell_type": "code", "execution_count": null, - "id": "12", + "id": "8", "metadata": {}, "outputs": [], "source": [ - "strategy_class = TextAdaptive.get_strategy_class()\n", - "\n", - "single_turn_scenario = TextAdaptive()\n", + "resumed_scenario = TextAdaptive(\n", + " epsilon=0.3,\n", + " max_attempts_per_objective=5,\n", + " context_extractor=harm_category_context,\n", + " seed=42,\n", + " scenario_result_id=str(configured_result.id),\n", + ")\n", "\n", - "await single_turn_scenario.initialize_async( # type: ignore\n", + "await resumed_scenario.initialize_async( # type: ignore\n", " objective_target=objective_target,\n", " scenario_strategies=[strategy_class(\"single_turn\")],\n", - " dataset_config=DatasetConfiguration(dataset_names=[\"airt_hate\"], max_dataset_size=4),\n", + " dataset_config=DatasetConfiguration(\n", + " dataset_names=[\"airt_hate\", \"airt_violence\"],\n", + " max_dataset_size=4,\n", + " ),\n", ")\n", - "single_turn_result = await single_turn_scenario.run_async() # type: ignore\n", - "await printer.write_async(single_turn_result) # type: ignore" + "resumed_result = await resumed_scenario.run_async() # type: ignore\n", + "await printer.write_async(resumed_result) # type: ignore" ] }, { "cell_type": "markdown", - "id": "13", + "id": "9", "metadata": {}, "source": [ - "## Reproducible runs\n", + "## Inspecting which techniques were tried\n", "\n", - "Pass `seed` to make every selection decision deterministic." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "14", - "metadata": {}, - "outputs": [], - "source": [ - "deterministic_scenario = TextAdaptive(seed=42, epsilon=0.3)\n", + "The dispatcher stamps every objective's `AttackResult.metadata` with:\n", "\n", - "await deterministic_scenario.initialize_async( # type: ignore\n", - " objective_target=objective_target,\n", - " dataset_config=DatasetConfiguration(dataset_names=[\"airt_hate\"], max_dataset_size=2),\n", - ")\n", - "deterministic_result = await deterministic_scenario.run_async() # type: ignore\n", - "await printer.write_async(deterministic_result) # type: ignore" - ] - }, - { - "cell_type": "markdown", - "id": "15", - "metadata": {}, - "source": [ - "## Resuming a run\n", + "- `adaptive_context` — the bucket key from the `context_extractor`.\n", + "- `adaptive_attempts` — the ordered list of `{\"technique\", \"outcome\"}` dicts\n", + " recording exactly which techniques the selector picked and what happened.\n", "\n", - "Adaptive scenarios are resumable \u2014 pass `scenario_result_id=...` to the `TextAdaptive`\n", - "constructor and the run picks up where it left off, with prior outcomes replayed into\n", - "the selector. Here we resume the deterministic run from the previous cell." + "Walk that metadata to see the per-objective trail and aggregate counts." ] }, { "cell_type": "code", "execution_count": null, - "id": "14", + "id": "10", "metadata": {}, "outputs": [], "source": [ - "resumed_scenario = TextAdaptive(\n", - " seed=42,\n", - " epsilon=0.3,\n", - " scenario_result_id=str(deterministic_result.id),\n", - ")\n", - "\n", - "await resumed_scenario.initialize_async( # type: ignore\n", - " objective_target=objective_target,\n", - " dataset_config=DatasetConfiguration(dataset_names=[\"airt_hate\"], max_dataset_size=2),\n", - ")\n", - "resumed_result = await resumed_scenario.run_async() # type: ignore\n", - "await printer.write_async(resumed_result) # type: ignore" + "from collections import Counter\n", + "\n", + "# Per-objective trail\n", + "for results in resumed_result.attack_results.values():\n", + " for r in results:\n", + " attempts = r.metadata.get(\"adaptive_attempts\", [])\n", + " trail = \" → \".join(f\"{a['technique']}({a['outcome']})\" for a in attempts)\n", + " print(f\"[{r.outcome.value:7s}] {r.objective!r}: {trail}\")\n", + "\n", + "# Aggregate per-technique pick counts and success rate across the run\n", + "picks: Counter[str] = Counter()\n", + "wins: Counter[str] = Counter()\n", + "for results in resumed_result.attack_results.values():\n", + " for r in results:\n", + " for step in r.metadata.get(\"adaptive_attempts\", []):\n", + " picks[step[\"technique\"]] += 1\n", + " if step[\"outcome\"] == \"success\":\n", + " wins[step[\"technique\"]] += 1\n", + "\n", + "print(\"\\nTechnique wins / picks rate\")\n", + "for technique, n in picks.most_common():\n", + " print(f\"{technique:20s} {wins[technique]:>4} / {n:<4} {wins[technique] / n:.0%}\")" ] } ], @@ -279,4 +239,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} diff --git a/doc/code/scenarios/3_adaptive_scenarios.py b/doc/code/scenarios/3_adaptive_scenarios.py index 1e09d235b5..96e3320bbb 100644 --- a/doc/code/scenarios/3_adaptive_scenarios.py +++ b/doc/code/scenarios/3_adaptive_scenarios.py @@ -72,108 +72,104 @@ await printer.write_async(result) # type: ignore # %% [markdown] -# ## Tuning exploration (`epsilon`) +# ## Configuring a run # -# - `epsilon=0.0` — pure exploitation (always pick the best-known technique). -# - `epsilon=1.0` — pure exploration (random every time). -# - `epsilon=0.2` (default) — 20% exploration. - -# %% -explorative_scenario = TextAdaptive(epsilon=0.5) - -await explorative_scenario.initialize_async( # type: ignore - objective_target=objective_target, - dataset_config=DatasetConfiguration(dataset_names=["airt_hate"], max_dataset_size=4), -) -explorative_result = await explorative_scenario.run_async() # type: ignore -await printer.write_async(explorative_result) # type: ignore - -# %% [markdown] -# ## Attempts per objective +# All the knobs below are constructor or `initialize_async` arguments — combine whichever +# you need on a single scenario instance: # -# `max_attempts_per_objective` caps how many techniques are tried per objective before -# moving on. Higher = more chances to succeed, more API calls. +# - **`epsilon`** — exploration probability. `0.0` is pure exploit, `1.0` is pure random, +# `0.2` (default) is 20% exploration. +# - **`max_attempts_per_objective`** — caps techniques tried per objective. Higher means +# more chances to succeed and more API calls. +# - **`context_extractor`** — partitions the success-rate table. The default +# `global_context` keeps one shared table; `harm_category_context` learns each harm +# category independently. Custom callables of type `Callable[[SeedAttackGroup], str]` +# are supported. +# - **`seed`** — makes every selection decision deterministic. +# - **`scenario_strategies`** (on `initialize_async`) — restricts which techniques the +# selector can pick from. Use `TextAdaptive.get_strategy_class()` to access the enum. +# +# The cell below exercises all of them at once. # %% -persistent_scenario = TextAdaptive(max_attempts_per_objective=5) +strategy_class = TextAdaptive.get_strategy_class() -await persistent_scenario.initialize_async( # type: ignore - objective_target=objective_target, - dataset_config=DatasetConfiguration(dataset_names=["airt_violence"], max_dataset_size=4), +configured_scenario = TextAdaptive( + epsilon=0.3, + max_attempts_per_objective=5, + context_extractor=harm_category_context, + seed=42, ) -persistent_result = await persistent_scenario.run_async() # type: ignore -await printer.write_async(persistent_result) # type: ignore - -# %% [markdown] -# ## Learning per harm category -# -# By default, the scenario keeps one global success-rate table — what works on hate -# objectives boosts the same technique on violence objectives. Pass `harm_category_context` -# to learn each category independently: - -# %% -contextual_scenario = TextAdaptive(context_extractor=harm_category_context) -await contextual_scenario.initialize_async( # type: ignore +await configured_scenario.initialize_async( # type: ignore objective_target=objective_target, + scenario_strategies=[strategy_class("single_turn")], dataset_config=DatasetConfiguration( dataset_names=["airt_hate", "airt_violence"], max_dataset_size=4, ), ) -contextual_result = await contextual_scenario.run_async() # type: ignore -await printer.write_async(contextual_result) # type: ignore - -# %% [markdown] -# ## Restricting which techniques participate -# -# Use `scenario_strategies` to limit which techniques the scenario can pick from. - -# %% -strategy_class = TextAdaptive.get_strategy_class() - -single_turn_scenario = TextAdaptive() - -await single_turn_scenario.initialize_async( # type: ignore - objective_target=objective_target, - scenario_strategies=[strategy_class("single_turn")], - dataset_config=DatasetConfiguration(dataset_names=["airt_hate"], max_dataset_size=4), -) -single_turn_result = await single_turn_scenario.run_async() # type: ignore -await printer.write_async(single_turn_result) # type: ignore - -# %% [markdown] -# ## Reproducible runs -# -# Pass `seed` to make every selection decision deterministic. - -# %% -deterministic_scenario = TextAdaptive(seed=42, epsilon=0.3) - -await deterministic_scenario.initialize_async( # type: ignore - objective_target=objective_target, - dataset_config=DatasetConfiguration(dataset_names=["airt_hate"], max_dataset_size=2), -) -deterministic_result = await deterministic_scenario.run_async() # type: ignore -await printer.write_async(deterministic_result) # type: ignore +configured_result = await configured_scenario.run_async() # type: ignore +await printer.write_async(configured_result) # type: ignore # %% [markdown] # ## Resuming a run # # Adaptive scenarios are resumable — pass `scenario_result_id=...` to the `TextAdaptive` # constructor and the run picks up where it left off, with prior outcomes replayed into -# the selector. Here we resume the deterministic run from the previous cell. +# the selector. Resume must use the same configuration as the original run. # %% resumed_scenario = TextAdaptive( - seed=42, epsilon=0.3, - scenario_result_id=str(deterministic_result.id), + max_attempts_per_objective=5, + context_extractor=harm_category_context, + seed=42, + scenario_result_id=str(configured_result.id), ) await resumed_scenario.initialize_async( # type: ignore objective_target=objective_target, - dataset_config=DatasetConfiguration(dataset_names=["airt_hate"], max_dataset_size=2), + scenario_strategies=[strategy_class("single_turn")], + dataset_config=DatasetConfiguration( + dataset_names=["airt_hate", "airt_violence"], + max_dataset_size=4, + ), ) resumed_result = await resumed_scenario.run_async() # type: ignore await printer.write_async(resumed_result) # type: ignore + +# %% [markdown] +# ## Inspecting which techniques were tried +# +# The dispatcher stamps every objective's `AttackResult.metadata` with: +# +# - `adaptive_context` — the bucket key from the `context_extractor`. +# - `adaptive_attempts` — the ordered list of `{"technique", "outcome"}` dicts +# recording exactly which techniques the selector picked and what happened. +# +# Walk that metadata to see the per-objective trail and aggregate counts. + +# %% +from collections import Counter + +# Per-objective trail +for results in resumed_result.attack_results.values(): + for r in results: + attempts = r.metadata.get("adaptive_attempts", []) + trail = " → ".join(f"{a['technique']}({a['outcome']})" for a in attempts) + print(f"[{r.outcome.value:7s}] {r.objective!r}: {trail}") + +# Aggregate per-technique pick counts and success rate across the run +picks: Counter[str] = Counter() +wins: Counter[str] = Counter() +for results in resumed_result.attack_results.values(): + for r in results: + for step in r.metadata.get("adaptive_attempts", []): + picks[step["technique"]] += 1 + if step["outcome"] == "success": + wins[step["technique"]] += 1 + +print("\nTechnique wins / picks rate") +for technique, n in picks.most_common(): + print(f"{technique:20s} {wins[technique]:>4} / {n:<4} {wins[technique] / n:.0%}") diff --git a/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py b/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py index 776c46ea58..723849ce96 100644 --- a/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py +++ b/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py @@ -215,8 +215,11 @@ def _build_atomic_for_seed_group( Filters the technique pool down to those whose ``seed_technique`` (if any) is compatible with this seed group, then constructs a dedicated - ``AdaptiveDispatchAttack`` bound to this seed group. Returns ``None`` - when no techniques are compatible (caller skips the objective). + ``AdaptiveDispatchAttack`` bound to this seed group. + + Returns: + AtomicAttack | None: The constructed atomic attack, or ``None`` when + no techniques are compatible (caller skips the objective). Raises: ValueError: If ``self._objective_target`` is not set (defensive @@ -225,12 +228,11 @@ def _build_atomic_for_seed_group( if self._objective_target is None: # pragma: no cover - defensive raise ValueError("objective_target must be set before creating attacks") - compatible: dict[str, TechniqueBundle] = {} - for name, bundle in techniques.items(): - if bundle.seed_technique is None or seed_group.is_compatible_with_technique( - technique=bundle.seed_technique - ): - compatible[name] = bundle + compatible: dict[str, TechniqueBundle] = { + name: bundle + for name, bundle in techniques.items() + if bundle.seed_technique is None or seed_group.is_compatible_with_technique(technique=bundle.seed_technique) + } if not compatible: logger.warning( diff --git a/pyrit/scenario/scenarios/adaptive/dispatcher.py b/pyrit/scenario/scenarios/adaptive/dispatcher.py index 2ff2451712..46808bfde6 100644 --- a/pyrit/scenario/scenarios/adaptive/dispatcher.py +++ b/pyrit/scenario/scenarios/adaptive/dispatcher.py @@ -142,7 +142,12 @@ def __init__( self._executor = AttackExecutor(max_concurrency=1) def _validate_context(self, *, context: AdaptiveDispatchContext) -> None: - """Ensure the context carries a non-empty objective string.""" + """ + Ensure the context carries a non-empty objective string. + + Raises: + ValueError: If ``context.objective`` is empty or whitespace-only. + """ if not context.objective or context.objective.isspace(): raise ValueError("Attack objective must be provided and non-empty") diff --git a/pyrit/scenario/scenarios/adaptive/selector.py b/pyrit/scenario/scenarios/adaptive/selector.py index cd78533019..d2d9e63a7c 100644 --- a/pyrit/scenario/scenarios/adaptive/selector.py +++ b/pyrit/scenario/scenarios/adaptive/selector.py @@ -13,14 +13,12 @@ if TYPE_CHECKING: from pyrit.models.seeds.seed_attack_group import SeedAttackGroup - -ContextExtractor = Callable[["SeedAttackGroup"], str] """Maps a ``SeedAttackGroup`` to an adaptive context key.""" - -GLOBAL_CONTEXT: str = "_global" +ContextExtractor = Callable[["SeedAttackGroup"], str] """Default context: all objectives share one selection table.""" -UNCATEGORIZED_CONTEXT: str = "_uncategorized" +GLOBAL_CONTEXT: str = "_global" """Fallback context for seed groups with no harm category metadata.""" +UNCATEGORIZED_CONTEXT: str = "_uncategorized" def global_context(_seed_attack_group: SeedAttackGroup) -> str: @@ -173,6 +171,9 @@ def _estimate(self, *, context: str, technique: str) -> float: ``pool_threshold`` local attempts. Callers must already hold ``self._lock``. + + Returns: + float: Laplace-smoothed success-rate estimate in ``(0, 1)``. """ local_s, local_n = self._counts.get((context, technique), (0, 0)) if local_n >= self._pool_threshold: diff --git a/pyrit/scenario/scenarios/adaptive/text_adaptive.py b/pyrit/scenario/scenarios/adaptive/text_adaptive.py index 1d8706fa5e..4bbbe7ff4b 100644 --- a/pyrit/scenario/scenarios/adaptive/text_adaptive.py +++ b/pyrit/scenario/scenarios/adaptive/text_adaptive.py @@ -14,24 +14,31 @@ from __future__ import annotations import logging -from typing import ClassVar +from typing import TYPE_CHECKING, ClassVar from pyrit.common import apply_defaults from pyrit.registry.tag_query import TagQuery from pyrit.scenario.core.dataset_configuration import DatasetConfiguration -from pyrit.scenario.core.scenario_strategy import ScenarioStrategy from pyrit.scenario.scenarios.adaptive.adaptive_scenario import AdaptiveScenario from pyrit.scenario.scenarios.adaptive.selector import ( ContextExtractor, global_context, ) -from pyrit.score import TrueFalseScorer + +if TYPE_CHECKING: + from pyrit.scenario.core.scenario_strategy import ScenarioStrategy + from pyrit.score import TrueFalseScorer logger = logging.getLogger(__name__) def _build_text_adaptive_strategy() -> type[ScenarioStrategy]: - """Build the strategy enum from the core scenario-techniques catalog.""" + """ + Build the strategy enum from the core scenario-techniques catalog. + + Returns: + type[ScenarioStrategy]: The dynamically-built strategy enum class. + """ from pyrit.registry.object_registries.attack_technique_registry import ( AttackTechniqueRegistry, ) From f86c191163d417c9cf3d2536b2e087af297e06e2 Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Thu, 21 May 2026 16:40:03 -0400 Subject: [PATCH 10/35] feat: address PR #1760 review feedback - Remove prompt_sending from adaptive pool; enable baseline comparison - Expose max_attempts_per_objective via supported_parameters() (scam.py pattern) - Rename AdaptiveTechniqueSelector -> EpsilonGreedyTechniqueSelector - Extract TechniqueSelector Protocol; accept custom selector via kwarg - Per-decision RNG derivation (SHA-256) for resume reproducibility - Drop uuid.uuid4() fallback for objective IDs - Per-dataset atomic attacks (one AtomicAttack per dataset, not per objective) - AdaptiveDispatchParams with per-call seed_group and compatibility filtering - Context extraction moved to dispatcher - Rehydration uses get_attack_results with attribution_data filtering - Split selector.py into selectors/ folder (protocol.py + epsilon_greedy.py) - Update notebooks for new API patterns Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- doc/code/scenarios/3_adaptive_scenarios.ipynb | 31 ++- doc/code/scenarios/3_adaptive_scenarios.py | 31 ++- pyrit/scenario/scenarios/adaptive/__init__.py | 10 +- .../scenarios/adaptive/adaptive_scenario.py | 214 ++++++++------- .../scenario/scenarios/adaptive/dispatcher.py | 184 +++++++++---- .../scenarios/adaptive/selectors/__init__.py | 26 ++ .../epsilon_greedy.py} | 85 +++--- .../scenarios/adaptive/selectors/protocol.py | 66 +++++ .../scenarios/adaptive/text_adaptive.py | 62 +++-- .../scenarios/adaptive/test_dispatcher.py | 60 ++--- ...est_selector.py => test_epsilon_greedy.py} | 63 ++--- .../scenarios/adaptive/test_protocol.py | 46 ++++ .../scenarios/adaptive/test_text_adaptive.py | 247 ++++++++++-------- 13 files changed, 675 insertions(+), 450 deletions(-) create mode 100644 pyrit/scenario/scenarios/adaptive/selectors/__init__.py rename pyrit/scenario/scenarios/adaptive/{selector.py => selectors/epsilon_greedy.py} (71%) create mode 100644 pyrit/scenario/scenarios/adaptive/selectors/protocol.py rename tests/unit/scenario/scenarios/adaptive/{test_selector.py => test_epsilon_greedy.py} (81%) create mode 100644 tests/unit/scenario/scenarios/adaptive/test_protocol.py diff --git a/doc/code/scenarios/3_adaptive_scenarios.ipynb b/doc/code/scenarios/3_adaptive_scenarios.ipynb index 7be2b738ec..88996496ea 100644 --- a/doc/code/scenarios/3_adaptive_scenarios.ipynb +++ b/doc/code/scenarios/3_adaptive_scenarios.ipynb @@ -73,7 +73,8 @@ "source": [ "## Basic usage\n", "\n", - "Defaults: `epsilon=0.2`, `max_attempts_per_objective=3`, the subclass's default datasets." + "Defaults: `max_attempts_per_objective=3`, epsilon-greedy selector with `epsilon=0.2`,\n", + "the subclass’s default datasets." ] }, { @@ -99,18 +100,16 @@ "source": [ "## Configuring a run\n", "\n", - "All the knobs below are constructor or `initialize_async` arguments — combine whichever\n", - "you need on a single scenario instance:\n", - "\n", - "- **`epsilon`** — exploration probability. `0.0` is pure exploit, `1.0` is pure random,\n", - " `0.2` (default) is 20% exploration.\n", "- **`max_attempts_per_objective`** — caps techniques tried per objective. Higher means\n", - " more chances to succeed and more API calls.\n", + " more chances to succeed and more API calls. Set via `set_params_from_args`.\n", + "- **`selector`** — a pre-built `TechniqueSelector` instance. Pass an\n", + " `EpsilonGreedyTechniqueSelector(epsilon=..., pool_threshold=..., random_seed=...)`\n", + " to tune the selection algorithm. Defaults to `EpsilonGreedyTechniqueSelector()`\n", + " (`epsilon=0.2`, `pool_threshold=3`).\n", "- **`context_extractor`** — partitions the success-rate table. The default\n", " `global_context` keeps one shared table; `harm_category_context` learns each harm\n", " category independently. Custom callables of type `Callable[[SeedAttackGroup], str]`\n", " are supported.\n", - "- **`seed`** — makes every selection decision deterministic.\n", "- **`scenario_strategies`** (on `initialize_async`) — restricts which techniques the\n", " selector can pick from. Use `TextAdaptive.get_strategy_class()` to access the enum.\n", "\n", @@ -124,13 +123,16 @@ "metadata": {}, "outputs": [], "source": [ + "from pyrit.scenario.scenarios.adaptive import EpsilonGreedyTechniqueSelector\n", + "\n", "strategy_class = TextAdaptive.get_strategy_class()\n", "\n", "configured_scenario = TextAdaptive(\n", - " epsilon=0.3,\n", - " max_attempts_per_objective=5,\n", " context_extractor=harm_category_context,\n", - " seed=42,\n", + " selector=EpsilonGreedyTechniqueSelector(epsilon=0.3, random_seed=42),\n", + ")\n", + "configured_scenario.set_params_from_args(\n", + " args={\"max_attempts_per_objective\": 5}\n", ")\n", "\n", "await configured_scenario.initialize_async( # type: ignore\n", @@ -165,12 +167,13 @@ "outputs": [], "source": [ "resumed_scenario = TextAdaptive(\n", - " epsilon=0.3,\n", - " max_attempts_per_objective=5,\n", " context_extractor=harm_category_context,\n", - " seed=42,\n", + " selector=EpsilonGreedyTechniqueSelector(epsilon=0.3, random_seed=42),\n", " scenario_result_id=str(configured_result.id),\n", ")\n", + "resumed_scenario.set_params_from_args(\n", + " args={\"max_attempts_per_objective\": 5}\n", + ")\n", "\n", "await resumed_scenario.initialize_async( # type: ignore\n", " objective_target=objective_target,\n", diff --git a/doc/code/scenarios/3_adaptive_scenarios.py b/doc/code/scenarios/3_adaptive_scenarios.py index 96e3320bbb..a0d38ec30a 100644 --- a/doc/code/scenarios/3_adaptive_scenarios.py +++ b/doc/code/scenarios/3_adaptive_scenarios.py @@ -60,7 +60,8 @@ # %% [markdown] # ## Basic usage # -# Defaults: `epsilon=0.2`, `max_attempts_per_objective=3`, the subclass's default datasets. +# Defaults: `max_attempts_per_objective=3`, epsilon-greedy selector with `epsilon=0.2`, +# the subclass's default datasets. # %% scenario = TextAdaptive() @@ -74,31 +75,32 @@ # %% [markdown] # ## Configuring a run # -# All the knobs below are constructor or `initialize_async` arguments — combine whichever -# you need on a single scenario instance: -# -# - **`epsilon`** — exploration probability. `0.0` is pure exploit, `1.0` is pure random, -# `0.2` (default) is 20% exploration. # - **`max_attempts_per_objective`** — caps techniques tried per objective. Higher means -# more chances to succeed and more API calls. +# more chances to succeed and more API calls. Set via `set_params_from_args`. +# - **`selector`** — a pre-built ``TechniqueSelector`` instance. Pass an +# ``EpsilonGreedyTechniqueSelector(epsilon=..., pool_threshold=..., random_seed=...)`` +# to tune the selection algorithm. Defaults to ``EpsilonGreedyTechniqueSelector()`` +# (``epsilon=0.2``, ``pool_threshold=3``). # - **`context_extractor`** — partitions the success-rate table. The default # `global_context` keeps one shared table; `harm_category_context` learns each harm # category independently. Custom callables of type `Callable[[SeedAttackGroup], str]` # are supported. -# - **`seed`** — makes every selection decision deterministic. # - **`scenario_strategies`** (on `initialize_async`) — restricts which techniques the # selector can pick from. Use `TextAdaptive.get_strategy_class()` to access the enum. # # The cell below exercises all of them at once. # %% +from pyrit.scenario.scenarios.adaptive import EpsilonGreedyTechniqueSelector + strategy_class = TextAdaptive.get_strategy_class() configured_scenario = TextAdaptive( - epsilon=0.3, - max_attempts_per_objective=5, context_extractor=harm_category_context, - seed=42, + selector=EpsilonGreedyTechniqueSelector(epsilon=0.3, random_seed=42), +) +configured_scenario.set_params_from_args( + args={"max_attempts_per_objective": 5} ) await configured_scenario.initialize_async( # type: ignore @@ -121,12 +123,13 @@ # %% resumed_scenario = TextAdaptive( - epsilon=0.3, - max_attempts_per_objective=5, context_extractor=harm_category_context, - seed=42, + selector=EpsilonGreedyTechniqueSelector(epsilon=0.3, random_seed=42), scenario_result_id=str(configured_result.id), ) +resumed_scenario.set_params_from_args( + args={"max_attempts_per_objective": 5} +) await resumed_scenario.initialize_async( # type: ignore objective_target=objective_target, diff --git a/pyrit/scenario/scenarios/adaptive/__init__.py b/pyrit/scenario/scenarios/adaptive/__init__.py index d0bd978c22..6ba7415632 100644 --- a/pyrit/scenario/scenarios/adaptive/__init__.py +++ b/pyrit/scenario/scenarios/adaptive/__init__.py @@ -7,10 +7,12 @@ from pyrit.scenario.scenarios.adaptive.dispatcher import ( ADAPTIVE_CONTEXT_LABEL, AdaptiveDispatchAttack, + AdaptiveDispatchParams, ) -from pyrit.scenario.scenarios.adaptive.selector import ( - AdaptiveTechniqueSelector, +from pyrit.scenario.scenarios.adaptive.selectors import ( ContextExtractor, + EpsilonGreedyTechniqueSelector, + TechniqueSelector, global_context, harm_category_context, ) @@ -19,9 +21,11 @@ __all__ = [ "ADAPTIVE_CONTEXT_LABEL", "AdaptiveDispatchAttack", + "AdaptiveDispatchParams", "AdaptiveScenario", - "AdaptiveTechniqueSelector", "ContextExtractor", + "EpsilonGreedyTechniqueSelector", + "TechniqueSelector", "TextAdaptive", "global_context", "harm_category_context", diff --git a/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py b/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py index 723849ce96..862565d8ef 100644 --- a/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py +++ b/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py @@ -3,36 +3,34 @@ """ ``AdaptiveScenario`` — modality-agnostic base for scenarios that pick attack -techniques per-objective using an ``AdaptiveTechniqueSelector``. +techniques per-objective using a ``TechniqueSelector``. -Owns selector wiring, dispatcher construction, per-objective atomic-attack +Owns selector wiring, dispatcher construction, per-dataset atomic-attack emission, and resume rehydration. Concrete subclasses (``TextAdaptive``, future ``ImageAdaptive`` / ``AudioAdaptive``) only declare strategy class, default datasets, version, and atomic-attack prefix. -Baseline policy is ``Forbidden``: ``prompt_sending`` participates as one of -the selector's techniques rather than being prepended. +Baseline policy is ``Enabled``: prompt_sending runs as a separate baseline +comparison and is excluded from the adaptive technique pool. """ from __future__ import annotations import logging -import random -import uuid from typing import TYPE_CHECKING, ClassVar from pyrit.executor.attack import AttackScoringConfig from pyrit.scenario.core.atomic_attack import AtomicAttack from pyrit.scenario.core.attack_technique import AttackTechnique -from pyrit.scenario.core.scenario import BaselinePolicy, Scenario +from pyrit.scenario.core.scenario import BaselineAttackPolicy, Scenario from pyrit.scenario.scenarios.adaptive.dispatcher import ( - ADAPTIVE_CONTEXT_LABEL, AdaptiveDispatchAttack, TechniqueBundle, ) -from pyrit.scenario.scenarios.adaptive.selector import ( - AdaptiveTechniqueSelector, +from pyrit.scenario.scenarios.adaptive.selectors import ( + EpsilonGreedyTechniqueSelector, ContextExtractor, + TechniqueSelector, global_context, ) @@ -54,7 +52,7 @@ class AdaptiveScenario(Scenario): rehydration are handled here. """ - BASELINE_POLICY: ClassVar[BaselinePolicy] = BaselinePolicy.Forbidden + BASELINE_ATTACK_POLICY: ClassVar[BaselineAttackPolicy] = BaselineAttackPolicy.Enabled #: Subclasses must declare a scenario version for memory bookkeeping. VERSION: ClassVar[int] @@ -66,36 +64,27 @@ def __init__( self, *, objective_scorer: TrueFalseScorer | None = None, - epsilon: float = 0.2, - pool_threshold: int = 3, - max_attempts_per_objective: int = 3, - seed: int | None = None, context_extractor: ContextExtractor = global_context, + selector: TechniqueSelector | None = None, scenario_result_id: str | None = None, ) -> None: """ Args: objective_scorer (TrueFalseScorer | None): Scorer used to judge each response. Defaults to the composite scorer from the base class. - epsilon (float): Exploration probability for the selector. Defaults to 0.2. - pool_threshold (int): Minimum per-(context, technique) attempts before - the local estimate overrides the pooled rate. Set to 1 to disable - pooling. Defaults to 3. - max_attempts_per_objective (int): Max techniques per objective. Defaults to 3. - seed (int | None): RNG seed for deterministic selection. Defaults to ``None``. context_extractor (ContextExtractor): Maps a ``SeedAttackGroup`` to a context key. Defaults to ``global_context``. + selector (TechniqueSelector | None): Pre-built selector. When ``None`` + (default) an :class:`EpsilonGreedyTechniqueSelector` is created + with default settings. scenario_result_id (str | None): ID of an existing ``ScenarioResult`` to resume. """ if not objective_scorer: objective_scorer = self._get_default_objective_scorer() self._objective_scorer: TrueFalseScorer = objective_scorer - self._epsilon = epsilon - self._pool_threshold = pool_threshold - self._max_attempts_per_objective = max_attempts_per_objective - self._seed = seed self._context_extractor = context_extractor + self._custom_selector = selector super().__init__( version=self.VERSION, @@ -106,18 +95,24 @@ def __init__( async def _get_atomic_attacks_async(self) -> list[AtomicAttack]: """ - Build one ``AtomicAttack`` per objective. + Build one ``AtomicAttack`` per dataset, each carrying every objective + in that dataset as a separate ``SeedAttackGroup``. - Each objective gets a freshly constructed ``AdaptiveDispatchAttack`` - bound to its seed group, but all dispatchers share the same selector - so learning accumulates across objectives. Per-objective, techniques - whose ``seed_technique`` is incompatible with the seed group are - filtered out; objectives left with no compatible techniques are skipped. + A single ``AdaptiveDispatchAttack`` is constructed per dataset and + shared across its seed groups; per-call seed-group routing and + per-call ``seed_technique`` compatibility filtering happen inside the + dispatcher (driven by ``AdaptiveDispatchParams.seed_group``). All + dispatchers across all datasets share one ``TechniqueSelector`` + instance so learning accumulates globally. + + Seed groups whose objective is incompatible with every technique are + dropped up-front with a warning so the dispatcher never sees an empty + compatible pool at run time. Returns: - list[AtomicAttack]: One ``AtomicAttack`` per objective with at - least one compatible technique. Empty if every seed group - is incompatible with every selected technique. + list[AtomicAttack]: One ``AtomicAttack`` per dataset that has at + least one compatible seed group. Empty if every seed group is + incompatible with every selected technique. Raises: ValueError: If ``self._objective_target`` is not set, or if @@ -128,26 +123,25 @@ async def _get_atomic_attacks_async(self) -> list[AtomicAttack]: techniques = self._build_techniques_dict(objective_target=self._objective_target) - selector = AdaptiveTechniqueSelector( - epsilon=self._epsilon, - pool_threshold=self._pool_threshold, - rng=random.Random(self._seed), - ) + selector: TechniqueSelector + if self._custom_selector is not None: + selector = self._custom_selector + else: + selector = EpsilonGreedyTechniqueSelector() # On resume, replay prior attempt outcomes from persisted metadata. self._rehydrate_selector_from_memory(selector=selector, known_techniques=set(techniques)) seed_groups_by_dataset = self._dataset_config.get_seed_attack_groups() atomic_attacks: list[AtomicAttack] = [] for dataset_name, seed_groups in seed_groups_by_dataset.items(): - for seed_group in seed_groups: - atomic = self._build_atomic_for_seed_group( - dataset_name=dataset_name, - seed_group=seed_group, - techniques=techniques, - selector=selector, - ) - if atomic is not None: - atomic_attacks.append(atomic) + atomic = self._build_atomic_for_dataset( + dataset_name=dataset_name, + seed_groups=seed_groups, + techniques=techniques, + selector=selector, + ) + if atomic is not None: + atomic_attacks.append(atomic) return atomic_attacks @@ -202,24 +196,25 @@ def _build_techniques_dict( return techniques - def _build_atomic_for_seed_group( + def _build_atomic_for_dataset( self, *, dataset_name: str, - seed_group: SeedAttackGroup, + seed_groups: list[SeedAttackGroup], techniques: dict[str, TechniqueBundle], - selector: AdaptiveTechniqueSelector, + selector: TechniqueSelector, ) -> AtomicAttack | None: """ - Build a single ``AtomicAttack`` for one ``SeedAttackGroup``. + Build a single ``AtomicAttack`` for one dataset with all compatible + seed groups attached. - Filters the technique pool down to those whose ``seed_technique`` (if - any) is compatible with this seed group, then constructs a dedicated - ``AdaptiveDispatchAttack`` bound to this seed group. + Seed groups for which no technique in the pool is compatible are + dropped here with a warning so the dispatcher's per-call compatible + pool is guaranteed non-empty. Returns: AtomicAttack | None: The constructed atomic attack, or ``None`` when - no techniques are compatible (caller skips the objective). + every seed group is incompatible with every technique. Raises: ValueError: If ``self._objective_target`` is not set (defensive @@ -228,64 +223,62 @@ def _build_atomic_for_seed_group( if self._objective_target is None: # pragma: no cover - defensive raise ValueError("objective_target must be set before creating attacks") - compatible: dict[str, TechniqueBundle] = { - name: bundle - for name, bundle in techniques.items() - if bundle.seed_technique is None or seed_group.is_compatible_with_technique(technique=bundle.seed_technique) - } - - if not compatible: - logger.warning( - "AdaptiveScenario: no compatible techniques for seed group in dataset '%s' (objective=%r); skipping.", - dataset_name, - seed_group.objective.value, + compatible_seed_groups: list[SeedAttackGroup] = [] + for seed_group in seed_groups: + has_compatible = any( + bundle.seed_technique is None + or seed_group.is_compatible_with_technique(technique=bundle.seed_technique) + for bundle in techniques.values() ) - return None + if has_compatible: + compatible_seed_groups.append(seed_group) + else: + logger.warning( + "AdaptiveScenario: no compatible techniques for seed group in dataset '%s' " + "(objective=%r); skipping.", + dataset_name, + seed_group.objective.value, + ) - adaptive_context = self._context_extractor(seed_group) - # Prefer the objective's id when available so resume keys stay stable - # across re-fetches of the same seed groups. - objective_id = seed_group.objective.id if seed_group.objective.id else uuid.uuid4() - atomic_attack_name = f"{self._atomic_attack_prefix}_{dataset_name}_{objective_id}" + if not compatible_seed_groups: + return None dispatcher = AdaptiveDispatchAttack( objective_target=self._objective_target, - techniques=compatible, + techniques=techniques, selector=selector, - seed_group=seed_group, + context_extractor=self._context_extractor, objective_scorer=self._objective_scorer, - max_attempts_per_objective=self._max_attempts_per_objective, + max_attempts_per_objective=self.params["max_attempts_per_objective"], ) - memory_labels = { - **self._memory_labels, - ADAPTIVE_CONTEXT_LABEL: adaptive_context, - } return AtomicAttack( - atomic_attack_name=atomic_attack_name, + atomic_attack_name=f"{self._atomic_attack_prefix}_{dataset_name}", attack_technique=AttackTechnique(attack=dispatcher), - seed_groups=[seed_group], + seed_groups=compatible_seed_groups, objective_scorer=self._objective_scorer, - memory_labels=memory_labels, + memory_labels=dict(self._memory_labels), display_group=dataset_name, ) def _rehydrate_selector_from_memory( self, *, - selector: AdaptiveTechniqueSelector, + selector: TechniqueSelector, known_techniques: set[str], ) -> None: """ Replay persisted dispatch trails into ``selector`` so resume preserves learned state. - Iterates every persisted ``AttackResult`` on the resumed - ``ScenarioResult`` and calls ``record_outcome`` once per attempt in - each ``metadata["adaptive_attempts"]`` trail. + Queries ``AttackResultEntry`` rows directly by ``scenario_result_id`` + (which selects on ``attribution_parent_id`` stamped at write time by + ``AtomicAttack``'s attribution path) and filters to rows belonging to + this scenario's adaptive atomic attacks via + ``attribution_data["parent_collection"]``. Args: - selector (AdaptiveTechniqueSelector): A freshly built selector to populate. + selector (TechniqueSelector): A freshly built selector to populate. known_techniques (set[str]): Techniques available in the current run. Trails referencing unknown techniques (e.g. after a strategies change) are skipped so replay can't poison the table. @@ -296,32 +289,35 @@ def _rehydrate_selector_from_memory( # Narrow to errors a memory backend would plausibly raise (DB/IO # failures, integrity issues). Programmer-level errors propagate. try: - scenario_results = self._memory.get_scenario_results(scenario_result_ids=[self._scenario_result_id]) + rows = self._memory.get_attack_results(scenario_result_id=self._scenario_result_id) except (RuntimeError, OSError, ValueError) as exc: - logger.warning(f"AdaptiveScenario: failed to load prior scenario result for rehydration: {exc}") - return - - if not scenario_results: + logger.warning(f"AdaptiveScenario: failed to load prior attack results for rehydration: {exc}") return + adaptive_prefix = f"{self._atomic_attack_prefix}_" replayed = 0 - for results_list in scenario_results[0].attack_results.values(): - for result in results_list: - trail = result.metadata.get("adaptive_attempts") if result.metadata else None - context = result.metadata.get("adaptive_context") if result.metadata else None - if not trail or not context: + for result in rows: + if result.attribution_data is None: + continue + collection = result.attribution_data.get("parent_collection") + if not collection or not collection.startswith(adaptive_prefix): + continue + metadata = result.metadata or {} + trail = metadata.get("adaptive_attempts") + context = metadata.get("adaptive_context") + if not trail or not context: + continue + for step in trail: + technique = step.get("technique") + outcome = step.get("outcome") + if not technique or technique not in known_techniques: continue - for step in trail: - technique = step.get("technique") - outcome = step.get("outcome") - if not technique or technique not in known_techniques: - continue - selector.record_outcome( - context=context, - technique=technique, - success=outcome == "success", - ) - replayed += 1 + selector.record_outcome( + context=context, + technique=technique, + success=outcome == "success", + ) + replayed += 1 if replayed: logger.info(f"AdaptiveScenario: rehydrated selector with {replayed} prior attempt(s).") diff --git a/pyrit/scenario/scenarios/adaptive/dispatcher.py b/pyrit/scenario/scenarios/adaptive/dispatcher.py index 46808bfde6..a8e671cdd0 100644 --- a/pyrit/scenario/scenarios/adaptive/dispatcher.py +++ b/pyrit/scenario/scenarios/adaptive/dispatcher.py @@ -2,35 +2,40 @@ # Licensed under the MIT license. """ -``AdaptiveDispatchAttack`` — picks an inner technique per attempt via an -``AdaptiveTechniqueSelector``, runs it, records the outcome, and loops up to -``max_attempts_per_objective`` times. Reads the per-objective context key from -``context.memory_labels[ADAPTIVE_CONTEXT_LABEL]`` (falls back to the global context). - -The dispatcher is bound to a single ``SeedAttackGroup`` at construction time so -it can merge each chosen technique's ``seed_technique`` (when present) into the -seed group before delegating execution to ``AttackExecutor``. +``AdaptiveDispatchAttack`` — picks an inner technique per attempt via a +``TechniqueSelector``, runs it, records the outcome, and loops up to +``max_attempts_per_objective`` times. + +The dispatcher is shared across all seed groups in an enclosing +``AtomicAttack`` and reads the per-call ``SeedAttackGroup`` from +``AdaptiveDispatchParams.seed_group`` (populated by +``AdaptiveDispatchParams.from_seed_group_async``). It computes the per-call +adaptive context key via the injected ``ContextExtractor`` and merges each +chosen technique's ``seed_technique`` (when present) into the seed group +before delegating execution to ``AttackExecutor``. """ from __future__ import annotations +import dataclasses import logging import uuid -from dataclasses import dataclass, replace +from dataclasses import dataclass, field, replace from datetime import datetime, timezone -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Optional from pyrit.executor.attack.core.attack_executor import AttackExecutor from pyrit.executor.attack.core.attack_parameters import AttackParameters from pyrit.executor.attack.core.attack_strategy import AttackContext, AttackStrategy -from pyrit.models import AttackOutcome, AttackResult -from pyrit.scenario.scenarios.adaptive.selector import ( - GLOBAL_CONTEXT, - AdaptiveTechniqueSelector, +from pyrit.models import AttackOutcome, AttackResult, SeedAttackGroup +from pyrit.scenario.scenarios.adaptive.selectors import ( + ContextExtractor, + TechniqueSelector, + global_context, ) if TYPE_CHECKING: - from pyrit.models import SeedAttackGroup, SeedAttackTechniqueGroup + from pyrit.models import SeedAttackTechniqueGroup from pyrit.prompt_target import PromptTarget from pyrit.score import TrueFalseScorer @@ -38,8 +43,8 @@ # Memory-label keys stamped onto persisted prompt rows so adaptive attempts -# can be filtered/grouped after a run. The scenario stamps the context once -# per objective; the dispatcher stamps technique + attempt index on each try. +# can be filtered/grouped after a run. The dispatcher stamps all three on +# each attempt (context derived per-call from the seed group). ADAPTIVE_CONTEXT_LABEL: str = "_adaptive_context" """Per-objective context key (e.g. ``"_global"`` or a harm category).""" ADAPTIVE_TECHNIQUE_LABEL: str = "_adaptive_technique" @@ -63,25 +68,75 @@ class TechniqueBundle: adversarial_chat: PromptTarget | None = None +@dataclass(frozen=True) +class AdaptiveDispatchParams(AttackParameters): + # The original SeedAttackGroup is preserved on the params so the + # dispatcher can apply per-attempt seed_technique merging and derive + # the per-call adaptive context. Captured by ``from_seed_group_async``; + # not user-supplied via overrides. + seed_group: Optional[SeedAttackGroup] = field(default=None, repr=False, compare=False) + + @classmethod + async def from_seed_group_async( + cls, + *, + seed_group: SeedAttackGroup, + adversarial_chat: Optional["PromptTarget"] = None, # noqa: ARG003 — required by base class signature + objective_scorer: Optional["TrueFalseScorer"] = None, # noqa: ARG003 — required by base class signature + **overrides: Any, + ) -> "AdaptiveDispatchParams": + """ + Build params for a single dispatch and capture the original seed_group. + + The dispatcher applies seed_technique merging itself per-attempt, so + we deliberately bypass the base class's simulated-conversation + expansion / next_message extraction: the inner technique runs through + its own ``execute_attack_from_seed_groups_async`` call which performs + that work using the technique-merged seed_group. + """ + if seed_group.objective is None: + raise ValueError("seed_group.objective is not initialized") + seed_group.validate() + + valid_fields = {f.name for f in dataclasses.fields(cls)} - {"seed_group"} + invalid = set(overrides.keys()) - valid_fields + if invalid: + raise ValueError( + f"{cls.__name__} does not accept parameters: {invalid}. Accepted: {valid_fields}" + ) + + return cls( + objective=seed_group.objective.value, + memory_labels=overrides.get("memory_labels") or {}, + seed_group=seed_group, + ) + + @dataclass -class AdaptiveDispatchContext(AttackContext[AttackParameters]): +class AdaptiveDispatchContext(AttackContext[AdaptiveDispatchParams]): """Execution context for ``AdaptiveDispatchAttack`` (no extra state).""" class AdaptiveDispatchAttack(AttackStrategy[AdaptiveDispatchContext, AttackResult]): """ Attack that delegates each attempt to one of several inner techniques, - choosing per attempt via an ``AdaptiveTechniqueSelector``. + choosing per attempt via a ``TechniqueSelector``. For each objective, loops up to ``max_attempts_per_objective`` times: - ask the selector, execute the chosen technique, record the outcome, and - stop early on success. The selector is shared by reference with the - scenario so learning accumulates across objectives. - - The dispatcher is bound to a single ``SeedAttackGroup`` at construction - time. When a chosen technique declares a ``seed_technique``, that group - is merged into the seed group before execution (mirroring the static - ``AtomicAttack`` path). + ask the selector, execute the chosen technique against the current seed + group, record the outcome, and stop early on success. The selector is + shared by reference across all dispatch calls in a scenario so learning + accumulates across objectives. + + The seed group for a given dispatch is read from + ``context.params.seed_group`` (captured by + ``AdaptiveDispatchParams.from_seed_group_async``). When a chosen + technique declares a ``seed_technique``, that group is merged into the + seed group before execution (mirroring the static ``AtomicAttack`` path). + Techniques whose ``seed_technique`` is incompatible with the current + seed group are filtered out of the candidate pool for that call; if the + pool is empty the dispatcher raises so the per-call seed group is dropped + by the executor's partial-failure path rather than silently no-op'ing. On success, the dispatcher returns a fresh ``AttackResult`` copy of the winning inner result (new ``attack_result_id`` and ``timestamp``) with @@ -97,8 +152,8 @@ def __init__( *, objective_target: PromptTarget, techniques: dict[str, TechniqueBundle], - selector: AdaptiveTechniqueSelector, - seed_group: SeedAttackGroup, + selector: TechniqueSelector, + context_extractor: ContextExtractor = global_context, objective_scorer: TrueFalseScorer | None = None, max_attempts_per_objective: int = 3, ) -> None: @@ -108,10 +163,9 @@ def __init__( Stored for identifier/logging parity; not called directly. techniques (dict[str, TechniqueBundle]): Mapping from technique name to its bundle (attack, seed_technique, adversarial_chat). Must be non-empty. - selector (AdaptiveTechniqueSelector): Shared selector state. - seed_group (SeedAttackGroup): The seed group bound to this dispatcher. - Each attempt's chosen technique is applied against this group - (merging the technique's ``seed_technique`` when present). + selector (TechniqueSelector): Shared selector state. + context_extractor (ContextExtractor): Maps a per-call ``SeedAttackGroup`` to + the adaptive context key used by the selector. Defaults to ``global_context``. objective_scorer (TrueFalseScorer | None): Scorer passed through to techniques that generate simulated conversations. max_attempts_per_objective (int): Max attempts per objective; >= 1. @@ -128,12 +182,12 @@ def __init__( super().__init__( objective_target=objective_target, context_type=AdaptiveDispatchContext, - params_type=AttackParameters, + params_type=AdaptiveDispatchParams, logger=logger, ) self._techniques = techniques self._selector = selector - self._seed_group = seed_group + self._context_extractor = context_extractor self._objective_scorer = objective_scorer self._max_attempts = max_attempts_per_objective # Attempts are inherently sequential (each one reads the selector @@ -161,17 +215,19 @@ async def _run_inner_attack_async( self, *, bundle: TechniqueBundle, + seed_group: SeedAttackGroup, attempt_labels: dict[str, str], ) -> AttackResult: """ - Execute the chosen technique against this dispatcher's seed group. + Execute the chosen technique against the per-call seed group. - Merges ``bundle.seed_technique`` into the bound ``seed_group`` (when - present) and delegates execution to ``AttackExecutor``. Isolated as a - method so tests can patch the inner-attack call surface. + Merges ``bundle.seed_technique`` into ``seed_group`` (when present) + and delegates execution to ``AttackExecutor``. Isolated as a method + so tests can patch the inner-attack call surface. Args: bundle (TechniqueBundle): The chosen technique's attack + seeds + chat. + seed_group (SeedAttackGroup): The seed group for this dispatch call. attempt_labels (dict[str, str]): Memory labels stamped onto this attempt. Returns: @@ -182,9 +238,9 @@ async def _run_inner_attack_async( propagated exception (should be unreachable). """ if bundle.seed_technique is not None: - execution_group = self._seed_group.with_technique(technique=bundle.seed_technique) + execution_group = seed_group.with_technique(technique=bundle.seed_technique) else: - execution_group = self._seed_group + execution_group = seed_group executor_result = await self._executor.execute_attack_from_seed_groups_async( attack=bundle.attack, @@ -206,14 +262,16 @@ async def _perform_async(self, *, context: AdaptiveDispatchContext) -> AttackRes """ Run the per-objective adaptive loop. - Resolves the per-objective context key from ``context.memory_labels`` - (falling back to :data:`GLOBAL_CONTEXT`), then loops up to + Reads the per-call ``SeedAttackGroup`` from ``context.params.seed_group``, + derives the adaptive context key via the injected ``ContextExtractor``, + and filters the technique pool to those whose ``seed_technique`` is + compatible with this seed group. Then loops up to ``max_attempts_per_objective`` times: select a technique, execute it, record the outcome, and stop early on success. Args: - context (AdaptiveDispatchContext): Execution context. ``memory_labels`` - may carry :data:`ADAPTIVE_CONTEXT_LABEL` to scope the selector. + context (AdaptiveDispatchContext): Execution context whose + ``params.seed_group`` carries the seed group for this call. Returns: AttackResult: A fresh dispatcher-owned copy of the final inner @@ -221,20 +279,46 @@ async def _perform_async(self, *, context: AdaptiveDispatchContext) -> AttackRes (see class docstring for the two-row persistence note). Raises: + ValueError: If ``context.params.seed_group`` is missing, or if no + techniques in the pool are compatible with the seed group. RuntimeError: If the loop somehow ran zero attempts (unreachable because ``max_attempts_per_objective`` is validated >= 1). """ - adaptive_context = context.memory_labels.get(ADAPTIVE_CONTEXT_LABEL, GLOBAL_CONTEXT) - technique_names = list(self._techniques.keys()) + seed_group = context.params.seed_group + if seed_group is None: + raise ValueError( + "AdaptiveDispatchAttack requires AdaptiveDispatchParams.seed_group; " + "build params via AdaptiveDispatchParams.from_seed_group_async." + ) + + compatible_names = [ + name + for name, bundle in self._techniques.items() + if bundle.seed_technique is None + or seed_group.is_compatible_with_technique(technique=bundle.seed_technique) + ] + if not compatible_names: + raise ValueError( + f"AdaptiveDispatchAttack: no compatible techniques for seed group " + f"(objective={seed_group.objective.value!r})." + ) + + adaptive_context = self._context_extractor(seed_group) last_result: AttackResult | None = None trail: list[dict[str, str]] = [] for attempt_idx in range(self._max_attempts): - chosen = self._selector.select(context=adaptive_context, techniques=technique_names) + decision_key = f"{context.objective}:{attempt_idx}" + chosen = self._selector.select( + context=adaptive_context, + techniques=compatible_names, + decision_key=decision_key, + ) bundle = self._techniques[chosen] attempt_labels = { **context.memory_labels, + ADAPTIVE_CONTEXT_LABEL: adaptive_context, ADAPTIVE_TECHNIQUE_LABEL: chosen, ADAPTIVE_ATTEMPT_LABEL: str(attempt_idx + 1), } @@ -247,7 +331,9 @@ async def _perform_async(self, *, context: AdaptiveDispatchContext) -> AttackRes chosen, ) - result = await self._run_inner_attack_async(bundle=bundle, attempt_labels=attempt_labels) + result = await self._run_inner_attack_async( + bundle=bundle, seed_group=seed_group, attempt_labels=attempt_labels + ) success = result.outcome == AttackOutcome.SUCCESS self._selector.record_outcome(context=adaptive_context, technique=chosen, success=success) diff --git a/pyrit/scenario/scenarios/adaptive/selectors/__init__.py b/pyrit/scenario/scenarios/adaptive/selectors/__init__.py new file mode 100644 index 0000000000..7e97f19400 --- /dev/null +++ b/pyrit/scenario/scenarios/adaptive/selectors/__init__.py @@ -0,0 +1,26 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""Selector protocol, context extractors, and selector implementations.""" + +from pyrit.scenario.scenarios.adaptive.selectors.epsilon_greedy import ( + EpsilonGreedyTechniqueSelector, +) +from pyrit.scenario.scenarios.adaptive.selectors.protocol import ( + GLOBAL_CONTEXT, + UNCATEGORIZED_CONTEXT, + ContextExtractor, + TechniqueSelector, + global_context, + harm_category_context, +) + +__all__ = [ + "ContextExtractor", + "EpsilonGreedyTechniqueSelector", + "GLOBAL_CONTEXT", + "TechniqueSelector", + "UNCATEGORIZED_CONTEXT", + "global_context", + "harm_category_context", +] diff --git a/pyrit/scenario/scenarios/adaptive/selector.py b/pyrit/scenario/scenarios/adaptive/selectors/epsilon_greedy.py similarity index 71% rename from pyrit/scenario/scenarios/adaptive/selector.py rename to pyrit/scenario/scenarios/adaptive/selectors/epsilon_greedy.py index d2d9e63a7c..ec6152097c 100644 --- a/pyrit/scenario/scenarios/adaptive/selector.py +++ b/pyrit/scenario/scenarios/adaptive/selectors/epsilon_greedy.py @@ -1,53 +1,32 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -"""Epsilon-greedy selector and context extractors for adaptive scenarios.""" +"""Epsilon-greedy technique selector for adaptive scenarios.""" from __future__ import annotations +import hashlib import random +import struct import threading -from collections.abc import Callable, Sequence -from typing import TYPE_CHECKING +from collections.abc import Sequence -if TYPE_CHECKING: - from pyrit.models.seeds.seed_attack_group import SeedAttackGroup -"""Maps a ``SeedAttackGroup`` to an adaptive context key.""" -ContextExtractor = Callable[["SeedAttackGroup"], str] -"""Default context: all objectives share one selection table.""" -GLOBAL_CONTEXT: str = "_global" -"""Fallback context for seed groups with no harm category metadata.""" -UNCATEGORIZED_CONTEXT: str = "_uncategorized" - - -def global_context(_seed_attack_group: SeedAttackGroup) -> str: - """ - Return a single shared context for all objectives. - - Returns: - str: Always :data:`GLOBAL_CONTEXT`. +def _derive_rng(random_seed: int | None, context: str, decision_key: str) -> random.Random: """ - return GLOBAL_CONTEXT - + Derive a per-decision ``Random`` from ``(random_seed, context, decision_key)``. -def harm_category_context(seed_attack_group: SeedAttackGroup) -> str: + Returns a fresh ``random.Random`` seeded deterministically from the + inputs when ``random_seed`` is not None, or an unseeded ``Random`` otherwise. """ - Return a context keyed by the sorted, ``|``-joined harm categories. + if random_seed is None: + return random.Random() + digest = hashlib.sha256(f"{random_seed}|{context}|{decision_key}".encode()).digest() + derived_seed = struct.unpack(" None: """ Args: @@ -80,8 +63,8 @@ def __init__( pool_threshold (int): Minimum per-(context, technique) attempts before the local estimate replaces the pooled rate. Must be >= 1; set to 1 to disable pooling. Defaults to 3. - rng (random.Random | None): RNG for reproducible decisions. Defaults - to a fresh unseeded ``random.Random()``. + random_seed (int | None): Base seed for deterministic per-decision RNG derivation. + Defaults to ``None`` (non-deterministic). Raises: ValueError: If ``epsilon`` is outside [0.0, 1.0] or ``pool_threshold`` < 1. @@ -93,21 +76,27 @@ def __init__( self._epsilon = epsilon self._pool_threshold = pool_threshold - self._rng = rng if rng is not None else random.Random() + self._seed = random_seed self._counts: dict[tuple[str, str], tuple[int, int]] = {} # Per-technique pooled counts, kept in sync with ``_counts`` so the # pooled-backoff branch in ``_estimate`` is O(1). self._global_counts: dict[str, tuple[int, int]] = {} - # Guards _counts, _global_counts, and _rng against concurrent callers. + # Monotonic counter for auto-generating decision keys when the caller + # doesn't provide one. + self._decision_counter: int = 0 + # Guards _counts, _global_counts, and _decision_counter against concurrent callers. self._lock = threading.Lock() - def select(self, *, context: str, techniques: Sequence[str]) -> str: + def select(self, *, context: str, techniques: Sequence[str], decision_key: str = "") -> str: """ Pick the next technique to try for ``context``. Args: context (str): The context key. techniques (Sequence[str]): Candidate technique names. + decision_key (str): Caller-supplied key (e.g. ``"obj_id:attempt_idx"``) + used to derive a per-decision RNG for deterministic replay. + Defaults to ``""`` (auto-incremented counter). Returns: str: The chosen technique name. @@ -120,13 +109,20 @@ def select(self, *, context: str, techniques: Sequence[str]) -> str: raise ValueError("techniques must contain at least one entry") with self._lock: - if self._rng.random() < self._epsilon: - return self._rng.choice(technique_list) + if decision_key: + effective_key = decision_key + else: + effective_key = str(self._decision_counter) + self._decision_counter += 1 + rng = _derive_rng(self._seed, context, effective_key) + + if rng.random() < self._epsilon: + return rng.choice(technique_list) estimates = {t: self._estimate(context=context, technique=t) for t in technique_list} best = max(estimates.values()) winners = [t for t, value in estimates.items() if value >= best - self._TIE_TOL] - return self._rng.choice(winners) + return rng.choice(winners) def record_outcome(self, *, context: str, technique: str, success: bool) -> None: """ @@ -180,3 +176,4 @@ def _estimate(self, *, context: str, technique: str) -> float: return (local_s + 1) / (local_n + 1) global_s, global_n = self._global_counts.get(technique, (0, 0)) return (global_s + 1) / (global_n + 1) + diff --git a/pyrit/scenario/scenarios/adaptive/selectors/protocol.py b/pyrit/scenario/scenarios/adaptive/selectors/protocol.py new file mode 100644 index 0000000000..e8c8c640f8 --- /dev/null +++ b/pyrit/scenario/scenarios/adaptive/selectors/protocol.py @@ -0,0 +1,66 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""Selector protocol and context extractors for adaptive scenarios.""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from typing import TYPE_CHECKING, Protocol, runtime_checkable + +if TYPE_CHECKING: + from pyrit.models.seeds.seed_attack_group import SeedAttackGroup + +ContextExtractor = Callable[["SeedAttackGroup"], str] +"""Maps a ``SeedAttackGroup`` to an adaptive context key.""" + +GLOBAL_CONTEXT: str = "_global" +"""Default context: all objectives share one selection table.""" + +UNCATEGORIZED_CONTEXT: str = "_uncategorized" +"""Fallback context for seed groups with no harm category metadata.""" + + +def global_context(_seed_attack_group: SeedAttackGroup) -> str: + """ + Return a single shared context for all objectives. + + Returns: + str: Always :data:`GLOBAL_CONTEXT`. + """ + return GLOBAL_CONTEXT + + +def harm_category_context(seed_attack_group: SeedAttackGroup) -> str: + """ + Return a context keyed by the sorted, ``|``-joined harm categories. + + Multi-category seeds form their own bucket; sorting makes the key deterministic. + + Returns: + str: The ``|``-joined sorted harm categories, or :data:`UNCATEGORIZED_CONTEXT` + when the seed group has no categories. + """ + categories = seed_attack_group.harm_categories + if not categories: + return UNCATEGORIZED_CONTEXT + return "|".join(sorted(categories)) + + +@runtime_checkable +class TechniqueSelector(Protocol): + """ + Protocol for adaptive technique selectors. + + Any object implementing ``select`` and ``record_outcome`` can serve as + the selector for an ``AdaptiveScenario``. The epsilon-greedy + implementation (:class:`EpsilonGreedyTechniqueSelector`) is the default. + """ + + def select(self, *, context: str, techniques: Sequence[str], decision_key: str = "") -> str: + """Pick the next technique to try for ``context``.""" + ... # pragma: no cover + + def record_outcome(self, *, context: str, technique: str, success: bool) -> None: + """Record the outcome of an attempt.""" + ... # pragma: no cover diff --git a/pyrit/scenario/scenarios/adaptive/text_adaptive.py b/pyrit/scenario/scenarios/adaptive/text_adaptive.py index 4bbbe7ff4b..c1d1e588a7 100644 --- a/pyrit/scenario/scenarios/adaptive/text_adaptive.py +++ b/pyrit/scenario/scenarios/adaptive/text_adaptive.py @@ -6,9 +6,9 @@ Picks attack techniques per-objective using an epsilon-greedy selector informed by observed success rates. Runs up to ``max_attempts_per_objective`` -techniques per objective and stops early on success. The available techniques -come from the selected scenario strategies (``--strategies single_turn`` -restricts to single-turn techniques, etc.). +techniques per objective and stops early on success. ``prompt_sending`` is +excluded from the adaptive technique pool and runs as the baseline comparison +instead. """ from __future__ import annotations @@ -17,11 +17,13 @@ from typing import TYPE_CHECKING, ClassVar from pyrit.common import apply_defaults +from pyrit.common.parameter import Parameter from pyrit.registry.tag_query import TagQuery from pyrit.scenario.core.dataset_configuration import DatasetConfiguration from pyrit.scenario.scenarios.adaptive.adaptive_scenario import AdaptiveScenario -from pyrit.scenario.scenarios.adaptive.selector import ( +from pyrit.scenario.scenarios.adaptive.selectors import ( ContextExtractor, + TechniqueSelector, global_context, ) @@ -31,10 +33,15 @@ logger = logging.getLogger(__name__) +# Techniques excluded from the adaptive technique pool. These run as the +# baseline comparison rather than as adversarial moves the selector chooses. +_EXCLUDED_TECHNIQUES = frozenset({"prompt_sending"}) + def _build_text_adaptive_strategy() -> type[ScenarioStrategy]: """ - Build the strategy enum from the core scenario-techniques catalog. + Build the strategy enum from the core scenario-techniques catalog, + excluding techniques that run as baseline. Returns: type[ScenarioStrategy]: The dynamically-built strategy enum class. @@ -44,9 +51,11 @@ def _build_text_adaptive_strategy() -> type[ScenarioStrategy]: ) from pyrit.scenario.core.scenario_techniques import SCENARIO_TECHNIQUES + filtered_specs = [spec for spec in SCENARIO_TECHNIQUES if spec.name not in _EXCLUDED_TECHNIQUES] + return AttackTechniqueRegistry.build_strategy_class_from_specs( # type: ignore[return-value, ty:invalid-return-type] class_name="TextAdaptiveStrategy", - specs=SCENARIO_TECHNIQUES, + specs=filtered_specs, aggregate_tags={ "default": TagQuery.any_of("default"), "single_turn": TagQuery.any_of("single_turn"), @@ -60,8 +69,8 @@ class TextAdaptive(AdaptiveScenario): Adaptive text-attack scenario. Selects techniques per-objective via an epsilon-greedy selector over the - set of selected strategies. ``prompt_sending`` participates as one of the - selector's techniques rather than being prepended as a baseline. + set of selected strategies. ``prompt_sending`` runs as the baseline + comparison and is excluded from the adaptive technique pool. """ VERSION: int = 1 @@ -99,39 +108,48 @@ def default_dataset_config(cls) -> DatasetConfiguration: """Return the default :class:`DatasetConfiguration` (required datasets, capped at 4 per dataset).""" return DatasetConfiguration(dataset_names=cls.required_datasets(), max_dataset_size=4) + @classmethod + def supported_parameters(cls) -> list[Parameter]: + """ + Declare custom parameters this scenario accepts from the CLI / config file. + + Returns: + list[Parameter]: Parameters configurable per-run. + """ + return [ + Parameter( + name="max_attempts_per_objective", + description="Max techniques tried per objective.", + param_type=int, + default=3, + ), + ] + @apply_defaults def __init__( self, *, objective_scorer: TrueFalseScorer | None = None, - epsilon: float = 0.2, - pool_threshold: int = 3, - max_attempts_per_objective: int = 3, - seed: int | None = None, context_extractor: ContextExtractor = global_context, + selector: TechniqueSelector | None = None, scenario_result_id: str | None = None, ) -> None: """ Args: objective_scorer (TrueFalseScorer | None): Scorer used to judge each response. Defaults to the composite scorer from the base class. - epsilon (float): Exploration probability for the selector. Defaults to 0.2. - pool_threshold (int): Minimum per-(context, technique) attempts before - the local estimate overrides the pooled rate. Set to 1 to disable - pooling. Defaults to 3. - max_attempts_per_objective (int): Max techniques per objective. Defaults to 3. - seed (int | None): RNG seed for deterministic selection. Defaults to ``None``. context_extractor (ContextExtractor): Maps a ``SeedAttackGroup`` to a context key. Defaults to ``global_context``. Use ``harm_category_context`` to partition by harm category. + selector (TechniqueSelector | None): Pre-built selector. When ``None`` + (default) an :class:`EpsilonGreedyTechniqueSelector` is created + with default settings. Pass a custom instance to tune + ``epsilon``, ``pool_threshold``, or ``random_seed``. scenario_result_id (str | None): ID of an existing ``ScenarioResult`` to resume. """ super().__init__( objective_scorer=objective_scorer, - epsilon=epsilon, - pool_threshold=pool_threshold, - max_attempts_per_objective=max_attempts_per_objective, - seed=seed, context_extractor=context_extractor, + selector=selector, scenario_result_id=scenario_result_id, ) diff --git a/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py b/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py index 4be4ffbb6c..963d6bd634 100644 --- a/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py +++ b/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py @@ -1,24 +1,23 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -import random from unittest.mock import AsyncMock, MagicMock import pytest -from pyrit.executor.attack.core.attack_parameters import AttackParameters from pyrit.models import AttackOutcome, AttackResult, SeedAttackGroup, SeedObjective from pyrit.scenario.scenarios.adaptive.dispatcher import ( ADAPTIVE_ATTEMPT_LABEL, - ADAPTIVE_CONTEXT_LABEL, ADAPTIVE_TECHNIQUE_LABEL, AdaptiveDispatchAttack, AdaptiveDispatchContext, + AdaptiveDispatchParams, TechniqueBundle, ) -from pyrit.scenario.scenarios.adaptive.selector import ( +from pyrit.scenario.scenarios.adaptive.selectors import ( GLOBAL_CONTEXT, - AdaptiveTechniqueSelector, + EpsilonGreedyTechniqueSelector, + harm_category_context, ) @@ -34,8 +33,22 @@ def _make_bundle(*, name: str, outcomes: list[AttackOutcome], seed_technique=Non return TechniqueBundle(attack=attack, seed_technique=seed_technique) -def _make_context(*, objective: str = "obj", labels: dict[str, str] | None = None) -> AdaptiveDispatchContext: - return AdaptiveDispatchContext(params=AttackParameters(objective=objective, memory_labels=labels or {})) +def _make_context( + *, + objective: str = "obj", + labels: dict[str, str] | None = None, + seed_group: SeedAttackGroup | None = None, + harm_categories: list[str] | None = None, +) -> AdaptiveDispatchContext: + if seed_group is None: + seed_group = SeedAttackGroup(seeds=[SeedObjective(value=objective, harm_categories=harm_categories)]) + return AdaptiveDispatchContext( + params=AdaptiveDispatchParams( + objective=objective, + memory_labels=labels or {}, + seed_group=seed_group, + ) + ) def _patch_inner( @@ -52,7 +65,7 @@ def _patch_inner( name_for_attack = {id(b.attack): name for name, b in bundles.items()} counters: dict[str, int] = dict.fromkeys(bundles, 0) - async def _stub(*, bundle: TechniqueBundle, attempt_labels: dict[str, str]) -> AttackResult: + async def _stub(*, bundle: TechniqueBundle, seed_group, attempt_labels: dict[str, str]) -> AttackResult: name = name_for_attack[id(bundle.attack)] idx = counters[name] counters[name] = idx + 1 @@ -69,9 +82,9 @@ async def _stub(*, bundle: TechniqueBundle, attempt_labels: dict[str, str]) -> A @pytest.fixture -def selector() -> AdaptiveTechniqueSelector: +def selector() -> EpsilonGreedyTechniqueSelector: # epsilon=0 makes selection deterministic given the table. - return AdaptiveTechniqueSelector(epsilon=0.0, pool_threshold=1, rng=random.Random(0)) + return EpsilonGreedyTechniqueSelector(epsilon=0.0, pool_threshold=1, random_seed=0) @pytest.fixture @@ -92,7 +105,6 @@ def test_init_rejects_empty_techniques(self, target, selector, seed_group): objective_target=target, techniques={}, selector=selector, - seed_group=seed_group, ) @pytest.mark.parametrize("bad_max", [0, -1]) @@ -103,7 +115,6 @@ def test_init_rejects_invalid_max_attempts(self, target, selector, seed_group, b objective_target=target, techniques={"a": _make_bundle(name="a", outcomes=[AttackOutcome.SUCCESS])}, selector=selector, - seed_group=seed_group, max_attempts_per_objective=bad_max, ) @@ -119,7 +130,6 @@ async def test_stops_on_first_success(self, target, selector, seed_group): objective_target=target, techniques=bundles, selector=selector, - seed_group=seed_group, max_attempts_per_objective=5, ) inner = _patch_inner(dispatcher=dispatcher, bundles=bundles) @@ -138,7 +148,6 @@ async def test_retries_until_max_attempts_on_failure(self, target, selector, see objective_target=target, techniques=bundles, selector=selector, - seed_group=seed_group, max_attempts_per_objective=3, ) inner = _patch_inner(dispatcher=dispatcher, bundles=bundles) @@ -157,7 +166,6 @@ async def test_updates_selector_on_each_attempt(self, target, selector, seed_gro objective_target=target, techniques=bundles, selector=selector, - seed_group=seed_group, max_attempts_per_objective=3, ) inner = _patch_inner(dispatcher=dispatcher, bundles=bundles) @@ -173,7 +181,6 @@ async def test_passes_attempt_labels_to_inner(self, target, selector, seed_group objective_target=target, techniques=bundles, selector=selector, - seed_group=seed_group, ) inner = _patch_inner(dispatcher=dispatcher, bundles=bundles) @@ -184,7 +191,7 @@ async def test_passes_attempt_labels_to_inner(self, target, selector, seed_group assert labels[ADAPTIVE_TECHNIQUE_LABEL] == "a" assert labels[ADAPTIVE_ATTEMPT_LABEL] == "1" - async def test_uses_adaptive_context_from_label(self, target, selector, seed_group): + async def test_uses_adaptive_context_from_extractor(self, target, selector, seed_group): # Two techniques; one has been heavily rewarded under context "violence" only. bundles = { "a": _make_bundle(name="a", outcomes=[AttackOutcome.SUCCESS]), @@ -199,23 +206,22 @@ async def test_uses_adaptive_context_from_label(self, target, selector, seed_gro objective_target=target, techniques=bundles, selector=selector, - seed_group=seed_group, + context_extractor=harm_category_context, ) inner = _patch_inner(dispatcher=dispatcher, bundles=bundles) - ctx = _make_context(labels={ADAPTIVE_CONTEXT_LABEL: "violence"}) + ctx = _make_context(harm_categories=["violence"]) await dispatcher._perform_async(context=ctx) # Exploit should have picked "b" first. chosen_bundle = inner.call_args.kwargs["bundle"] assert chosen_bundle is bundles["b"] - async def test_falls_back_to_global_context_when_label_missing(self, target, selector, seed_group): + async def test_falls_back_to_global_context_with_default_extractor(self, target, selector, seed_group): bundles = {"a": _make_bundle(name="a", outcomes=[AttackOutcome.SUCCESS])} dispatcher = AdaptiveDispatchAttack( objective_target=target, techniques=bundles, selector=selector, - seed_group=seed_group, ) _patch_inner(dispatcher=dispatcher, bundles=bundles) await dispatcher._perform_async(context=_make_context(labels={})) @@ -229,7 +235,6 @@ async def test_metadata_records_adaptive_trail(self, target, selector, seed_grou objective_target=target, techniques=bundles, selector=selector, - seed_group=seed_group, max_attempts_per_objective=3, ) _patch_inner(dispatcher=dispatcher, bundles=bundles) @@ -243,22 +248,15 @@ async def test_metadata_records_adaptive_trail(self, target, selector, seed_grou assert result.metadata["adaptive_context"] == GLOBAL_CONTEXT async def test_returns_fresh_result_distinct_from_inner(self, target, selector, seed_group): - # The dispatcher must NOT return the inner attack's ``AttackResult`` - # instance — doing so would cause a duplicate-PK insert when both the - # inner and the dispatcher's ``execute_async`` post-execute hooks try - # to persist the same row. Verify the returned result has a fresh - # ``attack_result_id`` while preserving the inner's identifying fields - # and stamping the dispatch trail. bundles = {"a": _make_bundle(name="a", outcomes=[AttackOutcome.SUCCESS])} dispatcher = AdaptiveDispatchAttack( objective_target=target, techniques=bundles, selector=selector, - seed_group=seed_group, ) inner_ids: list[str] = [] - async def _spy(*, bundle, attempt_labels): + async def _spy(*, bundle, seed_group, attempt_labels): inner_result = AttackResult( conversation_id="conv-a-0", objective="obj", @@ -287,7 +285,6 @@ def test_validate_rejects_empty_objective(self, target, selector, seed_group, ba objective_target=target, techniques={"a": _make_bundle(name="a", outcomes=[AttackOutcome.SUCCESS])}, selector=selector, - seed_group=seed_group, ) with pytest.raises(ValueError, match="objective"): dispatcher._validate_context(context=_make_context(objective=bad_objective)) @@ -297,7 +294,6 @@ def test_validate_accepts_normal_objective(self, target, selector, seed_group): objective_target=target, techniques={"a": _make_bundle(name="a", outcomes=[AttackOutcome.SUCCESS])}, selector=selector, - seed_group=seed_group, ) # Does not raise. dispatcher._validate_context(context=_make_context(objective="ok")) diff --git a/tests/unit/scenario/scenarios/adaptive/test_selector.py b/tests/unit/scenario/scenarios/adaptive/test_epsilon_greedy.py similarity index 81% rename from tests/unit/scenario/scenarios/adaptive/test_selector.py rename to tests/unit/scenario/scenarios/adaptive/test_epsilon_greedy.py index 2daba3b70c..80c2eec8b6 100644 --- a/tests/unit/scenario/scenarios/adaptive/test_selector.py +++ b/tests/unit/scenario/scenarios/adaptive/test_epsilon_greedy.py @@ -1,48 +1,42 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -import random -from unittest.mock import MagicMock - import pytest -from pyrit.scenario.scenarios.adaptive.selector import ( +from pyrit.scenario.scenarios.adaptive.selectors import ( GLOBAL_CONTEXT, - UNCATEGORIZED_CONTEXT, - AdaptiveTechniqueSelector, - global_context, - harm_category_context, + EpsilonGreedyTechniqueSelector, ) TECHNIQUES = ["a", "b", "c", "d"] -def _seeded_selector(*, epsilon: float = 0.0, pool_threshold: int = 3, seed: int = 0) -> AdaptiveTechniqueSelector: - return AdaptiveTechniqueSelector( +def _seeded_selector(*, epsilon: float = 0.0, pool_threshold: int = 3, random_seed: int = 0) -> EpsilonGreedyTechniqueSelector: + return EpsilonGreedyTechniqueSelector( epsilon=epsilon, pool_threshold=pool_threshold, - rng=random.Random(seed), + random_seed=random_seed, ) -class TestAdaptiveTechniqueSelectorInit: +class TestEpsilonGreedyTechniqueSelectorInit: def test_init_defaults(self): - selector = AdaptiveTechniqueSelector() + selector = EpsilonGreedyTechniqueSelector() assert selector.snapshot() == {} @pytest.mark.parametrize("bad_epsilon", [-0.1, 1.1, 2.0, -1.0]) def test_init_rejects_out_of_range_epsilon(self, bad_epsilon): with pytest.raises(ValueError, match="epsilon"): - AdaptiveTechniqueSelector(epsilon=bad_epsilon) + EpsilonGreedyTechniqueSelector(epsilon=bad_epsilon) def test_init_rejects_pool_threshold_below_one(self): with pytest.raises(ValueError, match="pool_threshold"): - AdaptiveTechniqueSelector(pool_threshold=0) + EpsilonGreedyTechniqueSelector(pool_threshold=0) with pytest.raises(ValueError, match="pool_threshold"): - AdaptiveTechniqueSelector(pool_threshold=-1) + EpsilonGreedyTechniqueSelector(pool_threshold=-1) -class TestAdaptiveTechniqueSelectorSelect: +class TestEpsilonGreedyTechniqueSelectorSelect: def test_select_empty_techniques_raises(self): selector = _seeded_selector() with pytest.raises(ValueError, match="techniques"): @@ -52,7 +46,7 @@ def test_select_all_unseen_ties_resolved_randomly(self): # With epsilon=0 and an empty table, every technique has estimate 1/1=1.0, # so the result is the seeded random tiebreak. Different seeds should # be able to produce different winners. - winners = {_seeded_selector(seed=s).select(context=GLOBAL_CONTEXT, techniques=TECHNIQUES) for s in range(50)} + winners = {_seeded_selector(random_seed=s).select(context=GLOBAL_CONTEXT, techniques=TECHNIQUES) for s in range(50)} assert len(winners) > 1 assert winners.issubset(set(TECHNIQUES)) @@ -102,7 +96,7 @@ def test_select_cold_start_round_robins(self): assert sorted(tried) == sorted(TECHNIQUES) -class TestAdaptiveTechniqueSelectorUpdate: +class TestEpsilonGreedyTechniqueSelectorUpdate: def test_record_outcome_accumulates_counts(self): selector = _seeded_selector() selector.record_outcome(context="ctx", technique="a", success=True) @@ -139,7 +133,7 @@ def test_record_outcome_keeps_pooled_global_counts_in_sync(self): assert selector.success_rate(context="new_ctx", technique="c") == pytest.approx(1.0) -class TestAdaptiveTechniqueSelectorEstimate: +class TestEpsilonGreedyTechniqueSelectorEstimate: def test_success_rate_unseen_is_one(self): # Optimistic init: (0 + 1) / (0 + 1) = 1.0 selector = _seeded_selector() @@ -163,34 +157,7 @@ def test_success_rate_pools_when_below_threshold(self): assert selector.success_rate(context="ctx", technique="a") == pytest.approx(11 / 12) -class TestContextExtractors: - def test_global_context_is_constant(self): - sg = MagicMock() - assert global_context(sg) == GLOBAL_CONTEXT - - def test_harm_category_context_joins_sorted_categories(self): - sg = MagicMock() - sg.harm_categories = ["violence", "hate"] - # Multi-category seeds form their own bucket; sorting keeps the key deterministic. - assert harm_category_context(sg) == "hate|violence" - - def test_harm_category_context_single_category(self): - sg = MagicMock() - sg.harm_categories = ["violence"] - assert harm_category_context(sg) == "violence" - - def test_harm_category_context_falls_back_when_empty(self): - sg = MagicMock() - sg.harm_categories = [] - assert harm_category_context(sg) == UNCATEGORIZED_CONTEXT - - def test_harm_category_context_falls_back_when_none(self): - sg = MagicMock() - sg.harm_categories = None - assert harm_category_context(sg) == UNCATEGORIZED_CONTEXT - - -class TestAdaptiveTechniqueSelectorConcurrency: +class TestEpsilonGreedyTechniqueSelectorConcurrency: """Concurrent record_outcome / select calls must not corrupt counts.""" def test_concurrent_record_outcome_preserves_total_attempts(self): diff --git a/tests/unit/scenario/scenarios/adaptive/test_protocol.py b/tests/unit/scenario/scenarios/adaptive/test_protocol.py new file mode 100644 index 0000000000..5d9b764e76 --- /dev/null +++ b/tests/unit/scenario/scenarios/adaptive/test_protocol.py @@ -0,0 +1,46 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from unittest.mock import MagicMock + +from pyrit.scenario.scenarios.adaptive.selectors import ( + GLOBAL_CONTEXT, + UNCATEGORIZED_CONTEXT, + EpsilonGreedyTechniqueSelector, + TechniqueSelector, + global_context, + harm_category_context, +) + + +class TestTechniqueSelectorProtocol: + def test_implements_protocol(self): + selector = EpsilonGreedyTechniqueSelector() + assert isinstance(selector, TechniqueSelector) + + +class TestContextExtractors: + def test_global_context_is_constant(self): + sg = MagicMock() + assert global_context(sg) == GLOBAL_CONTEXT + + def test_harm_category_context_joins_sorted_categories(self): + sg = MagicMock() + sg.harm_categories = ["violence", "hate"] + # Multi-category seeds form their own bucket; sorting keeps the key deterministic. + assert harm_category_context(sg) == "hate|violence" + + def test_harm_category_context_single_category(self): + sg = MagicMock() + sg.harm_categories = ["violence"] + assert harm_category_context(sg) == "violence" + + def test_harm_category_context_falls_back_when_empty(self): + sg = MagicMock() + sg.harm_categories = [] + assert harm_category_context(sg) == UNCATEGORIZED_CONTEXT + + def test_harm_category_context_falls_back_when_none(self): + sg = MagicMock() + sg.harm_categories = None + assert harm_category_context(sg) == UNCATEGORIZED_CONTEXT diff --git a/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py b/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py index 12b1a45e28..13c8fd97b4 100644 --- a/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py +++ b/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py @@ -14,12 +14,11 @@ from pyrit.prompt_target import PromptTarget from pyrit.registry.object_registries.attack_technique_registry import AttackTechniqueRegistry from pyrit.scenario.core.dataset_configuration import DatasetConfiguration -from pyrit.scenario.core.scenario import BaselinePolicy +from pyrit.scenario.core.scenario import BaselineAttackPolicy from pyrit.scenario.scenarios.adaptive.dispatcher import ( - ADAPTIVE_CONTEXT_LABEL, AdaptiveDispatchAttack, ) -from pyrit.scenario.scenarios.adaptive.selector import ( +from pyrit.scenario.scenarios.adaptive.selectors import ( GLOBAL_CONTEXT, harm_category_context, ) @@ -110,8 +109,8 @@ class TestTextAdaptiveBasics: def test_version(self): assert TextAdaptive.VERSION == 1 - def test_baseline_forbidden(self): - assert TextAdaptive.BASELINE_POLICY is BaselinePolicy.Forbidden + def test_baseline_enabled(self): + assert TextAdaptive.BASELINE_ATTACK_POLICY is BaselineAttackPolicy.Enabled def test_default_dataset_config(self): config = TextAdaptive.default_dataset_config() @@ -134,16 +133,13 @@ def test_get_default_strategy(self): @patch("pyrit.scenario.core.scenario.Scenario._get_default_objective_scorer") def test_init_stores_adaptive_params(self, mock_get_scorer, mock_objective_scorer): mock_get_scorer.return_value = mock_objective_scorer - scenario = TextAdaptive( - epsilon=0.4, - pool_threshold=5, - max_attempts_per_objective=7, - seed=42, + scenario = TextAdaptive() + scenario.set_params_from_args( + args={ + "max_attempts_per_objective": 7, + } ) - assert scenario._epsilon == 0.4 - assert scenario._pool_threshold == 5 - assert scenario._max_attempts_per_objective == 7 - assert scenario._seed == 42 + assert scenario.params["max_attempts_per_objective"] == 7 @pytest.mark.usefixtures(*FIXTURES) @@ -169,7 +165,7 @@ async def _build_scenario_and_attacks( ) return scenario, await scenario._get_atomic_attacks_async() - async def test_one_atomic_per_objective(self, mock_objective_target, mock_objective_scorer): + async def test_one_atomic_per_dataset(self, mock_objective_target, mock_objective_scorer): groups = { "violence": [ _make_seed_group(value="obj-v1", harm_categories=["violence"]), @@ -184,10 +180,10 @@ async def test_one_atomic_per_objective(self, mock_objective_target, mock_object mock_objective_scorer=mock_objective_scorer, seed_groups=groups, ) - assert len(attacks) == 3 - for atomic in attacks: - # Each atomic carries exactly one seed group. - assert len(atomic.objectives) == 1 + # One atomic per dataset, carrying all that dataset's seed groups. + assert len(attacks) == 2 + total_seed_groups = sum(len(a.seed_groups) for a in attacks) + assert total_seed_groups == 3 async def test_atomics_share_one_selector_across_dispatchers(self, mock_objective_target, mock_objective_scorer): groups = { @@ -195,6 +191,9 @@ async def test_atomics_share_one_selector_across_dispatchers(self, mock_objectiv _make_seed_group(value="obj-v1", harm_categories=["violence"]), _make_seed_group(value="obj-v2", harm_categories=["violence"]), ], + "hate": [ + _make_seed_group(value="obj-h1", harm_categories=["hate"]), + ], } _scenario, attacks = await self._build_scenario_and_attacks( mock_objective_target=mock_objective_target, @@ -202,15 +201,17 @@ async def test_atomics_share_one_selector_across_dispatchers(self, mock_objectiv seed_groups=groups, ) dispatchers = [atomic._attack_technique.attack for atomic in attacks] - # Each objective gets its own dispatcher (bound to its own seed group)... + # One dispatcher per dataset (atomic). assert len({id(d) for d in dispatchers}) == len(attacks) for d in dispatchers: assert isinstance(d, AdaptiveDispatchAttack) - # ...but they all share the same selector so learning is global. + # All dispatchers share the same selector so learning is global. selectors = {id(d._selector) for d in dispatchers} assert len(selectors) == 1 - async def test_global_context_label_when_using_global_extractor(self, mock_objective_target, mock_objective_scorer): + async def test_default_context_extractor_is_global(self, mock_objective_target, mock_objective_scorer): + from pyrit.scenario.scenarios.adaptive.selectors import global_context + groups = { "violence": [_make_seed_group(value="obj-1", harm_categories=["violence"])], "hate": [_make_seed_group(value="obj-2", harm_categories=["hate"])], @@ -221,9 +222,14 @@ async def test_global_context_label_when_using_global_extractor(self, mock_objec seed_groups=groups, ) for atomic in attacks: - assert atomic._memory_labels[ADAPTIVE_CONTEXT_LABEL] == GLOBAL_CONTEXT - - async def test_harm_category_extractor_partitions_labels(self, mock_objective_target, mock_objective_scorer): + dispatcher = atomic._attack_technique.attack + # All seed groups in a global-extractor scenario resolve to the same + # context bucket regardless of harm category. + for sg in atomic.seed_groups: + assert dispatcher._context_extractor(sg) == GLOBAL_CONTEXT + assert dispatcher._context_extractor is global_context + + async def test_harm_category_extractor_partitions_contexts(self, mock_objective_target, mock_objective_scorer): groups = { "violence": [_make_seed_group(value="obj-v", harm_categories=["violence"])], "hate": [_make_seed_group(value="obj-h", harm_categories=["hate"])], @@ -235,21 +241,29 @@ async def test_harm_category_extractor_partitions_labels(self, mock_objective_ta seed_groups=groups, context_extractor=harm_category_context, ) - contexts = {atomic._memory_labels[ADAPTIVE_CONTEXT_LABEL] for atomic in attacks} - # Each objective gets its own context bucket from harm_category_context. + contexts: set[str] = set() + for atomic in attacks: + dispatcher = atomic._attack_technique.attack + assert dispatcher._context_extractor is harm_category_context + for sg in atomic.seed_groups: + contexts.add(dispatcher._context_extractor(sg)) + # Each harm category gets its own context bucket. assert contexts == {"violence", "hate", "_uncategorized"} - async def test_atomic_names_are_unique(self, mock_objective_target, mock_objective_scorer): + async def test_atomic_names_are_dataset_scoped(self, mock_objective_target, mock_objective_scorer): groups = { "violence": [_make_seed_group(value=f"obj-{i}", harm_categories=["violence"]) for i in range(5)], + "hate": [_make_seed_group(value=f"hate-{i}", harm_categories=["hate"]) for i in range(3)], } _scenario, attacks = await self._build_scenario_and_attacks( mock_objective_target=mock_objective_target, mock_objective_scorer=mock_objective_scorer, seed_groups=groups, ) - names = [atomic.atomic_attack_name for atomic in attacks] - assert len(set(names)) == len(names) + names = {atomic.atomic_attack_name for atomic in attacks} + # One atomic name per dataset; the dataset name is embedded. + assert len(names) == len(groups) + assert all(any(ds in name for ds in groups) for name in names) async def test_display_group_is_dataset_name(self, mock_objective_target, mock_objective_scorer): groups = { @@ -290,14 +304,13 @@ async def test_techniques_with_seed_technique_are_kept(self, mock_objective_targ patch.object(SeedAttackGroup, "is_compatible_with_technique", return_value=True), ): scenario = TextAdaptive(objective_scorer=mock_objective_scorer) - with patch.object( - scenario, - "_get_attack_technique_factories", - return_value={"prompt_sending": plain_factory, "many_shot": seeded_factory}, - ): + strategy_class = scenario.get_strategy_class() + factories = {"role_play": plain_factory, "many_shot": seeded_factory} + with patch.object(scenario, "_get_attack_technique_factories", return_value=factories): await scenario.initialize_async( objective_target=mock_objective_target, include_baseline=False, + scenario_strategies=[strategy_class("role_play"), strategy_class("many_shot")], ) attacks = scenario._atomic_attacks @@ -306,7 +319,7 @@ async def test_techniques_with_seed_technique_are_kept(self, mock_objective_targ assert isinstance(dispatcher, AdaptiveDispatchAttack) # Both factories survive; in particular the seeded one is no longer # silently dropped. - assert "prompt_sending" in dispatcher._techniques + assert "role_play" in dispatcher._techniques assert "many_shot" in dispatcher._techniques async def test_incompatible_seed_technique_is_filtered_per_objective( @@ -324,23 +337,26 @@ async def test_incompatible_seed_technique_is_filtered_per_objective( patch.object(SeedAttackGroup, "is_compatible_with_technique", return_value=False), ): scenario = TextAdaptive(objective_scorer=mock_objective_scorer) - with patch.object( - scenario, - "_get_attack_technique_factories", - return_value={"prompt_sending": plain_factory, "many_shot": incompatible_factory}, - ): + strategy_class = scenario.get_strategy_class() + factories = {"role_play": plain_factory, "many_shot": incompatible_factory} + with patch.object(scenario, "_get_attack_technique_factories", return_value=factories): await scenario.initialize_async( objective_target=mock_objective_target, include_baseline=False, + scenario_strategies=[strategy_class("role_play"), strategy_class("many_shot")], ) attacks = scenario._atomic_attacks assert len(attacks) == 1 dispatcher = attacks[0]._attack_technique.attack - # Only the plain technique survives; the seed_technique-bearing one is filtered out - # because is_compatible_with_technique returned False. - assert "prompt_sending" in dispatcher._techniques - assert "many_shot" not in dispatcher._techniques + # Under the one-atomic-per-dataset design, the full technique pool is + # shared by the dispatcher; per-call compatibility filtering now + # happens inside ``AdaptiveDispatchAttack._perform_async``. The seed + # group survived because the plain (no-seed_technique) factory keeps + # the compatible pool non-empty. + assert "role_play" in dispatcher._techniques + assert "many_shot" in dispatcher._techniques + assert len(attacks[0].seed_groups) == 1 async def test_objective_skipped_when_no_compatible_techniques( self, mock_objective_target, mock_objective_scorer, caplog @@ -364,10 +380,11 @@ def _selective_compat(self_group, *, technique): patch.object(SeedAttackGroup, "is_compatible_with_technique", _selective_compat), ): scenario = TextAdaptive(objective_scorer=mock_objective_scorer) + strategy_class = scenario.get_strategy_class() with patch.object( scenario, "_get_attack_technique_factories", - return_value={"prompt_sending": seeded_factory}, + return_value={"role_play": seeded_factory}, ): import logging @@ -375,6 +392,7 @@ def _selective_compat(self_group, *, technique): await scenario.initialize_async( objective_target=mock_objective_target, include_baseline=False, + scenario_strategies=[strategy_class("role_play")], ) attacks = scenario._atomic_attacks @@ -392,49 +410,46 @@ def _build_scenario_no_resume_id(self, *, scorer): return TextAdaptive(objective_scorer=scorer) def test_no_scenario_result_id_is_noop(self, mock_objective_scorer): - from pyrit.scenario.scenarios.adaptive.selector import AdaptiveTechniqueSelector + from pyrit.scenario.scenarios.adaptive.selectors import EpsilonGreedyTechniqueSelector scenario = TextAdaptive(objective_scorer=mock_objective_scorer) - selector = AdaptiveTechniqueSelector() + selector = EpsilonGreedyTechniqueSelector() # No scenario_result_id set -> early return, no errors, no replays. scenario._rehydrate_selector_from_memory(selector=selector, known_techniques={"a", "b"}) assert selector.snapshot() == {} def test_replays_attempts_from_metadata(self, mock_objective_scorer): from pyrit.models import AttackResult - from pyrit.scenario.scenarios.adaptive.selector import AdaptiveTechniqueSelector + from pyrit.scenario.scenarios.adaptive.selectors import EpsilonGreedyTechniqueSelector scenario = TextAdaptive(objective_scorer=mock_objective_scorer, scenario_result_id="rid") - prior_result = MagicMock() - prior_result.attack_results = { - "adaptive_violence_o1": [ - AttackResult( - conversation_id="c1", - objective="o1", - metadata={ - "adaptive_attempts": [ - {"technique": "a", "outcome": "failure"}, - {"technique": "b", "outcome": "success"}, - ], - "adaptive_context": "violence", - }, - ), - ], - "adaptive_hate_o2": [ - AttackResult( - conversation_id="c2", - objective="o2", - metadata={ - "adaptive_attempts": [{"technique": "a", "outcome": "success"}], - "adaptive_context": "hate", - }, - ), - ], - } - - selector = AdaptiveTechniqueSelector() - with patch.object(scenario._memory, "get_scenario_results", return_value=[prior_result]): + rows = [ + AttackResult( + conversation_id="c1", + objective="o1", + attribution_data={"parent_collection": "adaptive_violence"}, + metadata={ + "adaptive_attempts": [ + {"technique": "a", "outcome": "failure"}, + {"technique": "b", "outcome": "success"}, + ], + "adaptive_context": "violence", + }, + ), + AttackResult( + conversation_id="c2", + objective="o2", + attribution_data={"parent_collection": "adaptive_hate"}, + metadata={ + "adaptive_attempts": [{"technique": "a", "outcome": "success"}], + "adaptive_context": "hate", + }, + ), + ] + + selector = EpsilonGreedyTechniqueSelector() + with patch.object(scenario._memory, "get_attack_results", return_value=rows): scenario._rehydrate_selector_from_memory(selector=selector, known_techniques={"a", "b"}) # Trails replayed verbatim into the per-context table. @@ -444,28 +459,26 @@ def test_replays_attempts_from_metadata(self, mock_objective_scorer): def test_skips_unknown_techniques(self, mock_objective_scorer): from pyrit.models import AttackResult - from pyrit.scenario.scenarios.adaptive.selector import AdaptiveTechniqueSelector + from pyrit.scenario.scenarios.adaptive.selectors import EpsilonGreedyTechniqueSelector scenario = TextAdaptive(objective_scorer=mock_objective_scorer, scenario_result_id="rid") - prior_result = MagicMock() - prior_result.attack_results = { - "x": [ - AttackResult( - conversation_id="c1", - objective="o1", - metadata={ - "adaptive_attempts": [ - {"technique": "removed_technique", "outcome": "success"}, - {"technique": "a", "outcome": "failure"}, - ], - "adaptive_context": "ctx", - }, - ), - ], - } - - selector = AdaptiveTechniqueSelector() - with patch.object(scenario._memory, "get_scenario_results", return_value=[prior_result]): + rows = [ + AttackResult( + conversation_id="c1", + objective="o1", + attribution_data={"parent_collection": "adaptive_violence"}, + metadata={ + "adaptive_attempts": [ + {"technique": "removed_technique", "outcome": "success"}, + {"technique": "a", "outcome": "failure"}, + ], + "adaptive_context": "ctx", + }, + ), + ] + + selector = EpsilonGreedyTechniqueSelector() + with patch.object(scenario._memory, "get_attack_results", return_value=rows): scenario._rehydrate_selector_from_memory(selector=selector, known_techniques={"a"}) # Only the known technique was recorded. @@ -474,26 +487,30 @@ def test_skips_unknown_techniques(self, mock_objective_scorer): def test_ignores_results_without_adaptive_metadata(self, mock_objective_scorer): from pyrit.models import AttackResult - from pyrit.scenario.scenarios.adaptive.selector import AdaptiveTechniqueSelector + from pyrit.scenario.scenarios.adaptive.selectors import EpsilonGreedyTechniqueSelector scenario = TextAdaptive(objective_scorer=mock_objective_scorer, scenario_result_id="rid") - prior_result = MagicMock() - prior_result.attack_results = { - "baseline": [AttackResult(conversation_id="c", objective="o", metadata={})], - } - - selector = AdaptiveTechniqueSelector() - with patch.object(scenario._memory, "get_scenario_results", return_value=[prior_result]): + rows = [ + AttackResult( + conversation_id="c", + objective="o", + attribution_data={"parent_collection": "adaptive_violence"}, + metadata={}, + ), + ] + + selector = EpsilonGreedyTechniqueSelector() + with patch.object(scenario._memory, "get_attack_results", return_value=rows): scenario._rehydrate_selector_from_memory(selector=selector, known_techniques={"a"}) assert selector.snapshot() == {} def test_memory_load_failure_is_swallowed(self, mock_objective_scorer): - from pyrit.scenario.scenarios.adaptive.selector import AdaptiveTechniqueSelector + from pyrit.scenario.scenarios.adaptive.selectors import EpsilonGreedyTechniqueSelector scenario = TextAdaptive(objective_scorer=mock_objective_scorer, scenario_result_id="rid") - selector = AdaptiveTechniqueSelector() - with patch.object(scenario._memory, "get_scenario_results", side_effect=RuntimeError("db down")): + selector = EpsilonGreedyTechniqueSelector() + with patch.object(scenario._memory, "get_attack_results", side_effect=RuntimeError("db down")): # Must not raise; selector remains empty. scenario._rehydrate_selector_from_memory(selector=selector, known_techniques={"a"}) assert selector.snapshot() == {} @@ -501,12 +518,12 @@ def test_memory_load_failure_is_swallowed(self, mock_objective_scorer): @pytest.mark.usefixtures(*FIXTURES) class TestTextAdaptiveBaselinePolicy: - async def test_initialize_async_rejects_explicit_baseline(self, mock_objective_target, mock_objective_scorer): + async def test_initialize_async_accepts_explicit_baseline(self, mock_objective_target, mock_objective_scorer): groups = {"violence": [_make_seed_group(value="obj", harm_categories=["violence"])]} with patch.object(DatasetConfiguration, "get_seed_attack_groups", return_value=groups): scenario = TextAdaptive(objective_scorer=mock_objective_scorer) - with pytest.raises(ValueError): - await scenario.initialize_async( - objective_target=mock_objective_target, - include_baseline=True, - ) + # Baseline is Enabled by default, so explicit include_baseline=True must not raise. + await scenario.initialize_async( + objective_target=mock_objective_target, + include_baseline=True, + ) From 26cd65e130e48baf794dff69c035b36d39cff333 Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Thu, 21 May 2026 17:09:50 -0400 Subject: [PATCH 11/35] fix: address pre-commit lint failures - SIM108: use ternary for selector assignment - D101: add docstring to AdaptiveDispatchParams - DOC201/DOC501: add Returns/Raises sections to docstrings - TC003: move Sequence import into TYPE_CHECKING block - Fix trailing newline in epsilon_greedy.py Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- doc/code/scenarios/3_adaptive_scenarios.ipynb | 8 ++----- doc/code/scenarios/3_adaptive_scenarios.py | 8 ++----- .../scenarios/adaptive/adaptive_scenario.py | 10 ++++----- .../scenario/scenarios/adaptive/dispatcher.py | 21 ++++++++++++------- .../adaptive/selectors/epsilon_greedy.py | 11 ++++++---- .../scenarios/adaptive/test_epsilon_greedy.py | 8 +++++-- 6 files changed, 34 insertions(+), 32 deletions(-) diff --git a/doc/code/scenarios/3_adaptive_scenarios.ipynb b/doc/code/scenarios/3_adaptive_scenarios.ipynb index 88996496ea..f3972653e4 100644 --- a/doc/code/scenarios/3_adaptive_scenarios.ipynb +++ b/doc/code/scenarios/3_adaptive_scenarios.ipynb @@ -131,9 +131,7 @@ " context_extractor=harm_category_context,\n", " selector=EpsilonGreedyTechniqueSelector(epsilon=0.3, random_seed=42),\n", ")\n", - "configured_scenario.set_params_from_args(\n", - " args={\"max_attempts_per_objective\": 5}\n", - ")\n", + "configured_scenario.set_params_from_args(args={\"max_attempts_per_objective\": 5})\n", "\n", "await configured_scenario.initialize_async( # type: ignore\n", " objective_target=objective_target,\n", @@ -171,9 +169,7 @@ " selector=EpsilonGreedyTechniqueSelector(epsilon=0.3, random_seed=42),\n", " scenario_result_id=str(configured_result.id),\n", ")\n", - "resumed_scenario.set_params_from_args(\n", - " args={\"max_attempts_per_objective\": 5}\n", - ")\n", + "resumed_scenario.set_params_from_args(args={\"max_attempts_per_objective\": 5})\n", "\n", "await resumed_scenario.initialize_async( # type: ignore\n", " objective_target=objective_target,\n", diff --git a/doc/code/scenarios/3_adaptive_scenarios.py b/doc/code/scenarios/3_adaptive_scenarios.py index a0d38ec30a..e4c8e211ab 100644 --- a/doc/code/scenarios/3_adaptive_scenarios.py +++ b/doc/code/scenarios/3_adaptive_scenarios.py @@ -99,9 +99,7 @@ context_extractor=harm_category_context, selector=EpsilonGreedyTechniqueSelector(epsilon=0.3, random_seed=42), ) -configured_scenario.set_params_from_args( - args={"max_attempts_per_objective": 5} -) +configured_scenario.set_params_from_args(args={"max_attempts_per_objective": 5}) await configured_scenario.initialize_async( # type: ignore objective_target=objective_target, @@ -127,9 +125,7 @@ selector=EpsilonGreedyTechniqueSelector(epsilon=0.3, random_seed=42), scenario_result_id=str(configured_result.id), ) -resumed_scenario.set_params_from_args( - args={"max_attempts_per_objective": 5} -) +resumed_scenario.set_params_from_args(args={"max_attempts_per_objective": 5}) await resumed_scenario.initialize_async( # type: ignore objective_target=objective_target, diff --git a/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py b/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py index 862565d8ef..f961071d11 100644 --- a/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py +++ b/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py @@ -28,8 +28,8 @@ TechniqueBundle, ) from pyrit.scenario.scenarios.adaptive.selectors import ( - EpsilonGreedyTechniqueSelector, ContextExtractor, + EpsilonGreedyTechniqueSelector, TechniqueSelector, global_context, ) @@ -123,11 +123,9 @@ async def _get_atomic_attacks_async(self) -> list[AtomicAttack]: techniques = self._build_techniques_dict(objective_target=self._objective_target) - selector: TechniqueSelector - if self._custom_selector is not None: - selector = self._custom_selector - else: - selector = EpsilonGreedyTechniqueSelector() + selector: TechniqueSelector = ( + self._custom_selector if self._custom_selector is not None else EpsilonGreedyTechniqueSelector() + ) # On resume, replay prior attempt outcomes from persisted metadata. self._rehydrate_selector_from_memory(selector=selector, known_techniques=set(techniques)) diff --git a/pyrit/scenario/scenarios/adaptive/dispatcher.py b/pyrit/scenario/scenarios/adaptive/dispatcher.py index a8e671cdd0..cd26a3b361 100644 --- a/pyrit/scenario/scenarios/adaptive/dispatcher.py +++ b/pyrit/scenario/scenarios/adaptive/dispatcher.py @@ -70,6 +70,8 @@ class TechniqueBundle: @dataclass(frozen=True) class AdaptiveDispatchParams(AttackParameters): + """Attack parameters for adaptive dispatch, carrying the original seed group.""" + # The original SeedAttackGroup is preserved on the params so the # dispatcher can apply per-attempt seed_technique merging and derive # the per-call adaptive context. Captured by ``from_seed_group_async``; @@ -81,10 +83,10 @@ async def from_seed_group_async( cls, *, seed_group: SeedAttackGroup, - adversarial_chat: Optional["PromptTarget"] = None, # noqa: ARG003 — required by base class signature - objective_scorer: Optional["TrueFalseScorer"] = None, # noqa: ARG003 — required by base class signature + adversarial_chat: Optional[PromptTarget] = None, # noqa: ARG003 — required by base class signature + objective_scorer: Optional[TrueFalseScorer] = None, # noqa: ARG003 — required by base class signature **overrides: Any, - ) -> "AdaptiveDispatchParams": + ) -> AdaptiveDispatchParams: """ Build params for a single dispatch and capture the original seed_group. @@ -93,6 +95,12 @@ async def from_seed_group_async( expansion / next_message extraction: the inner technique runs through its own ``execute_attack_from_seed_groups_async`` call which performs that work using the technique-merged seed_group. + + Returns: + AdaptiveDispatchParams: The constructed parameters with the seed group attached. + + Raises: + ValueError: If the seed_group's objective is not initialized or invalid overrides are passed. """ if seed_group.objective is None: raise ValueError("seed_group.objective is not initialized") @@ -101,9 +109,7 @@ async def from_seed_group_async( valid_fields = {f.name for f in dataclasses.fields(cls)} - {"seed_group"} invalid = set(overrides.keys()) - valid_fields if invalid: - raise ValueError( - f"{cls.__name__} does not accept parameters: {invalid}. Accepted: {valid_fields}" - ) + raise ValueError(f"{cls.__name__} does not accept parameters: {invalid}. Accepted: {valid_fields}") return cls( objective=seed_group.objective.value, @@ -294,8 +300,7 @@ async def _perform_async(self, *, context: AdaptiveDispatchContext) -> AttackRes compatible_names = [ name for name, bundle in self._techniques.items() - if bundle.seed_technique is None - or seed_group.is_compatible_with_technique(technique=bundle.seed_technique) + if bundle.seed_technique is None or seed_group.is_compatible_with_technique(technique=bundle.seed_technique) ] if not compatible_names: raise ValueError( diff --git a/pyrit/scenario/scenarios/adaptive/selectors/epsilon_greedy.py b/pyrit/scenario/scenarios/adaptive/selectors/epsilon_greedy.py index ec6152097c..9ff7e9f6b3 100644 --- a/pyrit/scenario/scenarios/adaptive/selectors/epsilon_greedy.py +++ b/pyrit/scenario/scenarios/adaptive/selectors/epsilon_greedy.py @@ -9,15 +9,19 @@ import random import struct import threading -from collections.abc import Sequence +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Sequence def _derive_rng(random_seed: int | None, context: str, decision_key: str) -> random.Random: """ Derive a per-decision ``Random`` from ``(random_seed, context, decision_key)``. - Returns a fresh ``random.Random`` seeded deterministically from the - inputs when ``random_seed`` is not None, or an unseeded ``Random`` otherwise. + Returns: + random.Random: A fresh ``random.Random`` seeded deterministically from the + inputs when ``random_seed`` is not None, or an unseeded ``Random`` otherwise. """ if random_seed is None: return random.Random() @@ -176,4 +180,3 @@ def _estimate(self, *, context: str, technique: str) -> float: return (local_s + 1) / (local_n + 1) global_s, global_n = self._global_counts.get(technique, (0, 0)) return (global_s + 1) / (global_n + 1) - diff --git a/tests/unit/scenario/scenarios/adaptive/test_epsilon_greedy.py b/tests/unit/scenario/scenarios/adaptive/test_epsilon_greedy.py index 80c2eec8b6..42d6b14b4c 100644 --- a/tests/unit/scenario/scenarios/adaptive/test_epsilon_greedy.py +++ b/tests/unit/scenario/scenarios/adaptive/test_epsilon_greedy.py @@ -11,7 +11,9 @@ TECHNIQUES = ["a", "b", "c", "d"] -def _seeded_selector(*, epsilon: float = 0.0, pool_threshold: int = 3, random_seed: int = 0) -> EpsilonGreedyTechniqueSelector: +def _seeded_selector( + *, epsilon: float = 0.0, pool_threshold: int = 3, random_seed: int = 0 +) -> EpsilonGreedyTechniqueSelector: return EpsilonGreedyTechniqueSelector( epsilon=epsilon, pool_threshold=pool_threshold, @@ -46,7 +48,9 @@ def test_select_all_unseen_ties_resolved_randomly(self): # With epsilon=0 and an empty table, every technique has estimate 1/1=1.0, # so the result is the seeded random tiebreak. Different seeds should # be able to produce different winners. - winners = {_seeded_selector(random_seed=s).select(context=GLOBAL_CONTEXT, techniques=TECHNIQUES) for s in range(50)} + winners = { + _seeded_selector(random_seed=s).select(context=GLOBAL_CONTEXT, techniques=TECHNIQUES) for s in range(50) + } assert len(winners) > 1 assert winners.issubset(set(TECHNIQUES)) From b4db6a674735ff11dada052072cca026c3de11f0 Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Fri, 22 May 2026 18:20:15 -0400 Subject: [PATCH 12/35] Redesign TechniqueSelector: stateless, memory-backed, eval-hash keyed - Make TechniqueSelector stateless: queries memory instead of internal counts - Identify techniques by AttackTechnique eval hashes instead of names - Pre-select K techniques via num_top_techniques parameter - Add SelectorScope enum (ALL_RUNS / CURRENT_RUN) - Move ASR aggregation to pyrit/analytics/scenario_analysis.py - Rename protocol.py to technique_selector.py - Remove ContextExtractor, SelectorContext, redundant VERSION/BASELINE_ATTACK_POLICY - Add scanner CLI section to notebook - Rename number_to_get to num_top_techniques - Remove label_key from user-facing API (hardcode ADAPTIVE_TECHNIQUE_LABEL) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- doc/code/scenarios/3_adaptive_scenarios.ipynb | 78 ++++-- doc/code/scenarios/3_adaptive_scenarios.py | 51 ++-- pyrit/analytics/scenario_analysis.py | 67 +++++ pyrit/scenario/scenarios/adaptive/__init__.py | 10 +- .../scenarios/adaptive/adaptive_scenario.py | 115 ++------ .../scenario/scenarios/adaptive/dispatcher.py | 94 +++---- .../scenarios/adaptive/selectors/__init__.py | 18 +- .../adaptive/selectors/epsilon_greedy.py | 204 ++++++-------- .../scenarios/adaptive/selectors/protocol.py | 66 ----- .../adaptive/selectors/technique_selector.py | 64 +++++ .../scenarios/adaptive/text_adaptive.py | 11 +- .../unit/analytics/test_scenario_analysis.py | 111 ++++++++ .../scenarios/adaptive/test_dispatcher.py | 134 ++++----- .../scenarios/adaptive/test_epsilon_greedy.py | 260 +++++++----------- .../scenarios/adaptive/test_protocol.py | 46 ---- .../adaptive/test_technique_selector.py | 13 + .../scenarios/adaptive/test_text_adaptive.py | 173 +----------- 17 files changed, 655 insertions(+), 860 deletions(-) create mode 100644 pyrit/analytics/scenario_analysis.py delete mode 100644 pyrit/scenario/scenarios/adaptive/selectors/protocol.py create mode 100644 pyrit/scenario/scenarios/adaptive/selectors/technique_selector.py create mode 100644 tests/unit/analytics/test_scenario_analysis.py delete mode 100644 tests/unit/scenario/scenarios/adaptive/test_protocol.py create mode 100644 tests/unit/scenario/scenarios/adaptive/test_technique_selector.py diff --git a/doc/code/scenarios/3_adaptive_scenarios.ipynb b/doc/code/scenarios/3_adaptive_scenarios.ipynb index f3972653e4..f52ddf1be7 100644 --- a/doc/code/scenarios/3_adaptive_scenarios.ipynb +++ b/doc/code/scenarios/3_adaptive_scenarios.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "markdown", - "id": "0", + "id": "94e7f44a", "metadata": {}, "source": [ "# Adaptive Scenarios\n", @@ -39,7 +39,7 @@ }, { "cell_type": "markdown", - "id": "1", + "id": "cb716650", "metadata": {}, "source": [ "## Setup" @@ -48,7 +48,7 @@ { "cell_type": "code", "execution_count": null, - "id": "2", + "id": "4b536900", "metadata": {}, "outputs": [], "source": [ @@ -57,7 +57,7 @@ "from pyrit.registry import TargetRegistry\n", "from pyrit.scenario import DatasetConfiguration\n", "from pyrit.scenario.printer.console_printer import ConsoleScenarioResultPrinter\n", - "from pyrit.scenario.scenarios.adaptive import TextAdaptive, harm_category_context\n", + "from pyrit.scenario.scenarios.adaptive import TextAdaptive\n", "from pyrit.setup import initialize_from_config_async\n", "\n", "await initialize_from_config_async(config_path=Path(\"../../scanner/pyrit_conf.yaml\")) # type: ignore\n", @@ -68,19 +68,19 @@ }, { "cell_type": "markdown", - "id": "3", + "id": "9f9ff786", "metadata": {}, "source": [ "## Basic usage\n", "\n", "Defaults: `max_attempts_per_objective=3`, epsilon-greedy selector with `epsilon=0.2`,\n", - "the subclass’s default datasets." + "the subclass's default datasets." ] }, { "cell_type": "code", "execution_count": null, - "id": "4", + "id": "33aa89d3", "metadata": {}, "outputs": [], "source": [ @@ -95,7 +95,7 @@ }, { "cell_type": "markdown", - "id": "5", + "id": "5083bbed", "metadata": {}, "source": [ "## Configuring a run\n", @@ -103,13 +103,9 @@ "- **`max_attempts_per_objective`** — caps techniques tried per objective. Higher means\n", " more chances to succeed and more API calls. Set via `set_params_from_args`.\n", "- **`selector`** — a pre-built `TechniqueSelector` instance. Pass an\n", - " `EpsilonGreedyTechniqueSelector(epsilon=..., pool_threshold=..., random_seed=...)`\n", - " to tune the selection algorithm. Defaults to `EpsilonGreedyTechniqueSelector()`\n", - " (`epsilon=0.2`, `pool_threshold=3`).\n", - "- **`context_extractor`** — partitions the success-rate table. The default\n", - " `global_context` keeps one shared table; `harm_category_context` learns each harm\n", - " category independently. Custom callables of type `Callable[[SeedAttackGroup], str]`\n", - " are supported.\n", + " `EpsilonGreedyTechniqueSelector(epsilon=..., random_seed=...)`\n", + " to tune the selection algorithm. Defaults to an epsilon-greedy selector with\n", + " `epsilon=0.2`.\n", "- **`scenario_strategies`** (on `initialize_async`) — restricts which techniques the\n", " selector can pick from. Use `TextAdaptive.get_strategy_class()` to access the enum.\n", "\n", @@ -119,7 +115,7 @@ { "cell_type": "code", "execution_count": null, - "id": "6", + "id": "db966395", "metadata": {}, "outputs": [], "source": [ @@ -128,8 +124,10 @@ "strategy_class = TextAdaptive.get_strategy_class()\n", "\n", "configured_scenario = TextAdaptive(\n", - " context_extractor=harm_category_context,\n", - " selector=EpsilonGreedyTechniqueSelector(epsilon=0.3, random_seed=42),\n", + " selector=EpsilonGreedyTechniqueSelector(\n", + " epsilon=0.3,\n", + " random_seed=42,\n", + " ),\n", ")\n", "configured_scenario.set_params_from_args(args={\"max_attempts_per_objective\": 5})\n", "\n", @@ -147,26 +145,28 @@ }, { "cell_type": "markdown", - "id": "7", + "id": "ba7e7126", "metadata": {}, "source": [ "## Resuming a run\n", "\n", "Adaptive scenarios are resumable — pass `scenario_result_id=...` to the `TextAdaptive`\n", - "constructor and the run picks up where it left off, with prior outcomes replayed into\n", - "the selector. Resume must use the same configuration as the original run." + "constructor and the run picks up where it left off. Resume must use the same\n", + "configuration as the original run." ] }, { "cell_type": "code", "execution_count": null, - "id": "8", + "id": "4857bace", "metadata": {}, "outputs": [], "source": [ "resumed_scenario = TextAdaptive(\n", - " context_extractor=harm_category_context,\n", - " selector=EpsilonGreedyTechniqueSelector(epsilon=0.3, random_seed=42),\n", + " selector=EpsilonGreedyTechniqueSelector(\n", + " epsilon=0.3,\n", + " random_seed=42,\n", + " ),\n", " scenario_result_id=str(configured_result.id),\n", ")\n", "resumed_scenario.set_params_from_args(args={\"max_attempts_per_objective\": 5})\n", @@ -185,14 +185,13 @@ }, { "cell_type": "markdown", - "id": "9", + "id": "e267467c", "metadata": {}, "source": [ "## Inspecting which techniques were tried\n", "\n", "The dispatcher stamps every objective's `AttackResult.metadata` with:\n", "\n", - "- `adaptive_context` — the bucket key from the `context_extractor`.\n", "- `adaptive_attempts` — the ordered list of `{\"technique\", \"outcome\"}` dicts\n", " recording exactly which techniques the selector picked and what happened.\n", "\n", @@ -202,7 +201,7 @@ { "cell_type": "code", "execution_count": null, - "id": "10", + "id": "3a95436b", "metadata": {}, "outputs": [], "source": [ @@ -229,6 +228,31 @@ "for technique, n in picks.most_common():\n", " print(f\"{technique:20s} {wins[technique]:>4} / {n:<4} {wins[technique] / n:.0%}\")" ] + }, + { + "cell_type": "markdown", + "id": "37cd0756", + "metadata": {}, + "source": [ + "## Running from the scanner CLI\n", + "\n", + "You can run `TextAdaptive` directly from the `pyrit_scan` CLI without writing Python:\n", + "\n", + "```bash\n", + "# Basic run with defaults\n", + "pyrit_scan --scenario TextAdaptive --target openai_chat\n", + "\n", + "# Tune max attempts and restrict strategies\n", + "pyrit_scan --scenario TextAdaptive --target openai_chat \\\n", + " --params max_attempts_per_objective=5 \\\n", + " --strategies single_turn\n", + "\n", + "# Use specific datasets and limit size\n", + "pyrit_scan --scenario TextAdaptive --target openai_chat \\\n", + " --datasets airt_hate airt_violence \\\n", + " --max-dataset-size 10\n", + "```" + ] } ], "metadata": { diff --git a/doc/code/scenarios/3_adaptive_scenarios.py b/doc/code/scenarios/3_adaptive_scenarios.py index e4c8e211ab..8826c42406 100644 --- a/doc/code/scenarios/3_adaptive_scenarios.py +++ b/doc/code/scenarios/3_adaptive_scenarios.py @@ -49,7 +49,7 @@ from pyrit.registry import TargetRegistry from pyrit.scenario import DatasetConfiguration from pyrit.scenario.printer.console_printer import ConsoleScenarioResultPrinter -from pyrit.scenario.scenarios.adaptive import TextAdaptive, harm_category_context +from pyrit.scenario.scenarios.adaptive import TextAdaptive from pyrit.setup import initialize_from_config_async await initialize_from_config_async(config_path=Path("../../scanner/pyrit_conf.yaml")) # type: ignore @@ -77,14 +77,10 @@ # # - **`max_attempts_per_objective`** — caps techniques tried per objective. Higher means # more chances to succeed and more API calls. Set via `set_params_from_args`. -# - **`selector`** — a pre-built ``TechniqueSelector`` instance. Pass an -# ``EpsilonGreedyTechniqueSelector(epsilon=..., pool_threshold=..., random_seed=...)`` -# to tune the selection algorithm. Defaults to ``EpsilonGreedyTechniqueSelector()`` -# (``epsilon=0.2``, ``pool_threshold=3``). -# - **`context_extractor`** — partitions the success-rate table. The default -# `global_context` keeps one shared table; `harm_category_context` learns each harm -# category independently. Custom callables of type `Callable[[SeedAttackGroup], str]` -# are supported. +# - **`selector`** — a pre-built `TechniqueSelector` instance. Pass an +# `EpsilonGreedyTechniqueSelector(epsilon=..., random_seed=...)` +# to tune the selection algorithm. Defaults to an epsilon-greedy selector with +# `epsilon=0.2`. # - **`scenario_strategies`** (on `initialize_async`) — restricts which techniques the # selector can pick from. Use `TextAdaptive.get_strategy_class()` to access the enum. # @@ -96,8 +92,10 @@ strategy_class = TextAdaptive.get_strategy_class() configured_scenario = TextAdaptive( - context_extractor=harm_category_context, - selector=EpsilonGreedyTechniqueSelector(epsilon=0.3, random_seed=42), + selector=EpsilonGreedyTechniqueSelector( + epsilon=0.3, + random_seed=42, + ), ) configured_scenario.set_params_from_args(args={"max_attempts_per_objective": 5}) @@ -116,13 +114,15 @@ # ## Resuming a run # # Adaptive scenarios are resumable — pass `scenario_result_id=...` to the `TextAdaptive` -# constructor and the run picks up where it left off, with prior outcomes replayed into -# the selector. Resume must use the same configuration as the original run. +# constructor and the run picks up where it left off. Resume must use the same +# configuration as the original run. # %% resumed_scenario = TextAdaptive( - context_extractor=harm_category_context, - selector=EpsilonGreedyTechniqueSelector(epsilon=0.3, random_seed=42), + selector=EpsilonGreedyTechniqueSelector( + epsilon=0.3, + random_seed=42, + ), scenario_result_id=str(configured_result.id), ) resumed_scenario.set_params_from_args(args={"max_attempts_per_objective": 5}) @@ -143,7 +143,6 @@ # # The dispatcher stamps every objective's `AttackResult.metadata` with: # -# - `adaptive_context` — the bucket key from the `context_extractor`. # - `adaptive_attempts` — the ordered list of `{"technique", "outcome"}` dicts # recording exactly which techniques the selector picked and what happened. # @@ -172,3 +171,23 @@ print("\nTechnique wins / picks rate") for technique, n in picks.most_common(): print(f"{technique:20s} {wins[technique]:>4} / {n:<4} {wins[technique] / n:.0%}") + +# %% [markdown] +# ## Running from the scanner CLI +# +# You can run `TextAdaptive` directly from the `pyrit_scan` CLI without writing Python: +# +# ```bash +# # Basic run with defaults +# pyrit_scan --scenario TextAdaptive --target openai_chat +# +# # Tune max attempts and restrict strategies +# pyrit_scan --scenario TextAdaptive --target openai_chat \ +# --params max_attempts_per_objective=5 \ +# --strategies single_turn +# +# # Use specific datasets and limit size +# pyrit_scan --scenario TextAdaptive --target openai_chat \ +# --datasets airt_hate airt_violence \ +# --max-dataset-size 10 +# ``` diff --git a/pyrit/analytics/scenario_analysis.py b/pyrit/analytics/scenario_analysis.py new file mode 100644 index 0000000000..0903231f8e --- /dev/null +++ b/pyrit/analytics/scenario_analysis.py @@ -0,0 +1,67 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""Scenario-level analytics: technique success rates and related helpers.""" + +from __future__ import annotations + +from collections.abc import Sequence + +from pyrit.analytics.result_analysis import AttackStats, _compute_stats +from pyrit.memory import CentralMemory +from pyrit.models import AttackOutcome + + +def compute_technique_success_rates( + *, + technique_hashes: Sequence[str], + label_key: str, + scenario_result_id: str | None = None, +) -> dict[str, AttackStats]: + """ + Query memory for historical success rates grouped by technique eval hash. + + Fetches all ``AttackResult`` rows whose memory labels contain + ``label_key`` matching one of ``technique_hashes``, then aggregates + outcomes into per-technique :class:`AttackStats`. + + By default queries across all scenario runs. Pass ``scenario_result_id`` + to restrict to a single run. + + Args: + technique_hashes (Sequence[str]): Technique eval hashes to query. + label_key (str): Memory-label key that stores the technique hash. + scenario_result_id (str | None): If provided, restrict results to + a single scenario run. Defaults to ``None`` (all runs). + + Returns: + dict[str, AttackStats]: Stats per technique hash. Techniques with + no history are omitted from the result. + """ + + memory = CentralMemory.get_memory_instance() + results = memory.get_attack_results( + labels={label_key: list(technique_hashes)}, + scenario_result_id=scenario_result_id, + ) + + counts: dict[str, tuple[int, int, int, int]] = {} + for result in results: + technique = result.labels.get(label_key) + if not technique or technique not in technique_hashes: + continue + + s, f, u, e = counts.get(technique, (0, 0, 0, 0)) + if result.outcome == AttackOutcome.SUCCESS: + counts[technique] = (s + 1, f, u, e) + elif result.outcome == AttackOutcome.FAILURE: + counts[technique] = (s, f + 1, u, e) + elif result.outcome == AttackOutcome.ERROR: + counts[technique] = (s, f, u, e + 1) + else: + counts[technique] = (s, f, u + 1, e) + + stats: dict[str, AttackStats] = {} + for technique, (s, f, u, e) in counts.items(): + stats[technique] = _compute_stats(successes=s, failures=f, undetermined=u, errors=e) + return stats diff --git a/pyrit/scenario/scenarios/adaptive/__init__.py b/pyrit/scenario/scenarios/adaptive/__init__.py index 6ba7415632..440e43a86a 100644 --- a/pyrit/scenario/scenarios/adaptive/__init__.py +++ b/pyrit/scenario/scenarios/adaptive/__init__.py @@ -5,28 +5,22 @@ from pyrit.scenario.scenarios.adaptive.adaptive_scenario import AdaptiveScenario from pyrit.scenario.scenarios.adaptive.dispatcher import ( - ADAPTIVE_CONTEXT_LABEL, AdaptiveDispatchAttack, AdaptiveDispatchParams, ) from pyrit.scenario.scenarios.adaptive.selectors import ( - ContextExtractor, EpsilonGreedyTechniqueSelector, + SelectorScope, TechniqueSelector, - global_context, - harm_category_context, ) from pyrit.scenario.scenarios.adaptive.text_adaptive import TextAdaptive __all__ = [ - "ADAPTIVE_CONTEXT_LABEL", "AdaptiveDispatchAttack", "AdaptiveDispatchParams", "AdaptiveScenario", - "ContextExtractor", "EpsilonGreedyTechniqueSelector", + "SelectorScope", "TechniqueSelector", "TextAdaptive", - "global_context", - "harm_category_context", ] diff --git a/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py b/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py index f961071d11..15d5fa6ad3 100644 --- a/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py +++ b/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py @@ -5,10 +5,10 @@ ``AdaptiveScenario`` — modality-agnostic base for scenarios that pick attack techniques per-objective using a ``TechniqueSelector``. -Owns selector wiring, dispatcher construction, per-dataset atomic-attack -emission, and resume rehydration. Concrete subclasses (``TextAdaptive``, -future ``ImageAdaptive`` / ``AudioAdaptive``) only declare strategy class, -default datasets, version, and atomic-attack prefix. +Owns selector wiring, dispatcher construction, and per-dataset atomic-attack +emission. Concrete subclasses (``TextAdaptive``, future ``ImageAdaptive`` / +``AudioAdaptive``) only declare strategy class, default datasets, version, +and atomic-attack prefix. Baseline policy is ``Enabled``: prompt_sending runs as a separate baseline comparison and is excluded from the adaptive technique pool. @@ -22,16 +22,14 @@ from pyrit.executor.attack import AttackScoringConfig from pyrit.scenario.core.atomic_attack import AtomicAttack from pyrit.scenario.core.attack_technique import AttackTechnique -from pyrit.scenario.core.scenario import BaselineAttackPolicy, Scenario +from pyrit.scenario.core.scenario import Scenario from pyrit.scenario.scenarios.adaptive.dispatcher import ( AdaptiveDispatchAttack, TechniqueBundle, ) from pyrit.scenario.scenarios.adaptive.selectors import ( - ContextExtractor, EpsilonGreedyTechniqueSelector, TechniqueSelector, - global_context, ) if TYPE_CHECKING: @@ -47,15 +45,12 @@ class AdaptiveScenario(Scenario): Abstract base for adaptive (epsilon-greedy) scenarios. Subclasses must implement the standard ``Scenario`` class-method overrides - and declare ``VERSION`` and ``_atomic_attack_prefix``. Selector wiring, - dispatcher construction, per-objective atomic-attack emission, and resume - rehydration are handled here. + and declare ``_atomic_attack_prefix``. Selector wiring + and dispatcher construction are handled here. """ - BASELINE_ATTACK_POLICY: ClassVar[BaselineAttackPolicy] = BaselineAttackPolicy.Enabled - - #: Subclasses must declare a scenario version for memory bookkeeping. - VERSION: ClassVar[int] + #: Scenario version for memory bookkeeping. + VERSION: ClassVar[int] = 1 #: Prefix for per-objective atomic-attack names (e.g. ``"adaptive_text"``). _atomic_attack_prefix: ClassVar[str] = "adaptive" @@ -64,7 +59,6 @@ def __init__( self, *, objective_scorer: TrueFalseScorer | None = None, - context_extractor: ContextExtractor = global_context, selector: TechniqueSelector | None = None, scenario_result_id: str | None = None, ) -> None: @@ -72,8 +66,6 @@ def __init__( Args: objective_scorer (TrueFalseScorer | None): Scorer used to judge each response. Defaults to the composite scorer from the base class. - context_extractor (ContextExtractor): Maps a ``SeedAttackGroup`` to a - context key. Defaults to ``global_context``. selector (TechniqueSelector | None): Pre-built selector. When ``None`` (default) an :class:`EpsilonGreedyTechniqueSelector` is created with default settings. @@ -83,7 +75,6 @@ def __init__( objective_scorer = self._get_default_objective_scorer() self._objective_scorer: TrueFalseScorer = objective_scorer - self._context_extractor = context_extractor self._custom_selector = selector super().__init__( @@ -105,14 +96,9 @@ async def _get_atomic_attacks_async(self) -> list[AtomicAttack]: dispatchers across all datasets share one ``TechniqueSelector`` instance so learning accumulates globally. - Seed groups whose objective is incompatible with every technique are - dropped up-front with a warning so the dispatcher never sees an empty - compatible pool at run time. - Returns: list[AtomicAttack]: One ``AtomicAttack`` per dataset that has at - least one compatible seed group. Empty if every seed group is - incompatible with every selected technique. + least one compatible seed group. Raises: ValueError: If ``self._objective_target`` is not set, or if @@ -124,10 +110,10 @@ async def _get_atomic_attacks_async(self) -> list[AtomicAttack]: techniques = self._build_techniques_dict(objective_target=self._objective_target) selector: TechniqueSelector = ( - self._custom_selector if self._custom_selector is not None else EpsilonGreedyTechniqueSelector() + self._custom_selector + if self._custom_selector is not None + else EpsilonGreedyTechniqueSelector() ) - # On resume, replay prior attempt outcomes from persisted metadata. - self._rehydrate_selector_from_memory(selector=selector, known_techniques=set(techniques)) seed_groups_by_dataset = self._dataset_config.get_seed_attack_groups() atomic_attacks: list[AtomicAttack] = [] @@ -149,14 +135,19 @@ def _build_techniques_dict( objective_target: PromptTarget, ) -> dict[str, TechniqueBundle]: """ - Resolve selected strategies into a ``{name: TechniqueBundle}`` map. + Resolve selected strategies into a ``{eval_hash: TechniqueBundle}`` map. Each bundle carries the inner attack strategy along with the factory's ``seed_technique`` and ``adversarial_chat`` so the dispatcher can reproduce the static ``AtomicAttack`` execution path per attempt. + Technique keys are eval hashes derived from the ``AttackTechnique`` + identity (strategy + seed_technique configuration). This allows the + selector and analytics to track techniques by their behavioral + configuration rather than by name alone. + Returns: - dict[str, TechniqueBundle]: Mapping from technique name to its + dict[str, TechniqueBundle]: Mapping from technique eval hash to its bundle, in the order selected strategies were resolved. Raises: @@ -179,8 +170,10 @@ def _build_techniques_dict( objective_target=objective_target, attack_scoring_config=scoring_config, ) - techniques[technique_name] = TechniqueBundle( + eval_hash = technique.get_identifier().hash + techniques[eval_hash] = TechniqueBundle( attack=technique.attack, + name=technique_name, seed_technique=technique.seed_technique, adversarial_chat=factory.adversarial_chat, ) @@ -245,9 +238,9 @@ def _build_atomic_for_dataset( objective_target=self._objective_target, techniques=techniques, selector=selector, - context_extractor=self._context_extractor, objective_scorer=self._objective_scorer, max_attempts_per_objective=self.params["max_attempts_per_objective"], + scenario_result_id=self._scenario_result_id, ) return AtomicAttack( @@ -259,63 +252,3 @@ def _build_atomic_for_dataset( display_group=dataset_name, ) - def _rehydrate_selector_from_memory( - self, - *, - selector: TechniqueSelector, - known_techniques: set[str], - ) -> None: - """ - Replay persisted dispatch trails into ``selector`` so resume - preserves learned state. - - Queries ``AttackResultEntry`` rows directly by ``scenario_result_id`` - (which selects on ``attribution_parent_id`` stamped at write time by - ``AtomicAttack``'s attribution path) and filters to rows belonging to - this scenario's adaptive atomic attacks via - ``attribution_data["parent_collection"]``. - - Args: - selector (TechniqueSelector): A freshly built selector to populate. - known_techniques (set[str]): Techniques available in the current run. - Trails referencing unknown techniques (e.g. after a strategies - change) are skipped so replay can't poison the table. - """ - if not self._scenario_result_id: - return - - # Narrow to errors a memory backend would plausibly raise (DB/IO - # failures, integrity issues). Programmer-level errors propagate. - try: - rows = self._memory.get_attack_results(scenario_result_id=self._scenario_result_id) - except (RuntimeError, OSError, ValueError) as exc: - logger.warning(f"AdaptiveScenario: failed to load prior attack results for rehydration: {exc}") - return - - adaptive_prefix = f"{self._atomic_attack_prefix}_" - replayed = 0 - for result in rows: - if result.attribution_data is None: - continue - collection = result.attribution_data.get("parent_collection") - if not collection or not collection.startswith(adaptive_prefix): - continue - metadata = result.metadata or {} - trail = metadata.get("adaptive_attempts") - context = metadata.get("adaptive_context") - if not trail or not context: - continue - for step in trail: - technique = step.get("technique") - outcome = step.get("outcome") - if not technique or technique not in known_techniques: - continue - selector.record_outcome( - context=context, - technique=technique, - success=outcome == "success", - ) - replayed += 1 - - if replayed: - logger.info(f"AdaptiveScenario: rehydrated selector with {replayed} prior attempt(s).") diff --git a/pyrit/scenario/scenarios/adaptive/dispatcher.py b/pyrit/scenario/scenarios/adaptive/dispatcher.py index cd26a3b361..6a49cacafc 100644 --- a/pyrit/scenario/scenarios/adaptive/dispatcher.py +++ b/pyrit/scenario/scenarios/adaptive/dispatcher.py @@ -2,17 +2,12 @@ # Licensed under the MIT license. """ -``AdaptiveDispatchAttack`` — picks an inner technique per attempt via a -``TechniqueSelector``, runs it, records the outcome, and loops up to -``max_attempts_per_objective`` times. - -The dispatcher is shared across all seed groups in an enclosing -``AtomicAttack`` and reads the per-call ``SeedAttackGroup`` from -``AdaptiveDispatchParams.seed_group`` (populated by -``AdaptiveDispatchParams.from_seed_group_async``). It computes the per-call -adaptive context key via the injected ``ContextExtractor`` and merges each -chosen technique's ``seed_technique`` (when present) into the seed group -before delegating execution to ``AttackExecutor``. +``AdaptiveDispatchAttack`` — picks inner techniques per objective via a +``TechniqueSelector``, runs them in priority order, and stops on success. + +The selector is stateless and async: it queries memory for historical +success rates. The dispatcher pre-selects up to ``max_attempts_per_objective`` +techniques at the start of each objective, then iterates through them. """ from __future__ import annotations @@ -29,10 +24,9 @@ from pyrit.executor.attack.core.attack_strategy import AttackContext, AttackStrategy from pyrit.models import AttackOutcome, AttackResult, SeedAttackGroup from pyrit.scenario.scenarios.adaptive.selectors import ( - ContextExtractor, TechniqueSelector, - global_context, ) +from pyrit.scenario.scenarios.adaptive.selectors.technique_selector import ADAPTIVE_TECHNIQUE_LABEL if TYPE_CHECKING: from pyrit.models import SeedAttackTechniqueGroup @@ -43,12 +37,7 @@ # Memory-label keys stamped onto persisted prompt rows so adaptive attempts -# can be filtered/grouped after a run. The dispatcher stamps all three on -# each attempt (context derived per-call from the seed group). -ADAPTIVE_CONTEXT_LABEL: str = "_adaptive_context" -"""Per-objective context key (e.g. ``"_global"`` or a harm category).""" -ADAPTIVE_TECHNIQUE_LABEL: str = "_adaptive_technique" -"""Technique chosen by the dispatcher for a given attempt.""" +# can be filtered/grouped after a run. ADAPTIVE_ATTEMPT_LABEL: str = "_adaptive_attempt" """1-based attempt index within the per-objective loop.""" @@ -64,6 +53,7 @@ class TechniqueBundle: """ attack: AttackStrategy[Any, AttackResult] + name: str = "" seed_technique: SeedAttackTechniqueGroup | None = None adversarial_chat: PromptTarget | None = None @@ -159,23 +149,22 @@ def __init__( objective_target: PromptTarget, techniques: dict[str, TechniqueBundle], selector: TechniqueSelector, - context_extractor: ContextExtractor = global_context, objective_scorer: TrueFalseScorer | None = None, max_attempts_per_objective: int = 3, + scenario_result_id: str | None = None, ) -> None: """ Args: objective_target (PromptTarget): The target inner attacks run against. - Stored for identifier/logging parity; not called directly. - techniques (dict[str, TechniqueBundle]): Mapping from technique name to - its bundle (attack, seed_technique, adversarial_chat). Must be non-empty. - selector (TechniqueSelector): Shared selector state. - context_extractor (ContextExtractor): Maps a per-call ``SeedAttackGroup`` to - the adaptive context key used by the selector. Defaults to ``global_context``. + techniques (dict[str, TechniqueBundle]): Mapping from technique eval hash to + its bundle (attack, name, seed_technique, adversarial_chat). Must be non-empty. + selector (TechniqueSelector): Stateless technique selector. objective_scorer (TrueFalseScorer | None): Scorer passed through to techniques that generate simulated conversations. max_attempts_per_objective (int): Max attempts per objective; >= 1. Defaults to 3. + scenario_result_id (str | None): If provided, passed to the selector + to scope memory queries to this scenario run. Defaults to ``None``. Raises: ValueError: If ``techniques`` is empty or ``max_attempts_per_objective`` < 1. @@ -193,12 +182,9 @@ def __init__( ) self._techniques = techniques self._selector = selector - self._context_extractor = context_extractor self._objective_scorer = objective_scorer self._max_attempts = max_attempts_per_objective - # Attempts are inherently sequential (each one reads the selector - # state updated by the previous), so a single shared executor with - # ``max_concurrency=1`` is reused across attempts. + self._scenario_result_id = scenario_result_id self._executor = AttackExecutor(max_concurrency=1) def _validate_context(self, *, context: AdaptiveDispatchContext) -> None: @@ -268,12 +254,9 @@ async def _perform_async(self, *, context: AdaptiveDispatchContext) -> AttackRes """ Run the per-objective adaptive loop. - Reads the per-call ``SeedAttackGroup`` from ``context.params.seed_group``, - derives the adaptive context key via the injected ``ContextExtractor``, - and filters the technique pool to those whose ``seed_technique`` is - compatible with this seed group. Then loops up to - ``max_attempts_per_objective`` times: select a technique, execute it, - record the outcome, and stop early on success. + Pre-selects up to ``max_attempts_per_objective`` techniques via the + stateless selector, then iterates in priority order. Stops early on + success. Args: context (AdaptiveDispatchContext): Execution context whose @@ -281,8 +264,7 @@ async def _perform_async(self, *, context: AdaptiveDispatchContext) -> AttackRes Returns: AttackResult: A fresh dispatcher-owned copy of the final inner - result with the dispatch trail stamped onto ``metadata`` - (see class docstring for the two-row persistence note). + result with the dispatch trail stamped onto ``metadata``. Raises: ValueError: If ``context.params.seed_group`` is missing, or if no @@ -308,55 +290,44 @@ async def _perform_async(self, *, context: AdaptiveDispatchContext) -> AttackRes f"(objective={seed_group.objective.value!r})." ) - adaptive_context = self._context_extractor(seed_group) + chosen_techniques = await self._selector.select_async( + technique_identifiers=compatible_names, + objective=context.objective, + num_top_techniques=self._max_attempts, + scenario_result_id=self._scenario_result_id, + ) last_result: AttackResult | None = None trail: list[dict[str, str]] = [] - for attempt_idx in range(self._max_attempts): - decision_key = f"{context.objective}:{attempt_idx}" - chosen = self._selector.select( - context=adaptive_context, - techniques=compatible_names, - decision_key=decision_key, - ) + for attempt_idx, chosen in enumerate(chosen_techniques): bundle = self._techniques[chosen] attempt_labels = { **context.memory_labels, - ADAPTIVE_CONTEXT_LABEL: adaptive_context, ADAPTIVE_TECHNIQUE_LABEL: chosen, ADAPTIVE_ATTEMPT_LABEL: str(attempt_idx + 1), } logger.debug( - "AdaptiveDispatchAttack: attempt %d/%d context=%r technique=%r", + "AdaptiveDispatchAttack: attempt %d/%d technique=%r (hash=%s)", attempt_idx + 1, - self._max_attempts, - adaptive_context, + len(chosen_techniques), + bundle.name, chosen, ) result = await self._run_inner_attack_async( bundle=bundle, seed_group=seed_group, attempt_labels=attempt_labels ) - success = result.outcome == AttackOutcome.SUCCESS - self._selector.record_outcome(context=adaptive_context, technique=chosen, success=success) - trail.append({"technique": chosen, "outcome": result.outcome.value}) + trail.append({"technique": bundle.name, "technique_hash": chosen, "outcome": result.outcome.value}) last_result = result - if success: + if result.outcome == AttackOutcome.SUCCESS: break - # ``max_attempts`` is validated >= 1, so the loop always runs at least - # once. Guard explicitly rather than with ``assert`` (stripped under -O). if last_result is None: # pragma: no cover - defensive raise RuntimeError("AdaptiveDispatchAttack ran zero attempts; this should be unreachable.") - # Return a fresh dispatcher-owned ``AttackResult``: the inner attack - # already persisted ``last_result`` via its own post-execute hook, so - # returning it directly would cause a PK conflict on the outer hook. - # ``dataclasses.replace`` copies every field; we override identity - # fields and stamp the trail onto metadata. return replace( last_result, attack_result_id=str(uuid.uuid4()), @@ -364,6 +335,5 @@ async def _perform_async(self, *, context: AdaptiveDispatchContext) -> AttackRes metadata={ **last_result.metadata, "adaptive_attempts": trail, - "adaptive_context": adaptive_context, }, ) diff --git a/pyrit/scenario/scenarios/adaptive/selectors/__init__.py b/pyrit/scenario/scenarios/adaptive/selectors/__init__.py index 7e97f19400..9fe4c2d3c7 100644 --- a/pyrit/scenario/scenarios/adaptive/selectors/__init__.py +++ b/pyrit/scenario/scenarios/adaptive/selectors/__init__.py @@ -1,26 +1,20 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -"""Selector protocol, context extractors, and selector implementations.""" +"""Selector protocol and selector implementations.""" from pyrit.scenario.scenarios.adaptive.selectors.epsilon_greedy import ( EpsilonGreedyTechniqueSelector, ) -from pyrit.scenario.scenarios.adaptive.selectors.protocol import ( - GLOBAL_CONTEXT, - UNCATEGORIZED_CONTEXT, - ContextExtractor, +from pyrit.scenario.scenarios.adaptive.selectors.technique_selector import ( + ADAPTIVE_TECHNIQUE_LABEL, + SelectorScope, TechniqueSelector, - global_context, - harm_category_context, ) __all__ = [ - "ContextExtractor", + "ADAPTIVE_TECHNIQUE_LABEL", "EpsilonGreedyTechniqueSelector", - "GLOBAL_CONTEXT", + "SelectorScope", "TechniqueSelector", - "UNCATEGORIZED_CONTEXT", - "global_context", - "harm_category_context", ] diff --git a/pyrit/scenario/scenarios/adaptive/selectors/epsilon_greedy.py b/pyrit/scenario/scenarios/adaptive/selectors/epsilon_greedy.py index 9ff7e9f6b3..662ff3cfbc 100644 --- a/pyrit/scenario/scenarios/adaptive/selectors/epsilon_greedy.py +++ b/pyrit/scenario/scenarios/adaptive/selectors/epsilon_greedy.py @@ -6,18 +6,21 @@ from __future__ import annotations import hashlib +import logging import random import struct -import threading -from typing import TYPE_CHECKING +from collections.abc import Sequence -if TYPE_CHECKING: - from collections.abc import Sequence +from pyrit.analytics.result_analysis import AttackStats +from pyrit.analytics.scenario_analysis import compute_technique_success_rates +from pyrit.scenario.scenarios.adaptive.selectors.technique_selector import ADAPTIVE_TECHNIQUE_LABEL, SelectorScope +logger = logging.getLogger(__name__) -def _derive_rng(random_seed: int | None, context: str, decision_key: str) -> random.Random: + +def _derive_rng(random_seed: int | None, decision_key: str) -> random.Random: """ - Derive a per-decision ``Random`` from ``(random_seed, context, decision_key)``. + Derive a per-decision ``Random`` from ``(random_seed, decision_key)``. Returns: random.Random: A fresh ``random.Random`` seeded deterministically from the @@ -25,158 +28,131 @@ def _derive_rng(random_seed: int | None, context: str, decision_key: str) -> ran """ if random_seed is None: return random.Random() - digest = hashlib.sha256(f"{random_seed}|{context}|{decision_key}".encode()).digest() + digest = hashlib.sha256(f"{random_seed}|{decision_key}".encode()).digest() derived_seed = struct.unpack(" (successes, attempts)`` table. With - probability ``epsilon`` picks uniformly at random; otherwise picks the - technique with the highest Laplace-smoothed estimate ``(s + 1) / (n + 1)`` - (unseen techniques start at 1.0). A ``(context, technique)`` cell with - fewer than ``pool_threshold`` attempts falls back to the technique's - pooled rate across all contexts. - - Each ``select`` call derives a per-decision ``Random`` from - ``(random_seed, context, decision_key)`` so that resume produces deterministic - decisions without persisting RNG state. - - All public methods are guarded by a ``threading.Lock`` so concurrent - callers cannot corrupt the table. The lock makes individual ops atomic, - not the overall select → execute → record sequence. + Stateless epsilon-greedy selector over attack techniques. + + Queries memory for historical success rates and applies epsilon-greedy + selection. With probability ``epsilon`` picks uniformly at random; + otherwise picks the technique with the highest Laplace-smoothed estimate + ``(s + 1) / (n + 1)`` (unseen techniques start at 1.0). + + The selector is **stateless** — it does not maintain internal counts. + All outcome data comes from the memory database via + ``_compute_success_rates``. Calling ``select_async`` with the same + arguments produces the same result (deterministic given memory + contents and ``random_seed``). """ - # Tolerance for tiebreaking on float estimates (current estimates are exact - # rationals; this guards against future estimator changes). _TIE_TOL: float = 1e-12 def __init__( self, *, epsilon: float = 0.2, - pool_threshold: int = 3, + scope: SelectorScope = SelectorScope.ALL_RUNS, random_seed: int | None = None, ) -> None: """ Args: epsilon (float): Exploration probability in [0.0, 1.0]. Defaults to 0.2. - pool_threshold (int): Minimum per-(context, technique) attempts before - the local estimate replaces the pooled rate. Must be >= 1; set to 1 - to disable pooling. Defaults to 3. - random_seed (int | None): Base seed for deterministic per-decision RNG derivation. - Defaults to ``None`` (non-deterministic). + scope (SelectorScope): Whether to use all historical data or only + the current scenario run. Defaults to ``SelectorScope.ALL_RUNS``. + random_seed (int | None): Base seed for deterministic per-decision RNG + derivation. Defaults to ``None`` (non-deterministic). Raises: - ValueError: If ``epsilon`` is outside [0.0, 1.0] or ``pool_threshold`` < 1. + ValueError: If ``epsilon`` is outside [0.0, 1.0]. """ if not 0.0 <= epsilon <= 1.0: raise ValueError(f"epsilon must be in [0.0, 1.0], got {epsilon}") - if pool_threshold < 1: - raise ValueError(f"pool_threshold must be >= 1, got {pool_threshold}") self._epsilon = epsilon - self._pool_threshold = pool_threshold + self._scope = scope self._seed = random_seed - self._counts: dict[tuple[str, str], tuple[int, int]] = {} - # Per-technique pooled counts, kept in sync with ``_counts`` so the - # pooled-backoff branch in ``_estimate`` is O(1). - self._global_counts: dict[str, tuple[int, int]] = {} - # Monotonic counter for auto-generating decision keys when the caller - # doesn't provide one. - self._decision_counter: int = 0 - # Guards _counts, _global_counts, and _decision_counter against concurrent callers. - self._lock = threading.Lock() - - def select(self, *, context: str, techniques: Sequence[str], decision_key: str = "") -> str: + + async def select_async( + self, + *, + technique_identifiers: Sequence[str], + objective: str, + num_top_techniques: int = 1, + scenario_result_id: str | None = None, + ) -> Sequence[str]: """ - Pick the next technique to try for ``context``. + Return up to ``num_top_techniques`` techniques in priority order. Args: - context (str): The context key. - techniques (Sequence[str]): Candidate technique names. - decision_key (str): Caller-supplied key (e.g. ``"obj_id:attempt_idx"``) - used to derive a per-decision RNG for deterministic replay. - Defaults to ``""`` (auto-incremented counter). + technique_identifiers (Sequence[str]): Available technique names. + objective (str): The objective text for scoping the per-decision RNG. + num_top_techniques (int): Max techniques to return. Defaults to 1. + scenario_result_id (str | None): If provided, restrict memory + queries to this scenario run. Defaults to ``None`` (all runs). Returns: - str: The chosen technique name. + Sequence[str]: Techniques in priority order. Fewer than + ``num_top_techniques`` if not enough techniques are available. Raises: - ValueError: If ``techniques`` is empty. + ValueError: If ``technique_identifiers`` is empty. """ - technique_list = list(techniques) + technique_list = list(technique_identifiers) if not technique_list: - raise ValueError("techniques must contain at least one entry") + raise ValueError("technique_identifiers must contain at least one entry") - with self._lock: - if decision_key: - effective_key = decision_key - else: - effective_key = str(self._decision_counter) - self._decision_counter += 1 - rng = _derive_rng(self._seed, context, effective_key) + num_top_techniques = min(num_top_techniques, len(technique_list)) + + decision_key = objective + rng = _derive_rng(self._seed, decision_key) + + stats = compute_technique_success_rates( + technique_hashes=technique_list, + label_key=ADAPTIVE_TECHNIQUE_LABEL, + scenario_result_id=scenario_result_id if self._scope == SelectorScope.CURRENT_RUN else None, + ) + + chosen: list[str] = [] + remaining = list(technique_list) + + for _ in range(num_top_techniques): + if not remaining: + break if rng.random() < self._epsilon: - return rng.choice(technique_list) + pick = rng.choice(remaining) + else: + estimates = { + t: self._estimate(technique=t, stats=stats) for t in remaining + } + best = max(estimates.values()) + winners = [t for t, v in estimates.items() if v >= best - self._TIE_TOL] + pick = rng.choice(winners) - estimates = {t: self._estimate(context=context, technique=t) for t in technique_list} - best = max(estimates.values()) - winners = [t for t, value in estimates.items() if value >= best - self._TIE_TOL] - return rng.choice(winners) + chosen.append(pick) + remaining.remove(pick) - def record_outcome(self, *, context: str, technique: str, success: bool) -> None: - """ - Record the outcome of an attempt. + return chosen - Args: - context (str): The context key the decision was made under. - technique (str): The technique that was tried. - success (bool): Whether the attempt succeeded. + @staticmethod + def _estimate(*, technique: str, stats: dict[str, AttackStats]) -> float: """ - with self._lock: - successes, attempts = self._counts.get((context, technique), (0, 0)) - attempts += 1 - if success: - successes += 1 - self._counts[(context, technique)] = (successes, attempts) - - global_successes, global_attempts = self._global_counts.get(technique, (0, 0)) - global_attempts += 1 - if success: - global_successes += 1 - self._global_counts[technique] = (global_successes, global_attempts) - - def success_rate(self, *, context: str, technique: str) -> float: - """Return the Laplace-smoothed estimate ``(s + 1) / (n + 1)`` used for exploitation.""" - with self._lock: - return self._estimate(context=context, technique=technique) - - def counts(self, *, context: str, technique: str) -> tuple[int, int]: - """Return raw ``(successes, attempts)`` for a ``(context, technique)`` cell.""" - with self._lock: - return self._counts.get((context, technique), (0, 0)) - - def snapshot(self) -> dict[tuple[str, str], tuple[int, int]]: - """Return a shallow copy of the full counts table (for logging/debug).""" - with self._lock: - return dict(self._counts) - - def _estimate(self, *, context: str, technique: str) -> float: - """ - Estimate for ``(context, technique)``; falls back to pooled rate below - ``pool_threshold`` local attempts. + Laplace-smoothed success-rate estimate for a technique. + + Unseen techniques get ``(0 + 1) / (0 + 1) = 1.0`` (optimistic init). - Callers must already hold ``self._lock``. + Args: + technique (str): The technique name. + stats (dict[str, AttackStats]): Pre-computed stats from memory. Returns: - float: Laplace-smoothed success-rate estimate in ``(0, 1)``. + float: Estimated success rate in ``(0, 1]``. """ - local_s, local_n = self._counts.get((context, technique), (0, 0)) - if local_n >= self._pool_threshold: - return (local_s + 1) / (local_n + 1) - global_s, global_n = self._global_counts.get(technique, (0, 0)) - return (global_s + 1) / (global_n + 1) + technique_stats = stats.get(technique) + if technique_stats is None or technique_stats.total_decided == 0: + return 1.0 + return (technique_stats.successes + 1) / (technique_stats.total_decided + 1) diff --git a/pyrit/scenario/scenarios/adaptive/selectors/protocol.py b/pyrit/scenario/scenarios/adaptive/selectors/protocol.py deleted file mode 100644 index e8c8c640f8..0000000000 --- a/pyrit/scenario/scenarios/adaptive/selectors/protocol.py +++ /dev/null @@ -1,66 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -"""Selector protocol and context extractors for adaptive scenarios.""" - -from __future__ import annotations - -from collections.abc import Callable, Sequence -from typing import TYPE_CHECKING, Protocol, runtime_checkable - -if TYPE_CHECKING: - from pyrit.models.seeds.seed_attack_group import SeedAttackGroup - -ContextExtractor = Callable[["SeedAttackGroup"], str] -"""Maps a ``SeedAttackGroup`` to an adaptive context key.""" - -GLOBAL_CONTEXT: str = "_global" -"""Default context: all objectives share one selection table.""" - -UNCATEGORIZED_CONTEXT: str = "_uncategorized" -"""Fallback context for seed groups with no harm category metadata.""" - - -def global_context(_seed_attack_group: SeedAttackGroup) -> str: - """ - Return a single shared context for all objectives. - - Returns: - str: Always :data:`GLOBAL_CONTEXT`. - """ - return GLOBAL_CONTEXT - - -def harm_category_context(seed_attack_group: SeedAttackGroup) -> str: - """ - Return a context keyed by the sorted, ``|``-joined harm categories. - - Multi-category seeds form their own bucket; sorting makes the key deterministic. - - Returns: - str: The ``|``-joined sorted harm categories, or :data:`UNCATEGORIZED_CONTEXT` - when the seed group has no categories. - """ - categories = seed_attack_group.harm_categories - if not categories: - return UNCATEGORIZED_CONTEXT - return "|".join(sorted(categories)) - - -@runtime_checkable -class TechniqueSelector(Protocol): - """ - Protocol for adaptive technique selectors. - - Any object implementing ``select`` and ``record_outcome`` can serve as - the selector for an ``AdaptiveScenario``. The epsilon-greedy - implementation (:class:`EpsilonGreedyTechniqueSelector`) is the default. - """ - - def select(self, *, context: str, techniques: Sequence[str], decision_key: str = "") -> str: - """Pick the next technique to try for ``context``.""" - ... # pragma: no cover - - def record_outcome(self, *, context: str, technique: str, success: bool) -> None: - """Record the outcome of an attempt.""" - ... # pragma: no cover diff --git a/pyrit/scenario/scenarios/adaptive/selectors/technique_selector.py b/pyrit/scenario/scenarios/adaptive/selectors/technique_selector.py new file mode 100644 index 0000000000..d87adb1a28 --- /dev/null +++ b/pyrit/scenario/scenarios/adaptive/selectors/technique_selector.py @@ -0,0 +1,64 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""Technique selector protocol for adaptive scenarios.""" + +from __future__ import annotations + +from collections.abc import Sequence +from enum import Enum +from typing import Protocol, runtime_checkable + + +# TODO: probably want to expand this to allow for more filtering options +# (e.g. filter by scenario parameters, attack labels, etc.) +class SelectorScope(str, Enum): + """Controls which historical data a selector queries.""" + + ALL_RUNS = "all_runs" + """Use technique success rates from all historical scenario runs.""" + + CURRENT_RUN = "current_run" + """Use technique success rates only from the current scenario run.""" + + +ADAPTIVE_TECHNIQUE_LABEL: str = "_adaptive_technique" +"""Memory-label key the dispatcher stamps on each attack result to record +which technique was used.""" + + +@runtime_checkable +class TechniqueSelector(Protocol): + """ + Protocol for adaptive technique selectors. + + Selectors are **stateless** — they query memory for historical success + rates rather than maintaining internal counts. Calling ``select_async`` + with the same arguments twice should yield the same answer + (deterministic given memory contents). + """ + + async def select_async( + self, + *, + technique_identifiers: Sequence[str], + objective: str, + num_top_techniques: int = 1, + scenario_result_id: str | None = None, + ) -> Sequence[str]: + """ + Return techniques in priority order (try first, try second, …). + + Args: + technique_identifiers (Sequence[str]): Available technique names. + objective (str): The objective text for this selection. + num_top_techniques (int): Max techniques to return. Defaults to 1. + scenario_result_id (str | None): The current scenario run ID, + provided by the dispatcher. Selectors use this when their + scope is ``SelectorScope.CURRENT_RUN``. + + Returns: + Sequence[str]: Up to ``num_top_techniques`` technique names in + priority order. Fewer if not enough techniques are available. + """ + ... # pragma: no cover diff --git a/pyrit/scenario/scenarios/adaptive/text_adaptive.py b/pyrit/scenario/scenarios/adaptive/text_adaptive.py index c1d1e588a7..a9fb12a037 100644 --- a/pyrit/scenario/scenarios/adaptive/text_adaptive.py +++ b/pyrit/scenario/scenarios/adaptive/text_adaptive.py @@ -22,9 +22,7 @@ from pyrit.scenario.core.dataset_configuration import DatasetConfiguration from pyrit.scenario.scenarios.adaptive.adaptive_scenario import AdaptiveScenario from pyrit.scenario.scenarios.adaptive.selectors import ( - ContextExtractor, TechniqueSelector, - global_context, ) if TYPE_CHECKING: @@ -73,8 +71,6 @@ class TextAdaptive(AdaptiveScenario): comparison and is excluded from the adaptive technique pool. """ - VERSION: int = 1 - _atomic_attack_prefix: ClassVar[str] = "adaptive" _cached_strategy_class: ClassVar[type[ScenarioStrategy] | None] = None @classmethod @@ -130,7 +126,6 @@ def __init__( self, *, objective_scorer: TrueFalseScorer | None = None, - context_extractor: ContextExtractor = global_context, selector: TechniqueSelector | None = None, scenario_result_id: str | None = None, ) -> None: @@ -138,18 +133,14 @@ def __init__( Args: objective_scorer (TrueFalseScorer | None): Scorer used to judge each response. Defaults to the composite scorer from the base class. - context_extractor (ContextExtractor): Maps a ``SeedAttackGroup`` to a - context key. Defaults to ``global_context``. Use - ``harm_category_context`` to partition by harm category. selector (TechniqueSelector | None): Pre-built selector. When ``None`` (default) an :class:`EpsilonGreedyTechniqueSelector` is created with default settings. Pass a custom instance to tune - ``epsilon``, ``pool_threshold``, or ``random_seed``. + ``epsilon`` or ``random_seed``. scenario_result_id (str | None): ID of an existing ``ScenarioResult`` to resume. """ super().__init__( objective_scorer=objective_scorer, - context_extractor=context_extractor, selector=selector, scenario_result_id=scenario_result_id, ) diff --git a/tests/unit/analytics/test_scenario_analysis.py b/tests/unit/analytics/test_scenario_analysis.py new file mode 100644 index 0000000000..0678f89df7 --- /dev/null +++ b/tests/unit/analytics/test_scenario_analysis.py @@ -0,0 +1,111 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from unittest.mock import MagicMock, patch + +import pytest + +from pyrit.analytics.scenario_analysis import compute_technique_success_rates +from pyrit.models import AttackOutcome + + +LABEL_KEY = "_adaptive_technique" + + +def _make_result(*, technique: str, outcome: AttackOutcome) -> MagicMock: + r = MagicMock() + r.labels = {LABEL_KEY: technique} + r.outcome = outcome + return r + + +@pytest.fixture(autouse=True) +def _patch_memory(): + mock_memory = MagicMock() + mock_memory.get_attack_results.return_value = [] + with patch("pyrit.memory.CentralMemory") as cm: + cm.get_memory_instance.return_value = mock_memory + yield mock_memory + + +class TestComputeTechniqueSuccessRates: + + def test_empty_results_returns_empty(self, _patch_memory): + stats = compute_technique_success_rates(technique_hashes=["a", "b"], label_key=LABEL_KEY) + assert stats == {} + + def test_counts_successes_and_failures(self, _patch_memory): + _patch_memory.get_attack_results.return_value = [ + _make_result(technique="a", outcome=AttackOutcome.SUCCESS), + _make_result(technique="a", outcome=AttackOutcome.SUCCESS), + _make_result(technique="a", outcome=AttackOutcome.FAILURE), + _make_result(technique="b", outcome=AttackOutcome.FAILURE), + ] + + stats = compute_technique_success_rates(technique_hashes=["a", "b"], label_key=LABEL_KEY) + + assert stats["a"].successes == 2 + assert stats["a"].failures == 1 + assert stats["a"].total_decided == 3 + assert stats["b"].successes == 0 + assert stats["b"].failures == 1 + + def test_counts_errors_and_undetermined(self, _patch_memory): + _patch_memory.get_attack_results.return_value = [ + _make_result(technique="a", outcome=AttackOutcome.ERROR), + _make_result(technique="a", outcome=AttackOutcome.UNDETERMINED), + ] + + stats = compute_technique_success_rates(technique_hashes=["a"], label_key=LABEL_KEY) + + assert stats["a"].errors == 1 + assert stats["a"].undetermined == 1 + + def test_ignores_techniques_not_in_requested_list(self, _patch_memory): + _patch_memory.get_attack_results.return_value = [ + _make_result(technique="a", outcome=AttackOutcome.SUCCESS), + _make_result(technique="c", outcome=AttackOutcome.SUCCESS), + ] + + stats = compute_technique_success_rates(technique_hashes=["a", "b"], label_key=LABEL_KEY) + + assert "a" in stats + assert "c" not in stats + + def test_passes_label_key_to_memory_query(self, _patch_memory): + custom_key = "my_custom_key" + compute_technique_success_rates(technique_hashes=["x"], label_key=custom_key) + + call_kwargs = _patch_memory.get_attack_results.call_args[1] + assert call_kwargs["labels"] == {custom_key: ["x"]} + assert call_kwargs["scenario_result_id"] is None + + def test_passes_scenario_result_id_to_memory_query(self, _patch_memory): + compute_technique_success_rates( + technique_hashes=["x"], label_key=LABEL_KEY, scenario_result_id="run-123" + ) + + call_kwargs = _patch_memory.get_attack_results.call_args[1] + assert call_kwargs["scenario_result_id"] == "run-123" + + def test_omits_techniques_with_no_history(self, _patch_memory): + _patch_memory.get_attack_results.return_value = [ + _make_result(technique="a", outcome=AttackOutcome.SUCCESS), + ] + + stats = compute_technique_success_rates(technique_hashes=["a", "b"], label_key=LABEL_KEY) + + assert "a" in stats + assert "b" not in stats + + def test_success_rate_computed(self, _patch_memory): + _patch_memory.get_attack_results.return_value = [ + _make_result(technique="a", outcome=AttackOutcome.SUCCESS), + _make_result(technique="a", outcome=AttackOutcome.SUCCESS), + _make_result(technique="a", outcome=AttackOutcome.FAILURE), + _make_result(technique="a", outcome=AttackOutcome.FAILURE), + ] + + stats = compute_technique_success_rates(technique_hashes=["a"], label_key=LABEL_KEY) + + assert stats["a"].success_rate == pytest.approx(0.5) diff --git a/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py b/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py index 963d6bd634..9c6ba29fb8 100644 --- a/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py +++ b/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py @@ -15,22 +15,16 @@ TechniqueBundle, ) from pyrit.scenario.scenarios.adaptive.selectors import ( - GLOBAL_CONTEXT, EpsilonGreedyTechniqueSelector, - harm_category_context, ) def _make_bundle(*, name: str, outcomes: list[AttackOutcome], seed_technique=None) -> TechniqueBundle: - """Build a TechniqueBundle whose attack stub yields the given outcomes in order. - - The dispatcher routes execution through ``_run_inner_attack_async``; tests - patch that method directly so we only need a placeholder attack here. - """ + """Build a TechniqueBundle whose attack stub yields the given outcomes in order.""" attack = MagicMock(name=f"attack-{name}") attack._outcomes = outcomes attack._name = name - return TechniqueBundle(attack=attack, seed_technique=seed_technique) + return TechniqueBundle(attack=attack, name=name, seed_technique=seed_technique) def _make_context( @@ -56,12 +50,7 @@ def _patch_inner( dispatcher: AdaptiveDispatchAttack, bundles: dict[str, TechniqueBundle], ) -> AsyncMock: - """Replace ``_run_inner_attack_async`` with a stub backed by per-bundle outcomes. - - Returns the AsyncMock so tests can introspect call history (kwargs include - ``bundle`` and ``attempt_labels``). - """ - # Each call consumes one outcome from the chosen bundle's deque. + """Replace ``_run_inner_attack_async`` with a stub backed by per-bundle outcomes.""" name_for_attack = {id(b.attack): name for name, b in bundles.items()} counters: dict[str, int] = dict.fromkeys(bundles, 0) @@ -81,10 +70,26 @@ async def _stub(*, bundle: TechniqueBundle, seed_group, attempt_labels: dict[str return inner_mock +class _StubSelector: + """A deterministic selector stub that returns techniques in the order given.""" + + def __init__(self, *, technique_order: list[str]): + self._order = technique_order + + async def select_async( + self, + *, + technique_identifiers, + objective: str, + num_top_techniques: int = 1, + scenario_result_id: str | None = None, + ): + return self._order[:num_top_techniques] + + @pytest.fixture -def selector() -> EpsilonGreedyTechniqueSelector: - # epsilon=0 makes selection deterministic given the table. - return EpsilonGreedyTechniqueSelector(epsilon=0.0, pool_threshold=1, random_seed=0) +def selector(): + return _StubSelector(technique_order=["a", "b", "c"]) @pytest.fixture @@ -105,6 +110,8 @@ def test_init_rejects_empty_techniques(self, target, selector, seed_group): objective_target=target, techniques={}, selector=selector, + + ) @pytest.mark.parametrize("bad_max", [0, -1]) @@ -115,21 +122,26 @@ def test_init_rejects_invalid_max_attempts(self, target, selector, seed_group, b objective_target=target, techniques={"a": _make_bundle(name="a", outcomes=[AttackOutcome.SUCCESS])}, selector=selector, + + max_attempts_per_objective=bad_max, ) @pytest.mark.usefixtures("patch_central_database") class TestPerform: - async def test_stops_on_first_success(self, target, selector, seed_group): + async def test_stops_on_first_success(self, target, seed_group): bundles = { "a": _make_bundle(name="a", outcomes=[AttackOutcome.SUCCESS]), "b": _make_bundle(name="b", outcomes=[AttackOutcome.SUCCESS]), } + selector = _StubSelector(technique_order=["a", "b"]) dispatcher = AdaptiveDispatchAttack( objective_target=target, techniques=bundles, selector=selector, + + max_attempts_per_objective=5, ) inner = _patch_inner(dispatcher=dispatcher, bundles=bundles) @@ -139,15 +151,19 @@ async def test_stops_on_first_success(self, target, selector, seed_group): assert result.outcome == AttackOutcome.SUCCESS assert inner.call_count == 1 - async def test_retries_until_max_attempts_on_failure(self, target, selector, seed_group): + async def test_retries_until_max_attempts_on_failure(self, target, seed_group): bundles = { "a": _make_bundle(name="a", outcomes=[AttackOutcome.FAILURE] * 3), "b": _make_bundle(name="b", outcomes=[AttackOutcome.FAILURE] * 3), + "c": _make_bundle(name="c", outcomes=[AttackOutcome.FAILURE] * 3), } + selector = _StubSelector(technique_order=["a", "b", "c"]) dispatcher = AdaptiveDispatchAttack( objective_target=target, techniques=bundles, selector=selector, + + max_attempts_per_objective=3, ) inner = _patch_inner(dispatcher=dispatcher, bundles=bundles) @@ -157,84 +173,37 @@ async def test_retries_until_max_attempts_on_failure(self, target, selector, see assert result.outcome == AttackOutcome.FAILURE assert inner.call_count == 3 - async def test_updates_selector_on_each_attempt(self, target, selector, seed_group): - bundles = { - "a": _make_bundle(name="a", outcomes=[AttackOutcome.FAILURE, AttackOutcome.SUCCESS]), - "b": _make_bundle(name="b", outcomes=[AttackOutcome.SUCCESS]), - } + async def test_passes_attempt_labels_to_inner(self, target, seed_group): + bundles = {"a": _make_bundle(name="a", outcomes=[AttackOutcome.SUCCESS])} + selector = _StubSelector(technique_order=["a"]) dispatcher = AdaptiveDispatchAttack( objective_target=target, techniques=bundles, selector=selector, - max_attempts_per_objective=3, - ) - inner = _patch_inner(dispatcher=dispatcher, bundles=bundles) - - await dispatcher._perform_async(context=_make_context()) - total_attempts = sum(selector.counts(context=GLOBAL_CONTEXT, technique=t)[1] for t in ("a", "b")) - assert total_attempts == inner.call_count - async def test_passes_attempt_labels_to_inner(self, target, selector, seed_group): - bundles = {"a": _make_bundle(name="a", outcomes=[AttackOutcome.SUCCESS])} - dispatcher = AdaptiveDispatchAttack( - objective_target=target, - techniques=bundles, - selector=selector, ) inner = _patch_inner(dispatcher=dispatcher, bundles=bundles) await dispatcher._perform_async(context=_make_context(labels={"foo": "bar"})) labels = inner.call_args.kwargs["attempt_labels"] - assert labels["foo"] == "bar" # caller labels preserved + assert labels["foo"] == "bar" assert labels[ADAPTIVE_TECHNIQUE_LABEL] == "a" assert labels[ADAPTIVE_ATTEMPT_LABEL] == "1" - async def test_uses_adaptive_context_from_extractor(self, target, selector, seed_group): - # Two techniques; one has been heavily rewarded under context "violence" only. + async def test_metadata_records_adaptive_trail(self, target, seed_group): bundles = { - "a": _make_bundle(name="a", outcomes=[AttackOutcome.SUCCESS]), + "a": _make_bundle(name="a", outcomes=[AttackOutcome.FAILURE]), "b": _make_bundle(name="b", outcomes=[AttackOutcome.SUCCESS]), } - for _ in range(5): - selector.record_outcome(context="violence", technique="b", success=True) - for _ in range(5): - selector.record_outcome(context="violence", technique="a", success=False) - + selector = _StubSelector(technique_order=["a", "b"]) dispatcher = AdaptiveDispatchAttack( objective_target=target, techniques=bundles, selector=selector, - context_extractor=harm_category_context, - ) - inner = _patch_inner(dispatcher=dispatcher, bundles=bundles) - ctx = _make_context(harm_categories=["violence"]) - await dispatcher._perform_async(context=ctx) - # Exploit should have picked "b" first. - chosen_bundle = inner.call_args.kwargs["bundle"] - assert chosen_bundle is bundles["b"] - async def test_falls_back_to_global_context_with_default_extractor(self, target, selector, seed_group): - bundles = {"a": _make_bundle(name="a", outcomes=[AttackOutcome.SUCCESS])} - dispatcher = AdaptiveDispatchAttack( - objective_target=target, - techniques=bundles, - selector=selector, - ) - _patch_inner(dispatcher=dispatcher, bundles=bundles) - await dispatcher._perform_async(context=_make_context(labels={})) - - # The global context bucket received the update. - assert selector.counts(context=GLOBAL_CONTEXT, technique="a") == (1, 1) - - async def test_metadata_records_adaptive_trail(self, target, selector, seed_group): - bundles = {"a": _make_bundle(name="a", outcomes=[AttackOutcome.FAILURE, AttackOutcome.SUCCESS])} - dispatcher = AdaptiveDispatchAttack( - objective_target=target, - techniques=bundles, - selector=selector, max_attempts_per_objective=3, ) _patch_inner(dispatcher=dispatcher, bundles=bundles) @@ -242,17 +211,19 @@ async def test_metadata_records_adaptive_trail(self, target, selector, seed_grou trail = result.metadata["adaptive_attempts"] assert trail == [ - {"technique": "a", "outcome": "failure"}, - {"technique": "a", "outcome": "success"}, + {"technique": "a", "technique_hash": "a", "outcome": "failure"}, + {"technique": "b", "technique_hash": "b", "outcome": "success"}, ] - assert result.metadata["adaptive_context"] == GLOBAL_CONTEXT - async def test_returns_fresh_result_distinct_from_inner(self, target, selector, seed_group): + async def test_returns_fresh_result_distinct_from_inner(self, target, seed_group): bundles = {"a": _make_bundle(name="a", outcomes=[AttackOutcome.SUCCESS])} + selector = _StubSelector(technique_order=["a"]) dispatcher = AdaptiveDispatchAttack( objective_target=target, techniques=bundles, selector=selector, + + ) inner_ids: list[str] = [] @@ -271,10 +242,8 @@ async def _spy(*, bundle, seed_group, attempt_labels): assert len(inner_ids) == 1 assert result.attack_result_id != inner_ids[0] - assert result.conversation_id # carried over from inner assert result.outcome == AttackOutcome.SUCCESS - assert result.metadata["adaptive_attempts"] == [{"technique": "a", "outcome": "success"}] - assert result.metadata["adaptive_context"] == GLOBAL_CONTEXT + assert result.metadata["adaptive_attempts"] == [{"technique": "a", "technique_hash": "a", "outcome": "success"}] @pytest.mark.usefixtures("patch_central_database") @@ -285,6 +254,8 @@ def test_validate_rejects_empty_objective(self, target, selector, seed_group, ba objective_target=target, techniques={"a": _make_bundle(name="a", outcomes=[AttackOutcome.SUCCESS])}, selector=selector, + + ) with pytest.raises(ValueError, match="objective"): dispatcher._validate_context(context=_make_context(objective=bad_objective)) @@ -294,6 +265,7 @@ def test_validate_accepts_normal_objective(self, target, selector, seed_group): objective_target=target, techniques={"a": _make_bundle(name="a", outcomes=[AttackOutcome.SUCCESS])}, selector=selector, + + ) - # Does not raise. dispatcher._validate_context(context=_make_context(objective="ok")) diff --git a/tests/unit/scenario/scenarios/adaptive/test_epsilon_greedy.py b/tests/unit/scenario/scenarios/adaptive/test_epsilon_greedy.py index 42d6b14b4c..985a6fe745 100644 --- a/tests/unit/scenario/scenarios/adaptive/test_epsilon_greedy.py +++ b/tests/unit/scenario/scenarios/adaptive/test_epsilon_greedy.py @@ -1,196 +1,132 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. +from unittest.mock import MagicMock, patch + import pytest +from pyrit.analytics.result_analysis import AttackStats from pyrit.scenario.scenarios.adaptive.selectors import ( - GLOBAL_CONTEXT, EpsilonGreedyTechniqueSelector, ) TECHNIQUES = ["a", "b", "c", "d"] -def _seeded_selector( - *, epsilon: float = 0.0, pool_threshold: int = 3, random_seed: int = 0 -) -> EpsilonGreedyTechniqueSelector: - return EpsilonGreedyTechniqueSelector( - epsilon=epsilon, - pool_threshold=pool_threshold, - random_seed=random_seed, - ) +def _seeded_selector(*, epsilon: float = 0.0, random_seed: int = 0) -> EpsilonGreedyTechniqueSelector: + return EpsilonGreedyTechniqueSelector(epsilon=epsilon, random_seed=random_seed) + + +def _empty_rates(*args, **kwargs) -> dict[str, AttackStats]: + """Return empty stats (all techniques unseen).""" + return {} + + +def _rates_with_winner(winner: str, *, successes: int = 5, failures: int = 0): + """Return stats where one technique has a clear win record and others have failures.""" + + def _compute(*args, **kwargs): + stats = {} + total = successes + failures + stats[winner] = AttackStats( + success_rate=successes / total if total else None, + total_decided=total, + successes=successes, + failures=failures, + undetermined=0, + errors=0, + ) + for t in TECHNIQUES: + if t != winner: + stats[t] = AttackStats( + success_rate=0.0, + total_decided=5, + successes=0, + failures=5, + undetermined=0, + errors=0, + ) + return stats + + return _compute class TestEpsilonGreedyTechniqueSelectorInit: def test_init_defaults(self): - selector = EpsilonGreedyTechniqueSelector() - assert selector.snapshot() == {} + EpsilonGreedyTechniqueSelector() @pytest.mark.parametrize("bad_epsilon", [-0.1, 1.1, 2.0, -1.0]) def test_init_rejects_out_of_range_epsilon(self, bad_epsilon): with pytest.raises(ValueError, match="epsilon"): EpsilonGreedyTechniqueSelector(epsilon=bad_epsilon) - def test_init_rejects_pool_threshold_below_one(self): - with pytest.raises(ValueError, match="pool_threshold"): - EpsilonGreedyTechniqueSelector(pool_threshold=0) - with pytest.raises(ValueError, match="pool_threshold"): - EpsilonGreedyTechniqueSelector(pool_threshold=-1) - class TestEpsilonGreedyTechniqueSelectorSelect: - def test_select_empty_techniques_raises(self): + @patch("pyrit.scenario.scenarios.adaptive.selectors.epsilon_greedy.compute_technique_success_rates", side_effect=_empty_rates) + async def test_select_empty_techniques_raises(self, _mock): selector = _seeded_selector() - with pytest.raises(ValueError, match="techniques"): - selector.select(context=GLOBAL_CONTEXT, techniques=[]) - - def test_select_all_unseen_ties_resolved_randomly(self): - # With epsilon=0 and an empty table, every technique has estimate 1/1=1.0, - # so the result is the seeded random tiebreak. Different seeds should - # be able to produce different winners. - winners = { - _seeded_selector(random_seed=s).select(context=GLOBAL_CONTEXT, techniques=TECHNIQUES) for s in range(50) - } + with pytest.raises(ValueError, match="technique_identifiers"): + await selector.select_async(technique_identifiers=[], objective="obj") + + @patch("pyrit.scenario.scenarios.adaptive.selectors.epsilon_greedy.compute_technique_success_rates", side_effect=_empty_rates) + async def test_select_all_unseen_ties_resolved_randomly(self, _mock): + winners = set() + for s in range(50): + sel = _seeded_selector(random_seed=s) + result = await sel.select_async(technique_identifiers=TECHNIQUES, objective="obj") + winners.add(result[0]) assert len(winners) > 1 assert winners.issubset(set(TECHNIQUES)) - def test_select_exploits_clear_winner(self): - selector = _seeded_selector(pool_threshold=1) - # Give "b" a track record of pure success, others pure failure. - for _ in range(5): - selector.record_outcome(context=GLOBAL_CONTEXT, technique="b", success=True) - for technique in ("a", "c", "d"): - for _ in range(5): - selector.record_outcome(context=GLOBAL_CONTEXT, technique=technique, success=False) - - # With epsilon=0, every selection must exploit the winner. + @patch( + "pyrit.scenario.scenarios.adaptive.selectors.epsilon_greedy.compute_technique_success_rates", + side_effect=_rates_with_winner("b"), + ) + async def test_select_exploits_clear_winner(self, _mock): + selector = _seeded_selector() for _ in range(20): - assert selector.select(context=GLOBAL_CONTEXT, techniques=TECHNIQUES) == "b" + result = await selector.select_async(technique_identifiers=TECHNIQUES, objective="obj") + assert result[0] == "b" - def test_select_epsilon_one_is_pure_random(self): + @patch("pyrit.scenario.scenarios.adaptive.selectors.epsilon_greedy.compute_technique_success_rates", side_effect=_empty_rates) + async def test_select_epsilon_one_is_pure_random(self, _mock): selector = _seeded_selector(epsilon=1.0) - # Bias the table heavily toward "a"; with epsilon=1 it must still be ignored. - for _ in range(20): - selector.record_outcome(context=GLOBAL_CONTEXT, technique="a", success=True) - - picks = [selector.select(context=GLOBAL_CONTEXT, techniques=TECHNIQUES) for _ in range(200)] - assert set(picks) == set(TECHNIQUES) - - def test_select_epsilon_zero_never_explores(self): - selector = _seeded_selector(epsilon=0.0, pool_threshold=1) - for _ in range(3): - selector.record_outcome(context=GLOBAL_CONTEXT, technique="a", success=True) - # Make the other techniques tried-and-failed so they fall below "a"'s estimate; - # unseen techniques would otherwise tie at the optimistic 1.0. - for technique in ("b", "c", "d"): - selector.record_outcome(context=GLOBAL_CONTEXT, technique=technique, success=False) - for _ in range(50): - assert selector.select(context=GLOBAL_CONTEXT, techniques=TECHNIQUES) == "a" - - def test_select_cold_start_round_robins(self): - # Optimistic init + epsilon=0: untried techniques tie at 1.0 and beat tried-and-failed - # techniques (1/2 = 0.5). So the first failures push each technique to "tried" exactly once - # before any technique gets tried twice. - selector = _seeded_selector(pool_threshold=1) - tried: list[str] = [] - for _ in range(len(TECHNIQUES)): - technique = selector.select(context=GLOBAL_CONTEXT, techniques=TECHNIQUES) - tried.append(technique) - selector.record_outcome(context=GLOBAL_CONTEXT, technique=technique, success=False) - assert sorted(tried) == sorted(TECHNIQUES) - - -class TestEpsilonGreedyTechniqueSelectorUpdate: - def test_record_outcome_accumulates_counts(self): + picks = set() + for i in range(200): + result = await selector.select_async( + technique_identifiers=TECHNIQUES, objective=f"obj-{i}" + ) + picks.add(result[0]) + assert picks == set(TECHNIQUES) + + @patch("pyrit.scenario.scenarios.adaptive.selectors.epsilon_greedy.compute_technique_success_rates", side_effect=_empty_rates) + async def test_select_returns_multiple_techniques(self, _mock): selector = _seeded_selector() - selector.record_outcome(context="ctx", technique="a", success=True) - selector.record_outcome(context="ctx", technique="a", success=False) - selector.record_outcome(context="ctx", technique="a", success=True) - assert selector.counts(context="ctx", technique="a") == (2, 3) - - def test_record_outcome_separate_contexts_are_independent(self): + result = await selector.select_async( + technique_identifiers=TECHNIQUES, objective="obj", num_top_techniques=3 + ) + assert len(result) == 3 + assert len(set(result)) == 3 # no duplicates + + @patch("pyrit.scenario.scenarios.adaptive.selectors.epsilon_greedy.compute_technique_success_rates", side_effect=_empty_rates) + async def test_select_caps_at_available_techniques(self, _mock): selector = _seeded_selector() - selector.record_outcome(context="x", technique="a", success=True) - selector.record_outcome(context="y", technique="a", success=False) - assert selector.counts(context="x", technique="a") == (1, 1) - assert selector.counts(context="y", technique="a") == (0, 1) + result = await selector.select_async( + technique_identifiers=["a", "b"], objective="obj", num_top_techniques=5 + ) + assert len(result) == 2 + + +class TestEpsilonGreedyEstimate: + def test_estimate_unseen_is_one(self): + assert EpsilonGreedyTechniqueSelector._estimate(technique="a", stats={}) == pytest.approx(1.0) + + def test_estimate_with_data(self): + stats = { + "a": AttackStats( + success_rate=0.6, total_decided=5, successes=3, failures=2, undetermined=0, errors=0 + ) + } + # (3 + 1) / (5 + 1) = 4/6 ≈ 0.6667 + assert EpsilonGreedyTechniqueSelector._estimate(technique="a", stats=stats) == pytest.approx(4 / 6) - def test_counts_default_zero_for_unseen(self): - selector = _seeded_selector() - assert selector.counts(context="missing", technique="missing") == (0, 0) - - def test_record_outcome_keeps_pooled_global_counts_in_sync(self): - # Pooled-global counts back the O(1) pooled-backoff branch in _estimate. - # They must aggregate across contexts for a given technique. - selector = _seeded_selector(pool_threshold=5) - selector.record_outcome(context="x", technique="a", success=True) - selector.record_outcome(context="y", technique="a", success=False) - selector.record_outcome(context="z", technique="a", success=True) - selector.record_outcome(context="x", technique="b", success=True) - - # Below the local threshold, _estimate must use the pooled-global rate. - # technique "a": 2 successes / 3 attempts -> (2+1)/(3+1) = 0.75 - assert selector.success_rate(context="new_ctx", technique="a") == pytest.approx(0.75) - # technique "b": 1/1 -> (1+1)/(1+1) = 1.0 - assert selector.success_rate(context="new_ctx", technique="b") == pytest.approx(1.0) - # Unseen technique "c" -> (0+1)/(0+1) = 1.0 - assert selector.success_rate(context="new_ctx", technique="c") == pytest.approx(1.0) - - -class TestEpsilonGreedyTechniqueSelectorEstimate: - def test_success_rate_unseen_is_one(self): - # Optimistic init: (0 + 1) / (0 + 1) = 1.0 - selector = _seeded_selector() - assert selector.success_rate(context="ctx", technique="a") == pytest.approx(1.0) - - def test_success_rate_local_when_above_threshold(self): - selector = _seeded_selector(pool_threshold=2) - for _ in range(3): - selector.record_outcome(context="ctx", technique="a", success=True) - # (3 + 1) / (3 + 1) = 1.0 - assert selector.success_rate(context="ctx", technique="a") == pytest.approx(1.0) - - def test_success_rate_pools_when_below_threshold(self): - selector = _seeded_selector(pool_threshold=5) - # Local cell has only 1 attempt (below threshold). - selector.record_outcome(context="ctx", technique="a", success=False) - # Other contexts have plenty of data for technique "a". - for _ in range(10): - selector.record_outcome(context="other", technique="a", success=True) - # Pooled estimate = (10 + 0 + 1) / (10 + 1 + 1) = 11/12. - assert selector.success_rate(context="ctx", technique="a") == pytest.approx(11 / 12) - - -class TestEpsilonGreedyTechniqueSelectorConcurrency: - """Concurrent record_outcome / select calls must not corrupt counts.""" - - def test_concurrent_record_outcome_preserves_total_attempts(self): - import threading - - selector = _seeded_selector(pool_threshold=1) - threads_per_arm = 8 - attempts_per_thread = 100 - techniques = ["a", "b", "c", "d"] - - def worker(technique: str, success_pattern: list[bool]) -> None: - for ok in success_pattern: - selector.record_outcome(context=GLOBAL_CONTEXT, technique=technique, success=ok) - - threads: list[threading.Thread] = [] - expected_successes: dict[str, int] = dict.fromkeys(techniques, 0) - for t in techniques: - for i in range(threads_per_arm): - pattern = [(j + i) % 2 == 0 for j in range(attempts_per_thread)] - expected_successes[t] += sum(pattern) - threads.append(threading.Thread(target=worker, args=(t, pattern))) - - for th in threads: - th.start() - for th in threads: - th.join() - - # Every increment landed: no lost updates from interleaved read-modify-write. - for t in techniques: - successes, attempts = selector.counts(context=GLOBAL_CONTEXT, technique=t) - assert attempts == threads_per_arm * attempts_per_thread - assert successes == expected_successes[t] diff --git a/tests/unit/scenario/scenarios/adaptive/test_protocol.py b/tests/unit/scenario/scenarios/adaptive/test_protocol.py deleted file mode 100644 index 5d9b764e76..0000000000 --- a/tests/unit/scenario/scenarios/adaptive/test_protocol.py +++ /dev/null @@ -1,46 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -from unittest.mock import MagicMock - -from pyrit.scenario.scenarios.adaptive.selectors import ( - GLOBAL_CONTEXT, - UNCATEGORIZED_CONTEXT, - EpsilonGreedyTechniqueSelector, - TechniqueSelector, - global_context, - harm_category_context, -) - - -class TestTechniqueSelectorProtocol: - def test_implements_protocol(self): - selector = EpsilonGreedyTechniqueSelector() - assert isinstance(selector, TechniqueSelector) - - -class TestContextExtractors: - def test_global_context_is_constant(self): - sg = MagicMock() - assert global_context(sg) == GLOBAL_CONTEXT - - def test_harm_category_context_joins_sorted_categories(self): - sg = MagicMock() - sg.harm_categories = ["violence", "hate"] - # Multi-category seeds form their own bucket; sorting keeps the key deterministic. - assert harm_category_context(sg) == "hate|violence" - - def test_harm_category_context_single_category(self): - sg = MagicMock() - sg.harm_categories = ["violence"] - assert harm_category_context(sg) == "violence" - - def test_harm_category_context_falls_back_when_empty(self): - sg = MagicMock() - sg.harm_categories = [] - assert harm_category_context(sg) == UNCATEGORIZED_CONTEXT - - def test_harm_category_context_falls_back_when_none(self): - sg = MagicMock() - sg.harm_categories = None - assert harm_category_context(sg) == UNCATEGORIZED_CONTEXT diff --git a/tests/unit/scenario/scenarios/adaptive/test_technique_selector.py b/tests/unit/scenario/scenarios/adaptive/test_technique_selector.py new file mode 100644 index 0000000000..5167cf3310 --- /dev/null +++ b/tests/unit/scenario/scenarios/adaptive/test_technique_selector.py @@ -0,0 +1,13 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from pyrit.scenario.scenarios.adaptive.selectors import ( + EpsilonGreedyTechniqueSelector, + TechniqueSelector, +) + + +class TestTechniqueSelectorProtocol: + def test_implements_protocol(self): + selector = EpsilonGreedyTechniqueSelector() + assert isinstance(selector, TechniqueSelector) diff --git a/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py b/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py index 13c8fd97b4..786f3692ac 100644 --- a/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py +++ b/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py @@ -18,10 +18,6 @@ from pyrit.scenario.scenarios.adaptive.dispatcher import ( AdaptiveDispatchAttack, ) -from pyrit.scenario.scenarios.adaptive.selectors import ( - GLOBAL_CONTEXT, - harm_category_context, -) from pyrit.scenario.scenarios.adaptive.text_adaptive import TextAdaptive from pyrit.score import TrueFalseScorer @@ -209,47 +205,6 @@ async def test_atomics_share_one_selector_across_dispatchers(self, mock_objectiv selectors = {id(d._selector) for d in dispatchers} assert len(selectors) == 1 - async def test_default_context_extractor_is_global(self, mock_objective_target, mock_objective_scorer): - from pyrit.scenario.scenarios.adaptive.selectors import global_context - - groups = { - "violence": [_make_seed_group(value="obj-1", harm_categories=["violence"])], - "hate": [_make_seed_group(value="obj-2", harm_categories=["hate"])], - } - _scenario, attacks = await self._build_scenario_and_attacks( - mock_objective_target=mock_objective_target, - mock_objective_scorer=mock_objective_scorer, - seed_groups=groups, - ) - for atomic in attacks: - dispatcher = atomic._attack_technique.attack - # All seed groups in a global-extractor scenario resolve to the same - # context bucket regardless of harm category. - for sg in atomic.seed_groups: - assert dispatcher._context_extractor(sg) == GLOBAL_CONTEXT - assert dispatcher._context_extractor is global_context - - async def test_harm_category_extractor_partitions_contexts(self, mock_objective_target, mock_objective_scorer): - groups = { - "violence": [_make_seed_group(value="obj-v", harm_categories=["violence"])], - "hate": [_make_seed_group(value="obj-h", harm_categories=["hate"])], - "uncat": [_make_seed_group(value="obj-u", harm_categories=None)], - } - _scenario, attacks = await self._build_scenario_and_attacks( - mock_objective_target=mock_objective_target, - mock_objective_scorer=mock_objective_scorer, - seed_groups=groups, - context_extractor=harm_category_context, - ) - contexts: set[str] = set() - for atomic in attacks: - dispatcher = atomic._attack_technique.attack - assert dispatcher._context_extractor is harm_category_context - for sg in atomic.seed_groups: - contexts.add(dispatcher._context_extractor(sg)) - # Each harm category gets its own context bucket. - assert contexts == {"violence", "hate", "_uncategorized"} - async def test_atomic_names_are_dataset_scoped(self, mock_objective_target, mock_objective_scorer): groups = { "violence": [_make_seed_group(value=f"obj-{i}", harm_categories=["violence"]) for i in range(5)], @@ -318,9 +273,10 @@ async def test_techniques_with_seed_technique_are_kept(self, mock_objective_targ dispatcher = attacks[0]._attack_technique.attack assert isinstance(dispatcher, AdaptiveDispatchAttack) # Both factories survive; in particular the seeded one is no longer - # silently dropped. - assert "role_play" in dispatcher._techniques - assert "many_shot" in dispatcher._techniques + # silently dropped. Keys are now eval hashes; check by bundle name. + technique_names = {b.name for b in dispatcher._techniques.values()} + assert "role_play" in technique_names + assert "many_shot" in technique_names async def test_incompatible_seed_technique_is_filtered_per_objective( self, mock_objective_target, mock_objective_scorer @@ -353,9 +309,10 @@ async def test_incompatible_seed_technique_is_filtered_per_objective( # shared by the dispatcher; per-call compatibility filtering now # happens inside ``AdaptiveDispatchAttack._perform_async``. The seed # group survived because the plain (no-seed_technique) factory keeps - # the compatible pool non-empty. - assert "role_play" in dispatcher._techniques - assert "many_shot" in dispatcher._techniques + # the compatible pool non-empty. Keys are now eval hashes; check by bundle name. + technique_names = {b.name for b in dispatcher._techniques.values()} + assert "role_play" in technique_names + assert "many_shot" in technique_names assert len(attacks[0].seed_groups) == 1 async def test_objective_skipped_when_no_compatible_techniques( @@ -402,120 +359,6 @@ def _selective_compat(self_group, *, technique): assert any("obj-skip" in record.getMessage() for record in caplog.records) -@pytest.mark.usefixtures(*FIXTURES) -class TestTextAdaptiveSelectorRehydration: - """When resuming, prior dispatch trails should replay into the new selector.""" - - def _build_scenario_no_resume_id(self, *, scorer): - return TextAdaptive(objective_scorer=scorer) - - def test_no_scenario_result_id_is_noop(self, mock_objective_scorer): - from pyrit.scenario.scenarios.adaptive.selectors import EpsilonGreedyTechniqueSelector - - scenario = TextAdaptive(objective_scorer=mock_objective_scorer) - selector = EpsilonGreedyTechniqueSelector() - # No scenario_result_id set -> early return, no errors, no replays. - scenario._rehydrate_selector_from_memory(selector=selector, known_techniques={"a", "b"}) - assert selector.snapshot() == {} - - def test_replays_attempts_from_metadata(self, mock_objective_scorer): - from pyrit.models import AttackResult - from pyrit.scenario.scenarios.adaptive.selectors import EpsilonGreedyTechniqueSelector - - scenario = TextAdaptive(objective_scorer=mock_objective_scorer, scenario_result_id="rid") - - rows = [ - AttackResult( - conversation_id="c1", - objective="o1", - attribution_data={"parent_collection": "adaptive_violence"}, - metadata={ - "adaptive_attempts": [ - {"technique": "a", "outcome": "failure"}, - {"technique": "b", "outcome": "success"}, - ], - "adaptive_context": "violence", - }, - ), - AttackResult( - conversation_id="c2", - objective="o2", - attribution_data={"parent_collection": "adaptive_hate"}, - metadata={ - "adaptive_attempts": [{"technique": "a", "outcome": "success"}], - "adaptive_context": "hate", - }, - ), - ] - - selector = EpsilonGreedyTechniqueSelector() - with patch.object(scenario._memory, "get_attack_results", return_value=rows): - scenario._rehydrate_selector_from_memory(selector=selector, known_techniques={"a", "b"}) - - # Trails replayed verbatim into the per-context table. - assert selector.counts(context="violence", technique="a") == (0, 1) - assert selector.counts(context="violence", technique="b") == (1, 1) - assert selector.counts(context="hate", technique="a") == (1, 1) - - def test_skips_unknown_techniques(self, mock_objective_scorer): - from pyrit.models import AttackResult - from pyrit.scenario.scenarios.adaptive.selectors import EpsilonGreedyTechniqueSelector - - scenario = TextAdaptive(objective_scorer=mock_objective_scorer, scenario_result_id="rid") - rows = [ - AttackResult( - conversation_id="c1", - objective="o1", - attribution_data={"parent_collection": "adaptive_violence"}, - metadata={ - "adaptive_attempts": [ - {"technique": "removed_technique", "outcome": "success"}, - {"technique": "a", "outcome": "failure"}, - ], - "adaptive_context": "ctx", - }, - ), - ] - - selector = EpsilonGreedyTechniqueSelector() - with patch.object(scenario._memory, "get_attack_results", return_value=rows): - scenario._rehydrate_selector_from_memory(selector=selector, known_techniques={"a"}) - - # Only the known technique was recorded. - assert selector.counts(context="ctx", technique="a") == (0, 1) - assert selector.counts(context="ctx", technique="removed_technique") == (0, 0) - - def test_ignores_results_without_adaptive_metadata(self, mock_objective_scorer): - from pyrit.models import AttackResult - from pyrit.scenario.scenarios.adaptive.selectors import EpsilonGreedyTechniqueSelector - - scenario = TextAdaptive(objective_scorer=mock_objective_scorer, scenario_result_id="rid") - rows = [ - AttackResult( - conversation_id="c", - objective="o", - attribution_data={"parent_collection": "adaptive_violence"}, - metadata={}, - ), - ] - - selector = EpsilonGreedyTechniqueSelector() - with patch.object(scenario._memory, "get_attack_results", return_value=rows): - scenario._rehydrate_selector_from_memory(selector=selector, known_techniques={"a"}) - assert selector.snapshot() == {} - - def test_memory_load_failure_is_swallowed(self, mock_objective_scorer): - from pyrit.scenario.scenarios.adaptive.selectors import EpsilonGreedyTechniqueSelector - - scenario = TextAdaptive(objective_scorer=mock_objective_scorer, scenario_result_id="rid") - - selector = EpsilonGreedyTechniqueSelector() - with patch.object(scenario._memory, "get_attack_results", side_effect=RuntimeError("db down")): - # Must not raise; selector remains empty. - scenario._rehydrate_selector_from_memory(selector=selector, known_techniques={"a"}) - assert selector.snapshot() == {} - - @pytest.mark.usefixtures(*FIXTURES) class TestTextAdaptiveBaselinePolicy: async def test_initialize_async_accepts_explicit_baseline(self, mock_objective_target, mock_objective_scorer): From e31c1996801b5604768ff8af0c127d75a0515d6c Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Wed, 27 May 2026 11:55:35 -0400 Subject: [PATCH 13/35] merge & fix tests --- tests/unit/analytics/test_scenario_analysis.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/analytics/test_scenario_analysis.py b/tests/unit/analytics/test_scenario_analysis.py index 0678f89df7..495ba00c45 100644 --- a/tests/unit/analytics/test_scenario_analysis.py +++ b/tests/unit/analytics/test_scenario_analysis.py @@ -23,7 +23,7 @@ def _make_result(*, technique: str, outcome: AttackOutcome) -> MagicMock: def _patch_memory(): mock_memory = MagicMock() mock_memory.get_attack_results.return_value = [] - with patch("pyrit.memory.CentralMemory") as cm: + with patch("pyrit.analytics.scenario_analysis.CentralMemory") as cm: cm.get_memory_instance.return_value = mock_memory yield mock_memory From c7a683b3c480b0858191f35c1b6f0cbf1d0f6162 Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Wed, 27 May 2026 12:25:16 -0400 Subject: [PATCH 14/35] fix: ruff TC001/TC003 and ty type narrowing for adaptive scenario - Move annotation-only imports (Sequence, AttackStats, TechniqueSelector) into TYPE_CHECKING blocks to satisfy ruff TC001/TC003. - Add assert eval_hash is not None before using technique identifier hash as dict key in AdaptiveScenario._build_techniques_dict (ty invalid-assignment). - Wrap long @patch decorators and let ruff-format clean up adjacent test_dispatcher blank lines and unused MagicMock import. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/analytics/scenario_analysis.py | 6 ++- .../scenarios/adaptive/adaptive_scenario.py | 6 +-- .../adaptive/selectors/epsilon_greedy.py | 12 +++-- .../adaptive/selectors/technique_selector.py | 6 ++- .../scenarios/adaptive/text_adaptive.py | 4 +- .../unit/analytics/test_scenario_analysis.py | 6 +-- .../scenarios/adaptive/test_dispatcher.py | 21 --------- .../scenarios/adaptive/test_epsilon_greedy.py | 46 ++++++++++--------- 8 files changed, 44 insertions(+), 63 deletions(-) diff --git a/pyrit/analytics/scenario_analysis.py b/pyrit/analytics/scenario_analysis.py index 0903231f8e..ed125fbe11 100644 --- a/pyrit/analytics/scenario_analysis.py +++ b/pyrit/analytics/scenario_analysis.py @@ -5,12 +5,15 @@ from __future__ import annotations -from collections.abc import Sequence +from typing import TYPE_CHECKING from pyrit.analytics.result_analysis import AttackStats, _compute_stats from pyrit.memory import CentralMemory from pyrit.models import AttackOutcome +if TYPE_CHECKING: + from collections.abc import Sequence + def compute_technique_success_rates( *, @@ -38,7 +41,6 @@ def compute_technique_success_rates( dict[str, AttackStats]: Stats per technique hash. Techniques with no history are omitted from the result. """ - memory = CentralMemory.get_memory_instance() results = memory.get_attack_results( labels={label_key: list(technique_hashes)}, diff --git a/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py b/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py index 15d5fa6ad3..0f68baa2f5 100644 --- a/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py +++ b/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py @@ -110,9 +110,7 @@ async def _get_atomic_attacks_async(self) -> list[AtomicAttack]: techniques = self._build_techniques_dict(objective_target=self._objective_target) selector: TechniqueSelector = ( - self._custom_selector - if self._custom_selector is not None - else EpsilonGreedyTechniqueSelector() + self._custom_selector if self._custom_selector is not None else EpsilonGreedyTechniqueSelector() ) seed_groups_by_dataset = self._dataset_config.get_seed_attack_groups() @@ -171,6 +169,7 @@ def _build_techniques_dict( attack_scoring_config=scoring_config, ) eval_hash = technique.get_identifier().hash + assert eval_hash is not None, f"Technique {technique_name!r} produced no identifier hash" techniques[eval_hash] = TechniqueBundle( attack=technique.attack, name=technique_name, @@ -251,4 +250,3 @@ def _build_atomic_for_dataset( memory_labels=dict(self._memory_labels), display_group=dataset_name, ) - diff --git a/pyrit/scenario/scenarios/adaptive/selectors/epsilon_greedy.py b/pyrit/scenario/scenarios/adaptive/selectors/epsilon_greedy.py index 662ff3cfbc..1034861367 100644 --- a/pyrit/scenario/scenarios/adaptive/selectors/epsilon_greedy.py +++ b/pyrit/scenario/scenarios/adaptive/selectors/epsilon_greedy.py @@ -9,12 +9,16 @@ import logging import random import struct -from collections.abc import Sequence +from typing import TYPE_CHECKING -from pyrit.analytics.result_analysis import AttackStats from pyrit.analytics.scenario_analysis import compute_technique_success_rates from pyrit.scenario.scenarios.adaptive.selectors.technique_selector import ADAPTIVE_TECHNIQUE_LABEL, SelectorScope +if TYPE_CHECKING: + from collections.abc import Sequence + + from pyrit.analytics.result_analysis import AttackStats + logger = logging.getLogger(__name__) @@ -126,9 +130,7 @@ async def select_async( if rng.random() < self._epsilon: pick = rng.choice(remaining) else: - estimates = { - t: self._estimate(technique=t, stats=stats) for t in remaining - } + estimates = {t: self._estimate(technique=t, stats=stats) for t in remaining} best = max(estimates.values()) winners = [t for t, v in estimates.items() if v >= best - self._TIE_TOL] pick = rng.choice(winners) diff --git a/pyrit/scenario/scenarios/adaptive/selectors/technique_selector.py b/pyrit/scenario/scenarios/adaptive/selectors/technique_selector.py index d87adb1a28..934dd823fc 100644 --- a/pyrit/scenario/scenarios/adaptive/selectors/technique_selector.py +++ b/pyrit/scenario/scenarios/adaptive/selectors/technique_selector.py @@ -5,9 +5,11 @@ from __future__ import annotations -from collections.abc import Sequence from enum import Enum -from typing import Protocol, runtime_checkable +from typing import TYPE_CHECKING, Protocol, runtime_checkable + +if TYPE_CHECKING: + from collections.abc import Sequence # TODO: probably want to expand this to allow for more filtering options diff --git a/pyrit/scenario/scenarios/adaptive/text_adaptive.py b/pyrit/scenario/scenarios/adaptive/text_adaptive.py index a9fb12a037..d7e726da5f 100644 --- a/pyrit/scenario/scenarios/adaptive/text_adaptive.py +++ b/pyrit/scenario/scenarios/adaptive/text_adaptive.py @@ -21,12 +21,10 @@ from pyrit.registry.tag_query import TagQuery from pyrit.scenario.core.dataset_configuration import DatasetConfiguration from pyrit.scenario.scenarios.adaptive.adaptive_scenario import AdaptiveScenario -from pyrit.scenario.scenarios.adaptive.selectors import ( - TechniqueSelector, -) if TYPE_CHECKING: from pyrit.scenario.core.scenario_strategy import ScenarioStrategy + from pyrit.scenario.scenarios.adaptive.selectors import TechniqueSelector from pyrit.score import TrueFalseScorer logger = logging.getLogger(__name__) diff --git a/tests/unit/analytics/test_scenario_analysis.py b/tests/unit/analytics/test_scenario_analysis.py index 495ba00c45..cc0683e6ea 100644 --- a/tests/unit/analytics/test_scenario_analysis.py +++ b/tests/unit/analytics/test_scenario_analysis.py @@ -8,7 +8,6 @@ from pyrit.analytics.scenario_analysis import compute_technique_success_rates from pyrit.models import AttackOutcome - LABEL_KEY = "_adaptive_technique" @@ -29,7 +28,6 @@ def _patch_memory(): class TestComputeTechniqueSuccessRates: - def test_empty_results_returns_empty(self, _patch_memory): stats = compute_technique_success_rates(technique_hashes=["a", "b"], label_key=LABEL_KEY) assert stats == {} @@ -81,9 +79,7 @@ def test_passes_label_key_to_memory_query(self, _patch_memory): assert call_kwargs["scenario_result_id"] is None def test_passes_scenario_result_id_to_memory_query(self, _patch_memory): - compute_technique_success_rates( - technique_hashes=["x"], label_key=LABEL_KEY, scenario_result_id="run-123" - ) + compute_technique_success_rates(technique_hashes=["x"], label_key=LABEL_KEY, scenario_result_id="run-123") call_kwargs = _patch_memory.get_attack_results.call_args[1] assert call_kwargs["scenario_result_id"] == "run-123" diff --git a/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py b/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py index 9c6ba29fb8..320b6dfd79 100644 --- a/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py +++ b/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py @@ -14,9 +14,6 @@ AdaptiveDispatchParams, TechniqueBundle, ) -from pyrit.scenario.scenarios.adaptive.selectors import ( - EpsilonGreedyTechniqueSelector, -) def _make_bundle(*, name: str, outcomes: list[AttackOutcome], seed_technique=None) -> TechniqueBundle: @@ -110,8 +107,6 @@ def test_init_rejects_empty_techniques(self, target, selector, seed_group): objective_target=target, techniques={}, selector=selector, - - ) @pytest.mark.parametrize("bad_max", [0, -1]) @@ -122,8 +117,6 @@ def test_init_rejects_invalid_max_attempts(self, target, selector, seed_group, b objective_target=target, techniques={"a": _make_bundle(name="a", outcomes=[AttackOutcome.SUCCESS])}, selector=selector, - - max_attempts_per_objective=bad_max, ) @@ -140,8 +133,6 @@ async def test_stops_on_first_success(self, target, seed_group): objective_target=target, techniques=bundles, selector=selector, - - max_attempts_per_objective=5, ) inner = _patch_inner(dispatcher=dispatcher, bundles=bundles) @@ -162,8 +153,6 @@ async def test_retries_until_max_attempts_on_failure(self, target, seed_group): objective_target=target, techniques=bundles, selector=selector, - - max_attempts_per_objective=3, ) inner = _patch_inner(dispatcher=dispatcher, bundles=bundles) @@ -180,8 +169,6 @@ async def test_passes_attempt_labels_to_inner(self, target, seed_group): objective_target=target, techniques=bundles, selector=selector, - - ) inner = _patch_inner(dispatcher=dispatcher, bundles=bundles) @@ -202,8 +189,6 @@ async def test_metadata_records_adaptive_trail(self, target, seed_group): objective_target=target, techniques=bundles, selector=selector, - - max_attempts_per_objective=3, ) _patch_inner(dispatcher=dispatcher, bundles=bundles) @@ -222,8 +207,6 @@ async def test_returns_fresh_result_distinct_from_inner(self, target, seed_group objective_target=target, techniques=bundles, selector=selector, - - ) inner_ids: list[str] = [] @@ -254,8 +237,6 @@ def test_validate_rejects_empty_objective(self, target, selector, seed_group, ba objective_target=target, techniques={"a": _make_bundle(name="a", outcomes=[AttackOutcome.SUCCESS])}, selector=selector, - - ) with pytest.raises(ValueError, match="objective"): dispatcher._validate_context(context=_make_context(objective=bad_objective)) @@ -265,7 +246,5 @@ def test_validate_accepts_normal_objective(self, target, selector, seed_group): objective_target=target, techniques={"a": _make_bundle(name="a", outcomes=[AttackOutcome.SUCCESS])}, selector=selector, - - ) dispatcher._validate_context(context=_make_context(objective="ok")) diff --git a/tests/unit/scenario/scenarios/adaptive/test_epsilon_greedy.py b/tests/unit/scenario/scenarios/adaptive/test_epsilon_greedy.py index 985a6fe745..4b8b74e35f 100644 --- a/tests/unit/scenario/scenarios/adaptive/test_epsilon_greedy.py +++ b/tests/unit/scenario/scenarios/adaptive/test_epsilon_greedy.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest @@ -62,13 +62,19 @@ def test_init_rejects_out_of_range_epsilon(self, bad_epsilon): class TestEpsilonGreedyTechniqueSelectorSelect: - @patch("pyrit.scenario.scenarios.adaptive.selectors.epsilon_greedy.compute_technique_success_rates", side_effect=_empty_rates) + @patch( + "pyrit.scenario.scenarios.adaptive.selectors.epsilon_greedy.compute_technique_success_rates", + side_effect=_empty_rates, + ) async def test_select_empty_techniques_raises(self, _mock): selector = _seeded_selector() with pytest.raises(ValueError, match="technique_identifiers"): await selector.select_async(technique_identifiers=[], objective="obj") - @patch("pyrit.scenario.scenarios.adaptive.selectors.epsilon_greedy.compute_technique_success_rates", side_effect=_empty_rates) + @patch( + "pyrit.scenario.scenarios.adaptive.selectors.epsilon_greedy.compute_technique_success_rates", + side_effect=_empty_rates, + ) async def test_select_all_unseen_ties_resolved_randomly(self, _mock): winners = set() for s in range(50): @@ -88,32 +94,35 @@ async def test_select_exploits_clear_winner(self, _mock): result = await selector.select_async(technique_identifiers=TECHNIQUES, objective="obj") assert result[0] == "b" - @patch("pyrit.scenario.scenarios.adaptive.selectors.epsilon_greedy.compute_technique_success_rates", side_effect=_empty_rates) + @patch( + "pyrit.scenario.scenarios.adaptive.selectors.epsilon_greedy.compute_technique_success_rates", + side_effect=_empty_rates, + ) async def test_select_epsilon_one_is_pure_random(self, _mock): selector = _seeded_selector(epsilon=1.0) picks = set() for i in range(200): - result = await selector.select_async( - technique_identifiers=TECHNIQUES, objective=f"obj-{i}" - ) + result = await selector.select_async(technique_identifiers=TECHNIQUES, objective=f"obj-{i}") picks.add(result[0]) assert picks == set(TECHNIQUES) - @patch("pyrit.scenario.scenarios.adaptive.selectors.epsilon_greedy.compute_technique_success_rates", side_effect=_empty_rates) + @patch( + "pyrit.scenario.scenarios.adaptive.selectors.epsilon_greedy.compute_technique_success_rates", + side_effect=_empty_rates, + ) async def test_select_returns_multiple_techniques(self, _mock): selector = _seeded_selector() - result = await selector.select_async( - technique_identifiers=TECHNIQUES, objective="obj", num_top_techniques=3 - ) + result = await selector.select_async(technique_identifiers=TECHNIQUES, objective="obj", num_top_techniques=3) assert len(result) == 3 assert len(set(result)) == 3 # no duplicates - @patch("pyrit.scenario.scenarios.adaptive.selectors.epsilon_greedy.compute_technique_success_rates", side_effect=_empty_rates) + @patch( + "pyrit.scenario.scenarios.adaptive.selectors.epsilon_greedy.compute_technique_success_rates", + side_effect=_empty_rates, + ) async def test_select_caps_at_available_techniques(self, _mock): selector = _seeded_selector() - result = await selector.select_async( - technique_identifiers=["a", "b"], objective="obj", num_top_techniques=5 - ) + result = await selector.select_async(technique_identifiers=["a", "b"], objective="obj", num_top_techniques=5) assert len(result) == 2 @@ -122,11 +131,6 @@ def test_estimate_unseen_is_one(self): assert EpsilonGreedyTechniqueSelector._estimate(technique="a", stats={}) == pytest.approx(1.0) def test_estimate_with_data(self): - stats = { - "a": AttackStats( - success_rate=0.6, total_decided=5, successes=3, failures=2, undetermined=0, errors=0 - ) - } + stats = {"a": AttackStats(success_rate=0.6, total_decided=5, successes=3, failures=2, undetermined=0, errors=0)} # (3 + 1) / (5 + 1) = 4/6 ≈ 0.6667 assert EpsilonGreedyTechniqueSelector._estimate(technique="a", stats=stats) == pytest.approx(4 / 6) - From a303d8170e63c34318074ead2fad70bcabf6249f Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Wed, 27 May 2026 12:36:52 -0400 Subject: [PATCH 15/35] Replace SelectorScope enum with frozen dataclass Addresses the TODO in technique_selector.py that called for richer scope filtering than the binary ALL_RUNS / CURRENT_RUN enum. - technique_selector.py: SelectorScope is now a frozen dataclass with current_run_only, attack_classes, targeted_harm_categories, and extra_labels fields, plus all_runs() / current_run() classmethods for the common cases. - analytics/scenario_analysis.py: compute_technique_success_rates now accepts attack_classes, targeted_harm_categories, and extra_labels kwargs and forwards them to memory.get_attack_results (extra_labels are merged with the technique-hash label filter; the technique label cannot be overridden). - EpsilonGreedyTechniqueSelector: default scope is SelectorScope.all_runs(); scope fields flow to analytics, and scenario_result_id is forwarded only when current_run_only=True. - Tests: extend test_scenario_analysis.py and test_epsilon_greedy.py with scope-filter forwarding cases; add test_selector_scope.py covering classmethods, frozen-ness, and field combinations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/analytics/scenario_analysis.py | 31 +++++++-- .../adaptive/selectors/epsilon_greedy.py | 20 ++++-- .../adaptive/selectors/technique_selector.py | 69 +++++++++++++++---- .../unit/analytics/test_scenario_analysis.py | 51 ++++++++++++++ .../scenarios/adaptive/test_epsilon_greedy.py | 38 ++++++++++ .../scenarios/adaptive/test_selector_scope.py | 63 +++++++++++++++++ 6 files changed, 249 insertions(+), 23 deletions(-) create mode 100644 tests/unit/scenario/scenarios/adaptive/test_selector_scope.py diff --git a/pyrit/analytics/scenario_analysis.py b/pyrit/analytics/scenario_analysis.py index ed125fbe11..4b203daedd 100644 --- a/pyrit/analytics/scenario_analysis.py +++ b/pyrit/analytics/scenario_analysis.py @@ -12,7 +12,7 @@ from pyrit.models import AttackOutcome if TYPE_CHECKING: - from collections.abc import Sequence + from collections.abc import Mapping, Sequence def compute_technique_success_rates( @@ -20,6 +20,9 @@ def compute_technique_success_rates( technique_hashes: Sequence[str], label_key: str, scenario_result_id: str | None = None, + attack_classes: Sequence[str] | None = None, + targeted_harm_categories: Sequence[str] | None = None, + extra_labels: Mapping[str, str | Sequence[str]] | None = None, ) -> dict[str, AttackStats]: """ Query memory for historical success rates grouped by technique eval hash. @@ -28,23 +31,43 @@ def compute_technique_success_rates( ``label_key`` matching one of ``technique_hashes``, then aggregates outcomes into per-technique :class:`AttackStats`. - By default queries across all scenario runs. Pass ``scenario_result_id`` - to restrict to a single run. + By default queries across all scenario runs. Pass any subset of the + optional filters to narrow the historical window. Args: technique_hashes (Sequence[str]): Technique eval hashes to query. label_key (str): Memory-label key that stores the technique hash. scenario_result_id (str | None): If provided, restrict results to a single scenario run. Defaults to ``None`` (all runs). + attack_classes (Sequence[str] | None): If provided, restrict to + results emitted by these attack / scenario class names. + Forwarded to ``memory.get_attack_results``. Defaults to ``None``. + targeted_harm_categories (Sequence[str] | None): If provided, + restrict to results whose prompts target these harm categories. + Defaults to ``None``. + extra_labels (Mapping[str, str | Sequence[str]] | None): Additional + memory-label filters merged on top of the + ``{label_key: technique_hashes}`` filter the function always + applies. Keys that collide with ``label_key`` are ignored + (the technique-hash filter wins). Defaults to ``None``. Returns: dict[str, AttackStats]: Stats per technique hash. Techniques with no history are omitted from the result. """ + labels: dict[str, str | Sequence[str]] = {label_key: list(technique_hashes)} + if extra_labels: + for key, value in extra_labels.items(): + if key == label_key: + continue + labels[key] = value + memory = CentralMemory.get_memory_instance() results = memory.get_attack_results( - labels={label_key: list(technique_hashes)}, + labels=labels, scenario_result_id=scenario_result_id, + attack_classes=attack_classes, + targeted_harm_categories=targeted_harm_categories, ) counts: dict[str, tuple[int, int, int, int]] = {} diff --git a/pyrit/scenario/scenarios/adaptive/selectors/epsilon_greedy.py b/pyrit/scenario/scenarios/adaptive/selectors/epsilon_greedy.py index 1034861367..aebcd65983 100644 --- a/pyrit/scenario/scenarios/adaptive/selectors/epsilon_greedy.py +++ b/pyrit/scenario/scenarios/adaptive/selectors/epsilon_greedy.py @@ -59,14 +59,15 @@ def __init__( self, *, epsilon: float = 0.2, - scope: SelectorScope = SelectorScope.ALL_RUNS, + scope: SelectorScope | None = None, random_seed: int | None = None, ) -> None: """ Args: epsilon (float): Exploration probability in [0.0, 1.0]. Defaults to 0.2. - scope (SelectorScope): Whether to use all historical data or only - the current scenario run. Defaults to ``SelectorScope.ALL_RUNS``. + scope (SelectorScope | None): Filter describing which historical + ``AttackResult`` rows to use when estimating success rates. + Defaults to :meth:`SelectorScope.all_runs` (all history). random_seed (int | None): Base seed for deterministic per-decision RNG derivation. Defaults to ``None`` (non-deterministic). @@ -77,7 +78,7 @@ def __init__( raise ValueError(f"epsilon must be in [0.0, 1.0], got {epsilon}") self._epsilon = epsilon - self._scope = scope + self._scope = scope if scope is not None else SelectorScope.all_runs() self._seed = random_seed async def select_async( @@ -95,8 +96,9 @@ async def select_async( technique_identifiers (Sequence[str]): Available technique names. objective (str): The objective text for scoping the per-decision RNG. num_top_techniques (int): Max techniques to return. Defaults to 1. - scenario_result_id (str | None): If provided, restrict memory - queries to this scenario run. Defaults to ``None`` (all runs). + scenario_result_id (str | None): The current scenario run ID, supplied + by the dispatcher. Forwarded to memory only when the configured + ``scope.current_run_only`` is ``True``. Defaults to ``None``. Returns: Sequence[str]: Techniques in priority order. Fewer than @@ -114,10 +116,14 @@ async def select_async( decision_key = objective rng = _derive_rng(self._seed, decision_key) + effective_run_id = scenario_result_id if self._scope.current_run_only else None stats = compute_technique_success_rates( technique_hashes=technique_list, label_key=ADAPTIVE_TECHNIQUE_LABEL, - scenario_result_id=scenario_result_id if self._scope == SelectorScope.CURRENT_RUN else None, + scenario_result_id=effective_run_id, + attack_classes=self._scope.attack_classes, + targeted_harm_categories=self._scope.targeted_harm_categories, + extra_labels=self._scope.extra_labels, ) chosen: list[str] = [] diff --git a/pyrit/scenario/scenarios/adaptive/selectors/technique_selector.py b/pyrit/scenario/scenarios/adaptive/selectors/technique_selector.py index 934dd823fc..c5c1ae685f 100644 --- a/pyrit/scenario/scenarios/adaptive/selectors/technique_selector.py +++ b/pyrit/scenario/scenarios/adaptive/selectors/technique_selector.py @@ -5,23 +5,67 @@ from __future__ import annotations -from enum import Enum +from dataclasses import dataclass from typing import TYPE_CHECKING, Protocol, runtime_checkable if TYPE_CHECKING: - from collections.abc import Sequence + from collections.abc import Mapping, Sequence -# TODO: probably want to expand this to allow for more filtering options -# (e.g. filter by scenario parameters, attack labels, etc.) -class SelectorScope(str, Enum): - """Controls which historical data a selector queries.""" +@dataclass(frozen=True) +class SelectorScope: + """ + Filter describing which historical ``AttackResult`` rows a selector + queries when estimating technique success rates. + + All fields default to "no restriction"; combine fields to narrow the + scope (e.g. current run only, same scenario class, same harm category). + Filter values flow through :func:`compute_technique_success_rates` to + :meth:`MemoryInterface.get_attack_results`. + + The scope is held by the selector at construction time. The per-call + ``scenario_result_id`` is supplied by the dispatcher and is forwarded + to memory only when ``current_run_only`` is set; otherwise the selector + queries across all runs. + """ + + current_run_only: bool = False + """Restrict to the dispatcher-supplied ``scenario_result_id`` for the + in-flight run. When ``False`` (default), query across all runs.""" + + attack_classes: Sequence[str] | None = None + """Filter to results emitted by these attack / scenario class names + (e.g. ``["TextAdaptive"]``). Useful to keep one modality's bandit from + being influenced by another's history. ``None`` means no class filter.""" + + targeted_harm_categories: Sequence[str] | None = None + """Filter to results whose prompts targeted these harm categories. + ``None`` means no harm-category filter.""" + + extra_labels: Mapping[str, str | Sequence[str]] | None = None + """Additional memory-label filters merged on top of the technique-hash + label that the selector adds internally. Use this as an escape hatch + for label-based filtering not covered by the named fields.""" + + @classmethod + def all_runs(cls) -> SelectorScope: + """ + Build a scope that queries across all historical scenario runs (the default). - ALL_RUNS = "all_runs" - """Use technique success rates from all historical scenario runs.""" + Returns: + SelectorScope: A scope with no restrictions. + """ + return cls() - CURRENT_RUN = "current_run" - """Use technique success rates only from the current scenario run.""" + @classmethod + def current_run(cls) -> SelectorScope: + """ + Build a scope restricted to the dispatcher-supplied scenario run. + + Returns: + SelectorScope: A scope with ``current_run_only=True``. + """ + return cls(current_run_only=True) ADAPTIVE_TECHNIQUE_LABEL: str = "_adaptive_technique" @@ -56,8 +100,9 @@ async def select_async( objective (str): The objective text for this selection. num_top_techniques (int): Max techniques to return. Defaults to 1. scenario_result_id (str | None): The current scenario run ID, - provided by the dispatcher. Selectors use this when their - scope is ``SelectorScope.CURRENT_RUN``. + provided by the dispatcher. Selectors forward this to + memory only when their :class:`SelectorScope` has + ``current_run_only=True``. Returns: Sequence[str]: Up to ``num_top_techniques`` technique names in diff --git a/tests/unit/analytics/test_scenario_analysis.py b/tests/unit/analytics/test_scenario_analysis.py index cc0683e6ea..c319273458 100644 --- a/tests/unit/analytics/test_scenario_analysis.py +++ b/tests/unit/analytics/test_scenario_analysis.py @@ -105,3 +105,54 @@ def test_success_rate_computed(self, _patch_memory): stats = compute_technique_success_rates(technique_hashes=["a"], label_key=LABEL_KEY) assert stats["a"].success_rate == pytest.approx(0.5) + + def test_passes_attack_classes_to_memory_query(self, _patch_memory): + compute_technique_success_rates( + technique_hashes=["x"], + label_key=LABEL_KEY, + attack_classes=["TextAdaptive", "ImageAdaptive"], + ) + + call_kwargs = _patch_memory.get_attack_results.call_args[1] + assert call_kwargs["attack_classes"] == ["TextAdaptive", "ImageAdaptive"] + + def test_passes_harm_categories_to_memory_query(self, _patch_memory): + compute_technique_success_rates( + technique_hashes=["x"], + label_key=LABEL_KEY, + targeted_harm_categories=["misinformation", "hate"], + ) + + call_kwargs = _patch_memory.get_attack_results.call_args[1] + assert call_kwargs["targeted_harm_categories"] == ["misinformation", "hate"] + + def test_merges_extra_labels_with_technique_label(self, _patch_memory): + compute_technique_success_rates( + technique_hashes=["x"], + label_key=LABEL_KEY, + extra_labels={"experiment": "ablation_v3", "tier": ["a", "b"]}, + ) + + call_kwargs = _patch_memory.get_attack_results.call_args[1] + assert call_kwargs["labels"] == { + LABEL_KEY: ["x"], + "experiment": "ablation_v3", + "tier": ["a", "b"], + } + + def test_extra_labels_cannot_override_technique_label(self, _patch_memory): + compute_technique_success_rates( + technique_hashes=["x"], + label_key=LABEL_KEY, + extra_labels={LABEL_KEY: ["evil"], "other": "ok"}, + ) + + call_kwargs = _patch_memory.get_attack_results.call_args[1] + assert call_kwargs["labels"] == {LABEL_KEY: ["x"], "other": "ok"} + + def test_default_filter_kwargs_are_none(self, _patch_memory): + compute_technique_success_rates(technique_hashes=["x"], label_key=LABEL_KEY) + + call_kwargs = _patch_memory.get_attack_results.call_args[1] + assert call_kwargs["attack_classes"] is None + assert call_kwargs["targeted_harm_categories"] is None diff --git a/tests/unit/scenario/scenarios/adaptive/test_epsilon_greedy.py b/tests/unit/scenario/scenarios/adaptive/test_epsilon_greedy.py index 4b8b74e35f..1052786476 100644 --- a/tests/unit/scenario/scenarios/adaptive/test_epsilon_greedy.py +++ b/tests/unit/scenario/scenarios/adaptive/test_epsilon_greedy.py @@ -8,10 +8,13 @@ from pyrit.analytics.result_analysis import AttackStats from pyrit.scenario.scenarios.adaptive.selectors import ( EpsilonGreedyTechniqueSelector, + SelectorScope, ) TECHNIQUES = ["a", "b", "c", "d"] +_COMPUTE_PATH = "pyrit.scenario.scenarios.adaptive.selectors.epsilon_greedy.compute_technique_success_rates" + def _seeded_selector(*, epsilon: float = 0.0, random_seed: int = 0) -> EpsilonGreedyTechniqueSelector: return EpsilonGreedyTechniqueSelector(epsilon=epsilon, random_seed=random_seed) @@ -126,6 +129,41 @@ async def test_select_caps_at_available_techniques(self, _mock): assert len(result) == 2 +class TestEpsilonGreedySelectorScope: + @patch(_COMPUTE_PATH, side_effect=_empty_rates) + async def test_default_scope_passes_none_scenario_result_id(self, mock_compute): + selector = _seeded_selector() + await selector.select_async(technique_identifiers=TECHNIQUES, objective="obj", scenario_result_id="run-1") + + # Default scope is all_runs(): the per-call scenario_result_id is dropped. + assert mock_compute.call_args.kwargs["scenario_result_id"] is None + assert mock_compute.call_args.kwargs["attack_classes"] is None + assert mock_compute.call_args.kwargs["targeted_harm_categories"] is None + assert mock_compute.call_args.kwargs["extra_labels"] is None + + @patch(_COMPUTE_PATH, side_effect=_empty_rates) + async def test_current_run_scope_forwards_scenario_result_id(self, mock_compute): + selector = EpsilonGreedyTechniqueSelector(epsilon=0.0, random_seed=0, scope=SelectorScope.current_run()) + await selector.select_async(technique_identifiers=TECHNIQUES, objective="obj", scenario_result_id="run-42") + + assert mock_compute.call_args.kwargs["scenario_result_id"] == "run-42" + + @patch(_COMPUTE_PATH, side_effect=_empty_rates) + async def test_scope_filter_fields_forwarded(self, mock_compute): + scope = SelectorScope( + attack_classes=["TextAdaptive"], + targeted_harm_categories=["misinformation"], + extra_labels={"experiment": "ablation_v3"}, + ) + selector = EpsilonGreedyTechniqueSelector(epsilon=0.0, random_seed=0, scope=scope) + await selector.select_async(technique_identifiers=TECHNIQUES, objective="obj") + + kwargs = mock_compute.call_args.kwargs + assert kwargs["attack_classes"] == ["TextAdaptive"] + assert kwargs["targeted_harm_categories"] == ["misinformation"] + assert kwargs["extra_labels"] == {"experiment": "ablation_v3"} + + class TestEpsilonGreedyEstimate: def test_estimate_unseen_is_one(self): assert EpsilonGreedyTechniqueSelector._estimate(technique="a", stats={}) == pytest.approx(1.0) diff --git a/tests/unit/scenario/scenarios/adaptive/test_selector_scope.py b/tests/unit/scenario/scenarios/adaptive/test_selector_scope.py new file mode 100644 index 0000000000..beef26f743 --- /dev/null +++ b/tests/unit/scenario/scenarios/adaptive/test_selector_scope.py @@ -0,0 +1,63 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import dataclasses + +import pytest + +from pyrit.scenario.scenarios.adaptive.selectors import SelectorScope + + +class TestSelectorScopeDefaults: + def test_default_constructs_all_runs(self): + scope = SelectorScope() + assert scope.current_run_only is False + assert scope.attack_classes is None + assert scope.targeted_harm_categories is None + assert scope.extra_labels is None + + def test_all_runs_classmethod_equivalent_to_default(self): + assert SelectorScope.all_runs() == SelectorScope() + + def test_current_run_classmethod_sets_flag(self): + scope = SelectorScope.current_run() + assert scope.current_run_only is True + assert scope.attack_classes is None + assert scope.targeted_harm_categories is None + assert scope.extra_labels is None + + +class TestSelectorScopeFrozen: + def test_assigning_field_raises(self): + scope = SelectorScope() + with pytest.raises(dataclasses.FrozenInstanceError): + scope.current_run_only = True # type: ignore[misc] + + def test_assigning_new_field_raises(self): + scope = SelectorScope() + with pytest.raises(dataclasses.FrozenInstanceError): + scope.extra_labels = {"a": "b"} # type: ignore[misc] + + +class TestSelectorScopeCombinations: + def test_fields_combine(self): + scope = SelectorScope( + current_run_only=True, + attack_classes=["TextAdaptive"], + targeted_harm_categories=["misinformation"], + extra_labels={"experiment": "v3"}, + ) + assert scope.current_run_only is True + assert scope.attack_classes == ["TextAdaptive"] + assert scope.targeted_harm_categories == ["misinformation"] + assert scope.extra_labels == {"experiment": "v3"} + + def test_equality_value_based(self): + a = SelectorScope(attack_classes=("X",), targeted_harm_categories=("y",)) + b = SelectorScope(attack_classes=("X",), targeted_harm_categories=("y",)) + assert a == b + + def test_inequality_when_fields_differ(self): + a = SelectorScope.all_runs() + b = SelectorScope.current_run() + assert a != b From d3cce0c684450adc504f89d5677ab1afe1ebfeed Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Wed, 27 May 2026 15:23:45 -0400 Subject: [PATCH 16/35] fix: pre-commit failures (ruff TC001 + nbstripout) Move TechniqueSelector import in dispatcher.py into the TYPE_CHECKING block (ruff TC001) -- it is only used as a type annotation. The ADAPTIVE_TECHNIQUE_LABEL constant from the same package stays at module scope since it is referenced at runtime. Also apply nbstripout normalization to doc/code/scenarios/3_adaptive_scenarios.ipynb (cell-id renumbering only; no semantic changes). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- doc/code/scenarios/3_adaptive_scenarios.ipynb | 24 +++++++++---------- .../scenario/scenarios/adaptive/dispatcher.py | 4 +--- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/doc/code/scenarios/3_adaptive_scenarios.ipynb b/doc/code/scenarios/3_adaptive_scenarios.ipynb index f52ddf1be7..0c9301d302 100644 --- a/doc/code/scenarios/3_adaptive_scenarios.ipynb +++ b/doc/code/scenarios/3_adaptive_scenarios.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "markdown", - "id": "94e7f44a", + "id": "0", "metadata": {}, "source": [ "# Adaptive Scenarios\n", @@ -39,7 +39,7 @@ }, { "cell_type": "markdown", - "id": "cb716650", + "id": "1", "metadata": {}, "source": [ "## Setup" @@ -48,7 +48,7 @@ { "cell_type": "code", "execution_count": null, - "id": "4b536900", + "id": "2", "metadata": {}, "outputs": [], "source": [ @@ -68,7 +68,7 @@ }, { "cell_type": "markdown", - "id": "9f9ff786", + "id": "3", "metadata": {}, "source": [ "## Basic usage\n", @@ -80,7 +80,7 @@ { "cell_type": "code", "execution_count": null, - "id": "33aa89d3", + "id": "4", "metadata": {}, "outputs": [], "source": [ @@ -95,7 +95,7 @@ }, { "cell_type": "markdown", - "id": "5083bbed", + "id": "5", "metadata": {}, "source": [ "## Configuring a run\n", @@ -115,7 +115,7 @@ { "cell_type": "code", "execution_count": null, - "id": "db966395", + "id": "6", "metadata": {}, "outputs": [], "source": [ @@ -145,7 +145,7 @@ }, { "cell_type": "markdown", - "id": "ba7e7126", + "id": "7", "metadata": {}, "source": [ "## Resuming a run\n", @@ -158,7 +158,7 @@ { "cell_type": "code", "execution_count": null, - "id": "4857bace", + "id": "8", "metadata": {}, "outputs": [], "source": [ @@ -185,7 +185,7 @@ }, { "cell_type": "markdown", - "id": "e267467c", + "id": "9", "metadata": {}, "source": [ "## Inspecting which techniques were tried\n", @@ -201,7 +201,7 @@ { "cell_type": "code", "execution_count": null, - "id": "3a95436b", + "id": "10", "metadata": {}, "outputs": [], "source": [ @@ -231,7 +231,7 @@ }, { "cell_type": "markdown", - "id": "37cd0756", + "id": "11", "metadata": {}, "source": [ "## Running from the scanner CLI\n", diff --git a/pyrit/scenario/scenarios/adaptive/dispatcher.py b/pyrit/scenario/scenarios/adaptive/dispatcher.py index 6a49cacafc..f651465f03 100644 --- a/pyrit/scenario/scenarios/adaptive/dispatcher.py +++ b/pyrit/scenario/scenarios/adaptive/dispatcher.py @@ -23,14 +23,12 @@ from pyrit.executor.attack.core.attack_parameters import AttackParameters from pyrit.executor.attack.core.attack_strategy import AttackContext, AttackStrategy from pyrit.models import AttackOutcome, AttackResult, SeedAttackGroup -from pyrit.scenario.scenarios.adaptive.selectors import ( - TechniqueSelector, -) from pyrit.scenario.scenarios.adaptive.selectors.technique_selector import ADAPTIVE_TECHNIQUE_LABEL if TYPE_CHECKING: from pyrit.models import SeedAttackTechniqueGroup from pyrit.prompt_target import PromptTarget + from pyrit.scenario.scenarios.adaptive.selectors import TechniqueSelector from pyrit.score import TrueFalseScorer logger = logging.getLogger(__name__) From cd9f200765f4d6cabf7e73e6e7c2a93190047608 Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Thu, 28 May 2026 17:25:05 -0400 Subject: [PATCH 17/35] refactor(adaptive): delegate dispatcher loop to SequentialAttack Per rlundeen2's review on PR #1760 (https://github.com/microsoft/PyRIT/pull/1760#discussion_r3290886565), collapse AdaptiveDispatchAttack's manual per-objective attempt loop into a SequentialAttack(completion_policy=FIRST_SUCCESS) (introduced in PR #1819). Each objective now produces exactly one AttackResult envelope (SequentialAttackResult, conversation_id=""), and the inner per-technique attempts persist as their own first-class AttackResult rows reachable via child_attack_results / child_attack_result_ids. This removes the prior hack where the winning inner result was rewritten with a fresh uuid and re-persisted alongside its original, producing two rows that shared a conversation_id. What changed in dispatcher.py: - Drop _run_inner_attack_async, the manual for-loop, AttackExecutor usage, and the uuid / datetime / dataclasses.replace / AttackOutcome imports. - Build a SequentialChildAttack per chosen technique (with per-attempt memory labels and per-attempt seed_group.with_technique() merging), then delegate iteration + stop-on-success + envelope construction to SequentialAttack. - Stamp the per-attempt trail at metadata["adaptive_attempts"] on the returned envelope (one entry per attempt that actually ran). Compatibility preserved: same public constructor signature, same filtering of techniques whose seed_technique is incompatible with the current seed_group (still raises ValueError on empty pool so the outer executor drops the seed group rather than silently no-op'ing), same per-attempt memory_labels shape, same seed_technique merging. Tests updated to patch SequentialAttack._run_child_attack_async instead of the deleted _run_inner_attack_async, and assert the envelope is a SequentialAttackResult with conversation_id == "" and the inner result exposed via child_attack_results. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../scenario/scenarios/adaptive/dispatcher.py | 254 ++++++++++-------- .../scenarios/adaptive/test_dispatcher.py | 98 ++++--- 2 files changed, 199 insertions(+), 153 deletions(-) diff --git a/pyrit/scenario/scenarios/adaptive/dispatcher.py b/pyrit/scenario/scenarios/adaptive/dispatcher.py index f651465f03..7a22fdcc4e 100644 --- a/pyrit/scenario/scenarios/adaptive/dispatcher.py +++ b/pyrit/scenario/scenarios/adaptive/dispatcher.py @@ -3,30 +3,42 @@ """ ``AdaptiveDispatchAttack`` — picks inner techniques per objective via a -``TechniqueSelector``, runs them in priority order, and stops on success. +``TechniqueSelector``, then runs them in priority order via a +``SequentialAttack`` (stop on first success). The selector is stateless and async: it queries memory for historical success rates. The dispatcher pre-selects up to ``max_attempts_per_objective`` -techniques at the start of each objective, then iterates through them. +techniques at the start of each objective, builds a per-call +``SequentialAttack`` whose child attacks are the chosen techniques, and +delegates iteration + stop-on-success + envelope construction to that +compound attack. + +Returned envelope is a ``SequentialAttackResult`` (an ``AttackResult`` +subclass) stamped with the adaptive trail under +``metadata["adaptive_attempts"]``. Inner per-attempt results live on the +envelope's ``child_attack_results`` and persist as their own DB rows; the +envelope itself owns no conversation (``conversation_id == ""``). """ from __future__ import annotations import dataclasses import logging -import uuid -from dataclasses import dataclass, field, replace -from datetime import datetime, timezone +from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Optional -from pyrit.executor.attack.core.attack_executor import AttackExecutor +from pyrit.executor.attack.compound.sequential_attack import ( + SequenceCompletionPolicy, + SequentialAttack, + SequentialAttackResult, + SequentialChildAttack, +) from pyrit.executor.attack.core.attack_parameters import AttackParameters from pyrit.executor.attack.core.attack_strategy import AttackContext, AttackStrategy -from pyrit.models import AttackOutcome, AttackResult, SeedAttackGroup from pyrit.scenario.scenarios.adaptive.selectors.technique_selector import ADAPTIVE_TECHNIQUE_LABEL if TYPE_CHECKING: - from pyrit.models import SeedAttackTechniqueGroup + from pyrit.models import AttackResult, SeedAttackGroup, SeedAttackTechniqueGroup from pyrit.prompt_target import PromptTarget from pyrit.scenario.scenarios.adaptive.selectors import TechniqueSelector from pyrit.score import TrueFalseScorer @@ -78,11 +90,12 @@ async def from_seed_group_async( """ Build params for a single dispatch and capture the original seed_group. - The dispatcher applies seed_technique merging itself per-attempt, so - we deliberately bypass the base class's simulated-conversation - expansion / next_message extraction: the inner technique runs through - its own ``execute_attack_from_seed_groups_async`` call which performs - that work using the technique-merged seed_group. + The dispatcher applies seed_technique merging itself per-attempt + (when constructing the per-call ``SequentialChildAttack``s), so we + deliberately bypass the base class's simulated-conversation + expansion / next_message extraction: each inner technique runs + through its own ``AttackExecutor`` call inside ``SequentialAttack`` + which performs that work using the technique-merged seed_group. Returns: AdaptiveDispatchParams: The constructed parameters with the seed group attached. @@ -111,36 +124,43 @@ class AdaptiveDispatchContext(AttackContext[AdaptiveDispatchParams]): """Execution context for ``AdaptiveDispatchAttack`` (no extra state).""" -class AdaptiveDispatchAttack(AttackStrategy[AdaptiveDispatchContext, AttackResult]): +class AdaptiveDispatchAttack(AttackStrategy[AdaptiveDispatchContext, SequentialAttackResult]): """ Attack that delegates each attempt to one of several inner techniques, choosing per attempt via a ``TechniqueSelector``. - For each objective, loops up to ``max_attempts_per_objective`` times: - ask the selector, execute the chosen technique against the current seed - group, record the outcome, and stop early on success. The selector is - shared by reference across all dispatch calls in a scenario so learning + For each objective: query the selector for the top + ``max_attempts_per_objective`` techniques compatible with the seed + group, then hand the chosen techniques off to a per-call + ``SequentialAttack`` (with ``SequenceCompletionPolicy.FIRST_SUCCESS``) + which iterates through them, stops on the first success, and returns + one ``SequentialAttackResult`` envelope. The selector is shared by + reference across all dispatch calls in a scenario so learning accumulates across objectives. The seed group for a given dispatch is read from ``context.params.seed_group`` (captured by ``AdaptiveDispatchParams.from_seed_group_async``). When a chosen technique declares a ``seed_technique``, that group is merged into the - seed group before execution (mirroring the static ``AtomicAttack`` path). - Techniques whose ``seed_technique`` is incompatible with the current - seed group are filtered out of the candidate pool for that call; if the - pool is empty the dispatcher raises so the per-call seed group is dropped - by the executor's partial-failure path rather than silently no-op'ing. - - On success, the dispatcher returns a fresh ``AttackResult`` copy of the - winning inner result (new ``attack_result_id`` and ``timestamp``) with - the dispatch trail stamped onto ``metadata``. The inner result has - already been persisted by its own post-execute hook, so two rows are - written per successful objective sharing the same ``conversation_id``: - the inner row carries the raw outcome, the outer row carries the - adaptive trail. + seed group when building the per-attempt ``SequentialChildAttack`` + (mirroring the static ``AtomicAttack`` path). Techniques whose + ``seed_technique`` is incompatible with the current seed group are + filtered out of the candidate pool for that call; if the pool is empty + the dispatcher raises so the per-call seed group is dropped by the + executor's partial-failure path rather than silently no-op'ing. + + The returned envelope owns no conversation of its own + (``conversation_id == ""``): the inner per-attempt ``AttackResult``s + each persist their own row with the raw conversation, and are + reachable via ``result.child_attack_results`` (in-memory) or via + ``result.child_attack_result_ids`` (after a DB round-trip). The + envelope itself is persisted with the dispatch trail stamped onto + ``metadata["adaptive_attempts"]``. """ + ADAPTIVE_ATTEMPTS_KEY: str = "adaptive_attempts" + """Metadata key under which the per-attempt dispatch trail is stamped on the envelope.""" + def __init__( self, *, @@ -183,7 +203,6 @@ def __init__( self._objective_scorer = objective_scorer self._max_attempts = max_attempts_per_objective self._scenario_result_id = scenario_result_id - self._executor = AttackExecutor(max_concurrency=1) def _validate_context(self, *, context: AdaptiveDispatchContext) -> None: """ @@ -196,79 +215,109 @@ def _validate_context(self, *, context: AdaptiveDispatchContext) -> None: raise ValueError("Attack objective must be provided and non-empty") async def _setup_async(self, *, context: AdaptiveDispatchContext) -> None: - """No-op: per-attempt setup is owned by the inner technique's executor.""" + """No-op: per-attempt setup is owned by ``SequentialAttack`` / its executor.""" async def _teardown_async(self, *, context: AdaptiveDispatchContext) -> None: - """No-op: per-attempt teardown is owned by the inner technique's executor.""" + """No-op: per-attempt teardown is owned by ``SequentialAttack`` / its executor.""" - async def _run_inner_attack_async( + def _build_child_attacks( self, *, - bundle: TechniqueBundle, seed_group: SeedAttackGroup, - attempt_labels: dict[str, str], - ) -> AttackResult: + chosen_techniques: list[str], + ) -> list[SequentialChildAttack]: """ - Execute the chosen technique against the per-call seed group. + Build the ``SequentialChildAttack`` list handed to ``SequentialAttack``. - Merges ``bundle.seed_technique`` into ``seed_group`` (when present) - and delegates execution to ``AttackExecutor``. Isolated as a method - so tests can patch the inner-attack call surface. + Per chosen technique: merge ``bundle.seed_technique`` into + ``seed_group`` (if any), and stamp the per-attempt + ``ADAPTIVE_TECHNIQUE_LABEL`` and ``ADAPTIVE_ATTEMPT_LABEL`` memory + labels. ``SequentialAttack`` further merges these with the + compound's own ``context.memory_labels`` at dispatch time, so the + outer caller's labels still propagate to every attempt. Args: - bundle (TechniqueBundle): The chosen technique's attack + seeds + chat. seed_group (SeedAttackGroup): The seed group for this dispatch call. - attempt_labels (dict[str, str]): Memory labels stamped onto this attempt. + chosen_techniques (list[str]): Technique eval hashes returned by + the selector, in priority order. Returns: - AttackResult: The single result produced for this attempt. + list[SequentialChildAttack]: One child attack per chosen + technique, in dispatch order. + """ + child_attacks: list[SequentialChildAttack] = [] + for attempt_idx, chosen in enumerate(chosen_techniques): + bundle = self._techniques[chosen] + execution_group = ( + seed_group.with_technique(technique=bundle.seed_technique) + if bundle.seed_technique is not None + else seed_group + ) + child_attacks.append( + SequentialChildAttack( + strategy=bundle.attack, + seed_group=execution_group, + adversarial_chat=bundle.adversarial_chat, + objective_scorer=self._objective_scorer, + memory_labels={ + ADAPTIVE_TECHNIQUE_LABEL: chosen, + ADAPTIVE_ATTEMPT_LABEL: str(attempt_idx + 1), + }, + ) + ) + return child_attacks - Raises: - RuntimeError: If the executor returned no completed results and no - propagated exception (should be unreachable). + def _build_adaptive_trail( + self, + *, + chosen_techniques: list[str], + child_results: list[AttackResult], + ) -> list[dict[str, str]]: """ - if bundle.seed_technique is not None: - execution_group = seed_group.with_technique(technique=bundle.seed_technique) - else: - execution_group = seed_group - - executor_result = await self._executor.execute_attack_from_seed_groups_async( - attack=bundle.attack, - seed_groups=[execution_group], - adversarial_chat=bundle.adversarial_chat, - objective_scorer=self._objective_scorer, - memory_labels=attempt_labels, - ) + Build the per-attempt dispatch trail stamped onto the envelope. - if executor_result.completed_results: - return executor_result.completed_results[0] - if executor_result.incomplete_objectives: - raise executor_result.incomplete_objectives[0][1] - raise RuntimeError( # pragma: no cover - defensive - "AttackExecutor returned neither completed nor incomplete results." - ) + ``chosen_techniques`` is the full pre-selected list; ``child_results`` + contains only the attempts that actually ran (``FIRST_SUCCESS`` may + halt early). Trail length matches ``len(child_results)``. - async def _perform_async(self, *, context: AdaptiveDispatchContext) -> AttackResult: + Returns: + list[dict[str, str]]: One entry per executed attempt, in + dispatch order, each carrying ``technique`` (display name), + ``technique_hash`` (eval hash), and ``outcome`` + (``AttackOutcome`` value). """ - Run the per-objective adaptive loop. + return [ + { + "technique": self._techniques[h].name, + "technique_hash": h, + "outcome": r.outcome.value, + } + for h, r in zip(chosen_techniques, child_results, strict=False) + ] - Pre-selects up to ``max_attempts_per_objective`` techniques via the - stateless selector, then iterates in priority order. Stops early on - success. + async def _perform_async(self, *, context: AdaptiveDispatchContext) -> SequentialAttackResult: + """ + Run the per-objective adaptive loop via ``SequentialAttack``. + + Queries the stateless selector for the top + ``max_attempts_per_objective`` techniques (filtered by per-call + seed-group compatibility), wraps them in a ``SequentialAttack`` + with ``SequenceCompletionPolicy.FIRST_SUCCESS``, and delegates + iteration + stop-on-success + envelope construction. Stamps the + ``adaptive_attempts`` trail on the envelope before returning. Args: context (AdaptiveDispatchContext): Execution context whose ``params.seed_group`` carries the seed group for this call. Returns: - AttackResult: A fresh dispatcher-owned copy of the final inner - result with the dispatch trail stamped onto ``metadata``. + SequentialAttackResult: The envelope produced by the inner + ``SequentialAttack`` with the adaptive trail stamped onto + ``metadata["adaptive_attempts"]``. Raises: ValueError: If ``context.params.seed_group`` is missing, or if no techniques in the pool are compatible with the seed group. - RuntimeError: If the loop somehow ran zero attempts (unreachable - because ``max_attempts_per_objective`` is validated >= 1). """ seed_group = context.params.seed_group if seed_group is None: @@ -295,43 +344,24 @@ async def _perform_async(self, *, context: AdaptiveDispatchContext) -> AttackRes scenario_result_id=self._scenario_result_id, ) - last_result: AttackResult | None = None - trail: list[dict[str, str]] = [] - - for attempt_idx, chosen in enumerate(chosen_techniques): - bundle = self._techniques[chosen] - attempt_labels = { - **context.memory_labels, - ADAPTIVE_TECHNIQUE_LABEL: chosen, - ADAPTIVE_ATTEMPT_LABEL: str(attempt_idx + 1), - } + child_attacks = self._build_child_attacks( + seed_group=seed_group, + chosen_techniques=chosen_techniques, + ) - logger.debug( - "AdaptiveDispatchAttack: attempt %d/%d technique=%r (hash=%s)", - attempt_idx + 1, - len(chosen_techniques), - bundle.name, - chosen, - ) + sequential = SequentialAttack( + objective_target=self._objective_target, + child_attacks=child_attacks, + completion_policy=SequenceCompletionPolicy.FIRST_SUCCESS, + ) - result = await self._run_inner_attack_async( - bundle=bundle, seed_group=seed_group, attempt_labels=attempt_labels - ) + result: SequentialAttackResult = await sequential.execute_async( + objective=context.objective, + memory_labels=dict(context.memory_labels), + ) - trail.append({"technique": bundle.name, "technique_hash": chosen, "outcome": result.outcome.value}) - last_result = result - - if result.outcome == AttackOutcome.SUCCESS: - break - - if last_result is None: # pragma: no cover - defensive - raise RuntimeError("AdaptiveDispatchAttack ran zero attempts; this should be unreachable.") - return replace( - last_result, - attack_result_id=str(uuid.uuid4()), - timestamp=datetime.now(timezone.utc), - metadata={ - **last_result.metadata, - "adaptive_attempts": trail, - }, + result.metadata[self.ADAPTIVE_ATTEMPTS_KEY] = self._build_adaptive_trail( + chosen_techniques=chosen_techniques, + child_results=result.child_attack_results, ) + return result diff --git a/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py b/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py index 320b6dfd79..dc48249ffe 100644 --- a/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py +++ b/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py @@ -1,10 +1,15 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import MagicMock import pytest +from pyrit.executor.attack.compound.sequential_attack import ( + SequentialAttack, + SequentialAttackResult, + SequentialChildAttack, +) from pyrit.models import AttackOutcome, AttackResult, SeedAttackGroup, SeedObjective from pyrit.scenario.scenarios.adaptive.dispatcher import ( ADAPTIVE_ATTEMPT_LABEL, @@ -42,29 +47,45 @@ def _make_context( ) -def _patch_inner( +def _patch_child_attack( + monkeypatch: pytest.MonkeyPatch, *, - dispatcher: AdaptiveDispatchAttack, bundles: dict[str, TechniqueBundle], -) -> AsyncMock: - """Replace ``_run_inner_attack_async`` with a stub backed by per-bundle outcomes.""" +) -> list[dict]: + """ + Replace ``SequentialAttack._run_child_attack_async`` with a stub backed + by per-bundle outcomes. + + Each invocation records the merged ``memory_labels`` and the resulting + ``AttackResult`` so tests can inspect per-attempt routing and per-attempt + label stamping without monkey-patching ``AttackExecutor``. + """ name_for_attack = {id(b.attack): name for name, b in bundles.items()} counters: dict[str, int] = dict.fromkeys(bundles, 0) + calls: list[dict] = [] - async def _stub(*, bundle: TechniqueBundle, seed_group, attempt_labels: dict[str, str]) -> AttackResult: - name = name_for_attack[id(bundle.attack)] + async def _stub(self, *, child_attack: SequentialChildAttack, memory_labels: dict[str, str], attribution=None): + name = name_for_attack[id(child_attack.strategy)] idx = counters[name] counters[name] = idx + 1 - outcome = bundle.attack._outcomes[idx] - return AttackResult( + outcome = child_attack.strategy._outcomes[idx] + result = AttackResult( conversation_id=f"conv-{name}-{idx}", objective="obj", outcome=outcome, ) + calls.append( + { + "name": name, + "attempt_labels": dict(memory_labels), + "child_attack": child_attack, + "result": result, + } + ) + return result - inner_mock = AsyncMock(side_effect=_stub) - dispatcher._run_inner_attack_async = inner_mock # type: ignore[method-assign] - return inner_mock + monkeypatch.setattr(SequentialAttack, "_run_child_attack_async", _stub, raising=True) + return calls class _StubSelector: @@ -123,7 +144,7 @@ def test_init_rejects_invalid_max_attempts(self, target, selector, seed_group, b @pytest.mark.usefixtures("patch_central_database") class TestPerform: - async def test_stops_on_first_success(self, target, seed_group): + async def test_stops_on_first_success(self, target, seed_group, monkeypatch): bundles = { "a": _make_bundle(name="a", outcomes=[AttackOutcome.SUCCESS]), "b": _make_bundle(name="b", outcomes=[AttackOutcome.SUCCESS]), @@ -135,18 +156,19 @@ async def test_stops_on_first_success(self, target, seed_group): selector=selector, max_attempts_per_objective=5, ) - inner = _patch_inner(dispatcher=dispatcher, bundles=bundles) + calls = _patch_child_attack(monkeypatch, bundles=bundles) result = await dispatcher._perform_async(context=_make_context()) + assert isinstance(result, SequentialAttackResult) assert result.outcome == AttackOutcome.SUCCESS - assert inner.call_count == 1 + assert len(calls) == 1 - async def test_retries_until_max_attempts_on_failure(self, target, seed_group): + async def test_retries_until_max_attempts_on_failure(self, target, seed_group, monkeypatch): bundles = { - "a": _make_bundle(name="a", outcomes=[AttackOutcome.FAILURE] * 3), - "b": _make_bundle(name="b", outcomes=[AttackOutcome.FAILURE] * 3), - "c": _make_bundle(name="c", outcomes=[AttackOutcome.FAILURE] * 3), + "a": _make_bundle(name="a", outcomes=[AttackOutcome.FAILURE]), + "b": _make_bundle(name="b", outcomes=[AttackOutcome.FAILURE]), + "c": _make_bundle(name="c", outcomes=[AttackOutcome.FAILURE]), } selector = _StubSelector(technique_order=["a", "b", "c"]) dispatcher = AdaptiveDispatchAttack( @@ -155,14 +177,14 @@ async def test_retries_until_max_attempts_on_failure(self, target, seed_group): selector=selector, max_attempts_per_objective=3, ) - inner = _patch_inner(dispatcher=dispatcher, bundles=bundles) + calls = _patch_child_attack(monkeypatch, bundles=bundles) result = await dispatcher._perform_async(context=_make_context()) assert result.outcome == AttackOutcome.FAILURE - assert inner.call_count == 3 + assert len(calls) == 3 - async def test_passes_attempt_labels_to_inner(self, target, seed_group): + async def test_passes_attempt_labels_to_inner(self, target, seed_group, monkeypatch): bundles = {"a": _make_bundle(name="a", outcomes=[AttackOutcome.SUCCESS])} selector = _StubSelector(technique_order=["a"]) dispatcher = AdaptiveDispatchAttack( @@ -170,16 +192,16 @@ async def test_passes_attempt_labels_to_inner(self, target, seed_group): techniques=bundles, selector=selector, ) - inner = _patch_inner(dispatcher=dispatcher, bundles=bundles) + calls = _patch_child_attack(monkeypatch, bundles=bundles) await dispatcher._perform_async(context=_make_context(labels={"foo": "bar"})) - labels = inner.call_args.kwargs["attempt_labels"] + labels = calls[0]["attempt_labels"] assert labels["foo"] == "bar" assert labels[ADAPTIVE_TECHNIQUE_LABEL] == "a" assert labels[ADAPTIVE_ATTEMPT_LABEL] == "1" - async def test_metadata_records_adaptive_trail(self, target, seed_group): + async def test_metadata_records_adaptive_trail(self, target, seed_group, monkeypatch): bundles = { "a": _make_bundle(name="a", outcomes=[AttackOutcome.FAILURE]), "b": _make_bundle(name="b", outcomes=[AttackOutcome.SUCCESS]), @@ -191,7 +213,7 @@ async def test_metadata_records_adaptive_trail(self, target, seed_group): selector=selector, max_attempts_per_objective=3, ) - _patch_inner(dispatcher=dispatcher, bundles=bundles) + _patch_child_attack(monkeypatch, bundles=bundles) result = await dispatcher._perform_async(context=_make_context()) trail = result.metadata["adaptive_attempts"] @@ -200,7 +222,7 @@ async def test_metadata_records_adaptive_trail(self, target, seed_group): {"technique": "b", "technique_hash": "b", "outcome": "success"}, ] - async def test_returns_fresh_result_distinct_from_inner(self, target, seed_group): + async def test_envelope_is_distinct_from_child_and_owns_no_conversation(self, target, seed_group, monkeypatch): bundles = {"a": _make_bundle(name="a", outcomes=[AttackOutcome.SUCCESS])} selector = _StubSelector(technique_order=["a"]) dispatcher = AdaptiveDispatchAttack( @@ -208,24 +230,18 @@ async def test_returns_fresh_result_distinct_from_inner(self, target, seed_group techniques=bundles, selector=selector, ) - inner_ids: list[str] = [] - - async def _spy(*, bundle, seed_group, attempt_labels): - inner_result = AttackResult( - conversation_id="conv-a-0", - objective="obj", - outcome=AttackOutcome.SUCCESS, - ) - inner_ids.append(inner_result.attack_result_id) - return inner_result - - dispatcher._run_inner_attack_async = AsyncMock(side_effect=_spy) # type: ignore[method-assign] + calls = _patch_child_attack(monkeypatch, bundles=bundles) result = await dispatcher._perform_async(context=_make_context()) - assert len(inner_ids) == 1 - assert result.attack_result_id != inner_ids[0] + assert len(calls) == 1 + inner_result = calls[0]["result"] + # The envelope is a fresh wrapper owning no conversation; the inner + # attempt's row lives on child_attack_results. + assert result.attack_result_id != inner_result.attack_result_id assert result.outcome == AttackOutcome.SUCCESS + assert result.conversation_id == "" + assert result.child_attack_results == [inner_result] assert result.metadata["adaptive_attempts"] == [{"technique": "a", "technique_hash": "a", "outcome": "success"}] From 70cda938b63f6a4a44723ca2019c01400e1aa4e6 Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Fri, 29 May 2026 11:49:59 -0400 Subject: [PATCH 18/35] docs: add RapidResponse scenario example to common parameters guide Add a new 'Rapid Response Scenario' section demonstrating how to use RapidResponse with focused dataset configuration and strategy selection. Update intro text to reference the new section. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../1_common_scenario_parameters.ipynb | 46 ++++++++++++++++++- .../scenarios/1_common_scenario_parameters.py | 36 +++++++++++++-- 2 files changed, 77 insertions(+), 5 deletions(-) diff --git a/doc/code/scenarios/1_common_scenario_parameters.ipynb b/doc/code/scenarios/1_common_scenario_parameters.ipynb index 9da9b9195b..880fdbc8bb 100644 --- a/doc/code/scenarios/1_common_scenario_parameters.ipynb +++ b/doc/code/scenarios/1_common_scenario_parameters.ipynb @@ -8,8 +8,8 @@ "# Common Scenario Parameters\n", "\n", "This guide covers the key parameters for configuring scenarios programmatically: datasets,\n", - "strategies, baseline execution, and custom scorers. All examples use `RedTeamAgent` but the\n", - "patterns apply to any scenario.\n", + "strategies, baseline execution, and custom scorers. Most examples use `RedTeamAgent` but the\n", + "patterns apply to any scenario — including [`RapidResponse`](#rapid-response-scenario).\n", "\n", "> **Two selection axes**: *Strategies* select attack techniques (*how* attacks run — e.g., prompt\n", "> sending, role play, TAP). *Datasets* select objectives (*what* is tested — e.g., harm categories,\n", @@ -713,6 +713,48 @@ "custom_result = await custom_scenario.run_async() # type: ignore\n", "await output_scenario_async(custom_result)" ] + }, + { + "cell_type": "markdown", + "id": "12d23cc4", + "metadata": {}, + "source": [ + "## Rapid Response Scenario\n", + "\n", + "`RapidResponse` is the content-harms testing scenario. It tests model behavior across multiple\n", + "harm categories (hate, fairness, violence, sexual, harassment, misinformation, leakage) using\n", + "selectable attack techniques (PromptSending, RolePlay, ManyShot, TAP). Unlike `RedTeamAgent`,\n", + "its results are grouped by **harm category** rather than by attack technique.\n", + "\n", + "The same parameter patterns from above — datasets, strategies, baseline, and custom scorers —\n", + "all work with `RapidResponse`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cb751c92", + "metadata": {}, + "outputs": [], + "source": [ + "from pyrit.scenario.scenarios.airt import RapidResponse, RapidResponseStrategy\n", + "\n", + "rapid_response = RapidResponse()\n", + "\n", + "# Use a focused dataset config — test only hate and fairness categories with 2 objectives each\n", + "rapid_dataset_config = DatasetConfiguration(\n", + " dataset_names=[\"airt_hate\", \"airt_fairness\"],\n", + " max_dataset_size=2,\n", + ")\n", + "\n", + "await rapid_response.initialize_async( # type: ignore\n", + " objective_target=objective_target,\n", + " scenario_strategies=[RapidResponseStrategy.prompt_sending],\n", + " dataset_config=rapid_dataset_config,\n", + ")\n", + "rapid_result = await rapid_response.run_async() # type: ignore\n", + "await output_scenario_async(rapid_result)" + ] } ], "metadata": { diff --git a/doc/code/scenarios/1_common_scenario_parameters.py b/doc/code/scenarios/1_common_scenario_parameters.py index 230e02fff3..82611f3cad 100644 --- a/doc/code/scenarios/1_common_scenario_parameters.py +++ b/doc/code/scenarios/1_common_scenario_parameters.py @@ -5,15 +5,15 @@ # extension: .py # format_name: percent # format_version: '1.3' -# jupytext_version: 1.18.1 +# jupytext_version: 1.19.3 # --- # %% [markdown] # # Common Scenario Parameters # # This guide covers the key parameters for configuring scenarios programmatically: datasets, -# strategies, baseline execution, and custom scorers. All examples use `RedTeamAgent` but the -# patterns apply to any scenario. +# strategies, baseline execution, and custom scorers. Most examples use `RedTeamAgent` but the +# patterns apply to any scenario — including [`RapidResponse`](#rapid-response-scenario). # # > **Two selection axes**: *Strategies* select attack techniques (*how* attacks run — e.g., prompt # > sending, role play, TAP). *Datasets* select objectives (*what* is tested — e.g., harm categories, @@ -172,3 +172,33 @@ ) custom_result = await custom_scenario.run_async() # type: ignore await output_scenario_async(custom_result) + +# %% [markdown] +# ## Rapid Response Scenario +# +# `RapidResponse` is the content-harms testing scenario. It tests model behavior across multiple +# harm categories (hate, fairness, violence, sexual, harassment, misinformation, leakage) using +# selectable attack techniques (PromptSending, RolePlay, ManyShot, TAP). Unlike `RedTeamAgent`, +# its results are grouped by **harm category** rather than by attack technique. +# +# The same parameter patterns from above — datasets, strategies, baseline, and custom scorers — +# all work with `RapidResponse`. + +# %% +from pyrit.scenario.scenarios.airt import RapidResponse, RapidResponseStrategy + +rapid_response = RapidResponse() + +# Use a focused dataset config — test only hate and fairness categories with 2 objectives each +rapid_dataset_config = DatasetConfiguration( + dataset_names=["airt_hate", "airt_fairness"], + max_dataset_size=2, +) + +await rapid_response.initialize_async( # type: ignore + objective_target=objective_target, + scenario_strategies=[RapidResponseStrategy.prompt_sending], + dataset_config=rapid_dataset_config, +) +rapid_result = await rapid_response.run_async() # type: ignore +await output_scenario_async(rapid_result) From 9203e53cbd0279a04c7040d2d06804cfffc47eef Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Mon, 1 Jun 2026 12:09:17 -0400 Subject: [PATCH 19/35] bug fixes --- doc/code/scenarios/3_adaptive_scenarios.ipynb | 50 +++-- doc/code/scenarios/3_adaptive_scenarios.py | 50 +++-- pyrit/cli/api_client.py | 84 ++++++++- pyrit/cli/pyrit_scan.py | 55 +++++- .../executor/attack/multi_turn/red_teaming.py | 31 +-- pyrit/models/seeds/seed_attack_group.py | 9 +- .../scenario/core/attack_technique_factory.py | 13 ++ .../scenarios/adaptive/adaptive_scenario.py | 55 +++++- .../scenario/scenarios/adaptive/dispatcher.py | 22 ++- tests/unit/cli/test_api_client.py | 39 +++- .../scenarios/adaptive/test_dispatcher.py | 176 +++++++++++++++++- .../scenarios/adaptive/test_text_adaptive.py | 101 +++++++++- 12 files changed, 622 insertions(+), 63 deletions(-) diff --git a/doc/code/scenarios/3_adaptive_scenarios.ipynb b/doc/code/scenarios/3_adaptive_scenarios.ipynb index 0c9301d302..e0a675ba3e 100644 --- a/doc/code/scenarios/3_adaptive_scenarios.ipynb +++ b/doc/code/scenarios/3_adaptive_scenarios.ipynb @@ -195,7 +195,10 @@ "- `adaptive_attempts` — the ordered list of `{\"technique\", \"outcome\"}` dicts\n", " recording exactly which techniques the selector picked and what happened.\n", "\n", - "Walk that metadata to see the per-objective trail and aggregate counts." + "Walk that metadata to see per-objective trails grouped by dataset, with a\n", + "per-technique success-rate table inside each group and a grand-total at the\n", + "bottom. Use `result.get_display_groups()` to aggregate `attack_results` by\n", + "the per-dataset display label set by the scenario." ] }, { @@ -207,26 +210,51 @@ "source": [ "from collections import Counter\n", "\n", - "# Per-objective trail\n", - "for results in resumed_result.attack_results.values():\n", + "# Per-group: one line per objective (the envelope, carrying the technique trail)\n", + "# plus a per-technique success-rate table within the group. Each adaptive run\n", + "# persists both the per-objective envelope AND its per-attempt child rows; the\n", + "# children are filtered out of the per-objective list so it stays one line per\n", + "# objective. Aggregate across groups for a final grand-total table.\n", + "display_groups = resumed_result.get_display_groups()\n", + "\n", + "total_picks: Counter[str] = Counter()\n", + "total_wins: Counter[str] = Counter()\n", + "\n", + "for group_name, results in display_groups.items():\n", + " print(f\"\\n=== Group: {group_name} ===\")\n", + "\n", + " # Collect every child id referenced by any envelope in this group so we\n", + " # can skip the per-attempt child rows when printing per-objective lines.\n", + " # Baseline rows have no envelope and pass through untouched.\n", + " child_ids: set[str] = set()\n", " for r in results:\n", + " child_ids.update(r.metadata.get(\"child_attack_result_ids\", []) or [])\n", + "\n", + " for r in results:\n", + " if r.attack_result_id in child_ids:\n", + " continue\n", " attempts = r.metadata.get(\"adaptive_attempts\", [])\n", " trail = \" → \".join(f\"{a['technique']}({a['outcome']})\" for a in attempts)\n", - " print(f\"[{r.outcome.value:7s}] {r.objective!r}: {trail}\")\n", + " print(f\" [{r.outcome.value:7s}] {r.objective!r}: {trail}\")\n", "\n", - "# Aggregate per-technique pick counts and success rate across the run\n", - "picks: Counter[str] = Counter()\n", - "wins: Counter[str] = Counter()\n", - "for results in resumed_result.attack_results.values():\n", + " picks: Counter[str] = Counter()\n", + " wins: Counter[str] = Counter()\n", " for r in results:\n", " for step in r.metadata.get(\"adaptive_attempts\", []):\n", " picks[step[\"technique\"]] += 1\n", + " total_picks[step[\"technique\"]] += 1\n", " if step[\"outcome\"] == \"success\":\n", " wins[step[\"technique\"]] += 1\n", + " total_wins[step[\"technique\"]] += 1\n", + "\n", + " print(\"\\n Technique wins / picks rate\")\n", + " for technique, n in picks.most_common():\n", + " print(f\" {technique:20s} {wins[technique]:>4} / {n:<4} {wins[technique] / n:.0%}\")\n", "\n", - "print(\"\\nTechnique wins / picks rate\")\n", - "for technique, n in picks.most_common():\n", - " print(f\"{technique:20s} {wins[technique]:>4} / {n:<4} {wins[technique] / n:.0%}\")" + "print(\"\\n=== Overall ===\")\n", + "print(\"Technique wins / picks rate\")\n", + "for technique, n in total_picks.most_common():\n", + " print(f\"{technique:20s} {total_wins[technique]:>4} / {n:<4} {total_wins[technique] / n:.0%}\")" ] }, { diff --git a/doc/code/scenarios/3_adaptive_scenarios.py b/doc/code/scenarios/3_adaptive_scenarios.py index 8826c42406..0fdd444a9e 100644 --- a/doc/code/scenarios/3_adaptive_scenarios.py +++ b/doc/code/scenarios/3_adaptive_scenarios.py @@ -146,31 +146,59 @@ # - `adaptive_attempts` — the ordered list of `{"technique", "outcome"}` dicts # recording exactly which techniques the selector picked and what happened. # -# Walk that metadata to see the per-objective trail and aggregate counts. +# Walk that metadata to see per-objective trails grouped by dataset, with a +# per-technique success-rate table inside each group and a grand-total at the +# bottom. Use `result.get_display_groups()` to aggregate `attack_results` by +# the per-dataset display label set by the scenario. # %% from collections import Counter -# Per-objective trail -for results in resumed_result.attack_results.values(): +# Per-group: one line per objective (the envelope, carrying the technique trail) +# plus a per-technique success-rate table within the group. Each adaptive run +# persists both the per-objective envelope AND its per-attempt child rows; the +# children are filtered out of the per-objective list so it stays one line per +# objective. Aggregate across groups for a final grand-total table. +display_groups = resumed_result.get_display_groups() + +total_picks: Counter[str] = Counter() +total_wins: Counter[str] = Counter() + +for group_name, results in display_groups.items(): + print(f"\n=== Group: {group_name} ===") + + # Collect every child id referenced by any envelope in this group so we + # can skip the per-attempt child rows when printing per-objective lines. + # Baseline rows have no envelope and pass through untouched. + child_ids: set[str] = set() for r in results: + child_ids.update(r.metadata.get("child_attack_result_ids", []) or []) + + for r in results: + if r.attack_result_id in child_ids: + continue attempts = r.metadata.get("adaptive_attempts", []) trail = " → ".join(f"{a['technique']}({a['outcome']})" for a in attempts) - print(f"[{r.outcome.value:7s}] {r.objective!r}: {trail}") + print(f" [{r.outcome.value:7s}] {r.objective!r}: {trail}") -# Aggregate per-technique pick counts and success rate across the run -picks: Counter[str] = Counter() -wins: Counter[str] = Counter() -for results in resumed_result.attack_results.values(): + picks: Counter[str] = Counter() + wins: Counter[str] = Counter() for r in results: for step in r.metadata.get("adaptive_attempts", []): picks[step["technique"]] += 1 + total_picks[step["technique"]] += 1 if step["outcome"] == "success": wins[step["technique"]] += 1 + total_wins[step["technique"]] += 1 + + print("\n Technique wins / picks rate") + for technique, n in picks.most_common(): + print(f" {technique:20s} {wins[technique]:>4} / {n:<4} {wins[technique] / n:.0%}") -print("\nTechnique wins / picks rate") -for technique, n in picks.most_common(): - print(f"{technique:20s} {wins[technique]:>4} / {n:<4} {wins[technique] / n:.0%}") +print("\n=== Overall ===") +print("Technique wins / picks rate") +for technique, n in total_picks.most_common(): + print(f"{technique:20s} {total_wins[technique]:>4} / {n:<4} {total_wins[technique] / n:.0%}") # %% [markdown] # ## Running from the scanner CLI diff --git a/pyrit/cli/api_client.py b/pyrit/cli/api_client.py index 11674a2298..7a387d0597 100644 --- a/pyrit/cli/api_client.py +++ b/pyrit/cli/api_client.py @@ -33,14 +33,20 @@ class PyRITApiClient: scenarios = await client.list_scenarios_async() """ - def __init__(self, *, base_url: str) -> None: + def __init__(self, *, base_url: str, request_timeout: float | None = None) -> None: """ Initialize the API client. Args: base_url (str): Base URL of the PyRIT backend (e.g., ``"http://localhost:8000"``). + request_timeout (float | None): Read timeout in seconds applied to every + non-polling request (catalog, results, cancel, start, etc.). Polling + the live scenario-run endpoint always uses ``read=None`` regardless + of this value, because the server may legitimately take many seconds + to respond while a scenario is executing. Defaults to ``60.0``. """ self._base_url = base_url.rstrip("/") + self._request_timeout = request_timeout if request_timeout is not None else 60.0 self._client: Any = None # httpx.AsyncClient (typed Any to avoid top-level import) async def __aenter__(self) -> PyRITApiClient: @@ -52,7 +58,7 @@ async def __aenter__(self) -> PyRITApiClient: """ import httpx - self._client = httpx.AsyncClient(base_url=self._base_url, timeout=60.0) + self._client = httpx.AsyncClient(base_url=self._base_url, timeout=self._request_timeout) return self async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: @@ -149,7 +155,7 @@ async def register_initializer_async(self, *, name: str, script_content: str) -> if resp.status_code == 403: detail = resp.json().get("detail", "Custom initializer operations are disabled on the server.") raise ServerNotAvailableError(detail) - resp.raise_for_status() + self._raise_for_status(resp) return resp.json() # ------------------------------------------------------------------ @@ -181,17 +187,41 @@ async def start_scenario_run_async(self, *, request: dict[str, Any]) -> dict[str """ client = self._get_client() resp = await client.post("/api/scenarios/runs", json=request) - resp.raise_for_status() + self._raise_for_status(resp) return resp.json() async def get_scenario_run_async(self, *, scenario_result_id: str) -> dict[str, Any]: """ Get the current status of a scenario run. + This is the endpoint the CLI polls while waiting for a run to finish. + It uses ``read=None`` (wait indefinitely for a response) so a server + busy executing a long-running scenario doesn't trip the client's + default read timeout. The other endpoints keep the configured timeout. + Returns: dict: ``ScenarioRunSummary`` payload. + + Raises: + ServerNotAvailableError: If the server cannot be reached. """ - return await self._get_json(path=f"/api/scenarios/runs/{scenario_result_id}") + import httpx + + client = self._get_client() + try: + resp = await client.get( + f"/api/scenarios/runs/{scenario_result_id}", + params=None, + timeout=httpx.Timeout(connect=10.0, read=None, write=30.0, pool=10.0), + ) + except httpx.ConnectError as exc: + raise ServerNotAvailableError( + f"Cannot connect to PyRIT server at {self._base_url}.\n" + "Hint: Use '--start-server' to launch a local backend, " + "or pass '--server-url '." + ) from exc + self._raise_for_status(resp) + return resp.json() async def get_scenario_run_results_async(self, *, scenario_result_id: str) -> dict[str, Any]: """ @@ -211,7 +241,7 @@ async def cancel_scenario_run_async(self, *, scenario_result_id: str) -> dict[st """ client = self._get_client() resp = await client.post(f"/api/scenarios/runs/{scenario_result_id}/cancel") - resp.raise_for_status() + self._raise_for_status(resp) return resp.json() async def list_scenario_runs_async(self, *, limit: int = 100) -> dict[str, Any]: @@ -275,5 +305,45 @@ async def _get_json(self, *, path: str, params: dict[str, Any] | None = None) -> "Hint: Use '--start-server' to launch a local backend, " "or pass '--server-url '." ) from exc - resp.raise_for_status() + self._raise_for_status(resp) return resp.json() + + @staticmethod + def _raise_for_status(resp: Any) -> None: + """ + Raise an HTTP error with the response body appended to the message. + + Behaves like ``httpx.Response.raise_for_status`` but includes the + ``detail`` field from the response body (falling back to raw text) so + CLI users can see the actual server-side reason instead of just the + HTTP status line. The exception type is preserved so existing callers + / tests continue to work. + + Raises: + httpx.HTTPStatusError: When the response carries a 4xx or 5xx status. + """ + import httpx + + try: + resp.raise_for_status() + except httpx.HTTPStatusError as exc: + detail: str | None = None + try: + payload = resp.json() + except Exception: + payload = None + if isinstance(payload, dict): + detail_value = payload.get("detail") + if isinstance(detail_value, str) and detail_value.strip(): + detail = detail_value + elif detail_value is not None: + detail = str(detail_value) + if detail is None: + text = getattr(resp, "text", "") or "" + text = text.strip() + if text: + detail = text + if detail is None: + raise + message = f"{exc}: {detail}" + raise httpx.HTTPStatusError(message, request=exc.request, response=exc.response) from exc diff --git a/pyrit/cli/pyrit_scan.py b/pyrit/cli/pyrit_scan.py index 70ec9c2f4d..bc5314261a 100644 --- a/pyrit/cli/pyrit_scan.py +++ b/pyrit/cli/pyrit_scan.py @@ -30,6 +30,44 @@ _TERMINAL_STATUSES = {"COMPLETED", "FAILED", "CANCELLED"} +def _print_cli_exception(*, exc: BaseException) -> None: + """ + Print a user-facing error line for an exception that bubbled out of the CLI. + + Surfaces the exception class (so callers can tell ``ReadTimeout`` apart from + ``HTTPStatusError``) and dumps the traceback when log-level is ``DEBUG``. + Adds a specific hint for ``httpx.ReadTimeout`` since that case usually means + the server is taking longer than ``--request-timeout`` to respond and the + default bare ``str(exc)`` is empty. + + Args: + exc (BaseException): The exception caught by the CLI. + """ + import traceback + + try: + import httpx + + is_read_timeout = isinstance(exc, httpx.ReadTimeout) + except Exception: + is_read_timeout = False + + cls_name = type(exc).__name__ + detail = str(exc) or repr(exc) + + if is_read_timeout: + print( + "\nError (ReadTimeout): server did not respond in time. " + "Pass '--request-timeout ' to wait longer, or check the " + "server logs for a blocked event loop." + ) + else: + print(f"\nError ({cls_name}): {detail}") + + if logging.getLogger().isEnabledFor(logging.DEBUG): + traceback.print_exception(type(exc), exc, exc.__traceback__) + + _DESCRIPTION = """PyRIT Scanner - Run AI security scenarios from the command line. Requires a running PyRIT backend server. Use --start-server to launch one, @@ -113,6 +151,16 @@ def _build_base_parser(*, add_help: bool = True) -> ArgumentParser: default=logging.WARNING, help="Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) (default: WARNING)", ) + server_group.add_argument( + "--request-timeout", + type=float, + default=None, + help=( + "HTTP read timeout in seconds for non-polling server requests " + "(catalog/results/cancel/etc). Defaults to 60. Polling a live " + "scenario run always waits indefinitely regardless of this value." + ), + ) # -- Discovery -- discovery_group = parser.add_argument_group("discovery") @@ -696,7 +744,10 @@ async def _run_async(*, parsed_args: Namespace) -> int: return 0 try: - async with PyRITApiClient(base_url=base_url_result) as client: + async with PyRITApiClient( + base_url=base_url_result, + request_timeout=getattr(parsed_args, "request_timeout", None), + ) as client: return await _dispatch_with_client_async(client=client, parsed_args=parsed_args) except ServerNotAvailableError as exc: _output.print_error_with_hint( @@ -705,7 +756,7 @@ async def _run_async(*, parsed_args: Namespace) -> int: ) return 1 except Exception as exc: - print(f"\nError: {exc}") + _print_cli_exception(exc=exc) return 1 diff --git a/pyrit/executor/attack/multi_turn/red_teaming.py b/pyrit/executor/attack/multi_turn/red_teaming.py index 4d5f09b2a2..0c0fa1e4be 100644 --- a/pyrit/executor/attack/multi_turn/red_teaming.py +++ b/pyrit/executor/attack/multi_turn/red_teaming.py @@ -247,20 +247,6 @@ async def _setup_async(self, *, context: MultiTurnAttackContext[Any]) -> None: memory_labels=self._memory_labels, ) - # Set up adversarial chat with prepended conversation - if context.prepended_conversation: - # Get adversarial messages with swapped roles - adversarial_messages = get_adversarial_chat_messages( - prepended_conversation=context.prepended_conversation, - adversarial_chat_conversation_id=context.session.adversarial_chat_conversation_id, - attack_identifier=self.get_identifier(), - adversarial_chat_target_identifier=self._adversarial_chat.get_identifier(), - labels=context.memory_labels, - ) - - for msg in adversarial_messages: - self._memory.add_message_to_memory(request=msg) - adversarial_system_prompt = self._adversarial_chat_system_prompt_template.render_template_value( objective=context.objective, max_turns=self._max_turns, @@ -268,6 +254,9 @@ async def _setup_async(self, *, context: MultiTurnAttackContext[Any]) -> None: if not adversarial_system_prompt: raise ValueError("Adversarial chat system prompt must be defined") + # ``set_system_prompt`` rejects any conversation that already has messages, + # so it must run before we hydrate the adversarial chat with the swapped + # prepended turns below. self._adversarial_chat.set_system_prompt( system_prompt=adversarial_system_prompt, conversation_id=context.session.adversarial_chat_conversation_id, @@ -275,6 +264,20 @@ async def _setup_async(self, *, context: MultiTurnAttackContext[Any]) -> None: labels=context.memory_labels, # deprecated ) + # Set up adversarial chat with prepended conversation + if context.prepended_conversation: + # Get adversarial messages with swapped roles + adversarial_messages = get_adversarial_chat_messages( + prepended_conversation=context.prepended_conversation, + adversarial_chat_conversation_id=context.session.adversarial_chat_conversation_id, + attack_identifier=self.get_identifier(), + adversarial_chat_target_identifier=self._adversarial_chat.get_identifier(), + labels=context.memory_labels, + ) + + for msg in adversarial_messages: + self._memory.add_message_to_memory(request=msg) + async def _perform_async(self, *, context: MultiTurnAttackContext[Any]) -> AttackResult: """ Execute the red teaming attack by iteratively generating prompts, diff --git a/pyrit/models/seeds/seed_attack_group.py b/pyrit/models/seeds/seed_attack_group.py index 3c02c96d9c..029ea17a64 100644 --- a/pyrit/models/seeds/seed_attack_group.py +++ b/pyrit/models/seeds/seed_attack_group.py @@ -9,6 +9,7 @@ from __future__ import annotations +import copy from typing import TYPE_CHECKING, Any, Union from pyrit.models.seeds.seed_group import SeedGroup @@ -178,9 +179,13 @@ def with_technique(self, *, technique: SeedAttackTechniqueGroup) -> SeedAttackGr technique_seeds = list(technique.seeds) merged_seeds = base + technique_seeds if idx is None else base[:idx] + technique_seeds + base[idx:] + # ``self`` and ``technique`` may be shared across multiple ``with_technique`` + # calls. Deepcopy first so the originals are untouched + merged_seeds = [copy.deepcopy(seed) for seed in merged_seeds] + # Clear group IDs so the new group assigns a fresh one. - # This mutates the seed objects, but _enforce_consistent_group_id - # in the constructor will immediately overwrite with a new UUID. + # ``_enforce_consistent_group_id`` in the constructor will overwrite + # all of them with a single new UUID. for seed in merged_seeds: seed.prompt_group_id = None diff --git a/pyrit/scenario/core/attack_technique_factory.py b/pyrit/scenario/core/attack_technique_factory.py index a080c1550a..ee19aafcba 100644 --- a/pyrit/scenario/core/attack_technique_factory.py +++ b/pyrit/scenario/core/attack_technique_factory.py @@ -160,6 +160,19 @@ def adversarial_chat(self) -> PromptTarget | None: """The adversarial chat target baked into this factory, or None.""" return self._adversarial_config.target if self._adversarial_config else None + @property + def scoring_config_type(self) -> type | None: + """ + The narrowed ``attack_scoring_config`` annotation declared by the attack class. + + Returns the concrete subtype of :class:`AttackScoringConfig` the attack's + constructor expects (e.g. ``TAPAttackScoringConfig`` for TAP), or ``None`` + when the base type is accepted or the annotation cannot be resolved. + Callers can use this to build a config of the right shape before invoking + :meth:`create`. + """ + return self._get_scoring_config_type() + def create( self, *, diff --git a/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py b/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py index 0f68baa2f5..72d00528f7 100644 --- a/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py +++ b/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py @@ -35,6 +35,7 @@ if TYPE_CHECKING: from pyrit.models import SeedAttackGroup from pyrit.prompt_target import PromptTarget + from pyrit.scenario.core.attack_technique_factory import AttackTechniqueFactory from pyrit.score import TrueFalseScorer logger = logging.getLogger(__name__) @@ -144,6 +145,13 @@ def _build_techniques_dict( selector and analytics to track techniques by their behavioral configuration rather than by name alone. + For factories whose attack class narrows ``attack_scoring_config`` to a + specific subtype (e.g. ``TAPAttackScoringConfig`` for TAP), this method + builds the matching subtype using the scenario's objective scorer. + Techniques whose factory rejects the scenario scorer at construction + time (e.g. TAP also requires a ``FloatScaleThresholdScorer`` at runtime) + are dropped with a warning so the rest of the pool continues to run. + Returns: dict[str, TechniqueBundle]: Mapping from technique eval hash to its bundle, in the order selected strategies were resolved. @@ -154,20 +162,26 @@ def _build_techniques_dict( """ selected_techniques = sorted({s.value for s in self._scenario_strategies}) factories = self._get_attack_technique_factories() - scoring_config = AttackScoringConfig(objective_scorer=self._objective_scorer) techniques: dict[str, TechniqueBundle] = {} skipped_no_factory: list[str] = [] + skipped_incompatible: dict[str, str] = {} for technique_name in selected_techniques: factory = factories.get(technique_name) if factory is None: skipped_no_factory.append(technique_name) logger.warning(f"No factory for technique '{technique_name}', skipping.") continue - technique = factory.create( - objective_target=objective_target, - attack_scoring_config=scoring_config, - ) + scoring_config = self._build_scoring_config_for_factory(factory=factory) + try: + technique = factory.create( + objective_target=objective_target, + attack_scoring_config=scoring_config, + ) + except (TypeError, ValueError) as exc: + skipped_incompatible[technique_name] = str(exc) + logger.warning(f"Skipping technique '{technique_name}': {type(exc).__name__}: {exc}") + continue eval_hash = technique.get_identifier().hash assert eval_hash is not None, f"Technique {technique_name!r} produced no identifier hash" techniques[eval_hash] = TechniqueBundle( @@ -178,7 +192,12 @@ def _build_techniques_dict( ) if not techniques: - suffix = f" (skipped, no factory registered: {sorted(skipped_no_factory)})" if skipped_no_factory else "" + details: list[str] = [] + if skipped_no_factory: + details.append(f"no factory registered: {sorted(skipped_no_factory)}") + if skipped_incompatible: + details.append(f"incompatible with scenario scorer: {sorted(skipped_incompatible)}") + suffix = f" ({'; '.join(details)})" if details else "" raise ValueError( f"{type(self).__name__}: no usable techniques after resolving strategies. " f"Check the --strategies selection.{suffix}" @@ -186,6 +205,30 @@ def _build_techniques_dict( return techniques + def _build_scoring_config_for_factory(self, *, factory: AttackTechniqueFactory) -> AttackScoringConfig: + """ + Build the most specific scoring config the factory's attack class accepts. + + When the attack's constructor narrows ``attack_scoring_config`` to a + subtype of ``AttackScoringConfig`` (e.g. TAP requires + ``TAPAttackScoringConfig``), construct that subtype directly so the + factory does not have to fall back to its WARN policy and silently + substitute an internal default scorer. When the subtype itself rejects + the scenario's objective scorer at construction, fall back to the base + ``AttackScoringConfig``; ``_build_techniques_dict`` will then catch the + constructor-time rejection and skip the technique. + + Returns: + AttackScoringConfig: The most specific config that could be built. + """ + required = factory.scoring_config_type + if required is None or required is AttackScoringConfig: + return AttackScoringConfig(objective_scorer=self._objective_scorer) + try: + return required(objective_scorer=self._objective_scorer) + except (TypeError, ValueError): + return AttackScoringConfig(objective_scorer=self._objective_scorer) + def _build_atomic_for_dataset( self, *, diff --git a/pyrit/scenario/scenarios/adaptive/dispatcher.py b/pyrit/scenario/scenarios/adaptive/dispatcher.py index 7a22fdcc4e..4709c4efe1 100644 --- a/pyrit/scenario/scenarios/adaptive/dispatcher.py +++ b/pyrit/scenario/scenarios/adaptive/dispatcher.py @@ -38,6 +38,8 @@ from pyrit.scenario.scenarios.adaptive.selectors.technique_selector import ADAPTIVE_TECHNIQUE_LABEL if TYPE_CHECKING: + from collections.abc import Sequence + from pyrit.models import AttackResult, SeedAttackGroup, SeedAttackTechniqueGroup from pyrit.prompt_target import PromptTarget from pyrit.scenario.scenarios.adaptive.selectors import TechniqueSelector @@ -224,7 +226,7 @@ def _build_child_attacks( self, *, seed_group: SeedAttackGroup, - chosen_techniques: list[str], + chosen_techniques: Sequence[str], ) -> list[SequentialChildAttack]: """ Build the ``SequentialChildAttack`` list handed to ``SequentialAttack``. @@ -238,7 +240,7 @@ def _build_child_attacks( Args: seed_group (SeedAttackGroup): The seed group for this dispatch call. - chosen_techniques (list[str]): Technique eval hashes returned by + chosen_techniques (Sequence[str]): Technique eval hashes returned by the selector, in priority order. Returns: @@ -270,7 +272,7 @@ def _build_child_attacks( def _build_adaptive_trail( self, *, - chosen_techniques: list[str], + chosen_techniques: Sequence[str], child_results: list[AttackResult], ) -> list[dict[str, str]]: """ @@ -306,6 +308,11 @@ async def _perform_async(self, *, context: AdaptiveDispatchContext) -> Sequentia iteration + stop-on-success + envelope construction. Stamps the ``adaptive_attempts`` trail on the envelope before returning. + Drives the inner sequence via ``_perform_async`` (not + ``execute_async``) so the outer dispatcher remains the sole owner + of envelope persistence; ``_attribution`` is forwarded so child + rows keep the scenario linkage. + Args: context (AdaptiveDispatchContext): Execution context whose ``params.seed_group`` carries the seed group for this call. @@ -355,10 +362,17 @@ async def _perform_async(self, *, context: AdaptiveDispatchContext) -> Sequentia completion_policy=SequenceCompletionPolicy.FIRST_SUCCESS, ) - result: SequentialAttackResult = await sequential.execute_async( + # Call ``_perform_async`` directly; ``execute_async`` would persist the same + # envelope a second time and trip an IntegrityError. Forward ``_attribution`` + # so child rows still carry the scenario linkage. + inner_params = sequential._params_type( objective=context.objective, memory_labels=dict(context.memory_labels), ) + inner_context = sequential._context_type(params=inner_params) + inner_context._attribution = context._attribution + + result: SequentialAttackResult = await sequential._perform_async(context=inner_context) result.metadata[self.ADAPTIVE_ATTEMPTS_KEY] = self._build_adaptive_trail( chosen_techniques=chosen_techniques, diff --git a/tests/unit/cli/test_api_client.py b/tests/unit/cli/test_api_client.py index f379985b85..91b7433380 100644 --- a/tests/unit/cli/test_api_client.py +++ b/tests/unit/cli/test_api_client.py @@ -59,6 +59,26 @@ async def test_async_context_manager_opens_and_closes(mock_httpx_client): # After exit, close was called mock_httpx_client.aclose.assert_awaited_once() assert c._client is None + # Default request_timeout (60s) propagates to the httpx client constructor. + fake_async_client_cls.assert_called_once_with(base_url="http://localhost:8000", timeout=60.0) + + +async def test_async_context_manager_passes_custom_request_timeout(mock_httpx_client): + c = PyRITApiClient(base_url="http://localhost:8000", request_timeout=120.0) + fake_async_client_cls = MagicMock(return_value=mock_httpx_client) + with patch("httpx.AsyncClient", fake_async_client_cls): + async with c: + pass + fake_async_client_cls.assert_called_once_with(base_url="http://localhost:8000", timeout=120.0) + + +async def test_async_context_manager_uses_default_when_request_timeout_is_none(mock_httpx_client): + c = PyRITApiClient(base_url="http://localhost:8000", request_timeout=None) + fake_async_client_cls = MagicMock(return_value=mock_httpx_client) + with patch("httpx.AsyncClient", fake_async_client_cls): + async with c: + pass + fake_async_client_cls.assert_called_once_with(base_url="http://localhost:8000", timeout=60.0) async def test_close_async_is_noop_when_already_closed(): @@ -199,10 +219,27 @@ async def test_start_scenario_run_async(client, mock_httpx_client): async def test_get_scenario_run_async(client, mock_httpx_client): + import httpx as _httpx + mock_httpx_client.get.return_value = _make_response(json_data={"status": "RUNNING"}) result = await client.get_scenario_run_async(scenario_result_id="abc") assert result == {"status": "RUNNING"} - mock_httpx_client.get.assert_awaited_once_with("/api/scenarios/runs/abc", params=None) + # Polling uses read=None so a busy server doesn't trip the client default + # timeout while a scenario is executing. + mock_httpx_client.get.assert_awaited_once() + args, kwargs = mock_httpx_client.get.call_args + assert args == ("/api/scenarios/runs/abc",) + assert kwargs["params"] is None + timeout = kwargs["timeout"] + assert isinstance(timeout, _httpx.Timeout) + assert timeout.read is None + assert timeout.connect == 10.0 + + +async def test_get_scenario_run_async_wraps_connect_error(client, mock_httpx_client): + mock_httpx_client.get.side_effect = httpx.ConnectError("nope") + with pytest.raises(ServerNotAvailableError, match="Cannot connect"): + await client.get_scenario_run_async(scenario_result_id="abc") async def test_get_scenario_run_results_async(client, mock_httpx_client): diff --git a/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py b/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py index dc48249ffe..abaa2b6f6d 100644 --- a/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py +++ b/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py @@ -56,9 +56,10 @@ def _patch_child_attack( Replace ``SequentialAttack._run_child_attack_async`` with a stub backed by per-bundle outcomes. - Each invocation records the merged ``memory_labels`` and the resulting - ``AttackResult`` so tests can inspect per-attempt routing and per-attempt - label stamping without monkey-patching ``AttackExecutor``. + Each invocation records the merged ``memory_labels``, the forwarded + ``attribution``, and the resulting ``AttackResult`` so tests can inspect + per-attempt routing, per-attempt label stamping, and attribution + propagation without monkey-patching ``AttackExecutor``. """ name_for_attack = {id(b.attack): name for name, b in bundles.items()} counters: dict[str, int] = dict.fromkeys(bundles, 0) @@ -80,6 +81,7 @@ async def _stub(self, *, child_attack: SequentialChildAttack, memory_labels: dic "attempt_labels": dict(memory_labels), "child_attack": child_attack, "result": result, + "attribution": attribution, } ) return result @@ -244,6 +246,174 @@ async def test_envelope_is_distinct_from_child_and_owns_no_conversation(self, ta assert result.child_attack_results == [inner_result] assert result.metadata["adaptive_attempts"] == [{"technique": "a", "technique_hash": "a", "outcome": "success"}] + async def test_attribution_forwarded_to_inner_sequence(self, target, seed_group, monkeypatch): + """ + The outer dispatcher owns persistence; child attacks need the scenario + ``_attribution`` forwarded onto the inner SequentialAttack context so + per-child rows persist with the scenario linkage. Without this, the + per-dataset adaptive results disappear from per-scenario hydration. + """ + from pyrit.executor.attack.core.attack_result_attribution import AttackResultAttribution + + bundles = {"a": _make_bundle(name="a", outcomes=[AttackOutcome.SUCCESS])} + dispatcher = AdaptiveDispatchAttack( + objective_target=target, + techniques=bundles, + selector=_StubSelector(technique_order=["a"]), + ) + calls = _patch_child_attack(monkeypatch, bundles=bundles) + + ctx = _make_context() + attribution = AttackResultAttribution( + parent_id="scenario-1", + parent_collection="adaptive_airt_hate", + ) + ctx._attribution = attribution + + await dispatcher._perform_async(context=ctx) + + assert calls[0]["attribution"] is attribution + + async def test_inner_lifecycle_bypassed_no_double_persist(self, target, seed_group, monkeypatch): + """ + The outer dispatcher must drive the inner SequentialAttack through + ``_perform_async`` directly, never through ``execute_async``. The + latter triggers the inner ``_on_post_execute`` which persists the + same ``attack_result_id`` a second time and rolls the existing row's + attribution off, hiding per-dataset adaptive results from + per-scenario hydration. + """ + bundles = {"a": _make_bundle(name="a", outcomes=[AttackOutcome.SUCCESS])} + dispatcher = AdaptiveDispatchAttack( + objective_target=target, + techniques=bundles, + selector=_StubSelector(technique_order=["a"]), + ) + _patch_child_attack(monkeypatch, bundles=bundles) + + async def _boom(self, *args, **kwargs): + raise AssertionError( + "Dispatcher must not call SequentialAttack.execute_async — " + "doing so triggers double-persistence of the envelope." + ) + + monkeypatch.setattr(SequentialAttack, "execute_async", _boom, raising=True) + + result = await dispatcher._perform_async(context=_make_context()) + assert result.outcome == AttackOutcome.SUCCESS + + +@pytest.mark.usefixtures("patch_central_database") +class TestEndToEndPersistence: + """ + Drive the full ``execute_with_context_async`` lifecycle against a real + in-memory SQLite to verify the dispatcher persists the envelope exactly + once and stamps it with the outer ``AttackResultAttribution``. This is + the integration-shape test that catches the bug where the inner + SequentialAttack's lifecycle would race the outer dispatcher's + persistence and strip attribution off the row. + """ + + async def _seed_scenario_row(self, sqlite_instance): + """Insert a minimal ScenarioResultEntry so the FK on attribution_parent_id can land.""" + from pyrit.models import ScenarioIdentifier, ScenarioResult + + scenario = ScenarioResult( + scenario_identifier=ScenarioIdentifier(name="test_scenario"), + objective_target_identifier=None, + objective_scorer_identifier=None, + attack_results={"adaptive_airt_hate": []}, + scenario_run_state="CREATED", + display_group_map={"adaptive_airt_hate": "airt_hate"}, + ) + sqlite_instance.add_scenario_results_to_memory(scenario_results=[scenario]) + return str(scenario.id) + + async def test_envelope_persisted_once_with_attribution(self, target, monkeypatch, sqlite_instance): + """ + End-to-end: drive ``dispatcher.execute_with_context_async`` and assert + exactly one envelope row lands in the DB with attribution stamped to + the outer scenario. Catches the regression where the inner + SequentialAttack's lifecycle either double-persists (IntegrityError + rollback strips attribution) or skips attribution forwarding. + """ + from pyrit.executor.attack.core.attack_result_attribution import AttackResultAttribution + from pyrit.memory.memory_models import AttackResultEntry + + scenario_id = await self._seed_scenario_row(sqlite_instance) + bundles = {"a": _make_bundle(name="a", outcomes=[AttackOutcome.SUCCESS])} + dispatcher = AdaptiveDispatchAttack( + objective_target=target, + techniques=bundles, + selector=_StubSelector(technique_order=["a"]), + ) + _patch_child_attack(monkeypatch, bundles=bundles) + + ctx = _make_context() + ctx._attribution = AttackResultAttribution( + parent_id=scenario_id, + parent_collection="adaptive_airt_hate", + ) + + await dispatcher.execute_with_context_async(context=ctx) + + with sqlite_instance.get_session() as session: + rows = session.query(AttackResultEntry).all() + + envelopes = [r for r in rows if str(r.attribution_parent_id) == scenario_id] + assert len(envelopes) == 1, ( + f"Expected exactly 1 attributed envelope row; found {len(envelopes)}. " + f"All rows: {[(r.id, r.attribution_parent_id, r.attribution_data) for r in rows]}" + ) + envelope = envelopes[0] + assert envelope.attribution_data["parent_collection"] == "adaptive_airt_hate" + + async def test_full_lifecycle_with_real_prompt_sending_attack(self, sqlite_instance): + """ + End-to-end with a real ``PromptSendingAttack`` child (no stubs) running + against a ``MockPromptTarget``. Verifies the full dispatcher → sequential + → child → memory chain works without IntegrityErrors and that both + envelope and child rows land in the DB with attribution. This is the + shape that exercises the path the live backend actually takes. + """ + from pyrit.executor.attack.core.attack_result_attribution import AttackResultAttribution + from pyrit.executor.attack.single_turn.prompt_sending import PromptSendingAttack + from pyrit.memory.memory_models import AttackResultEntry + from pyrit.scenario.scenarios.adaptive.dispatcher import TechniqueBundle + from tests.unit.mocks import MockPromptTarget + + scenario_id = await self._seed_scenario_row(sqlite_instance) + + live_target = MockPromptTarget() + child_attack = PromptSendingAttack(objective_target=live_target) + bundles = {"role_play": TechniqueBundle(attack=child_attack, name="role_play")} + + dispatcher = AdaptiveDispatchAttack( + objective_target=live_target, + techniques=bundles, + selector=_StubSelector(technique_order=["role_play"]), + max_attempts_per_objective=1, + ) + + ctx = _make_context() + ctx._attribution = AttackResultAttribution( + parent_id=scenario_id, + parent_collection="adaptive_airt_hate", + ) + + await dispatcher.execute_with_context_async(context=ctx) + + with sqlite_instance.get_session() as session: + rows = session.query(AttackResultEntry).all() + + attributed = [r for r in rows if str(r.attribution_parent_id) == scenario_id] + assert len(attributed) >= 1, ( + f"Expected at least one AttackResultEntry attributed to scenario {scenario_id}; " + f"found {len(attributed)} (total rows: {len(rows)})." + ) + for row in attributed: + assert (row.attribution_data or {}).get("parent_collection") == "adaptive_airt_hate" + @pytest.mark.usefixtures("patch_central_database") class TestValidate: diff --git a/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py b/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py index 786f3692ac..a888b45f73 100644 --- a/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py +++ b/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py @@ -82,11 +82,12 @@ def _make_seed_group(*, value: str, harm_categories: list[str] | None = None) -> return SeedAttackGroup(seeds=[SeedObjective(value=value, harm_categories=harm_categories)]) -def _make_fake_factory(*, seed_technique=None, adversarial_chat=None) -> MagicMock: +def _make_fake_factory(*, seed_technique=None, adversarial_chat=None, scoring_config_type=None) -> MagicMock: """Return a stub attack-technique factory that produces a fake ``AttackTechnique``. Mocks the surface ``AdaptiveScenario._build_techniques_dict`` consumes - (``factory.create(...)`` and ``factory.adversarial_chat``). + (``factory.create(...)``, ``factory.adversarial_chat``, and + ``factory.scoring_config_type``). """ fake_technique = MagicMock() fake_technique.attack = MagicMock(name="fake-attack-strategy") @@ -94,6 +95,7 @@ def _make_fake_factory(*, seed_technique=None, adversarial_chat=None) -> MagicMo factory = MagicMock() factory.create.return_value = fake_technique factory.adversarial_chat = adversarial_chat + factory.scoring_config_type = scoring_config_type return factory @@ -358,6 +360,101 @@ def _selective_compat(self_group, *, technique): # Skip was logged with the affected objective value. assert any("obj-skip" in record.getMessage() for record in caplog.records) + async def test_factory_with_narrowed_scoring_config_type_receives_subtype( + self, mock_objective_target, mock_objective_scorer + ): + """When a factory's attack class narrows ``attack_scoring_config`` to a + subtype, the scenario builds and passes that subtype to ``create``.""" + from pyrit.executor.attack import AttackScoringConfig + + class NarrowScoringConfig(AttackScoringConfig): + pass + + groups = {"violence": [_make_seed_group(value="obj")]} + narrow_factory = _make_fake_factory(scoring_config_type=NarrowScoringConfig) + with ( + patch.object(DatasetConfiguration, "get_seed_attack_groups", return_value=groups), + patch.object(SeedAttackGroup, "is_compatible_with_technique", return_value=True), + ): + scenario = TextAdaptive(objective_scorer=mock_objective_scorer) + strategy_class = scenario.get_strategy_class() + with patch.object( + scenario, + "_get_attack_technique_factories", + return_value={"role_play": narrow_factory}, + ): + await scenario.initialize_async( + objective_target=mock_objective_target, + include_baseline=False, + scenario_strategies=[strategy_class("role_play")], + ) + + narrow_factory.create.assert_called_once() + kwargs = narrow_factory.create.call_args.kwargs + passed_config = kwargs["attack_scoring_config"] + assert isinstance(passed_config, NarrowScoringConfig) + assert passed_config.objective_scorer is mock_objective_scorer + + async def test_factory_create_failure_skips_technique(self, mock_objective_target, mock_objective_scorer, caplog): + """A factory whose ``create`` raises ``ValueError`` (e.g. the attack + rejects the scenario's objective scorer) is logged and skipped, while + sibling techniques still build successfully. + """ + import logging + + groups = {"violence": [_make_seed_group(value="obj")]} + good_factory = _make_fake_factory() + bad_factory = _make_fake_factory() + bad_factory.create.side_effect = ValueError("requires FloatScaleThresholdScorer") + + with ( + patch.object(DatasetConfiguration, "get_seed_attack_groups", return_value=groups), + patch.object(SeedAttackGroup, "is_compatible_with_technique", return_value=True), + ): + scenario = TextAdaptive(objective_scorer=mock_objective_scorer) + strategy_class = scenario.get_strategy_class() + factories = {"role_play": good_factory, "tap": bad_factory} + with patch.object(scenario, "_get_attack_technique_factories", return_value=factories): + with caplog.at_level(logging.WARNING): + await scenario.initialize_async( + objective_target=mock_objective_target, + include_baseline=False, + scenario_strategies=[strategy_class("role_play"), strategy_class("tap")], + ) + attacks = scenario._atomic_attacks + + assert len(attacks) == 1 + dispatcher = attacks[0]._attack_technique.attack + assert isinstance(dispatcher, AdaptiveDispatchAttack) + technique_names = {b.name for b in dispatcher._techniques.values()} + assert technique_names == {"role_play"} + assert any("tap" in r.getMessage() and "Skipping" in r.getMessage() for r in caplog.records) + + async def test_all_factories_failing_raises_with_reason(self, mock_objective_target, mock_objective_scorer): + """When every technique's ``create`` fails, ``_build_techniques_dict`` + raises a ``ValueError`` that names the incompatible technique(s).""" + groups = {"violence": [_make_seed_group(value="obj")]} + bad_factory = _make_fake_factory() + bad_factory.create.side_effect = ValueError("requires FloatScaleThresholdScorer") + + with ( + patch.object(DatasetConfiguration, "get_seed_attack_groups", return_value=groups), + patch.object(SeedAttackGroup, "is_compatible_with_technique", return_value=True), + ): + scenario = TextAdaptive(objective_scorer=mock_objective_scorer) + strategy_class = scenario.get_strategy_class() + with patch.object( + scenario, + "_get_attack_technique_factories", + return_value={"tap": bad_factory}, + ): + with pytest.raises(ValueError, match="incompatible with scenario scorer.*tap"): + await scenario.initialize_async( + objective_target=mock_objective_target, + include_baseline=False, + scenario_strategies=[strategy_class("tap")], + ) + @pytest.mark.usefixtures(*FIXTURES) class TestTextAdaptiveBaselinePolicy: From e0dda285762b9663f695ca4c5bb0774afc5835cd Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Mon, 1 Jun 2026 14:54:37 -0400 Subject: [PATCH 20/35] address comments --- pyrit/analytics/technique_analysis.py | 107 +++++++ .../scenario/core/attack_technique_factory.py | 274 +++--------------- .../scenarios/adaptive/adaptive_scenario.py | 2 +- .../adaptive/selectors/epsilon_greedy.py | 11 +- .../adaptive/selectors/technique_selector.py | 6 +- .../scenarios/adaptive/text_adaptive.py | 4 +- .../unit/analytics/test_technique_analysis.py | 182 ++++++++++++ .../scenarios/adaptive/test_epsilon_greedy.py | 14 +- 8 files changed, 349 insertions(+), 251 deletions(-) create mode 100644 pyrit/analytics/technique_analysis.py create mode 100644 tests/unit/analytics/test_technique_analysis.py diff --git a/pyrit/analytics/technique_analysis.py b/pyrit/analytics/technique_analysis.py new file mode 100644 index 0000000000..f0d995c7e8 --- /dev/null +++ b/pyrit/analytics/technique_analysis.py @@ -0,0 +1,107 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""Scenario-level analytics: technique success rates and related helpers.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pyrit.analytics.result_analysis import AttackStats, _compute_stats +from pyrit.memory import CentralMemory +from pyrit.models import AttackOutcome + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + + from pyrit.memory.memory_interface import MemoryInterface + + +# Must match ``ADAPTIVE_TECHNIQUE_LABEL`` in +# ``pyrit.scenario.scenarios.adaptive.selectors.technique_selector``. Kept inline +# so the analytics layer doesn't import from the scenarios layer; a unit test +# asserts the two stay in sync. +_DEFAULT_TECHNIQUE_LABEL_KEY = "_adaptive_technique" + + +def compute_technique_stats( + *, + technique_eval_hashes: Sequence[str], + label_key: str = _DEFAULT_TECHNIQUE_LABEL_KEY, + scenario_result_id: str | None = None, + attack_classes: Sequence[str] | None = None, + targeted_harm_categories: Sequence[str] | None = None, + extra_labels: Mapping[str, str | Sequence[str]] | None = None, + memory: MemoryInterface | None = None, +) -> dict[str, AttackStats]: + """ + Query memory for historical outcome stats grouped by technique eval hash. + + Fetches all ``AttackResult`` rows whose memory labels contain + ``label_key`` matching one of ``technique_eval_hashes``, then aggregates + outcomes into per-technique ``AttackStats``. + + By default queries across all scenario runs. Pass any subset of the + optional filters to narrow the historical window. + + Args: + technique_eval_hashes (Sequence[str]): Technique eval hashes to + aggregate. Returned dict is keyed by these. + label_key (str): Memory-label key that stores the technique hash. + Defaults to the key the adaptive dispatcher stamps + (``"_adaptive_technique"``); override only for custom callers. + scenario_result_id (str | None): Restrict to a single scenario run. + Defaults to ``None`` (aggregate across all runs). + attack_classes (Sequence[str] | None): Restrict to results emitted + by these attack / scenario class names. Forwarded to + ``memory.get_attack_results``. Defaults to ``None``. + targeted_harm_categories (Sequence[str] | None): Restrict to results + whose prompts target these harm categories. Defaults to ``None``. + extra_labels (Mapping[str, str | Sequence[str]] | None): Additional + memory-label filters merged on top of the + ``{label_key: technique_eval_hashes}`` filter the function always + applies. Keys that collide with ``label_key`` are ignored + (the technique-hash filter wins). Defaults to ``None``. + memory (MemoryInterface | None): Memory backend to query. Defaults to + ``CentralMemory.get_memory_instance()``. + + Returns: + dict[str, AttackStats]: Stats per technique eval hash. Techniques + with no matching history are omitted from the result. + """ + labels: dict[str, str | Sequence[str]] = {label_key: list(technique_eval_hashes)} + if extra_labels: + for key, value in extra_labels.items(): + if key == label_key: + continue + labels[key] = value + + if memory is None: + memory = CentralMemory.get_memory_instance() + results = memory.get_attack_results( + labels=labels, + scenario_result_id=scenario_result_id, + attack_classes=attack_classes, + targeted_harm_categories=targeted_harm_categories, + ) + + counts: dict[str, tuple[int, int, int, int]] = {} + for result in results: + technique = result.labels.get(label_key) + if not technique or technique not in technique_eval_hashes: + continue + + s, f, u, e = counts.get(technique, (0, 0, 0, 0)) + if result.outcome == AttackOutcome.SUCCESS: + counts[technique] = (s + 1, f, u, e) + elif result.outcome == AttackOutcome.FAILURE: + counts[technique] = (s, f + 1, u, e) + elif result.outcome == AttackOutcome.ERROR: + counts[technique] = (s, f, u, e + 1) + else: + counts[technique] = (s, f, u + 1, e) + + stats: dict[str, AttackStats] = {} + for technique, (s, f, u, e) in counts.items(): + stats[technique] = _compute_stats(successes=s, failures=f, undetermined=u, errors=e) + return stats diff --git a/pyrit/scenario/core/attack_technique_factory.py b/pyrit/scenario/core/attack_technique_factory.py index 11cc45ba08..c846696095 100644 --- a/pyrit/scenario/core/attack_technique_factory.py +++ b/pyrit/scenario/core/attack_technique_factory.py @@ -2,18 +2,11 @@ # Licensed under the MIT license. """ -AttackTechniqueFactory — Self-describing deferred constructor for AttackTechnique instances. - -Captures technique-specific configuration (name, strategy tags, attack class, -attack-class kwargs, optional adversarial chat, optional seed technique) at -construction time. Scenarios produce fresh, fully-constructed attacks by calling -``create()`` with scenario-specific params (objective target, scorer). - -The canonical place to register factories is the -``ScenarioTechniqueInitializer`` in -``pyrit.setup.initializers.components.scenario_techniques``. New initializers -register additional factories by calling -``AttackTechniqueRegistry.register_from_factories(...)``. +AttackTechniqueFactory — Deferred construction of AttackTechnique instances. + +Captures technique-specific configuration at registration time and produces +fresh, fully-constructed attacks when scenario-specific params (objective target, +scorer) become available. """ from __future__ import annotations @@ -23,24 +16,19 @@ import sys import typing from enum import Enum -from pathlib import Path from typing import TYPE_CHECKING, Any, Union -from pyrit.common.path import EXECUTOR_SEED_PROMPT_PATH -from pyrit.executor.attack import PromptSendingAttack -from pyrit.executor.attack.core.attack_config import ( - AttackAdversarialConfig, - AttackScoringConfig, -) from pyrit.identifiers import ComponentIdentifier, Identifiable, build_seed_identifier -from pyrit.models import SeedAttackTechniqueGroup, SeedSimulatedConversation -from pyrit.models.seeds.seed_simulated_conversation import NextMessageSystemPromptPaths from pyrit.scenario.core.attack_technique import AttackTechnique -from pyrit.scenario.core.scenario_target_defaults import get_default_adversarial_target if TYPE_CHECKING: from pyrit.executor.attack import AttackStrategy - from pyrit.executor.attack.core.attack_config import AttackConverterConfig + from pyrit.executor.attack.core.attack_config import ( + AttackAdversarialConfig, + AttackConverterConfig, + AttackScoringConfig, + ) + from pyrit.models import SeedAttackTechniqueGroup from pyrit.prompt_target import PromptTarget logger = logging.getLogger(__name__) @@ -56,12 +44,12 @@ class ScorerOverridePolicy(str, Enum): class AttackTechniqueFactory(Identifiable): """ - A self-describing factory that produces AttackTechnique instances on demand. + A factory that produces AttackTechnique instances on demand. - Captures technique-specific configuration (name, strategy tags, converters, - adversarial config, tree depth, etc.) at construction time. Produces fresh, - fully-constructed attacks by calling the real constructor with the captured - params plus scenario-specific objective_target and scoring config. + Captures technique-specific configuration (converters, adversarial config, + tree depth, etc.) at registration time. Produces fresh, fully-constructed + attacks by calling the real constructor with the captured params plus + scenario-specific objective_target and scoring config. Validates kwargs against the attack class constructor signature at construction time, catching typos and incompatible parameter names early. @@ -70,179 +58,42 @@ class AttackTechniqueFactory(Identifiable): def __init__( self, *, - name: str, attack_class: type[AttackStrategy[Any, Any]], - strategy_tags: list[str] | None = None, attack_kwargs: dict[str, Any] | None = None, adversarial_config: AttackAdversarialConfig | None = None, seed_technique: SeedAttackTechniqueGroup | None = None, - uses_adversarial: bool | None = None, scorer_override_policy: ScorerOverridePolicy = ScorerOverridePolicy.WARN, ) -> None: """ Initialize the factory with a technique-specific configuration. Args: - name: Registry name for this technique. This is used as the - scenario strategy name. attack_class: The AttackStrategy subclass to instantiate. - strategy_tags: Tags controlling which ``ScenarioStrategy`` - aggregates include this technique (e.g. ``"single_turn"``, - ``"multi_turn"``, ``"default"``). attack_kwargs: Keyword arguments to pass to the attack constructor. Must not include ``objective_target`` (provided at create time) - or ``attack_adversarial_config`` (use ``adversarial_config`` - instead). - adversarial_config: Pre-built adversarial config. Injected into - the attack at ``create()`` time if the attack class accepts - ``attack_adversarial_config``. To bake in a bare - ``PromptTarget``, wrap it as - ``AttackAdversarialConfig(target=chat)``. - seed_technique: Optional technique seed group attached to created - techniques. - uses_adversarial: Whether this technique drives an adversarial - chat during execution. ``None`` auto-derives from the attack - class constructor signature and seed-technique shape. - Authors can override the derivation explicitly. - scorer_override_policy: What to do when a scenario's scorer is - incompatible with the attack's ``attack_scoring_config`` type - annotation. Defaults to WARN. + or ``attack_adversarial_config`` (use ``adversarial_config`` instead). + adversarial_config: Optional adversarial chat configuration. Stored + separately and injected into the attack at ``create()`` time if + the attack class accepts ``attack_adversarial_config``. Also + exposed via the ``adversarial_chat`` property for seed-technique + execution. + seed_technique: Optional technique seed group to attach to created techniques. + scorer_override_policy: What to do when a scenario's scorer is incompatible + with the attack's ``attack_scoring_config`` type annotation. Defaults to WARN. Raises: TypeError: If any kwarg name is not a valid constructor parameter, or if the attack class constructor uses ``**kwargs``. - ValueError: If ``objective_target`` or - ``attack_adversarial_config`` is included in ``attack_kwargs``, - or if ``uses_adversarial=False`` while an adversarial config - is wired. + ValueError: If ``objective_target`` or ``attack_adversarial_config`` + is included in attack_kwargs. """ - self._name = name self._attack_class = attack_class - self._strategy_tags = list(strategy_tags) if strategy_tags else [] self._attack_kwargs = dict(attack_kwargs) if attack_kwargs else {} self._adversarial_config = adversarial_config self._seed_technique = seed_technique self._scorer_override_policy = scorer_override_policy - self._uses_adversarial = uses_adversarial if uses_adversarial is not None else self._derive_uses_adversarial() - self._validate_kwargs() - self._validate_adversarial_flags() - - @classmethod - def with_simulated_conversation( - cls, - *, - name: str, - attack_class: type[AttackStrategy[Any, Any]] | None = None, - adversarial_chat_system_prompt_path: str | Path | None = None, - next_message_system_prompt_path: str | Path | None = None, - num_turns: int = 3, - strategy_tags: list[str] | None = None, - attack_kwargs: dict[str, Any] | None = None, - adversarial_config: AttackAdversarialConfig | None = None, - uses_adversarial: bool | None = None, - scorer_override_policy: ScorerOverridePolicy = ScorerOverridePolicy.WARN, - ) -> AttackTechniqueFactory: - """ - Alternative constructor that builds a ``SeedSimulatedConversation`` inline. - - Wraps a single ``SeedSimulatedConversation`` in a ``SeedAttackTechniqueGroup`` - and assigns it as ``seed_technique`` so callers don't have to construct - both manually. All other parameters are forwarded to ``__init__``. - - Args: - name: Registry name for this technique. When other defaults are used, - ``name`` also picks the canonical YAML at - ``EXECUTOR_SEED_PROMPT_PATH/red_teaming/{name}.yaml``. - attack_class: The AttackStrategy subclass to instantiate. Defaults to - ``PromptSendingAttack``. - adversarial_chat_system_prompt_path: Path to the YAML file containing - the adversarial chat system prompt for the simulated conversation. - Defaults to ``EXECUTOR_SEED_PROMPT_PATH/red_teaming/{name}.yaml``. - next_message_system_prompt_path: Optional path to the YAML file - containing the system prompt for generating a final user message - after the simulated conversation. Defaults to - ``NextMessageSystemPromptPaths.DIRECT.value``. - num_turns: Number of simulated conversation turns. Defaults to 3. - strategy_tags: Tags controlling which ``ScenarioStrategy`` aggregates - include this technique (e.g. ``"single_turn"``, ``"multi_turn"``, - ``"default"``). Forwarded to the factory constructor. - attack_kwargs: Keyword arguments forwarded to the attack constructor. - Must not include ``objective_target`` (provided at create time) - or ``attack_adversarial_config`` (use ``adversarial_config`` - instead). Forwarded to the factory constructor. - adversarial_config: Pre-built adversarial config injected into the - attack at ``create()`` time if the attack class accepts - ``attack_adversarial_config``. To bake in a bare - ``PromptTarget``, wrap it as - ``AttackAdversarialConfig(target=chat)``. Forwarded to the - factory constructor. - uses_adversarial: Whether this technique drives an adversarial chat - during execution. ``None`` auto-derives from the attack class - constructor signature and seed-technique shape. Forwarded to - the factory constructor. - scorer_override_policy: Policy applied when a scenario's scorer is - incompatible with the attack's ``attack_scoring_config`` type - annotation. Defaults to ``WARN``. Forwarded to the factory - constructor. - - Returns: - AttackTechniqueFactory: A new factory whose ``seed_technique`` is the - wrapped simulated conversation. - """ - if attack_class is None: - attack_class = PromptSendingAttack - if adversarial_chat_system_prompt_path is None: - adversarial_chat_system_prompt_path = Path(EXECUTOR_SEED_PROMPT_PATH) / "red_teaming" / f"{name}.yaml" - if next_message_system_prompt_path is None: - next_message_system_prompt_path = NextMessageSystemPromptPaths.DIRECT.value - - seed_technique = SeedAttackTechniqueGroup( - seeds=[ - SeedSimulatedConversation( - adversarial_chat_system_prompt_path=adversarial_chat_system_prompt_path, - next_message_system_prompt_path=next_message_system_prompt_path, - num_turns=num_turns, - ), - ], - ) - return cls( - name=name, - attack_class=attack_class, - strategy_tags=strategy_tags, - attack_kwargs=attack_kwargs, - adversarial_config=adversarial_config, - seed_technique=seed_technique, - uses_adversarial=uses_adversarial, - scorer_override_policy=scorer_override_policy, - ) - - def _derive_uses_adversarial(self) -> bool: - """ - Auto-derive ``uses_adversarial`` from the attack class signature and seed shape. - - Returns: - bool: ``True`` if the attack class accepts ``attack_adversarial_config`` - or the seed technique has a simulated conversation. - """ - sig = inspect.signature(self._attack_class.__init__) - if "attack_adversarial_config" in sig.parameters: - return True - return self._seed_technique is not None and self._seed_technique.has_simulated_conversation - - def _validate_adversarial_flags(self) -> None: - """ - Validate that ``uses_adversarial`` and ``adversarial_config`` are coherent. - - Raises: - ValueError: If an adversarial config is wired but ``uses_adversarial=False``. - """ - if not self._uses_adversarial and self._adversarial_config is not None: - raise ValueError( - f"Factory '{self._name}': adversarial_config is set but uses_adversarial=False. " - f"A technique that doesn't use an adversarial chat should not have one wired." - ) def _validate_kwargs(self) -> None: """ @@ -261,7 +112,9 @@ def _validate_kwargs(self) -> None: if "objective_target" in self._attack_kwargs: raise ValueError("objective_target must not be in attack_kwargs — it is provided at create() time.") if "attack_adversarial_config" in self._attack_kwargs: - raise ValueError("attack_adversarial_config must not be in attack_kwargs — use adversarial_config instead.") + raise ValueError( + "attack_adversarial_config must not be in attack_kwargs — use the adversarial_config parameter instead." + ) sig = inspect.signature(self._attack_class.__init__) @@ -292,21 +145,6 @@ def _validate_kwargs(self) -> None: f"Valid parameters: {sorted(valid_params)}" ) - @property - def name(self) -> str: - """The registry name for this technique.""" - return self._name - - @property - def strategy_tags(self) -> list[str]: - """Tags controlling which ``ScenarioStrategy`` aggregates include this technique.""" - return list(self._strategy_tags) - - @property - def tags(self) -> list[str]: - """Alias for ``strategy_tags`` exposing the Taggable interface (used by ``TagQuery.filter``).""" - return list(self._strategy_tags) - @property def attack_class(self) -> type[AttackStrategy[Any, Any]]: """The attack strategy class this factory produces.""" @@ -327,18 +165,14 @@ def scoring_config_type(self) -> type | None: """ The narrowed ``attack_scoring_config`` annotation declared by the attack class. - Returns the concrete subtype of :class:`AttackScoringConfig` the attack's + Returns the concrete subtype of ``AttackScoringConfig`` the attack's constructor expects (e.g. ``TAPAttackScoringConfig`` for TAP), or ``None`` when the base type is accepted or the annotation cannot be resolved. Callers can use this to build a config of the right shape before invoking - :meth:`create`. + ``create``. """ return self._get_scoring_config_type() - def uses_adversarial(self) -> bool: - """Whether this technique drives an adversarial chat during execution.""" - return self._uses_adversarial - def create( self, *, @@ -380,21 +214,9 @@ def create( A fresh AttackTechnique with a newly-constructed attack strategy. Raises: - ValueError: If ``attack_adversarial_config_override`` is supplied but - the factory already has an adversarial config baked in at - construction time, or if ``scorer_override_policy`` is RAISE and - the override config is incompatible with the attack's type annotation. + ValueError: If ``scorer_override_policy`` is RAISE and the override + config is incompatible with the attack's type annotation. """ - if attack_adversarial_config_override is not None and self._adversarial_config is not None: - raise ValueError( - f"Factory '{self._name}': adversarial config was baked in at construction; " - f"cannot supply attack_adversarial_config_override." - ) - - adversarial_config = self._adversarial_config - if self._uses_adversarial and adversarial_config is None and attack_adversarial_config_override is None: - adversarial_config = self._resolve_default_adversarial_config() - kwargs = dict(self._attack_kwargs) kwargs["objective_target"] = objective_target @@ -407,24 +229,14 @@ def create( if "attack_adversarial_config" in accepted_params: if attack_adversarial_config_override is not None: kwargs["attack_adversarial_config"] = attack_adversarial_config_override - elif adversarial_config is not None: - kwargs["attack_adversarial_config"] = adversarial_config + elif self._adversarial_config is not None: + kwargs["attack_adversarial_config"] = self._adversarial_config if attack_converter_config_override is not None and "attack_converter_config" in accepted_params: kwargs["attack_converter_config"] = attack_converter_config_override attack = self._attack_class(**kwargs) return AttackTechnique(attack=attack, seed_technique=self._seed_technique) - @staticmethod - def _resolve_default_adversarial_config() -> AttackAdversarialConfig: - """ - Lazily resolve the default adversarial chat target and wrap it in a config. - - Returns: - AttackAdversarialConfig: Config wrapping the default adversarial chat target. - """ - return AttackAdversarialConfig(target=get_default_adversarial_target()) - def _get_accepted_params(self) -> set[str]: """Return the set of keyword parameter names accepted by the attack class constructor.""" sig = inspect.signature(self._attack_class.__init__) @@ -508,6 +320,8 @@ def _get_scoring_config_type(self) -> type | None: Returns: The narrowed type if the annotation is narrower than the base, else None. """ + from pyrit.executor.attack.core.attack_config import AttackScoringConfig + try: # get_type_hints resolves string annotations from __future__ annotations hints = typing.get_type_hints( @@ -580,23 +394,19 @@ def _build_identifier(self) -> ComponentIdentifier: """ Build the behavioral identity for this factory. - Includes the factory name, attack class, kwargs, adversarial config, - and the adversarial-flag booleans so factories with different - configurations produce different hashes. When a seed technique is - present, its seeds are added as ``children["technique_seeds"]``. + Includes the attack class name and kwargs with their serialized values + so that factories with different configurations produce different hashes. + When a seed technique is present, its seeds are added as + ``children["technique_seeds"]``. Returns: ComponentIdentifier: The frozen identity snapshot. """ kwargs_for_id = {k: self._serialize_value(v) for k, v in sorted(self._attack_kwargs.items())} params: dict[str, Any] = { - "name": self._name, "attack_class": self._attack_class.__name__, "kwargs": kwargs_for_id, - "uses_adversarial": self._uses_adversarial, } - if self._strategy_tags: - params["strategy_tags"] = list(self._strategy_tags) if self._adversarial_config is not None: params["adversarial_config"] = self._serialize_value(self._adversarial_config) diff --git a/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py b/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py index 72d00528f7..38aee63774 100644 --- a/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py +++ b/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py @@ -68,7 +68,7 @@ def __init__( objective_scorer (TrueFalseScorer | None): Scorer used to judge each response. Defaults to the composite scorer from the base class. selector (TechniqueSelector | None): Pre-built selector. When ``None`` - (default) an :class:`EpsilonGreedyTechniqueSelector` is created + (default) an ``EpsilonGreedyTechniqueSelector`` is created with default settings. scenario_result_id (str | None): ID of an existing ``ScenarioResult`` to resume. """ diff --git a/pyrit/scenario/scenarios/adaptive/selectors/epsilon_greedy.py b/pyrit/scenario/scenarios/adaptive/selectors/epsilon_greedy.py index aebcd65983..6971c6ae95 100644 --- a/pyrit/scenario/scenarios/adaptive/selectors/epsilon_greedy.py +++ b/pyrit/scenario/scenarios/adaptive/selectors/epsilon_greedy.py @@ -11,8 +11,8 @@ import struct from typing import TYPE_CHECKING -from pyrit.analytics.scenario_analysis import compute_technique_success_rates -from pyrit.scenario.scenarios.adaptive.selectors.technique_selector import ADAPTIVE_TECHNIQUE_LABEL, SelectorScope +from pyrit.analytics.technique_analysis import compute_technique_stats +from pyrit.scenario.scenarios.adaptive.selectors.technique_selector import SelectorScope if TYPE_CHECKING: from collections.abc import Sequence @@ -67,7 +67,7 @@ def __init__( epsilon (float): Exploration probability in [0.0, 1.0]. Defaults to 0.2. scope (SelectorScope | None): Filter describing which historical ``AttackResult`` rows to use when estimating success rates. - Defaults to :meth:`SelectorScope.all_runs` (all history). + Defaults to ``SelectorScope.all_runs()`` (all history). random_seed (int | None): Base seed for deterministic per-decision RNG derivation. Defaults to ``None`` (non-deterministic). @@ -117,9 +117,8 @@ async def select_async( rng = _derive_rng(self._seed, decision_key) effective_run_id = scenario_result_id if self._scope.current_run_only else None - stats = compute_technique_success_rates( - technique_hashes=technique_list, - label_key=ADAPTIVE_TECHNIQUE_LABEL, + stats = compute_technique_stats( + technique_eval_hashes=technique_list, scenario_result_id=effective_run_id, attack_classes=self._scope.attack_classes, targeted_harm_categories=self._scope.targeted_harm_categories, diff --git a/pyrit/scenario/scenarios/adaptive/selectors/technique_selector.py b/pyrit/scenario/scenarios/adaptive/selectors/technique_selector.py index c5c1ae685f..f29f31ee1a 100644 --- a/pyrit/scenario/scenarios/adaptive/selectors/technique_selector.py +++ b/pyrit/scenario/scenarios/adaptive/selectors/technique_selector.py @@ -20,8 +20,8 @@ class SelectorScope: All fields default to "no restriction"; combine fields to narrow the scope (e.g. current run only, same scenario class, same harm category). - Filter values flow through :func:`compute_technique_success_rates` to - :meth:`MemoryInterface.get_attack_results`. + Filter values flow through ``compute_technique_stats`` to + ``MemoryInterface.get_attack_results``. The scope is held by the selector at construction time. The per-call ``scenario_result_id`` is supplied by the dispatcher and is forwarded @@ -101,7 +101,7 @@ async def select_async( num_top_techniques (int): Max techniques to return. Defaults to 1. scenario_result_id (str | None): The current scenario run ID, provided by the dispatcher. Selectors forward this to - memory only when their :class:`SelectorScope` has + memory only when their ``SelectorScope`` has ``current_run_only=True``. Returns: diff --git a/pyrit/scenario/scenarios/adaptive/text_adaptive.py b/pyrit/scenario/scenarios/adaptive/text_adaptive.py index d7e726da5f..3b00139ee5 100644 --- a/pyrit/scenario/scenarios/adaptive/text_adaptive.py +++ b/pyrit/scenario/scenarios/adaptive/text_adaptive.py @@ -99,7 +99,7 @@ def required_datasets(cls) -> list[str]: @classmethod def default_dataset_config(cls) -> DatasetConfiguration: - """Return the default :class:`DatasetConfiguration` (required datasets, capped at 4 per dataset).""" + """Return the default ``DatasetConfiguration`` (required datasets, capped at 4 per dataset).""" return DatasetConfiguration(dataset_names=cls.required_datasets(), max_dataset_size=4) @classmethod @@ -132,7 +132,7 @@ def __init__( objective_scorer (TrueFalseScorer | None): Scorer used to judge each response. Defaults to the composite scorer from the base class. selector (TechniqueSelector | None): Pre-built selector. When ``None`` - (default) an :class:`EpsilonGreedyTechniqueSelector` is created + (default) an ``EpsilonGreedyTechniqueSelector`` is created with default settings. Pass a custom instance to tune ``epsilon`` or ``random_seed``. scenario_result_id (str | None): ID of an existing ``ScenarioResult`` to resume. diff --git a/tests/unit/analytics/test_technique_analysis.py b/tests/unit/analytics/test_technique_analysis.py new file mode 100644 index 0000000000..d8b40c3699 --- /dev/null +++ b/tests/unit/analytics/test_technique_analysis.py @@ -0,0 +1,182 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from unittest.mock import MagicMock, patch + +import pytest + +from pyrit.analytics.technique_analysis import ( + _DEFAULT_TECHNIQUE_LABEL_KEY, + compute_technique_stats, +) +from pyrit.models import AttackOutcome + +LABEL_KEY = _DEFAULT_TECHNIQUE_LABEL_KEY + + +def _make_result(*, technique: str, outcome: AttackOutcome) -> MagicMock: + r = MagicMock() + r.labels = {LABEL_KEY: technique} + r.outcome = outcome + return r + + +@pytest.fixture(autouse=True) +def _patch_memory(): + mock_memory = MagicMock() + mock_memory.get_attack_results.return_value = [] + with patch("pyrit.analytics.technique_analysis.CentralMemory") as cm: + cm.get_memory_instance.return_value = mock_memory + yield mock_memory + + +class TestComputeTechniqueStats: + def test_empty_results_returns_empty(self, _patch_memory): + stats = compute_technique_stats(technique_eval_hashes=["a", "b"]) + assert stats == {} + + def test_counts_successes_and_failures(self, _patch_memory): + _patch_memory.get_attack_results.return_value = [ + _make_result(technique="a", outcome=AttackOutcome.SUCCESS), + _make_result(technique="a", outcome=AttackOutcome.SUCCESS), + _make_result(technique="a", outcome=AttackOutcome.FAILURE), + _make_result(technique="b", outcome=AttackOutcome.FAILURE), + ] + + stats = compute_technique_stats(technique_eval_hashes=["a", "b"]) + + assert stats["a"].successes == 2 + assert stats["a"].failures == 1 + assert stats["a"].total_decided == 3 + assert stats["b"].successes == 0 + assert stats["b"].failures == 1 + + def test_counts_errors_and_undetermined(self, _patch_memory): + _patch_memory.get_attack_results.return_value = [ + _make_result(technique="a", outcome=AttackOutcome.ERROR), + _make_result(technique="a", outcome=AttackOutcome.UNDETERMINED), + ] + + stats = compute_technique_stats(technique_eval_hashes=["a"]) + + assert stats["a"].errors == 1 + assert stats["a"].undetermined == 1 + + def test_ignores_techniques_not_in_requested_list(self, _patch_memory): + _patch_memory.get_attack_results.return_value = [ + _make_result(technique="a", outcome=AttackOutcome.SUCCESS), + _make_result(technique="c", outcome=AttackOutcome.SUCCESS), + ] + + stats = compute_technique_stats(technique_eval_hashes=["a", "b"]) + + assert "a" in stats + assert "c" not in stats + + def test_default_label_key_used_when_omitted(self, _patch_memory): + compute_technique_stats(technique_eval_hashes=["x"]) + + call_kwargs = _patch_memory.get_attack_results.call_args[1] + assert call_kwargs["labels"] == {LABEL_KEY: ["x"]} + + def test_passes_custom_label_key_to_memory_query(self, _patch_memory): + custom_key = "my_custom_key" + compute_technique_stats(technique_eval_hashes=["x"], label_key=custom_key) + + call_kwargs = _patch_memory.get_attack_results.call_args[1] + assert call_kwargs["labels"] == {custom_key: ["x"]} + assert call_kwargs["scenario_result_id"] is None + + def test_passes_scenario_result_id_to_memory_query(self, _patch_memory): + compute_technique_stats(technique_eval_hashes=["x"], scenario_result_id="run-123") + + call_kwargs = _patch_memory.get_attack_results.call_args[1] + assert call_kwargs["scenario_result_id"] == "run-123" + + def test_omits_techniques_with_no_history(self, _patch_memory): + _patch_memory.get_attack_results.return_value = [ + _make_result(technique="a", outcome=AttackOutcome.SUCCESS), + ] + + stats = compute_technique_stats(technique_eval_hashes=["a", "b"]) + + assert "a" in stats + assert "b" not in stats + + def test_success_rate_computed(self, _patch_memory): + _patch_memory.get_attack_results.return_value = [ + _make_result(technique="a", outcome=AttackOutcome.SUCCESS), + _make_result(technique="a", outcome=AttackOutcome.SUCCESS), + _make_result(technique="a", outcome=AttackOutcome.FAILURE), + _make_result(technique="a", outcome=AttackOutcome.FAILURE), + ] + + stats = compute_technique_stats(technique_eval_hashes=["a"]) + + assert stats["a"].success_rate == pytest.approx(0.5) + + def test_passes_attack_classes_to_memory_query(self, _patch_memory): + compute_technique_stats( + technique_eval_hashes=["x"], + attack_classes=["TextAdaptive", "ImageAdaptive"], + ) + + call_kwargs = _patch_memory.get_attack_results.call_args[1] + assert call_kwargs["attack_classes"] == ["TextAdaptive", "ImageAdaptive"] + + def test_passes_harm_categories_to_memory_query(self, _patch_memory): + compute_technique_stats( + technique_eval_hashes=["x"], + targeted_harm_categories=["misinformation", "hate"], + ) + + call_kwargs = _patch_memory.get_attack_results.call_args[1] + assert call_kwargs["targeted_harm_categories"] == ["misinformation", "hate"] + + def test_merges_extra_labels_with_technique_label(self, _patch_memory): + compute_technique_stats( + technique_eval_hashes=["x"], + extra_labels={"experiment": "ablation_v3", "tier": ["a", "b"]}, + ) + + call_kwargs = _patch_memory.get_attack_results.call_args[1] + assert call_kwargs["labels"] == { + LABEL_KEY: ["x"], + "experiment": "ablation_v3", + "tier": ["a", "b"], + } + + def test_extra_labels_cannot_override_technique_label(self, _patch_memory): + compute_technique_stats( + technique_eval_hashes=["x"], + extra_labels={LABEL_KEY: ["evil"], "other": "ok"}, + ) + + call_kwargs = _patch_memory.get_attack_results.call_args[1] + assert call_kwargs["labels"] == {LABEL_KEY: ["x"], "other": "ok"} + + def test_default_filter_kwargs_are_none(self, _patch_memory): + compute_technique_stats(technique_eval_hashes=["x"]) + + call_kwargs = _patch_memory.get_attack_results.call_args[1] + assert call_kwargs["attack_classes"] is None + assert call_kwargs["targeted_harm_categories"] is None + + def test_injected_memory_bypasses_central_memory(self, _patch_memory): + injected = MagicMock() + injected.get_attack_results.return_value = [ + _make_result(technique="a", outcome=AttackOutcome.SUCCESS), + ] + + stats = compute_technique_stats(technique_eval_hashes=["a"], memory=injected) + + injected.get_attack_results.assert_called_once() + _patch_memory.get_attack_results.assert_not_called() + assert stats["a"].successes == 1 + + def test_default_label_key_matches_adaptive_constant(self): + from pyrit.scenario.scenarios.adaptive.selectors.technique_selector import ( + ADAPTIVE_TECHNIQUE_LABEL, + ) + + assert _DEFAULT_TECHNIQUE_LABEL_KEY == ADAPTIVE_TECHNIQUE_LABEL diff --git a/tests/unit/scenario/scenarios/adaptive/test_epsilon_greedy.py b/tests/unit/scenario/scenarios/adaptive/test_epsilon_greedy.py index 1052786476..c9352a1b00 100644 --- a/tests/unit/scenario/scenarios/adaptive/test_epsilon_greedy.py +++ b/tests/unit/scenario/scenarios/adaptive/test_epsilon_greedy.py @@ -13,7 +13,7 @@ TECHNIQUES = ["a", "b", "c", "d"] -_COMPUTE_PATH = "pyrit.scenario.scenarios.adaptive.selectors.epsilon_greedy.compute_technique_success_rates" +_COMPUTE_PATH = "pyrit.scenario.scenarios.adaptive.selectors.epsilon_greedy.compute_technique_stats" def _seeded_selector(*, epsilon: float = 0.0, random_seed: int = 0) -> EpsilonGreedyTechniqueSelector: @@ -66,7 +66,7 @@ def test_init_rejects_out_of_range_epsilon(self, bad_epsilon): class TestEpsilonGreedyTechniqueSelectorSelect: @patch( - "pyrit.scenario.scenarios.adaptive.selectors.epsilon_greedy.compute_technique_success_rates", + "pyrit.scenario.scenarios.adaptive.selectors.epsilon_greedy.compute_technique_stats", side_effect=_empty_rates, ) async def test_select_empty_techniques_raises(self, _mock): @@ -75,7 +75,7 @@ async def test_select_empty_techniques_raises(self, _mock): await selector.select_async(technique_identifiers=[], objective="obj") @patch( - "pyrit.scenario.scenarios.adaptive.selectors.epsilon_greedy.compute_technique_success_rates", + "pyrit.scenario.scenarios.adaptive.selectors.epsilon_greedy.compute_technique_stats", side_effect=_empty_rates, ) async def test_select_all_unseen_ties_resolved_randomly(self, _mock): @@ -88,7 +88,7 @@ async def test_select_all_unseen_ties_resolved_randomly(self, _mock): assert winners.issubset(set(TECHNIQUES)) @patch( - "pyrit.scenario.scenarios.adaptive.selectors.epsilon_greedy.compute_technique_success_rates", + "pyrit.scenario.scenarios.adaptive.selectors.epsilon_greedy.compute_technique_stats", side_effect=_rates_with_winner("b"), ) async def test_select_exploits_clear_winner(self, _mock): @@ -98,7 +98,7 @@ async def test_select_exploits_clear_winner(self, _mock): assert result[0] == "b" @patch( - "pyrit.scenario.scenarios.adaptive.selectors.epsilon_greedy.compute_technique_success_rates", + "pyrit.scenario.scenarios.adaptive.selectors.epsilon_greedy.compute_technique_stats", side_effect=_empty_rates, ) async def test_select_epsilon_one_is_pure_random(self, _mock): @@ -110,7 +110,7 @@ async def test_select_epsilon_one_is_pure_random(self, _mock): assert picks == set(TECHNIQUES) @patch( - "pyrit.scenario.scenarios.adaptive.selectors.epsilon_greedy.compute_technique_success_rates", + "pyrit.scenario.scenarios.adaptive.selectors.epsilon_greedy.compute_technique_stats", side_effect=_empty_rates, ) async def test_select_returns_multiple_techniques(self, _mock): @@ -120,7 +120,7 @@ async def test_select_returns_multiple_techniques(self, _mock): assert len(set(result)) == 3 # no duplicates @patch( - "pyrit.scenario.scenarios.adaptive.selectors.epsilon_greedy.compute_technique_success_rates", + "pyrit.scenario.scenarios.adaptive.selectors.epsilon_greedy.compute_technique_stats", side_effect=_empty_rates, ) async def test_select_caps_at_available_techniques(self, _mock): From 200eacb881a25cd2bc3be948ccd35c0a7c6902ad Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Mon, 1 Jun 2026 15:59:36 -0400 Subject: [PATCH 21/35] use eval hash --- pyrit/analytics/scenario_analysis.py | 92 ---------- pyrit/analytics/technique_analysis.py | 83 ++++----- pyrit/identifiers/__init__.py | 2 + pyrit/identifiers/evaluation_identifier.py | 27 ++- pyrit/memory/memory_interface.py | 24 +++ .../scenarios/adaptive/adaptive_scenario.py | 15 +- .../scenario/scenarios/adaptive/dispatcher.py | 14 +- .../scenarios/adaptive/selectors/__init__.py | 2 - .../adaptive/selectors/epsilon_greedy.py | 2 - .../adaptive/selectors/technique_selector.py | 34 ++-- .../unit/analytics/test_scenario_analysis.py | 158 ------------------ .../unit/analytics/test_technique_analysis.py | 118 +++++-------- .../identifiers/test_evaluation_identifier.py | 71 ++++++++ .../test_interface_attack_results.py | 56 +++++++ .../scenarios/adaptive/test_dispatcher.py | 5 +- .../scenarios/adaptive/test_epsilon_greedy.py | 6 - .../scenarios/adaptive/test_selector_scope.py | 14 +- .../scenarios/adaptive/test_text_adaptive.py | 15 +- 18 files changed, 299 insertions(+), 439 deletions(-) delete mode 100644 pyrit/analytics/scenario_analysis.py delete mode 100644 tests/unit/analytics/test_scenario_analysis.py diff --git a/pyrit/analytics/scenario_analysis.py b/pyrit/analytics/scenario_analysis.py deleted file mode 100644 index 4b203daedd..0000000000 --- a/pyrit/analytics/scenario_analysis.py +++ /dev/null @@ -1,92 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -"""Scenario-level analytics: technique success rates and related helpers.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from pyrit.analytics.result_analysis import AttackStats, _compute_stats -from pyrit.memory import CentralMemory -from pyrit.models import AttackOutcome - -if TYPE_CHECKING: - from collections.abc import Mapping, Sequence - - -def compute_technique_success_rates( - *, - technique_hashes: Sequence[str], - label_key: str, - scenario_result_id: str | None = None, - attack_classes: Sequence[str] | None = None, - targeted_harm_categories: Sequence[str] | None = None, - extra_labels: Mapping[str, str | Sequence[str]] | None = None, -) -> dict[str, AttackStats]: - """ - Query memory for historical success rates grouped by technique eval hash. - - Fetches all ``AttackResult`` rows whose memory labels contain - ``label_key`` matching one of ``technique_hashes``, then aggregates - outcomes into per-technique :class:`AttackStats`. - - By default queries across all scenario runs. Pass any subset of the - optional filters to narrow the historical window. - - Args: - technique_hashes (Sequence[str]): Technique eval hashes to query. - label_key (str): Memory-label key that stores the technique hash. - scenario_result_id (str | None): If provided, restrict results to - a single scenario run. Defaults to ``None`` (all runs). - attack_classes (Sequence[str] | None): If provided, restrict to - results emitted by these attack / scenario class names. - Forwarded to ``memory.get_attack_results``. Defaults to ``None``. - targeted_harm_categories (Sequence[str] | None): If provided, - restrict to results whose prompts target these harm categories. - Defaults to ``None``. - extra_labels (Mapping[str, str | Sequence[str]] | None): Additional - memory-label filters merged on top of the - ``{label_key: technique_hashes}`` filter the function always - applies. Keys that collide with ``label_key`` are ignored - (the technique-hash filter wins). Defaults to ``None``. - - Returns: - dict[str, AttackStats]: Stats per technique hash. Techniques with - no history are omitted from the result. - """ - labels: dict[str, str | Sequence[str]] = {label_key: list(technique_hashes)} - if extra_labels: - for key, value in extra_labels.items(): - if key == label_key: - continue - labels[key] = value - - memory = CentralMemory.get_memory_instance() - results = memory.get_attack_results( - labels=labels, - scenario_result_id=scenario_result_id, - attack_classes=attack_classes, - targeted_harm_categories=targeted_harm_categories, - ) - - counts: dict[str, tuple[int, int, int, int]] = {} - for result in results: - technique = result.labels.get(label_key) - if not technique or technique not in technique_hashes: - continue - - s, f, u, e = counts.get(technique, (0, 0, 0, 0)) - if result.outcome == AttackOutcome.SUCCESS: - counts[technique] = (s + 1, f, u, e) - elif result.outcome == AttackOutcome.FAILURE: - counts[technique] = (s, f + 1, u, e) - elif result.outcome == AttackOutcome.ERROR: - counts[technique] = (s, f, u, e + 1) - else: - counts[technique] = (s, f, u + 1, e) - - stats: dict[str, AttackStats] = {} - for technique, (s, f, u, e) in counts.items(): - stats[technique] = _compute_stats(successes=s, failures=f, undetermined=u, errors=e) - return stats diff --git a/pyrit/analytics/technique_analysis.py b/pyrit/analytics/technique_analysis.py index f0d995c7e8..a091d29821 100644 --- a/pyrit/analytics/technique_analysis.py +++ b/pyrit/analytics/technique_analysis.py @@ -12,96 +12,73 @@ from pyrit.models import AttackOutcome if TYPE_CHECKING: - from collections.abc import Mapping, Sequence + from collections.abc import Sequence from pyrit.memory.memory_interface import MemoryInterface -# Must match ``ADAPTIVE_TECHNIQUE_LABEL`` in -# ``pyrit.scenario.scenarios.adaptive.selectors.technique_selector``. Kept inline -# so the analytics layer doesn't import from the scenarios layer; a unit test -# asserts the two stay in sync. -_DEFAULT_TECHNIQUE_LABEL_KEY = "_adaptive_technique" - - def compute_technique_stats( *, technique_eval_hashes: Sequence[str], - label_key: str = _DEFAULT_TECHNIQUE_LABEL_KEY, scenario_result_id: str | None = None, - attack_classes: Sequence[str] | None = None, targeted_harm_categories: Sequence[str] | None = None, - extra_labels: Mapping[str, str | Sequence[str]] | None = None, memory: MemoryInterface | None = None, ) -> dict[str, AttackStats]: """ - Query memory for historical outcome stats grouped by technique eval hash. - - Fetches all ``AttackResult`` rows whose memory labels contain - ``label_key`` matching one of ``technique_eval_hashes``, then aggregates - outcomes into per-technique ``AttackStats``. + Compute per-technique outcome statistics from persisted attack results. - By default queries across all scenario runs. Pass any subset of the - optional filters to narrow the historical window. + Queries memory for all ``AttackResult`` rows whose + ``atomic_attack_identifier.eval_hash`` matches one of + ``technique_eval_hashes``, then aggregates outcomes into per-technique + ``AttackStats``. The eval hash is auto-stamped on every persisted result + by ``AtomicAttackEvaluationIdentifier`` and is the canonical primitive + for behavioral-equivalence aggregation (seeds excluded, scorer excluded, + only behavior-relevant target params included). Args: - technique_eval_hashes (Sequence[str]): Technique eval hashes to - aggregate. Returned dict is keyed by these. - label_key (str): Memory-label key that stores the technique hash. - Defaults to the key the adaptive dispatcher stamps - (``"_adaptive_technique"``); override only for custom callers. + technique_eval_hashes (Sequence[str]): Eval hashes to aggregate. + Returned dict is keyed by these. scenario_result_id (str | None): Restrict to a single scenario run. Defaults to ``None`` (aggregate across all runs). - attack_classes (Sequence[str] | None): Restrict to results emitted - by these attack / scenario class names. Forwarded to - ``memory.get_attack_results``. Defaults to ``None``. targeted_harm_categories (Sequence[str] | None): Restrict to results - whose prompts target these harm categories. Defaults to ``None``. - extra_labels (Mapping[str, str | Sequence[str]] | None): Additional - memory-label filters merged on top of the - ``{label_key: technique_eval_hashes}`` filter the function always - applies. Keys that collide with ``label_key`` are ignored - (the technique-hash filter wins). Defaults to ``None``. + whose prompts targeted these harm categories. Defaults to ``None``. memory (MemoryInterface | None): Memory backend to query. Defaults to ``CentralMemory.get_memory_instance()``. Returns: - dict[str, AttackStats]: Stats per technique eval hash. Techniques - with no matching history are omitted from the result. + dict[str, AttackStats]: Stats per technique eval hash. Hashes with no + historical results are omitted from the result. """ - labels: dict[str, str | Sequence[str]] = {label_key: list(technique_eval_hashes)} - if extra_labels: - for key, value in extra_labels.items(): - if key == label_key: - continue - labels[key] = value + if not technique_eval_hashes: + return {} if memory is None: memory = CentralMemory.get_memory_instance() results = memory.get_attack_results( - labels=labels, + atomic_attack_eval_hashes=list(technique_eval_hashes), scenario_result_id=scenario_result_id, - attack_classes=attack_classes, targeted_harm_categories=targeted_harm_categories, ) + requested = set(technique_eval_hashes) counts: dict[str, tuple[int, int, int, int]] = {} for result in results: - technique = result.labels.get(label_key) - if not technique or technique not in technique_eval_hashes: + identifier = result.atomic_attack_identifier + eval_hash = identifier.eval_hash if identifier is not None else None + if eval_hash is None or eval_hash not in requested: continue - s, f, u, e = counts.get(technique, (0, 0, 0, 0)) + s, f, u, e = counts.get(eval_hash, (0, 0, 0, 0)) if result.outcome == AttackOutcome.SUCCESS: - counts[technique] = (s + 1, f, u, e) + counts[eval_hash] = (s + 1, f, u, e) elif result.outcome == AttackOutcome.FAILURE: - counts[technique] = (s, f + 1, u, e) + counts[eval_hash] = (s, f + 1, u, e) elif result.outcome == AttackOutcome.ERROR: - counts[technique] = (s, f, u, e + 1) + counts[eval_hash] = (s, f, u, e + 1) else: - counts[technique] = (s, f, u + 1, e) + counts[eval_hash] = (s, f, u + 1, e) - stats: dict[str, AttackStats] = {} - for technique, (s, f, u, e) in counts.items(): - stats[technique] = _compute_stats(successes=s, failures=f, undetermined=u, errors=e) - return stats + return { + eval_hash: _compute_stats(successes=s, failures=f, undetermined=u, errors=e) + for eval_hash, (s, f, u, e) in counts.items() + } diff --git a/pyrit/identifiers/__init__.py b/pyrit/identifiers/__init__.py index daa28292f8..84783f9efa 100644 --- a/pyrit/identifiers/__init__.py +++ b/pyrit/identifiers/__init__.py @@ -22,6 +22,7 @@ EvaluationIdentifier, ScorerEvaluationIdentifier, compute_eval_hash, + compute_inner_attack_eval_hash, ) from pyrit.identifiers.identifier_filters import IdentifierFilter, IdentifierType @@ -33,6 +34,7 @@ "class_name_to_snake_case", "ComponentIdentifier", "compute_eval_hash", + "compute_inner_attack_eval_hash", "EvaluationIdentifier", "Identifiable", "REGISTRY_NAME_PATTERN", diff --git a/pyrit/identifiers/evaluation_identifier.py b/pyrit/identifiers/evaluation_identifier.py index 0171d68b2c..f6268b31e5 100644 --- a/pyrit/identifiers/evaluation_identifier.py +++ b/pyrit/identifiers/evaluation_identifier.py @@ -21,10 +21,13 @@ from abc import ABC from dataclasses import dataclass, field -from typing import Any, ClassVar, Optional +from typing import TYPE_CHECKING, Any, ClassVar, Optional from pyrit.identifiers.component_identifier import ComponentIdentifier, config_hash +if TYPE_CHECKING: + from pyrit.executor.attack.core.attack_strategy import AttackStrategy + # Behavioral params that define model output quality for scoring. TARGET_EVAL_PARAMS: frozenset[str] = frozenset({"underlying_model_name", "temperature", "top_p"}) TARGET_EVAL_PARAM_FALLBACKS: dict[str, str] = {"underlying_model_name": "model_name"} @@ -301,3 +304,25 @@ class AtomicAttackEvaluationIdentifier(EvaluationIdentifier): # attack_technique: not listed in rules — fully included in eval hash. # technique_seeds (nested inside attack_technique): also not listed — fully included. } + + +def compute_inner_attack_eval_hash(*, attack: AttackStrategy) -> str: + """ + Predict the eval hash the executor will stamp on persisted child rows + for this attack. + + Mirrors the inner-attack write path so callers can look up historical + results matching the same behavioral configuration *before* any row is + written. Use this rather than reconstructing the recipe inline. + + Args: + attack (AttackStrategy): Inner attack strategy. + + Returns: + str: The eval hash that will appear on persisted child rows. + """ + # Local import avoids a circular dependency inside the identifiers package. + from pyrit.identifiers.atomic_attack_identifier import build_atomic_attack_identifier + + composite = build_atomic_attack_identifier(attack_identifier=attack.get_identifier()) + return AtomicAttackEvaluationIdentifier(composite).eval_hash diff --git a/pyrit/memory/memory_interface.py b/pyrit/memory/memory_interface.py index d5babd2cfd..185ef3ad8e 100644 --- a/pyrit/memory/memory_interface.py +++ b/pyrit/memory/memory_interface.py @@ -1705,6 +1705,7 @@ def get_attack_results( outcome: Optional[str] = None, attack_class: Optional[str] = None, attack_classes: Optional[Sequence[str]] = None, + atomic_attack_eval_hashes: Optional[Sequence[str]] = None, converter_classes: Optional[Sequence[str]] = None, converter_classes_match: Literal["all", "any"] = "all", has_converters: Optional[bool] = None, @@ -1730,6 +1731,11 @@ def get_attack_results( attack_classes (Optional[Sequence[str]], optional): Filter by exact attack class_name in attack_identifier. Returns attacks matching ANY of the listed class names (OR logic, case-sensitive). An empty sequence applies no filter. Defaults to None. + atomic_attack_eval_hashes (Optional[Sequence[str]], optional): Filter by behavioral + equivalence hash on ``atomic_attack_identifier.eval_hash`` (auto-stamped on persistence + by ``AtomicAttackEvaluationIdentifier``). Returns results matching ANY of the listed + hashes (OR logic, case-sensitive). Designed for ASR aggregation by technique + configuration. An empty sequence applies no filter. Defaults to None. converter_classes (Optional[Sequence[str]], optional): Filter by converter class names. Combination semantics for multiple entries are controlled by ``converter_classes_match``. An empty sequence filters to attacks that used no converters; ``None`` applies no @@ -1818,6 +1824,24 @@ def get_attack_results( ) ) + if atomic_attack_eval_hashes: + # Single JSON path query on the auto-stamped eval_hash. OR-combined across + # supplied hashes so callers can fetch history for multiple technique + # configurations in one round trip. + conditions.append( + or_( + *[ + self._get_condition_json_property_match( + json_column=AttackResultEntry.atomic_attack_identifier, + property_path="$.eval_hash", + value=h, + case_sensitive=True, + ) + for h in atomic_attack_eval_hashes + ] + ) + ) + if converter_classes is not None: # Non-empty sequence: filter to attacks that used ALL (or ANY, depending on # converter_classes_match) of the listed converters. diff --git a/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py b/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py index 38aee63774..ca065e7b74 100644 --- a/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py +++ b/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py @@ -20,6 +20,7 @@ from typing import TYPE_CHECKING, ClassVar from pyrit.executor.attack import AttackScoringConfig +from pyrit.identifiers import compute_inner_attack_eval_hash from pyrit.scenario.core.atomic_attack import AtomicAttack from pyrit.scenario.core.attack_technique import AttackTechnique from pyrit.scenario.core.scenario import Scenario @@ -140,10 +141,13 @@ def _build_techniques_dict( ``seed_technique`` and ``adversarial_chat`` so the dispatcher can reproduce the static ``AtomicAttack`` execution path per attempt. - Technique keys are eval hashes derived from the ``AttackTechnique`` - identity (strategy + seed_technique configuration). This allows the - selector and analytics to track techniques by their behavioral - configuration rather than by name alone. + Technique keys are eval hashes derived from the inner attack strategy's + identifier (run through ``AtomicAttackEvaluationIdentifier`` so seeds, + scorers, and operational target params are excluded). The same hash is + auto-stamped on every persisted ``AttackResultEntry.atomic_attack_identifier`` + by the executor, which lets the selector aggregate historical success + rates by behavioral configuration via + ``MemoryInterface.get_attack_results(atomic_attack_eval_hashes=...)``. For factories whose attack class narrows ``attack_scoring_config`` to a specific subtype (e.g. ``TAPAttackScoringConfig`` for TAP), this method @@ -182,8 +186,7 @@ def _build_techniques_dict( skipped_incompatible[technique_name] = str(exc) logger.warning(f"Skipping technique '{technique_name}': {type(exc).__name__}: {exc}") continue - eval_hash = technique.get_identifier().hash - assert eval_hash is not None, f"Technique {technique_name!r} produced no identifier hash" + eval_hash = compute_inner_attack_eval_hash(attack=technique.attack) techniques[eval_hash] = TechniqueBundle( attack=technique.attack, name=technique_name, diff --git a/pyrit/scenario/scenarios/adaptive/dispatcher.py b/pyrit/scenario/scenarios/adaptive/dispatcher.py index 4709c4efe1..ac5c9b429c 100644 --- a/pyrit/scenario/scenarios/adaptive/dispatcher.py +++ b/pyrit/scenario/scenarios/adaptive/dispatcher.py @@ -35,7 +35,6 @@ ) from pyrit.executor.attack.core.attack_parameters import AttackParameters from pyrit.executor.attack.core.attack_strategy import AttackContext, AttackStrategy -from pyrit.scenario.scenarios.adaptive.selectors.technique_selector import ADAPTIVE_TECHNIQUE_LABEL if TYPE_CHECKING: from collections.abc import Sequence @@ -233,10 +232,14 @@ def _build_child_attacks( Per chosen technique: merge ``bundle.seed_technique`` into ``seed_group`` (if any), and stamp the per-attempt - ``ADAPTIVE_TECHNIQUE_LABEL`` and ``ADAPTIVE_ATTEMPT_LABEL`` memory - labels. ``SequentialAttack`` further merges these with the - compound's own ``context.memory_labels`` at dispatch time, so the - outer caller's labels still propagate to every attempt. + ``ADAPTIVE_ATTEMPT_LABEL`` memory label. ``SequentialAttack`` + further merges this with the compound's own ``context.memory_labels`` + at dispatch time, so the outer caller's labels still propagate to + every attempt. + + Per-technique disambiguation in analytics relies on the + auto-stamped ``atomic_attack_identifier.eval_hash`` on each + persisted attempt row, not a custom label. Args: seed_group (SeedAttackGroup): The seed group for this dispatch call. @@ -262,7 +265,6 @@ def _build_child_attacks( adversarial_chat=bundle.adversarial_chat, objective_scorer=self._objective_scorer, memory_labels={ - ADAPTIVE_TECHNIQUE_LABEL: chosen, ADAPTIVE_ATTEMPT_LABEL: str(attempt_idx + 1), }, ) diff --git a/pyrit/scenario/scenarios/adaptive/selectors/__init__.py b/pyrit/scenario/scenarios/adaptive/selectors/__init__.py index 9fe4c2d3c7..709146fb43 100644 --- a/pyrit/scenario/scenarios/adaptive/selectors/__init__.py +++ b/pyrit/scenario/scenarios/adaptive/selectors/__init__.py @@ -7,13 +7,11 @@ EpsilonGreedyTechniqueSelector, ) from pyrit.scenario.scenarios.adaptive.selectors.technique_selector import ( - ADAPTIVE_TECHNIQUE_LABEL, SelectorScope, TechniqueSelector, ) __all__ = [ - "ADAPTIVE_TECHNIQUE_LABEL", "EpsilonGreedyTechniqueSelector", "SelectorScope", "TechniqueSelector", diff --git a/pyrit/scenario/scenarios/adaptive/selectors/epsilon_greedy.py b/pyrit/scenario/scenarios/adaptive/selectors/epsilon_greedy.py index 6971c6ae95..dd91af7d5c 100644 --- a/pyrit/scenario/scenarios/adaptive/selectors/epsilon_greedy.py +++ b/pyrit/scenario/scenarios/adaptive/selectors/epsilon_greedy.py @@ -120,9 +120,7 @@ async def select_async( stats = compute_technique_stats( technique_eval_hashes=technique_list, scenario_result_id=effective_run_id, - attack_classes=self._scope.attack_classes, targeted_harm_categories=self._scope.targeted_harm_categories, - extra_labels=self._scope.extra_labels, ) chosen: list[str] = [] diff --git a/pyrit/scenario/scenarios/adaptive/selectors/technique_selector.py b/pyrit/scenario/scenarios/adaptive/selectors/technique_selector.py index f29f31ee1a..eada0fb5ed 100644 --- a/pyrit/scenario/scenarios/adaptive/selectors/technique_selector.py +++ b/pyrit/scenario/scenarios/adaptive/selectors/technique_selector.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Protocol, runtime_checkable if TYPE_CHECKING: - from collections.abc import Mapping, Sequence + from collections.abc import Sequence @dataclass(frozen=True) @@ -19,34 +19,29 @@ class SelectorScope: queries when estimating technique success rates. All fields default to "no restriction"; combine fields to narrow the - scope (e.g. current run only, same scenario class, same harm category). - Filter values flow through ``compute_technique_stats`` to + scope (e.g. current run only, same harm category). Filter values flow + through ``compute_technique_stats`` to ``MemoryInterface.get_attack_results``. The scope is held by the selector at construction time. The per-call ``scenario_result_id`` is supplied by the dispatcher and is forwarded to memory only when ``current_run_only`` is set; otherwise the selector queries across all runs. + + Per-technique disambiguation uses ``atomic_attack_identifier.eval_hash`` + (auto-stamped on every persisted attack result), which already encodes + the attack class plus its behavior-relevant params. Class-based + narrowing is therefore unnecessary at this layer. """ current_run_only: bool = False """Restrict to the dispatcher-supplied ``scenario_result_id`` for the in-flight run. When ``False`` (default), query across all runs.""" - attack_classes: Sequence[str] | None = None - """Filter to results emitted by these attack / scenario class names - (e.g. ``["TextAdaptive"]``). Useful to keep one modality's bandit from - being influenced by another's history. ``None`` means no class filter.""" - targeted_harm_categories: Sequence[str] | None = None """Filter to results whose prompts targeted these harm categories. ``None`` means no harm-category filter.""" - extra_labels: Mapping[str, str | Sequence[str]] | None = None - """Additional memory-label filters merged on top of the technique-hash - label that the selector adds internally. Use this as an escape hatch - for label-based filtering not covered by the named fields.""" - @classmethod def all_runs(cls) -> SelectorScope: """ @@ -68,11 +63,6 @@ def current_run(cls) -> SelectorScope: return cls(current_run_only=True) -ADAPTIVE_TECHNIQUE_LABEL: str = "_adaptive_technique" -"""Memory-label key the dispatcher stamps on each attack result to record -which technique was used.""" - - @runtime_checkable class TechniqueSelector(Protocol): """ @@ -96,7 +86,8 @@ async def select_async( Return techniques in priority order (try first, try second, …). Args: - technique_identifiers (Sequence[str]): Available technique names. + technique_identifiers (Sequence[str]): Available technique eval + hashes. objective (str): The objective text for this selection. num_top_techniques (int): Max techniques to return. Defaults to 1. scenario_result_id (str | None): The current scenario run ID, @@ -105,7 +96,8 @@ async def select_async( ``current_run_only=True``. Returns: - Sequence[str]: Up to ``num_top_techniques`` technique names in - priority order. Fewer if not enough techniques are available. + Sequence[str]: Up to ``num_top_techniques`` technique eval hashes + in priority order. Fewer if not enough techniques are + available. """ ... # pragma: no cover diff --git a/tests/unit/analytics/test_scenario_analysis.py b/tests/unit/analytics/test_scenario_analysis.py deleted file mode 100644 index c319273458..0000000000 --- a/tests/unit/analytics/test_scenario_analysis.py +++ /dev/null @@ -1,158 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -from unittest.mock import MagicMock, patch - -import pytest - -from pyrit.analytics.scenario_analysis import compute_technique_success_rates -from pyrit.models import AttackOutcome - -LABEL_KEY = "_adaptive_technique" - - -def _make_result(*, technique: str, outcome: AttackOutcome) -> MagicMock: - r = MagicMock() - r.labels = {LABEL_KEY: technique} - r.outcome = outcome - return r - - -@pytest.fixture(autouse=True) -def _patch_memory(): - mock_memory = MagicMock() - mock_memory.get_attack_results.return_value = [] - with patch("pyrit.analytics.scenario_analysis.CentralMemory") as cm: - cm.get_memory_instance.return_value = mock_memory - yield mock_memory - - -class TestComputeTechniqueSuccessRates: - def test_empty_results_returns_empty(self, _patch_memory): - stats = compute_technique_success_rates(technique_hashes=["a", "b"], label_key=LABEL_KEY) - assert stats == {} - - def test_counts_successes_and_failures(self, _patch_memory): - _patch_memory.get_attack_results.return_value = [ - _make_result(technique="a", outcome=AttackOutcome.SUCCESS), - _make_result(technique="a", outcome=AttackOutcome.SUCCESS), - _make_result(technique="a", outcome=AttackOutcome.FAILURE), - _make_result(technique="b", outcome=AttackOutcome.FAILURE), - ] - - stats = compute_technique_success_rates(technique_hashes=["a", "b"], label_key=LABEL_KEY) - - assert stats["a"].successes == 2 - assert stats["a"].failures == 1 - assert stats["a"].total_decided == 3 - assert stats["b"].successes == 0 - assert stats["b"].failures == 1 - - def test_counts_errors_and_undetermined(self, _patch_memory): - _patch_memory.get_attack_results.return_value = [ - _make_result(technique="a", outcome=AttackOutcome.ERROR), - _make_result(technique="a", outcome=AttackOutcome.UNDETERMINED), - ] - - stats = compute_technique_success_rates(technique_hashes=["a"], label_key=LABEL_KEY) - - assert stats["a"].errors == 1 - assert stats["a"].undetermined == 1 - - def test_ignores_techniques_not_in_requested_list(self, _patch_memory): - _patch_memory.get_attack_results.return_value = [ - _make_result(technique="a", outcome=AttackOutcome.SUCCESS), - _make_result(technique="c", outcome=AttackOutcome.SUCCESS), - ] - - stats = compute_technique_success_rates(technique_hashes=["a", "b"], label_key=LABEL_KEY) - - assert "a" in stats - assert "c" not in stats - - def test_passes_label_key_to_memory_query(self, _patch_memory): - custom_key = "my_custom_key" - compute_technique_success_rates(technique_hashes=["x"], label_key=custom_key) - - call_kwargs = _patch_memory.get_attack_results.call_args[1] - assert call_kwargs["labels"] == {custom_key: ["x"]} - assert call_kwargs["scenario_result_id"] is None - - def test_passes_scenario_result_id_to_memory_query(self, _patch_memory): - compute_technique_success_rates(technique_hashes=["x"], label_key=LABEL_KEY, scenario_result_id="run-123") - - call_kwargs = _patch_memory.get_attack_results.call_args[1] - assert call_kwargs["scenario_result_id"] == "run-123" - - def test_omits_techniques_with_no_history(self, _patch_memory): - _patch_memory.get_attack_results.return_value = [ - _make_result(technique="a", outcome=AttackOutcome.SUCCESS), - ] - - stats = compute_technique_success_rates(technique_hashes=["a", "b"], label_key=LABEL_KEY) - - assert "a" in stats - assert "b" not in stats - - def test_success_rate_computed(self, _patch_memory): - _patch_memory.get_attack_results.return_value = [ - _make_result(technique="a", outcome=AttackOutcome.SUCCESS), - _make_result(technique="a", outcome=AttackOutcome.SUCCESS), - _make_result(technique="a", outcome=AttackOutcome.FAILURE), - _make_result(technique="a", outcome=AttackOutcome.FAILURE), - ] - - stats = compute_technique_success_rates(technique_hashes=["a"], label_key=LABEL_KEY) - - assert stats["a"].success_rate == pytest.approx(0.5) - - def test_passes_attack_classes_to_memory_query(self, _patch_memory): - compute_technique_success_rates( - technique_hashes=["x"], - label_key=LABEL_KEY, - attack_classes=["TextAdaptive", "ImageAdaptive"], - ) - - call_kwargs = _patch_memory.get_attack_results.call_args[1] - assert call_kwargs["attack_classes"] == ["TextAdaptive", "ImageAdaptive"] - - def test_passes_harm_categories_to_memory_query(self, _patch_memory): - compute_technique_success_rates( - technique_hashes=["x"], - label_key=LABEL_KEY, - targeted_harm_categories=["misinformation", "hate"], - ) - - call_kwargs = _patch_memory.get_attack_results.call_args[1] - assert call_kwargs["targeted_harm_categories"] == ["misinformation", "hate"] - - def test_merges_extra_labels_with_technique_label(self, _patch_memory): - compute_technique_success_rates( - technique_hashes=["x"], - label_key=LABEL_KEY, - extra_labels={"experiment": "ablation_v3", "tier": ["a", "b"]}, - ) - - call_kwargs = _patch_memory.get_attack_results.call_args[1] - assert call_kwargs["labels"] == { - LABEL_KEY: ["x"], - "experiment": "ablation_v3", - "tier": ["a", "b"], - } - - def test_extra_labels_cannot_override_technique_label(self, _patch_memory): - compute_technique_success_rates( - technique_hashes=["x"], - label_key=LABEL_KEY, - extra_labels={LABEL_KEY: ["evil"], "other": "ok"}, - ) - - call_kwargs = _patch_memory.get_attack_results.call_args[1] - assert call_kwargs["labels"] == {LABEL_KEY: ["x"], "other": "ok"} - - def test_default_filter_kwargs_are_none(self, _patch_memory): - compute_technique_success_rates(technique_hashes=["x"], label_key=LABEL_KEY) - - call_kwargs = _patch_memory.get_attack_results.call_args[1] - assert call_kwargs["attack_classes"] is None - assert call_kwargs["targeted_harm_categories"] is None diff --git a/tests/unit/analytics/test_technique_analysis.py b/tests/unit/analytics/test_technique_analysis.py index d8b40c3699..04b1d94890 100644 --- a/tests/unit/analytics/test_technique_analysis.py +++ b/tests/unit/analytics/test_technique_analysis.py @@ -5,18 +5,18 @@ import pytest -from pyrit.analytics.technique_analysis import ( - _DEFAULT_TECHNIQUE_LABEL_KEY, - compute_technique_stats, -) +from pyrit.analytics.technique_analysis import compute_technique_stats from pyrit.models import AttackOutcome -LABEL_KEY = _DEFAULT_TECHNIQUE_LABEL_KEY - -def _make_result(*, technique: str, outcome: AttackOutcome) -> MagicMock: +def _make_result(*, eval_hash: str | None, outcome: AttackOutcome) -> MagicMock: r = MagicMock() - r.labels = {LABEL_KEY: technique} + if eval_hash is None: + r.atomic_attack_identifier = None + else: + identifier = MagicMock() + identifier.eval_hash = eval_hash + r.atomic_attack_identifier = identifier r.outcome = outcome return r @@ -35,12 +35,17 @@ def test_empty_results_returns_empty(self, _patch_memory): stats = compute_technique_stats(technique_eval_hashes=["a", "b"]) assert stats == {} + def test_empty_hashes_short_circuits(self, _patch_memory): + stats = compute_technique_stats(technique_eval_hashes=[]) + assert stats == {} + _patch_memory.get_attack_results.assert_not_called() + def test_counts_successes_and_failures(self, _patch_memory): _patch_memory.get_attack_results.return_value = [ - _make_result(technique="a", outcome=AttackOutcome.SUCCESS), - _make_result(technique="a", outcome=AttackOutcome.SUCCESS), - _make_result(technique="a", outcome=AttackOutcome.FAILURE), - _make_result(technique="b", outcome=AttackOutcome.FAILURE), + _make_result(eval_hash="a", outcome=AttackOutcome.SUCCESS), + _make_result(eval_hash="a", outcome=AttackOutcome.SUCCESS), + _make_result(eval_hash="a", outcome=AttackOutcome.FAILURE), + _make_result(eval_hash="b", outcome=AttackOutcome.FAILURE), ] stats = compute_technique_stats(technique_eval_hashes=["a", "b"]) @@ -53,8 +58,8 @@ def test_counts_successes_and_failures(self, _patch_memory): def test_counts_errors_and_undetermined(self, _patch_memory): _patch_memory.get_attack_results.return_value = [ - _make_result(technique="a", outcome=AttackOutcome.ERROR), - _make_result(technique="a", outcome=AttackOutcome.UNDETERMINED), + _make_result(eval_hash="a", outcome=AttackOutcome.ERROR), + _make_result(eval_hash="a", outcome=AttackOutcome.UNDETERMINED), ] stats = compute_technique_stats(technique_eval_hashes=["a"]) @@ -62,10 +67,10 @@ def test_counts_errors_and_undetermined(self, _patch_memory): assert stats["a"].errors == 1 assert stats["a"].undetermined == 1 - def test_ignores_techniques_not_in_requested_list(self, _patch_memory): + def test_ignores_hashes_not_in_requested_list(self, _patch_memory): _patch_memory.get_attack_results.return_value = [ - _make_result(technique="a", outcome=AttackOutcome.SUCCESS), - _make_result(technique="c", outcome=AttackOutcome.SUCCESS), + _make_result(eval_hash="a", outcome=AttackOutcome.SUCCESS), + _make_result(eval_hash="c", outcome=AttackOutcome.SUCCESS), ] stats = compute_technique_stats(technique_eval_hashes=["a", "b"]) @@ -73,19 +78,23 @@ def test_ignores_techniques_not_in_requested_list(self, _patch_memory): assert "a" in stats assert "c" not in stats - def test_default_label_key_used_when_omitted(self, _patch_memory): - compute_technique_stats(technique_eval_hashes=["x"]) + def test_skips_results_without_eval_hash(self, _patch_memory): + _patch_memory.get_attack_results.return_value = [ + _make_result(eval_hash="a", outcome=AttackOutcome.SUCCESS), + _make_result(eval_hash=None, outcome=AttackOutcome.SUCCESS), + ] - call_kwargs = _patch_memory.get_attack_results.call_args[1] - assert call_kwargs["labels"] == {LABEL_KEY: ["x"]} + stats = compute_technique_stats(technique_eval_hashes=["a"]) + + assert stats["a"].successes == 1 - def test_passes_custom_label_key_to_memory_query(self, _patch_memory): - custom_key = "my_custom_key" - compute_technique_stats(technique_eval_hashes=["x"], label_key=custom_key) + def test_passes_eval_hashes_to_memory_query(self, _patch_memory): + compute_technique_stats(technique_eval_hashes=["x", "y"]) call_kwargs = _patch_memory.get_attack_results.call_args[1] - assert call_kwargs["labels"] == {custom_key: ["x"]} + assert call_kwargs["atomic_attack_eval_hashes"] == ["x", "y"] assert call_kwargs["scenario_result_id"] is None + assert call_kwargs["targeted_harm_categories"] is None def test_passes_scenario_result_id_to_memory_query(self, _patch_memory): compute_technique_stats(technique_eval_hashes=["x"], scenario_result_id="run-123") @@ -93,9 +102,9 @@ def test_passes_scenario_result_id_to_memory_query(self, _patch_memory): call_kwargs = _patch_memory.get_attack_results.call_args[1] assert call_kwargs["scenario_result_id"] == "run-123" - def test_omits_techniques_with_no_history(self, _patch_memory): + def test_omits_hashes_with_no_history(self, _patch_memory): _patch_memory.get_attack_results.return_value = [ - _make_result(technique="a", outcome=AttackOutcome.SUCCESS), + _make_result(eval_hash="a", outcome=AttackOutcome.SUCCESS), ] stats = compute_technique_stats(technique_eval_hashes=["a", "b"]) @@ -105,25 +114,16 @@ def test_omits_techniques_with_no_history(self, _patch_memory): def test_success_rate_computed(self, _patch_memory): _patch_memory.get_attack_results.return_value = [ - _make_result(technique="a", outcome=AttackOutcome.SUCCESS), - _make_result(technique="a", outcome=AttackOutcome.SUCCESS), - _make_result(technique="a", outcome=AttackOutcome.FAILURE), - _make_result(technique="a", outcome=AttackOutcome.FAILURE), + _make_result(eval_hash="a", outcome=AttackOutcome.SUCCESS), + _make_result(eval_hash="a", outcome=AttackOutcome.SUCCESS), + _make_result(eval_hash="a", outcome=AttackOutcome.FAILURE), + _make_result(eval_hash="a", outcome=AttackOutcome.FAILURE), ] stats = compute_technique_stats(technique_eval_hashes=["a"]) assert stats["a"].success_rate == pytest.approx(0.5) - def test_passes_attack_classes_to_memory_query(self, _patch_memory): - compute_technique_stats( - technique_eval_hashes=["x"], - attack_classes=["TextAdaptive", "ImageAdaptive"], - ) - - call_kwargs = _patch_memory.get_attack_results.call_args[1] - assert call_kwargs["attack_classes"] == ["TextAdaptive", "ImageAdaptive"] - def test_passes_harm_categories_to_memory_query(self, _patch_memory): compute_technique_stats( technique_eval_hashes=["x"], @@ -133,39 +133,10 @@ def test_passes_harm_categories_to_memory_query(self, _patch_memory): call_kwargs = _patch_memory.get_attack_results.call_args[1] assert call_kwargs["targeted_harm_categories"] == ["misinformation", "hate"] - def test_merges_extra_labels_with_technique_label(self, _patch_memory): - compute_technique_stats( - technique_eval_hashes=["x"], - extra_labels={"experiment": "ablation_v3", "tier": ["a", "b"]}, - ) - - call_kwargs = _patch_memory.get_attack_results.call_args[1] - assert call_kwargs["labels"] == { - LABEL_KEY: ["x"], - "experiment": "ablation_v3", - "tier": ["a", "b"], - } - - def test_extra_labels_cannot_override_technique_label(self, _patch_memory): - compute_technique_stats( - technique_eval_hashes=["x"], - extra_labels={LABEL_KEY: ["evil"], "other": "ok"}, - ) - - call_kwargs = _patch_memory.get_attack_results.call_args[1] - assert call_kwargs["labels"] == {LABEL_KEY: ["x"], "other": "ok"} - - def test_default_filter_kwargs_are_none(self, _patch_memory): - compute_technique_stats(technique_eval_hashes=["x"]) - - call_kwargs = _patch_memory.get_attack_results.call_args[1] - assert call_kwargs["attack_classes"] is None - assert call_kwargs["targeted_harm_categories"] is None - def test_injected_memory_bypasses_central_memory(self, _patch_memory): injected = MagicMock() injected.get_attack_results.return_value = [ - _make_result(technique="a", outcome=AttackOutcome.SUCCESS), + _make_result(eval_hash="a", outcome=AttackOutcome.SUCCESS), ] stats = compute_technique_stats(technique_eval_hashes=["a"], memory=injected) @@ -173,10 +144,3 @@ def test_injected_memory_bypasses_central_memory(self, _patch_memory): injected.get_attack_results.assert_called_once() _patch_memory.get_attack_results.assert_not_called() assert stats["a"].successes == 1 - - def test_default_label_key_matches_adaptive_constant(self): - from pyrit.scenario.scenarios.adaptive.selectors.technique_selector import ( - ADAPTIVE_TECHNIQUE_LABEL, - ) - - assert _DEFAULT_TECHNIQUE_LABEL_KEY == ADAPTIVE_TECHNIQUE_LABEL diff --git a/tests/unit/identifiers/test_evaluation_identifier.py b/tests/unit/identifiers/test_evaluation_identifier.py index c716b1a331..c4a066771e 100644 --- a/tests/unit/identifiers/test_evaluation_identifier.py +++ b/tests/unit/identifiers/test_evaluation_identifier.py @@ -584,3 +584,74 @@ def test_scorer_eval_hash_matches_with_and_without_round_robin(self): eval_rr = ScorerEvaluationIdentifier(scorer_rr).eval_hash assert eval_direct == eval_rr + + +class TestComputeInnerAttackEvalHash: + """``compute_inner_attack_eval_hash`` should match what the executor stamps.""" + + def _attack_with_identifier(self, identifier: ComponentIdentifier): + from unittest.mock import MagicMock + + attack = MagicMock() + attack.get_identifier.return_value = identifier + return attack + + def test_matches_manual_two_step_composition(self): + """Helper equals the executor recipe (build_atomic_attack_identifier + AtomicAttackEvaluationIdentifier).""" + from pyrit.identifiers import ( + AtomicAttackEvaluationIdentifier, + build_atomic_attack_identifier, + compute_inner_attack_eval_hash, + ) + + inner_id = ComponentIdentifier( + class_name="PromptSendingAttack", + class_module="pyrit.executor.attack.single_turn.prompt_sending", + ) + attack = self._attack_with_identifier(inner_id) + + expected = AtomicAttackEvaluationIdentifier( + build_atomic_attack_identifier(attack_identifier=inner_id), + ).eval_hash + assert compute_inner_attack_eval_hash(attack=attack) == expected + + def test_differs_when_attack_class_differs(self): + from pyrit.identifiers import compute_inner_attack_eval_hash + + a = self._attack_with_identifier( + ComponentIdentifier(class_name="A", class_module="m"), + ) + b = self._attack_with_identifier( + ComponentIdentifier(class_name="B", class_module="m"), + ) + assert compute_inner_attack_eval_hash(attack=a) != compute_inner_attack_eval_hash(attack=b) + + def test_stable_across_calls_for_same_attack(self): + from pyrit.identifiers import compute_inner_attack_eval_hash + + attack = self._attack_with_identifier( + ComponentIdentifier(class_name="Same", class_module="m"), + ) + assert compute_inner_attack_eval_hash(attack=attack) == compute_inner_attack_eval_hash(attack=attack) + + def test_matches_persisted_row_eval_hash(self): + """Whatever the helper returns, persisting an attack result with the same + identifier must yield an entry with the same eval_hash.""" + from pyrit.identifiers import build_atomic_attack_identifier, compute_inner_attack_eval_hash + from pyrit.memory.memory_models import AttackResultEntry + from pyrit.models import AttackResult + + inner_id = ComponentIdentifier( + class_name="MyAttack", + class_module="pyrit.attacks", + ) + attack = self._attack_with_identifier(inner_id) + predicted = compute_inner_attack_eval_hash(attack=attack) + + result = AttackResult( + conversation_id="conv_1", + objective="o", + atomic_attack_identifier=build_atomic_attack_identifier(attack_identifier=inner_id), + ) + entry = AttackResultEntry(entry=result) + assert entry.atomic_attack_identifier["eval_hash"] == predicted diff --git a/tests/unit/memory/memory_interface/test_interface_attack_results.py b/tests/unit/memory/memory_interface/test_interface_attack_results.py index ca96b873cc..6a422d1d81 100644 --- a/tests/unit/memory/memory_interface/test_interface_attack_results.py +++ b/tests/unit/memory/memory_interface/test_interface_attack_results.py @@ -1356,6 +1356,62 @@ def test_get_attack_results_attack_classes_empty_returns_all(sqlite_instance: Me assert len(results) == 2 +def _eval_hash_for(class_name: str) -> str: + from pyrit.identifiers.evaluation_identifier import AtomicAttackEvaluationIdentifier + + return AtomicAttackEvaluationIdentifier( + build_atomic_attack_identifier( + attack_identifier=ComponentIdentifier( + class_name=class_name, + class_module="pyrit.attacks", + ), + ), + ).eval_hash + + +def test_get_attack_results_by_atomic_attack_eval_hashes_single(sqlite_instance: MemoryInterface): + """Filter by a single eval_hash; only matching rows are returned.""" + ar1 = _make_attack_result_with_identifier("conv_1", "CrescendoAttack") + ar2 = _make_attack_result_with_identifier("conv_2", "ManualAttack") + sqlite_instance.add_attack_results_to_memory(attack_results=[ar1, ar2]) + + target_hash = _eval_hash_for("CrescendoAttack") + results = sqlite_instance.get_attack_results(atomic_attack_eval_hashes=[target_hash]) + assert len(results) == 1 + assert results[0].conversation_id == "conv_1" + + +def test_get_attack_results_by_atomic_attack_eval_hashes_multi_uses_or(sqlite_instance: MemoryInterface): + """Multiple eval_hashes OR-combine — matches any of the listed hashes.""" + ar1 = _make_attack_result_with_identifier("conv_1", "CrescendoAttack") + ar2 = _make_attack_result_with_identifier("conv_2", "ManualAttack") + ar3 = _make_attack_result_with_identifier("conv_3", "TreeOfAttacksAttack") + sqlite_instance.add_attack_results_to_memory(attack_results=[ar1, ar2, ar3]) + + hashes = [_eval_hash_for("CrescendoAttack"), _eval_hash_for("ManualAttack")] + results = sqlite_instance.get_attack_results(atomic_attack_eval_hashes=hashes) + assert {r.conversation_id for r in results} == {"conv_1", "conv_2"} + + +def test_get_attack_results_atomic_attack_eval_hashes_empty_returns_all(sqlite_instance: MemoryInterface): + """atomic_attack_eval_hashes=[] behaves like None (no filter applied).""" + ar1 = _make_attack_result_with_identifier("conv_1", "CrescendoAttack") + ar2 = _make_attack_result_with_identifier("conv_2", "ManualAttack") + sqlite_instance.add_attack_results_to_memory(attack_results=[ar1, ar2]) + + results = sqlite_instance.get_attack_results(atomic_attack_eval_hashes=[]) + assert len(results) == 2 + + +def test_get_attack_results_atomic_attack_eval_hashes_no_match(sqlite_instance: MemoryInterface): + """A non-matching eval_hash returns no rows.""" + ar1 = _make_attack_result_with_identifier("conv_1", "CrescendoAttack") + sqlite_instance.add_attack_results_to_memory(attack_results=[ar1]) + + results = sqlite_instance.get_attack_results(atomic_attack_eval_hashes=["deadbeef" * 8]) + assert len(results) == 0 + + def test_get_attack_results_converter_classes_none_returns_all(sqlite_instance: MemoryInterface): """Test that converter_classes=None (omitted) returns all attacks unfiltered.""" ar1 = _make_attack_result_with_identifier("conv_1", "Attack", ["Base64Converter"]) diff --git a/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py b/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py index abaa2b6f6d..95f1a45840 100644 --- a/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py +++ b/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py @@ -13,7 +13,6 @@ from pyrit.models import AttackOutcome, AttackResult, SeedAttackGroup, SeedObjective from pyrit.scenario.scenarios.adaptive.dispatcher import ( ADAPTIVE_ATTEMPT_LABEL, - ADAPTIVE_TECHNIQUE_LABEL, AdaptiveDispatchAttack, AdaptiveDispatchContext, AdaptiveDispatchParams, @@ -200,8 +199,10 @@ async def test_passes_attempt_labels_to_inner(self, target, seed_group, monkeypa labels = calls[0]["attempt_labels"] assert labels["foo"] == "bar" - assert labels[ADAPTIVE_TECHNIQUE_LABEL] == "a" assert labels[ADAPTIVE_ATTEMPT_LABEL] == "1" + # The dispatcher no longer stamps a technique label — per-technique + # disambiguation lives on the persisted ``atomic_attack_identifier.eval_hash``. + assert "_adaptive_technique" not in labels async def test_metadata_records_adaptive_trail(self, target, seed_group, monkeypatch): bundles = { diff --git a/tests/unit/scenario/scenarios/adaptive/test_epsilon_greedy.py b/tests/unit/scenario/scenarios/adaptive/test_epsilon_greedy.py index c9352a1b00..21144721f5 100644 --- a/tests/unit/scenario/scenarios/adaptive/test_epsilon_greedy.py +++ b/tests/unit/scenario/scenarios/adaptive/test_epsilon_greedy.py @@ -137,9 +137,7 @@ async def test_default_scope_passes_none_scenario_result_id(self, mock_compute): # Default scope is all_runs(): the per-call scenario_result_id is dropped. assert mock_compute.call_args.kwargs["scenario_result_id"] is None - assert mock_compute.call_args.kwargs["attack_classes"] is None assert mock_compute.call_args.kwargs["targeted_harm_categories"] is None - assert mock_compute.call_args.kwargs["extra_labels"] is None @patch(_COMPUTE_PATH, side_effect=_empty_rates) async def test_current_run_scope_forwards_scenario_result_id(self, mock_compute): @@ -151,17 +149,13 @@ async def test_current_run_scope_forwards_scenario_result_id(self, mock_compute) @patch(_COMPUTE_PATH, side_effect=_empty_rates) async def test_scope_filter_fields_forwarded(self, mock_compute): scope = SelectorScope( - attack_classes=["TextAdaptive"], targeted_harm_categories=["misinformation"], - extra_labels={"experiment": "ablation_v3"}, ) selector = EpsilonGreedyTechniqueSelector(epsilon=0.0, random_seed=0, scope=scope) await selector.select_async(technique_identifiers=TECHNIQUES, objective="obj") kwargs = mock_compute.call_args.kwargs - assert kwargs["attack_classes"] == ["TextAdaptive"] assert kwargs["targeted_harm_categories"] == ["misinformation"] - assert kwargs["extra_labels"] == {"experiment": "ablation_v3"} class TestEpsilonGreedyEstimate: diff --git a/tests/unit/scenario/scenarios/adaptive/test_selector_scope.py b/tests/unit/scenario/scenarios/adaptive/test_selector_scope.py index beef26f743..f024d2c5a5 100644 --- a/tests/unit/scenario/scenarios/adaptive/test_selector_scope.py +++ b/tests/unit/scenario/scenarios/adaptive/test_selector_scope.py @@ -12,9 +12,7 @@ class TestSelectorScopeDefaults: def test_default_constructs_all_runs(self): scope = SelectorScope() assert scope.current_run_only is False - assert scope.attack_classes is None assert scope.targeted_harm_categories is None - assert scope.extra_labels is None def test_all_runs_classmethod_equivalent_to_default(self): assert SelectorScope.all_runs() == SelectorScope() @@ -22,9 +20,7 @@ def test_all_runs_classmethod_equivalent_to_default(self): def test_current_run_classmethod_sets_flag(self): scope = SelectorScope.current_run() assert scope.current_run_only is True - assert scope.attack_classes is None assert scope.targeted_harm_categories is None - assert scope.extra_labels is None class TestSelectorScopeFrozen: @@ -36,25 +32,21 @@ def test_assigning_field_raises(self): def test_assigning_new_field_raises(self): scope = SelectorScope() with pytest.raises(dataclasses.FrozenInstanceError): - scope.extra_labels = {"a": "b"} # type: ignore[misc] + scope.targeted_harm_categories = ("a",) # type: ignore[misc] class TestSelectorScopeCombinations: def test_fields_combine(self): scope = SelectorScope( current_run_only=True, - attack_classes=["TextAdaptive"], targeted_harm_categories=["misinformation"], - extra_labels={"experiment": "v3"}, ) assert scope.current_run_only is True - assert scope.attack_classes == ["TextAdaptive"] assert scope.targeted_harm_categories == ["misinformation"] - assert scope.extra_labels == {"experiment": "v3"} def test_equality_value_based(self): - a = SelectorScope(attack_classes=("X",), targeted_harm_categories=("y",)) - b = SelectorScope(attack_classes=("X",), targeted_harm_categories=("y",)) + a = SelectorScope(targeted_harm_categories=("y",)) + b = SelectorScope(targeted_harm_categories=("y",)) assert a == b def test_inequality_when_fields_differ(self): diff --git a/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py b/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py index a888b45f73..a0cc19158a 100644 --- a/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py +++ b/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py @@ -22,6 +22,7 @@ from pyrit.score import TrueFalseScorer _MOCK_MANY_SHOT_EXAMPLES = [{"question": f"q{i}", "answer": f"a{i}"} for i in range(100)] +_FAKE_FACTORY_COUNTER = 0 def _mock_id(name: str) -> ComponentIdentifier: @@ -87,10 +88,20 @@ def _make_fake_factory(*, seed_technique=None, adversarial_chat=None, scoring_co Mocks the surface ``AdaptiveScenario._build_techniques_dict`` consumes (``factory.create(...)``, ``factory.adversarial_chat``, and - ``factory.scoring_config_type``). + ``factory.scoring_config_type``). Each call assigns a unique fake + attack identifier so the bundle dict keys (eval hashes) don't collide. """ + global _FAKE_FACTORY_COUNTER + _FAKE_FACTORY_COUNTER += 1 + fake_id = _FAKE_FACTORY_COUNTER + fake_technique = MagicMock() - fake_technique.attack = MagicMock(name="fake-attack-strategy") + fake_attack = MagicMock(name=f"fake-attack-strategy-{fake_id}") + fake_attack.get_identifier.return_value = ComponentIdentifier( + class_name=f"FakeAttack{fake_id}", + class_module="test_text_adaptive", + ) + fake_technique.attack = fake_attack fake_technique.seed_technique = seed_technique factory = MagicMock() factory.create.return_value = fake_technique From 1d8b611260a1cd01c899c77befd29bbbc6638304 Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Mon, 1 Jun 2026 16:41:29 -0400 Subject: [PATCH 22/35] fix merge --- pyrit/scenario/core/attack_technique_factory.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/pyrit/scenario/core/attack_technique_factory.py b/pyrit/scenario/core/attack_technique_factory.py index c846696095..d7e1d7d678 100644 --- a/pyrit/scenario/core/attack_technique_factory.py +++ b/pyrit/scenario/core/attack_technique_factory.py @@ -162,15 +162,7 @@ def adversarial_chat(self) -> PromptTarget | None: @property def scoring_config_type(self) -> type | None: - """ - The narrowed ``attack_scoring_config`` annotation declared by the attack class. - - Returns the concrete subtype of ``AttackScoringConfig`` the attack's - constructor expects (e.g. ``TAPAttackScoringConfig`` for TAP), or ``None`` - when the base type is accepted or the annotation cannot be resolved. - Callers can use this to build a config of the right shape before invoking - ``create``. - """ + """The required ``attack_scoring_config`` subtype, or ``None`` if any config is accepted.""" return self._get_scoring_config_type() def create( From a31a2e018a383f6f65a1ccfdb1d2f77e9463be45 Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Mon, 1 Jun 2026 18:33:53 -0400 Subject: [PATCH 23/35] fix merge --- .../1_common_scenario_parameters.ipynb | 46 +- .../scenarios/1_common_scenario_parameters.py | 36 +- .../executor/attack/multi_turn/red_teaming.py | 2 +- pyrit/memory/memory_interface.py | 18 +- pyrit/models/identifiers/__init__.py | 51 + .../identifiers/evaluation_identifier.py | 383 ++++++++ pyrit/models/seeds/seed_attack_group.py | 6 +- .../scenario/core/attack_technique_factory.py | 278 +++++- .../scenarios/adaptive/adaptive_scenario.py | 38 +- .../scenarios/adaptive/text_adaptive.py | 19 +- .../test_interface_attack_results.py | 28 +- .../identifiers/test_evaluation_identifier.py | 868 ++++++++++++++++++ tests/unit/models/test_import_boundary.py | 15 +- .../scenarios/adaptive/test_text_adaptive.py | 2 +- 14 files changed, 1634 insertions(+), 156 deletions(-) create mode 100644 pyrit/models/identifiers/__init__.py create mode 100644 pyrit/models/identifiers/evaluation_identifier.py create mode 100644 tests/unit/models/identifiers/test_evaluation_identifier.py diff --git a/doc/code/scenarios/1_common_scenario_parameters.ipynb b/doc/code/scenarios/1_common_scenario_parameters.ipynb index 880fdbc8bb..9da9b9195b 100644 --- a/doc/code/scenarios/1_common_scenario_parameters.ipynb +++ b/doc/code/scenarios/1_common_scenario_parameters.ipynb @@ -8,8 +8,8 @@ "# Common Scenario Parameters\n", "\n", "This guide covers the key parameters for configuring scenarios programmatically: datasets,\n", - "strategies, baseline execution, and custom scorers. Most examples use `RedTeamAgent` but the\n", - "patterns apply to any scenario — including [`RapidResponse`](#rapid-response-scenario).\n", + "strategies, baseline execution, and custom scorers. All examples use `RedTeamAgent` but the\n", + "patterns apply to any scenario.\n", "\n", "> **Two selection axes**: *Strategies* select attack techniques (*how* attacks run — e.g., prompt\n", "> sending, role play, TAP). *Datasets* select objectives (*what* is tested — e.g., harm categories,\n", @@ -713,48 +713,6 @@ "custom_result = await custom_scenario.run_async() # type: ignore\n", "await output_scenario_async(custom_result)" ] - }, - { - "cell_type": "markdown", - "id": "12d23cc4", - "metadata": {}, - "source": [ - "## Rapid Response Scenario\n", - "\n", - "`RapidResponse` is the content-harms testing scenario. It tests model behavior across multiple\n", - "harm categories (hate, fairness, violence, sexual, harassment, misinformation, leakage) using\n", - "selectable attack techniques (PromptSending, RolePlay, ManyShot, TAP). Unlike `RedTeamAgent`,\n", - "its results are grouped by **harm category** rather than by attack technique.\n", - "\n", - "The same parameter patterns from above — datasets, strategies, baseline, and custom scorers —\n", - "all work with `RapidResponse`." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cb751c92", - "metadata": {}, - "outputs": [], - "source": [ - "from pyrit.scenario.scenarios.airt import RapidResponse, RapidResponseStrategy\n", - "\n", - "rapid_response = RapidResponse()\n", - "\n", - "# Use a focused dataset config — test only hate and fairness categories with 2 objectives each\n", - "rapid_dataset_config = DatasetConfiguration(\n", - " dataset_names=[\"airt_hate\", \"airt_fairness\"],\n", - " max_dataset_size=2,\n", - ")\n", - "\n", - "await rapid_response.initialize_async( # type: ignore\n", - " objective_target=objective_target,\n", - " scenario_strategies=[RapidResponseStrategy.prompt_sending],\n", - " dataset_config=rapid_dataset_config,\n", - ")\n", - "rapid_result = await rapid_response.run_async() # type: ignore\n", - "await output_scenario_async(rapid_result)" - ] } ], "metadata": { diff --git a/doc/code/scenarios/1_common_scenario_parameters.py b/doc/code/scenarios/1_common_scenario_parameters.py index 82611f3cad..230e02fff3 100644 --- a/doc/code/scenarios/1_common_scenario_parameters.py +++ b/doc/code/scenarios/1_common_scenario_parameters.py @@ -5,15 +5,15 @@ # extension: .py # format_name: percent # format_version: '1.3' -# jupytext_version: 1.19.3 +# jupytext_version: 1.18.1 # --- # %% [markdown] # # Common Scenario Parameters # # This guide covers the key parameters for configuring scenarios programmatically: datasets, -# strategies, baseline execution, and custom scorers. Most examples use `RedTeamAgent` but the -# patterns apply to any scenario — including [`RapidResponse`](#rapid-response-scenario). +# strategies, baseline execution, and custom scorers. All examples use `RedTeamAgent` but the +# patterns apply to any scenario. # # > **Two selection axes**: *Strategies* select attack techniques (*how* attacks run — e.g., prompt # > sending, role play, TAP). *Datasets* select objectives (*what* is tested — e.g., harm categories, @@ -172,33 +172,3 @@ ) custom_result = await custom_scenario.run_async() # type: ignore await output_scenario_async(custom_result) - -# %% [markdown] -# ## Rapid Response Scenario -# -# `RapidResponse` is the content-harms testing scenario. It tests model behavior across multiple -# harm categories (hate, fairness, violence, sexual, harassment, misinformation, leakage) using -# selectable attack techniques (PromptSending, RolePlay, ManyShot, TAP). Unlike `RedTeamAgent`, -# its results are grouped by **harm category** rather than by attack technique. -# -# The same parameter patterns from above — datasets, strategies, baseline, and custom scorers — -# all work with `RapidResponse`. - -# %% -from pyrit.scenario.scenarios.airt import RapidResponse, RapidResponseStrategy - -rapid_response = RapidResponse() - -# Use a focused dataset config — test only hate and fairness categories with 2 objectives each -rapid_dataset_config = DatasetConfiguration( - dataset_names=["airt_hate", "airt_fairness"], - max_dataset_size=2, -) - -await rapid_response.initialize_async( # type: ignore - objective_target=objective_target, - scenario_strategies=[RapidResponseStrategy.prompt_sending], - dataset_config=rapid_dataset_config, -) -rapid_result = await rapid_response.run_async() # type: ignore -await output_scenario_async(rapid_result) diff --git a/pyrit/executor/attack/multi_turn/red_teaming.py b/pyrit/executor/attack/multi_turn/red_teaming.py index 0c0fa1e4be..73f2c44a53 100644 --- a/pyrit/executor/attack/multi_turn/red_teaming.py +++ b/pyrit/executor/attack/multi_turn/red_teaming.py @@ -26,7 +26,6 @@ MultiTurnAttackContext, MultiTurnAttackStrategy, ) -from pyrit.identifiers import build_atomic_attack_identifier from pyrit.memory import CentralMemory from pyrit.models import ( AttackOutcome, @@ -36,6 +35,7 @@ Message, Score, SeedPrompt, + build_atomic_attack_identifier, ) from pyrit.prompt_normalizer import PromptNormalizer from pyrit.prompt_target import CapabilityName diff --git a/pyrit/memory/memory_interface.py b/pyrit/memory/memory_interface.py index 185ef3ad8e..99a369c6b6 100644 --- a/pyrit/memory/memory_interface.py +++ b/pyrit/memory/memory_interface.py @@ -23,7 +23,6 @@ from pyrit.common.deprecation import print_deprecation_message from pyrit.common.path import DB_DATA_PATH -from pyrit.identifiers.identifier_filters import IdentifierFilter, IdentifierType from pyrit.memory.memory_exporter import MemoryExporter from pyrit.memory.memory_models import ( AttackResultEntry, @@ -38,6 +37,8 @@ AttackResult, ConversationStats, DataTypeSerializer, + IdentifierFilter, + IdentifierType, Message, MessagePiece, ScenarioResult, @@ -683,6 +684,14 @@ def _get_scenario_result_label_condition(self, *, labels: dict[str, str]) -> Any def add_scores_to_memory(self, *, scores: Sequence[Score]) -> None: """ Insert a list of scores into the memory storage. + + Callers that produce scores for pieces flagged via + ``MessagePiece.not_in_memory = True`` should null out + ``message_piece_id`` on those scores before calling this method so the + score itself can still be persisted without a dangling piece linkage. + Persisting the score even without a piece is intentional: aggregate + analytics (e.g. refusal rate over a batch) still want the score row + even when the scored content was never a real conversation turn. """ for score in scores: if score.message_piece_id: @@ -693,7 +702,7 @@ def add_scores_to_memory(self, *, scores: Sequence[Score]) -> None: continue # auto-link score to the original prompt id if the prompt is a duplicate if pieces[0].original_prompt_id != pieces[0].id: - score.message_piece_id = pieces[0].original_prompt_id + score.message_piece_id = pieces[0].original_prompt_id # type: ignore[ty:invalid-assignment] self._insert_entries(entries=[ScoreEntry(entry=score) for score in scores]) def get_scores( @@ -1591,6 +1600,11 @@ def export_conversations( Returns: Path: The path to the exported file. """ + print_deprecation_message( + old_item="MemoryInterface.export_conversations", + new_item="the pyrit.output module or direct serialization of get_message_pieces results", + removed_in="0.15.0", + ) data = self.get_message_pieces( attack_id=attack_id, conversation_id=conversation_id, diff --git a/pyrit/models/identifiers/__init__.py b/pyrit/models/identifiers/__init__.py new file mode 100644 index 0000000000..30fa701f1d --- /dev/null +++ b/pyrit/models/identifiers/__init__.py @@ -0,0 +1,51 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""Identifiers module for PyRIT components.""" + +from pyrit.models.identifiers.atomic_attack_identifier import ( + build_atomic_attack_identifier, + build_seed_identifier, +) +from pyrit.models.identifiers.class_name_utils import ( + REGISTRY_NAME_PATTERN, + class_name_to_snake_case, + snake_case_to_class_name, + validate_registry_name, +) +from pyrit.models.identifiers.component_identifier import ComponentIdentifier, Identifiable, config_hash +from pyrit.models.identifiers.evaluation_identifier import ( + TARGET_EVAL_PARAM_FALLBACKS, + TARGET_EVAL_PARAMS, + AtomicAttackEvaluationIdentifier, + ChildEvalRule, + EvaluationIdentifier, + ObjectiveTargetEvaluationIdentifier, + ScorerEvaluationIdentifier, + compute_eval_hash, + compute_inner_attack_eval_hash, +) +from pyrit.models.identifiers.identifier_filters import IdentifierFilter, IdentifierType + +__all__ = [ + "AtomicAttackEvaluationIdentifier", + "build_atomic_attack_identifier", + "build_seed_identifier", + "ChildEvalRule", + "class_name_to_snake_case", + "ComponentIdentifier", + "compute_eval_hash", + "compute_inner_attack_eval_hash", + "EvaluationIdentifier", + "Identifiable", + "ObjectiveTargetEvaluationIdentifier", + "REGISTRY_NAME_PATTERN", + "ScorerEvaluationIdentifier", + "snake_case_to_class_name", + "TARGET_EVAL_PARAM_FALLBACKS", + "TARGET_EVAL_PARAMS", + "validate_registry_name", + "config_hash", + "IdentifierFilter", + "IdentifierType", +] diff --git a/pyrit/models/identifiers/evaluation_identifier.py b/pyrit/models/identifiers/evaluation_identifier.py new file mode 100644 index 0000000000..f4c256af3a --- /dev/null +++ b/pyrit/models/identifiers/evaluation_identifier.py @@ -0,0 +1,383 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +Evaluation identity and eval-hash computation. + +This module provides: + +* ``ChildEvalRule`` — per-child configuration for eval-hash filtering. +* ``_build_eval_dict`` — builds a filtered dict for eval-hash computation. +* ``compute_eval_hash`` — free function that computes a behavioral equivalence + hash from a ``ComponentIdentifier``. +* ``EvaluationIdentifier`` — abstract base that wraps a ``ComponentIdentifier`` + with domain-specific eval-hash configuration. Concrete subclasses declare + per-child rules via ``CHILD_EVAL_RULES`` and (optionally) a root-level + ``OWN_RULE`` for leaf entities whose own params need filtering. +* ``ScorerEvaluationIdentifier`` — scorer-domain concrete subclass. +* ``AtomicAttackEvaluationIdentifier`` — attack-domain concrete subclass. +* ``ObjectiveTargetEvaluationIdentifier`` — leaf-target subclass used by the + analytics layer to key cached results by behavioral target configuration. +""" + +from __future__ import annotations + +from abc import ABC +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, ClassVar, Optional + +from pyrit.models.identifiers.component_identifier import ComponentIdentifier, config_hash + +if TYPE_CHECKING: + from pyrit.executor.attack.core.attack_strategy import AttackStrategy + +# Behavioral params that define model output quality for scoring. +TARGET_EVAL_PARAMS: frozenset[str] = frozenset({"underlying_model_name", "temperature", "top_p"}) +TARGET_EVAL_PARAM_FALLBACKS: dict[str, str] = {"underlying_model_name": "model_name"} + + +@dataclass(frozen=True) +class ChildEvalRule: + """ + Per-child configuration for eval-hash computation. + + Controls how a specific named child is treated when building the + evaluation hash: + + * ``exclude`` — if ``True``, drop this child entirely from the hash. + * ``included_params`` — if set, only include these param keys for this + child (and its recursive descendants). ``None`` means all params. + * ``included_item_values`` — for list-valued children, only include items + whose ``params`` match **all** specified key-value pairs. ``None`` + means include all items. + * ``param_fallbacks`` — maps a primary param key to a fallback key. + When the primary key's value is falsy (empty string, ``None``, or + missing), the fallback key's value from the component's raw params + is used instead. This keeps fallback logic in the eval layer without + changing full component hashes. ``None`` means no fallbacks. + * ``inner_child_name`` — if set, names the sub-child to "look through" + when the child being processed is a wrapper component (e.g., + ``RoundRobinTarget``). The first item of that sub-child list is + substituted before applying param filtering, so the eval hash + matches the unwrapped inner target. ``None`` means no unwrapping. + """ + + exclude: bool = False + included_params: Optional[frozenset[str]] = None + included_item_values: Optional[dict[str, Any]] = field(default=None) + param_fallbacks: Optional[dict[str, str]] = field(default=None) + inner_child_name: Optional[str] = field(default=None) + + +def _build_eval_dict( + identifier: ComponentIdentifier, + *, + child_eval_rules: dict[str, ChildEvalRule], + _included_params: Optional[frozenset[str]] = None, + _param_fallbacks: Optional[dict[str, str]] = None, +) -> dict[str, Any]: + """ + Build a filtered dictionary for eval-hash computation. + + Walks the ``ComponentIdentifier`` tree and applies per-child rules from + ``child_eval_rules``. Children not listed in the rules receive full + recursive treatment (no filtering). + + Args: + identifier (ComponentIdentifier): The component identity to process. + child_eval_rules (dict[str, ChildEvalRule]): Per-child eval rules. + Keys are child names; values describe how each child is filtered. + _included_params (Optional[frozenset[str]]): Internal. If set, only + include params whose keys are in this frozenset. Passed down from + a parent rule's ``included_params``. + _param_fallbacks (Optional[dict[str, str]]): Internal. Maps a primary + param key to a fallback key. When the primary value is falsy, + the fallback key's value from raw params is used instead. + Passed down from a parent rule's ``param_fallbacks``. + + Returns: + dict[str, Any]: The filtered dictionary suitable for hashing. + """ + eval_dict: dict[str, Any] = { + ComponentIdentifier.KEY_CLASS_NAME: identifier.class_name, + ComponentIdentifier.KEY_CLASS_MODULE: identifier.class_module, + } + + eval_dict.update( + { + key: value + for key, value in sorted(identifier.params.items()) + if value is not None and (_included_params is None or key in _included_params) + } + ) + + # Apply fallbacks: when a primary param is missing or empty string, + # substitute with the fallback key's value from the raw params. + if _param_fallbacks: + for primary_key, fallback_key in _param_fallbacks.items(): + primary_value = eval_dict.get(primary_key) + if primary_value is None or primary_value == "": + fallback_value = identifier.params.get(fallback_key) + if fallback_value is not None and fallback_value != "": + eval_dict[primary_key] = fallback_value + + if identifier.children: + eval_children: dict[str, Any] = {} + for name in sorted(identifier.children): + rule = child_eval_rules.get(name) + + if rule and rule.exclude: + continue + + child_list = identifier.get_child_list(name) + + # Inner child lookup: if the rule names a sub-child (e.g., "targets"), + # substitute the first item of that sub-child list. This lets wrapper + # components (e.g., RoundRobinTarget) be "seen through". + if rule and rule.inner_child_name: + unwrapped: list[ComponentIdentifier] = [] + for c in child_list: + inner = c.get_child_list(rule.inner_child_name) + if inner: + unwrapped.append(inner[0]) + else: + unwrapped.append(c) + child_list = unwrapped + + # Filter list items by param-value match (e.g., only is_general_technique=True seeds) + if rule and rule.included_item_values: + required = rule.included_item_values + child_list = [c for c in child_list if all(c.params.get(k) == v for k, v in required.items())] + + # For children with a rule, apply included_params and param_fallbacks; + # otherwise None → all params kept, no fallbacks. + child_included_params = rule.included_params if rule else None + child_param_fallbacks = rule.param_fallbacks if rule else None + hashes = [ + config_hash( + _build_eval_dict( + c, + child_eval_rules=child_eval_rules, + _included_params=child_included_params, + _param_fallbacks=child_param_fallbacks, + ) + ) + for c in child_list + ] + eval_children[name] = hashes[0] if len(hashes) == 1 else hashes + if eval_children: + eval_dict["children"] = eval_children + + return eval_dict + + +def compute_eval_hash( + identifier: ComponentIdentifier, + *, + child_eval_rules: dict[str, ChildEvalRule], + own_rule: Optional[ChildEvalRule] = None, +) -> str: + """ + Compute a behavioral equivalence hash for evaluation grouping. + + Unlike ``ComponentIdentifier.hash`` (which includes all params of self and + children), the eval hash applies per-child rules to strip operational params + (like endpoint, max_requests_per_minute), exclude children entirely, or + filter list items. ``own_rule`` extends this to the root entity itself, + which is required for leaf components (e.g., a target) whose own params + need filtering and which have no relevant children to delegate to. This + ensures the same logical configuration on different deployments produces + the same eval hash. + + Children not listed in ``child_eval_rules`` receive full recursive treatment. + + When both ``child_eval_rules`` is empty and ``own_rule`` is ``None``, no + filtering occurs and the result equals ``identifier.hash``. + + Args: + identifier (ComponentIdentifier): The component identity to compute + the hash for. + child_eval_rules (dict[str, ChildEvalRule]): Per-child eval rules. + own_rule (Optional[ChildEvalRule]): Rule applied to the root entity's + own params and fallbacks. Only ``included_params`` and + ``param_fallbacks`` are honored; ``exclude``, ``included_item_values``, + and ``inner_child_name`` are not meaningful at the root and will + raise ``ValueError`` if set. Defaults to None. + + Returns: + str: A hex-encoded SHA256 hash suitable for eval registry keying. + + Raises: + RuntimeError: If the identifier's hash is None and no filtering is configured. + ValueError: If ``own_rule`` carries fields that are not meaningful at the root. + """ + if own_rule is not None: + if own_rule.exclude: + raise ValueError("own_rule.exclude is not meaningful at the root entity") + if own_rule.included_item_values is not None: + raise ValueError("own_rule.included_item_values is not meaningful at the root entity") + if own_rule.inner_child_name is not None: + raise ValueError("own_rule.inner_child_name is not meaningful at the root entity") + + if not child_eval_rules and own_rule is None: + if identifier.hash is None: + raise RuntimeError("hash should be set by __post_init__") + return identifier.hash + + eval_dict = _build_eval_dict( + identifier, + child_eval_rules=child_eval_rules, + _included_params=own_rule.included_params if own_rule else None, + _param_fallbacks=own_rule.param_fallbacks if own_rule else None, + ) + return config_hash(eval_dict) + + +class EvaluationIdentifier(ABC): + """ + Wraps a ``ComponentIdentifier`` with domain-specific eval-hash configuration. + + Subclasses set ``CHILD_EVAL_RULES`` — a mapping of child names to + ``ChildEvalRule`` instances that control how each child is treated during + eval-hash computation. Children not listed receive full recursive treatment. + + Leaf-entity subclasses (no relevant children to delegate to) may also set + ``OWN_RULE`` to filter the root entity's own params. See + ``ObjectiveTargetEvaluationIdentifier`` for an example. + + The concrete ``eval_hash`` property delegates to the module-level + ``compute_eval_hash`` free function. + """ + + CHILD_EVAL_RULES: ClassVar[dict[str, ChildEvalRule]] + OWN_RULE: ClassVar[Optional[ChildEvalRule]] = None + + def __init__(self, identifier: ComponentIdentifier) -> None: + """ + Wrap a ComponentIdentifier and resolve its eval hash. + + If the identifier carries an ``eval_hash`` (preserved from a prior + DB round-trip or set by the scorer), that value is used directly. + Otherwise the eval hash is computed from the identifier's params + and children using the subclass's ``CHILD_EVAL_RULES`` and + ``OWN_RULE``. + """ + self._identifier = identifier + if identifier.eval_hash is not None: + self._eval_hash = identifier.eval_hash + else: + self._eval_hash = compute_eval_hash( + identifier, + child_eval_rules=self.CHILD_EVAL_RULES, + own_rule=self.OWN_RULE, + ) + + @property + def identifier(self) -> ComponentIdentifier: + """The underlying component identity.""" + return self._identifier + + @property + def eval_hash(self) -> str: + """Behavioral equivalence hash for evaluation grouping.""" + return self._eval_hash + + +class ScorerEvaluationIdentifier(EvaluationIdentifier): + """ + Evaluation identity for scorers. + + The ``prompt_target`` child is filtered to behavioral params only + (``underlying_model_name``, ``temperature``, ``top_p``), so the same scorer + configuration on different deployments produces the same eval hash. + """ + + CHILD_EVAL_RULES: ClassVar[dict[str, ChildEvalRule]] = { + "prompt_target": ChildEvalRule( + included_params=TARGET_EVAL_PARAMS, + param_fallbacks=TARGET_EVAL_PARAM_FALLBACKS, + inner_child_name="targets", + ), + } + + +class AtomicAttackEvaluationIdentifier(EvaluationIdentifier): + """ + Evaluation identity for atomic attacks. + + Per-child rules: + + * ``seed_identifiers`` — excluded entirely (present for traceability only). + * ``attack_technique`` — not listed, so fully included by default. + Its nested children (``objective_target``, ``adversarial_chat``, + ``objective_scorer``, ``technique_seeds``) are processed recursively + using the same rules dict, so the rules below apply at any depth. + * ``objective_target`` — include only ``temperature``. + * ``adversarial_chat`` — include ``underlying_model_name``, ``temperature``, ``top_p``. + * ``objective_scorer`` — excluded entirely. + + Non-target children (e.g., ``request_converters``, ``response_converters``, + ``technique_seeds``) receive full recursive eval treatment. + """ + + CHILD_EVAL_RULES: ClassVar[dict[str, ChildEvalRule]] = { + "objective_target": ChildEvalRule( + included_params=frozenset({"temperature"}), + inner_child_name="targets", + ), + "adversarial_chat": ChildEvalRule( + included_params=TARGET_EVAL_PARAMS, + param_fallbacks=TARGET_EVAL_PARAM_FALLBACKS, + ), + "objective_scorer": ChildEvalRule(exclude=True), + "seed_identifiers": ChildEvalRule(exclude=True), + # attack_technique: not listed in rules — fully included in eval hash. + # technique_seeds (nested inside attack_technique): also not listed — fully included. + } + + +class ObjectiveTargetEvaluationIdentifier(EvaluationIdentifier): + """ + Evaluation identity for an objective target. + + Mirrors how ``ScorerEvaluationIdentifier`` filters its inner + ``prompt_target`` child, except the target itself is the root of this + identifier (it has no children carrying behavioral configuration). The + target's own params are filtered to the behavioral set + (``underlying_model_name``, ``temperature``, ``top_p``) via ``OWN_RULE``, + so the same logical target on different deployments produces the same + eval hash. + + Wrapper targets (e.g., ``RoundRobinTarget``) are not unwrapped — the + caller must pass the inner target's ``ComponentIdentifier`` directly if + behavioral equivalence with the unwrapped form is desired. This mirrors + the constraint on ``OWN_RULE`` (no ``inner_child_name`` at the root). + """ + + CHILD_EVAL_RULES: ClassVar[dict[str, ChildEvalRule]] = {} + OWN_RULE: ClassVar[Optional[ChildEvalRule]] = ChildEvalRule( + included_params=TARGET_EVAL_PARAMS, + param_fallbacks=TARGET_EVAL_PARAM_FALLBACKS, + ) + + +def compute_inner_attack_eval_hash(*, attack: AttackStrategy) -> str: + """ + Predict the eval hash the executor will stamp on persisted child rows + for this attack. + + Mirrors the inner-attack write path so callers can look up historical + results matching the same behavioral configuration *before* any row is + written. Use this rather than reconstructing the recipe inline. + + Args: + attack (AttackStrategy): Inner attack strategy. + + Returns: + str: The eval hash that will appear on persisted child rows. + """ + # Local import avoids a circular dependency inside the identifiers package. + from pyrit.models.identifiers.atomic_attack_identifier import build_atomic_attack_identifier + + composite = build_atomic_attack_identifier(attack_identifier=attack.get_identifier()) + return AtomicAttackEvaluationIdentifier(composite).eval_hash diff --git a/pyrit/models/seeds/seed_attack_group.py b/pyrit/models/seeds/seed_attack_group.py index 029ea17a64..831b243875 100644 --- a/pyrit/models/seeds/seed_attack_group.py +++ b/pyrit/models/seeds/seed_attack_group.py @@ -180,7 +180,11 @@ def with_technique(self, *, technique: SeedAttackTechniqueGroup) -> SeedAttackGr merged_seeds = base + technique_seeds if idx is None else base[:idx] + technique_seeds + base[idx:] # ``self`` and ``technique`` may be shared across multiple ``with_technique`` - # calls. Deepcopy first so the originals are untouched + # calls (e.g. the dispatcher reuses one ``bundle.seed_technique`` instance + # across every objective). Deepcopy first so the per-seed mutation below + # and the fresh group_id assigned by ``SeedAttackGroup.__init__`` only + # touch the returned group, leaving the originals untouched as the + # docstring promises. merged_seeds = [copy.deepcopy(seed) for seed in merged_seeds] # Clear group IDs so the new group assigns a fresh one. diff --git a/pyrit/scenario/core/attack_technique_factory.py b/pyrit/scenario/core/attack_technique_factory.py index d7e1d7d678..3a1b3c6315 100644 --- a/pyrit/scenario/core/attack_technique_factory.py +++ b/pyrit/scenario/core/attack_technique_factory.py @@ -2,11 +2,18 @@ # Licensed under the MIT license. """ -AttackTechniqueFactory — Deferred construction of AttackTechnique instances. - -Captures technique-specific configuration at registration time and produces -fresh, fully-constructed attacks when scenario-specific params (objective target, -scorer) become available. +AttackTechniqueFactory — Self-describing deferred constructor for AttackTechnique instances. + +Captures technique-specific configuration (name, strategy tags, attack class, +attack-class kwargs, optional adversarial chat, optional seed technique) at +construction time. Scenarios produce fresh, fully-constructed attacks by calling +``create()`` with scenario-specific params (objective target, scorer). + +The canonical place to register factories is the +``ScenarioTechniqueInitializer`` in +``pyrit.setup.initializers.components.scenario_techniques``. New initializers +register additional factories by calling +``AttackTechniqueRegistry.register_from_factories(...)``. """ from __future__ import annotations @@ -16,19 +23,29 @@ import sys import typing from enum import Enum +from pathlib import Path from typing import TYPE_CHECKING, Any, Union -from pyrit.identifiers import ComponentIdentifier, Identifiable, build_seed_identifier +from pyrit.common.path import EXECUTOR_SEED_PROMPT_PATH +from pyrit.executor.attack import PromptSendingAttack +from pyrit.executor.attack.core.attack_config import ( + AttackAdversarialConfig, + AttackScoringConfig, +) +from pyrit.models import ( + ComponentIdentifier, + Identifiable, + SeedAttackTechniqueGroup, + SeedSimulatedConversation, + build_seed_identifier, +) +from pyrit.models.seeds.seed_simulated_conversation import NextMessageSystemPromptPaths from pyrit.scenario.core.attack_technique import AttackTechnique +from pyrit.scenario.core.scenario_target_defaults import get_default_adversarial_target if TYPE_CHECKING: from pyrit.executor.attack import AttackStrategy - from pyrit.executor.attack.core.attack_config import ( - AttackAdversarialConfig, - AttackConverterConfig, - AttackScoringConfig, - ) - from pyrit.models import SeedAttackTechniqueGroup + from pyrit.executor.attack.core.attack_config import AttackConverterConfig from pyrit.prompt_target import PromptTarget logger = logging.getLogger(__name__) @@ -44,12 +61,12 @@ class ScorerOverridePolicy(str, Enum): class AttackTechniqueFactory(Identifiable): """ - A factory that produces AttackTechnique instances on demand. + A self-describing factory that produces AttackTechnique instances on demand. - Captures technique-specific configuration (converters, adversarial config, - tree depth, etc.) at registration time. Produces fresh, fully-constructed - attacks by calling the real constructor with the captured params plus - scenario-specific objective_target and scoring config. + Captures technique-specific configuration (name, strategy tags, converters, + adversarial config, tree depth, etc.) at construction time. Produces fresh, + fully-constructed attacks by calling the real constructor with the captured + params plus scenario-specific objective_target and scoring config. Validates kwargs against the attack class constructor signature at construction time, catching typos and incompatible parameter names early. @@ -58,42 +75,179 @@ class AttackTechniqueFactory(Identifiable): def __init__( self, *, + name: str, attack_class: type[AttackStrategy[Any, Any]], + strategy_tags: list[str] | None = None, attack_kwargs: dict[str, Any] | None = None, adversarial_config: AttackAdversarialConfig | None = None, seed_technique: SeedAttackTechniqueGroup | None = None, + uses_adversarial: bool | None = None, scorer_override_policy: ScorerOverridePolicy = ScorerOverridePolicy.WARN, ) -> None: """ Initialize the factory with a technique-specific configuration. Args: + name: Registry name for this technique. This is used as the + scenario strategy name. attack_class: The AttackStrategy subclass to instantiate. + strategy_tags: Tags controlling which ``ScenarioStrategy`` + aggregates include this technique (e.g. ``"single_turn"``, + ``"multi_turn"``, ``"default"``). attack_kwargs: Keyword arguments to pass to the attack constructor. Must not include ``objective_target`` (provided at create time) - or ``attack_adversarial_config`` (use ``adversarial_config`` instead). - adversarial_config: Optional adversarial chat configuration. Stored - separately and injected into the attack at ``create()`` time if - the attack class accepts ``attack_adversarial_config``. Also - exposed via the ``adversarial_chat`` property for seed-technique - execution. - seed_technique: Optional technique seed group to attach to created techniques. - scorer_override_policy: What to do when a scenario's scorer is incompatible - with the attack's ``attack_scoring_config`` type annotation. Defaults to WARN. + or ``attack_adversarial_config`` (use ``adversarial_config`` + instead). + adversarial_config: Pre-built adversarial config. Injected into + the attack at ``create()`` time if the attack class accepts + ``attack_adversarial_config``. To bake in a bare + ``PromptTarget``, wrap it as + ``AttackAdversarialConfig(target=chat)``. + seed_technique: Optional technique seed group attached to created + techniques. + uses_adversarial: Whether this technique drives an adversarial + chat during execution. ``None`` auto-derives from the attack + class constructor signature and seed-technique shape. + Authors can override the derivation explicitly. + scorer_override_policy: What to do when a scenario's scorer is + incompatible with the attack's ``attack_scoring_config`` type + annotation. Defaults to WARN. Raises: TypeError: If any kwarg name is not a valid constructor parameter, or if the attack class constructor uses ``**kwargs``. - ValueError: If ``objective_target`` or ``attack_adversarial_config`` - is included in attack_kwargs. + ValueError: If ``objective_target`` or + ``attack_adversarial_config`` is included in ``attack_kwargs``, + or if ``uses_adversarial=False`` while an adversarial config + is wired. """ + self._name = name self._attack_class = attack_class + self._strategy_tags = list(strategy_tags) if strategy_tags else [] self._attack_kwargs = dict(attack_kwargs) if attack_kwargs else {} self._adversarial_config = adversarial_config self._seed_technique = seed_technique self._scorer_override_policy = scorer_override_policy + self._uses_adversarial = uses_adversarial if uses_adversarial is not None else self._derive_uses_adversarial() + self._validate_kwargs() + self._validate_adversarial_flags() + + @classmethod + def with_simulated_conversation( + cls, + *, + name: str, + attack_class: type[AttackStrategy[Any, Any]] | None = None, + adversarial_chat_system_prompt_path: str | Path | None = None, + next_message_system_prompt_path: str | Path | None = None, + num_turns: int = 3, + strategy_tags: list[str] | None = None, + attack_kwargs: dict[str, Any] | None = None, + adversarial_config: AttackAdversarialConfig | None = None, + uses_adversarial: bool | None = None, + scorer_override_policy: ScorerOverridePolicy = ScorerOverridePolicy.WARN, + ) -> AttackTechniqueFactory: + """ + Alternative constructor that builds a ``SeedSimulatedConversation`` inline. + + Wraps a single ``SeedSimulatedConversation`` in a ``SeedAttackTechniqueGroup`` + and assigns it as ``seed_technique`` so callers don't have to construct + both manually. All other parameters are forwarded to ``__init__``. + + Args: + name: Registry name for this technique. When other defaults are used, + ``name`` also picks the canonical YAML at + ``EXECUTOR_SEED_PROMPT_PATH/red_teaming/{name}.yaml``. + attack_class: The AttackStrategy subclass to instantiate. Defaults to + ``PromptSendingAttack``. + adversarial_chat_system_prompt_path: Path to the YAML file containing + the adversarial chat system prompt for the simulated conversation. + Defaults to ``EXECUTOR_SEED_PROMPT_PATH/red_teaming/{name}.yaml``. + next_message_system_prompt_path: Optional path to the YAML file + containing the system prompt for generating a final user message + after the simulated conversation. Defaults to + ``NextMessageSystemPromptPaths.DIRECT.value``. + num_turns: Number of simulated conversation turns. Defaults to 3. + strategy_tags: Tags controlling which ``ScenarioStrategy`` aggregates + include this technique (e.g. ``"single_turn"``, ``"multi_turn"``, + ``"default"``). Forwarded to the factory constructor. + attack_kwargs: Keyword arguments forwarded to the attack constructor. + Must not include ``objective_target`` (provided at create time) + or ``attack_adversarial_config`` (use ``adversarial_config`` + instead). Forwarded to the factory constructor. + adversarial_config: Pre-built adversarial config injected into the + attack at ``create()`` time if the attack class accepts + ``attack_adversarial_config``. To bake in a bare + ``PromptTarget``, wrap it as + ``AttackAdversarialConfig(target=chat)``. Forwarded to the + factory constructor. + uses_adversarial: Whether this technique drives an adversarial chat + during execution. ``None`` auto-derives from the attack class + constructor signature and seed-technique shape. Forwarded to + the factory constructor. + scorer_override_policy: Policy applied when a scenario's scorer is + incompatible with the attack's ``attack_scoring_config`` type + annotation. Defaults to ``WARN``. Forwarded to the factory + constructor. + + Returns: + AttackTechniqueFactory: A new factory whose ``seed_technique`` is the + wrapped simulated conversation. + """ + if attack_class is None: + attack_class = PromptSendingAttack + if adversarial_chat_system_prompt_path is None: + adversarial_chat_system_prompt_path = Path(EXECUTOR_SEED_PROMPT_PATH) / "red_teaming" / f"{name}.yaml" + if next_message_system_prompt_path is None: + next_message_system_prompt_path = NextMessageSystemPromptPaths.DIRECT.value + + seed_technique = SeedAttackTechniqueGroup( + seeds=[ + SeedSimulatedConversation( + adversarial_chat_system_prompt_path=adversarial_chat_system_prompt_path, + next_message_system_prompt_path=next_message_system_prompt_path, + num_turns=num_turns, + ), + ], + ) + return cls( + name=name, + attack_class=attack_class, + strategy_tags=strategy_tags, + attack_kwargs=attack_kwargs, + adversarial_config=adversarial_config, + seed_technique=seed_technique, + uses_adversarial=uses_adversarial, + scorer_override_policy=scorer_override_policy, + ) + + def _derive_uses_adversarial(self) -> bool: + """ + Auto-derive ``uses_adversarial`` from the attack class signature and seed shape. + + Returns: + bool: ``True`` if the attack class accepts ``attack_adversarial_config`` + or the seed technique has a simulated conversation. + """ + sig = inspect.signature(self._attack_class.__init__) + if "attack_adversarial_config" in sig.parameters: + return True + return self._seed_technique is not None and self._seed_technique.has_simulated_conversation + + def _validate_adversarial_flags(self) -> None: + """ + Validate that ``uses_adversarial`` and ``adversarial_config`` are coherent. + + Raises: + ValueError: If an adversarial config is wired but ``uses_adversarial=False``. + """ + if not self._uses_adversarial and self._adversarial_config is not None: + raise ValueError( + f"Factory '{self._name}': adversarial_config is set but uses_adversarial=False. " + f"A technique that doesn't use an adversarial chat should not have one wired." + ) def _validate_kwargs(self) -> None: """ @@ -112,9 +266,7 @@ def _validate_kwargs(self) -> None: if "objective_target" in self._attack_kwargs: raise ValueError("objective_target must not be in attack_kwargs — it is provided at create() time.") if "attack_adversarial_config" in self._attack_kwargs: - raise ValueError( - "attack_adversarial_config must not be in attack_kwargs — use the adversarial_config parameter instead." - ) + raise ValueError("attack_adversarial_config must not be in attack_kwargs — use adversarial_config instead.") sig = inspect.signature(self._attack_class.__init__) @@ -145,6 +297,21 @@ def _validate_kwargs(self) -> None: f"Valid parameters: {sorted(valid_params)}" ) + @property + def name(self) -> str: + """The registry name for this technique.""" + return self._name + + @property + def strategy_tags(self) -> list[str]: + """Tags controlling which ``ScenarioStrategy`` aggregates include this technique.""" + return list(self._strategy_tags) + + @property + def tags(self) -> list[str]: + """Alias for ``strategy_tags`` exposing the Taggable interface (used by ``TagQuery.filter``).""" + return list(self._strategy_tags) + @property def attack_class(self) -> type[AttackStrategy[Any, Any]]: """The attack strategy class this factory produces.""" @@ -160,6 +327,11 @@ def adversarial_chat(self) -> PromptTarget | None: """The adversarial chat target baked into this factory, or None.""" return self._adversarial_config.target if self._adversarial_config else None + @property + def uses_adversarial(self) -> bool: + """Whether this technique drives an adversarial chat during execution.""" + return self._uses_adversarial + @property def scoring_config_type(self) -> type | None: """The required ``attack_scoring_config`` subtype, or ``None`` if any config is accepted.""" @@ -206,9 +378,21 @@ def create( A fresh AttackTechnique with a newly-constructed attack strategy. Raises: - ValueError: If ``scorer_override_policy`` is RAISE and the override - config is incompatible with the attack's type annotation. + ValueError: If ``attack_adversarial_config_override`` is supplied but + the factory already has an adversarial config baked in at + construction time, or if ``scorer_override_policy`` is RAISE and + the override config is incompatible with the attack's type annotation. """ + if attack_adversarial_config_override is not None and self._adversarial_config is not None: + raise ValueError( + f"Factory '{self._name}': adversarial config was baked in at construction; " + f"cannot supply attack_adversarial_config_override." + ) + + adversarial_config = self._adversarial_config + if self._uses_adversarial and adversarial_config is None and attack_adversarial_config_override is None: + adversarial_config = self._resolve_default_adversarial_config() + kwargs = dict(self._attack_kwargs) kwargs["objective_target"] = objective_target @@ -221,14 +405,24 @@ def create( if "attack_adversarial_config" in accepted_params: if attack_adversarial_config_override is not None: kwargs["attack_adversarial_config"] = attack_adversarial_config_override - elif self._adversarial_config is not None: - kwargs["attack_adversarial_config"] = self._adversarial_config + elif adversarial_config is not None: + kwargs["attack_adversarial_config"] = adversarial_config if attack_converter_config_override is not None and "attack_converter_config" in accepted_params: kwargs["attack_converter_config"] = attack_converter_config_override attack = self._attack_class(**kwargs) return AttackTechnique(attack=attack, seed_technique=self._seed_technique) + @staticmethod + def _resolve_default_adversarial_config() -> AttackAdversarialConfig: + """ + Lazily resolve the default adversarial chat target and wrap it in a config. + + Returns: + AttackAdversarialConfig: Config wrapping the default adversarial chat target. + """ + return AttackAdversarialConfig(target=get_default_adversarial_target()) + def _get_accepted_params(self) -> set[str]: """Return the set of keyword parameter names accepted by the attack class constructor.""" sig = inspect.signature(self._attack_class.__init__) @@ -312,8 +506,6 @@ def _get_scoring_config_type(self) -> type | None: Returns: The narrowed type if the annotation is narrower than the base, else None. """ - from pyrit.executor.attack.core.attack_config import AttackScoringConfig - try: # get_type_hints resolves string annotations from __future__ annotations hints = typing.get_type_hints( @@ -386,19 +578,23 @@ def _build_identifier(self) -> ComponentIdentifier: """ Build the behavioral identity for this factory. - Includes the attack class name and kwargs with their serialized values - so that factories with different configurations produce different hashes. - When a seed technique is present, its seeds are added as - ``children["technique_seeds"]``. + Includes the factory name, attack class, kwargs, adversarial config, + and the adversarial-flag booleans so factories with different + configurations produce different hashes. When a seed technique is + present, its seeds are added as ``children["technique_seeds"]``. Returns: ComponentIdentifier: The frozen identity snapshot. """ kwargs_for_id = {k: self._serialize_value(v) for k, v in sorted(self._attack_kwargs.items())} params: dict[str, Any] = { + "name": self._name, "attack_class": self._attack_class.__name__, "kwargs": kwargs_for_id, + "uses_adversarial": self._uses_adversarial, } + if self._strategy_tags: + params["strategy_tags"] = list(self._strategy_tags) if self._adversarial_config is not None: params["adversarial_config"] = self._serialize_value(self._adversarial_config) diff --git a/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py b/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py index ca065e7b74..5016fcb21d 100644 --- a/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py +++ b/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py @@ -20,7 +20,7 @@ from typing import TYPE_CHECKING, ClassVar from pyrit.executor.attack import AttackScoringConfig -from pyrit.identifiers import compute_inner_attack_eval_hash +from pyrit.models.identifiers import compute_inner_attack_eval_hash from pyrit.scenario.core.atomic_attack import AtomicAttack from pyrit.scenario.core.attack_technique import AttackTechnique from pyrit.scenario.core.scenario import Scenario @@ -32,11 +32,16 @@ EpsilonGreedyTechniqueSelector, TechniqueSelector, ) +from pyrit.setup.initializers.components.scenario_techniques import ( + build_scenario_technique_factories, +) if TYPE_CHECKING: from pyrit.models import SeedAttackGroup from pyrit.prompt_target import PromptTarget from pyrit.scenario.core.attack_technique_factory import AttackTechniqueFactory + from pyrit.scenario.core.dataset_configuration import DatasetConfiguration + from pyrit.scenario.core.scenario_strategy import ScenarioStrategy from pyrit.score import TrueFalseScorer logger = logging.getLogger(__name__) @@ -57,6 +62,21 @@ class AdaptiveScenario(Scenario): #: Prefix for per-objective atomic-attack names (e.g. ``"adaptive_text"``). _atomic_attack_prefix: ClassVar[str] = "adaptive" + @classmethod + def get_strategy_class(cls) -> type[ScenarioStrategy]: + """Return the scenario's strategy enum (subclasses must override).""" + raise NotImplementedError + + @classmethod + def get_default_strategy(cls) -> ScenarioStrategy: + """Return the scenario's default strategy aggregate (subclasses must override).""" + raise NotImplementedError + + @classmethod + def default_dataset_config(cls) -> DatasetConfiguration: + """Return the scenario's default ``DatasetConfiguration`` (subclasses must override).""" + raise NotImplementedError + def __init__( self, *, @@ -82,10 +102,26 @@ def __init__( super().__init__( version=self.VERSION, strategy_class=self.get_strategy_class(), + default_strategy=self.get_default_strategy(), + default_dataset_config=self.default_dataset_config(), objective_scorer=objective_scorer, scenario_result_id=scenario_result_id, ) + def _get_attack_technique_factories(self) -> dict[str, AttackTechniqueFactory]: + """ + Build factories directly from the canonical scenario-techniques catalog. + + Bypasses the global ``AttackTechniqueRegistry`` singleton so adaptive + scenarios resolve their pool from the same source list used by + ``_build_text_adaptive_strategy`` — no implicit dependency on registry + initialization order. Subclasses may override to add or replace factories. + + Returns: + dict[str, AttackTechniqueFactory]: Mapping of technique name to factory. + """ + return {factory.name: factory for factory in build_scenario_technique_factories()} + async def _get_atomic_attacks_async(self) -> list[AtomicAttack]: """ Build one ``AtomicAttack`` per dataset, each carrying every objective diff --git a/pyrit/scenario/scenarios/adaptive/text_adaptive.py b/pyrit/scenario/scenarios/adaptive/text_adaptive.py index 3b00139ee5..2d3ace5661 100644 --- a/pyrit/scenario/scenarios/adaptive/text_adaptive.py +++ b/pyrit/scenario/scenarios/adaptive/text_adaptive.py @@ -18,9 +18,15 @@ from pyrit.common import apply_defaults from pyrit.common.parameter import Parameter +from pyrit.registry.object_registries.attack_technique_registry import ( + AttackTechniqueRegistry, +) from pyrit.registry.tag_query import TagQuery from pyrit.scenario.core.dataset_configuration import DatasetConfiguration from pyrit.scenario.scenarios.adaptive.adaptive_scenario import AdaptiveScenario +from pyrit.setup.initializers.components.scenario_techniques import ( + build_scenario_technique_factories, +) if TYPE_CHECKING: from pyrit.scenario.core.scenario_strategy import ScenarioStrategy @@ -42,16 +48,13 @@ def _build_text_adaptive_strategy() -> type[ScenarioStrategy]: Returns: type[ScenarioStrategy]: The dynamically-built strategy enum class. """ - from pyrit.registry.object_registries.attack_technique_registry import ( - AttackTechniqueRegistry, - ) - from pyrit.scenario.core.scenario_techniques import SCENARIO_TECHNIQUES - - filtered_specs = [spec for spec in SCENARIO_TECHNIQUES if spec.name not in _EXCLUDED_TECHNIQUES] + factories = [ + factory for factory in build_scenario_technique_factories() if factory.name not in _EXCLUDED_TECHNIQUES + ] - return AttackTechniqueRegistry.build_strategy_class_from_specs( # type: ignore[return-value, ty:invalid-return-type] + return AttackTechniqueRegistry.build_strategy_class_from_factories( # type: ignore[return-value, ty:invalid-return-type] class_name="TextAdaptiveStrategy", - specs=filtered_specs, + factories=factories, aggregate_tags={ "default": TagQuery.any_of("default"), "single_turn": TagQuery.any_of("single_turn"), diff --git a/tests/unit/memory/memory_interface/test_interface_attack_results.py b/tests/unit/memory/memory_interface/test_interface_attack_results.py index 6a422d1d81..cdb611e64b 100644 --- a/tests/unit/memory/memory_interface/test_interface_attack_results.py +++ b/tests/unit/memory/memory_interface/test_interface_attack_results.py @@ -8,18 +8,19 @@ import pytest from pyrit.common.utils import to_sha256 -from pyrit.identifiers import ComponentIdentifier -from pyrit.identifiers.atomic_attack_identifier import build_atomic_attack_identifier -from pyrit.identifiers.identifier_filters import IdentifierFilter, IdentifierType from pyrit.memory import MemoryInterface from pyrit.memory.memory_models import AttackResultEntry from pyrit.models import ( AttackOutcome, AttackResult, + ComponentIdentifier, ConversationReference, ConversationType, + IdentifierFilter, + IdentifierType, MessagePiece, Score, + build_atomic_attack_identifier, ) if TYPE_CHECKING: @@ -28,14 +29,17 @@ def create_message_piece(conversation_id: str, prompt_num: int, targeted_harm_categories=None, labels=None): """Helper function to create MessagePiece with optional targeted harm categories and labels.""" - return MessagePiece( - role="user", - original_value=f"Test prompt {prompt_num}", - converted_value=f"Test prompt {prompt_num}", - conversation_id=conversation_id, - targeted_harm_categories=targeted_harm_categories, - labels=labels, - ) + kwargs: dict = { + "role": "user", + "original_value": f"Test prompt {prompt_num}", + "converted_value": f"Test prompt {prompt_num}", + "conversation_id": conversation_id, + } + if targeted_harm_categories is not None: + kwargs["targeted_harm_categories"] = targeted_harm_categories + if labels is not None: + kwargs["labels"] = labels + return MessagePiece(**kwargs) def create_attack_result( @@ -1357,7 +1361,7 @@ def test_get_attack_results_attack_classes_empty_returns_all(sqlite_instance: Me def _eval_hash_for(class_name: str) -> str: - from pyrit.identifiers.evaluation_identifier import AtomicAttackEvaluationIdentifier + from pyrit.models.identifiers.evaluation_identifier import AtomicAttackEvaluationIdentifier return AtomicAttackEvaluationIdentifier( build_atomic_attack_identifier( diff --git a/tests/unit/models/identifiers/test_evaluation_identifier.py b/tests/unit/models/identifiers/test_evaluation_identifier.py new file mode 100644 index 0000000000..e9e0688142 --- /dev/null +++ b/tests/unit/models/identifiers/test_evaluation_identifier.py @@ -0,0 +1,868 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +Tests for pyrit.models.identifiers.evaluation_identifier. + +Covers the ``EvaluationIdentifier`` abstract base class, the ``_build_eval_dict`` +helper, and the ``compute_eval_hash`` free function. +""" + +from typing import ClassVar + +import pytest + +from pyrit.models.identifiers import ComponentIdentifier, compute_eval_hash +from pyrit.models.identifiers.evaluation_identifier import ChildEvalRule, EvaluationIdentifier, _build_eval_dict + +# --------------------------------------------------------------------------- +# Concrete subclass for testing the ABC +# --------------------------------------------------------------------------- + + +class _StubEvaluationIdentifier(EvaluationIdentifier): + """Minimal concrete subclass for testing the abstract base class.""" + + CHILD_EVAL_RULES: ClassVar[dict[str, ChildEvalRule]] = { + "my_target": ChildEvalRule(included_params=frozenset({"model_name"})), + } + + +# --------------------------------------------------------------------------- +# Test constants +# --------------------------------------------------------------------------- + +_CHILD_EVAL_RULES: dict[str, ChildEvalRule] = { + "prompt_target": ChildEvalRule( + included_params=frozenset({"model_name", "temperature", "top_p"}), + ), +} + + +class TestBuildEvalDict: + """Tests for _build_eval_dict filtering logic.""" + + def test_target_child_params_filtered(self): + """Test that target children only keep behavioral params.""" + child = ComponentIdentifier( + class_name="Target", + class_module="pyrit.target", + params={"model_name": "gpt-4", "endpoint": "https://example.com"}, + ) + identifier = ComponentIdentifier( + class_name="Scorer", + class_module="pyrit.score", + children={"prompt_target": child}, + ) + + result = _build_eval_dict( + identifier, + child_eval_rules=_CHILD_EVAL_RULES, + ) + + # "endpoint" must not appear anywhere in the child sub-dict + assert "endpoint" not in str(result) + assert "children" in result + + def test_non_target_child_params_kept(self): + """Test that non-target children keep all params (full recursive treatment).""" + child = ComponentIdentifier( + class_name="SubScorer", + class_module="pyrit.score", + params={"threshold": 0.5, "extra": "value"}, + ) + identifier = ComponentIdentifier( + class_name="Scorer", + class_module="pyrit.score", + children={"sub_scorer": child}, + ) + + result = _build_eval_dict( + identifier, + child_eval_rules=_CHILD_EVAL_RULES, + ) + + assert "children" in result + + def test_no_children_produces_flat_dict(self): + """Test that an identifier with no children produces a dict without 'children' key.""" + identifier = ComponentIdentifier( + class_name="Scorer", + class_module="pyrit.score", + params={"threshold": 0.5}, + ) + + result = _build_eval_dict( + identifier, + child_eval_rules=_CHILD_EVAL_RULES, + ) + + assert "children" not in result + assert result[ComponentIdentifier.KEY_CLASS_NAME] == "Scorer" + + +class TestComputeEvalHash: + """Tests for the compute_eval_hash free function.""" + + def test_deterministic(self): + """Test that the same identifier + config produces the same hash.""" + identifier = ComponentIdentifier(class_name="Scorer", class_module="pyrit.score") + h1 = compute_eval_hash(identifier, child_eval_rules=_CHILD_EVAL_RULES) + h2 = compute_eval_hash(identifier, child_eval_rules=_CHILD_EVAL_RULES) + assert h1 == h2 + + def test_empty_rules_returns_component_hash(self): + """Test that empty child_eval_rules bypasses filtering and returns component hash.""" + child = ComponentIdentifier( + class_name="Target", + class_module="pyrit.target", + params={"model_name": "gpt-4", "endpoint": "https://example.com"}, + ) + identifier = ComponentIdentifier( + class_name="Scorer", + class_module="pyrit.score", + children={"prompt_target": child}, + ) + + result = compute_eval_hash( + identifier, + child_eval_rules={}, + ) + assert result == identifier.hash + + def test_returns_64_char_hex(self): + """Test that the hash is a 64-char lowercase hex string (SHA-256).""" + identifier = ComponentIdentifier(class_name="S", class_module="m") + result = compute_eval_hash(identifier, child_eval_rules=_CHILD_EVAL_RULES) + assert len(result) == 64 + assert all(c in "0123456789abcdef" for c in result) + + +class TestEvaluationIdentifier: + """Tests for the EvaluationIdentifier abstract base class.""" + + def test_identifier_property_returns_original(self): + """Test that .identifier returns the ComponentIdentifier passed at construction.""" + cid = ComponentIdentifier(class_name="Scorer", class_module="pyrit.score") + identity = _StubEvaluationIdentifier(cid) + assert identity.identifier is cid + + def test_eval_hash_is_string(self): + """Test that .eval_hash is a valid hex string.""" + cid = ComponentIdentifier(class_name="Scorer", class_module="pyrit.score") + identity = _StubEvaluationIdentifier(cid) + assert isinstance(identity.eval_hash, str) + assert len(identity.eval_hash) == 64 + + def test_eval_hash_matches_free_function(self): + """Test that .eval_hash matches calling compute_eval_hash directly.""" + cid = ComponentIdentifier( + class_name="Scorer", + class_module="pyrit.score", + params={"threshold": 0.5}, + ) + identity = _StubEvaluationIdentifier(cid) + + expected = compute_eval_hash( + cid, + child_eval_rules=_StubEvaluationIdentifier.CHILD_EVAL_RULES, + ) + assert identity.eval_hash == expected + + def test_eval_hash_differs_from_component_hash_when_target_filtered(self): + """Test that eval hash differs from component hash when target children have operational params.""" + child = ComponentIdentifier( + class_name="Target", + class_module="pyrit.target", + params={"model_name": "gpt-4", "endpoint": "https://example.com"}, + ) + cid = ComponentIdentifier( + class_name="Scorer", + class_module="pyrit.score", + children={"my_target": child}, + ) + identity = _StubEvaluationIdentifier(cid) + + # "endpoint" is operational, so eval hash should differ from full component hash + assert identity.eval_hash != cid.hash + + def test_cannot_instantiate_abc_directly(self): + """Test that EvaluationIdentifier cannot be instantiated without ClassVars.""" + with pytest.raises(AttributeError): + EvaluationIdentifier(ComponentIdentifier(class_name="X", class_module="m")) # type: ignore[abstract] + + def test_custom_classvars_produce_expected_hash(self): + """Test that a concrete subclass with custom ClassVars produces the correct eval hash.""" + + class CustomIdentity(EvaluationIdentifier): + CHILD_EVAL_RULES: ClassVar[dict[str, ChildEvalRule]] = { + "special_target": ChildEvalRule( + included_params=frozenset({"model_name", "temperature"}), + ), + } + + child = ComponentIdentifier( + class_name="Target", + class_module="pyrit.target", + params={"model_name": "gpt-4", "temperature": 0.7, "endpoint": "https://example.com"}, + ) + cid = ComponentIdentifier( + class_name="Scorer", + class_module="pyrit.score", + children={"special_target": child}, + ) + identity = CustomIdentity(cid) + + expected = compute_eval_hash( + cid, + child_eval_rules={ + "special_target": ChildEvalRule( + included_params=frozenset({"model_name", "temperature"}), + ), + }, + ) + assert identity.eval_hash == expected + + def test_uses_eval_hash_when_available(self): + """Test that EvaluationIdentifier uses eval_hash instead of recomputing.""" + stored_hash = "stored_eval_hash_value_" + "0" * 42 # 64 chars + cid = ComponentIdentifier( + class_name="Scorer", + class_module="pyrit.score", + params={"system_prompt": "truncated..."}, + ).with_eval_hash(stored_hash) + + identity = _StubEvaluationIdentifier(cid) + assert identity.eval_hash == stored_hash + + def test_computes_eval_hash_when_not_set(self): + """Test that eval_hash is computed normally when eval_hash is None.""" + cid = ComponentIdentifier( + class_name="Scorer", + class_module="pyrit.score", + params={"threshold": 0.5}, + ) + assert cid.eval_hash is None + + identity = _StubEvaluationIdentifier(cid) + expected = compute_eval_hash(cid, child_eval_rules=_StubEvaluationIdentifier.CHILD_EVAL_RULES) + assert identity.eval_hash == expected + + def test_truncation_roundtrip_preserves_eval_hash(self): + """Regression test: eval_hash survives DB round-trip with param truncation. + + This is the core scenario for the bug fix. A scorer with a long system_prompt + gets stored to the DB with truncation. The eval_hash computed from the untruncated + identifier is included in to_dict(). After from_dict() reconstruction, the + EvaluationIdentifier should use the stored eval_hash (not recompute from truncated params). + """ + # Build a scorer identifier with a long system_prompt and a target child + long_prompt = "Evaluate whether the response achieves the objective. " * 10 + target_child = ComponentIdentifier( + class_name="OpenAIChatTarget", + class_module="pyrit.prompt_target", + params={"model_name": "gpt-4o", "endpoint": "https://api.openai.com", "temperature": 0.0}, + ) + scorer_id = ComponentIdentifier( + class_name="SelfAskTrueFalseScorer", + class_module="pyrit.score", + params={"system_prompt_template": long_prompt}, + children={"prompt_target": target_child}, + ) + + # Compute eval_hash from the untruncated identifier (the correct hash) + correct_eval_hash = compute_eval_hash(scorer_id, child_eval_rules=_CHILD_EVAL_RULES) + scorer_id = scorer_id.with_eval_hash(correct_eval_hash) + + # Simulate DB storage: serialize with truncation + truncated_dict = scorer_id.to_dict(max_value_length=80) + + # Verify params are actually truncated + assert truncated_dict["system_prompt_template"].endswith("...") + + # Reconstruct from truncated dict (simulates DB read) + reconstructed = ComponentIdentifier.from_dict(truncated_dict) + + # The reconstructed identifier has truncated params, so recomputing would give wrong hash + recomputed = compute_eval_hash(reconstructed, child_eval_rules=_CHILD_EVAL_RULES) + assert recomputed != correct_eval_hash, "Truncated params should produce different eval_hash" + + # But EvaluationIdentifier uses the preserved eval_hash, giving the correct result + identity = _StubEvaluationIdentifier(reconstructed) + assert identity.eval_hash == correct_eval_hash + + def test_eval_hash_preserved_through_double_roundtrip(self): + """Test that eval_hash is preserved when retrieved from DB and re-stored. + + Simulates: fresh save → DB retrieve → re-store → DB retrieve. + The eval_hash computed at first save should survive all round-trips. + """ + long_prompt = "Evaluate whether the response achieves the objective. " * 10 + scorer_id = ComponentIdentifier( + class_name="SelfAskTrueFalseScorer", + class_module="pyrit.score", + params={"system_prompt_template": long_prompt}, + ) + + # First save: compute eval_hash from untruncated identifier + correct_eval_hash = compute_eval_hash(scorer_id, child_eval_rules=_CHILD_EVAL_RULES) + scorer_id = scorer_id.with_eval_hash(correct_eval_hash) + d1 = scorer_id.to_dict(max_value_length=80) + + # First retrieve + r1 = ComponentIdentifier.from_dict(d1) + assert _StubEvaluationIdentifier(r1).eval_hash == correct_eval_hash + + # Re-store: EvaluationIdentifier should use stored value, not recompute + d2 = r1.to_dict(max_value_length=80) + + # Second retrieve + r2 = ComponentIdentifier.from_dict(d2) + assert _StubEvaluationIdentifier(r2).eval_hash == correct_eval_hash + + +class TestParamFallbacks: + """Tests for ChildEvalRule.param_fallbacks in _build_eval_dict.""" + + _RULES_WITH_FALLBACK: dict[str, ChildEvalRule] = { + "prompt_target": ChildEvalRule( + included_params=frozenset({"underlying_model_name", "temperature"}), + param_fallbacks={"underlying_model_name": "model_name"}, + ), + } + + def test_primary_param_used_when_present(self): + """Test that the primary param value is used when it is non-empty.""" + child = ComponentIdentifier( + class_name="Target", + class_module="pyrit.target", + params={"underlying_model_name": "gpt-4o", "model_name": "deploy-1", "temperature": 0.7}, + ) + identifier = ComponentIdentifier( + class_name="Scorer", + class_module="pyrit.score", + children={"prompt_target": child}, + ) + + result = _build_eval_dict(identifier, child_eval_rules=self._RULES_WITH_FALLBACK) + # The child hash should be based on underlying_model_name="gpt-4o", not model_name + assert "children" in result + + def test_fallback_used_when_primary_empty(self): + """Test that fallback param used when primary is empty string.""" + child_with_underlying = ComponentIdentifier( + class_name="Target", + class_module="pyrit.target", + params={"underlying_model_name": "gpt-4o", "model_name": "deploy-1", "temperature": 0.7}, + ) + child_with_fallback = ComponentIdentifier( + class_name="Target", + class_module="pyrit.target", + params={"underlying_model_name": "", "model_name": "gpt-4o", "temperature": 0.7}, + ) + id1 = ComponentIdentifier( + class_name="Scorer", + class_module="pyrit.score", + children={"prompt_target": child_with_underlying}, + ) + id2 = ComponentIdentifier( + class_name="Scorer", + class_module="pyrit.score", + children={"prompt_target": child_with_fallback}, + ) + + result1 = _build_eval_dict(id1, child_eval_rules=self._RULES_WITH_FALLBACK) + result2 = _build_eval_dict(id2, child_eval_rules=self._RULES_WITH_FALLBACK) + + assert result1["children"]["prompt_target"] == result2["children"]["prompt_target"] + + def test_fallback_used_when_primary_missing(self): + """Test that fallback param used when primary key is absent.""" + child_with_underlying = ComponentIdentifier( + class_name="Target", + class_module="pyrit.target", + params={"underlying_model_name": "gpt-4o", "temperature": 0.7}, + ) + child_with_model_name_only = ComponentIdentifier( + class_name="Target", + class_module="pyrit.target", + params={"model_name": "gpt-4o", "temperature": 0.7}, + ) + id1 = ComponentIdentifier( + class_name="Scorer", + class_module="pyrit.score", + children={"prompt_target": child_with_underlying}, + ) + id2 = ComponentIdentifier( + class_name="Scorer", + class_module="pyrit.score", + children={"prompt_target": child_with_model_name_only}, + ) + + result1 = _build_eval_dict(id1, child_eval_rules=self._RULES_WITH_FALLBACK) + result2 = _build_eval_dict(id2, child_eval_rules=self._RULES_WITH_FALLBACK) + + assert result1["children"]["prompt_target"] == result2["children"]["prompt_target"] + + def test_no_fallback_when_no_rules(self): + """Test that param_fallbacks=None means no fallback applied.""" + rules_without_fallback: dict[str, ChildEvalRule] = { + "prompt_target": ChildEvalRule( + included_params=frozenset({"underlying_model_name", "temperature"}), + ), + } + child_with = ComponentIdentifier( + class_name="Target", + class_module="pyrit.target", + params={"underlying_model_name": "gpt-4o", "temperature": 0.7}, + ) + child_without = ComponentIdentifier( + class_name="Target", + class_module="pyrit.target", + params={"model_name": "gpt-4o", "temperature": 0.7}, + ) + id1 = ComponentIdentifier( + class_name="Scorer", + class_module="pyrit.score", + children={"prompt_target": child_with}, + ) + id2 = ComponentIdentifier( + class_name="Scorer", + class_module="pyrit.score", + children={"prompt_target": child_without}, + ) + + result1 = _build_eval_dict(id1, child_eval_rules=rules_without_fallback) + result2 = _build_eval_dict(id2, child_eval_rules=rules_without_fallback) + + # Without fallback, these should produce different hashes + assert result1["children"]["prompt_target"] != result2["children"]["prompt_target"] + + +def test_compute_eval_hash_raises_when_hash_none_and_no_rules(): + identifier = ComponentIdentifier.__new__(ComponentIdentifier) + object.__setattr__(identifier, "hash", None) + object.__setattr__(identifier, "class_name", "Test") + object.__setattr__(identifier, "class_module", "test.module") + with pytest.raises(RuntimeError, match="hash should be set by __post_init__"): + compute_eval_hash(identifier, child_eval_rules={}) + + +# --------------------------------------------------------------------------- +# inner_child_name tests +# --------------------------------------------------------------------------- + + +class TestInnerChildName: + """Tests for the inner_child_name feature in ChildEvalRule.""" + + def test_unwrap_substitutes_first_inner_child(self): + """When the child has a sub-child matching inner_child_name, the unwrapped eval hash + matches a direct (non-wrapped) target with the same behavioral params.""" + inner_target_east = ComponentIdentifier( + class_name="OpenAIChatTarget", + class_module="pyrit.prompt_target.openai.openai_chat_target", + params={"underlying_model_name": "gpt-4o", "temperature": 0.7, "endpoint": "https://east.example.com"}, + ) + inner_target_west = ComponentIdentifier( + class_name="OpenAIChatTarget", + class_module="pyrit.prompt_target.openai.openai_chat_target", + params={"underlying_model_name": "gpt-4o", "temperature": 0.7, "endpoint": "https://west.example.com"}, + ) + wrapper = ComponentIdentifier( + class_name="RoundRobinTarget", + class_module="pyrit.prompt_target.round_robin_target", + params={"weights": [1, 1]}, + children={"targets": [inner_target_east, inner_target_west]}, + ) + scorer_wrapped = ComponentIdentifier( + class_name="Scorer", + class_module="pyrit.score", + children={"prompt_target": wrapper}, + ) + scorer_direct = ComponentIdentifier( + class_name="Scorer", + class_module="pyrit.score", + children={"prompt_target": inner_target_east}, + ) + + rules = { + "prompt_target": ChildEvalRule( + included_params=frozenset({"underlying_model_name", "temperature"}), + inner_child_name="targets", + ), + } + + result_wrapped = _build_eval_dict(scorer_wrapped, child_eval_rules=rules) + result_direct = _build_eval_dict(scorer_direct, child_eval_rules=rules) + + # Unwrapped hash should match the direct target (same behavioral params) + assert result_wrapped["children"]["prompt_target"] == result_direct["children"]["prompt_target"] + + def test_unwrap_no_op_when_child_has_no_matching_subchild(self): + """When the child doesn't have the named sub-child, use the child as-is.""" + regular_target = ComponentIdentifier( + class_name="OpenAIChatTarget", + class_module="pyrit.prompt_target.openai.openai_chat_target", + params={"underlying_model_name": "gpt-4o", "temperature": 0.7}, + ) + scorer = ComponentIdentifier( + class_name="Scorer", + class_module="pyrit.score", + children={"prompt_target": regular_target}, + ) + + rules = { + "prompt_target": ChildEvalRule( + included_params=frozenset({"underlying_model_name", "temperature"}), + inner_child_name="targets", # OpenAIChatTarget has no "targets" child + ), + } + + result = _build_eval_dict(scorer, child_eval_rules=rules) + # Should still work — uses OpenAIChatTarget directly + assert "children" in result + + # Compare with rules without inner_child_name — should be identical + rules_no_inner = { + "prompt_target": ChildEvalRule( + included_params=frozenset({"underlying_model_name", "temperature"}), + ), + } + result_no_inner = _build_eval_dict(scorer, child_eval_rules=rules_no_inner) + assert result == result_no_inner + + def test_scorer_eval_hash_matches_with_and_without_round_robin(self): + """ScorerEvaluationIdentifier produces the same eval_hash whether + the scorer uses a direct target or a RoundRobinTarget wrapping it.""" + from pyrit.models.identifiers.evaluation_identifier import ScorerEvaluationIdentifier + + inner_target = ComponentIdentifier( + class_name="OpenAIChatTarget", + class_module="pyrit.prompt_target.openai.openai_chat_target", + params={ + "underlying_model_name": "gpt-4o", + "temperature": 0.7, + "top_p": 1.0, + "endpoint": "https://east.example.com", + "model_name": "gpt4o-east", + }, + ) + inner_target_west = ComponentIdentifier( + class_name="OpenAIChatTarget", + class_module="pyrit.prompt_target.openai.openai_chat_target", + params={ + "underlying_model_name": "gpt-4o", + "temperature": 0.7, + "top_p": 1.0, + "endpoint": "https://west.example.com", + "model_name": "gpt4o-west", + }, + ) + + wrapper = ComponentIdentifier( + class_name="RoundRobinTarget", + class_module="pyrit.prompt_target.round_robin_target", + params={"weights": [1, 1]}, + children={"targets": [inner_target, inner_target_west]}, + ) + + scorer_direct = ComponentIdentifier( + class_name="SelfAskScaleScorer", + class_module="pyrit.score.self_ask_scale_scorer", + params={"scorer_type": "float_scale"}, + children={"prompt_target": inner_target}, + ) + scorer_rr = ComponentIdentifier( + class_name="SelfAskScaleScorer", + class_module="pyrit.score.self_ask_scale_scorer", + params={"scorer_type": "float_scale"}, + children={"prompt_target": wrapper}, + ) + + eval_direct = ScorerEvaluationIdentifier(scorer_direct).eval_hash + eval_rr = ScorerEvaluationIdentifier(scorer_rr).eval_hash + + assert eval_direct == eval_rr + + +# --------------------------------------------------------------------------- +# OWN_RULE / leaf-entity eval-hash tests +# --------------------------------------------------------------------------- + + +class TestOwnRule: + """Tests for compute_eval_hash(own_rule=...) — leaf-entity filtering.""" + + def test_own_rule_filters_root_params(self): + """own_rule.included_params is applied to the root entity's params.""" + target = ComponentIdentifier( + class_name="OpenAIChatTarget", + class_module="pyrit.prompt_target.openai.openai_chat_target", + params={ + "underlying_model_name": "gpt-4o", + "temperature": 0.7, + "top_p": 1.0, + "endpoint": "https://east.example.com", + }, + ) + rule = ChildEvalRule( + included_params=frozenset({"underlying_model_name", "temperature", "top_p"}), + ) + + eval_hash = compute_eval_hash(target, child_eval_rules={}, own_rule=rule) + + # Same target body without endpoint should produce the same hash. + target_no_endpoint = ComponentIdentifier( + class_name="OpenAIChatTarget", + class_module="pyrit.prompt_target.openai.openai_chat_target", + params={ + "underlying_model_name": "gpt-4o", + "temperature": 0.7, + "top_p": 1.0, + }, + ) + eval_hash_no_endpoint = compute_eval_hash(target_no_endpoint, child_eval_rules={}, own_rule=rule) + assert eval_hash == eval_hash_no_endpoint + + def test_own_rule_applies_param_fallbacks_at_root(self): + """When the primary param is missing at the root, the fallback is substituted.""" + target_primary = ComponentIdentifier( + class_name="OpenAIChatTarget", + class_module="pyrit.prompt_target", + params={"underlying_model_name": "gpt-4o", "temperature": 0.7}, + ) + target_fallback = ComponentIdentifier( + class_name="OpenAIChatTarget", + class_module="pyrit.prompt_target", + params={"model_name": "gpt-4o", "temperature": 0.7}, + ) + rule = ChildEvalRule( + included_params=frozenset({"underlying_model_name", "temperature"}), + param_fallbacks={"underlying_model_name": "model_name"}, + ) + + hash_primary = compute_eval_hash(target_primary, child_eval_rules={}, own_rule=rule) + hash_fallback = compute_eval_hash(target_fallback, child_eval_rules={}, own_rule=rule) + assert hash_primary == hash_fallback + + def test_own_rule_raises_on_exclude(self): + """own_rule.exclude has no meaning at the root.""" + rule = ChildEvalRule(exclude=True) + target = ComponentIdentifier(class_name="T", class_module="m") + with pytest.raises(ValueError, match="exclude"): + compute_eval_hash(target, child_eval_rules={}, own_rule=rule) + + def test_own_rule_raises_on_included_item_values(self): + """own_rule.included_item_values is only meaningful for list children.""" + rule = ChildEvalRule(included_item_values={"is_general_technique": True}) + target = ComponentIdentifier(class_name="T", class_module="m") + with pytest.raises(ValueError, match="included_item_values"): + compute_eval_hash(target, child_eval_rules={}, own_rule=rule) + + def test_own_rule_raises_on_inner_child_name(self): + """own_rule.inner_child_name is only meaningful for child rules.""" + rule = ChildEvalRule(inner_child_name="targets") + target = ComponentIdentifier(class_name="T", class_module="m") + with pytest.raises(ValueError, match="inner_child_name"): + compute_eval_hash(target, child_eval_rules={}, own_rule=rule) + + def test_short_circuit_only_when_both_empty(self): + """With own_rule set, the short-circuit MUST NOT return identifier.hash.""" + target = ComponentIdentifier( + class_name="OpenAIChatTarget", + class_module="pyrit.prompt_target", + params={"underlying_model_name": "gpt-4o", "endpoint": "https://east.example.com"}, + ) + rule = ChildEvalRule(included_params=frozenset({"underlying_model_name"})) + eval_hash = compute_eval_hash(target, child_eval_rules={}, own_rule=rule) + # The full identifier hash includes the endpoint; eval_hash must not. + assert eval_hash != target.hash + + +class TestEvaluationIdentifierOwnRule: + """Tests for the EvaluationIdentifier.OWN_RULE ClassVar.""" + + def test_own_rule_defaults_to_none(self): + """Subclasses that do not declare OWN_RULE inherit None.""" + assert _StubEvaluationIdentifier.OWN_RULE is None + + def test_subclass_with_own_rule_filters_root(self): + """A subclass that sets OWN_RULE filters root params at eval time.""" + + class TargetIdentity(EvaluationIdentifier): + CHILD_EVAL_RULES: ClassVar[dict[str, ChildEvalRule]] = {} + OWN_RULE: ClassVar = ChildEvalRule( + included_params=frozenset({"underlying_model_name"}), + ) + + target = ComponentIdentifier( + class_name="OpenAIChatTarget", + class_module="pyrit.prompt_target", + params={"underlying_model_name": "gpt-4o", "endpoint": "https://east.example.com"}, + ) + identity = TargetIdentity(target) + # Eval hash should not equal the raw identifier hash (endpoint must be stripped). + assert identity.eval_hash != target.hash + + +# --------------------------------------------------------------------------- +# ObjectiveTargetEvaluationIdentifier tests +# --------------------------------------------------------------------------- + + +class TestObjectiveTargetEvaluationIdentifier: + """Tests for the ObjectiveTargetEvaluationIdentifier concrete subclass.""" + + def test_different_endpoints_same_eval_hash(self): + """Same model name + temperature + top_p on different endpoints → same eval hash.""" + from pyrit.models.identifiers.evaluation_identifier import ObjectiveTargetEvaluationIdentifier + + target_east = ComponentIdentifier( + class_name="OpenAIChatTarget", + class_module="pyrit.prompt_target.openai.openai_chat_target", + params={ + "underlying_model_name": "gpt-4o", + "temperature": 0.7, + "top_p": 1.0, + "endpoint": "https://east.example.com", + "model_name": "gpt4o-east", + }, + ) + target_west = ComponentIdentifier( + class_name="OpenAIChatTarget", + class_module="pyrit.prompt_target.openai.openai_chat_target", + params={ + "underlying_model_name": "gpt-4o", + "temperature": 0.7, + "top_p": 1.0, + "endpoint": "https://west.example.com", + "model_name": "gpt4o-west", + }, + ) + + eval_east = ObjectiveTargetEvaluationIdentifier(target_east).eval_hash + eval_west = ObjectiveTargetEvaluationIdentifier(target_west).eval_hash + assert eval_east == eval_west + + def test_different_temperature_different_eval_hash(self): + """Behavioral params (temperature) DO contribute to the eval hash.""" + from pyrit.models.identifiers.evaluation_identifier import ObjectiveTargetEvaluationIdentifier + + target_cold = ComponentIdentifier( + class_name="OpenAIChatTarget", + class_module="pyrit.prompt_target", + params={"underlying_model_name": "gpt-4o", "temperature": 0.0}, + ) + target_hot = ComponentIdentifier( + class_name="OpenAIChatTarget", + class_module="pyrit.prompt_target", + params={"underlying_model_name": "gpt-4o", "temperature": 1.0}, + ) + + eval_cold = ObjectiveTargetEvaluationIdentifier(target_cold).eval_hash + eval_hot = ObjectiveTargetEvaluationIdentifier(target_hot).eval_hash + assert eval_cold != eval_hot + + def test_model_name_fallback_to_model_name(self): + """When underlying_model_name is missing, model_name is used as fallback.""" + from pyrit.models.identifiers.evaluation_identifier import ObjectiveTargetEvaluationIdentifier + + target_underlying = ComponentIdentifier( + class_name="OpenAIChatTarget", + class_module="pyrit.prompt_target", + params={"underlying_model_name": "gpt-4o", "temperature": 0.7}, + ) + target_only_model_name = ComponentIdentifier( + class_name="OpenAIChatTarget", + class_module="pyrit.prompt_target", + params={"model_name": "gpt-4o", "temperature": 0.7}, + ) + + eval_a = ObjectiveTargetEvaluationIdentifier(target_underlying).eval_hash + eval_b = ObjectiveTargetEvaluationIdentifier(target_only_model_name).eval_hash + assert eval_a == eval_b + + def test_stored_eval_hash_takes_precedence(self): + """A pre-stamped eval_hash is honored (DB round-trip safety).""" + from pyrit.models.identifiers.evaluation_identifier import ObjectiveTargetEvaluationIdentifier + + stored = "objective_target_stored_hash" + "0" * 36 + cid = ComponentIdentifier( + class_name="OpenAIChatTarget", + class_module="pyrit.prompt_target", + params={"underlying_model_name": "gpt-4o"}, + ).with_eval_hash(stored) + + assert ObjectiveTargetEvaluationIdentifier(cid).eval_hash == stored + + +class TestComputeInnerAttackEvalHash: + """``compute_inner_attack_eval_hash`` should match what the executor stamps.""" + + def _attack_with_identifier(self, identifier: ComponentIdentifier): + from unittest.mock import MagicMock + + attack = MagicMock() + attack.get_identifier.return_value = identifier + return attack + + def test_matches_manual_two_step_composition(self): + """Helper equals the executor recipe (build_atomic_attack_identifier + AtomicAttackEvaluationIdentifier).""" + from pyrit.models.identifiers import ( + AtomicAttackEvaluationIdentifier, + build_atomic_attack_identifier, + compute_inner_attack_eval_hash, + ) + + inner_id = ComponentIdentifier( + class_name="PromptSendingAttack", + class_module="pyrit.executor.attack.single_turn.prompt_sending", + ) + attack = self._attack_with_identifier(inner_id) + + expected = AtomicAttackEvaluationIdentifier( + build_atomic_attack_identifier(attack_identifier=inner_id), + ).eval_hash + assert compute_inner_attack_eval_hash(attack=attack) == expected + + def test_differs_when_attack_class_differs(self): + from pyrit.models.identifiers import compute_inner_attack_eval_hash + + a = self._attack_with_identifier( + ComponentIdentifier(class_name="A", class_module="m"), + ) + b = self._attack_with_identifier( + ComponentIdentifier(class_name="B", class_module="m"), + ) + assert compute_inner_attack_eval_hash(attack=a) != compute_inner_attack_eval_hash(attack=b) + + def test_stable_across_calls_for_same_attack(self): + from pyrit.models.identifiers import compute_inner_attack_eval_hash + + attack = self._attack_with_identifier( + ComponentIdentifier(class_name="Same", class_module="m"), + ) + assert compute_inner_attack_eval_hash(attack=attack) == compute_inner_attack_eval_hash(attack=attack) + + def test_matches_persisted_row_eval_hash(self): + """Whatever the helper returns, persisting an attack result with the same + identifier must yield an entry with the same eval_hash.""" + from pyrit.memory.memory_models import AttackResultEntry + from pyrit.models import AttackResult + from pyrit.models.identifiers import build_atomic_attack_identifier, compute_inner_attack_eval_hash + + inner_id = ComponentIdentifier( + class_name="MyAttack", + class_module="pyrit.attacks", + ) + attack = self._attack_with_identifier(inner_id) + predicted = compute_inner_attack_eval_hash(attack=attack) + + result = AttackResult( + conversation_id="conv_1", + objective="o", + atomic_attack_identifier=build_atomic_attack_identifier(attack_identifier=inner_id), + ) + entry = AttackResultEntry(entry=result) + assert entry.atomic_attack_identifier["eval_hash"] == predicted diff --git a/tests/unit/models/test_import_boundary.py b/tests/unit/models/test_import_boundary.py index 5ceda8552f..2b2b1f40d5 100644 --- a/tests/unit/models/test_import_boundary.py +++ b/tests/unit/models/test_import_boundary.py @@ -45,13 +45,6 @@ # violations not in this list fail the test; entries that no longer match # source also fail. KNOWN_TOP_LEVEL_VIOLATIONS: dict[str, dict[str, str]] = { - "pyrit.models.attack_result": { - "pyrit.identifiers.atomic_attack_identifier": "phase-2", - "pyrit.identifiers.component_identifier": "phase-2", - }, - "pyrit.models.message_piece": { - "pyrit.identifiers.component_identifier": "phase-2", - }, "pyrit.models.message": { "pyrit.common.utils": "phase-4", }, @@ -91,15 +84,13 @@ "pyrit.memory": "phase-8", "pyrit.common.path": "phase-8", }, + "pyrit.models.identifiers.evaluation_identifier": { + "pyrit.executor.attack.core.attack_strategy": "phase-7", + }, "pyrit.models.scenario_result": { - "pyrit.identifiers.component_identifier": "phase-2-and-7", - "pyrit.identifiers.evaluation_identifier": "phase-2-and-7", "pyrit.score.scorer_evaluation.scorer_metrics": "phase-7", "pyrit.score.scorer_evaluation.scorer_metrics_io": "phase-7", }, - "pyrit.models.score": { - "pyrit.identifiers.component_identifier": "phase-5-and-2", - }, "pyrit.models.storage_io": { "pyrit.auth": "phase-8", }, diff --git a/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py b/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py index a0cc19158a..eb9d6ff826 100644 --- a/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py +++ b/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py @@ -9,8 +9,8 @@ import pytest -from pyrit.identifiers import ComponentIdentifier from pyrit.models import SeedAttackGroup, SeedObjective +from pyrit.models.identifiers import ComponentIdentifier from pyrit.prompt_target import PromptTarget from pyrit.registry.object_registries.attack_technique_registry import AttackTechniqueRegistry from pyrit.scenario.core.dataset_configuration import DatasetConfiguration From 77a3704eee9441a633b87b98fa10d63128a58224 Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Mon, 1 Jun 2026 19:14:54 -0400 Subject: [PATCH 24/35] FIX: Resolve CI failures from upstream merge with PR #1881 (Pydantic identifiers) Four related fixes needed to unblock CI after the upstream merge that brought in PR #1881 (Refactoring Identifiers to be Pydantic classes): 1. `pyrit/models/identifiers/evaluation_identifier.py`: The merge hoisted `from pyrit.executor.attack.core.attack_strategy import AttackStrategy` to module level, forming a cycle through `pyrit.executor.attack` -> `pyrit.message_normalizer` -> `pyrit.common.data_url_converter` -> `pyrit.models`. Move it back inside `if TYPE_CHECKING:` (`from __future__ import annotations` is already enabled, so the string annotation `attack: AttackStrategy` still resolves at type-check time). 2. `tests/unit/models/identifiers/test_evaluation_identifier.py`: Add missing blank lines between top-level classes (ruff format) and replace four `from pyrit.identifiers import ...` lines with `from pyrit.models.identifiers import ...` (the former is now a deprecation shim that the static-scan deprecation test forbids internal callers from using). 3. `pyrit/scenario/scenarios/adaptive/adaptive_scenario.py`: Mark the three classmethod stubs as `@abstractmethod` so `inspect.isabstract(AdaptiveScenario)` returns `True` and the scenario registry's auto-discovery skips it (otherwise the registry tries to instantiate the abstract base and raises `NotImplementedError`, breaking `test_load_default_datasets`). 4. `pyrit/scenario/scenarios/adaptive/adaptive_scenario.py` and `pyrit/scenario/scenarios/adaptive/text_adaptive.py`: Move `from pyrit.setup.initializers.components.scenario_techniques import build_scenario_technique_factories` from module level back into the function body. `scenario_techniques` imports `pyrit.scenario.core`, which transitively re-imports the adaptive package during `pyrit.scenario` initialization, so a top-level import forms a cycle. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/models/identifiers/evaluation_identifier.py | 6 ++++-- .../scenarios/adaptive/adaptive_scenario.py | 14 +++++++++++--- pyrit/scenario/scenarios/adaptive/text_adaptive.py | 10 +++++++--- .../identifiers/test_evaluation_identifier.py | 12 ++++++++---- 4 files changed, 30 insertions(+), 12 deletions(-) diff --git a/pyrit/models/identifiers/evaluation_identifier.py b/pyrit/models/identifiers/evaluation_identifier.py index 8527fabadb..b2fc1b996d 100644 --- a/pyrit/models/identifiers/evaluation_identifier.py +++ b/pyrit/models/identifiers/evaluation_identifier.py @@ -23,13 +23,15 @@ from __future__ import annotations from abc import ABC -from typing import Any, ClassVar, Optional +from typing import TYPE_CHECKING, Any, ClassVar, Optional from pydantic import BaseModel, ConfigDict, Field -from pyrit.executor.attack.core.attack_strategy import AttackStrategy from pyrit.models.identifiers.component_identifier import ComponentIdentifier, config_hash +if TYPE_CHECKING: + from pyrit.executor.attack.core.attack_strategy import AttackStrategy + # Behavioral params that define model output quality for scoring. TARGET_EVAL_PARAMS: frozenset[str] = frozenset({"underlying_model_name", "temperature", "top_p"}) TARGET_EVAL_PARAM_FALLBACKS: dict[str, str] = {"underlying_model_name": "model_name"} diff --git a/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py b/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py index 5016fcb21d..e7e3e32cda 100644 --- a/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py +++ b/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py @@ -17,6 +17,7 @@ from __future__ import annotations import logging +from abc import abstractmethod from typing import TYPE_CHECKING, ClassVar from pyrit.executor.attack import AttackScoringConfig @@ -32,9 +33,6 @@ EpsilonGreedyTechniqueSelector, TechniqueSelector, ) -from pyrit.setup.initializers.components.scenario_techniques import ( - build_scenario_technique_factories, -) if TYPE_CHECKING: from pyrit.models import SeedAttackGroup @@ -63,16 +61,19 @@ class AdaptiveScenario(Scenario): _atomic_attack_prefix: ClassVar[str] = "adaptive" @classmethod + @abstractmethod def get_strategy_class(cls) -> type[ScenarioStrategy]: """Return the scenario's strategy enum (subclasses must override).""" raise NotImplementedError @classmethod + @abstractmethod def get_default_strategy(cls) -> ScenarioStrategy: """Return the scenario's default strategy aggregate (subclasses must override).""" raise NotImplementedError @classmethod + @abstractmethod def default_dataset_config(cls) -> DatasetConfiguration: """Return the scenario's default ``DatasetConfiguration`` (subclasses must override).""" raise NotImplementedError @@ -120,6 +121,13 @@ def _get_attack_technique_factories(self) -> dict[str, AttackTechniqueFactory]: Returns: dict[str, AttackTechniqueFactory]: Mapping of technique name to factory. """ + # Local import: ``scenario_techniques`` imports ``pyrit.scenario.core``, + # which transitively re-imports this module, so a top-level import + # would form a cycle during ``pyrit.scenario`` package initialization. + from pyrit.setup.initializers.components.scenario_techniques import ( + build_scenario_technique_factories, + ) + return {factory.name: factory for factory in build_scenario_technique_factories()} async def _get_atomic_attacks_async(self) -> list[AtomicAttack]: diff --git a/pyrit/scenario/scenarios/adaptive/text_adaptive.py b/pyrit/scenario/scenarios/adaptive/text_adaptive.py index 2d3ace5661..6fbddf26f7 100644 --- a/pyrit/scenario/scenarios/adaptive/text_adaptive.py +++ b/pyrit/scenario/scenarios/adaptive/text_adaptive.py @@ -24,9 +24,6 @@ from pyrit.registry.tag_query import TagQuery from pyrit.scenario.core.dataset_configuration import DatasetConfiguration from pyrit.scenario.scenarios.adaptive.adaptive_scenario import AdaptiveScenario -from pyrit.setup.initializers.components.scenario_techniques import ( - build_scenario_technique_factories, -) if TYPE_CHECKING: from pyrit.scenario.core.scenario_strategy import ScenarioStrategy @@ -48,6 +45,13 @@ def _build_text_adaptive_strategy() -> type[ScenarioStrategy]: Returns: type[ScenarioStrategy]: The dynamically-built strategy enum class. """ + # Local import: ``scenario_techniques`` imports ``pyrit.scenario.core``, + # which transitively re-imports this module, so a top-level import would + # form a cycle during ``pyrit.scenario`` package initialization. + from pyrit.setup.initializers.components.scenario_techniques import ( + build_scenario_technique_factories, + ) + factories = [ factory for factory in build_scenario_technique_factories() if factory.name not in _EXCLUDED_TECHNIQUES ] diff --git a/tests/unit/models/identifiers/test_evaluation_identifier.py b/tests/unit/models/identifiers/test_evaluation_identifier.py index 5125b4c76f..d43b488f65 100644 --- a/tests/unit/models/identifiers/test_evaluation_identifier.py +++ b/tests/unit/models/identifiers/test_evaluation_identifier.py @@ -585,6 +585,7 @@ def test_scorer_eval_hash_matches_with_and_without_round_robin(self): assert eval_direct == eval_rr + class TestComputeInnerAttackEvalHash: """``compute_inner_attack_eval_hash`` should match what the executor stamps.""" @@ -597,7 +598,7 @@ def _attack_with_identifier(self, identifier: ComponentIdentifier): def test_matches_manual_two_step_composition(self): """Helper equals the executor recipe (build_atomic_attack_identifier + AtomicAttackEvaluationIdentifier).""" - from pyrit.identifiers import ( + from pyrit.models.identifiers import ( AtomicAttackEvaluationIdentifier, build_atomic_attack_identifier, compute_inner_attack_eval_hash, @@ -615,7 +616,7 @@ def test_matches_manual_two_step_composition(self): assert compute_inner_attack_eval_hash(attack=attack) == expected def test_differs_when_attack_class_differs(self): - from pyrit.identifiers import compute_inner_attack_eval_hash + from pyrit.models.identifiers import compute_inner_attack_eval_hash a = self._attack_with_identifier( ComponentIdentifier(class_name="A", class_module="m"), @@ -626,7 +627,7 @@ def test_differs_when_attack_class_differs(self): assert compute_inner_attack_eval_hash(attack=a) != compute_inner_attack_eval_hash(attack=b) def test_stable_across_calls_for_same_attack(self): - from pyrit.identifiers import compute_inner_attack_eval_hash + from pyrit.models.identifiers import compute_inner_attack_eval_hash attack = self._attack_with_identifier( ComponentIdentifier(class_name="Same", class_module="m"), @@ -636,9 +637,9 @@ def test_stable_across_calls_for_same_attack(self): def test_matches_persisted_row_eval_hash(self): """Whatever the helper returns, persisting an attack result with the same identifier must yield an entry with the same eval_hash.""" - from pyrit.identifiers import build_atomic_attack_identifier, compute_inner_attack_eval_hash from pyrit.memory.memory_models import AttackResultEntry from pyrit.models import AttackResult + from pyrit.models.identifiers import build_atomic_attack_identifier, compute_inner_attack_eval_hash inner_id = ComponentIdentifier( class_name="MyAttack", @@ -654,6 +655,8 @@ def test_matches_persisted_row_eval_hash(self): ) entry = AttackResultEntry(entry=result) assert entry.atomic_attack_identifier["eval_hash"] == predicted + + # --------------------------------------------------------------------------- # OWN_RULE / leaf-entity eval-hash tests # --------------------------------------------------------------------------- @@ -864,6 +867,7 @@ def test_stored_eval_hash_takes_precedence(self): assert ObjectiveTargetEvaluationIdentifier(cid).eval_hash == stored + class TestComputeInnerAttackEvalHash: """``compute_inner_attack_eval_hash`` should match what the executor stamps.""" From 0055d258cbe7c08d808530d5344723374e123028 Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Tue, 2 Jun 2026 12:50:15 -0400 Subject: [PATCH 25/35] refactor(adaptive): convert dispatcher to factory that returns a plain SequentialAttack Replaces the previous AdaptiveDispatchAttack (an AttackStrategy subclass that delegated to an internal SequentialAttack) with a slim factory + subclass: - AdaptiveTechniqueDispatcher: plain class, not an AttackStrategy. Exposes compatible_techniques(seed_group=...) and async build_attack_async(seed_group=...). Selects techniques up-front per objective via TechniqueSelector and returns a fully-wired AdaptiveSequentialAttack. - AdaptiveSequentialAttack: ~15-LoC SequentialAttack subclass. Adds a technique_labels constructor argument and stamps adaptive_attempts metadata onto the envelope returned by super()._perform_async, then delegates everything else to the framework. Atomic-attack shape: - One AtomicAttack per (dataset, seed_group) instead of one per dataset. - atomic_attack_name = `{prefix}_{dataset}::{objective_sha[:12]}` so each objective gets its own deterministic, hash-disambiguated identifier. - display_group = dataset_name preserves the grouping for reporting. - All per-dataset dispatchers share the same TechniqueSelector instance so learning still accumulates globally. Rationale: eliminates the dispatcher's coupling to SequentialAttack's private lifecycle internals (no more manual `_perform_async` orchestration, no more duplicating envelope/metadata wiring) and removes a layer of indirection that was making the call graph hard to reason about. The dispatcher is now an `envelope factory'', not an attack. All 56 adaptive unit tests pass (verified via docker devcontainer pytest run). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- doc/code/scenarios/3_adaptive_scenarios.ipynb | 79 ++-- doc/code/scenarios/3_adaptive_scenarios.py | 79 ++-- pyrit/scenario/scenarios/adaptive/__init__.py | 10 +- .../scenarios/adaptive/adaptive_scenario.py | 118 ++--- .../scenario/scenarios/adaptive/dispatcher.py | 389 +++++------------ .../scenarios/adaptive/test_dispatcher.py | 406 ++++-------------- .../scenarios/adaptive/test_text_adaptive.py | 110 ++--- 7 files changed, 427 insertions(+), 764 deletions(-) diff --git a/doc/code/scenarios/3_adaptive_scenarios.ipynb b/doc/code/scenarios/3_adaptive_scenarios.ipynb index e0a675ba3e..a924b8481d 100644 --- a/doc/code/scenarios/3_adaptive_scenarios.ipynb +++ b/doc/code/scenarios/3_adaptive_scenarios.ipynb @@ -190,15 +190,22 @@ "source": [ "## Inspecting which techniques were tried\n", "\n", - "The dispatcher stamps every objective's `AttackResult.metadata` with:\n", - "\n", - "- `adaptive_attempts` — the ordered list of `{\"technique\", \"outcome\"}` dicts\n", - " recording exactly which techniques the selector picked and what happened.\n", - "\n", - "Walk that metadata to see per-objective trails grouped by dataset, with a\n", - "per-technique success-rate table inside each group and a grand-total at the\n", - "bottom. Use `result.get_display_groups()` to aggregate `attack_results` by\n", - "the per-dataset display label set by the scenario." + "Every adaptive run persists both the per-objective envelope (a\n", + "`SequentialAttackResult`) AND its per-attempt child rows. Each child row\n", + "carries its own `atomic_attack_identifier`, so the persisted data alone is\n", + "enough to reconstruct the per-attempt trail — no envelope-side metadata, no\n", + "scenario-side lookup tables needed.\n", + "\n", + "Walk the children via the envelope's `child_attack_result_ids` (joined\n", + "against the flat results list), then read each child's attack strategy\n", + "identifier with `child.get_attack_strategy_identifier()`. The returned\n", + "`ComponentIdentifier` exposes `class_name` (e.g. `\"CrescendoAttack\"`) and\n", + "`unique_name` (e.g. `\"CrescendoAttack::a1b2c3d4\"`), which uniquely\n", + "distinguishes two factories that wrap the same attack class with different\n", + "configurations.\n", + "\n", + "Use `result.get_display_groups()` to aggregate `attack_results` by the\n", + "per-dataset display label set by the scenario." ] }, { @@ -210,13 +217,23 @@ "source": [ "from collections import Counter\n", "\n", - "# Per-group: one line per objective (the envelope, carrying the technique trail)\n", - "# plus a per-technique success-rate table within the group. Each adaptive run\n", - "# persists both the per-objective envelope AND its per-attempt child rows; the\n", - "# children are filtered out of the per-objective list so it stays one line per\n", - "# objective. Aggregate across groups for a final grand-total table.\n", + "# Per-group: one line per objective (the envelope) showing the per-attempt\n", + "# trail, plus a per-technique success-rate table within the group. The child\n", + "# rows that compose each envelope are filtered out of the per-objective list so\n", + "# it stays one line per objective. Aggregate across groups for a grand-total.\n", "display_groups = resumed_result.get_display_groups()\n", "\n", + "# Flatten every persisted row across every group so we can look up a child\n", + "# AttackResult by its attack_result_id when reconstructing per-envelope trails.\n", + "results_by_id = {r.attack_result_id: r for results in display_groups.values() for r in results}\n", + "\n", + "\n", + "def _technique_label(result) -> str:\n", + " \"\"\"Display name for the attack strategy that produced ``result``.\"\"\"\n", + " attack_id = result.get_attack_strategy_identifier()\n", + " return attack_id.unique_name if attack_id else \"\"\n", + "\n", + "\n", "total_picks: Counter[str] = Counter()\n", "total_wins: Counter[str] = Counter()\n", "\n", @@ -233,28 +250,36 @@ " for r in results:\n", " if r.attack_result_id in child_ids:\n", " continue\n", - " attempts = r.metadata.get(\"adaptive_attempts\", [])\n", - " trail = \" → \".join(f\"{a['technique']}({a['outcome']})\" for a in attempts)\n", + " child_id_list = r.metadata.get(\"child_attack_result_ids\", []) or []\n", + " trail_parts: list[str] = []\n", + " for child_id in child_id_list:\n", + " child = results_by_id.get(child_id)\n", + " if child is None:\n", + " continue\n", + " trail_parts.append(f\"{_technique_label(child)}({child.outcome.value})\")\n", + " trail = \" → \".join(trail_parts)\n", " print(f\" [{r.outcome.value:7s}] {r.objective!r}: {trail}\")\n", "\n", " picks: Counter[str] = Counter()\n", " wins: Counter[str] = Counter()\n", " for r in results:\n", - " for step in r.metadata.get(\"adaptive_attempts\", []):\n", - " picks[step[\"technique\"]] += 1\n", - " total_picks[step[\"technique\"]] += 1\n", - " if step[\"outcome\"] == \"success\":\n", - " wins[step[\"technique\"]] += 1\n", - " total_wins[step[\"technique\"]] += 1\n", - "\n", - " print(\"\\n Technique wins / picks rate\")\n", + " if r.attack_result_id not in child_ids:\n", + " continue\n", + " technique = _technique_label(r)\n", + " picks[technique] += 1\n", + " total_picks[technique] += 1\n", + " if r.outcome.value == \"success\":\n", + " wins[technique] += 1\n", + " total_wins[technique] += 1\n", + "\n", + " print(\"\\n Technique wins / picks rate\")\n", " for technique, n in picks.most_common():\n", - " print(f\" {technique:20s} {wins[technique]:>4} / {n:<4} {wins[technique] / n:.0%}\")\n", + " print(f\" {technique:40s} {wins[technique]:>4} / {n:<4} {wins[technique] / n:.0%}\")\n", "\n", "print(\"\\n=== Overall ===\")\n", - "print(\"Technique wins / picks rate\")\n", + "print(\"Technique wins / picks rate\")\n", "for technique, n in total_picks.most_common():\n", - " print(f\"{technique:20s} {total_wins[technique]:>4} / {n:<4} {total_wins[technique] / n:.0%}\")" + " print(f\"{technique:40s} {total_wins[technique]:>4} / {n:<4} {total_wins[technique] / n:.0%}\")" ] }, { diff --git a/doc/code/scenarios/3_adaptive_scenarios.py b/doc/code/scenarios/3_adaptive_scenarios.py index 0fdd444a9e..2b77baeaa1 100644 --- a/doc/code/scenarios/3_adaptive_scenarios.py +++ b/doc/code/scenarios/3_adaptive_scenarios.py @@ -141,26 +141,43 @@ # %% [markdown] # ## Inspecting which techniques were tried # -# The dispatcher stamps every objective's `AttackResult.metadata` with: -# -# - `adaptive_attempts` — the ordered list of `{"technique", "outcome"}` dicts -# recording exactly which techniques the selector picked and what happened. -# -# Walk that metadata to see per-objective trails grouped by dataset, with a -# per-technique success-rate table inside each group and a grand-total at the -# bottom. Use `result.get_display_groups()` to aggregate `attack_results` by -# the per-dataset display label set by the scenario. +# Every adaptive run persists both the per-objective envelope (a +# `SequentialAttackResult`) AND its per-attempt child rows. Each child row +# carries its own `atomic_attack_identifier`, so the persisted data alone is +# enough to reconstruct the per-attempt trail — no envelope-side metadata, no +# scenario-side lookup tables needed. +# +# Walk the children via the envelope's `child_attack_result_ids` (joined +# against the flat results list), then read each child's attack strategy +# identifier with `child.get_attack_strategy_identifier()`. The returned +# `ComponentIdentifier` exposes `class_name` (e.g. `"CrescendoAttack"`) and +# `unique_name` (e.g. `"CrescendoAttack::a1b2c3d4"`), which uniquely +# distinguishes two factories that wrap the same attack class with different +# configurations. +# +# Use `result.get_display_groups()` to aggregate `attack_results` by the +# per-dataset display label set by the scenario. # %% from collections import Counter -# Per-group: one line per objective (the envelope, carrying the technique trail) -# plus a per-technique success-rate table within the group. Each adaptive run -# persists both the per-objective envelope AND its per-attempt child rows; the -# children are filtered out of the per-objective list so it stays one line per -# objective. Aggregate across groups for a final grand-total table. +# Per-group: one line per objective (the envelope) showing the per-attempt +# trail, plus a per-technique success-rate table within the group. The child +# rows that compose each envelope are filtered out of the per-objective list so +# it stays one line per objective. Aggregate across groups for a grand-total. display_groups = resumed_result.get_display_groups() +# Flatten every persisted row across every group so we can look up a child +# AttackResult by its attack_result_id when reconstructing per-envelope trails. +results_by_id = {r.attack_result_id: r for results in display_groups.values() for r in results} + + +def _technique_label(result) -> str: + """Display name for the attack strategy that produced ``result``.""" + attack_id = result.get_attack_strategy_identifier() + return attack_id.unique_name if attack_id else "" + + total_picks: Counter[str] = Counter() total_wins: Counter[str] = Counter() @@ -177,28 +194,36 @@ for r in results: if r.attack_result_id in child_ids: continue - attempts = r.metadata.get("adaptive_attempts", []) - trail = " → ".join(f"{a['technique']}({a['outcome']})" for a in attempts) + child_id_list = r.metadata.get("child_attack_result_ids", []) or [] + trail_parts: list[str] = [] + for child_id in child_id_list: + child = results_by_id.get(child_id) + if child is None: + continue + trail_parts.append(f"{_technique_label(child)}({child.outcome.value})") + trail = " → ".join(trail_parts) print(f" [{r.outcome.value:7s}] {r.objective!r}: {trail}") picks: Counter[str] = Counter() wins: Counter[str] = Counter() for r in results: - for step in r.metadata.get("adaptive_attempts", []): - picks[step["technique"]] += 1 - total_picks[step["technique"]] += 1 - if step["outcome"] == "success": - wins[step["technique"]] += 1 - total_wins[step["technique"]] += 1 - - print("\n Technique wins / picks rate") + if r.attack_result_id not in child_ids: + continue + technique = _technique_label(r) + picks[technique] += 1 + total_picks[technique] += 1 + if r.outcome.value == "success": + wins[technique] += 1 + total_wins[technique] += 1 + + print("\n Technique wins / picks rate") for technique, n in picks.most_common(): - print(f" {technique:20s} {wins[technique]:>4} / {n:<4} {wins[technique] / n:.0%}") + print(f" {technique:40s} {wins[technique]:>4} / {n:<4} {wins[technique] / n:.0%}") print("\n=== Overall ===") -print("Technique wins / picks rate") +print("Technique wins / picks rate") for technique, n in total_picks.most_common(): - print(f"{technique:20s} {total_wins[technique]:>4} / {n:<4} {total_wins[technique] / n:.0%}") + print(f"{technique:40s} {total_wins[technique]:>4} / {n:<4} {total_wins[technique] / n:.0%}") # %% [markdown] # ## Running from the scanner CLI diff --git a/pyrit/scenario/scenarios/adaptive/__init__.py b/pyrit/scenario/scenarios/adaptive/__init__.py index 440e43a86a..98bdf444dc 100644 --- a/pyrit/scenario/scenarios/adaptive/__init__.py +++ b/pyrit/scenario/scenarios/adaptive/__init__.py @@ -5,8 +5,9 @@ from pyrit.scenario.scenarios.adaptive.adaptive_scenario import AdaptiveScenario from pyrit.scenario.scenarios.adaptive.dispatcher import ( - AdaptiveDispatchAttack, - AdaptiveDispatchParams, + ADAPTIVE_ATTEMPT_LABEL, + AdaptiveTechniqueDispatcher, + TechniqueBundle, ) from pyrit.scenario.scenarios.adaptive.selectors import ( EpsilonGreedyTechniqueSelector, @@ -16,11 +17,12 @@ from pyrit.scenario.scenarios.adaptive.text_adaptive import TextAdaptive __all__ = [ - "AdaptiveDispatchAttack", - "AdaptiveDispatchParams", + "ADAPTIVE_ATTEMPT_LABEL", "AdaptiveScenario", + "AdaptiveTechniqueDispatcher", "EpsilonGreedyTechniqueSelector", "SelectorScope", + "TechniqueBundle", "TechniqueSelector", "TextAdaptive", ] diff --git a/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py b/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py index e7e3e32cda..5dff48be0f 100644 --- a/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py +++ b/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py @@ -20,13 +20,14 @@ from abc import abstractmethod from typing import TYPE_CHECKING, ClassVar +from pyrit.common.utils import to_sha256 from pyrit.executor.attack import AttackScoringConfig from pyrit.models.identifiers import compute_inner_attack_eval_hash from pyrit.scenario.core.atomic_attack import AtomicAttack from pyrit.scenario.core.attack_technique import AttackTechnique from pyrit.scenario.core.scenario import Scenario from pyrit.scenario.scenarios.adaptive.dispatcher import ( - AdaptiveDispatchAttack, + AdaptiveTechniqueDispatcher, TechniqueBundle, ) from pyrit.scenario.scenarios.adaptive.selectors import ( @@ -132,19 +133,19 @@ def _get_attack_technique_factories(self) -> dict[str, AttackTechniqueFactory]: async def _get_atomic_attacks_async(self) -> list[AtomicAttack]: """ - Build one ``AtomicAttack`` per dataset, each carrying every objective - in that dataset as a separate ``SeedAttackGroup``. + Build one ``AtomicAttack`` per (dataset, compatible seed group) pair. - A single ``AdaptiveDispatchAttack`` is constructed per dataset and - shared across its seed groups; per-call seed-group routing and - per-call ``seed_technique`` compatibility filtering happen inside the - dispatcher (driven by ``AdaptiveDispatchParams.seed_group``). All - dispatchers across all datasets share one ``TechniqueSelector`` - instance so learning accumulates globally. + For each dataset, construct a single ``AdaptiveTechniqueDispatcher`` + shared across that dataset's seed groups. For each seed group, ask + the dispatcher to build its per-objective ``SequentialAttack`` and + wrap it in its own ``AtomicAttack``. All dispatchers across all + datasets share one ``TechniqueSelector`` instance so learning + accumulates globally; selection is committed up-front during + scenario initialization, before any execution starts. Returns: - list[AtomicAttack]: One ``AtomicAttack`` per dataset that has at - least one compatible seed group. + list[AtomicAttack]: One ``AtomicAttack`` per compatible + seed group across all datasets. Raises: ValueError: If ``self._objective_target`` is not set, or if @@ -162,14 +163,14 @@ async def _get_atomic_attacks_async(self) -> list[AtomicAttack]: seed_groups_by_dataset = self._dataset_config.get_seed_attack_groups() atomic_attacks: list[AtomicAttack] = [] for dataset_name, seed_groups in seed_groups_by_dataset.items(): - atomic = self._build_atomic_for_dataset( - dataset_name=dataset_name, - seed_groups=seed_groups, - techniques=techniques, - selector=selector, + atomic_attacks.extend( + await self._build_atomics_for_dataset( + dataset_name=dataset_name, + seed_groups=seed_groups, + techniques=techniques, + selector=selector, + ) ) - if atomic is not None: - atomic_attacks.append(atomic) return atomic_attacks @@ -276,67 +277,72 @@ def _build_scoring_config_for_factory(self, *, factory: AttackTechniqueFactory) except (TypeError, ValueError): return AttackScoringConfig(objective_scorer=self._objective_scorer) - def _build_atomic_for_dataset( + async def _build_atomics_for_dataset( self, *, dataset_name: str, seed_groups: list[SeedAttackGroup], techniques: dict[str, TechniqueBundle], selector: TechniqueSelector, - ) -> AtomicAttack | None: + ) -> list[AtomicAttack]: """ - Build a single ``AtomicAttack`` for one dataset with all compatible - seed groups attached. + Build one ``AtomicAttack`` per seed group with at least one + compatible technique. + + A single ``AdaptiveTechniqueDispatcher`` is constructed for this + dataset and used to build a fresh ``SequentialAttack`` per seed + group. Each returned atomic carries one seed group and one + pre-built attack whose children were selected up-front via the + dispatcher. Seed groups for which no technique in the pool is compatible are - dropped here with a warning so the dispatcher's per-call compatible - pool is guaranteed non-empty. + dropped here with a warning. Returns: - AtomicAttack | None: The constructed atomic attack, or ``None`` when - every seed group is incompatible with every technique. + list[AtomicAttack]: One atomic per compatible seed group. + Empty list when every seed group is incompatible with + every technique. Raises: - ValueError: If ``self._objective_target`` is not set (defensive - guard; ``_get_atomic_attacks_async`` enforces this earlier). + ValueError: If ``self._objective_target`` is not set + (defensive guard; ``_get_atomic_attacks_async`` enforces + this earlier). """ if self._objective_target is None: # pragma: no cover - defensive raise ValueError("objective_target must be set before creating attacks") - compatible_seed_groups: list[SeedAttackGroup] = [] + dispatcher = AdaptiveTechniqueDispatcher( + objective_target=self._objective_target, + techniques=techniques, + selector=selector, + objective_scorer=self._objective_scorer, + max_attempts_per_objective=self.params["max_attempts_per_objective"], + scenario_result_id=self._scenario_result_id, + ) + + atomics: list[AtomicAttack] = [] for seed_group in seed_groups: - has_compatible = any( - bundle.seed_technique is None - or seed_group.is_compatible_with_technique(technique=bundle.seed_technique) - for bundle in techniques.values() - ) - if has_compatible: - compatible_seed_groups.append(seed_group) - else: + if not dispatcher.compatible_techniques(seed_group=seed_group): logger.warning( "AdaptiveScenario: no compatible techniques for seed group in dataset '%s' " "(objective=%r); skipping.", dataset_name, seed_group.objective.value, ) + continue - if not compatible_seed_groups: - return None - - dispatcher = AdaptiveDispatchAttack( - objective_target=self._objective_target, - techniques=techniques, - selector=selector, - objective_scorer=self._objective_scorer, - max_attempts_per_objective=self.params["max_attempts_per_objective"], - scenario_result_id=self._scenario_result_id, - ) + attack = await dispatcher.build_attack_async(seed_group=seed_group) + objective_sha = to_sha256(seed_group.objective.value) + atomic_attack_name = f"{self._atomic_attack_prefix}_{dataset_name}::{objective_sha[:12]}" + atomics.append( + AtomicAttack( + atomic_attack_name=atomic_attack_name, + attack_technique=AttackTechnique(attack=attack), + seed_groups=[seed_group], + objective_scorer=self._objective_scorer, + memory_labels=dict(self._memory_labels), + display_group=dataset_name, + ) + ) - return AtomicAttack( - atomic_attack_name=f"{self._atomic_attack_prefix}_{dataset_name}", - attack_technique=AttackTechnique(attack=dispatcher), - seed_groups=compatible_seed_groups, - objective_scorer=self._objective_scorer, - memory_labels=dict(self._memory_labels), - display_group=dataset_name, - ) + return atomics diff --git a/pyrit/scenario/scenarios/adaptive/dispatcher.py b/pyrit/scenario/scenarios/adaptive/dispatcher.py index ac5c9b429c..f1491b969d 100644 --- a/pyrit/scenario/scenarios/adaptive/dispatcher.py +++ b/pyrit/scenario/scenarios/adaptive/dispatcher.py @@ -2,43 +2,39 @@ # Licensed under the MIT license. """ -``AdaptiveDispatchAttack`` — picks inner techniques per objective via a -``TechniqueSelector``, then runs them in priority order via a -``SequentialAttack`` (stop on first success). - -The selector is stateless and async: it queries memory for historical -success rates. The dispatcher pre-selects up to ``max_attempts_per_objective`` -techniques at the start of each objective, builds a per-call -``SequentialAttack`` whose child attacks are the chosen techniques, and -delegates iteration + stop-on-success + envelope construction to that -compound attack. - -Returned envelope is a ``SequentialAttackResult`` (an ``AttackResult`` -subclass) stamped with the adaptive trail under -``metadata["adaptive_attempts"]``. Inner per-attempt results live on the -envelope's ``child_attack_results`` and persist as their own DB rows; the -envelope itself owns no conversation (``conversation_id == ""``). +``AdaptiveTechniqueDispatcher`` — selects inner techniques per objective via a +``TechniqueSelector`` and builds a ``SequentialAttack`` to run them. + +The dispatcher is a plain class, not an ``AttackStrategy``. It does not +execute anything and does not persist anything. ``AdaptiveScenario`` calls +``build_attack_async`` once per ``SeedAttackGroup`` during scenario +initialization, wraps each returned attack in its own ``AtomicAttack``, and +hands them to the scenario base for execution. + +The returned attack is a plain ``SequentialAttack`` with +``SequenceCompletionPolicy.FIRST_SUCCESS``. The per-attempt dispatch trail +(which technique ran, with what outcome, in what order) is not stamped onto +the envelope — every child ``AttackResult`` in +``SequentialAttackResult.child_attack_results`` already carries its own +``outcome`` and its own ``atomic_attack_identifier.eval_hash``, so callers +reconstruct the trail by joining children against +``AdaptiveScenario.technique_names_by_eval_hash``. """ from __future__ import annotations -import dataclasses import logging -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Optional from pyrit.executor.attack.compound.sequential_attack import ( SequenceCompletionPolicy, SequentialAttack, - SequentialAttackResult, SequentialChildAttack, ) -from pyrit.executor.attack.core.attack_parameters import AttackParameters -from pyrit.executor.attack.core.attack_strategy import AttackContext, AttackStrategy if TYPE_CHECKING: - from collections.abc import Sequence - + from pyrit.executor.attack.core.attack_strategy import AttackStrategy from pyrit.models import AttackResult, SeedAttackGroup, SeedAttackTechniqueGroup from pyrit.prompt_target import PromptTarget from pyrit.scenario.scenarios.adaptive.selectors import TechniqueSelector @@ -47,7 +43,7 @@ logger = logging.getLogger(__name__) -# Memory-label keys stamped onto persisted prompt rows so adaptive attempts +# Memory-label key stamped onto persisted prompt rows so adaptive attempts # can be filtered/grouped after a run. ADAPTIVE_ATTEMPT_LABEL: str = "_adaptive_attempt" """1-based attempt index within the per-objective loop.""" @@ -60,324 +56,165 @@ class TechniqueBundle: Carries the inner attack strategy alongside the factory-supplied ``seed_technique`` (if any) and ``adversarial_chat`` (required when the - seed_technique contains a simulated-conversation config). + seed_technique contains a simulated-conversation config). ``name`` is the + factory-registration key; the dispatcher does not consume it, but it is + convenient for diagnostics and is preserved here so callers/tests can + cross-check which factory each bundle came from. + + Notebook/report code that wants a human-readable label for a persisted + child ``AttackResult`` should read it from the child itself via + ``child.get_attack_strategy_identifier()`` — the executor already stamps + ``class_name`` and ``unique_name`` on every row, so there is no need to + publish a separate ``{eval_hash: name}`` map. """ attack: AttackStrategy[Any, AttackResult] name: str = "" - seed_technique: SeedAttackTechniqueGroup | None = None - adversarial_chat: PromptTarget | None = None - - -@dataclass(frozen=True) -class AdaptiveDispatchParams(AttackParameters): - """Attack parameters for adaptive dispatch, carrying the original seed group.""" - - # The original SeedAttackGroup is preserved on the params so the - # dispatcher can apply per-attempt seed_technique merging and derive - # the per-call adaptive context. Captured by ``from_seed_group_async``; - # not user-supplied via overrides. - seed_group: Optional[SeedAttackGroup] = field(default=None, repr=False, compare=False) - - @classmethod - async def from_seed_group_async( - cls, - *, - seed_group: SeedAttackGroup, - adversarial_chat: Optional[PromptTarget] = None, # noqa: ARG003 — required by base class signature - objective_scorer: Optional[TrueFalseScorer] = None, # noqa: ARG003 — required by base class signature - **overrides: Any, - ) -> AdaptiveDispatchParams: - """ - Build params for a single dispatch and capture the original seed_group. - - The dispatcher applies seed_technique merging itself per-attempt - (when constructing the per-call ``SequentialChildAttack``s), so we - deliberately bypass the base class's simulated-conversation - expansion / next_message extraction: each inner technique runs - through its own ``AttackExecutor`` call inside ``SequentialAttack`` - which performs that work using the technique-merged seed_group. - - Returns: - AdaptiveDispatchParams: The constructed parameters with the seed group attached. - - Raises: - ValueError: If the seed_group's objective is not initialized or invalid overrides are passed. - """ - if seed_group.objective is None: - raise ValueError("seed_group.objective is not initialized") - seed_group.validate() - - valid_fields = {f.name for f in dataclasses.fields(cls)} - {"seed_group"} - invalid = set(overrides.keys()) - valid_fields - if invalid: - raise ValueError(f"{cls.__name__} does not accept parameters: {invalid}. Accepted: {valid_fields}") - - return cls( - objective=seed_group.objective.value, - memory_labels=overrides.get("memory_labels") or {}, - seed_group=seed_group, - ) + seed_technique: Optional[SeedAttackTechniqueGroup] = None + adversarial_chat: Optional[PromptTarget] = None -@dataclass -class AdaptiveDispatchContext(AttackContext[AdaptiveDispatchParams]): - """Execution context for ``AdaptiveDispatchAttack`` (no extra state).""" - - -class AdaptiveDispatchAttack(AttackStrategy[AdaptiveDispatchContext, SequentialAttackResult]): +class AdaptiveTechniqueDispatcher: """ - Attack that delegates each attempt to one of several inner techniques, - choosing per attempt via a ``TechniqueSelector``. + Selects inner techniques per objective and builds a ``SequentialAttack``. + + Not an ``AttackStrategy``: the dispatcher does not execute anything + and does not persist anything. It is a small factory used by + ``AdaptiveScenario`` at initialization to translate one + ``SeedAttackGroup`` (one objective) into one ready-to-run attack. - For each objective: query the selector for the top + For each call: query the selector for the top ``max_attempts_per_objective`` techniques compatible with the seed - group, then hand the chosen techniques off to a per-call - ``SequentialAttack`` (with ``SequenceCompletionPolicy.FIRST_SUCCESS``) - which iterates through them, stops on the first success, and returns - one ``SequentialAttackResult`` envelope. The selector is shared by - reference across all dispatch calls in a scenario so learning - accumulates across objectives. - - The seed group for a given dispatch is read from - ``context.params.seed_group`` (captured by - ``AdaptiveDispatchParams.from_seed_group_async``). When a chosen - technique declares a ``seed_technique``, that group is merged into the - seed group when building the per-attempt ``SequentialChildAttack`` - (mirroring the static ``AtomicAttack`` path). Techniques whose - ``seed_technique`` is incompatible with the current seed group are - filtered out of the candidate pool for that call; if the pool is empty - the dispatcher raises so the per-call seed group is dropped by the - executor's partial-failure path rather than silently no-op'ing. - - The returned envelope owns no conversation of its own - (``conversation_id == ""``): the inner per-attempt ``AttackResult``s - each persist their own row with the raw conversation, and are - reachable via ``result.child_attack_results`` (in-memory) or via - ``result.child_attack_result_ids`` (after a DB round-trip). The - envelope itself is persisted with the dispatch trail stamped onto - ``metadata["adaptive_attempts"]``. + group, then construct a ``SequentialAttack`` (with + ``SequenceCompletionPolicy.FIRST_SUCCESS``) whose children are the + chosen techniques in priority order. The selector is shared by + reference across all calls in a scenario so learning accumulates + across objectives — though all selections are committed up-front + during scenario initialization (see + ``AdaptiveScenario._get_atomic_attacks_async``). """ - ADAPTIVE_ATTEMPTS_KEY: str = "adaptive_attempts" - """Metadata key under which the per-attempt dispatch trail is stamped on the envelope.""" - def __init__( self, *, objective_target: PromptTarget, techniques: dict[str, TechniqueBundle], selector: TechniqueSelector, - objective_scorer: TrueFalseScorer | None = None, + objective_scorer: Optional[TrueFalseScorer] = None, max_attempts_per_objective: int = 3, scenario_result_id: str | None = None, ) -> None: """ Args: objective_target (PromptTarget): The target inner attacks run against. - techniques (dict[str, TechniqueBundle]): Mapping from technique eval hash to - its bundle (attack, name, seed_technique, adversarial_chat). Must be non-empty. + techniques (dict[str, TechniqueBundle]): Mapping from + technique eval hash to its bundle. Must be non-empty. selector (TechniqueSelector): Stateless technique selector. - objective_scorer (TrueFalseScorer | None): Scorer passed through to - techniques that generate simulated conversations. - max_attempts_per_objective (int): Max attempts per objective; >= 1. - Defaults to 3. - scenario_result_id (str | None): If provided, passed to the selector - to scope memory queries to this scenario run. Defaults to ``None``. + objective_scorer (TrueFalseScorer | None): Scorer forwarded + to inner attacks that generate simulated conversations. + max_attempts_per_objective (int): Maximum attempts per + objective; must be >= 1. Defaults to 3. + scenario_result_id (str | None): Passed to the selector to + scope memory queries to this scenario run. Defaults to + ``None``. Raises: - ValueError: If ``techniques`` is empty or ``max_attempts_per_objective`` < 1. + ValueError: If ``techniques`` is empty or + ``max_attempts_per_objective`` < 1. """ if not techniques: raise ValueError("techniques must contain at least one attack technique") if max_attempts_per_objective < 1: raise ValueError(f"max_attempts_per_objective must be >= 1, got {max_attempts_per_objective}") - - super().__init__( - objective_target=objective_target, - context_type=AdaptiveDispatchContext, - params_type=AdaptiveDispatchParams, - logger=logger, - ) + self._objective_target = objective_target self._techniques = techniques self._selector = selector self._objective_scorer = objective_scorer self._max_attempts = max_attempts_per_objective self._scenario_result_id = scenario_result_id - def _validate_context(self, *, context: AdaptiveDispatchContext) -> None: + def compatible_techniques(self, *, seed_group: SeedAttackGroup) -> list[str]: """ - Ensure the context carries a non-empty objective string. + Return technique hashes whose ``seed_technique`` is compatible with ``seed_group``. - Raises: - ValueError: If ``context.objective`` is empty or whitespace-only. - """ - if not context.objective or context.objective.isspace(): - raise ValueError("Attack objective must be provided and non-empty") - - async def _setup_async(self, *, context: AdaptiveDispatchContext) -> None: - """No-op: per-attempt setup is owned by ``SequentialAttack`` / its executor.""" - - async def _teardown_async(self, *, context: AdaptiveDispatchContext) -> None: - """No-op: per-attempt teardown is owned by ``SequentialAttack`` / its executor.""" - - def _build_child_attacks( - self, - *, - seed_group: SeedAttackGroup, - chosen_techniques: Sequence[str], - ) -> list[SequentialChildAttack]: - """ - Build the ``SequentialChildAttack`` list handed to ``SequentialAttack``. - - Per chosen technique: merge ``bundle.seed_technique`` into - ``seed_group`` (if any), and stamp the per-attempt - ``ADAPTIVE_ATTEMPT_LABEL`` memory label. ``SequentialAttack`` - further merges this with the compound's own ``context.memory_labels`` - at dispatch time, so the outer caller's labels still propagate to - every attempt. - - Per-technique disambiguation in analytics relies on the - auto-stamped ``atomic_attack_identifier.eval_hash`` on each - persisted attempt row, not a custom label. - - Args: - seed_group (SeedAttackGroup): The seed group for this dispatch call. - chosen_techniques (Sequence[str]): Technique eval hashes returned by - the selector, in priority order. + Techniques with no ``seed_technique`` are universally compatible. + Used by ``AdaptiveScenario`` to drop seed groups with no usable + techniques before building atomic attacks. Returns: - list[SequentialChildAttack]: One child attack per chosen - technique, in dispatch order. - """ - child_attacks: list[SequentialChildAttack] = [] - for attempt_idx, chosen in enumerate(chosen_techniques): - bundle = self._techniques[chosen] - execution_group = ( - seed_group.with_technique(technique=bundle.seed_technique) - if bundle.seed_technique is not None - else seed_group - ) - child_attacks.append( - SequentialChildAttack( - strategy=bundle.attack, - seed_group=execution_group, - adversarial_chat=bundle.adversarial_chat, - objective_scorer=self._objective_scorer, - memory_labels={ - ADAPTIVE_ATTEMPT_LABEL: str(attempt_idx + 1), - }, - ) - ) - return child_attacks - - def _build_adaptive_trail( - self, - *, - chosen_techniques: Sequence[str], - child_results: list[AttackResult], - ) -> list[dict[str, str]]: - """ - Build the per-attempt dispatch trail stamped onto the envelope. - - ``chosen_techniques`` is the full pre-selected list; ``child_results`` - contains only the attempts that actually ran (``FIRST_SUCCESS`` may - halt early). Trail length matches ``len(child_results)``. - - Returns: - list[dict[str, str]]: One entry per executed attempt, in - dispatch order, each carrying ``technique`` (display name), - ``technique_hash`` (eval hash), and ``outcome`` - (``AttackOutcome`` value). + list[str]: Technique eval hashes in declaration order. """ return [ - { - "technique": self._techniques[h].name, - "technique_hash": h, - "outcome": r.outcome.value, - } - for h, r in zip(chosen_techniques, child_results, strict=False) + name + for name, bundle in self._techniques.items() + if bundle.seed_technique is None or seed_group.is_compatible_with_technique(technique=bundle.seed_technique) ] - async def _perform_async(self, *, context: AdaptiveDispatchContext) -> SequentialAttackResult: + async def build_attack_async(self, *, seed_group: SeedAttackGroup) -> SequentialAttack: """ - Run the per-objective adaptive loop via ``SequentialAttack``. + Build a ``SequentialAttack`` for one ``SeedAttackGroup``. - Queries the stateless selector for the top + Queries the selector for the top ``max_attempts_per_objective`` techniques (filtered by per-call - seed-group compatibility), wraps them in a ``SequentialAttack`` - with ``SequenceCompletionPolicy.FIRST_SUCCESS``, and delegates - iteration + stop-on-success + envelope construction. Stamps the - ``adaptive_attempts`` trail on the envelope before returning. - - Drives the inner sequence via ``_perform_async`` (not - ``execute_async``) so the outer dispatcher remains the sole owner - of envelope persistence; ``_attribution`` is forwarded so child - rows keep the scenario linkage. + seed-group compatibility) and wraps them in a + ``SequentialAttack`` with + ``SequenceCompletionPolicy.FIRST_SUCCESS``. Args: - context (AdaptiveDispatchContext): Execution context whose - ``params.seed_group`` carries the seed group for this call. + seed_group (SeedAttackGroup): The seed group for the + objective this attack will run against. Must carry a + non-None objective. Returns: - SequentialAttackResult: The envelope produced by the inner - ``SequentialAttack`` with the adaptive trail stamped onto - ``metadata["adaptive_attempts"]``. + SequentialAttack: The ready-to-run attack. Each child's + identity is captured by its own + ``atomic_attack_identifier.eval_hash`` after execution; + callers wanting the friendly technique name join those + hashes against + ``AdaptiveScenario.technique_names_by_eval_hash``. Raises: - ValueError: If ``context.params.seed_group`` is missing, or if no - techniques in the pool are compatible with the seed group. + ValueError: If ``seed_group.objective`` is not initialized, + or if no techniques in the pool are compatible with the + seed group. """ - seed_group = context.params.seed_group - if seed_group is None: - raise ValueError( - "AdaptiveDispatchAttack requires AdaptiveDispatchParams.seed_group; " - "build params via AdaptiveDispatchParams.from_seed_group_async." - ) + if seed_group.objective is None: + raise ValueError("seed_group.objective is not initialized") - compatible_names = [ - name - for name, bundle in self._techniques.items() - if bundle.seed_technique is None or seed_group.is_compatible_with_technique(technique=bundle.seed_technique) - ] - if not compatible_names: + compatible = self.compatible_techniques(seed_group=seed_group) + if not compatible: raise ValueError( - f"AdaptiveDispatchAttack: no compatible techniques for seed group " + f"AdaptiveTechniqueDispatcher: no compatible techniques for seed group " f"(objective={seed_group.objective.value!r})." ) - chosen_techniques = await self._selector.select_async( - technique_identifiers=compatible_names, - objective=context.objective, + chosen_hashes = await self._selector.select_async( + technique_identifiers=compatible, + objective=seed_group.objective.value, num_top_techniques=self._max_attempts, scenario_result_id=self._scenario_result_id, ) - child_attacks = self._build_child_attacks( - seed_group=seed_group, - chosen_techniques=chosen_techniques, - ) + child_attacks: list[SequentialChildAttack] = [] + for attempt_idx, chosen in enumerate(chosen_hashes): + bundle = self._techniques[chosen] + execution_group = ( + seed_group.with_technique(technique=bundle.seed_technique) + if bundle.seed_technique is not None + else seed_group + ) + child_attacks.append( + SequentialChildAttack( + strategy=bundle.attack, + seed_group=execution_group, + adversarial_chat=bundle.adversarial_chat, + objective_scorer=self._objective_scorer, + memory_labels={ADAPTIVE_ATTEMPT_LABEL: str(attempt_idx + 1)}, + ) + ) - sequential = SequentialAttack( + return SequentialAttack( objective_target=self._objective_target, child_attacks=child_attacks, completion_policy=SequenceCompletionPolicy.FIRST_SUCCESS, ) - - # Call ``_perform_async`` directly; ``execute_async`` would persist the same - # envelope a second time and trip an IntegrityError. Forward ``_attribution`` - # so child rows still carry the scenario linkage. - inner_params = sequential._params_type( - objective=context.objective, - memory_labels=dict(context.memory_labels), - ) - inner_context = sequential._context_type(params=inner_params) - inner_context._attribution = context._attribution - - result: SequentialAttackResult = await sequential._perform_async(context=inner_context) - - result.metadata[self.ADAPTIVE_ATTEMPTS_KEY] = self._build_adaptive_trail( - chosen_techniques=chosen_techniques, - child_results=result.child_attack_results, - ) - return result diff --git a/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py b/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py index 95f1a45840..cd92c8abda 100644 --- a/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py +++ b/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py @@ -6,16 +6,13 @@ import pytest from pyrit.executor.attack.compound.sequential_attack import ( + SequenceCompletionPolicy, SequentialAttack, - SequentialAttackResult, - SequentialChildAttack, ) -from pyrit.models import AttackOutcome, AttackResult, SeedAttackGroup, SeedObjective +from pyrit.models import AttackOutcome, SeedAttackGroup, SeedObjective from pyrit.scenario.scenarios.adaptive.dispatcher import ( ADAPTIVE_ATTEMPT_LABEL, - AdaptiveDispatchAttack, - AdaptiveDispatchContext, - AdaptiveDispatchParams, + AdaptiveTechniqueDispatcher, TechniqueBundle, ) @@ -28,67 +25,6 @@ def _make_bundle(*, name: str, outcomes: list[AttackOutcome], seed_technique=Non return TechniqueBundle(attack=attack, name=name, seed_technique=seed_technique) -def _make_context( - *, - objective: str = "obj", - labels: dict[str, str] | None = None, - seed_group: SeedAttackGroup | None = None, - harm_categories: list[str] | None = None, -) -> AdaptiveDispatchContext: - if seed_group is None: - seed_group = SeedAttackGroup(seeds=[SeedObjective(value=objective, harm_categories=harm_categories)]) - return AdaptiveDispatchContext( - params=AdaptiveDispatchParams( - objective=objective, - memory_labels=labels or {}, - seed_group=seed_group, - ) - ) - - -def _patch_child_attack( - monkeypatch: pytest.MonkeyPatch, - *, - bundles: dict[str, TechniqueBundle], -) -> list[dict]: - """ - Replace ``SequentialAttack._run_child_attack_async`` with a stub backed - by per-bundle outcomes. - - Each invocation records the merged ``memory_labels``, the forwarded - ``attribution``, and the resulting ``AttackResult`` so tests can inspect - per-attempt routing, per-attempt label stamping, and attribution - propagation without monkey-patching ``AttackExecutor``. - """ - name_for_attack = {id(b.attack): name for name, b in bundles.items()} - counters: dict[str, int] = dict.fromkeys(bundles, 0) - calls: list[dict] = [] - - async def _stub(self, *, child_attack: SequentialChildAttack, memory_labels: dict[str, str], attribution=None): - name = name_for_attack[id(child_attack.strategy)] - idx = counters[name] - counters[name] = idx + 1 - outcome = child_attack.strategy._outcomes[idx] - result = AttackResult( - conversation_id=f"conv-{name}-{idx}", - objective="obj", - outcome=outcome, - ) - calls.append( - { - "name": name, - "attempt_labels": dict(memory_labels), - "child_attack": child_attack, - "result": result, - "attribution": attribution, - } - ) - return result - - monkeypatch.setattr(SequentialAttack, "_run_child_attack_async", _stub, raising=True) - return calls - - class _StubSelector: """A deterministic selector stub that returns techniques in the order given.""" @@ -121,11 +57,11 @@ def seed_group() -> SeedAttackGroup: return SeedAttackGroup(seeds=[SeedObjective(value="obj")]) -class TestInit: +class TestDispatcherInit: @pytest.mark.usefixtures("patch_central_database") - def test_init_rejects_empty_techniques(self, target, selector, seed_group): + def test_init_rejects_empty_techniques(self, target, selector): with pytest.raises(ValueError, match="techniques"): - AdaptiveDispatchAttack( + AdaptiveTechniqueDispatcher( objective_target=target, techniques={}, selector=selector, @@ -133,9 +69,9 @@ def test_init_rejects_empty_techniques(self, target, selector, seed_group): @pytest.mark.parametrize("bad_max", [0, -1]) @pytest.mark.usefixtures("patch_central_database") - def test_init_rejects_invalid_max_attempts(self, target, selector, seed_group, bad_max): + def test_init_rejects_invalid_max_attempts(self, target, selector, bad_max): with pytest.raises(ValueError, match="max_attempts_per_objective"): - AdaptiveDispatchAttack( + AdaptiveTechniqueDispatcher( objective_target=target, techniques={"a": _make_bundle(name="a", outcomes=[AttackOutcome.SUCCESS])}, selector=selector, @@ -144,294 +80,118 @@ def test_init_rejects_invalid_max_attempts(self, target, selector, seed_group, b @pytest.mark.usefixtures("patch_central_database") -class TestPerform: - async def test_stops_on_first_success(self, target, seed_group, monkeypatch): +class TestCompatibleTechniques: + def test_returns_all_when_no_seed_technique(self, target, selector, seed_group): bundles = { "a": _make_bundle(name="a", outcomes=[AttackOutcome.SUCCESS]), "b": _make_bundle(name="b", outcomes=[AttackOutcome.SUCCESS]), } - selector = _StubSelector(technique_order=["a", "b"]) - dispatcher = AdaptiveDispatchAttack( + dispatcher = AdaptiveTechniqueDispatcher( objective_target=target, techniques=bundles, selector=selector, - max_attempts_per_objective=5, ) - calls = _patch_child_attack(monkeypatch, bundles=bundles) - - result = await dispatcher._perform_async(context=_make_context()) + assert dispatcher.compatible_techniques(seed_group=seed_group) == ["a", "b"] - assert isinstance(result, SequentialAttackResult) - assert result.outcome == AttackOutcome.SUCCESS - assert len(calls) == 1 - async def test_retries_until_max_attempts_on_failure(self, target, seed_group, monkeypatch): - bundles = { - "a": _make_bundle(name="a", outcomes=[AttackOutcome.FAILURE]), - "b": _make_bundle(name="b", outcomes=[AttackOutcome.FAILURE]), - "c": _make_bundle(name="c", outcomes=[AttackOutcome.FAILURE]), - } - selector = _StubSelector(technique_order=["a", "b", "c"]) - dispatcher = AdaptiveDispatchAttack( - objective_target=target, - techniques=bundles, - selector=selector, - max_attempts_per_objective=3, - ) - calls = _patch_child_attack(monkeypatch, bundles=bundles) - - result = await dispatcher._perform_async(context=_make_context()) - - assert result.outcome == AttackOutcome.FAILURE - assert len(calls) == 3 - - async def test_passes_attempt_labels_to_inner(self, target, seed_group, monkeypatch): - bundles = {"a": _make_bundle(name="a", outcomes=[AttackOutcome.SUCCESS])} - selector = _StubSelector(technique_order=["a"]) - dispatcher = AdaptiveDispatchAttack( - objective_target=target, - techniques=bundles, - selector=selector, - ) - calls = _patch_child_attack(monkeypatch, bundles=bundles) - - await dispatcher._perform_async(context=_make_context(labels={"foo": "bar"})) - - labels = calls[0]["attempt_labels"] - assert labels["foo"] == "bar" - assert labels[ADAPTIVE_ATTEMPT_LABEL] == "1" - # The dispatcher no longer stamps a technique label — per-technique - # disambiguation lives on the persisted ``atomic_attack_identifier.eval_hash``. - assert "_adaptive_technique" not in labels - - async def test_metadata_records_adaptive_trail(self, target, seed_group, monkeypatch): +@pytest.mark.usefixtures("patch_central_database") +class TestBuildAttackAsync: + async def test_builds_sequential_attack(self, target, seed_group): bundles = { - "a": _make_bundle(name="a", outcomes=[AttackOutcome.FAILURE]), + "a": _make_bundle(name="a", outcomes=[AttackOutcome.SUCCESS]), "b": _make_bundle(name="b", outcomes=[AttackOutcome.SUCCESS]), } selector = _StubSelector(technique_order=["a", "b"]) - dispatcher = AdaptiveDispatchAttack( + dispatcher = AdaptiveTechniqueDispatcher( objective_target=target, techniques=bundles, selector=selector, - max_attempts_per_objective=3, - ) - _patch_child_attack(monkeypatch, bundles=bundles) - result = await dispatcher._perform_async(context=_make_context()) - - trail = result.metadata["adaptive_attempts"] - assert trail == [ - {"technique": "a", "technique_hash": "a", "outcome": "failure"}, - {"technique": "b", "technique_hash": "b", "outcome": "success"}, - ] - - async def test_envelope_is_distinct_from_child_and_owns_no_conversation(self, target, seed_group, monkeypatch): - bundles = {"a": _make_bundle(name="a", outcomes=[AttackOutcome.SUCCESS])} + max_attempts_per_objective=2, + ) + + attack = await dispatcher.build_attack_async(seed_group=seed_group) + + # plain SequentialAttack (no adaptive subclass) + assert isinstance(attack, SequentialAttack) + assert type(attack) is SequentialAttack + assert len(attack._child_attacks) == 2 + # children in selection order + assert attack._child_attacks[0].strategy is bundles["a"].attack + assert attack._child_attacks[1].strategy is bundles["b"].attack + # 1-based per-attempt label stamped on each child + assert attack._child_attacks[0].memory_labels[ADAPTIVE_ATTEMPT_LABEL] == "1" + assert attack._child_attacks[1].memory_labels[ADAPTIVE_ATTEMPT_LABEL] == "2" + # default policy is FIRST_SUCCESS + assert attack._completion_policy is SequenceCompletionPolicy.FIRST_SUCCESS + + async def test_raises_when_no_compatible_techniques(self, target): + # bundle with an incompatible seed technique + incompatible_technique = MagicMock(name="incompatible_seed_technique") + bundle = _make_bundle(name="a", outcomes=[AttackOutcome.SUCCESS], seed_technique=incompatible_technique) + bundles = {"a": bundle} selector = _StubSelector(technique_order=["a"]) - dispatcher = AdaptiveDispatchAttack( + dispatcher = AdaptiveTechniqueDispatcher( objective_target=target, techniques=bundles, selector=selector, ) - calls = _patch_child_attack(monkeypatch, bundles=bundles) - - result = await dispatcher._perform_async(context=_make_context()) - - assert len(calls) == 1 - inner_result = calls[0]["result"] - # The envelope is a fresh wrapper owning no conversation; the inner - # attempt's row lives on child_attack_results. - assert result.attack_result_id != inner_result.attack_result_id - assert result.outcome == AttackOutcome.SUCCESS - assert result.conversation_id == "" - assert result.child_attack_results == [inner_result] - assert result.metadata["adaptive_attempts"] == [{"technique": "a", "technique_hash": "a", "outcome": "success"}] - - async def test_attribution_forwarded_to_inner_sequence(self, target, seed_group, monkeypatch): - """ - The outer dispatcher owns persistence; child attacks need the scenario - ``_attribution`` forwarded onto the inner SequentialAttack context so - per-child rows persist with the scenario linkage. Without this, the - per-dataset adaptive results disappear from per-scenario hydration. - """ - from pyrit.executor.attack.core.attack_result_attribution import AttackResultAttribution - - bundles = {"a": _make_bundle(name="a", outcomes=[AttackOutcome.SUCCESS])} - dispatcher = AdaptiveDispatchAttack( - objective_target=target, - techniques=bundles, - selector=_StubSelector(technique_order=["a"]), - ) - calls = _patch_child_attack(monkeypatch, bundles=bundles) - - ctx = _make_context() - attribution = AttackResultAttribution( - parent_id="scenario-1", - parent_collection="adaptive_airt_hate", - ) - ctx._attribution = attribution + seed_group = MagicMock(name="seed_group") + seed_group.objective = MagicMock(value="obj") + seed_group.is_compatible_with_technique.return_value = False - await dispatcher._perform_async(context=ctx) + with pytest.raises(ValueError, match="no compatible techniques"): + await dispatcher.build_attack_async(seed_group=seed_group) - assert calls[0]["attribution"] is attribution - - async def test_inner_lifecycle_bypassed_no_double_persist(self, target, seed_group, monkeypatch): - """ - The outer dispatcher must drive the inner SequentialAttack through - ``_perform_async`` directly, never through ``execute_async``. The - latter triggers the inner ``_on_post_execute`` which persists the - same ``attack_result_id`` a second time and rolls the existing row's - attribution off, hiding per-dataset adaptive results from - per-scenario hydration. - """ + async def test_raises_when_seed_group_has_no_objective(self, target, selector): bundles = {"a": _make_bundle(name="a", outcomes=[AttackOutcome.SUCCESS])} - dispatcher = AdaptiveDispatchAttack( + dispatcher = AdaptiveTechniqueDispatcher( objective_target=target, techniques=bundles, - selector=_StubSelector(technique_order=["a"]), + selector=selector, ) - _patch_child_attack(monkeypatch, bundles=bundles) - - async def _boom(self, *args, **kwargs): - raise AssertionError( - "Dispatcher must not call SequentialAttack.execute_async — " - "doing so triggers double-persistence of the envelope." - ) - - monkeypatch.setattr(SequentialAttack, "execute_async", _boom, raising=True) - - result = await dispatcher._perform_async(context=_make_context()) - assert result.outcome == AttackOutcome.SUCCESS - + sg = MagicMock(name="seed_group") + sg.objective = None + with pytest.raises(ValueError, match="objective is not initialized"): + await dispatcher.build_attack_async(seed_group=sg) -@pytest.mark.usefixtures("patch_central_database") -class TestEndToEndPersistence: - """ - Drive the full ``execute_with_context_async`` lifecycle against a real - in-memory SQLite to verify the dispatcher persists the envelope exactly - once and stamps it with the outer ``AttackResultAttribution``. This is - the integration-shape test that catches the bug where the inner - SequentialAttack's lifecycle would race the outer dispatcher's - persistence and strip attribution off the row. - """ - - async def _seed_scenario_row(self, sqlite_instance): - """Insert a minimal ScenarioResultEntry so the FK on attribution_parent_id can land.""" - from pyrit.models import ScenarioIdentifier, ScenarioResult - - scenario = ScenarioResult( - scenario_identifier=ScenarioIdentifier(name="test_scenario"), - objective_target_identifier=None, - objective_scorer_identifier=None, - attack_results={"adaptive_airt_hate": []}, - scenario_run_state="CREATED", - display_group_map={"adaptive_airt_hate": "airt_hate"}, - ) - sqlite_instance.add_scenario_results_to_memory(scenario_results=[scenario]) - return str(scenario.id) - - async def test_envelope_persisted_once_with_attribution(self, target, monkeypatch, sqlite_instance): - """ - End-to-end: drive ``dispatcher.execute_with_context_async`` and assert - exactly one envelope row lands in the DB with attribution stamped to - the outer scenario. Catches the regression where the inner - SequentialAttack's lifecycle either double-persists (IntegrityError - rollback strips attribution) or skips attribution forwarding. - """ - from pyrit.executor.attack.core.attack_result_attribution import AttackResultAttribution - from pyrit.memory.memory_models import AttackResultEntry - - scenario_id = await self._seed_scenario_row(sqlite_instance) - bundles = {"a": _make_bundle(name="a", outcomes=[AttackOutcome.SUCCESS])} - dispatcher = AdaptiveDispatchAttack( + async def test_respects_max_attempts(self, target, seed_group): + bundles = { + "a": _make_bundle(name="a", outcomes=[AttackOutcome.SUCCESS]), + "b": _make_bundle(name="b", outcomes=[AttackOutcome.SUCCESS]), + "c": _make_bundle(name="c", outcomes=[AttackOutcome.SUCCESS]), + } + selector = _StubSelector(technique_order=["a", "b", "c"]) + dispatcher = AdaptiveTechniqueDispatcher( objective_target=target, techniques=bundles, - selector=_StubSelector(technique_order=["a"]), - ) - _patch_child_attack(monkeypatch, bundles=bundles) - - ctx = _make_context() - ctx._attribution = AttackResultAttribution( - parent_id=scenario_id, - parent_collection="adaptive_airt_hate", - ) - - await dispatcher.execute_with_context_async(context=ctx) - - with sqlite_instance.get_session() as session: - rows = session.query(AttackResultEntry).all() - - envelopes = [r for r in rows if str(r.attribution_parent_id) == scenario_id] - assert len(envelopes) == 1, ( - f"Expected exactly 1 attributed envelope row; found {len(envelopes)}. " - f"All rows: {[(r.id, r.attribution_parent_id, r.attribution_data) for r in rows]}" - ) - envelope = envelopes[0] - assert envelope.attribution_data["parent_collection"] == "adaptive_airt_hate" - - async def test_full_lifecycle_with_real_prompt_sending_attack(self, sqlite_instance): - """ - End-to-end with a real ``PromptSendingAttack`` child (no stubs) running - against a ``MockPromptTarget``. Verifies the full dispatcher → sequential - → child → memory chain works without IntegrityErrors and that both - envelope and child rows land in the DB with attribution. This is the - shape that exercises the path the live backend actually takes. - """ - from pyrit.executor.attack.core.attack_result_attribution import AttackResultAttribution - from pyrit.executor.attack.single_turn.prompt_sending import PromptSendingAttack - from pyrit.memory.memory_models import AttackResultEntry - from pyrit.scenario.scenarios.adaptive.dispatcher import TechniqueBundle - from tests.unit.mocks import MockPromptTarget - - scenario_id = await self._seed_scenario_row(sqlite_instance) - - live_target = MockPromptTarget() - child_attack = PromptSendingAttack(objective_target=live_target) - bundles = {"role_play": TechniqueBundle(attack=child_attack, name="role_play")} - - dispatcher = AdaptiveDispatchAttack( - objective_target=live_target, - techniques=bundles, - selector=_StubSelector(technique_order=["role_play"]), - max_attempts_per_objective=1, - ) - - ctx = _make_context() - ctx._attribution = AttackResultAttribution( - parent_id=scenario_id, - parent_collection="adaptive_airt_hate", + selector=selector, + max_attempts_per_objective=2, ) - await dispatcher.execute_with_context_async(context=ctx) - - with sqlite_instance.get_session() as session: - rows = session.query(AttackResultEntry).all() + attack = await dispatcher.build_attack_async(seed_group=seed_group) + # selector receives num_top_techniques=2, returns 2 items + assert len(attack._child_attacks) == 2 - attributed = [r for r in rows if str(r.attribution_parent_id) == scenario_id] - assert len(attributed) >= 1, ( - f"Expected at least one AttackResultEntry attributed to scenario {scenario_id}; " - f"found {len(attributed)} (total rows: {len(rows)})." - ) - for row in attributed: - assert (row.attribution_data or {}).get("parent_collection") == "adaptive_airt_hate" + async def test_merges_seed_technique_into_child_seed_group(self, target): + """When a bundle declares a seed_technique it is merged into the seed group for that child.""" + seed_technique = MagicMock(name="seed_technique") + outer_sg = MagicMock(name="seed_group") + outer_sg.objective = MagicMock(value="obj") + outer_sg.is_compatible_with_technique.return_value = True + merged_sg = MagicMock(name="merged_seed_group") + outer_sg.with_technique.return_value = merged_sg -@pytest.mark.usefixtures("patch_central_database") -class TestValidate: - @pytest.mark.parametrize("bad_objective", ["", " ", "\n\t"]) - def test_validate_rejects_empty_objective(self, target, selector, seed_group, bad_objective): - dispatcher = AdaptiveDispatchAttack( + bundle = _make_bundle(name="a", outcomes=[AttackOutcome.SUCCESS], seed_technique=seed_technique) + bundles = {"a": bundle} + selector = _StubSelector(technique_order=["a"]) + dispatcher = AdaptiveTechniqueDispatcher( objective_target=target, - techniques={"a": _make_bundle(name="a", outcomes=[AttackOutcome.SUCCESS])}, + techniques=bundles, selector=selector, ) - with pytest.raises(ValueError, match="objective"): - dispatcher._validate_context(context=_make_context(objective=bad_objective)) - def test_validate_accepts_normal_objective(self, target, selector, seed_group): - dispatcher = AdaptiveDispatchAttack( - objective_target=target, - techniques={"a": _make_bundle(name="a", outcomes=[AttackOutcome.SUCCESS])}, - selector=selector, - ) - dispatcher._validate_context(context=_make_context(objective="ok")) + attack = await dispatcher.build_attack_async(seed_group=outer_sg) + # The merged seed group is forwarded to the child attack. + outer_sg.with_technique.assert_called_once_with(technique=seed_technique) + assert attack._child_attacks[0].seed_group is merged_sg diff --git a/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py b/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py index eb9d6ff826..ac1da354d8 100644 --- a/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py +++ b/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py @@ -16,7 +16,7 @@ from pyrit.scenario.core.dataset_configuration import DatasetConfiguration from pyrit.scenario.core.scenario import BaselineAttackPolicy from pyrit.scenario.scenarios.adaptive.dispatcher import ( - AdaptiveDispatchAttack, + AdaptiveTechniqueDispatcher, ) from pyrit.scenario.scenarios.adaptive.text_adaptive import TextAdaptive from pyrit.score import TrueFalseScorer @@ -174,7 +174,7 @@ async def _build_scenario_and_attacks( ) return scenario, await scenario._get_atomic_attacks_async() - async def test_one_atomic_per_dataset(self, mock_objective_target, mock_objective_scorer): + async def test_one_atomic_per_objective(self, mock_objective_target, mock_objective_scorer): groups = { "violence": [ _make_seed_group(value="obj-v1", harm_categories=["violence"]), @@ -189,36 +189,43 @@ async def test_one_atomic_per_dataset(self, mock_objective_target, mock_objectiv mock_objective_scorer=mock_objective_scorer, seed_groups=groups, ) - # One atomic per dataset, carrying all that dataset's seed groups. - assert len(attacks) == 2 - total_seed_groups = sum(len(a.seed_groups) for a in attacks) - assert total_seed_groups == 3 + # One atomic per objective; each carries exactly one seed group. + assert len(attacks) == 3 + for a in attacks: + assert len(a.seed_groups) == 1 + + async def test_dispatchers_share_one_selector(self, mock_objective_target, mock_objective_scorer): + """All per-dataset dispatchers share one TechniqueSelector instance so + learning accumulates globally (selection is committed up-front but the + selector is still shared by reference across constructions). + """ + selectors_seen: list = [] + real_init = AdaptiveTechniqueDispatcher.__init__ + + def _spy_init(self, *args, **kwargs): + selectors_seen.append(kwargs["selector"]) + return real_init(self, *args, **kwargs) - async def test_atomics_share_one_selector_across_dispatchers(self, mock_objective_target, mock_objective_scorer): groups = { - "violence": [ - _make_seed_group(value="obj-v1", harm_categories=["violence"]), - _make_seed_group(value="obj-v2", harm_categories=["violence"]), - ], - "hate": [ - _make_seed_group(value="obj-h1", harm_categories=["hate"]), - ], + "violence": [_make_seed_group(value="obj-v1", harm_categories=["violence"])], + "hate": [_make_seed_group(value="obj-h1", harm_categories=["hate"])], } - _scenario, attacks = await self._build_scenario_and_attacks( - mock_objective_target=mock_objective_target, - mock_objective_scorer=mock_objective_scorer, - seed_groups=groups, - ) - dispatchers = [atomic._attack_technique.attack for atomic in attacks] - # One dispatcher per dataset (atomic). - assert len({id(d) for d in dispatchers}) == len(attacks) - for d in dispatchers: - assert isinstance(d, AdaptiveDispatchAttack) - # All dispatchers share the same selector so learning is global. - selectors = {id(d._selector) for d in dispatchers} - assert len(selectors) == 1 - - async def test_atomic_names_are_dataset_scoped(self, mock_objective_target, mock_objective_scorer): + with patch.object(DatasetConfiguration, "get_seed_attack_groups", return_value=groups): + scenario = TextAdaptive(objective_scorer=mock_objective_scorer) + await scenario.initialize_async( + objective_target=mock_objective_target, + include_baseline=False, + ) + # Only spy on the explicit invocation so the initialize_async call + # doesn't double-count dispatchers. + with patch.object(AdaptiveTechniqueDispatcher, "__init__", _spy_init): + await scenario._get_atomic_attacks_async() + + # One dispatcher per dataset; all share the same selector identity. + assert len(selectors_seen) == 2 + assert len({id(s) for s in selectors_seen}) == 1 + + async def test_atomic_names_contain_dataset_and_objective_hash(self, mock_objective_target, mock_objective_scorer): groups = { "violence": [_make_seed_group(value=f"obj-{i}", harm_categories=["violence"]) for i in range(5)], "hate": [_make_seed_group(value=f"hate-{i}", harm_categories=["hate"]) for i in range(3)], @@ -228,10 +235,11 @@ async def test_atomic_names_are_dataset_scoped(self, mock_objective_target, mock mock_objective_scorer=mock_objective_scorer, seed_groups=groups, ) - names = {atomic.atomic_attack_name for atomic in attacks} - # One atomic name per dataset; the dataset name is embedded. - assert len(names) == len(groups) - assert all(any(ds in name for ds in groups) for name in names) + names = [atomic.atomic_attack_name for atomic in attacks] + # All names unique; each name embeds its dataset name. + assert len(set(names)) == len(names) == 8 + for atomic in attacks: + assert any(ds in atomic.atomic_attack_name for ds in groups) async def test_display_group_is_dataset_name(self, mock_objective_target, mock_objective_scorer): groups = { @@ -281,26 +289,28 @@ async def test_techniques_with_seed_technique_are_kept(self, mock_objective_targ scenario_strategies=[strategy_class("role_play"), strategy_class("many_shot")], ) attacks = scenario._atomic_attacks + techniques = scenario._build_techniques_dict(objective_target=mock_objective_target) + # One atomic for the single objective. assert len(attacks) == 1 - dispatcher = attacks[0]._attack_technique.attack - assert isinstance(dispatcher, AdaptiveDispatchAttack) - # Both factories survive; in particular the seeded one is no longer - # silently dropped. Keys are now eval hashes; check by bundle name. - technique_names = {b.name for b in dispatcher._techniques.values()} + # Both factories survive in the technique pool; in particular the + # seeded one is no longer silently dropped. + technique_names = {b.name for b in techniques.values()} assert "role_play" in technique_names assert "many_shot" in technique_names async def test_incompatible_seed_technique_is_filtered_per_objective( self, mock_objective_target, mock_objective_scorer ): - """Per-objective candidate pool drops techniques whose seed_technique - is incompatible with the seed group; compatible techniques remain. + """When one technique's ``seed_technique`` is incompatible but another + is universally compatible, the objective still produces an atomic that + uses only the compatible technique. """ groups = {"violence": [_make_seed_group(value="obj")]} plain_factory = _make_fake_factory(seed_technique=None) incompatible_factory = _make_fake_factory(seed_technique=MagicMock(name="incompatible_seed_technique")) + # Only the plain factory (no seed_technique) is compatible. with ( patch.object(DatasetConfiguration, "get_seed_attack_groups", return_value=groups), patch.object(SeedAttackGroup, "is_compatible_with_technique", return_value=False), @@ -315,18 +325,17 @@ async def test_incompatible_seed_technique_is_filtered_per_objective( scenario_strategies=[strategy_class("role_play"), strategy_class("many_shot")], ) attacks = scenario._atomic_attacks + techniques = scenario._build_techniques_dict(objective_target=mock_objective_target) + # Atomic survives because the plain factory keeps the compatible pool non-empty. assert len(attacks) == 1 - dispatcher = attacks[0]._attack_technique.attack - # Under the one-atomic-per-dataset design, the full technique pool is - # shared by the dispatcher; per-call compatibility filtering now - # happens inside ``AdaptiveDispatchAttack._perform_async``. The seed - # group survived because the plain (no-seed_technique) factory keeps - # the compatible pool non-empty. Keys are now eval hashes; check by bundle name. - technique_names = {b.name for b in dispatcher._techniques.values()} + assert len(attacks[0].seed_groups) == 1 + # Both factories live in the pool; per-objective compatibility filtering + # inside the dispatcher (``AdaptiveTechniqueDispatcher.compatible_techniques``) + # then drops the incompatible one before selection. + technique_names = {b.name for b in techniques.values()} assert "role_play" in technique_names assert "many_shot" in technique_names - assert len(attacks[0].seed_groups) == 1 async def test_objective_skipped_when_no_compatible_techniques( self, mock_objective_target, mock_objective_scorer, caplog @@ -433,11 +442,10 @@ async def test_factory_create_failure_skips_technique(self, mock_objective_targe scenario_strategies=[strategy_class("role_play"), strategy_class("tap")], ) attacks = scenario._atomic_attacks + techniques = scenario._build_techniques_dict(objective_target=mock_objective_target) assert len(attacks) == 1 - dispatcher = attacks[0]._attack_technique.attack - assert isinstance(dispatcher, AdaptiveDispatchAttack) - technique_names = {b.name for b in dispatcher._techniques.values()} + technique_names = {b.name for b in techniques.values()} assert technique_names == {"role_play"} assert any("tap" in r.getMessage() and "Skipping" in r.getMessage() for r in caplog.records) From 32cf9cb2fd9a7f9cad080f173683fbcb7ea47b82 Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Tue, 2 Jun 2026 14:40:27 -0400 Subject: [PATCH 26/35] internal pr review --- pyrit/identifiers/__init__.py | 2 - pyrit/models/scenario_result.py | 2 +- pyrit/models/seeds/seed_prompt.py | 2 +- .../scenarios/adaptive/adaptive_scenario.py | 93 +++++++++++++------ .../scenario/scenarios/adaptive/dispatcher.py | 38 +++++--- .../scenarios/adaptive/text_adaptive.py | 27 +++++- .../scenarios/adaptive/test_text_adaptive.py | 55 ++++++++++- 7 files changed, 168 insertions(+), 51 deletions(-) diff --git a/pyrit/identifiers/__init__.py b/pyrit/identifiers/__init__.py index 69f6ad5715..d9fe4977a7 100644 --- a/pyrit/identifiers/__init__.py +++ b/pyrit/identifiers/__init__.py @@ -35,7 +35,6 @@ build_seed_identifier, class_name_to_snake_case, compute_eval_hash, - compute_inner_attack_eval_hash, config_hash, snake_case_to_class_name, validate_registry_name, @@ -49,7 +48,6 @@ "class_name_to_snake_case", "ComponentIdentifier", "compute_eval_hash", - "compute_inner_attack_eval_hash", "config_hash", "EvaluationIdentifier", "Identifiable", diff --git a/pyrit/models/scenario_result.py b/pyrit/models/scenario_result.py index 3680d83b7d..3bddaf776f 100644 --- a/pyrit/models/scenario_result.py +++ b/pyrit/models/scenario_result.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Any, Literal import pyrit -from pyrit.models import AttackOutcome, AttackResult +from pyrit.models.attack_result import AttackOutcome, AttackResult if TYPE_CHECKING: from pyrit.models.identifiers.component_identifier import ComponentIdentifier diff --git a/pyrit/models/seeds/seed_prompt.py b/pyrit/models/seeds/seed_prompt.py index d2b867c105..2d8dc8dd16 100644 --- a/pyrit/models/seeds/seed_prompt.py +++ b/pyrit/models/seeds/seed_prompt.py @@ -15,7 +15,7 @@ from tinytag import TinyTag from pyrit.common.path import PATHS_DICT -from pyrit.models import DataTypeSerializer +from pyrit.models.data_type_serializer import DataTypeSerializer from pyrit.models.seeds.seed import Seed if TYPE_CHECKING: diff --git a/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py b/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py index 5dff48be0f..f067803325 100644 --- a/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py +++ b/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py @@ -51,15 +51,24 @@ class AdaptiveScenario(Scenario): Abstract base for adaptive (epsilon-greedy) scenarios. Subclasses must implement the standard ``Scenario`` class-method overrides - and declare ``_atomic_attack_prefix``. Selector wiring + and implement ``_atomic_attack_prefix``. Selector wiring and dispatcher construction are handled here. """ #: Scenario version for memory bookkeeping. VERSION: ClassVar[int] = 1 - #: Prefix for per-objective atomic-attack names (e.g. ``"adaptive_text"``). - _atomic_attack_prefix: ClassVar[str] = "adaptive" + @classmethod + @abstractmethod + def _atomic_attack_prefix(cls) -> str: + """ + Return the prefix for per-objective atomic-attack names (e.g. ``"adaptive_text"``). + + Must be unique across adaptive subclasses — different modalities + emitting the same prefix would collide on ``atomic_attack_name`` and + merge their resume bookkeeping silently. + """ + raise NotImplementedError @classmethod @abstractmethod @@ -99,7 +108,7 @@ def __init__( objective_scorer = self._get_default_objective_scorer() self._objective_scorer: TrueFalseScorer = objective_scorer - self._custom_selector = selector + self._selector: TechniqueSelector = selector if selector is not None else EpsilonGreedyTechniqueSelector() super().__init__( version=self.VERSION, @@ -112,12 +121,23 @@ def __init__( def _get_attack_technique_factories(self) -> dict[str, AttackTechniqueFactory]: """ - Build factories directly from the canonical scenario-techniques catalog. - - Bypasses the global ``AttackTechniqueRegistry`` singleton so adaptive - scenarios resolve their pool from the same source list used by - ``_build_text_adaptive_strategy`` — no implicit dependency on registry - initialization order. Subclasses may override to add or replace factories. + Build factories from the canonical scenario-techniques catalog, + augmented with any factory currently in the global + ``AttackTechniqueRegistry``. + + The catalog defines the deterministic baseline pool — it is also the + source of truth for the strategy enum's valid values, so iteration + order and presence of techniques do not depend on registry + initialization order. Registry-registered factories whose name + matches a catalog entry **override** the catalog default, letting + operators swap in tuned configurations (custom adversarial chat, + different converter chain, etc.) without editing core. Factories + registered only in the registry (no matching strategy enum value) + are returned too but the scenario will only consume those whose + names appear in ``self._scenario_strategies``. When the registry + has not been initialized yet, the catalog alone is used. + + Subclasses may override to further customize the pool. Returns: dict[str, AttackTechniqueFactory]: Mapping of technique name to factory. @@ -129,7 +149,15 @@ def _get_attack_technique_factories(self) -> dict[str, AttackTechniqueFactory]: build_scenario_technique_factories, ) - return {factory.name: factory for factory in build_scenario_technique_factories()} + catalog = {factory.name: factory for factory in build_scenario_technique_factories()} + try: + registry_overrides = super()._get_attack_technique_factories() + except RuntimeError: + # Registry not initialized yet (e.g. bare CLI parse before + # ScenarioTechniqueInitializer has run). Catalog alone is the + # safe fallback and matches the strategy enum's value set. + registry_overrides = {} + return {**catalog, **registry_overrides} async def _get_atomic_attacks_async(self) -> list[AtomicAttack]: """ @@ -156,10 +184,6 @@ async def _get_atomic_attacks_async(self) -> list[AtomicAttack]: techniques = self._build_techniques_dict(objective_target=self._objective_target) - selector: TechniqueSelector = ( - self._custom_selector if self._custom_selector is not None else EpsilonGreedyTechniqueSelector() - ) - seed_groups_by_dataset = self._dataset_config.get_seed_attack_groups() atomic_attacks: list[AtomicAttack] = [] for dataset_name, seed_groups in seed_groups_by_dataset.items(): @@ -168,7 +192,7 @@ async def _get_atomic_attacks_async(self) -> list[AtomicAttack]: dataset_name=dataset_name, seed_groups=seed_groups, techniques=techniques, - selector=selector, + selector=self._selector, ) ) @@ -222,6 +246,12 @@ def _build_techniques_dict( logger.warning(f"No factory for technique '{technique_name}', skipping.") continue scoring_config = self._build_scoring_config_for_factory(factory=factory) + if scoring_config is None: + required_name = factory.scoring_config_type.__name__ # type: ignore[union-attr] + reason = f"scenario scorer is incompatible with required {required_name}" + skipped_incompatible[technique_name] = reason + logger.warning(f"Skipping technique '{technique_name}': {reason}") + continue try: technique = factory.create( objective_target=objective_target, @@ -253,7 +283,7 @@ def _build_techniques_dict( return techniques - def _build_scoring_config_for_factory(self, *, factory: AttackTechniqueFactory) -> AttackScoringConfig: + def _build_scoring_config_for_factory(self, *, factory: AttackTechniqueFactory) -> AttackScoringConfig | None: """ Build the most specific scoring config the factory's attack class accepts. @@ -261,13 +291,21 @@ def _build_scoring_config_for_factory(self, *, factory: AttackTechniqueFactory) subtype of ``AttackScoringConfig`` (e.g. TAP requires ``TAPAttackScoringConfig``), construct that subtype directly so the factory does not have to fall back to its WARN policy and silently - substitute an internal default scorer. When the subtype itself rejects - the scenario's objective scorer at construction, fall back to the base - ``AttackScoringConfig``; ``_build_techniques_dict`` will then catch the - constructor-time rejection and skip the technique. + substitute an internal default scorer. + + Returns ``None`` when the required subtype itself rejects the + scenario's ``objective_scorer`` (e.g. TAP requires a + ``FloatScaleThresholdScorer`` while the scenario provides a + ``TrueFalseScorer``). ``None`` signals that no scenario-scorer- + preserving config exists for this technique — the caller drops the + technique rather than relying on the factory's override policy to + react (under WARN/SKIP the factory would silently substitute its + internal default scorer, masking the incompatibility). Returns: - AttackScoringConfig: The most specific config that could be built. + AttackScoringConfig | None: The most specific config that could + be built, or ``None`` if the technique is incompatible with + the scenario scorer. """ required = factory.scoring_config_type if required is None or required is AttackScoringConfig: @@ -275,7 +313,7 @@ def _build_scoring_config_for_factory(self, *, factory: AttackTechniqueFactory) try: return required(objective_scorer=self._objective_scorer) except (TypeError, ValueError): - return AttackScoringConfig(objective_scorer=self._objective_scorer) + return None async def _build_atomics_for_dataset( self, @@ -316,13 +354,14 @@ async def _build_atomics_for_dataset( techniques=techniques, selector=selector, objective_scorer=self._objective_scorer, - max_attempts_per_objective=self.params["max_attempts_per_objective"], + max_attempts_per_objective=self.params.get("max_attempts_per_objective", 3), scenario_result_id=self._scenario_result_id, ) atomics: list[AtomicAttack] = [] for seed_group in seed_groups: - if not dispatcher.compatible_techniques(seed_group=seed_group): + compatible = dispatcher.compatible_techniques(seed_group=seed_group) + if not compatible: logger.warning( "AdaptiveScenario: no compatible techniques for seed group in dataset '%s' " "(objective=%r); skipping.", @@ -331,9 +370,9 @@ async def _build_atomics_for_dataset( ) continue - attack = await dispatcher.build_attack_async(seed_group=seed_group) + attack = await dispatcher.build_attack_async(seed_group=seed_group, compatible=compatible) objective_sha = to_sha256(seed_group.objective.value) - atomic_attack_name = f"{self._atomic_attack_prefix}_{dataset_name}::{objective_sha[:12]}" + atomic_attack_name = f"{self._atomic_attack_prefix()}_{dataset_name}::{objective_sha}" atomics.append( AtomicAttack( atomic_attack_name=atomic_attack_name, diff --git a/pyrit/scenario/scenarios/adaptive/dispatcher.py b/pyrit/scenario/scenarios/adaptive/dispatcher.py index f1491b969d..7005e89d76 100644 --- a/pyrit/scenario/scenarios/adaptive/dispatcher.py +++ b/pyrit/scenario/scenarios/adaptive/dispatcher.py @@ -16,16 +16,18 @@ (which technique ran, with what outcome, in what order) is not stamped onto the envelope — every child ``AttackResult`` in ``SequentialAttackResult.child_attack_results`` already carries its own -``outcome`` and its own ``atomic_attack_identifier.eval_hash``, so callers -reconstruct the trail by joining children against -``AdaptiveScenario.technique_names_by_eval_hash``. +``outcome`` and its own ``atomic_attack_identifier.eval_hash``. Callers that +want a human-readable technique label per child read it directly from the +child via ``child.get_attack_strategy_identifier().unique_name`` (the +executor auto-stamps ``class_name`` and ``unique_name`` on every persisted +row), so there is no separate ``{eval_hash: name}`` map to consult. """ from __future__ import annotations import logging from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Any from pyrit.executor.attack.compound.sequential_attack import ( SequenceCompletionPolicy, @@ -70,8 +72,8 @@ class TechniqueBundle: attack: AttackStrategy[Any, AttackResult] name: str = "" - seed_technique: Optional[SeedAttackTechniqueGroup] = None - adversarial_chat: Optional[PromptTarget] = None + seed_technique: SeedAttackTechniqueGroup | None = None + adversarial_chat: PromptTarget | None = None class AdaptiveTechniqueDispatcher: @@ -100,7 +102,7 @@ def __init__( objective_target: PromptTarget, techniques: dict[str, TechniqueBundle], selector: TechniqueSelector, - objective_scorer: Optional[TrueFalseScorer] = None, + objective_scorer: TrueFalseScorer | None = None, max_attempts_per_objective: int = 3, scenario_result_id: str | None = None, ) -> None: @@ -150,7 +152,12 @@ def compatible_techniques(self, *, seed_group: SeedAttackGroup) -> list[str]: if bundle.seed_technique is None or seed_group.is_compatible_with_technique(technique=bundle.seed_technique) ] - async def build_attack_async(self, *, seed_group: SeedAttackGroup) -> SequentialAttack: + async def build_attack_async( + self, + *, + seed_group: SeedAttackGroup, + compatible: list[str] | None = None, + ) -> SequentialAttack: """ Build a ``SequentialAttack`` for one ``SeedAttackGroup``. @@ -164,14 +171,20 @@ async def build_attack_async(self, *, seed_group: SeedAttackGroup) -> Sequential seed_group (SeedAttackGroup): The seed group for the objective this attack will run against. Must carry a non-None objective. + compatible (list[str] | None): Precomputed result of + ``compatible_techniques(seed_group=...)``. When ``None`` + (default) the dispatcher computes it itself. Callers that + already filter empty pools out via ``compatible_techniques`` + should pass the result through to avoid re-scanning the + technique map. Returns: SequentialAttack: The ready-to-run attack. Each child's identity is captured by its own ``atomic_attack_identifier.eval_hash`` after execution; - callers wanting the friendly technique name join those - hashes against - ``AdaptiveScenario.technique_names_by_eval_hash``. + callers wanting the friendly technique name read it + directly from the child via + ``child.get_attack_strategy_identifier().unique_name``. Raises: ValueError: If ``seed_group.objective`` is not initialized, @@ -181,7 +194,8 @@ async def build_attack_async(self, *, seed_group: SeedAttackGroup) -> Sequential if seed_group.objective is None: raise ValueError("seed_group.objective is not initialized") - compatible = self.compatible_techniques(seed_group=seed_group) + if compatible is None: + compatible = self.compatible_techniques(seed_group=seed_group) if not compatible: raise ValueError( f"AdaptiveTechniqueDispatcher: no compatible techniques for seed group " diff --git a/pyrit/scenario/scenarios/adaptive/text_adaptive.py b/pyrit/scenario/scenarios/adaptive/text_adaptive.py index 6fbddf26f7..84c11603e8 100644 --- a/pyrit/scenario/scenarios/adaptive/text_adaptive.py +++ b/pyrit/scenario/scenarios/adaptive/text_adaptive.py @@ -44,6 +44,12 @@ def _build_text_adaptive_strategy() -> type[ScenarioStrategy]: Returns: type[ScenarioStrategy]: The dynamically-built strategy enum class. + + Logs a warning if any name in ``_EXCLUDED_TECHNIQUES`` is not present + in the current catalog. The exclusion is defensive — when the catalog + does not contain the named technique, the filter is a no-op, and we + surface that so a stale entry in the exclusion list (or a renamed + catalog entry) doesn't silently break the intended exclusion. """ # Local import: ``scenario_techniques`` imports ``pyrit.scenario.core``, # which transitively re-imports this module, so a top-level import would @@ -52,9 +58,19 @@ def _build_text_adaptive_strategy() -> type[ScenarioStrategy]: build_scenario_technique_factories, ) - factories = [ - factory for factory in build_scenario_technique_factories() if factory.name not in _EXCLUDED_TECHNIQUES - ] + all_factories = list(build_scenario_technique_factories()) + catalog_names = {factory.name for factory in all_factories} + unmatched = _EXCLUDED_TECHNIQUES - catalog_names + if unmatched: + logger.warning( + "TextAdaptive: _EXCLUDED_TECHNIQUES entries %s are not in the current " + "scenario-techniques catalog %s; the exclusion is a no-op for those entries. " + "Remove stale entries or update the catalog.", + sorted(unmatched), + sorted(catalog_names), + ) + + factories = [factory for factory in all_factories if factory.name not in _EXCLUDED_TECHNIQUES] return AttackTechniqueRegistry.build_strategy_class_from_factories( # type: ignore[return-value, ty:invalid-return-type] class_name="TextAdaptiveStrategy", @@ -78,6 +94,11 @@ class TextAdaptive(AdaptiveScenario): _cached_strategy_class: ClassVar[type[ScenarioStrategy] | None] = None + @classmethod + def _atomic_attack_prefix(cls) -> str: + """Return the prefix for per-objective atomic-attack names.""" + return "adaptive_text" + @classmethod def get_strategy_class(cls) -> type[ScenarioStrategy]: """Return the strategy enum for this scenario, building it once on first access.""" diff --git a/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py b/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py index ac1da354d8..8ffd5aaa4e 100644 --- a/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py +++ b/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py @@ -5,6 +5,7 @@ from __future__ import annotations +import uuid from unittest.mock import MagicMock, patch import pytest @@ -22,7 +23,6 @@ from pyrit.score import TrueFalseScorer _MOCK_MANY_SHOT_EXAMPLES = [{"question": f"q{i}", "answer": f"a{i}"} for i in range(100)] -_FAKE_FACTORY_COUNTER = 0 def _mock_id(name: str) -> ComponentIdentifier: @@ -89,11 +89,11 @@ def _make_fake_factory(*, seed_technique=None, adversarial_chat=None, scoring_co Mocks the surface ``AdaptiveScenario._build_techniques_dict`` consumes (``factory.create(...)``, ``factory.adversarial_chat``, and ``factory.scoring_config_type``). Each call assigns a unique fake - attack identifier so the bundle dict keys (eval hashes) don't collide. + attack identifier (via a fresh UUID) so the bundle dict keys (eval + hashes) don't collide across calls — no shared mutable test state, so + test execution order doesn't shift hash values. """ - global _FAKE_FACTORY_COUNTER - _FAKE_FACTORY_COUNTER += 1 - fake_id = _FAKE_FACTORY_COUNTER + fake_id = uuid.uuid4().hex[:8] fake_technique = MagicMock() fake_attack = MagicMock(name=f"fake-attack-strategy-{fake_id}") @@ -415,6 +415,51 @@ class NarrowScoringConfig(AttackScoringConfig): assert isinstance(passed_config, NarrowScoringConfig) assert passed_config.objective_scorer is mock_objective_scorer + async def test_factory_with_incompatible_narrowed_scoring_config_is_skipped( + self, mock_objective_target, mock_objective_scorer, caplog + ): + """When the narrowed ``attack_scoring_config`` subtype rejects the + scenario's objective scorer, the technique is skipped with a warning + rather than silently falling back to the base config (which could let + a WARN-policy factory substitute its internal default scorer).""" + import logging + + from pyrit.executor.attack import AttackScoringConfig + + class StrictScoringConfig(AttackScoringConfig): + def __init__(self, *, objective_scorer): + raise ValueError("StrictScoringConfig requires FloatScaleThresholdScorer") + + groups = {"violence": [_make_seed_group(value="obj")]} + good_factory = _make_fake_factory() + strict_factory = _make_fake_factory(scoring_config_type=StrictScoringConfig) + + with ( + patch.object(DatasetConfiguration, "get_seed_attack_groups", return_value=groups), + patch.object(SeedAttackGroup, "is_compatible_with_technique", return_value=True), + ): + scenario = TextAdaptive(objective_scorer=mock_objective_scorer) + strategy_class = scenario.get_strategy_class() + factories = {"role_play": good_factory, "tap": strict_factory} + with patch.object(scenario, "_get_attack_technique_factories", return_value=factories): + with caplog.at_level(logging.WARNING): + await scenario.initialize_async( + objective_target=mock_objective_target, + include_baseline=False, + scenario_strategies=[strategy_class("role_play"), strategy_class("tap")], + ) + techniques = scenario._build_techniques_dict(objective_target=mock_objective_target) + + # Strict factory's create is never called — incompatibility surfaces + # before construction, not via the factory's override policy. + strict_factory.create.assert_not_called() + # Only the compatible technique remains in the pool. + technique_names = {b.name for b in techniques.values()} + assert technique_names == {"role_play"} + # The skip reason mentions the required config type so operators can + # diagnose the mismatch. + assert any("tap" in r.getMessage() and "StrictScoringConfig" in r.getMessage() for r in caplog.records) + async def test_factory_create_failure_skips_technique(self, mock_objective_target, mock_objective_scorer, caplog): """A factory whose ``create`` raises ``ValueError`` (e.g. the attack rejects the scenario's objective scorer) is logged and skipped, while From 8e96798451c0db0c1d338518dc27d827a41a88bf Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Tue, 2 Jun 2026 14:58:14 -0400 Subject: [PATCH 27/35] pre-commit --- pyrit/scenario/scenarios/adaptive/adaptive_scenario.py | 6 +++--- pyrit/scenario/scenarios/adaptive/text_adaptive.py | 2 ++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py b/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py index f067803325..0f0cac911f 100644 --- a/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py +++ b/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py @@ -55,8 +55,7 @@ class AdaptiveScenario(Scenario): and dispatcher construction are handled here. """ - #: Scenario version for memory bookkeeping. - VERSION: ClassVar[int] = 1 + VERSION: ClassVar[int] @classmethod @abstractmethod @@ -247,7 +246,8 @@ def _build_techniques_dict( continue scoring_config = self._build_scoring_config_for_factory(factory=factory) if scoring_config is None: - required_name = factory.scoring_config_type.__name__ # type: ignore[union-attr] + required_type = factory.scoring_config_type + required_name = required_type.__name__ if required_type is not None else "AttackScoringConfig" reason = f"scenario scorer is incompatible with required {required_name}" skipped_incompatible[technique_name] = reason logger.warning(f"Skipping technique '{technique_name}': {reason}") diff --git a/pyrit/scenario/scenarios/adaptive/text_adaptive.py b/pyrit/scenario/scenarios/adaptive/text_adaptive.py index 84c11603e8..be55c53f04 100644 --- a/pyrit/scenario/scenarios/adaptive/text_adaptive.py +++ b/pyrit/scenario/scenarios/adaptive/text_adaptive.py @@ -94,6 +94,8 @@ class TextAdaptive(AdaptiveScenario): _cached_strategy_class: ClassVar[type[ScenarioStrategy] | None] = None + VERSION: int = 1 + @classmethod def _atomic_attack_prefix(cls) -> str: """Return the prefix for per-objective atomic-attack names.""" From c0a2dabea02ff7429606ff65c604c10d9d99e415 Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Tue, 2 Jun 2026 15:49:47 -0400 Subject: [PATCH 28/35] bug fix --- pyrit/scenario/scenarios/adaptive/adaptive_scenario.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py b/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py index 0f0cac911f..5cf65fb559 100644 --- a/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py +++ b/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py @@ -26,6 +26,7 @@ from pyrit.scenario.core.atomic_attack import AtomicAttack from pyrit.scenario.core.attack_technique import AttackTechnique from pyrit.scenario.core.scenario import Scenario +from pyrit.scenario.core.scenario_target_defaults import get_default_adversarial_target from pyrit.scenario.scenarios.adaptive.dispatcher import ( AdaptiveTechniqueDispatcher, TechniqueBundle, @@ -262,11 +263,14 @@ def _build_techniques_dict( logger.warning(f"Skipping technique '{technique_name}': {type(exc).__name__}: {exc}") continue eval_hash = compute_inner_attack_eval_hash(attack=technique.attack) + adversarial_chat = factory.adversarial_chat + if adversarial_chat is None and factory.uses_adversarial: + adversarial_chat = get_default_adversarial_target() techniques[eval_hash] = TechniqueBundle( attack=technique.attack, name=technique_name, seed_technique=technique.seed_technique, - adversarial_chat=factory.adversarial_chat, + adversarial_chat=adversarial_chat, ) if not techniques: From 7cd5aa744b940d85abfed29dfadf0592f847a4a4 Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Tue, 2 Jun 2026 15:54:02 -0400 Subject: [PATCH 29/35] clean up output --- doc/code/scenarios/3_adaptive_scenarios.ipynb | 438 +++++++++++++++++- doc/code/scenarios/3_adaptive_scenarios.py | 10 +- 2 files changed, 427 insertions(+), 21 deletions(-) diff --git a/doc/code/scenarios/3_adaptive_scenarios.ipynb b/doc/code/scenarios/3_adaptive_scenarios.ipynb index a924b8481d..ea595685a8 100644 --- a/doc/code/scenarios/3_adaptive_scenarios.ipynb +++ b/doc/code/scenarios/3_adaptive_scenarios.ipynb @@ -47,10 +47,43 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 1, "id": "2", "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_49587/458917033.py:5: DeprecationWarning: pyrit.scenario.printer.console_printer.ConsoleScenarioResultPrinter is deprecated and will be removed in 0.16.0. Use pyrit.output.scenario_result.pretty.PrettyScenarioResultMemoryPrinter instead.\n", + " from pyrit.scenario.printer.console_printer import ConsoleScenarioResultPrinter\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Found default environment files: ['/home/vscode/.pyrit/.env']\n", + "Loaded environment file: /home/vscode/.pyrit/.env\n", + "No new upgrade operations detected.\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Skipping target 'platform_openai_chat': PLATFORM_OPENAI_CHAT_GPT4O_MODEL is not set. All declared env vars (endpoint, key, model) must be present for this target to register.\n", + "Skipping target 'azure_foundry_phi4': AZURE_FOUNDRY_PHI4_MODEL is not set. All declared env vars (endpoint, key, model) must be present for this target to register.\n", + "Skipping scorer main: required target not found in TargetRegistry\n", + "TextAdaptive: _EXCLUDED_TECHNIQUES entries ['prompt_sending'] are not in the current scenario-techniques catalog ['context_compliance', 'crescendo_history_lecture', 'crescendo_journalist_interview', 'crescendo_movie_director', 'crescendo_simulated', 'many_shot', 'pair', 'red_teaming', 'role_play', 'tap']; the exclusion is a no-op for those entries. Remove stale entries or update the catalog.\n", + "TargetRegistry entry 'adversarial_chat' not found. Falling back to default OpenAIChatTarget.\n", + "TargetRegistry entry 'objective_scorer_chat' not found. Falling back to default OpenAIChatTarget.\n", + "TargetRegistry entry 'adversarial_chat' not found. Falling back to default OpenAIChatTarget.\n", + "TargetRegistry entry 'adversarial_chat' not found. Falling back to default OpenAIChatTarget.\n", + "Loading datasets - this can take a few minutes: 100%|██████████| 82/82 [00:03<00:00, 20.87dataset/s]\n" + ] + } + ], "source": [ "from pathlib import Path\n", "\n", @@ -79,10 +112,123 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "id": "4", "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "TargetRegistry entry 'adversarial_chat' not found. Falling back to default OpenAIChatTarget.\n", + "TargetRegistry entry 'adversarial_chat' not found. Falling back to default OpenAIChatTarget.\n", + "/tmp/ipykernel_49587/3812235746.py:3: DeprecationWarning: Implicit baseline injection for TextAdaptive._get_atomic_attacks_async() is deprecated and will be removed in 0.16.0. Use explicit emission via self._build_baseline_atomic_attack(seed_groups=...) in the override instead.\n", + " await scenario.initialize_async( # type: ignore\n" + ] + }, + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "58a5ce9c44f24d3ab737073e6ecd2430", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "Executing TextAdaptive: 0%| | 0/22 [00:00 str:\n", " \"\"\"Display name for the attack strategy that produced ``result``.\"\"\"\n", " attack_id = result.get_attack_strategy_identifier()\n", - " return attack_id.unique_name if attack_id else \"\"\n", + " return attack_id.class_name if attack_id else \"\"\n", "\n", "\n", "total_picks: Counter[str] = Counter()\n", @@ -279,7 +668,7 @@ "print(\"\\n=== Overall ===\")\n", "print(\"Technique wins / picks rate\")\n", "for technique, n in total_picks.most_common():\n", - " print(f\"{technique:40s} {total_wins[technique]:>4} / {n:<4} {total_wins[technique] / n:.0%}\")" + " print(f\"{technique:40s} {total_wins[technique]:>4} / {n:<4} {total_wins[technique] / n:.0%}\")\n" ] }, { @@ -311,6 +700,23 @@ "metadata": { "jupytext": { "main_language": "python" + }, + "kernelspec": { + "display_name": "pyrit", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.15" } }, "nbformat": 4, diff --git a/doc/code/scenarios/3_adaptive_scenarios.py b/doc/code/scenarios/3_adaptive_scenarios.py index 2b77baeaa1..1f838009f0 100644 --- a/doc/code/scenarios/3_adaptive_scenarios.py +++ b/doc/code/scenarios/3_adaptive_scenarios.py @@ -150,10 +150,10 @@ # Walk the children via the envelope's `child_attack_result_ids` (joined # against the flat results list), then read each child's attack strategy # identifier with `child.get_attack_strategy_identifier()`. The returned -# `ComponentIdentifier` exposes `class_name` (e.g. `"CrescendoAttack"`) and -# `unique_name` (e.g. `"CrescendoAttack::a1b2c3d4"`), which uniquely -# distinguishes two factories that wrap the same attack class with different -# configurations. +# `ComponentIdentifier` exposes `class_name` (e.g. `"CrescendoAttack"`) for a +# human-readable label, and `unique_name` (e.g. `"CrescendoAttack::a1b2c3d4"`) +# when you need to distinguish two factories that wrap the same attack class +# with different configurations. # # Use `result.get_display_groups()` to aggregate `attack_results` by the # per-dataset display label set by the scenario. @@ -175,7 +175,7 @@ def _technique_label(result) -> str: """Display name for the attack strategy that produced ``result``.""" attack_id = result.get_attack_strategy_identifier() - return attack_id.unique_name if attack_id else "" + return attack_id.class_name if attack_id else "" total_picks: Counter[str] = Counter() From e56bc73efff4ce67eb39f2cd8bd025082fefeacc Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Tue, 2 Jun 2026 16:36:56 -0400 Subject: [PATCH 30/35] fix ruff --- doc/code/scenarios/3_adaptive_scenarios.ipynb | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/doc/code/scenarios/3_adaptive_scenarios.ipynb b/doc/code/scenarios/3_adaptive_scenarios.ipynb index ea595685a8..257633d878 100644 --- a/doc/code/scenarios/3_adaptive_scenarios.ipynb +++ b/doc/code/scenarios/3_adaptive_scenarios.ipynb @@ -47,7 +47,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "id": "2", "metadata": {}, "outputs": [ @@ -63,8 +63,8 @@ "name": "stdout", "output_type": "stream", "text": [ - "Found default environment files: ['/home/vscode/.pyrit/.env']\n", - "Loaded environment file: /home/vscode/.pyrit/.env\n", + "Found default environment files: ['./.pyrit/.env']\n", + "Loaded environment file: ./.pyrit/.env\n", "No new upgrade operations detected.\n" ] }, @@ -79,8 +79,7 @@ "TargetRegistry entry 'adversarial_chat' not found. Falling back to default OpenAIChatTarget.\n", "TargetRegistry entry 'objective_scorer_chat' not found. Falling back to default OpenAIChatTarget.\n", "TargetRegistry entry 'adversarial_chat' not found. Falling back to default OpenAIChatTarget.\n", - "TargetRegistry entry 'adversarial_chat' not found. Falling back to default OpenAIChatTarget.\n", - "Loading datasets - this can take a few minutes: 100%|██████████| 82/82 [00:03<00:00, 20.87dataset/s]\n" + "TargetRegistry entry 'adversarial_chat' not found. Falling back to default OpenAIChatTarget.\n" ] } ], @@ -112,7 +111,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "id": "4", "metadata": {}, "outputs": [ @@ -260,7 +259,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "id": "6", "metadata": {}, "outputs": [ @@ -412,7 +411,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "id": "8", "metadata": {}, "outputs": [ @@ -701,11 +700,6 @@ "jupytext": { "main_language": "python" }, - "kernelspec": { - "display_name": "pyrit", - "language": "python", - "name": "python3" - }, "language_info": { "codemirror_mode": { "name": "ipython", From c7cb4e1880cc5996ee2911ce6d14c1cd58f2aeb2 Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Tue, 2 Jun 2026 16:49:33 -0400 Subject: [PATCH 31/35] add default clause --- pyrit/scenario/scenarios/adaptive/text_adaptive.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyrit/scenario/scenarios/adaptive/text_adaptive.py b/pyrit/scenario/scenarios/adaptive/text_adaptive.py index be55c53f04..1be215a536 100644 --- a/pyrit/scenario/scenarios/adaptive/text_adaptive.py +++ b/pyrit/scenario/scenarios/adaptive/text_adaptive.py @@ -143,7 +143,7 @@ def supported_parameters(cls) -> list[Parameter]: return [ Parameter( name="max_attempts_per_objective", - description="Max techniques tried per objective.", + description="Max techniques tried per objective. Defaults to 3.", param_type=int, default=3, ), From 62b4e9dafc80d2d9b6c97fb7af970aa3c3ed84e9 Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Tue, 2 Jun 2026 18:14:45 -0400 Subject: [PATCH 32/35] pr comments and pre commit --- doc/code/scenarios/3_adaptive_scenarios.ipynb | 2 +- .../scenarios/adaptive/adaptive_scenario.py | 11 ++- .../adaptive/selectors/epsilon_greedy.py | 20 ++++-- .../scenarios/adaptive/test_dispatcher.py | 68 +++++++++++++++++++ .../scenarios/adaptive/test_text_adaptive.py | 20 ++++++ 5 files changed, 115 insertions(+), 6 deletions(-) diff --git a/doc/code/scenarios/3_adaptive_scenarios.ipynb b/doc/code/scenarios/3_adaptive_scenarios.ipynb index 257633d878..87d02f1e70 100644 --- a/doc/code/scenarios/3_adaptive_scenarios.ipynb +++ b/doc/code/scenarios/3_adaptive_scenarios.ipynb @@ -667,7 +667,7 @@ "print(\"\\n=== Overall ===\")\n", "print(\"Technique wins / picks rate\")\n", "for technique, n in total_picks.most_common():\n", - " print(f\"{technique:40s} {total_wins[technique]:>4} / {n:<4} {total_wins[technique] / n:.0%}\")\n" + " print(f\"{technique:40s} {total_wins[technique]:>4} / {n:<4} {total_wins[technique] / n:.0%}\")" ] }, { diff --git a/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py b/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py index 5cf65fb559..1fe8397e56 100644 --- a/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py +++ b/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py @@ -171,9 +171,14 @@ async def _get_atomic_attacks_async(self) -> list[AtomicAttack]: accumulates globally; selection is committed up-front during scenario initialization, before any execution starts. + When ``self._include_baseline`` is true (the default under + ``BASELINE_ATTACK_POLICY = Enabled``), a baseline ``AtomicAttack`` + named ``"baseline"`` is prepended at index 0. + Returns: list[AtomicAttack]: One ``AtomicAttack`` per compatible - seed group across all datasets. + seed group across all datasets, with the baseline (when + enabled) prepended at index 0. Raises: ValueError: If ``self._objective_target`` is not set, or if @@ -196,6 +201,10 @@ async def _get_atomic_attacks_async(self) -> list[AtomicAttack]: ) ) + if self._include_baseline: + all_seed_groups = [g for groups in seed_groups_by_dataset.values() for g in groups] + atomic_attacks.insert(0, self._build_baseline_atomic_attack(seed_groups=all_seed_groups)) + return atomic_attacks def _build_techniques_dict( diff --git a/pyrit/scenario/scenarios/adaptive/selectors/epsilon_greedy.py b/pyrit/scenario/scenarios/adaptive/selectors/epsilon_greedy.py index dd91af7d5c..a415182733 100644 --- a/pyrit/scenario/scenarios/adaptive/selectors/epsilon_greedy.py +++ b/pyrit/scenario/scenarios/adaptive/selectors/epsilon_greedy.py @@ -50,7 +50,14 @@ class EpsilonGreedyTechniqueSelector: All outcome data comes from the memory database via ``_compute_success_rates``. Calling ``select_async`` with the same arguments produces the same result (deterministic given memory - contents and ``random_seed``). + contents, ``random_seed``, and ``scenario_result_id``). + + When ``random_seed`` is set, the per-decision RNG is also keyed on the + ``scenario_result_id`` argument so that two distinct scenario runs over + the same objective explore differently while a resume (which reuses the + same ``scenario_result_id``) reproduces the original picks. When + ``random_seed`` is ``None``, the RNG is unseeded and naturally diverges + across calls regardless of arguments. """ _TIE_TOL: float = 1e-12 @@ -97,8 +104,11 @@ async def select_async( objective (str): The objective text for scoping the per-decision RNG. num_top_techniques (int): Max techniques to return. Defaults to 1. scenario_result_id (str | None): The current scenario run ID, supplied - by the dispatcher. Forwarded to memory only when the configured - ``scope.current_run_only`` is ``True``. Defaults to ``None``. + by the dispatcher. Folded into the per-decision RNG key so distinct + runs diverge while resumes (same ``scenario_result_id``) reproduce + the original picks; also forwarded to memory only when the + configured ``scope.current_run_only`` is ``True``. Defaults to + ``None``. Returns: Sequence[str]: Techniques in priority order. Fewer than @@ -113,7 +123,9 @@ async def select_async( num_top_techniques = min(num_top_techniques, len(technique_list)) - decision_key = objective + # Fold scenario_result_id into the RNG key so two different scenario runs + # over the same objective explore differently. + decision_key = f"{objective}|{scenario_result_id or ''}" rng = _derive_rng(self._seed, decision_key) effective_run_id = scenario_result_id if self._scope.current_run_only else None diff --git a/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py b/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py index cd92c8abda..0ab722f224 100644 --- a/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py +++ b/tests/unit/scenario/scenarios/adaptive/test_dispatcher.py @@ -195,3 +195,71 @@ async def test_merges_seed_technique_into_child_seed_group(self, target): # The merged seed group is forwarded to the child attack. outer_sg.with_technique.assert_called_once_with(technique=seed_technique) assert attack._child_attacks[0].seed_group is merged_sg + + +@pytest.mark.usefixtures("patch_central_database") +class TestEvalHashRoundTrip: + """ + Pin the load-bearing invariant that ``compute_inner_attack_eval_hash`` + (used by ``AdaptiveScenario._build_techniques_dict`` to key the + ``techniques`` dict and by the selector to look up historical stats) + equals the ``eval_hash`` the executor stamps on persisted child rows. + + If the prediction helper and the write path ever drift (e.g. a new + field is added to the eval-hash rule on one side only), the selector + silently reads zero history for every technique and epsilon-greedy + degrades to random with no error. This test runs a real + ``PromptSendingAttack`` through the dispatcher's ``SequentialAttack`` + end-to-end and asserts the round-trip holds. + """ + + async def test_predicted_hash_matches_persisted_row(self, sqlite_instance): + from pyrit.executor.attack.single_turn.prompt_sending import PromptSendingAttack + from pyrit.memory.memory_models import AttackResultEntry + from pyrit.models import SeedAttackGroup, SeedObjective + from pyrit.models.identifiers import compute_inner_attack_eval_hash + from tests.unit.mocks import MockPromptTarget + + live_target = MockPromptTarget() + attack = PromptSendingAttack(objective_target=live_target) + predicted_hash = compute_inner_attack_eval_hash(attack=attack) + + bundles = {predicted_hash: TechniqueBundle(attack=attack, name="prompt_sending")} + dispatcher = AdaptiveTechniqueDispatcher( + objective_target=live_target, + techniques=bundles, + selector=_StubSelector(technique_order=[predicted_hash]), + max_attempts_per_objective=1, + ) + + sg = SeedAttackGroup(seeds=[SeedObjective(value="say hello")]) + sequential = await dispatcher.build_attack_async(seed_group=sg) + await sequential.execute_async(objective="say hello") + + with sqlite_instance.get_session() as session: + rows = session.query(AttackResultEntry).all() + + # Drill into the persisted envelope to find rows whose inner attack is PromptSendingAttack, + # then assert the eval_hash on those rows matches what the selector predicted. + matching_rows = [ + r + for r in rows + if r.atomic_attack_identifier + and r.atomic_attack_identifier.get("children", {}) + .get("attack_technique", {}) + .get("children", {}) + .get("attack", {}) + .get("class_name") + == "PromptSendingAttack" + ] + assert matching_rows, ( + f"Expected at least one persisted row whose inner attack is PromptSendingAttack; " + f"found rows: {[(r.id, r.atomic_attack_identifier) for r in rows]}" + ) + for row in matching_rows: + stamped_hash = row.atomic_attack_identifier["eval_hash"] + assert stamped_hash == predicted_hash, ( + f"Selector-side eval_hash ({predicted_hash}) drifted from executor-stamped " + f"eval_hash ({stamped_hash}) on persisted row {row.id}. " + f"compute_inner_attack_eval_hash and build_atomic_attack_identifier must agree." + ) diff --git a/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py b/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py index 8ffd5aaa4e..848851ae0a 100644 --- a/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py +++ b/tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py @@ -6,6 +6,7 @@ from __future__ import annotations import uuid +import warnings from unittest.mock import MagicMock, patch import pytest @@ -531,3 +532,22 @@ async def test_initialize_async_accepts_explicit_baseline(self, mock_objective_t objective_target=mock_objective_target, include_baseline=True, ) + + async def test_baseline_emitted_at_index_zero_by_default(self, mock_objective_target, mock_objective_scorer): + """ + Under ``BASELINE_ATTACK_POLICY = Enabled`` (the default), the override + must explicitly prepend a baseline atomic at index 0 so the legacy + deprecation-rescue branch in ``Scenario.initialize_async`` (slated + for removal in 0.16.0) is bypassed and no DeprecationWarning fires. + """ + groups = {"violence": [_make_seed_group(value="obj", harm_categories=["violence"])]} + with patch.object(DatasetConfiguration, "get_seed_attack_groups", return_value=groups): + scenario = TextAdaptive(objective_scorer=mock_objective_scorer) + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + await scenario.initialize_async(objective_target=mock_objective_target) + + assert scenario._atomic_attacks, "expected at least one atomic attack" + assert scenario._atomic_attacks[0].atomic_attack_name == "baseline", ( + f"baseline must be prepended at index 0; got {[a.atomic_attack_name for a in scenario._atomic_attacks]}" + ) From 09be122b4b9b63f9831475d5be85bce982d54c20 Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Wed, 3 Jun 2026 11:37:04 -0400 Subject: [PATCH 33/35] fix async --- pyrit/scenario/scenarios/adaptive/adaptive_scenario.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py b/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py index 1fe8397e56..081bfca989 100644 --- a/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py +++ b/pyrit/scenario/scenarios/adaptive/adaptive_scenario.py @@ -193,7 +193,7 @@ async def _get_atomic_attacks_async(self) -> list[AtomicAttack]: atomic_attacks: list[AtomicAttack] = [] for dataset_name, seed_groups in seed_groups_by_dataset.items(): atomic_attacks.extend( - await self._build_atomics_for_dataset( + await self._build_atomics_for_dataset_async( dataset_name=dataset_name, seed_groups=seed_groups, techniques=techniques, @@ -328,7 +328,7 @@ def _build_scoring_config_for_factory(self, *, factory: AttackTechniqueFactory) except (TypeError, ValueError): return None - async def _build_atomics_for_dataset( + async def _build_atomics_for_dataset_async( self, *, dataset_name: str, From 16e29778699253c6a81de0f8d28dc9b4eb36c128 Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Wed, 3 Jun 2026 12:39:01 -0400 Subject: [PATCH 34/35] remove extra tests --- .../identifiers/test_evaluation_identifier.py | 71 ------------------- 1 file changed, 71 deletions(-) diff --git a/tests/unit/models/identifiers/test_evaluation_identifier.py b/tests/unit/models/identifiers/test_evaluation_identifier.py index d43b488f65..46a6898fed 100644 --- a/tests/unit/models/identifiers/test_evaluation_identifier.py +++ b/tests/unit/models/identifiers/test_evaluation_identifier.py @@ -866,74 +866,3 @@ def test_stored_eval_hash_takes_precedence(self): ).with_eval_hash(stored) assert ObjectiveTargetEvaluationIdentifier(cid).eval_hash == stored - - -class TestComputeInnerAttackEvalHash: - """``compute_inner_attack_eval_hash`` should match what the executor stamps.""" - - def _attack_with_identifier(self, identifier: ComponentIdentifier): - from unittest.mock import MagicMock - - attack = MagicMock() - attack.get_identifier.return_value = identifier - return attack - - def test_matches_manual_two_step_composition(self): - """Helper equals the executor recipe (build_atomic_attack_identifier + AtomicAttackEvaluationIdentifier).""" - from pyrit.models.identifiers import ( - AtomicAttackEvaluationIdentifier, - build_atomic_attack_identifier, - compute_inner_attack_eval_hash, - ) - - inner_id = ComponentIdentifier( - class_name="PromptSendingAttack", - class_module="pyrit.executor.attack.single_turn.prompt_sending", - ) - attack = self._attack_with_identifier(inner_id) - - expected = AtomicAttackEvaluationIdentifier( - build_atomic_attack_identifier(attack_identifier=inner_id), - ).eval_hash - assert compute_inner_attack_eval_hash(attack=attack) == expected - - def test_differs_when_attack_class_differs(self): - from pyrit.models.identifiers import compute_inner_attack_eval_hash - - a = self._attack_with_identifier( - ComponentIdentifier(class_name="A", class_module="m"), - ) - b = self._attack_with_identifier( - ComponentIdentifier(class_name="B", class_module="m"), - ) - assert compute_inner_attack_eval_hash(attack=a) != compute_inner_attack_eval_hash(attack=b) - - def test_stable_across_calls_for_same_attack(self): - from pyrit.models.identifiers import compute_inner_attack_eval_hash - - attack = self._attack_with_identifier( - ComponentIdentifier(class_name="Same", class_module="m"), - ) - assert compute_inner_attack_eval_hash(attack=attack) == compute_inner_attack_eval_hash(attack=attack) - - def test_matches_persisted_row_eval_hash(self): - """Whatever the helper returns, persisting an attack result with the same - identifier must yield an entry with the same eval_hash.""" - from pyrit.memory.memory_models import AttackResultEntry - from pyrit.models import AttackResult - from pyrit.models.identifiers import build_atomic_attack_identifier, compute_inner_attack_eval_hash - - inner_id = ComponentIdentifier( - class_name="MyAttack", - class_module="pyrit.attacks", - ) - attack = self._attack_with_identifier(inner_id) - predicted = compute_inner_attack_eval_hash(attack=attack) - - result = AttackResult( - conversation_id="conv_1", - objective="o", - atomic_attack_identifier=build_atomic_attack_identifier(attack_identifier=inner_id), - ) - entry = AttackResultEntry(entry=result) - assert entry.atomic_attack_identifier["eval_hash"] == predicted From 9c9640b320bc997699a8c0667d69e8b0f2073be6 Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Wed, 3 Jun 2026 14:40:50 -0400 Subject: [PATCH 35/35] add clarify about pool --- doc/code/scenarios/3_adaptive_scenarios.ipynb | 6 +++++- doc/code/scenarios/3_adaptive_scenarios.py | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/doc/code/scenarios/3_adaptive_scenarios.ipynb b/doc/code/scenarios/3_adaptive_scenarios.ipynb index 87d02f1e70..026c4e083f 100644 --- a/doc/code/scenarios/3_adaptive_scenarios.ipynb +++ b/doc/code/scenarios/3_adaptive_scenarios.ipynb @@ -554,7 +554,11 @@ "with different configurations.\n", "\n", "Use `result.get_display_groups()` to aggregate `attack_results` by the\n", - "per-dataset display label set by the scenario.\n" + "per-dataset display label set by the scenario.\n", + "\n", + "If the trail of attacks attempted is shorter than `max_attempts_per_objective`,\n", + "the compatible-technique pool for that seed group was smaller than the cap —\n", + "the run exhausted the pool." ] }, { diff --git a/doc/code/scenarios/3_adaptive_scenarios.py b/doc/code/scenarios/3_adaptive_scenarios.py index 1f838009f0..6239a24077 100644 --- a/doc/code/scenarios/3_adaptive_scenarios.py +++ b/doc/code/scenarios/3_adaptive_scenarios.py @@ -157,6 +157,10 @@ # # Use `result.get_display_groups()` to aggregate `attack_results` by the # per-dataset display label set by the scenario. +# +# If the trail of attacks attempted is shorter than `max_attempts_per_objective`, +# the compatible-technique pool for that seed group was smaller than the cap — +# the run exhausted the pool. # %% from collections import Counter