Log in
scripts/regression_report.py 684 lines · 26.6 KB · python Blame
1
#!/usr/bin/env python3
2
"""Print a Markdown regression report for Oak benchmark JSONL files."""
3
4
from __future__ import annotations
5
6
import argparse
7
import json
8
import statistics
9
import sys
10
from dataclasses import dataclass
11
from pathlib import Path
12
from typing import Any, Iterable, Optional
13
14
from oakbench.reporting import (
15
    delta_ms,
16
    fmt_ms,
17
    fmt_num,
18
    fmt_pct,
19
    fmt_rate,
20
    lower_is_better_pct,
21
    median,
22
    percentile_nearest,
23
    slower_pct,
24
)
25
from oakbench.rows import (
26
    number_or_none,
27
    output_bytes as row_output_bytes,
28
    output_truncated as row_output_truncated,
29
    row_returncode,
30
    token_total as row_token_total,
31
    tool_calls as row_tool_calls,
32
)
33
34
35
SUBOP_SUFFIXES = (".add", ".commit")
36
SKIP_RETURNCODE = 77
37
38
39
@dataclass(frozen=True)
40
class Aggregate:
41
    subject: str
42
    scenario: str
43
    operation: str
44
    sample_count: int
45
    total_count: int
46
    failure_count: int
47
    skip_count: int
48
    p50_ms: Optional[float]
49
    p90_ms: Optional[float]
50
    avg_tokens: Optional[float]
51
    avg_tool_calls: Optional[float]
52
    avg_output_bytes: Optional[float]
53
    truncated_count: int
54
    avg_information_recall: Optional[float] = None
55
    pipe_compat_rate: Optional[float] = None
56
57
    @property
58
    def failure_rate(self) -> float:
59
        # Skip rows (returncode 77) are unmeasured coverage, not failures:
60
        # they leave both the numerator and the denominator (ADR-0002).
61
        measured = self.total_count - self.skip_count
62
        if measured <= 0:
63
            return 0.0
64
        return self.failure_count / measured
65
66
67
@dataclass(frozen=True)
68
class Regression:
69
    baseline_label: str
70
    baseline_subject: str
71
    target_subject: str
72
    scenario: str
73
    operation: str
74
    reasons: tuple[str, ...]
75
    target: Aggregate
76
    baseline: Aggregate
77
    p50_delta_ms: Optional[float]
78
    p50_slower_pct: Optional[float]
79
    p90_delta_ms: Optional[float]
80
    p90_slower_pct: Optional[float]
81
    failure_delta_pp: float
82
    severity: float
83
84
85
def parse_args() -> argparse.Namespace:
86
    parser = argparse.ArgumentParser(description=__doc__)
87
    parser.add_argument("jsonl", nargs="+", type=Path, help="One or more benchmark JSONL files")
88
    parser.add_argument("--target-subject", default="oak_local")
89
    parser.add_argument("--git-subject", default="git")
90
    parser.add_argument(
91
        "--oak-baseline",
92
        help="Default: oak_main when present, otherwise oak_installed",
93
    )
94
    parser.add_argument("--oak-threshold-pct", type=float, default=10.0)
95
    parser.add_argument("--git-threshold-pct", type=float, default=25.0)
96
    parser.add_argument("--min-delta-ms", type=float, default=5.0)
97
    parser.add_argument(
98
        "--failure-rate-threshold-pct",
99
        type=float,
100
        default=0.0,
101
        help="Allowed failure-rate increase in percentage points before flagging",
102
    )
103
    parser.add_argument(
104
        "--tool-calls-threshold-pct",
105
        type=float,
106
        default=0.0,
107
        help=(
108
            "Flag when target avg tool calls exceed the baseline by more than this percent. "
109
            "Tool calls are exact in the CLI harness, so the default flags any increase. "
110
            "Set negative to disable."
111
        ),
112
    )
113
    parser.add_argument(
114
        "--output-bytes-threshold-pct",
115
        type=float,
116
        default=25.0,
117
        help=(
118
            "Flag when target avg output bytes exceed the baseline by more than this percent. "
119
            "Output bytes are exact and drive agent context cost. Set negative to disable."
120
        ),
121
    )
