evalrx 0.1.2__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 (339) hide show
  1. evalrx/__init__.py +139 -0
  2. evalrx/agent_assets/__init__.py +2 -0
  3. evalrx/agent_assets/skills/README.md +28 -0
  4. evalrx/agent_assets/skills/eval-chart-style/SKILL.md +172 -0
  5. evalrx/agent_assets/skills/evalrx-report-ui/SKILL.md +116 -0
  6. evalrx/agent_assets/skills/nature-figure/LICENSE +201 -0
  7. evalrx/agent_assets/skills/nature-figure/README.md +412 -0
  8. evalrx/agent_assets/skills/nature-figure/SKILL.md +60 -0
  9. evalrx/agent_assets/skills/nature-figure/manifest.yaml +59 -0
  10. evalrx/agent_assets/skills/nature-figure/references/api.md +436 -0
  11. evalrx/agent_assets/skills/nature-figure/references/backend-selection.md +100 -0
  12. evalrx/agent_assets/skills/nature-figure/references/chart-types.md +281 -0
  13. evalrx/agent_assets/skills/nature-figure/references/common-patterns.md +350 -0
  14. evalrx/agent_assets/skills/nature-figure/references/demos.md +65 -0
  15. evalrx/agent_assets/skills/nature-figure/references/design-theory.md +439 -0
  16. evalrx/agent_assets/skills/nature-figure/references/figure-contract.md +93 -0
  17. evalrx/agent_assets/skills/nature-figure/references/figure-legend-conventions.md +71 -0
  18. evalrx/agent_assets/skills/nature-figure/references/nature-2026-observations.md +112 -0
  19. evalrx/agent_assets/skills/nature-figure/references/qa-contract.md +119 -0
  20. evalrx/agent_assets/skills/nature-figure/references/r-template-index.md +66 -0
  21. evalrx/agent_assets/skills/nature-figure/references/r-workflow.md +161 -0
  22. evalrx/agent_assets/skills/nature-figure/references/tutorials.md +251 -0
  23. evalrx/agent_assets/skills/nature-figure/static/core/contract.md +29 -0
  24. evalrx/agent_assets/skills/nature-figure/static/core/stance.md +37 -0
  25. evalrx/agent_assets/skills/nature-figure/static/fragments/backend/python.md +37 -0
  26. evalrx/agent_assets/skills/nature-figure/static/fragments/backend/r.md +44 -0
  27. evalrx/agent_assets/skills/outcome-driver-analysis/SKILL.md +213 -0
  28. evalrx/agent_assets/skills/outcome-driver-analysis/assets/analysis_report_template.md +53 -0
  29. evalrx/agent_assets/skills/outcome-driver-analysis/references/model_selection.md +72 -0
  30. evalrx/agent_assets/skills/outcome-driver-analysis/scripts/explanatory_var_eda.R +130 -0
  31. evalrx/agent_assets/skills/outcome-driver-analysis/scripts/explanatory_var_eda.py +150 -0
  32. evalrx/agent_assets/skills/outcome-driver-analysis/scripts/fit_outcome_model.R +181 -0
  33. evalrx/agent_assets/skills/outcome-driver-analysis/scripts/fit_outcome_model.py +186 -0
  34. evalrx/agent_assets/skills/outcome-driver-analysis/scripts/univariate_eda.R +149 -0
  35. evalrx/agent_assets/skills/outcome-driver-analysis/scripts/univariate_eda.py +177 -0
  36. evalrx/agent_assets/skills.py +27 -0
  37. evalrx/agent_runtime/__init__.py +78 -0
  38. evalrx/agent_runtime/_docker_runner.py +89 -0
  39. evalrx/agent_runtime/cli_runtime.py +103 -0
  40. evalrx/agent_runtime/cli_transcript.py +138 -0
  41. evalrx/agent_runtime/cli_types.py +68 -0
  42. evalrx/agent_runtime/codegen/__init__.py +5 -0
  43. evalrx/agent_runtime/codegen/runner.py +94 -0
  44. evalrx/agent_runtime/experiment_harness.py +117 -0
  45. evalrx/agent_runtime/factory.py +102 -0
  46. evalrx/agent_runtime/json_shape.py +44 -0
  47. evalrx/agent_runtime/judges/__init__.py +28 -0
  48. evalrx/agent_runtime/judges/agy.py +179 -0
  49. evalrx/agent_runtime/judges/autodetect.py +135 -0
  50. evalrx/agent_runtime/judges/claude.py +159 -0
  51. evalrx/agent_runtime/judges/codex.py +120 -0
  52. evalrx/agent_runtime/providers/__init__.py +21 -0
  53. evalrx/agent_runtime/providers/antigravity.py +31 -0
  54. evalrx/agent_runtime/providers/base.py +145 -0
  55. evalrx/agent_runtime/providers/claude_code.py +49 -0
  56. evalrx/agent_runtime/providers/codex.py +37 -0
  57. evalrx/agent_runtime/providers/gemini_cli.py +26 -0
  58. evalrx/agent_runtime/providers/kimi_cli.py +27 -0
  59. evalrx/agent_runtime/providers/opencode.py +27 -0
  60. evalrx/agent_runtime/providers/registry.py +58 -0
  61. evalrx/agent_runtime/sandbox.py +517 -0
  62. evalrx/agent_runtime/skill_audit.py +143 -0
  63. evalrx/agent_runtime/skills/__init__.py +19 -0
  64. evalrx/agent_runtime/skills/installer.py +68 -0
  65. evalrx/agent_runtime/skills/prompt_policy.py +86 -0
  66. evalrx/agent_runtime/skills/resolver.py +19 -0
  67. evalrx/analysis/__init__.py +132 -0
  68. evalrx/analysis/adjudicate.py +154 -0
  69. evalrx/analysis/analysis_module.py +361 -0
  70. evalrx/analysis/api.py +171 -0
  71. evalrx/analysis/case_studio.py +651 -0
  72. evalrx/analysis/cli.py +114 -0
  73. evalrx/analysis/dashboard.py +350 -0
  74. evalrx/analysis/eval_case_matrix.py +118 -0
  75. evalrx/analysis/eval_viz_theme.py +833 -0
  76. evalrx/analysis/explore_run.py +333 -0
  77. evalrx/analysis/explorer.py +1276 -0
  78. evalrx/analysis/failure_modes.py +607 -0
  79. evalrx/analysis/fused_pipeline.py +489 -0
  80. evalrx/analysis/holdout.py +300 -0
  81. evalrx/analysis/hypothesis_agent.py +230 -0
  82. evalrx/analysis/narration.py +177 -0
  83. evalrx/analysis/operationalize.py +442 -0
  84. evalrx/analysis/plain_language.py +42 -0
  85. evalrx/analysis/planner.py +283 -0
  86. evalrx/analysis/probe_search.py +203 -0
  87. evalrx/analysis/profile.py +268 -0
  88. evalrx/analysis/prompts/__init__.py +0 -0
  89. evalrx/analysis/prompts/explorer.py +417 -0
  90. evalrx/analysis/prompts/failure_modes.py +33 -0
  91. evalrx/analysis/prompts/holdout.py +27 -0
  92. evalrx/analysis/prompts/hypothesis_agent.py +78 -0
  93. evalrx/analysis/prompts/run_codebase.py +47 -0
  94. evalrx/analysis/prompts/stats_agent.py +72 -0
  95. evalrx/analysis/prompts/stats_tool_generator.py +43 -0
  96. evalrx/analysis/result_marker.py +47 -0
  97. evalrx/analysis/run_codebase.py +242 -0
  98. evalrx/analysis/run_view.py +205 -0
  99. evalrx/analysis/stage_views.py +93 -0
  100. evalrx/analysis/stats_agent.py +944 -0
  101. evalrx/analysis/stats_tool_agent.py +261 -0
  102. evalrx/analysis/stats_tool_generator.py +415 -0
  103. evalrx/analysis/stats_tools.py +1153 -0
  104. evalrx/analysis/trajectory_records.py +193 -0
  105. evalrx/analysis/workbench.py +431 -0
  106. evalrx/analyzers/__init__.py +42 -0
  107. evalrx/analyzers/agent/__init__.py +25 -0
  108. evalrx/analyzers/agent/counterfactual.py +84 -0
  109. evalrx/analyzers/agent/first_error_judge.py +96 -0
  110. evalrx/analyzers/agent/ignored_obs.py +81 -0
  111. evalrx/analyzers/agent/loop_detect.py +79 -0
  112. evalrx/analyzers/agent/reliability.py +165 -0
  113. evalrx/analyzers/agent/tool_shap.py +225 -0
  114. evalrx/analyzers/agent/trajectory_rubric.py +168 -0
  115. evalrx/analyzers/attention/__init__.py +19 -0
  116. evalrx/analyzers/attention/relative_attn.py +610 -0
  117. evalrx/analyzers/attention/rollout.py +73 -0
  118. evalrx/analyzers/attention/sink.py +56 -0
  119. evalrx/analyzers/attention/summary.py +190 -0
  120. evalrx/analyzers/attribution/__init__.py +6 -0
  121. evalrx/analyzers/attribution/generic_attn.py +31 -0
  122. evalrx/analyzers/attribution/gradcam.py +30 -0
  123. evalrx/analyzers/base.py +12 -0
  124. evalrx/analyzers/geometry/__init__.py +6 -0
  125. evalrx/analyzers/geometry/cka.py +70 -0
  126. evalrx/analyzers/geometry/linear_probe.py +157 -0
  127. evalrx/analyzers/hallucination/__init__.py +9 -0
  128. evalrx/analyzers/hallucination/chair.py +78 -0
  129. evalrx/analyzers/hallucination/opera.py +29 -0
  130. evalrx/analyzers/hallucination/pope.py +119 -0
  131. evalrx/analyzers/hallucination/selfcheck.py +155 -0
  132. evalrx/analyzers/hallucination/vcd.py +29 -0
  133. evalrx/analyzers/lens/__init__.py +7 -0
  134. evalrx/analyzers/lens/layer_contrast.py +133 -0
  135. evalrx/analyzers/lens/logit_lens.py +138 -0
  136. evalrx/analyzers/lens/tuned_lens.py +30 -0
  137. evalrx/analyzers/patching/__init__.py +5 -0
  138. evalrx/analyzers/patching/causal_trace.py +30 -0
  139. evalrx/analyzers/perturbation/__init__.py +23 -0
  140. evalrx/analyzers/perturbation/_shapley.py +54 -0
  141. evalrx/analyzers/perturbation/context_shap.py +174 -0
  142. evalrx/analyzers/perturbation/cot_faithfulness.py +239 -0
  143. evalrx/analyzers/perturbation/format_sensitivity.py +237 -0
  144. evalrx/analyzers/perturbation/mm_shap.py +146 -0
  145. evalrx/analyzers/perturbation/modality_ablation.py +196 -0
  146. evalrx/analyzers/perturbation/perturbation_battery.py +274 -0
  147. evalrx/analyzers/perturbation/prompt_contrast.py +265 -0
  148. evalrx/analyzers/perturbation/rise.py +94 -0
  149. evalrx/analyzers/perturbation/vl_shap.py +102 -0
  150. evalrx/analyzers/reasoning/__init__.py +33 -0
  151. evalrx/analyzers/reasoning/_text.py +328 -0
  152. evalrx/analyzers/reasoning/answer_extraction_audit.py +327 -0
  153. evalrx/analyzers/reasoning/arith_audit.py +226 -0
  154. evalrx/analyzers/reasoning/contamination.py +214 -0
  155. evalrx/analyzers/reasoning/knowledge_split.py +253 -0
  156. evalrx/analyzers/reasoning/self_repair.py +246 -0
  157. evalrx/analyzers/reasoning/step_rollout_value.py +216 -0
  158. evalrx/analyzers/reasoning/termination_audit.py +258 -0
  159. evalrx/analyzers/uncertainty/__init__.py +18 -0
  160. evalrx/analyzers/uncertainty/calibration.py +174 -0
  161. evalrx/analyzers/uncertainty/coverage_gap.py +199 -0
  162. evalrx/analyzers/uncertainty/entropy.py +90 -0
  163. evalrx/analyzers/uncertainty/logprob_entropy.py +69 -0
  164. evalrx/analyzers/uncertainty/self_consistency.py +204 -0
  165. evalrx/analyzers/uncertainty/verbalized_conf.py +64 -0
  166. evalrx/cli.py +411 -0
  167. evalrx/config.py +77 -0
  168. evalrx/contract/__init__.py +179 -0
  169. evalrx/contract/common.py +452 -0
  170. evalrx/contract/emit.py +948 -0
  171. evalrx/contract/export.py +237 -0
  172. evalrx/contract/m1.py +325 -0
  173. evalrx/contract/m2.py +317 -0
  174. evalrx/contract/m3.py +165 -0
  175. evalrx/contract/m4.py +130 -0
  176. evalrx/contract/m5.py +292 -0
  177. evalrx/contract/methodology.py +76 -0
  178. evalrx/contract/pre_m1.py +58 -0
  179. evalrx/contract/typescript.py +140 -0
  180. evalrx/core/__init__.py +85 -0
  181. evalrx/core/analyzer.py +174 -0
  182. evalrx/core/capability.py +54 -0
  183. evalrx/core/case.py +443 -0
  184. evalrx/core/experiment.py +106 -0
  185. evalrx/core/model.py +198 -0
  186. evalrx/core/pipeline.py +42 -0
  187. evalrx/core/registry.py +142 -0
  188. evalrx/core/result.py +64 -0
  189. evalrx/core/spec.py +173 -0
  190. evalrx/core/tokentype.py +165 -0
  191. evalrx/core/tool.py +92 -0
  192. evalrx/datasets/__init__.py +41 -0
  193. evalrx/datasets/base.py +68 -0
  194. evalrx/datasets/gui_os.py +52 -0
  195. evalrx/datasets/llm_qa.py +57 -0
  196. evalrx/datasets/pure_qa.py +12 -0
  197. evalrx/datasets/vlm_qa.py +695 -0
  198. evalrx/datasets/web_search_qa.py +52 -0
  199. evalrx/eval_agent/__init__.py +341 -0
  200. evalrx/eval_agent/_tools.py +81 -0
  201. evalrx/eval_agent/ab_runner.py +50 -0
  202. evalrx/eval_agent/agentic/__init__.py +43 -0
  203. evalrx/eval_agent/agentic/actions.py +216 -0
  204. evalrx/eval_agent/agentic/board.py +107 -0
  205. evalrx/eval_agent/agentic/loop.py +190 -0
  206. evalrx/eval_agent/agentic/tools.py +538 -0
  207. evalrx/eval_agent/checkpoint.py +57 -0
  208. evalrx/eval_agent/cli_agent.py +59 -0
  209. evalrx/eval_agent/cli_skills.py +5 -0
  210. evalrx/eval_agent/evolution.py +396 -0
  211. evalrx/eval_agent/git_manager.py +215 -0
  212. evalrx/eval_agent/hypothesis.py +172 -0
  213. evalrx/eval_agent/label_quarantine.py +209 -0
  214. evalrx/eval_agent/legacy.py +530 -0
  215. evalrx/eval_agent/log_schema.py +497 -0
  216. evalrx/eval_agent/loop.py +2159 -0
  217. evalrx/eval_agent/loop_reports.py +116 -0
  218. evalrx/eval_agent/model_instrumentation.py +282 -0
  219. evalrx/eval_agent/narration.py +193 -0
  220. evalrx/eval_agent/nl_runner.py +460 -0
  221. evalrx/eval_agent/orchestrator.py +61 -0
  222. evalrx/eval_agent/preregister.py +93 -0
  223. evalrx/eval_agent/prompts/__init__.py +1 -0
  224. evalrx/eval_agent/prompts/agentic.py +46 -0
  225. evalrx/eval_agent/prompts/case_discovery.py +25 -0
  226. evalrx/eval_agent/prompts/diagnosis.py +125 -0
  227. evalrx/eval_agent/prompts/experiment_writer.py +265 -0
  228. evalrx/eval_agent/prompts/explore_step.py +37 -0
  229. evalrx/eval_agent/prompts/fix_agent.py +257 -0
  230. evalrx/eval_agent/prompts/hypothesis_tester.py +15 -0
  231. evalrx/eval_agent/prompts/nl_runner.py +38 -0
  232. evalrx/eval_agent/prompts/probe_agent.py +25 -0
  233. evalrx/eval_agent/prompts/probe_candidate_generator.py +14 -0
  234. evalrx/eval_agent/prompts/probe_generator.py +35 -0
  235. evalrx/eval_agent/prompts/whitebox_probe_generator.py +38 -0
  236. evalrx/eval_agent/report.py +58 -0
  237. evalrx/eval_agent/run_context.py +354 -0
  238. evalrx/eval_agent/run_log.schema.json +1215 -0
  239. evalrx/eval_agent/run_logger_v2.py +1764 -0
  240. evalrx/eval_agent/run_metadata.py +208 -0
  241. evalrx/eval_agent/stages/__init__.py +56 -0
  242. evalrx/eval_agent/stages/case_discovery.py +293 -0
  243. evalrx/eval_agent/stages/diagnosis.py +1017 -0
  244. evalrx/eval_agent/stages/experiment_writer.py +1634 -0
  245. evalrx/eval_agent/stages/fix_agent.py +3916 -0
  246. evalrx/eval_agent/stages/fix_internals.py +499 -0
  247. evalrx/eval_agent/stages/fix_pipeline.py +725 -0
  248. evalrx/eval_agent/stages/fix_tiers.py +187 -0
  249. evalrx/eval_agent/stages/fix_tools.py +1034 -0
  250. evalrx/eval_agent/stages/hypothesis_tester.py +1014 -0
  251. evalrx/eval_agent/stages/probe.py +439 -0
  252. evalrx/eval_agent/stages/probe_agent.py +1079 -0
  253. evalrx/eval_agent/stages/probe_candidate_generator.py +128 -0
  254. evalrx/eval_agent/stages/probe_generator.py +326 -0
  255. evalrx/eval_agent/stages/probe_search_agent.py +106 -0
  256. evalrx/eval_agent/stages/protocol.py +112 -0
  257. evalrx/eval_agent/stages/repair_catalog.py +273 -0
  258. evalrx/eval_agent/stages/surgery.py +524 -0
  259. evalrx/eval_agent/stages/whitebox_probe_generator.py +351 -0
  260. evalrx/eval_agent/store.py +231 -0
  261. evalrx/logging_utils.py +112 -0
  262. evalrx/models/__init__.py +161 -0
  263. evalrx/models/_discover.py +101 -0
  264. evalrx/models/agent.py +380 -0
  265. evalrx/models/backends/__init__.py +58 -0
  266. evalrx/models/backends/api.py +169 -0
  267. evalrx/models/backends/base.py +57 -0
  268. evalrx/models/backends/gemini_compat.py +579 -0
  269. evalrx/models/backends/hf_local.py +2074 -0
  270. evalrx/models/backends/openai_compat.py +301 -0
  271. evalrx/models/backends/vllm_offline.py +116 -0
  272. evalrx/models/base.py +24 -0
  273. evalrx/models/blackbox/__init__.py +4 -0
  274. evalrx/models/blackbox/agent.py +31 -0
  275. evalrx/models/blackbox/base.py +29 -0
  276. evalrx/models/blackbox/gemini.py +279 -0
  277. evalrx/models/blackbox/llm_api.py +17 -0
  278. evalrx/models/blackbox/vlm_api.py +17 -0
  279. evalrx/models/compose.py +66 -0
  280. evalrx/models/inference.py +88 -0
  281. evalrx/models/paper_methods/__init__.py +8 -0
  282. evalrx/models/paper_methods/aad.py +53 -0
  283. evalrx/models/paper_methods/ifcd.py +204 -0
  284. evalrx/models/paper_methods/pai.py +164 -0
  285. evalrx/models/paper_methods/tcd.py +202 -0
  286. evalrx/models/paper_methods/vcd.py +45 -0
  287. evalrx/models/paper_methods/vicrop.py +137 -0
  288. evalrx/models/toolcodec.py +143 -0
  289. evalrx/models/tools/__init__.py +20 -0
  290. evalrx/models/tools/perception.py +300 -0
  291. evalrx/models/tools/visual.py +174 -0
  292. evalrx/models/whitebox/__init__.py +26 -0
  293. evalrx/models/whitebox/agent.py +31 -0
  294. evalrx/models/whitebox/base.py +24 -0
  295. evalrx/models/whitebox/qwen.py +61 -0
  296. evalrx/models/whitebox/qwen2_5_omni.py +29 -0
  297. evalrx/models/whitebox/qwen2_audio.py +25 -0
  298. evalrx/models/whitebox/qwen_omni.py +53 -0
  299. evalrx/models/whitebox/qwen_vl.py +62 -0
  300. evalrx/observability/__init__.py +21 -0
  301. evalrx/observability/envelope.py +122 -0
  302. evalrx/observability/outbox.py +111 -0
  303. evalrx/observability/tracer.py +882 -0
  304. evalrx/reporting/__init__.py +28 -0
  305. evalrx/reporting/case_study.py +947 -0
  306. evalrx/reporting/compiler.py +587 -0
  307. evalrx/reporting/dynamic.py +1882 -0
  308. evalrx/reporting/html_report.py +2225 -0
  309. evalrx/reporting/langfuse_exporter.py +38 -0
  310. evalrx/reporting/langfuse_source.py +155 -0
  311. evalrx/reporting/model.py +151 -0
  312. evalrx/reporting/run_events.py +184 -0
  313. evalrx/reporting/server.py +557 -0
  314. evalrx/reporting/stages.py +58 -0
  315. evalrx/reporting/static_export.py +142 -0
  316. evalrx/reporting/web_dist/index.html +146 -0
  317. evalrx/specs.py +727 -0
  318. evalrx/stats/__init__.py +47 -0
  319. evalrx/stats/api.py +192 -0
  320. evalrx/stats/bootstrap.py +86 -0
  321. evalrx/stats/ebh.py +27 -0
  322. evalrx/stats/evalue.py +98 -0
  323. evalrx/stats/friedman.py +138 -0
  324. evalrx/stats/mcnemar.py +40 -0
  325. evalrx/stats/multiplicity.py +159 -0
  326. evalrx/stats/subset_sampling.py +55 -0
  327. evalrx/term_links.py +43 -0
  328. evalrx/viz/__init__.py +7 -0
  329. evalrx/viz/labels.py +77 -0
  330. evalrx/viz/prompts.py +39 -0
  331. evalrx/viz/renderer.py +590 -0
  332. evalrx/viz/schema.py +36 -0
  333. evalrx/viz/style.py +134 -0
  334. evalrx-0.1.2.dist-info/METADATA +532 -0
  335. evalrx-0.1.2.dist-info/RECORD +339 -0
  336. evalrx-0.1.2.dist-info/WHEEL +5 -0
  337. evalrx-0.1.2.dist-info/entry_points.txt +3 -0
  338. evalrx-0.1.2.dist-info/licenses/LICENSE +121 -0
  339. evalrx-0.1.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,174 @@
