evalcore 2.4.3__tar.gz → 2.5.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.5.0}/CHANGELOG.md +42 -1
- {evalcore-2.4.3 → evalcore-2.5.0}/PKG-INFO +16 -6
- {evalcore-2.4.3 → evalcore-2.5.0}/README.md +15 -5
- {evalcore-2.4.3 → evalcore-2.5.0}/docs/design.md +9 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/examples/quickstart/graders.py +0 -4
- {evalcore-2.4.3 → evalcore-2.5.0}/examples/quickstart/run_eval.py +7 -11
- {evalcore-2.4.3 → evalcore-2.5.0}/examples/quickstart/suite.yaml +9 -2
- {evalcore-2.4.3 → evalcore-2.5.0}/examples/quickstart/tests/test_quickstart.py +0 -1
- {evalcore-2.4.3 → evalcore-2.5.0}/justfile +2 -2
- {evalcore-2.4.3 → evalcore-2.5.0}/pyproject.toml +1 -1
- {evalcore-2.4.3 → evalcore-2.5.0}/src/evalcore/cli.py +10 -13
- {evalcore-2.4.3 → evalcore-2.5.0}/src/evalcore/loader.py +5 -0
- evalcore-2.5.0/src/evalcore/plugins.py +79 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/src/evalcore/runner.py +6 -1
- evalcore-2.5.0/tests/test_plugins.py +160 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/uv.lock +1 -1
- {evalcore-2.4.3 → evalcore-2.5.0}/.github/workflows/ci.yml +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/.github/workflows/publish.yml +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/.gitignore +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/.pre-commit-config.yaml +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/LICENSE +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/examples/__init__.py +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/examples/quickstart/README.md +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/examples/quickstart/__init__.py +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/examples/quickstart/adapter.py +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/examples/quickstart/datasets/support_reply/v1/cases/angry_complaint.yaml +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/examples/quickstart/datasets/support_reply/v1/cases/billing_question.yaml +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/examples/quickstart/datasets/support_reply/v1/cases/double_charge.yaml +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/examples/quickstart/datasets/support_reply/v1/cases/refund_request.yaml +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/examples/quickstart/fixtures/support_reply_judge.yaml +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/examples/quickstart/fixtures/support_reply_pairwise.yaml +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/examples/quickstart/fixtures/support_reply_replay.yaml +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/examples/quickstart/tests/__init__.py +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/pyrightconfig.json +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/src/evalcore/__init__.py +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/src/evalcore/adapters/__init__.py +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/src/evalcore/adapters/base.py +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/src/evalcore/adapters/env.py +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/src/evalcore/adapters/http.py +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/src/evalcore/adapters/replay.py +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/src/evalcore/compare.py +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/src/evalcore/errors.py +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/src/evalcore/graders/__init__.py +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/src/evalcore/graders/base.py +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/src/evalcore/graders/classification.py +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/src/evalcore/graders/deterministic.py +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/src/evalcore/graders/judge.py +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/src/evalcore/graders/numeric.py +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/src/evalcore/models.py +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/src/evalcore/pairwise.py +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/src/evalcore/py.typed +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/src/evalcore/rating.py +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/src/evalcore/refs.py +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/src/evalcore/report.py +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/src/evalcore/reporters/__init__.py +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/src/evalcore/reporters/base.py +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/src/evalcore/reporters/html.py +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/src/evalcore/reporters/markdown.py +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/src/evalcore/retry.py +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/src/evalcore/store.py +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/src/evalcore/sweep.py +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/tests/__init__.py +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/tests/test_adapters.py +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/tests/test_cli.py +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/tests/test_edge_cases.py +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/tests/test_judge.py +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/tests/test_judge_extra.py +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/tests/test_live_clients.py +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/tests/test_pairwise_extra.py +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/tests/test_rating.py +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/tests/test_rating_server.py +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/tests/test_reporters.py +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/tests/test_retry.py +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/tests/test_runner.py +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/tests/test_store.py +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/tests/test_sweep_pairwise.py +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/tests/test_unit.py +0 -0
- {evalcore-2.4.3 → evalcore-2.5.0}/uv.toml +0 -0
|
@@ -6,6 +6,46 @@ All notable changes to this project are documented here. The format is based on
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [2.5.0] - 2026-08-11
|
|
10
|
+
|
|
11
|
+
A suite can declare the modules it needs imported.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- `plugins:` on a suite: a list of module paths the runner imports before it
|
|
15
|
+
looks up any `type`, so a suite that names a custom adapter or grader
|
|
16
|
+
resolves it without a flag at the call site. Registration is an import side
|
|
17
|
+
effect and nothing in the engine imports a consumer's module on its own, so
|
|
18
|
+
until now every entry point had to remember `--plugins my.graders` (CLI) or a
|
|
19
|
+
bare `import my.graders` (Python API) - and the two could disagree. A suite
|
|
20
|
+
is now self-contained: the same file runs from the CLI, from a consumer's own
|
|
21
|
+
`run_eval.py`, and from a test with nothing to remember.
|
|
22
|
+
|
|
23
|
+
`--plugins` is unchanged and still the way to add a module without editing
|
|
24
|
+
the suite, which would change `suite_hash`.
|
|
25
|
+
|
|
26
|
+
The import happens when a **run** starts, never in `load_suite`. Parsing,
|
|
27
|
+
hashing, diffing or reporting on a suite executes no consumer code, so a
|
|
28
|
+
suite you have not decided to run is still only data. `compare` and `report`
|
|
29
|
+
therefore do not import a suite's plugins - they do not need the registries.
|
|
30
|
+
|
|
31
|
+
A module that cannot be imported raises `ConfigError` naming it, rather than
|
|
32
|
+
the unknown-`type` error one lookup later.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
- `examples/quickstart/run_eval.py` called `JsonlOutboxExporter.export()`,
|
|
36
|
+
removed when 2.2.0 named the exporter seam, so `just example-api` had been
|
|
37
|
+
failing with `AttributeError` since. It now exports score rows only, which is
|
|
38
|
+
the one grain the store has: a scorecard is a read-time aggregation over
|
|
39
|
+
those rows, so exporting it too would persist something derived that could
|
|
40
|
+
disagree with them.
|
|
41
|
+
|
|
42
|
+
### Changed
|
|
43
|
+
- `examples/quickstart` declares its own `plugins:` and no longer needs
|
|
44
|
+
`--plugins` on the command line, nor the `import ... # noqa: F401` that three
|
|
45
|
+
of its entry points carried to force registration. `graders.py` no longer
|
|
46
|
+
imports `adapter.py` for the side effect either. The example is the same
|
|
47
|
+
eval; it just stops demonstrating the workaround.
|
|
48
|
+
|
|
9
49
|
## [2.4.3] - 2026-08-11
|
|
10
50
|
|
|
11
51
|
Live Anthropic judges work on current Claude models again.
|
|
@@ -340,7 +380,8 @@ by semantic versioning: a breaking change to either means a 2.0.
|
|
|
340
380
|
rating + ranking with judge agreement, Markdown/HTML reporters, JSON +
|
|
341
381
|
column-store outbox, and content-hash provenance.
|
|
342
382
|
|
|
343
|
-
[Unreleased]: https://github.com/scottpmiller/evalcore/compare/2.
|
|
383
|
+
[Unreleased]: https://github.com/scottpmiller/evalcore/compare/2.5.0...HEAD
|
|
384
|
+
[2.5.0]: https://github.com/scottpmiller/evalcore/compare/2.4.3...2.5.0
|
|
344
385
|
[2.4.3]: https://github.com/scottpmiller/evalcore/compare/2.4.2...2.4.3
|
|
345
386
|
[2.4.2]: https://github.com/scottpmiller/evalcore/compare/2.4.1...2.4.2
|
|
346
387
|
[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.5.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
|
|
@@ -438,16 +447,17 @@ reported informationally.
|
|
|
438
447
|
|
|
439
448
|
## 7. Running it
|
|
440
449
|
|
|
441
|
-
**CLI** (
|
|
450
|
+
**CLI** (a suite's `plugins:` registers its custom types; add `--plugins
|
|
451
|
+
mod1,mod2` before the subcommand for anything the suite does not declare):
|
|
442
452
|
|
|
443
453
|
```bash
|
|
444
454
|
# one variant -> scorecard (optionally saved)
|
|
445
|
-
evalcore
|
|
455
|
+
evalcore run \
|
|
446
456
|
--suite my_service/suite.yaml --variant candidate --mode replay \
|
|
447
457
|
--out candidate.scorecard.json --revision "$GIT_SHA"
|
|
448
458
|
|
|
449
459
|
# the CI workhorse: run baseline+candidate, compare, exit 1 on 'fail'
|
|
450
|
-
evalcore
|
|
460
|
+
evalcore gate \
|
|
451
461
|
--suite my_service/suite.yaml --mode replay \
|
|
452
462
|
--export outbox.jsonl --revision "$GIT_SHA"
|
|
453
463
|
|
|
@@ -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
|
|
@@ -406,16 +415,17 @@ reported informationally.
|
|
|
406
415
|
|
|
407
416
|
## 7. Running it
|
|
408
417
|
|
|
409
|
-
**CLI** (
|
|
418
|
+
**CLI** (a suite's `plugins:` registers its custom types; add `--plugins
|
|
419
|
+
mod1,mod2` before the subcommand for anything the suite does not declare):
|
|
410
420
|
|
|
411
421
|
```bash
|
|
412
422
|
# one variant -> scorecard (optionally saved)
|
|
413
|
-
evalcore
|
|
423
|
+
evalcore run \
|
|
414
424
|
--suite my_service/suite.yaml --variant candidate --mode replay \
|
|
415
425
|
--out candidate.scorecard.json --revision "$GIT_SHA"
|
|
416
426
|
|
|
417
427
|
# the CI workhorse: run baseline+candidate, compare, exit 1 on 'fail'
|
|
418
|
-
evalcore
|
|
428
|
+
evalcore gate \
|
|
419
429
|
--suite my_service/suite.yaml --mode replay \
|
|
420
430
|
--export outbox.jsonl --revision "$GIT_SHA"
|
|
421
431
|
|
|
@@ -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
|
)
|
|
@@ -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:
|
|
@@ -56,6 +56,11 @@ class SuiteConfig(pydantic.BaseModel):
|
|
|
56
56
|
replay_fixtures: str | None = None
|
|
57
57
|
adapter: dict
|
|
58
58
|
graders: list[dict] = pydantic.Field(default_factory=list)
|
|
59
|
+
# Consumer modules to import before a run, so their registered custom
|
|
60
|
+
# adapters/graders exist by the time a `type` here is looked up. Imported
|
|
61
|
+
# by the runner, NOT by load_suite - loading a suite stays side-effect
|
|
62
|
+
# free. See evalcore.plugins.
|
|
63
|
+
plugins: list[str] = pydantic.Field(default_factory=list)
|
|
59
64
|
variants: dict[str, dict] = pydantic.Field(default_factory=dict)
|
|
60
65
|
n_samples: int = 1
|
|
61
66
|
# Max concurrent (case, sample) invocations. Above 1, the adapter and
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Import consumer modules so their registrations take effect.
|
|
2
|
+
|
|
3
|
+
A custom adapter or grader reaches the engine through a registry keyed by the
|
|
4
|
+
``type`` string a suite names, and the registry entry is written by the
|
|
5
|
+
``@register`` decorator *at import time*. Nothing in evalcore imports a
|
|
6
|
+
consumer's module, so a suite naming ``type: my_grader`` fails as an unknown
|
|
7
|
+
type until something has imported the module that defines it.
|
|
8
|
+
|
|
9
|
+
Two things do that importing: a suite's own ``plugins:`` list, and the CLI's
|
|
10
|
+
``--plugins`` flag. Prefer the suite key. It keeps a suite self-contained -
|
|
11
|
+
the same file then works from the CLI, from the Python API, and from another
|
|
12
|
+
consumer's harness, with nothing to remember at the call site. The flag stays
|
|
13
|
+
for a suite that does not declare its own, and for adding a module to a run
|
|
14
|
+
without editing the suite (which would change ``suite_hash``).
|
|
15
|
+
|
|
16
|
+
Importing is deliberately **not** part of loading a suite.
|
|
17
|
+
:func:`~evalcore.loader.load_suite` parses and hashes YAML and executes
|
|
18
|
+
nothing, so reading a suite, rendering a report, or diffing two of them stays
|
|
19
|
+
free of side effects. The import happens when a run starts - which is already
|
|
20
|
+
the point where the suite's adapter gets to make network calls and spend
|
|
21
|
+
money, so it is the honest place to also let it run code.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import importlib
|
|
25
|
+
import os
|
|
26
|
+
import sys
|
|
27
|
+
|
|
28
|
+
from evalcore import errors
|
|
29
|
+
|
|
30
|
+
#: Names already imported. ``sys.modules`` is the real cache - this only keeps
|
|
31
|
+
#: a repeated run in one process from re-attempting an import that failed, and
|
|
32
|
+
#: lets :func:`load` report what it actually did.
|
|
33
|
+
_loaded: set[str] = set()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def allow_cwd_imports() -> None:
|
|
37
|
+
"""Put the working directory on ``sys.path``.
|
|
38
|
+
|
|
39
|
+
The console script (unlike ``python -m``) does not, so a
|
|
40
|
+
``plugins: [evals.graders]`` in a suite run from a repo root would never
|
|
41
|
+
resolve. Only the CLI calls this: a library has no business editing the
|
|
42
|
+
path of the process that imported it.
|
|
43
|
+
"""
|
|
44
|
+
cwd = os.getcwd()
|
|
45
|
+
if cwd not in sys.path:
|
|
46
|
+
sys.path.insert(0, cwd)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load(names: list[str] | None) -> list[str]:
|
|
50
|
+
"""Import each module in ``names`` once; return those newly imported.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
names: Module paths (``evals.graders``). Blanks are skipped, so a
|
|
54
|
+
comma-split CLI string can be passed straight in.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
The names imported by this call, in order. Already-imported names are
|
|
58
|
+
omitted rather than repeated.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
ConfigError: If a module cannot be imported. The name is quoted in the
|
|
62
|
+
message, because the failure is nearly always a typo in the suite
|
|
63
|
+
or a module that is not importable from the working directory.
|
|
64
|
+
|
|
65
|
+
"""
|
|
66
|
+
fresh: list[str] = []
|
|
67
|
+
for raw in names or ():
|
|
68
|
+
name = raw.strip()
|
|
69
|
+
if not name or name in _loaded:
|
|
70
|
+
continue
|
|
71
|
+
try:
|
|
72
|
+
importlib.import_module(name)
|
|
73
|
+
except ImportError as exc:
|
|
74
|
+
raise errors.ConfigError(
|
|
75
|
+
f'plugin module {name!r} could not be imported: {exc}'
|
|
76
|
+
) from exc
|
|
77
|
+
_loaded.add(name)
|
|
78
|
+
fresh.append(name)
|
|
79
|
+
return fresh
|
|
@@ -18,7 +18,7 @@ import statistics
|
|
|
18
18
|
import time
|
|
19
19
|
import uuid
|
|
20
20
|
|
|
21
|
-
from evalcore import compare, loader, models, store
|
|
21
|
+
from evalcore import compare, loader, models, plugins, store
|
|
22
22
|
from evalcore import retry as retry_mod
|
|
23
23
|
from evalcore.adapters import base as adapters_base
|
|
24
24
|
from evalcore.graders import base as graders_base
|
|
@@ -169,6 +169,11 @@ async def run_suite(
|
|
|
169
169
|
and their invocations skipped, so an interrupted run continues instead of
|
|
170
170
|
restarting. Without ``resume`` an existing checkpoint is overwritten.
|
|
171
171
|
"""
|
|
172
|
+
# Before anything reads the registries: a suite's own plugin modules have
|
|
173
|
+
# to be imported for its custom `type`s to resolve. This is the executing
|
|
174
|
+
# path, so it is where the suite gets to run code - load_suite does not.
|
|
175
|
+
plugins.load(suite.plugins)
|
|
176
|
+
|
|
172
177
|
if variant_name not in suite.variants:
|
|
173
178
|
raise KeyError(
|
|
174
179
|
f'variant {variant_name!r} not in suite '
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Suite-declared plugin modules (``plugins:``) and the CLI flag.
|
|
2
|
+
|
|
3
|
+
The registry is written by ``@register`` at import time, so the only question
|
|
4
|
+
these tests answer is *who* does the importing and *when*. The when matters as
|
|
5
|
+
much as the who: loading a suite must not run a consumer's code, and starting a
|
|
6
|
+
run must.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import pathlib
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
import unittest
|
|
13
|
+
from unittest import mock
|
|
14
|
+
|
|
15
|
+
from evalcore import errors, loader, models, plugins, runner
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _suite(**over):
|
|
19
|
+
base = {
|
|
20
|
+
'project': 'p',
|
|
21
|
+
'suite': 's',
|
|
22
|
+
'dataset': 'd',
|
|
23
|
+
'dataset_version': 'v1',
|
|
24
|
+
'mode_default': 'live',
|
|
25
|
+
'adapter': {'type': '_plugin_probe'},
|
|
26
|
+
'variants': {'baseline': {}},
|
|
27
|
+
}
|
|
28
|
+
base.update(over)
|
|
29
|
+
return loader.SuiteConfig.model_validate(base)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class LoadTests(unittest.TestCase):
|
|
33
|
+
def setUp(self):
|
|
34
|
+
# The module-level cache is process state; keep tests independent of
|
|
35
|
+
# each other and of whatever imported first.
|
|
36
|
+
self._saved = set(plugins._loaded)
|
|
37
|
+
plugins._loaded.clear()
|
|
38
|
+
self.addCleanup(
|
|
39
|
+
lambda: (
|
|
40
|
+
plugins._loaded.clear(),
|
|
41
|
+
plugins._loaded.update(self._saved),
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def test_imports_once_and_reports_only_fresh_names(self):
|
|
46
|
+
with mock.patch.object(plugins.importlib, 'import_module') as imp:
|
|
47
|
+
first = plugins.load(['json', 'json', ' csv '])
|
|
48
|
+
second = plugins.load(['json'])
|
|
49
|
+
self.assertEqual(first, ['json', 'csv'])
|
|
50
|
+
self.assertEqual(second, [])
|
|
51
|
+
self.assertEqual(
|
|
52
|
+
[c.args[0] for c in imp.call_args_list], ['json', 'csv']
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def test_blanks_and_none_are_skipped(self):
|
|
56
|
+
# A comma-split CLI string with no value is the common case:
|
|
57
|
+
# ''.split(',') is [''], not [].
|
|
58
|
+
self.assertEqual(plugins.load(None), [])
|
|
59
|
+
self.assertEqual(plugins.load(''.split(',')), [])
|
|
60
|
+
self.assertEqual(plugins.load(['', ' ']), [])
|
|
61
|
+
|
|
62
|
+
def test_unimportable_module_raises_config_error_naming_it(self):
|
|
63
|
+
with self.assertRaises(errors.ConfigError) as ctx:
|
|
64
|
+
plugins.load(['evalcore_no_such_plugin_module'])
|
|
65
|
+
self.assertIn('evalcore_no_such_plugin_module', str(ctx.exception))
|
|
66
|
+
# ConfigError is also a ValueError, so existing handlers keep working.
|
|
67
|
+
self.assertIsInstance(ctx.exception, ValueError)
|
|
68
|
+
|
|
69
|
+
def test_failed_import_is_not_cached_as_loaded(self):
|
|
70
|
+
for _ in range(2):
|
|
71
|
+
with self.assertRaises(errors.ConfigError):
|
|
72
|
+
plugins.load(['evalcore_no_such_plugin_module'])
|
|
73
|
+
self.assertNotIn('evalcore_no_such_plugin_module', plugins._loaded)
|
|
74
|
+
|
|
75
|
+
def test_allow_cwd_imports_adds_cwd_once(self):
|
|
76
|
+
saved = list(sys.path)
|
|
77
|
+
self.addCleanup(lambda: sys.path.__setitem__(slice(None), saved))
|
|
78
|
+
cwd = str(pathlib.Path.cwd())
|
|
79
|
+
# The test runner already has cwd on the path, so drop it first or the
|
|
80
|
+
# insert branch never runs and this asserts nothing.
|
|
81
|
+
sys.path[:] = [p for p in sys.path if p != cwd]
|
|
82
|
+
|
|
83
|
+
plugins.allow_cwd_imports()
|
|
84
|
+
self.assertEqual(sys.path[0], cwd)
|
|
85
|
+
plugins.allow_cwd_imports()
|
|
86
|
+
self.assertEqual(sys.path.count(cwd), 1)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class SuiteIntegrationTests(unittest.TestCase):
|
|
90
|
+
def test_load_suite_accepts_plugins_and_defaults_to_empty(self):
|
|
91
|
+
self.assertEqual(_suite().plugins, [])
|
|
92
|
+
self.assertEqual(_suite(plugins=['a.b']).plugins, ['a.b'])
|
|
93
|
+
|
|
94
|
+
def test_load_suite_does_not_import_them(self):
|
|
95
|
+
"""Reading a suite must stay side-effect free.
|
|
96
|
+
|
|
97
|
+
A bad module name would raise if load_suite imported it; parsing and
|
|
98
|
+
hashing a suite has to work on a file you have not decided to run.
|
|
99
|
+
"""
|
|
100
|
+
body = (
|
|
101
|
+
'project: p\nsuite: s\ndataset: d\n'
|
|
102
|
+
'adapter: {type: http}\n'
|
|
103
|
+
'plugins: [evalcore_no_such_plugin_module]\n'
|
|
104
|
+
)
|
|
105
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
106
|
+
path = pathlib.Path(tmp) / 'suite.yaml'
|
|
107
|
+
path.write_text(body, encoding='utf-8')
|
|
108
|
+
cfg = loader.load_suite(str(path))
|
|
109
|
+
self.assertEqual(cfg.plugins, ['evalcore_no_such_plugin_module'])
|
|
110
|
+
self.assertNotIn('evalcore_no_such_plugin_module', sys.modules)
|
|
111
|
+
|
|
112
|
+
def test_run_suite_imports_them_before_building_the_adapter(self):
|
|
113
|
+
"""The registration has to land before the `type` lookup.
|
|
114
|
+
|
|
115
|
+
The probe module registers `_plugin_probe`, which the suite names. If
|
|
116
|
+
the import happened after build_adapter - or not at all - this raises
|
|
117
|
+
an unknown-adapter ConfigError instead of running.
|
|
118
|
+
"""
|
|
119
|
+
module = 'evalcore_plugin_probe'
|
|
120
|
+
source = (
|
|
121
|
+
'from evalcore import models\n'
|
|
122
|
+
'from evalcore.adapters import base\n'
|
|
123
|
+
'\n'
|
|
124
|
+
'@base.register("_plugin_probe")\n'
|
|
125
|
+
'class Probe:\n'
|
|
126
|
+
' async def invoke(self, case, variant):\n'
|
|
127
|
+
' return models.Output(fields={"text": "ok"})\n'
|
|
128
|
+
)
|
|
129
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
130
|
+
(pathlib.Path(tmp) / f'{module}.py').write_text(
|
|
131
|
+
source, encoding='utf-8'
|
|
132
|
+
)
|
|
133
|
+
sys.path.insert(0, tmp)
|
|
134
|
+
self.addCleanup(lambda: sys.path.remove(tmp))
|
|
135
|
+
self.addCleanup(lambda: sys.modules.pop(module, None))
|
|
136
|
+
plugins._loaded.discard(module)
|
|
137
|
+
self.addCleanup(lambda: plugins._loaded.discard(module))
|
|
138
|
+
|
|
139
|
+
# No dataset dir needed; the point here is the registry lookup.
|
|
140
|
+
with mock.patch.object(
|
|
141
|
+
loader, 'load_cases', return_value=[models.Case(id='c1')]
|
|
142
|
+
):
|
|
143
|
+
run = runner.run_suite_sync(
|
|
144
|
+
_suite(plugins=[module]), 'baseline'
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
self.assertEqual(run.results[0].output.fields['text'], 'ok')
|
|
148
|
+
|
|
149
|
+
def test_run_suite_surfaces_a_bad_plugin_before_anything_else(self):
|
|
150
|
+
suite = _suite(
|
|
151
|
+
plugins=['evalcore_no_such_plugin_module'],
|
|
152
|
+
variants={'baseline': {}},
|
|
153
|
+
)
|
|
154
|
+
with self.assertRaises(errors.ConfigError) as ctx:
|
|
155
|
+
runner.run_suite_sync(suite, 'baseline')
|
|
156
|
+
self.assertIn('evalcore_no_such_plugin_module', str(ctx.exception))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
if __name__ == '__main__':
|
|
160
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|