evalcore 2.4.2__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.
Files changed (78) hide show
  1. {evalcore-2.4.2 → evalcore-2.5.0}/CHANGELOG.md +71 -1
  2. {evalcore-2.4.2 → evalcore-2.5.0}/PKG-INFO +18 -8
  3. {evalcore-2.4.2 → evalcore-2.5.0}/README.md +16 -6
  4. {evalcore-2.4.2 → evalcore-2.5.0}/docs/design.md +9 -0
  5. {evalcore-2.4.2 → evalcore-2.5.0}/examples/quickstart/graders.py +0 -4
  6. {evalcore-2.4.2 → evalcore-2.5.0}/examples/quickstart/run_eval.py +7 -11
  7. {evalcore-2.4.2 → evalcore-2.5.0}/examples/quickstart/suite.yaml +9 -2
  8. {evalcore-2.4.2 → evalcore-2.5.0}/examples/quickstart/tests/test_quickstart.py +0 -1
  9. {evalcore-2.4.2 → evalcore-2.5.0}/justfile +2 -2
  10. {evalcore-2.4.2 → evalcore-2.5.0}/pyproject.toml +1 -1
  11. {evalcore-2.4.2 → evalcore-2.5.0}/src/evalcore/cli.py +10 -13
  12. {evalcore-2.4.2 → evalcore-2.5.0}/src/evalcore/graders/judge.py +3 -4
  13. {evalcore-2.4.2 → evalcore-2.5.0}/src/evalcore/loader.py +5 -0
  14. {evalcore-2.4.2 → evalcore-2.5.0}/src/evalcore/pairwise.py +2 -3
  15. evalcore-2.5.0/src/evalcore/plugins.py +79 -0
  16. {evalcore-2.4.2 → evalcore-2.5.0}/src/evalcore/runner.py +6 -1
  17. evalcore-2.5.0/tests/test_plugins.py +160 -0
  18. {evalcore-2.4.2 → evalcore-2.5.0}/uv.lock +1 -1
  19. {evalcore-2.4.2 → evalcore-2.5.0}/.github/workflows/ci.yml +0 -0
  20. {evalcore-2.4.2 → evalcore-2.5.0}/.github/workflows/publish.yml +0 -0
  21. {evalcore-2.4.2 → evalcore-2.5.0}/.gitignore +0 -0
  22. {evalcore-2.4.2 → evalcore-2.5.0}/.pre-commit-config.yaml +0 -0
  23. {evalcore-2.4.2 → evalcore-2.5.0}/LICENSE +0 -0
  24. {evalcore-2.4.2 → evalcore-2.5.0}/examples/__init__.py +0 -0
  25. {evalcore-2.4.2 → evalcore-2.5.0}/examples/quickstart/README.md +0 -0
  26. {evalcore-2.4.2 → evalcore-2.5.0}/examples/quickstart/__init__.py +0 -0
  27. {evalcore-2.4.2 → evalcore-2.5.0}/examples/quickstart/adapter.py +0 -0
  28. {evalcore-2.4.2 → evalcore-2.5.0}/examples/quickstart/datasets/support_reply/v1/cases/angry_complaint.yaml +0 -0
  29. {evalcore-2.4.2 → evalcore-2.5.0}/examples/quickstart/datasets/support_reply/v1/cases/billing_question.yaml +0 -0
  30. {evalcore-2.4.2 → evalcore-2.5.0}/examples/quickstart/datasets/support_reply/v1/cases/double_charge.yaml +0 -0
  31. {evalcore-2.4.2 → evalcore-2.5.0}/examples/quickstart/datasets/support_reply/v1/cases/refund_request.yaml +0 -0
  32. {evalcore-2.4.2 → evalcore-2.5.0}/examples/quickstart/fixtures/support_reply_judge.yaml +0 -0
  33. {evalcore-2.4.2 → evalcore-2.5.0}/examples/quickstart/fixtures/support_reply_pairwise.yaml +0 -0
  34. {evalcore-2.4.2 → evalcore-2.5.0}/examples/quickstart/fixtures/support_reply_replay.yaml +0 -0
  35. {evalcore-2.4.2 → evalcore-2.5.0}/examples/quickstart/tests/__init__.py +0 -0
  36. {evalcore-2.4.2 → evalcore-2.5.0}/pyrightconfig.json +0 -0
  37. {evalcore-2.4.2 → evalcore-2.5.0}/src/evalcore/__init__.py +0 -0
  38. {evalcore-2.4.2 → evalcore-2.5.0}/src/evalcore/adapters/__init__.py +0 -0
  39. {evalcore-2.4.2 → evalcore-2.5.0}/src/evalcore/adapters/base.py +0 -0
  40. {evalcore-2.4.2 → evalcore-2.5.0}/src/evalcore/adapters/env.py +0 -0
  41. {evalcore-2.4.2 → evalcore-2.5.0}/src/evalcore/adapters/http.py +0 -0
  42. {evalcore-2.4.2 → evalcore-2.5.0}/src/evalcore/adapters/replay.py +0 -0
  43. {evalcore-2.4.2 → evalcore-2.5.0}/src/evalcore/compare.py +0 -0
  44. {evalcore-2.4.2 → evalcore-2.5.0}/src/evalcore/errors.py +0 -0
  45. {evalcore-2.4.2 → evalcore-2.5.0}/src/evalcore/graders/__init__.py +0 -0
  46. {evalcore-2.4.2 → evalcore-2.5.0}/src/evalcore/graders/base.py +0 -0
  47. {evalcore-2.4.2 → evalcore-2.5.0}/src/evalcore/graders/classification.py +0 -0
  48. {evalcore-2.4.2 → evalcore-2.5.0}/src/evalcore/graders/deterministic.py +0 -0
  49. {evalcore-2.4.2 → evalcore-2.5.0}/src/evalcore/graders/numeric.py +0 -0
  50. {evalcore-2.4.2 → evalcore-2.5.0}/src/evalcore/models.py +0 -0
  51. {evalcore-2.4.2 → evalcore-2.5.0}/src/evalcore/py.typed +0 -0
  52. {evalcore-2.4.2 → evalcore-2.5.0}/src/evalcore/rating.py +0 -0
  53. {evalcore-2.4.2 → evalcore-2.5.0}/src/evalcore/refs.py +0 -0
  54. {evalcore-2.4.2 → evalcore-2.5.0}/src/evalcore/report.py +0 -0
  55. {evalcore-2.4.2 → evalcore-2.5.0}/src/evalcore/reporters/__init__.py +0 -0
  56. {evalcore-2.4.2 → evalcore-2.5.0}/src/evalcore/reporters/base.py +0 -0
  57. {evalcore-2.4.2 → evalcore-2.5.0}/src/evalcore/reporters/html.py +0 -0
  58. {evalcore-2.4.2 → evalcore-2.5.0}/src/evalcore/reporters/markdown.py +0 -0
  59. {evalcore-2.4.2 → evalcore-2.5.0}/src/evalcore/retry.py +0 -0
  60. {evalcore-2.4.2 → evalcore-2.5.0}/src/evalcore/store.py +0 -0
  61. {evalcore-2.4.2 → evalcore-2.5.0}/src/evalcore/sweep.py +0 -0
  62. {evalcore-2.4.2 → evalcore-2.5.0}/tests/__init__.py +0 -0
  63. {evalcore-2.4.2 → evalcore-2.5.0}/tests/test_adapters.py +0 -0
  64. {evalcore-2.4.2 → evalcore-2.5.0}/tests/test_cli.py +0 -0
  65. {evalcore-2.4.2 → evalcore-2.5.0}/tests/test_edge_cases.py +0 -0
  66. {evalcore-2.4.2 → evalcore-2.5.0}/tests/test_judge.py +0 -0
  67. {evalcore-2.4.2 → evalcore-2.5.0}/tests/test_judge_extra.py +0 -0
  68. {evalcore-2.4.2 → evalcore-2.5.0}/tests/test_live_clients.py +0 -0
  69. {evalcore-2.4.2 → evalcore-2.5.0}/tests/test_pairwise_extra.py +0 -0
  70. {evalcore-2.4.2 → evalcore-2.5.0}/tests/test_rating.py +0 -0
  71. {evalcore-2.4.2 → evalcore-2.5.0}/tests/test_rating_server.py +0 -0
  72. {evalcore-2.4.2 → evalcore-2.5.0}/tests/test_reporters.py +0 -0
  73. {evalcore-2.4.2 → evalcore-2.5.0}/tests/test_retry.py +0 -0
  74. {evalcore-2.4.2 → evalcore-2.5.0}/tests/test_runner.py +0 -0
  75. {evalcore-2.4.2 → evalcore-2.5.0}/tests/test_store.py +0 -0
  76. {evalcore-2.4.2 → evalcore-2.5.0}/tests/test_sweep_pairwise.py +0 -0
  77. {evalcore-2.4.2 → evalcore-2.5.0}/tests/test_unit.py +0 -0
  78. {evalcore-2.4.2 → evalcore-2.5.0}/uv.toml +0 -0
