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/__init__.py +137 -0
- poolprobe/__main__.py +8 -0
- poolprobe/adapter.py +525 -0
- poolprobe/bench.py +368 -0
- poolprobe/chains.py +414 -0
- poolprobe/cli.py +1008 -0
- poolprobe/probe.py +352 -0
- poolprobe/report.py +1096 -0
- poolprobe/safewrite.py +66 -0
- poolprobe/state.py +272 -0
- poolprobe/taxonomy.py +312 -0
- poolprobe-1.0.0.dist-info/METADATA +259 -0
- poolprobe-1.0.0.dist-info/RECORD +17 -0
- poolprobe-1.0.0.dist-info/WHEEL +5 -0
- poolprobe-1.0.0.dist-info/entry_points.txt +2 -0
- poolprobe-1.0.0.dist-info/licenses/LICENSE +21 -0
- poolprobe-1.0.0.dist-info/top_level.txt +1 -0
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;``.
|
|
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">={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 ""
|