evalblink 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.
evalblink/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """evalblink — a tiny LLM benchmark runner over OpenRouter."""
2
+
3
+ __version__ = "0.1.0"
evalblink/analysis.py ADDED
@@ -0,0 +1,113 @@
1
+ """Derives insights from a finished run's raw results.
2
+
3
+ Pure functions over the ``results`` list that ``runner.run`` returns — no I/O, no
4
+ network. This module owns the *decisions* the reporter is forbidden to make:
5
+ which combo wins on quality, which wins on value, and how each combo's quality
6
+ breaks down per tag. The reporter only formats what ``summarize`` returns.
7
+
8
+ A "combo" is one ``model × prompt`` entry of ``results``; each carries a ``score``
9
+ (pass-rate %, ``success/total*100``) and a ``test_cases`` list whose entries hold
10
+ ``tags`` and ``match_result``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ # Any tag whose pass-rate falls below this gets flagged (README: "below 70%").
16
+ WARN_THRESHOLD = 70.0
17
+
18
+ # Floor on cost so best-value ranking never divides by zero (free models cost $0).
19
+ _COST_FLOOR = 1e-4
20
+
21
+
22
+ def tag_breakdown(combo) -> list[dict]:
23
+ """Per-tag pass-rate for one combo.
24
+
25
+ Returns ``[{"tag", "cases", "scored", "errors", "quality", "warn"}, ...]``. A
26
+ test case carrying several tags counts toward each of them. Quality is the
27
+ pass-rate over **scored** cases only (mean of ``match_result`` as 0/1);
28
+ pipeline failures (``match_score is None`` — a judge API/parse error) are
29
+ excluded from the denominator and tallied under ``errors`` rather than counted
30
+ as a real failure. A tag with no scored cases has ``quality=None`` and never
31
+ warns. Untagged cases are omitted. Sorted warnings-first, then by tag name.
32
+ """
33
+ passed: dict[str, int] = {}
34
+ scored: dict[str, int] = {}
35
+ counts: dict[str, int] = {}
36
+ for tc in combo["test_cases"]:
37
+ errored = tc.get("match_score") is None
38
+ for tag in tc.get("tags") or []:
39
+ counts[tag] = counts.get(tag, 0) + 1
40
+ if errored:
41
+ continue
42
+ scored[tag] = scored.get(tag, 0) + 1
43
+ if tc["match_result"]:
44
+ passed[tag] = passed.get(tag, 0) + 1
45
+
46
+ rows = []
47
+ for tag, n in counts.items():
48
+ n_scored = scored.get(tag, 0)
49
+ quality = passed.get(tag, 0) / n_scored * 100 if n_scored else None
50
+ warn = quality is not None and quality < WARN_THRESHOLD
51
+ rows.append(
52
+ {
53
+ "tag": tag,
54
+ "cases": n,
55
+ "scored": n_scored,
56
+ "errors": n - n_scored,
57
+ "quality": quality,
58
+ "warn": warn,
59
+ }
60
+ )
61
+ rows.sort(key=lambda r: (not r["warn"], r["tag"]))
62
+ return rows
63
+
64
+
65
+ def best_quality(results) -> dict:
66
+ """Combo with the highest score; ties broken by lower total cost."""
67
+ return max(results, key=lambda c: (c["score"], -c["total_cost"]))
68
+
69
+
70
+ def best_value(results) -> dict:
71
+ """Combo with the best score-per-dollar.
72
+
73
+ ``score / max(total_cost, _COST_FLOOR)`` — with free models (cost 0) the floor
74
+ makes this fall back to the highest-scoring combo. A documented heuristic; easy
75
+ to retune later.
76
+ """
77
+ return max(results, key=lambda c: c["score"] / max(c["total_cost"], _COST_FLOOR))
78
+
79
+
80
+ def _gate_score(combo) -> float:
81
+ """Pass rate (%) with unscored cases counted as failures."""
82
+ total = combo.get("total")
83
+ if total is None: # a hand-built combo without counts: its score is all we have
84
+ return combo["score"]
85
+ return combo.get("success", 0) / total * 100 if total else 0.0
86
+
87
+
88
+ def summarize(results, quality_threshold) -> dict | None:
89
+ """Single entry point: winners, the best combo's tag breakdown, and the gate.
90
+
91
+ ``passed`` is the CI/CD verdict against ``quality_threshold``. It is
92
+ fail-safe: ``gate_score`` is the best combo's pass rate with every unscored
93
+ (pipeline-error) case counted as a failure, so a run where most calls failed
94
+ can't pass on the few that were scored. When ``quality_threshold`` is
95
+ ``None`` no gate is configured, so ``passed`` is ``True``. Returns ``None`` when ``results`` is empty.
96
+ """
97
+ if not results:
98
+ return None
99
+ top = best_quality(results)
100
+ breakdown = tag_breakdown(top)
101
+ errors = sum(c.get("errors") or 0 for c in results)
102
+ gate_score = max(_gate_score(c) for c in results)
103
+ return {
104
+ "best_quality": top,
105
+ "best_value": best_value(results),
106
+ "tag_breakdown": breakdown,
107
+ "warnings": [r["tag"] for r in breakdown if r["warn"]],
108
+ # Run total (every combo), matching the end-of-run pipeline-error warning.
109
+ "errors": errors,
110
+ "gate_score": gate_score,
111
+ "passed": quality_threshold is None or gate_score >= quality_threshold,
112
+ "quality_threshold": quality_threshold,
113
+ }
evalblink/cache.py ADDED
@@ -0,0 +1,95 @@
1
+ """SHA256 file cache for OpenRouter responses.
2
+
3
+ A request is fully determined by its payload (model, messages, temperature,
4
+ max_tokens). We hash that payload and store the resulting completion under
5
+ ``.evalblink_cache/<sha256>.json`` so re-running an identical benchmark costs
6
+ nothing and skips the network entirely.
7
+
8
+ Two public functions: :func:`get` and :func:`set`. :func:`sha256_key` builds
9
+ the key from a request payload.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ import os
17
+ import tempfile
18
+
19
+ CACHE_DIR = ".evalblink_cache"
20
+
21
+
22
+ def sha256_key(payload: dict) -> str:
23
+ """Stable SHA256 of a request payload.
24
+
25
+ ``sort_keys`` makes the hash insensitive to dict ordering so the same
26
+ logical request always maps to the same key.
27
+ """
28
+ canonical = json.dumps(payload, sort_keys=True, ensure_ascii=False)
29
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
30
+
31
+
32
+ def _path(key: str) -> str:
33
+ return os.path.join(CACHE_DIR, f"{key}.json")
34
+
35
+
36
+ def get(key: str) -> dict | None:
37
+ """Return the cached value for ``key``, or ``None`` on a miss.
38
+
39
+ A corrupt, truncated or unreadable entry also reads as a miss (it is
40
+ overwritten on the next successful call) rather than aborting the run. The
41
+ caller still validates the entry's contents.
42
+ """
43
+ path = _path(key)
44
+ if not os.path.exists(path):
45
+ return None
46
+ try:
47
+ with open(path, encoding="utf-8") as f:
48
+ value = json.load(f)
49
+ except (OSError, ValueError, RecursionError):
50
+ return None
51
+ return value if isinstance(value, dict) and "response" in value else None
52
+
53
+
54
+ def set(key: str, value: dict) -> None:
55
+ """Persist ``value`` under ``key`` atomically.
56
+
57
+ Write to a unique temp file then ``os.replace`` (atomic on POSIX) so a
58
+ concurrent reader never sees a half-written file — the runner now populates
59
+ the cache from multiple worker threads, and two threads can race on the same
60
+ key when distinct test cases render to an identical request.
61
+ """
62
+ os.makedirs(CACHE_DIR, exist_ok=True)
63
+ fd, tmp = tempfile.mkstemp(dir=CACHE_DIR, suffix=".tmp")
64
+ try:
65
+ with os.fdopen(fd, "w") as f:
66
+ json.dump(value, f, indent=4)
67
+ os.replace(tmp, _path(key))
68
+ except BaseException:
69
+ # Never leave a stray temp file behind on failure.
70
+ if os.path.exists(tmp):
71
+ os.remove(tmp)
72
+ raise
73
+
74
+
75
+ def _is_file(name: str) -> bool:
76
+ return os.path.isfile(os.path.join(CACHE_DIR, name))
77
+
78
+
79
+ def stats() -> dict:
80
+ """Entry count and total size of the cache directory."""
81
+ if not os.path.isdir(CACHE_DIR):
82
+ return {"entries": 0, "size_bytes": 0}
83
+ files = [f for f in os.listdir(CACHE_DIR) if f.endswith(".json") and _is_file(f)]
84
+ size = sum(os.path.getsize(os.path.join(CACHE_DIR, f)) for f in files)
85
+ return {"entries": len(files), "size_bytes": size}
86
+
87
+
88
+ def clear() -> int:
89
+ """Delete all cached responses. Returns number of files removed."""
90
+ if not os.path.isdir(CACHE_DIR):
91
+ return 0
92
+ files = [f for f in os.listdir(CACHE_DIR) if f.endswith(".json") and _is_file(f)]
93
+ for f in files:
94
+ os.remove(os.path.join(CACHE_DIR, f))
95
+ return len(files)
evalblink/compare.py ADDED
@@ -0,0 +1,404 @@
1
+ """Diffs two finished run records into a per-combo quality/cost delta.
2
+
3
+ Pure functions over the JSON records the reporter persists — no network, no
4
+ scoring. Like ``analysis``, this module owns *decisions* (which combos changed,
5
+ by how much, and the overall verdict); the reporter only formats what ``diff``
6
+ returns. A combo is keyed by ``(model, prompt_id)``; each carries a ``score``
7
+ (pass-rate %) and ``total_cost``.
8
+
9
+ Latency is intentionally absent: the runner does not capture it, so comparing it
10
+ would be inventing data. Quality and cost only.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+
17
+ from .openrouter import is_number
18
+
19
+ # Score deltas smaller than this (in percentage points) read as "stable" — keeps
20
+ # floating-point noise from showing as spurious ↑/↓.
21
+ _SCORE_EPS = 0.05
22
+
23
+
24
+ class RecordError(ValueError):
25
+ """A run record that can't be read or isn't the shape ``reporter.write`` persists."""
26
+
27
+
28
+ def _record_problem(record) -> str | None:
29
+ """What makes ``record`` unusable by report / compare / history, or ``None``.
30
+
31
+ Checks exactly the fields those commands read, with the types they need:
32
+ strings where they are sorted or used as keys/filenames, finite numbers where
33
+ they are formatted or subtracted.
34
+ """
35
+ if not isinstance(record, dict):
36
+ return f"expected a JSON object, got {type(record).__name__}"
37
+ for key in ("benchmark", "timestamp"):
38
+ if not isinstance(record.get(key), str):
39
+ return f"'{key}' is missing or not a string"
40
+ if record.get("run_id") is not None and not isinstance(record["run_id"], str):
41
+ return "'run_id' is not a string"
42
+ results = record.get("results")
43
+ if not isinstance(results, list):
44
+ return "'results' is missing or not a list"
45
+ for i, combo in enumerate(results):
46
+ where = f"results[{i}]"
47
+ if not isinstance(combo, dict):
48
+ return f"{where} is not an object"
49
+ for key in ("model", "prompt_id"):
50
+ if not isinstance(combo.get(key), str):
51
+ return f"{where}.{key} is missing or not a string"
52
+ for key in (
53
+ "score",
54
+ "total_cost",
55
+ "success",
56
+ "total",
57
+ "total_prompt_tokens",
58
+ "total_completion_tokens",
59
+ ):
60
+ if not is_number(combo.get(key)):
61
+ return f"{where}.{key} is missing or not a finite number"
62
+ for key in ("scored", "errors"):
63
+ if combo.get(key) is not None and not is_number(combo[key]):
64
+ return f"{where}.{key} is not a finite number"
65
+ cases = combo.get("test_cases")
66
+ if not isinstance(cases, list):
67
+ return f"{where}.test_cases is missing or not a list"
68
+ for j, tc in enumerate(cases):
69
+ here = f"{where}.test_cases[{j}]"
70
+ if not isinstance(tc, dict) or not isinstance(tc.get("id"), str):
71
+ return f"{here} is not an object with a string 'id'"
72
+ if "match_score" not in tc:
73
+ return f"{here}.match_score is missing"
74
+ if tc["match_score"] is not None and not is_number(tc["match_score"]):
75
+ return f"{here}.match_score is not a finite number"
76
+ if tc["match_score"] is not None and not isinstance(
77
+ tc.get("match_result"), bool
78
+ ):
79
+ return f"{here}.match_result is missing or not true/false"
80
+ if tc.get("status") is not None and not isinstance(tc["status"], str):
81
+ return f"{here}.status is not a string"
82
+ if not is_number(tc.get("total_cost")):
83
+ return f"{here}.total_cost is missing or not a finite number"
84
+ tags = tc.get("tags")
85
+ if tags is not None and not (
86
+ isinstance(tags, list) and all(isinstance(t, str) for t in tags)
87
+ ):
88
+ return f"{here}.tags is not a list of strings"
89
+ return None
90
+
91
+
92
+ def load_record(path) -> dict:
93
+ """Load and shape-check a persisted run record.
94
+
95
+ Raises :class:`RecordError` (with a one-line reason) for a missing,
96
+ unreadable, non-JSON or wrongly-shaped file, so every caller can fail
97
+ cleanly. A missing ``schema_version`` defaults to 0: records written before
98
+ the field existed are legacy version 0; ``diff`` still compares them rather
99
+ than hard-blocking.
100
+ """
101
+ try:
102
+ with open(path, encoding="utf-8") as f:
103
+ record = json.load(f)
104
+ except FileNotFoundError:
105
+ raise RecordError(f"Run record not found: {path}") from None
106
+ except OSError as exc: # a directory, no permission, ...
107
+ raise RecordError(
108
+ f"Cannot read run record {path}: {exc.strerror or exc}"
109
+ ) from None
110
+ except (ValueError, RecursionError) as exc: # not JSON / not UTF-8 / too deep
111
+ raise RecordError(f"{path} is not a valid JSON run record: {exc}") from None
112
+ problem = _record_problem(record)
113
+ if problem:
114
+ raise RecordError(f"{path} is not an evalblink run record: {problem}")
115
+ record.setdefault("schema_version", 0)
116
+ return record
117
+
118
+
119
+ def _index(record) -> dict:
120
+ """Map ``(model, prompt_id)`` → combo for one record's ``results``."""
121
+ return {(c["model"], c["prompt_id"]): c for c in record.get("results", [])}
122
+
123
+
124
+ def diff(record_a, record_b) -> dict:
125
+ """Per-combo deltas from run A to run B over the union of their combos.
126
+
127
+ Each row is ``{model, prompt_id, score_a, score_b, score_delta, cost_a,
128
+ cost_b, cost_delta, present_in}`` where ``present_in`` is
129
+ ``"both" | "a_only" | "b_only"``. Combos missing from one side carry ``None``
130
+ for that side's score/cost and a ``None`` delta. Rows are sorted with
131
+ changed-or-missing combos first, then by model/prompt name.
132
+ """
133
+ index_a = _index(record_a)
134
+ index_b = _index(record_b)
135
+ keys = sorted(set(index_a) | set(index_b))
136
+
137
+ rows = []
138
+ improved = 0
139
+ comparable = 0
140
+ for key in keys:
141
+ a = index_a.get(key)
142
+ b = index_b.get(key)
143
+ if a and b:
144
+ present_in = "both"
145
+ score_delta = b["score"] - a["score"]
146
+ cost_delta = b["total_cost"] - a["total_cost"]
147
+ comparable += 1
148
+ if score_delta > _SCORE_EPS:
149
+ improved += 1
150
+ elif b:
151
+ present_in = "b_only"
152
+ score_delta = cost_delta = None
153
+ else:
154
+ present_in = "a_only"
155
+ score_delta = cost_delta = None
156
+ rows.append(
157
+ {
158
+ "model": key[0],
159
+ "prompt_id": key[1],
160
+ "score_a": a["score"] if a else None,
161
+ "score_b": b["score"] if b else None,
162
+ "score_delta": score_delta,
163
+ "cost_a": a["total_cost"] if a else None,
164
+ "cost_b": b["total_cost"] if b else None,
165
+ "cost_delta": cost_delta,
166
+ "present_in": present_in,
167
+ }
168
+ )
169
+
170
+ rows.sort(
171
+ key=lambda r: (
172
+ r["present_in"] == "both" and _is_stable(r),
173
+ r["model"],
174
+ r["prompt_id"],
175
+ )
176
+ )
177
+
178
+ schema_a = record_a.get("schema_version", 0)
179
+ schema_b = record_b.get("schema_version", 0)
180
+ version_note = (
181
+ f"Record schema versions differ (A=v{schema_a}, B=v{schema_b}); "
182
+ "comparing on shared fields only."
183
+ if schema_a != schema_b
184
+ else None
185
+ )
186
+
187
+ return {
188
+ "run_a": record_a.get("run_id"),
189
+ "run_b": record_b.get("run_id"),
190
+ "schema_a": schema_a,
191
+ "schema_b": schema_b,
192
+ "version_note": version_note,
193
+ "rows": rows,
194
+ "verdict": _verdict(improved, comparable, rows),
195
+ }
196
+
197
+
198
+ def _is_stable(row) -> bool:
199
+ """Whether a both-sides row's quality is unchanged (for sort ordering)."""
200
+ delta = row["score_delta"]
201
+ return delta is not None and abs(delta) <= _SCORE_EPS
202
+
203
+
204
+ def _verdict(improved, comparable, rows) -> str:
205
+ """One-line summary of B versus A."""
206
+ only = [r for r in rows if r["present_in"] != "both"]
207
+ only_note = f" {len(only)} combo(s) present in only one run." if only else ""
208
+ if not comparable:
209
+ return "No combos in common between the two runs." + only_note
210
+ if improved == comparable:
211
+ return f"Run B improves quality on all {comparable} shared combo(s).{only_note}"
212
+ if improved == 0:
213
+ return f"Run B improves quality on no shared combo(s).{only_note}"
214
+ return (
215
+ f"Run B improves quality on {improved}/{comparable} shared combo(s).{only_note}"
216
+ )
217
+
218
+
219
+ # ---------------------------------------------------------------------------
220
+ # Test-level diff (the --detailed view)
221
+ # ---------------------------------------------------------------------------
222
+ #
223
+ # Outcome of a single case, read the same way analysis.py distinguishes a
224
+ # pipeline error from a real 0: ``match_score is None`` means the judge
225
+ # API/parse failed — it is an "error", never a real fail.
226
+
227
+
228
+ def _state(tc) -> str:
229
+ """One of ``"pass" | "fail" | "error"`` for a test-case result dict."""
230
+ if tc.get("match_score") is None:
231
+ return "error"
232
+ return "pass" if tc.get("match_result") else "fail"
233
+
234
+
235
+ def _classify_case(a, b) -> str:
236
+ """Transition label for a case present in both runs (A → B)."""
237
+ sa, sb = _state(a), _state(b)
238
+ if sa == "error" and sb == "error":
239
+ return "still_error"
240
+ if sa != "error" and sb == "error":
241
+ return "new_error"
242
+ if sa == "error" and sb != "error":
243
+ return "recovered"
244
+ # Both scored.
245
+ if sa == "pass" and sb == "fail":
246
+ return "regressed"
247
+ if sa == "fail" and sb == "pass":
248
+ return "improved"
249
+ return "unchanged"
250
+
251
+
252
+ # Transitions the reporter lists as a "change"; ``unchanged``/``still_error``
253
+ # collapse into the footer count.
254
+ CHANGED_TRANSITIONS = {
255
+ "regressed",
256
+ "improved",
257
+ "new_error",
258
+ "recovered",
259
+ "added",
260
+ "removed",
261
+ }
262
+
263
+
264
+ def case_diff(combo_a, combo_b) -> dict:
265
+ """Per-test-case transitions within one shared ``(model, prompt)`` combo.
266
+
267
+ Indexes each combo's ``test_cases`` by ``id`` and classifies the union. Each
268
+ row is ``{id, tags, transition, a_state, b_state, score_a, score_b,
269
+ score_delta, present_in}``; ``rollup`` tallies every transition kind.
270
+ """
271
+ by_id_a = {tc["id"]: tc for tc in combo_a.get("test_cases", [])}
272
+ by_id_b = {tc["id"]: tc for tc in combo_b.get("test_cases", [])}
273
+
274
+ rollup = {
275
+ k: 0
276
+ for k in (
277
+ "regressed",
278
+ "improved",
279
+ "unchanged",
280
+ "new_error",
281
+ "recovered",
282
+ "still_error",
283
+ "added",
284
+ "removed",
285
+ )
286
+ }
287
+ rows = []
288
+ for case_id in sorted(set(by_id_a) | set(by_id_b)):
289
+ a = by_id_a.get(case_id)
290
+ b = by_id_b.get(case_id)
291
+ if a and b:
292
+ transition = _classify_case(a, b)
293
+ present_in = "both"
294
+ sa, sb = _state(a), _state(b)
295
+ score_a, score_b = a.get("match_score"), b.get("match_score")
296
+ score_delta = (
297
+ score_b - score_a
298
+ if score_a is not None and score_b is not None
299
+ else None
300
+ )
301
+ tags = b.get("tags") or a.get("tags") or []
302
+ elif b:
303
+ transition, present_in = "added", "b_only"
304
+ sa, sb = None, _state(b)
305
+ score_a, score_b, score_delta = None, b.get("match_score"), None
306
+ tags = b.get("tags") or []
307
+ else:
308
+ assert a is not None # union guarantees the case is in A when not in B
309
+ transition, present_in = "removed", "a_only"
310
+ sa, sb = _state(a), None
311
+ score_a, score_b, score_delta = a.get("match_score"), None, None
312
+ tags = a.get("tags") or []
313
+ rollup[transition] += 1
314
+ rows.append(
315
+ {
316
+ "id": case_id,
317
+ "tags": tags,
318
+ "transition": transition,
319
+ "a_state": sa,
320
+ "b_state": sb,
321
+ "score_a": score_a,
322
+ "score_b": score_b,
323
+ "score_delta": score_delta,
324
+ "present_in": present_in,
325
+ }
326
+ )
327
+ return {"rows": rows, "rollup": rollup}
328
+
329
+
330
+ def detailed_diff(record_a, record_b) -> dict:
331
+ """Combo diff plus a per-case breakdown and a global summary.
332
+
333
+ Extends :func:`diff` with ``case_combos`` (one entry per *shared* combo,
334
+ carrying its :func:`case_diff`) and ``summary`` — change totals + verdict,
335
+ the cases that regressed across the most combos, and net change per tag.
336
+ """
337
+ result = diff(record_a, record_b)
338
+ index_a = _index(record_a)
339
+ index_b = _index(record_b)
340
+
341
+ case_combos = []
342
+ totals = {"regressed": 0, "improved": 0, "new_error": 0, "recovered": 0}
343
+ regressed_by_id: dict[str, int] = {}
344
+ tag_net: dict[str, int] = {}
345
+
346
+ for row in result["rows"]:
347
+ if row["present_in"] != "both":
348
+ continue
349
+ key = (row["model"], row["prompt_id"])
350
+ cd = case_diff(index_a[key], index_b[key])
351
+ case_combos.append({"model": key[0], "prompt_id": key[1], **cd})
352
+ for k in totals:
353
+ totals[k] += cd["rollup"][k]
354
+ for case_row in cd["rows"]:
355
+ if case_row["transition"] == "regressed":
356
+ regressed_by_id[case_row["id"]] = (
357
+ regressed_by_id.get(case_row["id"], 0) + 1
358
+ )
359
+ for tag in case_row["tags"]:
360
+ tag_net[tag] = tag_net.get(tag, 0) - 1
361
+ elif case_row["transition"] == "improved":
362
+ for tag in case_row["tags"]:
363
+ tag_net[tag] = tag_net.get(tag, 0) + 1
364
+
365
+ worst_regressed = [
366
+ {"id": case_id, "count": n}
367
+ for case_id, n in sorted(
368
+ regressed_by_id.items(), key=lambda kv: (-kv[1], kv[0])
369
+ )
370
+ ]
371
+ tag_net_rows = [
372
+ {"tag": tag, "net": net}
373
+ for tag, net in sorted(tag_net.items(), key=lambda kv: (kv[1], kv[0]))
374
+ ]
375
+
376
+ result["case_combos"] = case_combos
377
+ result["summary"] = {
378
+ **totals,
379
+ "net": totals["improved"] - totals["regressed"],
380
+ "worst_regressed": worst_regressed,
381
+ "tag_net": tag_net_rows,
382
+ "verdict": _case_verdict(totals),
383
+ }
384
+ return result
385
+
386
+
387
+ def _case_verdict(totals) -> str:
388
+ """One-line synthesis of the test-level change counts."""
389
+ net = totals["improved"] - totals["regressed"]
390
+ if not any(totals.values()):
391
+ return "No test-case changes across shared combos."
392
+ if net > 0:
393
+ direction = f"net +{net} case(s) improved"
394
+ elif net < 0:
395
+ direction = f"net {net} case(s) regressed"
396
+ else:
397
+ direction = "no net quality change"
398
+ err = ""
399
+ if totals["new_error"]:
400
+ err = f"; {totals['new_error']} new pipeline error(s)"
401
+ return (
402
+ f"Run B: {totals['improved']} improved, {totals['regressed']} regressed "
403
+ f"({direction}){err}."
404
+ )