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,3916 @@
1
+ """Fix module — tiered, validated repair attempts after the diagnosis loop.
2
+
3
+ Design (intervention-space tiers, see :mod:`fix_tiers`): the allowed tier is
4
+ an **input** (default L2). The agent proposes candidate fixes inside the
5
+ allowed tiers, compiles every candidate to the same shape — a per-case success
6
+ function, exactly :mod:`~evalrx.eval_agent.ab_runner`'s *strategy*
7
+ contract — and validates each against the unmodified baseline with the paired
8
+ machinery from :mod:`evalrx.stats` (McNemar + e-value, never a bare p).
9
+
10
+ There is **no automatic tier escalation**: when no candidate within the allowed
11
+ tier validates, the outcome carries a *recommendation* to raise the tier,
12
+ routed from the verified hypotheses' mechanisms (:func:`route_min_tier`).
13
+
14
+ What the agent *does* retry within the allowed tier is the **proposal** itself:
15
+ ``max_repair_rounds`` (default 1) lets it run up to N propose→validate rounds.
16
+ After a round in which nothing validates, the per-candidate results (how many
17
+ cases each fixed / broke, net effect, or an execution error — see
18
+ :meth:`FixAgent._format_prior`) are summarised and fed back to the judge/coder,
19
+ which then proposes *different* strategies — never re-running an identical
20
+ candidate (:meth:`FixAgent._signature`), never raising the tier. The loop
21
+ stops as soon as a candidate validates, or when a round yields no new
22
+ candidate.
23
+
24
+ Executors by tier:
25
+
26
+ * **L0** — runtime configuration: bounded decoding controls such as
27
+ ``max_tokens``. Only proposed from explicit execution telemetry, never from
28
+ an LLM's guess about a response being short.
29
+ * **L1** — prompt transforms (judge-proposed templates).
30
+ * **L2 declarative** — catalog-tool pipelines (:mod:`fix_tools`): cheap,
31
+ deterministic, validated first.
32
+ * **L2 coded** — the coding agent (CLI agent first, judge fallback) writes a
33
+ brand-new pipeline as Python: multiple model calls per case, branching on
34
+ intermediate outputs — only the model itself is unchanged. The code runs
35
+ sandboxed with bridged model access (:mod:`fix_pipeline`); labels and
36
+ rubrics never reach it, so it cannot cheat by echoing gold answers.
37
+ * **L3a** — internals read (:mod:`fix_internals`): contrastive decoders read
38
+ and combine logits/attention from paired forward passes. Coded pipelines
39
+ are L3a only when their source actually calls the bridged
40
+ ``model_attend()``; merely exposing that optional API does not promote an
41
+ otherwise black-box L2 scaffold.
42
+ * **L3b** — internals write (:mod:`fix_internals`): pre-audited intervention
43
+ primitives (v1: visual embedding boost via a forward hook) — the judge
44
+ selects and parameterises; never free codegen against the model handle.
45
+ * **L4** — parameter space (:mod:`fix_internals`): the judge writes a
46
+ :class:`~.fix_internals.FinetuneSpec` recipe. v1's executor
47
+ (:func:`~.fix_internals.run_lora_repair`) runs exactly one shape —
48
+ ``method="lora"`` on ``target="llm"``, trained on ``finetune_pool`` (a
49
+ caller-supplied diagnosis-only :class:`CaseBatch`, never the validation
50
+ split) and validated through this same paired machinery. Every other
51
+ recipe shape, or no ``finetune_pool`` at all, is recorded but not
52
+ executed, so the escalation decision always has something concrete to
53
+ act on either way.
54
+
55
+ A *fixed* verdict means: the paired test rejects with positive net effect —
56
+ the candidate repairs significantly more cases than it breaks. With one
57
+ sample per arm that is McNemar + the Bernoulli-mixture e-value on the
58
+ discordant pairs; with ``baseline_repeats``/``candidate_repeats`` > 1 each
59
+ case is a per-arm PASS RATE and the test is the betting e-value on the paired
60
+ rate differences (:func:`evalrx.stats.compare_paired_rates`) — a
61
+ stochastic model's flaky cases are weighed by how far the candidate moves
62
+ them, neither dropped nor mistaken for repairs.
63
+ """
64
+
65
+ from __future__ import annotations
66
+
67
+ import ast
68
+ import dataclasses
69
+ import json
70
+ import logging
71
+ import re
72
+ import time
73
+ from dataclasses import dataclass, field
74
+ from typing import TYPE_CHECKING, Any, Callable, Iterable, Optional
75
+
76
+ from evalrx.analyzers.perturbation.prompt_contrast import _default_score
77
+ from evalrx.eval_agent.hypothesis import hypothesis_id
78
+ from evalrx.eval_agent.prompts.fix_agent import (
79
+ _L1_PROMPT,
80
+ _L2_CODE_PROMPT,
81
+ _L2_PROMPT,
82
+ _L3_PROMPT,
83
+ _L4_PROMPT,
84
+ _REPAIR_CATALOG_PROMPT,
85
+ _REPAIR_PROMPT_BODY,
86
+ )
87
+ from evalrx.eval_agent.stages.fix_internals import (
88
+ INTERNALS_PRIMITIVES,
89
+ FinetuneSpec,
90
+ primitives_catalog_text,
91
+ run_lora_repair,
92
+ )
93
+ from evalrx.eval_agent.stages.fix_pipeline import (
94
+ CodedPipelineResult,
95
+ run_coded_pipeline,
96
+ score_outputs,
97
+ )
98
+ from evalrx.eval_agent.stages.fix_tiers import FixTier, parse_tier, route_min_tier
99
+ from evalrx.eval_agent.stages.fix_tools import (
100
+ PipelineSpec,
101
+ _safe_generation_kwargs,
102
+ catalog_text,
103
+ run_pipeline,
104
+ safe_format,
105
+ score_to_bool,
106
+ spec_changes_input,
107
+ )
108
+ from evalrx.eval_agent.stages.probe_generator import _extract_code
109
+ from evalrx.eval_agent.stages.repair_catalog import (
110
+ discover_methods,
111
+ method_names,
112
+ supports_tier,
113
+ )
114
+ from evalrx.stats import compare, compare_paired_rates
115
+ from evalrx.stats.ebh import ebh
116
+ from evalrx.stats.evalue import evalue_bernoulli
117
+
118
+ if TYPE_CHECKING:
119
+ from evalrx.agent_runtime.cli_types import CliAgentConfig
120
+ from evalrx.core.case import CaseBatch, FailureCase
121
+ from evalrx.core.model import Model
122
+ from evalrx.eval_agent.hypothesis import Hypothesis
123
+ from evalrx.eval_agent.run_context import Trial
124
+
125
+ logger = logging.getLogger(__name__)
126
+
127
+
128
+ _MAX_JUDGE_CANDIDATES = 3
129
+ #: How many FAIL / PASS cases the proposer sees (prompt and the model's
130
+ #: baseline output; expected/gold answers are always withheld). Before this the
131
+ #: judge saw
132
+ #: only the first 160 characters of a few failing prompts (1000 characters in
133
+ #: total) — no model output, no answer format, no PASS contrast — and designed
134
+ #: blind: on bbh_tracking7 it never saw an option list or an "Answer: (X)".
135
+ _EXAMPLE_FAILS = 4
136
+ _EXAMPLE_PASSES = 2
137
+ _EXAMPLE_PROMPT_CHARS = 1600
138
+ _EXAMPLE_OUTPUT_CHARS = 1000
139
+
140
+ _TEXT_ONLY_CATALOG_NOTE = (
141
+ "(this batch is text-only: there are NO image tools; leave image_ops empty)"
142
+ )
143
+
144
+ _FLOOR_DESCRIPTIONS = {
145
+ "self_consistency_5": "5 independent samples, majority vote on the extracted final answer",
146
+ "self_refine": "answer -> critique -> revise, three calls",
147
+ "least_to_most": "decompose -> solve with the decomposition, two calls",
148
+ "chain_of_verification": "answer -> list checks -> answer after the checks, three calls",
149
+ }
150
+
151
+
152
+ def _clip(text: Any, limit: int, *, tail_share: float = 0.35) -> str:
153
+ """Head + tail of *text* within *limit* characters (the answer sits at the end)."""
154
+ text = str(text or "")
155
+ if len(text) <= limit:
156
+ return text
157
+ tail = max(1, int(limit * tail_share))
158
+ head = max(1, limit - tail)
159
+ return text[:head] + f"\n[… {len(text) - head - tail} chars elided …]\n" + text[-tail:]
160
+
161
+
162
+ def _format_examples(
163
+ cases: Any,
164
+ *,
165
+ n_fail: int = _EXAMPLE_FAILS,
166
+ n_pass: int = _EXAMPLE_PASSES,
167
+ ) -> str:
168
+ """Render prompts and baseline outputs; never expose expected/gold answers."""
169
+ fails: "list[Any]" = []
170
+ passes: "list[Any]" = []
171
+ for case in list(cases or []):
172
+ label = getattr(getattr(case, "label", None), "value", None)
173
+ (fails if label == "fail" else passes).append(case)
174
+ chosen = [("FAIL", c) for c in fails[:n_fail]] + [("PASS", c) for c in passes[:n_pass]]
175
+ if not chosen:
176
+ return "- (none)"
177
+ blocks: "list[str]" = []
178
+ for tag, case in chosen:
179
+ inp = getattr(case, "inputs", None)
180
+ prompt = _clip(getattr(inp, "prompt", ""), _EXAMPLE_PROMPT_CHARS, tail_share=0.3)
181
+ observed = getattr(case, "observed", None)
182
+ lines = [f"### {tag} case {getattr(case, 'id', '?')}", "PROMPT:", prompt]
183
+ if observed is not None:
184
+ raw = str(observed)
185
+ lines += [f"MODEL OUTPUT (baseline, {len(raw)} chars):",
186
+ _clip(raw, _EXAMPLE_OUTPUT_CHARS, tail_share=0.7)]
187
+ else:
188
+ lines.append("MODEL OUTPUT (baseline): (not recorded)")
189
+ blocks.append("\n".join(lines))
190
+ header = "(expected answers withheld; scored externally on disjoint cases)"
191
+ return header + "\n\n" + "\n\n".join(blocks)
192
+
193
+ def _code_calls_name(code: str, name: str) -> bool:
194
+ """Return whether executable source directly calls a named model bridge."""
195
+ try:
196
+ tree = ast.parse(str(code or ""))
197
+ except SyntaxError:
198
+ return False
199
+ return any(
200
+ isinstance(node, ast.Call)
201
+ and isinstance(node.func, ast.Name)
202
+ and node.func.id == name
203
+ for node in ast.walk(tree)
204
+ )
205
+
206
+
207
+ def _code_copies_example(code: str, examples: str, *, min_words: int = 8) -> bool:
208
+ """Reject generated programs that memorize an example ID or prompt phrase."""
209
+ try:
210
+ constants = [
211
+ node.value
212
+ for node in ast.walk(ast.parse(str(code or "")))
213
+ if isinstance(node, ast.Constant) and isinstance(node.value, str)
214
+ ]
215
+ except SyntaxError:
216
+ return False
217
+ literal_text = "\n".join(constants).lower()
218
+ ids = re.findall(r"^###\s+(?:FAIL|PASS)\s+case\s+(\S+)", examples, re.MULTILINE)
219
+ if any(case_id.lower() in literal_text for case_id in ids):
220
+ return True
221
+ prompts = re.findall(r"^PROMPT:\s*(.+)$", examples, re.MULTILINE)
222
+ literal_words = re.findall(r"[a-z0-9]+", literal_text)
223
+ literal_ngrams = {
224
+ tuple(literal_words[i : i + min_words])
225
+ for i in range(max(0, len(literal_words) - min_words + 1))
226
+ }
227
+ for prompt in prompts:
228
+ words = re.findall(r"[a-z0-9]+", prompt.lower())
229
+ if any(
230
+ tuple(words[i : i + min_words]) in literal_ngrams
231
+ for i in range(max(0, len(words) - min_words + 1))
232
+ ):
233
+ return True
234
+ return False
235
+
236
+
237
+ def _code_redefines_model_bridge(code: str) -> bool:
238
+ """Reject user pipelines that shadow host-provided model bridge functions."""
239
+ try:
240
+ tree = ast.parse(str(code or ""))
241
+ except SyntaxError:
242
+ return False
243
+ bridge_names = {"model_generate", "model_attend"}
244
+ return any(
245
+ isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
246
+ and node.name in bridge_names
247
+ for node in ast.walk(tree)
248
+ )
249
+
250
+
251
+ def _explicit_multiple_choice_answer(value: Any) -> str:
252
+ """Extract an explicit A-D commitment without mining unfinished prose."""
253
+ text = str(value or "").strip().upper()
254
+ marked = re.findall(
255
+ r"(?:FINAL(?:\s+ANSWER)?|ANSWER|CHOICE|OPTION)\s*(?::|=|\-|\bIS\b)\s*"
256
+ r"\(?([A-D])\)?\b",
257
+ text,
258
+ )
259
+ if marked:
260
+ return marked[-1]
261
+ lines = [line.strip() for line in text.splitlines() if line.strip()]
262
+ final = lines[-1] if lines else text
263
+ bare = re.fullmatch(r"\(?([A-D])\)?[.!]?", final)
264
+ return bare.group(1) if bare else ""
265
+
266
+
267
+ def _malformed_choice_predicate(case: Any) -> bool:
268
+ """Gold-free gate for re-asking only malformed multiple-choice outputs."""
269
+ task = str((getattr(case, "metadata", {}) or {}).get("task", ""))
270
+ return task == "multiple_choice_letter" and not _explicit_multiple_choice_answer(
271
+ getattr(case, "observed", None)
272
+ )
273
+
274
+
275
+ _CHART_ARITHMETIC_RE = re.compile(
276
+ r"\b(?:ratio|difference|sum|total|average|percent(?:age)?|how many|"
277
+ r"more than|less than|times|add(?:ing|ed)?|subtract(?:ing|ed)?)\b",
278
+ flags=re.IGNORECASE,
279
+ )
280
+
281
+
282
+ def _chart_arithmetic_predicate(case: Any) -> bool:
283
+ """Gold-free gate for chart questions that require an explicit operation."""
284
+ metadata = getattr(case, "metadata", {}) or {}
285
+ inputs = getattr(case, "inputs", None)
286
+ return (
287
+ str(metadata.get("task", "")) == "exact_or_numeric"
288
+ and getattr(inputs, "image", None) is not None
289
+ and bool(_CHART_ARITHMETIC_RE.search(str(getattr(inputs, "prompt", ""))))
290
+ )
291
+
292
+
293
+ def _chart_case_predicate(case: Any) -> bool:
294
+ """Gold-free gate for image-backed exact/numeric chart questions."""
295
+ metadata = getattr(case, "metadata", {}) or {}
296
+ inputs = getattr(case, "inputs", None)
297
+ return (
298
+ str(metadata.get("task", "")) == "exact_or_numeric"
299
+ and getattr(inputs, "image", None) is not None
300
+ )
301
+
302
+
303
+ _CHART_COUNT_EXTRACT_RE = re.compile(
304
+ r"\b(?:how many|number of)\b|"
305
+ r"\bvalue of the (?:gray|grey|red|blue|green|yellow|orange|purple|"
306
+ r"black|white|pink|brown) bar\b",
307
+ flags=re.IGNORECASE,
308
+ )
309
+
310
+
311
+ def _chart_count_extract_predicate(case: Any) -> bool:
312
+ """Gold-free gate for chart counting and explicit coloured-bar lookup."""
313
+ return _chart_case_predicate(case) and bool(
314
+ _CHART_COUNT_EXTRACT_RE.search(
315
+ str(getattr(getattr(case, "inputs", None), "prompt", ""))
316
+ )
317
+ )
318
+
319
+
320
+ # ---------------------------------------------------------------------------
321
+ # Data model
322
+ # ---------------------------------------------------------------------------
323
+
324
+
325
+ @dataclass
326
+ class FixCandidate:
327
+ """One proposed repair, compiled later to an ab_runner-style strategy.
328
+
329
+ Attributes:
330
+ tier: Intervention space the candidate lives in.
331
+ name: Short identifier -- a slug, used to key trial folders and
332
+ to join validations back to their candidate. It is not
333
+ shown to a reader; ``description`` is.
334
+ description: One plain sentence saying what this candidate does, for
335
+ someone with no background in the field. Supplied by the
336
+ judge where the judge invented the candidate, and by
337
+ :data:`_BUILTIN_DESCRIPTIONS` for the host's own. Empty
338
+ is allowed and means "nothing readable to say" -- readers
339
+ are shown a blank rather than the slug in title case.
340
+ kind: ``"template"`` (L1) | ``"spec"`` (L2 declarative) |
341
+ ``"code"`` (agent-written pipeline) |
342
+ ``"registered_repair"`` (runtime-discovered executor) |
343
+ ``"visual_search"`` (L2 question-guided crop through an
344
+ opt-in backend).
345
+ payload: Kind-specific — template: ``{"prompt_template": ...}``;
346
+ spec: a :class:`~.fix_tools.PipelineSpec` dict;
347
+ code: ``{"code": "<python source>"}``.
348
+ source: ``"judge"``, ``"cli:<provider>"`` or ``"default"``.
349
+ predicate: Optional ``(case) -> bool`` applicability gate. When set,
350
+ the candidate is only applied to (and only judged on) the
351
+ cases it returns True for — a *conditional* fix. When
352
+ ``None``, applicability is inferred structurally (a spec
353
+ that does not change a case's input is a no-op there).
354
+ trial: Optional :class:`~evalrx.eval_agent.run_context.Trial`
355
+ — when set (a ``RunContext`` is in play), this candidate's
356
+ code, the sandbox it ran in, and its record/result all
357
+ live under ``trial.root`` instead of being scattered
358
+ across ``tools/`` / ``workspace/`` / ``fixes/``.
359
+ """
360
+
361
+ tier: FixTier
362
+ name: str
363
+ payload: "dict[str, Any]"
364
+ kind: str = "spec"
365
+ source: str = "judge"
366
+ description: str = ""
367
+ predicate: "Callable[[FailureCase], bool] | None" = None
368
+ trial: "Trial | None" = None
369
+
370
+
371
+ #: What each of the host's own repairs does, in one sentence, for a reader who
372
+ #: has never heard of this model or this field. It lives here rather than in
373
+ #: the frontend because only this module knows what these candidates actually
374
+ #: do; a consumer handed ``vcd_diffusion_noise`` can title-case it and nothing
375
+ #: more, which produces a label that looks explained and is not.
376
+ #:
377
+ #: Judge-invented candidates are not in this table -- they carry their own
378
+ #: sentence from ``what_it_does`` in the proposal. A name in neither place
379
+ #: resolves to "" and is rendered blank, on purpose.
380
+ _BUILTIN_DESCRIPTIONS: "dict[str, str]" = {
381
+ "aad_silence_contrast":
382
+ "Compares the model's answer with what it says when the sound is replaced by "
383
+ "silence, and keeps only the part the sound itself explains.",
384
+ "aad_silence_contrast_gated_false_yes":
385
+ "Compares the answer with what the model says when the sound is replaced by "
386
+ "silence, applied only where it said yes to something the audio does not support.",
387
+ "annotate_horizontal_band_count":
388
+ "Counts the bars in the chart and writes that number onto the picture before asking.",
389
+ "answer_bbox_crop":
390
+ "Crops the picture down to the region the answer is about before asking.",
391
+ "assertive_grounding":
392
+ "Tells the model to answer from what is actually in the picture rather than from "
393
+ "what it expects to be there.",
394
+ "attend_carefully":
395
+ "Adds an instruction to examine the input closely before answering.",
396
+ "chain_of_verification":
397
+ "Has the model draft an answer, then check it with its own follow-up questions "
398
+ "before committing.",
399
+ "coded_pipeline":
400
+ "Runs a short program the agent wrote that re-asks the model several different "
401
+ "ways and keeps the answer those attempts agree on.",
402
+ "detector_visual_search_consensus":
403
+ "Uses an object detector to pick regions to look at, asks about each one, and "
404
+ "keeps the answer the regions agree on.",
405
+ "finetune_recipe":
406
+ "Writes down a retraining plan for a person to run later. Nothing is changed "
407
+ "automatically.",
408
+ "guided_visual_search_consensus":
409
+ "Uses the question to choose which parts of the picture to zoom into, then keeps "
410
+ "the answer those views agree on.",
411
+ "icd_instruction_disturbance":
412
+ "Compares the answer with what the model says under a deliberately misleading "
413
+ "instruction, and discounts whatever the misleading version produced too.",
414
+ "icd_instruction_disturbance_gated_false_yes":
415
+ "Discounts whatever a deliberately misleading instruction also produced, applied "
416
+ "only where the model said yes to something the picture does not support.",
417
+ "icd_instruction_disturbance_question":
418
+ "Attaches a deliberately misleading instruction to the question itself, and "
419
+ "discounts whatever the model says in both versions.",
420
+ "ifcd_truthx_contrast":
421
+ "Nudges the model's internal state toward the pattern it shows when it is being "
422
+ "truthful, and compares that with the untouched run.",
423
+ "increase_max_tokens":
424
+ "Gives the model more room to write, so answers are not cut off part-way.",
425
+ "least_to_most":
426
+ "Breaks the question into smaller steps and has the model work through them in order.",
427
+ "opera_overtrust_binary":
428
+ "Stops the model leaning too hard on a few words it has already written when it "
429
+ "makes a yes-or-no call.",
430
+ "pai_image_attention":
431
+ "Makes the model weigh the picture more heavily and its own prior expectations less.",
432
+ "salient_crop":
433
+ "Crops the picture to its most eye-catching region before asking.",
434
+ "self_consistency_5":
435
+ "Asks the same question five times and keeps the answer the model gives most often.",
436
+ "self_refine":
437
+ "Has the model criticise its own first answer and then rewrite it.",
438
+ "separate_horizontal_bands":
439
+ "Splits the chart into separate bars so each one can be read on its own.",
440
+ "tcd_temporal_blur":
441
+ "Compares the answer with what the model says when the timing in the clip is "
442
+ "smeared out, and keeps the part real timing explains.",
443
+ "upscale_sharpen":
444
+ "Enlarges and sharpens the picture before asking.",
445
+ "vcd_diffusion_noise":
446
+ "Compares the answer with what the model says when the picture is replaced by "
447
+ "noise, and discounts whatever it would have said without seeing anything.",
448
+ "vcd_diffusion_noise_gated_false_yes":
449
+ "Discounts whatever the model would say without seeing the picture, applied only "
450
+ "where it said yes to something the picture does not support.",
451
+ "vicrop_consensus_guard":
452
+ "Zooms into the region the model was already looking at, and changes the answer "
453
+ "only when the zoomed views agree.",
454
+ "vicrop_relative_attention":
455
+ "Finds the region the model was already looking at and zooms into it before asking again.",
456
+ "visual_grounding":
457
+ "Tells the model to read the answer off the picture first and to fall back on "
458
+ "general knowledge only after that.",
459
+ "zoom_equalize":
460
+ "Zooms in and evens out the brightness so faint details become visible.",
461
+ }
462
+
463
+
464
+ def _judge_description(proposal: "Any") -> str:
465
+ """The proposal's own ``what_it_does``, cleaned, or "".
466
+
467
+ Judges that ignore the field, or answer it with the snake_case name in
468
+ title case, contribute nothing a reader could not already see -- both come
469
+ back empty rather than as a sentence that only looks like one.
470
+ """
471
+ if not isinstance(proposal, dict):
472
+ return ""
473
+ text = " ".join(str(proposal.get("what_it_does", "") or "").split()).strip()
474
+ if not text:
475
+ return ""
476
+ name = str(proposal.get("name", "") or "").strip()
477
+ if name and text.lower().rstrip(".") == name.replace("_", " ").lower():
478
+ return ""
479
+ return text[:300]
480
+
481
+
482
+ def _code_description(code: str) -> str:
483
+ """The ``# WHAT_IT_DOES:`` header the codegen prompt asks for.
484
+
485
+ The prompt asks for one line and shows a one-line example, but a model that
486
+ wraps it anyway should not lose the second half of its own sentence, so
487
+ immediately following comment lines are folded in until the code starts.
488
+ """
489
+ lines = code.splitlines()[:12]
490
+ for index, line in enumerate(lines):
491
+ if not line.strip().upper().startswith("# WHAT_IT_DOES:"):
492
+ continue
493
+ parts = [line.strip().split(":", 1)[1]]
494
+ for follow in lines[index + 1:]:
495
+ stripped = follow.strip()
496
+ if not stripped.startswith("#"):
497
+ break
498
+ body = stripped.lstrip("#").strip()
499
+ if not body or body.upper().startswith("WHAT_IT_DOES"):
500
+ break
501
+ parts.append(body)
502
+ return " ".join(" ".join(parts).split()).strip()[:300]
503
+ return ""
504
+
505
+
506
+ def plain_description(candidate: "FixCandidate") -> str:
507
+ """One sentence for a reader, or "" when there is honestly nothing to say.
508
+
509
+ Never falls back to the slug. ``audio_evidence_then_answer`` rendered as
510
+ "Audio Evidence Then Answer" reads like an explanation the run never
511
+ produced, and a blank is the more honest signal that it did not.
512
+ """
513
+ described = (candidate.description or "").strip()
514
+ return described or _BUILTIN_DESCRIPTIONS.get(candidate.name, "")
515
+
516
+
517
+ @dataclass
518
+ class FixValidation:
519
+ """Paired validation of one candidate against the unmodified baseline.
520
+
521
+ Safety (``n_broken``) and coverage are scoped to the cases the candidate is
522
+ *applicable* to — a fix is not blamed for cases it never touched. Cases
523
+ whose baseline answer is unstable across repeats (sampling noise) are held
524
+ out as ``n_unstable`` so a stochastic flip is not mistaken for a regression.
525
+ """
526
+
527
+ candidate: FixCandidate
528
+ n_pairs: int = 0
529
+ n_baseline_correct: int = 0 # among the paired, applicable cases
530
+ n_candidate_correct: int = 0 # among the paired, applicable cases
531
+ n_fixed: int = 0
532
+ n_broken: int = 0
533
+ fixed_cases: "list[str]" = field(default_factory=list)
534
+ broken_cases: "list[str]" = field(default_factory=list)
535
+ effect: "float | None" = None
536
+ reject: bool = False
537
+ fixed: bool = False
538
+ summary: str = ""
539
+ # Applicability + noise accounting (defects 1 & 2).
540
+ n_applicable: int = 0 # cases the candidate actually touched
541
+ coverage: "float | None" = None # applicable FAILs / total FAILs in subset
542
+ n_unstable: int = 0 # cases dropped as baseline-unstable (noise)
543
+ # Failing cases a coded pipeline ALSO got right with the model frozen to
544
+ # its recorded answers (fix_pipeline.frozen_model_control) — the repair was
545
+ # the pipeline's own computation, not the model's, so they leave the paired
546
+ # test. Baseline-correct cases are never dropped on this ground.
547
+ n_model_independent: int = 0
548
+ e_value: "float | None" = None
549
+ # The bar ``e_value`` was judged against (1/alpha). Recorded beside the
550
+ # number so a reader of "1.5 to 1" can see it needed 20 to 1, not guess.
551
+ e_threshold: "float | None" = None
552
+ # Coarse verdict (defect 4): fixed | partial | unsafe | regressed |
553
+ # no_effect | not_executed | model_independent. Richer than the boolean
554
+ # ``fixed`` for triage.
555
+ verdict: str = ""
556
+ # Non-empty when the candidate never EXECUTED (sandbox crash, timeout,
557
+ # bridge contract violation) — distinct from "executed and not effective".
558
+ # Escalation must not treat these as evidence that the tier is exhausted.
559
+ exec_error: str = ""
560
+ # What the candidate actually PRODUCED, per case id (the aggregated /
561
+ # final answer that was scored). Persisted by RunLogger as
562
+ # ``outputs.jsonl`` beside the record, never inlined in the JSONL event.
563
+ # Without this a "regressed" verdict cannot be told apart from a truncated
564
+ # chain, a format slip, or a genuinely wrong answer after the fact.
565
+ outputs: "dict[str, str]" = field(default_factory=dict)
566
+ # Model calls that hit the decode cap while this candidate ran (delta of
567
+ # the model's ``n_truncated`` counter when it exposes one; ``None`` when
568
+ # the backend has no such telemetry). A candidate whose breaks coincide
569
+ # with truncations was undone by its decoding budget, not by its idea.
570
+ n_truncated: "int | None" = None
571
+ # Which paired test decided: ``"mcnemar"`` (one sample per arm; McNemar +
572
+ # Bernoulli-mixture e-value) or ``"paired_rates"`` (per-case pass rates
573
+ # from k baseline / m candidate samples; betting e-value on the rate
574
+ # difference). ``n_fixed``/``n_broken`` are always MODAL flips (baseline
575
+ # rate < 0.5 -> candidate rate >= 0.5 and the reverse) so they read the
576
+ # same under both; the effect/e-value under paired_rates weigh each case
577
+ # by the size of the move, so an unstable case counts fractionally.
578
+ noise_model: str = "mcnemar"
579
+ baseline_rate: "float | None" = None # mean per-case baseline pass rate (paired cases)
580
+ candidate_rate: "float | None" = None # mean per-case candidate pass rate
581
+ n_baseline_samples: int = 1
582
+ n_candidate_samples: int = 1
583
+ e_value_regression: "float | None" = None # mirror e-value (candidate WORSE), paired_rates only
584
+
585
+
586
+ @dataclass
587
+ class FixOutcome:
588
+ """Everything the fix module did, plus the escalation recommendation.
589
+
590
+ ``recommendation`` is ``None`` when a candidate validated; otherwise
591
+ ``{"recommend_tier": "L3a", "reason": ...}`` — the caller decides whether
592
+ to re-run with a higher ``max_tier`` (never automatic).
593
+ """
594
+
595
+ max_tier: FixTier
596
+ routed: "list[dict[str, str]]" = field(default_factory=list)
597
+ attempted: "list[FixValidation]" = field(default_factory=list)
598
+ best: "FixValidation | None" = None
599
+ fixed: bool = False
600
+ recommendation: "dict[str, Any] | None" = None
601
+ # Feedback edge back into diagnosis (defect 3): set when a candidate helps
602
+ # one subset and hurts another — evidence the mechanism is subset-specific
603
+ # and the hypothesis should be re-scoped, not that no fix exists.
604
+ refine_signal: "dict[str, Any] | None" = None
605
+ # Number of feedback-driven propose->validate rounds actually run (>= 1).
606
+ repair_rounds: int = 0
607
+ # Candidate names whose e-value survives e-BH FDR control across the whole
608
+ # tested family (the multiplicity correction for best-of-N selection). A
609
+ # candidate is eligible to be `best` only if it is BOTH individually `fixed`
610
+ # AND in this set. Empty when no candidate carried an e-value.
611
+ ebh_survivors: "list[str]" = field(default_factory=list)
612
+ # In held-out mode candidates are authored/tuned on EXPLORE, then exactly
613
+ # one frozen candidate is sent to CONFIRM. Keep a compact audit trail of
614
+ # that selection phase without mixing its statistics into the final gate.
615
+ selection_attempted: "list[dict[str, Any]]" = field(default_factory=list)
616
+ selected_on_explore: "str | None" = None
617
+ # A skipped repair is not a failed repair. This explicit state is used by
618
+ # the report and Langfuse lifecycle event instead of inferring intent from
619
+ # an empty ``attempted`` list.
620
+ stage_status: str = "completed"
621
+ skip_reason: str | None = None
622
+
623
+ def to_dict(self) -> "dict[str, Any]":
624
+ return {
625
+ "max_tier": self.max_tier.label,
626
+ "repair_rounds": self.repair_rounds,
627
+ "routed": self.routed,
628
+ "attempted": [
629
+ {
630
+ "tier": v.candidate.tier.label,
631
+ "name": v.candidate.name,
632
+ "kind": v.candidate.kind,
633
+ "source": v.candidate.source,
634
+ "payload": v.candidate.payload,
635
+ # Self-contained attempt folder (see RunContext.new_trial) —
636
+ # None when no RunContext is in play (legacy flat layout).
637
+ "trial_root": (str(v.candidate.trial.root) if v.candidate.trial else None),
638
+ "n_pairs": v.n_pairs,
639
+ "n_baseline_correct": v.n_baseline_correct,
640
+ "n_candidate_correct": v.n_candidate_correct,
641
+ "n_fixed": v.n_fixed,
642
+ "n_broken": v.n_broken,
643
+ "fixed_cases": v.fixed_cases,
644
+ "broken_cases": v.broken_cases,
645
+ "effect": v.effect,
646
+ "reject": v.reject,
647
+ "fixed": v.fixed,
648
+ "summary": v.summary,
649
+ "n_applicable": v.n_applicable,
650
+ "coverage": v.coverage,
651
+ "n_unstable": v.n_unstable,
652
+ "n_model_independent": v.n_model_independent,
653
+ "e_value": v.e_value,
654
+ "e_threshold": v.e_threshold,
655
+ "verdict": v.verdict,
656
+ "n_truncated": v.n_truncated,
657
+ "noise_model": v.noise_model,
658
+ "baseline_rate": v.baseline_rate,
659
+ "candidate_rate": v.candidate_rate,
660
+ "n_baseline_samples": v.n_baseline_samples,
661
+ "n_candidate_samples": v.n_candidate_samples,
662
+ "e_value_regression": v.e_value_regression,
663
+ "outputs": dict(v.outputs),
664
+ }
665
+ for v in self.attempted
666
+ ],
667
+ "best": self.best.candidate.name if self.best else None,
668
+ "fixed": self.fixed,
669
+ "recommendation": self.recommendation,
670
+ "refine_signal": self.refine_signal,
671
+ "ebh_survivors": self.ebh_survivors,
672
+ "selection_attempted": self.selection_attempted,
673
+ "selected_on_explore": self.selected_on_explore,
674
+ "stage_status": self.stage_status,
675
+ "skip_reason": self.skip_reason,
676
+ }
677
+
678
+
679
+ def _truncated_count(model: Any) -> "int | None":
680
+ """The model's ``n_truncated`` counter when it exposes one, else ``None``."""
681
+ value = getattr(model, "n_truncated", None)
682
+ if isinstance(value, bool) or not isinstance(value, int):
683
+ return None
684
+ return value
685
+
686
+
687
+ @dataclass
688
+ class FixContext:
689
+ """What the proposer (judge / coder) may see BESIDES the hypotheses.
690
+
691
+ Everything is optional; the loop builds one from its report in
692
+ ``run_fix``. The two rules that matter:
693
+
694
+ * ``example_cases`` expose only prompt, baseline output, and PASS/FAIL.
695
+ Expected/gold answers are always withheld. They are normally disjoint
696
+ from the candidate-validation batch: the loop passes its EXPLORE split
697
+ and scores the fix on CONFIRM.
698
+ * ``evidence`` / ``refuted`` are read-only narrative: what M2/M4/explore
699
+ established and what M5's intervention experiment knocked down. They
700
+ steer *what* to propose; validation still decides *whether* it works.
701
+
702
+ Attributes:
703
+ example_cases: Cases the proposer may see in full (see above).
704
+ evidence: Host-built summary of M2 statistics, M4 test
705
+ verdicts and exploratory notes.
706
+ refuted: Hypotheses an M5 experiment REFUTED (statement +
707
+ why), so the proposer does not build on them.
708
+ scoring_note: How outputs are scored / the expected final-answer
709
+ format (e.g. "last 'Answer:' line, '(D)' == 'D'").
710
+ baseline_decoding: ``{"max_tokens": .., "temperature": ..}`` the
711
+ baseline was generated with; the agent also uses
712
+ ``max_tokens`` as the floor a candidate may not go
713
+ below.
714
+ task_note: One-paragraph task / protocol description.
715
+ hypotheses_note: Status caveat printed right under the hypotheses
716
+ (e.g. "UNVERIFIED: M4 found no significant evidence
717
+ …") when the loop hands the fix unverified leads.
718
+ """
719
+
720
+ example_cases: "Any | None" = None
721
+ evidence: str = ""
722
+ refuted: "list[str]" = field(default_factory=list)
723
+ scoring_note: str = ""
724
+ baseline_decoding: "dict[str, Any]" = field(default_factory=dict)
725
+ task_note: str = ""
726
+ hypotheses_note: str = ""
727
+
728
+
729
+ # ---------------------------------------------------------------------------
730
+ # Agent
731
+ # ---------------------------------------------------------------------------
732
+
733
+
734
+ class FixAgent:
735
+ """Propose and validate tiered fixes for the loop's verified hypotheses.
736
+
737
+ Args:
738
+ judge: LLM proposing candidates (deterministic defaults
739
+ when ``None`` or unparseable).
740
+ max_tier: Highest allowed intervention tier (runtime config,
741
+ prompt, scaffold, …; default L2).
742
+ score_fn: ``(case, output) -> bool | None``; defaults to the
743
+ rubric scorer shared with prompt_contrast.
744
+ run_logger: Optional RunLogger — records the outcome as a
745
+ ``fix`` event and coded pipelines as ``tool_codegen``.
746
+ cli_config: CLI coding-agent config; when set (provider != "llm")
747
+ it writes the L2 coded pipeline (judge fallback).
748
+ allow_codegen: Gate for the L2 coded-pipeline path (sandboxed,
749
+ bridged model access). Declarative candidates do
750
+ not depend on this.
751
+ sandbox: Workdir provider for coded pipelines (fresh temp
752
+ dir when ``None``).
753
+ exec_timeout_sec: Wall-clock limit for one coded-pipeline session
754
+ (includes the bridged model calls).
755
+ max_validation_cases: When > 0 and the batch is larger, validate every
756
+ candidate on a label-stratified subset of this size
757
+ (all-FAIL-first; deterministic). Every candidate
758
+ validation costs >= one model call per case, so an
759
+ unbounded batch makes coded pipelines time out.
760
+ baseline_repeats: Samples per case for the unmodified baseline
761
+ (default 1 = the frozen ``observed`` output). With
762
+ ``k > 1`` the frozen sample counts as one and ``k-1``
763
+ fresh samples are drawn, giving each case a baseline
764
+ PASS RATE; the paired test then runs on per-case rate
765
+ differences (``noise_model="paired_rates"``, betting
766
+ e-value) instead of one-sample McNemar. A case whose
767
+ baseline flips across samples is *unstable* — it is
768
+ REPORTED and weighed by how much the candidate moves
769
+ its rate, no longer dropped (dropping removed exactly
770
+ the cases a variance-reduction scaffold repairs;
771
+ keeping one sample per arm let sampling noise pose as
772
+ fixes and breaks — the 2B runs' one-break-short
773
+ verdicts).
774
+ candidate_repeats: Passes per case for a template / spec candidate
775
+ (default 1). Coded pipelines, internals primitives
776
+ and fine-tune recipes always run once. Any value > 1
777
+ also switches the test to paired rates.
778
+ alpha: Significance level for the e-value gate (default 0.05;
779
+ rejects when e >= 1/alpha). Also sets the power
780
+ ceiling used to flag underpowered-by-design runs.
781
+ run_context: Optional :class:`~evalrx.eval_agent.run_context.RunContext`.
782
+ When set (directly, or inherited from ``run_logger``),
783
+ coded-pipeline sandbox workdirs are allocated durably
784
+ under ``workspace/`` instead of an ephemeral temp dir.
785
+ max_repair_rounds: Number of feedback-driven propose->validate rounds
786
+ (default 1 = single-shot). When > 1 and a round
787
+ validates nothing, the failed candidates' results are
788
+ fed back to the judge/coder (see
789
+ :meth:`_format_prior`), which proposes *different*
790
+ strategies within the SAME tier (no tier escalation).
791
+ Stops early on the first validated fix or when a
792
+ round adds no new candidate.
793
+ max_judge_candidates: Maximum candidates to request and validate for
794
+ each judge-proposed tier. Defaults to three. Lower
795
+ this for a bounded screening experiment; doing so is
796
+ also reflected in the multiplicity correction.
797
+ finetune_pool: Diagnosis-split-only cases available for L4's LoRA
798
+ executor to train on — MUST be disjoint from
799
+ whatever batch is passed to
800
+ :meth:`propose_and_validate` (never the selection
801
+ or confirmation split; training and validating on
802
+ the same cases is leakage, not a fix). ``None``
803
+ (default) means L4 candidates are recorded but not
804
+ executed, same as before this executor existed.
805
+ See :func:`~.fix_internals.run_lora_repair`.
806
+ verbose: When ``True``, print this agent's own tier-routing /
807
+ candidate-generation / validation-verdict narration
808
+ to stdout (``evalrx.enable_console_logging()``).
809
+ Redundant when the owning ``VLDiagnoseLoop`` was
810
+ already constructed with ``verbose=True``.
811
+ baseline_generation_kwargs: The decoding controls the BASELINE was
812
+ generated with (``{"max_tokens": 4096,
813
+ "temperature": 0.6}``). ``max_tokens`` becomes the
814
+ floor for every candidate: a judge-proposed
815
+ ``max_tokens`` below it is raised to it (measured:
816
+ on qwen3.5-2b/bbh_tracking7 the judge set 900 where
817
+ the baseline had 4096 and 30% of baseline outputs
818
+ already exceed 900 tokens — the candidate "regressed"
819
+ by truncation, not by its idea). When ``None`` the
820
+ floor is inferred from ``model.max_tokens`` or the
821
+ cases' ``metadata["generation_config"]``, else no
822
+ floor is enforced. Both values are also shown to the
823
+ proposer.
824
+ concurrency: Threads used to run a declarative candidate over
825
+ the validation batch, and how many bridged model
826
+ calls the coded-pipeline host services at once
827
+ (default 1 = serial, unchanged behaviour).
828
+ scoring_note: Free-text description of how outputs are scored /
829
+ the expected final-answer format, shown to the
830
+ proposer (a per-call ``FixContext.scoring_note``
831
+ overrides it).
832
+ floor_candidates: Names of built-in default candidates that are ALWAYS
833
+ in the tested family for a text-only batch, on top of
834
+ whatever the judge proposes (default:
835
+ ``("self_consistency_5",)`` — 5 samples, majority
836
+ vote on the extracted final answer). Before this the
837
+ defaults were a *fallback* used only when the judge
838
+ returned nothing, so the most basic variance-reduction
839
+ scaffold was never even tested next to the judge's
840
+ ideas. Empty tuple / ``None`` disables the floor.
841
+ """
842
+
843
+ def __init__(
844
+ self,
845
+ judge: "Model | None" = None,
846
+ max_tier: "str | FixTier" = FixTier.L2_SCAFFOLD,
847
+ min_tier: "str | FixTier | None" = None,
848
+ score_fn: "Callable[[FailureCase, str], Optional[bool]] | None" = None,
849
+ run_logger: "Any | None" = None,
850
+ cli_config: "CliAgentConfig | None" = None,
851
+ allow_codegen: bool = True,
852
+ sandbox: "Any | None" = None,
853
+ exec_timeout_sec: int = 600,
854
+ max_validation_cases: int = 0,
855
+ baseline_repeats: int = 1,
856
+ candidate_repeats: int = 1,
857
+ alpha: float = 0.05,
858
+ run_context: "Any | None" = None,
859
+ max_repair_rounds: int = 1,
860
+ max_judge_candidates: int = _MAX_JUDGE_CANDIDATES,
861
+ allow_adapted_paper_methods: bool = False,
862
+ paper_methods_only: bool = False,
863
+ candidate_allowlist: "Iterable[str] | None" = None,
864
+ finetune_pool: "CaseBatch | None" = None,
865
+ verbose: bool = False,
866
+ baseline_generation_kwargs: "dict[str, Any] | None" = None,
867
+ concurrency: int = 1,
868
+ scoring_note: str = "",
869
+ floor_candidates: "Iterable[str] | None" = ("self_consistency_5",),
870
+ prewritten_code: str = "",
871
+ candidate_model: "Model | None" = None,
872
+ deployed_spec: "dict[str, Any] | None" = None,
873
+ ) -> None:
874
+ if verbose:
875
+ # Surfaces this module's own logger.info()/.warning() calls (tier
876
+ # routing, candidate generation, validation verdicts) — see
877
+ # VLDiagnoseLoop's verbose= for the same convenience one layer up.
878
+ from evalrx.logging_utils import enable_console_logging
879
+
880
+ enable_console_logging()
881
+
882
+ self._judge = judge
883
+ self._finetune_pool = finetune_pool
884
+ self.max_tier = parse_tier(max_tier)
885
+ # Normally a fixed-ceiling run considers every cheaper tier. The loop's
886
+ # auto-escalation path sets this transiently so each ladder station
887
+ # evaluates only the newly opened intervention space.
888
+ self.min_tier = parse_tier(min_tier) if min_tier is not None else None
889
+ self._score = score_fn or _default_score
890
+ self.run_logger = run_logger
891
+ self._cli_config = cli_config
892
+ self._allow_codegen = allow_codegen
893
+ self._sandbox = sandbox
894
+ # When set (directly, or via the RunLogger's bound RunContext), the
895
+ # sandbox workdir is allocated durably under workspace/ instead of an
896
+ # ephemeral tempfile.mkdtemp() that the sandbox deletes on success.
897
+ self._run_context = run_context or getattr(run_logger, "_context", None)
898
+ self._exec_timeout_sec = exec_timeout_sec
899
+ self.max_validation_cases = max_validation_cases
900
+ self._baseline_repeats = max(1, int(baseline_repeats))
901
+ self._candidate_repeats = max(1, int(candidate_repeats))
902
+ self._baseline_rates: "dict[str, Optional[float]]" = {}
903
+ self._baseline_n: "dict[str, int]" = {}
904
+ self._alpha = float(alpha)
905
+ self.max_repair_rounds = max(1, int(max_repair_rounds))
906
+ self.max_judge_candidates = max(1, int(max_judge_candidates))
907
+ self._allow_adapted_paper_methods = bool(allow_adapted_paper_methods)
908
+ self._paper_methods_only = bool(paper_methods_only)
909
+ self._candidate_allowlist = (
910
+ frozenset(str(name) for name in candidate_allowlist)
911
+ if candidate_allowlist is not None
912
+ else None
913
+ )
914
+ self._last_repair_prompt = ""
915
+ self._last_raw_stream = ""
916
+ self._last_usage: dict | None = None
917
+ self._baseline_generation_kwargs = dict(baseline_generation_kwargs or {})
918
+ self._concurrency = max(1, int(concurrency))
919
+ self._scoring_note = str(scoring_note or "")
920
+ self._floor_candidates = (
921
+ tuple(str(n) for n in floor_candidates) if floor_candidates else ()
922
+ )
923
+ self._prewritten_code = str(prewritten_code or "")
924
+ # Winner-as-new-baseline (spec deploys, recursive rounds): when the
925
+ # caller runs the whole loop against a DEPLOYED pipeline handle
926
+ # (fix_tools.SpecPipelineModel), `candidate_model` is the raw model —
927
+ # baseline arms keep measuring the deployed pipeline while proposals
928
+ # and candidate arms run on the raw handle, so every candidate is a
929
+ # full REPLACEMENT pipeline paired against the deployed one.
930
+ # `deployed_spec` (the deployed PipelineSpec as a dict) is shown to
931
+ # the proposer so its candidates are edits of a known incumbent
932
+ # rather than blind wraps.
933
+ self._candidate_model = candidate_model
934
+ self._deployed_spec = dict(deployed_spec) if deployed_spec else None
935
+ # Per-candidate scratch: case id -> the final output that was scored.
936
+ # Filled by the strategy closures / run_pipeline capture while a
937
+ # candidate runs; _validate moves it onto the FixValidation.
938
+ self._captured: "dict[str, str]" = {}
939
+ self._max_tokens_floor: "int | None" = None
940
+
941
+ @property
942
+ def codegen_available(self) -> bool:
943
+ """True when the L2 coded-pipeline path has a code-writing backend."""
944
+ return self._allow_codegen and (
945
+ self._judge is not None
946
+ or (self._cli_config is not None and self._cli_config.provider != "llm")
947
+ )
948
+
949
+ # -- public ---------------------------------------------------------
950
+
951
+ def propose_and_validate(
952
+ self,
953
+ model: "Model",
954
+ data: "CaseBatch",
955
+ hypotheses: "list[Hypothesis]",
956
+ prior_attempts: "list[FixValidation] | None" = None,
957
+ context: "FixContext | None" = None,
958
+ proposal_data: "CaseBatch | None" = None,
959
+ ) -> FixOutcome:
960
+ """Generate candidates within the allowed tiers, validate, recommend.
961
+
962
+ *context* (optional :class:`FixContext`) is what the proposer sees
963
+ besides the hypotheses — full example cases from a DISJOINT split,
964
+ the M2/M4/explore evidence, M5-refuted hypotheses, the scoring rule
965
+ and the baseline decoding budget. ``proposal_data``, when supplied,
966
+ is the discovery partition available to the repair author; ``data``
967
+ remains untouched confirmation data and permits only one round.
968
+ """
969
+ outcome = FixOutcome(max_tier=self.max_tier)
970
+ # Winner-as-new-baseline: `model` as handed in is what the baseline
971
+ # arm must measure (the deployed pipeline, when one is configured);
972
+ # proposals and candidate arms run on the raw handle so a candidate
973
+ # REPLACES the deployed pipeline instead of nesting inside it.
974
+ baseline_model = model
975
+ if self._candidate_model is not None:
976
+ model = self._candidate_model
977
+ self._max_tokens_floor = self._resolve_max_tokens_floor(model, data)
978
+ routed_tiers: "list[FixTier]" = []
979
+ for h in hypotheses:
980
+ tier, why = route_min_tier(h)
981
+ routed_tiers.append(tier)
982
+ outcome.routed.append(
983
+ {
984
+ # Full-statement hash, NOT derived from the truncated
985
+ # "hypothesis" string below — must match the id the same
986
+ # Hypothesis object got in its M3 log entry.
987
+ "hypothesis_id": hypothesis_id(h),
988
+ "hypothesis": getattr(h, "statement", str(h))[:160],
989
+ "min_tier": tier.label,
990
+ "rationale": why,
991
+ }
992
+ )
993
+
994
+ data = self._validation_subset(data)
995
+ authoring_data = proposal_data if proposal_data is not None else data
996
+ baseline, unstable = self._baseline(baseline_model, data)
997
+ if not any(v is not None for v in baseline.values()):
998
+ logger.warning("FixAgent: no scorable case (no rubrics); nothing to validate")
999
+ outcome.recommendation = self._recommend(
1000
+ routed_tiers,
1001
+ model=model,
1002
+ data=data,
1003
+ reason_prefix=("no case carries a scoring rubric, so no fix can be validated"),
1004
+ )
1005
+ self._emit(outcome)
1006
+ return outcome
1007
+
1008
+ # With zero repairable failure mass in the fresh baseline no candidate
1009
+ # can ever validate (n_fixed stays 0, the e-value ceiling is 1 <
1010
+ # 1/alpha), yet the search would still spend its whole judge/model
1011
+ # budget — a saturated live cell burned an hour on 15 candidates this
1012
+ # way. The paired test uses the FRESH baseline, not the stale case
1013
+ # labels, so the gate must too. Tiers above L2 are exempt: an L3a
1014
+ # candidate pairs against its own matched sampling control
1015
+ # (``baseline_executor``), so a clean greedy baseline does not bound
1016
+ # its e-value.
1017
+ repairable = True
1018
+ if self.max_tier <= FixTier.L2_SCAFFOLD:
1019
+ if self._baseline_repeats > 1 or self._candidate_repeats > 1:
1020
+ repairable = any(
1021
+ r is not None and r < 1.0 for r in self._baseline_rates.values()
1022
+ )
1023
+ else:
1024
+ repairable = any(v is False for v in baseline.values())
1025
+ if not repairable:
1026
+ n_scorable = sum(1 for v in baseline.values() if v is not None)
1027
+ logger.warning(
1028
+ "FixAgent: all %d scorable case(s) pass the fresh baseline — no candidate "
1029
+ "can be validated here; skipping candidate proposal",
1030
+ n_scorable,
1031
+ )
1032
+ outcome.recommendation = {
1033
+ "recommend_tier": None,
1034
+ "action": "gather_more_failures",
1035
+ "reason": (
1036
+ f"nothing to repair: all {n_scorable} scorable validation case(s) pass "
1037
+ f"the fresh baseline, so even a perfect candidate tops out at e=1.0 "
1038
+ f"(< {1.0 / self._alpha:.0f} needed) — collect failing cases instead of "
1039
+ "spending the candidate budget."
1040
+ ),
1041
+ }
1042
+ self._emit(outcome)
1043
+ return outcome
1044
+
1045
+ # Feedback-driven repair rounds: propose -> validate; if nothing
1046
+ # validates, summarise the failures (this call's own attempts, PLUS
1047
+ # any prior_attempts carried over from an earlier escalation tier) and
1048
+ # ask for DIFFERENT candidates within the same tier (never escalating).
1049
+ # Stop on first validated fix or when a round adds no new candidate.
1050
+ seen: "set[tuple[str, str, str]]" = set()
1051
+ round_limit = 1 if proposal_data is not None else self.max_repair_rounds
1052
+ for round_idx in range(round_limit):
1053
+ combined_prior = list(prior_attempts or []) + outcome.attempted
1054
+ prior_text = (
1055
+ self._format_prior(combined_prior, authoring_data)
1056
+ if combined_prior
1057
+ else ""
1058
+ )
1059
+ prior_names: "frozenset[str]" = frozenset(v.candidate.name for v in combined_prior)
1060
+ new_candidates: "list[FixCandidate]" = []
1061
+ for candidate in self._propose(
1062
+ hypotheses, authoring_data, model, prior_text, prior_names, context=context
1063
+ ):
1064
+ sig = self._signature(candidate)
1065
+ if sig in seen:
1066
+ continue
1067
+ seen.add(sig)
1068
+ new_candidates.append(candidate)
1069
+ if not new_candidates:
1070
+ logger.info(
1071
+ "FixAgent: repair round %d produced no NEW candidate; stopping", round_idx + 1
1072
+ )
1073
+ break
1074
+ round_fixed = False
1075
+ for candidate in new_candidates:
1076
+ # Coded candidates already have a trial (allocated at proposal
1077
+ # time, since the CLI agent needs a workdir immediately).
1078
+ # Declarative candidates (template/spec/primitive) get one
1079
+ # here — AFTER dedup — so a repeated default proposal never
1080
+ # burns a folder for nothing.
1081
+ if candidate.trial is None and self._run_context is not None:
1082
+ candidate.trial = self._run_context.new_trial(
1083
+ "fixes", f"{candidate.tier.label}_{candidate.name}"
1084
+ )
1085
+ logger.info(
1086
+ "FixAgent: validating tier=%s candidate=%s kind=%s source=%s",
1087
+ candidate.tier.label,
1088
+ candidate.name,
1089
+ candidate.kind,
1090
+ candidate.source,
1091
+ )
1092
+ validation = self._validate(candidate, model, data, baseline, unstable)
1093
+ outcome.attempted.append(validation)
1094
+ logger.info(
1095
+ "FixAgent: result tier=%s candidate=%s verdict=%s "
1096
+ "effect=%s fixed=%d broken=%d pairs=%d",
1097
+ candidate.tier.label,
1098
+ candidate.name,
1099
+ validation.verdict,
1100
+ (
1101
+ "n/a"
1102
+ if validation.effect is None
1103
+ else f"{validation.effect:+.4f}"
1104
+ ),
1105
+ validation.n_fixed,
1106
+ validation.n_broken,
1107
+ validation.n_pairs,
1108
+ )
1109
+ round_fixed = round_fixed or validation.fixed
1110
+ outcome.repair_rounds = round_idx + 1
1111
+ if round_fixed:
1112
+ break
1113
+ if round_idx + 1 < round_limit:
1114
+ logger.info(
1115
+ "FixAgent: repair round %d validated no fix; feeding "
1116
+ "%d failed attempt(s) back for round %d",
1117
+ round_idx + 1,
1118
+ len(new_candidates),
1119
+ round_idx + 2,
1120
+ )
1121
+
1122
+ outcome.refine_signal = self._refine_signal(outcome.attempted, data)
1123
+ # Multiplicity control over the candidate family (best-of-N): every
1124
+ # candidate cleared only its OWN paired gate (e >= 1/alpha) against the
1125
+ # SAME baseline, so picking the max is a multiple-comparisons hazard.
1126
+ # e-BH composes the family's e-values into one FDR guarantee; a candidate
1127
+ # is a winner only if it is BOTH individually `fixed` AND an e-BH survivor.
1128
+ tested = [v for v in outcome.attempted if v.e_value is not None]
1129
+ survivors = self._ebh_survivors(tested)
1130
+ outcome.ebh_survivors = sorted(v.candidate.name for v in tested if id(v) in survivors)
1131
+ winners = [v for v in outcome.attempted if v.fixed and id(v) in survivors]
1132
+ if winners:
1133
+ outcome.best = max(winners, key=lambda v: (v.effect or 0.0, -v.n_broken))
1134
+ outcome.fixed = True
1135
+ else:
1136
+ culled = [v for v in outcome.attempted if v.fixed and id(v) not in survivors]
1137
+ if culled:
1138
+ names = ", ".join(sorted(v.candidate.name for v in culled))
1139
+ outcome.recommendation = {
1140
+ "recommend_tier": self.max_tier.label,
1141
+ "reason": (
1142
+ f"{len(culled)} candidate(s) cleared their own gate "
1143
+ f"({names}) but did NOT survive e-BH FDR across the "
1144
+ f"{len(tested)}-candidate family — best-of-N multiplicity, "
1145
+ "not a validated fix. Gather more failing cases (more "
1146
+ "power per candidate) or propose fewer, stronger candidates."
1147
+ ),
1148
+ }
1149
+ else:
1150
+ outcome.recommendation = self._no_fix_recommendation(
1151
+ outcome.attempted, routed_tiers, data, model
1152
+ )
1153
+ self._emit(outcome)
1154
+ return outcome
1155
+
1156
+ def validate_candidate(
1157
+ self,
1158
+ model: "Model",
1159
+ data: "CaseBatch",
1160
+ candidate: FixCandidate,
1161
+ ) -> FixValidation:
1162
+ """Confirm one pre-selected candidate on an untouched batch.
1163
+
1164
+ Candidate selection must happen before this method is called. Unlike
1165
+ :meth:`propose_and_validate`, this method never consults the judge and
1166
+ validates exactly one pre-registered candidate, so no best-of-N
1167
+ selection correction is needed on the confirmation split.
1168
+ """
1169
+ data = self._validation_subset(data)
1170
+ # Same handle split as propose_and_validate: baseline arm = deployed
1171
+ # pipeline (when configured), candidate arm = raw model.
1172
+ baseline_model = model
1173
+ if self._candidate_model is not None:
1174
+ model = self._candidate_model
1175
+ self._max_tokens_floor = self._resolve_max_tokens_floor(model, data)
1176
+ self._enforce_generation_floor([candidate])
1177
+ baseline, unstable = self._baseline(baseline_model, data)
1178
+ return self._validate(candidate, model, data, baseline, unstable)
1179
+
1180
+ def _ebh_survivors(self, tested: "list[FixValidation]") -> "set[int]":
1181
+ """id()s of validations whose e-value survives e-BH across the family.
1182
+
1183
+ The e-values come from compare() (the validated core), never from an
1184
+ LLM, so e-BH is sound. With m candidates, the sole-survivor bar rises to
1185
+ m/alpha (vs the per-candidate 1/alpha) — the correct best-of-N tax.
1186
+ A single tested candidate (m=1) reduces to the per-candidate gate, so
1187
+ non-competitive runs are unaffected.
1188
+ """
1189
+ if not tested:
1190
+ return set()
1191
+ evalues = [float(v.e_value) for v in tested]
1192
+ return {id(tested[i]) for i in ebh(evalues, alpha=self._alpha)}
1193
+
1194
+ # -- no-fix recommendation (escalate vs. gather data vs. retry exec) ----
1195
+
1196
+ def _no_fix_recommendation(
1197
+ self,
1198
+ attempted: "list[FixValidation]",
1199
+ routed_tiers: "list[FixTier]",
1200
+ data: "CaseBatch",
1201
+ model: "Model",
1202
+ ) -> "dict[str, Any] | None":
1203
+ """Decide what 'no candidate validated' actually means.
1204
+
1205
+ Three distinct causes the old single-path recommendation conflated:
1206
+
1207
+ * **never executed** — engineering failure; retry within the tier, do
1208
+ not escalate (a crash is not evidence the tier is exhausted).
1209
+ * **underpowered by design** — even a flawless fix of every failure
1210
+ could not reach significance with this few failures; gather more
1211
+ failing cases instead of climbing the (more invasive) tier ladder.
1212
+ * **genuinely exhausted** — executed, powered, still no fix; escalate.
1213
+ """
1214
+ executed = [v for v in attempted if v.n_pairs > 0]
1215
+ # A model-independent candidate DID execute; it just is not a repair of
1216
+ # the model. It is neither an engineering failure nor evidence the tier
1217
+ # is exhausted, so it sits in neither list.
1218
+ never_ran = [v for v in attempted
1219
+ if v.n_pairs == 0 and v.verdict != "model_independent"]
1220
+ if never_ran and not executed:
1221
+ return {
1222
+ "recommend_tier": self.max_tier.label,
1223
+ "action": "fix_execution",
1224
+ "reason": (
1225
+ "no candidate EXECUTED — escalating would be premature; "
1226
+ f"fix candidate execution and retry within {self.max_tier.label}. "
1227
+ "Failures: "
1228
+ + "; ".join(
1229
+ f"{v.candidate.name}: {(v.exec_error or v.summary)[:120]}"
1230
+ for v in never_ran[:3]
1231
+ )
1232
+ ),
1233
+ }
1234
+
1235
+ # Underpowered-by-design: with n_fail failures the best possible result
1236
+ # (every failure repaired, nothing broken) yields an e-value ceiling of
1237
+ # evalue_bernoulli(n_fail, n_fail); if that cannot clear 1/alpha, no fix
1238
+ # of any tier can be certified here — the bottleneck is sample size, and
1239
+ # a "promising" candidate (helped more than it hurt) confirms the lead.
1240
+ n_fail = sum(1 for c in data if getattr(c.label, "value", None) == "fail")
1241
+ if self._baseline_repeats > 1 or self._candidate_repeats > 1:
1242
+ # Paired rates: the best any candidate can do is lift every case's
1243
+ # rate to 1.0 -> the e-value of those differences is the ceiling.
1244
+ from evalrx.stats.evalue import evalue_bounded_mean
1245
+
1246
+ diffs = [
1247
+ 1.0 - r for c in data
1248
+ for r in [self._baseline_rates.get(c.id)] if r is not None
1249
+ ]
1250
+ ceiling = evalue_bounded_mean(diffs) if diffs else 1.0
1251
+ else:
1252
+ ceiling = evalue_bernoulli(n_fail, n_fail, p0=0.5) if n_fail > 0 else 1.0
1253
+ promising = [v for v in executed if (v.n_fixed - v.n_broken) > 0 and not v.reject]
1254
+ if ceiling < 1.0 / self._alpha:
1255
+ need = self._min_failures_for_power()
1256
+ evidence = ""
1257
+ if promising:
1258
+ best = max(
1259
+ promising,
1260
+ key=lambda v: (v.n_fixed - v.n_broken, v.effect or 0.0),
1261
+ )
1262
+ evidence = (
1263
+ f" {best.candidate.name!r} already helps net "
1264
+ f"{best.n_fixed - best.n_broken} case(s);"
1265
+ )
1266
+ return {
1267
+ "recommend_tier": None,
1268
+ "action": "gather_more_failures",
1269
+ "reason": (
1270
+ f"underpowered by design: only {n_fail} failure case(s) — even a "
1271
+ f"perfect fix tops out at e={ceiling:.1f} (< {1.0 / self._alpha:.0f} "
1272
+ f"needed).{evidence} collect >= {need} failing "
1273
+ "cases and re-validate before escalating the tier."
1274
+ ),
1275
+ }
1276
+
1277
+ rec = self._recommend(routed_tiers, model=model, data=data)
1278
+ if promising and rec is not None:
1279
+ best = max(promising, key=lambda v: (v.n_fixed - v.n_broken, v.effect or 0.0))
1280
+ eff = f"{best.effect:+.3f}" if best.effect is not None else "n/a"
1281
+ ev = f"{best.e_value:.1f}" if best.e_value is not None else "n/a"
1282
+ rec["promising"] = {
1283
+ "candidate": best.candidate.name,
1284
+ "n_fixed": best.n_fixed,
1285
+ "n_broken": best.n_broken,
1286
+ "effect": best.effect,
1287
+ "e_value": best.e_value,
1288
+ }
1289
+ rec["reason"] += (
1290
+ f" (note: {best.candidate.name!r} is INCONCLUSIVE, not refuted — "
1291
+ f"{best.n_fixed} fixed / {best.n_broken} broken, effect {eff}, "
1292
+ f"e={ev} < {1.0 / self._alpha:.0f}; re-validating it on more or cleaner "
1293
+ "pairs (baseline_repeats>1 to drop sampling-unstable cases, or a larger "
1294
+ "confirm split) is a cheaper next step than escalating the tier)"
1295
+ )
1296
+ solo = [v for v in attempted if v.verdict == "model_independent"]
1297
+ if solo and rec is not None:
1298
+ rec["reason"] += (
1299
+ f" (note: {len(solo)} candidate(s) solved the task without the model "
1300
+ "and were not counted as repairs: "
1301
+ + ", ".join(v.candidate.name for v in solo[:3])
1302
+ + ")"
1303
+ )
1304
+ if never_ran and rec is not None:
1305
+ rec["reason"] += (
1306
+ f" (caveat: {len(never_ran)} candidate(s) never executed: "
1307
+ + ", ".join(v.candidate.name for v in never_ran[:3])
1308
+ + ")"
1309
+ )
1310
+ return rec
1311
+
1312
+ def _min_failures_for_power(self) -> int:
1313
+ """Smallest n where a flawless fix could clear the e-value gate."""
1314
+ threshold = 1.0 / self._alpha
1315
+ for n in range(1, 200):
1316
+ if evalue_bernoulli(n, n, p0=0.5) >= threshold:
1317
+ return n
1318
+ return 200
1319
+
1320
+ @staticmethod
1321
+ def _refine_signal(
1322
+ attempted: "list[FixValidation]", data: "CaseBatch"
1323
+ ) -> "dict[str, Any] | None":
1324
+ """Heterogeneity feedback for re-diagnosis (defect 3).
1325
+
1326
+ A candidate that repairs one subset while breaking another is evidence
1327
+ the failure mode is *not* homogeneous: the right next move is to split
1328
+ the population by sub-mechanism and re-diagnose, not to keep proposing
1329
+ whole-population transforms. Surface the partition so the loop (or a
1330
+ human) can re-scope the hypothesis.
1331
+ """
1332
+ split = [v for v in attempted if v.n_fixed > 0 and v.n_broken > 0]
1333
+ if not split:
1334
+ return None
1335
+ v = max(split, key=lambda x: min(x.n_fixed, x.n_broken))
1336
+ return {
1337
+ "kind": "heterogeneous_failure_mode",
1338
+ "candidate": v.candidate.name,
1339
+ "helped_cases": list(v.fixed_cases),
1340
+ "hurt_cases": list(v.broken_cases),
1341
+ "message": (
1342
+ f"{v.candidate.name!r} repaired {v.n_fixed} case(s) but broke "
1343
+ f"{v.n_broken} — the failure mode is likely subset-specific. "
1344
+ "Re-diagnose: what distinguishes the helped cases from the hurt "
1345
+ "ones, and gate the fix on that predicate."
1346
+ ),
1347
+ }
1348
+
1349
+ def _validation_subset(self, data: "CaseBatch") -> "CaseBatch":
1350
+ """Label-stratified, deterministic subset for candidate validation."""
1351
+ cap = self.max_validation_cases
1352
+ if not cap or len(data) <= cap:
1353
+ return data
1354
+ import random as _random
1355
+
1356
+ from evalrx.core.case import CaseBatch, Label
1357
+
1358
+ rng = _random.Random(0)
1359
+ fails = [c for c in data if c.label == Label.FAIL]
1360
+ passes = [c for c in data if c.label != Label.FAIL]
1361
+ rng.shuffle(fails)
1362
+ rng.shuffle(passes)
1363
+ n_fail = min(len(fails), max(cap // 2, cap - len(passes)))
1364
+ keep = fails[:n_fail] + passes[: cap - n_fail]
1365
+ logger.info(
1366
+ "FixAgent: validating on %d/%d cases (%d fail, %d pass)",
1367
+ len(keep),
1368
+ len(data),
1369
+ n_fail,
1370
+ len(keep) - n_fail,
1371
+ )
1372
+ return CaseBatch(keep)
1373
+
1374
+ # -- candidate generation --------------------------------------------
1375
+
1376
+ def _propose(
1377
+ self,
1378
+ hypotheses: "list[Hypothesis]",
1379
+ data: "CaseBatch",
1380
+ model: "Model",
1381
+ prior_text: str = "",
1382
+ prior_names: "frozenset[str]" = frozenset(),
1383
+ context: "FixContext | None" = None,
1384
+ ) -> "list[FixCandidate]":
1385
+ context = context or FixContext()
1386
+ hyp_lines = (
1387
+ "\n".join(
1388
+ f"- [{getattr(h, 'predicted_failure_mode', '')}] {getattr(h, 'statement', h)}"
1389
+ for h in hypotheses
1390
+ )
1391
+ or "- (no verified hypotheses; failures are unexplained)"
1392
+ )
1393
+ if context.hypotheses_note:
1394
+ hyp_lines = f"({context.hypotheses_note.strip()})\n{hyp_lines}"
1395
+ # A repair proposer never receives expected/gold answers. Examples
1396
+ # contain only prompts and the model's recorded outputs; correctness is
1397
+ # evaluated outside the agent on disjoint EXPLORE/CONFIRM partitions.
1398
+ if context.example_cases is not None:
1399
+ examples = _format_examples(context.example_cases)
1400
+ else:
1401
+ examples = _format_examples(data)
1402
+ # A video frame is visual content too -- a video-only case batch (no
1403
+ # .image ever set, only .video) must not silently read as "no images"
1404
+ # and lock every image-gated L1/L2/L3 candidate out. This was found
1405
+ # via a real run (musicavqa_videollama2): has_images was False for
1406
+ # every case despite the task being audio-VISUAL QA, so no visual
1407
+ # candidate at any tier was ever structurally eligible.
1408
+ has_images = any(
1409
+ getattr(getattr(case, "inputs", None), "image", None) is not None
1410
+ or getattr(getattr(case, "inputs", None), "video", None) is not None
1411
+ for case in data
1412
+ )
1413
+ has_audio = any(
1414
+ getattr(getattr(case, "inputs", None), "audio", None) is not None for case in data
1415
+ )
1416
+ tasks = {
1417
+ str((getattr(case, "metadata", {}) or {}).get("task", "")) for case in data
1418
+ }
1419
+ # Text-only batches: the image-tool catalog is noise for the judge and
1420
+ # an invitation to burn a candidate on a structural no-op.
1421
+ catalog = catalog_text() if has_images else _TEXT_ONLY_CATALOG_NOTE
1422
+ floor_names = self._floor_names(has_images=has_images, tasks=tasks)
1423
+ context_block = self._context_block(
1424
+ context, data, model, floor_names=floor_names
1425
+ )
1426
+ # Recursive rounds only (see _edit_note): the L1/L2 proposers may also
1427
+ # EDIT the previously deployed template instead of wrapping it. The
1428
+ # coded-pipeline path is left out — its cases_file carries rendered
1429
+ # prompts only, so {original_prompt} means nothing to written code.
1430
+ proposal_context = context_block + self._edit_note(data)
1431
+
1432
+ candidates: "list[FixCandidate]" = []
1433
+ # Pre-registered conditional repair for audio/other A-D tasks whose
1434
+ # baseline never committed to an option. It gates only on the
1435
+ # observable output contract (never gold/correctness), so clean
1436
+ # answers are preserved while malformed prose is re-asked and voted.
1437
+ # Keeping it built-in also avoids asking codegen to rediscover this
1438
+ # simple, high-frequency failure mode and then implement brittle
1439
+ # option parsing from scratch.
1440
+ malformed_name = "malformed_choice_consensus"
1441
+ malformed_enabled = (
1442
+ tasks == {"multiple_choice_letter"}
1443
+ and malformed_name not in prior_names
1444
+ and (
1445
+ self._candidate_allowlist is None
1446
+ or malformed_name in self._candidate_allowlist
1447
+ )
1448
+ and self.max_tier >= FixTier.L2_SCAFFOLD
1449
+ )
1450
+ if malformed_enabled:
1451
+ spec = PipelineSpec(
1452
+ name=malformed_name,
1453
+ prompt_template=(
1454
+ "{prompt}\n\nListen to the audio evidence carefully and decide silently. "
1455
+ "Do not explain or repeat the choices. Reply with exactly one line: "
1456
+ "FINAL: X, where X is A, B, C, or D."
1457
+ ),
1458
+ n_samples=3,
1459
+ generation_kwargs={"do_sample": True, "temperature": 0.35, "top_p": 0.9},
1460
+ output_key_pattern=r"(?:FINAL(?:\s+ANSWER)?|ANSWER)\s*:\s*\(?([A-D])\)?",
1461
+ )
1462
+ candidates.append(
1463
+ FixCandidate(
1464
+ tier=FixTier.L2_SCAFFOLD,
1465
+ name=malformed_name,
1466
+ kind="spec",
1467
+ source="conditional_default",
1468
+ payload=spec.to_dict(),
1469
+ predicate=_malformed_choice_predicate,
1470
+ )
1471
+ )
1472
+ chart_name = "chart_arithmetic_verify"
1473
+ chart_enabled = (
1474
+ "exact_or_numeric" in tasks
1475
+ and has_images
1476
+ and chart_name not in prior_names
1477
+ and self._candidate_allowlist is not None
1478
+ and chart_name in self._candidate_allowlist
1479
+ and self.max_tier >= FixTier.L2_SCAFFOLD
1480
+ )
1481
+ if chart_enabled:
1482
+ spec = PipelineSpec(
1483
+ name=chart_name,
1484
+ prompt_template=(
1485
+ "Treat this as a chart measurement problem. First identify every "
1486
+ "legend/category and plotted value needed by the question, respecting "
1487
+ "the axis scale. Then perform the requested operation and independently "
1488
+ "check the arithmetic. Do all work internally and return only the short "
1489
+ "answer requested by the original question.\n\n{prompt}"
1490
+ ),
1491
+ strategy="chain_of_verification",
1492
+ )
1493
+ candidates.append(
1494
+ FixCandidate(
1495
+ tier=FixTier.L2_SCAFFOLD,
1496
+ name=chart_name,
1497
+ kind="spec",
1498
+ source="conditional_default",
1499
+ payload=spec.to_dict(),
1500
+ predicate=_chart_arithmetic_predicate,
1501
+ )
1502
+ )
1503
+ consensus_name = "chart_verified_consensus"
1504
+ consensus_enabled = (
1505
+ "exact_or_numeric" in tasks
1506
+ and has_images
1507
+ and consensus_name not in prior_names
1508
+ and self._candidate_allowlist is not None
1509
+ and consensus_name in self._candidate_allowlist
1510
+ and self.max_tier >= FixTier.L2_SCAFFOLD
1511
+ )
1512
+ if consensus_enabled:
1513
+ spec = PipelineSpec(
1514
+ name=consensus_name,
1515
+ prompt_template=(
1516
+ "Read the chart as measured evidence: identify the relevant labels, "
1517
+ "legend entries, marks, and axis scale; derive the requested answer; "
1518
+ "then check it independently. Work internally and return only the "
1519
+ "short answer requested.\n\n{prompt}"
1520
+ ),
1521
+ strategy="chain_of_verification",
1522
+ n_samples=3,
1523
+ generation_kwargs={"temperature": 0.35, "top_p": 0.9},
1524
+ baseline_override_min_support=3,
1525
+ )
1526
+ candidates.append(
1527
+ FixCandidate(
1528
+ tier=FixTier.L2_SCAFFOLD,
1529
+ name=consensus_name,
1530
+ kind="spec",
1531
+ source="conditional_default",
1532
+ payload=spec.to_dict(),
1533
+ predicate=_chart_case_predicate,
1534
+ )
1535
+ )
1536
+ count_name = "upscale_count_extract"
1537
+ count_enabled = (
1538
+ "exact_or_numeric" in tasks
1539
+ and has_images
1540
+ and count_name not in prior_names
1541
+ and self._candidate_allowlist is not None
1542
+ and count_name in self._candidate_allowlist
1543
+ and self.max_tier >= FixTier.L2_SCAFFOLD
1544
+ )
1545
+ if count_enabled:
1546
+ count_spec = self._default_spec("upscale_sharpen")
1547
+ if count_spec is not None:
1548
+ count_spec.name = count_name
1549
+ candidates.append(
1550
+ FixCandidate(
1551
+ tier=FixTier.L2_SCAFFOLD,
1552
+ name=count_name,
1553
+ kind="spec",
1554
+ source="conditional_default",
1555
+ payload=count_spec.to_dict(),
1556
+ predicate=_chart_count_extract_predicate,
1557
+ )
1558
+ )
1559
+ # An explicit allowlist is a pre-registration request. Built-in L2
1560
+ # defaults must be materialised before any judge proposal/family-size
1561
+ # truncation; filtering only afterwards could silently leave the run
1562
+ # with attempted=0 even though the requested candidate exists.
1563
+ requested_defaults = {
1564
+ "self_consistency_5", "self_refine", "least_to_most",
1565
+ "chain_of_verification", "upscale_sharpen", "zoom_equalize",
1566
+ }
1567
+ if (
1568
+ self._candidate_allowlist is not None
1569
+ and len(self._candidate_allowlist) == 1
1570
+ and self.max_tier >= FixTier.L2_SCAFFOLD
1571
+ ):
1572
+ requested = next(iter(self._candidate_allowlist))
1573
+ if requested in requested_defaults and requested not in prior_names:
1574
+ requested_spec = self._default_spec(requested)
1575
+ if requested_spec is not None:
1576
+ candidates.append(
1577
+ FixCandidate(
1578
+ tier=FixTier.L2_SCAFFOLD,
1579
+ name=requested,
1580
+ kind="spec",
1581
+ source="pre_registered_default",
1582
+ payload=requested_spec.to_dict(),
1583
+ )
1584
+ )
1585
+ # A code-only run is a pre-registered autonomous-repair experiment.
1586
+ # Do not spend three judge calls inventing L0/L1/declarative/L3
1587
+ # candidates that the allowlist will discard afterwards; apart from
1588
+ # latency and quota waste, those calls can fail before the requested
1589
+ # coding agent is ever reached.
1590
+ code_only = self._candidate_allowlist == frozenset({"coded_pipeline"})
1591
+ catalog_method_names = method_names()
1592
+ preregistered_only = self._candidate_allowlist in {
1593
+ frozenset({malformed_name}), frozenset({chart_name}),
1594
+ frozenset({consensus_name}),
1595
+ frozenset({count_name}),
1596
+ *(frozenset({name}) for name in requested_defaults),
1597
+ }
1598
+ catalog_method_only = self._candidate_allowlist in {
1599
+ frozenset({name}) for name in catalog_method_names
1600
+ }
1601
+ skip_lower_tiers = preregistered_only or catalog_method_only
1602
+ if (
1603
+ not code_only
1604
+ and not skip_lower_tiers
1605
+ and self.max_tier >= FixTier.L0_RUNTIME_CONFIG
1606
+ and (self.min_tier is None or self.min_tier <= FixTier.L0_RUNTIME_CONFIG)
1607
+ ):
1608
+ candidates += self._runtime_candidates(data, prior_names)
1609
+ if (
1610
+ not code_only
1611
+ and not skip_lower_tiers
1612
+ and self.max_tier >= FixTier.L1_PROMPT
1613
+ and (self.min_tier is None or self.min_tier <= FixTier.L1_PROMPT)
1614
+ and not self._paper_methods_only
1615
+ ):
1616
+ candidates += self._l1_candidates(
1617
+ hyp_lines,
1618
+ examples,
1619
+ prior_text,
1620
+ prior_names,
1621
+ has_images=has_images,
1622
+ tasks=tasks,
1623
+ context_block=proposal_context,
1624
+ )
1625
+ if (
1626
+ not code_only
1627
+ and not skip_lower_tiers
1628
+ and self.max_tier >= FixTier.L2_SCAFFOLD
1629
+ and (self.min_tier is None or self.min_tier <= FixTier.L2_SCAFFOLD)
1630
+ ):
1631
+ candidates += self._l2_candidates(
1632
+ hyp_lines,
1633
+ examples,
1634
+ prior_text,
1635
+ prior_names,
1636
+ has_images=has_images,
1637
+ model=model,
1638
+ tasks=tasks,
1639
+ context_block=proposal_context,
1640
+ catalog=catalog,
1641
+ )
1642
+ # The floor: always-tested defaults, on top of the judge's ideas.
1643
+ present = {c.name for c in candidates}
1644
+ for name in floor_names:
1645
+ if name in present or name in prior_names:
1646
+ continue
1647
+ spec = self._default_spec(name)
1648
+ if spec is not None:
1649
+ candidates.append(
1650
+ FixCandidate(
1651
+ tier=FixTier.L2_SCAFFOLD, name=name, kind="spec",
1652
+ source="floor", payload=spec.to_dict(),
1653
+ )
1654
+ )
1655
+ if (not skip_lower_tiers and self.max_tier >= FixTier.L2_SCAFFOLD
1656
+ and (self.min_tier is None or self.min_tier <= FixTier.L2_SCAFFOLD)
1657
+ and self.codegen_available):
1658
+ # The coder-written pipeline is the ONE candidate a code-only run
1659
+ # exists to field, so it sits outside the ``not code_only`` gate
1660
+ # (nested inside it, --code-only proposed nothing at all).
1661
+ candidates += self._l2_coded_candidate(
1662
+ hyp_lines, examples, model, prior_text,
1663
+ context_block=context_block, catalog=catalog,
1664
+ text_only=not has_images,
1665
+ )
1666
+ if (
1667
+ not code_only
1668
+ and not preregistered_only
1669
+ and self.max_tier >= FixTier.L2_SCAFFOLD
1670
+ and (self.min_tier is None or self.min_tier <= FixTier.L3B_INTERNALS_WRITE)
1671
+ ):
1672
+ # The declarative catalog contains tool-assisted L2 as well as
1673
+ # internals-aware L3 repairs.
1674
+ # Discover it at the current ladder station; the catalog's max-tier
1675
+ # gate and the final min-tier filter keep each method in its tier.
1676
+ candidates += self._l3_candidates(
1677
+ hyp_lines,
1678
+ model,
1679
+ prior_text,
1680
+ prior_names,
1681
+ has_images=has_images,
1682
+ has_audio=has_audio,
1683
+ tasks=tasks,
1684
+ )
1685
+ if (
1686
+ not code_only
1687
+ and not skip_lower_tiers
1688
+ and self.max_tier >= FixTier.L4_PARAMETERS
1689
+ and (self.min_tier is None or self.min_tier <= FixTier.L4_PARAMETERS)
1690
+ ):
1691
+ candidates += self._l4_candidates(hyp_lines)
1692
+ if self.min_tier is not None:
1693
+ candidates = [c for c in candidates if c.tier >= self.min_tier]
1694
+ if self._candidate_allowlist is not None:
1695
+ candidates = [c for c in candidates if c.name in self._candidate_allowlist]
1696
+ self._enforce_generation_floor(candidates)
1697
+ return candidates
1698
+
1699
+ # -- proposer context ---------------------------------------------------
1700
+
1701
+ def _floor_names(self, *, has_images: bool, tasks: "set[str] | None") -> "tuple[str, ...]":
1702
+ """Which built-in defaults are ALWAYS in the family for this batch.
1703
+
1704
+ Text-only batches get the configured floor. Image batches keep their
1705
+ existing ladder (image transforms first; self_refine/self_consistency
1706
+ only for reasoning-shaped tasks — see ``_l2_candidates``), so the
1707
+ floor applies there only for those reasoning tasks.
1708
+ """
1709
+ if not self._floor_candidates:
1710
+ return ()
1711
+ reasoning_task = bool(
1712
+ tasks and tasks & {"multiple_choice", "exact_or_numeric", "vqa_consensus"}
1713
+ )
1714
+ if has_images and not reasoning_task:
1715
+ return ()
1716
+ return tuple(self._floor_candidates)
1717
+
1718
+ def _default_spec(self, name: str) -> "PipelineSpec | None":
1719
+ """Built-in default L2 specs by name (the floor and the fallback)."""
1720
+ if name == "self_consistency_5":
1721
+ # Vote at a stochastic temperature: at T=0 five samples are one
1722
+ # sample. Inherit the baseline temperature when it already samples.
1723
+ base_t = self._baseline_generation_kwargs.get("temperature")
1724
+ try:
1725
+ base_t = float(base_t) if base_t is not None else None
1726
+ except (TypeError, ValueError):
1727
+ base_t = None
1728
+ temperature = base_t if (base_t is not None and base_t > 0.0) else 0.7
1729
+ return PipelineSpec(
1730
+ name="self_consistency_5",
1731
+ prompt_template="{prompt}",
1732
+ n_samples=5,
1733
+ generation_kwargs={"temperature": temperature},
1734
+ )
1735
+ if name == "self_refine":
1736
+ return PipelineSpec(name="self_refine", prompt_template="{prompt}",
1737
+ strategy="self_refine")
1738
+ if name == "least_to_most":
1739
+ return PipelineSpec(name="least_to_most", prompt_template="{prompt}",
1740
+ strategy="least_to_most")
1741
+ if name == "chain_of_verification":
1742
+ return PipelineSpec(name="chain_of_verification", prompt_template="{prompt}",
1743
+ strategy="chain_of_verification")
1744
+ if name == "upscale_sharpen":
1745
+ return PipelineSpec(
1746
+ name="upscale_sharpen",
1747
+ image_ops=[
1748
+ {"tool": "upscale", "params": {"factor": 2.0}},
1749
+ {"tool": "sharpen", "params": {"factor": 2.0}},
1750
+ ],
1751
+ )
1752
+ if name == "zoom_equalize":
1753
+ return PipelineSpec(
1754
+ name="zoom_equalize",
1755
+ image_ops=[
1756
+ {"tool": "zoom_center", "params": {"factor": 1.6}},
1757
+ {"tool": "equalize", "params": {}},
1758
+ ],
1759
+ )
1760
+ return None
1761
+
1762
+ def _resolve_max_tokens_floor(self, model: "Model | None", data: "CaseBatch") -> "int | None":
1763
+ """The baseline decode budget: explicit > model attribute > case metadata."""
1764
+ raw = self._baseline_generation_kwargs.get("max_tokens")
1765
+ try:
1766
+ if raw is not None and int(raw) > 0:
1767
+ return int(raw)
1768
+ except (TypeError, ValueError):
1769
+ pass
1770
+ attr = getattr(model, "max_tokens", None)
1771
+ if isinstance(attr, int) and not isinstance(attr, bool) and attr > 0:
1772
+ return attr
1773
+ caps: "list[int]" = []
1774
+ for case in data:
1775
+ meta = getattr(case, "metadata", {}) or {}
1776
+ config = meta.get("generation_config") or {}
1777
+ try:
1778
+ cap = int(config.get("max_tokens"))
1779
+ except (AttributeError, TypeError, ValueError):
1780
+ continue
1781
+ if cap > 0:
1782
+ caps.append(cap)
1783
+ return max(caps) if caps else None
1784
+
1785
+ def _enforce_generation_floor(self, candidates: "list[FixCandidate]") -> None:
1786
+ """Raise any candidate ``max_tokens`` below the baseline budget to it.
1787
+
1788
+ A scaffold may give the model MORE decode room than the baseline had,
1789
+ never less: a lower cap truncates the chain of thought and the answer
1790
+ never appears, which the scorer reads as a wrong answer — a decoding
1791
+ artefact that says nothing about the candidate's idea. The judge's
1792
+ proposal is kept in ``payload["generation_kwargs_proposed"]`` for the
1793
+ record; the applied value is what ``PipelineSpec.from_dict`` (spec) or
1794
+ the template runner (template) reads.
1795
+ """
1796
+ floor = self._max_tokens_floor
1797
+ if not floor:
1798
+ return
1799
+ for candidate in candidates:
1800
+ payload = candidate.payload
1801
+ if not isinstance(payload, dict) or candidate.kind not in ("spec", "template"):
1802
+ continue
1803
+ gk = payload.get("generation_kwargs")
1804
+ if not isinstance(gk, dict):
1805
+ continue
1806
+ try:
1807
+ proposed = int(gk.get("max_tokens"))
1808
+ except (TypeError, ValueError):
1809
+ continue
1810
+ if proposed < floor:
1811
+ payload.setdefault("generation_kwargs_proposed", dict(gk))
1812
+ gk["max_tokens"] = int(floor)
1813
+ logger.info(
1814
+ "FixAgent: %s proposed max_tokens=%d below the baseline budget %d; "
1815
+ "raised to the floor (a candidate may not decode with less room "
1816
+ "than the baseline)", candidate.name, proposed, floor,
1817
+ )
1818
+
1819
+ def _context_block(
1820
+ self,
1821
+ context: FixContext,
1822
+ data: "CaseBatch",
1823
+ model: "Model | None",
1824
+ *,
1825
+ floor_names: "tuple[str, ...]" = (),
1826
+ ) -> str:
1827
+ """Render task / scoring / decoding / evidence / refuted notes for the proposer."""
1828
+ lines: "list[str]" = []
1829
+ if context.task_note:
1830
+ lines += ["TASK:", f" {context.task_note.strip()}"]
1831
+ scoring = context.scoring_note or self._scoring_note
1832
+ if scoring:
1833
+ lines += ["HOW OUTPUTS ARE SCORED (the final answer must be recoverable this way):",
1834
+ f" {scoring.strip()}"]
1835
+ decoding = dict(self._baseline_generation_kwargs)
1836
+ decoding.update(context.baseline_decoding or {})
1837
+ floor = self._max_tokens_floor
1838
+ outputs = [str(getattr(c, "observed", "") or "") for c in data]
1839
+ lengths = sorted(len(o) for o in outputs if o)
1840
+ median_chars = lengths[len(lengths) // 2] if lengths else None
1841
+ if decoding or floor or median_chars:
1842
+ parts = []
1843
+ if floor:
1844
+ parts.append(f"max_tokens={floor}")
1845
+ for key in ("temperature", "top_p"):
1846
+ if key in decoding:
1847
+ parts.append(f"{key}={decoding[key]}")
1848
+ note = "BASELINE DECODING: " + (", ".join(parts) if parts else "(unknown)")
1849
+ if median_chars:
1850
+ note += f"; median baseline output ≈ {median_chars} chars"
1851
+ lines.append(note)
1852
+ if floor:
1853
+ lines.append(
1854
+ " A candidate's max_tokens is a FLOOR-RAISED value: anything below "
1855
+ f"{floor} is raised to {floor} (a candidate may give the model more "
1856
+ "room, never less — the model needs its chain of thought), so do not "
1857
+ "try to save tokens; only raise max_tokens if the baseline truncates."
1858
+ )
1859
+ if context.evidence:
1860
+ lines += ["DIAGNOSTIC EVIDENCE (what M2 statistics / M4 tests / exploration "
1861
+ "established — read-only, steer WHAT to try):", context.evidence.rstrip()]
1862
+ if context.refuted:
1863
+ lines += ["REFUTED BY AN INTERVENTION EXPERIMENT (M5) — do NOT build a fix on these:"]
1864
+ lines += [f" - {r}" for r in context.refuted]
1865
+ if floor_names:
1866
+ lines.append(
1867
+ "ALREADY IN THE TEST FAMILY (validated alongside your proposals — do not "
1868
+ "re-propose plain versions of these): " + ", ".join(
1869
+ f"{n} ({_FLOOR_DESCRIPTIONS.get(n, 'built-in default')})" for n in floor_names
1870
+ )
1871
+ )
1872
+ if not lines:
1873
+ return ""
1874
+ return "\n" + "\n".join(lines) + "\n"
1875
+
1876
+ @staticmethod
1877
+ def _deployed_template(data: "CaseBatch") -> "str | None":
1878
+ """The template a previous repair round already deployed, if coherent.
1879
+
1880
+ Recursive-round manifests record the winning template in every row's
1881
+ ``metadata.recursive_stack[-1]["template"]`` alongside the pristine
1882
+ ``metadata.original_prompt`` (the round-0 text). Returns the template
1883
+ only when EVERY case carries the same one plus its original prompt —
1884
+ anything less means there is no single deployed template to edit.
1885
+ """
1886
+ tpl: "str | None" = None
1887
+ for case in data:
1888
+ md = getattr(case, "metadata", {}) or {}
1889
+ stack = md.get("recursive_stack")
1890
+ entry = stack[-1] if isinstance(stack, list) and stack else None
1891
+ text = entry.get("template") if isinstance(entry, dict) else None
1892
+ if not text or not md.get("original_prompt"):
1893
+ return None
1894
+ if tpl is None:
1895
+ tpl = str(text)
1896
+ elif str(text) != tpl:
1897
+ return None
1898
+ return tpl
1899
+
1900
+ def _edit_note(self, data: "CaseBatch") -> str:
1901
+ """Proposer context inviting EDIT candidates on recursive rounds.
1902
+
1903
+ Only rendered when the batch carries a coherent deployed template (see
1904
+ :meth:`_deployed_template`); default runs get "" and are unchanged.
1905
+ An edit candidate is an ordinary template/spec proposal whose template
1906
+ is written against ``{original_prompt}`` — the L1 closure and the spec
1907
+ runner both fill that placeholder from case metadata, so it REPLACES
1908
+ the deployed template instead of wrapping it.
1909
+
1910
+ A SPEC deploy (``deployed_spec`` + ``candidate_model``: the baseline
1911
+ handle runs the previous winner's whole pipeline) is different: rows
1912
+ stay pristine, so templates use ``{prompt}`` as usual, and EVERY
1913
+ candidate is validated as a full pipeline paired against the deployed
1914
+ one — the note shows the incumbent spec and asks for revisions of it.
1915
+ """
1916
+ if self._deployed_spec is not None:
1917
+ spec_json = json.dumps(self._deployed_spec, indent=2, default=str)
1918
+ return (
1919
+ "\nDEPLOYED PIPELINE (the baseline your candidates are paired "
1920
+ "against is NOT the raw model: it is the already-deployed pipeline "
1921
+ "below — a previous repair round's validated winner. Every case's "
1922
+ "recorded baseline output came from running it):\n"
1923
+ "<<<\n" + spec_json + "\n>>>\n"
1924
+ "Your candidates run as full pipelines that REPLACE this one, so "
1925
+ "beating the baseline means beating this pipeline, not the plain "
1926
+ "model. Prefer minimal revisions of it — keep what its validation "
1927
+ "proved, change the ONE part the failure evidence indicts (its "
1928
+ "template wording, its sampling, its scaffold) — over unrelated "
1929
+ "fresh ideas that discard its confirmed gains.\n"
1930
+ )
1931
+ deployed = self._deployed_template(data)
1932
+ if not deployed:
1933
+ return ""
1934
+ return (
1935
+ "\nDEPLOYED TEMPLATE (a previous repair round already rewrote every case "
1936
+ "prompt: each case's {prompt} value IS the rendered output of the template "
1937
+ "below, and the pristine pre-rewrite text is available as the placeholder "
1938
+ "{original_prompt}):\n"
1939
+ "<<<\n" + deployed.rstrip() + "\n>>>\n"
1940
+ "In addition to your other strategies, propose 1-2 EDIT candidates: a "
1941
+ "minimal revision of the deployed template — change, tighten, or remove "
1942
+ "the ONE clause the failure evidence indicts, and keep what already "
1943
+ "works. Write an edit against {original_prompt} (and do NOT also "
1944
+ "include {prompt}): it REPLACES the deployed template instead of "
1945
+ "wrapping it, so the model sees one coherent instruction rather than "
1946
+ "an override fighting the text above it.\n"
1947
+ )
1948
+
1949
+ def _runtime_candidates(
1950
+ self,
1951
+ data: "CaseBatch",
1952
+ prior_names: "frozenset[str]" = frozenset(),
1953
+ ) -> "list[FixCandidate]":
1954
+ """Propose a decode-budget repair only from recorded telemetry."""
1955
+ caps: "list[int]" = []
1956
+ policy_caps: "list[int]" = []
1957
+ for case in data:
1958
+ if getattr(getattr(case, "label", None), "value", None) != "fail":
1959
+ continue
1960
+ meta = getattr(case, "metadata", {}) or {}
1961
+ if str(meta.get("finish_reason", "")).lower() != "length":
1962
+ continue
1963
+ config = meta.get("generation_config") or {}
1964
+ try:
1965
+ cap = int(config.get("max_tokens"))
1966
+ except (AttributeError, TypeError, ValueError):
1967
+ continue
1968
+ if 1 <= cap < 8192:
1969
+ caps.append(cap)
1970
+ policy = meta.get("generation_policy") or {}
1971
+ try:
1972
+ policy_cap = int(policy.get("max_tokens_cap"))
1973
+ except (AttributeError, TypeError, ValueError):
1974
+ continue
1975
+ if cap < policy_cap <= 8192:
1976
+ policy_caps.append(policy_cap)
1977
+
1978
+ out: "list[FixCandidate]" = []
1979
+ if caps and "increase_max_tokens" not in prior_names:
1980
+ old_cap = max(caps)
1981
+ new_cap = max(policy_caps) if policy_caps else min(8192, old_cap * 2)
1982
+ if new_cap > old_cap:
1983
+ spec = PipelineSpec(
1984
+ name="increase_max_tokens",
1985
+ generation_kwargs={"max_tokens": new_cap},
1986
+ )
1987
+ out.append(
1988
+ FixCandidate(
1989
+ tier=FixTier.L0_RUNTIME_CONFIG,
1990
+ name=spec.name,
1991
+ kind="spec",
1992
+ source="telemetry",
1993
+ payload=spec.to_dict(),
1994
+ )
1995
+ )
1996
+ return out
1997
+
1998
+ @staticmethod
1999
+ def _signature(candidate: FixCandidate) -> "tuple[str, str, str]":
2000
+ """Identity of a candidate, to skip re-validating an identical one.
2001
+
2002
+ Coded pipelines carry fresh source each round, so they never collide;
2003
+ templates / specs / primitives dedup on their defining payload. The
2004
+ candidate's ``name`` is always part of the signature, so separately
2005
+ registered experiments never collapse merely because their defaults
2006
+ happen to match.
2007
+ """
2008
+ p = candidate.payload
2009
+ if candidate.kind == "template":
2010
+ key = str(p.get("prompt_template", ""))
2011
+ elif candidate.kind == "primitive":
2012
+ key = json.dumps(
2013
+ {"primitive": p.get("primitive"), "params": p.get("params")},
2014
+ sort_keys=True,
2015
+ default=str,
2016
+ )
2017
+ elif candidate.kind == "code":
2018
+ key = str(p.get("code", ""))
2019
+ else:
2020
+ key = json.dumps(
2021
+ {k: v for k, v in p.items() if k != "exec_error"}, sort_keys=True, default=str
2022
+ )
2023
+ return (candidate.kind, candidate.name, key)
2024
+
2025
+ def _l1_candidates(
2026
+ self,
2027
+ hyp_lines: str,
2028
+ examples: str,
2029
+ prior_text: str = "",
2030
+ prior_names: "frozenset[str]" = frozenset(),
2031
+ *,
2032
+ has_images: bool = False,
2033
+ tasks: "set[str] | None" = None,
2034
+ context_block: str = "",
2035
+ ) -> "list[FixCandidate]":
2036
+ proposals = self._ask_judge(
2037
+ _L1_PROMPT.format(
2038
+ hypotheses=hyp_lines, examples=examples, k=self.max_judge_candidates,
2039
+ context=context_block,
2040
+ )
2041
+ + prior_text
2042
+ )
2043
+ out: "list[FixCandidate]" = []
2044
+ # Some permissive judges return an L2 pipeline for the L1 request
2045
+ # because both prompts include the same examples. Do not silently turn
2046
+ # that into an identity L1 candidate (and an unnecessary e-BH test).
2047
+ structural_keys = {"image_ops", "n_samples", "strategy"}
2048
+ has_structural_proposal = False
2049
+ for p in proposals:
2050
+ template = str(p.get("prompt_template", ""))
2051
+ name = str(p.get("name", "")).strip()
2052
+ if structural_keys.intersection(p):
2053
+ has_structural_proposal = True
2054
+ continue
2055
+ # An EDIT candidate (recursive rounds, see _edit_note) replaces the
2056
+ # deployed template by rendering against {original_prompt} instead
2057
+ # of wrapping the already-rewritten {prompt}.
2058
+ if name and ("{prompt}" in template or "{original_prompt}" in template):
2059
+ payload: "dict[str, Any]" = {"prompt_template": template}
2060
+ # L1 stays prompt-only except for decode ROOM: a template that
2061
+ # asks for intermediate work must be able to finish (at a 64-
2062
+ # token vlm budget every such rewrite truncated and scored as a
2063
+ # regression). max_tokens is floor-enforced later; sampler
2064
+ # controls (temperature/top_p) remain L2-only and are dropped.
2065
+ max_tokens = _safe_generation_kwargs(p.get("generation_kwargs")).get("max_tokens")
2066
+ if max_tokens:
2067
+ payload["generation_kwargs"] = {"max_tokens": max_tokens}
2068
+ out.append(
2069
+ FixCandidate(
2070
+ tier=FixTier.L1_PROMPT,
2071
+ name=name,
2072
+ kind="template",
2073
+ description=_judge_description(p),
2074
+ payload=payload,
2075
+ )
2076
+ )
2077
+ # Image tasks always receive one conservative, declarative grounding
2078
+ # control. A judge can overfit a diagnosis split with a narrow prompt;
2079
+ # this candidate establishes whether simply forcing visual evidence
2080
+ # before world knowledge helps, and it is selected/validated by the
2081
+ # same held-out procedure as every other repair.
2082
+ if has_images and not has_structural_proposal and "visual_grounding" not in prior_names:
2083
+ out.insert(
2084
+ 0,
2085
+ FixCandidate(
2086
+ tier=FixTier.L1_PROMPT,
2087
+ name="visual_grounding",
2088
+ kind="template",
2089
+ source="default",
2090
+ payload={
2091
+ "prompt_template": (
2092
+ "Inspect the image carefully for {failure_axis}. Work from visible "
2093
+ "evidence, then give only the final answer requested.\n\n{prompt}"
2094
+ )
2095
+ },
2096
+ ),
2097
+ )
2098
+ if not out and not has_structural_proposal and "attend_carefully" not in prior_names:
2099
+ # The judge gave nothing usable: one conservative default. Worded
2100
+ # for the modality — a text-only batch must not be told to
2101
+ # "examine the image".
2102
+ template = (
2103
+ "Examine the image carefully, including small, subtle and "
2104
+ "low-contrast regions, before answering. {prompt}"
2105
+ if has_images else
2106
+ "Work through this carefully step by step, re-read the question "
2107
+ "before committing, and double-check the final answer against the "
2108
+ "question's own wording before answering. {prompt}"
2109
+ )
2110
+ out = [
2111
+ FixCandidate(
2112
+ tier=FixTier.L1_PROMPT,
2113
+ name="attend_carefully",
2114
+ kind="template",
2115
+ source="default",
2116
+ payload={"prompt_template": template},
2117
+ )
2118
+ ]
2119
+ return out[: self.max_judge_candidates]
2120
+
2121
+ def _l2_candidates(
2122
+ self,
2123
+ hyp_lines: str,
2124
+ examples: str,
2125
+ prior_text: str = "",
2126
+ prior_names: "frozenset[str]" = frozenset(),
2127
+ *,
2128
+ has_images: bool = False,
2129
+ model: "Model | None" = None,
2130
+ tasks: "set[str] | None" = None,
2131
+ context_block: str = "",
2132
+ catalog: "str | None" = None,
2133
+ ) -> "list[FixCandidate]":
2134
+ if catalog is None:
2135
+ catalog = catalog_text() if has_images else _TEXT_ONLY_CATALOG_NOTE
2136
+ proposals = (
2137
+ []
2138
+ if self._paper_methods_only
2139
+ else self._ask_judge(
2140
+ _L2_PROMPT.format(
2141
+ hypotheses=hyp_lines,
2142
+ examples=examples,
2143
+ k=self.max_judge_candidates,
2144
+ catalog=catalog,
2145
+ context=context_block,
2146
+ )
2147
+ + prior_text
2148
+ )
2149
+ )
2150
+ out: "list[FixCandidate]" = []
2151
+ for p in proposals:
2152
+ spec = PipelineSpec.from_dict(p) if isinstance(p, dict) else None
2153
+ if spec is not None:
2154
+ out.append(
2155
+ FixCandidate(
2156
+ tier=FixTier.L2_SCAFFOLD, name=spec.name,
2157
+ description=_judge_description(p), payload=spec.to_dict(),
2158
+ )
2159
+ )
2160
+ if not out:
2161
+ image_defaults = [
2162
+ FixCandidate(
2163
+ tier=FixTier.L2_SCAFFOLD,
2164
+ name="answer_bbox_crop",
2165
+ source="default",
2166
+ payload=PipelineSpec(
2167
+ name="answer_bbox_crop",
2168
+ image_ops=[
2169
+ {
2170
+ "tool": "crop_case_bbox",
2171
+ "params": {
2172
+ "bbox_key": "answer_bbox_xyxy_norm",
2173
+ "padding": 0.40,
2174
+ "min_size_frac": 0.12,
2175
+ "sharpen_factor": 3.0,
2176
+ "contrast_factor": 1.4,
2177
+ },
2178
+ },
2179
+ ],
2180
+ prompt_template=(
2181
+ "The image may have been cropped and enhanced around "
2182
+ "the visual region that contains the answer. Read the "
2183
+ "visible text or number carefully, then answer the "
2184
+ "question. {prompt}"
2185
+ ),
2186
+ ).to_dict(),
2187
+ ),
2188
+ FixCandidate(
2189
+ tier=FixTier.L2_SCAFFOLD,
2190
+ name="annotate_horizontal_band_count",
2191
+ source="default",
2192
+ payload=PipelineSpec(
2193
+ name="annotate_horizontal_band_count",
2194
+ image_ops=[
2195
+ {
2196
+ "tool": "annotate_horizontal_band_count",
2197
+ "params": {
2198
+ "min_delta": 18.0,
2199
+ "color_delta": 35.0,
2200
+ "min_count": 8,
2201
+ },
2202
+ },
2203
+ ],
2204
+ prompt_template=(
2205
+ "A visual counting overlay may have been added to the "
2206
+ "image. If a COUNT value is visible, use that value. "
2207
+ "{prompt}"
2208
+ ),
2209
+ ).to_dict(),
2210
+ ),
2211
+ FixCandidate(
2212
+ tier=FixTier.L2_SCAFFOLD,
2213
+ name="separate_horizontal_bands",
2214
+ source="default",
2215
+ payload=PipelineSpec(
2216
+ name="separate_horizontal_bands",
2217
+ image_ops=[
2218
+ {
2219
+ "tool": "separate_horizontal_bands",
2220
+ "params": {"min_delta": 18.0, "color_delta": 35.0},
2221
+ },
2222
+ ],
2223
+ prompt_template=(
2224
+ "The image has been preprocessed so adjacent colored "
2225
+ "horizontal bands, if present, are separated by gray "
2226
+ "gaps. Count every colored band. {prompt}"
2227
+ ),
2228
+ ).to_dict(),
2229
+ ),
2230
+ FixCandidate(
2231
+ tier=FixTier.L2_SCAFFOLD,
2232
+ name="salient_crop",
2233
+ source="default",
2234
+ payload=PipelineSpec(
2235
+ name="salient_crop",
2236
+ image_ops=[
2237
+ {
2238
+ "tool": "crop_salient_region",
2239
+ "params": {"padding": 0.04, "min_delta": 18.0},
2240
+ },
2241
+ ],
2242
+ ).to_dict(),
2243
+ ),
2244
+ FixCandidate(
2245
+ tier=FixTier.L2_SCAFFOLD,
2246
+ name="zoom_equalize",
2247
+ source="default",
2248
+ payload=PipelineSpec(
2249
+ name="zoom_equalize",
2250
+ image_ops=[
2251
+ {"tool": "zoom_center", "params": {"factor": 1.6}},
2252
+ {"tool": "equalize", "params": {}},
2253
+ ],
2254
+ ).to_dict(),
2255
+ ),
2256
+ FixCandidate(
2257
+ tier=FixTier.L2_SCAFFOLD,
2258
+ name="upscale_sharpen",
2259
+ source="default",
2260
+ payload=PipelineSpec(
2261
+ name="upscale_sharpen",
2262
+ image_ops=[
2263
+ {"tool": "upscale", "params": {"factor": 2.0}},
2264
+ {"tool": "sharpen", "params": {"factor": 2.0}},
2265
+ ],
2266
+ ).to_dict(),
2267
+ ),
2268
+ ]
2269
+ text_defaults = [
2270
+ FixCandidate(
2271
+ tier=FixTier.L2_SCAFFOLD,
2272
+ name="self_refine",
2273
+ source="default",
2274
+ payload=PipelineSpec(
2275
+ name="self_refine",
2276
+ prompt_template="{prompt}",
2277
+ strategy="self_refine",
2278
+ ).to_dict(),
2279
+ ),
2280
+ FixCandidate(
2281
+ tier=FixTier.L2_SCAFFOLD,
2282
+ name="least_to_most",
2283
+ source="default",
2284
+ payload=PipelineSpec(
2285
+ name="least_to_most",
2286
+ prompt_template="{prompt}",
2287
+ strategy="least_to_most",
2288
+ ).to_dict(),
2289
+ ),
2290
+ ]
2291
+ # ``crop_case_bbox`` is only applicable when an upstream evaluator
2292
+ # supplies a bbox. For ordinary image cases, start with transforms
2293
+ # that actually touch the image instead of burning a candidate on a
2294
+ # structural no-op.
2295
+ defaults = image_defaults if has_images else text_defaults
2296
+ # self_refine/least_to_most were only ever offered for text-only
2297
+ # cases, even though run_pipeline already threads the case image
2298
+ # through every call of a multi-call strategy (fix_tools.py) --
2299
+ # nothing about them is text-specific. On a binary/grounding task
2300
+ # (yes_no) the image transforms above are the right first lever;
2301
+ # on a genuine multi-step reasoning task (multiple_choice,
2302
+ # exact_or_numeric -- MMMU, ChartQA) a purely visual transform
2303
+ # cannot fix a reasoning error the model makes after it has
2304
+ # already seen the image correctly, so offer self_refine there
2305
+ # too, prioritised ahead of the sharpen/crop family for that task
2306
+ # shape.
2307
+ # Positive allowlist, not "anything that isn't yes_no": unknown or
2308
+ # missing task metadata (common in unit tests and some non-VLM
2309
+ # integrations) must not silently opt into this branch.
2310
+ reasoning_task = bool(
2311
+ tasks and tasks & {"multiple_choice", "exact_or_numeric", "vqa_consensus"}
2312
+ )
2313
+ if has_images and reasoning_task:
2314
+ # self_refine (deterministic, single path, critique-then-revise)
2315
+ # and self_consistency (stochastic, multiple independent paths,
2316
+ # majority vote) target different failure shapes: self_refine
2317
+ # helps when the model's first pass missed something a second
2318
+ # look would catch; self_consistency helps when greedy decoding
2319
+ # is stuck on one answer but the model's distribution actually
2320
+ # has support elsewhere. Majority-vote aggregation already
2321
+ # exists in run_pipeline (fix_tools.py, n_samples > 1) but had
2322
+ # never been registered as a default candidate for any task.
2323
+ reasoning_defaults = []
2324
+ if "self_refine" not in prior_names:
2325
+ reasoning_defaults.append(
2326
+ FixCandidate(
2327
+ tier=FixTier.L2_SCAFFOLD,
2328
+ name="self_refine",
2329
+ source="default",
2330
+ payload=PipelineSpec(
2331
+ name="self_refine",
2332
+ prompt_template="{prompt}",
2333
+ strategy="self_refine",
2334
+ ).to_dict(),
2335
+ )
2336
+ )
2337
+ if "self_consistency_5" not in prior_names:
2338
+ reasoning_defaults.append(
2339
+ FixCandidate(
2340
+ tier=FixTier.L2_SCAFFOLD,
2341
+ name="self_consistency_5",
2342
+ source="default",
2343
+ payload=PipelineSpec(
2344
+ name="self_consistency_5",
2345
+ prompt_template="{prompt}",
2346
+ n_samples=5,
2347
+ generation_kwargs={"do_sample": True, "temperature": 0.7},
2348
+ ).to_dict(),
2349
+ )
2350
+ )
2351
+ defaults = reasoning_defaults + defaults
2352
+ if has_images:
2353
+ if (
2354
+ model is not None
2355
+ and callable(getattr(model, "generate_detector_visual_search", None))
2356
+ and "detector_visual_search_consensus" not in prior_names
2357
+ ):
2358
+ defaults.append(
2359
+ FixCandidate(
2360
+ tier=FixTier.L2_SCAFFOLD,
2361
+ name="detector_visual_search_consensus",
2362
+ kind="detector_visual_search",
2363
+ source="paper_inspired_black_box",
2364
+ payload={"decision": "unanimous_crop_override"},
2365
+ )
2366
+ )
2367
+ # V* (Wu et al.) establishes that question-guided visual
2368
+ # search can repair failures caused by a missed local detail.
2369
+ # An opt-in backend performs locate -> crop -> re-ask without
2370
+ # access to labels. It is deliberately distinct from the
2371
+ # trained SEAL reproduction: endpoint users get a testable
2372
+ # method-family control, never a false equivalence claim.
2373
+ if (
2374
+ model is not None
2375
+ and callable(getattr(model, "generate_visual_search", None))
2376
+ and "guided_visual_search_consensus" not in prior_names
2377
+ ):
2378
+ defaults.append(
2379
+ FixCandidate(
2380
+ tier=FixTier.L2_SCAFFOLD,
2381
+ name="guided_visual_search_consensus",
2382
+ kind="visual_search",
2383
+ source="paper_inspired_black_box",
2384
+ # SEAL is a search/query mechanism, not a single
2385
+ # arbitrary crop. Preserve the full scene and give the
2386
+ # answer pass three increasingly contextual views of
2387
+ # the controller-localised target.
2388
+ payload={
2389
+ "min_side": 0.10,
2390
+ "max_side": 0.50,
2391
+ "scales": [0.10, 0.25, 0.50],
2392
+ "decision": "unanimous_crop_override",
2393
+ },
2394
+ )
2395
+ )
2396
+ preferred = [
2397
+ "self_refine",
2398
+ "self_consistency_5",
2399
+ "detector_visual_search_consensus",
2400
+ "guided_visual_search_consensus",
2401
+ "salient_crop",
2402
+ "upscale_sharpen",
2403
+ "zoom_equalize",
2404
+ "answer_bbox_crop",
2405
+ "annotate_horizontal_band_count",
2406
+ "separate_horizontal_bands",
2407
+ ]
2408
+ defaults.sort(key=lambda candidate: preferred.index(candidate.name))
2409
+ out = [c for c in defaults if c.name not in prior_names]
2410
+ if self._paper_methods_only:
2411
+ out = [candidate for candidate in out if candidate.source.startswith("paper_")]
2412
+ return out[: self.max_judge_candidates]
2413
+
2414
+ def _l2_coded_candidate(
2415
+ self,
2416
+ hyp_lines: str,
2417
+ examples: str,
2418
+ model: "Model",
2419
+ prior_text: str = "",
2420
+ *,
2421
+ context_block: str = "",
2422
+ catalog: "str | None" = None,
2423
+ text_only: bool = False,
2424
+ ) -> "list[FixCandidate]":
2425
+ """The coding agent writes a brand-new pipeline (CLI first, judge fallback).
2426
+
2427
+ Allocates its trial up front (not after dedup, unlike declarative
2428
+ candidates) because the CLI agent needs a workdir *now* to write
2429
+ ``pipeline.py`` into — coded candidates dedup on code text, which is
2430
+ ~never identical run-to-run, so the orphan-folder risk this would
2431
+ otherwise create is negligible.
2432
+ """
2433
+ from evalrx.core.capability import Capability
2434
+ from evalrx.eval_agent.stages.fix_pipeline import (
2435
+ CASES_FILENAME,
2436
+ RESULT_MARKER,
2437
+ )
2438
+
2439
+ trial = (
2440
+ self._run_context.new_trial("fixes", "coded_pipeline")
2441
+ if self._run_context is not None
2442
+ else None
2443
+ )
2444
+ attend_available = (
2445
+ self.max_tier >= FixTier.L3A_INTERNALS_READ
2446
+ and Capability.ATTENTION in getattr(model, "capabilities", frozenset())
2447
+ )
2448
+ attend_hint = (
2449
+ (
2450
+ "\n- A function model_attend(case_id, prompt=None) -> "
2451
+ '{"grid": [[float,...],...], "shape": [H, W]} is ALSO defined: '
2452
+ "the model's attention heatmap over image patches (read-only "
2453
+ "internals). Use it e.g. to find where the model looks, then "
2454
+ "crop_region there and re-ask. This forward pass counts as ONE "
2455
+ "of the per-case model-call budget below."
2456
+ )
2457
+ if attend_available
2458
+ else ""
2459
+ )
2460
+ code, source, prompt, raw = "", "", "", ""
2461
+ if catalog is None:
2462
+ catalog = _TEXT_ONLY_CATALOG_NOTE if text_only else catalog_text()
2463
+ selection_guidance = self._code_selection_guidance(prior_text)
2464
+ # Explore rounds learn from a 2-of-3 consensus; a one-shot candidate
2465
+ # must have all three enhanced passes agree. The prompt states the
2466
+ # same number the host bridge enforces.
2467
+ min_support = 2 if self.max_repair_rounds > 1 else 3
2468
+ base = dict(
2469
+ hypotheses=hyp_lines,
2470
+ examples=examples,
2471
+ catalog=catalog,
2472
+ cases_file=CASES_FILENAME,
2473
+ marker=RESULT_MARKER,
2474
+ attend_hint=attend_hint,
2475
+ context=context_block,
2476
+ selection_guidance=selection_guidance,
2477
+ min_support=min_support,
2478
+ )
2479
+ if self._prewritten_code.strip():
2480
+ code = self._prewritten_code
2481
+ source = "prewritten"
2482
+ prompt = "Frozen prewritten coded pipeline supplied by the caller."
2483
+ elif self._cli_config is not None and self._cli_config.provider != "llm":
2484
+ prompt = (
2485
+ _L2_CODE_PROMPT.format(fences_hint=", written to a file named pipeline.py", **base)
2486
+ + prior_text
2487
+ )
2488
+ code, raw = self._write_code_cli(prompt, trial)
2489
+ source = f"cli:{self._cli_config.provider}"
2490
+ if _code_copies_example(code, examples) or _code_redefines_model_bridge(code):
2491
+ logger.warning("FixAgent: generated code violated anti-memorization/bridge rules; dropped")
2492
+ code = ""
2493
+ if not code.strip() and self._judge is not None:
2494
+ self._last_raw_stream = ""
2495
+ prompt = (
2496
+ _L2_CODE_PROMPT.format(fences_hint=" inside a ```python code block", **base)
2497
+ + prior_text
2498
+ )
2499
+ try:
2500
+ raw = str(self._judge.generate(prompt))
2501
+ except Exception as exc:
2502
+ logger.warning("FixAgent: code-writing judge call failed: %s", exc)
2503
+ raw = ""
2504
+ code = _extract_code(raw)
2505
+ # Syntax gate: a judge that answered in prose must not become a
2506
+ # "coded pipeline" candidate (the CLI path returns real files).
2507
+ if code.strip():
2508
+ import ast
2509
+
2510
+ try:
2511
+ ast.parse(code)
2512
+ except SyntaxError:
2513
+ logger.warning("FixAgent: judge code failed to parse; dropped")
2514
+ code = ""
2515
+ source = "judge"
2516
+ if _code_copies_example(code, examples) or _code_redefines_model_bridge(code):
2517
+ logger.warning("FixAgent: generated code violated anti-memorization/bridge rules; dropped")
2518
+ code = ""
2519
+ self._emit_codegen(
2520
+ "coded_pipeline", prompt, source, code, raw, ok=bool(code.strip()), trial=trial
2521
+ )
2522
+ if not code.strip():
2523
+ return []
2524
+ # Classify by the intervention the generated program actually uses,
2525
+ # not by an optional bridge merely being advertised in its prompt.
2526
+ # This prevents black-box multi-call/image-tool scaffolds from being
2527
+ # reported as L3a just because the model happens to expose attention.
2528
+ enable_attend = attend_available and _code_calls_name(code, "model_attend")
2529
+ tier = FixTier.L3A_INTERNALS_READ if enable_attend else FixTier.L2_SCAFFOLD
2530
+ return [
2531
+ FixCandidate(
2532
+ tier=tier,
2533
+ name="coded_pipeline",
2534
+ kind="code",
2535
+ description=_code_description(code),
2536
+ payload={
2537
+ "code": code,
2538
+ "enable_attend": enable_attend,
2539
+ "text_only": bool(text_only),
2540
+ # Prompt instructions are advisory; the host bridge also
2541
+ # enforces the selection rule without seeing gold labels
2542
+ # (anchored on each case's recorded baseline_output).
2543
+ "consensus_min_support": min_support,
2544
+ "max_calls_per_case": 4,
2545
+ },
2546
+ source=source,
2547
+ trial=trial,
2548
+ )
2549
+ ]
2550
+
2551
+ def _code_selection_guidance(self, prior_text: str = "") -> str:
2552
+ """Tell codegen whether this is discovery, revision, or one-shot use."""
2553
+ if prior_text:
2554
+ return (
2555
+ "- This is a FEEDBACK-DRIVEN EXPLORE revision. Use the prior "
2556
+ "aggregate fixed/broken counts and implementation below as "
2557
+ "training feedback. Raw case IDs, prompts, and answers are "
2558
+ "deliberately withheld: revise the general mechanism, never "
2559
+ "match a benchmark item or phrase. Keep baseline_output outside "
2560
+ "a label-free, generally applicable gate. If the prior attempt "
2561
+ "repaired zero cases, abandon its override mechanism instead of "
2562
+ "merely retuning it."
2563
+ )
2564
+ if self.max_repair_rounds > 1:
2565
+ return (
2566
+ "- This is EXPLORE round 1, used to learn which task subtypes "
2567
+ "benefit before a later candidate is frozen. Treat "
2568
+ "baseline_output as the baseline answer and keep it on ties, "
2569
+ "but you may use a controlled 2-of-3 alternative consensus "
2570
+ "(two DISTINCT enhanced calls agreeing on the same different "
2571
+ "answer) so the paired helped/hurt feedback is informative. "
2572
+ "Never hard-code answers or compute the benchmark task "
2573
+ "outside the model."
2574
+ )
2575
+ return (
2576
+ "- Treat baseline_output (the ORIGINAL direct answer) as the "
2577
+ "safety baseline. Keep it unless all 3 independent enhanced/"
2578
+ "reasoned passes agree on a different answer and none supports "
2579
+ "the baseline. Do not use unconditional majority replacement."
2580
+ )
2581
+
2582
+ def _write_code_cli(self, prompt: str, trial: "Trial | None" = None) -> "tuple[str, str]":
2583
+ from pathlib import Path
2584
+
2585
+ from evalrx.agent_runtime.codegen import CodegenRunner
2586
+
2587
+ workdir = Path(self._workdir(trial))
2588
+ result = CodegenRunner(self._cli_config).write_code( # type: ignore[arg-type]
2589
+ prompt,
2590
+ workdir=workdir,
2591
+ timeout_sec=self._cli_config.timeout_sec, # type: ignore[union-attr]
2592
+ preferred_filenames=("pipeline.py",),
2593
+ )
2594
+ self._last_usage = result.usage
2595
+ self._last_raw_stream = ""
2596
+ if result.raw_stream_path:
2597
+ try:
2598
+ self._last_raw_stream = (workdir / result.raw_stream_path).read_text(encoding="utf-8")
2599
+ except OSError:
2600
+ pass
2601
+ return result.code, result.raw_output
2602
+
2603
+ def _workdir(self, trial: "Trial | None" = None) -> str:
2604
+ """Sandbox workdir for a coded run.
2605
+
2606
+ With a *trial* (a ``RunContext`` is in play), each candidate gets its
2607
+ own durable ``<trial>/workspace/`` — no cross-attempt overwriting.
2608
+ Without one (legacy / no ``RunContext``), falls back to a single
2609
+ shared sandbox for the agent's lifetime, exactly as before.
2610
+ """
2611
+ if trial is not None:
2612
+ return str(trial.workspace)
2613
+ if self._sandbox is None:
2614
+ from evalrx.agent_runtime.sandbox import ExperimentSandbox
2615
+
2616
+ workdir = (
2617
+ self._run_context.new_workdir("fix") if self._run_context is not None else None
2618
+ )
2619
+ self._sandbox = ExperimentSandbox(workdir=workdir)
2620
+ return str(self._sandbox.workdir)
2621
+
2622
+ @staticmethod
2623
+ def _format_prior(
2624
+ attempts: "list[FixValidation]", data: "CaseBatch | None" = None
2625
+ ) -> str:
2626
+ """Format failed prior attempts as a context block for judge prompts.
2627
+
2628
+ Only aggregate outcomes and the attempted implementation are exposed.
2629
+ Raw IDs, prompts, and answers would let a later round memorize EXPLORE
2630
+ cases, so they never enter repair feedback.
2631
+ """
2632
+ del data
2633
+ items = []
2634
+ implementations = []
2635
+ heterogeneous = []
2636
+ for v in attempts:
2637
+ c = v.candidate
2638
+ if c.kind == "finetune_spec":
2639
+ continue
2640
+ effect = f"effect={v.effect:+.2f}" if v.effect is not None else "did not execute"
2641
+ trunc = (
2642
+ f"; {v.n_truncated} model call(s) hit the decode cap — its breaks are "
2643
+ "truncation, not the idea: give the model MORE room, never less"
2644
+ if v.n_truncated else ""
2645
+ )
2646
+ items.append(
2647
+ f"- [{c.tier.label}/{c.kind}] {c.name}: "
2648
+ f"{v.n_fixed} fixed / {v.n_broken} broken ({effect}{trunc})"
2649
+ )
2650
+ code = c.payload.get("code") if c.kind == "code" else None
2651
+ if isinstance(code, str) and code.strip():
2652
+ implementations.append(
2653
+ f"PREVIOUS IMPLEMENTATION ({c.name}):\n{code[:3000]}"
2654
+ )
2655
+ if v.n_fixed > 0 and v.n_broken > 0:
2656
+ heterogeneous.append(
2657
+ f" '{c.name}' helped {v.n_fixed} and hurt {v.n_broken} cases. "
2658
+ "The effect is heterogeneous, but case-level content is withheld; "
2659
+ "replace the mechanism or derive a label-free gate from runtime "
2660
+ "signals available on every future case."
2661
+ )
2662
+ if not items:
2663
+ return ""
2664
+ block = (
2665
+ "\n\nPRIOR ATTEMPTS THAT DID NOT WORK — reason from these failures "
2666
+ "and design something FUNDAMENTALLY DIFFERENT (different mechanism, "
2667
+ "not just different parameters):\n" + "\n".join(items)
2668
+ )
2669
+ if heterogeneous:
2670
+ block += (
2671
+ "\n\nHETEROGENEITY — a prior fix helped some cases and broke "
2672
+ "others. Prefer a CONDITIONAL fix (apply only where it helps) "
2673
+ "over a stronger global transform:\n" + "\n".join(heterogeneous)
2674
+ )
2675
+ if implementations:
2676
+ block += (
2677
+ "\n\nEXPLORE-TESTED IMPLEMENTATION(S) TO REVISE:\n"
2678
+ + "\n\n".join(implementations)
2679
+ )
2680
+ return block
2681
+
2682
+ def _emit_codegen(
2683
+ self,
2684
+ name: str,
2685
+ prompt: str,
2686
+ source: str,
2687
+ code: str,
2688
+ raw: str,
2689
+ *,
2690
+ ok: bool,
2691
+ trial: "Trial | None" = None,
2692
+ ) -> None:
2693
+ """Log one codegen attempt; persist its prompt/code/thinking.
2694
+
2695
+ With a *trial*, the files live under ``trial.root`` (alongside the
2696
+ rest of that attempt's record) instead of the run-global ``tools/`` —
2697
+ only a lean event (paths via ``extra["trial_root"]``) goes through
2698
+ :meth:`RunLogger.log_tool_codegen`, with no duplicate file copy.
2699
+ """
2700
+ extra = (
2701
+ {"cli_usage": self._last_usage}
2702
+ if source.startswith("cli:") and self._last_usage
2703
+ else None
2704
+ )
2705
+ inline = bool(getattr(self.run_logger, "inline_text_artifacts", False))
2706
+ if trial is not None and not inline:
2707
+ if prompt:
2708
+ trial.write(f"{name}_prompt.txt", prompt)
2709
+ if code:
2710
+ trial.write(f"{name}_code.py", code)
2711
+ if raw:
2712
+ trial.write(f"{name}_agent_thinking.txt", raw)
2713
+ if self._last_raw_stream:
2714
+ trial.write(f"{name}_agent_raw_stream.txt", self._last_raw_stream)
2715
+ extra = {**(extra or {}), "trial_root": str(trial.root)}
2716
+ prompt, code, raw = "", "", ""
2717
+ elif trial is not None:
2718
+ # V2 keeps the exact prompt/code/response in M5/log.json. The
2719
+ # trial workspace is ephemeral execution state and is snapshotted
2720
+ # separately by log_experiment/log_fix.
2721
+ extra = {**(extra or {}), "trial_root": str(trial.root)}
2722
+ if self.run_logger is None:
2723
+ return
2724
+ try:
2725
+ self.run_logger.log_tool_codegen(
2726
+ module="fix_pipeline",
2727
+ name=name,
2728
+ need="L2 coded repair pipeline",
2729
+ source=source,
2730
+ ok=ok,
2731
+ code=code,
2732
+ prompt=prompt,
2733
+ raw_output=raw,
2734
+ raw_stream=self._last_raw_stream,
2735
+ error="" if ok else "no code produced",
2736
+ extra=extra,
2737
+ )
2738
+ except Exception as exc: # logging must never break the fix step
2739
+ logger.debug("FixAgent: log_tool_codegen failed: %s", exc)
2740
+
2741
+ def _l3_candidates(
2742
+ self,
2743
+ hyp_lines: str,
2744
+ model: "Model",
2745
+ prior_text: str = "",
2746
+ prior_names: "frozenset[str]" = frozenset(),
2747
+ *,
2748
+ has_images: bool = False,
2749
+ has_audio: bool = False,
2750
+ tasks: "set[str] | None" = None,
2751
+ ) -> "list[FixCandidate]":
2752
+ """Select registered repair capabilities and internals primitives.
2753
+
2754
+ This layer is intentionally method-agnostic: executor names, fidelity
2755
+ gates, task compatibility, defaults, and evidence-facing descriptions
2756
+ live in :mod:`repair_catalog`. The judge sees only structurally
2757
+ executable entries and performs mechanism matching against diagnosis.
2758
+ """
2759
+ out: "list[FixCandidate]" = []
2760
+
2761
+ def finalize(options: "list[FixCandidate]") -> "list[FixCandidate]":
2762
+ if self._candidate_allowlist is not None:
2763
+ options = [c for c in options if c.name in self._candidate_allowlist]
2764
+ return options[: self.max_judge_candidates]
2765
+
2766
+ discovered = discover_methods(
2767
+ model,
2768
+ max_tier=self.max_tier,
2769
+ has_images=has_images,
2770
+ has_audio=has_audio,
2771
+ tasks=tasks or set(),
2772
+ allow_adapted=self._allow_adapted_paper_methods,
2773
+ prior_names=prior_names,
2774
+ )
2775
+ if discovered:
2776
+ by_name = {method.name: method for method in discovered}
2777
+ catalog_lines = "\n".join(
2778
+ f"- {method.name} [{method.tier.label}]: {method.description}"
2779
+ for method in discovered
2780
+ )
2781
+ requested = (
2782
+ next(iter(self._candidate_allowlist))
2783
+ if self._candidate_allowlist is not None
2784
+ and len(self._candidate_allowlist) == 1
2785
+ else None
2786
+ )
2787
+ selected_names: list[str]
2788
+ if requested in by_name:
2789
+ # A one-name allowlist is a frozen experiment. Structural
2790
+ # discovery still gates it, but mechanism matching is already
2791
+ # pre-registered and cannot be vetoed by a fresh judge call.
2792
+ selected_names = [requested]
2793
+ else:
2794
+ selected_names = []
2795
+ for proposal in self._ask_judge(
2796
+ _REPAIR_CATALOG_PROMPT.format(
2797
+ hypotheses=hyp_lines,
2798
+ catalog=catalog_lines,
2799
+ k=self.max_judge_candidates,
2800
+ )
2801
+ + prior_text
2802
+ ):
2803
+ name = str(proposal.get("name", ""))
2804
+ if name in by_name and name not in selected_names:
2805
+ selected_names.append(name)
2806
+ for name in selected_names:
2807
+ method = by_name[name]
2808
+ out.append(
2809
+ FixCandidate(
2810
+ tier=method.tier,
2811
+ name=method.name,
2812
+ kind="registered_repair",
2813
+ source=method.source,
2814
+ payload={
2815
+ "executor": method.executor,
2816
+ "baseline_executor": method.baseline_executor,
2817
+ "kwargs": dict(method.payload),
2818
+ "pass_baseline_answer": method.pass_baseline_answer,
2819
+ },
2820
+ )
2821
+ )
2822
+
2823
+ # Generic pre-audited internals-write primitives share the same
2824
+ # evidence-driven selection path but are registered separately because
2825
+ # they are host hooks rather than model-provided executor capabilities.
2826
+ catalog = primitives_catalog_text(model, self.max_tier)
2827
+ if catalog:
2828
+ for proposal in self._ask_judge(
2829
+ _L3_PROMPT.format(
2830
+ hypotheses=hyp_lines,
2831
+ catalog=catalog,
2832
+ k=self.max_judge_candidates,
2833
+ )
2834
+ + prior_text
2835
+ ):
2836
+ primitive = INTERNALS_PRIMITIVES.get(str(proposal.get("primitive", "")))
2837
+ if (
2838
+ primitive is None
2839
+ or primitive.tier > self.max_tier
2840
+ or not primitive.available(model)
2841
+ ):
2842
+ continue
2843
+ out.append(
2844
+ FixCandidate(
2845
+ tier=primitive.tier,
2846
+ name=primitive.name,
2847
+ kind="primitive",
2848
+ payload={
2849
+ "primitive": primitive.name,
2850
+ "params": dict(proposal.get("params") or {}),
2851
+ },
2852
+ )
2853
+ )
2854
+ if not out and catalog:
2855
+ # Registry defaults are a fallback only when the judge returned no
2856
+ # executable choice; no paper method is special-cased here.
2857
+ for primitive in INTERNALS_PRIMITIVES.values():
2858
+ if (
2859
+ primitive.name not in prior_names
2860
+ and primitive.tier <= self.max_tier
2861
+ and primitive.available(model)
2862
+ ):
2863
+ out.append(
2864
+ FixCandidate(
2865
+ tier=primitive.tier,
2866
+ name=primitive.name,
2867
+ kind="primitive",
2868
+ source="default",
2869
+ payload={"primitive": primitive.name, "params": {}},
2870
+ )
2871
+ )
2872
+ if not out and not catalog and not discovered:
2873
+ logger.info("FixAgent: no L3 repair capability is available for %r", model)
2874
+ return finalize(out)
2875
+
2876
+ def _l4_candidates(self, hyp_lines: str) -> "list[FixCandidate]":
2877
+ """L4 recipe — recorded for the escalation decision; executor is TODO."""
2878
+ spec: "FinetuneSpec | None" = None
2879
+ if self._judge is not None:
2880
+ raw = self._ask_judge_object(_L4_PROMPT.format(hypotheses=hyp_lines))
2881
+ if raw:
2882
+ spec = FinetuneSpec(
2883
+ dataset_recipe=str(raw.get("dataset_recipe", "")),
2884
+ method=str(raw.get("method", "lora")),
2885
+ target=str(raw.get("target", "llm")),
2886
+ eval_protocol=str(raw.get("eval_protocol", ""))
2887
+ or FinetuneSpec("").eval_protocol,
2888
+ rationale=str(raw.get("rationale", "")),
2889
+ )
2890
+ if spec is None or not spec.dataset_recipe:
2891
+ spec = FinetuneSpec(
2892
+ dataset_recipe="TODO: synthesise training data generalising the "
2893
+ "verified failure mechanism",
2894
+ rationale="default skeleton — no judge recipe available",
2895
+ )
2896
+ return [
2897
+ FixCandidate(
2898
+ tier=FixTier.L4_PARAMETERS,
2899
+ name="finetune_recipe",
2900
+ kind="finetune_spec",
2901
+ payload=spec.to_dict(),
2902
+ source="judge" if self._judge is not None else "default",
2903
+ )
2904
+ ]
2905
+
2906
+ def _ask_judge_object(self, prompt: str) -> "dict[str, Any]":
2907
+ """Single-JSON-object variant of :meth:`_ask_judge`."""
2908
+ if self._judge is None:
2909
+ return {}
2910
+ raw = ""
2911
+ error = None
2912
+ try:
2913
+ raw = str(self._judge.generate(prompt))
2914
+ except Exception as exc:
2915
+ logger.warning("FixAgent: judge call failed: %s", exc)
2916
+ error = str(exc)
2917
+ self._log_model_exchange("fix_judge_object", prompt, raw, error=error)
2918
+ return {}
2919
+ self._log_model_exchange("fix_judge_object", prompt, raw)
2920
+ match = re.search(
2921
+ r"\{.*\}", re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL), flags=re.DOTALL
2922
+ )
2923
+ if not match:
2924
+ return {}
2925
+ try:
2926
+ parsed = json.loads(match.group(0))
2927
+ except json.JSONDecodeError:
2928
+ return {}
2929
+ return parsed if isinstance(parsed, dict) else {}
2930
+
2931
+ def _ask_judge(self, prompt: str) -> "list[dict[str, Any]]":
2932
+ if self._judge is None:
2933
+ return []
2934
+ raw = ""
2935
+ error = None
2936
+ try:
2937
+ raw = str(self._judge.generate(prompt))
2938
+ except Exception as exc:
2939
+ logger.warning("FixAgent: judge call failed: %s", exc)
2940
+ error = str(exc)
2941
+ self._log_model_exchange("fix_judge_list", prompt, raw, error=error)
2942
+ return []
2943
+ self._log_model_exchange("fix_judge_list", prompt, raw)
2944
+ match = re.search(
2945
+ r"\[.*\]", re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL), flags=re.DOTALL
2946
+ )
2947
+ if not match:
2948
+ return []
2949
+ try:
2950
+ parsed = json.loads(match.group(0))
2951
+ except json.JSONDecodeError:
2952
+ logger.warning("FixAgent: unparseable judge proposal; using defaults")
2953
+ return []
2954
+ return [p for p in parsed if isinstance(p, dict)] if isinstance(parsed, list) else []
2955
+
2956
+ def _log_model_exchange(
2957
+ self, operation: str, prompt: str, output: str, *, error: "str | None" = None,
2958
+ ) -> None:
2959
+ """Best-effort detailed V2 model-call logging without changing V1."""
2960
+ fn = getattr(self.run_logger, "log_model_exchange", None)
2961
+ if not callable(fn):
2962
+ return
2963
+ try:
2964
+ fn(
2965
+ "M5", role="fix_judge", operation=operation,
2966
+ inputs=prompt, output=output, error=error,
2967
+ )
2968
+ except Exception as exc: # logging must never break repair
2969
+ logger.debug("FixAgent: model exchange logging failed: %s", exc)
2970
+
2971
+ def _log_target_exchange(
2972
+ self,
2973
+ operation: str,
2974
+ inputs: Any,
2975
+ output: Any,
2976
+ *,
2977
+ case_id: str,
2978
+ error: "str | None" = None,
2979
+ duration_sec: "float | None" = None,
2980
+ metadata: "dict[str, Any] | None" = None,
2981
+ ) -> None:
2982
+ fn = getattr(self.run_logger, "log_model_exchange", None)
2983
+ if not callable(fn):
2984
+ return
2985
+ try:
2986
+ fn(
2987
+ "M5", role="target_model", operation=operation,
2988
+ inputs=inputs, output=output, error=error, duration_sec=duration_sec,
2989
+ metadata={"case_id": case_id, **(metadata or {})},
2990
+ )
2991
+ except Exception as exc:
2992
+ logger.debug("FixAgent: target exchange logging failed: %s", exc)
2993
+
2994
+ def _log_bridge_exchange(self, record: "dict[str, Any]") -> None:
2995
+ response = record.get("response") or {}
2996
+ self._log_target_exchange(
2997
+ "coded_pipeline_bridge", record.get("request"), response.get("output"),
2998
+ case_id=str(record.get("case_id") or ""), error=response.get("error"),
2999
+ duration_sec=record.get("duration_sec"),
3000
+ metadata={
3001
+ "replayed_from_recorded_baseline": bool(
3002
+ record.get("replayed_from_recorded_baseline")
3003
+ )
3004
+ },
3005
+ )
3006
+
3007
+ # -- strategy compilation + validation --------------------------------
3008
+
3009
+ def _baseline(
3010
+ self, model: "Model", data: "CaseBatch"
3011
+ ) -> "tuple[dict[str, Optional[bool]], set[str]]":
3012
+ """Measure the unmodified baseline as a per-case PASS RATE.
3013
+
3014
+ Returns ``(modal_scores, unstable_ids)`` for callers that think in
3015
+ booleans, and stores the rates in ``self._baseline_rates`` /
3016
+ ``self._baseline_n`` for the paired-rates test.
3017
+
3018
+ * ``baseline_repeats == 1`` — the frozen ``observed`` output is the
3019
+ baseline (one sample; rate 0/1). Re-generating it would make a
3020
+ supposedly paired comparison depend on endpoint non-determinism and
3021
+ waste a call per case. Cases without an ``observed`` are generated
3022
+ once.
3023
+ * ``baseline_repeats == k > 1`` — the frozen sample counts as sample 1
3024
+ and ``k-1`` fresh samples are drawn (all ``k`` fresh when there is no
3025
+ frozen one), so each case gets a rate in ``{0, 1/k, ..., 1}``. A case
3026
+ with ``0 < rate < 1`` is *unstable* (its baseline answer is a coin);
3027
+ it is REPORTED, not dropped: the paired-rates test weighs it by how
3028
+ much a candidate moves its rate, which is the honest accounting for
3029
+ a stochastic model — dropping it removed exactly the cases a
3030
+ variance-reduction scaffold repairs.
3031
+ """
3032
+ k = max(1, int(self._baseline_repeats))
3033
+ samples: "dict[str, list[bool]]" = {c.id: [] for c in data}
3034
+ need: "list[tuple[Any, int]]" = []
3035
+ for case in data:
3036
+ observed = getattr(case, "observed", None)
3037
+ if observed is not None:
3038
+ s0 = score_to_bool(self._score(case, str(observed)))
3039
+ if s0 is not None:
3040
+ samples[case.id].append(bool(s0))
3041
+ fresh = k - len(samples[case.id])
3042
+ if fresh > 0:
3043
+ need.append((case, fresh))
3044
+ if need:
3045
+ self._sample_baseline(model, need, samples)
3046
+ scores: "dict[str, Optional[bool]]" = {}
3047
+ rates: "dict[str, Optional[float]]" = {}
3048
+ counts: "dict[str, int]" = {}
3049
+ unstable: "set[str]" = set()
3050
+ for cid, obs in samples.items():
3051
+ counts[cid] = len(obs)
3052
+ if not obs:
3053
+ scores[cid] = None
3054
+ rates[cid] = None
3055
+ continue
3056
+ rate = sum(1 for o in obs if o) / len(obs)
3057
+ rates[cid] = rate
3058
+ scores[cid] = rate >= 0.5 # modal; ties -> True
3059
+ if 0.0 < rate < 1.0:
3060
+ unstable.add(cid)
3061
+ self._baseline_rates = rates
3062
+ self._baseline_n = counts
3063
+ return scores, unstable
3064
+
3065
+ def _sample_baseline(
3066
+ self,
3067
+ model: "Model",
3068
+ need: "list[tuple[Any, int]]",
3069
+ samples: "dict[str, list[bool]]",
3070
+ ) -> None:
3071
+ """Draw the missing fresh baseline samples (threaded like candidates)."""
3072
+ jobs = [case for case, n in need for _ in range(n)]
3073
+
3074
+ def one(case: Any) -> "tuple[str, Optional[bool]]":
3075
+ started = time.perf_counter()
3076
+ try:
3077
+ output = str(model.generate(case.inputs))
3078
+ except Exception as exc:
3079
+ logger.debug("FixAgent: baseline generate failed on %s: %s", case.id, exc)
3080
+ self._log_target_exchange(
3081
+ "fresh_baseline", case.inputs, None, case_id=case.id,
3082
+ error=str(exc), duration_sec=time.perf_counter() - started,
3083
+ )
3084
+ return case.id, None
3085
+ self._log_target_exchange(
3086
+ "fresh_baseline", case.inputs, output, case_id=case.id,
3087
+ duration_sec=time.perf_counter() - started,
3088
+ )
3089
+ return case.id, score_to_bool(self._score(case, output))
3090
+
3091
+ if self._concurrency > 1 and len(jobs) > 1:
3092
+ from concurrent.futures import ThreadPoolExecutor
3093
+
3094
+ with ThreadPoolExecutor(max_workers=self._concurrency) as pool:
3095
+ results = list(pool.map(one, jobs))
3096
+ else:
3097
+ results = [one(case) for case in jobs]
3098
+ for cid, s in results:
3099
+ if s is not None:
3100
+ samples[cid].append(bool(s))
3101
+
3102
+ def _baseline_fresh(
3103
+ self, model: "Model", data: "CaseBatch"
3104
+ ) -> "tuple[dict[str, Optional[bool]], set[str]]":
3105
+ """Generate a baseline ignoring any frozen observation (k fresh samples)."""
3106
+ k = max(1, int(self._baseline_repeats))
3107
+ samples: "dict[str, list[bool]]" = {c.id: [] for c in data}
3108
+ self._sample_baseline(model, [(c, k) for c in data], samples)
3109
+ scores: "dict[str, Optional[bool]]" = {}
3110
+ unstable: "set[str]" = set()
3111
+ for cid, obs in samples.items():
3112
+ if not obs:
3113
+ scores[cid] = None
3114
+ continue
3115
+ rate = sum(1 for o in obs if o) / len(obs)
3116
+ scores[cid] = rate >= 0.5
3117
+ if 0.0 < rate < 1.0:
3118
+ unstable.add(cid)
3119
+ return scores, unstable
3120
+
3121
+ def _candidate_rates(
3122
+ self, candidate: FixCandidate, model: "Model", data: "CaseBatch"
3123
+ ) -> "tuple[dict[str, Optional[float]], int]":
3124
+ """Per-case candidate PASS RATE over ``candidate_repeats`` passes.
3125
+
3126
+ Coded pipelines / internals primitives / fine-tune recipes run once
3127
+ (they are expensive and vote internally when they want to); template
3128
+ and spec candidates are repeated ``candidate_repeats`` times. Returns
3129
+ ``(rates, n_passes)``; a case is ``None`` when every pass was unscorable.
3130
+ """
3131
+ m = int(self._candidate_repeats) if candidate.kind in ("template", "spec") else 1
3132
+ m = max(1, m)
3133
+ tallies: "dict[str, list[bool]]" = {c.id: [] for c in data}
3134
+ for _ in range(m):
3135
+ scores = self._candidate_scores(candidate, model, data)
3136
+ for cid, s in scores.items():
3137
+ b = score_to_bool(s)
3138
+ if b is not None:
3139
+ tallies.setdefault(cid, []).append(bool(b))
3140
+ rates = {
3141
+ cid: (sum(1 for o in obs if o) / len(obs) if obs else None)
3142
+ for cid, obs in tallies.items()
3143
+ }
3144
+ return rates, m
3145
+
3146
+ def _applies(self, candidate: FixCandidate, case: "FailureCase") -> bool:
3147
+ """Whether *candidate* is applicable to *case* (defect 1).
3148
+
3149
+ An explicit predicate wins. Otherwise applicability is structural: a
3150
+ prompt template that is the identity and image ops that leave the image
3151
+ unchanged mean the candidate never touches the case, so it must not be
3152
+ credited or blamed for it. Coded/primitive/finetune candidates run
3153
+ their own per-case logic, so they are treated as universally applicable.
3154
+ """
3155
+ if candidate.predicate is not None:
3156
+ try:
3157
+ return bool(candidate.predicate(case))
3158
+ except Exception as exc:
3159
+ logger.debug("FixAgent: predicate failed on %s: %s", case.id, exc)
3160
+ return True
3161
+ if candidate.kind == "template":
3162
+ return str(candidate.payload.get("prompt_template", "{prompt}")).strip() != "{prompt}"
3163
+ if candidate.kind == "spec":
3164
+ spec = PipelineSpec.from_dict(candidate.payload)
3165
+ if spec is None:
3166
+ return True
3167
+ try:
3168
+ return spec_changes_input(spec, case)
3169
+ except Exception as exc:
3170
+ logger.debug("FixAgent: applicability check failed on %s: %s", case.id, exc)
3171
+ return True
3172
+ return True
3173
+
3174
+ def _strategy(
3175
+ self, candidate: FixCandidate
3176
+ ) -> "Callable[[Model, FailureCase], Optional[bool]]":
3177
+ """Compile a candidate to a per-case success function (ab_runner shape)."""
3178
+ if candidate.kind == "registered_repair":
3179
+
3180
+ def registered_repair(
3181
+ model: "Model", case: "FailureCase"
3182
+ ) -> "Optional[bool]":
3183
+ executor_name = str(candidate.payload.get("executor", ""))
3184
+ kwargs = dict(candidate.payload.get("kwargs") or {})
3185
+ if candidate.payload.get("pass_baseline_answer"):
3186
+ kwargs["baseline_answer"] = str(getattr(case, "observed", ""))
3187
+ try:
3188
+ executor = getattr(model, executor_name)
3189
+ output = executor(case.inputs, **kwargs)
3190
+ self._record_output(case.id, output)
3191
+ return score_to_bool(self._score(case, str(output)))
3192
+ except Exception as exc:
3193
+ logger.debug(
3194
+ "Registered repair %s failed on %s: %s",
3195
+ candidate.name,
3196
+ case.id,
3197
+ exc,
3198
+ )
3199
+ return None
3200
+
3201
+ return registered_repair
3202
+ if candidate.kind == "visual_search":
3203
+
3204
+ def visual_search(model: "Model", case: "FailureCase") -> "Optional[bool]":
3205
+ started = time.perf_counter()
3206
+ try:
3207
+ search = getattr(model, "generate_visual_search")
3208
+ output = search(
3209
+ case.inputs,
3210
+ baseline_answer=getattr(case, "observed", None),
3211
+ **candidate.payload,
3212
+ )
3213
+ self._log_target_exchange(
3214
+ "guided_visual_search", case.inputs, output, case_id=case.id,
3215
+ duration_sec=time.perf_counter() - started,
3216
+ metadata={"candidate": candidate.name, "parameters": candidate.payload},
3217
+ )
3218
+ self._record_output(case.id, output)
3219
+ return score_to_bool(self._score(case, str(output)))
3220
+ except Exception as exc:
3221
+ self._log_target_exchange(
3222
+ "guided_visual_search", case.inputs, None, case_id=case.id,
3223
+ error=str(exc), duration_sec=time.perf_counter() - started,
3224
+ metadata={"candidate": candidate.name, "parameters": candidate.payload},
3225
+ )
3226
+ logger.debug("Guided visual search failed on %s: %s", case.id, exc)
3227
+ return None
3228
+
3229
+ return visual_search
3230
+ if candidate.kind == "detector_visual_search":
3231
+
3232
+ def detector_visual_search(model: "Model", case: "FailureCase") -> "Optional[bool]":
3233
+ started = time.perf_counter()
3234
+ try:
3235
+ search = getattr(model, "generate_detector_visual_search")
3236
+ output = search(
3237
+ case.inputs,
3238
+ baseline_answer=getattr(case, "observed", None),
3239
+ **candidate.payload,
3240
+ )
3241
+ self._log_target_exchange(
3242
+ "detector_visual_search", case.inputs, output, case_id=case.id,
3243
+ duration_sec=time.perf_counter() - started,
3244
+ metadata={"candidate": candidate.name, "parameters": candidate.payload},
3245
+ )
3246
+ self._record_output(case.id, output)
3247
+ return score_to_bool(self._score(case, str(output)))
3248
+ except Exception as exc:
3249
+ self._log_target_exchange(
3250
+ "detector_visual_search", case.inputs, None, case_id=case.id,
3251
+ error=str(exc), duration_sec=time.perf_counter() - started,
3252
+ metadata={"candidate": candidate.name, "parameters": candidate.payload},
3253
+ )
3254
+ logger.debug("Detector visual search failed on %s: %s", case.id, exc)
3255
+ return None
3256
+
3257
+ return detector_visual_search
3258
+ if candidate.kind == "template":
3259
+ template = candidate.payload["prompt_template"]
3260
+ gen_kwargs = _safe_generation_kwargs(candidate.payload.get("generation_kwargs"))
3261
+
3262
+ def l1(model: "Model", case: "FailureCase") -> "Optional[bool]":
3263
+ inp = case.inputs
3264
+ metadata = getattr(case, "metadata", {}) or {}
3265
+ template_context = {str(key): value for key, value in metadata.items()}
3266
+ template_context["prompt"] = str(getattr(inp, "prompt", ""))
3267
+ template_context.setdefault("failure_axis", "the relevant visual evidence")
3268
+ # Inside the try, not before it: rendering the template is as
3269
+ # capable of failing as generating from it, and a single bad
3270
+ # case must score None rather than abort the whole validation.
3271
+ started = time.perf_counter()
3272
+ try:
3273
+ # dataclasses.replace, not a bare Inputs(prompt=..., image=...):
3274
+ # that silently dropped .video/.audio, so every L1 candidate was
3275
+ # unconditionally inapplicable (generate() raising on the
3276
+ # missing required modality field, caught below, scored as
3277
+ # None for every case) on any non-image FailureCase.
3278
+ new_inputs = dataclasses.replace(
3279
+ inp, prompt=safe_format(template, template_context)
3280
+ )
3281
+ output = str(model.generate(new_inputs, **gen_kwargs))
3282
+ self._log_target_exchange(
3283
+ "template_candidate", new_inputs, output, case_id=case.id,
3284
+ duration_sec=time.perf_counter() - started,
3285
+ metadata={"generation_kwargs": gen_kwargs, "candidate": candidate.name},
3286
+ )
3287
+ self._record_output(case.id, output)
3288
+ return score_to_bool(self._score(case, output))
3289
+ except Exception as exc:
3290
+ self._log_target_exchange(
3291
+ "template_candidate", locals().get("new_inputs", inp), None,
3292
+ case_id=case.id, error=str(exc),
3293
+ duration_sec=time.perf_counter() - started,
3294
+ metadata={"generation_kwargs": gen_kwargs, "candidate": candidate.name},
3295
+ )
3296
+ return None
3297
+
3298
+ return l1
3299
+
3300
+ spec = PipelineSpec.from_dict(candidate.payload)
3301
+ if spec is None: # already validated at proposal time; belt and braces
3302
+ return lambda model, case: None
3303
+
3304
+ def declarative(model: "Model", case: "FailureCase") -> "Optional[bool]":
3305
+ capture: "dict[str, Any]" = {}
3306
+ result = run_pipeline(
3307
+ model, case, spec, self._score, capture=capture,
3308
+ call_logger=lambda record: self._log_target_exchange(
3309
+ "declarative_pipeline", record.get("inputs"), record.get("output"),
3310
+ case_id=case.id, error=record.get("error"),
3311
+ duration_sec=record.get("duration_sec"),
3312
+ metadata={
3313
+ "candidate": candidate.name,
3314
+ "generation_kwargs": record.get("generation_kwargs") or {},
3315
+ },
3316
+ ),
3317
+ )
3318
+ winner = capture.get("winner")
3319
+ if winner is None and capture.get("outputs"):
3320
+ winner = capture["outputs"][0]
3321
+ self._record_output(case.id, winner)
3322
+ return result
3323
+
3324
+ return declarative
3325
+
3326
+ def _candidate_scores(
3327
+ self, candidate: FixCandidate, model: "Model", data: "CaseBatch"
3328
+ ) -> "dict[str, Optional[bool]]":
3329
+ """Per-case success of one candidate (batch path for coded pipelines)."""
3330
+ if candidate.kind == "primitive":
3331
+ if getattr(self.run_logger, "preserve_full_model_io", False):
3332
+ from evalrx.eval_agent.model_instrumentation import InstrumentedModel
3333
+
3334
+ model = InstrumentedModel(
3335
+ model, self.run_logger, stage="M5",
3336
+ cycle=int(getattr(self.run_logger, "current_cycle", -1)),
3337
+ analyzer=f"fix_primitive:{candidate.name}",
3338
+ case_prompts={case.inputs.prompt: case.id for case in data},
3339
+ batch_case_ids=[case.id for case in data],
3340
+ )
3341
+ prim = INTERNALS_PRIMITIVES[candidate.payload["primitive"]]
3342
+ return prim.run(model, data, self._score, candidate.payload.get("params"))
3343
+ if candidate.kind == "code":
3344
+ result = self._run_coded(candidate, model, data)
3345
+ for cid, output in result.outputs.items():
3346
+ self._record_output(cid, output)
3347
+ if result.ok:
3348
+ self._frozen_model_control(candidate, data)
3349
+ return score_outputs(result, data, self._score)
3350
+ if candidate.kind == "finetune_spec":
3351
+ if getattr(self.run_logger, "preserve_full_model_io", False):
3352
+ from evalrx.eval_agent.model_instrumentation import InstrumentedModel
3353
+
3354
+ model = InstrumentedModel(
3355
+ model, self.run_logger, stage="M5",
3356
+ cycle=int(getattr(self.run_logger, "current_cycle", -1)),
3357
+ analyzer=f"fix_finetune:{candidate.name}",
3358
+ case_prompts={case.inputs.prompt: case.id for case in data},
3359
+ batch_case_ids=[case.id for case in data],
3360
+ )
3361
+ result = run_lora_repair(model, self._finetune_pool, data, candidate.payload, self._score)
3362
+ if isinstance(candidate.payload, dict):
3363
+ candidate.payload["exec_error"] = "" if result.ok else result.error
3364
+ return result.scores
3365
+ strategy = self._strategy(candidate)
3366
+ cases = list(data)
3367
+
3368
+ def guarded(case: "FailureCase") -> "tuple[str, Optional[bool]]":
3369
+ # One case's failure (a template that cannot render, an adapter
3370
+ # error) scores None for THAT case; it must never abort the whole
3371
+ # candidate — let alone the fix stage.
3372
+ try:
3373
+ # Do not execute a conditional repair outside its registered
3374
+ # population. Validation already excludes those cases, but
3375
+ # generating for them wastes compute and can advance a
3376
+ # stochastic model before the applicable cases are sampled.
3377
+ if not self._applies(candidate, case):
3378
+ return case.id, None
3379
+ return case.id, strategy(model, case)
3380
+ except Exception as exc:
3381
+ logger.warning("FixAgent: %s failed on case %s: %s", candidate.name, case.id, exc)
3382
+ return case.id, None
3383
+
3384
+ if self._concurrency > 1 and len(cases) > 1:
3385
+ from concurrent.futures import ThreadPoolExecutor
3386
+
3387
+ with ThreadPoolExecutor(max_workers=self._concurrency) as pool:
3388
+ results = list(pool.map(guarded, cases))
3389
+ return dict(results)
3390
+ return dict(guarded(case) for case in cases)
3391
+
3392
+ def _registered_baseline_scores(
3393
+ self, candidate: FixCandidate, model: "Model", data: "CaseBatch"
3394
+ ) -> "dict[str, Optional[bool]] | None":
3395
+ """Run a registered repair's matched control arm, when declared.
3396
+
3397
+ A decoding repair may change both sampling and logits. Comparing it to
3398
+ Stage 0's greedy output would confound those changes; this control
3399
+ keeps the paired test about the intervention itself.
3400
+ """
3401
+ if candidate.kind != "registered_repair":
3402
+ return None
3403
+ executor_name = str(candidate.payload.get("baseline_executor", "") or "")
3404
+ if not executor_name:
3405
+ return None
3406
+ executor = getattr(model, executor_name, None)
3407
+ if not callable(executor):
3408
+ return {case.id: None for case in data}
3409
+
3410
+ def guarded(case: "FailureCase") -> "tuple[str, Optional[bool]]":
3411
+ try:
3412
+ output = executor(case.inputs)
3413
+ return case.id, score_to_bool(self._score(case, str(output)))
3414
+ except Exception as exc:
3415
+ logger.warning(
3416
+ "FixAgent: matched baseline %s failed on case %s: %s",
3417
+ executor_name,
3418
+ case.id,
3419
+ exc,
3420
+ )
3421
+ return case.id, None
3422
+
3423
+ cases = list(data)
3424
+ if self._concurrency > 1 and len(cases) > 1:
3425
+ from concurrent.futures import ThreadPoolExecutor
3426
+
3427
+ with ThreadPoolExecutor(max_workers=self._concurrency) as pool:
3428
+ return dict(pool.map(guarded, cases))
3429
+ return dict(guarded(case) for case in cases)
3430
+
3431
+ def _record_output(self, case_id: str, output: Any) -> None:
3432
+ """Remember what a candidate produced for *case_id* (see FixValidation.outputs)."""
3433
+ if output is None:
3434
+ return
3435
+ self._captured[str(case_id)] = str(output)
3436
+
3437
+ def _run_coded(
3438
+ self, candidate: FixCandidate, model: "Model", data: "CaseBatch"
3439
+ ) -> "CodedPipelineResult":
3440
+ """Run a coded candidate; a failed run gets ONE coder repair round.
3441
+
3442
+ The execution error (strict-bridge message, timeout, traceback tail) is
3443
+ fed back verbatim — the coder fixes its own contract violation instead
3444
+ of the candidate silently dying. The final error, if any, is stashed
3445
+ in ``payload["exec_error"]`` for honest escalation accounting.
3446
+ """
3447
+ workdir = self._workdir(candidate.trial)
3448
+ result = run_coded_pipeline(
3449
+ candidate.payload["code"],
3450
+ model,
3451
+ data,
3452
+ workdir=workdir,
3453
+ timeout_sec=self._exec_timeout_sec,
3454
+ enable_attend=bool(candidate.payload.get("enable_attend")),
3455
+ max_tokens_floor=self._max_tokens_floor,
3456
+ concurrency=self._concurrency,
3457
+ consensus_min_support=int(candidate.payload.get("consensus_min_support", 0)),
3458
+ max_calls_per_case=int(candidate.payload.get("max_calls_per_case", 0)),
3459
+ call_logger=self._log_bridge_exchange,
3460
+ )
3461
+ if not result.ok and self.codegen_available:
3462
+ logger.warning("FixAgent: coded pipeline failed (%s) — one repair round", result.error)
3463
+ repaired, source, raw = self._repair_code(candidate, result.error)
3464
+ self._emit_codegen(
3465
+ "coded_pipeline_repair",
3466
+ self._last_repair_prompt,
3467
+ source,
3468
+ repaired,
3469
+ raw,
3470
+ ok=bool(repaired.strip()),
3471
+ trial=candidate.trial,
3472
+ )
3473
+ if repaired.strip():
3474
+ candidate.payload["code"] = repaired
3475
+ result = run_coded_pipeline(
3476
+ repaired,
3477
+ model,
3478
+ data,
3479
+ workdir=workdir,
3480
+ timeout_sec=self._exec_timeout_sec,
3481
+ enable_attend=bool(candidate.payload.get("enable_attend")),
3482
+ max_tokens_floor=self._max_tokens_floor,
3483
+ concurrency=self._concurrency,
3484
+ consensus_min_support=int(
3485
+ candidate.payload.get("consensus_min_support", 0)
3486
+ ),
3487
+ max_calls_per_case=int(candidate.payload.get("max_calls_per_case", 0)),
3488
+ call_logger=self._log_bridge_exchange,
3489
+ )
3490
+ candidate.payload["exec_error"] = "" if result.ok else result.error
3491
+ candidate.payload["selection_guard"] = {
3492
+ "min_support": int(candidate.payload.get("consensus_min_support", 0)),
3493
+ "n_guarded": result.n_guarded,
3494
+ "guarded_ids": result.guarded_ids,
3495
+ # Audit trail for the anchor semantics: how many cases were
3496
+ # anchored on their recorded baseline because the pipeline made
3497
+ # no direct call, how many plain direct calls were answered from
3498
+ # the record, and which cases had nothing to anchor on.
3499
+ "n_anchored_from_recorded": result.n_anchored_from_recorded,
3500
+ "n_replayed": result.n_replayed,
3501
+ "unanchored_ids": result.unanchored_ids,
3502
+ }
3503
+ if result.unanchored_ids:
3504
+ logger.warning(
3505
+ "FixAgent: %d case(s) excluded from the coded pipeline result — no "
3506
+ "recorded baseline and no direct model_generate(case_id) call to "
3507
+ "anchor the selection guard on", len(result.unanchored_ids),
3508
+ )
3509
+ if candidate.trial is not None:
3510
+ # Persist the audit trail next to this attempt's prompt and code:
3511
+ # the payload itself is never logged, and "did the guard anchor on
3512
+ # the record, did it revert anything" is the first question a
3513
+ # reviewer asks of a coded round.
3514
+ candidate.trial.write(
3515
+ "coded_pipeline_result.json",
3516
+ json.dumps({
3517
+ "ok": result.ok,
3518
+ "exec_error": "" if result.ok else result.error,
3519
+ "n_calls": result.n_calls,
3520
+ "n_outputs": len(result.outputs),
3521
+ **candidate.payload["selection_guard"],
3522
+ }, indent=1),
3523
+ )
3524
+ if not result.ok:
3525
+ logger.warning("FixAgent: coded pipeline produced no result: %s", result.error)
3526
+ return result
3527
+
3528
+ def _frozen_model_control(self, candidate: FixCandidate, data: "CaseBatch") -> None:
3529
+ """Re-run a coded candidate with the model frozen; record what it still
3530
+ gets right in ``payload["frozen_model_control"]``.
3531
+
3532
+ ``_validate`` reads ``solved`` and drops those failing cases from the
3533
+ paired test: with every model call answered by the model's own recorded
3534
+ output, a repair can only have come from the code. A control that does
3535
+ not complete (the pipeline crashes or spins without a live model) is
3536
+ evidence the pipeline needs the model — nothing is discounted, and the
3537
+ reason is kept.
3538
+ """
3539
+ from pathlib import Path
3540
+
3541
+ from evalrx.eval_agent.stages.fix_pipeline import frozen_model_control
3542
+
3543
+ workdir = Path(self._workdir(candidate.trial)) / "frozen_model_control"
3544
+ ctrl = frozen_model_control(
3545
+ candidate.payload["code"], data, workdir=workdir,
3546
+ timeout_sec=self._exec_timeout_sec,
3547
+ consensus_min_support=int(candidate.payload.get("consensus_min_support", 0)),
3548
+ max_calls_per_case=int(candidate.payload.get("max_calls_per_case", 0)),
3549
+ )
3550
+ solved: "list[str]" = []
3551
+ if ctrl.ok:
3552
+ for cid, ok in score_outputs(ctrl, data, self._score).items():
3553
+ if ok is True:
3554
+ solved.append(cid)
3555
+ candidate.payload["frozen_model_control"] = {
3556
+ "ok": ctrl.ok,
3557
+ "error": ctrl.error,
3558
+ "n_calls": ctrl.n_calls,
3559
+ "solved": solved,
3560
+ }
3561
+ if candidate.trial is not None:
3562
+ candidate.trial.write(
3563
+ "frozen_model_control.json",
3564
+ json.dumps(candidate.payload["frozen_model_control"], indent=1),
3565
+ )
3566
+ if solved:
3567
+ logger.info(
3568
+ "FixAgent: frozen-model control — %s still solves %d/%d case(s) with the "
3569
+ "model held at its recorded answers", candidate.name, len(solved), len(data),
3570
+ )
3571
+
3572
+ def _repair_code(self, candidate: FixCandidate, error: str) -> "tuple[str, str, str]":
3573
+ """Ask the coder to fix its failed pipeline; returns (code, source, raw)."""
3574
+ from pathlib import Path
3575
+
3576
+ from evalrx.eval_agent.stages.fix_pipeline import (
3577
+ CASES_FILENAME,
3578
+ RESULT_MARKER,
3579
+ )
3580
+
3581
+ attend_clause = (
3582
+ " and model_attend(case_id, prompt=None)"
3583
+ if candidate.payload.get("enable_attend")
3584
+ else ""
3585
+ )
3586
+ base = _REPAIR_PROMPT_BODY.format(
3587
+ error=error[:600],
3588
+ code=str(candidate.payload.get("code", ""))[:4000],
3589
+ attend_clause=attend_clause,
3590
+ catalog=(_TEXT_ONLY_CATALOG_NOTE if candidate.payload.get("text_only")
3591
+ else catalog_text()),
3592
+ cases_file=CASES_FILENAME,
3593
+ marker=RESULT_MARKER,
3594
+ # The repair round restates the selection contract in full: a
3595
+ # repair that only echoes the execution error used to re-violate
3596
+ # the selection rule it was never told about.
3597
+ min_support=int(candidate.payload.get("consensus_min_support", 0) or 0),
3598
+ selection_guidance=self._code_selection_guidance(),
3599
+ )
3600
+ code, source, raw = "", "", ""
3601
+ if self._cli_config is not None and self._cli_config.provider != "llm":
3602
+ # The execution host consumes/removes pipeline.py. Restore the
3603
+ # user's source before asking the CLI coder to edit it, otherwise
3604
+ # an agent can mistake fix_pipeline_exec.py for the target.
3605
+ Path(self._workdir(candidate.trial), "pipeline.py").write_text(
3606
+ str(candidate.payload.get("code", "")), encoding="utf-8"
3607
+ )
3608
+ self._last_repair_prompt = (
3609
+ base + "\nWrite the corrected code to a file named pipeline.py."
3610
+ )
3611
+ code, raw = self._write_code_cli(self._last_repair_prompt, candidate.trial)
3612
+ source = f"cli:{self._cli_config.provider}"
3613
+ if not code.strip() and self._judge is not None:
3614
+ self._last_raw_stream = ""
3615
+ self._last_repair_prompt = (
3616
+ base + "\nReturn ONLY the corrected Python code inside a ```python code block."
3617
+ )
3618
+ try:
3619
+ raw = str(self._judge.generate(self._last_repair_prompt))
3620
+ except Exception as exc:
3621
+ logger.warning("FixAgent: repair judge call failed: %s", exc)
3622
+ raw = ""
3623
+ code = _extract_code(raw)
3624
+ if code.strip():
3625
+ import ast
3626
+
3627
+ try:
3628
+ ast.parse(code)
3629
+ except SyntaxError:
3630
+ code = ""
3631
+ source = "judge"
3632
+ if _code_redefines_model_bridge(code):
3633
+ logger.warning("FixAgent: repaired code redefined a host model bridge; dropped")
3634
+ code = ""
3635
+ return code, source, raw
3636
+
3637
+ def _validate(
3638
+ self,
3639
+ candidate: FixCandidate,
3640
+ model: "Model",
3641
+ data: "CaseBatch",
3642
+ baseline: "dict[str, Optional[bool]]",
3643
+ unstable: "set[str] | None" = None,
3644
+ ) -> FixValidation:
3645
+ v = FixValidation(candidate=candidate, e_threshold=1.0 / self._alpha)
3646
+ unstable = unstable or set()
3647
+ rates_mode = self._baseline_repeats > 1 or self._candidate_repeats > 1
3648
+ v.noise_model = "paired_rates" if rates_mode else "mcnemar"
3649
+ self._captured = {}
3650
+ truncated_before = _truncated_count(model)
3651
+ if rates_mode:
3652
+ cand_rates, m = self._candidate_rates(candidate, model, data)
3653
+ v.n_candidate_samples = m
3654
+ scores: "dict[str, Optional[bool]]" = {
3655
+ cid: (None if r is None else r >= 0.5) for cid, r in cand_rates.items()
3656
+ }
3657
+ else:
3658
+ scores = self._candidate_scores(candidate, model, data)
3659
+ cand_rates = {cid: (None if score_to_bool(sc) is None else float(bool(score_to_bool(sc))))
3660
+ for cid, sc in scores.items()}
3661
+ truncated_after = _truncated_count(model)
3662
+ if truncated_before is not None and truncated_after is not None:
3663
+ v.n_truncated = max(0, truncated_after - truncated_before)
3664
+ v.outputs = dict(self._captured)
3665
+ self._captured = {}
3666
+ if isinstance(candidate.payload, dict):
3667
+ v.exec_error = str(candidate.payload.get("exec_error", "") or "")
3668
+ # Baseline rates: measured by _baseline (frozen sample + k-1 fresh);
3669
+ # when a caller hands in bare booleans (tests, external drivers) they
3670
+ # are 0/1 rates.
3671
+ matched_baseline = self._registered_baseline_scores(candidate, model, data)
3672
+ base_rates: "dict[str, Optional[float]]" = {}
3673
+ for case in data:
3674
+ if matched_baseline is not None:
3675
+ b = score_to_bool(matched_baseline.get(case.id))
3676
+ r = None if b is None else float(b)
3677
+ else:
3678
+ r = self._baseline_rates.get(case.id) if self._baseline_rates else None
3679
+ if r is None:
3680
+ b = score_to_bool(baseline.get(case.id))
3681
+ r = None if b is None else float(b)
3682
+ base_rates[case.id] = r
3683
+ if matched_baseline is not None:
3684
+ v.n_baseline_samples = 1
3685
+ elif self._baseline_n:
3686
+ v.n_baseline_samples = max([1] + [int(n) for n in self._baseline_n.values()])
3687
+
3688
+ n_fail = sum(1 for c in data if getattr(c.label, "value", None) == "fail")
3689
+ applicable_fail = 0
3690
+ base_vec: "list[float]" = []
3691
+ cand_vec: "list[float]" = []
3692
+ control = (candidate.payload.get("frozen_model_control") or {}
3693
+ if isinstance(candidate.payload, dict) else {})
3694
+ solved_without_model = set(control.get("solved") or [])
3695
+ for case in data:
3696
+ rb = base_rates.get(case.id)
3697
+ rc = cand_rates.get(case.id)
3698
+ if rb is None or rc is None:
3699
+ continue
3700
+ b = rb >= 0.5 # modal baseline; ties -> True (as _baseline)
3701
+ c = rc >= 0.5
3702
+ # A failing case the pipeline also gets right with the model frozen
3703
+ # to its recorded answer was repaired by the code, not the model:
3704
+ # not a fix of the model, not a regression either — out of the test.
3705
+ if not b and case.id in solved_without_model:
3706
+ v.n_model_independent += 1
3707
+ continue
3708
+ if case.id in unstable:
3709
+ # One sample per arm (mcnemar): the case's baseline is a coin
3710
+ # and a flip would masquerade as a fix/regression -> held out.
3711
+ # Paired rates: it is REPORTED and stays in, weighed by how far
3712
+ # the candidate moves its rate.
3713
+ v.n_unstable += 1
3714
+ if not rates_mode:
3715
+ continue
3716
+ # Applicability (defect 1): the safety/coverage test runs only on
3717
+ # cases the candidate actually touches.
3718
+ if not self._applies(candidate, case):
3719
+ continue
3720
+ is_fail = getattr(case.label, "value", None) == "fail"
3721
+ if is_fail:
3722
+ applicable_fail += 1
3723
+ base_vec.append(rb if rates_mode else float(b))
3724
+ cand_vec.append(rc if rates_mode else float(c))
3725
+ if not b and c:
3726
+ v.n_fixed += 1
3727
+ v.fixed_cases.append(case.id)
3728
+ elif b and not c:
3729
+ v.n_broken += 1
3730
+ v.broken_cases.append(case.id)
3731
+ v.n_pairs = len(base_vec)
3732
+ v.n_baseline_correct = sum(1 for x in base_vec if x >= 0.5)
3733
+ v.n_candidate_correct = sum(1 for x in cand_vec if x >= 0.5)
3734
+ v.n_applicable = v.n_pairs
3735
+ v.coverage = (applicable_fail / n_fail) if n_fail else None
3736
+ if v.n_pairs:
3737
+ v.baseline_rate = sum(base_vec) / v.n_pairs
3738
+ v.candidate_rate = sum(cand_vec) / v.n_pairs
3739
+ if v.n_pairs == 0 and v.n_model_independent:
3740
+ v.verdict = "model_independent"
3741
+ v.summary = (
3742
+ f"model-independent: all {v.n_model_independent} failing case(s) it "
3743
+ "repairs it also repairs with the model frozen to its recorded answers "
3744
+ "(frozen-model control) — the pipeline solves the task itself; no "
3745
+ "model-attributable pair to test"
3746
+ )
3747
+ return v
3748
+ if v.n_pairs == 0:
3749
+ v.verdict = "not_executed"
3750
+ v.summary = (
3751
+ f"never executed: {v.exec_error}"
3752
+ if v.exec_error
3753
+ else "no applicable scorable pair — candidate unvalidatable"
3754
+ )
3755
+ return v
3756
+ try:
3757
+ if rates_mode:
3758
+ stat = compare_paired_rates(base_vec, cand_vec, alpha=self._alpha)
3759
+ v.e_value_regression = stat.details.get("e_value_regression")
3760
+ else:
3761
+ stat = compare([x >= 0.5 for x in base_vec], [x >= 0.5 for x in cand_vec],
3762
+ paired=True, alpha=self._alpha)
3763
+ except Exception as exc:
3764
+ v.verdict = "not_executed"
3765
+ v.summary = f"stats failed: {exc}"
3766
+ return v
3767
+ v.effect = stat.effect
3768
+ v.reject = bool(stat.reject)
3769
+ v.e_value = stat.e_value
3770
+ # Fixed = the paired test rejects with a net-positive effect: the
3771
+ # candidate repairs significantly more cases than it breaks.
3772
+ v.fixed = v.reject and (v.effect or 0.0) > 0
3773
+ v.verdict = self._verdict(v)
3774
+ cov = "" if v.coverage is None else f", coverage={v.coverage:.0%}"
3775
+ if rates_mode:
3776
+ noise = (f", {v.n_unstable} unstable weighed (k={v.n_baseline_samples} baseline"
3777
+ f"/{v.n_candidate_samples} candidate samples)" if v.n_unstable else
3778
+ f", k={v.n_baseline_samples}/{v.n_candidate_samples} samples")
3779
+ else:
3780
+ noise = f", {v.n_unstable} unstable dropped" if v.n_unstable else ""
3781
+ solo = (f", {v.n_model_independent} model-independent excluded"
3782
+ if v.n_model_independent else "")
3783
+ trunc = (f", {v.n_truncated} call(s) hit the decode cap"
3784
+ if v.n_truncated else "")
3785
+ v.summary = f"{stat.summary()} [{v.verdict}{cov}{noise}{solo}{trunc}]"
3786
+ return v
3787
+
3788
+ @staticmethod
3789
+ def _verdict(v: FixValidation) -> str:
3790
+ """Coarse triage label (defect 4): why a candidate did/didn't pass."""
3791
+ if v.fixed:
3792
+ return "fixed"
3793
+ if v.reject and (v.effect or 0.0) < 0:
3794
+ return "regressed" # significantly worse
3795
+ net = v.n_fixed - v.n_broken
3796
+ if net > 0:
3797
+ return "partial" # helped more than hurt, not significant
3798
+ if v.n_broken > v.n_fixed:
3799
+ return "unsafe" # breaks more than it fixes
3800
+ return "no_effect"
3801
+
3802
+ # -- recommendation + logging ------------------------------------------
3803
+
3804
+ def _recommend(
3805
+ self,
3806
+ routed: "list[FixTier]",
3807
+ *,
3808
+ model: "Model | None" = None,
3809
+ data: "CaseBatch | None" = None,
3810
+ reason_prefix: str = "",
3811
+ ) -> "dict[str, Any] | None":
3812
+ above = sorted(t for t in routed if t > self.max_tier)
3813
+ if above:
3814
+ target = above[0]
3815
+ reason = (
3816
+ f"verified hypotheses route to {target.label} "
3817
+ f"({target.describe()}), beyond the allowed {self.max_tier.label}"
3818
+ )
3819
+ elif self.max_tier < FixTier.L4_PARAMETERS:
3820
+ target = FixTier(self.max_tier + 1)
3821
+ reason = (
3822
+ f"no candidate within {self.max_tier.label} validated; the next "
3823
+ f"intervention space is {target.describe()}"
3824
+ )
3825
+ else:
3826
+ return None # already at L4 — nothing above to recommend
3827
+ skipped: "list[str]" = []
3828
+ while (
3829
+ model is not None
3830
+ and target
3831
+ in {
3832
+ FixTier.L3A_INTERNALS_READ,
3833
+ FixTier.L3B_INTERNALS_WRITE,
3834
+ }
3835
+ and not self._tier_available(target, model, data=data)
3836
+ ):
3837
+ skipped.append(target.label)
3838
+ if target >= FixTier.L4_PARAMETERS:
3839
+ return None
3840
+ target = FixTier(target + 1)
3841
+ if skipped:
3842
+ reason += (
3843
+ f"; skipped unsupported tier(s) {', '.join(skipped)} for this model "
3844
+ f"and routed to {target.describe()}"
3845
+ )
3846
+ if reason_prefix:
3847
+ reason = f"{reason_prefix}; {reason}"
3848
+ return {"recommend_tier": target.label, "reason": reason}
3849
+
3850
+ def _tier_available(
3851
+ self,
3852
+ tier: FixTier,
3853
+ model: "Model",
3854
+ *,
3855
+ data: "CaseBatch | None" = None,
3856
+ ) -> bool:
3857
+ """Whether an invasive tier has an executor compatible with this task."""
3858
+ if data is not None and tier in {
3859
+ FixTier.L3A_INTERNALS_READ,
3860
+ FixTier.L3B_INTERNALS_WRITE,
3861
+ }:
3862
+ has_images = any(
3863
+ getattr(c.inputs, "image", None) is not None
3864
+ or getattr(c.inputs, "video", None) is not None
3865
+ for c in data
3866
+ )
3867
+ has_audio = any(getattr(c.inputs, "audio", None) is not None for c in data)
3868
+ tasks = {str((getattr(c, "metadata", {}) or {}).get("task", "")) for c in data}
3869
+ registered = any(
3870
+ method.tier == tier
3871
+ for method in discover_methods(
3872
+ model,
3873
+ max_tier=tier,
3874
+ has_images=has_images,
3875
+ has_audio=has_audio,
3876
+ tasks=tasks,
3877
+ allow_adapted=self._allow_adapted_paper_methods,
3878
+ )
3879
+ )
3880
+ if registered:
3881
+ return True
3882
+ if tier == FixTier.L3A_INTERNALS_READ:
3883
+ from evalrx.core.capability import Capability
3884
+
3885
+ return bool(
3886
+ self._allow_codegen
3887
+ and Capability.ATTENTION in getattr(model, "capabilities", frozenset())
3888
+ )
3889
+ return bool(
3890
+ has_images
3891
+ and any(
3892
+ primitive.tier == tier and primitive.available(model)
3893
+ for primitive in INTERNALS_PRIMITIVES.values()
3894
+ )
3895
+ )
3896
+ if tier == FixTier.L3A_INTERNALS_READ:
3897
+ from evalrx.core.capability import Capability
3898
+
3899
+ return (
3900
+ Capability.ATTENTION in getattr(model, "capabilities", frozenset())
3901
+ or supports_tier(model, tier)
3902
+ )
3903
+ if tier == FixTier.L3B_INTERNALS_WRITE:
3904
+ return any(
3905
+ primitive.tier == tier and primitive.available(model)
3906
+ for primitive in INTERNALS_PRIMITIVES.values()
3907
+ )
3908
+ return True
3909
+
3910
+ def _emit(self, outcome: FixOutcome) -> None:
3911
+ if self.run_logger is None:
3912
+ return
3913
+ try:
3914
+ self.run_logger.log_fix(outcome)
3915
+ except Exception as exc: # logging must never break the fix step
3916
+ logger.debug("FixAgent: log_fix failed: %s", exc)