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,201 @@
|
|
|
1
|
+
"""``slmeval baseline`` - write and inspect the committed reference.
|
|
2
|
+
|
|
3
|
+
``slmeval.baseline.json`` is the file ``slmeval gate --max-regression`` compares
|
|
4
|
+
against, and it belongs in the repository. That is the whole point:
|
|
5
|
+
gating against "the last run in the local database" gives different verdicts on
|
|
6
|
+
different machines and cannot be reviewed, while a committed file turns "we
|
|
7
|
+
accepted a two-point drop" into a diff somebody approved.
|
|
8
|
+
|
|
9
|
+
So this command is deliberately boring. It takes a stored run, writes the
|
|
10
|
+
metrics and the provenance to a JSON file, and tells the user to commit it.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Annotated
|
|
17
|
+
|
|
18
|
+
import typer
|
|
19
|
+
from rich.table import Table
|
|
20
|
+
from rich.text import Text
|
|
21
|
+
|
|
22
|
+
from slmeval.cli.loading import LATEST, open_project, resolve_run
|
|
23
|
+
from slmeval.cli.render import console, symbols
|
|
24
|
+
from slmeval.cli.wrap import handle_errors
|
|
25
|
+
from slmeval.errors import ConfigError
|
|
26
|
+
from slmeval.report.baseline import Baseline, baseline_path
|
|
27
|
+
from slmeval.report.model import build_scorecard
|
|
28
|
+
|
|
29
|
+
baseline_app = typer.Typer(
|
|
30
|
+
no_args_is_help=True,
|
|
31
|
+
help="The committed reference a gate compares against.",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
_ConfigOpt = Annotated[
|
|
35
|
+
Path | None,
|
|
36
|
+
typer.Option("--config", "-c", help="Path to eval.toml. Defaults to the nearest one."),
|
|
37
|
+
]
|
|
38
|
+
_OutOpt = Annotated[
|
|
39
|
+
Path | None,
|
|
40
|
+
typer.Option("--out", "-o", help="Where to write it. Defaults to slmeval.baseline.json."),
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@baseline_app.command("save")
|
|
45
|
+
@handle_errors
|
|
46
|
+
def baseline_save(
|
|
47
|
+
config: _ConfigOpt = None,
|
|
48
|
+
run_id: Annotated[
|
|
49
|
+
str, typer.Option("--run-id", help="A run id or prefix, or 'latest'.")
|
|
50
|
+
] = LATEST,
|
|
51
|
+
out_path: _OutOpt = None,
|
|
52
|
+
force: Annotated[
|
|
53
|
+
bool,
|
|
54
|
+
typer.Option("--force", help="Overwrite an existing baseline file."),
|
|
55
|
+
] = False,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Write a stored run's metrics to the committed baseline file."""
|
|
58
|
+
out = console()
|
|
59
|
+
sym = symbols()
|
|
60
|
+
|
|
61
|
+
with open_project(config, load_agent=False) as project:
|
|
62
|
+
resolved = resolve_run(project.store, run_id)
|
|
63
|
+
card = build_scorecard(
|
|
64
|
+
project.store, resolved, prices_as_of=project.prices.as_of
|
|
65
|
+
)
|
|
66
|
+
path = baseline_path(project.root, out_path)
|
|
67
|
+
previous = Baseline.load(path) if path.is_file() else None
|
|
68
|
+
if previous is not None and not force:
|
|
69
|
+
raise ConfigError(
|
|
70
|
+
f"{path.name} already holds the baseline from run {previous.run_id}",
|
|
71
|
+
source=path,
|
|
72
|
+
hint=(
|
|
73
|
+
"pass --force to replace it - and commit the diff, because "
|
|
74
|
+
"moving a baseline is a decision somebody should review"
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
baseline = Baseline.from_scorecard(card)
|
|
79
|
+
baseline.save(path)
|
|
80
|
+
|
|
81
|
+
out.print()
|
|
82
|
+
out.print(Text(f" {sym.ok} wrote {path.name}", style="bold green"))
|
|
83
|
+
out.print()
|
|
84
|
+
out.print(_table(baseline, previous))
|
|
85
|
+
out.print()
|
|
86
|
+
out.print(
|
|
87
|
+
Text(
|
|
88
|
+
f" {sym.arrow} commit it: a gate that compares against an "
|
|
89
|
+
"uncommitted file gives a different answer on every machine",
|
|
90
|
+
style="cyan",
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
if not card.latency_reportable:
|
|
94
|
+
out.print(
|
|
95
|
+
Text(
|
|
96
|
+
f" {sym.skip} no p95 recorded - this run was scheduled for "
|
|
97
|
+
"throughput, so its latency describes the scheduler. Re-run "
|
|
98
|
+
"with --serial-latency to gate on p95.",
|
|
99
|
+
style="dim",
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
out.print()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@baseline_app.command("show")
|
|
106
|
+
@handle_errors
|
|
107
|
+
def baseline_show(
|
|
108
|
+
config: _ConfigOpt = None,
|
|
109
|
+
path_opt: _OutOpt = None,
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Print the committed baseline, so a failing gate can be read against it."""
|
|
112
|
+
out = console()
|
|
113
|
+
with open_project(config, load_agent=False) as project:
|
|
114
|
+
path = baseline_path(project.root, path_opt)
|
|
115
|
+
baseline = Baseline.load(path)
|
|
116
|
+
|
|
117
|
+
out.print()
|
|
118
|
+
out.print(
|
|
119
|
+
Text(
|
|
120
|
+
f" {path.name} {baseline.run_id} · saved {baseline.saved_at[:19]} · "
|
|
121
|
+
f"slmeval {baseline.slmeval_version}",
|
|
122
|
+
style="dim",
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
out.print(
|
|
126
|
+
Text(
|
|
127
|
+
f" dataset {baseline.dataset_hash} · config {baseline.config_hash} · "
|
|
128
|
+
f"{baseline.mode} · seed {baseline.seed} · "
|
|
129
|
+
f"{baseline.runs_per_case} runs/case",
|
|
130
|
+
style="dim",
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
out.print()
|
|
134
|
+
out.print(_table(baseline, None))
|
|
135
|
+
out.print()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _table(baseline: Baseline, previous: Baseline | None) -> Table:
|
|
139
|
+
"""The rows, with the previous file's numbers beside them when replacing.
|
|
140
|
+
|
|
141
|
+
Shown side by side on a ``--force`` overwrite because that is the moment a
|
|
142
|
+
user is deciding whether to accept a change, and the diff they are about to
|
|
143
|
+
commit is the evidence.
|
|
144
|
+
"""
|
|
145
|
+
grid = Table(box=None, pad_edge=False, padding=(0, 2), header_style="bold", show_edge=False)
|
|
146
|
+
grid.add_column("Candidate", no_wrap=True)
|
|
147
|
+
grid.add_column("pass^k", justify="right", no_wrap=True)
|
|
148
|
+
grid.add_column("wilson lo", justify="right", no_wrap=True)
|
|
149
|
+
grid.add_column("cases", justify="right", no_wrap=True)
|
|
150
|
+
grid.add_column("$/case", justify="right", no_wrap=True)
|
|
151
|
+
if previous is not None:
|
|
152
|
+
grid.add_column("was", justify="right", no_wrap=True)
|
|
153
|
+
|
|
154
|
+
for candidate_id, row in sorted(
|
|
155
|
+
baseline.candidates.items(), key=lambda kv: (kv[1].role != "baseline", kv[0])
|
|
156
|
+
):
|
|
157
|
+
cells = [
|
|
158
|
+
Text(candidate_id, style="bold" if row.role == "baseline" else ""),
|
|
159
|
+
Text(_pct(row.pass_hat_k)),
|
|
160
|
+
Text(_pct(row.wilson_lo), style="dim"),
|
|
161
|
+
Text(f"{row.cases_all_pass}/{row.cases}", style="dim"),
|
|
162
|
+
Text(
|
|
163
|
+
f"${row.cost_per_case:.4f}" if row.cost_per_case is not None else "-",
|
|
164
|
+
style="dim",
|
|
165
|
+
),
|
|
166
|
+
]
|
|
167
|
+
if previous is not None:
|
|
168
|
+
before = previous.candidate(candidate_id)
|
|
169
|
+
cells.append(
|
|
170
|
+
Text(
|
|
171
|
+
_pct(before.pass_hat_k) if before else "new",
|
|
172
|
+
style=_delta_style(
|
|
173
|
+
before.pass_hat_k if before else None, row.pass_hat_k
|
|
174
|
+
),
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
grid.add_row(*cells)
|
|
178
|
+
return grid
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _pct(value: float | None) -> str:
|
|
182
|
+
return f"{value:.1%}" if value is not None else "-"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _delta_style(before: float | None, now: float | None) -> str:
|
|
186
|
+
"""Colour the old number by which way the new one moved.
|
|
187
|
+
|
|
188
|
+
One percentage point of slack, same as the scorecard: floating-point noise
|
|
189
|
+
must not flicker a committed baseline green and red between saves of the
|
|
190
|
+
same run.
|
|
191
|
+
"""
|
|
192
|
+
if before is None or now is None or abs(now - before) < 0.01:
|
|
193
|
+
return "dim"
|
|
194
|
+
return "green" if now > before else "red"
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def register(app: typer.Typer) -> None:
|
|
198
|
+
app.add_typer(baseline_app, name="baseline")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
__all__ = ["baseline_app", "register"]
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""`slmeval cache` — what the trace store holds, and how to reclaim it.
|
|
2
|
+
|
|
3
|
+
Benchmark traces grow to gigabytes. Give users the broom before they go looking
|
|
4
|
+
for one, because the alternative is someone deleting ``.slmeval/`` by hand and
|
|
5
|
+
losing the baselines their gate compares against.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from datetime import UTC, datetime, timedelta
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Annotated
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
from rich.table import Table
|
|
17
|
+
from rich.text import Text
|
|
18
|
+
|
|
19
|
+
from slmeval.cli.render import console, symbols
|
|
20
|
+
from slmeval.cli.wrap import handle_errors
|
|
21
|
+
from slmeval.config.loader import CACHE_DIR_NAME, DEFAULT_CONFIG_NAME
|
|
22
|
+
from slmeval.errors import ConfigError
|
|
23
|
+
from slmeval.storage import RunStore
|
|
24
|
+
|
|
25
|
+
cache_app = typer.Typer(
|
|
26
|
+
no_args_is_help=True,
|
|
27
|
+
help="Trace store statistics and cleanup.",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
_AGE_RE = re.compile(r"^\s*(\d+)\s*([smhdw])\s*$", re.IGNORECASE)
|
|
31
|
+
_UNITS = {
|
|
32
|
+
"s": timedelta(seconds=1),
|
|
33
|
+
"m": timedelta(minutes=1),
|
|
34
|
+
"h": timedelta(hours=1),
|
|
35
|
+
"d": timedelta(days=1),
|
|
36
|
+
"w": timedelta(weeks=1),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse_age(text: str) -> timedelta:
|
|
41
|
+
"""``30d``, ``12h``, ``2w``. No bare numbers.
|
|
42
|
+
|
|
43
|
+
A bare ``30`` could mean seconds, days or runs, and the command it feeds
|
|
44
|
+
deletes things. Refusing to guess costs the user one retyped character; a
|
|
45
|
+
wrong guess costs them their benchmark history.
|
|
46
|
+
"""
|
|
47
|
+
match = _AGE_RE.match(text)
|
|
48
|
+
if not match:
|
|
49
|
+
raise ConfigError(
|
|
50
|
+
f"cannot read {text!r} as an age",
|
|
51
|
+
hint="use a number and a unit: 30d, 12h, 2w",
|
|
52
|
+
)
|
|
53
|
+
return int(match.group(1)) * _UNITS[match.group(2).lower()]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def human_bytes(size: int) -> str:
|
|
57
|
+
value = float(size)
|
|
58
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
59
|
+
if value < 1024 or unit == "GB":
|
|
60
|
+
return f"{value:.0f} {unit}" if unit == "B" else f"{value:.1f} {unit}"
|
|
61
|
+
value /= 1024
|
|
62
|
+
return f"{value:.1f} GB"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def project_root(explicit: Path | None) -> Path:
|
|
66
|
+
"""The directory whose ``.slmeval`` we operate on.
|
|
67
|
+
|
|
68
|
+
Walks up for ``eval.toml`` like the other commands, but never parses it:
|
|
69
|
+
the whole point of ``cache stats`` is to work when something else is broken,
|
|
70
|
+
and a config too invalid to load must not stop a user inspecting their own
|
|
71
|
+
disk usage.
|
|
72
|
+
"""
|
|
73
|
+
if explicit is not None:
|
|
74
|
+
return Path(explicit).expanduser().resolve()
|
|
75
|
+
here = Path.cwd().resolve()
|
|
76
|
+
for directory in (here, *here.parents):
|
|
77
|
+
if (directory / DEFAULT_CONFIG_NAME).is_file() or (directory / CACHE_DIR_NAME).is_dir():
|
|
78
|
+
return directory
|
|
79
|
+
raise ConfigError(
|
|
80
|
+
f"no {DEFAULT_CONFIG_NAME} or {CACHE_DIR_NAME}/ found in {here} or any parent",
|
|
81
|
+
hint="run this from a project directory, or pass --path",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
_PathOpt = Annotated[
|
|
86
|
+
Path | None,
|
|
87
|
+
typer.Option("--path", help="Project directory to inspect. Defaults to the one you are in."),
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@cache_app.command("stats")
|
|
92
|
+
@handle_errors
|
|
93
|
+
def cache_stats(
|
|
94
|
+
path: _PathOpt = None,
|
|
95
|
+
verbose: Annotated[
|
|
96
|
+
bool, typer.Option("--verbose", "-v", help="Also show the schema migration history.")
|
|
97
|
+
] = False,
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Show what the trace store holds and what it costs on disk."""
|
|
100
|
+
root = project_root(path)
|
|
101
|
+
out = console()
|
|
102
|
+
sym = symbols()
|
|
103
|
+
|
|
104
|
+
with RunStore.for_project(root) as store:
|
|
105
|
+
stats = store.stats()
|
|
106
|
+
cassettes = root / CACHE_DIR_NAME / "cassettes.sqlite"
|
|
107
|
+
cassette_bytes = cassettes.stat().st_size if cassettes.is_file() else 0
|
|
108
|
+
|
|
109
|
+
table = Table.grid(padding=(0, 2))
|
|
110
|
+
table.add_column(style="dim", no_wrap=True)
|
|
111
|
+
table.add_column(overflow="fold")
|
|
112
|
+
table.add_row("store", str(store.path))
|
|
113
|
+
table.add_row("schema", f"v{store.schema_version}")
|
|
114
|
+
table.add_row("runs", str(stats.runs))
|
|
115
|
+
if stats.running:
|
|
116
|
+
table.add_row("in progress", Text(str(stats.running), style="yellow"))
|
|
117
|
+
table.add_row("executions", str(stats.executions))
|
|
118
|
+
table.add_row("model calls", str(stats.node_traces))
|
|
119
|
+
table.add_row("tool calls", str(stats.tool_traces))
|
|
120
|
+
table.add_row("evaluations", str(stats.evaluations))
|
|
121
|
+
if stats.oldest_run:
|
|
122
|
+
table.add_row("oldest run", stats.oldest_run[:19])
|
|
123
|
+
table.add_row("newest run", (stats.newest_run or "")[:19])
|
|
124
|
+
table.add_row("database", human_bytes(stats.db_bytes))
|
|
125
|
+
table.add_row("payloads", f"{stats.blobs} files, {human_bytes(stats.blob_bytes)}")
|
|
126
|
+
if cassette_bytes:
|
|
127
|
+
table.add_row("cassettes", human_bytes(cassette_bytes))
|
|
128
|
+
table.add_row(
|
|
129
|
+
"total",
|
|
130
|
+
human_bytes(stats.db_bytes + stats.blob_bytes + cassette_bytes),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
out.print()
|
|
134
|
+
out.print(table)
|
|
135
|
+
if verbose:
|
|
136
|
+
out.print()
|
|
137
|
+
out.print(Text(" migrations", style="dim"))
|
|
138
|
+
for version, name, at, built_by in store.migration_history():
|
|
139
|
+
out.print(
|
|
140
|
+
Text(
|
|
141
|
+
f" v{version} {name} · {at[:19]} · slmeval {built_by}",
|
|
142
|
+
style="dim",
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
out.print()
|
|
146
|
+
if stats.runs == 0:
|
|
147
|
+
out.print(Text(f" {sym.skip} no runs stored yet", style="dim"))
|
|
148
|
+
out.print()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@cache_app.command("prune")
|
|
152
|
+
@handle_errors
|
|
153
|
+
def cache_prune(
|
|
154
|
+
older_than: Annotated[
|
|
155
|
+
str,
|
|
156
|
+
typer.Option("--older-than", help="Delete runs older than this: 30d, 12h, 2w."),
|
|
157
|
+
] = "30d",
|
|
158
|
+
keep_last: Annotated[
|
|
159
|
+
int,
|
|
160
|
+
typer.Option(
|
|
161
|
+
"--keep-last",
|
|
162
|
+
min=0,
|
|
163
|
+
help="Always keep this many of the most recent runs, whatever their age.",
|
|
164
|
+
),
|
|
165
|
+
] = 3,
|
|
166
|
+
path: _PathOpt = None,
|
|
167
|
+
dry_run: Annotated[
|
|
168
|
+
bool, typer.Option("--dry-run", help="Show what would go, delete nothing.")
|
|
169
|
+
] = False,
|
|
170
|
+
yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip the confirmation.")] = False,
|
|
171
|
+
force: Annotated[
|
|
172
|
+
bool,
|
|
173
|
+
typer.Option("--force", help="Prune even with runs marked in progress (crash leftovers)."),
|
|
174
|
+
] = False,
|
|
175
|
+
) -> None:
|
|
176
|
+
"""Delete old runs and the payloads nothing references any more."""
|
|
177
|
+
root = project_root(path)
|
|
178
|
+
age = parse_age(older_than)
|
|
179
|
+
out = console()
|
|
180
|
+
sym = symbols()
|
|
181
|
+
|
|
182
|
+
with RunStore.for_project(root) as store:
|
|
183
|
+
cutoff = datetime.now(UTC) - age
|
|
184
|
+
stale = store.runs_older_than(cutoff, keep_last=keep_last)
|
|
185
|
+
|
|
186
|
+
out.print()
|
|
187
|
+
if not stale:
|
|
188
|
+
out.print(Text(f" {sym.ok} nothing older than {older_than} to prune", style="green"))
|
|
189
|
+
out.print()
|
|
190
|
+
# Payloads can still be orphaned by a crash between a blob write
|
|
191
|
+
# and its commit, so sweep anyway - just quietly.
|
|
192
|
+
swept = store.blobs.sweep(store.referenced_blobs(), dry_run=dry_run)
|
|
193
|
+
if swept.deleted:
|
|
194
|
+
out.print(
|
|
195
|
+
Text(
|
|
196
|
+
f" {sym.ok} freed {human_bytes(swept.freed_bytes)} "
|
|
197
|
+
f"of unreferenced payloads",
|
|
198
|
+
style="green",
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
out.print()
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
table = Table.grid(padding=(0, 2))
|
|
205
|
+
table.add_column(style="dim", no_wrap=True)
|
|
206
|
+
table.add_column(overflow="fold")
|
|
207
|
+
for run in stale[:10]:
|
|
208
|
+
table.add_row(run.run_id, f"{run.name} · {run.started_at[:19]}")
|
|
209
|
+
if len(stale) > 10:
|
|
210
|
+
table.add_row("", Text(f"... and {len(stale) - 10} more", style="dim"))
|
|
211
|
+
out.print(Text(f" {len(stale)} run(s) older than {older_than}:", style="bold"))
|
|
212
|
+
out.print()
|
|
213
|
+
out.print(table)
|
|
214
|
+
out.print()
|
|
215
|
+
|
|
216
|
+
if dry_run:
|
|
217
|
+
out.print(Text(f" {sym.skip} dry run, nothing deleted", style="dim"))
|
|
218
|
+
out.print()
|
|
219
|
+
return
|
|
220
|
+
# Deleting benchmark history is not undoable, and the thing being
|
|
221
|
+
# deleted is what the next gate compares against. Ask.
|
|
222
|
+
if not yes and not typer.confirm(" Delete these runs and their traces?"):
|
|
223
|
+
out.print()
|
|
224
|
+
out.print(Text(f" {sym.skip} cancelled", style="dim"))
|
|
225
|
+
out.print()
|
|
226
|
+
raise typer.Exit()
|
|
227
|
+
|
|
228
|
+
deleted, swept = store.prune(older_than=age, keep_last=keep_last, force=force)
|
|
229
|
+
out.print()
|
|
230
|
+
out.print(
|
|
231
|
+
Text(
|
|
232
|
+
f" {sym.ok} deleted {len(deleted)} run(s), freed "
|
|
233
|
+
f"{human_bytes(swept.freed_bytes)} of payloads",
|
|
234
|
+
style="bold green",
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
if swept.kept_recent:
|
|
238
|
+
out.print(
|
|
239
|
+
Text(
|
|
240
|
+
f" kept {swept.kept_recent} recent unreferenced payload(s) - "
|
|
241
|
+
"a sweep spares anything a run may still be committing",
|
|
242
|
+
style="dim",
|
|
243
|
+
)
|
|
244
|
+
)
|
|
245
|
+
out.print()
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def register(app: typer.Typer) -> None:
|
|
249
|
+
app.add_typer(cache_app, name="cache")
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""`slmeval dataset` — work with golden case files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
from slmeval.cli.render import console, symbols
|
|
13
|
+
from slmeval.cli.wrap import handle_errors
|
|
14
|
+
from slmeval.dataset import load_dataset
|
|
15
|
+
|
|
16
|
+
dataset_app = typer.Typer(
|
|
17
|
+
no_args_is_help=True,
|
|
18
|
+
help="Inspect and validate golden datasets.",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataset_app.command("validate")
|
|
23
|
+
@handle_errors
|
|
24
|
+
def validate_dataset(
|
|
25
|
+
path: Annotated[Path, typer.Argument(help="Path to a .jsonl dataset.")],
|
|
26
|
+
) -> None:
|
|
27
|
+
"""Check a dataset file: JSON, schema, unique ids, compilable regexes."""
|
|
28
|
+
out = console()
|
|
29
|
+
sym = symbols()
|
|
30
|
+
dataset = load_dataset(path)
|
|
31
|
+
|
|
32
|
+
table = Table.grid(padding=(0, 2))
|
|
33
|
+
table.add_column(style="dim", no_wrap=True)
|
|
34
|
+
table.add_column(overflow="fold")
|
|
35
|
+
table.add_row("file", str(dataset.path))
|
|
36
|
+
table.add_row("cases", str(len(dataset)))
|
|
37
|
+
table.add_row("hash", dataset.content_hash)
|
|
38
|
+
if dataset.tags:
|
|
39
|
+
table.add_row("tags", ", ".join(dataset.tags))
|
|
40
|
+
|
|
41
|
+
safety = sum(1 for c in dataset.cases if c.assertions.forbidden_tools)
|
|
42
|
+
table.add_row("safety cases", f"{safety} declare forbidden_tools")
|
|
43
|
+
|
|
44
|
+
out.print()
|
|
45
|
+
out.print(table)
|
|
46
|
+
out.print()
|
|
47
|
+
out.print(Text(f" {sym.ok} valid", style="bold green"))
|
|
48
|
+
out.print()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def register(app: typer.Typer) -> None:
|
|
52
|
+
app.add_typer(dataset_app, name="dataset")
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""`slmeval doctor` — can this machine run the benchmark right now?"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
|
|
11
|
+
from slmeval import exit_codes
|
|
12
|
+
from slmeval.cli.render import checks_summary, console, render_checks
|
|
13
|
+
from slmeval.cli.wrap import handle_errors
|
|
14
|
+
from slmeval.config import LoadedConfig, find_config, load_config
|
|
15
|
+
from slmeval.doctor import Status, run_checks, worst_status
|
|
16
|
+
from slmeval.errors import SlmevalError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _try_load(config: Path | None) -> tuple[LoadedConfig | None, str | None, Path]:
|
|
20
|
+
"""Load the config if there is one, reporting failure as a check rather than
|
|
21
|
+
an exception — doctor's whole job is to report problems, not to hit them."""
|
|
22
|
+
try:
|
|
23
|
+
path = find_config(config)
|
|
24
|
+
except SlmevalError as err:
|
|
25
|
+
return None, (err.message if config is not None else None), Path.cwd()
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
return load_config(path), None, path.parent
|
|
29
|
+
except SlmevalError as err:
|
|
30
|
+
return None, err.message, path.parent
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def register(app: typer.Typer) -> None:
|
|
34
|
+
@app.command()
|
|
35
|
+
@handle_errors
|
|
36
|
+
def doctor(
|
|
37
|
+
config: Annotated[
|
|
38
|
+
Path | None,
|
|
39
|
+
typer.Option("--config", "-c", help="Path to eval.toml. Default: search upwards."),
|
|
40
|
+
] = None,
|
|
41
|
+
skip_network: Annotated[
|
|
42
|
+
bool,
|
|
43
|
+
typer.Option("--skip-network", help="Do not probe providers (offline / CI use)."),
|
|
44
|
+
] = False,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Check Python, storage, credentials and provider reachability."""
|
|
47
|
+
out = console()
|
|
48
|
+
loaded, config_error, root = _try_load(config)
|
|
49
|
+
|
|
50
|
+
checks = run_checks(
|
|
51
|
+
root=root,
|
|
52
|
+
loaded=loaded,
|
|
53
|
+
config_error=config_error,
|
|
54
|
+
skip_network=skip_network,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
out.print()
|
|
58
|
+
out.print(render_checks(checks))
|
|
59
|
+
out.print()
|
|
60
|
+
out.print(Text(" ", end=""), end="")
|
|
61
|
+
out.print(checks_summary(checks))
|
|
62
|
+
out.print()
|
|
63
|
+
|
|
64
|
+
status = worst_status(checks)
|
|
65
|
+
if status is Status.FAIL:
|
|
66
|
+
# Exit 3, never 1. A broken environment is not a model regression,
|
|
67
|
+
# and CI must be able to tell them apart.
|
|
68
|
+
raise typer.Exit(exit_codes.ENVIRONMENT)
|