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,557 @@
1
+ """Local FastAPI application for dynamic completed-run reports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import mimetypes
8
+ import os
9
+ import re
10
+ import tempfile
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from evalrx.reporting.dynamic import (
16
+ CATALOG_VERSION,
17
+ JSON_RENDER_VERSION,
18
+ REPORT_SCHEMA_VERSION,
19
+ build_report_data,
20
+ fallback_spec,
21
+ load_published_report,
22
+ publish_report,
23
+ report_is_current,
24
+ )
25
+
26
+ try: # FastAPI stays optional, but a route's annotations must resolve against
27
+ # module globals — a function-local import of UploadFile would not be seen.
28
+ from fastapi import File, UploadFile
29
+ except ImportError: # pragma: no cover - create_app reports the missing extra
30
+ File = UploadFile = None # type: ignore[assignment]
31
+
32
+ # A dropped archive is a whole run directory, which for a media-carrying
33
+ # benchmark is mostly audio/video. The workbench default (1 GiB) is the same
34
+ # ceiling the upload app used, and it stays generous enough for those.
35
+ MAX_ARCHIVE_BYTES = 1024 * 1024 * 1024
36
+ _JUNK_DIRS = {"__MACOSX", ".git", "node_modules", ".venv"}
37
+
38
+
39
+ class ReportSession:
40
+ """The run currently on screen, and where dropped archives are unpacked.
41
+
42
+ ``serve`` starts either on a run directory or on nothing at all, and an
43
+ uploaded archive replaces whatever is loaded. Holding that in one object
44
+ lets every endpoint read the current run instead of closing over the one
45
+ that happened to exist at startup.
46
+ """
47
+
48
+ def __init__(self, run_dir: str | Path | None = None) -> None:
49
+ self.root: Path | None = None
50
+ self.data: dict[str, Any] = {}
51
+ self.envelope: dict[str, Any] = {}
52
+ self.media_by_id: dict[str, Any] = {}
53
+ self.label: str = ""
54
+ self._workspace: Path | None = None
55
+ if run_dir is not None:
56
+ self.load(run_dir)
57
+
58
+ @property
59
+ def loaded(self) -> bool:
60
+ return self.root is not None
61
+
62
+ def load(self, run_dir: str | Path) -> None:
63
+ root = _resolve_report_root(Path(run_dir).resolve())
64
+ data, envelope = _compile_report(root)
65
+ self.root = root
66
+ self.data = data
67
+ self.envelope = envelope
68
+ self.media_by_id = {str(item.get("id")): item for item in data.get("media", [])}
69
+ self.label = _run_label(root)
70
+
71
+ @property
72
+ def payload(self) -> dict[str, Any]:
73
+ return {"data": self.data, "layout": self.envelope}
74
+
75
+ def ingest_archive(self, payload: bytes, *, filename: str = "run.zip") -> Path:
76
+ """Unpack a dropped run archive and make it the report on screen."""
77
+ from evalrx.analysis.workbench import UploadLimits, extract_archive
78
+
79
+ if self._workspace is None:
80
+ self._workspace = Path(tempfile.mkdtemp(prefix="evalrx-report-"))
81
+ stem = re.sub(r"[^A-Za-z0-9._-]+", "-", Path(filename).stem).strip("-") or "run"
82
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
83
+ extracted = extract_archive(
84
+ payload,
85
+ self._workspace / f"{stem[:60]}-{stamp}",
86
+ limits=UploadLimits(max_archive_bytes=MAX_ARCHIVE_BYTES),
87
+ )
88
+ found = find_run_root(extracted)
89
+ if found is None:
90
+ raise ValueError(
91
+ "this archive has no run in it: expected a directory holding "
92
+ "run.json + M1..M5, or report/report_data.json"
93
+ )
94
+ self.load(found)
95
+ return found
96
+
97
+
98
+ def _run_label(root: Path) -> str:
99
+ # "logs" alone names nothing; a run written to <run>/logs is better
100
+ # identified by the directory the reader actually zipped up.
101
+ return f"{root.parent.name}/{root.name}" if root.name == "logs" else root.name
102
+
103
+
104
+ #: RunLoggerV2's per-stage documents (evalrx/eval_agent/run_logger_v2.py).
105
+ #: A run.json + at least one of these is its on-disk signature, matching
106
+ #: find_run_root's marker and reporting/run_events.py's resolve_v2_root.
107
+ _STAGES = ("M1", "M2", "M3", "M4", "M5")
108
+
109
+
110
+ def _is_run_root(current: Path, filenames: list[str]) -> bool:
111
+ """Whether *current* is a run's own directory."""
112
+ return "run.json" in filenames and any(
113
+ (current / stage / "log.json").is_file() for stage in _STAGES
114
+ )
115
+
116
+
117
+ def _run_mtime(root: Path) -> float | None:
118
+ """Last-activity time for a run — the most recently rewritten document."""
119
+ candidates = (root / "run.json", *(root / stage / "log.json" for stage in _STAGES))
120
+ stamps = []
121
+ for candidate in candidates:
122
+ try:
123
+ stamps.append(candidate.stat().st_mtime)
124
+ except OSError:
125
+ continue
126
+ return max(stamps) if stamps else None
127
+
128
+
129
+ # Directories a run scan never descends into: version control and dependency
130
+ # noise, plus a found run's own internals (raw case media, the coder's
131
+ # sandbox, cached transcodes) — none of those hold a *further* run, and
132
+ # `data/` in particular can be hundreds of megabytes of dataset files.
133
+ _SCAN_SKIP_DIRS = _JUNK_DIRS | {"data", "sandbox", ".media_cache"}
134
+
135
+ #: Runs panel display cap, applied after sorting by recency (see /api/runs) —
136
+ #: distinct from discover_runs' own max_results, which is a much larger
137
+ #: traversal safety valve, not a display limit.
138
+ RUNS_DISPLAY_LIMIT = 200
139
+
140
+
141
+ def discover_runs(scan_root: Path, *, max_depth: int = 8, max_results: int = 2000) -> list[Path]:
142
+ """Find run directories under ``scan_root``, without walking into any of them.
143
+
144
+ A run directory is one holding ``run.json`` plus at least one
145
+ ``M<n>/log.json`` (see ``_is_run_root``). Once a run is found its own
146
+ subtree (data, sandbox, report, the stage folders, ...) is not searched
147
+ further, and directories named `outputs*`/`logs` are walked through
148
+ since a run commonly sits a few levels under an example's output
149
+ directory.
150
+
151
+ ``max_results`` is a safety valve for a pathological tree, not the panel's
152
+ display limit — hitting it means the *walk* stops, in whatever order
153
+ ``os.walk`` reached matches, so a caller that wants "most recent N" must
154
+ sort the full result and slice afterward rather than rely on this cutoff
155
+ to have kept the newest ones.
156
+ """
157
+ scan_root = scan_root.resolve()
158
+ found: list[Path] = []
159
+ for dirpath, dirnames, filenames in os.walk(scan_root):
160
+ current = Path(dirpath)
161
+ dirnames[:] = sorted(d for d in dirnames if d not in _SCAN_SKIP_DIRS and not d.startswith("."))
162
+ if _is_run_root(current, filenames):
163
+ found.append(current)
164
+ dirnames[:] = [] # a run's own subtree holds no further runs
165
+ if len(found) >= max_results:
166
+ break
167
+ continue
168
+ if len(current.relative_to(scan_root).parts) >= max_depth:
169
+ dirnames[:] = [] # too deep to be worth descending further
170
+ return found
171
+
172
+
173
+ def _run_summary(root: Path, run_id: str, scan_root: Path) -> dict[str, Any]:
174
+ """Cheap, read-only metadata for the runs panel — never compiles a report."""
175
+ mtime = _run_mtime(root)
176
+ dataset = model = None
177
+ published = False
178
+ data_path = root / "report" / "report_data.json"
179
+ if data_path.is_file():
180
+ published = True
181
+ try:
182
+ setting = json.loads(data_path.read_text(encoding="utf-8")).get("setting") or {}
183
+ dataset, model = setting.get("dataset"), setting.get("model")
184
+ except (OSError, ValueError, json.JSONDecodeError):
185
+ pass
186
+ try:
187
+ rel_path = str(root.relative_to(scan_root))
188
+ except ValueError:
189
+ rel_path = str(root)
190
+ return {
191
+ "id": run_id,
192
+ # Relative to the scan root, not `_run_label`'s "<parent>/logs": two
193
+ # sibling experiments both nest their run under an `outputs/logs`
194
+ # child, and the topbar's one-run label collapses them to identical,
195
+ # unidentifiable rows in a list that shows several at once.
196
+ "path": rel_path,
197
+ # The resolved absolute path, matching what /api/session reports for
198
+ # the run currently on screen — how the panel knows which entry to
199
+ # highlight as active.
200
+ "root": str(root),
201
+ "dataset": dataset,
202
+ "model": model,
203
+ "published": published,
204
+ "modified_at": datetime.fromtimestamp(mtime, tz=timezone.utc).isoformat() if mtime else None,
205
+ }
206
+
207
+
208
+ def find_run_root(base: Path) -> Path | None:
209
+ """Locate the run inside an unpacked archive, shallowest candidate first.
210
+
211
+ Archives are zipped from wherever the run happened to sit, so the run log
212
+ can be at the top level or several directories down beside its example's
213
+ data. Depth is the tiebreak that matches how people zip: the run they meant
214
+ to share is the one nearest the surface.
215
+ """
216
+ candidates: list[tuple[int, int, Path]] = []
217
+ for rank, pattern in (
218
+ (0, "report/report_data.json"), (1, "run.json"),
219
+ ):
220
+ for hit in base.rglob(pattern.rsplit("/", 1)[-1]):
221
+ if rank == 0 and hit.parent.name != "report":
222
+ continue
223
+ run_root = hit.parent.parent if rank == 0 else hit.parent
224
+ if rank == 1 and not any(
225
+ (run_root / stage / "log.json").is_file() for stage in _STAGES
226
+ ):
227
+ continue
228
+ if any(part in _JUNK_DIRS for part in run_root.relative_to(base).parts):
229
+ continue
230
+ candidates.append((len(run_root.relative_to(base).parts), rank, run_root))
231
+ if not candidates:
232
+ return None
233
+ return min(candidates, key=lambda item: (item[0], item[1], str(item[2])))[2]
234
+
235
+
236
+ def _compile_report(root: Path) -> tuple[dict[str, Any], dict[str, Any]]:
237
+ """Publish the run's report cache when writable, else compile in memory."""
238
+ try:
239
+ if not report_is_current(root):
240
+ publish_report(root)
241
+ return load_published_report(root)
242
+ except PermissionError:
243
+ # Historical/shared runs are often intentionally read-only. Serving a
244
+ # report must not require mutating its evidence directory.
245
+ data = build_report_data(root)
246
+ envelope = {
247
+ "schema_version": REPORT_SCHEMA_VERSION,
248
+ "format": "json-render",
249
+ "json_render_version": JSON_RENDER_VERSION,
250
+ "catalog_version": CATALOG_VERSION,
251
+ "trace_id": data["trace_id"],
252
+ "source_event_seq": data["source_event_seq"],
253
+ "generated_by": {"mode": "deterministic-read-only", "model": None},
254
+ "spec": fallback_spec(data),
255
+ }
256
+ return data, envelope
257
+
258
+
259
+ def create_app(
260
+ run_dir: str | Path | None = None, *, frontend_dir: str | Path | None = None,
261
+ runs_root: str | Path | None = None,
262
+ ) -> Any:
263
+ """Create the loopback report API and serve the packaged React application.
264
+
265
+ ``run_dir`` may be omitted: the app then starts empty and waits for a run
266
+ archive to be dropped on the page.
267
+
268
+ ``runs_root`` is where the runs panel looks for sibling experiments to
269
+ list; it defaults to ``run_dir``'s parent (so opening one run in an
270
+ example's ``outputs/`` surfaces the others beside it) or, with no
271
+ ``run_dir``, the current directory.
272
+ """
273
+ try:
274
+ from fastapi import FastAPI, HTTPException, Query
275
+ from fastapi.responses import FileResponse
276
+ from fastapi.staticfiles import StaticFiles
277
+ except ImportError as exc:
278
+ raise ImportError(
279
+ "The dynamic UI needs `pip install evalrx[ui]`."
280
+ ) from exc
281
+
282
+ session = ReportSession(run_dir)
283
+ app = FastAPI(title="EvalRX Report", docs_url="/api/docs", redoc_url=None)
284
+
285
+ # State for the runs panel: which directory it scans, and the ids handed
286
+ # to the browser for the last listing (an id is a hash of a resolved
287
+ # path, so `open` needs this to map it back — the scan itself is not
288
+ # repeated on open).
289
+ scan_state: dict[str, Path] = {
290
+ "root": Path(runs_root).resolve() if runs_root is not None
291
+ else (Path(run_dir).resolve().parent if run_dir is not None else Path.cwd())
292
+ }
293
+ run_index: dict[str, Path] = {}
294
+
295
+ def current() -> ReportSession:
296
+ if not session.loaded:
297
+ raise HTTPException(status_code=404, detail="No run is loaded; drop a run .zip to open one.")
298
+ return session
299
+
300
+ @app.get("/api/session")
301
+ def get_session() -> dict[str, Any]:
302
+ return {
303
+ "loaded": session.loaded,
304
+ "label": session.label,
305
+ "trace_id": session.data.get("trace_id") if session.loaded else None,
306
+ "accepts_upload": True,
307
+ # Lets the runs panel highlight the run already on screen (e.g.
308
+ # one passed on the command line) against its own listing, which
309
+ # keys entries by this same resolved path.
310
+ "root": str(session.root) if session.loaded else None,
311
+ }
312
+
313
+ @app.get("/api/runs")
314
+ def list_runs() -> dict[str, Any]:
315
+ """List experiments found on disk under the scan root, most recent first."""
316
+ scan_root = scan_state["root"]
317
+ if not scan_root.is_dir():
318
+ raise HTTPException(status_code=404, detail=f"{scan_root} is not a directory")
319
+ found = discover_runs(scan_root)
320
+ # Sort the full (safety-valve-capped, not display-capped) find before
321
+ # truncating to what the panel actually shows — discover_runs' own
322
+ # cutoff stops the walk in traversal order, which is not recency.
323
+ found.sort(key=lambda path: _run_mtime(path) or 0, reverse=True)
324
+ items = []
325
+ for path in found[:RUNS_DISPLAY_LIMIT]:
326
+ run_id = hashlib.sha1(str(path).encode("utf-8")).hexdigest()[:16]
327
+ run_index[run_id] = path
328
+ items.append(_run_summary(path, run_id, scan_root))
329
+ return {"root": str(scan_root), "items": items, "truncated": len(found) > RUNS_DISPLAY_LIMIT}
330
+
331
+ @app.post("/api/runs/{run_id}/open")
332
+ def open_run(run_id: str) -> dict[str, Any]:
333
+ """Load a run found by /api/runs and make it the report on screen.
334
+
335
+ Opens by id rather than by a client-supplied path, so the browser can
336
+ only load a run this server itself already found on a prior listing.
337
+ """
338
+ path = run_index.get(run_id)
339
+ if path is None or not path.is_dir():
340
+ raise HTTPException(status_code=404, detail="Unknown run id; refresh the runs list and try again")
341
+ try:
342
+ session.load(path)
343
+ except Exception as exc: # a run mid-write or otherwise unreadable
344
+ raise HTTPException(status_code=422, detail=f"could not open this run: {exc}") from exc
345
+ return {"label": session.label, **session.payload}
346
+
347
+ @app.post("/api/upload")
348
+ async def upload_run(file: UploadFile = File(...)) -> dict[str, Any]:
349
+ """Accept a zipped run directory and swap it in as the current report."""
350
+ payload = await file.read()
351
+ try:
352
+ session.ingest_archive(payload, filename=file.filename or "run.zip")
353
+ except ValueError as exc:
354
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
355
+ except Exception as exc: # a malformed run reads as a bad upload, not a crash
356
+ raise HTTPException(status_code=422, detail=f"could not read this run: {exc}") from exc
357
+ return {"label": session.label, **session.payload}
358
+
359
+ @app.get("/api/report")
360
+ def get_report() -> dict[str, Any]:
361
+ return current().payload
362
+
363
+ @app.get("/api/stages/{stage_id}")
364
+ def get_stage(stage_id: str) -> dict[str, Any]:
365
+ data = current().data
366
+ stage = next((item for item in data.get("stages", []) if item.get("id") == stage_id.lower()), None)
367
+ if stage is None:
368
+ raise HTTPException(status_code=404, detail="Unknown stage")
369
+ related = [event for event in data.get("debug", {}).get("events", []) if str(event.get("stage", "")).lower() in {stage_id.lower(), stage.get("code", "").lower()}]
370
+ return {"stage": stage, "events": related}
371
+
372
+ @app.get("/api/cases")
373
+ def get_cases(
374
+ status: str | None = None,
375
+ task: str | None = None,
376
+ search: str | None = None,
377
+ offset: int = Query(0, ge=0),
378
+ limit: int = Query(50, ge=1, le=200),
379
+ ) -> dict[str, Any]:
380
+ rows = list(current().data.get("cases", []))
381
+ if status:
382
+ rows = [case for case in rows if str(case.get("status", "")).lower() == status.lower()]
383
+ if task:
384
+ rows = [case for case in rows if str(case.get("task", "")).lower() == task.lower()]
385
+ if search:
386
+ needle = search.lower()
387
+ rows = [case for case in rows if needle in " ".join(str(case.get(key, "")) for key in ("id", "prompt", "expected", "observed", "task")).lower()]
388
+ return {"total": len(rows), "offset": offset, "limit": limit, "items": rows[offset:offset + limit]}
389
+
390
+ @app.get("/api/cases/{case_id}")
391
+ def get_case(case_id: str) -> dict[str, Any]:
392
+ case = next((item for item in current().data.get("cases", []) if str(item.get("id")) == case_id), None)
393
+ if case is None:
394
+ raise HTTPException(status_code=404, detail="Unknown case")
395
+ return case
396
+
397
+ @app.get("/api/debug")
398
+ def get_debug(
399
+ event: str | None = None,
400
+ offset: int = Query(0, ge=0),
401
+ limit: int = Query(100, ge=1, le=500),
402
+ ) -> dict[str, Any]:
403
+ rows = list(current().data.get("debug", {}).get("events", []))
404
+ if event:
405
+ rows = [row for row in rows if row.get("event") == event]
406
+ return {"total": len(rows), "offset": offset, "limit": limit, "items": rows[offset:offset + limit]}
407
+
408
+ @app.get("/api/media/{media_id}")
409
+ def get_media(media_id: str) -> Any:
410
+ item = current().media_by_id.get(media_id)
411
+ if item is None:
412
+ raise HTTPException(status_code=404, detail="Unknown media")
413
+ # A media id is selected from the report's fixed evidence index, not a
414
+ # caller-provided path. It may safely resolve into the example's
415
+ # adjacent data directory (where VLM images / ALLM audio conventionally
416
+ # live), while the generic artifact endpoint remains run-confined.
417
+ path = _resolve_indexed_media(session.root, str(item.get("path") or ""))
418
+ if path is None:
419
+ raise HTTPException(status_code=404, detail="Media is not available in the local cache")
420
+ return FileResponse(path, media_type=mimetypes.guess_type(path.name)[0])
421
+
422
+ @app.get("/api/artifact")
423
+ def get_artifact(path: str = Query(...)) -> Any:
424
+ """Serve a report-referenced local figure, constrained to this run."""
425
+ resolved = _resolve_media(current().root, path)
426
+ if resolved is None:
427
+ raise HTTPException(status_code=404, detail="Artifact is not available in this run")
428
+ return FileResponse(resolved, media_type=mimetypes.guess_type(resolved.name)[0])
429
+
430
+ packaged = Path(frontend_dir).resolve() if frontend_dir else Path(__file__).with_name("web_dist")
431
+ if packaged.is_dir() and (packaged / "index.html").exists():
432
+ assets = packaged / "assets"
433
+ if assets.is_dir():
434
+ app.mount("/assets", StaticFiles(directory=assets), name="assets")
435
+
436
+ @app.get("/{path:path}")
437
+ def spa(path: str) -> Any:
438
+ candidate = (packaged / path).resolve()
439
+ if packaged in candidate.parents and candidate.is_file():
440
+ return FileResponse(candidate, headers={"Cache-Control": "no-store"})
441
+ # The packaged app is a single-file bundle. Keeping it out of the
442
+ # browser cache makes a restarted local report server immediately
443
+ # pick up new interactive behavior instead of a stale UI shell.
444
+ return FileResponse(packaged / "index.html", headers={"Cache-Control": "no-store"})
445
+ else:
446
+ @app.get("/")
447
+ def missing_frontend() -> dict[str, str]:
448
+ return {"message": "API ready; build evalrx/reporting/web to install the UI."}
449
+
450
+ return app
451
+
452
+
453
+ def _resolve_report_root(requested_root: Path) -> Path:
454
+ """Accept either a run-log directory or its enclosing output directory.
455
+
456
+ Agentic examples conventionally write their immutable event stream in an
457
+ ``outputs_*/logs`` child. The CLI takes the enclosing output directory so
458
+ that it also remains convenient; prefer the nested directory whenever it
459
+ contains a run log or a published report.
460
+ """
461
+ # An explicit directory with its own event stream always wins. Some
462
+ # examples retain a later ``logs/`` sub-run beside an earlier successful
463
+ # top-level run; silently preferring it makes the UI show the wrong repair.
464
+ if (requested_root / "run.json").is_file():
465
+ return requested_root
466
+ nested_logs = requested_root / "logs"
467
+ if nested_logs.is_dir() and (
468
+ (nested_logs / "report" / "report_data.json").is_file()
469
+ or (nested_logs / "run.json").is_file()
470
+ ):
471
+ return nested_logs
472
+ return requested_root
473
+
474
+
475
+ def serve_dynamic_report(
476
+ run_dir: str | Path | None = None,
477
+ *,
478
+ host: str = "127.0.0.1",
479
+ port: int = 8501,
480
+ open_browser: bool = True,
481
+ runs_root: str | Path | None = None,
482
+ ) -> int:
483
+ """Publish if necessary and run the completed-report UI on loopback.
484
+
485
+ With no ``run_dir`` the server comes up empty and waits for a zipped run to
486
+ be dropped on the page, which is how a run that was produced on another
487
+ machine gets looked at here. ``runs_root`` overrides where the runs panel
488
+ looks for sibling experiments (see ``create_app``).
489
+ """
490
+ try:
491
+ import uvicorn
492
+ except ImportError as exc:
493
+ raise ImportError("The dynamic UI needs `pip install evalrx[ui]`.") from exc
494
+ app = create_app(run_dir, runs_root=runs_root)
495
+ if open_browser:
496
+ import threading
497
+ import webbrowser
498
+
499
+ threading.Timer(0.8, lambda: webbrowser.open(f"http://{host}:{port}")).start()
500
+ from evalrx.term_links import hyperlink
501
+
502
+ print(f"Serving dynamic diagnostic report at {hyperlink(f'http://{host}:{port}')}")
503
+ if run_dir is None:
504
+ print("No run loaded yet — drop a zipped run directory on the page to open one.")
505
+ uvicorn.run(app, host=host, port=port, log_level="warning")
506
+ return 0
507
+
508
+
509
+ def _resolve_media(root: Path, value: str) -> Path | None:
510
+ candidate = Path(value)
511
+ allowed_roots = [root, root.parent]
512
+ possibilities = [candidate] if candidate.is_absolute() else [
513
+ root / candidate,
514
+ root.parent / candidate,
515
+ root.parent / "data" / candidate,
516
+ ]
517
+ for possibility in possibilities:
518
+ try:
519
+ resolved = possibility.resolve()
520
+ except OSError:
521
+ continue
522
+ if not resolved.is_file():
523
+ continue
524
+ # Absolute case refs are allowed locally only when they belong to the
525
+ # run or its enclosing example. Langfuse caches always materialize under
526
+ # the run root and therefore pass the same check.
527
+ if any(resolved == base or base in resolved.parents for base in allowed_roots):
528
+ return resolved
529
+ return None
530
+
531
+
532
+ def _resolve_indexed_media(root: Path, value: str) -> Path | None:
533
+ """Resolve report-indexed case media without opening arbitrary paths.
534
+
535
+ Case assets may live at ``<example>/data`` while a run writes its log to
536
+ ``<example>/outputs*/logs``. The media index was built from saved case
537
+ records, so this endpoint permits only that nearby data directory in
538
+ addition to the usual run-owned roots. ``/api/artifact`` deliberately
539
+ does not use this broader resolver.
540
+ """
541
+ resolved = _resolve_media(root, value)
542
+ if resolved is not None:
543
+ return resolved
544
+ candidate = Path(value)
545
+ possibilities = [candidate] if candidate.is_absolute() else [
546
+ root.parent / "data" / candidate,
547
+ root.parent.parent / "data" / candidate,
548
+ ]
549
+ allowed_roots = [root.parent / "data", root.parent.parent / "data"]
550
+ for possibility in possibilities:
551
+ try:
552
+ path = possibility.resolve()
553
+ except OSError:
554
+ continue
555
+ if path.is_file() and any(base.resolve() in path.parents for base in allowed_roots if base.is_dir()):
556
+ return path
557
+ return None
@@ -0,0 +1,58 @@
1
+ """Stage semantics for EvalRX diagnostic reports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class StageSpec:
10
+ id: str
11
+ name: str
12
+ question: str
13
+ artifacts: str
14
+ dashboard_role: str
15
+
16
+
17
+ STAGE_SPECS: tuple[StageSpec, ...] = (
18
+ StageSpec(
19
+ id="M1",
20
+ name="Measurement",
21
+ question="What per-case signals did the evaluators/analyzers extract?",
22
+ artifacts="Frozen per-case feature matrix, analyzer outputs, attention/probe artifacts.",
23
+ dashboard_role="Problem Setting: defines the dataset and available signals.",
24
+ ),
25
+ StageSpec(
26
+ id="M2",
27
+ name="Exploratory analysis",
28
+ question="Which signals are worth investigating, and how do they relate to the outcome?",
29
+ artifacts="Effect sizes, relationship charts/tables — descriptive, no validity verdict.",
30
+ dashboard_role="Analysis: method, evidence, chart, and takeaway (no supported/not-supported "
31
+ "claim; a confirm phase with e-BH/FDR is a separate, currently out-of-scope step).",
32
+ ),
33
+ StageSpec(
34
+ id="M3",
35
+ name="Hypothesis generation",
36
+ question="What falsifiable failure mechanisms explain the confirmed signals?",
37
+ artifacts="Hypotheses, failure modes, cited M2 charts/observations.",
38
+ dashboard_role="Hypotheses: candidate mechanisms linked back to evidence.",
39
+ ),
40
+ StageSpec(
41
+ id="M4",
42
+ name="Hypothesis validation",
43
+ question="Does corrected statistical evidence and protocol consistency support the hypothesis?",
44
+ artifacts="Held-out verdicts, adjudication records, and evidence grades.",
45
+ dashboard_role="Validate hypotheses: the gate before intervention or repair.",
46
+ ),
47
+ StageSpec(
48
+ id="M5",
49
+ name="Intervention & repair",
50
+ question="Does a targeted intervention repair failures without unacceptable regressions?",
51
+ artifacts="Intervention records, repair candidates, paired outcome comparisons.",
52
+ dashboard_role="Intervene & repair: causal experiments and the repair sweep after validation.",
53
+ ),
54
+ )
55
+
56
+
57
+ def stage_specs_as_dicts() -> list[dict[str, str]]:
58
+ return [spec.__dict__.copy() for spec in STAGE_SPECS]