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,1882 @@
1
+ """Agent-composed report contracts for the dynamic EvalRX UI.
2
+
3
+ The evidence model is deterministic and task agnostic. A Report Agent may
4
+ choose how to arrange a small, typed component catalog, but every value shown
5
+ by the UI is resolved from :class:`ReportData`; the model cannot invent data or
6
+ arbitrary executable UI.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import hashlib
13
+ import json
14
+ import os
15
+ import re
16
+ from dataclasses import dataclass
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+ from typing import Any, Iterable, Mapping, Sequence
20
+
21
+ # 18: `setting.partitions` + `case.split` — which band of the frozen batch
22
+ # each case sat in (explore / held-out / confirm), so the Case Studio can
23
+ # show the split the run was actually measured on.
24
+ # 17: the case-study sheet - the whole run as one page.
25
+ # 16: `setting.hero_image` — the run's own cover figure, embedded.
26
+ # 15: `setting.diagnosed_by` — which agent drove the run.
27
+ # 14: M3 hypotheses recover the plain sentence from proposed_hypotheses.
28
+ # 13: paired M2 rows carry the strategy pair they compared, so several tests
29
+ # that share one tool no longer share one label. Bumping this is what
30
+ # makes an already-published run pick the change up — `report_is_current`
31
+ # only tracks new EVENTS, so without the bump every existing run would
32
+ # keep serving the labels its old compiler produced.
33
+ REPORT_DATA_VERSION = 18
34
+ REPORT_SCHEMA_VERSION = 1
35
+ JSON_RENDER_VERSION = "0.19.0"
36
+ CATALOG_VERSION = "evalrx-report@2"
37
+
38
+ ALLOWED_COMPONENTS = frozenset(
39
+ {
40
+ "ReportPage",
41
+ "SettingHero",
42
+ "MetricStrip",
43
+ "Journey",
44
+ "FindingGrid",
45
+ "ChartGrid",
46
+ "OutcomeCard",
47
+ "CasePreview",
48
+ "CaseStudySheet",
49
+ "LoopFigure",
50
+ "EvidenceIndex",
51
+ }
52
+ )
53
+ # OutcomeCard stays in ALLOWED_COMPONENTS so layouts published before it was
54
+ # dropped from the page still validate; the renderer draws nothing for it.
55
+ REQUIRED_COMPONENTS = frozenset({"SettingHero"})
56
+ # The page needs one picture of the M1-M5 loop: the plain stage strip, or the
57
+ # loop figure that folds the case-study sheet into it.
58
+ PIPELINE_COMPONENTS = frozenset({"Journey", "LoopFigure"})
59
+ MAX_ELEMENTS = 40
60
+ MAX_DEPTH = 6
61
+
62
+ # These labels are deliberately about the behaviour being measured, rather
63
+ # than the analyzer implementation that happened to measure it. Unknown keys
64
+ # still get a deterministic readable fallback and preserve their raw key in
65
+ # the audit-only fields below.
66
+ PLAIN_LABELS = {
67
+ "answer_extraction_audit": "Did the model give a usable answer?",
68
+ "calibration": "Is the model confident when it is wrong?",
69
+ "termination_audit": "Did the model stop before finishing?",
70
+ "format_sensitivity": "Does changing answer order change its choice?",
71
+ "selfcheck_consistency": "Does the model contradict itself?",
72
+ "coverage_verification_gap": "Can repeated attempts ever find the right answer?",
73
+ "logprob_entropy": "Does the model appear uncertain while answering?",
74
+ "self_consistency": "Does the answer stay the same across retries?",
75
+ "signal_label_assoc": "How strongly is this behavior linked to errors?",
76
+ "rank_corr": "Do more of this behavior and more errors move together?",
77
+ "single_rate_evalue": "Is this rate unlikely to be chance?",
78
+ "n_correct": "Number of correct attempts",
79
+ "any_correct": "At least one correct attempt",
80
+ "majority_correct": "Most attempts were correct",
81
+ "format_flip_rate": "Answer changed after reordering choices",
82
+ "positional_bias": "Tendency to favor one answer position",
83
+ "output_chars": "Length of the model answer",
84
+ "output_truncated": "Answer appears cut short",
85
+ "recovered_by_continuation": "Could continue to a usable answer",
86
+ "pass_at_k": "Share of retries that were correct",
87
+ "n_sentences": "Number of answer sentences checked",
88
+ "conf_logprob": "How certain the model appeared",
89
+ "label_disagrees": "Recorded answer disagrees with the label",
90
+ "has_answer_tag": "Used the requested answer format",
91
+ "extracted_answer": "Answer read from the output",
92
+ "labelled_fail": "This case was an error",
93
+ }
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class PublishedReport:
98
+ """Paths and identity of a cached dynamic report publication."""
99
+
100
+ data_path: Path
101
+ spec_path: Path
102
+ trace_id: str
103
+ generated_by: str
104
+ sha256: str
105
+
106
+
107
+ class ReportSpecError(ValueError):
108
+ """Raised when a Report Agent emits an unsafe or invalid layout."""
109
+
110
+
111
+ def build_report_data(
112
+ run_dir: str | Path,
113
+ *,
114
+ example_dir: str | Path | None = None,
115
+ ) -> dict[str, Any]:
116
+ """Build the renderer-neutral data model for any completed EvalRX run."""
117
+ root = Path(run_dir).resolve()
118
+ from evalrx.reporting.case_study import build_case_study
119
+ from evalrx.reporting.html_report import extract_run_data
120
+
121
+ resolved_example = Path(example_dir).resolve() if example_dir else _infer_example_dir(root)
122
+ raw = extract_run_data(root, resolved_example)
123
+ events = _read_events(root)
124
+ # The logger copies each case's media under artifacts/case_media/ and records
125
+ # the RUN-RELATIVE path beside the case. Carry it onto the case: inputs.<slot>
126
+ # holds the absolute path on the producing host, so a run read anywhere else
127
+ # -- unzipped on a laptop, which is the normal way these are looked at --
128
+ # resolves every preview to a file that is not there.
129
+ case_events = []
130
+ for event in events:
131
+ if event.get("event") != "case_record":
132
+ continue
133
+ case = event.get("case")
134
+ if isinstance(case, dict):
135
+ local = [p for p in (event.get("media_paths") or []) if isinstance(p, str)]
136
+ # The partition the loop logged the case under (see
137
+ # RunLoggerV2.log_cases). Absent on runs from before it was recorded.
138
+ split = event.get("split")
139
+ extra: dict[str, Any] = {}
140
+ if local:
141
+ extra["_media_paths"] = local
142
+ if isinstance(split, str) and split:
143
+ extra["_split"] = split
144
+ case_events.append({**case, **extra} if extra else case)
145
+ cases = [case for case in case_events if isinstance(case, dict)] or list(raw.get("cases") or [])
146
+ run = dict(raw.get("run") or {})
147
+ trace_id = str((events[-1] if events else {}).get("trace_id") or root.name)
148
+ reader = dict(raw.get("reader_report") or {})
149
+
150
+ # Read before anything that can use it: the typed payloads outrank the run
151
+ # log wherever both describe the same thing.
152
+ contract = _contract_payloads(root)
153
+ findings = _findings(reader, raw)
154
+ charts = _charts(run, raw, contract)
155
+ repairs = _repairs(raw)
156
+ media = _media_index(cases)
157
+ normalized_cases = [_normalise_case(case, media) for case in cases if isinstance(case, dict)]
158
+ normalized_cases = _merge_recorded_case_evidence(normalized_cases, root)
159
+ # Attach what M5's repair answered on each case, so "12 repaired, 1 broken"
160
+ # is thirteen cases a reader can open rather than two numbers.
161
+ repairs = _repair_outcomes(root, contract)
162
+ for case in normalized_cases:
163
+ hit = repairs.get(case["id"])
164
+ if hit:
165
+ case["repair"] = hit
166
+ # Which partition each case sat in -- explore for M1-M3, the withheld
167
+ # confirm / test pools for M4 and M5 -- read off the records when the run
168
+ # tagged them, inferred from the record order when it did not.
169
+ partitions = _assign_partitions(
170
+ normalized_cases,
171
+ logged_ids=[str(c.get("id") or c.get("case_id") or "") for c in case_events],
172
+ n_explore=run.get("n_cases"),
173
+ final_ids=set(repairs),
174
+ )
175
+ stage_detail = _stage_detail(raw, root, normalized_cases, events)
176
+ stages = _stages(raw, stage_detail.get("m5") if isinstance(stage_detail, dict) else None)
177
+ setting = {
178
+ "model": _contract_model_name(contract) or run.get("model") or "Target model",
179
+ "dataset": run.get("benchmark_name") or "Evaluation dataset",
180
+ "n_cases": int(run.get("n_cases") or len(normalized_cases)),
181
+ "partitions": partitions,
182
+ }
183
+ # The same run, assembled as one failure-to-repair sheet. None when the run
184
+ # has no probe or stats artifacts to build it from -- the section is dropped
185
+ # rather than rendered empty.
186
+ case_study = build_case_study(root, events, setting=setting)
187
+
188
+ return _json_safe(
189
+ {
190
+ "version": REPORT_DATA_VERSION,
191
+ "trace_id": trace_id,
192
+ "source_event_seq": max((int(e.get("event_seq") or 0) for e in events), default=0),
193
+ "generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
194
+ "setting": {
195
+ # The contract's ModelRef is a NAME; run.model is whatever the
196
+ # producer stringified, which for a custom Model subclass was
197
+ # repr() -- an unreadable line ending in a memory address.
198
+ "model": _contract_model_name(contract) or run.get("model") or "Target model",
199
+ "dataset": run.get("benchmark_name") or "Evaluation dataset",
200
+ "question": reader.get("question") or "Why did the model fail, and can we fix it?",
201
+ "protocol": run.get("protocol") or "",
202
+ # Who drove the pipeline, as opposed to `model`, which is what
203
+ # it was pointed at. Empty when the run recorded neither.
204
+ "diagnosed_by": _diagnosed_by(root, run),
205
+ # The cover figure the run shipped, if any — see _hero_image.
206
+ "hero_image": _hero_image(root),
207
+ "n_cases": int(run.get("n_cases") or len(normalized_cases)),
208
+ # The frozen batch's partitions, in the order they are drawn:
209
+ # explore, then what was withheld. Empty when the run recorded
210
+ # no split and none can be inferred.
211
+ "partitions": partitions,
212
+ },
213
+ "summary": {
214
+ "headline": reader.get("headline") or "Failure analysis completed",
215
+ "answer": reader.get("answer") or "Inspect the evidence journey below.",
216
+ "confidence": reader.get("confidence") or "unknown",
217
+ "stopped_by": run.get("stopped_by") or "completed",
218
+ },
219
+ "metrics": _metrics(run, raw, normalized_cases),
220
+ "stages": stages,
221
+ "findings": findings,
222
+ "charts": charts,
223
+ "repairs": repairs,
224
+ "stage_detail": stage_detail,
225
+ "case_study": case_study,
226
+ "contract": contract,
227
+ "cases": normalized_cases,
228
+ "media": media,
229
+ "debug": {
230
+ "event_count": len(events),
231
+ "events": [_debug_event(event) for event in events],
232
+ },
233
+ }
234
+ )
235
+
236
+
237
+ class ReportAgent:
238
+ """Ask an injected text model to compose a json-render layout.
239
+
240
+ The model sees a compact evidence summary and a list of legal identifiers,
241
+ not raw media or full debug logs. One repair attempt is made for malformed
242
+ output; callers should use :func:`fallback_spec` if both attempts fail.
243
+ """
244
+
245
+ def __init__(self, model: Any, *, catalog_version: str = CATALOG_VERSION) -> None:
246
+ if not hasattr(model, "generate"):
247
+ raise TypeError("ReportAgent model must expose generate(prompt) -> str")
248
+ self.model = model
249
+ self.catalog_version = catalog_version
250
+
251
+ def compose(self, data: Mapping[str, Any]) -> dict[str, Any]:
252
+ prompt = self._prompt(data)
253
+ raw = str(self.model.generate(prompt))
254
+ try:
255
+ return validate_spec(_parse_json(raw), data=data)
256
+ except (ReportSpecError, json.JSONDecodeError, TypeError, ValueError) as first:
257
+ repair = (
258
+ "Your previous report layout was invalid. Return ONLY corrected JSON.\n"
259
+ f"Validation error: {first}\n\nOriginal output:\n{raw[:12000]}\n\n{prompt}"
260
+ )
261
+ return validate_spec(_parse_json(str(self.model.generate(repair))), data=data)
262
+
263
+ def _prompt(self, data: Mapping[str, Any]) -> str:
264
+ compact = {
265
+ "setting": data.get("setting"),
266
+ "summary": data.get("summary"),
267
+ "metrics": data.get("metrics"),
268
+ "stages": data.get("stages"),
269
+ "finding_ids": [item.get("id") for item in data.get("findings", [])],
270
+ "chart_ids": [item.get("id") for item in data.get("charts", [])],
271
+ "repair_ids": [item.get("id") for item in data.get("repairs", [])],
272
+ "case_ids": [item.get("id") for item in data.get("cases", [])[:8]],
273
+ # Not the sheet itself -- it is large, and the agent may not copy
274
+ # numbers. Only whether CaseStudySheet has anything to render.
275
+ "has_case_study": bool(data.get("case_study")),
276
+ }
277
+ return f"""You are the Report Agent for EvalRX. Compose a concise visual report
278
+ showing the journey: model fails on a dataset -> M1 probes behavior -> M2 screens
279
+ associations -> M3 proposes mechanisms -> M4 tests on held-out evidence -> M5 repairs
280
+ the model. A passer-by must understand the setting and outcome without knowing EvalRX.
281
+
282
+ Return ONLY a json-render tree with shape {{"root":"id","elements":{{...}}}}.
283
+ Allowed component types: {', '.join(sorted(ALLOWED_COMPONENTS))}.
284
+ Required exactly once or more: SettingHero, and one of Journey or LoopFigure.
285
+ When has_case_study is true prefer LoopFigure (inputs → explore M1·M2·M3 → held-out
286
+ M4 → repair M5 → health card, one clickable figure) in place of Journey; use
287
+ CaseStudySheet only alongside Journey, directly after it.
288
+ ReportPage may have children. Other elements use props only.
289
+ Every element MUST include a JSON object `"props": {{}}`, even when it has no
290
+ properties. This is required by the json-render runtime.
291
+ Legal props are data references such as {{"findingIds":["finding-1"]}}; never copy,
292
+ rewrite, or invent evidence text/numbers. Prefer charts and journey graphics over prose.
293
+ At most 3 findings, 2 charts, {MAX_ELEMENTS} total elements, depth {MAX_DEPTH}.
294
+ Catalog: {self.catalog_version}.
295
+
296
+ Available report data identifiers:
297
+ {json.dumps(compact, ensure_ascii=False, separators=(',', ':'))}
298
+ """
299
+
300
+
301
+ def fallback_spec(data: Mapping[str, Any]) -> dict[str, Any]:
302
+ """Deterministic, evidence-first layout used without or after a failed model.
303
+
304
+ Deliberately short: hero, metrics, the M1–M5 pipeline, the takeaway sheet,
305
+ and the audit index (the one-line outcome card was dropped 2026-09-10: it
306
+ restated the loop figure's health card). The finding grid, the chart
307
+ grid and the representative-case preview were dropped from the landing
308
+ page — on a real run they repeated the metric strip (pass/fail donut) or
309
+ said "no finding" in a full-width card, and every one of them is still a
310
+ click away in the Evidence / Cases views. FindingGrid, ChartGrid and
311
+ CasePreview stay in the catalog for agent-composed layouts.
312
+ """
313
+ children = ["setting", "metrics"]
314
+ has_sheet = bool(data.get("case_study"))
315
+ elements: dict[str, Any] = {
316
+ "page": {"type": "ReportPage", "props": {}, "children": children},
317
+ "setting": {"type": "SettingHero", "props": {}},
318
+ "metrics": {"type": "MetricStrip", "props": {}},
319
+ "evidence": {"type": "EvidenceIndex", "props": {}},
320
+ }
321
+ # With a case study the loop figure IS the pipeline strip and the sheet in
322
+ # one picture; without one there is nothing to fill its cards, so the plain
323
+ # stage strip stands in.
324
+ if has_sheet:
325
+ elements["loop"] = {"type": "LoopFigure", "props": {}}
326
+ children.append("loop")
327
+ else:
328
+ elements["journey"] = {"type": "Journey", "props": {}}
329
+ children.append("journey")
330
+ children.append("evidence")
331
+ return elements and {"root": "page", "elements": elements}
332
+
333
+
334
+ def validate_spec(spec: Any, *, data: Mapping[str, Any] | None = None) -> dict[str, Any]:
335
+ """Validate the closed json-render catalog and bounded tree shape."""
336
+ if not isinstance(spec, dict) or not isinstance(spec.get("root"), str):
337
+ raise ReportSpecError("spec needs string root and elements")
338
+ elements = spec.get("elements")
339
+ if not isinstance(elements, dict) or spec["root"] not in elements:
340
+ raise ReportSpecError("root must reference an element")
341
+ if len(elements) > MAX_ELEMENTS:
342
+ raise ReportSpecError(f"at most {MAX_ELEMENTS} elements are allowed")
343
+ seen_types: list[str] = []
344
+ for element_id, element in elements.items():
345
+ if not isinstance(element_id, str) or not isinstance(element, dict):
346
+ raise ReportSpecError("elements must be named objects")
347
+ component = element.get("type")
348
+ if component not in ALLOWED_COMPONENTS:
349
+ raise ReportSpecError(f"component {component!r} is not in the catalog")
350
+ seen_types.append(component)
351
+ if "props" not in element:
352
+ raise ReportSpecError(f"{element_id}.props must be present (use {{}} when empty)")
353
+ props = element["props"]
354
+ if not isinstance(props, dict):
355
+ raise ReportSpecError(f"{element_id}.props must be an object")
356
+ _validate_props(props)
357
+ allowed_props = {
358
+ "FindingGrid": {"findingIds"}, "ChartGrid": {"chartIds"},
359
+ "CasePreview": {"caseIds"},
360
+ }.get(str(component), set())
361
+ unknown_props = set(props).difference(allowed_props)
362
+ if unknown_props:
363
+ raise ReportSpecError(f"{element_id} has unknown props: {', '.join(sorted(unknown_props))}")
364
+ if component == "FindingGrid" and len(props.get("findingIds") or []) > 3:
365
+ raise ReportSpecError("FindingGrid accepts at most 3 findings")
366
+ if component == "ChartGrid" and len(props.get("chartIds") or []) > 2:
367
+ raise ReportSpecError("ChartGrid accepts at most 2 charts")
368
+ if data is not None:
369
+ _validate_data_refs(component, props, data)
370
+ children = element.get("children", [])
371
+ if children is not None and (
372
+ not isinstance(children, list) or not all(isinstance(child, str) for child in children)
373
+ ):
374
+ raise ReportSpecError(f"{element_id}.children must be element ids")
375
+ for child in children or []:
376
+ if child not in elements:
377
+ raise ReportSpecError(f"unknown child {child!r}")
378
+ missing = REQUIRED_COMPONENTS.difference(seen_types)
379
+ if missing:
380
+ raise ReportSpecError(f"missing required components: {', '.join(sorted(missing))}")
381
+ if not PIPELINE_COMPONENTS.intersection(seen_types):
382
+ raise ReportSpecError("missing required components: Journey or LoopFigure")
383
+ _check_tree_depth(spec["root"], elements, set(), 1)
384
+ return {"root": spec["root"], "elements": elements}
385
+
386
+
387
+ def publish_report(
388
+ run_dir: str | Path,
389
+ *,
390
+ example_dir: str | Path | None = None,
391
+ model: Any | None = None,
392
+ run_logger: Any | None = None,
393
+ ) -> PublishedReport:
394
+ """Compile, compose, validate, cache, and optionally log a completed report."""
395
+ root = Path(run_dir).resolve()
396
+ data = build_report_data(root, example_dir=example_dir)
397
+ mode = "deterministic"
398
+ model_name: str | None = None
399
+ spec = fallback_spec(data)
400
+ if model is not None:
401
+ model_name = getattr(model, "model_name", None) or getattr(model, "name", None) or type(model).__name__
402
+ try:
403
+ spec = ReportAgent(model).compose(data)
404
+ mode = "agent"
405
+ except Exception:
406
+ mode = "fallback"
407
+ spec = fallback_spec(data)
408
+ spec = validate_spec(spec, data=data)
409
+ canonical_spec = json.dumps(spec, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
410
+ digest = hashlib.sha256(canonical_spec.encode("utf-8")).hexdigest()
411
+ envelope = {
412
+ "schema_version": REPORT_SCHEMA_VERSION,
413
+ "format": "json-render",
414
+ "json_render_version": JSON_RENDER_VERSION,
415
+ "catalog_version": CATALOG_VERSION,
416
+ "trace_id": data["trace_id"],
417
+ "source_event_seq": data["source_event_seq"],
418
+ "generated_by": {"mode": mode, "model": model_name},
419
+ "spec": spec,
420
+ "sha256": digest,
421
+ }
422
+ report_dir = root / "report"
423
+ report_dir.mkdir(parents=True, exist_ok=True)
424
+ data_path = report_dir / "report_data.json"
425
+ spec_path = report_dir / "report_spec.json"
426
+ data_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
427
+ spec_path.write_text(json.dumps(envelope, ensure_ascii=False, indent=2), encoding="utf-8")
428
+ if run_logger is not None and hasattr(run_logger, "log_report_published"):
429
+ run_logger.log_report_published(envelope)
430
+ return PublishedReport(data_path, spec_path, str(data["trace_id"]), mode, digest)
431
+
432
+
433
+ def load_published_report(run_dir: str | Path) -> tuple[dict[str, Any], dict[str, Any]]:
434
+ run_root = Path(run_dir).resolve()
435
+ root = run_root / "report"
436
+ data = json.loads((root / "report_data.json").read_text(encoding="utf-8"))
437
+ envelope = json.loads((root / "report_spec.json").read_text(encoding="utf-8"))
438
+ # Reports published before the explicit-props contract omitted `props` for
439
+ # simple catalog elements. Make those immutable artifacts viewable while
440
+ # newly generated layouts are rejected and repaired by ReportAgent.
441
+ spec = envelope.get("spec")
442
+ if isinstance(spec, dict) and isinstance(spec.get("elements"), dict):
443
+ for element in spec["elements"].values():
444
+ if isinstance(element, dict):
445
+ element.setdefault("props", {})
446
+ # Do not make an old agent-composed layout disappear merely because the
447
+ # renderer learned a richer data projection. Rebuild its data in memory;
448
+ # the saved layout and its provenance remain untouched.
449
+ if int(data.get("version") or 0) < REPORT_DATA_VERSION:
450
+ data = build_report_data(run_root)
451
+ return data, envelope
452
+
453
+
454
+ def report_is_current(run_dir: str | Path) -> bool:
455
+ """Whether the cached layout covers every non-publication run event."""
456
+ root = Path(run_dir).resolve()
457
+ try:
458
+ _, envelope = load_published_report(root)
459
+ cached_seq = int(envelope.get("source_event_seq") or 0)
460
+ except (OSError, ValueError, TypeError, json.JSONDecodeError):
461
+ return False
462
+ evidence_seq = max(
463
+ (
464
+ int(event.get("event_seq") or 0)
465
+ for event in _read_events(root)
466
+ if event.get("event") != "report_published"
467
+ ),
468
+ default=0,
469
+ )
470
+ return cached_seq >= evidence_seq
471
+
472
+
473
+ #: CLI providers under the names their vendors use. A provider absent here is
474
+ #: shown as the run recorded it — a slug is a poor label but an honest one, and
475
+ #: better than a guess at what product it belongs to.
476
+ _AGENT_LABEL = {
477
+ "claude": "Claude Code", "claude_code": "Claude Code",
478
+ "agy": "Antigravity", "antigravity": "Antigravity",
479
+ "codex": "Codex", "gemini_cli": "Gemini CLI", "opencode": "OpenCode",
480
+ "kimi_cli": "Kimi CLI", "llm": "LLM",
481
+ }
482
+
483
+
484
+ #: Extensions a run's cover figure may use, with the media type each carries.
485
+ _HERO_IMAGE_TYPES = {
486
+ ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
487
+ ".svg": "image/svg+xml",
488
+ }
489
+
490
+ #: Above this, embedding the cover would dominate the payload the browser has
491
+ #: to parse before anything renders — the figure is skipped, not downscaled,
492
+ #: because resampling someone's diagram is worse than not showing it.
493
+ _HERO_IMAGE_MAX_BYTES = 10_000_000
494
+
495
+
496
+ def _hero_image(root: Path) -> str:
497
+ """The run's own cover figure as a data URI, or "" when it ships none.
498
+
499
+ A run directory may carry ``evalrx_main.(png|jpg|jpeg|svg)`` at its
500
+ top level — beside baseline.json, where a person browsing the folder would
501
+ put the one picture that explains the run (a pipeline diagram, a headline
502
+ chart). The report's hero has always reserved its right half for a
503
+ decorative void; a run that brought its own figure fills it instead.
504
+
505
+ Embedded as a data URI rather than served by path so the portable export,
506
+ a dropped zip and the live server all render it identically — and checked
507
+ in the logs directory as well as its parent, because ``root`` is the logs
508
+ dir for a benchmark run and the run root for a flat one.
509
+ """
510
+ candidates = [root] + ([root.parent] if root.name == "logs" else [])
511
+ for directory in candidates:
512
+ for ext, mime in _HERO_IMAGE_TYPES.items():
513
+ path = directory / f"evalrx_main{ext}"
514
+ try:
515
+ if not path.is_file() or path.stat().st_size > _HERO_IMAGE_MAX_BYTES:
516
+ continue
517
+ payload = base64.b64encode(path.read_bytes()).decode("ascii")
518
+ except OSError:
519
+ continue
520
+ return f"data:{mime};base64,{payload}"
521
+ return ""
522
+
523
+
524
+ def _diagnosed_by(root: Path, run: Mapping[str, Any]) -> str:
525
+ """Which agent drove this run, as one display string, or "" if unrecorded.
526
+
527
+ Two runs of the same benchmark against the same model differ ONLY in the
528
+ agent that drove them, and the report never said which — open both and they
529
+ are indistinguishable. The run manifest records the choice cleanly
530
+ (`judge_provider` / `judge_model`); the run_start event's `coder`
531
+ ("claude_code:opus") is the fallback for a run written before the manifest,
532
+ and its `judge` is a repr, so it is read only for its provider prefix.
533
+ """
534
+ config = _load_json(root / "manifest.json")
535
+ config = (config or {}).get("config") if isinstance(config, Mapping) else None
536
+ provider = model = ""
537
+ if isinstance(config, Mapping):
538
+ provider = str(config.get("judge_provider") or "")
539
+ model = str(config.get("judge_model") or "")
540
+ if not provider:
541
+ coder = str(run.get("coder") or "")
542
+ if ":" in coder:
543
+ provider, _, model = coder.partition(":")
544
+ else:
545
+ provider = coder
546
+ if not provider:
547
+ return ""
548
+ label = _AGENT_LABEL.get(provider.strip().lower(), provider.strip())
549
+ return f"{label} · {model.strip()}" if model.strip() else label
550
+
551
+
552
+ def _stages(raw: Mapping[str, Any], m5_detail: Mapping[str, Any] | None = None) -> list[dict[str, Any]]:
553
+ definitions = [
554
+ ("M1", "Check behavior", "See what the model does differently when it succeeds or fails", raw.get("m1"), "results"),
555
+ ("M2", "Find patterns", "Look for behaviors that reliably appear alongside errors", raw.get("m2"), "stats"),
556
+ ("M3", "Suggest causes", "Turn the strongest patterns into ideas that can be tested", raw.get("m3"), "hypotheses"),
557
+ ("M4", "Test on new cases", "Check those ideas on evidence not used to create them", raw.get("m4"), "results"),
558
+ ("M5", "Try repairs", "Compare possible fixes with the unchanged model", raw.get("m5_fix"), "selection"),
559
+ ]
560
+ result = []
561
+ for code, title, purpose, payload, evidence_key in definitions:
562
+ stage = dict(payload or {})
563
+ evidence = stage.get(evidence_key) or []
564
+ ran = bool(stage.get("ran")) if "ran" in stage else bool(evidence or stage.get("duration"))
565
+ status = "completed" if ran else "not-run"
566
+ if code == "M5" and m5_detail and m5_detail.get("skipped"):
567
+ status = "skipped"
568
+ if code == "M5" and ran:
569
+ status = "improved" if stage.get("fixed") else "no-improvement"
570
+ result.append({
571
+ "id": code.lower(), "code": code, "title": title, "purpose": purpose,
572
+ "status": status, "evidence_count": len(evidence) if isinstance(evidence, list) else 1,
573
+ })
574
+ return result
575
+
576
+
577
+ def _stage_detail(
578
+ raw: Mapping[str, Any], root: Path, cases: list[dict[str, Any]], events: list[Mapping[str, Any]]
579
+ ) -> dict[str, Any]:
580
+ """Compile the mature stage-specific views into a bounded web contract.
581
+
582
+ The contract preserves the old workbench's load-bearing distinctions:
583
+ M2 is descriptive, M3 is proposal-only, M4 owns held-out verdicts, and M5
584
+ compares every intervention against the unchanged baseline.
585
+ """
586
+ logs_dir = Path(raw.get("logs_dir") or root)
587
+ m1 = dict(raw.get("m1") or {})
588
+ probes = []
589
+ for result in m1.get("results") or []:
590
+ if not isinstance(result, dict):
591
+ continue
592
+ rows = list(result.get("per_case") or [])
593
+ probes.append({
594
+ "id": str(result.get("name") or "probe"),
595
+ # The plain question when the label table has one; otherwise the
596
+ # glossary's name ("Self-Repair on Re-ask"), never a Title-Cased id.
597
+ "title": (PLAIN_LABELS.get(str(result.get("name") or ""))
598
+ or str(result.get("display_name") or "")
599
+ or _plain_label(result.get("name") or "Probe")),
600
+ # The glossary's own name for the check ("Self-Repair on Re-ask"),
601
+ # as opposed to `title`, which is the plain question when one exists.
602
+ "display_name": str(result.get("display_name") or ""),
603
+ "raw_name": str(result.get("name") or ""),
604
+ "question": str(result.get("question") or ""),
605
+ "description": str(result.get("description") or ""),
606
+ # None when the analyzer reported no case count at all (a batch-level
607
+ # check such as self_consistency): "unknown" is not "zero".
608
+ "n_cases": result.get("n") if result.get("n") is not None else (len(rows) or None),
609
+ "metrics": list(result.get("headline") or []),
610
+ "finding_summary": _plain_mapping(_compact_mapping(result.get("findings"))),
611
+ "raw_finding_summary": _compact_mapping(result.get("findings")),
612
+ "sample_rows": rows[:12],
613
+ "n_sample_rows": len(rows),
614
+ })
615
+
616
+ m2 = dict(raw.get("m2") or {})
617
+ explore = dict(m2.get("explore") or {})
618
+ figure_dir = Path(raw.get("explore_dir") or logs_dir.parent / "explore") / "figures"
619
+ figures = _explore_figures(explore, figure_dir, root)
620
+ if not figures:
621
+ # No explore/ directory beside the logs (a run copied as logs/ alone,
622
+ # or a V2 run): the explore and analysis events name their figures as
623
+ # paths under logs/, and RunLoggerV2 copied them into M2/artifacts/.
624
+ figures = _logged_figures(
625
+ [*(explore.get("figures") or []), *(m2.get("figures") or [])], logs_dir, root)
626
+ takeaways = []
627
+ for item in explore.get("takeaways") or []:
628
+ if isinstance(item, dict):
629
+ takeaways.append({key: item.get(key) for key in (
630
+ "plain_title", "title", "analysis", "caveat", "chart_names", "table_names",
631
+ )})
632
+
633
+ judges = list((raw.get("agents") or {}).get("judge_calls") or [])
634
+ m3_call = next((call for call in judges if "M3" in str(call.get("stage") or "")), {})
635
+ agent_response = str(m3_call.get("response") or "")
636
+ m3 = dict(raw.get("m3") or {})
637
+ hypotheses = list(m3.get("hypotheses") or explore.get("hypotheses") or [])
638
+ # Runs recorded before the log writer carried `plain_statement` on this list
639
+ # still have it on `proposed_hypotheses`, which the critic step copies from.
640
+ # Joining on the statement recovers the plain sentence the judge did write,
641
+ # instead of leaving the report showing only the technical line. A run with
642
+ # neither is left exactly as it is — nothing is paraphrased into existence.
643
+ proposed = {str(h.get("statement") or ""): h
644
+ for h in (m3.get("proposed_hypotheses") or []) if isinstance(h, Mapping)}
645
+ for h in hypotheses:
646
+ if isinstance(h, dict) and not h.get("plain_statement"):
647
+ recovered = proposed.get(str(h.get("statement") or ""), {}).get("plain_statement")
648
+ if recovered:
649
+ h["plain_statement"] = recovered
650
+
651
+ m4 = dict(raw.get("m4") or {})
652
+ saved_m4 = _load_json(logs_dir / "report" / "m4_results.json")
653
+ m4_results = saved_m4 if isinstance(saved_m4, list) and saved_m4 else list(m4.get("results") or [])
654
+ m4_event = dict(m4.get("event") or {})
655
+
656
+ m5 = dict(raw.get("m5_fix") or {})
657
+ # html_report may see an older sibling log when an example contains
658
+ # multiple attempts. The event stream is trace-filtered and is the source
659
+ # of truth for the run currently being rendered.
660
+ fix_event = next((event for event in reversed(events) if event.get("event") == "fix"), {})
661
+ skipped_event = next((
662
+ event for event in reversed(events)
663
+ if event.get("event") == "stage_skipped" and str(event.get("stage")) == "M5"
664
+ ), {})
665
+ if fix_event:
666
+ attempted = [item for item in fix_event.get("attempted") or [] if isinstance(item, Mapping)]
667
+ best_name = fix_event.get("best")
668
+ best = next((item for item in attempted if item.get("name") == best_name), {})
669
+ m5.update({
670
+ "ran": True, "fixed": bool(fix_event.get("fixed")), "selection": attempted,
671
+ "best": best, "confirm": best, "recommendation": fix_event.get("recommendation"),
672
+ })
673
+ if skipped_event:
674
+ m5.update({
675
+ "ran": False,
676
+ "skipped": True,
677
+ "skip_reason": skipped_event.get("reason_code") or "not_attempted",
678
+ "skip_detail": skipped_event.get("detail") or "",
679
+ })
680
+ candidates = [_repair_candidate(item) for item in m5.get("selection") or [] if isinstance(item, dict)]
681
+ confirmation = _repair_candidate(m5.get("confirm") or {})
682
+ if confirmation.get("name"):
683
+ existing = next((i for i, item in enumerate(candidates) if item.get("name") == confirmation["name"]), None)
684
+ if existing is None:
685
+ candidates.append(confirmation)
686
+ else:
687
+ candidates[existing] = {**candidates[existing], **confirmation}
688
+ # Examples are a first-class, bounded evidence type. They are deliberately
689
+ # derived from recorded case I/O and per-case measurements, never written
690
+ # by the report agent. A single case makes a measurement legible; it does
691
+ # not establish the stage conclusion (the UI states that boundary too).
692
+ logged_m1_examples = [
693
+ item for event in events if event.get("event") == "probe"
694
+ for item in (event.get("examples") or []) if isinstance(item, Mapping)
695
+ ]
696
+ logged_m4_event = next((event for event in reversed(events) if event.get("event") == "surgery" and event.get("module") == "m4"), {})
697
+ m1_examples = [dict(item) for item in logged_m1_examples[:2]] or _m1_examples(probes, cases)
698
+ m4_examples = _m4_examples(m4_results, {**m4_event, **logged_m4_event}, cases)
699
+ m1_examples = _attach_example_media(m1_examples, cases)
700
+ m4_examples = _attach_example_media(m4_examples, cases)
701
+ enriched_stats = _enrich_m2_stats(
702
+ [item for item in m2.get("stats") or [] if isinstance(item, Mapping)], logs_dir
703
+ )
704
+ display_stats = [_display_stat(item) for item in enriched_stats]
705
+ repair_examples = _m5_examples(candidates, cases, list(m1.get("results") or []))
706
+ # A trajectory is the only source allowed to say that an agent actually
707
+ # used an intermediate operation (zoom, crop, search, ...). Candidate
708
+ # specs are kept separately below because a proposed operation is not an
709
+ # executed one.
710
+ operation_examples = _operation_examples(cases)
711
+ repair_operation_previews = _repair_operation_previews(candidates, cases)
712
+ m1_calls, m1_n_calls = _m1_calls(events)
713
+
714
+ return {
715
+ "m1": {
716
+ "duration": m1.get("duration"), "probes": probes,
717
+ "n_probes": len(probes), "n_measured": max((int(p.get("n_cases") or 0) for p in probes), default=0),
718
+ "examples": m1_examples, "operations": operation_examples,
719
+ # What each probe actually asked the model and what came back,
720
+ # grouped by analyzer: the audit trail behind the numbers above.
721
+ "calls": m1_calls, "n_calls": m1_n_calls,
722
+ },
723
+ "m2": {
724
+ "mode": "descriptive", "conclusion": m2.get("conclusion") or "",
725
+ "narrative": m2.get("narrative") or "", "stats": display_stats,
726
+ "takeaways": takeaways, "figures": figures,
727
+ "observations": list(explore.get("observations") or []),
728
+ "caveats": list(explore.get("caveats") or []),
729
+ "candidate_signals": list(explore.get("candidate_signals") or []),
730
+ "recommended_tests": list(explore.get("recommended_confirmatory_tests") or []),
731
+ },
732
+ "m3": {
733
+ "mode": "proposal", "hypotheses": hypotheses,
734
+ "unparsed_proposals": _recover_unparsed_hypotheses(agent_response) if not hypotheses else [],
735
+ "agent_response": agent_response,
736
+ "candidate_signals": list(explore.get("candidate_signals") or []),
737
+ "recommended_tests": list(explore.get("recommended_confirmatory_tests") or []),
738
+ "evidence_figures": figures[:3], "evidence_stats": display_stats[:6],
739
+ },
740
+ "m4": {
741
+ "ran": bool(m4.get("ran") or m4_results), "mode": "confirmatory",
742
+ "results": m4_results, "event": m4_event, "examples": m4_examples,
743
+ },
744
+ "m5": {
745
+ "ran": bool(m5.get("ran")), "fixed": bool(m5.get("fixed")),
746
+ "skipped": bool(m5.get("skipped")),
747
+ "skip_reason": str(m5.get("skip_reason") or ""),
748
+ "skip_detail": str(m5.get("skip_detail") or ""),
749
+ "candidates": candidates, "confirmation": confirmation,
750
+ "best": _repair_candidate(m5.get("best") or {}),
751
+ # The FixAgent's own verdict on the run — action + the sentence that
752
+ # justifies it ("underpowered: only 4 failing cases…"). This is the
753
+ # line the health card's promotion gate prints.
754
+ "recommendation": (dict(m5["recommendation"])
755
+ if isinstance(m5.get("recommendation"), Mapping) else None),
756
+ "examples": repair_examples,
757
+ "operation_previews": repair_operation_previews,
758
+ "surgeries": list((raw.get("m5_surgery") or {}).get("surgeries") or []),
759
+ "prompt_template": str(m5.get("prompt_template") or ""),
760
+ },
761
+ }
762
+
763
+
764
+ def _m1_examples(probes: list[Mapping[str, Any]], cases: list[Mapping[str, Any]]) -> list[dict[str, Any]]:
765
+ """Pick recorded, human-readable M1 case walk-throughs.
766
+
767
+ Recent loggers may include ``examples`` directly on a probe. Older runs
768
+ are reconstructed by joining a per-case measurement to the saved baseline
769
+ case record. The latter intentionally says *check result*, not
770
+ *after-answer*: many analyzers do not issue a second generation.
771
+ """
772
+ case_by_id = {str(case.get("id")): case for case in cases}
773
+ result: list[dict[str, Any]] = []
774
+ used: set[str] = set()
775
+ for probe in probes:
776
+ supplied = probe.get("examples")
777
+ if isinstance(supplied, list):
778
+ for item in supplied:
779
+ if isinstance(item, Mapping):
780
+ result.append(dict(item))
781
+ if len(result) >= 2:
782
+ return result
783
+ rows = [row for row in probe.get("sample_rows") or [] if isinstance(row, Mapping)]
784
+ # Failures are most intuitive when available. A pass is still useful
785
+ # when the run has no labelled failures in this particular probe.
786
+ rows.sort(key=lambda row: 0 if str(case_by_id.get(str(row.get("sample_id")), {}).get("status")) == "fail" else 1)
787
+ for row in rows:
788
+ case_id = str(row.get("sample_id") or row.get("case_id") or "")
789
+ case = case_by_id.get(case_id)
790
+ if not case or case_id in used:
791
+ continue
792
+ measured = {
793
+ str(key).replace("_", " "): value
794
+ for key, value in row.items()
795
+ if key not in {"sample_id", "case_id"} and isinstance(value, (str, int, float, bool))
796
+ }
797
+ if not measured:
798
+ continue
799
+ used.add(case_id)
800
+ result.append({
801
+ "id": f"m1-{probe.get('id', 'probe')}-{case_id}",
802
+ "kind": "case_measurement",
803
+ "case_id": case_id,
804
+ "probe_title": probe.get("title") or "Behavioral check",
805
+ "probe_question": probe.get("question") or "",
806
+ "input": case.get("prompt") or "",
807
+ "baseline_output": case.get("observed"),
808
+ "expected": case.get("expected"),
809
+ "outcome": case.get("status") or "unknown",
810
+ "media_ids": list(case.get("media_ids") or []),
811
+ "check_result": measured,
812
+ "plain_reading": "This one case shows what the check records. The overall pattern comes from every measured case, not this example alone.",
813
+ "evidence_scope": "one recorded case within M1",
814
+ })
815
+ break
816
+ if len(result) >= 2:
817
+ break
818
+ return result
819
+
820
+
821
+ def _attach_example_media(examples: list[dict[str, Any]], cases: list[Mapping[str, Any]]) -> list[dict[str, Any]]:
822
+ case_by_id = {str(case.get("id")): case for case in cases}
823
+ for example in examples:
824
+ if example.get("probe_title"):
825
+ example["probe_title"] = _plain_label(example["probe_title"])
826
+ if example.get("media_ids"):
827
+ continue
828
+ case = case_by_id.get(str(example.get("case_id") or ""))
829
+ if case:
830
+ example["media_ids"] = list(case.get("media_ids") or [])
831
+ return examples
832
+
833
+
834
+ def _m4_examples(
835
+ results: list[Any], event: Mapping[str, Any], cases: list[Mapping[str, Any]]
836
+ ) -> list[dict[str, Any]]:
837
+ """Return explicit validation examples, with a truthful aggregate fallback."""
838
+ supplied = event.get("validation_examples") if isinstance(event, Mapping) else None
839
+ if isinstance(supplied, list):
840
+ first = next((item for item in results if isinstance(item, Mapping)), {})
841
+ evidence = first.get("evidence") if isinstance(first.get("evidence"), Mapping) else {}
842
+ kept = [{
843
+ **dict(item),
844
+ "hypothesis": item.get("hypothesis") or first.get("hypothesis") or first.get("statement"),
845
+ "test": item.get("test") or first.get("test_name") or evidence.get("chosen_tool"),
846
+ "status": item.get("status") or first.get("status"),
847
+ } for item in supplied if isinstance(item, Mapping)]
848
+ if kept:
849
+ return kept[:2]
850
+ if not results:
851
+ return []
852
+ first = next((item for item in results if isinstance(item, Mapping)), None)
853
+ if first is None:
854
+ return []
855
+ evidence = first.get("evidence") if isinstance(first.get("evidence"), Mapping) else {}
856
+ # Legacy M4 logs retain the frozen claim and the actual validation statistic
857
+ # but not a row-level verdict. That is still a concrete example of the
858
+ # validation operation; label it as an aggregate test rather than pretend a
859
+ # case was individually adjudicated.
860
+ return [{
861
+ "id": "m4-validation-test-1",
862
+ "kind": "validation_test",
863
+ "hypothesis": first.get("hypothesis") or first.get("statement") or "Frozen hypothesis",
864
+ "test": first.get("test_name") or evidence.get("chosen_tool") or "held-out statistical test",
865
+ "effect": first.get("effect_size", evidence.get("effect_size")),
866
+ "interval": evidence.get("ci"),
867
+ "status": first.get("status") or "inconclusive",
868
+ "verdict": first.get("verdict") or evidence.get("m4_verdict") or "No validation explanation was retained.",
869
+ "plain_reading": "This is one frozen claim tested on the independent validation evidence. The verdict uses the full validation set, not a single hand-picked case.",
870
+ "evidence_scope": "aggregate independent-validation test",
871
+ }]
872
+
873
+
874
+ def _display_stat(value: Mapping[str, Any]) -> dict[str, Any]:
875
+ """Keep statistical evidence machine-auditable but primary labels human."""
876
+ config = value.get("config") if isinstance(value.get("config"), Mapping) else {}
877
+ signal = str(config.get("signal") or value.get("signal") or value.get("tool") or "")
878
+ details = value.get("details") if isinstance(value.get("details"), Mapping) else {}
879
+ # A PAIRED tool compares two strategies and carries no `signal`, so `signal`
880
+ # falls through to the tool name and every such row was labelled with the
881
+ # same words ("Mcnemar Evalue"). Three identically-labelled bars is not a
882
+ # chart. The pair it actually compared is right there in the config, and it
883
+ # is the only thing that tells the rows apart.
884
+ groups = [str(name) for name in (config.get("strategies") or []) if name]
885
+ label = _stat_label(config, signal, value.get("tool"))
886
+ return {
887
+ "label": label, "raw_signal": signal, "groups": groups,
888
+ "effect": value.get("effect"), "ci": value.get("ci"),
889
+ "reject": bool(value.get("reject")), "underpowered": bool(value.get("underpowered")),
890
+ "summary": value.get("summary") or "", "p_value": value.get("p_value"),
891
+ "tool": value.get("tool"), "e_value": value.get("e_value"),
892
+ "fail_rate_signal": details.get("fail_rate_signal"),
893
+ "fail_rate_control": details.get("fail_rate_control"),
894
+ }
895
+
896
+
897
+ def _enrich_m2_stats(stats: list[Mapping[str, Any]], logs_dir: Path) -> list[dict[str, Any]]:
898
+ """Restore detailed M2 rate fields from durable tool-result artifacts.
899
+
900
+ The compact HTML reader deliberately omits bulky ``details``. Its
901
+ omission used to leave the dynamic M2 view with only one generic effect
902
+ chart, despite the run already having stored the two groups needed for a
903
+ direct error-rate comparison. This is a local join keyed by the exact
904
+ test and signal; no values are re-computed or inferred.
905
+ """
906
+ artifact_rows: dict[tuple[str, str], Mapping[str, Any]] = {}
907
+ for path in sorted((logs_dir / "artifacts").glob("*m2_stats_results*.json")):
908
+ value = _load_json(path)
909
+ if not isinstance(value, list):
910
+ continue
911
+ for item in value:
912
+ if not isinstance(item, Mapping):
913
+ continue
914
+ config = item.get("config") if isinstance(item.get("config"), Mapping) else {}
915
+ key = (str(item.get("tool") or ""), str(config.get("signal") or item.get("signal") or ""))
916
+ if key[1]:
917
+ artifact_rows[key] = item
918
+ result: list[dict[str, Any]] = []
919
+ for item in stats:
920
+ config = item.get("config") if isinstance(item.get("config"), Mapping) else {}
921
+ key = (str(item.get("tool") or ""), str(config.get("signal") or item.get("signal") or ""))
922
+ saved = artifact_rows.get(key)
923
+ result.append({**dict(item), **({"details": saved.get("details")} if saved and saved.get("details") else {})})
924
+ return result
925
+
926
+
927
+ def _operation_examples(cases: list[Mapping[str, Any]]) -> list[dict[str, Any]]:
928
+ """Project recorded agent tool trajectories into compact, case-linked UI cards.
929
+
930
+ This intentionally ignores prompt text that *mentions* a tool. An action
931
+ appears only if a persisted trajectory step carries a structured
932
+ ``tool_call``. That preserves the distinction between an actual zoom and
933
+ an LLM merely proposing one.
934
+ """
935
+ result: list[dict[str, Any]] = []
936
+ for case in cases:
937
+ trajectory = case.get("trajectory")
938
+ if not isinstance(trajectory, Mapping):
939
+ continue
940
+ steps = trajectory.get("steps")
941
+ if not isinstance(steps, list):
942
+ continue
943
+ calls = []
944
+ for step in steps:
945
+ if not isinstance(step, Mapping) or not isinstance(step.get("tool_call"), Mapping):
946
+ continue
947
+ call = step["tool_call"]
948
+ name = str(call.get("name") or "tool")
949
+ calls.append({
950
+ "order": int(step.get("idx") or len(calls) + 1),
951
+ "action": _plain_operation(name), "raw_action": name,
952
+ "parameters": dict(call.get("args") or {}) if isinstance(call.get("args"), Mapping) else {},
953
+ "thought": str(step.get("content") or "")[:500],
954
+ })
955
+ if not calls:
956
+ continue
957
+ result.append({
958
+ "id": f"operations-{case.get('id')}", "case_id": str(case.get("id") or ""),
959
+ "input": case.get("prompt") or "", "expected": case.get("expected"),
960
+ "observed": case.get("observed"), "outcome": case.get("status") or "unknown",
961
+ "media_ids": list(case.get("media_ids") or []), "steps": calls[:6],
962
+ "plain_reading": "These are the actions recorded in this agent's own trajectory for this case.",
963
+ })
964
+ if len(result) >= 2:
965
+ break
966
+ return result
967
+
968
+
969
+ def _repair_operation_previews(
970
+ candidates: list[Mapping[str, Any]], cases: list[Mapping[str, Any]]
971
+ ) -> list[dict[str, Any]]:
972
+ """Show what an image-operation repair *would* apply, without claiming execution.
973
+
974
+ Older runs preserve a repair spec but not a per-call visual artifact. The
975
+ report can still pair its declared image operations with the original input
976
+ image for a representative case, as long as the card is explicitly marked
977
+ as a candidate preview. Actual execution is represented by
978
+ :func:`_operation_examples` instead.
979
+ """
980
+ image_cases = [case for case in cases if case.get("media_ids")]
981
+ result: list[dict[str, Any]] = []
982
+ for candidate in candidates:
983
+ payload = candidate.get("payload") if isinstance(candidate.get("payload"), Mapping) else {}
984
+ ops = [op for op in payload.get("image_ops") or [] if isinstance(op, Mapping) and op.get("tool")]
985
+ if not ops:
986
+ continue
987
+ preferred_ids = [str(item) for item in (candidate.get("fixed_cases") or candidate.get("broken_cases") or [])]
988
+ case = next((item for item in image_cases if str(item.get("id")) in preferred_ids), None)
989
+ case = case or next((item for item in image_cases if str(item.get("status")) == "fail"), None)
990
+ # A legacy run can retain its generated baseline answers in a separate
991
+ # artifact whose ids no longer line up with the saved image manifest.
992
+ # In that case an arbitrary corpus image is still useful to explain the
993
+ # *declared transform*, provided the UI calls it a preview and never a
994
+ # validated before/after result.
995
+ case = case or (image_cases[0] if image_cases else None)
996
+ if case is None:
997
+ continue
998
+ result.append({
999
+ "id": f"repair-operations-{candidate.get('name')}", "candidate": candidate.get("name"),
1000
+ "case_id": str(case.get("id") or ""), "input": case.get("prompt") or "",
1001
+ "expected": case.get("expected"), "observed": case.get("observed"),
1002
+ "media_ids": list(case.get("media_ids") or []),
1003
+ "executed": bool(candidate.get("n_pairs")),
1004
+ "operations": [{
1005
+ "action": _plain_operation(op.get("tool")), "raw_action": str(op.get("tool")),
1006
+ "parameters": dict(op.get("params") or {}) if isinstance(op.get("params"), Mapping) else {},
1007
+ } for op in ops],
1008
+ })
1009
+ if len(result) >= 2:
1010
+ break
1011
+ return result
1012
+
1013
+
1014
+ def _plain_operation(value: Any) -> str:
1015
+ names = {
1016
+ "image_zoom_in": "Zoom in on part of the image", "zoom_center": "Zoom into the chart center",
1017
+ "crop": "Crop the visual evidence", "crop_case_bbox": "Crop the relevant chart region",
1018
+ "sharpen": "Sharpen chart details", "contrast": "Increase visual contrast",
1019
+ "equalize": "Balance the image contrast", "detect": "Locate an object or region",
1020
+ }
1021
+ raw = str(value or "")
1022
+ return names.get(raw, _plain_label(raw))
1023
+
1024
+
1025
+ def _m5_examples(
1026
+ candidates: list[Mapping[str, Any]], cases: list[Mapping[str, Any]], probe_results: list[Any]
1027
+ ) -> list[dict[str, Any]]:
1028
+ """Reconstruct a truthful before/after repair example — confirmed or not.
1029
+
1030
+ A candidate that never cleared the significance bar is still what the
1031
+ search actually tried, and a reader debugging a failed repair needs to
1032
+ see a real case, not just "no candidate passed the repair gate". Prefer a
1033
+ `fixed` winner (statistically significant, net-positive); when none
1034
+ exists, fall back to the strongest attempt by effect size — the same
1035
+ fallback `M5Detail` already uses for its own "best repair" KPI — and tag
1036
+ the example `confirmed: False` so the UI can say plainly it is one
1037
+ candidate's attempt, not an accepted repair. When that candidate never
1038
+ flipped a case to correct either, fall back once more to a case it broke:
1039
+ still evidence, just of the failure mode rather than the fix.
1040
+ """
1041
+ winner = next((item for item in candidates if item.get("fixed")), None)
1042
+ confirmed = winner is not None
1043
+ if winner is None:
1044
+ winner = max(
1045
+ (item for item in candidates if isinstance(item.get("effect"), (int, float))),
1046
+ key=lambda item: item["effect"],
1047
+ default=(candidates[0] if candidates else None),
1048
+ )
1049
+ if winner is None:
1050
+ return []
1051
+ case_by_id = {str(case.get("id")): case for case in cases}
1052
+ baseline_by_id: dict[str, Any] = {}
1053
+ for probe in probe_results:
1054
+ if not isinstance(probe, Mapping):
1055
+ continue
1056
+ for row in probe.get("per_case") or []:
1057
+ if not isinstance(row, Mapping):
1058
+ continue
1059
+ case_id = str(row.get("sample_id") or row.get("case_id") or "")
1060
+ output = row.get("extracted_answer", row.get("modal_answer"))
1061
+ if case_id and output not in (None, ""):
1062
+ baseline_by_id.setdefault(case_id, output)
1063
+
1064
+ def _build(case_id: str, kind: str) -> "dict[str, Any] | None":
1065
+ case = case_by_id.get(case_id)
1066
+ if not case:
1067
+ return None
1068
+ # `case["observed"]` is the Stage-0 baseline, written once per case id
1069
+ # (RunLoggerV2.log_cases dedupes by id) and never updated — it is NOT
1070
+ # this candidate's repaired answer, no matter which case_record last
1071
+ # touched it. The only place a candidate's own per-case output lives
1072
+ # is `case["repair"]`, which build_report_data attached from that
1073
+ # attempt's trial_root/outputs.jsonl (see _repair_outcomes). Confirm
1074
+ # it is THIS candidate's repair, not some other one that also touched
1075
+ # this case, before trusting it as "repaired".
1076
+ repair_hit = case.get("repair") if isinstance(case.get("repair"), Mapping) else None
1077
+ repaired = (
1078
+ repair_hit.get("output")
1079
+ if repair_hit and repair_hit.get("candidate") == winner.get("name")
1080
+ else None
1081
+ )
1082
+ if repaired in (None, ""):
1083
+ return None # no recorded per-case output for this candidate — nothing truthful to show
1084
+ baseline = baseline_by_id.get(case_id)
1085
+ if baseline in (None, ""):
1086
+ baseline = case.get("observed") # the genuine Stage-0 answer, as a last resort
1087
+ if confirmed:
1088
+ reading = ("This case was counted as fixed in the paired repair evaluation. "
1089
+ "The repair decision still depends on all tested cases and its "
1090
+ "regression checks.")
1091
+ elif kind == "fixed":
1092
+ reading = ("This candidate flipped this case from wrong to right, but the "
1093
+ "repair overall did not clear the significance bar against luck — "
1094
+ "read it as one attempt worth inspecting, not a confirmed fix.")
1095
+ else:
1096
+ reading = ("This candidate did not clear the significance bar, and did not "
1097
+ "flip any case to correct either — this is a case it broke instead, "
1098
+ "shown so the failure mode is inspectable.")
1099
+ return {
1100
+ "id": f"m5-{winner.get('name', 'repair')}-{case_id}", "case_id": case_id,
1101
+ "kind": kind, "confirmed": confirmed,
1102
+ "repair_name": winner.get("name") or "Recorded repair", "input": case.get("prompt") or "",
1103
+ "expected": case.get("expected"), "baseline_output": baseline,
1104
+ "repaired_output": repaired, "media_ids": list(case.get("media_ids") or []),
1105
+ "plain_reading": reading,
1106
+ "baseline_available": baseline not in (None, ""),
1107
+ }
1108
+
1109
+ for case_id in [str(item) for item in winner.get("fixed_cases") or []]:
1110
+ example = _build(case_id, "fixed")
1111
+ if example:
1112
+ return [example]
1113
+ for case_id in [str(item) for item in winner.get("broken_cases") or []]:
1114
+ example = _build(case_id, "broken")
1115
+ if example:
1116
+ return [example]
1117
+ return []
1118
+ return []
1119
+
1120
+
1121
+ def _compact_mapping(value: Any, *, limit: int = 18) -> dict[str, Any]:
1122
+ """Retain readable aggregate findings, excluding unbounded per-case maps."""
1123
+ if not isinstance(value, Mapping):
1124
+ return {}
1125
+ compact: dict[str, Any] = {}
1126
+ for key, item in value.items():
1127
+ if key in {"per_case", "per_case_values", "case_values", "samples", "caveat"}:
1128
+ continue
1129
+ if isinstance(item, (str, int, float, bool)) or item is None:
1130
+ compact[str(key)] = item
1131
+ elif isinstance(item, list) and len(item) <= 12:
1132
+ compact[str(key)] = item
1133
+ elif isinstance(item, Mapping) and len(item) <= 12:
1134
+ nested = {str(k): v for k, v in item.items() if isinstance(v, (str, int, float, bool)) or v is None}
1135
+ if nested:
1136
+ compact[str(key)] = nested
1137
+ if len(compact) >= limit:
1138
+ break
1139
+ return compact
1140
+
1141
+
1142
+ def _plain_label(value: Any) -> str:
1143
+ raw = str(value or "").strip()
1144
+ if not raw:
1145
+ return "Recorded measurement"
1146
+ if raw in PLAIN_LABELS:
1147
+ return PLAIN_LABELS[raw]
1148
+ return re.sub(r"\s+", " ", raw.replace("_", " ")).strip().capitalize()
1149
+
1150
+
1151
+ def _stat_label(config: "Mapping[str, Any] | None", signal: Any, tool: Any) -> str:
1152
+ """A name for what one statistical test measured, unique within a report.
1153
+
1154
+ A signal test names its signal. A PAIRED test names none — it compares two
1155
+ strategies — so the label used to fall through to the tool, and every paired
1156
+ row in the report came out reading "Mcnemar Evalue". The pair it compared is
1157
+ the thing that tells those rows apart, and it is already in the config.
1158
+ """
1159
+ cfg = config if isinstance(config, Mapping) else {}
1160
+ if cfg.get("signal"):
1161
+ return _plain_signal(cfg["signal"])
1162
+ groups = [str(name) for name in (cfg.get("strategies") or []) if name]
1163
+ if len(groups) >= 2:
1164
+ return f"{_plain_label(groups[-1])} vs {_plain_label(groups[0])}"
1165
+ return _plain_signal(signal or tool)
1166
+
1167
+
1168
+ def _plain_signal(value: Any) -> str:
1169
+ """Convert ``analyzer.metric`` into a question a non-expert can read."""
1170
+ raw = str(value or "")
1171
+ if not raw:
1172
+ return "Recorded behavior"
1173
+ analyzer, _, metric = raw.partition(".")
1174
+ if analyzer and metric:
1175
+ return f"{_plain_label(analyzer)} — {_plain_label(metric)}"
1176
+ return _plain_label(raw)
1177
+
1178
+
1179
+ def _plain_mapping(value: Mapping[str, Any]) -> dict[str, Any]:
1180
+ return {_plain_label(key): item for key, item in value.items()}
1181
+
1182
+
1183
+ def _visual_key(value: Any) -> str:
1184
+ return re.sub(r"[^a-z0-9]", "", Path(str(value or "")).stem.lower())
1185
+
1186
+
1187
+ def _logged_figures(paths: Iterable[Any], logs_dir: Path, root: Path) -> list[dict[str, Any]]:
1188
+ """Figures the run logged by path (relative to logs/), that exist on disk."""
1189
+ result: list[dict[str, Any]] = []
1190
+ seen: set[Path] = set()
1191
+ for item in paths:
1192
+ if not isinstance(item, str) or not item.lower().endswith(".png"):
1193
+ continue
1194
+ candidate = (logs_dir / item).resolve()
1195
+ if candidate in seen or not candidate.is_file():
1196
+ continue
1197
+ seen.add(candidate)
1198
+ stem = re.sub(r"_[0-9a-f]{8}$", "", candidate.stem) # the content-hash suffix V2 adds
1199
+ result.append({
1200
+ "id": candidate.stem, "title": stem.replace("_", " ").strip().title(),
1201
+ "question": "", "path": os.path.relpath(candidate, root), "reading": "",
1202
+ "do_not_infer": "No agent-authored interpretation was saved for this figure.",
1203
+ "disposition": "supporting", "not_promoted_reason": "",
1204
+ })
1205
+ return result[:30]
1206
+
1207
+
1208
+ def _explore_figures(explore: Mapping[str, Any], figure_dir: Path, root: Path) -> list[dict[str, Any]]:
1209
+ plans = [item for item in explore.get("visual_plan") or [] if isinstance(item, Mapping)]
1210
+ readings = [item for item in explore.get("chart_readings") or [] if isinstance(item, Mapping)]
1211
+ files = sorted(figure_dir.glob("*.png")) if figure_dir.is_dir() else []
1212
+ result: list[dict[str, Any]] = []
1213
+ used: set[Path] = set()
1214
+ for plan in plans:
1215
+ name = str(plan.get("name") or "")
1216
+ key = _visual_key(name)
1217
+ match = next((path for path in files if key and key in _visual_key(path.name)), None)
1218
+ if match is None:
1219
+ continue
1220
+ used.add(match)
1221
+ reading = next((item for item in readings if _visual_key(item.get("chart")) == key), {})
1222
+ result.append({
1223
+ "id": name, "title": str(plan.get("display_name") or name.replace("_", " ").title()),
1224
+ "question": str(plan.get("question") or ""), "path": os.path.relpath(match, root),
1225
+ "reading": str(reading.get("reading") or ""),
1226
+ "do_not_infer": str(reading.get("do_not_infer") or ""),
1227
+ "disposition": str(plan.get("disposition") or "supporting"),
1228
+ "not_promoted_reason": str(plan.get("not_promoted_reason") or ""),
1229
+ })
1230
+ for path in files:
1231
+ if path not in used:
1232
+ result.append({
1233
+ "id": path.stem, "title": path.stem.replace("_", " ").title(),
1234
+ "question": "", "path": os.path.relpath(path, root), "reading": "",
1235
+ "do_not_infer": "No agent-authored interpretation was saved for this figure.",
1236
+ "disposition": "audit", "not_promoted_reason": "Unlinked exploratory artifact",
1237
+ })
1238
+ return result[:30]
1239
+
1240
+
1241
+ def _recover_unparsed_hypotheses(response: str) -> list[dict[str, Any]]:
1242
+ """Recover display-only proposals while preserving the parser failure state."""
1243
+ proposals = []
1244
+ for block in re.split(r"\n\s*\n", response.strip()):
1245
+ fields: dict[str, str] = {}
1246
+ for line in block.splitlines():
1247
+ if ":" not in line:
1248
+ continue
1249
+ key, value = line.split(":", 1)
1250
+ fields[key.strip().lower()] = value.strip()
1251
+ if fields.get("hypothesis"):
1252
+ proposals.append({
1253
+ "statement": fields["hypothesis"],
1254
+ "plain_statement": fields.get("plain_statement", ""),
1255
+ "failure_mode": fields.get("failure_mode", ""),
1256
+ "test_design": fields.get("test", ""),
1257
+ "expected_association": fields.get("expected_association", ""),
1258
+ "accepted_by_pipeline": False,
1259
+ })
1260
+ return proposals[:12]
1261
+
1262
+
1263
+ def _load_json(path: Path) -> Any:
1264
+ try:
1265
+ return json.loads(path.read_text(encoding="utf-8"))
1266
+ except (OSError, ValueError, TypeError):
1267
+ return None
1268
+
1269
+
1270
+ def _repair_headline(value: Mapping[str, Any]) -> str:
1271
+ """The candidate's own sentence, or the host's for a candidate it authored.
1272
+
1273
+ Looking `visual_grounding` up in the fix agent's own table is not a
1274
+ consumer guessing at a slug -- it is the package that named the candidate
1275
+ saying what it does. Runs recorded before `headline` existed become
1276
+ readable this way; a judge-invented name the host never heard of still
1277
+ resolves to "" and renders blank.
1278
+ """
1279
+ headline = " ".join(str(value.get("headline") or "").split())
1280
+ if headline:
1281
+ return headline[:300]
1282
+ try:
1283
+ from evalrx.eval_agent.stages.fix_agent import _BUILTIN_DESCRIPTIONS
1284
+ except Exception:
1285
+ return ""
1286
+ return _BUILTIN_DESCRIPTIONS.get(str(value.get("name") or ""), "")
1287
+
1288
+
1289
+ def _repair_candidate(value: Any) -> dict[str, Any]:
1290
+ if not isinstance(value, Mapping):
1291
+ return {}
1292
+ fixed_cases = list(value.get("fixed_cases") or [])
1293
+ broken_cases = list(value.get("broken_cases") or [])
1294
+ payload = value.get("payload") if isinstance(value.get("payload"), Mapping) else {}
1295
+ return {
1296
+ "tier": value.get("tier"), "name": value.get("name"), "kind": value.get("kind"),
1297
+ # `ref` and `headline` are what a reader sees; `name` is only the join
1298
+ # key. Both are carried here so the legacy report path shows the same
1299
+ # identifier and the same sentence as the contract-backed views.
1300
+ "ref": value.get("ref") or "", "headline": _repair_headline(value),
1301
+ "source": value.get("source"), "verdict": value.get("verdict"),
1302
+ "n_pairs": value.get("n_pairs"), "n_baseline_correct": value.get("n_baseline_correct"),
1303
+ "n_candidate_correct": value.get("n_candidate_correct"),
1304
+ "n_fixed": value.get("n_fixed", len(fixed_cases)), "n_broken": value.get("n_broken", len(broken_cases)),
1305
+ "effect": value.get("effect"), "e_value": value.get("e_value"),
1306
+ "e_threshold": value.get("e_threshold"),
1307
+ "coverage": value.get("coverage"), "reject": value.get("reject"), "fixed": value.get("fixed"),
1308
+ "n_model_independent": value.get("n_model_independent"),
1309
+ "n_unstable": value.get("n_unstable"),
1310
+ "summary": value.get("summary"), "payload": dict(payload),
1311
+ "fixed_cases": fixed_cases[:20], "broken_cases": broken_cases[:20],
1312
+ }
1313
+
1314
+
1315
+ def _findings(reader: Mapping[str, Any], raw: Mapping[str, Any]) -> list[dict[str, Any]]:
1316
+ result = []
1317
+ for index, finding in enumerate(reader.get("key_findings") or []):
1318
+ if isinstance(finding, dict):
1319
+ result.append({"id": f"finding-{index + 1}", **finding})
1320
+ if result:
1321
+ return result
1322
+ hypotheses = (raw.get("m3") or {}).get("hypotheses") or []
1323
+ for index, hypothesis in enumerate(hypotheses[:3]):
1324
+ if isinstance(hypothesis, dict):
1325
+ result.append({
1326
+ "id": f"finding-{index + 1}",
1327
+ "title": hypothesis.get("failure_mode") or "Candidate mechanism",
1328
+ "summary": hypothesis.get("plain_statement") or hypothesis.get("statement") or "",
1329
+ "evidence_level": hypothesis.get("status") or "Proposed",
1330
+ "limitation": "See M4 for independent verification.",
1331
+ })
1332
+ return result
1333
+
1334
+
1335
+ def _charts(run: Mapping[str, Any], raw: Mapping[str, Any],
1336
+ contract: "Mapping[str, Any] | None" = None) -> list[dict[str, Any]]:
1337
+ charts: list[dict[str, Any]] = []
1338
+ labels = run.get("label_distribution") or {}
1339
+ if isinstance(labels, dict) and labels:
1340
+ charts.append({
1341
+ "id": "outcomes", "kind": "donut", "title": "Evaluation outcomes",
1342
+ "series": [{"label": str(k).title(), "value": v} for k, v in labels.items() if isinstance(v, (int, float))],
1343
+ })
1344
+ # Prefer the contract's rows: `measured` is a name for the SUBJECT and is
1345
+ # unique across the report, so the axis cannot end up with two bars reading
1346
+ # "Mcnemar evalue" (the tool) or several truncating to the same sentence.
1347
+ contract_rows = _contract_stats(contract or {})
1348
+ effect_rows = []
1349
+ for item in contract_rows[:8]:
1350
+ if isinstance(item.get("effect"), (int, float)):
1351
+ signal = (item.get("config") or {}).get("signal")
1352
+ effect_rows.append({
1353
+ "label": item.get("measured") or _plain_signal(signal or item.get("tool")),
1354
+ # The analyzer's own sentence. Absent means undocumented, and the
1355
+ # UI must say that rather than paraphrase the identifier.
1356
+ "means": item.get("means") or "",
1357
+ "raw_label": str(signal or item.get("analysis_key") or item.get("tool") or ""),
1358
+ "value": item["effect"], "highlight": bool(item.get("fdr_corrected")),
1359
+ })
1360
+ if not effect_rows:
1361
+ stats = (raw.get("m2") or {}).get("stats") or []
1362
+ for item in stats[:8]:
1363
+ if isinstance(item, dict) and isinstance(item.get("effect"), (int, float)):
1364
+ signal = ((item.get("config") or {}).get("signal") if isinstance(item.get("config"), Mapping) else None)
1365
+ effect_rows.append({
1366
+ "label": _stat_label(item.get("config"), signal, item.get("tool")),
1367
+ "raw_label": str(signal or item.get("tool") or ""),
1368
+ "value": item["effect"], "highlight": bool(item.get("reject")),
1369
+ })
1370
+ if effect_rows:
1371
+ charts.append({
1372
+ "id": "effects", "kind": "bar", "title": "Which behaviors appear most connected to errors?",
1373
+ "subtitle": "A larger bar means a stronger observed pattern. It does not prove the behavior caused the error.",
1374
+ "series": effect_rows,
1375
+ })
1376
+ return charts
1377
+
1378
+
1379
+ def _repairs(raw: Mapping[str, Any]) -> list[dict[str, Any]]:
1380
+ fix = raw.get("m5_fix") or {}
1381
+ if not fix.get("ran"):
1382
+ return []
1383
+ confirm = fix.get("confirm") if isinstance(fix.get("confirm"), Mapping) else {}
1384
+ # Early runs recorded the winning candidate as its bare name; later ones
1385
+ # record the whole candidate. Both name the same repair.
1386
+ best = fix.get("best")
1387
+ best_name = str(best.get("name") or "") if isinstance(best, Mapping) else str(best or "")
1388
+ return [{
1389
+ "id": "repair-1", "fixed": bool(fix.get("fixed")),
1390
+ "title": best_name or confirm.get("name") or "Targeted repair",
1391
+ "effect": confirm.get("effect"),
1392
+ "fixed_cases": len(confirm.get("fixed_cases") or []),
1393
+ "broken_cases": len(confirm.get("broken_cases") or []),
1394
+ }]
1395
+
1396
+
1397
+ def _metrics(run: Mapping[str, Any], raw: Mapping[str, Any], cases: list[dict[str, Any]]) -> list[dict[str, Any]]:
1398
+ labels = run.get("label_distribution") or {}
1399
+ failed = labels.get("fail", labels.get("FAIL", 0)) if isinstance(labels, dict) else 0
1400
+ verified = len((raw.get("m4") or {}).get("results") or [])
1401
+ fixed = sum(1 for case in cases if case.get("status") == "fixed")
1402
+ return [
1403
+ {"id": "evaluated", "label": "Cases evaluated", "value": int(run.get("n_cases") or len(cases))},
1404
+ {"id": "failed", "label": "Initial failures", "value": int(failed or 0)},
1405
+ {"id": "verified", "label": "Mechanisms checked", "value": verified},
1406
+ {"id": "fixed", "label": "Cases repaired", "value": fixed},
1407
+ ]
1408
+
1409
+
1410
+ def _contract_payloads(root: Path) -> dict[str, Any]:
1411
+ """The validated per-stage payloads a run emitted, keyed by span id.
1412
+
1413
+ Passed through verbatim. ``stage_detail`` above is a hand-built view whose
1414
+ every field access is a defensive ``.get(x) or y`` — it has to be, because
1415
+ it reads whatever the run log happened to contain. These payloads were
1416
+ validated against the wire models on the way out, so a frontend can decode
1417
+ them with the generated TypeScript instead of re-deriving the shape.
1418
+
1419
+ Absent for a run produced before contract emission existed, or one where the
1420
+ ``contract`` extra was not installed. ``{}`` says "this run emitted none",
1421
+ which a reader must not confuse with "this run had no stages".
1422
+ """
1423
+ out: dict[str, Any] = {}
1424
+ directory = root / "contract"
1425
+ if not directory.is_dir():
1426
+ return out
1427
+ for path in sorted(directory.glob("*.json")):
1428
+ if path.name == "index.json":
1429
+ continue
1430
+ payload = _load_json(path)
1431
+ if payload is None:
1432
+ continue
1433
+ # "c0.m1.json" -> "c0.m1"; ".invalid.json" keeps its suffix so a reader
1434
+ # can see that a stage failed validation rather than silently missing it.
1435
+ out[path.name[: -len(".json")]] = payload
1436
+ for key, payload in out.items():
1437
+ if key.endswith("m5_fix"):
1438
+ _backfill_repair_identity(payload)
1439
+ return out
1440
+
1441
+
1442
+ def _backfill_repair_identity(payload: Any) -> None:
1443
+ """Give a pre-`ref` run the same numbering a current run would emit.
1444
+
1445
+ Runs recorded before these fields existed carry neither, and a consumer
1446
+ left to number rows itself gives the frozen candidate one number in the
1447
+ sweep and another on its card. The assignment here is the emitter's, done
1448
+ the emitter's way -- selection order first, one number per distinct name --
1449
+ so an old report and a new one read alike.
1450
+
1451
+ It only ever FILLS BLANKS. What a producer actually wrote is never
1452
+ overwritten, and a `headline` is only ever taken from the fix agent's table
1453
+ of its own candidates: a judge-invented name nobody described stays blank,
1454
+ because inventing a sentence for it here would be this file guessing.
1455
+ """
1456
+ if not isinstance(payload, dict):
1457
+ return
1458
+ try:
1459
+ from evalrx.eval_agent.stages.fix_agent import _BUILTIN_DESCRIPTIONS
1460
+ except Exception:
1461
+ _BUILTIN_DESCRIPTIONS = {}
1462
+ refs: dict[str, str] = {}
1463
+ for group in ("selection", "attempted"):
1464
+ for row in payload.get(group) or []:
1465
+ if not isinstance(row, dict):
1466
+ continue
1467
+ name = str(row.get("name") or "")
1468
+ ref = refs.get(name)
1469
+ if ref is None:
1470
+ ref = f"R{len(refs) + 1}"
1471
+ refs[name] = ref
1472
+ row.setdefault("ref", "")
1473
+ if not row["ref"]:
1474
+ row["ref"] = ref
1475
+ row.setdefault("headline", "")
1476
+ if not row["headline"]:
1477
+ row["headline"] = _BUILTIN_DESCRIPTIONS.get(name, "")
1478
+
1479
+
1480
+ def _repair_outcomes(root: Path, contract: Mapping[str, Any]) -> dict[str, dict[str, Any]]:
1481
+ """What each repair candidate answered, per case.
1482
+
1483
+ ``case_id -> {candidate, tier, status, output}``. The counts in M5 say twelve
1484
+ cases were repaired and one broken; this is what lets a reader open those
1485
+ thirteen and see what actually changed. Without it a repair is a number.
1486
+
1487
+ Read from each attempt's ``trial_root/outputs.jsonl`` rather than the wire,
1488
+ because it is one row per case per candidate — the contract points at the
1489
+ file instead of inlining it, and this is the reader following the pointer.
1490
+ Confirmation attempts only: the selection sweep chose the candidate and its
1491
+ per-case results are not evidence about it.
1492
+ """
1493
+ out: dict[str, dict[str, Any]] = {}
1494
+ for key in sorted(contract):
1495
+ if not key.endswith("m5_fix"):
1496
+ continue
1497
+ for attempt in (contract[key] or {}).get("attempted") or []:
1498
+ trial = str(attempt.get("trial_root") or "")
1499
+ if not trial:
1500
+ continue
1501
+ path = (root / trial / "outputs.jsonl")
1502
+ if not path.is_file():
1503
+ path = Path(trial) / "outputs.jsonl" # a run read where it was produced
1504
+ if not path.is_file():
1505
+ continue
1506
+ for line in path.read_text(encoding="utf-8").splitlines():
1507
+ try:
1508
+ row = json.loads(line)
1509
+ except ValueError:
1510
+ continue
1511
+ cid = str(row.get("case_id") or "")
1512
+ if not cid:
1513
+ continue
1514
+ out[cid] = {
1515
+ "candidate": attempt.get("name"),
1516
+ "tier": attempt.get("tier"),
1517
+ "status": row.get("status"),
1518
+ "output": row.get("output"),
1519
+ }
1520
+ return out
1521
+
1522
+
1523
+ def _contract_model_name(contract: Mapping[str, Any]) -> str:
1524
+ """The model's name from M1's payload, or "" for a run that emitted none."""
1525
+ for key in sorted(contract):
1526
+ if not key.endswith(".m1"):
1527
+ continue
1528
+ ref = (contract[key] or {}).get("model") or {}
1529
+ name = str(ref.get("name") or "").strip()
1530
+ if name:
1531
+ return name
1532
+ return ""
1533
+
1534
+
1535
+ def _contract_stats(contract: Mapping[str, Any]) -> list[Mapping[str, Any]]:
1536
+ """M2's statistical rows from the newest cycle that has any."""
1537
+ for key in sorted(contract, reverse=True):
1538
+ if not key.endswith(".m2"):
1539
+ continue
1540
+ rows = (contract[key] or {}).get("stats_results")
1541
+ if isinstance(rows, list) and rows:
1542
+ return rows
1543
+ return []
1544
+
1545
+
1546
+ def _media_index(cases: Iterable[Mapping[str, Any]]) -> list[dict[str, Any]]:
1547
+ """One entry per distinct media file, preferring the copy inside the run.
1548
+
1549
+ ``inputs.<slot>`` is where the producer read the file from — an absolute path
1550
+ on that host, and meaningless on any other. The run logger already copied the
1551
+ file to ``artifacts/case_media/`` and recorded that relative path, so a run
1552
+ unzipped elsewhere still has its media; index by that when it exists and keep
1553
+ the original only as the fallback for runs that predate the copy.
1554
+ """
1555
+ media: list[dict[str, Any]] = []
1556
+ seen: set[str] = set()
1557
+ for case in cases:
1558
+ inputs = case.get("inputs") if isinstance(case.get("inputs"), dict) else case
1559
+ # Slots and copies are recorded in the same order, so they zip up.
1560
+ local = [p for p in (case.get("_media_paths") or []) if isinstance(p, str)]
1561
+ i = 0
1562
+ for kind in ("audio", "image", "video"):
1563
+ source = inputs.get(kind) or inputs.get(f"{kind}_path")
1564
+ if not isinstance(source, str) or not source or source.startswith("<"):
1565
+ continue
1566
+ path = local[i] if i < len(local) else source
1567
+ i += 1
1568
+ if path in seen:
1569
+ continue
1570
+ seen.add(path)
1571
+ media.append({"id": f"media-{len(media) + 1}", "kind": kind,
1572
+ "path": path, "source": source})
1573
+ return media
1574
+
1575
+
1576
+ def _normalise_case(case: Mapping[str, Any], media: list[dict[str, Any]]) -> dict[str, Any]:
1577
+ inputs = case.get("inputs") if isinstance(case.get("inputs"), dict) else {}
1578
+ prompt = inputs.get("prompt") or case.get("instruction") or case.get("prompt") or ""
1579
+ # Match on either key: the index prefers the in-run copy, and a run without
1580
+ # copies still resolves through the producer's original path.
1581
+ media_by_path: dict[str, str] = {}
1582
+ for item in media:
1583
+ media_by_path[item["path"]] = item["id"]
1584
+ if item.get("source"):
1585
+ media_by_path[item["source"]] = item["id"]
1586
+ media_ids = []
1587
+ for path in (case.get("_media_paths") or []):
1588
+ if isinstance(path, str) and path in media_by_path and media_by_path[path] not in media_ids:
1589
+ media_ids.append(media_by_path[path])
1590
+ for kind in ("audio", "image", "video"):
1591
+ value = inputs.get(kind) or case.get(f"{kind}_path") or case.get(kind)
1592
+ if isinstance(value, str) and value in media_by_path \
1593
+ and media_by_path[value] not in media_ids:
1594
+ media_ids.append(media_by_path[value])
1595
+ return {
1596
+ "id": str(case.get("id") or case.get("case_id") or f"case-{id(case)}"),
1597
+ "status": str(case.get("status") or case.get("label") or "unknown").lower(),
1598
+ "prompt": prompt,
1599
+ "expected": case.get("expected"),
1600
+ "observed": case.get("observed", case.get("output")),
1601
+ "choices": case.get("choices") or [],
1602
+ "tags": case.get("tags") or case.get("probe_flags") or [],
1603
+ "task": case.get("task") or (case.get("metadata") or {}).get("category") or "",
1604
+ "media_ids": media_ids,
1605
+ "trajectory": case.get("trajectory"),
1606
+ # explore / confirm / test, as the loop logged it; None until
1607
+ # _assign_partitions has had a chance to infer it.
1608
+ "split": case.get("_split") if isinstance(case.get("_split"), str) else None,
1609
+ }
1610
+
1611
+
1612
+ # What each partition was used for, in the words the loop figure draws them
1613
+ # with: D_E on top, D_H, D_C at the bottom. The code is the subscript.
1614
+ _EXPLORE_ROLE = ("M1-M3 mined patterns and hypotheses here. Anything measured on "
1615
+ "these cases is a lead, not a verdict.")
1616
+ _HELDOUT_ROLE = ("Withheld from M1-M3. M4 adjudicated the hypotheses here and M5 "
1617
+ "developed its repair candidates on the same cases.")
1618
+ _CONFIRM_ROLE = ("Withheld from every adaptive decision. The frozen repair was "
1619
+ "scored exactly once here.")
1620
+ _TWO_WAY_ROLE = ("Withheld from M1-M3. M4 adjudicated the hypotheses here and the "
1621
+ "repair was scored on these same cases: one withheld pool "
1622
+ "serving as both the held-out and the confirm set.")
1623
+
1624
+
1625
+ def _assign_partitions(
1626
+ cases: list[dict[str, Any]],
1627
+ *,
1628
+ logged_ids: "Sequence[str]",
1629
+ n_explore: Any,
1630
+ final_ids: "set[str]",
1631
+ ) -> list[dict[str, Any]]:
1632
+ """Put every case in its partition and summarise the partitions.
1633
+
1634
+ ``case["split"]`` is filled in place (``explore`` / ``confirm`` / ``test``)
1635
+ and the summary rows come back in drawing order, each with the subscript
1636
+ the loop figure uses (E, H, C), its size and what the run used it for.
1637
+
1638
+ A run that tagged its case records is read as recorded. A run from before
1639
+ the tag existed is inferred from what its log still says: ``run_start``'s
1640
+ ``n_cases`` is the explore partition (the loop narrows ``data`` to it before
1641
+ recording the start), and the loop logs explore first, then confirm, then
1642
+ test -- so the first ``n_explore`` logged ids are explore and the rest were
1643
+ withheld. Whether the withheld pool was further divided (train/val/test
1644
+ mode) is recovered from M5's per-case scoring: the cases it scored the
1645
+ frozen repair on are the test partition when they are a strict subset of
1646
+ what was withheld, and the whole pool otherwise. Cases the report merged
1647
+ in from a manifest rather than the log stay untagged -- their order says
1648
+ nothing.
1649
+
1650
+ Returns ``[]`` when there is no split to show: no case carries one and none
1651
+ can be inferred (a run without a confirm split, or a legacy manifest).
1652
+ """
1653
+ by_id = {str(case.get("id")): case for case in cases}
1654
+ inferred = False
1655
+ if not any(case.get("split") for case in cases):
1656
+ ids = [cid for cid in logged_ids if cid and cid in by_id]
1657
+ try:
1658
+ n_head = int(n_explore or 0)
1659
+ except (TypeError, ValueError):
1660
+ n_head = 0
1661
+ if not ids or n_head <= 0 or n_head >= len(ids):
1662
+ return []
1663
+ withheld = ids[n_head:]
1664
+ final = {cid for cid in final_ids if cid in set(withheld)}
1665
+ three_way = bool(final) and len(final) < len(withheld)
1666
+ for cid in ids[:n_head]:
1667
+ by_id[cid]["split"] = "explore"
1668
+ for cid in withheld:
1669
+ by_id[cid]["split"] = "test" if three_way and cid in final else "confirm"
1670
+ inferred = True
1671
+
1672
+ present = {str(case.get("split")) for case in cases if case.get("split")}
1673
+ if not present:
1674
+ return []
1675
+ three_way = "test" in present
1676
+
1677
+ def row(split: str, code: str, label: str, role: str) -> dict[str, Any]:
1678
+ return {"split": split, "code": code, "label": label,
1679
+ "n": sum(1 for case in cases if case.get("split") == split),
1680
+ "role": role, "inferred": inferred}
1681
+
1682
+ rows = [row("explore", "E", "Explore", _EXPLORE_ROLE)]
1683
+ if three_way:
1684
+ rows.append(row("confirm", "H", "Held-out", _HELDOUT_ROLE))
1685
+ rows.append(row("test", "C", "Confirm", _CONFIRM_ROLE))
1686
+ elif "confirm" in present:
1687
+ rows.append(row("confirm", "H/C", "Held-out / Confirm", _TWO_WAY_ROLE))
1688
+ n_unknown = sum(1 for case in cases if not case.get("split"))
1689
+ if n_unknown:
1690
+ rows.append({"split": "", "code": "?", "label": "Unrecorded", "n": n_unknown,
1691
+ "role": "The run did not record which partition these came from.",
1692
+ "inferred": False})
1693
+ return rows
1694
+
1695
+
1696
+ def _merge_recorded_case_evidence(cases: list[dict[str, Any]], root: Path) -> list[dict[str, Any]]:
1697
+ """Supplement legacy manifests with the run's recorded baseline I/O.
1698
+
1699
+ Older benchmark exports often keep prompts/media in a manifest while
1700
+ baseline answers live separately in ``report/discovery_cases.json``. This
1701
+ is a generic run artifact, not a task-specific fallback. New JSONL case
1702
+ records already contain both and therefore simply win when populated.
1703
+ """
1704
+ snapshots: list[Mapping[str, Any]] = []
1705
+ for base in (root, root.parent):
1706
+ value = _load_json(base / "report" / "discovery_cases.json")
1707
+ if isinstance(value, list):
1708
+ snapshots = [item for item in value if isinstance(item, Mapping)]
1709
+ if snapshots:
1710
+ break
1711
+ if not snapshots:
1712
+ return cases
1713
+ existing = {str(case.get("id")): case for case in cases}
1714
+ for raw_case in snapshots:
1715
+ case_id = str(raw_case.get("id") or raw_case.get("case_id") or "")
1716
+ if not case_id:
1717
+ continue
1718
+ update = _normalise_case(raw_case, [])
1719
+ target = existing.get(case_id)
1720
+ if target is None:
1721
+ cases.append(update)
1722
+ existing[case_id] = update
1723
+ continue
1724
+ for key in ("prompt", "expected", "observed"):
1725
+ if not target.get(key) and update.get(key):
1726
+ target[key] = update[key]
1727
+ if target.get("status") in {"", "unknown", "unchanged"} and update.get("status"):
1728
+ target["status"] = update["status"]
1729
+ return cases
1730
+
1731
+
1732
+ def _read_events(root: Path) -> list[dict[str, Any]]:
1733
+ from evalrx.reporting.run_events import read_v2_events
1734
+
1735
+ return read_v2_events(root)
1736
+
1737
+
1738
+ def _infer_example_dir(root: Path) -> Path | None:
1739
+ """Find a nearby legacy manifest; v4 case events do not need this join."""
1740
+ for candidate in (root, root.parent, root.parent.parent):
1741
+ if any((candidate / "data").glob("*.jsonl")) or any((candidate / "data").glob("*.json")):
1742
+ return candidate
1743
+ return None
1744
+
1745
+
1746
+ _CALL_TEXT_CAP = 3000
1747
+ _CALLS_PER_ANALYZER = 120
1748
+
1749
+
1750
+ def _call_text(value: Any) -> str:
1751
+ """The prompt or the reply as text, cut so 500 calls stay a sane payload."""
1752
+ if isinstance(value, Mapping):
1753
+ value = value.get("prompt") if "prompt" in value else json.dumps(value, ensure_ascii=False)
1754
+ text = "" if value is None else str(value)
1755
+ return text if len(text) <= _CALL_TEXT_CAP else text[:_CALL_TEXT_CAP] + f"… [+{len(text) - _CALL_TEXT_CAP} chars]"
1756
+
1757
+
1758
+ def _m1_calls(events: Iterable[Mapping[str, Any]]) -> tuple[dict[str, list[dict[str, Any]]], int]:
1759
+ """M1's model calls grouped by analyzer, oldest first, capped per analyzer."""
1760
+ grouped: dict[str, list[dict[str, Any]]] = {}
1761
+ total = 0
1762
+ for event in events:
1763
+ if str(event.get("event") or "") != "model_call" or str(event.get("stage") or "") != "M1":
1764
+ continue
1765
+ total += 1
1766
+ analyzer = str(event.get("analyzer") or event.get("role") or "model")
1767
+ bucket = grouped.setdefault(analyzer, [])
1768
+ if len(bucket) >= _CALLS_PER_ANALYZER:
1769
+ continue
1770
+ batch = event.get("batch_case_ids")
1771
+ bucket.append({
1772
+ "seq": event.get("event_seq"), "cycle": event.get("cycle"),
1773
+ "method": event.get("method") or event.get("operation") or "",
1774
+ "case_id": event.get("case_id"),
1775
+ "batch_case_ids": [str(item) for item in batch] if isinstance(batch, list) else [],
1776
+ "duration_sec": event.get("duration_sec"),
1777
+ "prompt": _call_text(event.get("inputs")),
1778
+ "output": _call_text(event.get("output")),
1779
+ "error": str(event.get("error")) if event.get("error") else None,
1780
+ })
1781
+ return grouped, total
1782
+
1783
+
1784
+ def _debug_event(event: Mapping[str, Any]) -> dict[str, Any]:
1785
+ event_type = str(event.get("event") or "")
1786
+ inferred_stage = {
1787
+ "probe": "M1", "explore": "M2", "analysis": "M2", "diagnosis": "M3",
1788
+ "surgery": "M4" if event.get("module") == "m4" else "M5",
1789
+ "experiment": "M5", "fix": "M5", "case_record": "DATA",
1790
+ "report_published": "REPORT",
1791
+ }.get(event_type, "RUN")
1792
+ return {
1793
+ "event": event.get("event"), "stage": event.get("stage") or inferred_stage, "cycle": event.get("cycle"),
1794
+ "event_seq": event.get("event_seq"), "ts": event.get("ts"), "span_id": event.get("span_id"),
1795
+ "summary": _event_summary(event),
1796
+ }
1797
+
1798
+
1799
+ def _event_summary(event: Mapping[str, Any]) -> str:
1800
+ if str(event.get("event") or "") == "model_call":
1801
+ # The model calls are most of the stream; "Recorded pipeline event"
1802
+ # 500 times over says nothing. Name the probe, the operation, and the
1803
+ # size of the call instead.
1804
+ who = event.get("analyzer") or event.get("role") or "model"
1805
+ op = event.get("method") or event.get("operation") or "call"
1806
+ n_batch = event.get("n_batch_cases")
1807
+ scope = (f"{n_batch} cases" if n_batch else (f"case {str(event.get('case_id'))[:12]}" if event.get("case_id") else ""))
1808
+ took = event.get("duration_sec")
1809
+ parts = [f"{who} · {op}"] + ([scope] if scope else []) + ([f"{float(took):.1f}s"] if isinstance(took, (int, float)) else [])
1810
+ if event.get("error"):
1811
+ parts.append(f"error: {str(event['error'])[:80]}")
1812
+ return " · ".join(parts)
1813
+ for key in ("conclusion", "narrative", "selection_rationale", "status", "stopped_by"):
1814
+ value = event.get(key)
1815
+ if value not in (None, ""):
1816
+ return str(value)[:280]
1817
+ return "Recorded pipeline event"
1818
+
1819
+
1820
+ def _validate_props(value: Any, *, depth: int = 0) -> None:
1821
+ if depth > 4:
1822
+ raise ReportSpecError("props are too deeply nested")
1823
+ if isinstance(value, str) and len(value) > 200:
1824
+ raise ReportSpecError("layout strings may not exceed 200 characters")
1825
+ if isinstance(value, dict):
1826
+ for child in value.values():
1827
+ _validate_props(child, depth=depth + 1)
1828
+ elif isinstance(value, list):
1829
+ if len(value) > 20:
1830
+ raise ReportSpecError("layout lists may not exceed 20 items")
1831
+ for child in value:
1832
+ _validate_props(child, depth=depth + 1)
1833
+ elif value is not None and not isinstance(value, (str, bool, int, float)):
1834
+ raise ReportSpecError("props must be JSON scalar/list/object values")
1835
+
1836
+
1837
+ def _validate_data_refs(component: str, props: Mapping[str, Any], data: Mapping[str, Any]) -> None:
1838
+ mapping = {
1839
+ "FindingGrid": ("findingIds", "findings"),
1840
+ "ChartGrid": ("chartIds", "charts"),
1841
+ "CasePreview": ("caseIds", "cases"),
1842
+ }
1843
+ if component not in mapping:
1844
+ return
1845
+ prop, collection = mapping[component]
1846
+ refs = props.get(prop) or []
1847
+ if not isinstance(refs, list) or not all(isinstance(ref, str) for ref in refs):
1848
+ raise ReportSpecError(f"{prop} must be a list of strings")
1849
+ legal = {str(item.get("id")) for item in data.get(collection, []) if isinstance(item, dict)}
1850
+ unknown = set(refs).difference(legal)
1851
+ if unknown:
1852
+ raise ReportSpecError(f"unknown {collection} ids: {', '.join(sorted(unknown))}")
1853
+
1854
+
1855
+ def _check_tree_depth(node: str, elements: Mapping[str, Any], stack: set[str], depth: int) -> None:
1856
+ if depth > MAX_DEPTH:
1857
+ raise ReportSpecError(f"tree depth exceeds {MAX_DEPTH}")
1858
+ if node in stack:
1859
+ raise ReportSpecError("element tree contains a cycle")
1860
+ next_stack = {*stack, node}
1861
+ for child in elements[node].get("children") or []:
1862
+ _check_tree_depth(child, elements, next_stack, depth + 1)
1863
+
1864
+
1865
+ def _parse_json(text: str) -> Any:
1866
+ stripped = text.strip()
1867
+ fenced = re.search(r"```(?:json)?\s*(\{.*\})\s*```", stripped, re.DOTALL)
1868
+ return json.loads(fenced.group(1) if fenced else stripped)
1869
+
1870
+
1871
+ def _json_safe(value: Any) -> Any:
1872
+ if value is None or isinstance(value, (str, bool, int, float)):
1873
+ return value
1874
+ if isinstance(value, Path):
1875
+ return str(value)
1876
+ if isinstance(value, dict):
1877
+ return {str(key): _json_safe(item) for key, item in value.items()}
1878
+ if isinstance(value, (list, tuple, set, frozenset)):
1879
+ return [_json_safe(item) for item in value]
1880
+ if hasattr(value, "value"):
1881
+ return _json_safe(value.value)
1882
+ return str(value)