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,144 @@
1
+ """`slmeval init` — scaffold a benchmark in the current project."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Annotated, Literal
9
+
10
+ import typer
11
+ from rich.table import Table
12
+ from rich.text import Text
13
+
14
+ from slmeval.cli.render import console, symbols
15
+ from slmeval.cli.wrap import handle_errors
16
+ from slmeval.config import templates
17
+ from slmeval.errors import SlmevalError
18
+
19
+ Outcome = Literal["created", "skipped", "appended"]
20
+
21
+
22
+ @dataclass
23
+ class Written:
24
+ path: Path
25
+ outcome: Outcome
26
+ note: str = ""
27
+
28
+
29
+ def slugify(value: str) -> str:
30
+ slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
31
+ return slug or "agent"
32
+
33
+
34
+ def default_benchmark_name(directory: Path) -> str:
35
+ return f"{slugify(directory.resolve().name)}-slm-downsizing"
36
+
37
+
38
+ def scaffold(directory: Path, *, name: str | None = None, force: bool = False) -> list[Written]:
39
+ """Write the starter files. Never overwrites without ``force``."""
40
+ directory = Path(directory)
41
+ if directory.exists() and not directory.is_dir():
42
+ raise SlmevalError(f"{directory} exists and is not a directory")
43
+ directory.mkdir(parents=True, exist_ok=True)
44
+
45
+ benchmark_name = name or default_benchmark_name(directory)
46
+ plan: list[tuple[Path, str]] = [
47
+ (directory / "eval.toml", templates.eval_toml(benchmark_name)),
48
+ (directory / "datasets" / "golden_cases.jsonl", templates.DATASET_JSONL),
49
+ (directory / "slmeval_target.py", templates.TARGET_PY),
50
+ ]
51
+
52
+ written: list[Written] = []
53
+ for path, content in plan:
54
+ if path.exists() and not force:
55
+ written.append(Written(path, "skipped", "already exists; --force overwrites"))
56
+ continue
57
+ path.parent.mkdir(parents=True, exist_ok=True)
58
+ # newline="\n" so the file hashes identically on Windows and in CI.
59
+ path.write_text(content, encoding="utf-8", newline="\n")
60
+ written.append(Written(path, "created"))
61
+
62
+ written.append(_ensure_gitignore(directory))
63
+ return written
64
+
65
+
66
+ def _ensure_gitignore(directory: Path) -> Written:
67
+ """Keep the trace cache out of git without clobbering an existing file."""
68
+ path = directory / ".gitignore"
69
+ entry = templates.GITIGNORE_ENTRY
70
+ if not path.exists():
71
+ path.write_text(entry + "\n", encoding="utf-8", newline="\n")
72
+ return Written(path, "created")
73
+
74
+ existing = path.read_text(encoding="utf-8-sig").splitlines()
75
+ if any(line.strip().rstrip("/") == entry.rstrip("/") for line in existing):
76
+ return Written(path, "skipped", f"already ignores {entry}")
77
+
78
+ with path.open("a", encoding="utf-8", newline="\n") as fh:
79
+ if existing and existing[-1].strip():
80
+ fh.write("\n")
81
+ fh.write(f"# slmeval trace cache\n{entry}\n")
82
+ return Written(path, "appended", f"added {entry}")
83
+
84
+
85
+ def register(app: typer.Typer) -> None:
86
+ @app.command()
87
+ @handle_errors
88
+ def init(
89
+ directory: Annotated[
90
+ Path,
91
+ typer.Argument(help="Where to scaffold. Defaults to the current directory."),
92
+ ] = Path(),
93
+ name: Annotated[
94
+ str | None,
95
+ typer.Option("--name", "-n", help="Benchmark name written into eval.toml."),
96
+ ] = None,
97
+ force: Annotated[
98
+ bool,
99
+ typer.Option("--force", help="Overwrite files that already exist."),
100
+ ] = False,
101
+ ) -> None:
102
+ """Create eval.toml, a starter dataset, and a target stub."""
103
+ out = console()
104
+ sym = symbols()
105
+ written = scaffold(directory, name=name, force=force)
106
+
107
+ table = Table.grid(padding=(0, 2))
108
+ table.add_column(width=9)
109
+ table.add_column(style="bold", no_wrap=True)
110
+ table.add_column(style="dim", overflow="fold")
111
+ for item in written:
112
+ style = {"created": "green", "appended": "green", "skipped": "yellow"}[item.outcome]
113
+ table.add_row(
114
+ Text(item.outcome, style=style),
115
+ str(item.path),
116
+ item.note,
117
+ )
118
+
119
+ out.print()
120
+ out.print(table)
121
+ out.print()
122
+
123
+ if any(w.outcome == "skipped" and "already exists" in w.note for w in written):
124
+ out.print(
125
+ Text(
126
+ f" {sym.warn} some files were left alone - pass --force to overwrite",
127
+ style="yellow",
128
+ )
129
+ )
130
+ out.print()
131
+
132
+ out.print(Text(" Next:", style="bold"))
133
+ out.print(Text(" slmeval doctor ", style="cyan"), end="")
134
+ out.print(Text("check this machine can run the benchmark", style="dim"))
135
+ out.print(Text(" slmeval validate ", style="cyan"), end="")
136
+ out.print(Text("check eval.toml and the dataset", style="dim"))
137
+ out.print()
138
+ out.print(
139
+ Text(
140
+ " Then point [target].entrypoint at your own agent and edit the dataset.",
141
+ style="dim",
142
+ )
143
+ )
144
+ out.print()
@@ -0,0 +1,284 @@
1
+ """``slmeval judge`` - measure the judge before trusting it.
2
+
3
+ An LLM judge is the only evaluator here that costs money and the only one that
4
+ can be confidently wrong, so it does not get to produce a number until this
5
+ command has said what its agreement with human labels is. That is the whole
6
+ reason `judge calibrate` exists before Tier 3 is wired into a run: a judge
7
+ whose agreement nobody has measured is a random number generator with a
8
+ confident tone.
9
+
10
+ The output is three things, in this order, because that is the order a reader
11
+ needs them:
12
+
13
+ 1. the raw agreement **with its Wilson interval**, because thirty labels is a
14
+ small sample and a point estimate from one is exactly the mistake the rest
15
+ of this tool exists to avoid;
16
+ 2. Cohen's **kappa** beside it, because on a set where nine pairs in ten pass,
17
+ a judge that says "pass" to everything scores 90% and knows nothing;
18
+ 3. the **disagreements**, listed. The deliverable of a calibration run is not
19
+ a score, it is the handful of pairs where the judge and the human parted
20
+ company - that is what tells you whether the rubric is ambiguous, the judge
21
+ is lenient, or the human was wrong.
22
+
23
+ It writes `slmeval.judge.json`, which is committed, for the same reason the
24
+ baseline file is: "we accepted a judge that agrees with us 71% of the time"
25
+ should be a diff somebody approved.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import asyncio
31
+ import json
32
+ import sqlite3
33
+ from pathlib import Path
34
+ from typing import Annotated
35
+
36
+ import typer
37
+ from rich.table import Table
38
+ from rich.text import Text
39
+
40
+ from slmeval import exit_codes
41
+ from slmeval.cli.loading import open_project
42
+ from slmeval.cli.render import console, symbols
43
+ from slmeval.cli.wrap import handle_errors
44
+ from slmeval.config.models import JudgeCfg
45
+ from slmeval.errors import ConfigError
46
+ from slmeval.eval.calibration import (
47
+ MIN_LABELS,
48
+ Agreement,
49
+ Label,
50
+ agreement,
51
+ calibration_path,
52
+ load_labels,
53
+ verdict_counts,
54
+ )
55
+ from slmeval.eval.tier3 import Judge, Judgement, JudgementCache
56
+ from slmeval.providers.registry import build_client
57
+
58
+ judge_app = typer.Typer(
59
+ no_args_is_help=True,
60
+ help="Measure and inspect the Tier 3 judge.",
61
+ )
62
+
63
+ _ConfigOpt = Annotated[
64
+ Path | None,
65
+ typer.Option("--config", "-c", help="Path to eval.toml. Defaults to the nearest one."),
66
+ ]
67
+
68
+
69
+ @judge_app.command("calibrate")
70
+ @handle_errors
71
+ def judge_calibrate(
72
+ labels_path: Annotated[
73
+ Path,
74
+ typer.Argument(
75
+ metavar="LABELS",
76
+ help="A JSONL file of human-labelled pairs: case_id, rubric, "
77
+ "reference, candidate, verdict.",
78
+ ),
79
+ ],
80
+ config: _ConfigOpt = None,
81
+ out_path: Annotated[
82
+ Path | None,
83
+ typer.Option("--out", "-o", help="Where to write it. Default: slmeval.judge.json."),
84
+ ] = None,
85
+ min_agreement: Annotated[
86
+ float | None,
87
+ typer.Option(
88
+ "--min-agreement",
89
+ min=0.0,
90
+ max=1.0,
91
+ help="Exit 1 if the Wilson lower bound falls below this. "
92
+ "Defaults to [judge].min_agreement.",
93
+ ),
94
+ ] = None,
95
+ as_json: Annotated[
96
+ bool, typer.Option("--json", help="Emit the record as JSON instead of a table.")
97
+ ] = False,
98
+ ascii_mode: Annotated[
99
+ bool, typer.Option("--ascii", help="ASCII only, for legacy Windows consoles.")
100
+ ] = False,
101
+ ) -> None:
102
+ """Run the judge over human-labelled pairs and report its agreement."""
103
+ out = console()
104
+ sym = symbols(ascii_mode or None)
105
+
106
+ labels = load_labels(labels_path)
107
+
108
+ with open_project(config, load_agent=False) as project:
109
+ cfg = project.config.judge
110
+ if not cfg.enabled:
111
+ raise ConfigError(
112
+ "no judge model configured",
113
+ hint=(
114
+ 'Add a [judge] section to eval.toml: provider = "openai", '
115
+ 'model = "gpt-4o-mini". Calibration needs the model it is '
116
+ "about to measure."
117
+ ),
118
+ )
119
+ floor = min_agreement if min_agreement is not None else cfg.min_agreement
120
+ result = asyncio.run(_calibrate(cfg, labels, project.store.connection()))
121
+ path = calibration_path(project.root, out_path)
122
+ path.write_text(
123
+ json.dumps(result.payload(), indent=2) + "\n", encoding="utf-8"
124
+ )
125
+
126
+ if as_json:
127
+ print(json.dumps(result.payload(), indent=2))
128
+ else:
129
+ out.print()
130
+ out.print(_summary(result, labels, sym.arrow))
131
+ out.print()
132
+ out.print(_confusion(result))
133
+ if result.disagreements:
134
+ out.print()
135
+ out.print(_disagreements(result))
136
+ for note in result.warnings():
137
+ out.print(Text(f" {sym.warn} {note}", style="yellow"))
138
+ out.print()
139
+ out.print(Text(f" {sym.ok} wrote {path.name} - commit it", style="dim"))
140
+ out.print()
141
+
142
+ lower = result.lower
143
+ if not result.usable:
144
+ # Not a gate failure: nothing was measured, so there is nothing to
145
+ # fail. Exit 2 says "this invocation did not answer the question",
146
+ # which is a usage problem and not a verdict about the judge.
147
+ raise typer.Exit(exit_codes.USAGE)
148
+ if lower is not None and lower < floor:
149
+ out.print(
150
+ Text(
151
+ f" {sym.fail} agreement lower bound {lower:.0%} is below the "
152
+ f"{floor:.0%} floor - this judge is not ready to grade anything",
153
+ style="bold red",
154
+ )
155
+ )
156
+ out.print()
157
+ raise typer.Exit(exit_codes.GATE_FAILED)
158
+
159
+
160
+ async def _calibrate(
161
+ cfg: JudgeCfg, labels: list[Label], conn: sqlite3.Connection
162
+ ) -> Agreement:
163
+ """Judge every labelled pair, then compare.
164
+
165
+ Cached like any other judgement, so re-running a calibration after fixing
166
+ one label costs one call rather than thirty. ``uncalibrated_ok`` is true
167
+ here by definition: this is the run that produces the calibration.
168
+ """
169
+ client = build_client(cfg)
170
+ judge = Judge(client=client, cache=JudgementCache(conn), uncalibrated_ok=True)
171
+ pairs: list[tuple[Label, Judgement]] = []
172
+ try:
173
+ for label in labels:
174
+ judgement = await judge.judge(
175
+ rubric=label.rubric,
176
+ reference=label.reference,
177
+ candidate=label.candidate,
178
+ case_id=label.case_id,
179
+ question=label.question,
180
+ )
181
+ pairs.append((label, judgement))
182
+ finally:
183
+ await client.aclose()
184
+ return agreement(pairs, judge_model=judge.model)
185
+
186
+
187
+ # --------------------------------------------------------------------------- #
188
+ # Rendering
189
+ # --------------------------------------------------------------------------- #
190
+
191
+
192
+ def _summary(result: Agreement, labels: list[Label], arrow: str) -> Table:
193
+ grid = Table.grid(padding=(0, 2))
194
+ grid.add_column(style="dim", no_wrap=True)
195
+ grid.add_column(overflow="fold")
196
+
197
+ grid.add_row("judge", result.judge_model)
198
+ grid.add_row("labels", f"{result.n} ({_counts(verdict_counts(labels))})")
199
+ rate = f"{result.rate:.1%}" if result.rate is not None else "-"
200
+ interval = result.interval.render() if result.interval else "-"
201
+ grid.add_row("agreement", f"{rate} {interval}")
202
+ grid.add_row(
203
+ "kappa",
204
+ f"{result.kappa:.2f}" if result.kappa is not None else "undefined",
205
+ )
206
+ grid.add_row("verdict", _verdict_line(result))
207
+ grid.add_row(
208
+ "",
209
+ f"{arrow} gate on the lower bound, not the rate: {result.n} labels is a "
210
+ "small sample and the interval says how small",
211
+ )
212
+ return grid
213
+
214
+
215
+ def _verdict_line(result: Agreement) -> str:
216
+ """Whether this is a calibration, and if not, which of the two reasons.
217
+
218
+ Naming the reason matters: "you need more labels" and "these are not your
219
+ labels" are fixed by completely different work, and a single message for
220
+ both sends half the readers to the wrong one.
221
+ """
222
+ if result.usable:
223
+ return "usable as a calibration"
224
+ if result.illustrative:
225
+ return "NOT a calibration - these labels are not human labels"
226
+ return f"NOT a calibration - {MIN_LABELS} human labels is the floor, this has {result.n}"
227
+
228
+
229
+ def _confusion(result: Agreement) -> Table:
230
+ """Human down the side, judge across the top.
231
+
232
+ Worth printing even at n=30: the shape says *how* a judge is wrong, and
233
+ "lenient" and "harsh" need opposite fixes.
234
+ """
235
+ labels = ("pass", "fail", "unclear")
236
+ table = Table(
237
+ title="human (rows) vs judge (columns)",
238
+ title_style="dim",
239
+ box=None,
240
+ pad_edge=False,
241
+ )
242
+ table.add_column("", style="dim")
243
+ for name in labels:
244
+ table.add_column(name, justify="right")
245
+ for human in labels:
246
+ cells = [
247
+ str(result.confusion.get((human, judge), 0)) for judge in labels
248
+ ]
249
+ table.add_row(human, *cells)
250
+ return table
251
+
252
+
253
+ def _disagreements(result: Agreement) -> Table:
254
+ table = Table(
255
+ title="where the judge and the human parted company",
256
+ title_style="dim",
257
+ box=None,
258
+ pad_edge=False,
259
+ )
260
+ table.add_column("case", style="cyan", no_wrap=True)
261
+ table.add_column("human", no_wrap=True)
262
+ table.add_column("judge", no_wrap=True)
263
+ table.add_column("score", justify="right", no_wrap=True)
264
+ table.add_column("the judge's reason", overflow="fold")
265
+ for item in result.disagreements:
266
+ table.add_row(
267
+ item.case_id,
268
+ item.human,
269
+ item.judge,
270
+ f"{item.score:.2f}",
271
+ item.reasoning,
272
+ )
273
+ return table
274
+
275
+
276
+ def _counts(counts: dict[str, int]) -> str:
277
+ return ", ".join(f"{k} {v}" for k, v in sorted(counts.items())) or "none"
278
+
279
+
280
+ def register(app: typer.Typer) -> None:
281
+ app.add_typer(judge_app, name="judge")
282
+
283
+
284
+ __all__ = ["judge_app", "judge_calibrate", "register"]
@@ -0,0 +1,154 @@
1
+ """``slmeval record`` - execute the real tools once, so every later run is free.
2
+
3
+ The matrix runner does the work; this is a narrow front door onto it with three
4
+ settings fixed, and each of them prevents a specific way recording goes wrong.
5
+
6
+ **The baseline only.** Cassettes are what candidates replay, so they have to be
7
+ recorded by the model you trust. Letting a 4B candidate record them would bake
8
+ its mistakes into the reference every other candidate is measured against.
9
+
10
+ **One repeat per case.** Cassettes are scoped per case, not per repeat - every
11
+ repeat of a case must replay the same tool responses, or the repeats measure
12
+ different things. Recording three times would just overwrite the same rows
13
+ twice while tripling the calls to someone's real API.
14
+
15
+ **Serially.** Two executions recording the same case into one store at once is
16
+ a write race, and the loser's payloads simply are not there later - surfacing
17
+ as a cassette miss, which reads as a model failure.
18
+
19
+ ``--fill-gaps`` switches to ``auto``: replay what is recorded, record what is
20
+ not. That is what to run after adding a case, and it is the one mode where an
21
+ existing recording is never touched.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import asyncio
27
+ from pathlib import Path
28
+ from typing import Annotated
29
+
30
+ import typer
31
+ from rich.text import Text
32
+
33
+ from slmeval import exit_codes
34
+ from slmeval.cli.loading import open_project
35
+ from slmeval.cli.render import console, symbols
36
+ from slmeval.cli.wrap import handle_errors
37
+ from slmeval.core.runner import MatrixRunner, RunOptions
38
+ from slmeval.eval.pipeline import evaluator_for
39
+ from slmeval.report.progress import ProgressObserver
40
+ from slmeval.storage.models import Outcome
41
+
42
+
43
+ @handle_errors
44
+ def record(
45
+ config: Annotated[
46
+ Path | None,
47
+ typer.Option("--config", "-c", help="Path to eval.toml. Defaults to the nearest one."),
48
+ ] = None,
49
+ case: Annotated[
50
+ list[str] | None,
51
+ typer.Option("--case", help="Only these case ids."),
52
+ ] = None,
53
+ fill_gaps: Annotated[
54
+ bool,
55
+ typer.Option(
56
+ "--fill-gaps",
57
+ help="Replay what is already recorded and record only what is missing. "
58
+ "Run this after adding a case.",
59
+ ),
60
+ ] = False,
61
+ ascii_mode: Annotated[
62
+ bool, typer.Option("--ascii", help="ASCII only, for legacy Windows consoles.")
63
+ ] = False,
64
+ quiet: Annotated[bool, typer.Option("--quiet", "-q", help="No progress bars.")] = False,
65
+ ) -> None:
66
+ """Execute real tools once and cache them for replay."""
67
+ out = console()
68
+ sym = symbols(ascii_mode or None)
69
+
70
+ with open_project(config) as project:
71
+ options = RunOptions.from_config(
72
+ project.config,
73
+ repeats=1,
74
+ tool_mode="auto" if fill_gaps else "record",
75
+ # Recording a faulted payload would poison every later replay of
76
+ # that call, and the poison would be indistinguishable from a real
77
+ # recording. The sandbox refuses to inject while recording; this
78
+ # makes the intent explicit at the front door too.
79
+ fault_rate=0.0,
80
+ candidate_ids=(project.config.baseline.id,),
81
+ case_ids=tuple(case) if case else None,
82
+ )
83
+ runner = MatrixRunner(
84
+ config=project.config,
85
+ dataset=project.dataset,
86
+ target=project.target,
87
+ store=project.store,
88
+ options=options,
89
+ cassettes=project.cassettes,
90
+ evaluator=evaluator_for(project.config),
91
+ prices=project.prices,
92
+ root=project.root,
93
+ name=f"{project.config.benchmark.name} (record)",
94
+ )
95
+ observer = ProgressObserver(console=out, ascii_mode=ascii_mode or None, quiet=quiet)
96
+ runner.observer = observer
97
+ try:
98
+ summary = asyncio.run(runner.run())
99
+ except KeyboardInterrupt:
100
+ observer.close()
101
+ out.print()
102
+ out.print(
103
+ Text(
104
+ f" {sym.warn} interrupted - cassettes recorded so far are kept",
105
+ style="yellow",
106
+ )
107
+ )
108
+ out.print()
109
+ raise typer.Exit(exit_codes.USAGE) from None
110
+
111
+ stats = project.cassettes.stats()
112
+ failed = [r for r in summary.results if r.outcome is not Outcome.PASSED]
113
+ out.print(
114
+ Text(
115
+ f" {sym.ok} recorded {stats.cassettes} cassette(s) across "
116
+ f"{len(summary.results)} execution(s)",
117
+ style="bold green",
118
+ )
119
+ )
120
+ out.print(
121
+ Text(
122
+ " every later run can now use --tool-mode replay: no network, "
123
+ "no API keys, no GPU",
124
+ style="dim",
125
+ )
126
+ )
127
+ if failed:
128
+ # A case that could not be recorded has no coverage, so every
129
+ # candidate will miss on it and read as a model failure. Exit 4 -
130
+ # insufficient coverage - rather than letting that happen quietly.
131
+ out.print()
132
+ for result in failed:
133
+ out.print(
134
+ Text(
135
+ f" {sym.fail} {result.unit.case_id}: {result.outcome.value}"
136
+ + (f" - {result.error_message}" if result.error_message else ""),
137
+ style="red",
138
+ )
139
+ )
140
+ out.print()
141
+ out.print(
142
+ Text(
143
+ f" {len(failed)} case(s) were not recorded, so candidates will "
144
+ "miss on them. Fix those before benchmarking.",
145
+ style="yellow",
146
+ )
147
+ )
148
+ out.print()
149
+ raise typer.Exit(exit_codes.COVERAGE)
150
+ out.print()
151
+
152
+
153
+ def register(app: typer.Typer) -> None:
154
+ app.command(name="record", help="Execute real tools once and cache them for replay.")(record)
@@ -0,0 +1,98 @@
1
+ """``slmeval report`` - re-render a stored run, with nothing re-executed.
2
+
3
+ The acceptance criterion for this command is narrow and worth stating: the
4
+ table it prints for a finished run has to be *identical* to the one that run
5
+ printed when it finished. Nothing is recomputed from live objects, nothing is
6
+ re-estimated, and nothing is re-asked of a model. If that ever stops being
7
+ true, the trace store has stopped being the asset and the tool has become a
8
+ thing you have to re-run to look at twice.
9
+
10
+ It deliberately does not need the user's agent to import. A checkout whose
11
+ agent has since been refactored still has valid results from last Tuesday, and
12
+ refusing to print them would be the tool losing data it already holds.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from pathlib import Path
18
+ from typing import Annotated
19
+
20
+ import typer
21
+ from rich.text import Text
22
+
23
+ from slmeval.cli.loading import LATEST, open_project, resolve_run
24
+ from slmeval.cli.render import console, symbols
25
+ from slmeval.cli.wrap import handle_errors
26
+ from slmeval.report.export import FORMATS, export
27
+ from slmeval.report.model import build_scorecard
28
+ from slmeval.report.scorecard import (
29
+ render_rollups,
30
+ render_scorecard,
31
+ render_summary_line,
32
+ )
33
+
34
+
35
+ @handle_errors
36
+ def report(
37
+ run_id: Annotated[
38
+ str,
39
+ typer.Option("--run-id", help="A run id or prefix, or 'latest'."),
40
+ ] = LATEST,
41
+ fmt: Annotated[
42
+ str,
43
+ typer.Option("--format", "-f", help=f"One of: {', '.join(FORMATS)}."),
44
+ ] = "table",
45
+ config: Annotated[
46
+ Path | None,
47
+ typer.Option("--config", "-c", help="Path to eval.toml. Defaults to the nearest one."),
48
+ ] = None,
49
+ nodes: Annotated[
50
+ bool, typer.Option("--nodes/--no-nodes", help="Show the per-node attribution table.")
51
+ ] = True,
52
+ cases: Annotated[
53
+ bool, typer.Option("--cases", help="Also show the per-case pass table.")
54
+ ] = False,
55
+ out_path: Annotated[
56
+ Path | None,
57
+ typer.Option("--out", "-o", help="Write to this file instead of stdout."),
58
+ ] = None,
59
+ ascii_mode: Annotated[
60
+ bool, typer.Option("--ascii", help="ASCII only, for legacy Windows consoles.")
61
+ ] = False,
62
+ ) -> None:
63
+ """Re-render the scorecard from a stored run."""
64
+ if fmt not in FORMATS:
65
+ raise typer.BadParameter(f"format must be one of: {', '.join(FORMATS)}")
66
+
67
+ out = console()
68
+ sym = symbols(ascii_mode or None)
69
+
70
+ with open_project(config, load_agent=False) as project:
71
+ resolved = resolve_run(project.store, run_id)
72
+ card = build_scorecard(project.store, resolved, prices_as_of=project.prices.as_of)
73
+
74
+ if fmt != "table":
75
+ text = export(card, fmt)
76
+ if out_path is not None:
77
+ out_path.write_text(text, encoding="utf-8")
78
+ out.print(Text(f" {sym.ok} wrote {out_path}", style="dim"))
79
+ return
80
+ # print, not rich-print: a JSON or CSV payload is data on its way
81
+ # into a pipe, and Rich would wrap and style it into something no
82
+ # parser can read.
83
+ print(text)
84
+ return
85
+
86
+ out.print(render_scorecard(card, ascii_mode=ascii_mode or None, show_nodes=nodes))
87
+ if cases:
88
+ out.print()
89
+ out.print(render_rollups(card, ascii_mode=ascii_mode or None))
90
+ line = render_summary_line(card, ascii_mode=ascii_mode or None)
91
+ if str(line).strip():
92
+ out.print()
93
+ out.print(line)
94
+ out.print()
95
+
96
+
97
+ def register(app: typer.Typer) -> None:
98
+ app.command(name="report", help="Re-render the scorecard from a stored run.")(report)