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