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.
Files changed (157) hide show
  1. switch_trust/__init__.py +0 -0
  2. switch_trust/cli/__init__.py +0 -0
  3. switch_trust/cli/__main__.py +3 -0
  4. switch_trust/cli/builtin_config.json +879 -0
  5. switch_trust/cli/console.py +127 -0
  6. switch_trust/cli/eval_cli.py +905 -0
  7. switch_trust/cli/file_filter.py +172 -0
  8. switch_trust/cli/init_cli.py +156 -0
  9. switch_trust/cli/main.py +384 -0
  10. switch_trust/cli/output_formatters.py +482 -0
  11. switch_trust/cli/rich_observer.py +89 -0
  12. switch_trust/cli/runner.py +274 -0
  13. switch_trust/cli/scan_cli.py +314 -0
  14. switch_trust/cli/telemetry.py +430 -0
  15. switch_trust/cli/utils.py +158 -0
  16. switch_trust/cli/version.py +1 -0
  17. switch_trust/data/detector_prompts/llm01_adversarial.txt +59 -0
  18. switch_trust/data/detector_prompts/llm01_fixed.txt +6 -0
  19. switch_trust/data/detector_prompts/llm02_adversarial.txt +79 -0
  20. switch_trust/data/detector_prompts/llm02_fixed.txt +6 -0
  21. switch_trust/data/detector_prompts/llm05_adversarial.txt +67 -0
  22. switch_trust/data/detector_prompts/llm05_fixed.txt +14 -0
  23. switch_trust/data/detector_prompts/llm06_adversarial.txt +139 -0
  24. switch_trust/data/detector_prompts/llm06_fixed.txt +13 -0
  25. switch_trust/data/detector_prompts/llm07_adversarial.txt +189 -0
  26. switch_trust/data/detector_prompts/llm07_fixed.txt +12 -0
  27. switch_trust/data/detector_prompts/llm09_adversarial.txt +83 -0
  28. switch_trust/data/detector_prompts/llm09_fixed.txt +13 -0
  29. switch_trust/data/llm01_prompt_injection.csv +6708 -0
  30. switch_trust/data/llm02_sensitive_information.csv +1069 -0
  31. switch_trust/data/llm05_unsafe_output.csv +3882 -0
  32. switch_trust/data/llm06_excessive_agency.csv +778 -0
  33. switch_trust/data/llm07_system_prompt_leakage.csv +971 -0
  34. switch_trust/data/llm09_hallucination-goals.csv +32419 -0
  35. switch_trust/data/llm09_hallucination.csv +599 -0
  36. switch_trust/data/pii_leakage.csv +706 -0
  37. switch_trust/data/secret_leakage.csv +1380 -0
  38. switch_trust/eval/__init__.py +0 -0
  39. switch_trust/eval/common/__init__.py +0 -0
  40. switch_trust/eval/common/converter_anthropic.py +172 -0
  41. switch_trust/eval/common/converter_genai.py +171 -0
  42. switch_trust/eval/common/converter_openai.py +198 -0
  43. switch_trust/eval/common/log.py +116 -0
  44. switch_trust/eval/common/reference.py +25 -0
  45. switch_trust/eval/common/schema.py +190 -0
  46. switch_trust/eval/common/utils.py +119 -0
  47. switch_trust/eval/core/__init__.py +0 -0
  48. switch_trust/eval/core/detectors/__init__.py +0 -0
  49. switch_trust/eval/core/detectors/detector.py +25 -0
  50. switch_trust/eval/core/detectors/detector_garak.py +112 -0
  51. switch_trust/eval/core/detectors/detector_model.py +123 -0
  52. switch_trust/eval/core/detectors/detector_model_adversarial.py +91 -0
  53. switch_trust/eval/core/detectors/detector_pii.py +67 -0
  54. switch_trust/eval/core/detectors/detector_secret.py +88 -0
  55. switch_trust/eval/core/detectors/detector_topic_guard.py +63 -0
  56. switch_trust/eval/core/detectors/detector_toxicity.py +78 -0
  57. switch_trust/eval/core/eval/__init__.py +0 -0
  58. switch_trust/eval/core/eval/eval_creator.py +166 -0
  59. switch_trust/eval/core/eval/evaluation.py +112 -0
  60. switch_trust/eval/core/eval/evaluation_adversarial.py +412 -0
  61. switch_trust/eval/core/eval/evaluation_garak_module.py +45 -0
  62. switch_trust/eval/core/eval/evaluation_garak_probe.py +308 -0
  63. switch_trust/eval/core/eval/evaluation_message_list.py +46 -0
  64. switch_trust/eval/core/eval/evaluation_multi.py +276 -0
  65. switch_trust/eval/core/eval/evaluation_single.py +89 -0
  66. switch_trust/eval/core/eval/evaluation_single_prompt.py +62 -0
  67. switch_trust/eval/core/eval/evaluation_topic_guard.py +305 -0
  68. switch_trust/eval/core/eval/metric_conciseness.py +179 -0
  69. switch_trust/eval/core/eval/metric_factual_accuracy.py +312 -0
  70. switch_trust/eval/core/eval/metric_instruction_adherence.py +177 -0
  71. switch_trust/eval/core/eval/metric_tone.py +180 -0
  72. switch_trust/eval/core/eval/metric_toxicity.py +146 -0
  73. switch_trust/eval/core/eval/observer.py +137 -0
  74. switch_trust/eval/core/message/__init__.py +0 -0
  75. switch_trust/eval/core/message/message_collection.py +37 -0
  76. switch_trust/eval/core/message/message_collection_csv.py +44 -0
  77. switch_trust/eval/core/message/message_collection_garak.py +46 -0
  78. switch_trust/eval/core/message/message_collection_memory.py +34 -0
  79. switch_trust/eval/core/models/__init__.py +0 -0
  80. switch_trust/eval/core/models/generator_model.py +421 -0
  81. switch_trust/eval/core/models/model.py +172 -0
  82. switch_trust/eval/core/models/model_adk.py +186 -0
  83. switch_trust/eval/core/models/model_anthropic.py +79 -0
  84. switch_trust/eval/core/models/model_anthropic_agent.py +104 -0
  85. switch_trust/eval/core/models/model_gemini.py +146 -0
  86. switch_trust/eval/core/models/model_generic_http.py +120 -0
  87. switch_trust/eval/core/models/model_http.py +135 -0
  88. switch_trust/eval/core/models/model_huggingface.py +75 -0
  89. switch_trust/eval/core/models/model_langserve.py +96 -0
  90. switch_trust/eval/core/models/model_litellm.py +52 -0
  91. switch_trust/eval/core/models/model_ollama.py +65 -0
  92. switch_trust/eval/core/models/model_openai.py +71 -0
  93. switch_trust/eval/core/models/model_openai_agent.py +92 -0
  94. switch_trust/eval/core/models/model_openai_compatible.py +66 -0
  95. switch_trust/eval/core/models/model_per_endpoint_limiter.py +80 -0
  96. switch_trust/eval/core/models/model_retry.py +204 -0
  97. switch_trust/eval/core/models/model_sync_wrapper.py +38 -0
  98. switch_trust/eval/core/models/model_vertex_agent_runtime.py +315 -0
  99. switch_trust/eval/core/models/response_schema.py +148 -0
  100. switch_trust/eval/core/optional_deps.py +57 -0
  101. switch_trust/eval/db/__init__.py +0 -0
  102. switch_trust/eval/db/base/__init__.py +0 -0
  103. switch_trust/eval/db/base/common_types.py +16 -0
  104. switch_trust/eval/db/base/detectors/__init__.py +0 -0
  105. switch_trust/eval/db/base/detectors/detector_helpers.py +88 -0
  106. switch_trust/eval/db/base/detectors/detector_repository.py +96 -0
  107. switch_trust/eval/db/base/detectors/detector_types.py +77 -0
  108. switch_trust/eval/db/base/eval/__init__.py +0 -0
  109. switch_trust/eval/db/base/eval/eval_helpers.py +186 -0
  110. switch_trust/eval/db/base/eval/eval_repository.py +120 -0
  111. switch_trust/eval/db/base/eval/eval_run.py +142 -0
  112. switch_trust/eval/db/base/eval/eval_types.py +127 -0
  113. switch_trust/eval/db/base/eval/model_eval_repository.py +79 -0
  114. switch_trust/eval/db/base/eval/model_eval_run_repository.py +72 -0
  115. switch_trust/eval/db/base/eval/model_eval_run_result_repository.py +42 -0
  116. switch_trust/eval/db/base/eval/model_eval_run_result_types.py +21 -0
  117. switch_trust/eval/db/base/eval/model_eval_run_types.py +78 -0
  118. switch_trust/eval/db/base/eval/model_eval_types.py +69 -0
  119. switch_trust/eval/db/base/message/__init__.py +0 -0
  120. switch_trust/eval/db/base/message/message_collection_helpers.py +50 -0
  121. switch_trust/eval/db/base/message/message_collection_repository.py +95 -0
  122. switch_trust/eval/db/base/message/message_collection_types.py +87 -0
  123. switch_trust/eval/db/base/models/__init__.py +0 -0
  124. switch_trust/eval/db/base/models/model_helpers.py +253 -0
  125. switch_trust/eval/db/base/models/model_repository.py +104 -0
  126. switch_trust/eval/db/base/models/model_types.py +159 -0
  127. switch_trust/eval/db/json/__init__.py +0 -0
  128. switch_trust/eval/db/json/repository_json.py +559 -0
  129. switch_trust/scan/__init__.py +12 -0
  130. switch_trust/scan/agent_scanner.py +812 -0
  131. switch_trust/scan/config/agent_opengrep_rules.yaml +533 -0
  132. switch_trust/scan/config/agent_taxonomy.json +517 -0
  133. switch_trust/scan/config/agentic_cvss_mapping.yaml +136 -0
  134. switch_trust/scan/config/compliance_mappings.json +110 -0
  135. switch_trust/scan/config/triage_prompt.txt +326 -0
  136. switch_trust/scan/constants.py +15 -0
  137. switch_trust/scan/exception_classification.py +179 -0
  138. switch_trust/scan/llm_provider.py +330 -0
  139. switch_trust/scan/opengrep_resolver.py +12 -0
  140. switch_trust/scan/reasoner.py +602 -0
  141. switch_trust/scan/schema.py +315 -0
  142. switch_trust/scan/scorer.py +372 -0
  143. switch_trust/scan/static_scanner.py +681 -0
  144. switch_trust/scan/taxonomy.py +121 -0
  145. switch_trust/scan/tool_dispatcher.py +773 -0
  146. switch_trust/scan/trace_logger.py +118 -0
  147. switch_trust/scan/trace_logger_file.py +143 -0
  148. switch_trust/scan/trace_logger_log.py +100 -0
  149. switch_trust/scan/triage.py +614 -0
  150. switch_trust/schema.py +79 -0
  151. switch_trust/secret_anonymizer.py +72 -0
  152. switch_trust_cli-1.7.0.dist-info/METADATA +495 -0
  153. switch_trust_cli-1.7.0.dist-info/RECORD +157 -0
  154. switch_trust_cli-1.7.0.dist-info/WHEEL +5 -0
  155. switch_trust_cli-1.7.0.dist-info/entry_points.txt +2 -0
  156. switch_trust_cli-1.7.0.dist-info/licenses/LICENSE +210 -0
  157. 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()