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,1014 @@
|
|
|
1
|
+
"""M4 — HypothesisTester: verify hypotheses via statistical tests + protocol consistency.
|
|
2
|
+
|
|
3
|
+
M4 is the *gatekeeper* between M3 (hypothesis generation) and the stopping
|
|
4
|
+
decision. It asks two questions for each hypothesis:
|
|
5
|
+
|
|
6
|
+
1. **Statistical support** — do cases that exhibit the hypothesised signal fail
|
|
7
|
+
at a higher rate than cases that do not? M4 now *consumes M2's rigorous
|
|
8
|
+
``stats_results``* (effect size + CI + e-value, FDR-corrected via e-BH) when
|
|
9
|
+
present, deciding SUPPORTED/REFUTED only on a corrected rejection. When M2
|
|
10
|
+
ran without labeled data (no ``stats_results``), it falls back to a rigorous
|
|
11
|
+
clustered-bootstrap :func:`~evalrx.stats.compare` on the extracted
|
|
12
|
+
per-case signal — never a hand-rolled proportion difference.
|
|
13
|
+
|
|
14
|
+
2. **Protocol consistency** — is the hypothesis consistent with what the user
|
|
15
|
+
described in their experiment protocol? Uses keyword-based heuristics by
|
|
16
|
+
default; an optional LLM ``judge=`` runs a critic call for a richer check.
|
|
17
|
+
|
|
18
|
+
**Stopping criteria** (Plan A from the 2026-06-05 meeting):
|
|
19
|
+
:meth:`stopping_criteria_met` returns ``True`` when at least one hypothesis is
|
|
20
|
+
*both* statistically supported *and* protocol-consistent. The loop calls this
|
|
21
|
+
after each M4 pass and breaks when it returns ``True``.
|
|
22
|
+
|
|
23
|
+
Usage::
|
|
24
|
+
|
|
25
|
+
tester = HypothesisTester()
|
|
26
|
+
results = tester.test(hypotheses, stats_report, data, protocol=protocol)
|
|
27
|
+
|
|
28
|
+
if tester.stopping_criteria_met(results, protocol):
|
|
29
|
+
best = tester.best_hypotheses(results)
|
|
30
|
+
# pass best[0].hypothesis to M5
|
|
31
|
+
|
|
32
|
+
# With LLM judge for richer protocol consistency:
|
|
33
|
+
tester = HypothesisTester(judge=gemini)
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import logging
|
|
39
|
+
import re
|
|
40
|
+
from dataclasses import dataclass, field
|
|
41
|
+
from typing import TYPE_CHECKING, Any
|
|
42
|
+
|
|
43
|
+
from evalrx.core.case import CaseBatch, Label
|
|
44
|
+
from evalrx.eval_agent.hypothesis import Hypothesis, HypothesisStatus
|
|
45
|
+
from evalrx.eval_agent.prompts.hypothesis_tester import _CONSISTENCY_PROMPT
|
|
46
|
+
from evalrx.eval_agent.stages.surgery import _extract_per_case_signals
|
|
47
|
+
from evalrx.stats import compare
|
|
48
|
+
|
|
49
|
+
if TYPE_CHECKING:
|
|
50
|
+
from evalrx.analysis.stats_agent import StatsAnalysisReport
|
|
51
|
+
from evalrx.analysis.stats_tools import StatsToolResult
|
|
52
|
+
from evalrx.core.model import Model
|
|
53
|
+
from evalrx.eval_agent.stages.protocol import ExperimentProtocol
|
|
54
|
+
|
|
55
|
+
logger = logging.getLogger(__name__)
|
|
56
|
+
|
|
57
|
+
# M2 tools whose effect is a directional per-mechanism signal (effect>0 = harmful:
|
|
58
|
+
# the signal group fails MORE). These can decide SUPPORTED/REFUTED for a hypothesis.
|
|
59
|
+
_SIGNAL_TOOLS = frozenset({"signal_label_assoc", "bootstrap_diff", "mcnemar_evalue"})
|
|
60
|
+
# Tools that describe the run globally rather than a specific mechanism — used as
|
|
61
|
+
# corroborating evidence only, never sufficient to confirm a specific hypothesis.
|
|
62
|
+
# attention_decoding is a tensor-level OMNIBUS ("do the maps differ anywhere?") —
|
|
63
|
+
# corroborating, not a directional per-mechanism verdict.
|
|
64
|
+
_GLOBAL_TOOLS = frozenset({"single_rate_evalue", "friedman_nemenyi", "rank_corr",
|
|
65
|
+
"attention_decoding"})
|
|
66
|
+
# Descriptive-only tools whose "effect" is an artifact of the batch composition,
|
|
67
|
+
# not a mechanism signal (e.g. single_rate_evalue's rate − p0 on a curated/
|
|
68
|
+
# enriched batch). They are consulted for context but MUST NOT become a
|
|
69
|
+
# hypothesis verdict's headline result — otherwise an inconclusive run reads as
|
|
70
|
+
# "best: FAIL rate 7% vs p0=0.50 → reject", which sounds like a finding and is
|
|
71
|
+
# meaningless once the batch is not a representative sample.
|
|
72
|
+
_DESCRIPTIVE_TOOLS = frozenset({"single_rate_evalue"})
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# Output type
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class HypothesisTestResult:
|
|
81
|
+
"""Result of M4 testing one :class:`~evalrx.eval_agent.hypothesis.Hypothesis`.
|
|
82
|
+
|
|
83
|
+
Attributes:
|
|
84
|
+
hypothesis: The hypothesis under test.
|
|
85
|
+
status: Statistical verdict
|
|
86
|
+
(:class:`~evalrx.eval_agent.hypothesis.HypothesisStatus`).
|
|
87
|
+
test_name: Which test was used (e.g. ``"fail_rate_comparison"``).
|
|
88
|
+
effect_size: Observed fail-rate difference
|
|
89
|
+
(signal group minus control group).
|
|
90
|
+
is_consistent_with_protocol: ``True`` when the hypothesis is relevant
|
|
91
|
+
to the user's experiment protocol.
|
|
92
|
+
confidence: Combined score in [0, 1] — geometric mean
|
|
93
|
+
of evidence gap, sample adequacy, and
|
|
94
|
+
control cleanliness.
|
|
95
|
+
verdict: NL one-liner for human consumption.
|
|
96
|
+
evidence_grade: Strength tier of the deciding evidence:
|
|
97
|
+
``"intervention"`` (paired strategy contrast
|
|
98
|
+
or intervention-derived signal — causal),
|
|
99
|
+
``"observational"`` (signal/label
|
|
100
|
+
association), or ``"none"``.
|
|
101
|
+
evidence: Supporting statistics and group sizes.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
hypothesis: Hypothesis
|
|
105
|
+
status: HypothesisStatus
|
|
106
|
+
test_name: str
|
|
107
|
+
effect_size: float | None
|
|
108
|
+
is_consistent_with_protocol: bool
|
|
109
|
+
confidence: float
|
|
110
|
+
verdict: str
|
|
111
|
+
evidence_grade: str = "observational"
|
|
112
|
+
evidence: dict[str, Any] = field(default_factory=dict)
|
|
113
|
+
# Verbatim protocol-consistency judge I/O (empty when the heuristic check
|
|
114
|
+
# ran or no judge is configured) — forwarded to RunLogger.log_surgery so
|
|
115
|
+
# the M4 judge call is persisted under prompts/ like M1/M2/M3.
|
|
116
|
+
judge_prompt: str = ""
|
|
117
|
+
judge_raw: str = ""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# Evidence-strength ordering for the P4 depth-tiered stopping criterion.
|
|
121
|
+
_GRADE_ORDER = {"none": 0, "observational": 1, "intervention": 2}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _evidence_grade(tool: str, signal: str) -> str:
|
|
125
|
+
"""Grade the deciding evidence: paired contrasts and intervention-derived
|
|
126
|
+
per-case signals are causal ("intervention"); plain analyzer-signal
|
|
127
|
+
associations are correlational ("observational")."""
|
|
128
|
+
if tool in {"mcnemar_evalue", "friedman_nemenyi"}:
|
|
129
|
+
return "intervention"
|
|
130
|
+
if str(signal).startswith("prompt_contrast."):
|
|
131
|
+
return "intervention"
|
|
132
|
+
return "observational"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
# Tester
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
class HypothesisTester:
|
|
140
|
+
"""M4: test hypotheses using statistical methods and protocol consistency.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
judge: Optional LLM for protocol consistency checks.
|
|
144
|
+
Any :class:`~evalrx.core.model.Model` with
|
|
145
|
+
``Capability.GENERATE``. When ``None``, uses a
|
|
146
|
+
keyword-based heuristic instead.
|
|
147
|
+
alpha: Statistical significance level used for the e-value /
|
|
148
|
+
bootstrap-CI decisions inherited from
|
|
149
|
+
:func:`~evalrx.stats.compare` and for confidence
|
|
150
|
+
scaling.
|
|
151
|
+
min_effect: Minimum fail-rate difference to consider a finding
|
|
152
|
+
meaningful (default 0.10 = 10 pp). Used by the rigorous
|
|
153
|
+
fallback path when M2 supplied no ``stats_results``.
|
|
154
|
+
min_evidence_grade: Depth tier required for the stopping criterion
|
|
155
|
+
(P4). ``"observational"`` (default) stops on any
|
|
156
|
+
statistically supported + protocol-consistent hypothesis;
|
|
157
|
+
``"intervention"`` keeps the loop running until a
|
|
158
|
+
hypothesis is verified by intervention-grade evidence
|
|
159
|
+
(paired strategy contrast / intervention-derived signal),
|
|
160
|
+
letting cycle 2 collect the targeted evidence that M3's
|
|
161
|
+
test designs ask for.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
def __init__(
|
|
165
|
+
self,
|
|
166
|
+
judge: "Model | None" = None,
|
|
167
|
+
alpha: float = 0.05,
|
|
168
|
+
min_effect: float = 0.10,
|
|
169
|
+
min_evidence_grade: str = "observational",
|
|
170
|
+
) -> None:
|
|
171
|
+
self._judge = judge
|
|
172
|
+
self.alpha = alpha
|
|
173
|
+
self.min_effect = min_effect
|
|
174
|
+
# Verbatim I/O of the most recent protocol-consistency judge call
|
|
175
|
+
# (captured by _llm_consistency_check, consumed by _test_one).
|
|
176
|
+
self._last_judge_prompt: "str | None" = None
|
|
177
|
+
self._last_judge_raw: "str | None" = None
|
|
178
|
+
if min_evidence_grade not in _GRADE_ORDER:
|
|
179
|
+
raise ValueError(
|
|
180
|
+
f"min_evidence_grade must be one of {sorted(_GRADE_ORDER)}, "
|
|
181
|
+
f"got {min_evidence_grade!r}"
|
|
182
|
+
)
|
|
183
|
+
self.min_evidence_grade = min_evidence_grade
|
|
184
|
+
|
|
185
|
+
def test(
|
|
186
|
+
self,
|
|
187
|
+
hypotheses: list[Hypothesis],
|
|
188
|
+
stats_report: "StatsAnalysisReport",
|
|
189
|
+
data: CaseBatch,
|
|
190
|
+
protocol: "ExperimentProtocol | None" = None,
|
|
191
|
+
) -> list[HypothesisTestResult]:
|
|
192
|
+
"""Test each hypothesis against *data* and *protocol*.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
hypotheses: Hypotheses produced by M3 (DiagnosisAgent).
|
|
196
|
+
stats_report: M2's report — provides ``raw_results`` for
|
|
197
|
+
per-case signal extraction.
|
|
198
|
+
data: Cases to test against (must carry labels for
|
|
199
|
+
fail-rate comparison).
|
|
200
|
+
protocol: Experiment protocol for consistency checks.
|
|
201
|
+
``None`` → all hypotheses are assumed consistent.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
One :class:`HypothesisTestResult` per hypothesis, in the
|
|
205
|
+
same order as *hypotheses*.
|
|
206
|
+
"""
|
|
207
|
+
# The whole cycle shares one stats_report, so either ALL hypotheses take
|
|
208
|
+
# the primary (M2 e-BH-corrected) path or ALL take the label-free
|
|
209
|
+
# fallback. On the fallback path the family is every hypothesis, so each
|
|
210
|
+
# directional verdict must clear a Bonferroni-corrected significance
|
|
211
|
+
# level (alpha / family) — otherwise best-of-N over the fallback would
|
|
212
|
+
# manufacture SUPPORTED verdicts the primary path's e-BH would have
|
|
213
|
+
# blocked.
|
|
214
|
+
on_fallback = not list(getattr(stats_report, "stats_results", None) or [])
|
|
215
|
+
family_size = len(hypotheses) if on_fallback else 1
|
|
216
|
+
|
|
217
|
+
results: list[HypothesisTestResult] = []
|
|
218
|
+
for h in hypotheses:
|
|
219
|
+
result = self._test_one(h, stats_report, data, protocol, family_size)
|
|
220
|
+
results.append(result)
|
|
221
|
+
return results
|
|
222
|
+
|
|
223
|
+
def stopping_criteria_met(
|
|
224
|
+
self,
|
|
225
|
+
test_results: list[HypothesisTestResult],
|
|
226
|
+
protocol: "ExperimentProtocol | None" = None,
|
|
227
|
+
) -> bool:
|
|
228
|
+
"""Return ``True`` when at least one verified, protocol-consistent hypothesis exists.
|
|
229
|
+
|
|
230
|
+
This is the **Plan A stopping criterion** from the 2026-06-05
|
|
231
|
+
architecture meeting: the loop should stop once we have a
|
|
232
|
+
statistically supported hypothesis that addresses what the
|
|
233
|
+
user's protocol was testing — further cycling would only
|
|
234
|
+
rediscover the same root cause.
|
|
235
|
+
|
|
236
|
+
With ``min_evidence_grade="intervention"`` (P4 depth tier), an
|
|
237
|
+
observationally supported hypothesis does NOT stop the loop — the next
|
|
238
|
+
cycle can collect the targeted (intervention) evidence that M3's test
|
|
239
|
+
designs call for.
|
|
240
|
+
"""
|
|
241
|
+
need = _GRADE_ORDER[self.min_evidence_grade]
|
|
242
|
+
return any(
|
|
243
|
+
r.status == HypothesisStatus.SUPPORTED
|
|
244
|
+
and r.is_consistent_with_protocol
|
|
245
|
+
and _GRADE_ORDER.get(r.evidence_grade, 0) >= need
|
|
246
|
+
for r in test_results
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
def best_hypotheses(
|
|
250
|
+
self,
|
|
251
|
+
test_results: list[HypothesisTestResult],
|
|
252
|
+
) -> list[HypothesisTestResult]:
|
|
253
|
+
"""Return verified, protocol-consistent hypotheses, strongest evidence first.
|
|
254
|
+
|
|
255
|
+
Sorted by evidence grade (intervention > observational), then
|
|
256
|
+
confidence. The first element is the candidate to hand to M5.
|
|
257
|
+
"""
|
|
258
|
+
supported = [
|
|
259
|
+
r for r in test_results
|
|
260
|
+
if r.status == HypothesisStatus.SUPPORTED and r.is_consistent_with_protocol
|
|
261
|
+
]
|
|
262
|
+
return sorted(
|
|
263
|
+
supported,
|
|
264
|
+
key=lambda r: (_GRADE_ORDER.get(r.evidence_grade, 0), r.confidence),
|
|
265
|
+
reverse=True,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
# ------------------------------------------------------------------
|
|
269
|
+
# Internal
|
|
270
|
+
# ------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
def _test_one(
|
|
273
|
+
self,
|
|
274
|
+
hypothesis: Hypothesis,
|
|
275
|
+
stats_report: "StatsAnalysisReport",
|
|
276
|
+
data: CaseBatch,
|
|
277
|
+
protocol: "ExperimentProtocol | None",
|
|
278
|
+
family_size: int = 1,
|
|
279
|
+
) -> HypothesisTestResult:
|
|
280
|
+
"""Test a single hypothesis.
|
|
281
|
+
|
|
282
|
+
Primary path: consume M2's rigorous ``stats_results`` (effect + CI +
|
|
283
|
+
e-value, FDR-aware). Fallback (when M2 supplied none, e.g. it ran
|
|
284
|
+
without labeled data): a rigorous ``compare()`` on the extracted
|
|
285
|
+
per-case signal — *not* a hand-rolled proportion difference.
|
|
286
|
+
"""
|
|
287
|
+
stats_results = list(getattr(stats_report, "stats_results", None) or [])
|
|
288
|
+
if stats_results:
|
|
289
|
+
core = self._verdict_from_stats_results(hypothesis, stats_report, stats_results)
|
|
290
|
+
# An explicit M3 test design is a preregistration, not a hint. If
|
|
291
|
+
# that experiment was not run, unrelated signals must not be used
|
|
292
|
+
# to support/refute the claim. The next loop cycle can run it.
|
|
293
|
+
if core.get("evidence_grade") == "none" and not hypothesis.test_design.strip():
|
|
294
|
+
core = self._verdict_fallback(hypothesis, stats_report, data, family_size)
|
|
295
|
+
elif core.get("evidence_grade") == "unmet":
|
|
296
|
+
# The design named evidence nobody measured: stay inconclusive
|
|
297
|
+
# rather than let the generic per-case fallback (any truthy
|
|
298
|
+
# analyzer value vs FAIL) manufacture a verdict about it.
|
|
299
|
+
core["evidence_grade"] = "none"
|
|
300
|
+
elif not hypothesis.test_design.strip():
|
|
301
|
+
core = self._verdict_fallback(hypothesis, stats_report, data, family_size)
|
|
302
|
+
else:
|
|
303
|
+
core = {
|
|
304
|
+
"status": HypothesisStatus.INCONCLUSIVE,
|
|
305
|
+
"effect_size": None,
|
|
306
|
+
"confidence": 0.0,
|
|
307
|
+
"verdict": "The preregistered test was not run; no substitute evidence was used.",
|
|
308
|
+
"test_name": "preregistered_test_unavailable",
|
|
309
|
+
"evidence_grade": "none",
|
|
310
|
+
"evidence": {
|
|
311
|
+
"source": "preregistered_test",
|
|
312
|
+
"test_design": hypothesis.test_design,
|
|
313
|
+
"reason": "no M2 statistical result for the named test",
|
|
314
|
+
},
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
status: HypothesisStatus = core["status"]
|
|
318
|
+
verdict: str = core["verdict"]
|
|
319
|
+
|
|
320
|
+
# ── Protocol consistency check ────────────────────────────────
|
|
321
|
+
self._last_judge_prompt: "str | None" = None
|
|
322
|
+
self._last_judge_raw: "str | None" = None
|
|
323
|
+
is_consistent = self._check_protocol_consistency(
|
|
324
|
+
hypothesis, stats_report, protocol
|
|
325
|
+
)
|
|
326
|
+
if not is_consistent and status == HypothesisStatus.SUPPORTED:
|
|
327
|
+
verdict += " (note: not protocol-consistent — excluded from best candidates)"
|
|
328
|
+
|
|
329
|
+
return HypothesisTestResult(
|
|
330
|
+
hypothesis=hypothesis,
|
|
331
|
+
status=status,
|
|
332
|
+
test_name=core["test_name"],
|
|
333
|
+
effect_size=core["effect_size"],
|
|
334
|
+
is_consistent_with_protocol=is_consistent,
|
|
335
|
+
confidence=core["confidence"],
|
|
336
|
+
verdict=verdict,
|
|
337
|
+
evidence_grade=core.get("evidence_grade", "observational"),
|
|
338
|
+
evidence=core["evidence"],
|
|
339
|
+
judge_prompt=self._last_judge_prompt or "",
|
|
340
|
+
judge_raw=self._last_judge_raw or "",
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
# ------------------------------------------------------------------
|
|
344
|
+
# Primary path: consume M2's rigorous stats_results
|
|
345
|
+
# ------------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
def _verdict_from_stats_results(
|
|
348
|
+
self,
|
|
349
|
+
hypothesis: Hypothesis,
|
|
350
|
+
stats_report: "StatsAnalysisReport",
|
|
351
|
+
stats_results: "list[StatsToolResult]",
|
|
352
|
+
) -> dict[str, Any]:
|
|
353
|
+
"""Derive a verdict from M2's effect-sized, FDR-aware tool results."""
|
|
354
|
+
corrected = getattr(stats_report, "corrected_rejections", None) or {}
|
|
355
|
+
fdr_rejected_keys = set(corrected.get("rejected_result_keys") or [])
|
|
356
|
+
|
|
357
|
+
def _decisive(r: "StatsToolResult") -> bool:
|
|
358
|
+
# A result decides direction only if it rejected H0 AFTER multiplicity
|
|
359
|
+
# correction. correct_results() files every p- or e-valued result into
|
|
360
|
+
# a BH / e-BH family and writes the family's verdict to fdr_corrected;
|
|
361
|
+
# `reject` is deliberately left raw for BH members (loop evidence, see
|
|
362
|
+
# multiplicity.py), so reading it here was the defect: on
|
|
363
|
+
# qwen3.5-2b/bbh_word_sorting a signal with p=0.14 over n_signal=3
|
|
364
|
+
# "supported" a hypothesis because a bootstrap CI over three cases
|
|
365
|
+
# cannot straddle zero. Per result, not per tool name — every
|
|
366
|
+
# signal_label_assoc test shares one name, so a tool-level set said
|
|
367
|
+
# "rejected" for all of them once any one survived.
|
|
368
|
+
if not r.reject:
|
|
369
|
+
return False
|
|
370
|
+
if r.correction_method:
|
|
371
|
+
return bool(r.fdr_corrected)
|
|
372
|
+
if r.e_value is not None:
|
|
373
|
+
# e-valued but never corrected (deferred report): only the
|
|
374
|
+
# report's own survivor list can vouch for it.
|
|
375
|
+
return bool(r.analysis_key) and r.analysis_key in fdr_rejected_keys
|
|
376
|
+
return True
|
|
377
|
+
|
|
378
|
+
# Outcome re-grades ("is the answer correct" by an analyzer's own
|
|
379
|
+
# matcher) are tautological evidence in either direction; M2 isolates
|
|
380
|
+
# them to the sanity lane since 2026-08-19, but a confirm-only run
|
|
381
|
+
# reloads an older M2's results, so they are dropped here as well.
|
|
382
|
+
from evalrx.analysis.stats_tools import _is_outcome_regrade
|
|
383
|
+
|
|
384
|
+
stats_results = [r for r in stats_results
|
|
385
|
+
if not _is_outcome_regrade(_tool_signal(r))]
|
|
386
|
+
relevant, global_res, routed_by = self._select_results(hypothesis, stats_results)
|
|
387
|
+
decisive = [r for r in relevant if (r.effect or 0.0) != 0 and _decisive(r)]
|
|
388
|
+
# A signal's effect > 0 means "the signal group fails MORE". Whether that
|
|
389
|
+
# SUPPORTS the hypothesis depends on what the hypothesis predicted: one
|
|
390
|
+
# that says a signal is LOW / absent / "sits at 0" on failures (minervamath:
|
|
391
|
+
# "step_rollout_value.initial_value ... 5/8 chains sit at 0.0 from step
|
|
392
|
+
# 0") predicts a NEGATIVE effect, and reading every negative effect as
|
|
393
|
+
# "protective -> refuted" rejected hypotheses the data agreed with. The
|
|
394
|
+
# preregistered EXPECTED_ASSOCIATION field is the structured form of
|
|
395
|
+
# that prediction and takes precedence for every routed signal; absent
|
|
396
|
+
# it, the direction is read from the hypothesis text (TEST line first)
|
|
397
|
+
# per signal; absent any cue the classic reading (signal marks the
|
|
398
|
+
# failure) stands.
|
|
399
|
+
expected = str(getattr(hypothesis, "expected_association", "") or "").lower()
|
|
400
|
+
declared_sign = {"lower_on_failures": -1, "higher_on_failures": 1}.get(expected)
|
|
401
|
+
expected_by_result = {
|
|
402
|
+
id(r): (declared_sign if declared_sign is not None
|
|
403
|
+
else _expected_sign(hypothesis, _tool_signal(r)))
|
|
404
|
+
for r in decisive
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
def _agrees(r: "StatsToolResult") -> bool:
|
|
408
|
+
exp = expected_by_result[id(r)]
|
|
409
|
+
eff = float(r.effect or 0.0)
|
|
410
|
+
return eff > 0 if exp is None else (eff > 0) == (exp > 0)
|
|
411
|
+
|
|
412
|
+
supporting = [r for r in decisive if _agrees(r)]
|
|
413
|
+
contradicting = [r for r in decisive if not _agrees(r)]
|
|
414
|
+
|
|
415
|
+
consulted = [r.tool for r in relevant] + [r.tool for r in global_res]
|
|
416
|
+
|
|
417
|
+
if supporting:
|
|
418
|
+
chosen = max(supporting, key=lambda r: abs(r.effect or 0.0))
|
|
419
|
+
status = HypothesisStatus.SUPPORTED
|
|
420
|
+
elif contradicting:
|
|
421
|
+
chosen = max(contradicting, key=lambda r: abs(r.effect or 0.0))
|
|
422
|
+
status = HypothesisStatus.REFUTED
|
|
423
|
+
else:
|
|
424
|
+
# Descriptive tools (single_rate_evalue's rate − p0) must not be the
|
|
425
|
+
# headline: their effect is a batch-composition artifact, so on an
|
|
426
|
+
# enriched batch they would always win max(|effect|) and print a
|
|
427
|
+
# meaningless "vs p0=0.50 → reject" as the verdict.
|
|
428
|
+
pool = [r for r in (relevant or global_res)
|
|
429
|
+
if r.tool not in _DESCRIPTIVE_TOOLS]
|
|
430
|
+
chosen = max(pool, key=lambda r: abs(r.effect or 0.0)) if pool else None
|
|
431
|
+
status = HypothesisStatus.INCONCLUSIVE
|
|
432
|
+
|
|
433
|
+
if routed_by == "test_design_unmet":
|
|
434
|
+
named = sorted(_identifiers(hypothesis.test_design or ""))[:6]
|
|
435
|
+
return {
|
|
436
|
+
"status": HypothesisStatus.INCONCLUSIVE,
|
|
437
|
+
"effect_size": None,
|
|
438
|
+
"confidence": 0.0,
|
|
439
|
+
"verdict": (
|
|
440
|
+
"designated evidence not measured this cycle: the test design names "
|
|
441
|
+
+ (", ".join(named) if named else "signals")
|
|
442
|
+
+ " but M2 has no result for them — not judged on unrelated signals "
|
|
443
|
+
"(next cycle's M1 targets the design)"
|
|
444
|
+
),
|
|
445
|
+
"test_name": "stats_results",
|
|
446
|
+
"evidence_grade": "unmet",
|
|
447
|
+
"evidence": {"source": "m2_stats_results", "consulted_tools": consulted,
|
|
448
|
+
"routed_by": routed_by, "designated": named},
|
|
449
|
+
}
|
|
450
|
+
if chosen is None:
|
|
451
|
+
return {
|
|
452
|
+
"status": HypothesisStatus.INCONCLUSIVE,
|
|
453
|
+
"effect_size": None,
|
|
454
|
+
"confidence": 0.0,
|
|
455
|
+
"verdict": ("No discriminating M2 result for this hypothesis"
|
|
456
|
+
+ (f" (consulted {len(consulted)} tool(s); only "
|
|
457
|
+
"batch-descriptive statistics available)"
|
|
458
|
+
if consulted else "")),
|
|
459
|
+
"test_name": "stats_results",
|
|
460
|
+
"evidence_grade": "none",
|
|
461
|
+
"evidence": {"source": "m2_stats_results", "consulted_tools": consulted,
|
|
462
|
+
"routed_by": routed_by},
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
grade = _evidence_grade(chosen.tool, chosen.config.get("signal", ""))
|
|
466
|
+
confidence = _confidence_from_stat(
|
|
467
|
+
chosen.effect, chosen.ci, chosen.e_value, chosen.underpowered, self.alpha
|
|
468
|
+
)
|
|
469
|
+
# summary is baked at tool-run time, before correction, so it can still
|
|
470
|
+
# say "REJECT H0" on a result BH killed; carry the family verdict along.
|
|
471
|
+
from evalrx.analysis.stats_agent import _multiplicity_note
|
|
472
|
+
|
|
473
|
+
note = _multiplicity_note(chosen).strip()
|
|
474
|
+
note = f" {note}" if note else ""
|
|
475
|
+
exp_sign = _expected_sign(hypothesis, _tool_signal(chosen))
|
|
476
|
+
direction_note = ""
|
|
477
|
+
if exp_sign is not None and status != HypothesisStatus.INCONCLUSIVE:
|
|
478
|
+
direction_note = (
|
|
479
|
+
f" [hypothesis predicts the signal {'HIGHER' if exp_sign > 0 else 'LOWER'}"
|
|
480
|
+
f" on failures; observed effect {float(chosen.effect or 0.0):+.3f} -> "
|
|
481
|
+
+ ("consistent" if status == HypothesisStatus.SUPPORTED else "opposite")
|
|
482
|
+
+ "]"
|
|
483
|
+
)
|
|
484
|
+
if status == HypothesisStatus.INCONCLUSIVE:
|
|
485
|
+
verdict = f"No significant M2 result (best: {chosen.summary}{note})"
|
|
486
|
+
else:
|
|
487
|
+
verdict = f"{chosen.tool}: {chosen.summary}{note}{direction_note}"
|
|
488
|
+
|
|
489
|
+
evidence = {
|
|
490
|
+
"source": "m2_stats_results",
|
|
491
|
+
"chosen_tool": chosen.tool,
|
|
492
|
+
"expected_direction": (None if exp_sign is None
|
|
493
|
+
else ("higher_in_fail" if exp_sign > 0 else "lower_in_fail")),
|
|
494
|
+
"effect_size": chosen.effect,
|
|
495
|
+
"ci": list(chosen.ci) if chosen.ci is not None else None,
|
|
496
|
+
"e_value": chosen.e_value,
|
|
497
|
+
"reject": chosen.reject,
|
|
498
|
+
"underpowered": chosen.underpowered,
|
|
499
|
+
"consulted_tools": consulted,
|
|
500
|
+
"routed_by": routed_by,
|
|
501
|
+
"expected_association": expected or "higher_on_failures",
|
|
502
|
+
"evidence_grade": grade,
|
|
503
|
+
"fdr": corrected,
|
|
504
|
+
}
|
|
505
|
+
return {
|
|
506
|
+
"status": status,
|
|
507
|
+
"effect_size": chosen.effect,
|
|
508
|
+
"confidence": confidence,
|
|
509
|
+
"verdict": verdict,
|
|
510
|
+
"test_name": chosen.tool,
|
|
511
|
+
"evidence_grade": grade,
|
|
512
|
+
"evidence": evidence,
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
def _select_results(
|
|
516
|
+
self,
|
|
517
|
+
hypothesis: Hypothesis,
|
|
518
|
+
stats_results: "list[StatsToolResult]",
|
|
519
|
+
) -> "tuple[list[StatsToolResult], list[StatsToolResult], str]":
|
|
520
|
+
"""Split tool results into (relevant signal tools, global tools, routed_by).
|
|
521
|
+
|
|
522
|
+
Routing priority (P3):
|
|
523
|
+
1. **test_design** — when M3 attached an explicit test design to the
|
|
524
|
+
hypothesis, tools whose signal/strategies overlap it are the
|
|
525
|
+
designated evidence (deterministic routing).
|
|
526
|
+
2. **keywords** — overlap between the hypothesis text and the tool's
|
|
527
|
+
signal key/name.
|
|
528
|
+
3. **shared** — all signal tools as shared evidence (historical
|
|
529
|
+
aggregated-signal behaviour).
|
|
530
|
+
"""
|
|
531
|
+
signal_res = [
|
|
532
|
+
r for r in stats_results
|
|
533
|
+
if r.ok and r.tool in _SIGNAL_TOOLS and r.effect is not None
|
|
534
|
+
]
|
|
535
|
+
global_res = [r for r in stats_results if r.ok and r.tool in _GLOBAL_TOOLS]
|
|
536
|
+
|
|
537
|
+
def _tool_text(r: "StatsToolResult") -> str:
|
|
538
|
+
strategies = " ".join(r.config.get("strategies", []) or [])
|
|
539
|
+
return (f"{r.tool} {r.config.get('signal', '')} {strategies} "
|
|
540
|
+
f"{r.details.get('signal', '')}")
|
|
541
|
+
|
|
542
|
+
# 1. Identifiers first. M3 is told to name analyzers / per-case signals
|
|
543
|
+
# ("cot_faithfulness.drift_away", "perturbation_battery"); those are
|
|
544
|
+
# the underscore/dotted tokens of the design. Matching on them, and
|
|
545
|
+
# only them, keeps a design that says "... the answer ..." from
|
|
546
|
+
# routing to answer_extraction_audit.gold_in_answer_region — which
|
|
547
|
+
# is exactly what happened on qwen3.5-2b/bbh_causal_judgement: six
|
|
548
|
+
# hypotheses about overthinking / counterfactuals / prompt encoding
|
|
549
|
+
# all "refuted" by that one unrelated (protective) signal.
|
|
550
|
+
design_raw = hypothesis.test_design or ""
|
|
551
|
+
design_ids = _identifiers(design_raw)
|
|
552
|
+
if design_ids:
|
|
553
|
+
exact = [r for r in signal_res if _tool_ids(r, level="signal") & design_ids]
|
|
554
|
+
if exact:
|
|
555
|
+
return exact, global_res, "test_design"
|
|
556
|
+
by_analyzer = [r for r in signal_res if _tool_ids(r, level="analyzer") & design_ids]
|
|
557
|
+
if by_analyzer:
|
|
558
|
+
return by_analyzer, global_res, "test_design"
|
|
559
|
+
# The design named EXACT identifiers and none of them is in M2's
|
|
560
|
+
# results. Falling through to fuzzy words here let one generic
|
|
561
|
+
# overlap ("attention") claim the preregistered experiment ran on
|
|
562
|
+
# an unrelated signal; a named-but-unmeasured design is unmet.
|
|
563
|
+
return [], global_res, "test_design_unmet"
|
|
564
|
+
# 2. Prose designs only (no identifiers): words of the design (generic
|
|
565
|
+
# tokens removed) against the SIGNAL's own words — never the tool
|
|
566
|
+
# name ("signal_label_assoc" is in every tool text), never a stop word.
|
|
567
|
+
design_kw = _signal_keywords(design_raw)
|
|
568
|
+
if design_kw:
|
|
569
|
+
designed = [r for r in signal_res if design_kw & _tool_keywords(r)]
|
|
570
|
+
if designed:
|
|
571
|
+
return designed, global_res, "test_design"
|
|
572
|
+
if design_ids or design_kw:
|
|
573
|
+
# The design named evidence M2 did not measure this cycle. Judging
|
|
574
|
+
# the hypothesis on whatever else M2 happened to test is not a
|
|
575
|
+
# test of THIS hypothesis; say so (M1's next cycle targets the
|
|
576
|
+
# design) instead of borrowing an unrelated verdict.
|
|
577
|
+
return [], global_res, "test_design_unmet"
|
|
578
|
+
kw = _signal_keywords(hypothesis.statement + " "
|
|
579
|
+
+ hypothesis.predicted_failure_mode.replace("_", " "))
|
|
580
|
+
stmt_ids = _identifiers(hypothesis.statement)
|
|
581
|
+
matched = [
|
|
582
|
+
r for r in signal_res
|
|
583
|
+
if (_tool_ids(r, level="signal") & stmt_ids)
|
|
584
|
+
or (_tool_ids(r, level="analyzer") & stmt_ids)
|
|
585
|
+
or (kw & _tool_keywords(r))
|
|
586
|
+
]
|
|
587
|
+
if matched:
|
|
588
|
+
return matched, global_res, "keywords"
|
|
589
|
+
return signal_res, global_res, "shared"
|
|
590
|
+
|
|
591
|
+
# ------------------------------------------------------------------
|
|
592
|
+
# Fallback path: rigorous compare() on the extracted per-case signal
|
|
593
|
+
# ------------------------------------------------------------------
|
|
594
|
+
|
|
595
|
+
def _verdict_fallback(
|
|
596
|
+
self,
|
|
597
|
+
hypothesis: Hypothesis,
|
|
598
|
+
stats_report: "StatsAnalysisReport",
|
|
599
|
+
data: CaseBatch,
|
|
600
|
+
family_size: int = 1,
|
|
601
|
+
) -> dict[str, Any]:
|
|
602
|
+
"""Rigorous bootstrap comparison when M2 supplied no stats_results.
|
|
603
|
+
|
|
604
|
+
``family_size`` is the number of hypotheses sharing this fallback cycle;
|
|
605
|
+
a directional verdict must be significant at the Bonferroni-corrected
|
|
606
|
+
level ``alpha / family_size`` (FWER control over the fallback family,
|
|
607
|
+
the unpaired-CI analogue of the primary path's e-BH). At ``family_size=1``
|
|
608
|
+
the correction is a no-op.
|
|
609
|
+
"""
|
|
610
|
+
signal = _extract_per_case_signals(stats_report.raw_results)
|
|
611
|
+
signal_source = "analyzer_per_case"
|
|
612
|
+
if not signal:
|
|
613
|
+
signal = _fallback_case_signals(data, hypothesis)
|
|
614
|
+
signal_source = "case_metadata_fallback"
|
|
615
|
+
|
|
616
|
+
fail_signal: list[int] = []
|
|
617
|
+
fail_control: list[int] = []
|
|
618
|
+
for case in data:
|
|
619
|
+
if getattr(case, "label", None) is None:
|
|
620
|
+
continue
|
|
621
|
+
is_fail = int(case.label == Label.FAIL)
|
|
622
|
+
(fail_signal if signal.get(case.id, False) else fail_control).append(is_fail)
|
|
623
|
+
|
|
624
|
+
if not fail_signal or not fail_control:
|
|
625
|
+
return {
|
|
626
|
+
"status": HypothesisStatus.INCONCLUSIVE,
|
|
627
|
+
"effect_size": None,
|
|
628
|
+
"confidence": 0.0,
|
|
629
|
+
"verdict": "Insufficient labeled data to test statistically.",
|
|
630
|
+
"test_name": "fail_rate_compare",
|
|
631
|
+
"evidence": {
|
|
632
|
+
"source": "fallback_compare",
|
|
633
|
+
"signal_source": signal_source,
|
|
634
|
+
"reason": "no signal-present and signal-absent split available",
|
|
635
|
+
"n_signal": len(fail_signal),
|
|
636
|
+
"n_control": len(fail_control),
|
|
637
|
+
},
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
# Rigorous, effect-sized verdict (clustered bootstrap CI) — replaces the
|
|
641
|
+
# old hand-computed proportion difference + geometric-mean confidence.
|
|
642
|
+
# The CI is taken at the Bonferroni-corrected level so a directional
|
|
643
|
+
# verdict is significant across the whole fallback family, not just on
|
|
644
|
+
# its own (best-of-N over hypotheses would otherwise inflate SUPPORTED).
|
|
645
|
+
corrected_alpha = self.alpha / max(1, family_size)
|
|
646
|
+
sr = compare(
|
|
647
|
+
fail_control, fail_signal, paired=False,
|
|
648
|
+
alpha=corrected_alpha, min_effect=self.min_effect,
|
|
649
|
+
)
|
|
650
|
+
effect_size = round(sr.effect, 4)
|
|
651
|
+
confidence = _confidence_from_stat(
|
|
652
|
+
sr.effect, sr.ci, sr.e_value, sr.underpowered, corrected_alpha)
|
|
653
|
+
# A directional verdict requires BOTH a meaningful effect AND significance
|
|
654
|
+
# (CI excludes 0) at the family-corrected level.
|
|
655
|
+
significant = sr.reject
|
|
656
|
+
|
|
657
|
+
if effect_size > self.min_effect and significant:
|
|
658
|
+
status = HypothesisStatus.SUPPORTED
|
|
659
|
+
verdict = (
|
|
660
|
+
f"Signal group fails {effect_size:.0%} more than control "
|
|
661
|
+
f"({sr.summary()})."
|
|
662
|
+
)
|
|
663
|
+
elif effect_size < -self.min_effect and significant:
|
|
664
|
+
status = HypothesisStatus.REFUTED
|
|
665
|
+
verdict = (
|
|
666
|
+
f"Signal group fails less than control "
|
|
667
|
+
f"(effect={effect_size:.0%}); hypothesis refuted."
|
|
668
|
+
)
|
|
669
|
+
elif abs(effect_size) > self.min_effect and not significant:
|
|
670
|
+
status = HypothesisStatus.INCONCLUSIVE
|
|
671
|
+
verdict = (
|
|
672
|
+
f"Effect {effect_size:.0%} but CI includes 0 at the "
|
|
673
|
+
f"family-corrected level (alpha={corrected_alpha:.4g} over "
|
|
674
|
+
f"{family_size} hypotheses) — best-of-N multiplicity, inconclusive."
|
|
675
|
+
)
|
|
676
|
+
else:
|
|
677
|
+
status = HypothesisStatus.INCONCLUSIVE
|
|
678
|
+
verdict = (
|
|
679
|
+
f"Effect size {effect_size:.0%} below minimum {self.min_effect:.0%}; "
|
|
680
|
+
f"inconclusive."
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
evidence = {
|
|
684
|
+
"source": "fallback_compare",
|
|
685
|
+
"signal_source": signal_source,
|
|
686
|
+
"n_signal": len(fail_signal),
|
|
687
|
+
"n_control": len(fail_control),
|
|
688
|
+
"fail_rate_signal": round(sum(fail_signal) / len(fail_signal), 4),
|
|
689
|
+
"fail_rate_control": round(sum(fail_control) / len(fail_control), 4),
|
|
690
|
+
"effect_size": effect_size,
|
|
691
|
+
"ci": list(sr.ci),
|
|
692
|
+
"underpowered": sr.underpowered,
|
|
693
|
+
"method": sr.method,
|
|
694
|
+
"significant": significant,
|
|
695
|
+
"family_size": family_size,
|
|
696
|
+
"corrected_alpha": corrected_alpha,
|
|
697
|
+
}
|
|
698
|
+
return {
|
|
699
|
+
"status": status,
|
|
700
|
+
"effect_size": effect_size,
|
|
701
|
+
"confidence": confidence,
|
|
702
|
+
"verdict": verdict,
|
|
703
|
+
"test_name": "fail_rate_compare",
|
|
704
|
+
"evidence": evidence,
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
def _check_protocol_consistency(
|
|
708
|
+
self,
|
|
709
|
+
hypothesis: Hypothesis,
|
|
710
|
+
stats_report: "StatsAnalysisReport",
|
|
711
|
+
protocol: "ExperimentProtocol | None",
|
|
712
|
+
) -> bool:
|
|
713
|
+
"""Return True when the hypothesis is relevant to the protocol.
|
|
714
|
+
|
|
715
|
+
Uses LLM critic when a judge is available; falls back to keyword
|
|
716
|
+
overlap between the hypothesis failure mode and the protocol hints.
|
|
717
|
+
"""
|
|
718
|
+
if protocol is None:
|
|
719
|
+
return True
|
|
720
|
+
|
|
721
|
+
if self._judge is not None:
|
|
722
|
+
try:
|
|
723
|
+
return self._llm_consistency_check(hypothesis, protocol)
|
|
724
|
+
except Exception as exc:
|
|
725
|
+
logger.debug("LLM consistency check failed, falling back: %s", exc)
|
|
726
|
+
|
|
727
|
+
return self._heuristic_consistency_check(hypothesis, protocol)
|
|
728
|
+
|
|
729
|
+
def _heuristic_consistency_check(
|
|
730
|
+
self,
|
|
731
|
+
hypothesis: Hypothesis,
|
|
732
|
+
protocol: "ExperimentProtocol",
|
|
733
|
+
) -> bool:
|
|
734
|
+
"""Text overlap between protocol description and hypothesis."""
|
|
735
|
+
proto_text = (protocol.description + " " + protocol.failure_patterns).lower()
|
|
736
|
+
if not proto_text.strip():
|
|
737
|
+
return True # no prior to compare against
|
|
738
|
+
|
|
739
|
+
hyp_text = (
|
|
740
|
+
hypothesis.statement + " "
|
|
741
|
+
+ hypothesis.predicted_failure_mode.replace("_", " ")
|
|
742
|
+
).lower()
|
|
743
|
+
|
|
744
|
+
# Any significant word (6+ chars) from hypothesis in protocol text?
|
|
745
|
+
for word in hyp_text.split():
|
|
746
|
+
if len(word) >= 6 and word in proto_text:
|
|
747
|
+
return True
|
|
748
|
+
|
|
749
|
+
# Any significant word (6+ chars) from protocol in hypothesis text?
|
|
750
|
+
for word in proto_text.split():
|
|
751
|
+
if len(word) >= 6 and word in hyp_text:
|
|
752
|
+
return True
|
|
753
|
+
|
|
754
|
+
return False
|
|
755
|
+
|
|
756
|
+
def _llm_consistency_check(
|
|
757
|
+
self,
|
|
758
|
+
hypothesis: Hypothesis,
|
|
759
|
+
protocol: "ExperimentProtocol",
|
|
760
|
+
) -> bool:
|
|
761
|
+
"""LLM critic: ask judge if the hypothesis is relevant to the protocol."""
|
|
762
|
+
import inspect
|
|
763
|
+
|
|
764
|
+
prompt = _CONSISTENCY_PROMPT.format(
|
|
765
|
+
# M4 decides whether the hypothesis is within the task contract.
|
|
766
|
+
# ``description`` is only its overview; omission of
|
|
767
|
+
# ``failure_patterns`` made explicitly declared mechanisms (for
|
|
768
|
+
# example AudioCaps' paraphrase-stability failure) invisible to
|
|
769
|
+
# the LLM judge and incorrectly protocol-inconsistent.
|
|
770
|
+
protocol_text=protocol.prompt_text(),
|
|
771
|
+
statement=hypothesis.statement,
|
|
772
|
+
failure_mode=hypothesis.predicted_failure_mode,
|
|
773
|
+
)
|
|
774
|
+
sig = inspect.signature(self._judge.generate) # type: ignore[union-attr]
|
|
775
|
+
if "temperature" in sig.parameters:
|
|
776
|
+
raw = self._judge.generate(prompt, temperature=0) # type: ignore[union-attr]
|
|
777
|
+
else:
|
|
778
|
+
raw = self._judge.generate(prompt) # type: ignore[union-attr]
|
|
779
|
+
|
|
780
|
+
# Retain the verbatim judge I/O so the caller can persist it (M4 was
|
|
781
|
+
# the one stage whose judge calls left no record under prompts/).
|
|
782
|
+
self._last_judge_prompt = prompt
|
|
783
|
+
self._last_judge_raw = str(raw)
|
|
784
|
+
|
|
785
|
+
first_line = str(raw).strip().splitlines()[0].upper()
|
|
786
|
+
return first_line.startswith("YES")
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
# ---------------------------------------------------------------------------
|
|
790
|
+
# Module helpers
|
|
791
|
+
# ---------------------------------------------------------------------------
|
|
792
|
+
|
|
793
|
+
def _fallback_case_signals(data: CaseBatch, hypothesis: Hypothesis) -> dict[str, bool]:
|
|
794
|
+
"""Non-label fallback signals when analyzers expose no per-case entries.
|
|
795
|
+
|
|
796
|
+
These are operational signals from discovery/generation, not PASS/FAIL
|
|
797
|
+
labels: empty outputs, discovery exceptions, or UNKNOWN scorer verdicts.
|
|
798
|
+
They keep M4 from becoming vacuous while avoiding label leakage.
|
|
799
|
+
"""
|
|
800
|
+
hyp_text = (
|
|
801
|
+
hypothesis.statement + " " + hypothesis.predicted_failure_mode
|
|
802
|
+
).lower()
|
|
803
|
+
wants_empty = any(k in hyp_text for k in ("empty", "whitespace", "special token"))
|
|
804
|
+
wants_parse = any(k in hyp_text for k in ("parse", "parser", "unparsed", "format"))
|
|
805
|
+
|
|
806
|
+
signal: dict[str, bool] = {}
|
|
807
|
+
for case in data:
|
|
808
|
+
meta = getattr(case, "metadata", {}) or {}
|
|
809
|
+
observed = str(getattr(case, "observed", "") or meta.get("discovery_observed", ""))
|
|
810
|
+
hit = False
|
|
811
|
+
if wants_empty and not observed.strip():
|
|
812
|
+
hit = True
|
|
813
|
+
if wants_parse and meta.get("discovery_label") == "unknown":
|
|
814
|
+
hit = True
|
|
815
|
+
if meta.get("discovery_error"):
|
|
816
|
+
hit = True
|
|
817
|
+
if hit:
|
|
818
|
+
signal[case.id] = True
|
|
819
|
+
return signal
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def _keywords(text: str) -> set[str]:
|
|
823
|
+
"""Significant (4+ char) lowercase word tokens of *text*."""
|
|
824
|
+
return set(re.findall(r"[a-z]{4,}", text.lower()))
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
#: Underscore / dotted names — how M3 refers to analyzers and per-case signals
|
|
828
|
+
#: ("perturbation_battery", "cot_faithfulness.drift_away", "noop_break_rate").
|
|
829
|
+
_IDENT_RE = re.compile(r"[a-z][a-z0-9]*(?:[._][a-z0-9]+)+")
|
|
830
|
+
|
|
831
|
+
#: Words that appear in nearly every signal name / test design and therefore
|
|
832
|
+
#: route nothing: matching on them is what sent unrelated hypotheses to
|
|
833
|
+
#: answer_extraction_audit.gold_in_answer_region ("answer") on causal_judgement.
|
|
834
|
+
_GENERIC_KW = frozenset({
|
|
835
|
+
"answer", "answers", "output", "outputs", "case", "cases", "rate", "rates",
|
|
836
|
+
"final", "score", "scores", "model", "prompt", "prompts", "label", "labels",
|
|
837
|
+
"signal", "signals", "text", "value", "values", "step", "steps", "chain",
|
|
838
|
+
"token", "tokens", "index", "count", "number", "numbers", "flag", "flags",
|
|
839
|
+
"match", "matched", "matches", "held", "split", "test", "tests", "control",
|
|
840
|
+
"cases", "correct", "incorrect", "true", "false", "fail", "fails", "failed",
|
|
841
|
+
"pass", "passed", "with", "without", "that", "this", "from", "into", "only",
|
|
842
|
+
"each", "every", "after", "before", "versus", "against", "across", "between",
|
|
843
|
+
"using", "under", "over", "more", "less", "most", "least", "some", "none",
|
|
844
|
+
"then", "than", "when", "while", "should", "would", "could", "must",
|
|
845
|
+
"still", "also", "both", "same", "other", "their", "there", "these", "those",
|
|
846
|
+
"which", "where", "what", "does", "will", "have", "been", "were", "they",
|
|
847
|
+
"them", "much", "many", "very", "just", "like", "such", "here", "mean",
|
|
848
|
+
"median", "batch", "data", "audit", "battery", "probe", "probes",
|
|
849
|
+
})
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def _identifiers(text: str) -> set[str]:
|
|
853
|
+
"""Underscore / dotted identifiers in *text*, lower-cased (backticks ignored)."""
|
|
854
|
+
return {m.group(0) for m in _IDENT_RE.finditer(str(text or "").lower())}
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def _signal_keywords(text: str) -> set[str]:
|
|
858
|
+
"""Non-generic 4+ letter words of *text* (for word-level routing)."""
|
|
859
|
+
return _keywords(text) - _GENERIC_KW
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def _tool_signal(r: "StatsToolResult") -> str:
|
|
863
|
+
cfg = getattr(r, "config", None) or {}
|
|
864
|
+
det = getattr(r, "details", None) or {}
|
|
865
|
+
return str(cfg.get("signal") or det.get("signal") or "").lower()
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
# Direction cues. Explicit "<higher|lower> on/in FAIL" phrases are read first
|
|
869
|
+
# (the M3 TEST line asks for them); otherwise cue words within a short window
|
|
870
|
+
# after the signal's mention ("sits at 0", "= 0", "absent", "low" -> LOWER on
|
|
871
|
+
# failures; "= 1", "elevated", "present", "high" -> HIGHER). Both / neither ->
|
|
872
|
+
# None, and the classic reading (signal marks the failure: effect > 0 supports)
|
|
873
|
+
# applies.
|
|
874
|
+
_DIR_EXPLICIT_HIGH = re.compile(
|
|
875
|
+
r"\b(?:higher|high|elevated|greater|larger|more|raised|increased|present)\b"
|
|
876
|
+
r"[^.;\n]{0,40}?\b(?:on|in|for|among|across)\s+(?:the\s+)?(?:fail\w*|failing|failures?)"
|
|
877
|
+
r"|\b(?:lower|low|absent|smaller|less|reduced|decreased|missing|zero)\b"
|
|
878
|
+
r"[^.;\n]{0,40}?\b(?:on|in|for|among|across)\s+(?:the\s+)?(?:pass\w*|passing|successes?)")
|
|
879
|
+
_DIR_EXPLICIT_LOW = re.compile(
|
|
880
|
+
r"\b(?:lower|low|absent|smaller|less|reduced|decreased|missing|zero)\b"
|
|
881
|
+
r"[^.;\n]{0,40}?\b(?:on|in|for|among|across)\s+(?:the\s+)?(?:fail\w*|failing|failures?)"
|
|
882
|
+
r"|\b(?:higher|high|elevated|greater|larger|more|raised|increased|present)\b"
|
|
883
|
+
r"[^.;\n]{0,40}?\b(?:on|in|for|among|across)\s+(?:the\s+)?(?:pass\w*|passing|successes?)")
|
|
884
|
+
_DIR_CUE_LOW = re.compile(
|
|
885
|
+
r"(?:^|[\s(`])(?:=|==|is|are|at|of|sits?\s+at|sit\s+at|stuck\s+at|stays?\s+at|"
|
|
886
|
+
r"equals?|reads?|drops?\s+to|falls?\s+to)\s*0(?:\.0+)?(?![.\d%])"
|
|
887
|
+
r"|\b(?:low|lower|absent|zero|null|missing|lacking|drops?|falls?|"
|
|
888
|
+
r"decreas\w*|never\s+fires?|does\s+not\s+fire|=\s*false)\b")
|
|
889
|
+
_DIR_CUE_HIGH = re.compile(
|
|
890
|
+
r"(?:^|[\s(`])(?:=|==|is|are|at|of|sits?\s+at|equals?|reads?)\s*1(?:\.0+)?(?![.\d%])"
|
|
891
|
+
r"|\b(?:high|higher|elevated|present|raised|increas\w*|spikes?|rises?|fires?|"
|
|
892
|
+
r"=\s*true|dominat\w*)\b")
|
|
893
|
+
_DIR_WINDOW = 110
|
|
894
|
+
_DIR_EQ = re.compile(r"[`'\")\]]*\s*(?:==|=|is|equals?)\s*(0(?:\.0+)?|1(?:\.0+)?)(?![.\d%])(?!\s*[-–]\s*\d|\s*(?:to|vs|or)\b)")
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def _expected_sign(hypothesis: "Hypothesis", signal: str) -> "int | None":
|
|
898
|
+
"""+1 if *hypothesis* predicts *signal* HIGHER on failing cases, -1 if LOWER,
|
|
899
|
+
None when it says nothing legible about the direction (the caller then
|
|
900
|
+
falls back to the classic reading: the signal marks the failure)."""
|
|
901
|
+
if not signal:
|
|
902
|
+
return None
|
|
903
|
+
sig = str(signal).lower()
|
|
904
|
+
names = {sig}
|
|
905
|
+
if "." in sig:
|
|
906
|
+
names.add(sig.split(".", 1)[1]) # bare metric name
|
|
907
|
+
texts = [str(getattr(hypothesis, "test_design", "") or ""),
|
|
908
|
+
str(getattr(hypothesis, "statement", "") or "")]
|
|
909
|
+
for text in texts:
|
|
910
|
+
low = text.lower()
|
|
911
|
+
for name in sorted(names, key=len, reverse=True):
|
|
912
|
+
start = 0
|
|
913
|
+
votes_high = votes_low = 0
|
|
914
|
+
while True:
|
|
915
|
+
i = low.find(name, start)
|
|
916
|
+
if i < 0:
|
|
917
|
+
break
|
|
918
|
+
# whole-identifier match only (avoid "majority_correct" inside
|
|
919
|
+
# "majority_correctness" etc.)
|
|
920
|
+
before = low[i - 1] if i > 0 else " "
|
|
921
|
+
after_i = i + len(name)
|
|
922
|
+
after = low[after_i] if after_i < len(low) else " "
|
|
923
|
+
if before.isalnum() or before == "_" or after.isalnum() or after == "_":
|
|
924
|
+
start = i + 1
|
|
925
|
+
continue
|
|
926
|
+
window = low[max(0, i - 40):after_i + _DIR_WINDOW]
|
|
927
|
+
tail = low[after_i:after_i + _DIR_WINDOW]
|
|
928
|
+
# an equality right after the name ("`x` = 0", "x == 1", "x=0")
|
|
929
|
+
# is decisive on its own and must not be diluted by a second
|
|
930
|
+
# identifier's equality further along the sentence
|
|
931
|
+
near = _DIR_EQ.match(low[after_i:after_i + 16])
|
|
932
|
+
if near:
|
|
933
|
+
if near.group(1).startswith("0"):
|
|
934
|
+
votes_low += 1
|
|
935
|
+
else:
|
|
936
|
+
votes_high += 1
|
|
937
|
+
elif _DIR_EXPLICIT_HIGH.search(window):
|
|
938
|
+
votes_high += 1
|
|
939
|
+
elif _DIR_EXPLICIT_LOW.search(window):
|
|
940
|
+
votes_low += 1
|
|
941
|
+
else:
|
|
942
|
+
lo = bool(_DIR_CUE_LOW.search(tail))
|
|
943
|
+
hi = bool(_DIR_CUE_HIGH.search(tail))
|
|
944
|
+
if lo and not hi:
|
|
945
|
+
votes_low += 1
|
|
946
|
+
elif hi and not lo:
|
|
947
|
+
votes_high += 1
|
|
948
|
+
start = after_i
|
|
949
|
+
if votes_high and not votes_low:
|
|
950
|
+
return 1
|
|
951
|
+
if votes_low and not votes_high:
|
|
952
|
+
return -1
|
|
953
|
+
if votes_high and votes_low:
|
|
954
|
+
return None
|
|
955
|
+
return None
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def _tool_ids(r: "StatsToolResult", *, level: str = "signal") -> set[str]:
|
|
959
|
+
"""Identifiers a tool result answers to.
|
|
960
|
+
|
|
961
|
+
``level="signal"``: the full ``analyzer.signal`` key, the bare signal name
|
|
962
|
+
and the strategy names; ``level="analyzer"``: the analyzer prefix only.
|
|
963
|
+
"""
|
|
964
|
+
sig = _tool_signal(r)
|
|
965
|
+
cfg = getattr(r, "config", None) or {}
|
|
966
|
+
ids: set[str] = set()
|
|
967
|
+
if level == "analyzer":
|
|
968
|
+
if sig:
|
|
969
|
+
ids.add(sig.split(".", 1)[0])
|
|
970
|
+
return ids
|
|
971
|
+
if sig:
|
|
972
|
+
ids.add(sig)
|
|
973
|
+
parts = sig.split(".", 1)
|
|
974
|
+
if len(parts) > 1 and parts[1]:
|
|
975
|
+
ids.add(parts[1])
|
|
976
|
+
for strategy in cfg.get("strategies", []) or []:
|
|
977
|
+
ids.add(str(strategy).lower())
|
|
978
|
+
return ids
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
def _tool_keywords(r: "StatsToolResult") -> set[str]:
|
|
982
|
+
"""Non-generic words of the tool's signal key + strategies (NOT the tool name)."""
|
|
983
|
+
cfg = getattr(r, "config", None) or {}
|
|
984
|
+
words = " ".join([_tool_signal(r)] + [str(x) for x in (cfg.get("strategies", []) or [])])
|
|
985
|
+
return _signal_keywords(words.replace("_", " ").replace(".", " "))
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
def _confidence_from_stat(
|
|
989
|
+
effect: float | None,
|
|
990
|
+
ci: tuple[float, float] | None,
|
|
991
|
+
e_value: float | None,
|
|
992
|
+
underpowered: bool,
|
|
993
|
+
alpha: float = 0.05,
|
|
994
|
+
) -> float:
|
|
995
|
+
"""Map a rigorous statistical verdict to a confidence in [0, 1].
|
|
996
|
+
|
|
997
|
+
Priority: an e-value (anytime-valid) over a CI. An e-value at the rejection
|
|
998
|
+
threshold ``1/alpha`` maps to 1.0; a CI that excludes 0 scales by how far it
|
|
999
|
+
sits from 0 relative to its width. ``underpowered`` halves the score.
|
|
1000
|
+
"""
|
|
1001
|
+
c = 0.0
|
|
1002
|
+
if e_value is not None:
|
|
1003
|
+
c = min(1.0, e_value * alpha) # e_value / (1/alpha)
|
|
1004
|
+
elif ci is not None:
|
|
1005
|
+
lo, hi = ci
|
|
1006
|
+
width = (hi - lo) or 1e-9
|
|
1007
|
+
if lo <= 0 <= hi:
|
|
1008
|
+
# CI includes 0 → not significant; small, capped contribution.
|
|
1009
|
+
c = min(0.49, abs(effect or 0.0) / width * 0.5)
|
|
1010
|
+
else:
|
|
1011
|
+
c = min(1.0, abs(effect or 0.0) / (abs(effect or 0.0) + width))
|
|
1012
|
+
if underpowered:
|
|
1013
|
+
c *= 0.5
|
|
1014
|
+
return round(c, 3)
|