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.
Files changed (76) hide show
  1. {evalcore-2.1.0 → evalcore-2.3.0}/CHANGELOG.md +49 -1
  2. {evalcore-2.1.0 → evalcore-2.3.0}/PKG-INFO +36 -5
  3. {evalcore-2.1.0 → evalcore-2.3.0}/README.md +35 -4
  4. {evalcore-2.1.0 → evalcore-2.3.0}/examples/quickstart/graders.py +2 -2
  5. {evalcore-2.1.0 → evalcore-2.3.0}/pyproject.toml +1 -1
  6. {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/graders/base.py +4 -0
  7. {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/models.py +28 -0
  8. {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/runner.py +30 -0
  9. {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/store.py +63 -6
  10. {evalcore-2.1.0 → evalcore-2.3.0}/tests/test_store.py +86 -0
  11. {evalcore-2.1.0 → evalcore-2.3.0}/uv.lock +1 -1
  12. {evalcore-2.1.0 → evalcore-2.3.0}/.github/workflows/ci.yml +0 -0
  13. {evalcore-2.1.0 → evalcore-2.3.0}/.github/workflows/publish.yml +0 -0
  14. {evalcore-2.1.0 → evalcore-2.3.0}/.gitignore +0 -0
  15. {evalcore-2.1.0 → evalcore-2.3.0}/.pre-commit-config.yaml +0 -0
  16. {evalcore-2.1.0 → evalcore-2.3.0}/LICENSE +0 -0
  17. {evalcore-2.1.0 → evalcore-2.3.0}/docs/design.md +0 -0
  18. {evalcore-2.1.0 → evalcore-2.3.0}/examples/__init__.py +0 -0
  19. {evalcore-2.1.0 → evalcore-2.3.0}/examples/quickstart/README.md +0 -0
  20. {evalcore-2.1.0 → evalcore-2.3.0}/examples/quickstart/__init__.py +0 -0
  21. {evalcore-2.1.0 → evalcore-2.3.0}/examples/quickstart/adapter.py +0 -0
  22. {evalcore-2.1.0 → evalcore-2.3.0}/examples/quickstart/datasets/support_reply/v1/cases/angry_complaint.yaml +0 -0
  23. {evalcore-2.1.0 → evalcore-2.3.0}/examples/quickstart/datasets/support_reply/v1/cases/billing_question.yaml +0 -0
  24. {evalcore-2.1.0 → evalcore-2.3.0}/examples/quickstart/datasets/support_reply/v1/cases/double_charge.yaml +0 -0
  25. {evalcore-2.1.0 → evalcore-2.3.0}/examples/quickstart/datasets/support_reply/v1/cases/refund_request.yaml +0 -0
  26. {evalcore-2.1.0 → evalcore-2.3.0}/examples/quickstart/fixtures/support_reply_judge.yaml +0 -0
  27. {evalcore-2.1.0 → evalcore-2.3.0}/examples/quickstart/fixtures/support_reply_pairwise.yaml +0 -0
  28. {evalcore-2.1.0 → evalcore-2.3.0}/examples/quickstart/fixtures/support_reply_replay.yaml +0 -0
  29. {evalcore-2.1.0 → evalcore-2.3.0}/examples/quickstart/run_eval.py +0 -0
  30. {evalcore-2.1.0 → evalcore-2.3.0}/examples/quickstart/suite.yaml +0 -0
  31. {evalcore-2.1.0 → evalcore-2.3.0}/examples/quickstart/tests/__init__.py +0 -0
  32. {evalcore-2.1.0 → evalcore-2.3.0}/examples/quickstart/tests/test_quickstart.py +0 -0
  33. {evalcore-2.1.0 → evalcore-2.3.0}/justfile +0 -0
  34. {evalcore-2.1.0 → evalcore-2.3.0}/pyrightconfig.json +0 -0
  35. {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/__init__.py +0 -0
  36. {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/adapters/__init__.py +0 -0
  37. {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/adapters/base.py +0 -0
  38. {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/adapters/env.py +0 -0
  39. {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/adapters/http.py +0 -0
  40. {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/adapters/replay.py +0 -0
  41. {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/cli.py +0 -0
  42. {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/compare.py +0 -0
  43. {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/errors.py +0 -0
  44. {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/graders/__init__.py +0 -0
  45. {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/graders/classification.py +0 -0
  46. {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/graders/deterministic.py +0 -0
  47. {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/graders/judge.py +0 -0
  48. {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/graders/numeric.py +0 -0
  49. {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/loader.py +0 -0
  50. {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/pairwise.py +0 -0
  51. {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/py.typed +0 -0
  52. {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/rating.py +0 -0
  53. {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/refs.py +0 -0
  54. {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/report.py +0 -0
  55. {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/reporters/__init__.py +0 -0
  56. {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/reporters/base.py +0 -0
  57. {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/reporters/html.py +0 -0
  58. {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/reporters/markdown.py +0 -0
  59. {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/retry.py +0 -0
  60. {evalcore-2.1.0 → evalcore-2.3.0}/src/evalcore/sweep.py +0 -0
  61. {evalcore-2.1.0 → evalcore-2.3.0}/tests/__init__.py +0 -0
  62. {evalcore-2.1.0 → evalcore-2.3.0}/tests/test_adapters.py +0 -0
  63. {evalcore-2.1.0 → evalcore-2.3.0}/tests/test_cli.py +0 -0
  64. {evalcore-2.1.0 → evalcore-2.3.0}/tests/test_edge_cases.py +0 -0
  65. {evalcore-2.1.0 → evalcore-2.3.0}/tests/test_judge.py +0 -0
  66. {evalcore-2.1.0 → evalcore-2.3.0}/tests/test_judge_extra.py +0 -0
  67. {evalcore-2.1.0 → evalcore-2.3.0}/tests/test_live_clients.py +0 -0
  68. {evalcore-2.1.0 → evalcore-2.3.0}/tests/test_pairwise_extra.py +0 -0
  69. {evalcore-2.1.0 → evalcore-2.3.0}/tests/test_rating.py +0 -0
  70. {evalcore-2.1.0 → evalcore-2.3.0}/tests/test_rating_server.py +0 -0
  71. {evalcore-2.1.0 → evalcore-2.3.0}/tests/test_reporters.py +0 -0
  72. {evalcore-2.1.0 → evalcore-2.3.0}/tests/test_retry.py +0 -0
  73. {evalcore-2.1.0 → evalcore-2.3.0}/tests/test_runner.py +0 -0
  74. {evalcore-2.1.0 → evalcore-2.3.0}/tests/test_sweep_pairwise.py +0 -0
  75. {evalcore-2.1.0 → evalcore-2.3.0}/tests/test_unit.py +0 -0
  76. {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.1.0...HEAD
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.1.0
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
- result = compare.compare(baseline, candidate, suite.thresholds)
506
- print(report.render_scorecard(candidate))
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
- store.JsonlOutboxExporter('outbox.jsonl').export(candidate)
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
- result = compare.compare(baseline, candidate, suite.thresholds)
474
- print(report.render_scorecard(candidate))
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
- store.JsonlOutboxExporter('outbox.jsonl').export(candidate)
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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "evalcore"
3
- version = "2.1.0"
3
+ version = "2.3.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"
@@ -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
- ``grader_types`` and ``judge_scales`` are grader-name lookups the caller
405
- builds from the suite, since a ``Score`` carries neither.
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
- types = grader_types or {}
415
- scales = judge_scales or {}
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 replace this class with a database client
519
- implementing the same ``export_scores`` method.
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:
@@ -188,7 +188,7 @@ wheels = [
188
188
 
189
189
  [[package]]
190
190
  name = "evalcore"
191
- version = "2.1.0"
191
+ version = "2.3.0"
192
192
  source = { editable = "." }
193
193
  dependencies = [
194
194
  { name = "pydantic" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes