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,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)