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