evalcore 2.0.0__tar.gz → 2.1.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.1.0}/CHANGELOG.md +38 -1
  2. {evalcore-2.0.0 → evalcore-2.1.0}/PKG-INFO +1 -1
  3. {evalcore-2.0.0 → evalcore-2.1.0}/pyproject.toml +1 -1
  4. {evalcore-2.0.0 → evalcore-2.1.0}/src/evalcore/graders/__init__.py +10 -1
  5. evalcore-2.1.0/src/evalcore/graders/base.py +145 -0
  6. {evalcore-2.0.0 → evalcore-2.1.0}/src/evalcore/graders/classification.py +1 -1
  7. {evalcore-2.0.0 → evalcore-2.1.0}/src/evalcore/graders/deterministic.py +4 -4
  8. {evalcore-2.0.0 → evalcore-2.1.0}/src/evalcore/graders/judge.py +1 -1
  9. {evalcore-2.0.0 → evalcore-2.1.0}/src/evalcore/graders/numeric.py +1 -1
  10. {evalcore-2.0.0 → evalcore-2.1.0}/src/evalcore/store.py +9 -16
  11. {evalcore-2.0.0 → evalcore-2.1.0}/tests/test_runner.py +2 -2
  12. {evalcore-2.0.0 → evalcore-2.1.0}/tests/test_store.py +23 -1
  13. {evalcore-2.0.0 → evalcore-2.1.0}/uv.lock +1 -1
  14. evalcore-2.0.0/src/evalcore/graders/base.py +0 -78
  15. {evalcore-2.0.0 → evalcore-2.1.0}/.github/workflows/ci.yml +0 -0
  16. {evalcore-2.0.0 → evalcore-2.1.0}/.github/workflows/publish.yml +0 -0
  17. {evalcore-2.0.0 → evalcore-2.1.0}/.gitignore +0 -0
  18. {evalcore-2.0.0 → evalcore-2.1.0}/.pre-commit-config.yaml +0 -0
  19. {evalcore-2.0.0 → evalcore-2.1.0}/LICENSE +0 -0
  20. {evalcore-2.0.0 → evalcore-2.1.0}/README.md +0 -0
  21. {evalcore-2.0.0 → evalcore-2.1.0}/docs/design.md +0 -0
  22. {evalcore-2.0.0 → evalcore-2.1.0}/examples/__init__.py +0 -0
  23. {evalcore-2.0.0 → evalcore-2.1.0}/examples/quickstart/README.md +0 -0
  24. {evalcore-2.0.0 → evalcore-2.1.0}/examples/quickstart/__init__.py +0 -0
  25. {evalcore-2.0.0 → evalcore-2.1.0}/examples/quickstart/adapter.py +0 -0
  26. {evalcore-2.0.0 → evalcore-2.1.0}/examples/quickstart/datasets/support_reply/v1/cases/angry_complaint.yaml +0 -0
  27. {evalcore-2.0.0 → evalcore-2.1.0}/examples/quickstart/datasets/support_reply/v1/cases/billing_question.yaml +0 -0
  28. {evalcore-2.0.0 → evalcore-2.1.0}/examples/quickstart/datasets/support_reply/v1/cases/double_charge.yaml +0 -0
  29. {evalcore-2.0.0 → evalcore-2.1.0}/examples/quickstart/datasets/support_reply/v1/cases/refund_request.yaml +0 -0
  30. {evalcore-2.0.0 → evalcore-2.1.0}/examples/quickstart/fixtures/support_reply_judge.yaml +0 -0
  31. {evalcore-2.0.0 → evalcore-2.1.0}/examples/quickstart/fixtures/support_reply_pairwise.yaml +0 -0
  32. {evalcore-2.0.0 → evalcore-2.1.0}/examples/quickstart/fixtures/support_reply_replay.yaml +0 -0
  33. {evalcore-2.0.0 → evalcore-2.1.0}/examples/quickstart/graders.py +0 -0
  34. {evalcore-2.0.0 → evalcore-2.1.0}/examples/quickstart/run_eval.py +0 -0
  35. {evalcore-2.0.0 → evalcore-2.1.0}/examples/quickstart/suite.yaml +0 -0
  36. {evalcore-2.0.0 → evalcore-2.1.0}/examples/quickstart/tests/__init__.py +0 -0
  37. {evalcore-2.0.0 → evalcore-2.1.0}/examples/quickstart/tests/test_quickstart.py +0 -0
  38. {evalcore-2.0.0 → evalcore-2.1.0}/justfile +0 -0
  39. {evalcore-2.0.0 → evalcore-2.1.0}/pyrightconfig.json +0 -0
  40. {evalcore-2.0.0 → evalcore-2.1.0}/src/evalcore/__init__.py +0 -0
  41. {evalcore-2.0.0 → evalcore-2.1.0}/src/evalcore/adapters/__init__.py +0 -0
  42. {evalcore-2.0.0 → evalcore-2.1.0}/src/evalcore/adapters/base.py +0 -0
  43. {evalcore-2.0.0 → evalcore-2.1.0}/src/evalcore/adapters/env.py +0 -0
  44. {evalcore-2.0.0 → evalcore-2.1.0}/src/evalcore/adapters/http.py +0 -0
  45. {evalcore-2.0.0 → evalcore-2.1.0}/src/evalcore/adapters/replay.py +0 -0
  46. {evalcore-2.0.0 → evalcore-2.1.0}/src/evalcore/cli.py +0 -0
  47. {evalcore-2.0.0 → evalcore-2.1.0}/src/evalcore/compare.py +0 -0
  48. {evalcore-2.0.0 → evalcore-2.1.0}/src/evalcore/errors.py +0 -0
  49. {evalcore-2.0.0 → evalcore-2.1.0}/src/evalcore/loader.py +0 -0
  50. {evalcore-2.0.0 → evalcore-2.1.0}/src/evalcore/models.py +0 -0
  51. {evalcore-2.0.0 → evalcore-2.1.0}/src/evalcore/pairwise.py +0 -0
  52. {evalcore-2.0.0 → evalcore-2.1.0}/src/evalcore/py.typed +0 -0
  53. {evalcore-2.0.0 → evalcore-2.1.0}/src/evalcore/rating.py +0 -0
  54. {evalcore-2.0.0 → evalcore-2.1.0}/src/evalcore/refs.py +0 -0
  55. {evalcore-2.0.0 → evalcore-2.1.0}/src/evalcore/report.py +0 -0
  56. {evalcore-2.0.0 → evalcore-2.1.0}/src/evalcore/reporters/__init__.py +0 -0
  57. {evalcore-2.0.0 → evalcore-2.1.0}/src/evalcore/reporters/base.py +0 -0
  58. {evalcore-2.0.0 → evalcore-2.1.0}/src/evalcore/reporters/html.py +0 -0
  59. {evalcore-2.0.0 → evalcore-2.1.0}/src/evalcore/reporters/markdown.py +0 -0
  60. {evalcore-2.0.0 → evalcore-2.1.0}/src/evalcore/retry.py +0 -0
  61. {evalcore-2.0.0 → evalcore-2.1.0}/src/evalcore/runner.py +0 -0
  62. {evalcore-2.0.0 → evalcore-2.1.0}/src/evalcore/sweep.py +0 -0
  63. {evalcore-2.0.0 → evalcore-2.1.0}/tests/__init__.py +0 -0
  64. {evalcore-2.0.0 → evalcore-2.1.0}/tests/test_adapters.py +0 -0
  65. {evalcore-2.0.0 → evalcore-2.1.0}/tests/test_cli.py +0 -0
  66. {evalcore-2.0.0 → evalcore-2.1.0}/tests/test_edge_cases.py +0 -0
  67. {evalcore-2.0.0 → evalcore-2.1.0}/tests/test_judge.py +0 -0
  68. {evalcore-2.0.0 → evalcore-2.1.0}/tests/test_judge_extra.py +0 -0
  69. {evalcore-2.0.0 → evalcore-2.1.0}/tests/test_live_clients.py +0 -0
  70. {evalcore-2.0.0 → evalcore-2.1.0}/tests/test_pairwise_extra.py +0 -0
  71. {evalcore-2.0.0 → evalcore-2.1.0}/tests/test_rating.py +0 -0
  72. {evalcore-2.0.0 → evalcore-2.1.0}/tests/test_rating_server.py +0 -0
  73. {evalcore-2.0.0 → evalcore-2.1.0}/tests/test_reporters.py +0 -0
  74. {evalcore-2.0.0 → evalcore-2.1.0}/tests/test_retry.py +0 -0
  75. {evalcore-2.0.0 → evalcore-2.1.0}/tests/test_sweep_pairwise.py +0 -0
  76. {evalcore-2.0.0 → evalcore-2.1.0}/tests/test_unit.py +0 -0
  77. {evalcore-2.0.0 → evalcore-2.1.0}/uv.toml +0 -0
