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,1079 @@
|
|
|
1
|
+
"""M1 — ProbeAgent: select suitable analyzers and execute them.
|
|
2
|
+
|
|
3
|
+
Combines tool selection (which analyzers to run for this model kind) with
|
|
4
|
+
execution (running each analyzer directly or inside a Docker container).
|
|
5
|
+
|
|
6
|
+
Direct mode (default, static selection)::
|
|
7
|
+
|
|
8
|
+
agent = ProbeAgent(max_analyzers=4)
|
|
9
|
+
results = agent.probe(model, data) # {analyzer_name: Result}
|
|
10
|
+
|
|
11
|
+
LLM-guided mode — pass a judge model and a protocol::
|
|
12
|
+
|
|
13
|
+
agent = ProbeAgent(judge=model, max_analyzers=4)
|
|
14
|
+
results, schema = agent.probe_with_schema(model, data, protocol=protocol)
|
|
15
|
+
print(schema.rationale) # why these analyzers were chosen
|
|
16
|
+
|
|
17
|
+
When both *judge* and *protocol* are present the judge LLM reads the protocol
|
|
18
|
+
description and the catalog of available analyzers, then selects the most
|
|
19
|
+
relevant ones. If the LLM call fails the agent falls back to
|
|
20
|
+
:class:`~evalrx.eval_agent.probe.StrategyProbe` automatically.
|
|
21
|
+
|
|
22
|
+
Docker mode::
|
|
23
|
+
|
|
24
|
+
agent = ProbeAgent(use_docker=True, docker_image="evalrx:latest")
|
|
25
|
+
results = agent.probe(model, data)
|
|
26
|
+
|
|
27
|
+
Docker mode is only engaged for **black-box-compatible** analyzers (those
|
|
28
|
+
whose requirements can be satisfied by a GENERATE-only API model). Analyzers
|
|
29
|
+
that need white-box internals (ATTENTION, HIDDEN_STATES, …) always run
|
|
30
|
+
directly — they need the locally-loaded model and cannot be meaningfully
|
|
31
|
+
containerised without shipping the weights.
|
|
32
|
+
|
|
33
|
+
The Docker container receives a JSON payload via stdin::
|
|
34
|
+
|
|
35
|
+
{"analyzer": "self_consistency", "params": {}, "cases": [...],
|
|
36
|
+
"model_env": "GEMINI_API_KEY"} # which env var carries the API key
|
|
37
|
+
|
|
38
|
+
and writes the analyzer findings as JSON to stdout.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import inspect
|
|
44
|
+
import json
|
|
45
|
+
import logging
|
|
46
|
+
import re
|
|
47
|
+
import subprocess
|
|
48
|
+
import warnings
|
|
49
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
50
|
+
from typing import TYPE_CHECKING, Any
|
|
51
|
+
|
|
52
|
+
from evalrx.core.capability import Capability
|
|
53
|
+
from evalrx.core.experiment import Experiment, ExperimentRunner
|
|
54
|
+
from evalrx.core.registry import registry
|
|
55
|
+
from evalrx.eval_agent.prompts.probe_agent import _SELECTION_PROMPT_TMPL
|
|
56
|
+
from evalrx.eval_agent.stages.probe import ModelKind, StrategyProbe, get_analyzer_catalog
|
|
57
|
+
|
|
58
|
+
if TYPE_CHECKING:
|
|
59
|
+
from evalrx.core.analyzer import Analyzer
|
|
60
|
+
from evalrx.core.case import CaseBatch
|
|
61
|
+
from evalrx.core.model import Model
|
|
62
|
+
from evalrx.core.result import Result
|
|
63
|
+
from evalrx.eval_agent.stages.protocol import ExperimentProtocol, ProbingSchema
|
|
64
|
+
|
|
65
|
+
logger = logging.getLogger(__name__)
|
|
66
|
+
|
|
67
|
+
# Capabilities that a containerised (API-based) model can satisfy.
|
|
68
|
+
_BLACKBOX_CAPS = frozenset({Capability.GENERATE, Capability.LOGPROBS, Capability.TOOL_CALLS})
|
|
69
|
+
|
|
70
|
+
def _is_blackbox_compatible(analyzer_cls: type) -> bool:
|
|
71
|
+
"""True when the analyzer's requires are all satisfiable by a black-box API model."""
|
|
72
|
+
return analyzer_cls.requires <= _BLACKBOX_CAPS
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ProbeAgent:
|
|
76
|
+
"""M1: select analyzers and execute them (directly or via Docker).
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
probe: Static selector — used when *judge* is absent or the
|
|
80
|
+
LLM call fails. Defaults to ``StrategyProbe()``.
|
|
81
|
+
judge: Any :class:`~evalrx.core.model.Model` with
|
|
82
|
+
``Capability.GENERATE``. When set **and** a
|
|
83
|
+
protocol is supplied to :meth:`probe`, the judge LLM
|
|
84
|
+
reads the protocol description and available analyzer
|
|
85
|
+
catalog to select analyzers dynamically.
|
|
86
|
+
runner: Executes direct (non-Docker) runs. Defaults to a
|
|
87
|
+
fresh ``ExperimentRunner()``.
|
|
88
|
+
max_analyzers: Cap on analyzers per cycle.
|
|
89
|
+
analyzer_overrides: Pre-instantiated analyzers for those requiring
|
|
90
|
+
mandatory constructor arguments.
|
|
91
|
+
use_docker: When ``True``, black-box-compatible analyzers are
|
|
92
|
+
run inside a Docker container instead of in-process.
|
|
93
|
+
docker_image: Docker image to use when *use_docker* is ``True``.
|
|
94
|
+
docker_timeout: Seconds before a Docker run is killed.
|
|
95
|
+
model_env_var: Name of the env var inside Docker that carries the
|
|
96
|
+
API key for the containerised model.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(
|
|
100
|
+
self,
|
|
101
|
+
probe: StrategyProbe | None = None,
|
|
102
|
+
judge: "Model | None" = None,
|
|
103
|
+
runner: ExperimentRunner | None = None,
|
|
104
|
+
max_analyzers: int | None = None,
|
|
105
|
+
analyzer_overrides: dict[str, "Analyzer"] | None = None,
|
|
106
|
+
use_docker: bool = False,
|
|
107
|
+
docker_image: str = "evalrx:latest",
|
|
108
|
+
docker_timeout: int = 300,
|
|
109
|
+
model_env_var: str = "GEMINI_API_KEY",
|
|
110
|
+
allow_codegen: bool = False,
|
|
111
|
+
codegen_config: "Any | None" = None,
|
|
112
|
+
probe_generator: "Any | None" = None,
|
|
113
|
+
whitebox_generator: "Any | None" = None,
|
|
114
|
+
case_examples: tuple[int, int] = (4, 2),
|
|
115
|
+
run_logger: "Any | None" = None,
|
|
116
|
+
max_cases_per_analyzer: int = 0,
|
|
117
|
+
) -> None:
|
|
118
|
+
self.selector = probe or StrategyProbe()
|
|
119
|
+
self.judge = judge
|
|
120
|
+
self.runner = runner or ExperimentRunner()
|
|
121
|
+
self.max_analyzers = max_analyzers
|
|
122
|
+
self._overrides: dict[str, "Analyzer"] = analyzer_overrides or {}
|
|
123
|
+
self.use_docker = use_docker
|
|
124
|
+
self.docker_image = docker_image
|
|
125
|
+
self.docker_timeout = docker_timeout
|
|
126
|
+
self.model_env_var = model_env_var
|
|
127
|
+
# Set by probe() / probe_with_schema() so callers can inspect why
|
|
128
|
+
# each analyzer was chosen without changing the return type of probe().
|
|
129
|
+
self.last_schema: "ProbingSchema | None" = None
|
|
130
|
+
# Tier (b): generate a bespoke black-box probe in a sandbox when no
|
|
131
|
+
# catalog analyzer fits. Disabled unless allow_codegen=True + a backend.
|
|
132
|
+
self.allow_codegen = allow_codegen
|
|
133
|
+
self._codegen_config = codegen_config
|
|
134
|
+
self._probe_generator = probe_generator
|
|
135
|
+
self._whitebox_generator = whitebox_generator
|
|
136
|
+
# Tier (c): generated probes are cached and re-run on later cycles.
|
|
137
|
+
# Each entry is (generator, probe) so cached probes re-run on the
|
|
138
|
+
# generator (black-box or white-box) that created them.
|
|
139
|
+
self._generated_probes: list[Any] = []
|
|
140
|
+
# Optional "need_custom" hint extracted from the LLM selection response.
|
|
141
|
+
self._last_need_custom: str | None = None
|
|
142
|
+
# Analyzer runtime failures, accumulated ACROSS cycles (the loop reuses
|
|
143
|
+
# one agent instance). Fed back into the next selection prompt so the
|
|
144
|
+
# judge stops expecting evidence from broken tools, and into the
|
|
145
|
+
# tier-(b) trigger so a bespoke probe replaces the missing evidence.
|
|
146
|
+
self._failed_analyzers: dict[str, str] = {}
|
|
147
|
+
# How many FAIL / PASS example cases to fold into the LLM selection
|
|
148
|
+
# prompt. More examples = more specialised selection, less generalisable
|
|
149
|
+
# (the generalization–specialization trade-off). ``(0, 0)`` disables it.
|
|
150
|
+
self._case_examples = case_examples
|
|
151
|
+
# Optional RunLogger forwarded to the probe generators so their
|
|
152
|
+
# code-writing attempts surface as "tool_codegen" events. The loop sets this
|
|
153
|
+
# when it is left unset, so wiring it here is optional for callers.
|
|
154
|
+
self.run_logger = run_logger
|
|
155
|
+
# Last LLM analyzer-selection judge I/O, surfaced for RunLogger. Empty
|
|
156
|
+
# when selection used the static fallback (no judge / LLM call failed).
|
|
157
|
+
self.last_selection_prompt: str = ""
|
|
158
|
+
self.last_selection_raw: str = ""
|
|
159
|
+
#: Ceiling on how many cases ANY single analyzer receives. 0 = no limit.
|
|
160
|
+
#:
|
|
161
|
+
#: Capping via each analyzer's own ``max_cases`` constructor argument
|
|
162
|
+
#: only reaches the analyzers that HAVE one, and the ones that dominate
|
|
163
|
+
#: M1 wall-clock mostly do not: ``first_error_judge``, ``rise``,
|
|
164
|
+
#: ``self_consistency`` and ``verbalized_confidence`` expose no such knob
|
|
165
|
+
#: and ``trajectory_rubric`` defaults it to None, so all five iterate the
|
|
166
|
+
#: whole batch one generate() at a time. Measured on qwen3.5-2b /
|
|
167
|
+
#: minervamath (136 explore cases, 20 generating analyzers): the caller
|
|
168
|
+
#: capped 7 of them, observed concurrency decayed 5.8 -> 1.0 over three
|
|
169
|
+
#: hours and then sat at exactly 1.0 while the uncapped ones finished
|
|
170
|
+
#: alone, at ~1/10 of the endpoint's throughput.
|
|
171
|
+
#:
|
|
172
|
+
#: This is the backstop for that: it bounds every analyzer, including the
|
|
173
|
+
#: ones with no knob to turn. It costs statistical power, not
|
|
174
|
+
#: correctness -- the same measurement, fewer of them.
|
|
175
|
+
self.max_cases_per_analyzer = int(max_cases_per_analyzer or 0)
|
|
176
|
+
#: Names actually truncated by the cap, for the caller to report.
|
|
177
|
+
self.capped_analyzers: dict[str, tuple[int, int]] = {}
|
|
178
|
+
|
|
179
|
+
# ------------------------------------------------------------------
|
|
180
|
+
# Public interface
|
|
181
|
+
# ------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
def probe(
|
|
184
|
+
self,
|
|
185
|
+
model: "Model",
|
|
186
|
+
data: "CaseBatch",
|
|
187
|
+
*,
|
|
188
|
+
protocol: "ExperimentProtocol | None" = None,
|
|
189
|
+
prior_hypotheses: list[Any] | None = None,
|
|
190
|
+
hint_failure_modes: list[str] | None = None,
|
|
191
|
+
analyzers: list[str] | None = None,
|
|
192
|
+
) -> dict[str, "Result"]:
|
|
193
|
+
"""Select analyzers and run each one, returning a ``{name: Result}`` dict.
|
|
194
|
+
|
|
195
|
+
Analyzer selection strategy (in priority order):
|
|
196
|
+
|
|
197
|
+
1. **LLM-guided** — when a *judge* model and a *protocol* are both
|
|
198
|
+
present, the judge reads the protocol description plus an
|
|
199
|
+
auto-generated catalog of available analyzers and picks the most
|
|
200
|
+
relevant ones. Any *prior_hypotheses* from M3 are included as
|
|
201
|
+
additional context for cycle 2+ focused probing.
|
|
202
|
+
2. **Static fallback** — when no judge or no protocol, uses
|
|
203
|
+
:class:`~evalrx.eval_agent.probe.StrategyProbe` with the
|
|
204
|
+
optional *hint_failure_modes* list for priority-boosting (used by
|
|
205
|
+
:class:`~evalrx.eval_agent.legacy.AutoDiagnoseLoop`).
|
|
206
|
+
|
|
207
|
+
Analyzers are executed in parallel using a ``ThreadPoolExecutor`` so
|
|
208
|
+
that independent analyzers do not wait on each other.
|
|
209
|
+
|
|
210
|
+
Sets :attr:`last_schema` with the selection rationale so callers can
|
|
211
|
+
inspect which analyzers ran and why without changing the return type.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
model: The model to probe.
|
|
215
|
+
data: Cases to run analyzers on.
|
|
216
|
+
protocol: Experiment protocol — enables LLM-guided
|
|
217
|
+
selection when a judge is also configured.
|
|
218
|
+
prior_hypotheses: Hypotheses from M3 in prior cycles. Passed to
|
|
219
|
+
the judge as context for focused follow-up.
|
|
220
|
+
hint_failure_modes: Failure-mode tags for the static fallback path
|
|
221
|
+
(e.g. from :class:`~evalrx.eval_agent.legacy.AutoDiagnoseLoop`).
|
|
222
|
+
analyzers: Exact analyzer names to run, bypassing selection
|
|
223
|
+
entirely (still filtered for applicability).
|
|
224
|
+
Used by the loop's held-out M4 confirmation to
|
|
225
|
+
re-run the same set on the confirm split.
|
|
226
|
+
"""
|
|
227
|
+
rationale: str
|
|
228
|
+
self._last_need_custom = None
|
|
229
|
+
if analyzers is not None:
|
|
230
|
+
# Pinned re-run: the caller already knows exactly which analyzers to
|
|
231
|
+
# execute (M4's held-out confirmation re-runs the last explore
|
|
232
|
+
# cycle's set so the designated signals exist on the confirm split
|
|
233
|
+
# too — a fresh judge selection could pick a different set and turn
|
|
234
|
+
# a real signal into a spurious "designated evidence not measured").
|
|
235
|
+
names = list(dict.fromkeys(analyzers))
|
|
236
|
+
rationale = "Pinned by caller (held-out confirmation re-run)."
|
|
237
|
+
elif self.judge is not None and protocol is not None:
|
|
238
|
+
names, rationale = self._llm_select(protocol, model, prior_hypotheses, data)
|
|
239
|
+
else:
|
|
240
|
+
names = self.selector.select(
|
|
241
|
+
model,
|
|
242
|
+
max_analyzers=self.max_analyzers,
|
|
243
|
+
hint_failure_modes=hint_failure_modes or None,
|
|
244
|
+
data=data,
|
|
245
|
+
)
|
|
246
|
+
rationale = _static_rationale(names, hint_failure_modes)
|
|
247
|
+
names, filtered = self._filter_applicable_names(names, model, data)
|
|
248
|
+
if filtered:
|
|
249
|
+
rationale += f" Filtered inapplicable analyzer(s): {', '.join(filtered)}."
|
|
250
|
+
|
|
251
|
+
# Build (name, analyzer) pairs up front — _make_analyzer is cheap and
|
|
252
|
+
# not thread-safe to call concurrently.
|
|
253
|
+
tasks: list[tuple[str, "Analyzer"]] = []
|
|
254
|
+
for name in names:
|
|
255
|
+
analyzer = self._make_analyzer(name)
|
|
256
|
+
if analyzer is not None:
|
|
257
|
+
tasks.append((name, analyzer))
|
|
258
|
+
|
|
259
|
+
results: dict[str, "Result"] = {}
|
|
260
|
+
|
|
261
|
+
def _run_one(name: str, analyzer: "Analyzer") -> tuple[str, "Result | None"]:
|
|
262
|
+
cls = type(analyzer)
|
|
263
|
+
subset = self._cap_cases(name, analyzer, data)
|
|
264
|
+
if self.use_docker and _is_blackbox_compatible(cls):
|
|
265
|
+
return name, self._run_in_docker(name, analyzer, subset)
|
|
266
|
+
return name, self._run_direct(analyzer, model, subset)
|
|
267
|
+
|
|
268
|
+
# White-box analyzers do GPU forward passes on the SHARED local model;
|
|
269
|
+
# running them in threads races on the model (accelerate device_map
|
|
270
|
+
# hooks are not thread-safe → meta-tensor/dtype errors) and stacks
|
|
271
|
+
# transient activations until the GPU OOMs. Only black-box analyzers
|
|
272
|
+
# (GENERATE/LOGPROBS, possibly Dockerised) are safe to parallelise.
|
|
273
|
+
parallel = [(n, a) for n, a in tasks if _is_blackbox_compatible(type(a))]
|
|
274
|
+
serial = [(n, a) for n, a in tasks if not _is_blackbox_compatible(type(a))]
|
|
275
|
+
|
|
276
|
+
def _record(name: str, result, exc=None) -> None:
|
|
277
|
+
if exc is not None:
|
|
278
|
+
warnings.warn(f"Analyzer '{name}' raised in probe run: {exc}", stacklevel=2)
|
|
279
|
+
elif result is not None:
|
|
280
|
+
results[name] = result
|
|
281
|
+
|
|
282
|
+
if parallel:
|
|
283
|
+
max_workers = min(len(parallel), 8)
|
|
284
|
+
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
|
285
|
+
futures = {pool.submit(_run_one, n, a): n for n, a in parallel}
|
|
286
|
+
for future in as_completed(futures):
|
|
287
|
+
try:
|
|
288
|
+
name, result = future.result()
|
|
289
|
+
_record(name, result)
|
|
290
|
+
except Exception as exc:
|
|
291
|
+
_record(futures[future], None, exc)
|
|
292
|
+
for name, analyzer in serial: # white-box: one at a time on the GPU
|
|
293
|
+
try:
|
|
294
|
+
_, result = _run_one(name, analyzer)
|
|
295
|
+
_record(name, result)
|
|
296
|
+
except Exception as exc:
|
|
297
|
+
_record(name, None, exc)
|
|
298
|
+
|
|
299
|
+
selected = [t[0] for t in tasks]
|
|
300
|
+
failed_selected = [n for n in selected if n not in results]
|
|
301
|
+
for n in failed_selected:
|
|
302
|
+
self._failed_analyzers.setdefault(n, "produced no result")
|
|
303
|
+
if failed_selected:
|
|
304
|
+
rationale += (f" Failed at runtime: {', '.join(failed_selected)} — "
|
|
305
|
+
"their evidence is missing this cycle.")
|
|
306
|
+
# Tier (b)/(c): reuse + generate a bespoke probe when the catalog is
|
|
307
|
+
# thin OR the selected evidence failed to materialize.
|
|
308
|
+
if self.allow_codegen:
|
|
309
|
+
selected += self._maybe_generate(model, data, results,
|
|
310
|
+
failed=failed_selected, protocol=protocol)
|
|
311
|
+
|
|
312
|
+
self.last_schema = _build_schema(selected, rationale, protocol)
|
|
313
|
+
return results
|
|
314
|
+
|
|
315
|
+
def probe_with_schema(
|
|
316
|
+
self,
|
|
317
|
+
model: "Model",
|
|
318
|
+
data: "CaseBatch",
|
|
319
|
+
*,
|
|
320
|
+
protocol: "ExperimentProtocol | None" = None,
|
|
321
|
+
prior_hypotheses: list[Any] | None = None,
|
|
322
|
+
hint_failure_modes: list[str] | None = None,
|
|
323
|
+
) -> "tuple[dict[str, Result], ProbingSchema]":
|
|
324
|
+
"""Like :meth:`probe`, but also returns the :class:`~evalrx.eval_agent.protocol.ProbingSchema`.
|
|
325
|
+
|
|
326
|
+
The schema records which analyzers were selected and why — useful for
|
|
327
|
+
M2/M4 to understand the M1 reasoning without re-running selection.
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
``(results_dict, schema)`` — the same dict as :meth:`probe` plus a
|
|
331
|
+
:class:`~evalrx.eval_agent.protocol.ProbingSchema`.
|
|
332
|
+
"""
|
|
333
|
+
results = self.probe(
|
|
334
|
+
model, data,
|
|
335
|
+
protocol=protocol,
|
|
336
|
+
prior_hypotheses=prior_hypotheses,
|
|
337
|
+
hint_failure_modes=hint_failure_modes,
|
|
338
|
+
)
|
|
339
|
+
schema = self.last_schema
|
|
340
|
+
if schema is None:
|
|
341
|
+
from evalrx.eval_agent.stages.protocol import ProbingSchema
|
|
342
|
+
schema = ProbingSchema(selected_analyzers=list(results), protocol=protocol)
|
|
343
|
+
return results, schema
|
|
344
|
+
|
|
345
|
+
# ------------------------------------------------------------------
|
|
346
|
+
# LLM-guided selection
|
|
347
|
+
# ------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
def _llm_select(
|
|
350
|
+
self,
|
|
351
|
+
protocol: "ExperimentProtocol",
|
|
352
|
+
model: "Model",
|
|
353
|
+
prior_hypotheses: list[Any] | None,
|
|
354
|
+
data: "CaseBatch | None" = None,
|
|
355
|
+
) -> tuple[list[str], str]:
|
|
356
|
+
"""Ask the judge LLM to choose analyzers from the available catalog.
|
|
357
|
+
|
|
358
|
+
The selection prompt is grounded in the *observed* cases (a digest of
|
|
359
|
+
real PASS/FAIL prompts and the model's actual answers) so the judge
|
|
360
|
+
picks analyzers for what actually failed, not just the abstract protocol.
|
|
361
|
+
|
|
362
|
+
Returns ``(selected_names, rationale_string)``. Falls back to static
|
|
363
|
+
selection on any error.
|
|
364
|
+
"""
|
|
365
|
+
assert self.judge is not None # caller guarantees this
|
|
366
|
+
|
|
367
|
+
kind = self.selector.detect_kind(model, data)
|
|
368
|
+
catalog = self._offerable(get_analyzer_catalog(model), data, model)
|
|
369
|
+
if not catalog:
|
|
370
|
+
return self._static_fallback(model, data), "no analyzers available"
|
|
371
|
+
|
|
372
|
+
max_n = self.max_analyzers if self.max_analyzers is not None else len(catalog)
|
|
373
|
+
|
|
374
|
+
analyzer_lines = "\n".join(
|
|
375
|
+
f" - {name}: {desc}" for name, desc in catalog.items()
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
task_domain_section = (
|
|
379
|
+
f"\nTASK DOMAIN: {protocol.task_domain}\n" if protocol.task_domain else ""
|
|
380
|
+
)
|
|
381
|
+
success_criteria_section = (
|
|
382
|
+
f"\nSUCCESS CRITERIA: {protocol.success_criteria}\n"
|
|
383
|
+
if protocol.success_criteria
|
|
384
|
+
else ""
|
|
385
|
+
)
|
|
386
|
+
failure_patterns_section = (
|
|
387
|
+
f"\nADDITIONAL OBSERVATIONS: {protocol.failure_patterns}\n"
|
|
388
|
+
if protocol.failure_patterns
|
|
389
|
+
else ""
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
prior_section = ""
|
|
393
|
+
if prior_hypotheses:
|
|
394
|
+
lines = []
|
|
395
|
+
for h in prior_hypotheses:
|
|
396
|
+
stmt = getattr(h, "statement", str(h))
|
|
397
|
+
status = ""
|
|
398
|
+
s = getattr(h, "status", None)
|
|
399
|
+
if s is not None:
|
|
400
|
+
status = f" [{getattr(s, 'value', str(s))}]"
|
|
401
|
+
lines.append(f" - {stmt}{status}")
|
|
402
|
+
design = getattr(h, "test_design", "")
|
|
403
|
+
if design:
|
|
404
|
+
lines.append(f" → proposed test: {design}")
|
|
405
|
+
prior_section = (
|
|
406
|
+
"\nPRIOR HYPOTHESES FROM THIS INVESTIGATION "
|
|
407
|
+
"(select analyzers that collect the evidence their proposed "
|
|
408
|
+
"tests call for):\n"
|
|
409
|
+
+ "\n".join(lines)
|
|
410
|
+
+ "\n"
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
failed_section = ""
|
|
414
|
+
if self._failed_analyzers:
|
|
415
|
+
failed_lines = "\n".join(
|
|
416
|
+
f" - {n}: {err}" for n, err in self._failed_analyzers.items())
|
|
417
|
+
failed_section = (
|
|
418
|
+
"\nANALYZERS THAT FAILED AT RUNTIME IN THIS INVESTIGATION "
|
|
419
|
+
"(selecting them again will NOT produce evidence — pick "
|
|
420
|
+
"alternatives or request a custom probe):\n" + failed_lines + "\n")
|
|
421
|
+
|
|
422
|
+
n_fail, n_pass = self._case_examples
|
|
423
|
+
cases_section = _summarize_cases(data, max_fail=n_fail, max_pass=n_pass)
|
|
424
|
+
|
|
425
|
+
prompt = _SELECTION_PROMPT_TMPL.format(
|
|
426
|
+
description=protocol.description,
|
|
427
|
+
task_domain_section=task_domain_section,
|
|
428
|
+
success_criteria_section=success_criteria_section,
|
|
429
|
+
failure_patterns_section=failure_patterns_section,
|
|
430
|
+
cases_section=cases_section,
|
|
431
|
+
model_kind=kind.value,
|
|
432
|
+
n_available=len(catalog),
|
|
433
|
+
analyzer_list=analyzer_lines,
|
|
434
|
+
failed_section=failed_section,
|
|
435
|
+
prior_hypotheses_section=prior_section,
|
|
436
|
+
max_n=max_n,
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
self.last_selection_prompt = prompt
|
|
440
|
+
self.last_selection_raw = ""
|
|
441
|
+
try:
|
|
442
|
+
sig = inspect.signature(self.judge.generate)
|
|
443
|
+
if "temperature" in sig.parameters:
|
|
444
|
+
raw = self.judge.generate(prompt, temperature=0)
|
|
445
|
+
else:
|
|
446
|
+
raw = self.judge.generate(prompt)
|
|
447
|
+
except Exception as exc:
|
|
448
|
+
logger.warning("ProbeAgent LLM selection failed (%s) — using static fallback", exc)
|
|
449
|
+
return self._static_fallback(model, data), "static fallback (LLM call failed)"
|
|
450
|
+
|
|
451
|
+
self.last_selection_raw = str(raw)
|
|
452
|
+
return self._parse_llm_response(str(raw), set(catalog), max_n, model, data)
|
|
453
|
+
|
|
454
|
+
def _parse_llm_response(
|
|
455
|
+
self,
|
|
456
|
+
raw: str,
|
|
457
|
+
valid_names: set[str],
|
|
458
|
+
max_n: int,
|
|
459
|
+
model: "Model",
|
|
460
|
+
data: "CaseBatch | None" = None,
|
|
461
|
+
) -> tuple[list[str], str]:
|
|
462
|
+
"""Extract analyzer names and rationale from the LLM JSON response.
|
|
463
|
+
|
|
464
|
+
Strips ``<think>…</think>`` blocks emitted by reasoning models (e.g.
|
|
465
|
+
Qwen3) before searching for the JSON payload so that greedy matching
|
|
466
|
+
does not swallow reasoning text as part of the JSON.
|
|
467
|
+
"""
|
|
468
|
+
# Remove <think>…</think> reasoning blocks before JSON search
|
|
469
|
+
cleaned = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip()
|
|
470
|
+
|
|
471
|
+
# An empty response (e.g. agy rate-limited/quota-exhausted) is distinct
|
|
472
|
+
# from an unparseable one — surface that so the fallback is diagnosable.
|
|
473
|
+
if not cleaned:
|
|
474
|
+
logger.warning("ProbeAgent: LLM returned an empty response — using static fallback")
|
|
475
|
+
return self._static_fallback(model, data), "static fallback (LLM returned empty response)"
|
|
476
|
+
|
|
477
|
+
# Use a non-greedy search that stops at the first closing brace so we
|
|
478
|
+
# don't accidentally span multiple JSON objects.
|
|
479
|
+
match = re.search(r"\{[^{}]*\}", cleaned)
|
|
480
|
+
if match:
|
|
481
|
+
try:
|
|
482
|
+
payload = json.loads(match.group())
|
|
483
|
+
# Normalise to lowercase so the LLM can return "POPE" or "Pope"
|
|
484
|
+
names = [
|
|
485
|
+
n.lower() for n in payload.get("analyzers", [])
|
|
486
|
+
if n.lower() in valid_names
|
|
487
|
+
]
|
|
488
|
+
rationale = str(payload.get("rationale", "LLM-selected"))
|
|
489
|
+
need = payload.get("need_custom")
|
|
490
|
+
if isinstance(need, str) and need.strip():
|
|
491
|
+
self._last_need_custom = need.strip()
|
|
492
|
+
if names:
|
|
493
|
+
return names[:max_n], rationale
|
|
494
|
+
except json.JSONDecodeError:
|
|
495
|
+
pass
|
|
496
|
+
|
|
497
|
+
# Soft parse: case-insensitive scan for any known analyzer name
|
|
498
|
+
cleaned_lower = cleaned.lower()
|
|
499
|
+
found = [n for n in sorted(valid_names) if n in cleaned_lower]
|
|
500
|
+
if found:
|
|
501
|
+
logger.warning("ProbeAgent: LLM response not valid JSON — extracted names by text scan")
|
|
502
|
+
return found[:max_n], "LLM-selected (text-extracted)"
|
|
503
|
+
|
|
504
|
+
logger.warning("ProbeAgent: could not parse LLM response — using static fallback")
|
|
505
|
+
return self._static_fallback(model, data), "static fallback (LLM parse failed)"
|
|
506
|
+
|
|
507
|
+
def _static_fallback(self, model: "Model", data: "CaseBatch | None" = None) -> list[str]:
|
|
508
|
+
return self.selector.select(model, max_analyzers=self.max_analyzers, data=data)
|
|
509
|
+
|
|
510
|
+
def _filter_applicable_names(
|
|
511
|
+
self,
|
|
512
|
+
names: list[str],
|
|
513
|
+
model: "Model",
|
|
514
|
+
data: "CaseBatch",
|
|
515
|
+
) -> tuple[list[str], list[str]]:
|
|
516
|
+
"""Drop analyzers whose data preconditions are not met.
|
|
517
|
+
|
|
518
|
+
LLM selection can choose semantically plausible tools that require a
|
|
519
|
+
specific case shape. Filtering here prevents invalid runs such as POPE
|
|
520
|
+
on open-ended VQA prompts without yes/no gold labels.
|
|
521
|
+
"""
|
|
522
|
+
max_n = self.max_analyzers if self.max_analyzers is not None else len(names)
|
|
523
|
+
selected: list[str] = []
|
|
524
|
+
filtered: list[str] = []
|
|
525
|
+
filtered_seen: set[str] = set()
|
|
526
|
+
|
|
527
|
+
def _consider(candidate: str) -> None:
|
|
528
|
+
if candidate in selected:
|
|
529
|
+
return
|
|
530
|
+
# Both selection paths (judge and static) converge here, so this is
|
|
531
|
+
# where "the data is the wrong shape" and "the collaborator was never
|
|
532
|
+
# injected" have to be caught. Previously neither was: an analyzer
|
|
533
|
+
# that could not be built was selected anyway and then dropped at
|
|
534
|
+
# instantiation with a warning, having already cost a slot.
|
|
535
|
+
if not _analyzer_data_preconditions_met(candidate, data, model) \
|
|
536
|
+
or self._needs_injection(candidate):
|
|
537
|
+
if candidate not in filtered_seen:
|
|
538
|
+
filtered.append(candidate)
|
|
539
|
+
filtered_seen.add(candidate)
|
|
540
|
+
return
|
|
541
|
+
selected.append(candidate)
|
|
542
|
+
|
|
543
|
+
if max_n <= 0:
|
|
544
|
+
return selected, filtered
|
|
545
|
+
|
|
546
|
+
for name in names:
|
|
547
|
+
_consider(name)
|
|
548
|
+
if len(selected) >= max_n:
|
|
549
|
+
return selected, filtered
|
|
550
|
+
|
|
551
|
+
# Backfill from the static selector so one bad LLM choice does not leave
|
|
552
|
+
# M1 empty when other compatible analyzers can run.
|
|
553
|
+
for name in self.selector.select(model, max_analyzers=None, data=data):
|
|
554
|
+
_consider(name)
|
|
555
|
+
if len(selected) >= max_n:
|
|
556
|
+
break
|
|
557
|
+
return selected, filtered
|
|
558
|
+
|
|
559
|
+
# ------------------------------------------------------------------
|
|
560
|
+
# Tier (b)/(c): probe generation
|
|
561
|
+
# ------------------------------------------------------------------
|
|
562
|
+
|
|
563
|
+
def _maybe_generate(
|
|
564
|
+
self,
|
|
565
|
+
model: "Model",
|
|
566
|
+
data: "CaseBatch",
|
|
567
|
+
results: "dict[str, Result]",
|
|
568
|
+
failed: "list[str] | None" = None,
|
|
569
|
+
protocol: "ExperimentProtocol | None" = None,
|
|
570
|
+
) -> list[str]:
|
|
571
|
+
"""Reuse cached generated probes, then generate one if the catalog is thin.
|
|
572
|
+
|
|
573
|
+
Generator dispatch: when the requested probe targets the model's
|
|
574
|
+
*internals* (attention/layers/hidden states/…) and the model is
|
|
575
|
+
white-box, the capture-then-compute
|
|
576
|
+
:class:`~evalrx.eval_agent.stages.whitebox_probe_generator.WhiteboxProbeGenerator`
|
|
577
|
+
is used; otherwise the black-box output-probe generator.
|
|
578
|
+
|
|
579
|
+
Mutates *results* in place (adds ``generated:<name>`` entries) and returns
|
|
580
|
+
the list of probe names added, for inclusion in the schema.
|
|
581
|
+
"""
|
|
582
|
+
added: list[str] = []
|
|
583
|
+
|
|
584
|
+
# Tier (c): re-run cached generated probes on the generator that made them.
|
|
585
|
+
for gen, probe in self._generated_probes:
|
|
586
|
+
r = gen.run_cached(probe, model, data)
|
|
587
|
+
if r is not None:
|
|
588
|
+
results[r.analyzer] = r
|
|
589
|
+
added.append(r.analyzer)
|
|
590
|
+
|
|
591
|
+
# Tier (b): generate a new probe when the judge asked for one, when the
|
|
592
|
+
# judge's SELECTED analyzers failed at runtime (their evidence is
|
|
593
|
+
# missing either way), or when nothing produced any output.
|
|
594
|
+
need = self._last_need_custom
|
|
595
|
+
should_generate = (
|
|
596
|
+
(bool(need) or bool(failed)) and not self._generated_probes
|
|
597
|
+
) or not results
|
|
598
|
+
if not should_generate:
|
|
599
|
+
return added
|
|
600
|
+
|
|
601
|
+
if need:
|
|
602
|
+
goal = need
|
|
603
|
+
elif failed:
|
|
604
|
+
goal = (
|
|
605
|
+
f"Selected analyzers ({', '.join(failed)}) failed to run; collect "
|
|
606
|
+
"equivalent evidence for the failure mechanism: "
|
|
607
|
+
+ (protocol.description if protocol is not None
|
|
608
|
+
else "see the experiment protocol.")
|
|
609
|
+
)
|
|
610
|
+
else:
|
|
611
|
+
goal = "Probe the model outputs for the failure described in the protocol."
|
|
612
|
+
generator = self._dispatch_generator(goal, model)
|
|
613
|
+
if generator is None:
|
|
614
|
+
return added
|
|
615
|
+
name = f"probe{len(self._generated_probes) + 1}"
|
|
616
|
+
result, probe = generator.generate(goal, model, data, name=name)
|
|
617
|
+
if result is not None:
|
|
618
|
+
results[result.analyzer] = result
|
|
619
|
+
added.append(result.analyzer)
|
|
620
|
+
if probe is not None:
|
|
621
|
+
self._generated_probes.append((generator, probe))
|
|
622
|
+
return added
|
|
623
|
+
|
|
624
|
+
# Mechanism wording that calls for internal (white-box) evidence.
|
|
625
|
+
_WHITEBOX_NEED_KEYWORDS = (
|
|
626
|
+
"attention", "layer", "hidden", "logit", "internal", "sink",
|
|
627
|
+
"head", "patch", "token mass", "image token",
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
def _dispatch_generator(self, need: str, model: "Model") -> "Any | None":
|
|
631
|
+
"""Choose the white-box or black-box generator for *need*."""
|
|
632
|
+
lowered = need.lower()
|
|
633
|
+
wants_internals = any(k in lowered for k in self._WHITEBOX_NEED_KEYWORDS)
|
|
634
|
+
is_whitebox = Capability.ATTENTION in getattr(model, "capabilities", frozenset())
|
|
635
|
+
if wants_internals and is_whitebox:
|
|
636
|
+
gen = self._get_whitebox_generator()
|
|
637
|
+
if gen is not None:
|
|
638
|
+
return gen
|
|
639
|
+
return self._get_generator()
|
|
640
|
+
|
|
641
|
+
def _get_generator(self) -> "Any | None":
|
|
642
|
+
"""Lazily build the black-box probe generator from the judge / config."""
|
|
643
|
+
if self._probe_generator is not None:
|
|
644
|
+
return self._attach_logger(self._probe_generator)
|
|
645
|
+
if self.judge is None and self._codegen_config is None:
|
|
646
|
+
return None
|
|
647
|
+
from evalrx.eval_agent.stages.probe_generator import ProbeGenerator
|
|
648
|
+
gen = ProbeGenerator(
|
|
649
|
+
judge=self.judge, cli_config=self._codegen_config, run_logger=self.run_logger
|
|
650
|
+
)
|
|
651
|
+
if not gen.available:
|
|
652
|
+
return None
|
|
653
|
+
self._probe_generator = gen
|
|
654
|
+
return gen
|
|
655
|
+
|
|
656
|
+
def _get_whitebox_generator(self) -> "Any | None":
|
|
657
|
+
"""Lazily build the white-box (capture-then-compute) probe generator."""
|
|
658
|
+
if self._whitebox_generator is not None:
|
|
659
|
+
return self._attach_logger(self._whitebox_generator)
|
|
660
|
+
if self.judge is None and self._codegen_config is None:
|
|
661
|
+
return None
|
|
662
|
+
from evalrx.eval_agent.stages.whitebox_probe_generator import (
|
|
663
|
+
WhiteboxProbeGenerator,
|
|
664
|
+
)
|
|
665
|
+
gen = WhiteboxProbeGenerator(
|
|
666
|
+
judge=self.judge, cli_config=self._codegen_config, run_logger=self.run_logger
|
|
667
|
+
)
|
|
668
|
+
if not gen.available:
|
|
669
|
+
return None
|
|
670
|
+
self._whitebox_generator = gen
|
|
671
|
+
return gen
|
|
672
|
+
|
|
673
|
+
def _attach_logger(self, gen: "Any") -> "Any":
|
|
674
|
+
"""Ensure a pre-built / cached generator points at the current RunLogger."""
|
|
675
|
+
if self.run_logger is not None and getattr(gen, "run_logger", None) is None:
|
|
676
|
+
try:
|
|
677
|
+
gen.run_logger = self.run_logger
|
|
678
|
+
except Exception: # noqa: BLE001
|
|
679
|
+
pass
|
|
680
|
+
return gen
|
|
681
|
+
|
|
682
|
+
# ------------------------------------------------------------------
|
|
683
|
+
# Execution strategies
|
|
684
|
+
# ------------------------------------------------------------------
|
|
685
|
+
|
|
686
|
+
def _cap_cases(self, name: str, analyzer: "Analyzer", data: "CaseBatch"):
|
|
687
|
+
"""Bound how many cases *analyzer* receives, keeping the labels balanced.
|
|
688
|
+
|
|
689
|
+
Three things this must not do:
|
|
690
|
+
|
|
691
|
+
* **Fight the analyzer's own knob.** If it already caps itself at or
|
|
692
|
+
below the ceiling, it is left alone — otherwise a 12-case analyzer
|
|
693
|
+
configured deliberately would silently be handed 32.
|
|
694
|
+
* **Destroy the contrast.** M1 exists to compare PASS against FAIL, so a
|
|
695
|
+
head-truncation is not acceptable: on a batch that happens to open with
|
|
696
|
+
a run of one label it would hand the analyzer a single class and the
|
|
697
|
+
result would read as a null rather than as a missing comparison. Each
|
|
698
|
+
label group is subsampled proportionally.
|
|
699
|
+
* **Vary between runs.** Even striding, no RNG: two runs on the same
|
|
700
|
+
batch must select the same cases or their numbers are not comparable.
|
|
701
|
+
"""
|
|
702
|
+
cap = self.max_cases_per_analyzer
|
|
703
|
+
if cap <= 0 or data is None or len(data) <= cap:
|
|
704
|
+
return data
|
|
705
|
+
own = getattr(analyzer, "max_cases", None)
|
|
706
|
+
if isinstance(own, int) and 0 < own <= cap:
|
|
707
|
+
return data
|
|
708
|
+
|
|
709
|
+
from evalrx.core.case import CaseBatch
|
|
710
|
+
|
|
711
|
+
groups: dict[Any, list] = {}
|
|
712
|
+
for case in data:
|
|
713
|
+
groups.setdefault(getattr(case, "label", None), []).append(case)
|
|
714
|
+
|
|
715
|
+
picked: list = []
|
|
716
|
+
# Largest group first, so integer truncation costs the class that can
|
|
717
|
+
# best afford it rather than wiping out a small one.
|
|
718
|
+
order = sorted(groups.items(), key=lambda kv: -len(kv[1]))
|
|
719
|
+
remaining = cap
|
|
720
|
+
for index, (_, members) in enumerate(order):
|
|
721
|
+
share = remaining if index == len(order) - 1 else round(
|
|
722
|
+
cap * len(members) / len(data))
|
|
723
|
+
share = max(1, min(share, len(members), remaining))
|
|
724
|
+
if len(members) <= share:
|
|
725
|
+
picked.extend(members)
|
|
726
|
+
else: # even stride across the group, not its head
|
|
727
|
+
step = len(members) / share
|
|
728
|
+
picked.extend(members[int(i * step)] for i in range(share))
|
|
729
|
+
remaining -= share
|
|
730
|
+
if remaining <= 0:
|
|
731
|
+
break
|
|
732
|
+
|
|
733
|
+
self.capped_analyzers[name] = (len(data), len(picked))
|
|
734
|
+
return CaseBatch(picked)
|
|
735
|
+
|
|
736
|
+
def _run_direct(
|
|
737
|
+
self,
|
|
738
|
+
analyzer: "Analyzer",
|
|
739
|
+
model: "Model",
|
|
740
|
+
data: "CaseBatch",
|
|
741
|
+
) -> "Result | None":
|
|
742
|
+
# Wrap the model so every generate/forward/logprobs/chat call THIS
|
|
743
|
+
# analyzer makes is durably recorded (see model_instrumentation.py) —
|
|
744
|
+
# including calls an analyzer makes several times per case and then
|
|
745
|
+
# reduces to one derived score, which otherwise leave no trace. A
|
|
746
|
+
# fresh proxy per call keeps call_index scoped to (cycle, analyzer);
|
|
747
|
+
# __repr__ forwards to the wrapped model so Experiment.fingerprint()
|
|
748
|
+
# and Result.model stay unaffected by the wrapping. Only reaches
|
|
749
|
+
# in-process (non-Docker) analyzers — Docker-mode calls happen inside
|
|
750
|
+
# a subprocess and are out of reach of this wrapper.
|
|
751
|
+
if self.run_logger is not None:
|
|
752
|
+
from evalrx.eval_agent.model_instrumentation import InstrumentedModel
|
|
753
|
+
|
|
754
|
+
# Best-effort (prompt -> case_id) for THIS analyzer's batch, so a
|
|
755
|
+
# call that reuses the case's own prompt unmodified can be tied
|
|
756
|
+
# back to it; batch_case_ids is the fallback for calls that
|
|
757
|
+
# rewrite the prompt (format_sensitivity, cot_faithfulness, ...).
|
|
758
|
+
batch_case_ids = [str(getattr(c, "id", "")) for c in data]
|
|
759
|
+
case_prompts = {
|
|
760
|
+
str(prompt): case_id
|
|
761
|
+
for c, case_id in zip(data, batch_case_ids)
|
|
762
|
+
if (prompt := getattr(getattr(c, "inputs", None), "prompt", None)) is not None
|
|
763
|
+
}
|
|
764
|
+
model = InstrumentedModel(
|
|
765
|
+
model, self.run_logger,
|
|
766
|
+
cycle=getattr(self.run_logger, "current_cycle", -1),
|
|
767
|
+
analyzer=analyzer.name,
|
|
768
|
+
case_prompts=case_prompts,
|
|
769
|
+
batch_case_ids=batch_case_ids,
|
|
770
|
+
)
|
|
771
|
+
exp = Experiment(model=model, analyzer=analyzer, data=data)
|
|
772
|
+
try:
|
|
773
|
+
return self.runner.run(exp)
|
|
774
|
+
except Exception as exc:
|
|
775
|
+
self._failed_analyzers[analyzer.name] = f"{type(exc).__name__}: {str(exc)[:200]}"
|
|
776
|
+
warnings.warn(
|
|
777
|
+
f"Analyzer '{analyzer.name}' raised during direct run: {exc}",
|
|
778
|
+
stacklevel=3,
|
|
779
|
+
)
|
|
780
|
+
return None
|
|
781
|
+
|
|
782
|
+
def _run_in_docker(
|
|
783
|
+
self,
|
|
784
|
+
name: str,
|
|
785
|
+
analyzer: "Analyzer",
|
|
786
|
+
data: "CaseBatch",
|
|
787
|
+
) -> "Result | None":
|
|
788
|
+
"""Run *analyzer* inside a Docker container, returning the Result or None."""
|
|
789
|
+
from evalrx.core.result import Result
|
|
790
|
+
|
|
791
|
+
payload = json.dumps({
|
|
792
|
+
"analyzer": name,
|
|
793
|
+
"params": analyzer.get_params(),
|
|
794
|
+
"cases": _serialize_cases(data),
|
|
795
|
+
"model_env": self.model_env_var,
|
|
796
|
+
})
|
|
797
|
+
cmd = [
|
|
798
|
+
"docker", "run", "--rm", "-i",
|
|
799
|
+
"--env", self.model_env_var,
|
|
800
|
+
self.docker_image,
|
|
801
|
+
"python", "-m", "evalrx.agent_runtime._docker_runner",
|
|
802
|
+
]
|
|
803
|
+
try:
|
|
804
|
+
proc = subprocess.run(
|
|
805
|
+
cmd,
|
|
806
|
+
input=payload.encode(),
|
|
807
|
+
capture_output=True,
|
|
808
|
+
timeout=self.docker_timeout,
|
|
809
|
+
)
|
|
810
|
+
except FileNotFoundError:
|
|
811
|
+
warnings.warn(
|
|
812
|
+
"Docker is not available; falling back to direct execution "
|
|
813
|
+
f"for analyzer '{name}'.",
|
|
814
|
+
stacklevel=3,
|
|
815
|
+
)
|
|
816
|
+
return None
|
|
817
|
+
except subprocess.TimeoutExpired:
|
|
818
|
+
warnings.warn(f"Docker run for '{name}' timed out.", stacklevel=3)
|
|
819
|
+
return None
|
|
820
|
+
|
|
821
|
+
if proc.returncode != 0:
|
|
822
|
+
warnings.warn(
|
|
823
|
+
f"Docker run for '{name}' failed (exit {proc.returncode}): "
|
|
824
|
+
f"{proc.stderr.decode()[:300]}",
|
|
825
|
+
stacklevel=3,
|
|
826
|
+
)
|
|
827
|
+
return None
|
|
828
|
+
|
|
829
|
+
try:
|
|
830
|
+
raw = json.loads(proc.stdout)
|
|
831
|
+
return Result(
|
|
832
|
+
analyzer=name,
|
|
833
|
+
model=f"docker:{self.docker_image}",
|
|
834
|
+
findings=raw.get("findings", {}),
|
|
835
|
+
metadata={"docker": True, "image": self.docker_image},
|
|
836
|
+
cases=data,
|
|
837
|
+
)
|
|
838
|
+
except Exception as exc:
|
|
839
|
+
warnings.warn(f"Could not parse Docker output for '{name}': {exc}", stacklevel=3)
|
|
840
|
+
return None
|
|
841
|
+
|
|
842
|
+
# ------------------------------------------------------------------
|
|
843
|
+
# Helpers
|
|
844
|
+
# ------------------------------------------------------------------
|
|
845
|
+
|
|
846
|
+
def _offerable(self, catalog: "dict[str, str]", data: "Any" = None,
|
|
847
|
+
model: "Model | None" = None) -> "dict[str, str]":
|
|
848
|
+
"""Drop analyzers the judge could pick but this run could never use.
|
|
849
|
+
|
|
850
|
+
Capability + modality matching answers "does the MODEL provide what this
|
|
851
|
+
needs". Two things it cannot answer, both of which produced real damage
|
|
852
|
+
on a single-turn text run:
|
|
853
|
+
|
|
854
|
+
1. **Is the data the right shape?** Every analyzer in ``analyzers/agent/``
|
|
855
|
+
declares no capability (correctly — they read trajectories, sometimes
|
|
856
|
+
with no model at all), so all six matched a plain QA batch.
|
|
857
|
+
``first_error_judge`` was duly selected, ran, and reported
|
|
858
|
+
``n_trajectories: 0``, which M2 then listed as a healthy metric.
|
|
859
|
+
The same hole exists one axis over, for modality: the registry match
|
|
860
|
+
is an intersection against what the MODEL declares, so an omni model
|
|
861
|
+
evaluated on an audio benchmark still declares image and ``pope`` /
|
|
862
|
+
``chair`` were duly offered a batch with no images in it.
|
|
863
|
+
``requires_modalities`` is that gate.
|
|
864
|
+
2. **Did the caller inject the collaborator?** ``reliability_probe``
|
|
865
|
+
wants ``runs_fn``, ``tool_shap`` a tool runner, ``trajectory_rubric``
|
|
866
|
+
a judge. These were offered every run and skipped at instantiation
|
|
867
|
+
with a warning, spending a selection slot a runnable analyzer could
|
|
868
|
+
have had.
|
|
869
|
+
|
|
870
|
+
An override supplies exactly that missing collaborator, so anything in
|
|
871
|
+
``analyzer_overrides`` survives the second check.
|
|
872
|
+
"""
|
|
873
|
+
from evalrx.eval_agent.stages.probe import StrategyProbe, _carries_trajectories
|
|
874
|
+
|
|
875
|
+
has_traj = data is not None and _carries_trajectories(data)
|
|
876
|
+
starved = StrategyProbe.slot_starved(set(catalog), data, model)
|
|
877
|
+
keep: dict[str, str] = {}
|
|
878
|
+
for name, desc in catalog.items():
|
|
879
|
+
cls = registry.analyzers.get(name)
|
|
880
|
+
if getattr(cls, "requires_trajectories", False) and not has_traj:
|
|
881
|
+
logger.debug(
|
|
882
|
+
"not offering analyzer '%s': it reads agent trajectories and "
|
|
883
|
+
"this batch carries none", name,
|
|
884
|
+
)
|
|
885
|
+
continue
|
|
886
|
+
if name in starved:
|
|
887
|
+
logger.debug(
|
|
888
|
+
"not offering analyzer '%s': it needs one of %s filled and this "
|
|
889
|
+
"batch fills none of them", name,
|
|
890
|
+
sorted(getattr(cls, "requires_modalities", frozenset())),
|
|
891
|
+
)
|
|
892
|
+
continue
|
|
893
|
+
needs = self._needs_injection(name)
|
|
894
|
+
if needs:
|
|
895
|
+
logger.debug(
|
|
896
|
+
"not offering analyzer '%s': needs %s, which must be injected "
|
|
897
|
+
"via analyzer_overrides", name, needs,
|
|
898
|
+
)
|
|
899
|
+
continue
|
|
900
|
+
keep[name] = desc
|
|
901
|
+
return keep
|
|
902
|
+
|
|
903
|
+
def _needs_injection(self, name: str) -> "list[str]":
|
|
904
|
+
"""Constructor arguments the caller must supply; empty when buildable.
|
|
905
|
+
|
|
906
|
+
An entry in ``analyzer_overrides`` IS that supply, so an overridden
|
|
907
|
+
analyzer always reads as buildable.
|
|
908
|
+
"""
|
|
909
|
+
import inspect
|
|
910
|
+
|
|
911
|
+
if name in self._overrides:
|
|
912
|
+
return []
|
|
913
|
+
cls = registry.analyzers.get(name) if registry.analyzers.has(name) else None
|
|
914
|
+
if cls is None:
|
|
915
|
+
return []
|
|
916
|
+
try:
|
|
917
|
+
params = inspect.signature(cls.__init__).parameters
|
|
918
|
+
except (TypeError, ValueError):
|
|
919
|
+
return []
|
|
920
|
+
return [
|
|
921
|
+
n for n, p in params.items()
|
|
922
|
+
if n != "self"
|
|
923
|
+
and p.default is inspect.Parameter.empty
|
|
924
|
+
and p.kind not in (inspect.Parameter.VAR_POSITIONAL,
|
|
925
|
+
inspect.Parameter.VAR_KEYWORD)
|
|
926
|
+
]
|
|
927
|
+
|
|
928
|
+
def _make_analyzer(self, name: str) -> "Analyzer | None":
|
|
929
|
+
if name in self._overrides:
|
|
930
|
+
return self._overrides[name]
|
|
931
|
+
cls = registry.analyzers.get(name)
|
|
932
|
+
try:
|
|
933
|
+
return cls()
|
|
934
|
+
except TypeError as exc:
|
|
935
|
+
warnings.warn(
|
|
936
|
+
f"Skipping analyzer '{name}': cannot instantiate with default args "
|
|
937
|
+
f"({exc}). Pass an instance via analyzer_overrides.",
|
|
938
|
+
stacklevel=3,
|
|
939
|
+
)
|
|
940
|
+
return None
|
|
941
|
+
|
|
942
|
+
def detect_kind(self, model: "Model", data: "CaseBatch | None" = None) -> ModelKind:
|
|
943
|
+
"""Delegate to the inner :class:`~evalrx.eval_agent.probe.StrategyProbe`."""
|
|
944
|
+
return self.selector.detect_kind(model, data)
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
def _clip(text: str, max_chars: int) -> str:
|
|
948
|
+
"""Collapse whitespace and truncate *text* for compact prompt inclusion."""
|
|
949
|
+
flat = re.sub(r"\s+", " ", str(text)).strip()
|
|
950
|
+
return flat if len(flat) <= max_chars else flat[:max_chars] + "…"
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def _summarize_cases(
|
|
954
|
+
data: "CaseBatch | None",
|
|
955
|
+
*,
|
|
956
|
+
max_fail: int = 4,
|
|
957
|
+
max_pass: int = 2,
|
|
958
|
+
max_chars: int = 160,
|
|
959
|
+
) -> str:
|
|
960
|
+
"""Digest observed PASS/FAIL cases (prompt + expected + model answer).
|
|
961
|
+
|
|
962
|
+
Folded into the M1 selection prompt so the judge selects analyzers grounded
|
|
963
|
+
in concrete failures. Returns ``""`` when there are no labeled cases or the
|
|
964
|
+
feature is disabled (``max_fail == max_pass == 0``).
|
|
965
|
+
"""
|
|
966
|
+
from evalrx.core.case import Label
|
|
967
|
+
|
|
968
|
+
if data is None or (max_fail <= 0 and max_pass <= 0):
|
|
969
|
+
return ""
|
|
970
|
+
|
|
971
|
+
counts = {"pass": 0, "fail": 0, "unknown": 0}
|
|
972
|
+
fails: list[Any] = []
|
|
973
|
+
passes: list[Any] = []
|
|
974
|
+
for c in data:
|
|
975
|
+
label = getattr(c, "label", None)
|
|
976
|
+
key = getattr(label, "value", None)
|
|
977
|
+
if key in counts:
|
|
978
|
+
counts[key] += 1
|
|
979
|
+
if label == Label.FAIL and len(fails) < max_fail:
|
|
980
|
+
fails.append(c)
|
|
981
|
+
elif label == Label.PASS and len(passes) < max_pass:
|
|
982
|
+
passes.append(c)
|
|
983
|
+
|
|
984
|
+
# No concrete PASS/FAIL examples to show (e.g. all UNKNOWN) → no useful digest.
|
|
985
|
+
if not fails and not passes:
|
|
986
|
+
return ""
|
|
987
|
+
|
|
988
|
+
lines = [
|
|
989
|
+
f"\nOBSERVED CASES (PASS={counts['pass']} FAIL={counts['fail']} "
|
|
990
|
+
f"UNKNOWN={counts['unknown']}) — select analyzers for what actually failed:"
|
|
991
|
+
]
|
|
992
|
+
|
|
993
|
+
def _emit(case: Any, tag: str) -> None:
|
|
994
|
+
inp = getattr(case, "inputs", None)
|
|
995
|
+
prompt = getattr(inp, "prompt", "") if inp is not None else ""
|
|
996
|
+
observed = (
|
|
997
|
+
getattr(case, "observed", None)
|
|
998
|
+
or getattr(case, "metadata", {}).get("discovery_observed", "")
|
|
999
|
+
)
|
|
1000
|
+
lines.append(f" [{tag}] prompt: {_clip(prompt, max_chars)}")
|
|
1001
|
+
if getattr(case, "expected", None) is not None:
|
|
1002
|
+
lines.append(f" expected: {_clip(case.expected, max_chars)}")
|
|
1003
|
+
lines.append(f" model answered: {_clip(observed, max_chars)}")
|
|
1004
|
+
|
|
1005
|
+
for c in fails:
|
|
1006
|
+
_emit(c, "FAIL")
|
|
1007
|
+
for c in passes:
|
|
1008
|
+
_emit(c, "PASS")
|
|
1009
|
+
return "\n".join(lines) + "\n"
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
def _static_rationale(names: list[str], hints: list[str] | None) -> str:
|
|
1013
|
+
parts = []
|
|
1014
|
+
if hints:
|
|
1015
|
+
parts.append(f"failure-mode hints: {', '.join(hints)}")
|
|
1016
|
+
return (
|
|
1017
|
+
f"Selected {len(names)} analyzer(s) ({', '.join(names)}) "
|
|
1018
|
+
+ ("guided by " + " + ".join(parts) if parts else "by capability matching")
|
|
1019
|
+
+ "."
|
|
1020
|
+
)
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
def _build_schema(
|
|
1024
|
+
selected: list[str],
|
|
1025
|
+
rationale: str,
|
|
1026
|
+
protocol: "ExperimentProtocol | None",
|
|
1027
|
+
) -> "ProbingSchema":
|
|
1028
|
+
from evalrx.eval_agent.stages.protocol import ProbingSchema
|
|
1029
|
+
return ProbingSchema(
|
|
1030
|
+
selected_analyzers=selected,
|
|
1031
|
+
rationale=rationale,
|
|
1032
|
+
protocol=protocol,
|
|
1033
|
+
)
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
def _serialize_cases(data: "CaseBatch") -> list[dict[str, Any]]:
|
|
1037
|
+
"""Minimal JSON-serialisable representation of a CaseBatch for Docker."""
|
|
1038
|
+
out = []
|
|
1039
|
+
for case in data:
|
|
1040
|
+
inp = getattr(case, "inputs", None)
|
|
1041
|
+
out.append({
|
|
1042
|
+
"id": case.id,
|
|
1043
|
+
"prompt": str(inp.prompt) if inp else "",
|
|
1044
|
+
"label": getattr(case.label, "value", None) if hasattr(case, "label") else None,
|
|
1045
|
+
"metadata": getattr(case, "metadata", {}),
|
|
1046
|
+
})
|
|
1047
|
+
return out
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def _analyzer_data_preconditions_met(name: str, data: "CaseBatch",
|
|
1051
|
+
model: "Model | None" = None) -> bool:
|
|
1052
|
+
cls = registry.analyzers.get(name) if registry.analyzers.has(name) else None
|
|
1053
|
+
if cls is not None and getattr(cls, "requires_modalities", None):
|
|
1054
|
+
# All three selection paths (static, judge, pinned) converge here, so
|
|
1055
|
+
# this is where the modality gate has to hold for the pinned one too.
|
|
1056
|
+
from evalrx.eval_agent.stages.probe import StrategyProbe
|
|
1057
|
+
|
|
1058
|
+
if name in StrategyProbe.slot_starved({name}, data, model):
|
|
1059
|
+
return False
|
|
1060
|
+
if cls is not None and getattr(cls, "requires_trajectories", False):
|
|
1061
|
+
# The agent lane declares no capability — correctly, since these read
|
|
1062
|
+
# trajectories and often take model=None — so capability matching let all
|
|
1063
|
+
# of them through to single-turn batches. This is the precondition that
|
|
1064
|
+
# actually applies: are there trajectories to read?
|
|
1065
|
+
from evalrx.eval_agent.stages.probe import _carries_trajectories
|
|
1066
|
+
|
|
1067
|
+
return _carries_trajectories(data)
|
|
1068
|
+
if name == "pope":
|
|
1069
|
+
return any(
|
|
1070
|
+
str((getattr(case, "metadata", {}) or {}).get("pope_label", "")).lower()
|
|
1071
|
+
in {"yes", "no"}
|
|
1072
|
+
for case in data
|
|
1073
|
+
)
|
|
1074
|
+
if name == "chair":
|
|
1075
|
+
return any(
|
|
1076
|
+
bool((getattr(case, "metadata", {}) or {}).get("gt_objects"))
|
|
1077
|
+
for case in data
|
|
1078
|
+
)
|
|
1079
|
+
return True
|