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,1014 @@
1
+ """M4 — HypothesisTester: verify hypotheses via statistical tests + protocol consistency.
2
+
3
+ M4 is the *gatekeeper* between M3 (hypothesis generation) and the stopping
4
+ decision. It asks two questions for each hypothesis:
5
+
6
+ 1. **Statistical support** — do cases that exhibit the hypothesised signal fail
7
+ at a higher rate than cases that do not? M4 now *consumes M2's rigorous
8
+ ``stats_results``* (effect size + CI + e-value, FDR-corrected via e-BH) when
9
+ present, deciding SUPPORTED/REFUTED only on a corrected rejection. When M2
10
+ ran without labeled data (no ``stats_results``), it falls back to a rigorous
11
+ clustered-bootstrap :func:`~evalrx.stats.compare` on the extracted
12
+ per-case signal — never a hand-rolled proportion difference.
13
+
14
+ 2. **Protocol consistency** — is the hypothesis consistent with what the user
15
+ described in their experiment protocol? Uses keyword-based heuristics by
16
+ default; an optional LLM ``judge=`` runs a critic call for a richer check.
17
+
18
+ **Stopping criteria** (Plan A from the 2026-06-05 meeting):
19
+ :meth:`stopping_criteria_met` returns ``True`` when at least one hypothesis is
20
+ *both* statistically supported *and* protocol-consistent. The loop calls this
21
+ after each M4 pass and breaks when it returns ``True``.
22
+
23
+ Usage::
24
+
25
+ tester = HypothesisTester()
26
+ results = tester.test(hypotheses, stats_report, data, protocol=protocol)
27
+
28
+ if tester.stopping_criteria_met(results, protocol):
29
+ best = tester.best_hypotheses(results)
30
+ # pass best[0].hypothesis to M5
31
+
32
+ # With LLM judge for richer protocol consistency:
33
+ tester = HypothesisTester(judge=gemini)
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import logging
39
+ import re
40
+ from dataclasses import dataclass, field
41
+ from typing import TYPE_CHECKING, Any
42
+
43
+ from evalrx.core.case import CaseBatch, Label
44
+ from evalrx.eval_agent.hypothesis import Hypothesis, HypothesisStatus
45
+ from evalrx.eval_agent.prompts.hypothesis_tester import _CONSISTENCY_PROMPT
46
+ from evalrx.eval_agent.stages.surgery import _extract_per_case_signals
47
+ from evalrx.stats import compare
48
+
49
+ if TYPE_CHECKING:
50
+ from evalrx.analysis.stats_agent import StatsAnalysisReport
51
+ from evalrx.analysis.stats_tools import StatsToolResult
52
+ from evalrx.core.model import Model
53
+ from evalrx.eval_agent.stages.protocol import ExperimentProtocol
54
+
55
+ logger = logging.getLogger(__name__)
56
+
57
+ # M2 tools whose effect is a directional per-mechanism signal (effect>0 = harmful:
58
+ # the signal group fails MORE). These can decide SUPPORTED/REFUTED for a hypothesis.
59
+ _SIGNAL_TOOLS = frozenset({"signal_label_assoc", "bootstrap_diff", "mcnemar_evalue"})
60
+ # Tools that describe the run globally rather than a specific mechanism — used as
61
+ # corroborating evidence only, never sufficient to confirm a specific hypothesis.
62
+ # attention_decoding is a tensor-level OMNIBUS ("do the maps differ anywhere?") —
63
+ # corroborating, not a directional per-mechanism verdict.
64
+ _GLOBAL_TOOLS = frozenset({"single_rate_evalue", "friedman_nemenyi", "rank_corr",
65
+ "attention_decoding"})
66
+ # Descriptive-only tools whose "effect" is an artifact of the batch composition,
67
+ # not a mechanism signal (e.g. single_rate_evalue's rate − p0 on a curated/
68
+ # enriched batch). They are consulted for context but MUST NOT become a
69
+ # hypothesis verdict's headline result — otherwise an inconclusive run reads as
70
+ # "best: FAIL rate 7% vs p0=0.50 → reject", which sounds like a finding and is
71
+ # meaningless once the batch is not a representative sample.
72
+ _DESCRIPTIVE_TOOLS = frozenset({"single_rate_evalue"})
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # Output type
77
+ # ---------------------------------------------------------------------------
78
+
79
+ @dataclass
80
+ class HypothesisTestResult:
81
+ """Result of M4 testing one :class:`~evalrx.eval_agent.hypothesis.Hypothesis`.
82
+
83
+ Attributes:
84
+ hypothesis: The hypothesis under test.
85
+ status: Statistical verdict
86
+ (:class:`~evalrx.eval_agent.hypothesis.HypothesisStatus`).
87
+ test_name: Which test was used (e.g. ``"fail_rate_comparison"``).
88
+ effect_size: Observed fail-rate difference
89
+ (signal group minus control group).
90
+ is_consistent_with_protocol: ``True`` when the hypothesis is relevant
91
+ to the user's experiment protocol.
92
+ confidence: Combined score in [0, 1] — geometric mean
93
+ of evidence gap, sample adequacy, and
94
+ control cleanliness.
95
+ verdict: NL one-liner for human consumption.
96
+ evidence_grade: Strength tier of the deciding evidence:
97
+ ``"intervention"`` (paired strategy contrast
98
+ or intervention-derived signal — causal),
99
+ ``"observational"`` (signal/label
100
+ association), or ``"none"``.
101
+ evidence: Supporting statistics and group sizes.
102
+ """
103
+
104
+ hypothesis: Hypothesis
105
+ status: HypothesisStatus
106
+ test_name: str
107
+ effect_size: float | None
108
+ is_consistent_with_protocol: bool
109
+ confidence: float
110
+ verdict: str
111
+ evidence_grade: str = "observational"
112
+ evidence: dict[str, Any] = field(default_factory=dict)
113
+ # Verbatim protocol-consistency judge I/O (empty when the heuristic check
114
+ # ran or no judge is configured) — forwarded to RunLogger.log_surgery so
115
+ # the M4 judge call is persisted under prompts/ like M1/M2/M3.
116
+ judge_prompt: str = ""
117
+ judge_raw: str = ""
118
+
119
+
120
+ # Evidence-strength ordering for the P4 depth-tiered stopping criterion.
121
+ _GRADE_ORDER = {"none": 0, "observational": 1, "intervention": 2}
122
+
123
+
124
+ def _evidence_grade(tool: str, signal: str) -> str:
125
+ """Grade the deciding evidence: paired contrasts and intervention-derived
126
+ per-case signals are causal ("intervention"); plain analyzer-signal
127
+ associations are correlational ("observational")."""
128
+ if tool in {"mcnemar_evalue", "friedman_nemenyi"}:
129
+ return "intervention"
130
+ if str(signal).startswith("prompt_contrast."):
131
+ return "intervention"
132
+ return "observational"
133
+
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # Tester
137
+ # ---------------------------------------------------------------------------
138
+
139
+ class HypothesisTester:
140
+ """M4: test hypotheses using statistical methods and protocol consistency.
141
+
142
+ Args:
143
+ judge: Optional LLM for protocol consistency checks.
144
+ Any :class:`~evalrx.core.model.Model` with
145
+ ``Capability.GENERATE``. When ``None``, uses a
146
+ keyword-based heuristic instead.
147
+ alpha: Statistical significance level used for the e-value /
148
+ bootstrap-CI decisions inherited from
149
+ :func:`~evalrx.stats.compare` and for confidence
150
+ scaling.
151
+ min_effect: Minimum fail-rate difference to consider a finding
152
+ meaningful (default 0.10 = 10 pp). Used by the rigorous
153
+ fallback path when M2 supplied no ``stats_results``.
154
+ min_evidence_grade: Depth tier required for the stopping criterion
155
+ (P4). ``"observational"`` (default) stops on any
156
+ statistically supported + protocol-consistent hypothesis;
157
+ ``"intervention"`` keeps the loop running until a
158
+ hypothesis is verified by intervention-grade evidence
159
+ (paired strategy contrast / intervention-derived signal),
160
+ letting cycle 2 collect the targeted evidence that M3's
161
+ test designs ask for.
162
+ """
163
+
164
+ def __init__(
165
+ self,
166
+ judge: "Model | None" = None,
167
+ alpha: float = 0.05,
168
+ min_effect: float = 0.10,
169
+ min_evidence_grade: str = "observational",
170
+ ) -> None:
171
+ self._judge = judge
172
+ self.alpha = alpha
173
+ self.min_effect = min_effect
174
+ # Verbatim I/O of the most recent protocol-consistency judge call
175
+ # (captured by _llm_consistency_check, consumed by _test_one).
176
+ self._last_judge_prompt: "str | None" = None
177
+ self._last_judge_raw: "str | None" = None
178
+ if min_evidence_grade not in _GRADE_ORDER:
179
+ raise ValueError(
180
+ f"min_evidence_grade must be one of {sorted(_GRADE_ORDER)}, "
181
+ f"got {min_evidence_grade!r}"
182
+ )
183
+ self.min_evidence_grade = min_evidence_grade
184
+
185
+ def test(
186
+ self,
187
+ hypotheses: list[Hypothesis],
188
+ stats_report: "StatsAnalysisReport",
189
+ data: CaseBatch,
190
+ protocol: "ExperimentProtocol | None" = None,
191
+ ) -> list[HypothesisTestResult]:
192
+ """Test each hypothesis against *data* and *protocol*.
193
+
194
+ Args:
195
+ hypotheses: Hypotheses produced by M3 (DiagnosisAgent).
196
+ stats_report: M2's report — provides ``raw_results`` for
197
+ per-case signal extraction.
198
+ data: Cases to test against (must carry labels for
199
+ fail-rate comparison).
200
+ protocol: Experiment protocol for consistency checks.
201
+ ``None`` → all hypotheses are assumed consistent.
202
+
203
+ Returns:
204
+ One :class:`HypothesisTestResult` per hypothesis, in the
205
+ same order as *hypotheses*.
206
+ """
207
+ # The whole cycle shares one stats_report, so either ALL hypotheses take
208
+ # the primary (M2 e-BH-corrected) path or ALL take the label-free
209
+ # fallback. On the fallback path the family is every hypothesis, so each
210
+ # directional verdict must clear a Bonferroni-corrected significance
211
+ # level (alpha / family) — otherwise best-of-N over the fallback would
212
+ # manufacture SUPPORTED verdicts the primary path's e-BH would have
213
+ # blocked.
214
+ on_fallback = not list(getattr(stats_report, "stats_results", None) or [])
215
+ family_size = len(hypotheses) if on_fallback else 1
216
+
217
+ results: list[HypothesisTestResult] = []
218
+ for h in hypotheses:
219
+ result = self._test_one(h, stats_report, data, protocol, family_size)
220
+ results.append(result)
221
+ return results
222
+
223
+ def stopping_criteria_met(
224
+ self,
225
+ test_results: list[HypothesisTestResult],
226
+ protocol: "ExperimentProtocol | None" = None,
227
+ ) -> bool:
228
+ """Return ``True`` when at least one verified, protocol-consistent hypothesis exists.
229
+
230
+ This is the **Plan A stopping criterion** from the 2026-06-05
231
+ architecture meeting: the loop should stop once we have a
232
+ statistically supported hypothesis that addresses what the
233
+ user's protocol was testing — further cycling would only
234
+ rediscover the same root cause.
235
+
236
+ With ``min_evidence_grade="intervention"`` (P4 depth tier), an
237
+ observationally supported hypothesis does NOT stop the loop — the next
238
+ cycle can collect the targeted (intervention) evidence that M3's test
239
+ designs call for.
240
+ """
241
+ need = _GRADE_ORDER[self.min_evidence_grade]
242
+ return any(
243
+ r.status == HypothesisStatus.SUPPORTED
244
+ and r.is_consistent_with_protocol
245
+ and _GRADE_ORDER.get(r.evidence_grade, 0) >= need
246
+ for r in test_results
247
+ )
248
+
249
+ def best_hypotheses(
250
+ self,
251
+ test_results: list[HypothesisTestResult],
252
+ ) -> list[HypothesisTestResult]:
253
+ """Return verified, protocol-consistent hypotheses, strongest evidence first.
254
+
255
+ Sorted by evidence grade (intervention > observational), then
256
+ confidence. The first element is the candidate to hand to M5.
257
+ """
258
+ supported = [
259
+ r for r in test_results
260
+ if r.status == HypothesisStatus.SUPPORTED and r.is_consistent_with_protocol
261
+ ]
262
+ return sorted(
263
+ supported,
264
+ key=lambda r: (_GRADE_ORDER.get(r.evidence_grade, 0), r.confidence),
265
+ reverse=True,
266
+ )
267
+
268
+ # ------------------------------------------------------------------
269
+ # Internal
270
+ # ------------------------------------------------------------------
271
+
272
+ def _test_one(
273
+ self,
274
+ hypothesis: Hypothesis,
275
+ stats_report: "StatsAnalysisReport",
276
+ data: CaseBatch,
277
+ protocol: "ExperimentProtocol | None",
278
+ family_size: int = 1,
279
+ ) -> HypothesisTestResult:
280
+ """Test a single hypothesis.
281
+
282
+ Primary path: consume M2's rigorous ``stats_results`` (effect + CI +
283
+ e-value, FDR-aware). Fallback (when M2 supplied none, e.g. it ran
284
+ without labeled data): a rigorous ``compare()`` on the extracted
285
+ per-case signal — *not* a hand-rolled proportion difference.
286
+ """
287
+ stats_results = list(getattr(stats_report, "stats_results", None) or [])
288
+ if stats_results:
289
+ core = self._verdict_from_stats_results(hypothesis, stats_report, stats_results)
290
+ # An explicit M3 test design is a preregistration, not a hint. If
291
+ # that experiment was not run, unrelated signals must not be used
292
+ # to support/refute the claim. The next loop cycle can run it.
293
+ if core.get("evidence_grade") == "none" and not hypothesis.test_design.strip():
294
+ core = self._verdict_fallback(hypothesis, stats_report, data, family_size)
295
+ elif core.get("evidence_grade") == "unmet":
296
+ # The design named evidence nobody measured: stay inconclusive
297
+ # rather than let the generic per-case fallback (any truthy
298
+ # analyzer value vs FAIL) manufacture a verdict about it.
299
+ core["evidence_grade"] = "none"
300
+ elif not hypothesis.test_design.strip():
301
+ core = self._verdict_fallback(hypothesis, stats_report, data, family_size)
302
+ else:
303
+ core = {
304
+ "status": HypothesisStatus.INCONCLUSIVE,
305
+ "effect_size": None,
306
+ "confidence": 0.0,
307
+ "verdict": "The preregistered test was not run; no substitute evidence was used.",
308
+ "test_name": "preregistered_test_unavailable",
309
+ "evidence_grade": "none",
310
+ "evidence": {
311
+ "source": "preregistered_test",
312
+ "test_design": hypothesis.test_design,
313
+ "reason": "no M2 statistical result for the named test",
314
+ },
315
+ }
316
+
317
+ status: HypothesisStatus = core["status"]
318
+ verdict: str = core["verdict"]
319
+
320
+ # ── Protocol consistency check ────────────────────────────────
321
+ self._last_judge_prompt: "str | None" = None
322
+ self._last_judge_raw: "str | None" = None
323
+ is_consistent = self._check_protocol_consistency(
324
+ hypothesis, stats_report, protocol
325
+ )
326
+ if not is_consistent and status == HypothesisStatus.SUPPORTED:
327
+ verdict += " (note: not protocol-consistent — excluded from best candidates)"
328
+
329
+ return HypothesisTestResult(
330
+ hypothesis=hypothesis,
331
+ status=status,
332
+ test_name=core["test_name"],
333
+ effect_size=core["effect_size"],
334
+ is_consistent_with_protocol=is_consistent,
335
+ confidence=core["confidence"],
336
+ verdict=verdict,
337
+ evidence_grade=core.get("evidence_grade", "observational"),
338
+ evidence=core["evidence"],
339
+ judge_prompt=self._last_judge_prompt or "",
340
+ judge_raw=self._last_judge_raw or "",
341
+ )
342
+
343
+ # ------------------------------------------------------------------
344
+ # Primary path: consume M2's rigorous stats_results
345
+ # ------------------------------------------------------------------
346
+
347
+ def _verdict_from_stats_results(
348
+ self,
349
+ hypothesis: Hypothesis,
350
+ stats_report: "StatsAnalysisReport",
351
+ stats_results: "list[StatsToolResult]",
352
+ ) -> dict[str, Any]:
353
+ """Derive a verdict from M2's effect-sized, FDR-aware tool results."""
354
+ corrected = getattr(stats_report, "corrected_rejections", None) or {}
355
+ fdr_rejected_keys = set(corrected.get("rejected_result_keys") or [])
356
+
357
+ def _decisive(r: "StatsToolResult") -> bool:
358
+ # A result decides direction only if it rejected H0 AFTER multiplicity
359
+ # correction. correct_results() files every p- or e-valued result into
360
+ # a BH / e-BH family and writes the family's verdict to fdr_corrected;
361
+ # `reject` is deliberately left raw for BH members (loop evidence, see
362
+ # multiplicity.py), so reading it here was the defect: on
363
+ # qwen3.5-2b/bbh_word_sorting a signal with p=0.14 over n_signal=3
364
+ # "supported" a hypothesis because a bootstrap CI over three cases
365
+ # cannot straddle zero. Per result, not per tool name — every
366
+ # signal_label_assoc test shares one name, so a tool-level set said
367
+ # "rejected" for all of them once any one survived.
368
+ if not r.reject:
369
+ return False
370
+ if r.correction_method:
371
+ return bool(r.fdr_corrected)
372
+ if r.e_value is not None:
373
+ # e-valued but never corrected (deferred report): only the
374
+ # report's own survivor list can vouch for it.
375
+ return bool(r.analysis_key) and r.analysis_key in fdr_rejected_keys
376
+ return True
377
+
378
+ # Outcome re-grades ("is the answer correct" by an analyzer's own
379
+ # matcher) are tautological evidence in either direction; M2 isolates
380
+ # them to the sanity lane since 2026-08-19, but a confirm-only run
381
+ # reloads an older M2's results, so they are dropped here as well.
382
+ from evalrx.analysis.stats_tools import _is_outcome_regrade
383
+
384
+ stats_results = [r for r in stats_results
385
+ if not _is_outcome_regrade(_tool_signal(r))]
386
+ relevant, global_res, routed_by = self._select_results(hypothesis, stats_results)
387
+ decisive = [r for r in relevant if (r.effect or 0.0) != 0 and _decisive(r)]
388
+ # A signal's effect > 0 means "the signal group fails MORE". Whether that
389
+ # SUPPORTS the hypothesis depends on what the hypothesis predicted: one
390
+ # that says a signal is LOW / absent / "sits at 0" on failures (minervamath:
391
+ # "step_rollout_value.initial_value ... 5/8 chains sit at 0.0 from step
392
+ # 0") predicts a NEGATIVE effect, and reading every negative effect as
393
+ # "protective -> refuted" rejected hypotheses the data agreed with. The
394
+ # preregistered EXPECTED_ASSOCIATION field is the structured form of
395
+ # that prediction and takes precedence for every routed signal; absent
396
+ # it, the direction is read from the hypothesis text (TEST line first)
397
+ # per signal; absent any cue the classic reading (signal marks the
398
+ # failure) stands.
399
+ expected = str(getattr(hypothesis, "expected_association", "") or "").lower()
400
+ declared_sign = {"lower_on_failures": -1, "higher_on_failures": 1}.get(expected)
401
+ expected_by_result = {
402
+ id(r): (declared_sign if declared_sign is not None
403
+ else _expected_sign(hypothesis, _tool_signal(r)))
404
+ for r in decisive
405
+ }
406
+
407
+ def _agrees(r: "StatsToolResult") -> bool:
408
+ exp = expected_by_result[id(r)]
409
+ eff = float(r.effect or 0.0)
410
+ return eff > 0 if exp is None else (eff > 0) == (exp > 0)
411
+
412
+ supporting = [r for r in decisive if _agrees(r)]
413
+ contradicting = [r for r in decisive if not _agrees(r)]
414
+
415
+ consulted = [r.tool for r in relevant] + [r.tool for r in global_res]
416
+
417
+ if supporting:
418
+ chosen = max(supporting, key=lambda r: abs(r.effect or 0.0))
419
+ status = HypothesisStatus.SUPPORTED
420
+ elif contradicting:
421
+ chosen = max(contradicting, key=lambda r: abs(r.effect or 0.0))
422
+ status = HypothesisStatus.REFUTED
423
+ else:
424
+ # Descriptive tools (single_rate_evalue's rate − p0) must not be the
425
+ # headline: their effect is a batch-composition artifact, so on an
426
+ # enriched batch they would always win max(|effect|) and print a
427
+ # meaningless "vs p0=0.50 → reject" as the verdict.
428
+ pool = [r for r in (relevant or global_res)
429
+ if r.tool not in _DESCRIPTIVE_TOOLS]
430
+ chosen = max(pool, key=lambda r: abs(r.effect or 0.0)) if pool else None
431
+ status = HypothesisStatus.INCONCLUSIVE
432
+
433
+ if routed_by == "test_design_unmet":
434
+ named = sorted(_identifiers(hypothesis.test_design or ""))[:6]
435
+ return {
436
+ "status": HypothesisStatus.INCONCLUSIVE,
437
+ "effect_size": None,
438
+ "confidence": 0.0,
439
+ "verdict": (
440
+ "designated evidence not measured this cycle: the test design names "
441
+ + (", ".join(named) if named else "signals")
442
+ + " but M2 has no result for them — not judged on unrelated signals "
443
+ "(next cycle's M1 targets the design)"
444
+ ),
445
+ "test_name": "stats_results",
446
+ "evidence_grade": "unmet",
447
+ "evidence": {"source": "m2_stats_results", "consulted_tools": consulted,
448
+ "routed_by": routed_by, "designated": named},
449
+ }
450
+ if chosen is None:
451
+ return {
452
+ "status": HypothesisStatus.INCONCLUSIVE,
453
+ "effect_size": None,
454
+ "confidence": 0.0,
455
+ "verdict": ("No discriminating M2 result for this hypothesis"
456
+ + (f" (consulted {len(consulted)} tool(s); only "
457
+ "batch-descriptive statistics available)"
458
+ if consulted else "")),
459
+ "test_name": "stats_results",
460
+ "evidence_grade": "none",
461
+ "evidence": {"source": "m2_stats_results", "consulted_tools": consulted,
462
+ "routed_by": routed_by},
463
+ }
464
+
465
+ grade = _evidence_grade(chosen.tool, chosen.config.get("signal", ""))
466
+ confidence = _confidence_from_stat(
467
+ chosen.effect, chosen.ci, chosen.e_value, chosen.underpowered, self.alpha
468
+ )
469
+ # summary is baked at tool-run time, before correction, so it can still
470
+ # say "REJECT H0" on a result BH killed; carry the family verdict along.
471
+ from evalrx.analysis.stats_agent import _multiplicity_note
472
+
473
+ note = _multiplicity_note(chosen).strip()
474
+ note = f" {note}" if note else ""
475
+ exp_sign = _expected_sign(hypothesis, _tool_signal(chosen))
476
+ direction_note = ""
477
+ if exp_sign is not None and status != HypothesisStatus.INCONCLUSIVE:
478
+ direction_note = (
479
+ f" [hypothesis predicts the signal {'HIGHER' if exp_sign > 0 else 'LOWER'}"
480
+ f" on failures; observed effect {float(chosen.effect or 0.0):+.3f} -> "
481
+ + ("consistent" if status == HypothesisStatus.SUPPORTED else "opposite")
482
+ + "]"
483
+ )
484
+ if status == HypothesisStatus.INCONCLUSIVE:
485
+ verdict = f"No significant M2 result (best: {chosen.summary}{note})"
486
+ else:
487
+ verdict = f"{chosen.tool}: {chosen.summary}{note}{direction_note}"
488
+
489
+ evidence = {
490
+ "source": "m2_stats_results",
491
+ "chosen_tool": chosen.tool,
492
+ "expected_direction": (None if exp_sign is None
493
+ else ("higher_in_fail" if exp_sign > 0 else "lower_in_fail")),
494
+ "effect_size": chosen.effect,
495
+ "ci": list(chosen.ci) if chosen.ci is not None else None,
496
+ "e_value": chosen.e_value,
497
+ "reject": chosen.reject,
498
+ "underpowered": chosen.underpowered,
499
+ "consulted_tools": consulted,
500
+ "routed_by": routed_by,
501
+ "expected_association": expected or "higher_on_failures",
502
+ "evidence_grade": grade,
503
+ "fdr": corrected,
504
+ }
505
+ return {
506
+ "status": status,
507
+ "effect_size": chosen.effect,
508
+ "confidence": confidence,
509
+ "verdict": verdict,
510
+ "test_name": chosen.tool,
511
+ "evidence_grade": grade,
512
+ "evidence": evidence,
513
+ }
514
+
515
+ def _select_results(
516
+ self,
517
+ hypothesis: Hypothesis,
518
+ stats_results: "list[StatsToolResult]",
519
+ ) -> "tuple[list[StatsToolResult], list[StatsToolResult], str]":
520
+ """Split tool results into (relevant signal tools, global tools, routed_by).
521
+
522
+ Routing priority (P3):
523
+ 1. **test_design** — when M3 attached an explicit test design to the
524
+ hypothesis, tools whose signal/strategies overlap it are the
525
+ designated evidence (deterministic routing).
526
+ 2. **keywords** — overlap between the hypothesis text and the tool's
527
+ signal key/name.
528
+ 3. **shared** — all signal tools as shared evidence (historical
529
+ aggregated-signal behaviour).
530
+ """
531
+ signal_res = [
532
+ r for r in stats_results
533
+ if r.ok and r.tool in _SIGNAL_TOOLS and r.effect is not None
534
+ ]
535
+ global_res = [r for r in stats_results if r.ok and r.tool in _GLOBAL_TOOLS]
536
+
537
+ def _tool_text(r: "StatsToolResult") -> str:
538
+ strategies = " ".join(r.config.get("strategies", []) or [])
539
+ return (f"{r.tool} {r.config.get('signal', '')} {strategies} "
540
+ f"{r.details.get('signal', '')}")
541
+
542
+ # 1. Identifiers first. M3 is told to name analyzers / per-case signals
543
+ # ("cot_faithfulness.drift_away", "perturbation_battery"); those are
544
+ # the underscore/dotted tokens of the design. Matching on them, and
545
+ # only them, keeps a design that says "... the answer ..." from
546
+ # routing to answer_extraction_audit.gold_in_answer_region — which
547
+ # is exactly what happened on qwen3.5-2b/bbh_causal_judgement: six
548
+ # hypotheses about overthinking / counterfactuals / prompt encoding
549
+ # all "refuted" by that one unrelated (protective) signal.
550
+ design_raw = hypothesis.test_design or ""
551
+ design_ids = _identifiers(design_raw)
552
+ if design_ids:
553
+ exact = [r for r in signal_res if _tool_ids(r, level="signal") & design_ids]
554
+ if exact:
555
+ return exact, global_res, "test_design"
556
+ by_analyzer = [r for r in signal_res if _tool_ids(r, level="analyzer") & design_ids]
557
+ if by_analyzer:
558
+ return by_analyzer, global_res, "test_design"
559
+ # The design named EXACT identifiers and none of them is in M2's
560
+ # results. Falling through to fuzzy words here let one generic
561
+ # overlap ("attention") claim the preregistered experiment ran on
562
+ # an unrelated signal; a named-but-unmeasured design is unmet.
563
+ return [], global_res, "test_design_unmet"
564
+ # 2. Prose designs only (no identifiers): words of the design (generic
565
+ # tokens removed) against the SIGNAL's own words — never the tool
566
+ # name ("signal_label_assoc" is in every tool text), never a stop word.
567
+ design_kw = _signal_keywords(design_raw)
568
+ if design_kw:
569
+ designed = [r for r in signal_res if design_kw & _tool_keywords(r)]
570
+ if designed:
571
+ return designed, global_res, "test_design"
572
+ if design_ids or design_kw:
573
+ # The design named evidence M2 did not measure this cycle. Judging
574
+ # the hypothesis on whatever else M2 happened to test is not a
575
+ # test of THIS hypothesis; say so (M1's next cycle targets the
576
+ # design) instead of borrowing an unrelated verdict.
577
+ return [], global_res, "test_design_unmet"
578
+ kw = _signal_keywords(hypothesis.statement + " "
579
+ + hypothesis.predicted_failure_mode.replace("_", " "))
580
+ stmt_ids = _identifiers(hypothesis.statement)
581
+ matched = [
582
+ r for r in signal_res
583
+ if (_tool_ids(r, level="signal") & stmt_ids)
584
+ or (_tool_ids(r, level="analyzer") & stmt_ids)
585
+ or (kw & _tool_keywords(r))
586
+ ]
587
+ if matched:
588
+ return matched, global_res, "keywords"
589
+ return signal_res, global_res, "shared"
590
+
591
+ # ------------------------------------------------------------------
592
+ # Fallback path: rigorous compare() on the extracted per-case signal
593
+ # ------------------------------------------------------------------
594
+
595
+ def _verdict_fallback(
596
+ self,
597
+ hypothesis: Hypothesis,
598
+ stats_report: "StatsAnalysisReport",
599
+ data: CaseBatch,
600
+ family_size: int = 1,
601
+ ) -> dict[str, Any]:
602
+ """Rigorous bootstrap comparison when M2 supplied no stats_results.
603
+
604
+ ``family_size`` is the number of hypotheses sharing this fallback cycle;
605
+ a directional verdict must be significant at the Bonferroni-corrected
606
+ level ``alpha / family_size`` (FWER control over the fallback family,
607
+ the unpaired-CI analogue of the primary path's e-BH). At ``family_size=1``
608
+ the correction is a no-op.
609
+ """
610
+ signal = _extract_per_case_signals(stats_report.raw_results)
611
+ signal_source = "analyzer_per_case"
612
+ if not signal:
613
+ signal = _fallback_case_signals(data, hypothesis)
614
+ signal_source = "case_metadata_fallback"
615
+
616
+ fail_signal: list[int] = []
617
+ fail_control: list[int] = []
618
+ for case in data:
619
+ if getattr(case, "label", None) is None:
620
+ continue
621
+ is_fail = int(case.label == Label.FAIL)
622
+ (fail_signal if signal.get(case.id, False) else fail_control).append(is_fail)
623
+
624
+ if not fail_signal or not fail_control:
625
+ return {
626
+ "status": HypothesisStatus.INCONCLUSIVE,
627
+ "effect_size": None,
628
+ "confidence": 0.0,
629
+ "verdict": "Insufficient labeled data to test statistically.",
630
+ "test_name": "fail_rate_compare",
631
+ "evidence": {
632
+ "source": "fallback_compare",
633
+ "signal_source": signal_source,
634
+ "reason": "no signal-present and signal-absent split available",
635
+ "n_signal": len(fail_signal),
636
+ "n_control": len(fail_control),
637
+ },
638
+ }
639
+
640
+ # Rigorous, effect-sized verdict (clustered bootstrap CI) — replaces the
641
+ # old hand-computed proportion difference + geometric-mean confidence.
642
+ # The CI is taken at the Bonferroni-corrected level so a directional
643
+ # verdict is significant across the whole fallback family, not just on
644
+ # its own (best-of-N over hypotheses would otherwise inflate SUPPORTED).
645
+ corrected_alpha = self.alpha / max(1, family_size)
646
+ sr = compare(
647
+ fail_control, fail_signal, paired=False,
648
+ alpha=corrected_alpha, min_effect=self.min_effect,
649
+ )
650
+ effect_size = round(sr.effect, 4)
651
+ confidence = _confidence_from_stat(
652
+ sr.effect, sr.ci, sr.e_value, sr.underpowered, corrected_alpha)
653
+ # A directional verdict requires BOTH a meaningful effect AND significance
654
+ # (CI excludes 0) at the family-corrected level.
655
+ significant = sr.reject
656
+
657
+ if effect_size > self.min_effect and significant:
658
+ status = HypothesisStatus.SUPPORTED
659
+ verdict = (
660
+ f"Signal group fails {effect_size:.0%} more than control "
661
+ f"({sr.summary()})."
662
+ )
663
+ elif effect_size < -self.min_effect and significant:
664
+ status = HypothesisStatus.REFUTED
665
+ verdict = (
666
+ f"Signal group fails less than control "
667
+ f"(effect={effect_size:.0%}); hypothesis refuted."
668
+ )
669
+ elif abs(effect_size) > self.min_effect and not significant:
670
+ status = HypothesisStatus.INCONCLUSIVE
671
+ verdict = (
672
+ f"Effect {effect_size:.0%} but CI includes 0 at the "
673
+ f"family-corrected level (alpha={corrected_alpha:.4g} over "
674
+ f"{family_size} hypotheses) — best-of-N multiplicity, inconclusive."
675
+ )
676
+ else:
677
+ status = HypothesisStatus.INCONCLUSIVE
678
+ verdict = (
679
+ f"Effect size {effect_size:.0%} below minimum {self.min_effect:.0%}; "
680
+ f"inconclusive."
681
+ )
682
+
683
+ evidence = {
684
+ "source": "fallback_compare",
685
+ "signal_source": signal_source,
686
+ "n_signal": len(fail_signal),
687
+ "n_control": len(fail_control),
688
+ "fail_rate_signal": round(sum(fail_signal) / len(fail_signal), 4),
689
+ "fail_rate_control": round(sum(fail_control) / len(fail_control), 4),
690
+ "effect_size": effect_size,
691
+ "ci": list(sr.ci),
692
+ "underpowered": sr.underpowered,
693
+ "method": sr.method,
694
+ "significant": significant,
695
+ "family_size": family_size,
696
+ "corrected_alpha": corrected_alpha,
697
+ }
698
+ return {
699
+ "status": status,
700
+ "effect_size": effect_size,
701
+ "confidence": confidence,
702
+ "verdict": verdict,
703
+ "test_name": "fail_rate_compare",
704
+ "evidence": evidence,
705
+ }
706
+
707
+ def _check_protocol_consistency(
708
+ self,
709
+ hypothesis: Hypothesis,
710
+ stats_report: "StatsAnalysisReport",
711
+ protocol: "ExperimentProtocol | None",
712
+ ) -> bool:
713
+ """Return True when the hypothesis is relevant to the protocol.
714
+
715
+ Uses LLM critic when a judge is available; falls back to keyword
716
+ overlap between the hypothesis failure mode and the protocol hints.
717
+ """
718
+ if protocol is None:
719
+ return True
720
+
721
+ if self._judge is not None:
722
+ try:
723
+ return self._llm_consistency_check(hypothesis, protocol)
724
+ except Exception as exc:
725
+ logger.debug("LLM consistency check failed, falling back: %s", exc)
726
+
727
+ return self._heuristic_consistency_check(hypothesis, protocol)
728
+
729
+ def _heuristic_consistency_check(
730
+ self,
731
+ hypothesis: Hypothesis,
732
+ protocol: "ExperimentProtocol",
733
+ ) -> bool:
734
+ """Text overlap between protocol description and hypothesis."""
735
+ proto_text = (protocol.description + " " + protocol.failure_patterns).lower()
736
+ if not proto_text.strip():
737
+ return True # no prior to compare against
738
+
739
+ hyp_text = (
740
+ hypothesis.statement + " "
741
+ + hypothesis.predicted_failure_mode.replace("_", " ")
742
+ ).lower()
743
+
744
+ # Any significant word (6+ chars) from hypothesis in protocol text?
745
+ for word in hyp_text.split():
746
+ if len(word) >= 6 and word in proto_text:
747
+ return True
748
+
749
+ # Any significant word (6+ chars) from protocol in hypothesis text?
750
+ for word in proto_text.split():
751
+ if len(word) >= 6 and word in hyp_text:
752
+ return True
753
+
754
+ return False
755
+
756
+ def _llm_consistency_check(
757
+ self,
758
+ hypothesis: Hypothesis,
759
+ protocol: "ExperimentProtocol",
760
+ ) -> bool:
761
+ """LLM critic: ask judge if the hypothesis is relevant to the protocol."""
762
+ import inspect
763
+
764
+ prompt = _CONSISTENCY_PROMPT.format(
765
+ # M4 decides whether the hypothesis is within the task contract.
766
+ # ``description`` is only its overview; omission of
767
+ # ``failure_patterns`` made explicitly declared mechanisms (for
768
+ # example AudioCaps' paraphrase-stability failure) invisible to
769
+ # the LLM judge and incorrectly protocol-inconsistent.
770
+ protocol_text=protocol.prompt_text(),
771
+ statement=hypothesis.statement,
772
+ failure_mode=hypothesis.predicted_failure_mode,
773
+ )
774
+ sig = inspect.signature(self._judge.generate) # type: ignore[union-attr]
775
+ if "temperature" in sig.parameters:
776
+ raw = self._judge.generate(prompt, temperature=0) # type: ignore[union-attr]
777
+ else:
778
+ raw = self._judge.generate(prompt) # type: ignore[union-attr]
779
+
780
+ # Retain the verbatim judge I/O so the caller can persist it (M4 was
781
+ # the one stage whose judge calls left no record under prompts/).
782
+ self._last_judge_prompt = prompt
783
+ self._last_judge_raw = str(raw)
784
+
785
+ first_line = str(raw).strip().splitlines()[0].upper()
786
+ return first_line.startswith("YES")
787
+
788
+
789
+ # ---------------------------------------------------------------------------
790
+ # Module helpers
791
+ # ---------------------------------------------------------------------------
792
+
793
+ def _fallback_case_signals(data: CaseBatch, hypothesis: Hypothesis) -> dict[str, bool]:
794
+ """Non-label fallback signals when analyzers expose no per-case entries.
795
+
796
+ These are operational signals from discovery/generation, not PASS/FAIL
797
+ labels: empty outputs, discovery exceptions, or UNKNOWN scorer verdicts.
798
+ They keep M4 from becoming vacuous while avoiding label leakage.
799
+ """
800
+ hyp_text = (
801
+ hypothesis.statement + " " + hypothesis.predicted_failure_mode
802
+ ).lower()
803
+ wants_empty = any(k in hyp_text for k in ("empty", "whitespace", "special token"))
804
+ wants_parse = any(k in hyp_text for k in ("parse", "parser", "unparsed", "format"))
805
+
806
+ signal: dict[str, bool] = {}
807
+ for case in data:
808
+ meta = getattr(case, "metadata", {}) or {}
809
+ observed = str(getattr(case, "observed", "") or meta.get("discovery_observed", ""))
810
+ hit = False
811
+ if wants_empty and not observed.strip():
812
+ hit = True
813
+ if wants_parse and meta.get("discovery_label") == "unknown":
814
+ hit = True
815
+ if meta.get("discovery_error"):
816
+ hit = True
817
+ if hit:
818
+ signal[case.id] = True
819
+ return signal
820
+
821
+
822
+ def _keywords(text: str) -> set[str]:
823
+ """Significant (4+ char) lowercase word tokens of *text*."""
824
+ return set(re.findall(r"[a-z]{4,}", text.lower()))
825
+
826
+
827
+ #: Underscore / dotted names — how M3 refers to analyzers and per-case signals
828
+ #: ("perturbation_battery", "cot_faithfulness.drift_away", "noop_break_rate").
829
+ _IDENT_RE = re.compile(r"[a-z][a-z0-9]*(?:[._][a-z0-9]+)+")
830
+
831
+ #: Words that appear in nearly every signal name / test design and therefore
832
+ #: route nothing: matching on them is what sent unrelated hypotheses to
833
+ #: answer_extraction_audit.gold_in_answer_region ("answer") on causal_judgement.
834
+ _GENERIC_KW = frozenset({
835
+ "answer", "answers", "output", "outputs", "case", "cases", "rate", "rates",
836
+ "final", "score", "scores", "model", "prompt", "prompts", "label", "labels",
837
+ "signal", "signals", "text", "value", "values", "step", "steps", "chain",
838
+ "token", "tokens", "index", "count", "number", "numbers", "flag", "flags",
839
+ "match", "matched", "matches", "held", "split", "test", "tests", "control",
840
+ "cases", "correct", "incorrect", "true", "false", "fail", "fails", "failed",
841
+ "pass", "passed", "with", "without", "that", "this", "from", "into", "only",
842
+ "each", "every", "after", "before", "versus", "against", "across", "between",
843
+ "using", "under", "over", "more", "less", "most", "least", "some", "none",
844
+ "then", "than", "when", "while", "should", "would", "could", "must",
845
+ "still", "also", "both", "same", "other", "their", "there", "these", "those",
846
+ "which", "where", "what", "does", "will", "have", "been", "were", "they",
847
+ "them", "much", "many", "very", "just", "like", "such", "here", "mean",
848
+ "median", "batch", "data", "audit", "battery", "probe", "probes",
849
+ })
850
+
851
+
852
+ def _identifiers(text: str) -> set[str]:
853
+ """Underscore / dotted identifiers in *text*, lower-cased (backticks ignored)."""
854
+ return {m.group(0) for m in _IDENT_RE.finditer(str(text or "").lower())}
855
+
856
+
857
+ def _signal_keywords(text: str) -> set[str]:
858
+ """Non-generic 4+ letter words of *text* (for word-level routing)."""
859
+ return _keywords(text) - _GENERIC_KW
860
+
861
+
862
+ def _tool_signal(r: "StatsToolResult") -> str:
863
+ cfg = getattr(r, "config", None) or {}
864
+ det = getattr(r, "details", None) or {}
865
+ return str(cfg.get("signal") or det.get("signal") or "").lower()
866
+
867
+
868
+ # Direction cues. Explicit "<higher|lower> on/in FAIL" phrases are read first
869
+ # (the M3 TEST line asks for them); otherwise cue words within a short window
870
+ # after the signal's mention ("sits at 0", "= 0", "absent", "low" -> LOWER on
871
+ # failures; "= 1", "elevated", "present", "high" -> HIGHER). Both / neither ->
872
+ # None, and the classic reading (signal marks the failure: effect > 0 supports)
873
+ # applies.
874
+ _DIR_EXPLICIT_HIGH = re.compile(
875
+ r"\b(?:higher|high|elevated|greater|larger|more|raised|increased|present)\b"
876
+ r"[^.;\n]{0,40}?\b(?:on|in|for|among|across)\s+(?:the\s+)?(?:fail\w*|failing|failures?)"
877
+ r"|\b(?:lower|low|absent|smaller|less|reduced|decreased|missing|zero)\b"
878
+ r"[^.;\n]{0,40}?\b(?:on|in|for|among|across)\s+(?:the\s+)?(?:pass\w*|passing|successes?)")
879
+ _DIR_EXPLICIT_LOW = re.compile(
880
+ r"\b(?:lower|low|absent|smaller|less|reduced|decreased|missing|zero)\b"
881
+ r"[^.;\n]{0,40}?\b(?:on|in|for|among|across)\s+(?:the\s+)?(?:fail\w*|failing|failures?)"
882
+ r"|\b(?:higher|high|elevated|greater|larger|more|raised|increased|present)\b"
883
+ r"[^.;\n]{0,40}?\b(?:on|in|for|among|across)\s+(?:the\s+)?(?:pass\w*|passing|successes?)")
884
+ _DIR_CUE_LOW = re.compile(
885
+ r"(?:^|[\s(`])(?:=|==|is|are|at|of|sits?\s+at|sit\s+at|stuck\s+at|stays?\s+at|"
886
+ r"equals?|reads?|drops?\s+to|falls?\s+to)\s*0(?:\.0+)?(?![.\d%])"
887
+ r"|\b(?:low|lower|absent|zero|null|missing|lacking|drops?|falls?|"
888
+ r"decreas\w*|never\s+fires?|does\s+not\s+fire|=\s*false)\b")
889
+ _DIR_CUE_HIGH = re.compile(
890
+ r"(?:^|[\s(`])(?:=|==|is|are|at|of|sits?\s+at|equals?|reads?)\s*1(?:\.0+)?(?![.\d%])"
891
+ r"|\b(?:high|higher|elevated|present|raised|increas\w*|spikes?|rises?|fires?|"
892
+ r"=\s*true|dominat\w*)\b")
893
+ _DIR_WINDOW = 110
894
+ _DIR_EQ = re.compile(r"[`'\")\]]*\s*(?:==|=|is|equals?)\s*(0(?:\.0+)?|1(?:\.0+)?)(?![.\d%])(?!\s*[-–]\s*\d|\s*(?:to|vs|or)\b)")
895
+
896
+
897
+ def _expected_sign(hypothesis: "Hypothesis", signal: str) -> "int | None":
898
+ """+1 if *hypothesis* predicts *signal* HIGHER on failing cases, -1 if LOWER,
899
+ None when it says nothing legible about the direction (the caller then
900
+ falls back to the classic reading: the signal marks the failure)."""
901
+ if not signal:
902
+ return None
903
+ sig = str(signal).lower()
904
+ names = {sig}
905
+ if "." in sig:
906
+ names.add(sig.split(".", 1)[1]) # bare metric name
907
+ texts = [str(getattr(hypothesis, "test_design", "") or ""),
908
+ str(getattr(hypothesis, "statement", "") or "")]
909
+ for text in texts:
910
+ low = text.lower()
911
+ for name in sorted(names, key=len, reverse=True):
912
+ start = 0
913
+ votes_high = votes_low = 0
914
+ while True:
915
+ i = low.find(name, start)
916
+ if i < 0:
917
+ break
918
+ # whole-identifier match only (avoid "majority_correct" inside
919
+ # "majority_correctness" etc.)
920
+ before = low[i - 1] if i > 0 else " "
921
+ after_i = i + len(name)
922
+ after = low[after_i] if after_i < len(low) else " "
923
+ if before.isalnum() or before == "_" or after.isalnum() or after == "_":
924
+ start = i + 1
925
+ continue
926
+ window = low[max(0, i - 40):after_i + _DIR_WINDOW]
927
+ tail = low[after_i:after_i + _DIR_WINDOW]
928
+ # an equality right after the name ("`x` = 0", "x == 1", "x=0")
929
+ # is decisive on its own and must not be diluted by a second
930
+ # identifier's equality further along the sentence
931
+ near = _DIR_EQ.match(low[after_i:after_i + 16])
932
+ if near:
933
+ if near.group(1).startswith("0"):
934
+ votes_low += 1
935
+ else:
936
+ votes_high += 1
937
+ elif _DIR_EXPLICIT_HIGH.search(window):
938
+ votes_high += 1
939
+ elif _DIR_EXPLICIT_LOW.search(window):
940
+ votes_low += 1
941
+ else:
942
+ lo = bool(_DIR_CUE_LOW.search(tail))
943
+ hi = bool(_DIR_CUE_HIGH.search(tail))
944
+ if lo and not hi:
945
+ votes_low += 1
946
+ elif hi and not lo:
947
+ votes_high += 1
948
+ start = after_i
949
+ if votes_high and not votes_low:
950
+ return 1
951
+ if votes_low and not votes_high:
952
+ return -1
953
+ if votes_high and votes_low:
954
+ return None
955
+ return None
956
+
957
+
958
+ def _tool_ids(r: "StatsToolResult", *, level: str = "signal") -> set[str]:
959
+ """Identifiers a tool result answers to.
960
+
961
+ ``level="signal"``: the full ``analyzer.signal`` key, the bare signal name
962
+ and the strategy names; ``level="analyzer"``: the analyzer prefix only.
963
+ """
964
+ sig = _tool_signal(r)
965
+ cfg = getattr(r, "config", None) or {}
966
+ ids: set[str] = set()
967
+ if level == "analyzer":
968
+ if sig:
969
+ ids.add(sig.split(".", 1)[0])
970
+ return ids
971
+ if sig:
972
+ ids.add(sig)
973
+ parts = sig.split(".", 1)
974
+ if len(parts) > 1 and parts[1]:
975
+ ids.add(parts[1])
976
+ for strategy in cfg.get("strategies", []) or []:
977
+ ids.add(str(strategy).lower())
978
+ return ids
979
+
980
+
981
+ def _tool_keywords(r: "StatsToolResult") -> set[str]:
982
+ """Non-generic words of the tool's signal key + strategies (NOT the tool name)."""
983
+ cfg = getattr(r, "config", None) or {}
984
+ words = " ".join([_tool_signal(r)] + [str(x) for x in (cfg.get("strategies", []) or [])])
985
+ return _signal_keywords(words.replace("_", " ").replace(".", " "))
986
+
987
+
988
+ def _confidence_from_stat(
989
+ effect: float | None,
990
+ ci: tuple[float, float] | None,
991
+ e_value: float | None,
992
+ underpowered: bool,
993
+ alpha: float = 0.05,
994
+ ) -> float:
995
+ """Map a rigorous statistical verdict to a confidence in [0, 1].
996
+
997
+ Priority: an e-value (anytime-valid) over a CI. An e-value at the rejection
998
+ threshold ``1/alpha`` maps to 1.0; a CI that excludes 0 scales by how far it
999
+ sits from 0 relative to its width. ``underpowered`` halves the score.
1000
+ """
1001
+ c = 0.0
1002
+ if e_value is not None:
1003
+ c = min(1.0, e_value * alpha) # e_value / (1/alpha)
1004
+ elif ci is not None:
1005
+ lo, hi = ci
1006
+ width = (hi - lo) or 1e-9
1007
+ if lo <= 0 <= hi:
1008
+ # CI includes 0 → not significant; small, capped contribution.
1009
+ c = min(0.49, abs(effect or 0.0) / width * 0.5)
1010
+ else:
1011
+ c = min(1.0, abs(effect or 0.0) / (abs(effect or 0.0) + width))
1012
+ if underpowered:
1013
+ c *= 0.5
1014
+ return round(c, 3)