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,1882 @@
|
|
|
1
|
+
"""Agent-composed report contracts for the dynamic EvalRX UI.
|
|
2
|
+
|
|
3
|
+
The evidence model is deterministic and task agnostic. A Report Agent may
|
|
4
|
+
choose how to arrange a small, typed component catalog, but every value shown
|
|
5
|
+
by the UI is resolved from :class:`ReportData`; the model cannot invent data or
|
|
6
|
+
arbitrary executable UI.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Iterable, Mapping, Sequence
|
|
20
|
+
|
|
21
|
+
# 18: `setting.partitions` + `case.split` — which band of the frozen batch
|
|
22
|
+
# each case sat in (explore / held-out / confirm), so the Case Studio can
|
|
23
|
+
# show the split the run was actually measured on.
|
|
24
|
+
# 17: the case-study sheet - the whole run as one page.
|
|
25
|
+
# 16: `setting.hero_image` — the run's own cover figure, embedded.
|
|
26
|
+
# 15: `setting.diagnosed_by` — which agent drove the run.
|
|
27
|
+
# 14: M3 hypotheses recover the plain sentence from proposed_hypotheses.
|
|
28
|
+
# 13: paired M2 rows carry the strategy pair they compared, so several tests
|
|
29
|
+
# that share one tool no longer share one label. Bumping this is what
|
|
30
|
+
# makes an already-published run pick the change up — `report_is_current`
|
|
31
|
+
# only tracks new EVENTS, so without the bump every existing run would
|
|
32
|
+
# keep serving the labels its old compiler produced.
|
|
33
|
+
REPORT_DATA_VERSION = 18
|
|
34
|
+
REPORT_SCHEMA_VERSION = 1
|
|
35
|
+
JSON_RENDER_VERSION = "0.19.0"
|
|
36
|
+
CATALOG_VERSION = "evalrx-report@2"
|
|
37
|
+
|
|
38
|
+
ALLOWED_COMPONENTS = frozenset(
|
|
39
|
+
{
|
|
40
|
+
"ReportPage",
|
|
41
|
+
"SettingHero",
|
|
42
|
+
"MetricStrip",
|
|
43
|
+
"Journey",
|
|
44
|
+
"FindingGrid",
|
|
45
|
+
"ChartGrid",
|
|
46
|
+
"OutcomeCard",
|
|
47
|
+
"CasePreview",
|
|
48
|
+
"CaseStudySheet",
|
|
49
|
+
"LoopFigure",
|
|
50
|
+
"EvidenceIndex",
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
# OutcomeCard stays in ALLOWED_COMPONENTS so layouts published before it was
|
|
54
|
+
# dropped from the page still validate; the renderer draws nothing for it.
|
|
55
|
+
REQUIRED_COMPONENTS = frozenset({"SettingHero"})
|
|
56
|
+
# The page needs one picture of the M1-M5 loop: the plain stage strip, or the
|
|
57
|
+
# loop figure that folds the case-study sheet into it.
|
|
58
|
+
PIPELINE_COMPONENTS = frozenset({"Journey", "LoopFigure"})
|
|
59
|
+
MAX_ELEMENTS = 40
|
|
60
|
+
MAX_DEPTH = 6
|
|
61
|
+
|
|
62
|
+
# These labels are deliberately about the behaviour being measured, rather
|
|
63
|
+
# than the analyzer implementation that happened to measure it. Unknown keys
|
|
64
|
+
# still get a deterministic readable fallback and preserve their raw key in
|
|
65
|
+
# the audit-only fields below.
|
|
66
|
+
PLAIN_LABELS = {
|
|
67
|
+
"answer_extraction_audit": "Did the model give a usable answer?",
|
|
68
|
+
"calibration": "Is the model confident when it is wrong?",
|
|
69
|
+
"termination_audit": "Did the model stop before finishing?",
|
|
70
|
+
"format_sensitivity": "Does changing answer order change its choice?",
|
|
71
|
+
"selfcheck_consistency": "Does the model contradict itself?",
|
|
72
|
+
"coverage_verification_gap": "Can repeated attempts ever find the right answer?",
|
|
73
|
+
"logprob_entropy": "Does the model appear uncertain while answering?",
|
|
74
|
+
"self_consistency": "Does the answer stay the same across retries?",
|
|
75
|
+
"signal_label_assoc": "How strongly is this behavior linked to errors?",
|
|
76
|
+
"rank_corr": "Do more of this behavior and more errors move together?",
|
|
77
|
+
"single_rate_evalue": "Is this rate unlikely to be chance?",
|
|
78
|
+
"n_correct": "Number of correct attempts",
|
|
79
|
+
"any_correct": "At least one correct attempt",
|
|
80
|
+
"majority_correct": "Most attempts were correct",
|
|
81
|
+
"format_flip_rate": "Answer changed after reordering choices",
|
|
82
|
+
"positional_bias": "Tendency to favor one answer position",
|
|
83
|
+
"output_chars": "Length of the model answer",
|
|
84
|
+
"output_truncated": "Answer appears cut short",
|
|
85
|
+
"recovered_by_continuation": "Could continue to a usable answer",
|
|
86
|
+
"pass_at_k": "Share of retries that were correct",
|
|
87
|
+
"n_sentences": "Number of answer sentences checked",
|
|
88
|
+
"conf_logprob": "How certain the model appeared",
|
|
89
|
+
"label_disagrees": "Recorded answer disagrees with the label",
|
|
90
|
+
"has_answer_tag": "Used the requested answer format",
|
|
91
|
+
"extracted_answer": "Answer read from the output",
|
|
92
|
+
"labelled_fail": "This case was an error",
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class PublishedReport:
|
|
98
|
+
"""Paths and identity of a cached dynamic report publication."""
|
|
99
|
+
|
|
100
|
+
data_path: Path
|
|
101
|
+
spec_path: Path
|
|
102
|
+
trace_id: str
|
|
103
|
+
generated_by: str
|
|
104
|
+
sha256: str
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class ReportSpecError(ValueError):
|
|
108
|
+
"""Raised when a Report Agent emits an unsafe or invalid layout."""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def build_report_data(
|
|
112
|
+
run_dir: str | Path,
|
|
113
|
+
*,
|
|
114
|
+
example_dir: str | Path | None = None,
|
|
115
|
+
) -> dict[str, Any]:
|
|
116
|
+
"""Build the renderer-neutral data model for any completed EvalRX run."""
|
|
117
|
+
root = Path(run_dir).resolve()
|
|
118
|
+
from evalrx.reporting.case_study import build_case_study
|
|
119
|
+
from evalrx.reporting.html_report import extract_run_data
|
|
120
|
+
|
|
121
|
+
resolved_example = Path(example_dir).resolve() if example_dir else _infer_example_dir(root)
|
|
122
|
+
raw = extract_run_data(root, resolved_example)
|
|
123
|
+
events = _read_events(root)
|
|
124
|
+
# The logger copies each case's media under artifacts/case_media/ and records
|
|
125
|
+
# the RUN-RELATIVE path beside the case. Carry it onto the case: inputs.<slot>
|
|
126
|
+
# holds the absolute path on the producing host, so a run read anywhere else
|
|
127
|
+
# -- unzipped on a laptop, which is the normal way these are looked at --
|
|
128
|
+
# resolves every preview to a file that is not there.
|
|
129
|
+
case_events = []
|
|
130
|
+
for event in events:
|
|
131
|
+
if event.get("event") != "case_record":
|
|
132
|
+
continue
|
|
133
|
+
case = event.get("case")
|
|
134
|
+
if isinstance(case, dict):
|
|
135
|
+
local = [p for p in (event.get("media_paths") or []) if isinstance(p, str)]
|
|
136
|
+
# The partition the loop logged the case under (see
|
|
137
|
+
# RunLoggerV2.log_cases). Absent on runs from before it was recorded.
|
|
138
|
+
split = event.get("split")
|
|
139
|
+
extra: dict[str, Any] = {}
|
|
140
|
+
if local:
|
|
141
|
+
extra["_media_paths"] = local
|
|
142
|
+
if isinstance(split, str) and split:
|
|
143
|
+
extra["_split"] = split
|
|
144
|
+
case_events.append({**case, **extra} if extra else case)
|
|
145
|
+
cases = [case for case in case_events if isinstance(case, dict)] or list(raw.get("cases") or [])
|
|
146
|
+
run = dict(raw.get("run") or {})
|
|
147
|
+
trace_id = str((events[-1] if events else {}).get("trace_id") or root.name)
|
|
148
|
+
reader = dict(raw.get("reader_report") or {})
|
|
149
|
+
|
|
150
|
+
# Read before anything that can use it: the typed payloads outrank the run
|
|
151
|
+
# log wherever both describe the same thing.
|
|
152
|
+
contract = _contract_payloads(root)
|
|
153
|
+
findings = _findings(reader, raw)
|
|
154
|
+
charts = _charts(run, raw, contract)
|
|
155
|
+
repairs = _repairs(raw)
|
|
156
|
+
media = _media_index(cases)
|
|
157
|
+
normalized_cases = [_normalise_case(case, media) for case in cases if isinstance(case, dict)]
|
|
158
|
+
normalized_cases = _merge_recorded_case_evidence(normalized_cases, root)
|
|
159
|
+
# Attach what M5's repair answered on each case, so "12 repaired, 1 broken"
|
|
160
|
+
# is thirteen cases a reader can open rather than two numbers.
|
|
161
|
+
repairs = _repair_outcomes(root, contract)
|
|
162
|
+
for case in normalized_cases:
|
|
163
|
+
hit = repairs.get(case["id"])
|
|
164
|
+
if hit:
|
|
165
|
+
case["repair"] = hit
|
|
166
|
+
# Which partition each case sat in -- explore for M1-M3, the withheld
|
|
167
|
+
# confirm / test pools for M4 and M5 -- read off the records when the run
|
|
168
|
+
# tagged them, inferred from the record order when it did not.
|
|
169
|
+
partitions = _assign_partitions(
|
|
170
|
+
normalized_cases,
|
|
171
|
+
logged_ids=[str(c.get("id") or c.get("case_id") or "") for c in case_events],
|
|
172
|
+
n_explore=run.get("n_cases"),
|
|
173
|
+
final_ids=set(repairs),
|
|
174
|
+
)
|
|
175
|
+
stage_detail = _stage_detail(raw, root, normalized_cases, events)
|
|
176
|
+
stages = _stages(raw, stage_detail.get("m5") if isinstance(stage_detail, dict) else None)
|
|
177
|
+
setting = {
|
|
178
|
+
"model": _contract_model_name(contract) or run.get("model") or "Target model",
|
|
179
|
+
"dataset": run.get("benchmark_name") or "Evaluation dataset",
|
|
180
|
+
"n_cases": int(run.get("n_cases") or len(normalized_cases)),
|
|
181
|
+
"partitions": partitions,
|
|
182
|
+
}
|
|
183
|
+
# The same run, assembled as one failure-to-repair sheet. None when the run
|
|
184
|
+
# has no probe or stats artifacts to build it from -- the section is dropped
|
|
185
|
+
# rather than rendered empty.
|
|
186
|
+
case_study = build_case_study(root, events, setting=setting)
|
|
187
|
+
|
|
188
|
+
return _json_safe(
|
|
189
|
+
{
|
|
190
|
+
"version": REPORT_DATA_VERSION,
|
|
191
|
+
"trace_id": trace_id,
|
|
192
|
+
"source_event_seq": max((int(e.get("event_seq") or 0) for e in events), default=0),
|
|
193
|
+
"generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
194
|
+
"setting": {
|
|
195
|
+
# The contract's ModelRef is a NAME; run.model is whatever the
|
|
196
|
+
# producer stringified, which for a custom Model subclass was
|
|
197
|
+
# repr() -- an unreadable line ending in a memory address.
|
|
198
|
+
"model": _contract_model_name(contract) or run.get("model") or "Target model",
|
|
199
|
+
"dataset": run.get("benchmark_name") or "Evaluation dataset",
|
|
200
|
+
"question": reader.get("question") or "Why did the model fail, and can we fix it?",
|
|
201
|
+
"protocol": run.get("protocol") or "",
|
|
202
|
+
# Who drove the pipeline, as opposed to `model`, which is what
|
|
203
|
+
# it was pointed at. Empty when the run recorded neither.
|
|
204
|
+
"diagnosed_by": _diagnosed_by(root, run),
|
|
205
|
+
# The cover figure the run shipped, if any — see _hero_image.
|
|
206
|
+
"hero_image": _hero_image(root),
|
|
207
|
+
"n_cases": int(run.get("n_cases") or len(normalized_cases)),
|
|
208
|
+
# The frozen batch's partitions, in the order they are drawn:
|
|
209
|
+
# explore, then what was withheld. Empty when the run recorded
|
|
210
|
+
# no split and none can be inferred.
|
|
211
|
+
"partitions": partitions,
|
|
212
|
+
},
|
|
213
|
+
"summary": {
|
|
214
|
+
"headline": reader.get("headline") or "Failure analysis completed",
|
|
215
|
+
"answer": reader.get("answer") or "Inspect the evidence journey below.",
|
|
216
|
+
"confidence": reader.get("confidence") or "unknown",
|
|
217
|
+
"stopped_by": run.get("stopped_by") or "completed",
|
|
218
|
+
},
|
|
219
|
+
"metrics": _metrics(run, raw, normalized_cases),
|
|
220
|
+
"stages": stages,
|
|
221
|
+
"findings": findings,
|
|
222
|
+
"charts": charts,
|
|
223
|
+
"repairs": repairs,
|
|
224
|
+
"stage_detail": stage_detail,
|
|
225
|
+
"case_study": case_study,
|
|
226
|
+
"contract": contract,
|
|
227
|
+
"cases": normalized_cases,
|
|
228
|
+
"media": media,
|
|
229
|
+
"debug": {
|
|
230
|
+
"event_count": len(events),
|
|
231
|
+
"events": [_debug_event(event) for event in events],
|
|
232
|
+
},
|
|
233
|
+
}
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class ReportAgent:
|
|
238
|
+
"""Ask an injected text model to compose a json-render layout.
|
|
239
|
+
|
|
240
|
+
The model sees a compact evidence summary and a list of legal identifiers,
|
|
241
|
+
not raw media or full debug logs. One repair attempt is made for malformed
|
|
242
|
+
output; callers should use :func:`fallback_spec` if both attempts fail.
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
def __init__(self, model: Any, *, catalog_version: str = CATALOG_VERSION) -> None:
|
|
246
|
+
if not hasattr(model, "generate"):
|
|
247
|
+
raise TypeError("ReportAgent model must expose generate(prompt) -> str")
|
|
248
|
+
self.model = model
|
|
249
|
+
self.catalog_version = catalog_version
|
|
250
|
+
|
|
251
|
+
def compose(self, data: Mapping[str, Any]) -> dict[str, Any]:
|
|
252
|
+
prompt = self._prompt(data)
|
|
253
|
+
raw = str(self.model.generate(prompt))
|
|
254
|
+
try:
|
|
255
|
+
return validate_spec(_parse_json(raw), data=data)
|
|
256
|
+
except (ReportSpecError, json.JSONDecodeError, TypeError, ValueError) as first:
|
|
257
|
+
repair = (
|
|
258
|
+
"Your previous report layout was invalid. Return ONLY corrected JSON.\n"
|
|
259
|
+
f"Validation error: {first}\n\nOriginal output:\n{raw[:12000]}\n\n{prompt}"
|
|
260
|
+
)
|
|
261
|
+
return validate_spec(_parse_json(str(self.model.generate(repair))), data=data)
|
|
262
|
+
|
|
263
|
+
def _prompt(self, data: Mapping[str, Any]) -> str:
|
|
264
|
+
compact = {
|
|
265
|
+
"setting": data.get("setting"),
|
|
266
|
+
"summary": data.get("summary"),
|
|
267
|
+
"metrics": data.get("metrics"),
|
|
268
|
+
"stages": data.get("stages"),
|
|
269
|
+
"finding_ids": [item.get("id") for item in data.get("findings", [])],
|
|
270
|
+
"chart_ids": [item.get("id") for item in data.get("charts", [])],
|
|
271
|
+
"repair_ids": [item.get("id") for item in data.get("repairs", [])],
|
|
272
|
+
"case_ids": [item.get("id") for item in data.get("cases", [])[:8]],
|
|
273
|
+
# Not the sheet itself -- it is large, and the agent may not copy
|
|
274
|
+
# numbers. Only whether CaseStudySheet has anything to render.
|
|
275
|
+
"has_case_study": bool(data.get("case_study")),
|
|
276
|
+
}
|
|
277
|
+
return f"""You are the Report Agent for EvalRX. Compose a concise visual report
|
|
278
|
+
showing the journey: model fails on a dataset -> M1 probes behavior -> M2 screens
|
|
279
|
+
associations -> M3 proposes mechanisms -> M4 tests on held-out evidence -> M5 repairs
|
|
280
|
+
the model. A passer-by must understand the setting and outcome without knowing EvalRX.
|
|
281
|
+
|
|
282
|
+
Return ONLY a json-render tree with shape {{"root":"id","elements":{{...}}}}.
|
|
283
|
+
Allowed component types: {', '.join(sorted(ALLOWED_COMPONENTS))}.
|
|
284
|
+
Required exactly once or more: SettingHero, and one of Journey or LoopFigure.
|
|
285
|
+
When has_case_study is true prefer LoopFigure (inputs → explore M1·M2·M3 → held-out
|
|
286
|
+
M4 → repair M5 → health card, one clickable figure) in place of Journey; use
|
|
287
|
+
CaseStudySheet only alongside Journey, directly after it.
|
|
288
|
+
ReportPage may have children. Other elements use props only.
|
|
289
|
+
Every element MUST include a JSON object `"props": {{}}`, even when it has no
|
|
290
|
+
properties. This is required by the json-render runtime.
|
|
291
|
+
Legal props are data references such as {{"findingIds":["finding-1"]}}; never copy,
|
|
292
|
+
rewrite, or invent evidence text/numbers. Prefer charts and journey graphics over prose.
|
|
293
|
+
At most 3 findings, 2 charts, {MAX_ELEMENTS} total elements, depth {MAX_DEPTH}.
|
|
294
|
+
Catalog: {self.catalog_version}.
|
|
295
|
+
|
|
296
|
+
Available report data identifiers:
|
|
297
|
+
{json.dumps(compact, ensure_ascii=False, separators=(',', ':'))}
|
|
298
|
+
"""
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def fallback_spec(data: Mapping[str, Any]) -> dict[str, Any]:
|
|
302
|
+
"""Deterministic, evidence-first layout used without or after a failed model.
|
|
303
|
+
|
|
304
|
+
Deliberately short: hero, metrics, the M1–M5 pipeline, the takeaway sheet,
|
|
305
|
+
and the audit index (the one-line outcome card was dropped 2026-09-10: it
|
|
306
|
+
restated the loop figure's health card). The finding grid, the chart
|
|
307
|
+
grid and the representative-case preview were dropped from the landing
|
|
308
|
+
page — on a real run they repeated the metric strip (pass/fail donut) or
|
|
309
|
+
said "no finding" in a full-width card, and every one of them is still a
|
|
310
|
+
click away in the Evidence / Cases views. FindingGrid, ChartGrid and
|
|
311
|
+
CasePreview stay in the catalog for agent-composed layouts.
|
|
312
|
+
"""
|
|
313
|
+
children = ["setting", "metrics"]
|
|
314
|
+
has_sheet = bool(data.get("case_study"))
|
|
315
|
+
elements: dict[str, Any] = {
|
|
316
|
+
"page": {"type": "ReportPage", "props": {}, "children": children},
|
|
317
|
+
"setting": {"type": "SettingHero", "props": {}},
|
|
318
|
+
"metrics": {"type": "MetricStrip", "props": {}},
|
|
319
|
+
"evidence": {"type": "EvidenceIndex", "props": {}},
|
|
320
|
+
}
|
|
321
|
+
# With a case study the loop figure IS the pipeline strip and the sheet in
|
|
322
|
+
# one picture; without one there is nothing to fill its cards, so the plain
|
|
323
|
+
# stage strip stands in.
|
|
324
|
+
if has_sheet:
|
|
325
|
+
elements["loop"] = {"type": "LoopFigure", "props": {}}
|
|
326
|
+
children.append("loop")
|
|
327
|
+
else:
|
|
328
|
+
elements["journey"] = {"type": "Journey", "props": {}}
|
|
329
|
+
children.append("journey")
|
|
330
|
+
children.append("evidence")
|
|
331
|
+
return elements and {"root": "page", "elements": elements}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def validate_spec(spec: Any, *, data: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
|
335
|
+
"""Validate the closed json-render catalog and bounded tree shape."""
|
|
336
|
+
if not isinstance(spec, dict) or not isinstance(spec.get("root"), str):
|
|
337
|
+
raise ReportSpecError("spec needs string root and elements")
|
|
338
|
+
elements = spec.get("elements")
|
|
339
|
+
if not isinstance(elements, dict) or spec["root"] not in elements:
|
|
340
|
+
raise ReportSpecError("root must reference an element")
|
|
341
|
+
if len(elements) > MAX_ELEMENTS:
|
|
342
|
+
raise ReportSpecError(f"at most {MAX_ELEMENTS} elements are allowed")
|
|
343
|
+
seen_types: list[str] = []
|
|
344
|
+
for element_id, element in elements.items():
|
|
345
|
+
if not isinstance(element_id, str) or not isinstance(element, dict):
|
|
346
|
+
raise ReportSpecError("elements must be named objects")
|
|
347
|
+
component = element.get("type")
|
|
348
|
+
if component not in ALLOWED_COMPONENTS:
|
|
349
|
+
raise ReportSpecError(f"component {component!r} is not in the catalog")
|
|
350
|
+
seen_types.append(component)
|
|
351
|
+
if "props" not in element:
|
|
352
|
+
raise ReportSpecError(f"{element_id}.props must be present (use {{}} when empty)")
|
|
353
|
+
props = element["props"]
|
|
354
|
+
if not isinstance(props, dict):
|
|
355
|
+
raise ReportSpecError(f"{element_id}.props must be an object")
|
|
356
|
+
_validate_props(props)
|
|
357
|
+
allowed_props = {
|
|
358
|
+
"FindingGrid": {"findingIds"}, "ChartGrid": {"chartIds"},
|
|
359
|
+
"CasePreview": {"caseIds"},
|
|
360
|
+
}.get(str(component), set())
|
|
361
|
+
unknown_props = set(props).difference(allowed_props)
|
|
362
|
+
if unknown_props:
|
|
363
|
+
raise ReportSpecError(f"{element_id} has unknown props: {', '.join(sorted(unknown_props))}")
|
|
364
|
+
if component == "FindingGrid" and len(props.get("findingIds") or []) > 3:
|
|
365
|
+
raise ReportSpecError("FindingGrid accepts at most 3 findings")
|
|
366
|
+
if component == "ChartGrid" and len(props.get("chartIds") or []) > 2:
|
|
367
|
+
raise ReportSpecError("ChartGrid accepts at most 2 charts")
|
|
368
|
+
if data is not None:
|
|
369
|
+
_validate_data_refs(component, props, data)
|
|
370
|
+
children = element.get("children", [])
|
|
371
|
+
if children is not None and (
|
|
372
|
+
not isinstance(children, list) or not all(isinstance(child, str) for child in children)
|
|
373
|
+
):
|
|
374
|
+
raise ReportSpecError(f"{element_id}.children must be element ids")
|
|
375
|
+
for child in children or []:
|
|
376
|
+
if child not in elements:
|
|
377
|
+
raise ReportSpecError(f"unknown child {child!r}")
|
|
378
|
+
missing = REQUIRED_COMPONENTS.difference(seen_types)
|
|
379
|
+
if missing:
|
|
380
|
+
raise ReportSpecError(f"missing required components: {', '.join(sorted(missing))}")
|
|
381
|
+
if not PIPELINE_COMPONENTS.intersection(seen_types):
|
|
382
|
+
raise ReportSpecError("missing required components: Journey or LoopFigure")
|
|
383
|
+
_check_tree_depth(spec["root"], elements, set(), 1)
|
|
384
|
+
return {"root": spec["root"], "elements": elements}
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def publish_report(
|
|
388
|
+
run_dir: str | Path,
|
|
389
|
+
*,
|
|
390
|
+
example_dir: str | Path | None = None,
|
|
391
|
+
model: Any | None = None,
|
|
392
|
+
run_logger: Any | None = None,
|
|
393
|
+
) -> PublishedReport:
|
|
394
|
+
"""Compile, compose, validate, cache, and optionally log a completed report."""
|
|
395
|
+
root = Path(run_dir).resolve()
|
|
396
|
+
data = build_report_data(root, example_dir=example_dir)
|
|
397
|
+
mode = "deterministic"
|
|
398
|
+
model_name: str | None = None
|
|
399
|
+
spec = fallback_spec(data)
|
|
400
|
+
if model is not None:
|
|
401
|
+
model_name = getattr(model, "model_name", None) or getattr(model, "name", None) or type(model).__name__
|
|
402
|
+
try:
|
|
403
|
+
spec = ReportAgent(model).compose(data)
|
|
404
|
+
mode = "agent"
|
|
405
|
+
except Exception:
|
|
406
|
+
mode = "fallback"
|
|
407
|
+
spec = fallback_spec(data)
|
|
408
|
+
spec = validate_spec(spec, data=data)
|
|
409
|
+
canonical_spec = json.dumps(spec, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
410
|
+
digest = hashlib.sha256(canonical_spec.encode("utf-8")).hexdigest()
|
|
411
|
+
envelope = {
|
|
412
|
+
"schema_version": REPORT_SCHEMA_VERSION,
|
|
413
|
+
"format": "json-render",
|
|
414
|
+
"json_render_version": JSON_RENDER_VERSION,
|
|
415
|
+
"catalog_version": CATALOG_VERSION,
|
|
416
|
+
"trace_id": data["trace_id"],
|
|
417
|
+
"source_event_seq": data["source_event_seq"],
|
|
418
|
+
"generated_by": {"mode": mode, "model": model_name},
|
|
419
|
+
"spec": spec,
|
|
420
|
+
"sha256": digest,
|
|
421
|
+
}
|
|
422
|
+
report_dir = root / "report"
|
|
423
|
+
report_dir.mkdir(parents=True, exist_ok=True)
|
|
424
|
+
data_path = report_dir / "report_data.json"
|
|
425
|
+
spec_path = report_dir / "report_spec.json"
|
|
426
|
+
data_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
427
|
+
spec_path.write_text(json.dumps(envelope, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
428
|
+
if run_logger is not None and hasattr(run_logger, "log_report_published"):
|
|
429
|
+
run_logger.log_report_published(envelope)
|
|
430
|
+
return PublishedReport(data_path, spec_path, str(data["trace_id"]), mode, digest)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def load_published_report(run_dir: str | Path) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
434
|
+
run_root = Path(run_dir).resolve()
|
|
435
|
+
root = run_root / "report"
|
|
436
|
+
data = json.loads((root / "report_data.json").read_text(encoding="utf-8"))
|
|
437
|
+
envelope = json.loads((root / "report_spec.json").read_text(encoding="utf-8"))
|
|
438
|
+
# Reports published before the explicit-props contract omitted `props` for
|
|
439
|
+
# simple catalog elements. Make those immutable artifacts viewable while
|
|
440
|
+
# newly generated layouts are rejected and repaired by ReportAgent.
|
|
441
|
+
spec = envelope.get("spec")
|
|
442
|
+
if isinstance(spec, dict) and isinstance(spec.get("elements"), dict):
|
|
443
|
+
for element in spec["elements"].values():
|
|
444
|
+
if isinstance(element, dict):
|
|
445
|
+
element.setdefault("props", {})
|
|
446
|
+
# Do not make an old agent-composed layout disappear merely because the
|
|
447
|
+
# renderer learned a richer data projection. Rebuild its data in memory;
|
|
448
|
+
# the saved layout and its provenance remain untouched.
|
|
449
|
+
if int(data.get("version") or 0) < REPORT_DATA_VERSION:
|
|
450
|
+
data = build_report_data(run_root)
|
|
451
|
+
return data, envelope
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def report_is_current(run_dir: str | Path) -> bool:
|
|
455
|
+
"""Whether the cached layout covers every non-publication run event."""
|
|
456
|
+
root = Path(run_dir).resolve()
|
|
457
|
+
try:
|
|
458
|
+
_, envelope = load_published_report(root)
|
|
459
|
+
cached_seq = int(envelope.get("source_event_seq") or 0)
|
|
460
|
+
except (OSError, ValueError, TypeError, json.JSONDecodeError):
|
|
461
|
+
return False
|
|
462
|
+
evidence_seq = max(
|
|
463
|
+
(
|
|
464
|
+
int(event.get("event_seq") or 0)
|
|
465
|
+
for event in _read_events(root)
|
|
466
|
+
if event.get("event") != "report_published"
|
|
467
|
+
),
|
|
468
|
+
default=0,
|
|
469
|
+
)
|
|
470
|
+
return cached_seq >= evidence_seq
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
#: CLI providers under the names their vendors use. A provider absent here is
|
|
474
|
+
#: shown as the run recorded it — a slug is a poor label but an honest one, and
|
|
475
|
+
#: better than a guess at what product it belongs to.
|
|
476
|
+
_AGENT_LABEL = {
|
|
477
|
+
"claude": "Claude Code", "claude_code": "Claude Code",
|
|
478
|
+
"agy": "Antigravity", "antigravity": "Antigravity",
|
|
479
|
+
"codex": "Codex", "gemini_cli": "Gemini CLI", "opencode": "OpenCode",
|
|
480
|
+
"kimi_cli": "Kimi CLI", "llm": "LLM",
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
#: Extensions a run's cover figure may use, with the media type each carries.
|
|
485
|
+
_HERO_IMAGE_TYPES = {
|
|
486
|
+
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
|
487
|
+
".svg": "image/svg+xml",
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
#: Above this, embedding the cover would dominate the payload the browser has
|
|
491
|
+
#: to parse before anything renders — the figure is skipped, not downscaled,
|
|
492
|
+
#: because resampling someone's diagram is worse than not showing it.
|
|
493
|
+
_HERO_IMAGE_MAX_BYTES = 10_000_000
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _hero_image(root: Path) -> str:
|
|
497
|
+
"""The run's own cover figure as a data URI, or "" when it ships none.
|
|
498
|
+
|
|
499
|
+
A run directory may carry ``evalrx_main.(png|jpg|jpeg|svg)`` at its
|
|
500
|
+
top level — beside baseline.json, where a person browsing the folder would
|
|
501
|
+
put the one picture that explains the run (a pipeline diagram, a headline
|
|
502
|
+
chart). The report's hero has always reserved its right half for a
|
|
503
|
+
decorative void; a run that brought its own figure fills it instead.
|
|
504
|
+
|
|
505
|
+
Embedded as a data URI rather than served by path so the portable export,
|
|
506
|
+
a dropped zip and the live server all render it identically — and checked
|
|
507
|
+
in the logs directory as well as its parent, because ``root`` is the logs
|
|
508
|
+
dir for a benchmark run and the run root for a flat one.
|
|
509
|
+
"""
|
|
510
|
+
candidates = [root] + ([root.parent] if root.name == "logs" else [])
|
|
511
|
+
for directory in candidates:
|
|
512
|
+
for ext, mime in _HERO_IMAGE_TYPES.items():
|
|
513
|
+
path = directory / f"evalrx_main{ext}"
|
|
514
|
+
try:
|
|
515
|
+
if not path.is_file() or path.stat().st_size > _HERO_IMAGE_MAX_BYTES:
|
|
516
|
+
continue
|
|
517
|
+
payload = base64.b64encode(path.read_bytes()).decode("ascii")
|
|
518
|
+
except OSError:
|
|
519
|
+
continue
|
|
520
|
+
return f"data:{mime};base64,{payload}"
|
|
521
|
+
return ""
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _diagnosed_by(root: Path, run: Mapping[str, Any]) -> str:
|
|
525
|
+
"""Which agent drove this run, as one display string, or "" if unrecorded.
|
|
526
|
+
|
|
527
|
+
Two runs of the same benchmark against the same model differ ONLY in the
|
|
528
|
+
agent that drove them, and the report never said which — open both and they
|
|
529
|
+
are indistinguishable. The run manifest records the choice cleanly
|
|
530
|
+
(`judge_provider` / `judge_model`); the run_start event's `coder`
|
|
531
|
+
("claude_code:opus") is the fallback for a run written before the manifest,
|
|
532
|
+
and its `judge` is a repr, so it is read only for its provider prefix.
|
|
533
|
+
"""
|
|
534
|
+
config = _load_json(root / "manifest.json")
|
|
535
|
+
config = (config or {}).get("config") if isinstance(config, Mapping) else None
|
|
536
|
+
provider = model = ""
|
|
537
|
+
if isinstance(config, Mapping):
|
|
538
|
+
provider = str(config.get("judge_provider") or "")
|
|
539
|
+
model = str(config.get("judge_model") or "")
|
|
540
|
+
if not provider:
|
|
541
|
+
coder = str(run.get("coder") or "")
|
|
542
|
+
if ":" in coder:
|
|
543
|
+
provider, _, model = coder.partition(":")
|
|
544
|
+
else:
|
|
545
|
+
provider = coder
|
|
546
|
+
if not provider:
|
|
547
|
+
return ""
|
|
548
|
+
label = _AGENT_LABEL.get(provider.strip().lower(), provider.strip())
|
|
549
|
+
return f"{label} · {model.strip()}" if model.strip() else label
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _stages(raw: Mapping[str, Any], m5_detail: Mapping[str, Any] | None = None) -> list[dict[str, Any]]:
|
|
553
|
+
definitions = [
|
|
554
|
+
("M1", "Check behavior", "See what the model does differently when it succeeds or fails", raw.get("m1"), "results"),
|
|
555
|
+
("M2", "Find patterns", "Look for behaviors that reliably appear alongside errors", raw.get("m2"), "stats"),
|
|
556
|
+
("M3", "Suggest causes", "Turn the strongest patterns into ideas that can be tested", raw.get("m3"), "hypotheses"),
|
|
557
|
+
("M4", "Test on new cases", "Check those ideas on evidence not used to create them", raw.get("m4"), "results"),
|
|
558
|
+
("M5", "Try repairs", "Compare possible fixes with the unchanged model", raw.get("m5_fix"), "selection"),
|
|
559
|
+
]
|
|
560
|
+
result = []
|
|
561
|
+
for code, title, purpose, payload, evidence_key in definitions:
|
|
562
|
+
stage = dict(payload or {})
|
|
563
|
+
evidence = stage.get(evidence_key) or []
|
|
564
|
+
ran = bool(stage.get("ran")) if "ran" in stage else bool(evidence or stage.get("duration"))
|
|
565
|
+
status = "completed" if ran else "not-run"
|
|
566
|
+
if code == "M5" and m5_detail and m5_detail.get("skipped"):
|
|
567
|
+
status = "skipped"
|
|
568
|
+
if code == "M5" and ran:
|
|
569
|
+
status = "improved" if stage.get("fixed") else "no-improvement"
|
|
570
|
+
result.append({
|
|
571
|
+
"id": code.lower(), "code": code, "title": title, "purpose": purpose,
|
|
572
|
+
"status": status, "evidence_count": len(evidence) if isinstance(evidence, list) else 1,
|
|
573
|
+
})
|
|
574
|
+
return result
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _stage_detail(
|
|
578
|
+
raw: Mapping[str, Any], root: Path, cases: list[dict[str, Any]], events: list[Mapping[str, Any]]
|
|
579
|
+
) -> dict[str, Any]:
|
|
580
|
+
"""Compile the mature stage-specific views into a bounded web contract.
|
|
581
|
+
|
|
582
|
+
The contract preserves the old workbench's load-bearing distinctions:
|
|
583
|
+
M2 is descriptive, M3 is proposal-only, M4 owns held-out verdicts, and M5
|
|
584
|
+
compares every intervention against the unchanged baseline.
|
|
585
|
+
"""
|
|
586
|
+
logs_dir = Path(raw.get("logs_dir") or root)
|
|
587
|
+
m1 = dict(raw.get("m1") or {})
|
|
588
|
+
probes = []
|
|
589
|
+
for result in m1.get("results") or []:
|
|
590
|
+
if not isinstance(result, dict):
|
|
591
|
+
continue
|
|
592
|
+
rows = list(result.get("per_case") or [])
|
|
593
|
+
probes.append({
|
|
594
|
+
"id": str(result.get("name") or "probe"),
|
|
595
|
+
# The plain question when the label table has one; otherwise the
|
|
596
|
+
# glossary's name ("Self-Repair on Re-ask"), never a Title-Cased id.
|
|
597
|
+
"title": (PLAIN_LABELS.get(str(result.get("name") or ""))
|
|
598
|
+
or str(result.get("display_name") or "")
|
|
599
|
+
or _plain_label(result.get("name") or "Probe")),
|
|
600
|
+
# The glossary's own name for the check ("Self-Repair on Re-ask"),
|
|
601
|
+
# as opposed to `title`, which is the plain question when one exists.
|
|
602
|
+
"display_name": str(result.get("display_name") or ""),
|
|
603
|
+
"raw_name": str(result.get("name") or ""),
|
|
604
|
+
"question": str(result.get("question") or ""),
|
|
605
|
+
"description": str(result.get("description") or ""),
|
|
606
|
+
# None when the analyzer reported no case count at all (a batch-level
|
|
607
|
+
# check such as self_consistency): "unknown" is not "zero".
|
|
608
|
+
"n_cases": result.get("n") if result.get("n") is not None else (len(rows) or None),
|
|
609
|
+
"metrics": list(result.get("headline") or []),
|
|
610
|
+
"finding_summary": _plain_mapping(_compact_mapping(result.get("findings"))),
|
|
611
|
+
"raw_finding_summary": _compact_mapping(result.get("findings")),
|
|
612
|
+
"sample_rows": rows[:12],
|
|
613
|
+
"n_sample_rows": len(rows),
|
|
614
|
+
})
|
|
615
|
+
|
|
616
|
+
m2 = dict(raw.get("m2") or {})
|
|
617
|
+
explore = dict(m2.get("explore") or {})
|
|
618
|
+
figure_dir = Path(raw.get("explore_dir") or logs_dir.parent / "explore") / "figures"
|
|
619
|
+
figures = _explore_figures(explore, figure_dir, root)
|
|
620
|
+
if not figures:
|
|
621
|
+
# No explore/ directory beside the logs (a run copied as logs/ alone,
|
|
622
|
+
# or a V2 run): the explore and analysis events name their figures as
|
|
623
|
+
# paths under logs/, and RunLoggerV2 copied them into M2/artifacts/.
|
|
624
|
+
figures = _logged_figures(
|
|
625
|
+
[*(explore.get("figures") or []), *(m2.get("figures") or [])], logs_dir, root)
|
|
626
|
+
takeaways = []
|
|
627
|
+
for item in explore.get("takeaways") or []:
|
|
628
|
+
if isinstance(item, dict):
|
|
629
|
+
takeaways.append({key: item.get(key) for key in (
|
|
630
|
+
"plain_title", "title", "analysis", "caveat", "chart_names", "table_names",
|
|
631
|
+
)})
|
|
632
|
+
|
|
633
|
+
judges = list((raw.get("agents") or {}).get("judge_calls") or [])
|
|
634
|
+
m3_call = next((call for call in judges if "M3" in str(call.get("stage") or "")), {})
|
|
635
|
+
agent_response = str(m3_call.get("response") or "")
|
|
636
|
+
m3 = dict(raw.get("m3") or {})
|
|
637
|
+
hypotheses = list(m3.get("hypotheses") or explore.get("hypotheses") or [])
|
|
638
|
+
# Runs recorded before the log writer carried `plain_statement` on this list
|
|
639
|
+
# still have it on `proposed_hypotheses`, which the critic step copies from.
|
|
640
|
+
# Joining on the statement recovers the plain sentence the judge did write,
|
|
641
|
+
# instead of leaving the report showing only the technical line. A run with
|
|
642
|
+
# neither is left exactly as it is — nothing is paraphrased into existence.
|
|
643
|
+
proposed = {str(h.get("statement") or ""): h
|
|
644
|
+
for h in (m3.get("proposed_hypotheses") or []) if isinstance(h, Mapping)}
|
|
645
|
+
for h in hypotheses:
|
|
646
|
+
if isinstance(h, dict) and not h.get("plain_statement"):
|
|
647
|
+
recovered = proposed.get(str(h.get("statement") or ""), {}).get("plain_statement")
|
|
648
|
+
if recovered:
|
|
649
|
+
h["plain_statement"] = recovered
|
|
650
|
+
|
|
651
|
+
m4 = dict(raw.get("m4") or {})
|
|
652
|
+
saved_m4 = _load_json(logs_dir / "report" / "m4_results.json")
|
|
653
|
+
m4_results = saved_m4 if isinstance(saved_m4, list) and saved_m4 else list(m4.get("results") or [])
|
|
654
|
+
m4_event = dict(m4.get("event") or {})
|
|
655
|
+
|
|
656
|
+
m5 = dict(raw.get("m5_fix") or {})
|
|
657
|
+
# html_report may see an older sibling log when an example contains
|
|
658
|
+
# multiple attempts. The event stream is trace-filtered and is the source
|
|
659
|
+
# of truth for the run currently being rendered.
|
|
660
|
+
fix_event = next((event for event in reversed(events) if event.get("event") == "fix"), {})
|
|
661
|
+
skipped_event = next((
|
|
662
|
+
event for event in reversed(events)
|
|
663
|
+
if event.get("event") == "stage_skipped" and str(event.get("stage")) == "M5"
|
|
664
|
+
), {})
|
|
665
|
+
if fix_event:
|
|
666
|
+
attempted = [item for item in fix_event.get("attempted") or [] if isinstance(item, Mapping)]
|
|
667
|
+
best_name = fix_event.get("best")
|
|
668
|
+
best = next((item for item in attempted if item.get("name") == best_name), {})
|
|
669
|
+
m5.update({
|
|
670
|
+
"ran": True, "fixed": bool(fix_event.get("fixed")), "selection": attempted,
|
|
671
|
+
"best": best, "confirm": best, "recommendation": fix_event.get("recommendation"),
|
|
672
|
+
})
|
|
673
|
+
if skipped_event:
|
|
674
|
+
m5.update({
|
|
675
|
+
"ran": False,
|
|
676
|
+
"skipped": True,
|
|
677
|
+
"skip_reason": skipped_event.get("reason_code") or "not_attempted",
|
|
678
|
+
"skip_detail": skipped_event.get("detail") or "",
|
|
679
|
+
})
|
|
680
|
+
candidates = [_repair_candidate(item) for item in m5.get("selection") or [] if isinstance(item, dict)]
|
|
681
|
+
confirmation = _repair_candidate(m5.get("confirm") or {})
|
|
682
|
+
if confirmation.get("name"):
|
|
683
|
+
existing = next((i for i, item in enumerate(candidates) if item.get("name") == confirmation["name"]), None)
|
|
684
|
+
if existing is None:
|
|
685
|
+
candidates.append(confirmation)
|
|
686
|
+
else:
|
|
687
|
+
candidates[existing] = {**candidates[existing], **confirmation}
|
|
688
|
+
# Examples are a first-class, bounded evidence type. They are deliberately
|
|
689
|
+
# derived from recorded case I/O and per-case measurements, never written
|
|
690
|
+
# by the report agent. A single case makes a measurement legible; it does
|
|
691
|
+
# not establish the stage conclusion (the UI states that boundary too).
|
|
692
|
+
logged_m1_examples = [
|
|
693
|
+
item for event in events if event.get("event") == "probe"
|
|
694
|
+
for item in (event.get("examples") or []) if isinstance(item, Mapping)
|
|
695
|
+
]
|
|
696
|
+
logged_m4_event = next((event for event in reversed(events) if event.get("event") == "surgery" and event.get("module") == "m4"), {})
|
|
697
|
+
m1_examples = [dict(item) for item in logged_m1_examples[:2]] or _m1_examples(probes, cases)
|
|
698
|
+
m4_examples = _m4_examples(m4_results, {**m4_event, **logged_m4_event}, cases)
|
|
699
|
+
m1_examples = _attach_example_media(m1_examples, cases)
|
|
700
|
+
m4_examples = _attach_example_media(m4_examples, cases)
|
|
701
|
+
enriched_stats = _enrich_m2_stats(
|
|
702
|
+
[item for item in m2.get("stats") or [] if isinstance(item, Mapping)], logs_dir
|
|
703
|
+
)
|
|
704
|
+
display_stats = [_display_stat(item) for item in enriched_stats]
|
|
705
|
+
repair_examples = _m5_examples(candidates, cases, list(m1.get("results") or []))
|
|
706
|
+
# A trajectory is the only source allowed to say that an agent actually
|
|
707
|
+
# used an intermediate operation (zoom, crop, search, ...). Candidate
|
|
708
|
+
# specs are kept separately below because a proposed operation is not an
|
|
709
|
+
# executed one.
|
|
710
|
+
operation_examples = _operation_examples(cases)
|
|
711
|
+
repair_operation_previews = _repair_operation_previews(candidates, cases)
|
|
712
|
+
m1_calls, m1_n_calls = _m1_calls(events)
|
|
713
|
+
|
|
714
|
+
return {
|
|
715
|
+
"m1": {
|
|
716
|
+
"duration": m1.get("duration"), "probes": probes,
|
|
717
|
+
"n_probes": len(probes), "n_measured": max((int(p.get("n_cases") or 0) for p in probes), default=0),
|
|
718
|
+
"examples": m1_examples, "operations": operation_examples,
|
|
719
|
+
# What each probe actually asked the model and what came back,
|
|
720
|
+
# grouped by analyzer: the audit trail behind the numbers above.
|
|
721
|
+
"calls": m1_calls, "n_calls": m1_n_calls,
|
|
722
|
+
},
|
|
723
|
+
"m2": {
|
|
724
|
+
"mode": "descriptive", "conclusion": m2.get("conclusion") or "",
|
|
725
|
+
"narrative": m2.get("narrative") or "", "stats": display_stats,
|
|
726
|
+
"takeaways": takeaways, "figures": figures,
|
|
727
|
+
"observations": list(explore.get("observations") or []),
|
|
728
|
+
"caveats": list(explore.get("caveats") or []),
|
|
729
|
+
"candidate_signals": list(explore.get("candidate_signals") or []),
|
|
730
|
+
"recommended_tests": list(explore.get("recommended_confirmatory_tests") or []),
|
|
731
|
+
},
|
|
732
|
+
"m3": {
|
|
733
|
+
"mode": "proposal", "hypotheses": hypotheses,
|
|
734
|
+
"unparsed_proposals": _recover_unparsed_hypotheses(agent_response) if not hypotheses else [],
|
|
735
|
+
"agent_response": agent_response,
|
|
736
|
+
"candidate_signals": list(explore.get("candidate_signals") or []),
|
|
737
|
+
"recommended_tests": list(explore.get("recommended_confirmatory_tests") or []),
|
|
738
|
+
"evidence_figures": figures[:3], "evidence_stats": display_stats[:6],
|
|
739
|
+
},
|
|
740
|
+
"m4": {
|
|
741
|
+
"ran": bool(m4.get("ran") or m4_results), "mode": "confirmatory",
|
|
742
|
+
"results": m4_results, "event": m4_event, "examples": m4_examples,
|
|
743
|
+
},
|
|
744
|
+
"m5": {
|
|
745
|
+
"ran": bool(m5.get("ran")), "fixed": bool(m5.get("fixed")),
|
|
746
|
+
"skipped": bool(m5.get("skipped")),
|
|
747
|
+
"skip_reason": str(m5.get("skip_reason") or ""),
|
|
748
|
+
"skip_detail": str(m5.get("skip_detail") or ""),
|
|
749
|
+
"candidates": candidates, "confirmation": confirmation,
|
|
750
|
+
"best": _repair_candidate(m5.get("best") or {}),
|
|
751
|
+
# The FixAgent's own verdict on the run — action + the sentence that
|
|
752
|
+
# justifies it ("underpowered: only 4 failing cases…"). This is the
|
|
753
|
+
# line the health card's promotion gate prints.
|
|
754
|
+
"recommendation": (dict(m5["recommendation"])
|
|
755
|
+
if isinstance(m5.get("recommendation"), Mapping) else None),
|
|
756
|
+
"examples": repair_examples,
|
|
757
|
+
"operation_previews": repair_operation_previews,
|
|
758
|
+
"surgeries": list((raw.get("m5_surgery") or {}).get("surgeries") or []),
|
|
759
|
+
"prompt_template": str(m5.get("prompt_template") or ""),
|
|
760
|
+
},
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _m1_examples(probes: list[Mapping[str, Any]], cases: list[Mapping[str, Any]]) -> list[dict[str, Any]]:
|
|
765
|
+
"""Pick recorded, human-readable M1 case walk-throughs.
|
|
766
|
+
|
|
767
|
+
Recent loggers may include ``examples`` directly on a probe. Older runs
|
|
768
|
+
are reconstructed by joining a per-case measurement to the saved baseline
|
|
769
|
+
case record. The latter intentionally says *check result*, not
|
|
770
|
+
*after-answer*: many analyzers do not issue a second generation.
|
|
771
|
+
"""
|
|
772
|
+
case_by_id = {str(case.get("id")): case for case in cases}
|
|
773
|
+
result: list[dict[str, Any]] = []
|
|
774
|
+
used: set[str] = set()
|
|
775
|
+
for probe in probes:
|
|
776
|
+
supplied = probe.get("examples")
|
|
777
|
+
if isinstance(supplied, list):
|
|
778
|
+
for item in supplied:
|
|
779
|
+
if isinstance(item, Mapping):
|
|
780
|
+
result.append(dict(item))
|
|
781
|
+
if len(result) >= 2:
|
|
782
|
+
return result
|
|
783
|
+
rows = [row for row in probe.get("sample_rows") or [] if isinstance(row, Mapping)]
|
|
784
|
+
# Failures are most intuitive when available. A pass is still useful
|
|
785
|
+
# when the run has no labelled failures in this particular probe.
|
|
786
|
+
rows.sort(key=lambda row: 0 if str(case_by_id.get(str(row.get("sample_id")), {}).get("status")) == "fail" else 1)
|
|
787
|
+
for row in rows:
|
|
788
|
+
case_id = str(row.get("sample_id") or row.get("case_id") or "")
|
|
789
|
+
case = case_by_id.get(case_id)
|
|
790
|
+
if not case or case_id in used:
|
|
791
|
+
continue
|
|
792
|
+
measured = {
|
|
793
|
+
str(key).replace("_", " "): value
|
|
794
|
+
for key, value in row.items()
|
|
795
|
+
if key not in {"sample_id", "case_id"} and isinstance(value, (str, int, float, bool))
|
|
796
|
+
}
|
|
797
|
+
if not measured:
|
|
798
|
+
continue
|
|
799
|
+
used.add(case_id)
|
|
800
|
+
result.append({
|
|
801
|
+
"id": f"m1-{probe.get('id', 'probe')}-{case_id}",
|
|
802
|
+
"kind": "case_measurement",
|
|
803
|
+
"case_id": case_id,
|
|
804
|
+
"probe_title": probe.get("title") or "Behavioral check",
|
|
805
|
+
"probe_question": probe.get("question") or "",
|
|
806
|
+
"input": case.get("prompt") or "",
|
|
807
|
+
"baseline_output": case.get("observed"),
|
|
808
|
+
"expected": case.get("expected"),
|
|
809
|
+
"outcome": case.get("status") or "unknown",
|
|
810
|
+
"media_ids": list(case.get("media_ids") or []),
|
|
811
|
+
"check_result": measured,
|
|
812
|
+
"plain_reading": "This one case shows what the check records. The overall pattern comes from every measured case, not this example alone.",
|
|
813
|
+
"evidence_scope": "one recorded case within M1",
|
|
814
|
+
})
|
|
815
|
+
break
|
|
816
|
+
if len(result) >= 2:
|
|
817
|
+
break
|
|
818
|
+
return result
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def _attach_example_media(examples: list[dict[str, Any]], cases: list[Mapping[str, Any]]) -> list[dict[str, Any]]:
|
|
822
|
+
case_by_id = {str(case.get("id")): case for case in cases}
|
|
823
|
+
for example in examples:
|
|
824
|
+
if example.get("probe_title"):
|
|
825
|
+
example["probe_title"] = _plain_label(example["probe_title"])
|
|
826
|
+
if example.get("media_ids"):
|
|
827
|
+
continue
|
|
828
|
+
case = case_by_id.get(str(example.get("case_id") or ""))
|
|
829
|
+
if case:
|
|
830
|
+
example["media_ids"] = list(case.get("media_ids") or [])
|
|
831
|
+
return examples
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def _m4_examples(
|
|
835
|
+
results: list[Any], event: Mapping[str, Any], cases: list[Mapping[str, Any]]
|
|
836
|
+
) -> list[dict[str, Any]]:
|
|
837
|
+
"""Return explicit validation examples, with a truthful aggregate fallback."""
|
|
838
|
+
supplied = event.get("validation_examples") if isinstance(event, Mapping) else None
|
|
839
|
+
if isinstance(supplied, list):
|
|
840
|
+
first = next((item for item in results if isinstance(item, Mapping)), {})
|
|
841
|
+
evidence = first.get("evidence") if isinstance(first.get("evidence"), Mapping) else {}
|
|
842
|
+
kept = [{
|
|
843
|
+
**dict(item),
|
|
844
|
+
"hypothesis": item.get("hypothesis") or first.get("hypothesis") or first.get("statement"),
|
|
845
|
+
"test": item.get("test") or first.get("test_name") or evidence.get("chosen_tool"),
|
|
846
|
+
"status": item.get("status") or first.get("status"),
|
|
847
|
+
} for item in supplied if isinstance(item, Mapping)]
|
|
848
|
+
if kept:
|
|
849
|
+
return kept[:2]
|
|
850
|
+
if not results:
|
|
851
|
+
return []
|
|
852
|
+
first = next((item for item in results if isinstance(item, Mapping)), None)
|
|
853
|
+
if first is None:
|
|
854
|
+
return []
|
|
855
|
+
evidence = first.get("evidence") if isinstance(first.get("evidence"), Mapping) else {}
|
|
856
|
+
# Legacy M4 logs retain the frozen claim and the actual validation statistic
|
|
857
|
+
# but not a row-level verdict. That is still a concrete example of the
|
|
858
|
+
# validation operation; label it as an aggregate test rather than pretend a
|
|
859
|
+
# case was individually adjudicated.
|
|
860
|
+
return [{
|
|
861
|
+
"id": "m4-validation-test-1",
|
|
862
|
+
"kind": "validation_test",
|
|
863
|
+
"hypothesis": first.get("hypothesis") or first.get("statement") or "Frozen hypothesis",
|
|
864
|
+
"test": first.get("test_name") or evidence.get("chosen_tool") or "held-out statistical test",
|
|
865
|
+
"effect": first.get("effect_size", evidence.get("effect_size")),
|
|
866
|
+
"interval": evidence.get("ci"),
|
|
867
|
+
"status": first.get("status") or "inconclusive",
|
|
868
|
+
"verdict": first.get("verdict") or evidence.get("m4_verdict") or "No validation explanation was retained.",
|
|
869
|
+
"plain_reading": "This is one frozen claim tested on the independent validation evidence. The verdict uses the full validation set, not a single hand-picked case.",
|
|
870
|
+
"evidence_scope": "aggregate independent-validation test",
|
|
871
|
+
}]
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def _display_stat(value: Mapping[str, Any]) -> dict[str, Any]:
|
|
875
|
+
"""Keep statistical evidence machine-auditable but primary labels human."""
|
|
876
|
+
config = value.get("config") if isinstance(value.get("config"), Mapping) else {}
|
|
877
|
+
signal = str(config.get("signal") or value.get("signal") or value.get("tool") or "")
|
|
878
|
+
details = value.get("details") if isinstance(value.get("details"), Mapping) else {}
|
|
879
|
+
# A PAIRED tool compares two strategies and carries no `signal`, so `signal`
|
|
880
|
+
# falls through to the tool name and every such row was labelled with the
|
|
881
|
+
# same words ("Mcnemar Evalue"). Three identically-labelled bars is not a
|
|
882
|
+
# chart. The pair it actually compared is right there in the config, and it
|
|
883
|
+
# is the only thing that tells the rows apart.
|
|
884
|
+
groups = [str(name) for name in (config.get("strategies") or []) if name]
|
|
885
|
+
label = _stat_label(config, signal, value.get("tool"))
|
|
886
|
+
return {
|
|
887
|
+
"label": label, "raw_signal": signal, "groups": groups,
|
|
888
|
+
"effect": value.get("effect"), "ci": value.get("ci"),
|
|
889
|
+
"reject": bool(value.get("reject")), "underpowered": bool(value.get("underpowered")),
|
|
890
|
+
"summary": value.get("summary") or "", "p_value": value.get("p_value"),
|
|
891
|
+
"tool": value.get("tool"), "e_value": value.get("e_value"),
|
|
892
|
+
"fail_rate_signal": details.get("fail_rate_signal"),
|
|
893
|
+
"fail_rate_control": details.get("fail_rate_control"),
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def _enrich_m2_stats(stats: list[Mapping[str, Any]], logs_dir: Path) -> list[dict[str, Any]]:
|
|
898
|
+
"""Restore detailed M2 rate fields from durable tool-result artifacts.
|
|
899
|
+
|
|
900
|
+
The compact HTML reader deliberately omits bulky ``details``. Its
|
|
901
|
+
omission used to leave the dynamic M2 view with only one generic effect
|
|
902
|
+
chart, despite the run already having stored the two groups needed for a
|
|
903
|
+
direct error-rate comparison. This is a local join keyed by the exact
|
|
904
|
+
test and signal; no values are re-computed or inferred.
|
|
905
|
+
"""
|
|
906
|
+
artifact_rows: dict[tuple[str, str], Mapping[str, Any]] = {}
|
|
907
|
+
for path in sorted((logs_dir / "artifacts").glob("*m2_stats_results*.json")):
|
|
908
|
+
value = _load_json(path)
|
|
909
|
+
if not isinstance(value, list):
|
|
910
|
+
continue
|
|
911
|
+
for item in value:
|
|
912
|
+
if not isinstance(item, Mapping):
|
|
913
|
+
continue
|
|
914
|
+
config = item.get("config") if isinstance(item.get("config"), Mapping) else {}
|
|
915
|
+
key = (str(item.get("tool") or ""), str(config.get("signal") or item.get("signal") or ""))
|
|
916
|
+
if key[1]:
|
|
917
|
+
artifact_rows[key] = item
|
|
918
|
+
result: list[dict[str, Any]] = []
|
|
919
|
+
for item in stats:
|
|
920
|
+
config = item.get("config") if isinstance(item.get("config"), Mapping) else {}
|
|
921
|
+
key = (str(item.get("tool") or ""), str(config.get("signal") or item.get("signal") or ""))
|
|
922
|
+
saved = artifact_rows.get(key)
|
|
923
|
+
result.append({**dict(item), **({"details": saved.get("details")} if saved and saved.get("details") else {})})
|
|
924
|
+
return result
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def _operation_examples(cases: list[Mapping[str, Any]]) -> list[dict[str, Any]]:
|
|
928
|
+
"""Project recorded agent tool trajectories into compact, case-linked UI cards.
|
|
929
|
+
|
|
930
|
+
This intentionally ignores prompt text that *mentions* a tool. An action
|
|
931
|
+
appears only if a persisted trajectory step carries a structured
|
|
932
|
+
``tool_call``. That preserves the distinction between an actual zoom and
|
|
933
|
+
an LLM merely proposing one.
|
|
934
|
+
"""
|
|
935
|
+
result: list[dict[str, Any]] = []
|
|
936
|
+
for case in cases:
|
|
937
|
+
trajectory = case.get("trajectory")
|
|
938
|
+
if not isinstance(trajectory, Mapping):
|
|
939
|
+
continue
|
|
940
|
+
steps = trajectory.get("steps")
|
|
941
|
+
if not isinstance(steps, list):
|
|
942
|
+
continue
|
|
943
|
+
calls = []
|
|
944
|
+
for step in steps:
|
|
945
|
+
if not isinstance(step, Mapping) or not isinstance(step.get("tool_call"), Mapping):
|
|
946
|
+
continue
|
|
947
|
+
call = step["tool_call"]
|
|
948
|
+
name = str(call.get("name") or "tool")
|
|
949
|
+
calls.append({
|
|
950
|
+
"order": int(step.get("idx") or len(calls) + 1),
|
|
951
|
+
"action": _plain_operation(name), "raw_action": name,
|
|
952
|
+
"parameters": dict(call.get("args") or {}) if isinstance(call.get("args"), Mapping) else {},
|
|
953
|
+
"thought": str(step.get("content") or "")[:500],
|
|
954
|
+
})
|
|
955
|
+
if not calls:
|
|
956
|
+
continue
|
|
957
|
+
result.append({
|
|
958
|
+
"id": f"operations-{case.get('id')}", "case_id": str(case.get("id") or ""),
|
|
959
|
+
"input": case.get("prompt") or "", "expected": case.get("expected"),
|
|
960
|
+
"observed": case.get("observed"), "outcome": case.get("status") or "unknown",
|
|
961
|
+
"media_ids": list(case.get("media_ids") or []), "steps": calls[:6],
|
|
962
|
+
"plain_reading": "These are the actions recorded in this agent's own trajectory for this case.",
|
|
963
|
+
})
|
|
964
|
+
if len(result) >= 2:
|
|
965
|
+
break
|
|
966
|
+
return result
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
def _repair_operation_previews(
|
|
970
|
+
candidates: list[Mapping[str, Any]], cases: list[Mapping[str, Any]]
|
|
971
|
+
) -> list[dict[str, Any]]:
|
|
972
|
+
"""Show what an image-operation repair *would* apply, without claiming execution.
|
|
973
|
+
|
|
974
|
+
Older runs preserve a repair spec but not a per-call visual artifact. The
|
|
975
|
+
report can still pair its declared image operations with the original input
|
|
976
|
+
image for a representative case, as long as the card is explicitly marked
|
|
977
|
+
as a candidate preview. Actual execution is represented by
|
|
978
|
+
:func:`_operation_examples` instead.
|
|
979
|
+
"""
|
|
980
|
+
image_cases = [case for case in cases if case.get("media_ids")]
|
|
981
|
+
result: list[dict[str, Any]] = []
|
|
982
|
+
for candidate in candidates:
|
|
983
|
+
payload = candidate.get("payload") if isinstance(candidate.get("payload"), Mapping) else {}
|
|
984
|
+
ops = [op for op in payload.get("image_ops") or [] if isinstance(op, Mapping) and op.get("tool")]
|
|
985
|
+
if not ops:
|
|
986
|
+
continue
|
|
987
|
+
preferred_ids = [str(item) for item in (candidate.get("fixed_cases") or candidate.get("broken_cases") or [])]
|
|
988
|
+
case = next((item for item in image_cases if str(item.get("id")) in preferred_ids), None)
|
|
989
|
+
case = case or next((item for item in image_cases if str(item.get("status")) == "fail"), None)
|
|
990
|
+
# A legacy run can retain its generated baseline answers in a separate
|
|
991
|
+
# artifact whose ids no longer line up with the saved image manifest.
|
|
992
|
+
# In that case an arbitrary corpus image is still useful to explain the
|
|
993
|
+
# *declared transform*, provided the UI calls it a preview and never a
|
|
994
|
+
# validated before/after result.
|
|
995
|
+
case = case or (image_cases[0] if image_cases else None)
|
|
996
|
+
if case is None:
|
|
997
|
+
continue
|
|
998
|
+
result.append({
|
|
999
|
+
"id": f"repair-operations-{candidate.get('name')}", "candidate": candidate.get("name"),
|
|
1000
|
+
"case_id": str(case.get("id") or ""), "input": case.get("prompt") or "",
|
|
1001
|
+
"expected": case.get("expected"), "observed": case.get("observed"),
|
|
1002
|
+
"media_ids": list(case.get("media_ids") or []),
|
|
1003
|
+
"executed": bool(candidate.get("n_pairs")),
|
|
1004
|
+
"operations": [{
|
|
1005
|
+
"action": _plain_operation(op.get("tool")), "raw_action": str(op.get("tool")),
|
|
1006
|
+
"parameters": dict(op.get("params") or {}) if isinstance(op.get("params"), Mapping) else {},
|
|
1007
|
+
} for op in ops],
|
|
1008
|
+
})
|
|
1009
|
+
if len(result) >= 2:
|
|
1010
|
+
break
|
|
1011
|
+
return result
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def _plain_operation(value: Any) -> str:
|
|
1015
|
+
names = {
|
|
1016
|
+
"image_zoom_in": "Zoom in on part of the image", "zoom_center": "Zoom into the chart center",
|
|
1017
|
+
"crop": "Crop the visual evidence", "crop_case_bbox": "Crop the relevant chart region",
|
|
1018
|
+
"sharpen": "Sharpen chart details", "contrast": "Increase visual contrast",
|
|
1019
|
+
"equalize": "Balance the image contrast", "detect": "Locate an object or region",
|
|
1020
|
+
}
|
|
1021
|
+
raw = str(value or "")
|
|
1022
|
+
return names.get(raw, _plain_label(raw))
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
def _m5_examples(
|
|
1026
|
+
candidates: list[Mapping[str, Any]], cases: list[Mapping[str, Any]], probe_results: list[Any]
|
|
1027
|
+
) -> list[dict[str, Any]]:
|
|
1028
|
+
"""Reconstruct a truthful before/after repair example — confirmed or not.
|
|
1029
|
+
|
|
1030
|
+
A candidate that never cleared the significance bar is still what the
|
|
1031
|
+
search actually tried, and a reader debugging a failed repair needs to
|
|
1032
|
+
see a real case, not just "no candidate passed the repair gate". Prefer a
|
|
1033
|
+
`fixed` winner (statistically significant, net-positive); when none
|
|
1034
|
+
exists, fall back to the strongest attempt by effect size — the same
|
|
1035
|
+
fallback `M5Detail` already uses for its own "best repair" KPI — and tag
|
|
1036
|
+
the example `confirmed: False` so the UI can say plainly it is one
|
|
1037
|
+
candidate's attempt, not an accepted repair. When that candidate never
|
|
1038
|
+
flipped a case to correct either, fall back once more to a case it broke:
|
|
1039
|
+
still evidence, just of the failure mode rather than the fix.
|
|
1040
|
+
"""
|
|
1041
|
+
winner = next((item for item in candidates if item.get("fixed")), None)
|
|
1042
|
+
confirmed = winner is not None
|
|
1043
|
+
if winner is None:
|
|
1044
|
+
winner = max(
|
|
1045
|
+
(item for item in candidates if isinstance(item.get("effect"), (int, float))),
|
|
1046
|
+
key=lambda item: item["effect"],
|
|
1047
|
+
default=(candidates[0] if candidates else None),
|
|
1048
|
+
)
|
|
1049
|
+
if winner is None:
|
|
1050
|
+
return []
|
|
1051
|
+
case_by_id = {str(case.get("id")): case for case in cases}
|
|
1052
|
+
baseline_by_id: dict[str, Any] = {}
|
|
1053
|
+
for probe in probe_results:
|
|
1054
|
+
if not isinstance(probe, Mapping):
|
|
1055
|
+
continue
|
|
1056
|
+
for row in probe.get("per_case") or []:
|
|
1057
|
+
if not isinstance(row, Mapping):
|
|
1058
|
+
continue
|
|
1059
|
+
case_id = str(row.get("sample_id") or row.get("case_id") or "")
|
|
1060
|
+
output = row.get("extracted_answer", row.get("modal_answer"))
|
|
1061
|
+
if case_id and output not in (None, ""):
|
|
1062
|
+
baseline_by_id.setdefault(case_id, output)
|
|
1063
|
+
|
|
1064
|
+
def _build(case_id: str, kind: str) -> "dict[str, Any] | None":
|
|
1065
|
+
case = case_by_id.get(case_id)
|
|
1066
|
+
if not case:
|
|
1067
|
+
return None
|
|
1068
|
+
# `case["observed"]` is the Stage-0 baseline, written once per case id
|
|
1069
|
+
# (RunLoggerV2.log_cases dedupes by id) and never updated — it is NOT
|
|
1070
|
+
# this candidate's repaired answer, no matter which case_record last
|
|
1071
|
+
# touched it. The only place a candidate's own per-case output lives
|
|
1072
|
+
# is `case["repair"]`, which build_report_data attached from that
|
|
1073
|
+
# attempt's trial_root/outputs.jsonl (see _repair_outcomes). Confirm
|
|
1074
|
+
# it is THIS candidate's repair, not some other one that also touched
|
|
1075
|
+
# this case, before trusting it as "repaired".
|
|
1076
|
+
repair_hit = case.get("repair") if isinstance(case.get("repair"), Mapping) else None
|
|
1077
|
+
repaired = (
|
|
1078
|
+
repair_hit.get("output")
|
|
1079
|
+
if repair_hit and repair_hit.get("candidate") == winner.get("name")
|
|
1080
|
+
else None
|
|
1081
|
+
)
|
|
1082
|
+
if repaired in (None, ""):
|
|
1083
|
+
return None # no recorded per-case output for this candidate — nothing truthful to show
|
|
1084
|
+
baseline = baseline_by_id.get(case_id)
|
|
1085
|
+
if baseline in (None, ""):
|
|
1086
|
+
baseline = case.get("observed") # the genuine Stage-0 answer, as a last resort
|
|
1087
|
+
if confirmed:
|
|
1088
|
+
reading = ("This case was counted as fixed in the paired repair evaluation. "
|
|
1089
|
+
"The repair decision still depends on all tested cases and its "
|
|
1090
|
+
"regression checks.")
|
|
1091
|
+
elif kind == "fixed":
|
|
1092
|
+
reading = ("This candidate flipped this case from wrong to right, but the "
|
|
1093
|
+
"repair overall did not clear the significance bar against luck — "
|
|
1094
|
+
"read it as one attempt worth inspecting, not a confirmed fix.")
|
|
1095
|
+
else:
|
|
1096
|
+
reading = ("This candidate did not clear the significance bar, and did not "
|
|
1097
|
+
"flip any case to correct either — this is a case it broke instead, "
|
|
1098
|
+
"shown so the failure mode is inspectable.")
|
|
1099
|
+
return {
|
|
1100
|
+
"id": f"m5-{winner.get('name', 'repair')}-{case_id}", "case_id": case_id,
|
|
1101
|
+
"kind": kind, "confirmed": confirmed,
|
|
1102
|
+
"repair_name": winner.get("name") or "Recorded repair", "input": case.get("prompt") or "",
|
|
1103
|
+
"expected": case.get("expected"), "baseline_output": baseline,
|
|
1104
|
+
"repaired_output": repaired, "media_ids": list(case.get("media_ids") or []),
|
|
1105
|
+
"plain_reading": reading,
|
|
1106
|
+
"baseline_available": baseline not in (None, ""),
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
for case_id in [str(item) for item in winner.get("fixed_cases") or []]:
|
|
1110
|
+
example = _build(case_id, "fixed")
|
|
1111
|
+
if example:
|
|
1112
|
+
return [example]
|
|
1113
|
+
for case_id in [str(item) for item in winner.get("broken_cases") or []]:
|
|
1114
|
+
example = _build(case_id, "broken")
|
|
1115
|
+
if example:
|
|
1116
|
+
return [example]
|
|
1117
|
+
return []
|
|
1118
|
+
return []
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
def _compact_mapping(value: Any, *, limit: int = 18) -> dict[str, Any]:
|
|
1122
|
+
"""Retain readable aggregate findings, excluding unbounded per-case maps."""
|
|
1123
|
+
if not isinstance(value, Mapping):
|
|
1124
|
+
return {}
|
|
1125
|
+
compact: dict[str, Any] = {}
|
|
1126
|
+
for key, item in value.items():
|
|
1127
|
+
if key in {"per_case", "per_case_values", "case_values", "samples", "caveat"}:
|
|
1128
|
+
continue
|
|
1129
|
+
if isinstance(item, (str, int, float, bool)) or item is None:
|
|
1130
|
+
compact[str(key)] = item
|
|
1131
|
+
elif isinstance(item, list) and len(item) <= 12:
|
|
1132
|
+
compact[str(key)] = item
|
|
1133
|
+
elif isinstance(item, Mapping) and len(item) <= 12:
|
|
1134
|
+
nested = {str(k): v for k, v in item.items() if isinstance(v, (str, int, float, bool)) or v is None}
|
|
1135
|
+
if nested:
|
|
1136
|
+
compact[str(key)] = nested
|
|
1137
|
+
if len(compact) >= limit:
|
|
1138
|
+
break
|
|
1139
|
+
return compact
|
|
1140
|
+
|
|
1141
|
+
|
|
1142
|
+
def _plain_label(value: Any) -> str:
|
|
1143
|
+
raw = str(value or "").strip()
|
|
1144
|
+
if not raw:
|
|
1145
|
+
return "Recorded measurement"
|
|
1146
|
+
if raw in PLAIN_LABELS:
|
|
1147
|
+
return PLAIN_LABELS[raw]
|
|
1148
|
+
return re.sub(r"\s+", " ", raw.replace("_", " ")).strip().capitalize()
|
|
1149
|
+
|
|
1150
|
+
|
|
1151
|
+
def _stat_label(config: "Mapping[str, Any] | None", signal: Any, tool: Any) -> str:
|
|
1152
|
+
"""A name for what one statistical test measured, unique within a report.
|
|
1153
|
+
|
|
1154
|
+
A signal test names its signal. A PAIRED test names none — it compares two
|
|
1155
|
+
strategies — so the label used to fall through to the tool, and every paired
|
|
1156
|
+
row in the report came out reading "Mcnemar Evalue". The pair it compared is
|
|
1157
|
+
the thing that tells those rows apart, and it is already in the config.
|
|
1158
|
+
"""
|
|
1159
|
+
cfg = config if isinstance(config, Mapping) else {}
|
|
1160
|
+
if cfg.get("signal"):
|
|
1161
|
+
return _plain_signal(cfg["signal"])
|
|
1162
|
+
groups = [str(name) for name in (cfg.get("strategies") or []) if name]
|
|
1163
|
+
if len(groups) >= 2:
|
|
1164
|
+
return f"{_plain_label(groups[-1])} vs {_plain_label(groups[0])}"
|
|
1165
|
+
return _plain_signal(signal or tool)
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
def _plain_signal(value: Any) -> str:
|
|
1169
|
+
"""Convert ``analyzer.metric`` into a question a non-expert can read."""
|
|
1170
|
+
raw = str(value or "")
|
|
1171
|
+
if not raw:
|
|
1172
|
+
return "Recorded behavior"
|
|
1173
|
+
analyzer, _, metric = raw.partition(".")
|
|
1174
|
+
if analyzer and metric:
|
|
1175
|
+
return f"{_plain_label(analyzer)} — {_plain_label(metric)}"
|
|
1176
|
+
return _plain_label(raw)
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
def _plain_mapping(value: Mapping[str, Any]) -> dict[str, Any]:
|
|
1180
|
+
return {_plain_label(key): item for key, item in value.items()}
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
def _visual_key(value: Any) -> str:
|
|
1184
|
+
return re.sub(r"[^a-z0-9]", "", Path(str(value or "")).stem.lower())
|
|
1185
|
+
|
|
1186
|
+
|
|
1187
|
+
def _logged_figures(paths: Iterable[Any], logs_dir: Path, root: Path) -> list[dict[str, Any]]:
|
|
1188
|
+
"""Figures the run logged by path (relative to logs/), that exist on disk."""
|
|
1189
|
+
result: list[dict[str, Any]] = []
|
|
1190
|
+
seen: set[Path] = set()
|
|
1191
|
+
for item in paths:
|
|
1192
|
+
if not isinstance(item, str) or not item.lower().endswith(".png"):
|
|
1193
|
+
continue
|
|
1194
|
+
candidate = (logs_dir / item).resolve()
|
|
1195
|
+
if candidate in seen or not candidate.is_file():
|
|
1196
|
+
continue
|
|
1197
|
+
seen.add(candidate)
|
|
1198
|
+
stem = re.sub(r"_[0-9a-f]{8}$", "", candidate.stem) # the content-hash suffix V2 adds
|
|
1199
|
+
result.append({
|
|
1200
|
+
"id": candidate.stem, "title": stem.replace("_", " ").strip().title(),
|
|
1201
|
+
"question": "", "path": os.path.relpath(candidate, root), "reading": "",
|
|
1202
|
+
"do_not_infer": "No agent-authored interpretation was saved for this figure.",
|
|
1203
|
+
"disposition": "supporting", "not_promoted_reason": "",
|
|
1204
|
+
})
|
|
1205
|
+
return result[:30]
|
|
1206
|
+
|
|
1207
|
+
|
|
1208
|
+
def _explore_figures(explore: Mapping[str, Any], figure_dir: Path, root: Path) -> list[dict[str, Any]]:
|
|
1209
|
+
plans = [item for item in explore.get("visual_plan") or [] if isinstance(item, Mapping)]
|
|
1210
|
+
readings = [item for item in explore.get("chart_readings") or [] if isinstance(item, Mapping)]
|
|
1211
|
+
files = sorted(figure_dir.glob("*.png")) if figure_dir.is_dir() else []
|
|
1212
|
+
result: list[dict[str, Any]] = []
|
|
1213
|
+
used: set[Path] = set()
|
|
1214
|
+
for plan in plans:
|
|
1215
|
+
name = str(plan.get("name") or "")
|
|
1216
|
+
key = _visual_key(name)
|
|
1217
|
+
match = next((path for path in files if key and key in _visual_key(path.name)), None)
|
|
1218
|
+
if match is None:
|
|
1219
|
+
continue
|
|
1220
|
+
used.add(match)
|
|
1221
|
+
reading = next((item for item in readings if _visual_key(item.get("chart")) == key), {})
|
|
1222
|
+
result.append({
|
|
1223
|
+
"id": name, "title": str(plan.get("display_name") or name.replace("_", " ").title()),
|
|
1224
|
+
"question": str(plan.get("question") or ""), "path": os.path.relpath(match, root),
|
|
1225
|
+
"reading": str(reading.get("reading") or ""),
|
|
1226
|
+
"do_not_infer": str(reading.get("do_not_infer") or ""),
|
|
1227
|
+
"disposition": str(plan.get("disposition") or "supporting"),
|
|
1228
|
+
"not_promoted_reason": str(plan.get("not_promoted_reason") or ""),
|
|
1229
|
+
})
|
|
1230
|
+
for path in files:
|
|
1231
|
+
if path not in used:
|
|
1232
|
+
result.append({
|
|
1233
|
+
"id": path.stem, "title": path.stem.replace("_", " ").title(),
|
|
1234
|
+
"question": "", "path": os.path.relpath(path, root), "reading": "",
|
|
1235
|
+
"do_not_infer": "No agent-authored interpretation was saved for this figure.",
|
|
1236
|
+
"disposition": "audit", "not_promoted_reason": "Unlinked exploratory artifact",
|
|
1237
|
+
})
|
|
1238
|
+
return result[:30]
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
def _recover_unparsed_hypotheses(response: str) -> list[dict[str, Any]]:
|
|
1242
|
+
"""Recover display-only proposals while preserving the parser failure state."""
|
|
1243
|
+
proposals = []
|
|
1244
|
+
for block in re.split(r"\n\s*\n", response.strip()):
|
|
1245
|
+
fields: dict[str, str] = {}
|
|
1246
|
+
for line in block.splitlines():
|
|
1247
|
+
if ":" not in line:
|
|
1248
|
+
continue
|
|
1249
|
+
key, value = line.split(":", 1)
|
|
1250
|
+
fields[key.strip().lower()] = value.strip()
|
|
1251
|
+
if fields.get("hypothesis"):
|
|
1252
|
+
proposals.append({
|
|
1253
|
+
"statement": fields["hypothesis"],
|
|
1254
|
+
"plain_statement": fields.get("plain_statement", ""),
|
|
1255
|
+
"failure_mode": fields.get("failure_mode", ""),
|
|
1256
|
+
"test_design": fields.get("test", ""),
|
|
1257
|
+
"expected_association": fields.get("expected_association", ""),
|
|
1258
|
+
"accepted_by_pipeline": False,
|
|
1259
|
+
})
|
|
1260
|
+
return proposals[:12]
|
|
1261
|
+
|
|
1262
|
+
|
|
1263
|
+
def _load_json(path: Path) -> Any:
|
|
1264
|
+
try:
|
|
1265
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
1266
|
+
except (OSError, ValueError, TypeError):
|
|
1267
|
+
return None
|
|
1268
|
+
|
|
1269
|
+
|
|
1270
|
+
def _repair_headline(value: Mapping[str, Any]) -> str:
|
|
1271
|
+
"""The candidate's own sentence, or the host's for a candidate it authored.
|
|
1272
|
+
|
|
1273
|
+
Looking `visual_grounding` up in the fix agent's own table is not a
|
|
1274
|
+
consumer guessing at a slug -- it is the package that named the candidate
|
|
1275
|
+
saying what it does. Runs recorded before `headline` existed become
|
|
1276
|
+
readable this way; a judge-invented name the host never heard of still
|
|
1277
|
+
resolves to "" and renders blank.
|
|
1278
|
+
"""
|
|
1279
|
+
headline = " ".join(str(value.get("headline") or "").split())
|
|
1280
|
+
if headline:
|
|
1281
|
+
return headline[:300]
|
|
1282
|
+
try:
|
|
1283
|
+
from evalrx.eval_agent.stages.fix_agent import _BUILTIN_DESCRIPTIONS
|
|
1284
|
+
except Exception:
|
|
1285
|
+
return ""
|
|
1286
|
+
return _BUILTIN_DESCRIPTIONS.get(str(value.get("name") or ""), "")
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
def _repair_candidate(value: Any) -> dict[str, Any]:
|
|
1290
|
+
if not isinstance(value, Mapping):
|
|
1291
|
+
return {}
|
|
1292
|
+
fixed_cases = list(value.get("fixed_cases") or [])
|
|
1293
|
+
broken_cases = list(value.get("broken_cases") or [])
|
|
1294
|
+
payload = value.get("payload") if isinstance(value.get("payload"), Mapping) else {}
|
|
1295
|
+
return {
|
|
1296
|
+
"tier": value.get("tier"), "name": value.get("name"), "kind": value.get("kind"),
|
|
1297
|
+
# `ref` and `headline` are what a reader sees; `name` is only the join
|
|
1298
|
+
# key. Both are carried here so the legacy report path shows the same
|
|
1299
|
+
# identifier and the same sentence as the contract-backed views.
|
|
1300
|
+
"ref": value.get("ref") or "", "headline": _repair_headline(value),
|
|
1301
|
+
"source": value.get("source"), "verdict": value.get("verdict"),
|
|
1302
|
+
"n_pairs": value.get("n_pairs"), "n_baseline_correct": value.get("n_baseline_correct"),
|
|
1303
|
+
"n_candidate_correct": value.get("n_candidate_correct"),
|
|
1304
|
+
"n_fixed": value.get("n_fixed", len(fixed_cases)), "n_broken": value.get("n_broken", len(broken_cases)),
|
|
1305
|
+
"effect": value.get("effect"), "e_value": value.get("e_value"),
|
|
1306
|
+
"e_threshold": value.get("e_threshold"),
|
|
1307
|
+
"coverage": value.get("coverage"), "reject": value.get("reject"), "fixed": value.get("fixed"),
|
|
1308
|
+
"n_model_independent": value.get("n_model_independent"),
|
|
1309
|
+
"n_unstable": value.get("n_unstable"),
|
|
1310
|
+
"summary": value.get("summary"), "payload": dict(payload),
|
|
1311
|
+
"fixed_cases": fixed_cases[:20], "broken_cases": broken_cases[:20],
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
|
|
1315
|
+
def _findings(reader: Mapping[str, Any], raw: Mapping[str, Any]) -> list[dict[str, Any]]:
|
|
1316
|
+
result = []
|
|
1317
|
+
for index, finding in enumerate(reader.get("key_findings") or []):
|
|
1318
|
+
if isinstance(finding, dict):
|
|
1319
|
+
result.append({"id": f"finding-{index + 1}", **finding})
|
|
1320
|
+
if result:
|
|
1321
|
+
return result
|
|
1322
|
+
hypotheses = (raw.get("m3") or {}).get("hypotheses") or []
|
|
1323
|
+
for index, hypothesis in enumerate(hypotheses[:3]):
|
|
1324
|
+
if isinstance(hypothesis, dict):
|
|
1325
|
+
result.append({
|
|
1326
|
+
"id": f"finding-{index + 1}",
|
|
1327
|
+
"title": hypothesis.get("failure_mode") or "Candidate mechanism",
|
|
1328
|
+
"summary": hypothesis.get("plain_statement") or hypothesis.get("statement") or "",
|
|
1329
|
+
"evidence_level": hypothesis.get("status") or "Proposed",
|
|
1330
|
+
"limitation": "See M4 for independent verification.",
|
|
1331
|
+
})
|
|
1332
|
+
return result
|
|
1333
|
+
|
|
1334
|
+
|
|
1335
|
+
def _charts(run: Mapping[str, Any], raw: Mapping[str, Any],
|
|
1336
|
+
contract: "Mapping[str, Any] | None" = None) -> list[dict[str, Any]]:
|
|
1337
|
+
charts: list[dict[str, Any]] = []
|
|
1338
|
+
labels = run.get("label_distribution") or {}
|
|
1339
|
+
if isinstance(labels, dict) and labels:
|
|
1340
|
+
charts.append({
|
|
1341
|
+
"id": "outcomes", "kind": "donut", "title": "Evaluation outcomes",
|
|
1342
|
+
"series": [{"label": str(k).title(), "value": v} for k, v in labels.items() if isinstance(v, (int, float))],
|
|
1343
|
+
})
|
|
1344
|
+
# Prefer the contract's rows: `measured` is a name for the SUBJECT and is
|
|
1345
|
+
# unique across the report, so the axis cannot end up with two bars reading
|
|
1346
|
+
# "Mcnemar evalue" (the tool) or several truncating to the same sentence.
|
|
1347
|
+
contract_rows = _contract_stats(contract or {})
|
|
1348
|
+
effect_rows = []
|
|
1349
|
+
for item in contract_rows[:8]:
|
|
1350
|
+
if isinstance(item.get("effect"), (int, float)):
|
|
1351
|
+
signal = (item.get("config") or {}).get("signal")
|
|
1352
|
+
effect_rows.append({
|
|
1353
|
+
"label": item.get("measured") or _plain_signal(signal or item.get("tool")),
|
|
1354
|
+
# The analyzer's own sentence. Absent means undocumented, and the
|
|
1355
|
+
# UI must say that rather than paraphrase the identifier.
|
|
1356
|
+
"means": item.get("means") or "",
|
|
1357
|
+
"raw_label": str(signal or item.get("analysis_key") or item.get("tool") or ""),
|
|
1358
|
+
"value": item["effect"], "highlight": bool(item.get("fdr_corrected")),
|
|
1359
|
+
})
|
|
1360
|
+
if not effect_rows:
|
|
1361
|
+
stats = (raw.get("m2") or {}).get("stats") or []
|
|
1362
|
+
for item in stats[:8]:
|
|
1363
|
+
if isinstance(item, dict) and isinstance(item.get("effect"), (int, float)):
|
|
1364
|
+
signal = ((item.get("config") or {}).get("signal") if isinstance(item.get("config"), Mapping) else None)
|
|
1365
|
+
effect_rows.append({
|
|
1366
|
+
"label": _stat_label(item.get("config"), signal, item.get("tool")),
|
|
1367
|
+
"raw_label": str(signal or item.get("tool") or ""),
|
|
1368
|
+
"value": item["effect"], "highlight": bool(item.get("reject")),
|
|
1369
|
+
})
|
|
1370
|
+
if effect_rows:
|
|
1371
|
+
charts.append({
|
|
1372
|
+
"id": "effects", "kind": "bar", "title": "Which behaviors appear most connected to errors?",
|
|
1373
|
+
"subtitle": "A larger bar means a stronger observed pattern. It does not prove the behavior caused the error.",
|
|
1374
|
+
"series": effect_rows,
|
|
1375
|
+
})
|
|
1376
|
+
return charts
|
|
1377
|
+
|
|
1378
|
+
|
|
1379
|
+
def _repairs(raw: Mapping[str, Any]) -> list[dict[str, Any]]:
|
|
1380
|
+
fix = raw.get("m5_fix") or {}
|
|
1381
|
+
if not fix.get("ran"):
|
|
1382
|
+
return []
|
|
1383
|
+
confirm = fix.get("confirm") if isinstance(fix.get("confirm"), Mapping) else {}
|
|
1384
|
+
# Early runs recorded the winning candidate as its bare name; later ones
|
|
1385
|
+
# record the whole candidate. Both name the same repair.
|
|
1386
|
+
best = fix.get("best")
|
|
1387
|
+
best_name = str(best.get("name") or "") if isinstance(best, Mapping) else str(best or "")
|
|
1388
|
+
return [{
|
|
1389
|
+
"id": "repair-1", "fixed": bool(fix.get("fixed")),
|
|
1390
|
+
"title": best_name or confirm.get("name") or "Targeted repair",
|
|
1391
|
+
"effect": confirm.get("effect"),
|
|
1392
|
+
"fixed_cases": len(confirm.get("fixed_cases") or []),
|
|
1393
|
+
"broken_cases": len(confirm.get("broken_cases") or []),
|
|
1394
|
+
}]
|
|
1395
|
+
|
|
1396
|
+
|
|
1397
|
+
def _metrics(run: Mapping[str, Any], raw: Mapping[str, Any], cases: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
1398
|
+
labels = run.get("label_distribution") or {}
|
|
1399
|
+
failed = labels.get("fail", labels.get("FAIL", 0)) if isinstance(labels, dict) else 0
|
|
1400
|
+
verified = len((raw.get("m4") or {}).get("results") or [])
|
|
1401
|
+
fixed = sum(1 for case in cases if case.get("status") == "fixed")
|
|
1402
|
+
return [
|
|
1403
|
+
{"id": "evaluated", "label": "Cases evaluated", "value": int(run.get("n_cases") or len(cases))},
|
|
1404
|
+
{"id": "failed", "label": "Initial failures", "value": int(failed or 0)},
|
|
1405
|
+
{"id": "verified", "label": "Mechanisms checked", "value": verified},
|
|
1406
|
+
{"id": "fixed", "label": "Cases repaired", "value": fixed},
|
|
1407
|
+
]
|
|
1408
|
+
|
|
1409
|
+
|
|
1410
|
+
def _contract_payloads(root: Path) -> dict[str, Any]:
|
|
1411
|
+
"""The validated per-stage payloads a run emitted, keyed by span id.
|
|
1412
|
+
|
|
1413
|
+
Passed through verbatim. ``stage_detail`` above is a hand-built view whose
|
|
1414
|
+
every field access is a defensive ``.get(x) or y`` — it has to be, because
|
|
1415
|
+
it reads whatever the run log happened to contain. These payloads were
|
|
1416
|
+
validated against the wire models on the way out, so a frontend can decode
|
|
1417
|
+
them with the generated TypeScript instead of re-deriving the shape.
|
|
1418
|
+
|
|
1419
|
+
Absent for a run produced before contract emission existed, or one where the
|
|
1420
|
+
``contract`` extra was not installed. ``{}`` says "this run emitted none",
|
|
1421
|
+
which a reader must not confuse with "this run had no stages".
|
|
1422
|
+
"""
|
|
1423
|
+
out: dict[str, Any] = {}
|
|
1424
|
+
directory = root / "contract"
|
|
1425
|
+
if not directory.is_dir():
|
|
1426
|
+
return out
|
|
1427
|
+
for path in sorted(directory.glob("*.json")):
|
|
1428
|
+
if path.name == "index.json":
|
|
1429
|
+
continue
|
|
1430
|
+
payload = _load_json(path)
|
|
1431
|
+
if payload is None:
|
|
1432
|
+
continue
|
|
1433
|
+
# "c0.m1.json" -> "c0.m1"; ".invalid.json" keeps its suffix so a reader
|
|
1434
|
+
# can see that a stage failed validation rather than silently missing it.
|
|
1435
|
+
out[path.name[: -len(".json")]] = payload
|
|
1436
|
+
for key, payload in out.items():
|
|
1437
|
+
if key.endswith("m5_fix"):
|
|
1438
|
+
_backfill_repair_identity(payload)
|
|
1439
|
+
return out
|
|
1440
|
+
|
|
1441
|
+
|
|
1442
|
+
def _backfill_repair_identity(payload: Any) -> None:
|
|
1443
|
+
"""Give a pre-`ref` run the same numbering a current run would emit.
|
|
1444
|
+
|
|
1445
|
+
Runs recorded before these fields existed carry neither, and a consumer
|
|
1446
|
+
left to number rows itself gives the frozen candidate one number in the
|
|
1447
|
+
sweep and another on its card. The assignment here is the emitter's, done
|
|
1448
|
+
the emitter's way -- selection order first, one number per distinct name --
|
|
1449
|
+
so an old report and a new one read alike.
|
|
1450
|
+
|
|
1451
|
+
It only ever FILLS BLANKS. What a producer actually wrote is never
|
|
1452
|
+
overwritten, and a `headline` is only ever taken from the fix agent's table
|
|
1453
|
+
of its own candidates: a judge-invented name nobody described stays blank,
|
|
1454
|
+
because inventing a sentence for it here would be this file guessing.
|
|
1455
|
+
"""
|
|
1456
|
+
if not isinstance(payload, dict):
|
|
1457
|
+
return
|
|
1458
|
+
try:
|
|
1459
|
+
from evalrx.eval_agent.stages.fix_agent import _BUILTIN_DESCRIPTIONS
|
|
1460
|
+
except Exception:
|
|
1461
|
+
_BUILTIN_DESCRIPTIONS = {}
|
|
1462
|
+
refs: dict[str, str] = {}
|
|
1463
|
+
for group in ("selection", "attempted"):
|
|
1464
|
+
for row in payload.get(group) or []:
|
|
1465
|
+
if not isinstance(row, dict):
|
|
1466
|
+
continue
|
|
1467
|
+
name = str(row.get("name") or "")
|
|
1468
|
+
ref = refs.get(name)
|
|
1469
|
+
if ref is None:
|
|
1470
|
+
ref = f"R{len(refs) + 1}"
|
|
1471
|
+
refs[name] = ref
|
|
1472
|
+
row.setdefault("ref", "")
|
|
1473
|
+
if not row["ref"]:
|
|
1474
|
+
row["ref"] = ref
|
|
1475
|
+
row.setdefault("headline", "")
|
|
1476
|
+
if not row["headline"]:
|
|
1477
|
+
row["headline"] = _BUILTIN_DESCRIPTIONS.get(name, "")
|
|
1478
|
+
|
|
1479
|
+
|
|
1480
|
+
def _repair_outcomes(root: Path, contract: Mapping[str, Any]) -> dict[str, dict[str, Any]]:
|
|
1481
|
+
"""What each repair candidate answered, per case.
|
|
1482
|
+
|
|
1483
|
+
``case_id -> {candidate, tier, status, output}``. The counts in M5 say twelve
|
|
1484
|
+
cases were repaired and one broken; this is what lets a reader open those
|
|
1485
|
+
thirteen and see what actually changed. Without it a repair is a number.
|
|
1486
|
+
|
|
1487
|
+
Read from each attempt's ``trial_root/outputs.jsonl`` rather than the wire,
|
|
1488
|
+
because it is one row per case per candidate — the contract points at the
|
|
1489
|
+
file instead of inlining it, and this is the reader following the pointer.
|
|
1490
|
+
Confirmation attempts only: the selection sweep chose the candidate and its
|
|
1491
|
+
per-case results are not evidence about it.
|
|
1492
|
+
"""
|
|
1493
|
+
out: dict[str, dict[str, Any]] = {}
|
|
1494
|
+
for key in sorted(contract):
|
|
1495
|
+
if not key.endswith("m5_fix"):
|
|
1496
|
+
continue
|
|
1497
|
+
for attempt in (contract[key] or {}).get("attempted") or []:
|
|
1498
|
+
trial = str(attempt.get("trial_root") or "")
|
|
1499
|
+
if not trial:
|
|
1500
|
+
continue
|
|
1501
|
+
path = (root / trial / "outputs.jsonl")
|
|
1502
|
+
if not path.is_file():
|
|
1503
|
+
path = Path(trial) / "outputs.jsonl" # a run read where it was produced
|
|
1504
|
+
if not path.is_file():
|
|
1505
|
+
continue
|
|
1506
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
1507
|
+
try:
|
|
1508
|
+
row = json.loads(line)
|
|
1509
|
+
except ValueError:
|
|
1510
|
+
continue
|
|
1511
|
+
cid = str(row.get("case_id") or "")
|
|
1512
|
+
if not cid:
|
|
1513
|
+
continue
|
|
1514
|
+
out[cid] = {
|
|
1515
|
+
"candidate": attempt.get("name"),
|
|
1516
|
+
"tier": attempt.get("tier"),
|
|
1517
|
+
"status": row.get("status"),
|
|
1518
|
+
"output": row.get("output"),
|
|
1519
|
+
}
|
|
1520
|
+
return out
|
|
1521
|
+
|
|
1522
|
+
|
|
1523
|
+
def _contract_model_name(contract: Mapping[str, Any]) -> str:
|
|
1524
|
+
"""The model's name from M1's payload, or "" for a run that emitted none."""
|
|
1525
|
+
for key in sorted(contract):
|
|
1526
|
+
if not key.endswith(".m1"):
|
|
1527
|
+
continue
|
|
1528
|
+
ref = (contract[key] or {}).get("model") or {}
|
|
1529
|
+
name = str(ref.get("name") or "").strip()
|
|
1530
|
+
if name:
|
|
1531
|
+
return name
|
|
1532
|
+
return ""
|
|
1533
|
+
|
|
1534
|
+
|
|
1535
|
+
def _contract_stats(contract: Mapping[str, Any]) -> list[Mapping[str, Any]]:
|
|
1536
|
+
"""M2's statistical rows from the newest cycle that has any."""
|
|
1537
|
+
for key in sorted(contract, reverse=True):
|
|
1538
|
+
if not key.endswith(".m2"):
|
|
1539
|
+
continue
|
|
1540
|
+
rows = (contract[key] or {}).get("stats_results")
|
|
1541
|
+
if isinstance(rows, list) and rows:
|
|
1542
|
+
return rows
|
|
1543
|
+
return []
|
|
1544
|
+
|
|
1545
|
+
|
|
1546
|
+
def _media_index(cases: Iterable[Mapping[str, Any]]) -> list[dict[str, Any]]:
|
|
1547
|
+
"""One entry per distinct media file, preferring the copy inside the run.
|
|
1548
|
+
|
|
1549
|
+
``inputs.<slot>`` is where the producer read the file from — an absolute path
|
|
1550
|
+
on that host, and meaningless on any other. The run logger already copied the
|
|
1551
|
+
file to ``artifacts/case_media/`` and recorded that relative path, so a run
|
|
1552
|
+
unzipped elsewhere still has its media; index by that when it exists and keep
|
|
1553
|
+
the original only as the fallback for runs that predate the copy.
|
|
1554
|
+
"""
|
|
1555
|
+
media: list[dict[str, Any]] = []
|
|
1556
|
+
seen: set[str] = set()
|
|
1557
|
+
for case in cases:
|
|
1558
|
+
inputs = case.get("inputs") if isinstance(case.get("inputs"), dict) else case
|
|
1559
|
+
# Slots and copies are recorded in the same order, so they zip up.
|
|
1560
|
+
local = [p for p in (case.get("_media_paths") or []) if isinstance(p, str)]
|
|
1561
|
+
i = 0
|
|
1562
|
+
for kind in ("audio", "image", "video"):
|
|
1563
|
+
source = inputs.get(kind) or inputs.get(f"{kind}_path")
|
|
1564
|
+
if not isinstance(source, str) or not source or source.startswith("<"):
|
|
1565
|
+
continue
|
|
1566
|
+
path = local[i] if i < len(local) else source
|
|
1567
|
+
i += 1
|
|
1568
|
+
if path in seen:
|
|
1569
|
+
continue
|
|
1570
|
+
seen.add(path)
|
|
1571
|
+
media.append({"id": f"media-{len(media) + 1}", "kind": kind,
|
|
1572
|
+
"path": path, "source": source})
|
|
1573
|
+
return media
|
|
1574
|
+
|
|
1575
|
+
|
|
1576
|
+
def _normalise_case(case: Mapping[str, Any], media: list[dict[str, Any]]) -> dict[str, Any]:
|
|
1577
|
+
inputs = case.get("inputs") if isinstance(case.get("inputs"), dict) else {}
|
|
1578
|
+
prompt = inputs.get("prompt") or case.get("instruction") or case.get("prompt") or ""
|
|
1579
|
+
# Match on either key: the index prefers the in-run copy, and a run without
|
|
1580
|
+
# copies still resolves through the producer's original path.
|
|
1581
|
+
media_by_path: dict[str, str] = {}
|
|
1582
|
+
for item in media:
|
|
1583
|
+
media_by_path[item["path"]] = item["id"]
|
|
1584
|
+
if item.get("source"):
|
|
1585
|
+
media_by_path[item["source"]] = item["id"]
|
|
1586
|
+
media_ids = []
|
|
1587
|
+
for path in (case.get("_media_paths") or []):
|
|
1588
|
+
if isinstance(path, str) and path in media_by_path and media_by_path[path] not in media_ids:
|
|
1589
|
+
media_ids.append(media_by_path[path])
|
|
1590
|
+
for kind in ("audio", "image", "video"):
|
|
1591
|
+
value = inputs.get(kind) or case.get(f"{kind}_path") or case.get(kind)
|
|
1592
|
+
if isinstance(value, str) and value in media_by_path \
|
|
1593
|
+
and media_by_path[value] not in media_ids:
|
|
1594
|
+
media_ids.append(media_by_path[value])
|
|
1595
|
+
return {
|
|
1596
|
+
"id": str(case.get("id") or case.get("case_id") or f"case-{id(case)}"),
|
|
1597
|
+
"status": str(case.get("status") or case.get("label") or "unknown").lower(),
|
|
1598
|
+
"prompt": prompt,
|
|
1599
|
+
"expected": case.get("expected"),
|
|
1600
|
+
"observed": case.get("observed", case.get("output")),
|
|
1601
|
+
"choices": case.get("choices") or [],
|
|
1602
|
+
"tags": case.get("tags") or case.get("probe_flags") or [],
|
|
1603
|
+
"task": case.get("task") or (case.get("metadata") or {}).get("category") or "",
|
|
1604
|
+
"media_ids": media_ids,
|
|
1605
|
+
"trajectory": case.get("trajectory"),
|
|
1606
|
+
# explore / confirm / test, as the loop logged it; None until
|
|
1607
|
+
# _assign_partitions has had a chance to infer it.
|
|
1608
|
+
"split": case.get("_split") if isinstance(case.get("_split"), str) else None,
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
|
|
1612
|
+
# What each partition was used for, in the words the loop figure draws them
|
|
1613
|
+
# with: D_E on top, D_H, D_C at the bottom. The code is the subscript.
|
|
1614
|
+
_EXPLORE_ROLE = ("M1-M3 mined patterns and hypotheses here. Anything measured on "
|
|
1615
|
+
"these cases is a lead, not a verdict.")
|
|
1616
|
+
_HELDOUT_ROLE = ("Withheld from M1-M3. M4 adjudicated the hypotheses here and M5 "
|
|
1617
|
+
"developed its repair candidates on the same cases.")
|
|
1618
|
+
_CONFIRM_ROLE = ("Withheld from every adaptive decision. The frozen repair was "
|
|
1619
|
+
"scored exactly once here.")
|
|
1620
|
+
_TWO_WAY_ROLE = ("Withheld from M1-M3. M4 adjudicated the hypotheses here and the "
|
|
1621
|
+
"repair was scored on these same cases: one withheld pool "
|
|
1622
|
+
"serving as both the held-out and the confirm set.")
|
|
1623
|
+
|
|
1624
|
+
|
|
1625
|
+
def _assign_partitions(
|
|
1626
|
+
cases: list[dict[str, Any]],
|
|
1627
|
+
*,
|
|
1628
|
+
logged_ids: "Sequence[str]",
|
|
1629
|
+
n_explore: Any,
|
|
1630
|
+
final_ids: "set[str]",
|
|
1631
|
+
) -> list[dict[str, Any]]:
|
|
1632
|
+
"""Put every case in its partition and summarise the partitions.
|
|
1633
|
+
|
|
1634
|
+
``case["split"]`` is filled in place (``explore`` / ``confirm`` / ``test``)
|
|
1635
|
+
and the summary rows come back in drawing order, each with the subscript
|
|
1636
|
+
the loop figure uses (E, H, C), its size and what the run used it for.
|
|
1637
|
+
|
|
1638
|
+
A run that tagged its case records is read as recorded. A run from before
|
|
1639
|
+
the tag existed is inferred from what its log still says: ``run_start``'s
|
|
1640
|
+
``n_cases`` is the explore partition (the loop narrows ``data`` to it before
|
|
1641
|
+
recording the start), and the loop logs explore first, then confirm, then
|
|
1642
|
+
test -- so the first ``n_explore`` logged ids are explore and the rest were
|
|
1643
|
+
withheld. Whether the withheld pool was further divided (train/val/test
|
|
1644
|
+
mode) is recovered from M5's per-case scoring: the cases it scored the
|
|
1645
|
+
frozen repair on are the test partition when they are a strict subset of
|
|
1646
|
+
what was withheld, and the whole pool otherwise. Cases the report merged
|
|
1647
|
+
in from a manifest rather than the log stay untagged -- their order says
|
|
1648
|
+
nothing.
|
|
1649
|
+
|
|
1650
|
+
Returns ``[]`` when there is no split to show: no case carries one and none
|
|
1651
|
+
can be inferred (a run without a confirm split, or a legacy manifest).
|
|
1652
|
+
"""
|
|
1653
|
+
by_id = {str(case.get("id")): case for case in cases}
|
|
1654
|
+
inferred = False
|
|
1655
|
+
if not any(case.get("split") for case in cases):
|
|
1656
|
+
ids = [cid for cid in logged_ids if cid and cid in by_id]
|
|
1657
|
+
try:
|
|
1658
|
+
n_head = int(n_explore or 0)
|
|
1659
|
+
except (TypeError, ValueError):
|
|
1660
|
+
n_head = 0
|
|
1661
|
+
if not ids or n_head <= 0 or n_head >= len(ids):
|
|
1662
|
+
return []
|
|
1663
|
+
withheld = ids[n_head:]
|
|
1664
|
+
final = {cid for cid in final_ids if cid in set(withheld)}
|
|
1665
|
+
three_way = bool(final) and len(final) < len(withheld)
|
|
1666
|
+
for cid in ids[:n_head]:
|
|
1667
|
+
by_id[cid]["split"] = "explore"
|
|
1668
|
+
for cid in withheld:
|
|
1669
|
+
by_id[cid]["split"] = "test" if three_way and cid in final else "confirm"
|
|
1670
|
+
inferred = True
|
|
1671
|
+
|
|
1672
|
+
present = {str(case.get("split")) for case in cases if case.get("split")}
|
|
1673
|
+
if not present:
|
|
1674
|
+
return []
|
|
1675
|
+
three_way = "test" in present
|
|
1676
|
+
|
|
1677
|
+
def row(split: str, code: str, label: str, role: str) -> dict[str, Any]:
|
|
1678
|
+
return {"split": split, "code": code, "label": label,
|
|
1679
|
+
"n": sum(1 for case in cases if case.get("split") == split),
|
|
1680
|
+
"role": role, "inferred": inferred}
|
|
1681
|
+
|
|
1682
|
+
rows = [row("explore", "E", "Explore", _EXPLORE_ROLE)]
|
|
1683
|
+
if three_way:
|
|
1684
|
+
rows.append(row("confirm", "H", "Held-out", _HELDOUT_ROLE))
|
|
1685
|
+
rows.append(row("test", "C", "Confirm", _CONFIRM_ROLE))
|
|
1686
|
+
elif "confirm" in present:
|
|
1687
|
+
rows.append(row("confirm", "H/C", "Held-out / Confirm", _TWO_WAY_ROLE))
|
|
1688
|
+
n_unknown = sum(1 for case in cases if not case.get("split"))
|
|
1689
|
+
if n_unknown:
|
|
1690
|
+
rows.append({"split": "", "code": "?", "label": "Unrecorded", "n": n_unknown,
|
|
1691
|
+
"role": "The run did not record which partition these came from.",
|
|
1692
|
+
"inferred": False})
|
|
1693
|
+
return rows
|
|
1694
|
+
|
|
1695
|
+
|
|
1696
|
+
def _merge_recorded_case_evidence(cases: list[dict[str, Any]], root: Path) -> list[dict[str, Any]]:
|
|
1697
|
+
"""Supplement legacy manifests with the run's recorded baseline I/O.
|
|
1698
|
+
|
|
1699
|
+
Older benchmark exports often keep prompts/media in a manifest while
|
|
1700
|
+
baseline answers live separately in ``report/discovery_cases.json``. This
|
|
1701
|
+
is a generic run artifact, not a task-specific fallback. New JSONL case
|
|
1702
|
+
records already contain both and therefore simply win when populated.
|
|
1703
|
+
"""
|
|
1704
|
+
snapshots: list[Mapping[str, Any]] = []
|
|
1705
|
+
for base in (root, root.parent):
|
|
1706
|
+
value = _load_json(base / "report" / "discovery_cases.json")
|
|
1707
|
+
if isinstance(value, list):
|
|
1708
|
+
snapshots = [item for item in value if isinstance(item, Mapping)]
|
|
1709
|
+
if snapshots:
|
|
1710
|
+
break
|
|
1711
|
+
if not snapshots:
|
|
1712
|
+
return cases
|
|
1713
|
+
existing = {str(case.get("id")): case for case in cases}
|
|
1714
|
+
for raw_case in snapshots:
|
|
1715
|
+
case_id = str(raw_case.get("id") or raw_case.get("case_id") or "")
|
|
1716
|
+
if not case_id:
|
|
1717
|
+
continue
|
|
1718
|
+
update = _normalise_case(raw_case, [])
|
|
1719
|
+
target = existing.get(case_id)
|
|
1720
|
+
if target is None:
|
|
1721
|
+
cases.append(update)
|
|
1722
|
+
existing[case_id] = update
|
|
1723
|
+
continue
|
|
1724
|
+
for key in ("prompt", "expected", "observed"):
|
|
1725
|
+
if not target.get(key) and update.get(key):
|
|
1726
|
+
target[key] = update[key]
|
|
1727
|
+
if target.get("status") in {"", "unknown", "unchanged"} and update.get("status"):
|
|
1728
|
+
target["status"] = update["status"]
|
|
1729
|
+
return cases
|
|
1730
|
+
|
|
1731
|
+
|
|
1732
|
+
def _read_events(root: Path) -> list[dict[str, Any]]:
|
|
1733
|
+
from evalrx.reporting.run_events import read_v2_events
|
|
1734
|
+
|
|
1735
|
+
return read_v2_events(root)
|
|
1736
|
+
|
|
1737
|
+
|
|
1738
|
+
def _infer_example_dir(root: Path) -> Path | None:
|
|
1739
|
+
"""Find a nearby legacy manifest; v4 case events do not need this join."""
|
|
1740
|
+
for candidate in (root, root.parent, root.parent.parent):
|
|
1741
|
+
if any((candidate / "data").glob("*.jsonl")) or any((candidate / "data").glob("*.json")):
|
|
1742
|
+
return candidate
|
|
1743
|
+
return None
|
|
1744
|
+
|
|
1745
|
+
|
|
1746
|
+
_CALL_TEXT_CAP = 3000
|
|
1747
|
+
_CALLS_PER_ANALYZER = 120
|
|
1748
|
+
|
|
1749
|
+
|
|
1750
|
+
def _call_text(value: Any) -> str:
|
|
1751
|
+
"""The prompt or the reply as text, cut so 500 calls stay a sane payload."""
|
|
1752
|
+
if isinstance(value, Mapping):
|
|
1753
|
+
value = value.get("prompt") if "prompt" in value else json.dumps(value, ensure_ascii=False)
|
|
1754
|
+
text = "" if value is None else str(value)
|
|
1755
|
+
return text if len(text) <= _CALL_TEXT_CAP else text[:_CALL_TEXT_CAP] + f"… [+{len(text) - _CALL_TEXT_CAP} chars]"
|
|
1756
|
+
|
|
1757
|
+
|
|
1758
|
+
def _m1_calls(events: Iterable[Mapping[str, Any]]) -> tuple[dict[str, list[dict[str, Any]]], int]:
|
|
1759
|
+
"""M1's model calls grouped by analyzer, oldest first, capped per analyzer."""
|
|
1760
|
+
grouped: dict[str, list[dict[str, Any]]] = {}
|
|
1761
|
+
total = 0
|
|
1762
|
+
for event in events:
|
|
1763
|
+
if str(event.get("event") or "") != "model_call" or str(event.get("stage") or "") != "M1":
|
|
1764
|
+
continue
|
|
1765
|
+
total += 1
|
|
1766
|
+
analyzer = str(event.get("analyzer") or event.get("role") or "model")
|
|
1767
|
+
bucket = grouped.setdefault(analyzer, [])
|
|
1768
|
+
if len(bucket) >= _CALLS_PER_ANALYZER:
|
|
1769
|
+
continue
|
|
1770
|
+
batch = event.get("batch_case_ids")
|
|
1771
|
+
bucket.append({
|
|
1772
|
+
"seq": event.get("event_seq"), "cycle": event.get("cycle"),
|
|
1773
|
+
"method": event.get("method") or event.get("operation") or "",
|
|
1774
|
+
"case_id": event.get("case_id"),
|
|
1775
|
+
"batch_case_ids": [str(item) for item in batch] if isinstance(batch, list) else [],
|
|
1776
|
+
"duration_sec": event.get("duration_sec"),
|
|
1777
|
+
"prompt": _call_text(event.get("inputs")),
|
|
1778
|
+
"output": _call_text(event.get("output")),
|
|
1779
|
+
"error": str(event.get("error")) if event.get("error") else None,
|
|
1780
|
+
})
|
|
1781
|
+
return grouped, total
|
|
1782
|
+
|
|
1783
|
+
|
|
1784
|
+
def _debug_event(event: Mapping[str, Any]) -> dict[str, Any]:
|
|
1785
|
+
event_type = str(event.get("event") or "")
|
|
1786
|
+
inferred_stage = {
|
|
1787
|
+
"probe": "M1", "explore": "M2", "analysis": "M2", "diagnosis": "M3",
|
|
1788
|
+
"surgery": "M4" if event.get("module") == "m4" else "M5",
|
|
1789
|
+
"experiment": "M5", "fix": "M5", "case_record": "DATA",
|
|
1790
|
+
"report_published": "REPORT",
|
|
1791
|
+
}.get(event_type, "RUN")
|
|
1792
|
+
return {
|
|
1793
|
+
"event": event.get("event"), "stage": event.get("stage") or inferred_stage, "cycle": event.get("cycle"),
|
|
1794
|
+
"event_seq": event.get("event_seq"), "ts": event.get("ts"), "span_id": event.get("span_id"),
|
|
1795
|
+
"summary": _event_summary(event),
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
|
|
1799
|
+
def _event_summary(event: Mapping[str, Any]) -> str:
|
|
1800
|
+
if str(event.get("event") or "") == "model_call":
|
|
1801
|
+
# The model calls are most of the stream; "Recorded pipeline event"
|
|
1802
|
+
# 500 times over says nothing. Name the probe, the operation, and the
|
|
1803
|
+
# size of the call instead.
|
|
1804
|
+
who = event.get("analyzer") or event.get("role") or "model"
|
|
1805
|
+
op = event.get("method") or event.get("operation") or "call"
|
|
1806
|
+
n_batch = event.get("n_batch_cases")
|
|
1807
|
+
scope = (f"{n_batch} cases" if n_batch else (f"case {str(event.get('case_id'))[:12]}" if event.get("case_id") else ""))
|
|
1808
|
+
took = event.get("duration_sec")
|
|
1809
|
+
parts = [f"{who} · {op}"] + ([scope] if scope else []) + ([f"{float(took):.1f}s"] if isinstance(took, (int, float)) else [])
|
|
1810
|
+
if event.get("error"):
|
|
1811
|
+
parts.append(f"error: {str(event['error'])[:80]}")
|
|
1812
|
+
return " · ".join(parts)
|
|
1813
|
+
for key in ("conclusion", "narrative", "selection_rationale", "status", "stopped_by"):
|
|
1814
|
+
value = event.get(key)
|
|
1815
|
+
if value not in (None, ""):
|
|
1816
|
+
return str(value)[:280]
|
|
1817
|
+
return "Recorded pipeline event"
|
|
1818
|
+
|
|
1819
|
+
|
|
1820
|
+
def _validate_props(value: Any, *, depth: int = 0) -> None:
|
|
1821
|
+
if depth > 4:
|
|
1822
|
+
raise ReportSpecError("props are too deeply nested")
|
|
1823
|
+
if isinstance(value, str) and len(value) > 200:
|
|
1824
|
+
raise ReportSpecError("layout strings may not exceed 200 characters")
|
|
1825
|
+
if isinstance(value, dict):
|
|
1826
|
+
for child in value.values():
|
|
1827
|
+
_validate_props(child, depth=depth + 1)
|
|
1828
|
+
elif isinstance(value, list):
|
|
1829
|
+
if len(value) > 20:
|
|
1830
|
+
raise ReportSpecError("layout lists may not exceed 20 items")
|
|
1831
|
+
for child in value:
|
|
1832
|
+
_validate_props(child, depth=depth + 1)
|
|
1833
|
+
elif value is not None and not isinstance(value, (str, bool, int, float)):
|
|
1834
|
+
raise ReportSpecError("props must be JSON scalar/list/object values")
|
|
1835
|
+
|
|
1836
|
+
|
|
1837
|
+
def _validate_data_refs(component: str, props: Mapping[str, Any], data: Mapping[str, Any]) -> None:
|
|
1838
|
+
mapping = {
|
|
1839
|
+
"FindingGrid": ("findingIds", "findings"),
|
|
1840
|
+
"ChartGrid": ("chartIds", "charts"),
|
|
1841
|
+
"CasePreview": ("caseIds", "cases"),
|
|
1842
|
+
}
|
|
1843
|
+
if component not in mapping:
|
|
1844
|
+
return
|
|
1845
|
+
prop, collection = mapping[component]
|
|
1846
|
+
refs = props.get(prop) or []
|
|
1847
|
+
if not isinstance(refs, list) or not all(isinstance(ref, str) for ref in refs):
|
|
1848
|
+
raise ReportSpecError(f"{prop} must be a list of strings")
|
|
1849
|
+
legal = {str(item.get("id")) for item in data.get(collection, []) if isinstance(item, dict)}
|
|
1850
|
+
unknown = set(refs).difference(legal)
|
|
1851
|
+
if unknown:
|
|
1852
|
+
raise ReportSpecError(f"unknown {collection} ids: {', '.join(sorted(unknown))}")
|
|
1853
|
+
|
|
1854
|
+
|
|
1855
|
+
def _check_tree_depth(node: str, elements: Mapping[str, Any], stack: set[str], depth: int) -> None:
|
|
1856
|
+
if depth > MAX_DEPTH:
|
|
1857
|
+
raise ReportSpecError(f"tree depth exceeds {MAX_DEPTH}")
|
|
1858
|
+
if node in stack:
|
|
1859
|
+
raise ReportSpecError("element tree contains a cycle")
|
|
1860
|
+
next_stack = {*stack, node}
|
|
1861
|
+
for child in elements[node].get("children") or []:
|
|
1862
|
+
_check_tree_depth(child, elements, next_stack, depth + 1)
|
|
1863
|
+
|
|
1864
|
+
|
|
1865
|
+
def _parse_json(text: str) -> Any:
|
|
1866
|
+
stripped = text.strip()
|
|
1867
|
+
fenced = re.search(r"```(?:json)?\s*(\{.*\})\s*```", stripped, re.DOTALL)
|
|
1868
|
+
return json.loads(fenced.group(1) if fenced else stripped)
|
|
1869
|
+
|
|
1870
|
+
|
|
1871
|
+
def _json_safe(value: Any) -> Any:
|
|
1872
|
+
if value is None or isinstance(value, (str, bool, int, float)):
|
|
1873
|
+
return value
|
|
1874
|
+
if isinstance(value, Path):
|
|
1875
|
+
return str(value)
|
|
1876
|
+
if isinstance(value, dict):
|
|
1877
|
+
return {str(key): _json_safe(item) for key, item in value.items()}
|
|
1878
|
+
if isinstance(value, (list, tuple, set, frozenset)):
|
|
1879
|
+
return [_json_safe(item) for item in value]
|
|
1880
|
+
if hasattr(value, "value"):
|
|
1881
|
+
return _json_safe(value.value)
|
|
1882
|
+
return str(value)
|