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