Skip to content

draft: text adaptive scenario - #2

Draft
hannahwestra25 wants to merge 53 commits into
mainfrom
hawestra/text_adaptive_scenario
Draft

draft: text adaptive scenario#2
hannahwestra25 wants to merge 53 commits into
mainfrom
hawestra/text_adaptive_scenario

Conversation

@hannahwestra25

@hannahwestra25 hannahwestra25 commented May 18, 2026

Copy link
Copy Markdown
Owner

Add Adaptive Scenario Framework with TextAdaptive

Summary

Introduces an adaptive scenario framework that picks attack techniques per-objective using an epsilon-greedy bandit informed by observed success rates, rather than running every selected technique against every objective. Concentrates spend on techniques that actually work against the target, and stops early on first success.

Adds:

  • AdaptiveScenario — modality-agnostic base class
  • TextAdaptive — concrete text-attack subclass
  • AdaptiveTechniqueSelector — epsilon-greedy selector with Laplace-smoothed estimates and pooled cross-context backoff
  • AdaptiveDispatchAttack — per-objective dispatch strategy
  • Walkthrough notebook + .py doc
  • Unit tests (63 tests across selector, dispatcher, and scenario)

Motivation

Static scenarios are O(techniques × objectives): every technique runs against every objective regardless of whether earlier attempts already succeeded or whether the technique is known to be ineffective against the target. For evaluation runs with many techniques and many objectives, this wastes spend on combinations that aren't informative.

Adaptive scenarios reduce this to O(max_attempts × objectives) by:

  • learning from observed outcomes,
  • exploiting techniques that work on the target,
  • still exploring (with probability epsilon) so the table doesn't collapse onto a single technique prematurely,
  • stopping per-objective on first success.

How it works

For each objective the dispatcher loops up to max_attempts_per_objective times:

  1. Select — with probability epsilon pick a random technique, otherwise pick the one with the highest Laplace-smoothed success estimate (s + 1) / (n + 1). Cells with fewer than pool_threshold local observations fall back to the technique's pooled rate across all contexts (cold-start handling).
  2. Execute — run the chosen technique against the bound SeedAttackGroup, merging the technique's seed_technique if it declares one.
  3. Record — update the selector's (context, technique) → (successes, attempts) table and stop early on success.

The selector is shared by reference across all per-objective dispatchers in a scenario run, so learning accumulates globally. The per-objective context key is derived by a ContextExtractor; global_context (default) shares one table across all objectives, harm_category_context partitions by harm category.

Public API

from pyrit.scenario.scenarios.adaptive import TextAdaptive, harm_category_context

scenario = TextAdaptive(
    epsilon=0.2,
    pool_threshold=3,
    max_attempts_per_objective=3,
    seed=42,                                  # reproducible selection
    context_extractor=harm_category_context,  # optional per-category learning
)
await scenario.initialize_async(objective_target=target)
result = await scenario.run_async()

Adaptive scenarios are also resumable — pass scenario_result_id="..." to the constructor and prior dispatch trails are replayed into the selector before the remaining objectives run.

notes

  • BASELINE_POLICY = Forbiddenprompt_sending participates as one of the selector's techniques rather than being prepended as a guaranteed baseline. Mixing a forced baseline with adaptive selection would bias the table.
  • Per-objective compatibility filtering — techniques whose seed_technique is incompatible with a given seed group are dropped per-objective rather than globally, so a technique that's incompatible with one objective still participates for others. Objectives with no compatible techniques are skipped with a warning.
  • Resume rehydration — each attempt is persisted as a step in metadata["adaptive_attempts"] on the outer AttackResult; on resume these trails are replayed via record_outcome so the new selector starts with the prior run's learned state. Already-completed atomics are skipped by the base Scenario resume path (by atomic_attack_name), so there is no double-counting.
  • Two-row persistence per success — the inner technique persists its raw AttackResult via its own post-execute hook; the dispatcher returns a replace-based copy with a fresh attack_result_id/timestamp and the adaptive trail stamped onto metadata. Both rows share conversation_id. Documented on the dispatcher's class docstring.
  • Thread safetyAdaptiveTechniqueSelector guards its counts table with a threading.Lock so individual select / record_outcome operations are atomic. The overall select → execute → record sequence is not serialized.

Files

Area File
Base scenario pyrit/scenario/scenarios/adaptive/adaptive_scenario.py
Text subclass pyrit/scenario/scenarios/adaptive/text_adaptive.py
Selector + context extractors pyrit/scenario/scenarios/adaptive/selector.py
Per-objective dispatcher pyrit/scenario/scenarios/adaptive/dispatcher.py
Package wiring pyrit/scenario/scenarios/adaptive/__init__.py, pyrit/scenario/__init__.py
Walkthrough doc/code/scenarios/3_adaptive_scenarios.py, doc/code/scenarios/3_adaptive_scenarios.ipynb
Tests tests/unit/scenario/scenarios/adaptive/test_selector.py, tests/unit/scenario/scenarios/adaptive/test_dispatcher.py, tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py

