oryxeval 26.11.6__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.
- oryxeval/__init__.py +107 -0
- oryxeval/baseline.py +260 -0
- oryxeval/cases.py +267 -0
- oryxeval/cli.py +364 -0
- oryxeval/metric.py +121 -0
- oryxeval/sidebyside.py +180 -0
- oryxeval/stats.py +389 -0
- oryxeval/sweep.py +1257 -0
- oryxeval/task.py +785 -0
- oryxeval-26.11.6.dist-info/METADATA +163 -0
- oryxeval-26.11.6.dist-info/RECORD +14 -0
- oryxeval-26.11.6.dist-info/WHEEL +5 -0
- oryxeval-26.11.6.dist-info/licenses/LICENSE +21 -0
- oryxeval-26.11.6.dist-info/top_level.txt +1 -0
oryxeval/__init__.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""LLM evaluation harness: one cached cell per arm of an eval matrix.
|
|
2
|
+
|
|
3
|
+
Install it with::
|
|
4
|
+
|
|
5
|
+
pip install oryxeval
|
|
6
|
+
|
|
7
|
+
It is a separate package from oryxflow, which it builds on. Import it explicitly::
|
|
8
|
+
|
|
9
|
+
import oryxeval as ev
|
|
10
|
+
|
|
11
|
+
SEAM: the per-case DataFrame is the interface. Everything downstream of a run --
|
|
12
|
+
``Metric``, the slices, the intervals, the verdict, the report -- reads only that
|
|
13
|
+
frame and never touches a pydantic-evals object. The two override points on
|
|
14
|
+
``TaskEval`` are the exit: ``_evaluate()`` returns a report, so swap it for a
|
|
15
|
+
different runner, and ``_to_frame(report)`` returns the rows, so swap it for a
|
|
16
|
+
different report shape. Nothing else has to change. The property that buys is
|
|
17
|
+
available today rather than later: every metric, verdict and report test is
|
|
18
|
+
written against a hand-built DataFrame, so everything downstream of a run is
|
|
19
|
+
testable without pydantic-evals present.
|
|
20
|
+
|
|
21
|
+
``cli`` and ``build_app`` are resolved on first use rather than at import, so
|
|
22
|
+
``import oryxeval`` does not pay for Typer -- a sweep run from a notebook
|
|
23
|
+
never needs a command line.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import importlib
|
|
27
|
+
|
|
28
|
+
from importlib.metadata import PackageNotFoundError, version as _version
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
__version__ = _version("oryxeval")
|
|
32
|
+
except PackageNotFoundError:
|
|
33
|
+
__version__ = "unknown"
|
|
34
|
+
|
|
35
|
+
from oryxflow import (Parameter, IntParameter, FloatParameter, BoolParameter,
|
|
36
|
+
ChoiceParameter, ListParameter, DictParameter, DateParameter,
|
|
37
|
+
EnumParameter, hash_files)
|
|
38
|
+
|
|
39
|
+
from oryxeval.baseline import (
|
|
40
|
+
BaselineError,
|
|
41
|
+
PromptArm,
|
|
42
|
+
Variant,
|
|
43
|
+
VariantError,
|
|
44
|
+
git_sha,
|
|
45
|
+
git_tree,
|
|
46
|
+
)
|
|
47
|
+
from oryxeval.cases import RESERVED_METADATA, CaseList, load_cases
|
|
48
|
+
from oryxeval.metric import Metric
|
|
49
|
+
from oryxeval.stats import (DeltaCI, JudgeAlignment, RateCI, cluster_labels,
|
|
50
|
+
corrected_rate, delta_ci, judge_alignment,
|
|
51
|
+
rate_ci)
|
|
52
|
+
from oryxeval.sweep import EvalResult, sweep
|
|
53
|
+
from oryxeval.task import EvalFailureRateError, TaskEval
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
'__version__',
|
|
57
|
+
'BoolParameter',
|
|
58
|
+
'ChoiceParameter',
|
|
59
|
+
'DateParameter',
|
|
60
|
+
'DictParameter',
|
|
61
|
+
'EnumParameter',
|
|
62
|
+
'FloatParameter',
|
|
63
|
+
'IntParameter',
|
|
64
|
+
'ListParameter',
|
|
65
|
+
'Parameter',
|
|
66
|
+
'hash_files',
|
|
67
|
+
'BaselineError',
|
|
68
|
+
'CaseList',
|
|
69
|
+
'DeltaCI',
|
|
70
|
+
'EvalFailureRateError',
|
|
71
|
+
'EvalResult',
|
|
72
|
+
'JudgeAlignment',
|
|
73
|
+
'Metric',
|
|
74
|
+
'PromptArm',
|
|
75
|
+
'RESERVED_METADATA',
|
|
76
|
+
'RateCI',
|
|
77
|
+
'TaskEval',
|
|
78
|
+
'Variant',
|
|
79
|
+
'VariantError',
|
|
80
|
+
'build_app',
|
|
81
|
+
'cli',
|
|
82
|
+
'cluster_labels',
|
|
83
|
+
'corrected_rate',
|
|
84
|
+
'delta_ci',
|
|
85
|
+
'git_sha',
|
|
86
|
+
'git_tree',
|
|
87
|
+
'judge_alignment',
|
|
88
|
+
'load_cases',
|
|
89
|
+
'rate_ci',
|
|
90
|
+
'sweep',
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
_LAZY = ('cli', 'build_app')
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def __getattr__(name):
|
|
97
|
+
"""Import the Typer-backed names only when one of them is actually used."""
|
|
98
|
+
if name in _LAZY:
|
|
99
|
+
module = importlib.import_module('oryxeval.cli')
|
|
100
|
+
value = module if name == 'cli' else getattr(module, name)
|
|
101
|
+
globals()[name] = value
|
|
102
|
+
return value
|
|
103
|
+
raise AttributeError("module {!r} has no attribute {!r}".format(__name__, name))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def __dir__():
|
|
107
|
+
return sorted(set(list(globals()) + list(__all__)))
|
oryxeval/baseline.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""The two ways to build a comparison arm, deliberately distinct.
|
|
2
|
+
|
|
3
|
+
A baseline is *something that existed*, so it is checked out of git: byte-exact by
|
|
4
|
+
construction, immune to the live files moving under it, and -- the reason this module
|
|
5
|
+
does not simply ship a string-replacement helper -- able to restore a section that was
|
|
6
|
+
DELETED. A probe is a rewrite that has not shipped anywhere, so there is no ref to check
|
|
7
|
+
out and an anchored ``(old -> new)`` replacement is all there is.
|
|
8
|
+
|
|
9
|
+
Both fail loudly. A comparison arm that silently becomes a no-op reports the old version
|
|
10
|
+
as having scored well, which is indistinguishable from a real result and therefore the
|
|
11
|
+
worst failure a comparison can have.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import subprocess
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BaselineError(RuntimeError):
|
|
25
|
+
"""A baseline arm could not be built, or was built empty.
|
|
26
|
+
|
|
27
|
+
A ``RuntimeError``, because an empty or wrong baseline is not a recoverable
|
|
28
|
+
condition: whatever the arm went on to measure would not be the old version.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class VariantError(ValueError):
|
|
33
|
+
"""A variant's anchor text was not found in the rendered text.
|
|
34
|
+
|
|
35
|
+
A ``ValueError``, because the arguments no longer describe the text they are
|
|
36
|
+
applied to -- the anchor has been edited away, and applying the variant anyway
|
|
37
|
+
would report the unchanged prompt's numbers as the variant's.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _git(args, repo=None, binary=False):
|
|
42
|
+
"""Run a git command, returning stdout (bytes when ``binary``). Raise on failure."""
|
|
43
|
+
cwd = str(repo) if repo is not None else os.getcwd()
|
|
44
|
+
proc = subprocess.run(['git'] + list(args), cwd=cwd,
|
|
45
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
46
|
+
if proc.returncode != 0:
|
|
47
|
+
raise BaselineError("git {} failed in {}: {}".format(
|
|
48
|
+
' '.join(args), cwd, proc.stderr.decode('utf-8', 'replace').strip()))
|
|
49
|
+
return proc.stdout if binary else proc.stdout.decode('utf-8', 'replace')
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def git_sha(ref, repo=None):
|
|
53
|
+
"""Resolve ``ref`` to its full commit SHA.
|
|
54
|
+
|
|
55
|
+
An arm built from a git ref must report *this* as its ``code_version()``, never the
|
|
56
|
+
ref string: ``HEAD~1`` names different content every time something is committed, and
|
|
57
|
+
a cache key that moves under a stable-looking name reads stale numbers as fresh ones.
|
|
58
|
+
A resolved SHA is immutable, so the arm's cache key is stable forever.
|
|
59
|
+
"""
|
|
60
|
+
try:
|
|
61
|
+
out = _git(['rev-parse', '--verify', '{}^{{commit}}'.format(ref)], repo=repo)
|
|
62
|
+
except BaselineError:
|
|
63
|
+
out = _git(['rev-parse', '--verify', str(ref)], repo=repo)
|
|
64
|
+
sha = out.strip()
|
|
65
|
+
if not sha:
|
|
66
|
+
raise BaselineError("ref '{}' did not resolve to a commit".format(ref))
|
|
67
|
+
return sha
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _as_list(paths):
|
|
71
|
+
if paths is None:
|
|
72
|
+
return []
|
|
73
|
+
if isinstance(paths, (str, bytes, os.PathLike)):
|
|
74
|
+
return [paths]
|
|
75
|
+
return list(paths)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _ls_tree(ref, paths, repo=None):
|
|
79
|
+
"""Filenames under ``paths`` at ``ref``, recursively, git-style forward slashes."""
|
|
80
|
+
args = ['ls-tree', '-r', '-z', '--name-only', str(ref), '--']
|
|
81
|
+
args += [str(p).replace('\\', '/') for p in paths]
|
|
82
|
+
out = _git(args, repo=repo, binary=True).decode('utf-8', 'surrogateescape')
|
|
83
|
+
return [n for n in out.split('\0') if n]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _default_dest(sha, repo=None):
|
|
87
|
+
base = Path(repo) if repo is not None else Path.cwd()
|
|
88
|
+
root = base / '.oryxflow' / 'baseline'
|
|
89
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
# The materialized tree is generated, never source: ignore it wherever it lands,
|
|
91
|
+
# so a user whose repo predates this never has to notice it.
|
|
92
|
+
ignore = root / '.gitignore'
|
|
93
|
+
if not ignore.exists():
|
|
94
|
+
ignore.write_text('*\n')
|
|
95
|
+
return root / sha[:12]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def git_tree(ref, paths, dest=None, expect_absent=None, repo=None):
|
|
99
|
+
"""Materialize files from a git ref into a directory, for a BASELINE arm.
|
|
100
|
+
|
|
101
|
+
Use this for a prompt/template tree as it was before a change: it is
|
|
102
|
+
byte-exact by construction, cannot decay as the live files move, and -- the
|
|
103
|
+
reason it exists -- it restores DELETED files, which a string-replacement
|
|
104
|
+
variant cannot do at all.
|
|
105
|
+
|
|
106
|
+
``ref`` is anything git resolves ('abc123^', 'v2.1', 'main~3'). Because a
|
|
107
|
+
resolved ref is immutable, an arm built from one has a cache key that is
|
|
108
|
+
stable forever.
|
|
109
|
+
|
|
110
|
+
Raises RuntimeError when the ref yields no files under ``paths`` -- a
|
|
111
|
+
silently empty baseline reads as "the old version already scored well",
|
|
112
|
+
which is the worst possible failure for a comparison.
|
|
113
|
+
|
|
114
|
+
``expect_absent=`` names files that must NOT exist at this ref; their
|
|
115
|
+
presence means the ref is wrong and the arm is measuring nothing.
|
|
116
|
+
|
|
117
|
+
Returns the directory the files were written to. Point your own loader at it --
|
|
118
|
+
how your templates are loaded is yours to know, so nothing here is patched or
|
|
119
|
+
installed on your behalf. The default location is keyed by the resolved SHA,
|
|
120
|
+
beside the eval and gitignored, so a ref is materialized once and reused.
|
|
121
|
+
Pass ``repo=`` to run git somewhere other than the working directory, and use
|
|
122
|
+
``git_sha(ref)`` for the arm's ``code_version()``.
|
|
123
|
+
"""
|
|
124
|
+
paths = _as_list(paths)
|
|
125
|
+
if not paths:
|
|
126
|
+
raise BaselineError(
|
|
127
|
+
"git_tree(ref={!r}) needs at least one path; materializing nothing would "
|
|
128
|
+
"give an empty baseline".format(ref))
|
|
129
|
+
sha = git_sha(ref, repo=repo)
|
|
130
|
+
|
|
131
|
+
for name in _as_list(expect_absent):
|
|
132
|
+
found = _ls_tree(sha, [name], repo=repo)
|
|
133
|
+
if found:
|
|
134
|
+
raise BaselineError(
|
|
135
|
+
"ref '{}' ({}) still has {} -- expect_absent said it should not exist "
|
|
136
|
+
"there, so this is not the ref you meant and the arm would measure "
|
|
137
|
+
"nothing.".format(ref, sha[:10], ', '.join(sorted(found))))
|
|
138
|
+
|
|
139
|
+
names = _ls_tree(sha, paths, repo=repo)
|
|
140
|
+
if not names:
|
|
141
|
+
raise BaselineError(
|
|
142
|
+
"ref '{}' ({}) has no files under {} -- an empty baseline reads as 'the old "
|
|
143
|
+
"version already scored well', so it is refused rather than measured.".format(
|
|
144
|
+
ref, sha[:10], ', '.join(repr(str(p)) for p in paths)))
|
|
145
|
+
|
|
146
|
+
dest = Path(dest) if dest is not None else _default_dest(sha, repo=repo)
|
|
147
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
148
|
+
for name in names:
|
|
149
|
+
target = dest.joinpath(*name.split('/'))
|
|
150
|
+
blob = _git(['show', '{}:{}'.format(sha, name)], repo=repo, binary=True)
|
|
151
|
+
if target.exists() and target.read_bytes() == blob:
|
|
152
|
+
continue
|
|
153
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
154
|
+
target.write_bytes(blob)
|
|
155
|
+
manifest = dest / '_oryxflow_baseline.json'
|
|
156
|
+
manifest.write_text(json.dumps(
|
|
157
|
+
{'ref': str(ref), 'sha': sha, 'paths': [str(p) for p in paths], 'files': names},
|
|
158
|
+
indent=2, sort_keys=True))
|
|
159
|
+
return dest
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass
|
|
163
|
+
class Variant:
|
|
164
|
+
"""An exact (old -> new) replacement applied to already-rendered text, for a
|
|
165
|
+
PROBE arm -- a rewrite that has not shipped, so there is no ref to check out.
|
|
166
|
+
|
|
167
|
+
Raises when ``old`` is not present: a variant whose anchor text has been
|
|
168
|
+
edited away silently becomes a no-op, and then reports the unchanged prompt's
|
|
169
|
+
numbers as the variant's.
|
|
170
|
+
|
|
171
|
+
NOT for baselines. Use ``git_tree`` there: a replacement cannot restore a
|
|
172
|
+
deleted section, and cannot put one back in its original position.
|
|
173
|
+
"""
|
|
174
|
+
old: str
|
|
175
|
+
new: str
|
|
176
|
+
|
|
177
|
+
def apply(self, text):
|
|
178
|
+
"""Return ``text`` with ``old`` replaced by ``new``. Raise ``VariantError``
|
|
179
|
+
(a ``ValueError``) when ``old`` is absent, rather than returning the text
|
|
180
|
+
unchanged."""
|
|
181
|
+
if self.old not in text:
|
|
182
|
+
raise VariantError(
|
|
183
|
+
"variant anchor not found in the rendered text (anchor {!r}...): the "
|
|
184
|
+
"text it was written against has changed, so this variant would be a "
|
|
185
|
+
"no-op and its arm would report the unchanged prompt's numbers.".format(
|
|
186
|
+
self.old[:80]))
|
|
187
|
+
return text.replace(self.old, self.new)
|
|
188
|
+
|
|
189
|
+
def __call__(self, text):
|
|
190
|
+
return self.apply(text)
|
|
191
|
+
|
|
192
|
+
def code_version(self):
|
|
193
|
+
"""A token that changes when this replacement changes, for an arm's cache key."""
|
|
194
|
+
h = hashlib.md5()
|
|
195
|
+
h.update(self.old.encode('utf-8'))
|
|
196
|
+
h.update(b'\0')
|
|
197
|
+
h.update(self.new.encode('utf-8'))
|
|
198
|
+
return 'variant-' + h.hexdigest()[:10]
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass
|
|
202
|
+
class PromptArm:
|
|
203
|
+
"""Where one arm reads its prompt files, AND the cache key that matches them.
|
|
204
|
+
|
|
205
|
+
The two have to agree, and nothing else makes them. Written by hand it is two
|
|
206
|
+
places -- a ``prompt_root(arm)`` that returns a directory and a
|
|
207
|
+
``code_version()`` that returns a token -- and the failure when they drift is
|
|
208
|
+
silent and bad: a baseline arm reading a checked-out tree but keyed on a hash
|
|
209
|
+
of the LIVE files re-runs when a live prompt is edited (merely wasteful) and
|
|
210
|
+
does NOT re-run when you point it at a different ref (stale, and reported as
|
|
211
|
+
fresh). One object owns both, so it cannot happen.
|
|
212
|
+
|
|
213
|
+
ARMS = {
|
|
214
|
+
'live': ev.PromptArm(globs=['prompts/*.jinja2'], root=ROOT),
|
|
215
|
+
'baseline': ev.PromptArm(ref='v2.1', paths=['prompts/'], root=ROOT),
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
# in the agent: ARMS[arm].dir()
|
|
219
|
+
# in the task: def code_version(self): return ARMS[self.arm].code_version()
|
|
220
|
+
|
|
221
|
+
``ref=None`` means the working tree, keyed on the CONTENT of ``globs``. A
|
|
222
|
+
``ref`` means that commit, materialized with :py:func:`git_tree` and keyed on
|
|
223
|
+
the resolved sha -- immutable, so a baseline arm's cell stays valid for good.
|
|
224
|
+
|
|
225
|
+
``subdir`` is applied to both, so the two arms hand the loader the same
|
|
226
|
+
layout and it never has to know which one it got.
|
|
227
|
+
"""
|
|
228
|
+
ref: str = None
|
|
229
|
+
paths: tuple = ()
|
|
230
|
+
globs: tuple = ()
|
|
231
|
+
root: str = None
|
|
232
|
+
subdir: str = ''
|
|
233
|
+
expect_absent: tuple = ()
|
|
234
|
+
dest: str = None
|
|
235
|
+
|
|
236
|
+
def __post_init__(self):
|
|
237
|
+
if self.ref is None and not self.globs:
|
|
238
|
+
raise ValueError(
|
|
239
|
+
'PromptArm: the working-tree arm needs globs= (the files whose CONTENT '
|
|
240
|
+
'is its cache key) -- without them the arm is keyed on nothing and an '
|
|
241
|
+
'edited prompt is served from cache')
|
|
242
|
+
if self.ref is not None and not self.paths:
|
|
243
|
+
raise ValueError(
|
|
244
|
+
'PromptArm: a ref arm needs paths= (what to materialize from {!r})'.format(
|
|
245
|
+
self.ref))
|
|
246
|
+
|
|
247
|
+
def dir(self):
|
|
248
|
+
"""The directory this arm reads its prompts from."""
|
|
249
|
+
base = Path(self.root) if self.root is not None else Path.cwd()
|
|
250
|
+
if self.ref is not None:
|
|
251
|
+
base = git_tree(self.ref, self.paths, dest=self.dest,
|
|
252
|
+
expect_absent=self.expect_absent, repo=self.root)
|
|
253
|
+
return base.joinpath(*self.subdir.split('/')) if self.subdir else base
|
|
254
|
+
|
|
255
|
+
def code_version(self):
|
|
256
|
+
"""The cache token for the files :py:meth:`dir` will hand out."""
|
|
257
|
+
if self.ref is not None:
|
|
258
|
+
return git_sha(self.ref, repo=self.root)
|
|
259
|
+
from oryxflow.utils import hash_files
|
|
260
|
+
return hash_files(*self.globs, root=self.root)
|
oryxeval/cases.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Eval cases as a file a domain expert can edit, not constructors in Python.
|
|
2
|
+
|
|
3
|
+
A case set buried in ``Case(...)`` calls stops being reviewable as a diff, cannot be
|
|
4
|
+
edited by the person who actually has the examples, and turns "add fifty more" into code
|
|
5
|
+
generation. This module reads the same cases from a spreadsheet, a YAML file or a JSONL
|
|
6
|
+
file, and routes the columns by name with nothing to configure.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import csv
|
|
12
|
+
import json
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
from pydantic_evals import Case
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Reserved metadata column names. Mostly documented rather than enforced -- a user may
|
|
20
|
+
# have their own meaning for them, and the cost of being wrong is a slice column with a
|
|
21
|
+
# surprising name, not a wrong number. Two exceptions: ``holdout`` is acted on, and
|
|
22
|
+
# ``arm`` is REFUSED by ``sweep`` (it is the label sweep gives each cell, so a metadata
|
|
23
|
+
# column of that name would be shadowed and any slice on it would silently report the
|
|
24
|
+
# arm label instead).
|
|
25
|
+
RESERVED_METADATA = ('synthetic', 'holdout', 'arm', 'expect_*')
|
|
26
|
+
|
|
27
|
+
_BOOL_TRUE = ('true', 'yes')
|
|
28
|
+
_BOOL_FALSE = ('false', 'no')
|
|
29
|
+
_TRUTHY = ('true', 'yes', '1')
|
|
30
|
+
|
|
31
|
+
_EXPECTED = 'expected'
|
|
32
|
+
_EXPECTED_PREFIX = 'expected.'
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CaseList(list):
|
|
36
|
+
"""The list of cases, plus how many rows were withheld.
|
|
37
|
+
|
|
38
|
+
``excluded`` counts the ``holdout`` rows that were dropped, so a report can state
|
|
39
|
+
the number. A case whose text is embedded in the prompt under test is scoring
|
|
40
|
+
against its own answer key, and a silent exclusion is as misleading as none at all.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
excluded = 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_cases(path, inputs=None, expected=None, name_col='name'):
|
|
47
|
+
"""Load eval cases from a .csv / .yaml / .jsonl file into pydantic-evals ``Case`` objects.
|
|
48
|
+
|
|
49
|
+
Column routing, by name, with no configuration:
|
|
50
|
+
* ``name`` -> the case name
|
|
51
|
+
* a field of the ``inputs`` model -> that input field (validated by pydantic)
|
|
52
|
+
* ``expected`` / ``expected.*`` -> the expected output
|
|
53
|
+
* everything else -> case metadata, which becomes a DataFrame
|
|
54
|
+
column and is therefore available as a report slice
|
|
55
|
+
|
|
56
|
+
A value of the form ``@some/path.md`` is replaced by that file's text, resolved
|
|
57
|
+
relative to the case file. This is what keeps a flat CSV usable with realistic
|
|
58
|
+
inputs -- a long fixture document lives in one file instead of being repeated
|
|
59
|
+
down a column.
|
|
60
|
+
|
|
61
|
+
``inputs`` is a pydantic model class. Pass it: a column your model has no field for
|
|
62
|
+
lands in metadata, so a misspelled one leaves its real field unset and pydantic
|
|
63
|
+
raises a ``ValidationError`` naming that field. That check is the whole reason
|
|
64
|
+
moving cases out of Python is safe. Without a model there is nothing to tell an
|
|
65
|
+
input column from a metadata one, so every column except ``name``, ``expected*``
|
|
66
|
+
and the reserved names below becomes an input.
|
|
67
|
+
|
|
68
|
+
``expected`` is an optional model class for the expected output, built from the
|
|
69
|
+
``expected.*`` columns the same way.
|
|
70
|
+
|
|
71
|
+
Reserved metadata names: ``synthetic`` (kept apart from real cases, because a
|
|
72
|
+
synthetic set written by the mind that wrote the prompt flatters it), ``holdout``,
|
|
73
|
+
``arm``, and ``expect_*`` for per-case expectations. A truthy ``holdout``
|
|
74
|
+
**excludes** the case from the returned list; the number dropped is on the returned
|
|
75
|
+
list's ``.excluded`` attribute. ``arm`` is the one name a sweep will not accept --
|
|
76
|
+
it labels each cell of the matrix, so a column of that name is refused up front
|
|
77
|
+
rather than shadowed (name yours ``surface``, ``write_arm``, ``variant``...).
|
|
78
|
+
|
|
79
|
+
``path`` may also be a list of row dicts -- the same routing, for a probe that keeps
|
|
80
|
+
its handful of cases inline. ``@file`` references then resolve against the working
|
|
81
|
+
directory.
|
|
82
|
+
|
|
83
|
+
Returns a ``CaseList`` -- a list of ``Case`` carrying that count.
|
|
84
|
+
"""
|
|
85
|
+
if isinstance(path, (list, tuple)):
|
|
86
|
+
rows = [dict(r) if isinstance(r, dict) else r for r in path]
|
|
87
|
+
path = Path.cwd() / '<inline cases>'
|
|
88
|
+
else:
|
|
89
|
+
path = Path(path)
|
|
90
|
+
rows = _read(path)
|
|
91
|
+
field_names = _model_field_names(inputs) if inputs is not None else None
|
|
92
|
+
|
|
93
|
+
cases = CaseList()
|
|
94
|
+
excluded = 0
|
|
95
|
+
for i, row in enumerate(rows):
|
|
96
|
+
if not isinstance(row, dict):
|
|
97
|
+
raise ValueError(
|
|
98
|
+
"{}: case {} is a {}, expected one mapping per case".format(
|
|
99
|
+
path, i + 1, type(row).__name__))
|
|
100
|
+
row = {(k.strip() if isinstance(k, str) else k): v for k, v in row.items()}
|
|
101
|
+
name = row.pop(name_col, None)
|
|
102
|
+
name = str(name) if name not in (None, '') else 'case_{}'.format(i + 1)
|
|
103
|
+
row = {k: _resolve_at(v, path, name, k) for k, v in row.items()}
|
|
104
|
+
|
|
105
|
+
kwargs, expected_map, expected_scalar, metadata = {}, {}, None, {}
|
|
106
|
+
has_expected_scalar = False
|
|
107
|
+
for col, value in row.items():
|
|
108
|
+
if col == _EXPECTED:
|
|
109
|
+
expected_scalar, has_expected_scalar = value, True
|
|
110
|
+
elif col.startswith(_EXPECTED_PREFIX):
|
|
111
|
+
expected_map[col[len(_EXPECTED_PREFIX):]] = value
|
|
112
|
+
elif field_names is None:
|
|
113
|
+
if _is_reserved(col):
|
|
114
|
+
metadata[col] = _coerce_metadata(col, value)
|
|
115
|
+
else:
|
|
116
|
+
kwargs[col] = value
|
|
117
|
+
elif col in field_names:
|
|
118
|
+
kwargs[col] = value
|
|
119
|
+
else:
|
|
120
|
+
metadata[col] = _coerce_metadata(col, value)
|
|
121
|
+
|
|
122
|
+
if has_expected_scalar and expected_map:
|
|
123
|
+
raise ValueError(
|
|
124
|
+
"case '{}' in {}: has both an 'expected' column and expected.* columns "
|
|
125
|
+
"-- use one or the other".format(name, path))
|
|
126
|
+
if has_expected_scalar and isinstance(expected_scalar, dict):
|
|
127
|
+
expected_map, has_expected_scalar = dict(expected_scalar), False
|
|
128
|
+
|
|
129
|
+
case_inputs = _drop_empty(kwargs)
|
|
130
|
+
if inputs is not None:
|
|
131
|
+
case_inputs = _build(inputs, case_inputs, name, path)
|
|
132
|
+
|
|
133
|
+
if expected is not None:
|
|
134
|
+
if has_expected_scalar:
|
|
135
|
+
raise ValueError(
|
|
136
|
+
"case '{}' in {}: expected= is a model, so the file needs "
|
|
137
|
+
"expected.<field> columns, not a bare 'expected' column".format(name, path))
|
|
138
|
+
case_expected = _build(expected, _drop_empty(expected_map), name, path)
|
|
139
|
+
elif expected_map:
|
|
140
|
+
case_expected = _drop_empty(expected_map)
|
|
141
|
+
else:
|
|
142
|
+
case_expected = expected_scalar if expected_scalar != '' else None
|
|
143
|
+
|
|
144
|
+
if _truthy(metadata.get('holdout')):
|
|
145
|
+
excluded += 1
|
|
146
|
+
continue
|
|
147
|
+
cases.append(Case(name=name, inputs=case_inputs, metadata=metadata or None,
|
|
148
|
+
expected_output=case_expected))
|
|
149
|
+
|
|
150
|
+
cases.excluded = excluded
|
|
151
|
+
return cases
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ---- readers ---------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
def _read(path):
|
|
157
|
+
suffix = path.suffix.lower()
|
|
158
|
+
if suffix == '.csv':
|
|
159
|
+
return _read_csv(path)
|
|
160
|
+
if suffix in ('.yaml', '.yml'):
|
|
161
|
+
return _read_yaml(path)
|
|
162
|
+
if suffix == '.jsonl':
|
|
163
|
+
return _read_jsonl(path)
|
|
164
|
+
raise ValueError(
|
|
165
|
+
"{}: unsupported case file '{}' -- use .csv, .yaml or .jsonl".format(path, suffix))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _read_csv(path):
|
|
169
|
+
# utf-8-sig: a CSV saved by a spreadsheet carries a BOM, which would otherwise
|
|
170
|
+
# become part of the first column's name and route that column to metadata.
|
|
171
|
+
with open(path, newline='', encoding='utf-8-sig') as f:
|
|
172
|
+
return [dict(row) for row in csv.DictReader(f)]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _read_yaml(path):
|
|
176
|
+
with open(path, encoding='utf-8') as f:
|
|
177
|
+
raw = yaml.safe_load(f)
|
|
178
|
+
if isinstance(raw, dict) and 'cases' in raw:
|
|
179
|
+
raw = raw['cases']
|
|
180
|
+
if not isinstance(raw, list):
|
|
181
|
+
raise ValueError(
|
|
182
|
+
"{}: expected a list of cases, or a 'cases:' key holding one".format(path))
|
|
183
|
+
return raw
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _read_jsonl(path):
|
|
187
|
+
rows = []
|
|
188
|
+
with open(path, encoding='utf-8') as f:
|
|
189
|
+
for i, line in enumerate(f, 1):
|
|
190
|
+
line = line.strip()
|
|
191
|
+
if not line:
|
|
192
|
+
continue
|
|
193
|
+
try:
|
|
194
|
+
rows.append(json.loads(line))
|
|
195
|
+
except ValueError as e:
|
|
196
|
+
raise ValueError("{} line {}: not valid JSON -- {}".format(path, i, e))
|
|
197
|
+
return rows
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ---- helpers ---------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
def _model_field_names(model):
|
|
203
|
+
fields = getattr(model, 'model_fields', None) or getattr(model, '__fields__', None)
|
|
204
|
+
if not fields:
|
|
205
|
+
raise TypeError(
|
|
206
|
+
"{!r} is not a pydantic model class -- load_cases needs one so a misspelled "
|
|
207
|
+
"column is a ValidationError rather than a silent default".format(model))
|
|
208
|
+
return list(fields)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _build(model, kwargs, name, path):
|
|
212
|
+
try:
|
|
213
|
+
return model(**kwargs)
|
|
214
|
+
except Exception as e:
|
|
215
|
+
# Keep the exception type -- a ValidationError already names the offending
|
|
216
|
+
# field -- and add the case, which the file knows and the model does not.
|
|
217
|
+
if hasattr(e, 'add_note'):
|
|
218
|
+
e.add_note("while building case '{}' from {}".format(name, path))
|
|
219
|
+
raise
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _resolve_at(value, path, name, col):
|
|
223
|
+
"""``@some/path.md`` -> that file's text, resolved relative to the case file."""
|
|
224
|
+
if not isinstance(value, str) or not value.startswith('@'):
|
|
225
|
+
return value
|
|
226
|
+
ref = value[1:].strip()
|
|
227
|
+
target = (path.parent / ref).resolve()
|
|
228
|
+
if not target.is_file():
|
|
229
|
+
raise FileNotFoundError(
|
|
230
|
+
"case '{}', column '{}': '{}' points at {}, which does not exist".format(
|
|
231
|
+
name, col, value, target))
|
|
232
|
+
return target.read_text(encoding='utf-8')
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _drop_empty(kwargs):
|
|
236
|
+
"""An empty CSV cell means 'not provided', so the model's own default applies."""
|
|
237
|
+
return {k: v for k, v in kwargs.items() if v not in (None, '')}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _is_reserved(col):
|
|
241
|
+
return col in ('synthetic', 'holdout') or col.startswith('expect_')
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _coerce_metadata(col, value):
|
|
245
|
+
"""CSV gives every cell as a string; recover booleans where the target is unambiguous.
|
|
246
|
+
|
|
247
|
+
``true/false/yes/no`` are booleans in any column. ``1/0`` are booleans only in a
|
|
248
|
+
column that is boolean by convention (``holdout``, ``synthetic``, ``expect_*``) --
|
|
249
|
+
elsewhere they are as likely to be a count, and guessing wrong would put a bool in
|
|
250
|
+
a column someone means to average.
|
|
251
|
+
"""
|
|
252
|
+
if not isinstance(value, str):
|
|
253
|
+
return value
|
|
254
|
+
text = value.strip().lower()
|
|
255
|
+
if text in _BOOL_TRUE:
|
|
256
|
+
return True
|
|
257
|
+
if text in _BOOL_FALSE:
|
|
258
|
+
return False
|
|
259
|
+
if _is_reserved(col) and text in ('1', '0'):
|
|
260
|
+
return text == '1'
|
|
261
|
+
return None if value == '' else value
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _truthy(value):
|
|
265
|
+
if isinstance(value, str):
|
|
266
|
+
return value.strip().lower() in _TRUTHY
|
|
267
|
+
return bool(value)
|