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
evalrx/contract/m2.py
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""M2 — analysis: join measurements to outcomes, test which signals track failure.
|
|
2
|
+
|
|
3
|
+
M2 answers *what correlates*, never *why*. That boundary is load-bearing: an
|
|
4
|
+
in-sample association rendered with the same language as a corrected verdict is
|
|
5
|
+
the single most damaging mistake this pipeline can make, so the descriptive and
|
|
6
|
+
confirmatory states are distinguishable here at the type level
|
|
7
|
+
(:attr:`StatsReportWire.descriptive_only`), not by convention.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Literal
|
|
13
|
+
|
|
14
|
+
from pydantic import Field, field_validator, model_validator
|
|
15
|
+
|
|
16
|
+
from evalrx.contract.common import (
|
|
17
|
+
ArtifactRef,
|
|
18
|
+
CaseBatchRef,
|
|
19
|
+
ExternalRef,
|
|
20
|
+
JoinReport,
|
|
21
|
+
StageEnvelope,
|
|
22
|
+
WireModel,
|
|
23
|
+
)
|
|
24
|
+
from evalrx.contract.m1 import ProtocolWire
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Input
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
class AnalysisInput(WireModel):
|
|
31
|
+
"""M2 needs BOTH halves of the join: M1's numbers and the cases' labels.
|
|
32
|
+
|
|
33
|
+
``data`` is not optional in practice — without labels there is nothing to
|
|
34
|
+
correlate against and M2 degrades to threshold rules only.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
results_ref: str = Field(description="Path to the M1 ProbeOutput this analyzes.")
|
|
38
|
+
data: CaseBatchRef
|
|
39
|
+
model_name: str
|
|
40
|
+
protocol: ProtocolWire | None = None
|
|
41
|
+
confirmatory: bool = Field(
|
|
42
|
+
default=True,
|
|
43
|
+
description="False defers the e-BH validity verdict to the confirm phase; "
|
|
44
|
+
"the report then carries effect sizes and charts but NO reject decision.",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# Output
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
class AnalysisFindingWire(WireModel):
|
|
53
|
+
"""One threshold-rule violation. The no-judge baseline signal."""
|
|
54
|
+
|
|
55
|
+
analyzer: str
|
|
56
|
+
metric: str
|
|
57
|
+
value: float
|
|
58
|
+
threshold: float
|
|
59
|
+
direction: Literal["above", "below"]
|
|
60
|
+
severity: Literal["high", "medium", "low"]
|
|
61
|
+
message: str
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class StatsToolResultWire(WireModel):
|
|
65
|
+
"""One statistical test's verdict.
|
|
66
|
+
|
|
67
|
+
``reject`` is the RAW, uncorrected decision and must never drive a verdict on
|
|
68
|
+
its own. ``fdr_corrected`` is the family-level decision after multiplicity
|
|
69
|
+
control and is the only field a downstream verdict may read. They are kept as
|
|
70
|
+
separate fields precisely because collapsing them once let a p=0.14 result on
|
|
71
|
+
n=3 "support" a hypothesis.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
tool: str = Field(description="e.g. signal_label_assoc / mcnemar_evalue / bootstrap_diff / rank_corr")
|
|
75
|
+
measured: str | None = Field(
|
|
76
|
+
default=None,
|
|
77
|
+
description="WHAT this result is about, in words, and unique within the report — "
|
|
78
|
+
"the label a chart axis or a table row should carry.\n\n"
|
|
79
|
+
"`tool` is the procedure, not the subject, and using it as a label "
|
|
80
|
+
"put two rows reading 'Mcnemar evalue' on a chart with no way to "
|
|
81
|
+
"tell them apart. `config['signal']` is the subject but is a machine "
|
|
82
|
+
"name, and is absent on paired tools entirely. Neither can label a "
|
|
83
|
+
"row on its own, so the producer states the label here.",
|
|
84
|
+
)
|
|
85
|
+
means: str | None = Field(
|
|
86
|
+
default=None,
|
|
87
|
+
description="What the measured quantity IS, in one sentence, taken from the "
|
|
88
|
+
"producing analyzer's `signal_docs`. Null means the analyzer did not "
|
|
89
|
+
"document that metric — render it as undocumented rather than "
|
|
90
|
+
"inventing a gloss, because a plausible wrong explanation of a "
|
|
91
|
+
"statistic is worse than an admitted missing one.",
|
|
92
|
+
)
|
|
93
|
+
config: dict[str, Any] = Field(default_factory=dict, description="Includes 'signal' — the routing key.")
|
|
94
|
+
ok: bool
|
|
95
|
+
error: str | None = None
|
|
96
|
+
|
|
97
|
+
effect: float | None = None
|
|
98
|
+
ci: tuple[float, float] | None = None
|
|
99
|
+
p_value: float | None = None
|
|
100
|
+
e_value: float | None = None
|
|
101
|
+
underpowered: bool = False
|
|
102
|
+
|
|
103
|
+
reject: bool = Field(default=False, description="RAW per-test decision. Not a verdict.")
|
|
104
|
+
fdr_corrected: bool | None = Field(
|
|
105
|
+
default=None,
|
|
106
|
+
description="Family-level decision after correction. None = correction not yet run "
|
|
107
|
+
"(deferred descriptive phase) — which is NOT the same as False.",
|
|
108
|
+
)
|
|
109
|
+
correction_method: str | None = Field(default=None, description="e.g. 'bh' / 'ebh'.")
|
|
110
|
+
correction_family: str | None = None
|
|
111
|
+
analysis_key: str | None = Field(
|
|
112
|
+
default=None, description="Stable per-result key, e.g. 'signal_label_assoc:attention.entropy'. "
|
|
113
|
+
"Required for correction: one tool NAME covers many tests."
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
raw_reject: bool | None = Field(
|
|
117
|
+
default=None, description="Pre-correction decision, kept for audit alongside `reject`."
|
|
118
|
+
)
|
|
119
|
+
figure_path: str | None = None
|
|
120
|
+
|
|
121
|
+
n_signal: int | None = Field(default=None, description="Cases in the signal-present group.")
|
|
122
|
+
n_control: int | None = Field(default=None, description="Cases in the signal-absent group.")
|
|
123
|
+
n_measured: int | None = Field(
|
|
124
|
+
default=None,
|
|
125
|
+
description="Cases the producing analyzer actually measured. Analyzers measure "
|
|
126
|
+
"every case unless capped (an analyzer's max_cases or the probe "
|
|
127
|
+
"agent's max_cases_per_analyzer), so compare this with the batch size.",
|
|
128
|
+
)
|
|
129
|
+
n_imputed_absent: int | None = Field(
|
|
130
|
+
default=None,
|
|
131
|
+
description="Labeled cases with no measurement that were counted as signal-ABSENT. "
|
|
132
|
+
"Valid for a genuinely sparse flag that only lists where it fired; invalid "
|
|
133
|
+
"when the analyzer simply never looked. Nothing in the value distinguishes "
|
|
134
|
+
"the two, so the count must travel with the result.",
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
summary: str = ""
|
|
138
|
+
details: dict[str, Any] = Field(
|
|
139
|
+
default_factory=dict, description="fail rates / permutation_p / CI internals / ..."
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def imputation_share(self) -> float | None:
|
|
144
|
+
"""Share of the tested sample that was never measured.
|
|
145
|
+
|
|
146
|
+
Observed live (bbh_tracking7): ``step_rollout_value.recoverable`` was
|
|
147
|
+
measured on 8 cases, tested on 125, and survived e-BH — 94% of its
|
|
148
|
+
control group is imputed absence. A reader cannot see that from the
|
|
149
|
+
effect size, the CI, or the corrected flag.
|
|
150
|
+
"""
|
|
151
|
+
total = (self.n_signal or 0) + (self.n_control or 0)
|
|
152
|
+
if not total or self.n_imputed_absent is None:
|
|
153
|
+
return None
|
|
154
|
+
return self.n_imputed_absent / total
|
|
155
|
+
|
|
156
|
+
def is_mostly_imputed(self, threshold: float = 0.5) -> bool:
|
|
157
|
+
share = self.imputation_share
|
|
158
|
+
return share is not None and share > threshold
|
|
159
|
+
|
|
160
|
+
def is_decisive(self, report_survivors: set[str] | None = None) -> bool:
|
|
161
|
+
"""Whether this result may decide a hypothesis's direction.
|
|
162
|
+
|
|
163
|
+
Mirrors the M4 gate: raw rejection is necessary but never sufficient.
|
|
164
|
+
"""
|
|
165
|
+
if not self.reject:
|
|
166
|
+
return False
|
|
167
|
+
if self.correction_method:
|
|
168
|
+
return bool(self.fdr_corrected)
|
|
169
|
+
if self.e_value is not None:
|
|
170
|
+
return bool(self.analysis_key) and self.analysis_key in (report_survivors or set())
|
|
171
|
+
return True
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
#: Legacy spellings this contract shipped before it was checked against the
|
|
175
|
+
#: producer. ``evalrx.stats.multiplicity`` writes the hyphenated names and
|
|
176
|
+
#: has a fourth value the contract had no member for.
|
|
177
|
+
_LEGACY_CORRECTION_METHODS = {"ebh": "e-BH", "bh": "BH"}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class CorrectedRejections(WireModel):
|
|
181
|
+
"""Family-level multiplicity control across every tested signal.
|
|
182
|
+
|
|
183
|
+
Testing 40 signals at alpha=0.05 yields ~2 "significant" results from luck
|
|
184
|
+
alone. Without this the screen is not a screen.
|
|
185
|
+
|
|
186
|
+
``mixed-BH/e-BH`` is its own member rather than being folded into either
|
|
187
|
+
neighbour: it says the family contained both p-values and e-values, which
|
|
188
|
+
carry different validity guarantees, and a reader weighing the verdict needs
|
|
189
|
+
to know that the guarantee is the weaker of the two.
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
method: Literal["e-BH", "BH", "mixed-BH/e-BH", "none"] = "none"
|
|
193
|
+
alpha: float = 0.05
|
|
194
|
+
deferred: bool = Field(
|
|
195
|
+
default=False, description="True while the analysis phase withholds the verdict."
|
|
196
|
+
)
|
|
197
|
+
n_tested: int = 0
|
|
198
|
+
rejected_result_keys: list[str] = Field(
|
|
199
|
+
default_factory=list, description="analysis_key values that survived. The source of truth."
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
@field_validator("method", mode="before")
|
|
203
|
+
@classmethod
|
|
204
|
+
def _normalise_method(cls, v: Any) -> Any:
|
|
205
|
+
if isinstance(v, str):
|
|
206
|
+
return _LEGACY_CORRECTION_METHODS.get(v.lower(), v)
|
|
207
|
+
return v
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class StatsReportWire(StageEnvelope):
|
|
211
|
+
"""M2's serialized report.
|
|
212
|
+
|
|
213
|
+
Two field groups with different authority: everything above
|
|
214
|
+
``descriptive_only`` is computed by code and is authoritative; ``conclusion``
|
|
215
|
+
and ``evidence_chain`` are written by the judge and are presentation only.
|
|
216
|
+
Empty narrative is valid and must never be backfilled with an invented
|
|
217
|
+
summary. The LLM may not write a number, and keeping the groups visibly
|
|
218
|
+
apart is what makes that reviewable.
|
|
219
|
+
|
|
220
|
+
Absent by design: the judge's prompt and raw response (already
|
|
221
|
+
``prompts/c<cycle>_m2_analysis.{prompt,response}.txt``), the tool-selection
|
|
222
|
+
plan (already ``artifacts/c<cycle>_m2_stats_plan.json``), the model name and
|
|
223
|
+
protocol (both in the run config and in :class:`AnalysisInput`), and the
|
|
224
|
+
threshold-rule narrative (a rendering of ``findings``).
|
|
225
|
+
"""
|
|
226
|
+
|
|
227
|
+
findings: list[AnalysisFindingWire] = Field(default_factory=list)
|
|
228
|
+
stats_tool: Literal["threshold_rules", "llm_guided", "selected_tools", "generated"] = "threshold_rules"
|
|
229
|
+
stats_results: list[StatsToolResultWire] | ExternalRef = Field(default_factory=list)
|
|
230
|
+
corrected_rejections: CorrectedRejections | ExternalRef = Field(default_factory=CorrectedRejections)
|
|
231
|
+
figures: list[ArtifactRef] = Field(default_factory=list)
|
|
232
|
+
descriptive_only: bool = Field(
|
|
233
|
+
default=False,
|
|
234
|
+
description="True = effect sizes and charts only, e-BH deferred. A reader MUST NOT "
|
|
235
|
+
"render supported/rejected language while this is true.",
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
conclusion: str = Field(default="", description="Judge-written. Presentation only.")
|
|
239
|
+
evidence_chain: list[str] = Field(default_factory=list, description="Judge-written derivation.")
|
|
240
|
+
llm_fallback_reason: str = Field(
|
|
241
|
+
default="",
|
|
242
|
+
description="Non-empty only when the judge path was attempted AND raised. Not recoverable "
|
|
243
|
+
"from the prompt files: a fallback still writes both of them.",
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
raw_results_ref: str | None = Field(
|
|
247
|
+
default=None,
|
|
248
|
+
description="Path to the M1 ProbeOutput. The in-memory report carries the Result objects "
|
|
249
|
+
"themselves, but to_dict() drops them — M4/M5 still need per-case signals, so "
|
|
250
|
+
"the serialized contract carries a reference instead of pretending they survive.",
|
|
251
|
+
)
|
|
252
|
+
joins: list[JoinReport] = Field(
|
|
253
|
+
default_factory=list, description="Signal<->label join health. Zero coverage is a plumbing failure."
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
@property
|
|
257
|
+
def severity(self) -> Literal["high", "medium", "low", "none"]:
|
|
258
|
+
"""Worst flagged finding. Derived — a stored copy can contradict the list."""
|
|
259
|
+
for level in ("high", "medium", "low"):
|
|
260
|
+
if any(f.severity == level for f in self.findings):
|
|
261
|
+
return level # type: ignore[return-value]
|
|
262
|
+
return "none"
|
|
263
|
+
|
|
264
|
+
@model_validator(mode="after")
|
|
265
|
+
def _measured_labels_are_distinct(self) -> "StatsReportWire":
|
|
266
|
+
"""Two rows labelled the same are two rows a reader cannot tell apart.
|
|
267
|
+
|
|
268
|
+
Deduplicated rather than rejected: a colliding label is a presentation
|
|
269
|
+
fault, and dropping the whole M2 payload over one would lose the
|
|
270
|
+
statistics too. The suffix is the tool, then an ordinal — enough to
|
|
271
|
+
separate them without inventing meaning.
|
|
272
|
+
"""
|
|
273
|
+
rows = self.stats_results
|
|
274
|
+
if not isinstance(rows, list):
|
|
275
|
+
return self
|
|
276
|
+
seen: dict[str, int] = {}
|
|
277
|
+
for r in rows:
|
|
278
|
+
if not r.measured:
|
|
279
|
+
continue
|
|
280
|
+
if r.measured in seen:
|
|
281
|
+
seen[r.measured] += 1
|
|
282
|
+
r.measured = f"{r.measured} ({r.tool}, {seen[r.measured]})"
|
|
283
|
+
else:
|
|
284
|
+
seen[r.measured] = 1
|
|
285
|
+
return self
|
|
286
|
+
|
|
287
|
+
@model_validator(mode="after")
|
|
288
|
+
def _descriptive_has_no_verdict(self) -> "StatsReportWire":
|
|
289
|
+
corr = self.corrected_rejections
|
|
290
|
+
if self.descriptive_only and isinstance(corr, CorrectedRejections):
|
|
291
|
+
if corr.rejected_result_keys and not corr.deferred:
|
|
292
|
+
raise ValueError(
|
|
293
|
+
"descriptive_only=True but corrected_rejections carries survivors; "
|
|
294
|
+
"a descriptive report must not ship a validity verdict"
|
|
295
|
+
)
|
|
296
|
+
return self
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class ExploreContextWire(WireModel):
|
|
300
|
+
"""Descriptive EDA notes from the optional explore side-path.
|
|
301
|
+
|
|
302
|
+
Never authoritative. It enters the M3 prompt and the dashboard and nothing
|
|
303
|
+
else — not M2's tested family, not M4, not the fix gate. It shapes WHICH
|
|
304
|
+
hypotheses get proposed, never WHETHER one is true.
|
|
305
|
+
"""
|
|
306
|
+
|
|
307
|
+
observations: list[str] = Field(default_factory=list)
|
|
308
|
+
charts: list[dict[str, Any]] = Field(default_factory=list)
|
|
309
|
+
caveats: list[str] = Field(default_factory=list)
|
|
310
|
+
source: str = "lambda_explorer"
|
|
311
|
+
authoritative: Literal[False] = False
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
__all__ = [
|
|
315
|
+
"AnalysisInput", "AnalysisFindingWire", "StatsToolResultWire",
|
|
316
|
+
"CorrectedRejections", "StatsReportWire", "ExploreContextWire",
|
|
317
|
+
]
|
evalrx/contract/m3.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""M3 — diagnosis: turn correlations into falsifiable mechanism claims.
|
|
2
|
+
|
|
3
|
+
This is the one stage where the LLM produces the payload rather than the prose.
|
|
4
|
+
That is exactly why its output must be machine-checkable: a hypothesis carries a
|
|
5
|
+
``test_design`` naming the signal that would confirm or refute it, so M4 can
|
|
6
|
+
route deterministically instead of re-reading the sentence.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from pydantic import Field, field_validator
|
|
15
|
+
|
|
16
|
+
from evalrx.contract.common import HypothesisStatus, StageEnvelope, WireModel
|
|
17
|
+
from evalrx.contract.m2 import ExploreContextWire
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# Input
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
class DiagnosisInput(WireModel):
|
|
24
|
+
"""M3 reads M2's report — both the numbers and the narrative."""
|
|
25
|
+
|
|
26
|
+
analysis_ref: str = Field(description="Path to the M2 StatsReportWire.")
|
|
27
|
+
prior_cycles: list[dict[str, Any]] = Field(
|
|
28
|
+
default_factory=list, description="Summaries of earlier cycles, so the judge avoids re-proposing."
|
|
29
|
+
)
|
|
30
|
+
explore_context: ExploreContextWire | None = None
|
|
31
|
+
failure_modes: list[str] = Field(
|
|
32
|
+
default_factory=list, description="Descriptive-only clustering tags."
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# Output
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
#: A ``<analyzer>.<metric>`` reference — the form M4 resolves against M2's signals.
|
|
41
|
+
_SIGNAL_REF = re.compile(r"\b[a-z_][a-z0-9_]*\.[a-z_][a-z0-9_]*\b")
|
|
42
|
+
|
|
43
|
+
#: Non-signal test designs M4 knows how to route. Extend this together with the
|
|
44
|
+
#: router; a directive listed here and unhandled there is worse than a rejection.
|
|
45
|
+
TEST_DESIGN_DIRECTIVES = frozenset({
|
|
46
|
+
"prompt_contrast",
|
|
47
|
+
"analyzer_params",
|
|
48
|
+
"strategy_contrast",
|
|
49
|
+
"paired_rerun",
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class HypothesisWire(WireModel):
|
|
54
|
+
"""One falsifiable claim about *why* the model fails.
|
|
55
|
+
|
|
56
|
+
``statement`` is the mechanism; ``test_design`` is the machine-readable
|
|
57
|
+
commitment to how it could be wrong. A hypothesis without the latter cannot
|
|
58
|
+
be tested and is therefore not a hypothesis — M4 has nothing to route on and
|
|
59
|
+
M1 has nothing to re-probe.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
id: str = Field(min_length=1, description="Stable id. Never slugify the statement and assume uniqueness.")
|
|
63
|
+
statement: str = Field(min_length=10, description="Technical mechanism claim.")
|
|
64
|
+
plain_statement: str = Field(
|
|
65
|
+
default="",
|
|
66
|
+
description="The SAME claim in one everyday sentence, for a reader who runs "
|
|
67
|
+
"evaluations and does no statistics. Not a second, softer claim — a "
|
|
68
|
+
"second rendering of this one, checked host-side against a jargon "
|
|
69
|
+
"list before it is accepted (see analysis.plain_language). Empty "
|
|
70
|
+
"when the producer wrote none; a consumer should then show "
|
|
71
|
+
"`statement` rather than paraphrase it into something the run "
|
|
72
|
+
"never said.",
|
|
73
|
+
)
|
|
74
|
+
target_model: str
|
|
75
|
+
predicted_failure_mode: str = Field(min_length=2)
|
|
76
|
+
test_design: str = Field(
|
|
77
|
+
default="",
|
|
78
|
+
description="Signal or contrast that would decide this, e.g. 'attention.image_token_ratio' "
|
|
79
|
+
"or 'prompt_contrast describe_first'. M4 routes on it; M1 re-probes on it. "
|
|
80
|
+
"EMPTY means the judge proposed none — see `is_routable`.",
|
|
81
|
+
)
|
|
82
|
+
status: HypothesisStatus = HypothesisStatus.PROPOSED
|
|
83
|
+
parent_id: str | None = Field(default=None, description="Set when mutated from an earlier hypothesis.")
|
|
84
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def is_routable(self) -> bool:
|
|
88
|
+
"""Whether M4 can resolve this design against measured signals.
|
|
89
|
+
|
|
90
|
+
Mirrors what ``hypothesis_tester`` actually extracts — an identifier
|
|
91
|
+
anywhere in the text, or one of the directives — rather than demanding a
|
|
92
|
+
shape. The strict version required the string to BE a bare
|
|
93
|
+
``<analyzer>.<metric>`` or to START with a directive, and a strong judge
|
|
94
|
+
does not write like that: on the Music-AVQA run, Opus at high effort
|
|
95
|
+
produced three designs that named their analyzers and metrics inside a
|
|
96
|
+
paragraph of interventional protocol. M4 routed all three
|
|
97
|
+
(``routed_by="test_design"``, two at INTERVENTION grade) and the contract
|
|
98
|
+
rejected the whole M3 payload for the formatting.
|
|
99
|
+
|
|
100
|
+
A validator stricter than the consumer it protects does not prevent
|
|
101
|
+
anything; it just discards good work. So routability is now REPORTED, and
|
|
102
|
+
the three states stay distinguishable: empty (no test proposed),
|
|
103
|
+
non-empty but nothing resolvable (a human can act on it, M4 cannot), and
|
|
104
|
+
routable.
|
|
105
|
+
"""
|
|
106
|
+
text = self.test_design.strip()
|
|
107
|
+
if not text:
|
|
108
|
+
return False
|
|
109
|
+
if _SIGNAL_REF.search(text.lower()):
|
|
110
|
+
return True
|
|
111
|
+
return any(d in text for d in TEST_DESIGN_DIRECTIVES)
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def is_proposed(self) -> bool:
|
|
115
|
+
"""Whether the judge proposed a test at all, routable or not."""
|
|
116
|
+
return bool(self.test_design.strip())
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class DiagnosisOutput(StageEnvelope):
|
|
120
|
+
"""M3's serialized output: the hypotheses, and nothing that is already on disk.
|
|
121
|
+
|
|
122
|
+
Zero hypotheses is a completed, empty result (state EMPTY or ABSTAINED) — not
|
|
123
|
+
an error. Rendering it red teaches readers to distrust a correct abstention.
|
|
124
|
+
|
|
125
|
+
Judge I/O is deliberately absent. M3 is the one stage an LLM writes the
|
|
126
|
+
payload for, so its prompt and verbatim response do have to be recoverable —
|
|
127
|
+
but they are already persisted per cycle as
|
|
128
|
+
``prompts/c<cycle>_m3_diagnosis.{prompt,response}.txt``, and the findings the
|
|
129
|
+
judge was shown are the M2 report named by :attr:`DiagnosisInput.analysis_ref`.
|
|
130
|
+
Carrying either inline here would be a second copy that can disagree with the
|
|
131
|
+
first.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
hypotheses: list[HypothesisWire] = Field(default_factory=list)
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def untestable(self) -> list[str]:
|
|
138
|
+
"""Ids of hypotheses carrying no test_design at all.
|
|
139
|
+
|
|
140
|
+
These come back INCONCLUSIVE no matter how much evidence the next cycle
|
|
141
|
+
gathers, because nothing was ever named that could decide them.
|
|
142
|
+
"""
|
|
143
|
+
return [h.id for h in self.hypotheses if not h.is_proposed]
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def unroutable(self) -> list[str]:
|
|
147
|
+
"""Ids whose design a human can act on but M4 cannot resolve.
|
|
148
|
+
|
|
149
|
+
Distinct from :attr:`untestable`: a test WAS proposed, it just names no
|
|
150
|
+
measured signal, so it is work for the next M1 cycle rather than a claim
|
|
151
|
+
with no falsifier. Collapsing the two would report a real experimental
|
|
152
|
+
plan as an empty one.
|
|
153
|
+
"""
|
|
154
|
+
return [h.id for h in self.hypotheses if h.is_proposed and not h.is_routable]
|
|
155
|
+
|
|
156
|
+
@field_validator("hypotheses")
|
|
157
|
+
@classmethod
|
|
158
|
+
def _ids_unique(cls, v: list[HypothesisWire]) -> list[HypothesisWire]:
|
|
159
|
+
ids = [h.id for h in v]
|
|
160
|
+
if len(set(ids)) != len(ids):
|
|
161
|
+
raise ValueError("duplicate hypothesis ids make M4 verdicts ambiguous")
|
|
162
|
+
return v
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
__all__ = ["TEST_DESIGN_DIRECTIVES", "DiagnosisInput", "HypothesisWire", "DiagnosisOutput"]
|
evalrx/contract/m4.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""M4 — hypothesis testing: adjudicate M3's claims against corrected statistics.
|
|
2
|
+
|
|
3
|
+
M4 mostly does not compute. It routes a hypothesis's ``test_design`` to the M2
|
|
4
|
+
results that bear on it, admits only results that survived multiplicity
|
|
5
|
+
correction, reads the sign of the effect, and applies a second, independent gate:
|
|
6
|
+
is this claim even about what the user asked to investigate.
|
|
7
|
+
|
|
8
|
+
SUPPORTED requires BOTH gates. Either alone is a different, weaker statement.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any, Literal
|
|
14
|
+
|
|
15
|
+
from pydantic import Field, model_validator
|
|
16
|
+
|
|
17
|
+
from evalrx.contract.common import (
|
|
18
|
+
CaseBatchRef,
|
|
19
|
+
EvidenceGrade,
|
|
20
|
+
HypothesisStatus,
|
|
21
|
+
StageEnvelope,
|
|
22
|
+
WireModel,
|
|
23
|
+
)
|
|
24
|
+
from evalrx.contract.m1 import ProtocolWire
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Input
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
class HypothesisTestInput(WireModel):
|
|
31
|
+
"""M4's inputs.
|
|
32
|
+
|
|
33
|
+
``split`` is recorded because it changes what the verdict MEANS: testing on
|
|
34
|
+
the same cases the hypotheses were generated from is an internal consistency
|
|
35
|
+
check, not out-of-sample evidence. A reader cannot infer this from the numbers
|
|
36
|
+
and must not be left to guess.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
hypotheses_ref: str = Field(description="Path to the M3 DiagnosisOutput.")
|
|
40
|
+
stats_report_ref: str = Field(description="Path to the M2 StatsReportWire (post-correction).")
|
|
41
|
+
data: CaseBatchRef
|
|
42
|
+
protocol: ProtocolWire | None = Field(
|
|
43
|
+
default=None, description="None => every hypothesis is assumed protocol-consistent."
|
|
44
|
+
)
|
|
45
|
+
split: Literal["explore", "confirm", "all"] = Field(
|
|
46
|
+
default="explore",
|
|
47
|
+
description="Which partition M4 actually ran on. 'explore' means the hypotheses were "
|
|
48
|
+
"generated on these same cases — an in-sample adjudication, not held-out.",
|
|
49
|
+
)
|
|
50
|
+
alpha: float = 0.05
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# Output
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
class TestEvidence(WireModel):
|
|
58
|
+
"""The statistics behind one verdict, with its provenance."""
|
|
59
|
+
|
|
60
|
+
source: Literal["m2_stats_results", "fallback_per_case", "none"] = "none"
|
|
61
|
+
chosen_tool: str | None = None
|
|
62
|
+
routed_by: str = Field(default="", description="How test_design resolved to this result.")
|
|
63
|
+
consulted_tools: list[str] = Field(default_factory=list)
|
|
64
|
+
ci: tuple[float, float] | None = None
|
|
65
|
+
e_value: float | None = None
|
|
66
|
+
fdr_corrected: bool | None = Field(default=None, description="The field that actually decided this.")
|
|
67
|
+
underpowered: bool = False
|
|
68
|
+
n_signal: int | None = None
|
|
69
|
+
n_control: int | None = None
|
|
70
|
+
fail_rate_signal: float | None = None
|
|
71
|
+
fail_rate_control: float | None = None
|
|
72
|
+
details: dict[str, Any] = Field(default_factory=dict)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class HypothesisTestResultWire(WireModel):
|
|
76
|
+
"""One hypothesis's verdict.
|
|
77
|
+
|
|
78
|
+
``effect_size`` is signal-group minus control-group fail rate, so its SIGN is
|
|
79
|
+
the verdict's direction: positive supports the claim, negative refutes it.
|
|
80
|
+
A refutation is a real, useful outcome — not a failure to conclude.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
hypothesis_id: str = Field(min_length=1, description="Points into the M3 DiagnosisOutput.")
|
|
84
|
+
status: HypothesisStatus
|
|
85
|
+
test_name: str = Field(description="e.g. 'signal_label_assoc' / 'fail_rate_comparison'.")
|
|
86
|
+
effect_size: float | None = None
|
|
87
|
+
confidence: float = Field(ge=0.0, le=1.0, description="Geometric mean of evidence gap, "
|
|
88
|
+
"sample adequacy, control cleanliness.")
|
|
89
|
+
evidence_grade: EvidenceGrade = EvidenceGrade.OBSERVATIONAL
|
|
90
|
+
is_consistent_with_protocol: bool
|
|
91
|
+
verdict: str = Field(description="One-line natural-language summary.")
|
|
92
|
+
evidence: TestEvidence = Field(default_factory=TestEvidence)
|
|
93
|
+
|
|
94
|
+
@model_validator(mode="after")
|
|
95
|
+
def _supported_needs_both_gates(self) -> "HypothesisTestResultWire":
|
|
96
|
+
# The whole point of the second gate: a statistically real claim about
|
|
97
|
+
# something the user never asked about is not a supported diagnosis.
|
|
98
|
+
if self.status == HypothesisStatus.SUPPORTED and not self.is_consistent_with_protocol:
|
|
99
|
+
raise ValueError(
|
|
100
|
+
"SUPPORTED requires both the statistical test AND protocol consistency; "
|
|
101
|
+
"mark it INCONCLUSIVE and record the inconsistency in `verdict`"
|
|
102
|
+
)
|
|
103
|
+
if self.status == HypothesisStatus.SUPPORTED and self.evidence_grade == EvidenceGrade.NONE:
|
|
104
|
+
raise ValueError("SUPPORTED with evidence_grade=none has nothing backing it")
|
|
105
|
+
return self
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class HypothesisTestOutput(StageEnvelope):
|
|
109
|
+
"""M4's serialized output — one result per hypothesis, plus the loop gate."""
|
|
110
|
+
|
|
111
|
+
split: Literal["explore", "confirm", "all"] = "explore"
|
|
112
|
+
results: list[HypothesisTestResultWire] = Field(default_factory=list)
|
|
113
|
+
stopping_criteria_met: bool = Field(
|
|
114
|
+
default=False,
|
|
115
|
+
description="Whether the loop may stop on this cycle's evidence. Not simply "
|
|
116
|
+
"`verified() != []` — the depth-tiered rule also weighs evidence grade.",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def verified(self) -> list[str]:
|
|
120
|
+
"""Hypothesis ids M5 may operate on, strongest first.
|
|
121
|
+
|
|
122
|
+
Derived, never stored: a persisted copy can disagree with `results`, and
|
|
123
|
+
then nothing tells a reader which one the loop actually acted on.
|
|
124
|
+
"""
|
|
125
|
+
ok = [r for r in self.results
|
|
126
|
+
if r.status == HypothesisStatus.SUPPORTED and r.is_consistent_with_protocol]
|
|
127
|
+
return [r.hypothesis_id for r in sorted(ok, key=lambda r: -r.confidence)]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
__all__ = ["HypothesisTestInput", "TestEvidence", "HypothesisTestResultWire", "HypothesisTestOutput"]
|