Skip to content

Commit 66178ec

Browse files
authored
Merge branch 'main' into hawestra/scenario_blog
2 parents 6f2c0e3 + 1670bbd commit 66178ec

46 files changed

Lines changed: 454 additions & 1011 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/instructions/scenarios.instructions.md

Lines changed: 71 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,9 @@ For scenarios whose strategy enum is built dynamically (RapidResponse pattern),
3939
strategy class in a module-level `@cache`-decorated function and pass the result through
4040
the constructor — no classmethod indirection required.
4141

42-
4. **Optionally override `_get_atomic_attacks_async()`**the base class provides a default
43-
that uses the factory/registry pattern (see "AtomicAttack Construction" below).
44-
Only override if your scenario needs custom attack construction logic.
42+
4. **Implement `_build_atomic_attacks_async(self, *, context)`**this is the single
43+
abstract extension point every scenario must define (see "AtomicAttack Construction" below).
44+
Matrix-shaped scenarios delegate to `build_matrix_atomic_attacks(context=...)` in one line.
4545

4646
## Constructor Pattern
4747

@@ -58,7 +58,7 @@ def __init__(
5858
if not objective_scorer:
5959
objective_scorer = self._get_default_scorer()
6060

61-
# 2. Store config objects for _get_atomic_attacks_async
61+
# 2. Store config objects for _build_atomic_attacks_async
6262
self._scorer_config = AttackScoringConfig(objective_scorer=objective_scorer)
6363

6464
# 3. Call super().__init__ — required args: version, strategy_class, objective_scorer
@@ -80,7 +80,7 @@ Requirements:
8080
may opt into a one-release grace period via the class attribute
8181
`_brick_legacy_init = True`, which downgrades the error to a
8282
`DeprecationWarning(removed_in="0.16.0")`. The opt-out is removed in 0.16.0.
83-
- **All constructor parameters must be optional** (default to `None`) so the registry can instantiate the scenario with no arguments for metadata introspection. Defer required-input validation to `initialize_async()` or `_get_atomic_attacks_async()`. `ScenarioRegistry._build_metadata` raises `TypeError` if `scenario_class()` cannot be called with no arguments.
83+
- **All constructor parameters must be optional** (default to `None`) so the registry can instantiate the scenario with no arguments for metadata introspection. Defer required-input validation to `initialize_async()` or `_build_atomic_attacks_async()`. `ScenarioRegistry._build_metadata` raises `TypeError` if `scenario_class()` cannot be called with no arguments.
8484
- `super().__init__()` called with `version`, `strategy_class`, `default_strategy`, `default_dataset_config`, `objective_scorer`
8585
- complex objects like `adversarial_chat` or `objective_scorer` should be passed into the constructor.
8686

@@ -134,39 +134,70 @@ class MyStrategy(ScenarioStrategy):
134134
- Each member: `NAME = ("string_value", {tag_set})`
135135
- Aggregates expand to all strategies matching their tag
136136

137-
### `_build_display_group()`Result Grouping
137+
### Result grouping (`display_group`)
138138

139-
Override `_build_display_group()` on the `Scenario` base class to control how attack results are grouped for display:
139+
`display_group` controls how attack results are aggregated for display. It is set per
140+
`AtomicAttack` at construction time — there is no `_build_display_group` hook. When you build
141+
via `build_matrix_atomic_attacks`/`MatrixAtomicAttackBuilder`, pass a `display_group_fn`
142+
callback that maps each `MatrixCombo` to a group string:
140143

141144
```python
142-
def _build_display_group(self, *, technique_name: str, seed_group_name: str) -> str:
143-
# Default: group by technique name (most common)
144-
return technique_name
145-
146-
# Override examples:
147-
# Group by dataset/harm category: return seed_group_name
148-
# Cross-product: return f"{technique_name}_{seed_group_name}"
145+
build_matrix_atomic_attacks(
146+
context=context,
147+
objective_scorer=self._objective_scorer,
148+
display_group_fn=lambda combo: combo.technique_name, # default: group by technique
149+
# Group by dataset/harm category: lambda combo: combo.dataset_name
150+
# Cross-product: lambda combo: f"{combo.technique_name}_{combo.dataset_name}"
151+
)
149152
```
150153

151154
Note: `atomic_attack_name` must remain unique per `AtomicAttack` for correct resume behaviour.
152155
`display_group` controls user-facing aggregation only.
153156

154-
## AtomicAttack Construction — Default Base Class Behaviour
157+
## AtomicAttack Construction — `_build_atomic_attacks_async(context)`
158+
159+
Every scenario implements the single abstract extension point:
160+
161+
```python
162+
async def _build_atomic_attacks_async(self, *, context: ScenarioContext) -> list[AtomicAttack]:
163+
...
164+
```
165+
166+
`initialize_async` resolves the run's inputs once (objective target, strategies, dataset
167+
config, memory labels, baseline flag, and seed groups), snapshots them into an immutable
168+
`ScenarioContext`, calls this method, and then inserts the baseline centrally. Scenario authors
169+
never read half-initialized `self._*` state to build attacks — read everything from `context`.
155170

