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,725 @@
1
+ """L2 coded pipelines — agent-written repair scaffolds with bridged model access.
2
+
3
+ The declarative :class:`~.fix_tools.PipelineSpec` path covers fixed-shape
4
+ scaffolds (catalog tools + one templated call). Real L2 means the coding
5
+ agent writes a **brand-new pipeline**: multiple model calls per case, branching
6
+ on intermediate outputs (ask for a region → zoom → re-ask, describe → decide,
7
+ majority vote, …) — with the single constraint that the *unchanged* model is
8
+ used. Such adaptive pipelines cannot be pre-computed, so the usual
9
+ collect-then-compute split does not apply. Instead:
10
+
11
+ * The generated code runs in a **sandbox subprocess** (never sees the repo,
12
+ the weights, or the scoring rubric).
13
+ * Model access goes through a **bridge**: the injected ``model_generate()``
14
+ helper writes a ``@@MODEL_CALL@@{json}`` line to stdout and waits for the
15
+ reply on stdin; the host services each call (applies catalog image tools to
16
+ the case's image, runs ``model.generate``) under a per-session call budget
17
+ and wall-clock deadline. Requests carry a ``rid`` echoed by the reply, so
18
+ the sandbox helper is thread-safe and the host can service several calls at
19
+ once (``concurrency=``) — a pipeline that fans out over cases with a thread
20
+ pool really runs in parallel.
21
+ * The script's final ``FIX_PIPELINE_RESULT_JSON=`` line carries per-case final
22
+ answers; **scoring stays host-side** — the case payload shipped to the
23
+ sandbox contains only ``id``, ``prompt`` and the model's own
24
+ ``baseline_output`` (no labels, no expected rubric), so generated code
25
+ cannot cheat by echoing gold answers or flipping known failures.
26
+ * **Selection safety is anchored on the recorded baseline.** A case's
27
+ ``baseline_output`` IS the direct baseline: a plain
28
+ ``model_generate(case_id)`` (no prompt override, no image ops, no decoding
29
+ overrides) is answered from that record — it costs no model time and does
30
+ not count against the per-case call cap — and the host's consensus guard
31
+ anchors on the same text whether or not the pipeline made that call. A
32
+ final answer that differs from the anchor stands only when at least
33
+ ``consensus_min_support`` enhanced calls with distinct signatures returned
34
+ it; otherwise the case reverts to the anchor. Only a case with neither a
35
+ recorded baseline nor a direct call cannot be anchored; such a case is
36
+ excluded from the result — never the whole candidate. (Before 2026-08-21
37
+ the guard demanded a real direct call per case and voided the whole
38
+ candidate otherwise; every coder-written round tripped it, because the
39
+ prompt hands the coder ``baseline_output`` and a rational coder uses it.)
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import json
45
+ import logging
46
+ import re
47
+ import subprocess
48
+ import sys
49
+ import threading
50
+ import time
51
+ from concurrent.futures import ThreadPoolExecutor
52
+ from dataclasses import dataclass, field
53
+ from pathlib import Path
54
+ from typing import TYPE_CHECKING, Any, Callable, Optional
55
+
56
+ from evalrx.eval_agent.stages.fix_tools import apply_image_ops, score_to_bool
57
+
58
+ if TYPE_CHECKING:
59
+ from evalrx.core.case import CaseBatch
60
+ from evalrx.core.model import Model
61
+
62
+ logger = logging.getLogger(__name__)
63
+
64
+ CASES_FILENAME = "fix_cases.json"
65
+ CALL_MARKER = "@@MODEL_CALL@@"
66
+ RESULT_MARKER = "FIX_PIPELINE_RESULT_JSON="
67
+
68
+ # Injected ahead of the generated code: the only channel to the model.
69
+ _PRELUDE = '''\
70
+ import json as _json
71
+ import sys as _sys
72
+ import threading as _threading
73
+ import itertools as _itertools
74
+
75
+ # Bridge plumbing — request-id tagged so that CONCURRENT callers (threads) each
76
+ # get their own reply. One reader thread owns stdin and routes replies by id.
77
+ _bridge_lock = _threading.Lock()
78
+ _bridge_pending = {{}}
79
+ _bridge_ids = _itertools.count(1)
80
+ _bridge_reader = []
81
+
82
+
83
+ def _bridge_read_loop():
84
+ while True:
85
+ line = _sys.stdin.readline()
86
+ if not line:
87
+ with _bridge_lock:
88
+ for slot in _bridge_pending.values():
89
+ slot[1] = {{"error": "model bridge closed"}}
90
+ slot[0].set()
91
+ return
92
+ try:
93
+ resp = _json.loads(line)
94
+ except Exception:
95
+ continue
96
+ with _bridge_lock:
97
+ slot = _bridge_pending.get(resp.get("rid"))
98
+ if slot is not None:
99
+ slot[1] = resp
100
+ slot[0].set()
101
+
102
+
103
+ def _bridge_call(payload):
104
+ rid = next(_bridge_ids)
105
+ done = _threading.Event()
106
+ payload["rid"] = rid
107
+ with _bridge_lock:
108
+ if not _bridge_reader:
109
+ t = _threading.Thread(target=_bridge_read_loop, daemon=True)
110
+ t.start()
111
+ _bridge_reader.append(t)
112
+ _bridge_pending[rid] = [done, None]
113
+ _sys.stdout.write("{call_marker}" + _json.dumps(payload) + "\\n")
114
+ _sys.stdout.flush()
115
+ done.wait()
116
+ with _bridge_lock:
117
+ resp = _bridge_pending.pop(rid)[1] or {{"error": "model bridge closed"}}
118
+ if resp.get("error"):
119
+ raise RuntimeError(resp["error"])
120
+ return resp
121
+
122
+
123
+ def model_generate(case_id, prompt=None, image_ops=None, generation_kwargs=None):
124
+ """Call the original model on one case (host-mediated bridge).
125
+
126
+ Thread-safe: concurrent calls (e.g. from a ThreadPoolExecutor over cases)
127
+ are serviced in parallel by the host.
128
+
129
+ generation_kwargs: optional bounded decoding controls
130
+ ({{"max_tokens", "temperature", "top_p", "stop"}}); anything else is
131
+ dropped host-side, and max_tokens can only RAISE the baseline budget.
132
+ """
133
+ return _bridge_call(
134
+ {{"case_id": case_id, "prompt": prompt, "image_ops": image_ops or [],
135
+ "generation_kwargs": generation_kwargs or {{}}}}).get("output", "")
136
+
137
+
138
+ def model_attend(case_id, prompt=None):
139
+ """Read the model's attention heatmap over image patches (host-mediated).
140
+
141
+ Returns {{"grid": [[float, ...], ...], "shape": [H, W]}} — only available
142
+ when the fix tier allows internals read (L3a+) on a white-box model.
143
+ """
144
+ return _bridge_call({{"op": "attend", "case_id": case_id, "prompt": prompt}})
145
+
146
+ '''
147
+
148
+ def cases_payload(cases: "CaseBatch") -> "dict[str, Any]":
149
+ """Serialise cases for the sandbox: id, prompt and the model's ORIGINAL
150
+ answer — never a label, expected answer, or rubric.
151
+
152
+ ``baseline_output`` is the recorded baseline generation (``case.observed``,
153
+ ``None`` when the case carries none). It is not a label: it is what the
154
+ unchanged model already said, which a scaffold may legitimately compare
155
+ against, vote with, or ask the model to double-check. It carries no
156
+ information about correctness — the frozen-model control replays exactly
157
+ this text, so a pipeline that merely echoes it scores as the baseline.
158
+
159
+ It is also the DIRECT BASELINE the host's selection guard anchors on: a
160
+ plain ``model_generate(case_id)`` is answered from this record, and a
161
+ pipeline that never makes that call is anchored on it all the same.
162
+ """
163
+ out = []
164
+ for c in cases:
165
+ observed = getattr(c, "observed", None)
166
+ out.append({
167
+ "id": c.id,
168
+ "prompt": str(getattr(getattr(c, "inputs", None), "prompt", "")),
169
+ "baseline_output": None if observed is None else str(observed),
170
+ })
171
+ return {"cases": out}
172
+
173
+
174
+ @dataclass
175
+ class CodedPipelineResult:
176
+ """Outcome of one bridged pipeline session."""
177
+
178
+ outputs: "dict[str, str]" = field(default_factory=dict) # case id -> final answer
179
+ n_calls: int = 0
180
+ ok: bool = False
181
+ error: str = ""
182
+ n_guarded: int = 0
183
+ guarded_ids: "list[str]" = field(default_factory=list)
184
+ # Plain direct calls answered from the recorded baseline (no model time).
185
+ n_replayed: int = 0
186
+ # Cases the guard anchored on ``case.observed`` because the pipeline made
187
+ # no direct call (the common, legitimate shape since the coder is handed
188
+ # ``baseline_output``).
189
+ n_anchored_from_recorded: int = 0
190
+ # Cases with neither a recorded baseline nor a direct call: nothing to
191
+ # anchor the guard on, so they are dropped from ``outputs`` (scored as
192
+ # not measured) instead of voiding the candidate.
193
+ unanchored_ids: "list[str]" = field(default_factory=list)
194
+
195
+
196
+ def run_coded_pipeline(
197
+ code: str,
198
+ model: "Model | None",
199
+ cases: "CaseBatch",
200
+ workdir: "Path | str",
201
+ timeout_sec: int = 600,
202
+ max_calls: "int | None" = None,
203
+ enable_attend: bool = False,
204
+ reply_fn: "Callable[[Any, str], str] | None" = None,
205
+ max_tokens_floor: "int | None" = None,
206
+ concurrency: int = 1,
207
+ consensus_min_support: int = 0,
208
+ max_calls_per_case: int = 0,
209
+ call_logger: "Callable[[dict[str, Any]], None] | None" = None,
210
+ ) -> CodedPipelineResult:
211
+ """Execute agent-written pipeline *code* with bridged model access.
212
+
213
+ The subprocess gets ``fix_cases.json`` + the ``model_generate`` prelude;
214
+ every bridge call is serviced here (image tools applied host-side, model
215
+ invoked host-side). Returns the per-case final answers for host-side
216
+ scoring.
217
+
218
+ ``concurrency``: how many bridged calls the host services at once. Every
219
+ request carries a ``rid`` and the reply echoes it, so the sandbox side is
220
+ thread-safe — generated code that fans out over cases with a thread pool
221
+ really runs in parallel (with ``concurrency=1`` calls are serviced strictly
222
+ in arrival order, the old behaviour). Without the ids a threaded pipeline
223
+ silently read each other's replies: the reply to case A's prompt could
224
+ land in case B's vote.
225
+
226
+ ``reply_fn(case, prompt) -> str``, when given, answers every bridged call
227
+ instead of the model (which is then never touched) — the hook
228
+ :func:`frozen_model_control` uses to re-run the same code with the model
229
+ held at its recorded answers.
230
+
231
+ ``max_tokens_floor``: the baseline decode budget. A bridged call's
232
+ ``generation_kwargs["max_tokens"]`` below it is raised to it — a scaffold
233
+ may give the model MORE room than the baseline had, never less (a shorter
234
+ budget truncates the chain and scores as a wrong answer, which is a
235
+ decoding artefact, not evidence about the repair).
236
+
237
+ ``consensus_min_support`` (> 0 switches the gold-free selection guard
238
+ on): a case's final answer may differ from its anchor — the recorded
239
+ ``case.observed``, or the pipeline's own direct call when the case has no
240
+ record — only when at least that many enhanced calls with DISTINCT
241
+ signatures (prompt + image_ops) returned the same answer; otherwise the
242
+ case is reverted to the anchor (``guarded_ids``). A case with neither a
243
+ record nor a direct call is unanchorable and is excluded
244
+ (``unanchored_ids``), never the whole candidate.
245
+
246
+ ``max_calls_per_case`` (> 0): cap on bridged calls per case that HIT the
247
+ model. A plain direct call on a case with a recorded baseline is answered
248
+ from the record (``n_replayed``) and is free under the cap — there is
249
+ nothing to select from in re-reading the baseline.
250
+ """
251
+ from evalrx.core.case import Inputs
252
+
253
+ res = CodedPipelineResult()
254
+ case_by_id = {c.id: c for c in cases}
255
+ budget = max_calls if max_calls is not None else 6 * len(case_by_id) + 10
256
+
257
+ # Resolved to absolute: the subprocess below runs with cwd=workdir *and*
258
+ # a script path built from that same workdir — a relative workdir makes
259
+ # the child resolve the script path a second time relative to its new
260
+ # cwd, doubling it (FileNotFoundError instead of running the script).
261
+ workdir = Path(workdir).resolve()
262
+ workdir.mkdir(parents=True, exist_ok=True)
263
+ (workdir / CASES_FILENAME).write_text(
264
+ json.dumps(cases_payload(cases)), encoding="utf-8")
265
+ script = workdir / "fix_pipeline_exec.py"
266
+ script.write_text(
267
+ _PRELUDE.format(call_marker=CALL_MARKER) + "\n" + code, encoding="utf-8")
268
+
269
+ proc = subprocess.Popen(
270
+ [sys.executable, str(script)],
271
+ cwd=str(workdir),
272
+ stdin=subprocess.PIPE,
273
+ stdout=subprocess.PIPE,
274
+ stderr=subprocess.PIPE,
275
+ text=True,
276
+ encoding="utf-8",
277
+ errors="replace",
278
+ bufsize=1,
279
+ )
280
+
281
+ stderr_tail: "list[str]" = []
282
+
283
+ def _drain_stderr() -> None:
284
+ for line in proc.stderr: # type: ignore[union-attr]
285
+ stderr_tail.append(line)
286
+ del stderr_tail[:-30]
287
+
288
+ threading.Thread(target=_drain_stderr, daemon=True).start()
289
+ timed_out = threading.Event()
290
+
291
+ def _kill_on_timeout() -> None:
292
+ timed_out.set()
293
+ proc.kill()
294
+
295
+ watchdog = threading.Timer(timeout_sec, _kill_on_timeout)
296
+ watchdog.start()
297
+ deadline = time.monotonic() + timeout_sec
298
+
299
+ result_line: "str | None" = None
300
+ call_records: "dict[str, list[tuple[bool, str, str]]]" = {}
301
+ calls_per_case: "dict[str, int]" = {}
302
+ unmarked_tail: "list[str]" = [] # fallback: last non-marker stdout lines
303
+ stdin_lock = threading.Lock()
304
+ records_lock = threading.Lock()
305
+ pool = (ThreadPoolExecutor(max_workers=max(1, int(concurrency)))
306
+ if int(concurrency) > 1 else None)
307
+
308
+ def _rid_of(raw: str) -> "Any":
309
+ try:
310
+ return json.loads(raw).get("rid")
311
+ except Exception:
312
+ return None
313
+
314
+ def _send(reply: "dict[str, Any]") -> bool:
315
+ with stdin_lock:
316
+ try:
317
+ proc.stdin.write(json.dumps(reply) + "\n") # type: ignore[union-attr]
318
+ proc.stdin.flush() # type: ignore[union-attr]
319
+ return True
320
+ except (BrokenPipeError, OSError, ValueError):
321
+ return False
322
+
323
+ def _serve(raw: str, request: "dict[str, Any]", case_id: str) -> bool:
324
+ call_started = time.perf_counter()
325
+ case = case_by_id.get(case_id)
326
+ replayed = reply_fn is None and _replayable_direct(request, case)
327
+ if replayed:
328
+ # The direct baseline is already on record: answer from it. Under
329
+ # greedy decoding a fresh call would return the same text; under
330
+ # sampling the record is the frozen baseline arm the paired test
331
+ # compares against, so replaying it keeps anchor and baseline
332
+ # identical (a resample could drift from both).
333
+ reply: "dict[str, Any]" = {"output": str(case.observed)}
334
+ with records_lock:
335
+ res.n_replayed += 1
336
+ else:
337
+ reply = _service_call(raw, case_by_id, model, Inputs, enable_attend,
338
+ reply_fn, max_tokens_floor=max_tokens_floor)
339
+ reply["rid"] = _rid_of(raw)
340
+ if call_logger is not None:
341
+ try:
342
+ call_logger({
343
+ "case_id": case_id,
344
+ "request": request,
345
+ "response": {k: v for k, v in reply.items() if k != "rid"},
346
+ "replayed_from_recorded_baseline": replayed,
347
+ "duration_sec": time.perf_counter() - call_started,
348
+ })
349
+ except Exception as exc: # logging must never break the bridge
350
+ logger.debug("fix_pipeline: call logger failed: %s", exc)
351
+ # Record the call for the gold-free consensus guard: was it the direct
352
+ # baseline (untouched prompt, no ops), and what did it answer?
353
+ try:
354
+ if "output" in reply and case is not None:
355
+ supplied_prompt = request.get("prompt")
356
+ is_direct = _is_direct_request(request, case)
357
+ signature = json.dumps(
358
+ {
359
+ "prompt": supplied_prompt,
360
+ "image_ops": request.get("image_ops") or [],
361
+ },
362
+ sort_keys=True,
363
+ )
364
+ with records_lock:
365
+ call_records.setdefault(case_id, []).append(
366
+ (is_direct, signature, str(reply.get("output", "")))
367
+ )
368
+ except (TypeError, ValueError, json.JSONDecodeError):
369
+ pass
370
+ return _send(reply)
371
+
372
+ try:
373
+ for line in proc.stdout: # type: ignore[union-attr]
374
+ stripped = line.strip()
375
+ if stripped.startswith(CALL_MARKER):
376
+ res.n_calls += 1
377
+ if res.n_calls % 100 == 0:
378
+ logger.info(
379
+ "fix_pipeline: serviced %d model calls (budget=%d)",
380
+ res.n_calls,
381
+ budget,
382
+ )
383
+ if res.n_calls > budget or time.monotonic() > deadline:
384
+ res.error = f"model-call budget exhausted ({budget} calls)"
385
+ proc.kill()
386
+ break
387
+ raw = stripped[len(CALL_MARKER):]
388
+ # Selection-safety accounting runs on THIS (serial) reader
389
+ # thread before dispatch, so the per-case cap is deterministic
390
+ # under concurrency too; the reply itself may be serviced by
391
+ # the pool.
392
+ try:
393
+ request = json.loads(raw)
394
+ case_id = str(request.get("case_id", ""))
395
+ # A plain direct call on a recorded case is answered from
396
+ # the record and never touches the model: free under the
397
+ # cap (in the live run AND the frozen control, so the
398
+ # same code is accounted identically in both).
399
+ if not _replayable_direct(request, case_by_id.get(case_id)):
400
+ calls_per_case[case_id] = calls_per_case.get(case_id, 0) + 1
401
+ if (
402
+ max_calls_per_case > 0
403
+ and calls_per_case.get(case_id, 0) > max_calls_per_case
404
+ ):
405
+ res.error = (
406
+ "selection-safety contract violated: more than "
407
+ f"{max_calls_per_case} model calls for case {case_id!r}"
408
+ )
409
+ proc.kill()
410
+ break
411
+ except (TypeError, ValueError, json.JSONDecodeError):
412
+ request, case_id = {}, ""
413
+ if pool is not None:
414
+ pool.submit(_serve, raw, request, case_id)
415
+ else:
416
+ if not _serve(raw, request, case_id):
417
+ break
418
+ elif stripped.startswith(RESULT_MARKER):
419
+ result_line = stripped[len(RESULT_MARKER):]
420
+ elif stripped:
421
+ unmarked_tail.append(stripped)
422
+ del unmarked_tail[:-20]
423
+ proc.wait(timeout=10)
424
+ except Exception as exc:
425
+ res.error = res.error or f"bridge session failed: {exc}"
426
+ proc.kill()
427
+ finally:
428
+ watchdog.cancel()
429
+ if pool is not None:
430
+ # In-flight model calls finish on their own (the reply is dropped
431
+ # on a closed pipe); queued ones are cancelled.
432
+ pool.shutdown(wait=False, cancel_futures=True)
433
+
434
+ if result_line is None and not timed_out.is_set():
435
+ # A judge-written pipeline sometimes gets the JSON payload right but
436
+ # drops the exact literal marker prefix the prompt asked for (a
437
+ # compliance slip, not a content error, and the one-shot repair
438
+ # round tends to repeat it). Recover the payload straight from the
439
+ # last unmarked stdout line if it parses as the expected shape,
440
+ # rather than discarding a working pipeline over a missing prefix.
441
+ for candidate_line in reversed(unmarked_tail):
442
+ try:
443
+ parsed = json.loads(candidate_line)
444
+ except json.JSONDecodeError:
445
+ continue
446
+ if isinstance(parsed, dict) and isinstance(parsed.get("per_case"), list):
447
+ result_line = candidate_line
448
+ logger.info(
449
+ "fix_pipeline: recovered result JSON without the literal "
450
+ "%s prefix", RESULT_MARKER,
451
+ )
452
+ break
453
+
454
+ if result_line is None:
455
+ if timed_out.is_set():
456
+ # A kill looks like a silent exit (empty stderr) — name the cause,
457
+ # or the caller misreads an under-budgeted run as broken code.
458
+ res.error = res.error or (
459
+ f"timed out after {timeout_sec}s and was killed (bridge served "
460
+ f"{res.n_calls} model calls) — raise exec_timeout_sec or shrink "
461
+ "the validation batch"
462
+ )
463
+ else:
464
+ res.error = res.error or (
465
+ "no FIX_PIPELINE_RESULT_JSON line; stderr tail: "
466
+ + "".join(stderr_tail)[-400:].strip()
467
+ )
468
+ return res
469
+ try:
470
+ per_case = json.loads(result_line).get("per_case", [])
471
+ except json.JSONDecodeError as exc:
472
+ res.error = f"unparseable result line: {exc}"
473
+ return res
474
+ for entry in per_case:
475
+ if isinstance(entry, dict) and str(entry.get("sample_id", "")) in case_by_id:
476
+ res.outputs[str(entry["sample_id"])] = str(entry.get("output", ""))
477
+ if consensus_min_support > 0:
478
+ for case_id, final_output in list(res.outputs.items()):
479
+ records = call_records.get(case_id, [])
480
+ # Anchor: the recorded baseline when the case has one (the same
481
+ # text a plain direct call is answered with, and what the paired
482
+ # test's baseline arm holds); else the pipeline's own direct call.
483
+ observed = getattr(case_by_id[case_id], "observed", None)
484
+ direct = next(
485
+ (output for is_direct, _signature, output in records if is_direct), None
486
+ )
487
+ if observed is not None:
488
+ if direct is None:
489
+ res.n_anchored_from_recorded += 1
490
+ anchor = str(observed)
491
+ elif direct is not None:
492
+ anchor = direct
493
+ else:
494
+ # Nothing to anchor on — this case cannot be guarded, so it
495
+ # leaves the result (scored as not measured). The candidate
496
+ # as a whole is not voided for it.
497
+ res.unanchored_ids.append(case_id)
498
+ del res.outputs[case_id]
499
+ continue
500
+ if _answers_match(final_output, anchor):
501
+ continue
502
+ support = len({
503
+ signature for is_direct, signature, output in records
504
+ if not is_direct and _answers_match(final_output, output)
505
+ })
506
+ if support < consensus_min_support:
507
+ res.outputs[case_id] = anchor
508
+ res.guarded_ids.append(case_id)
509
+ res.n_guarded = len(res.guarded_ids)
510
+ if res.unanchored_ids and not res.outputs:
511
+ res.error = (
512
+ "selection-safety contract violated: no recorded baseline and "
513
+ "no direct baseline model_generate(case_id) call for "
514
+ f"{len(res.unanchored_ids)} case(s) — nothing to anchor the "
515
+ "selection guard on"
516
+ )
517
+ return res
518
+ res.ok = bool(res.outputs)
519
+ return res
520
+
521
+
522
+ def _is_direct_request(request: "dict[str, Any]", case: "Any") -> bool:
523
+ """A bridged call that re-asks the case as is: no op, no image ops, and
524
+ either no prompt or the case's own prompt (decoding overrides do not make
525
+ it an enhanced pass — they do make it a live call, see
526
+ :func:`_replayable_direct`)."""
527
+ if request.get("op") is not None or request.get("image_ops"):
528
+ return False
529
+ supplied = request.get("prompt")
530
+ if supplied is None:
531
+ return True
532
+ original = str(getattr(getattr(case, "inputs", None), "prompt", ""))
533
+ return str(supplied) == original
534
+
535
+
536
+ def _replayable_direct(request: "dict[str, Any]", case: "Any") -> bool:
537
+ """A plain direct call on a case that carries its recorded baseline: the
538
+ host answers it from the record instead of re-generating, and it is free
539
+ under the per-case cap (nothing to select from in re-reading the
540
+ baseline). Decoding overrides (``generation_kwargs``) ask for a genuinely
541
+ new generation, which still hits the model and counts."""
542
+ if case is None or getattr(case, "observed", None) is None:
543
+ return False
544
+ if request.get("generation_kwargs"):
545
+ return False
546
+ return _is_direct_request(request, case)
547
+
548
+
549
+ # Answer tags a scaffold commonly asks the model to emit ("FINAL: 42",
550
+ # "Answer: 42", "Prediction: yes"). The guard compares the pipeline's
551
+ # EXTRACTED final answer with the model's RAW replies; without stripping the
552
+ # tag, a reply "FINAL: 42" can never support the answer "42" and every
553
+ # tag-style override is reverted as unsupported (chartqa/qwen3.5-2b smoke,
554
+ # 2026-08-21: repair round scored no_effect 0/0 for exactly this reason).
555
+ _ANSWER_TAG_RE = re.compile(
556
+ r"^(?:final(?:\s+answer)?|answer|result|output|prediction|response|verdict)"
557
+ r"\s*[:=]\s*",
558
+ flags=re.IGNORECASE,
559
+ )
560
+
561
+
562
+ def _answer_key(value: str) -> str:
563
+ """Best-effort answer-only key used by the gold-free consensus guard."""
564
+ text = str(value or "").strip()
565
+ matches = re.findall(
566
+ r"(?:final\s+answer|answer)\s*[:=]\s*([^\n]+)", text,
567
+ flags=re.IGNORECASE,
568
+ )
569
+ if matches:
570
+ text = matches[-1]
571
+ else:
572
+ lines = [line.strip() for line in text.splitlines() if line.strip()]
573
+ if lines:
574
+ text = lines[-1]
575
+ text = _ANSWER_TAG_RE.sub("", text.strip())
576
+ text = re.sub(r"[`*_]+", "", text).strip().lower()
577
+ text = re.sub(r"^(?:the\s+answer\s+is|it\s+is)\s+", "", text)
578
+ text = text.strip(" .,:;!?\"'")
579
+ return re.sub(r"[^\w.%+\-/]+", " ", text).strip()
580
+
581
+
582
+ def _answers_match(left: str, right: str) -> bool:
583
+ left_key, right_key = _answer_key(left), _answer_key(right)
584
+ if not left_key or not right_key:
585
+ return False
586
+ return left_key == right_key
587
+
588
+
589
+ def _service_call(
590
+ raw: str,
591
+ case_by_id: "dict[str, Any]",
592
+ model: "Model | None",
593
+ inputs_cls: "type",
594
+ enable_attend: bool = False,
595
+ reply_fn: "Callable[[Any, str], str] | None" = None,
596
+ max_tokens_floor: "int | None" = None,
597
+ ) -> "dict[str, Any]":
598
+ """Handle one bridged model call; never raises (errors travel as JSON)."""
599
+ import dataclasses
600
+
601
+ from evalrx.eval_agent.stages.fix_tools import _safe_generation_kwargs
602
+
603
+ try:
604
+ req = json.loads(raw)
605
+ case = case_by_id.get(str(req.get("case_id", "")))
606
+ if case is None:
607
+ return {"error": f"unknown case_id {req.get('case_id')!r}"}
608
+ if req.get("op") == "attend":
609
+ if not enable_attend:
610
+ return {"error": "model_attend requires fix tier >= L3a on a "
611
+ "white-box model"}
612
+ from evalrx.analyzers.attention.relative_attn import attention_heatmap
613
+
614
+ grid = attention_heatmap(model, case)
615
+ if grid is None:
616
+ return {"error": "attention capture failed for this case"}
617
+ return {"grid": grid.tolist(), "shape": list(grid.shape)}
618
+ inp = getattr(case, "inputs", None)
619
+ prompt = req.get("prompt") or str(getattr(inp, "prompt", ""))
620
+ image = getattr(inp, "image", None)
621
+ ops = req.get("image_ops") or []
622
+ if ops:
623
+ # Strict contract: a malformed op silently skipped hides the bug
624
+ # from the coding agent forever; an error reply becomes a
625
+ # RuntimeError inside the generated code — visible and repairable.
626
+ from evalrx.eval_agent.stages.fix_tools import IMAGE_TOOLS
627
+
628
+ bad = [op for op in ops if not isinstance(op, dict) or not op.get("tool")]
629
+ unknown = [str(op["tool"]) for op in ops
630
+ if isinstance(op, dict) and op.get("tool")
631
+ and str(op["tool"]) not in IMAGE_TOOLS]
632
+ if bad or unknown:
633
+ return {"error": (
634
+ "invalid image_ops — each op must be "
635
+ "{'tool': <name>, 'params': {...}}"
636
+ + (f"; unknown tool(s): {', '.join(unknown)}" if unknown else "")
637
+ + "; available tools: " + ", ".join(IMAGE_TOOLS)
638
+ )}
639
+ if reply_fn is not None:
640
+ # Same contract as the real bridge (unknown case, bad image_ops
641
+ # still error) — only the answer comes from elsewhere.
642
+ return {"output": str(reply_fn(case, str(prompt)))}
643
+ if ops:
644
+ image = apply_image_ops(image, ops)
645
+ if model is None:
646
+ return {"error": "no model behind the bridge"}
647
+ gen_kwargs = _safe_generation_kwargs(req.get("generation_kwargs"))
648
+ # ``do_sample`` is an internal normalization used by declarative
649
+ # PipelineSpec execution. The code bridge exposes only the documented
650
+ # portable controls; adapters infer their sampling mode from a positive
651
+ # temperature where appropriate.
652
+ gen_kwargs.pop("do_sample", None)
653
+ if (
654
+ max_tokens_floor
655
+ and "max_tokens" in gen_kwargs
656
+ and gen_kwargs["max_tokens"] < int(max_tokens_floor)
657
+ ):
658
+ gen_kwargs["max_tokens"] = int(max_tokens_floor)
659
+ # dataclasses.replace keeps .video/.audio (a bare Inputs(prompt=,
660
+ # image=) dropped them — the same modality bug run_pipeline had).
661
+ if inp is not None and dataclasses.is_dataclass(inp):
662
+ new_inputs = dataclasses.replace(inp, prompt=str(prompt), image=image)
663
+ else:
664
+ new_inputs = inputs_cls(prompt=str(prompt), image=image)
665
+ return {"output": str(model.generate(new_inputs, **gen_kwargs))}
666
+ except Exception as exc:
667
+ return {"error": f"{type(exc).__name__}: {exc}"}
668
+
669
+
670
+ def frozen_model_control(
671
+ code: str,
672
+ cases: "CaseBatch",
673
+ workdir: "Path | str",
674
+ timeout_sec: int = 600,
675
+ max_calls: "int | None" = None,
676
+ consensus_min_support: int = 0,
677
+ max_calls_per_case: int = 0,
678
+ ) -> CodedPipelineResult:
679
+ """Re-run *code* with the model frozen: every bridged call is answered with
680
+ the case's recorded baseline output (``case.observed``; ``""`` when a case
681
+ has none).
682
+
683
+ A coded pipeline is only ever asked to repair the MODEL, but nothing in the
684
+ sandbox stops it from repairing the TASK: qwen3.5-2b / bbh_word_sorting
685
+ validated a pipeline whose final answer was ``sorted(input_words(prompt))``
686
+ with the model call accepted only when it already equalled that — 124/125
687
+ correct, e=8.6e15, "FIXED", and 113 model calls that changed nothing. Any
688
+ task with a mechanical oracle (sorting, arithmetic, dates: much of BBH)
689
+ invites the same move, and the timeout-repair round makes it worse.
690
+
691
+ Under this control the model cannot behave differently than it already
692
+ did, so a FAILING case that comes out right was solved by the pipeline's
693
+ own computation. The caller excludes such cases from the paired test
694
+ (baseline-correct cases are kept: replaying a right answer proves
695
+ nothing, and dropping them would hide what the candidate breaks).
696
+
697
+ Replaying the recorded answer rather than returning ``""`` matters:
698
+ real answers are non-empty and well-formed, so a solver gated on "the
699
+ model said something parseable" is still caught, and a legitimate
700
+ pipeline's benign empty-answer default is not tripped by an artefact of
701
+ the control.
702
+ """
703
+ def _replay(case: "Any", prompt: str) -> str:
704
+ observed = getattr(case, "observed", None)
705
+ return "" if observed is None else str(observed)
706
+
707
+ return run_coded_pipeline(
708
+ code, None, cases, workdir=workdir, timeout_sec=timeout_sec,
709
+ max_calls=max_calls, reply_fn=_replay,
710
+ consensus_min_support=consensus_min_support,
711
+ max_calls_per_case=max_calls_per_case,
712
+ )
713
+
714
+
715
+ def score_outputs(
716
+ result: CodedPipelineResult,
717
+ cases: "CaseBatch",
718
+ score_fn: "Callable[[Any, str], Optional[bool]]",
719
+ ) -> "dict[str, Optional[bool]]":
720
+ """Host-side scoring of the pipeline's final answers (rubrics never left)."""
721
+ scores: "dict[str, Optional[bool]]" = {}
722
+ for case in cases:
723
+ output = result.outputs.get(case.id)
724
+ scores[case.id] = None if output is None else score_to_bool(score_fn(case, output))
725
+ return scores