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,1634 @@
1
+ """ExperimentWriter — multi-phase LLM or CLI agent writes diagnostic code.
2
+
3
+ Mirrors the multi-phase CodeAgent from ``researchclaw/pipeline/code_agent.py``,
4
+ adapted for evalrx's hypothesis-testing context.
5
+
6
+ Six execution phases (LLM path):
7
+
8
+ Phase 1 · Blueprint LLM produces a YAML spec with file list, per-file
9
+ pseudocode, dependency order, and output contract.
10
+ Phase 2 · Sequential Generate each file following blueprint order;
11
+ each prior file is summarised via AST (CodeMem)
12
+ and injected as context for the next.
13
+ Phase 3 · Hard-validate AST-parse all files; critical issues (SyntaxError,
14
+ missing main guard, unresolvable cross-file imports)
15
+ trigger targeted repair; warnings are logged only.
16
+ Phase 4 · Exec-fix Run project via ``sandbox.run_project()``. Feed
17
+ stderr back to LLM for targeted single-file repair;
18
+ retry up to ``exec_fix_max_iterations`` times.
19
+ Phase 5 · Tree-search Explore multiple blueprint variants, score by
20
+ sandbox metrics, select best (off by default).
21
+ Phase 6 · Review Coder-reviewer dialog; revert if run degrades
22
+ (off by default, ``review_max_rounds=0``).
23
+
24
+ CLI agent path (``provider != "llm"``)
25
+ The CLI agent writes + self-repairs; we collect ``experiment.py``
26
+ (or first .py file) and run once via ``sandbox.run_project()``.
27
+ No exec-fix loop — the agent self-repairs during its own run.
28
+
29
+ Backward compatibility
30
+ The public ``write_and_run(hypothesis, model_context, cases_json, sandbox)``
31
+ signature is unchanged. With default config the LLM path runs Phase 2
32
+ (single-pass write) → Phase 3 → Phase 4, identical to the previous 3-phase
33
+ implementation. ``result.code`` always equals ``result.files["main.py"]``
34
+ (or the single-file code) so existing callers are unaffected.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import ast
40
+ import json
41
+ import logging
42
+ import re
43
+ import tempfile
44
+ from dataclasses import dataclass, field
45
+ from pathlib import Path
46
+ from typing import TYPE_CHECKING, Any
47
+
48
+ from evalrx.agent_runtime.sandbox import ExperimentSandbox, SandboxResult
49
+ from evalrx.eval_agent.prompts.experiment_writer import (
50
+ _BLUEPRINT_SYSTEM,
51
+ _BLUEPRINT_USER,
52
+ _GENERATE_FILE_SYSTEM,
53
+ _GENERATE_FILE_USER,
54
+ _REPAIR_SYSTEM,
55
+ _REPAIR_USER,
56
+ _REVIEW_SYSTEM,
57
+ _WRITE_SYSTEM,
58
+ _WRITE_USER,
59
+ build_cli_prompt,
60
+ )
61
+
62
+ if TYPE_CHECKING:
63
+ from evalrx.agent_runtime.sandbox import SandboxProtocol
64
+ from evalrx.core.model import Model
65
+ from evalrx.eval_agent.hypothesis import Hypothesis
66
+
67
+ logger = logging.getLogger(__name__)
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Configuration
71
+ # ---------------------------------------------------------------------------
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class ExperimentWriterConfig:
76
+ """Controls which phases run and their limits.
77
+
78
+ All new fields default to off / zero so that the existing 3-phase
79
+ behaviour (write → validate → exec-fix) is preserved by default.
80
+
81
+ To use a CLI coding agent for M5 instead of the single-pass LLM::
82
+
83
+ from evalrx.agent_runtime.cli_types import CliAgentConfig
84
+ cfg = ExperimentWriterConfig(
85
+ cli_agent=CliAgentConfig(provider="claude_code", model="sonnet"),
86
+ )
87
+
88
+ To enable the full multi-phase CodeAgent flow::
89
+
90
+ cfg = ExperimentWriterConfig(
91
+ blueprint_enabled=True,
92
+ sequential_generation=True,
93
+ review_max_rounds=1,
94
+ )
95
+ """
96
+
97
+ # Phase 1: blueprint planning (LLM outlines file structure first)
98
+ blueprint_enabled: bool = False
99
+
100
+ # Phase 2: sequential file generation following the blueprint
101
+ sequential_generation: bool = False
102
+
103
+ # Phase 3: AST validation + repair
104
+ hard_validation: bool = True
105
+ hard_validation_max_repairs: int = 3
106
+
107
+ # Phase 4: exec-fix loop
108
+ exec_fix_max_iterations: int = 3
109
+ exec_fix_timeout_sec: int = 60
110
+
111
+ # Phase 5: solution tree search (off by default — multiplies cost)
112
+ tree_search_enabled: bool = False
113
+ tree_search_candidates: int = 3
114
+ tree_search_max_depth: int = 2
115
+
116
+ # Phase 6: review dialog (off by default)
117
+ review_max_rounds: int = 0
118
+
119
+ # CLI agent dispatch — None → LLM path (backward-compatible default)
120
+ cli_agent: Any = None
121
+
122
+
123
+ # ---------------------------------------------------------------------------
124
+ # Result
125
+ # ---------------------------------------------------------------------------
126
+
127
+
128
+ @dataclass
129
+ class ExperimentWriterResult:
130
+ """Output of :class:`ExperimentWriter.write_and_run`.
131
+
132
+ Attributes:
133
+ code: ``files["main.py"]`` or the single-file code.
134
+ Kept for backward compatibility.
135
+ files: Multi-file output dict. ``{}`` in single-file mode.
136
+ blueprint: YAML spec produced in Phase 1; empty if disabled.
137
+ metrics: ``{name: float}`` parsed from stdout.
138
+ verdict: ``1.0`` = SUPPORTED, ``0.0`` = REFUTED,
139
+ ``None`` = no verdict printed (INCONCLUSIVE).
140
+ stdout / stderr: Raw subprocess output from the last run.
141
+ returncode: Exit code of the last sandbox run.
142
+ timed_out: ``True`` when the process was killed.
143
+ total_llm_calls: Number of LLM calls made across all phases.
144
+ total_sandbox_runs: Number of sandbox runs (initial + retries).
145
+ validation_log: Ordered list of events for debugging.
146
+ cli_raw_output: The CLI agent's narration while writing the code — for
147
+ ``stream-json`` providers this is the full rendered
148
+ coding trajectory (tool calls + results). Empty on
149
+ the LLM path (use ``validation_log`` there).
150
+ cli_usage: Token/cost usage reported by the CLI agent, or
151
+ ``None`` on the LLM path / when unavailable.
152
+ provider: Which backend wrote the code (``"llm"`` or the CLI
153
+ provider name, e.g. ``"claude_code"``).
154
+ workdir: Path to the working directory the agent operated in,
155
+ so callers can snapshot it before sandbox cleanup.
156
+ """
157
+
158
+ code: str = ""
159
+ files: dict[str, str] = field(default_factory=dict)
160
+ blueprint: str = ""
161
+ metrics: dict[str, float] = field(default_factory=dict)
162
+ verdict: float | None = None
163
+ stdout: str = ""
164
+ stderr: str = ""
165
+ returncode: int = -1
166
+ timed_out: bool = False
167
+ total_llm_calls: int = 0
168
+ total_sandbox_runs: int = 0
169
+ validation_log: list[str] = field(default_factory=list)
170
+ cli_raw_output: str = ""
171
+ cli_usage: dict | None = None
172
+ provider: str = "llm"
173
+ workdir: str = ""
174
+ model_calls: "list[dict[str, Any]]" = field(default_factory=list)
175
+
176
+ @property
177
+ def ok(self) -> bool:
178
+ return self.returncode == 0 and not self.timed_out
179
+
180
+
181
+ # ---------------------------------------------------------------------------
182
+ # Solution node (tree search)
183
+ # ---------------------------------------------------------------------------
184
+
185
+
186
+ @dataclass
187
+ class SolutionNode:
188
+ """One candidate in the tree-search phase."""
189
+
190
+ node_id: str
191
+ files: dict[str, str]
192
+ parent_id: str | None = None
193
+ depth: int = 0
194
+ runs_ok: bool = False
195
+ returncode: int = -1
196
+ evaluated: bool = False
197
+ stdout: str = ""
198
+ stderr: str = ""
199
+ metrics: dict[str, Any] = field(default_factory=dict)
200
+ score: float = 0.0
201
+ generation_method: str = "initial"
202
+
203
+
204
+ # ---------------------------------------------------------------------------
205
+ # Model context helper
206
+ # ---------------------------------------------------------------------------
207
+
208
+
209
+ def build_model_context(
210
+ model: "Model", *, allow_reconstruction: bool = True
211
+ ) -> dict[str, Any]:
212
+ """Extract the information needed to reconstruct *model* inside a subprocess.
213
+
214
+ Returns a dict with:
215
+ ``import_expr`` — Python import line(s)
216
+ ``load_expr`` — expression that produces the model object
217
+ ``capabilities`` — sorted list of capability name strings
218
+ """
219
+ caps = sorted(str(c.name) for c in getattr(model, "capabilities", frozenset()))
220
+
221
+ if not allow_reconstruction:
222
+ return {
223
+ "import_expr": (
224
+ "# The host model is already resident and is intentionally not "
225
+ "reconstructed in this child process."
226
+ ),
227
+ "load_expr": "None",
228
+ "capabilities": ["STORED_OUTPUTS_ONLY"],
229
+ "access_mode": "artifacts_only",
230
+ "access_note": (
231
+ "The model is unavailable inside the sandbox. Use only each case's "
232
+ "stored observed output, expected answer, label, and metadata. Do not "
233
+ "call evalrx.load, compose, from_pretrained, any model API, or the "
234
+ "network. If the hypothesis requires a fresh model intervention, print "
235
+ "inconclusive: 1.0 as the final line and do not print a verdict."
236
+ ),
237
+ }
238
+
239
+ spec = getattr(model, "spec", None)
240
+ if spec is not None and getattr(spec, "key", None):
241
+ runtime = getattr(model, "runtime", None)
242
+ device = getattr(runtime, "device", None)
243
+ dtype = getattr(runtime, "dtype", None)
244
+ # Infer want from capabilities so the sandbox loads with the same features.
245
+ want: list[str] = []
246
+ for cap in getattr(model, "capabilities", frozenset()):
247
+ cname = str(getattr(cap, "name", cap)).lower()
248
+ if cname in ("attention", "hidden_states", "logits"):
249
+ want.append(cname)
250
+ kwargs_parts = []
251
+ if device:
252
+ kwargs_parts.append(f"device={device!r}")
253
+ if dtype:
254
+ kwargs_parts.append(f"dtype={dtype!r}")
255
+ if want:
256
+ kwargs_parts.append(f"want={want!r}")
257
+ kwargs_str = (", " + ", ".join(kwargs_parts)) if kwargs_parts else ""
258
+ return {
259
+ "import_expr": "import evalrx",
260
+ "load_expr": f"evalrx.load({spec.key!r}{kwargs_str})",
261
+ "capabilities": caps,
262
+ }
263
+
264
+ if type(model).__name__ == "GeminiModel":
265
+ model_id = getattr(model, "model_id", "gemini-2.5-flash")
266
+ return {
267
+ "import_expr": "from evalrx.models.blackbox.gemini import GeminiModel",
268
+ "load_expr": f"GeminiModel(model_id={model_id!r})",
269
+ "capabilities": caps,
270
+ }
271
+
272
+ return {
273
+ "import_expr": "import evalrx",
274
+ "load_expr": f"# WARNING: cannot reconstruct {type(model).__name__!r} automatically",
275
+ "capabilities": caps,
276
+ }
277
+
278
+
279
+ # ---------------------------------------------------------------------------
280
+ # ExperimentWriter
281
+ # ---------------------------------------------------------------------------
282
+
283
+
284
+ class ExperimentWriter:
285
+ """Multi-phase LLM or CLI agent that writes and executes diagnostic code.
286
+
287
+ When ``config.cli_agent`` is None or has ``provider="llm"`` (default):
288
+ Phase 1 — Blueprint planning (optional, ``blueprint_enabled=True``)
289
+ Phase 2 — Sequential file generation (optional, ``sequential_generation=True``)
290
+ or single-pass write (default)
291
+ Phase 3 — Hard validation + targeted repair
292
+ Phase 4 — Exec-fix loop: run → stderr → targeted file repair → retry
293
+ Phase 5 — Tree search (optional, ``tree_search_enabled=True``)
294
+ Phase 6 — Reviewer dialog (optional, ``review_max_rounds > 0``)
295
+
296
+ When ``config.cli_agent.provider`` is a CLI provider:
297
+ Phase 1 — CLI agent writes ``experiment.py`` autonomously
298
+ Phase 2 — One ``sandbox.run_project()`` to collect metrics (no loop)
299
+
300
+ Args:
301
+ judge: Any :class:`~evalrx.core.model.Model` with ``Capability.GENERATE``.
302
+ config: :class:`ExperimentWriterConfig` controlling phases and limits.
303
+ """
304
+
305
+ def __init__(
306
+ self,
307
+ judge: "Model",
308
+ config: ExperimentWriterConfig = ExperimentWriterConfig(),
309
+ ) -> None:
310
+ self._judge = judge
311
+ self._cfg = config
312
+ self._calls = 0
313
+ self._runs = 0
314
+ self._log: list[str] = []
315
+ self._forbid_model_load = False
316
+ # Lazily created write workdir for multi-file output
317
+ self._workdir: Path | None = None
318
+
319
+ # ------------------------------------------------------------------
320
+ # Public interface
321
+ # ------------------------------------------------------------------
322
+
323
+ def write_and_run(
324
+ self,
325
+ hypothesis: "Hypothesis",
326
+ model_context: dict[str, Any],
327
+ cases_json: str,
328
+ sandbox: "ExperimentSandbox | SandboxProtocol",
329
+ ) -> ExperimentWriterResult:
330
+ """Generate, validate, and execute a diagnostic experiment.
331
+
332
+ Args:
333
+ hypothesis: The claim to test.
334
+ model_context: Output of :func:`build_model_context`.
335
+ cases_json: JSON-serialised failure cases.
336
+ sandbox: Sandbox used for execution.
337
+
338
+ Returns:
339
+ :class:`ExperimentWriterResult` with metrics and verdict.
340
+ """
341
+ self._calls = 0
342
+ self._runs = 0
343
+ self._log = []
344
+ self._model_calls: "list[dict[str, Any]]" = []
345
+ self._forbid_model_load = model_context.get("access_mode") == "artifacts_only"
346
+ self._log_event("ExperimentWriter.write_and_run() started")
347
+
348
+ # CLI agent dispatch (unchanged from previous implementation)
349
+ cli_cfg = self._cfg.cli_agent
350
+ if cli_cfg is None:
351
+ from evalrx.agent_runtime.cli_types import CliAgentConfig
352
+ cli_cfg = CliAgentConfig()
353
+ if cli_cfg.provider != "llm":
354
+ return self._cli_write_and_run(
355
+ hypothesis, model_context, cases_json, sandbox, cli_cfg
356
+ )
357
+
358
+ # LLM path: Phase 1 — blueprint
359
+ blueprint_yaml = ""
360
+ blueprint_dict: dict[str, Any] | None = None
361
+ if self._cfg.blueprint_enabled:
362
+ blueprint_yaml, blueprint_dict = self._phase1_blueprint(
363
+ hypothesis, model_context, cases_json
364
+ )
365
+
366
+ # Phase 2 — code generation (sequential or single-pass)
367
+ files: dict[str, str] = {}
368
+ if (
369
+ self._cfg.sequential_generation
370
+ and blueprint_dict is not None
371
+ and self._is_valid_blueprint(blueprint_dict)
372
+ ):
373
+ files = self._phase2_sequential(
374
+ hypothesis, model_context, cases_json, blueprint_yaml, blueprint_dict
375
+ )
376
+ else:
377
+ if self._cfg.sequential_generation and blueprint_dict is None:
378
+ self._log_event(
379
+ " Sequential generation requested but blueprint invalid — "
380
+ "falling back to single-pass write"
381
+ )
382
+ code = self._phase2_write(hypothesis, model_context, cases_json, blueprint_yaml)
383
+ if not code.strip():
384
+ self._log_event("Phase 2 produced empty script — aborting")
385
+ return ExperimentWriterResult(
386
+ blueprint=blueprint_yaml,
387
+ validation_log=list(self._log),
388
+ total_llm_calls=self._calls,
389
+ total_sandbox_runs=self._runs,
390
+ model_calls=list(self._model_calls),
391
+ )
392
+ files = {"main.py": code}
393
+
394
+ # Phase 3 — hard validation
395
+ if self._cfg.hard_validation and files:
396
+ files = self._phase3_hard_validate(files, hypothesis, model_context, cases_json)
397
+
398
+ if self._forbid_model_load and self._contains_forbidden_model_load(files):
399
+ message = (
400
+ "generated M5 code attempted to reconstruct or download a second model; "
401
+ "execution blocked to protect the resident host model and GPU memory"
402
+ )
403
+ self._log_event(f"Phase 3: {message}")
404
+ main_code = files.get("main.py") or (next(iter(files.values())) if files else "")
405
+ return ExperimentWriterResult(
406
+ code=main_code,
407
+ files=files,
408
+ stderr=message,
409
+ returncode=-1,
410
+ validation_log=list(self._log),
411
+ total_llm_calls=self._calls,
412
+ total_sandbox_runs=self._runs,
413
+ model_calls=list(self._model_calls),
414
+ )
415
+
416
+ # Phase 4 — exec-fix loop (tree search or plain)
417
+ last_result: SandboxResult
418
+ best_files = files
419
+ nodes_explored = 0
420
+
421
+ if self._cfg.tree_search_enabled:
422
+ best_node, nodes_explored = self._phase5_tree_search(
423
+ files, sandbox, hypothesis, model_context, cases_json
424
+ )
425
+ best_files = best_node.files
426
+ last_result = self._run_files(best_files, sandbox)
427
+ else:
428
+ best_files, last_result = self._phase4_exec_fix(files, sandbox, hypothesis)
429
+
430
+ # Phase 6 — review dialog
431
+ review_rounds = 0
432
+ if self._cfg.review_max_rounds > 0 and last_result.ok:
433
+ best_files, last_result, review_rounds = self._phase6_review(
434
+ best_files, last_result, sandbox
435
+ )
436
+
437
+ # Build result
438
+ main_code = best_files.get("main.py") or (next(iter(best_files.values())) if best_files else "")
439
+ verdict = last_result.metrics.get("verdict")
440
+
441
+ self._log_event(
442
+ f"write_and_run() done — {self._calls} LLM calls, "
443
+ f"{self._runs} sandbox runs, verdict={verdict}"
444
+ )
445
+
446
+ return ExperimentWriterResult(
447
+ code=main_code,
448
+ files=best_files,
449
+ blueprint=blueprint_yaml,
450
+ metrics=last_result.metrics,
451
+ verdict=verdict,
452
+ stdout=last_result.stdout,
453
+ stderr=last_result.stderr,
454
+ returncode=last_result.returncode,
455
+ timed_out=last_result.timed_out,
456
+ total_llm_calls=self._calls,
457
+ total_sandbox_runs=self._runs,
458
+ validation_log=list(self._log),
459
+ provider="llm",
460
+ workdir=str(getattr(sandbox, "workdir", "")),
461
+ model_calls=list(self._model_calls),
462
+ )
463
+
464
+ # ------------------------------------------------------------------
465
+ # Phase 1: Blueprint planning
466
+ # ------------------------------------------------------------------
467
+
468
+ def _phase1_blueprint(
469
+ self,
470
+ hypothesis: "Hypothesis",
471
+ model_context: dict[str, Any],
472
+ cases_json: str,
473
+ ) -> tuple[str, dict[str, Any] | None]:
474
+ """Generate YAML blueprint; return (raw_yaml, parsed_dict_or_None)."""
475
+ self._log_event("Phase 1: Blueprint planning")
476
+ system = _BLUEPRINT_SYSTEM.format(timeout_sec=self._cfg.exec_fix_timeout_sec)
477
+ user = _BLUEPRINT_USER.format(
478
+ statement=hypothesis.statement,
479
+ failure_mode=hypothesis.predicted_failure_mode,
480
+ import_expr=model_context.get("import_expr", "import evalrx"),
481
+ load_expr=model_context.get("load_expr", "# model"),
482
+ capabilities=", ".join(model_context.get("capabilities", [])) or "GENERATE",
483
+ model_access_note=model_context.get("access_note", ""),
484
+ cases_json_snippet=cases_json[:1500],
485
+ )
486
+ raw = self._llm_call(system, user)
487
+ # Extract YAML block
488
+ m = re.search(r"```ya?ml\s*\n(.*?)```", raw, re.DOTALL)
489
+ blueprint_yaml = m.group(1).strip() if m else raw.strip()
490
+ self._log_event(f" Blueprint spec: {len(blueprint_yaml)} chars")
491
+
492
+ blueprint_dict = self._parse_blueprint(blueprint_yaml)
493
+ if blueprint_dict:
494
+ n_files = len(blueprint_dict.get("files", []))
495
+ self._log_event(f" Parsed blueprint: {n_files} files")
496
+ else:
497
+ self._log_event(" WARNING: Could not parse blueprint YAML")
498
+ return blueprint_yaml, blueprint_dict
499
+
500
+ def _parse_blueprint(self, yaml_text: str) -> dict[str, Any] | None:
501
+ """Parse blueprint YAML, sanitising Python type annotations first (BUG-178)."""
502
+ try:
503
+ import yaml # type: ignore[import]
504
+ except ImportError:
505
+ self._log_event(" pyyaml not available — blueprint parsing skipped")
506
+ return None
507
+
508
+ sanitized_lines = []
509
+ for line in yaml_text.split("\n"):
510
+ stripped = line.lstrip()
511
+ if not stripped or stripped.startswith("#"):
512
+ sanitized_lines.append(line)
513
+ continue
514
+ if stripped.startswith(("- ", "---", "...")):
515
+ if stripped.startswith("- ") and ":" in stripped[2:]:
516
+ inner = stripped[2:]
517
+ else:
518
+ sanitized_lines.append(line)
519
+ continue
520
+ elif ":" in stripped:
521
+ inner = stripped
522
+ else:
523
+ sanitized_lines.append(line)
524
+ continue
525
+
526
+ m = re.search(r":\s", inner)
527
+ if not m:
528
+ sanitized_lines.append(line)
529
+ continue
530
+ val_part = inner[m.end():].strip()
531
+ if not val_part:
532
+ sanitized_lines.append(line)
533
+ continue
534
+ if val_part.startswith(("'", "|", ">")):
535
+ sanitized_lines.append(line)
536
+ continue
537
+ needs_quoting = False
538
+ if val_part.startswith('"'):
539
+ if not val_part.endswith('"') or val_part.count('"') % 2 != 0:
540
+ needs_quoting = True
541
+ else:
542
+ sanitized_lines.append(line)
543
+ continue
544
+ elif ":" in val_part or "->" in val_part:
545
+ needs_quoting = True
546
+
547
+ if needs_quoting:
548
+ clean = val_part.strip('"').replace('"', '\\"')
549
+ comment_idx = clean.find(" #")
550
+ if comment_idx >= 0:
551
+ clean = clean[:comment_idx].rstrip()
552
+ indent = line[:len(line) - len(stripped)]
553
+ prefix = stripped[:len(stripped) - len(inner)]
554
+ key_sep = inner[:m.end()]
555
+ sanitized_lines.append(f'{indent}{prefix}{key_sep}"{clean}"')
556
+ else:
557
+ sanitized_lines.append(line)
558
+
559
+ sanitized = "\n".join(sanitized_lines)
560
+ for attempt_text in (sanitized, yaml_text):
561
+ try:
562
+ data = yaml.safe_load(attempt_text)
563
+ if isinstance(data, dict) and "files" in data:
564
+ return data
565
+ except Exception as exc:
566
+ self._log_event(f" Blueprint YAML parse error: {exc}")
567
+ return None
568
+
569
+ @staticmethod
570
+ def _is_valid_blueprint(blueprint: dict[str, Any]) -> bool:
571
+ files = blueprint.get("files", [])
572
+ if not files or not isinstance(files, list):
573
+ return False
574
+ has_order = sum(
575
+ 1 for f in files if isinstance(f, dict) and "generation_order" in f
576
+ )
577
+ return has_order >= 2
578
+
579
+ # ------------------------------------------------------------------
580
+ # Phase 2a: Sequential file generation
581
+ # ------------------------------------------------------------------
582
+
583
+ def _phase2_sequential(
584
+ self,
585
+ hypothesis: "Hypothesis",
586
+ model_context: dict[str, Any],
587
+ cases_json: str,
588
+ blueprint_yaml: str,
589
+ blueprint: dict[str, Any],
590
+ ) -> dict[str, str]:
591
+ """Generate files one-by-one following blueprint dependency order."""
592
+ self._log_event("Phase 2: Sequential generation (blueprint-guided)")
593
+
594
+ generated: dict[str, str] = {}
595
+ code_memory: dict[str, dict[str, Any]] = {}
596
+
597
+ file_specs = [f for f in blueprint.get("files", []) if isinstance(f, dict)]
598
+ for i, fs in enumerate(file_specs):
599
+ if "generation_order" not in fs:
600
+ fs["generation_order"] = i + 1
601
+ file_specs.sort(key=lambda f: f.get("generation_order", 99))
602
+
603
+ for file_spec in file_specs:
604
+ file_name = file_spec.get("name", "")
605
+ if not file_name:
606
+ continue
607
+ self._log_event(
608
+ f" Generating {file_name} (order={file_spec.get('generation_order')})"
609
+ )
610
+
611
+ deps = file_spec.get("dependencies", [])
612
+ dep_summaries = ""
613
+ dep_code = ""
614
+ for dep in deps:
615
+ if isinstance(dep, str):
616
+ if dep in code_memory:
617
+ dep_summaries += (
618
+ f"\n### {dep} (summary)\n"
619
+ + json.dumps(code_memory[dep], indent=2)
620
+ + "\n"
621
+ )
622
+ if dep in generated:
623
+ dep_code += (
624
+ f"\n### {dep}\n```python\n{generated[dep]}\n```\n"
625
+ )
626
+ dep_summaries = dep_summaries or "(no dependencies yet)"
627
+ dep_code = dep_code or "(no dependencies yet)"
628
+
629
+ system = _GENERATE_FILE_SYSTEM
630
+ user = _GENERATE_FILE_USER.format(
631
+ file_name=file_name,
632
+ file_spec=json.dumps(file_spec, indent=2, default=str),
633
+ blueprint=blueprint_yaml[:3000],
634
+ dep_summaries=dep_summaries,
635
+ dep_code=dep_code,
636
+ model_access_note=model_context.get("access_note", ""),
637
+ )
638
+ raw = self._llm_call(system, user)
639
+ code = self._extract_single_file_code(raw, file_name)
640
+ if not code:
641
+ self._log_event(f" WARNING: Empty code for {file_name}")
642
+ continue
643
+
644
+ generated[file_name] = code
645
+ code_memory[file_name] = self._build_code_summary(file_name, code)
646
+ self._log_event(
647
+ f" {file_name}: {len(code.splitlines())} lines, "
648
+ f"{len(code_memory[file_name].get('classes', []))} classes"
649
+ )
650
+
651
+ if "main.py" not in generated and generated:
652
+ first_key = next(iter(generated))
653
+ self._log_event(
654
+ f" WARNING: No main.py generated — promoting '{first_key}'"
655
+ )
656
+ generated["main.py"] = generated.pop(first_key)
657
+
658
+ self._log_event(f" Sequential generation complete: {len(generated)} files")
659
+ return generated
660
+
661
+ # ------------------------------------------------------------------
662
+ # Phase 2b: Single-pass write (default path)
663
+ # ------------------------------------------------------------------
664
+
665
+ def _phase2_write(
666
+ self,
667
+ hypothesis: "Hypothesis",
668
+ model_context: dict[str, Any],
669
+ cases_json: str,
670
+ blueprint_yaml: str = "",
671
+ ) -> str:
672
+ self._log_event("Phase 2: Writing diagnostic script")
673
+ blueprint_context = (
674
+ f"## Blueprint (follow this structure)\n```yaml\n{blueprint_yaml}\n```\n"
675
+ if blueprint_yaml else ""
676
+ )
677
+ system = _WRITE_SYSTEM.format(timeout_sec=self._cfg.exec_fix_timeout_sec)
678
+ user = _WRITE_USER.format(
679
+ statement=hypothesis.statement,
680
+ failure_mode=hypothesis.predicted_failure_mode,
681
+ import_expr=model_context.get("import_expr", "import evalrx"),
682
+ load_expr=model_context.get("load_expr", "# model"),
683
+ capabilities=", ".join(model_context.get("capabilities", [])) or "GENERATE",
684
+ model_access_note=model_context.get("access_note", ""),
685
+ blueprint_context=blueprint_context,
686
+ cases_json=cases_json,
687
+ )
688
+ raw = self._llm_call(system, user)
689
+ code = self._extract_code_block(raw)
690
+ self._log_event(f" Script: {len(code)} chars")
691
+ return code
692
+
693
+ # ------------------------------------------------------------------
694
+ # Phase 3: Hard validation
695
+ # ------------------------------------------------------------------
696
+
697
+ def _phase3_hard_validate(
698
+ self,
699
+ files: dict[str, str],
700
+ hypothesis: "Hypothesis",
701
+ model_context: dict[str, Any],
702
+ cases_json: str,
703
+ ) -> dict[str, str]:
704
+ """AST-based validation; repair critical issues; log warnings."""
705
+ self._log_event("Phase 3: Hard validation")
706
+
707
+ for attempt in range(self._cfg.hard_validation_max_repairs + 1):
708
+ critical, warnings = self._hard_validate(files)
709
+
710
+ for w in warnings:
711
+ self._log_event(f" WARNING: {w}")
712
+
713
+ if not critical:
714
+ self._log_event(
715
+ f" Hard validation passed ({len(warnings)} warning(s), "
716
+ f"attempt {attempt})"
717
+ )
718
+ return files
719
+
720
+ self._log_event(
721
+ f" {len(critical)} CRITICAL issue(s) "
722
+ f"(attempt {attempt}/{self._cfg.hard_validation_max_repairs})"
723
+ )
724
+ for c in critical:
725
+ self._log_event(f" CRITICAL: {c}")
726
+
727
+ if attempt >= self._cfg.hard_validation_max_repairs:
728
+ self._log_event(" Max repairs reached — proceeding with warnings")
729
+ return files
730
+
731
+ files = self._repair_critical_issues(files, critical)
732
+
733
+ return files
734
+
735
+ def _hard_validate(
736
+ self, files: dict[str, str]
737
+ ) -> tuple[list[str], list[str]]:
738
+ """Return (critical_issues, warnings)."""
739
+ critical: list[str] = []
740
+ warnings: list[str] = []
741
+
742
+ # 1. Syntax errors — always critical
743
+ for fname, code in files.items():
744
+ if not fname.endswith(".py"):
745
+ continue
746
+ try:
747
+ ast.parse(code)
748
+ except SyntaxError as exc:
749
+ critical.append(
750
+ f"[{fname}] SyntaxError at line {exc.lineno}: {exc.msg}"
751
+ )
752
+
753
+ # 2. main.py MUST have `if __name__ == "__main__":` block (BUG-R41-04)
754
+ main_code = files.get("main.py", "")
755
+ if main_code:
756
+ try:
757
+ main_tree = ast.parse(main_code)
758
+ has_main_guard = any(
759
+ isinstance(node, ast.If)
760
+ and isinstance(node.test, ast.Compare)
761
+ and isinstance(node.test.left, ast.Name)
762
+ and node.test.left.id == "__name__"
763
+ and len(node.test.comparators) == 1
764
+ and isinstance(node.test.comparators[0], ast.Constant)
765
+ and node.test.comparators[0].value == "__main__"
766
+ for node in ast.walk(main_tree)
767
+ )
768
+ if not has_main_guard:
769
+ critical.append(
770
+ '[main.py] Missing `if __name__ == "__main__":` block — '
771
+ "script will define functions/classes but never execute. "
772
+ "Add a main guard that calls the experiment entry point."
773
+ )
774
+ except SyntaxError:
775
+ pass # already caught above
776
+
777
+ # 3. verdict line must be reachable in main.py (warning only)
778
+ if main_code and "verdict:" not in main_code:
779
+ warnings.append(
780
+ "[main.py] No 'verdict:' output found — "
781
+ "the script must print 'verdict: 1.0' or 'verdict: 0.0'"
782
+ )
783
+
784
+ # 4. Cross-file import consistency
785
+ known_modules = {
786
+ fname.replace(".py", "")
787
+ for fname in files
788
+ if fname.endswith(".py")
789
+ }
790
+ for fname, code in files.items():
791
+ if not fname.endswith(".py"):
792
+ continue
793
+ try:
794
+ tree = ast.parse(code)
795
+ except SyntaxError:
796
+ continue
797
+ for node in ast.walk(tree):
798
+ if isinstance(node, ast.ImportFrom) and node.module:
799
+ mod_top = node.module.split(".")[0]
800
+ if mod_top in known_modules:
801
+ target_file = f"{mod_top}.py"
802
+ if target_file in files and node.names:
803
+ try:
804
+ target_tree = ast.parse(files[target_file])
805
+ except SyntaxError:
806
+ continue
807
+ exported = {
808
+ n.name
809
+ for n in ast.walk(target_tree)
810
+ if isinstance(n, (ast.ClassDef, ast.FunctionDef))
811
+ }
812
+ for alias in node.names:
813
+ name = alias.name
814
+ if name != "*" and name not in exported:
815
+ critical.append(
816
+ f"[{fname}] ImportError: '{name}' not "
817
+ f"defined in '{target_file}' — will crash"
818
+ )
819
+
820
+ if self._forbid_model_load and self._contains_forbidden_model_load(files):
821
+ critical.append(
822
+ "[project] Model reconstruction is forbidden in this artifact-only M5 "
823
+ "sandbox. Remove evalrx.load/compose/from_pretrained/model-client "
824
+ "construction and analyze the stored case artifacts; if those cannot "
825
+ "test the hypothesis, emit inconclusive: 1.0 without a verdict."
826
+ )
827
+
828
+ return critical, warnings
829
+
830
+ @staticmethod
831
+ def _contains_forbidden_model_load(files: dict[str, str]) -> bool:
832
+ """Detect heavyweight/network model construction in generated M5 code."""
833
+ for name, code in files.items():
834
+ if not name.endswith(".py"):
835
+ continue
836
+ try:
837
+ tree = ast.parse(code)
838
+ except SyntaxError:
839
+ continue
840
+ evalrx_aliases = {"evalrx"}
841
+ direct_loaders: set[str] = set()
842
+ model_classes: set[str] = set()
843
+ for node in ast.walk(tree):
844
+ if isinstance(node, ast.Import):
845
+ for alias in node.names:
846
+ if alias.name == "evalrx":
847
+ evalrx_aliases.add(alias.asname or alias.name)
848
+ elif isinstance(node, ast.ImportFrom):
849
+ module = node.module or ""
850
+ for alias in node.names:
851
+ local = alias.asname or alias.name
852
+ if module == "evalrx" and alias.name in {"load", "compose"}:
853
+ direct_loaders.add(local)
854
+ if module.startswith(("transformers", "evalrx.models")) and (
855
+ alias.name.startswith("AutoModel") or alias.name == "GeminiModel"
856
+ ):
857
+ model_classes.add(local)
858
+ for node in ast.walk(tree):
859
+ if not isinstance(node, ast.Call):
860
+ continue
861
+ fn = node.func
862
+ if isinstance(fn, ast.Attribute):
863
+ if fn.attr.startswith("from_pretrained"):
864
+ return True
865
+ if (
866
+ fn.attr in {"load", "compose"}
867
+ and isinstance(fn.value, ast.Name)
868
+ and fn.value.id in evalrx_aliases
869
+ ):
870
+ return True
871
+ if isinstance(fn, ast.Name) and fn.id in direct_loaders | model_classes:
872
+ return True
873
+ return False
874
+
875
+ def _repair_critical_issues(
876
+ self,
877
+ files: dict[str, str],
878
+ critical_issues: list[str],
879
+ ) -> dict[str, str]:
880
+ """Ask LLM to fix critical validation issues."""
881
+ self._log_event(" Targeted repair for critical issues")
882
+
883
+ affected: set[str] = set()
884
+ for issue in critical_issues:
885
+ m = re.match(r"\[([^\]]+\.py)\]", issue)
886
+ if m:
887
+ affected.add(m.group(1))
888
+ else:
889
+ affected.update(f for f in files if f.endswith(".py"))
890
+ if not affected:
891
+ affected.update(f for f in files if f.endswith(".py"))
892
+
893
+ files_ctx = self._format_files(files)
894
+ issues_text = "\n".join(f"- {issue}" for issue in critical_issues)
895
+
896
+ prompt = (
897
+ "Your code has CRITICAL issues that will cause runtime failures. "
898
+ "Fix ALL of them.\n\n"
899
+ f"## Critical Issues\n{issues_text}\n\n"
900
+ f"## Current Code\n{files_ctx}\n\n"
901
+ "## Rules\n"
902
+ "1. Fix every critical issue listed\n"
903
+ "2. main.py MUST have `if __name__ == \"__main__\":` block\n"
904
+ "3. The last printed line MUST be `verdict: 1.0` or `verdict: 0.0`\n"
905
+ "4. Output ALL files in ```filename:xxx.py``` format\n"
906
+ )
907
+ raw = self._llm_call(
908
+ "You are an expert Python debugging engineer. Fix the code issues listed.",
909
+ prompt,
910
+ )
911
+ fixed = self._extract_files(raw)
912
+ if fixed:
913
+ merged = dict(files)
914
+ merged.update(fixed)
915
+ self._log_event(f" Repair updated {len(fixed)} file(s): {', '.join(sorted(fixed))}")
916
+ return merged
917
+ # Fall back to single-file repair if multi-file extraction failed
918
+ if "main.py" in files:
919
+ single = self._extract_code_block(raw)
920
+ if single.strip():
921
+ result = dict(files)
922
+ result["main.py"] = single
923
+ self._log_event(" Repair updated main.py (single-file fallback)")
924
+ return result
925
+ self._log_event(" WARNING: Repair produced no extractable files")
926
+ return files
927
+
928
+ # ------------------------------------------------------------------
929
+ # Phase 4: Exec-fix loop
930
+ # ------------------------------------------------------------------
931
+
932
+ def _phase4_exec_fix(
933
+ self,
934
+ files: dict[str, str],
935
+ sandbox: Any,
936
+ hypothesis: "Hypothesis",
937
+ ) -> tuple[dict[str, str], SandboxResult]:
938
+ """Run → fix → retry loop using targeted file repair."""
939
+ last_result: SandboxResult | None = None
940
+
941
+ for i in range(self._cfg.exec_fix_max_iterations):
942
+ result = self._run_files(files, sandbox)
943
+ last_result = result
944
+
945
+ if result.ok:
946
+ self._log_event(f" Exec-fix iter {i}: OK ({result.elapsed_sec:.1f}s)")
947
+ break
948
+
949
+ if result.timed_out:
950
+ self._log_event(f" Exec-fix iter {i}: TIMEOUT — no further repair")
951
+ break
952
+
953
+ self._log_event(
954
+ f" Exec-fix iter {i}: crashed (rc={result.returncode}), "
955
+ f"stderr={len(result.stderr)} chars"
956
+ )
957
+ if i < self._cfg.exec_fix_max_iterations - 1:
958
+ files = self._fix_runtime_error(files, result)
959
+
960
+ if last_result is None:
961
+ last_result = SandboxResult(
962
+ returncode=-1, stdout="", stderr="no runs performed", elapsed_sec=0.0
963
+ )
964
+
965
+ return files, last_result
966
+
967
+ def _fix_runtime_error(
968
+ self, files: dict[str, str], result: SandboxResult
969
+ ) -> dict[str, str]:
970
+ """Targeted or full-file repair after a sandbox crash (E-05 pattern)."""
971
+ stderr_tail = result.stderr[-3000:]
972
+ error_loc = self._parse_error_location(stderr_tail, files)
973
+
974
+ if error_loc:
975
+ fname, lineno, error_msg = error_loc
976
+ self._log_event(f" Targeted repair: {fname}:{lineno} — {error_msg[:80]}")
977
+ fixed = self._targeted_file_repair(files, fname, lineno, error_msg, stderr_tail)
978
+ if fixed:
979
+ return fixed
980
+
981
+ # Fallback: full-file repair (single-file or multi-file)
982
+ if len(files) == 1:
983
+ code = next(iter(files.values()))
984
+ repaired = self._llm_call(
985
+ _REPAIR_SYSTEM,
986
+ _REPAIR_USER.format(error=stderr_tail[-2000:], code=code),
987
+ )
988
+ repaired_code = self._extract_code_block(repaired)
989
+ if repaired_code.strip():
990
+ fname = next(iter(files))
991
+ return {fname: repaired_code}
992
+ else:
993
+ files_ctx = self._format_files(files)
994
+ prompt = (
995
+ f"## Error\n```\n{stderr_tail}\n```\n\n"
996
+ f"## Code\n{files_ctx}\n\n"
997
+ "Fix the error. Output ALL files in ```filename:xxx.py``` format."
998
+ )
999
+ raw = self._llm_call(
1000
+ "You are a Python debugging expert. Fix the runtime error.", prompt
1001
+ )
1002
+ fixed = self._extract_files(raw)
1003
+ if fixed:
1004
+ merged = dict(files)
1005
+ merged.update(fixed)
1006
+ return merged
1007
+
1008
+ return files
1009
+
1010
+ @staticmethod
1011
+ def _parse_error_location(
1012
+ stderr: str, files: dict[str, str]
1013
+ ) -> tuple[str, int, str] | None:
1014
+ """Parse Python traceback to find (filename, line_number, error_message)."""
1015
+ known_files = set(files.keys())
1016
+ tb_pattern = re.compile(r'File "(?:[^"]*[/\\])?([^"]+\.py)", line (\d+)')
1017
+ matches = list(tb_pattern.finditer(stderr))
1018
+ if not matches:
1019
+ return None
1020
+ for m in reversed(matches):
1021
+ fname = m.group(1)
1022
+ lineno = int(m.group(2))
1023
+ if fname in known_files:
1024
+ lines = stderr.strip().split("\n")
1025
+ error_msg = lines[-1] if lines else "Unknown error"
1026
+ return fname, lineno, error_msg
1027
+ return None
1028
+
1029
+ def _targeted_file_repair(
1030
+ self,
1031
+ files: dict[str, str],
1032
+ target_file: str,
1033
+ error_line: int,
1034
+ error_msg: str,
1035
+ full_stderr: str,
1036
+ ) -> dict[str, str] | None:
1037
+ """Repair a single file with a ±30-line context window around the error."""
1038
+ if target_file not in files:
1039
+ return None
1040
+ code = files[target_file]
1041
+ code_lines = code.split("\n")
1042
+ total_lines = len(code_lines)
1043
+ window = 30
1044
+ start = max(0, error_line - window - 1)
1045
+ end = min(total_lines, error_line + window)
1046
+ numbered = "\n".join(
1047
+ f"{start + i + 1:4d} | {line}"
1048
+ for i, line in enumerate(code_lines[start:end])
1049
+ )
1050
+
1051
+ dep_summaries = ""
1052
+ for fname, fcode in files.items():
1053
+ if fname != target_file and fname.endswith(".py"):
1054
+ summary = self._build_code_summary(fname, fcode)
1055
+ dep_summaries += (
1056
+ f"\n### {fname}: "
1057
+ f"{len(summary.get('classes', []))} classes, "
1058
+ f"{len(summary.get('functions', []))} functions\n"
1059
+ )
1060
+ for cls in summary.get("classes", []):
1061
+ methods = ", ".join(m["name"] for m in cls.get("methods", []))
1062
+ dep_summaries += (
1063
+ f" class {cls['name']}"
1064
+ f"({', '.join(cls.get('bases', []))}): [{methods}]\n"
1065
+ )
1066
+
1067
+ prompt = (
1068
+ f"Fix the runtime error in `{target_file}` at line {error_line}.\n\n"
1069
+ f"## Error\n```\n{error_msg}\n```\n\n"
1070
+ f"## Traceback (last 1500 chars)\n```\n{full_stderr[-1500:]}\n```\n\n"
1071
+ f"## {target_file} (lines {start + 1}-{end})\n"
1072
+ f"```python\n{numbered}\n```\n\n"
1073
+ f"## Other files\n{dep_summaries}\n\n"
1074
+ f"## Full {target_file} ({total_lines} lines)\n"
1075
+ f"```python\n{code}\n```\n\n"
1076
+ f"Output the COMPLETE fixed `{target_file}` in "
1077
+ f"```filename:{target_file}``` format."
1078
+ )
1079
+ raw = self._llm_call(
1080
+ "You are a debugging expert. Fix the specific runtime error shown. "
1081
+ "Output the COMPLETE fixed file.",
1082
+ prompt,
1083
+ )
1084
+ fixed = self._extract_files(raw)
1085
+ if not fixed:
1086
+ m = re.search(r"```(?:python|filename:\S+)\s*\n(.*?)```", raw, re.DOTALL)
1087
+ if m:
1088
+ fixed = {target_file: m.group(1).strip()}
1089
+
1090
+ if fixed and target_file in fixed:
1091
+ merged = dict(files)
1092
+ merged.update(fixed)
1093
+ self._log_event(
1094
+ f" Targeted repair applied to {target_file} "
1095
+ f"({len(fixed[target_file].splitlines())} lines)"
1096
+ )
1097
+ return merged
1098
+ return None
1099
+
1100
+ # ------------------------------------------------------------------
1101
+ # Phase 5: Tree search (optional)
1102
+ # ------------------------------------------------------------------
1103
+
1104
+ def _phase5_tree_search(
1105
+ self,
1106
+ initial_files: dict[str, str],
1107
+ sandbox: Any,
1108
+ hypothesis: "Hypothesis",
1109
+ model_context: dict[str, Any],
1110
+ cases_json: str,
1111
+ ) -> tuple[SolutionNode, int]:
1112
+ """Explore multiple candidates; return best node and count explored."""
1113
+ self._log_event("Phase 5: Tree search")
1114
+ all_nodes: list[SolutionNode] = []
1115
+
1116
+ # Generate initial candidates from blueprint variants
1117
+ n_cand = max(self._cfg.tree_search_candidates, 1)
1118
+ for k in range(n_cand):
1119
+ self._log_event(f" Generating candidate {k + 1}/{n_cand}")
1120
+ if k == 0:
1121
+ cand_files = initial_files
1122
+ else:
1123
+ # Re-generate with slight variation
1124
+ code = self._phase2_write(hypothesis, model_context, cases_json)
1125
+ cand_files = {"main.py": code} if code.strip() else initial_files
1126
+ node = SolutionNode(
1127
+ node_id=f"gen-{k}",
1128
+ files=cand_files,
1129
+ depth=0,
1130
+ generation_method="initial",
1131
+ )
1132
+ all_nodes.append(node)
1133
+
1134
+ # Evaluate-fix-branch loop
1135
+ for depth in range(self._cfg.tree_search_max_depth):
1136
+ for node in all_nodes:
1137
+ if not node.evaluated:
1138
+ self._evaluate_node(node, sandbox)
1139
+
1140
+ all_nodes.sort(key=lambda n: n.score, reverse=True)
1141
+ self._log_event(
1142
+ f" Depth {depth}: {len(all_nodes)} nodes, "
1143
+ f"best={all_nodes[0].node_id} score={all_nodes[0].score:.2f}"
1144
+ )
1145
+
1146
+ if all_nodes[0].runs_ok:
1147
+ break
1148
+
1149
+ new_nodes: list[SolutionNode] = []
1150
+ for node in all_nodes[:2]:
1151
+ if not node.runs_ok:
1152
+ fixed = self._fix_runtime_error(
1153
+ node.files,
1154
+ SandboxResult(
1155
+ returncode=node.returncode,
1156
+ stdout=node.stdout,
1157
+ stderr=node.stderr,
1158
+ elapsed_sec=0.0,
1159
+ ),
1160
+ )
1161
+ new_nodes.append(SolutionNode(
1162
+ node_id=f"{node.node_id}-fix{depth}",
1163
+ files=fixed,
1164
+ parent_id=node.node_id,
1165
+ depth=depth + 1,
1166
+ generation_method="fix",
1167
+ ))
1168
+ all_nodes.extend(new_nodes)
1169
+
1170
+ # Final evaluation
1171
+ for node in all_nodes:
1172
+ if not node.evaluated:
1173
+ self._evaluate_node(node, sandbox)
1174
+
1175
+ all_nodes.sort(key=lambda n: n.score, reverse=True)
1176
+ best = all_nodes[0]
1177
+ self._log_event(
1178
+ f" Tree search complete: best={best.node_id} "
1179
+ f"score={best.score:.2f}, explored {len(all_nodes)} nodes"
1180
+ )
1181
+ return best, len(all_nodes)
1182
+
1183
+ def _evaluate_node(self, node: SolutionNode, sandbox: Any) -> None:
1184
+ result = self._run_files(node.files, sandbox)
1185
+ node.evaluated = True
1186
+ node.returncode = result.returncode
1187
+ node.stdout = result.stdout
1188
+ node.stderr = result.stderr
1189
+ node.runs_ok = result.ok
1190
+ node.metrics = dict(result.metrics)
1191
+ node.score = self._score_node(node)
1192
+
1193
+ @staticmethod
1194
+ def _score_node(node: SolutionNode) -> float:
1195
+ score = 0.0
1196
+ if node.runs_ok:
1197
+ score += 1.0
1198
+ if node.stdout and len(node.stdout) > 100:
1199
+ score += 0.3
1200
+ if node.metrics:
1201
+ score += 0.5
1202
+ if "verdict" in node.metrics:
1203
+ score += 0.5
1204
+ if node.stderr and "Error" in node.stderr:
1205
+ score -= 0.2
1206
+ return max(score, 0.0)
1207
+
1208
+ # ------------------------------------------------------------------
1209
+ # Phase 6: Review dialog (optional)
1210
+ # ------------------------------------------------------------------
1211
+
1212
+ def _phase6_review(
1213
+ self,
1214
+ files: dict[str, str],
1215
+ last_result: SandboxResult,
1216
+ sandbox: Any,
1217
+ ) -> tuple[dict[str, str], SandboxResult, int]:
1218
+ """Coder-reviewer dialog; revert if re-run degrades."""
1219
+ self._log_event("Phase 6: Review dialog")
1220
+ rounds = 0
1221
+
1222
+ for r in range(self._cfg.review_max_rounds):
1223
+ rounds += 1
1224
+ files_ctx = self._format_files(files)
1225
+ raw = self._llm_call(
1226
+ _REVIEW_SYSTEM,
1227
+ f"## Experiment Code\n{files_ctx}\n\nReturn JSON only.",
1228
+ )
1229
+ review = self._parse_json(raw)
1230
+ if not isinstance(review, dict):
1231
+ self._log_event(f" Review round {r + 1}: could not parse JSON, skipping")
1232
+ break
1233
+
1234
+ verdict = review.get("verdict", "APPROVE")
1235
+ score = review.get("score", 10)
1236
+ critical = review.get("critical_issues", [])
1237
+ self._log_event(
1238
+ f" Review round {r + 1}: verdict={verdict}, score={score}, "
1239
+ f"critical={len(critical)}"
1240
+ )
1241
+
1242
+ if verdict == "APPROVE" or not critical:
1243
+ break
1244
+
1245
+ fix_prompt = (
1246
+ "A reviewer found critical issues. Fix ALL of them while "
1247
+ "preserving hypothesis testing logic.\n\n"
1248
+ "## Critical Issues\n"
1249
+ + "\n".join(f"- {issue}" for issue in critical)
1250
+ + f"\n\n## Current Code\n{files_ctx}\n\n"
1251
+ "Output ALL files in ```filename:xxx.py``` format."
1252
+ )
1253
+ fix_raw = self._llm_call(
1254
+ "You are an expert Python engineer. Fix the critical issues listed.",
1255
+ fix_prompt,
1256
+ )
1257
+ fixed = self._extract_files(fix_raw)
1258
+ if not fixed:
1259
+ break
1260
+
1261
+ candidate = dict(files)
1262
+ candidate.update(fixed)
1263
+ new_result = self._run_files(candidate, sandbox)
1264
+ if new_result.ok and new_result.returncode == 0:
1265
+ files = candidate
1266
+ last_result = new_result
1267
+ self._log_event(" Review fixes applied and verified")
1268
+ else:
1269
+ self._log_event(
1270
+ f" Review fixes degraded the run (rc={new_result.returncode}) — reverting"
1271
+ )
1272
+ break
1273
+
1274
+ return files, last_result, rounds
1275
+
1276
+ # ------------------------------------------------------------------
1277
+ # CLI agent path (unchanged from previous implementation)
1278
+ # ------------------------------------------------------------------
1279
+
1280
+ def _cli_write_and_run(
1281
+ self,
1282
+ hypothesis: "Hypothesis",
1283
+ model_context: dict[str, Any],
1284
+ cases_json: str,
1285
+ sandbox: Any,
1286
+ cli_cfg: Any,
1287
+ ) -> ExperimentWriterResult:
1288
+ self._log_event(f"CLI path: provider={cli_cfg.provider!r}")
1289
+ workdir = getattr(sandbox, "workdir", Path(tempfile.mkdtemp(prefix="evalrx_cli_")))
1290
+
1291
+ import json as _json
1292
+ (workdir / "cases.json").write_text(cases_json, encoding="utf-8")
1293
+ # Summarise which cases have images so the agent prompt is accurate
1294
+ try:
1295
+ _cases = _json.loads(cases_json)
1296
+ _n_images = sum(1 for c in _cases if c.get("image_path"))
1297
+ except Exception:
1298
+ _n_images = 0
1299
+
1300
+ (workdir / "hypothesis.md").write_text(
1301
+ f"# Hypothesis\n\n"
1302
+ f"**Statement:** {hypothesis.statement}\n\n"
1303
+ f"**Failure mode:** {hypothesis.predicted_failure_mode}\n\n"
1304
+ f"**Target model:** {hypothesis.target_model}\n",
1305
+ encoding="utf-8",
1306
+ )
1307
+
1308
+ prompt = build_cli_prompt(
1309
+ hypothesis, model_context, self._cfg.exec_fix_timeout_sec,
1310
+ n_images=_n_images,
1311
+ )
1312
+ from evalrx.agent_runtime.codegen import CodegenRunner
1313
+
1314
+ self._log_event(f" invoking {cli_cfg.provider!r}")
1315
+ cli_result = CodegenRunner(cli_cfg).run(
1316
+ prompt=prompt,
1317
+ workdir=workdir,
1318
+ timeout_sec=cli_cfg.timeout_sec,
1319
+ )
1320
+ # The agent's stdout is its narration / coding trajectory while it
1321
+ # writes and self-repairs the script — keep it for the coding log.
1322
+ cli_raw_output = cli_result.raw_output
1323
+ self._model_calls.append({
1324
+ "role": "experiment_coder",
1325
+ "operation": f"cli:{cli_cfg.provider}",
1326
+ "inputs": prompt,
1327
+ "output": cli_raw_output,
1328
+ "error": cli_result.error or None,
1329
+ "duration_sec": cli_result.elapsed_sec,
1330
+ "metadata": {"files": sorted(cli_result.files), "usage": cli_result.usage},
1331
+ })
1332
+ cli_usage = cli_result.usage
1333
+ self._log_event(
1334
+ f" CLI finished: ok={cli_result.ok}, "
1335
+ f"files={list(cli_result.files)}, elapsed={cli_result.elapsed_sec:.1f}s"
1336
+ )
1337
+ if cli_result.error:
1338
+ self._log_event(f" CLI error: {cli_result.error}")
1339
+
1340
+ if not cli_result.files:
1341
+ self._log_event("CLI agent produced no .py files — aborting")
1342
+ return ExperimentWriterResult(
1343
+ validation_log=list(self._log),
1344
+ total_llm_calls=0,
1345
+ total_sandbox_runs=0,
1346
+ cli_raw_output=cli_raw_output,
1347
+ cli_usage=cli_usage,
1348
+ provider=cli_cfg.provider,
1349
+ workdir=str(workdir),
1350
+ model_calls=list(self._model_calls),
1351
+ )
1352
+
1353
+ code = cli_result.files.get("experiment.py") or next(iter(cli_result.files.values()))
1354
+ self._log_event(f" collected script: {len(code)} chars")
1355
+
1356
+ if self._forbid_model_load and self._contains_forbidden_model_load(
1357
+ {"experiment.py": code}
1358
+ ):
1359
+ message = (
1360
+ "generated M5 code attempted to reconstruct or download a second model; "
1361
+ "execution blocked to protect the resident host model and GPU memory"
1362
+ )
1363
+ self._log_event(message)
1364
+ return ExperimentWriterResult(
1365
+ code=code,
1366
+ files={"experiment.py": code},
1367
+ stderr=message,
1368
+ returncode=-1,
1369
+ validation_log=list(self._log),
1370
+ cli_raw_output=cli_raw_output,
1371
+ cli_usage=cli_usage,
1372
+ provider=cli_cfg.provider,
1373
+ workdir=str(workdir),
1374
+ model_calls=list(self._model_calls),
1375
+ )
1376
+
1377
+ if self._cfg.hard_validation:
1378
+ errors = []
1379
+ try:
1380
+ ast.parse(code)
1381
+ except SyntaxError as exc:
1382
+ errors.append(f"SyntaxError at line {exc.lineno}: {exc.msg}")
1383
+ if errors:
1384
+ self._log_event(f" AST warnings: {'; '.join(errors)}")
1385
+
1386
+ result = sandbox.run(code, timeout_sec=self._cfg.exec_fix_timeout_sec)
1387
+ self._runs += 1
1388
+ self._log_event(
1389
+ f" sandbox run: rc={result.returncode}, "
1390
+ f"timed_out={result.timed_out}, metrics={list(result.metrics)}"
1391
+ )
1392
+
1393
+ verdict = result.metrics.get("verdict")
1394
+ return ExperimentWriterResult(
1395
+ code=code,
1396
+ files={"experiment.py": code},
1397
+ metrics=result.metrics,
1398
+ verdict=verdict,
1399
+ stdout=result.stdout,
1400
+ stderr=result.stderr,
1401
+ returncode=result.returncode,
1402
+ timed_out=result.timed_out,
1403
+ total_llm_calls=0,
1404
+ total_sandbox_runs=self._runs,
1405
+ validation_log=list(self._log),
1406
+ cli_raw_output=cli_raw_output,
1407
+ cli_usage=cli_usage,
1408
+ provider=cli_cfg.provider,
1409
+ workdir=str(workdir),
1410
+ model_calls=list(self._model_calls),
1411
+ )
1412
+
1413
+ @staticmethod
1414
+ def _build_cli_prompt(
1415
+ hypothesis: "Hypothesis",
1416
+ model_context: dict[str, Any],
1417
+ timeout_sec: int,
1418
+ n_images: int = 0,
1419
+ ) -> str:
1420
+ """Backward-compatible wrapper for the external CLI-agent prompt."""
1421
+ return build_cli_prompt(hypothesis, model_context, timeout_sec, n_images=n_images)
1422
+
1423
+ # ------------------------------------------------------------------
1424
+ # Sandbox execution helpers
1425
+ # ------------------------------------------------------------------
1426
+
1427
+ def _run_files(self, files: dict[str, str], sandbox: Any) -> SandboxResult:
1428
+ """Write files to a temp directory and run via sandbox."""
1429
+ self._runs += 1
1430
+ project_dir = Path(tempfile.mkdtemp(prefix="evalrx_exp_"))
1431
+ try:
1432
+ for fname, code in files.items():
1433
+ fpath = (project_dir / fname).resolve()
1434
+ if not fpath.is_relative_to(project_dir.resolve()):
1435
+ self._log_event(f" WARNING: Skipping path-traversal filename: {fname}")
1436
+ continue
1437
+ fpath.parent.mkdir(parents=True, exist_ok=True)
1438
+ fpath.write_text(code, encoding="utf-8")
1439
+
1440
+ if hasattr(sandbox, "run_project"):
1441
+ result = sandbox.run_project(
1442
+ project_dir,
1443
+ entry_point="main.py",
1444
+ timeout_sec=self._cfg.exec_fix_timeout_sec,
1445
+ )
1446
+ else:
1447
+ # Fallback: run single-file via sandbox.run()
1448
+ main_code = files.get("main.py") or next(iter(files.values()), "")
1449
+ result = sandbox.run(main_code, timeout_sec=self._cfg.exec_fix_timeout_sec)
1450
+ except Exception as exc: # noqa: BLE001
1451
+ result = SandboxResult(
1452
+ returncode=1,
1453
+ stdout="",
1454
+ stderr=f"[ExperimentWriter] sandbox error: {exc}",
1455
+ elapsed_sec=0.0,
1456
+ )
1457
+ finally:
1458
+ import shutil as _shutil
1459
+ _shutil.rmtree(project_dir, ignore_errors=True)
1460
+
1461
+ self._log_event(
1462
+ f" run_files: rc={result.returncode}, "
1463
+ f"timed_out={result.timed_out}, metrics={list(result.metrics)}"
1464
+ )
1465
+ return result
1466
+
1467
+ # ------------------------------------------------------------------
1468
+ # Code extraction helpers
1469
+ # ------------------------------------------------------------------
1470
+
1471
+ @staticmethod
1472
+ def _extract_code_block(text: str) -> str:
1473
+ """Extract the first ```python … ``` block, or the raw text if none found."""
1474
+ m = re.search(r"```(?:python)?\s*\n(.*?)```", text, re.DOTALL)
1475
+ if m:
1476
+ return m.group(1).strip()
1477
+ lines = text.strip().splitlines()
1478
+ if lines and lines[0].startswith("```"):
1479
+ lines = lines[1:]
1480
+ if lines and lines[-1].strip() == "```":
1481
+ lines = lines[:-1]
1482
+ return "\n".join(lines).strip()
1483
+
1484
+ @staticmethod
1485
+ def _extract_single_file_code(content: str, expected_name: str) -> str:
1486
+ """Extract Python code from LLM response for a single named file."""
1487
+ m = re.search(r"```python\s*\n(.*?)```", content, re.DOTALL)
1488
+ if m:
1489
+ return m.group(1).strip()
1490
+ m = re.search(
1491
+ rf"```(?:filename:)?{re.escape(expected_name)}\s*\n(.*?)```",
1492
+ content, re.DOTALL,
1493
+ )
1494
+ if m:
1495
+ return m.group(1).strip()
1496
+ stripped = content.strip()
1497
+ if stripped and stripped.startswith(
1498
+ ("import ", "from ", "#", "def ", "class ", '"""')
1499
+ ):
1500
+ return stripped
1501
+ return ""
1502
+
1503
+ @staticmethod
1504
+ def _extract_files(content: str) -> dict[str, str]:
1505
+ """Extract multi-file blocks from LLM output.
1506
+
1507
+ Recognises both:
1508
+ ```filename:foo.py
1509
+ ...code...
1510
+ ```
1511
+ and:
1512
+ ```python # foo.py
1513
+ ...code...
1514
+ ```
1515
+ """
1516
+ files: dict[str, str] = {}
1517
+ # Primary format: ```filename:xxx.py
1518
+ for m in re.finditer(
1519
+ r"```filename:([^\s`]+\.py)\s*\n(.*?)```", content, re.DOTALL
1520
+ ):
1521
+ fname = m.group(1).strip()
1522
+ code = m.group(2).strip()
1523
+ if fname and code:
1524
+ files[fname] = code
1525
+ if files:
1526
+ return files
1527
+ # Fallback: single ```python block → treat as main.py
1528
+ m = re.search(r"```python\s*\n(.*?)```", content, re.DOTALL)
1529
+ if m:
1530
+ return {"main.py": m.group(1).strip()}
1531
+ return files
1532
+
1533
+ @staticmethod
1534
+ def _format_files(files: dict[str, str]) -> str:
1535
+ parts = [f"```filename:{fname}\n{files[fname]}\n```" for fname in sorted(files)]
1536
+ return "\n\n".join(parts)
1537
+
1538
+ @staticmethod
1539
+ def _build_code_summary(filename: str, code: str) -> dict[str, Any]:
1540
+ """Build a CodeMem-style AST summary (mirrors ARC's implementation)."""
1541
+ summary: dict[str, Any] = {
1542
+ "filename": filename,
1543
+ "classes": [],
1544
+ "functions": [],
1545
+ "imports": [],
1546
+ }
1547
+ try:
1548
+ tree = ast.parse(code)
1549
+ except SyntaxError:
1550
+ summary["parse_error"] = True
1551
+ return summary
1552
+
1553
+ for node in ast.walk(tree):
1554
+ if isinstance(node, ast.ClassDef):
1555
+ methods = [
1556
+ {"name": n.name, "args": [a.arg for a in n.args.args if a.arg != "self"]}
1557
+ for n in node.body
1558
+ if isinstance(n, ast.FunctionDef)
1559
+ ]
1560
+ summary["classes"].append({
1561
+ "name": node.name,
1562
+ "bases": [ast.unparse(b) for b in node.bases],
1563
+ "methods": methods,
1564
+ })
1565
+ elif isinstance(node, ast.FunctionDef) and node.col_offset == 0:
1566
+ summary["functions"].append({
1567
+ "name": node.name,
1568
+ "args": [a.arg for a in node.args.args],
1569
+ })
1570
+ elif isinstance(node, (ast.Import, ast.ImportFrom)):
1571
+ try:
1572
+ summary["imports"].append(ast.unparse(node))
1573
+ except Exception:
1574
+ pass
1575
+
1576
+ return summary
1577
+
1578
+ @staticmethod
1579
+ def _parse_json(text: str) -> dict[str, Any] | None:
1580
+ """Best-effort JSON extraction from LLM response."""
1581
+ def _as_dict(val: Any) -> dict[str, Any] | None:
1582
+ return val if isinstance(val, dict) else None
1583
+
1584
+ try:
1585
+ return _as_dict(json.loads(text))
1586
+ except (json.JSONDecodeError, ValueError):
1587
+ pass
1588
+ m = re.search(r"```json\s*\n(.*?)```", text, re.DOTALL)
1589
+ if m:
1590
+ try:
1591
+ return _as_dict(json.loads(m.group(1)))
1592
+ except (json.JSONDecodeError, ValueError):
1593
+ pass
1594
+ m = re.search(
1595
+ r"\{[^{}]*(?:\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}[^{}]*)*\}",
1596
+ text, re.DOTALL,
1597
+ )
1598
+ if m:
1599
+ try:
1600
+ return _as_dict(json.loads(m.group(0)))
1601
+ except (json.JSONDecodeError, ValueError):
1602
+ pass
1603
+ return None
1604
+
1605
+ # ------------------------------------------------------------------
1606
+ # LLM call
1607
+ # ------------------------------------------------------------------
1608
+
1609
+ def _llm_call(self, system: str, user: str) -> str:
1610
+ self._calls += 1
1611
+ import time
1612
+
1613
+ started = time.perf_counter()
1614
+ prompt = f"{system}\n\n{user}"
1615
+ try:
1616
+ output = self._judge.generate(prompt)
1617
+ except Exception as exc: # noqa: BLE001
1618
+ self._log_event(f" LLM call failed: {exc}")
1619
+ self._model_calls.append({
1620
+ "role": "experiment_writer", "operation": "generate",
1621
+ "inputs": prompt, "output": None, "error": str(exc),
1622
+ "duration_sec": time.perf_counter() - started,
1623
+ })
1624
+ return ""
1625
+ self._model_calls.append({
1626
+ "role": "experiment_writer", "operation": "generate",
1627
+ "inputs": prompt, "output": output, "error": None,
1628
+ "duration_sec": time.perf_counter() - started,
1629
+ })
1630
+ return output
1631
+
1632
+ def _log_event(self, msg: str) -> None:
1633
+ logger.debug(msg)
1634
+ self._log.append(msg)