coder-eval 0.8.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.
- coder_eval/.gitattributes +2 -0
- coder_eval/__init__.py +3 -0
- coder_eval/agent.py +302 -0
- coder_eval/agents/__init__.py +41 -0
- coder_eval/agents/_logging.py +89 -0
- coder_eval/agents/antigravity_agent.py +811 -0
- coder_eval/agents/claude_code_agent.py +1701 -0
- coder_eval/agents/codex_agent.py +2055 -0
- coder_eval/agents/noop_agent.py +100 -0
- coder_eval/agents/registry.py +163 -0
- coder_eval/agents/watchdog.py +116 -0
- coder_eval/analysis.py +88 -0
- coder_eval/cli/__init__.py +87 -0
- coder_eval/cli/aggregate_command.py +153 -0
- coder_eval/cli/console.py +7 -0
- coder_eval/cli/evaluate_command.py +164 -0
- coder_eval/cli/plan_command.py +152 -0
- coder_eval/cli/report_command.py +121 -0
- coder_eval/cli/run_command.py +686 -0
- coder_eval/cli/run_helpers.py +137 -0
- coder_eval/cli/run_task_internal_command.py +210 -0
- coder_eval/cli/utils.py +37 -0
- coder_eval/config.py +212 -0
- coder_eval/criteria/__init__.py +163 -0
- coder_eval/criteria/_classification_aggregate.py +106 -0
- coder_eval/criteria/agent_judge.py +425 -0
- coder_eval/criteria/base.py +248 -0
- coder_eval/criteria/classification_match.py +107 -0
- coder_eval/criteria/command_executed.py +181 -0
- coder_eval/criteria/commands_efficiency.py +73 -0
- coder_eval/criteria/file_check.py +119 -0
- coder_eval/criteria/file_contains.py +85 -0
- coder_eval/criteria/file_exists.py +45 -0
- coder_eval/criteria/file_matches_regex.py +86 -0
- coder_eval/criteria/json_check.py +184 -0
- coder_eval/criteria/llm_judge.py +260 -0
- coder_eval/criteria/reference_comparison.py +130 -0
- coder_eval/criteria/run_command.py +220 -0
- coder_eval/criteria/skill_triggered.py +111 -0
- coder_eval/criteria/uipath_eval.py +223 -0
- coder_eval/errors/__init__.py +26 -0
- coder_eval/errors/agent.py +26 -0
- coder_eval/errors/budget.py +28 -0
- coder_eval/errors/categories.py +205 -0
- coder_eval/errors/categorization.py +240 -0
- coder_eval/errors/executor.py +124 -0
- coder_eval/errors/judge.py +12 -0
- coder_eval/errors/retry.py +211 -0
- coder_eval/errors/timeout.py +63 -0
- coder_eval/evaluation/__init__.py +10 -0
- coder_eval/evaluation/checker.py +260 -0
- coder_eval/evaluation/judge_anthropic.py +55 -0
- coder_eval/evaluation/judge_bedrock.py +114 -0
- coder_eval/evaluation/judge_context.py +497 -0
- coder_eval/evaluation/judge_models.py +53 -0
- coder_eval/evaluation/judge_persistence.py +291 -0
- coder_eval/evaluation/judge_usage.py +50 -0
- coder_eval/evaluation/sub_agent.py +231 -0
- coder_eval/evaluation/summaries.py +48 -0
- coder_eval/evaluation/verdict_tool.py +213 -0
- coder_eval/formatting.py +191 -0
- coder_eval/isolation/__init__.py +15 -0
- coder_eval/isolation/docker_runner.py +1253 -0
- coder_eval/logging_config.py +399 -0
- coder_eval/models/__init__.py +337 -0
- coder_eval/models/agent_config.py +373 -0
- coder_eval/models/container_paths.py +26 -0
- coder_eval/models/criteria.py +942 -0
- coder_eval/models/enums.py +121 -0
- coder_eval/models/experiment.py +314 -0
- coder_eval/models/judge.py +90 -0
- coder_eval/models/judge_defaults.py +11 -0
- coder_eval/models/limits.py +89 -0
- coder_eval/models/merge_strategy.py +132 -0
- coder_eval/models/mutations.py +95 -0
- coder_eval/models/results.py +787 -0
- coder_eval/models/routing.py +160 -0
- coder_eval/models/sandbox.py +371 -0
- coder_eval/models/tasks.py +631 -0
- coder_eval/models/telemetry.py +454 -0
- coder_eval/models/templates.py +108 -0
- coder_eval/orchestration/__init__.py +13 -0
- coder_eval/orchestration/batch.py +690 -0
- coder_eval/orchestration/config.py +111 -0
- coder_eval/orchestration/config_merge.py +431 -0
- coder_eval/orchestration/evaluation.py +94 -0
- coder_eval/orchestration/experiment.py +837 -0
- coder_eval/orchestration/overrides.py +206 -0
- coder_eval/orchestration/task_loader.py +437 -0
- coder_eval/orchestrator.py +2078 -0
- coder_eval/path_utils.py +79 -0
- coder_eval/plugins.py +81 -0
- coder_eval/pricing.py +169 -0
- coder_eval/py.typed +0 -0
- coder_eval/reports.py +1066 -0
- coder_eval/reports_experiment.py +761 -0
- coder_eval/reports_html.py +1659 -0
- coder_eval/reports_stats.py +250 -0
- coder_eval/resources/__init__.py +137 -0
- coder_eval/resources/default_experiment.yaml +85 -0
- coder_eval/resources/default_ignore_patterns.yaml +60 -0
- coder_eval/resources/tags.yaml +55 -0
- coder_eval/sandbox.py +1127 -0
- coder_eval/scoring/__init__.py +11 -0
- coder_eval/scoring/ast_similarity.py +38 -0
- coder_eval/scoring/complexity.py +115 -0
- coder_eval/scoring/quality.py +154 -0
- coder_eval/scoring/signature_similarity.py +57 -0
- coder_eval/scoring/similarity.py +103 -0
- coder_eval/scoring/token_similarity.py +44 -0
- coder_eval/simulation/__init__.py +27 -0
- coder_eval/simulation/termination.py +88 -0
- coder_eval/simulation/user_simulator.py +324 -0
- coder_eval/streaming/__init__.py +44 -0
- coder_eval/streaming/callbacks.py +57 -0
- coder_eval/streaming/collector.py +193 -0
- coder_eval/streaming/events.py +198 -0
- coder_eval/streaming/renderers.py +248 -0
- coder_eval/streaming/wire.py +115 -0
- coder_eval/telemetry.py +407 -0
- coder_eval/utils.py +517 -0
- coder_eval-0.8.2.dist-info/METADATA +242 -0
- coder_eval-0.8.2.dist-info/RECORD +127 -0
- coder_eval-0.8.2.dist-info/WHEEL +4 -0
- coder_eval-0.8.2.dist-info/entry_points.txt +5 -0
- coder_eval-0.8.2.dist-info/licenses/LICENSE +201 -0
- coder_eval-0.8.2.dist-info/licenses/NOTICE +18 -0
|
@@ -0,0 +1,761 @@
|
|
|
1
|
+
"""Report generation for experiment results (cross-variant and experiment-level)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from coder_eval.models import (
|
|
10
|
+
EvaluationResult,
|
|
11
|
+
ExperimentDefinition,
|
|
12
|
+
ExperimentResult,
|
|
13
|
+
TaskExperimentSummary,
|
|
14
|
+
)
|
|
15
|
+
from coder_eval.path_utils import replicate_subdir_name
|
|
16
|
+
from coder_eval.reports import resolve_agent_settings
|
|
17
|
+
from coder_eval.reports_stats import (
|
|
18
|
+
bootstrap_mean_ci,
|
|
19
|
+
cohens_d,
|
|
20
|
+
describe_prompt_config,
|
|
21
|
+
fmt_mean_sd,
|
|
22
|
+
fmt_p,
|
|
23
|
+
load_variant_eval_results,
|
|
24
|
+
paired_bootstrap_diff_ci,
|
|
25
|
+
stddev,
|
|
26
|
+
welch_t_test,
|
|
27
|
+
wilson_interval,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
# Default pass_threshold from BaseSuccessCriterion — used for Wilson pass-rate in replicate stats.
|
|
34
|
+
_REPLICATE_PASS_THRESHOLD = 0.9
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
# Helper: build task_result dict from EvaluationResult (for variant reports)
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def eval_result_to_task_dict(
|
|
43
|
+
result: EvaluationResult,
|
|
44
|
+
*,
|
|
45
|
+
variant_id: str | None = None,
|
|
46
|
+
tags: list[str] | None = None,
|
|
47
|
+
task_path: str | None = None,
|
|
48
|
+
duration_override: float | None = None,
|
|
49
|
+
replicate_index: int | None = None,
|
|
50
|
+
) -> dict[str, Any]:
|
|
51
|
+
"""Convert an EvaluationResult to the task_result dict format used by ReportGenerator.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
result: The evaluation result to convert.
|
|
55
|
+
variant_id: Optional variant ID to include in the dict.
|
|
56
|
+
tags: Optional tags list (defaults to []).
|
|
57
|
+
task_path: Optional path of the task YAML (as supplied to the runner) —
|
|
58
|
+
lets downstream consumers (evalboard) derive groupings like skill
|
|
59
|
+
from the source folder structure instead of guessing from tags.
|
|
60
|
+
duration_override: Optional duration value (defaults to result.duration_seconds).
|
|
61
|
+
replicate_index: Replicate index of this row (the ``<variant>/<task>/<NN>``
|
|
62
|
+
sub-dir). Repeated runs of the same task share a ``task_id``, so
|
|
63
|
+
without this the row is indistinguishable from its siblings and
|
|
64
|
+
downstream consumers (evalboard) collapse them to one. ``None`` when
|
|
65
|
+
the caller doesn't track replicates (repeats disabled / legacy).
|
|
66
|
+
"""
|
|
67
|
+
from coder_eval.reports_stats import expected_turns_overage, visible_turn_count
|
|
68
|
+
from coder_eval.reports_stats import has_final_reply as _has_final_reply
|
|
69
|
+
|
|
70
|
+
ref_similarity: float | None = None
|
|
71
|
+
for cr in result.success_criteria_results:
|
|
72
|
+
if cr.criterion_type == "reference_comparison":
|
|
73
|
+
ref_similarity = cr.score
|
|
74
|
+
break
|
|
75
|
+
|
|
76
|
+
overage = expected_turns_overage(result)
|
|
77
|
+
|
|
78
|
+
total_turns = sum((t.num_turns or 0) for t in result.iterations)
|
|
79
|
+
|
|
80
|
+
# Whether the agent emitted a text reply (becomes the trailing entry
|
|
81
|
+
# in the Turn timeline). Carried as a row-level boolean so evalboard
|
|
82
|
+
# grid/trends can compute the visible turn count without re-reading
|
|
83
|
+
# per-task content.
|
|
84
|
+
has_reply = _has_final_reply(result)
|
|
85
|
+
|
|
86
|
+
expected_turns_value: int | None = None
|
|
87
|
+
if result.task_config is not None:
|
|
88
|
+
rl = (result.task_config.resolved or {}).get("run_limits") or {}
|
|
89
|
+
if isinstance(rl, dict):
|
|
90
|
+
raw = rl.get("expected_turns")
|
|
91
|
+
if isinstance(raw, int) and raw >= 1:
|
|
92
|
+
expected_turns_value = raw
|
|
93
|
+
|
|
94
|
+
d: dict[str, Any] = {
|
|
95
|
+
"task_id": result.task_id,
|
|
96
|
+
"replicate_index": replicate_index,
|
|
97
|
+
"status": result.final_status,
|
|
98
|
+
"weighted_score": result.weighted_score,
|
|
99
|
+
"duration": duration_override if duration_override is not None else result.duration_seconds,
|
|
100
|
+
"iteration_count": result.iteration_count,
|
|
101
|
+
"tags": tags if tags is not None else [],
|
|
102
|
+
"task_path": task_path,
|
|
103
|
+
"iterations": [
|
|
104
|
+
{
|
|
105
|
+
"iteration": t.iteration,
|
|
106
|
+
"duration_seconds": t.duration_seconds,
|
|
107
|
+
"command_count": len(t.commands),
|
|
108
|
+
"assistant_turn_count": t.assistant_turn_count,
|
|
109
|
+
"crashed": t.crashed,
|
|
110
|
+
"crash_reason": t.crash_reason,
|
|
111
|
+
}
|
|
112
|
+
for t in result.iterations
|
|
113
|
+
],
|
|
114
|
+
"model_used": result.model_used,
|
|
115
|
+
"reference_similarity": ref_similarity,
|
|
116
|
+
"input_tokens": (result.total_token_usage.uncached_input_tokens if result.total_token_usage else None),
|
|
117
|
+
"output_tokens": (result.total_token_usage.output_tokens if result.total_token_usage else None),
|
|
118
|
+
"cache_creation_input_tokens": (
|
|
119
|
+
result.total_token_usage.cache_creation_input_tokens if result.total_token_usage else None
|
|
120
|
+
),
|
|
121
|
+
"cache_read_input_tokens": (
|
|
122
|
+
result.total_token_usage.cache_read_input_tokens if result.total_token_usage else None
|
|
123
|
+
),
|
|
124
|
+
"total_tokens": (result.total_token_usage.total_tokens if result.total_token_usage else None),
|
|
125
|
+
"total_cost_usd": (result.total_token_usage.total_cost_usd if result.total_token_usage else None),
|
|
126
|
+
"expected_commands": result.expected_commands,
|
|
127
|
+
"actual_commands": result.actual_commands,
|
|
128
|
+
"commands_efficiency": result.commands_efficiency,
|
|
129
|
+
"agent_config": (result.agent_config.model_dump() if result.agent_config else None),
|
|
130
|
+
"sdk_options": result.sdk_options,
|
|
131
|
+
"installed_tools": result.environment_info.get("installed_tools"),
|
|
132
|
+
"max_turns_exhausted": result.max_turns_exhausted,
|
|
133
|
+
"expected_turns_overage": list(overage) if overage is not None else None,
|
|
134
|
+
"total_turns": total_turns,
|
|
135
|
+
# Documented "visible turns" (tool calls + final reply) — the canonical
|
|
136
|
+
# turn count the run-level "within expected turns" metric compares against
|
|
137
|
+
# expected_turns. Distinct from total_turns (SDK num_turns).
|
|
138
|
+
"visible_turns": visible_turn_count(result),
|
|
139
|
+
"expected_turns": expected_turns_value,
|
|
140
|
+
"has_final_reply": has_reply,
|
|
141
|
+
}
|
|
142
|
+
d["variant_id"] = variant_id
|
|
143
|
+
return d
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class ExperimentReportGenerator:
|
|
147
|
+
"""Generates markdown reports for experiment results."""
|
|
148
|
+
|
|
149
|
+
@staticmethod
|
|
150
|
+
def generate_task_report(summary: TaskExperimentSummary) -> str:
|
|
151
|
+
"""Generate task-report content for a single task's cross-variant comparison.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
summary: Cross-variant summary for one task.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
Markdown string.
|
|
158
|
+
"""
|
|
159
|
+
lines = [
|
|
160
|
+
f"# Task Report: {summary.task_id}",
|
|
161
|
+
"",
|
|
162
|
+
f"**Best variant**: {summary.best_variant}",
|
|
163
|
+
f"**Score spread**: {summary.score_spread:.3f}",
|
|
164
|
+
"",
|
|
165
|
+
"## Variant Comparison",
|
|
166
|
+
"",
|
|
167
|
+
"| Variant | Score | Status | Avg Duration | Tokens |",
|
|
168
|
+
"|---------|-------|--------|--------------|--------|",
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
for v in summary.variant_results:
|
|
172
|
+
tokens_str = f"{v.total_tokens:,}" if v.total_tokens is not None else "N/A"
|
|
173
|
+
avg_dur = v.duration_seconds / v.replicate_count
|
|
174
|
+
lines.append(
|
|
175
|
+
f"| {v.variant_id} | {v.weighted_score:.3f} | {v.final_status}" + f" | {avg_dur:.1f}s | {tokens_str} |"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
return "\n".join(lines)
|
|
179
|
+
|
|
180
|
+
@staticmethod
|
|
181
|
+
def _experiment_header_lines(result: ExperimentResult) -> list[str]:
|
|
182
|
+
"""Title + Description / Variants / Total Duration. First block (no leading blank)."""
|
|
183
|
+
return [
|
|
184
|
+
f"# Experiment Report: {result.experiment_id}",
|
|
185
|
+
"",
|
|
186
|
+
f"**Description**: {result.description}",
|
|
187
|
+
f"**Variants**: {', '.join(result.variant_ids)}",
|
|
188
|
+
f"**Total Duration**: {result.total_duration_seconds:.1f}s",
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
@staticmethod
|
|
192
|
+
def _prompt_config_lines(result: ExperimentResult, experiment: ExperimentDefinition | None) -> list[str]:
|
|
193
|
+
"""The ``## Prompt Configuration`` block. Returns ``[]`` when there is no
|
|
194
|
+
experiment definition or no variant carries prompt config (both guards preserved)."""
|
|
195
|
+
# ── Variant prompt configuration (if experiment definition available) ──
|
|
196
|
+
if experiment is None:
|
|
197
|
+
return []
|
|
198
|
+
variant_map = {v.variant_id: v for v in experiment.variants}
|
|
199
|
+
has_prompt_config = bool(experiment.defaults and experiment.defaults.prompt_mutations) or any(
|
|
200
|
+
v.prompt_mutations or v.initial_prompt or v.initial_prompt_file for v in experiment.variants
|
|
201
|
+
)
|
|
202
|
+
if not has_prompt_config:
|
|
203
|
+
return []
|
|
204
|
+
lines = ["", "## Prompt Configuration", ""]
|
|
205
|
+
for vid in result.variant_ids:
|
|
206
|
+
v = variant_map.get(vid)
|
|
207
|
+
desc = describe_prompt_config(v) if v else "(unknown)"
|
|
208
|
+
lines.append(f"- **{vid}**: {desc}")
|
|
209
|
+
return lines
|
|
210
|
+
|
|
211
|
+
@staticmethod
|
|
212
|
+
def _collect_variant_series(
|
|
213
|
+
result: ExperimentResult,
|
|
214
|
+
) -> tuple[dict[str, list[float]], dict[str, list[float]], dict[str, list[float]], dict[str, list[float]]]:
|
|
215
|
+
"""Per-variant (scores, durations, tokens, assistant-turns) series collected
|
|
216
|
+
across all task summaries — the raw inputs to the Aggregate Metrics stat rows."""
|
|
217
|
+
variant_scores: dict[str, list[float]] = {vid: [] for vid in result.variant_ids}
|
|
218
|
+
variant_durations: dict[str, list[float]] = {vid: [] for vid in result.variant_ids}
|
|
219
|
+
variant_tokens: dict[str, list[float]] = {vid: [] for vid in result.variant_ids}
|
|
220
|
+
variant_asst_turns: dict[str, list[float]] = {vid: [] for vid in result.variant_ids}
|
|
221
|
+
|
|
222
|
+
for ts in result.task_summaries:
|
|
223
|
+
for vr in ts.variant_results:
|
|
224
|
+
variant_scores[vr.variant_id].append(vr.weighted_score)
|
|
225
|
+
variant_durations[vr.variant_id].append(vr.duration_seconds / vr.replicate_count)
|
|
226
|
+
if vr.total_tokens is not None:
|
|
227
|
+
variant_tokens[vr.variant_id].append(float(vr.total_tokens))
|
|
228
|
+
if vr.total_assistant_turns is not None:
|
|
229
|
+
variant_asst_turns[vr.variant_id].append(float(vr.total_assistant_turns))
|
|
230
|
+
return variant_scores, variant_durations, variant_tokens, variant_asst_turns
|
|
231
|
+
|
|
232
|
+
@staticmethod
|
|
233
|
+
def _aggregate_count_rows(result: ExperimentResult, show_p_values: bool) -> list[str]:
|
|
234
|
+
"""The integer-aggregate rows of the Aggregate Metrics table: Tasks Run,
|
|
235
|
+
Succeeded, Failed, the optional budget sub-rows, Errors, and Success Rate.
|
|
236
|
+
Each row appends ``" | —"`` in the p-value column when ``show_p_values``."""
|
|
237
|
+
lines: list[str] = []
|
|
238
|
+
|
|
239
|
+
# Row: Tasks Run (count, no stddev)
|
|
240
|
+
row = "| Tasks Run"
|
|
241
|
+
for vid in result.variant_ids:
|
|
242
|
+
agg = result.variant_aggregates[vid]
|
|
243
|
+
row += f" | {agg.tasks_run}"
|
|
244
|
+
if show_p_values:
|
|
245
|
+
row += " | —"
|
|
246
|
+
lines.append(row + " |")
|
|
247
|
+
|
|
248
|
+
# Row: Succeeded
|
|
249
|
+
row = "| Succeeded"
|
|
250
|
+
for vid in result.variant_ids:
|
|
251
|
+
agg = result.variant_aggregates[vid]
|
|
252
|
+
row += f" | {agg.tasks_succeeded}"
|
|
253
|
+
if show_p_values:
|
|
254
|
+
row += " | —"
|
|
255
|
+
lines.append(row + " |")
|
|
256
|
+
|
|
257
|
+
# Row: Failed
|
|
258
|
+
row = "| Failed"
|
|
259
|
+
for vid in result.variant_ids:
|
|
260
|
+
agg = result.variant_aggregates[vid]
|
|
261
|
+
row += f" | {agg.tasks_failed}"
|
|
262
|
+
if show_p_values:
|
|
263
|
+
row += " | —"
|
|
264
|
+
lines.append(row + " |")
|
|
265
|
+
|
|
266
|
+
# Optional sub-rows: only rendered when at least one variant has budget-exceeded tasks.
|
|
267
|
+
if any(result.variant_aggregates[vid].tasks_token_budget_exceeded > 0 for vid in result.variant_ids):
|
|
268
|
+
row = "| - Token budget"
|
|
269
|
+
for vid in result.variant_ids:
|
|
270
|
+
row += f" | {result.variant_aggregates[vid].tasks_token_budget_exceeded}"
|
|
271
|
+
if show_p_values:
|
|
272
|
+
row += " | —"
|
|
273
|
+
lines.append(row + " |")
|
|
274
|
+
if any(result.variant_aggregates[vid].tasks_cost_budget_exceeded > 0 for vid in result.variant_ids):
|
|
275
|
+
row = "| - Cost budget"
|
|
276
|
+
for vid in result.variant_ids:
|
|
277
|
+
row += f" | {result.variant_aggregates[vid].tasks_cost_budget_exceeded}"
|
|
278
|
+
if show_p_values:
|
|
279
|
+
row += " | —"
|
|
280
|
+
lines.append(row + " |")
|
|
281
|
+
|
|
282
|
+
# Row: Errors
|
|
283
|
+
row = "| Errors"
|
|
284
|
+
for vid in result.variant_ids:
|
|
285
|
+
agg = result.variant_aggregates[vid]
|
|
286
|
+
row += f" | {agg.tasks_error}"
|
|
287
|
+
if show_p_values:
|
|
288
|
+
row += " | —"
|
|
289
|
+
lines.append(row + " |")
|
|
290
|
+
|
|
291
|
+
# Row: Success Rate (errors excluded from denominator — they're infrastructure failures, not task failures)
|
|
292
|
+
row = "| Success Rate"
|
|
293
|
+
for vid in result.variant_ids:
|
|
294
|
+
agg = result.variant_aggregates[vid]
|
|
295
|
+
evaluable = agg.tasks_run - agg.tasks_error
|
|
296
|
+
rate = (agg.tasks_succeeded / evaluable * 100) if evaluable > 0 else 0
|
|
297
|
+
row += f" | {rate:.1f}%"
|
|
298
|
+
if show_p_values:
|
|
299
|
+
row += " | —"
|
|
300
|
+
lines.append(row + " |")
|
|
301
|
+
|
|
302
|
+
return lines
|
|
303
|
+
|
|
304
|
+
@staticmethod
|
|
305
|
+
def _aggregate_stat_rows(
|
|
306
|
+
result: ExperimentResult,
|
|
307
|
+
variant_scores: dict[str, list[float]],
|
|
308
|
+
variant_durations: dict[str, list[float]],
|
|
309
|
+
variant_tokens: dict[str, list[float]],
|
|
310
|
+
variant_asst_turns: dict[str, list[float]],
|
|
311
|
+
show_p_values: bool,
|
|
312
|
+
vid_a: str,
|
|
313
|
+
vid_b: str,
|
|
314
|
+
) -> list[str]:
|
|
315
|
+
"""The mean ± stddev rows of the Aggregate Metrics table (Score, Avg Duration,
|
|
316
|
+
and the optional Assistant Turns / Tokens rows), each with a Welch t-test
|
|
317
|
+
p-value when ``show_p_values``."""
|
|
318
|
+
lines: list[str] = []
|
|
319
|
+
|
|
320
|
+
# Row: Score (mean ± stddev, p-value)
|
|
321
|
+
row = "| Score"
|
|
322
|
+
for vid in result.variant_ids:
|
|
323
|
+
row += f" | {fmt_mean_sd(variant_scores[vid])}"
|
|
324
|
+
if show_p_values:
|
|
325
|
+
p = welch_t_test(variant_scores[vid_a], variant_scores[vid_b])
|
|
326
|
+
row += f" | {fmt_p(p)}"
|
|
327
|
+
lines.append(row + " |")
|
|
328
|
+
|
|
329
|
+
# Row: Duration
|
|
330
|
+
row = "| Avg Duration (s)"
|
|
331
|
+
for vid in result.variant_ids:
|
|
332
|
+
row += f" | {fmt_mean_sd(variant_durations[vid], '.1f')}"
|
|
333
|
+
if show_p_values:
|
|
334
|
+
p = welch_t_test(variant_durations[vid_a], variant_durations[vid_b])
|
|
335
|
+
row += f" | {fmt_p(p)}"
|
|
336
|
+
lines.append(row + " |")
|
|
337
|
+
|
|
338
|
+
# Row: Assistant Turns (if data available)
|
|
339
|
+
if any(variant_asst_turns[vid] for vid in result.variant_ids):
|
|
340
|
+
row = "| Assistant Turns"
|
|
341
|
+
for vid in result.variant_ids:
|
|
342
|
+
row += f" | {fmt_mean_sd(variant_asst_turns[vid], '.1f')}"
|
|
343
|
+
if show_p_values:
|
|
344
|
+
p = welch_t_test(variant_asst_turns[vid_a], variant_asst_turns[vid_b])
|
|
345
|
+
row += f" | {fmt_p(p)}"
|
|
346
|
+
lines.append(row + " |")
|
|
347
|
+
|
|
348
|
+
# Row: Tokens (if data available)
|
|
349
|
+
if any(variant_tokens[vid] for vid in result.variant_ids):
|
|
350
|
+
row = "| Tokens"
|
|
351
|
+
for vid in result.variant_ids:
|
|
352
|
+
row += f" | {fmt_mean_sd(variant_tokens[vid], ',.0f')}"
|
|
353
|
+
if show_p_values:
|
|
354
|
+
p = welch_t_test(variant_tokens[vid_a], variant_tokens[vid_b])
|
|
355
|
+
row += f" | {fmt_p(p)}"
|
|
356
|
+
lines.append(row + " |")
|
|
357
|
+
|
|
358
|
+
return lines
|
|
359
|
+
|
|
360
|
+
@staticmethod
|
|
361
|
+
def _aggregate_metrics_lines(result: ExperimentResult) -> list[str]:
|
|
362
|
+
"""The ``## Aggregate Metrics`` vertical table (metrics as rows, variants as
|
|
363
|
+
columns). The p-value column + Welch t-tests appear only for exactly 2 variants;
|
|
364
|
+
``vid_a``/``vid_b`` stay local so the 3+-variant path never indexes them."""
|
|
365
|
+
# ── Aggregate Metrics (vertical: metrics as rows, variants as columns) ──
|
|
366
|
+
variant_scores, variant_durations, variant_tokens, variant_asst_turns = (
|
|
367
|
+
ExperimentReportGenerator._collect_variant_series(result)
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
show_p_values = len(result.variant_ids) == 2
|
|
371
|
+
vid_a, vid_b = (result.variant_ids[0], result.variant_ids[1]) if show_p_values else ("", "")
|
|
372
|
+
|
|
373
|
+
# Build header
|
|
374
|
+
header = "| Metric | " + " | ".join(result.variant_ids)
|
|
375
|
+
sep = "|--------|" + "|".join("--------" for _ in result.variant_ids)
|
|
376
|
+
if show_p_values:
|
|
377
|
+
header += " | p-value"
|
|
378
|
+
sep += "|--------"
|
|
379
|
+
header += " |"
|
|
380
|
+
sep += "|"
|
|
381
|
+
|
|
382
|
+
lines = ["", "## Aggregate Metrics", "", header, sep]
|
|
383
|
+
lines += ExperimentReportGenerator._aggregate_count_rows(result, show_p_values)
|
|
384
|
+
lines += ExperimentReportGenerator._aggregate_stat_rows(
|
|
385
|
+
result, variant_scores, variant_durations, variant_tokens, variant_asst_turns, show_p_values, vid_a, vid_b
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
# Row: Replicates/task (if any variant ran >1 replicate)
|
|
389
|
+
if any(result.variant_aggregates[vid].replicate_count > 1 for vid in result.variant_ids):
|
|
390
|
+
row = "| Replicates/task"
|
|
391
|
+
for vid in result.variant_ids:
|
|
392
|
+
agg = result.variant_aggregates[vid]
|
|
393
|
+
row += f" | {agg.replicate_count}"
|
|
394
|
+
if show_p_values:
|
|
395
|
+
row += " | —"
|
|
396
|
+
lines.append(row + " |")
|
|
397
|
+
|
|
398
|
+
return lines
|
|
399
|
+
|
|
400
|
+
@staticmethod
|
|
401
|
+
def _win_loss_lines(result: ExperimentResult) -> list[str]:
|
|
402
|
+
"""The ``## Win Rates`` + ``## Per-Task Comparison`` + ``## Most Divergent Tasks``
|
|
403
|
+
block. Returns ``[]`` when there are no task summaries."""
|
|
404
|
+
# ── Win/loss/tie analysis ──
|
|
405
|
+
if not result.task_summaries:
|
|
406
|
+
return []
|
|
407
|
+
lines = ["", "## Win Rates", ""]
|
|
408
|
+
win_counts: dict[str, int] = {vid: 0 for vid in result.variant_ids}
|
|
409
|
+
tie_count = 0
|
|
410
|
+
for ts in result.task_summaries:
|
|
411
|
+
if ts.is_tie:
|
|
412
|
+
tie_count += 1
|
|
413
|
+
else:
|
|
414
|
+
win_counts[ts.best_variant] = win_counts.get(ts.best_variant, 0) + 1
|
|
415
|
+
total_tasks = len(result.task_summaries)
|
|
416
|
+
for vid in result.variant_ids:
|
|
417
|
+
wins = win_counts.get(vid, 0)
|
|
418
|
+
lines.append(f"- **{vid}**: {wins}/{total_tasks} tasks ({wins / total_tasks * 100:.0f}%)")
|
|
419
|
+
if tie_count > 0:
|
|
420
|
+
lines.append(f"- **Ties**: {tie_count}/{total_tasks} tasks ({tie_count / total_tasks * 100:.0f}%)")
|
|
421
|
+
|
|
422
|
+
# ── Per-task detailed comparison ──
|
|
423
|
+
show_reps = any(ts.replicate_count > 1 for ts in result.task_summaries)
|
|
424
|
+
lines.extend(["", "## Per-Task Comparison", ""])
|
|
425
|
+
header = "| Task | " + " | ".join(result.variant_ids) + " | Best | Spread |"
|
|
426
|
+
sep = "|------|" + "|".join("------" for _ in result.variant_ids) + "|------|--------|"
|
|
427
|
+
if show_reps:
|
|
428
|
+
header += " Reps |"
|
|
429
|
+
sep += "------|"
|
|
430
|
+
lines.append(header)
|
|
431
|
+
lines.append(sep)
|
|
432
|
+
|
|
433
|
+
for ts in result.task_summaries:
|
|
434
|
+
scores_by_variant = {vr.variant_id: vr for vr in ts.variant_results}
|
|
435
|
+
cells = []
|
|
436
|
+
for vid in result.variant_ids:
|
|
437
|
+
vr = scores_by_variant.get(vid)
|
|
438
|
+
if vr:
|
|
439
|
+
status_icon = vr.final_status.icon
|
|
440
|
+
cells.append(f"{vr.weighted_score:.3f} ({status_icon})")
|
|
441
|
+
else:
|
|
442
|
+
cells.append("N/A")
|
|
443
|
+
best_str = f"{'TIE' if ts.is_tie else ts.best_variant}"
|
|
444
|
+
row = f"| {ts.task_id} | " + " | ".join(cells) + f" | {best_str} | {ts.score_spread:.3f} |"
|
|
445
|
+
if show_reps:
|
|
446
|
+
row += f" {ts.replicate_count} |"
|
|
447
|
+
lines.append(row)
|
|
448
|
+
|
|
449
|
+
# ── Highest divergence ──
|
|
450
|
+
sorted_tasks = sorted(result.task_summaries, key=lambda t: t.score_spread, reverse=True)
|
|
451
|
+
if sorted_tasks and sorted_tasks[0].score_spread > 0:
|
|
452
|
+
lines.extend(["", "## Most Divergent Tasks", ""])
|
|
453
|
+
for ts in sorted_tasks[:5]:
|
|
454
|
+
lines.append(f"- **{ts.task_id}**: spread={ts.score_spread:.3f}, best={ts.best_variant}")
|
|
455
|
+
return lines
|
|
456
|
+
|
|
457
|
+
@staticmethod
|
|
458
|
+
def _replicate_stats_lines(result: ExperimentResult) -> list[str]:
|
|
459
|
+
"""The ``## Replicate Statistics`` block: per-variant bootstrap-CI / Wilson
|
|
460
|
+
pass-rate table + the 2-variant paired-bootstrap comparison. Returns ``[]``
|
|
461
|
+
when no variant ran more than one replicate."""
|
|
462
|
+
# ── Replicate Statistics (only when any variant ran >1 replicate) ──
|
|
463
|
+
if not any(ts.replicate_count > 1 for ts in result.task_summaries):
|
|
464
|
+
return []
|
|
465
|
+
lines = ["", "## Replicate Statistics", ""]
|
|
466
|
+
|
|
467
|
+
# Per-variant bootstrap CI + Wilson pass-rate table
|
|
468
|
+
lines.append("| Variant | Replicates/task | Mean score | 95% CI | Pass-rate (Wilson 95%) |")
|
|
469
|
+
lines.append("|---------|-----------------|------------|--------|------------------------|")
|
|
470
|
+
for vid in result.variant_ids:
|
|
471
|
+
per_rep = result.per_replicate_scores.get(vid, {})
|
|
472
|
+
all_scores: list[float] = [s for scores in per_rep.values() for s in scores]
|
|
473
|
+
passes = sum(1 for s in all_scores if s >= _REPLICATE_PASS_THRESHOLD)
|
|
474
|
+
m, lo, hi = bootstrap_mean_ci(all_scores)
|
|
475
|
+
wlo, whi = wilson_interval(passes, len(all_scores))
|
|
476
|
+
agg = result.variant_aggregates.get(vid)
|
|
477
|
+
rep_count = agg.replicate_count if agg else 1
|
|
478
|
+
lines.append(
|
|
479
|
+
f"| {vid} | {rep_count} | {m:.3f} | [{lo:.3f}, {hi:.3f}]"
|
|
480
|
+
+ f" | {passes}/{len(all_scores)} [{wlo:.2f}, {whi:.2f}] |"
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
# Paired comparison for 2-variant experiments
|
|
484
|
+
if len(result.variant_ids) == 2:
|
|
485
|
+
vid_a, vid_b = result.variant_ids[0], result.variant_ids[1]
|
|
486
|
+
per_rep_a = result.per_replicate_scores.get(vid_a, {})
|
|
487
|
+
per_rep_b = result.per_replicate_scores.get(vid_b, {})
|
|
488
|
+
common_tasks = sorted(set(per_rep_a) & set(per_rep_b))
|
|
489
|
+
a_scores: list[float] = []
|
|
490
|
+
b_scores: list[float] = []
|
|
491
|
+
skipped_tasks: list[str] = []
|
|
492
|
+
for task_id in common_tasks:
|
|
493
|
+
rep_a = per_rep_a[task_id]
|
|
494
|
+
rep_b = per_rep_b[task_id]
|
|
495
|
+
if len(rep_a) == len(rep_b):
|
|
496
|
+
a_scores.extend(rep_a)
|
|
497
|
+
b_scores.extend(rep_b)
|
|
498
|
+
else:
|
|
499
|
+
skipped_tasks.append(task_id)
|
|
500
|
+
diff = paired_bootstrap_diff_ci(a_scores, b_scores)
|
|
501
|
+
if diff is not None:
|
|
502
|
+
mean_diff, d_lo, d_hi = diff
|
|
503
|
+
d_val = cohens_d(a_scores, b_scores)
|
|
504
|
+
d_str = f"{d_val:.2f}" if d_val is not None else "n/a"
|
|
505
|
+
suffix = f" ({len(skipped_tasks)} task(s) excluded: unequal replicate counts)" if skipped_tasks else ""
|
|
506
|
+
lines.extend(
|
|
507
|
+
[
|
|
508
|
+
"",
|
|
509
|
+
f"**Paired mean diff ({vid_a} - {vid_b})**: {mean_diff:+.3f}"
|
|
510
|
+
+ f" [95% CI {d_lo:+.3f}, {d_hi:+.3f}], Cohen's d = {d_str}{suffix}",
|
|
511
|
+
]
|
|
512
|
+
)
|
|
513
|
+
else:
|
|
514
|
+
lines.extend(
|
|
515
|
+
[
|
|
516
|
+
"",
|
|
517
|
+
f"*Paired statistics skipped — unequal replicate counts between {vid_a} and {vid_b}.*",
|
|
518
|
+
]
|
|
519
|
+
)
|
|
520
|
+
return lines
|
|
521
|
+
|
|
522
|
+
@staticmethod
|
|
523
|
+
def generate_experiment_report(
|
|
524
|
+
result: ExperimentResult,
|
|
525
|
+
experiment: ExperimentDefinition | None = None,
|
|
526
|
+
) -> str:
|
|
527
|
+
"""Generate experiment-report content for the full experiment.
|
|
528
|
+
|
|
529
|
+
Produces a vertical "Aggregate Metrics" table (metrics as rows, variants
|
|
530
|
+
as columns) with mean ± stddev and Welch's t-test p-values.
|
|
531
|
+
|
|
532
|
+
Args:
|
|
533
|
+
result: Complete experiment result.
|
|
534
|
+
experiment: Optional experiment definition (enables prompt config display).
|
|
535
|
+
|
|
536
|
+
Returns:
|
|
537
|
+
Markdown string.
|
|
538
|
+
"""
|
|
539
|
+
lines = ExperimentReportGenerator._experiment_header_lines(result)
|
|
540
|
+
lines += ExperimentReportGenerator._prompt_config_lines(result, experiment)
|
|
541
|
+
lines += ExperimentReportGenerator._aggregate_metrics_lines(result)
|
|
542
|
+
lines += ExperimentReportGenerator._win_loss_lines(result)
|
|
543
|
+
lines += ExperimentReportGenerator._replicate_stats_lines(result)
|
|
544
|
+
return "\n".join(lines)
|
|
545
|
+
|
|
546
|
+
@staticmethod
|
|
547
|
+
def generate_variant_report(variant_id: str, result: ExperimentResult, run_dir: Path | None = None) -> str:
|
|
548
|
+
"""Generate a comprehensive variant report matching run-report.md format.
|
|
549
|
+
|
|
550
|
+
When run_dir is provided, loads full EvaluationResult data from disk to
|
|
551
|
+
include generation metrics, token usage, command telemetry, agent settings,
|
|
552
|
+
and environment information.
|
|
553
|
+
|
|
554
|
+
Args:
|
|
555
|
+
variant_id: The variant to generate the report for.
|
|
556
|
+
result: Complete experiment result.
|
|
557
|
+
run_dir: Top-level run directory (enables rich report sections).
|
|
558
|
+
|
|
559
|
+
Returns:
|
|
560
|
+
Markdown string.
|
|
561
|
+
"""
|
|
562
|
+
from coder_eval.reports import ReportGenerator
|
|
563
|
+
|
|
564
|
+
agg = result.variant_aggregates[variant_id]
|
|
565
|
+
evaluable = agg.tasks_run - agg.tasks_error
|
|
566
|
+
success_rate = (agg.tasks_succeeded / evaluable * 100) if evaluable > 0 else 0
|
|
567
|
+
tokens_str = f"{agg.total_tokens:,}" if agg.total_tokens is not None else "N/A"
|
|
568
|
+
|
|
569
|
+
failed_line = f"- **Failed**: {agg.tasks_failed}"
|
|
570
|
+
if agg.tasks_token_budget_exceeded or agg.tasks_cost_budget_exceeded:
|
|
571
|
+
failed_line += (
|
|
572
|
+
f" (incl. {agg.tasks_token_budget_exceeded} token budget, "
|
|
573
|
+
f"{agg.tasks_cost_budget_exceeded} cost budget exceeded)"
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
lines = [
|
|
577
|
+
f"# Variant Report: {variant_id}",
|
|
578
|
+
"",
|
|
579
|
+
f"**Experiment**: {result.experiment_id}",
|
|
580
|
+
f"**Description**: {result.description}",
|
|
581
|
+
"",
|
|
582
|
+
"## Summary",
|
|
583
|
+
"",
|
|
584
|
+
f"- **Tasks Run**: {agg.tasks_run}",
|
|
585
|
+
f"- **Succeeded**: {agg.tasks_succeeded}",
|
|
586
|
+
failed_line,
|
|
587
|
+
f"- **Errors**: {agg.tasks_error}",
|
|
588
|
+
f"- **Success Rate**: {success_rate:.1f}%",
|
|
589
|
+
f"- **Average Score**: {agg.average_score:.3f}",
|
|
590
|
+
f"- **Average Duration**: {agg.average_duration:.1f}s",
|
|
591
|
+
f"- **Total Tokens**: {tokens_str}",
|
|
592
|
+
]
|
|
593
|
+
|
|
594
|
+
# Collect per-task variant results for stddev metrics
|
|
595
|
+
variant_results = [
|
|
596
|
+
vr for ts in result.task_summaries for vr in ts.variant_results if vr.variant_id == variant_id
|
|
597
|
+
]
|
|
598
|
+
scores = [vr.weighted_score for vr in variant_results]
|
|
599
|
+
durations = [vr.duration_seconds / vr.replicate_count for vr in variant_results]
|
|
600
|
+
|
|
601
|
+
if scores and len(scores) >= 2:
|
|
602
|
+
lines.append(f"- **Score Stddev**: {stddev(scores):.3f}")
|
|
603
|
+
if durations and len(durations) >= 2:
|
|
604
|
+
lines.append(f"- **Duration Stddev**: {stddev(durations):.1f}s")
|
|
605
|
+
if agg.replicate_count > 1:
|
|
606
|
+
per_rep = result.per_replicate_scores.get(variant_id, {})
|
|
607
|
+
all_rep_scores: list[float] = [s for rep_scores in per_rep.values() for s in rep_scores]
|
|
608
|
+
if all_rep_scores:
|
|
609
|
+
_, lo, hi = bootstrap_mean_ci(all_rep_scores)
|
|
610
|
+
lines.append(f"- **Replicates/task**: {agg.replicate_count}")
|
|
611
|
+
lines.append(f"- **Score 95% CI**: [{lo:.3f}, {hi:.3f}] (bootstrap over {len(all_rep_scores)} samples)")
|
|
612
|
+
|
|
613
|
+
# Task Details table
|
|
614
|
+
has_similarity = any(vr.reference_similarity is not None for vr in variant_results)
|
|
615
|
+
has_reps = any(vr.replicate_count > 1 for vr in variant_results)
|
|
616
|
+
|
|
617
|
+
header = "| Task | Score | Status | Avg Duration |"
|
|
618
|
+
separator = "|------|-------|--------|--------------|"
|
|
619
|
+
if has_reps:
|
|
620
|
+
header += " Reps |"
|
|
621
|
+
separator += "------|"
|
|
622
|
+
if has_similarity:
|
|
623
|
+
header += " Similarity |"
|
|
624
|
+
separator += "------------|"
|
|
625
|
+
|
|
626
|
+
lines.extend(["", "## Task Details", "", header, separator])
|
|
627
|
+
|
|
628
|
+
for ts in result.task_summaries:
|
|
629
|
+
for vr in ts.variant_results:
|
|
630
|
+
if vr.variant_id == variant_id:
|
|
631
|
+
avg_duration = vr.duration_seconds / vr.replicate_count
|
|
632
|
+
row = f"| {ts.task_id} | {vr.weighted_score:.3f} | {vr.final_status} | {avg_duration:.1f}s |"
|
|
633
|
+
if has_reps:
|
|
634
|
+
row += f" {vr.replicate_count} |"
|
|
635
|
+
if has_similarity:
|
|
636
|
+
sim_str = f"{vr.reference_similarity:.3f}" if vr.reference_similarity is not None else "N/A"
|
|
637
|
+
row += f" {sim_str} |"
|
|
638
|
+
lines.append(row)
|
|
639
|
+
|
|
640
|
+
# ── Rich sections from EvaluationResult data (when run_dir available) ──
|
|
641
|
+
if run_dir:
|
|
642
|
+
eval_results = load_variant_eval_results(run_dir, variant_id, result.task_summaries)
|
|
643
|
+
if eval_results:
|
|
644
|
+
task_dicts = [eval_result_to_task_dict(er) for er in eval_results]
|
|
645
|
+
|
|
646
|
+
# Generation Metrics
|
|
647
|
+
if any(d.get("iterations") for d in task_dicts):
|
|
648
|
+
lines.extend(["", ""])
|
|
649
|
+
lines.extend(ReportGenerator._generate_generation_metrics_section(task_dicts))
|
|
650
|
+
|
|
651
|
+
# Token Usage
|
|
652
|
+
token_lines = ReportGenerator._generate_token_usage_section(task_dicts)
|
|
653
|
+
if token_lines:
|
|
654
|
+
lines.extend(["", ""])
|
|
655
|
+
lines.extend(token_lines)
|
|
656
|
+
|
|
657
|
+
# Command Telemetry (aggregate from variant dir)
|
|
658
|
+
variant_dir = run_dir / variant_id
|
|
659
|
+
aggregated_stats = ReportGenerator._aggregate_command_statistics(variant_dir)
|
|
660
|
+
if aggregated_stats and aggregated_stats.total_commands > 0:
|
|
661
|
+
lines.extend(["", ""])
|
|
662
|
+
lines.extend(ReportGenerator._generate_command_statistics_section(aggregated_stats))
|
|
663
|
+
|
|
664
|
+
# Agent Settings (from first task with data)
|
|
665
|
+
settings_source, is_sdk = resolve_agent_settings(task_dicts)
|
|
666
|
+
if settings_source:
|
|
667
|
+
lines.append("")
|
|
668
|
+
lines.extend(ReportGenerator._generate_agent_settings_section(settings_source, is_sdk))
|
|
669
|
+
|
|
670
|
+
# Installed Tools
|
|
671
|
+
installed_tools_lines = ReportGenerator._generate_installed_tools_section(task_dicts)
|
|
672
|
+
if installed_tools_lines:
|
|
673
|
+
lines.extend([""])
|
|
674
|
+
lines.extend(installed_tools_lines)
|
|
675
|
+
|
|
676
|
+
# Environment (from first result with data)
|
|
677
|
+
for er in eval_results:
|
|
678
|
+
if er.environment_info:
|
|
679
|
+
env = {k: v for k, v in er.environment_info.items() if k != "installed_tools"}
|
|
680
|
+
if env:
|
|
681
|
+
lines.extend(["", "## Environment", ""])
|
|
682
|
+
for key, value in env.items():
|
|
683
|
+
lines.append(f"- **{key}**: {value}")
|
|
684
|
+
break
|
|
685
|
+
|
|
686
|
+
return "\n".join(lines)
|
|
687
|
+
|
|
688
|
+
@staticmethod
|
|
689
|
+
def write_reports(
|
|
690
|
+
result: ExperimentResult,
|
|
691
|
+
run_dir: Path,
|
|
692
|
+
experiment: ExperimentDefinition | None = None,
|
|
693
|
+
) -> None:
|
|
694
|
+
"""Write all experiment reports to disk.
|
|
695
|
+
|
|
696
|
+
Creates:
|
|
697
|
+
- <run_dir>/experiment.md (cross-variant comparison)
|
|
698
|
+
- <run_dir>/experiment.json (full ExperimentResult)
|
|
699
|
+
- <run_dir>/<variant_id>/variant.md (per-variant aggregate)
|
|
700
|
+
- <run_dir>/<variant_id>/variant.json
|
|
701
|
+
|
|
702
|
+
Args:
|
|
703
|
+
result: Complete experiment result.
|
|
704
|
+
run_dir: Top-level run directory.
|
|
705
|
+
experiment: Optional experiment definition (enables prompt config in reports).
|
|
706
|
+
"""
|
|
707
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
708
|
+
|
|
709
|
+
# Experiment-level reports at run root
|
|
710
|
+
exp_report = ExperimentReportGenerator.generate_experiment_report(result, experiment=experiment)
|
|
711
|
+
(run_dir / "experiment.md").write_text(exp_report, encoding="utf-8")
|
|
712
|
+
(run_dir / "experiment.json").write_text(result.model_dump_json(indent=2), encoding="utf-8")
|
|
713
|
+
|
|
714
|
+
# Per-variant reports
|
|
715
|
+
for vid in result.variant_ids:
|
|
716
|
+
variant_dir = run_dir / vid
|
|
717
|
+
variant_dir.mkdir(parents=True, exist_ok=True)
|
|
718
|
+
|
|
719
|
+
agg = result.variant_aggregates.get(vid)
|
|
720
|
+
if agg:
|
|
721
|
+
variant_report = ExperimentReportGenerator.generate_variant_report(vid, result, run_dir=run_dir)
|
|
722
|
+
(variant_dir / "variant.md").write_text(variant_report, encoding="utf-8")
|
|
723
|
+
(variant_dir / "variant.json").write_text(agg.model_dump_json(indent=2), encoding="utf-8")
|
|
724
|
+
|
|
725
|
+
# HTML reports — each write is wrapped by ``safe_write`` so a render
|
|
726
|
+
# bug in one report cannot mask the run outcome.
|
|
727
|
+
from .reports_html import write_experiment_html, write_variant_html
|
|
728
|
+
|
|
729
|
+
# Build per-variant task link tables from task_summaries. Every
|
|
730
|
+
# variant_id in task_summaries is guaranteed to appear in
|
|
731
|
+
# ``result.variant_ids`` (the aggregator constructs them from the same
|
|
732
|
+
# source), so we pre-seed the dict with all known variants and extend.
|
|
733
|
+
task_links_by_variant: dict[str, list[tuple[str, str, float | None, str]]] = {
|
|
734
|
+
vid: [] for vid in result.variant_ids
|
|
735
|
+
}
|
|
736
|
+
for summary in result.task_summaries:
|
|
737
|
+
for vr in summary.variant_results:
|
|
738
|
+
rel_link = f"{vr.task_id}/{replicate_subdir_name(vr.replicate_index)}/task.html"
|
|
739
|
+
task_links_by_variant[vr.variant_id].append(
|
|
740
|
+
(vr.task_id, rel_link, vr.weighted_score, vr.final_status.value)
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
for vid in result.variant_ids:
|
|
744
|
+
agg = result.variant_aggregates.get(vid)
|
|
745
|
+
if agg is None:
|
|
746
|
+
continue
|
|
747
|
+
write_variant_html(
|
|
748
|
+
vid,
|
|
749
|
+
agg,
|
|
750
|
+
task_links_by_variant.get(vid, []),
|
|
751
|
+
run_dir / vid / "variant.html",
|
|
752
|
+
result=result,
|
|
753
|
+
run_dir=run_dir,
|
|
754
|
+
)
|
|
755
|
+
|
|
756
|
+
write_experiment_html(
|
|
757
|
+
result,
|
|
758
|
+
experiment,
|
|
759
|
+
[(v, f"{v}/variant.html") for v in result.variant_ids],
|
|
760
|
+
run_dir / "experiment.html",
|
|
761
|
+
)
|