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,300 @@
1
+ """Generic held-out verification for standalone explore runs.
2
+
3
+ Generalizes the deco_hallu_explore example's ``test_hypotheses.py`` to ANY
4
+ dataset the explorer can load (the upload workbench's mode 2, and
5
+ ``evalrx explore --holdout-frac F --holdout-confirm``):
6
+
7
+ 1. :func:`split_records` carves the loaded records into a deterministic,
8
+ outcome-stratified explore/holdout partition BEFORE exploration — the
9
+ explorer only ever sees the explore share.
10
+ 2. :func:`holdout_confirm` re-evaluates the explorer's FROZEN recipes verbatim
11
+ on the held-out rows (:func:`~evalrx.analysis.operationalize.compile_recipe`,
12
+ thresholds exactly as fitted — no re-fitting), rebuilds two-group sufficient
13
+ statistics, adjudicates with
14
+ :func:`~evalrx.analysis.adjudicate.adjudicate_signals`
15
+ (``split_label="held_out"`` — a REJECT here is a real held-out verdict), and
16
+ optionally has an LLM judge grade each M3 hypothesis against the held-out
17
+ table (supported / partial / refuted / not_testable + surgery routing).
18
+
19
+ The returned dict is the ``confirm_report.json`` shape the dashboard's
20
+ *Held-out Verdicts* tab renders. The judge is duck-typed (any object with
21
+ ``generate(str) -> str``); pass ``judge=None`` for signal verdicts only —
22
+ hypotheses then carry ``verdict="not_judged"``.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import re
29
+ from typing import Any
30
+
31
+ from evalrx.analysis.adjudicate import adjudicate_signals
32
+ from evalrx.analysis.explorer import CandidateSignal
33
+ from evalrx.analysis.fused_pipeline import _split_records
34
+ from evalrx.analysis.operationalize import RecipeError, SignalRecipe, compile_recipe
35
+ from evalrx.analysis.prompts.holdout import JUDGE_PROMPT
36
+
37
+ _FAIL_LIKE = ("fail", "failed", "failure", "error", "wrong", "incorrect", "no")
38
+
39
+
40
+ def split_records(
41
+ records: "list[dict[str, Any]]",
42
+ holdout_frac: float,
43
+ *,
44
+ seed: int = 0,
45
+ outcome_col: str | None = None,
46
+ ) -> "tuple[list[dict[str, Any]], list[dict[str, Any]] | None]":
47
+ """Deterministic ``(explore, holdout)`` partition, stratified by outcome.
48
+
49
+ Wraps the fused pipeline's split so the standalone path and the loop's
50
+ fused path hold out rows the same way. Returns ``(records, None)`` when
51
+ the fraction or batch size makes a split meaningless — callers fall back
52
+ to a plain in-sample run with a caveat rather than a fake split.
53
+ """
54
+ return _split_records(
55
+ records, frac=holdout_frac, seed=seed, label_col=outcome_col or "label"
56
+ )
57
+
58
+
59
+ def failure_indicator(
60
+ rows: "list[dict[str, Any]]", outcome_col: str
61
+ ) -> "tuple[list[int | None], Any, str]":
62
+ """Per-row binary failure indicator for an arbitrary outcome column.
63
+
64
+ Returns ``(indicators, positive_label, note)`` where ``indicators[i]`` is
65
+ 1 (failure), 0 (non-failure) or None (missing outcome). The positive
66
+ ("failure") class is chosen by, in order: a fail-like string value
67
+ (fail/failed/error/wrong/...), the truthy pole of a boolean/0-1 column, or
68
+ — for any other two-valued column — the MINORITY value (failures are
69
+ normally the rare class); *note* says which rule fired so the report can
70
+ state it honestly.
71
+ """
72
+ values = [r.get(outcome_col) for r in rows]
73
+ present = [v for v in values if v is not None]
74
+ uniq = {str(v).strip().lower() for v in present}
75
+
76
+ positive: Any = None
77
+ note = ""
78
+ fail_hits = sorted(uniq & set(_FAIL_LIKE))
79
+ if fail_hits:
80
+ positive = fail_hits[0]
81
+ note = f"failure = {outcome_col!r} == {positive!r} (fail-like value)"
82
+ elif uniq <= {"0", "1", "true", "false", "0.0", "1.0"} and uniq:
83
+ positive = "truthy"
84
+ note = f"failure = truthy {outcome_col!r} (boolean/0-1 column)"
85
+ elif len(uniq) == 2:
86
+ counts: dict[str, int] = {}
87
+ for v in present:
88
+ key = str(v).strip().lower()
89
+ counts[key] = counts.get(key, 0) + 1
90
+ positive = min(counts, key=lambda k: counts[k])
91
+ note = (
92
+ f"failure = {outcome_col!r} == {positive!r} "
93
+ "(minority value of a two-valued column)"
94
+ )
95
+ else:
96
+ return [None] * len(rows), None, (
97
+ f"outcome column {outcome_col!r} is not binary "
98
+ f"({len(uniq)} distinct values) — held-out verdicts unavailable"
99
+ )
100
+
101
+ out: "list[int | None]" = []
102
+ for v in values:
103
+ if v is None:
104
+ out.append(None)
105
+ continue
106
+ key = str(v).strip().lower()
107
+ if positive == "truthy":
108
+ out.append(1 if key in {"1", "true", "1.0"} else 0)
109
+ else:
110
+ out.append(1 if key == positive else 0)
111
+ return out, positive, note
112
+
113
+
114
+ def _holdout_sufficient(
115
+ values: "dict[str, float]",
116
+ id_to_fail: "dict[str, int]",
117
+ ) -> "dict[str, Any] | None":
118
+ """Two-group sufficient stats: failure indicators among signal-ABSENT (a)
119
+ vs signal-PRESENT (b) rows. Defined only for binary-flag recipes — a
120
+ continuous recipe has no frozen threshold to reuse."""
121
+ pairs = [(v, id_to_fail[cid]) for cid, v in values.items() if cid in id_to_fail]
122
+ if not pairs:
123
+ return None
124
+ if not {round(v, 6) for v, _ in pairs} <= {0.0, 1.0}:
125
+ return None
126
+ a = [f for v, f in pairs if v == 0.0]
127
+ b = [f for v, f in pairs if v == 1.0]
128
+ if not a or not b:
129
+ return None
130
+ return {"kind": "two_group", "a": a, "b": b}
131
+
132
+
133
+ def _judge_json(text: str) -> "dict[str, Any] | None":
134
+ m = re.search(r"\{.*\}", text, re.S)
135
+ if not m:
136
+ return None
137
+ try:
138
+ parsed = json.loads(m.group(0))
139
+ except json.JSONDecodeError:
140
+ return None
141
+ return parsed if isinstance(parsed, dict) else None
142
+
143
+
144
+ def holdout_confirm(
145
+ report: "dict[str, Any]",
146
+ holdout_rows: "list[dict[str, Any]]",
147
+ *,
148
+ outcome_col: str = "label",
149
+ alpha: float = 0.05,
150
+ judge: Any = None,
151
+ judge_meta: "dict[str, Any] | None" = None,
152
+ ) -> "dict[str, Any]":
153
+ """Re-test one exploratory report's recipes + hypotheses on held-out rows.
154
+
155
+ *report* is the ``exploratory_report.json`` dict (``to_dict()`` of a live
156
+ report works too). Returns the ``confirm_report.json`` payload; the caller
157
+ persists it next to the exploratory report.
158
+ """
159
+ rows = [dict(r) for r in holdout_rows]
160
+ indicators, positive_label, outcome_note = failure_indicator(rows, outcome_col)
161
+
162
+ # Stable ids: force the compile id_col so recipe values map back to rows
163
+ # regardless of what id-ish keys the upload carries.
164
+ id_to_fail: "dict[str, int]" = {}
165
+ for i, (row, ind) in enumerate(zip(rows, indicators)):
166
+ row["_holdout_id"] = f"h{i}"
167
+ if ind is not None:
168
+ id_to_fail[f"h{i}"] = ind
169
+ n_fail = sum(id_to_fail.values())
170
+
171
+ candidates_raw = [c for c in report.get("candidate_signals") or [] if isinstance(c, dict)]
172
+ hypotheses = [h for h in report.get("hypotheses") or [] if isinstance(h, dict)]
173
+
174
+ # ── 1. frozen-recipe re-evaluation on the held-out rows ─────────────────
175
+ signal_verdicts: "list[dict[str, Any]]" = []
176
+ candidates: "list[CandidateSignal]" = []
177
+ for c in candidates_raw:
178
+ entry: "dict[str, Any]" = {
179
+ "name": c.get("name"),
180
+ "display_name": c.get("display_name") or c.get("name"),
181
+ "recipe": c.get("recipe"),
182
+ "explore_effect": c.get("effect"),
183
+ "explore_reject_in_sample": c.get("reject"),
184
+ }
185
+ recipe_dict = c.get("recipe")
186
+ if not id_to_fail:
187
+ entry.update(status="skipped", reason=outcome_note or "no binary outcome")
188
+ signal_verdicts.append(entry)
189
+ continue
190
+ if not isinstance(recipe_dict, dict) or recipe_dict.get("kind") != "expr":
191
+ entry.update(status="skipped", reason="no expr recipe to re-evaluate")
192
+ signal_verdicts.append(entry)
193
+ continue
194
+ try:
195
+ values = compile_recipe(
196
+ SignalRecipe.from_dict(recipe_dict), rows, id_col="_holdout_id"
197
+ )
198
+ except (RecipeError, NotImplementedError) as exc:
199
+ entry.update(status="skipped", reason=f"recipe error: {exc}")
200
+ signal_verdicts.append(entry)
201
+ continue
202
+ sufficient = _holdout_sufficient(values, id_to_fail)
203
+ if sufficient is None:
204
+ entry.update(
205
+ status="skipped",
206
+ reason="recipe is not a binary flag on held-out rows "
207
+ "(no frozen threshold to reuse)",
208
+ )
209
+ signal_verdicts.append(entry)
210
+ continue
211
+ cand = CandidateSignal(
212
+ name=str(c.get("name") or "signal"),
213
+ rationale=str(c.get("rationale") or ""),
214
+ recipe=recipe_dict,
215
+ sufficient=sufficient,
216
+ )
217
+ n_a, n_b = len(sufficient["a"]), len(sufficient["b"])
218
+ entry.update(
219
+ status="adjudicated",
220
+ n_holdout=n_a + n_b,
221
+ n_flagged=n_b,
222
+ fail_rate_flagged=round(sum(sufficient["b"]) / n_b, 4),
223
+ fail_rate_unflagged=round(sum(sufficient["a"]) / n_a, 4),
224
+ )
225
+ signal_verdicts.append(entry)
226
+ candidates.append(cand)
227
+
228
+ meta = adjudicate_signals(candidates, alpha=alpha, split_label="held_out")
229
+ by_name = {c.name: c for c in candidates}
230
+ for entry in signal_verdicts:
231
+ cand = by_name.get(str(entry.get("name")))
232
+ if entry.get("status") == "adjudicated" and cand is not None:
233
+ entry.update(
234
+ reject=bool(cand.reject),
235
+ effect=cand.effect,
236
+ ci=cand.ci,
237
+ fdr_corrected=bool(cand.fdr_corrected),
238
+ )
239
+
240
+ # ── 2. LLM judge per hypothesis, grounded in the held-out table ─────────
241
+ evidence_lines = [
242
+ f"- {e.get('name')}: flagged-group fail rate {e.get('fail_rate_flagged')} "
243
+ f"vs unflagged {e.get('fail_rate_unflagged')} "
244
+ f"(n={e.get('n_holdout')}, effect={e.get('effect')}, CI={e.get('ci')}, "
245
+ f"verdict={'REJECT H0' if e.get('reject') else 'not rejected'})"
246
+ for e in signal_verdicts
247
+ if e.get("status") == "adjudicated"
248
+ ] or ["- (no recipe could be re-evaluated on the held-out split)"]
249
+ evidence = "\n".join(evidence_lines)
250
+
251
+ hypothesis_verdicts: "list[dict[str, Any]]" = []
252
+ for h in hypotheses:
253
+ verdict = dict(h)
254
+ if judge is None:
255
+ verdict.update(verdict="not_judged", reasoning="", needs_surgery=True)
256
+ hypothesis_verdicts.append(verdict)
257
+ continue
258
+ prompt = JUDGE_PROMPT.format(
259
+ statement=h.get("statement", ""),
260
+ basis=h.get("basis", ""),
261
+ test_design=h.get("test_design", ""),
262
+ n_rows=len(rows),
263
+ evidence=evidence,
264
+ )
265
+ try:
266
+ raw = judge.generate(prompt) or ""
267
+ except Exception as exc: # noqa: BLE001 — judge outage must not sink the verdicts
268
+ raw = ""
269
+ verdict["judge_error"] = str(exc)
270
+ parsed = _judge_json(raw)
271
+ if parsed and parsed.get("verdict") in {"supported", "partial", "refuted", "not_testable"}:
272
+ verdict.update(
273
+ verdict=parsed["verdict"],
274
+ reasoning=str(parsed.get("reasoning", "")),
275
+ needs_surgery=bool(parsed.get("needs_surgery", False)),
276
+ )
277
+ else:
278
+ verdict.update(
279
+ verdict="not_testable",
280
+ reasoning="judge output unparseable — defaulting to not_testable",
281
+ needs_surgery=True,
282
+ )
283
+ hypothesis_verdicts.append(verdict)
284
+
285
+ return {
286
+ "phase": "holdout_confirm",
287
+ "split": "held_out",
288
+ "n_validate_rows": len(rows),
289
+ "n_validate_fail": n_fail,
290
+ "alpha": alpha,
291
+ "outcome": {
292
+ "column": outcome_col,
293
+ "positive_label": positive_label,
294
+ "note": outcome_note,
295
+ },
296
+ "adjudication": meta,
297
+ "signal_verdicts": signal_verdicts,
298
+ "hypothesis_verdicts": hypothesis_verdicts,
299
+ "judge": judge_meta if judge is not None else None,
300
+ }
@@ -0,0 +1,230 @@
1
+ """M3 — HypothesisAgent: propose falsifiable hypotheses from an exploratory
2
+ analysis report (an ``ExploratoryAnalysisAgent`` / M2 output).
3
+
4
+ This is a lightweight, standalone counterpart to the diagnosis loop's
5
+ ``DiagnosisAgent`` (``evalrx.eval_agent.stages.diagnosis``): that one is
6
+ hard-bound to the loop's own ``AnalysisReport`` type and only accepts a judge
7
+ ``Model`` (Gemini by default) — it cannot read an ``ExploratoryAnalysisReport``
8
+ without raising or silently producing nothing (there is no adapter between the
9
+ two report shapes). This module reads the standalone explorer's own report
10
+ directly and supports the same ``judge``/``cli_config`` backend flexibility as
11
+ ``ExploratoryAnalysisAgent``, so it can run with whatever ``--backend`` the
12
+ ``evalrx explore`` CLI was given.
13
+
14
+ Proposal only — no validation. A hypothesis here is a candidate explanation to
15
+ investigate further, not a conclusion; there is no confirm/test phase wired up
16
+ for it (see ``StatsAnalysisAgent``/``HypothesisTester`` in the diagnosis loop
17
+ for that, a separate, currently out-of-scope system).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ import tempfile
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+ from typing import TYPE_CHECKING, Any
27
+
28
+ from evalrx.analysis.plain_language import jargon_violation
29
+ from evalrx.analysis.prompts.hypothesis_agent import (
30
+ AGENT_COLUMN_MARKERS as _AGENT_COLUMN_MARKERS,
31
+ )
32
+ from evalrx.analysis.prompts.hypothesis_agent import (
33
+ AGENT_TRAJECTORY_HINT as _AGENT_TRAJECTORY_HINT,
34
+ )
35
+ from evalrx.analysis.prompts.hypothesis_agent import PLAIN_REPAIR_PROMPT as _PLAIN_REPAIR_PROMPT
36
+ from evalrx.analysis.prompts.hypothesis_agent import PROPOSE_PROMPT as _PROPOSE_PROMPT
37
+
38
+
39
+ def _is_agent_report(report: dict) -> bool:
40
+ """True when the exploratory report shows agent-trajectory column families.
41
+
42
+ Detection scans the question, takeaway titles/analysis, and candidate
43
+ signal names/rationales for the marker substrings — the same places the
44
+ propose prompt quotes, so the hint fires exactly when the model will see
45
+ trajectory columns.
46
+ """
47
+ pieces: list[str] = [str(report.get("question") or "")]
48
+ for t in report.get("takeaways") or []:
49
+ if isinstance(t, dict):
50
+ pieces.append(str(t.get("title", "")))
51
+ pieces.append(str(t.get("analysis", "")))
52
+ for s in report.get("candidate_signals") or []:
53
+ if isinstance(s, dict):
54
+ pieces.append(str(s.get("name", "")))
55
+ pieces.append(str(s.get("display_name", "")))
56
+ pieces.append(str(s.get("rationale", "")))
57
+ text = " ".join(pieces)
58
+ return any(marker in text for marker in _AGENT_COLUMN_MARKERS)
59
+
60
+ if TYPE_CHECKING:
61
+ from evalrx.agent_runtime.cli_types import CliAgentConfig
62
+ from evalrx.core.model import Model
63
+
64
+ logger = logging.getLogger(__name__)
65
+
66
+
67
+ @dataclass
68
+ class Hypothesis:
69
+ """One M3-proposed, falsifiable candidate explanation. Proposed only —
70
+ ``test_design`` names how it *could* be checked, not a verdict."""
71
+
72
+ statement: str = ""
73
+ plain_statement: str = ""
74
+ basis: str = ""
75
+ test_design: str = ""
76
+
77
+ def to_dict(self) -> dict[str, Any]:
78
+ return {
79
+ "statement": self.statement,
80
+ "plain_statement": self.plain_statement,
81
+ "basis": self.basis,
82
+ "test_design": self.test_design,
83
+ }
84
+
85
+
86
+ class HypothesisAgent:
87
+ """M3: propose hypotheses from an ``ExploratoryAnalysisReport`` (as a dict).
88
+
89
+ Args:
90
+ judge: LLM-like object with ``generate(prompt) -> str``.
91
+ cli_config: Optional CLI coding-agent backend (same as
92
+ ``ExploratoryAnalysisAgent``'s ``cli_config``); used
93
+ instead of ``judge`` when its provider isn't ``"llm"``.
94
+ timeout_sec: Timeout for a single generation call.
95
+ """
96
+
97
+ def __init__(
98
+ self,
99
+ judge: "Model | None" = None,
100
+ cli_config: "CliAgentConfig | None" = None,
101
+ timeout_sec: int = 90,
102
+ ) -> None:
103
+ self._judge = judge
104
+ self._cli_config = cli_config
105
+ self._timeout_sec = timeout_sec
106
+
107
+ @property
108
+ def available(self) -> bool:
109
+ return self._judge is not None or (
110
+ self._cli_config is not None and self._cli_config.provider != "llm"
111
+ )
112
+
113
+ def propose(self, report: dict[str, Any], *, max_items: int = 8) -> list[Hypothesis]:
114
+ """Read *report* (an ``ExploratoryAnalysisReport.to_dict()``-shaped
115
+ dict) and return 0-3 proposed hypotheses. Never raises — a backend
116
+ failure just yields no hypotheses."""
117
+ if not self.available:
118
+ return []
119
+
120
+ takeaways = [t for t in report.get("takeaways") or [] if isinstance(t, dict)][:max_items]
121
+ takeaways_text = "\n".join(
122
+ f"- {t.get('title', '')}: {t.get('analysis', '')}" for t in takeaways
123
+ ) or "(none recorded)"
124
+ observations = [str(o) for o in report.get("observations") or []][:max_items]
125
+ observations_text = "\n".join(f"- {o}" for o in observations) or "(none recorded)"
126
+ signals = [s for s in report.get("candidate_signals") or [] if isinstance(s, dict)][:max_items]
127
+ signals_text = "\n".join(
128
+ f"- {s.get('display_name') or s.get('name')}: {s.get('rationale', '')}" for s in signals
129
+ ) or "(none recorded)"
130
+
131
+ if not takeaways and not observations and not signals:
132
+ return []
133
+
134
+ prompt = _PROPOSE_PROMPT.format(
135
+ question=report.get("question") or "Explore this dataset.",
136
+ takeaways_text=takeaways_text,
137
+ observations_text=observations_text,
138
+ signals_text=signals_text,
139
+ )
140
+ if _is_agent_report(report):
141
+ prompt += "\n\n" + _AGENT_TRAJECTORY_HINT
142
+ try:
143
+ raw = self._generate(prompt)
144
+ except Exception as exc: # noqa: BLE001
145
+ logger.warning("HypothesisAgent: generation failed: %s", exc)
146
+ return []
147
+ hypotheses = _parse_hypotheses(raw)
148
+ return self._repair_plain_language(raw, hypotheses)
149
+
150
+ def _repair_plain_language(self, raw: str, hypotheses: list[Hypothesis]) -> list[Hypothesis]:
151
+ """One bounded retry: if any PLAIN line is missing or still jargon-y,
152
+ ask the backend to rewrite just those lines. Never raises — a repair
153
+ failure just keeps the original (possibly jargon-y) hypotheses."""
154
+ if not hypotheses:
155
+ return hypotheses
156
+ violations = [
157
+ f"hypothesis {i}'s PLAIN line {reason}: {h.plain_statement!r}"
158
+ for i, h in enumerate(hypotheses, start=1)
159
+ for reason in [jargon_violation(h.plain_statement, h.statement)]
160
+ if reason
161
+ ]
162
+ if not violations:
163
+ return hypotheses
164
+ try:
165
+ repaired_raw = self._generate(
166
+ _PLAIN_REPAIR_PROMPT.format(raw=raw, violations="\n".join(f"- {v}" for v in violations))
167
+ )
168
+ except Exception as exc: # noqa: BLE001
169
+ logger.warning("HypothesisAgent: plain-language repair failed: %s", exc)
170
+ return hypotheses
171
+ repaired = _parse_hypotheses(repaired_raw)
172
+ return repaired or hypotheses
173
+
174
+ def _generate(self, prompt: str) -> str:
175
+ if self._cli_config is not None and self._cli_config.provider != "llm":
176
+ from evalrx.agent_runtime.codegen import CodegenRunner
177
+
178
+ with tempfile.TemporaryDirectory(prefix="evalrx_m3_") as tmp:
179
+ res = CodegenRunner(self._cli_config).run(
180
+ prompt,
181
+ workdir=Path(tmp),
182
+ timeout_sec=self._timeout_sec,
183
+ )
184
+ return res.raw_output or ""
185
+ return str(self._judge.generate(prompt)) # type: ignore[union-attr]
186
+
187
+
188
+ def _parse_hypotheses(raw: str) -> list[Hypothesis]:
189
+ """Parse ``HYPOTHESIS:``/``PLAIN:``/``BASIS:``/``TEST:`` blocks out of
190
+ *raw*. ``PLAIN:`` is optional (older/repaired responses may omit it),
191
+ in which case ``plain_statement`` stays "".
192
+
193
+ For the CLI-agent backend, *raw* is the full rendered tool-call
194
+ trajectory, not just a final answer (``CliAgentResult.raw_output``'s
195
+ docstring: "assistant text, every Bash/Edit/Write/Read tool call +
196
+ result") — an agent that narrates its plan before giving a final answer
197
+ can restate the same hypothesis twice in one trajectory. Dedupe by
198
+ statement (case-insensitive), keeping the order hypotheses first
199
+ appeared but the most recent basis/test for a repeated statement, since
200
+ later restatements tend to be the more refined ones.
201
+ """
202
+ if "HYPOTHESIS:" not in raw.upper():
203
+ return []
204
+ by_statement: dict[str, dict[str, str]] = {}
205
+ order: list[str] = []
206
+ cur: dict[str, str] | None = None
207
+ cur_key: str = ""
208
+
209
+ def _flush() -> None:
210
+ if cur and cur.get("statement"):
211
+ if cur_key not in by_statement:
212
+ order.append(cur_key)
213
+ by_statement[cur_key] = cur
214
+
215
+ for line in raw.splitlines():
216
+ line = line.strip()
217
+ upper = line.upper()
218
+ if upper.startswith("HYPOTHESIS:"):
219
+ _flush()
220
+ statement = line.split(":", 1)[1].strip()
221
+ cur = {"statement": statement, "plain_statement": "", "basis": "", "test_design": ""}
222
+ cur_key = statement.lower()
223
+ elif upper.startswith("PLAIN:") and cur is not None:
224
+ cur["plain_statement"] = line.split(":", 1)[1].strip()
225
+ elif upper.startswith("BASIS:") and cur is not None:
226
+ cur["basis"] = line.split(":", 1)[1].strip()
227
+ elif upper.startswith("TEST:") and cur is not None:
228
+ cur["test_design"] = line.split(":", 1)[1].strip()
229
+ _flush()
230
+ return [Hypothesis(**by_statement[key]) for key in order]