evalcore 2.0.0__tar.gz → 2.2.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.0.0 → evalcore-2.2.0}/CHANGELOG.md +61 -1
- {evalcore-2.0.0 → evalcore-2.2.0}/PKG-INFO +35 -5
- {evalcore-2.0.0 → evalcore-2.2.0}/README.md +34 -4
- {evalcore-2.0.0 → evalcore-2.2.0}/examples/quickstart/graders.py +2 -2
- {evalcore-2.0.0 → evalcore-2.2.0}/pyproject.toml +1 -1
- {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/graders/__init__.py +10 -1
- evalcore-2.2.0/src/evalcore/graders/base.py +145 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/graders/classification.py +1 -1
- {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/graders/deterministic.py +4 -4
- {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/graders/judge.py +1 -1
- {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/graders/numeric.py +1 -1
- {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/store.py +56 -18
- {evalcore-2.0.0 → evalcore-2.2.0}/tests/test_runner.py +2 -2
- {evalcore-2.0.0 → evalcore-2.2.0}/tests/test_store.py +53 -1
- {evalcore-2.0.0 → evalcore-2.2.0}/uv.lock +1 -1
- evalcore-2.0.0/src/evalcore/graders/base.py +0 -78
- {evalcore-2.0.0 → evalcore-2.2.0}/.github/workflows/ci.yml +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/.github/workflows/publish.yml +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/.gitignore +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/.pre-commit-config.yaml +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/LICENSE +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/docs/design.md +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/examples/__init__.py +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/examples/quickstart/README.md +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/examples/quickstart/__init__.py +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/examples/quickstart/adapter.py +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/examples/quickstart/datasets/support_reply/v1/cases/angry_complaint.yaml +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/examples/quickstart/datasets/support_reply/v1/cases/billing_question.yaml +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/examples/quickstart/datasets/support_reply/v1/cases/double_charge.yaml +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/examples/quickstart/datasets/support_reply/v1/cases/refund_request.yaml +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/examples/quickstart/fixtures/support_reply_judge.yaml +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/examples/quickstart/fixtures/support_reply_pairwise.yaml +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/examples/quickstart/fixtures/support_reply_replay.yaml +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/examples/quickstart/run_eval.py +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/examples/quickstart/suite.yaml +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/examples/quickstart/tests/__init__.py +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/examples/quickstart/tests/test_quickstart.py +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/justfile +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/pyrightconfig.json +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/__init__.py +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/adapters/__init__.py +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/adapters/base.py +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/adapters/env.py +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/adapters/http.py +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/adapters/replay.py +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/cli.py +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/compare.py +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/errors.py +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/loader.py +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/models.py +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/pairwise.py +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/py.typed +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/rating.py +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/refs.py +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/report.py +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/reporters/__init__.py +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/reporters/base.py +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/reporters/html.py +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/reporters/markdown.py +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/retry.py +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/runner.py +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/sweep.py +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/tests/__init__.py +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/tests/test_adapters.py +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/tests/test_cli.py +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/tests/test_edge_cases.py +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/tests/test_judge.py +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/tests/test_judge_extra.py +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/tests/test_live_clients.py +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/tests/test_pairwise_extra.py +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/tests/test_rating.py +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/tests/test_rating_server.py +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/tests/test_reporters.py +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/tests/test_retry.py +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/tests/test_sweep_pairwise.py +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/tests/test_unit.py +0 -0
- {evalcore-2.0.0 → evalcore-2.2.0}/uv.toml +0 -0
|
@@ -6,6 +6,63 @@ All notable changes to this project are documented here. The format is based on
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [2.2.0] - 2026-08-08
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- `store.ScoreExporter`, a `runtime_checkable` Protocol naming the seam
|
|
13
|
+
`JsonlOutboxExporter` already occupied. `store.py` has always said to
|
|
14
|
+
"replace this class with a database client implementing the same
|
|
15
|
+
`export_scores` method", but the contract was a docstring sentence and
|
|
16
|
+
`**kwargs`, so an implementation had to duck-type a private shape. An
|
|
17
|
+
exporter belongs in the package that owns the store it targets - it is the
|
|
18
|
+
store that knows its own column types, null policy, and transport - and
|
|
19
|
+
swapping one for another is now a constructor line at the call site, so an
|
|
20
|
+
offline run and a live one share a code path.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
- `examples/quickstart/graders.py` still used the one-argument `register` and
|
|
24
|
+
raised `TypeError` on import, so 2.1.0 shipped with its own bundled example
|
|
25
|
+
broken. `just test` does not run the example; `just test-all` does.
|
|
26
|
+
- The README's Python API example called a `.export()` that does not exist and
|
|
27
|
+
passed `RunResult`s to `compare.compare` and `render_scorecard`, which take
|
|
28
|
+
`Scorecard`s. It now runs verbatim, and shows `grader_lookups` feeding the
|
|
29
|
+
exporter.
|
|
30
|
+
|
|
31
|
+
## [2.1.0] - 2026-08-07
|
|
32
|
+
|
|
33
|
+
A grader declares what kind of check it is at registration, so a consumer
|
|
34
|
+
plug-in is categorised the same way a built-in is.
|
|
35
|
+
|
|
36
|
+
Shipped as a minor despite the signature change below. `register` is public,
|
|
37
|
+
so the 1.0.0 policy would call this a major; it goes out as 2.1.0 as a
|
|
38
|
+
deliberate exception, because the break is a one-line edit per grader that
|
|
39
|
+
fails loudly at import.
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
- **Breaking:** `graders.base.register` takes a required second argument,
|
|
43
|
+
`category`, a `graders.GraderType`. Every `@base.register('foo')` becomes
|
|
44
|
+
`@base.register('foo', base.GraderType.HEURISTIC)` or whichever member
|
|
45
|
+
applies; omitting it is a `TypeError` at import. Required rather than
|
|
46
|
+
defaulted on purpose - it is the only source of a row's `grader_type`, and
|
|
47
|
+
a default would be the value every grader forgets to override.
|
|
48
|
+
- `store.grader_lookups` reads the category from the registry instead of a
|
|
49
|
+
closed table of built-in type names, so a plug-in that declares
|
|
50
|
+
`HEURISTIC` reports `heuristic` where it used to report `unknown`. Rows for
|
|
51
|
+
consumer graders change value in the `grader_type` column; nothing about
|
|
52
|
+
the row shape changes.
|
|
53
|
+
|
|
54
|
+
### Added
|
|
55
|
+
- `graders.GraderType`, a `StrEnum` over the closed set the results store's
|
|
56
|
+
`grader_type` column accepts: `unknown`, `heuristic`, `statistical`,
|
|
57
|
+
`llm_as_judge`, `trajectory`, `human`. A `StrEnum` so it needs no
|
|
58
|
+
serializer of its own on the way to a row.
|
|
59
|
+
- `graders.category_of`, the registry lookup behind `grader_lookups`.
|
|
60
|
+
|
|
61
|
+
### Removed
|
|
62
|
+
- `store._GRADER_TYPES`, the private table the categories used to live in.
|
|
63
|
+
Keeping it alongside the registration argument would mean two sources for
|
|
64
|
+
one fact and a precedence rule between them.
|
|
65
|
+
|
|
9
66
|
## [2.0.0] - 2026-07-31
|
|
10
67
|
|
|
11
68
|
Breaks both the public API and the outbox row shape, so it's a major per the
|
|
@@ -139,7 +196,10 @@ by semantic versioning: a breaking change to either means a 2.0.
|
|
|
139
196
|
rating + ranking with judge agreement, Markdown/HTML reporters, JSON +
|
|
140
197
|
column-store outbox, and content-hash provenance.
|
|
141
198
|
|
|
142
|
-
[Unreleased]: https://github.com/scottpmiller/evalcore/compare/
|
|
199
|
+
[Unreleased]: https://github.com/scottpmiller/evalcore/compare/2.2.0...HEAD
|
|
200
|
+
[2.2.0]: https://github.com/scottpmiller/evalcore/compare/2.1.0...2.2.0
|
|
201
|
+
[2.1.0]: https://github.com/scottpmiller/evalcore/compare/2.0.0...2.1.0
|
|
202
|
+
[2.0.0]: https://github.com/scottpmiller/evalcore/compare/1.0.0...2.0.0
|
|
143
203
|
[1.0.0]: https://github.com/scottpmiller/evalcore/compare/0.3.0...1.0.0
|
|
144
204
|
[0.3.0]: https://github.com/scottpmiller/evalcore/compare/0.2.0...0.3.0
|
|
145
205
|
[0.2.0]: https://github.com/scottpmiller/evalcore/compare/0.1.0...0.2.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: evalcore
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.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
|
|
@@ -502,13 +502,30 @@ candidate = runner.run_suite_sync(suite, 'candidate', mode='replay',
|
|
|
502
502
|
revision='abc123', created_at=now)
|
|
503
503
|
# (async context: `await runner.run_suite(...)` is the same call)
|
|
504
504
|
|
|
505
|
-
|
|
506
|
-
|
|
505
|
+
# run_suite returns a RunResult: the scorecard plus every per-sample
|
|
506
|
+
# result behind it. compare and the reporters take the scorecard.
|
|
507
|
+
result = compare.compare(
|
|
508
|
+
baseline.scorecard, candidate.scorecard, suite.thresholds
|
|
509
|
+
)
|
|
510
|
+
print(report.render_scorecard(candidate.scorecard))
|
|
507
511
|
print(report.render_comparison(result))
|
|
508
512
|
|
|
509
|
-
store.write_scorecard('candidate.scorecard.json', candidate)
|
|
513
|
+
store.write_scorecard('candidate.scorecard.json', candidate.scorecard)
|
|
510
514
|
store.write_comparison('comparison.json', result)
|
|
511
|
-
|
|
515
|
+
|
|
516
|
+
# The rows. grader_lookups supplies the grader category and judge scale a
|
|
517
|
+
# Score doesn't carry; the baseline half exports without the comparison,
|
|
518
|
+
# since it was not itself gated.
|
|
519
|
+
types, scales = store.grader_lookups(suite.graders)
|
|
520
|
+
exporter = store.JsonlOutboxExporter('outbox.jsonl')
|
|
521
|
+
exporter.export_scores(baseline, grader_types=types, judge_scales=scales)
|
|
522
|
+
exporter.export_scores(
|
|
523
|
+
candidate,
|
|
524
|
+
result,
|
|
525
|
+
baseline_run_id=baseline.run_id,
|
|
526
|
+
grader_types=types,
|
|
527
|
+
judge_scales=scales,
|
|
528
|
+
)
|
|
512
529
|
|
|
513
530
|
raise SystemExit(0 if result.verdict != 'fail' else 1)
|
|
514
531
|
```
|
|
@@ -784,6 +801,19 @@ table without a mapping layer. A store that forbids nullable columns fills
|
|
|
784
801
|
those nulls in at ingest, on its side of the seam. Swap the exporter for a real
|
|
785
802
|
database client without touching the runner or any consumer.
|
|
786
803
|
|
|
804
|
+
`ScoreExporter` is that seam, named: any object with an `export_scores(run,
|
|
805
|
+
comparison, **kwargs)` method returning a count. `JsonlOutboxExporter` is the
|
|
806
|
+
built-in one; an exporter that publishes to a real store belongs in the
|
|
807
|
+
package that owns that store, since it is the store that knows its own column
|
|
808
|
+
types, null policy, and transport. Swapping one for the other is a
|
|
809
|
+
constructor line, so an offline run and a live one share a code path:
|
|
810
|
+
|
|
811
|
+
```python
|
|
812
|
+
exporter = store.JsonlOutboxExporter(path) # offline
|
|
813
|
+
exporter = KafkaOutboxExporter(...) # live, from another package
|
|
814
|
+
exporter.export_scores(run, comparison, grader_types=..., judge_scales=...)
|
|
815
|
+
```
|
|
816
|
+
|
|
787
817
|
---
|
|
788
818
|
|
|
789
819
|
## The two extension seams (recap)
|
|
@@ -470,13 +470,30 @@ candidate = runner.run_suite_sync(suite, 'candidate', mode='replay',
|
|
|
470
470
|
revision='abc123', created_at=now)
|
|
471
471
|
# (async context: `await runner.run_suite(...)` is the same call)
|
|
472
472
|
|
|
473
|
-
|
|
474
|
-
|
|
473
|
+
# run_suite returns a RunResult: the scorecard plus every per-sample
|
|
474
|
+
# result behind it. compare and the reporters take the scorecard.
|
|
475
|
+
result = compare.compare(
|
|
476
|
+
baseline.scorecard, candidate.scorecard, suite.thresholds
|
|
477
|
+
)
|
|
478
|
+
print(report.render_scorecard(candidate.scorecard))
|
|
475
479
|
print(report.render_comparison(result))
|
|
476
480
|
|
|
477
|
-
store.write_scorecard('candidate.scorecard.json', candidate)
|
|
481
|
+
store.write_scorecard('candidate.scorecard.json', candidate.scorecard)
|
|
478
482
|
store.write_comparison('comparison.json', result)
|
|
479
|
-
|
|
483
|
+
|
|
484
|
+
# The rows. grader_lookups supplies the grader category and judge scale a
|
|
485
|
+
# Score doesn't carry; the baseline half exports without the comparison,
|
|
486
|
+
# since it was not itself gated.
|
|
487
|
+
types, scales = store.grader_lookups(suite.graders)
|
|
488
|
+
exporter = store.JsonlOutboxExporter('outbox.jsonl')
|
|
489
|
+
exporter.export_scores(baseline, grader_types=types, judge_scales=scales)
|
|
490
|
+
exporter.export_scores(
|
|
491
|
+
candidate,
|
|
492
|
+
result,
|
|
493
|
+
baseline_run_id=baseline.run_id,
|
|
494
|
+
grader_types=types,
|
|
495
|
+
judge_scales=scales,
|
|
496
|
+
)
|
|
480
497
|
|
|
481
498
|
raise SystemExit(0 if result.verdict != 'fail' else 1)
|
|
482
499
|
```
|
|
@@ -752,6 +769,19 @@ table without a mapping layer. A store that forbids nullable columns fills
|
|
|
752
769
|
those nulls in at ingest, on its side of the seam. Swap the exporter for a real
|
|
753
770
|
database client without touching the runner or any consumer.
|
|
754
771
|
|
|
772
|
+
`ScoreExporter` is that seam, named: any object with an `export_scores(run,
|
|
773
|
+
comparison, **kwargs)` method returning a count. `JsonlOutboxExporter` is the
|
|
774
|
+
built-in one; an exporter that publishes to a real store belongs in the
|
|
775
|
+
package that owns that store, since it is the store that knows its own column
|
|
776
|
+
types, null policy, and transport. Swapping one for the other is a
|
|
777
|
+
constructor line, so an offline run and a live one share a code path:
|
|
778
|
+
|
|
779
|
+
```python
|
|
780
|
+
exporter = store.JsonlOutboxExporter(path) # offline
|
|
781
|
+
exporter = KafkaOutboxExporter(...) # live, from another package
|
|
782
|
+
exporter.export_scores(run, comparison, grader_types=..., judge_scales=...)
|
|
783
|
+
```
|
|
784
|
+
|
|
755
785
|
---
|
|
756
786
|
|
|
757
787
|
## The two extension seams (recap)
|
|
@@ -26,7 +26,7 @@ _ACK = re.compile(
|
|
|
26
26
|
)
|
|
27
27
|
|
|
28
28
|
|
|
29
|
-
@base.register('acknowledges_customer')
|
|
29
|
+
@base.register('acknowledges_customer', base.GraderType.HEURISTIC)
|
|
30
30
|
class AcknowledgesCustomer:
|
|
31
31
|
"""The reply must acknowledge the customer, not just brush them off.
|
|
32
32
|
|
|
@@ -59,7 +59,7 @@ class AcknowledgesCustomer:
|
|
|
59
59
|
]
|
|
60
60
|
|
|
61
61
|
|
|
62
|
-
@base.register('distinct_reply_rate')
|
|
62
|
+
@base.register('distinct_reply_rate', base.GraderType.HEURISTIC)
|
|
63
63
|
class DistinctReplyRate:
|
|
64
64
|
"""Fraction of cases whose reply is unique (mode-collapse check).
|
|
65
65
|
|
|
@@ -20,5 +20,14 @@ from evalcore.graders import (
|
|
|
20
20
|
judge,
|
|
21
21
|
numeric,
|
|
22
22
|
)
|
|
23
|
+
from evalcore.graders.base import GraderType, category_of
|
|
23
24
|
|
|
24
|
-
__all__ = [
|
|
25
|
+
__all__ = [
|
|
26
|
+
'GraderType',
|
|
27
|
+
'base',
|
|
28
|
+
'category_of',
|
|
29
|
+
'classification',
|
|
30
|
+
'deterministic',
|
|
31
|
+
'judge',
|
|
32
|
+
'numeric',
|
|
33
|
+
]
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Grader protocols and the type registry.
|
|
2
|
+
|
|
3
|
+
A grader spec is a plain dict from the suite config: ``{type, name, ...}``.
|
|
4
|
+
``build_graders`` turns a list of specs into grader instances, split into the
|
|
5
|
+
per-case and aggregate buckets the runner needs.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import enum
|
|
9
|
+
import typing
|
|
10
|
+
|
|
11
|
+
from evalcore import models
|
|
12
|
+
from evalcore.errors import ConfigError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class GraderType(enum.StrEnum):
|
|
16
|
+
"""What kind of check a grader performs, not which one.
|
|
17
|
+
|
|
18
|
+
A closed set, unlike the registry's ``type`` names, which any consumer
|
|
19
|
+
may extend. Declared once per grader at registration and read back by
|
|
20
|
+
``store.grader_lookups``; a ``StrEnum`` so it needs no serializer of its
|
|
21
|
+
own on the way to a row.
|
|
22
|
+
|
|
23
|
+
The same set is spelled out in three other places, all of which have to
|
|
24
|
+
change together: ``GraderType`` in ``internal-eval-results``, the
|
|
25
|
+
``grader_type`` ``Enum8`` in that repo's ``schema.sql``, and the deployed
|
|
26
|
+
DDL in ``schemata/clickhouse`` on GHE, which is the source of truth.
|
|
27
|
+
|
|
28
|
+
``UNKNOWN`` exists for a producer with no registry behind it. Nothing in
|
|
29
|
+
evalcore emits it: ``register`` requires a category, so a grader that
|
|
30
|
+
reaches a suite has always declared one.
|
|
31
|
+
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
UNKNOWN = 'unknown'
|
|
35
|
+
HEURISTIC = 'heuristic'
|
|
36
|
+
STATISTICAL = 'statistical'
|
|
37
|
+
LLM_AS_JUDGE = 'llm_as_judge'
|
|
38
|
+
TRAJECTORY = 'trajectory'
|
|
39
|
+
HUMAN = 'human'
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@typing.runtime_checkable
|
|
43
|
+
class Grader(typing.Protocol):
|
|
44
|
+
"""Per-case grader. Scores are averaged across cases by the runner."""
|
|
45
|
+
|
|
46
|
+
name: str
|
|
47
|
+
|
|
48
|
+
def grade(
|
|
49
|
+
self, case: models.Case, output: models.Output
|
|
50
|
+
) -> list[models.Score]: ...
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@typing.runtime_checkable
|
|
54
|
+
class AggregateGrader(typing.Protocol):
|
|
55
|
+
"""Whole-run grader for set-level metrics (P/R/F1, win-rate, ...)."""
|
|
56
|
+
|
|
57
|
+
name: str
|
|
58
|
+
|
|
59
|
+
def aggregate(
|
|
60
|
+
self, results: list[models.CaseResult]
|
|
61
|
+
) -> list[models.Score]: ...
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_REGISTRY: dict[str, type] = {}
|
|
65
|
+
|
|
66
|
+
_CATEGORIES: dict[str, GraderType] = {}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def register(
|
|
70
|
+
type_name: str, category: GraderType
|
|
71
|
+
) -> typing.Callable[[type], type]:
|
|
72
|
+
"""Class decorator registering a grader under a suite-config ``type``.
|
|
73
|
+
|
|
74
|
+
``category`` is required rather than defaulting, because it is the only
|
|
75
|
+
source of the row's ``grader_type`` and a default would be the value
|
|
76
|
+
every grader forgets to override. A grader's category belongs to its
|
|
77
|
+
implementation, not to a suite's use of it, so it is declared here and
|
|
78
|
+
not in the suite config.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
type_name: The ``type`` a suite spec names to select this grader.
|
|
82
|
+
category: What kind of check it performs.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
The decorator.
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
ConfigError: If ``type_name`` is already registered.
|
|
89
|
+
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def _decorate(cls: type) -> type:
|
|
93
|
+
if type_name in _REGISTRY:
|
|
94
|
+
raise ConfigError(f'grader type {type_name!r} already registered')
|
|
95
|
+
_REGISTRY[type_name] = cls
|
|
96
|
+
_CATEGORIES[type_name] = GraderType(category)
|
|
97
|
+
return cls
|
|
98
|
+
|
|
99
|
+
return _decorate
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def category_of(type_name: str) -> GraderType:
|
|
103
|
+
"""Return the category a grader type registered under.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
type_name: The suite spec's ``type``.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
The declared category, or ``UNKNOWN`` for a type no plug-in has
|
|
110
|
+
registered. A suite naming one cannot run - ``build_graders``
|
|
111
|
+
raises - so ``UNKNOWN`` only reaches a row when a caller builds
|
|
112
|
+
rows without loading the plug-ins that produced them.
|
|
113
|
+
|
|
114
|
+
"""
|
|
115
|
+
return _CATEGORIES.get(type_name, GraderType.UNKNOWN)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def build_graders(
|
|
119
|
+
specs: list[dict],
|
|
120
|
+
) -> tuple[list[Grader], list[AggregateGrader]]:
|
|
121
|
+
"""Instantiate grader specs, partitioned into per-case and aggregate.
|
|
122
|
+
|
|
123
|
+
Each spec's ``type`` selects a registered class; remaining keys (minus
|
|
124
|
+
``type``) are passed as keyword arguments to its constructor.
|
|
125
|
+
"""
|
|
126
|
+
per_case: list[Grader] = []
|
|
127
|
+
aggregate: list[AggregateGrader] = []
|
|
128
|
+
for spec in specs:
|
|
129
|
+
spec = dict(spec)
|
|
130
|
+
type_name = spec.pop('type')
|
|
131
|
+
if type_name not in _REGISTRY:
|
|
132
|
+
raise ConfigError(
|
|
133
|
+
f'unknown grader type {type_name!r}; '
|
|
134
|
+
f'known: {sorted(_REGISTRY)}'
|
|
135
|
+
)
|
|
136
|
+
grader = _REGISTRY[type_name](**spec)
|
|
137
|
+
if isinstance(grader, AggregateGrader):
|
|
138
|
+
aggregate.append(grader)
|
|
139
|
+
elif isinstance(grader, Grader):
|
|
140
|
+
per_case.append(grader)
|
|
141
|
+
else: # pragma: no cover - defensive
|
|
142
|
+
raise TypeError(
|
|
143
|
+
f'{type_name!r} is neither Grader nor AggregateGrader'
|
|
144
|
+
)
|
|
145
|
+
return per_case, aggregate
|
|
@@ -19,7 +19,7 @@ def _safe_div(numerator: float, denominator: float) -> float:
|
|
|
19
19
|
return numerator / denominator if denominator else 0.0
|
|
20
20
|
|
|
21
21
|
|
|
22
|
-
@base.register('classification')
|
|
22
|
+
@base.register('classification', base.GraderType.STATISTICAL)
|
|
23
23
|
class Classification:
|
|
24
24
|
"""Binary precision/recall/F1 + FN/FP rates over a labeled dataset."""
|
|
25
25
|
|
|
@@ -32,7 +32,7 @@ def _score(name: str, metric: str, case_id: str, ok: bool, detail: str):
|
|
|
32
32
|
)
|
|
33
33
|
|
|
34
34
|
|
|
35
|
-
@base.register('max_chars')
|
|
35
|
+
@base.register('max_chars', base.GraderType.HEURISTIC)
|
|
36
36
|
class MaxChars:
|
|
37
37
|
"""Assert a text field is at most ``maximum`` characters long."""
|
|
38
38
|
|
|
@@ -58,7 +58,7 @@ class MaxChars:
|
|
|
58
58
|
]
|
|
59
59
|
|
|
60
60
|
|
|
61
|
-
@base.register('regex_absent')
|
|
61
|
+
@base.register('regex_absent', base.GraderType.HEURISTIC)
|
|
62
62
|
class RegexAbsent:
|
|
63
63
|
"""Assert a text field does NOT match ``pattern`` (e.g. no tokens)."""
|
|
64
64
|
|
|
@@ -78,7 +78,7 @@ class RegexAbsent:
|
|
|
78
78
|
return [_score(self.name, self.name, case.id, ok, detail)]
|
|
79
79
|
|
|
80
80
|
|
|
81
|
-
@base.register('regex_present')
|
|
81
|
+
@base.register('regex_present', base.GraderType.HEURISTIC)
|
|
82
82
|
class RegexPresent:
|
|
83
83
|
"""Assert a text field matches EVERY pattern in ``patterns`` (all-of).
|
|
84
84
|
|
|
@@ -111,7 +111,7 @@ class RegexPresent:
|
|
|
111
111
|
return [_score(self.name, self.name, case.id, ok, detail)]
|
|
112
112
|
|
|
113
113
|
|
|
114
|
-
@base.register('non_empty')
|
|
114
|
+
@base.register('non_empty', base.GraderType.HEURISTIC)
|
|
115
115
|
class NonEmpty:
|
|
116
116
|
"""Assert a field resolves to a non-empty value."""
|
|
117
117
|
|
|
@@ -289,7 +289,7 @@ def _load_images(refs_values: list) -> list[dict]:
|
|
|
289
289
|
return images
|
|
290
290
|
|
|
291
291
|
|
|
292
|
-
@base.register('llm_judge')
|
|
292
|
+
@base.register('llm_judge', base.GraderType.LLM_AS_JUDGE)
|
|
293
293
|
class RubricJudge:
|
|
294
294
|
"""Score an output's text on rubric dimensions with an LLM judge/panel.
|
|
295
295
|
|
|
@@ -32,8 +32,9 @@ than model attribute names, so ``project`` is emitted as ``application``,
|
|
|
32
32
|
|
|
33
33
|
import json
|
|
34
34
|
import pathlib
|
|
35
|
+
import typing
|
|
35
36
|
|
|
36
|
-
from evalcore import models
|
|
37
|
+
from evalcore import graders, models
|
|
37
38
|
|
|
38
39
|
|
|
39
40
|
def write_scorecard(
|
|
@@ -210,25 +211,18 @@ def read_preferences(path: str | pathlib.Path) -> list[models.Preference]:
|
|
|
210
211
|
]
|
|
211
212
|
|
|
212
213
|
|
|
213
|
-
_GRADER_TYPES = {
|
|
214
|
-
'classification': 'statistical',
|
|
215
|
-
'llm_judge': 'llm_as_judge',
|
|
216
|
-
'max_chars': 'heuristic',
|
|
217
|
-
'non_empty': 'heuristic',
|
|
218
|
-
'numeric': 'heuristic',
|
|
219
|
-
'regex_absent': 'heuristic',
|
|
220
|
-
'regex_present': 'heuristic',
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
|
|
224
214
|
def grader_lookups(specs: list[dict]) -> tuple[dict[str, str], dict[str, int]]:
|
|
225
215
|
"""Grader name to category, and grader name to judge scale.
|
|
226
216
|
|
|
227
217
|
A ``Score`` names the grader that emitted it but carries neither the
|
|
228
218
|
grader's category nor the scale its raw judge points sit on, so the caller
|
|
229
|
-
builds both from ``suite.graders`` and hands them to :func:`score_rows`.
|
|
230
|
-
|
|
231
|
-
|
|
219
|
+
builds both from ``suite.graders`` and hands them to :func:`score_rows`.
|
|
220
|
+
|
|
221
|
+
The category comes from the registry, where each grader declared it at
|
|
222
|
+
registration, so a consumer plug-in is categorised the same way a built-in
|
|
223
|
+
is. A type nothing has registered lands on ``'unknown'`` rather than being
|
|
224
|
+
guessed at, which in practice means the caller built rows without
|
|
225
|
+
importing the plug-ins: a suite naming an unregistered type cannot run.
|
|
232
226
|
"""
|
|
233
227
|
types: dict[str, str] = {}
|
|
234
228
|
scales: dict[str, int] = {}
|
|
@@ -237,7 +231,7 @@ def grader_lookups(specs: list[dict]) -> tuple[dict[str, str], dict[str, int]]:
|
|
|
237
231
|
if not name:
|
|
238
232
|
continue
|
|
239
233
|
grader_type = spec.get('type', '')
|
|
240
|
-
types[name] =
|
|
234
|
+
types[name] = str(graders.category_of(grader_type))
|
|
241
235
|
if grader_type == 'llm_judge':
|
|
242
236
|
scales[name] = int(spec.get('scale', 5))
|
|
243
237
|
return types, scales
|
|
@@ -518,12 +512,56 @@ def score_rows(
|
|
|
518
512
|
return rows
|
|
519
513
|
|
|
520
514
|
|
|
515
|
+
@typing.runtime_checkable
|
|
516
|
+
class ScoreExporter(typing.Protocol):
|
|
517
|
+
"""Where a run's score rows go.
|
|
518
|
+
|
|
519
|
+
The seam between the engine and a results store. :func:`score_rows`
|
|
520
|
+
produces the rows; an exporter decides what happens to them. Implement
|
|
521
|
+
this to publish to a real store - a Kafka producer, a database client -
|
|
522
|
+
without the runner or any consumer changing.
|
|
523
|
+
|
|
524
|
+
An implementation lives with the store it targets, not here. It is the
|
|
525
|
+
store that knows its own column types, its null policy, and its
|
|
526
|
+
transport; the engine only knows the row.
|
|
527
|
+
|
|
528
|
+
Swapping one for another is a constructor line at the call site, so an
|
|
529
|
+
offline run and a live one share the same code path::
|
|
530
|
+
|
|
531
|
+
exporter = JsonlOutboxExporter(path) # offline
|
|
532
|
+
exporter = KafkaOutboxExporter(...) # live, from another package
|
|
533
|
+
exporter.export_scores(run, comparison, grader_types=..., ...)
|
|
534
|
+
|
|
535
|
+
"""
|
|
536
|
+
|
|
537
|
+
def export_scores(
|
|
538
|
+
self,
|
|
539
|
+
run: models.RunResult,
|
|
540
|
+
comparison: models.Comparison | None = None,
|
|
541
|
+
**kwargs,
|
|
542
|
+
) -> int:
|
|
543
|
+
"""Export a run's rows and return how many were exported.
|
|
544
|
+
|
|
545
|
+
Args:
|
|
546
|
+
run: The completed run.
|
|
547
|
+
comparison: The gate result, on the candidate half of a gate;
|
|
548
|
+
left off the baseline half, which was not itself gated.
|
|
549
|
+
kwargs: Passed through to :func:`score_rows` - ``baseline_run_id``,
|
|
550
|
+
``grader_types`` and ``judge_scales``.
|
|
551
|
+
|
|
552
|
+
Returns:
|
|
553
|
+
The number of rows exported.
|
|
554
|
+
|
|
555
|
+
"""
|
|
556
|
+
...
|
|
557
|
+
|
|
558
|
+
|
|
521
559
|
class JsonlOutboxExporter:
|
|
522
560
|
"""Append score rows to a JSONL outbox for a shipper to drain.
|
|
523
561
|
|
|
524
562
|
A no-network stand-in for direct ingestion: real deployments point a
|
|
525
|
-
shipper at this file, or
|
|
526
|
-
|
|
563
|
+
shipper at this file, or swap in another :class:`ScoreExporter` that
|
|
564
|
+
writes to the store directly.
|
|
527
565
|
"""
|
|
528
566
|
|
|
529
567
|
def __init__(self, outbox_path: str | pathlib.Path):
|
|
@@ -33,7 +33,7 @@ class _RecordingAdapter:
|
|
|
33
33
|
self.closed = True
|
|
34
34
|
|
|
35
35
|
|
|
36
|
-
@graders_base.register('_modewatch')
|
|
36
|
+
@graders_base.register('_modewatch', graders_base.GraderType.HEURISTIC)
|
|
37
37
|
class _ModeWatchGrader:
|
|
38
38
|
"""A per-case grader that records the run mode via set_mode."""
|
|
39
39
|
|
|
@@ -60,7 +60,7 @@ class _ModeWatchGrader:
|
|
|
60
60
|
]
|
|
61
61
|
|
|
62
62
|
|
|
63
|
-
@graders_base.register('_pin')
|
|
63
|
+
@graders_base.register('_pin', graders_base.GraderType.HEURISTIC)
|
|
64
64
|
class _PinGrader:
|
|
65
65
|
"""A grader exposing a judge_version pin, like the LLM judge does."""
|
|
66
66
|
|
|
@@ -5,7 +5,7 @@ import pathlib
|
|
|
5
5
|
import tempfile
|
|
6
6
|
import unittest
|
|
7
7
|
|
|
8
|
-
from evalcore import models, store
|
|
8
|
+
from evalcore import errors, graders, models, store
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
def _run(with_failure: bool = False):
|
|
@@ -315,6 +315,58 @@ class GraderLookupTests(unittest.TestCase):
|
|
|
315
315
|
types, _ = store.grader_lookups([{'type': 'non_empty'}])
|
|
316
316
|
self.assertEqual(types['non_empty'], 'heuristic')
|
|
317
317
|
|
|
318
|
+
def test_a_plugin_is_categorised_like_a_built_in(self):
|
|
319
|
+
"""The point of the registry lookup: a consumer grader that declares
|
|
320
|
+
its category reads back as that category, not as 'unknown'.
|
|
321
|
+
"""
|
|
322
|
+
|
|
323
|
+
@graders.base.register('_store_plugin', graders.GraderType.HEURISTIC)
|
|
324
|
+
class _Plugin:
|
|
325
|
+
name = '_store_plugin'
|
|
326
|
+
|
|
327
|
+
def grade(self, case, output): # pragma: no cover - not run
|
|
328
|
+
return []
|
|
329
|
+
|
|
330
|
+
types, _ = store.grader_lookups([{'type': '_store_plugin'}])
|
|
331
|
+
self.assertEqual(types['_store_plugin'], 'heuristic')
|
|
332
|
+
|
|
333
|
+
def test_a_type_cannot_register_twice(self):
|
|
334
|
+
with self.assertRaises(errors.ConfigError):
|
|
335
|
+
|
|
336
|
+
@graders.base.register('non_empty', graders.GraderType.HEURISTIC)
|
|
337
|
+
class _Clash:
|
|
338
|
+
name = 'non_empty'
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
class ScoreExporterProtocolTests(unittest.TestCase):
|
|
342
|
+
"""The seam another package implements to publish to a real store."""
|
|
343
|
+
|
|
344
|
+
def test_the_builtin_exporter_satisfies_it(self):
|
|
345
|
+
exporter = store.JsonlOutboxExporter('/dev/null')
|
|
346
|
+
self.assertIsInstance(exporter, store.ScoreExporter)
|
|
347
|
+
|
|
348
|
+
def test_an_outside_implementation_satisfies_it(self):
|
|
349
|
+
"""A store-side exporter: same method, its own transport."""
|
|
350
|
+
|
|
351
|
+
class Collecting:
|
|
352
|
+
def __init__(self):
|
|
353
|
+
self.rows = []
|
|
354
|
+
|
|
355
|
+
def export_scores(self, run, comparison=None, **kwargs):
|
|
356
|
+
self.rows = store.score_rows(run, comparison, **kwargs)
|
|
357
|
+
return len(self.rows)
|
|
358
|
+
|
|
359
|
+
exporter = Collecting()
|
|
360
|
+
self.assertIsInstance(exporter, store.ScoreExporter)
|
|
361
|
+
self.assertEqual(exporter.export_scores(_run()), len(exporter.rows))
|
|
362
|
+
|
|
363
|
+
def test_missing_the_method_does_not(self):
|
|
364
|
+
class NotAnExporter:
|
|
365
|
+
def export(self, run): # pragma: no cover - never called
|
|
366
|
+
return 0
|
|
367
|
+
|
|
368
|
+
self.assertNotIsInstance(NotAnExporter(), store.ScoreExporter)
|
|
369
|
+
|
|
318
370
|
|
|
319
371
|
class ExporterTests(unittest.TestCase):
|
|
320
372
|
def test_export_scores(self):
|
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
"""Grader protocols and the type registry.
|
|
2
|
-
|
|
3
|
-
A grader spec is a plain dict from the suite config: ``{type, name, ...}``.
|
|
4
|
-
``build_graders`` turns a list of specs into grader instances, split into the
|
|
5
|
-
per-case and aggregate buckets the runner needs.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
import typing
|
|
9
|
-
|
|
10
|
-
from evalcore import models
|
|
11
|
-
from evalcore.errors import ConfigError
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
@typing.runtime_checkable
|
|
15
|
-
class Grader(typing.Protocol):
|
|
16
|
-
"""Per-case grader. Scores are averaged across cases by the runner."""
|
|
17
|
-
|
|
18
|
-
name: str
|
|
19
|
-
|
|
20
|
-
def grade(
|
|
21
|
-
self, case: models.Case, output: models.Output
|
|
22
|
-
) -> list[models.Score]: ...
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
@typing.runtime_checkable
|
|
26
|
-
class AggregateGrader(typing.Protocol):
|
|
27
|
-
"""Whole-run grader for set-level metrics (P/R/F1, win-rate, ...)."""
|
|
28
|
-
|
|
29
|
-
name: str
|
|
30
|
-
|
|
31
|
-
def aggregate(
|
|
32
|
-
self, results: list[models.CaseResult]
|
|
33
|
-
) -> list[models.Score]: ...
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
_REGISTRY: dict[str, type] = {}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def register(type_name: str) -> typing.Callable[[type], type]:
|
|
40
|
-
"""Class decorator registering a grader under a suite-config ``type``."""
|
|
41
|
-
|
|
42
|
-
def _decorate(cls: type) -> type:
|
|
43
|
-
if type_name in _REGISTRY:
|
|
44
|
-
raise ConfigError(f'grader type {type_name!r} already registered')
|
|
45
|
-
_REGISTRY[type_name] = cls
|
|
46
|
-
return cls
|
|
47
|
-
|
|
48
|
-
return _decorate
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def build_graders(
|
|
52
|
-
specs: list[dict],
|
|
53
|
-
) -> tuple[list[Grader], list[AggregateGrader]]:
|
|
54
|
-
"""Instantiate grader specs, partitioned into per-case and aggregate.
|
|
55
|
-
|
|
56
|
-
Each spec's ``type`` selects a registered class; remaining keys (minus
|
|
57
|
-
``type``) are passed as keyword arguments to its constructor.
|
|
58
|
-
"""
|
|
59
|
-
per_case: list[Grader] = []
|
|
60
|
-
aggregate: list[AggregateGrader] = []
|
|
61
|
-
for spec in specs:
|
|
62
|
-
spec = dict(spec)
|
|
63
|
-
type_name = spec.pop('type')
|
|
64
|
-
if type_name not in _REGISTRY:
|
|
65
|
-
raise ConfigError(
|
|
66
|
-
f'unknown grader type {type_name!r}; '
|
|
67
|
-
f'known: {sorted(_REGISTRY)}'
|
|
68
|
-
)
|
|
69
|
-
grader = _REGISTRY[type_name](**spec)
|
|
70
|
-
if isinstance(grader, AggregateGrader):
|
|
71
|
-
aggregate.append(grader)
|
|
72
|
-
elif isinstance(grader, Grader):
|
|
73
|
-
per_case.append(grader)
|
|
74
|
-
else: # pragma: no cover - defensive
|
|
75
|
-
raise TypeError(
|
|
76
|
-
f'{type_name!r} is neither Grader nor AggregateGrader'
|
|
77
|
-
)
|
|
78
|
-
return per_case, aggregate
|
|
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
|