Skip to content

Commit dfa2b76

Browse files
authored
MAINT GCG: model progressive admission transitions (#2665) (#2720)
1 parent 5f8361a commit dfa2b76

4 files changed

Lines changed: 480 additions & 77 deletions

File tree

pyrit/executor/promptgen/gcg/attack/base/attack_manager.py

Lines changed: 32 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@
2424
from transformers.models.gpt_neox.modeling_gpt_neox import GPTNeoXForCausalLM
2525
from transformers.models.gptj.modeling_gptj import GPTJForCausalLM
2626

27+
from pyrit.executor.promptgen.gcg.attack.base.progressive_schedule import (
28+
ProgressiveScheduleController,
29+
ProgressiveScheduleState,
30+
ScheduleTransitionAction,
31+
)
2732
from pyrit.executor.promptgen.gcg.experiments.log import (
2833
log_gpu_memory,
2934
log_loss,
@@ -80,24 +85,6 @@ class OptimizationRunState:
8085
stop_reason: StopReason | None = None
8186

8287

83-
@dataclass
84-
class ProgressiveScheduleState:
85-
"""
86-
Typed schedule state for ``ProgressiveMultiPromptAttack``.
87-
88-
Tracks how many goals and workers have been admitted so far, together with
89-
the shared step counter and the loss carried between progressive rounds.
90-
Exposed as ``ProgressiveMultiPromptAttack.last_schedule_state`` after a call
91-
to ``ProgressiveMultiPromptAttack.run``.
92-
"""
93-
94-
goals_admitted: int
95-
workers_admitted: int
96-
steps_completed: int = 0
97-
loss: float = float("inf")
98-
stop_inner_on_success: bool = False
99-
100-
10188
@dataclass
10289
class RngBundle:
10390
"""Per-run RNG state bundle for deterministic GCG execution."""
@@ -1507,23 +1494,25 @@ def run(
15071494
},
15081495
)
15091496

1510-
schedule = ProgressiveScheduleState(
1511-
goals_admitted=1 if self.progressive_goals else len(self.goals),
1512-
workers_admitted=1 if self.progressive_models else len(self.workers),
1513-
stop_inner_on_success=self.progressive_goals,
1497+
controller = ProgressiveScheduleController(
1498+
total_goals=len(self.goals),
1499+
total_workers=len(self.workers),
1500+
progressive_goals=self.progressive_goals,
1501+
progressive_models=self.progressive_models,
1502+
n_steps=n_steps,
1503+
control_weight=control_weight,
1504+
incr_control=incr_control,
1505+
stop_on_success=stop_on_success,
1506+
verbose=verbose,
15141507
)
1515-
# Whether ``schedule.loss`` currently reflects an inner run's measured
1516-
# loss, as opposed to the ``inf`` sentinel written when a new round is
1517-
# admitted. Tracked explicitly so a legitimately non-finite inner loss
1518-
# (non-finite model loss or numeric overflow) is not mistaken for an
1519-
# unupdated sentinel value.
1520-
loss_is_measured = False
1521-
1522-
while schedule.steps_completed < n_steps:
1508+
1509+
while not controller.is_complete:
1510+
controller.before_inner_run()
1511+
schedule = controller.state
15231512
attack = self.managers["MPA"](
1524-
self.goals[: schedule.goals_admitted],
1525-
self.targets[: schedule.goals_admitted],
1526-
self.workers[: schedule.workers_admitted],
1513+
self.goals[: controller.active_goal_count],
1514+
self.targets[: controller.active_goal_count],
1515+
self.workers[: controller.active_worker_count],
15271516
self.control,
15281517
self.test_prefixes,
15291518
self.logfile,
@@ -1532,17 +1521,15 @@ def run(
15321521
self.test_targets,
15331522
self.test_workers,
15341523
)
1535-
if schedule.goals_admitted == len(self.goals) and schedule.workers_admitted == len(self.workers):
1536-
schedule.stop_inner_on_success = False
15371524
attack._rng_bundle = rng_bundle
15381525
inner_result: tuple[str, float, int] = attack.run(
1539-
n_steps=n_steps - schedule.steps_completed,
1526+
n_steps=controller.remaining_steps,
15401527
batch_size=batch_size,
15411528
topk=topk,
15421529
temp=temp,
15431530
allow_non_ascii=allow_non_ascii,
15441531
target_weight=target_weight,
1545-
control_weight=control_weight,
1532+
control_weight=controller.control_weight,
15461533
anneal=anneal,
15471534
anneal_from=schedule.steps_completed,
15481535
prev_loss=schedule.loss,
@@ -1553,28 +1540,13 @@ def run(
15531540
random_seed=random_seed,
15541541
)
15551542
control, inner_loss, inner_steps = inner_result
1556-
schedule.loss = inner_loss
1557-
loss_is_measured = True
1558-
1559-
schedule.steps_completed += inner_steps
15601543
self.control = control
15611544

1562-
# Once the step budget is spent, stop preparing further rounds:
1563-
# admissions and their sentinel resets would strand ``inf`` on
1564-
# ``schedule.loss`` for a run that legitimately ends right here.
1565-
prepare_next_round = schedule.steps_completed < n_steps
1566-
1567-
if schedule.goals_admitted < len(self.goals):
1568-
if prepare_next_round:
1569-
schedule.goals_admitted += 1
1570-
schedule.loss = np.inf
1571-
loss_is_measured = False
1572-
elif schedule.workers_admitted < len(self.workers):
1573-
if prepare_next_round:
1574-
schedule.workers_admitted += 1
1575-
schedule.loss = np.inf
1576-
loss_is_measured = False
1577-
elif schedule.workers_admitted == len(self.workers) and stop_on_success:
1545+
action = controller.advance_after_inner_run(
1546+
inner_loss=inner_loss,
1547+
inner_steps=inner_steps,
1548+
)
1549+
if action == ScheduleTransitionAction.FINALIZE_AND_STOP:
15781550
self._finalize_progressive_run(
15791551
attack=attack,
15801552
step=schedule.steps_completed,
@@ -1583,27 +1555,11 @@ def run(
15831555
verbose=verbose,
15841556
)
15851557
break
1586-
elif prepare_next_round and isinstance(control_weight, (int, float)) and incr_control:
1587-
if control_weight <= 0.09:
1588-
control_weight += 0.01
1589-
schedule.loss = np.inf
1590-
loss_is_measured = False
1591-
if verbose:
1592-
logger.info(f"Control weight increased to {control_weight:.5}")
1593-
else:
1594-
schedule.stop_inner_on_success = False
1595-
1596-
# The inner run must have produced a measured loss whenever any
1597-
# optimization happened; guards against silent carry-over regressions.
1598-
# Whether the loss was measured is tracked explicitly (a completed
1599-
# inner run may legitimately report a non-finite loss), never inferred
1600-
# from the numeric value.
1601-
if schedule.steps_completed > 0:
1602-
assert loss_is_measured, "schedule.loss was never updated by the inner run"
16031558

1604-
self.last_schedule_state = schedule
1559+
controller.validate_post_run()
1560+
self.last_schedule_state = controller.state
16051561

1606-
return self.control, schedule.steps_completed
1562+
return self.control, controller.state.steps_completed
16071563

16081564

16091565
class IndividualPromptAttack:
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
3+
4+
"""Progressive schedule controller and state models for Greedy Coordinate Gradient (GCG) attacks."""
5+
6+
import logging
7+
from dataclasses import dataclass
8+
from enum import Enum, auto
9+
10+
logger = logging.getLogger(__name__)
11+
12+
13+
@dataclass
14+
class ProgressiveScheduleState:
15+
"""
16+
Typed schedule state for ``ProgressiveMultiPromptAttack``.
17+
18+
Tracks how many goals and workers have been admitted so far, together with
19+
the shared step counter and the loss carried between progressive rounds.
20+
Exposed as ``ProgressiveMultiPromptAttack.last_schedule_state`` after a call
21+
to ``ProgressiveMultiPromptAttack.run``.
22+
"""
23+
24+
goals_admitted: int
25+
workers_admitted: int
26+
steps_completed: int = 0
27+
loss: float = float("inf")
28+
stop_inner_on_success: bool = False
29+
30+
31+
class ScheduleTransitionAction(Enum):
32+
"""Action to take following an inner attack result in progressive scheduling."""
33+
34+
CONTINUE = auto()
35+
FINALIZE_AND_STOP = auto()
36+
37+
38+
class ProgressiveScheduleController:
39+
"""Encapsulates progressive admission, step budget scheduling, and state transitions."""
40+
41+
def __init__(
42+
self,
43+
*,
44+
total_goals: int,
45+
total_workers: int,
46+
progressive_goals: bool = True,
47+
progressive_models: bool = True,
48+
n_steps: int = 1000,
49+
control_weight: float | None = None,
50+
incr_control: bool = True,
51+
stop_on_success: bool = True,
52+
verbose: bool = True,
53+
) -> None:
54+
"""
55+
Initialize the progressive schedule controller.
56+
57+
Args:
58+
total_goals: Total number of attack goals available for admission.
59+
total_workers: Total number of model workers available for admission.
60+
progressive_goals: Whether goals are admitted progressively one at a time.
61+
progressive_models: Whether models/workers are admitted progressively one at a time.
62+
n_steps: Total step budget across all progressive rounds.
63+
control_weight: Current control weight, or None.
64+
incr_control: Whether to increment control weight when all goals/models are admitted.
65+
stop_on_success: Whether to finalize and stop when fully admitted and success is achieved.
66+
verbose: Whether to log control weight updates.
67+
68+
Raises:
69+
ValueError: If total_goals or total_workers is not positive.
70+
"""
71+
if total_goals <= 0:
72+
raise ValueError(f"total_goals must be positive, got {total_goals}")
73+
if total_workers <= 0:
74+
raise ValueError(f"total_workers must be positive, got {total_workers}")
75+
76+
self._total_goals = total_goals
77+
self._total_workers = total_workers
78+
self._n_steps = n_steps
79+
self._control_weight = control_weight
80+
self._incr_control = incr_control
81+
self._stop_on_success = stop_on_success
82+
self._verbose = verbose
83+
84+
self._state = ProgressiveScheduleState(
85+
goals_admitted=1 if progressive_goals else total_goals,
86+
workers_admitted=1 if progressive_models else total_workers,
87+
stop_inner_on_success=progressive_goals,
88+
)
89+
self._loss_is_measured = False
90+
91+
@property
92+
def state(self) -> ProgressiveScheduleState:
93+
"""The current progressive schedule state."""
94+
return self._state
95+
96+
@property
97+
def is_complete(self) -> bool:
98+
"""Whether the overall step budget has been exhausted."""
99+
return self._state.steps_completed >= self._n_steps
100+
101+
@property
102+
def remaining_steps(self) -> int:
103+
"""The remaining number of optimization steps in the budget."""
104+
return max(0, self._n_steps - self._state.steps_completed)
105+
106+
@property
107+
def active_goal_count(self) -> int:
108+
"""The number of currently admitted goals."""
109+
return self._state.goals_admitted
110+
111+
@property
112+
def active_worker_count(self) -> int:
113+
"""The number of currently admitted workers."""
114+
return self._state.workers_admitted
115+
116+
@property
117+
def control_weight(self) -> float | None:
118+
"""The current control weight."""
119+
return self._control_weight
120+
121+
@property
122+
def is_fully_admitted(self) -> bool:
123+
"""Whether all goals and workers have been admitted."""
124+
return self._state.goals_admitted == self._total_goals and self._state.workers_admitted == self._total_workers
125+
126+
def before_inner_run(self) -> None:
127+
"""Prepare schedule state immediately before launching an inner attack."""
128+
if self.is_fully_admitted:
129+
self._state.stop_inner_on_success = False
130+
131+
def advance_after_inner_run(
132+
self,
133+
*,
134+
inner_loss: float,
135+
inner_steps: int,
136+
) -> ScheduleTransitionAction:
137+
"""
138+
Update schedule state and determine the next action after an inner attack round completes.
139+
140+
Args:
141+
inner_loss: Final loss reported by the inner attack.
142+
inner_steps: Number of steps completed by the inner attack.
143+
144+
Returns:
145+
ScheduleTransitionAction indicating whether to continue or finalize and stop.
146+
"""
147+
self._state.loss = inner_loss
148+
self._loss_is_measured = True
149+
self._state.steps_completed += inner_steps
150+
151+
prepare_next_round = self._state.steps_completed < self._n_steps
152+
153+
if self._state.goals_admitted < self._total_goals:
154+
if prepare_next_round:
155+
self._state.goals_admitted += 1
156+
self._state.loss = float("inf")
157+
self._loss_is_measured = False
158+
return ScheduleTransitionAction.CONTINUE
159+
160+
if self._state.workers_admitted < self._total_workers:
161+
if prepare_next_round:
162+
self._state.workers_admitted += 1
163+
self._state.loss = float("inf")
164+
self._loss_is_measured = False
165+
return ScheduleTransitionAction.CONTINUE
166+
167+
if self._state.workers_admitted == self._total_workers and self._stop_on_success:
168+
return ScheduleTransitionAction.FINALIZE_AND_STOP
169+
170+
if prepare_next_round and isinstance(self._control_weight, (int, float)) and self._incr_control:
171+
if self._control_weight <= 0.09:
172+
self._control_weight += 0.01
173+
self._state.loss = float("inf")
174+
self._loss_is_measured = False
175+
if self._verbose:
176+
logger.info(f"Control weight increased to {self._control_weight:.5}")
177+
else:
178+
self._state.stop_inner_on_success = False
179+
180+
return ScheduleTransitionAction.CONTINUE
181+
182+
def validate_post_run(self) -> None:
183+
"""
184+
Validate post-run invariants.
185+
186+
Raises:
187+
AssertionError: If steps were completed but schedule.loss was never updated by an inner run.
188+
"""
189+
if self._state.steps_completed > 0:
190+
assert self._loss_is_measured, "schedule.loss was never updated by the inner run"

0 commit comments

Comments
 (0)