156-
The `Scenario` base class provides a default `_get_atomic_attacks_async()` that uses the
157-
factory/registry pattern. Scenarios that register their techniques via `_get_attack_technique_factories()`
158-
get atomic-attack construction **for free** — no override needed.
171+
### Zero-boilerplate matrix scenarios
159172

160-
The default implementation:
161-
1. Calls `self._get_attack_technique_factories()` to get name→factory mapping
162-
(defaults to reading every `AttackTechniqueFactory` registered in the
163-
`AttackTechniqueRegistry` singleton)
164-
2. Iterates over every (technique × dataset) pair from `self._dataset_config`
165-
3. Calls `factory.create()` with `objective_target` and conditional scorer override
166-
(also forwards any per-technique converters from `self._strategy_converters`, populated
167-
from the CLI `--strategies <technique>:converter.<name>` modifier, as `extra_request_converters`)
168-
4. Uses `self._build_display_group()` for user-facing grouping
169-
5. Builds `AtomicAttack` with unique `atomic_attack_name` = `"{technique}_{dataset}"`
173+
Scenarios whose construction is the plain technique × dataset cross-product delegate to the
174+
`build_matrix_atomic_attacks` helper in one line (see `Cyber`, `RapidResponse`):
175+
176+
```python
177+
from pyrit.scenario.core.matrix_atomic_attack_builder import build_matrix_atomic_attacks
178+
179+
async def _build_atomic_attacks_async(self, *, context: ScenarioContext) -> list[AtomicAttack]:
180+
return build_matrix_atomic_attacks(
181+
context=context,
182+
objective_scorer=self._objective_scorer,
183+
strategy_converters=self._strategy_converters, # optional CLI converter stacks
184+
)
185+
```
186+
187+
`build_matrix_atomic_attacks`:
188+
1. Calls `resolve_technique_factories(context=context)` to map the selected strategies to their
189+
registered `AttackTechniqueFactory` instances (reads the `AttackTechniqueRegistry` singleton;
190+
strategies with no registered factory are dropped).
191+
2. Iterates every (technique × dataset) pair from `context.seed_groups_by_dataset`.
192+
3. Calls `factory.create()` with the objective target, conditional scorer override, and any
193+
per-technique converters (from `--strategies <technique>:converter.<name>`) as
194+
`extra_request_converters`.
195+
4. Builds each `AtomicAttack` with a unique `atomic_attack_name` and a `display_group`
196+
(customizable via `display_group_fn`).
197+
198+
Scenarios needing extra axes (adversarial targets, caching, converter stacks) call
199+
`MatrixAtomicAttackBuilder` directly; scenarios whose construction is composite or
200+
per-objective build the `AtomicAttack` list themselves (see "Manual AtomicAttack construction").
170201

171202
### AttackTechniqueFactory
172203

