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