Skip to content

Commit c1476bf

Browse files
author
Victor Valbuena
committed
FEAT: Add BenchmarkInitializer for adversarial-target fan-out
Registers one AttackTechniqueSpec variant per (adversarial-capable technique, adversarial-tagged target) pair into AttackTechniqueRegistry with the live target bound onto adversarial_chat. Variants are named f"{source}__{target_name}" and tagged ["benchmark_fanout", f"model:{target_name}"] so the benchmark scenario can discover them via tag query in a later commit. Adversarial-capability is determined by reusing _spec_needs_adversarial from scenario_techniques (multi-turn attacks + crescendo-style simulated conversations). Single-turn techniques without an adversarial chat target (prompt_sending, role_play, many_shot, context_compliance) are not fanned — the benchmark holds the objective target constant and varies the adversarial chat helper across runs. Placed at pyrit/setup/initializers/benchmark.py (top level) alongside AIRTInitializer and SimpleInitializer, not under components/. The components/ initializers (TargetInitializer, ScorerInitializer, ScenarioTechniqueInitializer) are auto-bundled building blocks that populate their registries during every PyRIT setup. BenchmarkInitializer is the opposite shape: a user-opted workflow profile named after the use case, listed in .pyrit_conf when the user wants a benchmarking trial. The placement convention is itself underspecified in the codebase and is tracked for follow-up. Parameter contract (target_names: list[str] | None): The optional target_names parameter is declared via PyRITInitializer.supported_parameters, which is the single source of truth shared across three consumer sites: 1. .pyrit_conf YAML: initializers: - name: benchmark args: target_names: - adversarial_chat_singleturn - adversarial_chat_reasoning ConfigurationLoader._resolve_initializers calls instance.set_params_from_args(args=config.args) and then _validate_params against supported_parameters, so unknown keys fail fast at config-load time. Omitting the args block uses the default (fan over every adversarial-tagged target). 2. CLI (--list-initializers via frontend_core._print_initializer_meta): reads metadata.supported_parameters and prints name + description + default for each declared parameter, so users discover what they can put in .pyrit_conf without reading source. 3. GUI backend (InitializerService): wraps each declared parameter as an InitializerParameterSummary({name, description, default}) on the RegisteredInitializer Pydantic model. The GUI renders form fields from this metadata. All three paths terminate at the same self.params dict that initialize_async reads via self.params.get("target_names"), so adding, renaming, or retyping the parameter is a single-site change. target_names narrows fan-out to a subset of adversarial targets by registry name; unknown names raise ValueError listing both the unknowns and the discovered set. Empty discovery raises ValueError naming the ADVERSARIAL_CHAT_* env vars and the TargetInitializer ordering dependency (closes one of the failure-mode follow-ups surfaced in the previous commit). Failure modes audited during this change but not addressed here (tracked for the PR description batch): - AttackTechniqueRegistry.register_from_specs is first-write-wins on name collision with no log entry. Disjoint name spaces between ScenarioTechniqueInitializer and BenchmarkInitializer mean this is inert today; future extensions that produce colliding names would be silently no-op'd. - BenchmarkInitializer's TargetRegistry walk is a snapshot at init time; later mutations to TargetRegistry leave the fanned specs holding stale references. Failure surfaces at API-call time, not at registration. - Top-level vs components/ initializer placement convention is implicit; this commit picks "top-level for workflow profiles", matching AIRT/Simple. Worth a CONTRIBUTING note when convention is formalized.
1 parent 190ee4a commit c1476bf

3 files changed

Lines changed: 381 additions & 0 deletions

File tree

