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