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,944 @@
|
|
|
1
|
+
"""M2 — StatsAnalysisAgent: statistically analyze probe results.
|
|
2
|
+
|
|
3
|
+
Extends :class:`~evalrx.analysis.analysis_module.AnalysisModule` with:
|
|
4
|
+
|
|
5
|
+
- **Protocol-aware interpretation**: focuses the analysis narrative on what
|
|
6
|
+
the user's experiment was actually testing.
|
|
7
|
+
- **Richer output** (:class:`StatsAnalysisReport`): adds ``conclusion``,
|
|
8
|
+
``evidence_chain``, and ``qualitative_findings`` on top of the base
|
|
9
|
+
:class:`~evalrx.analysis.analysis_module.AnalysisReport` fields so M3
|
|
10
|
+
(DiagnosisAgent) receives richer context.
|
|
11
|
+
- **LLM-guided path** (``judge=`` set): the LLM synthesizes a protocol-specific
|
|
12
|
+
narrative from the raw findings. The threshold-rules pass always runs first;
|
|
13
|
+
the LLM *enriches*, not replaces, its output.
|
|
14
|
+
|
|
15
|
+
Two analysis paths:
|
|
16
|
+
|
|
17
|
+
a. **Basic** (default, no judge): apply built-in threshold rules + build
|
|
18
|
+
conclusion/evidence_chain from the flagged anomalies. Fast, deterministic,
|
|
19
|
+
no API key needed.
|
|
20
|
+
|
|
21
|
+
b. **LLM-guided** (``judge=`` + ``protocol=`` at call time): judge reads the
|
|
22
|
+
protocol + raw findings and writes a protocol-specific conclusion.
|
|
23
|
+
Falls back to the basic path if the LLM call fails.
|
|
24
|
+
|
|
25
|
+
:class:`StatsAnalysisReport` inherits from :class:`AnalysisReport` so it is
|
|
26
|
+
accepted everywhere an ``AnalysisReport`` is expected (e.g. ``DiagnosisAgent``).
|
|
27
|
+
|
|
28
|
+
Usage::
|
|
29
|
+
|
|
30
|
+
# Basic path
|
|
31
|
+
agent = StatsAnalysisAgent()
|
|
32
|
+
report = agent.analyze(probe_results, model_name="qwen3-vl-8b")
|
|
33
|
+
|
|
34
|
+
# Protocol-guided (richer conclusion)
|
|
35
|
+
agent = StatsAnalysisAgent(judge=gemini)
|
|
36
|
+
report = agent.analyze(probe_results, protocol=protocol)
|
|
37
|
+
print(report.conclusion)
|
|
38
|
+
print(report.evidence_chain)
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import json
|
|
44
|
+
import logging
|
|
45
|
+
import os
|
|
46
|
+
import re
|
|
47
|
+
from dataclasses import dataclass, field
|
|
48
|
+
from typing import TYPE_CHECKING, Any
|
|
49
|
+
|
|
50
|
+
from evalrx.analysis.analysis_module import AnalysisModule, AnalysisReport
|
|
51
|
+
from evalrx.analysis.prompts.stats_agent import _ANALYSIS_PROMPT, _TOOL_SELECT_PROMPT
|
|
52
|
+
from evalrx.analysis.stats_tool_agent import StatsToolAgent as LegacyStatsToolAgent
|
|
53
|
+
from evalrx.analysis.stats_tools import (
|
|
54
|
+
STATS_TOOL_CATALOG,
|
|
55
|
+
StatsInput,
|
|
56
|
+
StatsToolResult,
|
|
57
|
+
build_stats_input,
|
|
58
|
+
build_stats_input_from_records,
|
|
59
|
+
default_plan,
|
|
60
|
+
describe_data,
|
|
61
|
+
fdr_correct,
|
|
62
|
+
has_testable_data,
|
|
63
|
+
plot_effects,
|
|
64
|
+
run_stats_tool,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
if TYPE_CHECKING:
|
|
68
|
+
from evalrx.agent_runtime.cli_types import CliAgentConfig
|
|
69
|
+
from evalrx.analysis.stats_tool_generator import (
|
|
70
|
+
GeneratedStatsTool,
|
|
71
|
+
StatsToolGenerator,
|
|
72
|
+
)
|
|
73
|
+
from evalrx.core.case import CaseBatch
|
|
74
|
+
from evalrx.core.model import Model
|
|
75
|
+
from evalrx.core.result import Result
|
|
76
|
+
from evalrx.eval_agent.stages.protocol import ExperimentProtocol
|
|
77
|
+
|
|
78
|
+
logger = logging.getLogger(__name__)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _select_tools_via_llm(
|
|
82
|
+
judge: "Model",
|
|
83
|
+
protocol: "ExperimentProtocol",
|
|
84
|
+
inp: StatsInput,
|
|
85
|
+
valid_names: set[str],
|
|
86
|
+
) -> set[str]:
|
|
87
|
+
"""Ask the judge to choose tool names from the catalog. Returns a name set.
|
|
88
|
+
|
|
89
|
+
Raises on any failure so the caller can fall back to the deterministic plan.
|
|
90
|
+
"""
|
|
91
|
+
import inspect
|
|
92
|
+
|
|
93
|
+
catalog = "\n".join(f" - {name}: {desc}" for name, desc in STATS_TOOL_CATALOG.items())
|
|
94
|
+
prompt = _TOOL_SELECT_PROMPT.format(
|
|
95
|
+
protocol_text=protocol.prompt_text(),
|
|
96
|
+
tool_catalog=catalog,
|
|
97
|
+
data_shape=json.dumps(describe_data(inp), indent=2),
|
|
98
|
+
)
|
|
99
|
+
sig = inspect.signature(judge.generate)
|
|
100
|
+
if "temperature" in sig.parameters:
|
|
101
|
+
raw = judge.generate(prompt, temperature=0)
|
|
102
|
+
else:
|
|
103
|
+
raw = judge.generate(prompt)
|
|
104
|
+
|
|
105
|
+
cleaned = re.sub(r"<think>.*?</think>", "", str(raw), flags=re.DOTALL).strip()
|
|
106
|
+
match = re.search(r"\{[^{}]*\}", cleaned)
|
|
107
|
+
if match:
|
|
108
|
+
data = json.loads(match.group())
|
|
109
|
+
names = {n.lower() for n in data.get("tools", []) if n.lower() in valid_names}
|
|
110
|
+
if names:
|
|
111
|
+
return names
|
|
112
|
+
# soft fallback: scan for any known tool name
|
|
113
|
+
low = cleaned.lower()
|
|
114
|
+
return {n for n in valid_names if n in low}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
#: A section label as an LLM actually writes it. The original matcher required
|
|
118
|
+
#: a literal "CONCLUSION:" at line start; a judge that wrote the markdown
|
|
119
|
+
#: heading "## CONCLUSION" entered no section at all, so a 4,158-character
|
|
120
|
+
#: analysis fell through to the base narrative's first line
|
|
121
|
+
#: ("Model: EndpointModel(qwen3.5-2b)") and M3 had nothing to hypothesise from —
|
|
122
|
+
#: the run then reported stopped_by=no_hypotheses as if that were a finding.
|
|
123
|
+
#: Earlier runs parsed only because the judge happened to use the colon form.
|
|
124
|
+
_SECTION_HEADER = re.compile(
|
|
125
|
+
r"^[#*\s]*(CONCLUSION|EVIDENCE_CHAIN|QUALITATIVE)\s*[::]?[*#\s]*(.*)$",
|
|
126
|
+
re.IGNORECASE,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _parse_llm_analysis(
|
|
131
|
+
raw: str,
|
|
132
|
+
base: AnalysisReport,
|
|
133
|
+
) -> tuple[str, list[str], list[str]]:
|
|
134
|
+
"""Parse LLM output into (conclusion, evidence_chain, qualitative_findings).
|
|
135
|
+
|
|
136
|
+
Returns "" for conclusion when the judge's response didn't contain a
|
|
137
|
+
parseable CONCLUSION: section (empty/error response, quota message,
|
|
138
|
+
wrong format, ...) -- NOT a synthesized fallback string. The only caller
|
|
139
|
+
(_analyze_llm_guided) already built a real, statistically-grounded
|
|
140
|
+
conclusion via _build_conclusion() before this parse ever runs and only
|
|
141
|
+
overwrites it `if conclusion:` — inventing a fallback here (this used to
|
|
142
|
+
return base.narrative's first line, which is just "Model: <repr>") meant
|
|
143
|
+
a failed/malformed judge call silently threw away the good deterministic
|
|
144
|
+
conclusion and replaced it with a useless one, with no error anywhere.
|
|
145
|
+
Measured live: a judge call that hit its account's spend limit returned
|
|
146
|
+
the CLI's plain-text quota message as `raw`, which parses to nothing
|
|
147
|
+
here — before this fix, that overwrote a real "statistically supported:
|
|
148
|
+
signal X flips on FAIL, effect +0.83" conclusion with "Model: <object
|
|
149
|
+
repr>", and M3 saw the latter.
|
|
150
|
+
"""
|
|
151
|
+
conclusion = ""
|
|
152
|
+
evidence: list[str] = []
|
|
153
|
+
qualitative: list[str] = []
|
|
154
|
+
section: str | None = None
|
|
155
|
+
|
|
156
|
+
for line in raw.splitlines():
|
|
157
|
+
s = line.strip()
|
|
158
|
+
upper = s.upper()
|
|
159
|
+
header = _SECTION_HEADER.match(s)
|
|
160
|
+
if header:
|
|
161
|
+
section = header.group(1).upper()
|
|
162
|
+
section = {"CONCLUSION": "conclusion",
|
|
163
|
+
"EVIDENCE_CHAIN": "evidence",
|
|
164
|
+
"QUALITATIVE": "qualitative"}[section]
|
|
165
|
+
rest = header.group(2).strip()
|
|
166
|
+
if section == "conclusion" and rest:
|
|
167
|
+
conclusion = rest
|
|
168
|
+
elif s.startswith("- "):
|
|
169
|
+
content = s[2:].strip()
|
|
170
|
+
if section == "evidence":
|
|
171
|
+
evidence.append(content)
|
|
172
|
+
elif section == "qualitative":
|
|
173
|
+
qualitative.append(content)
|
|
174
|
+
elif section == "conclusion" and s and not upper.startswith(("EVIDENCE", "QUALITATIVE")):
|
|
175
|
+
conclusion = (conclusion + " " + s).strip()
|
|
176
|
+
|
|
177
|
+
return conclusion, evidence, qualitative
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
# Output type
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
@dataclass
|
|
185
|
+
class StatsAnalysisReport(AnalysisReport):
|
|
186
|
+
"""M2 output: a richer, protocol-aware analysis report.
|
|
187
|
+
|
|
188
|
+
Inherits all :class:`~evalrx.analysis.analysis_module.AnalysisReport`
|
|
189
|
+
fields so it is accepted everywhere that type is expected.
|
|
190
|
+
|
|
191
|
+
Additional attributes:
|
|
192
|
+
conclusion: NL paragraph summarising what was found.
|
|
193
|
+
evidence_chain: Step-by-step derivation of the conclusion.
|
|
194
|
+
qualitative_findings: Patterns or anomalies noted in free text.
|
|
195
|
+
stats_tool: ``"threshold_rules"`` (basic), ``"llm_guided"``
|
|
196
|
+
(basic + LLM narrative), or ``"selected_tools"``
|
|
197
|
+
(statistical tools from the catalog were run).
|
|
198
|
+
stats_results: Per-tool :class:`StatsToolResult` verdicts.
|
|
199
|
+
stats_plan: ``[{tool, config, rationale}]`` — which tools
|
|
200
|
+
were selected/generated and why (M2's analog of
|
|
201
|
+
M1's ProbingSchema).
|
|
202
|
+
corrected_rejections: e-BH FDR correction across all tool e-values.
|
|
203
|
+
figures: Paths to generated figures (forest plot, …).
|
|
204
|
+
stats_tool_results: Backward-compatible JSON-safe stats summaries.
|
|
205
|
+
visualizations: Backward-compatible figure/spec list.
|
|
206
|
+
protocol: The protocol that guided this analysis, if any.
|
|
207
|
+
llm_fallback_reason: Why the LLM-guided path did not produce this
|
|
208
|
+
report. Empty when it was never attempted (no
|
|
209
|
+
judge) or when it succeeded — non-empty ONLY
|
|
210
|
+
when it was tried and raised.
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
conclusion: str = ""
|
|
214
|
+
evidence_chain: list[str] = field(default_factory=list)
|
|
215
|
+
qualitative_findings: list[str] = field(default_factory=list)
|
|
216
|
+
stats_tool: str = "threshold_rules"
|
|
217
|
+
stats_results: list[StatsToolResult] = field(default_factory=list)
|
|
218
|
+
stats_plan: list[dict[str, Any]] = field(default_factory=list)
|
|
219
|
+
corrected_rejections: dict[str, Any] = field(default_factory=dict)
|
|
220
|
+
figures: list[str] = field(default_factory=list)
|
|
221
|
+
stats_tool_results: list[dict[str, Any]] = field(default_factory=list)
|
|
222
|
+
visualizations: list[dict[str, Any]] = field(default_factory=list)
|
|
223
|
+
protocol: "ExperimentProtocol | None" = None
|
|
224
|
+
# When True the report carries effect sizes + charts but NO validity verdict:
|
|
225
|
+
# e-BH FDR correction was NOT run (``corrected_rejections`` is deferred). The
|
|
226
|
+
# analysis phase uses this so the dashboard shows distributions only; the
|
|
227
|
+
# confirm phase recomputes e-BH and flips this off. See VLDiagnoseLoop.
|
|
228
|
+
descriptive_only: bool = False
|
|
229
|
+
# Judge I/O for the LLM-guided path (empty on the threshold-rules path).
|
|
230
|
+
# Surfaced so RunLogger can persist exactly what the M2 judge was shown.
|
|
231
|
+
llm_prompt: str = ""
|
|
232
|
+
llm_raw: str = ""
|
|
233
|
+
# Why the LLM path did not produce this report. Non-empty ONLY after it was
|
|
234
|
+
# attempted and raised, so "" never has to be read as "it worked".
|
|
235
|
+
llm_fallback_reason: str = ""
|
|
236
|
+
|
|
237
|
+
def to_dict(self) -> dict[str, Any]:
|
|
238
|
+
d = super().to_dict()
|
|
239
|
+
d.update({
|
|
240
|
+
"conclusion": self.conclusion,
|
|
241
|
+
"evidence_chain": self.evidence_chain,
|
|
242
|
+
"qualitative_findings": self.qualitative_findings,
|
|
243
|
+
"stats_tool": self.stats_tool,
|
|
244
|
+
"llm_fallback_reason": self.llm_fallback_reason,
|
|
245
|
+
"stats_results": [r.to_dict() for r in self.stats_results],
|
|
246
|
+
"stats_plan": self.stats_plan,
|
|
247
|
+
"corrected_rejections": self.corrected_rejections,
|
|
248
|
+
"figures": self.figures,
|
|
249
|
+
"stats_tool_results": self.stats_tool_results,
|
|
250
|
+
"visualizations": self.visualizations,
|
|
251
|
+
"descriptive_only": self.descriptive_only,
|
|
252
|
+
})
|
|
253
|
+
return d
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# ---------------------------------------------------------------------------
|
|
257
|
+
# Agent
|
|
258
|
+
# ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
class StatsAnalysisAgent:
|
|
261
|
+
"""M2: statistically analyze probe results guided by the experiment protocol.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
judge: Optional LLM for the LLM-guided analysis path.
|
|
265
|
+
Any :class:`~evalrx.core.model.Model` with
|
|
266
|
+
``Capability.GENERATE``. Pass ``None`` to use
|
|
267
|
+
threshold rules only.
|
|
268
|
+
extra_rules: Additional ``{analyzer_name: [_Rule, …]}`` entries
|
|
269
|
+
merged with the built-in rules.
|
|
270
|
+
"""
|
|
271
|
+
|
|
272
|
+
def __init__(
|
|
273
|
+
self,
|
|
274
|
+
judge: "Model | None" = None,
|
|
275
|
+
extra_rules: dict | None = None,
|
|
276
|
+
stats_tool_agent: Any | None = None,
|
|
277
|
+
enable_stats_tools: bool = True,
|
|
278
|
+
figure_dir: "str | None" = None,
|
|
279
|
+
max_signal_tools: int | None = None,
|
|
280
|
+
allow_codegen: bool = False,
|
|
281
|
+
codegen_config: "CliAgentConfig | None" = None,
|
|
282
|
+
tool_generator: "StatsToolGenerator | None" = None,
|
|
283
|
+
run_logger: "Any | None" = None,
|
|
284
|
+
) -> None:
|
|
285
|
+
self._base = AnalysisModule(extra_rules)
|
|
286
|
+
self._judge = judge
|
|
287
|
+
self._stats_tool_agent = stats_tool_agent or LegacyStatsToolAgent()
|
|
288
|
+
self._enable_stats_tools = enable_stats_tools
|
|
289
|
+
self._figure_dir = figure_dir
|
|
290
|
+
self._max_signal_tools = max_signal_tools
|
|
291
|
+
# Tier (b): generate a bespoke stats tool in a sandbox when no catalog
|
|
292
|
+
# tool fits. Disabled unless allow_codegen=True and a backend exists.
|
|
293
|
+
self._allow_codegen = allow_codegen
|
|
294
|
+
self._codegen_config = codegen_config
|
|
295
|
+
self._tool_generator = tool_generator
|
|
296
|
+
# Tier (c): generated tools are cached here and re-run on later cycles
|
|
297
|
+
# without a second LLM call.
|
|
298
|
+
self._generated_tools: list[GeneratedStatsTool] = []
|
|
299
|
+
# Optional RunLogger forwarded to the tool generator so its code-writing
|
|
300
|
+
# attempts surface as "tool_codegen" events. The loop sets this if unset.
|
|
301
|
+
self.run_logger = run_logger
|
|
302
|
+
|
|
303
|
+
def analyze(
|
|
304
|
+
self,
|
|
305
|
+
results: "dict[str, Result]",
|
|
306
|
+
model_name: str = "",
|
|
307
|
+
protocol: "ExperimentProtocol | None" = None,
|
|
308
|
+
data: "CaseBatch | None" = None,
|
|
309
|
+
extra_figures: "list | None" = None,
|
|
310
|
+
confirmatory: bool = True,
|
|
311
|
+
) -> StatsAnalysisReport:
|
|
312
|
+
"""Analyze *results* into a :class:`StatsAnalysisReport`.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
results: ``{analyzer_name: Result}`` dict from M1.
|
|
316
|
+
model_name: Human-readable model identifier.
|
|
317
|
+
protocol: Experiment protocol for context-aware interpretation.
|
|
318
|
+
When ``None``, the analysis is protocol-agnostic.
|
|
319
|
+
data: Labeled cases from M1. Required to run the statistical
|
|
320
|
+
tool layer (per-case fail/pass labels). When ``None``
|
|
321
|
+
or unlabeled, M2 falls back to threshold rules only.
|
|
322
|
+
|
|
323
|
+
Returns:
|
|
324
|
+
:class:`StatsAnalysisReport` — backward-compatible with
|
|
325
|
+
:class:`~evalrx.analysis.analysis_module.AnalysisReport`.
|
|
326
|
+
"""
|
|
327
|
+
base = self._base.analyze(results, model_name)
|
|
328
|
+
legacy_tool_results = self._run_legacy_stats_tools(results, protocol)
|
|
329
|
+
inp = build_stats_input(results, data)
|
|
330
|
+
|
|
331
|
+
return self._analyze_from_input(
|
|
332
|
+
inp=inp,
|
|
333
|
+
base=base,
|
|
334
|
+
protocol=protocol,
|
|
335
|
+
results=results,
|
|
336
|
+
extra_figures=extra_figures,
|
|
337
|
+
legacy_tool_results=legacy_tool_results,
|
|
338
|
+
confirmatory=confirmatory,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
def analyze_input(
|
|
342
|
+
self,
|
|
343
|
+
inp: StatsInput,
|
|
344
|
+
*,
|
|
345
|
+
model_name: str = "",
|
|
346
|
+
protocol: "ExperimentProtocol | None" = None,
|
|
347
|
+
extra_figures: "list | None" = None,
|
|
348
|
+
) -> StatsAnalysisReport:
|
|
349
|
+
"""Analyze a pre-normalized :class:`StatsInput`.
|
|
350
|
+
|
|
351
|
+
This is the standalone path for callers that have already prepared
|
|
352
|
+
labels, per-case signals, scalar metrics, or strategy groups and do not
|
|
353
|
+
need to run M1 analyzers first.
|
|
354
|
+
"""
|
|
355
|
+
base = AnalysisReport(
|
|
356
|
+
model_name=model_name,
|
|
357
|
+
findings=[],
|
|
358
|
+
severity="none",
|
|
359
|
+
narrative=_build_input_narrative(model_name, inp),
|
|
360
|
+
raw_results={},
|
|
361
|
+
)
|
|
362
|
+
return self._analyze_from_input(
|
|
363
|
+
inp=inp,
|
|
364
|
+
base=base,
|
|
365
|
+
protocol=protocol,
|
|
366
|
+
results={},
|
|
367
|
+
extra_figures=extra_figures,
|
|
368
|
+
legacy_tool_results=[],
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
def analyze_records(
|
|
372
|
+
self,
|
|
373
|
+
records: "Any",
|
|
374
|
+
*,
|
|
375
|
+
id_col: str = "case_id",
|
|
376
|
+
label_col: str = "label",
|
|
377
|
+
signal_cols: "list[str] | tuple[str, ...] | None" = None,
|
|
378
|
+
scalar_cols: "list[str] | tuple[str, ...] | None" = None,
|
|
379
|
+
signal_prefix: str = "",
|
|
380
|
+
model_name: str = "",
|
|
381
|
+
protocol: "ExperimentProtocol | None" = None,
|
|
382
|
+
extra_figures: "list | None" = None,
|
|
383
|
+
) -> StatsAnalysisReport:
|
|
384
|
+
"""Analyze plain row dictionaries without constructing M1 ``Result`` objects."""
|
|
385
|
+
inp = build_stats_input_from_records(
|
|
386
|
+
records,
|
|
387
|
+
id_col=id_col,
|
|
388
|
+
label_col=label_col,
|
|
389
|
+
signal_cols=signal_cols,
|
|
390
|
+
scalar_cols=scalar_cols,
|
|
391
|
+
signal_prefix=signal_prefix,
|
|
392
|
+
)
|
|
393
|
+
return self.analyze_input(
|
|
394
|
+
inp,
|
|
395
|
+
model_name=model_name,
|
|
396
|
+
protocol=protocol,
|
|
397
|
+
extra_figures=extra_figures,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
def _analyze_from_input(
|
|
401
|
+
self,
|
|
402
|
+
*,
|
|
403
|
+
inp: StatsInput,
|
|
404
|
+
base: AnalysisReport,
|
|
405
|
+
protocol: "ExperimentProtocol | None",
|
|
406
|
+
results: "dict[str, Result]",
|
|
407
|
+
extra_figures: "list | None",
|
|
408
|
+
legacy_tool_results: list[Any],
|
|
409
|
+
confirmatory: bool = True,
|
|
410
|
+
) -> StatsAnalysisReport:
|
|
411
|
+
"""Shared implementation for loop-driven and standalone M2 analysis.
|
|
412
|
+
|
|
413
|
+
``confirmatory=False`` runs the tools (effect sizes + charts) but DEFERS
|
|
414
|
+
the validity verdict: e-BH FDR correction is skipped and the report is
|
|
415
|
+
marked ``descriptive_only`` so the dashboard shows distributions without
|
|
416
|
+
a supported/not-supported claim. The confirm phase recomputes e-BH.
|
|
417
|
+
"""
|
|
418
|
+
|
|
419
|
+
# ── Statistical tool layer (select → run → [FDR-correct] → plot) ──
|
|
420
|
+
stats_results, stats_plan, corrected, figures = self._run_stats_tools_input(
|
|
421
|
+
inp, protocol, confirmatory=confirmatory
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
# Merge in analyzer heatmap PNGs forwarded from the run logger.
|
|
425
|
+
if extra_figures:
|
|
426
|
+
from pathlib import Path as _Path
|
|
427
|
+
figures = figures + [
|
|
428
|
+
str(p) for p in extra_figures
|
|
429
|
+
if _Path(p).exists() and str(p).endswith(".png")
|
|
430
|
+
]
|
|
431
|
+
|
|
432
|
+
report: "StatsAnalysisReport | None" = None
|
|
433
|
+
fallback_reason = ""
|
|
434
|
+
if self._judge is not None and protocol is not None:
|
|
435
|
+
try:
|
|
436
|
+
report = self._analyze_llm_guided(
|
|
437
|
+
base, results, protocol,
|
|
438
|
+
stats_results, stats_plan, corrected, figures,
|
|
439
|
+
legacy_tool_results,
|
|
440
|
+
)
|
|
441
|
+
except Exception as exc:
|
|
442
|
+
# A judge that never answered and a judge that found nothing
|
|
443
|
+
# produce the SAME downstream run: threshold narrative, M3 with
|
|
444
|
+
# nothing to hypothesise from, stopped_by=no_hypotheses, rc=0.
|
|
445
|
+
# The reason has to travel with the report — a logger.warning
|
|
446
|
+
# here reached neither the console nor run_log.jsonl, so an
|
|
447
|
+
# OSError(E2BIG) on the prompt left no trace anywhere in the run.
|
|
448
|
+
fallback_reason = f"{type(exc).__name__}: {exc}"
|
|
449
|
+
logger.warning("LLM-guided M2 analysis failed, falling back: %s", exc)
|
|
450
|
+
if report is None:
|
|
451
|
+
report = self._to_stats_report(
|
|
452
|
+
base, protocol, stats_results, stats_plan, corrected, figures,
|
|
453
|
+
legacy_tool_results,
|
|
454
|
+
)
|
|
455
|
+
report.llm_fallback_reason = fallback_reason
|
|
456
|
+
report.descriptive_only = not confirmatory
|
|
457
|
+
return report
|
|
458
|
+
|
|
459
|
+
# ------------------------------------------------------------------
|
|
460
|
+
# Statistical tool layer
|
|
461
|
+
# ------------------------------------------------------------------
|
|
462
|
+
|
|
463
|
+
def _run_stats_tools(
|
|
464
|
+
self,
|
|
465
|
+
results: "dict[str, Result]",
|
|
466
|
+
data: "CaseBatch | None",
|
|
467
|
+
protocol: "ExperimentProtocol | None",
|
|
468
|
+
) -> tuple[list[StatsToolResult], list[dict[str, Any]], dict[str, Any], list[str]]:
|
|
469
|
+
"""Build input, select and run tools, FDR-correct, and plot.
|
|
470
|
+
|
|
471
|
+
Returns ``(stats_results, stats_plan, corrected, figures)``. All empty
|
|
472
|
+
when there is no labeled/grouped data to test (pure backward compat).
|
|
473
|
+
"""
|
|
474
|
+
inp = build_stats_input(results, data)
|
|
475
|
+
return self._run_stats_tools_input(inp, protocol)
|
|
476
|
+
|
|
477
|
+
def _run_stats_tools_input(
|
|
478
|
+
self,
|
|
479
|
+
inp: StatsInput,
|
|
480
|
+
protocol: "ExperimentProtocol | None",
|
|
481
|
+
confirmatory: bool = True,
|
|
482
|
+
) -> tuple[list[StatsToolResult], list[dict[str, Any]], dict[str, Any], list[str]]:
|
|
483
|
+
"""Run the statistical tool layer over already-normalized input.
|
|
484
|
+
|
|
485
|
+
``confirmatory=False`` skips the e-BH FDR correction (the validity
|
|
486
|
+
verdict) — the per-tool effect sizes / CIs are still computed (the
|
|
487
|
+
charts need them), but the family-level reject decision is deferred to
|
|
488
|
+
the confirm phase.
|
|
489
|
+
"""
|
|
490
|
+
if not self._enable_stats_tools or not has_testable_data(inp):
|
|
491
|
+
return [], [], {}, []
|
|
492
|
+
|
|
493
|
+
plan = self._select_plan(inp, protocol)
|
|
494
|
+
|
|
495
|
+
stats_results: list[StatsToolResult] = []
|
|
496
|
+
for tool, cfg, rationale in plan:
|
|
497
|
+
try:
|
|
498
|
+
r = run_stats_tool(tool, inp, cfg)
|
|
499
|
+
except Exception as exc: # never let one tool sink the analysis
|
|
500
|
+
logger.warning("stats tool %r failed: %s", tool, exc)
|
|
501
|
+
r = StatsToolResult(
|
|
502
|
+
tool=tool, config=cfg, ok=False,
|
|
503
|
+
error=str(exc), summary=f"{tool}: {exc}",
|
|
504
|
+
)
|
|
505
|
+
r.details.setdefault("rationale", rationale)
|
|
506
|
+
stats_results.append(r)
|
|
507
|
+
|
|
508
|
+
stats_plan = [
|
|
509
|
+
{"tool": t, "config": c, "rationale": rat} for t, c, rat in plan
|
|
510
|
+
]
|
|
511
|
+
|
|
512
|
+
# Tier (b)/(c): reuse previously generated tools, then generate a new one
|
|
513
|
+
# if the catalog produced nothing usable for this data.
|
|
514
|
+
gen_results, gen_plan = self._run_generated_tools(inp, protocol, stats_results)
|
|
515
|
+
stats_results.extend(gen_results)
|
|
516
|
+
stats_plan.extend(gen_plan)
|
|
517
|
+
|
|
518
|
+
# e-BH FDR correction is the validity verdict — deferred in descriptive
|
|
519
|
+
# (analysis-phase) mode; the confirm phase recomputes it.
|
|
520
|
+
corrected = (
|
|
521
|
+
fdr_correct(stats_results) if confirmatory
|
|
522
|
+
else {"method": "e-BH", "deferred": True, "n_tested": 0, "rejected_tools": [],
|
|
523
|
+
"note": "validity deferred to the confirm phase (descriptive_only)"}
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
figures: list[str] = []
|
|
527
|
+
if self._figure_dir:
|
|
528
|
+
fig = plot_effects(
|
|
529
|
+
stats_results, os.path.join(self._figure_dir, "m2_effects.png")
|
|
530
|
+
)
|
|
531
|
+
if fig:
|
|
532
|
+
figures.append(fig)
|
|
533
|
+
|
|
534
|
+
return stats_results, stats_plan, corrected, figures
|
|
535
|
+
|
|
536
|
+
def _run_legacy_stats_tools(
|
|
537
|
+
self,
|
|
538
|
+
results: "dict[str, Result]",
|
|
539
|
+
protocol: "ExperimentProtocol | None",
|
|
540
|
+
) -> list[Any]:
|
|
541
|
+
"""Run the older lightweight M2 tools for backward-compatible reports."""
|
|
542
|
+
if not self._enable_stats_tools or self._stats_tool_agent is None:
|
|
543
|
+
return []
|
|
544
|
+
try:
|
|
545
|
+
return list(self._stats_tool_agent.analyze(results, protocol=protocol))
|
|
546
|
+
except Exception as exc:
|
|
547
|
+
logger.warning("legacy M2 stats-tool analysis failed: %s", exc)
|
|
548
|
+
return []
|
|
549
|
+
|
|
550
|
+
def _run_generated_tools(
|
|
551
|
+
self,
|
|
552
|
+
inp: StatsInput,
|
|
553
|
+
protocol: "ExperimentProtocol | None",
|
|
554
|
+
catalog_results: list[StatsToolResult],
|
|
555
|
+
) -> tuple[list[StatsToolResult], list[dict[str, Any]]]:
|
|
556
|
+
"""Reuse cached generated tools (tier c) and generate a new one (tier b).
|
|
557
|
+
|
|
558
|
+
Generation fires only when ``allow_codegen`` is set, a backend exists,
|
|
559
|
+
and no catalog or cached tool produced a usable (``ok``) result.
|
|
560
|
+
"""
|
|
561
|
+
if not self._allow_codegen:
|
|
562
|
+
return [], []
|
|
563
|
+
generator = self._get_generator()
|
|
564
|
+
if generator is None:
|
|
565
|
+
return [], []
|
|
566
|
+
|
|
567
|
+
results: list[StatsToolResult] = []
|
|
568
|
+
plan: list[dict[str, Any]] = []
|
|
569
|
+
|
|
570
|
+
# Tier (c): re-run cached generated tools on this cycle's data.
|
|
571
|
+
for tool in self._generated_tools:
|
|
572
|
+
r = generator.run_cached(tool, inp)
|
|
573
|
+
results.append(r)
|
|
574
|
+
plan.append({"tool": f"generated:{tool.name}", "config": {},
|
|
575
|
+
"rationale": f"reuse generated tool ({tool.source})"})
|
|
576
|
+
|
|
577
|
+
usable = any(r.ok for r in catalog_results) or any(r.ok for r in results)
|
|
578
|
+
if usable:
|
|
579
|
+
return results, plan
|
|
580
|
+
|
|
581
|
+
# Tier (b): nothing fit — synthesise a new tool.
|
|
582
|
+
need = (
|
|
583
|
+
(protocol.prompt_text() + " " if protocol else "")
|
|
584
|
+
+ "No built-in statistical tool fit this data; write a test for "
|
|
585
|
+
"whether the analyzer signals predict case FAIL."
|
|
586
|
+
)
|
|
587
|
+
name = f"custom{len(self._generated_tools) + 1}"
|
|
588
|
+
result, tool = generator.generate(need, inp, name=name)
|
|
589
|
+
results.append(result)
|
|
590
|
+
plan.append({"tool": f"generated:{name}", "config": {},
|
|
591
|
+
"rationale": "generated: no catalog tool fit the data"})
|
|
592
|
+
if tool is not None:
|
|
593
|
+
self._generated_tools.append(tool)
|
|
594
|
+
return results, plan
|
|
595
|
+
|
|
596
|
+
def _get_generator(self) -> "StatsToolGenerator | None":
|
|
597
|
+
"""Lazily build the tool generator from the judge / codegen config."""
|
|
598
|
+
if self._tool_generator is not None:
|
|
599
|
+
if self.run_logger is not None and getattr(self._tool_generator, "run_logger", None) is None:
|
|
600
|
+
try:
|
|
601
|
+
self._tool_generator.run_logger = self.run_logger
|
|
602
|
+
except Exception: # noqa: BLE001
|
|
603
|
+
pass
|
|
604
|
+
return self._tool_generator
|
|
605
|
+
if self._judge is None and self._codegen_config is None:
|
|
606
|
+
return None
|
|
607
|
+
from evalrx.analysis.stats_tool_generator import StatsToolGenerator
|
|
608
|
+
gen = StatsToolGenerator(
|
|
609
|
+
judge=self._judge, cli_config=self._codegen_config, run_logger=self.run_logger
|
|
610
|
+
)
|
|
611
|
+
if not gen.available:
|
|
612
|
+
return None
|
|
613
|
+
self._tool_generator = gen
|
|
614
|
+
return gen
|
|
615
|
+
|
|
616
|
+
def _select_plan(
|
|
617
|
+
self,
|
|
618
|
+
inp: StatsInput,
|
|
619
|
+
protocol: "ExperimentProtocol | None",
|
|
620
|
+
) -> list[tuple[str, dict, str]]:
|
|
621
|
+
"""Select tools: LLM picks from the catalog, deterministic plan otherwise.
|
|
622
|
+
|
|
623
|
+
The deterministic plan is always the backbone (it guarantees coverage);
|
|
624
|
+
when a judge + protocol are present, the LLM *narrows* it to the chosen
|
|
625
|
+
tools. Any failure or empty selection falls back to the full plan.
|
|
626
|
+
"""
|
|
627
|
+
plan = default_plan(inp, max_signals=self._max_signal_tools)
|
|
628
|
+
if self._judge is None or protocol is None or not plan:
|
|
629
|
+
return plan
|
|
630
|
+
try:
|
|
631
|
+
chosen = _select_tools_via_llm(
|
|
632
|
+
self._judge, protocol, inp, set(STATS_TOOL_CATALOG)
|
|
633
|
+
)
|
|
634
|
+
except Exception as exc:
|
|
635
|
+
logger.debug("LLM stats-tool selection failed, using default plan: %s", exc)
|
|
636
|
+
return plan
|
|
637
|
+
if not chosen:
|
|
638
|
+
return plan
|
|
639
|
+
# Paired/omnibus tools (mcnemar, friedman, attention_decoding) only enter
|
|
640
|
+
# the plan when their inputs exist — they carry causal / tensor-level
|
|
641
|
+
# verdicts and must never be silently dropped by name-based narrowing.
|
|
642
|
+
_mandatory = {"mcnemar_evalue", "friedman_nemenyi", "attention_decoding"}
|
|
643
|
+
filtered = [p for p in plan if p[0] in chosen or p[0] in _mandatory]
|
|
644
|
+
return filtered or plan
|
|
645
|
+
|
|
646
|
+
# ------------------------------------------------------------------
|
|
647
|
+
# Report assembly
|
|
648
|
+
# ------------------------------------------------------------------
|
|
649
|
+
|
|
650
|
+
def _to_stats_report(
|
|
651
|
+
self,
|
|
652
|
+
base: AnalysisReport,
|
|
653
|
+
protocol: "ExperimentProtocol | None",
|
|
654
|
+
stats_results: list[StatsToolResult] | None = None,
|
|
655
|
+
stats_plan: list[dict[str, Any]] | None = None,
|
|
656
|
+
corrected: dict[str, Any] | None = None,
|
|
657
|
+
figures: list[str] | None = None,
|
|
658
|
+
legacy_tool_results: list[Any] | None = None,
|
|
659
|
+
) -> StatsAnalysisReport:
|
|
660
|
+
"""Basic path: wrap AnalysisReport + stats-tool results into a report."""
|
|
661
|
+
stats_results = stats_results or []
|
|
662
|
+
stats_plan = stats_plan or []
|
|
663
|
+
corrected = corrected or {}
|
|
664
|
+
figures = figures or []
|
|
665
|
+
legacy_tool_results = legacy_tool_results or []
|
|
666
|
+
|
|
667
|
+
chain: list[str] = []
|
|
668
|
+
if base.findings:
|
|
669
|
+
chain.append(
|
|
670
|
+
f"Applied threshold rules across {len(base.raw_results)} "
|
|
671
|
+
f"analyzer(s): {', '.join(sorted(base.raw_results))}"
|
|
672
|
+
)
|
|
673
|
+
for f in base.findings:
|
|
674
|
+
cmp = ">" if f.direction == "above" else "<"
|
|
675
|
+
chain.append(
|
|
676
|
+
f" [{f.severity.upper()}] {f.analyzer}.{f.metric}="
|
|
677
|
+
f"{f.value:.3g} {cmp} {f.threshold}: {f.message}"
|
|
678
|
+
)
|
|
679
|
+
chain.append(f"Overall severity: {base.severity}")
|
|
680
|
+
elif base.raw_results:
|
|
681
|
+
chain.append(
|
|
682
|
+
f"All metrics within normal ranges across "
|
|
683
|
+
f"{len(base.raw_results)} analyzer(s)."
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
# Append statistical verdicts to the evidence chain.
|
|
687
|
+
for r in stats_results:
|
|
688
|
+
if r.ok:
|
|
689
|
+
chain.append(f"[stats:{r.tool}] {r.summary}")
|
|
690
|
+
else:
|
|
691
|
+
chain.append(f"[stats:{r.tool}] N/A ({r.error})")
|
|
692
|
+
if corrected.get("rejected_tools"):
|
|
693
|
+
chain.append(
|
|
694
|
+
f"FDR (e-BH) survivors: {', '.join(corrected['rejected_tools'])}"
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
qualitative = [str(f) for f in base.findings]
|
|
698
|
+
conclusion = _build_conclusion(base, protocol, stats_results)
|
|
699
|
+
if any(r.tool.startswith("generated:") for r in stats_results):
|
|
700
|
+
stats_tool = "generated"
|
|
701
|
+
elif stats_results:
|
|
702
|
+
stats_tool = "selected_tools"
|
|
703
|
+
else:
|
|
704
|
+
stats_tool = "threshold_rules"
|
|
705
|
+
|
|
706
|
+
return StatsAnalysisReport(
|
|
707
|
+
# AnalysisReport fields
|
|
708
|
+
model_name=base.model_name,
|
|
709
|
+
findings=base.findings,
|
|
710
|
+
severity=base.severity,
|
|
711
|
+
narrative=base.narrative,
|
|
712
|
+
raw_results=base.raw_results,
|
|
713
|
+
# StatsAnalysisReport extras
|
|
714
|
+
conclusion=conclusion,
|
|
715
|
+
evidence_chain=chain,
|
|
716
|
+
qualitative_findings=qualitative,
|
|
717
|
+
stats_tool=stats_tool,
|
|
718
|
+
stats_results=stats_results,
|
|
719
|
+
stats_plan=stats_plan,
|
|
720
|
+
corrected_rejections=corrected,
|
|
721
|
+
figures=figures,
|
|
722
|
+
stats_tool_results=(
|
|
723
|
+
_serialize_legacy_tool_results(legacy_tool_results)
|
|
724
|
+
or _compat_stats_tool_results(stats_results)
|
|
725
|
+
),
|
|
726
|
+
visualizations=(
|
|
727
|
+
_legacy_visualizations(legacy_tool_results)
|
|
728
|
+
or [{"type": "figure", "path": p} for p in figures]
|
|
729
|
+
),
|
|
730
|
+
protocol=protocol,
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
def _analyze_llm_guided(
|
|
734
|
+
self,
|
|
735
|
+
base: AnalysisReport,
|
|
736
|
+
results: "dict[str, Result]",
|
|
737
|
+
protocol: "ExperimentProtocol",
|
|
738
|
+
stats_results: list[StatsToolResult],
|
|
739
|
+
stats_plan: list[dict[str, Any]],
|
|
740
|
+
corrected: dict[str, Any],
|
|
741
|
+
figures: list[str],
|
|
742
|
+
legacy_tool_results: list[Any],
|
|
743
|
+
) -> StatsAnalysisReport:
|
|
744
|
+
"""LLM-guided path: judge synthesises protocol-specific insights.
|
|
745
|
+
|
|
746
|
+
The judge now also sees the statistical verdicts so its conclusion is
|
|
747
|
+
grounded in effect sizes + e-values, not just raw findings.
|
|
748
|
+
"""
|
|
749
|
+
findings_json = json.dumps(
|
|
750
|
+
{r: res.findings for r, res in results.items()},
|
|
751
|
+
indent=2,
|
|
752
|
+
default=str,
|
|
753
|
+
)
|
|
754
|
+
stats_block = _format_stats_for_prompt(stats_results, corrected)
|
|
755
|
+
prompt = _ANALYSIS_PROMPT.format(
|
|
756
|
+
protocol_text=protocol.prompt_text(),
|
|
757
|
+
task_domain=protocol.task_domain or "general",
|
|
758
|
+
narrative=base.narrative + stats_block,
|
|
759
|
+
findings_json=findings_json,
|
|
760
|
+
)
|
|
761
|
+
import inspect as _inspect
|
|
762
|
+
from pathlib import Path as _Path
|
|
763
|
+
_sig = _inspect.signature(self._judge.generate) # type: ignore[union-attr]
|
|
764
|
+
if "images" in _sig.parameters and figures:
|
|
765
|
+
_imgs = [_Path(f) for f in figures if _Path(f).exists()]
|
|
766
|
+
raw = self._judge.generate(prompt, images=_imgs or None) # type: ignore[union-attr]
|
|
767
|
+
else:
|
|
768
|
+
raw = self._judge.generate(prompt) # type: ignore[union-attr]
|
|
769
|
+
conclusion, evidence, qualitative = _parse_llm_analysis(str(raw), base)
|
|
770
|
+
|
|
771
|
+
report = self._to_stats_report(
|
|
772
|
+
base, protocol, stats_results, stats_plan, corrected, figures,
|
|
773
|
+
legacy_tool_results,
|
|
774
|
+
)
|
|
775
|
+
if conclusion:
|
|
776
|
+
report.conclusion = conclusion
|
|
777
|
+
if evidence:
|
|
778
|
+
report.evidence_chain = evidence
|
|
779
|
+
if qualitative:
|
|
780
|
+
report.qualitative_findings = qualitative
|
|
781
|
+
report.stats_tool = "llm_guided"
|
|
782
|
+
report.llm_prompt = prompt
|
|
783
|
+
report.llm_raw = str(raw)
|
|
784
|
+
return report
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def _build_conclusion(
|
|
788
|
+
base: AnalysisReport,
|
|
789
|
+
protocol: "ExperimentProtocol | None",
|
|
790
|
+
stats_results: list[StatsToolResult] | None = None,
|
|
791
|
+
) -> str:
|
|
792
|
+
"""Build a short NL conclusion from the base report + stats + optional protocol."""
|
|
793
|
+
domain = f"For {protocol.task_domain} tasks: " if (protocol and protocol.task_domain) else ""
|
|
794
|
+
|
|
795
|
+
# Prefer a statistically supported verdict when one exists.
|
|
796
|
+
supported = [
|
|
797
|
+
r for r in (stats_results or [])
|
|
798
|
+
if r.ok and r.reject and r.effect is not None
|
|
799
|
+
]
|
|
800
|
+
stat_msg = ""
|
|
801
|
+
if supported:
|
|
802
|
+
top = max(supported, key=lambda r: abs(r.effect or 0.0))
|
|
803
|
+
stat_msg = f" Statistically supported: {top.summary}"
|
|
804
|
+
|
|
805
|
+
if not base.raw_results and base.narrative:
|
|
806
|
+
if stat_msg:
|
|
807
|
+
return f"{domain}Standalone statistical analysis found a supported signal.{stat_msg}"
|
|
808
|
+
return f"{domain}Standalone statistical analysis found no supported signal."
|
|
809
|
+
|
|
810
|
+
if not base.findings:
|
|
811
|
+
if stat_msg:
|
|
812
|
+
return f"{domain}No threshold violations, but a statistical test fired.{stat_msg}"
|
|
813
|
+
msg = "No threshold violations detected; all metrics within normal ranges."
|
|
814
|
+
return f"{domain}{msg}"
|
|
815
|
+
|
|
816
|
+
top_f = base.findings[0]
|
|
817
|
+
msg = (
|
|
818
|
+
f"Primary anomaly: {top_f.message} "
|
|
819
|
+
f"({top_f.analyzer}.{top_f.metric}={top_f.value:.3g}, severity={top_f.severity})."
|
|
820
|
+
)
|
|
821
|
+
if len(base.findings) > 1:
|
|
822
|
+
msg += f" {len(base.findings) - 1} additional finding(s) also flagged."
|
|
823
|
+
return f"{domain}{msg}{stat_msg}"
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def _build_input_narrative(model_name: str, inp: StatsInput) -> str:
|
|
827
|
+
"""Human-readable summary for standalone StatsInput analysis."""
|
|
828
|
+
d = describe_data(inp)
|
|
829
|
+
lines = [f"Model: {model_name}" if model_name else "Model: unspecified"]
|
|
830
|
+
lines.append("Input: standalone statistical dataset")
|
|
831
|
+
lines.append(
|
|
832
|
+
f"Labeled cases: {d['n_labeled']} "
|
|
833
|
+
f"({d['n_fail']} FAIL, {d['n_pass']} PASS)"
|
|
834
|
+
)
|
|
835
|
+
if d["per_case_signals"]:
|
|
836
|
+
lines.append(f"Per-case signals: {', '.join(d['per_case_signals'])}")
|
|
837
|
+
else:
|
|
838
|
+
lines.append("Per-case signals: none")
|
|
839
|
+
if d["scalar_metrics"]:
|
|
840
|
+
lines.append(f"Scalar metrics: {', '.join(d['scalar_metrics'])}")
|
|
841
|
+
if d["n_strategy_groups"]:
|
|
842
|
+
lines.append(f"Strategy groups: {d['n_strategy_groups']}")
|
|
843
|
+
return "\n".join(lines)
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def _compat_stats_tool_results(stats_results: list[StatsToolResult]) -> list[dict[str, Any]]:
|
|
847
|
+
"""Return the legacy JSON shape used by older logs/examples."""
|
|
848
|
+
out: list[dict[str, Any]] = []
|
|
849
|
+
for result in stats_results:
|
|
850
|
+
payload = result.to_dict()
|
|
851
|
+
payload["name"] = result.tool
|
|
852
|
+
payload["conclusion"] = result.summary or result.error or ""
|
|
853
|
+
payload["visualizations"] = (
|
|
854
|
+
[{"type": "figure", "path": result.figure_path}]
|
|
855
|
+
if result.figure_path
|
|
856
|
+
else []
|
|
857
|
+
)
|
|
858
|
+
out.append(payload)
|
|
859
|
+
return out
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def _serialize_legacy_tool_results(tool_results: list[Any]) -> list[dict[str, Any]]:
|
|
863
|
+
payloads: list[dict[str, Any]] = []
|
|
864
|
+
for result in tool_results:
|
|
865
|
+
if hasattr(result, "to_dict"):
|
|
866
|
+
payloads.append(result.to_dict())
|
|
867
|
+
elif isinstance(result, dict):
|
|
868
|
+
payloads.append(dict(result))
|
|
869
|
+
return payloads
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def _legacy_visualizations(tool_results: list[Any]) -> list[dict[str, Any]]:
|
|
873
|
+
visualizations: list[dict[str, Any]] = []
|
|
874
|
+
for result in tool_results:
|
|
875
|
+
visualizations.extend(getattr(result, "visualizations", []) or [])
|
|
876
|
+
return visualizations
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def _multiplicity_note(result: StatsToolResult) -> str:
|
|
880
|
+
"""Per-result multiplicity verdict, or "" for a result outside every family.
|
|
881
|
+
|
|
882
|
+
``correction_method`` is set by :func:`~evalrx.stats.multiplicity.correct_results`
|
|
883
|
+
on family members only, so its absence means "descriptive, never corrected" —
|
|
884
|
+
which must not be rendered as a failure to survive.
|
|
885
|
+
"""
|
|
886
|
+
method = result.correction_method
|
|
887
|
+
if not method:
|
|
888
|
+
return ""
|
|
889
|
+
bits = ["survived" if result.fdr_corrected else "NOT survived"]
|
|
890
|
+
if result.p_value is not None:
|
|
891
|
+
bits.append(f"p={result.p_value:.3g}")
|
|
892
|
+
if result.e_value is not None:
|
|
893
|
+
bits.append(f"e={result.e_value:.3g}")
|
|
894
|
+
n_signal = (result.details or {}).get("n_signal")
|
|
895
|
+
if n_signal is not None:
|
|
896
|
+
bits.append(f"n_signal={n_signal}")
|
|
897
|
+
return f" [{method}: {', '.join(bits)}]"
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def _format_stats_for_prompt(
|
|
901
|
+
stats_results: list[StatsToolResult],
|
|
902
|
+
corrected: dict[str, Any],
|
|
903
|
+
) -> str:
|
|
904
|
+
"""Render statistical verdicts as a block appended to the LLM narrative.
|
|
905
|
+
|
|
906
|
+
Every line carries its OWN multiplicity verdict, because two separate things
|
|
907
|
+
otherwise mislead the reader in the same direction:
|
|
908
|
+
|
|
909
|
+
* ``summary`` is baked when the tool RUNS, before any correction, so a line
|
|
910
|
+
ending "-> REJECT H0" is the uncorrected verdict. On qwen3.5-2b /
|
|
911
|
+
bbh_word_sorting, 23 of 42 ``signal_label_assoc`` results printed
|
|
912
|
+
REJECT H0 while BH kept 13; of the other ten, one rested on a single case
|
|
913
|
+
at permutation p = 1.000.
|
|
914
|
+
* The footer used to list ``rejected_tools`` — a set of TOOL NAMES. Every
|
|
915
|
+
per-signal test shares the name ``signal_label_assoc``, so a 42-signal
|
|
916
|
+
family collapsed to the single word "signal_label_assoc" and no reader
|
|
917
|
+
could tell WHICH signals survived. The judge on that run reconciled the
|
|
918
|
+
mismatch the only way left to it, concluding the tool had not run at all,
|
|
919
|
+
and discarded the entire family. ``rejected_result_keys`` was already in
|
|
920
|
+
the same dict, at per-signal granularity.
|
|
921
|
+
|
|
922
|
+
``reject`` itself is deliberately not touched here: for the BH family it
|
|
923
|
+
keeps the tool's raw CI verdict for the M1-M4 loop (see
|
|
924
|
+
:mod:`evalrx.stats.multiplicity`). This renderer is the place that has
|
|
925
|
+
to make the difference between raw and corrected legible.
|
|
926
|
+
"""
|
|
927
|
+
if not stats_results:
|
|
928
|
+
return ""
|
|
929
|
+
lines = ["", "Statistical test results (effect-sized, FDR-aware):"]
|
|
930
|
+
for r in stats_results:
|
|
931
|
+
if r.ok:
|
|
932
|
+
lines.append(f" - {r.summary}{_multiplicity_note(r)}")
|
|
933
|
+
else:
|
|
934
|
+
lines.append(f" - {r.tool}: not run ({r.error})")
|
|
935
|
+
n_tested = int(corrected.get("n_tested") or 0)
|
|
936
|
+
if n_tested:
|
|
937
|
+
keys = list(corrected.get("rejected_result_keys") or [])
|
|
938
|
+
method = corrected.get("method") or "FDR"
|
|
939
|
+
lines.append(
|
|
940
|
+
f" After {method} correction, {len(keys)} of {n_tested} corrected "
|
|
941
|
+
f"test(s) survive" + (":" if keys else " — none.")
|
|
942
|
+
)
|
|
943
|
+
lines.extend(f" * {key}" for key in keys)
|
|
944
|
+
return "\n".join(lines)
|