@@ -213,29 +244,28 @@ by the registry. Tests that exercise scenarios should reset both `AttackTechniqu
213244
and `TargetRegistry` and re-register a mock `adversarial_chat` so the catalog builder
214245
resolves without falling back to `OpenAIChatTarget`.
215246

216-
### Customization hooks (no need to override `_get_atomic_attacks_async`):
217-
- **`_get_attack_technique_factories()`** — override to add/remove/replace factories
218-
- **`_build_display_group()`** — override to change grouping (default: by technique)
219-
220-
### When to override `_get_atomic_attacks_async`:
221-
Only override when the scenario **cannot** use the factory/registry pattern — e.g., scenarios
222-
with custom composite logic, per-strategy converter stacks, or non-standard attack construction.
247+
### Baseline
223248

224-
Overrides that want baseline support must emit it themselves by calling `self._build_baseline_atomic_attack(seed_groups=...)` with the same seeds used for the strategy attacks and prepending the result. The base implementation emits baseline automatically; passing freshly resolved seeds reintroduces ADO 9012 (baseline-vs-strategy population divergence under `max_dataset_size`).
249+
The baseline (a `PromptSendingAttack` over the run's seeds) is inserted **centrally** by
250+
`Scenario.initialize_async` according to the scenario's `BASELINE_ATTACK_POLICY` class var and
251+
the runtime `include_baseline` flag. `_build_atomic_attacks_async` must **never** prepend its own
252+
baseline — doing so double-emits it and reintroduces baseline-vs-strategy population divergence
253+
under `max_dataset_size`.
225254

226-
### Manual AtomicAttack construction (for overrides):
255+
### Manual AtomicAttack construction:
227256

228257
```python
229258
AtomicAttack(
230-
atomic_attack_name=strategy_name, # groups related attacks
259+
atomic_attack_name=strategy_name, # must be unique per AtomicAttack
231260
attack=attack_instance, # AttackStrategy implementation
232261
seed_groups=list(seed_groups), # must be non-empty
233-
memory_labels=self._memory_labels, # from base class
262+
memory_labels=context.memory_labels, # from the context snapshot
234263
)
235264
```
236265

237266
- `seed_groups` must be non-empty — validate before constructing
238-
- `self._objective_target` is only available after `initialize_async()` — don't access in `__init__`
267+
- Read runtime inputs from `context`, not `self._*``self._objective_target` and
268+
`self._scenario_strategies` are only populated after `initialize_async()`
239269
- Pass `memory_labels` to every AtomicAttack
240270

241271
## Exports
@@ -248,4 +278,4 @@ New scenarios must be registered in `pyrit/scenario/__init__.py` as virtual pack
248278
- Forgetting `@apply_defaults` on `__init__`
249279
- Empty `seed_groups` passed to `AtomicAttack`
250280
- Missing `VERSION` class constant
251-
- Missing `_async` suffix on `_get_atomic_attacks_async`
281+
- Missing `_async` suffix on `_build_atomic_attacks_async`

doc/code/scenarios/0_scenarios.ipynb

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,12 @@
5757
" - Each enum member represents an **attack technique** (the *how* of an attack)\n",
5858
" - Each member is defined as `(value, tags)` where value is a string and tags is a set of strings\n",
5959
" - Include an `ALL` aggregate strategy that expands to all available strategies\n",
60-
" - Optionally override `_prepare_strategies()` for custom composition logic (see `FoundryComposite`)\n",
6160
"\n",
6261
"2. **Scenario Class**: Extend `Scenario` and pass these to `super().__init__()`:\n",
6362
" - `strategy_class`: Your strategy enum class\n",
6463
" - `default_strategy`: The default strategy (typically `YourStrategy.ALL` or `YourStrategy.DEFAULT`)\n",
65-
" - The base class provides a default `_get_atomic_attacks_async()` that uses the factory/registry\n",
66-
" pattern. Override it only if your scenario needs custom attack construction logic.\n",
64+
" - Implement `_build_atomic_attacks_async(context)` the single abstract extension point.\n",
65+
" Matrix-shaped scenarios delegate to `build_matrix_atomic_attacks(context=...)` in one line.\n",
6766
"\n",
6867
"3. **Default Dataset**: Pass `default_dataset_config=` to `super().__init__()` to specify the datasets your scenario uses out of the box.\n",
6968
" - Returns a `DatasetConfiguration` with one or more named datasets (e.g., `DatasetConfiguration(dataset_names=[\"my_dataset\"])`)\n",
@@ -89,9 +88,10 @@
8988
"\n",
9089
"### Example Structure\n",
9190
"\n",
92-
"The simplest approach uses the **factory/registry pattern**: define your strategy,\n",
93-
"dataset config, and constructor — the base class handles building atomic attacks\n",
94-
"automatically from registered attack techniques."
91+
"The construction path: define your strategy, dataset config, and constructor, then\n",
92+
"implement `_build_atomic_attacks_async(context)`. Matrix-shaped scenarios delegate to the\n",
93+
"`build_matrix_atomic_attacks` helper, which builds atomic attacks automatically from the\n",
94+
"registered attack techniques."
9595
]
9696
},
9797
{
@@ -124,6 +124,7 @@
124124
" Scenario,\n",
125125
" ScenarioStrategy,\n",
126126
")\n",
127+
"from pyrit.scenario.core.matrix_atomic_attack_builder import build_matrix_atomic_attacks\n",
127128
"from pyrit.score.true_false.true_false_scorer import TrueFalseScorer\n",
128129
"from pyrit.setup import initialize_pyrit_async\n",
129130
"from pyrit.setup.initializers.components import ScenarioTechniqueInitializer\n",
@@ -166,14 +167,15 @@
166167
" scenario_result_id=scenario_result_id,\n",
167168
" )\n",
168169
"\n",
169-
" # Optional: override _build_display_group to customize result grouping.\n",
170-
" # Default groups by technique name; override to group by dataset instead:\n",
171-
" def _build_display_group(self, *, technique_name: str, seed_group_name: str) -> str:\n",
172-
" return seed_group_name\n",
173-
"\n",
174-
" # No _get_atomic_attacks_async override needed!\n",
175-
" # The base class builds attacks from the (technique x dataset) cross-product\n",
176-
" # using the factory/registry pattern automatically."
170+
" # Implement the single abstract extension point. Matrix-shaped scenarios delegate\n",
171+
" # to build_matrix_atomic_attacks; pass display_group_fn to customize result grouping\n",
172+
" # (default groups by technique; here we group by dataset instead).\n",
173+
" async def _build_atomic_attacks_async(self, *, context):\n",
174+
" return build_matrix_atomic_attacks(\n",
175+
" context=context,\n",
176+
" objective_scorer=self._objective_scorer,\n",
177+
" display_group_fn=lambda combo: combo.dataset_name,\n",
178+
" )"
177179
]
178180
},
179181
{
@@ -337,7 +339,7 @@
337339
" ``TargetRegistry`` — typically by ``TargetInitializer`` from\n",
338340
" ``ADVERSARIAL_CHAT_*`` env vars, or programmatically via\n",
339341
" ``TargetRegistry.get_registry_singleton().instances.register``. At run\n",
340-
" time, ``_get_atomic_attacks_async`` performs the ``(technique ×\n",
342+
" time, ``_build_atomic_attacks_async`` performs the ``(technique ×\n",
341343
" adversarial_target × dataset)`` cross-product: for each selected\n",
342344
" adversarial-capable ``core`` factory in the ``AttackTechniqueRegistry``\n",
343345
" and each requested target, it calls\n",

doc/code/scenarios/0_scenarios.py

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -59,13 +59,12 @@
5959
# - Each enum member represents an **attack technique** (the *how* of an attack)
6060
# - Each member is defined as `(value, tags)` where value is a string and tags is a set of strings
6161
# - Include an `ALL` aggregate strategy that expands to all available strategies
62-
# - Optionally override `_prepare_strategies()` for custom composition logic (see `FoundryComposite`)
6362
#
6463
# 2. **Scenario Class**: Extend `Scenario` and pass these to `super().__init__()`:
6564
# - `strategy_class`: Your strategy enum class
6665
# - `default_strategy`: The default strategy (typically `YourStrategy.ALL` or `YourStrategy.DEFAULT`)
67-
# - The base class provides a default `_get_atomic_attacks_async()` that uses the factory/registry
68-
# pattern. Override it only if your scenario needs custom attack construction logic.
66+
# - Implement `_build_atomic_attacks_async(context)` the single abstract extension point.
67+
# Matrix-shaped scenarios delegate to `build_matrix_atomic_attacks(context=...)` in one line.
6968
#
7069
# 3. **Default Dataset**: Pass `default_dataset_config=` to `super().__init__()` to specify the datasets your scenario uses out of the box.
7170
# - Returns a `DatasetConfiguration` with one or more named datasets (e.g., `DatasetConfiguration(dataset_names=["my_dataset"])`)
@@ -91,9 +90,10 @@
9190
#
9291
# ### Example Structure
9392
#
94-
# The simplest approach uses the **factory/registry pattern**: define your strategy,
95-
# dataset config, and constructor — the base class handles building atomic attacks
96-
# automatically from registered attack techniques.
93+
# The construction path: define your strategy, dataset config, and constructor, then
94+
# implement `_build_atomic_attacks_async(context)`. Matrix-shaped scenarios delegate to the
95+
# `build_matrix_atomic_attacks` helper, which builds atomic attacks automatically from the
96+
# registered attack techniques.
9797
# %%
9898

9999
from pyrit.common import apply_defaults
@@ -102,6 +102,7 @@
102102
Scenario,
103103
ScenarioStrategy,
104104
)
105+
from pyrit.scenario.core.matrix_atomic_attack_builder import build_matrix_atomic_attacks
105106
from pyrit.score.true_false.true_false_scorer import TrueFalseScorer
106107
from pyrit.setup import initialize_pyrit_async
107108
from pyrit.setup.initializers.components import ScenarioTechniqueInitializer
@@ -144,14 +145,15 @@ def __init__(
144145
scenario_result_id=scenario_result_id,
145146
)
146147

