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,2159 @@
|
|
|
1
|
+
"""VLDiagnoseLoop — the current M1→M2→M3→M4 diagnosis loop.
|
|
2
|
+
|
|
3
|
+
┌──────────────────────────────────────────────────────────────────────┐
|
|
4
|
+
│ M1 · ProbeAgent protocol-guided analyzer selection + execute │
|
|
5
|
+
│ M2 · StatsAnalysisAgent protocol-aware stats analysis │
|
|
6
|
+
│ M3 · DiagnosisAgent "AI scientist" hypothesis generation │
|
|
7
|
+
│ M4 · HypothesisTester stats test + protocol consistency check │
|
|
8
|
+
└──────────────────────────────────────────────────────────────────────┘
|
|
9
|
+
↑_________________________________│
|
|
10
|
+
stop when M4 finds a verified, protocol-consistent hypothesis
|
|
11
|
+
|
|
12
|
+
M5 (SurgeryAgent) runs separately via ``VLDiagnoseLoop.run_m5()`` once the
|
|
13
|
+
loop stops — propose a fix for the best verified hypothesis (Plan A), or
|
|
14
|
+
propose + execute a fix (Plan B).
|
|
15
|
+
|
|
16
|
+
See also:
|
|
17
|
+
- :class:`~evalrx.eval_agent.agentic.AgenticDiagnoseLoop` — the same
|
|
18
|
+
M1-M4 stages driven by a judge-decided action loop instead of a fixed
|
|
19
|
+
cycle.
|
|
20
|
+
- :mod:`~evalrx.eval_agent.legacy` — ``SelfEvolveLoop`` and
|
|
21
|
+
``AutoDiagnoseLoop`` (the pre-2026-06-05 M1→M2→M3→M5 architecture), kept
|
|
22
|
+
for existing callers.
|
|
23
|
+
|
|
24
|
+
Usage::
|
|
25
|
+
|
|
26
|
+
from evalrx.eval_agent import VLDiagnoseLoop
|
|
27
|
+
from evalrx.eval_agent.stages.protocol import ExperimentProtocol
|
|
28
|
+
|
|
29
|
+
protocol = ExperimentProtocol(
|
|
30
|
+
description="QwenVL often confuses left/right positions in spatial tasks.",
|
|
31
|
+
task_domain="spatial reasoning",
|
|
32
|
+
target_modalities=frozenset({"text", "image"}),
|
|
33
|
+
)
|
|
34
|
+
loop = VLDiagnoseLoop(model=vlm, protocol=protocol)
|
|
35
|
+
report = loop.run(failure_cases)
|
|
36
|
+
fix = loop.run_m5(report, failure_cases) # separate fix-proposal step
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import logging
|
|
42
|
+
import random
|
|
43
|
+
import time
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
from typing import TYPE_CHECKING, Any
|
|
46
|
+
|
|
47
|
+
from evalrx.eval_agent.loop_reports import VLDiagnoseReport
|
|
48
|
+
from evalrx.eval_agent.run_metadata import (
|
|
49
|
+
_attach_run_logger,
|
|
50
|
+
_coerce_explore_context,
|
|
51
|
+
_log_generated_tools,
|
|
52
|
+
_make_intervention_result_from_test,
|
|
53
|
+
_run_config,
|
|
54
|
+
)
|
|
55
|
+
from evalrx.eval_agent.store import InMemoryStore, Store
|
|
56
|
+
|
|
57
|
+
if TYPE_CHECKING:
|
|
58
|
+
from evalrx.analysis.stats_agent import StatsAnalysisAgent
|
|
59
|
+
from evalrx.core.case import CaseBatch
|
|
60
|
+
from evalrx.core.model import Model
|
|
61
|
+
from evalrx.eval_agent.stages.hypothesis_tester import HypothesisTester
|
|
62
|
+
from evalrx.eval_agent.stages.protocol import ExperimentProtocol
|
|
63
|
+
|
|
64
|
+
logger = logging.getLogger(__name__)
|
|
65
|
+
|
|
66
|
+
_STOPPED_BY_CRITERIA = "criteria_met"
|
|
67
|
+
_STOPPED_BY_MAX = "max_cycles"
|
|
68
|
+
_STOPPED_BY_BUDGET = "budget"
|
|
69
|
+
_STOPPED_BY_NO_HYPS = "no_hypotheses"
|
|
70
|
+
_STOPPED_BY_NO_PROBE = "no_probe_results"
|
|
71
|
+
# run_analysis() ran M1->M2->M3 and proposed hypotheses without confirming them
|
|
72
|
+
# (M4 deferred to run_confirm()). Not a failure — the analysis dashboard is ready.
|
|
73
|
+
_STOPPED_BY_ANALYSIS = "analysis_complete"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _exact_size(picked: "list", pool: "list", n: int, seed: int) -> "list":
|
|
77
|
+
"""Top a stratified draw up to exactly *n* items, deterministically.
|
|
78
|
+
|
|
79
|
+
:func:`~evalrx.stats.subset_sampling.stratified_subset` returns at most
|
|
80
|
+
*n* items but per-stratum rounding can under-allocate by one or two; the
|
|
81
|
+
train/val/test split promises exact 1:1:1 counts, so the shortfall is
|
|
82
|
+
filled from a seeded shuffle of the untaken remainder of *pool*.
|
|
83
|
+
"""
|
|
84
|
+
if len(picked) >= n:
|
|
85
|
+
return picked[:n]
|
|
86
|
+
picked_ids = {id(c) for c in picked}
|
|
87
|
+
leftover = [c for c in pool if id(c) not in picked_ids]
|
|
88
|
+
random.Random(seed + 7919).shuffle(leftover)
|
|
89
|
+
return picked + leftover[: n - len(picked)]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _diagnose_with_optional_context(
|
|
93
|
+
diag_agent: "Any", stats_report: "Any", prior_cycles: "Any", explore_context: "Any | None",
|
|
94
|
+
failure_modes: "Any | None" = None, cases: "Any | None" = None,
|
|
95
|
+
) -> "Any":
|
|
96
|
+
"""Call ``diag_agent.diagnose`` passing ``explore_context``/``failure_modes``/
|
|
97
|
+
``cases`` only when the agent accepts them, so custom/legacy diagnosis
|
|
98
|
+
agents keep working unchanged. ``cases`` (the explore-split batch M1/M2
|
|
99
|
+
ran on) only feeds the critic's label summary -- never the proposer."""
|
|
100
|
+
import inspect as _inspect
|
|
101
|
+
|
|
102
|
+
kwargs: dict[str, Any] = {"prior_cycles": prior_cycles or None}
|
|
103
|
+
optional = {"explore_context": explore_context, "failure_modes": failure_modes,
|
|
104
|
+
"cases": cases}
|
|
105
|
+
if any(v is not None for v in optional.values()):
|
|
106
|
+
try:
|
|
107
|
+
params = _inspect.signature(diag_agent.diagnose).parameters
|
|
108
|
+
except (TypeError, ValueError):
|
|
109
|
+
params = {}
|
|
110
|
+
for name, value in optional.items():
|
|
111
|
+
if value is not None and name in params:
|
|
112
|
+
kwargs[name] = value
|
|
113
|
+
return diag_agent.diagnose(stats_report, **kwargs)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _propose_and_validate(agent: "Any", model: "Any", data: "Any", hypotheses: "Any",
|
|
117
|
+
**kwargs: Any) -> "Any":
|
|
118
|
+
"""Call ``agent.propose_and_validate`` passing ``prior_attempts`` /
|
|
119
|
+
``context`` only when the agent accepts them (custom/stub fix agents keep
|
|
120
|
+
working unchanged — same pattern as ``_diagnose_with_optional_context``)."""
|
|
121
|
+
import inspect as _inspect
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
params = _inspect.signature(agent.propose_and_validate).parameters
|
|
125
|
+
except (TypeError, ValueError):
|
|
126
|
+
params = {}
|
|
127
|
+
accepts_any = any(p.kind == p.VAR_KEYWORD for p in params.values())
|
|
128
|
+
passed = {k: v for k, v in kwargs.items()
|
|
129
|
+
if v is not None and (accepts_any or k in params)}
|
|
130
|
+
return agent.propose_and_validate(model, data, hypotheses, **passed)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _unverified_hypotheses(report: "Any") -> "list[Any]":
|
|
134
|
+
"""Best-first UNVERIFIED hypotheses: M4-tested and not refuted, highest
|
|
135
|
+
confidence first; then untested proposals from the last cycle."""
|
|
136
|
+
seen: set[str] = set()
|
|
137
|
+
out: list[Any] = []
|
|
138
|
+
tested = list(getattr(report, "all_test_results", None) or [])
|
|
139
|
+
|
|
140
|
+
def _critic_ok(tr: "Any") -> int:
|
|
141
|
+
meta = getattr(getattr(tr, "hypothesis", None), "metadata", None) or {}
|
|
142
|
+
return 0 if meta.get("critic") == "reject" else 1
|
|
143
|
+
|
|
144
|
+
ranked = sorted(
|
|
145
|
+
tested,
|
|
146
|
+
key=lambda tr: (float(getattr(tr, "confidence", 0.0) or 0.0), _critic_ok(tr)),
|
|
147
|
+
reverse=True,
|
|
148
|
+
)
|
|
149
|
+
for tr in ranked:
|
|
150
|
+
h = getattr(tr, "hypothesis", None)
|
|
151
|
+
if h is None or _hyp_key(h) in seen:
|
|
152
|
+
continue
|
|
153
|
+
seen.add(_hyp_key(h)) # tested (in any way) -> never re-added as "untested"
|
|
154
|
+
status = getattr(tr, "status", None)
|
|
155
|
+
status_s = str(getattr(status, "value", status) or "").lower()
|
|
156
|
+
if status_s == "refuted":
|
|
157
|
+
continue
|
|
158
|
+
out.append(h)
|
|
159
|
+
for h in reversed(list(getattr(report, "final_hypotheses", None) or [])):
|
|
160
|
+
if _hyp_key(h) not in seen:
|
|
161
|
+
seen.add(_hyp_key(h))
|
|
162
|
+
out.append(h)
|
|
163
|
+
return out
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _m5_supported_key(report: "Any") -> "str | None":
|
|
167
|
+
"""Key of the hypothesis M5's experiment SUPPORTED, if any."""
|
|
168
|
+
iv = getattr(report, "fix_proposal", None)
|
|
169
|
+
status = getattr(iv, "status", None)
|
|
170
|
+
status_s = str(getattr(status, "value", status) or "").lower()
|
|
171
|
+
hyp = getattr(iv, "hypothesis", None)
|
|
172
|
+
if iv is None or hyp is None or status_s != "supported":
|
|
173
|
+
return None
|
|
174
|
+
return _hyp_key(hyp)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _hyp_key(hypothesis: "Any") -> str:
|
|
178
|
+
"""Identity of a hypothesis for matching across M4/M5 results."""
|
|
179
|
+
hid = str(getattr(hypothesis, "id", "") or "")
|
|
180
|
+
return hid or str(getattr(hypothesis, "statement", hypothesis))
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _m5_refuted(report: "Any") -> "tuple[set[str], list[str]]":
|
|
184
|
+
"""Hypotheses M5's intervention experiment REFUTED, with a one-line why.
|
|
185
|
+
|
|
186
|
+
Reads ``report.fix_proposal`` (an ``InterventionResult`` from ``run_m5``).
|
|
187
|
+
Returns ``(keys, notes)``; both empty when M5 did not run or did not refute.
|
|
188
|
+
"""
|
|
189
|
+
iv = getattr(report, "fix_proposal", None)
|
|
190
|
+
if iv is None:
|
|
191
|
+
return set(), []
|
|
192
|
+
status = getattr(iv, "status", None)
|
|
193
|
+
status_s = str(getattr(status, "value", status) or "").lower()
|
|
194
|
+
if status_s != "refuted":
|
|
195
|
+
return set(), []
|
|
196
|
+
hyp = getattr(iv, "hypothesis", None)
|
|
197
|
+
if hyp is None:
|
|
198
|
+
return set(), []
|
|
199
|
+
statement = str(getattr(hyp, "statement", hyp))
|
|
200
|
+
evidence = getattr(iv, "evidence", None) or {}
|
|
201
|
+
scalars = []
|
|
202
|
+
if isinstance(evidence, dict):
|
|
203
|
+
for k, v in evidence.items():
|
|
204
|
+
if isinstance(v, (int, float)) and not isinstance(v, bool):
|
|
205
|
+
scalars.append(f"{k}={v:g}" if isinstance(v, float) else f"{k}={v}")
|
|
206
|
+
if len(scalars) >= 8:
|
|
207
|
+
break
|
|
208
|
+
why = f" [experiment: {', '.join(scalars)}]" if scalars else ""
|
|
209
|
+
return {_hyp_key(hyp)}, [f"{statement}{why}"]
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _fix_context_from_report(
|
|
213
|
+
report: "Any",
|
|
214
|
+
*,
|
|
215
|
+
example_cases: "Any | None",
|
|
216
|
+
explore_context: "Any | None",
|
|
217
|
+
protocol: "Any | None",
|
|
218
|
+
refuted: "list[str]",
|
|
219
|
+
refuted_ids: "set[str] | None" = None,
|
|
220
|
+
) -> "Any":
|
|
221
|
+
"""Build the :class:`~evalrx.eval_agent.stages.fix_agent.FixContext`
|
|
222
|
+
the proposer sees: M4 verdicts + M2 conclusion/tests + exploratory notes,
|
|
223
|
+
the M5-refuted hypotheses, and (when a confirm split is in play) the
|
|
224
|
+
EXPLORE cases in full."""
|
|
225
|
+
from evalrx.eval_agent.stages.fix_agent import FixContext
|
|
226
|
+
|
|
227
|
+
lines: list[str] = []
|
|
228
|
+
refuted_ids = set(refuted_ids or ())
|
|
229
|
+
verified = [
|
|
230
|
+
tr for tr in list(getattr(report, "verified_hypotheses", None) or [])
|
|
231
|
+
if _hyp_key(getattr(tr, "hypothesis", None)) not in refuted_ids
|
|
232
|
+
]
|
|
233
|
+
if verified:
|
|
234
|
+
lines.append(" M4 verified hypotheses (statistical tests on the diagnosis split):")
|
|
235
|
+
for tr in verified[:6]:
|
|
236
|
+
stmt = str(getattr(getattr(tr, "hypothesis", None), "statement", ""))[:220]
|
|
237
|
+
verdict = str(getattr(tr, "verdict", "") or "")[:300]
|
|
238
|
+
conf = getattr(tr, "confidence", None)
|
|
239
|
+
grade = getattr(tr, "evidence_grade", "")
|
|
240
|
+
conf_s = f" conf={conf:.2f}" if isinstance(conf, (int, float)) else ""
|
|
241
|
+
lines.append(f" - {stmt}{conf_s} grade={grade}")
|
|
242
|
+
if verdict:
|
|
243
|
+
lines.append(f" evidence: {verdict}")
|
|
244
|
+
stats = getattr(report, "final_stats_report", None) or getattr(report, "final_analysis", None)
|
|
245
|
+
if stats is not None:
|
|
246
|
+
conclusion = str(getattr(stats, "conclusion", "") or getattr(stats, "narrative", "") or "")
|
|
247
|
+
if conclusion:
|
|
248
|
+
lines.append(" M2 conclusion:")
|
|
249
|
+
lines.append(" " + conclusion.strip()[:1200])
|
|
250
|
+
chain = list(getattr(stats, "evidence_chain", None) or [])
|
|
251
|
+
if chain:
|
|
252
|
+
lines.append(" M2 evidence chain:")
|
|
253
|
+
lines += [f" - {str(step)[:300]}" for step in chain[:8]]
|
|
254
|
+
results = list(getattr(stats, "stats_results", None) or [])
|
|
255
|
+
sig = [r for r in results if getattr(r, "ok", False) and getattr(r, "reject", False)]
|
|
256
|
+
shown = sig[:8] or [r for r in results if getattr(r, "ok", False)][:5]
|
|
257
|
+
if shown:
|
|
258
|
+
lines.append(" M2 statistical tests" + (" (rejecting H0):" if sig else ":"))
|
|
259
|
+
lines += [f" - {str(getattr(r, 'summary', ''))[:300]}" for r in shown]
|
|
260
|
+
if explore_context is not None and not getattr(explore_context, "is_empty", True):
|
|
261
|
+
obs = list(getattr(explore_context, "observations", None) or [])
|
|
262
|
+
cav = list(getattr(explore_context, "caveats", None) or [])
|
|
263
|
+
if obs:
|
|
264
|
+
lines.append(" Exploratory notes (free-form EDA, UNCONFIRMED — hints only):")
|
|
265
|
+
lines += [f" - {str(o)[:300]}" for o in obs[:10]]
|
|
266
|
+
if cav:
|
|
267
|
+
lines.append(" Explorer caveats:")
|
|
268
|
+
lines += [f" - {str(c)[:200]}" for c in cav[:5]]
|
|
269
|
+
task_bits = []
|
|
270
|
+
for attr in ("description", "task_domain", "failure_patterns"):
|
|
271
|
+
value = str(getattr(protocol, attr, "") or "").strip()
|
|
272
|
+
if value:
|
|
273
|
+
task_bits.append(value)
|
|
274
|
+
return FixContext(
|
|
275
|
+
example_cases=example_cases,
|
|
276
|
+
evidence="\n".join(lines),
|
|
277
|
+
refuted=list(refuted),
|
|
278
|
+
task_note=" — ".join(task_bits),
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class VLDiagnoseLoop:
|
|
283
|
+
"""M1→M2→M3→M4 failure-analysis loop for VL tasks (Plan A architecture).
|
|
284
|
+
|
|
285
|
+
M5 (**SurgeryAgent**) is intentionally excluded from the inner loop.
|
|
286
|
+
Call :meth:`run_m5` on the returned :class:`VLDiagnoseReport` to obtain
|
|
287
|
+
a fix proposal based on the best verified hypothesis candidates.
|
|
288
|
+
|
|
289
|
+
Inner loop::
|
|
290
|
+
|
|
291
|
+
for cycle in range(max_cycles):
|
|
292
|
+
probe_results = M1.probe(model, data, protocol) # guided by protocol
|
|
293
|
+
explore_notes = explorer.explore_records(...) # optional, descriptive
|
|
294
|
+
stats_report = M2.analyze(probe_results, protocol)
|
|
295
|
+
diag = M3.diagnose(stats_report, explore_context=explore_notes)
|
|
296
|
+
test_results = M4.test(diag.hypotheses, stats_report, data, protocol)
|
|
297
|
+
if M4.stopping_criteria_met(test_results, protocol): break
|
|
298
|
+
|
|
299
|
+
Stopping criteria: at least one M4-verified hypothesis that is also
|
|
300
|
+
consistent with the user's experiment protocol.
|
|
301
|
+
|
|
302
|
+
**Decoupled two-phase use** (analysis → deferred confirm + fix)::
|
|
303
|
+
|
|
304
|
+
# Phase 1 — analyse + propose, build the dashboard. No M4, no fix.
|
|
305
|
+
report = loop.run_analysis(data) # M1 → M2 → M3, stop
|
|
306
|
+
save(report.final_hypotheses, report.final_stats_report)
|
|
307
|
+
|
|
308
|
+
# Phase 2 — later, reuse the saved artifacts to confirm + repair.
|
|
309
|
+
report = loop.run_confirm(data, hypotheses, stats_report=stats) # M4
|
|
310
|
+
loop.run_fix(report, data) # tiered repair
|
|
311
|
+
|
|
312
|
+
:meth:`run` is the all-in-one path (M1→M2→M3→M4 + stopping). Use
|
|
313
|
+
:meth:`run_analysis` + :meth:`run_confirm` when you want the analysis
|
|
314
|
+
dashboard *before* deciding whether to confirm hypotheses and repair.
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
model: The model under evaluation.
|
|
318
|
+
protocol: Experiment protocol — the human prior that guides
|
|
319
|
+
M1 analyzer selection, M2 narrative, and M4
|
|
320
|
+
consistency checks.
|
|
321
|
+
probe_agent: M1. Defaults to ``ProbeAgent()``.
|
|
322
|
+
stats_agent: M2. Defaults to ``StatsAnalysisAgent()``.
|
|
323
|
+
diagnosis_agent: M3. ``None`` lazily resolves ``DiagnosisAgent()``
|
|
324
|
+
on first use.
|
|
325
|
+
hypothesis_tester: M4. Defaults to ``HypothesisTester()``.
|
|
326
|
+
surgery_agent: M5 — used only by :meth:`run_m5`, never inside
|
|
327
|
+
the main loop. Defaults to ``SurgeryAgent()``.
|
|
328
|
+
store: Persistent memory.
|
|
329
|
+
m4_holdout: When a confirm split is in play (default on),
|
|
330
|
+
M4 is taken OUT of the cycle entirely: the cycles
|
|
331
|
+
only mine (M1→explore→M2→M3), and after the loop
|
|
332
|
+
every proposed hypothesis is tested ONCE on the
|
|
333
|
+
held-out confirm split (the last cycle's analyzers
|
|
334
|
+
re-run there, pinned). The loop neither stops
|
|
335
|
+
early nor keeps cycling based on M4 verdicts —
|
|
336
|
+
the same discipline the fix gate follows. Without
|
|
337
|
+
a confirm split M4 stays in-cycle as before.
|
|
338
|
+
test_split: Optional third partition (train/val/test mode).
|
|
339
|
+
When > 0 alongside ``confirm_split``, the batch is
|
|
340
|
+
split three ways: the loop mines on EXPLORE (train),
|
|
341
|
+
M4's single holdout pass AND the fix stage's whole
|
|
342
|
+
candidate search/selection run on CONFIRM (val),
|
|
343
|
+
and the frozen winner is scored exactly once on the
|
|
344
|
+
TEST partition — so hypothesis verification and fix
|
|
345
|
+
development never share cases with the final gate.
|
|
346
|
+
0.0 (default) keeps the two-way explore/confirm
|
|
347
|
+
behaviour byte-for-byte.
|
|
348
|
+
val_data: Optional EXTERNAL validation batch (cases frozen
|
|
349
|
+
from a separate, disjoint sample — e.g. a task's
|
|
350
|
+
``manifest_val.json``). The main batch keeps the
|
|
351
|
+
two-way explore/confirm split; when set (with
|
|
352
|
+
``m4_holdout``), M4's single holdout pass runs on
|
|
353
|
+
these cases instead of the confirm partition, the
|
|
354
|
+
fix ladder is searched and its winner selected
|
|
355
|
+
here, and the frozen winner is scored exactly once
|
|
356
|
+
on the untouched CONFIRM partition. The same
|
|
357
|
+
train/val/test discipline as ``test_split``, but
|
|
358
|
+
the validation data costs no confirm power and no
|
|
359
|
+
training cases. Mutually exclusive with
|
|
360
|
+
``test_split > 0``.
|
|
361
|
+
max_cycles: Hard cap on M1→M4 iterations (default 1: one
|
|
362
|
+
diagnosis pass, then the caller moves on to
|
|
363
|
+
M5/fix — with fix-on-unverified enabled the
|
|
364
|
+
extra cycles rarely verified anything and
|
|
365
|
+
tripled the wall-clock; raise it to keep
|
|
366
|
+
mining when a cycle's M4 designs feed the
|
|
367
|
+
next cycle's M1).
|
|
368
|
+
run_logger: Optional :class:`~evalrx.eval_agent.run_logger_v2.RunLoggerV2`.
|
|
369
|
+
token_budget: Stop early when accumulated token usage reaches
|
|
370
|
+
this limit (0 = unlimited).
|
|
371
|
+
analysis_only: Run only M1→M2 and stop before hypothesis generation.
|
|
372
|
+
explorer: Optional :class:`~evalrx.analysis.explorer.ExploratoryAnalysisAgent`.
|
|
373
|
+
When given, every cycle runs a free-form EDA step
|
|
374
|
+
between M1 and M2 over the same per-case table M2
|
|
375
|
+
sees (M1 analyzer signals + labels). Its output is
|
|
376
|
+
DESCRIPTIVE ONLY: observations/charts/caveats go to
|
|
377
|
+
M3 as an ``ExploreContext`` (which hypotheses to
|
|
378
|
+
propose) and to disk for the dashboard — never into
|
|
379
|
+
M2's confirmatory family, M4, or the fix gate. The
|
|
380
|
+
catalog M2 is unchanged. Best-effort: an explorer
|
|
381
|
+
failure logs a warning and the cycle continues.
|
|
382
|
+
explore_dir: Where the explore step persists
|
|
383
|
+
``exploratory_report.json`` + ``tables/`` +
|
|
384
|
+
``figures/`` (rendered chart PNGs, which M3 is shown).
|
|
385
|
+
Default: ``<ctx.root>/explore`` for a RunContext-
|
|
386
|
+
backed logger, ``<run>/explore`` beside a standalone
|
|
387
|
+
``logs*/`` dir, else ``<run_dir>/explore`` — always
|
|
388
|
+
inside the run, where the dashboard looks; with no
|
|
389
|
+
run_logger, nothing is persisted.
|
|
390
|
+
explore_question: The question handed to the explorer. Defaults to
|
|
391
|
+
:func:`~evalrx.eval_agent.prompts.explore_step.default_explore_question`
|
|
392
|
+
built from *protocol*.
|
|
393
|
+
verbose: When ``True``, print live M1-M4 stage narration to
|
|
394
|
+
stdout (equivalent to calling
|
|
395
|
+
``evalrx.enable_console_logging()`` yourself).
|
|
396
|
+
Separate from ``run_logger`` — this surfaces the
|
|
397
|
+
existing ``logger.info()``/``.warning()`` calls,
|
|
398
|
+
not RunLogger's structured JSONL event stream.
|
|
399
|
+
"""
|
|
400
|
+
|
|
401
|
+
def __init__(
|
|
402
|
+
self,
|
|
403
|
+
model: "Model",
|
|
404
|
+
protocol: "ExperimentProtocol",
|
|
405
|
+
probe_agent: "Any | None" = None,
|
|
406
|
+
stats_agent: "StatsAnalysisAgent | None" = None,
|
|
407
|
+
diagnosis_agent: "Any | None" = None,
|
|
408
|
+
hypothesis_tester: "HypothesisTester | None" = None,
|
|
409
|
+
surgery_agent: "Any | None" = None,
|
|
410
|
+
fix_agent: "Any | None" = None,
|
|
411
|
+
store: Store | None = None,
|
|
412
|
+
max_cycles: int = 1,
|
|
413
|
+
run_logger: "Any | None" = None,
|
|
414
|
+
token_budget: int = 0,
|
|
415
|
+
analysis_only: bool = False,
|
|
416
|
+
confirm_split: float = 0.0,
|
|
417
|
+
confirm_split_seed: int = 0,
|
|
418
|
+
m4_holdout: bool = True,
|
|
419
|
+
test_split: float = 0.0,
|
|
420
|
+
val_data: "CaseBatch | None" = None,
|
|
421
|
+
signal_recipes: "list | None" = None,
|
|
422
|
+
bridge_analyzer_name: str = "explored",
|
|
423
|
+
explore_report: "Any | None" = None,
|
|
424
|
+
explorer: "Any | None" = None,
|
|
425
|
+
explore_dir: "str | Path | None" = None,
|
|
426
|
+
explore_question: str = "",
|
|
427
|
+
verbose: bool = False,
|
|
428
|
+
) -> None:
|
|
429
|
+
from evalrx.analysis.stats_agent import StatsAnalysisAgent
|
|
430
|
+
from evalrx.eval_agent.stages.fix_agent import FixAgent
|
|
431
|
+
from evalrx.eval_agent.stages.hypothesis_tester import HypothesisTester
|
|
432
|
+
from evalrx.eval_agent.stages.probe_agent import ProbeAgent
|
|
433
|
+
from evalrx.eval_agent.stages.surgery import SurgeryAgent
|
|
434
|
+
|
|
435
|
+
if verbose:
|
|
436
|
+
# M1-M4 already narrate every stage transition via logger.info()/
|
|
437
|
+
# .warning() (this module's `logger`, plus probe_agent/diagnosis/
|
|
438
|
+
# hypothesis_tester/fix_agent's own) -- it's just invisible by
|
|
439
|
+
# default. This is the one-line equivalent of a caller doing
|
|
440
|
+
# `evalrx.enable_console_logging()` themselves; it does NOT
|
|
441
|
+
# touch RunLogger's separate structured JSONL event stream.
|
|
442
|
+
from evalrx.logging_utils import enable_console_logging
|
|
443
|
+
|
|
444
|
+
enable_console_logging()
|
|
445
|
+
|
|
446
|
+
self.model = model
|
|
447
|
+
self.protocol = protocol
|
|
448
|
+
self.probe_agent = probe_agent or ProbeAgent()
|
|
449
|
+
self.stats_agent = stats_agent or StatsAnalysisAgent()
|
|
450
|
+
self.diagnosis_agent = diagnosis_agent # None = lazy default on first call
|
|
451
|
+
self.hypothesis_tester = hypothesis_tester or HypothesisTester()
|
|
452
|
+
self.surgery_agent = surgery_agent or SurgeryAgent()
|
|
453
|
+
self.fix_agent = fix_agent or FixAgent(run_logger=run_logger)
|
|
454
|
+
self.store = store or InMemoryStore()
|
|
455
|
+
self.max_cycles = max_cycles
|
|
456
|
+
self.run_logger = run_logger
|
|
457
|
+
self.token_budget = token_budget
|
|
458
|
+
self.analysis_only = analysis_only
|
|
459
|
+
# Held-out CONFIRM split (leak #3): fraction of the batch reserved, away
|
|
460
|
+
# from M1-M4 hypothesis generation, for the post-loop fix/surgery to
|
|
461
|
+
# validate on — so the deployed fix is confirmed on data the loop never
|
|
462
|
+
# mined. 0.0 = off (current behavior); the split is deterministic
|
|
463
|
+
# (stratified by label+probe_type, seeded), so run() and run_m5/run_fix
|
|
464
|
+
# derive the identical partition from the same input batch.
|
|
465
|
+
self.confirm_split = float(confirm_split)
|
|
466
|
+
self.confirm_split_seed = int(confirm_split_seed)
|
|
467
|
+
self.m4_holdout = bool(m4_holdout)
|
|
468
|
+
# Train/val/test mode: a second held-out fraction. With both fractions
|
|
469
|
+
# set, CONFIRM becomes the VALIDATION partition (M4 holdout + fix
|
|
470
|
+
# candidate development) and the frozen fix candidate is scored exactly
|
|
471
|
+
# once on the TEST partition. 0.0 = two-way behaviour, unchanged.
|
|
472
|
+
self.test_split = float(test_split)
|
|
473
|
+
# External validation batch: the val role filled by SEPARATE frozen
|
|
474
|
+
# cases instead of a third carve of the main batch. With m4_holdout it
|
|
475
|
+
# takes the holdout-M4 + fix-development roles; the main batch keeps
|
|
476
|
+
# its full two-way explore/confirm split, and CONFIRM stays the single
|
|
477
|
+
# frozen-winner gate. Exclusive with test_split — two competing
|
|
478
|
+
# definitions of "validation" would be a config error, not a feature.
|
|
479
|
+
if val_data is not None and self.test_split > 0.0:
|
|
480
|
+
raise ValueError("val_data and test_split are mutually exclusive: "
|
|
481
|
+
"pass an external validation batch OR carve one "
|
|
482
|
+
"from the main batch, not both")
|
|
483
|
+
self.val_data = val_data
|
|
484
|
+
# Operationalization bridge (off by default): pre-registered SignalRecipes
|
|
485
|
+
# are compiled over the analyzer per_case signals each cycle into a synthetic
|
|
486
|
+
# "<bridge_analyzer_name>" analyzer Result, so LAMBDA-discovered composite
|
|
487
|
+
# signals enter M2's family via the standard findings["per_case"] contract.
|
|
488
|
+
# Leak-free by construction — recipes must be discovered out-of-band (e.g.
|
|
489
|
+
# the fused pipeline's held-out split), never by peeking at these labels.
|
|
490
|
+
self._signal_recipes = list(signal_recipes or [])
|
|
491
|
+
self._bridge_analyzer_name = bridge_analyzer_name
|
|
492
|
+
# Step-1 explorer mechanism notes (charts/observations/caveats). Descriptive,
|
|
493
|
+
# UNCONFIRMED: passed to M3's hypothesis-proposal prompt ONLY — never to the
|
|
494
|
+
# M2 confirmatory family, M4 testing, or the fix gate. Accepts an
|
|
495
|
+
# ExploreContext, a report dict (fused_report.json), or None.
|
|
496
|
+
self._explore_context = _coerce_explore_context(explore_report)
|
|
497
|
+
# In-cycle explore step (off by default): a free-form EDA pass over the
|
|
498
|
+
# M1 per-case table, run between M1 and M2. Same standing as a Step-1
|
|
499
|
+
# explore_report — descriptive notes for M3 + files for the dashboard.
|
|
500
|
+
# AgenticDiagnoseLoop overrides `self.explorer` after this constructor
|
|
501
|
+
# and drives it through its own judge-decided `explore_data` tool
|
|
502
|
+
# instead of the fixed in-cycle call (see run()/run_analysis()).
|
|
503
|
+
self.explorer = explorer
|
|
504
|
+
self._explore_dir = Path(explore_dir) if explore_dir is not None else None
|
|
505
|
+
self._explore_question = str(explore_question or "")
|
|
506
|
+
self._tokens_used: int = 0
|
|
507
|
+
self._run_id: str = ""
|
|
508
|
+
self._emitter: "Any | None" = None
|
|
509
|
+
|
|
510
|
+
# ------------------------------------------------------------------
|
|
511
|
+
# Contract emission — one validated payload per stage under <run>/contract/
|
|
512
|
+
# ------------------------------------------------------------------
|
|
513
|
+
@property
|
|
514
|
+
def emitter(self) -> "Any | None":
|
|
515
|
+
"""Lazily built :class:`~evalrx.contract.emit.ContractEmitter`.
|
|
516
|
+
|
|
517
|
+
``None`` when there is no run directory to write into, or when pydantic
|
|
518
|
+
(the ``contract`` extra) is not installed — the contract is an observer
|
|
519
|
+
of the pipeline and must never be a condition for running it.
|
|
520
|
+
"""
|
|
521
|
+
if self._emitter is not None:
|
|
522
|
+
return self._emitter
|
|
523
|
+
if self.run_logger is None:
|
|
524
|
+
return None
|
|
525
|
+
root = getattr(getattr(self.run_logger, "_context", None), "root", None)
|
|
526
|
+
if root is None:
|
|
527
|
+
run_dir = getattr(self.run_logger, "run_dir", None)
|
|
528
|
+
if run_dir is None:
|
|
529
|
+
return None
|
|
530
|
+
run_dir = Path(run_dir)
|
|
531
|
+
root = run_dir.parent if run_dir.name.startswith("logs") else run_dir
|
|
532
|
+
try:
|
|
533
|
+
from evalrx.contract.emit import ContractEmitter
|
|
534
|
+
except ImportError: # pragma: no cover - contract extra not installed
|
|
535
|
+
logger.debug("contract emission off: pip install evalrx[contract]")
|
|
536
|
+
return None
|
|
537
|
+
self._emitter = ContractEmitter(root, getattr(self.run_logger, "trace_id", ""))
|
|
538
|
+
return self._emitter
|
|
539
|
+
|
|
540
|
+
def _emit(self, name: str, build: "Any") -> None:
|
|
541
|
+
"""Emit one stage payload; never raises into the pipeline."""
|
|
542
|
+
emitter = self.emitter
|
|
543
|
+
if emitter is None:
|
|
544
|
+
return
|
|
545
|
+
emitter.emit(name, build)
|
|
546
|
+
|
|
547
|
+
def _set_fix_outcome(self, report: "Any", outcome: "Any") -> "Any":
|
|
548
|
+
"""Attach M5b's outcome to the report and emit it.
|
|
549
|
+
|
|
550
|
+
``run_fix`` has five return paths (escalation, frozen-candidate confirm,
|
|
551
|
+
legacy agents, skip, plain), so the assignment is the one point they all
|
|
552
|
+
pass through. Emitting per return would have missed whichever path was
|
|
553
|
+
added next.
|
|
554
|
+
"""
|
|
555
|
+
report.fix_outcome = outcome
|
|
556
|
+
if self.emitter is not None and hasattr(outcome, "attempted"):
|
|
557
|
+
from evalrx.contract.emit import from_fix_outcome
|
|
558
|
+
|
|
559
|
+
self._emit("m5_fix", lambda: from_fix_outcome(
|
|
560
|
+
outcome, trace_id=self.emitter.trace_id, run_root=self.emitter.root,
|
|
561
|
+
))
|
|
562
|
+
return outcome
|
|
563
|
+
|
|
564
|
+
def publish_report(
|
|
565
|
+
self,
|
|
566
|
+
*,
|
|
567
|
+
model: "Any | None" = None,
|
|
568
|
+
example_dir: "str | Path | None" = None,
|
|
569
|
+
) -> "Any":
|
|
570
|
+
"""Compose the completed-run UI, reusing the M3 judge when available."""
|
|
571
|
+
if self.run_logger is None:
|
|
572
|
+
raise RuntimeError("publish_report needs a run_logger with a durable run directory")
|
|
573
|
+
if model is None:
|
|
574
|
+
model = getattr(self.diagnosis_agent, "judge", None)
|
|
575
|
+
from evalrx.reporting.dynamic import publish_report
|
|
576
|
+
|
|
577
|
+
return publish_report(
|
|
578
|
+
self.run_logger.run_dir,
|
|
579
|
+
example_dir=example_dir,
|
|
580
|
+
model=model,
|
|
581
|
+
run_logger=self.run_logger,
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
@staticmethod
|
|
585
|
+
def _strat_key(case: "Any") -> "tuple":
|
|
586
|
+
"""Stratify the explore/confirm split by label + probe_type when present
|
|
587
|
+
(keeps the no-free-lunch control mix, e.g. present-detections, in both
|
|
588
|
+
partitions). Falls back to label alone for generic batches."""
|
|
589
|
+
label = getattr(getattr(case, "label", None), "value", "?")
|
|
590
|
+
probe = (getattr(case, "metadata", {}) or {}).get("probe_type")
|
|
591
|
+
return (label, probe)
|
|
592
|
+
|
|
593
|
+
def _split_partitions(self, data: "CaseBatch"):
|
|
594
|
+
"""Deterministic, stratified (explore, confirm, test) partition.
|
|
595
|
+
|
|
596
|
+
The single source of truth for how a batch is divided; every stage
|
|
597
|
+
re-derives the identical partition from the same input batch. When
|
|
598
|
+
``confirm_split <= 0`` (or the batch is too small to split), returns
|
|
599
|
+
``(data, None, None)`` — a no-op, so existing runs are byte-for-byte
|
|
600
|
+
unchanged. When ``test_split <= 0`` the third element is ``None`` (the
|
|
601
|
+
two-way explore/confirm behaviour). With both fractions set, CONFIRM is
|
|
602
|
+
drawn first (same seed as the two-way split), then TEST from the
|
|
603
|
+
remainder (seed+1); EXPLORE is what is left.
|
|
604
|
+
"""
|
|
605
|
+
from evalrx.core.case import CaseBatch
|
|
606
|
+
from evalrx.stats.subset_sampling import stratified_subset
|
|
607
|
+
|
|
608
|
+
cases = list(data)
|
|
609
|
+
frac = self.confirm_split
|
|
610
|
+
if frac <= 0.0 or len(cases) < 4:
|
|
611
|
+
return data, None, None
|
|
612
|
+
n_confirm = round(len(cases) * frac)
|
|
613
|
+
if n_confirm <= 0 or n_confirm >= len(cases):
|
|
614
|
+
return data, None, None
|
|
615
|
+
three_way = self.test_split > 0.0
|
|
616
|
+
confirm = stratified_subset(cases, self._strat_key, n_confirm,
|
|
617
|
+
seed=self.confirm_split_seed)
|
|
618
|
+
if three_way:
|
|
619
|
+
# ``stratified_subset`` guarantees at MOST n (per-stratum rounding
|
|
620
|
+
# can under-allocate by one or two); the train/val/test mode
|
|
621
|
+
# promises exact counts, so top the draw up deterministically.
|
|
622
|
+
# The two-way path is left untouched — byte-for-byte reproducible.
|
|
623
|
+
confirm = _exact_size(confirm, cases, n_confirm,
|
|
624
|
+
self.confirm_split_seed)
|
|
625
|
+
confirm_ids = {id(c) for c in confirm}
|
|
626
|
+
rest = [c for c in cases if id(c) not in confirm_ids]
|
|
627
|
+
if not three_way:
|
|
628
|
+
return CaseBatch(rest), CaseBatch(confirm), None
|
|
629
|
+
n_test = round(len(cases) * self.test_split)
|
|
630
|
+
if n_test <= 0 or n_test >= len(rest):
|
|
631
|
+
return CaseBatch(rest), CaseBatch(confirm), None
|
|
632
|
+
test = stratified_subset(rest, self._strat_key, n_test,
|
|
633
|
+
seed=self.confirm_split_seed + 1)
|
|
634
|
+
test = _exact_size(test, rest, n_test, self.confirm_split_seed + 1)
|
|
635
|
+
test_ids = {id(c) for c in test}
|
|
636
|
+
explore = [c for c in rest if id(c) not in test_ids]
|
|
637
|
+
return CaseBatch(explore), CaseBatch(confirm), CaseBatch(test)
|
|
638
|
+
|
|
639
|
+
def _split_explore_confirm(self, data: "CaseBatch"):
|
|
640
|
+
"""The (explore, confirm) view of :meth:`_split_partitions`.
|
|
641
|
+
|
|
642
|
+
Stages that never touch the TEST partition (analysis, deferred confirm,
|
|
643
|
+
surgery) use this two-tuple view; the partition is identical to the one
|
|
644
|
+
:meth:`run` / :meth:`run_fix` derive — the test cases are simply
|
|
645
|
+
invisible here, which is exactly the point.
|
|
646
|
+
"""
|
|
647
|
+
explore, confirm, _test = self._split_partitions(data)
|
|
648
|
+
return explore, confirm
|
|
649
|
+
|
|
650
|
+
def _bridge_signals(self, probe_results: "dict[str, Any]", data: "Any | None") -> None:
|
|
651
|
+
"""Compile pre-registered signal recipes into a synthetic analyzer Result
|
|
652
|
+
and inject it into *probe_results* so M2/M3/M4 see the bridged composite
|
|
653
|
+
signals through the standard findings["per_case"] contract. No-op when no
|
|
654
|
+
recipes are configured. Never raises into the loop."""
|
|
655
|
+
if not self._signal_recipes:
|
|
656
|
+
return
|
|
657
|
+
# Never silently overwrite a real analyzer that already used this key.
|
|
658
|
+
name = self._bridge_analyzer_name
|
|
659
|
+
if name in probe_results:
|
|
660
|
+
base, n = name, 1
|
|
661
|
+
while name in probe_results:
|
|
662
|
+
name, n = f"{base}_bridge{n}", n + 1
|
|
663
|
+
logger.warning(
|
|
664
|
+
"bridge analyzer name %r collides with a real analyzer; "
|
|
665
|
+
"injecting under %r instead", base, name,
|
|
666
|
+
)
|
|
667
|
+
try:
|
|
668
|
+
from evalrx.analysis.operationalize import bridge_recipes_to_result
|
|
669
|
+
|
|
670
|
+
synth = bridge_recipes_to_result(
|
|
671
|
+
self._signal_recipes, probe_results, data,
|
|
672
|
+
model_repr=repr(self.model),
|
|
673
|
+
analyzer_name=name,
|
|
674
|
+
)
|
|
675
|
+
except Exception as exc: # bridging must never sink the loop
|
|
676
|
+
logger.warning("signal bridge failed: %s", exc)
|
|
677
|
+
return
|
|
678
|
+
if synth is None:
|
|
679
|
+
return
|
|
680
|
+
probe_results[name] = synth
|
|
681
|
+
self.store.add_result(synth)
|
|
682
|
+
# The bridged signals ARE the discovered candidates — they must be tested,
|
|
683
|
+
# not optional. default_plan caps at the stats agent's max_signal_tools and
|
|
684
|
+
# appends bridged signals LAST, so a low cap silently drops them. Raise the
|
|
685
|
+
# cap to cover the expanded family (more multiplicity = more conservative,
|
|
686
|
+
# never less — e-BH still controls FDR over whatever is tested).
|
|
687
|
+
agent = getattr(self, "stats_agent", None)
|
|
688
|
+
if agent is not None and hasattr(agent, "_max_signal_tools"):
|
|
689
|
+
try:
|
|
690
|
+
from evalrx.analysis.stats_tools import build_stats_input
|
|
691
|
+
|
|
692
|
+
n_signals = len(build_stats_input(probe_results, data).per_case)
|
|
693
|
+
current = getattr(agent, "_max_signal_tools", None)
|
|
694
|
+
if current is not None:
|
|
695
|
+
agent._max_signal_tools = max(int(current), n_signals)
|
|
696
|
+
except Exception as exc: # never let the cap-bump break the loop
|
|
697
|
+
logger.debug("bridge: could not raise stats signal cap: %s", exc)
|
|
698
|
+
logger.info(
|
|
699
|
+
"bridged %d signal row(s) into M2 as analyzer %r",
|
|
700
|
+
len(synth.findings.get("per_case", [])), name,
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
# ──────────────────────────────────────────────────────────────────
|
|
704
|
+
# Stage helpers (shared by run / run_analysis / run_confirm)
|
|
705
|
+
# ──────────────────────────────────────────────────────────────────
|
|
706
|
+
|
|
707
|
+
def _get_diagnosis_agent(self) -> "Any":
|
|
708
|
+
"""Resolve M3 lazily so the default Gemini fallback matches
|
|
709
|
+
AutoDiagnoseLoop — a DiagnosisAgent() built eagerly would raise if
|
|
710
|
+
GEMINI_API_KEY is absent even when the caller passed diagnosis_agent=None."""
|
|
711
|
+
if self.diagnosis_agent is not None:
|
|
712
|
+
return self.diagnosis_agent
|
|
713
|
+
from evalrx.eval_agent.stages.diagnosis import DiagnosisAgent
|
|
714
|
+
return DiagnosisAgent()
|
|
715
|
+
|
|
716
|
+
def _do_m1(
|
|
717
|
+
self, cycle: int, data: "Any", all_hypotheses: "list[Any]",
|
|
718
|
+
timings: "dict[str, float]", *, log: bool = True,
|
|
719
|
+
) -> "tuple[dict[str, Any], list]":
|
|
720
|
+
"""M1: protocol-guided probing + signal bridge.
|
|
721
|
+
|
|
722
|
+
Returns ``(probe_results, artifact_pngs)``; ``probe_results`` is empty
|
|
723
|
+
when M1 produced nothing (caller decides whether to stop). When
|
|
724
|
+
``log`` is False the probe events are not written — used by
|
|
725
|
+
:meth:`run_confirm` when it only needs to regenerate the stats the
|
|
726
|
+
tester reads (M1/M2 already belong to the analysis phase's log)."""
|
|
727
|
+
prior_modes = list(dict.fromkeys(
|
|
728
|
+
h.predicted_failure_mode for h in all_hypotheses
|
|
729
|
+
if getattr(h, "predicted_failure_mode", None)
|
|
730
|
+
))
|
|
731
|
+
_t0 = time.monotonic()
|
|
732
|
+
probe_results = self.probe_agent.probe(
|
|
733
|
+
self.model,
|
|
734
|
+
data,
|
|
735
|
+
protocol=self.protocol,
|
|
736
|
+
prior_hypotheses=all_hypotheses or None,
|
|
737
|
+
hint_failure_modes=prior_modes or None,
|
|
738
|
+
)
|
|
739
|
+
_dt = time.monotonic() - _t0
|
|
740
|
+
timings["m1"] = timings.get("m1", 0.0) + _dt
|
|
741
|
+
artifact_pngs: list = []
|
|
742
|
+
if log and self.run_logger:
|
|
743
|
+
artifact_pngs = self.run_logger.log_probe(
|
|
744
|
+
cycle, probe_results, schema=self.probe_agent.last_schema,
|
|
745
|
+
cases=data,
|
|
746
|
+
judge_prompt=getattr(self.probe_agent, "last_selection_prompt", ""),
|
|
747
|
+
judge_raw=getattr(self.probe_agent, "last_selection_raw", ""),
|
|
748
|
+
duration_sec=_dt,
|
|
749
|
+
) or []
|
|
750
|
+
_log_generated_tools(self.run_logger, cycle, "m1_probe", self.probe_agent)
|
|
751
|
+
self._emit_m1(cycle, probe_results, data, _dt)
|
|
752
|
+
if not probe_results:
|
|
753
|
+
return {}, []
|
|
754
|
+
for r in probe_results.values():
|
|
755
|
+
self.store.add_result(r)
|
|
756
|
+
# Operationalization bridge: pre-registered recipes -> synthetic
|
|
757
|
+
# "explored" analyzer Result (no-op when none configured).
|
|
758
|
+
self._bridge_signals(probe_results, data)
|
|
759
|
+
return probe_results, artifact_pngs
|
|
760
|
+
|
|
761
|
+
def _emit_m1(self, cycle: int, probe_results: "dict[str, Any]", data: "Any",
|
|
762
|
+
duration_sec: float) -> None:
|
|
763
|
+
"""Emit M1's ProbeOutput, including how analyzer routing was decided.
|
|
764
|
+
|
|
765
|
+
The three modality sets are recorded separately because they can differ
|
|
766
|
+
and the difference is the diagnosis: an omni model on an audio benchmark
|
|
767
|
+
declares four modalities, fills one, and is routed on one.
|
|
768
|
+
"""
|
|
769
|
+
if self.emitter is None:
|
|
770
|
+
return
|
|
771
|
+
from evalrx.contract.emit import from_probe_results
|
|
772
|
+
from evalrx.core.case import probed_modalities
|
|
773
|
+
|
|
774
|
+
selector = self.probe_agent.selector if hasattr(self.probe_agent, "selector") else None
|
|
775
|
+
declared = sorted(getattr(self.model, "modalities", frozenset({"text"})) or {"text"})
|
|
776
|
+
probed = sorted(probed_modalities(data))
|
|
777
|
+
routed = sorted(
|
|
778
|
+
selector.routed_slots(self.model, data) if selector is not None else probed
|
|
779
|
+
)
|
|
780
|
+
# Trajectories in the batch, not declared TOOL_CALLS: the api backend
|
|
781
|
+
# reports tool_calls for every chat model, which made a single-turn text
|
|
782
|
+
# run record itself as an agent run.
|
|
783
|
+
from evalrx.eval_agent.stages.probe import _carries_trajectories
|
|
784
|
+
|
|
785
|
+
is_agent = bool(data is not None and _carries_trajectories(data))
|
|
786
|
+
self._emit(f"c{cycle}.m1", lambda: from_probe_results(
|
|
787
|
+
probe_results,
|
|
788
|
+
trace_id=self.emitter.trace_id, cycle=cycle, model=self.model,
|
|
789
|
+
model_modalities=declared, probed_modalities=probed, routed_on=routed,
|
|
790
|
+
is_agent=is_agent,
|
|
791
|
+
selector="llm_judge" if getattr(self.probe_agent, "judge", None) else "static_strategy",
|
|
792
|
+
generated=list(getattr(self.probe_agent, "generated_probes", []) or []),
|
|
793
|
+
failed_analyzers=dict(getattr(self.probe_agent, "_failed_analyzers", {}) or {}),
|
|
794
|
+
duration_sec=duration_sec,
|
|
795
|
+
))
|
|
796
|
+
|
|
797
|
+
def _explore_out_dir(self) -> "Path | None":
|
|
798
|
+
"""Where the explore step persists its report/tables/figures.
|
|
799
|
+
|
|
800
|
+
Explicit ``explore_dir`` wins. Otherwise it is derived from the run
|
|
801
|
+
logger so the files land INSIDE the run, beside the log, where the
|
|
802
|
+
dashboard's ``_find_explore_report`` looks (``<root>/*/exploratory_report.json``):
|
|
803
|
+
|
|
804
|
+
- a :class:`~evalrx.eval_agent.run_context.RunContext`-backed logger
|
|
805
|
+
→ ``ctx.explore_dir``, an ephemeral tree under ``ctx.runtime_root``
|
|
806
|
+
(captured into ``M2/log.json`` and deleted at ``finalize()``;
|
|
807
|
+
writing straight to ``<ctx.root>/explore`` would leak real files
|
|
808
|
+
outside V2's JSON-only run artifact);
|
|
809
|
+
- a standalone ``RunLoggerV2("<run>/logs")`` / ``logs_confirm`` … →
|
|
810
|
+
``<run>/explore`` (a sibling of the ``logs*/`` dir, the llm_benchmark
|
|
811
|
+
layout — under the log dir it would be two levels down for a
|
|
812
|
+
``logs_confirm/`` carrier log and the dashboard would miss it);
|
|
813
|
+
- any other standalone run dir → ``<run_dir>/explore``.
|
|
814
|
+
|
|
815
|
+
``None`` (nothing persisted, context in memory only) without a logger."""
|
|
816
|
+
if self._explore_dir is not None:
|
|
817
|
+
return self._explore_dir
|
|
818
|
+
if self.run_logger is None:
|
|
819
|
+
return None
|
|
820
|
+
ctx = getattr(self.run_logger, "_context", None)
|
|
821
|
+
if ctx is not None and hasattr(ctx, "explore_dir"):
|
|
822
|
+
return ctx.explore_dir
|
|
823
|
+
run_dir = getattr(self.run_logger, "run_dir", None)
|
|
824
|
+
if run_dir is None:
|
|
825
|
+
return None
|
|
826
|
+
run_dir = Path(run_dir)
|
|
827
|
+
if run_dir.name.startswith("logs"):
|
|
828
|
+
return run_dir.parent / "explore"
|
|
829
|
+
return run_dir / "explore"
|
|
830
|
+
|
|
831
|
+
def _do_explore(
|
|
832
|
+
self, cycle: int, probe_results: "dict[str, Any]", data: "Any",
|
|
833
|
+
timings: "dict[str, float]", *, log: bool = True,
|
|
834
|
+
) -> "Any | None":
|
|
835
|
+
"""Optional in-cycle explore step: free-form EDA over M1's per-case table.
|
|
836
|
+
|
|
837
|
+
Runs between M1 and M2 when an ``explorer`` is configured. The explorer
|
|
838
|
+
sees exactly what M2 sees — ``build_stats_input`` → ``per_case_to_records``
|
|
839
|
+
(M1 analyzer per-case signals + PASS/FAIL labels) — writes tables and
|
|
840
|
+
chart specs, and the host renders the charts. The result feeds:
|
|
841
|
+
|
|
842
|
+
- M3, as an :class:`~evalrx.eval_agent.stages.diagnosis.ExploreContext`
|
|
843
|
+
(observations / rendered charts / caveats — descriptive, UNCONFIRMED,
|
|
844
|
+
used only to decide WHICH hypotheses to propose);
|
|
845
|
+
- the dashboard, via ``exploratory_report.json`` + ``tables/`` +
|
|
846
|
+
``figures/`` under :meth:`_explore_out_dir` (one directory, rewritten
|
|
847
|
+
each cycle — it always holds what the *latest* M3 was shown; the
|
|
848
|
+
per-cycle ``explore`` run-log events keep every cycle's counts and
|
|
849
|
+
observations).
|
|
850
|
+
|
|
851
|
+
It never touches M2's confirmatory family, M4, or the fix gate: the
|
|
852
|
+
explorer's candidate-signal verdicts are host-adjudicated IN-SAMPLE
|
|
853
|
+
(labelled so) and dropped from the M3 context by construction. Held-out
|
|
854
|
+
confirmation of explorer recipes is the fused pipeline's job, not this
|
|
855
|
+
step's. Best-effort: any failure logs a warning and returns ``None`` —
|
|
856
|
+
an explorer outage must not cost the M2/M3 that already have their data.
|
|
857
|
+
|
|
858
|
+
Returns the explorer report (or ``None``)."""
|
|
859
|
+
if self.explorer is None:
|
|
860
|
+
return None
|
|
861
|
+
from evalrx.analysis.operationalize import per_case_to_records
|
|
862
|
+
from evalrx.analysis.stats_tools import build_stats_input
|
|
863
|
+
from evalrx.eval_agent.stages.diagnosis import ExploreContext
|
|
864
|
+
|
|
865
|
+
_t0 = time.monotonic()
|
|
866
|
+
report: Any = None
|
|
867
|
+
out_dir = self._explore_out_dir()
|
|
868
|
+
try:
|
|
869
|
+
inp = build_stats_input(probe_results, data)
|
|
870
|
+
records = per_case_to_records(inp.per_case, inp.labels)
|
|
871
|
+
if not records:
|
|
872
|
+
logger.info("explore: M1 produced no per-case signals — skipping.")
|
|
873
|
+
return None
|
|
874
|
+
question = self._explore_question
|
|
875
|
+
if not question:
|
|
876
|
+
from evalrx.eval_agent.prompts.explore_step import default_explore_question
|
|
877
|
+
|
|
878
|
+
question = default_explore_question(self.protocol)
|
|
879
|
+
report = self.explorer.explore_records(
|
|
880
|
+
records, question=question, outcome_col="label",
|
|
881
|
+
)
|
|
882
|
+
# Host firewall: recompute every candidate verdict from sufficient
|
|
883
|
+
# statistics with the M2 core (in-sample here — labelled as such so
|
|
884
|
+
# nothing downstream mistakes it for a held-out result).
|
|
885
|
+
try:
|
|
886
|
+
from evalrx.analysis.adjudicate import adjudicate_report
|
|
887
|
+
|
|
888
|
+
adjudicate_report(report, split_label="in_sample")
|
|
889
|
+
except Exception as exc: # adjudication is a verdict layer, not the data
|
|
890
|
+
logger.warning("explore: host adjudication failed: %s", exc)
|
|
891
|
+
if out_dir is not None:
|
|
892
|
+
from evalrx.analysis.explore_run import write_report_artifacts
|
|
893
|
+
|
|
894
|
+
# Renders chart specs from the copied tables/ CSVs, so the
|
|
895
|
+
# persisted report carries each chart's absolute figure_path —
|
|
896
|
+
# the PNGs M3 is shown and the dashboard displays.
|
|
897
|
+
write_report_artifacts(report, out_dir)
|
|
898
|
+
ctx = ExploreContext.from_report(
|
|
899
|
+
report.to_dict() if hasattr(report, "to_dict") else None
|
|
900
|
+
)
|
|
901
|
+
if ctx is not None:
|
|
902
|
+
ctx.source = "loop_explorer"
|
|
903
|
+
self._explore_context = ctx
|
|
904
|
+
elif getattr(report, "ok", False):
|
|
905
|
+
logger.info("explore: report carried no observations/charts — "
|
|
906
|
+
"M3 keeps its previous explore context.")
|
|
907
|
+
except Exception as exc: # the explorer must never sink the loop
|
|
908
|
+
logger.warning(
|
|
909
|
+
"explore step failed at cycle %d (%s) — M2/M3 continue without "
|
|
910
|
+
"explorer notes.", cycle, exc,
|
|
911
|
+
)
|
|
912
|
+
_dt = time.monotonic() - _t0
|
|
913
|
+
timings["explore"] = timings.get("explore", 0.0) + _dt
|
|
914
|
+
if log and self.run_logger is not None:
|
|
915
|
+
try:
|
|
916
|
+
self.run_logger.log_explore(
|
|
917
|
+
cycle, report, out_dir=out_dir, duration_sec=_dt,
|
|
918
|
+
)
|
|
919
|
+
except Exception as exc: # logging must never break the run
|
|
920
|
+
logger.warning("explore: could not log the explore event: %s", exc)
|
|
921
|
+
return report
|
|
922
|
+
|
|
923
|
+
def _do_m2(
|
|
924
|
+
self, cycle: int, probe_results: "dict[str, Any]", data: "Any",
|
|
925
|
+
artifact_pngs: "list", timings: "dict[str, float]", *, log: bool = True,
|
|
926
|
+
confirmatory: bool = True,
|
|
927
|
+
) -> "Any":
|
|
928
|
+
"""M2: protocol-aware rigorous stats analysis (effect sizes + charts).
|
|
929
|
+
|
|
930
|
+
``confirmatory=False`` defers the e-BH validity verdict (the analysis
|
|
931
|
+
phase shows distributions only); the confirm phase recomputes it."""
|
|
932
|
+
_t0 = time.monotonic()
|
|
933
|
+
stats_report = self.stats_agent.analyze(
|
|
934
|
+
probe_results,
|
|
935
|
+
model_name=repr(self.model),
|
|
936
|
+
protocol=self.protocol,
|
|
937
|
+
data=data,
|
|
938
|
+
extra_figures=artifact_pngs,
|
|
939
|
+
confirmatory=confirmatory,
|
|
940
|
+
)
|
|
941
|
+
_dt = time.monotonic() - _t0
|
|
942
|
+
timings["m2"] = timings.get("m2", 0.0) + _dt
|
|
943
|
+
if log and self.run_logger:
|
|
944
|
+
self.run_logger.log_analysis(cycle, stats_report, duration_sec=_dt)
|
|
945
|
+
_log_generated_tools(self.run_logger, cycle, "m2_stats", self.stats_agent)
|
|
946
|
+
if self.emitter is not None:
|
|
947
|
+
from evalrx.contract.emit import from_stats_report
|
|
948
|
+
|
|
949
|
+
self._emit(f"c{cycle}.m2", lambda: from_stats_report(
|
|
950
|
+
stats_report, trace_id=self.emitter.trace_id, cycle=cycle,
|
|
951
|
+
raw_results_ref=f"contract/c{cycle}.m1.json", duration_sec=_dt,
|
|
952
|
+
))
|
|
953
|
+
return stats_report
|
|
954
|
+
|
|
955
|
+
def _do_m3(
|
|
956
|
+
self, cycle: int, stats_report: "Any", prior_cycles: "list[Any]",
|
|
957
|
+
timings: "dict[str, float]", *, log: bool = True,
|
|
958
|
+
failure_modes: "Any | None" = None, cases: "Any | None" = None,
|
|
959
|
+
) -> "Any | None":
|
|
960
|
+
"""M3: hypothesis generation. Returns the diagnosis result, or ``None``
|
|
961
|
+
when M3 could not run (judge unavailable / timeout / quota) — the caller
|
|
962
|
+
stops gracefully on ``None`` (regression guard: an M3 timeout must not
|
|
963
|
+
kill the whole run after M1+M2 succeeded). Also accrues token usage.
|
|
964
|
+
|
|
965
|
+
``failure_modes`` (optional clustered ``FailureModeReport``) is passed
|
|
966
|
+
through to the diagnosis agent only when it accepts the parameter —
|
|
967
|
+
``None`` (the default, always the case for ``VLDiagnoseLoop``) adds
|
|
968
|
+
nothing to the prompt and costs no extra call."""
|
|
969
|
+
try:
|
|
970
|
+
diag_agent = self._get_diagnosis_agent()
|
|
971
|
+
# Retain the lazily resolved agent so report publication can reuse
|
|
972
|
+
# the same judge without asking callers to inject it a second time.
|
|
973
|
+
self.diagnosis_agent = diag_agent
|
|
974
|
+
except Exception as exc:
|
|
975
|
+
logger.warning("Could not resolve DiagnosisAgent: %s", exc)
|
|
976
|
+
return None
|
|
977
|
+
|
|
978
|
+
_t0 = time.monotonic()
|
|
979
|
+
try:
|
|
980
|
+
diag = _diagnose_with_optional_context(
|
|
981
|
+
diag_agent, stats_report, prior_cycles, self._explore_context,
|
|
982
|
+
failure_modes, cases=cases,
|
|
983
|
+
)
|
|
984
|
+
except Exception as exc: # judge timeout/quota must not kill the loop
|
|
985
|
+
logger.warning(
|
|
986
|
+
"M3 diagnosis failed at cycle %d (%s) — stopping with the "
|
|
987
|
+
"evidence collected so far.", cycle, exc,
|
|
988
|
+
)
|
|
989
|
+
return None
|
|
990
|
+
_dt = time.monotonic() - _t0
|
|
991
|
+
timings["m3"] = timings.get("m3", 0.0) + _dt
|
|
992
|
+
|
|
993
|
+
_tok = getattr(diag, "tokens_used", None)
|
|
994
|
+
if _tok is None:
|
|
995
|
+
_tok = max(1, len(diag.raw_judge_output) // 4)
|
|
996
|
+
self._tokens_used += _tok
|
|
997
|
+
|
|
998
|
+
if log and self.run_logger:
|
|
999
|
+
# Log only the figures that actually existed (the same existence
|
|
1000
|
+
# filter M3 applies) so the audit trail reflects what the judge saw.
|
|
1001
|
+
_explore_figs = None
|
|
1002
|
+
if self._explore_context is not None:
|
|
1003
|
+
from pathlib import Path as _P
|
|
1004
|
+
|
|
1005
|
+
_explore_figs = [
|
|
1006
|
+
f for f in self._explore_context.figure_paths if _P(f).exists()
|
|
1007
|
+
]
|
|
1008
|
+
self.run_logger.log_diagnosis(
|
|
1009
|
+
cycle, diag, duration_sec=_dt, explore_figures=_explore_figs or None
|
|
1010
|
+
)
|
|
1011
|
+
if self.emitter is not None:
|
|
1012
|
+
from evalrx.contract.emit import from_diagnosis
|
|
1013
|
+
|
|
1014
|
+
self._emit(f"c{cycle}.m3", lambda: from_diagnosis(
|
|
1015
|
+
diag, trace_id=self.emitter.trace_id, cycle=cycle, duration_sec=_dt,
|
|
1016
|
+
))
|
|
1017
|
+
return diag
|
|
1018
|
+
|
|
1019
|
+
@staticmethod
|
|
1020
|
+
def _finalize_confirmatory_stats(stats_report: "Any") -> None:
|
|
1021
|
+
"""Promote a descriptive (analysis-phase) stats report to confirmatory.
|
|
1022
|
+
|
|
1023
|
+
The analysis phase runs M2 with the e-BH validity verdict DEFERRED
|
|
1024
|
+
(``descriptive_only=True``). The confirm phase recomputes e-BH FDR
|
|
1025
|
+
correction over the report's stats results so M4 and the dashboard see
|
|
1026
|
+
the family-level reject decision. No-op when already confirmatory."""
|
|
1027
|
+
if stats_report is None:
|
|
1028
|
+
return
|
|
1029
|
+
corr = getattr(stats_report, "corrected_rejections", None) or {}
|
|
1030
|
+
if getattr(stats_report, "descriptive_only", False) or corr.get("deferred"):
|
|
1031
|
+
from evalrx.analysis.stats_tools import fdr_correct
|
|
1032
|
+
|
|
1033
|
+
stats_report.corrected_rejections = fdr_correct(
|
|
1034
|
+
list(getattr(stats_report, "stats_results", None) or [])
|
|
1035
|
+
)
|
|
1036
|
+
stats_report.descriptive_only = False
|
|
1037
|
+
|
|
1038
|
+
def _do_m4(
|
|
1039
|
+
self, cycle: int, hypotheses: "list[Any]", stats_report: "Any",
|
|
1040
|
+
data: "Any", timings: "dict[str, float]", *, log: bool = True,
|
|
1041
|
+
split_label: "str | None" = None,
|
|
1042
|
+
) -> "list[Any]":
|
|
1043
|
+
"""M4: statistical test + protocol consistency for each hypothesis,
|
|
1044
|
+
writing the verdict back onto ``hypothesis.status``.
|
|
1045
|
+
|
|
1046
|
+
``split_label`` tags each result's evidence with which data split the
|
|
1047
|
+
test read (``"confirm_holdout"`` for the held-out pass) BEFORE the
|
|
1048
|
+
result is logged, so the marker reaches the run log too."""
|
|
1049
|
+
_t0 = time.monotonic()
|
|
1050
|
+
test_results = self.hypothesis_tester.test(
|
|
1051
|
+
hypotheses,
|
|
1052
|
+
stats_report,
|
|
1053
|
+
data,
|
|
1054
|
+
protocol=self.protocol,
|
|
1055
|
+
)
|
|
1056
|
+
_dt = time.monotonic() - _t0
|
|
1057
|
+
timings["m4"] = timings.get("m4", 0.0) + _dt
|
|
1058
|
+
for tr in test_results:
|
|
1059
|
+
tr.hypothesis.status = tr.status
|
|
1060
|
+
if split_label and isinstance(getattr(tr, "evidence", None), dict):
|
|
1061
|
+
tr.evidence["split"] = split_label
|
|
1062
|
+
if log and self.run_logger:
|
|
1063
|
+
# Reuse the surgery log slot for M4 results (backward compat).
|
|
1064
|
+
for tr in test_results:
|
|
1065
|
+
_iv = _make_intervention_result_from_test(tr)
|
|
1066
|
+
self.run_logger.log_surgery(
|
|
1067
|
+
cycle, tr.hypothesis, _iv, duration_sec=_dt,
|
|
1068
|
+
validation_cases=data,
|
|
1069
|
+
judge_prompt=getattr(tr, "judge_prompt", None) or None,
|
|
1070
|
+
judge_raw=getattr(tr, "judge_raw", None) or None,
|
|
1071
|
+
)
|
|
1072
|
+
if log and self.emitter is not None:
|
|
1073
|
+
from evalrx.contract.emit import from_test_results
|
|
1074
|
+
|
|
1075
|
+
# M4 has no stage log of its own — it reuses the per-hypothesis
|
|
1076
|
+
# surgery slot — so this is the first place the whole verdict set
|
|
1077
|
+
# exists as one object a reader can load.
|
|
1078
|
+
split = "confirm" if split_label == "confirm_holdout" else "explore"
|
|
1079
|
+
self._emit(f"c{cycle}.m4", lambda: from_test_results(
|
|
1080
|
+
test_results, trace_id=self.emitter.trace_id, cycle=cycle,
|
|
1081
|
+
split=split, duration_sec=_dt,
|
|
1082
|
+
stopping_criteria_met=bool(
|
|
1083
|
+
self.hypothesis_tester.stopping_criteria_met(test_results, self.protocol)
|
|
1084
|
+
),
|
|
1085
|
+
))
|
|
1086
|
+
return test_results
|
|
1087
|
+
|
|
1088
|
+
def _m4_holdout_pass(
|
|
1089
|
+
self,
|
|
1090
|
+
hypotheses: "list[Any]",
|
|
1091
|
+
confirm: "Any",
|
|
1092
|
+
analyzer_names: "list[str]",
|
|
1093
|
+
timings: "dict[str, float]",
|
|
1094
|
+
) -> "tuple[list[Any], str]":
|
|
1095
|
+
"""The M4 pass — on the held-out confirm split.
|
|
1096
|
+
|
|
1097
|
+
With a confirm split in play this is the ONLY hypothesis test: M3's
|
|
1098
|
+
hypotheses are taken straight to data M1/M2/M3 never mined. The same
|
|
1099
|
+
analyzers as the last explore cycle are re-run on the confirm split
|
|
1100
|
+
(pinned via ``ProbeAgent.probe(analyzers=)`` — a fresh judge selection
|
|
1101
|
+
could miss the designated signals); with no names to pin (a reloaded
|
|
1102
|
+
run without a stats report) the probe agent selects normally.
|
|
1103
|
+
|
|
1104
|
+
Returns ``(test_results, status)`` with status ``"confirmed"`` (the
|
|
1105
|
+
pass ran) or ``"failed"`` (the confirm re-probe produced nothing — no
|
|
1106
|
+
hypothesis can be verified this run).
|
|
1107
|
+
"""
|
|
1108
|
+
pinned = [n for n in analyzer_names if not n.startswith("generated:")]
|
|
1109
|
+
logger.info(
|
|
1110
|
+
"M4 (held-out): testing %d hypothesis(es) on the %d-case confirm "
|
|
1111
|
+
"split (analyzers: %s)",
|
|
1112
|
+
len(hypotheses), len(list(confirm)),
|
|
1113
|
+
", ".join(pinned) or "<probe agent's own selection>",
|
|
1114
|
+
)
|
|
1115
|
+
# Stamped BEFORE probe() (not just before log_probe below): ProbeAgent
|
|
1116
|
+
# tags every model call it makes with whatever current_cycle reads AT
|
|
1117
|
+
# CALL TIME, and probe() is where those calls happen. Setting this
|
|
1118
|
+
# only right before log_probe(-1, ...) tagged this whole confirm-split
|
|
1119
|
+
# re-probe's model calls with the previous explore cycle's number.
|
|
1120
|
+
if self.run_logger:
|
|
1121
|
+
self.run_logger.current_cycle = -1
|
|
1122
|
+
_t0 = time.monotonic()
|
|
1123
|
+
try:
|
|
1124
|
+
if pinned:
|
|
1125
|
+
probe_results = self.probe_agent.probe(
|
|
1126
|
+
self.model, confirm, protocol=self.protocol,
|
|
1127
|
+
prior_hypotheses=hypotheses or None, analyzers=pinned,
|
|
1128
|
+
)
|
|
1129
|
+
else:
|
|
1130
|
+
probe_results = self.probe_agent.probe(
|
|
1131
|
+
self.model, confirm, protocol=self.protocol,
|
|
1132
|
+
prior_hypotheses=hypotheses or None,
|
|
1133
|
+
)
|
|
1134
|
+
except TypeError:
|
|
1135
|
+
# A custom probe agent that predates the ``analyzers=`` hook.
|
|
1136
|
+
probe_results = self.probe_agent.probe(
|
|
1137
|
+
self.model, confirm, protocol=self.protocol,
|
|
1138
|
+
prior_hypotheses=hypotheses or None,
|
|
1139
|
+
)
|
|
1140
|
+
timings["m4_holdout_m1"] = timings.get("m4_holdout_m1", 0.0) + (
|
|
1141
|
+
time.monotonic() - _t0)
|
|
1142
|
+
if not probe_results:
|
|
1143
|
+
logger.warning(
|
|
1144
|
+
"M4 (held-out): the confirm-split re-probe produced no results "
|
|
1145
|
+
"— no hypothesis can be verified this run."
|
|
1146
|
+
)
|
|
1147
|
+
return [], "failed"
|
|
1148
|
+
self._bridge_signals(probe_results, confirm)
|
|
1149
|
+
if self.run_logger:
|
|
1150
|
+
self.run_logger.log_probe(
|
|
1151
|
+
-1, probe_results,
|
|
1152
|
+
schema=getattr(self.probe_agent, "last_schema", None),
|
|
1153
|
+
cases=confirm,
|
|
1154
|
+
)
|
|
1155
|
+
stats_confirm = self._do_m2(
|
|
1156
|
+
-1, probe_results, confirm, [], timings, confirmatory=True
|
|
1157
|
+
)
|
|
1158
|
+
results = self._do_m4(-1, hypotheses, stats_confirm, confirm, timings,
|
|
1159
|
+
split_label="confirm_holdout")
|
|
1160
|
+
return results, "confirmed"
|
|
1161
|
+
|
|
1162
|
+
# ──────────────────────────────────────────────────────────────────
|
|
1163
|
+
# Public API
|
|
1164
|
+
# ──────────────────────────────────────────────────────────────────
|
|
1165
|
+
|
|
1166
|
+
def run(self, data: "CaseBatch") -> VLDiagnoseReport:
|
|
1167
|
+
"""Drive the M1→M2→M3→M4 loop to convergence.
|
|
1168
|
+
|
|
1169
|
+
Args:
|
|
1170
|
+
data: Cases to analyse (should carry :class:`~evalrx.core.case.Label`
|
|
1171
|
+
values for the M4 statistical tests to work).
|
|
1172
|
+
|
|
1173
|
+
Returns:
|
|
1174
|
+
:class:`VLDiagnoseReport` with the final state.
|
|
1175
|
+
Call :meth:`run_m5` on this to get a fix proposal (Plan A).
|
|
1176
|
+
"""
|
|
1177
|
+
all_hypotheses: list[Any] = []
|
|
1178
|
+
all_test_results: list[Any] = []
|
|
1179
|
+
final_stats_report = None
|
|
1180
|
+
stopped_by = _STOPPED_BY_MAX
|
|
1181
|
+
prior_cycles: list[dict[str, Any]] = []
|
|
1182
|
+
last_analyzer_names: list[str] = []
|
|
1183
|
+
self._tokens_used = 0
|
|
1184
|
+
|
|
1185
|
+
# Per-stage wall-clock totals (seconds) for the loop_end cost profile.
|
|
1186
|
+
timings: dict[str, float] = {}
|
|
1187
|
+
|
|
1188
|
+
# Held-out CONFIRM split (leak #3): M1-M4 see only EXPLORE; the post-loop
|
|
1189
|
+
# fix/surgery validate on the frozen CONFIRM partition (run_m5/run_fix
|
|
1190
|
+
# re-derive the same deterministic split from the same input batch).
|
|
1191
|
+
# With test_split > 0 (train/val/test mode) a third partition is also
|
|
1192
|
+
# reserved here: CONFIRM plays validation (M4 holdout + fix candidate
|
|
1193
|
+
# development) and TEST is only ever touched by the final frozen-fix
|
|
1194
|
+
# gate in run_fix.
|
|
1195
|
+
explore, confirm, test = self._split_partitions(data)
|
|
1196
|
+
if confirm is not None:
|
|
1197
|
+
if test is not None:
|
|
1198
|
+
logger.info(
|
|
1199
|
+
"train/val/test split: train=%d, val=%d, test=%d "
|
|
1200
|
+
"(frac=%.2f/%.2f)",
|
|
1201
|
+
len(list(explore)), len(list(confirm)), len(list(test)),
|
|
1202
|
+
self.confirm_split, self.test_split,
|
|
1203
|
+
)
|
|
1204
|
+
elif self.val_data is not None:
|
|
1205
|
+
logger.info(
|
|
1206
|
+
"explore/confirm split: explore=%d, confirm=%d (frac=%.2f) "
|
|
1207
|
+
"+ external validation set: %d cases",
|
|
1208
|
+
len(list(explore)), len(list(confirm)), self.confirm_split,
|
|
1209
|
+
len(list(self.val_data)),
|
|
1210
|
+
)
|
|
1211
|
+
else:
|
|
1212
|
+
logger.info(
|
|
1213
|
+
"confirm split: explore=%d cases, confirm=%d held out (frac=%.2f)",
|
|
1214
|
+
len(list(explore)), len(list(confirm)), self.confirm_split,
|
|
1215
|
+
)
|
|
1216
|
+
data = explore
|
|
1217
|
+
|
|
1218
|
+
# With a confirm split (and m4_holdout on), M4 runs ONCE after the
|
|
1219
|
+
# loop, on held-out data — the cycles only mine (M1→M3). The holdout
|
|
1220
|
+
# target is the external validation set when one was supplied (the
|
|
1221
|
+
# confirm partition then stays reserved for the frozen fix winner),
|
|
1222
|
+
# else the confirm partition itself. Without any held-out data M4
|
|
1223
|
+
# stays in-cycle as before.
|
|
1224
|
+
holdout_target = self.val_data if self.val_data is not None else confirm
|
|
1225
|
+
holdout_mode = holdout_target is not None and self.m4_holdout
|
|
1226
|
+
|
|
1227
|
+
# Forward the RunLogger into the agents so the probe / stats tool
|
|
1228
|
+
# generators record their tool-synthesis attempts ("tool_codegen" events).
|
|
1229
|
+
_attach_run_logger(self.run_logger, self.probe_agent, self.stats_agent)
|
|
1230
|
+
if self.run_logger is not None:
|
|
1231
|
+
self.run_logger.log_run_start(
|
|
1232
|
+
_run_config(self, data, loop_name="VLDiagnoseLoop")
|
|
1233
|
+
)
|
|
1234
|
+
# Each partition is logged under its own name: the records are the
|
|
1235
|
+
# only place a reader can learn which cases M1-M3 mined and which
|
|
1236
|
+
# were withheld for M4 / M5, so the tag has to be on the record.
|
|
1237
|
+
self.run_logger.log_cases(
|
|
1238
|
+
data, split="explore" if confirm is not None else None,
|
|
1239
|
+
)
|
|
1240
|
+
if confirm is not None:
|
|
1241
|
+
# `data` is the explore split by now, so logging only it left the
|
|
1242
|
+
# held-out cases unrecorded — and those are the ones M4's verdict
|
|
1243
|
+
# and M5's repair are measured on. A report then cannot show a
|
|
1244
|
+
# single case behind its strongest evidence: the repair's own
|
|
1245
|
+
# per-case outputs joined to nothing.
|
|
1246
|
+
self.run_logger.log_cases(confirm, split="confirm")
|
|
1247
|
+
if test is not None:
|
|
1248
|
+
self.run_logger.log_cases(test, split="test")
|
|
1249
|
+
if self.val_data is not None:
|
|
1250
|
+
self.run_logger.log_cases(self.val_data, split="val")
|
|
1251
|
+
|
|
1252
|
+
for cycle in range(self.max_cycles):
|
|
1253
|
+
if self.token_budget > 0 and self._tokens_used >= self.token_budget:
|
|
1254
|
+
logger.warning(
|
|
1255
|
+
"Token budget %d exhausted after %d cycles", self.token_budget, cycle
|
|
1256
|
+
)
|
|
1257
|
+
stopped_by = _STOPPED_BY_BUDGET
|
|
1258
|
+
break
|
|
1259
|
+
|
|
1260
|
+
if self.run_logger is not None:
|
|
1261
|
+
self.run_logger.current_cycle = cycle
|
|
1262
|
+
|
|
1263
|
+
# ── M1: protocol-guided probing (+ signal bridge) ─────────
|
|
1264
|
+
probe_results, artifact_pngs = self._do_m1(
|
|
1265
|
+
cycle, data, all_hypotheses, timings
|
|
1266
|
+
)
|
|
1267
|
+
if not probe_results:
|
|
1268
|
+
logger.info("M1 produced no probe results — stopping.")
|
|
1269
|
+
stopped_by = _STOPPED_BY_NO_PROBE
|
|
1270
|
+
break
|
|
1271
|
+
last_analyzer_names = list(probe_results)
|
|
1272
|
+
|
|
1273
|
+
# ── explore (optional): free-form EDA on M1's table → M3 notes ──
|
|
1274
|
+
self._do_explore(cycle, probe_results, data, timings)
|
|
1275
|
+
|
|
1276
|
+
# ── M2: protocol-aware stats analysis ────────────────────
|
|
1277
|
+
stats_report = self._do_m2(
|
|
1278
|
+
cycle, probe_results, data, artifact_pngs, timings
|
|
1279
|
+
)
|
|
1280
|
+
final_stats_report = stats_report
|
|
1281
|
+
|
|
1282
|
+
if self.analysis_only:
|
|
1283
|
+
stopped_by = _STOPPED_BY_NO_HYPS
|
|
1284
|
+
break
|
|
1285
|
+
|
|
1286
|
+
# ── M3: hypothesis generation ("AI scientist") ────────────
|
|
1287
|
+
diag = self._do_m3(cycle, stats_report, prior_cycles, timings, cases=data)
|
|
1288
|
+
if diag is None or not diag.hypotheses:
|
|
1289
|
+
if diag is not None:
|
|
1290
|
+
logger.info("M3 produced no hypotheses at cycle %d.", cycle)
|
|
1291
|
+
stopped_by = _STOPPED_BY_NO_HYPS
|
|
1292
|
+
break
|
|
1293
|
+
|
|
1294
|
+
for h in diag.hypotheses:
|
|
1295
|
+
self.store.add_hypothesis(h)
|
|
1296
|
+
all_hypotheses.extend(diag.hypotheses)
|
|
1297
|
+
|
|
1298
|
+
# ── M4: hypothesis testing (stats + protocol consistency) ─
|
|
1299
|
+
# In holdout mode M4 is deferred to the single held-out pass
|
|
1300
|
+
# after the loop: testing here would re-use the explore data
|
|
1301
|
+
# the hypotheses were mined from, and its verdict must not
|
|
1302
|
+
# steer the loop (no early stop / no extra cycles keyed on
|
|
1303
|
+
# SUPPORTED-or-not).
|
|
1304
|
+
if not holdout_mode:
|
|
1305
|
+
test_results = self._do_m4(
|
|
1306
|
+
cycle, diag.hypotheses, stats_report, data, timings
|
|
1307
|
+
)
|
|
1308
|
+
all_test_results.extend(test_results)
|
|
1309
|
+
|
|
1310
|
+
# ── Stopping criteria ────────────────────────────────────
|
|
1311
|
+
if self.hypothesis_tester.stopping_criteria_met(test_results, self.protocol):
|
|
1312
|
+
logger.info(
|
|
1313
|
+
"Stopping criteria met at cycle %d: verified, protocol-consistent "
|
|
1314
|
+
"hypothesis found.",
|
|
1315
|
+
cycle,
|
|
1316
|
+
)
|
|
1317
|
+
stopped_by = _STOPPED_BY_CRITERIA
|
|
1318
|
+
break
|
|
1319
|
+
|
|
1320
|
+
# Build prior-cycles context for next M3 call
|
|
1321
|
+
prior_cycles.append({
|
|
1322
|
+
"cycle": cycle,
|
|
1323
|
+
"severity": stats_report.severity,
|
|
1324
|
+
"hypotheses": [
|
|
1325
|
+
{
|
|
1326
|
+
"statement": h.statement,
|
|
1327
|
+
"failure_mode": h.predicted_failure_mode,
|
|
1328
|
+
"status": h.status.value if h.status else "pending",
|
|
1329
|
+
}
|
|
1330
|
+
for h in diag.hypotheses
|
|
1331
|
+
],
|
|
1332
|
+
})
|
|
1333
|
+
|
|
1334
|
+
# The M4 pass: with a confirm split in play the hypotheses are tested
|
|
1335
|
+
# ONCE, on the held-out split — the same discipline run_fix already
|
|
1336
|
+
# applies to candidates. Without one, the in-cycle results stand.
|
|
1337
|
+
m4_holdout_status: "str | None" = None
|
|
1338
|
+
if holdout_mode and all_hypotheses:
|
|
1339
|
+
all_test_results, m4_holdout_status = self._m4_holdout_pass(
|
|
1340
|
+
all_hypotheses, holdout_target, last_analyzer_names, timings,
|
|
1341
|
+
)
|
|
1342
|
+
verified = self.hypothesis_tester.best_hypotheses(all_test_results)
|
|
1343
|
+
|
|
1344
|
+
report = VLDiagnoseReport(
|
|
1345
|
+
cycles=cycle + 1 if self.max_cycles > 0 else 0, # type: ignore[possibly-undefined]
|
|
1346
|
+
resolved=bool(verified),
|
|
1347
|
+
stopped_by=stopped_by,
|
|
1348
|
+
verified_hypotheses=verified,
|
|
1349
|
+
final_hypotheses=all_hypotheses,
|
|
1350
|
+
all_test_results=all_test_results,
|
|
1351
|
+
final_stats_report=final_stats_report,
|
|
1352
|
+
store=self.store,
|
|
1353
|
+
m4_holdout=m4_holdout_status,
|
|
1354
|
+
_run_id=self._run_id,
|
|
1355
|
+
)
|
|
1356
|
+
if self.run_logger:
|
|
1357
|
+
self.run_logger.log_loop_end(
|
|
1358
|
+
report, tokens_used=self._tokens_used, timings=timings
|
|
1359
|
+
)
|
|
1360
|
+
return report
|
|
1361
|
+
|
|
1362
|
+
def run_analysis(self, data: "CaseBatch") -> VLDiagnoseReport:
|
|
1363
|
+
"""Phase 1 — analyse + propose, WITHOUT confirming (no M4, no fix).
|
|
1364
|
+
|
|
1365
|
+
Runs a single **M1 → M2 → M3** pass: select+execute analyzers (M1),
|
|
1366
|
+
rigorous protocol-aware stats + charts (M2, e-BH adjudication kept),
|
|
1367
|
+
and propose root-cause hypotheses (M3) — then stop. This is the path
|
|
1368
|
+
that feeds the dashboard *before* deciding whether to confirm and
|
|
1369
|
+
repair.
|
|
1370
|
+
|
|
1371
|
+
The returned :class:`VLDiagnoseReport` carries:
|
|
1372
|
+
- ``final_hypotheses`` — the M3 proposals (UNCONFIRMED), and
|
|
1373
|
+
- ``final_stats_report`` — the M2 report,
|
|
1374
|
+
with ``all_test_results`` / ``verified_hypotheses`` left empty (M4 has
|
|
1375
|
+
not run). Persist ``final_hypotheses`` (via
|
|
1376
|
+
:func:`~evalrx.eval_agent.hypothesis.hypothesis_to_dict`) and
|
|
1377
|
+
``final_stats_report``, then hand them to :meth:`run_confirm` for the
|
|
1378
|
+
deferred confirmation + repair phase.
|
|
1379
|
+
|
|
1380
|
+
Unlike :meth:`run`, there is no M4 stopping signal, so this is a single
|
|
1381
|
+
pass (one M1→M2→M3), not a multi-cycle loop; ``max_cycles`` is ignored.
|
|
1382
|
+
"""
|
|
1383
|
+
self._tokens_used = 0
|
|
1384
|
+
timings: dict[str, float] = {}
|
|
1385
|
+
|
|
1386
|
+
# Same deterministic explore/confirm partition as run() / run_fix() —
|
|
1387
|
+
# M1-M3 see only EXPLORE so the deferred fix can validate on the
|
|
1388
|
+
# untouched CONFIRM partition. No-op when confirm_split=0.
|
|
1389
|
+
explore, confirm = self._split_explore_confirm(data)
|
|
1390
|
+
if confirm is not None:
|
|
1391
|
+
logger.info(
|
|
1392
|
+
"confirm split: explore=%d cases, confirm=%d held out (frac=%.2f)",
|
|
1393
|
+
len(list(explore)), len(list(confirm)), self.confirm_split,
|
|
1394
|
+
)
|
|
1395
|
+
data = explore
|
|
1396
|
+
|
|
1397
|
+
_attach_run_logger(self.run_logger, self.probe_agent, self.stats_agent)
|
|
1398
|
+
if self.run_logger is not None:
|
|
1399
|
+
self.run_logger.current_cycle = 0
|
|
1400
|
+
self.run_logger.log_run_start(
|
|
1401
|
+
_run_config(self, data, loop_name="VLDiagnoseLoop.analysis")
|
|
1402
|
+
)
|
|
1403
|
+
self.run_logger.log_cases(
|
|
1404
|
+
data, split="explore" if confirm is not None else None,
|
|
1405
|
+
)
|
|
1406
|
+
if confirm is not None:
|
|
1407
|
+
self.run_logger.log_cases(confirm, split="confirm")
|
|
1408
|
+
|
|
1409
|
+
all_hypotheses: list[Any] = []
|
|
1410
|
+
final_stats_report = None
|
|
1411
|
+
stopped_by = _STOPPED_BY_ANALYSIS
|
|
1412
|
+
|
|
1413
|
+
probe_results, artifact_pngs = self._do_m1(0, data, all_hypotheses, timings)
|
|
1414
|
+
if not probe_results:
|
|
1415
|
+
logger.info("M1 produced no probe results — stopping.")
|
|
1416
|
+
stopped_by = _STOPPED_BY_NO_PROBE
|
|
1417
|
+
else:
|
|
1418
|
+
# Optional explore step (descriptive EDA → M3 notes + dashboard files).
|
|
1419
|
+
self._do_explore(0, probe_results, data, timings)
|
|
1420
|
+
# Descriptive M2: effect sizes + charts, but DEFER the e-BH validity
|
|
1421
|
+
# verdict to run_confirm so the analysis dashboard shows no
|
|
1422
|
+
# "supported/not-supported" claim (Q2: no validity before confirm).
|
|
1423
|
+
final_stats_report = self._do_m2(
|
|
1424
|
+
0, probe_results, data, artifact_pngs, timings, confirmatory=False
|
|
1425
|
+
)
|
|
1426
|
+
diag = self._do_m3(0, final_stats_report, [], timings, cases=data)
|
|
1427
|
+
if diag is None or not diag.hypotheses:
|
|
1428
|
+
# Stats + dashboard are still valid; just no hypotheses to confirm.
|
|
1429
|
+
if diag is not None:
|
|
1430
|
+
logger.info("M3 produced no hypotheses.")
|
|
1431
|
+
stopped_by = _STOPPED_BY_NO_HYPS
|
|
1432
|
+
else:
|
|
1433
|
+
for h in diag.hypotheses:
|
|
1434
|
+
self.store.add_hypothesis(h)
|
|
1435
|
+
all_hypotheses.extend(diag.hypotheses)
|
|
1436
|
+
|
|
1437
|
+
report = VLDiagnoseReport(
|
|
1438
|
+
cycles=1,
|
|
1439
|
+
stopped_by=stopped_by,
|
|
1440
|
+
verified_hypotheses=[],
|
|
1441
|
+
final_hypotheses=all_hypotheses,
|
|
1442
|
+
all_test_results=[],
|
|
1443
|
+
final_stats_report=final_stats_report,
|
|
1444
|
+
store=self.store,
|
|
1445
|
+
_run_id=self._run_id,
|
|
1446
|
+
)
|
|
1447
|
+
if self.run_logger:
|
|
1448
|
+
self.run_logger.log_loop_end(
|
|
1449
|
+
report, tokens_used=self._tokens_used, timings=timings
|
|
1450
|
+
)
|
|
1451
|
+
return report
|
|
1452
|
+
|
|
1453
|
+
def run_confirm(
|
|
1454
|
+
self,
|
|
1455
|
+
data: "CaseBatch",
|
|
1456
|
+
hypotheses: "list[Any]",
|
|
1457
|
+
*,
|
|
1458
|
+
stats_report: "Any | None" = None,
|
|
1459
|
+
) -> VLDiagnoseReport:
|
|
1460
|
+
"""Phase 2a — confirm previously-proposed hypotheses with M4.
|
|
1461
|
+
|
|
1462
|
+
Runs **M4** (:class:`~evalrx.eval_agent.stages.hypothesis_tester.HypothesisTester`)
|
|
1463
|
+
on ``hypotheses`` — typically reloaded from :meth:`run_analysis`'s output
|
|
1464
|
+
via :func:`~evalrx.eval_agent.hypothesis.hypothesis_from_dict` — so
|
|
1465
|
+
the *same* hypotheses the dashboard showed are the ones confirmed.
|
|
1466
|
+
|
|
1467
|
+
``stats_report`` is the M2 report M4 reads its rigorous evidence from
|
|
1468
|
+
(effect + CI + e-value, FDR-corrected). Pass the
|
|
1469
|
+
``final_stats_report`` persisted by :meth:`run_analysis` to confirm
|
|
1470
|
+
against the *exact* statistics the dashboard displayed; when omitted,
|
|
1471
|
+
M1→M2 are re-run silently (not logged — they belong to the analysis
|
|
1472
|
+
phase) to regenerate it.
|
|
1473
|
+
|
|
1474
|
+
Returns a :class:`VLDiagnoseReport` with ``all_test_results`` and
|
|
1475
|
+
``verified_hypotheses`` populated. Feed it into :meth:`run_m5` /
|
|
1476
|
+
:meth:`run_fix` for the repair step.
|
|
1477
|
+
"""
|
|
1478
|
+
self._tokens_used = 0
|
|
1479
|
+
timings: dict[str, float] = {}
|
|
1480
|
+
hypotheses = list(hypotheses or [])
|
|
1481
|
+
|
|
1482
|
+
# Mirror run()'s partition: the screening M4 reads the supplied (or
|
|
1483
|
+
# regenerated) EXPLORE stats the hypotheses were mined from; the
|
|
1484
|
+
# held-out confirmation below re-tests the screened ones on CONFIRM
|
|
1485
|
+
# (run_m5/run_fix re-derive the same partition for their validation).
|
|
1486
|
+
explore, confirm = self._split_explore_confirm(data)
|
|
1487
|
+
if confirm is not None:
|
|
1488
|
+
data = explore
|
|
1489
|
+
|
|
1490
|
+
_attach_run_logger(self.run_logger, self.probe_agent, self.stats_agent)
|
|
1491
|
+
if self.run_logger is not None:
|
|
1492
|
+
self.run_logger.current_cycle = 0
|
|
1493
|
+
self.run_logger.log_run_start(
|
|
1494
|
+
_run_config(self, data, loop_name="VLDiagnoseLoop.confirm")
|
|
1495
|
+
)
|
|
1496
|
+
self.run_logger.log_cases(
|
|
1497
|
+
data, split="explore" if confirm is not None else None,
|
|
1498
|
+
)
|
|
1499
|
+
if confirm is not None:
|
|
1500
|
+
self.run_logger.log_cases(confirm, split="confirm")
|
|
1501
|
+
|
|
1502
|
+
# Regenerate the stats the tester needs only when not supplied. The M1/M2
|
|
1503
|
+
# events are NOT logged here — they were recorded in the analysis phase,
|
|
1504
|
+
# and re-logging them would double up the dashboard's analysis story.
|
|
1505
|
+
if stats_report is None:
|
|
1506
|
+
probe_results, artifact_pngs = self._do_m1(
|
|
1507
|
+
0, data, hypotheses, timings, log=False
|
|
1508
|
+
)
|
|
1509
|
+
if not probe_results:
|
|
1510
|
+
logger.warning(
|
|
1511
|
+
"run_confirm: probe produced no results and no stats_report "
|
|
1512
|
+
"was supplied — cannot confirm."
|
|
1513
|
+
)
|
|
1514
|
+
report = VLDiagnoseReport(
|
|
1515
|
+
cycles=1, stopped_by=_STOPPED_BY_NO_PROBE,
|
|
1516
|
+
final_hypotheses=hypotheses, store=self.store, _run_id=self._run_id,
|
|
1517
|
+
)
|
|
1518
|
+
if self.run_logger:
|
|
1519
|
+
self.run_logger.log_loop_end(
|
|
1520
|
+
report, tokens_used=self._tokens_used, timings=timings
|
|
1521
|
+
)
|
|
1522
|
+
return report
|
|
1523
|
+
stats_report = self._do_m2(
|
|
1524
|
+
0, probe_results, data, artifact_pngs, timings, log=False
|
|
1525
|
+
)
|
|
1526
|
+
|
|
1527
|
+
# The confirm phase OWNS the validity verdict: if the reused report came
|
|
1528
|
+
# from the descriptive analysis phase (e-BH deferred), compute e-BH now,
|
|
1529
|
+
# flip descriptive_only off, and log the confirmatory M2 so the dashboard
|
|
1530
|
+
# surfaces the signal validity it withheld before confirmation.
|
|
1531
|
+
self._finalize_confirmatory_stats(stats_report)
|
|
1532
|
+
if self.run_logger and stats_report is not None:
|
|
1533
|
+
self.run_logger.log_analysis(0, stats_report)
|
|
1534
|
+
|
|
1535
|
+
test_results: list[Any] = []
|
|
1536
|
+
m4_holdout_status: "str | None" = None
|
|
1537
|
+
if hypotheses:
|
|
1538
|
+
for h in hypotheses:
|
|
1539
|
+
self.store.add_hypothesis(h)
|
|
1540
|
+
holdout_target = self.val_data if self.val_data is not None else confirm
|
|
1541
|
+
if holdout_target is not None and self.m4_holdout:
|
|
1542
|
+
# The one M4 pass, on held-out data (never the explore stats
|
|
1543
|
+
# the hypotheses were mined from): the external validation set
|
|
1544
|
+
# when one was supplied, else the confirm split. The analyzer
|
|
1545
|
+
# set to re-run there is recovered from the supplied/
|
|
1546
|
+
# regenerated stats report's signal keys; with none
|
|
1547
|
+
# recoverable the probe agent selects on the holdout itself.
|
|
1548
|
+
analyzer_names = _analyzer_names_from_stats(stats_report)
|
|
1549
|
+
test_results, m4_holdout_status = self._m4_holdout_pass(
|
|
1550
|
+
hypotheses, holdout_target, analyzer_names, timings,
|
|
1551
|
+
)
|
|
1552
|
+
else:
|
|
1553
|
+
test_results = self._do_m4(0, hypotheses, stats_report, data, timings)
|
|
1554
|
+
else:
|
|
1555
|
+
logger.info("run_confirm: no hypotheses to confirm.")
|
|
1556
|
+
|
|
1557
|
+
verified = self.hypothesis_tester.best_hypotheses(test_results)
|
|
1558
|
+
stopped_by = _STOPPED_BY_CRITERIA if verified else _STOPPED_BY_MAX
|
|
1559
|
+
|
|
1560
|
+
report = VLDiagnoseReport(
|
|
1561
|
+
cycles=1,
|
|
1562
|
+
resolved=bool(verified),
|
|
1563
|
+
stopped_by=stopped_by,
|
|
1564
|
+
verified_hypotheses=verified,
|
|
1565
|
+
final_hypotheses=hypotheses,
|
|
1566
|
+
all_test_results=test_results,
|
|
1567
|
+
final_stats_report=stats_report,
|
|
1568
|
+
store=self.store,
|
|
1569
|
+
m4_holdout=m4_holdout_status,
|
|
1570
|
+
_run_id=self._run_id,
|
|
1571
|
+
)
|
|
1572
|
+
if self.run_logger:
|
|
1573
|
+
self.run_logger.log_loop_end(
|
|
1574
|
+
report, tokens_used=self._tokens_used, timings=timings
|
|
1575
|
+
)
|
|
1576
|
+
return report
|
|
1577
|
+
|
|
1578
|
+
def run_m5(
|
|
1579
|
+
self,
|
|
1580
|
+
report: VLDiagnoseReport,
|
|
1581
|
+
data: "CaseBatch",
|
|
1582
|
+
*,
|
|
1583
|
+
allow_unverified: bool = False,
|
|
1584
|
+
) -> "Any | None":
|
|
1585
|
+
"""Plan A: run the M5 intervention experiment on the best hypothesis.
|
|
1586
|
+
|
|
1587
|
+
Called *after* :meth:`run` to avoid polluting the inner loop with
|
|
1588
|
+
fix-execution noise. Operates on the highest-confidence verified
|
|
1589
|
+
hypothesis from :attr:`VLDiagnoseReport.verified_hypotheses`; with
|
|
1590
|
+
``allow_unverified=True`` and no verified hypothesis it falls back to
|
|
1591
|
+
the best *unverified* one (see :func:`_unverified_hypotheses`) — the
|
|
1592
|
+
experiment is then a genuine test of a lead M4 could not decide, and
|
|
1593
|
+
its verdict (supported / refuted) is what ``run_fix`` reads.
|
|
1594
|
+
|
|
1595
|
+
Args:
|
|
1596
|
+
report: Returned by :meth:`run`.
|
|
1597
|
+
data: Original case batch (needed by the surgery agent).
|
|
1598
|
+
allow_unverified: Fall back to the best unverified hypothesis when
|
|
1599
|
+
M4 verified none (default False: verified only).
|
|
1600
|
+
|
|
1601
|
+
Returns:
|
|
1602
|
+
:class:`~evalrx.eval_agent.surgery.InterventionResult` or
|
|
1603
|
+
``None`` if there is no hypothesis to act on.
|
|
1604
|
+
"""
|
|
1605
|
+
if report.verified_hypotheses:
|
|
1606
|
+
best_hyp = report.verified_hypotheses[0].hypothesis
|
|
1607
|
+
unverified = False
|
|
1608
|
+
elif allow_unverified:
|
|
1609
|
+
candidates = _unverified_hypotheses(report)
|
|
1610
|
+
if not candidates:
|
|
1611
|
+
logger.info("run_m5: no hypotheses at all to act on.")
|
|
1612
|
+
return None
|
|
1613
|
+
best_hyp = candidates[0]
|
|
1614
|
+
unverified = True
|
|
1615
|
+
logger.info(
|
|
1616
|
+
"run_m5: no verified hypothesis — experimenting on the best UNVERIFIED "
|
|
1617
|
+
"one (allow_unverified=True): %s", str(getattr(best_hyp, "statement", best_hyp))[:120],
|
|
1618
|
+
)
|
|
1619
|
+
else:
|
|
1620
|
+
logger.info("run_m5: no verified hypotheses to act on.")
|
|
1621
|
+
return None
|
|
1622
|
+
|
|
1623
|
+
# M5 is an adaptive experiment: its verdict changes which hypothesis
|
|
1624
|
+
# reaches the repair proposer. It therefore belongs to EXPLORE, not
|
|
1625
|
+
# the final repair CONFIRM partition. Touching CONFIRM here would make
|
|
1626
|
+
# the later candidate choice depend on the same cases used for its
|
|
1627
|
+
# significance gate. (Train/val/test mode: surgery stays on TRAIN —
|
|
1628
|
+
# its inputs, the M2 raw_results and per-case signals, were computed
|
|
1629
|
+
# there — and the TEST partition is invisible to this method entirely.)
|
|
1630
|
+
explore, confirm = self._split_explore_confirm(data)
|
|
1631
|
+
if confirm is not None:
|
|
1632
|
+
data = explore
|
|
1633
|
+
|
|
1634
|
+
results: dict[str, Any] = (
|
|
1635
|
+
report.final_stats_report.raw_results
|
|
1636
|
+
if report.final_stats_report is not None
|
|
1637
|
+
else {}
|
|
1638
|
+
)
|
|
1639
|
+
iv = self.surgery_agent.operate(
|
|
1640
|
+
best_hyp,
|
|
1641
|
+
self.model,
|
|
1642
|
+
results,
|
|
1643
|
+
data,
|
|
1644
|
+
)
|
|
1645
|
+
try:
|
|
1646
|
+
iv.evidence = dict(getattr(iv, "evidence", None) or {})
|
|
1647
|
+
iv.evidence["hypothesis_was_verified"] = not unverified
|
|
1648
|
+
except Exception: # evidence is informational
|
|
1649
|
+
pass
|
|
1650
|
+
report.fix_proposal = iv
|
|
1651
|
+
if self.emitter is not None:
|
|
1652
|
+
from evalrx.contract.emit import from_intervention
|
|
1653
|
+
|
|
1654
|
+
self._emit("m5_surgery", lambda: from_intervention(
|
|
1655
|
+
iv, trace_id=self.emitter.trace_id,
|
|
1656
|
+
))
|
|
1657
|
+
# M5 runs *after* the loop, so log its experiment separately — the
|
|
1658
|
+
# generated script(s), the run output, the agent's thinking and a
|
|
1659
|
+
# snapshot of the workspace. ``cycle=-1`` marks it as post-loop.
|
|
1660
|
+
if self.run_logger is not None:
|
|
1661
|
+
try:
|
|
1662
|
+
self.run_logger.log_experiment(-1, best_hyp, iv, module="m5")
|
|
1663
|
+
except Exception as exc: # logging must never break the fix step
|
|
1664
|
+
logger.warning("run_m5: log_experiment failed: %s", exc)
|
|
1665
|
+
return iv
|
|
1666
|
+
|
|
1667
|
+
def run_fix(
|
|
1668
|
+
self,
|
|
1669
|
+
report: VLDiagnoseReport,
|
|
1670
|
+
data: "CaseBatch",
|
|
1671
|
+
max_tier: "str | Any | None" = None,
|
|
1672
|
+
fix_agent: "Any | None" = None,
|
|
1673
|
+
auto_escalate: bool = False,
|
|
1674
|
+
allow_unverified: bool = False,
|
|
1675
|
+
) -> "Any":
|
|
1676
|
+
"""Post-loop fix module: tiered, validated repair attempts.
|
|
1677
|
+
|
|
1678
|
+
By default the allowed tier is fixed (default L2) and there is no
|
|
1679
|
+
automatic escalation — the returned
|
|
1680
|
+
:class:`~evalrx.eval_agent.stages.fix_agent.FixOutcome` carries a
|
|
1681
|
+
recommendation when nothing validates.
|
|
1682
|
+
|
|
1683
|
+
When ``auto_escalate=True`` the agent steps through the intervention
|
|
1684
|
+
ladder L0/L1/L2 → L3a → L3b, stopping as soon as a candidate validates.
|
|
1685
|
+
Each escalation round receives the full history of prior failed
|
|
1686
|
+
attempts so the judge can generate fundamentally different strategies
|
|
1687
|
+
rather than repeating what already failed.
|
|
1688
|
+
|
|
1689
|
+
Args:
|
|
1690
|
+
report: Returned by :meth:`run`. Only M4-verified hypotheses
|
|
1691
|
+
may author a repair; an empty evidence gate records a
|
|
1692
|
+
skipped M5 stage instead of a pseudo-result.
|
|
1693
|
+
data: Original case batch (validated with paired McNemar
|
|
1694
|
+
against the unmodified baseline).
|
|
1695
|
+
max_tier: Ceiling tier: "L0", "L1", "L2", "L3a", "L3b", "L4".
|
|
1696
|
+
Defaults to L3b when ``auto_escalate=True``, or the
|
|
1697
|
+
agent's configured tier otherwise.
|
|
1698
|
+
fix_agent: Per-call override of :attr:`fix_agent`.
|
|
1699
|
+
auto_escalate: When True, step through tiers automatically,
|
|
1700
|
+
feeding prior failure context to each round.
|
|
1701
|
+
allow_unverified: When True and nothing was M4-verified, run the
|
|
1702
|
+
fix on the best UNVERIFIED leads (M4-tested,
|
|
1703
|
+
non-refuted, highest confidence first; else the
|
|
1704
|
+
last cycle's proposals), flagged to the proposer
|
|
1705
|
+
as leads rather than facts. The fix gate is the
|
|
1706
|
+
candidate validation on CONFIRM, not the
|
|
1707
|
+
hypothesis, so this is safe; the default (False)
|
|
1708
|
+
records a skipped stage instead.
|
|
1709
|
+
"""
|
|
1710
|
+
from evalrx.eval_agent.stages.fix_tiers import FixTier, parse_tier
|
|
1711
|
+
|
|
1712
|
+
# Validate the fix on a held-out partition (leak #3): the hypotheses
|
|
1713
|
+
# were generated on EXPLORE, so the deployed repair must be confirmed on
|
|
1714
|
+
# cases the loop never used to pick the fix. Deterministic re-split of
|
|
1715
|
+
# the same batch; no-op when confirm_split=0.
|
|
1716
|
+
#
|
|
1717
|
+
# Two-way mode: candidates are searched on EXPLORE, the frozen winner is
|
|
1718
|
+
# scored on CONFIRM. Train/val/test mode (test_split > 0): the whole
|
|
1719
|
+
# candidate search/selection moves to CONFIRM (val) — the same partition
|
|
1720
|
+
# M4's holdout verification used, never the train cases the hypotheses
|
|
1721
|
+
# were mined from — and the frozen winner is scored exactly once on the
|
|
1722
|
+
# TEST partition, which no adaptive decision has ever touched. External
|
|
1723
|
+
# validation mode (val_data): same discipline, with the separate frozen
|
|
1724
|
+
# validation batch as the development partition and CONFIRM as the
|
|
1725
|
+
# untouched final gate.
|
|
1726
|
+
explore, confirm, test = self._split_partitions(data)
|
|
1727
|
+
if self.val_data is not None and confirm is not None:
|
|
1728
|
+
dev_data, final_data, dev_label = self.val_data, confirm, "VAL"
|
|
1729
|
+
elif test is not None:
|
|
1730
|
+
dev_data, final_data, dev_label = confirm, test, "VAL"
|
|
1731
|
+
else:
|
|
1732
|
+
dev_data, final_data, dev_label = explore, confirm, "EXPLORE"
|
|
1733
|
+
if confirm is not None:
|
|
1734
|
+
data = final_data
|
|
1735
|
+
|
|
1736
|
+
agent = fix_agent or self.fix_agent
|
|
1737
|
+
# M5's intervention experiment (run_m5) can REFUTE the very hypothesis
|
|
1738
|
+
# M4 verified. A refuted hypothesis must not reach the proposer as
|
|
1739
|
+
# "verified": on qwen3.5-2b/bbh_tracking7 the fix judge/coder were told
|
|
1740
|
+
# the M5-refuted grading-mismatch hypothesis was verified and half the
|
|
1741
|
+
# coded pipeline's design served it. Refuted ones are dropped from the
|
|
1742
|
+
# list and passed to the proposer as "do not build on these".
|
|
1743
|
+
refuted_ids, refuted_notes = _m5_refuted(report)
|
|
1744
|
+
hypotheses = [
|
|
1745
|
+
tr.hypothesis for tr in report.verified_hypotheses
|
|
1746
|
+
if _hyp_key(tr.hypothesis) not in refuted_ids
|
|
1747
|
+
]
|
|
1748
|
+
hypotheses_note = ""
|
|
1749
|
+
if allow_unverified:
|
|
1750
|
+
# Opt-in exploratory repair keeps strong inconclusive leads even
|
|
1751
|
+
# when M4 happened to verify a different, narrower hypothesis. A
|
|
1752
|
+
# secondary supported symptom must not crowd the plausible causal
|
|
1753
|
+
# mechanisms out of the intervention search. They remain clearly
|
|
1754
|
+
# marked as leads; only the final candidate validation is a fix.
|
|
1755
|
+
leads = [
|
|
1756
|
+
h for h in _unverified_hypotheses(report)
|
|
1757
|
+
if _hyp_key(h) not in refuted_ids
|
|
1758
|
+
and _hyp_key(h) not in {_hyp_key(v) for v in hypotheses}
|
|
1759
|
+
][:3]
|
|
1760
|
+
had_verified = bool(hypotheses)
|
|
1761
|
+
hypotheses.extend(leads)
|
|
1762
|
+
supported = _m5_supported_key(report)
|
|
1763
|
+
if leads:
|
|
1764
|
+
hypotheses_note = (
|
|
1765
|
+
("MIXED EVIDENCE: verified hypotheses come first; the remaining "
|
|
1766
|
+
if had_verified else
|
|
1767
|
+
"UNVERIFIED: M4 found no statistically significant evidence for these ")
|
|
1768
|
+
+ "hypotheses; they are best-scoring leads, not established "
|
|
1769
|
+
"mechanisms"
|
|
1770
|
+
+ ("; the M5 intervention experiment SUPPORTED the first lead"
|
|
1771
|
+
if supported and _hyp_key(leads[0]) == supported else "")
|
|
1772
|
+
+ ". Treat inconclusive leads only as hints about WHERE to intervene; "
|
|
1773
|
+
"the final candidate validation decides."
|
|
1774
|
+
)
|
|
1775
|
+
if not hypotheses:
|
|
1776
|
+
# A repair proposal is an intervention, not another exploratory
|
|
1777
|
+
# probe. Do not turn an unreviewed/unsupported M3 lead into a
|
|
1778
|
+
# misleading empty M5 outcome. The caller gets a stable outcome
|
|
1779
|
+
# object for compatibility, while the audit records a skipped stage.
|
|
1780
|
+
from evalrx.eval_agent.stages.fix_agent import FixOutcome
|
|
1781
|
+
|
|
1782
|
+
outcome = FixOutcome(
|
|
1783
|
+
max_tier=getattr(agent, "max_tier", FixTier.L2_SCAFFOLD),
|
|
1784
|
+
stage_status="skipped",
|
|
1785
|
+
skip_reason="no_accepted_hypothesis",
|
|
1786
|
+
recommendation={
|
|
1787
|
+
"reason": "Repair was not attempted because no hypothesis passed the evidence gate.",
|
|
1788
|
+
"next_step": "Run the targeted diagnostic probe proposed by M3.",
|
|
1789
|
+
},
|
|
1790
|
+
)
|
|
1791
|
+
if self.run_logger is not None:
|
|
1792
|
+
self.run_logger.log_stage_skipped(
|
|
1793
|
+
"M5", "no_accepted_hypothesis",
|
|
1794
|
+
detail="No M4-verified hypothesis was available for repair authoring.",
|
|
1795
|
+
)
|
|
1796
|
+
self._set_fix_outcome(report, outcome)
|
|
1797
|
+
return outcome
|
|
1798
|
+
|
|
1799
|
+
context = _fix_context_from_report(
|
|
1800
|
+
report,
|
|
1801
|
+
example_cases=explore if confirm is not None else None,
|
|
1802
|
+
explore_context=self._explore_context,
|
|
1803
|
+
protocol=self.protocol,
|
|
1804
|
+
refuted=refuted_notes,
|
|
1805
|
+
refuted_ids=refuted_ids,
|
|
1806
|
+
)
|
|
1807
|
+
context.hypotheses_note = hypotheses_note
|
|
1808
|
+
|
|
1809
|
+
# With a held-out split, the entire tier ladder is searched on the
|
|
1810
|
+
# development partition (EXPLORE, or VAL in train/val/test mode).
|
|
1811
|
+
# Feedback may adapt the next tier there; the final partition remains
|
|
1812
|
+
# untouched until one candidate is frozen and evaluated exactly once.
|
|
1813
|
+
# This keeps automatic L0 -> L1 -> L2 -> L3a -> L3b escalation without
|
|
1814
|
+
# tuning on the holdout. Each ceiling is tried separately: cheaper
|
|
1815
|
+
# candidates are not pooled with a newly opened, more invasive tier.
|
|
1816
|
+
if auto_escalate and confirm is not None:
|
|
1817
|
+
ladder = [
|
|
1818
|
+
FixTier.L0_RUNTIME_CONFIG,
|
|
1819
|
+
FixTier.L1_PROMPT,
|
|
1820
|
+
FixTier.L2_SCAFFOLD,
|
|
1821
|
+
FixTier.L3A_INTERNALS_READ,
|
|
1822
|
+
FixTier.L3B_INTERNALS_WRITE,
|
|
1823
|
+
]
|
|
1824
|
+
ceiling = (
|
|
1825
|
+
parse_tier(max_tier) if max_tier is not None
|
|
1826
|
+
else FixTier.L3B_INTERNALS_WRITE
|
|
1827
|
+
)
|
|
1828
|
+
agent_logger = getattr(agent, "run_logger", None)
|
|
1829
|
+
original_min_tier = getattr(agent, "min_tier", None)
|
|
1830
|
+
agent.run_logger = None
|
|
1831
|
+
attempted: "list" = []
|
|
1832
|
+
prior: "list" = []
|
|
1833
|
+
repair_rounds = 0
|
|
1834
|
+
try:
|
|
1835
|
+
for tier in ladder:
|
|
1836
|
+
if tier > ceiling:
|
|
1837
|
+
break
|
|
1838
|
+
agent.max_tier = tier
|
|
1839
|
+
agent.min_tier = tier
|
|
1840
|
+
logger.info(
|
|
1841
|
+
"run_fix: %s trying tier %s (%d prior attempt(s))",
|
|
1842
|
+
dev_label,
|
|
1843
|
+
tier.label,
|
|
1844
|
+
len(prior),
|
|
1845
|
+
)
|
|
1846
|
+
selection = _propose_and_validate(
|
|
1847
|
+
agent,
|
|
1848
|
+
self.model,
|
|
1849
|
+
dev_data,
|
|
1850
|
+
hypotheses,
|
|
1851
|
+
prior_attempts=prior if prior else None,
|
|
1852
|
+
context=context,
|
|
1853
|
+
)
|
|
1854
|
+
if not hasattr(selection, "attempted"):
|
|
1855
|
+
self._set_fix_outcome(report, selection)
|
|
1856
|
+
return selection
|
|
1857
|
+
attempted.extend(selection.attempted)
|
|
1858
|
+
repair_rounds += int(getattr(selection, "repair_rounds", 0) or 0)
|
|
1859
|
+
if selection.fixed:
|
|
1860
|
+
logger.info("run_fix: %s fixed at tier %s", dev_label, tier.label)
|
|
1861
|
+
break
|
|
1862
|
+
prior.extend(v for v in selection.attempted if not v.fixed)
|
|
1863
|
+
logger.info("run_fix: %s tier %s exhausted — escalating", dev_label, tier.label)
|
|
1864
|
+
finally:
|
|
1865
|
+
agent.run_logger = agent_logger
|
|
1866
|
+
agent.max_tier = ceiling
|
|
1867
|
+
agent.min_tier = original_min_tier
|
|
1868
|
+
|
|
1869
|
+
outcome = _confirm_from_explore(
|
|
1870
|
+
agent,
|
|
1871
|
+
self.model,
|
|
1872
|
+
final_data,
|
|
1873
|
+
attempted,
|
|
1874
|
+
max_tier=ceiling,
|
|
1875
|
+
repair_rounds=repair_rounds,
|
|
1876
|
+
)
|
|
1877
|
+
try:
|
|
1878
|
+
if agent_logger is not None:
|
|
1879
|
+
agent_logger.log_fix(outcome)
|
|
1880
|
+
except Exception as exc:
|
|
1881
|
+
logger.debug("run_fix: held-out escalation log_fix failed: %s", exc)
|
|
1882
|
+
self._set_fix_outcome(report, outcome)
|
|
1883
|
+
return outcome
|
|
1884
|
+
|
|
1885
|
+
if auto_escalate:
|
|
1886
|
+
_LADDER = [
|
|
1887
|
+
FixTier.L0_RUNTIME_CONFIG,
|
|
1888
|
+
FixTier.L1_PROMPT,
|
|
1889
|
+
FixTier.L2_SCAFFOLD,
|
|
1890
|
+
FixTier.L3A_INTERNALS_READ,
|
|
1891
|
+
FixTier.L3B_INTERNALS_WRITE,
|
|
1892
|
+
]
|
|
1893
|
+
ceiling = (
|
|
1894
|
+
parse_tier(max_tier) if max_tier is not None
|
|
1895
|
+
else FixTier.L3B_INTERNALS_WRITE
|
|
1896
|
+
)
|
|
1897
|
+
# Suppress per-round log_fix so we can emit one combined outcome.
|
|
1898
|
+
agent_logger = getattr(agent, "run_logger", None)
|
|
1899
|
+
original_min_tier = getattr(agent, "min_tier", None)
|
|
1900
|
+
agent.run_logger = None
|
|
1901
|
+
|
|
1902
|
+
all_attempted: "list" = []
|
|
1903
|
+
all_prior: "list" = []
|
|
1904
|
+
last_outcome = None
|
|
1905
|
+
|
|
1906
|
+
try:
|
|
1907
|
+
for tier in _LADDER:
|
|
1908
|
+
if tier > ceiling:
|
|
1909
|
+
break
|
|
1910
|
+
agent.max_tier = tier
|
|
1911
|
+
agent.min_tier = tier
|
|
1912
|
+
logger.info("run_fix: trying tier %s (%d prior attempt(s))",
|
|
1913
|
+
tier.label, len(all_prior))
|
|
1914
|
+
outcome = _propose_and_validate(
|
|
1915
|
+
agent, self.model, data, hypotheses,
|
|
1916
|
+
prior_attempts=all_prior if all_prior else None,
|
|
1917
|
+
context=context,
|
|
1918
|
+
)
|
|
1919
|
+
all_attempted.extend(outcome.attempted)
|
|
1920
|
+
all_prior.extend(v for v in outcome.attempted if not v.fixed)
|
|
1921
|
+
last_outcome = outcome
|
|
1922
|
+
if outcome.fixed:
|
|
1923
|
+
logger.info("run_fix: fixed at tier %s", tier.label)
|
|
1924
|
+
break
|
|
1925
|
+
logger.info("run_fix: tier %s exhausted — escalating", tier.label)
|
|
1926
|
+
finally:
|
|
1927
|
+
agent.run_logger = agent_logger
|
|
1928
|
+
agent.min_tier = original_min_tier
|
|
1929
|
+
|
|
1930
|
+
# Merge all rounds into one combined outcome and emit once. The
|
|
1931
|
+
# merged set spans every escalated tier, so it is a LARGER best-of-N
|
|
1932
|
+
# family than any single tier — re-apply e-BH FDR control over the
|
|
1933
|
+
# whole union (mirrors FixAgent.propose_and_validate) instead of an
|
|
1934
|
+
# uncorrected max, or auto-escalation would re-open the multiplicity
|
|
1935
|
+
# leak it was meant to respect.
|
|
1936
|
+
if last_outcome is not None:
|
|
1937
|
+
last_outcome.attempted = all_attempted
|
|
1938
|
+
last_outcome.max_tier = ceiling
|
|
1939
|
+
tested = [v for v in all_attempted if v.e_value is not None]
|
|
1940
|
+
survivors = agent._ebh_survivors(tested)
|
|
1941
|
+
last_outcome.ebh_survivors = sorted(
|
|
1942
|
+
v.candidate.name for v in tested if id(v) in survivors)
|
|
1943
|
+
winners = [v for v in all_attempted
|
|
1944
|
+
if v.fixed and id(v) in survivors]
|
|
1945
|
+
if winners:
|
|
1946
|
+
last_outcome.best = max(
|
|
1947
|
+
winners, key=lambda v: (v.effect or 0.0, -v.n_broken)
|
|
1948
|
+
)
|
|
1949
|
+
last_outcome.fixed = True
|
|
1950
|
+
else:
|
|
1951
|
+
last_outcome.best = None
|
|
1952
|
+
last_outcome.fixed = False
|
|
1953
|
+
try:
|
|
1954
|
+
if agent_logger is not None:
|
|
1955
|
+
agent_logger.log_fix(last_outcome)
|
|
1956
|
+
except Exception as exc:
|
|
1957
|
+
logger.debug("run_fix: combined log_fix failed: %s", exc)
|
|
1958
|
+
|
|
1959
|
+
self._set_fix_outcome(report, last_outcome)
|
|
1960
|
+
return last_outcome
|
|
1961
|
+
|
|
1962
|
+
# Non-escalating path. With a held-out split this is a genuine
|
|
1963
|
+
# two-stage repair experiment: the agent may iterate and select on the
|
|
1964
|
+
# development partition (EXPLORE, or VAL in train/val/test mode), then
|
|
1965
|
+
# exactly one frozen candidate is tested on the final partition.
|
|
1966
|
+
# Selection statistics are descriptive only; the final paired gate is
|
|
1967
|
+
# computed from the final partition alone.
|
|
1968
|
+
if max_tier is not None:
|
|
1969
|
+
agent.max_tier = parse_tier(max_tier)
|
|
1970
|
+
if confirm is not None:
|
|
1971
|
+
agent_logger = getattr(agent, "run_logger", None)
|
|
1972
|
+
agent.run_logger = None
|
|
1973
|
+
try:
|
|
1974
|
+
selection = _propose_and_validate(
|
|
1975
|
+
agent, self.model, dev_data, hypotheses, context=context
|
|
1976
|
+
)
|
|
1977
|
+
finally:
|
|
1978
|
+
agent.run_logger = agent_logger
|
|
1979
|
+
|
|
1980
|
+
# Custom/legacy agents may return an opaque application-specific
|
|
1981
|
+
# result rather than FixOutcome. They cannot participate in the
|
|
1982
|
+
# built-in frozen-candidate confirmation protocol, but preserving
|
|
1983
|
+
# their return contract is preferable to turning a diagnostic run
|
|
1984
|
+
# into an AttributeError.
|
|
1985
|
+
if not hasattr(selection, "attempted"):
|
|
1986
|
+
self._set_fix_outcome(report, selection)
|
|
1987
|
+
return selection
|
|
1988
|
+
|
|
1989
|
+
outcome = _confirm_from_explore(
|
|
1990
|
+
agent,
|
|
1991
|
+
self.model,
|
|
1992
|
+
final_data,
|
|
1993
|
+
selection.attempted,
|
|
1994
|
+
max_tier=agent.max_tier,
|
|
1995
|
+
repair_rounds=selection.repair_rounds,
|
|
1996
|
+
)
|
|
1997
|
+
try:
|
|
1998
|
+
if agent_logger is not None:
|
|
1999
|
+
agent_logger.log_fix(outcome)
|
|
2000
|
+
except Exception as exc:
|
|
2001
|
+
logger.debug("run_fix: held-out log_fix failed: %s", exc)
|
|
2002
|
+
self._set_fix_outcome(report, outcome)
|
|
2003
|
+
return outcome
|
|
2004
|
+
|
|
2005
|
+
outcome = _propose_and_validate(agent, self.model, data, hypotheses, context=context)
|
|
2006
|
+
self._set_fix_outcome(report, outcome)
|
|
2007
|
+
return outcome
|
|
2008
|
+
|
|
2009
|
+
|
|
2010
|
+
def _confirm_from_explore(
|
|
2011
|
+
agent: "Any",
|
|
2012
|
+
model: "Any",
|
|
2013
|
+
confirm: "CaseBatch",
|
|
2014
|
+
attempted: "list[Any]",
|
|
2015
|
+
*,
|
|
2016
|
+
max_tier: "Any",
|
|
2017
|
+
repair_rounds: int,
|
|
2018
|
+
) -> "Any":
|
|
2019
|
+
"""Freeze the strongest EXPLORE improvement and test it once on CONFIRM."""
|
|
2020
|
+
from evalrx.eval_agent.stages.fix_agent import FixOutcome, plain_description
|
|
2021
|
+
|
|
2022
|
+
def _recommend(reason: str, validations: "list[Any]") -> "dict[str, Any] | None":
|
|
2023
|
+
routed = sorted({v.candidate.tier for v in validations})
|
|
2024
|
+
recommend = getattr(agent, "_recommend", None)
|
|
2025
|
+
if callable(recommend):
|
|
2026
|
+
try:
|
|
2027
|
+
return recommend(
|
|
2028
|
+
routed,
|
|
2029
|
+
model=model,
|
|
2030
|
+
data=confirm,
|
|
2031
|
+
reason_prefix=reason,
|
|
2032
|
+
)
|
|
2033
|
+
except TypeError:
|
|
2034
|
+
# Backward compatibility for application-specific FixAgents.
|
|
2035
|
+
pass
|
|
2036
|
+
return {"recommend_tier": max_tier.label, "reason": reason}
|
|
2037
|
+
|
|
2038
|
+
executed = [
|
|
2039
|
+
validation
|
|
2040
|
+
for validation in attempted
|
|
2041
|
+
if validation.n_pairs > 0 and validation.effect is not None
|
|
2042
|
+
]
|
|
2043
|
+
improving = [
|
|
2044
|
+
validation
|
|
2045
|
+
for validation in executed
|
|
2046
|
+
if validation.n_fixed > validation.n_broken
|
|
2047
|
+
]
|
|
2048
|
+
# A spectacular percentage on two applicable examples is a fragile lead,
|
|
2049
|
+
# not a better selection than a supported improvement on dozens. Require
|
|
2050
|
+
# up to eight EXPLORE pairs when that support exists anywhere in the
|
|
2051
|
+
# candidate family; small experiments still select from what they have.
|
|
2052
|
+
support_floor = min(8, max((v.n_pairs for v in improving), default=0))
|
|
2053
|
+
supported = [v for v in improving if v.n_pairs >= support_floor]
|
|
2054
|
+
selected = max(
|
|
2055
|
+
supported,
|
|
2056
|
+
key=lambda validation: (
|
|
2057
|
+
validation.e_value or 0.0,
|
|
2058
|
+
validation.n_fixed - validation.n_broken,
|
|
2059
|
+
validation.n_pairs,
|
|
2060
|
+
validation.effect or 0.0,
|
|
2061
|
+
-validation.n_broken,
|
|
2062
|
+
-int(validation.candidate.tier),
|
|
2063
|
+
),
|
|
2064
|
+
default=None,
|
|
2065
|
+
)
|
|
2066
|
+
# `headline` travels with the audit row because the candidate object does
|
|
2067
|
+
# not: the contract emitter sees only these dicts, and a slug is the one
|
|
2068
|
+
# thing it must not show a reader.
|
|
2069
|
+
audit = [
|
|
2070
|
+
{
|
|
2071
|
+
"name": validation.candidate.name,
|
|
2072
|
+
"headline": plain_description(validation.candidate),
|
|
2073
|
+
"tier": validation.candidate.tier.label,
|
|
2074
|
+
"n_pairs": validation.n_pairs,
|
|
2075
|
+
"n_fixed": validation.n_fixed,
|
|
2076
|
+
"n_broken": validation.n_broken,
|
|
2077
|
+
"effect": validation.effect,
|
|
2078
|
+
"e_value": validation.e_value,
|
|
2079
|
+
"coverage": validation.coverage,
|
|
2080
|
+
"verdict": validation.verdict,
|
|
2081
|
+
}
|
|
2082
|
+
for validation in attempted
|
|
2083
|
+
]
|
|
2084
|
+
if selected is None:
|
|
2085
|
+
return FixOutcome(
|
|
2086
|
+
max_tier=max_tier,
|
|
2087
|
+
repair_rounds=repair_rounds,
|
|
2088
|
+
selection_attempted=audit,
|
|
2089
|
+
recommendation=_recommend(
|
|
2090
|
+
"no EXPLORE candidate had positive net repairs; CONFIRM was left untouched",
|
|
2091
|
+
attempted,
|
|
2092
|
+
),
|
|
2093
|
+
)
|
|
2094
|
+
|
|
2095
|
+
# ``max_validation_cases`` is an EXPLORE-search budget. Reusing that cap
|
|
2096
|
+
# here would silently throw away untouched confirmation cases exactly when
|
|
2097
|
+
# more power matters most. The candidate is already frozen, so validate it
|
|
2098
|
+
# on the complete CONFIRM partition and restore the agent's search budget
|
|
2099
|
+
# afterwards. Custom/legacy agents without this attribute keep their
|
|
2100
|
+
# existing call contract.
|
|
2101
|
+
selection_cap = getattr(agent, "max_validation_cases", None)
|
|
2102
|
+
if selection_cap is not None:
|
|
2103
|
+
agent.max_validation_cases = 0
|
|
2104
|
+
try:
|
|
2105
|
+
validation = agent.validate_candidate(model, confirm, selected.candidate)
|
|
2106
|
+
finally:
|
|
2107
|
+
if selection_cap is not None:
|
|
2108
|
+
agent.max_validation_cases = selection_cap
|
|
2109
|
+
survivors = agent._ebh_survivors(
|
|
2110
|
+
[validation] if validation.e_value is not None else []
|
|
2111
|
+
)
|
|
2112
|
+
survived = id(validation) in survivors
|
|
2113
|
+
outcome = FixOutcome(
|
|
2114
|
+
max_tier=max_tier,
|
|
2115
|
+
attempted=[validation],
|
|
2116
|
+
best=validation if validation.fixed and survived else None,
|
|
2117
|
+
fixed=bool(validation.fixed and survived),
|
|
2118
|
+
repair_rounds=repair_rounds,
|
|
2119
|
+
ebh_survivors=[validation.candidate.name] if survived else [],
|
|
2120
|
+
selection_attempted=audit,
|
|
2121
|
+
selected_on_explore=selected.candidate.name,
|
|
2122
|
+
)
|
|
2123
|
+
if not outcome.fixed:
|
|
2124
|
+
no_fix = getattr(agent, "_no_fix_recommendation", None)
|
|
2125
|
+
if callable(no_fix):
|
|
2126
|
+
outcome.recommendation = no_fix(
|
|
2127
|
+
[validation], [selected.candidate.tier], confirm, model
|
|
2128
|
+
)
|
|
2129
|
+
if outcome.recommendation is not None:
|
|
2130
|
+
outcome.recommendation["reason"] = (
|
|
2131
|
+
"the candidate selected on EXPLORE did not validate on untouched CONFIRM; "
|
|
2132
|
+
+ outcome.recommendation["reason"]
|
|
2133
|
+
)
|
|
2134
|
+
else:
|
|
2135
|
+
outcome.recommendation = _recommend(
|
|
2136
|
+
"the candidate selected on EXPLORE did not validate on untouched CONFIRM",
|
|
2137
|
+
[validation],
|
|
2138
|
+
)
|
|
2139
|
+
outcome.refine_signal = agent._refine_signal([validation], confirm)
|
|
2140
|
+
return outcome
|
|
2141
|
+
|
|
2142
|
+
|
|
2143
|
+
def _analyzer_names_from_stats(stats_report: "Any") -> "list[str]":
|
|
2144
|
+
"""Analyzer names whose signals the M2 report actually tested.
|
|
2145
|
+
|
|
2146
|
+
Recovered from each result's signal key (``"analyzer.metric"``) so a
|
|
2147
|
+
reloaded report (confirm-only mode) can pin the same analyzer set for the
|
|
2148
|
+
held-out M4 re-probe without the original probe schema.
|
|
2149
|
+
"""
|
|
2150
|
+
names: "list[str]" = []
|
|
2151
|
+
for r in list(getattr(stats_report, "stats_results", None) or []):
|
|
2152
|
+
cfg = getattr(r, "config", None) or {}
|
|
2153
|
+
det = getattr(r, "details", None) or {}
|
|
2154
|
+
sig = str(cfg.get("signal") or det.get("signal") or "")
|
|
2155
|
+
if "." in sig:
|
|
2156
|
+
name = sig.split(".", 1)[0]
|
|
2157
|
+
if name and name not in names:
|
|
2158
|
+
names.append(name)
|
|
2159
|
+
return names
|