evalcore 2.1.0__tar.gz → 2.3.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.1.0 → evalcore-2.3.0}/CHANGELOG.md +49 -1
- {evalcore-2.1.0 → evalcore-2.3.0}/PKG-INFO +36 -5
- {evalcore-2.1.0 → evalcore-2.3.0}/README.md +35 -4
- {evalcore-2.1.0 → evalcore-2.3.0}/examples/quickstart/graders.py +2 -2
- {evalcore-2.1.0 → evalcore-2.3.0}/pyproject.toml +1 -1
- {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/graders/base.py +4 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/models.py +28 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/runner.py +30 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/store.py +63 -6
- {evalcore-2.1.0 → evalcore-2.3.0}/tests/test_store.py +86 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/uv.lock +1 -1
- {evalcore-2.1.0 → evalcore-2.3.0}/.github/workflows/ci.yml +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/.github/workflows/publish.yml +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/.gitignore +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/.pre-commit-config.yaml +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/LICENSE +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/docs/design.md +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/examples/__init__.py +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/examples/quickstart/README.md +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/examples/quickstart/__init__.py +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/examples/quickstart/adapter.py +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/examples/quickstart/datasets/support_reply/v1/cases/angry_complaint.yaml +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/examples/quickstart/datasets/support_reply/v1/cases/billing_question.yaml +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/examples/quickstart/datasets/support_reply/v1/cases/double_charge.yaml +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/examples/quickstart/datasets/support_reply/v1/cases/refund_request.yaml +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/examples/quickstart/fixtures/support_reply_judge.yaml +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/examples/quickstart/fixtures/support_reply_pairwise.yaml +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/examples/quickstart/fixtures/support_reply_replay.yaml +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/examples/quickstart/run_eval.py +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/examples/quickstart/suite.yaml +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/examples/quickstart/tests/__init__.py +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/examples/quickstart/tests/test_quickstart.py +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/justfile +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/pyrightconfig.json +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/__init__.py +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/adapters/__init__.py +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/adapters/base.py +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/adapters/env.py +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/adapters/http.py +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/adapters/replay.py +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/cli.py +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/compare.py +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/errors.py +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/graders/__init__.py +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/graders/classification.py +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/graders/deterministic.py +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/graders/judge.py +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/graders/numeric.py +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/loader.py +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/pairwise.py +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/py.typed +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/rating.py +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/refs.py +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/report.py +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/reporters/__init__.py +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/reporters/base.py +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/reporters/html.py +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/reporters/markdown.py +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/retry.py +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/sweep.py +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/tests/__init__.py +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/tests/test_adapters.py +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/tests/test_cli.py +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/tests/test_edge_cases.py +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/tests/test_judge.py +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/tests/test_judge_extra.py +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/tests/test_live_clients.py +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/tests/test_pairwise_extra.py +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/tests/test_rating.py +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/tests/test_rating_server.py +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/tests/test_reporters.py +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/tests/test_retry.py +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/tests/test_runner.py +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/tests/test_sweep_pairwise.py +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/tests/test_unit.py +0 -0
- {evalcore-2.1.0 → evalcore-2.3.0}/uv.toml +0 -0
|
@@ -6,6 +6,52 @@ All notable changes to this project are documented here. The format is based on
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [2.3.0] - 2026-08-08
|
|
10
|
+
|
|
11
|
+
A run now describes its own scores.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- `RunResult.graders`, a map of grader name to `models.GraderInfo`
|
|
15
|
+
(`category`, `scale`), filled in by the runner from the graders it built.
|
|
16
|
+
A `Score` names the grader that emitted it and nothing else, so a run could
|
|
17
|
+
not previously answer for itself: a `run.json` written by one CI step and
|
|
18
|
+
published by a later one needed the suite file alongside it, and without one
|
|
19
|
+
every grader reported `unknown` and a suite with a judge could not be
|
|
20
|
+
published at all. Run-grain rather than score-grain - one entry per grader,
|
|
21
|
+
not two fields repeated across a couple of hundred rows.
|
|
22
|
+
- `models.GraderInfo`.
|
|
23
|
+
- `register` also records the category on the decorated class as
|
|
24
|
+
`grader_category`, so a grader instance can answer for itself. The runner
|
|
25
|
+
reads it the same way it already collects `judge_version`.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
- `store.score_rows` takes the grader category and judge scale from
|
|
29
|
+
`run.graders`. `grader_types` and `judge_scales` still override it, and are
|
|
30
|
+
how a run written before this release - whose map is empty - publishes
|
|
31
|
+
correctly. `grader_lookups` is unchanged and still builds them from a suite.
|
|
32
|
+
|
|
33
|
+
## [2.2.0] - 2026-08-08
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
- `store.ScoreExporter`, a `runtime_checkable` Protocol naming the seam
|
|
37
|
+
`JsonlOutboxExporter` already occupied. `store.py` has always said to
|
|
38
|
+
"replace this class with a database client implementing the same
|
|
39
|
+
`export_scores` method", but the contract was a docstring sentence and
|
|
40
|
+
`**kwargs`, so an implementation had to duck-type a private shape. An
|
|
41
|
+
exporter belongs in the package that owns the store it targets - it is the
|
|
42
|
+
store that knows its own column types, null policy, and transport - and
|
|
43
|
+
swapping one for another is now a constructor line at the call site, so an
|
|
44
|
+
offline run and a live one share a code path.
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
- `examples/quickstart/graders.py` still used the one-argument `register` and
|
|
48
|
+
raised `TypeError` on import, so 2.1.0 shipped with its own bundled example
|
|
49
|
+
broken. `just test` does not run the example; `just test-all` does.
|
|
50
|
+
- The README's Python API example called a `.export()` that does not exist and
|
|
51
|
+
passed `RunResult`s to `compare.compare` and `render_scorecard`, which take
|
|
52
|
+
`Scorecard`s. It now runs verbatim, and shows `grader_lookups` feeding the
|
|
53
|
+
exporter.
|
|
54
|
+
|
|
9
55
|
## [2.1.0] - 2026-08-07
|
|
10
56
|
|
|
11
57
|
A grader declares what kind of check it is at registration, so a consumer
|
|
@@ -174,7 +220,9 @@ by semantic versioning: a breaking change to either means a 2.0.
|
|
|
174
220
|
rating + ranking with judge agreement, Markdown/HTML reporters, JSON +
|
|
175
221
|
column-store outbox, and content-hash provenance.
|
|
176
222
|
|
|
177
|
-
[Unreleased]: https://github.com/scottpmiller/evalcore/compare/2.
|
|
223
|
+
[Unreleased]: https://github.com/scottpmiller/evalcore/compare/2.3.0...HEAD
|
|
224
|
+
[2.3.0]: https://github.com/scottpmiller/evalcore/compare/2.2.0...2.3.0
|
|
225
|
+
[2.2.0]: https://github.com/scottpmiller/evalcore/compare/2.1.0...2.2.0
|
|
178
226
|
[2.1.0]: https://github.com/scottpmiller/evalcore/compare/2.0.0...2.1.0
|
|
179
227
|
[2.0.0]: https://github.com/scottpmiller/evalcore/compare/1.0.0...2.0.0
|
|
180
228
|
[1.0.0]: https://github.com/scottpmiller/evalcore/compare/0.3.0...1.0.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: evalcore
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.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,23 @@ 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. A run carries what its graders are, so nothing else is needed.
|
|
517
|
+
# The baseline half exports without the comparison, since it was not itself
|
|
518
|
+
# gated.
|
|
519
|
+
exporter = store.JsonlOutboxExporter('outbox.jsonl')
|
|
520
|
+
exporter.export_scores(baseline)
|
|
521
|
+
exporter.export_scores(candidate, result, baseline_run_id=baseline.run_id)
|
|
512
522
|
|
|
513
523
|
raise SystemExit(0 if result.verdict != 'fail' else 1)
|
|
514
524
|
```
|
|
@@ -784,6 +794,27 @@ table without a mapping layer. A store that forbids nullable columns fills
|
|
|
784
794
|
those nulls in at ingest, on its side of the seam. Swap the exporter for a real
|
|
785
795
|
database client without touching the runner or any consumer.
|
|
786
796
|
|
|
797
|
+
`ScoreExporter` is that seam, named: any object with an `export_scores(run,
|
|
798
|
+
comparison, **kwargs)` method returning a count. `JsonlOutboxExporter` is the
|
|
799
|
+
built-in one; an exporter that publishes to a real store belongs in the
|
|
800
|
+
package that owns that store, since it is the store that knows its own column
|
|
801
|
+
types, null policy, and transport. Swapping one for the other is a
|
|
802
|
+
constructor line, so an offline run and a live one share a code path:
|
|
803
|
+
|
|
804
|
+
```python
|
|
805
|
+
exporter = store.JsonlOutboxExporter(path) # offline
|
|
806
|
+
exporter = KafkaOutboxExporter(...) # live, from another package
|
|
807
|
+
exporter.export_scores(run, comparison)
|
|
808
|
+
```
|
|
809
|
+
|
|
810
|
+
A `RunResult` carries `graders`, a map of grader name to its category and
|
|
811
|
+
judge scale, so it describes its own scores and an exporter needs nothing but
|
|
812
|
+
the run. That matters most for an artifact: a `run.json` written by one CI
|
|
813
|
+
step and published by a later one used to need the suite file alongside it,
|
|
814
|
+
and without one every grader reported `unknown`. `grader_types` and
|
|
815
|
+
`judge_scales` still override the map, which is how a run written before
|
|
816
|
+
2.3.0 publishes correctly - `grader_lookups(suite.graders)` builds them.
|
|
817
|
+
|
|
787
818
|
---
|
|
788
819
|
|
|
789
820
|
## The two extension seams (recap)
|
|
@@ -470,13 +470,23 @@ 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. A run carries what its graders are, so nothing else is needed.
|
|
485
|
+
# The baseline half exports without the comparison, since it was not itself
|
|
486
|
+
# gated.
|
|
487
|
+
exporter = store.JsonlOutboxExporter('outbox.jsonl')
|
|
488
|
+
exporter.export_scores(baseline)
|
|
489
|
+
exporter.export_scores(candidate, result, baseline_run_id=baseline.run_id)
|
|
480
490
|
|
|
481
491
|
raise SystemExit(0 if result.verdict != 'fail' else 1)
|
|
482
492
|
```
|
|
@@ -752,6 +762,27 @@ table without a mapping layer. A store that forbids nullable columns fills
|
|
|
752
762
|
those nulls in at ingest, on its side of the seam. Swap the exporter for a real
|
|
753
763
|
database client without touching the runner or any consumer.
|
|
754
764
|
|
|
765
|
+
`ScoreExporter` is that seam, named: any object with an `export_scores(run,
|
|
766
|
+
comparison, **kwargs)` method returning a count. `JsonlOutboxExporter` is the
|
|
767
|
+
built-in one; an exporter that publishes to a real store belongs in the
|
|
768
|
+
package that owns that store, since it is the store that knows its own column
|
|
769
|
+
types, null policy, and transport. Swapping one for the other is a
|
|
770
|
+
constructor line, so an offline run and a live one share a code path:
|
|
771
|
+
|
|
772
|
+
```python
|
|
773
|
+
exporter = store.JsonlOutboxExporter(path) # offline
|
|
774
|
+
exporter = KafkaOutboxExporter(...) # live, from another package
|
|
775
|
+
exporter.export_scores(run, comparison)
|
|
776
|
+
```
|
|
777
|
+
|
|
778
|
+
A `RunResult` carries `graders`, a map of grader name to its category and
|
|
779
|
+
judge scale, so it describes its own scores and an exporter needs nothing but
|
|
780
|
+
the run. That matters most for an artifact: a `run.json` written by one CI
|
|
781
|
+
step and published by a later one used to need the suite file alongside it,
|
|
782
|
+
and without one every grader reported `unknown`. `grader_types` and
|
|
783
|
+
`judge_scales` still override the map, which is how a run written before
|
|
784
|
+
2.3.0 publishes correctly - `grader_lookups(suite.graders)` builds them.
|
|
785
|
+
|
|
755
786
|
---
|
|
756
787
|
|
|
757
788
|
## 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
|
|
|
@@ -94,6 +94,10 @@ def register(
|
|
|
94
94
|
raise ConfigError(f'grader type {type_name!r} already registered')
|
|
95
95
|
_REGISTRY[type_name] = cls
|
|
96
96
|
_CATEGORIES[type_name] = GraderType(category)
|
|
97
|
+
# Also on the class, so an instance can answer for itself. The runner
|
|
98
|
+
# reads it off the graders it built to make the run self-describing,
|
|
99
|
+
# the same way it collects `judge_version`.
|
|
100
|
+
cls.grader_category = GraderType(category)
|
|
97
101
|
return cls
|
|
98
102
|
|
|
99
103
|
return _decorate
|
|
@@ -162,6 +162,28 @@ class Scorecard(pydantic.BaseModel):
|
|
|
162
162
|
metrics: dict[str, MetricValue] = pydantic.Field(default_factory=dict)
|
|
163
163
|
|
|
164
164
|
|
|
165
|
+
class GraderInfo(pydantic.BaseModel):
|
|
166
|
+
"""What a grader is, as opposed to what it scored.
|
|
167
|
+
|
|
168
|
+
A ``Score`` names the grader that emitted it and nothing else, so a run
|
|
169
|
+
that did not carry this could not describe its own scores: publishing it
|
|
170
|
+
needed the suite file alongside it, and a ``run.json`` handed to a later
|
|
171
|
+
CI step without one reported every grader as ``unknown``.
|
|
172
|
+
|
|
173
|
+
Run-grain, not score-grain: one entry per grader rather than the same two
|
|
174
|
+
fields repeated on a couple of hundred rows.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
#: The registered category, a ``graders.GraderType`` value. A plain `str`
|
|
178
|
+
#: here because `graders.base` imports this module, so this module cannot
|
|
179
|
+
#: import it back; ``GraderType`` is a ``StrEnum`` and compares equal.
|
|
180
|
+
category: str = 'unknown'
|
|
181
|
+
#: The scale raw judge points sit on, 0 for a grader that is not a judge.
|
|
182
|
+
#: Normalization is points / scale, so the points are unreadable without
|
|
183
|
+
#: it.
|
|
184
|
+
scale: int = 0
|
|
185
|
+
|
|
186
|
+
|
|
165
187
|
class RunResult(pydantic.BaseModel):
|
|
166
188
|
"""Everything one run produced: the scorecard plus per-sample results.
|
|
167
189
|
|
|
@@ -174,12 +196,18 @@ class RunResult(pydantic.BaseModel):
|
|
|
174
196
|
over the whole run. The scorecard keeps their values but not which grader
|
|
175
197
|
emitted them or what it reported, so they are retained here too - a
|
|
176
198
|
results-store row needs the grader attribution.
|
|
199
|
+
|
|
200
|
+
``graders`` maps grader name to what that grader is, so the run is
|
|
201
|
+
self-describing: an exporter needs nothing but the run. Empty on runs
|
|
202
|
+
written before 2.3.0, which is why exporters still accept the lookups as
|
|
203
|
+
a fallback.
|
|
177
204
|
"""
|
|
178
205
|
|
|
179
206
|
run_id: str
|
|
180
207
|
scorecard: Scorecard
|
|
181
208
|
results: list[CaseResult] = pydantic.Field(default_factory=list)
|
|
182
209
|
aggregate_scores: list[Score] = pydantic.Field(default_factory=list)
|
|
210
|
+
graders: dict[str, GraderInfo] = pydantic.Field(default_factory=dict)
|
|
183
211
|
|
|
184
212
|
|
|
185
213
|
class Rating(pydantic.BaseModel):
|
|
@@ -84,6 +84,35 @@ def _uuid7() -> uuid.UUID:
|
|
|
84
84
|
)
|
|
85
85
|
|
|
86
86
|
|
|
87
|
+
def _grader_info(*grader_groups) -> dict[str, models.GraderInfo]:
|
|
88
|
+
"""What each grader is, so the run can describe its own scores.
|
|
89
|
+
|
|
90
|
+
Read off the grader instances rather than the suite config, so a run is
|
|
91
|
+
publishable from the artifact alone - no suite file alongside it. Both
|
|
92
|
+
attributes are duck-typed the way ``judge_version`` is: ``register`` puts
|
|
93
|
+
``grader_category`` on the class, and only a judge has a ``scale``.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
grader_groups: The per-case and aggregate grader lists.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Grader name to its category and judge scale.
|
|
100
|
+
|
|
101
|
+
"""
|
|
102
|
+
return {
|
|
103
|
+
grader.name: models.GraderInfo(
|
|
104
|
+
category=str(
|
|
105
|
+
getattr(
|
|
106
|
+
grader, 'grader_category', graders_base.GraderType.UNKNOWN
|
|
107
|
+
)
|
|
108
|
+
),
|
|
109
|
+
scale=int(getattr(grader, 'scale', 0) or 0),
|
|
110
|
+
)
|
|
111
|
+
for group in grader_groups
|
|
112
|
+
for grader in group
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
87
116
|
def _aggregate_metrics(
|
|
88
117
|
results: list[models.CaseResult], agg_scores: list[models.Score]
|
|
89
118
|
) -> dict[str, models.MetricValue]:
|
|
@@ -321,6 +350,7 @@ async def run_suite(
|
|
|
321
350
|
scorecard=scorecard,
|
|
322
351
|
results=results,
|
|
323
352
|
aggregate_scores=agg_scores,
|
|
353
|
+
graders=_grader_info(per_case_graders, aggregate_graders),
|
|
324
354
|
)
|
|
325
355
|
|
|
326
356
|
|
|
@@ -32,6 +32,7 @@ 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
37
|
from evalcore import graders, models
|
|
37
38
|
|
|
@@ -401,8 +402,12 @@ def score_rows(
|
|
|
401
402
|
|
|
402
403
|
``comparison`` stamps the run-grain gate columns and the per-metric ``win``
|
|
403
404
|
and ``guardrail`` fields; without it the run reads as ungated.
|
|
404
|
-
|
|
405
|
-
|
|
405
|
+
The grader category and judge scale come from ``run.graders``, which the
|
|
406
|
+
runner fills in - a ``Score`` carries neither, and a run that could not
|
|
407
|
+
answer for itself was not publishable from its own artifact.
|
|
408
|
+
``grader_types`` and ``judge_scales`` override it, and are how a run
|
|
409
|
+
written before 2.3.0 (whose map is empty) still publishes correctly;
|
|
410
|
+
:func:`grader_lookups` builds them from the suite.
|
|
406
411
|
"""
|
|
407
412
|
key = _run_key(run.scorecard)
|
|
408
413
|
gate = _gate(comparison, baseline_run_id)
|
|
@@ -411,8 +416,16 @@ def score_rows(
|
|
|
411
416
|
if comparison
|
|
412
417
|
else {}
|
|
413
418
|
)
|
|
414
|
-
|
|
415
|
-
|
|
419
|
+
# The run describes its own graders as of 2.3.0. The lookups remain for
|
|
420
|
+
# runs written before that, which carry an empty map - a `run.json` on
|
|
421
|
+
# disk outlives the release that wrote it. An explicit lookup still wins,
|
|
422
|
+
# so a caller can correct a run it did not produce.
|
|
423
|
+
types = {name: info.category for name, info in run.graders.items()}
|
|
424
|
+
scales = {
|
|
425
|
+
name: info.scale for name, info in run.graders.items() if info.scale
|
|
426
|
+
}
|
|
427
|
+
types.update(grader_types or {})
|
|
428
|
+
scales.update(judge_scales or {})
|
|
416
429
|
rows: list[dict] = []
|
|
417
430
|
scored: set[str] = set()
|
|
418
431
|
|
|
@@ -511,12 +524,56 @@ def score_rows(
|
|
|
511
524
|
return rows
|
|
512
525
|
|
|
513
526
|
|
|
527
|
+
@typing.runtime_checkable
|
|
528
|
+
class ScoreExporter(typing.Protocol):
|
|
529
|
+
"""Where a run's score rows go.
|
|
530
|
+
|
|
531
|
+
The seam between the engine and a results store. :func:`score_rows`
|
|
532
|
+
produces the rows; an exporter decides what happens to them. Implement
|
|
533
|
+
this to publish to a real store - a Kafka producer, a database client -
|
|
534
|
+
without the runner or any consumer changing.
|
|
535
|
+
|
|
536
|
+
An implementation lives with the store it targets, not here. It is the
|
|
537
|
+
store that knows its own column types, its null policy, and its
|
|
538
|
+
transport; the engine only knows the row.
|
|
539
|
+
|
|
540
|
+
Swapping one for another is a constructor line at the call site, so an
|
|
541
|
+
offline run and a live one share the same code path::
|
|
542
|
+
|
|
543
|
+
exporter = JsonlOutboxExporter(path) # offline
|
|
544
|
+
exporter = KafkaOutboxExporter(...) # live, from another package
|
|
545
|
+
exporter.export_scores(run, comparison, grader_types=..., ...)
|
|
546
|
+
|
|
547
|
+
"""
|
|
548
|
+
|
|
549
|
+
def export_scores(
|
|
550
|
+
self,
|
|
551
|
+
run: models.RunResult,
|
|
552
|
+
comparison: models.Comparison | None = None,
|
|
553
|
+
**kwargs,
|
|
554
|
+
) -> int:
|
|
555
|
+
"""Export a run's rows and return how many were exported.
|
|
556
|
+
|
|
557
|
+
Args:
|
|
558
|
+
run: The completed run.
|
|
559
|
+
comparison: The gate result, on the candidate half of a gate;
|
|
560
|
+
left off the baseline half, which was not itself gated.
|
|
561
|
+
kwargs: Passed through to :func:`score_rows` - ``baseline_run_id``,
|
|
562
|
+
``grader_types`` and ``judge_scales``.
|
|
563
|
+
|
|
564
|
+
Returns:
|
|
565
|
+
The number of rows exported.
|
|
566
|
+
|
|
567
|
+
"""
|
|
568
|
+
...
|
|
569
|
+
|
|
570
|
+
|
|
514
571
|
class JsonlOutboxExporter:
|
|
515
572
|
"""Append score rows to a JSONL outbox for a shipper to drain.
|
|
516
573
|
|
|
517
574
|
A no-network stand-in for direct ingestion: real deployments point a
|
|
518
|
-
shipper at this file, or
|
|
519
|
-
|
|
575
|
+
shipper at this file, or swap in another :class:`ScoreExporter` that
|
|
576
|
+
writes to the store directly.
|
|
520
577
|
"""
|
|
521
578
|
|
|
522
579
|
def __init__(self, outbox_path: str | pathlib.Path):
|
|
@@ -338,6 +338,92 @@ class GraderLookupTests(unittest.TestCase):
|
|
|
338
338
|
name = 'non_empty'
|
|
339
339
|
|
|
340
340
|
|
|
341
|
+
class SelfDescribingRunTests(unittest.TestCase):
|
|
342
|
+
"""A run carries what its scores mean, so an artifact is publishable."""
|
|
343
|
+
|
|
344
|
+
@staticmethod
|
|
345
|
+
def _run_with_graders():
|
|
346
|
+
run = _run()
|
|
347
|
+
run.graders = {
|
|
348
|
+
'det': models.GraderInfo(category='heuristic'),
|
|
349
|
+
'j': models.GraderInfo(category='llm_as_judge', scale=7),
|
|
350
|
+
'cls': models.GraderInfo(category='statistical'),
|
|
351
|
+
}
|
|
352
|
+
return run
|
|
353
|
+
|
|
354
|
+
def test_the_run_supplies_the_lookups(self):
|
|
355
|
+
rows = store.score_rows(self._run_with_graders())
|
|
356
|
+
types = {row['grader']: row['grader_type'] for row in rows}
|
|
357
|
+
self.assertEqual(types['det'], 'heuristic')
|
|
358
|
+
self.assertEqual(types['j'], 'llm_as_judge')
|
|
359
|
+
|
|
360
|
+
def test_a_judge_scale_comes_off_the_run(self):
|
|
361
|
+
rows = store.score_rows(self._run_with_graders())
|
|
362
|
+
judged = {r['judge_scale'] for r in rows if r['grader'] == 'j'}
|
|
363
|
+
plain = {r['judge_scale'] for r in rows if r['grader'] == 'det'}
|
|
364
|
+
self.assertEqual(judged, {7})
|
|
365
|
+
self.assertEqual(plain, {0})
|
|
366
|
+
|
|
367
|
+
def test_an_explicit_lookup_still_wins(self):
|
|
368
|
+
"""So a caller can correct a run it did not produce."""
|
|
369
|
+
rows = store.score_rows(
|
|
370
|
+
self._run_with_graders(), grader_types={'det': 'statistical'}
|
|
371
|
+
)
|
|
372
|
+
types = {row['grader']: row['grader_type'] for row in rows}
|
|
373
|
+
self.assertEqual(types['det'], 'statistical')
|
|
374
|
+
|
|
375
|
+
def test_a_run_from_before_the_field_still_publishes(self):
|
|
376
|
+
"""The map is empty on runs written by older versions, so the
|
|
377
|
+
lookups have to stay."""
|
|
378
|
+
run = _run()
|
|
379
|
+
self.assertEqual(run.graders, {})
|
|
380
|
+
rows = store.score_rows(run, grader_types={'det': 'heuristic'})
|
|
381
|
+
types = {row['grader']: row['grader_type'] for row in rows}
|
|
382
|
+
self.assertEqual(types['det'], 'heuristic')
|
|
383
|
+
|
|
384
|
+
def test_it_survives_the_json_round_trip(self):
|
|
385
|
+
"""The whole point: no suite file alongside the artifact."""
|
|
386
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
387
|
+
path = pathlib.Path(tmp) / 'run.json'
|
|
388
|
+
store.write_run(path, self._run_with_graders())
|
|
389
|
+
reloaded = store.read_run(path)
|
|
390
|
+
self.assertEqual(reloaded.graders['j'].scale, 7)
|
|
391
|
+
rows = store.score_rows(reloaded)
|
|
392
|
+
types = {row['grader']: row['grader_type'] for row in rows}
|
|
393
|
+
self.assertEqual(types['det'], 'heuristic')
|
|
394
|
+
self.assertEqual(types['j'], 'llm_as_judge')
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
class ScoreExporterProtocolTests(unittest.TestCase):
|
|
398
|
+
"""The seam another package implements to publish to a real store."""
|
|
399
|
+
|
|
400
|
+
def test_the_builtin_exporter_satisfies_it(self):
|
|
401
|
+
exporter = store.JsonlOutboxExporter('/dev/null')
|
|
402
|
+
self.assertIsInstance(exporter, store.ScoreExporter)
|
|
403
|
+
|
|
404
|
+
def test_an_outside_implementation_satisfies_it(self):
|
|
405
|
+
"""A store-side exporter: same method, its own transport."""
|
|
406
|
+
|
|
407
|
+
class Collecting:
|
|
408
|
+
def __init__(self):
|
|
409
|
+
self.rows = []
|
|
410
|
+
|
|
411
|
+
def export_scores(self, run, comparison=None, **kwargs):
|
|
412
|
+
self.rows = store.score_rows(run, comparison, **kwargs)
|
|
413
|
+
return len(self.rows)
|
|
414
|
+
|
|
415
|
+
exporter = Collecting()
|
|
416
|
+
self.assertIsInstance(exporter, store.ScoreExporter)
|
|
417
|
+
self.assertEqual(exporter.export_scores(_run()), len(exporter.rows))
|
|
418
|
+
|
|
419
|
+
def test_missing_the_method_does_not(self):
|
|
420
|
+
class NotAnExporter:
|
|
421
|
+
def export(self, run): # pragma: no cover - never called
|
|
422
|
+
return 0
|
|
423
|
+
|
|
424
|
+
self.assertNotIsInstance(NotAnExporter(), store.ScoreExporter)
|
|
425
|
+
|
|
426
|
+
|
|
341
427
|
class ExporterTests(unittest.TestCase):
|
|
342
428
|
def test_export_scores(self):
|
|
343
429
|
with tempfile.TemporaryDirectory() as tmp:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|