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.
Files changed (77) hide show
  1. {evalcore-2.0.0 → evalcore-2.2.0}/CHANGELOG.md +61 -1
  2. {evalcore-2.0.0 → evalcore-2.2.0}/PKG-INFO +35 -5
  3. {evalcore-2.0.0 → evalcore-2.2.0}/README.md +34 -4
  4. {evalcore-2.0.0 → evalcore-2.2.0}/examples/quickstart/graders.py +2 -2
  5. {evalcore-2.0.0 → evalcore-2.2.0}/pyproject.toml +1 -1
  6. {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/graders/__init__.py +10 -1
  7. evalcore-2.2.0/src/evalcore/graders/base.py +145 -0
  8. {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/graders/classification.py +1 -1
  9. {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/graders/deterministic.py +4 -4
  10. {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/graders/judge.py +1 -1
  11. {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/graders/numeric.py +1 -1
  12. {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/store.py +56 -18
  13. {evalcore-2.0.0 → evalcore-2.2.0}/tests/test_runner.py +2 -2
  14. {evalcore-2.0.0 → evalcore-2.2.0}/tests/test_store.py +53 -1
  15. {evalcore-2.0.0 → evalcore-2.2.0}/uv.lock +1 -1
  16. evalcore-2.0.0/src/evalcore/graders/base.py +0 -78
  17. {evalcore-2.0.0 → evalcore-2.2.0}/.github/workflows/ci.yml +0 -0
  18. {evalcore-2.0.0 → evalcore-2.2.0}/.github/workflows/publish.yml +0 -0
  19. {evalcore-2.0.0 → evalcore-2.2.0}/.gitignore +0 -0
  20. {evalcore-2.0.0 → evalcore-2.2.0}/.pre-commit-config.yaml +0 -0
  21. {evalcore-2.0.0 → evalcore-2.2.0}/LICENSE +0 -0
  22. {evalcore-2.0.0 → evalcore-2.2.0}/docs/design.md +0 -0
  23. {evalcore-2.0.0 → evalcore-2.2.0}/examples/__init__.py +0 -0
  24. {evalcore-2.0.0 → evalcore-2.2.0}/examples/quickstart/README.md +0 -0
  25. {evalcore-2.0.0 → evalcore-2.2.0}/examples/quickstart/__init__.py +0 -0
  26. {evalcore-2.0.0 → evalcore-2.2.0}/examples/quickstart/adapter.py +0 -0
  27. {evalcore-2.0.0 → evalcore-2.2.0}/examples/quickstart/datasets/support_reply/v1/cases/angry_complaint.yaml +0 -0
  28. {evalcore-2.0.0 → evalcore-2.2.0}/examples/quickstart/datasets/support_reply/v1/cases/billing_question.yaml +0 -0
  29. {evalcore-2.0.0 → evalcore-2.2.0}/examples/quickstart/datasets/support_reply/v1/cases/double_charge.yaml +0 -0
  30. {evalcore-2.0.0 → evalcore-2.2.0}/examples/quickstart/datasets/support_reply/v1/cases/refund_request.yaml +0 -0
  31. {evalcore-2.0.0 → evalcore-2.2.0}/examples/quickstart/fixtures/support_reply_judge.yaml +0 -0
  32. {evalcore-2.0.0 → evalcore-2.2.0}/examples/quickstart/fixtures/support_reply_pairwise.yaml +0 -0
  33. {evalcore-2.0.0 → evalcore-2.2.0}/examples/quickstart/fixtures/support_reply_replay.yaml +0 -0
  34. {evalcore-2.0.0 → evalcore-2.2.0}/examples/quickstart/run_eval.py +0 -0
  35. {evalcore-2.0.0 → evalcore-2.2.0}/examples/quickstart/suite.yaml +0 -0
  36. {evalcore-2.0.0 → evalcore-2.2.0}/examples/quickstart/tests/__init__.py +0 -0
  37. {evalcore-2.0.0 → evalcore-2.2.0}/examples/quickstart/tests/test_quickstart.py +0 -0
  38. {evalcore-2.0.0 → evalcore-2.2.0}/justfile +0 -0
  39. {evalcore-2.0.0 → evalcore-2.2.0}/pyrightconfig.json +0 -0
  40. {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/__init__.py +0 -0
  41. {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/adapters/__init__.py +0 -0
  42. {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/adapters/base.py +0 -0
  43. {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/adapters/env.py +0 -0
  44. {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/adapters/http.py +0 -0
  45. {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/adapters/replay.py +0 -0
  46. {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/cli.py +0 -0
  47. {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/compare.py +0 -0
  48. {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/errors.py +0 -0
  49. {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/loader.py +0 -0
  50. {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/models.py +0 -0
  51. {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/pairwise.py +0 -0
  52. {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/py.typed +0 -0
  53. {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/rating.py +0 -0
  54. {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/refs.py +0 -0
  55. {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/report.py +0 -0
  56. {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/reporters/__init__.py +0 -0
  57. {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/reporters/base.py +0 -0
  58. {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/reporters/html.py +0 -0
  59. {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/reporters/markdown.py +0 -0
  60. {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/retry.py +0 -0
  61. {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/runner.py +0 -0
  62. {evalcore-2.0.0 → evalcore-2.2.0}/src/evalcore/sweep.py +0 -0
  63. {evalcore-2.0.0 → evalcore-2.2.0}/tests/__init__.py +0 -0
  64. {evalcore-2.0.0 → evalcore-2.2.0}/tests/test_adapters.py +0 -0
  65. {evalcore-2.0.0 → evalcore-2.2.0}/tests/test_cli.py +0 -0
  66. {evalcore-2.0.0 → evalcore-2.2.0}/tests/test_edge_cases.py +0 -0
  67. {evalcore-2.0.0 → evalcore-2.2.0}/tests/test_judge.py +0 -0
  68. {evalcore-2.0.0 → evalcore-2.2.0}/tests/test_judge_extra.py +0 -0
  69. {evalcore-2.0.0 → evalcore-2.2.0}/tests/test_live_clients.py +0 -0
  70. {evalcore-2.0.0 → evalcore-2.2.0}/tests/test_pairwise_extra.py +0 -0
  71. {evalcore-2.0.0 → evalcore-2.2.0}/tests/test_rating.py +0 -0
  72. {evalcore-2.0.0 → evalcore-2.2.0}/tests/test_rating_server.py +0 -0
  73. {evalcore-2.0.0 → evalcore-2.2.0}/tests/test_reporters.py +0 -0
  74. {evalcore-2.0.0 → evalcore-2.2.0}/tests/test_retry.py +0 -0
  75. {evalcore-2.0.0 → evalcore-2.2.0}/tests/test_sweep_pairwise.py +0 -0
  76. {evalcore-2.0.0 → evalcore-2.2.0}/tests/test_unit.py +0 -0
  77. {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/1.0.0...HEAD
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.0.0
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
- 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. 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
- 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. 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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "evalcore"
3
- version = "2.0.0"
3
+ version = "2.2.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"
@@ -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__ = ['base', 'classification', 'deterministic', 'judge', 'numeric']
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
 
@@ -48,7 +48,7 @@ def _as_float(value) -> float | None:
48
48
  return None
49
49
 
50
50
 
51
- @base.register('numeric')
51
+ @base.register('numeric', base.GraderType.HEURISTIC)
52
52
  class Numeric:
53
53
  """Surface numeric output fields as metrics, optionally range-checked."""
54
54
 
@@ -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`. A
230
- grader type the registry does not know lands on ``'unknown'`` rather than
231
- being guessed at.
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] = _GRADER_TYPES.get(grader_type, 'unknown')
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 replace this class with a database client
526
- implementing the same ``export_scores`` method.
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):
@@ -188,7 +188,7 @@ wheels = [
188
188
 
189
189
  [[package]]
190
190
  name = "evalcore"
191
- version = "2.0.0"
191
+ version = "2.2.0"
192
192
  source = { editable = "." }
193
193
  dependencies = [
194
194
  { name = "pydantic" },
@@ -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