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
|
@@ -0,0 +1,828 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: evalcore
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A generic, consumer-agnostic evaluation engine for prompt, model, and API outputs.
|
|
5
|
+
Project-URL: Homepage, https://github.com/scottpmiller/evalcore
|
|
6
|
+
Project-URL: Repository, https://github.com/scottpmiller/evalcore
|
|
7
|
+
Project-URL: Issues, https://github.com/scottpmiller/evalcore/issues
|
|
8
|
+
Author: AWeber Communications
|
|
9
|
+
License-Expression: BSD-3-Clause
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Natural Language :: English
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Topic :: Software Development :: Testing
|
|
16
|
+
Requires-Python: >=3.14
|
|
17
|
+
Requires-Dist: pydantic<3,>=2.7
|
|
18
|
+
Requires-Dist: pyyaml<7,>=6
|
|
19
|
+
Provides-Extra: http
|
|
20
|
+
Requires-Dist: httpx>=0.27; extra == 'http'
|
|
21
|
+
Provides-Extra: judge
|
|
22
|
+
Requires-Dist: anthropic>=0.40; extra == 'judge'
|
|
23
|
+
Requires-Dist: openai>=1.40; extra == 'judge'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# evalkit
|
|
27
|
+
|
|
28
|
+
A small, **consumer-agnostic** evaluation engine for prompt, model, and API
|
|
29
|
+
outputs. It detects regressions and improvements as prompts and models change,
|
|
30
|
+
by scoring a candidate against a baseline over a fixed dataset and applying
|
|
31
|
+
guardrails + a headline win metric to produce a gate verdict.
|
|
32
|
+
|
|
33
|
+
evalkit knows nothing about any particular system under test. A consumer
|
|
34
|
+
supplies four things; evalkit supplies everything else:
|
|
35
|
+
|
|
36
|
+
| Consumer provides (data + small plug-ins) | evalkit provides |
|
|
37
|
+
| --- | --- |
|
|
38
|
+
| an **adapter** config (how to call the system + the knobs a variant sets) | runner (N-sampling), comparison/regression engine |
|
|
39
|
+
| **datasets** (cases with opaque `input`/`expected` blobs) | grader registry + generic graders |
|
|
40
|
+
| **graders** + judge rubrics | results store (JSON + column-store outbox) |
|
|
41
|
+
| a **suite + thresholds** config | Markdown reporting, CLI, gate exit codes |
|
|
42
|
+
|
|
43
|
+
See `docs/design.md` for the full design. A complete runnable consumer lives
|
|
44
|
+
in `examples/quickstart/` (a support-reply eval: custom adapter + custom
|
|
45
|
+
graders + deterministic/classification/LLM-judge checks, runnable fully
|
|
46
|
+
offline) and doubles as an end-to-end usage reference.
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install evalcore # distribution name; imports as `evalkit`
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The PyPI package is **`evalcore`** (the name `evalkit` was taken); the import
|
|
55
|
+
package and CLI are still `evalkit` (`import evalkit`, `evalkit --help`). Extras:
|
|
56
|
+
`evalcore[http]`, `evalcore[judge]`.
|
|
57
|
+
|
|
58
|
+
## Develop
|
|
59
|
+
|
|
60
|
+
Standard [uv](https://docs.astral.sh/uv/) project; recipes via
|
|
61
|
+
[just](https://github.com/casey/just):
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
just sync # editable install + all extras + dev deps
|
|
65
|
+
just test # engine unit tests (coverage over src/)
|
|
66
|
+
just test-example # run the quickstart example assertions offline
|
|
67
|
+
just example # quickstart suite: scorecards + verdict (replay)
|
|
68
|
+
just example-api # quickstart suite through the Python API (replay)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Extras: `http` (httpx, needed only for live adapter runs) and `judge`
|
|
72
|
+
(anthropic + openai SDKs, needed only for the live LLM judge). Replay/offline
|
|
73
|
+
runs need none of them.
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
# Writing a consumer
|
|
78
|
+
|
|
79
|
+
A consumer is a directory of data files plus (optionally) a small plug-in
|
|
80
|
+
module. Nothing about your system leaks into the engine; everything below
|
|
81
|
+
lives in *your* tree (your repo, or a directory under `examples/` here):
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
my_service/
|
|
85
|
+
suite.yaml # the suite: adapter + graders + variants + thresholds
|
|
86
|
+
graders.py # optional: custom graders/adapters (plug-in module)
|
|
87
|
+
datasets/<name>/v1/cases/*.yaml
|
|
88
|
+
fixtures/replay.yaml # recorded outputs (offline runs / CI)
|
|
89
|
+
fixtures/judge.yaml # recorded judgments (offline judge)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The five steps, in dependency order:
|
|
93
|
+
|
|
94
|
+
## 1. Cases (the dataset)
|
|
95
|
+
|
|
96
|
+
A dataset is a directory containing `cases/`, holding one YAML (or JSON)
|
|
97
|
+
file per case. Every field except `id` is **opaque to the engine** — only
|
|
98
|
+
your adapter and graders interpret `input` and `expected`:
|
|
99
|
+
|
|
100
|
+
```yaml
|
|
101
|
+
# datasets/support_reply/v1/cases/refund_request.yaml
|
|
102
|
+
id: refund_request # optional; defaults to the filename stem
|
|
103
|
+
labels: {category: billing} # optional metadata, useful for slicing later
|
|
104
|
+
input: # whatever YOUR adapter needs to call the system
|
|
105
|
+
ticket_text: 'I was charged twice for March...'
|
|
106
|
+
customer_tier: pro
|
|
107
|
+
expected: # optional ground truth for graders
|
|
108
|
+
intent: refund
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`loader.load_cases(dataset_dir)` reads every `cases/*.yaml|yml|json` in
|
|
112
|
+
sorted filename order and validates them into `models.Case`. Version the
|
|
113
|
+
dataset by directory (`v1`, `v2`, ...) and set `dataset_version` in the
|
|
114
|
+
suite to match; the engine also computes a `dataset_hash` at run time (see
|
|
115
|
+
"Provenance" below) so an edited case can't hide behind an unbumped version.
|
|
116
|
+
|
|
117
|
+
## 2. The adapter (how to call your system)
|
|
118
|
+
|
|
119
|
+
The adapter is the single seam to the system under test:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
class TargetAdapter(typing.Protocol):
|
|
123
|
+
async def invoke(self, case: Case, variant: Variant) -> Output: ...
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
It receives one `Case` and one `Variant` and must return an
|
|
127
|
+
`Output` — never raise for a failed invocation; set `Output.error` instead
|
|
128
|
+
so graders can count it (see `errors` below). Set `Output.retryable = True`
|
|
129
|
+
alongside a *transient* error (a 429, a 5xx, a network timeout) and the
|
|
130
|
+
runner's retry loop will back off and try again (see "Retries" below); leave
|
|
131
|
+
it False for terminal failures (a bad request, a malformed response) so the
|
|
132
|
+
run doesn't burn attempts on something a retry can't fix.
|
|
133
|
+
|
|
134
|
+
### The built-in `http` adapter
|
|
135
|
+
|
|
136
|
+
Declares the whole call as data in the suite file:
|
|
137
|
+
|
|
138
|
+
```yaml
|
|
139
|
+
adapter:
|
|
140
|
+
type: http
|
|
141
|
+
base_url: ${MY_SERVICE_BASE_URL} # ${VAR} expands from the environment
|
|
142
|
+
path: /reply
|
|
143
|
+
method: POST # default POST
|
|
144
|
+
timeout: 30.0
|
|
145
|
+
headers:
|
|
146
|
+
Content-Type: application/json
|
|
147
|
+
Authorization: ${MY_SERVICE_JWT} # header dropped entirely if VAR unset
|
|
148
|
+
body: # template rendered per (case, variant)
|
|
149
|
+
ticket: $input.ticket_text # $-strings are references, resolved
|
|
150
|
+
tier: $input.customer_tier # against the case/variant; anything
|
|
151
|
+
model: $variant.model # else passes through literally
|
|
152
|
+
extract: # Output.fields <- dotted paths into
|
|
153
|
+
reply: choices.0.text # the JSON response (list indices ok)
|
|
154
|
+
intent: analysis.intent
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Reference roots available in `body`: `$input.*`, `$expected.*`,
|
|
158
|
+
`$variant.*` (the variant's knobs), `$case.*` (the whole case). A path that
|
|
159
|
+
doesn't resolve degrades to `null` rather than erroring. HTTP failures,
|
|
160
|
+
non-2xx statuses, and non-JSON bodies all become `Output.error` values with
|
|
161
|
+
the latency still recorded.
|
|
162
|
+
|
|
163
|
+
### Custom adapters
|
|
164
|
+
|
|
165
|
+
When the built-in isn't enough — auth dances, response post-processing,
|
|
166
|
+
non-HTTP targets — register your own under a config `type` and select it in
|
|
167
|
+
the suite. Subclassing the http adapter is often the shortest path.
|
|
168
|
+
`examples/quickstart/adapter.py` is a worked example: it turns each case's
|
|
169
|
+
input into structured `Output.fields` your graders can score:
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
from evalkit import models
|
|
173
|
+
from evalkit.adapters import base, http
|
|
174
|
+
|
|
175
|
+
@base.register('my_service_json')
|
|
176
|
+
class MyAdapter(http.HTTPAdapter):
|
|
177
|
+
async def invoke(self, case, variant):
|
|
178
|
+
output = await super().invoke(case, variant)
|
|
179
|
+
...post-process output.fields...
|
|
180
|
+
return output
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Constructor kwargs come from the suite's `adapter:` mapping (everything
|
|
184
|
+
except `type`). Load the module at run time with `--plugins my_service.graders`
|
|
185
|
+
(CLI) or a plain `import` (Python API) — registration happens on import.
|
|
186
|
+
|
|
187
|
+
An adapter need not be HTTP-backed: it can grade *what a deployed system
|
|
188
|
+
already did* by reading from an observability store — turning an aggregated
|
|
189
|
+
result row into `Output.fields`.
|
|
190
|
+
|
|
191
|
+
An adapter that holds resources (a browser, an injected session, pooled
|
|
192
|
+
connections) may expose an optional async `aclose()`; the runner calls it
|
|
193
|
+
after the run, even on failure — so a browser-automation adapter (e.g. one
|
|
194
|
+
driving Playwright) can open its context once and tear it down cleanly.
|
|
195
|
+
|
|
196
|
+
## 3. Variants (what's being compared)
|
|
197
|
+
|
|
198
|
+
A variant is a named dict of **knobs** — opaque to the engine, interpreted
|
|
199
|
+
by your adapter (usually via `$variant.*` refs in the body template):
|
|
200
|
+
|
|
201
|
+
```yaml
|
|
202
|
+
variants:
|
|
203
|
+
baseline:
|
|
204
|
+
model: claude-haiku-4-5
|
|
205
|
+
prompt_version: v3
|
|
206
|
+
candidate:
|
|
207
|
+
model: claude-sonnet-4-6
|
|
208
|
+
prompt_version: v4
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Two knob names get special treatment: `model` and `prompt_version` are
|
|
212
|
+
lifted onto the scorecard as `model_id` / `prompt_version` so results are
|
|
213
|
+
self-describing in the store. Everything else is yours.
|
|
214
|
+
|
|
215
|
+
## 4. Graders (how outputs are scored)
|
|
216
|
+
|
|
217
|
+
Two protocols; a class implements one or the other and the runner sorts
|
|
218
|
+
them into buckets automatically:
|
|
219
|
+
|
|
220
|
+
```python
|
|
221
|
+
class Grader(typing.Protocol): # per-case; scores averaged
|
|
222
|
+
name: str
|
|
223
|
+
def grade(self, case, output) -> list[Score]: ... # may be async
|
|
224
|
+
|
|
225
|
+
class AggregateGrader(typing.Protocol): # whole-run; scores stored as-is
|
|
226
|
+
name: str
|
|
227
|
+
def aggregate(self, results: list[CaseResult]) -> list[Score]: ...
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Suite config is a list of `{type, ...kwargs}` specs; `type` selects a
|
|
231
|
+
registered class, the rest becomes constructor kwargs. Built-ins:
|
|
232
|
+
|
|
233
|
+
```yaml
|
|
234
|
+
graders:
|
|
235
|
+
# --- deterministic per-case checks (emit 1.0/0.0 + passed) -------------
|
|
236
|
+
- type: max_chars # len(field) <= maximum
|
|
237
|
+
name: length_ok # `name` doubles as the metric name
|
|
238
|
+
field: output.reply
|
|
239
|
+
maximum: 400
|
|
240
|
+
- type: regex_absent # field must NOT match pattern
|
|
241
|
+
name: no_pii
|
|
242
|
+
field: output.reply
|
|
243
|
+
pattern: '\b\d{3}-\d{2}-\d{4}\b'
|
|
244
|
+
- type: regex_present # field must match EVERY pattern (all-of)
|
|
245
|
+
name: required_markup
|
|
246
|
+
field: output.html
|
|
247
|
+
patterns: ['<form', 'type="email"']
|
|
248
|
+
- type: non_empty # field must be truthy
|
|
249
|
+
field: output.reply
|
|
250
|
+
|
|
251
|
+
# --- numeric (per-case): promote numeric output fields to metrics ------
|
|
252
|
+
- type: numeric # each field -> a scorecard metric (its mean)
|
|
253
|
+
fields:
|
|
254
|
+
- {ref: output.tool_error_rate, max: 0.09} # bounded -> pass/fail too
|
|
255
|
+
- {ref: output.hallucination_rate, max: 0.02}
|
|
256
|
+
- output.cost_per_request # unbounded -> measurement
|
|
257
|
+
|
|
258
|
+
# --- classification (aggregate): P/R/F1 + FN/FP rates ------------------
|
|
259
|
+
- type: classification
|
|
260
|
+
name: intent_detection
|
|
261
|
+
predicted_ref: output.intent # what the system produced
|
|
262
|
+
expected_ref: expected.intent # the human-authored answer key
|
|
263
|
+
positive_labels: [refund] # the class you must not miss
|
|
264
|
+
negative_labels: [question, complaint]
|
|
265
|
+
|
|
266
|
+
# --- LLM judge (per-case): rubric scoring, 1..scale -> 0..1 ------------
|
|
267
|
+
- type: llm_judge
|
|
268
|
+
name: quality
|
|
269
|
+
content_ref: output.reply # the text to judge
|
|
270
|
+
scale: 5
|
|
271
|
+
dimensions:
|
|
272
|
+
- {key: empathy, description: "Acknowledges the customer's situation."}
|
|
273
|
+
- {key: accuracy, description: 'Consistent with the ticket facts.'}
|
|
274
|
+
rubric: | # optional free-text rubric for the judge
|
|
275
|
+
Judge as a support-quality reviewer...
|
|
276
|
+
context_refs: # extra context shown to the judge
|
|
277
|
+
ticket: input.ticket_text
|
|
278
|
+
model: claude-sonnet-4-6 # single-judge shorthand (live mode)
|
|
279
|
+
judge_version: v1 # bump on ANY judge change; re-baseline
|
|
280
|
+
replay_path: fixtures/judge.yaml # recorded judgments (replay mode)
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
Grader field selectors resolve against roots `input`, `expected`, `output`
|
|
284
|
+
(the adapter's extracted fields), `case`, and `artifacts` (the output's
|
|
285
|
+
saved files, e.g. `artifacts.screenshot`).
|
|
286
|
+
|
|
287
|
+
The `numeric` grader is what turns adapter-extracted numbers into scorecard
|
|
288
|
+
metrics: only `Score`s reach a scorecard, so a value the adapter merely put in
|
|
289
|
+
`Output.fields` (a cost, an error rate) needs a grader to promote it. Each
|
|
290
|
+
field's metric name defaults to the ref's leaf (`output.cost` -> `cost`); add
|
|
291
|
+
`min`/`max` to also emit per-case pass/fail. Absent or non-numeric fields
|
|
292
|
+
degrade to `null`. `compare`'s guardrails and a `win_metric` with
|
|
293
|
+
`win_higher_is_better: false` then gate on these directly — e.g. gating a
|
|
294
|
+
`generation_cost` or `tool_error_rate` alongside quality judges.
|
|
295
|
+
|
|
296
|
+
The judge runs live (`AnthropicJudgeClient` forced tool call, or
|
|
297
|
+
`OpenAIJudgeClient` `json_schema` — both temperature 0, needing the `judge`
|
|
298
|
+
extra plus `ANTHROPIC_API_KEY`/`OPENAI_API_KEY`) or offline
|
|
299
|
+
(`ReplayJudgeClient`), chosen by the run mode like the adapter. Each
|
|
300
|
+
dimension becomes a metric `<name>.<key>` plus a `<name>.overall` mean.
|
|
301
|
+
|
|
302
|
+
**Panel + images.** Replace the single `model`/`replay_path` with a
|
|
303
|
+
`judges:` list to run a **panel** — each judge scores independently:
|
|
304
|
+
|
|
305
|
+
```yaml
|
|
306
|
+
- type: llm_judge
|
|
307
|
+
name: quality
|
|
308
|
+
content_ref: output.html
|
|
309
|
+
dimensions: [ ... ]
|
|
310
|
+
image_refs: # images shown to judges (live only)
|
|
311
|
+
screenshot: artifacts.screenshot
|
|
312
|
+
disagreement_threshold: 2 # raw-point spread that flags a case
|
|
313
|
+
judges:
|
|
314
|
+
- {key: claude, provider: anthropic, model: claude-sonnet-4-6, replay_path: fixtures/judge_claude.yaml}
|
|
315
|
+
- {key: gpt, provider: openai, model: 'openai:gpt-4o', replay_path: fixtures/judge_gpt.yaml}
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
A panel emits, on top of the per-dimension panel means and `<name>.overall`:
|
|
319
|
+
`<name>.<judge>.overall` (each judge's own mean, so a systematically
|
|
320
|
+
generous judge is visible), `<name>.disagreement` (mean inter-judge spread
|
|
321
|
+
in raw points), and `<name>.flagged` (1.0 when any dimension's spread
|
|
322
|
+
reaches `disagreement_threshold` — averaged across cases, the fraction a
|
|
323
|
+
human should review). `image_refs` resolve to file paths (from
|
|
324
|
+
`output.artifacts`) or inline `{media_type, data}`; images are sent live
|
|
325
|
+
only, so replay stays offline. A single judge emits none of the panel-only
|
|
326
|
+
metrics, so existing single-judge suites are unchanged (a panel is the natural
|
|
327
|
+
fit for judging rendered screenshots with a Claude+GPT pair, for instance).
|
|
328
|
+
|
|
329
|
+
Custom graders register exactly like adapters — see
|
|
330
|
+
`examples/quickstart/graders.py` for one of each kind (a per-case
|
|
331
|
+
keyword check and a whole-run distinctness check). A grader that needs to
|
|
332
|
+
know the run mode (live vs replay) can expose `set_mode(mode: str)`; the
|
|
333
|
+
runner calls it before the run starts.
|
|
334
|
+
|
|
335
|
+
## 5. Fixtures (offline / CI runs)
|
|
336
|
+
|
|
337
|
+
Replay mode swaps the configured adapter for recorded outputs, keyed by
|
|
338
|
+
case id and variant name — the whole pipeline then runs with no network,
|
|
339
|
+
keys, or deployed service:
|
|
340
|
+
|
|
341
|
+
```yaml
|
|
342
|
+
# fixtures/replay.yaml
|
|
343
|
+
refund_request:
|
|
344
|
+
baseline: {reply: 'We can refund...', intent: refund}
|
|
345
|
+
candidate: {reply: 'Refund issued...', intent: refund}
|
|
346
|
+
billing_question:
|
|
347
|
+
baseline: {error: 'HTTP 502'} # recorded failures work too
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
Judge fixtures are keyed by the **exact content string under judgment**, so
|
|
351
|
+
different variants (different text) deterministically get different scores:
|
|
352
|
+
|
|
353
|
+
```yaml
|
|
354
|
+
# fixtures/judge.yaml
|
|
355
|
+
'We can refund...':
|
|
356
|
+
scores: {empathy: 4, accuracy: 5}
|
|
357
|
+
rationale: Correct but a little curt.
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
## 6. The suite + thresholds (the gate policy)
|
|
361
|
+
|
|
362
|
+
Ties it all together. Paths (`dataset`, `replay_fixtures`, grader
|
|
363
|
+
`replay_path`) resolve relative to the suite file, so the suite runs from
|
|
364
|
+
any working directory:
|
|
365
|
+
|
|
366
|
+
```yaml
|
|
367
|
+
project: my-service # store namespace
|
|
368
|
+
suite: support_reply # suite name within the project
|
|
369
|
+
dataset: datasets/support_reply/v1
|
|
370
|
+
dataset_version: v1
|
|
371
|
+
mode_default: http # 'replay' to default offline
|
|
372
|
+
replay_fixtures: fixtures/replay.yaml
|
|
373
|
+
adapter: {...} # step 2
|
|
374
|
+
graders: [...] # step 4
|
|
375
|
+
variants: {...} # step 3
|
|
376
|
+
n_samples: 1 # invocations per case (sampling)
|
|
377
|
+
concurrency: 1 # max concurrent (case, sample) invocations
|
|
378
|
+
retry: # transient-failure retry (default: no retry)
|
|
379
|
+
max_attempts: 3 # total tries per invocation (1 = off)
|
|
380
|
+
backoff_base: 0.5 # seconds; delay = base * 2**(attempt-1)
|
|
381
|
+
backoff_max: 30.0 # per-sleep cap
|
|
382
|
+
jitter: 0.1 # +/- fractional randomization
|
|
383
|
+
|
|
384
|
+
thresholds:
|
|
385
|
+
win_metric: quality.overall # the ONE headline signal
|
|
386
|
+
win_higher_is_better: true
|
|
387
|
+
win_min_delta: 0.02 # dead band: |delta| <= this -> neutral
|
|
388
|
+
on_regression: warn # or 'fail' to hard-gate the win metric
|
|
389
|
+
variants: {baseline: baseline, candidate: candidate} # gate defaults
|
|
390
|
+
guardrails: # hard constraints on the CANDIDATE
|
|
391
|
+
- metric: false_negative_rate
|
|
392
|
+
max: 0.10 # absolute ceiling
|
|
393
|
+
must_not_increase: true # ...and no worse than baseline
|
|
394
|
+
- metric: no_pii
|
|
395
|
+
min: 1.0 # absolute floor (pass-rates: 'all passed')
|
|
396
|
+
- metric: errors
|
|
397
|
+
max: 0 # any failed invocation fails the gate
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
Guardrail rules compose: `max`, `min`, `must_not_increase`,
|
|
401
|
+
`must_not_decrease`. A guardrail whose metric is missing on the candidate
|
|
402
|
+
fails closed. Pick guardrails for the failures that must never ship, and
|
|
403
|
+
one win metric for the improvement you're hunting; everything else is
|
|
404
|
+
reported informationally.
|
|
405
|
+
|
|
406
|
+
## 7. Running it
|
|
407
|
+
|
|
408
|
+
**CLI** (plug-ins first, so custom types register):
|
|
409
|
+
|
|
410
|
+
```bash
|
|
411
|
+
# one variant -> scorecard (optionally saved)
|
|
412
|
+
evalkit --plugins my_service.graders run \
|
|
413
|
+
--suite my_service/suite.yaml --variant candidate --mode replay \
|
|
414
|
+
--out candidate.scorecard.json --revision "$GIT_SHA"
|
|
415
|
+
|
|
416
|
+
# the CI workhorse: run baseline+candidate, compare, exit 1 on 'fail'
|
|
417
|
+
evalkit --plugins my_service.graders gate \
|
|
418
|
+
--suite my_service/suite.yaml --mode replay \
|
|
419
|
+
--export outbox.jsonl --revision "$GIT_SHA"
|
|
420
|
+
|
|
421
|
+
# re-compare two previously saved runs (accepts --out scorecards OR
|
|
422
|
+
# --run-out run files)
|
|
423
|
+
evalkit compare --suite my_service/suite.yaml \
|
|
424
|
+
--baseline old.run.json --candidate new.run.json
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
`gate` picks variant names from `thresholds.variants`, falling back to
|
|
428
|
+
variants literally named `baseline`/`candidate`. `--revision` is an opaque
|
|
429
|
+
provenance id (git SHA, image digest, release label — whatever your world
|
|
430
|
+
uses; the engine never interprets it).
|
|
431
|
+
|
|
432
|
+
`run`, `compare`, and `gate` take `--report markdown` (default) or
|
|
433
|
+
`--report html` for a standalone, self-contained report document (a CI
|
|
434
|
+
artifact or PR attachment). Reporters are a registry seam like adapters and
|
|
435
|
+
graders — register a custom format with `evalkit.reporters.base.register`
|
|
436
|
+
and select it by name, e.g. `--report pdf`.
|
|
437
|
+
|
|
438
|
+
**The change loop — run, change, run, compare.** To measure whether a
|
|
439
|
+
change (a prompt edit, a new model, a frontend PR) helped or regressed,
|
|
440
|
+
run the *same variant* before and after and compare the two saved runs:
|
|
441
|
+
|
|
442
|
+
```bash
|
|
443
|
+
evalkit ... run --variant candidate --run-out before.run.json --revision before
|
|
444
|
+
# ... make the change (edit the prompt, point at the PR build, swap the model) ...
|
|
445
|
+
evalkit ... run --variant candidate --run-out after.run.json --revision after
|
|
446
|
+
evalkit compare --suite my_service/suite.yaml \
|
|
447
|
+
--baseline before.run.json --candidate after.run.json # deltas + verdict
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
The comparison's guardrails + win metric then read as "did the change
|
|
451
|
+
regress?" For nondeterministic targets (LLMs, browsers) raise `n_samples`
|
|
452
|
+
so each metric is a mean (with `stdev`) over several generations — a single
|
|
453
|
+
run per side makes a small delta indistinguishable from run-to-run noise,
|
|
454
|
+
and `win_min_delta` is your noise floor.
|
|
455
|
+
|
|
456
|
+
**Python API** — everything the CLI does is a library call; the full worked
|
|
457
|
+
version is `examples/quickstart/run_eval.py`:
|
|
458
|
+
|
|
459
|
+
```python
|
|
460
|
+
import my_service.graders # noqa: F401 (registers custom types)
|
|
461
|
+
from evalkit import compare, loader, report, runner, store
|
|
462
|
+
|
|
463
|
+
suite = loader.load_suite('my_service/suite.yaml')
|
|
464
|
+
baseline = runner.run_suite_sync(suite, 'baseline', mode='replay',
|
|
465
|
+
revision='abc123', created_at=now)
|
|
466
|
+
candidate = runner.run_suite_sync(suite, 'candidate', mode='replay',
|
|
467
|
+
revision='abc123', created_at=now)
|
|
468
|
+
# (async context: `await runner.run_suite(...)` is the same call)
|
|
469
|
+
|
|
470
|
+
result = compare.compare(baseline, candidate, suite.thresholds)
|
|
471
|
+
print(report.render_scorecard(candidate))
|
|
472
|
+
print(report.render_comparison(result))
|
|
473
|
+
|
|
474
|
+
store.write_scorecard('candidate.scorecard.json', candidate)
|
|
475
|
+
store.write_comparison('comparison.json', result)
|
|
476
|
+
store.JsonlOutboxExporter('outbox.jsonl').export(candidate)
|
|
477
|
+
|
|
478
|
+
raise SystemExit(0 if result.verdict != 'fail' else 1)
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
---
|
|
482
|
+
|
|
483
|
+
# Interpreting results
|
|
484
|
+
|
|
485
|
+
## The scorecard
|
|
486
|
+
|
|
487
|
+
One scorecard per (suite × variant) run. Header first:
|
|
488
|
+
|
|
489
|
+
```
|
|
490
|
+
### my-service/support_reply - `candidate`
|
|
491
|
+
- model: `claude-sonnet-4-6` mode: `replay` dataset: `v1` cases: 6x2
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
`cases: 6x2` = 6 cases × `n_samples` 2 → **12 observations** behind every
|
|
495
|
+
number below it. Then one row per metric; there are three families, read
|
|
496
|
+
differently:
|
|
497
|
+
|
|
498
|
+
**Pass-rates** (deterministic + custom per-case graders). Each output
|
|
499
|
+
scored 1.0 or 0.0; the scorecard shows the mean. `1.0000` = every
|
|
500
|
+
observation passed; `0.9167` = 11 of 12. The metric name is the grader's
|
|
501
|
+
`name`.
|
|
502
|
+
|
|
503
|
+
**Judge scores** (`quality.empathy`, ..., `quality.overall`). Each output
|
|
504
|
+
rubric-scored 1..scale by the pinned judge, normalized to 0..1, averaged
|
|
505
|
+
across observations. `overall` is the per-output mean of the dimensions,
|
|
506
|
+
then averaged. An output the judge couldn't score (errored invocation,
|
|
507
|
+
missing content) contributes *nothing* — it is excluded from the mean, not
|
|
508
|
+
counted as zero — so always read judge means alongside `errors`.
|
|
509
|
+
|
|
510
|
+
**Set-level aggregates** (classification + custom aggregate graders).
|
|
511
|
+
Computed once over the whole run from a confusion matrix. The
|
|
512
|
+
`classification` grader maps predicted/expected labels to
|
|
513
|
+
positive/negative via your configured label sets, with three rules: an
|
|
514
|
+
errored output increments `errors` and is excluded; a label resolving to
|
|
515
|
+
nothing is an error too; an **unlisted** label counts as *negative*, so a
|
|
516
|
+
stray verdict can never masquerade as a catch. Then:
|
|
517
|
+
|
|
518
|
+
| metric | formula | question it answers |
|
|
519
|
+
| --- | --- | --- |
|
|
520
|
+
| `precision` | TP/(TP+FP) | of what it flagged positive, how much really was? |
|
|
521
|
+
| `recall` | TP/(TP+FN) | of the real positives, how many did it catch? |
|
|
522
|
+
| `f1` | 2PR/(P+R) | single-number balance of the two |
|
|
523
|
+
| `false_negative_rate` | FN/(FN+TP) | misses, as a fraction of real positives |
|
|
524
|
+
| `false_positive_rate` | FP/(FP+TN) | false alarms, as a fraction of real negatives |
|
|
525
|
+
| `accuracy` | (TP+TN)/all | overall fraction correct — flatters on imbalanced data; never guardrail it |
|
|
526
|
+
| `support_positive` / `support_negative` | TP+FN / TN+FP | the denominators: how much evidence backs the rates |
|
|
527
|
+
| `errors` | count | invocations that failed or produced no usable label |
|
|
528
|
+
|
|
529
|
+
FNR and FPR are first-class (not just `1-recall`) because they're the
|
|
530
|
+
operational failure modes gates hang guardrails on: FNR = "a positive
|
|
531
|
+
slipped through", FPR = "a negative got blocked". They have different
|
|
532
|
+
denominators, so they stay honest on imbalanced datasets where `accuracy`
|
|
533
|
+
lies. `support_*` doubles as a drift alarm: if it changes between runs on
|
|
534
|
+
the same `dataset_hash`, extraction or labels broke. `errors` is a raw
|
|
535
|
+
count and worth a `max: 0` guardrail — errored results are excluded from
|
|
536
|
+
every rate, so without it a variant that crashes on its hardest cases
|
|
537
|
+
would look *better*.
|
|
538
|
+
|
|
539
|
+
In JSON/outbox form each metric carries `kind` (`mean` vs `aggregate`) and
|
|
540
|
+
`n`. Downstream tooling may re-average `mean` metrics across runs (weighted
|
|
541
|
+
by `n`) but must never average two `aggregate` values (the mean of two F1s
|
|
542
|
+
is not the combined F1).
|
|
543
|
+
|
|
544
|
+
## The comparison
|
|
545
|
+
|
|
546
|
+
```
|
|
547
|
+
## **PASS** - my-service/support_reply
|
|
548
|
+
`candidate` vs `baseline` - quality.overall neutral
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
The badge is the verdict; the tail is why. The delta table lists every
|
|
552
|
+
metric with baseline / candidate / delta; the row marked
|
|
553
|
+
**(improved | regressed | neutral)** is the configured win metric. Its call
|
|
554
|
+
uses the dead band: `|delta| <= win_min_delta` → **neutral** — deliberate
|
|
555
|
+
protection against celebrating (or reverting on) noise from small samples.
|
|
556
|
+
|
|
557
|
+
The **Guardrails** section shows each rule as `[ok]` or `[BREACH]` with the
|
|
558
|
+
measured value. Verdict logic, in order:
|
|
559
|
+
|
|
560
|
+
1. any guardrail breach → **FAIL** (regardless of the win metric);
|
|
561
|
+
2. else win metric regressed → **WARN** (or **FAIL** if
|
|
562
|
+
`on_regression: fail`);
|
|
563
|
+
3. else → **PASS**.
|
|
564
|
+
|
|
565
|
+
`gate` (and the example driver) exit non-zero exactly on **FAIL**, so the
|
|
566
|
+
verdict drops straight into CI. A **PASS with neutral win** is a perfectly
|
|
567
|
+
good outcome — it means "no regression, no proven improvement".
|
|
568
|
+
|
|
569
|
+
## Provenance (trusting a number later)
|
|
570
|
+
|
|
571
|
+
Every scorecard (and every outbox row) carries the full reproducibility
|
|
572
|
+
key: `project, suite, variant, dataset_version, model_id, prompt_version,
|
|
573
|
+
judge_version, revision, suite_hash, dataset_hash, mode, created_at`.
|
|
574
|
+
|
|
575
|
+
The declared versions state *intent*; the engine-computed content hashes
|
|
576
|
+
prove it: `suite_hash` digests the raw suite file, `dataset_hash` digests
|
|
577
|
+
the loaded cases (order-independent, formatting-independent). Two runs
|
|
578
|
+
whose hashes match evaluated the same config over the same data — if a
|
|
579
|
+
metric moved, the *system under test* moved. If a hash changed, the eval
|
|
580
|
+
itself changed and the comparison is apples-to-oranges: re-baseline.
|
|
581
|
+
`revision` ties the run to whatever provenance scheme you use (commit,
|
|
582
|
+
image digest, release label). A judge model/prompt/scale change is also a
|
|
583
|
+
re-baseline event — bump `judge_version`; the runner lifts each judge
|
|
584
|
+
grader's pin (`key@version`, a panel joins them) onto `Scorecard.judge_version`
|
|
585
|
+
so it rides the reproducibility key, and it stays recorded on every judge
|
|
586
|
+
score's `detail` too.
|
|
587
|
+
|
|
588
|
+
## Per-sample results
|
|
589
|
+
|
|
590
|
+
`runner.run_suite` returns a **`RunResult`** — the scorecard plus every
|
|
591
|
+
per-sample `CaseResult` (the output, its `artifacts`, and its scores). The
|
|
592
|
+
scorecard is the aggregate; the results are the ground truth it was folded
|
|
593
|
+
from. Persist the whole thing with `store.write_run` (CLI: `run --run-out`)
|
|
594
|
+
so transcript review, human rating, and judge-agreement analysis can read
|
|
595
|
+
back individual generations without re-running the suite. Every run gets a
|
|
596
|
+
`run_id` (a UUID) that threads onto the scorecard and every store row.
|
|
597
|
+
|
|
598
|
+
With `n_samples > 1`, each `mean` metric also carries a `stdev` over its
|
|
599
|
+
observations, so repeat-generation spread is visible, not just the average.
|
|
600
|
+
Set `concurrency: N` in the suite to run invocations concurrently (the
|
|
601
|
+
adapter and per-case graders must then tolerate concurrent calls).
|
|
602
|
+
|
|
603
|
+
**Retries.** Live targets fail transiently — a rate limit, a 5xx, a dropped
|
|
604
|
+
connection. A `retry:` block (above) makes the runner re-invoke the adapter
|
|
605
|
+
with exponential backoff (`backoff_base * 2**(attempt-1)`, capped at
|
|
606
|
+
`backoff_max`, ± `jitter`) when — and only when — the adapter marks the
|
|
607
|
+
failure `Output.retryable`. The built-in `http` adapter flags 429/5xx/network
|
|
608
|
+
errors and leaves other 4xx terminal; a custom adapter sets the flag for
|
|
609
|
+
whatever its transient failures are. The default (`max_attempts: 1`) is a
|
|
610
|
+
no-op, so existing suites are unchanged. Retries hold their concurrency slot
|
|
611
|
+
while backing off, so a rate-limited target naturally applies backpressure.
|
|
612
|
+
|
|
613
|
+
The **LLM judge** honors the same `retry:` policy: a transient judge-client
|
|
614
|
+
error (a 429/5xx/timeout raised by the Anthropic/OpenAI SDK) backs off and
|
|
615
|
+
retries, and only a sustained failure surfaces.
|
|
616
|
+
|
|
617
|
+
**Resume.** For long live runs, pass `run --checkpoint run.ckpt`: the runner
|
|
618
|
+
appends each `(case, sample)` result to that JSONL file as it completes, so an
|
|
619
|
+
interrupted run (Ctrl-C, crash, spot-instance reclaim) leaves a valid partial
|
|
620
|
+
trail. Re-run with `--resume` and it reuses the recorded results and invokes
|
|
621
|
+
only what's missing, reusing the original `run_id`:
|
|
622
|
+
|
|
623
|
+
```bash
|
|
624
|
+
evalkit run --suite suite.yaml --variant candidate --checkpoint run.ckpt
|
|
625
|
+
# ... interrupted after 40/100 cases ...
|
|
626
|
+
evalkit run --suite suite.yaml --variant candidate --checkpoint run.ckpt --resume
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
The checkpoint's meta line records `suite_hash`/`dataset_hash`, so a resume
|
|
630
|
+
against a changed suite, dataset, or variant refuses rather than mixing
|
|
631
|
+
incompatible results — delete the checkpoint to start over (which `--resume`
|
|
632
|
+
also does implicitly when the file is absent). A checkpointed `(case, sample)`
|
|
633
|
+
is treated as done whether it succeeded or errored; to redo just the failures,
|
|
634
|
+
drop their lines from the checkpoint first.
|
|
635
|
+
|
|
636
|
+
## Human rating & judge calibration
|
|
637
|
+
|
|
638
|
+
An LLM judge is only trustworthy as a win metric once you've checked it
|
|
639
|
+
tracks human taste. evalkit closes that loop over the persisted runs:
|
|
640
|
+
|
|
641
|
+
```bash
|
|
642
|
+
# blind rating web app over one or more saved runs (repeat --run to blind
|
|
643
|
+
# across variants: the browser never sees which model produced an output)
|
|
644
|
+
evalkit rate --run cand.run.json --run base.run.json \
|
|
645
|
+
--ratings ratings.jsonl --dimensions visual_design,copy_quality \
|
|
646
|
+
--content-ref output.html # or a screenshot via artifacts.*
|
|
647
|
+
|
|
648
|
+
# how well the judge agreed with the humans, per dimension
|
|
649
|
+
evalkit agreement --run cand.run.json --ratings ratings.jsonl \
|
|
650
|
+
--dimensions visual_design,copy_quality --judge-name quality
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
`rate` serves a dependency-free localhost page: a seeded-shuffled queue and
|
|
654
|
+
1..scale buttons per dimension. It renders each item as **typed panels**
|
|
655
|
+
derived from the output — `image`/`pdf` artifacts, `html` (sandboxed iframe
|
|
656
|
+
with a rendered/source toggle), `json`, or `text` — so plain-text
|
|
657
|
+
or JSON results render with zero config, and any number of artifacts become
|
|
658
|
+
that many panels. `--content-ref`/`--screenshot-ref` are the common
|
|
659
|
+
shorthand; a repeatable `--view label:kind:ref` gives explicit control.
|
|
660
|
+
Sessions are **resumable** (a rater only sees items they haven't scored).
|
|
661
|
+
**Blinding is enforced server-side** — the queue payload carries an opaque
|
|
662
|
+
item id and never the run/variant/model; ratings map back to
|
|
663
|
+
`(run_id, case_id, sample_idx)` only on the server. Ratings land in a JSONL
|
|
664
|
+
file (`models.Rating`) that is the **open interchange format**: any external
|
|
665
|
+
tool or spreadsheet export in the same shape feeds `agreement` too.
|
|
666
|
+
|
|
667
|
+
`agreement` reports, per dimension and overall, the mean-absolute-error and
|
|
668
|
+
correlation between the per-case human mean and the judge's score (both on
|
|
669
|
+
0..1). Low MAE + high correlation is the green light to trust that judge
|
|
670
|
+
dimension as a win metric; a panel's `flagged` cases (see the judge panel
|
|
671
|
+
above) are the natural first items to route through `rate`.
|
|
672
|
+
|
|
673
|
+
**Side-by-side preference (`rank`/`preferences`).** `rate` scores each output
|
|
674
|
+
in isolation; `rank` is its A-vs-B analog — the human counterpart of
|
|
675
|
+
`pairwise`. It shows both variants' outputs for the same case as neutral
|
|
676
|
+
"Option 1"/"Option 2" columns and the rater picks a winner overall and per
|
|
677
|
+
dimension:
|
|
678
|
+
|
|
679
|
+
```bash
|
|
680
|
+
# blind side-by-side ranking web app over two saved runs
|
|
681
|
+
evalkit rank --run-a base.run.json --run-b cand.run.json \
|
|
682
|
+
--preferences prefs.jsonl --dimensions visual_design,copy_quality \
|
|
683
|
+
--content-ref output.html
|
|
684
|
+
|
|
685
|
+
# human A-vs-B win-rate (overall + per dimension) from the collected file
|
|
686
|
+
evalkit preferences --run-a base.run.json --run-b cand.run.json \
|
|
687
|
+
--preferences prefs.jsonl --report html --report-out prefs.html
|
|
688
|
+
```
|
|
689
|
+
|
|
690
|
+
Left/right sides are **shuffled per rater and un-blinded server-side**, so a
|
|
691
|
+
stored pick is always in *variant* terms (`variant_a`/`variant_b`) regardless
|
|
692
|
+
of which side it was shown on — position bias counterbalances across raters
|
|
693
|
+
exactly like `pairwise`'s order swap. Sessions are resumable and picks land
|
|
694
|
+
in a JSONL file (`models.Preference`), the **open A-vs-B interchange format**.
|
|
695
|
+
`preferences` aggregates it (ties count half); to check the LLM pairwise judge
|
|
696
|
+
against the human panel, pass the same file to `pairwise --preferences
|
|
697
|
+
prefs.jsonl`, which appends a per-case human-vs-judge agreement table — the
|
|
698
|
+
head-to-head calibration gate.
|
|
699
|
+
|
|
700
|
+
**Live judges on recorded data.** `--mode` drives the adapter; `--judge-mode`
|
|
701
|
+
drives the graders independently (default: same as `--mode`). So
|
|
702
|
+
`--mode replay --judge-mode live` re-scores recorded outputs with the live
|
|
703
|
+
judge panel — no regeneration — which is how you iterate on a rubric or
|
|
704
|
+
re-record judge fixtures cheaply.
|
|
705
|
+
|
|
706
|
+
## Sweeps & pairwise win-rate
|
|
707
|
+
|
|
708
|
+
`compare`/`gate` answer "candidate vs baseline". Two commands go beyond that:
|
|
709
|
+
|
|
710
|
+
```bash
|
|
711
|
+
# N-way: run every variant (or a subset) and rank them by the win metric
|
|
712
|
+
evalkit sweep --suite suite.yaml --mode replay # or --variants a,b,c
|
|
713
|
+
|
|
714
|
+
# A-vs-B: a judge picks a winner per case -> A's win-rate
|
|
715
|
+
evalkit pairwise --suite suite.yaml --a baseline --b candidate --mode replay
|
|
716
|
+
```
|
|
717
|
+
|
|
718
|
+
`sweep` prints a ranked leaderboard plus a full metric × variant matrix (a
|
|
719
|
+
model × prompt-version grid is just several named variants) — reusing the
|
|
720
|
+
per-variant runner unchanged; it's pure orchestration + tabulation.
|
|
721
|
+
|
|
722
|
+
`pairwise` is the sharper subjective signal: instead of scoring each output
|
|
723
|
+
in isolation, a judge is shown **both** variants' outputs for the same case
|
|
724
|
+
and picks a winner, and evalkit reports A's win-rate (ties count half).
|
|
725
|
+
Order is **counterbalanced** — each pair is judged both ways and a pick that
|
|
726
|
+
flips when you swap the order collapses to a tie, so position bias can't
|
|
727
|
+
manufacture a winner. Configure it under `thresholds.pairwise` (`content_ref`,
|
|
728
|
+
`model`/`replay_path`, optional `rubric`/`context_refs`); it runs live
|
|
729
|
+
(Anthropic or OpenAI) or offline against recorded, order-independent
|
|
730
|
+
judgments, like the rubric judge. `examples/quickstart/suite.yaml` has an
|
|
731
|
+
offline pairwise config.
|
|
732
|
+
|
|
733
|
+
## The outbox
|
|
734
|
+
|
|
735
|
+
`JsonlOutboxExporter` flattens results into JSONL for a column-store shipper
|
|
736
|
+
(e.g. ClickHouse) to drain, in a flat `eval_runs`/`eval_scores` shape:
|
|
737
|
+
`export(scorecard)` writes one **metric** row each (`metric, value, stdev,
|
|
738
|
+
metric_kind, n`), and `export_scores(run)` writes one **per-case score** row
|
|
739
|
+
each (`case_id, sample_idx, grader, metric, value, passed, detail`). Both
|
|
740
|
+
repeat the full reproducibility key (incl. `run_id`) so a multi-tenant
|
|
741
|
+
trend table can filter/group on any dimension without joins. Swap the
|
|
742
|
+
exporter for a real database client without touching the runner or any
|
|
743
|
+
consumer. The rows use a no-`Nullable` convention (a missing value is the
|
|
744
|
+
sentinel pair `(value=0, has_value=false)`; `passed` is the tri-state string
|
|
745
|
+
`'true'|'false'|'null'`), so a JSONEachRow-style feed maps straight onto a
|
|
746
|
+
flat schema.
|
|
747
|
+
|
|
748
|
+
---
|
|
749
|
+
|
|
750
|
+
## The two extension seams (recap)
|
|
751
|
+
|
|
752
|
+
```python
|
|
753
|
+
class TargetAdapter(typing.Protocol): # how to call the system under test
|
|
754
|
+
async def invoke(self, case, variant) -> Output: ...
|
|
755
|
+
|
|
756
|
+
class Grader(typing.Protocol): # per-case (averaged)
|
|
757
|
+
def grade(self, case, output) -> list[Score]: ...
|
|
758
|
+
class AggregateGrader(typing.Protocol): # whole-run (P/R/F1, win-rate)
|
|
759
|
+
def aggregate(self, results) -> list[Score]: ...
|
|
760
|
+
```
|
|
761
|
+
|
|
762
|
+
Built-ins: `http` + `replay` adapters; `classification`, `max_chars`,
|
|
763
|
+
`regex_absent`, `regex_present`, `non_empty`, and `llm_judge` graders; and
|
|
764
|
+
`markdown` + `html` reporters (single scorecards and comparative
|
|
765
|
+
comparisons; pick one with `--report`). Register more with
|
|
766
|
+
`evalkit.adapters.base.register` / `evalkit.graders.base.register` /
|
|
767
|
+
`evalkit.reporters.base.register` and load them with `--plugins your.module`
|
|
768
|
+
(CLI) or a plain import (Python API). If onboarding a new consumer ever
|
|
769
|
+
requires touching `src/evalkit/`, that's an abstraction leak — fix the engine
|
|
770
|
+
seam, don't fork it.
|
|
771
|
+
|
|
772
|
+
## Layout
|
|
773
|
+
|
|
774
|
+
```
|
|
775
|
+
src/evalkit/
|
|
776
|
+
models.py Case, Variant, Output, Score, Scorecard, Comparison (opaque-blob based)
|
|
777
|
+
refs.py $ref resolution (the only thing that opens a consumer's blobs)
|
|
778
|
+
loader.py suite + dataset loading, content hashes (YAML/JSON; suite-relative paths)
|
|
779
|
+
adapters/ target seam - http, replay
|
|
780
|
+
graders/ grader seam - deterministic, classification, llm_judge
|
|
781
|
+
runner.py suite x variant -> RunResult (scorecard + per-sample results)
|
|
782
|
+
compare.py candidate vs baseline -> guardrails + win -> verdict
|
|
783
|
+
sweep.py run N variants -> ranked leaderboard (metric x variant)
|
|
784
|
+
pairwise.py A-vs-B judging -> counterbalanced win-rate
|
|
785
|
+
store.py scorecard/run JSON + column-store outbox + ratings/prefs JSONL
|
|
786
|
+
rating.py blind rating + side-by-side ranking web apps + agreement
|
|
787
|
+
reporters/ report seam - markdown / html (scorecard, comparison, ...)
|
|
788
|
+
report.py Markdown renderers (scorecard / comparison / sweep / pairwise / ...)
|
|
789
|
+
cli.py run - compare - gate - sweep - pairwise - rate - rank - report - ...
|
|
790
|
+
tests/ engine unit tests
|
|
791
|
+
examples/quickstart a runnable consumer that doubles as an implementation test
|
|
792
|
+
docs/design.md the design overview
|
|
793
|
+
```
|
|
794
|
+
|
|
795
|
+
## Status
|
|
796
|
+
|
|
797
|
+
MVP. Built: deterministic + classification + **LLM-judge** (rubric scoring;
|
|
798
|
+
single judge or a Claude/GPT **panel** with per-dimension means, per-judge
|
|
799
|
+
overalls, inter-judge disagreement flagging, and image/screenshot inputs)
|
|
800
|
+
graders, http/replay/browser adapters, runner (N-sampling, optional
|
|
801
|
+
concurrency, per-sample `RunResult` + `run_id` + variance), comparison/gate,
|
|
802
|
+
JSON + run + outbox store (metric and per-case-score rows), **N-way sweeps
|
|
803
|
+
+ counterbalanced pairwise A-vs-B win-rate** (`sweep`/`pairwise`), **blind
|
|
804
|
+
human-rating + side-by-side ranking web apps** with judge↔human agreement and
|
|
805
|
+
human-vs-judge pairwise agreement (`rate`/`agreement`, `rank`/`preferences`),
|
|
806
|
+
**pluggable reporters** (`markdown`/`html`, `--report`), provenance
|
|
807
|
+
(`revision` + suite/dataset content hashes), typed package (`py.typed`).
|
|
808
|
+
|
|
809
|
+
Known gaps / next:
|
|
810
|
+
|
|
811
|
+
- A real column-store client (the row shape + JSONL outbox exporter stand
|
|
812
|
+
in, in `store.py`).
|
|
813
|
+
- Cost/token capture: `Output.tokens`/`cost` fields exist but nothing
|
|
814
|
+
populates them (an adapter must fill them from whatever usage its target
|
|
815
|
+
reports).
|
|
816
|
+
- Run robustness lands: retry with exponential backoff on transient failures
|
|
817
|
+
for both the adapter (suite `retry:` + `Output.retryable`) and the LLM judge
|
|
818
|
+
client, plus idempotent mid-run resume from a `run --checkpoint`.
|
|
819
|
+
|
|
820
|
+
## Releasing
|
|
821
|
+
|
|
822
|
+
Releases publish to PyPI as **`evalcore`** via
|
|
823
|
+
[Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (OIDC) — no API
|
|
824
|
+
tokens are stored. One-time setup on PyPI: add a *pending publisher* for project
|
|
825
|
+
`evalcore` pointing at owner `scottpmiller`, repo `evalcore`, workflow
|
|
826
|
+
`publish.yml`, environment `pypi`. Then to cut a release: bump `version` in
|
|
827
|
+
`pyproject.toml`, tag it, and publish a GitHub Release — `.github/workflows/publish.yml`
|
|
828
|
+
builds the sdist + wheel and uploads them. (Point it at TestPyPI first for a dry run.)
|