Skip to content

Commit 519b709

Browse files
hannahwestra25hannahwestra25Copilothannahwestra25
authored
FEAT text adaptive scenario (#1760)
Co-authored-by: hannahwestra25 <hannahwestra@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: hannahwestra25 <hannahwestra@github.com>
1 parent d50caf0 commit 519b709

32 files changed

Lines changed: 3778 additions & 29 deletions

doc/code/scenarios/3_adaptive_scenarios.ipynb

Lines changed: 722 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
# ---
2+
# jupyter:
3+
# jupytext:
4+
# text_representation:
5+
# extension: .py
6+
# format_name: percent
7+
# format_version: '1.3'
8+
# jupytext_version: 1.18.1
9+
# ---
10+
11+
# %% [markdown]
12+
# # Adaptive Scenarios
13+
#
14+
# An **adaptive scenario** doesn't run every attack technique against every objective.
15+
# Instead, it picks which technique to try next per-objective, learns from what worked,
16+
# and stops as soon as one technique succeeds. This concentrates spend on techniques
17+
# that actually work on your target.
18+
#
19+
# ## How it works (high level)
20+
#
21+
# For each objective, the scenario tries up to `max_attempts_per_objective` techniques:
22+
#
23+
# - With probability `epsilon`, it **explores** — picks a random technique.
24+
# - Otherwise it **exploits** — picks the technique with the highest observed success
25+
# rate so far.
26+
# - It records the outcome and stops early on success.
27+
#
28+
# Unseen techniques are tried first, so the first few objectives effectively round-robin
29+
# through every technique before the scenario settles on the best performers.
30+
#
31+
# ## Adaptive vs. static scenarios
32+
#
33+
# | Feature | Static scenarios | Adaptive scenarios |
34+
# |---------------------|-----------------------------------|------------------------------------|
35+
# | Technique selection | Run every selected technique | Pick per-objective from outcomes |
36+
# | Early stopping | No | Yes — stops on first success |
37+
# | Cost | O(techniques × objectives) | O(max_attempts × objectives) |
38+
#
39+
# `AdaptiveScenario` is the modality-agnostic base class.
40+
# [`TextAdaptive`](../../../pyrit/scenario/scenarios/adaptive/text_adaptive.py) is the
41+
# text subclass used in the examples below.
42+
43+
# %% [markdown]
44+
# ## Setup
45+
46+
# %%
47+
from pathlib import Path
48+
49+
from pyrit.registry import TargetRegistry
50+
from pyrit.scenario import DatasetConfiguration
51+
from pyrit.scenario.printer.console_printer import ConsoleScenarioResultPrinter
52+
from pyrit.scenario.scenarios.adaptive import TextAdaptive
53+
from pyrit.setup import initialize_from_config_async
54+
55+
await initialize_from_config_async(config_path=Path("../../scanner/pyrit_conf.yaml")) # type: ignore
56+
57+
objective_target = TargetRegistry.get_registry_singleton().get_instance_by_name("openai_chat")
58+
printer = ConsoleScenarioResultPrinter()
59+
60+
# %% [markdown]
61+
# ## Basic usage
62+
#
63+
# Defaults: `max_attempts_per_objective=3`, epsilon-greedy selector with `epsilon=0.2`,
64+
# the subclass's default datasets.
65+
66+
# %%
67+
scenario = TextAdaptive()
68+
69+
await scenario.initialize_async( # type: ignore
70+
objective_target=objective_target,
71+
)
72+
result = await scenario.run_async() # type: ignore
73+
await printer.write_async(result) # type: ignore
74+
75+
# %% [markdown]
76+
# ## Configuring a run
77+
#
78+
# - **`max_attempts_per_objective`** — caps techniques tried per objective. Higher means
79+
# more chances to succeed and more API calls. Set via `set_params_from_args`.
80+
# - **`selector`** — a pre-built `TechniqueSelector` instance. Pass an
81+
# `EpsilonGreedyTechniqueSelector(epsilon=..., random_seed=...)`
82+
# to tune the selection algorithm. Defaults to an epsilon-greedy selector with
83+
# `epsilon=0.2`.
84+
# - **`scenario_strategies`** (on `initialize_async`) — restricts which techniques the
85+
# selector can pick from. Use `TextAdaptive.get_strategy_class()` to access the enum.
86+
#
87+
# The cell below exercises all of them at once.
88+
89+
# %%
90+
from pyrit.scenario.scenarios.adaptive import EpsilonGreedyTechniqueSelector
91+
92+
strategy_class = TextAdaptive.get_strategy_class()
93+
94+
configured_scenario = TextAdaptive(
95+
selector=EpsilonGreedyTechniqueSelector(
96+
epsilon=0.3,
97+
random_seed=42,
98+
),
99+
)
100+
configured_scenario.set_params_from_args(args={"max_attempts_per_objective": 5})
101+
102+
await configured_scenario.initialize_async( # type: ignore
103+
objective_target=objective_target,
104+
scenario_strategies=[strategy_class("single_turn")],
105+
dataset_config=DatasetConfiguration(
106+
dataset_names=["airt_hate", "airt_violence"],
107+
max_dataset_size=4,
108+
),
109+
)
110+
configured_result = await configured_scenario.run_async() # type: ignore
111+
await printer.write_async(configured_result) # type: ignore
112+
113+
# %% [markdown]
114+
# ## Resuming a run
115+
#
116+
# Adaptive scenarios are resumable — pass `scenario_result_id=...` to the `TextAdaptive`
117+
# constructor and the run picks up where it left off. Resume must use the same
118+
# configuration as the original run.
119+
120+
# %%
121+
resumed_scenario = TextAdaptive(
122+
selector=EpsilonGreedyTechniqueSelector(
123+
epsilon=0.3,
124+
random_seed=42,
125+
),
126+
scenario_result_id=str(configured_result.id),
127+
)
128+
resumed_scenario.set_params_from_args(args={"max_attempts_per_objective": 5})
129+
130+
await resumed_scenario.initialize_async( # type: ignore
131+
objective_target=objective_target,
132+
scenario_strategies=[strategy_class("single_turn")],
133+
dataset_config=DatasetConfiguration(
134+
dataset_names=["airt_hate", "airt_violence"],
135+
max_dataset_size=4,
136+
),
137+
)
138+
resumed_result = await resumed_scenario.run_async() # type: ignore
139+
await printer.write_async(resumed_result) # type: ignore
140+
141+
# %% [markdown]
142+
# ## Inspecting which techniques were tried
143+
#
144+
# Every adaptive run persists both the per-objective envelope (a
145+
# `SequentialAttackResult`) AND its per-attempt child rows. Each child row
146+
# carries its own `atomic_attack_identifier`, so the persisted data alone is
147+
# enough to reconstruct the per-attempt trail — no envelope-side metadata, no
148+
# scenario-side lookup tables needed.
149+
#
150+
# Walk the children via the envelope's `child_attack_result_ids` (joined
151+
# against the flat results list), then read each child's attack strategy
152+
# identifier with `child.get_attack_strategy_identifier()`. The returned
153+
# `ComponentIdentifier` exposes `class_name` (e.g. `"CrescendoAttack"`) for a
154+
# human-readable label, and `unique_name` (e.g. `"CrescendoAttack::a1b2c3d4"`)
155+
# when you need to distinguish two factories that wrap the same attack class
156+
# with different configurations.
157+
#
158+
# Use `result.get_display_groups()` to aggregate `attack_results` by the
159+
# per-dataset display label set by the scenario.
160+
#
161+
# If the trail of attacks attempted is shorter than `max_attempts_per_objective`,
162+
# the compatible-technique pool for that seed group was smaller than the cap —
163+
# the run exhausted the pool.
164+
165+
# %%
166+
from collections import Counter
167+
168+
# Per-group: one line per objective (the envelope) showing the per-attempt
169+
# trail, plus a per-technique success-rate table within the group. The child
170+
# rows that compose each envelope are filtered out of the per-objective list so
171+
# it stays one line per objective. Aggregate across groups for a grand-total.
172+
display_groups = resumed_result.get_display_groups()
173+
174+
# Flatten every persisted row across every group so we can look up a child
175+
# AttackResult by its attack_result_id when reconstructing per-envelope trails.
176+
results_by_id = {r.attack_result_id: r for results in display_groups.values() for r in results}
177+
178+
179+
def _technique_label(result) -> str:
180+
"""Display name for the attack strategy that produced ``result``."""
181+
attack_id = result.get_attack_strategy_identifier()
182+
return attack_id.class_name if attack_id else "<unknown>"
183+
184+
185+
total_picks: Counter[str] = Counter()
186+
total_wins: Counter[str] = Counter()
187+
188+
for group_name, results in display_groups.items():
189+
print(f"\n=== Group: {group_name} ===")
190+
191+
# Collect every child id referenced by any envelope in this group so we
192+
# can skip the per-attempt child rows when printing per-objective lines.
193+
# Baseline rows have no envelope and pass through untouched.
194+
child_ids: set[str] = set()
195+
for r in results:
196+
child_ids.update(r.metadata.get("child_attack_result_ids", []) or [])
197+
198+
for r in results:
199+
if r.attack_result_id in child_ids:
200+
continue
201+
child_id_list = r.metadata.get("child_attack_result_ids", []) or []
202+
trail_parts: list[str] = []
203+
for child_id in child_id_list:
204+
child = results_by_id.get(child_id)
205+
if child is None:
206+
continue
207+
trail_parts.append(f"{_technique_label(child)}({child.outcome.value})")
208+
trail = " → ".join(trail_parts)
209+
print(f" [{r.outcome.value:7s}] {r.objective!r}: {trail}")
210+
211+
picks: Counter[str] = Counter()
212+
wins: Counter[str] = Counter()
213+
for r in results:
214+
if r.attack_result_id not in child_ids:
215+
continue
216+
technique = _technique_label(r)
217+
picks[technique] += 1
218+
total_picks[technique] += 1
219+
if r.outcome.value == "success":
220+
wins[technique] += 1
221+
total_wins[technique] += 1
222+
223+
print("\n Technique wins / picks rate")
224+
for technique, n in picks.most_common():
225+
print(f" {technique:40s} {wins[technique]:>4} / {n:<4} {wins[technique] / n:.0%}")
226+
227+
print("\n=== Overall ===")
228+
print("Technique wins / picks rate")
229+
for technique, n in total_picks.most_common():
230+
print(f"{technique:40s} {total_wins[technique]:>4} / {n:<4} {total_wins[technique] / n:.0%}")
231+
232+
# %% [markdown]
233+
# ## Running from the scanner CLI
234+
#
235+
# You can run `TextAdaptive` directly from the `pyrit_scan` CLI without writing Python:
236+
#
237+
# ```bash
238+
# # Basic run with defaults
239+
# pyrit_scan --scenario TextAdaptive --target openai_chat
240+
#
241+
# # Tune max attempts and restrict strategies
242+
# pyrit_scan --scenario TextAdaptive --target openai_chat \
243+
# --params max_attempts_per_objective=5 \
244+
# --strategies single_turn
245+
#
246+
# # Use specific datasets and limit size
247+
# pyrit_scan --scenario TextAdaptive --target openai_chat \
248+
# --datasets airt_hate airt_violence \
249+
# --max-dataset-size 10
250+
# ```

doc/myst.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ project:
190190
children:
191191
- file: code/scenarios/1_common_scenario_parameters.ipynb
192192
- file: code/scenarios/2_custom_scenario_parameters.ipynb
193+
- file: code/scenarios/3_adaptive_scenarios.ipynb
193194
- file: code/registry/0_registry.md
194195
children:
195196
- file: code/registry/1_class_registry.ipynb
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
3+
4+
"""Scenario-level analytics: technique success rates and related helpers."""
5+
6+
from __future__ import annotations
7+
8+
from typing import TYPE_CHECKING
9+
10+
from pyrit.analytics.result_analysis import AttackStats, _compute_stats
11+
from pyrit.memory import CentralMemory
12+
from pyrit.models import AttackOutcome
13+
14+
if TYPE_CHECKING:
15+
from collections.abc import Sequence
16+
17+
from pyrit.memory.memory_interface import MemoryInterface
18+
19+
20+
def compute_technique_stats(
21+
*,
22+
technique_eval_hashes: Sequence[str],
23+
scenario_result_id: str | None = None,
24+
targeted_harm_categories: Sequence[str] | None = None,
25+
memory: MemoryInterface | None = None,
26+
) -> dict[str, AttackStats]:
27+
"""
28+
Compute per-technique outcome statistics from persisted attack results.
29+
30+
Queries memory for all ``AttackResult`` rows whose
31+
``atomic_attack_identifier.eval_hash`` matches one of
32+
``technique_eval_hashes``, then aggregates outcomes into per-technique
33+
``AttackStats``. The eval hash is auto-stamped on every persisted result
34+
by ``AtomicAttackEvaluationIdentifier`` and is the canonical primitive
35+
for behavioral-equivalence aggregation (seeds excluded, scorer excluded,
36+
only behavior-relevant target params included).
37+
38+
Args:
39+
technique_eval_hashes (Sequence[str]): Eval hashes to aggregate.
40+
Returned dict is keyed by these.
41+
scenario_result_id (str | None): Restrict to a single scenario run.
42+
Defaults to ``None`` (aggregate across all runs).
43+
targeted_harm_categories (Sequence[str] | None): Restrict to results
44+
whose prompts targeted these harm categories. Defaults to ``None``.
45+
memory (MemoryInterface | None): Memory backend to query. Defaults to
46+
``CentralMemory.get_memory_instance()``.
47+
48+
Returns:
49+
dict[str, AttackStats]: Stats per technique eval hash. Hashes with no
50+
historical results are omitted from the result.
51+
"""
52+
if not technique_eval_hashes:
53+
return {}
54+
55+
if memory is None:
56+
memory = CentralMemory.get_memory_instance()
57+
results = memory.get_attack_results(
58+
atomic_attack_eval_hashes=list(technique_eval_hashes),
59+
scenario_result_id=scenario_result_id,
60+
targeted_harm_categories=targeted_harm_categories,
61+
)
62+
63+
requested = set(technique_eval_hashes)
64+
counts: dict[str, tuple[int, int, int, int]] = {}
65+
for result in results:
66+
identifier = result.atomic_attack_identifier
67+
eval_hash = identifier.eval_hash if identifier is not None else None
68+
if eval_hash is None or eval_hash not in requested:
69+
continue
70+
71+
s, f, u, e = counts.get(eval_hash, (0, 0, 0, 0))
72+
if result.outcome == AttackOutcome.SUCCESS:
73+
counts[eval_hash] = (s + 1, f, u, e)
74+
elif result.outcome == AttackOutcome.FAILURE:
75+
counts[eval_hash] = (s, f + 1, u, e)
76+
elif result.outcome == AttackOutcome.ERROR:
77+
counts[eval_hash] = (s, f, u, e + 1)
78+
else:
79+
counts[eval_hash] = (s, f, u + 1, e)
80+
81+
return {
82+
eval_hash: _compute_stats(successes=s, failures=f, undetermined=u, errors=e)
83+
for eval_hash, (s, f, u, e) in counts.items()
84+
}

0 commit comments

Comments
 (0)