Skip to content

Commit d1aafe1

Browse files
rlundeen2Copilot
andauthored
MAINT: Simplifying building atomic attacks (#2107)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent a11d279 commit d1aafe1

18 files changed

Lines changed: 1313 additions & 431 deletions
Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
3+
4+
"""
5+
Reusable matrix builder for scenario atomic attacks.
6+
7+
``MatrixAtomicAttackBuilder`` turns a technique × dataset (× optional adversarial
8+
target) grid into a flat list of ``AtomicAttack`` instances. It centralizes the
9+
seed-technique compatibility filtering, ``factory.create`` wiring, ``AtomicAttack``
10+
construction, and baseline emission for scenarios whose attacks form such a
11+
cross-product.
12+
13+
This is one member of a *family* of construction helpers named by shape
14+
(``Matrix...``); scenarios whose construction is composite or per-objective build
15+
``AtomicAttack`` lists differently. There is intentionally no shared builder
16+
interface yet — each scenario calls the builder it needs directly.
17+
"""
18+
19+
from __future__ import annotations
20+
21+
import logging
22+
from dataclasses import dataclass
23+
from typing import TYPE_CHECKING, cast
24+
25+
from pyrit.executor.attack import AttackScoringConfig
26+
from pyrit.executor.attack.single_turn.prompt_sending import PromptSendingAttack
27+
from pyrit.models import SeedAttackGroup
28+
from pyrit.scenario.core.atomic_attack import AtomicAttack
29+
from pyrit.scenario.core.attack_technique import AttackTechnique
30+
31+
if TYPE_CHECKING:
32+
from collections.abc import Callable, Sequence
33+
34+
from pyrit.prompt_target import PromptTarget
35+
from pyrit.scenario.core.attack_technique_factory import AttackTechniqueFactory
36+
from pyrit.score import Scorer
37+
from pyrit.score.true_false.true_false_scorer import TrueFalseScorer
38+
39+
logger = logging.getLogger(__name__)
40+
41+
42+
@dataclass(frozen=True)
43+
class MatrixCombo:
44+
"""
45+
One cell of the build matrix, passed to the ``name_fn``/``display_group_fn`` callbacks.
46+
47+
Attributes:
48+
technique_name (str): The technique (strategy enum value) for this cell.
49+
dataset_name (str): The dataset key from ``DatasetAttackConfiguration.get_attack_groups_by_dataset_async()``.
50+
target_name (str | None): The adversarial-target registry name when an
51+
adversarial-target axis is in play, else ``None``.
52+
"""
53+
54+
technique_name: str
55+
dataset_name: str
56+
target_name: str | None = None
57+
58+
59+
def _default_atomic_attack_name(combo: MatrixCombo) -> str:
60+
"""
61+
Default ``atomic_attack_name`` builder; target-aware so names stay unique.
62+
63+
Args:
64+
combo (MatrixCombo): The matrix cell being named.
65+
66+
Returns:
67+
str: The atomic attack name for the cell.
68+
"""
69+
if combo.target_name is None:
70+
return f"{combo.technique_name}_{combo.dataset_name}"
71+
return f"{combo.technique_name}__{combo.target_name}_{combo.dataset_name}"
72+
73+
74+
def _default_display_group(combo: MatrixCombo) -> str:
75+
"""
76+
Build the default display group: aggregate results by technique.
77+
78+
Args:
79+
combo (MatrixCombo): The matrix cell being grouped.
80+
81+
Returns:
82+
str: The display group for the cell.
83+
"""
84+
return combo.technique_name
85+
86+
87+
def build_baseline_atomic_attack(
88+
*,
89+
objective_target: PromptTarget,
90+
objective_scorer: Scorer,
91+
seed_groups: list[SeedAttackGroup],
92+
memory_labels: dict[str, str] | None = None,
93+
) -> AtomicAttack:
94+
"""
95+
Build the baseline ``AtomicAttack`` that sends each objective unmodified.
96+
97+
The baseline is a plain ``PromptSendingAttack`` used as a comparison point against
98+
a scenario's strategy attacks. Pass the *same* ``seed_groups`` used to build the
99+
strategy attacks so both populations match — re-resolving under ``max_dataset_size``
100+
would draw a fresh random sample and diverge from the strategy population.
101+
102+
Args:
103+
objective_target (PromptTarget): The target to attack.
104+
objective_scorer (Scorer): The scorer used to evaluate the baseline.
105+
seed_groups (list[SeedAttackGroup]): Seed groups to attack. Used as-is.
106+
memory_labels (dict[str, str] | None): Labels applied to the baseline's prompts.
107+
108+
Returns:
109+
AtomicAttack: The baseline atomic attack named ``"baseline"``.
110+
"""
111+
attack = PromptSendingAttack(
112+
objective_target=objective_target,
113+
attack_scoring_config=AttackScoringConfig(objective_scorer=cast("TrueFalseScorer", objective_scorer)),
114+
)
115+
return AtomicAttack(
116+
atomic_attack_name="baseline",
117+
attack_technique=AttackTechnique(attack=attack),
118+
seed_groups=seed_groups,
119+
memory_labels=memory_labels or {},
120+
)
121+
122+
123+
class MatrixAtomicAttackBuilder:
124+
"""
125+
Build ``AtomicAttack`` instances from a technique × dataset (× target) cross-product.
126+
127+
Construct once with the shared run inputs (target, scorer, labels), then call
128+
``build`` with the per-run grid. The builder owns:
129+
130+
- seed-technique compatibility filtering (``SeedAttackGroup.filter_compatible``),
131+
- the ``factory.create(...)`` call, forwarding an adversarial target when the
132+
adversarial-target axis is active,
133+
- ``AtomicAttack`` construction with naming and display-group stamping, and
134+
- optional baseline emission using the same resolved seed groups.
135+
136+
Example:
137+
>>> builder = MatrixAtomicAttackBuilder(
138+
... objective_target=target,
139+
... objective_scorer=scorer,
140+
... memory_labels=labels,
141+
... )
142+
>>> attacks = builder.build(
143+
... technique_factories=factories,
144+
... dataset_groups=groups,
145+
... include_baseline=True,
146+
... )
147+
"""
148+
149+
def __init__(
150+
self,
151+
*,
152+
objective_target: PromptTarget,
153+
objective_scorer: Scorer,
154+
memory_labels: dict[str, str] | None = None,
155+
) -> None:
156+
"""
157+
Initialize the builder with inputs shared across every atomic attack it produces.
158+
159+
Args:
160+
objective_target (PromptTarget): The target system to attack.
161+
objective_scorer (Scorer): The scorer applied to each produced atomic attack
162+
and to the baseline.
163+
memory_labels (dict[str, str] | None): Labels applied to every produced
164+
atomic attack.
165+
"""
166+
self._objective_target = objective_target
167+
self._objective_scorer = objective_scorer
168+
self._memory_labels = memory_labels or {}
169+
170+
def build(
171+
self,
172+
*,
173+
technique_factories: dict[str, AttackTechniqueFactory],
174+
dataset_groups: dict[str, list[SeedAttackGroup]],
175+
adversarial_targets: Sequence[tuple[str, PromptTarget]] | None = None,
176+
name_fn: Callable[[MatrixCombo], str] | None = None,
177+
display_group_fn: Callable[[MatrixCombo], str] | None = None,
178+
include_baseline: bool = False,
179+
) -> list[AtomicAttack]:
180+
"""
181+
Build the atomic attacks for the given grid.
182+
183+
Iterates technique → (adversarial target) → dataset. The caller pre-resolves
184+
``technique_factories`` to exactly the techniques to build (and, by dict
185+
insertion order, the order to build them in), so the builder does not need the
186+
full registry or the selected-strategy set.
187+
188+
Args:
189+
technique_factories (dict[str, AttackTechniqueFactory]): Mapping of technique
190+
name to the factory that produces it. Only these techniques are built.
191+
dataset_groups (dict[str, list[SeedAttackGroup]]): Mapping of dataset name to
192+
its seed groups (e.g. ``await DatasetAttackConfiguration.get_attack_groups_by_dataset_async()``).
193+
adversarial_targets (Sequence[tuple[str, PromptTarget]] | None): Optional
194+
``(name, instance)`` pairs adding an adversarial-target axis. When set,
195+
each technique is swept across every target and the target instance is
196+
forwarded to ``factory.create(adversarial_chat=...)``. When ``None``, the
197+
axis is collapsed and each factory uses its own (possibly lazy)
198+
adversarial target.
199+
name_fn (Callable[[MatrixCombo], str] | None): Builds each ``atomic_attack_name``.
200+
Defaults to ``"{technique}_{dataset}"`` (or ``"{technique}__{target}_{dataset}"``
201+
when an adversarial-target axis is active).
202+
display_group_fn (Callable[[MatrixCombo], str] | None): Builds each
203+
``display_group``. Defaults to grouping by technique name.
204+
include_baseline (bool): When ``True``, prepend a baseline atomic attack built
205+
from the flattened seed groups across all datasets.
206+
207+
Returns:
208+
list[AtomicAttack]: The generated atomic attacks, baseline first when requested.
209+
"""
210+
name_fn = name_fn or _default_atomic_attack_name
211+
display_group_fn = display_group_fn or _default_display_group
212+
213+
scoring_config = AttackScoringConfig(objective_scorer=cast("TrueFalseScorer", self._objective_scorer))
214+
215+
target_axis: Sequence[tuple[str | None, PromptTarget | None]] = (
216+
list(adversarial_targets) if adversarial_targets else [(None, None)]
217+
)
218+
219+
atomic_attacks: list[AtomicAttack] = []
220+
for technique_name, factory in technique_factories.items():
221+
for target_name, target_instance in target_axis:
222+
for dataset_name, seed_groups in dataset_groups.items():
223+
compatible_groups = self._filter_compatible_groups(
224+
factory=factory,
225+
seed_groups=seed_groups,
226+
technique_name=technique_name,
227+
dataset_name=dataset_name,
228+
)
229+
if compatible_groups is None:
230+
continue
231+
232+
create_adversarial = {"adversarial_chat": target_instance} if target_instance is not None else {}
233+
attack_technique = factory.create(
234+
objective_target=self._objective_target,
235+
attack_scoring_config=scoring_config,
236+
**create_adversarial,
237+
)
238+
239+
combo = MatrixCombo(
240+
technique_name=technique_name,
241+
dataset_name=dataset_name,
242+
target_name=target_name,
243+
)
244+
atomic_attacks.append(
245+
AtomicAttack(
246+
atomic_attack_name=name_fn(combo),
247+
attack_technique=attack_technique,
248+
seed_groups=compatible_groups,
249+
adversarial_chat=(
250+
target_instance if target_instance is not None else factory.adversarial_chat
251+
),
252+
objective_scorer=cast("TrueFalseScorer", self._objective_scorer),
253+
memory_labels=self._memory_labels,
254+
display_group=display_group_fn(combo),
255+
)
256+
)
257+
258+
if include_baseline:
259+
all_seed_groups = [group for groups in dataset_groups.values() for group in groups]
260+
atomic_attacks.insert(
261+
0,
262+
build_baseline_atomic_attack(
263+
objective_target=self._objective_target,
264+
objective_scorer=self._objective_scorer,
265+
seed_groups=all_seed_groups,
266+
memory_labels=self._memory_labels,
267+
),
268+
)
269+
270+
return atomic_attacks
271+
272+
def _filter_compatible_groups(
273+
self,
274+
*,
275+
factory: AttackTechniqueFactory,
276+
seed_groups: list[SeedAttackGroup],
277+
technique_name: str,
278+
dataset_name: str,
279+
) -> list[SeedAttackGroup] | None:
280+
"""
281+
Filter seed groups to those compatible with the factory's seed technique.
282+
283+
Args:
284+
factory (AttackTechniqueFactory): The factory whose ``seed_technique`` gates
285+
compatibility.
286+
seed_groups (list[SeedAttackGroup]): Candidate seed groups for one dataset.
287+
technique_name (str): Technique name, used only for log messages.
288+
dataset_name (str): Dataset name, used only for log messages.
289+
290+
Returns:
291+
list[SeedAttackGroup] | None: The compatible groups, or ``None`` when the
292+
``(technique, dataset)`` pair has no compatible groups and should be skipped.
293+
"""
294+
if factory.seed_technique is None:
295+
return list(seed_groups)
296+
297+
compatible_groups = SeedAttackGroup.filter_compatible(
298+
seed_groups=seed_groups,
299+
technique=factory.seed_technique,
300+
)
301+
skipped = len(seed_groups) - len(compatible_groups)
302+
if skipped:
303+
logger.info(
304+
f"Skipped {skipped} seed group(s) from '{dataset_name}' for technique "
305+
f"'{technique_name}' (prompt sequences overlap with simulated conversation)."
306+
)
307+
if not compatible_groups:
308+
logger.warning(
309+
f"No compatible seed groups in '{dataset_name}' for technique "
310+
f"'{technique_name}', skipping this (technique, dataset) pair."
311+
)
312+
return None
313+
return compatible_groups

0 commit comments

Comments
 (0)