@@ -6,6 +6,41 @@ All notable changes to this project are documented here. The format is based on
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.1.0] - 2026-08-07
10
+
11
+ A grader declares what kind of check it is at registration, so a consumer
12
+ plug-in is categorised the same way a built-in is.
13
+
14
+ Shipped as a minor despite the signature change below. `register` is public,
15
+ so the 1.0.0 policy would call this a major; it goes out as 2.1.0 as a
16
+ deliberate exception, because the break is a one-line edit per grader that
17
+ fails loudly at import.
18
+
19
+ ### Changed
20
+ - **Breaking:** `graders.base.register` takes a required second argument,
21
+ `category`, a `graders.GraderType`. Every `@base.register('foo')` becomes
22
+ `@base.register('foo', base.GraderType.HEURISTIC)` or whichever member
23
+ applies; omitting it is a `TypeError` at import. Required rather than
24
+ defaulted on purpose - it is the only source of a row's `grader_type`, and
25
+ a default would be the value every grader forgets to override.
26
+ - `store.grader_lookups` reads the category from the registry instead of a
27
+ closed table of built-in type names, so a plug-in that declares
28
+ `HEURISTIC` reports `heuristic` where it used to report `unknown`. Rows for
29
+ consumer graders change value in the `grader_type` column; nothing about
30
+ the row shape changes.
31
+
32
+ ### Added
33
+ - `graders.GraderType`, a `StrEnum` over the closed set the results store's
34
+ `grader_type` column accepts: `unknown`, `heuristic`, `statistical`,
35
+ `llm_as_judge`, `trajectory`, `human`. A `StrEnum` so it needs no
36
+ serializer of its own on the way to a row.
37
+ - `graders.category_of`, the registry lookup behind `grader_lookups`.
38
+
39
+ ### Removed
40
+ - `store._GRADER_TYPES`, the private table the categories used to live in.
41
+ Keeping it alongside the registration argument would mean two sources for
42
+ one fact and a precedence rule between them.
43
+
9
44
  ## [2.0.0] - 2026-07-31
