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.
- skill_evals/__init__.py +7 -0
- skill_evals/cli.py +162 -0
- skill_evals/config.py +494 -0
- skill_evals/harnesses/base.sbxenv.yaml +55 -0
- skill_evals/harnesses/claude/cli/spec.yaml +32 -0
- skill_evals/harnesses/claude/harness.yaml +24 -0
- skill_evals/harnesses/claude/sbxenv.yaml +16 -0
- skill_evals/harnesses/claude/tracing/spec.yaml +28 -0
- skill_evals/harnesses/codex/cli/spec.yaml +28 -0
- skill_evals/harnesses/codex/harness.yaml +17 -0
- skill_evals/harnesses/codex/sbxenv.yaml +16 -0
- skill_evals/harnesses/codex/tracing/files/home/.mlflow-codex/setup_tracing.py +123 -0
- skill_evals/harnesses/codex/tracing/spec.yaml +66 -0
- skill_evals/init.py +84 -0
- skill_evals/mlflow_server.py +140 -0
- skill_evals/run.py +481 -0
- skill_evals/schemas/config.schema.json +44 -0
- skill_evals/schemas/eval.schema.json +217 -0
- skill_evals/schemas/harness.schema.json +48 -0
- skill_evals/score.py +362 -0
- skill_evals/scorers/__init__.py +60 -0
- skill_evals/scorers/answers.py +37 -0
- skill_evals/scorers/skills.py +61 -0
- skill_evals/traces/__init__.py +49 -0
- skill_evals/traces/base.py +150 -0
- skill_evals/traces/claude.py +55 -0
- skill_evals/traces/codex.py +45 -0
- skill_evals-0.1.0.dist-info/METADATA +20 -0
- skill_evals-0.1.0.dist-info/RECORD +32 -0
- skill_evals-0.1.0.dist-info/WHEEL +4 -0
- skill_evals-0.1.0.dist-info/entry_points.txt +3 -0
- skill_evals-0.1.0.dist-info/licenses/LICENSE +21 -0
skill_evals/__init__.py
ADDED
|
@@ -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())
|