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,489 @@
1
+ """Fused explore -> confirm pipeline (DESIGN §4).
2
+
3
+ One pass that DISCOVERS freely and CONFIRMS rigorously, with the two split apart:
4
+
5
+ 1. ``_split_records`` — deterministic, stratified (by label) EXPLORE / CONFIRM
6
+ partition (mirrors ``VLDiagnoseLoop._split_explore_confirm``). The explorer and
7
+ catalog planner see ONLY explore rows; every e-value/CI is computed only on the
8
+ disjoint confirm rows. That is the double-dip firewall.
9
+ 2. Discovery on EXPLORE — TWO sources, blind to each other:
10
+ - the LAMBDA explorer (free codegen EDA) -> observations / charts /
11
+ candidate_signals, each optionally carrying a deterministic ``recipe``;
12
+ - the catalog planner -> the per-case signal columns already present.
13
+ Their union is deduped by signal name (estimand proxy; value-level estimand
14
+ identity is an open question, see DESIGN §10).
15
+ 3. Operationalization bridge — each explorer recipe is compiled on the CONFIRM rows
16
+ into a frozen per-case column (``compile_recipe``) and merged into the confirm
17
+ ``StatsInput.per_case`` so it competes like any analyzer signal.
18
+ 4. Confirmation on CONFIRM — the real M2 firewall (``StatsAnalysisAgent``) runs the
19
+ validated catalog over the EXPANDED signal family and produces effect/CI/e-value
20
+ verdicts + e-BH ``corrected_rejections`` + conclusion. The explorer never decides.
21
+ 5. Output assembly — per-signal host verdicts (provenance-tagged), and a graceful
22
+ ``recommended_confirmatory_tests`` channel for candidates that could not be
23
+ operationalized / were underpowered / had no confirm data. Nothing is silently
24
+ dropped and nothing is over-claimed.
25
+
26
+ Multiplicity note: paired/e-value tools use e-BH and marginal p-value tools use
27
+ BH. The report exposes both legacy ``rejected_tools`` and precise
28
+ ``rejected_result_keys`` so M1-M4 remains compatible while standalone M2 can
29
+ audit the exact tested family.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from dataclasses import dataclass, field
35
+ from typing import Any
36
+
37
+ from evalrx.analysis.operationalize import (
38
+ RecipeError,
39
+ SignalRecipe,
40
+ compile_recipe,
41
+ )
42
+ from evalrx.analysis.stats_tools import build_stats_input_from_records
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Output types
46
+ # ---------------------------------------------------------------------------
47
+
48
+ @dataclass
49
+ class FusedSignal:
50
+ """One candidate signal confirmed (or attempted) on the held-out CONFIRM split."""
51
+
52
+ name: str
53
+ source: str # "explorer" | "catalog" | "both"
54
+ description: str = ""
55
+ suggested_test: str = ""
56
+ recipe: dict[str, Any] | None = None # the SignalRecipe dict (bridged signals); replayable in-loop
57
+ operationalized: bool = True # became a per-case column on CONFIRM
58
+ # host verdict (from the M2 firewall on CONFIRM) — authoritative
59
+ effect: float | None = None
60
+ ci: tuple[float, float] | None = None
61
+ e_value: float | None = None
62
+ p_value: float | None = None
63
+ reject: bool | None = None
64
+ underpowered: bool = False
65
+ host_adjudicated: bool = False
66
+ fdr_corrected: bool = False
67
+ correction_method: str | None = None
68
+ confirmed_on: str = "" # "held_out" | "in_sample"
69
+
70
+ def to_dict(self) -> dict[str, Any]:
71
+ return {
72
+ "name": self.name,
73
+ "source": self.source,
74
+ "description": self.description,
75
+ "suggested_test": self.suggested_test,
76
+ "recipe": self.recipe,
77
+ "operationalized": self.operationalized,
78
+ "effect": self.effect,
79
+ "ci": list(self.ci) if self.ci is not None else None,
80
+ "e_value": self.e_value,
81
+ "p_value": self.p_value,
82
+ "reject": self.reject,
83
+ "underpowered": self.underpowered,
84
+ "host_adjudicated": self.host_adjudicated,
85
+ "fdr_corrected": self.fdr_corrected,
86
+ "correction_method": self.correction_method,
87
+ "confirmed_on": self.confirmed_on,
88
+ }
89
+
90
+
91
+ @dataclass
92
+ class FusedReport:
93
+ """Output of :func:`run_fused_analysis` — explore (descriptive) + confirm (verdicts)."""
94
+
95
+ question: str = ""
96
+ ok: bool = False
97
+ observations: list[str] = field(default_factory=list)
98
+ visual_plan: list[dict[str, Any]] = field(default_factory=list)
99
+ chart_readings: list[dict[str, Any]] = field(default_factory=list)
100
+ dashboard_storyboard: list[dict[str, Any]] = field(default_factory=list)
101
+ claims: list[dict[str, Any]] = field(default_factory=list)
102
+ candidate_signals: list[FusedSignal] = field(default_factory=list)
103
+ charts: list[dict[str, Any]] = field(default_factory=list)
104
+ recommended_confirmatory_tests: list[str] = field(default_factory=list)
105
+ caveats: list[str] = field(default_factory=list)
106
+ critique: list[str] = field(default_factory=list)
107
+ adjudication: dict[str, Any] = field(default_factory=dict)
108
+ split: dict[str, Any] = field(default_factory=dict)
109
+ conclusion: str = ""
110
+ explore_report: dict[str, Any] = field(default_factory=dict)
111
+ confirm_stats: dict[str, Any] = field(default_factory=dict)
112
+
113
+ @property
114
+ def confirmed_signal_names(self) -> list[str]:
115
+ return [s.name for s in self.candidate_signals if s.reject]
116
+
117
+ def to_dict(self) -> dict[str, Any]:
118
+ return {
119
+ "question": self.question,
120
+ "ok": self.ok,
121
+ "observations": self.observations,
122
+ "visual_plan": self.visual_plan,
123
+ "chart_readings": self.chart_readings,
124
+ "dashboard_storyboard": self.dashboard_storyboard,
125
+ "claims": self.claims,
126
+ "candidate_signals": [s.to_dict() for s in self.candidate_signals],
127
+ "charts": self.charts,
128
+ "recommended_confirmatory_tests": self.recommended_confirmatory_tests,
129
+ "caveats": self.caveats,
130
+ "critique": self.critique,
131
+ "adjudication": self.adjudication,
132
+ "split": self.split,
133
+ "conclusion": self.conclusion,
134
+ }
135
+
136
+
137
+ # ---------------------------------------------------------------------------
138
+ # Deterministic stratified EXPLORE / CONFIRM split (records level)
139
+ # ---------------------------------------------------------------------------
140
+
141
+ def _split_records(
142
+ records: list[dict[str, Any]],
143
+ *,
144
+ frac: float,
145
+ seed: int,
146
+ label_col: str,
147
+ ) -> "tuple[list[dict], list[dict] | None]":
148
+ """Deterministic, label-stratified ``(explore, confirm)`` split.
149
+
150
+ Returns ``(explore, confirm)``. Like the in-loop split, returns
151
+ ``(records, None)`` when ``frac <= 0`` or the batch is too small to hold out —
152
+ a no-op, so the caller falls back to in-sample confirmation with a caveat.
153
+ """
154
+ from evalrx.stats.subset_sampling import stratified_subset
155
+
156
+ rows = list(records)
157
+ if frac <= 0.0 or len(rows) < 4:
158
+ return rows, None
159
+ n_confirm = round(len(rows) * frac)
160
+ if n_confirm <= 0 or n_confirm >= len(rows):
161
+ return rows, None
162
+
163
+ def _key(row: dict[str, Any]) -> Any:
164
+ return str(row.get(label_col))
165
+
166
+ confirm = stratified_subset(rows, _key, n_confirm, seed=seed)
167
+ confirm_ids = {id(r) for r in confirm}
168
+ explore = [r for r in rows if id(r) not in confirm_ids]
169
+ if not explore or not confirm:
170
+ return rows, None
171
+ return explore, confirm
172
+
173
+
174
+ # ---------------------------------------------------------------------------
175
+ # Orchestrator
176
+ # ---------------------------------------------------------------------------
177
+
178
+ def run_fused_analysis(
179
+ records: list[dict[str, Any]],
180
+ *,
181
+ question: str = "What distinguishes failures from passes?",
182
+ explorer: Any,
183
+ stats_agent: Any | None = None,
184
+ confirm_split: float = 0.3,
185
+ seed: int = 0,
186
+ id_col: str = "case_id",
187
+ label_col: str = "label",
188
+ ) -> FusedReport:
189
+ """Run discovery on EXPLORE, confirmation on the held-out CONFIRM split.
190
+
191
+ Args:
192
+ records: list of row dicts (id/label/signal columns).
193
+ explorer: an object with ``explore_records(rows, question=...) ->
194
+ ExploratoryAnalysisReport`` (e.g. :class:`ExploratoryAnalysisAgent`).
195
+ stats_agent: optional :class:`StatsAnalysisAgent`; one is built with a
196
+ high signal cap when omitted so the whole family is tested.
197
+ confirm_split: fraction held out for confirmation (0 = in-sample fallback).
198
+ """
199
+ rows = list(records or [])
200
+ explore_rows, confirm_rows = _split_records(
201
+ rows, frac=confirm_split, seed=seed, label_col=label_col
202
+ )
203
+ held_out = confirm_rows is not None
204
+ confirm_label = "held_out" if held_out else "in_sample"
205
+ if not held_out:
206
+ confirm_rows = explore_rows # in-sample fallback (small batch)
207
+
208
+ report = FusedReport(question=question)
209
+ report.split = {
210
+ "mode": confirm_label,
211
+ "n_total": len(rows),
212
+ "n_explore": len(explore_rows),
213
+ "n_confirm": len(confirm_rows),
214
+ "frac": confirm_split,
215
+ "seed": seed,
216
+ }
217
+
218
+ # ── 1. Discovery source A: the LAMBDA explorer (EXPLORE only) ──
219
+ explore_report = explorer.explore_records(explore_rows, question=question)
220
+ report.ok = bool(getattr(explore_report, "ok", False))
221
+ report.observations = list(getattr(explore_report, "observations", []) or [])
222
+ report.visual_plan = list(getattr(explore_report, "visual_plan", []) or [])
223
+ report.chart_readings = list(getattr(explore_report, "chart_readings", []) or [])
224
+ report.dashboard_storyboard = list(getattr(explore_report, "dashboard_storyboard", []) or [])
225
+ report.claims = list(getattr(explore_report, "claims", []) or [])
226
+ report.charts = list(getattr(explore_report, "charts", []) or [])
227
+ report.caveats = list(getattr(explore_report, "caveats", []) or [])
228
+ report.critique = list(getattr(explore_report, "critique", []) or [])
229
+ report.explore_report = (
230
+ explore_report.to_dict() if hasattr(explore_report, "to_dict") else {}
231
+ )
232
+ explorer_candidates = list(getattr(explore_report, "candidate_signals", []) or [])
233
+ explorer_named = {c.name: c for c in explorer_candidates if getattr(c, "name", "")}
234
+
235
+ # ── 2. Discovery source B: catalog columns present on EXPLORE ──
236
+ explore_inp = build_stats_input_from_records(
237
+ explore_rows, id_col=id_col, label_col=label_col
238
+ )
239
+ catalog_names = set(explore_inp.per_case)
240
+
241
+ # ── 3. Operationalization bridge: compile explorer recipes on CONFIRM ──
242
+ bridged: dict[str, dict[str, float]] = {}
243
+ recipe_by_name: dict[str, dict[str, Any]] = {} # recipe name -> recipe dict (for replay)
244
+ bridge_failed: list[Any] = []
245
+ for c in explorer_candidates:
246
+ recipe_data = getattr(c, "recipe", None)
247
+ if not isinstance(recipe_data, dict):
248
+ continue
249
+ recipe = SignalRecipe.from_dict(recipe_data)
250
+ if not recipe.name:
251
+ recipe.name = c.name
252
+ try:
253
+ values = compile_recipe(recipe, confirm_rows, id_col=id_col)
254
+ except (RecipeError, NotImplementedError):
255
+ values = {}
256
+ if values:
257
+ bridged[recipe.name] = values
258
+ recipe_by_name[recipe.name] = {**recipe_data, "name": recipe.name}
259
+ else:
260
+ bridge_failed.append(c)
261
+
262
+ # ── 4. Confirmation: build confirm StatsInput, inject bridged signals, run M2 ──
263
+ confirm_inp = build_stats_input_from_records(
264
+ confirm_rows, id_col=id_col, label_col=label_col
265
+ )
266
+ catalog_confirm = set(confirm_inp.per_case) # real catalog columns on CONFIRM
267
+ bridged_keys: set[str] = set() # final per_case keys for bridged signals
268
+ bridged_origin: dict[str, str] = {} # final key -> originating recipe name
269
+ collisions: list[str] = []
270
+ for name, values in bridged.items():
271
+ key = name
272
+ if key in catalog_confirm:
273
+ # A bridged recipe must NEVER silently overwrite a real catalog column —
274
+ # that would test a DIFFERENT estimand under the same name. Namespace it.
275
+ key = f"bridged.{name}"
276
+ while key in confirm_inp.per_case:
277
+ key = f"_{key}"
278
+ collisions.append(name)
279
+ confirm_inp.per_case[key] = values
280
+ bridged_keys.add(key)
281
+ bridged_origin[key] = name
282
+ if collisions:
283
+ report.caveats.append(
284
+ "bridged recipe name(s) collided with catalog column(s) and were "
285
+ f"namespaced to avoid overwriting a real signal: {sorted(collisions)}"
286
+ )
287
+
288
+ # The deferred "leak-1" check: a bridged recipe (or catalog column) whose
289
+ # value RECONSTRUCTS the FAIL label is the label in disguise — it would
290
+ # trivially "confirm" with effect→1 and pollute confirmed_recipes.json. Route
291
+ # such columns to the sanity lane so they never enter the confirmatory family
292
+ # or get reported as discriminators; record a caveat for the audit trail.
293
+ # (build_stats_input_from_records already isolated raw leaks; this re-runs
294
+ # over the just-injected bridged signals.)
295
+ from evalrx.analysis.stats_tools import isolate_label_leaks
296
+
297
+ leaked = isolate_label_leaks(confirm_inp)
298
+ for key in leaked:
299
+ bridged_keys.discard(key)
300
+ if leaked:
301
+ report.caveats.append(
302
+ "label-reconstructing signal(s) held out of the confirmatory family "
303
+ "(sanity/plumbing, not discriminators): "
304
+ + ", ".join(f"{k} [{v}]" for k, v in sorted(leaked.items()))
305
+ )
306
+
307
+ if stats_agent is None:
308
+ from evalrx.analysis.stats_agent import StatsAnalysisAgent
309
+
310
+ stats_agent = StatsAnalysisAgent(
311
+ max_signal_tools=max(8, len(confirm_inp.per_case))
312
+ )
313
+
314
+ confirm_report = stats_agent.analyze_input(confirm_inp)
315
+ report.confirm_stats = (
316
+ confirm_report.to_dict() if hasattr(confirm_report, "to_dict") else {}
317
+ )
318
+ report.conclusion = getattr(confirm_report, "conclusion", "")
319
+ corrected = getattr(confirm_report, "corrected_rejections", {}) or {}
320
+ rejected_tools = set(corrected.get("rejected_tools", []))
321
+ rejected_result_keys = set(corrected.get("rejected_result_keys", []))
322
+
323
+ # ── 5. Assemble per-signal verdicts (provenance-tagged) ──
324
+ verdict_by_signal = _verdicts_by_signal(getattr(confirm_report, "stats_results", []))
325
+ for name in confirm_inp.per_case:
326
+ result = verdict_by_signal.get(name)
327
+ origin = bridged_origin.get(name, name) # recipe name for a (possibly renamed) bridged key
328
+ report.candidate_signals.append(
329
+ _fused_signal(
330
+ name=name,
331
+ source=_source_of(name, catalog_names, bridged_keys, explorer_named),
332
+ description=_describe(name, origin, explorer_named, bridged_keys),
333
+ suggested_test=_suggested_test(origin, explorer_named),
334
+ recipe=recipe_by_name.get(origin),
335
+ result=result,
336
+ rejected_tools=rejected_tools,
337
+ rejected_result_keys=rejected_result_keys,
338
+ confirmed_on=confirm_label,
339
+ )
340
+ )
341
+
342
+ # ── 6. Graceful degradation channel: nothing silently dropped ──
343
+ rec = list(getattr(explore_report, "recommended_confirmatory_tests", []) or [])
344
+ for c in bridge_failed:
345
+ rec.append(
346
+ f"{c.name}: {getattr(c, 'rationale', '')} "
347
+ "(recipe could not be operationalized on the confirm split)"
348
+ )
349
+ for c in explorer_candidates:
350
+ if (
351
+ not getattr(c, "recipe", None)
352
+ and c.name not in catalog_names
353
+ and c.name not in bridged
354
+ ):
355
+ rec.append(
356
+ f"{c.name}: {getattr(c, 'rationale', '')} "
357
+ "(descriptive only — no host-testable recipe)"
358
+ )
359
+ for fs in report.candidate_signals:
360
+ if fs.underpowered:
361
+ rec.append(f"{fs.name}: underpowered on the confirm split")
362
+ report.recommended_confirmatory_tests = rec
363
+
364
+ # ── 7. Family metadata + honest caveats ──
365
+ report.adjudication = {
366
+ "method": corrected.get("method", "e-BH"),
367
+ "alpha": corrected.get("alpha"),
368
+ "n_in_family": corrected.get("n_tested", 0),
369
+ "rejected_tools": sorted(rejected_tools),
370
+ "rejected_result_keys": sorted(rejected_result_keys),
371
+ "families": corrected.get("families", {}),
372
+ "n_signals_tested": len(confirm_inp.per_case),
373
+ "n_signals_rejected": sum(1 for s in report.candidate_signals if s.reject),
374
+ "split": confirm_label,
375
+ }
376
+ if not held_out:
377
+ report.caveats.append(
378
+ "CONFIRM split is IN-SAMPLE (batch too small to hold out); verdicts are "
379
+ "not double-dip-protected — enlarge the batch or lower confirm_split"
380
+ )
381
+ if any(s.host_adjudicated and s.reject and not s.fdr_corrected
382
+ for s in report.candidate_signals):
383
+ report.caveats.append(
384
+ "some rejected signal verdicts were not FDR-corrected; treat them as "
385
+ "descriptive until rerun with a correction-capable test"
386
+ )
387
+ return report
388
+
389
+
390
+ # ---------------------------------------------------------------------------
391
+ # Helpers
392
+ # ---------------------------------------------------------------------------
393
+
394
+ def _verdicts_by_signal(stats_results: list[Any]) -> dict[str, Any]:
395
+ """Map ``signal name -> StatsToolResult`` from the per-case catalog tools.
396
+
397
+ Prefers ``signal_label_assoc`` (carries a reject) over ``rank_corr``.
398
+ """
399
+ out: dict[str, Any] = {}
400
+ for r in stats_results or []:
401
+ if not getattr(r, "ok", False):
402
+ continue
403
+ signal = (getattr(r, "config", {}) or {}).get("signal")
404
+ if not signal:
405
+ continue
406
+ if getattr(r, "tool", "") == "signal_label_assoc":
407
+ out[signal] = r
408
+ elif signal not in out:
409
+ out[signal] = r
410
+ return out
411
+
412
+
413
+ def _fused_signal(
414
+ *,
415
+ name: str,
416
+ source: str,
417
+ description: str,
418
+ suggested_test: str,
419
+ recipe: dict[str, Any] | None,
420
+ result: Any,
421
+ rejected_tools: set[str],
422
+ rejected_result_keys: set[str],
423
+ confirmed_on: str,
424
+ ) -> FusedSignal:
425
+ fs = FusedSignal(
426
+ name=name,
427
+ source=source,
428
+ description=description,
429
+ suggested_test=suggested_test,
430
+ recipe=recipe,
431
+ confirmed_on=confirmed_on,
432
+ )
433
+ if result is None:
434
+ # Still a per-case column, but no usable test ran on CONFIRM (e.g. a
435
+ # degenerate one-sided split). Left host_adjudicated=False, reject=None.
436
+ return fs
437
+ fs.host_adjudicated = True
438
+ fs.effect = getattr(result, "effect", None)
439
+ fs.ci = getattr(result, "ci", None)
440
+ fs.e_value = getattr(result, "e_value", None)
441
+ fs.p_value = getattr(result, "p_value", None)
442
+ fs.underpowered = bool(getattr(result, "underpowered", False))
443
+ fs.reject = bool(getattr(result, "reject", False))
444
+ fs.correction_method = getattr(result, "correction_method", None)
445
+ result_key = getattr(result, "analysis_key", None)
446
+ fs.fdr_corrected = bool(getattr(result, "fdr_corrected", False)) or (
447
+ (result_key in rejected_result_keys)
448
+ or (getattr(result, "tool", "") in rejected_tools and fs.e_value is not None)
449
+ )
450
+ if fs.fdr_corrected:
451
+ fs.reject = True
452
+ return fs
453
+
454
+
455
+ def _source_of(
456
+ name: str,
457
+ catalog_names: set[str],
458
+ bridged_keys: set[str],
459
+ explorer_named: dict[str, Any],
460
+ ) -> str:
461
+ in_bridged = name in bridged_keys
462
+ in_catalog = name in catalog_names
463
+ pointed = name in explorer_named
464
+ # A bridged key is namespaced away from catalog columns, so a bridged signal is
465
+ # purely explorer-sourced; "both" is reserved for the explorer naming a real column.
466
+ if in_bridged:
467
+ return "both" if in_catalog else "explorer"
468
+ if in_catalog and pointed:
469
+ return "both"
470
+ return "catalog"
471
+
472
+
473
+ def _describe(
474
+ name: str,
475
+ origin: str,
476
+ explorer_named: dict[str, Any],
477
+ bridged_keys: set[str],
478
+ ) -> str:
479
+ c = explorer_named.get(origin) or explorer_named.get(name)
480
+ if c is not None and getattr(c, "rationale", ""):
481
+ return c.rationale
482
+ if name in bridged_keys:
483
+ return "bridged explorer signal"
484
+ return "existing per-case column"
485
+
486
+
487
+ def _suggested_test(name: str, explorer_named: dict[str, Any]) -> str:
488
+ c = explorer_named.get(name)
489
+ return getattr(c, "suggested_test", "") if c is not None else ""