coder-eval 0.8.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- coder_eval/.gitattributes +2 -0
- coder_eval/__init__.py +3 -0
- coder_eval/agent.py +302 -0
- coder_eval/agents/__init__.py +41 -0
- coder_eval/agents/_logging.py +89 -0
- coder_eval/agents/antigravity_agent.py +811 -0
- coder_eval/agents/claude_code_agent.py +1701 -0
- coder_eval/agents/codex_agent.py +2055 -0
- coder_eval/agents/noop_agent.py +100 -0
- coder_eval/agents/registry.py +163 -0
- coder_eval/agents/watchdog.py +116 -0
- coder_eval/analysis.py +88 -0
- coder_eval/cli/__init__.py +87 -0
- coder_eval/cli/aggregate_command.py +153 -0
- coder_eval/cli/console.py +7 -0
- coder_eval/cli/evaluate_command.py +164 -0
- coder_eval/cli/plan_command.py +152 -0
- coder_eval/cli/report_command.py +121 -0
- coder_eval/cli/run_command.py +686 -0
- coder_eval/cli/run_helpers.py +137 -0
- coder_eval/cli/run_task_internal_command.py +210 -0
- coder_eval/cli/utils.py +37 -0
- coder_eval/config.py +212 -0
- coder_eval/criteria/__init__.py +163 -0
- coder_eval/criteria/_classification_aggregate.py +106 -0
- coder_eval/criteria/agent_judge.py +425 -0
- coder_eval/criteria/base.py +248 -0
- coder_eval/criteria/classification_match.py +107 -0
- coder_eval/criteria/command_executed.py +181 -0
- coder_eval/criteria/commands_efficiency.py +73 -0
- coder_eval/criteria/file_check.py +119 -0
- coder_eval/criteria/file_contains.py +85 -0
- coder_eval/criteria/file_exists.py +45 -0
- coder_eval/criteria/file_matches_regex.py +86 -0
- coder_eval/criteria/json_check.py +184 -0
- coder_eval/criteria/llm_judge.py +260 -0
- coder_eval/criteria/reference_comparison.py +130 -0
- coder_eval/criteria/run_command.py +220 -0
- coder_eval/criteria/skill_triggered.py +111 -0
- coder_eval/criteria/uipath_eval.py +223 -0
- coder_eval/errors/__init__.py +26 -0
- coder_eval/errors/agent.py +26 -0
- coder_eval/errors/budget.py +28 -0
- coder_eval/errors/categories.py +205 -0
- coder_eval/errors/categorization.py +240 -0
- coder_eval/errors/executor.py +124 -0
- coder_eval/errors/judge.py +12 -0
- coder_eval/errors/retry.py +211 -0
- coder_eval/errors/timeout.py +63 -0
- coder_eval/evaluation/__init__.py +10 -0
- coder_eval/evaluation/checker.py +260 -0
- coder_eval/evaluation/judge_anthropic.py +55 -0
- coder_eval/evaluation/judge_bedrock.py +114 -0
- coder_eval/evaluation/judge_context.py +497 -0
- coder_eval/evaluation/judge_models.py +53 -0
- coder_eval/evaluation/judge_persistence.py +291 -0
- coder_eval/evaluation/judge_usage.py +50 -0
- coder_eval/evaluation/sub_agent.py +231 -0
- coder_eval/evaluation/summaries.py +48 -0
- coder_eval/evaluation/verdict_tool.py +213 -0
- coder_eval/formatting.py +191 -0
- coder_eval/isolation/__init__.py +15 -0
- coder_eval/isolation/docker_runner.py +1253 -0
- coder_eval/logging_config.py +399 -0
- coder_eval/models/__init__.py +337 -0
- coder_eval/models/agent_config.py +373 -0
- coder_eval/models/container_paths.py +26 -0
- coder_eval/models/criteria.py +942 -0
- coder_eval/models/enums.py +121 -0
- coder_eval/models/experiment.py +314 -0
- coder_eval/models/judge.py +90 -0
- coder_eval/models/judge_defaults.py +11 -0
- coder_eval/models/limits.py +89 -0
- coder_eval/models/merge_strategy.py +132 -0
- coder_eval/models/mutations.py +95 -0
- coder_eval/models/results.py +787 -0
- coder_eval/models/routing.py +160 -0
- coder_eval/models/sandbox.py +371 -0
- coder_eval/models/tasks.py +631 -0
- coder_eval/models/telemetry.py +454 -0
- coder_eval/models/templates.py +108 -0
- coder_eval/orchestration/__init__.py +13 -0
- coder_eval/orchestration/batch.py +690 -0
- coder_eval/orchestration/config.py +111 -0
- coder_eval/orchestration/config_merge.py +431 -0
- coder_eval/orchestration/evaluation.py +94 -0
- coder_eval/orchestration/experiment.py +837 -0
- coder_eval/orchestration/overrides.py +206 -0
- coder_eval/orchestration/task_loader.py +437 -0
- coder_eval/orchestrator.py +2078 -0
- coder_eval/path_utils.py +79 -0
- coder_eval/plugins.py +81 -0
- coder_eval/pricing.py +169 -0
- coder_eval/py.typed +0 -0
- coder_eval/reports.py +1066 -0
- coder_eval/reports_experiment.py +761 -0
- coder_eval/reports_html.py +1659 -0
- coder_eval/reports_stats.py +250 -0
- coder_eval/resources/__init__.py +137 -0
- coder_eval/resources/default_experiment.yaml +85 -0
- coder_eval/resources/default_ignore_patterns.yaml +60 -0
- coder_eval/resources/tags.yaml +55 -0
- coder_eval/sandbox.py +1127 -0
- coder_eval/scoring/__init__.py +11 -0
- coder_eval/scoring/ast_similarity.py +38 -0
- coder_eval/scoring/complexity.py +115 -0
- coder_eval/scoring/quality.py +154 -0
- coder_eval/scoring/signature_similarity.py +57 -0
- coder_eval/scoring/similarity.py +103 -0
- coder_eval/scoring/token_similarity.py +44 -0
- coder_eval/simulation/__init__.py +27 -0
- coder_eval/simulation/termination.py +88 -0
- coder_eval/simulation/user_simulator.py +324 -0
- coder_eval/streaming/__init__.py +44 -0
- coder_eval/streaming/callbacks.py +57 -0
- coder_eval/streaming/collector.py +193 -0
- coder_eval/streaming/events.py +198 -0
- coder_eval/streaming/renderers.py +248 -0
- coder_eval/streaming/wire.py +115 -0
- coder_eval/telemetry.py +407 -0
- coder_eval/utils.py +517 -0
- coder_eval-0.8.2.dist-info/METADATA +242 -0
- coder_eval-0.8.2.dist-info/RECORD +127 -0
- coder_eval-0.8.2.dist-info/WHEEL +4 -0
- coder_eval-0.8.2.dist-info/entry_points.txt +5 -0
- coder_eval-0.8.2.dist-info/licenses/LICENSE +201 -0
- coder_eval-0.8.2.dist-info/licenses/NOTICE +18 -0
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
|