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