switch-trust-cli 1.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- switch_trust/__init__.py +0 -0
- switch_trust/cli/__init__.py +0 -0
- switch_trust/cli/__main__.py +3 -0
- switch_trust/cli/builtin_config.json +879 -0
- switch_trust/cli/console.py +127 -0
- switch_trust/cli/eval_cli.py +905 -0
- switch_trust/cli/file_filter.py +172 -0
- switch_trust/cli/init_cli.py +156 -0
- switch_trust/cli/main.py +384 -0
- switch_trust/cli/output_formatters.py +482 -0
- switch_trust/cli/rich_observer.py +89 -0
- switch_trust/cli/runner.py +274 -0
- switch_trust/cli/scan_cli.py +314 -0
- switch_trust/cli/telemetry.py +430 -0
- switch_trust/cli/utils.py +158 -0
- switch_trust/cli/version.py +1 -0
- switch_trust/data/detector_prompts/llm01_adversarial.txt +59 -0
- switch_trust/data/detector_prompts/llm01_fixed.txt +6 -0
- switch_trust/data/detector_prompts/llm02_adversarial.txt +79 -0
- switch_trust/data/detector_prompts/llm02_fixed.txt +6 -0
- switch_trust/data/detector_prompts/llm05_adversarial.txt +67 -0
- switch_trust/data/detector_prompts/llm05_fixed.txt +14 -0
- switch_trust/data/detector_prompts/llm06_adversarial.txt +139 -0
- switch_trust/data/detector_prompts/llm06_fixed.txt +13 -0
- switch_trust/data/detector_prompts/llm07_adversarial.txt +189 -0
- switch_trust/data/detector_prompts/llm07_fixed.txt +12 -0
- switch_trust/data/detector_prompts/llm09_adversarial.txt +83 -0
- switch_trust/data/detector_prompts/llm09_fixed.txt +13 -0
- switch_trust/data/llm01_prompt_injection.csv +6708 -0
- switch_trust/data/llm02_sensitive_information.csv +1069 -0
- switch_trust/data/llm05_unsafe_output.csv +3882 -0
- switch_trust/data/llm06_excessive_agency.csv +778 -0
- switch_trust/data/llm07_system_prompt_leakage.csv +971 -0
- switch_trust/data/llm09_hallucination-goals.csv +32419 -0
- switch_trust/data/llm09_hallucination.csv +599 -0
- switch_trust/data/pii_leakage.csv +706 -0
- switch_trust/data/secret_leakage.csv +1380 -0
- switch_trust/eval/__init__.py +0 -0
- switch_trust/eval/common/__init__.py +0 -0
- switch_trust/eval/common/converter_anthropic.py +172 -0
- switch_trust/eval/common/converter_genai.py +171 -0
- switch_trust/eval/common/converter_openai.py +198 -0
- switch_trust/eval/common/log.py +116 -0
- switch_trust/eval/common/reference.py +25 -0
- switch_trust/eval/common/schema.py +190 -0
- switch_trust/eval/common/utils.py +119 -0
- switch_trust/eval/core/__init__.py +0 -0
- switch_trust/eval/core/detectors/__init__.py +0 -0
- switch_trust/eval/core/detectors/detector.py +25 -0
- switch_trust/eval/core/detectors/detector_garak.py +112 -0
- switch_trust/eval/core/detectors/detector_model.py +123 -0
- switch_trust/eval/core/detectors/detector_model_adversarial.py +91 -0
- switch_trust/eval/core/detectors/detector_pii.py +67 -0
- switch_trust/eval/core/detectors/detector_secret.py +88 -0
- switch_trust/eval/core/detectors/detector_topic_guard.py +63 -0
- switch_trust/eval/core/detectors/detector_toxicity.py +78 -0
- switch_trust/eval/core/eval/__init__.py +0 -0
- switch_trust/eval/core/eval/eval_creator.py +166 -0
- switch_trust/eval/core/eval/evaluation.py +112 -0
- switch_trust/eval/core/eval/evaluation_adversarial.py +412 -0
- switch_trust/eval/core/eval/evaluation_garak_module.py +45 -0
- switch_trust/eval/core/eval/evaluation_garak_probe.py +308 -0
- switch_trust/eval/core/eval/evaluation_message_list.py +46 -0
- switch_trust/eval/core/eval/evaluation_multi.py +276 -0
- switch_trust/eval/core/eval/evaluation_single.py +89 -0
- switch_trust/eval/core/eval/evaluation_single_prompt.py +62 -0
- switch_trust/eval/core/eval/evaluation_topic_guard.py +305 -0
- switch_trust/eval/core/eval/metric_conciseness.py +179 -0
- switch_trust/eval/core/eval/metric_factual_accuracy.py +312 -0
- switch_trust/eval/core/eval/metric_instruction_adherence.py +177 -0
- switch_trust/eval/core/eval/metric_tone.py +180 -0
- switch_trust/eval/core/eval/metric_toxicity.py +146 -0
- switch_trust/eval/core/eval/observer.py +137 -0
- switch_trust/eval/core/message/__init__.py +0 -0
- switch_trust/eval/core/message/message_collection.py +37 -0
- switch_trust/eval/core/message/message_collection_csv.py +44 -0
- switch_trust/eval/core/message/message_collection_garak.py +46 -0
- switch_trust/eval/core/message/message_collection_memory.py +34 -0
- switch_trust/eval/core/models/__init__.py +0 -0
- switch_trust/eval/core/models/generator_model.py +421 -0
- switch_trust/eval/core/models/model.py +172 -0
- switch_trust/eval/core/models/model_adk.py +186 -0
- switch_trust/eval/core/models/model_anthropic.py +79 -0
- switch_trust/eval/core/models/model_anthropic_agent.py +104 -0
- switch_trust/eval/core/models/model_gemini.py +146 -0
- switch_trust/eval/core/models/model_generic_http.py +120 -0
- switch_trust/eval/core/models/model_http.py +135 -0
- switch_trust/eval/core/models/model_huggingface.py +75 -0
- switch_trust/eval/core/models/model_langserve.py +96 -0
- switch_trust/eval/core/models/model_litellm.py +52 -0
- switch_trust/eval/core/models/model_ollama.py +65 -0
- switch_trust/eval/core/models/model_openai.py +71 -0
- switch_trust/eval/core/models/model_openai_agent.py +92 -0
- switch_trust/eval/core/models/model_openai_compatible.py +66 -0
- switch_trust/eval/core/models/model_per_endpoint_limiter.py +80 -0
- switch_trust/eval/core/models/model_retry.py +204 -0
- switch_trust/eval/core/models/model_sync_wrapper.py +38 -0
- switch_trust/eval/core/models/model_vertex_agent_runtime.py +315 -0
- switch_trust/eval/core/models/response_schema.py +148 -0
- switch_trust/eval/core/optional_deps.py +57 -0
- switch_trust/eval/db/__init__.py +0 -0
- switch_trust/eval/db/base/__init__.py +0 -0
- switch_trust/eval/db/base/common_types.py +16 -0
- switch_trust/eval/db/base/detectors/__init__.py +0 -0
- switch_trust/eval/db/base/detectors/detector_helpers.py +88 -0
- switch_trust/eval/db/base/detectors/detector_repository.py +96 -0
- switch_trust/eval/db/base/detectors/detector_types.py +77 -0
- switch_trust/eval/db/base/eval/__init__.py +0 -0
- switch_trust/eval/db/base/eval/eval_helpers.py +186 -0
- switch_trust/eval/db/base/eval/eval_repository.py +120 -0
- switch_trust/eval/db/base/eval/eval_run.py +142 -0
- switch_trust/eval/db/base/eval/eval_types.py +127 -0
- switch_trust/eval/db/base/eval/model_eval_repository.py +79 -0
- switch_trust/eval/db/base/eval/model_eval_run_repository.py +72 -0
- switch_trust/eval/db/base/eval/model_eval_run_result_repository.py +42 -0
- switch_trust/eval/db/base/eval/model_eval_run_result_types.py +21 -0
- switch_trust/eval/db/base/eval/model_eval_run_types.py +78 -0
- switch_trust/eval/db/base/eval/model_eval_types.py +69 -0
- switch_trust/eval/db/base/message/__init__.py +0 -0
- switch_trust/eval/db/base/message/message_collection_helpers.py +50 -0
- switch_trust/eval/db/base/message/message_collection_repository.py +95 -0
- switch_trust/eval/db/base/message/message_collection_types.py +87 -0
- switch_trust/eval/db/base/models/__init__.py +0 -0
- switch_trust/eval/db/base/models/model_helpers.py +253 -0
- switch_trust/eval/db/base/models/model_repository.py +104 -0
- switch_trust/eval/db/base/models/model_types.py +159 -0
- switch_trust/eval/db/json/__init__.py +0 -0
- switch_trust/eval/db/json/repository_json.py +559 -0
- switch_trust/scan/__init__.py +12 -0
- switch_trust/scan/agent_scanner.py +812 -0
- switch_trust/scan/config/agent_opengrep_rules.yaml +533 -0
- switch_trust/scan/config/agent_taxonomy.json +517 -0
- switch_trust/scan/config/agentic_cvss_mapping.yaml +136 -0
- switch_trust/scan/config/compliance_mappings.json +110 -0
- switch_trust/scan/config/triage_prompt.txt +326 -0
- switch_trust/scan/constants.py +15 -0
- switch_trust/scan/exception_classification.py +179 -0
- switch_trust/scan/llm_provider.py +330 -0
- switch_trust/scan/opengrep_resolver.py +12 -0
- switch_trust/scan/reasoner.py +602 -0
- switch_trust/scan/schema.py +315 -0
- switch_trust/scan/scorer.py +372 -0
- switch_trust/scan/static_scanner.py +681 -0
- switch_trust/scan/taxonomy.py +121 -0
- switch_trust/scan/tool_dispatcher.py +773 -0
- switch_trust/scan/trace_logger.py +118 -0
- switch_trust/scan/trace_logger_file.py +143 -0
- switch_trust/scan/trace_logger_log.py +100 -0
- switch_trust/scan/triage.py +614 -0
- switch_trust/schema.py +79 -0
- switch_trust/secret_anonymizer.py +72 -0
- switch_trust_cli-1.7.0.dist-info/METADATA +495 -0
- switch_trust_cli-1.7.0.dist-info/RECORD +157 -0
- switch_trust_cli-1.7.0.dist-info/WHEEL +5 -0
- switch_trust_cli-1.7.0.dist-info/entry_points.txt +2 -0
- switch_trust_cli-1.7.0.dist-info/licenses/LICENSE +210 -0
- switch_trust_cli-1.7.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import io
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
from dataclasses_json import dataclass_json
|
|
10
|
+
|
|
11
|
+
from switch_trust.eval.common.schema import Content, Message, PartType, Role, Session
|
|
12
|
+
from switch_trust.eval.core.detectors.detector_garak import GarakDetector
|
|
13
|
+
from switch_trust.eval.core.eval.evaluation import Evaluation, EvaluationStatus
|
|
14
|
+
from switch_trust.eval.core.eval.evaluation_multi import MultiEvaluation
|
|
15
|
+
from switch_trust.eval.core.eval.evaluation_single import SingleEvaluation
|
|
16
|
+
from switch_trust.eval.core.eval.evaluation_single_prompt import (
|
|
17
|
+
SinglePromptEvaluation,
|
|
18
|
+
)
|
|
19
|
+
from switch_trust.eval.core.models.model import Model, ResponseStatus
|
|
20
|
+
from switch_trust.eval.core.models.model_sync_wrapper import SyncModelWrapper
|
|
21
|
+
from switch_trust.eval.core.optional_deps import require_garak
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from garak.attempt import Conversation
|
|
25
|
+
from garak.attempt import Message as GarakMessage
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
# Cached at first use so the garak Generator base class (part of the optional
|
|
30
|
+
# ``full`` extra) is only imported when a dynamic garak probe actually runs.
|
|
31
|
+
_generator_adapter_cls: type | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# -- Helpers -------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _ensure_garak_config() -> None:
|
|
38
|
+
"""Initialize garak's global config if not already loaded.
|
|
39
|
+
|
|
40
|
+
Garak probes expect config values (verbose, generations, seed,
|
|
41
|
+
etc.) that are normally set by garak's CLI harness.
|
|
42
|
+
``load_base_config`` reads garak's bundled ``garak.core.yaml``
|
|
43
|
+
to set all defaults. We also provide a dummy ``reportfile``
|
|
44
|
+
since some probes write progress to it.
|
|
45
|
+
"""
|
|
46
|
+
config = require_garak()._config
|
|
47
|
+
if not config.loaded:
|
|
48
|
+
config.load_base_config()
|
|
49
|
+
if config.transient.reportfile is None:
|
|
50
|
+
config.transient.reportfile = io.StringIO()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _garak_prompt_to_message(prompt) -> Message:
|
|
54
|
+
garak = require_garak()
|
|
55
|
+
if isinstance(prompt, str):
|
|
56
|
+
text = prompt
|
|
57
|
+
elif isinstance(prompt, garak.attempt.Message):
|
|
58
|
+
text = prompt.text
|
|
59
|
+
elif isinstance(prompt, garak.attempt.Conversation):
|
|
60
|
+
parts = [turn.content.text for turn in prompt.turns]
|
|
61
|
+
text = "\n".join(parts)
|
|
62
|
+
else:
|
|
63
|
+
text = str(prompt)
|
|
64
|
+
return Message(content=Content.text(Role.USER, text))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _has_static_prompts(probe: Any) -> bool:
|
|
68
|
+
"""Check whether a garak probe has a static prompts list."""
|
|
69
|
+
return (
|
|
70
|
+
hasattr(probe, "prompts")
|
|
71
|
+
and probe.prompts is not None
|
|
72
|
+
and len(probe.prompts) > 0
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _conversation_to_messages(
|
|
77
|
+
conversation: Conversation,
|
|
78
|
+
) -> list[Message]:
|
|
79
|
+
"""Convert a garak Conversation to a list of AIRed Messages."""
|
|
80
|
+
role_map = {
|
|
81
|
+
"system": Role.SYSTEM,
|
|
82
|
+
"user": Role.USER,
|
|
83
|
+
"assistant": Role.ASSISTANT,
|
|
84
|
+
}
|
|
85
|
+
messages: list[Message] = []
|
|
86
|
+
for turn in conversation.turns:
|
|
87
|
+
role = role_map.get(turn.role, Role.USER)
|
|
88
|
+
text = turn.content.text if turn.content else ""
|
|
89
|
+
messages.append(
|
|
90
|
+
Message(content=Content.text(role, text)),
|
|
91
|
+
)
|
|
92
|
+
return messages
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _extract_text(message: Message) -> str:
|
|
96
|
+
"""Extract plain text from an AIRed Message."""
|
|
97
|
+
text_parts = [
|
|
98
|
+
part.text
|
|
99
|
+
for part in message.content.parts
|
|
100
|
+
if part.part_type == PartType.TEXT and part.text
|
|
101
|
+
]
|
|
102
|
+
return "\n".join(text_parts)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _attempts_to_session(attempts: list) -> Session:
|
|
106
|
+
"""Convert garak Attempts into an AIRed Session."""
|
|
107
|
+
messages: list[Message] = []
|
|
108
|
+
role_map = {
|
|
109
|
+
"system": Role.SYSTEM,
|
|
110
|
+
"user": Role.USER,
|
|
111
|
+
"assistant": Role.ASSISTANT,
|
|
112
|
+
}
|
|
113
|
+
for attempt in attempts:
|
|
114
|
+
for conversation in attempt.conversations:
|
|
115
|
+
for turn in conversation.turns:
|
|
116
|
+
role = role_map.get(turn.role, Role.USER)
|
|
117
|
+
text = turn.content.text if turn.content else ""
|
|
118
|
+
messages.append(
|
|
119
|
+
Message(content=Content.text(role, text)),
|
|
120
|
+
)
|
|
121
|
+
return Session(messages=messages)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# -- Generator adapter ---------------------------------------------
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _get_generator_adapter_cls() -> type:
|
|
128
|
+
"""Build (once) the garak Generator adapter class.
|
|
129
|
+
|
|
130
|
+
The class subclasses garak's ``Generator``, which lives in the optional
|
|
131
|
+
``full`` extra, so it can only be defined once garak is importable. The
|
|
132
|
+
result is memoized in ``_generator_adapter_cls``.
|
|
133
|
+
"""
|
|
134
|
+
global _generator_adapter_cls
|
|
135
|
+
if _generator_adapter_cls is not None:
|
|
136
|
+
return _generator_adapter_cls
|
|
137
|
+
|
|
138
|
+
generator_base = require_garak().generators.base.Generator
|
|
139
|
+
|
|
140
|
+
class GarakGeneratorAdapter(generator_base):
|
|
141
|
+
"""Wraps an AIRed Model as a garak Generator.
|
|
142
|
+
|
|
143
|
+
Garak probes call ``generator.generate(conversation)`` which
|
|
144
|
+
delegates to ``_call_model``. This adapter translates between
|
|
145
|
+
garak's Conversation/Message types and AIRed's Message type,
|
|
146
|
+
forwards the call to the wrapped SyncModelWrapper, and returns
|
|
147
|
+
the result as a garak Message.
|
|
148
|
+
|
|
149
|
+
Uses SyncModelWrapper because garak's probe system is entirely
|
|
150
|
+
synchronous.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
def __init__(self, sync_model: SyncModelWrapper):
|
|
154
|
+
self._sync_model = sync_model
|
|
155
|
+
self.name = "aired-adapter"
|
|
156
|
+
self.generations = 1
|
|
157
|
+
self.supports_multiple_generations = False
|
|
158
|
+
self.seed = None
|
|
159
|
+
self.fullname = "aired-adapter"
|
|
160
|
+
|
|
161
|
+
def _call_model(
|
|
162
|
+
self,
|
|
163
|
+
prompt: Conversation,
|
|
164
|
+
generations_this_call: int = 1,
|
|
165
|
+
) -> list[GarakMessage | None]:
|
|
166
|
+
messages = _conversation_to_messages(prompt)
|
|
167
|
+
response = self._sync_model.generate(messages)
|
|
168
|
+
|
|
169
|
+
if response.status != ResponseStatus.OK:
|
|
170
|
+
return [None]
|
|
171
|
+
if response.message is None:
|
|
172
|
+
return [None]
|
|
173
|
+
|
|
174
|
+
text = _extract_text(response.message)
|
|
175
|
+
return [require_garak().attempt.Message(text=text)]
|
|
176
|
+
|
|
177
|
+
_generator_adapter_cls = GarakGeneratorAdapter
|
|
178
|
+
return _generator_adapter_cls
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# -- Evaluation classes --------------------------------------------
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass
|
|
185
|
+
class GarakMultiTurnEvaluation(SingleEvaluation):
|
|
186
|
+
"""Evaluates a multi-turn garak probe against a model.
|
|
187
|
+
|
|
188
|
+
Delegates the entire interaction to the garak probe's
|
|
189
|
+
``.probe()`` method. This supports probes that dynamically
|
|
190
|
+
generate prompts, use red-team models, or adapt based on
|
|
191
|
+
target responses (e.g. ``atkgen.Tox``).
|
|
192
|
+
|
|
193
|
+
The probe manages the full conversation flow. Results are
|
|
194
|
+
scored using the probe's primary detector.
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
probe_name: str | None = None
|
|
198
|
+
|
|
199
|
+
def __init__(self, probe_name: str):
|
|
200
|
+
super().__init__()
|
|
201
|
+
self.probe_name = probe_name
|
|
202
|
+
|
|
203
|
+
async def init(self):
|
|
204
|
+
if not self.probe_name:
|
|
205
|
+
raise ValueError("probe_name is required")
|
|
206
|
+
self.status = EvaluationStatus.INITIALIZED
|
|
207
|
+
|
|
208
|
+
async def execute_internal(self, model: Model) -> float:
|
|
209
|
+
if not self.probe_name:
|
|
210
|
+
raise ValueError("probe_name is required")
|
|
211
|
+
|
|
212
|
+
return await asyncio.to_thread(
|
|
213
|
+
self._execute_sync,
|
|
214
|
+
model,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def _execute_sync(self, model: Model) -> float:
|
|
218
|
+
sync_model = SyncModelWrapper(model)
|
|
219
|
+
try:
|
|
220
|
+
plugins = require_garak()._plugins
|
|
221
|
+
_ensure_garak_config()
|
|
222
|
+
probe = plugins.load_plugin(self.probe_name)
|
|
223
|
+
detector_name = f"detectors.{probe.primary_detector}"
|
|
224
|
+
detector = plugins.load_plugin(detector_name)
|
|
225
|
+
|
|
226
|
+
adapter = _get_generator_adapter_cls()(sync_model)
|
|
227
|
+
attempts = probe.probe(adapter)
|
|
228
|
+
|
|
229
|
+
if not attempts:
|
|
230
|
+
return 1.0
|
|
231
|
+
|
|
232
|
+
self.session = _attempts_to_session(attempts)
|
|
233
|
+
|
|
234
|
+
all_scores: list[float] = []
|
|
235
|
+
for attempt in attempts:
|
|
236
|
+
results = detector.detect(attempt)
|
|
237
|
+
scores = [s for s in results if s is not None]
|
|
238
|
+
if scores:
|
|
239
|
+
all_scores.append(max(scores))
|
|
240
|
+
|
|
241
|
+
if not all_scores:
|
|
242
|
+
score = 1.0
|
|
243
|
+
else:
|
|
244
|
+
worst_hit = max(all_scores)
|
|
245
|
+
score = 1.0 - worst_hit
|
|
246
|
+
logger.debug(
|
|
247
|
+
"GarakMultiTurnEvaluation(%s): score=%.2f", self.probe_name, score
|
|
248
|
+
)
|
|
249
|
+
return score
|
|
250
|
+
finally:
|
|
251
|
+
sync_model.close()
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@dataclass_json
|
|
255
|
+
@dataclass
|
|
256
|
+
class GarakProbeEvaluation(MultiEvaluation):
|
|
257
|
+
"""Evaluates a garak probe against a model.
|
|
258
|
+
|
|
259
|
+
Automatically detects whether the probe provides static prompts
|
|
260
|
+
(single-prompt evaluation, run in parallel) or requires dynamic
|
|
261
|
+
interaction (multi-turn evaluation) and delegates accordingly.
|
|
262
|
+
"""
|
|
263
|
+
|
|
264
|
+
probe_name: str | None = None
|
|
265
|
+
|
|
266
|
+
def __init__(self, probe_name: str):
|
|
267
|
+
super().__init__()
|
|
268
|
+
self.probe_name = probe_name
|
|
269
|
+
|
|
270
|
+
async def get_children(self) -> list[Evaluation]:
|
|
271
|
+
if not self.probe_name:
|
|
272
|
+
raise ValueError("probe_name is required")
|
|
273
|
+
|
|
274
|
+
_ensure_garak_config()
|
|
275
|
+
probe = require_garak()._plugins.load_plugin(self.probe_name)
|
|
276
|
+
|
|
277
|
+
if _has_static_prompts(probe):
|
|
278
|
+
detector_name = probe.primary_detector
|
|
279
|
+
detector = GarakDetector(
|
|
280
|
+
f"detectors.{detector_name}",
|
|
281
|
+
)
|
|
282
|
+
children = [
|
|
283
|
+
SinglePromptEvaluation(
|
|
284
|
+
prompt=_garak_prompt_to_message(p),
|
|
285
|
+
detector=detector,
|
|
286
|
+
)
|
|
287
|
+
for p in probe.prompts
|
|
288
|
+
]
|
|
289
|
+
logger.debug(
|
|
290
|
+
"GarakProbeEvaluation(%s): %d children (%s)",
|
|
291
|
+
self.probe_name,
|
|
292
|
+
len(children),
|
|
293
|
+
"static",
|
|
294
|
+
)
|
|
295
|
+
return children
|
|
296
|
+
|
|
297
|
+
children = [
|
|
298
|
+
GarakMultiTurnEvaluation(
|
|
299
|
+
probe_name=self.probe_name,
|
|
300
|
+
),
|
|
301
|
+
]
|
|
302
|
+
logger.debug(
|
|
303
|
+
"GarakProbeEvaluation(%s): %d children (%s)",
|
|
304
|
+
self.probe_name,
|
|
305
|
+
len(children),
|
|
306
|
+
"multi-turn",
|
|
307
|
+
)
|
|
308
|
+
return children
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import random
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
from dataclasses_json import dataclass_json
|
|
5
|
+
|
|
6
|
+
from switch_trust.eval.common.schema import Message
|
|
7
|
+
from switch_trust.eval.core.detectors.detector import Detector
|
|
8
|
+
from switch_trust.eval.core.eval.evaluation import Evaluation
|
|
9
|
+
from switch_trust.eval.core.eval.evaluation_multi import MultiEvaluation
|
|
10
|
+
from switch_trust.eval.core.eval.evaluation_single_prompt import (
|
|
11
|
+
SinglePromptEvaluation,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass_json
|
|
16
|
+
@dataclass
|
|
17
|
+
class MessageListEvaluation(MultiEvaluation):
|
|
18
|
+
"""Evaluates a list of messages against a detector."""
|
|
19
|
+
|
|
20
|
+
messages: list[Message] | None = None
|
|
21
|
+
detector: Detector | None = None
|
|
22
|
+
num_prompts: int | None = None
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
messages: list[Message],
|
|
27
|
+
detector: Detector,
|
|
28
|
+
num_prompts: int | None = None,
|
|
29
|
+
):
|
|
30
|
+
super().__init__()
|
|
31
|
+
self.messages = messages
|
|
32
|
+
self.detector = detector
|
|
33
|
+
self.num_prompts = num_prompts
|
|
34
|
+
|
|
35
|
+
async def get_children(self) -> list[Evaluation]:
|
|
36
|
+
if not self.messages or self.detector is None:
|
|
37
|
+
raise ValueError("messages and detector must be set")
|
|
38
|
+
|
|
39
|
+
messages = self.messages
|
|
40
|
+
if self.num_prompts is not None and self.num_prompts < len(messages):
|
|
41
|
+
messages = random.sample(messages, self.num_prompts)
|
|
42
|
+
|
|
43
|
+
return [
|
|
44
|
+
SinglePromptEvaluation(prompt=msg, detector=self.detector)
|
|
45
|
+
for msg in messages
|
|
46
|
+
]
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from abc import abstractmethod
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
from dataclasses_json import dataclass_json
|
|
9
|
+
|
|
10
|
+
from switch_trust.eval.core.eval.evaluation import (
|
|
11
|
+
DEFAULT_MIN_SUCCESS_RATE,
|
|
12
|
+
Evaluation,
|
|
13
|
+
EvaluationObserver,
|
|
14
|
+
EvaluationResult,
|
|
15
|
+
EvaluationStatus,
|
|
16
|
+
EvaluationSummary,
|
|
17
|
+
)
|
|
18
|
+
from switch_trust.eval.core.models.model import Model
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
PER_CHILD_TIMEOUT = 60 # seconds per child
|
|
24
|
+
MIN_TIMEOUT = 30 * 60 # 30 minutes minimum
|
|
25
|
+
MAX_CONSECUTIVE_FAILURES = 5
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass_json
|
|
29
|
+
@dataclass
|
|
30
|
+
class MultiEvaluation(Evaluation):
|
|
31
|
+
"""An evaluation that produces and runs a list of child evaluations.
|
|
32
|
+
|
|
33
|
+
Subclasses implement ``get_children`` to define the children.
|
|
34
|
+
``init`` calls ``get_children`` and then initializes each child.
|
|
35
|
+
``run`` executes all initialized children concurrently using
|
|
36
|
+
asyncio with a semaphore for concurrency control.
|
|
37
|
+
|
|
38
|
+
Observer propagation: rather than forwarding observers directly
|
|
39
|
+
to children (which would cause the observer to receive the
|
|
40
|
+
child as the ``evaluation`` argument and report per-child
|
|
41
|
+
progress), we attach a relay observer to each child that
|
|
42
|
+
triggers ``_notify_observers`` on **this** parent evaluation.
|
|
43
|
+
The observer therefore always sees the top-level aggregated
|
|
44
|
+
summary.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
children: list[Evaluation] = field(default_factory=list)
|
|
48
|
+
_child_relay: EvaluationObserver | None = field(
|
|
49
|
+
default=None,
|
|
50
|
+
repr=False,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def __init__(self):
|
|
54
|
+
super().__init__()
|
|
55
|
+
self.children = []
|
|
56
|
+
self._child_relay = None
|
|
57
|
+
|
|
58
|
+
def _ensure_relay(self) -> EvaluationObserver:
|
|
59
|
+
"""Return a relay observer that notifies this parent."""
|
|
60
|
+
if self._child_relay is None:
|
|
61
|
+
|
|
62
|
+
def relay(_child: Evaluation) -> None:
|
|
63
|
+
self._notify_observers()
|
|
64
|
+
|
|
65
|
+
self._child_relay = relay
|
|
66
|
+
return self._child_relay
|
|
67
|
+
|
|
68
|
+
async def init(self):
|
|
69
|
+
try:
|
|
70
|
+
self.status = EvaluationStatus.INITIALIZING
|
|
71
|
+
self.children = await self.get_children()
|
|
72
|
+
logger.debug(
|
|
73
|
+
"%s initialized: %d children", type(self).__name__, len(self.children)
|
|
74
|
+
)
|
|
75
|
+
relay = self._ensure_relay()
|
|
76
|
+
for child in self.children:
|
|
77
|
+
child.add_observer(relay)
|
|
78
|
+
await child.init()
|
|
79
|
+
self.status = EvaluationStatus.INITIALIZED
|
|
80
|
+
except Exception as e:
|
|
81
|
+
self.error_message = str(e)
|
|
82
|
+
self.status = EvaluationStatus.ERROR
|
|
83
|
+
logger.error(
|
|
84
|
+
"%s init failed (%s: %s)",
|
|
85
|
+
type(self).__name__,
|
|
86
|
+
type(e).__name__,
|
|
87
|
+
e,
|
|
88
|
+
exc_info=True,
|
|
89
|
+
)
|
|
90
|
+
finally:
|
|
91
|
+
self._notify_observers()
|
|
92
|
+
|
|
93
|
+
@abstractmethod
|
|
94
|
+
async def get_children(self) -> list[Evaluation]:
|
|
95
|
+
"""Return the child evaluations to run."""
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
def get_summary(self) -> EvaluationSummary:
|
|
99
|
+
summaries = [c.get_summary() for c in self.children]
|
|
100
|
+
total = sum(s.total_evaluations for s in summaries)
|
|
101
|
+
finished = sum(s.finished_evaluations for s in summaries)
|
|
102
|
+
errors = sum(s.error_evaluations for s in summaries)
|
|
103
|
+
max_score = sum(s.max_score for s in summaries)
|
|
104
|
+
achieved = sum(s.achieved_score for s in summaries)
|
|
105
|
+
error_msgs = [msg for s in summaries for msg in s.error_messages]
|
|
106
|
+
if self.error_message:
|
|
107
|
+
error_msgs.append(self.error_message)
|
|
108
|
+
|
|
109
|
+
return EvaluationSummary(
|
|
110
|
+
status=self.status,
|
|
111
|
+
total_evaluations=total,
|
|
112
|
+
finished_evaluations=finished,
|
|
113
|
+
error_evaluations=errors,
|
|
114
|
+
max_score=max_score,
|
|
115
|
+
achieved_score=achieved,
|
|
116
|
+
error_messages=error_msgs,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def get_results(self) -> list[EvaluationResult]:
|
|
120
|
+
results: list[EvaluationResult] = []
|
|
121
|
+
for child in self.children:
|
|
122
|
+
results.extend(child.get_results())
|
|
123
|
+
return results
|
|
124
|
+
|
|
125
|
+
async def run(
|
|
126
|
+
self,
|
|
127
|
+
model: Model,
|
|
128
|
+
concurrency: int = 50,
|
|
129
|
+
min_success_rate: float = DEFAULT_MIN_SUCCESS_RATE,
|
|
130
|
+
) -> None:
|
|
131
|
+
if self.status == EvaluationStatus.ERROR:
|
|
132
|
+
return
|
|
133
|
+
self.status = EvaluationStatus.RUNNING
|
|
134
|
+
self._notify_observers()
|
|
135
|
+
|
|
136
|
+
semaphore = asyncio.Semaphore(concurrency)
|
|
137
|
+
abort = asyncio.Event()
|
|
138
|
+
consecutive_errors = 0
|
|
139
|
+
error_lock = asyncio.Lock()
|
|
140
|
+
|
|
141
|
+
initialized = [
|
|
142
|
+
c
|
|
143
|
+
for c in self.children
|
|
144
|
+
if c.get_summary().status == EvaluationStatus.INITIALIZED
|
|
145
|
+
]
|
|
146
|
+
timeout = max(
|
|
147
|
+
MIN_TIMEOUT,
|
|
148
|
+
PER_CHILD_TIMEOUT * len(initialized),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
name = type(self).__name__
|
|
152
|
+
logger.debug(
|
|
153
|
+
"%s running: %d children, concurrency=%d",
|
|
154
|
+
name,
|
|
155
|
+
len(initialized),
|
|
156
|
+
concurrency,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
async def run_child(child: Evaluation) -> None:
|
|
160
|
+
nonlocal consecutive_errors
|
|
161
|
+
async with semaphore:
|
|
162
|
+
if abort.is_set():
|
|
163
|
+
child.status = EvaluationStatus.ERROR
|
|
164
|
+
child.error_message = "Aborted: too many consecutive failures"
|
|
165
|
+
child._notify_observers()
|
|
166
|
+
return
|
|
167
|
+
await child.run(model, concurrency)
|
|
168
|
+
async with error_lock:
|
|
169
|
+
if child.get_summary().status == EvaluationStatus.ERROR:
|
|
170
|
+
consecutive_errors += 1
|
|
171
|
+
if consecutive_errors >= MAX_CONSECUTIVE_FAILURES:
|
|
172
|
+
abort.set()
|
|
173
|
+
logger.warning(
|
|
174
|
+
"%s: aborting after %d consecutive failures",
|
|
175
|
+
name,
|
|
176
|
+
consecutive_errors,
|
|
177
|
+
)
|
|
178
|
+
else:
|
|
179
|
+
consecutive_errors = 0
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
tasks = [asyncio.create_task(run_child(child)) for child in initialized]
|
|
183
|
+
if tasks:
|
|
184
|
+
try:
|
|
185
|
+
results = await asyncio.wait_for(
|
|
186
|
+
asyncio.gather(*tasks, return_exceptions=True),
|
|
187
|
+
timeout=timeout,
|
|
188
|
+
)
|
|
189
|
+
errors = [r for r in results if isinstance(r, Exception)]
|
|
190
|
+
if errors:
|
|
191
|
+
self.error_message = (
|
|
192
|
+
f"Evaluation errors: {'; '.join(str(e) for e in errors)}"
|
|
193
|
+
)
|
|
194
|
+
logger.warning("%s: %s", name, self.error_message)
|
|
195
|
+
except TimeoutError:
|
|
196
|
+
for task in tasks:
|
|
197
|
+
task.cancel()
|
|
198
|
+
unfinished = sum(
|
|
199
|
+
1
|
|
200
|
+
for c in self.children
|
|
201
|
+
if c.get_summary().status
|
|
202
|
+
not in (
|
|
203
|
+
EvaluationStatus.FINISHED,
|
|
204
|
+
EvaluationStatus.ERROR,
|
|
205
|
+
)
|
|
206
|
+
)
|
|
207
|
+
self.error_message = (
|
|
208
|
+
f"{unfinished} (of {len(initialized)}) "
|
|
209
|
+
f"evaluations timed out after "
|
|
210
|
+
f"{timeout}s"
|
|
211
|
+
)
|
|
212
|
+
logger.warning("%s: %s", name, self.error_message)
|
|
213
|
+
|
|
214
|
+
if abort.is_set() and not self.error_message:
|
|
215
|
+
self.error_message = (
|
|
216
|
+
f"aborted after {MAX_CONSECUTIVE_FAILURES} consecutive failures"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
errored = [
|
|
220
|
+
c
|
|
221
|
+
for c in self.children
|
|
222
|
+
if c.get_summary().status == EvaluationStatus.ERROR
|
|
223
|
+
]
|
|
224
|
+
finished = [
|
|
225
|
+
c
|
|
226
|
+
for c in self.children
|
|
227
|
+
if c.get_summary().status == EvaluationStatus.FINISHED
|
|
228
|
+
]
|
|
229
|
+
total = len(self.children)
|
|
230
|
+
if errored:
|
|
231
|
+
logger.warning(
|
|
232
|
+
"%s: %d of %d prompts errored (%.0f%%)",
|
|
233
|
+
name,
|
|
234
|
+
len(errored),
|
|
235
|
+
total,
|
|
236
|
+
100.0 * len(errored) / total,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# ERROR on a structural failure (error_message), no successful prompt,
|
|
240
|
+
# or a success rate below the minimum. Otherwise FINISHED.
|
|
241
|
+
#
|
|
242
|
+
# Split into one branch per cause so each sets error_message. The
|
|
243
|
+
# worker reports that field and falls back to a generic "evaluation
|
|
244
|
+
# produced no results" when it is empty, which is what every one of
|
|
245
|
+
# these used to look like from the outside.
|
|
246
|
+
success_rate = len(finished) / total if total else 0.0
|
|
247
|
+
if self.error_message:
|
|
248
|
+
self.status = EvaluationStatus.ERROR
|
|
249
|
+
elif not finished:
|
|
250
|
+
self.error_message = (
|
|
251
|
+
"evaluation had no prompts to run"
|
|
252
|
+
if not total
|
|
253
|
+
else f"all {total} prompts failed"
|
|
254
|
+
)
|
|
255
|
+
self.status = EvaluationStatus.ERROR
|
|
256
|
+
elif success_rate < min_success_rate:
|
|
257
|
+
self.error_message = (
|
|
258
|
+
f"only {len(finished)} of {total} prompts succeeded "
|
|
259
|
+
# .1%, not .0%: at 200+ prompts a rate under the minimum can
|
|
260
|
+
# round up to it (179/200 shows as 90%, below a 90% minimum),
|
|
261
|
+
# and the message then contradicts itself.
|
|
262
|
+
f"({success_rate:.1%}), below the "
|
|
263
|
+
f"{min_success_rate:.1%} minimum"
|
|
264
|
+
)
|
|
265
|
+
self.status = EvaluationStatus.ERROR
|
|
266
|
+
else:
|
|
267
|
+
self.status = EvaluationStatus.FINISHED
|
|
268
|
+
except Exception as e:
|
|
269
|
+
self.error_message = str(e)
|
|
270
|
+
self.status = EvaluationStatus.ERROR
|
|
271
|
+
logger.error(
|
|
272
|
+
"%s run failed (%s: %s)", name, type(e).__name__, e, exc_info=True
|
|
273
|
+
)
|
|
274
|
+
finally:
|
|
275
|
+
logger.info("%s finished: status=%s", name, self.status.value)
|
|
276
|
+
self._notify_observers()
|