Testing

pytest tests/unit/scenario/scenarios/adaptive/ — 63 tests pass. Coverage includes:

  • selector exploration / exploitation / cold-start / pooled backoff / concurrent record_outcome
  • dispatcher early-stop, max-attempts retry, label propagation, context-label routing, fresh-result invariant
  • scenario atomic-attack emission, harm-category partitioning, per-objective seed-technique filtering, resume rehydration, baseline-policy enforcement

Comment thread pyrit/scenario/scenarios/adaptive/dispatcher.py Outdated
@hannahwestra25
hannahwestra25 requested a review from Copilot May 18, 2026 15:53
Comment thread pyrit/scenario/scenarios/adaptive/dispatcher.py Outdated
Comment thread pyrit/scenario/scenarios/adaptive/dispatcher.py Outdated
Comment thread pyrit/scenario/scenarios/adaptive/dispatcher.py Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds a new adaptive text scenario that chooses attack techniques per objective using an epsilon-greedy selector, plus unit tests for the selector, dispatcher, and scenario construction.

Changes:

  • Adds TextAdaptive, AdaptiveDispatchAttack, and AdaptiveTechniqueSelector.
  • Registers the adaptive scenario virtual package under pyrit.scenario.adaptive.
  • Adds unit coverage for adaptive selection, dispatch behavior, and atomic attack generation.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
pyrit/scenario/scenarios/adaptive/text_adaptive.py Implements the adaptive scenario and per-objective atomic attack construction.
pyrit/scenario/scenarios/adaptive/selector.py Adds epsilon-greedy technique selection and context helpers.
pyrit/scenario/scenarios/adaptive/dispatcher.py Adds dispatcher attack that selects and runs inner attack arms.
pyrit/scenario/scenarios/adaptive/__init__.py Exports adaptive scenario components.
pyrit/scenario/__init__.py Registers adaptive as a virtual scenario subpackage.
tests/unit/scenario/scenarios/adaptive/test_text_adaptive.py Adds scenario construction and baseline policy tests.
tests/unit/scenario/scenarios/adaptive/test_selector.py Adds selector behavior tests.
tests/unit/scenario/scenarios/adaptive/test_dispatcher.py Adds dispatcher behavior tests.

