FEAT text adaptive scenario#1760
Conversation
…ra/text_adaptive_scenario
…ra/text_adaptive_scenario
| return [ | ||
| "airt_hate", | ||
| "airt_fairness", | ||
| "airt_violence", | ||
| "airt_sexual", | ||
| "airt_harassment", | ||
| "airt_misinformation", | ||
| "airt_leakage", |
There was a problem hiding this comment.
we may want a separate set of datasets
|
|
||
| techniques = self._build_techniques_dict(objective_target=self._objective_target) | ||
|
|
||
| selector = AdaptiveTechniqueSelector( |
There was a problem hiding this comment.
We may want to name this more specifically, because I could envision different types of selectors also. But maybe this is a future problem. Nit only
There was a problem hiding this comment.
Agree, and I'd actually push slightly harder than "nit" — this is cheap to fix now and expensive to fix later (the class name is part of the public __init__.py surface).
Concrete suggestion:
-
Rename the concrete class to
EpsilonGreedyTechniqueSelector(orEpsilonGreedySelector). The current name describes what role it plays in the scenario, not what algorithm it implements — and the algorithm is what callers will care about when picking between selectors. -
Extract a
TechniqueSelectorProtocol (or ABC) capturing the surface the dispatcher actually depends on — justselect(*, context, techniques) -> strandrecord_outcome(*, context, technique, success). The dispatcher andAdaptiveScenariotype-hint against the Protocol, the concrete class is one implementation. -
Plumb selector choice as a constructor arg.
AdaptiveScenario.__init__(..., selector: TechniqueSelector | None = None)defaulting toEpsilonGreedyTechniqueSelector(epsilon=..., pool_threshold=..., rng=...). That immediately unlocks future selectors (UCB1, Thompson sampling, contextual bandits, even a plug-in for a tuned policy) without subclassingAdaptiveScenario. -
The rehydration hook (
_rehydrate_selector_from_memory) needs to become selector-aware, since UCB-style selectors care about timestamps, Thompson sampling needs Beta posterior parameters, etc. For v1 you can document that only epsilon-greedy is rehydratable and others start fresh — but it's worth a TODO so it doesn't surprise the next contributor.
Steps 1–3 are mechanical and worth doing in this PR (keeps the public API stable for v1). Step 4 is genuinely future work.
The epsilon/pool_threshold constructor args on AdaptiveScenario then become awkward — they're epsilon-greedy specific. Either:
- Drop them from the scenario constructor entirely and require callers wanting non-defaults to pass a constructed
selector=..., or - Keep them as ergonomic shortcuts that only apply to the default selector (raise if
selector=is also passed with these).
I'd go with the first — simpler contract, and once you have selector= as the extension point, the kwargs are redundant sugar.
There was a problem hiding this comment.
I updated to add the Selector as input and created a greedy epsilon specific selector (with a base selector class), so I added the max_attempts_per_objective to supported_params but removed everything else so now users are able to create a selector or we use greedy epsilon as the default. This does make it a little less flexible in that you can't customize the greedy epsilon selector without creating another object and then passing it in but I think it's better than having these as params and I like supporting different selectors. wdyt ?
…ra/text_adaptive_scenario
…-text-adaptive-scenario
- Remove prompt_sending from adaptive pool; enable baseline comparison - Expose max_attempts_per_objective via supported_parameters() (scam.py pattern) - Rename AdaptiveTechniqueSelector -> EpsilonGreedyTechniqueSelector - Extract TechniqueSelector Protocol; accept custom selector via kwarg - Per-decision RNG derivation (SHA-256) for resume reproducibility - Drop uuid.uuid4() fallback for objective IDs - Per-dataset atomic attacks (one AtomicAttack per dataset, not per objective) - AdaptiveDispatchParams with per-call seed_group and compatibility filtering - Context extraction moved to dispatcher - Rehydration uses get_attack_results with attribution_data filtering - Split selector.py into selectors/ folder (protocol.py + epsilon_greedy.py) - Update notebooks for new API patterns Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
| @@ -0,0 +1,66 @@ | |||
| # Copyright (c) Microsoft Corporation. | |||
| # Licensed under the MIT license. | |||
|
|
|||
There was a problem hiding this comment.
i don't love this file name so if anyone has suggestions, please comment!
…-text-adaptive-scenario
- SIM108: use ternary for selector assignment - D101: add docstring to AdaptiveDispatchParams - DOC201/DOC501: add Returns/Raises sections to docstrings - TC003: move Sequence import into TYPE_CHECKING block - Fix trailing newline in epsilon_greedy.py Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
| @@ -0,0 +1,66 @@ | |||
| # Copyright (c) Microsoft Corporation. | |||
| # Licensed under the MIT license. | |||
|
|
|||
|
|
||
| techniques = self._build_techniques_dict(objective_target=self._objective_target) | ||
|
|
||
| selector: TechniqueSelector |
There was a problem hiding this comment.
Nits and nonblocking:
- Does a selector belong to a scenario or is it meant to be a runtime parameter? It uses the same pattern as the scorer and context extractor but seems more intrinsic to the scenario than the other two
- What does
selector: TechniqueSelectordo? Is it just for type checking? - Is
EpsilonGreedyTechniqueSelectorguaranteed to work as a default for all subclasses ofAdaptiveScenario?
Add Adaptive Scenario Framework with
TextAdaptiveSummary
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 classTextAdaptive— concrete text-attack subclassTechniqueSelectorProtocol +EpsilonGreedyTechniqueSelector— pluggable selector with Laplace-smoothed estimates and pooled cross-context backoffAdaptiveDispatchAttack— per-dataset dispatch strategy with per-call seed-group routing.pydocMotivation
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:epsilon) so the table doesn't collapse onto a single technique prematurely,How it works
For each objective the dispatcher loops up to
max_attempts_per_objectivetimes:epsilonpick a random technique, otherwise pick the one with the highest Laplace-smoothed success estimate(s + 1) / (n + 1). Cells with fewer thanpool_thresholdlocal observations fall back to the technique's pooled rate across all contexts (cold-start handling). Each decision derives a per-decision RNG fromSHA-256(random_seed|context|decision_key)for resume-safe reproducibility.AdaptiveDispatchParams.seed_group), merging the technique'sseed_techniqueif it declares one. Techniques incompatible with the current seed group are filtered per-call.(context, technique) → (successes, attempts)table and stop early on success.The selector is shared by reference across all dispatchers in a scenario run, so learning accumulates globally. The per-call
contextkey is derived by aContextExtractor;global_context(default) shares one table across all objectives,harm_category_contextpartitions by harm category.Public API
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_ATTACK_POLICY = Enabled—prompt_sendingis excluded from the adaptive technique pool and runs as the baseline comparison instead. This separates "what does the target do unprovoked" (baseline) from "what adversarial moves help" (adaptive techniques).AtomicAttackper dataset carrying all seed groups, with per-call seed-group routing viaAdaptiveDispatchParams. Per-call compatibility filtering happens inside the dispatcher.selector: TechniqueSelector | Noneon the scenario. WhenNone(default), anEpsilonGreedyTechniqueSelectoris created with default settings. Selector-specific params (epsilon,pool_threshold,random_seed) live on the selector, not the scenario.max_attempts_per_objectiveis a scenario parameter viasupported_parameters().get_attack_results(scenario_result_id=...)and filters byattribution_data["parent_collection"]to replay prior dispatch trails viarecord_outcome. Already-completed atomics are skipped by the baseScenarioresume path.AttackResultvia its own post-execute hook; the dispatcher returns areplace-based copy with a freshattack_result_id/timestampand the adaptive trail stamped onto metadata. Both rows shareconversation_id.EpsilonGreedyTechniqueSelectorguards its counts table with athreading.Lockso individualselect/record_outcomeoperations are atomic.Files
pyrit/scenario/scenarios/adaptive/adaptive_scenario.pypyrit/scenario/scenarios/adaptive/text_adaptive.pypyrit/scenario/scenarios/adaptive/selectors/protocol.pypyrit/scenario/scenarios/adaptive/selectors/epsilon_greedy.pypyrit/scenario/scenarios/adaptive/dispatcher.pypyrit/scenario/scenarios/adaptive/__init__.py,pyrit/scenario/scenarios/adaptive/selectors/__init__.pydoc/code/scenarios/3_adaptive_scenarios.py,doc/code/scenarios/3_adaptive_scenarios.ipynbtests/unit/scenario/scenarios/adaptive/test_epsilon_greedy.py,tests/unit/scenario/scenarios/adaptive/test_protocol.py,tests/unit/scenario/scenarios/adaptive/test_dispatcher.py,tests/unit/scenario/scenarios/adaptive/test_text_adaptive.pyTesting
pytest tests/unit/scenario/scenarios/adaptive/— 64 tests pass. Coverage includes:record_outcomesupported_parameters()