Skip to content

Commit 9bb005f

Browse files
romanlutzCopilot
andauthored
TEST: stop GCG unit tests from hitting HuggingFace (#1886)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent a6c3fb0 commit 9bb005f

3 files changed

Lines changed: 127 additions & 113 deletions

File tree

tests/unit/auxiliary_attacks/gcg/test_attack_wiring.py renamed to tests/integration/auxiliary_attacks/test_gcg_attack_wiring_integration.py

Lines changed: 99 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,28 @@
11
# Copyright (c) Microsoft Corporation.
22
# Licensed under the MIT license.
33

4-
"""Tests that exercise the full attack class wiring without mocking manager classes.
5-
6-
These tests catch kwarg mismatches between IndividualPromptAttack/ProgressiveMultiPromptAttack
7-
and MultiPromptAttack.__init__(), and template compatibility issues in _update_ids().
4+
"""Integration tests for GCG attack-class wiring.
5+
6+
These tests construct real ``IndividualPromptAttack`` / ``ProgressiveMultiPromptAttack``
7+
instances and exercise their wiring all the way down through
8+
``GCGAttackPrompt._update_ids``, which calls ``tokenizer.apply_chat_template`` and
9+
walks character positions via ``char_to_token``. They live here (not under
10+
``tests/unit/``) because they require a real HuggingFace tokenizer (gpt2) to
11+
back the chat-template pipeline — mocking that out would defeat the test's
12+
purpose, which is to catch kwarg-mismatch and template-compatibility bugs that
13+
mocked tests miss.
14+
15+
Network dependency: the gpt2 tokenizer is fetched from HuggingFace on first
16+
use (and then cached). This is why these tests run only in the integration tier
17+
(``make integration-test``), not in the PR-time unit-test matrix.
18+
19+
Requires: torch, transformers (GCG optional deps).
20+
Skipped via importorskip when deps are not installed.
821
"""
922

23+
from __future__ import annotations
24+
25+
from typing import TYPE_CHECKING
1026
from unittest.mock import MagicMock, patch
1127

1228
import pytest
@@ -22,29 +38,48 @@
2238
reason="GCG optional dependencies not installed",
2339
)
2440

41+
generator_mod = pytest.importorskip(
42+
"pyrit.auxiliary_attacks.gcg.generator",
43+
reason="GCG optional dependencies (torch, transformers, etc.) not installed",
44+
)
45+
46+
from pyrit.auxiliary_attacks.gcg.config import ( # noqa: E402
47+
GCGAlgorithmConfig,
48+
GCGModelConfig,
49+
GCGOutputConfig,
50+
GCGStrategyConfig,
51+
)
52+
53+
if TYPE_CHECKING:
54+
from pathlib import Path
55+
2556
IndividualPromptAttack = attack_manager_mod.IndividualPromptAttack
2657
ProgressiveMultiPromptAttack = attack_manager_mod.ProgressiveMultiPromptAttack
2758
MultiPromptAttack = attack_manager_mod.MultiPromptAttack
2859
GCGAttackPrompt = gcg_attack_mod.GCGAttackPrompt
2960
GCGPromptManager = gcg_attack_mod.GCGPromptManager
3061
GCGMultiPromptAttack = gcg_attack_mod.GCGMultiPromptAttack
62+
GCGGenerator = generator_mod.GCGGenerator
63+
GCGContext = generator_mod.GCGContext
3164

3265
MANAGERS = {
3366
"AP": GCGAttackPrompt,
3467
"PM": GCGPromptManager,
3568
"MPA": GCGMultiPromptAttack,
3669
}
3770

71+
_LLAMA_2 = "meta-llama/Llama-2-7b-chat-hf"
72+
3873

39-
def _make_mock_worker() -> MagicMock:
40-
"""Create a mock worker whose tokenizer can stand in for a real chat tokenizer.
74+
def _make_mock_worker_with_real_tokenizer() -> MagicMock:
75+
"""Worker mock backed by a real gpt2 tokenizer.
4176
4277
The wiring tests construct real ``GCGAttackPrompt`` instances which call
4378
``tokenizer.apply_chat_template`` and then walk character positions in the
4479
rendered prompt. We need a real string + a tokenizer that can answer
45-
``char_to_token`` queries on it, so we back the mock with a real
46-
distilgpt2 tokenizer (the smallest available transformers tokenizer that
47-
ships with all the methods we touch).
80+
``char_to_token`` queries on it, so we back the mock with the smallest
81+
workable real HF tokenizer (gpt2) plus an explicit llama-2-style chat
82+
template (gpt2 ships without one).
4883
"""
4984
from transformers import AutoTokenizer
5085

@@ -67,9 +102,9 @@ def _make_mock_worker() -> MagicMock:
67102

68103

69104
class TestAttackClassWiring:
70-
"""Tests that verify attack classes can be constructed with real manager classes.
105+
"""Verify attack classes can be constructed and run with real manager classes.
71106
72-
These catch kwarg mismatches that mocked tests miss.
107+
Catches kwarg mismatches that mocked tests miss.
73108
"""
74109

75110
def test_individual_attack_creates_mpa_without_error(self) -> None:
@@ -78,7 +113,7 @@ def test_individual_attack_creates_mpa_without_error(self) -> None:
78113
This catches the mpa_kwargs bug where dead kwargs (deterministic, lr, etc.)
79114
were passed to MultiPromptAttack.__init__() which didn't accept them.
80115
"""
81-
worker = _make_mock_worker()
116+
worker = _make_mock_worker_with_real_tokenizer()
82117

83118
# Create IndividualPromptAttack with the real GCG manager classes
84119
attack = IndividualPromptAttack(
@@ -114,7 +149,7 @@ def test_individual_attack_creates_mpa_without_error(self) -> None:
114149

115150
def test_progressive_attack_creates_mpa_without_error(self) -> None:
116151
"""ProgressiveMultiPromptAttack.run() should create MultiPromptAttack without TypeError."""
117-
worker = _make_mock_worker()
152+
worker = _make_mock_worker_with_real_tokenizer()
118153

119154
attack = ProgressiveMultiPromptAttack(
120155
goals=["test goal"],
@@ -145,3 +180,54 @@ def test_progressive_attack_creates_mpa_without_error(self) -> None:
145180
verbose=False,
146181
filter_cand=True,
147182
)
183+
184+
185+
class TestCreateAttackWiring:
186+
"""Construct real attack classes via :meth:`GCGGenerator._create_attack` to catch kwarg mismatches."""
187+
188+
def test_transfer_false_returns_individual(self, tmp_path: Path) -> None:
189+
gen = GCGGenerator(
190+
models=[GCGModelConfig(name=_LLAMA_2)],
191+
algorithm=GCGAlgorithmConfig(n_steps=5, batch_size=64, control_init="! ! !"),
192+
output=GCGOutputConfig(result_prefix=str(tmp_path / "gcg")),
193+
)
194+
worker = _make_mock_worker_with_real_tokenizer()
195+
context = GCGContext(goals=["g"], targets=["t"])
196+
params = gen._to_attack_params(context=context)
197+
198+
attack = gen._create_attack(
199+
params=params,
200+
managers=MANAGERS,
201+
train_goals=["g"],
202+
train_targets=["t"],
203+
test_goals=[],
204+
test_targets=[],
205+
workers=[worker],
206+
test_workers=[],
207+
logfile_path=str(tmp_path / "log.json"),
208+
)
209+
assert isinstance(attack, IndividualPromptAttack)
210+
211+
def test_transfer_true_returns_progressive(self, tmp_path: Path) -> None:
212+
gen = GCGGenerator(
213+
models=[GCGModelConfig(name=_LLAMA_2)],
214+
algorithm=GCGAlgorithmConfig(n_steps=5, batch_size=64, control_init="! ! !"),
215+
strategy=GCGStrategyConfig(transfer=True, progressive_goals=True, progressive_models=True),
216+
output=GCGOutputConfig(result_prefix=str(tmp_path / "gcg")),
217+
)
218+
worker = _make_mock_worker_with_real_tokenizer()
219+
context = GCGContext(goals=["g"], targets=["t"])
220+
params = gen._to_attack_params(context=context)
221+
222+
attack = gen._create_attack(
223+
params=params,
224+
managers=MANAGERS,
225+
train_goals=["g"],
226+
train_targets=["t"],
227+
test_goals=[],
228+
test_targets=[],
229+
workers=[worker],
230+
test_workers=[],
231+
logfile_path=str(tmp_path / "log.json"),
232+
)
233+
assert isinstance(attack, ProgressiveMultiPromptAttack)

tests/unit/auxiliary_attacks/gcg/test_gcg_core.py

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -443,29 +443,39 @@ def char_to_token(pos: int) -> int | None:
443443

444444
def test_end_tok_returns_len_toks_when_target_is_at_prompt_end(self) -> None:
445445
"""If the target sits at the very end of the rendered prompt,
446-
char_to_token(end_pos) returns None — end_tok must clamp to len(toks)."""
447-
from transformers import AutoTokenizer
448-
449-
tokenizer = AutoTokenizer.from_pretrained("gpt2")
450-
tokenizer.pad_token = tokenizer.eos_token
451-
tokenizer.chat_template = (
452-
"{%- for m in messages -%}"
453-
"{%- if m['role'] == 'user' -%}"
454-
"[INST] {{ m['content'] }} [/INST]"
455-
"{%- elif m['role'] == 'assistant' -%}"
456-
" {{ m['content'] }}"
457-
"{%- endif -%}"
458-
"{%- endfor -%}"
459-
)
446+
char_to_token(end_pos) returns None — end_tok must clamp to len(toks)
447+
(line 201 in attack_manager.py)."""
448+
# Fully-mocked tokenizer so we can deterministically force char_to_token to
449+
# return None at the position just past the target. Mirrors the pattern used
450+
# by the two adjacent tests above.
451+
prompt_text = "[INST] hello !! [/INST] world"
452+
toks = list(range(10))
453+
target_end_pos = len(prompt_text) # one past the final char of "world"
454+
455+
def char_to_token(pos: int) -> int | None:
456+
# Position at/after end-of-prompt has no token → triggers the
457+
# `return len(toks)` fallback in end_tok.
458+
if pos >= target_end_pos:
459+
return None
460+
# Everything else maps to a valid token index that preserves ordering.
461+
return min(pos // 3, len(toks) - 1)
462+
463+
encoding = MagicMock()
464+
encoding.input_ids = toks
465+
encoding.char_to_token.side_effect = char_to_token
466+
467+
tokenizer = MagicMock()
468+
tokenizer.apply_chat_template.return_value = prompt_text
469+
tokenizer.return_value = encoding
460470

461471
prompt = AttackPrompt(
462472
goal="hello",
463-
target="world", # this sits at end of rendered prompt with no trailing tokens
473+
target="world", # sits at end of prompt_text; target end has no token
464474
tokenizer=tokenizer,
465-
control_init="! ! !",
475+
control_init="!!",
466476
)
467-
# _target_slice.stop should be len(toks), not None or NoneType arithmetic
468-
assert isinstance(prompt._target_slice.stop, int)
477+
# end_tok(target_end_pos) saw None from char_to_token → clamped to len(toks).
478+
assert prompt._target_slice.stop == len(toks)
469479
assert prompt._target_slice.stop > prompt._target_slice.start
470480

471481

tests/unit/auxiliary_attacks/gcg/test_generator.py

Lines changed: 0 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -238,57 +238,6 @@ def test_augmentation_modifies_at_least_some_targets(self) -> None:
238238
assert num_changed > 0
239239

240240

241-
class TestCreateAttackWiring:
242-
"""Construct real attack classes via _create_attack to catch kwarg mismatches."""
243-
244-
def test_transfer_false_returns_individual(self, tmp_path: Path) -> None:
245-
from pyrit.auxiliary_attacks.gcg.attack.base.attack_manager import IndividualPromptAttack
246-
247-
gen = _make_generator(output_dir=tmp_path, n_steps=5, batch_size=64, control_init="! ! !")
248-
worker = _make_mock_worker_with_real_tokenizer()
249-
context = GCGContext(goals=["g"], targets=["t"])
250-
params = gen._to_attack_params(context=context)
251-
252-
attack = gen._create_attack(
253-
params=params,
254-
managers=_real_managers(),
255-
train_goals=["g"],
256-
train_targets=["t"],
257-
test_goals=[],
258-
test_targets=[],
259-
workers=[worker],
260-
test_workers=[],
261-
logfile_path=str(tmp_path / "log.json"),
262-
)
263-
assert isinstance(attack, IndividualPromptAttack)
264-
265-
def test_transfer_true_returns_progressive(self, tmp_path: Path) -> None:
266-
from pyrit.auxiliary_attacks.gcg.attack.base.attack_manager import ProgressiveMultiPromptAttack
267-
268-
gen = GCGGenerator(
269-
models=[GCGModelConfig(name=_LLAMA_2)],
270-
algorithm=GCGAlgorithmConfig(n_steps=5, batch_size=64, control_init="! ! !"),
271-
strategy=GCGStrategyConfig(transfer=True, progressive_goals=True, progressive_models=True),
272-
output=GCGOutputConfig(result_prefix=str(tmp_path / "gcg")),
273-
)
274-
worker = _make_mock_worker_with_real_tokenizer()
275-
context = GCGContext(goals=["g"], targets=["t"])
276-
params = gen._to_attack_params(context=context)
277-
278-
attack = gen._create_attack(
279-
params=params,
280-
managers=_real_managers(),
281-
train_goals=["g"],
282-
train_targets=["t"],
283-
test_goals=[],
284-
test_targets=[],
285-
workers=[worker],
286-
test_workers=[],
287-
logfile_path=str(tmp_path / "log.json"),
288-
)
289-
assert isinstance(attack, ProgressiveMultiPromptAttack)
290-
291-
292241
class TestReadResult:
293242
def test_reads_final_suffix_and_loss(self, tmp_path: Path) -> None:
294243
log_path = tmp_path / "result.json"
@@ -323,34 +272,3 @@ def test_empty_controls_returns_nan_loss(self, tmp_path: Path) -> None:
323272
result = GCGGenerator._read_result(logfile_path=str(log_path), memory_labels={})
324273
assert result.final_suffix == ""
325274
assert math.isnan(result.final_loss)
326-
327-
328-
def _make_mock_worker_with_real_tokenizer() -> MagicMock:
329-
"""Worker mock backed by a real GPT-2 tokenizer (the smallest workable for chat templates)."""
330-
from transformers import AutoTokenizer
331-
332-
tokenizer = AutoTokenizer.from_pretrained("gpt2")
333-
tokenizer.pad_token = tokenizer.eos_token
334-
tokenizer.chat_template = (
335-
"{%- for m in messages -%}"
336-
"{%- if m['role'] == 'user' -%}"
337-
"[INST] {{ m['content'] }} [/INST] "
338-
"{%- elif m['role'] == 'assistant' -%}"
339-
"{{ m['content'] }}"
340-
"{%- endif -%}"
341-
"{%- endfor -%}"
342-
)
343-
worker = MagicMock()
344-
worker.model.name_or_path = "test-model"
345-
worker.tokenizer = tokenizer
346-
return worker
347-
348-
349-
def _real_managers() -> dict:
350-
from pyrit.auxiliary_attacks.gcg.attack.gcg.gcg_attack import (
351-
GCGAttackPrompt,
352-
GCGMultiPromptAttack,
353-
GCGPromptManager,
354-
)
355-
356-
return {"AP": GCGAttackPrompt, "PM": GCGPromptManager, "MPA": GCGMultiPromptAttack}

0 commit comments

Comments
 (0)