Skip to content

Commit 52dfa12

Browse files
committed
fix(GCG): address review — schedule loss carry-over, stale state clearing, candidate loss pairing
- Replace RST roles with double-backtick references (check-no-rest-roles) - ProgressiveMultiPromptAttack carries the inner loss on ``ProgressiveScheduleState.loss`` instead of a loose local, so ``last_schedule_state.loss`` reflects the final inner run; added a post-loop assertion guarding silent carry-over regressions - ``last_run_state`` / ``last_schedule_state`` default to ``None`` and are cleared at the start of each run, so failed runs expose no stale data - Annealing rejection no longer pairs the accepted suffix with the rejected candidate's loss: ``OptimizationRunState.candidate_loss`` tracks what was just evaluated while ``loss`` stays paired with ``state.control`` Signed-off-by: fei <204683769+feiiiiii5@users.noreply.github.com>
1 parent b718775 commit 52dfa12

2 files changed

Lines changed: 83 additions & 15 deletions

File tree

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

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -68,14 +68,17 @@ class OptimizationRunState:
6868
Captures the current suffix, losses, best result, counters, and stop reason
6969
explicitly instead of leaving them as loose loop locals, so each phase of
7070
the optimization loop has a stable contract that can be asserted under
71-
seeded tests. Exposed as ``MultiPromptAttack.last_run_state`` after a call
72-
to :meth:`MultiPromptAttack.run`.
71+
seeded tests. ``loss`` is the loss of the *active* control suffix;
72+
``candidate_loss`` is the loss of the most recently evaluated candidate,
73+
which annealing may have rejected. Exposed as ``MultiPromptAttack.last_run_state`` after a call
74+
to ``MultiPromptAttack.run``.
7375
"""
7476

7577
control: str
7678
best_control: str
7779
loss: float
7880
best_loss: float
81+
candidate_loss: float | None = None
7982
steps_completed: int = 0
8083
runtime: float = 0.0
8184
stop_reason: StopReason | None = None
@@ -84,12 +87,12 @@ class OptimizationRunState:
8487
@dataclass
8588
class ProgressiveScheduleState:
8689
"""
87-
Typed schedule state for :class:`ProgressiveMultiPromptAttack`.
90+
Typed schedule state for ``ProgressiveMultiPromptAttack``.
8891
8992
Tracks how many goals and workers have been admitted so far, together with
9093
the shared step counter and the loss carried between progressive rounds.
9194
Exposed as ``ProgressiveMultiPromptAttack.last_schedule_state`` after a call
92-
to :meth:`ProgressiveMultiPromptAttack.run`.
95+
to ``ProgressiveMultiPromptAttack.run``.
9396
"""
9497

9598
goals_admitted: int
@@ -814,6 +817,10 @@ def disallowed_toks(self) -> torch.Tensor:
814817
class MultiPromptAttack:
815818
"""A class used to manage multiple prompt-based attacks."""
816819

820+
#: State of the most recent `run` call; ``None`` until one completes
821+
#: and cleared at the start of each run so failed runs expose no stale data.
822+
last_run_state: OptimizationRunState | None = None
823+
817824
def __init__(
818825
self,
819826
goals: list[str],
@@ -1041,6 +1048,10 @@ def control_weight_fn(_: int) -> float:
10411048
def control_weight_fn(_: int) -> float:
10421049
return control_weight
10431050

1051+
# Clear eagerly: a run that raises mid-loop must not leave the previous
1052+
# run's state looking current.
1053+
self.last_run_state = None
1054+
10441055
state = OptimizationRunState(
10451056
control=self.control_str,
10461057
best_control=self.control_str,
@@ -1082,9 +1093,13 @@ def control_weight_fn(_: int) -> float:
10821093
if keep_control:
10831094
self.control_str = control
10841095
state.control = control
1096+
state.loss = loss
10851097

1098+
# ``candidate_loss`` tracks what was just evaluated even when
1099+
# annealing rejects it, so ``state.loss`` always describes the
1100+
# suffix in ``state.control``.
1101+
state.candidate_loss = loss
10861102
prev_loss = loss
1087-
state.loss = loss
10881103
if loss < state.best_loss:
10891104
state.best_loss = loss
10901105
state.best_control = control
@@ -1251,6 +1266,10 @@ def log(
12511266
class ProgressiveMultiPromptAttack:
12521267
"""A class used to manage multiple progressive prompt-based attacks."""
12531268

1269+
#: State of the most recent `run` call; ``None`` until one completes
1270+
#: and cleared at the start of each run so failed runs expose no stale data.
1271+
last_schedule_state: ProgressiveScheduleState | None = None
1272+
12541273
def __init__(
12551274
self,
12561275
goals: list[str],
@@ -1429,12 +1448,15 @@ def run(
14291448
},
14301449
)
14311450

1451+
# Clear eagerly: a run that raises mid-loop must not leave the previous
1452+
# run's state looking current.
1453+
self.last_schedule_state = None
1454+
14321455
schedule = ProgressiveScheduleState(
14331456
goals_admitted=1 if self.progressive_goals else len(self.goals),
14341457
workers_admitted=1 if self.progressive_models else len(self.workers),
14351458
stop_inner_on_success=self.progressive_goals,
14361459
)
1437-
loss = np.inf
14381460

14391461
while schedule.steps_completed < n_steps:
14401462
attack = self.managers["MPA"](
@@ -1461,39 +1483,49 @@ def run(
14611483
control_weight=control_weight,
14621484
anneal=anneal,
14631485
anneal_from=schedule.steps_completed,
1464-
prev_loss=loss,
1486+
prev_loss=schedule.loss,
14651487
stop_on_success=schedule.stop_inner_on_success,
14661488
test_steps=test_steps,
14671489
filter_cand=filter_cand,
14681490
verbose=verbose,
14691491
)
1470-
control, loss, inner_steps = inner_result
1492+
control, inner_loss, inner_steps = inner_result
1493+
schedule.loss = inner_loss
14711494

14721495
schedule.steps_completed += inner_steps
14731496
self.control = control
14741497

14751498
if schedule.goals_admitted < len(self.goals):
14761499
schedule.goals_admitted += 1
1477-
loss = np.inf
1500+
schedule.loss = np.inf
14781501
elif schedule.goals_admitted == len(self.goals):
14791502
if schedule.workers_admitted < len(self.workers):
14801503
schedule.workers_admitted += 1
1481-
loss = np.inf
1504+
schedule.loss = np.inf
14821505
elif schedule.workers_admitted == len(self.workers) and stop_on_success:
14831506
self._finalize_progressive_run(
1484-
attack=attack, step=schedule.steps_completed, n_steps=n_steps, loss=loss, verbose=verbose
1507+
attack=attack,
1508+
step=schedule.steps_completed,
1509+
n_steps=n_steps,
1510+
loss=schedule.loss,
1511+
verbose=verbose,
14851512
)
14861513
break
14871514
else:
14881515
if isinstance(control_weight, (int, float)) and incr_control:
14891516
if control_weight <= 0.09:
14901517
control_weight += 0.01
1491-
loss = np.inf
1518+
schedule.loss = np.inf
14921519
if verbose:
14931520
logger.info(f"Control weight increased to {control_weight:.5}")
14941521
else:
14951522
schedule.stop_inner_on_success = False
14961523

1524+
# The inner run must have produced a measurable loss whenever any
1525+
# optimization happened; guards against silent carry-over regressions.
1526+
if schedule.steps_completed > 0:
1527+
assert not math.isinf(schedule.loss), "schedule.loss was never updated by the inner run"
1528+
14971529
self.last_schedule_state = schedule
14981530

14991531
return self.control, schedule.steps_completed

tests/unit/executor/promptgen/gcg/test_run_state.py

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ def test_counters_and_stop_reason_default(self) -> None:
4949
assert state.runtime == 0.0
5050
assert state.stop_reason is None
5151

52+
def test_candidate_loss_defaults_to_none(self) -> None:
53+
state = OptimizationRunState(control="c", best_control="c", loss=1e6, best_loss=1e6)
54+
55+
assert state.candidate_loss is None
56+
5257

5358
class TestProgressiveScheduleState:
5459
def test_defaults(self) -> None:
@@ -86,22 +91,39 @@ def test_run_records_jailbroken_stop_reason_without_counting_final_check(self) -
8691
assert state.stop_reason == StopReason.ALL_PROMPTS_JAILBROKEN
8792
attack.step.assert_not_called()
8893

89-
def test_rejected_candidate_keeps_active_suffix_but_updates_loss(self) -> None:
94+
def test_rejected_candidate_keeps_active_suffix_and_loss(self) -> None:
9095
attack = _bare_multi_prompt_attack([("better", 1.0), ("worse", 5.0)])
9196
random.seed(2026)
9297

9398
control, loss, steps = attack.run(n_steps=2, prev_loss=2.0, stop_on_success=False, anneal=True)
9499

95100
# The worse candidate must be rejected by annealing with overwhelming
96-
# probability under this seed; the active suffix stays "better".
101+
# probability under this seed; the active suffix stays "better" and the
102+
# reported loss stays paired with it. The rejected candidate's loss is
103+
# still observable through ``candidate_loss``.
97104
assert control == "better"
98105
assert steps == 2
99106
state: OptimizationRunState = attack.last_run_state
100107
assert state.best_control == "better"
101108
assert state.best_loss == 1.0
102-
assert state.loss == 5.0
109+
assert state.control == "better"
110+
assert state.loss == 1.0
111+
assert state.candidate_loss == 5.0
103112
assert state.stop_reason == StopReason.MAX_STEPS_REACHED
104113

114+
def test_failed_run_clears_stale_last_run_state(self) -> None:
115+
attack = _bare_multi_prompt_attack([("better", 1.0)])
116+
stale = OptimizationRunState(control="stale", best_control="stale", loss=0.1, best_loss=0.1)
117+
attack.last_run_state = stale
118+
attack.step = MagicMock(side_effect=RuntimeError("model exploded"))
119+
120+
with pytest.raises(RuntimeError, match="model exploded"):
121+
attack.run(n_steps=3, stop_on_success=False)
122+
123+
# A run that raises mid-loop must not leave the previous run's state
124+
# looking current.
125+
assert attack.last_run_state is None
126+
105127
def test_periodic_checkpoint_restores_active_suffix(self) -> None:
106128
attack = _bare_multi_prompt_attack([("better", 1.0), ("best-yet", 0.25)])
107129
attack.logfile = "unused-by-test.json" # gate for periodic checkpoints; log/test_all are mocked
@@ -228,3 +250,17 @@ def test_schedule_exhaustion_continues_until_step_budget_spent(self) -> None:
228250
filter_cand=True,
229251
verbose=True,
230252
)
253+
254+
def test_schedule_loss_carried_on_schedule_object(self) -> None:
255+
# The loss fed back between progressive rounds lives on the schedule
256+
# state (not a loose local), so ``last_schedule_state.loss`` reflects
257+
# the final inner run instead of staying at its ``inf`` default.
258+
inner_attack = MagicMock()
259+
inner_attack.run.return_value = ("ctrl", 0.75, 3)
260+
progressive = self._bare_progressive_attack(inner_attack)
261+
262+
progressive.run(n_steps=6, stop_on_success=False)
263+
264+
schedule: ProgressiveScheduleState = progressive.last_schedule_state
265+
assert schedule.steps_completed == 6
266+
assert schedule.loss == 0.75

0 commit comments

Comments
 (0)