Skip to content

Commit 946b674

Browse files
romanlutzCopilot
andauthored
FEAT: Wire GCG extension protocol implementations (#2070)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 0bbeb71 commit 946b674

7 files changed

Lines changed: 892 additions & 25 deletions

File tree

pyrit/auxiliary_attacks/gcg/attack/gcg/gcg_attack.py

Lines changed: 134 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@
1717
get_embedding_matrix,
1818
get_embeddings,
1919
)
20+
from pyrit.auxiliary_attacks.gcg.default_implementations import (
21+
CrossEntropyLoss,
22+
LengthPreservingFilter,
23+
StandardGCGSampling,
24+
)
25+
from pyrit.auxiliary_attacks.gcg.extension_protocols import CandidateFilter, LossFunction, SamplingStrategy
2026

2127
logger = logging.getLogger(__name__)
2228

@@ -125,6 +131,99 @@ def sample_control(
125131
class GCGMultiPromptAttack(MultiPromptAttack):
126132
"""GCG-specific multi-prompt attack that implements the GCG optimization step."""
127133

134+
def __init__(
135+
self,
136+
goals: list[str],
137+
targets: list[str],
138+
workers: list[Any],
139+
control_init: str = "! ! ! ! ! ! ! ! ! ! ! ! ! ! ! ! ! ! ! !",
140+
test_prefixes: list[str] | None = None,
141+
logfile: str | None = None,
142+
managers: dict[str, Any] | None = None,
143+
test_goals: list[str] | None = None,
144+
test_targets: list[str] | None = None,
145+
test_workers: list[Any] | None = None,
146+
*,
147+
sampling: SamplingStrategy | None = None,
148+
loss: LossFunction | None = None,
149+
candidate_filter: CandidateFilter | None = None,
150+
) -> None:
151+
super().__init__(
152+
goals,
153+
targets,
154+
workers,
155+
control_init,
156+
test_prefixes,
157+
logfile,
158+
managers,
159+
test_goals,
160+
test_targets,
161+
test_workers,
162+
)
163+
self._sampling = sampling
164+
self._loss = loss
165+
self._candidate_filter = candidate_filter
166+
167+
def _resolve_sampling(self) -> SamplingStrategy:
168+
sampling = getattr(self, "_sampling", None)
169+
if sampling is not None:
170+
return sampling
171+
return StandardGCGSampling()
172+
173+
def _resolve_loss(self, *, target_weight: float, control_weight: float) -> LossFunction:
174+
loss = getattr(self, "_loss", None)
175+
if loss is not None:
176+
return loss
177+
return CrossEntropyLoss(target_weight=target_weight, control_weight=control_weight)
178+
179+
def _resolve_candidate_filter(self, *, filter_cand: bool) -> CandidateFilter:
180+
candidate_filter = getattr(self, "_candidate_filter", None)
181+
if candidate_filter is not None:
182+
return candidate_filter
183+
return LengthPreservingFilter(filter=filter_cand)
184+
185+
def _sample_control_candidates(
186+
self,
187+
*,
188+
worker_index: int,
189+
gradient: torch.Tensor,
190+
batch_size: int,
191+
topk: int,
192+
temp: float,
193+
allow_non_ascii: bool,
194+
) -> torch.Tensor:
195+
sampler = self._resolve_sampling()
196+
prompt_manager = self.prompts[worker_index]
197+
return sampler.sample_candidates(
198+
gradient=gradient,
199+
control_tokens=prompt_manager.control_toks,
200+
batch_size=batch_size,
201+
top_k=topk,
202+
temperature=temp,
203+
allow_non_ascii=allow_non_ascii,
204+
non_ascii_tokens=prompt_manager.disallowed_toks,
205+
)
206+
207+
def _filter_control_candidates(
208+
self,
209+
*,
210+
worker_index: int,
211+
control_cand: torch.Tensor,
212+
filter_cand: bool,
213+
) -> list[str]:
214+
candidate_filter = self._resolve_candidate_filter(filter_cand=filter_cand)
215+
return candidate_filter.filter_candidates(
216+
candidate_tokens=control_cand,
217+
tokenizer=self.workers[worker_index].tokenizer,
218+
current_control=self.control_str,
219+
)
220+
221+
def _get_control_length(self, *, control: str) -> int | None:
222+
try:
223+
return len(self.workers[0].tokenizer(control).input_ids[1:])
224+
except (AttributeError, TypeError, ValueError):
225+
return None
226+
128227
def step(
129228
self,
130229
*,
@@ -158,6 +257,7 @@ def step(
158257
"""
159258
main_device = self.models[0].device
160259
control_cands = []
260+
loss_function = self._resolve_loss(target_weight=target_weight, control_weight=control_weight)
161261

162262
for j, worker in enumerate(self.workers):
163263
worker(self.prompts[j], "grad", worker.model)
@@ -171,20 +271,40 @@ def step(
171271
grad = torch.zeros_like(new_grad)
172272
if grad.shape != new_grad.shape:
173273
with torch.no_grad():
174-
control_cand = self.prompts[j - 1].sample_control(grad, batch_size, topk, temp, allow_non_ascii)
274+
control_cand = self._sample_control_candidates(
275+
worker_index=j - 1,
276+
gradient=grad,
277+
batch_size=batch_size,
278+
topk=topk,
279+
temp=temp,
280+
allow_non_ascii=allow_non_ascii,
281+
)
175282
control_cands.append(
176-
self.get_filtered_cands(
177-
j - 1, control_cand, filter_cand=filter_cand, curr_control=self.control_str
283+
self._filter_control_candidates(
284+
worker_index=j - 1,
285+
control_cand=control_cand,
286+
filter_cand=filter_cand,
178287
)
179288
)
180289
grad = new_grad
181290
else:
182291
grad += new_grad
183292

184293
with torch.no_grad():
185-
control_cand = self.prompts[j].sample_control(grad, batch_size, topk, temp, allow_non_ascii)
294+
control_cand = self._sample_control_candidates(
295+
worker_index=j,
296+
gradient=grad,
297+
batch_size=batch_size,
298+
topk=topk,
299+
temp=temp,
300+
allow_non_ascii=allow_non_ascii,
301+
)
186302
control_cands.append(
187-
self.get_filtered_cands(j, control_cand, filter_cand=filter_cand, curr_control=self.control_str)
303+
self._filter_control_candidates(
304+
worker_index=j,
305+
control_cand=control_cand,
306+
filter_cand=filter_cand,
307+
)
188308
)
189309
del grad, control_cand
190310
gc.collect()
@@ -205,14 +325,14 @@ def step(
205325
worker(self.prompts[k][i], "logits", worker.model, cand, return_ids=True)
206326
logits, ids = zip(*[worker.results.get() for worker in self.workers])
207327
loss[j * batch_size : (j + 1) * batch_size] += sum(
208-
target_weight * self.prompts[k][i].target_loss(logit, id).mean(dim=-1).to(main_device)
328+
loss_function.compute_loss(
329+
logits=logit,
330+
token_ids=id,
331+
target_slice=self.prompts[k][i]._target_slice,
332+
control_slice=self.prompts[k][i]._control_slice,
333+
).to(main_device)
209334
for k, (logit, id) in enumerate(zip(logits, ids))
210335
)
211-
if control_weight != 0:
212-
loss[j * batch_size : (j + 1) * batch_size] += sum(
213-
control_weight * self.prompts[k][i].control_loss(logit, id).mean(dim=-1).to(main_device)
214-
for k, (logit, id) in enumerate(zip(logits, ids))
215-
)
216336
del logits, ids
217337
gc.collect()
218338

@@ -229,7 +349,9 @@ def step(
229349
del control_cands, loss
230350
gc.collect()
231351

232-
logger.info(f"Current length: {len(self.workers[0].tokenizer(next_control).input_ids[1:])}")
352+
current_length = self._get_control_length(control=next_control)
353+
if current_length is not None:
354+
logger.info(f"Current length: {current_length}")
233355
logger.info(next_control)
234356

235357
return next_control, cand_loss.item() / len(self.prompts[0]) / len(self.workers)

pyrit/auxiliary_attacks/gcg/config.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@
2525
if TYPE_CHECKING:
2626
from pathlib import Path
2727

28+
from pyrit.auxiliary_attacks.gcg.extension_protocols import (
29+
CandidateFilter,
30+
LossFunction,
31+
SamplingStrategy,
32+
SuffixInitializer,
33+
)
34+
2835
_DEFAULT_CONTROL_INIT: str = "! ! ! ! ! ! ! ! ! ! ! ! ! ! ! ! ! ! ! !"
2936

3037

@@ -147,6 +154,18 @@ class GCGAlgorithmConfig:
147154
random_seed (int): Seed for ``torch``/``numpy``/``random``. Defaults to 42.
148155
control_init (str): Initial suffix string the optimization starts from.
149156
Defaults to twenty space-separated ``!`` tokens.
157+
sampling (SamplingStrategy | None): Optional strategy object that
158+
samples candidate suffix token sequences from the aggregated
159+
gradient. ``None`` uses the built-in default implementation.
160+
loss (LossFunction | None): Optional loss object used to score each
161+
candidate suffix. ``None`` uses the built-in weighted
162+
cross-entropy default that preserves legacy behavior.
163+
candidate_filter (CandidateFilter | None): Optional candidate-filter
164+
object that decodes/prunes sampled candidate token sequences.
165+
``None`` uses the built-in length-preserving filter.
166+
suffix_init (SuffixInitializer | None): Optional initializer object
167+
that produces the initial suffix string at attack construction
168+
time. ``None`` uses ``control_init`` verbatim.
150169
"""
151170

152171
n_steps: int = 500
@@ -161,6 +180,10 @@ class GCGAlgorithmConfig:
161180
filter_cand: bool = True
162181
random_seed: int = 42
163182
control_init: str = _DEFAULT_CONTROL_INIT
183+
sampling: SamplingStrategy | None = None
184+
loss: LossFunction | None = None
185+
candidate_filter: CandidateFilter | None = None
186+
suffix_init: SuffixInitializer | None = None
164187

165188
def __post_init__(self) -> None:
166189
if self.n_steps <= 0:
@@ -183,6 +206,27 @@ def __post_init__(self) -> None:
183206
)
184207
if not self.control_init:
185208
raise ValueError("GCGAlgorithmConfig.control_init must be a non-empty string.")
209+
self._validate_extensions()
210+
211+
def _validate_extensions(self) -> None:
212+
from pyrit.auxiliary_attacks.gcg.extension_protocols import (
213+
CandidateFilter,
214+
LossFunction,
215+
SamplingStrategy,
216+
SuffixInitializer,
217+
)
218+
219+
checks = (
220+
("sampling", self.sampling, SamplingStrategy),
221+
("loss", self.loss, LossFunction),
222+
("candidate_filter", self.candidate_filter, CandidateFilter),
223+
("suffix_init", self.suffix_init, SuffixInitializer),
224+
)
225+
for field_name, value, protocol in checks:
226+
if value is not None and not isinstance(value, protocol):
227+
raise ValueError(
228+
f"GCGAlgorithmConfig.{field_name} must satisfy {protocol.__name__}, got {type(value)!r}."
229+
)
186230

187231

188232
@dataclass

pyrit/auxiliary_attacks/gcg/extension_protocols.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,11 @@
1616
- ``SuffixInitializer`` — how the initial suffix string fed into the
1717
optimization loop is constructed.
1818
19-
The module is **typing surface only**. It ships no concrete implementations,
20-
no defaults, and no wiring into ``GCGAlgorithmConfig`` or
21-
``GCGMultiPromptAttack``. The default behaviors that match the current attack
22-
code will land as concrete classes in a follow-up PR; the optional
23-
``GCGAlgorithmConfig`` fields that select between defaults and custom
24-
implementations will land in the PR after that.
19+
The module is **typing surface only**. Concrete defaults live in
20+
``default_implementations.py``, and orchestration wiring lives in
21+
``GCGAlgorithmConfig`` + ``GCGMultiPromptAttack``. Keeping this module purely
22+
protocol definitions preserves a stable extension API that can be imported
23+
without pulling in heavy runtime dependencies.
2524
2625
Tensor-typed signatures are kept lazy via ``from __future__ import
2726
annotations`` plus a ``TYPE_CHECKING`` import for ``torch`` so that

pyrit/auxiliary_attacks/gcg/generator.py

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
import logging
3939
import time
4040
from dataclasses import dataclass, field
41+
from functools import partial
4142
from typing import Any, overload
4243

4344
import numpy as np
@@ -212,6 +213,18 @@ def _build_identifier(self) -> ComponentIdentifier:
212213
"topk": self._algorithm.topk,
213214
"target_weight": self._algorithm.target_weight,
214215
"control_weight": self._algorithm.control_weight,
216+
"sampling_impl": (
217+
type(self._algorithm.sampling).__name__ if self._algorithm.sampling is not None else "default"
218+
),
219+
"loss_impl": type(self._algorithm.loss).__name__ if self._algorithm.loss is not None else "default",
220+
"candidate_filter_impl": (
221+
type(self._algorithm.candidate_filter).__name__
222+
if self._algorithm.candidate_filter is not None
223+
else "default"
224+
),
225+
"suffix_init_impl": (
226+
type(self._algorithm.suffix_init).__name__ if self._algorithm.suffix_init is not None else "default"
227+
),
215228
"transfer": self._strategy.transfer,
216229
"progressive_goals": self._strategy.progressive_goals,
217230
"progressive_models": self._strategy.progressive_models,
@@ -257,7 +270,12 @@ async def _perform_async(self, *, context: GCGContext) -> GCGResult:
257270
managers = {
258271
"AP": attack_lib.GCGAttackPrompt,
259272
"PM": attack_lib.GCGPromptManager,
260-
"MPA": attack_lib.GCGMultiPromptAttack,
273+
"MPA": partial(
274+
attack_lib.GCGMultiPromptAttack,
275+
sampling=self._algorithm.sampling,
276+
loss=self._algorithm.loss,
277+
candidate_filter=self._algorithm.candidate_filter,
278+
),
261279
}
262280
context.attack = self._create_attack(
263281
params=params,
@@ -400,14 +418,15 @@ def _create_attack(
400418
logfile_path: str,
401419
) -> Any:
402420
"""Build the right attack object based on the strategy flags."""
421+
control_init = self._resolve_control_init(workers=workers)
403422
if self._strategy.transfer:
404423
return ProgressiveMultiPromptAttack(
405424
train_goals,
406425
train_targets,
407426
workers,
408427
progressive_models=self._strategy.progressive_models,
409428
progressive_goals=self._strategy.progressive_goals,
410-
control_init=self._algorithm.control_init,
429+
control_init=control_init,
411430
logfile=logfile_path,
412431
managers=managers,
413432
test_goals=test_goals,
@@ -421,7 +440,7 @@ def _create_attack(
421440
train_goals,
422441
train_targets,
423442
workers,
424-
control_init=self._algorithm.control_init,
443+
control_init=control_init,
425444
logfile=logfile_path,
426445
managers=managers,
427446
test_goals=test_goals,
@@ -432,6 +451,18 @@ def _create_attack(
432451
mpa_n_steps=self._algorithm.n_steps,
433452
)
434453

454+
def _resolve_control_init(self, *, workers: list[Any]) -> str:
455+
"""Resolve the initial suffix string for a run.
456+
457+
Uses the configured ``suffix_init`` extension when provided; otherwise
458+
falls back to the legacy literal ``control_init`` value.
459+
"""
460+
if self._algorithm.suffix_init is None:
461+
return self._algorithm.control_init
462+
if not workers:
463+
raise ValueError("Cannot resolve suffix_init without at least one worker tokenizer.")
464+
return self._algorithm.suffix_init.make_initial_suffix(tokenizer=workers[0].tokenizer)
465+
435466
@staticmethod
436467
def _read_result(*, logfile_path: str, memory_labels: dict[str, str]) -> GCGResult:
437468
"""Pull final-step values out of the JSON log written during the run."""

0 commit comments

Comments
 (0)