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.
Files changed (94) hide show
  1. slmeval/__init__.py +71 -0
  2. slmeval/__main__.py +14 -0
  3. slmeval/adapters/__init__.py +24 -0
  4. slmeval/adapters/instrument.py +135 -0
  5. slmeval/adapters/langgraph.py +237 -0
  6. slmeval/cli/__init__.py +15 -0
  7. slmeval/cli/commands/__init__.py +35 -0
  8. slmeval/cli/commands/baseline_cmd.py +201 -0
  9. slmeval/cli/commands/cache_cmd.py +249 -0
  10. slmeval/cli/commands/dataset_cmd.py +52 -0
  11. slmeval/cli/commands/doctor_cmd.py +68 -0
  12. slmeval/cli/commands/gate_cmd.py +602 -0
  13. slmeval/cli/commands/init_cmd.py +144 -0
  14. slmeval/cli/commands/judge_cmd.py +284 -0
  15. slmeval/cli/commands/record_cmd.py +154 -0
  16. slmeval/cli/commands/report_cmd.py +98 -0
  17. slmeval/cli/commands/run_cmd.py +247 -0
  18. slmeval/cli/commands/show_cmd.py +482 -0
  19. slmeval/cli/commands/stubs.py +57 -0
  20. slmeval/cli/commands/validate_cmd.py +296 -0
  21. slmeval/cli/loading.py +149 -0
  22. slmeval/cli/main.py +91 -0
  23. slmeval/cli/render.py +251 -0
  24. slmeval/cli/wrap.py +31 -0
  25. slmeval/config/__init__.py +47 -0
  26. slmeval/config/loader.py +363 -0
  27. slmeval/config/locate.py +97 -0
  28. slmeval/config/models.py +430 -0
  29. slmeval/config/templates.py +246 -0
  30. slmeval/core/__init__.py +78 -0
  31. slmeval/core/adapter.py +253 -0
  32. slmeval/core/context.py +418 -0
  33. slmeval/core/outcome.py +260 -0
  34. slmeval/core/router.py +319 -0
  35. slmeval/core/runner.py +1209 -0
  36. slmeval/core/trace.py +188 -0
  37. slmeval/core/verdict.py +127 -0
  38. slmeval/dataset/__init__.py +8 -0
  39. slmeval/dataset/loader.py +130 -0
  40. slmeval/dataset/models.py +108 -0
  41. slmeval/doctor/__init__.py +7 -0
  42. slmeval/doctor/checks.py +380 -0
  43. slmeval/errors.py +76 -0
  44. slmeval/eval/__init__.py +64 -0
  45. slmeval/eval/assertions.py +356 -0
  46. slmeval/eval/calibration.py +364 -0
  47. slmeval/eval/cost.py +291 -0
  48. slmeval/eval/pipeline.py +273 -0
  49. slmeval/eval/stats.py +376 -0
  50. slmeval/eval/tier1.py +510 -0
  51. slmeval/eval/tier2.py +582 -0
  52. slmeval/eval/tier3.py +415 -0
  53. slmeval/exit_codes.py +28 -0
  54. slmeval/gate.py +722 -0
  55. slmeval/providers/__init__.py +69 -0
  56. slmeval/providers/base.py +426 -0
  57. slmeval/providers/fake.py +760 -0
  58. slmeval/providers/jsonx.py +239 -0
  59. slmeval/providers/ladder.py +339 -0
  60. slmeval/providers/openai_compat.py +392 -0
  61. slmeval/providers/registry.py +185 -0
  62. slmeval/providers/retry.py +81 -0
  63. slmeval/providers/schema.py +136 -0
  64. slmeval/providers/tokens.py +150 -0
  65. slmeval/py.typed +0 -0
  66. slmeval/report/__init__.py +115 -0
  67. slmeval/report/attribution.py +197 -0
  68. slmeval/report/baseline.py +353 -0
  69. slmeval/report/drilldown.py +1395 -0
  70. slmeval/report/export.py +450 -0
  71. slmeval/report/model.py +562 -0
  72. slmeval/report/progress.py +257 -0
  73. slmeval/report/scorecard.py +367 -0
  74. slmeval/sandbox/__init__.py +145 -0
  75. slmeval/sandbox/diagnosis.py +690 -0
  76. slmeval/sandbox/envelope.py +486 -0
  77. slmeval/sandbox/faults.py +794 -0
  78. slmeval/sandbox/keys.py +458 -0
  79. slmeval/sandbox/normalizers.py +333 -0
  80. slmeval/sandbox/store.py +525 -0
  81. slmeval/sandbox/tool.py +685 -0
  82. slmeval/storage/__init__.py +73 -0
  83. slmeval/storage/blobs.py +308 -0
  84. slmeval/storage/codec.py +93 -0
  85. slmeval/storage/db.py +1334 -0
  86. slmeval/storage/ingest.py +346 -0
  87. slmeval/storage/migrations.py +358 -0
  88. slmeval/storage/models.py +554 -0
  89. slmeval/storage/provenance.py +201 -0
  90. slmeval_cli-0.1.0.dist-info/METADATA +484 -0
  91. slmeval_cli-0.1.0.dist-info/RECORD +94 -0
  92. slmeval_cli-0.1.0.dist-info/WHEEL +4 -0
  93. slmeval_cli-0.1.0.dist-info/entry_points.txt +2 -0
  94. 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
+ ]