evalcore 2.1.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 (76) hide show
  1. {evalcore-2.1.0 → evalcore-2.2.0}/CHANGELOG.md +24 -1
  2. {evalcore-2.1.0 → evalcore-2.2.0}/PKG-INFO +35 -5
  3. {evalcore-2.1.0 → evalcore-2.2.0}/README.md +34 -4
  4. {evalcore-2.1.0 → evalcore-2.2.0}/examples/quickstart/graders.py +2 -2
  5. {evalcore-2.1.0 → evalcore-2.2.0}/pyproject.toml +1 -1
  6. {evalcore-2.1.0 → evalcore-2.2.0}/src/evalcore/store.py +47 -2
  7. {evalcore-2.1.0 → evalcore-2.2.0}/tests/test_store.py +30 -0
  8. {evalcore-2.1.0 → evalcore-2.2.0}/uv.lock +1 -1
  9. {evalcore-2.1.0 → evalcore-2.2.0}/.github/workflows/ci.yml +0 -0
  10. {evalcore-2.1.0 → evalcore-2.2.0}/.github/workflows/publish.yml +0 -0
  11. {evalcore-2.1.0 → evalcore-2.2.0}/.gitignore +0 -0
  12. {evalcore-2.1.0 → evalcore-2.2.0}/.pre-commit-config.yaml +0 -0
  13. {evalcore-2.1.0 → evalcore-2.2.0}/LICENSE +0 -0
  14. {evalcore-2.1.0 → evalcore-2.2.0}/docs/design.md +0 -0
  15. {evalcore-2.1.0 → evalcore-2.2.0}/examples/__init__.py +0 -0
  16. {evalcore-2.1.0 → evalcore-2.2.0}/examples/quickstart/README.md +0 -0
  17. {evalcore-2.1.0 → evalcore-2.2.0}/examples/quickstart/__init__.py +0 -0
  18. {evalcore-2.1.0 → evalcore-2.2.0}/examples/quickstart/adapter.py +0 -0
  19. {evalcore-2.1.0 → evalcore-2.2.0}/examples/quickstart/datasets/support_reply/v1/cases/angry_complaint.yaml +0 -0
  20. {evalcore-2.1.0 → evalcore-2.2.0}/examples/quickstart/datasets/support_reply/v1/cases/billing_question.yaml +0 -0
  21. {evalcore-2.1.0 → evalcore-2.2.0}/examples/quickstart/datasets/support_reply/v1/cases/double_charge.yaml +0 -0
  22. {evalcore-2.1.0 → evalcore-2.2.0}/examples/quickstart/datasets/support_reply/v1/cases/refund_request.yaml +0 -0
  23. {evalcore-2.1.0 → evalcore-2.2.0}/examples/quickstart/fixtures/support_reply_judge.yaml +0 -0
  24. {evalcore-2.1.0 → evalcore-2.2.0}/examples/quickstart/fixtures/support_reply_pairwise.yaml +0 -0
  25. {evalcore-2.1.0 → evalcore-2.2.0}/examples/quickstart/fixtures/support_reply_replay.yaml +0 -0
  26. {evalcore-2.1.0 → evalcore-2.2.0}/examples/quickstart/run_eval.py +0 -0
  27. {evalcore-2.1.0 → evalcore-2.2.0}/examples/quickstart/suite.yaml +0 -0
  28. {evalcore-2.1.0 → evalcore-2.2.0}/examples/quickstart/tests/__init__.py +0 -0
  29. {evalcore-2.1.0 → evalcore-2.2.0}/examples/quickstart/tests/test_quickstart.py +0 -0
  30. {evalcore-2.1.0 → evalcore-2.2.0}/justfile +0 -0
  31. {evalcore-2.1.0 → evalcore-2.2.0}/pyrightconfig.json +0 -0
  32. {evalcore-2.1.0 → evalcore-2.2.0}/src/evalcore/__init__.py +0 -0
  33. {evalcore-2.1.0 → evalcore-2.2.0}/src/evalcore/adapters/__init__.py +0 -0
  34. {evalcore-2.1.0 → evalcore-2.2.0}/src/evalcore/adapters/base.py +0 -0
  35. {evalcore-2.1.0 → evalcore-2.2.0}/src/evalcore/adapters/env.py +0 -0
  36. {evalcore-2.1.0 → evalcore-2.2.0}/src/evalcore/adapters/http.py +0 -0
  37. {evalcore-2.1.0 → evalcore-2.2.0}/src/evalcore/adapters/replay.py +0 -0
  38. {evalcore-2.1.0 → evalcore-2.2.0}/src/evalcore/cli.py +0 -0
  39. {evalcore-2.1.0 → evalcore-2.2.0}/src/evalcore/compare.py +0 -0
  40. {evalcore-2.1.0 → evalcore-2.2.0}/src/evalcore/errors.py +0 -0
  41. {evalcore-2.1.0 → evalcore-2.2.0}/src/evalcore/graders/__init__.py +0 -0
  42. {evalcore-2.1.0 → evalcore-2.2.0}/src/evalcore/graders/base.py +0 -0
  43. {evalcore-2.1.0 → evalcore-2.2.0}/src/evalcore/graders/classification.py +0 -0
  44. {evalcore-2.1.0 → evalcore-2.2.0}/src/evalcore/graders/deterministic.py +0 -0
  45. {evalcore-2.1.0 → evalcore-2.2.0}/src/evalcore/graders/judge.py +0 -0
  46. {evalcore-2.1.0 → evalcore-2.2.0}/src/evalcore/graders/numeric.py +0 -0
  47. {evalcore-2.1.0 → evalcore-2.2.0}/src/evalcore/loader.py +0 -0
  48. {evalcore-2.1.0 → evalcore-2.2.0}/src/evalcore/models.py +0 -0
  49. {evalcore-2.1.0 → evalcore-2.2.0}/src/evalcore/pairwise.py +0 -0
  50. {evalcore-2.1.0 → evalcore-2.2.0}/src/evalcore/py.typed +0 -0
  51. {evalcore-2.1.0 → evalcore-2.2.0}/src/evalcore/rating.py +0 -0
  52. {evalcore-2.1.0 → evalcore-2.2.0}/src/evalcore/refs.py +0 -0
  53. {evalcore-2.1.0 → evalcore-2.2.0}/src/evalcore/report.py +0 -0
  54. {evalcore-2.1.0 → evalcore-2.2.0}/src/evalcore/reporters/__init__.py +0 -0
  55. {evalcore-2.1.0 → evalcore-2.2.0}/src/evalcore/reporters/base.py +0 -0
  56. {evalcore-2.1.0 → evalcore-2.2.0}/src/evalcore/reporters/html.py +0 -0
  57. {evalcore-2.1.0 → evalcore-2.2.0}/src/evalcore/reporters/markdown.py +0 -0
  58. {evalcore-2.1.0 → evalcore-2.2.0}/src/evalcore/retry.py +0 -0
  59. {evalcore-2.1.0 → evalcore-2.2.0}/src/evalcore/runner.py +0 -0
  60. {evalcore-2.1.0 → evalcore-2.2.0}/src/evalcore/sweep.py +0 -0
  61. {evalcore-2.1.0 → evalcore-2.2.0}/tests/__init__.py +0 -0
  62. {evalcore-2.1.0 → evalcore-2.2.0}/tests/test_adapters.py +0 -0
  63. {evalcore-2.1.0 → evalcore-2.2.0}/tests/test_cli.py +0 -0
  64. {evalcore-2.1.0 → evalcore-2.2.0}/tests/test_edge_cases.py +0 -0
  65. {evalcore-2.1.0 → evalcore-2.2.0}/tests/test_judge.py +0 -0
  66. {evalcore-2.1.0 → evalcore-2.2.0}/tests/test_judge_extra.py +0 -0
  67. {evalcore-2.1.0 → evalcore-2.2.0}/tests/test_live_clients.py +0 -0
  68. {evalcore-2.1.0 → evalcore-2.2.0}/tests/test_pairwise_extra.py +0 -0
  69. {evalcore-2.1.0 → evalcore-2.2.0}/tests/test_rating.py +0 -0
  70. {evalcore-2.1.0 → evalcore-2.2.0}/tests/test_rating_server.py +0 -0
  71. {evalcore-2.1.0 → evalcore-2.2.0}/tests/test_reporters.py +0 -0
  72. {evalcore-2.1.0 → evalcore-2.2.0}/tests/test_retry.py +0 -0
  73. {evalcore-2.1.0 → evalcore-2.2.0}/tests/test_runner.py +0 -0
  74. {evalcore-2.1.0 → evalcore-2.2.0}/tests/test_sweep_pairwise.py +0 -0
  75. {evalcore-2.1.0 → evalcore-2.2.0}/tests/test_unit.py +0 -0
  76. {evalcore-2.1.0 → evalcore-2.2.0}/uv.toml +0 -0
