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,1079 @@
1
+ """M1 — ProbeAgent: select suitable analyzers and execute them.
2
+
3
+ Combines tool selection (which analyzers to run for this model kind) with
4
+ execution (running each analyzer directly or inside a Docker container).
5
+
6
+ Direct mode (default, static selection)::
7
+
8
+ agent = ProbeAgent(max_analyzers=4)
9
+ results = agent.probe(model, data) # {analyzer_name: Result}
10
+
11
+ LLM-guided mode — pass a judge model and a protocol::
12
+
13
+ agent = ProbeAgent(judge=model, max_analyzers=4)
14
+ results, schema = agent.probe_with_schema(model, data, protocol=protocol)
15
+ print(schema.rationale) # why these analyzers were chosen
16
+
17
+ When both *judge* and *protocol* are present the judge LLM reads the protocol
18
+ description and the catalog of available analyzers, then selects the most
19
+ relevant ones. If the LLM call fails the agent falls back to
20
+ :class:`~evalrx.eval_agent.probe.StrategyProbe` automatically.
21
+
22
+ Docker mode::
23
+
24
+ agent = ProbeAgent(use_docker=True, docker_image="evalrx:latest")
25
+ results = agent.probe(model, data)
26
+
27
+ Docker mode is only engaged for **black-box-compatible** analyzers (those
28
+ whose requirements can be satisfied by a GENERATE-only API model). Analyzers
29
+ that need white-box internals (ATTENTION, HIDDEN_STATES, …) always run
30
+ directly — they need the locally-loaded model and cannot be meaningfully
31
+ containerised without shipping the weights.
32
+
33
+ The Docker container receives a JSON payload via stdin::
34
+
35
+ {"analyzer": "self_consistency", "params": {}, "cases": [...],
36
+ "model_env": "GEMINI_API_KEY"} # which env var carries the API key
37
+
38
+ and writes the analyzer findings as JSON to stdout.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import inspect
44
+ import json
45
+ import logging
46
+ import re
47
+ import subprocess
48
+ import warnings
49
+ from concurrent.futures import ThreadPoolExecutor, as_completed
50
+ from typing import TYPE_CHECKING, Any
51
+
52
+ from evalrx.core.capability import Capability
53
+ from evalrx.core.experiment import Experiment, ExperimentRunner
54
+ from evalrx.core.registry import registry
55
+ from evalrx.eval_agent.prompts.probe_agent import _SELECTION_PROMPT_TMPL
56
+ from evalrx.eval_agent.stages.probe import ModelKind, StrategyProbe, get_analyzer_catalog
57
+
58
+ if TYPE_CHECKING:
59
+ from evalrx.core.analyzer import Analyzer
60
+ from evalrx.core.case import CaseBatch
61
+ from evalrx.core.model import Model
62
+ from evalrx.core.result import Result
63
+ from evalrx.eval_agent.stages.protocol import ExperimentProtocol, ProbingSchema
64
+
65
+ logger = logging.getLogger(__name__)
66
+
67
+ # Capabilities that a containerised (API-based) model can satisfy.
68
+ _BLACKBOX_CAPS = frozenset({Capability.GENERATE, Capability.LOGPROBS, Capability.TOOL_CALLS})
69
+
70
+ def _is_blackbox_compatible(analyzer_cls: type) -> bool:
71
+ """True when the analyzer's requires are all satisfiable by a black-box API model."""
72
+ return analyzer_cls.requires <= _BLACKBOX_CAPS
73
+
74
+
75
+ class ProbeAgent:
76
+ """M1: select analyzers and execute them (directly or via Docker).
77
+
78
+ Args:
79
+ probe: Static selector — used when *judge* is absent or the
80
+ LLM call fails. Defaults to ``StrategyProbe()``.
81
+ judge: Any :class:`~evalrx.core.model.Model` with
82
+ ``Capability.GENERATE``. When set **and** a
83
+ protocol is supplied to :meth:`probe`, the judge LLM
84
+ reads the protocol description and available analyzer
85
+ catalog to select analyzers dynamically.
86
+ runner: Executes direct (non-Docker) runs. Defaults to a
87
+ fresh ``ExperimentRunner()``.
88
+ max_analyzers: Cap on analyzers per cycle.
89
+ analyzer_overrides: Pre-instantiated analyzers for those requiring
90
+ mandatory constructor arguments.
91
+ use_docker: When ``True``, black-box-compatible analyzers are
92
+ run inside a Docker container instead of in-process.
93
+ docker_image: Docker image to use when *use_docker* is ``True``.
94
+ docker_timeout: Seconds before a Docker run is killed.
95
+ model_env_var: Name of the env var inside Docker that carries the
96
+ API key for the containerised model.
97
+ """
98
+
99
+ def __init__(
100
+ self,
101
+ probe: StrategyProbe | None = None,
102
+ judge: "Model | None" = None,
103
+ runner: ExperimentRunner | None = None,
104
+ max_analyzers: int | None = None,
105
+ analyzer_overrides: dict[str, "Analyzer"] | None = None,
106
+ use_docker: bool = False,
107
+ docker_image: str = "evalrx:latest",
108
+ docker_timeout: int = 300,
109
+ model_env_var: str = "GEMINI_API_KEY",
110
+ allow_codegen: bool = False,
111
+ codegen_config: "Any | None" = None,
112
+ probe_generator: "Any | None" = None,
113
+ whitebox_generator: "Any | None" = None,
114
+ case_examples: tuple[int, int] = (4, 2),
115
+ run_logger: "Any | None" = None,
116
+ max_cases_per_analyzer: int = 0,
117
+ ) -> None:
118
+ self.selector = probe or StrategyProbe()
119
+ self.judge = judge
120
+ self.runner = runner or ExperimentRunner()
121
+ self.max_analyzers = max_analyzers
122
+ self._overrides: dict[str, "Analyzer"] = analyzer_overrides or {}
123
+ self.use_docker = use_docker
124
+ self.docker_image = docker_image
125
+ self.docker_timeout = docker_timeout
126
+ self.model_env_var = model_env_var
127
+ # Set by probe() / probe_with_schema() so callers can inspect why
128
+ # each analyzer was chosen without changing the return type of probe().
129
+ self.last_schema: "ProbingSchema | None" = None
130
+ # Tier (b): generate a bespoke black-box probe in a sandbox when no
131
+ # catalog analyzer fits. Disabled unless allow_codegen=True + a backend.
132
+ self.allow_codegen = allow_codegen
133
+ self._codegen_config = codegen_config
134
+ self._probe_generator = probe_generator
135
+ self._whitebox_generator = whitebox_generator
136
+ # Tier (c): generated probes are cached and re-run on later cycles.
137
+ # Each entry is (generator, probe) so cached probes re-run on the
138
+ # generator (black-box or white-box) that created them.
139
+ self._generated_probes: list[Any] = []
140
+ # Optional "need_custom" hint extracted from the LLM selection response.
141
+ self._last_need_custom: str | None = None
142
+ # Analyzer runtime failures, accumulated ACROSS cycles (the loop reuses
143
+ # one agent instance). Fed back into the next selection prompt so the
144
+ # judge stops expecting evidence from broken tools, and into the
145
+ # tier-(b) trigger so a bespoke probe replaces the missing evidence.
146
+ self._failed_analyzers: dict[str, str] = {}
147
+ # How many FAIL / PASS example cases to fold into the LLM selection
148
+ # prompt. More examples = more specialised selection, less generalisable
149
+ # (the generalization–specialization trade-off). ``(0, 0)`` disables it.
150
+ self._case_examples = case_examples
151
+ # Optional RunLogger forwarded to the probe generators so their
152
+ # code-writing attempts surface as "tool_codegen" events. The loop sets this
153
+ # when it is left unset, so wiring it here is optional for callers.
154
+ self.run_logger = run_logger
155
+ # Last LLM analyzer-selection judge I/O, surfaced for RunLogger. Empty
156
+ # when selection used the static fallback (no judge / LLM call failed).
157
+ self.last_selection_prompt: str = ""
158
+ self.last_selection_raw: str = ""
159
+ #: Ceiling on how many cases ANY single analyzer receives. 0 = no limit.
160
+ #:
161
+ #: Capping via each analyzer's own ``max_cases`` constructor argument
162
+ #: only reaches the analyzers that HAVE one, and the ones that dominate
163
+ #: M1 wall-clock mostly do not: ``first_error_judge``, ``rise``,
164
+ #: ``self_consistency`` and ``verbalized_confidence`` expose no such knob
165
+ #: and ``trajectory_rubric`` defaults it to None, so all five iterate the
166
+ #: whole batch one generate() at a time. Measured on qwen3.5-2b /
167
+ #: minervamath (136 explore cases, 20 generating analyzers): the caller
168
+ #: capped 7 of them, observed concurrency decayed 5.8 -> 1.0 over three
169
+ #: hours and then sat at exactly 1.0 while the uncapped ones finished
170
+ #: alone, at ~1/10 of the endpoint's throughput.
171
+ #:
172
+ #: This is the backstop for that: it bounds every analyzer, including the
173
+ #: ones with no knob to turn. It costs statistical power, not
174
+ #: correctness -- the same measurement, fewer of them.
175
+ self.max_cases_per_analyzer = int(max_cases_per_analyzer or 0)
176
+ #: Names actually truncated by the cap, for the caller to report.
177
+ self.capped_analyzers: dict[str, tuple[int, int]] = {}
178
+
179
+ # ------------------------------------------------------------------
180
+ # Public interface
181
+ # ------------------------------------------------------------------
182
+
183
+ def probe(
184
+ self,
185
+ model: "Model",
186
+ data: "CaseBatch",
187
+ *,
188
+ protocol: "ExperimentProtocol | None" = None,
189
+ prior_hypotheses: list[Any] | None = None,
190
+ hint_failure_modes: list[str] | None = None,
191
+ analyzers: list[str] | None = None,
192
+ ) -> dict[str, "Result"]:
193
+ """Select analyzers and run each one, returning a ``{name: Result}`` dict.
194
+
195
+ Analyzer selection strategy (in priority order):
196
+
197
+ 1. **LLM-guided** — when a *judge* model and a *protocol* are both
198
+ present, the judge reads the protocol description plus an
199
+ auto-generated catalog of available analyzers and picks the most
200
+ relevant ones. Any *prior_hypotheses* from M3 are included as
201
+ additional context for cycle 2+ focused probing.
202
+ 2. **Static fallback** — when no judge or no protocol, uses
203
+ :class:`~evalrx.eval_agent.probe.StrategyProbe` with the
204
+ optional *hint_failure_modes* list for priority-boosting (used by
205
+ :class:`~evalrx.eval_agent.legacy.AutoDiagnoseLoop`).
206
+
207
+ Analyzers are executed in parallel using a ``ThreadPoolExecutor`` so
208
+ that independent analyzers do not wait on each other.
209
+
210
+ Sets :attr:`last_schema` with the selection rationale so callers can
211
+ inspect which analyzers ran and why without changing the return type.
212
+
213
+ Args:
214
+ model: The model to probe.
215
+ data: Cases to run analyzers on.
216
+ protocol: Experiment protocol — enables LLM-guided
217
+ selection when a judge is also configured.
218
+ prior_hypotheses: Hypotheses from M3 in prior cycles. Passed to
219
+ the judge as context for focused follow-up.
220
+ hint_failure_modes: Failure-mode tags for the static fallback path
221
+ (e.g. from :class:`~evalrx.eval_agent.legacy.AutoDiagnoseLoop`).
222
+ analyzers: Exact analyzer names to run, bypassing selection
223
+ entirely (still filtered for applicability).
224
+ Used by the loop's held-out M4 confirmation to
225
+ re-run the same set on the confirm split.
226
+ """
227
+ rationale: str
228
+ self._last_need_custom = None
229
+ if analyzers is not None:
230
+ # Pinned re-run: the caller already knows exactly which analyzers to
231
+ # execute (M4's held-out confirmation re-runs the last explore
232
+ # cycle's set so the designated signals exist on the confirm split
233
+ # too — a fresh judge selection could pick a different set and turn
234
+ # a real signal into a spurious "designated evidence not measured").
235
+ names = list(dict.fromkeys(analyzers))
236
+ rationale = "Pinned by caller (held-out confirmation re-run)."
237
+ elif self.judge is not None and protocol is not None:
238
+ names, rationale = self._llm_select(protocol, model, prior_hypotheses, data)
239
+ else:
240
+ names = self.selector.select(
241
+ model,
242
+ max_analyzers=self.max_analyzers,
243
+ hint_failure_modes=hint_failure_modes or None,
244
+ data=data,
245
+ )
246
+ rationale = _static_rationale(names, hint_failure_modes)
247
+ names, filtered = self._filter_applicable_names(names, model, data)
248
+ if filtered:
249
+ rationale += f" Filtered inapplicable analyzer(s): {', '.join(filtered)}."
250
+
251
+ # Build (name, analyzer) pairs up front — _make_analyzer is cheap and
252
+ # not thread-safe to call concurrently.
253
+ tasks: list[tuple[str, "Analyzer"]] = []
254
+ for name in names:
255
+ analyzer = self._make_analyzer(name)
256
+ if analyzer is not None:
257
+ tasks.append((name, analyzer))
258
+
259
+ results: dict[str, "Result"] = {}
260
+
261
+ def _run_one(name: str, analyzer: "Analyzer") -> tuple[str, "Result | None"]:
262
+ cls = type(analyzer)
263
+ subset = self._cap_cases(name, analyzer, data)
264
+ if self.use_docker and _is_blackbox_compatible(cls):
265
+ return name, self._run_in_docker(name, analyzer, subset)
266
+ return name, self._run_direct(analyzer, model, subset)
267
+
268
+ # White-box analyzers do GPU forward passes on the SHARED local model;
269
+ # running them in threads races on the model (accelerate device_map
270
+ # hooks are not thread-safe → meta-tensor/dtype errors) and stacks
271
+ # transient activations until the GPU OOMs. Only black-box analyzers
272
+ # (GENERATE/LOGPROBS, possibly Dockerised) are safe to parallelise.
273
+ parallel = [(n, a) for n, a in tasks if _is_blackbox_compatible(type(a))]
274
+ serial = [(n, a) for n, a in tasks if not _is_blackbox_compatible(type(a))]
275
+
276
+ def _record(name: str, result, exc=None) -> None:
277
+ if exc is not None:
278
+ warnings.warn(f"Analyzer '{name}' raised in probe run: {exc}", stacklevel=2)
279
+ elif result is not None:
280
+ results[name] = result
281
+
282
+ if parallel:
283
+ max_workers = min(len(parallel), 8)
284
+ with ThreadPoolExecutor(max_workers=max_workers) as pool:
285
+ futures = {pool.submit(_run_one, n, a): n for n, a in parallel}
286
+ for future in as_completed(futures):
287
+ try:
288
+ name, result = future.result()
289
+ _record(name, result)
290
+ except Exception as exc:
291
+ _record(futures[future], None, exc)
292
+ for name, analyzer in serial: # white-box: one at a time on the GPU
293
+ try:
294
+ _, result = _run_one(name, analyzer)
295
+ _record(name, result)
296
+ except Exception as exc:
297
+ _record(name, None, exc)
298
+
299
+ selected = [t[0] for t in tasks]
300
+ failed_selected = [n for n in selected if n not in results]
301
+ for n in failed_selected:
302
+ self._failed_analyzers.setdefault(n, "produced no result")
303
+ if failed_selected:
304
+ rationale += (f" Failed at runtime: {', '.join(failed_selected)} — "
305
+ "their evidence is missing this cycle.")
306
+ # Tier (b)/(c): reuse + generate a bespoke probe when the catalog is
307
+ # thin OR the selected evidence failed to materialize.
308
+ if self.allow_codegen:
309
+ selected += self._maybe_generate(model, data, results,
310
+ failed=failed_selected, protocol=protocol)
311
+
312
+ self.last_schema = _build_schema(selected, rationale, protocol)
313
+ return results
314
+
315
+ def probe_with_schema(
316
+ self,
317
+ model: "Model",
318
+ data: "CaseBatch",
319
+ *,
320
+ protocol: "ExperimentProtocol | None" = None,
321
+ prior_hypotheses: list[Any] | None = None,
322
+ hint_failure_modes: list[str] | None = None,
323
+ ) -> "tuple[dict[str, Result], ProbingSchema]":
324
+ """Like :meth:`probe`, but also returns the :class:`~evalrx.eval_agent.protocol.ProbingSchema`.
325
+
326
+ The schema records which analyzers were selected and why — useful for
327
+ M2/M4 to understand the M1 reasoning without re-running selection.
328
+
329
+ Returns:
330
+ ``(results_dict, schema)`` — the same dict as :meth:`probe` plus a
331
+ :class:`~evalrx.eval_agent.protocol.ProbingSchema`.
332
+ """
333
+ results = self.probe(
334
+ model, data,
335
+ protocol=protocol,
336
+ prior_hypotheses=prior_hypotheses,
337
+ hint_failure_modes=hint_failure_modes,
338
+ )
339
+ schema = self.last_schema
340
+ if schema is None:
341
+ from evalrx.eval_agent.stages.protocol import ProbingSchema
342
+ schema = ProbingSchema(selected_analyzers=list(results), protocol=protocol)
343
+ return results, schema
344
+
345
+ # ------------------------------------------------------------------
346
+ # LLM-guided selection
347
+ # ------------------------------------------------------------------
348
+
349
+ def _llm_select(
350
+ self,
351
+ protocol: "ExperimentProtocol",
352
+ model: "Model",
353
+ prior_hypotheses: list[Any] | None,
354
+ data: "CaseBatch | None" = None,
355
+ ) -> tuple[list[str], str]:
356
+ """Ask the judge LLM to choose analyzers from the available catalog.
357
+
358
+ The selection prompt is grounded in the *observed* cases (a digest of
359
+ real PASS/FAIL prompts and the model's actual answers) so the judge
360
+ picks analyzers for what actually failed, not just the abstract protocol.
361
+
362
+ Returns ``(selected_names, rationale_string)``. Falls back to static
363
+ selection on any error.
364
+ """
365
+ assert self.judge is not None # caller guarantees this
366
+
367
+ kind = self.selector.detect_kind(model, data)
368
+ catalog = self._offerable(get_analyzer_catalog(model), data, model)
369
+ if not catalog:
370
+ return self._static_fallback(model, data), "no analyzers available"
371
+
372
+ max_n = self.max_analyzers if self.max_analyzers is not None else len(catalog)
373
+
374
+ analyzer_lines = "\n".join(
375
+ f" - {name}: {desc}" for name, desc in catalog.items()
376
+ )
377
+
378
+ task_domain_section = (
379
+ f"\nTASK DOMAIN: {protocol.task_domain}\n" if protocol.task_domain else ""
380
+ )
381
+ success_criteria_section = (
382
+ f"\nSUCCESS CRITERIA: {protocol.success_criteria}\n"
383
+ if protocol.success_criteria
384
+ else ""
385
+ )
386
+ failure_patterns_section = (
387
+ f"\nADDITIONAL OBSERVATIONS: {protocol.failure_patterns}\n"
388
+ if protocol.failure_patterns
389
+ else ""
390
+ )
391
+
392
+ prior_section = ""
393
+ if prior_hypotheses:
394
+ lines = []
395
+ for h in prior_hypotheses:
396
+ stmt = getattr(h, "statement", str(h))
397
+ status = ""
398
+ s = getattr(h, "status", None)
399
+ if s is not None:
400
+ status = f" [{getattr(s, 'value', str(s))}]"
401
+ lines.append(f" - {stmt}{status}")
402
+ design = getattr(h, "test_design", "")
403
+ if design:
404
+ lines.append(f" → proposed test: {design}")
405
+ prior_section = (
406
+ "\nPRIOR HYPOTHESES FROM THIS INVESTIGATION "
407
+ "(select analyzers that collect the evidence their proposed "
408
+ "tests call for):\n"
409
+ + "\n".join(lines)
410
+ + "\n"
411
+ )
412
+
413
+ failed_section = ""
414
+ if self._failed_analyzers:
415
+ failed_lines = "\n".join(
416
+ f" - {n}: {err}" for n, err in self._failed_analyzers.items())
417
+ failed_section = (
418
+ "\nANALYZERS THAT FAILED AT RUNTIME IN THIS INVESTIGATION "
419
+ "(selecting them again will NOT produce evidence — pick "
420
+ "alternatives or request a custom probe):\n" + failed_lines + "\n")
421
+
422
+ n_fail, n_pass = self._case_examples
423
+ cases_section = _summarize_cases(data, max_fail=n_fail, max_pass=n_pass)
424
+
425
+ prompt = _SELECTION_PROMPT_TMPL.format(
426
+ description=protocol.description,
427
+ task_domain_section=task_domain_section,
428
+ success_criteria_section=success_criteria_section,
429
+ failure_patterns_section=failure_patterns_section,
430
+ cases_section=cases_section,
431
+ model_kind=kind.value,
432
+ n_available=len(catalog),
433
+ analyzer_list=analyzer_lines,
434
+ failed_section=failed_section,
435
+ prior_hypotheses_section=prior_section,
436
+ max_n=max_n,
437
+ )
438
+
439
+ self.last_selection_prompt = prompt
440
+ self.last_selection_raw = ""
441
+ try:
442
+ sig = inspect.signature(self.judge.generate)
443
+ if "temperature" in sig.parameters:
444
+ raw = self.judge.generate(prompt, temperature=0)
445
+ else:
446
+ raw = self.judge.generate(prompt)
447
+ except Exception as exc:
448
+ logger.warning("ProbeAgent LLM selection failed (%s) — using static fallback", exc)
449
+ return self._static_fallback(model, data), "static fallback (LLM call failed)"
450
+
451
+ self.last_selection_raw = str(raw)
452
+ return self._parse_llm_response(str(raw), set(catalog), max_n, model, data)
453
+
454
+ def _parse_llm_response(
455
+ self,
456
+ raw: str,
457
+ valid_names: set[str],
458
+ max_n: int,
459
+ model: "Model",
460
+ data: "CaseBatch | None" = None,
461
+ ) -> tuple[list[str], str]:
462
+ """Extract analyzer names and rationale from the LLM JSON response.
463
+
464
+ Strips ``<think>…</think>`` blocks emitted by reasoning models (e.g.
465
+ Qwen3) before searching for the JSON payload so that greedy matching
466
+ does not swallow reasoning text as part of the JSON.
467
+ """
468
+ # Remove <think>…</think> reasoning blocks before JSON search
469
+ cleaned = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip()
470
+
471
+ # An empty response (e.g. agy rate-limited/quota-exhausted) is distinct
472
+ # from an unparseable one — surface that so the fallback is diagnosable.
473
+ if not cleaned:
474
+ logger.warning("ProbeAgent: LLM returned an empty response — using static fallback")
475
+ return self._static_fallback(model, data), "static fallback (LLM returned empty response)"
476
+
477
+ # Use a non-greedy search that stops at the first closing brace so we
478
+ # don't accidentally span multiple JSON objects.
479
+ match = re.search(r"\{[^{}]*\}", cleaned)
480
+ if match:
481
+ try:
482
+ payload = json.loads(match.group())
483
+ # Normalise to lowercase so the LLM can return "POPE" or "Pope"
484
+ names = [
485
+ n.lower() for n in payload.get("analyzers", [])
486
+ if n.lower() in valid_names
487
+ ]
488
+ rationale = str(payload.get("rationale", "LLM-selected"))
489
+ need = payload.get("need_custom")
490
+ if isinstance(need, str) and need.strip():
491
+ self._last_need_custom = need.strip()
492
+ if names:
493
+ return names[:max_n], rationale
494
+ except json.JSONDecodeError:
495
+ pass
496
+
497
+ # Soft parse: case-insensitive scan for any known analyzer name
498
+ cleaned_lower = cleaned.lower()
499
+ found = [n for n in sorted(valid_names) if n in cleaned_lower]
500
+ if found:
501
+ logger.warning("ProbeAgent: LLM response not valid JSON — extracted names by text scan")
502
+ return found[:max_n], "LLM-selected (text-extracted)"
503
+
504
+ logger.warning("ProbeAgent: could not parse LLM response — using static fallback")
505
+ return self._static_fallback(model, data), "static fallback (LLM parse failed)"
506
+
507
+ def _static_fallback(self, model: "Model", data: "CaseBatch | None" = None) -> list[str]:
508
+ return self.selector.select(model, max_analyzers=self.max_analyzers, data=data)
509
+
510
+ def _filter_applicable_names(
511
+ self,
512
+ names: list[str],
513
+ model: "Model",
514
+ data: "CaseBatch",
515
+ ) -> tuple[list[str], list[str]]:
516
+ """Drop analyzers whose data preconditions are not met.
517
+
518
+ LLM selection can choose semantically plausible tools that require a
519
+ specific case shape. Filtering here prevents invalid runs such as POPE
520
+ on open-ended VQA prompts without yes/no gold labels.
521
+ """
522
+ max_n = self.max_analyzers if self.max_analyzers is not None else len(names)
523
+ selected: list[str] = []
524
+ filtered: list[str] = []
525
+ filtered_seen: set[str] = set()
526
+
527
+ def _consider(candidate: str) -> None:
528
+ if candidate in selected:
529
+ return
530
+ # Both selection paths (judge and static) converge here, so this is
531
+ # where "the data is the wrong shape" and "the collaborator was never
532
+ # injected" have to be caught. Previously neither was: an analyzer
533
+ # that could not be built was selected anyway and then dropped at
534
+ # instantiation with a warning, having already cost a slot.
535
+ if not _analyzer_data_preconditions_met(candidate, data, model) \
536
+ or self._needs_injection(candidate):
537
+ if candidate not in filtered_seen:
538
+ filtered.append(candidate)
539
+ filtered_seen.add(candidate)
540
+ return
541
+ selected.append(candidate)
542
+
543
+ if max_n <= 0:
544
+ return selected, filtered
545
+
546
+ for name in names:
547
+ _consider(name)
548
+ if len(selected) >= max_n:
549
+ return selected, filtered
550
+
551
+ # Backfill from the static selector so one bad LLM choice does not leave
552
+ # M1 empty when other compatible analyzers can run.
553
+ for name in self.selector.select(model, max_analyzers=None, data=data):
554
+ _consider(name)
555
+ if len(selected) >= max_n:
556
+ break
557
+ return selected, filtered
558
+
559
+ # ------------------------------------------------------------------
560
+ # Tier (b)/(c): probe generation
561
+ # ------------------------------------------------------------------
562
+
563
+ def _maybe_generate(
564
+ self,
565
+ model: "Model",
566
+ data: "CaseBatch",
567
+ results: "dict[str, Result]",
568
+ failed: "list[str] | None" = None,
569
+ protocol: "ExperimentProtocol | None" = None,
570
+ ) -> list[str]:
571
+ """Reuse cached generated probes, then generate one if the catalog is thin.
572
+
573
+ Generator dispatch: when the requested probe targets the model's
574
+ *internals* (attention/layers/hidden states/…) and the model is
575
+ white-box, the capture-then-compute
576
+ :class:`~evalrx.eval_agent.stages.whitebox_probe_generator.WhiteboxProbeGenerator`
577
+ is used; otherwise the black-box output-probe generator.
578
+
579
+ Mutates *results* in place (adds ``generated:<name>`` entries) and returns
580
+ the list of probe names added, for inclusion in the schema.
581
+ """
582
+ added: list[str] = []
583
+
584
+ # Tier (c): re-run cached generated probes on the generator that made them.
585
+ for gen, probe in self._generated_probes:
586
+ r = gen.run_cached(probe, model, data)
587
+ if r is not None:
588
+ results[r.analyzer] = r
589
+ added.append(r.analyzer)
590
+
591
+ # Tier (b): generate a new probe when the judge asked for one, when the
592
+ # judge's SELECTED analyzers failed at runtime (their evidence is
593
+ # missing either way), or when nothing produced any output.
594
+ need = self._last_need_custom
595
+ should_generate = (
596
+ (bool(need) or bool(failed)) and not self._generated_probes
597
+ ) or not results
598
+ if not should_generate:
599
+ return added
600
+
601
+ if need:
602
+ goal = need
603
+ elif failed:
604
+ goal = (
605
+ f"Selected analyzers ({', '.join(failed)}) failed to run; collect "
606
+ "equivalent evidence for the failure mechanism: "
607
+ + (protocol.description if protocol is not None
608
+ else "see the experiment protocol.")
609
+ )
610
+ else:
611
+ goal = "Probe the model outputs for the failure described in the protocol."
612
+ generator = self._dispatch_generator(goal, model)
613
+ if generator is None:
614
+ return added
615
+ name = f"probe{len(self._generated_probes) + 1}"
616
+ result, probe = generator.generate(goal, model, data, name=name)
617
+ if result is not None:
618
+ results[result.analyzer] = result
619
+ added.append(result.analyzer)
620
+ if probe is not None:
621
+ self._generated_probes.append((generator, probe))
622
+ return added
623
+
624
+ # Mechanism wording that calls for internal (white-box) evidence.
625
+ _WHITEBOX_NEED_KEYWORDS = (
626
+ "attention", "layer", "hidden", "logit", "internal", "sink",
627
+ "head", "patch", "token mass", "image token",
628
+ )
629
+
630
+ def _dispatch_generator(self, need: str, model: "Model") -> "Any | None":
631
+ """Choose the white-box or black-box generator for *need*."""
632
+ lowered = need.lower()
633
+ wants_internals = any(k in lowered for k in self._WHITEBOX_NEED_KEYWORDS)
634
+ is_whitebox = Capability.ATTENTION in getattr(model, "capabilities", frozenset())
635
+ if wants_internals and is_whitebox:
636
+ gen = self._get_whitebox_generator()
637
+ if gen is not None:
638
+ return gen
639
+ return self._get_generator()
640
+
641
+ def _get_generator(self) -> "Any | None":
642
+ """Lazily build the black-box probe generator from the judge / config."""
643
+ if self._probe_generator is not None:
644
+ return self._attach_logger(self._probe_generator)
645
+ if self.judge is None and self._codegen_config is None:
646
+ return None
647
+ from evalrx.eval_agent.stages.probe_generator import ProbeGenerator
648
+ gen = ProbeGenerator(
649
+ judge=self.judge, cli_config=self._codegen_config, run_logger=self.run_logger
650
+ )
651
+ if not gen.available:
652
+ return None
653
+ self._probe_generator = gen
654
+ return gen
655
+
656
+ def _get_whitebox_generator(self) -> "Any | None":
657
+ """Lazily build the white-box (capture-then-compute) probe generator."""
658
+ if self._whitebox_generator is not None:
659
+ return self._attach_logger(self._whitebox_generator)
660
+ if self.judge is None and self._codegen_config is None:
661
+ return None
662
+ from evalrx.eval_agent.stages.whitebox_probe_generator import (
663
+ WhiteboxProbeGenerator,
664
+ )
665
+ gen = WhiteboxProbeGenerator(
666
+ judge=self.judge, cli_config=self._codegen_config, run_logger=self.run_logger
667
+ )
668
+ if not gen.available:
669
+ return None
670
+ self._whitebox_generator = gen
671
+ return gen
672
+
673
+ def _attach_logger(self, gen: "Any") -> "Any":
674
+ """Ensure a pre-built / cached generator points at the current RunLogger."""
675
+ if self.run_logger is not None and getattr(gen, "run_logger", None) is None:
676
+ try:
677
+ gen.run_logger = self.run_logger
678
+ except Exception: # noqa: BLE001
679
+ pass
680
+ return gen
681
+
682
+ # ------------------------------------------------------------------
683
+ # Execution strategies
684
+ # ------------------------------------------------------------------
685
+
686
+ def _cap_cases(self, name: str, analyzer: "Analyzer", data: "CaseBatch"):
687
+ """Bound how many cases *analyzer* receives, keeping the labels balanced.
688
+
689
+ Three things this must not do:
690
+
691
+ * **Fight the analyzer's own knob.** If it already caps itself at or
692
+ below the ceiling, it is left alone — otherwise a 12-case analyzer
693
+ configured deliberately would silently be handed 32.
694
+ * **Destroy the contrast.** M1 exists to compare PASS against FAIL, so a
695
+ head-truncation is not acceptable: on a batch that happens to open with
696
+ a run of one label it would hand the analyzer a single class and the
697
+ result would read as a null rather than as a missing comparison. Each
698
+ label group is subsampled proportionally.
699
+ * **Vary between runs.** Even striding, no RNG: two runs on the same
700
+ batch must select the same cases or their numbers are not comparable.
701
+ """
702
+ cap = self.max_cases_per_analyzer
703
+ if cap <= 0 or data is None or len(data) <= cap:
704
+ return data
705
+ own = getattr(analyzer, "max_cases", None)
706
+ if isinstance(own, int) and 0 < own <= cap:
707
+ return data
708
+
709
+ from evalrx.core.case import CaseBatch
710
+
711
+ groups: dict[Any, list] = {}
712
+ for case in data:
713
+ groups.setdefault(getattr(case, "label", None), []).append(case)
714
+
715
+ picked: list = []
716
+ # Largest group first, so integer truncation costs the class that can
717
+ # best afford it rather than wiping out a small one.
718
+ order = sorted(groups.items(), key=lambda kv: -len(kv[1]))
719
+ remaining = cap
720
+ for index, (_, members) in enumerate(order):
721
+ share = remaining if index == len(order) - 1 else round(
722
+ cap * len(members) / len(data))
723
+ share = max(1, min(share, len(members), remaining))
724
+ if len(members) <= share:
725
+ picked.extend(members)
726
+ else: # even stride across the group, not its head
727
+ step = len(members) / share
728
+ picked.extend(members[int(i * step)] for i in range(share))
729
+ remaining -= share
730
+ if remaining <= 0:
731
+ break
732
+
733
+ self.capped_analyzers[name] = (len(data), len(picked))
734
+ return CaseBatch(picked)
735
+
736
+ def _run_direct(
737
+ self,
738
+ analyzer: "Analyzer",
739
+ model: "Model",
740
+ data: "CaseBatch",
741
+ ) -> "Result | None":
742
+ # Wrap the model so every generate/forward/logprobs/chat call THIS
743
+ # analyzer makes is durably recorded (see model_instrumentation.py) —
744
+ # including calls an analyzer makes several times per case and then
745
+ # reduces to one derived score, which otherwise leave no trace. A
746
+ # fresh proxy per call keeps call_index scoped to (cycle, analyzer);
747
+ # __repr__ forwards to the wrapped model so Experiment.fingerprint()
748
+ # and Result.model stay unaffected by the wrapping. Only reaches
749
+ # in-process (non-Docker) analyzers — Docker-mode calls happen inside
750
+ # a subprocess and are out of reach of this wrapper.
751
+ if self.run_logger is not None:
752
+ from evalrx.eval_agent.model_instrumentation import InstrumentedModel
753
+
754
+ # Best-effort (prompt -> case_id) for THIS analyzer's batch, so a
755
+ # call that reuses the case's own prompt unmodified can be tied
756
+ # back to it; batch_case_ids is the fallback for calls that
757
+ # rewrite the prompt (format_sensitivity, cot_faithfulness, ...).
758
+ batch_case_ids = [str(getattr(c, "id", "")) for c in data]
759
+ case_prompts = {
760
+ str(prompt): case_id
761
+ for c, case_id in zip(data, batch_case_ids)
762
+ if (prompt := getattr(getattr(c, "inputs", None), "prompt", None)) is not None
763
+ }
764
+ model = InstrumentedModel(
765
+ model, self.run_logger,
766
+ cycle=getattr(self.run_logger, "current_cycle", -1),
767
+ analyzer=analyzer.name,
768
+ case_prompts=case_prompts,
769
+ batch_case_ids=batch_case_ids,
770
+ )
771
+ exp = Experiment(model=model, analyzer=analyzer, data=data)
772
+ try:
773
+ return self.runner.run(exp)
774
+ except Exception as exc:
775
+ self._failed_analyzers[analyzer.name] = f"{type(exc).__name__}: {str(exc)[:200]}"
776
+ warnings.warn(
777
+ f"Analyzer '{analyzer.name}' raised during direct run: {exc}",
778
+ stacklevel=3,
779
+ )
780
+ return None
781
+
782
+ def _run_in_docker(
783
+ self,
784
+ name: str,
785
+ analyzer: "Analyzer",
786
+ data: "CaseBatch",
787
+ ) -> "Result | None":
788
+ """Run *analyzer* inside a Docker container, returning the Result or None."""
789
+ from evalrx.core.result import Result
790
+
791
+ payload = json.dumps({
792
+ "analyzer": name,
793
+ "params": analyzer.get_params(),
794
+ "cases": _serialize_cases(data),
795
+ "model_env": self.model_env_var,
796
+ })
797
+ cmd = [
798
+ "docker", "run", "--rm", "-i",
799
+ "--env", self.model_env_var,
800
+ self.docker_image,
801
+ "python", "-m", "evalrx.agent_runtime._docker_runner",
802
+ ]
803
+ try:
804
+ proc = subprocess.run(
805
+ cmd,
806
+ input=payload.encode(),
807
+ capture_output=True,
808
+ timeout=self.docker_timeout,
809
+ )
810
+ except FileNotFoundError:
811
+ warnings.warn(
812
+ "Docker is not available; falling back to direct execution "
813
+ f"for analyzer '{name}'.",
814
+ stacklevel=3,
815
+ )
816
+ return None
817
+ except subprocess.TimeoutExpired:
818
+ warnings.warn(f"Docker run for '{name}' timed out.", stacklevel=3)
819
+ return None
820
+
821
+ if proc.returncode != 0:
822
+ warnings.warn(
823
+ f"Docker run for '{name}' failed (exit {proc.returncode}): "
824
+ f"{proc.stderr.decode()[:300]}",
825
+ stacklevel=3,
826
+ )
827
+ return None
828
+
829
+ try:
830
+ raw = json.loads(proc.stdout)
831
+ return Result(
832
+ analyzer=name,
833
+ model=f"docker:{self.docker_image}",
834
+ findings=raw.get("findings", {}),
835
+ metadata={"docker": True, "image": self.docker_image},
836
+ cases=data,
837
+ )
838
+ except Exception as exc:
839
+ warnings.warn(f"Could not parse Docker output for '{name}': {exc}", stacklevel=3)
840
+ return None
841
+
842
+ # ------------------------------------------------------------------
843
+ # Helpers
844
+ # ------------------------------------------------------------------
845
+
846
+ def _offerable(self, catalog: "dict[str, str]", data: "Any" = None,
847
+ model: "Model | None" = None) -> "dict[str, str]":
848
+ """Drop analyzers the judge could pick but this run could never use.
849
+
850
+ Capability + modality matching answers "does the MODEL provide what this
851
+ needs". Two things it cannot answer, both of which produced real damage
852
+ on a single-turn text run:
853
+
854
+ 1. **Is the data the right shape?** Every analyzer in ``analyzers/agent/``
855
+ declares no capability (correctly — they read trajectories, sometimes
856
+ with no model at all), so all six matched a plain QA batch.
857
+ ``first_error_judge`` was duly selected, ran, and reported
858
+ ``n_trajectories: 0``, which M2 then listed as a healthy metric.
859
+ The same hole exists one axis over, for modality: the registry match
860
+ is an intersection against what the MODEL declares, so an omni model
861
+ evaluated on an audio benchmark still declares image and ``pope`` /
862
+ ``chair`` were duly offered a batch with no images in it.
863
+ ``requires_modalities`` is that gate.
864
+ 2. **Did the caller inject the collaborator?** ``reliability_probe``
865
+ wants ``runs_fn``, ``tool_shap`` a tool runner, ``trajectory_rubric``
866
+ a judge. These were offered every run and skipped at instantiation
867
+ with a warning, spending a selection slot a runnable analyzer could
868
+ have had.
869
+
870
+ An override supplies exactly that missing collaborator, so anything in
871
+ ``analyzer_overrides`` survives the second check.
872
+ """
873
+ from evalrx.eval_agent.stages.probe import StrategyProbe, _carries_trajectories
874
+
875
+ has_traj = data is not None and _carries_trajectories(data)
876
+ starved = StrategyProbe.slot_starved(set(catalog), data, model)
877
+ keep: dict[str, str] = {}
878
+ for name, desc in catalog.items():
879
+ cls = registry.analyzers.get(name)
880
+ if getattr(cls, "requires_trajectories", False) and not has_traj:
881
+ logger.debug(
882
+ "not offering analyzer '%s': it reads agent trajectories and "
883
+ "this batch carries none", name,
884
+ )
885
+ continue
886
+ if name in starved:
887
+ logger.debug(
888
+ "not offering analyzer '%s': it needs one of %s filled and this "
889
+ "batch fills none of them", name,
890
+ sorted(getattr(cls, "requires_modalities", frozenset())),
891
+ )
892
+ continue
893
+ needs = self._needs_injection(name)
894
+ if needs:
895
+ logger.debug(
896
+ "not offering analyzer '%s': needs %s, which must be injected "
897
+ "via analyzer_overrides", name, needs,
898
+ )
899
+ continue
900
+ keep[name] = desc
901
+ return keep
902
+
903
+ def _needs_injection(self, name: str) -> "list[str]":
904
+ """Constructor arguments the caller must supply; empty when buildable.
905
+
906
+ An entry in ``analyzer_overrides`` IS that supply, so an overridden
907
+ analyzer always reads as buildable.
908
+ """
909
+ import inspect
910
+
911
+ if name in self._overrides:
912
+ return []
913
+ cls = registry.analyzers.get(name) if registry.analyzers.has(name) else None
914
+ if cls is None:
915
+ return []
916
+ try:
917
+ params = inspect.signature(cls.__init__).parameters
918
+ except (TypeError, ValueError):
919
+ return []
920
+ return [
921
+ n for n, p in params.items()
922
+ if n != "self"
923
+ and p.default is inspect.Parameter.empty
924
+ and p.kind not in (inspect.Parameter.VAR_POSITIONAL,
925
+ inspect.Parameter.VAR_KEYWORD)
926
+ ]
927
+
928
+ def _make_analyzer(self, name: str) -> "Analyzer | None":
929
+ if name in self._overrides:
930
+ return self._overrides[name]
931
+ cls = registry.analyzers.get(name)
932
+ try:
933
+ return cls()
934
+ except TypeError as exc:
935
+ warnings.warn(
936
+ f"Skipping analyzer '{name}': cannot instantiate with default args "
937
+ f"({exc}). Pass an instance via analyzer_overrides.",
938
+ stacklevel=3,
939
+ )
940
+ return None
941
+
942
+ def detect_kind(self, model: "Model", data: "CaseBatch | None" = None) -> ModelKind:
943
+ """Delegate to the inner :class:`~evalrx.eval_agent.probe.StrategyProbe`."""
944
+ return self.selector.detect_kind(model, data)
945
+
946
+
947
+ def _clip(text: str, max_chars: int) -> str:
948
+ """Collapse whitespace and truncate *text* for compact prompt inclusion."""
949
+ flat = re.sub(r"\s+", " ", str(text)).strip()
950
+ return flat if len(flat) <= max_chars else flat[:max_chars] + "…"
951
+
952
+
953
+ def _summarize_cases(
954
+ data: "CaseBatch | None",
955
+ *,
956
+ max_fail: int = 4,
957
+ max_pass: int = 2,
958
+ max_chars: int = 160,
959
+ ) -> str:
960
+ """Digest observed PASS/FAIL cases (prompt + expected + model answer).
961
+
962
+ Folded into the M1 selection prompt so the judge selects analyzers grounded
963
+ in concrete failures. Returns ``""`` when there are no labeled cases or the
964
+ feature is disabled (``max_fail == max_pass == 0``).
965
+ """
966
+ from evalrx.core.case import Label
967
+
968
+ if data is None or (max_fail <= 0 and max_pass <= 0):
969
+ return ""
970
+
971
+ counts = {"pass": 0, "fail": 0, "unknown": 0}
972
+ fails: list[Any] = []
973
+ passes: list[Any] = []
974
+ for c in data:
975
+ label = getattr(c, "label", None)
976
+ key = getattr(label, "value", None)
977
+ if key in counts:
978
+ counts[key] += 1
979
+ if label == Label.FAIL and len(fails) < max_fail:
980
+ fails.append(c)
981
+ elif label == Label.PASS and len(passes) < max_pass:
982
+ passes.append(c)
983
+
984
+ # No concrete PASS/FAIL examples to show (e.g. all UNKNOWN) → no useful digest.
985
+ if not fails and not passes:
986
+ return ""
987
+
988
+ lines = [
989
+ f"\nOBSERVED CASES (PASS={counts['pass']} FAIL={counts['fail']} "
990
+ f"UNKNOWN={counts['unknown']}) — select analyzers for what actually failed:"
991
+ ]
992
+
993
+ def _emit(case: Any, tag: str) -> None:
994
+ inp = getattr(case, "inputs", None)
995
+ prompt = getattr(inp, "prompt", "") if inp is not None else ""
996
+ observed = (
997
+ getattr(case, "observed", None)
998
+ or getattr(case, "metadata", {}).get("discovery_observed", "")
999
+ )
1000
+ lines.append(f" [{tag}] prompt: {_clip(prompt, max_chars)}")
1001
+ if getattr(case, "expected", None) is not None:
1002
+ lines.append(f" expected: {_clip(case.expected, max_chars)}")
1003
+ lines.append(f" model answered: {_clip(observed, max_chars)}")
1004
+
1005
+ for c in fails:
1006
+ _emit(c, "FAIL")
1007
+ for c in passes:
1008
+ _emit(c, "PASS")
1009
+ return "\n".join(lines) + "\n"
1010
+
1011
+
1012
+ def _static_rationale(names: list[str], hints: list[str] | None) -> str:
1013
+ parts = []
1014
+ if hints:
1015
+ parts.append(f"failure-mode hints: {', '.join(hints)}")
1016
+ return (
1017
+ f"Selected {len(names)} analyzer(s) ({', '.join(names)}) "
1018
+ + ("guided by " + " + ".join(parts) if parts else "by capability matching")
1019
+ + "."
1020
+ )
1021
+
1022
+
1023
+ def _build_schema(
1024
+ selected: list[str],
1025
+ rationale: str,
1026
+ protocol: "ExperimentProtocol | None",
1027
+ ) -> "ProbingSchema":
1028
+ from evalrx.eval_agent.stages.protocol import ProbingSchema
1029
+ return ProbingSchema(
1030
+ selected_analyzers=selected,
1031
+ rationale=rationale,
1032
+ protocol=protocol,
1033
+ )
1034
+
1035
+
1036
+ def _serialize_cases(data: "CaseBatch") -> list[dict[str, Any]]:
1037
+ """Minimal JSON-serialisable representation of a CaseBatch for Docker."""
1038
+ out = []
1039
+ for case in data:
1040
+ inp = getattr(case, "inputs", None)
1041
+ out.append({
1042
+ "id": case.id,
1043
+ "prompt": str(inp.prompt) if inp else "",
1044
+ "label": getattr(case.label, "value", None) if hasattr(case, "label") else None,
1045
+ "metadata": getattr(case, "metadata", {}),
1046
+ })
1047
+ return out
1048
+
1049
+
1050
+ def _analyzer_data_preconditions_met(name: str, data: "CaseBatch",
1051
+ model: "Model | None" = None) -> bool:
1052
+ cls = registry.analyzers.get(name) if registry.analyzers.has(name) else None
1053
+ if cls is not None and getattr(cls, "requires_modalities", None):
1054
+ # All three selection paths (static, judge, pinned) converge here, so
1055
+ # this is where the modality gate has to hold for the pinned one too.
1056
+ from evalrx.eval_agent.stages.probe import StrategyProbe
1057
+
1058
+ if name in StrategyProbe.slot_starved({name}, data, model):
1059
+ return False
1060
+ if cls is not None and getattr(cls, "requires_trajectories", False):
1061
+ # The agent lane declares no capability — correctly, since these read
1062
+ # trajectories and often take model=None — so capability matching let all
1063
+ # of them through to single-turn batches. This is the precondition that
1064
+ # actually applies: are there trajectories to read?
1065
+ from evalrx.eval_agent.stages.probe import _carries_trajectories
1066
+
1067
+ return _carries_trajectories(data)
1068
+ if name == "pope":
1069
+ return any(
1070
+ str((getattr(case, "metadata", {}) or {}).get("pope_label", "")).lower()
1071
+ in {"yes", "no"}
1072
+ for case in data
1073
+ )
1074
+ if name == "chair":
1075
+ return any(
1076
+ bool((getattr(case, "metadata", {}) or {}).get("gt_objects"))
1077
+ for case in data
1078
+ )
1079
+ return True