Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
a24ec71
feat(scenario): scaffold state-machine refactor (Phase 0 + OutcomeSco…
May 20, 2026
9201e4a
feat(scenario): land ScenarioStep adapter for AtomicAttack (Phase 2)
May 20, 2026
56b7eba
feat(scenario): add linear_strategy_policy builder + branching graph …
May 20, 2026
b372d08
feat(scenario): add step_identifier persistence layer (Phase 4)
May 20, 2026
952311d
MAINT: rewire Scenario.run_async through StrategyGraph (Phase 5)
May 20, 2026
99fa9dc
MAINT: vendor adaptive scenario from PR #1760 verbatim (Phase 6a)
May 20, 2026
a151bed
MAINT: migrate adaptive scenario onto StrategyGraph (Phase 6b)
May 20, 2026
e548dd2
TEST: augment OutcomeScorer coverage (Phase 10a)
May 20, 2026
f2f9f08
TEST: augment ScenarioStep + adapter coverage (Phase 10b)
May 20, 2026
30f6dee
TEST: augment StrategyGraph + state coverage (Phase 10c)
May 20, 2026
9e46574
TEST: augment adaptive scenario migration coverage (Phase 10f)
May 20, 2026
c2acb5e
TEST: augment step_identifier persistence coverage (Phase 10d)
May 20, 2026
15c0275
FEAT: add BroadSweepThenDeepDive branching scenario (Phase 7)
May 20, 2026
325bea9
TEST: augment Scenario.run_async graph rewire coverage (Phase 10e)
May 20, 2026
a14b1a9
Merge branch 'main' into vvalbuena-microsoft/scenario-core-refactor
ValbuenaVC May 20, 2026
aec0bd0
Apply ruff format to scenario core refactor
May 20, 2026
be970a3
finish BaselinePolicy -> BaselineAttackPolicy rename after main merge
May 20, 2026
fee626d
FEAT: Phase 8a schema foundation + forward waterfall
May 20, 2026
228ecad
declare BroadSweepThenDeepDive baseline policy as Forbidden
May 20, 2026
1bd09dd
FEAT: Phase 8b per-scenario input schemas + 8g prerequisites
May 20, 2026
4c8dd94
FEAT: Phase 8c scenario builder + input collectors
May 20, 2026
d6148fe
FEAT: Phase 8g graph artifact + load contract
May 20, 2026
23e5da7
Merge main into vvalbuena-microsoft/scenario-core-refactor
May 21, 2026
1a992f8
opt BroadSweepThenDeepDive out of the technique registry inspection path
May 20, 2026
e0f011b
surface AdversarialBenchmark's benchmarkable specs from the technique…
May 20, 2026
5e28fee
validate weakness_label at BroadSweepThenDeepDive constructor
May 20, 2026
66d496c
validate epsilon, pool_threshold, max_attempts at AdaptiveScenario co…
May 20, 2026
b5a518d
pin TextAdaptive policy_to_spec/spec_to_enum round-trip
May 20, 2026
bb928bc
validate init_async_inputs keys against initialize_async signature
May 20, 2026
5a4f463
pin InputCollector runtime_checkable + ArtifactInputCollector defensi…
May 20, 2026
b293919
pin collect_inputs_with_retry only catches ScenarioInputValidationError
May 20, 2026
24211e3
FEAT: Phase 8d pyrit_wizard CLI
May 21, 2026
9010ff7
FEAT: Phase 8e pyrit_scan --from-artifact + inverse waterfall
May 21, 2026
c5f167c
DOCS: Phase 8f wizard walkthrough + waterfall round-trip integration …
May 21, 2026
e61df6f
FEAT: ScenarioPipeline composition primitive (R5)
May 21, 2026
eb2be7f
pin ScenarioPipeline scenario_factory guard uses isinstance not duck-…
May 21, 2026
3be99e4
pin ScenarioPipeline graph artifact round-trip not supported in v1
May 21, 2026
c35a77c
pin BroadSweepThenDeepDive graph artifact round-trip not supported in v1
May 21, 2026
75506af
MAINT: honest ScenarioPipeline persistence docstring + R1 forward-com…
May 21, 2026
308002b
MAINT: R5.1 ScenarioPipeline polish (persistence + merge order + fail…
May 21, 2026
06e8257
MAINT: R1 collapse adaptive override into base linear policy
May 21, 2026
134049d
Merge branch 'main' into vvalbuena-microsoft/scenario-core-refactor
ValbuenaVC May 21, 2026
e3518d5
MAINT: R2 rename _get_atomic_attacks_async to _get_steps_async
May 21, 2026
d5a910d
MAINT: R3 split StrategyGraph state from step state
May 21, 2026
2842879
MAINT: R4 concurrent deep-dive dispatch via asyncio.gather
May 21, 2026
6561488
Merge branch 'main' into vvalbuena-microsoft/scenario-core-refactor
ValbuenaVC May 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 16 additions & 9 deletions .github/instructions/scenarios.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,13 @@ class MyScenario(Scenario):
return DatasetConfiguration(dataset_names=["my_dataset"])
```

4. **Optionally override `_get_atomic_attacks_async()`** — the base class provides a default
4. **Optionally override `_get_steps_async()`** — the base class provides a default
that uses the factory/registry pattern (see "AtomicAttack Construction" below).
Only override if your scenario needs custom attack construction logic.
Only override if your scenario needs custom step construction logic.

> **Deprecation note:** The legacy hook `_get_atomic_attacks_async()` still works as a
> passthrough but is deprecated and will be removed in 0.16.0. Migrate overrides to
> `_get_steps_async()` — the body is identical; only the name changes.

## Constructor Pattern

Expand All @@ -53,7 +57,7 @@ def __init__(
if not objective_scorer:
objective_scorer = self._get_default_scorer()

# 2. Store config objects for _get_atomic_attacks_async
# 2. Store config objects for _get_steps_async
self._scorer_config = AttackScoringConfig(objective_scorer=objective_scorer)

# 3. Call super().__init__ — required args: version, strategy_class, objective_scorer
Expand Down Expand Up @@ -139,9 +143,12 @@ Note: `atomic_attack_name` must remain unique per `AtomicAttack` for correct res

## AtomicAttack Construction — Default Base Class Behaviour

The `Scenario` base class provides a default `_get_atomic_attacks_async()` that uses the
The `Scenario` base class provides a default `_get_steps_async()` that uses the
factory/registry pattern. Scenarios that register their techniques via `_get_attack_technique_factories()`
get atomic-attack construction **for free** — no override needed.
get step construction **for free** — no override needed.

> The legacy hook `_get_atomic_attacks_async()` still works as a passthrough but is
> deprecated and will be removed in 0.16.0. Use `_get_steps_async()` for new code.

The default implementation:
1. Calls `self._get_attack_technique_factories()` to get name→factory mapping
Expand All @@ -150,13 +157,13 @@ The default implementation:
4. Uses `self._build_display_group()` for user-facing grouping
5. Builds `AtomicAttack` with unique `atomic_attack_name` = `"{technique}_{dataset}"`

### Customization hooks (no need to override `_get_atomic_attacks_async`):
### Customization hooks (no need to override `_get_steps_async`):
- **`_get_attack_technique_factories()`** — override to add/remove/replace factories
- **`_build_display_group()`** — override to change grouping (default: by technique)

### When to override `_get_atomic_attacks_async`:
### When to override `_get_steps_async`:
Only override when the scenario **cannot** use the factory/registry pattern — e.g., scenarios
with custom composite logic, per-strategy converter stacks, or non-standard attack construction.
with custom composite logic, per-strategy converter stacks, or non-standard step construction.

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`).

Expand Down Expand Up @@ -185,4 +192,4 @@ New scenarios must be registered in `pyrit/scenario/__init__.py` as virtual pack
- Forgetting `@apply_defaults` on `__init__`
- Empty `seed_groups` passed to `AtomicAttack`
- Missing `VERSION` class constant
- Missing `_async` suffix on `_get_atomic_attacks_async`
- Missing `_async` suffix on `_get_steps_async`
10 changes: 6 additions & 4 deletions doc/code/scenarios/0_scenarios.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,10 @@
# 2. **Scenario Class**: Extend `Scenario` and implement these abstract methods:
# - `get_strategy_class()`: Return your strategy enum class
# - `get_default_strategy()`: Return the default strategy (typically `YourStrategy.ALL`)
# - The base class provides a default `_get_atomic_attacks_async()` that uses the factory/registry
# pattern. Override it only if your scenario needs custom attack construction logic.
# - The base class provides a default `_get_steps_async()` that uses the factory/registry
# pattern. Override it only if your scenario needs custom step construction logic.
# (The legacy hook `_get_atomic_attacks_async()` still works but is deprecated and will
# be removed in 0.16.0.)
#
# 3. **Default Dataset**: Implement `default_dataset_config()` to specify the datasets your scenario uses out of the box.
# - Returns a `DatasetConfiguration` with one or more named datasets (e.g., `DatasetConfiguration(dataset_names=["my_dataset"])`)
Expand Down Expand Up @@ -155,8 +157,8 @@ def __init__(
def _build_display_group(self, *, technique_name: str, seed_group_name: str) -> str:
return seed_group_name

# No _get_atomic_attacks_async override needed!
# The base class builds attacks from the (technique x dataset) cross-product
# No _get_steps_async override needed!
# The base class builds steps from the (technique x dataset) cross-product
# using the factory/registry pattern automatically.


Expand Down
242 changes: 242 additions & 0 deletions doc/code/scenarios/3_adaptive_scenarios.ipynb
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
{
"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.write_async(result) # type: ignore"
]
},
{
"cell_type": "markdown",
"id": "5",
"metadata": {},
"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",
"- **`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",
"The cell below exercises all of them at once."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "6",
"metadata": {},
"outputs": [],
"source": [
"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",
")\n",
"\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",
"configured_result = await configured_scenario.run_async() # type: ignore\n",
"await printer.write_async(configured_result) # type: ignore"
]
},
{
"cell_type": "markdown",
"id": "7",
"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."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "8",
"metadata": {},
"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",
" scenario_result_id=str(configured_result.id),\n",
")\n",
"\n",
"await resumed_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",
"resumed_result = await resumed_scenario.run_async() # type: ignore\n",
"await printer.write_async(resumed_result) # type: ignore"
]
},
{
"cell_type": "markdown",
"id": "9",
"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",
"Walk that metadata to see the per-objective trail and aggregate counts."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "10",
"metadata": {},
"outputs": [],
"source": [
"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%}\")"
]
}
],
"metadata": {
"jupytext": {
"main_language": "python"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
Loading
Loading