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,602 @@
|
|
|
1
|
+
"""``slmeval gate`` - the command that blocks a merge.
|
|
2
|
+
|
|
3
|
+
The exit code is the product here. Everything printed is for the human reading
|
|
4
|
+
the CI log afterwards; the number is what the pipeline branches on, and it is a
|
|
5
|
+
documented contract (:mod:`slmeval.exit_codes`), frozen from 0.1.0:
|
|
6
|
+
|
|
7
|
+
==== ==========================================================
|
|
8
|
+
``0`` every threshold met
|
|
9
|
+
``1`` a threshold was not met. A real quality finding.
|
|
10
|
+
``2`` bad invocation - no candidate, no threshold, unreadable baseline
|
|
11
|
+
``3`` the model server was unreachable. **Infrastructure, not a regression.**
|
|
12
|
+
``4`` not enough recorded ground truth to judge the run
|
|
13
|
+
==== ==========================================================
|
|
14
|
+
|
|
15
|
+
Distinguishing 1 from 3 is the whole reason the codes exist. A job that reports
|
|
16
|
+
"Ollama was not running" as "the model got worse" teaches a team to ignore the
|
|
17
|
+
gate, and after that the gate is worth nothing.
|
|
18
|
+
|
|
19
|
+
Nothing here runs the matrix. ``gate`` reads a stored run, which means CI can
|
|
20
|
+
run ``slmeval run --tool-mode replay`` once and gate the result, and means a
|
|
21
|
+
failing gate can be re-examined locally with ``slmeval show`` without spending
|
|
22
|
+
another token.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import xml.etree.ElementTree as ElementTree
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Annotated, Any, Final
|
|
31
|
+
|
|
32
|
+
import typer
|
|
33
|
+
from rich.console import Group, RenderableType
|
|
34
|
+
from rich.table import Table
|
|
35
|
+
from rich.text import Text
|
|
36
|
+
|
|
37
|
+
from slmeval.cli.loading import LATEST, open_project, resolve_run
|
|
38
|
+
from slmeval.cli.render import Symbols, console, symbols
|
|
39
|
+
from slmeval.cli.wrap import handle_errors
|
|
40
|
+
from slmeval.errors import ConfigError
|
|
41
|
+
from slmeval.gate import (
|
|
42
|
+
Check,
|
|
43
|
+
GateReport,
|
|
44
|
+
Severity,
|
|
45
|
+
Thresholds,
|
|
46
|
+
blocking_divergences,
|
|
47
|
+
evaluate,
|
|
48
|
+
)
|
|
49
|
+
from slmeval.report.baseline import Baseline, baseline_path
|
|
50
|
+
from slmeval.report.model import build_scorecard
|
|
51
|
+
|
|
52
|
+
FORMATS: Final = ("text", "md", "json", "junit")
|
|
53
|
+
|
|
54
|
+
_SEVERITY_STYLE: Final = {
|
|
55
|
+
Severity.GATE: "bold red",
|
|
56
|
+
Severity.ENVIRONMENT: "bold yellow",
|
|
57
|
+
Severity.COVERAGE: "bold cyan",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@handle_errors
|
|
62
|
+
def gate(
|
|
63
|
+
config: Annotated[
|
|
64
|
+
Path | None,
|
|
65
|
+
typer.Option("--config", "-c", help="Path to eval.toml. Defaults to the nearest one."),
|
|
66
|
+
] = None,
|
|
67
|
+
run_id: Annotated[
|
|
68
|
+
str, typer.Option("--run-id", help="A run id or prefix, or 'latest'.")
|
|
69
|
+
] = LATEST,
|
|
70
|
+
min_pass_k: Annotated[
|
|
71
|
+
float | None,
|
|
72
|
+
typer.Option(
|
|
73
|
+
"--min-pass-k",
|
|
74
|
+
min=0.0,
|
|
75
|
+
max=1.0,
|
|
76
|
+
help="Floor on pass^k, read on the Wilson lower bound.",
|
|
77
|
+
),
|
|
78
|
+
] = None,
|
|
79
|
+
use_wilson_lower: Annotated[
|
|
80
|
+
bool | None,
|
|
81
|
+
typer.Option(
|
|
82
|
+
"--use-wilson-lower/--use-point-estimate",
|
|
83
|
+
help="Gate on the lower bound (default) or on the point estimate. "
|
|
84
|
+
"Three passes out of three is not 100%.",
|
|
85
|
+
),
|
|
86
|
+
] = None,
|
|
87
|
+
max_cost_ratio: Annotated[
|
|
88
|
+
float | None,
|
|
89
|
+
typer.Option(
|
|
90
|
+
"--max-cost-ratio",
|
|
91
|
+
min=0.0,
|
|
92
|
+
help="Candidate cost per case as a fraction of the baseline's.",
|
|
93
|
+
),
|
|
94
|
+
] = None,
|
|
95
|
+
max_p95_ratio: Annotated[
|
|
96
|
+
float | None,
|
|
97
|
+
typer.Option(
|
|
98
|
+
"--max-p95-ratio",
|
|
99
|
+
min=0.0,
|
|
100
|
+
help="Candidate p95 as a multiple of the baseline's. Needs a "
|
|
101
|
+
"--serial-latency run; skipped loudly otherwise.",
|
|
102
|
+
),
|
|
103
|
+
] = None,
|
|
104
|
+
no_forbidden_tools: Annotated[
|
|
105
|
+
bool | None,
|
|
106
|
+
typer.Option(
|
|
107
|
+
"--no-forbidden-tools/--allow-forbidden-tools",
|
|
108
|
+
help="Fail on any call to a tool a case forbids. On by default.",
|
|
109
|
+
),
|
|
110
|
+
] = None,
|
|
111
|
+
max_regression: Annotated[
|
|
112
|
+
float | None,
|
|
113
|
+
typer.Option(
|
|
114
|
+
"--max-regression",
|
|
115
|
+
min=0.0,
|
|
116
|
+
max=1.0,
|
|
117
|
+
help="Allowed drop in pass^k against the committed baseline file.",
|
|
118
|
+
),
|
|
119
|
+
] = None,
|
|
120
|
+
max_uncovered: Annotated[
|
|
121
|
+
float | None,
|
|
122
|
+
typer.Option(
|
|
123
|
+
"--max-uncovered",
|
|
124
|
+
min=0.0,
|
|
125
|
+
max=1.0,
|
|
126
|
+
help="Share of executions that may take unrecorded paths before the "
|
|
127
|
+
"run is called unjudgeable (exit 4).",
|
|
128
|
+
),
|
|
129
|
+
] = None,
|
|
130
|
+
max_provider_errors: Annotated[
|
|
131
|
+
float | None,
|
|
132
|
+
typer.Option(
|
|
133
|
+
"--max-provider-errors",
|
|
134
|
+
min=0.0,
|
|
135
|
+
max=1.0,
|
|
136
|
+
help="Share of executions that may fail to reach the model server "
|
|
137
|
+
"before the job is called broken (exit 3).",
|
|
138
|
+
),
|
|
139
|
+
] = None,
|
|
140
|
+
require_fault_parity: Annotated[
|
|
141
|
+
bool | None,
|
|
142
|
+
typer.Option(
|
|
143
|
+
"--require-fault-parity/--no-fault-parity",
|
|
144
|
+
help="Refuse to compare candidates that met different faults.",
|
|
145
|
+
),
|
|
146
|
+
] = None,
|
|
147
|
+
baseline_file: Annotated[
|
|
148
|
+
Path | None,
|
|
149
|
+
typer.Option(
|
|
150
|
+
"--baseline",
|
|
151
|
+
help="The committed baseline to compare against. Defaults to "
|
|
152
|
+
"slmeval.baseline.json beside eval.toml.",
|
|
153
|
+
),
|
|
154
|
+
] = None,
|
|
155
|
+
force: Annotated[
|
|
156
|
+
bool,
|
|
157
|
+
typer.Option(
|
|
158
|
+
"--force",
|
|
159
|
+
help="Compare against the baseline file even when the dataset or "
|
|
160
|
+
"config it was built on has changed.",
|
|
161
|
+
),
|
|
162
|
+
] = False,
|
|
163
|
+
fmt: Annotated[
|
|
164
|
+
str, typer.Option("--format", "-f", help=f"One of: {', '.join(FORMATS)}.")
|
|
165
|
+
] = "text",
|
|
166
|
+
out_path: Annotated[
|
|
167
|
+
Path | None,
|
|
168
|
+
typer.Option("--out", "-o", help="Write the report to this file as well."),
|
|
169
|
+
] = None,
|
|
170
|
+
verbose: Annotated[
|
|
171
|
+
bool,
|
|
172
|
+
typer.Option(
|
|
173
|
+
"--verbose",
|
|
174
|
+
"-v",
|
|
175
|
+
help="Show every check, not only the failures. Worth running once "
|
|
176
|
+
"when you set the thresholds up, to see what they actually enforce.",
|
|
177
|
+
),
|
|
178
|
+
] = False,
|
|
179
|
+
ascii_mode: Annotated[
|
|
180
|
+
bool, typer.Option("--ascii", help="ASCII only, for legacy Windows consoles.")
|
|
181
|
+
] = False,
|
|
182
|
+
) -> None:
|
|
183
|
+
"""Fail the build when a candidate regresses."""
|
|
184
|
+
if fmt not in FORMATS:
|
|
185
|
+
raise typer.BadParameter(f"format must be one of: {', '.join(FORMATS)}")
|
|
186
|
+
|
|
187
|
+
out = console()
|
|
188
|
+
sym = symbols(ascii_mode or None)
|
|
189
|
+
|
|
190
|
+
with open_project(config, load_agent=False) as project:
|
|
191
|
+
resolved = resolve_run(project.store, run_id)
|
|
192
|
+
card = build_scorecard(project.store, resolved, prices_as_of=project.prices.as_of)
|
|
193
|
+
thresholds = Thresholds.from_config(
|
|
194
|
+
project.config.gate,
|
|
195
|
+
min_pass_k=min_pass_k,
|
|
196
|
+
use_wilson_lower=use_wilson_lower,
|
|
197
|
+
max_cost_ratio=max_cost_ratio,
|
|
198
|
+
max_p95_ratio=max_p95_ratio,
|
|
199
|
+
no_forbidden_tools=no_forbidden_tools,
|
|
200
|
+
max_regression=max_regression,
|
|
201
|
+
max_uncovered=max_uncovered,
|
|
202
|
+
max_provider_error_rate=max_provider_errors,
|
|
203
|
+
require_fault_parity=require_fault_parity,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
# Only when a regression check was actually asked for. A committed
|
|
207
|
+
# baseline sitting in the repository is not a reason to refuse a gate
|
|
208
|
+
# that was never going to read it - and refusing one because that file
|
|
209
|
+
# predates a dataset edit would block a pass-rate check that is
|
|
210
|
+
# perfectly valid.
|
|
211
|
+
if baseline_file is not None and thresholds.max_regression is None:
|
|
212
|
+
# They named a file to compare against and set no allowance, so
|
|
213
|
+
# nothing would read it. Refusing beats running a gate that
|
|
214
|
+
# silently ignores half of what was asked for.
|
|
215
|
+
raise ConfigError(
|
|
216
|
+
f"--baseline {baseline_file} was given with no --max-regression, "
|
|
217
|
+
"so nothing would be compared against it",
|
|
218
|
+
hint="add --max-regression 0.05 (or [gate].max_regression)",
|
|
219
|
+
)
|
|
220
|
+
stored = None
|
|
221
|
+
if thresholds.max_regression is not None:
|
|
222
|
+
path = baseline_path(project.root, baseline_file)
|
|
223
|
+
stored = _load_baseline(path, explicit=baseline_file is not None)
|
|
224
|
+
if stored is not None:
|
|
225
|
+
_check_provenance(card, stored, force=force, path=path)
|
|
226
|
+
|
|
227
|
+
report = evaluate(card, thresholds, baseline=stored)
|
|
228
|
+
|
|
229
|
+
if fmt == "text":
|
|
230
|
+
out.print(
|
|
231
|
+
render_gate(report, ascii_mode=ascii_mode or None, verbose=verbose)
|
|
232
|
+
)
|
|
233
|
+
else:
|
|
234
|
+
text = export(report, fmt)
|
|
235
|
+
print(text)
|
|
236
|
+
if out_path is not None:
|
|
237
|
+
out_path.write_text(
|
|
238
|
+
export(report, "md" if fmt == "text" else fmt), encoding="utf-8"
|
|
239
|
+
)
|
|
240
|
+
if fmt == "text":
|
|
241
|
+
out.print(Text(f" {sym.ok} wrote {out_path}", style="dim"))
|
|
242
|
+
out.print()
|
|
243
|
+
|
|
244
|
+
raise typer.Exit(report.exit_code)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _load_baseline(path: Path, *, explicit: bool) -> Baseline | None:
|
|
248
|
+
"""The committed baseline, or ``None`` when there is none.
|
|
249
|
+
|
|
250
|
+
A missing default file is not an error - most runs do not gate on a
|
|
251
|
+
regression, and demanding a baseline before the first gate would make the
|
|
252
|
+
command unusable on day one. A missing file the user *named* is an error,
|
|
253
|
+
because they clearly meant to compare against something.
|
|
254
|
+
"""
|
|
255
|
+
if not path.is_file():
|
|
256
|
+
if explicit:
|
|
257
|
+
raise ConfigError(
|
|
258
|
+
f"no baseline file at {path}",
|
|
259
|
+
hint="`slmeval baseline save` writes one from a stored run",
|
|
260
|
+
)
|
|
261
|
+
return None
|
|
262
|
+
return Baseline.load(path)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _check_provenance(
|
|
266
|
+
card: Any, stored: Baseline, *, force: bool, path: Path
|
|
267
|
+
) -> None:
|
|
268
|
+
"""Refuse to compare a run against a baseline built on different inputs.
|
|
269
|
+
|
|
270
|
+
This is the refusal the provenance hashes exist for. A dataset edit
|
|
271
|
+
compared against an old baseline shows up as a regression nobody caused,
|
|
272
|
+
and a tool that publishes one of those loses the right to be believed -
|
|
273
|
+
once, permanently. ``--force`` exists for the user who knows exactly what
|
|
274
|
+
they changed.
|
|
275
|
+
"""
|
|
276
|
+
blocking = blocking_divergences(card, stored)
|
|
277
|
+
if not blocking or force:
|
|
278
|
+
return
|
|
279
|
+
reasons = "; ".join(f"{d.reason} ({d.field})" for d in blocking)
|
|
280
|
+
raise ConfigError(
|
|
281
|
+
f"{path.name} was built on different inputs: {reasons}",
|
|
282
|
+
source=path,
|
|
283
|
+
hint=(
|
|
284
|
+
"re-save it with `slmeval baseline save` if the change was intended, "
|
|
285
|
+
"or pass --force to compare anyway and read the numbers knowing why "
|
|
286
|
+
"they moved"
|
|
287
|
+
),
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# --------------------------------------------------------------------------- #
|
|
292
|
+
# Rendering
|
|
293
|
+
# --------------------------------------------------------------------------- #
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def render_gate(
|
|
297
|
+
report: GateReport, *, ascii_mode: bool | None = None, verbose: bool = False
|
|
298
|
+
) -> RenderableType:
|
|
299
|
+
"""The verdict first, then why, then what was not checked.
|
|
300
|
+
|
|
301
|
+
Verdict first because a CI log is read from the top and usually only from
|
|
302
|
+
the top. The failures that follow name the candidate and the case, because
|
|
303
|
+
"pass^3 below floor" is not something anybody can act on.
|
|
304
|
+
|
|
305
|
+
Only the failures and the skips, unless ``verbose``: a passing gate in a CI
|
|
306
|
+
log should be one line, and nine green rows nobody reads are nine rows of
|
|
307
|
+
scrollback between a reviewer and the thing that did fail.
|
|
308
|
+
"""
|
|
309
|
+
sym = symbols(ascii_mode)
|
|
310
|
+
parts: list[RenderableType] = [Text(""), _verdict(report, sym), Text("")]
|
|
311
|
+
|
|
312
|
+
if report.usage_error:
|
|
313
|
+
parts += [Text(f" {report.usage_error}", style="yellow"), Text("")]
|
|
314
|
+
return Group(*parts)
|
|
315
|
+
|
|
316
|
+
if verbose:
|
|
317
|
+
passing = tuple(c for c in report.checks if c.passed and not c.skipped)
|
|
318
|
+
if passing:
|
|
319
|
+
parts += [
|
|
320
|
+
Text(f" {sym.ok} met:", style="dim"),
|
|
321
|
+
_checks_table(passing, sym, failed=False),
|
|
322
|
+
Text(""),
|
|
323
|
+
]
|
|
324
|
+
if report.failures:
|
|
325
|
+
parts += [_checks_table(report.failures, sym, failed=True), Text("")]
|
|
326
|
+
if report.skipped:
|
|
327
|
+
parts += [
|
|
328
|
+
Text(
|
|
329
|
+
f" {sym.skip} not checked - the figure was never measured, so "
|
|
330
|
+
"these neither passed nor failed:",
|
|
331
|
+
style="dim",
|
|
332
|
+
),
|
|
333
|
+
_checks_table(report.skipped, sym, failed=False),
|
|
334
|
+
Text(""),
|
|
335
|
+
]
|
|
336
|
+
for note in report.warnings:
|
|
337
|
+
parts.append(Text(f" {sym.warn} {note}", style="yellow"))
|
|
338
|
+
if report.warnings:
|
|
339
|
+
parts.append(Text(""))
|
|
340
|
+
parts += [_summary(report, sym), Text("")]
|
|
341
|
+
return Group(*parts)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _verdict(report: GateReport, sym: Symbols) -> Text:
|
|
345
|
+
out = Text(" ")
|
|
346
|
+
if report.usage_error:
|
|
347
|
+
out.append(f"{sym.warn} GATE NOT RUN", style="bold yellow")
|
|
348
|
+
out.append(f" (exit {report.exit_code})", style="dim")
|
|
349
|
+
return out
|
|
350
|
+
if report.passed:
|
|
351
|
+
out.append(f"{sym.ok} GATE PASSED", style="bold green")
|
|
352
|
+
else:
|
|
353
|
+
severity = report.severity
|
|
354
|
+
label = {
|
|
355
|
+
Severity.GATE: "GATE FAILED",
|
|
356
|
+
# Named so nobody reads it as a regression. This is the line the
|
|
357
|
+
# whole exit-code contract exists to make possible.
|
|
358
|
+
Severity.ENVIRONMENT: "INFRASTRUCTURE PROBLEM - not a regression",
|
|
359
|
+
Severity.COVERAGE: "NOT ENOUGH COVERAGE TO JUDGE - not a regression",
|
|
360
|
+
}[severity] if severity is not None else "GATE FAILED"
|
|
361
|
+
glyph = sym.fail if severity is Severity.GATE else sym.warn
|
|
362
|
+
out.append(f"{glyph} {label}", style=_SEVERITY_STYLE.get(severity or Severity.GATE))
|
|
363
|
+
run = report.card.run
|
|
364
|
+
out.append(f" (exit {report.exit_code})", style="dim")
|
|
365
|
+
out.append(
|
|
366
|
+
f"\n {run.name} · {run.run_id} · {len(report.card.cases)} cases x "
|
|
367
|
+
f"{report.card.repeats} runs · {run.mode} · seed {run.seed}",
|
|
368
|
+
style="dim",
|
|
369
|
+
)
|
|
370
|
+
estimator = (
|
|
371
|
+
"Wilson lower bound" if report.thresholds.use_wilson_lower else "point estimate"
|
|
372
|
+
)
|
|
373
|
+
out.append(f"\n gating on the {estimator}", style="dim")
|
|
374
|
+
if report.baseline is not None:
|
|
375
|
+
out.append(
|
|
376
|
+
f" · baseline {report.baseline.run_id} saved {report.baseline.saved_at[:10]}",
|
|
377
|
+
style="dim",
|
|
378
|
+
)
|
|
379
|
+
return out
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _checks_table(checks: tuple[Check, ...], sym: Symbols, *, failed: bool) -> Table:
|
|
383
|
+
grid = Table(box=None, pad_edge=False, padding=(0, 2), show_edge=False, show_header=False)
|
|
384
|
+
grid.add_column(width=2, no_wrap=True)
|
|
385
|
+
grid.add_column(style="bold", no_wrap=True)
|
|
386
|
+
grid.add_column(no_wrap=True)
|
|
387
|
+
grid.add_column(overflow="fold")
|
|
388
|
+
for check in checks:
|
|
389
|
+
if check.passed and not check.skipped:
|
|
390
|
+
glyph, style = sym.ok, "green"
|
|
391
|
+
elif check.skipped:
|
|
392
|
+
glyph, style = sym.skip, "dim"
|
|
393
|
+
else:
|
|
394
|
+
glyph = sym.fail if check.severity is Severity.GATE else sym.warn
|
|
395
|
+
style = _SEVERITY_STYLE.get(check.severity, "red")
|
|
396
|
+
grid.add_row(
|
|
397
|
+
Text(glyph, style=style),
|
|
398
|
+
Text(check.candidate_id, style="dim" if check.skipped else ""),
|
|
399
|
+
Text(check.name, style=style),
|
|
400
|
+
Text(check.detail, style="dim" if check.skipped else ""),
|
|
401
|
+
)
|
|
402
|
+
return grid
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _summary(report: GateReport, sym: Symbols) -> Text:
|
|
406
|
+
checked = [c for c in report.checks if not c.skipped]
|
|
407
|
+
passed = [c for c in checked if c.passed]
|
|
408
|
+
out = Text()
|
|
409
|
+
out.append(
|
|
410
|
+
f" {len(passed)}/{len(checked)} check(s) passed"
|
|
411
|
+
f"{f', {len(report.skipped)} not measured' if report.skipped else ''}\n",
|
|
412
|
+
style="dim",
|
|
413
|
+
)
|
|
414
|
+
out.append(
|
|
415
|
+
f" {sym.arrow} exit codes: 0 pass · 1 gate failed · 2 usage · "
|
|
416
|
+
"3 environment · 4 coverage",
|
|
417
|
+
style="dim",
|
|
418
|
+
)
|
|
419
|
+
return out
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
# --------------------------------------------------------------------------- #
|
|
423
|
+
# Exports
|
|
424
|
+
# --------------------------------------------------------------------------- #
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def to_payload(report: GateReport) -> dict[str, Any]:
|
|
428
|
+
"""The gate as data. Stable keys; a CI script may read any of them."""
|
|
429
|
+
thresholds = report.thresholds
|
|
430
|
+
return {
|
|
431
|
+
"schema": "slmeval.gate/1",
|
|
432
|
+
"passed": report.passed,
|
|
433
|
+
"exit_code": report.exit_code,
|
|
434
|
+
"severity": report.severity.value if report.severity else None,
|
|
435
|
+
"usage_error": report.usage_error or None,
|
|
436
|
+
"run_id": report.card.run.run_id,
|
|
437
|
+
"name": report.card.run.name,
|
|
438
|
+
"cases": len(report.card.cases),
|
|
439
|
+
"runs_per_case": report.card.repeats,
|
|
440
|
+
"baseline_run_id": report.baseline.run_id if report.baseline else None,
|
|
441
|
+
"thresholds": {
|
|
442
|
+
"min_pass_k": thresholds.min_pass_k,
|
|
443
|
+
"use_wilson_lower": thresholds.use_wilson_lower,
|
|
444
|
+
"max_cost_ratio": thresholds.max_cost_ratio,
|
|
445
|
+
"max_p95_ratio": thresholds.max_p95_ratio,
|
|
446
|
+
"no_forbidden_tools": thresholds.no_forbidden_tools,
|
|
447
|
+
"max_regression": thresholds.max_regression,
|
|
448
|
+
"max_uncovered": thresholds.max_uncovered,
|
|
449
|
+
"max_provider_error_rate": thresholds.max_provider_error_rate,
|
|
450
|
+
"require_fault_parity": thresholds.require_fault_parity,
|
|
451
|
+
},
|
|
452
|
+
"checks": [
|
|
453
|
+
{
|
|
454
|
+
"name": check.name,
|
|
455
|
+
"candidate_id": check.candidate_id,
|
|
456
|
+
"passed": check.passed,
|
|
457
|
+
"skipped": check.skipped,
|
|
458
|
+
"absolute": check.absolute,
|
|
459
|
+
"severity": check.severity.value,
|
|
460
|
+
"observed": check.observed,
|
|
461
|
+
"limit": check.limit,
|
|
462
|
+
"detail": check.detail,
|
|
463
|
+
}
|
|
464
|
+
for check in report.checks
|
|
465
|
+
],
|
|
466
|
+
"warnings": list(report.warnings),
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def to_markdown(report: GateReport) -> str:
|
|
471
|
+
"""For a pull-request comment, which is where most people meet the gate."""
|
|
472
|
+
# The headline word matches the exit code, because a comment that said
|
|
473
|
+
# "FAILED" over an unreachable model server would be read as a regression
|
|
474
|
+
# by everyone who did not get as far as the blockquote.
|
|
475
|
+
verdict = {
|
|
476
|
+
None: "**PASSED**",
|
|
477
|
+
Severity.GATE: "**FAILED**",
|
|
478
|
+
Severity.ENVIRONMENT: "**INFRASTRUCTURE PROBLEM** - not a regression",
|
|
479
|
+
Severity.COVERAGE: "**NOT ENOUGH COVERAGE TO JUDGE** - not a regression",
|
|
480
|
+
}[report.severity]
|
|
481
|
+
if report.usage_error:
|
|
482
|
+
verdict = "**GATE NOT RUN**"
|
|
483
|
+
run = report.card.run
|
|
484
|
+
lines = [
|
|
485
|
+
f"### slmeval gate · {verdict} (exit {report.exit_code})",
|
|
486
|
+
"",
|
|
487
|
+
f"`{run.run_id}` · {len(report.card.cases)} cases x {report.card.repeats} "
|
|
488
|
+
f"runs · {run.mode} · seed {run.seed} · dataset `{run.dataset_hash}`",
|
|
489
|
+
"",
|
|
490
|
+
]
|
|
491
|
+
if report.usage_error:
|
|
492
|
+
return "\n".join([*lines, report.usage_error, ""])
|
|
493
|
+
if report.severity is Severity.ENVIRONMENT:
|
|
494
|
+
lines += [
|
|
495
|
+
"> This is an infrastructure failure, **not a model regression** - "
|
|
496
|
+
"the model server was not reachable.",
|
|
497
|
+
"",
|
|
498
|
+
]
|
|
499
|
+
if report.severity is Severity.COVERAGE:
|
|
500
|
+
lines += [
|
|
501
|
+
"> Not enough recorded ground truth to judge this run. Run "
|
|
502
|
+
"`slmeval record --fill-gaps`; this is **not a model regression**.",
|
|
503
|
+
"",
|
|
504
|
+
]
|
|
505
|
+
lines += ["| | Candidate | Check | Detail |", "|---|---|---|---|"]
|
|
506
|
+
for check in report.checks:
|
|
507
|
+
mark = "-" if check.skipped else ("✅" if check.passed else "❌")
|
|
508
|
+
lines.append(
|
|
509
|
+
f"| {mark} | `{check.candidate_id}` | {check.name} | {check.detail} |"
|
|
510
|
+
)
|
|
511
|
+
estimator = (
|
|
512
|
+
"the Wilson lower bound" if report.thresholds.use_wilson_lower else "the point estimate"
|
|
513
|
+
)
|
|
514
|
+
lines += [
|
|
515
|
+
"",
|
|
516
|
+
f"Gated on {estimator} of `pass^{report.card.repeats}`. "
|
|
517
|
+
"A rate without its interval is a number pretending to be evidence.",
|
|
518
|
+
]
|
|
519
|
+
for note in report.warnings:
|
|
520
|
+
lines.append(f"- {note}")
|
|
521
|
+
return "\n".join(lines) + "\n"
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def to_junit(report: GateReport) -> str:
|
|
525
|
+
"""JUnit XML, so an existing CI dashboard picks up the checks for free.
|
|
526
|
+
|
|
527
|
+
One test case per check, named ``candidate.check``. A skipped check is a
|
|
528
|
+
JUnit ``skipped``, which is exactly the right vocabulary: the dashboard
|
|
529
|
+
shows it as neither green nor red, which is what it is.
|
|
530
|
+
"""
|
|
531
|
+
checks = report.checks
|
|
532
|
+
suite = ElementTree.Element(
|
|
533
|
+
"testsuite",
|
|
534
|
+
{
|
|
535
|
+
"name": f"slmeval.gate.{report.card.run.name}",
|
|
536
|
+
"tests": str(len(checks)),
|
|
537
|
+
"failures": str(len(report.failures)),
|
|
538
|
+
"skipped": str(len(report.skipped)),
|
|
539
|
+
"errors": "0",
|
|
540
|
+
},
|
|
541
|
+
)
|
|
542
|
+
if report.usage_error:
|
|
543
|
+
case = ElementTree.SubElement(
|
|
544
|
+
suite, "testcase", {"classname": "slmeval.gate", "name": "configured"}
|
|
545
|
+
)
|
|
546
|
+
ElementTree.SubElement(
|
|
547
|
+
case, "failure", {"message": report.usage_error, "type": "usage"}
|
|
548
|
+
).text = report.usage_error
|
|
549
|
+
for check in checks:
|
|
550
|
+
case = ElementTree.SubElement(
|
|
551
|
+
suite,
|
|
552
|
+
"testcase",
|
|
553
|
+
{"classname": f"slmeval.gate.{check.candidate_id}", "name": check.name},
|
|
554
|
+
)
|
|
555
|
+
if check.skipped:
|
|
556
|
+
ElementTree.SubElement(case, "skipped", {"message": check.detail})
|
|
557
|
+
elif not check.passed:
|
|
558
|
+
ElementTree.SubElement(
|
|
559
|
+
case,
|
|
560
|
+
"failure",
|
|
561
|
+
{"message": check.detail, "type": check.severity.value},
|
|
562
|
+
).text = check.detail
|
|
563
|
+
root = ElementTree.Element(
|
|
564
|
+
"testsuites",
|
|
565
|
+
{
|
|
566
|
+
"name": "slmeval",
|
|
567
|
+
"tests": str(len(checks)),
|
|
568
|
+
"failures": str(len(report.failures)),
|
|
569
|
+
},
|
|
570
|
+
)
|
|
571
|
+
root.append(suite)
|
|
572
|
+
return (
|
|
573
|
+
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
574
|
+
+ ElementTree.tostring(root, encoding="unicode")
|
|
575
|
+
+ "\n"
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def export(report: GateReport, fmt: str) -> str:
|
|
580
|
+
if fmt == "json":
|
|
581
|
+
return json.dumps(to_payload(report), indent=2, default=str)
|
|
582
|
+
if fmt == "md":
|
|
583
|
+
return to_markdown(report)
|
|
584
|
+
if fmt == "junit":
|
|
585
|
+
return to_junit(report)
|
|
586
|
+
raise ValueError(f"unknown gate format {fmt!r}; expected one of {', '.join(FORMATS)}")
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def register(app: typer.Typer) -> None:
|
|
590
|
+
app.command(name="gate", help="Fail the build when a candidate regresses.")(gate)
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
__all__ = [
|
|
594
|
+
"FORMATS",
|
|
595
|
+
"export",
|
|
596
|
+
"gate",
|
|
597
|
+
"register",
|
|
598
|
+
"render_gate",
|
|
599
|
+
"to_junit",
|
|
600
|
+
"to_markdown",
|
|
601
|
+
"to_payload",
|
|
602
|
+
]
|