poolprobe 1.0.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.
poolprobe/report.py ADDED
@@ -0,0 +1,1096 @@
1
+ """Rendering: terminal table, JSON payload, and a self-contained HTML page.
2
+
3
+ One dataset, three renderers, no logic in any of them. Every number shown here
4
+ was measured by :mod:`poolprobe.bench` or :mod:`poolprobe.probe`; the renderers
5
+ only decide how to lay it out and where a caveat belongs.
6
+
7
+ Two honesty rules the renderers enforce:
8
+
9
+ * Throughput from SSE chunk counts is always prefixed with ``>=`` and titled as
10
+ a lower bound. Throughput with no token evidence at all renders as ``-``,
11
+ never as ``0.0 tok/s``, because a zero reads like a measurement.
12
+ * A burst history renders as one glyph per attempt, so "recovered on attempt 3"
13
+ is visible in the table instead of being folded into a green tick.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import html as html_module
19
+ import json
20
+ import os
21
+ import shutil
22
+ import sys
23
+ from dataclasses import asdict, dataclass, field
24
+ from datetime import datetime, timezone
25
+
26
+ from . import taxonomy
27
+
28
+ # --- palette ----------------------------------------------------------------
29
+
30
+ _CODES = {
31
+ "reset": "0",
32
+ "bold": "1",
33
+ "dim": "2",
34
+ "red": "31",
35
+ "green": "32",
36
+ "yellow": "33",
37
+ "blue": "34",
38
+ "magenta": "35",
39
+ "cyan": "36",
40
+ "white": "37",
41
+ }
42
+
43
+ VERDICT_STYLE = {
44
+ taxonomy.OK: ("green", "bold"),
45
+ taxonomy.EMPTY_LENGTH: ("yellow",),
46
+ taxonomy.EMPTY: ("red",),
47
+ taxonomy.ERR: ("red", "bold"),
48
+ taxonomy.TIMEOUT: ("magenta",),
49
+ }
50
+
51
+ KIND_STYLE = {
52
+ taxonomy.ALIVE: ("green",),
53
+ taxonomy.ALIVE_STARVED: ("yellow",),
54
+ taxonomy.PERMANENT: ("red", "bold"),
55
+ taxonomy.TRANSIENT: ("cyan",),
56
+ taxonomy.CREDENTIAL: ("yellow",),
57
+ taxonomy.MODALITY: ("magenta",),
58
+ taxonomy.UNKNOWN: ("white", "dim"),
59
+ }
60
+
61
+ #: One glyph per verdict, so a burst history fits in a few columns.
62
+ GLYPHS = {
63
+ taxonomy.OK: "\u2713",
64
+ taxonomy.EMPTY_LENGTH: "\u25d0",
65
+ taxonomy.EMPTY: "\u2205",
66
+ taxonomy.ERR: "\u2717",
67
+ taxonomy.TIMEOUT: "\u23f1",
68
+ }
69
+ ASCII_GLYPHS = {
70
+ taxonomy.OK: "o",
71
+ taxonomy.EMPTY_LENGTH: "~",
72
+ taxonomy.EMPTY: "0",
73
+ taxonomy.ERR: "x",
74
+ taxonomy.TIMEOUT: "T",
75
+ }
76
+
77
+ # Separators and marks as constants: Python 3.10 refuses a backslash inside the
78
+ # expression part of an f-string, and these are all non-ASCII.
79
+ _SEP = " \u00b7 "
80
+ _ARROW = " \u2192 "
81
+ _ELLIPSIS = "\u2026"
82
+ _CROSS = "\u2717"
83
+ _DASH = " \u2014 "
84
+
85
+
86
+ def _terminal_wants_color(stream: object) -> bool:
87
+ """NO_COLOR wins, then FORCE_COLOR, then "is this actually a tty"."""
88
+ if os.environ.get("NO_COLOR"):
89
+ return False
90
+ if os.environ.get("FORCE_COLOR"):
91
+ return True
92
+ if os.environ.get("TERM") == "dumb":
93
+ return False
94
+ return bool(getattr(stream, "isatty", lambda: False)())
95
+
96
+
97
+ def _terminal_is_unicode(stream: object) -> bool:
98
+ """Glyph check, not a locale sermon: an unknown encoding on POSIX is
99
+ assumed capable, on Windows it is assumed not to be."""
100
+ encoding = str(getattr(stream, "encoding", "") or "").lower()
101
+ if "utf" in encoding:
102
+ return True
103
+ return encoding in ("", "ansi_x3.4-1968") and sys.platform != "win32"
104
+
105
+
106
+ @dataclass
107
+ class Palette:
108
+ """Colour and glyph decisions in one place, so renderers stay declarative."""
109
+
110
+ color: bool = False
111
+ unicode: bool = True
112
+
113
+ @classmethod
114
+ def detect(cls, stream: object | None = None) -> "Palette":
115
+ out = stream if stream is not None else sys.stdout
116
+ return cls(color=_terminal_wants_color(out), unicode=_terminal_is_unicode(out))
117
+
118
+ def paint(self, text: str, *styles: str) -> str:
119
+ if not self.color or not styles:
120
+ return text
121
+ codes = ";".join(_CODES[s] for s in styles if s in _CODES)
122
+ return f"\033[{codes}m{text}\033[0m" if codes else text
123
+
124
+ def verdict(self, text: str) -> str:
125
+ return self.paint(text, *VERDICT_STYLE.get(text, ("white",)))
126
+
127
+ def kind(self, text: str) -> str:
128
+ return self.paint(text, *KIND_STYLE.get(text, ("white", "dim")))
129
+
130
+ def glyph(self, verdict: str) -> str:
131
+ table = GLYPHS if self.unicode else ASCII_GLYPHS
132
+ return table.get(verdict, "?")
133
+
134
+ def burst(self, history: list, limit: int = 0) -> str:
135
+ """``[(verdict, note), ...]`` -> a coloured glyph run.
136
+
137
+ ``limit`` keeps the *tail*: attempt order is chronological, so the last
138
+ glyph is the one the verdict came from. Truncating the list rather than
139
+ the rendered string is what keeps the escape sequences intact.
140
+ """
141
+ if not history:
142
+ return self.paint("-", "dim")
143
+ if limit and len(history) > limit:
144
+ history = history[-limit:]
145
+ return "".join(
146
+ self.paint(self.glyph(verdict), *VERDICT_STYLE.get(verdict, ("white",)))
147
+ for verdict, _note in history
148
+ )
149
+
150
+
151
+ # --- dataset ----------------------------------------------------------------
152
+
153
+
154
+ @dataclass
155
+ class RunMeta:
156
+ """Everything a report header needs about the run itself."""
157
+
158
+ command: str = "sweep"
159
+ gateway: str = ""
160
+ adapter: str = ""
161
+ generated_at: str = ""
162
+ duration_s: float = 0.0
163
+ pool: int = 0
164
+ run_count: int = 0
165
+ state_path: str = ""
166
+ attempts: int = 1
167
+ threads: int = 1
168
+ timeout: float = 0.0
169
+ max_tokens: int = 0
170
+ stream: bool = False
171
+ repeat: int = 1
172
+ argv: list[str] = field(default_factory=list)
173
+ warnings: list[str] = field(default_factory=list)
174
+ notes: list[str] = field(default_factory=list)
175
+ tool: str = "poolprobe"
176
+ version: str = ""
177
+
178
+ def stamp(self) -> str:
179
+ return self.generated_at or datetime.now(timezone.utc).replace(microsecond=0).isoformat()
180
+
181
+
182
+ @dataclass
183
+ class Prunable:
184
+ """A model that survived every guard and is therefore a prune candidate."""
185
+
186
+ model: str
187
+ kind: str = taxonomy.PERMANENT
188
+ verdict: str = taxonomy.ERR
189
+ streak: int = 0
190
+ attempts: int = 1
191
+ provider: str = ""
192
+ note: str = ""
193
+ advice: str = ""
194
+
195
+
196
+ @dataclass
197
+ class Report:
198
+ """One finished run. Renderers take this and nothing else."""
199
+
200
+ meta: RunMeta = field(default_factory=RunMeta)
201
+ probes: list = field(default_factory=list) # list[ProbeResult]
202
+ bench: list = field(default_factory=list) # list[BenchResult]
203
+ prunable: list = field(default_factory=list) # list[Prunable]
204
+ streaks: dict = field(default_factory=dict) # model -> (fail_streak, ok_streak)
205
+ attribution: dict = field(default_factory=dict) # model -> Attribution.as_dict()
206
+ bench_summary: dict = field(default_factory=dict)
207
+ diff: str = "" # unified diff of a planned config edit, if any
208
+ edit_path: str = ""
209
+ edit_removed: list = field(default_factory=list)
210
+ edit_not_found: list = field(default_factory=list)
211
+ chains: list = field(default_factory=list) # list[dict] from chains.validate
212
+
213
+ def streak_of(self, model: str) -> tuple[int, int]:
214
+ pair = self.streaks.get(model)
215
+ return tuple(pair) if isinstance(pair, (list, tuple)) and len(pair) == 2 else (0, 0)
216
+
217
+
218
+ def summarize_probes(probes: list) -> dict:
219
+ """Roll probe results into counts a header can show."""
220
+ by_verdict: dict[str, int] = {}
221
+ by_kind: dict[str, int] = {}
222
+ alive = 0
223
+ starved = 0
224
+ dead = 0
225
+ latencies: list[float] = []
226
+ recovered = 0
227
+ flaky = 0
228
+
229
+ for item in probes:
230
+ by_verdict[item.verdict] = by_verdict.get(item.verdict, 0) + 1
231
+ by_kind[item.kind] = by_kind.get(item.kind, 0) + 1
232
+ if item.verdict == taxonomy.OK:
233
+ alive += 1
234
+ latencies.append(item.latency_s)
235
+ elif item.verdict == taxonomy.EMPTY_LENGTH:
236
+ starved += 1
237
+ latencies.append(item.latency_s)
238
+ else:
239
+ dead += 1
240
+ history = [verdict for verdict, _note in getattr(item, "history", [])]
241
+ if len(history) > 1:
242
+ saw_dead = any(verdict in taxonomy.DEAD for verdict in history)
243
+ saw_live = any(verdict not in taxonomy.DEAD for verdict in history)
244
+ if saw_dead and not item.dead:
245
+ recovered += 1
246
+ if saw_dead and saw_live:
247
+ flaky += 1
248
+
249
+ latencies.sort()
250
+ return {
251
+ "total": len(probes),
252
+ "alive": alive,
253
+ "starved": starved,
254
+ "dead": dead,
255
+ "recovered": recovered,
256
+ "flaky": flaky,
257
+ "by_verdict": dict(sorted(by_verdict.items())),
258
+ "by_kind": dict(sorted(by_kind.items())),
259
+ "median_latency_s": round(_median(latencies), 2),
260
+ "p95_latency_s": round(_percentile(latencies, 0.95), 2),
261
+ }
262
+
263
+
264
+ def _median(values: list) -> float:
265
+ if not values:
266
+ return 0.0
267
+ middle = len(values) // 2
268
+ if len(values) % 2:
269
+ return float(values[middle])
270
+ return (float(values[middle - 1]) + float(values[middle])) / 2.0
271
+
272
+
273
+ def _percentile(values: list, fraction: float) -> float:
274
+ if not values:
275
+ return 0.0
276
+ index = min(len(values) - 1, max(0, int(round(fraction * (len(values) - 1)))))
277
+ return float(values[index])
278
+
279
+
280
+ # --- terminal ---------------------------------------------------------------
281
+
282
+
283
+ def _clip(text: str, width: int) -> str:
284
+ if width <= 0:
285
+ return ""
286
+ if len(text) <= width:
287
+ return text
288
+ if width == 1:
289
+ return text[:1]
290
+ return text[: width - 1] + _ELLIPSIS
291
+
292
+
293
+ def _pad_row(plain: list, painted: list, widths: list, aligns: list) -> str:
294
+ """Lay one row out: pad on the plain text, then drop the colour in.
295
+
296
+ An ANSI escape has a length that is not a width, so every measurement here
297
+ is made on the uncoloured cell. A cell that still does not fit loses its
298
+ colour instead of being sliced — cutting a painted string can halve an
299
+ escape sequence and leak ``[31m`` into the rest of the line.
300
+ """
301
+ cells = []
302
+ for text, styled, width, align in zip(plain, painted, widths, aligns):
303
+ if len(text) > width:
304
+ text = _clip(text, width)
305
+ styled = text
306
+ padding = max(0, width - len(text))
307
+ if align == "r":
308
+ cells.append(" " * padding + styled)
309
+ elif padding:
310
+ cells.append(styled + " " * padding)
311
+ else:
312
+ cells.append(styled)
313
+ return " ".join(cells).rstrip()
314
+
315
+
316
+ def _latency(seconds: float) -> str:
317
+ if seconds <= 0:
318
+ return "-"
319
+ if seconds < 10:
320
+ return f"{seconds:.2f}s"
321
+ return f"{seconds:.1f}s"
322
+
323
+
324
+ def _footer_parts(meta: RunMeta) -> list[str]:
325
+ """The provenance line, shared by the terminal and HTML footers.
326
+
327
+ One helper for both views so they cannot disagree about what a run recorded.
328
+ A field the command never set is dropped rather than printed as a zero:
329
+ ``chains`` sends no request at all, so ``timeout 0s · max_tokens 0`` would
330
+ read like a setting the run used.
331
+ """
332
+ parts = [meta.stamp(), f"{_latency(meta.duration_s)} wall"]
333
+ if meta.pool:
334
+ parts.append(f"pool {meta.pool}")
335
+ if meta.run_count:
336
+ parts.append(f"run {meta.run_count}")
337
+ if meta.threads > 1:
338
+ parts.append(f"threads {meta.threads}")
339
+ if meta.attempts > 1:
340
+ parts.append(f"attempts {meta.attempts}")
341
+ if meta.repeat > 1:
342
+ parts.append(f"repeat {meta.repeat}")
343
+ if meta.timeout:
344
+ parts.append(f"timeout {meta.timeout:g}s")
345
+ if meta.max_tokens:
346
+ parts.append(f"max_tokens {meta.max_tokens}")
347
+ if meta.stream:
348
+ parts.append("stream")
349
+ if meta.state_path:
350
+ parts.append(f"state {meta.state_path}")
351
+ return parts
352
+
353
+
354
+ def render_terminal(report: Report, palette: Palette | None = None, width: int | None = None) -> str:
355
+ """The whole run as text. Never emits ANSI when the palette says no."""
356
+ pal = palette or Palette.detect()
357
+ total_width = width or shutil.get_terminal_size((118, 24)).columns
358
+ total_width = max(72, min(int(total_width), 220))
359
+ meta = report.meta
360
+ lines: list[str] = []
361
+
362
+ title = f"{meta.tool} {meta.version}".strip()
363
+ lines.append(pal.paint(title, "bold") + pal.paint(f" \u00b7 {meta.command}", "dim"))
364
+ header_bits = [bit for bit in (meta.gateway, meta.adapter) if bit]
365
+ if header_bits:
366
+ lines.append(pal.paint(" \u00b7 ".join(header_bits), "dim"))
367
+
368
+ if report.probes:
369
+ summary = summarize_probes(report.probes)
370
+ lines.append("")
371
+ lines.append(
372
+ pal.paint(
373
+ f"{summary['total']} models \u00b7 ",
374
+ "dim",
375
+ )
376
+ + pal.paint(f"{summary['alive']} alive", "green")
377
+ + pal.paint(" \u00b7 ", "dim")
378
+ + pal.paint(f"{summary['starved']} starved", "yellow")
379
+ + pal.paint(" \u00b7 ", "dim")
380
+ + pal.paint(f"{summary['dead']} dead", "red" if summary["dead"] else "dim")
381
+ + pal.paint(" \u00b7 ", "dim")
382
+ + pal.paint(
383
+ f"{len(report.prunable)} prunable",
384
+ "red" if report.prunable else "dim",
385
+ )
386
+ + pal.paint(
387
+ f" \u00b7 median {_latency(summary['median_latency_s'])}", "dim"
388
+ )
389
+ + pal.paint(f" \u00b7 run #{meta.run_count}", "dim")
390
+ )
391
+ if summary["recovered"]:
392
+ lines.append(
393
+ pal.paint(
394
+ f"{summary['recovered']} model(s) recovered inside the burst "
395
+ f"({summary['flaky']} flaky) \u2014 a single probe would have called them dead",
396
+ "cyan",
397
+ )
398
+ )
399
+
400
+ lines.append("")
401
+ lines.extend(_probe_table(report, pal, total_width))
402
+
403
+ if report.bench:
404
+ lines.append("")
405
+ lines.append(pal.paint("bench", "bold"))
406
+ lines.extend(_bench_table(report, pal, total_width))
407
+
408
+ if report.prunable:
409
+ lines.append("")
410
+ lines.append(pal.paint("prunable", "bold"))
411
+ lines.append(
412
+ pal.paint(
413
+ "passed every guard: dead + PERMANENT + streak + not protected + chat modality",
414
+ "dim",
415
+ )
416
+ )
417
+ for row in report.prunable:
418
+ streak = row.streak
419
+ lines.append(
420
+ " "
421
+ + pal.paint(_clip(row.model, 34), "red", "bold")
422
+ + pal.paint(f" streak {streak} ", "dim")
423
+ + pal.paint(_clip(row.note, 60), "white")
424
+ )
425
+ elif report.probes:
426
+ lines.append("")
427
+ lines.append(
428
+ pal.paint(
429
+ "prunable: none \u2014 nothing met all five guards (dead, PERMANENT, "
430
+ f"streak, unprotected, chat modality)",
431
+ "dim",
432
+ )
433
+ )
434
+
435
+ advice_lines = _advice_lines(report)
436
+ if advice_lines:
437
+ lines.append("")
438
+ lines.append(pal.paint("what to do", "bold"))
439
+ lines.extend(advice_lines)
440
+
441
+ if report.chains:
442
+ lines.append("")
443
+ lines.append(pal.paint("fallback chains", "bold"))
444
+ lines.extend(_chain_lines(report, pal))
445
+
446
+ if report.diff:
447
+ lines.append("")
448
+ lines.append(pal.paint(f"planned edit \u00b7 {report.edit_path}", "bold"))
449
+ if report.edit_not_found:
450
+ lines.append(
451
+ pal.paint("not found in config (left alone): ", "dim")
452
+ + ", ".join(report.edit_not_found)
453
+ )
454
+ for line in report.diff.splitlines():
455
+ if line.startswith("+") and not line.startswith("+++"):
456
+ lines.append(pal.paint(line, "green"))
457
+ elif line.startswith("-") and not line.startswith("---"):
458
+ lines.append(pal.paint(line, "red"))
459
+ elif line.startswith("@@"):
460
+ lines.append(pal.paint(line, "cyan"))
461
+ else:
462
+ lines.append(pal.paint(line, "dim"))
463
+
464
+ warnings = list(meta.warnings)
465
+ if warnings:
466
+ lines.append("")
467
+ for warning in warnings:
468
+ lines.append(pal.paint(f"! {warning}", "yellow"))
469
+
470
+ if meta.notes:
471
+ lines.append("")
472
+ for note in meta.notes:
473
+ lines.append(pal.paint(note, "dim"))
474
+
475
+ lines.append("")
476
+ # The provenance line is the only part of the report that grows with the
477
+ # options used -- `--state` alone outgrows 80 columns -- so it wraps at
478
+ # separator boundaries instead of breaking mid-field.
479
+ row = ""
480
+ for part in _footer_parts(meta):
481
+ if row and len(row) + len(_SEP) + len(part) > total_width:
482
+ lines.append(pal.paint(row, "dim"))
483
+ row = part
484
+ else:
485
+ row = f"{row}{_SEP}{part}" if row else part
486
+ lines.append(pal.paint(row, "dim"))
487
+ return "\n".join(lines)
488
+
489
+
490
+ def _probe_table(report: Report, pal: Palette, total_width: int) -> list[str]:
491
+ """One row per probe.
492
+
493
+ The column set depends on ``--attempts``: a burst history only exists when a
494
+ model was probed more than once, and an empty BURST column on a single-probe
495
+ run would be a column that says nothing. Every index below is derived from
496
+ ``headers`` rather than hardcoded, so the two layouts cannot drift apart.
497
+ """
498
+ meta = report.meta
499
+ show_burst = meta.attempts > 1
500
+ columns = [
501
+ ("VERDICT", 12, "l"),
502
+ ("MODEL", 28, "l"),
503
+ ("PROVIDER", 13, "l"),
504
+ ("LAT", 7, "r"),
505
+ ]
506
+ if show_burst:
507
+ longest = max((len(item.history) for item in report.probes), default=meta.attempts)
508
+ columns.append(("BURST", max(5, min(longest, 16)), "l"))
509
+ columns.extend(
510
+ [
511
+ ("STREAK", 6, "r"),
512
+ ("KIND", 13, "l"),
513
+ ("NOTE", 0, "l"),
514
+ ]
515
+ )
516
+
517
+ headers = [name for name, _w, _a in columns]
518
+ aligns = [align for _n, _w, align in columns]
519
+ fixed = sum(width for _h, width, _a in columns if width) + 2 * (len(columns) - 1)
520
+ widths = [width if width else max(12, total_width - fixed) for _h, width, _a in columns]
521
+ i_burst = headers.index("BURST") if show_burst else -1
522
+ i_note = len(headers) - 1
523
+ i_kind = i_note - 1
524
+
525
+ out = [" ".join(h.ljust(w)[:w] for h, w in zip(headers, widths))]
526
+ out.append(" ".join("-" * w for w in widths))
527
+
528
+ for item in report.probes:
529
+ fail, ok = report.streak_of(item.model)
530
+ streak = f"x{fail}" if fail else (f"ok{ok}" if ok else "-")
531
+ kind = item.kind if item.kind != taxonomy.ALIVE else "-"
532
+ plain = [
533
+ _clip(item.verdict, widths[0]),
534
+ _clip(item.model, widths[1]),
535
+ _clip(item.provider or "-", widths[2]),
536
+ _latency(item.latency_s),
537
+ ]
538
+ if show_burst:
539
+ glyphs = "".join(pal.glyph(verdict) for verdict, _n in item.history) or "-"
540
+ plain.append(_clip(glyphs, widths[i_burst]))
541
+ plain.extend(
542
+ [
543
+ streak,
544
+ _clip(kind, widths[i_kind]),
545
+ _clip(item.note or "-", widths[i_note]),
546
+ ]
547
+ )
548
+
549
+ painted = [
550
+ pal.verdict(plain[0]),
551
+ pal.paint(plain[1], "bold"),
552
+ pal.paint(plain[2], "dim"),
553
+ plain[3],
554
+ ]
555
+ if show_burst:
556
+ painted.append(pal.burst(item.history, limit=widths[i_burst]))
557
+ painted.extend(
558
+ [
559
+ pal.paint(streak, "red" if fail else "dim"),
560
+ pal.kind(plain[i_kind]),
561
+ pal.paint(plain[i_note], "white" if item.dead else "dim"),
562
+ ]
563
+ )
564
+ out.append(_pad_row(plain, painted, widths, aligns))
565
+ return out
566
+
567
+
568
+ def _bench_table(report: Report, pal: Palette, total_width: int) -> list[str]:
569
+ columns = [
570
+ ("MODEL", 28, "l"),
571
+ ("PROVIDER", 13, "l"),
572
+ ("LAT", 8, "r"),
573
+ ("TTFT", 7, "r"),
574
+ ("TOK", 6, "r"),
575
+ ("TOK/S", 10, "r"),
576
+ ("SOURCE", 8, "l"),
577
+ ("NOTE", 0, "l"),
578
+ ]
579
+ aligns = [align for _n, _w, align in columns]
580
+ fixed = sum(w for _h, w, _a in columns if w) + 2 * (len(columns) - 1)
581
+ widths = [w if w else max(10, total_width - fixed) for _h, w, _a in columns]
582
+
583
+ out = [" ".join(h.ljust(w)[:w] for (h, _w, _a), w in zip(columns, widths))]
584
+ out.append(" ".join("-" * w for w in widths))
585
+ for item in report.bench:
586
+ if item.ok:
587
+ throughput = f"{item.tok_s:.1f}" if item.tok_s_exact else (
588
+ f">={item.tok_s:.1f}" if item.tok_s > 0 else "-"
589
+ )
590
+ note = "-"
591
+ style = ("green",)
592
+ else:
593
+ throughput = "-"
594
+ note = item.error or item.verdict or "dead"
595
+ style = ("red",)
596
+ plain = [
597
+ _clip(item.model, widths[0]),
598
+ _clip(item.provider or "-", widths[1]),
599
+ _latency(item.latency_s),
600
+ _latency(item.ttft_s),
601
+ str(item.tokens) if item.tokens else "-",
602
+ throughput,
603
+ item.token_source if item.token_source != "none" else "-",
604
+ _clip(note, widths[7]),
605
+ ]
606
+ painted = [
607
+ pal.paint(plain[0], "bold"),
608
+ pal.paint(plain[1], "dim"),
609
+ plain[2],
610
+ plain[3],
611
+ plain[4],
612
+ pal.paint(plain[5], *style),
613
+ pal.paint(plain[6], "dim"),
614
+ pal.paint(plain[7], "dim" if item.ok else "white"),
615
+ ]
616
+ out.append(_pad_row(plain, painted, widths, aligns))
617
+
618
+ summary = report.bench_summary
619
+ if summary:
620
+ exact = summary.get("with_exact_tokens", 0)
621
+ out.append(
622
+ pal.paint(
623
+ f"{summary.get('alive', 0)}/{summary.get('total', 0)} answered \u00b7 "
624
+ f"median latency {_latency(float(summary.get('median_latency_s', 0.0)))} \u00b7 "
625
+ f"{exact} with exact token counts (usage), "
626
+ f"{summary.get('alive', 0) - exact} lower-bound (chunks) or unmeasured",
627
+ "dim",
628
+ )
629
+ )
630
+ if exact < summary.get("alive", 0):
631
+ out.append(
632
+ pal.paint(
633
+ "tok/s marked >= is counted from SSE chunks, a transport artifact: "
634
+ "a hard lower bound, not a measurement. Ranking uses latency, which is exact.",
635
+ "yellow",
636
+ )
637
+ )
638
+ return out
639
+
640
+
641
+ def _advice_lines(report: Report) -> list[str]:
642
+ seen: dict[str, int] = {}
643
+ order: list[str] = []
644
+ for item in report.probes:
645
+ if item.kind == taxonomy.ALIVE:
646
+ continue
647
+ seen[item.kind] = seen.get(item.kind, 0) + 1
648
+ if item.kind not in order:
649
+ order.append(item.kind)
650
+ lines = []
651
+ for kind in order:
652
+ text = taxonomy.advice(kind)
653
+ if not text:
654
+ continue
655
+ lines.append(f" {kind} ({seen[kind]}): {text}")
656
+ return lines
657
+
658
+
659
+ def _chain_lines(report: Report, pal: Palette) -> list[str]:
660
+ lines: list[str] = []
661
+ for chain in report.chains:
662
+ label = chain.get("label", "?")
663
+ issues = chain.get("issues") or []
664
+ rungs = chain.get("rungs") or []
665
+ head = (f" {label}: " + _ARROW.join(rungs)) if rungs else f" {label}: (empty)"
666
+ if issues:
667
+ lines.append(pal.paint(head, "yellow"))
668
+ for issue in issues:
669
+ lines.append(pal.paint(f" - {issue}", "yellow"))
670
+ else:
671
+ lines.append(pal.paint(head, "green"))
672
+ return lines
673
+
674
+
675
+ # --- json -------------------------------------------------------------------
676
+
677
+
678
+ def render_json(report: Report, indent: int | None = 2) -> str:
679
+ """Machine-readable form of the same run. Stable key order, no ANSI."""
680
+ summary = summarize_probes(report.probes) if report.probes else {}
681
+ payload = {
682
+ "tool": report.meta.tool,
683
+ "version": report.meta.version,
684
+ "generated_at": report.meta.stamp(),
685
+ "command": report.meta.command,
686
+ "gateway": report.meta.gateway,
687
+ "adapter": report.meta.adapter,
688
+ "duration_s": round(report.meta.duration_s, 3),
689
+ "settings": {
690
+ "attempts": report.meta.attempts,
691
+ "threads": report.meta.threads,
692
+ "timeout": report.meta.timeout,
693
+ "max_tokens": report.meta.max_tokens,
694
+ "stream": report.meta.stream,
695
+ "repeat": report.meta.repeat,
696
+ },
697
+ "summary": summary,
698
+ "prunable": [asdict(row) for row in report.prunable],
699
+ "results": [],
700
+ "bench": [],
701
+ "bench_summary": report.bench_summary,
702
+ "chains": report.chains,
703
+ "edit": {
704
+ "path": report.edit_path,
705
+ "removed": report.edit_removed,
706
+ "not_found": report.edit_not_found,
707
+ "diff": report.diff,
708
+ }
709
+ if report.diff or report.edit_path
710
+ else {},
711
+ "warnings": list(report.meta.warnings),
712
+ "notes": list(report.meta.notes),
713
+ "state": {
714
+ "path": report.meta.state_path,
715
+ "run_count": report.meta.run_count,
716
+ "pool": report.meta.pool,
717
+ },
718
+ }
719
+ for item in report.probes:
720
+ row = item.as_dict()
721
+ fail, ok = report.streak_of(item.model)
722
+ row["fail_streak"] = fail
723
+ row["ok_streak"] = ok
724
+ attr = report.attribution.get(item.model)
725
+ if isinstance(attr, dict):
726
+ row["attribution"] = attr
727
+ payload["results"].append(row)
728
+ for item in report.bench:
729
+ payload["bench"].append(item.as_dict())
730
+ return json.dumps(payload, indent=indent, ensure_ascii=False, sort_keys=False)
731
+
732
+
733
+ # --- html -------------------------------------------------------------------
734
+
735
+ _CSS = """
736
+ :root{--bg:#0d1117;--panel:#151b23;--line:#232c37;--fg:#e6edf3;--dim:#8b98a5;
737
+ --green:#3fb950;--yellow:#d29922;--red:#f85149;--cyan:#39c5cf;--magenta:#bc8cff;--accent:#58a6ff}
738
+ *{box-sizing:border-box}
739
+ body{margin:0;background:var(--bg);color:var(--fg);
740
+ font:14px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
741
+ .wrap{max-width:1180px;margin:0 auto;padding:28px 20px 56px}
742
+ header{display:flex;flex-wrap:wrap;gap:10px 18px;align-items:baseline;
743
+ border-bottom:1px solid var(--line);padding-bottom:16px;margin-bottom:22px}
744
+ h1{font-size:20px;margin:0;letter-spacing:.2px}
745
+ h1 .v{color:var(--dim);font-weight:400;font-size:13px;margin-left:8px}
746
+ .sub{color:var(--dim);font-size:12.5px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
747
+ .tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:10px;margin-bottom:22px}
748
+ .tile{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:12px 14px}
749
+ .tile .n{font-size:24px;font-weight:650;line-height:1.1}
750
+ .tile .l{color:var(--dim);font-size:11.5px;text-transform:uppercase;letter-spacing:.7px;margin-top:3px}
751
+ .tile.good .n{color:var(--green)}.tile.warn .n{color:var(--yellow)}
752
+ .tile.bad .n{color:var(--red)}.tile.info .n{color:var(--cyan)}
753
+ h2{font-size:13px;text-transform:uppercase;letter-spacing:.9px;color:var(--dim);
754
+ margin:26px 0 10px;font-weight:600}
755
+ .filters{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px}
756
+ .filters button{background:var(--panel);border:1px solid var(--line);color:var(--dim);
757
+ border-radius:999px;padding:4px 12px;font-size:12px;cursor:pointer;font-family:inherit}
758
+ .filters button:hover{color:var(--fg);border-color:#33414f}
759
+ .filters button[aria-pressed="true"]{color:var(--fg);border-color:var(--accent);background:#12233a}
760
+ .panel{background:var(--panel);border:1px solid var(--line);border-radius:10px;overflow:auto}
761
+ table{border-collapse:collapse;width:100%;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12.5px}
762
+ thead th{position:sticky;top:0;background:#111820;color:var(--dim);text-align:left;
763
+ font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.6px;
764
+ padding:9px 12px;border-bottom:1px solid var(--line);white-space:nowrap}
765
+ tbody td{padding:7px 12px;border-bottom:1px solid #1b232d;vertical-align:top;white-space:nowrap}
766
+ tbody tr:last-child td{border-bottom:0}
767
+ tbody tr:hover{background:#131a22}
768
+ td.note{white-space:normal;color:var(--dim);min-width:220px}
769
+ td.num,th.num{text-align:right;font-variant-numeric:tabular-nums}
770
+ td.model{color:var(--fg);font-weight:600}
771
+ td.prov{color:var(--dim)}
772
+ .badge{display:inline-block;padding:1px 8px;border-radius:999px;font-size:11px;
773
+ font-weight:600;letter-spacing:.3px;border:1px solid transparent}
774
+ .b-OK{color:var(--green);background:#0f2417;border-color:#1d4429}
775
+ .b-EMPTY-LENGTH{color:var(--yellow);background:#241f0f;border-color:#4d3f1a}
776
+ .b-EMPTY{color:var(--red);background:#2a1416;border-color:#5a2429}
777
+ .b-ERR{color:var(--red);background:#2a1416;border-color:#5a2429}
778
+ .b-TIMEOUT{color:var(--magenta);background:#20162c;border-color:#3f2a5c}
779
+ .k-ALIVE{color:var(--green)}.k-ALIVE-STARVED{color:var(--yellow)}
780
+ .k-PERMANENT{color:var(--red);font-weight:600}.k-TRANSIENT{color:var(--cyan)}
781
+ .k-CREDENTIAL{color:var(--yellow)}.k-MODALITY{color:var(--magenta)}.k-UNKNOWN{color:var(--dim)}
782
+ .burst{letter-spacing:1px;font-size:13px}
783
+ .g-OK{color:var(--green)}.g-EMPTY-LENGTH{color:var(--yellow)}.g-EMPTY{color:var(--red)}
784
+ .g-ERR{color:var(--red)}.g-TIMEOUT{color:var(--magenta)}
785
+ .streak{font-variant-numeric:tabular-nums}
786
+ .streak.bad{color:var(--red);font-weight:600}
787
+ ul.advice{margin:0;padding-left:18px}ul.advice li{margin:4px 0}
788
+ ul.advice code{color:var(--accent)}
789
+ pre{background:#0a0e13;border:1px solid var(--line);border-radius:10px;padding:14px;
790
+ overflow:auto;font-size:12px;line-height:1.55;margin:0}
791
+ pre .add{color:var(--green)}pre .del{color:var(--red)}
792
+ pre .hunk{color:var(--cyan)}pre .ctx{color:var(--dim)}
793
+ .warn{background:#241f0f;border:1px solid #4d3f1a;border-radius:10px;padding:10px 14px;color:var(--yellow)}
794
+ .foot{color:var(--dim);font-size:11.5px;margin-top:26px;border-top:1px solid var(--line);
795
+ padding-top:14px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
796
+ .empty{color:var(--dim);padding:16px}
797
+ """
798
+
799
+ _JS = """
800
+ (function(){
801
+ var buttons=document.querySelectorAll('.filters button');
802
+ function apply(filter){
803
+ document.querySelectorAll('tbody tr[data-v]').forEach(function(tr){
804
+ tr.style.display=(filter==='all'||tr.getAttribute('data-v')===filter)?'':'none';
805
+ });
806
+ buttons.forEach(function(b){b.setAttribute('aria-pressed',String(b.getAttribute('data-f')===filter));});
807
+ }
808
+ buttons.forEach(function(b){b.addEventListener('click',function(){apply(b.getAttribute('data-f'));});});
809
+ })();
810
+ """
811
+
812
+
813
+ def _esc(value: object) -> str:
814
+ return html_module.escape("" if value is None else str(value), quote=True)
815
+
816
+
817
+ def render_html(report: Report) -> str:
818
+ """A single self-contained page: inline CSS, no fonts, no network, no build."""
819
+ meta = report.meta
820
+ summary = summarize_probes(report.probes) if report.probes else {}
821
+ parts: list[str] = []
822
+ parts.append("<!doctype html>")
823
+ parts.append('<html lang="en"><head><meta charset="utf-8">')
824
+ parts.append('<meta name="viewport" content="width=device-width,initial-scale=1">')
825
+ parts.append(f"<title>{_esc(meta.tool)} {_esc(meta.version)} \u00b7 {_esc(meta.command)}</title>")
826
+ parts.append(f"<style>{_CSS}</style></head><body><div class='wrap'>")
827
+
828
+ parts.append("<header>")
829
+ parts.append(f"<h1>{_esc(meta.tool)}<span class='v'>{_esc(meta.version)}</span></h1>")
830
+ bits = [meta.command, meta.gateway, meta.adapter, meta.stamp()]
831
+ parts.append(f"<div class='sub'>{_esc(_SEP.join(b for b in bits if b))}</div>")
832
+ parts.append("</header>")
833
+
834
+ if report.probes:
835
+ parts.append("<div class='tiles'>")
836
+ parts.append(_tile(summary.get("total", 0), "models probed", ""))
837
+ parts.append(_tile(summary.get("alive", 0), "alive", "good"))
838
+ parts.append(_tile(summary.get("starved", 0), "starved", "warn"))
839
+ parts.append(_tile(summary.get("dead", 0), "dead", "bad" if summary.get("dead") else ""))
840
+ parts.append(
841
+ _tile(len(report.prunable), "prunable", "bad" if report.prunable else "info")
842
+ )
843
+ parts.append(_tile(_latency(summary.get("median_latency_s", 0.0)), "median latency", "info"))
844
+ parts.append(_tile(f"#{meta.run_count}", "run", ""))
845
+ parts.append("</div>")
846
+
847
+ if summary.get("recovered"):
848
+ parts.append(
849
+ "<div class='warn'>"
850
+ f"{_esc(summary['recovered'])} model(s) answered inside the burst after failing "
851
+ f"an attempt ({_esc(summary['flaky'])} flaky in total). A single-probe gate would "
852
+ "have marked them dead \u2014 this is the measurement, not an opinion.</div>"
853
+ )
854
+
855
+ parts.append(f"<h2>probes <span class='sub'>attempts {_esc(meta.attempts)}</span></h2>")
856
+ verdict_counts = summary.get("by_verdict", {})
857
+ parts.append("<div class='filters'><button data-f='all' aria-pressed='true'>all</button>")
858
+ for verdict in taxonomy.VERDICTS:
859
+ count = verdict_counts.get(verdict, 0)
860
+ if not count:
861
+ continue
862
+ parts.append(
863
+ f"<button data-f='{_esc(verdict)}'>{_esc(verdict)} {_esc(count)}</button>"
864
+ )
865
+ parts.append("</div>")
866
+ parts.extend(_html_probe_table(report))
867
+
868
+ if report.bench:
869
+ parts.append("<h2>bench</h2>")
870
+ parts.extend(_html_bench_table(report))
871
+
872
+ if report.prunable:
873
+ parts.append("<h2>prune candidates</h2>")
874
+ parts.append("<div class='panel'><table><thead><tr>"
875
+ "<th>model</th><th>provider</th><th class='num'>streak</th>"
876
+ "<th class='num'>attempts</th><th>evidence</th></tr></thead><tbody>")
877
+ for row in report.prunable:
878
+ parts.append(
879
+ "<tr>"
880
+ f"<td class='model'>{_esc(row.model)}</td>"
881
+ f"<td class='prov'>{_esc(row.provider or '-')}</td>"
882
+ f"<td class='num'>{_esc(row.streak)}</td>"
883
+ f"<td class='num'>{_esc(row.attempts)}</td>"
884
+ f"<td class='note'>{_esc(row.note or '-')}</td>"
885
+ "</tr>"
886
+ )
887
+ parts.append("</tbody></table></div>")
888
+
889
+ advice = _advice_html(report)
890
+ if advice:
891
+ parts.append("<h2>what to do</h2>")
892
+ parts.append(f"<div class='panel' style='padding:14px 18px'>{advice}</div>")
893
+
894
+ if report.chains:
895
+ parts.append("<h2>fallback chains</h2>")
896
+ parts.append("<div class='panel'><table><thead><tr>"
897
+ "<th>chain</th><th>rungs</th><th>issues</th></tr></thead><tbody>")
898
+ for chain in report.chains:
899
+ issues = chain.get("issues") or []
900
+ rungs = chain.get("rungs") or []
901
+ issue_html = (
902
+ "<br>".join(_esc(i) for i in issues) if issues else "<span class='k-ALIVE'>ok</span>"
903
+ )
904
+ parts.append(
905
+ "<tr>"
906
+ f"<td class='model'>{_esc(chain.get('label', '?'))}</td>"
907
+ f"<td>{_esc(_ARROW.join(rungs) or '(empty)')}</td>"
908
+ f"<td class='note'>{issue_html}</td>"
909
+ "</tr>"
910
+ )
911
+ parts.append("</tbody></table></div>")
912
+
913
+ if report.diff:
914
+ parts.append(f"<h2>planned edit \u00b7 {_esc(report.edit_path)}</h2>")
915
+ if report.edit_not_found:
916
+ parts.append(
917
+ "<div class='warn'>not found in config, left alone: "
918
+ f"{_esc(', '.join(report.edit_not_found))}</div>"
919
+ )
920
+ parts.append("<pre>")
921
+ for line in report.diff.splitlines():
922
+ css = "ctx"
923
+ if line.startswith("+") and not line.startswith("+++"):
924
+ css = "add"
925
+ elif line.startswith("-") and not line.startswith("---"):
926
+ css = "del"
927
+ elif line.startswith("@@"):
928
+ css = "hunk"
929
+ parts.append(f"<span class='{css}'>{_esc(line)}</span>")
930
+ parts.append("</pre>")
931
+
932
+ if meta.warnings:
933
+ parts.append("<h2>warnings</h2>")
934
+ for warning in meta.warnings:
935
+ parts.append(f"<div class='warn' style='margin-bottom:8px'>{_esc(warning)}</div>")
936
+
937
+ # argv is HTML-only: the terminal footer would wrap, and HTML is the
938
+ # artefact that gets shared — with the bearer key already masked by the CLI.
939
+ foot = _footer_parts(meta)
940
+ if meta.argv:
941
+ foot.append(" ".join(str(a) for a in meta.argv))
942
+ # Escaped once, here: two of the items above used to escape themselves and
943
+ # then get escaped again, which renders an ampersand as ``&amp;amp;``.
944
+ parts.append(f"<div class='foot'>{_esc(_SEP.join(str(f) for f in foot))}</div>")
945
+ parts.append(f"<script>{_JS}</script>")
946
+ parts.append("</div></body></html>")
947
+ return "\n".join(parts)
948
+
949
+
950
+ def _tile(value: object, label: str, tone: str) -> str:
951
+ css = f"tile {tone}".strip()
952
+ return f"<div class='{css}'><div class='n'>{_esc(value)}</div><div class='l'>{_esc(label)}</div></div>"
953
+
954
+
955
+ def _html_probe_table(report: Report) -> list[str]:
956
+ show_burst = report.meta.attempts > 1
957
+ pal = Palette(unicode=True)
958
+ out = ["<div class='panel'><table><thead><tr>"]
959
+ for name in ("verdict", "model", "provider", "latency"):
960
+ out.append(f"<th class='{'num' if name == 'latency' else ''}'>{name}</th>")
961
+ if show_burst:
962
+ out.append("<th>burst</th>")
963
+ for name in ("streak", "kind", "note"):
964
+ out.append(f"<th>{name}</th>")
965
+ out.append("</tr></thead><tbody>")
966
+
967
+ for item in report.probes:
968
+ fail, ok = report.streak_of(item.model)
969
+ streak = f"{_CROSS}{fail}" if fail else (f"ok{ok}" if ok else "-")
970
+ streak_css = "streak bad" if fail else "streak"
971
+ attr = report.attribution.get(item.model) or {}
972
+ upstream = attr.get("upstream") if isinstance(attr, dict) else ""
973
+ title = f" upstream={_esc(upstream)}" if upstream and upstream != item.model else ""
974
+ out.append(f"<tr data-v='{_esc(item.verdict)}'>")
975
+ out.append(
976
+ f"<td><span class='badge b-{_esc(item.verdict)}'>{_esc(item.verdict)}</span></td>"
977
+ )
978
+ out.append(f"<td class='model'{title}>{_esc(item.model)}</td>")
979
+ out.append(f"<td class='prov'>{_esc(item.provider or '-')}</td>")
980
+ out.append(f"<td class='num'>{_esc(_latency(item.latency_s))}</td>")
981
+ if show_burst:
982
+ glyphs = "".join(
983
+ f"<span class='g-{_esc(verdict)}'>{_esc(pal.glyph(verdict))}</span>"
984
+ for verdict, _note in item.history
985
+ )
986
+ # Recovery is a property of the burst, not of the state file: a model
987
+ # that failed attempt 1 and answered on attempt 2 is the whole reason
988
+ # the burst exists, and a persisted streak knows nothing about it.
989
+ failed_inside = any(verdict in taxonomy.DEAD for verdict, _n in item.history)
990
+ recovered = " \u00b7 recovered inside burst" if failed_inside and not item.dead else ""
991
+ out.append(
992
+ f"<td class='burst' title='{_esc(str(item.attempts))} attempts{recovered}'>"
993
+ f"{glyphs or '-'}</td>"
994
+ )
995
+ out.append(f"<td class='{streak_css}'>{_esc(streak)}</td>")
996
+ kind = item.kind if item.kind != taxonomy.ALIVE else "-"
997
+ out.append(f"<td class='k-{_esc(item.kind)}'>{_esc(kind)}</td>")
998
+ note = item.note or "-"
999
+ if item.dead and item.advice:
1000
+ note = f"{note}{_DASH}{item.advice}"
1001
+ out.append(f"<td class='note'>{_esc(note)}</td>")
1002
+ out.append("</tr>")
1003
+
1004
+ if not report.probes:
1005
+ span = 8 if show_burst else 7
1006
+ out.append(f"<tr><td colspan='{span}' class='empty'>nothing probed</td></tr>")
1007
+ out.append("</tbody></table></div>")
1008
+ return out
1009
+
1010
+
1011
+ def _html_bench_table(report: Report) -> list[str]:
1012
+ out = ["<div class='panel'><table><thead><tr>"]
1013
+ out.append("<th>model</th><th>provider</th>")
1014
+ for name in ("latency", "ttft", "tokens", "tok/s", "source"):
1015
+ out.append(f"<th class='num'>{name}</th>")
1016
+ out.append("<th>note</th></tr></thead><tbody>")
1017
+
1018
+ for item in report.bench:
1019
+ if item.ok:
1020
+ throughput = f"{item.tok_s:.1f}" if item.tok_s_exact else (
1021
+ f"&gt;={item.tok_s:.1f}" if item.tok_s > 0 else "-"
1022
+ )
1023
+ title = (
1024
+ "exact: usage.completion_tokens from the reply"
1025
+ if item.tok_s_exact
1026
+ else "lower bound: counted SSE chunks, a transport artifact"
1027
+ if item.tok_s > 0
1028
+ else "no token evidence: refused to guess"
1029
+ )
1030
+ note = "-"
1031
+ else:
1032
+ throughput = "-"
1033
+ title = ""
1034
+ note = item.error or item.verdict or "dead"
1035
+ # Hoisted out of the f-string: a three-way ternary inside an attribute
1036
+ # is unreadable, and the tone is a claim about evidence, not decoration.
1037
+ if not item.ok:
1038
+ tone = "PERMANENT"
1039
+ elif item.tok_s_exact:
1040
+ tone = "ALIVE"
1041
+ else:
1042
+ tone = "ALIVE-STARVED"
1043
+ out.append("<tr>")
1044
+ out.append(f"<td class='model'>{_esc(item.model)}</td>")
1045
+ out.append(f"<td class='prov'>{_esc(item.provider or '-')}</td>")
1046
+ out.append(f"<td class='num'>{_esc(_latency(item.latency_s))}</td>")
1047
+ out.append(f"<td class='num'>{_esc(_latency(item.ttft_s))}</td>")
1048
+ out.append(f"<td class='num'>{_esc(item.tokens or '-')}</td>")
1049
+ out.append(f"<td class='num k-{tone}' title='{_esc(title)}'>{throughput}</td>")
1050
+ out.append(f"<td class='num prov'>{_esc(item.token_source)}</td>")
1051
+ out.append(f"<td class='note'>{_esc(note)}</td>")
1052
+ out.append("</tr>")
1053
+
1054
+ if not report.bench:
1055
+ out.append("<tr><td colspan='8' class='empty'>nothing measured</td></tr>")
1056
+ out.append("</tbody></table></div>")
1057
+
1058
+ summary = report.bench_summary
1059
+ if summary:
1060
+ alive = summary.get("alive", 0)
1061
+ exact = summary.get("with_exact_tokens", 0)
1062
+ out.append(
1063
+ "<p class='sub' style='margin-top:10px'>"
1064
+ f"{_esc(alive)}/{_esc(summary.get('total', 0))} answered \u00b7 median latency "
1065
+ f"{_esc(_latency(float(summary.get('median_latency_s', 0.0))))} \u00b7 "
1066
+ f"{_esc(exact)} exact token counts, {_esc(alive - exact)} lower-bound or unmeasured."
1067
+ " Ranked by latency, which is always measured exactly; tok/s from chunk counts is a "
1068
+ "hard lower bound and is never comparable to a usage-based figure.</p>"
1069
+ )
1070
+ return out
1071
+
1072
+
1073
+ def _advice_html(report: Report) -> str:
1074
+ seen: dict[str, int] = {}
1075
+ order: list[str] = []
1076
+ for item in report.probes:
1077
+ if item.kind == taxonomy.ALIVE:
1078
+ continue
1079
+ seen[item.kind] = seen.get(item.kind, 0) + 1
1080
+ if item.kind not in order:
1081
+ order.append(item.kind)
1082
+ items = []
1083
+ for kind in order:
1084
+ text = taxonomy.advice(kind)
1085
+ if not text:
1086
+ continue
1087
+ items.append(
1088
+ f"<li><code class='k-{_esc(kind)}'>{_esc(kind)}</code> "
1089
+ f"({_esc(seen[kind])}) \u2014 {_esc(text)}</li>"
1090
+ )
1091
+ if report.prunable:
1092
+ items.append(
1093
+ f"<li><code>{_esc(len(report.prunable))}</code> model(s) passed every prune guard. "
1094
+ "Nothing is written without <code>--apply</code>.</li>"
1095
+ )
1096
+ return f"<ul class='advice'>{''.join(items)}</ul>" if items else ""