@@ -6,6 +6,28 @@ 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
+
9
31
  ## [2.1.0] - 2026-08-07
10
32
 
11
33
  A grader declares what kind of check it is at registration, so a consumer
@@ -174,7 +196,8 @@ by semantic versioning: a breaking change to either means a 2.0.
174
196
  rating + ranking with judge agreement, Markdown/HTML reporters, JSON +
175
197
  column-store outbox, and content-hash provenance.
176
198
 
177
- [Unreleased]: https://github.com/scottpmiller/evalcore/compare/2.1.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
178
201
  [2.1.0]: https://github.com/scottpmiller/evalcore/compare/2.0.0...2.1.0
179
202
  [2.0.0]: https://github.com/scottpmiller/evalcore/compare/1.0.0...2.0.0
180
203
  [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.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.1.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"
@@ -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
 
@@ -511,12 +512,56 @@ def score_rows(
511
512
  return rows
512
513
 
513
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
+
514
559
  class JsonlOutboxExporter:
515
560
  """Append score rows to a JSONL outbox for a shipper to drain.
516
561
 
517
562
  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.
563
+ shipper at this file, or swap in another :class:`ScoreExporter` that
564
+ writes to the store directly.
520
565
  """
521
566
 
522
567
  def __init__(self, outbox_path: str | pathlib.Path):
@@ -338,6 +338,36 @@ class GraderLookupTests(unittest.TestCase):
338
338
  name = 'non_empty'
339
339
 
340
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
+
370
+
341
371
  class ExporterTests(unittest.TestCase):
342
372
  def test_export_scores(self):
343
373
  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.2.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