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.
Files changed (79) hide show
  1. {evalcore-2.4.3 → evalcore-2.6.0}/CHANGELOG.md +94 -1
  2. {evalcore-2.4.3 → evalcore-2.6.0}/PKG-INFO +64 -7
  3. {evalcore-2.4.3 → evalcore-2.6.0}/README.md +63 -6
  4. {evalcore-2.4.3 → evalcore-2.6.0}/docs/design.md +9 -0
  5. {evalcore-2.4.3 → evalcore-2.6.0}/examples/quickstart/graders.py +5 -4
  6. {evalcore-2.4.3 → evalcore-2.6.0}/examples/quickstart/run_eval.py +7 -11
  7. {evalcore-2.4.3 → evalcore-2.6.0}/examples/quickstart/suite.yaml +9 -2
  8. {evalcore-2.4.3 → evalcore-2.6.0}/examples/quickstart/tests/test_quickstart.py +0 -1
  9. {evalcore-2.4.3 → evalcore-2.6.0}/justfile +2 -2
  10. {evalcore-2.4.3 → evalcore-2.6.0}/pyproject.toml +1 -1
  11. {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/cli.py +10 -13
  12. {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/compare.py +128 -9
  13. {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/graders/classification.py +23 -5
  14. {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/graders/deterministic.py +3 -0
  15. {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/graders/judge.py +16 -1
  16. {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/graders/numeric.py +25 -0
  17. {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/loader.py +5 -0
  18. {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/models.py +52 -1
  19. evalcore-2.6.0/src/evalcore/plugins.py +79 -0
  20. {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/runner.py +32 -1
  21. evalcore-2.6.0/tests/test_plugins.py +160 -0
  22. evalcore-2.6.0/tests/test_unit.py +446 -0
  23. {evalcore-2.4.3 → evalcore-2.6.0}/uv.lock +1 -1
  24. evalcore-2.4.3/tests/test_unit.py +0 -239
  25. {evalcore-2.4.3 → evalcore-2.6.0}/.github/workflows/ci.yml +0 -0
  26. {evalcore-2.4.3 → evalcore-2.6.0}/.github/workflows/publish.yml +0 -0
  27. {evalcore-2.4.3 → evalcore-2.6.0}/.gitignore +0 -0
  28. {evalcore-2.4.3 → evalcore-2.6.0}/.pre-commit-config.yaml +0 -0
  29. {evalcore-2.4.3 → evalcore-2.6.0}/LICENSE +0 -0
  30. {evalcore-2.4.3 → evalcore-2.6.0}/examples/__init__.py +0 -0
  31. {evalcore-2.4.3 → evalcore-2.6.0}/examples/quickstart/README.md +0 -0
  32. {evalcore-2.4.3 → evalcore-2.6.0}/examples/quickstart/__init__.py +0 -0
  33. {evalcore-2.4.3 → evalcore-2.6.0}/examples/quickstart/adapter.py +0 -0
  34. {evalcore-2.4.3 → evalcore-2.6.0}/examples/quickstart/datasets/support_reply/v1/cases/angry_complaint.yaml +0 -0
  35. {evalcore-2.4.3 → evalcore-2.6.0}/examples/quickstart/datasets/support_reply/v1/cases/billing_question.yaml +0 -0
  36. {evalcore-2.4.3 → evalcore-2.6.0}/examples/quickstart/datasets/support_reply/v1/cases/double_charge.yaml +0 -0
  37. {evalcore-2.4.3 → evalcore-2.6.0}/examples/quickstart/datasets/support_reply/v1/cases/refund_request.yaml +0 -0
  38. {evalcore-2.4.3 → evalcore-2.6.0}/examples/quickstart/fixtures/support_reply_judge.yaml +0 -0
  39. {evalcore-2.4.3 → evalcore-2.6.0}/examples/quickstart/fixtures/support_reply_pairwise.yaml +0 -0
  40. {evalcore-2.4.3 → evalcore-2.6.0}/examples/quickstart/fixtures/support_reply_replay.yaml +0 -0
  41. {evalcore-2.4.3 → evalcore-2.6.0}/examples/quickstart/tests/__init__.py +0 -0
  42. {evalcore-2.4.3 → evalcore-2.6.0}/pyrightconfig.json +0 -0
  43. {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/__init__.py +0 -0
  44. {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/adapters/__init__.py +0 -0
  45. {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/adapters/base.py +0 -0
  46. {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/adapters/env.py +0 -0
  47. {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/adapters/http.py +0 -0
  48. {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/adapters/replay.py +0 -0
  49. {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/errors.py +0 -0
  50. {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/graders/__init__.py +0 -0
  51. {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/graders/base.py +0 -0
  52. {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/pairwise.py +0 -0
  53. {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/py.typed +0 -0
  54. {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/rating.py +0 -0
  55. {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/refs.py +0 -0
  56. {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/report.py +0 -0
  57. {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/reporters/__init__.py +0 -0
  58. {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/reporters/base.py +0 -0
  59. {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/reporters/html.py +0 -0
  60. {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/reporters/markdown.py +0 -0
  61. {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/retry.py +0 -0
  62. {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/store.py +0 -0
  63. {evalcore-2.4.3 → evalcore-2.6.0}/src/evalcore/sweep.py +0 -0
  64. {evalcore-2.4.3 → evalcore-2.6.0}/tests/__init__.py +0 -0
  65. {evalcore-2.4.3 → evalcore-2.6.0}/tests/test_adapters.py +0 -0
  66. {evalcore-2.4.3 → evalcore-2.6.0}/tests/test_cli.py +0 -0
  67. {evalcore-2.4.3 → evalcore-2.6.0}/tests/test_edge_cases.py +0 -0
  68. {evalcore-2.4.3 → evalcore-2.6.0}/tests/test_judge.py +0 -0
  69. {evalcore-2.4.3 → evalcore-2.6.0}/tests/test_judge_extra.py +0 -0
  70. {evalcore-2.4.3 → evalcore-2.6.0}/tests/test_live_clients.py +0 -0
  71. {evalcore-2.4.3 → evalcore-2.6.0}/tests/test_pairwise_extra.py +0 -0
  72. {evalcore-2.4.3 → evalcore-2.6.0}/tests/test_rating.py +0 -0
  73. {evalcore-2.4.3 → evalcore-2.6.0}/tests/test_rating_server.py +0 -0
  74. {evalcore-2.4.3 → evalcore-2.6.0}/tests/test_reporters.py +0 -0
  75. {evalcore-2.4.3 → evalcore-2.6.0}/tests/test_retry.py +0 -0
  76. {evalcore-2.4.3 → evalcore-2.6.0}/tests/test_runner.py +0 -0
  77. {evalcore-2.4.3 → evalcore-2.6.0}/tests/test_store.py +0 -0
  78. {evalcore-2.4.3 → evalcore-2.6.0}/tests/test_sweep_pairwise.py +0 -0
  79. {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.4.3...HEAD
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.4.3
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`). 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
@@ -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. Pick guardrails for the failures that must never ship, and
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** (plug-ins first, so custom types register):
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 --plugins my_service.graders run \
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 --plugins my_service.graders gate \
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`). 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
@@ -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. Pick guardrails for the failures that must never ship, and
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** (plug-ins first, so custom types register):
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 --plugins my_service.graders run \
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 --plugins my_service.graders gate \
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: 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.3"
3
+ version = "2.6.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: