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,1034 @@
1
+ """L2 scaffold tool catalog + pipeline executor for the fix module.
2
+
3
+ An L2 candidate fix wraps the *unchanged* model in a small pipeline: image
4
+ preprocessing tools applied to the case image, an optional prompt template,
5
+ and optional multi-sample aggregation. Tools are a registered catalog (the
6
+ judge selects and parameterises them — same select-from-catalog pattern as
7
+ M1's analyzers); pipeline specs are plain dicts so they serialise into run
8
+ logs and can be re-executed.
9
+
10
+ PIL is imported lazily — the core package does not depend on pillow; any
11
+ environment that loads images for a VLM already has it.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import re
18
+ from dataclasses import dataclass, field
19
+ from typing import TYPE_CHECKING, Any, Callable, Optional
20
+
21
+ from evalrx.core.model import Model
22
+
23
+ if TYPE_CHECKING:
24
+ from evalrx.core.case import FailureCase
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Scoring helpers
31
+ # ---------------------------------------------------------------------------
32
+
33
+ def score_to_bool(value: Any) -> "Optional[bool]":
34
+ """Normalize scorer outputs to the fix-module success contract.
35
+
36
+ The fix module accepts user-provided scorers. Some examples reuse
37
+ ``CaseDiscoveryAgent`` scorers that return ``Label.PASS`` / ``Label.FAIL``
38
+ instead of bare booleans. Enum instances are truthy in Python, including
39
+ ``Label.FAIL``, so every fix executor must normalize before doing boolean
40
+ algebra or passing vectors into statistical tests.
41
+ """
42
+ if value is None:
43
+ return None
44
+ if isinstance(value, bool):
45
+ return value
46
+
47
+ text = str(getattr(value, "value", value)).strip().lower()
48
+ if text in {"pass", "passed", "true", "correct", "ok", "1", "yes"}:
49
+ return True
50
+ if text in {"fail", "failed", "false", "incorrect", "wrong", "0", "no"}:
51
+ return False
52
+ if text in {"unknown", "none", "unscored", ""}:
53
+ return None
54
+
55
+ if isinstance(value, (int, float)):
56
+ return bool(value)
57
+ return None
58
+
59
+
60
+ #: ``{identifier}`` — the only thing a prompt template may substitute.
61
+ _TEMPLATE_FIELD = re.compile(r"\{(\w+)\}")
62
+
63
+
64
+ def safe_format(template: str, context: "dict[str, Any]") -> str:
65
+ r"""Fill ``{known}`` placeholders and leave every other brace group alone.
66
+
67
+ ``str.format`` treats EVERY ``{...}`` as a replacement field, but a prompt
68
+ template is model-authored prose about a task whose text legitimately
69
+ contains braces — LaTeX above all. Measured against the real thing:
70
+
71
+ "{prompt} \frac{a}{b}" -> KeyError: 'a'
72
+ "{prompt} 10^{33}" -> IndexError: Replacement index 33
73
+ "{prompt} ${~m}$" -> KeyError: '~m'
74
+ "{prompt} {}" -> IndexError: Replacement index 0
75
+
76
+ The third is not hypothetical: it ended a qwen3.5-2b/minervamath run
77
+ *after* M5 had produced its fix, because the formatting sat outside the
78
+ per-case ``try``, so a template the model wrote for a LaTeX dataset took
79
+ down the process instead of scoring one case as ``None``.
80
+
81
+ Substituting by regex rather than forgiving ``format_map`` because the
82
+ failures above are three different exception types from two different
83
+ causes (unknown name, positional index), and a rule of "replace exactly the
84
+ ``{identifier}`` groups I know" has none of them: unknown names stay
85
+ literal, and nothing else is even looked at. The cost is format specs
86
+ (``{value:.2f}``), which a prompt template has no use for.
87
+ """
88
+ return _TEMPLATE_FIELD.sub(
89
+ lambda m: str(context.get(m.group(1), m.group(0))), template
90
+ )
91
+
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # Image tool catalog
95
+ # ---------------------------------------------------------------------------
96
+
97
+ def _as_pil(image: Any):
98
+ """Decode *image* (PIL.Image | path/URL) to a PIL image, or ``None``."""
99
+ if image is None:
100
+ return None
101
+ from PIL import Image
102
+
103
+ if isinstance(image, Image.Image):
104
+ return image
105
+ try:
106
+ return Image.open(str(image))
107
+ except Exception as exc:
108
+ logger.debug("fix_tools: cannot decode image %r: %s", image, exc)
109
+ return None
110
+
111
+
112
+ def zoom_center(img, factor: float = 1.5):
113
+ """Crop the central 1/factor region and resize back to the original size."""
114
+ factor = max(1.0, float(factor))
115
+ w, h = img.size
116
+ cw, ch = int(w / factor), int(h / factor)
117
+ left, top = (w - cw) // 2, (h - ch) // 2
118
+ from PIL import Image
119
+
120
+ return img.crop((left, top, left + cw, top + ch)).resize((w, h), Image.LANCZOS)
121
+
122
+
123
+ def enhance_contrast(img, factor: float = 1.5):
124
+ from PIL import ImageEnhance
125
+
126
+ return ImageEnhance.Contrast(img).enhance(float(factor))
127
+
128
+
129
+ def sharpen(img, factor: float = 2.0):
130
+ from PIL import ImageEnhance
131
+
132
+ return ImageEnhance.Sharpness(img).enhance(float(factor))
133
+
134
+
135
+ def equalize(img):
136
+ """Histogram equalization (high dynamic-range scans, e.g. radiology)."""
137
+ from PIL import ImageOps
138
+
139
+ return ImageOps.equalize(img.convert("RGB"))
140
+
141
+
142
+ def upscale(img, factor: float = 2.0):
143
+ factor = max(1.0, float(factor))
144
+ from PIL import Image
145
+
146
+ w, h = img.size
147
+ return img.resize((int(w * factor), int(h * factor)), Image.LANCZOS)
148
+
149
+
150
+ def crop_region(img, box=(0.25, 0.25, 0.75, 0.75)):
151
+ """Crop a normalized (left, top, right, bottom) box, resize to original size.
152
+
153
+ Like :func:`zoom_center` but for an arbitrary region — the building block
154
+ for attention-guided cropping (L3a), also usable by coded pipelines.
155
+ """
156
+ from PIL import Image
157
+
158
+ w, h = img.size
159
+ left, top, right, bottom = (float(v) for v in box)
160
+ left, top = max(0.0, min(left, 0.95)), max(0.0, min(top, 0.95))
161
+ right, bottom = min(1.0, max(right, left + 0.05)), min(1.0, max(bottom, top + 0.05))
162
+ px = (int(left * w), int(top * h), max(int(right * w), int(left * w) + 1),
163
+ max(int(bottom * h), int(top * h) + 1))
164
+ return img.crop(px).resize((w, h), Image.LANCZOS)
165
+
166
+
167
+ def crop_case_bbox(
168
+ img,
169
+ case: "FailureCase | None" = None,
170
+ bbox_key: str = "answer_bbox_xyxy_norm",
171
+ padding: float = 0.15,
172
+ min_size_frac: float = 0.08,
173
+ sharpen_factor: float = 1.0,
174
+ contrast_factor: float = 1.0,
175
+ ):
176
+ """Crop a per-case normalized bbox from metadata, then resize back.
177
+
178
+ TextVQA-style size-sensitivity experiments often include answer bboxes.
179
+ This L2 scaffold implements the paper's human-CROP intervention: use a
180
+ dataset-provided visual localization annotation to magnify the small answer
181
+ region. It does not read labels or expected answers, and is a no-op for
182
+ cases without a usable bbox.
183
+ """
184
+ meta = getattr(case, "metadata", {}) or {}
185
+ raw = meta.get(bbox_key) or meta.get("answer_bbox_norm") or meta.get("bbox_xyxy_norm")
186
+ if raw is None:
187
+ return img
188
+ if isinstance(raw, dict):
189
+ try:
190
+ box = [raw["left"], raw["top"], raw["right"], raw["bottom"]]
191
+ except KeyError:
192
+ try:
193
+ box = [raw["x1"], raw["y1"], raw["x2"], raw["y2"]]
194
+ except KeyError:
195
+ return img
196
+ else:
197
+ box = raw
198
+ try:
199
+ left, top, right, bottom = (float(v) for v in box)
200
+ except Exception:
201
+ return img
202
+ if right <= left or bottom <= top:
203
+ return img
204
+
205
+ left, top = max(0.0, min(1.0, left)), max(0.0, min(1.0, top))
206
+ right, bottom = max(0.0, min(1.0, right)), max(0.0, min(1.0, bottom))
207
+ cx, cy = (left + right) / 2.0, (top + bottom) / 2.0
208
+ side = max(right - left, bottom - top, float(min_size_frac))
209
+ side = min(1.0, side * (1.0 + 2.0 * max(0.0, float(padding))))
210
+ new_left = cx - side / 2.0
211
+ new_top = cy - side / 2.0
212
+ new_right = cx + side / 2.0
213
+ new_bottom = cy + side / 2.0
214
+ if new_left < 0.0:
215
+ new_right -= new_left
216
+ new_left = 0.0
217
+ if new_top < 0.0:
218
+ new_bottom -= new_top
219
+ new_top = 0.0
220
+ if new_right > 1.0:
221
+ new_left -= new_right - 1.0
222
+ new_right = 1.0
223
+ if new_bottom > 1.0:
224
+ new_top -= new_bottom - 1.0
225
+ new_bottom = 1.0
226
+ out = crop_region(
227
+ img,
228
+ box=(
229
+ max(0.0, new_left),
230
+ max(0.0, new_top),
231
+ min(1.0, new_right),
232
+ min(1.0, new_bottom),
233
+ ),
234
+ )
235
+ try:
236
+ from PIL import ImageEnhance
237
+
238
+ if float(sharpen_factor) != 1.0:
239
+ out = ImageEnhance.Sharpness(out).enhance(float(sharpen_factor))
240
+ if float(contrast_factor) != 1.0:
241
+ out = ImageEnhance.Contrast(out).enhance(float(contrast_factor))
242
+ except Exception:
243
+ return out
244
+ return out
245
+
246
+
247
+ def crop_salient_region(img, padding: float = 0.05, min_delta: float = 18.0):
248
+ """Crop non-background content, then resize back to the original size.
249
+
250
+ The background is estimated from image-border pixels. This is a generic L2
251
+ preprocessing tool for small colored marks, narrow bands, dots, and other
252
+ objects that are visually distinct from a mostly uniform canvas. It does
253
+ not inspect labels or answer text; it only magnifies the detected content.
254
+ """
255
+ import numpy as np
256
+ from PIL import Image
257
+
258
+ rgb = img.convert("RGB")
259
+ arr = np.asarray(rgb, dtype=np.float32)
260
+ h, w = arr.shape[:2]
261
+ border = max(1, min(h, w) // 32)
262
+ samples = np.concatenate(
263
+ [
264
+ arr[:border, :, :].reshape(-1, 3),
265
+ arr[-border:, :, :].reshape(-1, 3),
266
+ arr[:, :border, :].reshape(-1, 3),
267
+ arr[:, -border:, :].reshape(-1, 3),
268
+ ],
269
+ axis=0,
270
+ )
271
+ bg = np.median(samples, axis=0)
272
+ dist = np.linalg.norm(arr - bg, axis=2)
273
+ border_dist = np.linalg.norm(samples - bg, axis=1)
274
+ border_median = float(np.median(border_dist))
275
+ border_mad = float(np.median(np.abs(border_dist - border_median)))
276
+ thresh = max(float(min_delta), border_median + 6.0 * border_mad + float(min_delta))
277
+ mask = dist > thresh
278
+ if not bool(mask.any()):
279
+ return img
280
+
281
+ ys, xs = np.where(mask)
282
+ pad_px = max(1, int(round(float(padding) * max(h, w))))
283
+ left = max(0, int(xs.min()) - pad_px)
284
+ right = min(w, int(xs.max()) + pad_px + 1)
285
+ top = max(0, int(ys.min()) - pad_px)
286
+ bottom = min(h, int(ys.max()) + pad_px + 1)
287
+ if right <= left or bottom <= top:
288
+ return img
289
+ return rgb.crop((left, top, right, bottom)).resize((w, h), Image.LANCZOS)
290
+
291
+
292
+ def separate_horizontal_bands(
293
+ img,
294
+ min_delta: float = 18.0,
295
+ color_delta: float = 35.0,
296
+ min_width_frac: float = 0.35,
297
+ ):
298
+ """Render detected horizontal color runs as separated, thick bands.
299
+
300
+ This is a deterministic visibility transform for sub-patch horizontal
301
+ structures: it detects rows whose color differs from the border-estimated
302
+ background, splits adjacent rows when their median color changes, and
303
+ redraws each run with gray gaps. It preserves the number/order/color of
304
+ detected bands while making individual bands resolvable to a VLM.
305
+ """
306
+ import numpy as np
307
+ from PIL import Image, ImageDraw
308
+
309
+ rgb = img.convert("RGB")
310
+ arr = np.asarray(rgb, dtype=np.float32)
311
+ h, w = arr.shape[:2]
312
+ border = max(1, min(h, w) // 32)
313
+ samples = np.concatenate(
314
+ [
315
+ arr[:border, :, :].reshape(-1, 3),
316
+ arr[-border:, :, :].reshape(-1, 3),
317
+ arr[:, :border, :].reshape(-1, 3),
318
+ arr[:, -border:, :].reshape(-1, 3),
319
+ ],
320
+ axis=0,
321
+ )
322
+ bg = np.median(samples, axis=0)
323
+ dist = np.linalg.norm(arr - bg, axis=2)
324
+ mask = dist > float(min_delta)
325
+ if not bool(mask.any()):
326
+ return img
327
+
328
+ ys, xs = np.where(mask)
329
+ if (int(xs.max()) - int(xs.min()) + 1) / max(1, w) < float(min_width_frac):
330
+ return img
331
+
332
+ row_salient = mask.mean(axis=1) > 0.05
333
+ row_colors: list[tuple[int, np.ndarray]] = []
334
+ for y in np.where(row_salient)[0].tolist():
335
+ row_mask = mask[y]
336
+ if not bool(row_mask.any()):
337
+ continue
338
+ row_colors.append((y, np.median(arr[y, row_mask, :], axis=0)))
339
+ if not row_colors:
340
+ return img
341
+
342
+ segments: list[tuple[int, int, np.ndarray]] = []
343
+ start_y, prev_y, running = row_colors[0][0], row_colors[0][0], [row_colors[0][1]]
344
+ prev_color = row_colors[0][1]
345
+ for y, color in row_colors[1:]:
346
+ new_band = (y != prev_y + 1) or (
347
+ float(np.linalg.norm(color - prev_color)) > float(color_delta)
348
+ )
349
+ if new_band:
350
+ segments.append((start_y, prev_y, np.median(np.stack(running), axis=0)))
351
+ start_y, running = y, [color]
352
+ else:
353
+ running.append(color)
354
+ prev_y, prev_color = y, color
355
+ segments.append((start_y, prev_y, np.median(np.stack(running), axis=0)))
356
+ if len(segments) <= 1:
357
+ return img
358
+
359
+ bg_color = tuple(int(max(0, min(255, round(v)))) for v in bg)
360
+ out = Image.new("RGB", (w, h), color=bg_color)
361
+ draw = ImageDraw.Draw(out)
362
+ margin_y = max(8, int(round(0.06 * h)))
363
+ gap = max(2, min(8, int(round(h / (len(segments) * 12)))))
364
+ band_h = max(3, int((h - 2 * margin_y - gap * (len(segments) - 1)) / len(segments)))
365
+ total_h = len(segments) * band_h + (len(segments) - 1) * gap
366
+ y = max(0, (h - total_h) // 2)
367
+ x0 = max(0, int(xs.min()) - max(2, int(round(0.02 * w))))
368
+ x1 = min(w - 1, int(xs.max()) + max(2, int(round(0.02 * w))))
369
+ for _, _, color in segments:
370
+ fill = tuple(int(max(0, min(255, round(v)))) for v in color)
371
+ draw.rectangle([x0, y, x1, min(h - 1, y + band_h - 1)], fill=fill)
372
+ y += band_h + gap
373
+ return out
374
+
375
+
376
+ def _horizontal_band_count(
377
+ img,
378
+ min_delta: float = 18.0,
379
+ color_delta: float = 35.0,
380
+ min_width_frac: float = 0.35,
381
+ ) -> int:
382
+ """Count horizontal color runs detected against the border background."""
383
+ import numpy as np
384
+
385
+ rgb = img.convert("RGB")
386
+ arr = np.asarray(rgb, dtype=np.float32)
387
+ h, w = arr.shape[:2]
388
+ border = max(1, min(h, w) // 32)
389
+ samples = np.concatenate(
390
+ [
391
+ arr[:border, :, :].reshape(-1, 3),
392
+ arr[-border:, :, :].reshape(-1, 3),
393
+ arr[:, :border, :].reshape(-1, 3),
394
+ arr[:, -border:, :].reshape(-1, 3),
395
+ ],
396
+ axis=0,
397
+ )
398
+ bg = np.median(samples, axis=0)
399
+ dist = np.linalg.norm(arr - bg, axis=2)
400
+ mask = dist > float(min_delta)
401
+ if not bool(mask.any()):
402
+ return 0
403
+ _, xs = np.where(mask)
404
+ if (int(xs.max()) - int(xs.min()) + 1) / max(1, w) < float(min_width_frac):
405
+ return 0
406
+
407
+ row_salient = mask.mean(axis=1) > 0.05
408
+ row_colors: list[tuple[int, np.ndarray]] = []
409
+ for y in np.where(row_salient)[0].tolist():
410
+ row_mask = mask[y]
411
+ if bool(row_mask.any()):
412
+ row_colors.append((y, np.median(arr[y, row_mask, :], axis=0)))
413
+ if not row_colors:
414
+ return 0
415
+
416
+ count = 1
417
+ prev_y, prev_color = row_colors[0]
418
+ for y, color in row_colors[1:]:
419
+ if (y != prev_y + 1) or float(np.linalg.norm(color - prev_color)) > float(color_delta):
420
+ count += 1
421
+ prev_y, prev_color = y, color
422
+ return count
423
+
424
+
425
+ def annotate_horizontal_band_count(
426
+ img,
427
+ min_delta: float = 18.0,
428
+ color_delta: float = 35.0,
429
+ min_width_frac: float = 0.35,
430
+ min_count: int = 1,
431
+ ):
432
+ """Overlay a deterministic horizontal-band count on the image.
433
+
434
+ The count is computed from the image pixels using the same horizontal run
435
+ detector as :func:`separate_horizontal_bands`. This is an L2 tool-assisted
436
+ scaffold: it gives the unchanged VLM an explicit visual measurement without
437
+ exposing labels or expected answers.
438
+ """
439
+ from PIL import ImageDraw, ImageFont
440
+
441
+ count = _horizontal_band_count(
442
+ img,
443
+ min_delta=min_delta,
444
+ color_delta=color_delta,
445
+ min_width_frac=min_width_frac,
446
+ )
447
+ if count < int(min_count):
448
+ return img
449
+
450
+ out = separate_horizontal_bands(
451
+ img,
452
+ min_delta=min_delta,
453
+ color_delta=color_delta,
454
+ min_width_frac=min_width_frac,
455
+ ).convert("RGB")
456
+ w, h = out.size
457
+ draw = ImageDraw.Draw(out)
458
+ banner_h = max(44, int(round(0.16 * h)))
459
+ draw.rectangle([0, 0, w - 1, banner_h], fill=(255, 255, 255), outline=(0, 0, 0))
460
+ text = f"COUNT: {count}"
461
+ try:
462
+ font = ImageFont.truetype("DejaVuSans-Bold.ttf", max(28, int(round(banner_h * 0.55))))
463
+ except Exception:
464
+ font = ImageFont.load_default()
465
+ try:
466
+ bbox = draw.textbbox((0, 0), text, font=font)
467
+ tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
468
+ except Exception:
469
+ tw, th = 120, 20
470
+ draw.text(((w - tw) // 2, max(4, (banner_h - th) // 2)), text, fill=(0, 0, 0), font=font)
471
+ return out
472
+
473
+
474
+ #: name -> (function, parameter hint for the judge prompt, description)
475
+ IMAGE_TOOLS: "dict[str, tuple[Callable, str, str]]" = {
476
+ "zoom_center": (zoom_center, "factor: float >= 1 (default 1.5)",
477
+ "crop the central 1/factor region, resize back — magnifies small findings"),
478
+ "enhance_contrast": (enhance_contrast, "factor: float (default 1.5)",
479
+ "global contrast boost — low-contrast findings"),
480
+ "sharpen": (sharpen, "factor: float (default 2.0)",
481
+ "edge sharpening — blurred or subtle boundaries"),
482
+ "equalize": (equalize, "(no params)",
483
+ "histogram equalization — compressed dynamic range"),
484
+ "upscale": (upscale, "factor: float >= 1 (default 2.0)",
485
+ "resize up before encoding — more vision tokens per region"),
486
+ "crop_region": (crop_region, "box: [left, top, right, bottom] normalized 0..1",
487
+ "crop an arbitrary region and resize back — magnify a known area"),
488
+ "crop_case_bbox": (
489
+ crop_case_bbox,
490
+ "bbox_key: str metadata key (default answer_bbox_xyxy_norm), padding: float "
491
+ "(default 0.15), min_size_frac: float (default 0.08), "
492
+ "sharpen_factor: float (default 1), contrast_factor: float (default 1)",
493
+ "crop the per-case bbox stored in metadata and resize back — paper-style "
494
+ "human-CROP for TextVQA answer regions, optionally sharpened/enhanced",
495
+ ),
496
+ "crop_salient_region": (crop_salient_region,
497
+ "padding: float 0-0.5 (default 0.05), min_delta: float "
498
+ "(default 18)",
499
+ "detect content that differs from a uniform border/background, "
500
+ "crop it with padding, and resize back — magnifies small objects"),
501
+ "separate_horizontal_bands": (
502
+ separate_horizontal_bands,
503
+ "min_delta: float (default 18), color_delta: float (default 35), "
504
+ "min_width_frac: float 0-1 (default 0.35)",
505
+ "detect adjacent horizontal color bands and redraw them as separated, "
506
+ "thick bands — makes sub-patch stripe structure countable",
507
+ ),
508
+ "annotate_horizontal_band_count": (
509
+ annotate_horizontal_band_count,
510
+ "min_delta: float (default 18), color_delta: float (default 35), "
511
+ "min_width_frac: float 0-1 (default 0.35), min_count: int (default 1)",
512
+ "detect adjacent horizontal color bands and overlay a visual COUNT: N "
513
+ "measurement derived from the image pixels",
514
+ ),
515
+ }
516
+
517
+
518
+ def catalog_text() -> str:
519
+ """Render the tool catalog for inclusion in a judge prompt."""
520
+ return "\n".join(
521
+ f"- {name}: {desc} [params: {params}]"
522
+ for name, (_, params, desc) in IMAGE_TOOLS.items()
523
+ )
524
+
525
+
526
+ def apply_image_ops(
527
+ image: Any,
528
+ ops: "list[dict[str, Any]]",
529
+ case: "FailureCase | None" = None,
530
+ ) -> Any:
531
+ """Apply ``[{"tool": name, "params": {...}}, ...]`` to *image*.
532
+
533
+ Unknown tools and per-op failures are skipped (logged); returns the
534
+ original object when nothing could be applied (e.g. no image).
535
+ """
536
+ img = _as_pil(image)
537
+ if img is None:
538
+ return image
539
+ for op in ops:
540
+ name = str(op.get("tool", ""))
541
+ entry = IMAGE_TOOLS.get(name)
542
+ if entry is None:
543
+ logger.warning("fix_tools: unknown tool %r skipped", name)
544
+ continue
545
+ fn = entry[0]
546
+ try:
547
+ if name == "crop_case_bbox":
548
+ img = fn(img, case=case, **dict(op.get("params") or {}))
549
+ else:
550
+ img = fn(img, **dict(op.get("params") or {}))
551
+ except Exception as exc:
552
+ logger.warning("fix_tools: tool %r failed (%s); skipped", name, exc)
553
+ return img
554
+
555
+
556
+ # ---------------------------------------------------------------------------
557
+ # Pipeline spec + executor
558
+ # ---------------------------------------------------------------------------
559
+
560
+ @dataclass
561
+ class PipelineSpec:
562
+ """A serialisable L2 scaffold around the unchanged model.
563
+
564
+ Attributes:
565
+ name: Short identifier for logs.
566
+ image_ops: Tool applications, in order (see :data:`IMAGE_TOOLS`).
567
+ prompt_template: Must contain ``{prompt}``; identity by default.
568
+ n_samples: Independent passes per case (majority vote on the
569
+ extracted final answer when > 1). Applies to every
570
+ strategy — a multi-call strategy is repeated end-to-end.
571
+ generation_kwargs: Safe, backend-neutral decoding overrides. This is
572
+ deliberately part of the serialized candidate: a
573
+ length stop is an execution-health failure, not a
574
+ prompt failure.
575
+ strategy: A reviewed multi-call scaffold. ``direct`` is the
576
+ normal one-call path; the other choices encode common
577
+ general-purpose reasoning patterns without arbitrary
578
+ code execution.
579
+ output_key_pattern: Optional safe regex (one capture group) used to
580
+ aggregate structured final answers across samples.
581
+ baseline_override_min_support: When positive, preserve the recorded
582
+ baseline unless this many enhanced samples agree on
583
+ one different answer and no enhanced sample supports
584
+ the baseline. This is a label-free safety gate.
585
+ """
586
+
587
+ name: str
588
+ image_ops: "list[dict[str, Any]]" = field(default_factory=list)
589
+ prompt_template: str = "{prompt}"
590
+ n_samples: int = 1
591
+ generation_kwargs: "dict[str, Any]" = field(default_factory=dict)
592
+ strategy: str = "direct"
593
+ output_key_pattern: str = ""
594
+ baseline_override_min_support: int = 0
595
+
596
+ @classmethod
597
+ def from_dict(cls, d: "dict[str, Any]") -> "PipelineSpec | None":
598
+ """Validate a judge-proposed spec dict; ``None`` when unusable."""
599
+ name = str(d.get("name", "")).strip()
600
+ template = str(d.get("prompt_template") or "{prompt}")
601
+ if not name or "{prompt}" not in template:
602
+ return None
603
+ ops = [
604
+ {"tool": str(op["tool"]), "params": dict(op.get("params") or {})}
605
+ for op in d.get("image_ops") or []
606
+ if isinstance(op, dict) and str(op.get("tool", "")) in IMAGE_TOOLS
607
+ ]
608
+ try:
609
+ n_samples = max(1, int(d.get("n_samples", 1)))
610
+ except (TypeError, ValueError):
611
+ n_samples = 1
612
+ generation_kwargs = _safe_generation_kwargs(d.get("generation_kwargs"))
613
+ strategy = str(d.get("strategy", "direct")).strip().lower()
614
+ if strategy not in _SCAFFOLD_STRATEGIES:
615
+ strategy = "direct"
616
+ # Bound the product, not just n_samples: repeating a three-call
617
+ # strategy five times silently creates 15 generations per case (3840
618
+ # calls on a 256-case EXPLORE split). Six calls/case still permits a
619
+ # useful vote while keeping one autonomous candidate operationally
620
+ # bounded. Direct candidates retain the historical cap of five.
621
+ calls_per_sample = len(STRATEGY_CALLS.get(strategy, (("direct", ""),)))
622
+ sample_cap = min(5, max(1, 6 // calls_per_sample))
623
+ pattern = _safe_output_key_pattern(d.get("output_key_pattern"))
624
+ try:
625
+ override_support = max(0, int(d.get("baseline_override_min_support", 0)))
626
+ except (TypeError, ValueError):
627
+ override_support = 0
628
+ return cls(name=name, image_ops=ops, prompt_template=template,
629
+ n_samples=min(n_samples, sample_cap), generation_kwargs=generation_kwargs,
630
+ strategy=strategy, output_key_pattern=pattern,
631
+ baseline_override_min_support=min(override_support, 5))
632
+
633
+ def to_dict(self) -> "dict[str, Any]":
634
+ return {"name": self.name, "image_ops": self.image_ops,
635
+ "prompt_template": self.prompt_template, "n_samples": self.n_samples,
636
+ "generation_kwargs": self.generation_kwargs, "strategy": self.strategy,
637
+ "output_key_pattern": self.output_key_pattern,
638
+ "baseline_override_min_support": self.baseline_override_min_support}
639
+
640
+
641
+ _SCAFFOLD_STRATEGIES = frozenset({
642
+ "direct", "least_to_most", "self_refine", "chain_of_verification",
643
+ })
644
+
645
+ #: The ordered call chain each multi-call strategy issues, as
646
+ #: ``(step label, instruction prepended for that call)``. An empty instruction
647
+ #: means the call sends the task prompt with nothing added.
648
+ #:
649
+ #: Hoisted out of ``run_pipeline`` (which now formats from these) so that the
650
+ #: dashboard can SHOW the text a strategy actually sends instead of
651
+ #: paraphrasing it. ``analysis/case_studio.py`` keeps a mirror of this table —
652
+ #: it may not import this package at runtime (analysis has to stay standalone)
653
+ #: — and ``test_strategy_flow_matches_what_the_pipeline_sends`` asserts the two
654
+ #: are equal, so re-wording a prompt here without updating the mirror fails the
655
+ #: suite rather than silently leaving the UI describing a pipeline that is gone.
656
+ STRATEGY_CALLS: "dict[str, tuple[tuple[str, str], ...]]" = {
657
+ "self_refine": (
658
+ ("answer", ""),
659
+ ("critique that answer",
660
+ "Check the attempted answer for factual, reasoning, arithmetic, and "
661
+ "instruction-following errors. Give concise correction advice only."),
662
+ ("revise it",
663
+ "Produce a corrected final answer to the original task using the "
664
+ "feedback. Do not discuss the revision process."),
665
+ ),
666
+ "least_to_most": (
667
+ ("break into subproblems",
668
+ "Break the following task into the smallest useful subproblems. "
669
+ "Do not answer the task yet."),
670
+ ("solve using the decomposition",
671
+ "Solve the original task using the decomposition. Give only the final "
672
+ "answer required by the task."),
673
+ ),
674
+ "chain_of_verification": (
675
+ ("answer", ""),
676
+ ("list verification checks",
677
+ "List short, independent checks needed to verify this attempted answer. "
678
+ "Do not answer the original task yet."),
679
+ ("answer after the checks",
680
+ "Answer the original task after applying the independent verification "
681
+ "checks. Give only the final answer required by the task."),
682
+ ),
683
+ }
684
+
685
+
686
+ def _safe_output_key_pattern(value: Any) -> str:
687
+ """Accept one bounded capture regex for evaluator-declared answer formats."""
688
+ pattern = str(value or "").strip()
689
+ if not pattern or len(pattern) > 256:
690
+ return ""
691
+ try:
692
+ compiled = re.compile(pattern, flags=re.IGNORECASE | re.DOTALL)
693
+ except re.error:
694
+ return ""
695
+ return pattern if compiled.groups >= 1 else ""
696
+
697
+
698
+ #: Upper bound on a candidate's ``max_tokens``. This is an audit ceiling, not a
699
+ #: budget: it only stops a judge from smuggling an absurd number in. It sits
700
+ #: well above every baseline budget this repo runs (20480 for the long-form
701
+ #: llm_benchmark datasets) — the old 8192 was BELOW those, so a candidate could
702
+ #: never even match the baseline on them. The floor (a candidate may not decode
703
+ #: with LESS than the baseline budget) is enforced by the fix agent, which
704
+ #: knows the baseline; see ``FixAgent._enforce_generation_floor``.
705
+ MAX_TOKENS_CAP = 32768
706
+
707
+
708
+ def _safe_generation_kwargs(value: Any) -> "dict[str, Any]":
709
+ """Keep only portable, bounded decoding controls from a candidate spec.
710
+
711
+ The model adapter owns its provider-specific API. Repair candidates may
712
+ only tune controls that are safe to replay and audit; this avoids a judge
713
+ smuggling arbitrary provider arguments or callbacks into ``generate``.
714
+ """
715
+ raw = value if isinstance(value, dict) else {}
716
+ out: "dict[str, Any]" = {}
717
+ try:
718
+ max_tokens = int(raw.get("max_tokens"))
719
+ if 1 <= max_tokens <= MAX_TOKENS_CAP:
720
+ out["max_tokens"] = max_tokens
721
+ except (TypeError, ValueError):
722
+ pass
723
+ try:
724
+ temperature = float(raw.get("temperature"))
725
+ if 0.0 < temperature <= 2.0:
726
+ out["temperature"] = temperature
727
+ out["do_sample"] = True
728
+ elif temperature == 0.0:
729
+ out["do_sample"] = False
730
+ except (TypeError, ValueError):
731
+ pass
732
+ try:
733
+ top_p = float(raw.get("top_p"))
734
+ if 0.0 < top_p <= 1.0:
735
+ out["top_p"] = top_p
736
+ except (TypeError, ValueError):
737
+ pass
738
+ stop = raw.get("stop")
739
+ if isinstance(stop, str) and stop and len(stop) <= 128:
740
+ out["stop"] = stop
741
+ elif isinstance(stop, list) and stop and len(stop) <= 4 and all(
742
+ isinstance(item, str) and len(item) <= 128 for item in stop
743
+ ):
744
+ out["stop"] = list(stop)
745
+ return out
746
+
747
+
748
+ def spec_changes_input(spec: PipelineSpec, case: "FailureCase") -> bool:
749
+ """True when *spec* actually alters this case's effective input.
750
+
751
+ A spec that leaves both the prompt and the image untouched is a *no-op* on
752
+ the case — e.g. ``crop_case_bbox`` on a case that carries no answer bbox, or
753
+ an enhancement with unit factors. Such a case is outside the candidate's
754
+ applicability: the fix can neither repair nor break it, so it must be scoped
755
+ out of the safety/coverage accounting rather than counted as an unchanged
756
+ "control". This is the structural half of an applicability predicate; an
757
+ explicit :attr:`FixCandidate.predicate` overrides it.
758
+ """
759
+ if (
760
+ spec.prompt_template.strip() != "{prompt}"
761
+ or spec.generation_kwargs
762
+ or spec.strategy != "direct"
763
+ or spec.n_samples > 1
764
+ ):
765
+ return True
766
+ if not spec.image_ops:
767
+ return False
768
+ inp = getattr(case, "inputs", None)
769
+ image = getattr(inp, "image", None) if inp is not None else None
770
+ before = _as_pil(image)
771
+ if before is None:
772
+ return False
773
+ after = apply_image_ops(before, spec.image_ops, case=case)
774
+ if after is before:
775
+ return False
776
+ try:
777
+ import numpy as np
778
+
779
+ a, b = np.asarray(before), np.asarray(after)
780
+ return a.shape != b.shape or not np.array_equal(a, b)
781
+ except Exception:
782
+ return True # cannot prove it is a no-op — treat as applicable
783
+
784
+
785
+ def _run_strategy_once(
786
+ generate: "Callable[[str], str]", strategy: str, base_prompt: str
787
+ ) -> str:
788
+ """One pass of a reviewed multi-call strategy; returns its final output."""
789
+ if strategy == "least_to_most":
790
+ calls = STRATEGY_CALLS["least_to_most"]
791
+ decomposition = generate(calls[0][1] + "\n\n" + base_prompt)
792
+ return generate(
793
+ f"{calls[1][1]}\n\nOriginal task:\n"
794
+ f"{base_prompt}\n\nDecomposition:\n{decomposition}"
795
+ )
796
+ if strategy == "self_refine":
797
+ calls = STRATEGY_CALLS["self_refine"]
798
+ draft = generate(base_prompt)
799
+ feedback = generate(
800
+ f"{calls[1][1]}\n\n"
801
+ f"Original task:\n{base_prompt}\n\nAttempt:\n{draft}"
802
+ )
803
+ return generate(
804
+ f"{calls[2][1]}\n\n"
805
+ f"Original task:\n{base_prompt}\n\nAttempt:\n{draft}\n\nFeedback:\n{feedback}"
806
+ )
807
+ if strategy == "chain_of_verification":
808
+ calls = STRATEGY_CALLS["chain_of_verification"]
809
+ draft = generate(base_prompt)
810
+ checks = generate(
811
+ f"{calls[1][1]}\n\n"
812
+ f"Original task:\n{base_prompt}\n\nAttempt:\n{draft}"
813
+ )
814
+ return generate(
815
+ f"{calls[2][1]}\n\n"
816
+ f"Original task:\n{base_prompt}\n\nAttempt:\n{draft}\n\nChecks:\n{checks}"
817
+ )
818
+ return generate(base_prompt)
819
+
820
+
821
+ def answer_key(output: str, pattern: str = "") -> str:
822
+ """Label-free key two samples must share to count as the same answer.
823
+
824
+ *pattern* (evaluator-declared, one capture group) wins when it matches.
825
+ Otherwise the key is the FINAL ANSWER span — last ``Answer:`` tag /
826
+ ``\\boxed{}`` / last non-empty line, normalised — via the same
827
+ ``extract_answer`` + ``normalize_answer`` the reasoning analyzers use.
828
+
829
+ Why not the whole normalised text (the previous fallback): with a
830
+ chain-of-thought model no two samples are ever byte-identical, so every
831
+ sample sat in its own group and ``n_samples=5`` silently degenerated to
832
+ "return the first sample" — five calls, no vote. Measured on the
833
+ qwen3.5-2b/bbh_tracking7 candidates: the judge's ``n_samples=5`` proposals
834
+ voted on nothing. Neither path reads the gold answer.
835
+ """
836
+ text = str(output or "")
837
+ if pattern:
838
+ try:
839
+ key_re = re.compile(pattern, flags=re.IGNORECASE | re.DOTALL)
840
+ except re.error:
841
+ key_re = None
842
+ if key_re is not None:
843
+ matches = list(key_re.finditer(text))
844
+ if matches and matches[-1].lastindex:
845
+ return matches[-1].group(1).strip().lower()
846
+ try:
847
+ from evalrx.analyzers.reasoning._text import extract_answer, normalize_answer
848
+
849
+ key = normalize_answer(extract_answer(text))
850
+ if key:
851
+ return key
852
+ except Exception: # analyzers are optional at this layer; degrade gracefully
853
+ pass
854
+ return re.sub(r"\s+", " ", text.strip().lower())
855
+
856
+
857
+ def run_pipeline(
858
+ model: "Model",
859
+ case: "FailureCase",
860
+ spec: PipelineSpec,
861
+ score_fn: "Callable[[FailureCase, str], Optional[bool]]",
862
+ capture: "dict[str, Any] | None" = None,
863
+ call_logger: "Callable[[dict[str, Any]], None] | None" = None,
864
+ ) -> "Optional[bool]":
865
+ """Execute *spec* on one case; aggregate outputs before host-side scoring.
866
+
867
+ ``n_samples`` applies to EVERY strategy: a multi-call strategy is run
868
+ ``n_samples`` times end-to-end and its final outputs vote (previously only
869
+ ``direct`` honoured ``n_samples`` — a judge's ``least_to_most, n_samples=3``
870
+ silently ran once). Samples vote on an evaluator-declared final-answer key
871
+ when one is available, otherwise on the extracted final answer
872
+ (:func:`answer_key`). The scoring rubric is applied *after* aggregation, so
873
+ a pipeline cannot use the hidden gold answer to select its preferred
874
+ sample. Reviewed multi-call strategies are prompt-only and keep the
875
+ underlying model unchanged. Returns ``None`` when the case cannot be scored
876
+ (no rubric / all calls failed).
877
+
878
+ *capture*, when a dict, receives ``{"prompt", "outputs", "winner",
879
+ "n_calls"}`` so the caller can persist WHAT the candidate produced (the
880
+ only way to tell a truncated answer from a wrong one after the fact).
881
+ """
882
+ import dataclasses
883
+
884
+ from evalrx.core.case import Inputs
885
+
886
+ inp = getattr(case, "inputs", None)
887
+ prompt = str(getattr(inp, "prompt", "")) if inp is not None else ""
888
+ image = getattr(inp, "image", None) if inp is not None else None
889
+ if spec.image_ops:
890
+ image = apply_image_ops(image, spec.image_ops, case=case)
891
+ # safe_format, not str.format: a judge-written template legitimately
892
+ # contains braces (LaTeX, sets like "{1,2,3}", JSON) — str.format raised
893
+ # KeyError OUTSIDE the per-case try and took down a whole fix stage
894
+ # (qwen3.5-2b/bbh_tracking7 run8: template with "{1,2,3,4,5,6,7}").
895
+ # {original_prompt} lets an EDIT candidate (recursive rounds) REPLACE the
896
+ # previously deployed template instead of wrapping its rendered output.
897
+ template_context = {"prompt": prompt}
898
+ original = (getattr(case, "metadata", {}) or {}).get("original_prompt")
899
+ if original:
900
+ template_context["original_prompt"] = str(original)
901
+ base_prompt = safe_format(spec.prompt_template, template_context)
902
+ n_calls = 0
903
+
904
+ def generate(text: str) -> str:
905
+ nonlocal n_calls
906
+ import time
907
+
908
+ n_calls += 1
909
+ started = time.perf_counter()
910
+ new_inputs = None
911
+ try:
912
+ # dataclasses.replace(inp, ...), not a bare Inputs(prompt=...,
913
+ # image=...): the bare form silently dropped .video/.audio, so
914
+ # every strategy (least_to_most/self_refine/chain_of_verification)
915
+ # was unconditionally inapplicable on any non-image FailureCase --
916
+ # generate() raising on the missing required modality field,
917
+ # caught below, every call returning "" (all outputs empty ->
918
+ # run_pipeline returns None -> unscoreable for every case). Only
919
+ # `image` is deliberately overridden (it may have been rewritten
920
+ # by spec.image_ops just above); no fallback needed for inp is
921
+ # None since FailureCase.inputs is never optional in practice.
922
+ new_inputs = (
923
+ dataclasses.replace(inp, prompt=text, image=image)
924
+ if inp is not None
925
+ else Inputs(prompt=text, image=image)
926
+ )
927
+ output = str(model.generate(new_inputs, **spec.generation_kwargs))
928
+ if call_logger is not None:
929
+ call_logger({
930
+ "inputs": new_inputs, "output": output, "error": None,
931
+ "duration_sec": time.perf_counter() - started,
932
+ "generation_kwargs": dict(spec.generation_kwargs),
933
+ })
934
+ return output
935
+ except Exception as exc:
936
+ logger.debug("run_pipeline: generate failed on %s: %s", case.id, exc)
937
+ if call_logger is not None:
938
+ try:
939
+ call_logger({
940
+ "inputs": new_inputs or {"prompt": text}, "output": None,
941
+ "error": str(exc), "duration_sec": time.perf_counter() - started,
942
+ "generation_kwargs": dict(spec.generation_kwargs),
943
+ })
944
+ except Exception as log_exc:
945
+ logger.debug("run_pipeline: call logger failed: %s", log_exc)
946
+ return ""
947
+
948
+ outputs = [
949
+ _run_strategy_once(generate, spec.strategy, base_prompt)
950
+ for _ in range(max(1, spec.n_samples))
951
+ ]
952
+ outputs = [output for output in outputs if output]
953
+ if capture is not None:
954
+ capture.update({"prompt": base_prompt, "outputs": list(outputs),
955
+ "winner": None, "n_calls": n_calls})
956
+ if not outputs:
957
+ return None
958
+ # Keep the first original response for the winning normalized key; stable
959
+ # tie-breaking avoids injecting a score-dependent preference.
960
+ pattern = spec.output_key_pattern or str(
961
+ (getattr(case, "metadata", {}) or {}).get("output_key_pattern", "")
962
+ )
963
+ grouped: "dict[str, list[str]]" = {}
964
+ for output in outputs:
965
+ grouped.setdefault(answer_key(output, pattern), []).append(output)
966
+ winner = max(grouped.values(), key=len)[0]
967
+ min_support = max(0, int(spec.baseline_override_min_support))
968
+ if min_support:
969
+ baseline = str(getattr(case, "observed", "") or "")
970
+ baseline_key = answer_key(baseline, pattern)
971
+ alternatives = [group for key, group in grouped.items() if key != baseline_key]
972
+ best_alternative = max(alternatives, key=len) if alternatives else []
973
+ # Any enhanced support for the baseline is evidence against a safe
974
+ # override. The fallback is the exact recorded output, not a host-side
975
+ # rewrite, so this gate never solves the benchmark task itself.
976
+ if baseline_key in grouped or len(best_alternative) < min_support:
977
+ winner = baseline
978
+ else:
979
+ winner = best_alternative[0]
980
+ if capture is not None:
981
+ capture["winner"] = winner
982
+ return score_to_bool(score_fn(case, winner))
983
+
984
+
985
+ class SpecPipelineModel(Model):
986
+ """A deployed :class:`PipelineSpec` exposed as a ``Model`` handle.
987
+
988
+ Winner-as-new-baseline for recursive repair rounds: a pure L1 template
989
+ winner deploys as data (its rendering pre-baked into manifest prompts),
990
+ but a spec winner (n_samples / strategy / generation_kwargs) has to RUN.
991
+ Wrapping it as a model makes "the fixed pipeline" the thing every
992
+ downstream stage calls ``.generate()`` on: Stage-0 discovery, the M-stage
993
+ probes and the fix stage's baseline arm all measure the deployed pipeline,
994
+ while candidates run on the raw handle (``FixAgent(candidate_model=...)``)
995
+ and REPLACE it.
996
+
997
+ Two deliberate semantic notes:
998
+
999
+ * ``baseline_override_min_support`` is stripped from the deployed spec: it
1000
+ arbitrates against a case's RECORDED baseline output, and once the spec
1001
+ IS the baseline there is no previous recording to defer to (a bare
1002
+ ``generate(inputs)`` call carries no ``observed``, which the gate would
1003
+ treat as the incumbent answer and return verbatim).
1004
+ * caller ``generation_kwargs`` are ignored: the deployed pipeline defines
1005
+ its own decoding, exactly as it did when it validated.
1006
+ """
1007
+
1008
+ def __init__(self, model: "Model", spec: "PipelineSpec | dict[str, Any]") -> None:
1009
+ payload = spec.to_dict() if isinstance(spec, PipelineSpec) else dict(spec)
1010
+ payload.pop("baseline_override_min_support", None)
1011
+ parsed = PipelineSpec.from_dict(payload)
1012
+ if parsed is None:
1013
+ raise ValueError(f"not a deployable pipeline spec: {payload!r}")
1014
+ self.inner_model = model
1015
+ self.spec = parsed
1016
+ self.capabilities = getattr(model, "capabilities", frozenset())
1017
+ self.modalities = getattr(model, "modalities", frozenset({"text"}))
1018
+
1019
+ def generate(self, inputs: Any, **kwargs: Any) -> str:
1020
+ from evalrx.core.case import FailureCase, Inputs
1021
+
1022
+ if not hasattr(inputs, "prompt"):
1023
+ inputs = Inputs(prompt=str(inputs))
1024
+ case = FailureCase(id="spec_baseline", inputs=inputs)
1025
+ capture: "dict[str, Any]" = {}
1026
+ run_pipeline(self.inner_model, case, self.spec, lambda _case, _out: None, capture)
1027
+ winner = capture.get("winner")
1028
+ if winner is None:
1029
+ outputs = capture.get("outputs") or []
1030
+ winner = outputs[0] if outputs else ""
1031
+ return str(winner)
1032
+
1033
+ def forward(self, inputs: Any, capture: Any, spec: Any = None):
1034
+ raise NotImplementedError("SpecPipelineModel is a generate-only deployed pipeline")