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