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,944 @@
1
+ """M2 — StatsAnalysisAgent: statistically analyze probe results.
2
+
3
+ Extends :class:`~evalrx.analysis.analysis_module.AnalysisModule` with:
4
+
5
+ - **Protocol-aware interpretation**: focuses the analysis narrative on what
6
+ the user's experiment was actually testing.
7
+ - **Richer output** (:class:`StatsAnalysisReport`): adds ``conclusion``,
8
+ ``evidence_chain``, and ``qualitative_findings`` on top of the base
9
+ :class:`~evalrx.analysis.analysis_module.AnalysisReport` fields so M3
10
+ (DiagnosisAgent) receives richer context.
11
+ - **LLM-guided path** (``judge=`` set): the LLM synthesizes a protocol-specific
12
+ narrative from the raw findings. The threshold-rules pass always runs first;
13
+ the LLM *enriches*, not replaces, its output.
14
+
15
+ Two analysis paths:
16
+
17
+ a. **Basic** (default, no judge): apply built-in threshold rules + build
18
+ conclusion/evidence_chain from the flagged anomalies. Fast, deterministic,
19
+ no API key needed.
20
+
21
+ b. **LLM-guided** (``judge=`` + ``protocol=`` at call time): judge reads the
22
+ protocol + raw findings and writes a protocol-specific conclusion.
23
+ Falls back to the basic path if the LLM call fails.
24
+
25
+ :class:`StatsAnalysisReport` inherits from :class:`AnalysisReport` so it is
26
+ accepted everywhere an ``AnalysisReport`` is expected (e.g. ``DiagnosisAgent``).
27
+
28
+ Usage::
29
+
30
+ # Basic path
31
+ agent = StatsAnalysisAgent()
32
+ report = agent.analyze(probe_results, model_name="qwen3-vl-8b")
33
+
34
+ # Protocol-guided (richer conclusion)
35
+ agent = StatsAnalysisAgent(judge=gemini)
36
+ report = agent.analyze(probe_results, protocol=protocol)
37
+ print(report.conclusion)
38
+ print(report.evidence_chain)
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import json
44
+ import logging
45
+ import os
46
+ import re
47
+ from dataclasses import dataclass, field
48
+ from typing import TYPE_CHECKING, Any
49
+
50
+ from evalrx.analysis.analysis_module import AnalysisModule, AnalysisReport
51
+ from evalrx.analysis.prompts.stats_agent import _ANALYSIS_PROMPT, _TOOL_SELECT_PROMPT
52
+ from evalrx.analysis.stats_tool_agent import StatsToolAgent as LegacyStatsToolAgent
53
+ from evalrx.analysis.stats_tools import (
54
+ STATS_TOOL_CATALOG,
55
+ StatsInput,
56
+ StatsToolResult,
57
+ build_stats_input,
58
+ build_stats_input_from_records,
59
+ default_plan,
60
+ describe_data,
61
+ fdr_correct,
62
+ has_testable_data,
63
+ plot_effects,
64
+ run_stats_tool,
65
+ )
66
+
67
+ if TYPE_CHECKING:
68
+ from evalrx.agent_runtime.cli_types import CliAgentConfig
69
+ from evalrx.analysis.stats_tool_generator import (
70
+ GeneratedStatsTool,
71
+ StatsToolGenerator,
72
+ )
73
+ from evalrx.core.case import CaseBatch
74
+ from evalrx.core.model import Model
75
+ from evalrx.core.result import Result
76
+ from evalrx.eval_agent.stages.protocol import ExperimentProtocol
77
+
78
+ logger = logging.getLogger(__name__)
79
+
80
+
81
+ def _select_tools_via_llm(
82
+ judge: "Model",
83
+ protocol: "ExperimentProtocol",
84
+ inp: StatsInput,
85
+ valid_names: set[str],
86
+ ) -> set[str]:
87
+ """Ask the judge to choose tool names from the catalog. Returns a name set.
88
+
89
+ Raises on any failure so the caller can fall back to the deterministic plan.
90
+ """
91
+ import inspect
92
+
93
+ catalog = "\n".join(f" - {name}: {desc}" for name, desc in STATS_TOOL_CATALOG.items())
94
+ prompt = _TOOL_SELECT_PROMPT.format(
95
+ protocol_text=protocol.prompt_text(),
96
+ tool_catalog=catalog,
97
+ data_shape=json.dumps(describe_data(inp), indent=2),
98
+ )
99
+ sig = inspect.signature(judge.generate)
100
+ if "temperature" in sig.parameters:
101
+ raw = judge.generate(prompt, temperature=0)
102
+ else:
103
+ raw = judge.generate(prompt)
104
+
105
+ cleaned = re.sub(r"<think>.*?</think>", "", str(raw), flags=re.DOTALL).strip()
106
+ match = re.search(r"\{[^{}]*\}", cleaned)
107
+ if match:
108
+ data = json.loads(match.group())
109
+ names = {n.lower() for n in data.get("tools", []) if n.lower() in valid_names}
110
+ if names:
111
+ return names
112
+ # soft fallback: scan for any known tool name
113
+ low = cleaned.lower()
114
+ return {n for n in valid_names if n in low}
115
+
116
+
117
+ #: A section label as an LLM actually writes it. The original matcher required
118
+ #: a literal "CONCLUSION:" at line start; a judge that wrote the markdown
119
+ #: heading "## CONCLUSION" entered no section at all, so a 4,158-character
120
+ #: analysis fell through to the base narrative's first line
121
+ #: ("Model: EndpointModel(qwen3.5-2b)") and M3 had nothing to hypothesise from —
122
+ #: the run then reported stopped_by=no_hypotheses as if that were a finding.
123
+ #: Earlier runs parsed only because the judge happened to use the colon form.
124
+ _SECTION_HEADER = re.compile(
125
+ r"^[#*\s]*(CONCLUSION|EVIDENCE_CHAIN|QUALITATIVE)\s*[::]?[*#\s]*(.*)$",
126
+ re.IGNORECASE,
127
+ )
128
+
129
+
130
+ def _parse_llm_analysis(
131
+ raw: str,
132
+ base: AnalysisReport,
133
+ ) -> tuple[str, list[str], list[str]]:
134
+ """Parse LLM output into (conclusion, evidence_chain, qualitative_findings).
135
+
136
+ Returns "" for conclusion when the judge's response didn't contain a
137
+ parseable CONCLUSION: section (empty/error response, quota message,
138
+ wrong format, ...) -- NOT a synthesized fallback string. The only caller
139
+ (_analyze_llm_guided) already built a real, statistically-grounded
140
+ conclusion via _build_conclusion() before this parse ever runs and only
141
+ overwrites it `if conclusion:` — inventing a fallback here (this used to
142
+ return base.narrative's first line, which is just "Model: <repr>") meant
143
+ a failed/malformed judge call silently threw away the good deterministic
144
+ conclusion and replaced it with a useless one, with no error anywhere.
145
+ Measured live: a judge call that hit its account's spend limit returned
146
+ the CLI's plain-text quota message as `raw`, which parses to nothing
147
+ here — before this fix, that overwrote a real "statistically supported:
148
+ signal X flips on FAIL, effect +0.83" conclusion with "Model: <object
149
+ repr>", and M3 saw the latter.
150
+ """
151
+ conclusion = ""
152
+ evidence: list[str] = []
153
+ qualitative: list[str] = []
154
+ section: str | None = None
155
+
156
+ for line in raw.splitlines():
157
+ s = line.strip()
158
+ upper = s.upper()
159
+ header = _SECTION_HEADER.match(s)
160
+ if header:
161
+ section = header.group(1).upper()
162
+ section = {"CONCLUSION": "conclusion",
163
+ "EVIDENCE_CHAIN": "evidence",
164
+ "QUALITATIVE": "qualitative"}[section]
165
+ rest = header.group(2).strip()
166
+ if section == "conclusion" and rest:
167
+ conclusion = rest
168
+ elif s.startswith("- "):
169
+ content = s[2:].strip()
170
+ if section == "evidence":
171
+ evidence.append(content)
172
+ elif section == "qualitative":
173
+ qualitative.append(content)
174
+ elif section == "conclusion" and s and not upper.startswith(("EVIDENCE", "QUALITATIVE")):
175
+ conclusion = (conclusion + " " + s).strip()
176
+
177
+ return conclusion, evidence, qualitative
178
+
179
+
180
+ # ---------------------------------------------------------------------------
181
+ # Output type
182
+ # ---------------------------------------------------------------------------
183
+
184
+ @dataclass
185
+ class StatsAnalysisReport(AnalysisReport):
186
+ """M2 output: a richer, protocol-aware analysis report.
187
+
188
+ Inherits all :class:`~evalrx.analysis.analysis_module.AnalysisReport`
189
+ fields so it is accepted everywhere that type is expected.
190
+
191
+ Additional attributes:
192
+ conclusion: NL paragraph summarising what was found.
193
+ evidence_chain: Step-by-step derivation of the conclusion.
194
+ qualitative_findings: Patterns or anomalies noted in free text.
195
+ stats_tool: ``"threshold_rules"`` (basic), ``"llm_guided"``
196
+ (basic + LLM narrative), or ``"selected_tools"``
197
+ (statistical tools from the catalog were run).
198
+ stats_results: Per-tool :class:`StatsToolResult` verdicts.
199
+ stats_plan: ``[{tool, config, rationale}]`` — which tools
200
+ were selected/generated and why (M2's analog of
201
+ M1's ProbingSchema).
202
+ corrected_rejections: e-BH FDR correction across all tool e-values.
203
+ figures: Paths to generated figures (forest plot, …).
204
+ stats_tool_results: Backward-compatible JSON-safe stats summaries.
205
+ visualizations: Backward-compatible figure/spec list.
206
+ protocol: The protocol that guided this analysis, if any.
207
+ llm_fallback_reason: Why the LLM-guided path did not produce this
208
+ report. Empty when it was never attempted (no
209
+ judge) or when it succeeded — non-empty ONLY
210
+ when it was tried and raised.
211
+ """
212
+
213
+ conclusion: str = ""
214
+ evidence_chain: list[str] = field(default_factory=list)
215
+ qualitative_findings: list[str] = field(default_factory=list)
216
+ stats_tool: str = "threshold_rules"
217
+ stats_results: list[StatsToolResult] = field(default_factory=list)
218
+ stats_plan: list[dict[str, Any]] = field(default_factory=list)
219
+ corrected_rejections: dict[str, Any] = field(default_factory=dict)
220
+ figures: list[str] = field(default_factory=list)
221
+ stats_tool_results: list[dict[str, Any]] = field(default_factory=list)
222
+ visualizations: list[dict[str, Any]] = field(default_factory=list)
223
+ protocol: "ExperimentProtocol | None" = None
224
+ # When True the report carries effect sizes + charts but NO validity verdict:
225
+ # e-BH FDR correction was NOT run (``corrected_rejections`` is deferred). The
226
+ # analysis phase uses this so the dashboard shows distributions only; the
227
+ # confirm phase recomputes e-BH and flips this off. See VLDiagnoseLoop.
228
+ descriptive_only: bool = False
229
+ # Judge I/O for the LLM-guided path (empty on the threshold-rules path).
230
+ # Surfaced so RunLogger can persist exactly what the M2 judge was shown.
231
+ llm_prompt: str = ""
232
+ llm_raw: str = ""
233
+ # Why the LLM path did not produce this report. Non-empty ONLY after it was
234
+ # attempted and raised, so "" never has to be read as "it worked".
235
+ llm_fallback_reason: str = ""
236
+
237
+ def to_dict(self) -> dict[str, Any]:
238
+ d = super().to_dict()
239
+ d.update({
240
+ "conclusion": self.conclusion,
241
+ "evidence_chain": self.evidence_chain,
242
+ "qualitative_findings": self.qualitative_findings,
243
+ "stats_tool": self.stats_tool,
244
+ "llm_fallback_reason": self.llm_fallback_reason,
245
+ "stats_results": [r.to_dict() for r in self.stats_results],
246
+ "stats_plan": self.stats_plan,
247
+ "corrected_rejections": self.corrected_rejections,
248
+ "figures": self.figures,
249
+ "stats_tool_results": self.stats_tool_results,
250
+ "visualizations": self.visualizations,
251
+ "descriptive_only": self.descriptive_only,
252
+ })
253
+ return d
254
+
255
+
256
+ # ---------------------------------------------------------------------------
257
+ # Agent
258
+ # ---------------------------------------------------------------------------
259
+
260
+ class StatsAnalysisAgent:
261
+ """M2: statistically analyze probe results guided by the experiment protocol.
262
+
263
+ Args:
264
+ judge: Optional LLM for the LLM-guided analysis path.
265
+ Any :class:`~evalrx.core.model.Model` with
266
+ ``Capability.GENERATE``. Pass ``None`` to use
267
+ threshold rules only.
268
+ extra_rules: Additional ``{analyzer_name: [_Rule, …]}`` entries
269
+ merged with the built-in rules.
270
+ """
271
+
272
+ def __init__(
273
+ self,
274
+ judge: "Model | None" = None,
275
+ extra_rules: dict | None = None,
276
+ stats_tool_agent: Any | None = None,
277
+ enable_stats_tools: bool = True,
278
+ figure_dir: "str | None" = None,
279
+ max_signal_tools: int | None = None,
280
+ allow_codegen: bool = False,
281
+ codegen_config: "CliAgentConfig | None" = None,
282
+ tool_generator: "StatsToolGenerator | None" = None,
283
+ run_logger: "Any | None" = None,
284
+ ) -> None:
285
+ self._base = AnalysisModule(extra_rules)
286
+ self._judge = judge
287
+ self._stats_tool_agent = stats_tool_agent or LegacyStatsToolAgent()
288
+ self._enable_stats_tools = enable_stats_tools
289
+ self._figure_dir = figure_dir
290
+ self._max_signal_tools = max_signal_tools
291
+ # Tier (b): generate a bespoke stats tool in a sandbox when no catalog
292
+ # tool fits. Disabled unless allow_codegen=True and a backend exists.
293
+ self._allow_codegen = allow_codegen
294
+ self._codegen_config = codegen_config
295
+ self._tool_generator = tool_generator
296
+ # Tier (c): generated tools are cached here and re-run on later cycles
297
+ # without a second LLM call.
298
+ self._generated_tools: list[GeneratedStatsTool] = []
299
+ # Optional RunLogger forwarded to the tool generator so its code-writing
300
+ # attempts surface as "tool_codegen" events. The loop sets this if unset.
301
+ self.run_logger = run_logger
302
+
303
+ def analyze(
304
+ self,
305
+ results: "dict[str, Result]",
306
+ model_name: str = "",
307
+ protocol: "ExperimentProtocol | None" = None,
308
+ data: "CaseBatch | None" = None,
309
+ extra_figures: "list | None" = None,
310
+ confirmatory: bool = True,
311
+ ) -> StatsAnalysisReport:
312
+ """Analyze *results* into a :class:`StatsAnalysisReport`.
313
+
314
+ Args:
315
+ results: ``{analyzer_name: Result}`` dict from M1.
316
+ model_name: Human-readable model identifier.
317
+ protocol: Experiment protocol for context-aware interpretation.
318
+ When ``None``, the analysis is protocol-agnostic.
319
+ data: Labeled cases from M1. Required to run the statistical
320
+ tool layer (per-case fail/pass labels). When ``None``
321
+ or unlabeled, M2 falls back to threshold rules only.
322
+
323
+ Returns:
324
+ :class:`StatsAnalysisReport` — backward-compatible with
325
+ :class:`~evalrx.analysis.analysis_module.AnalysisReport`.
326
+ """
327
+ base = self._base.analyze(results, model_name)
328
+ legacy_tool_results = self._run_legacy_stats_tools(results, protocol)
329
+ inp = build_stats_input(results, data)
330
+
331
+ return self._analyze_from_input(
332
+ inp=inp,
333
+ base=base,
334
+ protocol=protocol,
335
+ results=results,
336
+ extra_figures=extra_figures,
337
+ legacy_tool_results=legacy_tool_results,
338
+ confirmatory=confirmatory,
339
+ )
340
+
341
+ def analyze_input(
342
+ self,
343
+ inp: StatsInput,
344
+ *,
345
+ model_name: str = "",
346
+ protocol: "ExperimentProtocol | None" = None,
347
+ extra_figures: "list | None" = None,
348
+ ) -> StatsAnalysisReport:
349
+ """Analyze a pre-normalized :class:`StatsInput`.
350
+
351
+ This is the standalone path for callers that have already prepared
352
+ labels, per-case signals, scalar metrics, or strategy groups and do not
353
+ need to run M1 analyzers first.
354
+ """
355
+ base = AnalysisReport(
356
+ model_name=model_name,
357
+ findings=[],
358
+ severity="none",
359
+ narrative=_build_input_narrative(model_name, inp),
360
+ raw_results={},
361
+ )
362
+ return self._analyze_from_input(
363
+ inp=inp,
364
+ base=base,
365
+ protocol=protocol,
366
+ results={},
367
+ extra_figures=extra_figures,
368
+ legacy_tool_results=[],
369
+ )
370
+
371
+ def analyze_records(
372
+ self,
373
+ records: "Any",
374
+ *,
375
+ id_col: str = "case_id",
376
+ label_col: str = "label",
377
+ signal_cols: "list[str] | tuple[str, ...] | None" = None,
378
+ scalar_cols: "list[str] | tuple[str, ...] | None" = None,
379
+ signal_prefix: str = "",
380
+ model_name: str = "",
381
+ protocol: "ExperimentProtocol | None" = None,
382
+ extra_figures: "list | None" = None,
383
+ ) -> StatsAnalysisReport:
384
+ """Analyze plain row dictionaries without constructing M1 ``Result`` objects."""
385
+ inp = build_stats_input_from_records(
386
+ records,
387
+ id_col=id_col,
388
+ label_col=label_col,
389
+ signal_cols=signal_cols,
390
+ scalar_cols=scalar_cols,
391
+ signal_prefix=signal_prefix,
392
+ )
393
+ return self.analyze_input(
394
+ inp,
395
+ model_name=model_name,
396
+ protocol=protocol,
397
+ extra_figures=extra_figures,
398
+ )
399
+
400
+ def _analyze_from_input(
401
+ self,
402
+ *,
403
+ inp: StatsInput,
404
+ base: AnalysisReport,
405
+ protocol: "ExperimentProtocol | None",
406
+ results: "dict[str, Result]",
407
+ extra_figures: "list | None",
408
+ legacy_tool_results: list[Any],
409
+ confirmatory: bool = True,
410
+ ) -> StatsAnalysisReport:
411
+ """Shared implementation for loop-driven and standalone M2 analysis.
412
+
413
+ ``confirmatory=False`` runs the tools (effect sizes + charts) but DEFERS
414
+ the validity verdict: e-BH FDR correction is skipped and the report is
415
+ marked ``descriptive_only`` so the dashboard shows distributions without
416
+ a supported/not-supported claim. The confirm phase recomputes e-BH.
417
+ """
418
+
419
+ # ── Statistical tool layer (select → run → [FDR-correct] → plot) ──
420
+ stats_results, stats_plan, corrected, figures = self._run_stats_tools_input(
421
+ inp, protocol, confirmatory=confirmatory
422
+ )
423
+
424
+ # Merge in analyzer heatmap PNGs forwarded from the run logger.
425
+ if extra_figures:
426
+ from pathlib import Path as _Path
427
+ figures = figures + [
428
+ str(p) for p in extra_figures
429
+ if _Path(p).exists() and str(p).endswith(".png")
430
+ ]
431
+
432
+ report: "StatsAnalysisReport | None" = None
433
+ fallback_reason = ""
434
+ if self._judge is not None and protocol is not None:
435
+ try:
436
+ report = self._analyze_llm_guided(
437
+ base, results, protocol,
438
+ stats_results, stats_plan, corrected, figures,
439
+ legacy_tool_results,
440
+ )
441
+ except Exception as exc:
442
+ # A judge that never answered and a judge that found nothing
443
+ # produce the SAME downstream run: threshold narrative, M3 with
444
+ # nothing to hypothesise from, stopped_by=no_hypotheses, rc=0.
445
+ # The reason has to travel with the report — a logger.warning
446
+ # here reached neither the console nor run_log.jsonl, so an
447
+ # OSError(E2BIG) on the prompt left no trace anywhere in the run.
448
+ fallback_reason = f"{type(exc).__name__}: {exc}"
449
+ logger.warning("LLM-guided M2 analysis failed, falling back: %s", exc)
450
+ if report is None:
451
+ report = self._to_stats_report(
452
+ base, protocol, stats_results, stats_plan, corrected, figures,
453
+ legacy_tool_results,
454
+ )
455
+ report.llm_fallback_reason = fallback_reason
456
+ report.descriptive_only = not confirmatory
457
+ return report
458
+
459
+ # ------------------------------------------------------------------
460
+ # Statistical tool layer
461
+ # ------------------------------------------------------------------
462
+
463
+ def _run_stats_tools(
464
+ self,
465
+ results: "dict[str, Result]",
466
+ data: "CaseBatch | None",
467
+ protocol: "ExperimentProtocol | None",
468
+ ) -> tuple[list[StatsToolResult], list[dict[str, Any]], dict[str, Any], list[str]]:
469
+ """Build input, select and run tools, FDR-correct, and plot.
470
+
471
+ Returns ``(stats_results, stats_plan, corrected, figures)``. All empty
472
+ when there is no labeled/grouped data to test (pure backward compat).
473
+ """
474
+ inp = build_stats_input(results, data)
475
+ return self._run_stats_tools_input(inp, protocol)
476
+
477
+ def _run_stats_tools_input(
478
+ self,
479
+ inp: StatsInput,
480
+ protocol: "ExperimentProtocol | None",
481
+ confirmatory: bool = True,
482
+ ) -> tuple[list[StatsToolResult], list[dict[str, Any]], dict[str, Any], list[str]]:
483
+ """Run the statistical tool layer over already-normalized input.
484
+
485
+ ``confirmatory=False`` skips the e-BH FDR correction (the validity
486
+ verdict) — the per-tool effect sizes / CIs are still computed (the
487
+ charts need them), but the family-level reject decision is deferred to
488
+ the confirm phase.
489
+ """
490
+ if not self._enable_stats_tools or not has_testable_data(inp):
491
+ return [], [], {}, []
492
+
493
+ plan = self._select_plan(inp, protocol)
494
+
495
+ stats_results: list[StatsToolResult] = []
496
+ for tool, cfg, rationale in plan:
497
+ try:
498
+ r = run_stats_tool(tool, inp, cfg)
499
+ except Exception as exc: # never let one tool sink the analysis
500
+ logger.warning("stats tool %r failed: %s", tool, exc)
501
+ r = StatsToolResult(
502
+ tool=tool, config=cfg, ok=False,
503
+ error=str(exc), summary=f"{tool}: {exc}",
504
+ )
505
+ r.details.setdefault("rationale", rationale)
506
+ stats_results.append(r)
507
+
508
+ stats_plan = [
509
+ {"tool": t, "config": c, "rationale": rat} for t, c, rat in plan
510
+ ]
511
+
512
+ # Tier (b)/(c): reuse previously generated tools, then generate a new one
513
+ # if the catalog produced nothing usable for this data.
514
+ gen_results, gen_plan = self._run_generated_tools(inp, protocol, stats_results)
515
+ stats_results.extend(gen_results)
516
+ stats_plan.extend(gen_plan)
517
+
518
+ # e-BH FDR correction is the validity verdict — deferred in descriptive
519
+ # (analysis-phase) mode; the confirm phase recomputes it.
520
+ corrected = (
521
+ fdr_correct(stats_results) if confirmatory
522
+ else {"method": "e-BH", "deferred": True, "n_tested": 0, "rejected_tools": [],
523
+ "note": "validity deferred to the confirm phase (descriptive_only)"}
524
+ )
525
+
526
+ figures: list[str] = []
527
+ if self._figure_dir:
528
+ fig = plot_effects(
529
+ stats_results, os.path.join(self._figure_dir, "m2_effects.png")
530
+ )
531
+ if fig:
532
+ figures.append(fig)
533
+
534
+ return stats_results, stats_plan, corrected, figures
535
+
536
+ def _run_legacy_stats_tools(
537
+ self,
538
+ results: "dict[str, Result]",
539
+ protocol: "ExperimentProtocol | None",
540
+ ) -> list[Any]:
541
+ """Run the older lightweight M2 tools for backward-compatible reports."""
542
+ if not self._enable_stats_tools or self._stats_tool_agent is None:
543
+ return []
544
+ try:
545
+ return list(self._stats_tool_agent.analyze(results, protocol=protocol))
546
+ except Exception as exc:
547
+ logger.warning("legacy M2 stats-tool analysis failed: %s", exc)
548
+ return []
549
+
550
+ def _run_generated_tools(
551
+ self,
552
+ inp: StatsInput,
553
+ protocol: "ExperimentProtocol | None",
554
+ catalog_results: list[StatsToolResult],
555
+ ) -> tuple[list[StatsToolResult], list[dict[str, Any]]]:
556
+ """Reuse cached generated tools (tier c) and generate a new one (tier b).
557
+
558
+ Generation fires only when ``allow_codegen`` is set, a backend exists,
559
+ and no catalog or cached tool produced a usable (``ok``) result.
560
+ """
561
+ if not self._allow_codegen:
562
+ return [], []
563
+ generator = self._get_generator()
564
+ if generator is None:
565
+ return [], []
566
+
567
+ results: list[StatsToolResult] = []
568
+ plan: list[dict[str, Any]] = []
569
+
570
+ # Tier (c): re-run cached generated tools on this cycle's data.
571
+ for tool in self._generated_tools:
572
+ r = generator.run_cached(tool, inp)
573
+ results.append(r)
574
+ plan.append({"tool": f"generated:{tool.name}", "config": {},
575
+ "rationale": f"reuse generated tool ({tool.source})"})
576
+
577
+ usable = any(r.ok for r in catalog_results) or any(r.ok for r in results)
578
+ if usable:
579
+ return results, plan
580
+
581
+ # Tier (b): nothing fit — synthesise a new tool.
582
+ need = (
583
+ (protocol.prompt_text() + " " if protocol else "")
584
+ + "No built-in statistical tool fit this data; write a test for "
585
+ "whether the analyzer signals predict case FAIL."
586
+ )
587
+ name = f"custom{len(self._generated_tools) + 1}"
588
+ result, tool = generator.generate(need, inp, name=name)
589
+ results.append(result)
590
+ plan.append({"tool": f"generated:{name}", "config": {},
591
+ "rationale": "generated: no catalog tool fit the data"})
592
+ if tool is not None:
593
+ self._generated_tools.append(tool)
594
+ return results, plan
595
+
596
+ def _get_generator(self) -> "StatsToolGenerator | None":
597
+ """Lazily build the tool generator from the judge / codegen config."""
598
+ if self._tool_generator is not None:
599
+ if self.run_logger is not None and getattr(self._tool_generator, "run_logger", None) is None:
600
+ try:
601
+ self._tool_generator.run_logger = self.run_logger
602
+ except Exception: # noqa: BLE001
603
+ pass
604
+ return self._tool_generator
605
+ if self._judge is None and self._codegen_config is None:
606
+ return None
607
+ from evalrx.analysis.stats_tool_generator import StatsToolGenerator
608
+ gen = StatsToolGenerator(
609
+ judge=self._judge, cli_config=self._codegen_config, run_logger=self.run_logger
610
+ )
611
+ if not gen.available:
612
+ return None
613
+ self._tool_generator = gen
614
+ return gen
615
+
616
+ def _select_plan(
617
+ self,
618
+ inp: StatsInput,
619
+ protocol: "ExperimentProtocol | None",
620
+ ) -> list[tuple[str, dict, str]]:
621
+ """Select tools: LLM picks from the catalog, deterministic plan otherwise.
622
+
623
+ The deterministic plan is always the backbone (it guarantees coverage);
624
+ when a judge + protocol are present, the LLM *narrows* it to the chosen
625
+ tools. Any failure or empty selection falls back to the full plan.
626
+ """
627
+ plan = default_plan(inp, max_signals=self._max_signal_tools)
628
+ if self._judge is None or protocol is None or not plan:
629
+ return plan
630
+ try:
631
+ chosen = _select_tools_via_llm(
632
+ self._judge, protocol, inp, set(STATS_TOOL_CATALOG)
633
+ )
634
+ except Exception as exc:
635
+ logger.debug("LLM stats-tool selection failed, using default plan: %s", exc)
636
+ return plan
637
+ if not chosen:
638
+ return plan
639
+ # Paired/omnibus tools (mcnemar, friedman, attention_decoding) only enter
640
+ # the plan when their inputs exist — they carry causal / tensor-level
641
+ # verdicts and must never be silently dropped by name-based narrowing.
642
+ _mandatory = {"mcnemar_evalue", "friedman_nemenyi", "attention_decoding"}
643
+ filtered = [p for p in plan if p[0] in chosen or p[0] in _mandatory]
644
+ return filtered or plan
645
+
646
+ # ------------------------------------------------------------------
647
+ # Report assembly
648
+ # ------------------------------------------------------------------
649
+
650
+ def _to_stats_report(
651
+ self,
652
+ base: AnalysisReport,
653
+ protocol: "ExperimentProtocol | None",
654
+ stats_results: list[StatsToolResult] | None = None,
655
+ stats_plan: list[dict[str, Any]] | None = None,
656
+ corrected: dict[str, Any] | None = None,
657
+ figures: list[str] | None = None,
658
+ legacy_tool_results: list[Any] | None = None,
659
+ ) -> StatsAnalysisReport:
660
+ """Basic path: wrap AnalysisReport + stats-tool results into a report."""
661
+ stats_results = stats_results or []
662
+ stats_plan = stats_plan or []
663
+ corrected = corrected or {}
664
+ figures = figures or []
665
+ legacy_tool_results = legacy_tool_results or []
666
+
667
+ chain: list[str] = []
668
+ if base.findings:
669
+ chain.append(
670
+ f"Applied threshold rules across {len(base.raw_results)} "
671
+ f"analyzer(s): {', '.join(sorted(base.raw_results))}"
672
+ )
673
+ for f in base.findings:
674
+ cmp = ">" if f.direction == "above" else "<"
675
+ chain.append(
676
+ f" [{f.severity.upper()}] {f.analyzer}.{f.metric}="
677
+ f"{f.value:.3g} {cmp} {f.threshold}: {f.message}"
678
+ )
679
+ chain.append(f"Overall severity: {base.severity}")
680
+ elif base.raw_results:
681
+ chain.append(
682
+ f"All metrics within normal ranges across "
683
+ f"{len(base.raw_results)} analyzer(s)."
684
+ )
685
+
686
+ # Append statistical verdicts to the evidence chain.
687
+ for r in stats_results:
688
+ if r.ok:
689
+ chain.append(f"[stats:{r.tool}] {r.summary}")
690
+ else:
691
+ chain.append(f"[stats:{r.tool}] N/A ({r.error})")
692
+ if corrected.get("rejected_tools"):
693
+ chain.append(
694
+ f"FDR (e-BH) survivors: {', '.join(corrected['rejected_tools'])}"
695
+ )
696
+
697
+ qualitative = [str(f) for f in base.findings]
698
+ conclusion = _build_conclusion(base, protocol, stats_results)
699
+ if any(r.tool.startswith("generated:") for r in stats_results):
700
+ stats_tool = "generated"
701
+ elif stats_results:
702
+ stats_tool = "selected_tools"
703
+ else:
704
+ stats_tool = "threshold_rules"
705
+
706
+ return StatsAnalysisReport(
707
+ # AnalysisReport fields
708
+ model_name=base.model_name,
709
+ findings=base.findings,
710
+ severity=base.severity,
711
+ narrative=base.narrative,
712
+ raw_results=base.raw_results,
713
+ # StatsAnalysisReport extras
714
+ conclusion=conclusion,
715
+ evidence_chain=chain,
716
+ qualitative_findings=qualitative,
717
+ stats_tool=stats_tool,
718
+ stats_results=stats_results,
719
+ stats_plan=stats_plan,
720
+ corrected_rejections=corrected,
721
+ figures=figures,
722
+ stats_tool_results=(
723
+ _serialize_legacy_tool_results(legacy_tool_results)
724
+ or _compat_stats_tool_results(stats_results)
725
+ ),
726
+ visualizations=(
727
+ _legacy_visualizations(legacy_tool_results)
728
+ or [{"type": "figure", "path": p} for p in figures]
729
+ ),
730
+ protocol=protocol,
731
+ )
732
+
733
+ def _analyze_llm_guided(
734
+ self,
735
+ base: AnalysisReport,
736
+ results: "dict[str, Result]",
737
+ protocol: "ExperimentProtocol",
738
+ stats_results: list[StatsToolResult],
739
+ stats_plan: list[dict[str, Any]],
740
+ corrected: dict[str, Any],
741
+ figures: list[str],
742
+ legacy_tool_results: list[Any],
743
+ ) -> StatsAnalysisReport:
744
+ """LLM-guided path: judge synthesises protocol-specific insights.
745
+
746
+ The judge now also sees the statistical verdicts so its conclusion is
747
+ grounded in effect sizes + e-values, not just raw findings.
748
+ """
749
+ findings_json = json.dumps(
750
+ {r: res.findings for r, res in results.items()},
751
+ indent=2,
752
+ default=str,
753
+ )
754
+ stats_block = _format_stats_for_prompt(stats_results, corrected)
755
+ prompt = _ANALYSIS_PROMPT.format(
756
+ protocol_text=protocol.prompt_text(),
757
+ task_domain=protocol.task_domain or "general",
758
+ narrative=base.narrative + stats_block,
759
+ findings_json=findings_json,
760
+ )
761
+ import inspect as _inspect
762
+ from pathlib import Path as _Path
763
+ _sig = _inspect.signature(self._judge.generate) # type: ignore[union-attr]
764
+ if "images" in _sig.parameters and figures:
765
+ _imgs = [_Path(f) for f in figures if _Path(f).exists()]
766
+ raw = self._judge.generate(prompt, images=_imgs or None) # type: ignore[union-attr]
767
+ else:
768
+ raw = self._judge.generate(prompt) # type: ignore[union-attr]
769
+ conclusion, evidence, qualitative = _parse_llm_analysis(str(raw), base)
770
+
771
+ report = self._to_stats_report(
772
+ base, protocol, stats_results, stats_plan, corrected, figures,
773
+ legacy_tool_results,
774
+ )
775
+ if conclusion:
776
+ report.conclusion = conclusion
777
+ if evidence:
778
+ report.evidence_chain = evidence
779
+ if qualitative:
780
+ report.qualitative_findings = qualitative
781
+ report.stats_tool = "llm_guided"
782
+ report.llm_prompt = prompt
783
+ report.llm_raw = str(raw)
784
+ return report
785
+
786
+
787
+ def _build_conclusion(
788
+ base: AnalysisReport,
789
+ protocol: "ExperimentProtocol | None",
790
+ stats_results: list[StatsToolResult] | None = None,
791
+ ) -> str:
792
+ """Build a short NL conclusion from the base report + stats + optional protocol."""
793
+ domain = f"For {protocol.task_domain} tasks: " if (protocol and protocol.task_domain) else ""
794
+
795
+ # Prefer a statistically supported verdict when one exists.
796
+ supported = [
797
+ r for r in (stats_results or [])
798
+ if r.ok and r.reject and r.effect is not None
799
+ ]
800
+ stat_msg = ""
801
+ if supported:
802
+ top = max(supported, key=lambda r: abs(r.effect or 0.0))
803
+ stat_msg = f" Statistically supported: {top.summary}"
804
+
805
+ if not base.raw_results and base.narrative:
806
+ if stat_msg:
807
+ return f"{domain}Standalone statistical analysis found a supported signal.{stat_msg}"
808
+ return f"{domain}Standalone statistical analysis found no supported signal."
809
+
810
+ if not base.findings:
811
+ if stat_msg:
812
+ return f"{domain}No threshold violations, but a statistical test fired.{stat_msg}"
813
+ msg = "No threshold violations detected; all metrics within normal ranges."
814
+ return f"{domain}{msg}"
815
+
816
+ top_f = base.findings[0]
817
+ msg = (
818
+ f"Primary anomaly: {top_f.message} "
819
+ f"({top_f.analyzer}.{top_f.metric}={top_f.value:.3g}, severity={top_f.severity})."
820
+ )
821
+ if len(base.findings) > 1:
822
+ msg += f" {len(base.findings) - 1} additional finding(s) also flagged."
823
+ return f"{domain}{msg}{stat_msg}"
824
+
825
+
826
+ def _build_input_narrative(model_name: str, inp: StatsInput) -> str:
827
+ """Human-readable summary for standalone StatsInput analysis."""
828
+ d = describe_data(inp)
829
+ lines = [f"Model: {model_name}" if model_name else "Model: unspecified"]
830
+ lines.append("Input: standalone statistical dataset")
831
+ lines.append(
832
+ f"Labeled cases: {d['n_labeled']} "
833
+ f"({d['n_fail']} FAIL, {d['n_pass']} PASS)"
834
+ )
835
+ if d["per_case_signals"]:
836
+ lines.append(f"Per-case signals: {', '.join(d['per_case_signals'])}")
837
+ else:
838
+ lines.append("Per-case signals: none")
839
+ if d["scalar_metrics"]:
840
+ lines.append(f"Scalar metrics: {', '.join(d['scalar_metrics'])}")
841
+ if d["n_strategy_groups"]:
842
+ lines.append(f"Strategy groups: {d['n_strategy_groups']}")
843
+ return "\n".join(lines)
844
+
845
+
846
+ def _compat_stats_tool_results(stats_results: list[StatsToolResult]) -> list[dict[str, Any]]:
847
+ """Return the legacy JSON shape used by older logs/examples."""
848
+ out: list[dict[str, Any]] = []
849
+ for result in stats_results:
850
+ payload = result.to_dict()
851
+ payload["name"] = result.tool
852
+ payload["conclusion"] = result.summary or result.error or ""
853
+ payload["visualizations"] = (
854
+ [{"type": "figure", "path": result.figure_path}]
855
+ if result.figure_path
856
+ else []
857
+ )
858
+ out.append(payload)
859
+ return out
860
+
861
+
862
+ def _serialize_legacy_tool_results(tool_results: list[Any]) -> list[dict[str, Any]]:
863
+ payloads: list[dict[str, Any]] = []
864
+ for result in tool_results:
865
+ if hasattr(result, "to_dict"):
866
+ payloads.append(result.to_dict())
867
+ elif isinstance(result, dict):
868
+ payloads.append(dict(result))
869
+ return payloads
870
+
871
+
872
+ def _legacy_visualizations(tool_results: list[Any]) -> list[dict[str, Any]]:
873
+ visualizations: list[dict[str, Any]] = []
874
+ for result in tool_results:
875
+ visualizations.extend(getattr(result, "visualizations", []) or [])
876
+ return visualizations
877
+
878
+
879
+ def _multiplicity_note(result: StatsToolResult) -> str:
880
+ """Per-result multiplicity verdict, or "" for a result outside every family.
881
+
882
+ ``correction_method`` is set by :func:`~evalrx.stats.multiplicity.correct_results`
883
+ on family members only, so its absence means "descriptive, never corrected" —
884
+ which must not be rendered as a failure to survive.
885
+ """
886
+ method = result.correction_method
887
+ if not method:
888
+ return ""
889
+ bits = ["survived" if result.fdr_corrected else "NOT survived"]
890
+ if result.p_value is not None:
891
+ bits.append(f"p={result.p_value:.3g}")
892
+ if result.e_value is not None:
893
+ bits.append(f"e={result.e_value:.3g}")
894
+ n_signal = (result.details or {}).get("n_signal")
895
+ if n_signal is not None:
896
+ bits.append(f"n_signal={n_signal}")
897
+ return f" [{method}: {', '.join(bits)}]"
898
+
899
+
900
+ def _format_stats_for_prompt(
901
+ stats_results: list[StatsToolResult],
902
+ corrected: dict[str, Any],
903
+ ) -> str:
904
+ """Render statistical verdicts as a block appended to the LLM narrative.
905
+
906
+ Every line carries its OWN multiplicity verdict, because two separate things
907
+ otherwise mislead the reader in the same direction:
908
+
909
+ * ``summary`` is baked when the tool RUNS, before any correction, so a line
910
+ ending "-> REJECT H0" is the uncorrected verdict. On qwen3.5-2b /
911
+ bbh_word_sorting, 23 of 42 ``signal_label_assoc`` results printed
912
+ REJECT H0 while BH kept 13; of the other ten, one rested on a single case
913
+ at permutation p = 1.000.
914
+ * The footer used to list ``rejected_tools`` — a set of TOOL NAMES. Every
915
+ per-signal test shares the name ``signal_label_assoc``, so a 42-signal
916
+ family collapsed to the single word "signal_label_assoc" and no reader
917
+ could tell WHICH signals survived. The judge on that run reconciled the
918
+ mismatch the only way left to it, concluding the tool had not run at all,
919
+ and discarded the entire family. ``rejected_result_keys`` was already in
920
+ the same dict, at per-signal granularity.
921
+
922
+ ``reject`` itself is deliberately not touched here: for the BH family it
923
+ keeps the tool's raw CI verdict for the M1-M4 loop (see
924
+ :mod:`evalrx.stats.multiplicity`). This renderer is the place that has
925
+ to make the difference between raw and corrected legible.
926
+ """
927
+ if not stats_results:
928
+ return ""
929
+ lines = ["", "Statistical test results (effect-sized, FDR-aware):"]
930
+ for r in stats_results:
931
+ if r.ok:
932
+ lines.append(f" - {r.summary}{_multiplicity_note(r)}")
933
+ else:
934
+ lines.append(f" - {r.tool}: not run ({r.error})")
935
+ n_tested = int(corrected.get("n_tested") or 0)
936
+ if n_tested:
937
+ keys = list(corrected.get("rejected_result_keys") or [])
938
+ method = corrected.get("method") or "FDR"
939
+ lines.append(
940
+ f" After {method} correction, {len(keys)} of {n_tested} corrected "
941
+ f"test(s) survive" + (":" if keys else " — none.")
942
+ )
943
+ lines.extend(f" * {key}" for key in keys)
944
+ return "\n".join(lines)