evalcore 2.4.3__tar.gz → 2.6.0__tar.gz
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-2.4.3 → evalcore-2.6.0}/CHANGELOG.md +94 -1
- {evalcore-2.4.3 → evalcore-2.6.0}/PKG-INFO +64 -7
- {evalcore-2.4.3 → evalcore-2.6.0}/README.md +63 -6
- {evalcore-2.4.3 → evalcore-2.6.0}/docs/design.md +9 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/examples/quickstart/graders.py +5 -4
- {evalcore-2.4.3 → evalcore-2.6.0}/examples/quickstart/run_eval.py +7 -11
- {evalcore-2.4.3 → evalcore-2.6.0}/examples/quickstart/suite.yaml +9 -2
- {evalcore-2.4.3 → evalcore-2.6.0}/examples/quickstart/tests/test_quickstart.py +0 -1
- {evalcore-2.4.3 → evalcore-2.6.0}/justfile +2 -2
- {evalcore-2.4.3 → evalcore-2.6.0}/pyproject.toml +1 -1
- {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/cli.py +10 -13
- {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/compare.py +128 -9
- {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/graders/classification.py +23 -5
- {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/graders/deterministic.py +3 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/graders/judge.py +16 -1
- {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/graders/numeric.py +25 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/loader.py +5 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/models.py +52 -1
- evalcore-2.6.0/src/evalcore/plugins.py +79 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/runner.py +32 -1
- evalcore-2.6.0/tests/test_plugins.py +160 -0
- evalcore-2.6.0/tests/test_unit.py +446 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/uv.lock +1 -1
- evalcore-2.4.3/tests/test_unit.py +0 -239
- {evalcore-2.4.3 → evalcore-2.6.0}/.github/workflows/ci.yml +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/.github/workflows/publish.yml +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/.gitignore +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/.pre-commit-config.yaml +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/LICENSE +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/examples/__init__.py +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/examples/quickstart/README.md +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/examples/quickstart/__init__.py +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/examples/quickstart/adapter.py +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/examples/quickstart/datasets/support_reply/v1/cases/angry_complaint.yaml +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/examples/quickstart/datasets/support_reply/v1/cases/billing_question.yaml +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/examples/quickstart/datasets/support_reply/v1/cases/double_charge.yaml +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/examples/quickstart/datasets/support_reply/v1/cases/refund_request.yaml +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/examples/quickstart/fixtures/support_reply_judge.yaml +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/examples/quickstart/fixtures/support_reply_pairwise.yaml +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/examples/quickstart/fixtures/support_reply_replay.yaml +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/examples/quickstart/tests/__init__.py +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/pyrightconfig.json +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/__init__.py +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/adapters/__init__.py +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/adapters/base.py +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/adapters/env.py +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/adapters/http.py +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/adapters/replay.py +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/errors.py +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/graders/__init__.py +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/graders/base.py +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/pairwise.py +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/py.typed +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/rating.py +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/refs.py +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/report.py +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/reporters/__init__.py +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/reporters/base.py +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/reporters/html.py +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/reporters/markdown.py +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/retry.py +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/store.py +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/sweep.py +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/tests/__init__.py +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/tests/test_adapters.py +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/tests/test_cli.py +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/tests/test_edge_cases.py +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/tests/test_judge.py +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/tests/test_judge_extra.py +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/tests/test_live_clients.py +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/tests/test_pairwise_extra.py +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/tests/test_rating.py +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/tests/test_rating_server.py +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/tests/test_reporters.py +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/tests/test_retry.py +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/tests/test_runner.py +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/tests/test_store.py +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/tests/test_sweep_pairwise.py +0 -0
- {evalcore-2.4.3 → evalcore-2.6.0}/uv.toml +0 -0
|
@@ -6,6 +6,97 @@ All notable changes to this project are documented here. The format is based on
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [2.6.0] - 2026-09-12
|
|
10
|
+
|
|
11
|
+
A metric says which way is good and what its values sit on.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- `MetricRange` and a `value_range` field on `Score` and `MetricValue`.
|
|
15
|
+
Nothing about a float says what it is on: `0.86` is 86% if the metric runs
|
|
16
|
+
0..1 and 4.3 out of 5 if it runs 1..5. A consumer holding only the number
|
|
17
|
+
guesses from magnitude, which is how a run whose costs happened to stay
|
|
18
|
+
under a dollar gets rendered as percentages, and how the same metric gets
|
|
19
|
+
classified differently depending on the window you look at.
|
|
20
|
+
- Built-in graders declare their own ranges. Deterministic checks are 0..1;
|
|
21
|
+
`classification` separates its confusion-matrix metrics (0..1) from
|
|
22
|
+
`support_*` and `errors` (0..unbounded); judge metrics are 0..1, being
|
|
23
|
+
points over the scale, except `disagreement`, a spread in raw points,
|
|
24
|
+
which is 0..scale-1.
|
|
25
|
+
- `range:` on a `numeric` field spec, as `{min, max}` or a two-element list.
|
|
26
|
+
That grader surfaces whatever the adapter put in the field, so it is the
|
|
27
|
+
one place the engine cannot know. It is a separate key from the `min`/`max`
|
|
28
|
+
bounds beside it, which are a pass/fail threshold rather than a domain: a
|
|
29
|
+
cost that must stay under a dollar can still cost five.
|
|
30
|
+
- `maximum=None` is a positive statement - unbounded above, so not a
|
|
31
|
+
fraction of anything - and is different from carrying no range at all,
|
|
32
|
+
which says only that nobody declared one. Nothing is inferred from
|
|
33
|
+
observed values.
|
|
34
|
+
- `direction` and `higher_is_better` on `MetricDelta`. A delta's sign and its
|
|
35
|
+
meaning are different questions - `f1` rising is an improvement, and
|
|
36
|
+
`false_negative_rate` rising is a regression - and until now the engine only
|
|
37
|
+
knew the difference for the single win metric, via `win_higher_is_better`.
|
|
38
|
+
Every other metric came out of `compare()` as a bare number, so anything
|
|
39
|
+
downstream that wanted to rank, colour or summarize deltas had to keep its
|
|
40
|
+
own list of which metrics are inverted, or get it wrong.
|
|
41
|
+
- `thresholds.metrics`, an optional map declaring polarity per metric:
|
|
42
|
+
`lower_is_better`, `higher_is_better`, or `neutral` for one that moves
|
|
43
|
+
without either direction being a result.
|
|
44
|
+
- **Polarity is inferred from the guardrails when it is not declared**, which
|
|
45
|
+
is what makes this useful without editing a single existing suite. A `max`
|
|
46
|
+
or `must_not_increase` rule is only ever written about a metric you want
|
|
47
|
+
low, and `min`/`must_not_decrease` about one you want high, so a gated
|
|
48
|
+
metric has already stated its direction. Precedence, strongest first: an
|
|
49
|
+
explicit `metrics:` entry, then `win_higher_is_better` for the win metric,
|
|
50
|
+
then the guardrails. A metric none of them mention defaults to
|
|
51
|
+
higher-is-better; a metric fenced in on both sides by a band resolves to
|
|
52
|
+
`neutral` rather than falling through to that default, since a band says
|
|
53
|
+
neither direction is the good one.
|
|
54
|
+
|
|
55
|
+
### Changed
|
|
56
|
+
- `_evaluate_win` derives the win verdict through the same call that fills in
|
|
57
|
+
each `MetricDelta.direction`, so `Comparison.win` and the win metric's own
|
|
58
|
+
delta row cannot disagree about the same number.
|
|
59
|
+
|
|
60
|
+
## [2.5.0] - 2026-08-11
|
|
61
|
+
|
|
62
|
+
A suite can declare the modules it needs imported.
|
|
63
|
+
|
|
64
|
+
### Added
|
|
65
|
+
- `plugins:` on a suite: a list of module paths the runner imports before it
|
|
66
|
+
looks up any `type`, so a suite that names a custom adapter or grader
|
|
67
|
+
resolves it without a flag at the call site. Registration is an import side
|
|
68
|
+
effect and nothing in the engine imports a consumer's module on its own, so
|
|
69
|
+
until now every entry point had to remember `--plugins my.graders` (CLI) or a
|
|
70
|
+
bare `import my.graders` (Python API) - and the two could disagree. A suite
|
|
71
|
+
is now self-contained: the same file runs from the CLI, from a consumer's own
|
|
72
|
+
`run_eval.py`, and from a test with nothing to remember.
|
|
73
|
+
|
|
74
|
+
`--plugins` is unchanged and still the way to add a module without editing
|
|
75
|
+
the suite, which would change `suite_hash`.
|
|
76
|
+
|
|
77
|
+
The import happens when a **run** starts, never in `load_suite`. Parsing,
|
|
78
|
+
hashing, diffing or reporting on a suite executes no consumer code, so a
|
|
79
|
+
suite you have not decided to run is still only data. `compare` and `report`
|
|
80
|
+
therefore do not import a suite's plugins - they do not need the registries.
|
|
81
|
+
|
|
82
|
+
A module that cannot be imported raises `ConfigError` naming it, rather than
|
|
83
|
+
the unknown-`type` error one lookup later.
|
|
84
|
+
|
|
85
|
+
### Fixed
|
|
86
|
+
- `examples/quickstart/run_eval.py` called `JsonlOutboxExporter.export()`,
|
|
87
|
+
removed when 2.2.0 named the exporter seam, so `just example-api` had been
|
|
88
|
+
failing with `AttributeError` since. It now exports score rows only, which is
|
|
89
|
+
the one grain the store has: a scorecard is a read-time aggregation over
|
|
90
|
+
those rows, so exporting it too would persist something derived that could
|
|
91
|
+
disagree with them.
|
|
92
|
+
|
|
93
|
+
### Changed
|
|
94
|
+
- `examples/quickstart` declares its own `plugins:` and no longer needs
|
|
95
|
+
`--plugins` on the command line, nor the `import ... # noqa: F401` that three
|
|
96
|
+
of its entry points carried to force registration. `graders.py` no longer
|
|
97
|
+
imports `adapter.py` for the side effect either. The example is the same
|
|
98
|
+
eval; it just stops demonstrating the workaround.
|
|
99
|
+
|
|
9
100
|
## [2.4.3] - 2026-08-11
|
|
10
101
|
|
|
11
102
|
Live Anthropic judges work on current Claude models again.
|
|
@@ -340,7 +431,9 @@ by semantic versioning: a breaking change to either means a 2.0.
|
|
|
340
431
|
rating + ranking with judge agreement, Markdown/HTML reporters, JSON +
|
|
341
432
|
column-store outbox, and content-hash provenance.
|
|
342
433
|
|
|
343
|
-
[Unreleased]: https://github.com/scottpmiller/evalcore/compare/2.
|
|
434
|
+
[Unreleased]: https://github.com/scottpmiller/evalcore/compare/2.6.0...HEAD
|
|
435
|
+
[2.6.0]: https://github.com/scottpmiller/evalcore/compare/2.5.0...2.6.0
|
|
436
|
+
[2.5.0]: https://github.com/scottpmiller/evalcore/compare/2.4.3...2.5.0
|
|
344
437
|
[2.4.3]: https://github.com/scottpmiller/evalcore/compare/2.4.2...2.4.3
|
|
345
438
|
[2.4.2]: https://github.com/scottpmiller/evalcore/compare/2.4.1...2.4.2
|
|
346
439
|
[2.4.1]: https://github.com/scottpmiller/evalcore/compare/2.4.0...2.4.1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: evalcore
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.6.0
|
|
4
4
|
Summary: A generic, consumer-agnostic evaluation engine for prompt, model, and API outputs.
|
|
5
5
|
Project-URL: Homepage, https://github.com/scottpmiller/evalcore
|
|
6
6
|
Project-URL: Repository, https://github.com/scottpmiller/evalcore
|
|
@@ -214,8 +214,15 @@ class MyAdapter(http.HTTPAdapter):
|
|
|
214
214
|
```
|
|
215
215
|
|
|
216
216
|
Constructor kwargs come from the suite's `adapter:` mapping (everything
|
|
217
|
-
except `type
|
|
218
|
-
|
|
217
|
+
except `type`, which is popped to select the class).
|
|
218
|
+
|
|
219
|
+
Registration happens on import, so something has to import the module. List
|
|
220
|
+
it in the suite's `plugins:` and the runner does it for you, which keeps the
|
|
221
|
+
suite self-contained - the same file then works from the CLI, the Python API,
|
|
222
|
+
and another harness with nothing to remember at the call site. `--plugins
|
|
223
|
+
my_service.graders` (CLI) and a plain `import` (Python API) both still work,
|
|
224
|
+
and are the way to add a module without editing the suite (which would change
|
|
225
|
+
`suite_hash`). Suites are imported when a run starts, never by `load_suite`.
|
|
219
226
|
|
|
220
227
|
An adapter need not be HTTP-backed: it can grade *what a deployed system
|
|
221
228
|
already did* by reading from an observability store - turning an aggregated
|
|
@@ -403,6 +410,8 @@ dataset: datasets/support_reply/v1
|
|
|
403
410
|
dataset_version: v1
|
|
404
411
|
mode_default: http # 'replay' to default offline
|
|
405
412
|
replay_fixtures: fixtures/replay.yaml
|
|
413
|
+
plugins: # modules to import so custom `type`s resolve
|
|
414
|
+
- my_service.graders # (see step 2/4); omit if you use only built-ins
|
|
406
415
|
adapter: {...} # step 2
|
|
407
416
|
graders: [...] # step 4
|
|
408
417
|
variants: {...} # step 3
|
|
@@ -420,6 +429,9 @@ thresholds:
|
|
|
420
429
|
win_min_delta: 0.02 # dead band: |delta| <= this -> neutral
|
|
421
430
|
on_regression: warn # or 'fail' to hard-gate the win metric
|
|
422
431
|
variants: {baseline: baseline, candidate: candidate} # gate defaults
|
|
432
|
+
metrics: # optional: which way is good, per metric
|
|
433
|
+
generation_cost: lower_is_better
|
|
434
|
+
total_tool_calls: neutral # moves, but neither way is a result
|
|
423
435
|
guardrails: # hard constraints on the CANDIDATE
|
|
424
436
|
- metric: false_negative_rate
|
|
425
437
|
max: 0.10 # absolute ceiling
|
|
@@ -432,22 +444,67 @@ thresholds:
|
|
|
432
444
|
|
|
433
445
|
Guardrail rules compose: `max`, `min`, `must_not_increase`,
|
|
434
446
|
`must_not_decrease`. A guardrail whose metric is missing on the candidate
|
|
435
|
-
fails closed.
|
|
447
|
+
fails closed.
|
|
448
|
+
|
|
449
|
+
Every metric also carries a `value_range`, because nothing about a float says
|
|
450
|
+
what it is on: `0.86` is 86% if the metric runs 0..1 and 4.3 out of 5 if it
|
|
451
|
+
runs 1..5. The grader declares it - deterministic checks are 0..1,
|
|
452
|
+
`classification` puts `support_*` and `errors` on 0..unbounded while its
|
|
453
|
+
confusion-matrix metrics are 0..1, and judge metrics are 0..1 except
|
|
454
|
+
`disagreement`, a spread in raw points, which is 0..scale-1. A custom grader
|
|
455
|
+
passes `value_range=` on the `Score`s it returns.
|
|
456
|
+
|
|
457
|
+
`numeric` is the exception, since it surfaces whatever the adapter put in the
|
|
458
|
+
field, so declare it there:
|
|
459
|
+
|
|
460
|
+
```yaml
|
|
461
|
+
graders:
|
|
462
|
+
- type: numeric
|
|
463
|
+
fields:
|
|
464
|
+
- {ref: output.cost, range: {min: 0, max: null}}
|
|
465
|
+
- {ref: output.quality, range: [1, 5]}
|
|
466
|
+
# `range` and `max` are different things - see below
|
|
467
|
+
- {ref: output.latency, range: {min: 0}, max: 2.5}
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
That last field is the distinction worth knowing. `min`/`max` on a numeric
|
|
471
|
+
field are a **pass/fail threshold** - "fail this case over 2.5 seconds" - and
|
|
472
|
+
`range` is what the number could be at all. A cost that must stay under a
|
|
473
|
+
dollar can still cost five, so the two keys stay separate.
|
|
474
|
+
|
|
475
|
+
`maximum: null` is a statement, not a gap - unbounded above, so not a fraction
|
|
476
|
+
of anything, render it as it is. A metric with no range at all is different
|
|
477
|
+
again: nobody declared one. The engine never infers a range from the values it
|
|
478
|
+
sees, because "everything stayed under 1, so it must be a 0..1 metric" is what
|
|
479
|
+
turns a run whose costs happened to stay cheap into percentages.
|
|
480
|
+
|
|
481
|
+
Every `MetricDelta` carries `direction` (`improved`/`regressed`/`neutral`)
|
|
482
|
+
alongside `delta`, because the sign of a number does not say what it means: a
|
|
483
|
+
rise in `f1` is an improvement and a rise in `false_negative_rate` is a
|
|
484
|
+
regression. Direction comes from the first of these that speaks to the metric:
|
|
485
|
+
an explicit `metrics:` entry, `win_higher_is_better` for the win metric, or the
|
|
486
|
+
guardrails themselves - a `max` or `must_not_increase` rule means you want the
|
|
487
|
+
metric low, a `min` or `must_not_decrease` rule means high. So a suite that
|
|
488
|
+
gates a metric already declares its direction and needs no `metrics:` block at
|
|
489
|
+
all. A metric nothing speaks to is assumed higher-is-better; one fenced in on
|
|
490
|
+
both sides by a band, or declared `neutral`, reports `direction: neutral` and
|
|
491
|
+
`higher_is_better: null` rather than guessing. Pick guardrails for the failures that must never ship, and
|
|
436
492
|
one win metric for the improvement you're hunting; everything else is
|
|
437
493
|
reported informationally.
|
|
438
494
|
|
|
439
495
|
## 7. Running it
|
|
440
496
|
|
|
441
|
-
**CLI** (
|
|
497
|
+
**CLI** (a suite's `plugins:` registers its custom types; add `--plugins
|
|
498
|
+
mod1,mod2` before the subcommand for anything the suite does not declare):
|
|
442
499
|
|
|
443
500
|
```bash
|
|
444
501
|
# one variant -> scorecard (optionally saved)
|
|
445
|
-
evalcore
|
|
502
|
+
evalcore run \
|
|
446
503
|
--suite my_service/suite.yaml --variant candidate --mode replay \
|
|
447
504
|
--out candidate.scorecard.json --revision "$GIT_SHA"
|
|
448
505
|
|
|
449
506
|
# the CI workhorse: run baseline+candidate, compare, exit 1 on 'fail'
|
|
450
|
-
evalcore
|
|
507
|
+
evalcore gate \
|
|
451
508
|
--suite my_service/suite.yaml --mode replay \
|
|
452
509
|
--export outbox.jsonl --revision "$GIT_SHA"
|
|
453
510
|
|
|
@@ -182,8 +182,15 @@ class MyAdapter(http.HTTPAdapter):
|
|
|
182
182
|
```
|
|
183
183
|
|
|
184
184
|
Constructor kwargs come from the suite's `adapter:` mapping (everything
|
|
185
|
-
except `type
|
|
186
|
-
|
|
185
|
+
except `type`, which is popped to select the class).
|
|
186
|
+
|
|
187
|
+
Registration happens on import, so something has to import the module. List
|
|
188
|
+
it in the suite's `plugins:` and the runner does it for you, which keeps the
|
|
189
|
+
suite self-contained - the same file then works from the CLI, the Python API,
|
|
190
|
+
and another harness with nothing to remember at the call site. `--plugins
|
|
191
|
+
my_service.graders` (CLI) and a plain `import` (Python API) both still work,
|
|
192
|
+
and are the way to add a module without editing the suite (which would change
|
|
193
|
+
`suite_hash`). Suites are imported when a run starts, never by `load_suite`.
|
|
187
194
|
|
|
188
195
|
An adapter need not be HTTP-backed: it can grade *what a deployed system
|
|
189
196
|
already did* by reading from an observability store - turning an aggregated
|
|
@@ -371,6 +378,8 @@ dataset: datasets/support_reply/v1
|
|
|
371
378
|
dataset_version: v1
|
|
372
379
|
mode_default: http # 'replay' to default offline
|
|
373
380
|
replay_fixtures: fixtures/replay.yaml
|
|
381
|
+
plugins: # modules to import so custom `type`s resolve
|
|
382
|
+
- my_service.graders # (see step 2/4); omit if you use only built-ins
|
|
374
383
|
adapter: {...} # step 2
|
|
375
384
|
graders: [...] # step 4
|
|
376
385
|
variants: {...} # step 3
|
|
@@ -388,6 +397,9 @@ thresholds:
|
|
|
388
397
|
win_min_delta: 0.02 # dead band: |delta| <= this -> neutral
|
|
389
398
|
on_regression: warn # or 'fail' to hard-gate the win metric
|
|
390
399
|
variants: {baseline: baseline, candidate: candidate} # gate defaults
|
|
400
|
+
metrics: # optional: which way is good, per metric
|
|
401
|
+
generation_cost: lower_is_better
|
|
402
|
+
total_tool_calls: neutral # moves, but neither way is a result
|
|
391
403
|
guardrails: # hard constraints on the CANDIDATE
|
|
392
404
|
- metric: false_negative_rate
|
|
393
405
|
max: 0.10 # absolute ceiling
|
|
@@ -400,22 +412,67 @@ thresholds:
|
|
|
400
412
|
|
|
401
413
|
Guardrail rules compose: `max`, `min`, `must_not_increase`,
|
|
402
414
|
`must_not_decrease`. A guardrail whose metric is missing on the candidate
|
|
403
|
-
fails closed.
|
|
415
|
+
fails closed.
|
|
416
|
+
|
|
417
|
+
Every metric also carries a `value_range`, because nothing about a float says
|
|
418
|
+
what it is on: `0.86` is 86% if the metric runs 0..1 and 4.3 out of 5 if it
|
|
419
|
+
runs 1..5. The grader declares it - deterministic checks are 0..1,
|
|
420
|
+
`classification` puts `support_*` and `errors` on 0..unbounded while its
|
|
421
|
+
confusion-matrix metrics are 0..1, and judge metrics are 0..1 except
|
|
422
|
+
`disagreement`, a spread in raw points, which is 0..scale-1. A custom grader
|
|
423
|
+
passes `value_range=` on the `Score`s it returns.
|
|
424
|
+
|
|
425
|
+
`numeric` is the exception, since it surfaces whatever the adapter put in the
|
|
426
|
+
field, so declare it there:
|
|
427
|
+
|
|
428
|
+
```yaml
|
|
429
|
+
graders:
|
|
430
|
+
- type: numeric
|
|
431
|
+
fields:
|
|
432
|
+
- {ref: output.cost, range: {min: 0, max: null}}
|
|
433
|
+
- {ref: output.quality, range: [1, 5]}
|
|
434
|
+
# `range` and `max` are different things - see below
|
|
435
|
+
- {ref: output.latency, range: {min: 0}, max: 2.5}
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
That last field is the distinction worth knowing. `min`/`max` on a numeric
|
|
439
|
+
field are a **pass/fail threshold** - "fail this case over 2.5 seconds" - and
|
|
440
|
+
`range` is what the number could be at all. A cost that must stay under a
|
|
441
|
+
dollar can still cost five, so the two keys stay separate.
|
|
442
|
+
|
|
443
|
+
`maximum: null` is a statement, not a gap - unbounded above, so not a fraction
|
|
444
|
+
of anything, render it as it is. A metric with no range at all is different
|
|
445
|
+
again: nobody declared one. The engine never infers a range from the values it
|
|
446
|
+
sees, because "everything stayed under 1, so it must be a 0..1 metric" is what
|
|
447
|
+
turns a run whose costs happened to stay cheap into percentages.
|
|
448
|
+
|
|
449
|
+
Every `MetricDelta` carries `direction` (`improved`/`regressed`/`neutral`)
|
|
450
|
+
alongside `delta`, because the sign of a number does not say what it means: a
|
|
451
|
+
rise in `f1` is an improvement and a rise in `false_negative_rate` is a
|
|
452
|
+
regression. Direction comes from the first of these that speaks to the metric:
|
|
453
|
+
an explicit `metrics:` entry, `win_higher_is_better` for the win metric, or the
|
|
454
|
+
guardrails themselves - a `max` or `must_not_increase` rule means you want the
|
|
455
|
+
metric low, a `min` or `must_not_decrease` rule means high. So a suite that
|
|
456
|
+
gates a metric already declares its direction and needs no `metrics:` block at
|
|
457
|
+
all. A metric nothing speaks to is assumed higher-is-better; one fenced in on
|
|
458
|
+
both sides by a band, or declared `neutral`, reports `direction: neutral` and
|
|
459
|
+
`higher_is_better: null` rather than guessing. Pick guardrails for the failures that must never ship, and
|
|
404
460
|
one win metric for the improvement you're hunting; everything else is
|
|
405
461
|
reported informationally.
|
|
406
462
|
|
|
407
463
|
## 7. Running it
|
|
408
464
|
|
|
409
|
-
**CLI** (
|
|
465
|
+
**CLI** (a suite's `plugins:` registers its custom types; add `--plugins
|
|
466
|
+
mod1,mod2` before the subcommand for anything the suite does not declare):
|
|
410
467
|
|
|
411
468
|
```bash
|
|
412
469
|
# one variant -> scorecard (optionally saved)
|
|
413
|
-
evalcore
|
|
470
|
+
evalcore run \
|
|
414
471
|
--suite my_service/suite.yaml --variant candidate --mode replay \
|
|
415
472
|
--out candidate.scorecard.json --revision "$GIT_SHA"
|
|
416
473
|
|
|
417
474
|
# the CI workhorse: run baseline+candidate, compare, exit 1 on 'fail'
|
|
418
|
-
evalcore
|
|
475
|
+
evalcore gate \
|
|
419
476
|
--suite my_service/suite.yaml --mode replay \
|
|
420
477
|
--export outbox.jsonl --revision "$GIT_SHA"
|
|
421
478
|
|
|
@@ -116,6 +116,15 @@ A consumer adds an eval tree (its own repo, or a directory like
|
|
|
116
116
|
4. **Suite + threshold config** - guardrail metrics, win metric + dead band,
|
|
117
117
|
N samples, which triggers run which suite.
|
|
118
118
|
|
|
119
|
+
A consumer that writes custom graders or adapters adds one more line: a
|
|
120
|
+
`plugins:` list in the suite naming the modules to import, so the registry
|
|
121
|
+
holds its `type`s before they are looked up. Registration is an import side
|
|
122
|
+
effect, and nothing in the engine imports a consumer's module on its own. The
|
|
123
|
+
import happens when a **run** starts, not when a suite is loaded - `load_suite`
|
|
124
|
+
parses and hashes YAML and executes nothing, so reading, hashing, or reporting
|
|
125
|
+
on a suite is free of side effects, while starting a run is already the point
|
|
126
|
+
where the suite's adapter gets to make network calls.
|
|
127
|
+
|
|
119
128
|
The engine supplies runner, comparison, gate, store, reporters, and the CLI.
|
|
120
129
|
That ratio - four data files vs. a whole engine - is the genericity test: if
|
|
121
130
|
onboarding a consumer ever requires editing `src/evalcore/`, that's an
|
|
@@ -17,10 +17,6 @@ import re
|
|
|
17
17
|
from evalcore import models
|
|
18
18
|
from evalcore.graders import base
|
|
19
19
|
|
|
20
|
-
# Registering the adapter alongside the graders means one plug-in module wires
|
|
21
|
-
# up the whole consumer (adapter + custom graders).
|
|
22
|
-
from examples.quickstart import adapter # noqa: F401
|
|
23
|
-
|
|
24
20
|
_ACK = re.compile(
|
|
25
21
|
r"\b(sorry|understand|happy to help|you're right|apolog)", re.IGNORECASE
|
|
26
22
|
)
|
|
@@ -51,6 +47,10 @@ class AcknowledgesCustomer:
|
|
|
51
47
|
grader=self.name,
|
|
52
48
|
metric=self.name,
|
|
53
49
|
value=1.0 if ok else 0.0,
|
|
50
|
+
# 1/0 per case, so the run mean is a share of cases. A
|
|
51
|
+
# grader knows this about its own output; nothing
|
|
52
|
+
# downstream can work it out from the numbers alone.
|
|
53
|
+
value_range=models.MetricRange(minimum=0.0, maximum=1.0),
|
|
54
54
|
passed=ok,
|
|
55
55
|
detail=detail,
|
|
56
56
|
case_id=case.id,
|
|
@@ -87,6 +87,7 @@ class DistinctReplyRate:
|
|
|
87
87
|
grader=self.name,
|
|
88
88
|
metric=self.name,
|
|
89
89
|
value=rate,
|
|
90
|
+
value_range=models.MetricRange(minimum=0.0, maximum=1.0),
|
|
90
91
|
detail=f'{distinct} distinct across {len(by_case)} cases',
|
|
91
92
|
kind='aggregate',
|
|
92
93
|
)
|
|
@@ -23,7 +23,6 @@ import os
|
|
|
23
23
|
import pathlib
|
|
24
24
|
import sys
|
|
25
25
|
|
|
26
|
-
import examples.quickstart.graders # noqa: F401 - registers plug-ins
|
|
27
26
|
from evalcore import compare, loader, report, runner, store
|
|
28
27
|
|
|
29
28
|
HERE = pathlib.Path(__file__).resolve().parent
|
|
@@ -91,17 +90,14 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
91
90
|
raise RuntimeError('run JSON round-trip mismatch')
|
|
92
91
|
|
|
93
92
|
# 6. Flatten to self-describing rows and append the outbox a column-store
|
|
94
|
-
# shipper would drain
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
n_scores =
|
|
99
|
-
|
|
100
|
-
) + score_exporter.export_scores(candidate_run)
|
|
101
|
-
print(f'\noutbox: {metric_rows} metric rows -> {exporter.outbox_path}')
|
|
102
|
-
print(
|
|
103
|
-
f'scores: {n_scores} per-sample rows -> {score_exporter.outbox_path}'
|
|
93
|
+
# shipper would drain. One grain only - per-sample score rows. A run's
|
|
94
|
+
# scorecard is a read-time aggregation over these, so exporting it too
|
|
95
|
+
# would store something derived that could disagree with them.
|
|
96
|
+
exporter = store.JsonlOutboxExporter(out / 'scores.jsonl')
|
|
97
|
+
n_scores = exporter.export_scores(baseline_run) + exporter.export_scores(
|
|
98
|
+
candidate_run
|
|
104
99
|
)
|
|
100
|
+
print(f'\nscores: {n_scores} per-sample rows -> {exporter.outbox_path}')
|
|
105
101
|
|
|
106
102
|
# 7. Gate semantics: non-zero exit on a failing verdict.
|
|
107
103
|
print(f'\ngate verdict: {result.verdict} ({result.summary})')
|
|
@@ -10,8 +10,7 @@
|
|
|
10
10
|
# - a head-to-head pairwise config
|
|
11
11
|
#
|
|
12
12
|
# Run it:
|
|
13
|
-
# evalcore --
|
|
14
|
-
# --suite examples/quickstart/suite.yaml --mode replay
|
|
13
|
+
# evalcore gate --suite examples/quickstart/suite.yaml --mode replay
|
|
15
14
|
#
|
|
16
15
|
# Paths are relative to this file.
|
|
17
16
|
|
|
@@ -22,6 +21,14 @@ dataset_version: v1
|
|
|
22
21
|
mode_default: replay
|
|
23
22
|
replay_fixtures: fixtures/support_reply_replay.yaml
|
|
24
23
|
|
|
24
|
+
# This consumer's own modules. The runner imports them before it looks up any
|
|
25
|
+
# `type` below, which is what makes `canned_support` and the two custom graders
|
|
26
|
+
# resolvable - so this suite runs from the CLI, from run_eval.py, or from a
|
|
27
|
+
# test with nothing to remember at the call site.
|
|
28
|
+
plugins:
|
|
29
|
+
- examples.quickstart.adapter
|
|
30
|
+
- examples.quickstart.graders
|
|
31
|
+
|
|
25
32
|
# The custom offline stub adapter (examples/quickstart/adapter.py). In replay
|
|
26
33
|
# mode the engine ignores this and reads replay_fixtures instead; in any other
|
|
27
34
|
# mode it builds this adapter (which needs no network).
|
|
@@ -9,7 +9,6 @@ the custom adapter and the custom graders.
|
|
|
9
9
|
import pathlib
|
|
10
10
|
import unittest
|
|
11
11
|
|
|
12
|
-
import examples.quickstart.graders # noqa: F401 - registers adapter + graders
|
|
13
12
|
from evalcore import compare, loader, runner
|
|
14
13
|
|
|
15
14
|
SUITE_PATH = pathlib.Path(__file__).resolve().parents[1] / 'suite.yaml'
|
|
@@ -23,7 +23,7 @@ lint:
|
|
|
23
23
|
|
|
24
24
|
# Run the quickstart suite offline against recorded fixtures.
|
|
25
25
|
example:
|
|
26
|
-
uv run evalcore
|
|
26
|
+
uv run evalcore gate --suite examples/quickstart/suite.yaml --mode replay
|
|
27
27
|
|
|
28
28
|
# Run the quickstart suite through the Python API (no CLI), offline.
|
|
29
29
|
example-api:
|
|
@@ -31,4 +31,4 @@ example-api:
|
|
|
31
31
|
|
|
32
32
|
# Head-to-head A-vs-B win-rate over the quickstart suite (offline).
|
|
33
33
|
example-pairwise:
|
|
34
|
-
uv run evalcore
|
|
34
|
+
uv run evalcore pairwise --suite examples/quickstart/suite.yaml --a baseline --b candidate --mode replay
|
|
@@ -26,28 +26,25 @@ adapters register before the run.
|
|
|
26
26
|
import argparse
|
|
27
27
|
import asyncio
|
|
28
28
|
import datetime
|
|
29
|
-
import importlib
|
|
30
|
-
import os
|
|
31
29
|
import pathlib
|
|
32
30
|
import sys
|
|
33
31
|
|
|
34
32
|
from evalcore import compare as compare_mod
|
|
35
|
-
from evalcore import loader, rating, report, reporters, runner, store
|
|
33
|
+
from evalcore import loader, plugins, rating, report, reporters, runner, store
|
|
36
34
|
from evalcore import pairwise as pairwise_mod
|
|
37
35
|
from evalcore import sweep as sweep_mod
|
|
38
36
|
|
|
39
37
|
|
|
40
38
|
def _load_plugins(spec: str | None) -> None:
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
importlib.import_module(name.strip())
|
|
39
|
+
"""Import the `--plugins` modules, and make cwd importable for both.
|
|
40
|
+
|
|
41
|
+
A suite's own `plugins:` list is imported by the runner, not here - but it
|
|
42
|
+
needs the same working-directory path fix, and this runs first on every
|
|
43
|
+
executing command, so it is done unconditionally rather than only when the
|
|
44
|
+
flag is passed.
|
|
45
|
+
"""
|
|
46
|
+
plugins.allow_cwd_imports()
|
|
47
|
+
plugins.load((spec or '').split(','))
|
|
51
48
|
|
|
52
49
|
|
|
53
50
|
def _now() -> str:
|