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,1017 @@
|
|
|
1
|
+
"""M3 — DiagnosisAgent: synthesize analysis findings into falsifiable hypotheses.
|
|
2
|
+
|
|
3
|
+
The agent reads the structured :class:`~evalrx.eval_agent.analysis.AnalysisReport`
|
|
4
|
+
produced by M2, formats it as a prompt, sends it to an LLM judge (Gemini by
|
|
5
|
+
default), and parses the response into :class:`~evalrx.eval_agent.hypothesis.Hypothesis`
|
|
6
|
+
objects.
|
|
7
|
+
|
|
8
|
+
The judge model defaults to **Gemini** when ``GEMINI_API_KEY`` is set in the
|
|
9
|
+
environment and no explicit judge is passed.
|
|
10
|
+
|
|
11
|
+
Usage::
|
|
12
|
+
|
|
13
|
+
# Gemini default (set GEMINI_API_KEY in env)
|
|
14
|
+
agent = DiagnosisAgent()
|
|
15
|
+
diag = agent.diagnose(analysis_report)
|
|
16
|
+
|
|
17
|
+
# Explicit judge (any Model with Capability.GENERATE)
|
|
18
|
+
agent = DiagnosisAgent(judge=my_model)
|
|
19
|
+
diag = agent.diagnose(analysis_report)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import re
|
|
26
|
+
import time
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from typing import TYPE_CHECKING, Any
|
|
29
|
+
|
|
30
|
+
from evalrx.agent_runtime.json_shape import validate_json_shape
|
|
31
|
+
from evalrx.analysis.plain_language import jargon_violation
|
|
32
|
+
from evalrx.eval_agent.hypothesis import Hypothesis
|
|
33
|
+
from evalrx.eval_agent.prompts.diagnosis import (
|
|
34
|
+
_DIAGNOSE_PROMPT,
|
|
35
|
+
_FORMAT_REPAIR_PROMPT,
|
|
36
|
+
_PLAIN_REPAIR_PROMPT,
|
|
37
|
+
_VALIDATE_PROMPT,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from evalrx.analysis.analysis_module import AnalysisReport
|
|
42
|
+
from evalrx.core.model import Model
|
|
43
|
+
from evalrx.core.result import Result
|
|
44
|
+
|
|
45
|
+
def _format_prior_section(prior_cycles: list[dict]) -> str:
|
|
46
|
+
"""Render prior cycle history as a context block for the diagnosis prompt."""
|
|
47
|
+
if not prior_cycles:
|
|
48
|
+
return ""
|
|
49
|
+
lines = [
|
|
50
|
+
"\nPrior investigation cycles — do NOT repeat these hypotheses; propose "
|
|
51
|
+
"distinct or refined ones that address what remains unexplained:",
|
|
52
|
+
]
|
|
53
|
+
for c in prior_cycles:
|
|
54
|
+
lines.append(f"\nCycle {c['cycle']} (severity={c['severity']}):")
|
|
55
|
+
for h in c.get("hypotheses", []):
|
|
56
|
+
lines.append(
|
|
57
|
+
f" [{h['status'].upper()}] {h['statement']} "
|
|
58
|
+
f"(failure_mode: {h['failure_mode']})"
|
|
59
|
+
)
|
|
60
|
+
return "\n".join(lines) + "\n"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class ExploreContext:
|
|
65
|
+
"""Descriptive mechanism notes from the LAMBDA explorer (Step 1).
|
|
66
|
+
|
|
67
|
+
This is **never authoritative**: it is read-only, enters ONLY the M3
|
|
68
|
+
hypothesis-proposal prompt (and the dashboard), and never the M2 confirmatory
|
|
69
|
+
family, M4 testing, or the fix gate. It informs *which* hypotheses M3
|
|
70
|
+
proposes — not *whether* any of them is true. Every chart/observation here is
|
|
71
|
+
free-form EDA on a HELD-OUT explore split and stays UNCONFIRMED until the
|
|
72
|
+
downstream M4+M5+e-BH machinery tests it.
|
|
73
|
+
|
|
74
|
+
Attributes:
|
|
75
|
+
observations: Free-text EDA observations.
|
|
76
|
+
charts: Chart dicts ``{title, kind, description, figure_path}`` —
|
|
77
|
+
``figure_path`` (when present) is attached to M3 as an image.
|
|
78
|
+
caveats: The explorer's own warnings about its findings.
|
|
79
|
+
source: Provenance tag (default ``"lambda_explorer"``).
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
observations: list[str] = field(default_factory=list)
|
|
83
|
+
charts: list[dict[str, Any]] = field(default_factory=list)
|
|
84
|
+
caveats: list[str] = field(default_factory=list)
|
|
85
|
+
source: str = "lambda_explorer"
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def from_report(cls, data: dict[str, Any] | None) -> "ExploreContext | None":
|
|
89
|
+
"""Build from an explore/fused report dict (``fused_report.json`` etc.).
|
|
90
|
+
|
|
91
|
+
Returns ``None`` when *data* is empty or carries no descriptive content,
|
|
92
|
+
so callers can pass it through unconditionally.
|
|
93
|
+
"""
|
|
94
|
+
if not data or not isinstance(data, dict):
|
|
95
|
+
return None
|
|
96
|
+
observations = [str(x) for x in (data.get("observations") or [])]
|
|
97
|
+
charts = [dict(c) for c in (data.get("charts") or []) if isinstance(c, dict)]
|
|
98
|
+
caveats = [str(x) for x in (data.get("caveats") or [])]
|
|
99
|
+
if not (observations or charts or caveats):
|
|
100
|
+
return None
|
|
101
|
+
return cls(
|
|
102
|
+
observations=observations,
|
|
103
|
+
charts=charts,
|
|
104
|
+
caveats=caveats,
|
|
105
|
+
source=str(data.get("source") or "lambda_explorer"),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def figure_paths(self) -> list[str]:
|
|
110
|
+
return [
|
|
111
|
+
str(c["figure_path"])
|
|
112
|
+
for c in self.charts
|
|
113
|
+
if isinstance(c, dict) and c.get("figure_path")
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def is_empty(self) -> bool:
|
|
118
|
+
return not (self.observations or self.charts or self.caveats)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _format_explore_section(ctx: "ExploreContext | None") -> str:
|
|
122
|
+
"""Render an :class:`ExploreContext` as a strongly-labelled, UNCONFIRMED
|
|
123
|
+
block for the diagnosis prompt. Returns ``""`` when there is nothing to add."""
|
|
124
|
+
if ctx is None or ctx.is_empty:
|
|
125
|
+
return ""
|
|
126
|
+
lines = [
|
|
127
|
+
"",
|
|
128
|
+
"EXPLORATORY MECHANISM NOTES (free-form EDA on a HELD-OUT explore split — "
|
|
129
|
+
"DESCRIPTIVE, UNCONFIRMED; use ONLY to decide WHICH hypotheses to propose, "
|
|
130
|
+
"NEVER as evidence; every claim must still be tested downstream):",
|
|
131
|
+
]
|
|
132
|
+
if ctx.observations:
|
|
133
|
+
lines.append(" observations:")
|
|
134
|
+
for obs in ctx.observations[:12]:
|
|
135
|
+
lines.append(f" - {obs}")
|
|
136
|
+
if ctx.charts:
|
|
137
|
+
lines.append(" charts (attached as images when rendered):")
|
|
138
|
+
for c in ctx.charts[:12]:
|
|
139
|
+
title = str(c.get("title") or c.get("name") or "chart")
|
|
140
|
+
desc = str(c.get("description") or c.get("kind") or "")
|
|
141
|
+
tag = "" if c.get("figure_path") else " [text-only, not rendered]"
|
|
142
|
+
lines.append(f" - [{title}]{tag} {desc}")
|
|
143
|
+
if ctx.caveats:
|
|
144
|
+
lines.append(" caveats (the explorer's own warnings):")
|
|
145
|
+
for cav in ctx.caveats[:8]:
|
|
146
|
+
lines.append(f" - {cav}")
|
|
147
|
+
return "\n".join(lines) + "\n"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _format_failure_modes_section(report: "Any | None") -> str:
|
|
151
|
+
"""Render a :class:`~evalrx.analysis.failure_modes.FailureModeReport`
|
|
152
|
+
as a labelled block for the diagnosis prompt. Descriptive only — clustering
|
|
153
|
+
groups FAIL cases by similarity, it does not itself confirm a mechanism.
|
|
154
|
+
Returns ``""`` when there is nothing to add (no report, or zero clusters)."""
|
|
155
|
+
context = getattr(report, "as_hypothesis_context", lambda: "")()
|
|
156
|
+
if not context:
|
|
157
|
+
return ""
|
|
158
|
+
return "\n" + context + "\n"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _extract_referenced(raw: str, ctx: "ExploreContext | None") -> list[str]:
|
|
162
|
+
"""Best-effort, DISPLAY-ONLY list of explore artifacts M3's output referenced.
|
|
163
|
+
|
|
164
|
+
Matches chart titles/names mentioned in the judge output as a whole phrase
|
|
165
|
+
(word boundaries, min length) to avoid crediting a chart just because a short
|
|
166
|
+
generic title like "rate"/"loss" happens to be a substring of unrelated prose.
|
|
167
|
+
Used only for provenance/dashboard display — no effect on hypothesis survival.
|
|
168
|
+
"""
|
|
169
|
+
if ctx is None or ctx.is_empty:
|
|
170
|
+
return []
|
|
171
|
+
import re
|
|
172
|
+
|
|
173
|
+
low = str(raw).lower()
|
|
174
|
+
referenced: list[str] = []
|
|
175
|
+
for c in ctx.charts:
|
|
176
|
+
for key in ("title", "name"):
|
|
177
|
+
label = str(c.get(key) or "").strip()
|
|
178
|
+
# Require a reasonably specific label matched on word boundaries.
|
|
179
|
+
if len(label) >= 6 and label not in referenced:
|
|
180
|
+
if re.search(rf"\b{re.escape(label.lower())}\b", low):
|
|
181
|
+
referenced.append(label)
|
|
182
|
+
break
|
|
183
|
+
return referenced
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@dataclass
|
|
187
|
+
class DiagnosisResult:
|
|
188
|
+
"""Output of :class:`DiagnosisAgent`.
|
|
189
|
+
|
|
190
|
+
Attributes:
|
|
191
|
+
model_name: ``repr()`` of the analysed model.
|
|
192
|
+
hypotheses: Proposed :class:`~evalrx.eval_agent.hypothesis.Hypothesis`
|
|
193
|
+
objects for M5.
|
|
194
|
+
findings_summary: The findings dict forwarded to the judge.
|
|
195
|
+
raw_judge_output: Verbatim LLM response (useful for debugging).
|
|
196
|
+
referenced_charts: Explore chart titles M3 cited (provenance only).
|
|
197
|
+
explore_context_used: Whether an :class:`ExploreContext` was supplied.
|
|
198
|
+
failure_modes_used: Whether a non-empty ``FailureModeReport`` was supplied.
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
model_name: str
|
|
202
|
+
hypotheses: list[Hypothesis] = field(default_factory=list)
|
|
203
|
+
findings_summary: dict[str, Any] = field(default_factory=dict)
|
|
204
|
+
raw_judge_output: str = ""
|
|
205
|
+
prompt: str = ""
|
|
206
|
+
referenced_charts: list[str] = field(default_factory=list)
|
|
207
|
+
explore_context_used: bool = False
|
|
208
|
+
failure_modes_used: bool = False
|
|
209
|
+
#: The adversarial critic's verbatim verdicts (``KEEP:``/``REJECT:`` +
|
|
210
|
+
#: ``REASON:`` lines) and its tally. The critic ANNOTATES hypotheses
|
|
211
|
+
#: (``metadata["critic"]``, ``metadata["critic_reason"]``) and orders the
|
|
212
|
+
#: kept ones first — it never removes a proposal: with M4 testing on the
|
|
213
|
+
#: held-out split, the data is the arbiter, and a critic that rejects every
|
|
214
|
+
#: claim used to end the run with "0 hypotheses" and no M4/M5/fix at all.
|
|
215
|
+
critic_raw_output: str = ""
|
|
216
|
+
critic_prompt: str = ""
|
|
217
|
+
n_critic_kept: int = 0
|
|
218
|
+
n_critic_rejected: int = 0
|
|
219
|
+
# M3 is two separate model calls: a proposer and an adversarial reviewer.
|
|
220
|
+
# The proposer's full list and the reviewer's per-hypothesis decisions are
|
|
221
|
+
# kept as well, so a rejected proposal stays distinguishable from a
|
|
222
|
+
# parser/model failure in the report and in Langfuse. ``review_prompt`` /
|
|
223
|
+
# ``review_raw`` mirror ``critic_prompt`` / ``critic_raw_output``;
|
|
224
|
+
# ``review_decisions`` (statement, decision, reason) is derived from the
|
|
225
|
+
# critic annotations — decision keep | reject | unparsed |
|
|
226
|
+
# review_unavailable — and the reviewer never removes a hypothesis.
|
|
227
|
+
proposed_hypotheses: list[Hypothesis] = field(default_factory=list)
|
|
228
|
+
review_prompt: str = ""
|
|
229
|
+
review_raw: str = ""
|
|
230
|
+
review_decisions: list[dict[str, Any]] = field(default_factory=list)
|
|
231
|
+
model_calls: list[dict[str, Any]] = field(default_factory=list)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
_HYPOTHESIS_SCHEMA: dict = {
|
|
235
|
+
"type": "array",
|
|
236
|
+
"items": {
|
|
237
|
+
"type": "object",
|
|
238
|
+
"required": ["hypothesis", "failure_mode"],
|
|
239
|
+
"properties": {
|
|
240
|
+
"hypothesis": {"type": "string", "minLength": 10},
|
|
241
|
+
"plain_statement": {"type": "string"},
|
|
242
|
+
"failure_mode": {"type": "string", "minLength": 2},
|
|
243
|
+
"expected_association": {"type": "string"},
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _parse_hypotheses_json(raw: str, model_name: str) -> list[Hypothesis] | None:
|
|
250
|
+
"""Try to parse *raw* as a JSON array of hypothesis objects.
|
|
251
|
+
|
|
252
|
+
Returns ``None`` when the response is not JSON or fails schema validation
|
|
253
|
+
so the caller can fall back to the text parser.
|
|
254
|
+
"""
|
|
255
|
+
text = raw.strip()
|
|
256
|
+
# Strip markdown code fences if present
|
|
257
|
+
if text.startswith("```"):
|
|
258
|
+
import re as _re
|
|
259
|
+
text = _re.sub(r"^```\w*\n?", "", text)
|
|
260
|
+
text = _re.sub(r"\n?```\s*$", "", text)
|
|
261
|
+
try:
|
|
262
|
+
data = json.loads(text)
|
|
263
|
+
except json.JSONDecodeError:
|
|
264
|
+
return None
|
|
265
|
+
|
|
266
|
+
errors = validate_json_shape(data, _HYPOTHESIS_SCHEMA)
|
|
267
|
+
if errors:
|
|
268
|
+
import logging as _logging
|
|
269
|
+
_logging.getLogger(__name__).debug(
|
|
270
|
+
"DiagnosisAgent JSON schema validation failed: %s", errors
|
|
271
|
+
)
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
return [
|
|
275
|
+
Hypothesis(
|
|
276
|
+
statement=item["hypothesis"],
|
|
277
|
+
target_model=model_name,
|
|
278
|
+
predicted_failure_mode=item["failure_mode"],
|
|
279
|
+
plain_statement=str(item.get("plain_statement") or item.get("plain_language") or item.get("plain") or ""),
|
|
280
|
+
test_design=str(item.get("test", "")),
|
|
281
|
+
expected_association=str(item.get("expected_association", "")),
|
|
282
|
+
)
|
|
283
|
+
for item in data
|
|
284
|
+
if item.get("hypothesis") and item.get("failure_mode")
|
|
285
|
+
]
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# A judge that answers in Markdown decorates the labels — ``**HYPOTHESIS:**``,
|
|
289
|
+
# ``**HYPOTHESIS**:``, ``- HYPOTHESIS:``, ``1. TEST:``, ``### FAILURE_MODE:`` —
|
|
290
|
+
# and the bare ``startswith("HYPOTHESIS:")`` check saw none of them: a live
|
|
291
|
+
# qwen3.5-2b/bbh_word_sorting run had three well-formed hypotheses in the
|
|
292
|
+
# response and M3 reported zero. Normalise the label, leave the text alone.
|
|
293
|
+
#
|
|
294
|
+
# The ordinal AFTER the label is the same bug and was missed the first time.
|
|
295
|
+
# ``HYPOTHESIS 1:`` is how a model numbers a list of three of them — the most
|
|
296
|
+
# natural formatting there is, and the one Opus used on a 560-case audio-visual
|
|
297
|
+
# run: three hypotheses, each with a test design naming a BH-surviving signal,
|
|
298
|
+
# and M3 again reported zero. Two and a half hours of GPU and a high-effort
|
|
299
|
+
# judge, discarded over a digit. Accept the ordinal in either position.
|
|
300
|
+
_LABEL_LINE = re.compile(
|
|
301
|
+
r"^\s*(?:(?:[-*•>]|#+|\d+[.)])\s*)*[*_`]*\s*"
|
|
302
|
+
r"(HYPOTHESIS|PLAIN_STATEMENT|PLAIN_LANGUAGE|FAILURE_MODE|TEST|EXPECTED_ASSOCIATION|KEEP|REJECT|REASON)"
|
|
303
|
+
r"(?:\s*[#(]?\d+[.)]?)?" # HYPOTHESIS 1: / TEST #2: / REASON (3):
|
|
304
|
+
r"\s*(?P<close>[*_`]*)\s*:\s*(?P<after>[*_`]*)\s*",
|
|
305
|
+
re.IGNORECASE,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
#: A leading emphasis/code marker, past any list bullet or heading. Its presence
|
|
310
|
+
#: is what makes a marker after the colon a CLOSER rather than content.
|
|
311
|
+
_OPENING_MARKER = re.compile(r"^\s*(?:(?:[-*•>]|#+|\d+[.)])\s*)*[*_`]")
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
#: A value that is ENTIRELY one code span or emphasis pair: `mode`, **mode**.
|
|
315
|
+
_WRAPPED_VALUE = re.compile(r"^([*_`]{1,2})(.+?)\1$")
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _unwrap_value(value: str) -> str:
|
|
319
|
+
"""Strip wrapping decoration from a value that is a bare identifier.
|
|
320
|
+
|
|
321
|
+
Applied to FAILURE_MODE and EXPECTED_ASSOCIATION only. Those are looked up
|
|
322
|
+
verbatim -- ``_FAILURE_MODE_TO_ANALYZERS["ignored_obs"]`` decides which
|
|
323
|
+
analyzers the next cycle re-probes with -- so a judge writing
|
|
324
|
+
``FAILURE_MODE: `ignored_obs` `` must not produce a key with a backtick in
|
|
325
|
+
it. Prose fields (HYPOTHESIS, TEST) keep their code spans: there the
|
|
326
|
+
backticks mark identifiers inside a sentence and removing them was its own
|
|
327
|
+
bug.
|
|
328
|
+
|
|
329
|
+
Only an ENTIRELY wrapped value is unwrapped, so ``some `sig` text`` is left
|
|
330
|
+
exactly as written.
|
|
331
|
+
"""
|
|
332
|
+
text = value.strip()
|
|
333
|
+
m = _WRAPPED_VALUE.match(text)
|
|
334
|
+
return m.group(2).strip() if m else text
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
#: ``predicted_failure_mode`` given to a hypothesis whose ``FAILURE_MODE:`` line the
|
|
338
|
+
#: judge omitted. Downstream stages treat the mode as a tag, so an explicit
|
|
339
|
+
#: placeholder keeps the claim (statement / test design) instead of dropping it.
|
|
340
|
+
MISSING_FAILURE_MODE = "unspecified"
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _normalise_label_line(line: str) -> str:
|
|
344
|
+
"""``**HYPOTHESIS:** foo`` / ``- FAILURE_MODE: bar`` → ``HYPOTHESIS: foo`` /
|
|
345
|
+
``FAILURE_MODE: bar``. Lines without a recognised label are returned
|
|
346
|
+
stripped and otherwise untouched (emphasis inside the statement stays)."""
|
|
347
|
+
line = line.strip()
|
|
348
|
+
m = _LABEL_LINE.match(line)
|
|
349
|
+
if not m:
|
|
350
|
+
return line
|
|
351
|
+
|
|
352
|
+
# Decoration is only decoration when something opened it. `**KEEP:** x` and
|
|
353
|
+
# `` `KEEP:` x `` wrap the LABEL, so the marker after the colon closes that
|
|
354
|
+
# wrapper and goes. `TEST: `modality_ablation.grounded_in_audio` ...` opens a
|
|
355
|
+
# code span belonging to the CONTENT, and eating it corrupts the identifier
|
|
356
|
+
# M4 routes on. The difference is whether the line opened with a marker at
|
|
357
|
+
# all -- so decide on that rather than on the marker's position.
|
|
358
|
+
opened = bool(_OPENING_MARKER.match(line))
|
|
359
|
+
rest = line[m.end():] if opened else (m.group("after") or "") + line[m.end():]
|
|
360
|
+
rest = rest.strip()
|
|
361
|
+
if opened:
|
|
362
|
+
rest = re.sub(r"[*_`]+$", "", rest).strip()
|
|
363
|
+
return f"{m.group(1).upper()}: {rest}"
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _parse_hypotheses(raw: str, model_name: str) -> list[Hypothesis]:
|
|
367
|
+
"""Parse hypothesis objects from LLM output.
|
|
368
|
+
|
|
369
|
+
Tries JSON structured format first (more reliable), then falls back to the
|
|
370
|
+
line-oriented ``HYPOTHESIS:`` / ``FAILURE_MODE:`` text format. This makes
|
|
371
|
+
the parser resilient to both output modes without requiring a hard migration.
|
|
372
|
+
Labels may carry Markdown decoration (see :func:`_normalise_label_line`).
|
|
373
|
+
"""
|
|
374
|
+
json_result = _parse_hypotheses_json(raw, model_name)
|
|
375
|
+
if json_result is not None:
|
|
376
|
+
return json_result
|
|
377
|
+
|
|
378
|
+
# Text-format fallback. A hypothesis is opened by ``HYPOTHESIS:`` and closed
|
|
379
|
+
# by ``FAILURE_MODE:`` — or, when the judge forgot that line, by the next
|
|
380
|
+
# ``HYPOTHESIS:`` / the end of the text. gemma-4-e2b / gsm8k (2026-08-27):
|
|
381
|
+
# the judge wrote three complete HYPOTHESIS / PLAIN_STATEMENT / TEST blocks
|
|
382
|
+
# without FAILURE_MODE and the old parser (append-on-FAILURE_MODE only)
|
|
383
|
+
# returned zero, so the loop stopped with "no_hypotheses" and the fix stage
|
|
384
|
+
# never ran. The mode is a tag; a missing tag must not erase the claim.
|
|
385
|
+
hypotheses: list[Hypothesis] = []
|
|
386
|
+
pending: Hypothesis | None = None
|
|
387
|
+
|
|
388
|
+
def _flush() -> None:
|
|
389
|
+
nonlocal pending
|
|
390
|
+
if pending is not None and pending.statement:
|
|
391
|
+
if not pending.predicted_failure_mode:
|
|
392
|
+
pending.predicted_failure_mode = MISSING_FAILURE_MODE
|
|
393
|
+
hypotheses.append(pending)
|
|
394
|
+
pending = None
|
|
395
|
+
|
|
396
|
+
for line in raw.splitlines():
|
|
397
|
+
line = _normalise_label_line(line)
|
|
398
|
+
upper = line.upper()
|
|
399
|
+
if upper.startswith("HYPOTHESIS:"):
|
|
400
|
+
_flush()
|
|
401
|
+
pending = Hypothesis(
|
|
402
|
+
statement=line[len("HYPOTHESIS:"):].strip(),
|
|
403
|
+
target_model=model_name,
|
|
404
|
+
predicted_failure_mode="",
|
|
405
|
+
)
|
|
406
|
+
continue
|
|
407
|
+
target = pending if pending is not None else (hypotheses[-1] if hypotheses else None)
|
|
408
|
+
if upper.startswith("PLAIN_STATEMENT:") or upper.startswith("PLAIN_LANGUAGE:"):
|
|
409
|
+
tag = "PLAIN_STATEMENT:" if upper.startswith("PLAIN_STATEMENT:") else "PLAIN_LANGUAGE:"
|
|
410
|
+
if target is not None and not target.plain_statement:
|
|
411
|
+
target.plain_statement = line[len(tag):].strip()
|
|
412
|
+
elif upper.startswith("FAILURE_MODE:") and pending is not None:
|
|
413
|
+
pending.predicted_failure_mode = _unwrap_value(line[len("FAILURE_MODE:"):])
|
|
414
|
+
_flush()
|
|
415
|
+
elif upper.startswith("TEST:") and target is not None:
|
|
416
|
+
# Attach the test design to the hypothesis being read (or the last one).
|
|
417
|
+
target.test_design = line[len("TEST:"):].strip()
|
|
418
|
+
elif upper.startswith("EXPECTED_ASSOCIATION:") and target is not None:
|
|
419
|
+
target.expected_association = _unwrap_value(
|
|
420
|
+
line[len("EXPECTED_ASSOCIATION:"):]
|
|
421
|
+
).lower()
|
|
422
|
+
_flush()
|
|
423
|
+
return hypotheses
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _format_label_context(cases: "Any | None") -> str:
|
|
427
|
+
"""Descriptive label summary for the M3 critic (and nothing else).
|
|
428
|
+
|
|
429
|
+
The critic used to see only the findings JSON — per-case rows with
|
|
430
|
+
``labelled_fail`` and ``extracted_answer`` but no gold — and rejected a
|
|
431
|
+
correct "answers Yes regardless of the audio" hypothesis for "no
|
|
432
|
+
ground-truth present/absent field in the evidence" (audiocaps 2026-08-20)
|
|
433
|
+
while the proposer had read exactly that breakdown in the explore notes.
|
|
434
|
+
Counts only; on a yes/no (true/false) batch a gold x answer table whose
|
|
435
|
+
cells carry the FAIL count. Empty string when *cases* is ``None`` or
|
|
436
|
+
carries no labels, so callers can concatenate it unconditionally.
|
|
437
|
+
"""
|
|
438
|
+
if cases is None:
|
|
439
|
+
return ""
|
|
440
|
+
try:
|
|
441
|
+
from evalrx.analyzers.reasoning._text import (
|
|
442
|
+
binary_direction,
|
|
443
|
+
binary_gold,
|
|
444
|
+
extract_answer,
|
|
445
|
+
)
|
|
446
|
+
from evalrx.core.case import Label
|
|
447
|
+
except Exception: # pragma: no cover - defensive import guard
|
|
448
|
+
return ""
|
|
449
|
+
rows = [c for c in cases if getattr(c, "label", None) in (Label.PASS, Label.FAIL)]
|
|
450
|
+
if not rows:
|
|
451
|
+
return ""
|
|
452
|
+
n_fail = sum(1 for c in rows if c.label == Label.FAIL)
|
|
453
|
+
lines = [f" labelled cases: {len(rows)} ({n_fail} FAIL / {len(rows) - n_fail} PASS)"]
|
|
454
|
+
golds = [(c, binary_gold(getattr(c, "expected", None))) for c in rows]
|
|
455
|
+
binary = [(c, g) for c, g in golds if g is not None]
|
|
456
|
+
if len(binary) >= max(4, int(0.8 * len(rows))):
|
|
457
|
+
table: dict[tuple[str, str], list[int]] = {}
|
|
458
|
+
for c, g in binary:
|
|
459
|
+
text = str(getattr(c, "observed", "") or "")
|
|
460
|
+
a = binary_direction(extract_answer(text)) or binary_direction(text[-200:], last=True)
|
|
461
|
+
key = (g, a or "unparsed")
|
|
462
|
+
cell = table.setdefault(key, [0, 0])
|
|
463
|
+
cell[0] += 1
|
|
464
|
+
cell[1] += int(c.label == Label.FAIL)
|
|
465
|
+
lines.append(" yes/no task -- gold x answer (n, of which FAIL):")
|
|
466
|
+
for g in ("yes", "no"):
|
|
467
|
+
for a in ("yes", "no", "unparsed"):
|
|
468
|
+
n, f = table.get((g, a), [0, 0])
|
|
469
|
+
if n:
|
|
470
|
+
lines.append(f" gold={g:<3} answered={a:<8} n={n:<4} FAIL={f}")
|
|
471
|
+
n_yes = sum(n for (g, a), (n, f) in table.items() if a == "yes")
|
|
472
|
+
lines.append(f" answered yes on {n_yes}/{len(binary)} binary cases")
|
|
473
|
+
return "LABEL SUMMARY (descriptive counts from the case batch, not a test result):\n" + "\n".join(lines) + "\n"
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _validate_hypotheses(
|
|
477
|
+
hypotheses: list[Hypothesis],
|
|
478
|
+
findings_json: str,
|
|
479
|
+
judge: "Model",
|
|
480
|
+
*,
|
|
481
|
+
capture: "dict[str, Any] | None" = None,
|
|
482
|
+
context: str = "",
|
|
483
|
+
) -> list[Hypothesis]:
|
|
484
|
+
"""Adversarial review of *hypotheses*: a critic call at temperature=0.
|
|
485
|
+
|
|
486
|
+
The same judge is called a second time with a prompt designed to find
|
|
487
|
+
reasons to *reject* each hypothesis, breaking the confirmation-bias loop
|
|
488
|
+
where the model that generated a claim then waves it through.
|
|
489
|
+
|
|
490
|
+
The critic ANNOTATES, it does not filter: every hypothesis comes back,
|
|
491
|
+
``metadata["critic"]`` set to ``"keep"`` / ``"reject"`` (``"unparsed"``
|
|
492
|
+
when the critic's answer named neither), ``metadata["critic_reason"]``
|
|
493
|
+
carrying its ``REASON:`` line, kept hypotheses ordered first. Dropping
|
|
494
|
+
the rejected ones used to end a run at "0 hypotheses" whenever a strict
|
|
495
|
+
critic (opus at high effort, n=32 evidence) rejected all of them —
|
|
496
|
+
three llm_benchmark runs in a row on 2026-08-20 — with no M4, M5 or fix
|
|
497
|
+
afterwards. M4 now tests on the held-out split, so the data decides; the
|
|
498
|
+
critic's verdict travels with the hypothesis as provenance and as the
|
|
499
|
+
tie-breaker for M5's best-lead ordering.
|
|
500
|
+
|
|
501
|
+
*capture*, when given, receives ``raw`` (the critic's verbatim answer),
|
|
502
|
+
``prompt``, ``n_kept`` and ``n_rejected``. Falls back to the unannotated
|
|
503
|
+
list if the critic call fails so the loop is never blocked by a transient
|
|
504
|
+
error.
|
|
505
|
+
|
|
506
|
+
*context* is the evidence the PROPOSER worked from beyond the findings
|
|
507
|
+
JSON -- M2's conclusion and evidence chain, the statistical verdicts, the
|
|
508
|
+
explore notes and the label summary. Without it the critic judged the
|
|
509
|
+
hypotheses against less than the proposer had seen and rejected them for
|
|
510
|
+
missing evidence that was on the table (audiocaps 2026-08-20).
|
|
511
|
+
"""
|
|
512
|
+
if not hypotheses:
|
|
513
|
+
return hypotheses
|
|
514
|
+
|
|
515
|
+
hyp_lines = "\n".join(
|
|
516
|
+
f"- HYPOTHESIS: {h.statement} (failure_mode: {h.predicted_failure_mode})"
|
|
517
|
+
for h in hypotheses
|
|
518
|
+
)
|
|
519
|
+
context_section = (
|
|
520
|
+
"\nContext the proposer worked from (analyst conclusion, evidence chain, "
|
|
521
|
+
"statistical verdicts, exploratory notes, label summary):\n"
|
|
522
|
+
+ context.strip() + "\n"
|
|
523
|
+
) if context and context.strip() else ""
|
|
524
|
+
prompt = _VALIDATE_PROMPT.format(
|
|
525
|
+
context_section=context_section,
|
|
526
|
+
findings_json=findings_json,
|
|
527
|
+
hypotheses_text=hyp_lines,
|
|
528
|
+
)
|
|
529
|
+
if capture is not None:
|
|
530
|
+
capture["prompt"] = prompt
|
|
531
|
+
|
|
532
|
+
started = time.perf_counter()
|
|
533
|
+
try:
|
|
534
|
+
# Use temperature=0 so the critic is deterministic and strict.
|
|
535
|
+
# Models that don't accept a temperature kwarg are called without it.
|
|
536
|
+
import inspect
|
|
537
|
+
sig = inspect.signature(judge.generate)
|
|
538
|
+
if "temperature" in sig.parameters:
|
|
539
|
+
raw = judge.generate(prompt, temperature=0)
|
|
540
|
+
else:
|
|
541
|
+
raw = judge.generate(prompt)
|
|
542
|
+
except Exception as exc:
|
|
543
|
+
if capture is not None:
|
|
544
|
+
# A transport failure must not silently delete ideas — and the
|
|
545
|
+
# record must distinguish it from an evidence-based rejection.
|
|
546
|
+
capture["decisions"] = [{
|
|
547
|
+
"statement": h.statement, "decision": "review_unavailable",
|
|
548
|
+
"reason": "The adversarial review call failed; proposal retained.",
|
|
549
|
+
} for h in hypotheses]
|
|
550
|
+
capture["error"] = str(exc)
|
|
551
|
+
capture["duration_sec"] = time.perf_counter() - started
|
|
552
|
+
return hypotheses # validation failed — keep originals, unannotated
|
|
553
|
+
raw = str(raw)
|
|
554
|
+
if capture is not None:
|
|
555
|
+
capture["raw"] = raw
|
|
556
|
+
capture["error"] = None
|
|
557
|
+
capture["duration_sec"] = time.perf_counter() - started
|
|
558
|
+
|
|
559
|
+
def _match(stmt: str) -> "Hypothesis | None":
|
|
560
|
+
stmt = stmt.strip().lower()
|
|
561
|
+
for h in hypotheses:
|
|
562
|
+
hs = h.statement.lower()
|
|
563
|
+
if hs[:60] in stmt or stmt[:60] in hs:
|
|
564
|
+
return h
|
|
565
|
+
return None
|
|
566
|
+
|
|
567
|
+
verdicts: dict[int, str] = {}
|
|
568
|
+
reasons: dict[int, str] = {}
|
|
569
|
+
last: "Hypothesis | None" = None
|
|
570
|
+
saw_decision = False
|
|
571
|
+
for line in raw.splitlines():
|
|
572
|
+
line = _normalise_label_line(line)
|
|
573
|
+
upper = line.upper()
|
|
574
|
+
if upper.startswith("KEEP:") or upper.startswith("REJECT:"):
|
|
575
|
+
saw_decision = True
|
|
576
|
+
verdict = "keep" if upper.startswith("KEEP:") else "reject"
|
|
577
|
+
h = _match(line.split(":", 1)[1])
|
|
578
|
+
if h is not None:
|
|
579
|
+
verdicts[id(h)] = verdict
|
|
580
|
+
last = h
|
|
581
|
+
else:
|
|
582
|
+
last = None
|
|
583
|
+
elif upper.startswith("REASON:") and last is not None:
|
|
584
|
+
reasons[id(last)] = line[len("REASON:"):].strip()
|
|
585
|
+
|
|
586
|
+
if not saw_decision:
|
|
587
|
+
# Malformed/empty critic output is an infrastructure failure: keep the
|
|
588
|
+
# proposals, say so, and mark them so the record shows no review ran.
|
|
589
|
+
import logging as _logging
|
|
590
|
+
_logging.getLogger(__name__).warning(
|
|
591
|
+
"DiagnosisAgent validation: critic response could not be parsed for "
|
|
592
|
+
"%d hypothesis(es) — keeping them unreviewed",
|
|
593
|
+
len(hypotheses),
|
|
594
|
+
)
|
|
595
|
+
for h in hypotheses:
|
|
596
|
+
h.metadata["critic"] = "unparsed"
|
|
597
|
+
if capture is not None:
|
|
598
|
+
capture.update(n_kept=0, n_rejected=0, decisions=_critic_decisions(hypotheses))
|
|
599
|
+
return hypotheses
|
|
600
|
+
|
|
601
|
+
for h in hypotheses:
|
|
602
|
+
h.metadata["critic"] = verdicts.get(id(h), "unparsed")
|
|
603
|
+
if id(h) in reasons:
|
|
604
|
+
h.metadata["critic_reason"] = reasons[id(h)]
|
|
605
|
+
n_kept = sum(1 for h in hypotheses if h.metadata.get("critic") == "keep")
|
|
606
|
+
n_rejected = sum(1 for h in hypotheses if h.metadata.get("critic") == "reject")
|
|
607
|
+
if capture is not None:
|
|
608
|
+
capture.update(n_kept=n_kept, n_rejected=n_rejected,
|
|
609
|
+
decisions=_critic_decisions(hypotheses))
|
|
610
|
+
if n_rejected and not n_kept:
|
|
611
|
+
import logging as _logging
|
|
612
|
+
_logging.getLogger(__name__).info(
|
|
613
|
+
"DiagnosisAgent validation: critic rejected all %d hypothesis(es) — "
|
|
614
|
+
"kept as flagged leads; M4 testing decides",
|
|
615
|
+
len(hypotheses),
|
|
616
|
+
)
|
|
617
|
+
order = {"keep": 0, "unparsed": 1, "reject": 2}
|
|
618
|
+
return sorted(hypotheses, key=lambda h: order.get(h.metadata.get("critic"), 1))
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _critic_decisions(hypotheses: list[Hypothesis]) -> list[dict[str, Any]]:
|
|
622
|
+
"""Per-hypothesis review record derived from the critic's annotations: the
|
|
623
|
+
``{statement, decision, reason}`` shape the run log, report and Langfuse
|
|
624
|
+
consume. ``decision`` is ``keep`` / ``reject`` / ``unparsed`` (or
|
|
625
|
+
``review_unavailable`` when the critic call itself failed). The critic
|
|
626
|
+
never removes a hypothesis, so this is a record, not a filter."""
|
|
627
|
+
return [{
|
|
628
|
+
"statement": h.statement,
|
|
629
|
+
"decision": str(h.metadata.get("critic", "unparsed")),
|
|
630
|
+
"reason": str(h.metadata.get("critic_reason", "")),
|
|
631
|
+
} for h in hypotheses]
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _default_judge() -> "Model":
|
|
635
|
+
"""Return a judge model without requiring an explicit API key.
|
|
636
|
+
|
|
637
|
+
Resolution order:
|
|
638
|
+
1. ``agy`` CLI (antigravity) — if the binary is on PATH, no key needed.
|
|
639
|
+
2. Gemini — if ``GEMINI_API_KEY`` is set.
|
|
640
|
+
3. Raise with instructions.
|
|
641
|
+
"""
|
|
642
|
+
import os
|
|
643
|
+
import shutil
|
|
644
|
+
|
|
645
|
+
if shutil.which("agy"):
|
|
646
|
+
from evalrx.agent_runtime.judges import AgyModel
|
|
647
|
+
return AgyModel()
|
|
648
|
+
|
|
649
|
+
if os.getenv("GEMINI_API_KEY"):
|
|
650
|
+
from evalrx.models.blackbox.gemini import GeminiModel
|
|
651
|
+
return GeminiModel()
|
|
652
|
+
|
|
653
|
+
raise ValueError(
|
|
654
|
+
"DiagnosisAgent requires a judge model. "
|
|
655
|
+
"Options: install antigravity (agy) — no API key needed, "
|
|
656
|
+
"or set GEMINI_API_KEY to use Gemini "
|
|
657
|
+
"(install with: pip install 'evalrx[gemini]'), "
|
|
658
|
+
"or pass judge= explicitly."
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
class DiagnosisAgent:
|
|
663
|
+
"""Proposes hypotheses from an :class:`~evalrx.eval_agent.analysis.AnalysisReport`.
|
|
664
|
+
|
|
665
|
+
Args:
|
|
666
|
+
judge: Any :class:`~evalrx.core.model.Model` with
|
|
667
|
+
``Capability.GENERATE``. Defaults to a ``GeminiModel``
|
|
668
|
+
when ``GEMINI_API_KEY`` is in the environment.
|
|
669
|
+
api_key: Gemini API key — used only when *judge* is ``None`` and no
|
|
670
|
+
``GEMINI_API_KEY`` env var is set.
|
|
671
|
+
model_id: Gemini model identifier (default: ``"gemini-2.0-flash"``).
|
|
672
|
+
"""
|
|
673
|
+
|
|
674
|
+
def __init__(
|
|
675
|
+
self,
|
|
676
|
+
judge: "Model | None" = None,
|
|
677
|
+
api_key: str | None = None,
|
|
678
|
+
model_id: str = "gemini-2.0-flash",
|
|
679
|
+
) -> None:
|
|
680
|
+
if judge is None and api_key is not None:
|
|
681
|
+
from evalrx.models.blackbox.gemini import GeminiModel
|
|
682
|
+
|
|
683
|
+
judge = GeminiModel(model_id=model_id, api_key=api_key)
|
|
684
|
+
self._judge = judge # None → resolved lazily on first call
|
|
685
|
+
|
|
686
|
+
@property
|
|
687
|
+
def judge(self) -> "Model":
|
|
688
|
+
if self._judge is None:
|
|
689
|
+
self._judge = _default_judge()
|
|
690
|
+
return self._judge
|
|
691
|
+
|
|
692
|
+
@judge.setter
|
|
693
|
+
def judge(self, value: "Model") -> None:
|
|
694
|
+
self._judge = value
|
|
695
|
+
|
|
696
|
+
def _reask_for_format(self, raw: str, model_name: str) -> tuple[str, list[Hypothesis]]:
|
|
697
|
+
"""One re-ask when a non-empty, non-NO_ISSUE answer parsed to zero hypotheses.
|
|
698
|
+
|
|
699
|
+
gemma-4-e2b / gsm8k (2026-08-27): the judge wrote three hypotheses in prose
|
|
700
|
+
blocks the label parser could not read and the run ended with
|
|
701
|
+
``no_hypotheses`` — no warning, no second chance, no fix stage. The
|
|
702
|
+
label format is the contract, but a single formatting miss should cost
|
|
703
|
+
one more judge call, not the diagnosis. Returns the answer that was
|
|
704
|
+
finally parsed (so ``raw_judge_output`` records what the hypotheses came
|
|
705
|
+
from) and the hypotheses; on any failure the original answer and the
|
|
706
|
+
empty list come back and the existing fallbacks apply.
|
|
707
|
+
"""
|
|
708
|
+
text = raw.strip()
|
|
709
|
+
if not text or "NO_ISSUE" in text.upper():
|
|
710
|
+
return raw, []
|
|
711
|
+
import logging
|
|
712
|
+
|
|
713
|
+
log = logging.getLogger(__name__)
|
|
714
|
+
log.warning(
|
|
715
|
+
"DiagnosisAgent: the judge wrote %d chars that parsed to zero hypotheses; "
|
|
716
|
+
"re-asking once for the labelled format: %r", len(text), text[:160],
|
|
717
|
+
)
|
|
718
|
+
repair_prompt = _FORMAT_REPAIR_PROMPT.format(raw=raw)
|
|
719
|
+
started = time.perf_counter()
|
|
720
|
+
try:
|
|
721
|
+
reasked = str(self.judge.generate(repair_prompt))
|
|
722
|
+
except Exception as exc: # noqa: BLE001
|
|
723
|
+
self._model_calls.append({
|
|
724
|
+
"role": "diagnosis_format_repair", "operation": "generate",
|
|
725
|
+
"inputs": repair_prompt, "output": None, "error": str(exc),
|
|
726
|
+
"duration_sec": time.perf_counter() - started,
|
|
727
|
+
})
|
|
728
|
+
log.warning("DiagnosisAgent: format re-ask failed: %s", exc)
|
|
729
|
+
return raw, []
|
|
730
|
+
self._model_calls.append({
|
|
731
|
+
"role": "diagnosis_format_repair", "operation": "generate",
|
|
732
|
+
"inputs": repair_prompt, "output": reasked, "error": None,
|
|
733
|
+
"duration_sec": time.perf_counter() - started,
|
|
734
|
+
})
|
|
735
|
+
hypotheses = _parse_hypotheses(reasked, model_name)
|
|
736
|
+
if hypotheses:
|
|
737
|
+
return reasked, hypotheses
|
|
738
|
+
if "NO_ISSUE" in reasked.upper():
|
|
739
|
+
return reasked, []
|
|
740
|
+
log.warning("DiagnosisAgent: the re-asked answer also parsed to zero hypotheses")
|
|
741
|
+
return raw, []
|
|
742
|
+
|
|
743
|
+
def _repair_plain_language(
|
|
744
|
+
self, raw: str, hypotheses: list[Hypothesis], model_name: str,
|
|
745
|
+
) -> list[Hypothesis]:
|
|
746
|
+
"""One bounded retry when a PLAIN_STATEMENT line is missing or jargon-y.
|
|
747
|
+
|
|
748
|
+
The prompt asking for plain language is not enough on its own — a judge
|
|
749
|
+
will happily reuse the technical line — so the same host-side check the
|
|
750
|
+
explorer and the analysis-side hypothesis agent already run
|
|
751
|
+
(:func:`~evalrx.analysis.plain_language.jargon_violation`) also
|
|
752
|
+
guards this path, which is the one the benchmark runner takes. Never
|
|
753
|
+
raises: a failed repair keeps the original hypotheses, since a jargon-y
|
|
754
|
+
headline is worse than no diagnosis only for the reader, not for M4.
|
|
755
|
+
"""
|
|
756
|
+
if not hypotheses:
|
|
757
|
+
return hypotheses
|
|
758
|
+
violations = [
|
|
759
|
+
f"hypothesis {i}'s PLAIN_STATEMENT {reason}: {h.plain_statement!r}"
|
|
760
|
+
for i, h in enumerate(hypotheses, start=1)
|
|
761
|
+
for reason in [jargon_violation(h.plain_statement, h.statement)]
|
|
762
|
+
if reason
|
|
763
|
+
]
|
|
764
|
+
if not violations:
|
|
765
|
+
return hypotheses
|
|
766
|
+
repair_prompt = _PLAIN_REPAIR_PROMPT.format(
|
|
767
|
+
raw=raw, violations="\n".join(f"- {v}" for v in violations),
|
|
768
|
+
)
|
|
769
|
+
started = time.perf_counter()
|
|
770
|
+
try:
|
|
771
|
+
repaired_raw = self.judge.generate(repair_prompt)
|
|
772
|
+
except Exception as exc: # noqa: BLE001
|
|
773
|
+
self._model_calls.append({
|
|
774
|
+
"role": "diagnosis_plain_language_repair", "operation": "generate",
|
|
775
|
+
"inputs": repair_prompt, "output": None, "error": str(exc),
|
|
776
|
+
"duration_sec": time.perf_counter() - started,
|
|
777
|
+
})
|
|
778
|
+
import logging
|
|
779
|
+
|
|
780
|
+
logging.getLogger(__name__).warning(
|
|
781
|
+
"DiagnosisAgent: plain-language repair failed: %s", exc)
|
|
782
|
+
return hypotheses
|
|
783
|
+
self._model_calls.append({
|
|
784
|
+
"role": "diagnosis_plain_language_repair", "operation": "generate",
|
|
785
|
+
"inputs": repair_prompt, "output": str(repaired_raw), "error": None,
|
|
786
|
+
"duration_sec": time.perf_counter() - started,
|
|
787
|
+
})
|
|
788
|
+
repaired = _parse_hypotheses(str(repaired_raw), model_name)
|
|
789
|
+
return repaired or hypotheses
|
|
790
|
+
|
|
791
|
+
def diagnose(
|
|
792
|
+
self,
|
|
793
|
+
analysis: "AnalysisReport | dict[str, Result]",
|
|
794
|
+
model_name: str = "",
|
|
795
|
+
prior_cycles: list[dict] | None = None,
|
|
796
|
+
explore_context: "ExploreContext | None" = None,
|
|
797
|
+
failure_modes: "Any | None" = None,
|
|
798
|
+
cases: "Any | None" = None,
|
|
799
|
+
) -> DiagnosisResult:
|
|
800
|
+
"""Synthesize *analysis* into a set of falsifiable hypotheses.
|
|
801
|
+
|
|
802
|
+
Args:
|
|
803
|
+
analysis: An :class:`~evalrx.eval_agent.analysis.AnalysisReport`
|
|
804
|
+
from M2, or (for backward compatibility) a plain
|
|
805
|
+
``{analyzer_name: Result}`` dict.
|
|
806
|
+
model_name: Ignored when *analysis* is an ``AnalysisReport``
|
|
807
|
+
(the name is taken from the report).
|
|
808
|
+
prior_cycles: Summary of previous M1→M5 cycles produced by
|
|
809
|
+
:class:`~evalrx.eval_agent.legacy.AutoDiagnoseLoop`.
|
|
810
|
+
Each entry is ``{"cycle": int, "severity": str,
|
|
811
|
+
"hypotheses": [{"statement", "failure_mode", "status"}]}``.
|
|
812
|
+
Injected into the prompt so the judge avoids re-proposing
|
|
813
|
+
already-tested hypotheses.
|
|
814
|
+
failure_modes: Optional :class:`~evalrx.analysis.failure_modes.FailureModeReport`
|
|
815
|
+
(clustered FAIL cases). Descriptive only, like
|
|
816
|
+
*explore_context* — informs which hypotheses M3
|
|
817
|
+
proposes, never a claim itself. ``None`` (default)
|
|
818
|
+
adds nothing to the prompt and costs no extra call.
|
|
819
|
+
cases: The labelled case batch M1/M2 ran on. Used ONLY to
|
|
820
|
+
give the critic a label summary (PASS/FAIL counts;
|
|
821
|
+
gold x answer table on a yes/no batch) — see
|
|
822
|
+
:func:`_format_label_context`. ``None`` adds nothing.
|
|
823
|
+
|
|
824
|
+
Returns:
|
|
825
|
+
:class:`DiagnosisResult` with zero or more hypotheses.
|
|
826
|
+
"""
|
|
827
|
+
from evalrx.analysis.analysis_module import AnalysisModule, AnalysisReport
|
|
828
|
+
|
|
829
|
+
self._model_calls: list[dict[str, Any]] = []
|
|
830
|
+
|
|
831
|
+
if not isinstance(analysis, AnalysisReport):
|
|
832
|
+
# Backward compat: wrap raw results in a minimal AnalysisReport.
|
|
833
|
+
analysis = AnalysisModule().analyze(analysis, model_name)
|
|
834
|
+
|
|
835
|
+
summary = {name: r.findings for name, r in analysis.raw_results.items()}
|
|
836
|
+
|
|
837
|
+
# Prefer M2's LLM conclusion + evidence chain (StatsAnalysisReport) over
|
|
838
|
+
# the bare threshold narrative, so a real failure mode surfaced by the
|
|
839
|
+
# analyst is not dropped just because no numeric threshold fired.
|
|
840
|
+
conclusion = getattr(analysis, "conclusion", "") or analysis.narrative or "(no conclusion)"
|
|
841
|
+
evidence_chain = getattr(analysis, "evidence_chain", None) or []
|
|
842
|
+
evidence_section = ""
|
|
843
|
+
if evidence_chain:
|
|
844
|
+
evidence_section = (
|
|
845
|
+
"\nEvidence chain:\n"
|
|
846
|
+
+ "\n".join(f" - {step}" for step in evidence_chain)
|
|
847
|
+
+ "\n"
|
|
848
|
+
)
|
|
849
|
+
stats_results = getattr(analysis, "stats_results", None) or []
|
|
850
|
+
stats_section = ""
|
|
851
|
+
stats_lines = [
|
|
852
|
+
f" - {r.summary}"
|
|
853
|
+
for r in stats_results
|
|
854
|
+
if getattr(r, "ok", False) and getattr(r, "summary", "")
|
|
855
|
+
]
|
|
856
|
+
if stats_lines:
|
|
857
|
+
stats_section = "\nStatistical test results:\n" + "\n".join(stats_lines) + "\n"
|
|
858
|
+
|
|
859
|
+
# Evidence sources M3 can reference in TEST designs: per-case signal
|
|
860
|
+
# keys and strategy contrasts that already exist in this cycle.
|
|
861
|
+
signals: list[str] = []
|
|
862
|
+
for r in stats_results:
|
|
863
|
+
cfg = getattr(r, "config", None) or {}
|
|
864
|
+
sig = cfg.get("signal")
|
|
865
|
+
if sig and sig not in signals:
|
|
866
|
+
signals.append(sig)
|
|
867
|
+
for s in cfg.get("strategies", []) or []:
|
|
868
|
+
tag = f"strategy contrast vs '{s}'"
|
|
869
|
+
if s != "baseline" and tag not in signals:
|
|
870
|
+
signals.append(tag)
|
|
871
|
+
available_signals_section = ""
|
|
872
|
+
if signals:
|
|
873
|
+
available_signals_section = (
|
|
874
|
+
"AVAILABLE EVIDENCE (per-case signals / contrasts already measured):\n"
|
|
875
|
+
+ "\n".join(f" - {s}" for s in signals)
|
|
876
|
+
+ "\n\n"
|
|
877
|
+
)
|
|
878
|
+
|
|
879
|
+
protocol = getattr(analysis, "protocol", None)
|
|
880
|
+
protocol_section = (
|
|
881
|
+
protocol.prompt_text() if protocol is not None
|
|
882
|
+
else "(not supplied; infer cautiously from the stored findings)"
|
|
883
|
+
)
|
|
884
|
+
|
|
885
|
+
prompt = _DIAGNOSE_PROMPT.format(
|
|
886
|
+
prior_section=_format_prior_section(prior_cycles or []),
|
|
887
|
+
model_name=analysis.model_name or model_name,
|
|
888
|
+
severity=analysis.severity,
|
|
889
|
+
protocol_section=protocol_section,
|
|
890
|
+
conclusion=conclusion,
|
|
891
|
+
evidence_section=evidence_section,
|
|
892
|
+
stats_section=stats_section,
|
|
893
|
+
explore_section=_format_explore_section(explore_context),
|
|
894
|
+
failure_modes_section=_format_failure_modes_section(failure_modes),
|
|
895
|
+
available_signals_section=available_signals_section,
|
|
896
|
+
findings_json=json.dumps(summary, indent=2, default=str),
|
|
897
|
+
)
|
|
898
|
+
import inspect as _inspect
|
|
899
|
+
from pathlib import Path as _Path
|
|
900
|
+
# M2's confirmatory figures, then the explorer's (UNCONFIRMED) charts.
|
|
901
|
+
# The explore charts are attached to the M3 prompt ONLY — they never reach
|
|
902
|
+
# M2's confirmatory family, M4, or the fix gate.
|
|
903
|
+
_figs = [_Path(f) for f in getattr(analysis, "figures", []) if _Path(f).exists()]
|
|
904
|
+
if explore_context is not None:
|
|
905
|
+
_figs += [_Path(f) for f in explore_context.figure_paths if _Path(f).exists()]
|
|
906
|
+
_sig = _inspect.signature(self.judge.generate)
|
|
907
|
+
started = time.perf_counter()
|
|
908
|
+
try:
|
|
909
|
+
if "images" in _sig.parameters and _figs:
|
|
910
|
+
raw = self.judge.generate(prompt, images=_figs)
|
|
911
|
+
else:
|
|
912
|
+
raw = self.judge.generate(prompt)
|
|
913
|
+
except Exception as exc:
|
|
914
|
+
self._model_calls.append({
|
|
915
|
+
"role": "diagnosis_judge", "operation": "generate",
|
|
916
|
+
"inputs": {"prompt": prompt, "images": [str(f) for f in _figs]},
|
|
917
|
+
"output": None, "error": str(exc),
|
|
918
|
+
"duration_sec": time.perf_counter() - started,
|
|
919
|
+
})
|
|
920
|
+
raise
|
|
921
|
+
self._model_calls.append({
|
|
922
|
+
"role": "diagnosis_judge", "operation": "generate",
|
|
923
|
+
"inputs": {"prompt": prompt, "images": [str(f) for f in _figs]},
|
|
924
|
+
"output": str(raw), "error": None,
|
|
925
|
+
"duration_sec": time.perf_counter() - started,
|
|
926
|
+
})
|
|
927
|
+
proposed_hypotheses = _parse_hypotheses(str(raw), analysis.model_name or model_name)
|
|
928
|
+
if not proposed_hypotheses:
|
|
929
|
+
raw, proposed_hypotheses = self._reask_for_format(
|
|
930
|
+
str(raw), analysis.model_name or model_name,
|
|
931
|
+
)
|
|
932
|
+
proposed_hypotheses = self._repair_plain_language(
|
|
933
|
+
str(raw), proposed_hypotheses, analysis.model_name or model_name,
|
|
934
|
+
)
|
|
935
|
+
hypotheses = list(proposed_hypotheses)
|
|
936
|
+
|
|
937
|
+
# Adversarial validation: run a second critic call at temperature=0 to
|
|
938
|
+
# prune hypotheses the generator produced without sufficient evidence.
|
|
939
|
+
# This prevents the self-evaluation loop where the same model that
|
|
940
|
+
# proposed a hypothesis then approves it uncritically.
|
|
941
|
+
critic: dict[str, Any] = {}
|
|
942
|
+
if hypotheses:
|
|
943
|
+
findings_json_str = json.dumps(summary, indent=2, default=str)
|
|
944
|
+
# The critic reviews against what the proposer saw, not less:
|
|
945
|
+
# conclusion + evidence chain + stats verdicts + explore notes,
|
|
946
|
+
# plus a label summary the proposer never had either.
|
|
947
|
+
critic_context = "\n".join(part.strip("\n") for part in (
|
|
948
|
+
f"Analysis conclusion: {conclusion}",
|
|
949
|
+
f"Experiment protocol:\n{protocol_section}",
|
|
950
|
+
evidence_section,
|
|
951
|
+
stats_section,
|
|
952
|
+
_format_explore_section(explore_context),
|
|
953
|
+
_format_label_context(cases),
|
|
954
|
+
) if part and part.strip())
|
|
955
|
+
hypotheses = _validate_hypotheses(
|
|
956
|
+
hypotheses, findings_json_str, self.judge, capture=critic,
|
|
957
|
+
context=critic_context,
|
|
958
|
+
)
|
|
959
|
+
self._model_calls.append({
|
|
960
|
+
"role": "hypothesis_critic", "operation": "generate",
|
|
961
|
+
"inputs": critic.get("prompt", ""), "output": critic.get("raw"),
|
|
962
|
+
"error": critic.get("error"),
|
|
963
|
+
"duration_sec": critic.get("duration_sec"),
|
|
964
|
+
})
|
|
965
|
+
|
|
966
|
+
# Fallback: if the judge returned NO_ISSUE but M2 has medium/high findings,
|
|
967
|
+
# auto-generate one hypothesis per finding so M5 can still run.
|
|
968
|
+
# This prevents self-diagnosis bias when the judge is the model under test.
|
|
969
|
+
if not hypotheses and analysis.findings:
|
|
970
|
+
from evalrx.analysis.analysis_module import _SEVERITY_ORDER
|
|
971
|
+
raw_text = str(raw or "").strip()
|
|
972
|
+
if raw_text and "NO_ISSUE" not in raw_text.upper():
|
|
973
|
+
# Not a NO_ISSUE verdict: the judge proposed something the label
|
|
974
|
+
# parser could not read (gemma-4-e2b/bbh_causal_judgement 2026-08-22:
|
|
975
|
+
# ``**HYPOTHESIS 1:**``). Substituting templates silently turned a
|
|
976
|
+
# three-hypothesis diagnosis into "low self-consistency".
|
|
977
|
+
import logging
|
|
978
|
+
|
|
979
|
+
logging.getLogger(__name__).warning(
|
|
980
|
+
"DiagnosisAgent: the judge wrote %d chars that parsed to zero hypotheses "
|
|
981
|
+
"(not NO_ISSUE); falling back to analysis-module template hypotheses — "
|
|
982
|
+
"check the label format in the response: %r",
|
|
983
|
+
len(raw_text), raw_text[:160],
|
|
984
|
+
)
|
|
985
|
+
for finding in analysis.findings:
|
|
986
|
+
if _SEVERITY_ORDER.get(finding.severity, 0) >= 2: # medium or high
|
|
987
|
+
hypotheses.append(Hypothesis(
|
|
988
|
+
statement=(
|
|
989
|
+
f"The model {finding.message} "
|
|
990
|
+
f"({finding.analyzer}.{finding.metric}={finding.value:.3g})"
|
|
991
|
+
),
|
|
992
|
+
target_model=analysis.model_name or model_name,
|
|
993
|
+
predicted_failure_mode=finding.analyzer,
|
|
994
|
+
))
|
|
995
|
+
|
|
996
|
+
return DiagnosisResult(
|
|
997
|
+
model_name=analysis.model_name or model_name,
|
|
998
|
+
hypotheses=hypotheses,
|
|
999
|
+
findings_summary=summary,
|
|
1000
|
+
raw_judge_output=str(raw),
|
|
1001
|
+
prompt=prompt,
|
|
1002
|
+
referenced_charts=_extract_referenced(str(raw), explore_context),
|
|
1003
|
+
explore_context_used=bool(explore_context is not None and not explore_context.is_empty),
|
|
1004
|
+
failure_modes_used=bool(getattr(failure_modes, "clusters", None)),
|
|
1005
|
+
critic_raw_output=str(critic.get("raw", "") or ""),
|
|
1006
|
+
critic_prompt=str(critic.get("prompt", "") or ""),
|
|
1007
|
+
n_critic_kept=int(critic.get("n_kept", 0) or 0),
|
|
1008
|
+
n_critic_rejected=int(critic.get("n_rejected", 0) or 0),
|
|
1009
|
+
# The same review in the proposer/reviewer record shape: the full
|
|
1010
|
+
# proposal list and a per-hypothesis decision derived from the
|
|
1011
|
+
# critic annotations (the critic never removes a hypothesis).
|
|
1012
|
+
proposed_hypotheses=proposed_hypotheses,
|
|
1013
|
+
review_prompt=str(critic.get("prompt", "") or ""),
|
|
1014
|
+
review_raw=str(critic.get("raw", "") or ""),
|
|
1015
|
+
review_decisions=list(critic.get("decisions") or []),
|
|
1016
|
+
model_calls=list(self._model_calls),
|
|
1017
|
+
)
|