Skip to content

Commit b54c47f

Browse files
author
Victor Valbuena
committed
FEAT: Add skip_cached cross-run caching to AdversarialBenchmark
Adds a skip_cached: bool = False constructor parameter and a thin _get_atomic_attacks_async override on AdversarialBenchmark that, when enabled, filters out atomic-attack candidates whose (atomic_attack_name, technique_eval_hash) tuple appears in any prior COMPLETED ScenarioResult for the same scenario name + VERSION with outcome SUCCESS or FAILURE. ERROR and UNDETERMINED outcomes always retry. Caching is off by default to preserve existing behavior. Built on the AttackResultAttribution primitives introduced in microsoft#1758: - AtomicAttack.technique_eval_hash provides the candidate side of the cache key (content-derived via AtomicAttackEvaluationIdentifier). - AttackResultEntry.attribution_data['parent_collection' + 'parent_eval_hash'] provides the persisted side; the executor stamps these per AttackResult, so two atomic attacks sharing a name but using different technique configurations don't cross-pollinate. Defensive behavior: - Missing attribution_data or missing parent_collection -> skip the row silently (treat as not-cached). - Memory exceptions from get_scenario_results / get_attack_results -> log a warning and fall back to no filtering. Caching becomes a no-op rather than blocking the run. - Scenarios in IN_PROGRESS / FAILED / CANCELLED state contribute nothing (no get_attack_results query made for them at all). - Scenario name is matched on type(self).__name__ (PascalCase "AdversarialBenchmark"), aligned with how ScenarioIdentifier stores it; VERSION filter ensures the VERSION bump in the previous commit invalidates old VERSION=1 results for cache purposes (they remain queryable; they just don't suppress fresh runs). Tests: 11 new unit tests (TestAdversarialBenchmarkSkipCachedFilter + TestAdversarialBenchmarkSkipCachedInit) covering filtering semantics, outcome filters, eval-hash disambiguation, scenario-state filter, query-arg shape, missing-attribution defense, memory-error defense, and constructor defaults. Integration test with full persistence round-trip is a separate follow-up commit (F6.3 per plan). Wider regression: 1649/1649 pass across scenario+setup+registry+ backend. Failure mode flagged for the PR description batch: - The override + helper are scenario-agnostic in shape and should probably live on base Scenario behind a duck-typed identity hook (e.g. cls.cache_scope_name() classmethod) so other scenarios (RapidResponse, Scam, etc.) can opt into skip_cached without copy-pasting the wrapper. Enhancement, not a bug; tracked as lift-skip-cached-to-base-scenario.
1 parent d652a56 commit b54c47f

2 files changed

Lines changed: 385 additions & 2 deletions

File tree

pyrit/scenario/scenarios/benchmark/adversarial.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@
99
from typing import TYPE_CHECKING, ClassVar
1010

1111
from pyrit.common import apply_defaults
12+
from pyrit.models import AttackOutcome
1213
from pyrit.registry import AttackTechniqueRegistry, AttackTechniqueSpec
1314
from pyrit.registry.tag_query import TagQuery
1415
from pyrit.scenario.core.dataset_configuration import DatasetConfiguration
1516
from pyrit.scenario.core.scenario import BaselineAttackPolicy, Scenario
1617

1718
if TYPE_CHECKING:
19+
from pyrit.scenario.core.atomic_attack import AtomicAttack
1820
from pyrit.scenario.core.scenario_strategy import ScenarioStrategy
1921
from pyrit.score import TrueFalseScorer
2022

@@ -218,6 +220,7 @@ def __init__(
218220
self,
219221
*,
220222
objective_scorer: TrueFalseScorer | None = None,
223+
skip_cached: bool = False,
221224
scenario_result_id: str | None = None,
222225
) -> None:
223226
"""
@@ -227,12 +230,23 @@ def __init__(
227230
objective_scorer: Scorer for evaluating attack success. Defaults
228231
to the registered default objective scorer (typically the
229232
composite refusal+scale scorer set up by an initializer).
233+
skip_cached: When ``True``, ``_get_atomic_attacks_async`` filters
234+
out atomic attacks whose ``(atomic_attack_name,
235+
technique_eval_hash)`` tuple already appears in a prior
236+
``COMPLETED`` ``ScenarioResult`` for the same scenario name
237+
and version with outcome ``SUCCESS`` or ``FAILURE``.
238+
``ERROR`` and ``UNDETERMINED`` outcomes always retry. Cache
239+
identity is content-derived via
240+
``AtomicAttack.technique_eval_hash``, so two atomic attacks
241+
with the same name but different technique configurations
242+
(e.g. different scorer) do not cross-pollinate.
230243
scenario_result_id: Optional ID of an existing scenario result
231244
to resume.
232245
"""
233246
self._objective_scorer: TrueFalseScorer = (
234247
objective_scorer if objective_scorer else self._get_default_objective_scorer()
235248
)
249+
self._skip_cached: bool = skip_cached
236250

