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.
- evalrx/__init__.py +139 -0
- evalrx/agent_assets/__init__.py +2 -0
- evalrx/agent_assets/skills/README.md +28 -0
- evalrx/agent_assets/skills/eval-chart-style/SKILL.md +172 -0
- evalrx/agent_assets/skills/evalrx-report-ui/SKILL.md +116 -0
- evalrx/agent_assets/skills/nature-figure/LICENSE +201 -0
- evalrx/agent_assets/skills/nature-figure/README.md +412 -0
- evalrx/agent_assets/skills/nature-figure/SKILL.md +60 -0
- evalrx/agent_assets/skills/nature-figure/manifest.yaml +59 -0
- evalrx/agent_assets/skills/nature-figure/references/api.md +436 -0
- evalrx/agent_assets/skills/nature-figure/references/backend-selection.md +100 -0
- evalrx/agent_assets/skills/nature-figure/references/chart-types.md +281 -0
- evalrx/agent_assets/skills/nature-figure/references/common-patterns.md +350 -0
- evalrx/agent_assets/skills/nature-figure/references/demos.md +65 -0
- evalrx/agent_assets/skills/nature-figure/references/design-theory.md +439 -0
- evalrx/agent_assets/skills/nature-figure/references/figure-contract.md +93 -0
- evalrx/agent_assets/skills/nature-figure/references/figure-legend-conventions.md +71 -0
- evalrx/agent_assets/skills/nature-figure/references/nature-2026-observations.md +112 -0
- evalrx/agent_assets/skills/nature-figure/references/qa-contract.md +119 -0
- evalrx/agent_assets/skills/nature-figure/references/r-template-index.md +66 -0
- evalrx/agent_assets/skills/nature-figure/references/r-workflow.md +161 -0
- evalrx/agent_assets/skills/nature-figure/references/tutorials.md +251 -0
- evalrx/agent_assets/skills/nature-figure/static/core/contract.md +29 -0
- evalrx/agent_assets/skills/nature-figure/static/core/stance.md +37 -0
- evalrx/agent_assets/skills/nature-figure/static/fragments/backend/python.md +37 -0
- evalrx/agent_assets/skills/nature-figure/static/fragments/backend/r.md +44 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/SKILL.md +213 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/assets/analysis_report_template.md +53 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/references/model_selection.md +72 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/explanatory_var_eda.R +130 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/explanatory_var_eda.py +150 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/fit_outcome_model.R +181 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/fit_outcome_model.py +186 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/univariate_eda.R +149 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/univariate_eda.py +177 -0
- evalrx/agent_assets/skills.py +27 -0
- evalrx/agent_runtime/__init__.py +78 -0
- evalrx/agent_runtime/_docker_runner.py +89 -0
- evalrx/agent_runtime/cli_runtime.py +103 -0
- evalrx/agent_runtime/cli_transcript.py +138 -0
- evalrx/agent_runtime/cli_types.py +68 -0
- evalrx/agent_runtime/codegen/__init__.py +5 -0
- evalrx/agent_runtime/codegen/runner.py +94 -0
- evalrx/agent_runtime/experiment_harness.py +117 -0
- evalrx/agent_runtime/factory.py +102 -0
- evalrx/agent_runtime/json_shape.py +44 -0
- evalrx/agent_runtime/judges/__init__.py +28 -0
- evalrx/agent_runtime/judges/agy.py +179 -0
- evalrx/agent_runtime/judges/autodetect.py +135 -0
- evalrx/agent_runtime/judges/claude.py +159 -0
- evalrx/agent_runtime/judges/codex.py +120 -0
- evalrx/agent_runtime/providers/__init__.py +21 -0
- evalrx/agent_runtime/providers/antigravity.py +31 -0
- evalrx/agent_runtime/providers/base.py +145 -0
- evalrx/agent_runtime/providers/claude_code.py +49 -0
- evalrx/agent_runtime/providers/codex.py +37 -0
- evalrx/agent_runtime/providers/gemini_cli.py +26 -0
- evalrx/agent_runtime/providers/kimi_cli.py +27 -0
- evalrx/agent_runtime/providers/opencode.py +27 -0
- evalrx/agent_runtime/providers/registry.py +58 -0
- evalrx/agent_runtime/sandbox.py +517 -0
- evalrx/agent_runtime/skill_audit.py +143 -0
- evalrx/agent_runtime/skills/__init__.py +19 -0
- evalrx/agent_runtime/skills/installer.py +68 -0
- evalrx/agent_runtime/skills/prompt_policy.py +86 -0
- evalrx/agent_runtime/skills/resolver.py +19 -0
- evalrx/analysis/__init__.py +132 -0
- evalrx/analysis/adjudicate.py +154 -0
- evalrx/analysis/analysis_module.py +361 -0
- evalrx/analysis/api.py +171 -0
- evalrx/analysis/case_studio.py +651 -0
- evalrx/analysis/cli.py +114 -0
- evalrx/analysis/dashboard.py +350 -0
- evalrx/analysis/eval_case_matrix.py +118 -0
- evalrx/analysis/eval_viz_theme.py +833 -0
- evalrx/analysis/explore_run.py +333 -0
- evalrx/analysis/explorer.py +1276 -0
- evalrx/analysis/failure_modes.py +607 -0
- evalrx/analysis/fused_pipeline.py +489 -0
- evalrx/analysis/holdout.py +300 -0
- evalrx/analysis/hypothesis_agent.py +230 -0
- evalrx/analysis/narration.py +177 -0
- evalrx/analysis/operationalize.py +442 -0
- evalrx/analysis/plain_language.py +42 -0
- evalrx/analysis/planner.py +283 -0
- evalrx/analysis/probe_search.py +203 -0
- evalrx/analysis/profile.py +268 -0
- evalrx/analysis/prompts/__init__.py +0 -0
- evalrx/analysis/prompts/explorer.py +417 -0
- evalrx/analysis/prompts/failure_modes.py +33 -0
- evalrx/analysis/prompts/holdout.py +27 -0
- evalrx/analysis/prompts/hypothesis_agent.py +78 -0
- evalrx/analysis/prompts/run_codebase.py +47 -0
- evalrx/analysis/prompts/stats_agent.py +72 -0
- evalrx/analysis/prompts/stats_tool_generator.py +43 -0
- evalrx/analysis/result_marker.py +47 -0
- evalrx/analysis/run_codebase.py +242 -0
- evalrx/analysis/run_view.py +205 -0
- evalrx/analysis/stage_views.py +93 -0
- evalrx/analysis/stats_agent.py +944 -0
- evalrx/analysis/stats_tool_agent.py +261 -0
- evalrx/analysis/stats_tool_generator.py +415 -0
- evalrx/analysis/stats_tools.py +1153 -0
- evalrx/analysis/trajectory_records.py +193 -0
- evalrx/analysis/workbench.py +431 -0
- evalrx/analyzers/__init__.py +42 -0
- evalrx/analyzers/agent/__init__.py +25 -0
- evalrx/analyzers/agent/counterfactual.py +84 -0
- evalrx/analyzers/agent/first_error_judge.py +96 -0
- evalrx/analyzers/agent/ignored_obs.py +81 -0
- evalrx/analyzers/agent/loop_detect.py +79 -0
- evalrx/analyzers/agent/reliability.py +165 -0
- evalrx/analyzers/agent/tool_shap.py +225 -0
- evalrx/analyzers/agent/trajectory_rubric.py +168 -0
- evalrx/analyzers/attention/__init__.py +19 -0
- evalrx/analyzers/attention/relative_attn.py +610 -0
- evalrx/analyzers/attention/rollout.py +73 -0
- evalrx/analyzers/attention/sink.py +56 -0
- evalrx/analyzers/attention/summary.py +190 -0
- evalrx/analyzers/attribution/__init__.py +6 -0
- evalrx/analyzers/attribution/generic_attn.py +31 -0
- evalrx/analyzers/attribution/gradcam.py +30 -0
- evalrx/analyzers/base.py +12 -0
- evalrx/analyzers/geometry/__init__.py +6 -0
- evalrx/analyzers/geometry/cka.py +70 -0
- evalrx/analyzers/geometry/linear_probe.py +157 -0
- evalrx/analyzers/hallucination/__init__.py +9 -0
- evalrx/analyzers/hallucination/chair.py +78 -0
- evalrx/analyzers/hallucination/opera.py +29 -0
- evalrx/analyzers/hallucination/pope.py +119 -0
- evalrx/analyzers/hallucination/selfcheck.py +155 -0
- evalrx/analyzers/hallucination/vcd.py +29 -0
- evalrx/analyzers/lens/__init__.py +7 -0
- evalrx/analyzers/lens/layer_contrast.py +133 -0
- evalrx/analyzers/lens/logit_lens.py +138 -0
- evalrx/analyzers/lens/tuned_lens.py +30 -0
- evalrx/analyzers/patching/__init__.py +5 -0
- evalrx/analyzers/patching/causal_trace.py +30 -0
- evalrx/analyzers/perturbation/__init__.py +23 -0
- evalrx/analyzers/perturbation/_shapley.py +54 -0
- evalrx/analyzers/perturbation/context_shap.py +174 -0
- evalrx/analyzers/perturbation/cot_faithfulness.py +239 -0
- evalrx/analyzers/perturbation/format_sensitivity.py +237 -0
- evalrx/analyzers/perturbation/mm_shap.py +146 -0
- evalrx/analyzers/perturbation/modality_ablation.py +196 -0
- evalrx/analyzers/perturbation/perturbation_battery.py +274 -0
- evalrx/analyzers/perturbation/prompt_contrast.py +265 -0
- evalrx/analyzers/perturbation/rise.py +94 -0
- evalrx/analyzers/perturbation/vl_shap.py +102 -0
- evalrx/analyzers/reasoning/__init__.py +33 -0
- evalrx/analyzers/reasoning/_text.py +328 -0
- evalrx/analyzers/reasoning/answer_extraction_audit.py +327 -0
- evalrx/analyzers/reasoning/arith_audit.py +226 -0
- evalrx/analyzers/reasoning/contamination.py +214 -0
- evalrx/analyzers/reasoning/knowledge_split.py +253 -0
- evalrx/analyzers/reasoning/self_repair.py +246 -0
- evalrx/analyzers/reasoning/step_rollout_value.py +216 -0
- evalrx/analyzers/reasoning/termination_audit.py +258 -0
- evalrx/analyzers/uncertainty/__init__.py +18 -0
- evalrx/analyzers/uncertainty/calibration.py +174 -0
- evalrx/analyzers/uncertainty/coverage_gap.py +199 -0
- evalrx/analyzers/uncertainty/entropy.py +90 -0
- evalrx/analyzers/uncertainty/logprob_entropy.py +69 -0
- evalrx/analyzers/uncertainty/self_consistency.py +204 -0
- evalrx/analyzers/uncertainty/verbalized_conf.py +64 -0
- evalrx/cli.py +411 -0
- evalrx/config.py +77 -0
- evalrx/contract/__init__.py +179 -0
- evalrx/contract/common.py +452 -0
- evalrx/contract/emit.py +948 -0
- evalrx/contract/export.py +237 -0
- evalrx/contract/m1.py +325 -0
- evalrx/contract/m2.py +317 -0
- evalrx/contract/m3.py +165 -0
- evalrx/contract/m4.py +130 -0
- evalrx/contract/m5.py +292 -0
- evalrx/contract/methodology.py +76 -0
- evalrx/contract/pre_m1.py +58 -0
- evalrx/contract/typescript.py +140 -0
- evalrx/core/__init__.py +85 -0
- evalrx/core/analyzer.py +174 -0
- evalrx/core/capability.py +54 -0
- evalrx/core/case.py +443 -0
- evalrx/core/experiment.py +106 -0
- evalrx/core/model.py +198 -0
- evalrx/core/pipeline.py +42 -0
- evalrx/core/registry.py +142 -0
- evalrx/core/result.py +64 -0
- evalrx/core/spec.py +173 -0
- evalrx/core/tokentype.py +165 -0
- evalrx/core/tool.py +92 -0
- evalrx/datasets/__init__.py +41 -0
- evalrx/datasets/base.py +68 -0
- evalrx/datasets/gui_os.py +52 -0
- evalrx/datasets/llm_qa.py +57 -0
- evalrx/datasets/pure_qa.py +12 -0
- evalrx/datasets/vlm_qa.py +695 -0
- evalrx/datasets/web_search_qa.py +52 -0
- evalrx/eval_agent/__init__.py +341 -0
- evalrx/eval_agent/_tools.py +81 -0
- evalrx/eval_agent/ab_runner.py +50 -0
- evalrx/eval_agent/agentic/__init__.py +43 -0
- evalrx/eval_agent/agentic/actions.py +216 -0
- evalrx/eval_agent/agentic/board.py +107 -0
- evalrx/eval_agent/agentic/loop.py +190 -0
- evalrx/eval_agent/agentic/tools.py +538 -0
- evalrx/eval_agent/checkpoint.py +57 -0
- evalrx/eval_agent/cli_agent.py +59 -0
- evalrx/eval_agent/cli_skills.py +5 -0
- evalrx/eval_agent/evolution.py +396 -0
- evalrx/eval_agent/git_manager.py +215 -0
- evalrx/eval_agent/hypothesis.py +172 -0
- evalrx/eval_agent/label_quarantine.py +209 -0
- evalrx/eval_agent/legacy.py +530 -0
- evalrx/eval_agent/log_schema.py +497 -0
- evalrx/eval_agent/loop.py +2159 -0
- evalrx/eval_agent/loop_reports.py +116 -0
- evalrx/eval_agent/model_instrumentation.py +282 -0
- evalrx/eval_agent/narration.py +193 -0
- evalrx/eval_agent/nl_runner.py +460 -0
- evalrx/eval_agent/orchestrator.py +61 -0
- evalrx/eval_agent/preregister.py +93 -0
- evalrx/eval_agent/prompts/__init__.py +1 -0
- evalrx/eval_agent/prompts/agentic.py +46 -0
- evalrx/eval_agent/prompts/case_discovery.py +25 -0
- evalrx/eval_agent/prompts/diagnosis.py +125 -0
- evalrx/eval_agent/prompts/experiment_writer.py +265 -0
- evalrx/eval_agent/prompts/explore_step.py +37 -0
- evalrx/eval_agent/prompts/fix_agent.py +257 -0
- evalrx/eval_agent/prompts/hypothesis_tester.py +15 -0
- evalrx/eval_agent/prompts/nl_runner.py +38 -0
- evalrx/eval_agent/prompts/probe_agent.py +25 -0
- evalrx/eval_agent/prompts/probe_candidate_generator.py +14 -0
- evalrx/eval_agent/prompts/probe_generator.py +35 -0
- evalrx/eval_agent/prompts/whitebox_probe_generator.py +38 -0
- evalrx/eval_agent/report.py +58 -0
- evalrx/eval_agent/run_context.py +354 -0
- evalrx/eval_agent/run_log.schema.json +1215 -0
- evalrx/eval_agent/run_logger_v2.py +1764 -0
- evalrx/eval_agent/run_metadata.py +208 -0
- evalrx/eval_agent/stages/__init__.py +56 -0
- evalrx/eval_agent/stages/case_discovery.py +293 -0
- evalrx/eval_agent/stages/diagnosis.py +1017 -0
- evalrx/eval_agent/stages/experiment_writer.py +1634 -0
- evalrx/eval_agent/stages/fix_agent.py +3916 -0
- evalrx/eval_agent/stages/fix_internals.py +499 -0
- evalrx/eval_agent/stages/fix_pipeline.py +725 -0
- evalrx/eval_agent/stages/fix_tiers.py +187 -0
- evalrx/eval_agent/stages/fix_tools.py +1034 -0
- evalrx/eval_agent/stages/hypothesis_tester.py +1014 -0
- evalrx/eval_agent/stages/probe.py +439 -0
- evalrx/eval_agent/stages/probe_agent.py +1079 -0
- evalrx/eval_agent/stages/probe_candidate_generator.py +128 -0
- evalrx/eval_agent/stages/probe_generator.py +326 -0
- evalrx/eval_agent/stages/probe_search_agent.py +106 -0
- evalrx/eval_agent/stages/protocol.py +112 -0
- evalrx/eval_agent/stages/repair_catalog.py +273 -0
- evalrx/eval_agent/stages/surgery.py +524 -0
- evalrx/eval_agent/stages/whitebox_probe_generator.py +351 -0
- evalrx/eval_agent/store.py +231 -0
- evalrx/logging_utils.py +112 -0
- evalrx/models/__init__.py +161 -0
- evalrx/models/_discover.py +101 -0
- evalrx/models/agent.py +380 -0
- evalrx/models/backends/__init__.py +58 -0
- evalrx/models/backends/api.py +169 -0
- evalrx/models/backends/base.py +57 -0
- evalrx/models/backends/gemini_compat.py +579 -0
- evalrx/models/backends/hf_local.py +2074 -0
- evalrx/models/backends/openai_compat.py +301 -0
- evalrx/models/backends/vllm_offline.py +116 -0
- evalrx/models/base.py +24 -0
- evalrx/models/blackbox/__init__.py +4 -0
- evalrx/models/blackbox/agent.py +31 -0
- evalrx/models/blackbox/base.py +29 -0
- evalrx/models/blackbox/gemini.py +279 -0
- evalrx/models/blackbox/llm_api.py +17 -0
- evalrx/models/blackbox/vlm_api.py +17 -0
- evalrx/models/compose.py +66 -0
- evalrx/models/inference.py +88 -0
- evalrx/models/paper_methods/__init__.py +8 -0
- evalrx/models/paper_methods/aad.py +53 -0
- evalrx/models/paper_methods/ifcd.py +204 -0
- evalrx/models/paper_methods/pai.py +164 -0
- evalrx/models/paper_methods/tcd.py +202 -0
- evalrx/models/paper_methods/vcd.py +45 -0
- evalrx/models/paper_methods/vicrop.py +137 -0
- evalrx/models/toolcodec.py +143 -0
- evalrx/models/tools/__init__.py +20 -0
- evalrx/models/tools/perception.py +300 -0
- evalrx/models/tools/visual.py +174 -0
- evalrx/models/whitebox/__init__.py +26 -0
- evalrx/models/whitebox/agent.py +31 -0
- evalrx/models/whitebox/base.py +24 -0
- evalrx/models/whitebox/qwen.py +61 -0
- evalrx/models/whitebox/qwen2_5_omni.py +29 -0
- evalrx/models/whitebox/qwen2_audio.py +25 -0
- evalrx/models/whitebox/qwen_omni.py +53 -0
- evalrx/models/whitebox/qwen_vl.py +62 -0
- evalrx/observability/__init__.py +21 -0
- evalrx/observability/envelope.py +122 -0
- evalrx/observability/outbox.py +111 -0
- evalrx/observability/tracer.py +882 -0
- evalrx/reporting/__init__.py +28 -0
- evalrx/reporting/case_study.py +947 -0
- evalrx/reporting/compiler.py +587 -0
- evalrx/reporting/dynamic.py +1882 -0
- evalrx/reporting/html_report.py +2225 -0
- evalrx/reporting/langfuse_exporter.py +38 -0
- evalrx/reporting/langfuse_source.py +155 -0
- evalrx/reporting/model.py +151 -0
- evalrx/reporting/run_events.py +184 -0
- evalrx/reporting/server.py +557 -0
- evalrx/reporting/stages.py +58 -0
- evalrx/reporting/static_export.py +142 -0
- evalrx/reporting/web_dist/index.html +146 -0
- evalrx/specs.py +727 -0
- evalrx/stats/__init__.py +47 -0
- evalrx/stats/api.py +192 -0
- evalrx/stats/bootstrap.py +86 -0
- evalrx/stats/ebh.py +27 -0
- evalrx/stats/evalue.py +98 -0
- evalrx/stats/friedman.py +138 -0
- evalrx/stats/mcnemar.py +40 -0
- evalrx/stats/multiplicity.py +159 -0
- evalrx/stats/subset_sampling.py +55 -0
- evalrx/term_links.py +43 -0
- evalrx/viz/__init__.py +7 -0
- evalrx/viz/labels.py +77 -0
- evalrx/viz/prompts.py +39 -0
- evalrx/viz/renderer.py +590 -0
- evalrx/viz/schema.py +36 -0
- evalrx/viz/style.py +134 -0
- evalrx-0.1.2.dist-info/METADATA +532 -0
- evalrx-0.1.2.dist-info/RECORD +339 -0
- evalrx-0.1.2.dist-info/WHEEL +5 -0
- evalrx-0.1.2.dist-info/entry_points.txt +3 -0
- evalrx-0.1.2.dist-info/licenses/LICENSE +121 -0
- 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)
|