122
    parser.add_argument(
123
        "--tokens-threshold-pct",
124
        type=float,
125
        default=-1.0,
126
        help=(
127
            "Flag when target avg tokens exceed the baseline by more than this percent. "
128
            "Disabled by default because char/4 estimates are advisory; gate on output bytes "
129
            "and tool calls (exact) instead, or set this once provider-reported rows dominate."
130
        ),
131
    )
132
    parser.add_argument("--include-subops", action="store_true")
133
    parser.add_argument("--limit", type=int, default=40, help="Maximum flagged regressions to print")
134
    parser.add_argument("--fail-on-regression", action="store_true")
135
    return parser.parse_args()
136
137
138
def is_suboperation(operation: str) -> bool:
139
    return operation.endswith(SUBOP_SUFFIXES)
140
141
142
def iter_rows(paths: Iterable[Path]) -> Iterable[tuple[Path, int, dict[str, Any]]]:
143
    for path in paths:
144
        with path.open() as fh:
145
            for line_number, raw_line in enumerate(fh, start=1):
146
                line = raw_line.strip()
147
                if not line:
148
                    continue
149
                try:
150
                    row = json.loads(line)
151
                except json.JSONDecodeError as exc:
152
                    raise SystemExit(f"{path}:{line_number}: invalid JSON: {exc}") from exc
153
                if not isinstance(row, dict):
154
                    raise SystemExit(f"{path}:{line_number}: expected a JSON object")
155
                yield path, line_number, row
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
def choose_oak_baseline(subjects: set[str], explicit: Optional[str]) -> Optional[str]:
172
    if explicit:
173
        return explicit if explicit in subjects else None
174
    if "oak_main" in subjects:
175
        return "oak_main"
176
    if "oak_installed" in subjects:
177
        return "oak_installed"
178
    return None
179
180
181
def choose_targets(
182
    subjects: set[str],
183
    target_subject: str,
184
    git_subject: str,
185
    oak_baseline: Optional[str],
186
) -> list[str]:
187
    if target_subject in subjects:
188
        return [target_subject]
189
    excluded = {git_subject}
190
    if oak_baseline:
191
        excluded.add(oak_baseline)
192
    return sorted(subject for subject in subjects if subject not in excluded)
193
194
195
def subject_order(subject: str, git_subject: str, oak_baseline: Optional[str], targets: list[str]) -> tuple[int, str]:
196
    if subject == git_subject:
197
        return (0, subject)
198
    if oak_baseline and subject == oak_baseline:
199
        return (1, subject)
200
    if subject in targets:
201
        return (2, subject)
202
    return (3, subject)
203
204
205
def build_aggregates(args: argparse.Namespace) -> tuple[
206
    dict[tuple[str, str, str], Aggregate],
207
    dict[str, Any],
208
]:
209
    samples: dict[tuple[str, str, str], list[float]] = {}
210
    token_samples: dict[tuple[str, str, str], list[float]] = {}
211
    tool_samples: dict[tuple[str, str, str], list[float]] = {}
212
    output_samples: dict[tuple[str, str, str], list[float]] = {}
213
    truncations: dict[tuple[str, str, str], int] = {}
214
    totals: dict[tuple[str, str, str], int] = {}
215
    failures: dict[tuple[str, str, str], int] = {}
216
    skips: dict[tuple[str, str, str], int] = {}
217
    recall_samples: dict[tuple[str, str, str], list[float]] = {}
218
    pipe_samples: dict[tuple[str, str, str], list[float]] = {}
219
    meta: dict[str, Any] = {
220
        "bench_ids": set(),
221
        "profiles": set(),
222
        "hosts": set(),
223
        "subjects": set(),
224
        "source_hashes": set(),
225
        "skipped": 0,
226
        "row_count": 0,
227
        "paths": [str(path) for path in args.jsonl],
228
    }
229
230
    for _, _, row in iter_rows(args.jsonl):
231
        meta["row_count"] += 1
232
        subject = str(row.get("subject") or "")
233
        scenario = str(row.get("scenario") or "")
234
        operation = str(row.get("operation") or "")
235
        if not subject or not scenario or not operation:
236
            meta["skipped"] += 1
237
            continue
238
        if not args.include_subops and is_suboperation(operation):
239
            continue
240
241
        key = (subject, scenario, operation)