@@ -6,6 +6,74 @@ 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
+
49
+ ## [2.4.3] - 2026-08-11
50
+
51
+ Live Anthropic judges work on current Claude models again.
52
+
53
+ ### Fixed
54
+ - The Anthropic judge and pairwise clients no longer send `temperature=0`.
55
+ `temperature` (with `top_p`/`top_k`) was removed from the Claude request
56
+ surface at Opus 4.7, and sending it at all is a 400 there and on every model
57
+ after it - so a judge or a pairwise comparison pinned to `claude-opus-4-7`,
58
+ `claude-opus-4-8`, `claude-opus-5`, `claude-sonnet-5` or `claude-fable-5`
59
+ failed every call. The OpenAI clients still send it; that API still takes it.
60
+
61
+ ### Changed
62
+ - Live judge `max_tokens` defaults are now 8192 (from 1024 on the rubric
63
+ judge, 512 on pairwise). Thinking is on by default from Opus 5 and Sonnet 5
64
+ onward and `max_tokens` bounds thinking plus reply together, so a
65
+ 1024-token budget could be spent on reasoning before the forced tool call
66
+ landed - which surfaced as error scores rather than as an error. It is a
67
+ ceiling, not a spend: a model that does not think generates the same handful
68
+ of tokens it did before.
69
+
70
+ **Upgrading:** a judge on a model that still accepts `temperature` (Sonnet
71
+ 4.6, Opus 4.6, the 4.5 line and older) now samples at the API default instead
72
+ of 0, so its scores are no longer pinned run to run - expect more variance in
73
+ a rubric dimension or a win-rate than before, and re-baseline if a gate sits
74
+ close to its threshold. A judge on a thinking model also now bills thinking
75
+ tokens on every call. Pass `max_tokens=` to a client to keep the old budget.
76
+
9
77
  ## [2.4.2] - 2026-08-09
