evalcore 2.4.0__tar.gz → 2.4.2__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.4.0 → evalcore-2.4.2}/CHANGELOG.md +67 -1
  2. {evalcore-2.4.0 → evalcore-2.4.2}/PKG-INFO +1 -1
  3. {evalcore-2.4.0 → evalcore-2.4.2}/pyproject.toml +1 -1
  4. {evalcore-2.4.0 → evalcore-2.4.2}/src/evalcore/graders/judge.py +39 -5
  5. {evalcore-2.4.0 → evalcore-2.4.2}/tests/test_judge.py +89 -9
  6. {evalcore-2.4.0 → evalcore-2.4.2}/uv.lock +1 -1
  7. {evalcore-2.4.0 → evalcore-2.4.2}/.github/workflows/ci.yml +0 -0
  8. {evalcore-2.4.0 → evalcore-2.4.2}/.github/workflows/publish.yml +0 -0
  9. {evalcore-2.4.0 → evalcore-2.4.2}/.gitignore +0 -0
  10. {evalcore-2.4.0 → evalcore-2.4.2}/.pre-commit-config.yaml +0 -0
  11. {evalcore-2.4.0 → evalcore-2.4.2}/LICENSE +0 -0
  12. {evalcore-2.4.0 → evalcore-2.4.2}/README.md +0 -0
  13. {evalcore-2.4.0 → evalcore-2.4.2}/docs/design.md +0 -0
  14. {evalcore-2.4.0 → evalcore-2.4.2}/examples/__init__.py +0 -0
  15. {evalcore-2.4.0 → evalcore-2.4.2}/examples/quickstart/README.md +0 -0
  16. {evalcore-2.4.0 → evalcore-2.4.2}/examples/quickstart/__init__.py +0 -0
  17. {evalcore-2.4.0 → evalcore-2.4.2}/examples/quickstart/adapter.py +0 -0
  18. {evalcore-2.4.0 → evalcore-2.4.2}/examples/quickstart/datasets/support_reply/v1/cases/angry_complaint.yaml +0 -0
  19. {evalcore-2.4.0 → evalcore-2.4.2}/examples/quickstart/datasets/support_reply/v1/cases/billing_question.yaml +0 -0
  20. {evalcore-2.4.0 → evalcore-2.4.2}/examples/quickstart/datasets/support_reply/v1/cases/double_charge.yaml +0 -0
  21. {evalcore-2.4.0 → evalcore-2.4.2}/examples/quickstart/datasets/support_reply/v1/cases/refund_request.yaml +0 -0
  22. {evalcore-2.4.0 → evalcore-2.4.2}/examples/quickstart/fixtures/support_reply_judge.yaml +0 -0
  23. {evalcore-2.4.0 → evalcore-2.4.2}/examples/quickstart/fixtures/support_reply_pairwise.yaml +0 -0
  24. {evalcore-2.4.0 → evalcore-2.4.2}/examples/quickstart/fixtures/support_reply_replay.yaml +0 -0
  25. {evalcore-2.4.0 → evalcore-2.4.2}/examples/quickstart/graders.py +0 -0
  26. {evalcore-2.4.0 → evalcore-2.4.2}/examples/quickstart/run_eval.py +0 -0
  27. {evalcore-2.4.0 → evalcore-2.4.2}/examples/quickstart/suite.yaml +0 -0
  28. {evalcore-2.4.0 → evalcore-2.4.2}/examples/quickstart/tests/__init__.py +0 -0
  29. {evalcore-2.4.0 → evalcore-2.4.2}/examples/quickstart/tests/test_quickstart.py +0 -0
  30. {evalcore-2.4.0 → evalcore-2.4.2}/justfile +0 -0
  31. {evalcore-2.4.0 → evalcore-2.4.2}/pyrightconfig.json +0 -0
  32. {evalcore-2.4.0 → evalcore-2.4.2}/src/evalcore/__init__.py +0 -0
  33. {evalcore-2.4.0 → evalcore-2.4.2}/src/evalcore/adapters/__init__.py +0 -0
  34. {evalcore-2.4.0 → evalcore-2.4.2}/src/evalcore/adapters/base.py +0 -0
  35. {evalcore-2.4.0 → evalcore-2.4.2}/src/evalcore/adapters/env.py +0 -0
  36. {evalcore-2.4.0 → evalcore-2.4.2}/src/evalcore/adapters/http.py +0 -0
  37. {evalcore-2.4.0 → evalcore-2.4.2}/src/evalcore/adapters/replay.py +0 -0
  38. {evalcore-2.4.0 → evalcore-2.4.2}/src/evalcore/cli.py +0 -0
  39. {evalcore-2.4.0 → evalcore-2.4.2}/src/evalcore/compare.py +0 -0
  40. {evalcore-2.4.0 → evalcore-2.4.2}/src/evalcore/errors.py +0 -0
  41. {evalcore-2.4.0 → evalcore-2.4.2}/src/evalcore/graders/__init__.py +0 -0
  42. {evalcore-2.4.0 → evalcore-2.4.2}/src/evalcore/graders/base.py +0 -0
  43. {evalcore-2.4.0 → evalcore-2.4.2}/src/evalcore/graders/classification.py +0 -0
  44. {evalcore-2.4.0 → evalcore-2.4.2}/src/evalcore/graders/deterministic.py +0 -0
  45. {evalcore-2.4.0 → evalcore-2.4.2}/src/evalcore/graders/numeric.py +0 -0
  46. {evalcore-2.4.0 → evalcore-2.4.2}/src/evalcore/loader.py +0 -0
  47. {evalcore-2.4.0 → evalcore-2.4.2}/src/evalcore/models.py +0 -0
  48. {evalcore-2.4.0 → evalcore-2.4.2}/src/evalcore/pairwise.py +0 -0
  49. {evalcore-2.4.0 → evalcore-2.4.2}/src/evalcore/py.typed +0 -0
  50. {evalcore-2.4.0 → evalcore-2.4.2}/src/evalcore/rating.py +0 -0
  51. {evalcore-2.4.0 → evalcore-2.4.2}/src/evalcore/refs.py +0 -0
  52. {evalcore-2.4.0 → evalcore-2.4.2}/src/evalcore/report.py +0 -0
  53. {evalcore-2.4.0 → evalcore-2.4.2}/src/evalcore/reporters/__init__.py +0 -0
  54. {evalcore-2.4.0 → evalcore-2.4.2}/src/evalcore/reporters/base.py +0 -0
  55. {evalcore-2.4.0 → evalcore-2.4.2}/src/evalcore/reporters/html.py +0 -0
  56. {evalcore-2.4.0 → evalcore-2.4.2}/src/evalcore/reporters/markdown.py +0 -0
  57. {evalcore-2.4.0 → evalcore-2.4.2}/src/evalcore/retry.py +0 -0
  58. {evalcore-2.4.0 → evalcore-2.4.2}/src/evalcore/runner.py +0 -0
  59. {evalcore-2.4.0 → evalcore-2.4.2}/src/evalcore/store.py +0 -0
  60. {evalcore-2.4.0 → evalcore-2.4.2}/src/evalcore/sweep.py +0 -0
  61. {evalcore-2.4.0 → evalcore-2.4.2}/tests/__init__.py +0 -0
  62. {evalcore-2.4.0 → evalcore-2.4.2}/tests/test_adapters.py +0 -0
  63. {evalcore-2.4.0 → evalcore-2.4.2}/tests/test_cli.py +0 -0
  64. {evalcore-2.4.0 → evalcore-2.4.2}/tests/test_edge_cases.py +0 -0
  65. {evalcore-2.4.0 → evalcore-2.4.2}/tests/test_judge_extra.py +0 -0
  66. {evalcore-2.4.0 → evalcore-2.4.2}/tests/test_live_clients.py +0 -0
  67. {evalcore-2.4.0 → evalcore-2.4.2}/tests/test_pairwise_extra.py +0 -0
  68. {evalcore-2.4.0 → evalcore-2.4.2}/tests/test_rating.py +0 -0
  69. {evalcore-2.4.0 → evalcore-2.4.2}/tests/test_rating_server.py +0 -0
  70. {evalcore-2.4.0 → evalcore-2.4.2}/tests/test_reporters.py +0 -0
  71. {evalcore-2.4.0 → evalcore-2.4.2}/tests/test_retry.py +0 -0
  72. {evalcore-2.4.0 → evalcore-2.4.2}/tests/test_runner.py +0 -0
  73. {evalcore-2.4.0 → evalcore-2.4.2}/tests/test_store.py +0 -0
  74. {evalcore-2.4.0 → evalcore-2.4.2}/tests/test_sweep_pairwise.py +0 -0
  75. {evalcore-2.4.0 → evalcore-2.4.2}/tests/test_unit.py +0 -0
  76. {evalcore-2.4.0 → evalcore-2.4.2}/uv.toml +0 -0
@@ -6,6 +6,70 @@ All notable changes to this project are documented here. The format is based on
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.4.2] - 2026-08-09
10
+
11
+ Every `llm_as_judge` row now describes what it measures.
12
+
13
+ Shipped as a patch. It changes what published rows contain, so the 1.0.0
14
+ policy would call it a minor; it goes out as 2.4.2 as a deliberate exception,
15
+ alongside 2.4.1's judge-pin fix that it completes. No schema change - the
16
+ columns already exist and were empty.
17
+
18
+ ### Fixed
19
+ - A judge's dimension row (`<grader>.<dimension>`) carried no judge
20
+ information at all: no `judges.*`, and `judge_scale` of 0. So `0.400` on a
21
+ row could not say who scored it, on what scale, or that the raw number was
22
+ 2 out of 5 - the scale lived only on `<grader>.overall`, and recovering the
23
+ raw point meant joining back to it.
24
+
25
+ Each dimension row now carries the verdict for the dimension it measures:
26
+ `judges.name`, `judges.version`, `judges.rationale`, `judge_scale`, a
27
+ `judges.points` **scoped to that dimension**, and a `judges.score` that is
28
+ that judge's number for it.
29
+
30
+ `<grader>.overall` is unchanged and still carries the full points map.
31
+
32
+ This establishes one invariant across every judge row, dimension rows
33
+ included: `value == mean(judges.score)`. With one judge they are equal;
34
+ with a panel, `value` is the mean and `judges.score` shows the spread - so
35
+ per-dimension disagreement is readable from the dimension row instead of
36
+ by unpacking the map on `.overall`.
37
+
38
+ **Upgrading:** `notEmpty(judges.name)` now matches every judge row rather
39
+ than only `<grader>.overall`, so a six-case run goes from 12 judged rows to
40
+ 48. Anything aggregating over judges must filter to `.overall` or it
41
+ multiplies by the dimension count. That is a change in results, not an error.
42
+
43
+ ## [2.4.1] - 2026-08-09
44
+
45
+ The judge's identity is now in its provenance pin.
46
+
47
+ Shipped as a patch. Both changes below alter a published value, so the 1.0.0
48
+ policy would call this a minor at least; it goes out as 2.4.1 as a deliberate
49
+ exception, because the old pin was answering a provenance question wrongly
50
+ and the sooner it stops the fewer runs are affected.
51
+
52
+ ### Fixed
53
+ - `Scorecard.judge_version` includes the judge's model:
54
+ `anthropic:claude-sonnet-4-6@v1` rather than `judge@v1`. It was
55
+ `key@judge_version`, so swapping the judge's model while leaving the
56
+ declared `judge_version` alone produced a byte-identical pin - and any
57
+ provenance check reading that field passed a comparison against a baseline
58
+ scored by a different model. The model is the thing most likely to change
59
+ and the thing a declared version is most likely to miss.
60
+
61
+ ### Changed
62
+ - A single judge's key defaults to its provider (`anthropic`) instead of the
63
+ literal `judge`. A panel already defaulted to `key or provider`, so the
64
+ one-judge case was the odd one out, and `judge` named nothing the `grader`
65
+ column did not already say. It appears in the `judges.name` column.
66
+
67
+ **Upgrading:** every suite with a judge re-baselines once, because
68
+ `judge_version` is part of what identifies a comparable run - which is the
69
+ intended behaviour, just paid all at once. Queries filtering
70
+ `judges.name = 'judge'` need the provider instead. Set `key:` on a `judges:`
71
+ entry to pin a name of your own.
72
+
9
73
  ## [2.4.0] - 2026-08-09