242
        totals[key] = totals.get(key, 0) + 1
243
        meta["subjects"].add(subject)
244
        if row.get("bench_id"):
245
            meta["bench_ids"].add(str(row["bench_id"]))
246
        if row.get("profile"):
247
            meta["profiles"].add(str(row["profile"]))
248
        host_parts = [row.get("host"), row.get("platform"), row.get("machine")]
249
        if any(host_parts):
250
            meta["hosts"].add(" / ".join(str(part) for part in host_parts if part))
251
        source = row.get("source")
252
        if isinstance(source, dict) and source.get("oak_hash"):
253
            meta["source_hashes"].add(str(source["oak_hash"]))
254
255
        # Skip rows are recorded coverage gaps (returncode 77 + skipped):
256
        # they must not count as failures and must not feed any average β€”
257
        # a skipped Oak capability is unmeasured, not "0 tokens, 0 tools".
258
        returncode = row_returncode(row)
259
        if returncode == SKIP_RETURNCODE or row.get("skipped"):
260
            skips[key] = skips.get(key, 0) + 1
261
            continue
262
        if returncode != 0:
263
            failures[key] = failures.get(key, 0) + 1
264
            continue
265
266
        # Efficiency averages sample successful rows only: a failed command's
267
        # token/output cost is real but belongs to the failure metrics, not to
268
        # the "what does this operation cost when it works" trend line.
269
        token_total = row_token_total(row)
270
        if token_total is not None:
271
            token_samples.setdefault(key, []).append(token_total)
272
273
        tool_total = row_tool_calls(row)
274
        if tool_total is not None:
275
            tool_samples.setdefault(key, []).append(tool_total)
276
277
        output_total = row_output_bytes(row)
278
        if output_total is not None:
279
            output_samples.setdefault(key, []).append(output_total)
280
281
        if row_output_truncated(row):
282
            truncations[key] = truncations.get(key, 0) + 1
283
284
        # Output sufficiency from inforecall probe rows: recall of the
285
        # ground-truth changed set and piped-diff structural compatibility.
286
        recall = row.get("information_recall")
287
        if isinstance(recall, (int, float)) and not isinstance(recall, bool):
288
            recall_samples.setdefault(key, []).append(float(recall))
289
        pipe_compatible = row.get("pipe_compatible_unified")
290
        if isinstance(pipe_compatible, bool):
291
            pipe_samples.setdefault(key, []).append(1.0 if pipe_compatible else 0.0)
292
293
        try:
294
            elapsed_ms = float(row["elapsed_ms"])
295
        except (KeyError, TypeError, ValueError):
296
            meta["skipped"] += 1
297
            continue
298
        samples.setdefault(key, []).append(elapsed_ms)
299
300
    aggregates: dict[tuple[str, str, str], Aggregate] = {}
301
    for key in sorted(totals):
302
        subject, scenario, operation = key
303
        values = samples.get(key, [])
304
        aggregates[key] = Aggregate(
305
            subject=subject,
306
            scenario=scenario,
307
            operation=operation,
308
            sample_count=len(values),
309
            total_count=totals[key],
310
            failure_count=failures.get(key, 0),
311
            skip_count=skips.get(key, 0),
312
            p50_ms=median(values),
313
            p90_ms=percentile_nearest(values, 90.0),
314
            avg_tokens=statistics.mean(token_samples[key]) if key in token_samples else None,
315
            avg_tool_calls=statistics.mean(tool_samples[key]) if key in tool_samples else None,
316
            avg_output_bytes=statistics.mean(output_samples[key]) if key in output_samples else None,
317
            truncated_count=truncations.get(key, 0),
318
            avg_information_recall=statistics.mean(recall_samples[key]) if key in recall_samples else None,
319
            pipe_compat_rate=statistics.mean(pipe_samples[key]) if key in pipe_samples else None,
320
        )
321
    return aggregates, meta
322
323
324
def compare_target(
325
    aggregates: dict[tuple[str, str, str], Aggregate],
326
    target_subject: str,
327
    baseline_subject: str,
328
    baseline_label: str,
329
    threshold_pct: float,
330
    min_delta_ms: float,
331
    failure_threshold_pp: float,
332
    tool_calls_threshold_pct: float = -1.0,
333
    output_bytes_threshold_pct: float = -1.0,
334
    tokens_threshold_pct: float = -1.0,
335
) -> list[Regression]:
336
    regressions: list[Regression] = []
