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,2225 @@
1
+ """Self-contained, tabbed, human-understandable HTML report generator for EvalRX.
2
+
3
+ This module parses a finished diagnostic run (M1 through M4 and fixes) and
4
+ renders a single, zero-dependency, tabbed interactive HTML diagnostic report in English.
5
+
6
+ Design Philosophy:
7
+ - Tabbed Navigation: Clean stage-by-stage tabs (Overview, M1, M2, M3, M4, M5, Cases)
8
+ allowing focused inspection without endless scrolling.
9
+ - Human-Friendly Header: Clean model and benchmark titles instead of raw code/debug dumps.
10
+ - Plain-English First: Clinical medical-checkup analogy with intuitive explanations.
11
+ - 100% Dynamic: Fully parsed from experiment artifacts without hardcoding.
12
+ - Multi-Modal: Inlines audio (AAC), images, and structured prompts.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import base64
19
+ import html
20
+ import json
21
+ import re
22
+ import shutil
23
+ import subprocess
24
+ import tempfile
25
+ from concurrent.futures import ThreadPoolExecutor
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ STAGE_METADATA: dict[str, dict[str, str]] = {
30
+ "overview": {
31
+ "code": "SUMMARY",
32
+ "name": "Executive Summary",
33
+ "short_role": "Overview & Key Findings",
34
+ "plain_desc": "High-level diagnostic summary: what was tested, what failure mechanism was diagnosed, and the final repair outcome.",
35
+ },
36
+ "pre_m1": {
37
+ "code": "PRE-M1",
38
+ "name": "Case Synthesis",
39
+ "short_role": "Adversarial Probe Synthesis",
40
+ "plain_desc": "Evaluates whether the system automatically synthesised challenging edge cases or adversarial probes to uncover hidden blind spots. If skipped, testing ran on fixed benchmark data.",
41
+ },
42
+ "m1": {
43
+ "code": "M1",
44
+ "name": "Checkup & Signals",
45
+ "short_role": "Multi-Dimensional Measurement",
46
+ "plain_desc": "Performs a multi-dimensional clinical checkup on the model, measuring vital signs (such as attention focus, overconfidence, order sensitivity, etc.) without pre-judging guilt.",
47
+ },
48
+ "m2": {
49
+ "code": "M2",
50
+ "name": "Screening & EDA",
51
+ "short_role": "Statistical Association Screening",
52
+ "plain_desc": "Correlates vital signs with actual failure cases. Filters out noise and random coincidence using rigorous multiple-testing corrections (Benjamini–Hochberg FDR) to isolate prime suspects.",
53
+ },
54
+ "m3": {
55
+ "code": "M3",
56
+ "name": "Root-Cause Diagnosis",
57
+ "short_role": "Falsifiable Mechanism Hypothesis",
58
+ "plain_desc": "An AI Diagnostician analyzes the screened evidence to propose specific, falsifiable root-cause hypotheses explaining exactly why and when the model fails.",
59
+ },
60
+ "m4": {
61
+ "code": "M4",
62
+ "name": "Independent Adjudication",
63
+ "short_role": "Blind-Holdout Validation",
64
+ "plain_desc": "Blindly re-tests the AI Doctor's hypothesis on a held-out validation set the model has never seen before, determining whether the mechanism is confirmed or refuted.",
65
+ },
66
+ "m5_surgery": {
67
+ "code": "M5-SURGERY",
68
+ "name": "Causal Surgery",
69
+ "short_role": "Internal Mechanism Intervention",
70
+ "plain_desc": "Directly intervenes in or ablates internal model components (such as attention heads or activation layers) to prove causal necessity.",
71
+ },
72
+ "m5_fix": {
73
+ "code": "M5-FIX",
74
+ "name": "Targeted Repair",
75
+ "short_role": "Repair Search & Paired Confirmation",
76
+ "plain_desc": "Applies targeted treatments across intervention tiers (from prompt patches to decoding scaffolds). Validates on held-out test data by comparing cured cases against broken cases.",
77
+ },
78
+ "case_book": {
79
+ "code": "CASE-STUDIO",
80
+ "name": "Interactive Case Studio",
81
+ "short_role": "Case-Level Inspection & Audio",
82
+ "plain_desc": "Inspect real failure vs repaired cases with before-and-after model answering comparisons and playable audio clips.",
83
+ },
84
+ "agents": {
85
+ "code": "AGENTS",
86
+ "name": "Agent Trajectories",
87
+ "short_role": "Judge & Coder Agent I/O",
88
+ "plain_desc": "Every agent invocation behind the diagnosis, layer by layer: verbatim judge prompts and responses (M1 selection, M2 screening, M3 diagnosis, M4 adjudication), the explore coder-agent's raw CLI trajectory, and every synthesized tool's codegen attempt. This is the audit trail for debugging and case studies.",
89
+ },
90
+ }
91
+
92
+ ANALYZER_GLOSSARY: dict[str, tuple[str, str, str, list[tuple[str, str, str]]]] = {
93
+ "answer_extraction_audit": (
94
+ "Answer Extraction Audit",
95
+ "Did the model actually answer wrong, or did our parser fail to extract its response?",
96
+ "Checks whether formatting quirks (e.g. missing tags, preamble chatter) prevented the parser from extracting the answer even when the model understood the problem.",
97
+ [
98
+ ("Extraction Failure Rate on Errors", "suspect_rate", "pct"),
99
+ ("Missing Format Tag Rate", "missing_tag_rate", "pct"),
100
+ ],
101
+ ),
102
+ "termination_audit": (
103
+ "Truncation & Early Stop Audit",
104
+ "Did the model finish its thought, or was it abruptly cut off by length caps?",
105
+ "Identifies whether premature token limits or sudden EOS tokens clipped the model's reasoning chain mid-sentence.",
106
+ [
107
+ ("Suspected Truncation Rate", "truncation_rate", "pct"),
108
+ ("Recovery Rate upon Continuation", "recovered_rate", "pct"),
109
+ ],
110
+ ),
111
+ "calibration": (
112
+ "Confidence & Overconfidence (ECE)",
113
+ "When the model sounds certain, is its actual correctness probability truly high?",
114
+ "Measures Expected Calibration Error (ECE) across token logprobs and verbalized confidence. High ECE indicates dangerous overconfidence on incorrect answers.",
115
+ [
116
+ ("Token Logprob Calibration Error (ECE)", "logprob_channel.ece", "num"),
117
+ ("Verbalized Confidence Error (ECE)", "verbalized_channel.ece", "num"),
118
+ ],
119
+ ),
120
+ "format_sensitivity": (
121
+ "Option Order Sensitivity (Position Bias)",
122
+ "If we shuffle choices (A/B/C/D), does the model arbitrarily flip its answer?",
123
+ "Permutes multiple-choice options across variants. If answer flips across orderings, the model suffers from severe positional shortcutting rather than semantic understanding.",
124
+ [
125
+ ("Answer Flip Rate after Shuffling", "mean_flip_rate", "pct"),
126
+ ],
127
+ ),
128
+ "coverage_verification_gap": (
129
+ "Pass@k Knowledge Blindspot (Coverage)",
130
+ "Over 5 repeat attempts, does the model ever produce the right answer even once?",
131
+ "Samples temperature completions k=5 times. If pass@5 is 0%, the capability is completely missing from weights; if pass@5 is high but greedy fails, the model suffers from search/ranking misalignment.",
132
+ [
133
+ ("Persistent Failure Rate (0/5 correct)", "no_coverage_rate", "pct"),
134
+ ("Pass@5 Coverage Rate", "mean_pass_at_k", "pct"),
135
+ ],
136
+ ),
137
+ "self_consistency": (
138
+ "Sampling Consistency",
139
+ "When sampled repeatedly with temperature, does the answer waver wildly?",
140
+ "Measures whether model predictions stabilize on a single consensus choice or disperse across inconsistent alternatives.",
141
+ [
142
+ ("Majority Answer Agreement Rate", "consistency", "pct"),
143
+ ],
144
+ ),
145
+ "self_repair": (
146
+ "Self-Repair on Re-ask",
147
+ "When asked to check its own answer, does the model catch and fix its mistakes?",
148
+ "Asks the model to critique and revise its first answer. Reports how often it detects a real error, how often it raises a false alarm on a correct answer, and how often the revision actually repairs a failure.",
149
+ [
150
+ ("Error Detection Accuracy", "detection_accuracy", "pct"),
151
+ ("False Alarm Rate on Correct Answers", "false_alarm_rate", "pct"),
152
+ ("Repair Rate on Failures", "repair_rate", "pct"),
153
+ ],
154
+ ),
155
+ "cot_faithfulness": (
156
+ "Chain-of-Thought Faithfulness",
157
+ "Does the written reasoning actually drive the final answer, or is it decoration?",
158
+ "Compares the answer the model commits to early in its reasoning with the one it ends on: reasoning that drifts away from a correct early answer, or rescues a wrong one late, is unfaithful to the final output.",
159
+ [
160
+ ("Early Answer Matches Final", "mean_early_match_rate", "pct"),
161
+ ("Drift-Away Rate", "drift_away_rate", "pct"),
162
+ ("Late Rescue Rate", "late_rescue_rate", "pct"),
163
+ ],
164
+ ),
165
+ "perturbation_battery": (
166
+ "Perturbation Invariance",
167
+ "Do meaning-preserving edits to the prompt change the answer?",
168
+ "Rewrites each prompt in ways that should not change the answer (paraphrase, whitespace, ordering) and counts how often the answer breaks anyway; a no-op edit that breaks the answer points at memorisation or brittleness.",
169
+ [
170
+ ("Invariance Break Rate", "mean_invariance_break_rate", "pct"),
171
+ ("No-op Break Rate", "noop_break_rate", "pct"),
172
+ ],
173
+ ),
174
+ "step_rollout_value": (
175
+ "Step Rollout Value",
176
+ "At which reasoning step does the model's chance of finishing correctly collapse?",
177
+ "Rolls out completions from successive prefixes of the reasoning and scores each; the point where the success rate drops is where the reasoning went wrong.",
178
+ [
179
+ ("Success Rate from the First Step", "mean_initial_value", "pct"),
180
+ ("Success Rate from the Last Step", "mean_final_value", "pct"),
181
+ ("Mean Break Depth", "mean_break_depth", "num"),
182
+ ],
183
+ ),
184
+ "logprob_entropy": (
185
+ "Predictive Uncertainty (Output Entropy)",
186
+ "Is the model confident or internally hesitating when generating key tokens?",
187
+ "Computes Shannon entropy across the top candidate tokens to measure decision ambivalence.",
188
+ [
189
+ ("Top-Token Prediction Entropy", "mean_top_entropy", "num"),
190
+ ("Model Perplexity", "perplexity", "num"),
191
+ ],
192
+ ),
193
+ "selfcheck_consistency": (
194
+ "Self-Contradiction Detection",
195
+ "Does the model contradict its own claims across repeat samplings?",
196
+ "Probes whether the model generates assertions in one sample that directly contradict its claims in another sample.",
197
+ [
198
+ ("Self-Contradiction Index", "mean_inconsistency", "num"),
199
+ ],
200
+ ),
201
+ "hallucination": (
202
+ "Hallucination Probe",
203
+ "Does the model generate details unsupported by the source stimulus?",
204
+ "Measures semantic grounding against the audible stimulus to catch fabricated details.",
205
+ [
206
+ ("Hallucination Deviation Score", "hallucination_score", "num"),
207
+ ],
208
+ ),
209
+ "qwen_attention": (
210
+ "Attention Focus & Sparsity",
211
+ "Does model attention properly focus on critical prompt and media cues?",
212
+ "Examines cross-attention maps between audio tokens and query tokens to verify sensory uptake.",
213
+ [
214
+ ("Core Cue Attention Share", "focus_share", "pct"),
215
+ ],
216
+ ),
217
+ }
218
+
219
+
220
+ def clean_model_display_name(raw: str) -> str:
221
+ """Format raw python class repr like HFLocalModel(key='...') into a human-readable title."""
222
+ if not raw:
223
+ return "Target Model"
224
+ m = re.search(r"key=[\'\"]([^\'\"]+)[\'\"]", raw)
225
+ if m:
226
+ key = m.group(1)
227
+ parts = [p.capitalize() if not p.isdigit() else p for p in key.split("-")]
228
+ return "-".join(parts)
229
+ # Any Model subclass stringifies as ClassName(<name>) — EndpointModel(qwen3.5-2b)
230
+ # for the vLLM chain — and the class is not something a reader needs.
231
+ clean = re.sub(r"^[A-Za-z_]\w*\((.*)\)$", r"\1", raw.strip()).strip().strip("'\"")
232
+ return clean or raw
233
+
234
+
235
+ def clean_benchmark_name(protocol: str, manifest_path: str) -> str:
236
+ """Extract a friendly benchmark name from protocol or manifest path."""
237
+ text = (protocol + " " + manifest_path).lower()
238
+ if "mmau" in text:
239
+ return "MMAU (Multi-Modal Audio Understanding)"
240
+ if "chartqa" in text:
241
+ return "ChartQA (Chart Visual Reasoning)"
242
+ if "spatial" in text:
243
+ return "Spatial457 (Spatial Visual Reasoning)"
244
+ if "audiocaps" in text:
245
+ return "AudioCaps Hallucination"
246
+ if "pope" in text:
247
+ return "POPE (Object Probing Evaluation)"
248
+ if "chair" in text:
249
+ return "CHAIR (Captioning Hallucination)"
250
+ return "Evaluation Benchmark"
251
+
252
+
253
+ def _dig(d: dict, path: str) -> Any:
254
+ cur = d
255
+ for part in path.split("."):
256
+ if not isinstance(cur, dict):
257
+ return None
258
+ cur = cur.get(part)
259
+ return cur
260
+
261
+
262
+ def resolve_run_dirs(run_dir: Path) -> tuple[Path, Path | None, Path | None]:
263
+ """Return (logs_dir, explore_dir, fixes_dir)."""
264
+ run_dir = run_dir.resolve()
265
+ logs_dir = run_dir
266
+ if (run_dir / "logs" / "run.json").exists():
267
+ logs_dir = run_dir / "logs"
268
+ elif not (run_dir / "run.json").exists():
269
+ for child in run_dir.glob("*/run.json"):
270
+ logs_dir = child.parent
271
+ break
272
+
273
+ explore_dir = logs_dir / "explore"
274
+ if not explore_dir.is_dir() and (logs_dir.parent / "explore").is_dir():
275
+ explore_dir = logs_dir.parent / "explore"
276
+ if not explore_dir.is_dir():
277
+ explore_dir = None
278
+
279
+ fixes_dir = logs_dir / "fixes"
280
+ if not fixes_dir.is_dir() and (logs_dir.parent / "fixes").is_dir():
281
+ fixes_dir = logs_dir.parent / "fixes"
282
+ if not fixes_dir.is_dir():
283
+ fixes_dir = None
284
+
285
+ return logs_dir, explore_dir, fixes_dir
286
+
287
+
288
+ def find_manifest(example_dir: Path, logs_dir: Path) -> tuple[Path | None, dict[str, dict[str, Any]]]:
289
+ """Find and load benchmark manifest (.jsonl / .json)."""
290
+ candidates = []
291
+ if example_dir:
292
+ candidates.extend(sorted(example_dir.glob("data/*.jsonl")))
293
+ candidates.extend(sorted(example_dir.glob("data/*.json")))
294
+ candidates.extend(sorted(example_dir.glob("*.jsonl")))
295
+ candidates.extend(sorted(logs_dir.glob("data/*.jsonl")))
296
+ candidates.extend(sorted(logs_dir.parent.glob("data/*.jsonl")))
297
+
298
+ for p in candidates:
299
+ if p.name.endswith(".result.json") or p.name.endswith("results.json"):
300
+ continue
301
+ try:
302
+ rows = {}
303
+ if p.suffix == ".jsonl":
304
+ for line in p.open(encoding="utf-8"):
305
+ line = line.strip()
306
+ if line:
307
+ obj = json.loads(line)
308
+ cid = str(obj.get("id") or obj.get("case_id") or "")
309
+ if cid:
310
+ rows[cid] = obj
311
+ elif p.suffix == ".json":
312
+ data = json.loads(p.read_text(encoding="utf-8"))
313
+ recs = data if isinstance(data, list) else (data.get("records") or data.get("cases") or [])
314
+ for obj in recs:
315
+ if isinstance(obj, dict):
316
+ cid = str(obj.get("id") or obj.get("case_id") or "")
317
+ if cid:
318
+ rows[cid] = obj
319
+ if rows:
320
+ return p, rows
321
+ except Exception:
322
+ continue
323
+ return None, {}
324
+
325
+
326
+ def _extract_agent_layer(logs_dir: Path, explore_dir: "Path | None") -> dict[str, Any]:
327
+ """The agent layer of a run: verbatim judge I/O, the explore coder-agent's
328
+ trajectory, synthesized tool codegen attempts, and the Langfuse trace bundle.
329
+
330
+ This is what makes the report drill below the pipeline stages: a reader who
331
+ sees a probe or a hypothesis and asks "what did the agent actually see and
332
+ say?" can expand down to the raw prompt/response text.
333
+ """
334
+ agents: dict[str, Any] = {
335
+ "judge_calls": [],
336
+ "explore": {},
337
+ "tool_codegen": [],
338
+ "langfuse": {},
339
+ }
340
+
341
+ # -- Verbatim judge calls (prompts/<stem>.prompt.txt + .response.txt) ----
342
+ def _stage_of(stem: str) -> str:
343
+ if "_m1_" in stem:
344
+ return "M1 Analyzer Selection"
345
+ if "_m2_" in stem:
346
+ return "M2 Statistical Screening"
347
+ if "_m3_" in stem:
348
+ return "M3 AI Doctor Diagnosis"
349
+ if "_m4_" in stem:
350
+ return "M4 Protocol Consistency"
351
+ if "_m5_" in stem:
352
+ return "M5 Intervention"
353
+ if "_agent_decision" in stem:
354
+ return "Agentic Loop Decision"
355
+ return "Other"
356
+
357
+ prompts_dir = logs_dir / "prompts"
358
+ if prompts_dir.exists():
359
+ for pf in sorted(prompts_dir.glob("*.prompt.txt")):
360
+ stem = pf.name[: -len(".prompt.txt")]
361
+ rf = pf.with_name(stem + ".response.txt")
362
+ try:
363
+ prompt = pf.read_text(encoding="utf-8")
364
+ except Exception:
365
+ prompt = ""
366
+ try:
367
+ response = rf.read_text(encoding="utf-8") if rf.exists() else ""
368
+ except Exception:
369
+ response = ""
370
+ if not prompt and not response:
371
+ continue
372
+ agents["judge_calls"].append({
373
+ "stem": stem,
374
+ "stage": _stage_of(stem),
375
+ "prompt": prompt,
376
+ "response": response,
377
+ "prompt_chars": len(prompt),
378
+ "raw_chars": len(response),
379
+ })
380
+
381
+ # -- Explore coder-agent trajectory --------------------------------------
382
+ exp: dict[str, Any] = {}
383
+ if explore_dir is not None and Path(explore_dir).exists():
384
+ ed = Path(explore_dir)
385
+ for key, fname in (
386
+ ("raw_output", "agent_raw_output.txt"),
387
+ ("raw_streams", "agent_raw_streams.txt"),
388
+ ("code", "analysis.py"),
389
+ ("stdout", "stdout.txt"),
390
+ ("stderr", "stderr.txt"),
391
+ ):
392
+ p = ed / fname
393
+ if p.exists():
394
+ try:
395
+ exp[key] = p.read_text(encoding="utf-8")
396
+ except Exception:
397
+ pass
398
+ audit_p = ed / "agent_audit.json"
399
+ if audit_p.exists():
400
+ try:
401
+ exp["audit"] = json.loads(audit_p.read_text(encoding="utf-8"))
402
+ except Exception:
403
+ pass
404
+ agents["explore"] = exp
405
+
406
+ # -- Synthesized tool codegen attempts (tools/) --------------------------
407
+ tools_dir = logs_dir / "tools"
408
+ if tools_dir.exists():
409
+ groups: dict[str, dict[str, str]] = {}
410
+ order: list[str] = []
411
+ for f in sorted(tools_dir.iterdir()):
412
+ if not f.is_file():
413
+ continue
414
+ name = f.name
415
+ for suffix, kind in (
416
+ ("_code.py", "code"), ("_prompt.txt", "prompt"),
417
+ ("_agent_thinking.txt", "agent_thinking"), ("_stdout.txt", "stdout"),
418
+ ("_agent_raw_stream.txt", "raw_stream"),
419
+ ):
420
+ if name.endswith(suffix):
421
+ stem = name[: -len(suffix)]
422
+ if stem not in groups:
423
+ groups[stem] = {}
424
+ order.append(stem)
425
+ try:
426
+ groups[stem][kind] = f.read_text(encoding="utf-8")
427
+ except Exception:
428
+ pass
429
+ break
430
+ for stem in order:
431
+ if groups[stem]:
432
+ agents["tool_codegen"].append({"stem": stem, "files": groups[stem]})
433
+
434
+ # -- Langfuse trace bundle summary ---------------------------------------
435
+ lf = logs_dir / "langfuse_trace.json"
436
+ if lf.exists():
437
+ try:
438
+ b = json.loads(lf.read_text(encoding="utf-8"))
439
+ agents["langfuse"] = {
440
+ "trace_id": (b.get("trace") or {}).get("id"),
441
+ "n_spans": len(b.get("spans") or []),
442
+ "n_generations": len(b.get("generations") or []),
443
+ "n_scores": len(b.get("scores") or []),
444
+ "spans": [
445
+ {
446
+ "name": s.get("name"),
447
+ "stage": s.get("stage"),
448
+ "status": s.get("status", "completed"),
449
+ }
450
+ for s in (b.get("spans") or [])
451
+ ],
452
+ }
453
+ except Exception:
454
+ pass
455
+
456
+ return agents
457
+
458
+
459
+ def extract_run_data(run_dir: Path, example_dir: Path | None = None) -> dict[str, Any]:
460
+ """Parse all run artifacts dynamically into a unified dictionary."""
461
+ logs_dir, explore_dir, fixes_dir = resolve_run_dirs(run_dir)
462
+ manifest_path, manifest = find_manifest(example_dir or logs_dir.parent, logs_dir)
463
+
464
+ from evalrx.reporting.run_events import read_v2_events
465
+
466
+ events: list[dict[str, Any]] = read_v2_events(logs_dir)
467
+
468
+ all_run_starts = [e for e in events if e.get("event") == "run_start"]
469
+ run_start = all_run_starts[-1] if all_run_starts else {}
470
+ active_trace_id = run_start.get("trace_id")
471
+ # Directories are commonly reused. Keep a report internally consistent by
472
+ # selecting the events from the latest run's trace only.
473
+ if active_trace_id:
474
+ scoped_events = [e for e in events if e.get("trace_id") == active_trace_id]
475
+ # Keep compatibility with pre-trace legacy logs, where only run_start
476
+ # may have a trace id (or none of the events do).
477
+ if len(scoped_events) > 1:
478
+ events = scoped_events
479
+
480
+ def by_event(event: str) -> list[dict[str, Any]]:
481
+ return [e for e in events if e.get("event") == event]
482
+
483
+ loop_end = by_event("loop_end")[-1] if by_event("loop_end") else {}
484
+ manifest_json = logs_dir / "manifest.json"
485
+ cfg = json.loads(manifest_json.read_text()) if manifest_json.exists() else {}
486
+
487
+ raw_model_name = (
488
+ run_start.get("model")
489
+ or cfg.get("model")
490
+ or cfg.get("config", {}).get("model")
491
+ or "Target Model"
492
+ )
493
+ clean_model = clean_model_display_name(raw_model_name)
494
+ n_cases = run_start.get("n_cases") or len(manifest) or 0
495
+ label_dist = run_start.get("label_distribution") or {}
496
+ protocol = run_start.get("protocol") or {}
497
+ protocol_desc = (
498
+ protocol.get("description") or ""
499
+ if isinstance(protocol, dict)
500
+ else str(protocol)
501
+ )
502
+ benchmark_name = clean_benchmark_name(protocol_desc, str(manifest_path or ""))
503
+ # The run's own summary.json (written by run_pipeline beside logs/) names
504
+ # the model and dataset the way the user typed them — "qwen3.5-2b",
505
+ # "bbh_word_sorting". It beats both a stringified Model repr and the
506
+ # keyword table above, which only knows a handful of benchmarks and
507
+ # otherwise says "Evaluation Benchmark". The run directory's own name is
508
+ # the last resort for the dataset, since run_all.sh names it after one.
509
+ run_summary: dict[str, Any] = {}
510
+ try:
511
+ summary_path = logs_dir.parent / "summary.json"
512
+ loaded = json.loads(summary_path.read_text()) if summary_path.exists() else {}
513
+ run_summary = loaded if isinstance(loaded, dict) else {}
514
+ except (OSError, json.JSONDecodeError):
515
+ run_summary = {}
516
+ if run_summary.get("model"):
517
+ clean_model = str(run_summary["model"])
518
+ if benchmark_name == "Evaluation Benchmark":
519
+ folder = logs_dir.parent.name
520
+ benchmark_name = (
521
+ str(run_summary.get("dataset") or "")
522
+ or (folder if folder not in {"outputs", "logs", ""} else "")
523
+ or benchmark_name
524
+ )
525
+
526
+ # Pre-M1
527
+ probe_searches = by_event("probe_search")
528
+ pre_m1_ran = bool(probe_searches)
529
+ pre_m1_cases = probe_searches[0].get("n_synthesized") if pre_m1_ran else 0
530
+
531
+ # Probe Case Map for reverse-lookup of anomalies per sample
532
+ probe_case_flags: dict[str, list[str]] = {}
533
+
534
+ # M1: Measurements & Per-case probe drilldowns
535
+ probes = by_event("probe")
536
+ p0 = probes[-1] if probes else {}
537
+ m1_cycle = int(p0.get("cycle", 0) or 0)
538
+ analyzers = p0.get("analyzers") or p0.get("selected_analyzers") or []
539
+ m1_duration = p0.get("duration_sec")
540
+
541
+ # An agent-written probe ("generated:probe1") has no glossary entry; the
542
+ # need it was written for is the only description that exists, and the
543
+ # codegen event carries it verbatim.
544
+ generated_need: dict[str, str] = {}
545
+ for event in by_event("tool_codegen"):
546
+ tool = str(event.get("tool_name") or "")
547
+ if tool and event.get("need"):
548
+ generated_need[tool] = str(event["need"])
549
+ m1_results = []
550
+ for name in analyzers:
551
+ result_paths = p0.get("result_paths") or {}
552
+ p = logs_dir / str(result_paths.get(name) or f"artifacts/c{m1_cycle}_{name}.result.json")
553
+ findings, n = {}, None
554
+ per_case_rows = []
555
+ # V1 externalised each analyzer's result to artifacts/; RunLoggerV2
556
+ # keeps the findings inline on the probe entry itself.
557
+ inline = (p0.get("findings") or {}).get(name)
558
+ if not p.exists() and isinstance(inline, dict):
559
+ findings = inline
560
+ n = findings.get("n_cases") or findings.get("n_scored")
561
+ per_case_rows = findings.get("per_case") or []
562
+ if p.exists():
563
+ try:
564
+ raw = json.loads(p.read_text())
565
+ findings = raw.get("findings") or {}
566
+ n = findings.get("n_cases") or findings.get("n_scored")
567
+ per_case_rows = findings.get("per_case") or []
568
+ for pc in per_case_rows:
569
+ sid = str(pc.get("sample_id", ""))
570
+ if sid:
571
+ if sid not in probe_case_flags:
572
+ probe_case_flags[sid] = []
573
+ if pc.get("format_flip_rate", 0) > 0:
574
+ probe_case_flags[sid].append(f"Option Order Flip ({pc['format_flip_rate']:.0%})")
575
+ if pc.get("conf_logprob", 0) > 0.8 and pc.get("correct") == 0:
576
+ probe_case_flags[sid].append(f"Overconfident on Error ({pc['conf_logprob']:.0%})")
577
+ if pc.get("pass_at_k") == 1.0:
578
+ probe_case_flags[sid].append("Pass@5 Capable")
579
+ elif pc.get("pass_at_k") == 0.0:
580
+ probe_case_flags[sid].append("Pass@5 Zero-Coverage")
581
+ except Exception:
582
+ pass
583
+ meta = ANALYZER_GLOSSARY.get(
584
+ name, (name.replace("_", " ").title(), "Measures model behavior across this dimension", "Standard diagnostic probe.", [])
585
+ )
586
+ if name.startswith("generated:"):
587
+ # No glossary can know a probe the agent wrote during this run: the
588
+ # need it was written for is its question (first sentence up front,
589
+ # the whole brief as the description), and its headline is whatever
590
+ # scalar findings it reported, under their own names.
591
+ need = generated_need.get(name.split(":", 1)[1], "")
592
+ first = re.split(r"(?<=[.;])\s+", need.strip(), maxsplit=1)[0] if need else "Agent-written probe"
593
+ if len(first) > 180:
594
+ first = first[:177].rstrip() + "…"
595
+ scalar = [
596
+ (key.replace("_", " "), key, "pct" if key.endswith(("_rate", "_frac")) else "num")
597
+ for key, value in findings.items()
598
+ if isinstance(value, (int, float)) and not isinstance(value, bool)
599
+ and key not in {"n_cases", "n_scored", "n_graded", "n_samples"}
600
+ ][:5]
601
+ meta = (f"Agent-written probe · {name.split(':', 1)[1]}", first, need or "Agent-written probe.", scalar)
602
+ headline = []
603
+ for label, path, fmt in meta[3]:
604
+ v = _dig(findings, path)
605
+ if isinstance(v, (int, float)):
606
+ headline.append({
607
+ "label": label,
608
+ "value": f"{v:.1%}" if fmt == "pct" else f"{v:.3f}".rstrip("0").rstrip("."),
609
+ })
610
+ else:
611
+ headline.append({"label": label, "value": None})
612
+ m1_results.append({
613
+ "name": name,
614
+ "display_name": meta[0],
615
+ "question": meta[1],
616
+ "description": meta[2],
617
+ "n": n,
618
+ "headline": headline,
619
+ "findings": findings,
620
+ "per_case": per_case_rows,
621
+ })
622
+ m1_results.sort(key=lambda r: -(r["n"] or 0))
623
+
624
+ # M2: Explore & Screening
625
+ analyses = by_event("analysis")
626
+ a0 = analyses[-1] if analyses else {}
627
+ m2_conclusion = a0.get("conclusion") or ""
628
+ m2_narrative = a0.get("narrative") or ""
629
+ m2_severity = a0.get("severity") or "medium"
630
+ m2_duration = a0.get("duration_sec")
631
+
632
+ m2_cycle = int(a0.get("cycle", m1_cycle) or 0)
633
+ stats_path = logs_dir / f"artifacts/c{m2_cycle}_m2_stats_results.json"
634
+ stats_ref = a0.get("stats_results")
635
+ if isinstance(stats_ref, dict) and stats_ref.get("path"):
636
+ stats_path = logs_dir / str(stats_ref["path"])
637
+ try:
638
+ raw_stats = json.loads(stats_path.read_text()) if stats_path.exists() else []
639
+ except (OSError, json.JSONDecodeError):
640
+ raw_stats = []
641
+ if not raw_stats and isinstance(stats_ref, list):
642
+ # RunLoggerV2 keeps the M2 rows inline on the analysis entry rather
643
+ # than externalising them to artifacts/.
644
+ raw_stats = [row for row in stats_ref if isinstance(row, dict)]
645
+ stats = []
646
+ for s in raw_stats:
647
+ stats.append({
648
+ "tool": s.get("tool"),
649
+ "config": s.get("config") or {},
650
+ "summary": s.get("summary") or "",
651
+ "effect": s.get("effect"),
652
+ "ci": s.get("ci") or [None, None],
653
+ "reject": bool(s.get("reject")),
654
+ "p_value": s.get("p_value"),
655
+ "underpowered": bool(s.get("underpowered")),
656
+ })
657
+ stats.sort(key=lambda s: (not s["reject"], -abs(s.get("effect") or 0)))
658
+
659
+ explore_data: dict[str, Any] = {}
660
+ if explore_dir and (explore_dir / "exploratory_report.json").exists():
661
+ try:
662
+ explore_data = json.loads((explore_dir / "exploratory_report.json").read_text())
663
+ except Exception:
664
+ pass
665
+ if not explore_data:
666
+ # RunLoggerV2 records the explore step as an M2 event: observations and
667
+ # caveats as sentences, figures as paths under logs/. Shape it the way
668
+ # the explore report is shaped, so the M2 record renders either.
669
+ explores = by_event("explore")
670
+ e0 = explores[-1] if explores else {}
671
+ takeaways = []
672
+ for text in a0.get("findings") or []:
673
+ if isinstance(text, str) and text.strip():
674
+ # "[MEDIUM] self_consistency.consistency=0.2 < 0.5: low self-consistency — …"
675
+ body = re.sub(r"^\[[A-Z]+\]\s*", "", text.strip())
676
+ head = body.split(": ", 1)[1] if ": " in body else body
677
+ takeaways.append({"title": head[:110], "plain_title": head[:110],
678
+ "analysis": body, "chart_names": [], "table_names": []})
679
+ for text in e0.get("observations") or []:
680
+ if isinstance(text, str) and text.strip():
681
+ head = text.strip().split(";")[0].split(". ")[0]
682
+ takeaways.append({"title": head[:90], "plain_title": head[:90],
683
+ "analysis": text.strip(), "chart_names": [], "table_names": []})
684
+ if takeaways or e0:
685
+ explore_data = {
686
+ "takeaways": takeaways,
687
+ "caveats": [c for c in e0.get("caveats") or [] if isinstance(c, str)],
688
+ "observations": [o for o in e0.get("observations") or [] if isinstance(o, str)],
689
+ "figures": [f for f in e0.get("figures") or [] if isinstance(f, str)],
690
+ "adjudication": e0.get("adjudication") or {},
691
+ "candidate_signals": [], "hypotheses": [],
692
+ }
693
+
694
+ # M3: Hypotheses
695
+ diagnoses = by_event("diagnosis")
696
+ dg = diagnoses[-1] if diagnoses else {}
697
+ hypotheses = dg.get("hypotheses") or []
698
+ if not hypotheses and (logs_dir / "report" / "hypotheses.json").exists():
699
+ try:
700
+ hypotheses = json.loads((logs_dir / "report" / "hypotheses.json").read_text())
701
+ except Exception:
702
+ pass
703
+
704
+ # M4: Adjudication
705
+ surgeries = by_event("surgery")
706
+ m4_surgeries = [s for s in surgeries if s.get("module") == "m4" or s.get("adjudication")]
707
+ m4_results_file = logs_dir / "report" / "m4_results.json"
708
+ try:
709
+ m4_results = json.loads(m4_results_file.read_text()) if m4_results_file.exists() else []
710
+ except (OSError, json.JSONDecodeError):
711
+ m4_results = []
712
+ m4_event = m4_surgeries[-1] if m4_surgeries else {}
713
+ if m4_surgeries:
714
+ # The JSONL event is trace-scoped; a report artifact can be stale when
715
+ # the same run directory is appended to later. One hypothesis can be
716
+ # tested more than once across cycles — every hypothesis this run
717
+ # actually adjudicated belongs here, not just the most recent verdict,
718
+ # otherwise a 3-hypothesis M4 pass reports as if only 1 ran.
719
+ m4_results = [{
720
+ "hypothesis_id": event.get("hypothesis_id"),
721
+ "hypothesis": event.get("hypothesis"),
722
+ "status": event.get("status"),
723
+ "effect_size": (event.get("evidence") or {}).get("m4_effect_size"),
724
+ "confidence": event.get("confidence_score"),
725
+ "verdict": (event.get("evidence") or {}).get("m4_verdict"),
726
+ "evidence_grade": (event.get("evidence") or {}).get("m4_evidence_grade"),
727
+ "protocol_consistent": (event.get("evidence") or {}).get("m4_protocol_consistent"),
728
+ } for event in m4_surgeries]
729
+
730
+ # M5-Surgery
731
+ m5_surgeries = [s for s in surgeries if s.get("module") != "m4"]
732
+ m5_surgery_ran = bool(m5_surgeries)
733
+
734
+ # M5-Fix: Repair
735
+ fixes = by_event("fix")
736
+ f0 = fixes[-1] if fixes else {}
737
+ best_fix = f0.get("best") or {}
738
+ fix_attempts = f0.get("selection_attempted") or f0.get("attempted") or []
739
+
740
+ confirmed_fix: dict[str, Any] = {}
741
+ fix_dir = None
742
+ if fixes_dir:
743
+ cand_dirs = sorted(fixes_dir.glob("*/result.json"))
744
+ if cand_dirs:
745
+ picked = cand_dirs[-1]
746
+ for cd in cand_dirs:
747
+ try:
748
+ res_j = json.loads(cd.read_text())
749
+ if res_j.get("fixed"):
750
+ picked = cd
751
+ break
752
+ except Exception:
753
+ pass
754
+ fix_dir = picked.parent
755
+ try:
756
+ confirmed_fix = json.loads(picked.read_text())
757
+ except Exception:
758
+ pass
759
+
760
+ if not confirmed_fix and isinstance(best_fix, dict):
761
+ confirmed_fix = best_fix
762
+
763
+ cases: list[dict[str, Any]] = []
764
+ output_files = []
765
+ if fix_dir and (fix_dir / "outputs.jsonl").exists():
766
+ output_files.append(fix_dir / "outputs.jsonl")
767
+ output_files.extend(sorted(logs_dir.glob("**/outputs.jsonl")))
768
+ if logs_dir.parent != logs_dir:
769
+ output_files.extend(sorted(logs_dir.parent.glob("**/outputs.jsonl")))
770
+
771
+ seen_case_ids = set()
772
+ if output_files:
773
+ target_out = output_files[0]
774
+ for line in target_out.open(encoding="utf-8"):
775
+ line = line.strip()
776
+ if line:
777
+ try:
778
+ r = json.loads(line)
779
+ cid = str(r.get("case_id") or r.get("id") or "")
780
+ if not cid:
781
+ continue
782
+ seen_case_ids.add(cid)
783
+ m = manifest.get(cid) or {}
784
+ instruction = (
785
+ m.get("instruction")
786
+ or m.get("question")
787
+ or m.get("prompt")
788
+ or m.get("query")
789
+ or ""
790
+ )
791
+ choices = m.get("choices") or m.get("options") or m.get("candidates") or []
792
+ expected = m.get("expected") or m.get("answer") or m.get("label") or m.get("gt") or ""
793
+ audio_p = (
794
+ m.get("audio_path")
795
+ or m.get("audio")
796
+ or m.get("wav_path")
797
+ or m.get("audio_file")
798
+ or ""
799
+ )
800
+ image_p = (
801
+ m.get("image")
802
+ or m.get("image_path")
803
+ or m.get("image_file")
804
+ or ""
805
+ )
806
+ if isinstance(image_p, (list, tuple)):
807
+ image_p = image_p[0] if image_p else ""
808
+
809
+ cases.append({
810
+ "id": cid,
811
+ "status": r.get("status", "unchanged"),
812
+ "output": str(r.get("output", "")),
813
+ "instruction": instruction,
814
+ "choices": choices,
815
+ "expected": str(expected),
816
+ "audio_path": audio_p,
817
+ "image_path": image_p,
818
+ "duration": float(m.get("duration_sec") or 0.0),
819
+ "task": (m.get("metadata") or {}).get("mmau_task") or (m.get("metadata") or {}).get("category") or "",
820
+ "probe_flags": probe_case_flags.get(cid, []),
821
+ })
822
+ except Exception:
823
+ pass
824
+
825
+ # Fallback to manifest + baseline
826
+ if not cases and manifest:
827
+ fixed_set = set(confirmed_fix.get("fixed_cases") or [])
828
+ broken_set = set(confirmed_fix.get("broken_cases") or [])
829
+ baseline_map = {}
830
+ for bp in [logs_dir / "artifacts" / "baseline.json", *logs_dir.glob("**/baseline.json")]:
831
+ if bp.exists():
832
+ try:
833
+ bdata = json.loads(bp.read_text())
834
+ for bc in (bdata.get("cases") or []):
835
+ if isinstance(bc, dict) and bc.get("id"):
836
+ baseline_map[str(bc["id"])] = bc
837
+ except Exception:
838
+ pass
839
+
840
+ for cid, m in manifest.items():
841
+ st = "unchanged"
842
+ if cid in fixed_set:
843
+ st = "fixed"
844
+ elif cid in broken_set:
845
+ st = "broken"
846
+
847
+ b_out = baseline_map.get(cid, {}).get("output", "")
848
+ instruction = (
849
+ m.get("instruction")
850
+ or m.get("question")
851
+ or m.get("prompt")
852
+ or m.get("query")
853
+ or ""
854
+ )
855
+ choices = m.get("choices") or m.get("options") or m.get("candidates") or []
856
+ expected = m.get("expected") or m.get("answer") or m.get("label") or m.get("gt") or ""
857
+ audio_p = (
858
+ m.get("audio_path")
859
+ or m.get("audio")
860
+ or m.get("wav_path")
861
+ or m.get("audio_file")
862
+ or ""
863
+ )
864
+ image_p = (
865
+ m.get("image")
866
+ or m.get("image_path")
867
+ or m.get("image_file")
868
+ or ""
869
+ )
870
+ if isinstance(image_p, (list, tuple)):
871
+ image_p = image_p[0] if image_p else ""
872
+
873
+ cases.append({
874
+ "id": cid,
875
+ "status": st,
876
+ "output": str(b_out),
877
+ "instruction": instruction,
878
+ "choices": choices,
879
+ "expected": str(expected),
880
+ "audio_path": audio_p,
881
+ "image_path": image_p,
882
+ "duration": float(m.get("duration_sec") or 0.0),
883
+ "task": (m.get("metadata") or {}).get("mmau_task") or (m.get("metadata") or {}).get("category") or "",
884
+ "probe_flags": probe_case_flags.get(cid, []),
885
+ })
886
+
887
+ order = {"fixed": 0, "broken": 1, "unchanged": 2, "untested": 3}
888
+ cases.sort(key=lambda c: (order.get(c["status"], 9), c["id"]))
889
+
890
+ data = {
891
+ "run": {
892
+ "model": clean_model,
893
+ "raw_model": raw_model_name,
894
+ "benchmark_name": benchmark_name,
895
+ "n_cases": n_cases,
896
+ "label_distribution": label_dist,
897
+ "protocol": protocol_desc,
898
+ "cycles": loop_end.get("cycles", 1),
899
+ "stopped_by": loop_end.get("stopped_by", "completed"),
900
+ "duration_sec": loop_end.get("total_duration_sec", 0),
901
+ "version": run_start.get("evalrx_version", "0.2.0"),
902
+ "data_fingerprint": run_start.get("data_fingerprint", ""),
903
+ "logs_dir": str(logs_dir),
904
+ "manifest_path": str(manifest_path) if manifest_path else "",
905
+ },
906
+ "pre_m1": {
907
+ "ran": pre_m1_ran,
908
+ "n_cases": pre_m1_cases,
909
+ },
910
+ "m1": {
911
+ "analyzers": analyzers,
912
+ "duration": m1_duration,
913
+ "results": m1_results,
914
+ },
915
+ "m2": {
916
+ "conclusion": m2_conclusion,
917
+ "narrative": m2_narrative,
918
+ "severity": m2_severity,
919
+ "duration": m2_duration,
920
+ "stats": stats,
921
+ "explore": explore_data,
922
+ # The analysis step's own figures (V2 paths under logs/).
923
+ "figures": [f for f in a0.get("figures") or [] if isinstance(f, str)],
924
+ },
925
+ "m3": {
926
+ "hypotheses": hypotheses,
927
+ # The proposer's own list, kept beside the (possibly reordered and
928
+ # critic-annotated) accepted list. It is the only place a run
929
+ # recorded before the log writer carried `plain_statement` on
930
+ # `hypotheses` still has the judge's plain sentence, and the report
931
+ # compiler joins the two on the statement to recover it.
932
+ "proposed_hypotheses": dg.get("proposed_hypotheses") or [],
933
+ "duration": dg.get("duration_sec"),
934
+ },
935
+ "m4": {
936
+ "ran": bool(m4_surgeries or m4_results),
937
+ "results": m4_results,
938
+ "event": m4_event,
939
+ },
940
+ "m5_surgery": {
941
+ "ran": m5_surgery_ran,
942
+ "surgeries": m5_surgeries,
943
+ },
944
+ "m5_fix": {
945
+ "ran": bool(fixes or confirmed_fix),
946
+ "fixed": bool(f0.get("fixed") or confirmed_fix.get("fixed")),
947
+ "selection": fix_attempts,
948
+ "confirm": confirmed_fix,
949
+ "best": best_fix,
950
+ "prompt_template": (confirmed_fix.get("payload") or {}).get("prompt_template") or "",
951
+ },
952
+ "cases": cases,
953
+ "agents": _extract_agent_layer(logs_dir, explore_dir),
954
+ "logs_dir": logs_dir,
955
+ "explore_dir": explore_dir,
956
+ "example_dir": example_dir or logs_dir.parent,
957
+ }
958
+ # Keep the audience-first semantic layer independent from HTML. The same
959
+ # object can later drive a different renderer without reinterpreting logs.
960
+ from evalrx.reporting.compiler import compile_reader_report
961
+
962
+ data["reader_report"] = compile_reader_report(data).to_dict()
963
+ return data
964
+
965
+
966
+ def embed_figures(explore_dir: Path | None, logs_dir: Path) -> dict[str, str]:
967
+ """Find all PNG charts and convert to base64 Data URIs."""
968
+ out = {}
969
+ paths = []
970
+ if explore_dir and (explore_dir / "figures").is_dir():
971
+ paths += sorted((explore_dir / "figures").glob("*.png"))
972
+ # V1 writes M2 charts to logs_dir/figures; V2's RunContext.figures_dir
973
+ # maps the same role to logs_dir/M2/artifacts (see run_context.py). V2's
974
+ # explore-step figures are captured inline into M2/log.json rather than
975
+ # written as loose files, so the explore_dir branch above still finds
976
+ # nothing for a V2 run — a known follow-up, not covered here.
977
+ for candidate in (logs_dir / "figures", logs_dir / "M2" / "artifacts"):
978
+ if candidate.is_dir():
979
+ paths += sorted(candidate.glob("*.png"))
980
+
981
+ for p in paths:
982
+ key = p.stem
983
+ clean_key = re.sub(r"^\d+_", "", key)
984
+ try:
985
+ b64 = base64.b64encode(p.read_bytes()).decode("ascii")
986
+ out[clean_key] = f"data:image/png;base64,{b64}"
987
+ out[key] = f"data:image/png;base64,{b64}"
988
+ except Exception:
989
+ pass
990
+ return out
991
+
992
+
993
+ def embed_media(cases: list[dict], example_dir: Path, cache_dir: Path, no_audio: bool = False) -> tuple[dict[str, str], dict[str, str]]:
994
+ """Embed audio and image files as base64 data URIs concurrently."""
995
+ audio_map: dict[str, str] = {}
996
+ image_map: dict[str, str] = {}
997
+
998
+ has_ffmpeg = bool(shutil.which("ffmpeg"))
999
+ try:
1000
+ cache_dir.mkdir(parents=True, exist_ok=True)
1001
+ except Exception:
1002
+ cache_dir = Path("/tmp/evalrx_media_cache")
1003
+ cache_dir.mkdir(parents=True, exist_ok=True)
1004
+
1005
+ def process_audio(c: dict) -> tuple[str, str] | None:
1006
+ cid = c["id"]
1007
+ if not no_audio and c.get("audio_path"):
1008
+ src = example_dir / "data" / c["audio_path"]
1009
+ if not src.exists() and (example_dir / c["audio_path"]).exists():
1010
+ src = example_dir / c["audio_path"]
1011
+
1012
+ if src.exists():
1013
+ dst = cache_dir / f"{cid}.m4a"
1014
+ if not dst.exists() and has_ffmpeg:
1015
+ try:
1016
+ subprocess.run(
1017
+ ["ffmpeg", "-y", "-loglevel", "error", "-i", str(src),
1018
+ "-ac", "1", "-ar", "16000", "-c:a", "aac", "-b:a", "48k", str(dst)],
1019
+ capture_output=True,
1020
+ timeout=10,
1021
+ )
1022
+ except Exception:
1023
+ pass
1024
+ target = dst if dst.exists() else src
1025
+ try:
1026
+ mime = "audio/mp4" if target.suffix in (".m4a", ".aac") else "audio/wav"
1027
+ b64 = base64.b64encode(target.read_bytes()).decode("ascii")
1028
+ return cid, f"data:{mime};base64,{b64}"
1029
+ except Exception:
1030
+ pass
1031
+ return None
1032
+
1033
+ def process_image(c: dict) -> tuple[str, str] | None:
1034
+ cid = c["id"]
1035
+ if c.get("image_path"):
1036
+ src = example_dir / "data" / c["image_path"]
1037
+ if not src.exists() and (example_dir / c["image_path"]).exists():
1038
+ src = example_dir / c["image_path"]
1039
+ if src.exists():
1040
+ try:
1041
+ ext = src.suffix.lower().lstrip(".")
1042
+ mime = f"image/{ext}" if ext in ("png", "jpeg", "webp", "gif") else "image/jpeg"
1043
+ b64 = base64.b64encode(src.read_bytes()).decode("ascii")
1044
+ return cid, f"data:{mime};base64,{b64}"
1045
+ except Exception:
1046
+ pass
1047
+ return None
1048
+
1049
+ with ThreadPoolExecutor(max_workers=16) as pool:
1050
+ for res in pool.map(process_audio, cases):
1051
+ if res:
1052
+ audio_map[res[0]] = res[1]
1053
+ for res in pool.map(process_image, cases):
1054
+ if res:
1055
+ image_map[res[0]] = res[1]
1056
+
1057
+ return audio_map, image_map
1058
+
1059
+
1060
+ def esc(s: Any) -> str:
1061
+ if s is None:
1062
+ return ""
1063
+ return html.escape(str(s), quote=True)
1064
+
1065
+
1066
+ def _json_for_script(value: Any) -> str:
1067
+ """Serialize untrusted values safely inside an inline ``<script>`` block."""
1068
+ return (
1069
+ json.dumps(value, ensure_ascii=False)
1070
+ .replace("<", "\\u003c")
1071
+ .replace(">", "\\u003e")
1072
+ .replace("&", "\\u0026")
1073
+ .replace("\u2028", "\\u2028")
1074
+ .replace("\u2029", "\\u2029")
1075
+ )
1076
+
1077
+
1078
+ def generate_html_report(data: dict[str, Any], figures: dict[str, str], audio_map: dict[str, str], image_map: dict[str, str]) -> str:
1079
+ run = data["run"]
1080
+ m1 = data["m1"]
1081
+ m2 = data["m2"]
1082
+ m3 = data["m3"]
1083
+ m4 = data["m4"]
1084
+ m5_s = data["m5_surgery"]
1085
+ m5_f = data["m5_fix"]
1086
+ cases = data["cases"]
1087
+ reader = data.get("reader_report") or {}
1088
+
1089
+ n_total = run["n_cases"] or len(cases) or 1
1090
+ cfm = m5_f.get("confirm") or {}
1091
+ repair_effect = cfm.get("effect")
1092
+ e_val = cfm.get("e_value")
1093
+ n_fixed = cfm.get("n_fixed") or sum(1 for c in cases if c["status"] == "fixed")
1094
+ n_broken = cfm.get("n_broken") or sum(1 for c in cases if c["status"] == "broken")
1095
+
1096
+ # M4 Verdict
1097
+ m4_status = "Skipped"
1098
+ m4_tone = "skip"
1099
+ if m4["ran"]:
1100
+ m4_r0 = m4["results"][0] if m4["results"] else {}
1101
+ verdict_status = str(m4_r0.get("status") or m4["event"].get("status") or "").lower()
1102
+ if verdict_status == "supported":
1103
+ m4_status = "Supported"
1104
+ m4_tone = "good"
1105
+ elif verdict_status == "refuted":
1106
+ m4_status = "Refuted"
1107
+ m4_tone = "warn"
1108
+ else:
1109
+ m4_status = "Inconclusive"
1110
+ m4_tone = "neutral"
1111
+
1112
+ # M5 Status
1113
+ m5_status = "Not Tested"
1114
+ m5_tone = "skip"
1115
+ if m5_f["ran"]:
1116
+ if m5_f["fixed"] or (repair_effect is not None and repair_effect > 0):
1117
+ m5_status = f"+{repair_effect * 100:.1f}% Net Gain" if repair_effect is not None else "Repair Validated"
1118
+ m5_tone = "good"
1119
+ else:
1120
+ m5_status = "Inconclusive / Neutral"
1121
+ m5_tone = "warn"
1122
+
1123
+ stats_sig = [s for s in m2["stats"] if s["reject"]]
1124
+
1125
+ # Compact visual summary. Values come only from the canonical run stages,
1126
+ # so this remains valid for any task that emits the standard artifacts.
1127
+ tiles_data = [
1128
+ ("Cases", f"{n_total:,}", "evaluated", "neutral"),
1129
+ (
1130
+ "Patterns",
1131
+ str(len(stats_sig)),
1132
+ "worth checking",
1133
+ "good" if stats_sig else "neutral",
1134
+ ),
1135
+ (
1136
+ "Independent check",
1137
+ m4_status,
1138
+ "new cases" if m4["ran"] else "not run",
1139
+ m4_tone,
1140
+ ),
1141
+ (
1142
+ "Repair result",
1143
+ f"{repair_effect * 100:+.2f}%" if repair_effect is not None else "—",
1144
+ f"{n_fixed} improved · {n_broken} worse" if (n_fixed or n_broken) else "not tested",
1145
+ m5_tone,
1146
+ ),
1147
+ ]
1148
+
1149
+ tiles_html = "\n".join([
1150
+ f'<div class="kpi-card kpi-card--{tone}">'
1151
+ f'<div class="kpi-label">{esc(lab)}</div>'
1152
+ f'<div class="kpi-val">{esc(val)}</div>'
1153
+ f'<div class="kpi-sub">{note}</div></div>'
1154
+ for lab, val, note, tone in tiles_data
1155
+ ])
1156
+
1157
+ reader_findings_html = "\n".join(
1158
+ f'''<article class="reader-finding">
1159
+ <div class="reader-finding-head"><h3>{esc(finding.get("title"))}</h3><span class="badge badge--neutral">{esc(finding.get("evidence_level"))}</span></div>
1160
+ <p>{esc(finding.get("summary"))}</p>
1161
+ </article>'''
1162
+ for finding in (reader.get("key_findings") or [])
1163
+ ) or '<p class="text-muted">No reader-ready findings are available for this run.</p>'
1164
+ reader_method = " ".join(str(step) for step in (reader.get("what_we_did") or []))
1165
+ reader_caveat = next(iter(reader.get("open_questions") or []), "")
1166
+ reader_next_step = next(iter(reader.get("next_steps") or []), "")
1167
+ setting_html = f'''<section class="setting-card">
1168
+ <div>
1169
+ <div class="setting-kicker">EvalRX · failure investigation</div>
1170
+ <h1 class="setting-title">Why did this model fail this evaluation?</h1>
1171
+ <p class="setting-copy">We evaluate <b>{esc(run["model"])}</b> on <b>{esc(run["benchmark_name"])}</b>, then trace a failure from targeted probes to a verified repair.</p>
1172
+ </div>
1173
+ <div class="setting-facts">
1174
+ <div class="setting-fact"><span class="setting-fact-label">Evaluation</span><span class="setting-fact-value">{n_total:,} cases</span></div>
1175
+ <div class="setting-fact"><span class="setting-fact-label">Investigation question</span><span class="setting-fact-value">{esc(reader.get("question") or "Find the mechanism behind observed failures.")}</span></div>
1176
+ </div>
1177
+ </section>'''
1178
+ run_map_html = "\n".join([
1179
+ f'<div class="run-node run-node--neutral"><span class="run-node-label">Start</span><span class="run-node-title">Failure observed</span><span class="run-node-value">{n_total:,} cases evaluated</span></div>',
1180
+ f'<div class="run-node run-node--good"><span class="run-node-label">M1 · Probe</span><span class="run-node-title">Characterize the failure</span><span class="run-node-value">{len(m1["analyzers"])} targeted probes</span></div>',
1181
+ f'<div class="run-node run-node--good"><span class="run-node-label">M2 · Analyze</span><span class="run-node-title">Find recurring structure</span><span class="run-node-value">{len(stats_sig)} patterns retained</span></div>',
1182
+ f'<div class="run-node run-node--{"good" if m3["hypotheses"] else "skip"}"><span class="run-node-label">M3 · Diagnose</span><span class="run-node-title">Propose a testable mechanism</span><span class="run-node-value">{len(m3["hypotheses"])} hypotheses</span></div>',
1183
+ f'<div class="run-node run-node--{m4_tone if m4["ran"] else "skip"}"><span class="run-node-label">M4 · Verify</span><span class="run-node-title">Check on unseen cases</span><span class="run-node-value">{esc(m4_status)}</span></div>',
1184
+ f'<div class="run-node run-node--{m5_tone}"><span class="run-node-label">M5 · Repair</span><span class="run-node-title">Beat the original baseline</span><span class="run-node-value">{esc(m5_status)}</span></div>',
1185
+ ])
1186
+
1187
+ # ── Agent Trajectory layer ────────────────────────────────────────────
1188
+ agents = data.get("agents") or {}
1189
+ agent_judges = agents.get("judge_calls") or []
1190
+ agent_explore = agents.get("explore") or {}
1191
+ agent_codegen = agents.get("tool_codegen") or []
1192
+ agent_langfuse = agents.get("langfuse") or {}
1193
+ agents_n = len(agent_judges) + len(agent_codegen) + (1 if agent_explore else 0)
1194
+ agents_summary = f"{len(agent_judges) + len(agent_codegen) + (1 if agent_explore else 0)} Calls"
1195
+
1196
+ judge_boxes = []
1197
+ for jc in agent_judges:
1198
+ judge_boxes.append(f"""
1199
+ <details class="collapsible-box">
1200
+ <summary>{esc(jc['stage'])} — <span class="mono" style="font-size:12px;">{esc(jc['stem'])}</span>
1201
+ <span class="text-muted" style="font-weight:400; font-size:12px;">&nbsp;(prompt {jc['prompt_chars']:,} chars · response {jc['raw_chars']:,} chars)</span></summary>
1202
+ <div class="content">
1203
+ <div class="section-subhead">Agent Input (Prompt)</div>
1204
+ <pre class="code-block" style="max-height:260px;">{esc(jc['prompt'])}</pre>
1205
+ <div class="section-subhead" style="margin-top:12px;">Agent Output (Response)</div>
1206
+ <pre class="code-block" style="max-height:260px;">{esc(jc['response']) or '<span class="text-muted">(empty)</span>'}</pre>
1207
+ </div>
1208
+ </details>""")
1209
+ judge_boxes_html = "\n".join(judge_boxes) or '<p class="text-muted">No judge calls were recorded for this run.</p>'
1210
+
1211
+ explore_boxes = []
1212
+ if agent_explore:
1213
+ # Prefer the UNTRUNCATED streams when present; fall back to the
1214
+ # capped rendering.
1215
+ explore_stream_text = agent_explore.get("raw_streams") or agent_explore.get("raw_output") or ""
1216
+ n_attempts = explore_stream_text.count("--- attempt") or (1 if explore_stream_text else 0)
1217
+ if explore_stream_text:
1218
+ explore_boxes.append(f"""
1219
+ <details class="collapsible-box">
1220
+ <summary>Explore Coder Agent — Raw CLI Trajectory ({n_attempts} attempt{'s' if n_attempts != 1 else ''}{' · full untruncated streams' if agent_explore.get('raw_streams') else ' · truncated rendering'})</summary>
1221
+ <div class="content"><pre class="code-block" style="max-height:340px;">{esc(explore_stream_text)}</pre></div>
1222
+ </details>""")
1223
+ if agent_explore.get("code"):
1224
+ explore_boxes.append(f"""
1225
+ <details class="collapsible-box">
1226
+ <summary>Explore Synthesized Analysis Code (analysis.py)</summary>
1227
+ <div class="content"><pre class="code-block" style="max-height:340px;">{esc(agent_explore['code'])}</pre></div>
1228
+ </details>""")
1229
+ if agent_explore.get("audit"):
1230
+ explore_boxes.append(f"""
1231
+ <details class="collapsible-box">
1232
+ <summary>Explore Agent Audit (provider, commands, artifacts)</summary>
1233
+ <div class="content"><pre class="code-block" style="max-height:260px;">{esc(json.dumps(agent_explore['audit'], indent=2, ensure_ascii=False))}</pre></div>
1234
+ </details>""")
1235
+ if agent_explore.get("stdout"):
1236
+ explore_boxes.append(f"""
1237
+ <details class="collapsible-box">
1238
+ <summary>Explore Execution stdout</summary>
1239
+ <div class="content"><pre class="code-block" style="max-height:260px;">{esc(agent_explore['stdout'])}</pre></div>
1240
+ </details>""")
1241
+ explore_boxes_html = "\n".join(explore_boxes) or '<p class="text-muted">The explore step did not run or left no trajectory for this run.</p>'
1242
+
1243
+ codegen_boxes = []
1244
+ for cg in agent_codegen:
1245
+ files = cg.get("files") or {}
1246
+ inner = []
1247
+ for kind, label in (
1248
+ ("prompt", "Agent Input (Task Prompt)"),
1249
+ ("raw_stream", "Agent Output (Full Raw CLI Trajectory)"),
1250
+ ("agent_thinking", "Agent Output (Raw CLI Trajectory)"),
1251
+ ("code", "Synthesized Code"),
1252
+ ("stdout", "Validation stdout"),
1253
+ ):
1254
+ if files.get(kind):
1255
+ inner.append(
1256
+ f'<div class="section-subhead" style="margin-top:10px;">{label}</div>'
1257
+ f'<pre class="code-block" style="max-height:260px;">{esc(files[kind])}</pre>'
1258
+ )
1259
+ codegen_boxes.append(f"""
1260
+ <details class="collapsible-box">
1261
+ <summary>Tool Codegen — <span class="mono" style="font-size:12px;">{esc(cg['stem'])}</span>
1262
+ <span class="text-muted" style="font-weight:400; font-size:12px;">&nbsp;({len(files)} artifacts)</span></summary>
1263
+ <div class="content">{''.join(inner)}</div>
1264
+ </details>""")
1265
+ codegen_boxes_html = "\n".join(codegen_boxes) or '<p class="text-muted">No tools were synthesized in this run.</p>'
1266
+
1267
+ if agent_langfuse:
1268
+ span_rows = "".join(
1269
+ f'<tr><td class="mono font-semibold" style="font-size:12px;">{esc(s.get("stage") or "")}</td>'
1270
+ f'<td style="font-size:12.5px;">{esc(s.get("name") or "")}</td>'
1271
+ f'<td><span class="badge badge--{"good" if s.get("status", "completed") == "completed" else "mute"}">{esc(s.get("status", "completed"))}</span></td></tr>'
1272
+ for s in agent_langfuse.get("spans") or []
1273
+ )
1274
+ langfuse_html = f"""
1275
+ <details class="collapsible-box" open>
1276
+ <summary>Langfuse Trace Bundle — {agent_langfuse.get('n_spans', 0)} spans · {agent_langfuse.get('n_generations', 0)} generations · {agent_langfuse.get('n_scores', 0)} scores</summary>
1277
+ <div class="content">
1278
+ <p class="text-muted" style="font-size:12.5px;">Trace id <span class="mono">{esc(agent_langfuse.get('trace_id') or '')}</span> — the same id used by <span class="mono">run.json</span> and (when live-synced) the Langfuse dashboard. Every span below is also mirrored there.</p>
1279
+ <div class="table-wrapper" style="max-height:300px; overflow-y:auto;">
1280
+ <table class="data-table"><thead><tr><th>Stage</th><th>Span</th><th>Status</th></tr></thead><tbody>{span_rows}</tbody></table>
1281
+ </div>
1282
+ </div>
1283
+ </details>"""
1284
+ else:
1285
+ langfuse_html = '<p class="text-muted">No Langfuse trace bundle (langfuse_trace.json) was written for this run.</p>'
1286
+
1287
+ tab_items = [
1288
+ ("tab_overview", "START", "What this report says", reader.get("confidence", "Summary"), "good"),
1289
+ ("tab_m1", "1", "What we checked", f"{len(m1['analyzers'])} checks", "neutral"),
1290
+ ("tab_m2", "2", "What we found", f"{len(stats_sig)} leads" if stats_sig else "Patterns", "neutral"),
1291
+ ("tab_m3", "3", "Possible explanation", "Not run" if not m3["hypotheses"] else "To test", "skip" if not m3["hypotheses"] else "neutral"),
1292
+ ("tab_m4", "4", "Independent check", "Not run" if not m4["ran"] else m4_status, "skip" if not m4["ran"] else m4_tone),
1293
+ ("tab_m5_fix", "5", "Repair attempt", m5_status, m5_tone),
1294
+ ("tab_case_book", "EXAMPLES", "Listen to real cases", f"{len(cases)} cases", "neutral"),
1295
+ ("tab_agents", "DETAILS", "Research details", f"{agents_summary}", "neutral" if agents_n else "skip"),
1296
+ ]
1297
+ if data["pre_m1"]["ran"]:
1298
+ tab_items.insert(1, ("tab_pre_m1", "PREP", "Extra test cases", "Completed", "neutral"))
1299
+ if m5_s["ran"]:
1300
+ tab_items.insert(-2, ("tab_m5_surgery", "RESEARCH", "Internal intervention", "Completed", "neutral"))
1301
+
1302
+ tabs_html = "\n".join([
1303
+ f'<button type="button" class="tab-btn {"is-active" if tid == "tab_overview" else ""}" data-tab="{tid}">'
1304
+ f'<span class="tab-code">{esc(code)}</span>'
1305
+ f'<span class="tab-label">{esc(label)}</span>'
1306
+ f'<span class="tab-badge badge--{tone}">{esc(badge)}</span>'
1307
+ f'</button>'
1308
+ for tid, code, label, badge, tone in tab_items
1309
+ ])
1310
+
1311
+ cov_max = max((r["n"] or 0) for r in m1["results"]) if m1["results"] else 1
1312
+ cov_max = max(cov_max, 1)
1313
+ m1_blocks = []
1314
+ for idx, r in enumerate(m1["results"], 1):
1315
+ n = r["n"] or 0
1316
+ hl_items = []
1317
+ for h in r["headline"]:
1318
+ if h["value"] is not None:
1319
+ hl_items.append(f'<div class="hl-pill"><span class="hl-v">{esc(h["value"])}</span><span class="hl-l">{esc(h["label"])}</span></div>')
1320
+ else:
1321
+ hl_items.append(f'<div class="hl-pill hl-pill--none"><span class="hl-v">—</span><span class="hl-l">{esc(h["label"])}</span></div>')
1322
+ hl_html = "".join(hl_items) or '<span class="text-muted">No scalar headlines</span>'
1323
+
1324
+ pct_val = (n / n_total) if n_total > 0 else 0
1325
+ cov_bar = (
1326
+ f'<div class="bar-track"><span class="bar-fill" style="width:{n / cov_max * 100:.1f}%"></span></div>'
1327
+ f'<span class="barpct">{pct_val:.0%} coverage ({n:,} cases scored)</span>'
1328
+ )
1329
+
1330
+ # Build Per-Case Drilldown Table
1331
+ per_case_rows = r.get("per_case") or []
1332
+ case_rows_html = []
1333
+ for pc in per_case_rows[:30]:
1334
+ sid = str(pc.get("sample_id", "case"))
1335
+ metrics_summary = ", ".join(
1336
+ f"<b>{esc(k)}</b>: {esc(v)}" for k, v in pc.items() if k != "sample_id"
1337
+ )
1338
+ sid_js = esc(json.dumps(sid))
1339
+ case_rows_html.append(
1340
+ f'<tr><td class="mono font-bold" style="font-size:12px; color:var(--brand);">{esc(sid[:12])}</td>'
1341
+ f'<td style="font-size:12.5px;">{metrics_summary}</td>'
1342
+ f'<td><button type="button" class="mini-btn" onclick="openCaseModal({sid_js})">Inspect Case & Audio</button></td></tr>'
1343
+ )
1344
+ per_case_table = (
1345
+ f'<div class="table-wrapper" style="margin-top:12px; max-height:280px; overflow-y:auto;">'
1346
+ f'<table class="data-table"><thead><tr><th>Sample ID</th><th>Probe Model Output & Decision Metrics</th><th>Action</th></tr></thead>'
1347
+ f'<tbody>{"".join(case_rows_html)}</tbody></table></div>'
1348
+ if case_rows_html else '<p class="text-muted" style="font-size:12px; margin-top:8px;">No per-case drilldown records for this probe.</p>'
1349
+ )
1350
+
1351
+ findings_json_str = esc(json.dumps(r.get("findings", {}), indent=2, ensure_ascii=False))
1352
+
1353
+ m1_blocks.append(f"""
1354
+ <div class="probe-card">
1355
+ <div class="probe-header" onclick="toggleProbe('probe_detail_{idx}')">
1356
+ <div style="flex:1;">
1357
+ <div class="probe-title">{esc(r["display_name"])} <span class="probe-code mono">{esc(r["name"])}</span></div>
1358
+ <div class="probe-q">{esc(r["question"])}</div>
1359
+ </div>
1360
+ <div style="width:160px; margin:0 16px;">{cov_bar}</div>
1361
+ <div class="hl-wrap" style="margin-right:16px;">{hl_html}</div>
1362
+ <div class="expand-icon" id="icon_probe_detail_{idx}">▼</div>
1363
+ </div>
1364
+ <div class="probe-body" id="probe_detail_{idx}" style="display:none;">
1365
+ <div class="probe-desc-box">
1366
+ <div class="section-subhead">Clinical Methodology & Rationale</div>
1367
+ <p>{esc(r.get("description", ""))}</p>
1368
+ </div>
1369
+ <div style="margin-top:14px;">
1370
+ <div class="section-subhead">Sample-Level Probe Execution & Model Decisions ({len(per_case_rows)} samples recorded)</div>
1371
+ {per_case_table}
1372
+ </div>
1373
+ <details class="collapsible-box" style="margin-top:14px;">
1374
+ <summary>View Complete Probe Findings JSON & Attention Parameters</summary>
1375
+ <div class="content"><pre class="code-block">{findings_json_str}</pre></div>
1376
+ </details>
1377
+ </div>
1378
+ </div>""")
1379
+
1380
+ m1_cards_html = "\\n".join(m1_blocks) if m1_blocks else "<p class='text-muted'>No probes recorded.</p>"
1381
+
1382
+ stat_rows_sig = []
1383
+ stat_rows_null = []
1384
+ for s in m2["stats"]:
1385
+ sig_name = (s.get("config") or {}).get("signal") or s.get("tool") or "Signal"
1386
+ ci = s.get("ci") or [None, None]
1387
+ ci_str = f"[{ci[0]:+.3f}, {ci[1]:+.3f}]" if (ci[0] is not None and ci[1] is not None) else "—"
1388
+ p_val = s.get("p_value")
1389
+ p_str = f"{p_val:.2e}" if isinstance(p_val, (int, float)) else "—"
1390
+ verdict = (
1391
+ '<span class="badge badge--good">Significant (BH FDR Passed)</span>'
1392
+ if s["reject"]
1393
+ else '<span class="badge badge--mute">Not Significant</span>'
1394
+ )
1395
+ row_h = (
1396
+ f'<tr><td class="mono font-semibold">{esc(sig_name)}</td>'
1397
+ f'<td class="num font-bold">{(s.get("effect") or 0):+.4f}</td>'
1398
+ f'<td class="num text-muted mono">{ci_str}</td><td class="num text-muted mono">{p_str}</td>'
1399
+ f'<td>{verdict}</td></tr>'
1400
+ )
1401
+ if s["reject"]:
1402
+ stat_rows_sig.append(row_h)
1403
+ else:
1404
+ stat_rows_null.append(row_h)
1405
+
1406
+ stats_sig_html = "\n".join(stat_rows_sig) if stat_rows_sig else "<tr><td colspan='5'>No statistically significant signals found.</td></tr>"
1407
+ stats_null_html = "\n".join(stat_rows_null) if stat_rows_null else "<tr><td colspan='5'>No null signals recorded.</td></tr>"
1408
+
1409
+ fig_blocks = []
1410
+ for k, v in figures.items():
1411
+ if k in ("test", "m2_effects"):
1412
+ continue
1413
+ fig_blocks.append(
1414
+ f'<div class="chart-card">'
1415
+ f'<div class="chart-head"><span class="chart-title">{esc(k.replace("_", " ").title())}</span></div>'
1416
+ f'<img src="{v}" alt="{esc(k)}" loading="lazy">'
1417
+ f'</div>'
1418
+ )
1419
+ all_figs_html = "\n".join(fig_blocks) if fig_blocks else "<p class='text-muted' style='padding:16px;'>No exploratory charts generated for this run.</p>"
1420
+ # A small, deterministic visual sample on the landing page. Do not infer
1421
+ # which plot is "best" from its filename; keep the first two unique
1422
+ # generated artifacts in their stable order and leave the full set in M2.
1423
+ hero_fig_blocks = []
1424
+ seen_figure_data: set[str] = set()
1425
+ for k, v in figures.items():
1426
+ if k in ("test", "m2_effects") or v in seen_figure_data:
1427
+ continue
1428
+ seen_figure_data.add(v)
1429
+ hero_fig_blocks.append(
1430
+ f'<div class="chart-card"><div class="chart-head"><span class="chart-title">{esc(k.replace("_", " ").title())}</span></div>'
1431
+ f'<img src="{v}" alt="{esc(k)}" loading="lazy"></div>'
1432
+ )
1433
+ if len(hero_fig_blocks) == 2:
1434
+ break
1435
+ hero_figs_html = "\n".join(hero_fig_blocks)
1436
+
1437
+ sel_rows = []
1438
+ max_eff = max([abs(s.get("effect") or 0) for s in m5_f["selection"]] + [0.01])
1439
+ for s in m5_f["selection"]:
1440
+ eff = s.get("effect") or 0.0
1441
+ w = min(100, abs(eff) / max_eff * 50)
1442
+ side = "pos" if eff >= 0 else "neg"
1443
+ verdict = s.get("verdict", "tested")
1444
+ tone = "good" if verdict == "fixed" else ("warn" if verdict == "partial" else "bad")
1445
+ sel_rows.append(
1446
+ f'<tr><td class="mono font-semibold">{esc(s.get("tier", "L1"))}</td>'
1447
+ f'<td class="mono font-bold">{esc(s.get("name", "candidate"))}</td>'
1448
+ f'<td><span class="badge badge--{tone}">{esc(verdict)}</span></td>'
1449
+ f'<td class="num text-good">+{s.get("n_fixed", 0)}</td>'
1450
+ f'<td class="num text-bad">-{s.get("n_broken", 0)}</td>'
1451
+ f'<td class="num eff-cell eff-cell--{side}">{eff * 100:+.2f}%</td>'
1452
+ f'<td class="diverge-cell"><div class="diverge-axis"></div>'
1453
+ f'<div class="diverge-bar diverge-bar--{side}" style="width:{w:.1f}%"></div></td></tr>'
1454
+ )
1455
+ sel_table_html = "\n".join(sel_rows) if sel_rows else "<tr><td colspan='7'>No repair candidate sweep recorded.</td></tr>"
1456
+
1457
+ meta_chips = "".join([f"<span class='tag-pill'>{esc(m)}</span>" for m in [
1458
+ run["benchmark_name"],
1459
+ f"{n_total:,} Cases",
1460
+ f"{run['cycles']} Cycles",
1461
+ f"{len(m1['analyzers'])} Probes",
1462
+ f"{run['duration_sec']:.0f}s Duration" if run['duration_sec'] else "Completed",
1463
+ f"EvalRX v{run['version']}",
1464
+ ]])
1465
+
1466
+ pre_m1_desc = f"Active probe search generated {data['pre_m1']['n_cases']} synthetic test cases to probe failure mechanisms." if data["pre_m1"]["ran"] else "Testing ran directly on fixed benchmark cases (automated Pre-M1 adversarial probe synthesis was not configured)."
1467
+ pre_m1_sub = f"{data['pre_m1']['n_cases']} Probes Synthesized" if data["pre_m1"]["ran"] else "Standard Benchmark"
1468
+ m1_duration_str = f"{m1['duration']:.1f}s" if m1["duration"] else "Completed"
1469
+ m2_duration_str = f"{m2['duration']:.1f}s" if m2["duration"] else "Completed"
1470
+ m2_conclusion_box = f"<div class='callout callout--accent'><b>Screening Summary:</b> {esc(m2['conclusion'])}</div>" if m2["conclusion"] else ""
1471
+
1472
+ m3_items = []
1473
+ for idx, h in enumerate(m3["hypotheses"], 1):
1474
+ fm = esc(h.get("failure_mode", "mechanism"))
1475
+ st_plain = esc(h.get("plain_statement") or h.get("statement") or h.get("hypothesis"))
1476
+ st_raw = esc(h.get("statement"))
1477
+ td = esc(h.get("test_design"))
1478
+ tech_line = f'<div class="card-detail"><span class="label">Technical Claim:</span> <code>{st_raw}</code></div>' if st_raw and st_raw != st_plain else ""
1479
+ test_line = f'<div class="card-detail"><span class="label">Test Design:</span> {td}</div>' if td else ""
1480
+ m3_items.append(f"""
1481
+ <div class="hypothesis-card">
1482
+ <div class="hyp-head">
1483
+ <span class="badge badge--brand">{fm}</span>
1484
+ <span class="mono text-muted" style="font-size:12px;">Hypothesis #{idx}</span>
1485
+ </div>
1486
+ <div class="hyp-statement">{st_plain}</div>
1487
+ {tech_line}
1488
+ {test_line}
1489
+ </div>""")
1490
+ m3_hypotheses_html = "\n".join(m3_items) if m3_items else "<p class='text-muted'>No M3 hypotheses proposed.</p>"
1491
+ m4_tone_cls = "callout--good" if m4_tone == "good" else ("callout--warn" if m4_tone == "warn" else "callout--neutral")
1492
+ if m4_tone == "good":
1493
+ m4_plain_text = f"<b>Verdict: {m4_status}</b>. Re-evaluated probe signals on an independent held-out split. Observed direction matched the predicted failure mechanism with statistical significance."
1494
+ elif m4_tone == "warn":
1495
+ m4_plain_text = f"<b>Verdict: {m4_status}</b>. When re-tested on independent validation cases, the empirical data contradicted the AI Doctor's hypothesis."
1496
+ else:
1497
+ m4_plain_text = "Independent M4 blind validation was not executed for this run."
1498
+
1499
+ m4_detail_html = ""
1500
+ if m4["results"] or m4["event"]:
1501
+ res0 = m4["results"][0] if m4["results"] else {}
1502
+ eff_size = res0.get("effect_size", 0)
1503
+ eff_str = f"{eff_size:+.3f}" if isinstance(eff_size, (int, float)) else "—"
1504
+ conf_val = res0.get("confidence", 0)
1505
+ conf_str = f"{conf_val:.2f}" if isinstance(conf_val, (int, float)) else "—"
1506
+ ev_grade = esc(res0.get("evidence_grade") or res0.get("evidence", {}).get("evidence_grade") or "—")
1507
+ verdict_raw = esc(res0.get("verdict") or m4["event"].get("verdict") or json.dumps(m4["event"], indent=2, ensure_ascii=False))
1508
+ status_cls = "text-good font-bold" if m4_tone == "good" else "text-bad font-bold"
1509
+
1510
+ m4_detail_html = f"""
1511
+ <div class="kpi-grid">
1512
+ <div class="kpi-card"><div class="kpi-label">Verdict</div><div class="kpi-val {status_cls}">{esc(m4_status)}</div></div>
1513
+ <div class="kpi-card"><div class="kpi-label">Validation Effect</div><div class="kpi-val">{eff_str}</div></div>
1514
+ <div class="kpi-card"><div class="kpi-label">Confidence Score</div><div class="kpi-val">{conf_str}</div></div>
1515
+ <div class="kpi-card"><div class="kpi-label">Evidence Grade</div><div class="kpi-val">{ev_grade}</div></div>
1516
+ </div>
1517
+ <pre class="code-block"><b>Adjudication Audit Log:</b>\n{verdict_raw}</pre>"""
1518
+
1519
+ m5_s_desc = f"Executed {len(m5_s['surgeries'])} causal model interventions / ablations." if m5_s["ran"] else "Focused on black-box prompt and scaffold optimizations (white-box surgery was not invoked)."
1520
+ m5_s_sub = "Executed" if m5_s["ran"] else "Skipped"
1521
+
1522
+ m5_f_tone_cls = "callout--good" if m5_tone == "good" else "callout--neutral"
1523
+ if repair_effect is not None and repair_effect > 0:
1524
+ cand_name = esc(cfm.get("name", "selected_fix"))
1525
+ e_str = f"e = {e_val:,.0f}" if e_val else "Significant"
1526
+ m5_f_plain = f"Successfully confirmed repair strategy <b><code>{cand_name}</code></b>! Produced a <b>{repair_effect * 100:+.2f}% net accuracy gain</b> on held-out test data (<b>{n_fixed}</b> cured vs <b>{n_broken}</b> broken, evidence strength <b>{e_str}</b>)."
1527
+ else:
1528
+ m5_f_plain = "Screened repair candidates across tiers, but no strategy met the statistical threshold for a significant net gain."
1529
+
1530
+ tmpl_code = esc(m5_f["prompt_template"] or cfm.get("summary") or "")
1531
+ m5_f_tmpl_html = ""
1532
+ if tmpl_code:
1533
+ m5_f_tmpl_html = f"""
1534
+ <div style="margin-top:16px;">
1535
+ <div class="section-subhead">Winning Prompt / Repair Patch</div>
1536
+ <pre class="code-block">{tmpl_code}</pre>
1537
+ </div>"""
1538
+
1539
+ cases_json = _json_for_script(cases)
1540
+ audio_json = _json_for_script(audio_map)
1541
+ images_json = _json_for_script(image_map)
1542
+
1543
+ html_lines = [
1544
+ '<!DOCTYPE html>',
1545
+ '<html lang="en">',
1546
+ '<head>',
1547
+ '<meta charset="UTF-8">',
1548
+ '<meta name="viewport" content="width=device-width, initial-scale=1.0">',
1549
+ f'<title>EvalRX · {esc(run["model"])} Diagnostic Report</title>',
1550
+ '<style>',
1551
+ ':root {',
1552
+ ' --bg: #0b0f19;',
1553
+ ' --surface: #111827;',
1554
+ ' --surface-raised: #1f2937;',
1555
+ ' --surface-hover: #374151;',
1556
+ ' --border: #374151;',
1557
+ ' --border-subtle: #242d3d;',
1558
+ ' --text: #f9fafb;',
1559
+ ' --text-muted: #9ca3af;',
1560
+ ' --text-sub: #6b7280;',
1561
+ ' --brand: #6366f1;',
1562
+ ' --brand-soft: rgba(99, 102, 241, 0.12);',
1563
+ ' --brand-border: rgba(99, 102, 241, 0.35);',
1564
+ ' --good: #10b981;',
1565
+ ' --good-soft: rgba(16, 185, 129, 0.12);',
1566
+ ' --good-border: rgba(16, 185, 129, 0.35);',
1567
+ ' --warn: #f59e0b;',
1568
+ ' --warn-soft: rgba(245, 158, 11, 0.12);',
1569
+ ' --warn-border: rgba(245, 158, 11, 0.35);',
1570
+ ' --bad: #ef4444;',
1571
+ ' --bad-soft: rgba(239, 68, 68, 0.12);',
1572
+ ' --bad-border: rgba(239, 68, 68, 0.35);',
1573
+ ' --radius: 10px;',
1574
+ ' --radius-sm: 6px;',
1575
+ ' --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;',
1576
+ ' --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;',
1577
+ '}',
1578
+ '* { box-sizing: border-box; margin: 0; padding: 0; }',
1579
+ 'body { background: var(--bg); color: var(--text); font-family: var(--font-sans); font-size: 14.5px; line-height: 1.6; -webkit-font-smoothing: antialiased; }',
1580
+ '.container { max-width: 1240px; margin: 0 auto; padding: 0 24px; }',
1581
+ 'header.app-header { border-bottom: 1px solid var(--border-subtle); background: rgba(17, 24, 39, 0.95); backdrop-filter: blur(12px); position: sticky; top: 0; z-index: 100; }',
1582
+ '.header-inner { padding: 16px 0; display: flex; justify-content: space-between; align-items: center; gap: 16px; }',
1583
+ '.brand-group { display: flex; align-items: center; gap: 14px; }',
1584
+ '.brand-icon { width: 32px; height: 32px; border-radius: 8px; background: linear-gradient(135deg, var(--brand), #8b5cf6); display: flex; align-items: center; justify-content: center; color: white; font-size: 16px; font-weight: 700; }',
1585
+ '.brand-titles { display: flex; flex-direction: column; }',
1586
+ '.brand-model { font-size: 17px; font-weight: 700; color: #ffffff; letter-spacing: -0.02em; }',
1587
+ '.brand-benchmark { font-size: 12.5px; color: var(--text-muted); }',
1588
+ '.header-badges { display: flex; gap: 8px; flex-wrap: wrap; }',
1589
+ 'nav.tabs-nav { background: var(--surface); border-bottom: 1px solid var(--border); position: sticky; top: 65px; z-index: 90; }',
1590
+ '.tabs-scroll { display: flex; gap: 6px; overflow-x: auto; padding: 10px 0; scrollbar-width: none; }',
1591
+ '.tabs-scroll::-webkit-scrollbar { display: none; }',
1592
+ '.tab-btn { display: flex; align-items: center; gap: 8px; padding: 8px 16px; border-radius: 8px; border: 1px solid transparent; background: transparent; color: var(--text-muted); font-family: var(--font-sans); font-size: 13.5px; font-weight: 500; cursor: pointer; white-space: nowrap; transition: all 0.15s ease; }',
1593
+ '.tab-btn:hover { background: var(--surface-raised); color: var(--text); }',
1594
+ '.tab-btn.is-active { background: var(--surface-raised); border-color: var(--border); color: #ffffff; font-weight: 600; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); }',
1595
+ '.tab-code { font-family: var(--font-mono); font-size: 11px; font-weight: 700; color: var(--brand); }',
1596
+ '.tab-badge { font-family: var(--font-mono); font-size: 10.5px; padding: 2px 6px; border-radius: 99px; }',
1597
+ '.tab-pane { display: none; padding: 32px 0 80px; animation: fadeIn 0.15s ease-in-out; }',
1598
+ '.tab-pane.is-active { display: block; }',
1599
+ '@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }',
1600
+ '.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 24px; margin-bottom: 24px; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); }',
1601
+ '.card-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 12px; padding-bottom: 16px; margin-bottom: 20px; border-bottom: 1px solid var(--border-subtle); }',
1602
+ '.card-tag { font-family: var(--font-mono); font-size: 11px; font-weight: 700; color: var(--brand); background: var(--brand-soft); border: 1px solid var(--brand-border); padding: 3px 8px; border-radius: 4px; }',
1603
+ '.card-title { font-size: 20px; font-weight: 700; letter-spacing: -0.02em; color: #ffffff; }',
1604
+ '.card-subtext { flex: 1 1 100%; font-size: 13.5px; color: var(--text-muted); }',
1605
+ '.story-box { background: linear-gradient(180deg, rgba(31, 41, 55, 0.6) 0%, rgba(17, 24, 39, 0.9) 100%); border: 1px solid var(--border); border-radius: var(--radius); padding: 22px 26px; margin-bottom: 24px; display: flex; flex-direction: column; gap: 10px; }',
1606
+ '.story-box p { font-size: 15px; color: var(--text); line-height: 1.6; }',
1607
+ '.story-box b { color: #ffffff; }',
1608
+ '.setting-card { display:grid; grid-template-columns: minmax(0, 1.6fr) minmax(220px, .8fr); gap:22px; border:1px solid var(--brand-border); background:linear-gradient(135deg, var(--brand-soft), transparent 62%), var(--surface); border-radius:var(--radius); padding:26px; margin-bottom:22px; }',
1609
+ '.setting-kicker { color:var(--brand); font-family:var(--font-mono); font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; }',
1610
+ '.setting-title { color:#fff; font-size:clamp(24px, 3vw, 34px); line-height:1.12; letter-spacing:-.03em; margin:7px 0 10px; }',
1611
+ '.setting-copy { color:var(--text-muted); max-width:760px; font-size:15px; }',
1612
+ '.setting-facts { display:grid; gap:9px; align-content:center; }',
1613
+ '.setting-fact { border-left:2px solid var(--brand); padding:4px 0 4px 11px; }',
1614
+ '.setting-fact-label { display:block; color:var(--text-sub); font-size:10px; letter-spacing:.07em; text-transform:uppercase; }',
1615
+ '.setting-fact-value { display:block; color:var(--text); font-weight:600; font-size:13px; overflow-wrap:anywhere; }',
1616
+ '.reader-headline { font-size: clamp(28px, 4vw, 42px); line-height: 1.12; margin: 0 0 12px; letter-spacing: -0.035em; }',
1617
+ '.reader-question { color: var(--text-muted); font-size: 16px; line-height: 1.6; margin: 0; }',
1618
+ '.reader-section { margin: 30px 0; }',
1619
+ '.reader-section h2 { margin: 0 0 12px; font-size: 22px; letter-spacing: -0.02em; }',
1620
+ '.reader-list { margin: 0; padding-left: 20px; color: var(--text-muted); line-height: 1.75; }',
1621
+ '.reader-findings { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; }',
1622
+ '.reader-finding { border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-raised); padding: 20px; position: relative; overflow: hidden; }',
1623
+ '.reader-finding::before { content: ""; position: absolute; inset: 0 auto 0 0; width: 4px; background: var(--brand); }',
1624
+ '.reader-finding-head { display: flex; justify-content: space-between; gap: 12px; align-items: flex-start; }',
1625
+ '.reader-finding h3 { font-size: 17px; line-height: 1.35; margin: 0; }',
1626
+ '.reader-finding p { color: var(--text-muted); line-height: 1.55; font-size: 14px; }',
1627
+ '.reader-finding .reader-why { color: var(--text); }',
1628
+ '.journey-title { display:flex; align-items:baseline; justify-content:space-between; gap:12px; margin: 28px 0 10px; }',
1629
+ '.journey-title h2 { font-size:20px; letter-spacing:-.02em; }',
1630
+ '.run-map { display: grid; grid-template-columns: repeat(auto-fit, minmax(145px, 1fr)); gap: 8px; margin: 12px 0 10px; }',
1631
+ '.run-node { position: relative; min-height: 112px; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 15px; background: var(--surface); }',
1632
+ '.run-node:not(:last-child)::after { content: "→"; position: absolute; right: -13px; top: 42px; z-index: 2; color: var(--text-sub); font-size: 18px; }',
1633
+ '.run-node--good { border-color: var(--good-border); }',
1634
+ '.run-node--neutral { border-color: var(--brand-border); }',
1635
+ '.run-node--skip { opacity: .62; }',
1636
+ '.run-node-label { display: block; color: var(--brand); font-size: 11px; font-weight:700; text-transform: uppercase; letter-spacing: .08em; }',
1637
+ '.run-node-title { display:block; margin-top:5px; color:#fff; font-size:14px; font-weight:700; line-height:1.2; }',
1638
+ '.run-node-value { display: block; margin-top: 8px; color: var(--text-muted); font-size:12px; line-height: 1.25; }',
1639
+ '.journey-loop { color:var(--text-sub); font-size:12px; text-align:right; margin-bottom:26px; }',
1640
+ '.hero-charts { margin: 28px 0; }',
1641
+ '.hero-charts .chart-card { min-height: 240px; }',
1642
+ '@media (max-width: 780px) { .setting-card { grid-template-columns:1fr; } .run-map { grid-template-columns: repeat(2, minmax(0, 1fr)); } .run-node:not(:last-child)::after { display: none; } }',
1643
+ '.reader-finding .reader-limit { font-size: 12.5px; color: var(--text-sub); margin-bottom: 0; }',
1644
+ '.kpi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 14px; margin-bottom: 24px; }',
1645
+ '.kpi-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px 20px; display: flex; flex-direction: column; gap: 5px; }',
1646
+ '.kpi-label { font-size: 11.5px; font-family: var(--font-mono); text-transform: uppercase; color: var(--text-sub); letter-spacing: 0.05em; }',
1647
+ '.kpi-val { font-size: 26px; font-weight: 700; font-family: var(--font-mono); letter-spacing: -0.02em; }',
1648
+ '.kpi-sub { font-size: 12px; color: var(--text-muted); }',
1649
+ '.kpi-card--good { border-color: var(--good-border); } .kpi-card--good .kpi-val { color: var(--good); }',
1650
+ '.kpi-card--warn { border-color: var(--warn-border); } .kpi-card--warn .kpi-val { color: var(--warn); }',
1651
+ '.kpi-card--bad { border-color: var(--bad-border); } .kpi-card--bad .kpi-val { color: var(--bad); }',
1652
+ '.callout { padding: 16px 20px; border-radius: var(--radius-sm); background: var(--surface-raised); border-left: 4px solid var(--border); font-size: 14px; color: var(--text); margin-bottom: 20px; }',
1653
+ '.callout--accent { border-left-color: var(--brand); background: var(--brand-soft); }',
1654
+ '.callout--good { border-left-color: var(--good); background: var(--good-soft); }',
1655
+ '.callout--warn { border-left-color: var(--warn); background: var(--warn-soft); }',
1656
+ '.callout b { color: #ffffff; }',
1657
+ '.table-wrapper { overflow-x: auto; border: 1px solid var(--border); border-radius: var(--radius-sm); margin-bottom: 20px; }',
1658
+ 'table.data-table { width: 100%; border-collapse: collapse; font-size: 13px; text-align: left; }',
1659
+ 'table.data-table th { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-sub); background: var(--surface-raised); padding: 11px 14px; border-bottom: 1px solid var(--border); }',
1660
+ 'table.data-table td { padding: 11px 14px; border-bottom: 1px solid var(--border-subtle); vertical-align: middle; }',
1661
+ 'table.data-table tr:hover td { background: rgba(255, 255, 255, 0.02); }',
1662
+ '.num { text-align: right; font-variant-numeric: tabular-nums; font-family: var(--font-mono); }',
1663
+ '.mono { font-family: var(--font-mono); font-size: 0.92em; }',
1664
+ '.font-semibold { font-weight: 600; }',
1665
+ '.font-bold { font-weight: 700; }',
1666
+ '.text-muted { color: var(--text-muted); }',
1667
+ '.text-good { color: var(--good); }',
1668
+ '.text-warn { color: var(--warn); }',
1669
+ '.text-bad { color: var(--bad); }',
1670
+ '.badge { display: inline-flex; align-items: center; font-family: var(--font-mono); font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 99px; border: 1px solid transparent; }',
1671
+ '.badge--brand { color: #818cf8; background: var(--brand-soft); border-color: var(--brand-border); }',
1672
+ '.badge--good { color: var(--good); background: var(--good-soft); border-color: var(--good-border); }',
1673
+ '.badge--warn { color: var(--warn); background: var(--warn-soft); border-color: var(--warn-border); }',
1674
+ '.badge--bad { color: var(--bad); background: var(--bad-soft); border-color: var(--bad-border); }',
1675
+ '.badge--mute { color: var(--text-muted); background: var(--surface-raised); border-color: var(--border); }',
1676
+ '.tag-pill { font-family: var(--font-mono); font-size: 12px; color: var(--text-muted); background: var(--surface-raised); border: 1px solid var(--border); padding: 4px 12px; border-radius: 99px; }',
1677
+ '.probe-card { border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); margin-bottom: 16px; overflow: hidden; }',
1678
+ '.probe-header { padding: 16px 20px; display: flex; align-items: center; cursor: pointer; transition: background 0.15s ease; user-select: none; }',
1679
+ '.probe-header:hover { background: var(--surface-raised); }',
1680
+ '.probe-title { font-weight: 700; font-size: 15px; color: #ffffff; display: flex; align-items: center; }',
1681
+ '.probe-code { color: var(--brand); font-size: 11.5px; margin-left: 8px; }',
1682
+ '.probe-q { font-size: 13px; color: var(--text-muted); margin-top: 3px; }',
1683
+ '.probe-body { padding: 20px 24px; background: rgba(0, 0, 0, 0.2); border-top: 1px solid var(--border-subtle); }',
1684
+ '.probe-desc-box { background: var(--surface-raised); padding: 14px 18px; border-radius: var(--radius-sm); border-left: 3px solid var(--brand); font-size: 13.5px; }',
1685
+ '.expand-icon { font-size: 12px; color: var(--text-sub); transition: transform 0.2s ease; margin-left: 12px; }',
1686
+ '.expand-icon.is-open { transform: rotate(180deg); }',
1687
+ '.mini-btn { padding: 4px 10px; font-size: 11px; font-family: var(--font-mono); border-radius: 4px; border: 1px solid var(--border); background: var(--surface-raised); color: var(--text); cursor: pointer; }',
1688
+ '.mini-btn:hover { background: var(--brand); color: white; border-color: var(--brand); }',
1689
+ '.bar-track { height: 6px; border-radius: 3px; background: var(--surface-raised); overflow: hidden; margin-bottom: 3px; }',
1690
+ '.bar-fill { display: block; height: 100%; background: var(--brand); border-radius: 3px; }',
1691
+ '.barpct { font-family: var(--font-mono); font-size: 11px; color: var(--text-sub); }',
1692
+ '.hl-wrap { display: flex; flex-direction: column; gap: 4px; }',
1693
+ '.hl-pill { display: inline-flex; align-items: baseline; gap: 6px; font-size: 12px; }',
1694
+ '.hl-v { font-family: var(--font-mono); font-weight: 700; color: var(--text); min-width: 44px; text-align: right; }',
1695
+ '.hl-l { color: var(--text-muted); font-size: 11.5px; }',
1696
+ '.hl-pill--none .hl-v { color: var(--text-sub); }',
1697
+ '.diverge-cell { position: relative; width: 120px; height: 20px; }',
1698
+ '.diverge-axis { position: absolute; left: 50%; top: 0; bottom: 0; width: 1px; background: var(--border); }',
1699
+ '.diverge-bar { position: absolute; top: 6px; height: 8px; border-radius: 2px; }',
1700
+ '.diverge-bar--pos { left: 50%; background: var(--good); }',
1701
+ '.diverge-bar--neg { right: 50%; background: var(--bad); }',
1702
+ '.eff-cell--pos { color: var(--good); font-weight: 700; }',
1703
+ '.eff-cell--neg { color: var(--bad); font-weight: 700; }',
1704
+ '.hypothesis-card { border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-raised); padding: 18px 22px; display: flex; flex-direction: column; gap: 12px; margin-bottom: 14px; }',
1705
+ '.hyp-head { display: flex; justify-content: space-between; align-items: center; }',
1706
+ '.hyp-statement { font-size: 16px; font-weight: 600; color: #ffffff; line-height: 1.45; }',
1707
+ '.card-detail { font-size: 13px; color: var(--text-muted); }',
1708
+ '.card-detail .label { font-weight: 600; color: var(--text); margin-right: 6px; }',
1709
+ '.charts-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); gap: 18px; }',
1710
+ '.chart-card { border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-raised); overflow: hidden; cursor: pointer; }',
1711
+ '.chart-head { padding: 11px 16px; background: rgba(0, 0, 0, 0.25); border-bottom: 1px solid var(--border-subtle); }',
1712
+ '.chart-title { font-size: 13.5px; font-weight: 600; color: var(--text); }',
1713
+ '.chart-card img { display: block; width: 100%; height: auto; transition: transform 0.2s ease; }',
1714
+ '.chart-card:hover img { transform: scale(1.02); }',
1715
+ 'details.collapsible-box { border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); margin-bottom: 16px; }',
1716
+ 'details.collapsible-box summary { padding: 13px 18px; cursor: pointer; font-weight: 600; font-size: 14px; color: var(--text-muted); user-select: none; }',
1717
+ 'details.collapsible-box summary:hover { color: var(--text); }',
1718
+ 'details.collapsible-box .content { padding: 0 18px 18px; }',
1719
+ 'pre.code-block { background: #06090e; border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); padding: 16px; font-family: var(--font-mono); font-size: 12.5px; line-height: 1.55; color: #e2e8f0; overflow-x: auto; white-space: pre-wrap; word-break: break-word; }',
1720
+ '.section-subhead { font-size: 12px; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-sub); margin-bottom: 8px; }',
1721
+ '.case-controls { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; margin-bottom: 20px; }',
1722
+ '.filter-btn-group { display: flex; gap: 6px; background: var(--surface-raised); padding: 4px; border-radius: 99px; border: 1px solid var(--border); }',
1723
+ '.filter-btn { font-family: var(--font-mono); font-size: 12.5px; padding: 6px 14px; border-radius: 99px; border: none; background: transparent; color: var(--text-muted); cursor: pointer; transition: all 0.15s ease; }',
1724
+ '.filter-btn:hover { color: var(--text); }',
1725
+ '.filter-btn.is-active { background: var(--brand); color: white; font-weight: 600; }',
1726
+ '.search-box { margin-left: auto; padding: 8px 16px; border-radius: 99px; border: 1px solid var(--border); background: var(--surface-raised); color: var(--text); font-size: 13.5px; outline: none; width: 280px; }',
1727
+ '.search-box:focus { border-color: var(--brand); }',
1728
+ '.case-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); gap: 18px; }',
1729
+ '.case-item { border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-raised); display: flex; flex-direction: column; overflow: hidden; }',
1730
+ '.case-item-header { padding: 11px 16px; background: rgba(0, 0, 0, 0.3); border-bottom: 1px solid var(--border-subtle); display: flex; align-items: center; gap: 8px; }',
1731
+ '.case-dur { margin-left: auto; font-family: var(--font-mono); font-size: 11px; color: var(--text-sub); }',
1732
+ '.case-q { padding: 16px; font-size: 14.5px; font-weight: 600; color: var(--text); line-height: 1.45; }',
1733
+ '.case-media-box { padding: 0 16px 12px; }',
1734
+ '.case-media-box audio { width: 100%; height: 34px; }',
1735
+ '.case-media-box img { width: 100%; max-height: 220px; object-fit: contain; border-radius: 4px; border: 1px solid var(--border); }',
1736
+ '.case-choices { list-style: none; padding: 0 16px 14px; display: flex; flex-direction: column; gap: 6px; }',
1737
+ '.case-choices li { font-size: 13px; padding: 7px 12px; border-radius: 6px; background: var(--surface); border: 1px solid var(--border-subtle); display: flex; gap: 8px; align-items: baseline; }',
1738
+ '.case-choices li.is-gold { background: var(--good-soft); border-color: var(--good-border); color: #ffffff; font-weight: 600; }',
1739
+ '.case-item-foot { margin-top: auto; padding: 11px 16px; background: rgba(0, 0, 0, 0.3); border-top: 1px solid var(--border-subtle); display: flex; align-items: center; gap: 8px; font-size: 12.5px; }',
1740
+ '.case-id-tag { margin-left: auto; font-family: var(--font-mono); font-size: 11px; color: var(--text-sub); }',
1741
+ '.case-probe-tags { padding: 0 16px 10px; display: flex; flex-wrap: wrap; gap: 4px; }',
1742
+ '.case-probe-tag { font-family: var(--font-mono); font-size: 10px; padding: 2px 6px; border-radius: 4px; background: var(--brand-soft); color: var(--brand); border: 1px solid var(--brand-border); }',
1743
+ '.modal-backdrop { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.85); backdrop-filter: blur(8px); z-index: 1000; display: none; align-items: center; justify-content: center; padding: 24px; }',
1744
+ '.modal-backdrop.is-open { display: flex; animation: fadeIn 0.15s ease; }',
1745
+ '.modal-content { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); max-width: 820px; width: 100%; max-height: 90vh; overflow-y: auto; padding: 28px; position: relative; box-shadow: 0 12px 40px rgba(0, 0, 0, 0.6); }',
1746
+ '.modal-close { position: absolute; top: 16px; right: 16px; background: transparent; border: none; font-size: 22px; color: var(--text-muted); cursor: pointer; line-height: 1; padding: 4px 8px; }',
1747
+ '.modal-close:hover { color: #ffffff; }',
1748
+ 'footer.app-footer { border-top: 1px solid var(--border-subtle); padding: 32px 0 48px; color: var(--text-sub); font-size: 13px; }',
1749
+ '</style>',
1750
+ '</head>',
1751
+ '<body>',
1752
+ '<header class="app-header">',
1753
+ ' <div class="container header-inner">',
1754
+ ' <div class="brand-group">',
1755
+ ' <div class="brand-icon">⚡</div>',
1756
+ ' <div class="brand-titles">',
1757
+ f' <span class="brand-model">{esc(run["model"])}</span>',
1758
+ f' <span class="brand-benchmark">{esc(run["benchmark_name"])}</span>',
1759
+ ' </div>',
1760
+ ' </div>',
1761
+ f' <div class="header-badges">{meta_chips}</div>',
1762
+ ' </div>',
1763
+ '</header>',
1764
+ '<nav class="tabs-nav">',
1765
+ ' <div class="container">',
1766
+ f' <div class="tabs-scroll">{tabs_html}</div>',
1767
+ ' </div>',
1768
+ '</nav>',
1769
+ '<div class="container">',
1770
+ ' <div class="tab-pane is-active" id="tab_overview">',
1771
+ f' {setting_html}',
1772
+ ' <div class="story-box">',
1773
+ f' <h1 class="reader-headline">{esc(reader.get("headline") or "What this report says")}</h1>',
1774
+ f' <p class="reader-question"><b>Short answer:</b> {esc(reader.get("answer") or "")}</p>',
1775
+ f' <p class="reader-question">{esc(reader_method)}</p>',
1776
+ ' </div>',
1777
+ f' <div class="kpi-grid">{tiles_html}</div>',
1778
+ ' <div class="journey-title"><h2>Failure-to-fix journey</h2><span class="text-muted">Each card is this run’s actual state</span></div>',
1779
+ f' <div class="run-map">{run_map_html}</div>',
1780
+ ' <div class="journey-loop">If verification refutes the mechanism or repair misses the baseline, the next cycle returns to M1.</div>',
1781
+ ' <section class="reader-section">',
1782
+ ' <h2>Key findings</h2>',
1783
+ f' <div class="reader-findings">{reader_findings_html}</div>',
1784
+ ' </section>',
1785
+ f' <div class="callout callout--accent"><b>Limit:</b> {esc(reader_caveat or "See the evidence details for the scope of this result.")} {("<b>Next:</b> " + esc(reader_next_step)) if reader_next_step else ""}</div>',
1786
+ (f' <section class="hero-charts"><h2 class="card-title" style="margin-bottom:12px;">Selected charts</h2><div class="charts-grid">{hero_figs_html}</div></section>' if hero_figs_html else ''),
1787
+ ' <details class="collapsible-box"><summary>Numbers and study details</summary><div class="content">Full methods, statistical tables, and every chart are in the stage tabs.</div></details>',
1788
+ ' </div>',
1789
+ ' <div class="tab-pane" id="tab_pre_m1">',
1790
+ ' <div class="card">',
1791
+ ' <div class="card-head">',
1792
+ ' <span class="card-tag">PRE-M1</span>',
1793
+ ' <h2 class="card-title">Case Synthesis & Adversarial Probes</h2>',
1794
+ f' <span class="text-muted mono" style="margin-left:auto; font-size:12px;">{pre_m1_sub}</span>',
1795
+ f' <p class="card-subtext">{STAGE_METADATA["pre_m1"]["plain_desc"]}</p>',
1796
+ ' </div>',
1797
+ f' <div class="callout"><b>Stage Status:</b> {pre_m1_desc}</div>',
1798
+ ' </div>',
1799
+ ' </div>',
1800
+ ' <div class="tab-pane" id="tab_m1">',
1801
+ ' <div class="card">',
1802
+ ' <div class="card-head">',
1803
+ ' <span class="card-tag">M1</span>',
1804
+ ' <h2 class="card-title">What we checked</h2>',
1805
+ f' <span class="text-muted mono" style="margin-left:auto; font-size:12px;">{m1_duration_str}</span>',
1806
+ ' <p class="card-subtext">We looked for observable behaviors that might be connected to mistakes. Open a check only if you want its method and raw records.</p>',
1807
+ ' </div>',
1808
+ f' <div class="callout callout--accent"><b>Summary:</b> We ran <b>{len(m1["analyzers"])} checks</b> across {n_total:,} cases. These checks describe behavior; they do not by themselves prove a cause.</div>',
1809
+ f' <div style="display:flex; flex-direction:column; gap:14px;">{m1_cards_html}</div>',
1810
+ ' </div>',
1811
+ ' </div>',
1812
+ ' <div class="tab-pane" id="tab_m2">',
1813
+ ' <div class="card">',
1814
+ ' <div class="card-head">',
1815
+ ' <span class="card-tag">M2</span>',
1816
+ ' <h2 class="card-title">What patterns we found</h2>',
1817
+ f' <span class="text-muted mono" style="margin-left:auto; font-size:12px;">{m2_duration_str}</span>',
1818
+ ' <p class="card-subtext">These are patterns that appeared more often around mistakes. They are leads for further tests, not final explanations.</p>',
1819
+ ' </div>',
1820
+ f' <div class="callout callout--good"><b>What this means:</b> Out of {len(m2["stats"])} measured patterns, <b>{len(stat_rows_sig)}</b> were strong enough to keep investigating. This does not prove they caused the errors.</div>',
1821
+ f' {m2_conclusion_box}',
1822
+ ' <details class="collapsible-box">',
1823
+ ' <summary>Research details: statistical results and effect sizes</summary>',
1824
+ ' <div class="content"><div class="table-wrapper">',
1825
+ ' <table class="data-table">',
1826
+ ' <thead>',
1827
+ ' <tr>',
1828
+ ' <th>Anomalous Feature Signal</th>',
1829
+ ' <th class="num">Effect Size</th>',
1830
+ ' <th class="num">95% Conf. Interval</th>',
1831
+ ' <th class="num">p-value</th>',
1832
+ ' <th>FDR Screening Verdict</th>',
1833
+ ' </tr>',
1834
+ ' </thead>',
1835
+ f' <tbody>{stats_sig_html}</tbody>',
1836
+ ' </table>',
1837
+ ' </div></div>',
1838
+ ' </details>',
1839
+ ' <details class="collapsible-box">',
1840
+ f' <summary>View {len(stat_rows_null)} Non-Significant Feature Signals</summary>',
1841
+ ' <div class="content">',
1842
+ ' <div class="table-wrapper">',
1843
+ ' <table class="data-table">',
1844
+ ' <thead>',
1845
+ ' <tr>',
1846
+ ' <th>Feature Signal</th>',
1847
+ ' <th class="num">Effect Size</th>',
1848
+ ' <th class="num">95% Conf. Interval</th>',
1849
+ ' <th class="num">p-value</th>',
1850
+ ' <th>Verdict</th>',
1851
+ ' </tr>',
1852
+ ' </thead>',
1853
+ f' <tbody>{stats_null_html}</tbody>',
1854
+ ' </table>',
1855
+ ' </div>',
1856
+ ' </div>',
1857
+ ' </details>',
1858
+ ' <details class="collapsible-box" open>',
1859
+ f' <summary>Exploratory Data Analysis Charts ({len(figures)} plots)</summary>',
1860
+ f' <div class="content"><div class="charts-grid">{all_figs_html}</div></div>',
1861
+ ' </details>',
1862
+ ' </div>',
1863
+ ' </div>',
1864
+ ' <div class="tab-pane" id="tab_m3">',
1865
+ ' <div class="card">',
1866
+ ' <div class="card-head">',
1867
+ ' <span class="card-tag">M3</span>',
1868
+ ' <h2 class="card-title">Possible explanation to test</h2>',
1869
+ f' <span class="text-muted mono" style="margin-left:auto; font-size:12px;">{len(m3["hypotheses"])} Hypotheses</span>',
1870
+ ' <p class="card-subtext">A possible explanation must predict what should happen on new cases before we can treat it as evidence.</p>',
1871
+ ' </div>',
1872
+ ' <div class="callout"><b>Important:</b> A possible explanation is not a confirmed root cause. It must be checked on cases that were not used to find it.</div>',
1873
+ f' {m3_hypotheses_html}',
1874
+ ' </div>',
1875
+ ' </div>',
1876
+ ' <div class="tab-pane" id="tab_m4">',
1877
+ ' <div class="card">',
1878
+ ' <div class="card-head">',
1879
+ ' <span class="card-tag">M4</span>',
1880
+ ' <h2 class="card-title">Independent check</h2>',
1881
+ f' <span class="text-muted mono" style="margin-left:auto; font-size:12px;">{m4_status}</span>',
1882
+ ' <p class="card-subtext">This is where we ask whether a proposed explanation still holds on new cases.</p>',
1883
+ ' </div>',
1884
+ f' <div class="callout {m4_tone_cls}">{m4_plain_text}</div>',
1885
+ f' {m4_detail_html}',
1886
+ ' </div>',
1887
+ ' </div>',
1888
+ ' <div class="tab-pane" id="tab_m5_surgery">',
1889
+ ' <div class="card">',
1890
+ ' <div class="card-head">',
1891
+ ' <span class="card-tag">M5-SURGERY</span>',
1892
+ ' <h2 class="card-title">Causal Surgery & Interventions</h2>',
1893
+ f' <span class="text-muted mono" style="margin-left:auto; font-size:12px;">{m5_s_sub}</span>',
1894
+ f' <p class="card-subtext">{STAGE_METADATA["m5_surgery"]["plain_desc"]}</p>',
1895
+ ' </div>',
1896
+ f' <div class="callout"><b>Stage Status:</b> {m5_s_desc}</div>',
1897
+ ' </div>',
1898
+ ' </div>',
1899
+ ' <div class="tab-pane" id="tab_m5_fix">',
1900
+ ' <div class="card">',
1901
+ ' <div class="card-head">',
1902
+ ' <span class="card-tag">M5-FIX</span>',
1903
+ ' <h2 class="card-title">Repair attempt</h2>',
1904
+ f' <span class="text-muted mono" style="margin-left:auto; font-size:12px;">{m5_status}</span>',
1905
+ ' <p class="card-subtext">We test a focused change and count both improvements and newly introduced mistakes.</p>',
1906
+ ' </div>',
1907
+ f' <div class="callout {m5_f_tone_cls}">{m5_f_plain}</div>',
1908
+ ' <details class="collapsible-box" open>',
1909
+ f' <summary>Repair Candidate Sweep ({len(m5_f["selection"])} candidates evaluated)</summary>',
1910
+ ' <div class="content">',
1911
+ ' <div class="table-wrapper">',
1912
+ ' <table class="data-table">',
1913
+ ' <thead>',
1914
+ ' <tr>',
1915
+ ' <th>Tier</th>',
1916
+ ' <th>Candidate Strategy</th>',
1917
+ ' <th>Screening Verdict</th>',
1918
+ ' <th class="num">Cured (+)</th>',
1919
+ ' <th class="num">Broken (-)</th>',
1920
+ ' <th class="num">Net Accuracy Shift</th>',
1921
+ ' <th>Balance Direction</th>',
1922
+ ' </tr>',
1923
+ ' </thead>',
1924
+ f' <tbody>{sel_table_html}</tbody>',
1925
+ ' </table>',
1926
+ ' </div>',
1927
+ ' </div>',
1928
+ ' </details>',
1929
+ f' {m5_f_tmpl_html}',
1930
+ ' </div>',
1931
+ ' </div>',
1932
+ ' <div class="tab-pane" id="tab_agents">',
1933
+ ' <div class="card">',
1934
+ ' <div class="card-head">',
1935
+ ' <span class="card-tag">AGENTS</span>',
1936
+ ' <h2 class="card-title">Agent Trajectories — Layer-by-Layer Audit</h2>',
1937
+ f' <span class="text-muted mono" style="margin-left:auto; font-size:12px;">{agents_n} Agent Invocations</span>',
1938
+ f' <p class="card-subtext">{STAGE_METADATA["agents"]["plain_desc"]}</p>',
1939
+ ' </div>',
1940
+ ' <div class="callout neutral">Every pipeline stage above is driven by agent calls (LLM judges and CLI coder agents). This tab is the bottom layer of the drill-down: pipeline stage → probe / hypothesis → the raw agent input and output that produced it.</div>',
1941
+ ' <div class="section-subhead" style="margin-top:18px;">Judge Calls (verbatim prompt → response)</div>',
1942
+ f' {judge_boxes_html}',
1943
+ ' <div class="section-subhead" style="margin-top:18px;">Explore Coder Agent</div>',
1944
+ f' {explore_boxes_html}',
1945
+ ' <div class="section-subhead" style="margin-top:18px;">Tool Codegen Attempts</div>',
1946
+ f' {codegen_boxes_html}',
1947
+ ' <div class="section-subhead" style="margin-top:18px;">Unified Trace (Langfuse Bundle)</div>',
1948
+ f' {langfuse_html}',
1949
+ ' </div>',
1950
+ ' </div>',
1951
+ ' <div class="tab-pane" id="tab_case_book">',
1952
+ ' <div class="card">',
1953
+ ' <div class="card-head">',
1954
+ ' <span class="card-tag">CASE-STUDIO</span>',
1955
+ ' <h2 class="card-title">Interactive Case Studio</h2>',
1956
+ f' <span class="text-muted mono" style="margin-left:auto; font-size:12px;">{len(cases)} Cases Available</span>',
1957
+ f' <p class="card-subtext">{STAGE_METADATA["case_book"]["plain_desc"]}</p>',
1958
+ ' </div>',
1959
+ ' <div class="case-controls">',
1960
+ ' <div class="filter-btn-group">',
1961
+ f' <button type="button" class="filter-btn is-active" data-f="all">All ({len(cases)})</button>',
1962
+ f' <button type="button" class="filter-btn" data-f="fixed">Cured (+{sum(1 for c in cases if c["status"] == "fixed")})</button>',
1963
+ f' <button type="button" class="filter-btn" data-f="broken">Broken (-{sum(1 for c in cases if c["status"] == "broken")})</button>',
1964
+ f' <button type="button" class="filter-btn" data-f="unchanged">Unchanged ({sum(1 for c in cases if c["status"] == "unchanged")})</button>',
1965
+ ' </div>',
1966
+ ' <input type="text" class="search-box" id="csearch" placeholder="Search prompt, question, or ID...">',
1967
+ ' </div>',
1968
+ ' <div class="case-grid" id="cases_grid"></div>',
1969
+ ' </div>',
1970
+ ' </div>',
1971
+ '</div>',
1972
+ '<!-- MODAL LIGHTBOX & CASE AUDIT DRAWER -->',
1973
+ '<div class="modal-backdrop" id="app_modal" onclick="closeModal(event)">',
1974
+ ' <div class="modal-content" onclick="event.stopPropagation()">',
1975
+ ' <button type="button" class="modal-close" onclick="closeModal()">&times;</button>',
1976
+ ' <div id="modal_body"></div>',
1977
+ ' </div>',
1978
+ '</div>',
1979
+ '<footer class="app-footer">',
1980
+ ' <div class="container" style="display:flex; flex-direction:column; gap:6px;">',
1981
+ ' <div>Generated by <b>EvalRX Diagnostic Engine</b> · Native Langfuse OpenTelemetry Schema · Single-page zero external dependencies</div>',
1982
+ f' <div>Model: <code>{esc(run["model"])}</code> · Fingerprint: <code>{esc(run["data_fingerprint"])}</code> · Path: <code>{esc(run["logs_dir"])}</code></div>',
1983
+ ' </div>',
1984
+ '</footer>',
1985
+ '<script>',
1986
+ f'const CASES = {cases_json};',
1987
+ f'const AUDIO = {audio_json};',
1988
+ f'const IMAGES = {images_json};',
1989
+ r"""
1990
+ function switchTab(tabId) {
1991
+ document.querySelectorAll('.tab-btn').forEach(b => {
1992
+ b.classList.toggle('is-active', b.dataset.tab === tabId);
1993
+ });
1994
+ document.querySelectorAll('.tab-pane').forEach(p => {
1995
+ p.classList.toggle('is-active', p.id === tabId);
1996
+ });
1997
+ if (history.replaceState) {
1998
+ history.replaceState(null, null, '#' + tabId.replace('tab_', ''));
1999
+ }
2000
+ }
2001
+
2002
+ function toggleProbe(detailId) {
2003
+ const el = document.getElementById(detailId);
2004
+ const icon = document.getElementById('icon_' + detailId);
2005
+ if (!el) return;
2006
+ const isHidden = el.style.display === 'none';
2007
+ el.style.display = isHidden ? 'block' : 'none';
2008
+ if (icon) icon.classList.toggle('is-open', isHidden);
2009
+ }
2010
+
2011
+ function openLightbox(imgSrc, title) {
2012
+ const modal = document.getElementById('app_modal');
2013
+ const body = document.getElementById('modal_body');
2014
+ body.innerHTML = '<div style="font-size:18px; font-weight:700; margin-bottom:12px;">' + esc(title) + '</div>' +
2015
+ '<img src="' + imgSrc + '" style="width:100%; border-radius:6px; border:1px solid var(--border);">';
2016
+ modal.classList.add('is-open');
2017
+ }
2018
+
2019
+ function openCaseModal(caseId) {
2020
+ const c = CASES.find(item => item.id === caseId);
2021
+ if (!c) return;
2022
+ const modal = document.getElementById('app_modal');
2023
+ const body = document.getElementById('modal_body');
2024
+ const audioSrc = AUDIO[c.id];
2025
+ const imgSrc = IMAGES[c.id];
2026
+ const probeFlagsHtml = (c.probe_flags && c.probe_flags.length > 0) ?
2027
+ '<div style="margin-bottom:14px;"><div class="section-subhead">Anomalies Detected on This Case</div><div style="display:flex; flex-wrap:wrap; gap:6px;">' +
2028
+ c.probe_flags.map(f => '<span class="badge badge--warn" style="font-size:11px;">⚠️ ' + esc(f) + '</span>').join('') +
2029
+ '</div></div>' : '';
2030
+
2031
+ body.innerHTML = '<div style="font-size:18px; font-weight:700; margin-bottom:4px;">Case Drilldown: <span class="mono" style="color:var(--brand);">' + esc(c.id) + '</span></div>' +
2032
+ '<div style="font-size:13px; color:var(--text-muted); margin-bottom:16px;">Task Domain: ' + esc(c.task || 'General Audio Understanding') + '</div>' +
2033
+ probeFlagsHtml +
2034
+ '<div class="callout"><b>Prompt / Instruction:</b> ' + esc(c.instruction) + '</div>' +
2035
+ (audioSrc ? '<div style="margin-bottom:16px;"><div class="section-subhead">Audible Stimulus (AAC 16kHz)</div><audio controls src="' + audioSrc + '" style="width:100%;"></audio></div>' : '') +
2036
+ (imgSrc ? '<div style="margin-bottom:16px;"><img src="' + imgSrc + '" style="max-height:300px; width:100%; object-fit:contain;"></div>' : '') +
2037
+ '<div class="section-subhead">Options & Ground Truth</div>' +
2038
+ '<ul class="case-choices" style="padding:0; margin-bottom:16px;">' + formatChoices(c) + '</ul>' +
2039
+ '<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px;">' +
2040
+ '<div class="kpi-card"><div class="kpi-label">Ground Truth Expected</div><div class="kpi-val text-good">' + esc(c.expected || '—') + '</div></div>' +
2041
+ '<div class="kpi-card"><div class="kpi-label">Repaired Model Output</div><div class="kpi-val">' + esc(c.output || '—') + '</div></div>' +
2042
+ '</div>';
2043
+ modal.classList.add('is-open');
2044
+ }
2045
+
2046
+ function closeModal(e) {
2047
+ const modal = document.getElementById('app_modal');
2048
+ modal.classList.remove('is-open');
2049
+ }
2050
+
2051
+ document.querySelectorAll('.tab-btn').forEach(b => {
2052
+ b.addEventListener('click', () => switchTab(b.dataset.tab));
2053
+ });
2054
+
2055
+ if (window.location.hash) {
2056
+ const targetId = 'tab_' + window.location.hash.replace('#', '');
2057
+ if (document.getElementById(targetId)) {
2058
+ switchTab(targetId);
2059
+ }
2060
+ }
2061
+
2062
+ const grid = document.getElementById('cases_grid');
2063
+ const searchInput = document.getElementById('csearch');
2064
+ let currentFilter = 'all';
2065
+ let searchKeyword = '';
2066
+
2067
+ const esc = s => String(s || '').replace(/[&<>"']/g, c => {
2068
+ if (c === '&') return '&amp;';
2069
+ if (c === '<') return '&lt;';
2070
+ if (c === '>') return '&gt;';
2071
+ if (c === '"') return '&quot;';
2072
+ return '&#39;';
2073
+ });
2074
+
2075
+ function formatChoices(c) {
2076
+ if (!Array.isArray(c.choices) || c.choices.length === 0) return '';
2077
+ return c.choices.map(raw => {
2078
+ let letter = '', text = String(raw);
2079
+ const m = text.match(/^\s*\(?([A-Za-z])\)?[\.:)]?\s*(.*)$/s);
2080
+ if (m) {
2081
+ letter = m[1].toUpperCase();
2082
+ text = m[2];
2083
+ }
2084
+ const isGold = letter && (letter === String(c.expected).trim().toUpperCase());
2085
+ const goldBadge = isGold ? '<span class="badge badge--good" style="margin-left:auto; font-size:10px;">Ground Truth</span>' : '';
2086
+ const goldClass = isGold ? 'is-gold' : '';
2087
+ return '<li class="' + goldClass + '"><span class="mono font-bold">' + esc(letter || '·') + '</span> <span>' + esc(text) + '</span> ' + goldBadge + '</li>';
2088
+ }).join('');
2089
+ }
2090
+
2091
+ function renderCard(c) {
2092
+ let badgeClass = 'badge--mute', badgeText = 'Unchanged';
2093
+ if (c.status === 'fixed') { badgeClass = 'badge--good'; badgeText = 'Cured (Fixed)'; }
2094
+ else if (c.status === 'broken') { badgeClass = 'badge--bad'; badgeText = 'Regression (Broken)'; }
2095
+
2096
+ const audioSrc = AUDIO[c.id];
2097
+ const imgSrc = IMAGES[c.id];
2098
+ const audioHtml = audioSrc ? '<audio controls preload="none" src="' + audioSrc + '"></audio>' : '';
2099
+ const imgHtml = imgSrc ? '<img src="' + imgSrc + '" alt="Stimulus" loading="lazy">' : '';
2100
+ const taskHtml = c.task ? '<span class="mono text-muted" style="font-size:11px;">' + esc(c.task) + '</span>' : '';
2101
+ const durHtml = c.duration ? '<span class="case-dur">' + c.duration.toFixed(1) + 's</span>' : '';
2102
+ const probeTagsHtml = (c.probe_flags && c.probe_flags.length > 0) ?
2103
+ '<div class="case-probe-tags">' + c.probe_flags.map(f => '<span class="case-probe-tag">' + esc(f) + '</span>').join('') + '</div>' : '';
2104
+
2105
+ return '<article class="case-item">' +
2106
+ '<div class="case-item-header"><span class="badge ' + badgeClass + '">' + badgeText + '</span>' + taskHtml + durHtml + '</div>' +
2107
+ '<div class="case-q">' + esc(c.instruction) + '</div>' +
2108
+ probeTagsHtml +
2109
+ '<div class="case-media-box">' + audioHtml + imgHtml + '</div>' +
2110
+ '<ul class="case-choices">' + formatChoices(c) + '</ul>' +
2111
+ '<div class="case-item-foot"><span class="text-muted font-semibold" style="font-size:11px; text-transform:uppercase;">Repaired Output:</span>' +
2112
+ '<span class="mono font-bold" style="color:#ffffff;">' + esc(c.output || '—') + '</span>' +
2113
+ '<button type="button" class="mini-btn" style="margin-left:auto;" onclick="openCaseModal(' + JSON.stringify(c.id) + ')">Deep Audit</button></div>' +
2114
+ '</article>';
2115
+ }
2116
+
2117
+ function updateGrid() {
2118
+ let rows = CASES;
2119
+ if (currentFilter !== 'all') {
2120
+ rows = rows.filter(c => c.status === currentFilter);
2121
+ }
2122
+ if (searchKeyword) {
2123
+ const kw = searchKeyword.toLowerCase();
2124
+ rows = rows.filter(c =>
2125
+ c.id.toLowerCase().includes(kw) ||
2126
+ (c.instruction && c.instruction.toLowerCase().includes(kw)) ||
2127
+ (c.task && c.task.toLowerCase().includes(kw))
2128
+ );
2129
+ }
2130
+ grid.innerHTML = rows.slice(0, 100).map(renderCard).join('') || '<p class="text-muted" style="padding:24px;">No matching cases found.</p>';
2131
+ }
2132
+
2133
+ document.querySelectorAll('.filter-btn').forEach(b => {
2134
+ b.addEventListener('click', () => {
2135
+ document.querySelectorAll('.filter-btn').forEach(o => o.classList.toggle('is-active', o === b));
2136
+ currentFilter = b.dataset.f;
2137
+ updateGrid();
2138
+ });
2139
+ });
2140
+
2141
+ if (searchInput) {
2142
+ searchInput.addEventListener('input', e => {
2143
+ searchKeyword = e.target.value.trim();
2144
+ updateGrid();
2145
+ });
2146
+ }
2147
+
2148
+ updateGrid();
2149
+ """,
2150
+ '</script>',
2151
+ '</body>',
2152
+ '</html>',
2153
+ ]
2154
+ return '\n'.join(html_lines)
2155
+
2156
+
2157
+ # ---------------------------------------------------------------------------
2158
+ # CLI Entry Point
2159
+ # ---------------------------------------------------------------------------
2160
+
2161
+ def build_html_report(
2162
+ run_dir: str | Path,
2163
+ example_dir: str | Path | None = None,
2164
+ out_path: str | Path | None = None,
2165
+ no_audio: bool = False,
2166
+ ) -> Path:
2167
+ """Collect data from *run_dir* and write a self-contained HTML report."""
2168
+ run_dir = Path(run_dir).resolve()
2169
+ example_dir = Path(example_dir).resolve() if example_dir else run_dir.parent
2170
+ out_path = Path(out_path).resolve() if out_path else run_dir / "report.html"
2171
+
2172
+ print(f"[*] Reading run artifacts from: {run_dir}")
2173
+ data = extract_run_data(run_dir, example_dir)
2174
+ logs_dir = Path(data["run"]["logs_dir"])
2175
+ explore_dir = data["explore_dir"]
2176
+
2177
+ figures = embed_figures(explore_dir, logs_dir)
2178
+ print(f"[*] Embedded {len(figures)} figures/charts")
2179
+
2180
+ cache_dir = out_path.parent / ".media_cache"
2181
+ audio_map, image_map = embed_media(data["cases"], example_dir, cache_dir, no_audio=no_audio)
2182
+ print(f"[*] Embedded {len(audio_map)} audio clips and {len(image_map)} images ({len(data['cases'])} joined cases)")
2183
+
2184
+ html_content = generate_html_report(data, figures, audio_map, image_map)
2185
+
2186
+ out_path.parent.mkdir(parents=True, exist_ok=True)
2187
+ # A report may be tens or hundreds of MB when it embeds case media. Write
2188
+ # beside the destination and replace atomically so a browser never sees a
2189
+ # partially generated diagnosis after an interruption or disk-full error.
2190
+ fd, tmp_name = tempfile.mkstemp(
2191
+ prefix=f".{out_path.stem}.", suffix=".tmp", dir=out_path.parent,
2192
+ )
2193
+ tmp_path = Path(tmp_name)
2194
+ try:
2195
+ with open(fd, "w", encoding="utf-8", closefd=True) as handle:
2196
+ handle.write(html_content)
2197
+ tmp_path.replace(out_path)
2198
+ finally:
2199
+ if tmp_path.exists():
2200
+ tmp_path.unlink()
2201
+ size_mb = out_path.stat().st_size / (1024 * 1024)
2202
+ from evalrx.term_links import hyperlink
2203
+
2204
+ print(f"[✓] Wrote self-contained HTML report to: {hyperlink(str(out_path))} ({size_mb:.2f} MB)")
2205
+ return out_path
2206
+
2207
+
2208
+ def main() -> None:
2209
+ ap = argparse.ArgumentParser(description="Render a finished EvalRX diagnostic run as an interactive HTML page.")
2210
+ ap.add_argument("run_dir", nargs="?", default="outputs", help="Run directory holding run.json or logs/")
2211
+ ap.add_argument("--example-dir", default=None, help="Root holding data/ (default: parent of run_dir)")
2212
+ ap.add_argument("--out", "-o", default=None, help="Output HTML file path (default: <run_dir>/report.html)")
2213
+ ap.add_argument("--no-audio", action="store_true", help="Skip audio clip compression and embedding")
2214
+ args = ap.parse_args()
2215
+
2216
+ build_html_report(
2217
+ run_dir=args.run_dir,
2218
+ example_dir=args.example_dir,
2219
+ out_path=args.out,
2220
+ no_audio=args.no_audio,
2221
+ )
2222
+
2223
+
2224
+ if __name__ == "__main__":
2225
+ main()