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,1276 @@
|
|
|
1
|
+
"""General-purpose exploratory data analysis agent (LAMBDA-style).
|
|
2
|
+
|
|
3
|
+
A coding agent writes Python over arbitrary tabular records — not just M1
|
|
4
|
+
pass/fail diagnosis logs — EvalRX runs it locally in a sandbox, and the
|
|
5
|
+
result is parsed into a structured exploratory report. The prompt adapts its
|
|
6
|
+
framing to whatever the data's outcome column actually is (binary,
|
|
7
|
+
multi-class, continuous, or none — see :func:`_framing_block`), so a dataset
|
|
8
|
+
with no FAIL/PASS label gets unsupervised EDA instead of an invented split.
|
|
9
|
+
|
|
10
|
+
M1 integration is one caller among others, not a special case: the diagnosis
|
|
11
|
+
loop's per-case records already carry a ``label`` column, which the name
|
|
12
|
+
heuristic in :func:`evalrx.analysis.profile.profile_records` recognizes
|
|
13
|
+
as the outcome automatically, so it still gets the same binary FAIL/PASS
|
|
14
|
+
framing it always did (callers with an arbitrarily-named target can pass
|
|
15
|
+
``outcome_col=`` explicitly instead of relying on the heuristic). Findings
|
|
16
|
+
from this module are candidates only; use ``StatsAnalysisAgent`` for
|
|
17
|
+
confirmatory effect/CI/e-value/FDR verdicts.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import logging
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import shutil
|
|
27
|
+
import time
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
31
|
+
|
|
32
|
+
from evalrx.agent_runtime.sandbox import ExperimentSandbox, SandboxResult
|
|
33
|
+
from evalrx.analysis.plain_language import jargon_violation
|
|
34
|
+
from evalrx.analysis.profile import describe_outcome, profile_records
|
|
35
|
+
from evalrx.analysis.prompts.explorer import (
|
|
36
|
+
GENERATE_PROMPT_RAW_FOLDER as _GENERATE_PROMPT_RAW_FOLDER,
|
|
37
|
+
)
|
|
38
|
+
from evalrx.analysis.prompts.explorer import (
|
|
39
|
+
GENERATE_PROMPT_RECORDS as _GENERATE_PROMPT_RECORDS,
|
|
40
|
+
)
|
|
41
|
+
from evalrx.analysis.prompts.explorer import (
|
|
42
|
+
GENERIC_FRAMING as _GENERIC_FRAMING,
|
|
43
|
+
)
|
|
44
|
+
from evalrx.analysis.prompts.explorer import (
|
|
45
|
+
RECORDS_FILENAME,
|
|
46
|
+
)
|
|
47
|
+
from evalrx.analysis.prompts.explorer import (
|
|
48
|
+
REPAIR_PROMPT as _REPAIR_PROMPT,
|
|
49
|
+
)
|
|
50
|
+
from evalrx.analysis.prompts.explorer import (
|
|
51
|
+
RESULT_MARKER as _RESULT_MARKER,
|
|
52
|
+
)
|
|
53
|
+
from evalrx.analysis.prompts.explorer import (
|
|
54
|
+
fences_hint as _fences_hint,
|
|
55
|
+
)
|
|
56
|
+
from evalrx.analysis.prompts.explorer import (
|
|
57
|
+
skills_hint as _skills_hint,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
if TYPE_CHECKING:
|
|
61
|
+
from evalrx.agent_runtime.cli_types import CliAgentConfig
|
|
62
|
+
from evalrx.core.model import Model
|
|
63
|
+
|
|
64
|
+
logger = logging.getLogger(__name__)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class Takeaway:
|
|
69
|
+
"""One finding paired with its supporting evidence — the primary UI unit.
|
|
70
|
+
|
|
71
|
+
The dashboard renders each takeaway as: plain_title (falling back to
|
|
72
|
+
title) -> chart(s)/table(s) -> analysis, so a reader never sees an
|
|
73
|
+
orphaned chart or a claim with no evidence next to it. ``plain_title`` is
|
|
74
|
+
the jargon-free headline aimed at a non-technical reader; ``title`` keeps
|
|
75
|
+
the precise technical wording/numbers as a companion line. Always
|
|
76
|
+
descriptive: this agent does not generate or validate hypotheses, so a
|
|
77
|
+
takeaway is a description of the data, not a claim of causation or
|
|
78
|
+
statistical confirmation.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
title: str = ""
|
|
82
|
+
plain_title: str = ""
|
|
83
|
+
analysis: str = ""
|
|
84
|
+
chart_names: list[str] = field(default_factory=list)
|
|
85
|
+
table_names: list[str] = field(default_factory=list)
|
|
86
|
+
caveat: str = ""
|
|
87
|
+
|
|
88
|
+
def to_dict(self) -> dict[str, Any]:
|
|
89
|
+
return {
|
|
90
|
+
"title": self.title,
|
|
91
|
+
"plain_title": self.plain_title,
|
|
92
|
+
"analysis": self.analysis,
|
|
93
|
+
"chart_names": self.chart_names,
|
|
94
|
+
"table_names": self.table_names,
|
|
95
|
+
"caveat": self.caveat,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class CandidateSignal:
|
|
101
|
+
"""A signal worth testing later with a confirmatory pipeline
|
|
102
|
+
(:class:`~evalrx.analysis.stats_agent.StatsAnalysisAgent`, or the
|
|
103
|
+
diagnosis loop) — not something this agent tests or confirms itself.
|
|
104
|
+
|
|
105
|
+
The explorer PROPOSES this signal; it has no adjudication authority. When the
|
|
106
|
+
explorer attaches host-adjudicable ``sufficient`` statistics, the host
|
|
107
|
+
(:func:`evalrx.analysis.adjudicate.adjudicate_report`) recomputes the
|
|
108
|
+
verdict with the validated, multiplicity-aware core and fills in the
|
|
109
|
+
``effect`` / ``ci`` / ``e_value`` / ``reject`` / ``host_adjudicated`` fields.
|
|
110
|
+
Any ``reject`` / ``e_value`` the explorer self-declares is IGNORED — proposing
|
|
111
|
+
is not validating; only ``sufficient`` (or, in Phase B, ``recipe``) is read.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
name: str
|
|
115
|
+
display_name: str = ""
|
|
116
|
+
rationale: str = ""
|
|
117
|
+
suggested_test: str = ""
|
|
118
|
+
# --- proposed by the explorer (no authority over the verdict) ---
|
|
119
|
+
sufficient: dict[str, Any] | None = None # host-adjudicable sufficient stats
|
|
120
|
+
recipe: dict[str, Any] | None = None # Phase B operationalization recipe
|
|
121
|
+
# --- filled in by the host adjudication pass (authoritative) ---
|
|
122
|
+
effect: float | None = None
|
|
123
|
+
ci: tuple[float, float] | None = None
|
|
124
|
+
e_value: float | None = None
|
|
125
|
+
reject: bool | None = None
|
|
126
|
+
underpowered: bool = False
|
|
127
|
+
host_adjudicated: bool = False
|
|
128
|
+
fdr_corrected: bool = False
|
|
129
|
+
descriptive_only: bool = False
|
|
130
|
+
|
|
131
|
+
def to_dict(self) -> dict[str, Any]:
|
|
132
|
+
return {
|
|
133
|
+
"name": self.name,
|
|
134
|
+
"display_name": self.display_name,
|
|
135
|
+
"rationale": self.rationale,
|
|
136
|
+
"suggested_test": self.suggested_test,
|
|
137
|
+
"sufficient": self.sufficient,
|
|
138
|
+
"recipe": self.recipe,
|
|
139
|
+
"effect": self.effect,
|
|
140
|
+
"ci": list(self.ci) if self.ci is not None else None,
|
|
141
|
+
"e_value": self.e_value,
|
|
142
|
+
"reject": self.reject,
|
|
143
|
+
"underpowered": self.underpowered,
|
|
144
|
+
"host_adjudicated": self.host_adjudicated,
|
|
145
|
+
"fdr_corrected": self.fdr_corrected,
|
|
146
|
+
"descriptive_only": self.descriptive_only,
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass
|
|
151
|
+
class ExploratoryAnalysisReport:
|
|
152
|
+
"""Output of the exploratory analysis agent: a data profile, a ranked list
|
|
153
|
+
of takeaways (each paired with its supporting chart/table), and the raw
|
|
154
|
+
charts/tables/code behind them. Purely descriptive — this agent does not
|
|
155
|
+
generate or validate hypotheses itself; ``hypotheses`` (if present) is
|
|
156
|
+
filled in by a separate downstream stage (M3, see
|
|
157
|
+
``evalrx.analysis.hypothesis_agent.HypothesisAgent``), not by this
|
|
158
|
+
class — proposal only, still no validation; see ``StatsAnalysisAgent``
|
|
159
|
+
for confirmatory testing.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
question: str = ""
|
|
163
|
+
plain_question: str = ""
|
|
164
|
+
ok: bool = False
|
|
165
|
+
observations: list[str] = field(default_factory=list)
|
|
166
|
+
takeaways: list[Takeaway] = field(default_factory=list)
|
|
167
|
+
hypotheses: list[dict[str, Any]] = field(default_factory=list)
|
|
168
|
+
visual_plan: list[dict[str, Any]] = field(default_factory=list)
|
|
169
|
+
chart_readings: list[dict[str, Any]] = field(default_factory=list)
|
|
170
|
+
dashboard_storyboard: list[dict[str, Any]] = field(default_factory=list)
|
|
171
|
+
claims: list[dict[str, Any]] = field(default_factory=list)
|
|
172
|
+
candidate_signals: list[CandidateSignal] = field(default_factory=list)
|
|
173
|
+
plots: list[str] = field(default_factory=list)
|
|
174
|
+
tables: dict[str, Any] = field(default_factory=dict)
|
|
175
|
+
charts: list[dict[str, Any]] = field(default_factory=list)
|
|
176
|
+
caveats: list[str] = field(default_factory=list)
|
|
177
|
+
critique: list[str] = field(default_factory=list)
|
|
178
|
+
recommended_confirmatory_tests: list[str] = field(default_factory=list)
|
|
179
|
+
data_profile: dict[str, Any] = field(default_factory=dict)
|
|
180
|
+
# Host adjudication family metadata (method/alpha/split/n_in_family/rejected),
|
|
181
|
+
# filled by evalrx.analysis.adjudicate; empty until adjudicated.
|
|
182
|
+
adjudication: dict[str, Any] = field(default_factory=dict)
|
|
183
|
+
code: str = ""
|
|
184
|
+
stdout: str = ""
|
|
185
|
+
stderr: str = ""
|
|
186
|
+
error: str = ""
|
|
187
|
+
attempts: int = 0
|
|
188
|
+
workdir: str = ""
|
|
189
|
+
raw_outputs: list[str] = field(default_factory=list)
|
|
190
|
+
agent_audits: list[dict[str, Any]] = field(default_factory=list)
|
|
191
|
+
# UNTRUNCATED raw CLI streams, one per attempt (the truncated renderings
|
|
192
|
+
# above exist for compact UIs; these are the full audit trail).
|
|
193
|
+
raw_streams: list[str] = field(default_factory=list)
|
|
194
|
+
model_calls: list[dict[str, Any]] = field(default_factory=list)
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def candidate_signal_names(self) -> list[str]:
|
|
198
|
+
return [s.name for s in self.candidate_signals if s.name]
|
|
199
|
+
|
|
200
|
+
def to_dict(self) -> dict[str, Any]:
|
|
201
|
+
return {
|
|
202
|
+
"question": self.question,
|
|
203
|
+
"plain_question": self.plain_question,
|
|
204
|
+
"ok": self.ok,
|
|
205
|
+
"observations": self.observations,
|
|
206
|
+
"takeaways": [t.to_dict() for t in self.takeaways],
|
|
207
|
+
"hypotheses": self.hypotheses,
|
|
208
|
+
"visual_plan": self.visual_plan,
|
|
209
|
+
"chart_readings": self.chart_readings,
|
|
210
|
+
"dashboard_storyboard": self.dashboard_storyboard,
|
|
211
|
+
"claims": self.claims,
|
|
212
|
+
"candidate_signals": [s.to_dict() for s in self.candidate_signals],
|
|
213
|
+
"plots": self.plots,
|
|
214
|
+
"tables": self.tables,
|
|
215
|
+
"charts": self.charts,
|
|
216
|
+
"caveats": self.caveats,
|
|
217
|
+
"critique": self.critique,
|
|
218
|
+
"recommended_confirmatory_tests": self.recommended_confirmatory_tests,
|
|
219
|
+
"data_profile": self.data_profile,
|
|
220
|
+
"adjudication": self.adjudication,
|
|
221
|
+
"code": self.code,
|
|
222
|
+
"stdout": self.stdout,
|
|
223
|
+
"stderr": self.stderr,
|
|
224
|
+
"error": self.error,
|
|
225
|
+
"attempts": self.attempts,
|
|
226
|
+
"workdir": self.workdir,
|
|
227
|
+
"agent_audits": self.agent_audits,
|
|
228
|
+
"model_calls": self.model_calls,
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class ExploratoryAnalysisAgent:
|
|
233
|
+
"""Backend-only exploratory analysis agent.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
judge: LLM-like object with ``generate(prompt) -> str`` used to
|
|
237
|
+
write the initial analysis script.
|
|
238
|
+
inspector: Optional LLM-like object used for repair prompts. When not
|
|
239
|
+
provided, ``judge`` repairs its own code.
|
|
240
|
+
cli_config: Optional CLI coding-agent backend. If set to a non-``llm``
|
|
241
|
+
provider, it writes ``analysis.py`` locally.
|
|
242
|
+
sandbox: Local execution sandbox. A durable temp sandbox is created
|
|
243
|
+
when omitted so generated code and figures remain available.
|
|
244
|
+
"""
|
|
245
|
+
|
|
246
|
+
def __init__(
|
|
247
|
+
self,
|
|
248
|
+
judge: "Model | None" = None,
|
|
249
|
+
inspector: "Model | None" = None,
|
|
250
|
+
cli_config: "CliAgentConfig | None" = None,
|
|
251
|
+
sandbox: ExperimentSandbox | None = None,
|
|
252
|
+
timeout_sec: int = 60,
|
|
253
|
+
max_attempts: int = 2,
|
|
254
|
+
use_bundled_skills: bool = True,
|
|
255
|
+
progress_sink: Any = None,
|
|
256
|
+
) -> None:
|
|
257
|
+
self._judge = judge
|
|
258
|
+
self._inspector = inspector
|
|
259
|
+
# Figure-styling skills are on by default for every skill-capable CLI
|
|
260
|
+
# backend: when the caller configured none, vendor the package-bundled
|
|
261
|
+
# ones (eval-chart-style, nature-figure, evalrx-report-ui) so agent
|
|
262
|
+
# figures follow the house chart-type policy without per-caller wiring.
|
|
263
|
+
if use_bundled_skills and cli_config is not None and not cli_config.skills:
|
|
264
|
+
from evalrx.agent_runtime.skills.resolver import resolve_skill_paths
|
|
265
|
+
|
|
266
|
+
bundled = resolve_skill_paths(
|
|
267
|
+
provider=cli_config.provider,
|
|
268
|
+
explicit=(),
|
|
269
|
+
use_bundled=True,
|
|
270
|
+
)
|
|
271
|
+
if bundled:
|
|
272
|
+
from dataclasses import replace
|
|
273
|
+
|
|
274
|
+
cli_config = replace(cli_config, skills=bundled, allow_skills=True)
|
|
275
|
+
self._cli_config = cli_config
|
|
276
|
+
self._sandbox = sandbox or ExperimentSandbox(cleanup=False)
|
|
277
|
+
self._timeout_sec = timeout_sec
|
|
278
|
+
self._max_attempts = max(1, max_attempts)
|
|
279
|
+
self._progress_sink = progress_sink
|
|
280
|
+
self._last_agent_audit: dict[str, Any] | None = None
|
|
281
|
+
self._last_raw_stream: str = ""
|
|
282
|
+
self._model_calls: list[dict[str, Any]] = []
|
|
283
|
+
|
|
284
|
+
@property
|
|
285
|
+
def available(self) -> bool:
|
|
286
|
+
return self._judge is not None or (
|
|
287
|
+
self._cli_config is not None and self._cli_config.provider != "llm"
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
def explore_records(
|
|
291
|
+
self,
|
|
292
|
+
records: Any,
|
|
293
|
+
*,
|
|
294
|
+
question: str = "Explore this dataset and surface the patterns that matter.",
|
|
295
|
+
outcome_col: str | None = None,
|
|
296
|
+
) -> ExploratoryAnalysisReport:
|
|
297
|
+
"""Run local exploratory analysis over plain records.
|
|
298
|
+
|
|
299
|
+
``outcome_col`` optionally names the target/label column explicitly
|
|
300
|
+
(e.g. M1 always passes ``"label"``). When omitted, the outcome (if
|
|
301
|
+
any) is auto-detected by name heuristics; arbitrary datasets with no
|
|
302
|
+
recognizable outcome column fall back to unsupervised EDA instead of
|
|
303
|
+
having a FAIL/PASS split forced onto them.
|
|
304
|
+
"""
|
|
305
|
+
rows = _records_to_rows(records)
|
|
306
|
+
profile = _profile_rows(rows, outcome_col=outcome_col)
|
|
307
|
+
self._write_input(rows)
|
|
308
|
+
return self._run_explore_loop(
|
|
309
|
+
question, profile, lambda: self._write_code(question, profile)
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
def explore_path(
|
|
313
|
+
self,
|
|
314
|
+
path: str | Path,
|
|
315
|
+
*,
|
|
316
|
+
question: str = "Explore this dataset and surface the patterns that matter.",
|
|
317
|
+
max_rows: int = 2000,
|
|
318
|
+
max_files: int = 200,
|
|
319
|
+
include_tool_calls: bool = False,
|
|
320
|
+
outcome_col: str | None = None,
|
|
321
|
+
) -> ExploratoryAnalysisReport:
|
|
322
|
+
"""Run exploratory analysis directly over *path* (a file or directory).
|
|
323
|
+
|
|
324
|
+
This is the no-code entrypoint behind the CLI. With a CLI coding-agent
|
|
325
|
+
backend (``cli_config``, the ``evalrx explore`` default), the raw
|
|
326
|
+
source is handed to the agent as-is and IT loads/organizes whatever
|
|
327
|
+
shape it finds — no host-side JSON-shape parsing, so this works for an
|
|
328
|
+
arbitrary M1 output layout, not just the ones the host loader
|
|
329
|
+
recognizes. With a plain LLM ``judge`` (no filesystem access), the host
|
|
330
|
+
must still pre-flatten records via :func:`load_records_from_path`
|
|
331
|
+
before handing them to :meth:`explore_records`.
|
|
332
|
+
"""
|
|
333
|
+
folder_scan = scan_folder(path, max_files=max_files, include_tool_calls=include_tool_calls)
|
|
334
|
+
is_agentic = self._cli_config is not None and self._cli_config.provider != "llm"
|
|
335
|
+
if is_agentic:
|
|
336
|
+
report = self._explore_raw_folder(
|
|
337
|
+
path,
|
|
338
|
+
question=question,
|
|
339
|
+
folder_scan=folder_scan,
|
|
340
|
+
max_files=max_files,
|
|
341
|
+
include_tool_calls=include_tool_calls,
|
|
342
|
+
outcome_col=outcome_col,
|
|
343
|
+
)
|
|
344
|
+
else:
|
|
345
|
+
rows = load_records_from_path(
|
|
346
|
+
path,
|
|
347
|
+
max_rows=max_rows,
|
|
348
|
+
max_files=max_files,
|
|
349
|
+
include_tool_calls=include_tool_calls,
|
|
350
|
+
)
|
|
351
|
+
report = self.explore_records(rows, question=question, outcome_col=outcome_col)
|
|
352
|
+
report.data_profile.setdefault("loaded_rows", len(rows))
|
|
353
|
+
report.data_profile.setdefault("source_path", str(Path(path)))
|
|
354
|
+
report.data_profile.setdefault("folder_scan", folder_scan)
|
|
355
|
+
return report
|
|
356
|
+
|
|
357
|
+
def _explore_raw_folder(
|
|
358
|
+
self,
|
|
359
|
+
path: str | Path,
|
|
360
|
+
*,
|
|
361
|
+
question: str,
|
|
362
|
+
folder_scan: dict[str, Any],
|
|
363
|
+
max_files: int,
|
|
364
|
+
include_tool_calls: bool,
|
|
365
|
+
outcome_col: str | None,
|
|
366
|
+
) -> ExploratoryAnalysisReport:
|
|
367
|
+
raw_input_dir = self._copy_raw_input(path, max_files=max_files, include_tool_calls=include_tool_calls)
|
|
368
|
+
profile: dict[str, Any] = {"folder_scan": folder_scan, "raw_input_dir": raw_input_dir}
|
|
369
|
+
return self._run_explore_loop(
|
|
370
|
+
question,
|
|
371
|
+
profile,
|
|
372
|
+
lambda: self._write_code_raw_folder(question, folder_scan, raw_input_dir, outcome_col),
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
def _copy_raw_input(self, path: str | Path, *, max_files: int, include_tool_calls: bool) -> str:
|
|
376
|
+
"""Copy the raw source into the sandbox untouched, so the CLI agent
|
|
377
|
+
reads and organizes it itself — the host does no JSON-shape parsing
|
|
378
|
+
for this path. Mirrors the same file selection ``load_records_from_path``
|
|
379
|
+
would use (extension filter, ``tool_calls_*`` skip, ``max_files`` cap),
|
|
380
|
+
but copies the original bytes instead of parsing them."""
|
|
381
|
+
root = Path(path)
|
|
382
|
+
dest_root = Path(self._sandbox.workdir) / "raw_input"
|
|
383
|
+
if dest_root.exists():
|
|
384
|
+
shutil.rmtree(dest_root)
|
|
385
|
+
dest_root.mkdir(parents=True)
|
|
386
|
+
if root.is_file():
|
|
387
|
+
shutil.copy2(root, dest_root / root.name)
|
|
388
|
+
else:
|
|
389
|
+
# A workbench DatasetBundle contains normalized records.json plus
|
|
390
|
+
# optional original media. Keep the old JSON selection semantics
|
|
391
|
+
# for regular result folders, but include known table/media files
|
|
392
|
+
# so a CLI coding agent can inspect the same bundle the UI reports.
|
|
393
|
+
from evalrx.analysis.workbench import SUPPORTED_SUFFIXES
|
|
394
|
+
|
|
395
|
+
files = [
|
|
396
|
+
p for p in sorted(root.rglob("*"))
|
|
397
|
+
if p.is_file()
|
|
398
|
+
and p.suffix.lower() in SUPPORTED_SUFFIXES
|
|
399
|
+
and (include_tool_calls or not p.name.startswith("tool_calls_"))
|
|
400
|
+
]
|
|
401
|
+
# Normalized rows are the primary analysis input and must not be
|
|
402
|
+
# crowded out by a large media directory.
|
|
403
|
+
files.sort(key=lambda p: (p.name != RECORDS_FILENAME, str(p)))
|
|
404
|
+
files = files[:max_files]
|
|
405
|
+
for f in files:
|
|
406
|
+
dest = dest_root / f.relative_to(root)
|
|
407
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
408
|
+
shutil.copy2(f, dest)
|
|
409
|
+
return "raw_input"
|
|
410
|
+
|
|
411
|
+
def _run_explore_loop(
|
|
412
|
+
self,
|
|
413
|
+
question: str,
|
|
414
|
+
profile: dict[str, Any],
|
|
415
|
+
write_first: "Callable[[], tuple[str, str]]",
|
|
416
|
+
) -> ExploratoryAnalysisReport:
|
|
417
|
+
"""Shared write/run/repair-attempt loop behind :meth:`explore_records`
|
|
418
|
+
and :meth:`_explore_raw_folder` — they differ only in how the first
|
|
419
|
+
attempt's code is written (pre-loaded records vs. a raw folder); repair
|
|
420
|
+
attempts reuse :meth:`_repair_code` either way."""
|
|
421
|
+
if not self.available:
|
|
422
|
+
return ExploratoryAnalysisReport(
|
|
423
|
+
question=question,
|
|
424
|
+
ok=False,
|
|
425
|
+
data_profile=profile,
|
|
426
|
+
error="no code-writing backend configured (judge or cli_config)",
|
|
427
|
+
workdir=str(self._sandbox.workdir),
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
self._model_calls = []
|
|
431
|
+
raw_outputs: list[str] = []
|
|
432
|
+
raw_streams: list[str] = []
|
|
433
|
+
agent_audits: list[dict[str, Any]] = []
|
|
434
|
+
code = ""
|
|
435
|
+
last_result: SandboxResult | None = None
|
|
436
|
+
last_error = ""
|
|
437
|
+
# A working report we're retrying only to fix a plain_title, kept in
|
|
438
|
+
# case the retry itself produces something worse (crashes, unparseable
|
|
439
|
+
# JSON) — a jargon-y headline must never cost the whole analysis.
|
|
440
|
+
best_report: ExploratoryAnalysisReport | None = None
|
|
441
|
+
|
|
442
|
+
for attempt in range(1, self._max_attempts + 1):
|
|
443
|
+
try:
|
|
444
|
+
if self._progress_sink is not None:
|
|
445
|
+
self._progress_sink.emit(
|
|
446
|
+
"m2_codegen", "started", "Generating analysis code", attempt=attempt
|
|
447
|
+
)
|
|
448
|
+
if attempt == 1:
|
|
449
|
+
code, raw = write_first()
|
|
450
|
+
else:
|
|
451
|
+
code, raw = self._repair_code(
|
|
452
|
+
question, profile, code, last_result, last_error
|
|
453
|
+
)
|
|
454
|
+
raw_outputs.append(raw)
|
|
455
|
+
raw_streams.append(self._last_raw_stream)
|
|
456
|
+
self._last_raw_stream = ""
|
|
457
|
+
if self._last_agent_audit is not None:
|
|
458
|
+
agent_audits.append(self._last_agent_audit)
|
|
459
|
+
self._last_agent_audit = None
|
|
460
|
+
if code.strip() and self._progress_sink is not None:
|
|
461
|
+
self._progress_sink.emit(
|
|
462
|
+
"m2_codegen", "completed", "Analysis code is ready", attempt=attempt
|
|
463
|
+
)
|
|
464
|
+
except Exception as exc: # noqa: BLE001
|
|
465
|
+
last_error = f"code writing failed: {exc}"
|
|
466
|
+
logger.warning("ExploratoryAnalysisAgent: %s", last_error)
|
|
467
|
+
if self._progress_sink is not None:
|
|
468
|
+
self._progress_sink.emit("m2_codegen", "failed", last_error, attempt=attempt)
|
|
469
|
+
break
|
|
470
|
+
|
|
471
|
+
if not code.strip():
|
|
472
|
+
last_error = "backend produced no code"
|
|
473
|
+
if self._progress_sink is not None:
|
|
474
|
+
self._progress_sink.emit(
|
|
475
|
+
"m2_codegen", "failed", last_error, attempt=attempt
|
|
476
|
+
)
|
|
477
|
+
continue
|
|
478
|
+
|
|
479
|
+
if self._progress_sink is not None:
|
|
480
|
+
self._progress_sink.emit(
|
|
481
|
+
"m2_execute", "started", "Running generated analysis", attempt=attempt
|
|
482
|
+
)
|
|
483
|
+
last_result = self._sandbox.run(code, timeout_sec=self._timeout_sec)
|
|
484
|
+
if self._progress_sink is not None:
|
|
485
|
+
self._progress_sink.emit(
|
|
486
|
+
"m2_execute", "completed" if last_result.ok else "failed",
|
|
487
|
+
"Generated analysis finished" if last_result.ok else "Generated analysis needs repair",
|
|
488
|
+
attempt=attempt,
|
|
489
|
+
metrics={"elapsed_sec": last_result.elapsed_sec, "returncode": last_result.returncode},
|
|
490
|
+
)
|
|
491
|
+
report, last_error = _report_from_sandbox(
|
|
492
|
+
question=question,
|
|
493
|
+
profile=profile,
|
|
494
|
+
code=code,
|
|
495
|
+
result=last_result,
|
|
496
|
+
attempts=attempt,
|
|
497
|
+
workdir=Path(self._sandbox.workdir),
|
|
498
|
+
)
|
|
499
|
+
report.raw_outputs = raw_outputs
|
|
500
|
+
report.raw_streams = raw_streams
|
|
501
|
+
report.agent_audits = agent_audits
|
|
502
|
+
report.model_calls = list(self._model_calls)
|
|
503
|
+
if report.ok:
|
|
504
|
+
violations = _plain_language_violations(report)
|
|
505
|
+
if not violations or attempt == self._max_attempts:
|
|
506
|
+
if violations:
|
|
507
|
+
report.critique = [
|
|
508
|
+
*report.critique,
|
|
509
|
+
"Plain-language check failed and repair attempts were "
|
|
510
|
+
"exhausted: " + "; ".join(violations),
|
|
511
|
+
]
|
|
512
|
+
return report
|
|
513
|
+
best_report = report
|
|
514
|
+
last_error = (
|
|
515
|
+
"The analysis itself is fine — do not change it. Only the "
|
|
516
|
+
"plain-language check on \"plain_title\"/\"plain_question\" "
|
|
517
|
+
"failed: " + "; ".join(violations) + ". Rewrite each flagged "
|
|
518
|
+
"field as one jargon-free, everyday sentence (numbers are "
|
|
519
|
+
"fine; acronyms/stats terms/symbols are not), keep "
|
|
520
|
+
"everything else the same, and reprint the full result JSON."
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
if best_report is not None:
|
|
524
|
+
# The plain-language retry crashed or produced something
|
|
525
|
+
# unparseable — fall back to the last working analysis rather
|
|
526
|
+
# than losing every takeaway/chart/hypothesis over a headline.
|
|
527
|
+
best_report.critique = [
|
|
528
|
+
*best_report.critique,
|
|
529
|
+
"A plain-language rewrite attempt failed (" + last_error + "); "
|
|
530
|
+
"kept the original working analysis, whose plain_title/"
|
|
531
|
+
"plain_question violation may be unfixed.",
|
|
532
|
+
]
|
|
533
|
+
best_report.model_calls = list(self._model_calls)
|
|
534
|
+
return best_report
|
|
535
|
+
|
|
536
|
+
stdout = last_result.stdout if last_result is not None else ""
|
|
537
|
+
stderr = last_result.stderr if last_result is not None else ""
|
|
538
|
+
return ExploratoryAnalysisReport(
|
|
539
|
+
question=question,
|
|
540
|
+
ok=False,
|
|
541
|
+
data_profile=profile,
|
|
542
|
+
code=code,
|
|
543
|
+
stdout=stdout,
|
|
544
|
+
stderr=stderr,
|
|
545
|
+
error=last_error or "exploratory analysis failed",
|
|
546
|
+
attempts=min(self._max_attempts, len(raw_outputs)),
|
|
547
|
+
workdir=str(self._sandbox.workdir),
|
|
548
|
+
raw_outputs=raw_outputs,
|
|
549
|
+
raw_streams=raw_streams,
|
|
550
|
+
agent_audits=agent_audits,
|
|
551
|
+
model_calls=list(self._model_calls),
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
def _write_input(self, rows: list[dict[str, Any]]) -> None:
|
|
555
|
+
path = Path(self._sandbox.workdir) / RECORDS_FILENAME
|
|
556
|
+
path.write_text(json.dumps(rows, default=str), encoding="utf-8")
|
|
557
|
+
|
|
558
|
+
def _write_code(self, question: str, profile: dict[str, Any]) -> tuple[str, str]:
|
|
559
|
+
prompt = _GENERATE_PROMPT_RECORDS.format(
|
|
560
|
+
question=question,
|
|
561
|
+
input_filename=RECORDS_FILENAME,
|
|
562
|
+
data_profile=json.dumps(profile, indent=2, default=str),
|
|
563
|
+
framing=_framing_block(profile.get("outcome") or {}),
|
|
564
|
+
marker=_RESULT_MARKER,
|
|
565
|
+
skills_hint=_skills_hint(self._cli_config),
|
|
566
|
+
fences_hint=_fences_hint(self._cli_config),
|
|
567
|
+
)
|
|
568
|
+
return self._run_writer(prompt, use_inspector=False)
|
|
569
|
+
|
|
570
|
+
def _write_code_raw_folder(
|
|
571
|
+
self,
|
|
572
|
+
question: str,
|
|
573
|
+
folder_scan: dict[str, Any],
|
|
574
|
+
raw_input_dir: str,
|
|
575
|
+
outcome_col: str | None,
|
|
576
|
+
) -> tuple[str, str]:
|
|
577
|
+
outcome_hint = (
|
|
578
|
+
f'- the caller named "{outcome_col}" as the target/outcome column — '
|
|
579
|
+
"use it as the outcome if it exists in your tidy table, regardless "
|
|
580
|
+
"of any other auto-detection heuristic you might otherwise apply\n"
|
|
581
|
+
if outcome_col else ""
|
|
582
|
+
)
|
|
583
|
+
prompt = _GENERATE_PROMPT_RAW_FOLDER.format(
|
|
584
|
+
question=question,
|
|
585
|
+
input_filename=RECORDS_FILENAME,
|
|
586
|
+
raw_input_dir=raw_input_dir,
|
|
587
|
+
folder_scan=json.dumps(folder_scan, indent=2, default=str),
|
|
588
|
+
outcome_hint=outcome_hint,
|
|
589
|
+
framing=_GENERIC_FRAMING,
|
|
590
|
+
marker=_RESULT_MARKER,
|
|
591
|
+
skills_hint=_skills_hint(self._cli_config),
|
|
592
|
+
fences_hint=_fences_hint(self._cli_config),
|
|
593
|
+
)
|
|
594
|
+
return self._run_cli_writer(prompt)
|
|
595
|
+
|
|
596
|
+
def _repair_code(
|
|
597
|
+
self,
|
|
598
|
+
question: str,
|
|
599
|
+
profile: dict[str, Any],
|
|
600
|
+
code: str,
|
|
601
|
+
result: SandboxResult | None,
|
|
602
|
+
error: str,
|
|
603
|
+
) -> tuple[str, str]:
|
|
604
|
+
prompt = _REPAIR_PROMPT.format(
|
|
605
|
+
question=question,
|
|
606
|
+
data_profile=json.dumps(profile, indent=2, default=str),
|
|
607
|
+
code=code,
|
|
608
|
+
stdout=(result.stdout if result is not None else "")[-2000:],
|
|
609
|
+
stderr=(result.stderr if result is not None else "")[-2000:],
|
|
610
|
+
error=error,
|
|
611
|
+
input_filename=RECORDS_FILENAME,
|
|
612
|
+
marker=_RESULT_MARKER,
|
|
613
|
+
fences_hint=_fences_hint(self._cli_config),
|
|
614
|
+
)
|
|
615
|
+
return self._run_writer(prompt, use_inspector=True)
|
|
616
|
+
|
|
617
|
+
def _run_writer(self, prompt: str, *, use_inspector: bool) -> tuple[str, str]:
|
|
618
|
+
if self._cli_config is not None and self._cli_config.provider != "llm":
|
|
619
|
+
return self._run_cli_writer(prompt)
|
|
620
|
+
model = self._inspector if use_inspector and self._inspector is not None else self._judge
|
|
621
|
+
started = time.perf_counter()
|
|
622
|
+
try:
|
|
623
|
+
raw = model.generate(prompt) # type: ignore[union-attr]
|
|
624
|
+
except Exception as exc:
|
|
625
|
+
self._model_calls.append({
|
|
626
|
+
"role": "explore_inspector" if use_inspector else "explore_coder",
|
|
627
|
+
"operation": "generate", "inputs": prompt, "output": None,
|
|
628
|
+
"error": str(exc), "duration_sec": time.perf_counter() - started,
|
|
629
|
+
})
|
|
630
|
+
raise
|
|
631
|
+
raw_text = str(raw)
|
|
632
|
+
self._model_calls.append({
|
|
633
|
+
"role": "explore_inspector" if use_inspector else "explore_coder",
|
|
634
|
+
"operation": "generate", "inputs": prompt, "output": raw_text,
|
|
635
|
+
"error": None, "duration_sec": time.perf_counter() - started,
|
|
636
|
+
})
|
|
637
|
+
return _extract_code(raw_text), raw_text
|
|
638
|
+
|
|
639
|
+
def _run_cli_writer(self, prompt: str) -> tuple[str, str]:
|
|
640
|
+
from evalrx.agent_runtime.codegen import CodegenRunner
|
|
641
|
+
|
|
642
|
+
started = time.perf_counter()
|
|
643
|
+
try:
|
|
644
|
+
result = CodegenRunner(self._cli_config).write_code( # type: ignore[arg-type]
|
|
645
|
+
prompt,
|
|
646
|
+
workdir=Path(self._sandbox.workdir),
|
|
647
|
+
timeout_sec=self._timeout_sec,
|
|
648
|
+
preferred_filenames=("analysis.py",),
|
|
649
|
+
include_error_in_raw=True,
|
|
650
|
+
)
|
|
651
|
+
except Exception as exc:
|
|
652
|
+
self._model_calls.append({
|
|
653
|
+
"role": "explore_cli_coder", "operation": "write_code",
|
|
654
|
+
"inputs": prompt, "output": None, "error": str(exc),
|
|
655
|
+
"duration_sec": time.perf_counter() - started,
|
|
656
|
+
})
|
|
657
|
+
raise
|
|
658
|
+
self._model_calls.append({
|
|
659
|
+
"role": "explore_cli_coder", "operation": "write_code",
|
|
660
|
+
"inputs": prompt, "output": result.raw_output, "error": None,
|
|
661
|
+
"duration_sec": time.perf_counter() - started,
|
|
662
|
+
"metadata": {"audit": result.audit},
|
|
663
|
+
})
|
|
664
|
+
self._last_agent_audit = result.audit
|
|
665
|
+
# Snapshot the untruncated raw stream NOW: the next repair attempt in
|
|
666
|
+
# the loop overwrites agent_raw_stream.txt in the shared workdir.
|
|
667
|
+
self._last_raw_stream = ""
|
|
668
|
+
if getattr(result, "raw_stream_path", ""):
|
|
669
|
+
try:
|
|
670
|
+
stream_p = Path(self._sandbox.workdir) / result.raw_stream_path
|
|
671
|
+
self._last_raw_stream = stream_p.read_text(encoding="utf-8")
|
|
672
|
+
except OSError:
|
|
673
|
+
pass
|
|
674
|
+
return result.code, result.raw_output
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _records_to_rows(records: Any) -> list[dict[str, Any]]:
|
|
678
|
+
if records is None:
|
|
679
|
+
return []
|
|
680
|
+
if hasattr(records, "to_dict"):
|
|
681
|
+
try:
|
|
682
|
+
data = records.to_dict(orient="records")
|
|
683
|
+
if isinstance(data, list):
|
|
684
|
+
return [dict(r) for r in data if isinstance(r, dict)]
|
|
685
|
+
except TypeError:
|
|
686
|
+
pass
|
|
687
|
+
rows: list[dict[str, Any]] = []
|
|
688
|
+
for row in list(records):
|
|
689
|
+
if isinstance(row, dict):
|
|
690
|
+
rows.append(dict(row))
|
|
691
|
+
elif hasattr(row, "_asdict"):
|
|
692
|
+
rows.append(dict(row._asdict()))
|
|
693
|
+
elif hasattr(row, "__dict__"):
|
|
694
|
+
rows.append(dict(vars(row)))
|
|
695
|
+
else:
|
|
696
|
+
rows.append({"value": row})
|
|
697
|
+
return rows
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _discover_json_files(
|
|
701
|
+
root: Path, *, max_files: int, include_tool_calls: bool
|
|
702
|
+
) -> tuple[list[Path], int]:
|
|
703
|
+
"""Find the JSON/JSONL files ``load_records_from_path`` would read.
|
|
704
|
+
|
|
705
|
+
Returns ``(files_used, n_matching_before_filter_and_cap)`` — the second
|
|
706
|
+
value counts every ``*.json`` file on disk (including ``tool_calls_*``),
|
|
707
|
+
so :func:`scan_folder` can show how many were excluded/capped, not just
|
|
708
|
+
how many survived."""
|
|
709
|
+
if root.is_file():
|
|
710
|
+
return [root], 1
|
|
711
|
+
all_json = sorted(root.rglob("*.json"))
|
|
712
|
+
files = all_json
|
|
713
|
+
if not include_tool_calls:
|
|
714
|
+
files = [p for p in files if not p.name.startswith("tool_calls_")]
|
|
715
|
+
return files[:max_files], len(all_json)
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def scan_folder(
|
|
719
|
+
path: str | Path,
|
|
720
|
+
*,
|
|
721
|
+
max_files: int = 200,
|
|
722
|
+
include_tool_calls: bool = False,
|
|
723
|
+
max_listing: int = 60,
|
|
724
|
+
max_scan_entries: int = 200_000,
|
|
725
|
+
) -> dict[str, Any]:
|
|
726
|
+
"""Filesystem-level summary of what a folder actually contains.
|
|
727
|
+
|
|
728
|
+
This is deliberately independent of :class:`~evalrx.analysis.profile.
|
|
729
|
+
DatasetProfile` (which describes parsed row/column structure): it answers
|
|
730
|
+
"what did the agent see on disk" — file/dir counts, extension mix, and how
|
|
731
|
+
many of the discovered JSON files were actually sampled — since that
|
|
732
|
+
differs from folder to folder and is useful to see before any row-level
|
|
733
|
+
parsing happens.
|
|
734
|
+
|
|
735
|
+
Uses a single ``os.walk`` pass rather than ``Path.rglob`` + per-entry
|
|
736
|
+
``is_file()``/``is_dir()`` checks: real run directories can hold tens of
|
|
737
|
+
thousands of files (one JSON per tool call), and ``rglob`` results lose
|
|
738
|
+
the cached direntry type, so each check becomes its own ``stat()`` — on
|
|
739
|
+
networked storage that made this function slower than the actual row
|
|
740
|
+
loader it's meant to summarize. ``max_scan_entries`` is a hard backstop so
|
|
741
|
+
an unexpectedly huge tree degrades (partial counts, flagged as capped)
|
|
742
|
+
instead of hanging.
|
|
743
|
+
"""
|
|
744
|
+
root = Path(path)
|
|
745
|
+
if root.is_file():
|
|
746
|
+
return {
|
|
747
|
+
"root": str(root),
|
|
748
|
+
"is_file": True,
|
|
749
|
+
"n_files_total": 1,
|
|
750
|
+
"n_dirs": 0,
|
|
751
|
+
"extensions": {root.suffix.lower() or "(no extension)": 1},
|
|
752
|
+
"json_files_found": 1,
|
|
753
|
+
"json_files_used": 1,
|
|
754
|
+
"entries": [root.name],
|
|
755
|
+
"truncated": False,
|
|
756
|
+
"scan_capped": False,
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
n_files = 0
|
|
760
|
+
n_dirs = 0
|
|
761
|
+
n_json_found = 0
|
|
762
|
+
n_json_after_filter = 0
|
|
763
|
+
ext_counts: dict[str, int] = {}
|
|
764
|
+
entries: list[str] = []
|
|
765
|
+
scan_capped = False
|
|
766
|
+
scanned = 0
|
|
767
|
+
|
|
768
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
769
|
+
dirnames.sort()
|
|
770
|
+
filenames.sort()
|
|
771
|
+
rel_dir = Path(dirpath).relative_to(root)
|
|
772
|
+
|
|
773
|
+
for name in dirnames:
|
|
774
|
+
n_dirs += 1
|
|
775
|
+
scanned += 1
|
|
776
|
+
if len(entries) < max_listing:
|
|
777
|
+
rel = name if rel_dir == Path(".") else str(rel_dir / name)
|
|
778
|
+
entries.append(rel + "/")
|
|
779
|
+
|
|
780
|
+
for name in filenames:
|
|
781
|
+
n_files += 1
|
|
782
|
+
scanned += 1
|
|
783
|
+
ext = Path(name).suffix.lower() or "(no extension)"
|
|
784
|
+
ext_counts[ext] = ext_counts.get(ext, 0) + 1
|
|
785
|
+
if name.endswith(".json"):
|
|
786
|
+
n_json_found += 1
|
|
787
|
+
if include_tool_calls or not name.startswith("tool_calls_"):
|
|
788
|
+
n_json_after_filter += 1
|
|
789
|
+
if len(entries) < max_listing:
|
|
790
|
+
rel = name if rel_dir == Path(".") else str(rel_dir / name)
|
|
791
|
+
entries.append(rel)
|
|
792
|
+
|
|
793
|
+
if scanned >= max_scan_entries:
|
|
794
|
+
scan_capped = True
|
|
795
|
+
break
|
|
796
|
+
|
|
797
|
+
entries.sort()
|
|
798
|
+
return {
|
|
799
|
+
"root": str(root),
|
|
800
|
+
"is_file": False,
|
|
801
|
+
"n_files_total": n_files,
|
|
802
|
+
"n_dirs": n_dirs,
|
|
803
|
+
"extensions": dict(sorted(ext_counts.items(), key=lambda kv: -kv[1])),
|
|
804
|
+
"json_files_found": n_json_found,
|
|
805
|
+
"json_files_used": min(n_json_after_filter, max_files),
|
|
806
|
+
"entries": entries[:max_listing],
|
|
807
|
+
"truncated": (n_files + n_dirs) > max_listing,
|
|
808
|
+
"scan_capped": scan_capped,
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def load_records_from_path(
|
|
813
|
+
path: str | Path,
|
|
814
|
+
*,
|
|
815
|
+
max_rows: int = 2000,
|
|
816
|
+
max_files: int = 200,
|
|
817
|
+
include_tool_calls: bool = False,
|
|
818
|
+
) -> list[dict[str, Any]]:
|
|
819
|
+
"""Load a bounded sample of JSON/JSONL records from a file or directory."""
|
|
820
|
+
root = Path(path)
|
|
821
|
+
files, _ = _discover_json_files(root, max_files=max_files, include_tool_calls=include_tool_calls)
|
|
822
|
+
per_file_limit = max(1, max_rows // max(1, len(files)))
|
|
823
|
+
rows: list[dict[str, Any]] = []
|
|
824
|
+
for file_path in files:
|
|
825
|
+
n_from_file = 0
|
|
826
|
+
for row in _load_json_records(file_path):
|
|
827
|
+
flat = _flatten_record(row)
|
|
828
|
+
flat["_source_file"] = str(file_path)
|
|
829
|
+
flat["_source_dir"] = file_path.parent.name
|
|
830
|
+
rows.append(flat)
|
|
831
|
+
n_from_file += 1
|
|
832
|
+
if len(rows) >= max_rows:
|
|
833
|
+
return rows
|
|
834
|
+
if n_from_file >= per_file_limit:
|
|
835
|
+
break
|
|
836
|
+
return rows
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
_RECORD_LIST_KEYS = ("cases", "records", "results", "rows", "items", "data", "examples", "samples")
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def _load_json_records(path: Path) -> list[Any]:
|
|
843
|
+
try:
|
|
844
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
845
|
+
except OSError:
|
|
846
|
+
return []
|
|
847
|
+
stripped = text.strip()
|
|
848
|
+
if not stripped:
|
|
849
|
+
return []
|
|
850
|
+
try:
|
|
851
|
+
parsed = json.loads(stripped)
|
|
852
|
+
if isinstance(parsed, list):
|
|
853
|
+
return parsed
|
|
854
|
+
if isinstance(parsed, dict):
|
|
855
|
+
unpacked = _unpack_record_container(parsed)
|
|
856
|
+
if unpacked is not None:
|
|
857
|
+
return unpacked
|
|
858
|
+
return [parsed]
|
|
859
|
+
except json.JSONDecodeError:
|
|
860
|
+
pass
|
|
861
|
+
records: list[Any] = []
|
|
862
|
+
for line in text.splitlines():
|
|
863
|
+
line = line.strip()
|
|
864
|
+
if not line:
|
|
865
|
+
continue
|
|
866
|
+
try:
|
|
867
|
+
records.append(json.loads(line))
|
|
868
|
+
except json.JSONDecodeError:
|
|
869
|
+
continue
|
|
870
|
+
return records
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def _unpack_record_container(parsed: dict[str, Any]) -> list[dict[str, Any]] | None:
|
|
874
|
+
"""Recognize a common M1-output shape: one JSON object per run/model, with
|
|
875
|
+
a list of per-case dicts under a conventional key (``cases``, ``results``,
|
|
876
|
+
...) alongside scalar run metadata (e.g. ``model``, ``seed``). Unpack it
|
|
877
|
+
into one row per case, each carrying the file's scalar metadata merged in
|
|
878
|
+
(per-case fields win on collision) — so a directory of these files loads
|
|
879
|
+
as a flat, analyzable table without a bespoke pre-processing script."""
|
|
880
|
+
list_key = next(
|
|
881
|
+
(
|
|
882
|
+
key
|
|
883
|
+
for key in _RECORD_LIST_KEYS
|
|
884
|
+
if isinstance(parsed.get(key), list)
|
|
885
|
+
and parsed[key]
|
|
886
|
+
and all(isinstance(item, dict) for item in parsed[key])
|
|
887
|
+
),
|
|
888
|
+
None,
|
|
889
|
+
)
|
|
890
|
+
if list_key is None:
|
|
891
|
+
return None
|
|
892
|
+
meta = {
|
|
893
|
+
key: value
|
|
894
|
+
for key, value in parsed.items()
|
|
895
|
+
if key != list_key and (isinstance(value, (str, int, float, bool)) or value is None)
|
|
896
|
+
}
|
|
897
|
+
return [{**meta, **item} for item in parsed[list_key]]
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def _flatten_record(record: Any, *, max_text: int = 500) -> dict[str, Any]:
|
|
901
|
+
if not isinstance(record, dict):
|
|
902
|
+
return {"value": _shorten(record, max_text)}
|
|
903
|
+
|
|
904
|
+
out: dict[str, Any] = {}
|
|
905
|
+
for key, value in record.items():
|
|
906
|
+
if isinstance(value, (str, int, float, bool)) or value is None:
|
|
907
|
+
out[str(key)] = _shorten(value, max_text)
|
|
908
|
+
elif isinstance(value, dict):
|
|
909
|
+
for sub_key, sub_value in value.items():
|
|
910
|
+
flat_key = f"{key}.{sub_key}"
|
|
911
|
+
if isinstance(sub_value, (str, int, float, bool)) or sub_value is None:
|
|
912
|
+
out[flat_key] = _shorten(sub_value, max_text)
|
|
913
|
+
elif isinstance(value, list):
|
|
914
|
+
out[f"{key}._len"] = len(value)
|
|
915
|
+
if key == "model_answer":
|
|
916
|
+
out["model_answer_text"] = _shorten(_join_text_blocks(value), max_text)
|
|
917
|
+
else:
|
|
918
|
+
out[str(key)] = _shorten(value, max_text)
|
|
919
|
+
|
|
920
|
+
if "is_correct" in out:
|
|
921
|
+
out.setdefault("label", "pass" if bool(out["is_correct"]) else "fail")
|
|
922
|
+
attempts = record.get("attempts")
|
|
923
|
+
if isinstance(attempts, list):
|
|
924
|
+
out.setdefault("attempts_count", len(attempts))
|
|
925
|
+
trace = record.get("trace")
|
|
926
|
+
if isinstance(trace, list):
|
|
927
|
+
out.setdefault("trace_steps", len(trace))
|
|
928
|
+
return out
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def _join_text_blocks(value: list[Any]) -> str:
|
|
932
|
+
parts: list[str] = []
|
|
933
|
+
for item in value:
|
|
934
|
+
if isinstance(item, dict) and item.get("type") == "text":
|
|
935
|
+
parts.append(str(item.get("text", "")))
|
|
936
|
+
elif isinstance(item, str):
|
|
937
|
+
parts.append(item)
|
|
938
|
+
return "\n".join(parts)
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def _shorten(value: Any, max_text: int) -> Any:
|
|
942
|
+
if not isinstance(value, str):
|
|
943
|
+
return value
|
|
944
|
+
if len(value) <= max_text:
|
|
945
|
+
return value
|
|
946
|
+
return value[:max_text] + f"... [truncated {len(value) - max_text} chars]"
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def _profile_rows(
|
|
950
|
+
rows: list[dict[str, Any]],
|
|
951
|
+
*,
|
|
952
|
+
outcome_col: str | None = None,
|
|
953
|
+
sample_size: int = 5,
|
|
954
|
+
) -> dict[str, Any]:
|
|
955
|
+
"""Profile rows for the explorer prompt, including outcome-kind detection.
|
|
956
|
+
|
|
957
|
+
Delegates role/dtype inference to :func:`profile_records` (the shared,
|
|
958
|
+
domain-agnostic profiler) rather than re-deriving it from raw values, so
|
|
959
|
+
the prompt can adapt to binary / categorical / continuous / no-outcome
|
|
960
|
+
data instead of always assuming a FAIL/PASS split.
|
|
961
|
+
"""
|
|
962
|
+
profile = profile_records(rows, outcome_col=outcome_col)
|
|
963
|
+
outcome = describe_outcome(profile)
|
|
964
|
+
numeric_columns = [
|
|
965
|
+
c.name for c in profile.columns.values()
|
|
966
|
+
if c.dtype == "numeric" and c.role == "predictor"
|
|
967
|
+
]
|
|
968
|
+
categorical_columns = [
|
|
969
|
+
c.name for c in profile.columns.values()
|
|
970
|
+
if c.dtype in ("categorical", "boolean") and c.role == "predictor"
|
|
971
|
+
]
|
|
972
|
+
label_like = [outcome["column"]] if outcome["present"] else []
|
|
973
|
+
return {
|
|
974
|
+
"n_rows": profile.n_rows,
|
|
975
|
+
"grain": profile.grain,
|
|
976
|
+
"columns": {name: c.to_dict() for name, c in profile.columns.items()},
|
|
977
|
+
"numeric_columns": numeric_columns,
|
|
978
|
+
"categorical_columns": categorical_columns,
|
|
979
|
+
"label_like_columns": label_like,
|
|
980
|
+
"id_columns": profile.id_columns,
|
|
981
|
+
"group_columns": profile.group_columns,
|
|
982
|
+
"time_columns": profile.time_columns,
|
|
983
|
+
"outcome": outcome,
|
|
984
|
+
"warnings": profile.warnings,
|
|
985
|
+
"sample_rows": rows[:sample_size],
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def _framing_block(outcome: dict[str, Any]) -> str:
|
|
990
|
+
"""Build the prompt's outcome-specific framing + standard chart battery.
|
|
991
|
+
|
|
992
|
+
This is what stops the explorer from forcing a FAIL/PASS story onto data
|
|
993
|
+
that has a multi-class, continuous, or absent outcome. ``outcome`` is the
|
|
994
|
+
``describe_outcome()`` dict already embedded in the data profile.
|
|
995
|
+
"""
|
|
996
|
+
kind = outcome.get("kind", "none")
|
|
997
|
+
col = outcome.get("column")
|
|
998
|
+
unique = outcome.get("unique", 0)
|
|
999
|
+
|
|
1000
|
+
if kind == "binary":
|
|
1001
|
+
return f"""\
|
|
1002
|
+
OUTCOME: "{col}" is a BINARY outcome (host detected {unique} distinct values).
|
|
1003
|
+
Call the two groups FAIL and PASS (map the two outcome values to whichever
|
|
1004
|
+
reads as the negative/positive case) and tell the FAIL-vs-PASS story.
|
|
1005
|
+
|
|
1006
|
+
As a minimum, consider this standard battery when the columns exist:
|
|
1007
|
+
1. Class balance: count of FAIL vs PASS overall (and per group column if present).
|
|
1008
|
+
2. Per numeric signal — how it separates FAIL vs PASS: a distribution view
|
|
1009
|
+
(violin/box/strip, drawn as a PNG) AND a binned fail-rate curve spec
|
|
1010
|
+
(bin -> fail_rate, n per bin). The curve is the deterministic spec for a
|
|
1011
|
+
numeric signal — do not emit a two-group "mean by outcome" spec instead.
|
|
1012
|
+
3. Top discriminators: a ranked bar of each signal's FAIL-vs-PASS separation
|
|
1013
|
+
(e.g. standardized mean difference / |meanFAIL - meanPASS| / s), largest first.
|
|
1014
|
+
4. Fail rate by each categorical group column: group -> fail_rate with n and
|
|
1015
|
+
n_fail per group (the host draws <= 3 groups as dot + 95% CI, never bars).
|
|
1016
|
+
5. Signal correlations: a correlation table and, when helpful, a heatmap PNG.
|
|
1017
|
+
6. 1-2 scatter plots of the most discriminative signal pairs, coloured by outcome."""
|
|
1018
|
+
|
|
1019
|
+
if kind == "categorical":
|
|
1020
|
+
return f"""\
|
|
1021
|
+
OUTCOME: "{col}" is a CATEGORICAL outcome with {unique} distinct classes. Tell
|
|
1022
|
+
the per-class story — do NOT collapse it into a binary FAIL/PASS split.
|
|
1023
|
+
|
|
1024
|
+
As a minimum, consider this standard battery when the columns exist:
|
|
1025
|
+
1. Class balance: count per class overall (and per group column if present).
|
|
1026
|
+
2. Per numeric signal — how its distribution differs across classes (box/violin
|
|
1027
|
+
per class), AND a ranked bar of each signal's cross-class separation (e.g.
|
|
1028
|
+
between-class variance ratio or ANOVA-style F statistic), largest first.
|
|
1029
|
+
3. Class composition by each categorical group column (grouped/stacked bar).
|
|
1030
|
+
4. Signal correlations: a correlation table and, when helpful, a heatmap PNG.
|
|
1031
|
+
5. 1-2 scatter plots of the most discriminative signal pairs, coloured by class."""
|
|
1032
|
+
|
|
1033
|
+
if kind == "continuous":
|
|
1034
|
+
return f"""\
|
|
1035
|
+
OUTCOME: "{col}" is a CONTINUOUS outcome ({unique} distinct values seen). This
|
|
1036
|
+
is a correlation/regression-style story — there is no FAIL/PASS split to
|
|
1037
|
+
invent; do not binarize the outcome unless the question explicitly asks for it.
|
|
1038
|
+
|
|
1039
|
+
As a minimum, consider this standard battery when the columns exist:
|
|
1040
|
+
1. Outcome distribution (histogram).
|
|
1041
|
+
2. Per numeric signal vs outcome: scatter with a trend line, AND a binned
|
|
1042
|
+
mean-outcome curve (bin -> mean {col}).
|
|
1043
|
+
3. Top associates: a ranked bar of each signal's correlation magnitude with
|
|
1044
|
+
the outcome (Pearson and/or Spearman), largest first.
|
|
1045
|
+
4. Outcome distribution by each categorical group column (box/violin per group).
|
|
1046
|
+
5. Signal correlations among predictors: a correlation table and heatmap PNG.
|
|
1047
|
+
6. 1-2 scatter plots of the most associated signal pairs, coloured/sized by outcome."""
|
|
1048
|
+
|
|
1049
|
+
return """\
|
|
1050
|
+
OUTCOME: no recognizable outcome/target column was found. This is UNSUPERVISED
|
|
1051
|
+
exploration — describe the dataset's structure; do NOT invent a FAIL/PASS or
|
|
1052
|
+
any other label that is not actually in the data.
|
|
1053
|
+
|
|
1054
|
+
As a minimum, consider this standard battery when the columns exist:
|
|
1055
|
+
1. Missingness overview: non-null rate per column (bar).
|
|
1056
|
+
2. Per numeric column: distribution (histogram/box).
|
|
1057
|
+
3. Per categorical column: value counts (bar); skip columns with very high
|
|
1058
|
+
cardinality (say >20 distinct values) as a bar chart and note it in caveats.
|
|
1059
|
+
4. Signal correlations: a correlation table and, when helpful, a heatmap PNG
|
|
1060
|
+
among the numeric columns.
|
|
1061
|
+
5. 1-2 scatter plots of the most correlated numeric pairs.
|
|
1062
|
+
6. If a group or time column exists, contrast numeric distributions across
|
|
1063
|
+
its groups/periods; otherwise skip this item."""
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
def _plain_language_violations(report: ExploratoryAnalysisReport) -> list[str]:
|
|
1067
|
+
"""Host-side check behind the prompt's ``plain_title``/``plain_question``
|
|
1068
|
+
requirement — the prompt already asks for jargon-free headlines, but that
|
|
1069
|
+
alone doesn't hold, so this is what actually gates a retry (see
|
|
1070
|
+
``_run_explore_loop``)."""
|
|
1071
|
+
violations = []
|
|
1072
|
+
for i, t in enumerate(report.takeaways, start=1):
|
|
1073
|
+
reason = jargon_violation(t.plain_title, t.title)
|
|
1074
|
+
if reason:
|
|
1075
|
+
violations.append(f'takeaway {i}\'s "plain_title" {reason}: {t.plain_title!r}')
|
|
1076
|
+
# The caller's own `question` is often already plain (short, jargon-free)
|
|
1077
|
+
# — don't force a pointless rewrite of a fine question just because
|
|
1078
|
+
# plain_question is empty or a verbatim copy in that case.
|
|
1079
|
+
if jargon_violation(report.question) is not None:
|
|
1080
|
+
reason = jargon_violation(report.plain_question, report.question)
|
|
1081
|
+
if reason:
|
|
1082
|
+
violations.append(f'"plain_question" {reason}: {report.plain_question!r}')
|
|
1083
|
+
return violations
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
def _report_from_sandbox(
|
|
1087
|
+
*,
|
|
1088
|
+
question: str,
|
|
1089
|
+
profile: dict[str, Any],
|
|
1090
|
+
code: str,
|
|
1091
|
+
result: SandboxResult,
|
|
1092
|
+
attempts: int,
|
|
1093
|
+
workdir: Path,
|
|
1094
|
+
) -> tuple[ExploratoryAnalysisReport, str]:
|
|
1095
|
+
if not result.ok:
|
|
1096
|
+
err = (result.stderr or "").strip() or "sandbox run failed"
|
|
1097
|
+
return (
|
|
1098
|
+
ExploratoryAnalysisReport(
|
|
1099
|
+
question=question,
|
|
1100
|
+
ok=False,
|
|
1101
|
+
data_profile=profile,
|
|
1102
|
+
code=code,
|
|
1103
|
+
stdout=result.stdout,
|
|
1104
|
+
stderr=result.stderr,
|
|
1105
|
+
error=err,
|
|
1106
|
+
attempts=attempts,
|
|
1107
|
+
workdir=str(workdir),
|
|
1108
|
+
),
|
|
1109
|
+
err,
|
|
1110
|
+
)
|
|
1111
|
+
|
|
1112
|
+
parsed, err = _parse_result_json(result.stdout)
|
|
1113
|
+
if err:
|
|
1114
|
+
return (
|
|
1115
|
+
ExploratoryAnalysisReport(
|
|
1116
|
+
question=question,
|
|
1117
|
+
ok=False,
|
|
1118
|
+
data_profile=profile,
|
|
1119
|
+
code=code,
|
|
1120
|
+
stdout=result.stdout,
|
|
1121
|
+
stderr=result.stderr,
|
|
1122
|
+
error=err,
|
|
1123
|
+
attempts=attempts,
|
|
1124
|
+
workdir=str(workdir),
|
|
1125
|
+
),
|
|
1126
|
+
err,
|
|
1127
|
+
)
|
|
1128
|
+
|
|
1129
|
+
plots = _normalize_plot_paths(parsed.get("plots", []), workdir)
|
|
1130
|
+
takeaways = [
|
|
1131
|
+
Takeaway(
|
|
1132
|
+
title=str(item.get("title", "")),
|
|
1133
|
+
plain_title=str(item.get("plain_title", "")),
|
|
1134
|
+
analysis=str(item.get("analysis", "")),
|
|
1135
|
+
chart_names=[str(x) for x in item.get("chart_names", []) or []],
|
|
1136
|
+
table_names=[str(x) for x in item.get("table_names", []) or []],
|
|
1137
|
+
caveat=str(item.get("caveat", "")),
|
|
1138
|
+
)
|
|
1139
|
+
for item in parsed.get("takeaways", []) or []
|
|
1140
|
+
if isinstance(item, dict) and (item.get("title") or item.get("plain_title"))
|
|
1141
|
+
]
|
|
1142
|
+
signals = [
|
|
1143
|
+
CandidateSignal(
|
|
1144
|
+
name=str(item.get("name", "")),
|
|
1145
|
+
display_name=str(item.get("display_name", "")),
|
|
1146
|
+
rationale=str(item.get("rationale", "")),
|
|
1147
|
+
suggested_test=str(item.get("suggested_test", "")),
|
|
1148
|
+
sufficient=item["sufficient"] if isinstance(item.get("sufficient"), dict) else None,
|
|
1149
|
+
recipe=item["recipe"] if isinstance(item.get("recipe"), dict) else None,
|
|
1150
|
+
)
|
|
1151
|
+
for item in parsed.get("candidate_signals", []) or []
|
|
1152
|
+
if isinstance(item, dict) and item.get("name")
|
|
1153
|
+
]
|
|
1154
|
+
return (
|
|
1155
|
+
ExploratoryAnalysisReport(
|
|
1156
|
+
question=question,
|
|
1157
|
+
plain_question=str(parsed.get("plain_question", "")),
|
|
1158
|
+
ok=True,
|
|
1159
|
+
observations=[str(x) for x in parsed.get("observations", []) or []],
|
|
1160
|
+
takeaways=takeaways,
|
|
1161
|
+
visual_plan=_normalize_visual_plan(parsed.get("visual_plan", [])),
|
|
1162
|
+
chart_readings=_normalize_list_of_dicts(parsed.get("chart_readings", [])),
|
|
1163
|
+
dashboard_storyboard=_normalize_list_of_dicts(parsed.get("dashboard_storyboard", [])),
|
|
1164
|
+
claims=_normalize_list_of_dicts(parsed.get("claims", [])),
|
|
1165
|
+
candidate_signals=signals,
|
|
1166
|
+
plots=plots,
|
|
1167
|
+
tables=dict(parsed.get("tables", {}) or {}),
|
|
1168
|
+
charts=[dict(x) for x in parsed.get("charts", []) or [] if isinstance(x, dict)],
|
|
1169
|
+
caveats=[str(x) for x in parsed.get("caveats", []) or []],
|
|
1170
|
+
critique=[str(x) for x in parsed.get("critique", []) or []],
|
|
1171
|
+
recommended_confirmatory_tests=[
|
|
1172
|
+
str(x) for x in parsed.get("recommended_confirmatory_tests", []) or []
|
|
1173
|
+
],
|
|
1174
|
+
data_profile=profile,
|
|
1175
|
+
code=code,
|
|
1176
|
+
stdout=result.stdout,
|
|
1177
|
+
stderr=result.stderr,
|
|
1178
|
+
attempts=attempts,
|
|
1179
|
+
workdir=str(workdir),
|
|
1180
|
+
),
|
|
1181
|
+
"",
|
|
1182
|
+
)
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
def _parse_result_json(stdout: str) -> tuple[dict[str, Any], str]:
|
|
1186
|
+
"""The explorer's result object: the JSON after the last ``EXPLORATORY_RESULT_JSON=``.
|
|
1187
|
+
|
|
1188
|
+
Multi-line (``json.dumps(..., indent=2)``) payloads are accepted — see
|
|
1189
|
+
:mod:`evalrx.analysis.result_marker` for the failure that motivated it.
|
|
1190
|
+
"""
|
|
1191
|
+
from evalrx.analysis.result_marker import extract_marker_json
|
|
1192
|
+
|
|
1193
|
+
parsed, error = extract_marker_json(stdout, _RESULT_MARKER)
|
|
1194
|
+
if error:
|
|
1195
|
+
return {}, error
|
|
1196
|
+
if not isinstance(parsed, dict):
|
|
1197
|
+
return {}, f"{_RESULT_MARKER} payload must be a JSON object"
|
|
1198
|
+
return parsed, ""
|
|
1199
|
+
|
|
1200
|
+
|
|
1201
|
+
def _normalize_visual_plan(raw: Any) -> list[dict[str, Any]]:
|
|
1202
|
+
"""Keep agent plot-type decisions auditable without making old reports invalid."""
|
|
1203
|
+
plan: list[dict[str, Any]] = []
|
|
1204
|
+
for idx, item in enumerate(raw or []):
|
|
1205
|
+
if not isinstance(item, dict):
|
|
1206
|
+
continue
|
|
1207
|
+
cleaned: dict[str, Any] = {}
|
|
1208
|
+
for key in (
|
|
1209
|
+
"name",
|
|
1210
|
+
"display_name",
|
|
1211
|
+
"question",
|
|
1212
|
+
"data_shape",
|
|
1213
|
+
"plot_kind",
|
|
1214
|
+
"fallback_kind",
|
|
1215
|
+
"rationale",
|
|
1216
|
+
"status",
|
|
1217
|
+
"disposition",
|
|
1218
|
+
"not_promoted_reason",
|
|
1219
|
+
):
|
|
1220
|
+
if item.get(key) is not None:
|
|
1221
|
+
cleaned[key] = str(item.get(key))
|
|
1222
|
+
cols = item.get("required_columns")
|
|
1223
|
+
if isinstance(cols, list):
|
|
1224
|
+
cleaned["required_columns"] = [str(c) for c in cols]
|
|
1225
|
+
elif cols is not None:
|
|
1226
|
+
cleaned["required_columns"] = [str(cols)]
|
|
1227
|
+
if "name" not in cleaned:
|
|
1228
|
+
cleaned["name"] = f"visual_{idx}"
|
|
1229
|
+
plan.append(cleaned)
|
|
1230
|
+
return plan
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
def _normalize_list_of_dicts(raw: Any) -> list[dict[str, Any]]:
|
|
1234
|
+
out: list[dict[str, Any]] = []
|
|
1235
|
+
for item in raw or []:
|
|
1236
|
+
if not isinstance(item, dict):
|
|
1237
|
+
continue
|
|
1238
|
+
cleaned: dict[str, Any] = {}
|
|
1239
|
+
for key, value in item.items():
|
|
1240
|
+
if isinstance(value, (str, int, float, bool)) or value is None:
|
|
1241
|
+
cleaned[str(key)] = value
|
|
1242
|
+
elif isinstance(value, list):
|
|
1243
|
+
cleaned[str(key)] = [str(x) for x in value]
|
|
1244
|
+
elif isinstance(value, dict):
|
|
1245
|
+
cleaned[str(key)] = {str(k): str(v) for k, v in value.items()}
|
|
1246
|
+
else:
|
|
1247
|
+
cleaned[str(key)] = str(value)
|
|
1248
|
+
out.append(cleaned)
|
|
1249
|
+
return out
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
def _normalize_plot_paths(raw: Any, workdir: Path) -> list[str]:
|
|
1253
|
+
paths: list[str] = []
|
|
1254
|
+
for item in raw or []:
|
|
1255
|
+
text = str(item)
|
|
1256
|
+
path = Path(text)
|
|
1257
|
+
if not path.is_absolute():
|
|
1258
|
+
candidate = workdir / path
|
|
1259
|
+
if candidate.exists():
|
|
1260
|
+
path = candidate
|
|
1261
|
+
paths.append(str(path))
|
|
1262
|
+
figures_dir = workdir / "figures"
|
|
1263
|
+
if figures_dir.exists():
|
|
1264
|
+
for png in sorted(figures_dir.glob("*.png")):
|
|
1265
|
+
p = str(png)
|
|
1266
|
+
if p not in paths:
|
|
1267
|
+
paths.append(p)
|
|
1268
|
+
return paths
|
|
1269
|
+
|
|
1270
|
+
|
|
1271
|
+
def _extract_code(raw: str) -> str:
|
|
1272
|
+
cleaned = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL)
|
|
1273
|
+
fence = re.search(r"```(?:python)?\s*\n(.*?)```", cleaned, flags=re.DOTALL)
|
|
1274
|
+
if fence:
|
|
1275
|
+
return fence.group(1).strip()
|
|
1276
|
+
return cleaned.strip()
|