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,2159 @@
1
+ """VLDiagnoseLoop — the current M1→M2→M3→M4 diagnosis loop.
2
+
3
+ ┌──────────────────────────────────────────────────────────────────────┐
4
+ │ M1 · ProbeAgent protocol-guided analyzer selection + execute │
5
+ │ M2 · StatsAnalysisAgent protocol-aware stats analysis │
6
+ │ M3 · DiagnosisAgent "AI scientist" hypothesis generation │
7
+ │ M4 · HypothesisTester stats test + protocol consistency check │
8
+ └──────────────────────────────────────────────────────────────────────┘
9
+ ↑_________________________________│
10
+ stop when M4 finds a verified, protocol-consistent hypothesis
11
+
12
+ M5 (SurgeryAgent) runs separately via ``VLDiagnoseLoop.run_m5()`` once the
13
+ loop stops — propose a fix for the best verified hypothesis (Plan A), or
14
+ propose + execute a fix (Plan B).
15
+
16
+ See also:
17
+ - :class:`~evalrx.eval_agent.agentic.AgenticDiagnoseLoop` — the same
18
+ M1-M4 stages driven by a judge-decided action loop instead of a fixed
19
+ cycle.
20
+ - :mod:`~evalrx.eval_agent.legacy` — ``SelfEvolveLoop`` and
21
+ ``AutoDiagnoseLoop`` (the pre-2026-06-05 M1→M2→M3→M5 architecture), kept
22
+ for existing callers.
23
+
24
+ Usage::
25
+
26
+ from evalrx.eval_agent import VLDiagnoseLoop
27
+ from evalrx.eval_agent.stages.protocol import ExperimentProtocol
28
+
29
+ protocol = ExperimentProtocol(
30
+ description="QwenVL often confuses left/right positions in spatial tasks.",
31
+ task_domain="spatial reasoning",
32
+ target_modalities=frozenset({"text", "image"}),
33
+ )
34
+ loop = VLDiagnoseLoop(model=vlm, protocol=protocol)
35
+ report = loop.run(failure_cases)
36
+ fix = loop.run_m5(report, failure_cases) # separate fix-proposal step
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import logging
42
+ import random
43
+ import time
44
+ from pathlib import Path
45
+ from typing import TYPE_CHECKING, Any
46
+
47
+ from evalrx.eval_agent.loop_reports import VLDiagnoseReport
48
+ from evalrx.eval_agent.run_metadata import (
49
+ _attach_run_logger,
50
+ _coerce_explore_context,
51
+ _log_generated_tools,
52
+ _make_intervention_result_from_test,
53
+ _run_config,
54
+ )
55
+ from evalrx.eval_agent.store import InMemoryStore, Store
56
+
57
+ if TYPE_CHECKING:
58
+ from evalrx.analysis.stats_agent import StatsAnalysisAgent
59
+ from evalrx.core.case import CaseBatch
60
+ from evalrx.core.model import Model
61
+ from evalrx.eval_agent.stages.hypothesis_tester import HypothesisTester
62
+ from evalrx.eval_agent.stages.protocol import ExperimentProtocol
63
+
64
+ logger = logging.getLogger(__name__)
65
+
66
+ _STOPPED_BY_CRITERIA = "criteria_met"
67
+ _STOPPED_BY_MAX = "max_cycles"
68
+ _STOPPED_BY_BUDGET = "budget"
69
+ _STOPPED_BY_NO_HYPS = "no_hypotheses"
70
+ _STOPPED_BY_NO_PROBE = "no_probe_results"
71
+ # run_analysis() ran M1->M2->M3 and proposed hypotheses without confirming them
72
+ # (M4 deferred to run_confirm()). Not a failure — the analysis dashboard is ready.
73
+ _STOPPED_BY_ANALYSIS = "analysis_complete"
74
+
75
+
76
+ def _exact_size(picked: "list", pool: "list", n: int, seed: int) -> "list":
77
+ """Top a stratified draw up to exactly *n* items, deterministically.
78
+
79
+ :func:`~evalrx.stats.subset_sampling.stratified_subset` returns at most
80
+ *n* items but per-stratum rounding can under-allocate by one or two; the
81
+ train/val/test split promises exact 1:1:1 counts, so the shortfall is
82
+ filled from a seeded shuffle of the untaken remainder of *pool*.
83
+ """
84
+ if len(picked) >= n:
85
+ return picked[:n]
86
+ picked_ids = {id(c) for c in picked}
87
+ leftover = [c for c in pool if id(c) not in picked_ids]
88
+ random.Random(seed + 7919).shuffle(leftover)
89
+ return picked + leftover[: n - len(picked)]
90
+
91
+
92
+ def _diagnose_with_optional_context(
93
+ diag_agent: "Any", stats_report: "Any", prior_cycles: "Any", explore_context: "Any | None",
94
+ failure_modes: "Any | None" = None, cases: "Any | None" = None,
95
+ ) -> "Any":
96
+ """Call ``diag_agent.diagnose`` passing ``explore_context``/``failure_modes``/
97
+ ``cases`` only when the agent accepts them, so custom/legacy diagnosis
98
+ agents keep working unchanged. ``cases`` (the explore-split batch M1/M2
99
+ ran on) only feeds the critic's label summary -- never the proposer."""
100
+ import inspect as _inspect
101
+
102
+ kwargs: dict[str, Any] = {"prior_cycles": prior_cycles or None}
103
+ optional = {"explore_context": explore_context, "failure_modes": failure_modes,
104
+ "cases": cases}
105
+ if any(v is not None for v in optional.values()):
106
+ try:
107
+ params = _inspect.signature(diag_agent.diagnose).parameters
108
+ except (TypeError, ValueError):
109
+ params = {}
110
+ for name, value in optional.items():
111
+ if value is not None and name in params:
112
+ kwargs[name] = value
113
+ return diag_agent.diagnose(stats_report, **kwargs)
114
+
115
+
116
+ def _propose_and_validate(agent: "Any", model: "Any", data: "Any", hypotheses: "Any",
117
+ **kwargs: Any) -> "Any":
118
+ """Call ``agent.propose_and_validate`` passing ``prior_attempts`` /
119
+ ``context`` only when the agent accepts them (custom/stub fix agents keep
120
+ working unchanged — same pattern as ``_diagnose_with_optional_context``)."""
121
+ import inspect as _inspect
122
+
123
+ try:
124
+ params = _inspect.signature(agent.propose_and_validate).parameters
125
+ except (TypeError, ValueError):
126
+ params = {}
127
+ accepts_any = any(p.kind == p.VAR_KEYWORD for p in params.values())
128
+ passed = {k: v for k, v in kwargs.items()
129
+ if v is not None and (accepts_any or k in params)}
130
+ return agent.propose_and_validate(model, data, hypotheses, **passed)
131
+
132
+
133
+ def _unverified_hypotheses(report: "Any") -> "list[Any]":
134
+ """Best-first UNVERIFIED hypotheses: M4-tested and not refuted, highest
135
+ confidence first; then untested proposals from the last cycle."""
136
+ seen: set[str] = set()
137
+ out: list[Any] = []
138
+ tested = list(getattr(report, "all_test_results", None) or [])
139
+
140
+ def _critic_ok(tr: "Any") -> int:
141
+ meta = getattr(getattr(tr, "hypothesis", None), "metadata", None) or {}
142
+ return 0 if meta.get("critic") == "reject" else 1
143
+
144
+ ranked = sorted(
145
+ tested,
146
+ key=lambda tr: (float(getattr(tr, "confidence", 0.0) or 0.0), _critic_ok(tr)),
147
+ reverse=True,
148
+ )
149
+ for tr in ranked:
150
+ h = getattr(tr, "hypothesis", None)
151
+ if h is None or _hyp_key(h) in seen:
152
+ continue
153
+ seen.add(_hyp_key(h)) # tested (in any way) -> never re-added as "untested"
154
+ status = getattr(tr, "status", None)
155
+ status_s = str(getattr(status, "value", status) or "").lower()
156
+ if status_s == "refuted":
157
+ continue
158
+ out.append(h)
159
+ for h in reversed(list(getattr(report, "final_hypotheses", None) or [])):
160
+ if _hyp_key(h) not in seen:
161
+ seen.add(_hyp_key(h))
162
+ out.append(h)
163
+ return out
164
+
165
+
166
+ def _m5_supported_key(report: "Any") -> "str | None":
167
+ """Key of the hypothesis M5's experiment SUPPORTED, if any."""
168
+ iv = getattr(report, "fix_proposal", None)
169
+ status = getattr(iv, "status", None)
170
+ status_s = str(getattr(status, "value", status) or "").lower()
171
+ hyp = getattr(iv, "hypothesis", None)
172
+ if iv is None or hyp is None or status_s != "supported":
173
+ return None
174
+ return _hyp_key(hyp)
175
+
176
+
177
+ def _hyp_key(hypothesis: "Any") -> str:
178
+ """Identity of a hypothesis for matching across M4/M5 results."""
179
+ hid = str(getattr(hypothesis, "id", "") or "")
180
+ return hid or str(getattr(hypothesis, "statement", hypothesis))
181
+
182
+
183
+ def _m5_refuted(report: "Any") -> "tuple[set[str], list[str]]":
184
+ """Hypotheses M5's intervention experiment REFUTED, with a one-line why.
185
+
186
+ Reads ``report.fix_proposal`` (an ``InterventionResult`` from ``run_m5``).
187
+ Returns ``(keys, notes)``; both empty when M5 did not run or did not refute.
188
+ """
189
+ iv = getattr(report, "fix_proposal", None)
190
+ if iv is None:
191
+ return set(), []
192
+ status = getattr(iv, "status", None)
193
+ status_s = str(getattr(status, "value", status) or "").lower()
194
+ if status_s != "refuted":
195
+ return set(), []
196
+ hyp = getattr(iv, "hypothesis", None)
197
+ if hyp is None:
198
+ return set(), []
199
+ statement = str(getattr(hyp, "statement", hyp))
200
+ evidence = getattr(iv, "evidence", None) or {}
201
+ scalars = []
202
+ if isinstance(evidence, dict):
203
+ for k, v in evidence.items():
204
+ if isinstance(v, (int, float)) and not isinstance(v, bool):
205
+ scalars.append(f"{k}={v:g}" if isinstance(v, float) else f"{k}={v}")
206
+ if len(scalars) >= 8:
207
+ break
208
+ why = f" [experiment: {', '.join(scalars)}]" if scalars else ""
209
+ return {_hyp_key(hyp)}, [f"{statement}{why}"]
210
+
211
+
212
+ def _fix_context_from_report(
213
+ report: "Any",
214
+ *,
215
+ example_cases: "Any | None",
216
+ explore_context: "Any | None",
217
+ protocol: "Any | None",
218
+ refuted: "list[str]",
219
+ refuted_ids: "set[str] | None" = None,
220
+ ) -> "Any":
221
+ """Build the :class:`~evalrx.eval_agent.stages.fix_agent.FixContext`
222
+ the proposer sees: M4 verdicts + M2 conclusion/tests + exploratory notes,
223
+ the M5-refuted hypotheses, and (when a confirm split is in play) the
224
+ EXPLORE cases in full."""
225
+ from evalrx.eval_agent.stages.fix_agent import FixContext
226
+
227
+ lines: list[str] = []
228
+ refuted_ids = set(refuted_ids or ())
229
+ verified = [
230
+ tr for tr in list(getattr(report, "verified_hypotheses", None) or [])
231
+ if _hyp_key(getattr(tr, "hypothesis", None)) not in refuted_ids
232
+ ]
233
+ if verified:
234
+ lines.append(" M4 verified hypotheses (statistical tests on the diagnosis split):")
235
+ for tr in verified[:6]:
236
+ stmt = str(getattr(getattr(tr, "hypothesis", None), "statement", ""))[:220]
237
+ verdict = str(getattr(tr, "verdict", "") or "")[:300]
238
+ conf = getattr(tr, "confidence", None)
239
+ grade = getattr(tr, "evidence_grade", "")
240
+ conf_s = f" conf={conf:.2f}" if isinstance(conf, (int, float)) else ""
241
+ lines.append(f" - {stmt}{conf_s} grade={grade}")
242
+ if verdict:
243
+ lines.append(f" evidence: {verdict}")
244
+ stats = getattr(report, "final_stats_report", None) or getattr(report, "final_analysis", None)
245
+ if stats is not None:
246
+ conclusion = str(getattr(stats, "conclusion", "") or getattr(stats, "narrative", "") or "")
247
+ if conclusion:
248
+ lines.append(" M2 conclusion:")
249
+ lines.append(" " + conclusion.strip()[:1200])
250
+ chain = list(getattr(stats, "evidence_chain", None) or [])
251
+ if chain:
252
+ lines.append(" M2 evidence chain:")
253
+ lines += [f" - {str(step)[:300]}" for step in chain[:8]]
254
+ results = list(getattr(stats, "stats_results", None) or [])
255
+ sig = [r for r in results if getattr(r, "ok", False) and getattr(r, "reject", False)]
256
+ shown = sig[:8] or [r for r in results if getattr(r, "ok", False)][:5]
257
+ if shown:
258
+ lines.append(" M2 statistical tests" + (" (rejecting H0):" if sig else ":"))
259
+ lines += [f" - {str(getattr(r, 'summary', ''))[:300]}" for r in shown]
260
+ if explore_context is not None and not getattr(explore_context, "is_empty", True):
261
+ obs = list(getattr(explore_context, "observations", None) or [])
262
+ cav = list(getattr(explore_context, "caveats", None) or [])
263
+ if obs:
264
+ lines.append(" Exploratory notes (free-form EDA, UNCONFIRMED — hints only):")
265
+ lines += [f" - {str(o)[:300]}" for o in obs[:10]]
266
+ if cav:
267
+ lines.append(" Explorer caveats:")
268
+ lines += [f" - {str(c)[:200]}" for c in cav[:5]]
269
+ task_bits = []
270
+ for attr in ("description", "task_domain", "failure_patterns"):
271
+ value = str(getattr(protocol, attr, "") or "").strip()
272
+ if value:
273
+ task_bits.append(value)
274
+ return FixContext(
275
+ example_cases=example_cases,
276
+ evidence="\n".join(lines),
277
+ refuted=list(refuted),
278
+ task_note=" — ".join(task_bits),
279
+ )
280
+
281
+
282
+ class VLDiagnoseLoop:
283
+ """M1→M2→M3→M4 failure-analysis loop for VL tasks (Plan A architecture).
284
+
285
+ M5 (**SurgeryAgent**) is intentionally excluded from the inner loop.
286
+ Call :meth:`run_m5` on the returned :class:`VLDiagnoseReport` to obtain
287
+ a fix proposal based on the best verified hypothesis candidates.
288
+
289
+ Inner loop::
290
+
291
+ for cycle in range(max_cycles):
292
+ probe_results = M1.probe(model, data, protocol) # guided by protocol
293
+ explore_notes = explorer.explore_records(...) # optional, descriptive
294
+ stats_report = M2.analyze(probe_results, protocol)
295
+ diag = M3.diagnose(stats_report, explore_context=explore_notes)
296
+ test_results = M4.test(diag.hypotheses, stats_report, data, protocol)
297
+ if M4.stopping_criteria_met(test_results, protocol): break
298
+
299
+ Stopping criteria: at least one M4-verified hypothesis that is also
300
+ consistent with the user's experiment protocol.
301
+
302
+ **Decoupled two-phase use** (analysis → deferred confirm + fix)::
303
+
304
+ # Phase 1 — analyse + propose, build the dashboard. No M4, no fix.
305
+ report = loop.run_analysis(data) # M1 → M2 → M3, stop
306
+ save(report.final_hypotheses, report.final_stats_report)
307
+
308
+ # Phase 2 — later, reuse the saved artifacts to confirm + repair.
309
+ report = loop.run_confirm(data, hypotheses, stats_report=stats) # M4
310
+ loop.run_fix(report, data) # tiered repair
311
+
312
+ :meth:`run` is the all-in-one path (M1→M2→M3→M4 + stopping). Use
313
+ :meth:`run_analysis` + :meth:`run_confirm` when you want the analysis
314
+ dashboard *before* deciding whether to confirm hypotheses and repair.
315
+
316
+ Args:
317
+ model: The model under evaluation.
318
+ protocol: Experiment protocol — the human prior that guides
319
+ M1 analyzer selection, M2 narrative, and M4
320
+ consistency checks.
321
+ probe_agent: M1. Defaults to ``ProbeAgent()``.
322
+ stats_agent: M2. Defaults to ``StatsAnalysisAgent()``.
323
+ diagnosis_agent: M3. ``None`` lazily resolves ``DiagnosisAgent()``
324
+ on first use.
325
+ hypothesis_tester: M4. Defaults to ``HypothesisTester()``.
326
+ surgery_agent: M5 — used only by :meth:`run_m5`, never inside
327
+ the main loop. Defaults to ``SurgeryAgent()``.
328
+ store: Persistent memory.
329
+ m4_holdout: When a confirm split is in play (default on),
330
+ M4 is taken OUT of the cycle entirely: the cycles
331
+ only mine (M1→explore→M2→M3), and after the loop
332
+ every proposed hypothesis is tested ONCE on the
333
+ held-out confirm split (the last cycle's analyzers
334
+ re-run there, pinned). The loop neither stops
335
+ early nor keeps cycling based on M4 verdicts —
336
+ the same discipline the fix gate follows. Without
337
+ a confirm split M4 stays in-cycle as before.
338
+ test_split: Optional third partition (train/val/test mode).
339
+ When > 0 alongside ``confirm_split``, the batch is
340
+ split three ways: the loop mines on EXPLORE (train),
341
+ M4's single holdout pass AND the fix stage's whole
342
+ candidate search/selection run on CONFIRM (val),
343
+ and the frozen winner is scored exactly once on the
344
+ TEST partition — so hypothesis verification and fix
345
+ development never share cases with the final gate.
346
+ 0.0 (default) keeps the two-way explore/confirm
347
+ behaviour byte-for-byte.
348
+ val_data: Optional EXTERNAL validation batch (cases frozen
349
+ from a separate, disjoint sample — e.g. a task's
350
+ ``manifest_val.json``). The main batch keeps the
351
+ two-way explore/confirm split; when set (with
352
+ ``m4_holdout``), M4's single holdout pass runs on
353
+ these cases instead of the confirm partition, the
354
+ fix ladder is searched and its winner selected
355
+ here, and the frozen winner is scored exactly once
356
+ on the untouched CONFIRM partition. The same
357
+ train/val/test discipline as ``test_split``, but
358
+ the validation data costs no confirm power and no
359
+ training cases. Mutually exclusive with
360
+ ``test_split > 0``.
361
+ max_cycles: Hard cap on M1→M4 iterations (default 1: one
362
+ diagnosis pass, then the caller moves on to
363
+ M5/fix — with fix-on-unverified enabled the
364
+ extra cycles rarely verified anything and
365
+ tripled the wall-clock; raise it to keep
366
+ mining when a cycle's M4 designs feed the
367
+ next cycle's M1).
368
+ run_logger: Optional :class:`~evalrx.eval_agent.run_logger_v2.RunLoggerV2`.
369
+ token_budget: Stop early when accumulated token usage reaches
370
+ this limit (0 = unlimited).
371
+ analysis_only: Run only M1→M2 and stop before hypothesis generation.
372
+ explorer: Optional :class:`~evalrx.analysis.explorer.ExploratoryAnalysisAgent`.
373
+ When given, every cycle runs a free-form EDA step
374
+ between M1 and M2 over the same per-case table M2
375
+ sees (M1 analyzer signals + labels). Its output is
376
+ DESCRIPTIVE ONLY: observations/charts/caveats go to
377
+ M3 as an ``ExploreContext`` (which hypotheses to
378
+ propose) and to disk for the dashboard — never into
379
+ M2's confirmatory family, M4, or the fix gate. The
380
+ catalog M2 is unchanged. Best-effort: an explorer
381
+ failure logs a warning and the cycle continues.
382
+ explore_dir: Where the explore step persists
383
+ ``exploratory_report.json`` + ``tables/`` +
384
+ ``figures/`` (rendered chart PNGs, which M3 is shown).
385
+ Default: ``<ctx.root>/explore`` for a RunContext-
386
+ backed logger, ``<run>/explore`` beside a standalone
387
+ ``logs*/`` dir, else ``<run_dir>/explore`` — always
388
+ inside the run, where the dashboard looks; with no
389
+ run_logger, nothing is persisted.
390
+ explore_question: The question handed to the explorer. Defaults to
391
+ :func:`~evalrx.eval_agent.prompts.explore_step.default_explore_question`
392
+ built from *protocol*.
393
+ verbose: When ``True``, print live M1-M4 stage narration to
394
+ stdout (equivalent to calling
395
+ ``evalrx.enable_console_logging()`` yourself).
396
+ Separate from ``run_logger`` — this surfaces the
397
+ existing ``logger.info()``/``.warning()`` calls,
398
+ not RunLogger's structured JSONL event stream.
399
+ """
400
+
401
+ def __init__(
402
+ self,
403
+ model: "Model",
404
+ protocol: "ExperimentProtocol",
405
+ probe_agent: "Any | None" = None,
406
+ stats_agent: "StatsAnalysisAgent | None" = None,
407
+ diagnosis_agent: "Any | None" = None,
408
+ hypothesis_tester: "HypothesisTester | None" = None,
409
+ surgery_agent: "Any | None" = None,
410
+ fix_agent: "Any | None" = None,
411
+ store: Store | None = None,
412
+ max_cycles: int = 1,
413
+ run_logger: "Any | None" = None,
414
+ token_budget: int = 0,
415
+ analysis_only: bool = False,
416
+ confirm_split: float = 0.0,
417
+ confirm_split_seed: int = 0,
418
+ m4_holdout: bool = True,
419
+ test_split: float = 0.0,
420
+ val_data: "CaseBatch | None" = None,
421
+ signal_recipes: "list | None" = None,
422
+ bridge_analyzer_name: str = "explored",
423
+ explore_report: "Any | None" = None,
424
+ explorer: "Any | None" = None,
425
+ explore_dir: "str | Path | None" = None,
426
+ explore_question: str = "",
427
+ verbose: bool = False,
428
+ ) -> None:
429
+ from evalrx.analysis.stats_agent import StatsAnalysisAgent
430
+ from evalrx.eval_agent.stages.fix_agent import FixAgent
431
+ from evalrx.eval_agent.stages.hypothesis_tester import HypothesisTester
432
+ from evalrx.eval_agent.stages.probe_agent import ProbeAgent
433
+ from evalrx.eval_agent.stages.surgery import SurgeryAgent
434
+
435
+ if verbose:
436
+ # M1-M4 already narrate every stage transition via logger.info()/
437
+ # .warning() (this module's `logger`, plus probe_agent/diagnosis/
438
+ # hypothesis_tester/fix_agent's own) -- it's just invisible by
439
+ # default. This is the one-line equivalent of a caller doing
440
+ # `evalrx.enable_console_logging()` themselves; it does NOT
441
+ # touch RunLogger's separate structured JSONL event stream.
442
+ from evalrx.logging_utils import enable_console_logging
443
+
444
+ enable_console_logging()
445
+
446
+ self.model = model
447
+ self.protocol = protocol
448
+ self.probe_agent = probe_agent or ProbeAgent()
449
+ self.stats_agent = stats_agent or StatsAnalysisAgent()
450
+ self.diagnosis_agent = diagnosis_agent # None = lazy default on first call
451
+ self.hypothesis_tester = hypothesis_tester or HypothesisTester()
452
+ self.surgery_agent = surgery_agent or SurgeryAgent()
453
+ self.fix_agent = fix_agent or FixAgent(run_logger=run_logger)
454
+ self.store = store or InMemoryStore()
455
+ self.max_cycles = max_cycles
456
+ self.run_logger = run_logger
457
+ self.token_budget = token_budget
458
+ self.analysis_only = analysis_only
459
+ # Held-out CONFIRM split (leak #3): fraction of the batch reserved, away
460
+ # from M1-M4 hypothesis generation, for the post-loop fix/surgery to
461
+ # validate on — so the deployed fix is confirmed on data the loop never
462
+ # mined. 0.0 = off (current behavior); the split is deterministic
463
+ # (stratified by label+probe_type, seeded), so run() and run_m5/run_fix
464
+ # derive the identical partition from the same input batch.
465
+ self.confirm_split = float(confirm_split)
466
+ self.confirm_split_seed = int(confirm_split_seed)
467
+ self.m4_holdout = bool(m4_holdout)
468
+ # Train/val/test mode: a second held-out fraction. With both fractions
469
+ # set, CONFIRM becomes the VALIDATION partition (M4 holdout + fix
470
+ # candidate development) and the frozen fix candidate is scored exactly
471
+ # once on the TEST partition. 0.0 = two-way behaviour, unchanged.
472
+ self.test_split = float(test_split)
473
+ # External validation batch: the val role filled by SEPARATE frozen
474
+ # cases instead of a third carve of the main batch. With m4_holdout it
475
+ # takes the holdout-M4 + fix-development roles; the main batch keeps
476
+ # its full two-way explore/confirm split, and CONFIRM stays the single
477
+ # frozen-winner gate. Exclusive with test_split — two competing
478
+ # definitions of "validation" would be a config error, not a feature.
479
+ if val_data is not None and self.test_split > 0.0:
480
+ raise ValueError("val_data and test_split are mutually exclusive: "
481
+ "pass an external validation batch OR carve one "
482
+ "from the main batch, not both")
483
+ self.val_data = val_data
484
+ # Operationalization bridge (off by default): pre-registered SignalRecipes
485
+ # are compiled over the analyzer per_case signals each cycle into a synthetic
486
+ # "<bridge_analyzer_name>" analyzer Result, so LAMBDA-discovered composite
487
+ # signals enter M2's family via the standard findings["per_case"] contract.
488
+ # Leak-free by construction — recipes must be discovered out-of-band (e.g.
489
+ # the fused pipeline's held-out split), never by peeking at these labels.
490
+ self._signal_recipes = list(signal_recipes or [])
491
+ self._bridge_analyzer_name = bridge_analyzer_name
492
+ # Step-1 explorer mechanism notes (charts/observations/caveats). Descriptive,
493
+ # UNCONFIRMED: passed to M3's hypothesis-proposal prompt ONLY — never to the
494
+ # M2 confirmatory family, M4 testing, or the fix gate. Accepts an
495
+ # ExploreContext, a report dict (fused_report.json), or None.
496
+ self._explore_context = _coerce_explore_context(explore_report)
497
+ # In-cycle explore step (off by default): a free-form EDA pass over the
498
+ # M1 per-case table, run between M1 and M2. Same standing as a Step-1
499
+ # explore_report — descriptive notes for M3 + files for the dashboard.
500
+ # AgenticDiagnoseLoop overrides `self.explorer` after this constructor
501
+ # and drives it through its own judge-decided `explore_data` tool
502
+ # instead of the fixed in-cycle call (see run()/run_analysis()).
503
+ self.explorer = explorer
504
+ self._explore_dir = Path(explore_dir) if explore_dir is not None else None
505
+ self._explore_question = str(explore_question or "")
506
+ self._tokens_used: int = 0
507
+ self._run_id: str = ""
508
+ self._emitter: "Any | None" = None
509
+
510
+ # ------------------------------------------------------------------
511
+ # Contract emission — one validated payload per stage under <run>/contract/
512
+ # ------------------------------------------------------------------
513
+ @property
514
+ def emitter(self) -> "Any | None":
515
+ """Lazily built :class:`~evalrx.contract.emit.ContractEmitter`.
516
+
517
+ ``None`` when there is no run directory to write into, or when pydantic
518
+ (the ``contract`` extra) is not installed — the contract is an observer
519
+ of the pipeline and must never be a condition for running it.
520
+ """
521
+ if self._emitter is not None:
522
+ return self._emitter
523
+ if self.run_logger is None:
524
+ return None
525
+ root = getattr(getattr(self.run_logger, "_context", None), "root", None)
526
+ if root is None:
527
+ run_dir = getattr(self.run_logger, "run_dir", None)
528
+ if run_dir is None:
529
+ return None
530
+ run_dir = Path(run_dir)
531
+ root = run_dir.parent if run_dir.name.startswith("logs") else run_dir
532
+ try:
533
+ from evalrx.contract.emit import ContractEmitter
534
+ except ImportError: # pragma: no cover - contract extra not installed
535
+ logger.debug("contract emission off: pip install evalrx[contract]")
536
+ return None
537
+ self._emitter = ContractEmitter(root, getattr(self.run_logger, "trace_id", ""))
538
+ return self._emitter
539
+
540
+ def _emit(self, name: str, build: "Any") -> None:
541
+ """Emit one stage payload; never raises into the pipeline."""
542
+ emitter = self.emitter
543
+ if emitter is None:
544
+ return
545
+ emitter.emit(name, build)
546
+
547
+ def _set_fix_outcome(self, report: "Any", outcome: "Any") -> "Any":
548
+ """Attach M5b's outcome to the report and emit it.
549
+
550
+ ``run_fix`` has five return paths (escalation, frozen-candidate confirm,
551
+ legacy agents, skip, plain), so the assignment is the one point they all
552
+ pass through. Emitting per return would have missed whichever path was
553
+ added next.
554
+ """
555
+ report.fix_outcome = outcome
556
+ if self.emitter is not None and hasattr(outcome, "attempted"):
557
+ from evalrx.contract.emit import from_fix_outcome
558
+
559
+ self._emit("m5_fix", lambda: from_fix_outcome(
560
+ outcome, trace_id=self.emitter.trace_id, run_root=self.emitter.root,
561
+ ))
562
+ return outcome
563
+
564
+ def publish_report(
565
+ self,
566
+ *,
567
+ model: "Any | None" = None,
568
+ example_dir: "str | Path | None" = None,
569
+ ) -> "Any":
570
+ """Compose the completed-run UI, reusing the M3 judge when available."""
571
+ if self.run_logger is None:
572
+ raise RuntimeError("publish_report needs a run_logger with a durable run directory")
573
+ if model is None:
574
+ model = getattr(self.diagnosis_agent, "judge", None)
575
+ from evalrx.reporting.dynamic import publish_report
576
+
577
+ return publish_report(
578
+ self.run_logger.run_dir,
579
+ example_dir=example_dir,
580
+ model=model,
581
+ run_logger=self.run_logger,
582
+ )
583
+
584
+ @staticmethod
585
+ def _strat_key(case: "Any") -> "tuple":
586
+ """Stratify the explore/confirm split by label + probe_type when present
587
+ (keeps the no-free-lunch control mix, e.g. present-detections, in both
588
+ partitions). Falls back to label alone for generic batches."""
589
+ label = getattr(getattr(case, "label", None), "value", "?")
590
+ probe = (getattr(case, "metadata", {}) or {}).get("probe_type")
591
+ return (label, probe)
592
+
593
+ def _split_partitions(self, data: "CaseBatch"):
594
+ """Deterministic, stratified (explore, confirm, test) partition.
595
+
596
+ The single source of truth for how a batch is divided; every stage
597
+ re-derives the identical partition from the same input batch. When
598
+ ``confirm_split <= 0`` (or the batch is too small to split), returns
599
+ ``(data, None, None)`` — a no-op, so existing runs are byte-for-byte
600
+ unchanged. When ``test_split <= 0`` the third element is ``None`` (the
601
+ two-way explore/confirm behaviour). With both fractions set, CONFIRM is
602
+ drawn first (same seed as the two-way split), then TEST from the
603
+ remainder (seed+1); EXPLORE is what is left.
604
+ """
605
+ from evalrx.core.case import CaseBatch
606
+ from evalrx.stats.subset_sampling import stratified_subset
607
+
608
+ cases = list(data)
609
+ frac = self.confirm_split
610
+ if frac <= 0.0 or len(cases) < 4:
611
+ return data, None, None
612
+ n_confirm = round(len(cases) * frac)
613
+ if n_confirm <= 0 or n_confirm >= len(cases):
614
+ return data, None, None
615
+ three_way = self.test_split > 0.0
616
+ confirm = stratified_subset(cases, self._strat_key, n_confirm,
617
+ seed=self.confirm_split_seed)
618
+ if three_way:
619
+ # ``stratified_subset`` guarantees at MOST n (per-stratum rounding
620
+ # can under-allocate by one or two); the train/val/test mode
621
+ # promises exact counts, so top the draw up deterministically.
622
+ # The two-way path is left untouched — byte-for-byte reproducible.
623
+ confirm = _exact_size(confirm, cases, n_confirm,
624
+ self.confirm_split_seed)
625
+ confirm_ids = {id(c) for c in confirm}
626
+ rest = [c for c in cases if id(c) not in confirm_ids]
627
+ if not three_way:
628
+ return CaseBatch(rest), CaseBatch(confirm), None
629
+ n_test = round(len(cases) * self.test_split)
630
+ if n_test <= 0 or n_test >= len(rest):
631
+ return CaseBatch(rest), CaseBatch(confirm), None
632
+ test = stratified_subset(rest, self._strat_key, n_test,
633
+ seed=self.confirm_split_seed + 1)
634
+ test = _exact_size(test, rest, n_test, self.confirm_split_seed + 1)
635
+ test_ids = {id(c) for c in test}
636
+ explore = [c for c in rest if id(c) not in test_ids]
637
+ return CaseBatch(explore), CaseBatch(confirm), CaseBatch(test)
638
+
639
+ def _split_explore_confirm(self, data: "CaseBatch"):
640
+ """The (explore, confirm) view of :meth:`_split_partitions`.
641
+
642
+ Stages that never touch the TEST partition (analysis, deferred confirm,
643
+ surgery) use this two-tuple view; the partition is identical to the one
644
+ :meth:`run` / :meth:`run_fix` derive — the test cases are simply
645
+ invisible here, which is exactly the point.
646
+ """
647
+ explore, confirm, _test = self._split_partitions(data)
648
+ return explore, confirm
649
+
650
+ def _bridge_signals(self, probe_results: "dict[str, Any]", data: "Any | None") -> None:
651
+ """Compile pre-registered signal recipes into a synthetic analyzer Result
652
+ and inject it into *probe_results* so M2/M3/M4 see the bridged composite
653
+ signals through the standard findings["per_case"] contract. No-op when no
654
+ recipes are configured. Never raises into the loop."""
655
+ if not self._signal_recipes:
656
+ return
657
+ # Never silently overwrite a real analyzer that already used this key.
658
+ name = self._bridge_analyzer_name
659
+ if name in probe_results:
660
+ base, n = name, 1
661
+ while name in probe_results:
662
+ name, n = f"{base}_bridge{n}", n + 1
663
+ logger.warning(
664
+ "bridge analyzer name %r collides with a real analyzer; "
665
+ "injecting under %r instead", base, name,
666
+ )
667
+ try:
668
+ from evalrx.analysis.operationalize import bridge_recipes_to_result
669
+
670
+ synth = bridge_recipes_to_result(
671
+ self._signal_recipes, probe_results, data,
672
+ model_repr=repr(self.model),
673
+ analyzer_name=name,
674
+ )
675
+ except Exception as exc: # bridging must never sink the loop
676
+ logger.warning("signal bridge failed: %s", exc)
677
+ return
678
+ if synth is None:
679
+ return
680
+ probe_results[name] = synth
681
+ self.store.add_result(synth)
682
+ # The bridged signals ARE the discovered candidates — they must be tested,
683
+ # not optional. default_plan caps at the stats agent's max_signal_tools and
684
+ # appends bridged signals LAST, so a low cap silently drops them. Raise the
685
+ # cap to cover the expanded family (more multiplicity = more conservative,
686
+ # never less — e-BH still controls FDR over whatever is tested).
687
+ agent = getattr(self, "stats_agent", None)
688
+ if agent is not None and hasattr(agent, "_max_signal_tools"):
689
+ try:
690
+ from evalrx.analysis.stats_tools import build_stats_input
691
+
692
+ n_signals = len(build_stats_input(probe_results, data).per_case)
693
+ current = getattr(agent, "_max_signal_tools", None)
694
+ if current is not None:
695
+ agent._max_signal_tools = max(int(current), n_signals)
696
+ except Exception as exc: # never let the cap-bump break the loop
697
+ logger.debug("bridge: could not raise stats signal cap: %s", exc)
698
+ logger.info(
699
+ "bridged %d signal row(s) into M2 as analyzer %r",
700
+ len(synth.findings.get("per_case", [])), name,
701
+ )
702
+
703
+ # ──────────────────────────────────────────────────────────────────
704
+ # Stage helpers (shared by run / run_analysis / run_confirm)
705
+ # ──────────────────────────────────────────────────────────────────
706
+
707
+ def _get_diagnosis_agent(self) -> "Any":
708
+ """Resolve M3 lazily so the default Gemini fallback matches
709
+ AutoDiagnoseLoop — a DiagnosisAgent() built eagerly would raise if
710
+ GEMINI_API_KEY is absent even when the caller passed diagnosis_agent=None."""
711
+ if self.diagnosis_agent is not None:
712
+ return self.diagnosis_agent
713
+ from evalrx.eval_agent.stages.diagnosis import DiagnosisAgent
714
+ return DiagnosisAgent()
715
+
716
+ def _do_m1(
717
+ self, cycle: int, data: "Any", all_hypotheses: "list[Any]",
718
+ timings: "dict[str, float]", *, log: bool = True,
719
+ ) -> "tuple[dict[str, Any], list]":
720
+ """M1: protocol-guided probing + signal bridge.
721
+
722
+ Returns ``(probe_results, artifact_pngs)``; ``probe_results`` is empty
723
+ when M1 produced nothing (caller decides whether to stop). When
724
+ ``log`` is False the probe events are not written — used by
725
+ :meth:`run_confirm` when it only needs to regenerate the stats the
726
+ tester reads (M1/M2 already belong to the analysis phase's log)."""
727
+ prior_modes = list(dict.fromkeys(
728
+ h.predicted_failure_mode for h in all_hypotheses
729
+ if getattr(h, "predicted_failure_mode", None)
730
+ ))
731
+ _t0 = time.monotonic()
732
+ probe_results = self.probe_agent.probe(
733
+ self.model,
734
+ data,
735
+ protocol=self.protocol,
736
+ prior_hypotheses=all_hypotheses or None,
737
+ hint_failure_modes=prior_modes or None,
738
+ )
739
+ _dt = time.monotonic() - _t0
740
+ timings["m1"] = timings.get("m1", 0.0) + _dt
741
+ artifact_pngs: list = []
742
+ if log and self.run_logger:
743
+ artifact_pngs = self.run_logger.log_probe(
744
+ cycle, probe_results, schema=self.probe_agent.last_schema,
745
+ cases=data,
746
+ judge_prompt=getattr(self.probe_agent, "last_selection_prompt", ""),
747
+ judge_raw=getattr(self.probe_agent, "last_selection_raw", ""),
748
+ duration_sec=_dt,
749
+ ) or []
750
+ _log_generated_tools(self.run_logger, cycle, "m1_probe", self.probe_agent)
751
+ self._emit_m1(cycle, probe_results, data, _dt)
752
+ if not probe_results:
753
+ return {}, []
754
+ for r in probe_results.values():
755
+ self.store.add_result(r)
756
+ # Operationalization bridge: pre-registered recipes -> synthetic
757
+ # "explored" analyzer Result (no-op when none configured).
758
+ self._bridge_signals(probe_results, data)
759
+ return probe_results, artifact_pngs
760
+
761
+ def _emit_m1(self, cycle: int, probe_results: "dict[str, Any]", data: "Any",
762
+ duration_sec: float) -> None:
763
+ """Emit M1's ProbeOutput, including how analyzer routing was decided.
764
+
765
+ The three modality sets are recorded separately because they can differ
766
+ and the difference is the diagnosis: an omni model on an audio benchmark
767
+ declares four modalities, fills one, and is routed on one.
768
+ """
769
+ if self.emitter is None:
770
+ return
771
+ from evalrx.contract.emit import from_probe_results
772
+ from evalrx.core.case import probed_modalities
773
+
774
+ selector = self.probe_agent.selector if hasattr(self.probe_agent, "selector") else None
775
+ declared = sorted(getattr(self.model, "modalities", frozenset({"text"})) or {"text"})
776
+ probed = sorted(probed_modalities(data))
777
+ routed = sorted(
778
+ selector.routed_slots(self.model, data) if selector is not None else probed
779
+ )
780
+ # Trajectories in the batch, not declared TOOL_CALLS: the api backend
781
+ # reports tool_calls for every chat model, which made a single-turn text
782
+ # run record itself as an agent run.
783
+ from evalrx.eval_agent.stages.probe import _carries_trajectories
784
+
785
+ is_agent = bool(data is not None and _carries_trajectories(data))
786
+ self._emit(f"c{cycle}.m1", lambda: from_probe_results(
787
+ probe_results,
788
+ trace_id=self.emitter.trace_id, cycle=cycle, model=self.model,
789
+ model_modalities=declared, probed_modalities=probed, routed_on=routed,
790
+ is_agent=is_agent,
791
+ selector="llm_judge" if getattr(self.probe_agent, "judge", None) else "static_strategy",
792
+ generated=list(getattr(self.probe_agent, "generated_probes", []) or []),
793
+ failed_analyzers=dict(getattr(self.probe_agent, "_failed_analyzers", {}) or {}),
794
+ duration_sec=duration_sec,
795
+ ))
796
+
797
+ def _explore_out_dir(self) -> "Path | None":
798
+ """Where the explore step persists its report/tables/figures.
799
+
800
+ Explicit ``explore_dir`` wins. Otherwise it is derived from the run
801
+ logger so the files land INSIDE the run, beside the log, where the
802
+ dashboard's ``_find_explore_report`` looks (``<root>/*/exploratory_report.json``):
803
+
804
+ - a :class:`~evalrx.eval_agent.run_context.RunContext`-backed logger
805
+ → ``ctx.explore_dir``, an ephemeral tree under ``ctx.runtime_root``
806
+ (captured into ``M2/log.json`` and deleted at ``finalize()``;
807
+ writing straight to ``<ctx.root>/explore`` would leak real files
808
+ outside V2's JSON-only run artifact);
809
+ - a standalone ``RunLoggerV2("<run>/logs")`` / ``logs_confirm`` … →
810
+ ``<run>/explore`` (a sibling of the ``logs*/`` dir, the llm_benchmark
811
+ layout — under the log dir it would be two levels down for a
812
+ ``logs_confirm/`` carrier log and the dashboard would miss it);
813
+ - any other standalone run dir → ``<run_dir>/explore``.
814
+
815
+ ``None`` (nothing persisted, context in memory only) without a logger."""
816
+ if self._explore_dir is not None:
817
+ return self._explore_dir
818
+ if self.run_logger is None:
819
+ return None
820
+ ctx = getattr(self.run_logger, "_context", None)
821
+ if ctx is not None and hasattr(ctx, "explore_dir"):
822
+ return ctx.explore_dir
823
+ run_dir = getattr(self.run_logger, "run_dir", None)
824
+ if run_dir is None:
825
+ return None
826
+ run_dir = Path(run_dir)
827
+ if run_dir.name.startswith("logs"):
828
+ return run_dir.parent / "explore"
829
+ return run_dir / "explore"
830
+
831
+ def _do_explore(
832
+ self, cycle: int, probe_results: "dict[str, Any]", data: "Any",
833
+ timings: "dict[str, float]", *, log: bool = True,
834
+ ) -> "Any | None":
835
+ """Optional in-cycle explore step: free-form EDA over M1's per-case table.
836
+
837
+ Runs between M1 and M2 when an ``explorer`` is configured. The explorer
838
+ sees exactly what M2 sees — ``build_stats_input`` → ``per_case_to_records``
839
+ (M1 analyzer per-case signals + PASS/FAIL labels) — writes tables and
840
+ chart specs, and the host renders the charts. The result feeds:
841
+
842
+ - M3, as an :class:`~evalrx.eval_agent.stages.diagnosis.ExploreContext`
843
+ (observations / rendered charts / caveats — descriptive, UNCONFIRMED,
844
+ used only to decide WHICH hypotheses to propose);
845
+ - the dashboard, via ``exploratory_report.json`` + ``tables/`` +
846
+ ``figures/`` under :meth:`_explore_out_dir` (one directory, rewritten
847
+ each cycle — it always holds what the *latest* M3 was shown; the
848
+ per-cycle ``explore`` run-log events keep every cycle's counts and
849
+ observations).
850
+
851
+ It never touches M2's confirmatory family, M4, or the fix gate: the
852
+ explorer's candidate-signal verdicts are host-adjudicated IN-SAMPLE
853
+ (labelled so) and dropped from the M3 context by construction. Held-out
854
+ confirmation of explorer recipes is the fused pipeline's job, not this
855
+ step's. Best-effort: any failure logs a warning and returns ``None`` —
856
+ an explorer outage must not cost the M2/M3 that already have their data.
857
+
858
+ Returns the explorer report (or ``None``)."""
859
+ if self.explorer is None:
860
+ return None
861
+ from evalrx.analysis.operationalize import per_case_to_records
862
+ from evalrx.analysis.stats_tools import build_stats_input
863
+ from evalrx.eval_agent.stages.diagnosis import ExploreContext
864
+
865
+ _t0 = time.monotonic()
866
+ report: Any = None
867
+ out_dir = self._explore_out_dir()
868
+ try:
869
+ inp = build_stats_input(probe_results, data)
870
+ records = per_case_to_records(inp.per_case, inp.labels)
871
+ if not records:
872
+ logger.info("explore: M1 produced no per-case signals — skipping.")
873
+ return None
874
+ question = self._explore_question
875
+ if not question:
876
+ from evalrx.eval_agent.prompts.explore_step import default_explore_question
877
+
878
+ question = default_explore_question(self.protocol)
879
+ report = self.explorer.explore_records(
880
+ records, question=question, outcome_col="label",
881
+ )
882
+ # Host firewall: recompute every candidate verdict from sufficient
883
+ # statistics with the M2 core (in-sample here — labelled as such so
884
+ # nothing downstream mistakes it for a held-out result).
885
+ try:
886
+ from evalrx.analysis.adjudicate import adjudicate_report
887
+
888
+ adjudicate_report(report, split_label="in_sample")
889
+ except Exception as exc: # adjudication is a verdict layer, not the data
890
+ logger.warning("explore: host adjudication failed: %s", exc)
891
+ if out_dir is not None:
892
+ from evalrx.analysis.explore_run import write_report_artifacts
893
+
894
+ # Renders chart specs from the copied tables/ CSVs, so the
895
+ # persisted report carries each chart's absolute figure_path —
896
+ # the PNGs M3 is shown and the dashboard displays.
897
+ write_report_artifacts(report, out_dir)
898
+ ctx = ExploreContext.from_report(
899
+ report.to_dict() if hasattr(report, "to_dict") else None
900
+ )
901
+ if ctx is not None:
902
+ ctx.source = "loop_explorer"
903
+ self._explore_context = ctx
904
+ elif getattr(report, "ok", False):
905
+ logger.info("explore: report carried no observations/charts — "
906
+ "M3 keeps its previous explore context.")
907
+ except Exception as exc: # the explorer must never sink the loop
908
+ logger.warning(
909
+ "explore step failed at cycle %d (%s) — M2/M3 continue without "
910
+ "explorer notes.", cycle, exc,
911
+ )
912
+ _dt = time.monotonic() - _t0
913
+ timings["explore"] = timings.get("explore", 0.0) + _dt
914
+ if log and self.run_logger is not None:
915
+ try:
916
+ self.run_logger.log_explore(
917
+ cycle, report, out_dir=out_dir, duration_sec=_dt,
918
+ )
919
+ except Exception as exc: # logging must never break the run
920
+ logger.warning("explore: could not log the explore event: %s", exc)
921
+ return report
922
+
923
+ def _do_m2(
924
+ self, cycle: int, probe_results: "dict[str, Any]", data: "Any",
925
+ artifact_pngs: "list", timings: "dict[str, float]", *, log: bool = True,
926
+ confirmatory: bool = True,
927
+ ) -> "Any":
928
+ """M2: protocol-aware rigorous stats analysis (effect sizes + charts).
929
+
930
+ ``confirmatory=False`` defers the e-BH validity verdict (the analysis
931
+ phase shows distributions only); the confirm phase recomputes it."""
932
+ _t0 = time.monotonic()
933
+ stats_report = self.stats_agent.analyze(
934
+ probe_results,
935
+ model_name=repr(self.model),
936
+ protocol=self.protocol,
937
+ data=data,
938
+ extra_figures=artifact_pngs,
939
+ confirmatory=confirmatory,
940
+ )
941
+ _dt = time.monotonic() - _t0
942
+ timings["m2"] = timings.get("m2", 0.0) + _dt
943
+ if log and self.run_logger:
944
+ self.run_logger.log_analysis(cycle, stats_report, duration_sec=_dt)
945
+ _log_generated_tools(self.run_logger, cycle, "m2_stats", self.stats_agent)
946
+ if self.emitter is not None:
947
+ from evalrx.contract.emit import from_stats_report
948
+
949
+ self._emit(f"c{cycle}.m2", lambda: from_stats_report(
950
+ stats_report, trace_id=self.emitter.trace_id, cycle=cycle,
951
+ raw_results_ref=f"contract/c{cycle}.m1.json", duration_sec=_dt,
952
+ ))
953
+ return stats_report
954
+
955
+ def _do_m3(
956
+ self, cycle: int, stats_report: "Any", prior_cycles: "list[Any]",
957
+ timings: "dict[str, float]", *, log: bool = True,
958
+ failure_modes: "Any | None" = None, cases: "Any | None" = None,
959
+ ) -> "Any | None":
960
+ """M3: hypothesis generation. Returns the diagnosis result, or ``None``
961
+ when M3 could not run (judge unavailable / timeout / quota) — the caller
962
+ stops gracefully on ``None`` (regression guard: an M3 timeout must not
963
+ kill the whole run after M1+M2 succeeded). Also accrues token usage.
964
+
965
+ ``failure_modes`` (optional clustered ``FailureModeReport``) is passed
966
+ through to the diagnosis agent only when it accepts the parameter —
967
+ ``None`` (the default, always the case for ``VLDiagnoseLoop``) adds
968
+ nothing to the prompt and costs no extra call."""
969
+ try:
970
+ diag_agent = self._get_diagnosis_agent()
971
+ # Retain the lazily resolved agent so report publication can reuse
972
+ # the same judge without asking callers to inject it a second time.
973
+ self.diagnosis_agent = diag_agent
974
+ except Exception as exc:
975
+ logger.warning("Could not resolve DiagnosisAgent: %s", exc)
976
+ return None
977
+
978
+ _t0 = time.monotonic()
979
+ try:
980
+ diag = _diagnose_with_optional_context(
981
+ diag_agent, stats_report, prior_cycles, self._explore_context,
982
+ failure_modes, cases=cases,
983
+ )
984
+ except Exception as exc: # judge timeout/quota must not kill the loop
985
+ logger.warning(
986
+ "M3 diagnosis failed at cycle %d (%s) — stopping with the "
987
+ "evidence collected so far.", cycle, exc,
988
+ )
989
+ return None
990
+ _dt = time.monotonic() - _t0
991
+ timings["m3"] = timings.get("m3", 0.0) + _dt
992
+
993
+ _tok = getattr(diag, "tokens_used", None)
994
+ if _tok is None:
995
+ _tok = max(1, len(diag.raw_judge_output) // 4)
996
+ self._tokens_used += _tok
997
+
998
+ if log and self.run_logger:
999
+ # Log only the figures that actually existed (the same existence
1000
+ # filter M3 applies) so the audit trail reflects what the judge saw.
1001
+ _explore_figs = None
1002
+ if self._explore_context is not None:
1003
+ from pathlib import Path as _P
1004
+
1005
+ _explore_figs = [
1006
+ f for f in self._explore_context.figure_paths if _P(f).exists()
1007
+ ]
1008
+ self.run_logger.log_diagnosis(
1009
+ cycle, diag, duration_sec=_dt, explore_figures=_explore_figs or None
1010
+ )
1011
+ if self.emitter is not None:
1012
+ from evalrx.contract.emit import from_diagnosis
1013
+
1014
+ self._emit(f"c{cycle}.m3", lambda: from_diagnosis(
1015
+ diag, trace_id=self.emitter.trace_id, cycle=cycle, duration_sec=_dt,
1016
+ ))
1017
+ return diag
1018
+
1019
+ @staticmethod
1020
+ def _finalize_confirmatory_stats(stats_report: "Any") -> None:
1021
+ """Promote a descriptive (analysis-phase) stats report to confirmatory.
1022
+
1023
+ The analysis phase runs M2 with the e-BH validity verdict DEFERRED
1024
+ (``descriptive_only=True``). The confirm phase recomputes e-BH FDR
1025
+ correction over the report's stats results so M4 and the dashboard see
1026
+ the family-level reject decision. No-op when already confirmatory."""
1027
+ if stats_report is None:
1028
+ return
1029
+ corr = getattr(stats_report, "corrected_rejections", None) or {}
1030
+ if getattr(stats_report, "descriptive_only", False) or corr.get("deferred"):
1031
+ from evalrx.analysis.stats_tools import fdr_correct
1032
+
1033
+ stats_report.corrected_rejections = fdr_correct(
1034
+ list(getattr(stats_report, "stats_results", None) or [])
1035
+ )
1036
+ stats_report.descriptive_only = False
1037
+
1038
+ def _do_m4(
1039
+ self, cycle: int, hypotheses: "list[Any]", stats_report: "Any",
1040
+ data: "Any", timings: "dict[str, float]", *, log: bool = True,
1041
+ split_label: "str | None" = None,
1042
+ ) -> "list[Any]":
1043
+ """M4: statistical test + protocol consistency for each hypothesis,
1044
+ writing the verdict back onto ``hypothesis.status``.
1045
+
1046
+ ``split_label`` tags each result's evidence with which data split the
1047
+ test read (``"confirm_holdout"`` for the held-out pass) BEFORE the
1048
+ result is logged, so the marker reaches the run log too."""
1049
+ _t0 = time.monotonic()
1050
+ test_results = self.hypothesis_tester.test(
1051
+ hypotheses,
1052
+ stats_report,
1053
+ data,
1054
+ protocol=self.protocol,
1055
+ )
1056
+ _dt = time.monotonic() - _t0
1057
+ timings["m4"] = timings.get("m4", 0.0) + _dt
1058
+ for tr in test_results:
1059
+ tr.hypothesis.status = tr.status
1060
+ if split_label and isinstance(getattr(tr, "evidence", None), dict):
1061
+ tr.evidence["split"] = split_label
1062
+ if log and self.run_logger:
1063
+ # Reuse the surgery log slot for M4 results (backward compat).
1064
+ for tr in test_results:
1065
+ _iv = _make_intervention_result_from_test(tr)
1066
+ self.run_logger.log_surgery(
1067
+ cycle, tr.hypothesis, _iv, duration_sec=_dt,
1068
+ validation_cases=data,
1069
+ judge_prompt=getattr(tr, "judge_prompt", None) or None,
1070
+ judge_raw=getattr(tr, "judge_raw", None) or None,
1071
+ )
1072
+ if log and self.emitter is not None:
1073
+ from evalrx.contract.emit import from_test_results
1074
+
1075
+ # M4 has no stage log of its own — it reuses the per-hypothesis
1076
+ # surgery slot — so this is the first place the whole verdict set
1077
+ # exists as one object a reader can load.
1078
+ split = "confirm" if split_label == "confirm_holdout" else "explore"
1079
+ self._emit(f"c{cycle}.m4", lambda: from_test_results(
1080
+ test_results, trace_id=self.emitter.trace_id, cycle=cycle,
1081
+ split=split, duration_sec=_dt,
1082
+ stopping_criteria_met=bool(
1083
+ self.hypothesis_tester.stopping_criteria_met(test_results, self.protocol)
1084
+ ),
1085
+ ))
1086
+ return test_results
1087
+
1088
+ def _m4_holdout_pass(
1089
+ self,
1090
+ hypotheses: "list[Any]",
1091
+ confirm: "Any",
1092
+ analyzer_names: "list[str]",
1093
+ timings: "dict[str, float]",
1094
+ ) -> "tuple[list[Any], str]":
1095
+ """The M4 pass — on the held-out confirm split.
1096
+
1097
+ With a confirm split in play this is the ONLY hypothesis test: M3's
1098
+ hypotheses are taken straight to data M1/M2/M3 never mined. The same
1099
+ analyzers as the last explore cycle are re-run on the confirm split
1100
+ (pinned via ``ProbeAgent.probe(analyzers=)`` — a fresh judge selection
1101
+ could miss the designated signals); with no names to pin (a reloaded
1102
+ run without a stats report) the probe agent selects normally.
1103
+
1104
+ Returns ``(test_results, status)`` with status ``"confirmed"`` (the
1105
+ pass ran) or ``"failed"`` (the confirm re-probe produced nothing — no
1106
+ hypothesis can be verified this run).
1107
+ """
1108
+ pinned = [n for n in analyzer_names if not n.startswith("generated:")]
1109
+ logger.info(
1110
+ "M4 (held-out): testing %d hypothesis(es) on the %d-case confirm "
1111
+ "split (analyzers: %s)",
1112
+ len(hypotheses), len(list(confirm)),
1113
+ ", ".join(pinned) or "<probe agent's own selection>",
1114
+ )
1115
+ # Stamped BEFORE probe() (not just before log_probe below): ProbeAgent
1116
+ # tags every model call it makes with whatever current_cycle reads AT
1117
+ # CALL TIME, and probe() is where those calls happen. Setting this
1118
+ # only right before log_probe(-1, ...) tagged this whole confirm-split
1119
+ # re-probe's model calls with the previous explore cycle's number.
1120
+ if self.run_logger:
1121
+ self.run_logger.current_cycle = -1
1122
+ _t0 = time.monotonic()
1123
+ try:
1124
+ if pinned:
1125
+ probe_results = self.probe_agent.probe(
1126
+ self.model, confirm, protocol=self.protocol,
1127
+ prior_hypotheses=hypotheses or None, analyzers=pinned,
1128
+ )
1129
+ else:
1130
+ probe_results = self.probe_agent.probe(
1131
+ self.model, confirm, protocol=self.protocol,
1132
+ prior_hypotheses=hypotheses or None,
1133
+ )
1134
+ except TypeError:
1135
+ # A custom probe agent that predates the ``analyzers=`` hook.
1136
+ probe_results = self.probe_agent.probe(
1137
+ self.model, confirm, protocol=self.protocol,
1138
+ prior_hypotheses=hypotheses or None,
1139
+ )
1140
+ timings["m4_holdout_m1"] = timings.get("m4_holdout_m1", 0.0) + (
1141
+ time.monotonic() - _t0)
1142
+ if not probe_results:
1143
+ logger.warning(
1144
+ "M4 (held-out): the confirm-split re-probe produced no results "
1145
+ "— no hypothesis can be verified this run."
1146
+ )
1147
+ return [], "failed"
1148
+ self._bridge_signals(probe_results, confirm)
1149
+ if self.run_logger:
1150
+ self.run_logger.log_probe(
1151
+ -1, probe_results,
1152
+ schema=getattr(self.probe_agent, "last_schema", None),
1153
+ cases=confirm,
1154
+ )
1155
+ stats_confirm = self._do_m2(
1156
+ -1, probe_results, confirm, [], timings, confirmatory=True
1157
+ )
1158
+ results = self._do_m4(-1, hypotheses, stats_confirm, confirm, timings,
1159
+ split_label="confirm_holdout")
1160
+ return results, "confirmed"
1161
+
1162
+ # ──────────────────────────────────────────────────────────────────
1163
+ # Public API
1164
+ # ──────────────────────────────────────────────────────────────────
1165
+
1166
+ def run(self, data: "CaseBatch") -> VLDiagnoseReport:
1167
+ """Drive the M1→M2→M3→M4 loop to convergence.
1168
+
1169
+ Args:
1170
+ data: Cases to analyse (should carry :class:`~evalrx.core.case.Label`
1171
+ values for the M4 statistical tests to work).
1172
+
1173
+ Returns:
1174
+ :class:`VLDiagnoseReport` with the final state.
1175
+ Call :meth:`run_m5` on this to get a fix proposal (Plan A).
1176
+ """
1177
+ all_hypotheses: list[Any] = []
1178
+ all_test_results: list[Any] = []
1179
+ final_stats_report = None
1180
+ stopped_by = _STOPPED_BY_MAX
1181
+ prior_cycles: list[dict[str, Any]] = []
1182
+ last_analyzer_names: list[str] = []
1183
+ self._tokens_used = 0
1184
+
1185
+ # Per-stage wall-clock totals (seconds) for the loop_end cost profile.
1186
+ timings: dict[str, float] = {}
1187
+
1188
+ # Held-out CONFIRM split (leak #3): M1-M4 see only EXPLORE; the post-loop
1189
+ # fix/surgery validate on the frozen CONFIRM partition (run_m5/run_fix
1190
+ # re-derive the same deterministic split from the same input batch).
1191
+ # With test_split > 0 (train/val/test mode) a third partition is also
1192
+ # reserved here: CONFIRM plays validation (M4 holdout + fix candidate
1193
+ # development) and TEST is only ever touched by the final frozen-fix
1194
+ # gate in run_fix.
1195
+ explore, confirm, test = self._split_partitions(data)
1196
+ if confirm is not None:
1197
+ if test is not None:
1198
+ logger.info(
1199
+ "train/val/test split: train=%d, val=%d, test=%d "
1200
+ "(frac=%.2f/%.2f)",
1201
+ len(list(explore)), len(list(confirm)), len(list(test)),
1202
+ self.confirm_split, self.test_split,
1203
+ )
1204
+ elif self.val_data is not None:
1205
+ logger.info(
1206
+ "explore/confirm split: explore=%d, confirm=%d (frac=%.2f) "
1207
+ "+ external validation set: %d cases",
1208
+ len(list(explore)), len(list(confirm)), self.confirm_split,
1209
+ len(list(self.val_data)),
1210
+ )
1211
+ else:
1212
+ logger.info(
1213
+ "confirm split: explore=%d cases, confirm=%d held out (frac=%.2f)",
1214
+ len(list(explore)), len(list(confirm)), self.confirm_split,
1215
+ )
1216
+ data = explore
1217
+
1218
+ # With a confirm split (and m4_holdout on), M4 runs ONCE after the
1219
+ # loop, on held-out data — the cycles only mine (M1→M3). The holdout
1220
+ # target is the external validation set when one was supplied (the
1221
+ # confirm partition then stays reserved for the frozen fix winner),
1222
+ # else the confirm partition itself. Without any held-out data M4
1223
+ # stays in-cycle as before.
1224
+ holdout_target = self.val_data if self.val_data is not None else confirm
1225
+ holdout_mode = holdout_target is not None and self.m4_holdout
1226
+
1227
+ # Forward the RunLogger into the agents so the probe / stats tool
1228
+ # generators record their tool-synthesis attempts ("tool_codegen" events).
1229
+ _attach_run_logger(self.run_logger, self.probe_agent, self.stats_agent)
1230
+ if self.run_logger is not None:
1231
+ self.run_logger.log_run_start(
1232
+ _run_config(self, data, loop_name="VLDiagnoseLoop")
1233
+ )
1234
+ # Each partition is logged under its own name: the records are the
1235
+ # only place a reader can learn which cases M1-M3 mined and which
1236
+ # were withheld for M4 / M5, so the tag has to be on the record.
1237
+ self.run_logger.log_cases(
1238
+ data, split="explore" if confirm is not None else None,
1239
+ )
1240
+ if confirm is not None:
1241
+ # `data` is the explore split by now, so logging only it left the
1242
+ # held-out cases unrecorded — and those are the ones M4's verdict
1243
+ # and M5's repair are measured on. A report then cannot show a
1244
+ # single case behind its strongest evidence: the repair's own
1245
+ # per-case outputs joined to nothing.
1246
+ self.run_logger.log_cases(confirm, split="confirm")
1247
+ if test is not None:
1248
+ self.run_logger.log_cases(test, split="test")
1249
+ if self.val_data is not None:
1250
+ self.run_logger.log_cases(self.val_data, split="val")
1251
+
1252
+ for cycle in range(self.max_cycles):
1253
+ if self.token_budget > 0 and self._tokens_used >= self.token_budget:
1254
+ logger.warning(
1255
+ "Token budget %d exhausted after %d cycles", self.token_budget, cycle
1256
+ )
1257
+ stopped_by = _STOPPED_BY_BUDGET
1258
+ break
1259
+
1260
+ if self.run_logger is not None:
1261
+ self.run_logger.current_cycle = cycle
1262
+
1263
+ # ── M1: protocol-guided probing (+ signal bridge) ─────────
1264
+ probe_results, artifact_pngs = self._do_m1(
1265
+ cycle, data, all_hypotheses, timings
1266
+ )
1267
+ if not probe_results:
1268
+ logger.info("M1 produced no probe results — stopping.")
1269
+ stopped_by = _STOPPED_BY_NO_PROBE
1270
+ break
1271
+ last_analyzer_names = list(probe_results)
1272
+
1273
+ # ── explore (optional): free-form EDA on M1's table → M3 notes ──
1274
+ self._do_explore(cycle, probe_results, data, timings)
1275
+
1276
+ # ── M2: protocol-aware stats analysis ────────────────────
1277
+ stats_report = self._do_m2(
1278
+ cycle, probe_results, data, artifact_pngs, timings
1279
+ )
1280
+ final_stats_report = stats_report
1281
+
1282
+ if self.analysis_only:
1283
+ stopped_by = _STOPPED_BY_NO_HYPS
1284
+ break
1285
+
1286
+ # ── M3: hypothesis generation ("AI scientist") ────────────
1287
+ diag = self._do_m3(cycle, stats_report, prior_cycles, timings, cases=data)
1288
+ if diag is None or not diag.hypotheses:
1289
+ if diag is not None:
1290
+ logger.info("M3 produced no hypotheses at cycle %d.", cycle)
1291
+ stopped_by = _STOPPED_BY_NO_HYPS
1292
+ break
1293
+
1294
+ for h in diag.hypotheses:
1295
+ self.store.add_hypothesis(h)
1296
+ all_hypotheses.extend(diag.hypotheses)
1297
+
1298
+ # ── M4: hypothesis testing (stats + protocol consistency) ─
1299
+ # In holdout mode M4 is deferred to the single held-out pass
1300
+ # after the loop: testing here would re-use the explore data
1301
+ # the hypotheses were mined from, and its verdict must not
1302
+ # steer the loop (no early stop / no extra cycles keyed on
1303
+ # SUPPORTED-or-not).
1304
+ if not holdout_mode:
1305
+ test_results = self._do_m4(
1306
+ cycle, diag.hypotheses, stats_report, data, timings
1307
+ )
1308
+ all_test_results.extend(test_results)
1309
+
1310
+ # ── Stopping criteria ────────────────────────────────────
1311
+ if self.hypothesis_tester.stopping_criteria_met(test_results, self.protocol):
1312
+ logger.info(
1313
+ "Stopping criteria met at cycle %d: verified, protocol-consistent "
1314
+ "hypothesis found.",
1315
+ cycle,
1316
+ )
1317
+ stopped_by = _STOPPED_BY_CRITERIA
1318
+ break
1319
+
1320
+ # Build prior-cycles context for next M3 call
1321
+ prior_cycles.append({
1322
+ "cycle": cycle,
1323
+ "severity": stats_report.severity,
1324
+ "hypotheses": [
1325
+ {
1326
+ "statement": h.statement,
1327
+ "failure_mode": h.predicted_failure_mode,
1328
+ "status": h.status.value if h.status else "pending",
1329
+ }
1330
+ for h in diag.hypotheses
1331
+ ],
1332
+ })
1333
+
1334
+ # The M4 pass: with a confirm split in play the hypotheses are tested
1335
+ # ONCE, on the held-out split — the same discipline run_fix already
1336
+ # applies to candidates. Without one, the in-cycle results stand.
1337
+ m4_holdout_status: "str | None" = None
1338
+ if holdout_mode and all_hypotheses:
1339
+ all_test_results, m4_holdout_status = self._m4_holdout_pass(
1340
+ all_hypotheses, holdout_target, last_analyzer_names, timings,
1341
+ )
1342
+ verified = self.hypothesis_tester.best_hypotheses(all_test_results)
1343
+
1344
+ report = VLDiagnoseReport(
1345
+ cycles=cycle + 1 if self.max_cycles > 0 else 0, # type: ignore[possibly-undefined]
1346
+ resolved=bool(verified),
1347
+ stopped_by=stopped_by,
1348
+ verified_hypotheses=verified,
1349
+ final_hypotheses=all_hypotheses,
1350
+ all_test_results=all_test_results,
1351
+ final_stats_report=final_stats_report,
1352
+ store=self.store,
1353
+ m4_holdout=m4_holdout_status,
1354
+ _run_id=self._run_id,
1355
+ )
1356
+ if self.run_logger:
1357
+ self.run_logger.log_loop_end(
1358
+ report, tokens_used=self._tokens_used, timings=timings
1359
+ )
1360
+ return report
1361
+
1362
+ def run_analysis(self, data: "CaseBatch") -> VLDiagnoseReport:
1363
+ """Phase 1 — analyse + propose, WITHOUT confirming (no M4, no fix).
1364
+
1365
+ Runs a single **M1 → M2 → M3** pass: select+execute analyzers (M1),
1366
+ rigorous protocol-aware stats + charts (M2, e-BH adjudication kept),
1367
+ and propose root-cause hypotheses (M3) — then stop. This is the path
1368
+ that feeds the dashboard *before* deciding whether to confirm and
1369
+ repair.
1370
+
1371
+ The returned :class:`VLDiagnoseReport` carries:
1372
+ - ``final_hypotheses`` — the M3 proposals (UNCONFIRMED), and
1373
+ - ``final_stats_report`` — the M2 report,
1374
+ with ``all_test_results`` / ``verified_hypotheses`` left empty (M4 has
1375
+ not run). Persist ``final_hypotheses`` (via
1376
+ :func:`~evalrx.eval_agent.hypothesis.hypothesis_to_dict`) and
1377
+ ``final_stats_report``, then hand them to :meth:`run_confirm` for the
1378
+ deferred confirmation + repair phase.
1379
+
1380
+ Unlike :meth:`run`, there is no M4 stopping signal, so this is a single
1381
+ pass (one M1→M2→M3), not a multi-cycle loop; ``max_cycles`` is ignored.
1382
+ """
1383
+ self._tokens_used = 0
1384
+ timings: dict[str, float] = {}
1385
+
1386
+ # Same deterministic explore/confirm partition as run() / run_fix() —
1387
+ # M1-M3 see only EXPLORE so the deferred fix can validate on the
1388
+ # untouched CONFIRM partition. No-op when confirm_split=0.
1389
+ explore, confirm = self._split_explore_confirm(data)
1390
+ if confirm is not None:
1391
+ logger.info(
1392
+ "confirm split: explore=%d cases, confirm=%d held out (frac=%.2f)",
1393
+ len(list(explore)), len(list(confirm)), self.confirm_split,
1394
+ )
1395
+ data = explore
1396
+
1397
+ _attach_run_logger(self.run_logger, self.probe_agent, self.stats_agent)
1398
+ if self.run_logger is not None:
1399
+ self.run_logger.current_cycle = 0
1400
+ self.run_logger.log_run_start(
1401
+ _run_config(self, data, loop_name="VLDiagnoseLoop.analysis")
1402
+ )
1403
+ self.run_logger.log_cases(
1404
+ data, split="explore" if confirm is not None else None,
1405
+ )
1406
+ if confirm is not None:
1407
+ self.run_logger.log_cases(confirm, split="confirm")
1408
+
1409
+ all_hypotheses: list[Any] = []
1410
+ final_stats_report = None
1411
+ stopped_by = _STOPPED_BY_ANALYSIS
1412
+
1413
+ probe_results, artifact_pngs = self._do_m1(0, data, all_hypotheses, timings)
1414
+ if not probe_results:
1415
+ logger.info("M1 produced no probe results — stopping.")
1416
+ stopped_by = _STOPPED_BY_NO_PROBE
1417
+ else:
1418
+ # Optional explore step (descriptive EDA → M3 notes + dashboard files).
1419
+ self._do_explore(0, probe_results, data, timings)
1420
+ # Descriptive M2: effect sizes + charts, but DEFER the e-BH validity
1421
+ # verdict to run_confirm so the analysis dashboard shows no
1422
+ # "supported/not-supported" claim (Q2: no validity before confirm).
1423
+ final_stats_report = self._do_m2(
1424
+ 0, probe_results, data, artifact_pngs, timings, confirmatory=False
1425
+ )
1426
+ diag = self._do_m3(0, final_stats_report, [], timings, cases=data)
1427
+ if diag is None or not diag.hypotheses:
1428
+ # Stats + dashboard are still valid; just no hypotheses to confirm.
1429
+ if diag is not None:
1430
+ logger.info("M3 produced no hypotheses.")
1431
+ stopped_by = _STOPPED_BY_NO_HYPS
1432
+ else:
1433
+ for h in diag.hypotheses:
1434
+ self.store.add_hypothesis(h)
1435
+ all_hypotheses.extend(diag.hypotheses)
1436
+
1437
+ report = VLDiagnoseReport(
1438
+ cycles=1,
1439
+ stopped_by=stopped_by,
1440
+ verified_hypotheses=[],
1441
+ final_hypotheses=all_hypotheses,
1442
+ all_test_results=[],
1443
+ final_stats_report=final_stats_report,
1444
+ store=self.store,
1445
+ _run_id=self._run_id,
1446
+ )
1447
+ if self.run_logger:
1448
+ self.run_logger.log_loop_end(
1449
+ report, tokens_used=self._tokens_used, timings=timings
1450
+ )
1451
+ return report
1452
+
1453
+ def run_confirm(
1454
+ self,
1455
+ data: "CaseBatch",
1456
+ hypotheses: "list[Any]",
1457
+ *,
1458
+ stats_report: "Any | None" = None,
1459
+ ) -> VLDiagnoseReport:
1460
+ """Phase 2a — confirm previously-proposed hypotheses with M4.
1461
+
1462
+ Runs **M4** (:class:`~evalrx.eval_agent.stages.hypothesis_tester.HypothesisTester`)
1463
+ on ``hypotheses`` — typically reloaded from :meth:`run_analysis`'s output
1464
+ via :func:`~evalrx.eval_agent.hypothesis.hypothesis_from_dict` — so
1465
+ the *same* hypotheses the dashboard showed are the ones confirmed.
1466
+
1467
+ ``stats_report`` is the M2 report M4 reads its rigorous evidence from
1468
+ (effect + CI + e-value, FDR-corrected). Pass the
1469
+ ``final_stats_report`` persisted by :meth:`run_analysis` to confirm
1470
+ against the *exact* statistics the dashboard displayed; when omitted,
1471
+ M1→M2 are re-run silently (not logged — they belong to the analysis
1472
+ phase) to regenerate it.
1473
+
1474
+ Returns a :class:`VLDiagnoseReport` with ``all_test_results`` and
1475
+ ``verified_hypotheses`` populated. Feed it into :meth:`run_m5` /
1476
+ :meth:`run_fix` for the repair step.
1477
+ """
1478
+ self._tokens_used = 0
1479
+ timings: dict[str, float] = {}
1480
+ hypotheses = list(hypotheses or [])
1481
+
1482
+ # Mirror run()'s partition: the screening M4 reads the supplied (or
1483
+ # regenerated) EXPLORE stats the hypotheses were mined from; the
1484
+ # held-out confirmation below re-tests the screened ones on CONFIRM
1485
+ # (run_m5/run_fix re-derive the same partition for their validation).
1486
+ explore, confirm = self._split_explore_confirm(data)
1487
+ if confirm is not None:
1488
+ data = explore
1489
+
1490
+ _attach_run_logger(self.run_logger, self.probe_agent, self.stats_agent)
1491
+ if self.run_logger is not None:
1492
+ self.run_logger.current_cycle = 0
1493
+ self.run_logger.log_run_start(
1494
+ _run_config(self, data, loop_name="VLDiagnoseLoop.confirm")
1495
+ )
1496
+ self.run_logger.log_cases(
1497
+ data, split="explore" if confirm is not None else None,
1498
+ )
1499
+ if confirm is not None:
1500
+ self.run_logger.log_cases(confirm, split="confirm")
1501
+
1502
+ # Regenerate the stats the tester needs only when not supplied. The M1/M2
1503
+ # events are NOT logged here — they were recorded in the analysis phase,
1504
+ # and re-logging them would double up the dashboard's analysis story.
1505
+ if stats_report is None:
1506
+ probe_results, artifact_pngs = self._do_m1(
1507
+ 0, data, hypotheses, timings, log=False
1508
+ )
1509
+ if not probe_results:
1510
+ logger.warning(
1511
+ "run_confirm: probe produced no results and no stats_report "
1512
+ "was supplied — cannot confirm."
1513
+ )
1514
+ report = VLDiagnoseReport(
1515
+ cycles=1, stopped_by=_STOPPED_BY_NO_PROBE,
1516
+ final_hypotheses=hypotheses, store=self.store, _run_id=self._run_id,
1517
+ )
1518
+ if self.run_logger:
1519
+ self.run_logger.log_loop_end(
1520
+ report, tokens_used=self._tokens_used, timings=timings
1521
+ )
1522
+ return report
1523
+ stats_report = self._do_m2(
1524
+ 0, probe_results, data, artifact_pngs, timings, log=False
1525
+ )
1526
+
1527
+ # The confirm phase OWNS the validity verdict: if the reused report came
1528
+ # from the descriptive analysis phase (e-BH deferred), compute e-BH now,
1529
+ # flip descriptive_only off, and log the confirmatory M2 so the dashboard
1530
+ # surfaces the signal validity it withheld before confirmation.
1531
+ self._finalize_confirmatory_stats(stats_report)
1532
+ if self.run_logger and stats_report is not None:
1533
+ self.run_logger.log_analysis(0, stats_report)
1534
+
1535
+ test_results: list[Any] = []
1536
+ m4_holdout_status: "str | None" = None
1537
+ if hypotheses:
1538
+ for h in hypotheses:
1539
+ self.store.add_hypothesis(h)
1540
+ holdout_target = self.val_data if self.val_data is not None else confirm
1541
+ if holdout_target is not None and self.m4_holdout:
1542
+ # The one M4 pass, on held-out data (never the explore stats
1543
+ # the hypotheses were mined from): the external validation set
1544
+ # when one was supplied, else the confirm split. The analyzer
1545
+ # set to re-run there is recovered from the supplied/
1546
+ # regenerated stats report's signal keys; with none
1547
+ # recoverable the probe agent selects on the holdout itself.
1548
+ analyzer_names = _analyzer_names_from_stats(stats_report)
1549
+ test_results, m4_holdout_status = self._m4_holdout_pass(
1550
+ hypotheses, holdout_target, analyzer_names, timings,
1551
+ )
1552
+ else:
1553
+ test_results = self._do_m4(0, hypotheses, stats_report, data, timings)
1554
+ else:
1555
+ logger.info("run_confirm: no hypotheses to confirm.")
1556
+
1557
+ verified = self.hypothesis_tester.best_hypotheses(test_results)
1558
+ stopped_by = _STOPPED_BY_CRITERIA if verified else _STOPPED_BY_MAX
1559
+
1560
+ report = VLDiagnoseReport(
1561
+ cycles=1,
1562
+ resolved=bool(verified),
1563
+ stopped_by=stopped_by,
1564
+ verified_hypotheses=verified,
1565
+ final_hypotheses=hypotheses,
1566
+ all_test_results=test_results,
1567
+ final_stats_report=stats_report,
1568
+ store=self.store,
1569
+ m4_holdout=m4_holdout_status,
1570
+ _run_id=self._run_id,
1571
+ )
1572
+ if self.run_logger:
1573
+ self.run_logger.log_loop_end(
1574
+ report, tokens_used=self._tokens_used, timings=timings
1575
+ )
1576
+ return report
1577
+
1578
+ def run_m5(
1579
+ self,
1580
+ report: VLDiagnoseReport,
1581
+ data: "CaseBatch",
1582
+ *,
1583
+ allow_unverified: bool = False,
1584
+ ) -> "Any | None":
1585
+ """Plan A: run the M5 intervention experiment on the best hypothesis.
1586
+
1587
+ Called *after* :meth:`run` to avoid polluting the inner loop with
1588
+ fix-execution noise. Operates on the highest-confidence verified
1589
+ hypothesis from :attr:`VLDiagnoseReport.verified_hypotheses`; with
1590
+ ``allow_unverified=True`` and no verified hypothesis it falls back to
1591
+ the best *unverified* one (see :func:`_unverified_hypotheses`) — the
1592
+ experiment is then a genuine test of a lead M4 could not decide, and
1593
+ its verdict (supported / refuted) is what ``run_fix`` reads.
1594
+
1595
+ Args:
1596
+ report: Returned by :meth:`run`.
1597
+ data: Original case batch (needed by the surgery agent).
1598
+ allow_unverified: Fall back to the best unverified hypothesis when
1599
+ M4 verified none (default False: verified only).
1600
+
1601
+ Returns:
1602
+ :class:`~evalrx.eval_agent.surgery.InterventionResult` or
1603
+ ``None`` if there is no hypothesis to act on.
1604
+ """
1605
+ if report.verified_hypotheses:
1606
+ best_hyp = report.verified_hypotheses[0].hypothesis
1607
+ unverified = False
1608
+ elif allow_unverified:
1609
+ candidates = _unverified_hypotheses(report)
1610
+ if not candidates:
1611
+ logger.info("run_m5: no hypotheses at all to act on.")
1612
+ return None
1613
+ best_hyp = candidates[0]
1614
+ unverified = True
1615
+ logger.info(
1616
+ "run_m5: no verified hypothesis — experimenting on the best UNVERIFIED "
1617
+ "one (allow_unverified=True): %s", str(getattr(best_hyp, "statement", best_hyp))[:120],
1618
+ )
1619
+ else:
1620
+ logger.info("run_m5: no verified hypotheses to act on.")
1621
+ return None
1622
+
1623
+ # M5 is an adaptive experiment: its verdict changes which hypothesis
1624
+ # reaches the repair proposer. It therefore belongs to EXPLORE, not
1625
+ # the final repair CONFIRM partition. Touching CONFIRM here would make
1626
+ # the later candidate choice depend on the same cases used for its
1627
+ # significance gate. (Train/val/test mode: surgery stays on TRAIN —
1628
+ # its inputs, the M2 raw_results and per-case signals, were computed
1629
+ # there — and the TEST partition is invisible to this method entirely.)
1630
+ explore, confirm = self._split_explore_confirm(data)
1631
+ if confirm is not None:
1632
+ data = explore
1633
+
1634
+ results: dict[str, Any] = (
1635
+ report.final_stats_report.raw_results
1636
+ if report.final_stats_report is not None
1637
+ else {}
1638
+ )
1639
+ iv = self.surgery_agent.operate(
1640
+ best_hyp,
1641
+ self.model,
1642
+ results,
1643
+ data,
1644
+ )
1645
+ try:
1646
+ iv.evidence = dict(getattr(iv, "evidence", None) or {})
1647
+ iv.evidence["hypothesis_was_verified"] = not unverified
1648
+ except Exception: # evidence is informational
1649
+ pass
1650
+ report.fix_proposal = iv
1651
+ if self.emitter is not None:
1652
+ from evalrx.contract.emit import from_intervention
1653
+
1654
+ self._emit("m5_surgery", lambda: from_intervention(
1655
+ iv, trace_id=self.emitter.trace_id,
1656
+ ))
1657
+ # M5 runs *after* the loop, so log its experiment separately — the
1658
+ # generated script(s), the run output, the agent's thinking and a
1659
+ # snapshot of the workspace. ``cycle=-1`` marks it as post-loop.
1660
+ if self.run_logger is not None:
1661
+ try:
1662
+ self.run_logger.log_experiment(-1, best_hyp, iv, module="m5")
1663
+ except Exception as exc: # logging must never break the fix step
1664
+ logger.warning("run_m5: log_experiment failed: %s", exc)
1665
+ return iv
1666
+
1667
+ def run_fix(
1668
+ self,
1669
+ report: VLDiagnoseReport,
1670
+ data: "CaseBatch",
1671
+ max_tier: "str | Any | None" = None,
1672
+ fix_agent: "Any | None" = None,
1673
+ auto_escalate: bool = False,
1674
+ allow_unverified: bool = False,
1675
+ ) -> "Any":
1676
+ """Post-loop fix module: tiered, validated repair attempts.
1677
+
1678
+ By default the allowed tier is fixed (default L2) and there is no
1679
+ automatic escalation — the returned
1680
+ :class:`~evalrx.eval_agent.stages.fix_agent.FixOutcome` carries a
1681
+ recommendation when nothing validates.
1682
+
1683
+ When ``auto_escalate=True`` the agent steps through the intervention
1684
+ ladder L0/L1/L2 → L3a → L3b, stopping as soon as a candidate validates.
1685
+ Each escalation round receives the full history of prior failed
1686
+ attempts so the judge can generate fundamentally different strategies
1687
+ rather than repeating what already failed.
1688
+
1689
+ Args:
1690
+ report: Returned by :meth:`run`. Only M4-verified hypotheses
1691
+ may author a repair; an empty evidence gate records a
1692
+ skipped M5 stage instead of a pseudo-result.
1693
+ data: Original case batch (validated with paired McNemar
1694
+ against the unmodified baseline).
1695
+ max_tier: Ceiling tier: "L0", "L1", "L2", "L3a", "L3b", "L4".
1696
+ Defaults to L3b when ``auto_escalate=True``, or the
1697
+ agent's configured tier otherwise.
1698
+ fix_agent: Per-call override of :attr:`fix_agent`.
1699
+ auto_escalate: When True, step through tiers automatically,
1700
+ feeding prior failure context to each round.
1701
+ allow_unverified: When True and nothing was M4-verified, run the
1702
+ fix on the best UNVERIFIED leads (M4-tested,
1703
+ non-refuted, highest confidence first; else the
1704
+ last cycle's proposals), flagged to the proposer
1705
+ as leads rather than facts. The fix gate is the
1706
+ candidate validation on CONFIRM, not the
1707
+ hypothesis, so this is safe; the default (False)
1708
+ records a skipped stage instead.
1709
+ """
1710
+ from evalrx.eval_agent.stages.fix_tiers import FixTier, parse_tier
1711
+
1712
+ # Validate the fix on a held-out partition (leak #3): the hypotheses
1713
+ # were generated on EXPLORE, so the deployed repair must be confirmed on
1714
+ # cases the loop never used to pick the fix. Deterministic re-split of
1715
+ # the same batch; no-op when confirm_split=0.
1716
+ #
1717
+ # Two-way mode: candidates are searched on EXPLORE, the frozen winner is
1718
+ # scored on CONFIRM. Train/val/test mode (test_split > 0): the whole
1719
+ # candidate search/selection moves to CONFIRM (val) — the same partition
1720
+ # M4's holdout verification used, never the train cases the hypotheses
1721
+ # were mined from — and the frozen winner is scored exactly once on the
1722
+ # TEST partition, which no adaptive decision has ever touched. External
1723
+ # validation mode (val_data): same discipline, with the separate frozen
1724
+ # validation batch as the development partition and CONFIRM as the
1725
+ # untouched final gate.
1726
+ explore, confirm, test = self._split_partitions(data)
1727
+ if self.val_data is not None and confirm is not None:
1728
+ dev_data, final_data, dev_label = self.val_data, confirm, "VAL"
1729
+ elif test is not None:
1730
+ dev_data, final_data, dev_label = confirm, test, "VAL"
1731
+ else:
1732
+ dev_data, final_data, dev_label = explore, confirm, "EXPLORE"
1733
+ if confirm is not None:
1734
+ data = final_data
1735
+
1736
+ agent = fix_agent or self.fix_agent
1737
+ # M5's intervention experiment (run_m5) can REFUTE the very hypothesis
1738
+ # M4 verified. A refuted hypothesis must not reach the proposer as
1739
+ # "verified": on qwen3.5-2b/bbh_tracking7 the fix judge/coder were told
1740
+ # the M5-refuted grading-mismatch hypothesis was verified and half the
1741
+ # coded pipeline's design served it. Refuted ones are dropped from the
1742
+ # list and passed to the proposer as "do not build on these".
1743
+ refuted_ids, refuted_notes = _m5_refuted(report)
1744
+ hypotheses = [
1745
+ tr.hypothesis for tr in report.verified_hypotheses
1746
+ if _hyp_key(tr.hypothesis) not in refuted_ids
1747
+ ]
1748
+ hypotheses_note = ""
1749
+ if allow_unverified:
1750
+ # Opt-in exploratory repair keeps strong inconclusive leads even
1751
+ # when M4 happened to verify a different, narrower hypothesis. A
1752
+ # secondary supported symptom must not crowd the plausible causal
1753
+ # mechanisms out of the intervention search. They remain clearly
1754
+ # marked as leads; only the final candidate validation is a fix.
1755
+ leads = [
1756
+ h for h in _unverified_hypotheses(report)
1757
+ if _hyp_key(h) not in refuted_ids
1758
+ and _hyp_key(h) not in {_hyp_key(v) for v in hypotheses}
1759
+ ][:3]
1760
+ had_verified = bool(hypotheses)
1761
+ hypotheses.extend(leads)
1762
+ supported = _m5_supported_key(report)
1763
+ if leads:
1764
+ hypotheses_note = (
1765
+ ("MIXED EVIDENCE: verified hypotheses come first; the remaining "
1766
+ if had_verified else
1767
+ "UNVERIFIED: M4 found no statistically significant evidence for these ")
1768
+ + "hypotheses; they are best-scoring leads, not established "
1769
+ "mechanisms"
1770
+ + ("; the M5 intervention experiment SUPPORTED the first lead"
1771
+ if supported and _hyp_key(leads[0]) == supported else "")
1772
+ + ". Treat inconclusive leads only as hints about WHERE to intervene; "
1773
+ "the final candidate validation decides."
1774
+ )
1775
+ if not hypotheses:
1776
+ # A repair proposal is an intervention, not another exploratory
1777
+ # probe. Do not turn an unreviewed/unsupported M3 lead into a
1778
+ # misleading empty M5 outcome. The caller gets a stable outcome
1779
+ # object for compatibility, while the audit records a skipped stage.
1780
+ from evalrx.eval_agent.stages.fix_agent import FixOutcome
1781
+
1782
+ outcome = FixOutcome(
1783
+ max_tier=getattr(agent, "max_tier", FixTier.L2_SCAFFOLD),
1784
+ stage_status="skipped",
1785
+ skip_reason="no_accepted_hypothesis",
1786
+ recommendation={
1787
+ "reason": "Repair was not attempted because no hypothesis passed the evidence gate.",
1788
+ "next_step": "Run the targeted diagnostic probe proposed by M3.",
1789
+ },
1790
+ )
1791
+ if self.run_logger is not None:
1792
+ self.run_logger.log_stage_skipped(
1793
+ "M5", "no_accepted_hypothesis",
1794
+ detail="No M4-verified hypothesis was available for repair authoring.",
1795
+ )
1796
+ self._set_fix_outcome(report, outcome)
1797
+ return outcome
1798
+
1799
+ context = _fix_context_from_report(
1800
+ report,
1801
+ example_cases=explore if confirm is not None else None,
1802
+ explore_context=self._explore_context,
1803
+ protocol=self.protocol,
1804
+ refuted=refuted_notes,
1805
+ refuted_ids=refuted_ids,
1806
+ )
1807
+ context.hypotheses_note = hypotheses_note
1808
+
1809
+ # With a held-out split, the entire tier ladder is searched on the
1810
+ # development partition (EXPLORE, or VAL in train/val/test mode).
1811
+ # Feedback may adapt the next tier there; the final partition remains
1812
+ # untouched until one candidate is frozen and evaluated exactly once.
1813
+ # This keeps automatic L0 -> L1 -> L2 -> L3a -> L3b escalation without
1814
+ # tuning on the holdout. Each ceiling is tried separately: cheaper
1815
+ # candidates are not pooled with a newly opened, more invasive tier.
1816
+ if auto_escalate and confirm is not None:
1817
+ ladder = [
1818
+ FixTier.L0_RUNTIME_CONFIG,
1819
+ FixTier.L1_PROMPT,
1820
+ FixTier.L2_SCAFFOLD,
1821
+ FixTier.L3A_INTERNALS_READ,
1822
+ FixTier.L3B_INTERNALS_WRITE,
1823
+ ]
1824
+ ceiling = (
1825
+ parse_tier(max_tier) if max_tier is not None
1826
+ else FixTier.L3B_INTERNALS_WRITE
1827
+ )
1828
+ agent_logger = getattr(agent, "run_logger", None)
1829
+ original_min_tier = getattr(agent, "min_tier", None)
1830
+ agent.run_logger = None
1831
+ attempted: "list" = []
1832
+ prior: "list" = []
1833
+ repair_rounds = 0
1834
+ try:
1835
+ for tier in ladder:
1836
+ if tier > ceiling:
1837
+ break
1838
+ agent.max_tier = tier
1839
+ agent.min_tier = tier
1840
+ logger.info(
1841
+ "run_fix: %s trying tier %s (%d prior attempt(s))",
1842
+ dev_label,
1843
+ tier.label,
1844
+ len(prior),
1845
+ )
1846
+ selection = _propose_and_validate(
1847
+ agent,
1848
+ self.model,
1849
+ dev_data,
1850
+ hypotheses,
1851
+ prior_attempts=prior if prior else None,
1852
+ context=context,
1853
+ )
1854
+ if not hasattr(selection, "attempted"):
1855
+ self._set_fix_outcome(report, selection)
1856
+ return selection
1857
+ attempted.extend(selection.attempted)
1858
+ repair_rounds += int(getattr(selection, "repair_rounds", 0) or 0)
1859
+ if selection.fixed:
1860
+ logger.info("run_fix: %s fixed at tier %s", dev_label, tier.label)
1861
+ break
1862
+ prior.extend(v for v in selection.attempted if not v.fixed)
1863
+ logger.info("run_fix: %s tier %s exhausted — escalating", dev_label, tier.label)
1864
+ finally:
1865
+ agent.run_logger = agent_logger
1866
+ agent.max_tier = ceiling
1867
+ agent.min_tier = original_min_tier
1868
+
1869
+ outcome = _confirm_from_explore(
1870
+ agent,
1871
+ self.model,
1872
+ final_data,
1873
+ attempted,
1874
+ max_tier=ceiling,
1875
+ repair_rounds=repair_rounds,
1876
+ )
1877
+ try:
1878
+ if agent_logger is not None:
1879
+ agent_logger.log_fix(outcome)
1880
+ except Exception as exc:
1881
+ logger.debug("run_fix: held-out escalation log_fix failed: %s", exc)
1882
+ self._set_fix_outcome(report, outcome)
1883
+ return outcome
1884
+
1885
+ if auto_escalate:
1886
+ _LADDER = [
1887
+ FixTier.L0_RUNTIME_CONFIG,
1888
+ FixTier.L1_PROMPT,
1889
+ FixTier.L2_SCAFFOLD,
1890
+ FixTier.L3A_INTERNALS_READ,
1891
+ FixTier.L3B_INTERNALS_WRITE,
1892
+ ]
1893
+ ceiling = (
1894
+ parse_tier(max_tier) if max_tier is not None
1895
+ else FixTier.L3B_INTERNALS_WRITE
1896
+ )
1897
+ # Suppress per-round log_fix so we can emit one combined outcome.
1898
+ agent_logger = getattr(agent, "run_logger", None)
1899
+ original_min_tier = getattr(agent, "min_tier", None)
1900
+ agent.run_logger = None
1901
+
1902
+ all_attempted: "list" = []
1903
+ all_prior: "list" = []
1904
+ last_outcome = None
1905
+
1906
+ try:
1907
+ for tier in _LADDER:
1908
+ if tier > ceiling:
1909
+ break
1910
+ agent.max_tier = tier
1911
+ agent.min_tier = tier
1912
+ logger.info("run_fix: trying tier %s (%d prior attempt(s))",
1913
+ tier.label, len(all_prior))
1914
+ outcome = _propose_and_validate(
1915
+ agent, self.model, data, hypotheses,
1916
+ prior_attempts=all_prior if all_prior else None,
1917
+ context=context,
1918
+ )
1919
+ all_attempted.extend(outcome.attempted)
1920
+ all_prior.extend(v for v in outcome.attempted if not v.fixed)
1921
+ last_outcome = outcome
1922
+ if outcome.fixed:
1923
+ logger.info("run_fix: fixed at tier %s", tier.label)
1924
+ break
1925
+ logger.info("run_fix: tier %s exhausted — escalating", tier.label)
1926
+ finally:
1927
+ agent.run_logger = agent_logger
1928
+ agent.min_tier = original_min_tier
1929
+
1930
+ # Merge all rounds into one combined outcome and emit once. The
1931
+ # merged set spans every escalated tier, so it is a LARGER best-of-N
1932
+ # family than any single tier — re-apply e-BH FDR control over the
1933
+ # whole union (mirrors FixAgent.propose_and_validate) instead of an
1934
+ # uncorrected max, or auto-escalation would re-open the multiplicity
1935
+ # leak it was meant to respect.
1936
+ if last_outcome is not None:
1937
+ last_outcome.attempted = all_attempted
1938
+ last_outcome.max_tier = ceiling
1939
+ tested = [v for v in all_attempted if v.e_value is not None]
1940
+ survivors = agent._ebh_survivors(tested)
1941
+ last_outcome.ebh_survivors = sorted(
1942
+ v.candidate.name for v in tested if id(v) in survivors)
1943
+ winners = [v for v in all_attempted
1944
+ if v.fixed and id(v) in survivors]
1945
+ if winners:
1946
+ last_outcome.best = max(
1947
+ winners, key=lambda v: (v.effect or 0.0, -v.n_broken)
1948
+ )
1949
+ last_outcome.fixed = True
1950
+ else:
1951
+ last_outcome.best = None
1952
+ last_outcome.fixed = False
1953
+ try:
1954
+ if agent_logger is not None:
1955
+ agent_logger.log_fix(last_outcome)
1956
+ except Exception as exc:
1957
+ logger.debug("run_fix: combined log_fix failed: %s", exc)
1958
+
1959
+ self._set_fix_outcome(report, last_outcome)
1960
+ return last_outcome
1961
+
1962
+ # Non-escalating path. With a held-out split this is a genuine
1963
+ # two-stage repair experiment: the agent may iterate and select on the
1964
+ # development partition (EXPLORE, or VAL in train/val/test mode), then
1965
+ # exactly one frozen candidate is tested on the final partition.
1966
+ # Selection statistics are descriptive only; the final paired gate is
1967
+ # computed from the final partition alone.
1968
+ if max_tier is not None:
1969
+ agent.max_tier = parse_tier(max_tier)
1970
+ if confirm is not None:
1971
+ agent_logger = getattr(agent, "run_logger", None)
1972
+ agent.run_logger = None
1973
+ try:
1974
+ selection = _propose_and_validate(
1975
+ agent, self.model, dev_data, hypotheses, context=context
1976
+ )
1977
+ finally:
1978
+ agent.run_logger = agent_logger
1979
+
1980
+ # Custom/legacy agents may return an opaque application-specific
1981
+ # result rather than FixOutcome. They cannot participate in the
1982
+ # built-in frozen-candidate confirmation protocol, but preserving
1983
+ # their return contract is preferable to turning a diagnostic run
1984
+ # into an AttributeError.
1985
+ if not hasattr(selection, "attempted"):
1986
+ self._set_fix_outcome(report, selection)
1987
+ return selection
1988
+
1989
+ outcome = _confirm_from_explore(
1990
+ agent,
1991
+ self.model,
1992
+ final_data,
1993
+ selection.attempted,
1994
+ max_tier=agent.max_tier,
1995
+ repair_rounds=selection.repair_rounds,
1996
+ )
1997
+ try:
1998
+ if agent_logger is not None:
1999
+ agent_logger.log_fix(outcome)
2000
+ except Exception as exc:
2001
+ logger.debug("run_fix: held-out log_fix failed: %s", exc)
2002
+ self._set_fix_outcome(report, outcome)
2003
+ return outcome
2004
+
2005
+ outcome = _propose_and_validate(agent, self.model, data, hypotheses, context=context)
2006
+ self._set_fix_outcome(report, outcome)
2007
+ return outcome
2008
+
2009
+
2010
+ def _confirm_from_explore(
2011
+ agent: "Any",
2012
+ model: "Any",
2013
+ confirm: "CaseBatch",
2014
+ attempted: "list[Any]",
2015
+ *,
2016
+ max_tier: "Any",
2017
+ repair_rounds: int,
2018
+ ) -> "Any":
2019
+ """Freeze the strongest EXPLORE improvement and test it once on CONFIRM."""
2020
+ from evalrx.eval_agent.stages.fix_agent import FixOutcome, plain_description
2021
+
2022
+ def _recommend(reason: str, validations: "list[Any]") -> "dict[str, Any] | None":
2023
+ routed = sorted({v.candidate.tier for v in validations})
2024
+ recommend = getattr(agent, "_recommend", None)
2025
+ if callable(recommend):
2026
+ try:
2027
+ return recommend(
2028
+ routed,
2029
+ model=model,
2030
+ data=confirm,
2031
+ reason_prefix=reason,
2032
+ )
2033
+ except TypeError:
2034
+ # Backward compatibility for application-specific FixAgents.
2035
+ pass
2036
+ return {"recommend_tier": max_tier.label, "reason": reason}
2037
+
2038
+ executed = [
2039
+ validation
2040
+ for validation in attempted
2041
+ if validation.n_pairs > 0 and validation.effect is not None
2042
+ ]
2043
+ improving = [
2044
+ validation
2045
+ for validation in executed
2046
+ if validation.n_fixed > validation.n_broken
2047
+ ]
2048
+ # A spectacular percentage on two applicable examples is a fragile lead,
2049
+ # not a better selection than a supported improvement on dozens. Require
2050
+ # up to eight EXPLORE pairs when that support exists anywhere in the
2051
+ # candidate family; small experiments still select from what they have.
2052
+ support_floor = min(8, max((v.n_pairs for v in improving), default=0))
2053
+ supported = [v for v in improving if v.n_pairs >= support_floor]
2054
+ selected = max(
2055
+ supported,
2056
+ key=lambda validation: (
2057
+ validation.e_value or 0.0,
2058
+ validation.n_fixed - validation.n_broken,
2059
+ validation.n_pairs,
2060
+ validation.effect or 0.0,
2061
+ -validation.n_broken,
2062
+ -int(validation.candidate.tier),
2063
+ ),
2064
+ default=None,
2065
+ )
2066
+ # `headline` travels with the audit row because the candidate object does
2067
+ # not: the contract emitter sees only these dicts, and a slug is the one
2068
+ # thing it must not show a reader.
2069
+ audit = [
2070
+ {
2071
+ "name": validation.candidate.name,
2072
+ "headline": plain_description(validation.candidate),
2073
+ "tier": validation.candidate.tier.label,
2074
+ "n_pairs": validation.n_pairs,
2075
+ "n_fixed": validation.n_fixed,
2076
+ "n_broken": validation.n_broken,
2077
+ "effect": validation.effect,
2078
+ "e_value": validation.e_value,
2079
+ "coverage": validation.coverage,
2080
+ "verdict": validation.verdict,
2081
+ }
2082
+ for validation in attempted
2083
+ ]
2084
+ if selected is None:
2085
+ return FixOutcome(
2086
+ max_tier=max_tier,
2087
+ repair_rounds=repair_rounds,
2088
+ selection_attempted=audit,
2089
+ recommendation=_recommend(
2090
+ "no EXPLORE candidate had positive net repairs; CONFIRM was left untouched",
2091
+ attempted,
2092
+ ),
2093
+ )
2094
+
2095
+ # ``max_validation_cases`` is an EXPLORE-search budget. Reusing that cap
2096
+ # here would silently throw away untouched confirmation cases exactly when
2097
+ # more power matters most. The candidate is already frozen, so validate it
2098
+ # on the complete CONFIRM partition and restore the agent's search budget
2099
+ # afterwards. Custom/legacy agents without this attribute keep their
2100
+ # existing call contract.
2101
+ selection_cap = getattr(agent, "max_validation_cases", None)
2102
+ if selection_cap is not None:
2103
+ agent.max_validation_cases = 0
2104
+ try:
2105
+ validation = agent.validate_candidate(model, confirm, selected.candidate)
2106
+ finally:
2107
+ if selection_cap is not None:
2108
+ agent.max_validation_cases = selection_cap
2109
+ survivors = agent._ebh_survivors(
2110
+ [validation] if validation.e_value is not None else []
2111
+ )
2112
+ survived = id(validation) in survivors
2113
+ outcome = FixOutcome(
2114
+ max_tier=max_tier,
2115
+ attempted=[validation],
2116
+ best=validation if validation.fixed and survived else None,
2117
+ fixed=bool(validation.fixed and survived),
2118
+ repair_rounds=repair_rounds,
2119
+ ebh_survivors=[validation.candidate.name] if survived else [],
2120
+ selection_attempted=audit,
2121
+ selected_on_explore=selected.candidate.name,
2122
+ )
2123
+ if not outcome.fixed:
2124
+ no_fix = getattr(agent, "_no_fix_recommendation", None)
2125
+ if callable(no_fix):
2126
+ outcome.recommendation = no_fix(
2127
+ [validation], [selected.candidate.tier], confirm, model
2128
+ )
2129
+ if outcome.recommendation is not None:
2130
+ outcome.recommendation["reason"] = (
2131
+ "the candidate selected on EXPLORE did not validate on untouched CONFIRM; "
2132
+ + outcome.recommendation["reason"]
2133
+ )
2134
+ else:
2135
+ outcome.recommendation = _recommend(
2136
+ "the candidate selected on EXPLORE did not validate on untouched CONFIRM",
2137
+ [validation],
2138
+ )
2139
+ outcome.refine_signal = agent._refine_signal([validation], confirm)
2140
+ return outcome
2141
+
2142
+
2143
+ def _analyzer_names_from_stats(stats_report: "Any") -> "list[str]":
2144
+ """Analyzer names whose signals the M2 report actually tested.
2145
+
2146
+ Recovered from each result's signal key (``"analyzer.metric"``) so a
2147
+ reloaded report (confirm-only mode) can pin the same analyzer set for the
2148
+ held-out M4 re-probe without the original probe schema.
2149
+ """
2150
+ names: "list[str]" = []
2151
+ for r in list(getattr(stats_report, "stats_results", None) or []):
2152
+ cfg = getattr(r, "config", None) or {}
2153
+ det = getattr(r, "details", None) or {}
2154
+ sig = str(cfg.get("signal") or det.get("signal") or "")
2155
+ if "." in sig:
2156
+ name = sig.split(".", 1)[0]
2157
+ if name and name not in names:
2158
+ names.append(name)
2159
+ return names