337
    scenario_ops = sorted({(scenario, operation) for _, scenario, operation in aggregates})
338
339
    for scenario, operation in scenario_ops:
340
        target = aggregates.get((target_subject, scenario, operation))
341
        baseline = aggregates.get((baseline_subject, scenario, operation))
342
        if target is None or baseline is None:
343
            continue
344
345
        p50_delta = delta_ms(baseline.p50_ms, target.p50_ms)
346
        p50_pct = slower_pct(baseline.p50_ms, target.p50_ms)
347
        p90_delta = delta_ms(baseline.p90_ms, target.p90_ms)
348
        p90_pct = slower_pct(baseline.p90_ms, target.p90_ms)
349
        failure_delta_pp = (target.failure_rate - baseline.failure_rate) * 100.0
350
351
        reasons: list[str] = []
352
        severities: list[float] = []
353
354
        if p50_delta is not None and p50_pct is not None:
355
            if p50_delta >= min_delta_ms and p50_pct >= threshold_pct:
356
                reasons.append("p50")
357
                severities.append(p50_pct / max(threshold_pct, 0.001))
358
359
        if p90_delta is not None and p90_pct is not None:
360
            if p90_delta >= min_delta_ms and p90_pct >= threshold_pct:
361
                reasons.append("p90")
362
                severities.append(p90_pct / max(threshold_pct, 0.001))
363
364
        if failure_delta_pp > failure_threshold_pp:
365
            reasons.append("failure-rate")
366
            severities.append(max(1.0, failure_delta_pp / max(failure_threshold_pp, 0.1)))
367
368
        # Agent-efficiency gates. Tool calls and output bytes are exact for the
369
        # CLI harness; token estimates are advisory unless explicitly enabled.
370
        efficiency_checks = (
371
            ("tool-calls", tool_calls_threshold_pct, baseline.avg_tool_calls, target.avg_tool_calls),
372
            ("output-bytes", output_bytes_threshold_pct, baseline.avg_output_bytes, target.avg_output_bytes),
373
            ("tokens", tokens_threshold_pct, baseline.avg_tokens, target.avg_tokens),
374
        )
375
        for reason, eff_threshold, base_value, target_value in efficiency_checks:
376
            if eff_threshold < 0:
377
                continue
378
            pct = lower_is_better_pct(base_value, target_value)
379
            if pct is not None and pct > eff_threshold:
380
                reasons.append(reason)
381
                severities.append(pct / max(eff_threshold, 1.0))
382
383
        # Output-sufficiency gates: exact semantic metrics, always on when
384
        # both sides measured them. Tokens and time can improve while
385
        # actionability regresses β€” fewer bytes that drop changed-file names
386
        # (recall) or break piped-diff structure (pipe-compat) is a
387
        # regression, not a win.
388
        if (
389
            target.avg_information_recall is not None
390
            and baseline.avg_information_recall is not None
391
            and target.avg_information_recall < baseline.avg_information_recall - 1e-6
392
        ):
393
            reasons.append("information-recall")
394
            severities.append(2.0)
395
        if (
396
            target.pipe_compat_rate is not None
397
            and baseline.pipe_compat_rate is not None
398
            and target.pipe_compat_rate < baseline.pipe_compat_rate - 1e-6
399
        ):
400
            reasons.append("pipe-compat")
401
            severities.append(2.0)
402
403
        if reasons:
404
            regressions.append(
405
                Regression(
406
                    baseline_label=baseline_label,
407
                    baseline_subject=baseline_subject,
408
                    target_subject=target_subject,
409
                    scenario=scenario,
410
                    operation=operation,
411
                    reasons=tuple(reasons),
412
                    target=target,
413
                    baseline=baseline,
414
                    p50_delta_ms=p50_delta,
415
                    p50_slower_pct=p50_pct,
416
                    p90_delta_ms=p90_delta,
417
                    p90_slower_pct=p90_pct,
418
                    failure_delta_pp=failure_delta_pp,
419
                    severity=max(severities) if severities else 0.0,
420
                )
421
            )