10
45
 
11
46
  Breaks both the public API and the outbox row shape, so it's a major per the
@@ -139,7 +174,9 @@ by semantic versioning: a breaking change to either means a 2.0.
139
174
  rating + ranking with judge agreement, Markdown/HTML reporters, JSON +
140
175
  column-store outbox, and content-hash provenance.
141
176
 
142
- [Unreleased]: https://github.com/scottpmiller/evalcore/compare/1.0.0...HEAD
177
+ [Unreleased]: https://github.com/scottpmiller/evalcore/compare/2.1.0...HEAD
178
+ [2.1.0]: https://github.com/scottpmiller/evalcore/compare/2.0.0...2.1.0
179
+ [2.0.0]: https://github.com/scottpmiller/evalcore/compare/1.0.0...2.0.0
143
180
  [1.0.0]: https://github.com/scottpmiller/evalcore/compare/0.3.0...1.0.0
144
181
  [0.3.0]: https://github.com/scottpmiller/evalcore/compare/0.2.0...0.3.0
145
182
  [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.1.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "evalcore"
3
- version = "2.0.0"
3
+ version = "2.1.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
 
@@ -33,7 +33,7 @@ than model attribute names, so ``project`` is emitted as ``application``,
33
33
  import json
34
34
  import pathlib
35
35
 
36
- from evalcore import models
36
+ from evalcore import graders, models
37
37
 
38
38
 
39
39
  def write_scorecard(
@@ -210,25 +210,18 @@ def read_preferences(path: str | pathlib.Path) -> list[models.Preference]:
210
210
  ]
211
211
 
212
212
 
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
213
  def grader_lookups(specs: list[dict]) -> tuple[dict[str, str], dict[str, int]]:
225
214
  """Grader name to category, and grader name to judge scale.
226
215
 
227
216
  A ``Score`` names the grader that emitted it but carries neither the
228
217
  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.
218
+ builds both from ``suite.graders`` and hands them to :func:`score_rows`.
219
+
220
+ The category comes from the registry, where each grader declared it at
221
+ registration, so a consumer plug-in is categorised the same way a built-in
222
+ is. A type nothing has registered lands on ``'unknown'`` rather than being
223
+ guessed at, which in practice means the caller built rows without
224
+ importing the plug-ins: a suite naming an unregistered type cannot run.
232
225
  """
233
226
  types: dict[str, str] = {}
234
227
  scales: dict[str, int] = {}
@@ -237,7 +230,7 @@ def grader_lookups(specs: list[dict]) -> tuple[dict[str, str], dict[str, int]]:
237
230
  if not name:
238
231
  continue
239
232
  grader_type = spec.get('type', '')
240
- types[name] = _GRADER_TYPES.get(grader_type, 'unknown')
233
+ types[name] = str(graders.category_of(grader_type))
241
234
  if grader_type == 'llm_judge':
242
235
  scales[name] = int(spec.get('scale', 5))
243
236
  return types, scales
@@ -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,28 @@ 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
+
318
340
 
319
341
  class ExporterTests(unittest.TestCase):
320
342
  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.1.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
File without changes