Skip to content

Commit a9d0671

Browse files
Copilotslister1001
andcommitted
Fix duplicate queries in DirectAttackSimulator by using different seeds
Co-authored-by: slister1001 <[email protected]>
1 parent d02b609 commit a9d0671

File tree

5 files changed

+466
-5
lines changed

5 files changed

+466
-5
lines changed

sdk/evaluation/azure-ai-evaluation/azure/ai/evaluation/simulator/_direct_attack_simulator.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -134,9 +134,9 @@ async def __call__(
134134
:keyword concurrent_async_task: The number of asynchronous tasks to run concurrently during the simulation.
135135
Defaults to 3.
136136
:paramtype concurrent_async_task: int
137-
:keyword randomization_seed: Seed used to randomize prompt selection, shared by both jailbreak
138-
and regular simulation to ensure consistent results. If not provided, a random seed will be generated
139-
and shared between simulations.
137+
:keyword randomization_seed: Seed used to randomize prompt selection. This seed is used to derive
138+
different but deterministic seeds for regular and jailbreak simulations to ensure consistent
139+
results while avoiding duplicate queries. If not provided, a random seed will be generated.
140140
:paramtype randomization_seed: Optional[int]
141141
:return: A list of dictionaries, each representing a simulated conversation. Each dictionary contains:
142142
@@ -201,6 +201,11 @@ async def __call__(
201201
if not randomization_seed:
202202
randomization_seed = randint(0, 1000000)
203203

204+
# Derive different seeds for regular and jailbreak simulations to avoid duplicate queries
205+
# This ensures deterministic behavior while preventing identical results
206+
regular_seed = randomization_seed
207+
jailbreak_seed = randomization_seed + 1 if randomization_seed < 999999 else randomization_seed - 1
208+
204209
regular_sim = AdversarialSimulator(azure_ai_project=self.azure_ai_project, credential=self.credential)
205210
regular_sim_results = await regular_sim(
206211
scenario=scenario,
@@ -212,7 +217,7 @@ async def __call__(
212217
api_call_delay_sec=api_call_delay_sec,
213218
concurrent_async_task=concurrent_async_task,
214219
randomize_order=False,
215-
randomization_seed=randomization_seed,
220+
randomization_seed=regular_seed,
216221
)
217222
jb_sim = AdversarialSimulator(azure_ai_project=self.azure_ai_project, credential=self.credential)
218223
jb_sim_results = await jb_sim(
@@ -226,6 +231,6 @@ async def __call__(
226231
concurrent_async_task=concurrent_async_task,
227232
_jailbreak_type="upia",
228233
randomize_order=False,
229-
randomization_seed=randomization_seed,
234+
randomization_seed=jailbreak_seed,
230235
)
231236
return {"jailbreak": jb_sim_results, "regular": regular_sim_results}
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
# ---------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# ---------------------------------------------------------
4+
5+
import pytest
6+
from unittest.mock import AsyncMock, MagicMock, patch
7+
from azure.ai.evaluation.simulator import DirectAttackSimulator, AdversarialScenario
8+
from azure.ai.evaluation.simulator._utils import JsonLineList
9+
from azure.core.credentials import TokenCredential
10+
11+
12+
@pytest.fixture
13+
def mock_credential():
14+
return MagicMock(spec=TokenCredential)
15+
16+
17+
@pytest.fixture
18+
def mock_azure_ai_project():
19+
return {
20+
"subscription_id": "mock-sub",
21+
"resource_group_name": "mock-rg",
22+
"project_name": "mock-proj"
23+
}
24+
25+
26+
@pytest.fixture
27+
def mock_target():
28+
async def mock_target_fn(query: str) -> str:
29+
return "mock response"
30+
return mock_target_fn
31+
32+
33+
@pytest.mark.unittest
34+
class TestDirectAttackSimulator:
35+
36+
@pytest.mark.asyncio
37+
@patch("azure.ai.evaluation.simulator._direct_attack_simulator.DirectAttackSimulator._ensure_service_dependencies")
38+
@patch("azure.ai.evaluation.simulator.AdversarialSimulator.__init__", return_value=None)
39+
@patch("azure.ai.evaluation.simulator.AdversarialSimulator.__call__", new_callable=AsyncMock)
40+
async def test_different_randomization_seeds_fix(
41+
self,
42+
mock_adv_call,
43+
mock_adv_init,
44+
mock_ensure_deps,
45+
mock_azure_ai_project,
46+
mock_credential,
47+
mock_target
48+
):
49+
"""Test that DirectAttackSimulator uses different seeds for regular and jailbreak simulations."""
50+
51+
# Setup mock returns
52+
mock_result = JsonLineList([
53+
{"messages": [{"content": "test_query", "role": "user"}]}
54+
])
55+
mock_adv_call.return_value = mock_result
56+
57+
# Create DirectAttackSimulator
58+
simulator = DirectAttackSimulator(
59+
azure_ai_project=mock_azure_ai_project,
60+
credential=mock_credential
61+
)
62+
63+
# Call with fixed randomization seed
64+
result = await simulator(
65+
scenario=AdversarialScenario.ADVERSARIAL_QA,
66+
target=mock_target,
67+
max_simulation_results=3,
68+
randomization_seed=42
69+
)
70+
71+
# Verify that AdversarialSimulator was called twice (regular and jailbreak)
72+
assert mock_adv_call.call_count == 2
73+
74+
# Extract the randomization_seed from each call
75+
call_kwargs_list = [call[1] for call in mock_adv_call.call_args_list]
76+
regular_seed = call_kwargs_list[0].get("randomization_seed")
77+
jailbreak_seed = call_kwargs_list[1].get("randomization_seed")
78+
79+
# The fix should ensure different seeds are used
80+
assert regular_seed != jailbreak_seed, "Regular and jailbreak simulations should use different seeds"
81+
assert regular_seed == 42, "Regular simulation should use the original seed"
82+
assert jailbreak_seed == 43, "Jailbreak simulation should use derived seed (original + 1)"
83+
84+
# Verify the structure of the result
85+
assert "regular" in result
86+
assert "jailbreak" in result
87+
assert result["regular"] == mock_result
88+
assert result["jailbreak"] == mock_result
89+
90+
@pytest.mark.asyncio
91+
@patch("azure.ai.evaluation.simulator._direct_attack_simulator.DirectAttackSimulator._ensure_service_dependencies")
92+
@patch("azure.ai.evaluation.simulator.AdversarialSimulator.__init__", return_value=None)
93+
@patch("azure.ai.evaluation.simulator.AdversarialSimulator.__call__", new_callable=AsyncMock)
94+
async def test_edge_case_max_seed_value(
95+
self,
96+
mock_adv_call,
97+
mock_adv_init,
98+
mock_ensure_deps,
99+
mock_azure_ai_project,
100+
mock_credential,
101+
mock_target
102+
):
103+
"""Test edge case when randomization_seed is at maximum value."""
104+
105+
# Setup mock returns
106+
mock_result = JsonLineList([
107+
{"messages": [{"content": "test_query", "role": "user"}]}
108+
])
109+
mock_adv_call.return_value = mock_result
110+
111+
# Create DirectAttackSimulator
112+
simulator = DirectAttackSimulator(
113+
azure_ai_project=mock_azure_ai_project,
114+
credential=mock_credential
115+
)
116+
117+
# Call with max seed value
118+
max_seed = 999999
119+
result = await simulator(
120+
scenario=AdversarialScenario.ADVERSARIAL_QA,
121+
target=mock_target,
122+
max_simulation_results=3,
123+
randomization_seed=max_seed
124+
)
125+
126+
# Verify that AdversarialSimulator was called twice
127+
assert mock_adv_call.call_count == 2
128+
129+
# Extract the randomization_seed from each call
130+
call_kwargs_list = [call[1] for call in mock_adv_call.call_args_list]
131+
regular_seed = call_kwargs_list[0].get("randomization_seed")
132+
jailbreak_seed = call_kwargs_list[1].get("randomization_seed")
133+
134+
# When at max value, jailbreak seed should be original - 1
135+
assert regular_seed != jailbreak_seed, "Seeds should still be different at max value"
136+
assert regular_seed == max_seed, "Regular simulation should use the original max seed"
137+
assert jailbreak_seed == max_seed - 1, "Jailbreak simulation should use max seed - 1"
138+
139+
@pytest.mark.asyncio
140+
@patch("azure.ai.evaluation.simulator._direct_attack_simulator.DirectAttackSimulator._ensure_service_dependencies")
141+
@patch("azure.ai.evaluation.simulator.AdversarialSimulator.__init__", return_value=None)
142+
@patch("azure.ai.evaluation.simulator.AdversarialSimulator.__call__", new_callable=AsyncMock)
143+
async def test_no_seed_provided_generates_different_seeds(
144+
self,
145+
mock_adv_call,
146+
mock_adv_init,
147+
mock_ensure_deps,
148+
mock_azure_ai_project,
149+
mock_credential,
150+
mock_target
151+
):
152+
"""Test that when no seed is provided, different seeds are still generated."""
153+
154+
# Setup mock returns
155+
mock_result = JsonLineList([
156+
{"messages": [{"content": "test_query", "role": "user"}]}
157+
])
158+
mock_adv_call.return_value = mock_result
159+
160+
# Create DirectAttackSimulator
161+
simulator = DirectAttackSimulator(
162+
azure_ai_project=mock_azure_ai_project,
163+
credential=mock_credential
164+
)
165+
166+
# Call without providing randomization_seed (it will be generated randomly)
167+
result = await simulator(
168+
scenario=AdversarialScenario.ADVERSARIAL_QA,
169+
target=mock_target,
170+
max_simulation_results=3
171+
)
172+
173+
# Verify that AdversarialSimulator was called twice
174+
assert mock_adv_call.call_count == 2
175+
176+
# Extract the randomization_seed from each call
177+
call_kwargs_list = [call[1] for call in mock_adv_call.call_args_list]
178+
regular_seed = call_kwargs_list[0].get("randomization_seed")
179+
jailbreak_seed = call_kwargs_list[1].get("randomization_seed")
180+
181+
# Even with random generation, seeds should be different
182+
assert regular_seed != jailbreak_seed, "Generated seeds should be different"
183+
assert jailbreak_seed == regular_seed + 1 or jailbreak_seed == regular_seed - 1, "Jailbreak seed should be derived from regular seed"

sdk/evaluation/azure-ai-evaluation/tests/unittests/test_safety_evaluation.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,3 +395,89 @@ def test_local_random_no_global_state_pollution(self):
395395
# Global state should be unchanged
396396
after_value = random.random()
397397
assert initial_value == after_value, "Local Random usage should not affect global state"
398+
399+
@pytest.mark.asyncio
400+
@patch("azure.ai.evaluation.simulator.DirectAttackSimulator.__init__", return_value=None)
401+
@patch("azure.ai.evaluation.simulator.AdversarialSimulator.__init__", return_value=None)
402+
@patch("azure.ai.evaluation.simulator.AdversarialSimulator.__call__", new_callable=AsyncMock)
403+
async def test_direct_attack_different_seeds_fix(self, mock_adv_call, mock_adv_init, mock_direct_init, safety_eval, mock_target):
404+
"""Test that DirectAttackSimulator uses different seeds for regular and jailbreak simulations."""
405+
406+
# Mock AdversarialSimulator calls
407+
mock_adv_call.return_value = JsonLineList([
408+
{"messages": [{"content": "test_query", "role": "user"}]}
409+
])
410+
411+
# Import and create DirectAttackSimulator manually to test the fix
412+
from azure.ai.evaluation.simulator._direct_attack_simulator import DirectAttackSimulator
413+
from azure.ai.evaluation.simulator import AdversarialScenario
414+
415+
# Create a real DirectAttackSimulator instance with mocked dependencies
416+
simulator = DirectAttackSimulator.__new__(DirectAttackSimulator)
417+
simulator.azure_ai_project = {"test": "project"}
418+
simulator.credential = MagicMock()
419+
simulator.adversarial_template_handler = MagicMock()
420+
421+
# Call the fixed method
422+
result = await simulator.__call__(
423+
scenario=AdversarialScenario.ADVERSARIAL_QA,
424+
target=mock_target,
425+
max_simulation_results=2,
426+
randomization_seed=42
427+
)
428+
429+
# Verify that AdversarialSimulator was called twice (regular and jailbreak)
430+
assert mock_adv_call.call_count == 2
431+
432+
# Check that different seeds were used
433+
call_kwargs_list = [call[1] for call in mock_adv_call.call_args_list]
434+
regular_seed = call_kwargs_list[0].get("randomization_seed")
435+
jailbreak_seed = call_kwargs_list[1].get("randomization_seed")
436+
437+
# The fix should use different seeds
438+
assert regular_seed != jailbreak_seed
439+
assert regular_seed == 42 # Original seed for regular simulation
440+
assert jailbreak_seed == 43 # Derived seed for jailbreak simulation
441+
442+
# Verify the structure of the result
443+
assert "regular" in result
444+
assert "jailbreak" in result
445+
446+
@pytest.mark.asyncio
447+
@patch("azure.ai.evaluation.simulator.DirectAttackSimulator.__init__", return_value=None)
448+
@patch("azure.ai.evaluation.simulator.DirectAttackSimulator.__call__", new_callable=AsyncMock)
449+
async def test_direct_attack_duplicate_queries_issue(self, mock_direct_call, mock_direct_init, safety_eval, mock_target):
450+
"""Test that DirectAttackSimulator doesn't produce duplicate queries when using same randomization_seed."""
451+
452+
# Mock the DirectAttackSimulator to expose the issue
453+
# Simulate what happens when both regular and jailbreak simulations use the same seed
454+
mock_regular_results = [
455+
{"messages": [{"content": "query_1", "role": "user"}]},
456+
{"messages": [{"content": "query_2", "role": "user"}]},
457+
]
458+
mock_jailbreak_results = [
459+
{"messages": [{"content": "query_1", "role": "user"}]}, # Same as regular - this is the bug!
460+
{"messages": [{"content": "query_2", "role": "user"}]}, # Same as regular - this is the bug!
461+
]
462+
463+
mock_direct_call.return_value = {
464+
"regular": JsonLineList(mock_regular_results),
465+
"jailbreak": JsonLineList(mock_jailbreak_results)
466+
}
467+
468+
# Call safety evaluation with DIRECT_ATTACK
469+
await safety_eval._simulate(
470+
target=mock_target,
471+
direct_attack=True,
472+
adversarial_scenario=AdversarialScenario.ADVERSARIAL_QA,
473+
max_simulation_results=2,
474+
randomization_seed=42
475+
)
476+
477+
# Verify DirectAttackSimulator was called
478+
mock_direct_call.assert_called_once()
479+
call_kwargs = mock_direct_call.call_args[1]
480+
481+
# The issue is that the same randomization_seed gets passed to both regular and jailbreak simulators
482+
# This test documents the current problematic behavior
483+
assert call_kwargs.get("randomization_seed") == 42

0 commit comments

Comments
 (0)