422
423
    return regressions
424
425
426
def markdown_report(args: argparse.Namespace) -> tuple[str, bool]:
427
    aggregates, meta = build_aggregates(args)
428
    subjects = set(meta["subjects"])
429
    oak_baseline = choose_oak_baseline(subjects, args.oak_baseline)
430
    targets = choose_targets(subjects, args.target_subject, args.git_subject, oak_baseline)
431
432
    regressions: list[Regression] = []
433
    for target in targets:
434
        if args.git_subject in subjects and target != args.git_subject:
435
            regressions.extend(
436
                compare_target(
437
                    aggregates,
438
                    target,
439
                    args.git_subject,
440
                    "Git",
441
                    args.git_threshold_pct,
442
                    args.min_delta_ms,
443
                    args.failure_rate_threshold_pct,
444
                )
445
            )
446
        if oak_baseline and target != oak_baseline:
447
            # Efficiency gates apply to the Oak-baseline comparison only: the
448
            # release-blocking question is "did this changeset make Oak worse",
449
            # not "does Oak emit more text than Git" (tracked separately).
450
            regressions.extend(
451
                compare_target(
452
                    aggregates,
453
                    target,
454
                    oak_baseline,
455
                    "Oak baseline",
456
                    args.oak_threshold_pct,
457
                    args.min_delta_ms,
458
                    args.failure_rate_threshold_pct,
459
                    tool_calls_threshold_pct=args.tool_calls_threshold_pct,
460
                    output_bytes_threshold_pct=args.output_bytes_threshold_pct,
461
                    tokens_threshold_pct=args.tokens_threshold_pct,
462
                )
463
            )
464
465
    regressions.sort(
466
        key=lambda item: (
467
            "failure-rate" not in item.reasons,
468
            -item.severity,
469
            -(item.p90_delta_ms or 0.0),
470
            item.scenario,
471
            item.operation,
472
        )
473
    )
474
475
    lines: list[str] = [
476
        "# Oak Benchmark Regression Report",
477
        "",
478
        (
479
            "Positive comparison percentages mean the target subject is higher than the baseline; "
480
            "for time that is slower, and for tokens/tool calls that is more expensive."
481
        ),
482
        "",
483
        f"- Inputs: {', '.join(f'`{path}`' for path in meta['paths'])}",
484
        f"- Rows read: {meta['row_count']}",
485
        f"- Bench IDs: {format_set(meta['bench_ids'])}",
486
        f"- Profiles: {format_set(meta['profiles'])}",
487
        f"- Source hashes: {format_set(meta['source_hashes'])}",
488
        f"- Hosts: {format_set(meta['hosts'])}",
489
        f"- Target subjects: {', '.join(f'`{subject}`' for subject in targets) or '`none`'}",
490
        f"- Git baseline: `{args.git_subject}`" if args.git_subject in subjects else f"- Git baseline: `{args.git_subject}` missing",
491
        f"- Oak baseline: `{oak_baseline}`" if oak_baseline else "- Oak baseline: missing",
492
        (
493
            f"- Thresholds: Oak {args.oak_threshold_pct:.1f}%, Git {args.git_threshold_pct:.1f}%, "
494
            f"minimum {args.min_delta_ms:.1f} ms, failure delta {args.failure_rate_threshold_pct:.1f} pp"
495
        ),
496
        (
497
            "- Efficiency gates vs Oak baseline (exact metrics): tool calls "
498
            + (f"+{args.tool_calls_threshold_pct:.1f}%" if args.tool_calls_threshold_pct >= 0 else "off")
499
            + ", output bytes "
500
            + (f"+{args.output_bytes_threshold_pct:.1f}%" if args.output_bytes_threshold_pct >= 0 else "off")
501
            + ", tokens (advisory estimate) "
502
            + (f"+{args.tokens_threshold_pct:.1f}%" if args.tokens_threshold_pct >= 0 else "off")
503
        ),
504
    ]
505
506
    if meta["skipped"]:
507
        lines.append(f"- Skipped malformed rows: {meta['skipped']}")
508
509
    lines.extend(["", "## Status", ""])
510
    if regressions:
511
        lines.append(f"Regressions flagged: {len(regressions)}")
512
    else:
513
        lines.append("No regressions exceeded the configured thresholds.")