10
78
 
11
79
  Every `llm_as_judge` row now describes what it measures.
@@ -312,7 +380,9 @@ by semantic versioning: a breaking change to either means a 2.0.
312
380
  rating + ranking with judge agreement, Markdown/HTML reporters, JSON +
313
381
  column-store outbox, and content-hash provenance.
314
382
 
315
- [Unreleased]: https://github.com/scottpmiller/evalcore/compare/2.4.2...HEAD
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
385
+ [2.4.3]: https://github.com/scottpmiller/evalcore/compare/2.4.2...2.4.3
316
386
  [2.4.2]: https://github.com/scottpmiller/evalcore/compare/2.4.1...2.4.2
317
387
  [2.4.1]: https://github.com/scottpmiller/evalcore/compare/2.4.0...2.4.1
318
388
  [2.4.0]: https://github.com/scottpmiller/evalcore/compare/2.3.0...2.4.0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: evalcore
3
- Version: 2.4.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`). Load the module at run time with `--plugins my_service.graders`
218
- (CLI) or a plain `import` (Python API) - registration happens on import.
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
@@ -327,7 +334,7 @@ degrade to `null`. `compare`'s guardrails and a `win_metric` with
327
334
  `generation_cost` or `tool_error_rate` alongside quality judges.
328
335
 
329
336
  The judge runs live (`AnthropicJudgeClient` forced tool call, or
330
- `OpenAIJudgeClient` `json_schema` - both temperature 0, needing the `judge`
337
+ `OpenAIJudgeClient` `json_schema` at temperature 0 - needing the `judge`
331
338
  extra plus `ANTHROPIC_API_KEY`/`OPENAI_API_KEY`) or offline
332
339
  (`ReplayJudgeClient`), chosen by the run mode like the adapter. Each
333
340
  dimension becomes a metric `<name>.<key>` plus a `<name>.overall` mean.
@@ -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** (plug-ins first, so custom types register):
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 --plugins my_service.graders run \
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 --plugins my_service.graders gate \
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`). Load the module at run time with `--plugins my_service.graders`
186
- (CLI) or a plain `import` (Python API) - registration happens on import.
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
@@ -295,7 +302,7 @@ degrade to `null`. `compare`'s guardrails and a `win_metric` with
295
302
  `generation_cost` or `tool_error_rate` alongside quality judges.
296
303
 
297
304
  The judge runs live (`AnthropicJudgeClient` forced tool call, or
298
- `OpenAIJudgeClient` `json_schema` - both temperature 0, needing the `judge`
305
+ `OpenAIJudgeClient` `json_schema` at temperature 0 - needing the `judge`
299
306
  extra plus `ANTHROPIC_API_KEY`/`OPENAI_API_KEY`) or offline
300
307
  (`ReplayJudgeClient`), chosen by the run mode like the adapter. Each
