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,1659 @@
1
+ """HTML report generation for coder_eval runs.
2
+
3
+ Produces self-contained HTML files (inline CSS/JS, no external fonts or
4
+ images) that visualize a single task's conversation trace and success
5
+ criteria, plus cross-variant experiment summaries.
6
+
7
+ Designed for offline viewing and for upload as CI artifacts.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import html as _html
13
+ import json
14
+ import logging
15
+ from collections.abc import Callable
16
+ from datetime import datetime
17
+ from pathlib import Path
18
+ from typing import TYPE_CHECKING, Any
19
+
20
+ from coder_eval.models import FinalStatus
21
+
22
+
23
+ if TYPE_CHECKING:
24
+ from coder_eval.models import (
25
+ CommandTelemetry,
26
+ CriterionResult,
27
+ EvaluationResult,
28
+ ExperimentDefinition,
29
+ ExperimentResult,
30
+ TurnRecord,
31
+ VariantAggregate,
32
+ )
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Styling — fully inline; dark theme with light override via `.light` class.
39
+ # ---------------------------------------------------------------------------
40
+
41
+ _CSS = """
42
+ :root {
43
+ --bg-page: #0f1419;
44
+ --bg-card: #1a2129;
45
+ --bg-card-2: #232d38;
46
+ --bg-code: #0b0f14;
47
+ --fg: #e6edf3;
48
+ --fg-muted: #8b98a5;
49
+ --fg-dim: #6e7681;
50
+ --border: #30363d;
51
+ --accent: #58a6ff;
52
+ --score-high: #22c55e;
53
+ --score-mid: #f59e0b;
54
+ --score-low: #ef4444;
55
+ --status-success: #22c55e;
56
+ --status-failure: #f59e0b;
57
+ --status-error: #ef4444;
58
+ --status-neutral: #6e7681;
59
+ --shadow: 0 1px 3px rgba(0,0,0,0.3);
60
+ }
61
+ .light {
62
+ --bg-page: #ffffff;
63
+ --bg-card: #f6f8fa;
64
+ --bg-card-2: #eaeef2;
65
+ --bg-code: #f6f8fa;
66
+ --fg: #1f2328;
67
+ --fg-muted: #636c76;
68
+ --fg-dim: #8b949e;
69
+ --border: #d0d7de;
70
+ --accent: #0969da;
71
+ }
72
+ * { box-sizing: border-box; }
73
+ html, body {
74
+ margin: 0;
75
+ padding: 0;
76
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial,
77
+ sans-serif;
78
+ font-size: 14px;
79
+ line-height: 1.55;
80
+ color: var(--fg);
81
+ background: var(--bg-page);
82
+ }
83
+ .container { max-width: 1200px; margin: 0 auto; padding: 24px; }
84
+ a { color: var(--accent); text-decoration: none; }
85
+ a:hover { text-decoration: underline; }
86
+ h1, h2, h3, h4 { margin: 0 0 12px 0; line-height: 1.25; }
87
+ h1 { font-size: 22px; font-weight: 600; }
88
+ h2 { font-size: 18px; font-weight: 600; margin-top: 28px;
89
+ padding-bottom: 8px; border-bottom: 1px solid var(--border); }
90
+ h3 { font-size: 15px; font-weight: 600; color: var(--fg-muted); }
91
+ h4 { font-size: 13px; font-weight: 600; color: var(--fg-muted); }
92
+ p { margin: 0 0 8px 0; }
93
+ .muted { color: var(--fg-muted); }
94
+ .dim { color: var(--fg-dim); }
95
+ .mono { font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
96
+ monospace; font-size: 12.5px; }
97
+ code { font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
98
+ monospace; font-size: 12.5px; background: var(--bg-code);
99
+ border: 1px solid var(--border); padding: 1px 5px; border-radius: 3px; }
100
+ pre { font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
101
+ monospace; font-size: 12.5px; background: var(--bg-code);
102
+ border: 1px solid var(--border); border-radius: 6px; padding: 10px 12px;
103
+ margin: 6px 0; overflow-x: auto; white-space: pre-wrap;
104
+ word-wrap: break-word; }
105
+ .header-bar { display: flex; align-items: center; justify-content: space-between;
106
+ margin-bottom: 20px; flex-wrap: wrap; gap: 12px; }
107
+ .title-group { flex: 1; min-width: 0; }
108
+ .title-group .subtitle { color: var(--fg-muted); font-size: 13px;
109
+ margin-top: 2px; word-break: break-all; }
110
+ .badges { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
111
+ .badge { display: inline-flex; align-items: center; gap: 6px;
112
+ padding: 4px 10px; border-radius: 12px; font-size: 12px;
113
+ font-weight: 600; background: var(--bg-card-2);
114
+ border: 1px solid var(--border); color: var(--fg); }
115
+ .badge.success { background: color-mix(in srgb, var(--status-success) 20%, var(--bg-card-2));
116
+ border-color: var(--status-success); color: var(--status-success); }
117
+ .badge.failure { background: color-mix(in srgb, var(--status-failure) 20%, var(--bg-card-2));
118
+ border-color: var(--status-failure); color: var(--status-failure); }
119
+ .badge.error { background: color-mix(in srgb, var(--status-error) 20%, var(--bg-card-2));
120
+ border-color: var(--status-error); color: var(--status-error); }
121
+ .badge.warning { background: color-mix(in srgb, #f4a52810 20%, var(--bg-card-2));
122
+ border-color: #f4a528; color: #f4a528; }
123
+ .badge.neutral { color: var(--fg-muted); }
124
+ .score-pill { display: inline-block; padding: 2px 8px; border-radius: 10px;
125
+ font-size: 12px; font-weight: 700; min-width: 42px;
126
+ text-align: center; color: #0b0f14; }
127
+ .score-high { background: var(--score-high); }
128
+ .score-mid { background: var(--score-mid); }
129
+ .score-low { background: var(--score-low); }
130
+ .card { background: var(--bg-card); border: 1px solid var(--border);
131
+ border-radius: 8px; padding: 16px 20px; margin-bottom: 16px;
132
+ box-shadow: var(--shadow); }
133
+ .card.highlight { border-left: 4px solid var(--accent); }
134
+ .grid { display: grid; gap: 12px;
135
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); }
136
+ .stat { background: var(--bg-card-2); padding: 10px 12px; border-radius: 6px;
137
+ border: 1px solid var(--border); }
138
+ .stat .label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px;
139
+ color: var(--fg-muted); margin-bottom: 4px; }
140
+ .stat .value { font-size: 16px; font-weight: 600; }
141
+ table { width: 100%; border-collapse: collapse; margin: 4px 0 12px 0; }
142
+ th, td { text-align: left; padding: 8px 10px;
143
+ border-bottom: 1px solid var(--border); vertical-align: top;
144
+ font-size: 13px; }
145
+ th { color: var(--fg-muted); font-weight: 600; font-size: 12px;
146
+ text-transform: uppercase; letter-spacing: 0.3px; }
147
+ tr:last-child td { border-bottom: none; }
148
+ details { margin: 6px 0; border: 1px solid var(--border); border-radius: 6px;
149
+ background: var(--bg-card-2); }
150
+ details > summary { cursor: pointer; padding: 8px 12px; user-select: none;
151
+ font-weight: 500; list-style: none; }
152
+ details > summary::-webkit-details-marker { display: none; }
153
+ details > summary::before { content: "▸ "; color: var(--fg-dim);
154
+ display: inline-block; width: 14px;
155
+ transition: transform 0.15s; }
156
+ details[open] > summary::before { content: "▾ "; }
157
+ details .details-body { padding: 0 12px 10px 12px; }
158
+ .tool-row { display: flex; align-items: center; gap: 10px;
159
+ flex-wrap: wrap; font-size: 13px; }
160
+ .tool-name { font-weight: 600; color: var(--accent); font-family: ui-monospace,
161
+ SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; }
162
+ .tool-status-ok { color: var(--status-success); font-size: 11px;
163
+ font-weight: 600; }
164
+ .tool-status-err { color: var(--status-error); font-size: 11px;
165
+ font-weight: 600; }
166
+ .tool-duration { color: var(--fg-muted); font-size: 12px; }
167
+ .tool-seq { color: var(--fg-dim); font-size: 11px; font-weight: 600;
168
+ min-width: 24px; text-align: right; font-family: ui-monospace,
169
+ SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; }
170
+ .turn-header { display: flex; justify-content: space-between;
171
+ align-items: baseline; flex-wrap: wrap; gap: 10px;
172
+ margin-bottom: 10px; }
173
+ .turn-meta { font-size: 12px; color: var(--fg-muted); }
174
+ .criterion-row td:first-child { font-family: ui-monospace, SFMono-Regular,
175
+ "SF Mono", Menlo, Consolas, monospace;
176
+ color: var(--fg-muted); font-size: 12px; }
177
+ .judge-section { display: flex; flex-direction: column; gap: 12px; }
178
+ .judge-card { padding: 14px 16px; }
179
+ .judge-card-head { display: flex; justify-content: space-between;
180
+ align-items: flex-start; gap: 12px; flex-wrap: wrap; }
181
+ .judge-card-title { font-size: 14px; line-height: 1.4; flex: 1; min-width: 0; }
182
+ .judge-card-score { flex-shrink: 0; }
183
+ .judge-rationale { margin-top: 10px; padding: 10px 12px;
184
+ background: var(--bg-code); border-radius: 4px;
185
+ font-size: 13px; line-height: 1.5; }
186
+ .judge-findings { margin: 8px 0 0 18px; padding-left: 4px; font-size: 13px;
187
+ line-height: 1.6; }
188
+ .judge-findings li { margin: 2px 0; }
189
+ .nav-toggle { display: inline-block; padding: 5px 10px; font-size: 12px;
190
+ background: var(--bg-card-2); border: 1px solid var(--border);
191
+ border-radius: 5px; color: var(--fg); cursor: pointer; }
192
+ .nav-toggle:hover { background: var(--bg-card); }
193
+ .llm-next-steps { margin: 6px 0 0 20px; padding-left: 0; }
194
+ .llm-next-steps li { margin: 3px 0; }
195
+ .iteration-group { padding: 12px 16px 4px 16px; margin-bottom: 20px;
196
+ border-radius: 0 6px 6px 0; border-left: 4px solid var(--status-neutral); }
197
+ /* rgba fallback for browsers without color-mix (Chrome <111, Firefox <113, Safari <16.2). */
198
+ .iteration-group--recovered { border-left-color: var(--status-success);
199
+ background: rgba(34, 197, 94, 0.06);
200
+ background: color-mix(in srgb, var(--status-success) 6%, transparent); }
201
+ .iteration-group--terminal { border-left-color: var(--status-error);
202
+ background: rgba(239, 68, 68, 0.06);
203
+ background: color-mix(in srgb, var(--status-error) 6%, transparent); }
204
+ .iteration-group__banner { margin: 0 0 12px 0; font-size: 14px; font-weight: 600; }
205
+ .iteration-group--recovered .iteration-group__banner { color: var(--status-success); }
206
+ .iteration-group--terminal .iteration-group__banner { color: var(--status-error); }
207
+ .attempt-transition { display: flex; align-items: center; gap: 8px;
208
+ margin: -6px 0 12px 8px; color: var(--status-error); font-size: 12.5px; }
209
+ .attempt-transition__icon { font-weight: 700; }
210
+ footer { margin-top: 40px; padding-top: 16px; border-top: 1px solid var(--border);
211
+ color: var(--fg-dim); font-size: 12px; text-align: center; }
212
+ """
213
+
214
+
215
+ _JS = """
216
+ function toggleTheme() {
217
+ document.documentElement.classList.toggle('light');
218
+ try {
219
+ const mode = document.documentElement.classList.contains('light') ? 'light' : 'dark';
220
+ localStorage.setItem('coder-eval-theme', mode);
221
+ } catch (e) { /* ignore */ }
222
+ }
223
+ (function() {
224
+ try {
225
+ if (localStorage.getItem('coder-eval-theme') === 'light') {
226
+ document.documentElement.classList.add('light');
227
+ }
228
+ } catch (e) { /* ignore */ }
229
+ })();
230
+ """
231
+
232
+
233
+ # ---------------------------------------------------------------------------
234
+ # Formatting helpers
235
+ # ---------------------------------------------------------------------------
236
+
237
+
238
+ _MAX_VALUE_LEN = 400
239
+ _MAX_RESULT_LEN = 1200
240
+
241
+
242
+ def _esc(value: Any) -> str:
243
+ """HTML-escape a value after coercing to string."""
244
+ if value is None:
245
+ return ""
246
+ return _html.escape(str(value), quote=True)
247
+
248
+
249
+ def _truncate(text: str, limit: int) -> tuple[str, bool]:
250
+ """Truncate text to `limit` chars. Returns (text, was_truncated)."""
251
+ if text is None:
252
+ return "", False
253
+ s = str(text)
254
+ if len(s) <= limit:
255
+ return s, False
256
+ return s[:limit], True
257
+
258
+
259
+ def _score_class(score: float | None) -> str:
260
+ """Return CSS class for a 0..1 score."""
261
+ if score is None:
262
+ return "score-mid"
263
+ if score >= 0.8:
264
+ return "score-high"
265
+ if score >= 0.5:
266
+ return "score-mid"
267
+ return "score-low"
268
+
269
+
270
+ def _score_pill(score: float | None, suffix: str = "") -> str:
271
+ """Render a colored score pill."""
272
+ if score is None:
273
+ return '<span class="score-pill score-mid">—</span>'
274
+ label = f"{score:.2f}{suffix}"
275
+ return f'<span class="score-pill {_score_class(score)}">{_esc(label)}</span>'
276
+
277
+
278
+ def _status_badge(status: Any) -> str:
279
+ """Render a colored status badge for FinalStatus (dispatches on .category)."""
280
+ status_str = getattr(status, "value", None) or str(status)
281
+ try:
282
+ fs = status if isinstance(status, FinalStatus) else FinalStatus(str(status))
283
+ cls = {"succeeded": "success", "failed": "failure", "error": "error"}[fs.category]
284
+ except (ValueError, KeyError):
285
+ cls = "neutral" # unknown / non-FinalStatus input
286
+ return f'<span class="badge {_esc(cls)}">{_esc(status_str)}</span>'
287
+
288
+
289
+ def _format_duration(seconds: float | None) -> str:
290
+ if seconds is None:
291
+ return "—"
292
+ if seconds < 60:
293
+ return f"{seconds:.1f}s"
294
+ minutes = int(seconds // 60)
295
+ secs = seconds % 60
296
+ return f"{minutes}m {secs:.0f}s"
297
+
298
+
299
+ def _format_ms(ms: float | None) -> str:
300
+ if ms is None:
301
+ return "—"
302
+ if ms < 1000:
303
+ return f"{ms:.0f}ms"
304
+ return f"{ms / 1000:.2f}s"
305
+
306
+
307
+ def _format_params(params: dict[str, Any]) -> str:
308
+ """Pretty-print tool parameters as JSON, truncating long values."""
309
+ try:
310
+ trimmed: dict[str, Any] = {}
311
+ for key, val in params.items():
312
+ if isinstance(val, str) and len(val) > _MAX_VALUE_LEN:
313
+ trimmed[key] = val[:_MAX_VALUE_LEN] + f"… ({len(val) - _MAX_VALUE_LEN} more chars)"
314
+ else:
315
+ trimmed[key] = val
316
+ return json.dumps(trimmed, indent=2, ensure_ascii=False, default=str)
317
+ except (TypeError, ValueError):
318
+ return repr(params)
319
+
320
+
321
+ # ---------------------------------------------------------------------------
322
+ # Section renderers
323
+ # ---------------------------------------------------------------------------
324
+
325
+
326
+ def _render_header(result: EvaluationResult) -> str:
327
+ from .reports_stats import expected_turns_overage
328
+
329
+ started = result.started_at.isoformat(timespec="seconds") if result.started_at else "—"
330
+ duration = _format_duration(result.duration_seconds)
331
+ score_badge = _score_pill(result.weighted_score) if result.weighted_score is not None else ""
332
+ model = _esc(result.model_used or "—")
333
+ agent_type = _esc(result.agent_type)
334
+ subtitle = _esc(result.task_description.strip().splitlines()[0] if result.task_description else "")
335
+ turns_count = result.total_assistant_turns or 0
336
+ cost_badge = ""
337
+ if result.total_token_usage and result.total_token_usage.total_cost_usd is not None:
338
+ cost_badge = f'<span class="badge neutral">${result.total_token_usage.total_cost_usd:.4f}</span>'
339
+ expected_turns_badge = ""
340
+ overage = expected_turns_overage(result)
341
+ if overage is not None:
342
+ actual, expected = overage
343
+ expected_turns_badge = f'<span class="badge warning">expected_turns exceeded ({actual}/{expected})</span>'
344
+ return f"""
345
+ <div class="header-bar">
346
+ <div class="title-group">
347
+ <h1>Task: {_esc(result.task_id)}</h1>
348
+ <div class="subtitle">{subtitle}</div>
349
+ </div>
350
+ <div class="badges">
351
+ {_status_badge(result.final_status)}
352
+ {score_badge}
353
+ <span class="badge neutral">{agent_type} · {model}</span>
354
+ <span class="badge neutral">{_esc(duration)}</span>
355
+ {cost_badge}
356
+ {expected_turns_badge}
357
+ <span class="nav-toggle" onclick="toggleTheme()">Toggle theme</span>
358
+ </div>
359
+ </div>
360
+ <div class="card">
361
+ <div class="grid">
362
+ <div class="stat"><div class="label">Variant</div><div class="value">{_esc(result.variant_id)}</div></div>
363
+ <div class="stat"><div class="label">Started</div><div class="value mono">{_esc(started)}</div></div>
364
+ <div class="stat"><div class="label">Duration</div><div class="value">{_esc(duration)}</div></div>
365
+ <div class="stat"><div class="label">Turns (SDK)</div><div class="value">{turns_count}</div></div>
366
+ <div class="stat"><div class="label">Commands</div><div class="value">{result.actual_commands or 0}</div></div>
367
+ </div>
368
+ </div>
369
+ """
370
+
371
+
372
+ def _render_criteria_details(cr: CriterionResult) -> str:
373
+ """Render the Details cell for a criterion.
374
+
375
+ Short details (no newline) render inline to keep the table scannable.
376
+ Multiline details (typical for `run_command` which includes command,
377
+ exit, stdout, and stderr) render as a collapsible `<details>` block
378
+ whose summary shows just the first line — the user expands to see the
379
+ full captured output.
380
+
381
+ Judge criteria render their findings + transcript in a dedicated
382
+ "Judge Verdicts" section below the criteria table; this cell stays
383
+ compact and just shows the score=...\\nrationale: ... summary.
384
+ """
385
+ err = _esc(cr.error) if cr.error else ""
386
+ details_raw = (cr.details or "").strip()
387
+ details_txt = _esc(details_raw)
388
+
389
+ if not details_raw:
390
+ return err or '<span class="dim">—</span>'
391
+
392
+ lines = details_raw.splitlines()
393
+ if len(lines) <= 1:
394
+ body = err + ("<br/>" if err and details_txt else "") + details_txt
395
+ return f'<div class="dim mono" style="white-space:pre-wrap">{body}</div>'
396
+
397
+ summary = _esc(lines[0])
398
+ full_pre = f'<pre style="margin:6px 0 0 0">{details_txt}</pre>'
399
+ prefix = err + "<br/>" if err else ""
400
+ return (
401
+ f"{prefix}"
402
+ f'<details><summary class="mono dim">{summary}</summary>'
403
+ f'<div class="details-body">{full_pre}</div></details>'
404
+ )
405
+
406
+
407
+ def _render_judge_section(criteria: list[CriterionResult]) -> str:
408
+ """Render the dedicated 'Judge Verdicts' section containing per-judge cards.
409
+
410
+ Each judge result that carries verdict evidence (findings, transcript) gets
411
+ a card with: type + description, score badge, rationale extracted from the
412
+ details string, an expanded-by-default Findings list, and a collapsed
413
+ Judge transcript disclosure.
414
+
415
+ Returns an empty string when there's no judge result with anything to show
416
+ — non-judge tasks render no Judge Verdicts heading.
417
+ """
418
+ cards: list[str] = []
419
+ for cr in criteria:
420
+ # criterion_type identifies judges; details/findings/transcript may all
421
+ # be present (typed JudgeCriterionResult) or in model_extra (round-tripped).
422
+ if cr.criterion_type not in ("llm_judge", "agent_judge"):
423
+ continue
424
+ findings_raw = getattr(cr, "findings", []) or []
425
+ findings = [str(f).strip() for f in findings_raw if str(f).strip()]
426
+ transcript = getattr(cr, "transcript", None)
427
+ if not findings and transcript in (None, {}, []):
428
+ continue
429
+ cards.append(_render_judge_card(cr, findings, transcript))
430
+
431
+ if not cards:
432
+ return ""
433
+
434
+ return f'<h2>Judge Verdicts ({len(cards)})</h2><div class="judge-section">' + "".join(cards) + "</div>"
435
+
436
+
437
+ def _extract_rationale(details: str | None) -> str:
438
+ """Pull the rationale line out of the standard format_details payload.
439
+
440
+ ``format_details`` writes ``score=0.750\\nrationale: ...\\nnotes: ...``.
441
+ Reach into the second line and strip the ``rationale: `` prefix.
442
+ Returns an empty string when the details aren't in this shape.
443
+ """
444
+ if not details:
445
+ return ""
446
+ for line in details.splitlines():
447
+ prefix = "rationale: "
448
+ if line.startswith(prefix):
449
+ return line[len(prefix) :].strip()
450
+ return ""
451
+
452
+
453
+ def _render_judge_card(cr: CriterionResult, findings: list[str], transcript: Any) -> str:
454
+ """Render one judge result as a card (type + score + rationale + findings + transcript)."""
455
+ rationale = _extract_rationale(cr.details)
456
+ rationale_html = f'<div class="judge-rationale">{_esc(rationale)}</div>' if rationale else ""
457
+
458
+ findings_html = ""
459
+ if findings:
460
+ items = "".join(f"<li>{_esc(f)}</li>" for f in findings)
461
+ # Findings open by default — they're the audit highlight reviewers came for.
462
+ findings_html = (
463
+ f'<details open style="margin-top:8px">'
464
+ f'<summary class="mono dim">Findings ({len(findings)})</summary>'
465
+ f'<ul class="judge-findings">{items}</ul>'
466
+ f"</details>"
467
+ )
468
+
469
+ transcript_html = _render_judge_transcript(transcript)
470
+
471
+ return (
472
+ '<div class="card judge-card">'
473
+ + '<div class="judge-card-head">'
474
+ + f'<div class="judge-card-title"><span class="mono dim">{_esc(cr.criterion_type)}</span> '
475
+ + f"&middot; {_esc(cr.description)}</div>"
476
+ + f'<div class="judge-card-score">{_score_pill(cr.score)}</div>'
477
+ + "</div>"
478
+ + rationale_html
479
+ + findings_html
480
+ + transcript_html
481
+ + "</div>"
482
+ )
483
+
484
+
485
+ def _render_judge_transcript(transcript: Any) -> str:
486
+ """Render a JudgeTranscript (or its dict round-trip form) as a disclosure block."""
487
+ if transcript is None:
488
+ return ""
489
+ # Normalize to a plain dict so dict-form (round-tripped) and model-form work the same.
490
+ if hasattr(transcript, "model_dump"):
491
+ data = transcript.model_dump()
492
+ elif isinstance(transcript, dict):
493
+ data = transcript
494
+ else:
495
+ return ""
496
+
497
+ tool_calls = data.get("tool_calls") or []
498
+ raw_verdict = (data.get("raw_verdict") or "").strip()
499
+ duration = data.get("duration_seconds") or 0.0
500
+ truncated = bool(data.get("truncated"))
501
+ token_usage = data.get("token_usage")
502
+
503
+ if not tool_calls and not raw_verdict and not token_usage:
504
+ return ""
505
+
506
+ rows: list[str] = []
507
+ for tc in tool_calls:
508
+ if isinstance(tc, dict):
509
+ tool = _esc(str(tc.get("tool_name", "")))
510
+ detail = _esc(str(tc.get("detail", "")))
511
+ status = _esc(str(tc.get("status", "")))
512
+ preview = _esc(str(tc.get("result_preview", "")))
513
+ else:
514
+ tool = _esc(getattr(tc, "tool_name", ""))
515
+ detail = _esc(getattr(tc, "detail", ""))
516
+ status = _esc(getattr(tc, "status", ""))
517
+ preview = _esc(getattr(tc, "result_preview", ""))
518
+ rows.append(
519
+ f"<tr><td class='mono'>{tool}</td><td class='mono dim'>{status}</td>"
520
+ + f"<td class='mono' style='white-space:pre-wrap'>{detail}</td>"
521
+ + f"<td class='dim' style='white-space:pre-wrap'>{preview}</td></tr>"
522
+ )
523
+ table = ""
524
+ if rows:
525
+ table = (
526
+ "<table style='margin-top:6px;font-size:0.9em'>"
527
+ "<thead><tr><th>Tool</th><th>Status</th><th>Detail</th><th>Result</th></tr></thead>"
528
+ f"<tbody>{''.join(rows)}</tbody></table>"
529
+ )
530
+
531
+ raw_block = ""
532
+ if raw_verdict:
533
+ raw_block = (
534
+ f'<details style="margin-top:6px"><summary class="mono dim">Raw verdict</summary>'
535
+ f'<pre style="margin:6px 0 0 0;white-space:pre-wrap">{_esc(raw_verdict)}</pre></details>'
536
+ )
537
+
538
+ meta_bits: list[str] = []
539
+ if duration:
540
+ meta_bits.append(f"duration: {float(duration):.1f}s")
541
+ if isinstance(token_usage, dict):
542
+ in_tok = token_usage.get("input_tokens")
543
+ out_tok = token_usage.get("output_tokens")
544
+ if in_tok is not None or out_tok is not None:
545
+ meta_bits.append(f"tokens: in={in_tok or 0}, out={out_tok or 0}")
546
+ if truncated:
547
+ meta_bits.append("truncated")
548
+ meta = f'<div class="dim mono" style="margin-top:4px">{_esc(" • ".join(meta_bits))}</div>' if meta_bits else ""
549
+
550
+ summary_label = f"Judge transcript ({len(tool_calls)} tool calls)" if tool_calls else "Judge transcript"
551
+ return (
552
+ f'<details style="margin-top:4px"><summary class="mono dim">{_esc(summary_label)}</summary>'
553
+ f'<div class="details-body">{meta}{table}{raw_block}</div></details>'
554
+ )
555
+
556
+
557
+ def _render_criteria(results: list[CriterionResult]) -> str:
558
+ if not results:
559
+ return """
560
+ <div class="card">
561
+ <h2 style="margin-top:0;border:none;padding:0">Success Criteria</h2>
562
+ <p class="muted">No criteria were evaluated (task may have errored before
563
+ the checker ran).</p>
564
+ </div>
565
+ """
566
+ rows: list[str] = []
567
+ for cr in results:
568
+ rows.append(
569
+ f"""
570
+ <tr class="criterion-row">
571
+ <td>{_esc(cr.criterion_type)}</td>
572
+ <td>{_esc(cr.description)}</td>
573
+ <td style="text-align:center">{_score_pill(cr.score)}</td>
574
+ <td>{_render_criteria_details(cr)}</td>
575
+ </tr>
576
+ """
577
+ )
578
+ passed = sum(1 for r in results if r.score >= r.pass_threshold)
579
+ total = len(results)
580
+ return f"""
581
+ <h2>Success Criteria <span class="muted" style="font-weight:400">({passed}/{total} passed)</span></h2>
582
+ <div class="card" style="padding:0">
583
+ <table>
584
+ <thead>
585
+ <tr>
586
+ <th style="width:15%">Type</th>
587
+ <th style="width:35%">Description</th>
588
+ <th style="width:10%">Score</th>
589
+ <th>Details</th>
590
+ </tr>
591
+ </thead>
592
+ <tbody>
593
+ {"".join(rows)}
594
+ </tbody>
595
+ </table>
596
+ </div>
597
+ """
598
+
599
+
600
+ def _render_command(cmd: CommandTelemetry) -> str:
601
+ status_cls = "tool-status-ok" if cmd.result_status == "success" else "tool-status-err"
602
+ status_label = (cmd.result_status or "unknown").upper()
603
+ params_pretty = _format_params(cmd.parameters or {})
604
+ params_pretty_trunc, _ = _truncate(params_pretty, 4000)
605
+ result_text = cmd.result_summary or ""
606
+ result_trunc, was_trunc = _truncate(result_text, _MAX_RESULT_LEN)
607
+ if was_trunc:
608
+ result_trunc += f"\n\n… (truncated, {len(result_text) - _MAX_RESULT_LEN} more chars)"
609
+ err_block = ""
610
+ if cmd.error_message:
611
+ err_trunc, _ = _truncate(cmd.error_message, 2000)
612
+ err_block = f"""<h4>Error</h4><pre>{_esc(err_trunc)}</pre>"""
613
+ return f"""
614
+ <details>
615
+ <summary>
616
+ <span class="tool-row">
617
+ <span class="tool-seq">#{cmd.sequence_number}</span>
618
+ <span class="tool-name">{_esc(cmd.tool_name)}</span>
619
+ <span class="{status_cls}">{_esc(status_label)}</span>
620
+ <span class="tool-duration">{_esc(_format_ms(cmd.duration_ms))}</span>
621
+ </span>
622
+ </summary>
623
+ <div class="details-body">
624
+ <h4>Parameters</h4>
625
+ <pre>{_esc(params_pretty_trunc)}</pre>
626
+ <h4>Result</h4>
627
+ <pre>{_esc(result_trunc) if result_trunc else '<span class="dim">(no summary)</span>'}</pre>
628
+ {err_block}
629
+ </div>
630
+ </details>
631
+ """
632
+
633
+
634
+ def _group_turns_by_iteration(
635
+ turns: list[TurnRecord],
636
+ ) -> list[tuple[int, list[TurnRecord]]]:
637
+ """Group consecutive TurnRecords by iteration as ``(iteration, group)`` tuples for the renderer."""
638
+ from .reports import group_consecutive_by_iteration
639
+
640
+ groups = group_consecutive_by_iteration(turns, lambda t: t.iteration)
641
+ return [(group[0].iteration, group) for group in groups]
642
+
643
+
644
+ def _render_iteration_group(iteration: int, group: list[TurnRecord]) -> str:
645
+ """Render an iteration's turns; multi-attempt groups get a coloured wrapper + banner."""
646
+ if len(group) == 1:
647
+ return _render_turn(group[0])
648
+
649
+ n = len(group)
650
+ crashed_count = sum(1 for t in group if t.crashed)
651
+ recovered = any(not t.crashed for t in group)
652
+ if recovered:
653
+ plural = "s" if crashed_count != 1 else ""
654
+ banner = f"Iteration {iteration} — recovered after {crashed_count} crashed attempt{plural}"
655
+ modifier = "iteration-group--recovered"
656
+ else:
657
+ banner = f"Iteration {iteration} — terminal failure ({n} crashed attempts)"
658
+ modifier = "iteration-group--terminal"
659
+
660
+ parts: list[str] = []
661
+ for i, t in enumerate(group):
662
+ parts.append(_render_turn(t, attempt_index=i + 1, attempt_total=n, recovered=(not t.crashed and recovered)))
663
+ # Marker between a crashed attempt and the next; suppressed on terminal (no following card).
664
+ if t.crashed and i + 1 < len(group):
665
+ reason = t.crash_reason or "Agent crashed"
666
+ parts.append(_render_attempt_transition(reason))
667
+ return f"""
668
+ <div class="iteration-group {modifier}">
669
+ <h3 class="iteration-group__banner">{_esc(banner)}</h3>
670
+ {"".join(parts)}
671
+ </div>
672
+ """
673
+
674
+
675
+ def _render_attempt_transition(reason: str) -> str:
676
+ """Render a small inline marker between two attempts of the same iteration."""
677
+ return f"""
678
+ <div class="attempt-transition">
679
+ <span class="attempt-transition__icon">⚠</span>
680
+ <span><strong>{_esc(reason)}</strong> · resuming ↓</span>
681
+ </div>
682
+ """
683
+
684
+
685
+ def _render_turn(
686
+ turn: TurnRecord,
687
+ *,
688
+ attempt_index: int | None = None,
689
+ attempt_total: int | None = None,
690
+ recovered: bool = False,
691
+ ) -> str:
692
+ prompt_trunc, _ = _truncate(turn.user_input or "", 2000)
693
+ response_trunc, _ = _truncate(turn.agent_output or "", 4000)
694
+ cmds_html = "".join(_render_command(c) for c in (turn.commands or []))
695
+ if not cmds_html:
696
+ cmds_html = '<p class="muted">No tool calls recorded for this turn.</p>'
697
+ tokens_label = ""
698
+ if turn.token_usage:
699
+ tu = turn.token_usage
700
+ tokens_label = (
701
+ f'<span class="badge neutral">'
702
+ f"in {tu.uncached_input_tokens} · out {tu.output_tokens}"
703
+ f" · cache {tu.cache_read_input_tokens}"
704
+ f"</span>"
705
+ )
706
+ duration_label = f'<span class="badge neutral">{_esc(_format_duration(turn.duration_seconds))}</span>'
707
+ exhausted = '<span class="badge failure">max_turns exhausted</span>' if turn.max_turns_exhausted else ""
708
+ response_block = (
709
+ f"""
710
+ <details>
711
+ <summary>Agent response</summary>
712
+ <div class="details-body"><pre>{_esc(response_trunc)}</pre></div>
713
+ </details>"""
714
+ if turn.agent_output
715
+ else ""
716
+ )
717
+ crashed = '<span class="badge error">crashed (partial)</span>' if turn.crashed else ""
718
+ recovered_badge = '<span class="badge success">recovered</span>' if recovered and not turn.crashed else ""
719
+ if attempt_total is not None and attempt_total > 1 and attempt_index is not None:
720
+ # Iteration number lives on the group banner; card heading is just the attempt.
721
+ heading = f"Attempt {attempt_index} of {attempt_total}"
722
+ else:
723
+ heading = f"Iteration {turn.iteration}"
724
+ return f"""
725
+ <div class="card">
726
+ <div class="turn-header">
727
+ <h3>{_esc(heading)}</h3>
728
+ <div class="badges">
729
+ <span class="badge neutral">{turn.assistant_turn_count} assistant turns</span>
730
+ {tokens_label}
731
+ {duration_label}
732
+ {exhausted}
733
+ {crashed}
734
+ {recovered_badge}
735
+ </div>
736
+ </div>
737
+ <details>
738
+ <summary>Prompt to agent</summary>
739
+ <div class="details-body"><pre>{_esc(prompt_trunc)}</pre></div>
740
+ </details>{response_block}
741
+ <h4 style="margin-top:12px">Tool Calls ({len(turn.commands or [])})</h4>
742
+ {cmds_html}
743
+ </div>
744
+ """
745
+
746
+
747
+ def _render_command_stats(stats: Any | None) -> str:
748
+ if stats is None:
749
+ return ""
750
+ rows: list[str] = []
751
+ for tool, count in sorted((stats.commands_by_tool or {}).items(), key=lambda x: x[1], reverse=True):
752
+ rows.append(f"<tr><td class='mono'>{_esc(tool)}</td><td>{count}</td></tr>")
753
+ rows_html = "".join(rows) or "<tr><td colspan='2' class='muted'>No commands</td></tr>"
754
+ from .reports import SLOW_PARAMS_PREVIEW_CHARS
755
+
756
+ slow_rows_list: list[str] = []
757
+ for c in stats.slowest_commands or []:
758
+ params_full = str(c.parameters)
759
+ params_preview = params_full[:SLOW_PARAMS_PREVIEW_CHARS]
760
+ if len(params_full) > SLOW_PARAMS_PREVIEW_CHARS:
761
+ params_preview += "..."
762
+ slow_rows_list.append(
763
+ f"<tr><td class='mono'>{_esc(c.tool)}</td><td>{_esc(_format_ms(c.duration_ms))}</td>"
764
+ + f"<td class='mono dim'>{_esc(params_preview)}</td></tr>"
765
+ )
766
+ slow_rows = "".join(slow_rows_list) or "<tr><td colspan='3' class='muted'>—</td></tr>"
767
+ success_pct = (stats.successful_commands / stats.total_commands * 100) if stats.total_commands else 0.0
768
+ successful_str = f"{stats.successful_commands} ({success_pct:.0f}%)"
769
+ avg_str = _esc(_format_ms(stats.avg_command_time_ms))
770
+
771
+ extras: list[str] = []
772
+ if stats.most_common_sequence:
773
+ extras.append(f"<p><strong>Most Common Pattern:</strong> <code>{_esc(stats.most_common_sequence)}</code></p>")
774
+ skill_count = (stats.commands_by_tool or {}).get("Skill", 0)
775
+ if skill_count > 0:
776
+ extras.append(f"<p><strong>Skill Tool Invoked:</strong> {skill_count} time(s)</p>")
777
+ extras_html = "".join(extras)
778
+
779
+ return f"""
780
+ <h2>Command Telemetry</h2>
781
+ <div class="card">
782
+ <div class="grid">
783
+ <div class="stat"><div class="label">Total</div><div class="value">{stats.total_commands}</div></div>
784
+ <div class="stat"><div class="label">Successful</div><div class="value">{successful_str}</div></div>
785
+ <div class="stat"><div class="label">Failed</div><div class="value">{stats.failed_commands}</div></div>
786
+ <div class="stat"><div class="label">Avg / cmd</div><div class="value">{avg_str}</div></div>
787
+ </div>
788
+ <div style="display:grid;grid-template-columns:1fr 2fr;gap:16px;margin-top:12px">
789
+ <div>
790
+ <h4>By Tool</h4>
791
+ <table>
792
+ <thead><tr><th>Tool</th><th>Count</th></tr></thead>
793
+ <tbody>{rows_html}</tbody>
794
+ </table>
795
+ </div>
796
+ <div>
797
+ <h4>Slowest</h4>
798
+ <table>
799
+ <thead><tr><th>Tool</th><th>Duration</th><th>Parameters</th></tr></thead>
800
+ <tbody>{slow_rows}</tbody>
801
+ </table>
802
+ </div>
803
+ </div>
804
+ {extras_html}
805
+ </div>
806
+ """
807
+
808
+
809
+ def _render_error_details(result: EvaluationResult) -> str:
810
+ if not result.error_message and not result.error_details:
811
+ return ""
812
+ details = result.error_details or {}
813
+ category = details.get("error_category", "unknown") if isinstance(details, dict) else ""
814
+ component = details.get("component", "") if isinstance(details, dict) else ""
815
+ retryable = details.get("is_retryable") if isinstance(details, dict) else None
816
+ retry_badge = ""
817
+ if retryable is not None:
818
+ retry_badge = (
819
+ '<span class="badge neutral">retryable</span>'
820
+ if retryable
821
+ else '<span class="badge neutral">non-retryable</span>'
822
+ )
823
+ # Prefer the in-result tail captured at run time (sanitised, bounded). Fall
824
+ # back to the legacy stack_trace from error_details so reports regenerated
825
+ # against archived runs (pre-error_log_tail) still surface diagnostics.
826
+ log_text = result.error_log_tail or ""
827
+ if not log_text and isinstance(details, dict):
828
+ stack = details.get("stack_trace")
829
+ if stack:
830
+ log_text = str(stack)
831
+ logs_html = ""
832
+ if log_text:
833
+ logs_body = f'<div class="details-body"><pre>{_esc(log_text)}</pre></div>'
834
+ logs_html = f"<details><summary>Logs</summary>{logs_body}</details>"
835
+ component_html = f'<span class="badge neutral">{_esc(component)}</span>' if component else ""
836
+ return f"""
837
+ <h2>Error</h2>
838
+ <div class="card" style="border-left:4px solid var(--status-error)">
839
+ <div class="badges" style="margin-bottom:10px">
840
+ <span class="badge error">{_esc(category)}</span>
841
+ {component_html}
842
+ {retry_badge}
843
+ </div>
844
+ <h4>Message</h4>
845
+ <pre>{_esc(result.error_message or "(no message)")}</pre>
846
+ {logs_html}
847
+ </div>
848
+ """
849
+
850
+
851
+ def _render_token_usage(result: EvaluationResult) -> str:
852
+ """Render Token Usage section — totals + cost."""
853
+ tu = result.total_token_usage
854
+ if tu is None:
855
+ return ""
856
+ total = tu.total_tokens
857
+ cost_str = f"${tu.total_cost_usd:.4f}" if tu.total_cost_usd is not None else "N/A"
858
+ uncached_fmt = f"{tu.uncached_input_tokens:,}"
859
+ cache_write_fmt = f"{tu.cache_creation_input_tokens:,}"
860
+ cache_read_fmt = f"{tu.cache_read_input_tokens:,}"
861
+ return f"""
862
+ <h2>Token Usage</h2>
863
+ <div class="card">
864
+ <div class="grid">
865
+ <div class="stat"><div class="label">Input (uncached)</div><div class="value">{uncached_fmt}</div></div>
866
+ <div class="stat"><div class="label">Output</div><div class="value">{tu.output_tokens:,}</div></div>
867
+ <div class="stat"><div class="label">Cache Write</div><div class="value">{cache_write_fmt}</div></div>
868
+ <div class="stat"><div class="label">Cache Read</div><div class="value">{cache_read_fmt}</div></div>
869
+ <div class="stat"><div class="label">Total</div><div class="value">{total:,}</div></div>
870
+ <div class="stat"><div class="label">Cost</div><div class="value">{_esc(cost_str)}</div></div>
871
+ </div>
872
+ </div>
873
+ """
874
+
875
+
876
+ def _render_generation_metrics(result: EvaluationResult) -> str:
877
+ """Render Generation Metrics — latency, turns."""
878
+ from .reports import count_partials_by_outcome, group_consecutive_by_iteration
879
+
880
+ turns = result.iterations or []
881
+ num_turns = len(turns)
882
+ asst_turns = result.total_assistant_turns or 0
883
+ avg_turn = (sum(t.duration_seconds for t in turns) / num_turns) if num_turns else 0.0
884
+ total_latency = _esc(_format_duration(result.duration_seconds))
885
+ avg_latency = _esc(_format_duration(avg_turn))
886
+ crashed_stat = ""
887
+ groups = group_consecutive_by_iteration(turns, lambda t: t.iteration)
888
+ total_partials, recovered_partials, terminal_partials = count_partials_by_outcome(groups, lambda t: t.crashed)
889
+ if total_partials:
890
+ breakdown = f"{total_partials} ({recovered_partials} recovered, {terminal_partials} terminal)"
891
+ crashed_stat = (
892
+ f'<div class="stat"><div class="label">Crashed Partials</div>'
893
+ f'<div class="value">{_esc(breakdown)}</div></div>'
894
+ )
895
+ return f"""
896
+ <h2>Generation Metrics</h2>
897
+ <div class="card">
898
+ <div class="grid">
899
+ <div class="stat"><div class="label">Total Latency</div><div class="value">{total_latency}</div></div>
900
+ <div class="stat"><div class="label">Turns</div><div class="value">{num_turns}</div></div>
901
+ <div class="stat"><div class="label">Assistant Turns</div><div class="value">{asst_turns}</div></div>
902
+ <div class="stat"><div class="label">Avg Turn Latency</div><div class="value">{avg_latency}</div></div>
903
+ {crashed_stat}
904
+ </div>
905
+ </div>
906
+ """
907
+
908
+
909
+ def _render_commands_efficiency(result: EvaluationResult) -> str:
910
+ """Render Commands Efficiency card. Empty when data missing."""
911
+ if result.commands_efficiency is None or result.expected_commands is None or result.actual_commands is None:
912
+ return ""
913
+ pct = result.commands_efficiency * 100
914
+ ratio = f"{result.expected_commands}/{result.actual_commands}"
915
+ return f"""
916
+ <h2>Commands Efficiency</h2>
917
+ <div class="card">
918
+ <div class="grid">
919
+ <div class="stat"><div class="label">Efficiency</div><div class="value">{pct:.1f}%</div></div>
920
+ <div class="stat"><div class="label">Expected / Actual</div><div class="value">{ratio}</div></div>
921
+ </div>
922
+ </div>
923
+ """
924
+
925
+
926
+ def _render_agent_settings(result: EvaluationResult) -> str:
927
+ """Render Agent Settings section. Prefers sdk_options, falls back to agent_config."""
928
+ from .reports import collect_agent_settings_rows
929
+
930
+ if result.sdk_options:
931
+ settings: dict[str, Any] = result.sdk_options
932
+ is_sdk = True
933
+ elif result.agent_config is not None:
934
+ settings = result.agent_config.model_dump(warnings=False)
935
+ is_sdk = False
936
+ else:
937
+ return ""
938
+
939
+ rows = collect_agent_settings_rows(settings, is_sdk)
940
+ body = "".join(f"<tr><td class='mono dim'>{_esc(label)}</td><td>{_esc(value)}</td></tr>" for label, value in rows)
941
+ return f"""
942
+ <h2>Agent Settings</h2>
943
+ <div class="card" style="padding:0">
944
+ <table>
945
+ <tbody>{body}</tbody>
946
+ </table>
947
+ </div>
948
+ """
949
+
950
+
951
+ def _render_installed_tools(result: EvaluationResult) -> str:
952
+ """Render Installed Tools section. Empty when absent."""
953
+ tools = result.environment_info.get("installed_tools") if result.environment_info else None
954
+ if not tools or not isinstance(tools, dict):
955
+ return ""
956
+ rows = "".join(
957
+ f"<tr><td class='mono'>{_esc(name)}</td><td class='mono'>{_esc(ver)}</td></tr>"
958
+ for name, ver in sorted(tools.items())
959
+ )
960
+ return f"""
961
+ <h2>Installed Tools</h2>
962
+ <div class="card" style="padding:0">
963
+ <table>
964
+ <thead><tr><th>Tool</th><th>Version</th></tr></thead>
965
+ <tbody>{rows}</tbody>
966
+ </table>
967
+ </div>
968
+ """
969
+
970
+
971
+ _SIMULATION_STOP_REASON_LABELS = {
972
+ "criteria_passed": ("success", "criteria passed"),
973
+ "stop_token": ("neutral", "simulator ended dialog"),
974
+ "max_turns": ("failure", "turn cap reached"),
975
+ "budget": ("failure", "token budget exhausted"),
976
+ "error": ("failure", "simulator error"),
977
+ }
978
+
979
+
980
+ def _render_simulation(result: EvaluationResult) -> str:
981
+ """Render the Simulation section (only when simulation telemetry is present)."""
982
+ sim = result.simulation
983
+ if sim is None:
984
+ return ""
985
+ badge_class, label = _SIMULATION_STOP_REASON_LABELS.get(sim.stop_reason, ("neutral", sim.stop_reason))
986
+ trial_line = (
987
+ f"<tr><td class='mono dim'>Trial</td><td>{sim.replicate_index + 1} of {sim.n_trials}</td></tr>"
988
+ if sim.n_trials > 1
989
+ else ""
990
+ )
991
+ failure_line = (
992
+ f"<tr><td class='mono dim'>Simulator failures</td><td>{sim.simulator_failures}</td></tr>"
993
+ if sim.simulator_failures > 0
994
+ else ""
995
+ )
996
+ return f"""
997
+ <h2>Simulation</h2>
998
+ <div class="card" style="padding:0">
999
+ <table>
1000
+ <tbody>
1001
+ <tr><td class='mono dim'>Stop reason</td>
1002
+ <td><span class="badge {badge_class}">{_esc(label)}</span>
1003
+ <span class="mono dim"> ({_esc(sim.stop_reason)})</span></td></tr>
1004
+ <tr><td class='mono dim'>Total turns</td><td>{sim.total_turns}</td></tr>
1005
+ {trial_line}
1006
+ <tr><td class='mono dim'>Simulator tokens</td>
1007
+ <td>in {sim.simulator_input_tokens} · out {sim.simulator_output_tokens}</td></tr>
1008
+ {failure_line}
1009
+ </tbody>
1010
+ </table>
1011
+ </div>
1012
+ """
1013
+
1014
+
1015
+ def _render_environment(result: EvaluationResult) -> str:
1016
+ """Render Environment section (excluding installed_tools, which has its own)."""
1017
+ env = {k: v for k, v in (result.environment_info or {}).items() if k != "installed_tools"}
1018
+ if not env:
1019
+ return ""
1020
+ rows = "".join(f"<tr><td class='mono dim'>{_esc(k)}</td><td>{_esc(v)}</td></tr>" for k, v in env.items())
1021
+ return f"""
1022
+ <h2>Environment</h2>
1023
+ <div class="card" style="padding:0">
1024
+ <table>
1025
+ <tbody>{rows}</tbody>
1026
+ </table>
1027
+ </div>
1028
+ """
1029
+
1030
+
1031
+ def _wrap_document(title: str, body: str) -> str:
1032
+ """Wrap body HTML in a complete standalone document."""
1033
+ return f"""<!DOCTYPE html>
1034
+ <html lang="en">
1035
+ <head>
1036
+ <meta charset="UTF-8">
1037
+ <meta name="viewport" content="width=device-width,initial-scale=1">
1038
+ <title>{_esc(title)}</title>
1039
+ <style>{_CSS}</style>
1040
+ </head>
1041
+ <body>
1042
+ <div class="container">
1043
+ {body}
1044
+ <footer>Generated by coder_eval · {_esc(datetime.now().isoformat(timespec="seconds"))}</footer>
1045
+ </div>
1046
+ <script>{_JS}</script>
1047
+ </body>
1048
+ </html>
1049
+ """
1050
+
1051
+
1052
+ # ---------------------------------------------------------------------------
1053
+ # Variant / Experiment helpers
1054
+ # ---------------------------------------------------------------------------
1055
+
1056
+
1057
+ def _variant_stddev_lines(variant_id: str, result: ExperimentResult | None) -> str:
1058
+ """Render Score/Duration stddev stats inside the summary card.
1059
+
1060
+ Returns an empty string when ``result`` is not provided or data is insufficient.
1061
+ """
1062
+ if result is None:
1063
+ return ""
1064
+ from .reports_stats import stddev
1065
+
1066
+ vrs = [vr for ts in result.task_summaries for vr in ts.variant_results if vr.variant_id == variant_id]
1067
+ scores = [vr.weighted_score for vr in vrs]
1068
+ durations = [vr.duration_seconds for vr in vrs]
1069
+ extras: list[str] = []
1070
+ if len(scores) >= 2:
1071
+ extras.append(f"<li><strong>Score Stddev:</strong> {stddev(scores):.3f}</li>")
1072
+ if len(durations) >= 2:
1073
+ extras.append(f"<li><strong>Duration Stddev:</strong> {stddev(durations):.1f}s</li>")
1074
+ if not extras:
1075
+ return ""
1076
+ return f'<ul style="margin-top:10px">{"".join(extras)}</ul>'
1077
+
1078
+
1079
+ def _variant_rich_sections(variant_id: str, result: ExperimentResult | None, run_dir: Path | None) -> str:
1080
+ """Render Generation Metrics, Token Usage, Command Telemetry, Agent Settings,
1081
+ Installed Tools, and Environment sections by loading per-task JSON from disk.
1082
+
1083
+ Aggregates across all tasks in the variant (matching the markdown reporter).
1084
+ Returns "" when either ``result`` or ``run_dir`` is missing or no task data
1085
+ loads successfully.
1086
+ """
1087
+ if result is None or run_dir is None:
1088
+ return ""
1089
+
1090
+ from .analysis import calculate_command_statistics
1091
+ from .reports_stats import load_variant_eval_results
1092
+
1093
+ eval_results = load_variant_eval_results(run_dir, variant_id, result.task_summaries)
1094
+ if not eval_results:
1095
+ return ""
1096
+
1097
+ sections: list[str] = []
1098
+ sections.append(_render_variant_generation_metrics(eval_results))
1099
+ sections.append(_render_variant_token_usage(eval_results))
1100
+
1101
+ # Command Telemetry — aggregate turns across all variant tasks
1102
+ all_turns = [t for r in eval_results for t in r.iterations]
1103
+ if all_turns:
1104
+ stats = calculate_command_statistics(all_turns)
1105
+ if stats.total_commands > 0:
1106
+ sections.append(_render_command_stats(stats))
1107
+
1108
+ # Agent Settings and Environment are per-task snapshots — first task with
1109
+ # data wins, matching the markdown reporter's `first task` fallback.
1110
+ settings_result = next((r for r in eval_results if r.sdk_options or r.agent_config is not None), None)
1111
+ if settings_result is not None:
1112
+ sections.append(_render_agent_settings(settings_result))
1113
+
1114
+ sections.append(_render_variant_installed_tools(eval_results))
1115
+
1116
+ env_result = next((r for r in eval_results if r.environment_info), None)
1117
+ if env_result is not None:
1118
+ sections.append(_render_environment(env_result))
1119
+ return "".join(sections)
1120
+
1121
+
1122
+ def _render_variant_generation_metrics(eval_results: list[EvaluationResult]) -> str:
1123
+ """Aggregate generation metrics across all tasks in a variant."""
1124
+ if not eval_results:
1125
+ return ""
1126
+ total_tasks = len(eval_results)
1127
+ total_duration = sum(r.duration_seconds for r in eval_results)
1128
+ total_turns = sum(len(r.iterations) for r in eval_results)
1129
+ total_asst = sum(r.total_assistant_turns or 0 for r in eval_results)
1130
+ per_turn_latencies = [t.duration_seconds for r in eval_results for t in r.iterations]
1131
+ avg_turn = (sum(per_turn_latencies) / len(per_turn_latencies)) if per_turn_latencies else 0.0
1132
+ total_latency_fmt = _esc(_format_duration(total_duration))
1133
+ avg_turn_fmt = _esc(_format_duration(avg_turn))
1134
+ return f"""
1135
+ <h2>Generation Metrics</h2>
1136
+ <div class="card">
1137
+ <div class="grid">
1138
+ <div class="stat"><div class="label">Tasks</div><div class="value">{total_tasks}</div></div>
1139
+ <div class="stat"><div class="label">Total Latency</div><div class="value">{total_latency_fmt}</div></div>
1140
+ <div class="stat"><div class="label">Turns</div><div class="value">{total_turns}</div></div>
1141
+ <div class="stat"><div class="label">Assistant Turns</div><div class="value">{total_asst}</div></div>
1142
+ <div class="stat"><div class="label">Avg Turn Latency</div><div class="value">{avg_turn_fmt}</div></div>
1143
+ </div>
1144
+ </div>
1145
+ """
1146
+
1147
+
1148
+ def _render_variant_token_usage(eval_results: list[EvaluationResult]) -> str:
1149
+ """Aggregate token usage across all tasks in a variant."""
1150
+ usages = [r.total_token_usage for r in eval_results if r.total_token_usage is not None]
1151
+ if not usages:
1152
+ return ""
1153
+ input_tok = sum(u.uncached_input_tokens for u in usages)
1154
+ output_tok = sum(u.output_tokens for u in usages)
1155
+ cache_write = sum(u.cache_creation_input_tokens for u in usages)
1156
+ cache_read = sum(u.cache_read_input_tokens for u in usages)
1157
+ total = input_tok + output_tok + cache_write + cache_read
1158
+ costs = [u.total_cost_usd for u in usages if u.total_cost_usd is not None]
1159
+ cost_str = f"${sum(costs):.4f}" if costs else "N/A"
1160
+ return f"""
1161
+ <h2>Token Usage</h2>
1162
+ <div class="card">
1163
+ <div class="grid">
1164
+ <div class="stat"><div class="label">Input</div><div class="value">{input_tok:,}</div></div>
1165
+ <div class="stat"><div class="label">Output</div><div class="value">{output_tok:,}</div></div>
1166
+ <div class="stat"><div class="label">Cache Write</div><div class="value">{cache_write:,}</div></div>
1167
+ <div class="stat"><div class="label">Cache Read</div><div class="value">{cache_read:,}</div></div>
1168
+ <div class="stat"><div class="label">Total</div><div class="value">{total:,}</div></div>
1169
+ <div class="stat"><div class="label">Cost</div><div class="value">{_esc(cost_str)}</div></div>
1170
+ </div>
1171
+ </div>
1172
+ """
1173
+
1174
+
1175
+ def _render_variant_installed_tools(eval_results: list[EvaluationResult]) -> str:
1176
+ """Render Installed Tools rows aggregated from every task in the variant."""
1177
+ rows: list[str] = []
1178
+ for r in eval_results:
1179
+ tools = r.environment_info.get("installed_tools") if r.environment_info else None
1180
+ if not tools or not isinstance(tools, dict):
1181
+ continue
1182
+ for name, ver in sorted(tools.items()):
1183
+ rows.append(
1184
+ f"<tr><td class='mono'>{_esc(r.task_id)}</td>"
1185
+ + f"<td class='mono'>{_esc(name)}</td>"
1186
+ + f"<td class='mono'>{_esc(ver)}</td></tr>"
1187
+ )
1188
+ if not rows:
1189
+ return ""
1190
+ return f"""
1191
+ <h2>Installed Tools</h2>
1192
+ <div class="card" style="padding:0">
1193
+ <table>
1194
+ <thead><tr><th>Task</th><th>Tool</th><th>Version</th></tr></thead>
1195
+ <tbody>{"".join(rows)}</tbody>
1196
+ </table>
1197
+ </div>
1198
+ """
1199
+
1200
+
1201
+ def _experiment_prompt_config(experiment: ExperimentDefinition | None, variant_ids: list[str]) -> str:
1202
+ """Render the Prompt Configuration section when the experiment definition
1203
+ actually specifies any mutations or overrides."""
1204
+ if experiment is None:
1205
+ return ""
1206
+ from .reports_stats import describe_prompt_config
1207
+
1208
+ has_config = bool(experiment.defaults and experiment.defaults.prompt_mutations) or any(
1209
+ v.prompt_mutations or v.initial_prompt or v.initial_prompt_file for v in experiment.variants
1210
+ )
1211
+ if not has_config:
1212
+ return ""
1213
+ variant_map = {v.variant_id: v for v in experiment.variants}
1214
+ items = []
1215
+ for vid in variant_ids:
1216
+ v = variant_map.get(vid)
1217
+ desc = describe_prompt_config(v) if v else "(unknown)"
1218
+ items.append(f"<li><strong>{_esc(vid)}</strong>: {_esc(desc)}</li>")
1219
+ return f"""
1220
+ <h2>Prompt Configuration</h2>
1221
+ <div class="card">
1222
+ <ul>{"".join(items)}</ul>
1223
+ </div>
1224
+ """
1225
+
1226
+
1227
+ def _collect_variant_series(result: ExperimentResult) -> dict[str, dict[str, list[float]]]:
1228
+ """Group per-variant numeric series (scores, durations, etc.)."""
1229
+ series: dict[str, dict[str, list[float]]] = {
1230
+ vid: {"scores": [], "durations": [], "asst_turns": [], "tokens": []} for vid in result.variant_ids
1231
+ }
1232
+ for ts in result.task_summaries:
1233
+ for vr in ts.variant_results:
1234
+ s = series.get(vr.variant_id)
1235
+ if s is None:
1236
+ continue
1237
+ s["scores"].append(vr.weighted_score)
1238
+ s["durations"].append(vr.duration_seconds)
1239
+ if vr.total_tokens is not None:
1240
+ s["tokens"].append(float(vr.total_tokens))
1241
+ if vr.total_assistant_turns is not None:
1242
+ s["asst_turns"].append(float(vr.total_assistant_turns))
1243
+ return series
1244
+
1245
+
1246
+ def _experiment_aggregate_metrics(result: ExperimentResult) -> str:
1247
+ """Render the Aggregate Metrics table (with p-values when exactly 2 variants)."""
1248
+ from .reports_stats import fmt_mean_sd, fmt_p, welch_t_test
1249
+
1250
+ show_p = len(result.variant_ids) == 2
1251
+ vid_a, vid_b = (result.variant_ids[0], result.variant_ids[1]) if show_p else ("", "")
1252
+
1253
+ header_cells = ["<th>Metric</th>"] + [f"<th>{_esc(vid)}</th>" for vid in result.variant_ids]
1254
+ if show_p:
1255
+ header_cells.append("<th>p-value</th>")
1256
+ header_html = "<tr>" + "".join(header_cells) + "</tr>"
1257
+
1258
+ series = _collect_variant_series(result)
1259
+
1260
+ def _row(label: str, values: list[str], p: str | None) -> str:
1261
+ cells = [f"<td>{_esc(label)}</td>"] + [f"<td>{_esc(v)}</td>" for v in values]
1262
+ if show_p:
1263
+ cells.append(f"<td>{_esc(p) if p is not None else '—'}</td>")
1264
+ return "<tr>" + "".join(cells) + "</tr>"
1265
+
1266
+ rows: list[str] = []
1267
+ rows.append(_row("Tasks Run", [str(result.variant_aggregates[vid].tasks_run) for vid in result.variant_ids], None))
1268
+ rows.append(
1269
+ _row(
1270
+ "Succeeded",
1271
+ [str(result.variant_aggregates[vid].tasks_succeeded) for vid in result.variant_ids],
1272
+ None,
1273
+ )
1274
+ )
1275
+ rows.append(_row("Failed", [str(result.variant_aggregates[vid].tasks_failed) for vid in result.variant_ids], None))
1276
+ rows.append(_row("Errors", [str(result.variant_aggregates[vid].tasks_error) for vid in result.variant_ids], None))
1277
+
1278
+ def _success_rate(vid: str) -> str:
1279
+ agg = result.variant_aggregates[vid]
1280
+ evaluable = agg.tasks_run - agg.tasks_error
1281
+ rate = (agg.tasks_succeeded / evaluable * 100) if evaluable > 0 else 0.0
1282
+ return f"{rate:.1f}%"
1283
+
1284
+ rows.append(_row("Success Rate", [_success_rate(vid) for vid in result.variant_ids], None))
1285
+
1286
+ rows.append(
1287
+ _row(
1288
+ "Score",
1289
+ [fmt_mean_sd(series[vid]["scores"]) for vid in result.variant_ids],
1290
+ fmt_p(welch_t_test(series[vid_a]["scores"], series[vid_b]["scores"])) if show_p else None,
1291
+ )
1292
+ )
1293
+ rows.append(
1294
+ _row(
1295
+ "Duration (s)",
1296
+ [fmt_mean_sd(series[vid]["durations"], ".1f") for vid in result.variant_ids],
1297
+ fmt_p(welch_t_test(series[vid_a]["durations"], series[vid_b]["durations"])) if show_p else None,
1298
+ )
1299
+ )
1300
+ if any(series[vid]["asst_turns"] for vid in result.variant_ids):
1301
+ rows.append(
1302
+ _row(
1303
+ "Assistant Turns",
1304
+ [fmt_mean_sd(series[vid]["asst_turns"], ".1f") for vid in result.variant_ids],
1305
+ fmt_p(welch_t_test(series[vid_a]["asst_turns"], series[vid_b]["asst_turns"])) if show_p else None,
1306
+ )
1307
+ )
1308
+ if any(series[vid]["tokens"] for vid in result.variant_ids):
1309
+ rows.append(
1310
+ _row(
1311
+ "Tokens",
1312
+ [fmt_mean_sd(series[vid]["tokens"], ",.0f") for vid in result.variant_ids],
1313
+ fmt_p(welch_t_test(series[vid_a]["tokens"], series[vid_b]["tokens"])) if show_p else None,
1314
+ )
1315
+ )
1316
+
1317
+ return f"""
1318
+ <h2>Aggregate Metrics</h2>
1319
+ <div class="card" style="padding:0">
1320
+ <table>
1321
+ <thead>{header_html}</thead>
1322
+ <tbody>{"".join(rows)}</tbody>
1323
+ </table>
1324
+ </div>
1325
+ """
1326
+
1327
+
1328
+ def _experiment_win_rates(result: ExperimentResult) -> str:
1329
+ """Render the Win Rates bullet list."""
1330
+ if not result.task_summaries:
1331
+ return ""
1332
+ total = len(result.task_summaries)
1333
+ win_counts: dict[str, int] = {vid: 0 for vid in result.variant_ids}
1334
+ tie_count = 0
1335
+ for ts in result.task_summaries:
1336
+ if ts.is_tie:
1337
+ tie_count += 1
1338
+ else:
1339
+ win_counts[ts.best_variant] = win_counts.get(ts.best_variant, 0) + 1
1340
+ items = []
1341
+ for vid in result.variant_ids:
1342
+ wins = win_counts.get(vid, 0)
1343
+ pct = wins / total * 100 if total else 0.0
1344
+ items.append(f"<li><strong>{_esc(vid)}</strong>: {wins}/{total} tasks ({pct:.0f}%)</li>")
1345
+ if tie_count > 0:
1346
+ pct = tie_count / total * 100
1347
+ items.append(f"<li><strong>Ties</strong>: {tie_count}/{total} tasks ({pct:.0f}%)</li>")
1348
+ return f"""
1349
+ <h2>Win Rates</h2>
1350
+ <div class="card">
1351
+ <ul>{"".join(items)}</ul>
1352
+ </div>
1353
+ """
1354
+
1355
+
1356
+ def _experiment_per_task_comparison(result: ExperimentResult) -> str:
1357
+ """Render the Per-Task Comparison table."""
1358
+ if not result.task_summaries:
1359
+ return ""
1360
+ header_cells = ["<th>Task</th>"] + [f"<th>{_esc(vid)}</th>" for vid in result.variant_ids]
1361
+ header_cells += ["<th>Best</th>", "<th>Spread</th>"]
1362
+ header = "<tr>" + "".join(header_cells) + "</tr>"
1363
+ rows: list[str] = []
1364
+ for ts in result.task_summaries:
1365
+ scores_by_variant = {vr.variant_id: vr for vr in ts.variant_results}
1366
+ cells = [f"<td class='mono'>{_esc(ts.task_id)}</td>"]
1367
+ for vid in result.variant_ids:
1368
+ vr = scores_by_variant.get(vid)
1369
+ if vr is None:
1370
+ cells.append("<td>N/A</td>")
1371
+ else:
1372
+ cells.append(f"<td>{vr.weighted_score:.3f} ({_esc(vr.final_status.icon)})</td>")
1373
+ best = "TIE" if ts.is_tie else ts.best_variant
1374
+ cells.append(f"<td>{_esc(best)}</td>")
1375
+ cells.append(f"<td>{ts.score_spread:.3f}</td>")
1376
+ rows.append("<tr>" + "".join(cells) + "</tr>")
1377
+ return f"""
1378
+ <h2>Per-Task Comparison</h2>
1379
+ <div class="card" style="padding:0">
1380
+ <table>
1381
+ <thead>{header}</thead>
1382
+ <tbody>{"".join(rows)}</tbody>
1383
+ </table>
1384
+ </div>
1385
+ """
1386
+
1387
+
1388
+ def _experiment_most_divergent(result: ExperimentResult) -> str:
1389
+ """Render the Most Divergent Tasks section — omitted when all spreads are 0."""
1390
+ if not result.task_summaries:
1391
+ return ""
1392
+ sorted_tasks = sorted(result.task_summaries, key=lambda t: t.score_spread, reverse=True)
1393
+ if not sorted_tasks or sorted_tasks[0].score_spread == 0:
1394
+ return ""
1395
+ items = [
1396
+ f"<li><strong>{_esc(ts.task_id)}</strong>: spread={ts.score_spread:.3f}, best={_esc(ts.best_variant)}</li>"
1397
+ for ts in sorted_tasks[:5]
1398
+ ]
1399
+ return f"""
1400
+ <h2>Most Divergent Tasks</h2>
1401
+ <div class="card">
1402
+ <ul>{"".join(items)}</ul>
1403
+ </div>
1404
+ """
1405
+
1406
+
1407
+ # ---------------------------------------------------------------------------
1408
+ # Public API
1409
+ # ---------------------------------------------------------------------------
1410
+
1411
+
1412
+ class HTMLReportGenerator:
1413
+ """Generates self-contained HTML reports from coder_eval run data."""
1414
+
1415
+ @staticmethod
1416
+ def generate_task_html(result: EvaluationResult) -> str:
1417
+ """Render a single task's EvaluationResult as a standalone HTML page.
1418
+
1419
+ The page shows the run header, success-criteria table, per-turn
1420
+ conversation trace with tool calls, command telemetry stats, and
1421
+ error details (if any). When ``result.error_log_tail`` is set, the
1422
+ captured tail is embedded under a "Logs" disclosure in the error
1423
+ section; otherwise the renderer falls back to the legacy
1424
+ ``error_details["stack_trace"]`` so archived runs predating the tail
1425
+ capture still surface diagnostics.
1426
+ """
1427
+ groups = _group_turns_by_iteration(result.iterations or [])
1428
+ turns_html = "".join(_render_iteration_group(it, group) for it, group in groups)
1429
+ if not turns_html:
1430
+ no_turn_msg = "No turn data (agent communication failed before producing any turns)."
1431
+ turns_html = f'<div class="card"><p class="muted">{no_turn_msg}</p></div>'
1432
+
1433
+ n_iterations = len(groups)
1434
+ n_attempts = len(result.iterations or [])
1435
+ has_partials = any(t.crashed for t in (result.iterations or []))
1436
+ iter_label = f"{n_iterations} iteration" + ("s" if n_iterations != 1 else "")
1437
+ if has_partials:
1438
+ attempts_label = f", {n_attempts} attempt" + ("s" if n_attempts != 1 else "")
1439
+ trace_count = f"{iter_label}{attempts_label}"
1440
+ else:
1441
+ trace_count = iter_label
1442
+
1443
+ body = (
1444
+ _render_header(result)
1445
+ + _render_simulation(result)
1446
+ + _render_criteria(result.success_criteria_results or [])
1447
+ + _render_judge_section(result.success_criteria_results or [])
1448
+ + _render_error_details(result)
1449
+ + f"<h2>Conversation Trace ({trace_count})</h2>"
1450
+ + turns_html
1451
+ + _render_command_stats(result.command_stats)
1452
+ + _render_generation_metrics(result)
1453
+ + _render_token_usage(result)
1454
+ + _render_commands_efficiency(result)
1455
+ + _render_agent_settings(result)
1456
+ + _render_installed_tools(result)
1457
+ + _render_environment(result)
1458
+ )
1459
+ title = f"coder_eval · {result.task_id} · {result.variant_id}"
1460
+ return _wrap_document(title, body)
1461
+
1462
+ @staticmethod
1463
+ def generate_variant_html(
1464
+ variant_id: str,
1465
+ agg: VariantAggregate,
1466
+ task_links: list[tuple[str, str, float | None, str]],
1467
+ result: ExperimentResult | None = None,
1468
+ run_dir: Path | None = None,
1469
+ ) -> str:
1470
+ """Render a variant-level summary page linking to per-task HTMLs.
1471
+
1472
+ Args:
1473
+ variant_id: The variant identifier.
1474
+ agg: VariantAggregate with summary metrics.
1475
+ task_links: List of (task_id, relative_html_path, score, status).
1476
+ result: Optional full ExperimentResult — enables stddev rows.
1477
+ run_dir: Optional top-level run dir — enables Generation Metrics,
1478
+ Token Usage, Command Telemetry, Agent Settings, Installed
1479
+ Tools, and Environment sections loaded from per-task JSON.
1480
+ """
1481
+ rows = "".join(
1482
+ f"""
1483
+ <tr>
1484
+ <td><a href="{_esc(link)}">{_esc(tid)}</a></td>
1485
+ <td style="text-align:center">{_score_pill(score) if score is not None else "—"}</td>
1486
+ <td>{_status_badge(status)}</td>
1487
+ </tr>
1488
+ """
1489
+ for tid, link, score, status in task_links
1490
+ )
1491
+ stddev_lines = _variant_stddev_lines(variant_id, result)
1492
+ rich_sections = _variant_rich_sections(variant_id, result, run_dir)
1493
+ budget_stats = ""
1494
+ if agg.tasks_token_budget_exceeded > 0:
1495
+ budget_stats += (
1496
+ f'<div class="stat"><div class="label">Token Budget</div>'
1497
+ f'<div class="value">{agg.tasks_token_budget_exceeded}</div></div>'
1498
+ )
1499
+ if agg.tasks_cost_budget_exceeded > 0:
1500
+ budget_stats += (
1501
+ f'<div class="stat"><div class="label">Cost Budget</div>'
1502
+ f'<div class="value">{agg.tasks_cost_budget_exceeded}</div></div>'
1503
+ )
1504
+ body = f"""
1505
+ <div class="header-bar">
1506
+ <div class="title-group">
1507
+ <h1>Variant: {_esc(variant_id)}</h1>
1508
+ <div class="subtitle">Aggregate summary for variant <code>{_esc(variant_id)}</code></div>
1509
+ </div>
1510
+ <div class="badges">
1511
+ {_score_pill(agg.average_score, suffix=" avg")}
1512
+ <span class="nav-toggle" onclick="toggleTheme()">Toggle theme</span>
1513
+ </div>
1514
+ </div>
1515
+ <div class="card">
1516
+ <div class="grid">
1517
+ <div class="stat"><div class="label">Tasks Run</div><div class="value">{agg.tasks_run}</div></div>
1518
+ <div class="stat"><div class="label">Succeeded</div><div class="value">{agg.tasks_succeeded}</div></div>
1519
+ <div class="stat"><div class="label">Failed</div><div class="value">{agg.tasks_failed}</div></div>
1520
+ <div class="stat"><div class="label">Errors</div><div class="value">{agg.tasks_error}</div></div>
1521
+ {budget_stats}
1522
+ </div>
1523
+ {stddev_lines}
1524
+ </div>
1525
+ <h2>Tasks</h2>
1526
+ <div class="card" style="padding:0">
1527
+ <table>
1528
+ <thead><tr><th>Task</th><th style="width:80px">Score</th><th style="width:140px">Status</th></tr></thead>
1529
+ <tbody>{rows or '<tr><td colspan="3" class="muted">No tasks</td></tr>'}</tbody>
1530
+ </table>
1531
+ </div>
1532
+ {rich_sections}
1533
+ """
1534
+ return _wrap_document(f"Variant {variant_id}", body)
1535
+
1536
+ @staticmethod
1537
+ def generate_experiment_html(
1538
+ result: ExperimentResult,
1539
+ experiment: ExperimentDefinition | None = None,
1540
+ variant_links: list[tuple[str, str]] | None = None,
1541
+ ) -> str:
1542
+ """Render an experiment-level index linking to each variant's page.
1543
+
1544
+ Matches ``ExperimentReportGenerator.generate_experiment_report`` in
1545
+ content: header, optional Prompt Configuration, Aggregate Metrics,
1546
+ Win Rates, Per-Task Comparison, Most Divergent Tasks.
1547
+ """
1548
+ exp_name = experiment.experiment_id if experiment else result.experiment_id
1549
+ exp_desc = experiment.description if experiment else ""
1550
+
1551
+ rows: list[str] = []
1552
+ for vid in result.variant_ids:
1553
+ agg = result.variant_aggregates.get(vid)
1554
+ link = next((ln for v, ln in (variant_links or []) if v == vid), f"{vid}/variant.html")
1555
+ if agg is None:
1556
+ continue
1557
+ rows.append(
1558
+ f"""
1559
+ <tr>
1560
+ <td><a href="{_esc(link)}">{_esc(vid)}</a></td>
1561
+ <td style="text-align:center">{_score_pill(agg.average_score)}</td>
1562
+ <td>{agg.tasks_succeeded}/{agg.tasks_run}</td>
1563
+ </tr>
1564
+ """
1565
+ )
1566
+ body = (
1567
+ f"""
1568
+ <div class="header-bar">
1569
+ <div class="title-group">
1570
+ <h1>Experiment: {_esc(exp_name)}</h1>
1571
+ <div class="subtitle">{_esc(exp_desc)}</div>
1572
+ </div>
1573
+ <div class="badges">
1574
+ <span class="badge neutral">{len(result.variant_ids)} variants</span>
1575
+ <span class="badge neutral">{_esc(_format_duration(result.total_duration_seconds))}</span>
1576
+ <span class="nav-toggle" onclick="toggleTheme()">Toggle theme</span>
1577
+ </div>
1578
+ </div>
1579
+ <h2>Variants</h2>
1580
+ <div class="card" style="padding:0">
1581
+ <table>
1582
+ <thead><tr><th>Variant</th><th style="width:100px">Avg Score</th><th style="width:120px">Pass Ratio</th></tr></thead>
1583
+ <tbody>{"".join(rows) or '<tr><td colspan="3" class="muted">No variants</td></tr>'}</tbody>
1584
+ </table>
1585
+ </div>
1586
+ """
1587
+ + _experiment_prompt_config(experiment, result.variant_ids)
1588
+ + _experiment_aggregate_metrics(result)
1589
+ + _experiment_win_rates(result)
1590
+ + _experiment_per_task_comparison(result)
1591
+ + _experiment_most_divergent(result)
1592
+ )
1593
+ return _wrap_document(f"Experiment {exp_name}", body)
1594
+
1595
+
1596
+ def safe_write(generate: Callable[[], str], output_path: Path, *, label: str) -> Path | None:
1597
+ """Render and write an HTML report. Log and return None on failure.
1598
+
1599
+ Used by the orchestrator and experiment writers so a render bug in one
1600
+ report never masks the underlying run outcome. The CLI regen path
1601
+ surfaces the ``None`` return as a per-task failure.
1602
+ """
1603
+ try:
1604
+ html_text = generate()
1605
+ output_path.parent.mkdir(parents=True, exist_ok=True)
1606
+ output_path.write_text(html_text, encoding="utf-8")
1607
+ return output_path
1608
+ except Exception:
1609
+ logger.exception("Failed to generate %s", label)
1610
+ return None
1611
+
1612
+
1613
+ def write_task_html(result: EvaluationResult, output_path: Path) -> Path | None:
1614
+ """Render a per-task HTML report via ``safe_write``.
1615
+
1616
+ The renderer is a pure function of ``result``; the captured
1617
+ ``error_log_tail`` (if any) drives the error section's "Logs" disclosure.
1618
+ """
1619
+ return safe_write(
1620
+ lambda: HTMLReportGenerator.generate_task_html(result),
1621
+ output_path,
1622
+ label=f"task.html for {result.task_id}",
1623
+ )
1624
+
1625
+
1626
+ def write_variant_html(
1627
+ variant_id: str,
1628
+ agg: VariantAggregate,
1629
+ task_links: list[tuple[str, str, float | None, str]],
1630
+ output_path: Path,
1631
+ *,
1632
+ result: ExperimentResult | None = None,
1633
+ run_dir: Path | None = None,
1634
+ ) -> Path | None:
1635
+ """Render a per-variant HTML report via ``safe_write``.
1636
+
1637
+ When ``result`` and ``run_dir`` are provided, the page also includes
1638
+ per-variant Generation Metrics, Token Usage, Command Telemetry, Agent
1639
+ Settings, Installed Tools, and Environment sections.
1640
+ """
1641
+ return safe_write(
1642
+ lambda: HTMLReportGenerator.generate_variant_html(variant_id, agg, task_links, result, run_dir),
1643
+ output_path,
1644
+ label=f"variant.html for {variant_id}",
1645
+ )
1646
+
1647
+
1648
+ def write_experiment_html(
1649
+ result: ExperimentResult,
1650
+ experiment: ExperimentDefinition | None,
1651
+ variant_links: list[tuple[str, str]] | None,
1652
+ output_path: Path,
1653
+ ) -> Path | None:
1654
+ """Render a per-experiment HTML report via ``safe_write``."""
1655
+ return safe_write(
1656
+ lambda: HTMLReportGenerator.generate_experiment_html(result, experiment, variant_links),
1657
+ output_path,
1658
+ label=f"experiment.html for {result.experiment_id}",
1659
+ )