514
515
    lines.extend(
516
        [
517
            "",
518
            "## Subject Averages",
519
            "",
520
            (
521
                "Token values are provider-reported when present; otherwise they are benchmark-estimated "
522
                "from normalized transcript characters or direct CLI output."
523
            ),
524
            "Output bytes are full stdout/stderr byte counts. Truncated rows indicate admitted text was capped.",
525
            "",
526
            "| Subject | Avg ms/op | Avg tokens/op | Avg output bytes/op | Avg tool calls/op | Tokens vs Git | Tokens vs Oak | Tools vs Git | Tools vs Oak |",
527
            "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
528
        ]
529
    )
530
    ordered_subjects = sorted(
531
        subjects,
532
        key=lambda subject: subject_order(subject, args.git_subject, oak_baseline, targets),
533
    )
534
    git_avg_tokens = weighted_subject_average(aggregates, args.git_subject, "avg_tokens")
535
    git_avg_tools = weighted_subject_average(aggregates, args.git_subject, "avg_tool_calls")
536
    oak_avg_tokens = weighted_subject_average(aggregates, oak_baseline, "avg_tokens") if oak_baseline else None
537
    oak_avg_tools = weighted_subject_average(aggregates, oak_baseline, "avg_tool_calls") if oak_baseline else None
538
    for subject in ordered_subjects:
539
        avg_time = weighted_subject_average(aggregates, subject, "p50_ms")
540
        avg_tokens = weighted_subject_average(aggregates, subject, "avg_tokens")
541
        avg_output = weighted_subject_average(aggregates, subject, "avg_output_bytes")
542
        avg_tools = weighted_subject_average(aggregates, subject, "avg_tool_calls")
543
        lines.append(
544
            "| "
545
            f"`{subject}` | "
546
            f"{fmt_ms(avg_time)} | "
547
            f"{fmt_num(avg_tokens)} | "
548
            f"{fmt_num(avg_output, 0)} | "
549
            f"{fmt_num(avg_tools, 2)} | "
550
            f"{fmt_pct(lower_is_better_pct(git_avg_tokens, avg_tokens)) if subject != args.git_subject else ''} | "
551
            f"{fmt_pct(lower_is_better_pct(oak_avg_tokens, avg_tokens)) if subject != oak_baseline and subject != args.git_subject else ''} | "
552
            f"{fmt_pct(lower_is_better_pct(git_avg_tools, avg_tools)) if subject != args.git_subject else ''} | "
553
            f"{fmt_pct(lower_is_better_pct(oak_avg_tools, avg_tools)) if subject != oak_baseline and subject != args.git_subject else ''} |"
554
        )
555
556
    if regressions:
557
        lines.extend(
558
            [
559
                "",
560
                "## Flagged Regressions",
561
                "",
562
            "| Baseline | Subject | Scenario / operation | Reason | p50 ms | Baseline p50 | p50 slower | p90 ms | Baseline p90 | p90 slower | Failure delta |",
563
                "| --- | --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
564
            ]
565
        )
566
        for item in regressions[: args.limit]:
567
            lines.append(
568
                "| "
569
                f"{item.baseline_label} `{item.baseline_subject}` | "
570
                f"`{item.target_subject}` | "
571
                f"`{item.scenario}/{item.operation}` | "
572
                f"{', '.join(item.reasons)} | "
573
                f"{fmt_ms(item.target.p50_ms)} | "
574
                f"{fmt_ms(item.baseline.p50_ms)} | "
575
                f"{fmt_pct(item.p50_slower_pct)} | "
576
                f"{fmt_ms(item.target.p90_ms)} | "
577
                f"{fmt_ms(item.baseline.p90_ms)} | "
578
                f"{fmt_pct(item.p90_slower_pct)} | "
579
                f"{item.failure_delta_pp:+.1f} pp |"
580
            )
581
        if len(regressions) > args.limit:
582
            lines.append(f"\n_Only the first {args.limit} regressions are shown._")
583
584
    lines.extend(
585
        [
586
            "",
587
            "## Metrics",
588
            "",
589
            "| Scenario / operation | Subject | n | Failures | Skips | Failure rate | p50 ms | p90 ms | Avg tokens | Avg output bytes | Trunc rows | Avg tools | vs Git p50 | vs Git tokens | vs Git tools | vs Oak p50 | vs Oak tokens | vs Oak tools |",
590
            "| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
591
        ]
592
    )
