forked from microsoft/PyRIT
-
Notifications
You must be signed in to change notification settings - Fork 0
draft: text adaptive scenario #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
hannahwestra25
wants to merge
53
commits into
main
Choose a base branch
from
hawestra/text_adaptive_scenario
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
53 commits
Select commit
Hold shift + click to select a range
83a778e
init commit
hannahwestra25 d74fe3e
Merge branch 'main' of https://github.com/microsoft/PyRIT into hawest…
hannahwestra25 09e3007
merge
hannahwestra25 70d14c4
proofread
hannahwestra25 3df5787
pr review
hannahwestra25 eb68c1b
Merge branch 'main' of https://github.com/microsoft/PyRIT into hawest…
hannahwestra25 b794db0
generalize and clean up comments & notebooks
hannahwestra25 2c06a24
pre-commit
hannahwestra25 11b39a0
integrate attack technique group
hannahwestra25 61a1b7d
clean up and fix docstrings
hannahwestra25 32d8b5e
simplify notebook and pre-commit
hannahwestra25 1375974
Merge branch 'main' of https://github.com/microsoft/PyRIT into hawest…
hannahwestra25 b3150da
Merge remote-tracking branch 'origin/main' into hawestra/text_adaptiv…
4d5c2de
Merge remote-tracking branch 'upstream/main' into hannahwestra25/feat…
hannahwestra25 f86c191
feat: address PR #1760 review feedback
hannahwestra25 9e38a33
Merge remote-tracking branch 'upstream/main' into hannahwestra25/feat…
hannahwestra25 26cd65e
fix: address pre-commit lint failures
hannahwestra25 d420f16
Merge branch 'main' of https://github.com/microsoft/PyRIT into hawest…
b4db6a6
Redesign TechniqueSelector: stateless, memory-backed, eval-hash keyed
hannahwestra25 1524926
Merge branch 'main' of https://github.com/microsoft/PyRIT into hawest…
cbd3f9a
Merge PR head b4db6a674 (review-feedback redesign) into local branch
e31c199
merge & fix tests
c7a683b
fix: ruff TC001/TC003 and ty type narrowing for adaptive scenario
a303d81
Replace SelectorScope enum with frozen dataclass
8d7abd0
Merge branch 'main' into hawestra/text_adaptive_scenario
hannahwestra25 d3cce0c
fix: pre-commit failures (ruff TC001 + nbstripout)
hannahwestra25 9ca97c3
Merge remote-tracking branch 'upstream/main' into hannahwestra25/pull…
hannahwestra25 cd9f200
refactor(adaptive): delegate dispatcher loop to SequentialAttack
hannahwestra25 70cda93
docs: add RapidResponse scenario example to common parameters guide
9203e53
bug fixes
37ab50e
merge
e0dda28
address comments
200eacb
use eval hash
1d8b611
fix merge
a31a2e0
fix merge
115d1f7
merge
77a3704
FIX: Resolve CI failures from upstream merge with PR #1881 (Pydantic …
hannahwestra25 0055d25
refactor(adaptive): convert dispatcher to factory that returns a plai…
32cf9cb
internal pr review
8e96798
pre-commit
9fedf2f
Merge branch 'main' of https://github.com/microsoft/PyRIT into hawest…
c0a2dab
bug fix
7cd5aa7
clean up output
e56bc73
fix ruff
c7cb4e1
add default clause
6905254
Merge branch 'main' of https://github.com/microsoft/PyRIT into hawest…
62b4e9d
pr comments and pre commit
d25c98a
Merge branch 'main' into hawestra/text_adaptive_scenario
hannahwestra25 630cbfa
resolve merge
09be122
fix async
16e2977
remove extra tests
9c9640b
add clarify about pool
aa8d204
merge
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,250 @@ | ||
| # --- | ||
| # 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 | ||
| 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: `max_attempts_per_objective=3`, epsilon-greedy selector with `epsilon=0.2`, | ||
| # 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.write_async(result) # type: ignore | ||
|
|
||
| # %% [markdown] | ||
| # ## Configuring a run | ||
| # | ||
| # - **`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=..., 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. | ||
| # | ||
| # 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( | ||
| 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 | ||
| objective_target=objective_target, | ||
| scenario_strategies=[strategy_class("single_turn")], | ||
| dataset_config=DatasetConfiguration( | ||
| dataset_names=["airt_hate", "airt_violence"], | ||
| max_dataset_size=4, | ||
| ), | ||
| ) | ||
| 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. Resume must use the same | ||
| # configuration as the original run. | ||
|
|
||
| # %% | ||
| resumed_scenario = TextAdaptive( | ||
| 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, | ||
| 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 | ||
| # | ||
| # 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"`) 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. | ||
| # | ||
| # 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 | ||
|
|
||
| # 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.class_name if attack_id else "<unknown>" | ||
|
|
||
|
|
||
| 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 | ||
| 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: | ||
| 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:40s} {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:40s} {total_wins[technique]:>4} / {n:<4} {total_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 | ||
| # ``` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| # 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 Sequence | ||
|
|
||
| from pyrit.memory.memory_interface import MemoryInterface | ||
|
|
||
|
|
||
| def compute_technique_stats( | ||
| *, | ||
| technique_eval_hashes: Sequence[str], | ||
| scenario_result_id: str | None = None, | ||
| targeted_harm_categories: Sequence[str] | None = None, | ||
| memory: MemoryInterface | None = None, | ||
| ) -> dict[str, AttackStats]: | ||
| """ | ||
| Compute per-technique outcome statistics from persisted attack results. | ||
|
|
||
| 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]): 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). | ||
| targeted_harm_categories (Sequence[str] | None): Restrict to results | ||
| 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. Hashes with no | ||
| historical results are omitted from the result. | ||
| """ | ||
| if not technique_eval_hashes: | ||
| return {} | ||
|
|
||
| if memory is None: | ||
| memory = CentralMemory.get_memory_instance() | ||
| results = memory.get_attack_results( | ||
| atomic_attack_eval_hashes=list(technique_eval_hashes), | ||
| scenario_result_id=scenario_result_id, | ||
| targeted_harm_categories=targeted_harm_categories, | ||
| ) | ||
|
|
||
| requested = set(technique_eval_hashes) | ||
| counts: dict[str, tuple[int, int, int, int]] = {} | ||
| for result in results: | ||
| 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(eval_hash, (0, 0, 0, 0)) | ||
| if result.outcome == AttackOutcome.SUCCESS: | ||
| counts[eval_hash] = (s + 1, f, u, e) | ||
| elif result.outcome == AttackOutcome.FAILURE: | ||
| counts[eval_hash] = (s, f + 1, u, e) | ||
| elif result.outcome == AttackOutcome.ERROR: | ||
| counts[eval_hash] = (s, f, u, e + 1) | ||
| else: | ||
| counts[eval_hash] = (s, f, u + 1, e) | ||
|
|
||
| return { | ||
| eval_hash: _compute_stats(successes=s, failures=f, undetermined=u, errors=e) | ||
| for eval_hash, (s, f, u, e) in counts.items() | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.