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
evalrx/contract/m2.py ADDED
@@ -0,0 +1,317 @@
1
+ """M2 — analysis: join measurements to outcomes, test which signals track failure.
2
+
3
+ M2 answers *what correlates*, never *why*. That boundary is load-bearing: an
4
+ in-sample association rendered with the same language as a corrected verdict is
5
+ the single most damaging mistake this pipeline can make, so the descriptive and
6
+ confirmatory states are distinguishable here at the type level
7
+ (:attr:`StatsReportWire.descriptive_only`), not by convention.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Literal
13
+
14
+ from pydantic import Field, field_validator, model_validator
15
+
16
+ from evalrx.contract.common import (
17
+ ArtifactRef,
18
+ CaseBatchRef,
19
+ ExternalRef,
20
+ JoinReport,
21
+ StageEnvelope,
22
+ WireModel,
23
+ )
24
+ from evalrx.contract.m1 import ProtocolWire
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Input
28
+ # ---------------------------------------------------------------------------
29
+
30
+ class AnalysisInput(WireModel):
31
+ """M2 needs BOTH halves of the join: M1's numbers and the cases' labels.
32
+
33
+ ``data`` is not optional in practice — without labels there is nothing to
34
+ correlate against and M2 degrades to threshold rules only.
35
+ """
36
+
37
+ results_ref: str = Field(description="Path to the M1 ProbeOutput this analyzes.")
38
+ data: CaseBatchRef
39
+ model_name: str
40
+ protocol: ProtocolWire | None = None
41
+ confirmatory: bool = Field(
42
+ default=True,
43
+ description="False defers the e-BH validity verdict to the confirm phase; "
44
+ "the report then carries effect sizes and charts but NO reject decision.",
45
+ )
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Output
50
+ # ---------------------------------------------------------------------------
51
+
52
+ class AnalysisFindingWire(WireModel):
53
+ """One threshold-rule violation. The no-judge baseline signal."""
54
+
55
+ analyzer: str
56
+ metric: str
57
+ value: float
58
+ threshold: float
59
+ direction: Literal["above", "below"]
60
+ severity: Literal["high", "medium", "low"]
61
+ message: str
62
+
63
+
64
+ class StatsToolResultWire(WireModel):
65
+ """One statistical test's verdict.
66
+
67
+ ``reject`` is the RAW, uncorrected decision and must never drive a verdict on
68
+ its own. ``fdr_corrected`` is the family-level decision after multiplicity
69
+ control and is the only field a downstream verdict may read. They are kept as
70
+ separate fields precisely because collapsing them once let a p=0.14 result on
71
+ n=3 "support" a hypothesis.
72
+ """
73
+
74
+ tool: str = Field(description="e.g. signal_label_assoc / mcnemar_evalue / bootstrap_diff / rank_corr")
75
+ measured: str | None = Field(
76
+ default=None,
77
+ description="WHAT this result is about, in words, and unique within the report — "
78
+ "the label a chart axis or a table row should carry.\n\n"
79
+ "`tool` is the procedure, not the subject, and using it as a label "
80
+ "put two rows reading 'Mcnemar evalue' on a chart with no way to "
81
+ "tell them apart. `config['signal']` is the subject but is a machine "
82
+ "name, and is absent on paired tools entirely. Neither can label a "
83
+ "row on its own, so the producer states the label here.",
84
+ )
85
+ means: str | None = Field(
86
+ default=None,
87
+ description="What the measured quantity IS, in one sentence, taken from the "
88
+ "producing analyzer's `signal_docs`. Null means the analyzer did not "
89
+ "document that metric — render it as undocumented rather than "
90
+ "inventing a gloss, because a plausible wrong explanation of a "
91
+ "statistic is worse than an admitted missing one.",
92
+ )
93
+ config: dict[str, Any] = Field(default_factory=dict, description="Includes 'signal' — the routing key.")
94
+ ok: bool
95
+ error: str | None = None
96
+
97
+ effect: float | None = None
98
+ ci: tuple[float, float] | None = None
99
+ p_value: float | None = None
100
+ e_value: float | None = None
101
+ underpowered: bool = False
102
+
103
+ reject: bool = Field(default=False, description="RAW per-test decision. Not a verdict.")
104
+ fdr_corrected: bool | None = Field(
105
+ default=None,
106
+ description="Family-level decision after correction. None = correction not yet run "
107
+ "(deferred descriptive phase) — which is NOT the same as False.",
108
+ )
109
+ correction_method: str | None = Field(default=None, description="e.g. 'bh' / 'ebh'.")
110
+ correction_family: str | None = None
111
+ analysis_key: str | None = Field(
112
+ default=None, description="Stable per-result key, e.g. 'signal_label_assoc:attention.entropy'. "
113
+ "Required for correction: one tool NAME covers many tests."
114
+ )
115
+
116
+ raw_reject: bool | None = Field(
117
+ default=None, description="Pre-correction decision, kept for audit alongside `reject`."
118
+ )
119
+ figure_path: str | None = None
120
+
121
+ n_signal: int | None = Field(default=None, description="Cases in the signal-present group.")
122
+ n_control: int | None = Field(default=None, description="Cases in the signal-absent group.")
123
+ n_measured: int | None = Field(
124
+ default=None,
125
+ description="Cases the producing analyzer actually measured. Analyzers measure "
126
+ "every case unless capped (an analyzer's max_cases or the probe "
127
+ "agent's max_cases_per_analyzer), so compare this with the batch size.",
128
+ )
129
+ n_imputed_absent: int | None = Field(
130
+ default=None,
131
+ description="Labeled cases with no measurement that were counted as signal-ABSENT. "
132
+ "Valid for a genuinely sparse flag that only lists where it fired; invalid "
133
+ "when the analyzer simply never looked. Nothing in the value distinguishes "
134
+ "the two, so the count must travel with the result.",
135
+ )
136
+
137
+ summary: str = ""
138
+ details: dict[str, Any] = Field(
139
+ default_factory=dict, description="fail rates / permutation_p / CI internals / ..."
140
+ )
141
+
142
+ @property
143
+ def imputation_share(self) -> float | None:
144
+ """Share of the tested sample that was never measured.
145
+
146
+ Observed live (bbh_tracking7): ``step_rollout_value.recoverable`` was
147
+ measured on 8 cases, tested on 125, and survived e-BH — 94% of its
148
+ control group is imputed absence. A reader cannot see that from the
149
+ effect size, the CI, or the corrected flag.
150
+ """
151
+ total = (self.n_signal or 0) + (self.n_control or 0)
152
+ if not total or self.n_imputed_absent is None:
153
+ return None
154
+ return self.n_imputed_absent / total
155
+
156
+ def is_mostly_imputed(self, threshold: float = 0.5) -> bool:
157
+ share = self.imputation_share
158
+ return share is not None and share > threshold
159
+
160
+ def is_decisive(self, report_survivors: set[str] | None = None) -> bool:
161
+ """Whether this result may decide a hypothesis's direction.
162
+
163
+ Mirrors the M4 gate: raw rejection is necessary but never sufficient.
164
+ """
165
+ if not self.reject:
166
+ return False
167
+ if self.correction_method:
168
+ return bool(self.fdr_corrected)
169
+ if self.e_value is not None:
170
+ return bool(self.analysis_key) and self.analysis_key in (report_survivors or set())
171
+ return True
172
+
173
+
174
+ #: Legacy spellings this contract shipped before it was checked against the
175
+ #: producer. ``evalrx.stats.multiplicity`` writes the hyphenated names and
176
+ #: has a fourth value the contract had no member for.
177
+ _LEGACY_CORRECTION_METHODS = {"ebh": "e-BH", "bh": "BH"}
178
+
179
+
180
+ class CorrectedRejections(WireModel):
181
+ """Family-level multiplicity control across every tested signal.
182
+
183
+ Testing 40 signals at alpha=0.05 yields ~2 "significant" results from luck
184
+ alone. Without this the screen is not a screen.
185
+
186
+ ``mixed-BH/e-BH`` is its own member rather than being folded into either
187
+ neighbour: it says the family contained both p-values and e-values, which
188
+ carry different validity guarantees, and a reader weighing the verdict needs
189
+ to know that the guarantee is the weaker of the two.
190
+ """
191
+
192
+ method: Literal["e-BH", "BH", "mixed-BH/e-BH", "none"] = "none"
193
+ alpha: float = 0.05
194
+ deferred: bool = Field(
195
+ default=False, description="True while the analysis phase withholds the verdict."
196
+ )
197
+ n_tested: int = 0
198
+ rejected_result_keys: list[str] = Field(
199
+ default_factory=list, description="analysis_key values that survived. The source of truth."
200
+ )
201
+
202
+ @field_validator("method", mode="before")
203
+ @classmethod
204
+ def _normalise_method(cls, v: Any) -> Any:
205
+ if isinstance(v, str):
206
+ return _LEGACY_CORRECTION_METHODS.get(v.lower(), v)
207
+ return v
208
+
209
+
210
+ class StatsReportWire(StageEnvelope):
211
+ """M2's serialized report.
212
+
213
+ Two field groups with different authority: everything above
214
+ ``descriptive_only`` is computed by code and is authoritative; ``conclusion``
215
+ and ``evidence_chain`` are written by the judge and are presentation only.
216
+ Empty narrative is valid and must never be backfilled with an invented
217
+ summary. The LLM may not write a number, and keeping the groups visibly
218
+ apart is what makes that reviewable.
219
+
220
+ Absent by design: the judge's prompt and raw response (already
221
+ ``prompts/c<cycle>_m2_analysis.{prompt,response}.txt``), the tool-selection
222
+ plan (already ``artifacts/c<cycle>_m2_stats_plan.json``), the model name and
223
+ protocol (both in the run config and in :class:`AnalysisInput`), and the
224
+ threshold-rule narrative (a rendering of ``findings``).
225
+ """
226
+
227
+ findings: list[AnalysisFindingWire] = Field(default_factory=list)
228
+ stats_tool: Literal["threshold_rules", "llm_guided", "selected_tools", "generated"] = "threshold_rules"
229
+ stats_results: list[StatsToolResultWire] | ExternalRef = Field(default_factory=list)
230
+ corrected_rejections: CorrectedRejections | ExternalRef = Field(default_factory=CorrectedRejections)
231
+ figures: list[ArtifactRef] = Field(default_factory=list)
232
+ descriptive_only: bool = Field(
233
+ default=False,
234
+ description="True = effect sizes and charts only, e-BH deferred. A reader MUST NOT "
235
+ "render supported/rejected language while this is true.",
236
+ )
237
+
238
+ conclusion: str = Field(default="", description="Judge-written. Presentation only.")
239
+ evidence_chain: list[str] = Field(default_factory=list, description="Judge-written derivation.")
240
+ llm_fallback_reason: str = Field(
241
+ default="",
242
+ description="Non-empty only when the judge path was attempted AND raised. Not recoverable "
243
+ "from the prompt files: a fallback still writes both of them.",
244
+ )
245
+
246
+ raw_results_ref: str | None = Field(
247
+ default=None,
248
+ description="Path to the M1 ProbeOutput. The in-memory report carries the Result objects "
249
+ "themselves, but to_dict() drops them — M4/M5 still need per-case signals, so "
250
+ "the serialized contract carries a reference instead of pretending they survive.",
251
+ )
252
+ joins: list[JoinReport] = Field(
253
+ default_factory=list, description="Signal<->label join health. Zero coverage is a plumbing failure."
254
+ )
255
+
256
+ @property
257
+ def severity(self) -> Literal["high", "medium", "low", "none"]:
258
+ """Worst flagged finding. Derived — a stored copy can contradict the list."""
259
+ for level in ("high", "medium", "low"):
260
+ if any(f.severity == level for f in self.findings):
261
+ return level # type: ignore[return-value]
262
+ return "none"
263
+
264
+ @model_validator(mode="after")
265
+ def _measured_labels_are_distinct(self) -> "StatsReportWire":
266
+ """Two rows labelled the same are two rows a reader cannot tell apart.
267
+
268
+ Deduplicated rather than rejected: a colliding label is a presentation
269
+ fault, and dropping the whole M2 payload over one would lose the
270
+ statistics too. The suffix is the tool, then an ordinal — enough to
271
+ separate them without inventing meaning.
272
+ """
273
+ rows = self.stats_results
274
+ if not isinstance(rows, list):
275
+ return self
276
+ seen: dict[str, int] = {}
277
+ for r in rows:
278
+ if not r.measured:
279
+ continue
280
+ if r.measured in seen:
281
+ seen[r.measured] += 1
282
+ r.measured = f"{r.measured} ({r.tool}, {seen[r.measured]})"
283
+ else:
284
+ seen[r.measured] = 1
285
+ return self
286
+
287
+ @model_validator(mode="after")
288
+ def _descriptive_has_no_verdict(self) -> "StatsReportWire":
289
+ corr = self.corrected_rejections
290
+ if self.descriptive_only and isinstance(corr, CorrectedRejections):
291
+ if corr.rejected_result_keys and not corr.deferred:
292
+ raise ValueError(
293
+ "descriptive_only=True but corrected_rejections carries survivors; "
294
+ "a descriptive report must not ship a validity verdict"
295
+ )
296
+ return self
297
+
298
+
299
+ class ExploreContextWire(WireModel):
300
+ """Descriptive EDA notes from the optional explore side-path.
301
+
302
+ Never authoritative. It enters the M3 prompt and the dashboard and nothing
303
+ else — not M2's tested family, not M4, not the fix gate. It shapes WHICH
304
+ hypotheses get proposed, never WHETHER one is true.
305
+ """
306
+
307
+ observations: list[str] = Field(default_factory=list)
308
+ charts: list[dict[str, Any]] = Field(default_factory=list)
309
+ caveats: list[str] = Field(default_factory=list)
310
+ source: str = "lambda_explorer"
311
+ authoritative: Literal[False] = False
312
+
313
+
314
+ __all__ = [
315
+ "AnalysisInput", "AnalysisFindingWire", "StatsToolResultWire",
316
+ "CorrectedRejections", "StatsReportWire", "ExploreContextWire",
317
+ ]
evalrx/contract/m3.py ADDED
@@ -0,0 +1,165 @@
1
+ """M3 — diagnosis: turn correlations into falsifiable mechanism claims.
2
+
3
+ This is the one stage where the LLM produces the payload rather than the prose.
4
+ That is exactly why its output must be machine-checkable: a hypothesis carries a
5
+ ``test_design`` naming the signal that would confirm or refute it, so M4 can
6
+ route deterministically instead of re-reading the sentence.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from typing import Any
13
+
14
+ from pydantic import Field, field_validator
15
+
16
+ from evalrx.contract.common import HypothesisStatus, StageEnvelope, WireModel
17
+ from evalrx.contract.m2 import ExploreContextWire
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Input
21
+ # ---------------------------------------------------------------------------
22
+
23
+ class DiagnosisInput(WireModel):
24
+ """M3 reads M2's report — both the numbers and the narrative."""
25
+
26
+ analysis_ref: str = Field(description="Path to the M2 StatsReportWire.")
27
+ prior_cycles: list[dict[str, Any]] = Field(
28
+ default_factory=list, description="Summaries of earlier cycles, so the judge avoids re-proposing."
29
+ )
30
+ explore_context: ExploreContextWire | None = None
31
+ failure_modes: list[str] = Field(
32
+ default_factory=list, description="Descriptive-only clustering tags."
33
+ )
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Output
38
+ # ---------------------------------------------------------------------------
39
+
40
+ #: A ``<analyzer>.<metric>`` reference — the form M4 resolves against M2's signals.
41
+ _SIGNAL_REF = re.compile(r"\b[a-z_][a-z0-9_]*\.[a-z_][a-z0-9_]*\b")
42
+
43
+ #: Non-signal test designs M4 knows how to route. Extend this together with the
44
+ #: router; a directive listed here and unhandled there is worse than a rejection.
45
+ TEST_DESIGN_DIRECTIVES = frozenset({
46
+ "prompt_contrast",
47
+ "analyzer_params",
48
+ "strategy_contrast",
49
+ "paired_rerun",
50
+ })
51
+
52
+
53
+ class HypothesisWire(WireModel):
54
+ """One falsifiable claim about *why* the model fails.
55
+
56
+ ``statement`` is the mechanism; ``test_design`` is the machine-readable
57
+ commitment to how it could be wrong. A hypothesis without the latter cannot
58
+ be tested and is therefore not a hypothesis — M4 has nothing to route on and
59
+ M1 has nothing to re-probe.
60
+ """
61
+
62
+ id: str = Field(min_length=1, description="Stable id. Never slugify the statement and assume uniqueness.")
63
+ statement: str = Field(min_length=10, description="Technical mechanism claim.")
64
+ plain_statement: str = Field(
65
+ default="",
66
+ description="The SAME claim in one everyday sentence, for a reader who runs "
67
+ "evaluations and does no statistics. Not a second, softer claim — a "
68
+ "second rendering of this one, checked host-side against a jargon "
69
+ "list before it is accepted (see analysis.plain_language). Empty "
70
+ "when the producer wrote none; a consumer should then show "
71
+ "`statement` rather than paraphrase it into something the run "
72
+ "never said.",
73
+ )
74
+ target_model: str
75
+ predicted_failure_mode: str = Field(min_length=2)
76
+ test_design: str = Field(
77
+ default="",
78
+ description="Signal or contrast that would decide this, e.g. 'attention.image_token_ratio' "
79
+ "or 'prompt_contrast describe_first'. M4 routes on it; M1 re-probes on it. "
80
+ "EMPTY means the judge proposed none — see `is_routable`.",
81
+ )
82
+ status: HypothesisStatus = HypothesisStatus.PROPOSED
83
+ parent_id: str | None = Field(default=None, description="Set when mutated from an earlier hypothesis.")
84
+ metadata: dict[str, Any] = Field(default_factory=dict)
85
+
86
+ @property
87
+ def is_routable(self) -> bool:
88
+ """Whether M4 can resolve this design against measured signals.
89
+
90
+ Mirrors what ``hypothesis_tester`` actually extracts — an identifier
91
+ anywhere in the text, or one of the directives — rather than demanding a
92
+ shape. The strict version required the string to BE a bare
93
+ ``<analyzer>.<metric>`` or to START with a directive, and a strong judge
94
+ does not write like that: on the Music-AVQA run, Opus at high effort
95
+ produced three designs that named their analyzers and metrics inside a
96
+ paragraph of interventional protocol. M4 routed all three
97
+ (``routed_by="test_design"``, two at INTERVENTION grade) and the contract
98
+ rejected the whole M3 payload for the formatting.
99
+
100
+ A validator stricter than the consumer it protects does not prevent
101
+ anything; it just discards good work. So routability is now REPORTED, and
102
+ the three states stay distinguishable: empty (no test proposed),
103
+ non-empty but nothing resolvable (a human can act on it, M4 cannot), and
104
+ routable.
105
+ """
106
+ text = self.test_design.strip()
107
+ if not text:
108
+ return False
109
+ if _SIGNAL_REF.search(text.lower()):
110
+ return True
111
+ return any(d in text for d in TEST_DESIGN_DIRECTIVES)
112
+
113
+ @property
114
+ def is_proposed(self) -> bool:
115
+ """Whether the judge proposed a test at all, routable or not."""
116
+ return bool(self.test_design.strip())
117
+
118
+
119
+ class DiagnosisOutput(StageEnvelope):
120
+ """M3's serialized output: the hypotheses, and nothing that is already on disk.
121
+
122
+ Zero hypotheses is a completed, empty result (state EMPTY or ABSTAINED) — not
123
+ an error. Rendering it red teaches readers to distrust a correct abstention.
124
+
125
+ Judge I/O is deliberately absent. M3 is the one stage an LLM writes the
126
+ payload for, so its prompt and verbatim response do have to be recoverable —
127
+ but they are already persisted per cycle as
128
+ ``prompts/c<cycle>_m3_diagnosis.{prompt,response}.txt``, and the findings the
129
+ judge was shown are the M2 report named by :attr:`DiagnosisInput.analysis_ref`.
130
+ Carrying either inline here would be a second copy that can disagree with the
131
+ first.
132
+ """
133
+
134
+ hypotheses: list[HypothesisWire] = Field(default_factory=list)
135
+
136
+ @property
137
+ def untestable(self) -> list[str]:
138
+ """Ids of hypotheses carrying no test_design at all.
139
+
140
+ These come back INCONCLUSIVE no matter how much evidence the next cycle
141
+ gathers, because nothing was ever named that could decide them.
142
+ """
143
+ return [h.id for h in self.hypotheses if not h.is_proposed]
144
+
145
+ @property
146
+ def unroutable(self) -> list[str]:
147
+ """Ids whose design a human can act on but M4 cannot resolve.
148
+
149
+ Distinct from :attr:`untestable`: a test WAS proposed, it just names no
150
+ measured signal, so it is work for the next M1 cycle rather than a claim
151
+ with no falsifier. Collapsing the two would report a real experimental
152
+ plan as an empty one.
153
+ """
154
+ return [h.id for h in self.hypotheses if h.is_proposed and not h.is_routable]
155
+
156
+ @field_validator("hypotheses")
157
+ @classmethod
158
+ def _ids_unique(cls, v: list[HypothesisWire]) -> list[HypothesisWire]:
159
+ ids = [h.id for h in v]
160
+ if len(set(ids)) != len(ids):
161
+ raise ValueError("duplicate hypothesis ids make M4 verdicts ambiguous")
162
+ return v
163
+
164
+
165
+ __all__ = ["TEST_DESIGN_DIRECTIVES", "DiagnosisInput", "HypothesisWire", "DiagnosisOutput"]
evalrx/contract/m4.py ADDED
@@ -0,0 +1,130 @@
1
+ """M4 — hypothesis testing: adjudicate M3's claims against corrected statistics.
2
+
3
+ M4 mostly does not compute. It routes a hypothesis's ``test_design`` to the M2
4
+ results that bear on it, admits only results that survived multiplicity
5
+ correction, reads the sign of the effect, and applies a second, independent gate:
6
+ is this claim even about what the user asked to investigate.
7
+
8
+ SUPPORTED requires BOTH gates. Either alone is a different, weaker statement.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any, Literal
14
+
15
+ from pydantic import Field, model_validator
16
+
17
+ from evalrx.contract.common import (
18
+ CaseBatchRef,
19
+ EvidenceGrade,
20
+ HypothesisStatus,
21
+ StageEnvelope,
22
+ WireModel,
23
+ )
24
+ from evalrx.contract.m1 import ProtocolWire
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Input
28
+ # ---------------------------------------------------------------------------
29
+
30
+ class HypothesisTestInput(WireModel):
31
+ """M4's inputs.
32
+
33
+ ``split`` is recorded because it changes what the verdict MEANS: testing on
34
+ the same cases the hypotheses were generated from is an internal consistency
35
+ check, not out-of-sample evidence. A reader cannot infer this from the numbers
36
+ and must not be left to guess.
37
+ """
38
+
39
+ hypotheses_ref: str = Field(description="Path to the M3 DiagnosisOutput.")
40
+ stats_report_ref: str = Field(description="Path to the M2 StatsReportWire (post-correction).")
41
+ data: CaseBatchRef
42
+ protocol: ProtocolWire | None = Field(
43
+ default=None, description="None => every hypothesis is assumed protocol-consistent."
44
+ )
45
+ split: Literal["explore", "confirm", "all"] = Field(
46
+ default="explore",
47
+ description="Which partition M4 actually ran on. 'explore' means the hypotheses were "
48
+ "generated on these same cases — an in-sample adjudication, not held-out.",
49
+ )
50
+ alpha: float = 0.05
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Output
55
+ # ---------------------------------------------------------------------------
56
+
57
+ class TestEvidence(WireModel):
58
+ """The statistics behind one verdict, with its provenance."""
59
+
60
+ source: Literal["m2_stats_results", "fallback_per_case", "none"] = "none"
61
+ chosen_tool: str | None = None
62
+ routed_by: str = Field(default="", description="How test_design resolved to this result.")
63
+ consulted_tools: list[str] = Field(default_factory=list)
64
+ ci: tuple[float, float] | None = None
65
+ e_value: float | None = None
66
+ fdr_corrected: bool | None = Field(default=None, description="The field that actually decided this.")
67
+ underpowered: bool = False
68
+ n_signal: int | None = None
69
+ n_control: int | None = None
70
+ fail_rate_signal: float | None = None
71
+ fail_rate_control: float | None = None
72
+ details: dict[str, Any] = Field(default_factory=dict)
73
+
74
+
75
+ class HypothesisTestResultWire(WireModel):
76
+ """One hypothesis's verdict.
77
+
78
+ ``effect_size`` is signal-group minus control-group fail rate, so its SIGN is
79
+ the verdict's direction: positive supports the claim, negative refutes it.
80
+ A refutation is a real, useful outcome — not a failure to conclude.
81
+ """
82
+
83
+ hypothesis_id: str = Field(min_length=1, description="Points into the M3 DiagnosisOutput.")
84
+ status: HypothesisStatus
85
+ test_name: str = Field(description="e.g. 'signal_label_assoc' / 'fail_rate_comparison'.")
86
+ effect_size: float | None = None
87
+ confidence: float = Field(ge=0.0, le=1.0, description="Geometric mean of evidence gap, "
88
+ "sample adequacy, control cleanliness.")
89
+ evidence_grade: EvidenceGrade = EvidenceGrade.OBSERVATIONAL
90
+ is_consistent_with_protocol: bool
91
+ verdict: str = Field(description="One-line natural-language summary.")
92
+ evidence: TestEvidence = Field(default_factory=TestEvidence)
93
+
94
+ @model_validator(mode="after")
95
+ def _supported_needs_both_gates(self) -> "HypothesisTestResultWire":
96
+ # The whole point of the second gate: a statistically real claim about
97
+ # something the user never asked about is not a supported diagnosis.
98
+ if self.status == HypothesisStatus.SUPPORTED and not self.is_consistent_with_protocol:
99
+ raise ValueError(
100
+ "SUPPORTED requires both the statistical test AND protocol consistency; "
101
+ "mark it INCONCLUSIVE and record the inconsistency in `verdict`"
102
+ )
103
+ if self.status == HypothesisStatus.SUPPORTED and self.evidence_grade == EvidenceGrade.NONE:
104
+ raise ValueError("SUPPORTED with evidence_grade=none has nothing backing it")
105
+ return self
106
+
107
+
108
+ class HypothesisTestOutput(StageEnvelope):
109
+ """M4's serialized output — one result per hypothesis, plus the loop gate."""
110
+
111
+ split: Literal["explore", "confirm", "all"] = "explore"
112
+ results: list[HypothesisTestResultWire] = Field(default_factory=list)
113
+ stopping_criteria_met: bool = Field(
114
+ default=False,
115
+ description="Whether the loop may stop on this cycle's evidence. Not simply "
116
+ "`verified() != []` — the depth-tiered rule also weighs evidence grade.",
117
+ )
118
+
119
+ def verified(self) -> list[str]:
120
+ """Hypothesis ids M5 may operate on, strongest first.
121
+
122
+ Derived, never stored: a persisted copy can disagree with `results`, and
123
+ then nothing tells a reader which one the loop actually acted on.
124
+ """
125
+ ok = [r for r in self.results
126
+ if r.status == HypothesisStatus.SUPPORTED and r.is_consistent_with_protocol]
127
+ return [r.hypothesis_id for r in sorted(ok, key=lambda r: -r.confidence)]
128
+
129
+
130
+ __all__ = ["HypothesisTestInput", "TestEvidence", "HypothesisTestResultWire", "HypothesisTestOutput"]