evalcore 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.
- evalcore-0.1.0.dist-info/METADATA +828 -0
- evalcore-0.1.0.dist-info/RECORD +34 -0
- evalcore-0.1.0.dist-info/WHEEL +4 -0
- evalcore-0.1.0.dist-info/entry_points.txt +2 -0
- evalcore-0.1.0.dist-info/licenses/LICENSE +28 -0
- evalkit/__init__.py +39 -0
- evalkit/adapters/__init__.py +15 -0
- evalkit/adapters/_env.py +24 -0
- evalkit/adapters/base.py +40 -0
- evalkit/adapters/http.py +96 -0
- evalkit/adapters/replay.py +41 -0
- evalkit/cli.py +655 -0
- evalkit/compare.py +137 -0
- evalkit/graders/__init__.py +18 -0
- evalkit/graders/base.py +77 -0
- evalkit/graders/classification.py +107 -0
- evalkit/graders/deterministic.py +127 -0
- evalkit/graders/judge.py +635 -0
- evalkit/graders/numeric.py +91 -0
- evalkit/loader.py +129 -0
- evalkit/models.py +390 -0
- evalkit/pairwise.py +324 -0
- evalkit/py.typed +0 -0
- evalkit/rating.py +1323 -0
- evalkit/refs.py +71 -0
- evalkit/report.py +186 -0
- evalkit/reporters/__init__.py +33 -0
- evalkit/reporters/base.py +159 -0
- evalkit/reporters/html.py +426 -0
- evalkit/reporters/markdown.py +74 -0
- evalkit/retry.py +85 -0
- evalkit/runner.py +283 -0
- evalkit/store.py +286 -0
- evalkit/sweep.py +93 -0
evalkit/runner.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""The runner: execute one suite x one variant over a dataset -> RunResult.
|
|
2
|
+
|
|
3
|
+
Generic and consumer-agnostic. For each case it invokes the adapter
|
|
4
|
+
``n_samples`` times, runs the per-case graders, then runs the aggregate graders
|
|
5
|
+
over the whole result set, and returns the aggregated ``Scorecard`` together
|
|
6
|
+
with every per-sample ``CaseResult`` (outputs, artifacts, scores) so nothing
|
|
7
|
+
downstream has to re-run the suite to inspect individual generations.
|
|
8
|
+
|
|
9
|
+
``suite.concurrency > 1`` runs the (case, sample) invocations concurrently
|
|
10
|
+
under a semaphore; the adapter and per-case graders must then tolerate
|
|
11
|
+
concurrent calls.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import inspect
|
|
16
|
+
import statistics
|
|
17
|
+
import uuid
|
|
18
|
+
|
|
19
|
+
from evalkit import loader, models, store
|
|
20
|
+
from evalkit import retry as retry_mod
|
|
21
|
+
from evalkit.adapters import base as adapters_base
|
|
22
|
+
from evalkit.graders import base as graders_base
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def _invoke_with_retry(
|
|
26
|
+
adapter,
|
|
27
|
+
case: models.Case,
|
|
28
|
+
variant: models.Variant,
|
|
29
|
+
retry: retry_mod.RetryConfig,
|
|
30
|
+
) -> models.Output:
|
|
31
|
+
"""Invoke the adapter, retrying transient failures with backoff.
|
|
32
|
+
|
|
33
|
+
Retries only an ``Output`` the adapter flagged ``retryable`` (a 429, 5xx,
|
|
34
|
+
or network error); a terminal error or a success returns immediately. The
|
|
35
|
+
adapter contract still holds - a failed call sets ``Output.error`` rather
|
|
36
|
+
than raising - so this loop reads the flag, it does not catch exceptions.
|
|
37
|
+
Returns the last ``Output`` (errored) once the attempt budget is spent.
|
|
38
|
+
"""
|
|
39
|
+
attempt = 1
|
|
40
|
+
while True:
|
|
41
|
+
output = await adapter.invoke(case, variant)
|
|
42
|
+
if not (output.error and output.retryable):
|
|
43
|
+
return output
|
|
44
|
+
if attempt >= retry.max_attempts:
|
|
45
|
+
return output
|
|
46
|
+
await asyncio.sleep(retry_mod.backoff_delay(attempt, retry))
|
|
47
|
+
attempt += 1
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _aggregate_metrics(
|
|
51
|
+
results: list[models.CaseResult], agg_scores: list[models.Score]
|
|
52
|
+
) -> dict[str, models.MetricValue]:
|
|
53
|
+
"""Fold per-case scores (mean) and aggregate scores (as-is) to metrics."""
|
|
54
|
+
metrics: dict[str, models.MetricValue] = {}
|
|
55
|
+
|
|
56
|
+
by_metric: dict[str, list[float]] = {}
|
|
57
|
+
for result in results:
|
|
58
|
+
for score in result.scores:
|
|
59
|
+
if score.value is not None:
|
|
60
|
+
by_metric.setdefault(score.metric, []).append(score.value)
|
|
61
|
+
for metric, values in by_metric.items():
|
|
62
|
+
metrics[metric] = models.MetricValue(
|
|
63
|
+
metric=metric,
|
|
64
|
+
value=statistics.fmean(values) if values else None,
|
|
65
|
+
stdev=statistics.stdev(values) if len(values) > 1 else None,
|
|
66
|
+
kind='mean',
|
|
67
|
+
n=len(values),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
for score in agg_scores:
|
|
71
|
+
metrics[score.metric] = models.MetricValue(
|
|
72
|
+
metric=score.metric,
|
|
73
|
+
value=score.value,
|
|
74
|
+
kind='aggregate',
|
|
75
|
+
n=len(results),
|
|
76
|
+
)
|
|
77
|
+
return metrics
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def run_suite(
|
|
81
|
+
suite: loader.SuiteConfig,
|
|
82
|
+
variant_name: str,
|
|
83
|
+
*,
|
|
84
|
+
mode: str | None = None,
|
|
85
|
+
grader_mode: str | None = None,
|
|
86
|
+
revision: str | None = None,
|
|
87
|
+
created_at: str | None = None,
|
|
88
|
+
checkpoint: str | None = None,
|
|
89
|
+
resume: bool = False,
|
|
90
|
+
) -> models.RunResult:
|
|
91
|
+
"""Run ``variant_name`` of ``suite``; return scorecard + all results.
|
|
92
|
+
|
|
93
|
+
``mode`` overrides ``suite.mode_default`` and drives the ADAPTER
|
|
94
|
+
(``'replay'`` swaps the configured adapter for the replay adapter backed
|
|
95
|
+
by ``replay_fixtures``). ``grader_mode`` drives the GRADERS independently
|
|
96
|
+
(defaults to ``mode``); set it to run live judges over recorded outputs
|
|
97
|
+
(``mode='replay', grader_mode='http'``) or replayed judges over a live
|
|
98
|
+
target, without coupling the two.
|
|
99
|
+
|
|
100
|
+
``checkpoint`` is a JSONL file the runner appends each completed
|
|
101
|
+
``(case, sample)`` to; if ``resume`` and it already holds results for the
|
|
102
|
+
*same* suite/dataset/variant (verified by content hash), those are loaded
|
|
103
|
+
and their invocations skipped, so an interrupted run continues instead of
|
|
104
|
+
restarting. Without ``resume`` an existing checkpoint is overwritten.
|
|
105
|
+
"""
|
|
106
|
+
if variant_name not in suite.variants:
|
|
107
|
+
raise KeyError(
|
|
108
|
+
f'variant {variant_name!r} not in suite '
|
|
109
|
+
f'(have {sorted(suite.variants)})'
|
|
110
|
+
)
|
|
111
|
+
mode = mode or suite.mode_default
|
|
112
|
+
grader_mode = grader_mode or mode
|
|
113
|
+
variant = models.Variant(
|
|
114
|
+
name=variant_name, knobs=suite.variants[variant_name]
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
if mode == 'replay':
|
|
118
|
+
if not suite.replay_fixtures:
|
|
119
|
+
raise ValueError('replay mode requires suite.replay_fixtures')
|
|
120
|
+
adapter = adapters_base.build_adapter(
|
|
121
|
+
{'type': 'replay', 'fixtures': suite.replay_fixtures}
|
|
122
|
+
)
|
|
123
|
+
else:
|
|
124
|
+
adapter = adapters_base.build_adapter(suite.adapter)
|
|
125
|
+
|
|
126
|
+
per_case_graders, aggregate_graders = graders_base.build_graders(
|
|
127
|
+
suite.graders
|
|
128
|
+
)
|
|
129
|
+
# Let graders that care (e.g. the LLM judge) bind to grader_mode so they
|
|
130
|
+
# pick a live vs. replay client - independently of the adapter's mode -
|
|
131
|
+
# and to the suite's retry policy so a transient judge-client error backs
|
|
132
|
+
# off like an adapter call. Graders without the hook are left untouched.
|
|
133
|
+
for grader in (*per_case_graders, *aggregate_graders):
|
|
134
|
+
setter = getattr(grader, 'set_mode', None)
|
|
135
|
+
if callable(setter):
|
|
136
|
+
setter(grader_mode)
|
|
137
|
+
retry_setter = getattr(grader, 'set_retry', None)
|
|
138
|
+
if callable(retry_setter):
|
|
139
|
+
retry_setter(suite.retry)
|
|
140
|
+
|
|
141
|
+
cases = loader.load_cases(suite.dataset)
|
|
142
|
+
dataset_hash = loader.dataset_hash(cases)
|
|
143
|
+
run_id = uuid.uuid4().hex
|
|
144
|
+
|
|
145
|
+
# Resume: reuse already-completed (case, sample) results from a checkpoint
|
|
146
|
+
# of the *same* eval; otherwise start (or restart) the checkpoint fresh.
|
|
147
|
+
done: dict[tuple[str, int], models.CaseResult] = {}
|
|
148
|
+
if checkpoint:
|
|
149
|
+
meta = store.checkpoint_meta(checkpoint) if resume else None
|
|
150
|
+
if meta:
|
|
151
|
+
mismatch = (
|
|
152
|
+
meta.get('suite_hash') != suite.suite_hash
|
|
153
|
+
or meta.get('dataset_hash') != dataset_hash
|
|
154
|
+
or meta.get('variant') != variant_name
|
|
155
|
+
)
|
|
156
|
+
if mismatch:
|
|
157
|
+
raise ValueError(
|
|
158
|
+
'checkpoint is for a different suite/dataset/variant; '
|
|
159
|
+
'cannot resume (delete it to start over)'
|
|
160
|
+
)
|
|
161
|
+
run_id = meta['run_id']
|
|
162
|
+
for result in store.read_checkpoint_results(checkpoint):
|
|
163
|
+
done[result.case.id, result.sample_idx] = result
|
|
164
|
+
else:
|
|
165
|
+
store.init_checkpoint(
|
|
166
|
+
checkpoint,
|
|
167
|
+
{
|
|
168
|
+
'run_id': run_id,
|
|
169
|
+
'suite_hash': suite.suite_hash,
|
|
170
|
+
'dataset_hash': dataset_hash,
|
|
171
|
+
'project': suite.project,
|
|
172
|
+
'suite': suite.suite,
|
|
173
|
+
'variant': variant_name,
|
|
174
|
+
},
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
async def _run_one(
|
|
178
|
+
case: models.Case, sample_idx: int
|
|
179
|
+
) -> models.CaseResult:
|
|
180
|
+
output = await _invoke_with_retry(adapter, case, variant, suite.retry)
|
|
181
|
+
scores: list[models.Score] = []
|
|
182
|
+
for grader in per_case_graders:
|
|
183
|
+
graded = grader.grade(case, output)
|
|
184
|
+
if inspect.isawaitable(graded):
|
|
185
|
+
graded = await graded
|
|
186
|
+
scores.extend(graded)
|
|
187
|
+
result = models.CaseResult(
|
|
188
|
+
case=case,
|
|
189
|
+
variant_name=variant_name,
|
|
190
|
+
sample_idx=sample_idx,
|
|
191
|
+
output=output,
|
|
192
|
+
scores=scores,
|
|
193
|
+
)
|
|
194
|
+
# Record on completion so an interrupt leaves a resumable trail. The
|
|
195
|
+
# append is synchronous within this coroutine, so concurrent runs
|
|
196
|
+
# never interleave a half-written line.
|
|
197
|
+
if checkpoint:
|
|
198
|
+
store.append_checkpoint_result(checkpoint, result)
|
|
199
|
+
return result
|
|
200
|
+
|
|
201
|
+
jobs = [
|
|
202
|
+
(case, idx)
|
|
203
|
+
for case in cases
|
|
204
|
+
for idx in range(suite.n_samples)
|
|
205
|
+
if (case.id, idx) not in done
|
|
206
|
+
]
|
|
207
|
+
try:
|
|
208
|
+
if suite.concurrency > 1:
|
|
209
|
+
semaphore = asyncio.Semaphore(suite.concurrency)
|
|
210
|
+
|
|
211
|
+
async def _bounded(
|
|
212
|
+
case: models.Case, idx: int
|
|
213
|
+
) -> models.CaseResult:
|
|
214
|
+
async with semaphore:
|
|
215
|
+
return await _run_one(case, idx)
|
|
216
|
+
|
|
217
|
+
fresh = list(
|
|
218
|
+
await asyncio.gather(
|
|
219
|
+
*(_bounded(case, idx) for case, idx in jobs)
|
|
220
|
+
)
|
|
221
|
+
)
|
|
222
|
+
else:
|
|
223
|
+
fresh = [await _run_one(case, idx) for case, idx in jobs]
|
|
224
|
+
finally:
|
|
225
|
+
# Adapters holding resources (browser, injected session, pooled
|
|
226
|
+
# connections) may expose an optional async ``aclose`` hook.
|
|
227
|
+
closer = getattr(adapter, 'aclose', None)
|
|
228
|
+
if callable(closer):
|
|
229
|
+
closed = closer()
|
|
230
|
+
if inspect.isawaitable(closed):
|
|
231
|
+
await closed
|
|
232
|
+
|
|
233
|
+
# Merge resumed + fresh results into dataset order (case position, then
|
|
234
|
+
# sample), so the run is identical regardless of completion order or how
|
|
235
|
+
# many resumes it took.
|
|
236
|
+
position = {case.id: i for i, case in enumerate(cases)}
|
|
237
|
+
results = sorted(
|
|
238
|
+
[*done.values(), *fresh],
|
|
239
|
+
key=lambda r: (position.get(r.case.id, len(cases)), r.sample_idx),
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
agg_scores: list[models.Score] = []
|
|
243
|
+
for grader in aggregate_graders:
|
|
244
|
+
agg_scores.extend(grader.aggregate(results))
|
|
245
|
+
|
|
246
|
+
# Provenance: a grader may expose a ``judge_version`` pin (the LLM judge
|
|
247
|
+
# does). Collect it onto the scorecard so a judge change re-baselines;
|
|
248
|
+
# multiple judge graders join with ';'. Generic - non-judge graders have
|
|
249
|
+
# no such attribute and contribute nothing.
|
|
250
|
+
judge_pins = [
|
|
251
|
+
pin
|
|
252
|
+
for grader in (*per_case_graders, *aggregate_graders)
|
|
253
|
+
if (pin := getattr(grader, 'judge_version', None))
|
|
254
|
+
]
|
|
255
|
+
|
|
256
|
+
scorecard = models.Scorecard(
|
|
257
|
+
run_id=run_id,
|
|
258
|
+
project=suite.project,
|
|
259
|
+
suite=suite.suite,
|
|
260
|
+
variant=variant,
|
|
261
|
+
dataset_version=suite.dataset_version,
|
|
262
|
+
model_id=variant.knobs.get('model'),
|
|
263
|
+
prompt_version=variant.knobs.get('prompt_version'),
|
|
264
|
+
judge_version=';'.join(judge_pins) or None,
|
|
265
|
+
revision=revision,
|
|
266
|
+
suite_hash=suite.suite_hash,
|
|
267
|
+
dataset_hash=dataset_hash,
|
|
268
|
+
mode=mode,
|
|
269
|
+
n_samples=suite.n_samples,
|
|
270
|
+
n_cases=len(cases),
|
|
271
|
+
created_at=created_at,
|
|
272
|
+
metrics=_aggregate_metrics(results, agg_scores),
|
|
273
|
+
)
|
|
274
|
+
return models.RunResult(
|
|
275
|
+
run_id=run_id, scorecard=scorecard, results=results
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def run_suite_sync(
|
|
280
|
+
suite: loader.SuiteConfig, variant_name: str, **kwargs
|
|
281
|
+
) -> models.RunResult:
|
|
282
|
+
"""Blocking convenience wrapper around :func:`run_suite`."""
|
|
283
|
+
return asyncio.run(run_suite(suite, variant_name, **kwargs))
|
evalkit/store.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Scorecard/comparison persistence and the results-store export seam.
|
|
2
|
+
|
|
3
|
+
Scorecards and comparisons serialize to JSON (CI artifacts, local files). For
|
|
4
|
+
trend tracking you can store them in a column store such as ClickHouse, keyed
|
|
5
|
+
by ``project``/``suite``; rather than couple the engine to any particular
|
|
6
|
+
driver, this module flattens a scorecard into a stable row shape and writes it
|
|
7
|
+
to a JSONL **outbox** a separate shipper drains. Swap ``JsonlOutboxExporter``
|
|
8
|
+
for a real database client without touching the runner or any consumer.
|
|
9
|
+
|
|
10
|
+
The emitted rows follow a no-``Nullable`` convention that maps cleanly onto a
|
|
11
|
+
column store: a missing metric value is the sentinel pair
|
|
12
|
+
``(value=0, has_value=false)`` and ``passed`` is the tri-state string
|
|
13
|
+
``'true'|'false'|'null'``, so a JSONEachRow-style feed maps straight onto a
|
|
14
|
+
flat ``eval_runs``/``eval_scores`` schema.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import pathlib
|
|
19
|
+
|
|
20
|
+
from evalkit import models
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def write_scorecard(
|
|
24
|
+
path: str | pathlib.Path, scorecard: models.Scorecard
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Write a scorecard as pretty JSON."""
|
|
27
|
+
pathlib.Path(path).write_text(
|
|
28
|
+
scorecard.model_dump_json(indent=2), encoding='utf-8'
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def read_scorecard(path: str | pathlib.Path) -> models.Scorecard:
|
|
33
|
+
"""Read a scorecard previously written by :func:`write_scorecard`."""
|
|
34
|
+
return models.Scorecard.model_validate_json(
|
|
35
|
+
pathlib.Path(path).read_text(encoding='utf-8')
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_scorecard(path: str | pathlib.Path) -> models.Scorecard:
|
|
40
|
+
"""Read a scorecard from either a scorecard file or a full-run file.
|
|
41
|
+
|
|
42
|
+
Accepts both ``run --out`` (a bare Scorecard) and ``run --run-out`` (a
|
|
43
|
+
RunResult wrapping one), so a comparison can consume whichever artifact
|
|
44
|
+
a prior run left behind.
|
|
45
|
+
"""
|
|
46
|
+
data = json.loads(pathlib.Path(path).read_text(encoding='utf-8'))
|
|
47
|
+
if isinstance(data, dict) and 'scorecard' in data and 'results' in data:
|
|
48
|
+
return models.Scorecard.model_validate(data['scorecard'])
|
|
49
|
+
return models.Scorecard.model_validate(data)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def write_run(path: str | pathlib.Path, run: models.RunResult) -> None:
|
|
53
|
+
"""Write a full run (scorecard + every per-sample result) as JSON.
|
|
54
|
+
|
|
55
|
+
This is the persisted ground truth behind a scorecard: transcript
|
|
56
|
+
review, human rating, and judge-agreement analysis all read it back
|
|
57
|
+
rather than re-running the suite.
|
|
58
|
+
"""
|
|
59
|
+
pathlib.Path(path).write_text(
|
|
60
|
+
run.model_dump_json(indent=2), encoding='utf-8'
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def read_run(path: str | pathlib.Path) -> models.RunResult:
|
|
65
|
+
"""Read a run previously written by :func:`write_run`."""
|
|
66
|
+
return models.RunResult.model_validate_json(
|
|
67
|
+
pathlib.Path(path).read_text(encoding='utf-8')
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# -- run checkpointing (idempotent resume) ----------------------------------
|
|
72
|
+
#
|
|
73
|
+
# A checkpoint is a JSONL file: line 1 is a meta header (run_id + the content
|
|
74
|
+
# hashes that identify *which* eval it belongs to), and each subsequent line is
|
|
75
|
+
# one completed ``CaseResult``. The runner appends a line as each (case,
|
|
76
|
+
# sample) finishes, so an interrupted run leaves a valid partial file that a
|
|
77
|
+
# ``--resume`` re-run reads back to skip work already done.
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def init_checkpoint(path: str | pathlib.Path, meta: dict) -> None:
|
|
81
|
+
"""Start a fresh checkpoint file with its meta header line (truncating)."""
|
|
82
|
+
path = pathlib.Path(path)
|
|
83
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
with path.open('w', encoding='utf-8') as handle:
|
|
85
|
+
handle.write(json.dumps(meta) + '\n')
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def checkpoint_meta(path: str | pathlib.Path) -> dict | None:
|
|
89
|
+
"""The checkpoint meta header, or ``None`` if the file is absent/empty."""
|
|
90
|
+
path = pathlib.Path(path)
|
|
91
|
+
if not path.is_file():
|
|
92
|
+
return None
|
|
93
|
+
with path.open(encoding='utf-8') as handle:
|
|
94
|
+
first = handle.readline()
|
|
95
|
+
return json.loads(first) if first.strip() else None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def append_checkpoint_result(
|
|
99
|
+
path: str | pathlib.Path, result: models.CaseResult
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Append one completed per-sample result to the checkpoint file."""
|
|
102
|
+
with pathlib.Path(path).open('a', encoding='utf-8') as handle:
|
|
103
|
+
handle.write(result.model_dump_json() + '\n')
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def read_checkpoint_results(
|
|
107
|
+
path: str | pathlib.Path,
|
|
108
|
+
) -> list[models.CaseResult]:
|
|
109
|
+
"""The completed results recorded in a checkpoint (after the meta line)."""
|
|
110
|
+
path = pathlib.Path(path)
|
|
111
|
+
if not path.is_file():
|
|
112
|
+
return []
|
|
113
|
+
lines = path.read_text(encoding='utf-8').splitlines()
|
|
114
|
+
return [
|
|
115
|
+
models.CaseResult.model_validate_json(line)
|
|
116
|
+
for line in lines[1:]
|
|
117
|
+
if line.strip()
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def write_comparison(
|
|
122
|
+
path: str | pathlib.Path, comparison: models.Comparison
|
|
123
|
+
) -> None:
|
|
124
|
+
"""Write a comparison as pretty JSON."""
|
|
125
|
+
pathlib.Path(path).write_text(
|
|
126
|
+
comparison.model_dump_json(indent=2), encoding='utf-8'
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def append_rating(path: str | pathlib.Path, rating: models.Rating) -> None:
|
|
131
|
+
"""Append one human rating to a JSONL ratings file (creating it)."""
|
|
132
|
+
path = pathlib.Path(path)
|
|
133
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
with path.open('a', encoding='utf-8') as handle:
|
|
135
|
+
handle.write(rating.model_dump_json() + '\n')
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def read_ratings(path: str | pathlib.Path) -> list[models.Rating]:
|
|
139
|
+
"""Read a JSONL ratings file (the open human-rating interchange format).
|
|
140
|
+
|
|
141
|
+
Rows may come from the ``rate`` web app or any external tool that emits
|
|
142
|
+
the same shape - one JSON object per line.
|
|
143
|
+
"""
|
|
144
|
+
path = pathlib.Path(path)
|
|
145
|
+
if not path.is_file():
|
|
146
|
+
return []
|
|
147
|
+
return [
|
|
148
|
+
models.Rating.model_validate_json(line)
|
|
149
|
+
for line in path.read_text(encoding='utf-8').splitlines()
|
|
150
|
+
if line.strip()
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def append_preference(
|
|
155
|
+
path: str | pathlib.Path, preference: models.Preference
|
|
156
|
+
) -> None:
|
|
157
|
+
"""Append one side-by-side preference to a JSONL file (creating it)."""
|
|
158
|
+
path = pathlib.Path(path)
|
|
159
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
160
|
+
with path.open('a', encoding='utf-8') as handle:
|
|
161
|
+
handle.write(preference.model_dump_json() + '\n')
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def read_preferences(path: str | pathlib.Path) -> list[models.Preference]:
|
|
165
|
+
"""Read a JSONL preferences file (the open A-vs-B interchange format).
|
|
166
|
+
|
|
167
|
+
Rows may come from the ``rank`` web app or any external tool that emits
|
|
168
|
+
the same shape - one JSON object per line.
|
|
169
|
+
"""
|
|
170
|
+
path = pathlib.Path(path)
|
|
171
|
+
if not path.is_file():
|
|
172
|
+
return []
|
|
173
|
+
return [
|
|
174
|
+
models.Preference.model_validate_json(line)
|
|
175
|
+
for line in path.read_text(encoding='utf-8').splitlines()
|
|
176
|
+
if line.strip()
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def scorecard_rows(scorecard: models.Scorecard) -> list[dict]:
|
|
181
|
+
"""Flatten a scorecard into one row per metric (the store schema).
|
|
182
|
+
|
|
183
|
+
Matches the ``eval_runs``/``eval_scores`` columns in the platform design:
|
|
184
|
+
every row carries the full reproducibility key so rows are self-describing
|
|
185
|
+
in a multi-tenant table.
|
|
186
|
+
"""
|
|
187
|
+
key = _run_key(scorecard)
|
|
188
|
+
return [
|
|
189
|
+
{
|
|
190
|
+
**key,
|
|
191
|
+
'metric': metric.metric,
|
|
192
|
+
# No-Nullable store convention: a missing value is the sentinel
|
|
193
|
+
# pair (0, has_value=false), not null (docs/clickhouse-schema.sql).
|
|
194
|
+
'value': metric.value if metric.value is not None else 0.0,
|
|
195
|
+
'has_value': metric.value is not None,
|
|
196
|
+
'stdev': metric.stdev if metric.stdev is not None else 0.0,
|
|
197
|
+
'has_stdev': metric.stdev is not None,
|
|
198
|
+
'metric_kind': metric.kind,
|
|
199
|
+
'n': metric.n,
|
|
200
|
+
}
|
|
201
|
+
for metric in scorecard.metrics.values()
|
|
202
|
+
]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _run_key(scorecard: models.Scorecard) -> dict:
|
|
206
|
+
"""The reproducibility key carried on every store row."""
|
|
207
|
+
return {
|
|
208
|
+
'run_id': scorecard.run_id,
|
|
209
|
+
'project': scorecard.project,
|
|
210
|
+
'suite': scorecard.suite,
|
|
211
|
+
'variant': scorecard.variant.name,
|
|
212
|
+
'dataset_version': scorecard.dataset_version,
|
|
213
|
+
'model_id': scorecard.model_id,
|
|
214
|
+
'prompt_version': scorecard.prompt_version,
|
|
215
|
+
'judge_version': scorecard.judge_version,
|
|
216
|
+
'revision': scorecard.revision,
|
|
217
|
+
'suite_hash': scorecard.suite_hash,
|
|
218
|
+
'dataset_hash': scorecard.dataset_hash,
|
|
219
|
+
'mode': scorecard.mode,
|
|
220
|
+
'created_at': scorecard.created_at,
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def score_rows(run: models.RunResult) -> list[dict]:
|
|
225
|
+
"""Flatten a run into one row per per-case score (``eval_scores``).
|
|
226
|
+
|
|
227
|
+
The per-sample companion to :func:`scorecard_rows`: every individual
|
|
228
|
+
grader score, keyed by ``run_id``/``case_id``/``sample_idx``, so the
|
|
229
|
+
store keeps the raw observations the scorecard means were computed
|
|
230
|
+
from (needed for variance, drill-down, and judge-vs-human analysis).
|
|
231
|
+
"""
|
|
232
|
+
key = _run_key(run.scorecard)
|
|
233
|
+
rows: list[dict] = []
|
|
234
|
+
for result in run.results:
|
|
235
|
+
for score in result.scores:
|
|
236
|
+
rows.append(
|
|
237
|
+
{
|
|
238
|
+
**key,
|
|
239
|
+
'case_id': result.case.id,
|
|
240
|
+
'sample_idx': result.sample_idx,
|
|
241
|
+
'grader': score.grader,
|
|
242
|
+
'metric': score.metric,
|
|
243
|
+
'value': score.value if score.value is not None else 0.0,
|
|
244
|
+
'has_value': score.value is not None,
|
|
245
|
+
# Tri-state (Enum8 'true'/'false'/'null') per the store
|
|
246
|
+
# convention: deterministic graders set passed, judges
|
|
247
|
+
# leave it null.
|
|
248
|
+
'passed': (
|
|
249
|
+
'true'
|
|
250
|
+
if score.passed is True
|
|
251
|
+
else 'false'
|
|
252
|
+
if score.passed is False
|
|
253
|
+
else 'null'
|
|
254
|
+
),
|
|
255
|
+
'detail': score.detail or '',
|
|
256
|
+
'errored': result.output.error is not None,
|
|
257
|
+
}
|
|
258
|
+
)
|
|
259
|
+
return rows
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class JsonlOutboxExporter:
|
|
263
|
+
"""Append scorecard rows to a JSONL outbox for a ClickHouse shipper.
|
|
264
|
+
|
|
265
|
+
A no-network stand-in for direct ClickHouse ingestion: real deployments
|
|
266
|
+
point a shipper at this file (or replace this class with a ClickHouse
|
|
267
|
+
client implementing the same ``export`` method).
|
|
268
|
+
"""
|
|
269
|
+
|
|
270
|
+
def __init__(self, outbox_path: str | pathlib.Path):
|
|
271
|
+
self.outbox_path = pathlib.Path(outbox_path)
|
|
272
|
+
|
|
273
|
+
def _append(self, rows: list[dict]) -> int:
|
|
274
|
+
self.outbox_path.parent.mkdir(parents=True, exist_ok=True)
|
|
275
|
+
with self.outbox_path.open('a', encoding='utf-8') as handle:
|
|
276
|
+
for row in rows:
|
|
277
|
+
handle.write(json.dumps(row) + '\n')
|
|
278
|
+
return len(rows)
|
|
279
|
+
|
|
280
|
+
def export(self, scorecard: models.Scorecard) -> int:
|
|
281
|
+
"""Append one row per metric; return the number of rows written."""
|
|
282
|
+
return self._append(scorecard_rows(scorecard))
|
|
283
|
+
|
|
284
|
+
def export_scores(self, run: models.RunResult) -> int:
|
|
285
|
+
"""Append one row per per-case score (the ``eval_scores`` feed)."""
|
|
286
|
+
return self._append(score_rows(run))
|
evalkit/sweep.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""N-way sweeps: run many variants of a suite and rank them.
|
|
2
|
+
|
|
3
|
+
Where :mod:`compare` answers "candidate vs baseline", a sweep answers
|
|
4
|
+
"which of these N variants is best" - a model x prompt-version matrix.
|
|
5
|
+
It reuses the per-variant runner unchanged: run every named variant, then
|
|
6
|
+
tabulate one leaderboard (metric x variant) and a ranking by the suite's
|
|
7
|
+
win metric. Purely a run-orchestration + summary layer; no new seams.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from evalkit import loader, models, runner
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def run_sweep(
|
|
14
|
+
suite: loader.SuiteConfig,
|
|
15
|
+
variant_names: list[str] | None = None,
|
|
16
|
+
*,
|
|
17
|
+
mode: str | None = None,
|
|
18
|
+
grader_mode: str | None = None,
|
|
19
|
+
revision: str | None = None,
|
|
20
|
+
created_at: str | None = None,
|
|
21
|
+
) -> list[models.RunResult]:
|
|
22
|
+
"""Run each named variant (default: all) and return their runs."""
|
|
23
|
+
names = variant_names or list(suite.variants)
|
|
24
|
+
runs: list[models.RunResult] = []
|
|
25
|
+
for name in names:
|
|
26
|
+
runs.append(
|
|
27
|
+
await runner.run_suite(
|
|
28
|
+
suite,
|
|
29
|
+
name,
|
|
30
|
+
mode=mode,
|
|
31
|
+
grader_mode=grader_mode,
|
|
32
|
+
revision=revision,
|
|
33
|
+
created_at=created_at,
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
return runs
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def summarize_sweep(
|
|
40
|
+
runs: list[models.RunResult], thresholds: dict | None = None
|
|
41
|
+
) -> models.SweepResult:
|
|
42
|
+
"""Fold sweep runs into a ranked leaderboard by the win metric."""
|
|
43
|
+
thresholds = thresholds or {}
|
|
44
|
+
win_metric = thresholds.get('win_metric')
|
|
45
|
+
higher = thresholds.get('win_higher_is_better', True)
|
|
46
|
+
scorecards = [run.scorecard for run in runs]
|
|
47
|
+
|
|
48
|
+
all_metrics = sorted(
|
|
49
|
+
{metric for card in scorecards for metric in card.metrics}
|
|
50
|
+
)
|
|
51
|
+
matrix: dict[str, dict[str, float | None]] = {}
|
|
52
|
+
for metric in all_metrics:
|
|
53
|
+
matrix[metric] = {}
|
|
54
|
+
for card in scorecards:
|
|
55
|
+
found = card.metrics.get(metric)
|
|
56
|
+
matrix[metric][card.variant.name] = found.value if found else None
|
|
57
|
+
|
|
58
|
+
entries: list[models.SweepEntry] = []
|
|
59
|
+
if win_metric:
|
|
60
|
+
pairs = [
|
|
61
|
+
(
|
|
62
|
+
card.variant.name,
|
|
63
|
+
(
|
|
64
|
+
card.metrics[win_metric].value
|
|
65
|
+
if win_metric in card.metrics
|
|
66
|
+
else None
|
|
67
|
+
),
|
|
68
|
+
)
|
|
69
|
+
for card in scorecards
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
def sort_key(pair: tuple[str, float | None]):
|
|
73
|
+
value = pair[1]
|
|
74
|
+
if value is None:
|
|
75
|
+
return (1, 0.0) # missing metric sorts last
|
|
76
|
+
return (0, -value if higher else value)
|
|
77
|
+
|
|
78
|
+
for rank, (name, value) in enumerate(
|
|
79
|
+
sorted(pairs, key=sort_key), start=1
|
|
80
|
+
):
|
|
81
|
+
entries.append(
|
|
82
|
+
models.SweepEntry(variant=name, win_value=value, rank=rank)
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
first = scorecards[0]
|
|
86
|
+
return models.SweepResult(
|
|
87
|
+
project=first.project,
|
|
88
|
+
suite=first.suite,
|
|
89
|
+
win_metric=win_metric,
|
|
90
|
+
win_higher_is_better=higher,
|
|
91
|
+
entries=entries,
|
|
92
|
+
matrix=matrix,
|
|
93
|
+
)
|