slmeval-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- slmeval/__init__.py +71 -0
- slmeval/__main__.py +14 -0
- slmeval/adapters/__init__.py +24 -0
- slmeval/adapters/instrument.py +135 -0
- slmeval/adapters/langgraph.py +237 -0
- slmeval/cli/__init__.py +15 -0
- slmeval/cli/commands/__init__.py +35 -0
- slmeval/cli/commands/baseline_cmd.py +201 -0
- slmeval/cli/commands/cache_cmd.py +249 -0
- slmeval/cli/commands/dataset_cmd.py +52 -0
- slmeval/cli/commands/doctor_cmd.py +68 -0
- slmeval/cli/commands/gate_cmd.py +602 -0
- slmeval/cli/commands/init_cmd.py +144 -0
- slmeval/cli/commands/judge_cmd.py +284 -0
- slmeval/cli/commands/record_cmd.py +154 -0
- slmeval/cli/commands/report_cmd.py +98 -0
- slmeval/cli/commands/run_cmd.py +247 -0
- slmeval/cli/commands/show_cmd.py +482 -0
- slmeval/cli/commands/stubs.py +57 -0
- slmeval/cli/commands/validate_cmd.py +296 -0
- slmeval/cli/loading.py +149 -0
- slmeval/cli/main.py +91 -0
- slmeval/cli/render.py +251 -0
- slmeval/cli/wrap.py +31 -0
- slmeval/config/__init__.py +47 -0
- slmeval/config/loader.py +363 -0
- slmeval/config/locate.py +97 -0
- slmeval/config/models.py +430 -0
- slmeval/config/templates.py +246 -0
- slmeval/core/__init__.py +78 -0
- slmeval/core/adapter.py +253 -0
- slmeval/core/context.py +418 -0
- slmeval/core/outcome.py +260 -0
- slmeval/core/router.py +319 -0
- slmeval/core/runner.py +1209 -0
- slmeval/core/trace.py +188 -0
- slmeval/core/verdict.py +127 -0
- slmeval/dataset/__init__.py +8 -0
- slmeval/dataset/loader.py +130 -0
- slmeval/dataset/models.py +108 -0
- slmeval/doctor/__init__.py +7 -0
- slmeval/doctor/checks.py +380 -0
- slmeval/errors.py +76 -0
- slmeval/eval/__init__.py +64 -0
- slmeval/eval/assertions.py +356 -0
- slmeval/eval/calibration.py +364 -0
- slmeval/eval/cost.py +291 -0
- slmeval/eval/pipeline.py +273 -0
- slmeval/eval/stats.py +376 -0
- slmeval/eval/tier1.py +510 -0
- slmeval/eval/tier2.py +582 -0
- slmeval/eval/tier3.py +415 -0
- slmeval/exit_codes.py +28 -0
- slmeval/gate.py +722 -0
- slmeval/providers/__init__.py +69 -0
- slmeval/providers/base.py +426 -0
- slmeval/providers/fake.py +760 -0
- slmeval/providers/jsonx.py +239 -0
- slmeval/providers/ladder.py +339 -0
- slmeval/providers/openai_compat.py +392 -0
- slmeval/providers/registry.py +185 -0
- slmeval/providers/retry.py +81 -0
- slmeval/providers/schema.py +136 -0
- slmeval/providers/tokens.py +150 -0
- slmeval/py.typed +0 -0
- slmeval/report/__init__.py +115 -0
- slmeval/report/attribution.py +197 -0
- slmeval/report/baseline.py +353 -0
- slmeval/report/drilldown.py +1395 -0
- slmeval/report/export.py +450 -0
- slmeval/report/model.py +562 -0
- slmeval/report/progress.py +257 -0
- slmeval/report/scorecard.py +367 -0
- slmeval/sandbox/__init__.py +145 -0
- slmeval/sandbox/diagnosis.py +690 -0
- slmeval/sandbox/envelope.py +486 -0
- slmeval/sandbox/faults.py +794 -0
- slmeval/sandbox/keys.py +458 -0
- slmeval/sandbox/normalizers.py +333 -0
- slmeval/sandbox/store.py +525 -0
- slmeval/sandbox/tool.py +685 -0
- slmeval/storage/__init__.py +73 -0
- slmeval/storage/blobs.py +308 -0
- slmeval/storage/codec.py +93 -0
- slmeval/storage/db.py +1334 -0
- slmeval/storage/ingest.py +346 -0
- slmeval/storage/migrations.py +358 -0
- slmeval/storage/models.py +554 -0
- slmeval/storage/provenance.py +201 -0
- slmeval_cli-0.1.0.dist-info/METADATA +484 -0
- slmeval_cli-0.1.0.dist-info/RECORD +94 -0
- slmeval_cli-0.1.0.dist-info/WHEEL +4 -0
- slmeval_cli-0.1.0.dist-info/entry_points.txt +2 -0
- slmeval_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""The live progress view, as an :class:`~slmeval.core.runner.Observer`.
|
|
2
|
+
|
|
3
|
+
A matrix run takes twenty minutes. The user has to be able to see it working
|
|
4
|
+
and form an opinion before it ends - a silent terminal for twenty minutes is
|
|
5
|
+
indistinguishable from a hang, and the first thing anyone does about a hang is
|
|
6
|
+
``Ctrl-C``.
|
|
7
|
+
|
|
8
|
+
It lives here rather than in the runner because the runner must not print. That
|
|
9
|
+
keeps the orchestration testable without a terminal, and it means this whole
|
|
10
|
+
file can be swapped for a JSON-lines observer in CI without touching the
|
|
11
|
+
scheduler.
|
|
12
|
+
|
|
13
|
+
Three details that matter more than they look:
|
|
14
|
+
|
|
15
|
+
- **One bar per candidate.** The interesting question mid-run is not "how far
|
|
16
|
+
along" but "is this candidate collapsing", and a single aggregate bar cannot
|
|
17
|
+
answer it.
|
|
18
|
+
- **Live pass / fail / error counters, with errors separate.** A harness error
|
|
19
|
+
must be visibly not a model failure even while the run is still going.
|
|
20
|
+
- **Non-TTY degrades to plain lines.** Piped into a CI log, a repainting
|
|
21
|
+
progress bar becomes thousands of escape sequences; the same information goes
|
|
22
|
+
out one line at a time instead.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from types import TracebackType
|
|
29
|
+
|
|
30
|
+
from rich.console import Console
|
|
31
|
+
from rich.progress import (
|
|
32
|
+
BarColumn,
|
|
33
|
+
MofNCompleteColumn,
|
|
34
|
+
Progress,
|
|
35
|
+
SpinnerColumn,
|
|
36
|
+
TaskID,
|
|
37
|
+
TaskProgressColumn,
|
|
38
|
+
TextColumn,
|
|
39
|
+
TimeElapsedColumn,
|
|
40
|
+
TimeRemainingColumn,
|
|
41
|
+
)
|
|
42
|
+
from rich.text import Text
|
|
43
|
+
|
|
44
|
+
from slmeval.cli.render import console as make_console
|
|
45
|
+
from slmeval.cli.render import symbols
|
|
46
|
+
from slmeval.core.runner import RunPlan, RunSummary, UnitResult, WorkUnit
|
|
47
|
+
from slmeval.storage.models import Outcome
|
|
48
|
+
|
|
49
|
+
_QUIET_OUTCOMES = frozenset({Outcome.PASSED, Outcome.UNCLASSIFIED})
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class Tally:
|
|
54
|
+
"""Live counters. ``errors`` is ours, and stays apart from ``failed``."""
|
|
55
|
+
|
|
56
|
+
passed: int = 0
|
|
57
|
+
failed: int = 0
|
|
58
|
+
errors: int = 0
|
|
59
|
+
uncovered: int = 0
|
|
60
|
+
unsafe: int = 0
|
|
61
|
+
|
|
62
|
+
def add(self, result: UnitResult) -> None:
|
|
63
|
+
if result.outcome is Outcome.PASSED:
|
|
64
|
+
self.passed += 1
|
|
65
|
+
elif result.outcome is Outcome.HARNESS_ERROR:
|
|
66
|
+
self.errors += 1
|
|
67
|
+
elif result.outcome is Outcome.UNCOVERED:
|
|
68
|
+
self.uncovered += 1
|
|
69
|
+
else:
|
|
70
|
+
self.failed += 1
|
|
71
|
+
if result.outcome is Outcome.FORBIDDEN_TOOL:
|
|
72
|
+
self.unsafe += 1
|
|
73
|
+
|
|
74
|
+
def render(self) -> str:
|
|
75
|
+
parts = [f"{self.passed} pass", f"{self.failed} fail"]
|
|
76
|
+
if self.errors:
|
|
77
|
+
parts.append(f"{self.errors} harness")
|
|
78
|
+
if self.uncovered:
|
|
79
|
+
parts.append(f"{self.uncovered} uncovered")
|
|
80
|
+
if self.unsafe:
|
|
81
|
+
parts.append(f"{self.unsafe} UNSAFE")
|
|
82
|
+
return " · ".join(parts)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class ProgressObserver:
|
|
87
|
+
"""Renders a run as it happens. Use as a context manager."""
|
|
88
|
+
|
|
89
|
+
console: Console = field(default_factory=make_console)
|
|
90
|
+
ascii_mode: bool | None = None
|
|
91
|
+
quiet: bool = False
|
|
92
|
+
|
|
93
|
+
_progress: Progress | None = field(default=None, init=False, repr=False)
|
|
94
|
+
_tasks: dict[str, TaskID] = field(default_factory=dict, init=False, repr=False)
|
|
95
|
+
_overall: TaskID | None = field(default=None, init=False, repr=False)
|
|
96
|
+
_tally: Tally = field(default_factory=Tally, init=False, repr=False)
|
|
97
|
+
_plain: bool = field(default=False, init=False, repr=False)
|
|
98
|
+
|
|
99
|
+
def __post_init__(self) -> None:
|
|
100
|
+
# Rich's own terminal detection, not an environment guess: it already
|
|
101
|
+
# accounts for NO_COLOR, dumb terminals and redirected output.
|
|
102
|
+
self._plain = self.quiet or not self.console.is_terminal
|
|
103
|
+
|
|
104
|
+
# ------------------------------------------------------------- lifecycle
|
|
105
|
+
|
|
106
|
+
def __enter__(self) -> ProgressObserver:
|
|
107
|
+
return self
|
|
108
|
+
|
|
109
|
+
def __exit__(
|
|
110
|
+
self,
|
|
111
|
+
exc_type: type[BaseException] | None,
|
|
112
|
+
exc: BaseException | None,
|
|
113
|
+
tb: TracebackType | None,
|
|
114
|
+
) -> None:
|
|
115
|
+
self.close()
|
|
116
|
+
|
|
117
|
+
def close(self) -> None:
|
|
118
|
+
if self._progress is not None:
|
|
119
|
+
self._progress.stop()
|
|
120
|
+
self._progress = None
|
|
121
|
+
|
|
122
|
+
# -------------------------------------------------------------- observer
|
|
123
|
+
|
|
124
|
+
def on_plan(self, plan: RunPlan) -> None:
|
|
125
|
+
self.console.print()
|
|
126
|
+
self.console.print(
|
|
127
|
+
Text(f" {plan.name}", style="bold"),
|
|
128
|
+
Text(
|
|
129
|
+
f" · {len(plan.matrix.candidate_ids)} candidates"
|
|
130
|
+
f" · {len(plan.matrix.case_ids)} cases"
|
|
131
|
+
f" x {plan.matrix.repeats} runs"
|
|
132
|
+
f" · {plan.matrix.planned} executions",
|
|
133
|
+
style="dim",
|
|
134
|
+
),
|
|
135
|
+
)
|
|
136
|
+
detail = f" {plan.run_id} · {plan.tool_mode} · {plan.scheduling}"
|
|
137
|
+
if plan.limits:
|
|
138
|
+
detail += f" · limits {plan.limits}"
|
|
139
|
+
if plan.matrix.resumed:
|
|
140
|
+
detail += f" · resuming, {len(plan.matrix.resumed)} already done"
|
|
141
|
+
self.console.print(Text(detail, style="dim"))
|
|
142
|
+
self.console.print()
|
|
143
|
+
|
|
144
|
+
if self._plain:
|
|
145
|
+
return
|
|
146
|
+
self._progress = Progress(
|
|
147
|
+
SpinnerColumn(style="dim"),
|
|
148
|
+
TextColumn("[progress.description]{task.description}"),
|
|
149
|
+
BarColumn(bar_width=28),
|
|
150
|
+
MofNCompleteColumn(),
|
|
151
|
+
TaskProgressColumn(),
|
|
152
|
+
TimeElapsedColumn(),
|
|
153
|
+
TimeRemainingColumn(),
|
|
154
|
+
TextColumn("{task.fields[note]}"),
|
|
155
|
+
console=self.console,
|
|
156
|
+
transient=False,
|
|
157
|
+
)
|
|
158
|
+
self._progress.start()
|
|
159
|
+
for candidate_id in plan.matrix.candidate_ids:
|
|
160
|
+
total = len(plan.matrix.for_candidate(candidate_id))
|
|
161
|
+
if not total:
|
|
162
|
+
continue
|
|
163
|
+
self._tasks[candidate_id] = self._progress.add_task(
|
|
164
|
+
f" {candidate_id}", total=total, note=""
|
|
165
|
+
)
|
|
166
|
+
self._overall = self._progress.add_task(
|
|
167
|
+
" all", total=len(plan.matrix.units), note=""
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def on_unit_start(self, unit: WorkUnit) -> None:
|
|
171
|
+
if self._progress is None:
|
|
172
|
+
return
|
|
173
|
+
task = self._tasks.get(unit.candidate_id)
|
|
174
|
+
if task is not None:
|
|
175
|
+
self._progress.update(task, note=f"[dim]{unit.case_id}#{unit.repeat_idx}[/dim]")
|
|
176
|
+
|
|
177
|
+
def on_unit_done(self, result: UnitResult) -> None:
|
|
178
|
+
self._tally.add(result)
|
|
179
|
+
if self._progress is None:
|
|
180
|
+
self._print_line(result)
|
|
181
|
+
return
|
|
182
|
+
task = self._tasks.get(result.candidate_id)
|
|
183
|
+
if task is not None:
|
|
184
|
+
self._progress.advance(task)
|
|
185
|
+
if self._overall is not None:
|
|
186
|
+
self._progress.update(
|
|
187
|
+
self._overall, advance=1, note=f"[dim]{self._tally.render()}[/dim]"
|
|
188
|
+
)
|
|
189
|
+
# A forbidden tool call is not something to leave in a progress field
|
|
190
|
+
# that scrolls away. It is printed as its own line, in red, while the
|
|
191
|
+
# run continues.
|
|
192
|
+
if result.outcome is Outcome.FORBIDDEN_TOOL:
|
|
193
|
+
self._progress.console.print(
|
|
194
|
+
Text(
|
|
195
|
+
f" {symbols(self.ascii_mode).fail} FORBIDDEN TOOL in "
|
|
196
|
+
f"{result.unit.case_id} ({result.candidate_id})",
|
|
197
|
+
style="bold red",
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
elif result.outcome is Outcome.HARNESS_ERROR:
|
|
201
|
+
self._progress.console.print(
|
|
202
|
+
Text(
|
|
203
|
+
f" {symbols(self.ascii_mode).fail} harness error in "
|
|
204
|
+
f"{result.unit}: {result.error_class} - this is our bug, not "
|
|
205
|
+
"the model's",
|
|
206
|
+
style="bold magenta",
|
|
207
|
+
)
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
def _print_line(self, result: UnitResult) -> None:
|
|
211
|
+
"""One line per execution, for a log rather than a terminal.
|
|
212
|
+
|
|
213
|
+
Failures and anything unsafe always print. Passes do not: a 1,500-row
|
|
214
|
+
CI log of "ok" is noise that hides the twelve lines that matter.
|
|
215
|
+
"""
|
|
216
|
+
if result.outcome in _QUIET_OUTCOMES:
|
|
217
|
+
return
|
|
218
|
+
sym = symbols(self.ascii_mode)
|
|
219
|
+
self.console.print(
|
|
220
|
+
Text(
|
|
221
|
+
f" {sym.fail} {result.unit} {sym.arrow} {result.outcome.value}"
|
|
222
|
+
+ (f": {result.error_message}" if result.error_message else ""),
|
|
223
|
+
style="red" if result.outcome is not Outcome.UNCOVERED else "cyan",
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
def on_note(self, message: str) -> None:
|
|
228
|
+
target = self._progress.console if self._progress else self.console
|
|
229
|
+
target.print(Text(f" {symbols(self.ascii_mode).skip} {message}", style="dim"))
|
|
230
|
+
|
|
231
|
+
def on_finish(self, summary: RunSummary) -> None:
|
|
232
|
+
self.close()
|
|
233
|
+
sym = symbols(self.ascii_mode)
|
|
234
|
+
self.console.print()
|
|
235
|
+
style = "bold green" if summary.complete else "bold yellow"
|
|
236
|
+
line = (
|
|
237
|
+
f" {sym.ok if summary.complete else sym.warn} "
|
|
238
|
+
f"{summary.executed} executed"
|
|
239
|
+
)
|
|
240
|
+
if summary.resumed:
|
|
241
|
+
line += f", {summary.resumed} resumed"
|
|
242
|
+
line += f" of {summary.planned} · {self._tally.render()}"
|
|
243
|
+
if summary.spend_usd:
|
|
244
|
+
line += f" · ${summary.spend_usd:.4f}"
|
|
245
|
+
self.console.print(Text(line, style=style))
|
|
246
|
+
if summary.aborted_reason:
|
|
247
|
+
self.console.print(
|
|
248
|
+
Text(f" {sym.warn} {summary.aborted_reason}", style="yellow")
|
|
249
|
+
)
|
|
250
|
+
self.console.print()
|
|
251
|
+
|
|
252
|
+
@property
|
|
253
|
+
def tally(self) -> Tally:
|
|
254
|
+
return self._tally
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
__all__ = ["ProgressObserver", "Tally"]
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""The scorecard. For a CLI tool, the terminal table *is* the product.
|
|
2
|
+
|
|
3
|
+
This is what gets screenshotted, posted, and pasted into a pull request, so it
|
|
4
|
+
gets the same care as the measurement code. Pure functions from a
|
|
5
|
+
:class:`~slmeval.report.model.Scorecard` to a Rich renderable - nothing here
|
|
6
|
+
prints, which is what makes every view snapshot-testable.
|
|
7
|
+
|
|
8
|
+
Four rules it follows, all of them about not being misleading:
|
|
9
|
+
|
|
10
|
+
**Always print ``n``.** A rate without its sample size is a number pretending
|
|
11
|
+
to be evidence, and the whole product's credibility rests on nobody
|
|
12
|
+
over-reading three samples.
|
|
13
|
+
|
|
14
|
+
**Always print the interval, next to the point estimate.** Seeing ``100%`` and
|
|
15
|
+
``[44%, 100%]`` side by side teaches the reader more about ``n = 3`` than any
|
|
16
|
+
documentation will.
|
|
17
|
+
|
|
18
|
+
**Colour by delta, not by absolute value.** 83% is not red; 83% where the
|
|
19
|
+
baseline got 97% is. Colouring absolutes would paint a hard benchmark red
|
|
20
|
+
everywhere and a trivial one green, telling the reader nothing either way.
|
|
21
|
+
|
|
22
|
+
**Warnings go above the table.** A forbidden tool call must never be a column
|
|
23
|
+
somebody scrolls past. It appears before the first number, and so does a
|
|
24
|
+
harness error, because that one calls the whole run into question.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from rich.console import Group, RenderableType
|
|
30
|
+
from rich.table import Table
|
|
31
|
+
from rich.text import Text
|
|
32
|
+
|
|
33
|
+
from slmeval.cli.render import Symbols, ascii_only, symbols
|
|
34
|
+
from slmeval.cli.render import dash as dash_for
|
|
35
|
+
from slmeval.report.model import CandidateReport, Scorecard
|
|
36
|
+
|
|
37
|
+
GOOD = "green"
|
|
38
|
+
BAD = "red"
|
|
39
|
+
MEH = "yellow"
|
|
40
|
+
DIM = "dim"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def render_scorecard(
|
|
44
|
+
card: Scorecard,
|
|
45
|
+
*,
|
|
46
|
+
ascii_mode: bool | None = None,
|
|
47
|
+
show_nodes: bool = False,
|
|
48
|
+
) -> RenderableType:
|
|
49
|
+
"""The whole scorecard: header, warnings, table, footer."""
|
|
50
|
+
sym = symbols(ascii_mode)
|
|
51
|
+
parts: list[RenderableType] = [
|
|
52
|
+
Text(""),
|
|
53
|
+
header(card),
|
|
54
|
+
Text(""),
|
|
55
|
+
]
|
|
56
|
+
notes = warnings_block(card, ascii_mode=ascii_mode)
|
|
57
|
+
if notes is not None:
|
|
58
|
+
parts += [notes, Text("")]
|
|
59
|
+
parts.append(table(card, ascii_mode=ascii_mode))
|
|
60
|
+
if show_nodes:
|
|
61
|
+
from slmeval.report.attribution import render_attribution
|
|
62
|
+
|
|
63
|
+
parts += [Text(""), render_attribution(card, ascii_mode=ascii_mode)]
|
|
64
|
+
parts += [Text(""), footer(card, sym)]
|
|
65
|
+
return Group(*parts)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def header(card: Scorecard) -> Text:
|
|
69
|
+
"""One line naming everything that would invalidate a comparison.
|
|
70
|
+
|
|
71
|
+
The seed, the tool mode and the fault rate are in the headline rather than
|
|
72
|
+
a footnote because they are what someone re-running this needs, and because
|
|
73
|
+
two tables that differ in any of them are not comparable however similar
|
|
74
|
+
they look.
|
|
75
|
+
"""
|
|
76
|
+
run = card.run
|
|
77
|
+
out = Text(" ")
|
|
78
|
+
out.append(run.name, style="bold")
|
|
79
|
+
bits = [
|
|
80
|
+
f"{len(card.cases)} cases x {card.repeats} runs",
|
|
81
|
+
run.mode,
|
|
82
|
+
f"faults {run.fault_rate:.0%}" if run.fault_rate else "no faults",
|
|
83
|
+
f"seed {run.seed}",
|
|
84
|
+
run.scheduling,
|
|
85
|
+
]
|
|
86
|
+
out.append(" · ", style=DIM)
|
|
87
|
+
out.append(" · ".join(bits), style=DIM)
|
|
88
|
+
out.append("\n ")
|
|
89
|
+
out.append(f"{run.run_id}", style=DIM)
|
|
90
|
+
if run.git_sha:
|
|
91
|
+
out.append(f" · {run.git_sha}", style=DIM)
|
|
92
|
+
out.append(f" · dataset {run.dataset_hash}", style=DIM)
|
|
93
|
+
return out
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def warnings_block(
|
|
97
|
+
card: Scorecard, *, ascii_mode: bool | None = None
|
|
98
|
+
) -> Text | None:
|
|
99
|
+
"""The lines above the table, worst first."""
|
|
100
|
+
notes = card.warnings()
|
|
101
|
+
if not notes:
|
|
102
|
+
return None
|
|
103
|
+
sym = symbols(ascii_mode)
|
|
104
|
+
out = Text()
|
|
105
|
+
for index, note in enumerate(notes):
|
|
106
|
+
glyph, style = _note_style(note, sym)
|
|
107
|
+
out.append(f" {glyph} ", style=style)
|
|
108
|
+
out.append(note, style=style)
|
|
109
|
+
if index < len(notes) - 1:
|
|
110
|
+
out.append("\n")
|
|
111
|
+
return out
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _note_style(note: str, sym: Symbols) -> tuple[str, str]:
|
|
115
|
+
lowered = note.lower()
|
|
116
|
+
if "forbidden" in lowered:
|
|
117
|
+
return sym.fail, "bold red"
|
|
118
|
+
if "inside slmeval" in lowered:
|
|
119
|
+
return sym.fail, "bold magenta"
|
|
120
|
+
# Not red, and that is the point: an uncovered path is a gap in our
|
|
121
|
+
# recordings, not a model failure, and colouring it like one would undo the
|
|
122
|
+
# distinction the diagnosis exists to draw.
|
|
123
|
+
if "do not cover" in lowered:
|
|
124
|
+
return sym.warn, "cyan"
|
|
125
|
+
if "latency not measured" in lowered:
|
|
126
|
+
return sym.skip, DIM
|
|
127
|
+
return sym.warn, MEH
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def table(card: Scorecard, *, ascii_mode: bool | None = None) -> Table:
|
|
131
|
+
"""The table itself."""
|
|
132
|
+
dash = dash_for(ascii_mode)
|
|
133
|
+
grid = Table(
|
|
134
|
+
box=None,
|
|
135
|
+
pad_edge=False,
|
|
136
|
+
padding=(0, 2),
|
|
137
|
+
header_style="bold",
|
|
138
|
+
show_edge=False,
|
|
139
|
+
)
|
|
140
|
+
grid.add_column("Candidate", no_wrap=True)
|
|
141
|
+
grid.add_column(f"pass^{card.repeats}", justify="right", no_wrap=True)
|
|
142
|
+
grid.add_column("95% CI", justify="right", no_wrap=True)
|
|
143
|
+
grid.add_column("runs", justify="right", no_wrap=True)
|
|
144
|
+
grid.add_column("Schema", justify="right", no_wrap=True)
|
|
145
|
+
grid.add_column("Tool F1", justify="right", no_wrap=True)
|
|
146
|
+
grid.add_column("Steps", justify="right", no_wrap=True)
|
|
147
|
+
if card.latency_reportable:
|
|
148
|
+
grid.add_column("p95 ms", justify="right", no_wrap=True)
|
|
149
|
+
grid.add_column("$/case", justify="right", no_wrap=True)
|
|
150
|
+
grid.add_column(_delta_header(ascii_mode), justify="right", no_wrap=True)
|
|
151
|
+
|
|
152
|
+
for row in card.rows:
|
|
153
|
+
grid.add_row(*_cells(card, row, dash=dash))
|
|
154
|
+
return grid
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _delta_header(ascii_mode: bool | None) -> str:
|
|
158
|
+
"""``d Cost`` on a legacy console, which cannot encode a delta glyph."""
|
|
159
|
+
return "d Cost" if ascii_only(ascii_mode) else "Δ Cost"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _cells(card: Scorecard, row: CandidateReport, *, dash: str) -> list[RenderableType]:
|
|
163
|
+
estimated = "~" if row.tokens_estimated else ""
|
|
164
|
+
cells: list[RenderableType] = [
|
|
165
|
+
_name(row),
|
|
166
|
+
_pass_cell(card, row, dash),
|
|
167
|
+
_interval_cell(row, dash),
|
|
168
|
+
Text(
|
|
169
|
+
f"{row.stats.passes}/{row.stats.executions}"
|
|
170
|
+
if row.stats.executions
|
|
171
|
+
else dash,
|
|
172
|
+
style=DIM,
|
|
173
|
+
),
|
|
174
|
+
_rate_cell(card, row, row.schema_valid_rate, "schema", dash),
|
|
175
|
+
_rate_cell(card, row, row.tool_f1, "f1", dash, digits=2),
|
|
176
|
+
Text(f"{row.mean_steps:.1f}" if row.mean_steps is not None else dash),
|
|
177
|
+
]
|
|
178
|
+
if card.latency_reportable:
|
|
179
|
+
cells.append(
|
|
180
|
+
Text(f"{row.p95_ms:,.0f}" if row.p95_ms is not None else dash)
|
|
181
|
+
)
|
|
182
|
+
cells.append(
|
|
183
|
+
Text(
|
|
184
|
+
f"{estimated}${row.cost_per_case:.4f}"
|
|
185
|
+
if row.cost_per_case is not None
|
|
186
|
+
else dash,
|
|
187
|
+
style=DIM if row.cost_per_case == 0 else "",
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
cells.append(_delta_cost_cell(card, row, dash))
|
|
191
|
+
return cells
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _name(row: CandidateReport) -> Text:
|
|
195
|
+
out = Text()
|
|
196
|
+
if row.baseline and row.candidate_id != "baseline":
|
|
197
|
+
out.append("baseline ", style=DIM)
|
|
198
|
+
out.append(row.candidate_id, style="bold" if row.baseline else "")
|
|
199
|
+
return out
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _pass_cell(card: Scorecard, row: CandidateReport, dash: str) -> Text:
|
|
203
|
+
value = row.pass_hat_k
|
|
204
|
+
if value is None:
|
|
205
|
+
return Text(dash, style=DIM)
|
|
206
|
+
delta = card.delta_pass(row)
|
|
207
|
+
return Text(f"{value:.1%}", style=_delta_style(delta, bigger_is_better=True))
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _interval_cell(row: CandidateReport, dash: str) -> Text:
|
|
211
|
+
interval = row.stats.interval
|
|
212
|
+
if interval is None:
|
|
213
|
+
return Text(dash, style=DIM)
|
|
214
|
+
return Text(interval.render(), style=DIM)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _rate_cell(
|
|
218
|
+
card: Scorecard,
|
|
219
|
+
row: CandidateReport,
|
|
220
|
+
value: float | None,
|
|
221
|
+
which: str,
|
|
222
|
+
dash: str,
|
|
223
|
+
*,
|
|
224
|
+
digits: int = 0,
|
|
225
|
+
) -> Text:
|
|
226
|
+
"""One rate, coloured by how it compares with the baseline's own.
|
|
227
|
+
|
|
228
|
+
``which`` names the baseline figure to compare against rather than taking
|
|
229
|
+
the number directly, because the baseline row has to be able to render
|
|
230
|
+
itself - and it has no delta against itself to colour by.
|
|
231
|
+
"""
|
|
232
|
+
if value is None:
|
|
233
|
+
return Text(dash, style=DIM)
|
|
234
|
+
reference = _reference_value(card, which)
|
|
235
|
+
delta = None if reference is None or row.baseline else value - reference
|
|
236
|
+
text = f"{value:.{digits}f}" if digits else f"{value:.0%}"
|
|
237
|
+
return Text(text, style=_delta_style(delta, bigger_is_better=True))
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _delta_cost_cell(card: Scorecard, row: CandidateReport, dash: str) -> Text:
|
|
241
|
+
delta = card.delta_cost(row)
|
|
242
|
+
if delta is None:
|
|
243
|
+
return Text(dash, style=DIM)
|
|
244
|
+
# Cheaper is better, so the sign convention is inverted against the
|
|
245
|
+
# quality columns. Spelled out here rather than left to the reader.
|
|
246
|
+
return Text(f"{delta:+.0%}", style=_delta_style(delta, bigger_is_better=False))
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _reference_value(card: Scorecard, which: str) -> float | None:
|
|
250
|
+
base = card.baseline
|
|
251
|
+
if base is None:
|
|
252
|
+
return None
|
|
253
|
+
return {"schema": base.schema_valid_rate, "f1": base.tool_f1}.get(which)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _delta_style(delta: float | None, *, bigger_is_better: bool) -> str:
|
|
257
|
+
"""Colour from the change, never the level.
|
|
258
|
+
|
|
259
|
+
A threshold of one percentage point, so floating-point noise and a genuinely
|
|
260
|
+
identical result both render neutral instead of flickering green and red
|
|
261
|
+
between runs of the same config.
|
|
262
|
+
"""
|
|
263
|
+
if delta is None or abs(delta) < 0.01:
|
|
264
|
+
return ""
|
|
265
|
+
improved = delta > 0 if bigger_is_better else delta < 0
|
|
266
|
+
return GOOD if improved else BAD
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def footer(card: Scorecard, sym: Symbols) -> Text:
|
|
270
|
+
"""Where the numbers came from, in one dim block.
|
|
271
|
+
|
|
272
|
+
The estimator and the price date belong on the artefact, not in the
|
|
273
|
+
documentation: this table gets screenshotted away from everything that
|
|
274
|
+
would otherwise explain it.
|
|
275
|
+
"""
|
|
276
|
+
out = Text()
|
|
277
|
+
out.append(
|
|
278
|
+
f" pass^{card.repeats} = fraction of cases passing every repeat; "
|
|
279
|
+
"95% CI is the Wilson interval on that fraction\n",
|
|
280
|
+
style=DIM,
|
|
281
|
+
)
|
|
282
|
+
out.append(
|
|
283
|
+
f" gate on the lower bound, never the point estimate "
|
|
284
|
+
f"(n={len(card.cases)} cases)\n",
|
|
285
|
+
style=DIM,
|
|
286
|
+
)
|
|
287
|
+
out.append(f" prices as of {card.prices_as_of}", style=DIM)
|
|
288
|
+
if any(r.recovery_rate is not None for r in card.rows):
|
|
289
|
+
out.append("\n ", style=DIM)
|
|
290
|
+
out.append(
|
|
291
|
+
" · ".join(
|
|
292
|
+
f"{r.candidate_id} recovered from {r.recovery_rate:.0%} of faults"
|
|
293
|
+
for r in card.rows
|
|
294
|
+
if r.recovery_rate is not None
|
|
295
|
+
),
|
|
296
|
+
style=DIM,
|
|
297
|
+
)
|
|
298
|
+
return out
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def render_summary_line(card: Scorecard, *, ascii_mode: bool | None = None) -> Text:
|
|
302
|
+
"""The one-line verdict the whole product exists to print.
|
|
303
|
+
|
|
304
|
+
intent_router: gpt-4o -> gemma3:4b - cost -78%, pass^3 96.4% -> 91.2%
|
|
305
|
+
|
|
306
|
+
Only for the single-candidate case. With several candidates there is no
|
|
307
|
+
single recommendation to make, and inventing one would be the tool
|
|
308
|
+
editorialising over the user's own trade-off.
|
|
309
|
+
"""
|
|
310
|
+
sym = symbols(ascii_mode)
|
|
311
|
+
base = card.baseline
|
|
312
|
+
others = card.candidates
|
|
313
|
+
if base is None or len(others) != 1:
|
|
314
|
+
return Text("")
|
|
315
|
+
row = others[0]
|
|
316
|
+
cost = card.delta_cost(row)
|
|
317
|
+
out = Text(" ")
|
|
318
|
+
out.append(f"{base.model} {sym.arrow} {row.model}", style="bold")
|
|
319
|
+
if cost is not None:
|
|
320
|
+
out.append(f" - cost {cost:+.0%}", style=GOOD if cost < 0 else BAD)
|
|
321
|
+
if base.pass_hat_k is not None and row.pass_hat_k is not None:
|
|
322
|
+
interval = row.stats.interval
|
|
323
|
+
tail = f" (CI {interval.render()})" if interval else ""
|
|
324
|
+
out.append(
|
|
325
|
+
f", pass^{card.repeats} {base.pass_hat_k:.1%} {sym.arrow} "
|
|
326
|
+
f"{row.pass_hat_k:.1%}{tail}"
|
|
327
|
+
)
|
|
328
|
+
return out
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def render_rollups(
|
|
332
|
+
card: Scorecard, *, ascii_mode: bool | None = None, limit: int = 0
|
|
333
|
+
) -> Table:
|
|
334
|
+
"""Per-case detail: which cases each candidate actually failed.
|
|
335
|
+
|
|
336
|
+
The scorecard says a candidate lost four points; this says which four cases
|
|
337
|
+
and how consistently. A user cannot act on the first without the second.
|
|
338
|
+
"""
|
|
339
|
+
dash = dash_for(ascii_mode)
|
|
340
|
+
grid = Table(box=None, pad_edge=False, padding=(0, 2), header_style="bold", show_edge=False)
|
|
341
|
+
grid.add_column("Case", no_wrap=True)
|
|
342
|
+
for row in card.rows:
|
|
343
|
+
grid.add_column(row.candidate_id, justify="right", no_wrap=True)
|
|
344
|
+
|
|
345
|
+
shown = card.cases[:limit] if limit else card.cases
|
|
346
|
+
for case_id in shown:
|
|
347
|
+
cells: list[RenderableType] = [Text(case_id)]
|
|
348
|
+
for row in card.rows:
|
|
349
|
+
found = row.rollups.get(case_id)
|
|
350
|
+
if found is None or not found.n:
|
|
351
|
+
cells.append(Text(dash, style=DIM))
|
|
352
|
+
continue
|
|
353
|
+
style = GOOD if found.all_passed else (MEH if found.c else BAD)
|
|
354
|
+
cells.append(Text(f"{found.c}/{found.n}", style=style))
|
|
355
|
+
grid.add_row(*cells)
|
|
356
|
+
return grid
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
__all__ = [
|
|
360
|
+
"footer",
|
|
361
|
+
"header",
|
|
362
|
+
"render_rollups",
|
|
363
|
+
"render_scorecard",
|
|
364
|
+
"render_summary_line",
|
|
365
|
+
"table",
|
|
366
|
+
"warnings_block",
|
|
367
|
+
]
|