1
+ """Analyzer — the sklearn-like estimator at the heart of EvalRX.
2
+
3
+ Consistent contract, every analyzer:
4
+ - is configured with hyper-parameters in ``__init__`` (stored, introspectable
5
+ via ``get_params``/``set_params`` — exactly like a scikit-learn estimator),
6
+ - declares the capabilities it ``requires``,
7
+ - runs via ``run(model, data) -> Result``.
8
+
9
+ ``run`` normalises ``data`` into a :class:`CaseBatch`, verifies the model
10
+ provides the required capabilities (clear :class:`CapabilityError` otherwise),
11
+ then delegates to the subclass's :meth:`_run`. Subclasses implement only
12
+ ``_run`` and never repeat the boilerplate.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import inspect
18
+ from abc import ABC, abstractmethod
19
+ from typing import TYPE_CHECKING, Any
20
+
21
+ from evalrx.core.capability import Capability, CapabilityError
22
+ from evalrx.core.case import CaseBatch, as_casebatch
23
+
24
+ if TYPE_CHECKING:
25
+ from evalrx.core.model import Model
26
+ from evalrx.core.result import Result
27
+
28
+
29
+ class Analyzer(ABC):
30
+ """Abstract base for every analysis (sklearn-estimator style).
31
+
32
+ Class attributes (set by subclasses):
33
+ name: Registered short name (e.g. ``"attention"``).
34
+ requires: Capabilities the analysed model must provide.
35
+
36
+ Example::
37
+
38
+ result = AttentionAnalyzer(layer=-1).run(qwen, "The capital of France is")
39
+ """
40
+
41
+ name: str = "analyzer"
42
+ requires: frozenset[Capability] = frozenset()
43
+ #: Modalities this analysis applies to; matched against ``model.modalities``.
44
+ #: ``{"text"}`` runs on any text-capable model; ``{"image"}`` only on VLMs.
45
+ applies_to_modalities: frozenset[str] = frozenset({"text"})
46
+ #: True when the analysis reads multi-step agent runs
47
+ #: (:class:`~evalrx.core.case.Trajectory`) rather than single turns.
48
+ #:
49
+ #: A requirement on the DATA, which is why it is not a
50
+ #: :class:`~evalrx.core.capability.Capability`: these analyzers run on
51
+ #: trajectories loaded from disk with ``model=None``, so nothing about the
52
+ #: model can express it. ``requires``/``applies_to_modalities`` therefore let
53
+ #: every one of them match a plain LLM or VLM, and a single-turn QA batch got
54
+ #: offered trajectory analyzers — the ones that built with default args then
55
+ #: ran and reported ``n_trajectories: 0``. Selection gates on this against
56
+ #: the batch actually in hand.
57
+ requires_trajectories: bool = False
58
+ #: Modality slots that must actually be FILLED in the batch for this analysis
59
+ #: to mean anything — the data-shape counterpart of ``applies_to_modalities``.
60
+ #:
61
+ #: ``applies_to_modalities`` is matched against what the MODEL declares, and
62
+ #: the match is an intersection, so one shared member is enough: an audio
63
+ #: model declares ``{"text", "audio"}`` and therefore matches every analyzer
64
+ #: carrying ``"text"``. That is the same hole ``requires_trajectories`` was
65
+ #: added to close, one modality over — the analyzer is selected, runs, reads
66
+ #: an empty slot, and reports a number computed over nothing.
67
+ #:
68
+ #: Empty (the default) means the analysis needs no particular slot filled.
69
+ #: Selection gates this against the slots the batch in hand actually fills.
70
+ requires_modalities: frozenset[str] = frozenset()
71
+ #: ``metric name -> what it measures, in one plain sentence``.
72
+ #:
73
+ #: The analyzer is the only thing that knows what its own numbers mean, and
74
+ #: without saying so every consumer has to guess from the identifier. The
75
+ #: dashboard guessed by replacing underscores with spaces, which is how a
76
+ #: chart came to be labelled "output chars (answer extraction audit)" and
77
+ #: how six different metrics all truncated to "Did the model g...".
78
+ #:
79
+ #: Write it for someone who has not read this file: say what the number
80
+ #: counts and which direction is bad. Undocumented metrics still work --
81
+ #: they are reported as undocumented rather than dressed up, so the gap is
82
+ #: visible instead of being filled with plausible-looking prose.
83
+ #:
84
+ #: A value may be the sentence alone, or ``(short_label, sentence)`` when the
85
+ #: metric needs a name a chart axis can hold — roughly 24 characters, since
86
+ #: a bar chart's label column is narrow and anything longer is truncated to
87
+ #: an ellipsis that identifies nothing. Without a short label the identifier
88
+ #: is humanized as the fallback, which reads as jargon but at least does not
89
+ #: pretend to be an explanation.
90
+ signal_docs: dict[str, "str | tuple[str, str]"] = {}
91
+
92
+ def __init__(self, **params: Any) -> None:
93
+ # Store hyper-parameters sklearn-style for introspection / reproduction.
94
+ self._params: dict[str, Any] = dict(params)
95
+ for key, value in params.items():
96
+ setattr(self, key, value)
97
+
98
+ # ------------------------------------------------------------------
99
+ # sklearn-style introspection
100
+ # ------------------------------------------------------------------
101
+
102
+ def get_params(self) -> dict[str, Any]:
103
+ """Return the analyzer's hyper-parameters.
104
+
105
+ Derived from the concrete subclass's ``__init__`` signature so that
106
+ typed subclasses never silently omit a parameter by forgetting to
107
+ forward it to ``super().__init__``.
108
+ """
109
+ sig = inspect.signature(type(self).__init__)
110
+ typed_params = {
111
+ name
112
+ for name, p in sig.parameters.items()
113
+ if name != "self"
114
+ and p.kind not in (
115
+ inspect.Parameter.VAR_POSITIONAL,
116
+ inspect.Parameter.VAR_KEYWORD,
117
+ )
118
+ }
119
+ if typed_params:
120
+ return {name: getattr(self, name) for name in typed_params}
121
+ # No typed params — base **params pattern or zero-param analyzer.
122
+ return dict(self._params)
123
+
124
+ def set_params(self, **params: Any) -> "Analyzer":
125
+ """Update hyper-parameters in place and return self (chainable)."""
126
+ self._params.update(params)
127
+ for key, value in params.items():
128
+ setattr(self, key, value)
129
+ return self
130
+
131
+ # ------------------------------------------------------------------
132
+ # Execution
133
+ # ------------------------------------------------------------------
134
+
135
+ def run(self, model: "Model", data: Any) -> "Result":
136
+ """Validate capabilities, normalise *data*, and run the analysis.
137
+
138
+ Args:
139
+ model: Any :class:`~evalrx.core.model.Model`.
140
+ data: ``str | FailureCase | Inputs | CaseBatch | iterable`` — normalised
141
+ via :func:`~evalrx.core.case.as_casebatch`.
142
+
143
+ Returns:
144
+ A :class:`~evalrx.core.result.Result` (subclass) instance.
145
+
146
+ Raises:
147
+ CapabilityError: if *model* lacks a required capability.
148
+ """
149
+ self._check_capabilities(model)
150
+ cases = as_casebatch(data)
151
+ return self._run(model, cases)
152
+
153
+ @abstractmethod
154
+ def _run(self, model: "Model", cases: CaseBatch) -> "Result":
155
+ """Subclass hook: perform the analysis over an already-normalised batch."""
156
+
157
+ # ------------------------------------------------------------------
158
+ # Helpers
159
+ # ------------------------------------------------------------------
160
+
161
+ def _check_capabilities(self, model: "Model") -> None:
162
+ if not self.requires: # capability-free (e.g. trajectory heuristics) — model may be None
163
+ return
164
+ missing = set(self.requires) - set(getattr(model, "capabilities", frozenset()))
165
+ if missing:
166
+ raise CapabilityError(
167
+ analyzer=self.name,
168
+ model=repr(model),
169
+ missing=missing,
170
+ )
171
+
172
+ def __repr__(self) -> str:
173
+ params = ", ".join(f"{k}={v!r}" for k, v in self.get_params().items())
174
+ return f"{type(self).__name__}({params})"
@@ -0,0 +1,54 @@
1
+ """Capability vocabulary — the contract between models and analyzers.
2
+
3
+ A *model* declares the capabilities it **provides** (e.g. a local Qwen exposes
4
+ ``ATTENTION`` and ``HIDDEN_STATES``; an API model provides only ``GENERATE``).
5
+ An *analyzer* declares the capabilities it **requires**. The registry matches
6
+ the two, so an agent can ask "what can I run on this model?" without touching
7
+ any model internals.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from enum import Enum
13
+
14
+
15
+ class Capability(str, Enum):
16
+ """What a model can expose to analyzers.
17
+
18
+ Inherits from ``str`` so values are JSON-serialisable and print cleanly,
19
+ which matters when results are handed to an LLM agent.
20
+ """
21
+
22
+ GENERATE = "generate" # produce text output
23
+ TOOL_CALLS = "tool_calls" # emit/consume tool calls (agent mode)
24
+ LOGPROBS = "logprobs" # top-k token logprobs (API-feasible; OpenAI yes / Gemini no)
25
+ LOGITS = "logits" # full next-token logit tensor (white-box forward)
26
+ ATTENTION = "attention" # per-layer, per-head attention weights
27
+ HIDDEN_STATES = "hidden_states" # per-layer residual-stream activations
28
+ EMBEDDINGS = "embeddings" # input/output embedding vectors
29
+ ACTIVATIONS = "activations" # arbitrary hooked module activations
30
+ GRADIENTS = "gradients" # input/parameter gradients (saliency)
31
+
32
+ def __repr__(self) -> str: # pragma: no cover - cosmetic
33
+ return f"Capability.{self.name}"
34
+
35
+
36
+ class CapabilityError(RuntimeError):
37
+ """Raised when an analyzer is run on a model that lacks required capabilities."""
38
+
39
+ def __init__(
40
+ self,
41
+ analyzer: str,
42
+ model: str,
43
+ missing: set[Capability],
44
+ ) -> None:
45
+ self.analyzer = analyzer
46
+ self.model = model
47
+ self.missing = missing
48
+ missing_names = sorted(c.value for c in missing)
49
+ super().__init__(
50
+ f"Analyzer '{analyzer}' cannot run on model '{model}': "
51
+ f"missing capabilities {missing_names}. "
52
+ f"This analysis requires a model that provides them "
53
+ f"(typically a white-box / locally-deployed model)."
54
+ )
evalrx/core/case.py ADDED
@@ -0,0 +1,443 @@
1
+ """FailureCase — the central data object of EvalRX.
2
+
3
+ Everything in the system speaks ``FailureCase``:
4
+ - datasets *produce* batches of cases,
5
+ - analyzers *attribute* failures over cases,
6
+ - stats *test* significance across cases,
7
+ - the agent *accumulates* and *evolves* a corpus of cases.
8
+
9
+ Analyzers accept ``str | FailureCase | list | CaseBatch`` and normalise via
10
+ :func:`as_casebatch`, so ``model.call_attention("a prompt")`` stays ergonomic
11
+ while the canonical unit of work remains the case.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import uuid
17
+ from dataclasses import dataclass, field
18
+ from enum import Enum
19
+ from typing import Any, Iterable, Iterator, Optional
20
+
21
+
22
+ class Label(str, Enum):
23
+ """Outcome label for a case."""
24
+
25
+ PASS = "pass"
26
+ FAIL = "fail"
27
+ UNKNOWN = "unknown"
28
+
29
+
30
+ class Source(str, Enum):
31
+ """Where a case came from — important for self-evolution provenance."""
32
+
33
+ HUMAN = "human"
34
+ DATASET = "dataset"
35
+ AGENT = "agent"
36
+
37
+
38
+ @dataclass
39
+ class Provenance:
40
+ """How a case was created."""
41
+
42
+ source: Source = Source.HUMAN
43
+ metadata: dict[str, Any] = field(default_factory=dict)
44
+
45
+
46
+ @dataclass
47
+ class Inputs:
48
+ """Model inputs for a case.
49
+
50
+ ``prompt`` is always present; the multimodal slots are populated per the
51
+ model's modality set — ``image`` for VLMs, ``audio`` / ``video`` for omni
52
+ models (e.g. Qwen3-Omni). Each slot holds a decoded object (PIL.Image /
53
+ waveform / frames) **or** a path/URL the backend resolves.
54
+ """
55
+
56
+ prompt: str
57
+ image: Any = None # PIL.Image | path/URL | None (VLM)
58
+ audio: Any = None # np.ndarray | path/URL | None (omni)
59
+ video: Any = None # frames | path/URL | None (omni)
60
+
61
+ def __str__(self) -> str:
62
+ return self.prompt
63
+
64
+
65
+ # ----------------------------------------------------------------------
66
+ # Agent / trajectory data model
67
+ # ----------------------------------------------------------------------
68
+ # A FailureCase can be a single (unit) interaction OR carry a multi-step agent
69
+ # trajectory. The trajectory schema below is the canonical substrate for both
70
+ # execution logging and agent failure analysis (loop detection, first-error
71
+ # attribution, recovery). It deliberately mirrors the keys an OTel/OpenInference
72
+ # span carries and an existing node-tree conversation already stores, so a real
73
+ # engine's trace adapts in via ``Trajectory.from_records`` without re-modelling.
74
+
75
+ class StepRole(str, Enum):
76
+ """Role of a single trajectory step."""
77
+
78
+ USER = "user"
79
+ PLANNER = "planner"
80
+ ACTOR = "actor"
81
+ TOOL = "tool"
82
+ VERIFIER = "verifier"
83
+ MEMORY = "memory"
84
+ SYSTEM = "system"
85
+
86
+
87
+ def _json_safe(value: Any) -> Any:
88
+ """Best-effort conversion of *value* into something ``json.dumps`` accepts.
89
+
90
+ Rich objects degrade to descriptors instead of raising: images (anything
91
+ with ``.size`` + ``.mode``, i.e. PIL) become ``"<image WxH>"`` — trajectories
92
+ reference media, they never embed pixels.
93
+ """
94
+ if value is None or isinstance(value, (bool, int, float, str)):
95
+ return value
96
+ if isinstance(value, Enum):
97
+ return value.value
98
+ if isinstance(value, dict):
99
+ return {str(k): _json_safe(v) for k, v in value.items()}
100
+ if isinstance(value, (list, tuple, set, frozenset)):
101
+ return [_json_safe(v) for v in value]
102
+ if hasattr(value, "size") and hasattr(value, "mode"): # PIL.Image, without importing PIL
103
+ try:
104
+ w, h = value.size
105
+ return f"<image {w}x{h}>"
106
+ except Exception: # pragma: no cover - exotic size attrs
107
+ return "<image>"
108
+ if hasattr(value, "to_dict"):
109
+ try:
110
+ return _json_safe(value.to_dict())
111
+ except Exception: # pragma: no cover - defensive
112
+ return str(value)
113
+ return str(value)
114
+
115
+
116
+ @dataclass
117
+ class Step:
118
+ """One atomic step in an agent trajectory.
119
+
120
+ Execution fills the top block; analyzers WRITE the annotation block
121
+ (``is_first_error`` / ``failure_mode`` / ``judge_confidence``).
122
+ """
123
+
124
+ idx: int
125
+ role: StepRole = StepRole.ACTOR
126
+ content: Any = None # thought / message / final text
127
+ agent_id: str = "main" # which agent acted (multi-agent traces)
128
+ tool_call: Optional[dict] = None # {"name": ..., "args": {...}, "id": ...}
129
+ observation: Any = None # tool/env result (images by ref)
130
+ span: dict = field(default_factory=dict) # OTel-ish attrs: tokens, latency_ms, cost, model
131
+ # --- analysis annotations (written by analyzers, not execution) ---
132
+ is_first_error: Optional[bool] = None
133
+ failure_mode: Optional[str] = None # MAST code, e.g. "FM-2.4"
134
+ judge_confidence: Optional[float] = None
135
+
136
+ def to_dict(self) -> dict[str, Any]:
137
+ """JSON-safe dict (images and rich objects degrade to descriptors)."""
138
+ return {
139
+ "idx": self.idx,
140
+ "role": self.role.value,
141
+ "content": _json_safe(self.content),
142
+ "agent_id": self.agent_id,
143
+ "tool_call": _json_safe(self.tool_call),
144
+ "observation": _json_safe(self.observation),
145
+ "span": _json_safe(self.span),
146
+ "is_first_error": self.is_first_error,
147
+ "failure_mode": self.failure_mode,
148
+ "judge_confidence": self.judge_confidence,
149
+ }
150
+
151
+ @classmethod
152
+ def from_dict(cls, d: dict) -> "Step":
153
+ """Inverse of :meth:`to_dict` (descriptor strings stay strings)."""
154
+ role = d.get("role", StepRole.ACTOR)
155
+ return cls(
156
+ idx=int(d.get("idx", 0)),
157
+ role=StepRole(role) if not isinstance(role, StepRole) else role,
158
+ content=d.get("content"),
159
+ agent_id=d.get("agent_id", "main"),
160
+ tool_call=d.get("tool_call"),
161
+ observation=d.get("observation"),
162
+ span=d.get("span") or {},
163
+ is_first_error=d.get("is_first_error"),
164
+ failure_mode=d.get("failure_mode"),
165
+ judge_confidence=d.get("judge_confidence"),
166
+ )
167
+
168
+
169
+ @dataclass
170
+ class Trajectory:
171
+ """The full sequence of an agent run — the unit of trajectory analysis."""
172
+
173
+ sample_id: str
174
+ goal: str = ""
175
+ steps: list[Step] = field(default_factory=list)
176
+ final_answer: Any = None
177
+ ground_truth: Any = None
178
+ outcome: Label = Label.UNKNOWN # reuse the engine's outcome, don't recompute
179
+ metrics: dict[str, Any] = field(default_factory=dict) # n_steps, tool_cost, latency, tokens, recovery_rate
180
+
181
+ def __len__(self) -> int:
182
+ return len(self.steps)
183
+
184
+ def __iter__(self) -> Iterator[Step]:
185
+ return iter(self.steps)
186
+
187
+ def to_dict(self) -> dict[str, Any]:
188
+ """JSON-safe dict — the on-disk trajectory format (``trajectory.json``)."""
189
+ return {
190
+ "sample_id": self.sample_id,
191
+ "goal": self.goal,
192
+ "steps": [s.to_dict() for s in self.steps],
193
+ "final_answer": _json_safe(self.final_answer),
194
+ "ground_truth": _json_safe(self.ground_truth),
195
+ "outcome": self.outcome.value,
196
+ "metrics": _json_safe(self.metrics),
197
+ }
198
+
199
+ @classmethod
200
+ def from_dict(cls, d: dict) -> "Trajectory":
201
+ """Inverse of :meth:`to_dict` — reload a persisted trajectory."""
202
+ outcome = d.get("outcome", Label.UNKNOWN)
203
+ return cls(
204
+ sample_id=d.get("sample_id", ""),
205
+ goal=d.get("goal", ""),
206
+ steps=[Step.from_dict(s) for s in d.get("steps") or []],
207
+ final_answer=d.get("final_answer"),
208
+ ground_truth=d.get("ground_truth"),
209
+ outcome=Label(outcome) if not isinstance(outcome, Label) else outcome,
210
+ metrics=d.get("metrics") or {},
211
+ )
212
+
213
+ @classmethod
214
+ def from_records(
215
+ cls,
216
+ records: Iterable[dict],
217
+ *,
218
+ sample_id: str,
219
+ goal: str = "",
220
+ final_answer: Any = None,
221
+ ground_truth: Any = None,
222
+ outcome: Label = Label.UNKNOWN,
223
+ metrics: dict | None = None,
224
+ ) -> "Trajectory":
225
+ """Build a Trajectory from a list of plain dict step-records.
226
+
227
+ This is the seam for an external engine (e.g. the node-tree
228
+ ``conversation_history`` of an existing API runner): map each turn to a
229
+ ``{role, content, tool_call, observation, span, agent_id}`` dict and
230
+ pass them here. No engine internals leak into the analysis layer.
231
+ """
232
+ steps: list[Step] = []
233
+ for i, rec in enumerate(records):
234
+ role = rec.get("role", StepRole.ACTOR)
235
+ steps.append(
236
+ Step(
237
+ idx=i,
238
+ role=StepRole(role) if not isinstance(role, StepRole) else role,
239
+ content=rec.get("content"),
240
+ agent_id=rec.get("agent_id", "main"),
241
+ tool_call=rec.get("tool_call"),
242
+ observation=rec.get("observation"),
243
+ span=rec.get("span", {}),
244
+ )
245
+ )
246
+ return cls(
247
+ sample_id=sample_id,
248
+ goal=goal,
249
+ steps=steps,
250
+ final_answer=final_answer,
251
+ ground_truth=ground_truth,
252
+ outcome=outcome,
253
+ metrics=metrics or {},
254
+ )
255
+
256
+
257
+ @dataclass
258
+ class FailureCase:
259
+ """A single unit of failure analysis.
260
+
261
+ Attributes:
262
+ inputs: What was fed to the model (:class:`Inputs`).
263
+ expected: Gold / expected behaviour, if known.
264
+ observed: What the model actually produced, if run.
265
+ label: :class:`Label` — pass / fail / unknown.
266
+ tags: Free-form failure-taxonomy tags (e.g. ``{"hallucination"}``).
267
+ provenance: How this case was created (:class:`Provenance`).
268
+ id: Stable identifier (auto-generated if omitted).
269
+ metadata: Free-form extra fields.
270
+ """
271
+
272
+ inputs: Inputs
273
+ expected: Any = None
274
+ observed: Any = None
275
+ trajectory: Optional[Trajectory] = None # set for multi-step / agent cases
276
+ label: Label = Label.UNKNOWN
277
+ tags: set[str] = field(default_factory=set)
278
+ provenance: Provenance = field(default_factory=Provenance)
279
+ id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
280
+ metadata: dict[str, Any] = field(default_factory=dict)
281
+
282
+ @classmethod
283
+ def from_prompt(cls, prompt: str, **kwargs) -> "FailureCase":
284
+ """Build a case from a raw prompt string."""
285
+ return cls(inputs=Inputs(prompt=prompt), **kwargs)
286
+
287
+ def to_dict(self) -> dict[str, Any]:
288
+ return {
289
+ "id": self.id,
290
+ "inputs": {
291
+ "prompt": self.inputs.prompt,
292
+ "image": _json_safe(self.inputs.image),
293
+ "audio": _json_safe(self.inputs.audio),
294
+ "video": _json_safe(self.inputs.video),
295
+ },
296
+ "expected": self.expected,
297
+ "observed": self.observed,
298
+ "trajectory": self.trajectory.to_dict() if self.trajectory else None,
299
+ "label": self.label.value,
300
+ "tags": sorted(self.tags),
301
+ "provenance": {
302
+ "source": self.provenance.source.value,
303
+ "metadata": self.provenance.metadata,
304
+ },
305
+ "metadata": self.metadata,
306
+ }
307
+
308
+
309
+ class CaseBatch:
310
+ """An ordered collection of :class:`FailureCase` with convenience helpers."""
311
+
312
+ def __init__(self, cases: Iterable[FailureCase] | None = None) -> None:
313
+ self._cases: list[FailureCase] = list(cases) if cases else []
314
+
315
+ # -- constructors --------------------------------------------------
316
+ @classmethod
317
+ def from_prompts(cls, prompts: Iterable[str], **kwargs) -> "CaseBatch":
318
+ """Build a batch from raw prompt strings."""
319
+ return cls(FailureCase.from_prompt(p, **kwargs) for p in prompts)
320
+
321
+ # -- list-like behaviour -------------------------------------------
322
+ def __iter__(self) -> Iterator[FailureCase]:
323
+ return iter(self._cases)
324
+
325
+ def __len__(self) -> int:
326
+ return len(self._cases)
327
+
328
+ def __getitem__(self, idx: int) -> FailureCase:
329
+ return self._cases[idx]
330
+
331
+ def append(self, case: FailureCase) -> None:
332
+ self._cases.append(case)
333
+
334
+ # -- querying (used by the agent / stats) --------------------------
335
+ def filter(
336
+ self,
337
+ label: Label | None = None,
338
+ tags: set[str] | None = None,
339
+ ) -> "CaseBatch":
340
+ """Return a new batch matching *label* and/or containing all *tags*."""
341
+ out = [
342
+ c
343
+ for c in self._cases
344
+ if (label is None or c.label == label)
345
+ and (tags is None or tags.issubset(c.tags))
346
+ ]
347
+ return CaseBatch(out)
348
+
349
+ def stratified_head(self, n: int) -> "list[FailureCase]":
350
+ """First *n* cases, but label-balanced: take FAIL and PASS in document
351
+ order and interleave so a capped subsample keeps minority-class (FAIL)
352
+ representation instead of whatever happens to sit at the head.
353
+
354
+ White-box analyzers cap how many cases they probe (one forward each);
355
+ on an enriched/curated batch a plain ``[:n]`` head is mostly PASS, which
356
+ starves the FAIL group and leaves downstream group contrasts
357
+ underpowered. Returns all cases unchanged when ``n >= len`` or ``n<=0``.
358
+ """
359
+ if n <= 0 or n >= len(self._cases):
360
+ return list(self._cases)
361
+ fails = [c for c in self._cases if c.label == Label.FAIL]
362
+ rest = [c for c in self._cases if c.label != Label.FAIL]
363
+ # Aim for a balanced split, but never drop available minority cases below
364
+ # what fits: give FAIL up to half the budget, fill the rest with others.
365
+ n_fail = min(len(fails), max(n // 2, n - len(rest)))
366
+ keep = fails[:n_fail] + rest[: n - n_fail]
367
+ # preserve original document order among the kept cases
368
+ kept = set(id(c) for c in keep)
369
+ return [c for c in self._cases if id(c) in kept]
370
+
371
+ def __repr__(self) -> str:
372
+ return f"CaseBatch(n={len(self)})"
373
+
374
+
375
+ #: Modality slots other than text. Mirrors ``contract.common.MEDIA_SLOTS``;
376
+ #: duplicated rather than imported so ``core`` keeps no dependency on the
377
+ #: optional ``contract`` extra (pydantic).
378
+ MEDIA_SLOTS: tuple[str, ...] = ("image", "audio", "video")
379
+
380
+
381
+ def probed_modalities(data: Any) -> set[str]:
382
+ """Modality slots the cases in *data* actually fill.
383
+
384
+ This is what a batch IS, not what the model CAN DO, and routing must use
385
+ this one: an omni model (text+image+audio+video) evaluated on an audio
386
+ benchmark declares image as well, so routing on the model's declaration
387
+ sends image analyzers at a batch holding no images — they match, run, and
388
+ report a number computed over nothing.
389
+
390
+ Returns ``{"text"}`` for a batch that fills no media slot; a prompt is always
391
+ present, so text is the floor rather than a slot. Non-iterable or unreadable
392
+ *data* also yields ``{"text"}`` — an unknown shape must not be reported as
393
+ evidence that a slot is absent.
394
+ """
395
+ present: set[str] = {"text"}
396
+ try:
397
+ cases = list(data) if data is not None else []
398
+ except TypeError:
399
+ return present
400
+ for case in cases:
401
+ inputs = getattr(case, "inputs", None)
402
+ if inputs is None:
403
+ continue
404
+ for slot in MEDIA_SLOTS:
405
+ if getattr(inputs, slot, None) is not None:
406
+ present.add(slot)
407
+ return present
408
+
409
+
410
+ def as_casebatch(
411
+ data: str | FailureCase | Inputs | Trajectory | Iterable | CaseBatch,
412
+ ) -> CaseBatch:
413
+ """Normalise common inputs into a :class:`CaseBatch`.
414
+
415
+ Accepts:
416
+ - a ``str`` → single-case batch from the prompt,
417
+ - a :class:`FailureCase` → single-case batch,
418
+ - an :class:`Inputs` → single-case batch,
419
+ - a :class:`Trajectory` → single-case batch,
420
+ - a :class:`CaseBatch` → returned unchanged,
421
+ - any iterable of the above.
422
+ """
423
+ if isinstance(data, CaseBatch):
424
+ return data
425
+ if isinstance(data, str):
426
+ return CaseBatch([FailureCase.from_prompt(data)])
427
+ if isinstance(data, FailureCase):
428
+ return CaseBatch([data])
429
+ if isinstance(data, Inputs):
430
+ return CaseBatch([FailureCase(inputs=data)])
431
+ if isinstance(data, Trajectory): # agent analyzers accept a Trajectory directly
432
+ return CaseBatch([FailureCase(inputs=Inputs(prompt=data.goal), trajectory=data)])
433
+ if isinstance(data, Iterable):
434
+ batch = CaseBatch()
435
+ for item in data:
436
+ # flatten via single-item normalisation
437
+ for case in as_casebatch(item):
438
+ batch.append(case)
439
+ return batch
440
+ raise TypeError(
441
+ f"Cannot interpret {type(data).__name__} as cases. "
442
+ "Pass a str, FailureCase, Inputs, Trajectory, CaseBatch, or an iterable of these."
443
+ )