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,261 @@
|
|
|
1
|
+
"""Stats-tool selection and execution for M2 exploratory analysis.
|
|
2
|
+
|
|
3
|
+
The goal is to make M2 more than a narrative wrapper around threshold rules:
|
|
4
|
+
given raw analyzer results, this module selects small, reproducible analysis
|
|
5
|
+
tools and returns quantitative tables plus visualization specs. The specs are
|
|
6
|
+
JSON-safe so they can be logged, rendered later, or handed to an LLM without
|
|
7
|
+
shipping heavy arrays.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from statistics import mean
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
from evalrx.core.case import CaseBatch, Label
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from evalrx.core.result import Result
|
|
20
|
+
from evalrx.eval_agent.stages.protocol import ExperimentProtocol
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class StatsToolResult:
|
|
25
|
+
"""Output from one selected stats tool."""
|
|
26
|
+
|
|
27
|
+
name: str
|
|
28
|
+
rationale: str
|
|
29
|
+
metrics: dict[str, Any] = field(default_factory=dict)
|
|
30
|
+
tables: dict[str, list[dict[str, Any]]] = field(default_factory=dict)
|
|
31
|
+
visualizations: list[dict[str, Any]] = field(default_factory=list)
|
|
32
|
+
conclusion: str = ""
|
|
33
|
+
|
|
34
|
+
def to_dict(self) -> dict[str, Any]:
|
|
35
|
+
return {
|
|
36
|
+
"name": self.name,
|
|
37
|
+
"rationale": self.rationale,
|
|
38
|
+
"metrics": self.metrics,
|
|
39
|
+
"tables": self.tables,
|
|
40
|
+
"visualizations": self.visualizations,
|
|
41
|
+
"conclusion": self.conclusion,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class StatsToolAgent:
|
|
46
|
+
"""Select and run built-in exploratory stats tools.
|
|
47
|
+
|
|
48
|
+
This is the deterministic baseline for the future self-extending path where
|
|
49
|
+
an agent can generate new stats code. It intentionally keeps tool outputs
|
|
50
|
+
compact and JSON-serializable.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, max_tools: int = 3) -> None:
|
|
54
|
+
self.max_tools = max_tools
|
|
55
|
+
|
|
56
|
+
def analyze(
|
|
57
|
+
self,
|
|
58
|
+
results: dict[str, "Result"],
|
|
59
|
+
*,
|
|
60
|
+
protocol: "ExperimentProtocol | None" = None,
|
|
61
|
+
) -> list[StatsToolResult]:
|
|
62
|
+
selected = self.select(results, protocol=protocol)
|
|
63
|
+
outputs: list[StatsToolResult] = []
|
|
64
|
+
for name in selected:
|
|
65
|
+
if name == "scalar_summary":
|
|
66
|
+
out = _scalar_summary(results)
|
|
67
|
+
elif name == "per_case_signal_label_association":
|
|
68
|
+
out = _per_case_signal_label_association(results)
|
|
69
|
+
else:
|
|
70
|
+
continue
|
|
71
|
+
if out is not None:
|
|
72
|
+
outputs.append(out)
|
|
73
|
+
return outputs
|
|
74
|
+
|
|
75
|
+
def select(
|
|
76
|
+
self,
|
|
77
|
+
results: dict[str, "Result"],
|
|
78
|
+
*,
|
|
79
|
+
protocol: "ExperimentProtocol | None" = None,
|
|
80
|
+
) -> list[str]:
|
|
81
|
+
names: list[str] = []
|
|
82
|
+
if _has_numeric_scalars(results):
|
|
83
|
+
names.append("scalar_summary")
|
|
84
|
+
if _has_per_case_entries(results):
|
|
85
|
+
names.append("per_case_signal_label_association")
|
|
86
|
+
return names[: self.max_tools]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _has_numeric_scalars(results: dict[str, "Result"]) -> bool:
|
|
90
|
+
for result in results.values():
|
|
91
|
+
for key, value in result.findings.items():
|
|
92
|
+
if key == "per_case":
|
|
93
|
+
continue
|
|
94
|
+
if isinstance(value, (int, float, bool)):
|
|
95
|
+
return True
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _has_per_case_entries(results: dict[str, "Result"]) -> bool:
|
|
100
|
+
return any(
|
|
101
|
+
isinstance(result.findings.get("per_case"), list)
|
|
102
|
+
and len(result.findings.get("per_case", [])) > 0
|
|
103
|
+
for result in results.values()
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _scalar_summary(results: dict[str, "Result"]) -> StatsToolResult | None:
|
|
108
|
+
rows: list[dict[str, Any]] = []
|
|
109
|
+
values: list[float] = []
|
|
110
|
+
|
|
111
|
+
for analyzer_name, result in sorted(results.items()):
|
|
112
|
+
for metric, raw in sorted(result.findings.items()):
|
|
113
|
+
if metric == "per_case" or not isinstance(raw, (int, float, bool)):
|
|
114
|
+
continue
|
|
115
|
+
value = float(raw)
|
|
116
|
+
values.append(value)
|
|
117
|
+
rows.append({
|
|
118
|
+
"analyzer": analyzer_name,
|
|
119
|
+
"metric": metric,
|
|
120
|
+
"value": round(value, 6),
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
if not rows:
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
chart = {
|
|
127
|
+
"kind": "bar",
|
|
128
|
+
"title": "Analyzer scalar metrics",
|
|
129
|
+
"x": [f"{r['analyzer']}.{r['metric']}" for r in rows],
|
|
130
|
+
"y": [r["value"] for r in rows],
|
|
131
|
+
"y_label": "value",
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return StatsToolResult(
|
|
135
|
+
name="scalar_summary",
|
|
136
|
+
rationale="Summarize numeric analyzer findings before hypothesis generation.",
|
|
137
|
+
metrics={
|
|
138
|
+
"n_scalar_metrics": len(rows),
|
|
139
|
+
"min": round(min(values), 6),
|
|
140
|
+
"max": round(max(values), 6),
|
|
141
|
+
"mean": round(mean(values), 6),
|
|
142
|
+
},
|
|
143
|
+
tables={"scalar_metrics": rows},
|
|
144
|
+
visualizations=[chart],
|
|
145
|
+
conclusion=f"Collected {len(rows)} numeric metric(s) across analyzer outputs.",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _per_case_signal_label_association(
|
|
150
|
+
results: dict[str, "Result"],
|
|
151
|
+
) -> StatsToolResult | None:
|
|
152
|
+
cases = _find_cases(results)
|
|
153
|
+
signal = _extract_signals(results)
|
|
154
|
+
if not signal:
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
labeled: dict[str, bool] = {}
|
|
158
|
+
if cases is not None:
|
|
159
|
+
for case in cases:
|
|
160
|
+
if getattr(case, "label", None) in {Label.PASS, Label.FAIL}:
|
|
161
|
+
labeled[case.id] = case.label == Label.FAIL
|
|
162
|
+
signal.setdefault(case.id, False)
|
|
163
|
+
|
|
164
|
+
rows: list[dict[str, Any]] = []
|
|
165
|
+
for cid, has_signal in sorted(signal.items()):
|
|
166
|
+
row: dict[str, Any] = {
|
|
167
|
+
"case_id": cid,
|
|
168
|
+
"has_signal": bool(has_signal),
|
|
169
|
+
}
|
|
170
|
+
if cid in labeled:
|
|
171
|
+
row["label"] = "fail" if labeled[cid] else "pass"
|
|
172
|
+
rows.append(row)
|
|
173
|
+
|
|
174
|
+
n_signal = sum(1 for hit in signal.values() if hit)
|
|
175
|
+
n_no_signal = len(signal) - n_signal
|
|
176
|
+
metrics: dict[str, Any] = {
|
|
177
|
+
"n_cases_with_signal_entries": len(signal),
|
|
178
|
+
"n_signal": n_signal,
|
|
179
|
+
"n_no_signal": n_no_signal,
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
labeled_rows = [r for r in rows if "label" in r]
|
|
183
|
+
if labeled_rows:
|
|
184
|
+
signal_labels = [r["label"] == "fail" for r in labeled_rows if r["has_signal"]]
|
|
185
|
+
control_labels = [r["label"] == "fail" for r in labeled_rows if not r["has_signal"]]
|
|
186
|
+
if signal_labels:
|
|
187
|
+
metrics["fail_rate_signal"] = round(sum(signal_labels) / len(signal_labels), 6)
|
|
188
|
+
if control_labels:
|
|
189
|
+
metrics["fail_rate_control"] = round(sum(control_labels) / len(control_labels), 6)
|
|
190
|
+
if signal_labels and control_labels:
|
|
191
|
+
metrics["effect_size"] = round(
|
|
192
|
+
metrics["fail_rate_signal"] - metrics["fail_rate_control"], 6
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
chart = {
|
|
196
|
+
"kind": "bar",
|
|
197
|
+
"title": "Per-case diagnostic signal split",
|
|
198
|
+
"x": ["signal", "no_signal"],
|
|
199
|
+
"y": [n_signal, n_no_signal],
|
|
200
|
+
"y_label": "case count",
|
|
201
|
+
}
|
|
202
|
+
if "fail_rate_signal" in metrics or "fail_rate_control" in metrics:
|
|
203
|
+
chart_rates = {
|
|
204
|
+
"kind": "bar",
|
|
205
|
+
"title": "Fail rate by diagnostic signal",
|
|
206
|
+
"x": ["signal", "no_signal"],
|
|
207
|
+
"y": [
|
|
208
|
+
metrics.get("fail_rate_signal", 0.0),
|
|
209
|
+
metrics.get("fail_rate_control", 0.0),
|
|
210
|
+
],
|
|
211
|
+
"y_label": "fail rate",
|
|
212
|
+
}
|
|
213
|
+
visualizations = [chart, chart_rates]
|
|
214
|
+
else:
|
|
215
|
+
visualizations = [chart]
|
|
216
|
+
|
|
217
|
+
conclusion = (
|
|
218
|
+
"Computed per-case diagnostic signal coverage."
|
|
219
|
+
if "effect_size" not in metrics
|
|
220
|
+
else f"Signal fail-rate gap is {metrics['effect_size']:.3f}."
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
return StatsToolResult(
|
|
224
|
+
name="per_case_signal_label_association",
|
|
225
|
+
rationale="Check whether analyzer per-case signals align with PASS/FAIL outcomes.",
|
|
226
|
+
metrics=metrics,
|
|
227
|
+
tables={"per_case_signal": rows},
|
|
228
|
+
visualizations=visualizations,
|
|
229
|
+
conclusion=conclusion,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _find_cases(results: dict[str, "Result"]) -> CaseBatch | None:
|
|
234
|
+
for result in results.values():
|
|
235
|
+
cases = getattr(result, "cases", None)
|
|
236
|
+
if cases is not None and len(cases) > 0:
|
|
237
|
+
return cases
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _extract_signals(results: dict[str, "Result"]) -> dict[str, bool]:
|
|
242
|
+
signal: dict[str, bool] = {}
|
|
243
|
+
non_signal_keys = {"sample_id", "id", "step", "first_error_step", "action", "judge_raw"}
|
|
244
|
+
|
|
245
|
+
for result in results.values():
|
|
246
|
+
per_case = result.findings.get("per_case", [])
|
|
247
|
+
if not isinstance(per_case, list):
|
|
248
|
+
continue
|
|
249
|
+
for entry in per_case:
|
|
250
|
+
if not isinstance(entry, dict):
|
|
251
|
+
continue
|
|
252
|
+
cid = str(entry.get("sample_id") or entry.get("id") or "")
|
|
253
|
+
if not cid:
|
|
254
|
+
continue
|
|
255
|
+
hit = any(
|
|
256
|
+
isinstance(v, (int, float, bool)) and bool(v)
|
|
257
|
+
for k, v in entry.items()
|
|
258
|
+
if k not in non_signal_keys
|
|
259
|
+
)
|
|
260
|
+
signal[cid] = signal.get(cid, False) or hit
|
|
261
|
+
return signal
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
"""M2 tier (b) — StatsToolGenerator: synthesise a new statistical tool on demand.
|
|
2
|
+
|
|
3
|
+
When no catalog tool in :mod:`~evalrx.analysis.stats_tools` fits the
|
|
4
|
+
data, this generator asks an LLM / CLI coding agent to *write* a small statistics
|
|
5
|
+
script, runs it inside an :class:`~evalrx.agent_runtime.sandbox.ExperimentSandbox`
|
|
6
|
+
(subprocess + resource limits + path-traversal protection), and parses a strict
|
|
7
|
+
``STATS_RESULT_JSON=`` line from its stdout back into a
|
|
8
|
+
:class:`~evalrx.analysis.stats_tools.StatsToolResult`.
|
|
9
|
+
|
|
10
|
+
Design (mirrors M5's ExperimentWriter):
|
|
11
|
+
|
|
12
|
+
- **Generated code never touches the repo source.** It lives only in the
|
|
13
|
+
sandbox workdir and is executed in a child process; the host never imports it.
|
|
14
|
+
- The generated script reads ``m2_stats_input.json`` (the serialised
|
|
15
|
+
:class:`StatsInput`) from its working directory and may ``import
|
|
16
|
+
evalrx.stats`` to compose existing primitives or write novel statistics
|
|
17
|
+
with numpy.
|
|
18
|
+
- **Tier (c) reuse**: a script that runs and parses cleanly is returned as a
|
|
19
|
+
:class:`GeneratedStatsTool`; the caller can re-run it on later cycles via
|
|
20
|
+
:meth:`run_cached` with fresh data — no second LLM call.
|
|
21
|
+
|
|
22
|
+
Backends (first available wins):
|
|
23
|
+
|
|
24
|
+
1. ``cli_config`` with ``provider != "llm"`` → a CLI coding agent
|
|
25
|
+
(agy / codex / claude_code …) writes files into the workdir.
|
|
26
|
+
2. ``judge`` model → single-pass ``generate()`` returning a fenced code block.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import logging
|
|
33
|
+
import re
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import TYPE_CHECKING, Any
|
|
37
|
+
|
|
38
|
+
from evalrx.agent_runtime.sandbox import ExperimentSandbox
|
|
39
|
+
from evalrx.analysis.prompts.stats_tool_generator import (
|
|
40
|
+
_GENERATE_PROMPT,
|
|
41
|
+
_INPUT_FILENAME,
|
|
42
|
+
_RESULT_MARKER,
|
|
43
|
+
)
|
|
44
|
+
from evalrx.analysis.stats_tools import StatsInput, StatsToolResult, describe_data
|
|
45
|
+
from evalrx.stats import compare
|
|
46
|
+
from evalrx.stats.evalue import evalue_bernoulli
|
|
47
|
+
|
|
48
|
+
if TYPE_CHECKING:
|
|
49
|
+
from evalrx.agent_runtime.cli_types import CliAgentConfig
|
|
50
|
+
from evalrx.core.model import Model
|
|
51
|
+
|
|
52
|
+
logger = logging.getLogger(__name__)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class GeneratedStatsTool:
|
|
58
|
+
"""A validated, sandbox-executed statistical tool that can be re-run.
|
|
59
|
+
|
|
60
|
+
Attributes:
|
|
61
|
+
name: Short identifier (becomes ``generated:<name>``).
|
|
62
|
+
code: The Python source that was written and executed.
|
|
63
|
+
need: The natural-language goal it was generated for.
|
|
64
|
+
source: Which backend wrote it (``"cli:<provider>"`` or ``"llm"``).
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
name: str
|
|
68
|
+
code: str
|
|
69
|
+
need: str = ""
|
|
70
|
+
source: str = ""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class StatsToolGenerator:
|
|
74
|
+
"""Generate, run, and cache bespoke statistical tools in a sandbox.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
judge: LLM used for the single-pass code-writing path.
|
|
78
|
+
cli_config: :class:`~evalrx.agent_runtime.cli_types.CliAgentConfig`
|
|
79
|
+
with ``provider != "llm"`` to use a CLI coding agent
|
|
80
|
+
instead of (or before) the judge.
|
|
81
|
+
sandbox: Execution sandbox. A fresh temp-dir sandbox is created
|
|
82
|
+
when ``None``.
|
|
83
|
+
timeout_sec: Hard wall-clock limit per sandbox run — the primary guard.
|
|
84
|
+
max_cpu_seconds / max_memory_bytes: optional resource caps for the
|
|
85
|
+
default sandbox. Default ``None`` (no cap) to match M5:
|
|
86
|
+
generated tools may ``import evalrx.stats`` which pulls
|
|
87
|
+
in torch, whose large virtual-memory reservation a
|
|
88
|
+
``RLIMIT_AS`` cap would crash. Ignored when *sandbox* is
|
|
89
|
+
supplied.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
judge: "Model | None" = None,
|
|
95
|
+
cli_config: "CliAgentConfig | None" = None,
|
|
96
|
+
sandbox: "ExperimentSandbox | None" = None,
|
|
97
|
+
timeout_sec: int = 60,
|
|
98
|
+
max_cpu_seconds: int | None = None,
|
|
99
|
+
max_memory_bytes: int | None = None,
|
|
100
|
+
run_logger: "Any | None" = None,
|
|
101
|
+
alpha: float = 0.05,
|
|
102
|
+
) -> None:
|
|
103
|
+
self._judge = judge
|
|
104
|
+
self._cli_config = cli_config
|
|
105
|
+
self._timeout_sec = timeout_sec
|
|
106
|
+
# Significance level the HOST uses to reconstruct each generated tool's
|
|
107
|
+
# reject decision from its sufficient statistics (the LLM never decides).
|
|
108
|
+
self._alpha = float(alpha)
|
|
109
|
+
self._sandbox = sandbox or ExperimentSandbox(
|
|
110
|
+
max_cpu_seconds=max_cpu_seconds,
|
|
111
|
+
max_memory_bytes=max_memory_bytes,
|
|
112
|
+
)
|
|
113
|
+
# Optional RunLogger — records each stats-tool code-writing attempt.
|
|
114
|
+
self.run_logger = run_logger
|
|
115
|
+
self._last_prompt: str = ""
|
|
116
|
+
self._last_raw: str = ""
|
|
117
|
+
self._last_raw_stream: str = ""
|
|
118
|
+
self._last_usage: dict | None = None
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def available(self) -> bool:
|
|
122
|
+
"""True when at least one code-writing backend is configured."""
|
|
123
|
+
return self._judge is not None or (
|
|
124
|
+
self._cli_config is not None and self._cli_config.provider != "llm"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# ------------------------------------------------------------------
|
|
128
|
+
# Public interface
|
|
129
|
+
# ------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
def generate(
|
|
132
|
+
self,
|
|
133
|
+
need: str,
|
|
134
|
+
inp: StatsInput,
|
|
135
|
+
name: str = "custom",
|
|
136
|
+
) -> "tuple[StatsToolResult, GeneratedStatsTool | None]":
|
|
137
|
+
"""Write a new stats tool for *need*, run it on *inp*, and return both.
|
|
138
|
+
|
|
139
|
+
Returns ``(result, tool)``. ``tool`` is ``None`` when generation or the
|
|
140
|
+
first execution failed (the result then carries ``ok=False`` + error).
|
|
141
|
+
"""
|
|
142
|
+
if not self.available:
|
|
143
|
+
return (
|
|
144
|
+
StatsToolResult(
|
|
145
|
+
tool=f"generated:{name}", ok=False,
|
|
146
|
+
error="no code-writing backend (judge or cli_config) configured",
|
|
147
|
+
summary="codegen unavailable",
|
|
148
|
+
),
|
|
149
|
+
None,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
self._write_input(inp)
|
|
153
|
+
self._last_prompt = ""
|
|
154
|
+
self._last_raw = ""
|
|
155
|
+
self._last_raw_stream = ""
|
|
156
|
+
self._last_usage = None
|
|
157
|
+
try:
|
|
158
|
+
code, source = self._write_code(need, inp)
|
|
159
|
+
except Exception as exc:
|
|
160
|
+
logger.warning("StatsToolGenerator: code writing failed: %s", exc)
|
|
161
|
+
self._emit_codegen(name, need, "", "", ok=False, error=f"code writing failed: {exc}")
|
|
162
|
+
return (
|
|
163
|
+
StatsToolResult(
|
|
164
|
+
tool=f"generated:{name}", ok=False,
|
|
165
|
+
error=f"code writing failed: {exc}", summary="codegen failed",
|
|
166
|
+
),
|
|
167
|
+
None,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
if not code.strip():
|
|
171
|
+
self._emit_codegen(name, need, source, "", ok=False, error="backend produced no code")
|
|
172
|
+
return (
|
|
173
|
+
StatsToolResult(
|
|
174
|
+
tool=f"generated:{name}", ok=False,
|
|
175
|
+
error="backend produced no code", summary="codegen empty",
|
|
176
|
+
),
|
|
177
|
+
None,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
result = self._run_code(code, name)
|
|
181
|
+
self._emit_codegen(
|
|
182
|
+
name, need, source, code, ok=result.ok,
|
|
183
|
+
error="" if result.ok else (result.error or "tool run failed"),
|
|
184
|
+
)
|
|
185
|
+
tool = (
|
|
186
|
+
GeneratedStatsTool(name=name, code=code, need=need, source=source)
|
|
187
|
+
if result.ok else None
|
|
188
|
+
)
|
|
189
|
+
return result, tool
|
|
190
|
+
|
|
191
|
+
def _emit_codegen(
|
|
192
|
+
self, name: str, need: str, source: str, code: str, *, ok: bool, error: str = ""
|
|
193
|
+
) -> None:
|
|
194
|
+
"""Record one stats-tool code-writing attempt to the RunLogger."""
|
|
195
|
+
if self.run_logger is None:
|
|
196
|
+
return
|
|
197
|
+
extra = ({"cli_usage": self._last_usage}
|
|
198
|
+
if source.startswith("cli:") and self._last_usage else None)
|
|
199
|
+
try:
|
|
200
|
+
self.run_logger.log_tool_codegen(
|
|
201
|
+
module="m2_stats", name=name, need=need, source=source, ok=ok,
|
|
202
|
+
code=code, prompt=self._last_prompt, raw_output=self._last_raw,
|
|
203
|
+
raw_stream=self._last_raw_stream, error=error,
|
|
204
|
+
extra=extra,
|
|
205
|
+
)
|
|
206
|
+
except Exception as exc: # logging must never break generation
|
|
207
|
+
logger.debug("StatsToolGenerator: log_tool_codegen failed: %s", exc)
|
|
208
|
+
|
|
209
|
+
def run_cached(self, tool: GeneratedStatsTool, inp: StatsInput) -> StatsToolResult:
|
|
210
|
+
"""Re-run an already-generated tool on fresh *inp* (no LLM call)."""
|
|
211
|
+
self._write_input(inp)
|
|
212
|
+
return self._run_code(tool.code, tool.name)
|
|
213
|
+
|
|
214
|
+
# ------------------------------------------------------------------
|
|
215
|
+
# Internals
|
|
216
|
+
# ------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
def _write_input(self, inp: StatsInput) -> None:
|
|
219
|
+
payload = {
|
|
220
|
+
"labels": inp.labels,
|
|
221
|
+
"per_case": inp.per_case,
|
|
222
|
+
"scalars": inp.scalars,
|
|
223
|
+
"groups": inp.groups,
|
|
224
|
+
}
|
|
225
|
+
path = Path(self._sandbox.workdir) / _INPUT_FILENAME
|
|
226
|
+
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
227
|
+
|
|
228
|
+
def _write_code(self, need: str, inp: StatsInput) -> tuple[str, str]:
|
|
229
|
+
"""Return ``(code, source)`` from the CLI agent or the judge."""
|
|
230
|
+
if self._cli_config is not None and self._cli_config.provider != "llm":
|
|
231
|
+
code = self._write_code_cli(need, inp)
|
|
232
|
+
if code:
|
|
233
|
+
return code, f"cli:{self._cli_config.provider}"
|
|
234
|
+
# Single-pass LLM fallback
|
|
235
|
+
prompt = self._build_prompt(need, inp, fenced=True)
|
|
236
|
+
self._last_prompt = prompt
|
|
237
|
+
raw = self._judge.generate(prompt) # type: ignore[union-attr]
|
|
238
|
+
self._last_raw = str(raw)
|
|
239
|
+
return _extract_code(str(raw)), "llm"
|
|
240
|
+
|
|
241
|
+
def _write_code_cli(self, need: str, inp: StatsInput) -> str:
|
|
242
|
+
from evalrx.agent_runtime.codegen import CodegenRunner
|
|
243
|
+
|
|
244
|
+
prompt = self._build_prompt(need, inp, fenced=False)
|
|
245
|
+
self._last_prompt = prompt
|
|
246
|
+
result = CodegenRunner(self._cli_config).write_code( # type: ignore[arg-type]
|
|
247
|
+
prompt,
|
|
248
|
+
workdir=Path(self._sandbox.workdir),
|
|
249
|
+
timeout_sec=self._timeout_sec,
|
|
250
|
+
preferred_filenames=("stats_tool.py",),
|
|
251
|
+
)
|
|
252
|
+
self._last_raw = result.raw_output
|
|
253
|
+
self._last_raw_stream = ""
|
|
254
|
+
if result.raw_stream_path:
|
|
255
|
+
try:
|
|
256
|
+
self._last_raw_stream = (
|
|
257
|
+
Path(self._sandbox.workdir) / result.raw_stream_path
|
|
258
|
+
).read_text(encoding="utf-8")
|
|
259
|
+
except OSError:
|
|
260
|
+
pass
|
|
261
|
+
self._last_usage = result.usage
|
|
262
|
+
if not result.code:
|
|
263
|
+
logger.debug("CLI codegen produced no files (%s)", result.error)
|
|
264
|
+
return result.code
|
|
265
|
+
|
|
266
|
+
def _build_prompt(self, need: str, inp: StatsInput, *, fenced: bool) -> str:
|
|
267
|
+
return _GENERATE_PROMPT.format(
|
|
268
|
+
need=need.strip() or "Test whether the analyzer signals predict case FAIL.",
|
|
269
|
+
input_filename=_INPUT_FILENAME,
|
|
270
|
+
data_shape=json.dumps(describe_data(inp), indent=2),
|
|
271
|
+
marker=_RESULT_MARKER,
|
|
272
|
+
fences_hint=" inside a ```python code block" if fenced else
|
|
273
|
+
", written to a file named stats_tool.py",
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def _run_code(self, code: str, name: str) -> StatsToolResult:
|
|
277
|
+
repo_root = Path(__file__).resolve().parents[3]
|
|
278
|
+
wrapped = (
|
|
279
|
+
"import sys\n"
|
|
280
|
+
f"sys.path.insert(0, {str(repo_root)!r})\n"
|
|
281
|
+
+ code
|
|
282
|
+
)
|
|
283
|
+
sandbox_result = self._sandbox.run(wrapped, timeout_sec=self._timeout_sec)
|
|
284
|
+
if not sandbox_result.ok:
|
|
285
|
+
err = (sandbox_result.stderr or "").strip()[:300] or "non-zero exit"
|
|
286
|
+
return StatsToolResult(
|
|
287
|
+
tool=f"generated:{name}", ok=False,
|
|
288
|
+
error=f"sandbox run failed: {err}",
|
|
289
|
+
summary=f"generated:{name} failed to run",
|
|
290
|
+
details={"returncode": sandbox_result.returncode,
|
|
291
|
+
"timed_out": sandbox_result.timed_out},
|
|
292
|
+
)
|
|
293
|
+
return _parse_result(sandbox_result.stdout, name, self._alpha)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# ---------------------------------------------------------------------------
|
|
297
|
+
# Module helpers
|
|
298
|
+
# ---------------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
def _extract_code(raw: str) -> str:
|
|
301
|
+
"""Pull the Python source out of an LLM response (fenced or bare)."""
|
|
302
|
+
cleaned = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL)
|
|
303
|
+
fence = re.search(r"```(?:python)?\s*\n(.*?)```", cleaned, flags=re.DOTALL)
|
|
304
|
+
if fence:
|
|
305
|
+
return fence.group(1).strip()
|
|
306
|
+
return cleaned.strip()
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _reconstruct_decision(
|
|
310
|
+
suff: Any, alpha: float
|
|
311
|
+
) -> "tuple[bool, float | None, float | None, tuple | None] | None":
|
|
312
|
+
"""Recompute ``(reject, e_value, effect, ci)`` HOST-SIDE from a generated
|
|
313
|
+
tool's sufficient statistics, using the validated core — so a generated
|
|
314
|
+
tool can choose WHAT statistic to compute but never WHETHER to reject.
|
|
315
|
+
|
|
316
|
+
Returns ``None`` when *suff* is missing or not an adjudicable shape; the
|
|
317
|
+
caller then treats the tool as descriptive (``reject=False``).
|
|
318
|
+
"""
|
|
319
|
+
if not isinstance(suff, dict):
|
|
320
|
+
return None
|
|
321
|
+
kind = suff.get("kind")
|
|
322
|
+
try:
|
|
323
|
+
if kind == "paired_binary":
|
|
324
|
+
b, c = int(suff["b"]), int(suff["c"])
|
|
325
|
+
if b < 0 or c < 0:
|
|
326
|
+
return None
|
|
327
|
+
n = b + c
|
|
328
|
+
e_value = evalue_bernoulli(b, n, p0=0.5) if n > 0 else 1.0
|
|
329
|
+
reject = e_value >= 1.0 / alpha
|
|
330
|
+
effect = (b - c) / n if n > 0 else 0.0
|
|
331
|
+
return reject, float(e_value), effect, None
|
|
332
|
+
if kind == "two_group":
|
|
333
|
+
a = [int(x) for x in suff["a"]]
|
|
334
|
+
grp_b = [int(x) for x in suff["b"]]
|
|
335
|
+
if not a or not grp_b:
|
|
336
|
+
return None
|
|
337
|
+
sr = compare(a, grp_b, paired=False, alpha=alpha)
|
|
338
|
+
return bool(sr.reject), sr.e_value, sr.effect, tuple(sr.ci)
|
|
339
|
+
except (KeyError, TypeError, ValueError):
|
|
340
|
+
return None
|
|
341
|
+
return None
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _parse_result(stdout: str, name: str, alpha: float = 0.05) -> StatsToolResult:
|
|
345
|
+
"""Parse the last ``STATS_RESULT_JSON=`` line of *stdout*.
|
|
346
|
+
|
|
347
|
+
The generated script supplies a DESCRIPTION (summary/effect/ci/details) plus
|
|
348
|
+
SUFFICIENT STATISTICS; the host reconstructs the *decision*
|
|
349
|
+
(reject / e-value) from those statistics via the validated core. A
|
|
350
|
+
self-declared ``reject``/``e_value``/``p_value`` in the JSON is IGNORED — the
|
|
351
|
+
LLM proposes evidence, it never adjudicates it. A tool with no adjudicable
|
|
352
|
+
sufficient statistic is descriptive only (``reject=False``), mirroring the
|
|
353
|
+
``single_rate_evalue`` muzzle, so it can never reach M4's headline.
|
|
354
|
+
"""
|
|
355
|
+
from evalrx.analysis.result_marker import extract_marker_json
|
|
356
|
+
|
|
357
|
+
data, error = extract_marker_json(stdout, _RESULT_MARKER)
|
|
358
|
+
if data is None and error.startswith("no "):
|
|
359
|
+
return StatsToolResult(
|
|
360
|
+
tool=f"generated:{name}", ok=False,
|
|
361
|
+
error="no STATS_RESULT_JSON line in output",
|
|
362
|
+
summary=f"generated:{name} produced no result line",
|
|
363
|
+
details={"stdout_tail": stdout.strip()[-300:]},
|
|
364
|
+
)
|
|
365
|
+
if error or not isinstance(data, dict):
|
|
366
|
+
return StatsToolResult(
|
|
367
|
+
tool=f"generated:{name}", ok=False,
|
|
368
|
+
error=(error.replace(_RESULT_MARKER, "STATS_RESULT_JSON") if error
|
|
369
|
+
else "STATS_RESULT_JSON payload must be a JSON object"),
|
|
370
|
+
summary=f"generated:{name} bad result json",
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
ci = data.get("ci")
|
|
374
|
+
ci_tuple = tuple(ci) if isinstance(ci, (list, tuple)) and len(ci) == 2 else None
|
|
375
|
+
effect = _as_float(data.get("effect"))
|
|
376
|
+
details = data.get("details") if isinstance(data.get("details"), dict) else {}
|
|
377
|
+
|
|
378
|
+
# HOST reconstructs the decision; the script's own reject/e_value/p_value are
|
|
379
|
+
# never trusted as a verdict (closes the codegen p-hacking hole).
|
|
380
|
+
recon = _reconstruct_decision(data.get("sufficient"), alpha)
|
|
381
|
+
if recon is not None:
|
|
382
|
+
reject, e_value, eff_h, ci_h = recon
|
|
383
|
+
if eff_h is not None:
|
|
384
|
+
effect = eff_h
|
|
385
|
+
if ci_h is not None:
|
|
386
|
+
ci_tuple = ci_h
|
|
387
|
+
details = {**details, "host_adjudicated": True}
|
|
388
|
+
else:
|
|
389
|
+
reject, e_value = False, None
|
|
390
|
+
details = {**details, "host_adjudicated": False, "descriptive_only": True}
|
|
391
|
+
|
|
392
|
+
return StatsToolResult(
|
|
393
|
+
tool=f"generated:{name}",
|
|
394
|
+
ok=True,
|
|
395
|
+
summary=str(data.get("summary", "")) or f"generated:{name}",
|
|
396
|
+
effect=effect,
|
|
397
|
+
ci=ci_tuple,
|
|
398
|
+
reject=reject,
|
|
399
|
+
e_value=e_value,
|
|
400
|
+
p_value=None, # diagnostic at most; never a host decision input
|
|
401
|
+
underpowered=bool(data.get("underpowered", False)),
|
|
402
|
+
analysis_key=f"generated:{name}",
|
|
403
|
+
correction_family="e_bh" if e_value is not None else None,
|
|
404
|
+
raw_reject=reject,
|
|
405
|
+
details=details,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _as_float(v: Any) -> float | None:
|
|
410
|
+
if v is None or isinstance(v, bool):
|
|
411
|
+
return None
|
|
412
|
+
try:
|
|
413
|
+
return float(v)
|
|
414
|
+
except (TypeError, ValueError):
|
|
415
|
+
return None
|