skill-evals 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.
@@ -0,0 +1,7 @@
1
+ """Evaluate whether coding agents load and follow skills, in Docker Sandboxes.
2
+
3
+ The `skill-evals` command (see cli.py) runs evals in sandboxes (run.py) and scores
4
+ their MLflow traces (score.py, with the scorers in scorers/ and the per-harness trace
5
+ readers in traces/). What it runs comes from the project that .skill-evals.yaml
6
+ describes (config.py); `skill-evals init` creates one with the shipped harnesses/.
7
+ """
skill_evals/cli.py ADDED
@@ -0,0 +1,162 @@
1
+ """skill-evals: test whether coding agents load and follow your skills.
2
+
3
+ skill-evals init [DIR] Set up a project (.skill-evals.yaml + folders)
4
+ skill-evals evaluate Run the evals, then score them (run + score)
5
+ skill-evals run Run evals in sandboxes
6
+ skill-evals score Score runs' traces; check thresholds
7
+ skill-evals validate Check the project's eval and harness files
8
+ skill-evals mlflow Open the MLflow server (UI) on the project's store
9
+
10
+ run, score and evaluate start an MLflow server for their duration if none is up.
11
+ """
12
+
13
+ import argparse
14
+ import sys
15
+ from collections.abc import Callable
16
+ from pathlib import Path
17
+
18
+ from skill_evals import init, mlflow_server, run, score
19
+ from skill_evals.config import (
20
+ discover_evals,
21
+ discover_harnesses,
22
+ eval_cases,
23
+ load_harness,
24
+ use_config,
25
+ )
26
+
27
+ # A subcommand's function: takes the parsed options, returns an exit code (or, like
28
+ # `run`, something else that counts as success).
29
+ Handler = Callable[[argparse.Namespace], object]
30
+
31
+
32
+ def validate(args: argparse.Namespace) -> int:
33
+ """Validate every eval file and harness.yaml.
34
+
35
+ Args:
36
+ args: The parsed options; `validate` has none of its own.
37
+
38
+ Returns:
39
+ The exit code, 0 (invalid files stop with a message instead).
40
+ """
41
+ del args # Every subcommand takes the parsed options; this one has none
42
+ evals, harnesses = discover_evals(), discover_harnesses()
43
+ for name in evals:
44
+ eval_cases(name) # Validates the file and resolves fixtures and scorers
45
+ for name in harnesses:
46
+ load_harness(name)
47
+ print(f"valid: {len(evals)} eval(s), {len(harnesses)} harness(es)")
48
+ return 0
49
+
50
+
51
+ def evaluate(args: argparse.Namespace) -> int:
52
+ """Run the selected evals, then score exactly that run.
53
+
54
+ Args:
55
+ args: The parsed options of `run` plus the threshold options of `score`.
56
+
57
+ Returns:
58
+ The exit code of the scoring: 1 if a threshold was missed, else 0.
59
+ """
60
+ args.stamp = run.main(args)
61
+ return score.main(args)
62
+
63
+
64
+ def serve(args: argparse.Namespace) -> int:
65
+ """Run the MLflow server in the foreground.
66
+
67
+ Args:
68
+ args: The parsed options; `mlflow` has none of its own.
69
+
70
+ Returns:
71
+ The exit code, 0.
72
+ """
73
+ del args # Every subcommand takes the parsed options; this one has none
74
+ mlflow_server.serve()
75
+ return 0
76
+
77
+
78
+ def _with_mlflow(handler: Handler) -> Handler:
79
+ """Wrap a subcommand so an MLflow server runs while it does.
80
+
81
+ Args:
82
+ handler: The subcommand's function, taking the parsed options.
83
+
84
+ Returns:
85
+ A function with the same signature.
86
+ """
87
+
88
+ def wrapped(args: argparse.Namespace) -> int:
89
+ """Run the wrapped subcommand inside `mlflow_server.running`.
90
+
91
+ Args:
92
+ args: The parsed options.
93
+
94
+ Returns:
95
+ The subcommand's exit code; a non-integer result counts as 0.
96
+ """
97
+ with mlflow_server.running():
98
+ result = handler(args)
99
+ return result if isinstance(result, int) else 0
100
+
101
+ return wrapped
102
+
103
+
104
+ def build_parser() -> argparse.ArgumentParser:
105
+ """Build the command-line parser with every subcommand.
106
+
107
+ Returns:
108
+ The parser; each subcommand sets `handler`.
109
+ """
110
+ fmt = argparse.RawDescriptionHelpFormatter
111
+ ap = argparse.ArgumentParser(
112
+ prog="skill-evals", description=__doc__, formatter_class=fmt
113
+ )
114
+ ap.add_argument(
115
+ "--config",
116
+ type=Path,
117
+ help="the project's .skill-evals.yaml (default: the nearest one above the "
118
+ "current folder)",
119
+ )
120
+ sub = ap.add_subparsers(required=True, metavar="COMMAND")
121
+
122
+ p = sub.add_parser(
123
+ "init", help="set up a project", description=init.__doc__, formatter_class=fmt
124
+ )
125
+ p.add_argument(
126
+ "dir", nargs="?", default="skill-evals", help="folder (default: skill-evals)"
127
+ )
128
+ p.set_defaults(handler=init.main)
129
+
130
+ p = sub.add_parser(
131
+ "evaluate", help="run, then score that run", description=evaluate.__doc__
132
+ )
133
+ run.add_arguments(p)
134
+ score.add_threshold_arguments(p)
135
+ p.set_defaults(handler=_with_mlflow(evaluate))
136
+
137
+ p = sub.add_parser(
138
+ "run", help="run evals in sandboxes", description=run.__doc__, formatter_class=fmt
139
+ )
140
+ run.add_arguments(p)
141
+ p.set_defaults(handler=_with_mlflow(run.main))
142
+
143
+ p = sub.add_parser(
144
+ "score", help="score runs' traces", description=score.__doc__, formatter_class=fmt
145
+ )
146
+ score.add_arguments(p)
147
+ p.set_defaults(handler=_with_mlflow(score.main))
148
+
149
+ p = sub.add_parser("validate", help="check eval and harness files")
150
+ p.set_defaults(handler=validate)
151
+
152
+ p = sub.add_parser("mlflow", help="open the MLflow server (UI) in the foreground")
153
+ p.set_defaults(handler=serve)
154
+ return ap
155
+
156
+
157
+ def main() -> None:
158
+ """Parse the command line and run the chosen subcommand."""
159
+ args = build_parser().parse_args()
160
+ use_config(args.config)
161
+ code = args.handler(args)
162
+ sys.exit(code if isinstance(code, int) else 0)
skill_evals/config.py ADDED
@@ -0,0 +1,494 @@
1
+ """The project being evaluated: its .skill-evals.yaml and the folders it points to.
2
+
3
+ A project is any repository with a `.skill-evals.yaml` (see `skill-evals init`). The
4
+ file names the folders that hold its evals, harnesses, libraries and fixtures, and
5
+ optionally its own scorers; paths are relative to the file:
6
+
7
+ evals: tests/skill-evals/evals
8
+ harnesses: tests/skill-evals/harnesses
9
+ libraries: tests/skill-evals/libraries
10
+ fixtures: tests/skill-evals/fixtures
11
+ scorers: tests/skill-evals/scorers # Optional: extra scorers
12
+ runs: .skill-evals/runs # Optional: run folders (this default)
13
+ mlflow: .skill-evals/mlflow # Optional: MLflow store (this default)
14
+
15
+ The folder layout *is* the configuration: evals are discovered as <evals>/*.yaml,
16
+ harnesses, libraries and fixtures as folders. Eval files and harness.yaml are
17
+ validated against the schemas shipped with this package.
18
+ """
19
+
20
+ import importlib.util
21
+ import json
22
+ import sys
23
+ from collections.abc import Callable, Iterable
24
+ from dataclasses import dataclass
25
+ from functools import cache
26
+ from pathlib import Path
27
+ from types import ModuleType
28
+ from typing import Any
29
+
30
+ import jsonschema
31
+ import yaml
32
+ from mlflow.genai.scorers import Scorer
33
+
34
+ from skill_evals import scorers as builtin_scorers
35
+
36
+ CONFIG_NAME = ".skill-evals.yaml"
37
+ PACKAGE = Path(__file__).resolve().parent
38
+ HARNESSES = PACKAGE / "harnesses" # The shipped harnesses, which `init` copies
39
+ SCHEMAS = PACKAGE / "schemas"
40
+ MLFLOW_PORT = 5000 # Fixed: base.sbxenv.yaml points sandboxes at this port
41
+ MLFLOW_URI = f"http://127.0.0.1:{MLFLOW_PORT}" # Host side
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class Project:
46
+ """A project's folders, resolved from its .skill-evals.yaml.
47
+
48
+ Attributes:
49
+ root: The folder holding .skill-evals.yaml; relative paths start here.
50
+ evals: Folder of eval files (<name>.yaml).
51
+ harnesses: Folder of harnesses, plus the shared base.sbxenv.yaml.
52
+ libraries: Folder of skill libraries.
53
+ fixtures: Folder of fixture kits.
54
+ scorers: Optional Python package with extra scorers (a REGISTRY dict).
55
+ runs: Where each invocation's run folders and report.md go.
56
+ mlflow: Where the managed MLflow server keeps its database and artifacts.
57
+ """
58
+
59
+ root: Path
60
+ evals: Path
61
+ harnesses: Path
62
+ libraries: Path
63
+ fixtures: Path
64
+ scorers: Path | None
65
+ runs: Path
66
+ mlflow: Path
67
+
68
+ @property
69
+ def base_env(self) -> Path:
70
+ """The sbxenv layer every sandbox starts from."""
71
+ return self.harnesses / "base.sbxenv.yaml"
72
+
73
+ def relative(self, path: Path) -> str:
74
+ """Show a path relative to the project root, for messages.
75
+
76
+ Args:
77
+ path: A path inside or outside the project.
78
+
79
+ Returns:
80
+ The relative path if it is inside the project, else the path itself.
81
+ """
82
+ return (
83
+ str(path.relative_to(self.root))
84
+ if path.is_relative_to(self.root)
85
+ else str(path)
86
+ )
87
+
88
+
89
+ _config_path: Path | None = None # Set by `use_config`, e.g. from --config
90
+
91
+
92
+ def use_config(path: Path | None) -> None:
93
+ """Choose the .skill-evals.yaml to use instead of searching for one.
94
+
95
+ Args:
96
+ path: The config file, or None to search upwards from the current folder.
97
+ """
98
+ global _config_path
99
+ _config_path = path.resolve() if path else None
100
+ project.cache_clear()
101
+
102
+
103
+ def find_config(start: Path) -> Path:
104
+ """Find .skill-evals.yaml in `start` or the nearest folder above it.
105
+
106
+ Args:
107
+ start: Where to start looking.
108
+
109
+ Returns:
110
+ The config file's path.
111
+
112
+ Raises:
113
+ SystemExit: If there is none; the message points to `skill-evals init`.
114
+ """
115
+ for folder in [start, *start.parents]:
116
+ if (folder / CONFIG_NAME).is_file():
117
+ return folder / CONFIG_NAME
118
+ msg = f"no {CONFIG_NAME} in {start} or above; create one with `skill-evals init`"
119
+ raise SystemExit(msg)
120
+
121
+
122
+ @cache
123
+ def project() -> Project:
124
+ """Load the current project, once.
125
+
126
+ Returns:
127
+ The project of the chosen config file (see `use_config`), or of the nearest
128
+ .skill-evals.yaml above the current folder.
129
+ """
130
+ path = _config_path or find_config(Path.cwd().resolve())
131
+ data = load_yaml(path, "config.schema.json")
132
+ root = path.parent
133
+
134
+ def folder(key: str, default: str | None = None) -> Path:
135
+ """Resolve one configured folder.
136
+
137
+ Args:
138
+ key: The config key.
139
+ default: Path to use when the key is absent.
140
+
141
+ Returns:
142
+ The absolute folder path.
143
+ """
144
+ return (root / data.get(key, default)).resolve()
145
+
146
+ return Project(
147
+ root=root,
148
+ evals=folder("evals"),
149
+ harnesses=folder("harnesses"),
150
+ libraries=folder("libraries"),
151
+ fixtures=folder("fixtures"),
152
+ scorers=folder("scorers") if "scorers" in data else None,
153
+ runs=folder("runs", ".skill-evals/runs"),
154
+ mlflow=folder("mlflow", ".skill-evals/mlflow"),
155
+ )
156
+
157
+
158
+ def discover_evals() -> dict[str, Path]:
159
+ """Find the evals: every <evals>/<name>.yaml.
160
+
161
+ Returns:
162
+ Eval name mapped to its file, sorted by name.
163
+ """
164
+ return {p.stem: p for p in sorted(project().evals.glob("*.yaml"))}
165
+
166
+
167
+ def discover_harnesses() -> dict[str, Path]:
168
+ """Find the harnesses: every <harnesses>/<name>/ with sbxenv.yaml and harness.yaml.
169
+
170
+ Returns:
171
+ Harness name mapped to its folder, sorted by name.
172
+ """
173
+ return {
174
+ p.parent.name: p.parent
175
+ for p in sorted(project().harnesses.glob("*/harness.yaml"))
176
+ if (p.parent / "sbxenv.yaml").exists()
177
+ }
178
+
179
+
180
+ def discover_libraries() -> list[str]:
181
+ """Find the skill libraries: every folder in <libraries>/.
182
+
183
+ Returns:
184
+ "none" (the baseline without a library), then the library names, sorted.
185
+ """
186
+ folders = project().libraries.iterdir() if project().libraries.is_dir() else []
187
+ return ["none"] + sorted(p.name for p in folders if p.is_dir())
188
+
189
+
190
+ def discover_fixtures() -> dict[str, Path]:
191
+ """Find the fixtures: every <fixtures>/<name>/ with a kit spec.yaml.
192
+
193
+ Returns:
194
+ Fixture name mapped to its kit folder, sorted by name.
195
+ """
196
+ return {
197
+ p.parent.name: p.parent for p in sorted(project().fixtures.glob("*/spec.yaml"))
198
+ }
199
+
200
+
201
+ def library_layer(library: str, harness: str) -> Path | None:
202
+ """Find a library's sbxenv layer for one harness.
203
+
204
+ Args:
205
+ library: Library name, a folder in <libraries>/.
206
+ harness: Harness name, a folder in <harnesses>/.
207
+
208
+ Returns:
209
+ The layer's path, or None if the library doesn't support the harness.
210
+ """
211
+ layer = project().libraries / library / harness / "sbxenv.yaml"
212
+ return layer if layer.exists() else None
213
+
214
+
215
+ @cache
216
+ def _schema(name: str) -> dict[str, Any]:
217
+ """Read a JSON Schema shipped with the package.
218
+
219
+ Args:
220
+ name: The schema's file name, e.g. "eval.schema.json".
221
+
222
+ Returns:
223
+ The parsed schema.
224
+ """
225
+ return json.loads((SCHEMAS / name).read_text())
226
+
227
+
228
+ def load_yaml(path: Path, schema: str) -> dict[str, Any]:
229
+ """Read a YAML file and validate it against one of the package's schemas.
230
+
231
+ Args:
232
+ path: The YAML file.
233
+ schema: The schema's file name, e.g. "eval.schema.json".
234
+
235
+ Returns:
236
+ The parsed, valid content.
237
+
238
+ Raises:
239
+ SystemExit: If the file is missing, isn't valid YAML or doesn't match the
240
+ schema; the message names the file and, for a schema error, the field.
241
+ """
242
+ try:
243
+ data = yaml.safe_load(path.read_text())
244
+ except FileNotFoundError as e:
245
+ msg = f"{path}: no such file"
246
+ raise SystemExit(msg) from e
247
+ except yaml.YAMLError as e:
248
+ msg = f"{path}: invalid YAML: {e}"
249
+ raise SystemExit(msg) from e
250
+ try:
251
+ jsonschema.validate(data, _schema(schema))
252
+ except jsonschema.ValidationError as e:
253
+ where = "/".join(map(str, e.absolute_path)) or "(top level)"
254
+ msg = f"{path}: {where}: {e.message}"
255
+ raise SystemExit(msg) from e
256
+ return data
257
+
258
+
259
+ @dataclass(frozen=True)
260
+ class Fixture:
261
+ """A fixture kit plus the arguments one case passes it.
262
+
263
+ Attributes:
264
+ name: The fixture's folder name.
265
+ path: The kit folder.
266
+ args: Kit arguments, as the strings sbx expects.
267
+ """
268
+
269
+ name: str
270
+ path: Path
271
+ args: dict[str, str]
272
+
273
+
274
+ @dataclass(frozen=True)
275
+ class Case:
276
+ """One prompt of an eval, with the defaults it inherits filled in.
277
+
278
+ Attributes:
279
+ id: Unique within the eval; names run folders and the trace tag eval.case.
280
+ prompt: The non-interactive prompt the agent gets.
281
+ fixture: The kit that builds the workspace, or None for an empty workspace.
282
+ scorers: The scorer specs for this case's traces.
283
+ """
284
+
285
+ id: str
286
+ prompt: str
287
+ fixture: Fixture | None
288
+ scorers: list[dict[str, Any]]
289
+
290
+
291
+ def load_eval(name: str) -> dict[str, Any]:
292
+ """Read and validate <evals>/<name>.yaml.
293
+
294
+ Beyond the schema, checks what JSON Schema can't: unique case ids, every case
295
+ ending up with scorers, that the libraries, fixtures and scorers it names exist,
296
+ and that the scorers can be built with the given arguments.
297
+
298
+ Args:
299
+ name: The eval's name.
300
+
301
+ Returns:
302
+ The eval's spec, as written.
303
+
304
+ Raises:
305
+ SystemExit: If the eval is invalid; the message lists every problem, each
306
+ with the file and field.
307
+ """
308
+ path = discover_evals()[name]
309
+ spec = load_yaml(path, "eval.schema.json")
310
+ where = project().relative(path)
311
+
312
+ problems = []
313
+ if not spec["libraries"] and not spec.get("baseline", True):
314
+ problems.append("baseline: false and no libraries, so nothing would run")
315
+ if unknown := sorted(set(spec["libraries"]) - set(discover_libraries())):
316
+ problems.append(f"libraries: no such library: {', '.join(unknown)}")
317
+ ids = [case["id"] for case in spec["cases"]]
318
+ if duplicates := sorted({i for i in ids if ids.count(i) > 1}):
319
+ problems.append(f"cases: duplicate id: {', '.join(duplicates)}")
320
+ fixtures, registry = discover_fixtures(), scorer_registry()
321
+ for i, case in enumerate(spec["cases"]):
322
+ label = f"cases/{i} ({case['id']})"
323
+ fixture = case.get("fixture", spec.get("fixture"))
324
+ fixture_name = (
325
+ fixture if isinstance(fixture, str) else (fixture or {}).get("name")
326
+ )
327
+ if fixture_name and fixture_name not in fixtures:
328
+ problems.append(f"{label}: no such fixture: {fixture_name}")
329
+ specs = case.get("scorers", spec.get("scorers"))
330
+ if specs is None:
331
+ problems.append(f"{label}: no scorers and no default")
332
+ elif unknown := sorted({s["name"] for s in specs} - set(registry)):
333
+ problems.append(f"{label}: no such scorer: {', '.join(unknown)}")
334
+ else:
335
+ # Build the scorers once, so wrong arguments surface before any run.
336
+ try:
337
+ builtin_scorers.build_scorers(specs, registry)
338
+ except Exception as e: # Any error from a factory means a bad config
339
+ problems.append(f"{label}: scorers: {type(e).__name__}: {e}")
340
+ if problems:
341
+ msg = "\n".join(f"{where}: {problem}" for problem in problems)
342
+ raise SystemExit(msg)
343
+ return spec
344
+
345
+
346
+ def _fixture(value: str | dict[str, Any] | None) -> Fixture | None:
347
+ """Resolve an eval file's `fixture:` value.
348
+
349
+ Args:
350
+ value: A fixture name, a mapping with `name` and `args`, or None.
351
+
352
+ Returns:
353
+ The fixture, or None for an empty workspace.
354
+ """
355
+ if value is None:
356
+ return None
357
+ spec = {"name": value} if isinstance(value, str) else value
358
+ args = {
359
+ key: str(arg).lower() if isinstance(arg, bool) else str(arg)
360
+ for key, arg in (spec.get("args") or {}).items()
361
+ }
362
+ return Fixture(name=spec["name"], path=discover_fixtures()[spec["name"]], args=args)
363
+
364
+
365
+ def eval_cases(name: str) -> list[Case]:
366
+ """List an eval's cases, with the top-level defaults filled in.
367
+
368
+ A case's own `fixture` or `scorers` replaces the default; they aren't merged.
369
+
370
+ Args:
371
+ name: The eval's name.
372
+
373
+ Returns:
374
+ The cases in file order.
375
+ """
376
+ spec = load_eval(name)
377
+ return [
378
+ Case(
379
+ id=case["id"],
380
+ prompt=case["prompt"],
381
+ fixture=_fixture(case.get("fixture", spec.get("fixture"))),
382
+ scorers=case.get("scorers", spec.get("scorers")),
383
+ )
384
+ for case in spec["cases"]
385
+ ]
386
+
387
+
388
+ def eval_libraries(name: str) -> list[str]:
389
+ """List the libraries an eval runs with.
390
+
391
+ Args:
392
+ name: The eval's name.
393
+
394
+ Returns:
395
+ "none" (the baseline without a library) unless the eval sets `baseline:
396
+ false`, then the eval's `libraries`.
397
+ """
398
+ spec = load_eval(name)
399
+ baseline = ["none"] if spec.get("baseline", True) else []
400
+ return baseline + spec["libraries"]
401
+
402
+
403
+ def select_names(kind: str, option: str | None, available: Iterable[str]) -> list[str]:
404
+ """Resolve a comma-separated option such as --evals against what exists.
405
+
406
+ Args:
407
+ kind: What the names are, for the error message, e.g. "eval".
408
+ option: The option's value, or None for everything.
409
+ available: The names that exist.
410
+
411
+ Returns:
412
+ The names asked for, in the order given; all available names for None.
413
+
414
+ Raises:
415
+ SystemExit: If a name doesn't exist; the message lists the available ones.
416
+ """
417
+ names = list(available)
418
+ if option is None:
419
+ return names
420
+ wanted = option.split(",")
421
+ if unknown := [name for name in wanted if name not in names]:
422
+ msg = f"no {kind} {', '.join(map(repr, unknown))}; available: {', '.join(names)}"
423
+ raise SystemExit(msg)
424
+ return wanted
425
+
426
+
427
+ def load_harness(name: str) -> dict[str, Any]:
428
+ """Read and validate <harnesses>/<name>/harness.yaml.
429
+
430
+ Args:
431
+ name: The harness's name.
432
+
433
+ Returns:
434
+ The harness's run settings.
435
+ """
436
+ return load_yaml(discover_harnesses()[name] / "harness.yaml", "harness.schema.json")
437
+
438
+
439
+ def _project_scorers() -> ModuleType | None:
440
+ """Import the project's scorer package, if it configures one.
441
+
442
+ It is loaded by path, once, as `skill_evals_project_scorers`, so its modules can
443
+ import each other relatively.
444
+
445
+ Returns:
446
+ The package, or None if the project has no `scorers` folder.
447
+
448
+ Raises:
449
+ SystemExit: If the folder has no __init__.py.
450
+ ImportError: If its __init__.py can't be loaded.
451
+ """
452
+ folder = project().scorers
453
+ if folder is None:
454
+ return None
455
+ name = "skill_evals_project_scorers"
456
+ if name in sys.modules:
457
+ return sys.modules[name]
458
+ init = folder / "__init__.py"
459
+ if not init.is_file():
460
+ msg = f"{init}: no such file; `scorers` must be a Python package"
461
+ raise SystemExit(msg)
462
+ spec = importlib.util.spec_from_file_location(
463
+ name, init, submodule_search_locations=[str(folder)]
464
+ )
465
+ if spec is None or spec.loader is None:
466
+ msg = f"cannot load {init}"
467
+ raise ImportError(msg)
468
+ module = importlib.util.module_from_spec(spec)
469
+ sys.modules[name] = module
470
+ spec.loader.exec_module(module)
471
+ return module
472
+
473
+
474
+ def scorer_registry() -> dict[str, Callable[..., Scorer]]:
475
+ """Collect every scorer an eval can name.
476
+
477
+ Returns:
478
+ The built-in scorers, plus the project's (its package's REGISTRY), which win
479
+ on a name clash.
480
+ """
481
+ extra = _project_scorers()
482
+ return {**builtin_scorers.REGISTRY, **(getattr(extra, "REGISTRY", None) or {})}
483
+
484
+
485
+ def build_scorers(specs: list[dict[str, Any]]) -> list[Scorer]:
486
+ """Build a case's scorers, plus the always-on diagnostics.
487
+
488
+ Args:
489
+ specs: The case's `scorers` list.
490
+
491
+ Returns:
492
+ One scorer per spec, in order, followed by the diagnostics.
493
+ """
494
+ return builtin_scorers.build_scorers(specs, scorer_registry())