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,261 @@
1
+ """Stats-tool selection and execution for M2 exploratory analysis.
2
+
3
+ The goal is to make M2 more than a narrative wrapper around threshold rules:
4
+ given raw analyzer results, this module selects small, reproducible analysis
5
+ tools and returns quantitative tables plus visualization specs. The specs are
6
+ JSON-safe so they can be logged, rendered later, or handed to an LLM without
7
+ shipping heavy arrays.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+ from statistics import mean
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ from evalrx.core.case import CaseBatch, Label
17
+
18
+ if TYPE_CHECKING:
19
+ from evalrx.core.result import Result
20
+ from evalrx.eval_agent.stages.protocol import ExperimentProtocol
21
+
22
+
23
+ @dataclass
24
+ class StatsToolResult:
25
+ """Output from one selected stats tool."""
26
+
27
+ name: str
28
+ rationale: str
29
+ metrics: dict[str, Any] = field(default_factory=dict)
30
+ tables: dict[str, list[dict[str, Any]]] = field(default_factory=dict)
31
+ visualizations: list[dict[str, Any]] = field(default_factory=list)
32
+ conclusion: str = ""
33
+
34
+ def to_dict(self) -> dict[str, Any]:
35
+ return {
36
+ "name": self.name,
37
+ "rationale": self.rationale,
38
+ "metrics": self.metrics,
39
+ "tables": self.tables,
40
+ "visualizations": self.visualizations,
41
+ "conclusion": self.conclusion,
42
+ }
43
+
44
+
45
+ class StatsToolAgent:
46
+ """Select and run built-in exploratory stats tools.
47
+
48
+ This is the deterministic baseline for the future self-extending path where
49
+ an agent can generate new stats code. It intentionally keeps tool outputs
50
+ compact and JSON-serializable.
51
+ """
52
+
53
+ def __init__(self, max_tools: int = 3) -> None:
54
+ self.max_tools = max_tools
55
+
56
+ def analyze(
57
+ self,
58
+ results: dict[str, "Result"],
59
+ *,
60
+ protocol: "ExperimentProtocol | None" = None,
61
+ ) -> list[StatsToolResult]:
62
+ selected = self.select(results, protocol=protocol)
63
+ outputs: list[StatsToolResult] = []
64
+ for name in selected:
65
+ if name == "scalar_summary":
66
+ out = _scalar_summary(results)
67
+ elif name == "per_case_signal_label_association":
68
+ out = _per_case_signal_label_association(results)
69
+ else:
70
+ continue
71
+ if out is not None:
72
+ outputs.append(out)
73
+ return outputs
74
+
75
+ def select(
76
+ self,
77
+ results: dict[str, "Result"],
78
+ *,
79
+ protocol: "ExperimentProtocol | None" = None,
80
+ ) -> list[str]:
81
+ names: list[str] = []
82
+ if _has_numeric_scalars(results):
83
+ names.append("scalar_summary")
84
+ if _has_per_case_entries(results):
85
+ names.append("per_case_signal_label_association")
86
+ return names[: self.max_tools]
87
+
88
+
89
+ def _has_numeric_scalars(results: dict[str, "Result"]) -> bool:
90
+ for result in results.values():
91
+ for key, value in result.findings.items():
92
+ if key == "per_case":
93
+ continue
94
+ if isinstance(value, (int, float, bool)):
95
+ return True
96
+ return False
97
+
98
+
99
+ def _has_per_case_entries(results: dict[str, "Result"]) -> bool:
100
+ return any(
101
+ isinstance(result.findings.get("per_case"), list)
102
+ and len(result.findings.get("per_case", [])) > 0
103
+ for result in results.values()
104
+ )
105
+
106
+
107
+ def _scalar_summary(results: dict[str, "Result"]) -> StatsToolResult | None:
108
+ rows: list[dict[str, Any]] = []
109
+ values: list[float] = []
110
+
111
+ for analyzer_name, result in sorted(results.items()):
112
+ for metric, raw in sorted(result.findings.items()):
113
+ if metric == "per_case" or not isinstance(raw, (int, float, bool)):
114
+ continue
115
+ value = float(raw)
116
+ values.append(value)
117
+ rows.append({
118
+ "analyzer": analyzer_name,
119
+ "metric": metric,
120
+ "value": round(value, 6),
121
+ })
122
+
123
+ if not rows:
124
+ return None
125
+
126
+ chart = {
127
+ "kind": "bar",
128
+ "title": "Analyzer scalar metrics",
129
+ "x": [f"{r['analyzer']}.{r['metric']}" for r in rows],
130
+ "y": [r["value"] for r in rows],
131
+ "y_label": "value",
132
+ }
133
+
134
+ return StatsToolResult(
135
+ name="scalar_summary",
136
+ rationale="Summarize numeric analyzer findings before hypothesis generation.",
137
+ metrics={
138
+ "n_scalar_metrics": len(rows),
139
+ "min": round(min(values), 6),
140
+ "max": round(max(values), 6),
141
+ "mean": round(mean(values), 6),
142
+ },
143
+ tables={"scalar_metrics": rows},
144
+ visualizations=[chart],
145
+ conclusion=f"Collected {len(rows)} numeric metric(s) across analyzer outputs.",
146
+ )
147
+
148
+
149
+ def _per_case_signal_label_association(
150
+ results: dict[str, "Result"],
151
+ ) -> StatsToolResult | None:
152
+ cases = _find_cases(results)
153
+ signal = _extract_signals(results)
154
+ if not signal:
155
+ return None
156
+
157
+ labeled: dict[str, bool] = {}
158
+ if cases is not None:
159
+ for case in cases:
160
+ if getattr(case, "label", None) in {Label.PASS, Label.FAIL}:
161
+ labeled[case.id] = case.label == Label.FAIL
162
+ signal.setdefault(case.id, False)
163
+
164
+ rows: list[dict[str, Any]] = []
165
+ for cid, has_signal in sorted(signal.items()):
166
+ row: dict[str, Any] = {
167
+ "case_id": cid,
168
+ "has_signal": bool(has_signal),
169
+ }
170
+ if cid in labeled:
171
+ row["label"] = "fail" if labeled[cid] else "pass"
172
+ rows.append(row)
173
+
174
+ n_signal = sum(1 for hit in signal.values() if hit)
175
+ n_no_signal = len(signal) - n_signal
176
+ metrics: dict[str, Any] = {
177
+ "n_cases_with_signal_entries": len(signal),
178
+ "n_signal": n_signal,
179
+ "n_no_signal": n_no_signal,
180
+ }
181
+
182
+ labeled_rows = [r for r in rows if "label" in r]
183
+ if labeled_rows:
184
+ signal_labels = [r["label"] == "fail" for r in labeled_rows if r["has_signal"]]
185
+ control_labels = [r["label"] == "fail" for r in labeled_rows if not r["has_signal"]]
186
+ if signal_labels:
187
+ metrics["fail_rate_signal"] = round(sum(signal_labels) / len(signal_labels), 6)
188
+ if control_labels:
189
+ metrics["fail_rate_control"] = round(sum(control_labels) / len(control_labels), 6)
190
+ if signal_labels and control_labels:
191
+ metrics["effect_size"] = round(
192
+ metrics["fail_rate_signal"] - metrics["fail_rate_control"], 6
193
+ )
194
+
195
+ chart = {
196
+ "kind": "bar",
197
+ "title": "Per-case diagnostic signal split",
198
+ "x": ["signal", "no_signal"],
199
+ "y": [n_signal, n_no_signal],
200
+ "y_label": "case count",
201
+ }
202
+ if "fail_rate_signal" in metrics or "fail_rate_control" in metrics:
203
+ chart_rates = {
204
+ "kind": "bar",
205
+ "title": "Fail rate by diagnostic signal",
206
+ "x": ["signal", "no_signal"],
207
+ "y": [
208
+ metrics.get("fail_rate_signal", 0.0),
209
+ metrics.get("fail_rate_control", 0.0),
210
+ ],
211
+ "y_label": "fail rate",
212
+ }
213
+ visualizations = [chart, chart_rates]
214
+ else:
215
+ visualizations = [chart]
216
+
217
+ conclusion = (
218
+ "Computed per-case diagnostic signal coverage."
219
+ if "effect_size" not in metrics
220
+ else f"Signal fail-rate gap is {metrics['effect_size']:.3f}."
221
+ )
222
+
223
+ return StatsToolResult(
224
+ name="per_case_signal_label_association",
225
+ rationale="Check whether analyzer per-case signals align with PASS/FAIL outcomes.",
226
+ metrics=metrics,
227
+ tables={"per_case_signal": rows},
228
+ visualizations=visualizations,
229
+ conclusion=conclusion,
230
+ )
231
+
232
+
233
+ def _find_cases(results: dict[str, "Result"]) -> CaseBatch | None:
234
+ for result in results.values():
235
+ cases = getattr(result, "cases", None)
236
+ if cases is not None and len(cases) > 0:
237
+ return cases
238
+ return None
239
+
240
+
241
+ def _extract_signals(results: dict[str, "Result"]) -> dict[str, bool]:
242
+ signal: dict[str, bool] = {}
243
+ non_signal_keys = {"sample_id", "id", "step", "first_error_step", "action", "judge_raw"}
244
+
245
+ for result in results.values():
246
+ per_case = result.findings.get("per_case", [])
247
+ if not isinstance(per_case, list):
248
+ continue
249
+ for entry in per_case:
250
+ if not isinstance(entry, dict):
251
+ continue
252
+ cid = str(entry.get("sample_id") or entry.get("id") or "")
253
+ if not cid:
254
+ continue
255
+ hit = any(
256
+ isinstance(v, (int, float, bool)) and bool(v)
257
+ for k, v in entry.items()
258
+ if k not in non_signal_keys
259
+ )
260
+ signal[cid] = signal.get(cid, False) or hit
261
+ return signal
@@ -0,0 +1,415 @@
1
+ """M2 tier (b) — StatsToolGenerator: synthesise a new statistical tool on demand.
2
+
3
+ When no catalog tool in :mod:`~evalrx.analysis.stats_tools` fits the
4
+ data, this generator asks an LLM / CLI coding agent to *write* a small statistics
5
+ script, runs it inside an :class:`~evalrx.agent_runtime.sandbox.ExperimentSandbox`
6
+ (subprocess + resource limits + path-traversal protection), and parses a strict
7
+ ``STATS_RESULT_JSON=`` line from its stdout back into a
8
+ :class:`~evalrx.analysis.stats_tools.StatsToolResult`.
9
+
10
+ Design (mirrors M5's ExperimentWriter):
11
+
12
+ - **Generated code never touches the repo source.** It lives only in the
13
+ sandbox workdir and is executed in a child process; the host never imports it.
14
+ - The generated script reads ``m2_stats_input.json`` (the serialised
15
+ :class:`StatsInput`) from its working directory and may ``import
16
+ evalrx.stats`` to compose existing primitives or write novel statistics
17
+ with numpy.
18
+ - **Tier (c) reuse**: a script that runs and parses cleanly is returned as a
19
+ :class:`GeneratedStatsTool`; the caller can re-run it on later cycles via
20
+ :meth:`run_cached` with fresh data — no second LLM call.
21
+
22
+ Backends (first available wins):
23
+
24
+ 1. ``cli_config`` with ``provider != "llm"`` → a CLI coding agent
25
+ (agy / codex / claude_code …) writes files into the workdir.
26
+ 2. ``judge`` model → single-pass ``generate()`` returning a fenced code block.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ import logging
33
+ import re
34
+ from dataclasses import dataclass
35
+ from pathlib import Path
36
+ from typing import TYPE_CHECKING, Any
37
+
38
+ from evalrx.agent_runtime.sandbox import ExperimentSandbox
39
+ from evalrx.analysis.prompts.stats_tool_generator import (
40
+ _GENERATE_PROMPT,
41
+ _INPUT_FILENAME,
42
+ _RESULT_MARKER,
43
+ )
44
+ from evalrx.analysis.stats_tools import StatsInput, StatsToolResult, describe_data
45
+ from evalrx.stats import compare
46
+ from evalrx.stats.evalue import evalue_bernoulli
47
+
48
+ if TYPE_CHECKING:
49
+ from evalrx.agent_runtime.cli_types import CliAgentConfig
50
+ from evalrx.core.model import Model
51
+
52
+ logger = logging.getLogger(__name__)
53
+
54
+
55
+
56
+ @dataclass
57
+ class GeneratedStatsTool:
58
+ """A validated, sandbox-executed statistical tool that can be re-run.
59
+
60
+ Attributes:
61
+ name: Short identifier (becomes ``generated:<name>``).
62
+ code: The Python source that was written and executed.
63
+ need: The natural-language goal it was generated for.
64
+ source: Which backend wrote it (``"cli:<provider>"`` or ``"llm"``).
65
+ """
66
+
67
+ name: str
68
+ code: str
69
+ need: str = ""
70
+ source: str = ""
71
+
72
+
73
+ class StatsToolGenerator:
74
+ """Generate, run, and cache bespoke statistical tools in a sandbox.
75
+
76
+ Args:
77
+ judge: LLM used for the single-pass code-writing path.
78
+ cli_config: :class:`~evalrx.agent_runtime.cli_types.CliAgentConfig`
79
+ with ``provider != "llm"`` to use a CLI coding agent
80
+ instead of (or before) the judge.
81
+ sandbox: Execution sandbox. A fresh temp-dir sandbox is created
82
+ when ``None``.
83
+ timeout_sec: Hard wall-clock limit per sandbox run — the primary guard.
84
+ max_cpu_seconds / max_memory_bytes: optional resource caps for the
85
+ default sandbox. Default ``None`` (no cap) to match M5:
86
+ generated tools may ``import evalrx.stats`` which pulls
87
+ in torch, whose large virtual-memory reservation a
88
+ ``RLIMIT_AS`` cap would crash. Ignored when *sandbox* is
89
+ supplied.
90
+ """
91
+
92
+ def __init__(
93
+ self,
94
+ judge: "Model | None" = None,
95
+ cli_config: "CliAgentConfig | None" = None,
96
+ sandbox: "ExperimentSandbox | None" = None,
97
+ timeout_sec: int = 60,
98
+ max_cpu_seconds: int | None = None,
99
+ max_memory_bytes: int | None = None,
100
+ run_logger: "Any | None" = None,
101
+ alpha: float = 0.05,
102
+ ) -> None:
103
+ self._judge = judge
104
+ self._cli_config = cli_config
105
+ self._timeout_sec = timeout_sec
106
+ # Significance level the HOST uses to reconstruct each generated tool's
107
+ # reject decision from its sufficient statistics (the LLM never decides).
108
+ self._alpha = float(alpha)
109
+ self._sandbox = sandbox or ExperimentSandbox(
110
+ max_cpu_seconds=max_cpu_seconds,
111
+ max_memory_bytes=max_memory_bytes,
112
+ )
113
+ # Optional RunLogger — records each stats-tool code-writing attempt.
114
+ self.run_logger = run_logger
115
+ self._last_prompt: str = ""
116
+ self._last_raw: str = ""
117
+ self._last_raw_stream: str = ""
118
+ self._last_usage: dict | None = None
119
+
120
+ @property
121
+ def available(self) -> bool:
122
+ """True when at least one code-writing backend is configured."""
123
+ return self._judge is not None or (
124
+ self._cli_config is not None and self._cli_config.provider != "llm"
125
+ )
126
+
127
+ # ------------------------------------------------------------------
128
+ # Public interface
129
+ # ------------------------------------------------------------------
130
+
131
+ def generate(
132
+ self,
133
+ need: str,
134
+ inp: StatsInput,
135
+ name: str = "custom",
136
+ ) -> "tuple[StatsToolResult, GeneratedStatsTool | None]":
137
+ """Write a new stats tool for *need*, run it on *inp*, and return both.
138
+
139
+ Returns ``(result, tool)``. ``tool`` is ``None`` when generation or the
140
+ first execution failed (the result then carries ``ok=False`` + error).
141
+ """
142
+ if not self.available:
143
+ return (
144
+ StatsToolResult(
145
+ tool=f"generated:{name}", ok=False,
146
+ error="no code-writing backend (judge or cli_config) configured",
147
+ summary="codegen unavailable",
148
+ ),
149
+ None,
150
+ )
151
+
152
+ self._write_input(inp)
153
+ self._last_prompt = ""
154
+ self._last_raw = ""
155
+ self._last_raw_stream = ""
156
+ self._last_usage = None
157
+ try:
158
+ code, source = self._write_code(need, inp)
159
+ except Exception as exc:
160
+ logger.warning("StatsToolGenerator: code writing failed: %s", exc)
161
+ self._emit_codegen(name, need, "", "", ok=False, error=f"code writing failed: {exc}")
162
+ return (
163
+ StatsToolResult(
164
+ tool=f"generated:{name}", ok=False,
165
+ error=f"code writing failed: {exc}", summary="codegen failed",
166
+ ),
167
+ None,
168
+ )
169
+
170
+ if not code.strip():
171
+ self._emit_codegen(name, need, source, "", ok=False, error="backend produced no code")
172
+ return (
173
+ StatsToolResult(
174
+ tool=f"generated:{name}", ok=False,
175
+ error="backend produced no code", summary="codegen empty",
176
+ ),
177
+ None,
178
+ )
179
+
180
+ result = self._run_code(code, name)
181
+ self._emit_codegen(
182
+ name, need, source, code, ok=result.ok,
183
+ error="" if result.ok else (result.error or "tool run failed"),
184
+ )
185
+ tool = (
186
+ GeneratedStatsTool(name=name, code=code, need=need, source=source)
187
+ if result.ok else None
188
+ )
189
+ return result, tool
190
+
191
+ def _emit_codegen(
192
+ self, name: str, need: str, source: str, code: str, *, ok: bool, error: str = ""
193
+ ) -> None:
194
+ """Record one stats-tool code-writing attempt to the RunLogger."""
195
+ if self.run_logger is None:
196
+ return
197
+ extra = ({"cli_usage": self._last_usage}
198
+ if source.startswith("cli:") and self._last_usage else None)
199
+ try:
200
+ self.run_logger.log_tool_codegen(
201
+ module="m2_stats", name=name, need=need, source=source, ok=ok,
202
+ code=code, prompt=self._last_prompt, raw_output=self._last_raw,
203
+ raw_stream=self._last_raw_stream, error=error,
204
+ extra=extra,
205
+ )
206
+ except Exception as exc: # logging must never break generation
207
+ logger.debug("StatsToolGenerator: log_tool_codegen failed: %s", exc)
208
+
209
+ def run_cached(self, tool: GeneratedStatsTool, inp: StatsInput) -> StatsToolResult:
210
+ """Re-run an already-generated tool on fresh *inp* (no LLM call)."""
211
+ self._write_input(inp)
212
+ return self._run_code(tool.code, tool.name)
213
+
214
+ # ------------------------------------------------------------------
215
+ # Internals
216
+ # ------------------------------------------------------------------
217
+
218
+ def _write_input(self, inp: StatsInput) -> None:
219
+ payload = {
220
+ "labels": inp.labels,
221
+ "per_case": inp.per_case,
222
+ "scalars": inp.scalars,
223
+ "groups": inp.groups,
224
+ }
225
+ path = Path(self._sandbox.workdir) / _INPUT_FILENAME
226
+ path.write_text(json.dumps(payload), encoding="utf-8")
227
+
228
+ def _write_code(self, need: str, inp: StatsInput) -> tuple[str, str]:
229
+ """Return ``(code, source)`` from the CLI agent or the judge."""
230
+ if self._cli_config is not None and self._cli_config.provider != "llm":
231
+ code = self._write_code_cli(need, inp)
232
+ if code:
233
+ return code, f"cli:{self._cli_config.provider}"
234
+ # Single-pass LLM fallback
235
+ prompt = self._build_prompt(need, inp, fenced=True)
236
+ self._last_prompt = prompt
237
+ raw = self._judge.generate(prompt) # type: ignore[union-attr]
238
+ self._last_raw = str(raw)
239
+ return _extract_code(str(raw)), "llm"
240
+
241
+ def _write_code_cli(self, need: str, inp: StatsInput) -> str:
242
+ from evalrx.agent_runtime.codegen import CodegenRunner
243
+
244
+ prompt = self._build_prompt(need, inp, fenced=False)
245
+ self._last_prompt = prompt
246
+ result = CodegenRunner(self._cli_config).write_code( # type: ignore[arg-type]
247
+ prompt,
248
+ workdir=Path(self._sandbox.workdir),
249
+ timeout_sec=self._timeout_sec,
250
+ preferred_filenames=("stats_tool.py",),
251
+ )
252
+ self._last_raw = result.raw_output
253
+ self._last_raw_stream = ""
254
+ if result.raw_stream_path:
255
+ try:
256
+ self._last_raw_stream = (
257
+ Path(self._sandbox.workdir) / result.raw_stream_path
258
+ ).read_text(encoding="utf-8")
259
+ except OSError:
260
+ pass
261
+ self._last_usage = result.usage
262
+ if not result.code:
263
+ logger.debug("CLI codegen produced no files (%s)", result.error)
264
+ return result.code
265
+
266
+ def _build_prompt(self, need: str, inp: StatsInput, *, fenced: bool) -> str:
267
+ return _GENERATE_PROMPT.format(
268
+ need=need.strip() or "Test whether the analyzer signals predict case FAIL.",
269
+ input_filename=_INPUT_FILENAME,
270
+ data_shape=json.dumps(describe_data(inp), indent=2),
271
+ marker=_RESULT_MARKER,
272
+ fences_hint=" inside a ```python code block" if fenced else
273
+ ", written to a file named stats_tool.py",
274
+ )
275
+
276
+ def _run_code(self, code: str, name: str) -> StatsToolResult:
277
+ repo_root = Path(__file__).resolve().parents[3]
278
+ wrapped = (
279
+ "import sys\n"
280
+ f"sys.path.insert(0, {str(repo_root)!r})\n"
281
+ + code
282
+ )
283
+ sandbox_result = self._sandbox.run(wrapped, timeout_sec=self._timeout_sec)
284
+ if not sandbox_result.ok:
285
+ err = (sandbox_result.stderr or "").strip()[:300] or "non-zero exit"
286
+ return StatsToolResult(
287
+ tool=f"generated:{name}", ok=False,
288
+ error=f"sandbox run failed: {err}",
289
+ summary=f"generated:{name} failed to run",
290
+ details={"returncode": sandbox_result.returncode,
291
+ "timed_out": sandbox_result.timed_out},
292
+ )
293
+ return _parse_result(sandbox_result.stdout, name, self._alpha)
294
+
295
+
296
+ # ---------------------------------------------------------------------------
297
+ # Module helpers
298
+ # ---------------------------------------------------------------------------
299
+
300
+ def _extract_code(raw: str) -> str:
301
+ """Pull the Python source out of an LLM response (fenced or bare)."""
302
+ cleaned = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL)
303
+ fence = re.search(r"```(?:python)?\s*\n(.*?)```", cleaned, flags=re.DOTALL)
304
+ if fence:
305
+ return fence.group(1).strip()
306
+ return cleaned.strip()
307
+
308
+
309
+ def _reconstruct_decision(
310
+ suff: Any, alpha: float
311
+ ) -> "tuple[bool, float | None, float | None, tuple | None] | None":
312
+ """Recompute ``(reject, e_value, effect, ci)`` HOST-SIDE from a generated
313
+ tool's sufficient statistics, using the validated core — so a generated
314
+ tool can choose WHAT statistic to compute but never WHETHER to reject.
315
+
316
+ Returns ``None`` when *suff* is missing or not an adjudicable shape; the
317
+ caller then treats the tool as descriptive (``reject=False``).
318
+ """
319
+ if not isinstance(suff, dict):
320
+ return None
321
+ kind = suff.get("kind")
322
+ try:
323
+ if kind == "paired_binary":
324
+ b, c = int(suff["b"]), int(suff["c"])
325
+ if b < 0 or c < 0:
326
+ return None
327
+ n = b + c
328
+ e_value = evalue_bernoulli(b, n, p0=0.5) if n > 0 else 1.0
329
+ reject = e_value >= 1.0 / alpha
330
+ effect = (b - c) / n if n > 0 else 0.0
331
+ return reject, float(e_value), effect, None
332
+ if kind == "two_group":
333
+ a = [int(x) for x in suff["a"]]
334
+ grp_b = [int(x) for x in suff["b"]]
335
+ if not a or not grp_b:
336
+ return None
337
+ sr = compare(a, grp_b, paired=False, alpha=alpha)
338
+ return bool(sr.reject), sr.e_value, sr.effect, tuple(sr.ci)
339
+ except (KeyError, TypeError, ValueError):
340
+ return None
341
+ return None
342
+
343
+
344
+ def _parse_result(stdout: str, name: str, alpha: float = 0.05) -> StatsToolResult:
345
+ """Parse the last ``STATS_RESULT_JSON=`` line of *stdout*.
346
+
347
+ The generated script supplies a DESCRIPTION (summary/effect/ci/details) plus
348
+ SUFFICIENT STATISTICS; the host reconstructs the *decision*
349
+ (reject / e-value) from those statistics via the validated core. A
350
+ self-declared ``reject``/``e_value``/``p_value`` in the JSON is IGNORED — the
351
+ LLM proposes evidence, it never adjudicates it. A tool with no adjudicable
352
+ sufficient statistic is descriptive only (``reject=False``), mirroring the
353
+ ``single_rate_evalue`` muzzle, so it can never reach M4's headline.
354
+ """
355
+ from evalrx.analysis.result_marker import extract_marker_json
356
+
357
+ data, error = extract_marker_json(stdout, _RESULT_MARKER)
358
+ if data is None and error.startswith("no "):
359
+ return StatsToolResult(
360
+ tool=f"generated:{name}", ok=False,
361
+ error="no STATS_RESULT_JSON line in output",
362
+ summary=f"generated:{name} produced no result line",
363
+ details={"stdout_tail": stdout.strip()[-300:]},
364
+ )
365
+ if error or not isinstance(data, dict):
366
+ return StatsToolResult(
367
+ tool=f"generated:{name}", ok=False,
368
+ error=(error.replace(_RESULT_MARKER, "STATS_RESULT_JSON") if error
369
+ else "STATS_RESULT_JSON payload must be a JSON object"),
370
+ summary=f"generated:{name} bad result json",
371
+ )
372
+
373
+ ci = data.get("ci")
374
+ ci_tuple = tuple(ci) if isinstance(ci, (list, tuple)) and len(ci) == 2 else None
375
+ effect = _as_float(data.get("effect"))
376
+ details = data.get("details") if isinstance(data.get("details"), dict) else {}
377
+
378
+ # HOST reconstructs the decision; the script's own reject/e_value/p_value are
379
+ # never trusted as a verdict (closes the codegen p-hacking hole).
380
+ recon = _reconstruct_decision(data.get("sufficient"), alpha)
381
+ if recon is not None:
382
+ reject, e_value, eff_h, ci_h = recon
383
+ if eff_h is not None:
384
+ effect = eff_h
385
+ if ci_h is not None:
386
+ ci_tuple = ci_h
387
+ details = {**details, "host_adjudicated": True}
388
+ else:
389
+ reject, e_value = False, None
390
+ details = {**details, "host_adjudicated": False, "descriptive_only": True}
391
+
392
+ return StatsToolResult(
393
+ tool=f"generated:{name}",
394
+ ok=True,
395
+ summary=str(data.get("summary", "")) or f"generated:{name}",
396
+ effect=effect,
397
+ ci=ci_tuple,
398
+ reject=reject,
399
+ e_value=e_value,
400
+ p_value=None, # diagnostic at most; never a host decision input
401
+ underpowered=bool(data.get("underpowered", False)),
402
+ analysis_key=f"generated:{name}",
403
+ correction_family="e_bh" if e_value is not None else None,
404
+ raw_reject=reject,
405
+ details=details,
406
+ )
407
+
408
+
409
+ def _as_float(v: Any) -> float | None:
410
+ if v is None or isinstance(v, bool):
411
+ return None
412
+ try:
413
+ return float(v)
414
+ except (TypeError, ValueError):
415
+ return None