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,177 @@
|
|
|
1
|
+
"""Live terminal narration for the CLI-facing pipelines (`evalrx explore`,
|
|
2
|
+
`evalrx run-codebase`).
|
|
3
|
+
|
|
4
|
+
The project's own landing page shows a "simulated run" terminal — one
|
|
5
|
+
aligned line per M-stage, a dot leader, then the real tally. This module is
|
|
6
|
+
the non-simulated version of that: it renders the *same* stage transitions
|
|
7
|
+
`--progress-path`'s :class:`~evalrx.analysis.workbench.EventSink` already
|
|
8
|
+
receives, as they actually happen, instead of only writing them to a JSONL
|
|
9
|
+
file for a separate workbench UI to read later.
|
|
10
|
+
|
|
11
|
+
:class:`TerminalNarrator` duck-types ``EventSink.emit(stage, status,
|
|
12
|
+
message, *, attempt=None, artifact_refs=(), metrics=None)`` so it can be
|
|
13
|
+
passed anywhere a ``progress_sink`` is accepted, or fanned out alongside a
|
|
14
|
+
real ``EventSink`` via :class:`MultiSink`. Nothing here invents data — every
|
|
15
|
+
count and duration comes from the ``metrics``/``message`` the caller passed
|
|
16
|
+
at the real event.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
from typing import IO, Any, Iterable
|
|
25
|
+
|
|
26
|
+
# Stage keys line up with the M1-M5 pipeline naming used throughout the
|
|
27
|
+
# docs and the landing page's own terminal walkthrough. `evalrx explore`
|
|
28
|
+
# only ever drives m2/m3(/m4-as-holdout-confirm); `run-codebase` adds a
|
|
29
|
+
# pre-M2 "run" step (running the user's own codebase, not a numbered stage).
|
|
30
|
+
_STAGE_LABELS = {
|
|
31
|
+
"run": "codebase",
|
|
32
|
+
"m1": "probe",
|
|
33
|
+
"m2": "explore",
|
|
34
|
+
"m2_codegen": "explore",
|
|
35
|
+
"m2_execute": "explore",
|
|
36
|
+
"m3": "diagnose",
|
|
37
|
+
"m4": "verify",
|
|
38
|
+
"m5": "intervene",
|
|
39
|
+
"persist": "write output",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# Sub-stages of a numbered stage: narrated only on retry or failure, never on
|
|
43
|
+
# a clean first-attempt pass, so the common case stays exactly one line per
|
|
44
|
+
# stage (matching the mockup) while a real repair loop is still visible.
|
|
45
|
+
_SUBSTAGES = {"m2_codegen", "m2_execute"}
|
|
46
|
+
|
|
47
|
+
_RESET = "\033[0m"
|
|
48
|
+
_DIM = "\033[2m"
|
|
49
|
+
_BOLD = "\033[1m"
|
|
50
|
+
_CYAN = "\033[36m"
|
|
51
|
+
_GREEN = "\033[32m"
|
|
52
|
+
_RED = "\033[31m"
|
|
53
|
+
_YELLOW = "\033[33m"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _supports_color(stream: IO[str]) -> bool:
|
|
57
|
+
"""Respect NO_COLOR (https://no-color.org) and never color a non-tty."""
|
|
58
|
+
if os.environ.get("NO_COLOR") is not None:
|
|
59
|
+
return False
|
|
60
|
+
if os.environ.get("EVALRX_FORCE_COLOR"):
|
|
61
|
+
return True
|
|
62
|
+
return bool(getattr(stream, "isatty", lambda: False)())
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _pluralize(n: int, word: str) -> str:
|
|
66
|
+
return f"{n} {word}" if n == 1 else f"{n} {word}s"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class TerminalNarrator:
|
|
70
|
+
"""Prints one aligned line per pipeline stage, live, as it happens.
|
|
71
|
+
|
|
72
|
+
Format matches the landing page's terminal walkthrough:
|
|
73
|
+
``M2 explore ············ 23 figures · 4 candidate signals``.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
LABEL_WIDTH = 12
|
|
77
|
+
|
|
78
|
+
def __init__(self, *, stream: IO[str] | None = None, color: bool | None = None) -> None:
|
|
79
|
+
self.stream = stream if stream is not None else sys.stdout
|
|
80
|
+
self.color = _supports_color(self.stream) if color is None else color
|
|
81
|
+
self._started_at: dict[str, float] = {}
|
|
82
|
+
|
|
83
|
+
def _c(self, code: str, text: str) -> str:
|
|
84
|
+
return f"{code}{text}{_RESET}" if self.color and text else text
|
|
85
|
+
|
|
86
|
+
def _row(self, stage_key: str, detail: str) -> str:
|
|
87
|
+
label = _STAGE_LABELS.get(stage_key, stage_key)
|
|
88
|
+
# "m2" -> "M2 "; "run" -> "RUN" (no numbered stage of its own).
|
|
89
|
+
key = self._c(_BOLD + _CYAN, stage_key.upper().ljust(3))
|
|
90
|
+
dots = self._c(_DIM, "·" * max(4, self.LABEL_WIDTH + 2 - len(label)))
|
|
91
|
+
return f"{key} {label.ljust(self.LABEL_WIDTH)}{dots} {detail}"
|
|
92
|
+
|
|
93
|
+
def _print(self, stage_key: str, detail: str) -> None:
|
|
94
|
+
print(self._row(stage_key, detail), file=self.stream)
|
|
95
|
+
|
|
96
|
+
def emit(
|
|
97
|
+
self,
|
|
98
|
+
stage: str,
|
|
99
|
+
status: str,
|
|
100
|
+
message: str,
|
|
101
|
+
*,
|
|
102
|
+
attempt: int | None = None,
|
|
103
|
+
artifact_refs: Iterable[Any] = (),
|
|
104
|
+
metrics: dict[str, Any] | None = None,
|
|
105
|
+
) -> None:
|
|
106
|
+
del artifact_refs # not narrated — the summary printed at the end lists paths
|
|
107
|
+
metrics = metrics or {}
|
|
108
|
+
# "m2_codegen"/"m2_execute" -> "m2"; anything else is its own key.
|
|
109
|
+
stage_key = stage.split("_")[0]
|
|
110
|
+
|
|
111
|
+
if stage in _SUBSTAGES:
|
|
112
|
+
# Only worth a line on retry (attempt > 1) or failure — a clean
|
|
113
|
+
# first attempt is already covered by the parent stage's line.
|
|
114
|
+
if status == "failed":
|
|
115
|
+
self._print(stage_key, self._c(_RED, "✗") + f" {message}"
|
|
116
|
+
+ (f" (attempt {attempt})" if attempt else ""))
|
|
117
|
+
elif attempt and attempt > 1 and status == "started":
|
|
118
|
+
self._print(stage_key, self._c(_YELLOW, "↻") + f" retry {attempt} — {message.lower()}")
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
if status == "started":
|
|
122
|
+
self._started_at[stage_key] = time.monotonic()
|
|
123
|
+
self._print(stage_key, self._c(_DIM, "running…"))
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
elapsed = time.monotonic() - self._started_at.get(stage_key, time.monotonic())
|
|
127
|
+
mark = self._c(_GREEN, "✓") if status == "completed" else self._c(_RED, "✗")
|
|
128
|
+
detail = self._summary(stage_key, status, message, metrics) or message
|
|
129
|
+
timing = self._c(_DIM, f" ({elapsed:.1f}s)") if stage_key in self._started_at else ""
|
|
130
|
+
self._print(stage_key, f"{mark} {detail}{timing}")
|
|
131
|
+
|
|
132
|
+
def _summary(self, stage: str, status: str, message: str, metrics: dict[str, Any]) -> str | None:
|
|
133
|
+
if status != "completed":
|
|
134
|
+
return None
|
|
135
|
+
if stage == "run":
|
|
136
|
+
return message # "Harvested N record(s)" is already the real tally
|
|
137
|
+
if stage == "m2":
|
|
138
|
+
parts = []
|
|
139
|
+
if "n_figures" in metrics:
|
|
140
|
+
parts.append(_pluralize(metrics["n_figures"], "figure"))
|
|
141
|
+
if "n_candidate_signals" in metrics:
|
|
142
|
+
parts.append(_pluralize(metrics["n_candidate_signals"], "candidate signal"))
|
|
143
|
+
return " · ".join(parts) if parts else None
|
|
144
|
+
if stage == "m3":
|
|
145
|
+
n = metrics.get("n_hypotheses")
|
|
146
|
+
if n is not None:
|
|
147
|
+
noun = "falsifiable hypothesis" if n == 1 else "falsifiable hypotheses"
|
|
148
|
+
return f"{n} {noun}"
|
|
149
|
+
if stage == "m4":
|
|
150
|
+
n_rows = metrics.get("n_rows")
|
|
151
|
+
n_reject = metrics.get("n_rejected")
|
|
152
|
+
n_total = metrics.get("n_adjudicated")
|
|
153
|
+
if n_total is not None:
|
|
154
|
+
bits = [f"held-out n={n_rows}" if n_rows is not None else "held-out"]
|
|
155
|
+
bits.append(f"{n_reject}/{n_total} reject")
|
|
156
|
+
return " · ".join(bits)
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class MultiSink:
|
|
161
|
+
"""Fan one `.emit(...)` call out to several progress sinks.
|
|
162
|
+
|
|
163
|
+
Lets the terminal narrator run alongside a real
|
|
164
|
+
:class:`~evalrx.analysis.workbench.EventSink` (``--progress-path``)
|
|
165
|
+
without either one knowing about the other. `None` entries are dropped
|
|
166
|
+
so callers can pass an optional sink straight through.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
def __init__(self, *sinks: Any) -> None:
|
|
170
|
+
self._sinks = [s for s in sinks if s is not None]
|
|
171
|
+
|
|
172
|
+
def __bool__(self) -> bool:
|
|
173
|
+
return bool(self._sinks)
|
|
174
|
+
|
|
175
|
+
def emit(self, *args: Any, **kwargs: Any) -> None:
|
|
176
|
+
for sink in self._sinks:
|
|
177
|
+
sink.emit(*args, **kwargs)
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
"""Operationalization bridge — compile a candidate's signal RECIPE into a frozen,
|
|
2
|
+
reproducible per-case signal function (case -> value).
|
|
3
|
+
|
|
4
|
+
This is the one channel that lets the rich, free-form discoveries of the LAMBDA
|
|
5
|
+
explorer enter the validated stats engine. A hypothesis ("small peripheral objects
|
|
6
|
+
fail more") is a sentence; it cannot be tested until it is pinned to a concrete,
|
|
7
|
+
deterministic per-case signal that lands in ``StatsInput.per_case`` and competes in
|
|
8
|
+
M2's e-BH family. ``compile_recipe`` performs that compilation.
|
|
9
|
+
|
|
10
|
+
Two recipe kinds (DESIGN §5):
|
|
11
|
+
|
|
12
|
+
- ``kind="expr"`` (PREFERRED): a RESTRICTED DSL evaluated over a row's already-known
|
|
13
|
+
columns — interactions / thresholds / composite predicates reduced to one
|
|
14
|
+
boolean/continuous function of existing signals. No codegen, deterministic,
|
|
15
|
+
auditable. A safe AST walker (no calls except a tiny numeric whitelist, no
|
|
16
|
+
attribute/subscript access, no imports) evaluates it; a case missing any
|
|
17
|
+
referenced column is SKIPPED, never defaulted.
|
|
18
|
+
- ``kind="code"`` (FALLBACK): a brand-new continuous estimand that is not yet any
|
|
19
|
+
known column. Deferred to a later Phase B step — it will reuse the leak-1
|
|
20
|
+
sandbox + sufficient-statistics host-check path. Raises ``NotImplementedError``
|
|
21
|
+
for now.
|
|
22
|
+
|
|
23
|
+
``compile_recipe`` returns ``{case_id -> value}``; the fused pipeline (Phase B2)
|
|
24
|
+
wraps that into a synthetic-analyzer ``findings["per_case"]`` entry so
|
|
25
|
+
``build_stats_input`` collects it like any analyzer signal.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import ast
|
|
31
|
+
import logging
|
|
32
|
+
import operator
|
|
33
|
+
import re
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
# A recipe expr may NEVER reference the outcome/id columns: a signal that IS the
|
|
40
|
+
# label (or trivially derived from it) would perfectly "predict" FAIL — a
|
|
41
|
+
# self-prediction leak. records/per_case_to_records may carry these columns, so the
|
|
42
|
+
# DSL hard-rejects any expr that names one (defense regardless of what the rows hold).
|
|
43
|
+
_RESERVED_RECIPE_NAMES = frozenset({
|
|
44
|
+
"label", "labels", "case_id", "id", "sample_id",
|
|
45
|
+
"is_fail", "is_correct", "outcome", "y_true", "target", "gold",
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# Recipe data contract
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class RecipeError(ValueError):
|
|
54
|
+
"""A recipe is structurally invalid (bad syntax / disallowed construct)."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class SignalRecipe:
|
|
59
|
+
"""A deterministic recipe for a new per-case signal (carries NO e-value).
|
|
60
|
+
|
|
61
|
+
Attributes:
|
|
62
|
+
name: per-case signal key, e.g. ``"explored.small_and_peripheral"``.
|
|
63
|
+
description: mechanism language (read by M3), not used for computation.
|
|
64
|
+
kind: ``"expr"`` | ``"code"``.
|
|
65
|
+
expr: ``kind="expr"`` — restricted DSL over existing columns,
|
|
66
|
+
e.g. ``"(obj_size < 40) and (attention_focus_share < 0.3)"``.
|
|
67
|
+
code: ``kind="code"`` — sandbox source (deferred).
|
|
68
|
+
suggested_test: catalog tool to route to, e.g. ``"signal_label_assoc"``.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
name: str
|
|
72
|
+
description: str = ""
|
|
73
|
+
kind: str = "expr"
|
|
74
|
+
expr: str = ""
|
|
75
|
+
code: str = ""
|
|
76
|
+
suggested_test: str = ""
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def from_dict(cls, data: dict[str, Any]) -> "SignalRecipe":
|
|
80
|
+
"""Build from the ``recipe`` dict an explorer candidate may carry."""
|
|
81
|
+
return cls(
|
|
82
|
+
name=str(data.get("name", "")),
|
|
83
|
+
description=str(data.get("description", "")),
|
|
84
|
+
kind=str(data.get("kind", "expr")),
|
|
85
|
+
expr=str(data.get("expr", "")),
|
|
86
|
+
code=str(data.get("code", "")),
|
|
87
|
+
suggested_test=str(data.get("suggested_test", "")),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
# Safe expression DSL (kind="expr")
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
_BIN_OPS = {
|
|
96
|
+
ast.Add: operator.add,
|
|
97
|
+
ast.Sub: operator.sub,
|
|
98
|
+
ast.Mult: operator.mul,
|
|
99
|
+
ast.Div: operator.truediv,
|
|
100
|
+
ast.FloorDiv: operator.floordiv,
|
|
101
|
+
ast.Mod: operator.mod,
|
|
102
|
+
ast.Pow: operator.pow,
|
|
103
|
+
}
|
|
104
|
+
_CMP_OPS = {
|
|
105
|
+
ast.Lt: operator.lt,
|
|
106
|
+
ast.LtE: operator.le,
|
|
107
|
+
ast.Gt: operator.gt,
|
|
108
|
+
ast.GtE: operator.ge,
|
|
109
|
+
ast.Eq: operator.eq,
|
|
110
|
+
ast.NotEq: operator.ne,
|
|
111
|
+
}
|
|
112
|
+
_UNARY_OPS = {ast.UAdd: operator.pos, ast.USub: operator.neg, ast.Not: operator.not_}
|
|
113
|
+
# The ONLY callables a recipe may invoke. All pure, total, side-effect free.
|
|
114
|
+
_FUNCS = {"abs": abs, "min": min, "max": max, "float": float, "int": int, "len": len}
|
|
115
|
+
|
|
116
|
+
# AST node types the validator permits (anything else is rejected outright).
|
|
117
|
+
_ALLOWED_NODES = (
|
|
118
|
+
ast.Expression,
|
|
119
|
+
ast.BoolOp, ast.And, ast.Or,
|
|
120
|
+
ast.BinOp, ast.UnaryOp,
|
|
121
|
+
ast.Compare,
|
|
122
|
+
ast.IfExp,
|
|
123
|
+
ast.Call,
|
|
124
|
+
ast.Name, ast.Load,
|
|
125
|
+
ast.Constant,
|
|
126
|
+
*(_BIN_OPS), *(_CMP_OPS), *(_UNARY_OPS),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _validate(tree: ast.AST) -> None:
|
|
131
|
+
"""Reject any construct outside the safe subset (calls, attributes, imports…)."""
|
|
132
|
+
for node in ast.walk(tree):
|
|
133
|
+
if isinstance(node, ast.Call):
|
|
134
|
+
if not (isinstance(node.func, ast.Name) and node.func.id in _FUNCS):
|
|
135
|
+
raise RecipeError("only abs/min/max/float/int/len calls are allowed")
|
|
136
|
+
if node.keywords:
|
|
137
|
+
raise RecipeError("keyword arguments are not allowed in a recipe expr")
|
|
138
|
+
continue
|
|
139
|
+
if not isinstance(node, _ALLOWED_NODES):
|
|
140
|
+
raise RecipeError(f"disallowed expression construct: {type(node).__name__}")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _free_names(tree: ast.AST) -> list[str]:
|
|
144
|
+
"""Column identifiers the expr references (Names that are not whitelisted funcs)."""
|
|
145
|
+
names: list[str] = []
|
|
146
|
+
for node in ast.walk(tree):
|
|
147
|
+
if isinstance(node, ast.Name) and node.id not in _FUNCS and node.id not in names:
|
|
148
|
+
names.append(node.id)
|
|
149
|
+
return names
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _ev(node: ast.AST, env: dict[str, Any]) -> Any:
|
|
153
|
+
if isinstance(node, ast.Expression):
|
|
154
|
+
return _ev(node.body, env)
|
|
155
|
+
if isinstance(node, ast.Constant):
|
|
156
|
+
return node.value
|
|
157
|
+
if isinstance(node, ast.Name):
|
|
158
|
+
return env[node.id]
|
|
159
|
+
if isinstance(node, ast.UnaryOp):
|
|
160
|
+
return _UNARY_OPS[type(node.op)](_ev(node.operand, env))
|
|
161
|
+
if isinstance(node, ast.BinOp):
|
|
162
|
+
return _BIN_OPS[type(node.op)](_ev(node.left, env), _ev(node.right, env))
|
|
163
|
+
if isinstance(node, ast.BoolOp):
|
|
164
|
+
if isinstance(node.op, ast.And):
|
|
165
|
+
result: Any = True
|
|
166
|
+
for v in node.values:
|
|
167
|
+
result = _ev(v, env)
|
|
168
|
+
if not result:
|
|
169
|
+
return result
|
|
170
|
+
return result
|
|
171
|
+
result = False
|
|
172
|
+
for v in node.values:
|
|
173
|
+
result = _ev(v, env)
|
|
174
|
+
if result:
|
|
175
|
+
return result
|
|
176
|
+
return result
|
|
177
|
+
if isinstance(node, ast.Compare):
|
|
178
|
+
left = _ev(node.left, env)
|
|
179
|
+
for op, comp in zip(node.ops, node.comparators):
|
|
180
|
+
right = _ev(comp, env)
|
|
181
|
+
if not _CMP_OPS[type(op)](left, right):
|
|
182
|
+
return False
|
|
183
|
+
left = right
|
|
184
|
+
return True
|
|
185
|
+
if isinstance(node, ast.IfExp):
|
|
186
|
+
return _ev(node.body, env) if _ev(node.test, env) else _ev(node.orelse, env)
|
|
187
|
+
if isinstance(node, ast.Call):
|
|
188
|
+
args = [_ev(a, env) for a in node.args]
|
|
189
|
+
return _FUNCS[node.func.id](*args) # type: ignore[attr-defined]
|
|
190
|
+
raise RecipeError(f"cannot evaluate node: {type(node).__name__}")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _to_float(value: Any) -> float | None:
|
|
194
|
+
"""Coerce a recipe result to a signal value; None if it is not numeric."""
|
|
195
|
+
if isinstance(value, bool):
|
|
196
|
+
return 1.0 if value else 0.0
|
|
197
|
+
if isinstance(value, (int, float)):
|
|
198
|
+
return float(value)
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
# Compilation
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
_ID_KEYS = ("case_id", "id", "sample_id")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _row_get(row: Any, key: str) -> Any:
|
|
210
|
+
if isinstance(row, dict):
|
|
211
|
+
return row.get(key)
|
|
212
|
+
return getattr(row, key, None)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _row_id(row: Any, id_col: str, index: int) -> str:
|
|
216
|
+
for key in (id_col, *_ID_KEYS):
|
|
217
|
+
v = _row_get(row, key)
|
|
218
|
+
if v not in (None, ""):
|
|
219
|
+
return str(v)
|
|
220
|
+
return str(index)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def compile_recipe(
|
|
224
|
+
recipe: SignalRecipe,
|
|
225
|
+
records: Any,
|
|
226
|
+
*,
|
|
227
|
+
id_col: str = "case_id",
|
|
228
|
+
) -> dict[str, float]:
|
|
229
|
+
"""Compile *recipe* into ``{case_id -> value}`` over *records*.
|
|
230
|
+
|
|
231
|
+
Raises :class:`RecipeError` for a structurally invalid ``expr`` (bad syntax or
|
|
232
|
+
a disallowed construct) — fail loud at compile time. A per-row failure (missing
|
|
233
|
+
column, type mismatch, non-numeric result) SKIPS that case rather than aborting,
|
|
234
|
+
so a recipe that only partially applies still yields a usable (smaller) signal;
|
|
235
|
+
an empty result tells the caller the recipe could not be operationalized.
|
|
236
|
+
"""
|
|
237
|
+
if recipe.kind == "code":
|
|
238
|
+
raise NotImplementedError(
|
|
239
|
+
"SignalRecipe kind='code' (sandbox codegen) is deferred to a later "
|
|
240
|
+
"Phase B step; use kind='expr' for now"
|
|
241
|
+
)
|
|
242
|
+
if recipe.kind != "expr":
|
|
243
|
+
raise RecipeError(f"unknown recipe kind: {recipe.kind!r}")
|
|
244
|
+
|
|
245
|
+
expr = (recipe.expr or "").strip()
|
|
246
|
+
if not expr:
|
|
247
|
+
raise RecipeError("recipe.expr is empty")
|
|
248
|
+
try:
|
|
249
|
+
tree = ast.parse(expr, mode="eval")
|
|
250
|
+
except SyntaxError as exc:
|
|
251
|
+
raise RecipeError(f"invalid expr syntax: {exc}") from exc
|
|
252
|
+
_validate(tree)
|
|
253
|
+
names = _free_names(tree)
|
|
254
|
+
|
|
255
|
+
# Hard leak guard (G4): a recipe must be a function of PREDICTOR signals only,
|
|
256
|
+
# never the outcome/id. Referencing the label column would make the signal the
|
|
257
|
+
# label itself — a guaranteed double-dip self-prediction.
|
|
258
|
+
reserved = _RESERVED_RECIPE_NAMES | {id_col}
|
|
259
|
+
leaked = sorted(n for n in names if n in reserved)
|
|
260
|
+
if leaked:
|
|
261
|
+
raise RecipeError(
|
|
262
|
+
f"recipe expr may not reference reserved outcome/id column(s): {leaked}"
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
out: dict[str, float] = {}
|
|
266
|
+
for i, row in enumerate(records or []):
|
|
267
|
+
env: dict[str, Any] = {}
|
|
268
|
+
missing = False
|
|
269
|
+
for n in names:
|
|
270
|
+
v = _row_get(row, n)
|
|
271
|
+
if v is None:
|
|
272
|
+
missing = True
|
|
273
|
+
break
|
|
274
|
+
env[n] = v
|
|
275
|
+
if missing:
|
|
276
|
+
continue
|
|
277
|
+
try:
|
|
278
|
+
value = _ev(tree.body, env)
|
|
279
|
+
except (KeyError, TypeError, ValueError, ZeroDivisionError):
|
|
280
|
+
continue
|
|
281
|
+
fv = _to_float(value)
|
|
282
|
+
if fv is not None:
|
|
283
|
+
out[_row_id(row, id_col, i)] = fv
|
|
284
|
+
return out
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def compile_recipes(
|
|
288
|
+
recipes: "list[SignalRecipe]",
|
|
289
|
+
records: Any,
|
|
290
|
+
*,
|
|
291
|
+
id_col: str = "case_id",
|
|
292
|
+
) -> dict[str, dict[str, float]]:
|
|
293
|
+
"""Compile each expr recipe; skip (do not abort) recipes that fail to compile.
|
|
294
|
+
|
|
295
|
+
Returns ``{recipe.name -> {case_id -> value}}`` for the recipes that produced a
|
|
296
|
+
non-empty signal. Recipes that raise :class:`RecipeError` or yield an empty map
|
|
297
|
+
are dropped here; the caller routes them to ``recommended_confirmatory_tests``.
|
|
298
|
+
"""
|
|
299
|
+
out: dict[str, dict[str, float]] = {}
|
|
300
|
+
for r in recipes:
|
|
301
|
+
if r.kind != "expr":
|
|
302
|
+
continue
|
|
303
|
+
if r.name in out:
|
|
304
|
+
# Duplicate recipe name would silently clobber the first signal — keep
|
|
305
|
+
# the first, drop the rest, and say so rather than corrupt the family.
|
|
306
|
+
logger.warning(
|
|
307
|
+
"compile_recipes: duplicate recipe name %r — keeping the first, "
|
|
308
|
+
"dropping the duplicate", r.name,
|
|
309
|
+
)
|
|
310
|
+
continue
|
|
311
|
+
try:
|
|
312
|
+
values = compile_recipe(r, records, id_col=id_col)
|
|
313
|
+
except (RecipeError, NotImplementedError):
|
|
314
|
+
continue
|
|
315
|
+
if values:
|
|
316
|
+
out[r.name] = values
|
|
317
|
+
return out
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def per_case_finding(value_maps: dict[str, dict[str, float]], *, id_key: str = "case_id") -> list[dict[str, Any]]:
|
|
321
|
+
"""Assemble compiled signals into ``findings["per_case"]`` entries.
|
|
322
|
+
|
|
323
|
+
Merges several ``{case_id -> value}`` maps into one row-per-case list so a
|
|
324
|
+
synthetic analyzer Result carries the bridged signals; ``build_stats_input``
|
|
325
|
+
then keys them as ``"<analyzer>.<signal>"`` like any other per-case finding.
|
|
326
|
+
"""
|
|
327
|
+
rows: dict[str, dict[str, Any]] = {}
|
|
328
|
+
for signal_name, values in value_maps.items():
|
|
329
|
+
for cid, val in values.items():
|
|
330
|
+
rows.setdefault(cid, {id_key: cid})[signal_name] = val
|
|
331
|
+
return list(rows.values())
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# ---------------------------------------------------------------------------
|
|
335
|
+
# In-loop bridge: existing analyzer per_case signals -> records -> synthetic Result
|
|
336
|
+
# ---------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
def safe_ident(name: str) -> str:
|
|
339
|
+
"""Map a per-case signal key (e.g. ``"saliency.obj_size"``) to a DSL identifier
|
|
340
|
+
(``"saliency_obj_size"``). Recipe ``expr`` references these sanitized names, since
|
|
341
|
+
the dotted ``analyzer.metric`` keys are not valid Python identifiers."""
|
|
342
|
+
s = re.sub(r"\W", "_", name)
|
|
343
|
+
if s and s[0].isdigit():
|
|
344
|
+
s = "_" + s
|
|
345
|
+
return s
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def per_case_to_records(
|
|
349
|
+
per_case: dict[str, dict[str, float]],
|
|
350
|
+
labels: dict[str, bool] | None = None,
|
|
351
|
+
*,
|
|
352
|
+
id_key: str = "case_id",
|
|
353
|
+
label_key: str = "label",
|
|
354
|
+
) -> list[dict[str, Any]]:
|
|
355
|
+
"""Transpose ``{signal -> {case_id -> value}}`` into per-case row dicts.
|
|
356
|
+
|
|
357
|
+
Signal keys are sanitized via :func:`safe_ident` so recipe exprs can reference
|
|
358
|
+
them. Each row carries only the signals present for that case (missing ones are
|
|
359
|
+
absent, so a recipe referencing a missing signal SKIPS that case). When *labels*
|
|
360
|
+
is given, a ``pass``/``fail`` ``label`` column is added.
|
|
361
|
+
|
|
362
|
+
``safe_ident`` is not injective (``a.b`` and ``a_b`` both map to ``a_b``). When
|
|
363
|
+
two DISTINCT signal keys collide on their sanitized identifier, the mapping is
|
|
364
|
+
ambiguous, so BOTH are EXCLUDED (never first-wins-overwrite, which would silently
|
|
365
|
+
test the wrong estimand) and a warning is logged. A recipe referencing the
|
|
366
|
+
ambiguous name then finds nothing and is dropped — fail-closed, not corrupt.
|
|
367
|
+
"""
|
|
368
|
+
# Identify safe_ident collisions among distinct signal keys; exclude them.
|
|
369
|
+
ident_owner: dict[str, str] = {}
|
|
370
|
+
collided: set[str] = set()
|
|
371
|
+
for signal in per_case:
|
|
372
|
+
ident = safe_ident(signal)
|
|
373
|
+
prior = ident_owner.get(ident)
|
|
374
|
+
if prior is not None and prior != signal:
|
|
375
|
+
collided.add(ident)
|
|
376
|
+
else:
|
|
377
|
+
ident_owner[ident] = signal
|
|
378
|
+
if collided:
|
|
379
|
+
clashing = sorted(s for s in per_case if safe_ident(s) in collided)
|
|
380
|
+
logger.warning(
|
|
381
|
+
"per_case_to_records: %d signal key(s) collide under safe_ident and are "
|
|
382
|
+
"EXCLUDED (ambiguous): %s", len(clashing), clashing,
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
case_ids: set[str] = set()
|
|
386
|
+
for vals in per_case.values():
|
|
387
|
+
case_ids.update(vals)
|
|
388
|
+
if labels:
|
|
389
|
+
case_ids.update(labels)
|
|
390
|
+
|
|
391
|
+
records: list[dict[str, Any]] = []
|
|
392
|
+
for cid in sorted(case_ids):
|
|
393
|
+
row: dict[str, Any] = {id_key: cid}
|
|
394
|
+
for signal, vals in per_case.items():
|
|
395
|
+
ident = safe_ident(signal)
|
|
396
|
+
if ident in collided:
|
|
397
|
+
continue
|
|
398
|
+
if cid in vals:
|
|
399
|
+
row[ident] = vals[cid]
|
|
400
|
+
if labels is not None and cid in labels:
|
|
401
|
+
row[label_key] = "fail" if labels[cid] else "pass"
|
|
402
|
+
records.append(row)
|
|
403
|
+
return records
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def bridge_recipes_to_result(
|
|
407
|
+
recipes: "list[SignalRecipe]",
|
|
408
|
+
probe_results: "dict[str, Any]",
|
|
409
|
+
data: "Any | None" = None,
|
|
410
|
+
*,
|
|
411
|
+
model_repr: str = "",
|
|
412
|
+
analyzer_name: str = "explored",
|
|
413
|
+
id_col: str = "case_id",
|
|
414
|
+
) -> "Any | None":
|
|
415
|
+
"""Compile pre-registered recipes over existing analyzer per_case signals into a
|
|
416
|
+
synthetic analyzer ``Result``, so bridged composite signals enter M2 through the
|
|
417
|
+
standard ``findings["per_case"]`` contract (DESIGN §5.3).
|
|
418
|
+
|
|
419
|
+
LEAK-FREE BY CONSTRUCTION: the recipes must be PRE-SPECIFIED (discovered
|
|
420
|
+
out-of-band — e.g. on the fused pipeline's held-out split, or hand-authored —
|
|
421
|
+
NOT chosen by peeking at *these* labels). Testing a frozen extractor here is then
|
|
422
|
+
exactly like testing a pre-registered analyzer; this function does no discovery.
|
|
423
|
+
|
|
424
|
+
Returns the synthetic ``Result`` (analyzer=*analyzer_name*), or ``None`` if no
|
|
425
|
+
recipe compiled to a non-empty signal.
|
|
426
|
+
"""
|
|
427
|
+
from evalrx.analysis.stats_tools import build_stats_input
|
|
428
|
+
from evalrx.core.result import Result
|
|
429
|
+
|
|
430
|
+
inp = build_stats_input(probe_results, data)
|
|
431
|
+
# Compile over PREDICTOR signals only — never expose the label to recipe
|
|
432
|
+
# compilation (a recipe is a function of signals, not the outcome).
|
|
433
|
+
records = per_case_to_records(inp.per_case, labels=None, id_key=id_col)
|
|
434
|
+
compiled = compile_recipes(recipes, records, id_col=id_col)
|
|
435
|
+
if not compiled:
|
|
436
|
+
return None
|
|
437
|
+
return Result(
|
|
438
|
+
analyzer=analyzer_name,
|
|
439
|
+
model=model_repr,
|
|
440
|
+
findings={"per_case": per_case_finding(compiled, id_key=id_col)},
|
|
441
|
+
cases=data,
|
|
442
|
+
)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Shared plain-language guardrail for reader-facing headlines.
|
|
2
|
+
|
|
3
|
+
Both the M2 explorer's takeaway ``plain_title`` and the M3 hypothesis agent's
|
|
4
|
+
``PLAIN`` line are meant to be read by someone with no statistics
|
|
5
|
+
background — the prompt asking nicely for "plain language" is not enough on
|
|
6
|
+
its own (an LLM will readily reuse the jargon-heavy technical line), so this
|
|
7
|
+
is the host-side check that catches it and drives a bounded repair turn.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
_JARGON_PATTERN = re.compile(
|
|
15
|
+
# PDF/CDF are excluded: this package's own prompts use "PDF" for the
|
|
16
|
+
# document filetype (see prompts/explorer.py's raw-folder framing), so a
|
|
17
|
+
# perfectly plain headline about scanned PDFs would otherwise false-flag.
|
|
18
|
+
r"\b(AUC|ROC|ECDF|Spearman|Pearson|collinear(?:ity)?|logit|"
|
|
19
|
+
r"logistic|coefficient(?:s)?|p-value|p value|confound(?:ed|ing)?|"
|
|
20
|
+
r"monotonic|quantile|covariance|latent|mediator|"
|
|
21
|
+
r"stratif(?:y|ies|ied)|z-score|t-test|chi-square(?:d)?|FDR|e-value|CI)\b",
|
|
22
|
+
re.IGNORECASE,
|
|
23
|
+
)
|
|
24
|
+
_JARGON_SYMBOLS = ("→", "ρ", "σ", "µ")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def jargon_violation(plain_text: str, technical_text: str = "") -> str | None:
|
|
28
|
+
"""Return a short reason *plain_text* fails the plain-language check, or
|
|
29
|
+
None if it passes. *technical_text* — the paired jargon-heavy line, if
|
|
30
|
+
any — is used only to catch a lazy verbatim copy."""
|
|
31
|
+
plain = (plain_text or "").strip()
|
|
32
|
+
if not plain:
|
|
33
|
+
return "missing"
|
|
34
|
+
if technical_text and plain.lower() == technical_text.strip().lower():
|
|
35
|
+
return "repeats the technical line verbatim"
|
|
36
|
+
hit = _JARGON_PATTERN.search(plain)
|
|
37
|
+
if hit:
|
|
38
|
+
return f"uses jargon ({hit.group(0)!r})"
|
|
39
|
+
for sym in _JARGON_SYMBOLS:
|
|
40
|
+
if sym in plain:
|
|
41
|
+
return f"uses a symbol ({sym!r}) instead of words"
|
|
42
|
+
return None
|