pyrit/setup/initializers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from pyrit.common.deprecation import print_deprecation_message
77
from pyrit.common.parameter import Parameter
88
from pyrit.setup.initializers.airt import AIRTInitializer
9+
from pyrit.setup.initializers.benchmark import BenchmarkInitializer
910
from pyrit.setup.initializers.components.scenarios import ScenarioTechniqueInitializer
1011
from pyrit.setup.initializers.components.scorers import ScorerInitializer
1112
from pyrit.setup.initializers.components.targets import TargetInitializer
@@ -18,6 +19,7 @@
1819
"Parameter",
1920
"PyRITInitializer",
2021
"AIRTInitializer",
22+
"BenchmarkInitializer",
2123
"ScenarioTechniqueInitializer",
2224
"ScorerInitializer",
2325
"TargetInitializer",
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
3+
4+
"""
5+
Benchmark initializer that fans adversarial-capable scenario techniques across
6+
adversarial targets discovered in ``TargetRegistry``.
7+
8+
This is the entry point for bootstrapping an ``AdversarialBenchmark`` trial.
9+
It queries ``TargetRegistry`` for entries tagged ``ADVERSARIAL`` (via
10+
``TagQuery.all("adversarial")``), then for every adversarial-capable
11+
technique in ``SCENARIO_TECHNIQUES`` builds one fanned
12+
``AttackTechniqueSpec`` per discovered target. Each fanned spec binds the
13+
live target onto ``adversarial_chat`` and is registered into
14+
``AttackTechniqueRegistry`` tagged ``["benchmark_fanout", f"model:{name}"]``
15+
so the benchmark scenario can discover them via tag query in a later commit.
16+
17+
The ``target_names`` parameter (optional, settable from ``.pyrit_conf``)
18+
narrows the fan-out to a specific subset of adversarial targets by registry
19+
name. Unknown names raise ``ValueError``.
20+
21+
Discovery returning no adversarial-tagged targets raises ``ValueError`` with
22+
an actionable message pointing at the ``ADVERSARIAL_CHAT_*`` env vars and
23+
the ``TargetInitializer`` dependency.
24+
"""
25+
26+
import dataclasses
27+
import logging
28+
29+
from pyrit.common.parameter import Parameter
30+
from pyrit.registry import TargetRegistry
31+
from pyrit.registry.object_registries.attack_technique_registry import (
32+
AttackTechniqueRegistry,
33+
AttackTechniqueSpec,
34+
)
35+
from pyrit.registry.tag_query import TagQuery
36+
from pyrit.scenario.core.scenario_techniques import SCENARIO_TECHNIQUES, _spec_needs_adversarial
37+
from pyrit.setup.initializers.components.targets import TargetInitializerTags
38+
from pyrit.setup.initializers.pyrit_initializer import PyRITInitializer
39+
40+
logger = logging.getLogger(__name__)
41+
42+
43+
#: Default discovery query used when no ``target_names`` override is provided.
44+
#: Resolves every ``TargetRegistry`` entry tagged ``ADVERSARIAL`` (which today
45+
#: includes ``adversarial_chat`` plus the ``ADVERSARIAL_CHAT_{SINGLETURN,
46+
#: MULTITURN,REASONING}`` variants — all set by ``TargetInitializer``).
47+
DEFAULT_ADVERSARIAL_TAG_QUERY: TagQuery = TagQuery.all(TargetInitializerTags.ADVERSARIAL.value)
48+
49+
50+
class BenchmarkInitializer(PyRITInitializer):
51+
"""
52+
Fan adversarial-capable scenario techniques across discovered adversarial targets.
53+
54+
For every ``AttackTechniqueSpec`` in ``SCENARIO_TECHNIQUES`` that uses an
55+
adversarial chat target (multi-turn attacks plus crescendo-style
56+
simulated conversations), this initializer registers one variant per
57+
discovered adversarial target with the target bound onto
58+
``adversarial_chat``. Variants are named
59+
``f"{source_spec.name}__{target_name}"`` (e.g. ``red_teaming__adversarial_chat_singleturn``)
60+
and carry the additional strategy tags ``"benchmark_fanout"`` and
61+
``f"model:{target_name}"`` so the benchmark scenario can query the
62+
registry by tag in a later commit.
63+
64+
Parameters (declared via :attr:`supported_parameters`):
65+
66+
* ``target_names`` (``list[str]``, optional): Narrow fan-out to a
67+
specific subset of adversarial targets by registry name. When omitted,
68+
every target matching :data:`DEFAULT_ADVERSARIAL_TAG_QUERY` is used.
69+
70+
Raises (at ``initialize_async``):
71+
72+
* ``ValueError`` — no adversarial-tagged targets are registered. The
73+
error names the ``ADVERSARIAL_CHAT_*`` env vars to set and the
74+
``TargetInitializer`` dependency.
75+
* ``ValueError`` — any name in ``target_names`` does not match a
76+
discovered adversarial-tagged target. The error lists discovered names.
77+
78+
Prerequisites: ``TargetInitializer`` must have run first so adversarial
79+
env-driven targets are present in ``TargetRegistry``. Registering the
80+
base scenario-technique catalog (``ScenarioTechniqueInitializer`` or an
81+
equivalent caller of ``register_scenario_techniques``) is also expected
82+
if users will select non-benchmark strategies in the same session;
83+
``BenchmarkInitializer`` itself only registers the fanned variants.
84+
Per-name idempotent via ``AttackTechniqueRegistry.register_from_specs``:
85+
running the initializer twice with the same registry state is a no-op.
86+
"""
87+
88+
@property
89+
def supported_parameters(self) -> list[Parameter]:
90+
"""Declare the optional ``target_names`` narrowing parameter."""
91+
return [
92+
Parameter(
93+
name="target_names",
94+
description=(
95+
"Optional list of adversarial target registry names to narrow benchmark fan-out. "
96+
'When omitted, every target matching TagQuery.all("adversarial") is used.'
97+
),
98+
default=None,
99+
param_type=list[str],
100+
),
101+
]
102+
103+
async def initialize_async(self) -> None:
104+
"""
105+
Discover adversarial targets and register fanned specs into the technique registry.
106+
107+
Raises:
108+
ValueError: If no adversarial-tagged targets are registered in
109+
``TargetRegistry``, or if ``self.params['target_names']``
110+
contains a name not in the discovered set.
111+
"""
112+
target_registry = TargetRegistry.get_registry_singleton()
113+
discovered_entries = target_registry.get_by_tag_query(query=DEFAULT_ADVERSARIAL_TAG_QUERY)
114+
if not discovered_entries:
115+
raise ValueError(
116+
"BenchmarkInitializer: no adversarial-tagged targets registered in TargetRegistry. "
117+
"Set ADVERSARIAL_CHAT_* env vars (see .env_example) and ensure TargetInitializer runs "
118+
"before BenchmarkInitializer (e.g. via .pyrit_conf initializer ordering)."
119+
)
120+
121+
selected_entries = self._narrow_by_target_names(discovered_entries=discovered_entries)
122+
123+
fanned_specs = self._build_fanned_specs(target_entries=selected_entries)
124+
125+
attack_registry = AttackTechniqueRegistry.get_registry_singleton()
126+
attack_registry.register_from_specs(fanned_specs)
127+
128+
logger.info(
129+
"BenchmarkInitializer: registered %d fanned spec(s) across %d adversarial target(s): %s",
130+
len(fanned_specs),
131+
len(selected_entries),
132+
", ".join(entry.name for entry in selected_entries),
133+
)
134+
135+
def _narrow_by_target_names(self, *, discovered_entries: list) -> list:
136+
"""
137+
Filter ``discovered_entries`` to the names in ``self.params['target_names']``, if set.
138+
139+
Args:
140+
discovered_entries: The full set of adversarial-tagged registry entries.
141+
142+
Returns:
143+
list: ``discovered_entries`` unchanged when no ``target_names`` param
144+
is set, otherwise the subset whose ``name`` is in the requested set.
145+
146+
Raises:
147+
ValueError: If any name in ``self.params['target_names']`` is not
148+
present in ``discovered_entries``.
149+
"""
150+
target_names_param = self.params.get("target_names")
151+
if not target_names_param:
152+
return discovered_entries
153+
154+
requested = set(target_names_param)
155+
discovered_names = {entry.name for entry in discovered_entries}
156+
unknown = requested - discovered_names
157+
if unknown:
158+
raise ValueError(
159+
f"BenchmarkInitializer: unknown target_names {sorted(unknown)}. "
160+
f"Discovered adversarial targets: {sorted(discovered_names)}."
161+
)
162+
return [entry for entry in discovered_entries if entry.name in requested]
163+
164+
def _build_fanned_specs(self, *, target_entries: list) -> list[AttackTechniqueSpec]:
165+
"""
166+
Build fanned ``AttackTechniqueSpec``s for every (adversarial-capable technique, target) pair.
167+
168+
Adversarial-capability is determined by ``_spec_needs_adversarial``
169+
(re-used from ``scenario_techniques``): a spec needs an adversarial
170+
chat target when its attack class accepts ``attack_adversarial_config``
171+
or its ``seed_technique`` has a simulated conversation. Non-adversarial
172+
techniques (e.g. ``prompt_sending``, ``role_play``) are skipped — the
173+
benchmark holds the objective target constant and varies the
174+
adversarial chat helper across runs.
175+
176+
Args:
177+
target_entries: The adversarial-tagged registry entries to fan over.
178+
179+
Returns:
180+
list[AttackTechniqueSpec]: One fanned spec per (adversarial-capable
181+
technique, target entry) pair, with the live target bound onto
182+
``adversarial_chat`` and benchmark-specific strategy tags appended.
183+
"""
184+
fanned: list[AttackTechniqueSpec] = []
185+
for source_spec in SCENARIO_TECHNIQUES:
186+
if not _spec_needs_adversarial(source_spec):
187+
continue
188+
fanned.extend(
189+
dataclasses.replace(
190+
source_spec,
191+
name=f"{source_spec.name}__{entry.name}",
192+
adversarial_chat=entry.instance,
193+
adversarial_chat_key=None,
194+
strategy_tags=[*source_spec.strategy_tags, "benchmark_fanout", f"model:{entry.name}"],
195+
)
196+
for entry in target_entries
197+
)
198+
return fanned
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
3+
4+
"""Tests for BenchmarkInitializer."""
5+
6+
from unittest.mock import MagicMock
7+
8+
import pytest
9+
10+
from pyrit.prompt_target import PromptTarget
11+
from pyrit.registry import TargetRegistry
12+
from pyrit.registry.object_registries.attack_technique_registry import AttackTechniqueRegistry
13+
from pyrit.setup.initializers import BenchmarkInitializer
14+
from pyrit.setup.initializers.benchmark import DEFAULT_ADVERSARIAL_TAG_QUERY
15+
from pyrit.setup.initializers.components.targets import TargetInitializerTags
16+
17+
18+
@pytest.fixture(autouse=True)
19+
def reset_registries():
20+
"""Reset technique and target registries between tests."""
21+
AttackTechniqueRegistry.reset_instance()
22+
TargetRegistry.reset_instance()
23+
yield
24+
AttackTechniqueRegistry.reset_instance()
25+
TargetRegistry.reset_instance()
26+
27+
28+
def _register_adversarial_target(*, name: str) -> PromptTarget:
29+
"""Register a mock adversarial-tagged target and return the instance."""
30+
target = MagicMock(spec=PromptTarget)
31+
target.capabilities.includes.return_value = True
32+
registry = TargetRegistry.get_registry_singleton()
33+
registry.register_instance(target, name=name, tags=[TargetInitializerTags.ADVERSARIAL.value])
34+
return target
35+
36+
37+
class TestBenchmarkInitializerBasic:
38+
"""Class metadata tests."""
39+
40+
def test_can_be_created(self):
41+
init = BenchmarkInitializer()
42+
assert init is not None
43+
44+
def test_required_env_vars_is_empty(self):
45+
"""Initializer takes no required env vars; discovery happens via TargetRegistry."""
46+
init = BenchmarkInitializer()
47+
assert init.required_env_vars == []
48+
49+
def test_supported_parameters_declares_target_names(self):
50+
init = BenchmarkInitializer()
51+
names = [p.name for p in init.supported_parameters]
52+
assert "target_names" in names
53+
54+
def test_default_adversarial_tag_query_matches_adversarial_only(self):
55+
"""The default discovery query is exactly ``TagQuery.all("adversarial")``."""
56+
assert DEFAULT_ADVERSARIAL_TAG_QUERY.matches({"adversarial"})
57+
assert not DEFAULT_ADVERSARIAL_TAG_QUERY.matches({"default"})
58+
assert not DEFAULT_ADVERSARIAL_TAG_QUERY.matches(set())
59+
60+
61+
class TestBenchmarkInitializerFanOut:
62+
"""Tests for the fan-out registration behavior."""
63+
64+
async def test_fans_out_one_spec_per_target_per_adversarial_technique(self):
65+
"""N targets * M adversarial-capable techniques = N*M fanned specs in the attack registry."""
66+
_register_adversarial_target(name="adv_a")
67+
_register_adversarial_target(name="adv_b")
68+
69+
init = BenchmarkInitializer()
70+
await init.initialize_async()
71+
72+
attack_registry = AttackTechniqueRegistry.get_registry_singleton()
73+
fanned = attack_registry.get_by_tag(tag="benchmark_fanout")
74+
assert len(fanned) > 0
75+
assert len(fanned) % 2 == 0, "Expected an even count: every adversarial technique fanned across both targets"
76+
77+
fanned_names = {entry.name for entry in fanned}
78+
for name in fanned_names:
79+
assert "__" in name, f"Fanned spec name '{name}' missing '__' separator"
80+
81+
async def test_fanned_spec_names_use_source_double_underscore_target(self):
82+
"""Spec naming contract: ``f'{source_spec.name}__{target_name}'``."""
83+
_register_adversarial_target(name="adv_single")
84+
85+
init = BenchmarkInitializer()
86+
await init.initialize_async()
87+
88+
attack_registry = AttackTechniqueRegistry.get_registry_singleton()
89+
fanned = attack_registry.get_by_tag(tag="model:adv_single")
90+
assert len(fanned) > 0
91+
for entry in fanned:
92+
assert entry.name.endswith("__adv_single")
93+
source_name = entry.name.split("__", 1)[0]
94+
assert source_name and "__" not in source_name
95+
96+
async def test_fanned_specs_carry_benchmark_and_model_tags(self):
97+
"""Each fanned spec is tagged ``benchmark_fanout`` plus ``f'model:{name}'``."""
98+
_register_adversarial_target(name="adv_a")
99+
_register_adversarial_target(name="adv_b")
100+
101+
init = BenchmarkInitializer()
102+
await init.initialize_async()
103+
104+
attack_registry = AttackTechniqueRegistry.get_registry_singleton()
105+
for entry in attack_registry.get_by_tag(tag="benchmark_fanout"):
106+
assert "benchmark_fanout" in entry.tags
107+
model_tags = [tag for tag in entry.tags if tag.startswith("model:")]
108+
assert len(model_tags) == 1, f"Expected exactly one model:* tag on {entry.name}, got {model_tags}"
109+
assert model_tags[0] in ("model:adv_a", "model:adv_b")
110+
111+
async def test_registration_is_idempotent_across_re_init(self):
112+
"""Re-running initialize_async produces the same registry state (per-name idempotent)."""
113+
_register_adversarial_target(name="adv_a")
114+
115+
init = BenchmarkInitializer()
116+
await init.initialize_async()
117+
attack_registry = AttackTechniqueRegistry.get_registry_singleton()
118+
first_count = len(attack_registry.get_by_tag(tag="benchmark_fanout"))
119+
120+
await init.initialize_async()
121+
second_count = len(attack_registry.get_by_tag(tag="benchmark_fanout"))
122+
123+
assert first_count == second_count
124+
125+
126+
class TestBenchmarkInitializerTargetNamesNarrowing:
127+
"""Tests for the optional ``target_names`` parameter."""
128+
129+
async def test_target_names_narrows_to_subset(self):
130+
"""When ``target_names`` is set, only those entries are fanned."""
131+
_register_adversarial_target(name="adv_a")
132+
_register_adversarial_target(name="adv_b")
133+
_register_adversarial_target(name="adv_c")
134+
135+
init = BenchmarkInitializer()
136+
init.params = {"target_names": ["adv_a", "adv_c"]}
137+
await init.initialize_async()
138+
139+
attack_registry = AttackTechniqueRegistry.get_registry_singleton()
140+
model_b_specs = attack_registry.get_by_tag(tag="model:adv_b")
141+
assert model_b_specs == []
142+
143+
model_a_specs = attack_registry.get_by_tag(tag="model:adv_a")
144+
model_c_specs = attack_registry.get_by_tag(tag="model:adv_c")
145+
assert len(model_a_specs) > 0
146+
assert len(model_c_specs) > 0
147+
148+
async def test_target_names_unknown_raises_with_discovered_list(self):
149+
"""Unknown ``target_names`` raise ``ValueError`` naming both the unknowns and the discovered set."""
150+
_register_adversarial_target(name="adv_a")
151+
152+
init = BenchmarkInitializer()
153+
init.params = {"target_names": ["nonexistent"]}
154+
155+
with pytest.raises(ValueError, match=r"nonexistent.*adv_a"):
156+
await init.initialize_async()
157+
158+
async def test_empty_target_names_param_falls_back_to_default_query(self):
159+
"""An empty ``target_names`` list is treated as "no narrowing" (same as omitting it)."""
160+
_register_adversarial_target(name="adv_a")
161+
162+
init = BenchmarkInitializer()
163+
init.params = {"target_names": []}
164+
await init.initialize_async()
165+
166+
attack_registry = AttackTechniqueRegistry.get_registry_singleton()
167+
assert len(attack_registry.get_by_tag(tag="model:adv_a")) > 0
168+
169+
170+
class TestBenchmarkInitializerErrorMessages:
171+
"""Tests for the actionable error message on empty discovery."""
172+
173+
async def test_no_adversarial_targets_raises_with_actionable_message(self):
174+
"""``ValueError`` must name ``ADVERSARIAL_CHAT_*`` env vars and the ``TargetInitializer`` dependency."""
175+
init = BenchmarkInitializer()
176+
with pytest.raises(ValueError) as exc_info:
177+
await init.initialize_async()
178+
179+
msg = str(exc_info.value)
180+
assert "ADVERSARIAL_CHAT_" in msg
181+
assert "TargetInitializer" in msg

0 commit comments

Comments
 (0)