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,1276 @@
1
+ """General-purpose exploratory data analysis agent (LAMBDA-style).
2
+
3
+ A coding agent writes Python over arbitrary tabular records — not just M1
4
+ pass/fail diagnosis logs — EvalRX runs it locally in a sandbox, and the
5
+ result is parsed into a structured exploratory report. The prompt adapts its
6
+ framing to whatever the data's outcome column actually is (binary,
7
+ multi-class, continuous, or none — see :func:`_framing_block`), so a dataset
8
+ with no FAIL/PASS label gets unsupervised EDA instead of an invented split.
9
+
10
+ M1 integration is one caller among others, not a special case: the diagnosis
11
+ loop's per-case records already carry a ``label`` column, which the name
12
+ heuristic in :func:`evalrx.analysis.profile.profile_records` recognizes
13
+ as the outcome automatically, so it still gets the same binary FAIL/PASS
14
+ framing it always did (callers with an arbitrarily-named target can pass
15
+ ``outcome_col=`` explicitly instead of relying on the heuristic). Findings
16
+ from this module are candidates only; use ``StatsAnalysisAgent`` for
17
+ confirmatory effect/CI/e-value/FDR verdicts.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import logging
24
+ import os
25
+ import re
26
+ import shutil
27
+ import time
28
+ from dataclasses import dataclass, field
29
+ from pathlib import Path
30
+ from typing import TYPE_CHECKING, Any, Callable
31
+
32
+ from evalrx.agent_runtime.sandbox import ExperimentSandbox, SandboxResult
33
+ from evalrx.analysis.plain_language import jargon_violation
34
+ from evalrx.analysis.profile import describe_outcome, profile_records
35
+ from evalrx.analysis.prompts.explorer import (
36
+ GENERATE_PROMPT_RAW_FOLDER as _GENERATE_PROMPT_RAW_FOLDER,
37
+ )
38
+ from evalrx.analysis.prompts.explorer import (
39
+ GENERATE_PROMPT_RECORDS as _GENERATE_PROMPT_RECORDS,
40
+ )
41
+ from evalrx.analysis.prompts.explorer import (
42
+ GENERIC_FRAMING as _GENERIC_FRAMING,
43
+ )
44
+ from evalrx.analysis.prompts.explorer import (
45
+ RECORDS_FILENAME,
46
+ )
47
+ from evalrx.analysis.prompts.explorer import (
48
+ REPAIR_PROMPT as _REPAIR_PROMPT,
49
+ )
50
+ from evalrx.analysis.prompts.explorer import (
51
+ RESULT_MARKER as _RESULT_MARKER,
52
+ )
53
+ from evalrx.analysis.prompts.explorer import (
54
+ fences_hint as _fences_hint,
55
+ )
56
+ from evalrx.analysis.prompts.explorer import (
57
+ skills_hint as _skills_hint,
58
+ )
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 Takeaway:
69
+ """One finding paired with its supporting evidence — the primary UI unit.
70
+
71
+ The dashboard renders each takeaway as: plain_title (falling back to
72
+ title) -> chart(s)/table(s) -> analysis, so a reader never sees an
73
+ orphaned chart or a claim with no evidence next to it. ``plain_title`` is
74
+ the jargon-free headline aimed at a non-technical reader; ``title`` keeps
75
+ the precise technical wording/numbers as a companion line. Always
76
+ descriptive: this agent does not generate or validate hypotheses, so a
77
+ takeaway is a description of the data, not a claim of causation or
78
+ statistical confirmation.
79
+ """
80
+
81
+ title: str = ""
82
+ plain_title: str = ""
83
+ analysis: str = ""
84
+ chart_names: list[str] = field(default_factory=list)
85
+ table_names: list[str] = field(default_factory=list)
86
+ caveat: str = ""
87
+
88
+ def to_dict(self) -> dict[str, Any]:
89
+ return {
90
+ "title": self.title,
91
+ "plain_title": self.plain_title,
92
+ "analysis": self.analysis,
93
+ "chart_names": self.chart_names,
94
+ "table_names": self.table_names,
95
+ "caveat": self.caveat,
96
+ }
97
+
98
+
99
+ @dataclass
100
+ class CandidateSignal:
101
+ """A signal worth testing later with a confirmatory pipeline
102
+ (:class:`~evalrx.analysis.stats_agent.StatsAnalysisAgent`, or the
103
+ diagnosis loop) — not something this agent tests or confirms itself.
104
+
105
+ The explorer PROPOSES this signal; it has no adjudication authority. When the
106
+ explorer attaches host-adjudicable ``sufficient`` statistics, the host
107
+ (:func:`evalrx.analysis.adjudicate.adjudicate_report`) recomputes the
108
+ verdict with the validated, multiplicity-aware core and fills in the
109
+ ``effect`` / ``ci`` / ``e_value`` / ``reject`` / ``host_adjudicated`` fields.
110
+ Any ``reject`` / ``e_value`` the explorer self-declares is IGNORED — proposing
111
+ is not validating; only ``sufficient`` (or, in Phase B, ``recipe``) is read.
112
+ """
113
+
114
+ name: str
115
+ display_name: str = ""
116
+ rationale: str = ""
117
+ suggested_test: str = ""
118
+ # --- proposed by the explorer (no authority over the verdict) ---
119
+ sufficient: dict[str, Any] | None = None # host-adjudicable sufficient stats
120
+ recipe: dict[str, Any] | None = None # Phase B operationalization recipe
121
+ # --- filled in by the host adjudication pass (authoritative) ---
122
+ effect: float | None = None
123
+ ci: tuple[float, float] | None = None
124
+ e_value: float | None = None
125
+ reject: bool | None = None
126
+ underpowered: bool = False
127
+ host_adjudicated: bool = False
128
+ fdr_corrected: bool = False
129
+ descriptive_only: bool = False
130
+
131
+ def to_dict(self) -> dict[str, Any]:
132
+ return {
133
+ "name": self.name,
134
+ "display_name": self.display_name,
135
+ "rationale": self.rationale,
136
+ "suggested_test": self.suggested_test,
137
+ "sufficient": self.sufficient,
138
+ "recipe": self.recipe,
139
+ "effect": self.effect,
140
+ "ci": list(self.ci) if self.ci is not None else None,
141
+ "e_value": self.e_value,
142
+ "reject": self.reject,
143
+ "underpowered": self.underpowered,
144
+ "host_adjudicated": self.host_adjudicated,
145
+ "fdr_corrected": self.fdr_corrected,
146
+ "descriptive_only": self.descriptive_only,
147
+ }
148
+
149
+
150
+ @dataclass
151
+ class ExploratoryAnalysisReport:
152
+ """Output of the exploratory analysis agent: a data profile, a ranked list
153
+ of takeaways (each paired with its supporting chart/table), and the raw
154
+ charts/tables/code behind them. Purely descriptive — this agent does not
155
+ generate or validate hypotheses itself; ``hypotheses`` (if present) is
156
+ filled in by a separate downstream stage (M3, see
157
+ ``evalrx.analysis.hypothesis_agent.HypothesisAgent``), not by this
158
+ class — proposal only, still no validation; see ``StatsAnalysisAgent``
159
+ for confirmatory testing.
160
+ """
161
+
162
+ question: str = ""
163
+ plain_question: str = ""
164
+ ok: bool = False
165
+ observations: list[str] = field(default_factory=list)
166
+ takeaways: list[Takeaway] = field(default_factory=list)
167
+ hypotheses: list[dict[str, Any]] = field(default_factory=list)
168
+ visual_plan: list[dict[str, Any]] = field(default_factory=list)
169
+ chart_readings: list[dict[str, Any]] = field(default_factory=list)
170
+ dashboard_storyboard: list[dict[str, Any]] = field(default_factory=list)
171
+ claims: list[dict[str, Any]] = field(default_factory=list)
172
+ candidate_signals: list[CandidateSignal] = field(default_factory=list)
173
+ plots: list[str] = field(default_factory=list)
174
+ tables: dict[str, Any] = field(default_factory=dict)
175
+ charts: list[dict[str, Any]] = field(default_factory=list)
176
+ caveats: list[str] = field(default_factory=list)
177
+ critique: list[str] = field(default_factory=list)
178
+ recommended_confirmatory_tests: list[str] = field(default_factory=list)
179
+ data_profile: dict[str, Any] = field(default_factory=dict)
180
+ # Host adjudication family metadata (method/alpha/split/n_in_family/rejected),
181
+ # filled by evalrx.analysis.adjudicate; empty until adjudicated.
182
+ adjudication: dict[str, Any] = field(default_factory=dict)
183
+ code: str = ""
184
+ stdout: str = ""
185
+ stderr: str = ""
186
+ error: str = ""
187
+ attempts: int = 0
188
+ workdir: str = ""
189
+ raw_outputs: list[str] = field(default_factory=list)
190
+ agent_audits: list[dict[str, Any]] = field(default_factory=list)
191
+ # UNTRUNCATED raw CLI streams, one per attempt (the truncated renderings
192
+ # above exist for compact UIs; these are the full audit trail).
193
+ raw_streams: list[str] = field(default_factory=list)
194
+ model_calls: list[dict[str, Any]] = field(default_factory=list)
195
+
196
+ @property
197
+ def candidate_signal_names(self) -> list[str]:
198
+ return [s.name for s in self.candidate_signals if s.name]
199
+
200
+ def to_dict(self) -> dict[str, Any]:
201
+ return {
202
+ "question": self.question,
203
+ "plain_question": self.plain_question,
204
+ "ok": self.ok,
205
+ "observations": self.observations,
206
+ "takeaways": [t.to_dict() for t in self.takeaways],
207
+ "hypotheses": self.hypotheses,
208
+ "visual_plan": self.visual_plan,
209
+ "chart_readings": self.chart_readings,
210
+ "dashboard_storyboard": self.dashboard_storyboard,
211
+ "claims": self.claims,
212
+ "candidate_signals": [s.to_dict() for s in self.candidate_signals],
213
+ "plots": self.plots,
214
+ "tables": self.tables,
215
+ "charts": self.charts,
216
+ "caveats": self.caveats,
217
+ "critique": self.critique,
218
+ "recommended_confirmatory_tests": self.recommended_confirmatory_tests,
219
+ "data_profile": self.data_profile,
220
+ "adjudication": self.adjudication,
221
+ "code": self.code,
222
+ "stdout": self.stdout,
223
+ "stderr": self.stderr,
224
+ "error": self.error,
225
+ "attempts": self.attempts,
226
+ "workdir": self.workdir,
227
+ "agent_audits": self.agent_audits,
228
+ "model_calls": self.model_calls,
229
+ }
230
+
231
+
232
+ class ExploratoryAnalysisAgent:
233
+ """Backend-only exploratory analysis agent.
234
+
235
+ Args:
236
+ judge: LLM-like object with ``generate(prompt) -> str`` used to
237
+ write the initial analysis script.
238
+ inspector: Optional LLM-like object used for repair prompts. When not
239
+ provided, ``judge`` repairs its own code.
240
+ cli_config: Optional CLI coding-agent backend. If set to a non-``llm``
241
+ provider, it writes ``analysis.py`` locally.
242
+ sandbox: Local execution sandbox. A durable temp sandbox is created
243
+ when omitted so generated code and figures remain available.
244
+ """
245
+
246
+ def __init__(
247
+ self,
248
+ judge: "Model | None" = None,
249
+ inspector: "Model | None" = None,
250
+ cli_config: "CliAgentConfig | None" = None,
251
+ sandbox: ExperimentSandbox | None = None,
252
+ timeout_sec: int = 60,
253
+ max_attempts: int = 2,
254
+ use_bundled_skills: bool = True,
255
+ progress_sink: Any = None,
256
+ ) -> None:
257
+ self._judge = judge
258
+ self._inspector = inspector
259
+ # Figure-styling skills are on by default for every skill-capable CLI
260
+ # backend: when the caller configured none, vendor the package-bundled
261
+ # ones (eval-chart-style, nature-figure, evalrx-report-ui) so agent
262
+ # figures follow the house chart-type policy without per-caller wiring.
263
+ if use_bundled_skills and cli_config is not None and not cli_config.skills:
264
+ from evalrx.agent_runtime.skills.resolver import resolve_skill_paths
265
+
266
+ bundled = resolve_skill_paths(
267
+ provider=cli_config.provider,
268
+ explicit=(),
269
+ use_bundled=True,
270
+ )
271
+ if bundled:
272
+ from dataclasses import replace
273
+
274
+ cli_config = replace(cli_config, skills=bundled, allow_skills=True)
275
+ self._cli_config = cli_config
276
+ self._sandbox = sandbox or ExperimentSandbox(cleanup=False)
277
+ self._timeout_sec = timeout_sec
278
+ self._max_attempts = max(1, max_attempts)
279
+ self._progress_sink = progress_sink
280
+ self._last_agent_audit: dict[str, Any] | None = None
281
+ self._last_raw_stream: str = ""
282
+ self._model_calls: list[dict[str, Any]] = []
283
+
284
+ @property
285
+ def available(self) -> bool:
286
+ return self._judge is not None or (
287
+ self._cli_config is not None and self._cli_config.provider != "llm"
288
+ )
289
+
290
+ def explore_records(
291
+ self,
292
+ records: Any,
293
+ *,
294
+ question: str = "Explore this dataset and surface the patterns that matter.",
295
+ outcome_col: str | None = None,
296
+ ) -> ExploratoryAnalysisReport:
297
+ """Run local exploratory analysis over plain records.
298
+
299
+ ``outcome_col`` optionally names the target/label column explicitly
300
+ (e.g. M1 always passes ``"label"``). When omitted, the outcome (if
301
+ any) is auto-detected by name heuristics; arbitrary datasets with no
302
+ recognizable outcome column fall back to unsupervised EDA instead of
303
+ having a FAIL/PASS split forced onto them.
304
+ """
305
+ rows = _records_to_rows(records)
306
+ profile = _profile_rows(rows, outcome_col=outcome_col)
307
+ self._write_input(rows)
308
+ return self._run_explore_loop(
309
+ question, profile, lambda: self._write_code(question, profile)
310
+ )
311
+
312
+ def explore_path(
313
+ self,
314
+ path: str | Path,
315
+ *,
316
+ question: str = "Explore this dataset and surface the patterns that matter.",
317
+ max_rows: int = 2000,
318
+ max_files: int = 200,
319
+ include_tool_calls: bool = False,
320
+ outcome_col: str | None = None,
321
+ ) -> ExploratoryAnalysisReport:
322
+ """Run exploratory analysis directly over *path* (a file or directory).
323
+
324
+ This is the no-code entrypoint behind the CLI. With a CLI coding-agent
325
+ backend (``cli_config``, the ``evalrx explore`` default), the raw
326
+ source is handed to the agent as-is and IT loads/organizes whatever
327
+ shape it finds — no host-side JSON-shape parsing, so this works for an
328
+ arbitrary M1 output layout, not just the ones the host loader
329
+ recognizes. With a plain LLM ``judge`` (no filesystem access), the host
330
+ must still pre-flatten records via :func:`load_records_from_path`
331
+ before handing them to :meth:`explore_records`.
332
+ """
333
+ folder_scan = scan_folder(path, max_files=max_files, include_tool_calls=include_tool_calls)
334
+ is_agentic = self._cli_config is not None and self._cli_config.provider != "llm"
335
+ if is_agentic:
336
+ report = self._explore_raw_folder(
337
+ path,
338
+ question=question,
339
+ folder_scan=folder_scan,
340
+ max_files=max_files,
341
+ include_tool_calls=include_tool_calls,
342
+ outcome_col=outcome_col,
343
+ )
344
+ else:
345
+ rows = load_records_from_path(
346
+ path,
347
+ max_rows=max_rows,
348
+ max_files=max_files,
349
+ include_tool_calls=include_tool_calls,
350
+ )
351
+ report = self.explore_records(rows, question=question, outcome_col=outcome_col)
352
+ report.data_profile.setdefault("loaded_rows", len(rows))
353
+ report.data_profile.setdefault("source_path", str(Path(path)))
354
+ report.data_profile.setdefault("folder_scan", folder_scan)
355
+ return report
356
+
357
+ def _explore_raw_folder(
358
+ self,
359
+ path: str | Path,
360
+ *,
361
+ question: str,
362
+ folder_scan: dict[str, Any],
363
+ max_files: int,
364
+ include_tool_calls: bool,
365
+ outcome_col: str | None,
366
+ ) -> ExploratoryAnalysisReport:
367
+ raw_input_dir = self._copy_raw_input(path, max_files=max_files, include_tool_calls=include_tool_calls)
368
+ profile: dict[str, Any] = {"folder_scan": folder_scan, "raw_input_dir": raw_input_dir}
369
+ return self._run_explore_loop(
370
+ question,
371
+ profile,
372
+ lambda: self._write_code_raw_folder(question, folder_scan, raw_input_dir, outcome_col),
373
+ )
374
+
375
+ def _copy_raw_input(self, path: str | Path, *, max_files: int, include_tool_calls: bool) -> str:
376
+ """Copy the raw source into the sandbox untouched, so the CLI agent
377
+ reads and organizes it itself — the host does no JSON-shape parsing
378
+ for this path. Mirrors the same file selection ``load_records_from_path``
379
+ would use (extension filter, ``tool_calls_*`` skip, ``max_files`` cap),
380
+ but copies the original bytes instead of parsing them."""
381
+ root = Path(path)
382
+ dest_root = Path(self._sandbox.workdir) / "raw_input"
383
+ if dest_root.exists():
384
+ shutil.rmtree(dest_root)
385
+ dest_root.mkdir(parents=True)
386
+ if root.is_file():
387
+ shutil.copy2(root, dest_root / root.name)
388
+ else:
389
+ # A workbench DatasetBundle contains normalized records.json plus
390
+ # optional original media. Keep the old JSON selection semantics
391
+ # for regular result folders, but include known table/media files
392
+ # so a CLI coding agent can inspect the same bundle the UI reports.
393
+ from evalrx.analysis.workbench import SUPPORTED_SUFFIXES
394
+
395
+ files = [
396
+ p for p in sorted(root.rglob("*"))
397
+ if p.is_file()
398
+ and p.suffix.lower() in SUPPORTED_SUFFIXES
399
+ and (include_tool_calls or not p.name.startswith("tool_calls_"))
400
+ ]
401
+ # Normalized rows are the primary analysis input and must not be
402
+ # crowded out by a large media directory.
403
+ files.sort(key=lambda p: (p.name != RECORDS_FILENAME, str(p)))
404
+ files = files[:max_files]
405
+ for f in files:
406
+ dest = dest_root / f.relative_to(root)
407
+ dest.parent.mkdir(parents=True, exist_ok=True)
408
+ shutil.copy2(f, dest)
409
+ return "raw_input"
410
+
411
+ def _run_explore_loop(
412
+ self,
413
+ question: str,
414
+ profile: dict[str, Any],
415
+ write_first: "Callable[[], tuple[str, str]]",
416
+ ) -> ExploratoryAnalysisReport:
417
+ """Shared write/run/repair-attempt loop behind :meth:`explore_records`
418
+ and :meth:`_explore_raw_folder` — they differ only in how the first
419
+ attempt's code is written (pre-loaded records vs. a raw folder); repair
420
+ attempts reuse :meth:`_repair_code` either way."""
421
+ if not self.available:
422
+ return ExploratoryAnalysisReport(
423
+ question=question,
424
+ ok=False,
425
+ data_profile=profile,
426
+ error="no code-writing backend configured (judge or cli_config)",
427
+ workdir=str(self._sandbox.workdir),
428
+ )
429
+
430
+ self._model_calls = []
431
+ raw_outputs: list[str] = []
432
+ raw_streams: list[str] = []
433
+ agent_audits: list[dict[str, Any]] = []
434
+ code = ""
435
+ last_result: SandboxResult | None = None
436
+ last_error = ""
437
+ # A working report we're retrying only to fix a plain_title, kept in
438
+ # case the retry itself produces something worse (crashes, unparseable
439
+ # JSON) — a jargon-y headline must never cost the whole analysis.
440
+ best_report: ExploratoryAnalysisReport | None = None
441
+
442
+ for attempt in range(1, self._max_attempts + 1):
443
+ try:
444
+ if self._progress_sink is not None:
445
+ self._progress_sink.emit(
446
+ "m2_codegen", "started", "Generating analysis code", attempt=attempt
447
+ )
448
+ if attempt == 1:
449
+ code, raw = write_first()
450
+ else:
451
+ code, raw = self._repair_code(
452
+ question, profile, code, last_result, last_error
453
+ )
454
+ raw_outputs.append(raw)
455
+ raw_streams.append(self._last_raw_stream)
456
+ self._last_raw_stream = ""
457
+ if self._last_agent_audit is not None:
458
+ agent_audits.append(self._last_agent_audit)
459
+ self._last_agent_audit = None
460
+ if code.strip() and self._progress_sink is not None:
461
+ self._progress_sink.emit(
462
+ "m2_codegen", "completed", "Analysis code is ready", attempt=attempt
463
+ )
464
+ except Exception as exc: # noqa: BLE001
465
+ last_error = f"code writing failed: {exc}"
466
+ logger.warning("ExploratoryAnalysisAgent: %s", last_error)
467
+ if self._progress_sink is not None:
468
+ self._progress_sink.emit("m2_codegen", "failed", last_error, attempt=attempt)
469
+ break
470
+
471
+ if not code.strip():
472
+ last_error = "backend produced no code"
473
+ if self._progress_sink is not None:
474
+ self._progress_sink.emit(
475
+ "m2_codegen", "failed", last_error, attempt=attempt
476
+ )
477
+ continue
478
+
479
+ if self._progress_sink is not None:
480
+ self._progress_sink.emit(
481
+ "m2_execute", "started", "Running generated analysis", attempt=attempt
482
+ )
483
+ last_result = self._sandbox.run(code, timeout_sec=self._timeout_sec)
484
+ if self._progress_sink is not None:
485
+ self._progress_sink.emit(
486
+ "m2_execute", "completed" if last_result.ok else "failed",
487
+ "Generated analysis finished" if last_result.ok else "Generated analysis needs repair",
488
+ attempt=attempt,
489
+ metrics={"elapsed_sec": last_result.elapsed_sec, "returncode": last_result.returncode},
490
+ )
491
+ report, last_error = _report_from_sandbox(
492
+ question=question,
493
+ profile=profile,
494
+ code=code,
495
+ result=last_result,
496
+ attempts=attempt,
497
+ workdir=Path(self._sandbox.workdir),
498
+ )
499
+ report.raw_outputs = raw_outputs
500
+ report.raw_streams = raw_streams
501
+ report.agent_audits = agent_audits
502
+ report.model_calls = list(self._model_calls)
503
+ if report.ok:
504
+ violations = _plain_language_violations(report)
505
+ if not violations or attempt == self._max_attempts:
506
+ if violations:
507
+ report.critique = [
508
+ *report.critique,
509
+ "Plain-language check failed and repair attempts were "
510
+ "exhausted: " + "; ".join(violations),
511
+ ]
512
+ return report
513
+ best_report = report
514
+ last_error = (
515
+ "The analysis itself is fine — do not change it. Only the "
516
+ "plain-language check on \"plain_title\"/\"plain_question\" "
517
+ "failed: " + "; ".join(violations) + ". Rewrite each flagged "
518
+ "field as one jargon-free, everyday sentence (numbers are "
519
+ "fine; acronyms/stats terms/symbols are not), keep "
520
+ "everything else the same, and reprint the full result JSON."
521
+ )
522
+
523
+ if best_report is not None:
524
+ # The plain-language retry crashed or produced something
525
+ # unparseable — fall back to the last working analysis rather
526
+ # than losing every takeaway/chart/hypothesis over a headline.
527
+ best_report.critique = [
528
+ *best_report.critique,
529
+ "A plain-language rewrite attempt failed (" + last_error + "); "
530
+ "kept the original working analysis, whose plain_title/"
531
+ "plain_question violation may be unfixed.",
532
+ ]
533
+ best_report.model_calls = list(self._model_calls)
534
+ return best_report
535
+
536
+ stdout = last_result.stdout if last_result is not None else ""
537
+ stderr = last_result.stderr if last_result is not None else ""
538
+ return ExploratoryAnalysisReport(
539
+ question=question,
540
+ ok=False,
541
+ data_profile=profile,
542
+ code=code,
543
+ stdout=stdout,
544
+ stderr=stderr,
545
+ error=last_error or "exploratory analysis failed",
546
+ attempts=min(self._max_attempts, len(raw_outputs)),
547
+ workdir=str(self._sandbox.workdir),
548
+ raw_outputs=raw_outputs,
549
+ raw_streams=raw_streams,
550
+ agent_audits=agent_audits,
551
+ model_calls=list(self._model_calls),
552
+ )
553
+
554
+ def _write_input(self, rows: list[dict[str, Any]]) -> None:
555
+ path = Path(self._sandbox.workdir) / RECORDS_FILENAME
556
+ path.write_text(json.dumps(rows, default=str), encoding="utf-8")
557
+
558
+ def _write_code(self, question: str, profile: dict[str, Any]) -> tuple[str, str]:
559
+ prompt = _GENERATE_PROMPT_RECORDS.format(
560
+ question=question,
561
+ input_filename=RECORDS_FILENAME,
562
+ data_profile=json.dumps(profile, indent=2, default=str),
563
+ framing=_framing_block(profile.get("outcome") or {}),
564
+ marker=_RESULT_MARKER,
565
+ skills_hint=_skills_hint(self._cli_config),
566
+ fences_hint=_fences_hint(self._cli_config),
567
+ )
568
+ return self._run_writer(prompt, use_inspector=False)
569
+
570
+ def _write_code_raw_folder(
571
+ self,
572
+ question: str,
573
+ folder_scan: dict[str, Any],
574
+ raw_input_dir: str,
575
+ outcome_col: str | None,
576
+ ) -> tuple[str, str]:
577
+ outcome_hint = (
578
+ f'- the caller named "{outcome_col}" as the target/outcome column — '
579
+ "use it as the outcome if it exists in your tidy table, regardless "
580
+ "of any other auto-detection heuristic you might otherwise apply\n"
581
+ if outcome_col else ""
582
+ )
583
+ prompt = _GENERATE_PROMPT_RAW_FOLDER.format(
584
+ question=question,
585
+ input_filename=RECORDS_FILENAME,
586
+ raw_input_dir=raw_input_dir,
587
+ folder_scan=json.dumps(folder_scan, indent=2, default=str),
588
+ outcome_hint=outcome_hint,
589
+ framing=_GENERIC_FRAMING,
590
+ marker=_RESULT_MARKER,
591
+ skills_hint=_skills_hint(self._cli_config),
592
+ fences_hint=_fences_hint(self._cli_config),
593
+ )
594
+ return self._run_cli_writer(prompt)
595
+
596
+ def _repair_code(
597
+ self,
598
+ question: str,
599
+ profile: dict[str, Any],
600
+ code: str,
601
+ result: SandboxResult | None,
602
+ error: str,
603
+ ) -> tuple[str, str]:
604
+ prompt = _REPAIR_PROMPT.format(
605
+ question=question,
606
+ data_profile=json.dumps(profile, indent=2, default=str),
607
+ code=code,
608
+ stdout=(result.stdout if result is not None else "")[-2000:],
609
+ stderr=(result.stderr if result is not None else "")[-2000:],
610
+ error=error,
611
+ input_filename=RECORDS_FILENAME,
612
+ marker=_RESULT_MARKER,
613
+ fences_hint=_fences_hint(self._cli_config),
614
+ )
615
+ return self._run_writer(prompt, use_inspector=True)
616
+
617
+ def _run_writer(self, prompt: str, *, use_inspector: bool) -> tuple[str, str]:
618
+ if self._cli_config is not None and self._cli_config.provider != "llm":
619
+ return self._run_cli_writer(prompt)
620
+ model = self._inspector if use_inspector and self._inspector is not None else self._judge
621
+ started = time.perf_counter()
622
+ try:
623
+ raw = model.generate(prompt) # type: ignore[union-attr]
624
+ except Exception as exc:
625
+ self._model_calls.append({
626
+ "role": "explore_inspector" if use_inspector else "explore_coder",
627
+ "operation": "generate", "inputs": prompt, "output": None,
628
+ "error": str(exc), "duration_sec": time.perf_counter() - started,
629
+ })
630
+ raise
631
+ raw_text = str(raw)
632
+ self._model_calls.append({
633
+ "role": "explore_inspector" if use_inspector else "explore_coder",
634
+ "operation": "generate", "inputs": prompt, "output": raw_text,
635
+ "error": None, "duration_sec": time.perf_counter() - started,
636
+ })
637
+ return _extract_code(raw_text), raw_text
638
+
639
+ def _run_cli_writer(self, prompt: str) -> tuple[str, str]:
640
+ from evalrx.agent_runtime.codegen import CodegenRunner
641
+
642
+ started = time.perf_counter()
643
+ try:
644
+ result = CodegenRunner(self._cli_config).write_code( # type: ignore[arg-type]
645
+ prompt,
646
+ workdir=Path(self._sandbox.workdir),
647
+ timeout_sec=self._timeout_sec,
648
+ preferred_filenames=("analysis.py",),
649
+ include_error_in_raw=True,
650
+ )
651
+ except Exception as exc:
652
+ self._model_calls.append({
653
+ "role": "explore_cli_coder", "operation": "write_code",
654
+ "inputs": prompt, "output": None, "error": str(exc),
655
+ "duration_sec": time.perf_counter() - started,
656
+ })
657
+ raise
658
+ self._model_calls.append({
659
+ "role": "explore_cli_coder", "operation": "write_code",
660
+ "inputs": prompt, "output": result.raw_output, "error": None,
661
+ "duration_sec": time.perf_counter() - started,
662
+ "metadata": {"audit": result.audit},
663
+ })
664
+ self._last_agent_audit = result.audit
665
+ # Snapshot the untruncated raw stream NOW: the next repair attempt in
666
+ # the loop overwrites agent_raw_stream.txt in the shared workdir.
667
+ self._last_raw_stream = ""
668
+ if getattr(result, "raw_stream_path", ""):
669
+ try:
670
+ stream_p = Path(self._sandbox.workdir) / result.raw_stream_path
671
+ self._last_raw_stream = stream_p.read_text(encoding="utf-8")
672
+ except OSError:
673
+ pass
674
+ return result.code, result.raw_output
675
+
676
+
677
+ def _records_to_rows(records: Any) -> list[dict[str, Any]]:
678
+ if records is None:
679
+ return []
680
+ if hasattr(records, "to_dict"):
681
+ try:
682
+ data = records.to_dict(orient="records")
683
+ if isinstance(data, list):
684
+ return [dict(r) for r in data if isinstance(r, dict)]
685
+ except TypeError:
686
+ pass
687
+ rows: list[dict[str, Any]] = []
688
+ for row in list(records):
689
+ if isinstance(row, dict):
690
+ rows.append(dict(row))
691
+ elif hasattr(row, "_asdict"):
692
+ rows.append(dict(row._asdict()))
693
+ elif hasattr(row, "__dict__"):
694
+ rows.append(dict(vars(row)))
695
+ else:
696
+ rows.append({"value": row})
697
+ return rows
698
+
699
+
700
+ def _discover_json_files(
701
+ root: Path, *, max_files: int, include_tool_calls: bool
702
+ ) -> tuple[list[Path], int]:
703
+ """Find the JSON/JSONL files ``load_records_from_path`` would read.
704
+
705
+ Returns ``(files_used, n_matching_before_filter_and_cap)`` — the second
706
+ value counts every ``*.json`` file on disk (including ``tool_calls_*``),
707
+ so :func:`scan_folder` can show how many were excluded/capped, not just
708
+ how many survived."""
709
+ if root.is_file():
710
+ return [root], 1
711
+ all_json = sorted(root.rglob("*.json"))
712
+ files = all_json
713
+ if not include_tool_calls:
714
+ files = [p for p in files if not p.name.startswith("tool_calls_")]
715
+ return files[:max_files], len(all_json)
716
+
717
+
718
+ def scan_folder(
719
+ path: str | Path,
720
+ *,
721
+ max_files: int = 200,
722
+ include_tool_calls: bool = False,
723
+ max_listing: int = 60,
724
+ max_scan_entries: int = 200_000,
725
+ ) -> dict[str, Any]:
726
+ """Filesystem-level summary of what a folder actually contains.
727
+
728
+ This is deliberately independent of :class:`~evalrx.analysis.profile.
729
+ DatasetProfile` (which describes parsed row/column structure): it answers
730
+ "what did the agent see on disk" — file/dir counts, extension mix, and how
731
+ many of the discovered JSON files were actually sampled — since that
732
+ differs from folder to folder and is useful to see before any row-level
733
+ parsing happens.
734
+
735
+ Uses a single ``os.walk`` pass rather than ``Path.rglob`` + per-entry
736
+ ``is_file()``/``is_dir()`` checks: real run directories can hold tens of
737
+ thousands of files (one JSON per tool call), and ``rglob`` results lose
738
+ the cached direntry type, so each check becomes its own ``stat()`` — on
739
+ networked storage that made this function slower than the actual row
740
+ loader it's meant to summarize. ``max_scan_entries`` is a hard backstop so
741
+ an unexpectedly huge tree degrades (partial counts, flagged as capped)
742
+ instead of hanging.
743
+ """
744
+ root = Path(path)
745
+ if root.is_file():
746
+ return {
747
+ "root": str(root),
748
+ "is_file": True,
749
+ "n_files_total": 1,
750
+ "n_dirs": 0,
751
+ "extensions": {root.suffix.lower() or "(no extension)": 1},
752
+ "json_files_found": 1,
753
+ "json_files_used": 1,
754
+ "entries": [root.name],
755
+ "truncated": False,
756
+ "scan_capped": False,
757
+ }
758
+
759
+ n_files = 0
760
+ n_dirs = 0
761
+ n_json_found = 0
762
+ n_json_after_filter = 0
763
+ ext_counts: dict[str, int] = {}
764
+ entries: list[str] = []
765
+ scan_capped = False
766
+ scanned = 0
767
+
768
+ for dirpath, dirnames, filenames in os.walk(root):
769
+ dirnames.sort()
770
+ filenames.sort()
771
+ rel_dir = Path(dirpath).relative_to(root)
772
+
773
+ for name in dirnames:
774
+ n_dirs += 1
775
+ scanned += 1
776
+ if len(entries) < max_listing:
777
+ rel = name if rel_dir == Path(".") else str(rel_dir / name)
778
+ entries.append(rel + "/")
779
+
780
+ for name in filenames:
781
+ n_files += 1
782
+ scanned += 1
783
+ ext = Path(name).suffix.lower() or "(no extension)"
784
+ ext_counts[ext] = ext_counts.get(ext, 0) + 1
785
+ if name.endswith(".json"):
786
+ n_json_found += 1
787
+ if include_tool_calls or not name.startswith("tool_calls_"):
788
+ n_json_after_filter += 1
789
+ if len(entries) < max_listing:
790
+ rel = name if rel_dir == Path(".") else str(rel_dir / name)
791
+ entries.append(rel)
792
+
793
+ if scanned >= max_scan_entries:
794
+ scan_capped = True
795
+ break
796
+
797
+ entries.sort()
798
+ return {
799
+ "root": str(root),
800
+ "is_file": False,
801
+ "n_files_total": n_files,
802
+ "n_dirs": n_dirs,
803
+ "extensions": dict(sorted(ext_counts.items(), key=lambda kv: -kv[1])),
804
+ "json_files_found": n_json_found,
805
+ "json_files_used": min(n_json_after_filter, max_files),
806
+ "entries": entries[:max_listing],
807
+ "truncated": (n_files + n_dirs) > max_listing,
808
+ "scan_capped": scan_capped,
809
+ }
810
+
811
+
812
+ def load_records_from_path(
813
+ path: str | Path,
814
+ *,
815
+ max_rows: int = 2000,
816
+ max_files: int = 200,
817
+ include_tool_calls: bool = False,
818
+ ) -> list[dict[str, Any]]:
819
+ """Load a bounded sample of JSON/JSONL records from a file or directory."""
820
+ root = Path(path)
821
+ files, _ = _discover_json_files(root, max_files=max_files, include_tool_calls=include_tool_calls)
822
+ per_file_limit = max(1, max_rows // max(1, len(files)))
823
+ rows: list[dict[str, Any]] = []
824
+ for file_path in files:
825
+ n_from_file = 0
826
+ for row in _load_json_records(file_path):
827
+ flat = _flatten_record(row)
828
+ flat["_source_file"] = str(file_path)
829
+ flat["_source_dir"] = file_path.parent.name
830
+ rows.append(flat)
831
+ n_from_file += 1
832
+ if len(rows) >= max_rows:
833
+ return rows
834
+ if n_from_file >= per_file_limit:
835
+ break
836
+ return rows
837
+
838
+
839
+ _RECORD_LIST_KEYS = ("cases", "records", "results", "rows", "items", "data", "examples", "samples")
840
+
841
+
842
+ def _load_json_records(path: Path) -> list[Any]:
843
+ try:
844
+ text = path.read_text(encoding="utf-8", errors="replace")
845
+ except OSError:
846
+ return []
847
+ stripped = text.strip()
848
+ if not stripped:
849
+ return []
850
+ try:
851
+ parsed = json.loads(stripped)
852
+ if isinstance(parsed, list):
853
+ return parsed
854
+ if isinstance(parsed, dict):
855
+ unpacked = _unpack_record_container(parsed)
856
+ if unpacked is not None:
857
+ return unpacked
858
+ return [parsed]
859
+ except json.JSONDecodeError:
860
+ pass
861
+ records: list[Any] = []
862
+ for line in text.splitlines():
863
+ line = line.strip()
864
+ if not line:
865
+ continue
866
+ try:
867
+ records.append(json.loads(line))
868
+ except json.JSONDecodeError:
869
+ continue
870
+ return records
871
+
872
+
873
+ def _unpack_record_container(parsed: dict[str, Any]) -> list[dict[str, Any]] | None:
874
+ """Recognize a common M1-output shape: one JSON object per run/model, with
875
+ a list of per-case dicts under a conventional key (``cases``, ``results``,
876
+ ...) alongside scalar run metadata (e.g. ``model``, ``seed``). Unpack it
877
+ into one row per case, each carrying the file's scalar metadata merged in
878
+ (per-case fields win on collision) — so a directory of these files loads
879
+ as a flat, analyzable table without a bespoke pre-processing script."""
880
+ list_key = next(
881
+ (
882
+ key
883
+ for key in _RECORD_LIST_KEYS
884
+ if isinstance(parsed.get(key), list)
885
+ and parsed[key]
886
+ and all(isinstance(item, dict) for item in parsed[key])
887
+ ),
888
+ None,
889
+ )
890
+ if list_key is None:
891
+ return None
892
+ meta = {
893
+ key: value
894
+ for key, value in parsed.items()
895
+ if key != list_key and (isinstance(value, (str, int, float, bool)) or value is None)
896
+ }
897
+ return [{**meta, **item} for item in parsed[list_key]]
898
+
899
+
900
+ def _flatten_record(record: Any, *, max_text: int = 500) -> dict[str, Any]:
901
+ if not isinstance(record, dict):
902
+ return {"value": _shorten(record, max_text)}
903
+
904
+ out: dict[str, Any] = {}
905
+ for key, value in record.items():
906
+ if isinstance(value, (str, int, float, bool)) or value is None:
907
+ out[str(key)] = _shorten(value, max_text)
908
+ elif isinstance(value, dict):
909
+ for sub_key, sub_value in value.items():
910
+ flat_key = f"{key}.{sub_key}"
911
+ if isinstance(sub_value, (str, int, float, bool)) or sub_value is None:
912
+ out[flat_key] = _shorten(sub_value, max_text)
913
+ elif isinstance(value, list):
914
+ out[f"{key}._len"] = len(value)
915
+ if key == "model_answer":
916
+ out["model_answer_text"] = _shorten(_join_text_blocks(value), max_text)
917
+ else:
918
+ out[str(key)] = _shorten(value, max_text)
919
+
920
+ if "is_correct" in out:
921
+ out.setdefault("label", "pass" if bool(out["is_correct"]) else "fail")
922
+ attempts = record.get("attempts")
923
+ if isinstance(attempts, list):
924
+ out.setdefault("attempts_count", len(attempts))
925
+ trace = record.get("trace")
926
+ if isinstance(trace, list):
927
+ out.setdefault("trace_steps", len(trace))
928
+ return out
929
+
930
+
931
+ def _join_text_blocks(value: list[Any]) -> str:
932
+ parts: list[str] = []
933
+ for item in value:
934
+ if isinstance(item, dict) and item.get("type") == "text":
935
+ parts.append(str(item.get("text", "")))
936
+ elif isinstance(item, str):
937
+ parts.append(item)
938
+ return "\n".join(parts)
939
+
940
+
941
+ def _shorten(value: Any, max_text: int) -> Any:
942
+ if not isinstance(value, str):
943
+ return value
944
+ if len(value) <= max_text:
945
+ return value
946
+ return value[:max_text] + f"... [truncated {len(value) - max_text} chars]"
947
+
948
+
949
+ def _profile_rows(
950
+ rows: list[dict[str, Any]],
951
+ *,
952
+ outcome_col: str | None = None,
953
+ sample_size: int = 5,
954
+ ) -> dict[str, Any]:
955
+ """Profile rows for the explorer prompt, including outcome-kind detection.
956
+
957
+ Delegates role/dtype inference to :func:`profile_records` (the shared,
958
+ domain-agnostic profiler) rather than re-deriving it from raw values, so
959
+ the prompt can adapt to binary / categorical / continuous / no-outcome
960
+ data instead of always assuming a FAIL/PASS split.
961
+ """
962
+ profile = profile_records(rows, outcome_col=outcome_col)
963
+ outcome = describe_outcome(profile)
964
+ numeric_columns = [
965
+ c.name for c in profile.columns.values()
966
+ if c.dtype == "numeric" and c.role == "predictor"
967
+ ]
968
+ categorical_columns = [
969
+ c.name for c in profile.columns.values()
970
+ if c.dtype in ("categorical", "boolean") and c.role == "predictor"
971
+ ]
972
+ label_like = [outcome["column"]] if outcome["present"] else []
973
+ return {
974
+ "n_rows": profile.n_rows,
975
+ "grain": profile.grain,
976
+ "columns": {name: c.to_dict() for name, c in profile.columns.items()},
977
+ "numeric_columns": numeric_columns,
978
+ "categorical_columns": categorical_columns,
979
+ "label_like_columns": label_like,
980
+ "id_columns": profile.id_columns,
981
+ "group_columns": profile.group_columns,
982
+ "time_columns": profile.time_columns,
983
+ "outcome": outcome,
984
+ "warnings": profile.warnings,
985
+ "sample_rows": rows[:sample_size],
986
+ }
987
+
988
+
989
+ def _framing_block(outcome: dict[str, Any]) -> str:
990
+ """Build the prompt's outcome-specific framing + standard chart battery.
991
+
992
+ This is what stops the explorer from forcing a FAIL/PASS story onto data
993
+ that has a multi-class, continuous, or absent outcome. ``outcome`` is the
994
+ ``describe_outcome()`` dict already embedded in the data profile.
995
+ """
996
+ kind = outcome.get("kind", "none")
997
+ col = outcome.get("column")
998
+ unique = outcome.get("unique", 0)
999
+
1000
+ if kind == "binary":
1001
+ return f"""\
1002
+ OUTCOME: "{col}" is a BINARY outcome (host detected {unique} distinct values).
1003
+ Call the two groups FAIL and PASS (map the two outcome values to whichever
1004
+ reads as the negative/positive case) and tell the FAIL-vs-PASS story.
1005
+
1006
+ As a minimum, consider this standard battery when the columns exist:
1007
+ 1. Class balance: count of FAIL vs PASS overall (and per group column if present).
1008
+ 2. Per numeric signal — how it separates FAIL vs PASS: a distribution view
1009
+ (violin/box/strip, drawn as a PNG) AND a binned fail-rate curve spec
1010
+ (bin -> fail_rate, n per bin). The curve is the deterministic spec for a
1011
+ numeric signal — do not emit a two-group "mean by outcome" spec instead.
1012
+ 3. Top discriminators: a ranked bar of each signal's FAIL-vs-PASS separation
1013
+ (e.g. standardized mean difference / |meanFAIL - meanPASS| / s), largest first.
1014
+ 4. Fail rate by each categorical group column: group -> fail_rate with n and
1015
+ n_fail per group (the host draws <= 3 groups as dot + 95% CI, never bars).
1016
+ 5. Signal correlations: a correlation table and, when helpful, a heatmap PNG.
1017
+ 6. 1-2 scatter plots of the most discriminative signal pairs, coloured by outcome."""
1018
+
1019
+ if kind == "categorical":
1020
+ return f"""\
1021
+ OUTCOME: "{col}" is a CATEGORICAL outcome with {unique} distinct classes. Tell
1022
+ the per-class story — do NOT collapse it into a binary FAIL/PASS split.
1023
+
1024
+ As a minimum, consider this standard battery when the columns exist:
1025
+ 1. Class balance: count per class overall (and per group column if present).
1026
+ 2. Per numeric signal — how its distribution differs across classes (box/violin
1027
+ per class), AND a ranked bar of each signal's cross-class separation (e.g.
1028
+ between-class variance ratio or ANOVA-style F statistic), largest first.
1029
+ 3. Class composition by each categorical group column (grouped/stacked bar).
1030
+ 4. Signal correlations: a correlation table and, when helpful, a heatmap PNG.
1031
+ 5. 1-2 scatter plots of the most discriminative signal pairs, coloured by class."""
1032
+
1033
+ if kind == "continuous":
1034
+ return f"""\
1035
+ OUTCOME: "{col}" is a CONTINUOUS outcome ({unique} distinct values seen). This
1036
+ is a correlation/regression-style story — there is no FAIL/PASS split to
1037
+ invent; do not binarize the outcome unless the question explicitly asks for it.
1038
+
1039
+ As a minimum, consider this standard battery when the columns exist:
1040
+ 1. Outcome distribution (histogram).
1041
+ 2. Per numeric signal vs outcome: scatter with a trend line, AND a binned
1042
+ mean-outcome curve (bin -> mean {col}).
1043
+ 3. Top associates: a ranked bar of each signal's correlation magnitude with
1044
+ the outcome (Pearson and/or Spearman), largest first.
1045
+ 4. Outcome distribution by each categorical group column (box/violin per group).
1046
+ 5. Signal correlations among predictors: a correlation table and heatmap PNG.
1047
+ 6. 1-2 scatter plots of the most associated signal pairs, coloured/sized by outcome."""
1048
+
1049
+ return """\
1050
+ OUTCOME: no recognizable outcome/target column was found. This is UNSUPERVISED
1051
+ exploration — describe the dataset's structure; do NOT invent a FAIL/PASS or
1052
+ any other label that is not actually in the data.
1053
+
1054
+ As a minimum, consider this standard battery when the columns exist:
1055
+ 1. Missingness overview: non-null rate per column (bar).
1056
+ 2. Per numeric column: distribution (histogram/box).
1057
+ 3. Per categorical column: value counts (bar); skip columns with very high
1058
+ cardinality (say >20 distinct values) as a bar chart and note it in caveats.
1059
+ 4. Signal correlations: a correlation table and, when helpful, a heatmap PNG
1060
+ among the numeric columns.
1061
+ 5. 1-2 scatter plots of the most correlated numeric pairs.
1062
+ 6. If a group or time column exists, contrast numeric distributions across
1063
+ its groups/periods; otherwise skip this item."""
1064
+
1065
+
1066
+ def _plain_language_violations(report: ExploratoryAnalysisReport) -> list[str]:
1067
+ """Host-side check behind the prompt's ``plain_title``/``plain_question``
1068
+ requirement — the prompt already asks for jargon-free headlines, but that
1069
+ alone doesn't hold, so this is what actually gates a retry (see
1070
+ ``_run_explore_loop``)."""
1071
+ violations = []
1072
+ for i, t in enumerate(report.takeaways, start=1):
1073
+ reason = jargon_violation(t.plain_title, t.title)
1074
+ if reason:
1075
+ violations.append(f'takeaway {i}\'s "plain_title" {reason}: {t.plain_title!r}')
1076
+ # The caller's own `question` is often already plain (short, jargon-free)
1077
+ # — don't force a pointless rewrite of a fine question just because
1078
+ # plain_question is empty or a verbatim copy in that case.
1079
+ if jargon_violation(report.question) is not None:
1080
+ reason = jargon_violation(report.plain_question, report.question)
1081
+ if reason:
1082
+ violations.append(f'"plain_question" {reason}: {report.plain_question!r}')
1083
+ return violations
1084
+
1085
+
1086
+ def _report_from_sandbox(
1087
+ *,
1088
+ question: str,
1089
+ profile: dict[str, Any],
1090
+ code: str,
1091
+ result: SandboxResult,
1092
+ attempts: int,
1093
+ workdir: Path,
1094
+ ) -> tuple[ExploratoryAnalysisReport, str]:
1095
+ if not result.ok:
1096
+ err = (result.stderr or "").strip() or "sandbox run failed"
1097
+ return (
1098
+ ExploratoryAnalysisReport(
1099
+ question=question,
1100
+ ok=False,
1101
+ data_profile=profile,
1102
+ code=code,
1103
+ stdout=result.stdout,
1104
+ stderr=result.stderr,
1105
+ error=err,
1106
+ attempts=attempts,
1107
+ workdir=str(workdir),
1108
+ ),
1109
+ err,
1110
+ )
1111
+
1112
+ parsed, err = _parse_result_json(result.stdout)
1113
+ if err:
1114
+ return (
1115
+ ExploratoryAnalysisReport(
1116
+ question=question,
1117
+ ok=False,
1118
+ data_profile=profile,
1119
+ code=code,
1120
+ stdout=result.stdout,
1121
+ stderr=result.stderr,
1122
+ error=err,
1123
+ attempts=attempts,
1124
+ workdir=str(workdir),
1125
+ ),
1126
+ err,
1127
+ )
1128
+
1129
+ plots = _normalize_plot_paths(parsed.get("plots", []), workdir)
1130
+ takeaways = [
1131
+ Takeaway(
1132
+ title=str(item.get("title", "")),
1133
+ plain_title=str(item.get("plain_title", "")),
1134
+ analysis=str(item.get("analysis", "")),
1135
+ chart_names=[str(x) for x in item.get("chart_names", []) or []],
1136
+ table_names=[str(x) for x in item.get("table_names", []) or []],
1137
+ caveat=str(item.get("caveat", "")),
1138
+ )
1139
+ for item in parsed.get("takeaways", []) or []
1140
+ if isinstance(item, dict) and (item.get("title") or item.get("plain_title"))
1141
+ ]
1142
+ signals = [
1143
+ CandidateSignal(
1144
+ name=str(item.get("name", "")),
1145
+ display_name=str(item.get("display_name", "")),
1146
+ rationale=str(item.get("rationale", "")),
1147
+ suggested_test=str(item.get("suggested_test", "")),
1148
+ sufficient=item["sufficient"] if isinstance(item.get("sufficient"), dict) else None,
1149
+ recipe=item["recipe"] if isinstance(item.get("recipe"), dict) else None,
1150
+ )
1151
+ for item in parsed.get("candidate_signals", []) or []
1152
+ if isinstance(item, dict) and item.get("name")
1153
+ ]
1154
+ return (
1155
+ ExploratoryAnalysisReport(
1156
+ question=question,
1157
+ plain_question=str(parsed.get("plain_question", "")),
1158
+ ok=True,
1159
+ observations=[str(x) for x in parsed.get("observations", []) or []],
1160
+ takeaways=takeaways,
1161
+ visual_plan=_normalize_visual_plan(parsed.get("visual_plan", [])),
1162
+ chart_readings=_normalize_list_of_dicts(parsed.get("chart_readings", [])),
1163
+ dashboard_storyboard=_normalize_list_of_dicts(parsed.get("dashboard_storyboard", [])),
1164
+ claims=_normalize_list_of_dicts(parsed.get("claims", [])),
1165
+ candidate_signals=signals,
1166
+ plots=plots,
1167
+ tables=dict(parsed.get("tables", {}) or {}),
1168
+ charts=[dict(x) for x in parsed.get("charts", []) or [] if isinstance(x, dict)],
1169
+ caveats=[str(x) for x in parsed.get("caveats", []) or []],
1170
+ critique=[str(x) for x in parsed.get("critique", []) or []],
1171
+ recommended_confirmatory_tests=[
1172
+ str(x) for x in parsed.get("recommended_confirmatory_tests", []) or []
1173
+ ],
1174
+ data_profile=profile,
1175
+ code=code,
1176
+ stdout=result.stdout,
1177
+ stderr=result.stderr,
1178
+ attempts=attempts,
1179
+ workdir=str(workdir),
1180
+ ),
1181
+ "",
1182
+ )
1183
+
1184
+
1185
+ def _parse_result_json(stdout: str) -> tuple[dict[str, Any], str]:
1186
+ """The explorer's result object: the JSON after the last ``EXPLORATORY_RESULT_JSON=``.
1187
+
1188
+ Multi-line (``json.dumps(..., indent=2)``) payloads are accepted — see
1189
+ :mod:`evalrx.analysis.result_marker` for the failure that motivated it.
1190
+ """
1191
+ from evalrx.analysis.result_marker import extract_marker_json
1192
+
1193
+ parsed, error = extract_marker_json(stdout, _RESULT_MARKER)
1194
+ if error:
1195
+ return {}, error
1196
+ if not isinstance(parsed, dict):
1197
+ return {}, f"{_RESULT_MARKER} payload must be a JSON object"
1198
+ return parsed, ""
1199
+
1200
+
1201
+ def _normalize_visual_plan(raw: Any) -> list[dict[str, Any]]:
1202
+ """Keep agent plot-type decisions auditable without making old reports invalid."""
1203
+ plan: list[dict[str, Any]] = []
1204
+ for idx, item in enumerate(raw or []):
1205
+ if not isinstance(item, dict):
1206
+ continue
1207
+ cleaned: dict[str, Any] = {}
1208
+ for key in (
1209
+ "name",
1210
+ "display_name",
1211
+ "question",
1212
+ "data_shape",
1213
+ "plot_kind",
1214
+ "fallback_kind",
1215
+ "rationale",
1216
+ "status",
1217
+ "disposition",
1218
+ "not_promoted_reason",
1219
+ ):
1220
+ if item.get(key) is not None:
1221
+ cleaned[key] = str(item.get(key))
1222
+ cols = item.get("required_columns")
1223
+ if isinstance(cols, list):
1224
+ cleaned["required_columns"] = [str(c) for c in cols]
1225
+ elif cols is not None:
1226
+ cleaned["required_columns"] = [str(cols)]
1227
+ if "name" not in cleaned:
1228
+ cleaned["name"] = f"visual_{idx}"
1229
+ plan.append(cleaned)
1230
+ return plan
1231
+
1232
+
1233
+ def _normalize_list_of_dicts(raw: Any) -> list[dict[str, Any]]:
1234
+ out: list[dict[str, Any]] = []
1235
+ for item in raw or []:
1236
+ if not isinstance(item, dict):
1237
+ continue
1238
+ cleaned: dict[str, Any] = {}
1239
+ for key, value in item.items():
1240
+ if isinstance(value, (str, int, float, bool)) or value is None:
1241
+ cleaned[str(key)] = value
1242
+ elif isinstance(value, list):
1243
+ cleaned[str(key)] = [str(x) for x in value]
1244
+ elif isinstance(value, dict):
1245
+ cleaned[str(key)] = {str(k): str(v) for k, v in value.items()}
1246
+ else:
1247
+ cleaned[str(key)] = str(value)
1248
+ out.append(cleaned)
1249
+ return out
1250
+
1251
+
1252
+ def _normalize_plot_paths(raw: Any, workdir: Path) -> list[str]:
1253
+ paths: list[str] = []
1254
+ for item in raw or []:
1255
+ text = str(item)
1256
+ path = Path(text)
1257
+ if not path.is_absolute():
1258
+ candidate = workdir / path
1259
+ if candidate.exists():
1260
+ path = candidate
1261
+ paths.append(str(path))
1262
+ figures_dir = workdir / "figures"
1263
+ if figures_dir.exists():
1264
+ for png in sorted(figures_dir.glob("*.png")):
1265
+ p = str(png)
1266
+ if p not in paths:
1267
+ paths.append(p)
1268
+ return paths
1269
+
1270
+
1271
+ def _extract_code(raw: str) -> str:
1272
+ cleaned = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL)
1273
+ fence = re.search(r"```(?:python)?\s*\n(.*?)```", cleaned, flags=re.DOTALL)
1274
+ if fence:
1275
+ return fence.group(1).strip()
1276
+ return cleaned.strip()