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,1017 @@
1
+ """M3 — DiagnosisAgent: synthesize analysis findings into falsifiable hypotheses.
2
+
3
+ The agent reads the structured :class:`~evalrx.eval_agent.analysis.AnalysisReport`
4
+ produced by M2, formats it as a prompt, sends it to an LLM judge (Gemini by
5
+ default), and parses the response into :class:`~evalrx.eval_agent.hypothesis.Hypothesis`
6
+ objects.
7
+
8
+ The judge model defaults to **Gemini** when ``GEMINI_API_KEY`` is set in the
9
+ environment and no explicit judge is passed.
10
+
11
+ Usage::
12
+
13
+ # Gemini default (set GEMINI_API_KEY in env)
14
+ agent = DiagnosisAgent()
15
+ diag = agent.diagnose(analysis_report)
16
+
17
+ # Explicit judge (any Model with Capability.GENERATE)
18
+ agent = DiagnosisAgent(judge=my_model)
19
+ diag = agent.diagnose(analysis_report)
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import re
26
+ import time
27
+ from dataclasses import dataclass, field
28
+ from typing import TYPE_CHECKING, Any
29
+
30
+ from evalrx.agent_runtime.json_shape import validate_json_shape
31
+ from evalrx.analysis.plain_language import jargon_violation
32
+ from evalrx.eval_agent.hypothesis import Hypothesis
33
+ from evalrx.eval_agent.prompts.diagnosis import (
34
+ _DIAGNOSE_PROMPT,
35
+ _FORMAT_REPAIR_PROMPT,
36
+ _PLAIN_REPAIR_PROMPT,
37
+ _VALIDATE_PROMPT,
38
+ )
39
+
40
+ if TYPE_CHECKING:
41
+ from evalrx.analysis.analysis_module import AnalysisReport
42
+ from evalrx.core.model import Model
43
+ from evalrx.core.result import Result
44
+
45
+ def _format_prior_section(prior_cycles: list[dict]) -> str:
46
+ """Render prior cycle history as a context block for the diagnosis prompt."""
47
+ if not prior_cycles:
48
+ return ""
49
+ lines = [
50
+ "\nPrior investigation cycles — do NOT repeat these hypotheses; propose "
51
+ "distinct or refined ones that address what remains unexplained:",
52
+ ]
53
+ for c in prior_cycles:
54
+ lines.append(f"\nCycle {c['cycle']} (severity={c['severity']}):")
55
+ for h in c.get("hypotheses", []):
56
+ lines.append(
57
+ f" [{h['status'].upper()}] {h['statement']} "
58
+ f"(failure_mode: {h['failure_mode']})"
59
+ )
60
+ return "\n".join(lines) + "\n"
61
+
62
+
63
+ @dataclass
64
+ class ExploreContext:
65
+ """Descriptive mechanism notes from the LAMBDA explorer (Step 1).
66
+
67
+ This is **never authoritative**: it is read-only, enters ONLY the M3
68
+ hypothesis-proposal prompt (and the dashboard), and never the M2 confirmatory
69
+ family, M4 testing, or the fix gate. It informs *which* hypotheses M3
70
+ proposes — not *whether* any of them is true. Every chart/observation here is
71
+ free-form EDA on a HELD-OUT explore split and stays UNCONFIRMED until the
72
+ downstream M4+M5+e-BH machinery tests it.
73
+
74
+ Attributes:
75
+ observations: Free-text EDA observations.
76
+ charts: Chart dicts ``{title, kind, description, figure_path}`` —
77
+ ``figure_path`` (when present) is attached to M3 as an image.
78
+ caveats: The explorer's own warnings about its findings.
79
+ source: Provenance tag (default ``"lambda_explorer"``).
80
+ """
81
+
82
+ observations: list[str] = field(default_factory=list)
83
+ charts: list[dict[str, Any]] = field(default_factory=list)
84
+ caveats: list[str] = field(default_factory=list)
85
+ source: str = "lambda_explorer"
86
+
87
+ @classmethod
88
+ def from_report(cls, data: dict[str, Any] | None) -> "ExploreContext | None":
89
+ """Build from an explore/fused report dict (``fused_report.json`` etc.).
90
+
91
+ Returns ``None`` when *data* is empty or carries no descriptive content,
92
+ so callers can pass it through unconditionally.
93
+ """
94
+ if not data or not isinstance(data, dict):
95
+ return None
96
+ observations = [str(x) for x in (data.get("observations") or [])]
97
+ charts = [dict(c) for c in (data.get("charts") or []) if isinstance(c, dict)]
98
+ caveats = [str(x) for x in (data.get("caveats") or [])]
99
+ if not (observations or charts or caveats):
100
+ return None
101
+ return cls(
102
+ observations=observations,
103
+ charts=charts,
104
+ caveats=caveats,
105
+ source=str(data.get("source") or "lambda_explorer"),
106
+ )
107
+
108
+ @property
109
+ def figure_paths(self) -> list[str]:
110
+ return [
111
+ str(c["figure_path"])
112
+ for c in self.charts
113
+ if isinstance(c, dict) and c.get("figure_path")
114
+ ]
115
+
116
+ @property
117
+ def is_empty(self) -> bool:
118
+ return not (self.observations or self.charts or self.caveats)
119
+
120
+
121
+ def _format_explore_section(ctx: "ExploreContext | None") -> str:
122
+ """Render an :class:`ExploreContext` as a strongly-labelled, UNCONFIRMED
123
+ block for the diagnosis prompt. Returns ``""`` when there is nothing to add."""
124
+ if ctx is None or ctx.is_empty:
125
+ return ""
126
+ lines = [
127
+ "",
128
+ "EXPLORATORY MECHANISM NOTES (free-form EDA on a HELD-OUT explore split — "
129
+ "DESCRIPTIVE, UNCONFIRMED; use ONLY to decide WHICH hypotheses to propose, "
130
+ "NEVER as evidence; every claim must still be tested downstream):",
131
+ ]
132
+ if ctx.observations:
133
+ lines.append(" observations:")
134
+ for obs in ctx.observations[:12]:
135
+ lines.append(f" - {obs}")
136
+ if ctx.charts:
137
+ lines.append(" charts (attached as images when rendered):")
138
+ for c in ctx.charts[:12]:
139
+ title = str(c.get("title") or c.get("name") or "chart")
140
+ desc = str(c.get("description") or c.get("kind") or "")
141
+ tag = "" if c.get("figure_path") else " [text-only, not rendered]"
142
+ lines.append(f" - [{title}]{tag} {desc}")
143
+ if ctx.caveats:
144
+ lines.append(" caveats (the explorer's own warnings):")
145
+ for cav in ctx.caveats[:8]:
146
+ lines.append(f" - {cav}")
147
+ return "\n".join(lines) + "\n"
148
+
149
+
150
+ def _format_failure_modes_section(report: "Any | None") -> str:
151
+ """Render a :class:`~evalrx.analysis.failure_modes.FailureModeReport`
152
+ as a labelled block for the diagnosis prompt. Descriptive only — clustering
153
+ groups FAIL cases by similarity, it does not itself confirm a mechanism.
154
+ Returns ``""`` when there is nothing to add (no report, or zero clusters)."""
155
+ context = getattr(report, "as_hypothesis_context", lambda: "")()
156
+ if not context:
157
+ return ""
158
+ return "\n" + context + "\n"
159
+
160
+
161
+ def _extract_referenced(raw: str, ctx: "ExploreContext | None") -> list[str]:
162
+ """Best-effort, DISPLAY-ONLY list of explore artifacts M3's output referenced.
163
+
164
+ Matches chart titles/names mentioned in the judge output as a whole phrase
165
+ (word boundaries, min length) to avoid crediting a chart just because a short
166
+ generic title like "rate"/"loss" happens to be a substring of unrelated prose.
167
+ Used only for provenance/dashboard display — no effect on hypothesis survival.
168
+ """
169
+ if ctx is None or ctx.is_empty:
170
+ return []
171
+ import re
172
+
173
+ low = str(raw).lower()
174
+ referenced: list[str] = []
175
+ for c in ctx.charts:
176
+ for key in ("title", "name"):
177
+ label = str(c.get(key) or "").strip()
178
+ # Require a reasonably specific label matched on word boundaries.
179
+ if len(label) >= 6 and label not in referenced:
180
+ if re.search(rf"\b{re.escape(label.lower())}\b", low):
181
+ referenced.append(label)
182
+ break
183
+ return referenced
184
+
185
+
186
+ @dataclass
187
+ class DiagnosisResult:
188
+ """Output of :class:`DiagnosisAgent`.
189
+
190
+ Attributes:
191
+ model_name: ``repr()`` of the analysed model.
192
+ hypotheses: Proposed :class:`~evalrx.eval_agent.hypothesis.Hypothesis`
193
+ objects for M5.
194
+ findings_summary: The findings dict forwarded to the judge.
195
+ raw_judge_output: Verbatim LLM response (useful for debugging).
196
+ referenced_charts: Explore chart titles M3 cited (provenance only).
197
+ explore_context_used: Whether an :class:`ExploreContext` was supplied.
198
+ failure_modes_used: Whether a non-empty ``FailureModeReport`` was supplied.
199
+ """
200
+
201
+ model_name: str
202
+ hypotheses: list[Hypothesis] = field(default_factory=list)
203
+ findings_summary: dict[str, Any] = field(default_factory=dict)
204
+ raw_judge_output: str = ""
205
+ prompt: str = ""
206
+ referenced_charts: list[str] = field(default_factory=list)
207
+ explore_context_used: bool = False
208
+ failure_modes_used: bool = False
209
+ #: The adversarial critic's verbatim verdicts (``KEEP:``/``REJECT:`` +
210
+ #: ``REASON:`` lines) and its tally. The critic ANNOTATES hypotheses
211
+ #: (``metadata["critic"]``, ``metadata["critic_reason"]``) and orders the
212
+ #: kept ones first — it never removes a proposal: with M4 testing on the
213
+ #: held-out split, the data is the arbiter, and a critic that rejects every
214
+ #: claim used to end the run with "0 hypotheses" and no M4/M5/fix at all.
215
+ critic_raw_output: str = ""
216
+ critic_prompt: str = ""
217
+ n_critic_kept: int = 0
218
+ n_critic_rejected: int = 0
219
+ # M3 is two separate model calls: a proposer and an adversarial reviewer.
220
+ # The proposer's full list and the reviewer's per-hypothesis decisions are
221
+ # kept as well, so a rejected proposal stays distinguishable from a
222
+ # parser/model failure in the report and in Langfuse. ``review_prompt`` /
223
+ # ``review_raw`` mirror ``critic_prompt`` / ``critic_raw_output``;
224
+ # ``review_decisions`` (statement, decision, reason) is derived from the
225
+ # critic annotations — decision keep | reject | unparsed |
226
+ # review_unavailable — and the reviewer never removes a hypothesis.
227
+ proposed_hypotheses: list[Hypothesis] = field(default_factory=list)
228
+ review_prompt: str = ""
229
+ review_raw: str = ""
230
+ review_decisions: list[dict[str, Any]] = field(default_factory=list)
231
+ model_calls: list[dict[str, Any]] = field(default_factory=list)
232
+
233
+
234
+ _HYPOTHESIS_SCHEMA: dict = {
235
+ "type": "array",
236
+ "items": {
237
+ "type": "object",
238
+ "required": ["hypothesis", "failure_mode"],
239
+ "properties": {
240
+ "hypothesis": {"type": "string", "minLength": 10},
241
+ "plain_statement": {"type": "string"},
242
+ "failure_mode": {"type": "string", "minLength": 2},
243
+ "expected_association": {"type": "string"},
244
+ },
245
+ },
246
+ }
247
+
248
+
249
+ def _parse_hypotheses_json(raw: str, model_name: str) -> list[Hypothesis] | None:
250
+ """Try to parse *raw* as a JSON array of hypothesis objects.
251
+
252
+ Returns ``None`` when the response is not JSON or fails schema validation
253
+ so the caller can fall back to the text parser.
254
+ """
255
+ text = raw.strip()
256
+ # Strip markdown code fences if present
257
+ if text.startswith("```"):
258
+ import re as _re
259
+ text = _re.sub(r"^```\w*\n?", "", text)
260
+ text = _re.sub(r"\n?```\s*$", "", text)
261
+ try:
262
+ data = json.loads(text)
263
+ except json.JSONDecodeError:
264
+ return None
265
+
266
+ errors = validate_json_shape(data, _HYPOTHESIS_SCHEMA)
267
+ if errors:
268
+ import logging as _logging
269
+ _logging.getLogger(__name__).debug(
270
+ "DiagnosisAgent JSON schema validation failed: %s", errors
271
+ )
272
+ return None
273
+
274
+ return [
275
+ Hypothesis(
276
+ statement=item["hypothesis"],
277
+ target_model=model_name,
278
+ predicted_failure_mode=item["failure_mode"],
279
+ plain_statement=str(item.get("plain_statement") or item.get("plain_language") or item.get("plain") or ""),
280
+ test_design=str(item.get("test", "")),
281
+ expected_association=str(item.get("expected_association", "")),
282
+ )
283
+ for item in data
284
+ if item.get("hypothesis") and item.get("failure_mode")
285
+ ]
286
+
287
+
288
+ # A judge that answers in Markdown decorates the labels — ``**HYPOTHESIS:**``,
289
+ # ``**HYPOTHESIS**:``, ``- HYPOTHESIS:``, ``1. TEST:``, ``### FAILURE_MODE:`` —
290
+ # and the bare ``startswith("HYPOTHESIS:")`` check saw none of them: a live
291
+ # qwen3.5-2b/bbh_word_sorting run had three well-formed hypotheses in the
292
+ # response and M3 reported zero. Normalise the label, leave the text alone.
293
+ #
294
+ # The ordinal AFTER the label is the same bug and was missed the first time.
295
+ # ``HYPOTHESIS 1:`` is how a model numbers a list of three of them — the most
296
+ # natural formatting there is, and the one Opus used on a 560-case audio-visual
297
+ # run: three hypotheses, each with a test design naming a BH-surviving signal,
298
+ # and M3 again reported zero. Two and a half hours of GPU and a high-effort
299
+ # judge, discarded over a digit. Accept the ordinal in either position.
300
+ _LABEL_LINE = re.compile(
301
+ r"^\s*(?:(?:[-*•>]|#+|\d+[.)])\s*)*[*_`]*\s*"
302
+ r"(HYPOTHESIS|PLAIN_STATEMENT|PLAIN_LANGUAGE|FAILURE_MODE|TEST|EXPECTED_ASSOCIATION|KEEP|REJECT|REASON)"
303
+ r"(?:\s*[#(]?\d+[.)]?)?" # HYPOTHESIS 1: / TEST #2: / REASON (3):
304
+ r"\s*(?P<close>[*_`]*)\s*:\s*(?P<after>[*_`]*)\s*",
305
+ re.IGNORECASE,
306
+ )
307
+
308
+
309
+ #: A leading emphasis/code marker, past any list bullet or heading. Its presence
310
+ #: is what makes a marker after the colon a CLOSER rather than content.
311
+ _OPENING_MARKER = re.compile(r"^\s*(?:(?:[-*•>]|#+|\d+[.)])\s*)*[*_`]")
312
+
313
+
314
+ #: A value that is ENTIRELY one code span or emphasis pair: `mode`, **mode**.
315
+ _WRAPPED_VALUE = re.compile(r"^([*_`]{1,2})(.+?)\1$")
316
+
317
+
318
+ def _unwrap_value(value: str) -> str:
319
+ """Strip wrapping decoration from a value that is a bare identifier.
320
+
321
+ Applied to FAILURE_MODE and EXPECTED_ASSOCIATION only. Those are looked up
322
+ verbatim -- ``_FAILURE_MODE_TO_ANALYZERS["ignored_obs"]`` decides which
323
+ analyzers the next cycle re-probes with -- so a judge writing
324
+ ``FAILURE_MODE: `ignored_obs` `` must not produce a key with a backtick in
325
+ it. Prose fields (HYPOTHESIS, TEST) keep their code spans: there the
326
+ backticks mark identifiers inside a sentence and removing them was its own
327
+ bug.
328
+
329
+ Only an ENTIRELY wrapped value is unwrapped, so ``some `sig` text`` is left
330
+ exactly as written.
331
+ """
332
+ text = value.strip()
333
+ m = _WRAPPED_VALUE.match(text)
334
+ return m.group(2).strip() if m else text
335
+
336
+
337
+ #: ``predicted_failure_mode`` given to a hypothesis whose ``FAILURE_MODE:`` line the
338
+ #: judge omitted. Downstream stages treat the mode as a tag, so an explicit
339
+ #: placeholder keeps the claim (statement / test design) instead of dropping it.
340
+ MISSING_FAILURE_MODE = "unspecified"
341
+
342
+
343
+ def _normalise_label_line(line: str) -> str:
344
+ """``**HYPOTHESIS:** foo`` / ``- FAILURE_MODE: bar`` → ``HYPOTHESIS: foo`` /
345
+ ``FAILURE_MODE: bar``. Lines without a recognised label are returned
346
+ stripped and otherwise untouched (emphasis inside the statement stays)."""
347
+ line = line.strip()
348
+ m = _LABEL_LINE.match(line)
349
+ if not m:
350
+ return line
351
+
352
+ # Decoration is only decoration when something opened it. `**KEEP:** x` and
353
+ # `` `KEEP:` x `` wrap the LABEL, so the marker after the colon closes that
354
+ # wrapper and goes. `TEST: `modality_ablation.grounded_in_audio` ...` opens a
355
+ # code span belonging to the CONTENT, and eating it corrupts the identifier
356
+ # M4 routes on. The difference is whether the line opened with a marker at
357
+ # all -- so decide on that rather than on the marker's position.
358
+ opened = bool(_OPENING_MARKER.match(line))
359
+ rest = line[m.end():] if opened else (m.group("after") or "") + line[m.end():]
360
+ rest = rest.strip()
361
+ if opened:
362
+ rest = re.sub(r"[*_`]+$", "", rest).strip()
363
+ return f"{m.group(1).upper()}: {rest}"
364
+
365
+
366
+ def _parse_hypotheses(raw: str, model_name: str) -> list[Hypothesis]:
367
+ """Parse hypothesis objects from LLM output.
368
+
369
+ Tries JSON structured format first (more reliable), then falls back to the
370
+ line-oriented ``HYPOTHESIS:`` / ``FAILURE_MODE:`` text format. This makes
371
+ the parser resilient to both output modes without requiring a hard migration.
372
+ Labels may carry Markdown decoration (see :func:`_normalise_label_line`).
373
+ """
374
+ json_result = _parse_hypotheses_json(raw, model_name)
375
+ if json_result is not None:
376
+ return json_result
377
+
378
+ # Text-format fallback. A hypothesis is opened by ``HYPOTHESIS:`` and closed
379
+ # by ``FAILURE_MODE:`` — or, when the judge forgot that line, by the next
380
+ # ``HYPOTHESIS:`` / the end of the text. gemma-4-e2b / gsm8k (2026-08-27):
381
+ # the judge wrote three complete HYPOTHESIS / PLAIN_STATEMENT / TEST blocks
382
+ # without FAILURE_MODE and the old parser (append-on-FAILURE_MODE only)
383
+ # returned zero, so the loop stopped with "no_hypotheses" and the fix stage
384
+ # never ran. The mode is a tag; a missing tag must not erase the claim.
385
+ hypotheses: list[Hypothesis] = []
386
+ pending: Hypothesis | None = None
387
+
388
+ def _flush() -> None:
389
+ nonlocal pending
390
+ if pending is not None and pending.statement:
391
+ if not pending.predicted_failure_mode:
392
+ pending.predicted_failure_mode = MISSING_FAILURE_MODE
393
+ hypotheses.append(pending)
394
+ pending = None
395
+
396
+ for line in raw.splitlines():
397
+ line = _normalise_label_line(line)
398
+ upper = line.upper()
399
+ if upper.startswith("HYPOTHESIS:"):
400
+ _flush()
401
+ pending = Hypothesis(
402
+ statement=line[len("HYPOTHESIS:"):].strip(),
403
+ target_model=model_name,
404
+ predicted_failure_mode="",
405
+ )
406
+ continue
407
+ target = pending if pending is not None else (hypotheses[-1] if hypotheses else None)
408
+ if upper.startswith("PLAIN_STATEMENT:") or upper.startswith("PLAIN_LANGUAGE:"):
409
+ tag = "PLAIN_STATEMENT:" if upper.startswith("PLAIN_STATEMENT:") else "PLAIN_LANGUAGE:"
410
+ if target is not None and not target.plain_statement:
411
+ target.plain_statement = line[len(tag):].strip()
412
+ elif upper.startswith("FAILURE_MODE:") and pending is not None:
413
+ pending.predicted_failure_mode = _unwrap_value(line[len("FAILURE_MODE:"):])
414
+ _flush()
415
+ elif upper.startswith("TEST:") and target is not None:
416
+ # Attach the test design to the hypothesis being read (or the last one).
417
+ target.test_design = line[len("TEST:"):].strip()
418
+ elif upper.startswith("EXPECTED_ASSOCIATION:") and target is not None:
419
+ target.expected_association = _unwrap_value(
420
+ line[len("EXPECTED_ASSOCIATION:"):]
421
+ ).lower()
422
+ _flush()
423
+ return hypotheses
424
+
425
+
426
+ def _format_label_context(cases: "Any | None") -> str:
427
+ """Descriptive label summary for the M3 critic (and nothing else).
428
+
429
+ The critic used to see only the findings JSON — per-case rows with
430
+ ``labelled_fail`` and ``extracted_answer`` but no gold — and rejected a
431
+ correct "answers Yes regardless of the audio" hypothesis for "no
432
+ ground-truth present/absent field in the evidence" (audiocaps 2026-08-20)
433
+ while the proposer had read exactly that breakdown in the explore notes.
434
+ Counts only; on a yes/no (true/false) batch a gold x answer table whose
435
+ cells carry the FAIL count. Empty string when *cases* is ``None`` or
436
+ carries no labels, so callers can concatenate it unconditionally.
437
+ """
438
+ if cases is None:
439
+ return ""
440
+ try:
441
+ from evalrx.analyzers.reasoning._text import (
442
+ binary_direction,
443
+ binary_gold,
444
+ extract_answer,
445
+ )
446
+ from evalrx.core.case import Label
447
+ except Exception: # pragma: no cover - defensive import guard
448
+ return ""
449
+ rows = [c for c in cases if getattr(c, "label", None) in (Label.PASS, Label.FAIL)]
450
+ if not rows:
451
+ return ""
452
+ n_fail = sum(1 for c in rows if c.label == Label.FAIL)
453
+ lines = [f" labelled cases: {len(rows)} ({n_fail} FAIL / {len(rows) - n_fail} PASS)"]
454
+ golds = [(c, binary_gold(getattr(c, "expected", None))) for c in rows]
455
+ binary = [(c, g) for c, g in golds if g is not None]
456
+ if len(binary) >= max(4, int(0.8 * len(rows))):
457
+ table: dict[tuple[str, str], list[int]] = {}
458
+ for c, g in binary:
459
+ text = str(getattr(c, "observed", "") or "")
460
+ a = binary_direction(extract_answer(text)) or binary_direction(text[-200:], last=True)
461
+ key = (g, a or "unparsed")
462
+ cell = table.setdefault(key, [0, 0])
463
+ cell[0] += 1
464
+ cell[1] += int(c.label == Label.FAIL)
465
+ lines.append(" yes/no task -- gold x answer (n, of which FAIL):")
466
+ for g in ("yes", "no"):
467
+ for a in ("yes", "no", "unparsed"):
468
+ n, f = table.get((g, a), [0, 0])
469
+ if n:
470
+ lines.append(f" gold={g:<3} answered={a:<8} n={n:<4} FAIL={f}")
471
+ n_yes = sum(n for (g, a), (n, f) in table.items() if a == "yes")
472
+ lines.append(f" answered yes on {n_yes}/{len(binary)} binary cases")
473
+ return "LABEL SUMMARY (descriptive counts from the case batch, not a test result):\n" + "\n".join(lines) + "\n"
474
+
475
+
476
+ def _validate_hypotheses(
477
+ hypotheses: list[Hypothesis],
478
+ findings_json: str,
479
+ judge: "Model",
480
+ *,
481
+ capture: "dict[str, Any] | None" = None,
482
+ context: str = "",
483
+ ) -> list[Hypothesis]:
484
+ """Adversarial review of *hypotheses*: a critic call at temperature=0.
485
+
486
+ The same judge is called a second time with a prompt designed to find
487
+ reasons to *reject* each hypothesis, breaking the confirmation-bias loop
488
+ where the model that generated a claim then waves it through.
489
+
490
+ The critic ANNOTATES, it does not filter: every hypothesis comes back,
491
+ ``metadata["critic"]`` set to ``"keep"`` / ``"reject"`` (``"unparsed"``
492
+ when the critic's answer named neither), ``metadata["critic_reason"]``
493
+ carrying its ``REASON:`` line, kept hypotheses ordered first. Dropping
494
+ the rejected ones used to end a run at "0 hypotheses" whenever a strict
495
+ critic (opus at high effort, n=32 evidence) rejected all of them —
496
+ three llm_benchmark runs in a row on 2026-08-20 — with no M4, M5 or fix
497
+ afterwards. M4 now tests on the held-out split, so the data decides; the
498
+ critic's verdict travels with the hypothesis as provenance and as the
499
+ tie-breaker for M5's best-lead ordering.
500
+
501
+ *capture*, when given, receives ``raw`` (the critic's verbatim answer),
502
+ ``prompt``, ``n_kept`` and ``n_rejected``. Falls back to the unannotated
503
+ list if the critic call fails so the loop is never blocked by a transient
504
+ error.
505
+
506
+ *context* is the evidence the PROPOSER worked from beyond the findings
507
+ JSON -- M2's conclusion and evidence chain, the statistical verdicts, the
508
+ explore notes and the label summary. Without it the critic judged the
509
+ hypotheses against less than the proposer had seen and rejected them for
510
+ missing evidence that was on the table (audiocaps 2026-08-20).
511
+ """
512
+ if not hypotheses:
513
+ return hypotheses
514
+
515
+ hyp_lines = "\n".join(
516
+ f"- HYPOTHESIS: {h.statement} (failure_mode: {h.predicted_failure_mode})"
517
+ for h in hypotheses
518
+ )
519
+ context_section = (
520
+ "\nContext the proposer worked from (analyst conclusion, evidence chain, "
521
+ "statistical verdicts, exploratory notes, label summary):\n"
522
+ + context.strip() + "\n"
523
+ ) if context and context.strip() else ""
524
+ prompt = _VALIDATE_PROMPT.format(
525
+ context_section=context_section,
526
+ findings_json=findings_json,
527
+ hypotheses_text=hyp_lines,
528
+ )
529
+ if capture is not None:
530
+ capture["prompt"] = prompt
531
+
532
+ started = time.perf_counter()
533
+ try:
534
+ # Use temperature=0 so the critic is deterministic and strict.
535
+ # Models that don't accept a temperature kwarg are called without it.
536
+ import inspect
537
+ sig = inspect.signature(judge.generate)
538
+ if "temperature" in sig.parameters:
539
+ raw = judge.generate(prompt, temperature=0)
540
+ else:
541
+ raw = judge.generate(prompt)
542
+ except Exception as exc:
543
+ if capture is not None:
544
+ # A transport failure must not silently delete ideas — and the
545
+ # record must distinguish it from an evidence-based rejection.
546
+ capture["decisions"] = [{
547
+ "statement": h.statement, "decision": "review_unavailable",
548
+ "reason": "The adversarial review call failed; proposal retained.",
549
+ } for h in hypotheses]
550
+ capture["error"] = str(exc)
551
+ capture["duration_sec"] = time.perf_counter() - started
552
+ return hypotheses # validation failed — keep originals, unannotated
553
+ raw = str(raw)
554
+ if capture is not None:
555
+ capture["raw"] = raw
556
+ capture["error"] = None
557
+ capture["duration_sec"] = time.perf_counter() - started
558
+
559
+ def _match(stmt: str) -> "Hypothesis | None":
560
+ stmt = stmt.strip().lower()
561
+ for h in hypotheses:
562
+ hs = h.statement.lower()
563
+ if hs[:60] in stmt or stmt[:60] in hs:
564
+ return h
565
+ return None
566
+
567
+ verdicts: dict[int, str] = {}
568
+ reasons: dict[int, str] = {}
569
+ last: "Hypothesis | None" = None
570
+ saw_decision = False
571
+ for line in raw.splitlines():
572
+ line = _normalise_label_line(line)
573
+ upper = line.upper()
574
+ if upper.startswith("KEEP:") or upper.startswith("REJECT:"):
575
+ saw_decision = True
576
+ verdict = "keep" if upper.startswith("KEEP:") else "reject"
577
+ h = _match(line.split(":", 1)[1])
578
+ if h is not None:
579
+ verdicts[id(h)] = verdict
580
+ last = h
581
+ else:
582
+ last = None
583
+ elif upper.startswith("REASON:") and last is not None:
584
+ reasons[id(last)] = line[len("REASON:"):].strip()
585
+
586
+ if not saw_decision:
587
+ # Malformed/empty critic output is an infrastructure failure: keep the
588
+ # proposals, say so, and mark them so the record shows no review ran.
589
+ import logging as _logging
590
+ _logging.getLogger(__name__).warning(
591
+ "DiagnosisAgent validation: critic response could not be parsed for "
592
+ "%d hypothesis(es) — keeping them unreviewed",
593
+ len(hypotheses),
594
+ )
595
+ for h in hypotheses:
596
+ h.metadata["critic"] = "unparsed"
597
+ if capture is not None:
598
+ capture.update(n_kept=0, n_rejected=0, decisions=_critic_decisions(hypotheses))
599
+ return hypotheses
600
+
601
+ for h in hypotheses:
602
+ h.metadata["critic"] = verdicts.get(id(h), "unparsed")
603
+ if id(h) in reasons:
604
+ h.metadata["critic_reason"] = reasons[id(h)]
605
+ n_kept = sum(1 for h in hypotheses if h.metadata.get("critic") == "keep")
606
+ n_rejected = sum(1 for h in hypotheses if h.metadata.get("critic") == "reject")
607
+ if capture is not None:
608
+ capture.update(n_kept=n_kept, n_rejected=n_rejected,
609
+ decisions=_critic_decisions(hypotheses))
610
+ if n_rejected and not n_kept:
611
+ import logging as _logging
612
+ _logging.getLogger(__name__).info(
613
+ "DiagnosisAgent validation: critic rejected all %d hypothesis(es) — "
614
+ "kept as flagged leads; M4 testing decides",
615
+ len(hypotheses),
616
+ )
617
+ order = {"keep": 0, "unparsed": 1, "reject": 2}
618
+ return sorted(hypotheses, key=lambda h: order.get(h.metadata.get("critic"), 1))
619
+
620
+
621
+ def _critic_decisions(hypotheses: list[Hypothesis]) -> list[dict[str, Any]]:
622
+ """Per-hypothesis review record derived from the critic's annotations: the
623
+ ``{statement, decision, reason}`` shape the run log, report and Langfuse
624
+ consume. ``decision`` is ``keep`` / ``reject`` / ``unparsed`` (or
625
+ ``review_unavailable`` when the critic call itself failed). The critic
626
+ never removes a hypothesis, so this is a record, not a filter."""
627
+ return [{
628
+ "statement": h.statement,
629
+ "decision": str(h.metadata.get("critic", "unparsed")),
630
+ "reason": str(h.metadata.get("critic_reason", "")),
631
+ } for h in hypotheses]
632
+
633
+
634
+ def _default_judge() -> "Model":
635
+ """Return a judge model without requiring an explicit API key.
636
+
637
+ Resolution order:
638
+ 1. ``agy`` CLI (antigravity) — if the binary is on PATH, no key needed.
639
+ 2. Gemini — if ``GEMINI_API_KEY`` is set.
640
+ 3. Raise with instructions.
641
+ """
642
+ import os
643
+ import shutil
644
+
645
+ if shutil.which("agy"):
646
+ from evalrx.agent_runtime.judges import AgyModel
647
+ return AgyModel()
648
+
649
+ if os.getenv("GEMINI_API_KEY"):
650
+ from evalrx.models.blackbox.gemini import GeminiModel
651
+ return GeminiModel()
652
+
653
+ raise ValueError(
654
+ "DiagnosisAgent requires a judge model. "
655
+ "Options: install antigravity (agy) — no API key needed, "
656
+ "or set GEMINI_API_KEY to use Gemini "
657
+ "(install with: pip install 'evalrx[gemini]'), "
658
+ "or pass judge= explicitly."
659
+ )
660
+
661
+
662
+ class DiagnosisAgent:
663
+ """Proposes hypotheses from an :class:`~evalrx.eval_agent.analysis.AnalysisReport`.
664
+
665
+ Args:
666
+ judge: Any :class:`~evalrx.core.model.Model` with
667
+ ``Capability.GENERATE``. Defaults to a ``GeminiModel``
668
+ when ``GEMINI_API_KEY`` is in the environment.
669
+ api_key: Gemini API key — used only when *judge* is ``None`` and no
670
+ ``GEMINI_API_KEY`` env var is set.
671
+ model_id: Gemini model identifier (default: ``"gemini-2.0-flash"``).
672
+ """
673
+
674
+ def __init__(
675
+ self,
676
+ judge: "Model | None" = None,
677
+ api_key: str | None = None,
678
+ model_id: str = "gemini-2.0-flash",
679
+ ) -> None:
680
+ if judge is None and api_key is not None:
681
+ from evalrx.models.blackbox.gemini import GeminiModel
682
+
683
+ judge = GeminiModel(model_id=model_id, api_key=api_key)
684
+ self._judge = judge # None → resolved lazily on first call
685
+
686
+ @property
687
+ def judge(self) -> "Model":
688
+ if self._judge is None:
689
+ self._judge = _default_judge()
690
+ return self._judge
691
+
692
+ @judge.setter
693
+ def judge(self, value: "Model") -> None:
694
+ self._judge = value
695
+
696
+ def _reask_for_format(self, raw: str, model_name: str) -> tuple[str, list[Hypothesis]]:
697
+ """One re-ask when a non-empty, non-NO_ISSUE answer parsed to zero hypotheses.
698
+
699
+ gemma-4-e2b / gsm8k (2026-08-27): the judge wrote three hypotheses in prose
700
+ blocks the label parser could not read and the run ended with
701
+ ``no_hypotheses`` — no warning, no second chance, no fix stage. The
702
+ label format is the contract, but a single formatting miss should cost
703
+ one more judge call, not the diagnosis. Returns the answer that was
704
+ finally parsed (so ``raw_judge_output`` records what the hypotheses came
705
+ from) and the hypotheses; on any failure the original answer and the
706
+ empty list come back and the existing fallbacks apply.
707
+ """
708
+ text = raw.strip()
709
+ if not text or "NO_ISSUE" in text.upper():
710
+ return raw, []
711
+ import logging
712
+
713
+ log = logging.getLogger(__name__)
714
+ log.warning(
715
+ "DiagnosisAgent: the judge wrote %d chars that parsed to zero hypotheses; "
716
+ "re-asking once for the labelled format: %r", len(text), text[:160],
717
+ )
718
+ repair_prompt = _FORMAT_REPAIR_PROMPT.format(raw=raw)
719
+ started = time.perf_counter()
720
+ try:
721
+ reasked = str(self.judge.generate(repair_prompt))
722
+ except Exception as exc: # noqa: BLE001
723
+ self._model_calls.append({
724
+ "role": "diagnosis_format_repair", "operation": "generate",
725
+ "inputs": repair_prompt, "output": None, "error": str(exc),
726
+ "duration_sec": time.perf_counter() - started,
727
+ })
728
+ log.warning("DiagnosisAgent: format re-ask failed: %s", exc)
729
+ return raw, []
730
+ self._model_calls.append({
731
+ "role": "diagnosis_format_repair", "operation": "generate",
732
+ "inputs": repair_prompt, "output": reasked, "error": None,
733
+ "duration_sec": time.perf_counter() - started,
734
+ })
735
+ hypotheses = _parse_hypotheses(reasked, model_name)
736
+ if hypotheses:
737
+ return reasked, hypotheses
738
+ if "NO_ISSUE" in reasked.upper():
739
+ return reasked, []
740
+ log.warning("DiagnosisAgent: the re-asked answer also parsed to zero hypotheses")
741
+ return raw, []
742
+
743
+ def _repair_plain_language(
744
+ self, raw: str, hypotheses: list[Hypothesis], model_name: str,
745
+ ) -> list[Hypothesis]:
746
+ """One bounded retry when a PLAIN_STATEMENT line is missing or jargon-y.
747
+
748
+ The prompt asking for plain language is not enough on its own — a judge
749
+ will happily reuse the technical line — so the same host-side check the
750
+ explorer and the analysis-side hypothesis agent already run
751
+ (:func:`~evalrx.analysis.plain_language.jargon_violation`) also
752
+ guards this path, which is the one the benchmark runner takes. Never
753
+ raises: a failed repair keeps the original hypotheses, since a jargon-y
754
+ headline is worse than no diagnosis only for the reader, not for M4.
755
+ """
756
+ if not hypotheses:
757
+ return hypotheses
758
+ violations = [
759
+ f"hypothesis {i}'s PLAIN_STATEMENT {reason}: {h.plain_statement!r}"
760
+ for i, h in enumerate(hypotheses, start=1)
761
+ for reason in [jargon_violation(h.plain_statement, h.statement)]
762
+ if reason
763
+ ]
764
+ if not violations:
765
+ return hypotheses
766
+ repair_prompt = _PLAIN_REPAIR_PROMPT.format(
767
+ raw=raw, violations="\n".join(f"- {v}" for v in violations),
768
+ )
769
+ started = time.perf_counter()
770
+ try:
771
+ repaired_raw = self.judge.generate(repair_prompt)
772
+ except Exception as exc: # noqa: BLE001
773
+ self._model_calls.append({
774
+ "role": "diagnosis_plain_language_repair", "operation": "generate",
775
+ "inputs": repair_prompt, "output": None, "error": str(exc),
776
+ "duration_sec": time.perf_counter() - started,
777
+ })
778
+ import logging
779
+
780
+ logging.getLogger(__name__).warning(
781
+ "DiagnosisAgent: plain-language repair failed: %s", exc)
782
+ return hypotheses
783
+ self._model_calls.append({
784
+ "role": "diagnosis_plain_language_repair", "operation": "generate",
785
+ "inputs": repair_prompt, "output": str(repaired_raw), "error": None,
786
+ "duration_sec": time.perf_counter() - started,
787
+ })
788
+ repaired = _parse_hypotheses(str(repaired_raw), model_name)
789
+ return repaired or hypotheses
790
+
791
+ def diagnose(
792
+ self,
793
+ analysis: "AnalysisReport | dict[str, Result]",
794
+ model_name: str = "",
795
+ prior_cycles: list[dict] | None = None,
796
+ explore_context: "ExploreContext | None" = None,
797
+ failure_modes: "Any | None" = None,
798
+ cases: "Any | None" = None,
799
+ ) -> DiagnosisResult:
800
+ """Synthesize *analysis* into a set of falsifiable hypotheses.
801
+
802
+ Args:
803
+ analysis: An :class:`~evalrx.eval_agent.analysis.AnalysisReport`
804
+ from M2, or (for backward compatibility) a plain
805
+ ``{analyzer_name: Result}`` dict.
806
+ model_name: Ignored when *analysis* is an ``AnalysisReport``
807
+ (the name is taken from the report).
808
+ prior_cycles: Summary of previous M1→M5 cycles produced by
809
+ :class:`~evalrx.eval_agent.legacy.AutoDiagnoseLoop`.
810
+ Each entry is ``{"cycle": int, "severity": str,
811
+ "hypotheses": [{"statement", "failure_mode", "status"}]}``.
812
+ Injected into the prompt so the judge avoids re-proposing
813
+ already-tested hypotheses.
814
+ failure_modes: Optional :class:`~evalrx.analysis.failure_modes.FailureModeReport`
815
+ (clustered FAIL cases). Descriptive only, like
816
+ *explore_context* — informs which hypotheses M3
817
+ proposes, never a claim itself. ``None`` (default)
818
+ adds nothing to the prompt and costs no extra call.
819
+ cases: The labelled case batch M1/M2 ran on. Used ONLY to
820
+ give the critic a label summary (PASS/FAIL counts;
821
+ gold x answer table on a yes/no batch) — see
822
+ :func:`_format_label_context`. ``None`` adds nothing.
823
+
824
+ Returns:
825
+ :class:`DiagnosisResult` with zero or more hypotheses.
826
+ """
827
+ from evalrx.analysis.analysis_module import AnalysisModule, AnalysisReport
828
+
829
+ self._model_calls: list[dict[str, Any]] = []
830
+
831
+ if not isinstance(analysis, AnalysisReport):
832
+ # Backward compat: wrap raw results in a minimal AnalysisReport.
833
+ analysis = AnalysisModule().analyze(analysis, model_name)
834
+
835
+ summary = {name: r.findings for name, r in analysis.raw_results.items()}
836
+
837
+ # Prefer M2's LLM conclusion + evidence chain (StatsAnalysisReport) over
838
+ # the bare threshold narrative, so a real failure mode surfaced by the
839
+ # analyst is not dropped just because no numeric threshold fired.
840
+ conclusion = getattr(analysis, "conclusion", "") or analysis.narrative or "(no conclusion)"
841
+ evidence_chain = getattr(analysis, "evidence_chain", None) or []
842
+ evidence_section = ""
843
+ if evidence_chain:
844
+ evidence_section = (
845
+ "\nEvidence chain:\n"
846
+ + "\n".join(f" - {step}" for step in evidence_chain)
847
+ + "\n"
848
+ )
849
+ stats_results = getattr(analysis, "stats_results", None) or []
850
+ stats_section = ""
851
+ stats_lines = [
852
+ f" - {r.summary}"
853
+ for r in stats_results
854
+ if getattr(r, "ok", False) and getattr(r, "summary", "")
855
+ ]
856
+ if stats_lines:
857
+ stats_section = "\nStatistical test results:\n" + "\n".join(stats_lines) + "\n"
858
+
859
+ # Evidence sources M3 can reference in TEST designs: per-case signal
860
+ # keys and strategy contrasts that already exist in this cycle.
861
+ signals: list[str] = []
862
+ for r in stats_results:
863
+ cfg = getattr(r, "config", None) or {}
864
+ sig = cfg.get("signal")
865
+ if sig and sig not in signals:
866
+ signals.append(sig)
867
+ for s in cfg.get("strategies", []) or []:
868
+ tag = f"strategy contrast vs '{s}'"
869
+ if s != "baseline" and tag not in signals:
870
+ signals.append(tag)
871
+ available_signals_section = ""
872
+ if signals:
873
+ available_signals_section = (
874
+ "AVAILABLE EVIDENCE (per-case signals / contrasts already measured):\n"
875
+ + "\n".join(f" - {s}" for s in signals)
876
+ + "\n\n"
877
+ )
878
+
879
+ protocol = getattr(analysis, "protocol", None)
880
+ protocol_section = (
881
+ protocol.prompt_text() if protocol is not None
882
+ else "(not supplied; infer cautiously from the stored findings)"
883
+ )
884
+
885
+ prompt = _DIAGNOSE_PROMPT.format(
886
+ prior_section=_format_prior_section(prior_cycles or []),
887
+ model_name=analysis.model_name or model_name,
888
+ severity=analysis.severity,
889
+ protocol_section=protocol_section,
890
+ conclusion=conclusion,
891
+ evidence_section=evidence_section,
892
+ stats_section=stats_section,
893
+ explore_section=_format_explore_section(explore_context),
894
+ failure_modes_section=_format_failure_modes_section(failure_modes),
895
+ available_signals_section=available_signals_section,
896
+ findings_json=json.dumps(summary, indent=2, default=str),
897
+ )
898
+ import inspect as _inspect
899
+ from pathlib import Path as _Path
900
+ # M2's confirmatory figures, then the explorer's (UNCONFIRMED) charts.
901
+ # The explore charts are attached to the M3 prompt ONLY — they never reach
902
+ # M2's confirmatory family, M4, or the fix gate.
903
+ _figs = [_Path(f) for f in getattr(analysis, "figures", []) if _Path(f).exists()]
904
+ if explore_context is not None:
905
+ _figs += [_Path(f) for f in explore_context.figure_paths if _Path(f).exists()]
906
+ _sig = _inspect.signature(self.judge.generate)
907
+ started = time.perf_counter()
908
+ try:
909
+ if "images" in _sig.parameters and _figs:
910
+ raw = self.judge.generate(prompt, images=_figs)
911
+ else:
912
+ raw = self.judge.generate(prompt)
913
+ except Exception as exc:
914
+ self._model_calls.append({
915
+ "role": "diagnosis_judge", "operation": "generate",
916
+ "inputs": {"prompt": prompt, "images": [str(f) for f in _figs]},
917
+ "output": None, "error": str(exc),
918
+ "duration_sec": time.perf_counter() - started,
919
+ })
920
+ raise
921
+ self._model_calls.append({
922
+ "role": "diagnosis_judge", "operation": "generate",
923
+ "inputs": {"prompt": prompt, "images": [str(f) for f in _figs]},
924
+ "output": str(raw), "error": None,
925
+ "duration_sec": time.perf_counter() - started,
926
+ })
927
+ proposed_hypotheses = _parse_hypotheses(str(raw), analysis.model_name or model_name)
928
+ if not proposed_hypotheses:
929
+ raw, proposed_hypotheses = self._reask_for_format(
930
+ str(raw), analysis.model_name or model_name,
931
+ )
932
+ proposed_hypotheses = self._repair_plain_language(
933
+ str(raw), proposed_hypotheses, analysis.model_name or model_name,
934
+ )
935
+ hypotheses = list(proposed_hypotheses)
936
+
937
+ # Adversarial validation: run a second critic call at temperature=0 to
938
+ # prune hypotheses the generator produced without sufficient evidence.
939
+ # This prevents the self-evaluation loop where the same model that
940
+ # proposed a hypothesis then approves it uncritically.
941
+ critic: dict[str, Any] = {}
942
+ if hypotheses:
943
+ findings_json_str = json.dumps(summary, indent=2, default=str)
944
+ # The critic reviews against what the proposer saw, not less:
945
+ # conclusion + evidence chain + stats verdicts + explore notes,
946
+ # plus a label summary the proposer never had either.
947
+ critic_context = "\n".join(part.strip("\n") for part in (
948
+ f"Analysis conclusion: {conclusion}",
949
+ f"Experiment protocol:\n{protocol_section}",
950
+ evidence_section,
951
+ stats_section,
952
+ _format_explore_section(explore_context),
953
+ _format_label_context(cases),
954
+ ) if part and part.strip())
955
+ hypotheses = _validate_hypotheses(
956
+ hypotheses, findings_json_str, self.judge, capture=critic,
957
+ context=critic_context,
958
+ )
959
+ self._model_calls.append({
960
+ "role": "hypothesis_critic", "operation": "generate",
961
+ "inputs": critic.get("prompt", ""), "output": critic.get("raw"),
962
+ "error": critic.get("error"),
963
+ "duration_sec": critic.get("duration_sec"),
964
+ })
965
+
966
+ # Fallback: if the judge returned NO_ISSUE but M2 has medium/high findings,
967
+ # auto-generate one hypothesis per finding so M5 can still run.
968
+ # This prevents self-diagnosis bias when the judge is the model under test.
969
+ if not hypotheses and analysis.findings:
970
+ from evalrx.analysis.analysis_module import _SEVERITY_ORDER
971
+ raw_text = str(raw or "").strip()
972
+ if raw_text and "NO_ISSUE" not in raw_text.upper():
973
+ # Not a NO_ISSUE verdict: the judge proposed something the label
974
+ # parser could not read (gemma-4-e2b/bbh_causal_judgement 2026-08-22:
975
+ # ``**HYPOTHESIS 1:**``). Substituting templates silently turned a
976
+ # three-hypothesis diagnosis into "low self-consistency".
977
+ import logging
978
+
979
+ logging.getLogger(__name__).warning(
980
+ "DiagnosisAgent: the judge wrote %d chars that parsed to zero hypotheses "
981
+ "(not NO_ISSUE); falling back to analysis-module template hypotheses — "
982
+ "check the label format in the response: %r",
983
+ len(raw_text), raw_text[:160],
984
+ )
985
+ for finding in analysis.findings:
986
+ if _SEVERITY_ORDER.get(finding.severity, 0) >= 2: # medium or high
987
+ hypotheses.append(Hypothesis(
988
+ statement=(
989
+ f"The model {finding.message} "
990
+ f"({finding.analyzer}.{finding.metric}={finding.value:.3g})"
991
+ ),
992
+ target_model=analysis.model_name or model_name,
993
+ predicted_failure_mode=finding.analyzer,
994
+ ))
995
+
996
+ return DiagnosisResult(
997
+ model_name=analysis.model_name or model_name,
998
+ hypotheses=hypotheses,
999
+ findings_summary=summary,
1000
+ raw_judge_output=str(raw),
1001
+ prompt=prompt,
1002
+ referenced_charts=_extract_referenced(str(raw), explore_context),
1003
+ explore_context_used=bool(explore_context is not None and not explore_context.is_empty),
1004
+ failure_modes_used=bool(getattr(failure_modes, "clusters", None)),
1005
+ critic_raw_output=str(critic.get("raw", "") or ""),
1006
+ critic_prompt=str(critic.get("prompt", "") or ""),
1007
+ n_critic_kept=int(critic.get("n_kept", 0) or 0),
1008
+ n_critic_rejected=int(critic.get("n_rejected", 0) or 0),
1009
+ # The same review in the proposer/reviewer record shape: the full
1010
+ # proposal list and a per-hypothesis decision derived from the
1011
+ # critic annotations (the critic never removes a hypothesis).
1012
+ proposed_hypotheses=proposed_hypotheses,
1013
+ review_prompt=str(critic.get("prompt", "") or ""),
1014
+ review_raw=str(critic.get("raw", "") or ""),
1015
+ review_decisions=list(critic.get("decisions") or []),
1016
+ model_calls=list(self._model_calls),
1017
+ )