147-
# Optional: override _build_display_group to customize result grouping.
148-
# Default groups by technique name; override to group by dataset instead:
149-
def _build_display_group(self, *, technique_name: str, seed_group_name: str) -> str:
150-
return seed_group_name
151-
152-
# No _get_atomic_attacks_async override needed!
153-
# The base class builds attacks from the (technique x dataset) cross-product
154-
# using the factory/registry pattern automatically.
148+
# Implement the single abstract extension point. Matrix-shaped scenarios delegate
149+
# to build_matrix_atomic_attacks; pass display_group_fn to customize result grouping
150+
# (default groups by technique; here we group by dataset instead).
151+
async def _build_atomic_attacks_async(self, *, context):
152+
return build_matrix_atomic_attacks(
153+
context=context,
154+
objective_scorer=self._objective_scorer,
155+
display_group_fn=lambda combo: combo.dataset_name,
156+
)
155157

156158

157159
# %% [markdown]

doc/scanner/1_pyrit_scan.ipynb

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -242,9 +242,10 @@
242242
" )\n",
243243
" # ... your scenario-specific initialization code\n",
244244
"\n",
245-
" async def _get_atomic_attacks_async(self):\n",
246-
" # Override only if your scenario needs custom attack construction.\n",
247-
" # The base class provides a default that uses the factory/registry pattern.\n",
245+
" async def _build_atomic_attacks_async(self, *, context):\n",
246+
" # The single abstract extension point every scenario implements.\n",
247+
" # Read runtime inputs from `context`; return the list of AtomicAttack to run.\n",
248+
" # Matrix-shaped scenarios can delegate to build_matrix_atomic_attacks(context=...).\n",
248249
" # Example: create attacks for each strategy composite\n",
249250
" return []\n",
250251
"\n",

doc/scanner/1_pyrit_scan.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -180,9 +180,10 @@ def __init__(self, *, scenario_result_id=None, **kwargs):
180180
)
181181
# ... your scenario-specific initialization code
182182

183-
async def _get_atomic_attacks_async(self):
184-
# Override only if your scenario needs custom attack construction.
185-
# The base class provides a default that uses the factory/registry pattern.
183+
async def _build_atomic_attacks_async(self, *, context):
184+
# The single abstract extension point every scenario implements.
185+
# Read runtime inputs from `context`; return the list of AtomicAttack to run.
186+
# Matrix-shaped scenarios can delegate to build_matrix_atomic_attacks(context=...).
186187
# Example: create attacks for each strategy composite
187188
return []
188189

0 commit comments

Comments
 (0)