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,72 @@
1
+ """Prompt templates for M2 statistical analysis."""
2
+
3
+ _ANALYSIS_PROMPT = """\
4
+ You are an expert in ML failure analysis for vision-language models and agentic systems.
5
+
6
+ Experiment protocol:
7
+ {protocol_text}
8
+
9
+ Task domain: {task_domain}
10
+
11
+ Analyzer summary:
12
+ {narrative}
13
+
14
+ Raw per-analyzer findings (JSON):
15
+ {findings_json}
16
+
17
+ WHO READS THIS: an engineer who builds and runs evaluations but does not do
18
+ statistics. They know what a benchmark, a prompt and a failure case are. They
19
+ have never met an e-value, a McNemar test, a bootstrap interval or a
20
+ Benjamini-Hochberg correction, and they will not go and look them up.
21
+
22
+ HOW TO WRITE FOR THEM:
23
+ - Answer first. The opening sentence says what is going wrong, in ordinary
24
+ words. Numbers, mechanism and caveats all come after it.
25
+ - A bare number tells this reader nothing. Give the count AND what it amounts
26
+ to: "the model got 45 of the 125 wrong, and almost all of those were lists
27
+ longer than eight words" — not "fail rate 0.36, effect +0.28".
28
+ - Prefer counts to rates, and give both when you have them ("24 of 30 cases,
29
+ 80%"). For a comparison, name both groups and how many cases each holds.
30
+ - If a statistical term really is the clearest word, define it in the same
31
+ sentence the first time it appears: "an e-value of 45 — the evidence runs
32
+ about 45 to 1 against this being chance". Never leave one standing bare.
33
+ - Never make a bare metric identifier the subject of a sentence. Say what it
34
+ measures, then put the identifier in parentheses if the reader needs the
35
+ handle: "how often the model answered Yes (answer_extraction_audit.answered_yes)".
36
+ - No symbols standing in for words (→, ρ, σ), and no "p<0.05" shorthand.
37
+ - These are patterns on the analysis split, not proven causes. Write "shows up
38
+ more often on" or "is linked with", never "causes" or "explains", unless an
39
+ intervention actually tested it.
40
+
41
+ Based on the protocol and the findings above, write:
42
+
43
+ CONCLUSION: <one paragraph. FIRST sentence: what is going wrong, in plain words,
44
+ no numbers. Then the evidence for it, with counts. Then what this evidence
45
+ does NOT settle. If the model looks healthy given the protocol, say that
46
+ plainly in the first sentence.>
47
+ EVIDENCE_CHAIN:
48
+ - <step 1: which measurement first caught your attention — say what it measures
49
+ in words, not just its identifier — and why it stood out>
50
+ - <step 2: how it connects to the protocol's stated failure patterns>
51
+ - <step 3: any corroborating or contradicting signals from other analyzers>
52
+ QUALITATIVE:
53
+ - <observation 1: a pattern not captured by numbers alone>
54
+ - <observation 2: anything unexpected or surprising>
55
+
56
+ Keep each bullet to one sentence."""
57
+
58
+ _TOOL_SELECT_PROMPT = """\
59
+ You are selecting statistical tools to test why a model fails, given the data on hand.
60
+
61
+ Experiment protocol:
62
+ {protocol_text}
63
+
64
+ Available statistical tools:
65
+ {tool_catalog}
66
+
67
+ Data shape available for testing:
68
+ {data_shape}
69
+
70
+ Pick the tools whose data requirements are satisfied and that best test the \
71
+ protocol's question. Return ONLY a JSON object, no other text:
72
+ {{"tools": ["name1", "name2", ...], "rationale": "one sentence"}}"""
@@ -0,0 +1,43 @@
1
+ """Prompt templates for generated code-writing stages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ _INPUT_FILENAME = "m2_stats_input.json"
6
+ _RESULT_MARKER = "STATS_RESULT_JSON="
7
+
8
+ _GENERATE_PROMPT = """\
9
+ You are writing a self-contained Python statistics script for a model-failure analysis.
10
+
11
+ GOAL (what statistic to compute):
12
+ {need}
13
+
14
+ DATA: a JSON file named "{input_filename}" sits in the current working directory with:
15
+ {{
16
+ "labels": {{case_id: is_fail_bool}}, # PASS/FAIL labels
17
+ "per_case": {{"analyzer.metric": {{case_id: value}}}}, # per-case signals
18
+ "scalars": {{"analyzer.metric": value}}, # aggregate metrics
19
+ "groups": {{strategy: {{case_id: success}}}} or null # strategy comparison
20
+ }}
21
+
22
+ Data shape available for THIS run:
23
+ {data_shape}
24
+
25
+ REQUIREMENTS:
26
+ - Read "{input_filename}" from the current directory. Do NOT hardcode the data.
27
+ - You MAY `import numpy`. No network, no file writes, no other I/O.
28
+ - You decide WHAT statistic to compute, but you do NOT adjudicate significance.
29
+ Do NOT emit a "reject", "e_value", or "p_value" verdict — the HOST recomputes
30
+ the decision from your SUFFICIENT STATISTICS with its validated,
31
+ multiplicity-aware core; a self-declared verdict is ignored.
32
+ - The LAST line of stdout MUST be exactly one line of the form:
33
+ {marker}{{"summary": "<one sentence>", "effect": <number or null>, "ci": [lo, hi] or null, "underpowered": <true/false>, "details": {{}}, "sufficient": <a SUFFICIENT-STATISTICS object or null>}}
34
+ - "sufficient" must be ONE of these host-adjudicable shapes:
35
+ {{"kind": "paired_binary", "b": <int: #cases that flipped the GOOD way>, "c": <int: #cases that flipped the BAD way>}}
36
+ {{"kind": "two_group", "a": [0/1, ...], "b": [0/1, ...]}} # two independent success/indicator vectors (e.g. is_fail among signal-absent vs signal-present)
37
+ If your statistic cannot be expressed as one of these, set "sufficient": null —
38
+ your tool is then DESCRIPTIVE (it reports effect/CI but can never claim a
39
+ rejection). Choose the shape that captures your test; the host owns the verdict.
40
+ - Print NOTHING after that line. Keep the script under ~60 lines.
41
+
42
+ Return ONLY the Python code{fences_hint}."""
43
+
@@ -0,0 +1,47 @@
1
+ """Shared parser for ``<MARKER>=<json>`` result lines printed by agent-written scripts.
2
+
3
+ The explorer (``EXPLORATORY_RESULT_JSON=``) and the M2 stats-tool generator
4
+ (``STATS_RESULT_JSON=``) both ask the generated Python to print its result as
5
+ one JSON object after a fixed marker. The original parsers read only the
6
+ remainder of the marker LINE, so ``print(f"MARKER={json.dumps(obj, indent=2)}")``
7
+ — a perfectly valid object that merely spans several lines — parsed as ``{`` and
8
+ failed (qwen3.5-2b / gsm8k, 2026-08-27: three explorer attempts in a row, the
9
+ report lost). The marker is the contract; the line break is not.
10
+
11
+ :func:`extract_marker_json` therefore decodes from the LAST marker to wherever
12
+ the JSON value ends (``json.JSONDecoder.raw_decode``), so single-line,
13
+ pretty-printed and trailing-noise outputs all read the same object.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from typing import Any
20
+
21
+
22
+ def extract_marker_json(stdout: str, marker: str) -> tuple[Any, str]:
23
+ """Return ``(value, "")`` for the JSON value that follows the last *marker*
24
+ line in *stdout*, or ``(None, error)`` when there is none / it is invalid.
25
+
26
+ The marker must start a line (after whitespace); the value may continue on
27
+ the following lines and may be followed by unrelated output. When the
28
+ marker appears more than once the last occurrence wins, matching the
29
+ line-oriented behaviour these parsers always had.
30
+ """
31
+ start = -1
32
+ pos = 0
33
+ for line in stdout.splitlines(keepends=True):
34
+ stripped = line.lstrip()
35
+ if stripped.startswith(marker):
36
+ start = pos + (len(line) - len(stripped)) + len(marker)
37
+ pos += len(line)
38
+ if start < 0:
39
+ return None, f"no {marker} line in output"
40
+ payload = stdout[start:].lstrip()
41
+ if not payload:
42
+ return None, f"empty {marker} payload"
43
+ try:
44
+ value, _end = json.JSONDecoder().raw_decode(payload)
45
+ except json.JSONDecodeError as exc:
46
+ return None, f"unparseable {marker} JSON: {exc}"
47
+ return value, ""
@@ -0,0 +1,242 @@
1
+ """Run a user's existing evaluation codebase and feed its results into
2
+ exploratory analysis.
3
+
4
+ ``run_codebase()`` bridges the run infrastructure (:mod:`evalrx.agent_runtime`)
5
+ and the analysis entry (:func:`evalrx.analysis.api.explore`): given a
6
+ path to a codebase, a CLI coding agent runs it inside an isolated copy,
7
+ harvests the per-case records it produces, and (by default) hands them
8
+ straight to ``explore()``.
9
+
10
+ Flow::
11
+
12
+ run_codebase(path)
13
+ -> copy repo into an isolated workspace (the original is never touched)
14
+ -> CodegenRunner.run(prompt) # the agent runs the eval in-place
15
+ -> harvest workspace/records.json (one repair turn if empty/missing)
16
+ -> explore(records) # M2 + M3, unchanged
17
+
18
+ ``run_codebase_cli`` is the CLI-facing counterpart: same pipeline, prints a
19
+ summary and optionally opens the dashboard.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import shutil
26
+ import tempfile
27
+ from dataclasses import dataclass, field
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ from evalrx.agent_runtime.cli_types import CliAgentConfig
32
+ from evalrx.agent_runtime.codegen.runner import CodegenRunner
33
+ from evalrx.analysis.api import ExploreRunResult, explore
34
+ from evalrx.analysis.explorer import RECORDS_FILENAME, load_records_from_path
35
+ from evalrx.analysis.prompts.run_codebase import REPAIR_PROMPT_TEMPLATE, RUN_PROMPT_TEMPLATE
36
+
37
+ _IGNORE_DIR_NAMES = {".git", "__pycache__", "node_modules", ".venv", "venv", ".mypy_cache", ".pytest_cache"}
38
+ _HARVEST_MAX_ROWS = 20_000
39
+
40
+
41
+ @dataclass
42
+ class CodebaseRunResult:
43
+ """Outcome of one :func:`run_codebase` call."""
44
+
45
+ records: list[dict[str, Any]] = field(default_factory=list)
46
+ records_path: Path | None = None
47
+ workspace: Path | None = None
48
+ ran_ok: bool = False
49
+ explore: ExploreRunResult | None = None
50
+ error: str | None = None
51
+ audit: dict | None = None
52
+
53
+
54
+ def _copy_ignore(_dir: str, names: list[str]) -> set[str]:
55
+ return {n for n in names if n in _IGNORE_DIR_NAMES}
56
+
57
+
58
+ def _harvest(workspace: Path, records_name: str) -> list[dict[str, Any]]:
59
+ target = workspace / records_name
60
+ if not target.exists():
61
+ return []
62
+ return load_records_from_path(target, max_rows=_HARVEST_MAX_ROWS, max_files=1)
63
+
64
+
65
+ def run_codebase(
66
+ path: "str | Path",
67
+ *,
68
+ out: "str | Path | None" = None,
69
+ analyze: bool = True,
70
+ provider: str = "claude_code",
71
+ model: str = "",
72
+ binary: str = "",
73
+ outcome_col: str | None = None,
74
+ records_name: str = RECORDS_FILENAME,
75
+ timeout_sec: int = 1200,
76
+ max_attempts: int = 2,
77
+ question: str = "Explore this dataset and surface the patterns that matter.",
78
+ progress_sink: Any = None,
79
+ **explore_kwargs: Any,
80
+ ) -> CodebaseRunResult:
81
+ """Run a user's evaluation codebase at *path* and analyze its results.
82
+
83
+ A CLI coding agent (*provider*) runs inside an isolated copy of *path* —
84
+ the original directory is never modified — and is instructed to produce
85
+ *records_name* (default ``"records.json"``, a JSON array or JSON-Lines
86
+ file with one row per evaluation case, each carrying a ``label`` plus
87
+ input/prediction/target fields). Those records are then handed to
88
+ :func:`evalrx.analysis.api.explore` (M2 + M3) unless *analyze* is
89
+ False.
90
+
91
+ When *out* is given, the workspace lives under ``out/workspace`` and is
92
+ kept on disk (harvested records plus explore artifacts are persisted
93
+ there too), which is useful for inspecting what the agent did — including
94
+ after a failed run. Without *out* everything runs in a temporary
95
+ directory that is removed before returning; only the returned
96
+ :class:`CodebaseRunResult` carries the records in memory.
97
+ """
98
+ src = Path(path).resolve()
99
+ if not src.exists():
100
+ return CodebaseRunResult(error=f"path does not exist: {src}")
101
+
102
+ out_dir = Path(out).resolve() if out is not None else None
103
+ cleanup: tempfile.TemporaryDirectory | None = None
104
+ if out_dir is not None:
105
+ out_dir.mkdir(parents=True, exist_ok=True)
106
+ workspace = out_dir / "workspace"
107
+ if workspace.exists():
108
+ shutil.rmtree(workspace)
109
+ else:
110
+ cleanup = tempfile.TemporaryDirectory(prefix="evalrx_run_codebase_")
111
+ workspace = Path(cleanup.name) / "workspace"
112
+ shutil.copytree(src, workspace, ignore=_copy_ignore)
113
+
114
+ cli_config = CliAgentConfig(provider=provider, binary_path=binary, model=model, timeout_sec=timeout_sec)
115
+ runner = CodegenRunner(cli_config)
116
+
117
+ if progress_sink is not None:
118
+ progress_sink.emit("run", "started", f"Running codebase at {src}")
119
+
120
+ prompt = RUN_PROMPT_TEMPLATE.format(records_name=records_name, question=question)
121
+ result = runner.run(prompt, workdir=workspace, timeout_sec=timeout_sec)
122
+ audit = result.audit
123
+ records = _harvest(workspace, records_name)
124
+
125
+ attempts = 1
126
+ while not records and attempts < max_attempts:
127
+ reason = result.error or "no records found"
128
+ repair_prompt = REPAIR_PROMPT_TEMPLATE.format(records_name=records_name, reason=reason)
129
+ result = runner.run(repair_prompt, workdir=workspace, timeout_sec=timeout_sec)
130
+ audit = result.audit or audit
131
+ records = _harvest(workspace, records_name)
132
+ attempts += 1
133
+
134
+ if not records:
135
+ error = result.error or f"the coding agent did not produce '{records_name}'"
136
+ if progress_sink is not None:
137
+ progress_sink.emit("run", "failed", error)
138
+ if cleanup is not None:
139
+ cleanup.cleanup()
140
+ return CodebaseRunResult(workspace=workspace, ran_ok=False, error=error, audit=audit)
141
+
142
+ if progress_sink is not None:
143
+ progress_sink.emit("run", "completed", f"Harvested {len(records)} record(s)")
144
+
145
+ run_result = CodebaseRunResult(
146
+ records=records,
147
+ records_path=workspace / records_name,
148
+ workspace=workspace,
149
+ ran_ok=True,
150
+ audit=audit,
151
+ )
152
+
153
+ if analyze:
154
+ run_result.explore = explore(
155
+ records,
156
+ question=question,
157
+ out=out_dir,
158
+ provider=provider,
159
+ model=model,
160
+ binary=binary,
161
+ outcome_col=outcome_col,
162
+ progress_sink=progress_sink,
163
+ **explore_kwargs,
164
+ )
165
+
166
+ if out_dir is not None:
167
+ (out_dir / records_name).write_text(json.dumps(records, default=str), encoding="utf-8")
168
+ if cleanup is not None:
169
+ cleanup.cleanup()
170
+
171
+ return run_result
172
+
173
+
174
+ def run_codebase_cli(
175
+ path: "str | Path",
176
+ *,
177
+ out: "str | Path" = "evalrx_run_codebase_output",
178
+ coder_provider: str = "claude_code",
179
+ coder_model: str = "",
180
+ coder_binary: str = "",
181
+ outcome_col: str | None = None,
182
+ records_name: str = RECORDS_FILENAME,
183
+ timeout_sec: int = 1200,
184
+ max_attempts: int = 2,
185
+ question: str = "Explore this dataset and surface the patterns that matter.",
186
+ analyze: bool = True,
187
+ dashboard: bool = False,
188
+ dashboard_port: int | None = None,
189
+ progress_sink: Any = None,
190
+ ) -> int:
191
+ """Run *path* and persist its harvested records + explore artifacts under *out*.
192
+
193
+ Prints a short summary and returns a process exit code: ``0`` if records
194
+ were harvested (and, when *analyze*, the exploration succeeded), else
195
+ ``1``. When *dashboard* is set, the return value is the dashboard
196
+ process's code instead.
197
+ """
198
+ out_dir = Path(out).resolve()
199
+ result = run_codebase(
200
+ path,
201
+ out=out_dir,
202
+ analyze=analyze,
203
+ provider=coder_provider,
204
+ model=coder_model,
205
+ binary=coder_binary,
206
+ outcome_col=outcome_col,
207
+ records_name=records_name,
208
+ timeout_sec=timeout_sec,
209
+ max_attempts=max_attempts,
210
+ question=question,
211
+ progress_sink=progress_sink,
212
+ )
213
+
214
+ print(f"ran_ok: {result.ran_ok}")
215
+ print(f"records harvested: {len(result.records)}")
216
+ print(f"output: {out_dir}")
217
+ if result.error:
218
+ print(f"error: {result.error}")
219
+ if result.explore is not None:
220
+ report = result.explore.report
221
+ print(f"explore ok: {report.ok}")
222
+ if report.hypotheses:
223
+ print("\nhypotheses (M3, proposed only — not validated):")
224
+ for h in report.hypotheses[:8]:
225
+ print(f"- {h.get('statement')}")
226
+ if report.observations:
227
+ print("\nobservations:")
228
+ for obs in report.observations[:8]:
229
+ print(f"- {obs}")
230
+
231
+ ok = result.ran_ok and (result.explore is None or result.explore.ok)
232
+
233
+ if ok:
234
+ from evalrx.reporting.html_report import build_html_report
235
+
236
+ build_html_report(out_dir, out_path=out_dir / "report.html")
237
+
238
+ if dashboard:
239
+ from evalrx.analysis.dashboard import launch_dashboard
240
+
241
+ return launch_dashboard(out_dir, port=dashboard_port)
242
+ return 0 if ok else 1
@@ -0,0 +1,205 @@
1
+ """Normalized, UI-facing representation of an EvalRX run.
2
+
3
+ The persisted formats predate the web workbench and intentionally differ:
4
+ ``exploratory_report.json`` is a finished M2/M3 artifact, while a diagnostic
5
+ loop is an event stream. Rendering either format directly made the UI grow
6
+ two incompatible information architectures. This module is the compatibility
7
+ boundary: readers turn either source into the small, stable model the UI uses.
8
+
9
+ It deliberately does *not* replace the wire contract. New producers should
10
+ write the contract; these adapters keep existing report bundles and
11
+ ``run.json``/``M<n>/log.json`` run directories readable during that migration.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+ from enum import Enum
18
+ from pathlib import Path
19
+ from typing import Any, Literal
20
+
21
+
22
+ class _StrEnum(str, Enum):
23
+ """``enum.StrEnum`` for the Python floor this package declares.
24
+
25
+ ``StrEnum`` is 3.11+, and ``pyproject.toml`` says ``requires-python >=3.10``
26
+ with 3.10 in the CI matrix — so importing it here broke collection of this
27
+ module, and of its tests, on the oldest version we claim to support.
28
+
29
+ ``(str, Enum)`` is what the rest of the codebase already uses
30
+ (:class:`evalrx.core.case.Label`); the explicit ``__str__`` is what
31
+ keeps it interchangeable, since plain ``(str, Enum)`` formats as
32
+ ``StageId.M1`` where ``StrEnum`` formats as ``M1``.
33
+ """
34
+
35
+ def __str__(self) -> str:
36
+ return str(self.value)
37
+
38
+
39
+ class StageId(_StrEnum):
40
+ """Pipeline identifiers retained for auditability, not navigation order."""
41
+
42
+ M1 = "M1"
43
+ M2 = "M2"
44
+ M3 = "M3"
45
+ M4 = "M4"
46
+ M5 = "M5"
47
+
48
+
49
+ class StageState(_StrEnum):
50
+ """Reader-visible stage state; absence is distinct from an empty result."""
51
+
52
+ NOT_STARTED = "not_started"
53
+ RUNNING = "running"
54
+ SUCCEEDED = "succeeded"
55
+ EMPTY = "empty"
56
+ PARTIAL = "partial"
57
+ FAILED = "failed"
58
+ UNAVAILABLE = "unavailable"
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class StageView:
63
+ id: StageId
64
+ label: str
65
+ state: StageState
66
+ count: int | None = None
67
+ detail: str = ""
68
+
69
+
70
+ @dataclass
71
+ class RunView:
72
+ """One source-independent run consumed by the unified web shell."""
73
+
74
+ kind: Literal["explore", "diagnostic", "casebench", "empty"]
75
+ root: Path
76
+ title: str
77
+ stages: list[StageView]
78
+ report: dict[str, Any] | None = None
79
+ confirm: dict[str, Any] | None = None
80
+ fix_report: dict[str, Any] | None = None
81
+ story: dict[str, Any] | None = None
82
+ case_studies: list[Any] = field(default_factory=list)
83
+
84
+ @property
85
+ def is_diagnostic(self) -> bool:
86
+ return self.kind in {"diagnostic", "casebench"}
87
+
88
+ def stage(self, stage_id: StageId) -> StageView:
89
+ return next(s for s in self.stages if s.id == stage_id)
90
+
91
+
92
+ _STAGE_LABELS = {
93
+ StageId.M1: "Measure",
94
+ StageId.M2: "Explore evidence",
95
+ StageId.M3: "Explain hypotheses",
96
+ # M4 precedes M5 in the actual current loop. The UI uses this action
97
+ # order rather than implying the numeric labels are execution order.
98
+ StageId.M4: "Validate hypotheses",
99
+ StageId.M5: "Intervene & repair",
100
+ }
101
+
102
+
103
+ def _stages(states: dict[StageId, tuple[StageState, int | None, str]]) -> list[StageView]:
104
+ return [
105
+ StageView(stage_id, _STAGE_LABELS[stage_id], *states[stage_id])
106
+ for stage_id in StageId
107
+ ]
108
+
109
+
110
+ def _sibling_json(root: Path, name: str) -> dict[str, Any] | None:
111
+ import json
112
+
113
+ try:
114
+ value = json.loads((root / name).read_text(encoding="utf-8"))
115
+ except (OSError, ValueError):
116
+ return None
117
+ return value if isinstance(value, dict) else None
118
+
119
+
120
+ def from_session(session: dict[str, Any]) -> RunView:
121
+ """Adapt the dict returned by :func:`dashboard.load_run`.
122
+
123
+ Keeping the adapter dependent on the public loader result, rather than on
124
+ paths or event parsing, makes it usable by both ``dashboard`` and ``web``.
125
+ """
126
+
127
+ root = Path(str(session.get("root") or ".")).resolve()
128
+ kind = str(session.get("kind") or "empty")
129
+ if kind == "loop":
130
+ return _from_loop(root, session.get("story") or {})
131
+ if kind == "casebench":
132
+ studies = list(session.get("case_studies") or [])
133
+ title = getattr(studies[0], "name", root.name) if studies else root.name
134
+ ready = StageState.SUCCEEDED if studies else StageState.EMPTY
135
+ return RunView(
136
+ kind="casebench", root=root, title=str(title), case_studies=studies,
137
+ stages=_stages({stage: (ready if stage is StageId.M1 else StageState.UNAVAILABLE, None, "")
138
+ for stage in StageId}),
139
+ )
140
+ runs = list(session.get("runs") or [])
141
+ if runs:
142
+ turn = runs[0]
143
+ report = turn.get("report") if isinstance(turn, dict) else None
144
+ artifact_dir = Path(str(turn.get("dir") or root)) if isinstance(turn, dict) else root
145
+ return _from_explore(root, artifact_dir, report if isinstance(report, dict) else {})
146
+ return RunView(
147
+ kind="empty", root=root, title=root.name,
148
+ stages=_stages({stage: (StageState.UNAVAILABLE, None, "No readable run artifact.") for stage in StageId}),
149
+ )
150
+
151
+
152
+ def _from_explore(root: Path, artifact_dir: Path, report: dict[str, Any]) -> RunView:
153
+ confirm = _sibling_json(artifact_dir, "confirm_report.json")
154
+ fix_report = _sibling_json(artifact_dir, "fix_report.json")
155
+ ok = bool(report.get("ok", True))
156
+ m2 = StageState.SUCCEEDED if ok else StageState.FAILED
157
+ hypotheses = [h for h in report.get("hypotheses") or [] if isinstance(h, dict)]
158
+ m3 = StageState.SUCCEEDED if hypotheses else StageState.EMPTY
159
+ records = report.get("data_profile") or {}
160
+ has_measurement = bool(records or (artifact_dir / "records.json").exists())
161
+ title = str(report.get("plain_question") or report.get("question") or root.name)
162
+ states = {
163
+ StageId.M1: (StageState.SUCCEEDED if has_measurement else StageState.UNAVAILABLE, None,
164
+ "Normalized input records" if has_measurement else "No per-case measurement artifact."),
165
+ StageId.M2: (m2, len(report.get("takeaways") or []), "Exploratory report"),
166
+ StageId.M3: (m3, len(hypotheses), "Falsifiable hypotheses"),
167
+ StageId.M4: (StageState.SUCCEEDED if confirm else StageState.NOT_STARTED,
168
+ len((confirm or {}).get("hypothesis_verdicts") or []) if confirm else None,
169
+ "Held-out validation" if confirm else "No held-out validation artifact."),
170
+ StageId.M5: (StageState.SUCCEEDED if fix_report else StageState.NOT_STARTED,
171
+ len(((fix_report or {}).get("fix") or {}).get("attempted") or []) if fix_report else None,
172
+ "Intervention and repair sweep" if fix_report else "No repair artifact."),
173
+ }
174
+ return RunView("explore", root, title, _stages(states), report, confirm, fix_report)
175
+
176
+
177
+ def _from_loop(root: Path, story: dict[str, Any]) -> RunView:
178
+ probes = list(story.get("probes") or [])
179
+ analyses = list(story.get("analyses") or [])
180
+ diagnoses = list(story.get("diagnoses") or [])
181
+ surgeries = list(story.get("surgeries") or [])
182
+ fixes = list(story.get("fixes") or [])
183
+ lifecycle = story.get("run_start") or {}
184
+ stored_name = root.parent.name if root.name.lower() in {"output", "outputs", "results", "run"} else root.name
185
+ title = str(lifecycle.get("protocol_description") or lifecycle.get("question") or stored_name)
186
+
187
+ def outcome(events: list[Any], detail: str) -> tuple[StageState, int | None, str]:
188
+ if not events:
189
+ return StageState.NOT_STARTED, None, detail
190
+ return StageState.SUCCEEDED, len(events), detail
191
+
192
+ m4 = [event for event in surgeries if str(event.get("module", "")).lower() == "m4"]
193
+ m5 = [event for event in surgeries if str(event.get("module", "")).lower() == "m5"]
194
+ # A legacy/post-loop ``fix`` event is semantically M5 even when it does
195
+ # not carry an explicit m5 module tag.
196
+ m5_count = len(m5) + len(fixes)
197
+ states = {
198
+ StageId.M1: outcome(probes, "Analyzer measurements"),
199
+ StageId.M2: outcome(analyses, "Evidence screening"),
200
+ StageId.M3: outcome(diagnoses, "Mechanism hypotheses"),
201
+ StageId.M4: outcome(m4, "Hypothesis validation"),
202
+ StageId.M5: (StageState.SUCCEEDED, m5_count, "Interventions and repair attempts")
203
+ if m5_count else (StageState.NOT_STARTED, None, "No intervention or repair run."),
204
+ }
205
+ return RunView("diagnostic", root, title, _stages(states), story=story)