593
594
    scenario_ops = sorted({(scenario, operation) for _, scenario, operation in aggregates})
595
596
    for scenario, operation in scenario_ops:
597
        git_base = aggregates.get((args.git_subject, scenario, operation))
598
        oak_base = aggregates.get((oak_baseline, scenario, operation)) if oak_baseline else None
599
        for subject in ordered_subjects:
600
            aggregate = aggregates.get((subject, scenario, operation))
601
            if aggregate is None:
602
                continue
603
            vs_git = ""
604
            if git_base and subject != args.git_subject:
605
                vs_git = fmt_pct(slower_pct(git_base.p50_ms, aggregate.p50_ms))
606
            vs_oak = ""
607
            if oak_base and subject != oak_baseline:
608
                vs_oak = fmt_pct(slower_pct(oak_base.p50_ms, aggregate.p50_ms))
609
            vs_git_tokens = ""
610
            if git_base and subject != args.git_subject:
611
                vs_git_tokens = fmt_pct(lower_is_better_pct(git_base.avg_tokens, aggregate.avg_tokens))
612
            vs_git_tools = ""
613
            if git_base and subject != args.git_subject:
614
                vs_git_tools = fmt_pct(lower_is_better_pct(git_base.avg_tool_calls, aggregate.avg_tool_calls))
615
            vs_oak_tokens = ""
616
            if oak_base and subject != oak_baseline and subject != args.git_subject:
617
                vs_oak_tokens = fmt_pct(lower_is_better_pct(oak_base.avg_tokens, aggregate.avg_tokens))
618
            vs_oak_tools = ""
619
            if oak_base and subject != oak_baseline and subject != args.git_subject:
620
                vs_oak_tools = fmt_pct(lower_is_better_pct(oak_base.avg_tool_calls, aggregate.avg_tool_calls))
621
            lines.append(
622
                "| "
623
                f"`{scenario}/{operation}` | "
624
                f"`{subject}` | "
625
                f"{aggregate.sample_count} | "
626
                f"{aggregate.failure_count} | "
627
                f"{aggregate.skip_count} | "
628
                f"{fmt_rate(aggregate.failure_rate)} | "
629
                f"{fmt_ms(aggregate.p50_ms)} | "
630
                f"{fmt_ms(aggregate.p90_ms)} | "
631
                f"{fmt_num(aggregate.avg_tokens)} | "
632
                f"{fmt_num(aggregate.avg_output_bytes, 0)} | "
633
                f"{aggregate.truncated_count} | "
634
                f"{fmt_num(aggregate.avg_tool_calls, 2)} | "
635
                f"{vs_git} | "
636
                f"{vs_git_tokens} | "
637
                f"{vs_git_tools} | "
638
                f"{vs_oak} | "
639
                f"{vs_oak_tokens} | "
640
                f"{vs_oak_tools} |"
641
            )
642
643
    return "\n".join(lines) + "\n", bool(regressions)
644
645
646
def format_set(values: set[str]) -> str:
647
    if not values:
648
        return "`none`"
649
    return ", ".join(f"`{value}`" for value in sorted(values))
650
651
652
653
def weighted_subject_average(
654
    aggregates: dict[tuple[str, str, str], Aggregate],
655
    subject: str,
656
    attr: str,
657
) -> Optional[float]:
658
    total_weight = 0
659
    total_value = 0.0
660
    for aggregate in aggregates.values():
661
        if aggregate.subject != subject:
662
            continue
663
        value = getattr(aggregate, attr)
664
        if value is None:
665
            continue
666
        weight = max(aggregate.total_count, 1)
667
        total_weight += weight
668
        total_value += float(value) * weight
669
    if total_weight == 0:
670
        return None
671
    return total_value / total_weight
672
673
674
def main() -> int:
675
    args = parse_args()
676
    report, has_regressions = markdown_report(args)
677
    sys.stdout.write(report)
678
    if has_regressions and args.fail_on_regression:
679
        return 1
680
    return 0
681
682
683
if __name__ == "__main__":
684
    raise SystemExit(main())