301
308
  dimension becomes a metric `<name>.<key>` plus a `<name>.overall` mean.
@@ -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** (plug-ins first, so custom types register):
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 --plugins my_service.graders run \
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 --plugins my_service.graders gate \
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: scorecard metrics AND per-sample score rows.
95
- exporter = store.JsonlOutboxExporter(out / 'outbox.jsonl')
96
- metric_rows = exporter.export(baseline) + exporter.export(candidate)
97
- score_exporter = store.JsonlOutboxExporter(out / 'scores.jsonl')
98
- n_scores = score_exporter.export_scores(
99
- baseline_run
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 --plugins examples.quickstart.graders gate \
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 --plugins examples.quickstart.graders gate --suite examples/quickstart/suite.yaml --mode replay
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 --plugins examples.quickstart.graders pairwise --suite examples/quickstart/suite.yaml --a baseline --b candidate --mode replay
34
+ uv run evalcore pairwise --suite examples/quickstart/suite.yaml --a baseline --b candidate --mode replay
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "evalcore"
3
- version = "2.4.2"
3
+ version = "2.5.0"
4
4
  description = "A generic, consumer-agnostic evaluation engine for prompt, model, and API outputs."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -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
- if not spec:
42
- return
43
- # Consumers run the CLI from their repo root; the console script
44
- # (unlike `python -m`) does not put the cwd on sys.path, so add it
45
- # or `--plugins my_pkg.graders` could never import.
46
- cwd = os.getcwd()
47
- if cwd not in sys.path:
48
- sys.path.insert(0, cwd)
49
- for name in filter(None, spec.split(',')):
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:
@@ -104,7 +104,7 @@ class AnthropicJudgeClient:
104
104
  self,
105
105
  model: str,
106
106
  api_key_env: str = 'ANTHROPIC_API_KEY',
107
- max_tokens: int = 1024,
107
+ max_tokens: int = 8192,
108
108
  timeout: float = 30.0,
109
109
  ):
110
110
  self.model = model
@@ -156,7 +156,6 @@ class AnthropicJudgeClient:
156
156
  response = await client.messages.create(
157
157
  model=self.model,
158
158
  max_tokens=self.max_tokens,
159
- temperature=0,
160
159
  timeout=self.timeout,
161
160
  system=system,
162
161
  tools=[tool],
@@ -179,7 +178,7 @@ class OpenAIJudgeClient:
179
178
  self,
180
179
  model: str,
181
180
  api_key_env: str = 'OPENAI_API_KEY',
182
- max_tokens: int = 1024,
181
+ max_tokens: int = 8192,
183
182
  timeout: float = 30.0,
184
183
  ):
185
184
  # Accept a 'provider:model' id (e.g. 'openai:gpt-4o'); SDK wants bare.
@@ -326,7 +325,7 @@ class RubricJudge:
326
325
  judge_version: str = 'v1',
327
326
  replay_path: str | None = None,
328
327
  client: JudgeClient | None = None,
329
- max_tokens: int = 1024,
328
+ max_tokens: int = 8192,
330
329
  ):
331
330
  self.name = name
332
331
  self.content_ref = content_ref
@@ -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
@@ -70,7 +70,7 @@ class AnthropicPairwiseClient:
70
70
  self,
71
71
  model: str,
72
72
  api_key_env: str = 'ANTHROPIC_API_KEY',
73
- max_tokens: int = 512,
73
+ max_tokens: int = 8192,
74
74
  timeout: float = 30.0,
75
75
  ):
76
76
  self.model = model
@@ -88,7 +88,6 @@ class AnthropicPairwiseClient:
88
88
  response = await client.messages.create(
89
89
  model=self.model,
90
90
  max_tokens=self.max_tokens,
91
- temperature=0,
92
91
  timeout=self.timeout,
93
92
  system=system,
94
93
  tools=[tool],
@@ -111,7 +110,7 @@ class OpenAIPairwiseClient:
111
110
  self,
112
111
  model: str,
113
112
  api_key_env: str = 'OPENAI_API_KEY',
114
- max_tokens: int = 512,
113
+ max_tokens: int = 8192,
115
114
  timeout: float = 30.0,
116
115
  ):
117
116
  self.model = model.split(':', 1)[1] if ':' in model else model
@@ -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()
@@ -188,7 +188,7 @@ wheels = [
188
188
 
189
189
  [[package]]
190
190
  name = "evalcore"
191
- version = "2.4.2"
191
+ version = "2.5.0"
192
192
  source = { editable = "." }
193
193
  dependencies = [
194
194
  { name = "pydantic" },
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