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.
Files changed (127) hide show
  1. coder_eval/.gitattributes +2 -0
  2. coder_eval/__init__.py +3 -0
  3. coder_eval/agent.py +302 -0
  4. coder_eval/agents/__init__.py +41 -0
  5. coder_eval/agents/_logging.py +89 -0
  6. coder_eval/agents/antigravity_agent.py +811 -0
  7. coder_eval/agents/claude_code_agent.py +1701 -0
  8. coder_eval/agents/codex_agent.py +2055 -0
  9. coder_eval/agents/noop_agent.py +100 -0
  10. coder_eval/agents/registry.py +163 -0
  11. coder_eval/agents/watchdog.py +116 -0
  12. coder_eval/analysis.py +88 -0
  13. coder_eval/cli/__init__.py +87 -0
  14. coder_eval/cli/aggregate_command.py +153 -0
  15. coder_eval/cli/console.py +7 -0
  16. coder_eval/cli/evaluate_command.py +164 -0
  17. coder_eval/cli/plan_command.py +152 -0
  18. coder_eval/cli/report_command.py +121 -0
  19. coder_eval/cli/run_command.py +686 -0
  20. coder_eval/cli/run_helpers.py +137 -0
  21. coder_eval/cli/run_task_internal_command.py +210 -0
  22. coder_eval/cli/utils.py +37 -0
  23. coder_eval/config.py +212 -0
  24. coder_eval/criteria/__init__.py +163 -0
  25. coder_eval/criteria/_classification_aggregate.py +106 -0
  26. coder_eval/criteria/agent_judge.py +425 -0
  27. coder_eval/criteria/base.py +248 -0
  28. coder_eval/criteria/classification_match.py +107 -0
  29. coder_eval/criteria/command_executed.py +181 -0
  30. coder_eval/criteria/commands_efficiency.py +73 -0
  31. coder_eval/criteria/file_check.py +119 -0
  32. coder_eval/criteria/file_contains.py +85 -0
  33. coder_eval/criteria/file_exists.py +45 -0
  34. coder_eval/criteria/file_matches_regex.py +86 -0
  35. coder_eval/criteria/json_check.py +184 -0
  36. coder_eval/criteria/llm_judge.py +260 -0
  37. coder_eval/criteria/reference_comparison.py +130 -0
  38. coder_eval/criteria/run_command.py +220 -0
  39. coder_eval/criteria/skill_triggered.py +111 -0
  40. coder_eval/criteria/uipath_eval.py +223 -0
  41. coder_eval/errors/__init__.py +26 -0
  42. coder_eval/errors/agent.py +26 -0
  43. coder_eval/errors/budget.py +28 -0
  44. coder_eval/errors/categories.py +205 -0
  45. coder_eval/errors/categorization.py +240 -0
  46. coder_eval/errors/executor.py +124 -0
  47. coder_eval/errors/judge.py +12 -0
  48. coder_eval/errors/retry.py +211 -0
  49. coder_eval/errors/timeout.py +63 -0
  50. coder_eval/evaluation/__init__.py +10 -0
  51. coder_eval/evaluation/checker.py +260 -0
  52. coder_eval/evaluation/judge_anthropic.py +55 -0
  53. coder_eval/evaluation/judge_bedrock.py +114 -0
  54. coder_eval/evaluation/judge_context.py +497 -0
  55. coder_eval/evaluation/judge_models.py +53 -0
  56. coder_eval/evaluation/judge_persistence.py +291 -0
  57. coder_eval/evaluation/judge_usage.py +50 -0
  58. coder_eval/evaluation/sub_agent.py +231 -0
  59. coder_eval/evaluation/summaries.py +48 -0
  60. coder_eval/evaluation/verdict_tool.py +213 -0
  61. coder_eval/formatting.py +191 -0
  62. coder_eval/isolation/__init__.py +15 -0
  63. coder_eval/isolation/docker_runner.py +1253 -0
  64. coder_eval/logging_config.py +399 -0
  65. coder_eval/models/__init__.py +337 -0
  66. coder_eval/models/agent_config.py +373 -0
  67. coder_eval/models/container_paths.py +26 -0
  68. coder_eval/models/criteria.py +942 -0
  69. coder_eval/models/enums.py +121 -0
  70. coder_eval/models/experiment.py +314 -0
  71. coder_eval/models/judge.py +90 -0
  72. coder_eval/models/judge_defaults.py +11 -0
  73. coder_eval/models/limits.py +89 -0
  74. coder_eval/models/merge_strategy.py +132 -0
  75. coder_eval/models/mutations.py +95 -0
  76. coder_eval/models/results.py +787 -0
  77. coder_eval/models/routing.py +160 -0
  78. coder_eval/models/sandbox.py +371 -0
  79. coder_eval/models/tasks.py +631 -0
  80. coder_eval/models/telemetry.py +454 -0
  81. coder_eval/models/templates.py +108 -0
  82. coder_eval/orchestration/__init__.py +13 -0
  83. coder_eval/orchestration/batch.py +690 -0
  84. coder_eval/orchestration/config.py +111 -0
  85. coder_eval/orchestration/config_merge.py +431 -0
  86. coder_eval/orchestration/evaluation.py +94 -0
  87. coder_eval/orchestration/experiment.py +837 -0
  88. coder_eval/orchestration/overrides.py +206 -0
  89. coder_eval/orchestration/task_loader.py +437 -0
  90. coder_eval/orchestrator.py +2078 -0
  91. coder_eval/path_utils.py +79 -0
  92. coder_eval/plugins.py +81 -0
  93. coder_eval/pricing.py +169 -0
  94. coder_eval/py.typed +0 -0
  95. coder_eval/reports.py +1066 -0
  96. coder_eval/reports_experiment.py +761 -0
  97. coder_eval/reports_html.py +1659 -0
  98. coder_eval/reports_stats.py +250 -0
  99. coder_eval/resources/__init__.py +137 -0
  100. coder_eval/resources/default_experiment.yaml +85 -0
  101. coder_eval/resources/default_ignore_patterns.yaml +60 -0
  102. coder_eval/resources/tags.yaml +55 -0
  103. coder_eval/sandbox.py +1127 -0
  104. coder_eval/scoring/__init__.py +11 -0
  105. coder_eval/scoring/ast_similarity.py +38 -0
  106. coder_eval/scoring/complexity.py +115 -0
  107. coder_eval/scoring/quality.py +154 -0
  108. coder_eval/scoring/signature_similarity.py +57 -0
  109. coder_eval/scoring/similarity.py +103 -0
  110. coder_eval/scoring/token_similarity.py +44 -0
  111. coder_eval/simulation/__init__.py +27 -0
  112. coder_eval/simulation/termination.py +88 -0
  113. coder_eval/simulation/user_simulator.py +324 -0
  114. coder_eval/streaming/__init__.py +44 -0
  115. coder_eval/streaming/callbacks.py +57 -0
  116. coder_eval/streaming/collector.py +193 -0
  117. coder_eval/streaming/events.py +198 -0
  118. coder_eval/streaming/renderers.py +248 -0
  119. coder_eval/streaming/wire.py +115 -0
  120. coder_eval/telemetry.py +407 -0
  121. coder_eval/utils.py +517 -0
  122. coder_eval-0.8.2.dist-info/METADATA +242 -0
  123. coder_eval-0.8.2.dist-info/RECORD +127 -0
  124. coder_eval-0.8.2.dist-info/WHEEL +4 -0
  125. coder_eval-0.8.2.dist-info/entry_points.txt +5 -0
  126. coder_eval-0.8.2.dist-info/licenses/LICENSE +201 -0
  127. 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
+ )