237251
super().__init__(
238252
version=self.VERSION,
@@ -241,6 +255,102 @@ def __init__(
241255
scenario_result_id=scenario_result_id,
242256
)
243257

258+
async def _get_atomic_attacks_async(self) -> list[AtomicAttack]:
259+
"""
260+
Build the base set of atomic attacks, then filter out cached completions when requested.
261+
262+
Delegates to the base ``Scenario._get_atomic_attacks_async`` to
263+
construct the (fanned-variant × dataset) candidate list, then drops
264+
any candidate whose ``(atomic_attack_name, technique_eval_hash)``
265+
tuple appears in :meth:`_collect_cached_completion_pairs` (only when
266+
``self._skip_cached`` is ``True``). Always returns the unfiltered
267+
base list when caching is disabled.
268+
269+
Returns:
270+
list[AtomicAttack]: The atomic attacks to actually execute on
271+
this run.
272+
"""
273+
candidates = await super()._get_atomic_attacks_async()
274+
if not self._skip_cached:
275+
return candidates
276+
277+
cached_pairs = self._collect_cached_completion_pairs()
278+
filtered = [c for c in candidates if (c.atomic_attack_name, c.technique_eval_hash) not in cached_pairs]
279+
skipped = len(candidates) - len(filtered)
280+
if skipped > 0:
281+
logger.info(
282+
"skip_cached=True: dropping %d/%d atomic attack(s) already completed in prior runs.",
283+
skipped,
284+
len(candidates),
285+
)
286+
return filtered
287+
288+
def _collect_cached_completion_pairs(self) -> set[tuple[str, str | None]]:
289+
"""
290+
Collect cache keys for atomic attacks that completed in any prior run of this scenario.
291+
292+
Walks ``ScenarioResult`` rows for the same scenario name and
293+
``VERSION``, restricts to ``scenario_run_state == "COMPLETED"``,
294+
then walks the linked ``AttackResult`` rows (joined via
295+
``AttackResultEntry.attribution_parent_id``) and records the
296+
``(atomic_attack_name, parent_eval_hash)`` tuple for every
297+
``SUCCESS`` or ``FAILURE`` outcome. The pair shape mirrors the
298+
``(atomic_attack_name, technique_eval_hash)`` tuple used by
299+
:meth:`_get_atomic_attacks_async` so a direct ``in`` check filters
300+
candidates without further key construction.
301+
302+
Resilient to attribution-data variation: rows whose
303+
``attribution_data`` is ``None`` or missing ``parent_collection``
304+
are skipped. Rows without ``parent_eval_hash`` enter the cache with
305+
``None`` in that slot, so they only match candidates whose
306+
``technique_eval_hash`` also resolves to ``None`` (currently never,
307+
since ``AtomicAttack.technique_eval_hash`` is always populated post-#1758).
308+
309+
Returns:
310+
set[tuple[str, str | None]]: Cache keys for already-completed
311+
atomic attacks. Empty set on any unexpected error (logged at
312+
warning level) — caching becomes a no-op rather than blocking
313+
the run.
314+
"""
315+
scenario_name = type(self).__name__
316+
cached_pairs: set[tuple[str, str | None]] = set()
317+
318+
try:
319+
prior_results = self._memory.get_scenario_results(
320+
scenario_name=scenario_name,
321+
scenario_version=self.VERSION,
322+
)
323+
except Exception as exc:
324+
logger.warning("skip_cached: failed to query prior scenario results (%s); skipping cache filter.", exc)
325+
return cached_pairs
326+
327+
for scenario_result in prior_results:
328+
if scenario_result.scenario_run_state != "COMPLETED":
329+
continue
330+
if scenario_result.id is None:
331+
continue
332+
try:
333+
attack_results = self._memory.get_attack_results(scenario_result_id=str(scenario_result.id))
334+
except Exception as exc:
335+
logger.warning(
336+
"skip_cached: failed to load attack results for scenario %s (%s); skipping that run.",
337+
scenario_result.id,
338+
exc,
339+
)
340+
continue
341+
342+
for ar in attack_results:
343+
if ar.outcome not in (AttackOutcome.SUCCESS, AttackOutcome.FAILURE):
344+
continue
345+
data = ar.attribution_data or {}
346+
atomic_attack_name = data.get("parent_collection")
347+
if not atomic_attack_name:
348+
continue
349+
parent_eval_hash = data.get("parent_eval_hash")
350+
cached_pairs.add((atomic_attack_name, parent_eval_hash))
351+
352+
return cached_pairs
353+
244354
def _build_display_group(self, *, technique_name: str, seed_group_name: str) -> str:
245355
"""
246356
Group atomic-attack results by adversarial-target label rather than by technique.

0 commit comments

Comments
 (0)