Comment thread pyrit/scenario/scenarios/adaptive/dispatcher.py Outdated
Comment thread pyrit/scenario/scenarios/adaptive/text_adaptive.py Outdated
Comment thread pyrit/scenario/scenarios/adaptive/dispatcher.py Outdated
Comment thread pyrit/scenario/scenarios/adaptive/selector.py Outdated
Comment thread pyrit/scenario/scenarios/adaptive/selector.py Outdated
Comment thread pyrit/scenario/scenarios/adaptive/selector.py Outdated
Comment thread pyrit/scenario/scenarios/adaptive/selector.py Outdated
Comment thread pyrit/scenario/scenarios/adaptive/selectors/epsilon_greedy.py
Comment thread pyrit/scenario/scenarios/adaptive/selector.py Outdated
Comment thread pyrit/scenario/scenarios/adaptive/selector.py Outdated
Comment thread pyrit/scenario/scenarios/adaptive/selector.py Outdated
Comment thread pyrit/scenario/scenarios/adaptive/text_adaptive.py
Comment thread pyrit/scenario/scenarios/adaptive/selector.py Outdated
Comment thread pyrit/scenario/scenarios/adaptive/selector.py Outdated
Comment thread pyrit/scenario/scenarios/adaptive/text_adaptive.py Outdated
Comment thread pyrit/scenario/scenarios/adaptive/text_adaptive.py Outdated
Comment thread pyrit/scenario/scenarios/adaptive/text_adaptive.py Outdated
Comment thread pyrit/scenario/scenarios/adaptive/text_adaptive.py Outdated
hannahwestra25 and others added 10 commits May 28, 2026 17:14
Per rlundeen2's review on PR microsoft#1760
(microsoft#1760 (comment)),
collapse AdaptiveDispatchAttack's manual per-objective attempt loop into
a SequentialAttack(completion_policy=FIRST_SUCCESS) (introduced in
PR microsoft#1819).

Each objective now produces exactly one AttackResult envelope
(SequentialAttackResult, conversation_id=""), and the inner per-technique
attempts persist as their own first-class AttackResult rows reachable via
child_attack_results / child_attack_result_ids. This removes the prior
hack where the winning inner result was rewritten with a fresh uuid and
re-persisted alongside its original, producing two rows that shared a
conversation_id.

What changed in dispatcher.py:
- Drop _run_inner_attack_async, the manual for-loop, AttackExecutor usage,
  and the uuid / datetime / dataclasses.replace / AttackOutcome imports.
- Build a SequentialChildAttack per chosen technique (with per-attempt
  memory labels and per-attempt seed_group.with_technique() merging), then
  delegate iteration + stop-on-success + envelope construction to
  SequentialAttack.
- Stamp the per-attempt trail at metadata["adaptive_attempts"] on the
  returned envelope (one entry per attempt that actually ran).

Compatibility preserved: same public constructor signature, same
filtering of techniques whose seed_technique is incompatible with the
current seed_group (still raises ValueError on empty pool so the outer
executor drops the seed group rather than silently no-op'ing), same
per-attempt memory_labels shape, same seed_technique merging.

Tests updated to patch SequentialAttack._run_child_attack_async instead
of the deleted _run_inner_attack_async, and assert the envelope is a
SequentialAttackResult with conversation_id == "" and the inner result
exposed via child_attack_results.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add a new 'Rapid Response Scenario' section demonstrating how to use
RapidResponse with focused dataset configuration and strategy selection.
Update intro text to reference the new section.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@hannahwestra25
hannahwestra25 force-pushed the hawestra/text_adaptive_scenario branch from f0db060 to 929d398 Compare June 1, 2026 23:19
…Pydantic identifiers)

Four related fixes needed to unblock CI after the upstream merge that
brought in PR microsoft#1881 (Refactoring Identifiers to be Pydantic classes):

1. `pyrit/models/identifiers/evaluation_identifier.py`:
   The merge hoisted `from pyrit.executor.attack.core.attack_strategy
   import AttackStrategy` to module level, forming a cycle through
   `pyrit.executor.attack` -> `pyrit.message_normalizer` ->
   `pyrit.common.data_url_converter` -> `pyrit.models`. Move it back
   inside `if TYPE_CHECKING:` (`from __future__ import annotations`
   is already enabled, so the string annotation `attack: AttackStrategy`
   still resolves at type-check time).

2. `tests/unit/models/identifiers/test_evaluation_identifier.py`:
   Add missing blank lines between top-level classes (ruff format) and
   replace four `from pyrit.identifiers import ...` lines with
   `from pyrit.models.identifiers import ...` (the former is now a
   deprecation shim that the static-scan deprecation test forbids
   internal callers from using).

3. `pyrit/scenario/scenarios/adaptive/adaptive_scenario.py`:
   Mark the three classmethod stubs as `@abstractmethod` so
   `inspect.isabstract(AdaptiveScenario)` returns `True` and the
   scenario registry's auto-discovery skips it (otherwise the registry
   tries to instantiate the abstract base and raises
   `NotImplementedError`, breaking `test_load_default_datasets`).

4. `pyrit/scenario/scenarios/adaptive/adaptive_scenario.py` and
   `pyrit/scenario/scenarios/adaptive/text_adaptive.py`:
   Move `from pyrit.setup.initializers.components.scenario_techniques
   import build_scenario_technique_factories` from module level back
   into the function body. `scenario_techniques` imports
   `pyrit.scenario.core`, which transitively re-imports the adaptive
   package during `pyrit.scenario` initialization, so a top-level
   import forms a cycle.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@hannahwestra25
hannahwestra25 force-pushed the hawestra/text_adaptive_scenario branch 2 times, most recently from f1b6373 to 77a3704 Compare June 2, 2026 16:43
hannahwestra25 and others added 16 commits June 2, 2026 13:28
…n SequentialAttack

Replaces the previous AdaptiveDispatchAttack (an AttackStrategy subclass that
delegated to an internal SequentialAttack) with a slim factory + subclass:

  - AdaptiveTechniqueDispatcher: plain class, not an AttackStrategy. Exposes
    compatible_techniques(seed_group=...) and async build_attack_async(seed_group=...).
    Selects techniques up-front per objective via TechniqueSelector and returns
    a fully-wired AdaptiveSequentialAttack.

  - AdaptiveSequentialAttack: ~15-LoC SequentialAttack subclass. Adds a
    technique_labels constructor argument and stamps adaptive_attempts
    metadata onto the envelope returned by super()._perform_async, then
    delegates everything else to the framework.

Atomic-attack shape:
  - One AtomicAttack per (dataset, seed_group) instead of one per dataset.
  - atomic_attack_name = `{prefix}_{dataset}::{objective_sha[:12]}` so each
    objective gets its own deterministic, hash-disambiguated identifier.
  - display_group = dataset_name preserves the grouping for reporting.
  - All per-dataset dispatchers share the same TechniqueSelector instance so
    learning still accumulates globally.

Rationale: eliminates the dispatcher's coupling to SequentialAttack's private
lifecycle internals (no more manual `_perform_async` orchestration, no more
duplicating envelope/metadata wiring) and removes a layer of indirection that
was making the call graph hard to reason about. The dispatcher is now an
`envelope factory'', not an attack.

All 56 adaptive unit tests pass (verified via docker devcontainer pytest run).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants