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
coder_eval/reports.py ADDED
@@ -0,0 +1,1066 @@
1
+ """Report generation and formatting for evaluation runs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from collections import defaultdict
7
+ from collections.abc import Callable, Iterable, Sequence
8
+ from pathlib import Path, PurePosixPath
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ from .models import (
12
+ CriterionAggregate,
13
+ CriterionStats,
14
+ FailedRowSummary,
15
+ SuiteRollup,
16
+ TaskResult,
17
+ ThresholdCheck,
18
+ )
19
+ from .path_utils import build_task_run_dir
20
+
21
+
22
+ if TYPE_CHECKING:
23
+ from .models import CommandStatistics, RunSummary
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ # Cap on how many failed rows are carried in suite.json / rendered in suite.md.
28
+ _FAILED_SAMPLE_LIMIT = 20
29
+ # Cap on how many criterion failure reasons we record per failed row.
30
+ _FAILURE_REASONS_PER_ROW = 3
31
+ # Cap on each failure-reason string so suite.md stays readable.
32
+ _FAILURE_REASON_MAX_LEN = 240
33
+
34
+
35
+ SYSTEM_PROMPT_PREVIEW_CHARS = 200
36
+ SLOW_PARAMS_PREVIEW_CHARS = 50
37
+
38
+
39
+ def resolve_agent_settings(task_dicts: list[dict[str, Any]]) -> tuple[dict[str, Any] | None, bool]:
40
+ """Pick the best agent settings source from a list of task result dicts.
41
+
42
+ Prefers ``sdk_options`` (full SDK dump) over ``agent_config``.
43
+
44
+ Returns:
45
+ Tuple of (settings_dict_or_None, is_sdk_options).
46
+ """
47
+ sdk_opts_list = [t["sdk_options"] for t in task_dicts if t.get("sdk_options")]
48
+ if sdk_opts_list:
49
+ return sdk_opts_list[0], True
50
+ agent_configs = [t["agent_config"] for t in task_dicts if t.get("agent_config")]
51
+ if agent_configs:
52
+ return agent_configs[0], False
53
+ return None, False
54
+
55
+
56
+ def collect_agent_settings_rows(settings_source: dict[str, Any], is_sdk: bool) -> list[tuple[str, str]]:
57
+ """Extract ordered label/value pairs from an agent settings dict.
58
+
59
+ Shared by markdown and HTML reporters so field ordering, defaulting,
60
+ and truncation stay consistent between the two formats.
61
+ """
62
+ rows: list[tuple[str, str]] = [
63
+ ("Permission Mode", str(settings_source.get("permission_mode", "N/A"))),
64
+ ]
65
+ tools = settings_source.get("allowed_tools")
66
+ rows.append(("Allowed Tools", ", ".join(tools) if tools else "(all)"))
67
+ model = settings_source.get("model")
68
+ if model:
69
+ rows.append(("Model", str(model)))
70
+
71
+ if is_sdk:
72
+ for key, label in (
73
+ ("max_turns", "Max Turns"),
74
+ ("max_budget_usd", "Max Budget (USD)"),
75
+ ("thinking", "Thinking"),
76
+ ("effort", "Effort"),
77
+ ):
78
+ if settings_source.get(key) is not None:
79
+ rows.append((label, str(settings_source[key])))
80
+ mcp = settings_source.get("mcp_servers")
81
+ if mcp:
82
+ rows.append(("MCP Servers", ", ".join(mcp.keys()) if isinstance(mcp, dict) else str(mcp)))
83
+ betas = settings_source.get("betas")
84
+ if betas:
85
+ rows.append(("Betas", ", ".join(betas)))
86
+ if settings_source.get("system_prompt") is not None:
87
+ prompt_str = str(settings_source["system_prompt"]).replace("\n", " ")
88
+ if len(prompt_str) > SYSTEM_PROMPT_PREVIEW_CHARS:
89
+ prompt_str = prompt_str[:SYSTEM_PROMPT_PREVIEW_CHARS] + "..."
90
+ rows.append(("System Prompt", prompt_str))
91
+
92
+ plugins = settings_source.get("plugins")
93
+ if isinstance(plugins, list):
94
+ if plugins:
95
+ paths = [p.get("path", "unknown") if isinstance(p, dict) else str(p) for p in plugins]
96
+ rows.append(("Plugins", ", ".join(paths)))
97
+ else:
98
+ rows.append(("Plugins", "(none)"))
99
+ return rows
100
+
101
+
102
+ def group_consecutive_by_iteration[T](
103
+ items: Iterable[T],
104
+ iteration_of: Callable[[T], int | None],
105
+ ) -> list[list[T]]:
106
+ """Group consecutive items sharing the same iteration value into runs (input order preserved)."""
107
+ groups: list[list[T]] = []
108
+ last_iter: int | None = None
109
+ for item in items:
110
+ it = iteration_of(item)
111
+ if groups and it == last_iter:
112
+ groups[-1].append(item)
113
+ else:
114
+ groups.append([item])
115
+ last_iter = it
116
+ return groups
117
+
118
+
119
+ def count_partials_by_outcome[T](
120
+ groups: Iterable[Sequence[T]],
121
+ crashed_of: Callable[[T], bool],
122
+ ) -> tuple[int, int, int]:
123
+ """Return ``(total, recovered, terminal)`` partial counts; recovered = group has a non-crashed turn."""
124
+ total = recovered = terminal = 0
125
+ for group in groups:
126
+ partials = sum(1 for item in group if crashed_of(item))
127
+ if not partials:
128
+ continue
129
+ total += partials
130
+ if any(not crashed_of(item) for item in group):
131
+ recovered += partials
132
+ else:
133
+ terminal += partials
134
+ return total, recovered, terminal
135
+
136
+
137
+ def _count_crashed_partials(task_results: list[dict[str, Any]]) -> tuple[int, int, int]:
138
+ """Run-wide ``(total, recovered, terminal)`` over the markdown rollup's dict shape."""
139
+
140
+ def _iteration_of(t: dict[str, Any]) -> int | None:
141
+ # Coerce to int so a serialized "1" doesn't fragment from a sibling int 1.
142
+ raw = t.get("iteration")
143
+ if raw is None:
144
+ return None
145
+ try:
146
+ return int(raw)
147
+ except (TypeError, ValueError):
148
+ return None
149
+
150
+ total = recovered = terminal = 0
151
+ for task in task_results:
152
+ turns = task.get("iterations") or []
153
+ groups = group_consecutive_by_iteration(turns, _iteration_of)
154
+ t_count, r_count, term_count = count_partials_by_outcome(groups, lambda t: bool(t.get("crashed")))
155
+ total += t_count
156
+ recovered += r_count
157
+ terminal += term_count
158
+ return total, recovered, terminal
159
+
160
+
161
+ class ReportGenerator:
162
+ """Generates reports from evaluation results."""
163
+
164
+ @staticmethod
165
+ def _generate_command_statistics_section(stats: CommandStatistics) -> list[str]:
166
+ """Generate markdown section for command statistics.
167
+
168
+ Args:
169
+ stats: CommandStatistics object with aggregated metrics
170
+
171
+ Returns:
172
+ List of markdown lines
173
+ """
174
+ lines = [
175
+ "## Command Telemetry",
176
+ "",
177
+ f"**Total Commands**: {stats.total_commands}",
178
+ ]
179
+
180
+ if stats.total_commands > 0:
181
+ success_rate = (stats.successful_commands / stats.total_commands * 100) if stats.total_commands > 0 else 0
182
+ lines.append(f"**Success Rate**: {stats.successful_commands}/{stats.total_commands} ({success_rate:.1f}%)")
183
+
184
+ if stats.commands_by_tool:
185
+ lines.extend(["", "### Commands by Tool", "", "| Tool | Count | % |", "|------|-------|---|"])
186
+
187
+ total = stats.total_commands
188
+ for tool, count in sorted(stats.commands_by_tool.items(), key=lambda x: x[1], reverse=True):
189
+ pct = count / total * 100 if total > 0 else 0
190
+ lines.append(f"| {tool} | {count} | {pct:.1f}% |")
191
+
192
+ if stats.avg_command_time_ms and stats.avg_command_time_ms > 0:
193
+ lines.extend(
194
+ [
195
+ "",
196
+ "### Performance",
197
+ "",
198
+ f"- **Average Command Time**: {stats.avg_command_time_ms:.1f}ms",
199
+ f"- **Total Command Time**: {stats.total_command_time_ms / 1000:.2f}s",
200
+ ]
201
+ )
202
+
203
+ if stats.slowest_commands:
204
+ lines.extend(
205
+ ["", "### Slowest Commands", "", "| Tool | Duration | Parameters |", "|------|----------|------------|"]
206
+ )
207
+ for cmd in stats.slowest_commands:
208
+ params_str = str(cmd.parameters)[:50]
209
+ if len(str(cmd.parameters)) > 50:
210
+ params_str += "..."
211
+ lines.append(f"| {cmd.tool} | {cmd.duration_ms:.0f}ms | {params_str} |")
212
+
213
+ if stats.most_common_sequence:
214
+ lines.extend(["", f"**Most Common Pattern**: `{stats.most_common_sequence}`"])
215
+
216
+ # Skill tool usage callout — useful for skill-impact experiments
217
+ if stats.commands_by_tool:
218
+ skill_count = stats.commands_by_tool.get("Skill", 0)
219
+ if skill_count > 0:
220
+ lines.extend(["", f"**Skill Tool Invoked**: {skill_count} time(s)"])
221
+
222
+ return lines
223
+
224
+ @staticmethod
225
+ def _generate_generation_metrics_section(task_results: list[dict[str, Any]]) -> list[str]:
226
+ """Generate Generation Metrics section showing per-task latency and iteration breakdown.
227
+
228
+ Args:
229
+ task_results: List of task result dictionaries from RunSummary
230
+
231
+ Returns:
232
+ List of markdown lines
233
+ """
234
+ lines = [
235
+ "## Generation Metrics",
236
+ "",
237
+ "| Task ID | Total Latency | Turns | Asst Turns | Avg Turn Latency |",
238
+ "|---------|---------------|-------|------------|------------------|",
239
+ ]
240
+
241
+ for task in task_results:
242
+ task_id = task["task_id"]
243
+ total_latency = f"{task['duration']:.1f}s"
244
+ turns = task.get("iterations", [])
245
+ num_turns = len(turns)
246
+
247
+ asst_turns = sum(t.get("assistant_turn_count", 0) for t in turns)
248
+
249
+ if turns:
250
+ avg_turn = sum(t["duration_seconds"] for t in turns) / len(turns)
251
+ avg_turn_str = f"{avg_turn:.1f}s"
252
+ else:
253
+ avg_turn_str = "N/A"
254
+
255
+ lines.append(f"| {task_id} | {total_latency} | {num_turns} | {asst_turns} | {avg_turn_str} |")
256
+
257
+ return lines
258
+
259
+ @staticmethod
260
+ def _report_header_lines(summary: RunSummary) -> list[str]:
261
+ """Title + Run ID / Date / Duration + the Model(s) line.
262
+
263
+ First block — returns the title with no leading blank (the caller prepends
264
+ the blanks between sections).
265
+ """
266
+ # Collect unique models across all tasks
267
+ models = sorted({t["model_used"] for t in summary.task_results if t.get("model_used")})
268
+
269
+ lines = [
270
+ "# Evaluation Run Report",
271
+ "",
272
+ f"**Run ID**: `{summary.run_id}`",
273
+ f"**Date**: {summary.start_time.strftime('%Y-%m-%d %H:%M:%S')}",
274
+ f"**Duration**: {summary.total_duration_seconds:.2f}s",
275
+ ]
276
+
277
+ if len(models) == 1:
278
+ lines.append(f"**Model**: `{models[0]}`")
279
+ elif len(models) > 1:
280
+ lines.append(f"**Models**: {', '.join(f'`{m}`' for m in models)}")
281
+ return lines
282
+
283
+ @staticmethod
284
+ def _summary_section_lines(summary: RunSummary) -> list[str]:
285
+ """The ``## Summary`` block: totals, success rate, and the P0 metrics.
286
+
287
+ Returns a ``## Header``-led block with no leading blank (caller prepends it).
288
+ """
289
+ evaluable = summary.tasks_run - summary.tasks_error
290
+ success_rate = (summary.tasks_succeeded / evaluable * 100) if evaluable > 0 else 0
291
+
292
+ failed_line = f"- **Failed**: {summary.tasks_failed}"
293
+ if summary.tasks_token_budget_exceeded or summary.tasks_cost_budget_exceeded:
294
+ failed_line += (
295
+ f" (incl. {summary.tasks_token_budget_exceeded} token budget, "
296
+ f"{summary.tasks_cost_budget_exceeded} cost budget exceeded)"
297
+ )
298
+
299
+ lines = [
300
+ "## Summary",
301
+ "",
302
+ f"- **Total Tasks**: {summary.tasks_run}",
303
+ f"- **Succeeded**: {summary.tasks_succeeded}",
304
+ failed_line,
305
+ f"- **Errors**: {summary.tasks_error}",
306
+ f"- **Success Rate**: {success_rate:.1f}%",
307
+ ]
308
+
309
+ # Aggregate P0 metrics
310
+ scores = [t["weighted_score"] for t in summary.task_results if t.get("weighted_score") is not None]
311
+ durations = [t["duration"] for t in summary.task_results if t["duration"] > 0]
312
+ similarities = [
313
+ t["reference_similarity"] for t in summary.task_results if t.get("reference_similarity") is not None
314
+ ]
315
+
316
+ if scores:
317
+ lines.append(f"- **Avg Reliability Score**: {sum(scores) / len(scores):.3f}")
318
+ if durations:
319
+ lines.append(f"- **Avg Generation Latency**: {sum(durations) / len(durations):.1f}s")
320
+
321
+ total_asst_turns = sum(
322
+ sum(t.get("assistant_turn_count", 0) for t in task.get("iterations", [])) for task in summary.task_results
323
+ )
324
+ if total_asst_turns > 0:
325
+ lines.append(f"- **Total Assistant Turns**: {total_asst_turns}")
326
+
327
+ crashed_total, recovered_partials, terminal_partials = _count_crashed_partials(summary.task_results)
328
+ if crashed_total > 0:
329
+ lines.append(
330
+ f"- **Crashed Partials**: {crashed_total} "
331
+ + f"({recovered_partials} recovered, {terminal_partials} terminal)"
332
+ )
333
+
334
+ if similarities:
335
+ lines.append(f"- **Avg Ground Truth Similarity**: {sum(similarities) / len(similarities):.3f}")
336
+
337
+ return lines
338
+
339
+ @staticmethod
340
+ def _task_details_lines(summary: RunSummary) -> list[str]:
341
+ """The ``## Task Details`` dynamic-column table.
342
+
343
+ The ``has_*`` column flags are computed over ALL task_results, then every row
344
+ renders exactly the columns those flags enable. Returns a ``## Header``-led
345
+ block with no leading blank (caller prepends it).
346
+ """
347
+ has_similarity = any(t.get("reference_similarity") is not None for t in summary.task_results)
348
+ has_model = any(t.get("model_used") for t in summary.task_results)
349
+ has_tags = any(t.get("tags") for t in summary.task_results)
350
+ has_cmds_efficiency = any(t.get("commands_efficiency") is not None for t in summary.task_results)
351
+
352
+ header = "| Task ID | Status | Reliability Score | Latency |"
353
+ separator = "|---------|--------|-------------------|---------|"
354
+ if has_model:
355
+ header += " Model |"
356
+ separator += "-------|"
357
+ if has_tags:
358
+ header += " Tags |"
359
+ separator += "------|"
360
+ if has_similarity:
361
+ header += " Similarity |"
362
+ separator += "------------|"
363
+ if has_cmds_efficiency:
364
+ header += " Cmd Efficiency |"
365
+ separator += "----------------|"
366
+
367
+ lines = ["## Task Details", "", header, separator]
368
+
369
+ for task_result in summary.task_results:
370
+ weighted_score = task_result.get("weighted_score")
371
+ score_str = f"{weighted_score:.3f}" if weighted_score is not None else "N/A"
372
+ duration = f"{task_result['duration']:.1f}s"
373
+
374
+ row = f"| {task_result['task_id']} | {task_result['status']} | {score_str} | {duration} |"
375
+ if has_model:
376
+ model = task_result.get("model_used") or "N/A"
377
+ row += f" {model} |"
378
+ if has_tags:
379
+ tags = task_result.get("tags", [])
380
+ tags_str = ", ".join(tags) if tags else ""
381
+ row += f" {tags_str} |"
382
+ if has_similarity:
383
+ sim = task_result.get("reference_similarity")
384
+ sim_str = f"{sim:.3f}" if sim is not None else "N/A"
385
+ row += f" {sim_str} |"
386
+ if has_cmds_efficiency:
387
+ eff = task_result.get("commands_efficiency")
388
+ eff_str = f"{eff * 100:.1f}%" if eff is not None else "N/A"
389
+ expected = task_result.get("expected_commands")
390
+ actual = task_result.get("actual_commands")
391
+ if expected is not None and actual is not None:
392
+ eff_str += f" ({expected}/{actual})"
393
+ row += f" {eff_str} |"
394
+ lines.append(row)
395
+
396
+ return lines
397
+
398
+ @staticmethod
399
+ def _runtime_notes_lines(summary: RunSummary) -> list[str]:
400
+ """The ``## Run-time Notes`` blockquotes (max_turns exhaustion + expected_turns
401
+ overage). Returns ``[]`` when there are no notes so the caller adds nothing —
402
+ preserving the "only render the section when notes exist" behavior.
403
+ """
404
+ # Surface per-task signals as plain blockquote one-liners. Same surface for
405
+ # both so the Markdown report has parity with the HTML report's badges.
406
+ notes: list[str] = []
407
+ for t in summary.task_results:
408
+ task_id = t.get("task_id", "?")
409
+ if t.get("max_turns_exhausted"):
410
+ notes.append(f"> **WARNING:** [{task_id}] max_turns exhausted")
411
+ overage_field = t.get("expected_turns_overage")
412
+ if (
413
+ isinstance(overage_field, (list, tuple))
414
+ and len(overage_field) == 2
415
+ and all(isinstance(x, int) for x in overage_field)
416
+ ):
417
+ actual, expected = overage_field
418
+ notes.append(
419
+ f"> **WARNING:** [{task_id}] expected_turns exceeded: {actual}/{expected} (cumulative SDK turns)"
420
+ )
421
+ if not notes:
422
+ return []
423
+ return ["## Run-time Notes", "", *notes]
424
+
425
+ @staticmethod
426
+ def _environment_lines(summary: RunSummary) -> list[str]:
427
+ """The ``## Environment`` block. Returns a ``## Header``-led block with no leading
428
+ blank (caller prepends it)."""
429
+ lines = ["## Environment", ""]
430
+ for key, value in summary.environment_info.items():
431
+ lines.append(f"- **{key}**: {value}")
432
+ return lines
433
+
434
+ @staticmethod
435
+ def generate_markdown(summary: RunSummary, run_dir: Path | None = None) -> str:
436
+ """Generate markdown report from run summary.
437
+
438
+ Args:
439
+ summary: RunSummary object containing evaluation results
440
+ run_dir: Optional path to run directory to load command statistics
441
+
442
+ Returns:
443
+ Markdown-formatted report string
444
+ """
445
+ lines = ReportGenerator._report_header_lines(summary)
446
+
447
+ lines.append("")
448
+ lines.extend(ReportGenerator._summary_section_lines(summary))
449
+
450
+ lines.append("")
451
+ lines.extend(ReportGenerator._task_details_lines(summary))
452
+
453
+ notes_lines = ReportGenerator._runtime_notes_lines(summary)
454
+ if notes_lines:
455
+ lines.append("")
456
+ lines.extend(notes_lines)
457
+
458
+ # Generation Metrics section
459
+ if any(t.get("iterations") for t in summary.task_results):
460
+ lines.extend(["", ""])
461
+ lines.extend(ReportGenerator._generate_generation_metrics_section(summary.task_results))
462
+
463
+ # Token Usage section
464
+ token_lines = ReportGenerator._generate_token_usage_section(summary.task_results)
465
+ if token_lines:
466
+ lines.extend(["", ""])
467
+ lines.extend(token_lines)
468
+
469
+ # Add aggregated command statistics if run_dir is provided
470
+ if run_dir:
471
+ aggregated_stats = ReportGenerator._aggregate_command_statistics(run_dir)
472
+ if aggregated_stats and aggregated_stats.total_commands > 0:
473
+ lines.extend(["", ""])
474
+ lines.extend(ReportGenerator._generate_command_statistics_section(aggregated_stats))
475
+
476
+ # Agent Settings section — prefer sdk_options (full SDK dump), fall back to agent_config
477
+ settings_source, is_sdk = resolve_agent_settings(summary.task_results)
478
+ if settings_source:
479
+ lines.append("")
480
+ lines.extend(ReportGenerator._generate_agent_settings_section(settings_source, is_sdk))
481
+
482
+ # Installed Tools section (per-task tool versions from sandbox npm packages etc.)
483
+ installed_tools_lines = ReportGenerator._generate_installed_tools_section(summary.task_results)
484
+ if installed_tools_lines:
485
+ lines.extend([""])
486
+ lines.extend(installed_tools_lines)
487
+
488
+ lines.append("")
489
+ lines.extend(ReportGenerator._environment_lines(summary))
490
+
491
+ return "\n".join(lines)
492
+
493
+ @staticmethod
494
+ def _generate_agent_settings_section(settings_source: dict[str, Any], is_sdk: bool) -> list[str]:
495
+ """Generate Agent Settings markdown lines from a settings dict."""
496
+ lines = ["## Agent Settings", ""]
497
+ for label, value in collect_agent_settings_rows(settings_source, is_sdk):
498
+ lines.append(f"- **{label}**: {value}")
499
+ return lines
500
+
501
+ @staticmethod
502
+ def _generate_token_usage_section(task_results: list[dict[str, Any]]) -> list[str]:
503
+ """Generate Token Usage section for the report.
504
+
505
+ Args:
506
+ task_results: List of task result dictionaries from RunSummary
507
+
508
+ Returns:
509
+ List of markdown lines (empty if no token data available)
510
+ """
511
+ tasks_with_tokens = [t for t in task_results if t.get("total_tokens") is not None]
512
+ if not tasks_with_tokens:
513
+ return []
514
+
515
+ lines = ["## Token Usage", ""]
516
+
517
+ total_input = sum(t.get("input_tokens") or 0 for t in tasks_with_tokens)
518
+ total_output = sum(t.get("output_tokens") or 0 for t in tasks_with_tokens)
519
+ total_cache_write = sum(t.get("cache_creation_input_tokens") or 0 for t in tasks_with_tokens)
520
+ total_cache_read = sum(t.get("cache_read_input_tokens") or 0 for t in tasks_with_tokens)
521
+ total_tokens = sum(t["total_tokens"] for t in tasks_with_tokens)
522
+ costs = [t["total_cost_usd"] for t in tasks_with_tokens if t.get("total_cost_usd") is not None]
523
+ total_cost = sum(costs) if costs else None
524
+
525
+ lines.append(f"**Total Tokens**: {total_tokens:,} (input: {total_input:,}, output: {total_output:,})")
526
+ if total_cache_write > 0 or total_cache_read > 0:
527
+ lines.append(f"**Cache Tokens**: write: {total_cache_write:,}, read: {total_cache_read:,}")
528
+ if total_cost is not None:
529
+ lines.append(f"**Total Cost**: ${total_cost:.4f}")
530
+ lines.append(f"**Avg Tokens/Task**: {total_tokens // len(tasks_with_tokens):,}")
531
+ lines.append("")
532
+
533
+ lines.extend(
534
+ [
535
+ "| Task ID | Input (uncached) | Output | Cache Write | Cache Read | Total | Cost |",
536
+ "|---------|------------------|--------|-------------|------------|-------|------|",
537
+ ]
538
+ )
539
+
540
+ for t in tasks_with_tokens:
541
+ input_tok = t.get("input_tokens") or 0
542
+ output_tok = t.get("output_tokens") or 0
543
+ cache_write = t.get("cache_creation_input_tokens") or 0
544
+ cache_read = t.get("cache_read_input_tokens") or 0
545
+ tokens = t.get("total_tokens", 0)
546
+ cost = t.get("total_cost_usd")
547
+ cost_str = f"${cost:.4f}" if cost is not None else "N/A"
548
+ row = (
549
+ f"| {t['task_id']} | {input_tok:,} | {output_tok:,} "
550
+ f"| {cache_write:,} | {cache_read:,} | {tokens:,} | {cost_str} |"
551
+ )
552
+ lines.append(row)
553
+
554
+ return lines
555
+
556
+ @staticmethod
557
+ def _generate_installed_tools_section(task_results: list[dict[str, Any]]) -> list[str]:
558
+ """Generate Installed Tools section showing per-task tool versions.
559
+
560
+ Args:
561
+ task_results: List of task result dictionaries from RunSummary
562
+
563
+ Returns:
564
+ List of markdown lines (empty if no tasks have installed tools)
565
+ """
566
+ tasks_with_tools = [t for t in task_results if t.get("installed_tools")]
567
+ if not tasks_with_tools:
568
+ return []
569
+
570
+ lines = [
571
+ "## Installed Tools",
572
+ "",
573
+ "| Task ID | Tool | Version |",
574
+ "|---------|------|---------|",
575
+ ]
576
+
577
+ for task in tasks_with_tools:
578
+ task_id = task["task_id"]
579
+ for tool_name, version in sorted(task["installed_tools"].items()):
580
+ lines.append(f"| {task_id} | {tool_name} | {version} |")
581
+
582
+ return lines
583
+
584
+ @staticmethod
585
+ def _aggregate_command_statistics(run_dir: Path) -> CommandStatistics | None:
586
+ """Aggregate command statistics from all task reports in a run.
587
+
588
+ Args:
589
+ run_dir: Path to run directory containing task subdirectories
590
+
591
+ Returns:
592
+ Aggregated CommandStatistics or None if no stats available
593
+ """
594
+ from .analysis import calculate_command_statistics
595
+ from .models import EvaluationResult, TurnRecord
596
+
597
+ all_turns: list[TurnRecord] = []
598
+
599
+ # Find all task.json files recursively to handle both flat and nested (experiment) layouts
600
+ for report_path in run_dir.rglob("task.json"):
601
+ if "artifacts" in report_path.parts or ".git" in report_path.parts:
602
+ continue
603
+ try:
604
+ result = EvaluationResult.model_validate_json(report_path.read_text(encoding="utf-8"))
605
+ all_turns.extend(result.iterations)
606
+ except Exception:
607
+ logger.warning("Failed to load report %s for command statistics", report_path, exc_info=True)
608
+
609
+ if not all_turns:
610
+ return None
611
+
612
+ return calculate_command_statistics(all_turns)
613
+
614
+ @staticmethod
615
+ def load_from_run_dir(run_dir: Path) -> tuple[str, Path]:
616
+ """Load report from run directory.
617
+
618
+ Tries to load pre-generated markdown report first, then falls back
619
+ to regenerating from JSON summary if needed.
620
+
621
+ Args:
622
+ run_dir: Path to run directory containing report files
623
+
624
+ Returns:
625
+ Tuple of (report_content, source_path)
626
+
627
+ Raises:
628
+ FileNotFoundError: If no report files exist in the directory
629
+ """
630
+ # Resolve symlink if necessary (e.g., runs/latest)
631
+ if run_dir.is_symlink():
632
+ run_dir = run_dir.resolve()
633
+
634
+ # Check for reports in order of preference:
635
+ # 1. experiment.md/json (written by ExperimentReportGenerator)
636
+ # 2. run.md/json (written by batch-level _generate_run_summary)
637
+ for md_name, json_name in [("experiment.md", "experiment.json"), ("run.md", "run.json")]:
638
+ report_md_path = run_dir / md_name
639
+ summary_json_path = run_dir / json_name
640
+
641
+ if report_md_path.exists():
642
+ return report_md_path.read_text(encoding="utf-8"), report_md_path
643
+
644
+ if summary_json_path.exists():
645
+ from .models import RunSummary
646
+
647
+ summary = RunSummary.model_validate_json(summary_json_path.read_text(encoding="utf-8"))
648
+ report_md = ReportGenerator.generate_markdown(summary, run_dir=run_dir)
649
+ return report_md, summary_json_path
650
+
651
+ raise FileNotFoundError(
652
+ f"No report found in {run_dir}. Expected experiment.md, experiment.json, run.md, or run.json"
653
+ )
654
+
655
+
656
+ def _evaluate_thresholds(
657
+ aggregate: CriterionAggregate, suite_thresholds: dict[str, float] | None
658
+ ) -> CriterionAggregate:
659
+ """Return a new CriterionAggregate with threshold_checks + passed filled in.
660
+
661
+ When a threshold references a metric the aggregate didn't produce, the check
662
+ records actual_value=None and fails.
663
+ """
664
+ if not suite_thresholds:
665
+ return aggregate.model_copy(update={"threshold_checks": [], "passed": True})
666
+
667
+ checks: list[ThresholdCheck] = []
668
+ for metric, min_value in suite_thresholds.items():
669
+ actual = aggregate.metrics.get(metric)
670
+ passed = actual is not None and actual >= min_value
671
+ checks.append(ThresholdCheck(metric=metric, min_value=min_value, actual_value=actual, passed=passed))
672
+
673
+ return aggregate.model_copy(update={"threshold_checks": checks, "passed": all(c.passed for c in checks)})
674
+
675
+
676
+ def _attach_row_accounting(agg: CriterionAggregate, rows_total: int, rows_aggregated: int) -> CriterionAggregate:
677
+ """Record the suite-size denominator on an aggregate and derive completion_rate.
678
+
679
+ ERROR rows carry ``success_criteria_results=[]`` and are silently dropped by
680
+ the per-criterion position slice, shrinking the denominator behind metrics
681
+ like ``recall``. This stamps the visible counts (``rows_total`` /
682
+ ``rows_excluded``) and mirrors a gateable ``completion_rate`` into ``metrics``
683
+ so a ``suite_thresholds: {completion_rate: ...}`` gate can fail when too many
684
+ rows errored. ``rows_aggregated`` is ``len(per_rows)`` — never read back from
685
+ ``metrics["count"]`` (the missing-aggregator stub has no ``count`` key).
686
+ """
687
+ excluded = rows_total - rows_aggregated
688
+ metrics = dict(agg.metrics)
689
+ metrics["completion_rate"] = (rows_aggregated / rows_total) if rows_total else 0.0
690
+ return agg.model_copy(update={"rows_total": rows_total, "rows_excluded": excluded, "metrics": metrics})
691
+
692
+
693
+ def _build_missing_aggregator(
694
+ criterion_type: str, suite_thresholds: dict[str, float], description: str | None = None
695
+ ) -> CriterionAggregate:
696
+ """A stub aggregate used when a criterion declares suite_thresholds but its
697
+ checker doesn't implement ``aggregate()``. Marks every threshold as failed
698
+ with no actual value and records an error."""
699
+ checks = [
700
+ ThresholdCheck(metric=metric, min_value=min_value, actual_value=None, passed=False)
701
+ for metric, min_value in suite_thresholds.items()
702
+ ]
703
+ return CriterionAggregate(
704
+ criterion_type=criterion_type,
705
+ description=description,
706
+ metrics={},
707
+ threshold_checks=checks,
708
+ passed=False,
709
+ details={},
710
+ error="Criterion checker did not produce an aggregate, so thresholds cannot be evaluated.",
711
+ )
712
+
713
+
714
+ def _compute_suite_rollup(
715
+ suite_id: str,
716
+ variant_id: str,
717
+ rows: list[TaskResult],
718
+ run_dir: Path,
719
+ task_criteria: list[Any] | None = None,
720
+ ) -> SuiteRollup:
721
+ """Compute a SuiteRollup from a list of row-level TaskResults in one variant.
722
+
723
+ ``task_criteria`` is the original ``success_criteria`` list from the task
724
+ definition. It drives per-criterion ``aggregate()`` invocation and
725
+ ``suite_thresholds`` evaluation. Pass None when unavailable — per-criterion
726
+ stats still compute but no aggregate/threshold gating happens.
727
+ """
728
+ from .criteria import CriterionRegistry, init_criteria
729
+
730
+ rows_total = len(rows)
731
+ rows_passed = sum(1 for r in rows if r.result.final_status.category == "succeeded")
732
+ rows_failed = sum(1 for r in rows if r.result.final_status.category == "failed")
733
+ rows_error = sum(1 for r in rows if r.result.final_status.category == "error")
734
+
735
+ scored = [r.result.weighted_score for r in rows if r.result.weighted_score is not None]
736
+ average_weighted_score = sum(scored) / len(scored) if scored else None
737
+
738
+ # Per-criterion-type tallies (scores + errors) for the type-level summary.
739
+ by_type: dict[str, list[float]] = defaultdict(list)
740
+ errors_by_type: dict[str, int] = defaultdict(int)
741
+ for row in rows:
742
+ for cr in row.result.success_criteria_results:
743
+ by_type[cr.criterion_type].append(cr.score)
744
+ if cr.error is not None:
745
+ errors_by_type[cr.criterion_type] += 1
746
+
747
+ criterion_stats = [
748
+ CriterionStats(
749
+ criterion_type=ctype,
750
+ rows_evaluated=len(scores),
751
+ average_score=sum(scores) / len(scores) if scores else 0.0,
752
+ error_count=errors_by_type.get(ctype, 0),
753
+ )
754
+ for ctype, scores in sorted(by_type.items())
755
+ ]
756
+
757
+ # Drive each criterion's aggregate() + evaluate suite_thresholds. Per-row
758
+ # results are sliced per criterion INSTANCE by position: SuccessChecker.
759
+ # check_all appends one CriterionResult per criterion in declared order
760
+ # (evaluation/checker.py), so row.success_criteria_results[i] belongs to
761
+ # task_criteria[i]. Aggregating per-instance (not pooled by type) is what
762
+ # lets a task stack many criteria of the SAME type — e.g. activation's
763
+ # per-skill skill_triggered criteria — and get a distinct aggregate
764
+ # (per-skill recall / F1) for each, instead of one type-pooled number
765
+ # repeated once per instance. The aggregate carries the criterion's
766
+ # description so the stacked instances stay distinguishable downstream.
767
+ criterion_aggregates: list[CriterionAggregate] = []
768
+ if task_criteria is not None:
769
+ init_criteria(validate=False)
770
+ for i, criterion in enumerate(task_criteria):
771
+ ctype = criterion.type
772
+ per_rows = [
773
+ row.result.success_criteria_results[i] for row in rows if i < len(row.result.success_criteria_results)
774
+ ]
775
+ suite_thresholds = getattr(criterion, "suite_thresholds", None)
776
+ description = getattr(criterion, "description", None)
777
+ try:
778
+ checker_cls = CriterionRegistry.get_checker(ctype)
779
+ except KeyError:
780
+ logger.warning("No checker registered for criterion type %s; skipping aggregate", ctype)
781
+ continue
782
+ checker = checker_cls()
783
+ aggregate = checker.aggregate(criterion, per_rows)
784
+ if aggregate is None:
785
+ if suite_thresholds:
786
+ # Thresholds declared but nothing produced — fail loudly.
787
+ stub = _build_missing_aggregator(ctype, suite_thresholds, description)
788
+ stub = _attach_row_accounting(stub, rows_total, len(per_rows))
789
+ # completion_rate is now a real value in metrics; refresh the
790
+ # threshold checks so the rendered actual matches it, but keep
791
+ # the aggregate failed because the real metrics are absent.
792
+ stub = _evaluate_thresholds(stub, suite_thresholds).model_copy(update={"passed": False})
793
+ criterion_aggregates.append(stub)
794
+ continue
795
+ aggregate = aggregate.model_copy(update={"description": description})
796
+ aggregate = _attach_row_accounting(aggregate, rows_total, len(per_rows))
797
+ criterion_aggregates.append(_evaluate_thresholds(aggregate, suite_thresholds))
798
+
799
+ suite_passed = all(a.passed for a in criterion_aggregates)
800
+
801
+ # Sample up to K failed/errored rows for error analysis
802
+ failed_samples: list[FailedRowSummary] = []
803
+ for row in rows:
804
+ if row.result.final_status.category == "succeeded":
805
+ continue
806
+ if len(failed_samples) >= _FAILED_SAMPLE_LIMIT:
807
+ break
808
+ reasons: list[str] = []
809
+ for cr in row.result.success_criteria_results:
810
+ if cr.error is not None or cr.score < 1.0:
811
+ reason = cr.error or cr.details or f"{cr.criterion_type}: score={cr.score:.2f}"
812
+ reasons.append(reason[:_FAILURE_REASON_MAX_LEN])
813
+ if len(reasons) >= _FAILURE_REASONS_PER_ROW:
814
+ break
815
+ task_json_path = (
816
+ build_task_run_dir(run_dir, variant_id, row.task_id, replicate_index=row.replicate_index) / "task.json"
817
+ )
818
+ try:
819
+ # Persist as POSIX — this value lands in suite.json and in
820
+ # suite.md markdown links, both of which must be platform-agnostic.
821
+ rel = task_json_path.relative_to(run_dir).as_posix()
822
+ except ValueError:
823
+ rel = task_json_path.as_posix()
824
+ failed_samples.append(
825
+ FailedRowSummary(
826
+ row_id=row.row_id,
827
+ task_id=row.task_id,
828
+ final_status=row.result.final_status,
829
+ weighted_score=row.result.weighted_score,
830
+ failure_reasons=reasons,
831
+ error_message=row.result.error_message,
832
+ task_json_relpath=rel,
833
+ replicate_index=row.replicate_index,
834
+ )
835
+ )
836
+
837
+ return SuiteRollup(
838
+ suite_id=suite_id,
839
+ variant_id=variant_id,
840
+ rows_total=rows_total,
841
+ rows_passed=rows_passed,
842
+ rows_failed=rows_failed,
843
+ rows_error=rows_error,
844
+ pass_rate=rows_passed / rows_total if rows_total else 0.0,
845
+ average_weighted_score=average_weighted_score,
846
+ criterion_stats=criterion_stats,
847
+ failed_samples=failed_samples,
848
+ criterion_aggregates=criterion_aggregates,
849
+ passed=suite_passed,
850
+ )
851
+
852
+
853
+ def _render_suite_markdown(rollup: SuiteRollup) -> str:
854
+ """Render a SuiteRollup as a concise markdown report."""
855
+ lines: list[str] = [
856
+ f"# Suite Rollup: {rollup.suite_id}",
857
+ "",
858
+ f"**Variant**: `{rollup.variant_id}`",
859
+ (
860
+ f"**Rows**: {rollup.rows_total} total — "
861
+ f"{rollup.rows_passed} passed, {rollup.rows_failed} failed, {rollup.rows_error} errored"
862
+ ),
863
+ f"**Pass rate**: {rollup.pass_rate * 100:.1f}%",
864
+ ]
865
+ if rollup.average_weighted_score is not None:
866
+ lines.append(f"**Average weighted score**: {rollup.average_weighted_score:.3f}")
867
+
868
+ if rollup.criterion_stats:
869
+ lines.extend(
870
+ [
871
+ "",
872
+ "## Criterion stats",
873
+ "",
874
+ "| Criterion | Rows | Avg score | Errors |",
875
+ "|---|---|---|---|",
876
+ ]
877
+ )
878
+ for cs in rollup.criterion_stats:
879
+ lines.append(f"| `{cs.criterion_type}` | {cs.rows_evaluated} | {cs.average_score:.3f} | {cs.error_count} |")
880
+
881
+ if rollup.criterion_aggregates:
882
+ for aggregate in rollup.criterion_aggregates:
883
+ lines.extend(_render_criterion_aggregate(aggregate))
884
+ lines.extend(
885
+ [
886
+ "",
887
+ f"**Suite gate**: {'PASSED' if rollup.passed else 'FAILED'}",
888
+ ]
889
+ )
890
+
891
+ if rollup.failed_samples:
892
+ lines.extend(
893
+ [
894
+ "",
895
+ f"## Failed/errored samples (up to {_FAILED_SAMPLE_LIMIT})",
896
+ "",
897
+ ]
898
+ )
899
+ for s in rollup.failed_samples:
900
+ lines.append(f"### `{s.task_id}` — {s.final_status.value}")
901
+ if s.weighted_score is not None:
902
+ lines.append(f"- score: {s.weighted_score:.3f}")
903
+ if s.error_message:
904
+ lines.append(f"- error: {s.error_message[:_FAILURE_REASON_MAX_LEN]}")
905
+ for r in s.failure_reasons:
906
+ lines.append(f"- {r}")
907
+ # Strip the leading variant segment so the link resolves from the
908
+ # suite dir where suite.md lives. PurePosixPath keeps the separator
909
+ # POSIX on Windows too. Fall back to the raw relpath if it isn't
910
+ # prefixed with the variant (e.g. serialized from a legacy shape).
911
+ rel_path = PurePosixPath(s.task_json_relpath)
912
+ try:
913
+ suite_rel: PurePosixPath = rel_path.relative_to(rollup.variant_id)
914
+ except ValueError:
915
+ suite_rel = rel_path
916
+ lines.append(f"- [task.json](./{suite_rel})")
917
+ lines.append("")
918
+
919
+ return "\n".join(lines) + "\n"
920
+
921
+
922
+ def _render_criterion_aggregate(aggregate: CriterionAggregate) -> list[str]:
923
+ """Render one CriterionAggregate as a markdown section.
924
+
925
+ Shape:
926
+ - flat metrics table (always)
927
+ - threshold_checks table (when thresholds were configured)
928
+ - confusion matrix (when details carry 'labels' + 'confusion' — by convention)
929
+ - per-label P/R/F1 table (when details carry 'per_label')
930
+ """
931
+ status = "PASSED" if aggregate.passed else "FAILED"
932
+ lines: list[str] = [
933
+ "",
934
+ f"## Aggregate metrics — `{aggregate.criterion_type}` ({status})",
935
+ "",
936
+ ]
937
+ if aggregate.error:
938
+ lines.append(f"_Error: {aggregate.error}_")
939
+ lines.append("")
940
+
941
+ if aggregate.rows_excluded > 0:
942
+ included = aggregate.rows_total - aggregate.rows_excluded
943
+ lines.append(
944
+ f"_Denominator: {included}/{aggregate.rows_total} rows"
945
+ + f" ({aggregate.rows_excluded} excluded — errored before criteria ran)_"
946
+ )
947
+ lines.append("")
948
+
949
+ if aggregate.metrics:
950
+ lines.extend(["| metric | value |", "|---|---|"])
951
+ for key in sorted(aggregate.metrics):
952
+ lines.append(f"| `{key}` | {aggregate.metrics[key]:.3f} |")
953
+
954
+ if aggregate.threshold_checks:
955
+ lines.extend(
956
+ [
957
+ "",
958
+ "### Thresholds",
959
+ "",
960
+ "| metric | minimum | actual | passed |",
961
+ "|---|---|---|---|",
962
+ ]
963
+ )
964
+ for check in aggregate.threshold_checks:
965
+ actual = f"{check.actual_value:.3f}" if check.actual_value is not None else "—"
966
+ passed = "✓" if check.passed else "✗"
967
+ lines.append(f"| `{check.metric}` | {check.min_value:.3f} | {actual} | {passed} |")
968
+
969
+ per_label = aggregate.details.get("per_label") if aggregate.details else None
970
+ if isinstance(per_label, list) and per_label:
971
+ lines.extend(
972
+ [
973
+ "",
974
+ "### Per-label breakdown",
975
+ "",
976
+ "| label | precision | recall | f1 | support |",
977
+ "|---|---|---|---|---|",
978
+ ]
979
+ )
980
+ for row in per_label:
981
+ lines.append(
982
+ f"| `{row['label']}` | {row['precision']:.3f} | {row['recall']:.3f}"
983
+ + f" | {row['f1']:.3f} | {row['support']} |"
984
+ )
985
+
986
+ labels = aggregate.details.get("labels") if aggregate.details else None
987
+ confusion = aggregate.details.get("confusion") if aggregate.details else None
988
+ if isinstance(labels, list) and isinstance(confusion, list) and confusion:
989
+ lines.extend(
990
+ [
991
+ "",
992
+ "### Confusion matrix",
993
+ "",
994
+ "| expected \\\\ observed | " + " | ".join(f"`{lbl}`" for lbl in labels) + " |",
995
+ "|---" * (len(labels) + 1) + "|",
996
+ ]
997
+ )
998
+ grid: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
999
+ for entry in confusion:
1000
+ grid[entry["expected"]][entry["observed"]] = entry["count"]
1001
+ for expected in labels:
1002
+ cells = [str(grid[expected].get(observed, 0)) for observed in labels]
1003
+ lines.append(f"| `{expected}` | " + " | ".join(cells) + " |")
1004
+
1005
+ return lines
1006
+
1007
+
1008
+ def write_suite_rollups(
1009
+ run_dir: Path,
1010
+ task_results: list[TaskResult],
1011
+ resolved_tasks: list[Any] | None = None,
1012
+ ) -> list[SuiteRollup]:
1013
+ """Write per-suite pass-rate rollups for all dataset-backed tasks in this run.
1014
+
1015
+ Groups results by ``(variant_id, suite_id)`` for rows where ``suite_id`` is
1016
+ set by the dataset expander. Non-dataset tasks are ignored — this is a
1017
+ no-op when no task used ``dataset:``.
1018
+
1019
+ ``resolved_tasks`` carries the resolved TaskDefinitions used to drive
1020
+ across-row ``aggregate()`` and to evaluate each criterion's
1021
+ ``suite_thresholds``. When omitted, aggregates + threshold gating are
1022
+ skipped (per-criterion stats + failed-sample listing still work).
1023
+
1024
+ For each group, writes:
1025
+ ``<run_dir>/<variant_id>/<suite_id>/suite.json``
1026
+ ``<run_dir>/<variant_id>/<suite_id>/suite.md``
1027
+
1028
+ Returns the computed SuiteRollup objects (useful for CLI exit-code logic).
1029
+ """
1030
+ groups: dict[tuple[str, str], list[TaskResult]] = defaultdict(list)
1031
+ for tr in task_results:
1032
+ if tr.suite_id is None:
1033
+ continue
1034
+ groups[(tr.variant_id, tr.suite_id)].append(tr)
1035
+
1036
+ # Map (variant_id, suite_id) -> task_criteria from the first matching resolved task.
1037
+ # Rows in the same suite share identical criteria (expand_dataset copies them).
1038
+ criteria_by_group: dict[tuple[str, str], list[Any]] = {}
1039
+ if resolved_tasks is not None:
1040
+ for rt in resolved_tasks:
1041
+ task = rt.task
1042
+ if task.suite_id is None:
1043
+ continue
1044
+ key = (rt.variant_id, task.suite_id)
1045
+ if key not in criteria_by_group:
1046
+ criteria_by_group[key] = list(task.success_criteria)
1047
+
1048
+ rollups: list[SuiteRollup] = []
1049
+ for (variant_id, suite_id), rows in groups.items():
1050
+ suite_dir = run_dir / variant_id / suite_id
1051
+ suite_dir.mkdir(parents=True, exist_ok=True)
1052
+ task_criteria = criteria_by_group.get((variant_id, suite_id))
1053
+ rollup = _compute_suite_rollup(suite_id, variant_id, rows, run_dir, task_criteria=task_criteria)
1054
+ (suite_dir / "suite.json").write_text(rollup.model_dump_json(indent=2), encoding="utf-8")
1055
+ (suite_dir / "suite.md").write_text(_render_suite_markdown(rollup), encoding="utf-8")
1056
+ rollups.append(rollup)
1057
+ logger.info(
1058
+ "Wrote suite rollup: variant=%s suite=%s pass_rate=%.1f%% (%d/%d) gate=%s",
1059
+ variant_id,
1060
+ suite_id,
1061
+ rollup.pass_rate * 100,
1062
+ rollup.rows_passed,
1063
+ rollup.rows_total,
1064
+ "PASS" if rollup.passed else "FAIL",
1065
+ )
1066
+ return rollups