evalbar 0.1.0__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.
evalbar/__init__.py ADDED
@@ -0,0 +1,34 @@
1
+ """evalbar: run an LLM pipeline over test cases, trace tokens/latency/cost, and score it."""
2
+
3
+ from .dataset import Case, DatasetError, load_dataset
4
+ from .diff import RunDiff, check_gates, diff_runs
5
+ from .llm import completion
6
+ from .report import load_run, render, save_run, summarize
7
+ from .runner import CaseResult, Eval, RunResult, run
8
+ from .scorers import Score, Scorer
9
+ from .trace import Trace, current_trace, record_llm_call, record_tool_call, traced_tool
10
+
11
+ __all__ = [
12
+ "Case",
13
+ "CaseResult",
14
+ "DatasetError",
15
+ "Eval",
16
+ "RunDiff",
17
+ "RunResult",
18
+ "Score",
19
+ "Scorer",
20
+ "Trace",
21
+ "check_gates",
22
+ "completion",
23
+ "current_trace",
24
+ "diff_runs",
25
+ "load_dataset",
26
+ "load_run",
27
+ "record_llm_call",
28
+ "record_tool_call",
29
+ "render",
30
+ "run",
31
+ "save_run",
32
+ "summarize",
33
+ "traced_tool",
34
+ ]
evalbar/cli.py ADDED
@@ -0,0 +1,181 @@
1
+ """Command line: `evalbar run`, `show`, `diff` and `html`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import importlib.util
7
+ import os
8
+ import sys
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+
12
+ from rich.console import Console
13
+
14
+ from .diff import check_gates, diff_renderable, diff_runs, render_markdown
15
+ from .html import render_html
16
+ from .report import load_run, report_renderable, save_run
17
+ from .runner import CaseResult, Eval, run
18
+
19
+
20
+ class UsageError(Exception):
21
+ pass
22
+
23
+
24
+ def main(argv: list[str] | None = None) -> int:
25
+ parser = argparse.ArgumentParser(prog="evalbar", description="Run, trace and score LLM pipelines.")
26
+ commands = parser.add_subparsers(dest="command", required=True)
27
+
28
+ run_cmd = commands.add_parser("run", help="run an Eval defined in a Python file")
29
+ run_cmd.add_argument("target", help="path/to/eval.py or path/to/eval.py:eval_variable")
30
+ run_cmd.add_argument("--out", help="run file to write (default: runs/<name>-<timestamp>.json)")
31
+ run_cmd.add_argument("--limit", type=int, help="only run the first N cases (smoke test)")
32
+ run_cmd.add_argument("--quiet", action="store_true", help="no per-case progress lines")
33
+ run_cmd.add_argument("--env-file", default=".env",
34
+ help="KEY=VALUE file loaded before the eval (default: .env if present); "
35
+ "variables already set win")
36
+
37
+ show_cmd = commands.add_parser("show", help="print the report for a saved run file")
38
+ show_cmd.add_argument("run_file")
39
+
40
+ html_cmd = commands.add_parser("html", help="write a self-contained HTML report for a run file")
41
+ html_cmd.add_argument("run_file")
42
+ html_cmd.add_argument("--out", help="HTML file to write (default: the run file with .html)")
43
+
44
+ diff_cmd = commands.add_parser("diff", help="compare two run files; exit 1 when a gate fails")
45
+ diff_cmd.add_argument("base", help="baseline run file (e.g. committed on main)")
46
+ diff_cmd.add_argument("head", help="new run file")
47
+ diff_cmd.add_argument("--fail-on-drop", type=float, metavar="FRACTION",
48
+ help="fail when a scorer's pass rate drops by more than this (0.02 = 2 points)")
49
+ diff_cmd.add_argument("--scorer", action="append", metavar="NAME",
50
+ help="only gate on this scorer (repeatable; default: all shared scorers)")
51
+ diff_cmd.add_argument("--max-cost-increase", type=float, metavar="FRACTION",
52
+ help="fail when cost per case rises by more than this (0.2 = 20%%)")
53
+ diff_cmd.add_argument("--format", choices=["text", "markdown"], default="text",
54
+ help="markdown suits $GITHUB_STEP_SUMMARY and PR comments")
55
+
56
+ args = parser.parse_args(argv)
57
+ console = Console()
58
+ try:
59
+ if args.command == "run":
60
+ return _run(args, console)
61
+ if args.command == "diff":
62
+ return _diff(args, console)
63
+ if args.command == "html":
64
+ return _html(args, console)
65
+ return _show(args, console)
66
+ except UsageError as exc:
67
+ print(f"evalbar: {exc}", file=sys.stderr)
68
+ return 2
69
+
70
+
71
+ def _run(args: argparse.Namespace, console: Console) -> int:
72
+ load_env_file(args.env_file)
73
+ evaluation = load_eval(args.target)
74
+ total = min(len(evaluation.dataset), args.limit) if args.limit is not None else len(evaluation.dataset)
75
+ progress = {"done": 0}
76
+
77
+ def on_case(result: CaseResult) -> None:
78
+ progress["done"] += 1
79
+ if args.quiet:
80
+ return
81
+ failed = [score.name for score in result.scores if not score.passed]
82
+ status = "ERROR" if result.error else ("fail: " + ", ".join(failed) if failed else "ok")
83
+ print(f"[{progress['done']}/{total}] {result.case_id} {result.latency_s:.2f}s {status}", file=sys.stderr)
84
+
85
+ result = run(evaluation, limit=args.limit, on_case=on_case)
86
+ out = args.out or f"runs/{evaluation.name}-{datetime.now():%Y%m%d-%H%M%S}.json"
87
+ path = save_run(result, out)
88
+ console.print(report_renderable(result))
89
+ console.print(f"run file: {path}", style="dim")
90
+ return 0
91
+
92
+
93
+ def _show(args: argparse.Namespace, console: Console) -> int:
94
+ path = Path(args.run_file)
95
+ if not path.is_file():
96
+ raise UsageError(f"run file not found: {path}")
97
+ console.print(report_renderable(load_run(path)))
98
+ return 0
99
+
100
+
101
+ def _html(args: argparse.Namespace, console: Console) -> int:
102
+ path = Path(args.run_file)
103
+ if not path.is_file():
104
+ raise UsageError(f"run file not found: {path}")
105
+ out = Path(args.out) if args.out else path.with_suffix(".html")
106
+ out.parent.mkdir(parents=True, exist_ok=True)
107
+ out.write_text(render_html(load_run(path)), encoding="utf-8")
108
+ console.print(f"wrote {out}")
109
+ return 0
110
+
111
+
112
+ def _diff(args: argparse.Namespace, console: Console) -> int:
113
+ runs = []
114
+ for run_file in (args.base, args.head):
115
+ path = Path(run_file)
116
+ if not path.is_file():
117
+ raise UsageError(f"run file not found: {path}")
118
+ runs.append(load_run(path))
119
+ diff = diff_runs(*runs)
120
+ try:
121
+ failures = check_gates(diff, fail_on_drop=args.fail_on_drop, scorers=args.scorer,
122
+ max_cost_increase=args.max_cost_increase)
123
+ except ValueError as exc:
124
+ raise UsageError(str(exc)) from exc
125
+
126
+ if args.format == "markdown":
127
+ print(render_markdown(diff, failures), end="")
128
+ else:
129
+ console.print(diff_renderable(diff))
130
+ for failure in failures:
131
+ print(f"FAIL: {failure}", file=sys.stderr)
132
+ return 1 if failures else 0
133
+
134
+
135
+ def load_env_file(path: str) -> None:
136
+ """Set variables from a KEY=VALUE file without overriding the environment. Values are never printed."""
137
+ env_path = Path(path)
138
+ if not env_path.is_file():
139
+ return
140
+ for line in env_path.read_text(encoding="utf-8").splitlines():
141
+ line = line.strip()
142
+ if not line or line.startswith("#") or "=" not in line:
143
+ continue
144
+ key, _, value = line.removeprefix("export ").partition("=")
145
+ value = value.strip()
146
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
147
+ value = value[1:-1]
148
+ os.environ.setdefault(key.strip(), value)
149
+
150
+
151
+ def load_eval(target: str) -> Eval:
152
+ """Import a Python file and return the Eval named after ':' (or its only Eval)."""
153
+ file_part, _, name = target.partition(":") if target.count(":") == 1 else (target, "", "")
154
+ path = Path(file_part)
155
+ if not path.is_file():
156
+ raise UsageError(f"eval file not found: {path}")
157
+
158
+ module_name = f"evalbar_user_{abs(hash(path.resolve()))}"
159
+ spec = importlib.util.spec_from_file_location(module_name, path)
160
+ if spec is None or spec.loader is None:
161
+ raise UsageError(f"cannot import {path}")
162
+ module = importlib.util.module_from_spec(spec)
163
+ sys.path.insert(0, str(path.resolve().parent))
164
+ try:
165
+ spec.loader.exec_module(module)
166
+ finally:
167
+ sys.path.pop(0)
168
+
169
+ evals = {key: value for key, value in vars(module).items() if isinstance(value, Eval)}
170
+ if name:
171
+ if name not in evals:
172
+ raise UsageError(f"{path} has no Eval named '{name}' (found: {', '.join(evals) or 'none'})")
173
+ return evals[name]
174
+ if len(evals) != 1:
175
+ found = ", ".join(evals) or "none"
176
+ raise UsageError(f"{path} defines {len(evals)} Evals ({found}); pick one with {path}:<name>")
177
+ return next(iter(evals.values()))
178
+
179
+
180
+ if __name__ == "__main__":
181
+ raise SystemExit(main())
evalbar/dataset.py ADDED
@@ -0,0 +1,78 @@
1
+ """Load test cases from JSONL or YAML files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import yaml
11
+
12
+
13
+ class DatasetError(ValueError):
14
+ """A dataset file is malformed. The message names the file and, for JSONL, the line."""
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Case:
19
+ id: str
20
+ input: Any
21
+ expected: dict[str, Any] = field(default_factory=dict)
22
+ metadata: dict[str, Any] = field(default_factory=dict)
23
+
24
+
25
+ def load_dataset(path: str | Path) -> list[Case]:
26
+ path = Path(path)
27
+ suffix = path.suffix.lower()
28
+ if suffix == ".jsonl":
29
+ records = _read_jsonl(path)
30
+ elif suffix in (".yaml", ".yml"):
31
+ records = _read_yaml(path)
32
+ else:
33
+ raise DatasetError(f"{path}: unsupported dataset format '{suffix}' (use .jsonl, .yaml or .yml)")
34
+
35
+ cases: list[Case] = []
36
+ seen: set[str] = set()
37
+ for where, record in records:
38
+ case = _to_case(path, where, record)
39
+ if case.id in seen:
40
+ raise DatasetError(f"{path} {where}: duplicate id '{case.id}'")
41
+ seen.add(case.id)
42
+ cases.append(case)
43
+ return cases
44
+
45
+
46
+ def _read_jsonl(path: Path) -> list[tuple[str, Any]]:
47
+ records = []
48
+ for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
49
+ if not line.strip():
50
+ continue
51
+ try:
52
+ records.append((f"line {number}", json.loads(line)))
53
+ except json.JSONDecodeError as exc:
54
+ raise DatasetError(f"{path} line {number}: invalid JSON ({exc.msg})") from exc
55
+ return records
56
+
57
+
58
+ def _read_yaml(path: Path) -> list[tuple[str, Any]]:
59
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
60
+ if isinstance(data, dict):
61
+ data = data.get("cases")
62
+ if not isinstance(data, list):
63
+ raise DatasetError(f"{path}: expected a list of cases or a mapping with a 'cases' list")
64
+ return [(f"case {index}", record) for index, record in enumerate(data, start=1)]
65
+
66
+
67
+ def _to_case(path: Path, where: str, record: Any) -> Case:
68
+ if not isinstance(record, dict):
69
+ raise DatasetError(f"{path} {where}: each case must be an object")
70
+ if "id" not in record:
71
+ raise DatasetError(f"{path} {where}: missing 'id'")
72
+ if "input" not in record:
73
+ raise DatasetError(f"{path} {where}: missing 'input'")
74
+ expected = record.get("expected") or {}
75
+ metadata = record.get("metadata") or {}
76
+ if not isinstance(expected, dict) or not isinstance(metadata, dict):
77
+ raise DatasetError(f"{path} {where}: 'expected' and 'metadata' must be objects")
78
+ return Case(id=str(record["id"]), input=record["input"], expected=expected, metadata=metadata)
evalbar/diff.py ADDED
@@ -0,0 +1,265 @@
1
+ """Compare two runs case by case and decide whether a CI gate passes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ from collections.abc import Sequence
7
+ from dataclasses import dataclass
8
+
9
+ from rich import box
10
+ from rich.console import Console, Group
11
+ from rich.table import Table
12
+ from rich.text import Text
13
+
14
+ from .report import format_cost, percentile
15
+ from .runner import CaseResult, RunResult
16
+
17
+ _EPSILON = 1e-9
18
+ _MAX_IDS = 10
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class ScorerDiff:
23
+ name: str
24
+ n: int
25
+ base_passed: int
26
+ head_passed: int
27
+ regressions: list[str]
28
+ fixes: list[str]
29
+
30
+ @property
31
+ def base_rate(self) -> float:
32
+ return self.base_passed / self.n if self.n else 0.0
33
+
34
+ @property
35
+ def head_rate(self) -> float:
36
+ return self.head_passed / self.n if self.n else 0.0
37
+
38
+ @property
39
+ def delta(self) -> float:
40
+ return self.head_rate - self.base_rate
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class RunDiff:
45
+ base_name: str
46
+ head_name: str
47
+ n_common: int
48
+ scorers: list[ScorerDiff]
49
+ added_cases: list[str]
50
+ removed_cases: list[str]
51
+ new_errors: list[str]
52
+ fixed_errors: list[str]
53
+ base_cost_per_case: float | None
54
+ head_cost_per_case: float | None
55
+ base_tokens_per_case: float
56
+ head_tokens_per_case: float
57
+ base_llm_latency_p50_s: float
58
+ head_llm_latency_p50_s: float
59
+
60
+ @property
61
+ def cost_change(self) -> float | None:
62
+ """Relative change in cost per case, or None when either side has unpriced calls."""
63
+ if self.base_cost_per_case is None or self.head_cost_per_case is None or not self.base_cost_per_case:
64
+ return None
65
+ return self.head_cost_per_case / self.base_cost_per_case - 1
66
+
67
+
68
+ def diff_runs(base: RunResult, head: RunResult) -> RunDiff:
69
+ """Compare on the cases both runs share, so a changed dataset cannot fake a gain."""
70
+ base_cases = {case.case_id: case for case in base.cases}
71
+ head_cases = {case.case_id: case for case in head.cases}
72
+ common = [case_id for case_id in base_cases if case_id in head_cases]
73
+ pairs = [(base_cases[case_id], head_cases[case_id]) for case_id in common]
74
+
75
+ scorer_names = [name for name in base.scorer_names if name in head.scorer_names]
76
+ scorers = [_diff_scorer(name, pairs) for name in scorer_names]
77
+
78
+ return RunDiff(
79
+ base_name=base.name,
80
+ head_name=head.name,
81
+ n_common=len(common),
82
+ scorers=scorers,
83
+ added_cases=[case_id for case_id in head_cases if case_id not in base_cases],
84
+ removed_cases=[case_id for case_id in base_cases if case_id not in head_cases],
85
+ new_errors=[b.case_id for b, h in pairs if b.error is None and h.error is not None],
86
+ fixed_errors=[b.case_id for b, h in pairs if b.error is not None and h.error is None],
87
+ base_cost_per_case=_cost_per_case([b for b, _ in pairs]),
88
+ head_cost_per_case=_cost_per_case([h for _, h in pairs]),
89
+ base_tokens_per_case=_tokens_per_case([b for b, _ in pairs]),
90
+ head_tokens_per_case=_tokens_per_case([h for _, h in pairs]),
91
+ base_llm_latency_p50_s=_llm_p50([b for b, _ in pairs]),
92
+ head_llm_latency_p50_s=_llm_p50([h for _, h in pairs]),
93
+ )
94
+
95
+
96
+ def check_gates(diff: RunDiff, *, fail_on_drop: float | None = None, scorers: Sequence[str] | None = None,
97
+ max_cost_increase: float | None = None) -> list[str]:
98
+ """Return one message per failed gate; an empty list means the gate passes."""
99
+ known = {scorer.name for scorer in diff.scorers}
100
+ for name in scorers or []:
101
+ if name not in known:
102
+ raise ValueError(f"unknown scorer '{name}' (in both runs: {', '.join(sorted(known)) or 'none'})")
103
+
104
+ failures = []
105
+ if fail_on_drop is not None:
106
+ for scorer in diff.scorers:
107
+ if scorers and scorer.name not in scorers:
108
+ continue
109
+ drop = scorer.base_rate - scorer.head_rate
110
+ if drop > fail_on_drop + _EPSILON:
111
+ cases = _ids(scorer.regressions)
112
+ failures.append(
113
+ f"{scorer.name} dropped {drop * 100:.1f} pts ({scorer.base_rate:.1%} -> {scorer.head_rate:.1%}),"
114
+ f" allowed {fail_on_drop * 100:.1f}; regressed: {cases}"
115
+ )
116
+ if max_cost_increase is not None:
117
+ failure = _cost_gate(diff, max_cost_increase)
118
+ if failure:
119
+ failures.append(failure)
120
+ return failures
121
+
122
+
123
+ def _cost_gate(diff: RunDiff, max_increase: float) -> str | None:
124
+ base, head = diff.base_cost_per_case, diff.head_cost_per_case
125
+ if base is None or head is None:
126
+ # A requested gate must not pass on missing data.
127
+ return "cost gate requested but cost per case is unknown (some LLM calls have no known price)"
128
+ if base == 0:
129
+ return f"cost per case rose from $0 to {format_cost(head)}" if head > 0 else None
130
+ change = head / base - 1
131
+ if change > max_increase + _EPSILON:
132
+ return (f"cost per case rose {change:.0%} ({format_cost(base)} -> {format_cost(head)}), "
133
+ f"allowed {max_increase:.0%}")
134
+ return None
135
+
136
+
137
+ def diff_renderable(diff: RunDiff) -> Group:
138
+ parts: list = [Text(f"evalbar diff: {diff.base_name} -> {diff.head_name} ({diff.n_common} shared cases)",
139
+ style="bold")]
140
+ if diff.added_cases or diff.removed_cases:
141
+ parts.append(Text(f" only in head: {_ids(diff.added_cases)}; only in base: {_ids(diff.removed_cases)}",
142
+ style="yellow"))
143
+
144
+ table = Table(title="Scores (pass rate)", title_justify="left", box=box.SIMPLE_HEAD)
145
+ table.add_column("scorer", no_wrap=True)
146
+ for column in ("n", "base", "head", "change", "down", "up"):
147
+ table.add_column(column, justify="right", no_wrap=True)
148
+ for scorer in diff.scorers:
149
+ style = "red" if scorer.delta < -_EPSILON else ("green" if scorer.delta > _EPSILON else "")
150
+ table.add_row(scorer.name, str(scorer.n), f"{scorer.base_rate:.1%}", f"{scorer.head_rate:.1%}",
151
+ Text(f"{scorer.delta * 100:+.1f}", style=style), str(len(scorer.regressions)),
152
+ str(len(scorer.fixes)))
153
+ parts.append(table)
154
+ for scorer in diff.scorers:
155
+ if scorer.regressions:
156
+ parts.append(Text(f"regressed {scorer.name}: {_ids(scorer.regressions)}", style="red"))
157
+ if scorer.fixes:
158
+ parts.append(Text(f"fixed {scorer.name}: {_ids(scorer.fixes)}", style="green"))
159
+
160
+ usage = Table(title="Usage per case", title_justify="left", box=box.SIMPLE_HEAD)
161
+ for column in ("metric", "base", "head", "change"):
162
+ usage.add_column(column, justify="left" if column == "metric" else "right")
163
+ for row in _usage_rows(diff):
164
+ usage.add_row(*row)
165
+ parts.append(usage)
166
+ if diff.new_errors:
167
+ parts.append(Text(f"new errors: {_ids(diff.new_errors)}", style="red"))
168
+ if diff.fixed_errors:
169
+ parts.append(Text(f"fixed errors: {_ids(diff.fixed_errors)}", style="green"))
170
+ return Group(*parts)
171
+
172
+
173
+ def render_diff(diff: RunDiff, width: int | None = None) -> str:
174
+ console = Console(file=io.StringIO(), width=width or 120, record=True, color_system=None)
175
+ console.print(diff_renderable(diff))
176
+ return console.export_text()
177
+
178
+
179
+ def render_markdown(diff: RunDiff, failures: Sequence[str] = ()) -> str:
180
+ """GitHub-flavoured markdown, e.g. for $GITHUB_STEP_SUMMARY or a PR comment."""
181
+ lines = [f"### evalbar diff: {diff.base_name} -> {diff.head_name}", "",
182
+ f"{diff.n_common} shared cases."]
183
+ if diff.added_cases or diff.removed_cases:
184
+ lines.append(f"Only in head: {_ids(diff.added_cases)}. Only in base: {_ids(diff.removed_cases)}.")
185
+ lines += ["", "| scorer | cases | base | head | change | regressed | fixed |", "|---|---|---|---|---|---|---|"]
186
+ for scorer in diff.scorers:
187
+ lines.append(f"| {scorer.name} | {scorer.n} | {scorer.base_rate:.1%} | {scorer.head_rate:.1%} | "
188
+ f"{scorer.delta * 100:+.1f} pts | {_ids(scorer.regressions)} | {_ids(scorer.fixes)} |")
189
+ lines += ["", "| per case | base | head | change |", "|---|---|---|---|"]
190
+ lines += [f"| {' | '.join(row)} |" for row in _usage_rows(diff)]
191
+ if diff.new_errors:
192
+ lines += ["", f"New errors: {_ids(diff.new_errors)}"]
193
+ lines.append("")
194
+ if failures:
195
+ lines.append("**Gate failed**")
196
+ lines += [f"- {failure}" for failure in failures]
197
+ else:
198
+ lines.append("Gate passed.")
199
+ return "\n".join(lines) + "\n"
200
+
201
+
202
+ def _diff_scorer(name: str, pairs: list[tuple[CaseResult, CaseResult]]) -> ScorerDiff:
203
+ n = base_passed = head_passed = 0
204
+ regressions, fixes = [], []
205
+ for base_case, head_case in pairs:
206
+ before = _passed(base_case, name)
207
+ after = _passed(head_case, name)
208
+ if before is None or after is None:
209
+ continue
210
+ n += 1
211
+ base_passed += before
212
+ head_passed += after
213
+ if before and not after:
214
+ regressions.append(base_case.case_id)
215
+ elif after and not before:
216
+ fixes.append(base_case.case_id)
217
+ return ScorerDiff(name, n, base_passed, head_passed, regressions, fixes)
218
+
219
+
220
+ def _passed(case: CaseResult, name: str) -> bool | None:
221
+ return next((score.passed for score in case.scores if score.name == name), None)
222
+
223
+
224
+ def _cost_per_case(cases: list[CaseResult]) -> float | None:
225
+ if not cases:
226
+ return None
227
+ costs = [case.trace.cost_usd for case in cases]
228
+ if any(cost is None for cost in costs):
229
+ return None
230
+ return sum(costs) / len(cases) # type: ignore[arg-type]
231
+
232
+
233
+ def _tokens_per_case(cases: list[CaseResult]) -> float:
234
+ if not cases:
235
+ return 0.0
236
+ return sum(case.trace.prompt_tokens + case.trace.completion_tokens for case in cases) / len(cases)
237
+
238
+
239
+ def _llm_p50(cases: list[CaseResult]) -> float:
240
+ return percentile(sorted(case.trace.llm_latency_s for case in cases if case.trace.pipeline_calls), 0.5)
241
+
242
+
243
+ def _usage_rows(diff: RunDiff) -> list[tuple[str, str, str, str]]:
244
+ def cost(value: float | None) -> str:
245
+ return "unknown" if value is None else format_cost(value)
246
+
247
+ def relative(before: float, after: float, floor: float = 0.0) -> str:
248
+ return f"{after / before - 1:+.0%}" if before > floor else ""
249
+
250
+ cost_change = "" if diff.cost_change is None else f"{diff.cost_change:+.0%}"
251
+ return [
252
+ ("cost (list price)", cost(diff.base_cost_per_case), cost(diff.head_cost_per_case), cost_change),
253
+ ("tokens", f"{diff.base_tokens_per_case:,.0f}", f"{diff.head_tokens_per_case:,.0f}",
254
+ relative(diff.base_tokens_per_case, diff.head_tokens_per_case)),
255
+ ("LLM latency p50", f"{diff.base_llm_latency_p50_s:.2f}s", f"{diff.head_llm_latency_p50_s:.2f}s",
256
+ # Sub-50 ms latencies are mocks or caches; a percentage of them is noise.
257
+ relative(diff.base_llm_latency_p50_s, diff.head_llm_latency_p50_s, floor=0.05)),
258
+ ]
259
+
260
+
261
+ def _ids(case_ids: Sequence[str]) -> str:
262
+ if not case_ids:
263
+ return "-"
264
+ shown = ", ".join(case_ids[:_MAX_IDS])
265
+ return shown if len(case_ids) <= _MAX_IDS else f"{shown} (+{len(case_ids) - _MAX_IDS} more)"