10
74
 
11
75
  A run without a baseline can now say whether it passed.
@@ -248,7 +312,9 @@ by semantic versioning: a breaking change to either means a 2.0.
248
312
  rating + ranking with judge agreement, Markdown/HTML reporters, JSON +
249
313
  column-store outbox, and content-hash provenance.
250
314
 
251
- [Unreleased]: https://github.com/scottpmiller/evalcore/compare/2.4.0...HEAD
315
+ [Unreleased]: https://github.com/scottpmiller/evalcore/compare/2.4.2...HEAD
316
+ [2.4.2]: https://github.com/scottpmiller/evalcore/compare/2.4.1...2.4.2
317
+ [2.4.1]: https://github.com/scottpmiller/evalcore/compare/2.4.0...2.4.1
252
318
  [2.4.0]: https://github.com/scottpmiller/evalcore/compare/2.3.0...2.4.0
253
319
  [2.3.0]: https://github.com/scottpmiller/evalcore/compare/2.2.0...2.3.0
254
320
  [2.2.0]: https://github.com/scottpmiller/evalcore/compare/2.1.0...2.2.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evalcore
3
- Version: 2.4.0
3
+ Version: 2.4.2
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.4.0"
3
+ version = "2.4.2"
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"
@@ -358,7 +358,11 @@ class RubricJudge:
358
358
  else:
359
359
  self.judges = [
360
360
  {
361
- 'key': 'judge',
361
+ # The provider, not a literal 'judge'. A single judge is
362
+ # a panel of one, so it names itself the same way a panel
363
+ # member does; 'judge' carried no information and made
364
+ # the one-judge case the odd one out.
365
+ 'key': 'anthropic',
362
366
  'provider': 'anthropic',
363
367
  'model': _env_expand(model),
364
368
  'api_key_env': None,
@@ -394,17 +398,27 @@ class RubricJudge:
394
398
 
395
399
  @property
396
400
  def judge_version(self) -> str:
397
- """Provenance pin: ``key@version`` per configured judge (a panel
398
- joins them, comma-separated).
401
+ """Provenance pin: ``key:model@version`` per configured judge (a
402
+ panel joins them, comma-separated).
399
403
 
400
404
  The runner reads this onto ``Scorecard.judge_version`` so a judge
401
405
  model / prompt / scale change surfaces as a re-baseline event rather
402
406
  than hiding in each score's ``detail``. Uses the configured judges,
403
407
  not the mode-filtered active set, so the pin is stable across
404
408
  environments.
409
+
410
+ The model is in the pin because it is the thing most likely to
411
+ change and the thing a declared version is most likely to miss.
412
+ Without it, swapping the judge's model while leaving
413
+ ``judge_version`` alone produced a byte-identical pin - so a
414
+ comparison against a baseline scored by a different model passed
415
+ every provenance check that reads this field.
405
416
  """
406
417
  return ','.join(
407
- f'{j["key"]}@{j["judge_version"]}' for j in self.judges
418
+ f'{j["key"]}:{j["model"]}@{j["judge_version"]}'
419
+ if j['model']
420
+ else f'{j["key"]}@{j["judge_version"]}'
421
+ for j in self.judges
408
422
  )
409
423
 
410
424
  def set_mode(self, mode: str) -> None:
@@ -565,6 +579,14 @@ class RubricJudge:
565
579
 
566
580
  # Panel mean per dimension (identical to the single judge's value
567
581
  # when there is only one).
582
+ #
583
+ # Each dimension row carries the verdict for the dimension it
584
+ # measures, not the whole one: points scoped to this key, and a score
585
+ # that is this judge's number for it. That makes `value ==
586
+ # mean(judges.score)` hold on every judge row, so per-dimension
587
+ # disagreement is readable without unpacking the map on `.overall`.
588
+ # Carrying the full map here would put two irrelevant numbers on a
589
+ # row and invite reading the wrong one.
568
590
  panel_dims: list[float] = []
569
591
  for key, _ in self.dimensions:
570
592
  per_judge = [
@@ -574,7 +596,19 @@ class RubricJudge:
574
596
  value = sum(present) / len(present) if present else None
575
597
  if value is not None:
576
598
  panel_dims.append(value)
577
- out.append(score(f'{self.name}.{key}', value))
599
+ dim_details = [
600
+ models.JudgeDetail(
601
+ key=judge['key'],
602
+ version=judge['judge_version'],
603
+ rationale=rationales.get(judge['key']),
604
+ points={key: raw.get(judge['key'], {}).get(key)},
605
+ overall=self._normalize(
606
+ raw.get(judge['key'], {}).get(key)
607
+ ),
608
+ )
609
+ for judge in active
610
+ ]
611
+ out.append(score(f'{self.name}.{key}', value, judges=dim_details))
578
612
 
579
613
  overall = sum(panel_dims) / len(panel_dims) if panel_dims else None
580
614
  out.append(
@@ -59,13 +59,62 @@ class RubricJudgeTests(unittest.TestCase):
59
59
  # raw 1..scale points are retained, not just the normalized mean
60
60
  self.assertEqual(jd.points, {'clarity': 5, 'specificity': 4})
61
61
  self.assertAlmostEqual(jd.overall, 0.9)
62
- # only the overall score carries the breakdown; dimensions do not
63
- dims = [
64
- s
65
- for s in _grade(grader, case, output)
66
- if s.metric == 'llm_judge.clarity'
67
- ]
68
- self.assertEqual(dims[0].judges, [])
62
+ # the overall score carries the whole breakdown
63
+ self.assertEqual(jd.points, {'clarity': 5, 'specificity': 4})
64
+
65
+ def test_a_dimension_row_carries_its_own_verdict(self):
66
+ """Every judge row describes what that row measures.
67
+
68
+ The dimension row used to carry nothing, so `0.8` alone could not say
69
+ who scored it, on what scale, or that the raw number was 4.
70
+ """
71
+ grader = self._grader(
72
+ {
73
+ 'Great subject': {
74
+ 'scores': {'clarity': 5, 'specificity': 4},
75
+ 'rationale': 'Clear and specific enough.',
76
+ }
77
+ }
78
+ )
79
+ scores = _grade(
80
+ grader,
81
+ models.Case(id='c1'),
82
+ models.Output(fields={'text': 'Great subject'}),
83
+ )
84
+ dim = next(s for s in scores if s.metric == 'llm_judge.specificity')
85
+ self.assertEqual(len(dim.judges), 1)
86
+ jd = dim.judges[0]
87
+ # Scoped: only the point this row is about.
88
+ self.assertEqual(jd.points, {'specificity': 4})
89
+ self.assertAlmostEqual(jd.overall, 0.8)
90
+ self.assertEqual(jd.rationale, 'Clear and specific enough.')
91
+
92
+ def test_value_is_the_mean_of_the_judge_scores(self):
93
+ """The invariant that holds on every judge row, dimensions included:
94
+ value == mean(judges.score)."""
95
+ grader = self._grader(
96
+ {'X': {'scores': {'clarity': 5, 'specificity': 2}}}
97
+ )
98
+ scores = _grade(
99
+ grader, models.Case(id='c'), models.Output(fields={'text': 'X'})
100
+ )
101
+ judged = [s for s in scores if s.judges and s.value is not None]
102
+ self.assertTrue(judged)
103
+ for s in judged:
104
+ mean = sum(j.overall for j in s.judges) / len(s.judges)
105
+ self.assertAlmostEqual(s.value, mean, msg=s.metric)
106
+
107
+ def test_the_overall_row_keeps_the_full_map(self):
108
+ grader = self._grader(
109
+ {'X': {'scores': {'clarity': 5, 'specificity': 4}}}
110
+ )
111
+ scores = _grade(
112
+ grader, models.Case(id='c'), models.Output(fields={'text': 'X'})
113
+ )
114
+ overall = next(s for s in scores if s.metric == 'llm_judge.overall')
115
+ self.assertEqual(
116
+ overall.judges[0].points, {'clarity': 5, 'specificity': 4}
117
+ )
69
118
 
70
119
  def test_different_content_gets_different_scores(self):
71
120
  grader = self._grader(
@@ -125,7 +174,8 @@ class RubricJudgeTests(unittest.TestCase):
125
174
  replay_path='f.yaml',
126
175
  judge_version='v3',
127
176
  )
128
- self.assertEqual(single.judge_version, 'judge@v3')
177
+ # No model configured (replay-only), so the pin is key@version.
178
+ self.assertEqual(single.judge_version, 'anthropic@v3')
129
179
  panel = judge.RubricJudge(
130
180
  content_ref='output.text',
131
181
  dimensions=DIMENSIONS,
@@ -139,7 +189,37 @@ class RubricJudgeTests(unittest.TestCase):
139
189
  },
140
190
  ],
141
191
  )
142
- self.assertEqual(panel.judge_version, 'claude@v2,gpt@v5')
192
+ self.assertEqual(panel.judge_version, 'claude:m@v2,gpt:openai:g@v5')
193
+
194
+ def test_the_model_is_in_the_pin(self):
195
+ """Swapping the judge's model has to move the pin.
196
+
197
+ Without this, a model swap that left `judge_version` alone produced
198
+ a byte-identical pin, so a comparison against a baseline scored by a
199
+ different model passed every provenance check reading this field.
200
+ """
201
+
202
+ def _judge(model):
203
+ return judge.RubricJudge(
204
+ content_ref='output.text',
205
+ dimensions=DIMENSIONS,
206
+ model=model,
207
+ judge_version='v1',
208
+ )
209
+
210
+ before = _judge('claude-sonnet-4-6').judge_version
211
+ after = _judge('claude-opus-4-1').judge_version
212
+ self.assertEqual(before, 'anthropic:claude-sonnet-4-6@v1')
213
+ self.assertNotEqual(before, after)
214
+
215
+ def test_a_single_judge_names_itself_by_provider(self):
216
+ """Not the literal 'judge', which carried no information."""
217
+ single = judge.RubricJudge(
218
+ content_ref='output.text',
219
+ dimensions=DIMENSIONS,
220
+ replay_path='f.yaml',
221
+ )
222
+ self.assertEqual(single.judges[0]['key'], 'anthropic')
143
223
 
144
224
  def test_transient_client_error_retried_via_set_retry(self):
145
225
  class _RateLimit(Exception):
@@ -188,7 +188,7 @@ wheels = [
188
188
 
189
189
  [[package]]
190
190
  name = "evalcore"
191
- version = "2.4.0"
191
+ version = "2.4.2"
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
File without changes
File without changes