evalcore 0.3.0__tar.gz → 2.0.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-0.3.0 → evalcore-2.0.0}/CHANGELOG.md +70 -1
  2. {evalcore-0.3.0 → evalcore-2.0.0}/PKG-INFO +28 -21
  3. {evalcore-0.3.0 → evalcore-2.0.0}/README.md +26 -19
  4. {evalcore-0.3.0 → evalcore-2.0.0}/docs/design.md +26 -13
  5. {evalcore-0.3.0 → evalcore-2.0.0}/justfile +3 -3
  6. {evalcore-0.3.0 → evalcore-2.0.0}/pyproject.toml +2 -2
  7. {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/__init__.py +6 -9
  8. {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/errors.py +2 -1
  9. {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/models.py +27 -7
  10. {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/pairwise.py +62 -22
  11. {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/rating.py +56 -46
  12. {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/reporters/base.py +5 -1
  13. {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/runner.py +47 -23
  14. {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/store.py +81 -46
  15. {evalcore-0.3.0 → evalcore-2.0.0}/tests/test_cli.py +17 -7
  16. {evalcore-0.3.0 → evalcore-2.0.0}/tests/test_edge_cases.py +2 -2
  17. evalcore-2.0.0/tests/test_pairwise_extra.py +178 -0
  18. {evalcore-0.3.0 → evalcore-2.0.0}/tests/test_rating.py +26 -41
  19. {evalcore-0.3.0 → evalcore-2.0.0}/tests/test_rating_server.py +2 -2
  20. {evalcore-0.3.0 → evalcore-2.0.0}/tests/test_reporters.py +3 -3
  21. {evalcore-0.3.0 → evalcore-2.0.0}/tests/test_runner.py +125 -17
  22. {evalcore-0.3.0 → evalcore-2.0.0}/tests/test_store.py +68 -15
  23. {evalcore-0.3.0 → evalcore-2.0.0}/tests/test_sweep_pairwise.py +1 -1
  24. {evalcore-0.3.0 → evalcore-2.0.0}/tests/test_unit.py +1 -1
  25. {evalcore-0.3.0 → evalcore-2.0.0}/uv.lock +1 -1
  26. evalcore-0.3.0/tests/test_pairwise_extra.py +0 -68
  27. {evalcore-0.3.0 → evalcore-2.0.0}/.github/workflows/ci.yml +0 -0
  28. {evalcore-0.3.0 → evalcore-2.0.0}/.github/workflows/publish.yml +0 -0
  29. {evalcore-0.3.0 → evalcore-2.0.0}/.gitignore +0 -0
  30. {evalcore-0.3.0 → evalcore-2.0.0}/.pre-commit-config.yaml +0 -0
  31. {evalcore-0.3.0 → evalcore-2.0.0}/LICENSE +0 -0
  32. {evalcore-0.3.0 → evalcore-2.0.0}/examples/__init__.py +0 -0
  33. {evalcore-0.3.0 → evalcore-2.0.0}/examples/quickstart/README.md +0 -0
  34. {evalcore-0.3.0 → evalcore-2.0.0}/examples/quickstart/__init__.py +0 -0
  35. {evalcore-0.3.0 → evalcore-2.0.0}/examples/quickstart/adapter.py +0 -0
  36. {evalcore-0.3.0 → evalcore-2.0.0}/examples/quickstart/datasets/support_reply/v1/cases/angry_complaint.yaml +0 -0
  37. {evalcore-0.3.0 → evalcore-2.0.0}/examples/quickstart/datasets/support_reply/v1/cases/billing_question.yaml +0 -0
  38. {evalcore-0.3.0 → evalcore-2.0.0}/examples/quickstart/datasets/support_reply/v1/cases/double_charge.yaml +0 -0
  39. {evalcore-0.3.0 → evalcore-2.0.0}/examples/quickstart/datasets/support_reply/v1/cases/refund_request.yaml +0 -0
  40. {evalcore-0.3.0 → evalcore-2.0.0}/examples/quickstart/fixtures/support_reply_judge.yaml +0 -0
  41. {evalcore-0.3.0 → evalcore-2.0.0}/examples/quickstart/fixtures/support_reply_pairwise.yaml +0 -0
  42. {evalcore-0.3.0 → evalcore-2.0.0}/examples/quickstart/fixtures/support_reply_replay.yaml +0 -0
  43. {evalcore-0.3.0 → evalcore-2.0.0}/examples/quickstart/graders.py +0 -0
  44. {evalcore-0.3.0 → evalcore-2.0.0}/examples/quickstart/run_eval.py +0 -0
  45. {evalcore-0.3.0 → evalcore-2.0.0}/examples/quickstart/suite.yaml +0 -0
  46. {evalcore-0.3.0 → evalcore-2.0.0}/examples/quickstart/tests/__init__.py +0 -0
  47. {evalcore-0.3.0 → evalcore-2.0.0}/examples/quickstart/tests/test_quickstart.py +0 -0
  48. {evalcore-0.3.0 → evalcore-2.0.0}/pyrightconfig.json +0 -0
  49. {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/adapters/__init__.py +0 -0
  50. {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/adapters/base.py +0 -0
  51. {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/adapters/env.py +0 -0
  52. {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/adapters/http.py +0 -0
  53. {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/adapters/replay.py +0 -0
  54. {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/cli.py +0 -0
  55. {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/compare.py +0 -0
  56. {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/graders/__init__.py +0 -0
  57. {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/graders/base.py +0 -0
  58. {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/graders/classification.py +0 -0
  59. {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/graders/deterministic.py +0 -0
  60. {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/graders/judge.py +0 -0
  61. {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/graders/numeric.py +0 -0
  62. {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/loader.py +0 -0
  63. {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/py.typed +0 -0
  64. {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/refs.py +0 -0
  65. {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/report.py +0 -0
  66. {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/reporters/__init__.py +0 -0
  67. {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/reporters/html.py +0 -0
  68. {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/reporters/markdown.py +0 -0
  69. {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/retry.py +0 -0
  70. {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/sweep.py +0 -0
  71. {evalcore-0.3.0 → evalcore-2.0.0}/tests/__init__.py +0 -0
  72. {evalcore-0.3.0 → evalcore-2.0.0}/tests/test_adapters.py +0 -0
  73. {evalcore-0.3.0 → evalcore-2.0.0}/tests/test_judge.py +0 -0
  74. {evalcore-0.3.0 → evalcore-2.0.0}/tests/test_judge_extra.py +0 -0
  75. {evalcore-0.3.0 → evalcore-2.0.0}/tests/test_live_clients.py +0 -0
  76. {evalcore-0.3.0 → evalcore-2.0.0}/tests/test_retry.py +0 -0
  77. {evalcore-0.3.0 → evalcore-2.0.0}/uv.toml +0 -0
@@ -6,6 +6,74 @@ All notable changes to this project are documented here. The format is based on
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.0.0] - 2026-07-31
10
+
11
+ Breaks both the public API and the outbox row shape, so it's a major per the
12
+ 1.0.0 policy.
13
+
14
+ ### Changed
15
+ - **Breaking:** a sample is identified by `sample_hash` - a content digest of
16
+ the output it produced - rather than by the ordinal `sample_idx`. A row now
17
+ names the exact response behind it. The field is renamed on `CaseResult`,
18
+ `Rating`, `Preference`, `PairwiseOutcome` and `PairwiseAgreementCase`, and in
19
+ the outbox rows. Two runs of the same case never share a hash, so anything
20
+ comparing runs aligns on `case_id` and sample order; pairwise and the ranking
21
+ app anchor a pair on the `variant_a` side's digest so human and judge picks
22
+ still join. **Ratings and preferences files written by 1.x do not carry a
23
+ hash and will not join to a run** - re-collect them, or backfill the field.
24
+ - **Breaking:** outbox keys track the store's column names: `created_at` is
25
+ emitted as `started_at`, `n_cases` as `case_count`, `n_samples` as
26
+ `sample_count`.
27
+ - **Breaking:** `store.read_checkpoint_results` is now
28
+ `store.read_checkpoint_samples` and returns `(ordinal, result)` pairs.
29
+ Checkpoint lines nest the result under `result` and tag it with `sample`, so
30
+ resume knows which samples are still owed. 1.x checkpoints cannot be resumed.
31
+
32
+ ### Fixed
33
+ - Resuming a run with `concurrency > 1` could skip a sample and re-run another.
34
+ A concurrent run checkpoints in completion order, so an interrupt leaves a
35
+ hole rather than a clean prefix; resume now reruns the samples that are
36
+ actually missing. On a deterministic target the re-run collided with a digest
37
+ already recorded, so the store collapsed two samples into one row.
38
+ - The pairwise judge and the side-by-side ranking app filtered samples
39
+ differently - non-empty content vs. no error - so with `n_samples > 1` and an
40
+ error on either side they paired A's n-th sample against different B samples.
41
+ `agreement` joins the two on A's digest, so it scored two different
42
+ comparisons as one. Both now go through
43
+ `pairwise.comparable_samples`, which requires a successful invocation *and*
44
+ resolvable content. Two behaviour changes fall out: an errored output is no
45
+ longer judged even when its content ref still resolves, and a sample with
46
+ empty content is no longer shown to a rater as a blank panel.
47
+
48
+ ### Changed (internal)
49
+ - `pairwise._content_map` is now `pairwise.comparable_samples` and is the one
50
+ place that decides whether a sample can take part in a comparison.
51
+
52
+ ### Removed
53
+ - `docs/clickhouse-schema.{sql,html}`. The outbox targets a flat row shape, not
54
+ one vendor's DDL, and the file documented a specific deployment - database
55
+ name, ingestion topology, sample data - none of which the engine needs. A
56
+ store with column types the feed does not match maps the rows at its own
57
+ boundary.
58
+
59
+ ## [1.0.0] - 2026-07-28
60
+
61
+ First stable release. The public API and the outbox row shape are now covered
62
+ by semantic versioning: a breaking change to either means a 2.0.
63
+
64
+ ### Changed
65
+ - **Breaking:** outbox rows no longer carry `input_tokens`, `output_tokens` or
66
+ `cost`. `Output.tokens` is an open dict an adapter may put anything in, and
67
+ usage accounting is captured outside the results store.
68
+ - **Breaking:** the run-grain `revision` key is emitted as
69
+ `application_revision`, which is its column name in the store.
70
+
71
+ ### Added
72
+ - `docs/clickhouse-schema.sql`: the ClickHouse schema the outbox rows target -
73
+ the written `evaluation_scores` table, the row grammar as `CONSTRAINT`s, and
74
+ the canonical scorecard query. Also rendered as
75
+ `docs/clickhouse-schema.html`. (Both removed again in 2.0.0.)
76
+
9
77
  ## [0.3.0] - 2026-07-28
10
78
 
11
79
  ### Changed
@@ -71,7 +139,8 @@ All notable changes to this project are documented here. The format is based on
71
139
  rating + ranking with judge agreement, Markdown/HTML reporters, JSON +
72
140
  column-store outbox, and content-hash provenance.
73
141
 
74
- [Unreleased]: https://github.com/scottpmiller/evalcore/compare/0.3.0...HEAD
142
+ [Unreleased]: https://github.com/scottpmiller/evalcore/compare/1.0.0...HEAD
143
+ [1.0.0]: https://github.com/scottpmiller/evalcore/compare/0.3.0...1.0.0
75
144
  [0.3.0]: https://github.com/scottpmiller/evalcore/compare/0.2.0...0.3.0
76
145
  [0.2.0]: https://github.com/scottpmiller/evalcore/compare/0.1.0...0.2.0
77
146
  [0.1.0]: https://github.com/scottpmiller/evalcore/releases/tag/0.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evalcore
3
- Version: 0.3.0
3
+ Version: 2.0.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
@@ -8,7 +8,7 @@ Project-URL: Issues, https://github.com/scottpmiller/evalcore/issues
8
8
  Author: AWeber Communications
9
9
  License-Expression: BSD-3-Clause
10
10
  License-File: LICENSE
11
- Classifier: Development Status :: 4 - Beta
11
+ Classifier: Development Status :: 5 - Production/Stable
12
12
  Classifier: Intended Audience :: Developers
13
13
  Classifier: Natural Language :: English
14
14
  Classifier: Operating System :: OS Independent
@@ -695,7 +695,7 @@ shorthand; a repeatable `--view label:kind:ref` gives explicit control.
695
695
  Sessions are **resumable** (a rater only sees items they haven't scored).
696
696
  **Blinding is enforced server-side** - the queue payload carries an opaque
697
697
  item id and never the run/variant/model; ratings map back to
698
- `(run_id, case_id, sample_idx)` only on the server. Ratings land in a JSONL
698
+ `(run_id, case_id, sample_hash)` only on the server. Ratings land in a JSONL
699
699
  file (`models.Rating`) that is the **open interchange format**: any external
700
700
  tool or spreadsheet export in the same shape feeds `agreement` too.
701
701
 
@@ -768,17 +768,21 @@ offline pairwise config.
768
768
  ## The outbox
769
769
 
770
770
  `JsonlOutboxExporter` flattens results into JSONL for a column-store shipper
771
- (e.g. ClickHouse) to drain, in a flat `eval_runs`/`eval_scores` shape:
772
- `export(scorecard)` writes one **metric** row each (`metric, value, stdev,
773
- metric_kind, n`), and `export_scores(run)` writes one **per-case score** row
774
- each (`case_id, sample_idx, grader, metric, value, passed, detail`). Both
775
- repeat the full reproducibility key (incl. `run_id`) so a multi-tenant
776
- trend table can filter/group on any dimension without joins. Swap the
777
- exporter for a real database client without touching the runner or any
778
- consumer. The rows use a no-`Nullable` convention (a missing value is the
779
- sentinel pair `(value=0, has_value=false)`; `passed` is the tri-state string
780
- `'true'|'false'|'null'`), so a JSONEachRow-style feed maps straight onto a
781
- flat schema.
771
+ (e.g. ClickHouse) to drain. `export_scores(run)` writes one feed at
772
+ `(run, case, sample, grader, metric)` grain - one row per score, carrying the
773
+ scored value, the invocation it came from, the gate verdict, and the full
774
+ reproducibility key (incl. `run_id`), so a multi-tenant trend table can
775
+ filter/group on any dimension without joins. Nothing derived is written: a
776
+ run's scorecard is a read-time aggregation over the same rows, so it cannot
777
+ disagree with the scores behind it. A measurement that does not exist is
778
+ `null`, not `0` - a real `0.0` is a meaningful score, and `avg`/`sum`/`count`
779
+ skip nulls, so no query has to remember a filter; `passed` is the tri-state
780
+ string `'true'|'false'|'null'`, since a judge has no pass line by design and
781
+ that is a third state rather than a missing value. Row keys are column names
782
+ rather than model attribute names, so a JSONEachRow-style feed lands in a flat
783
+ table without a mapping layer. A store that forbids nullable columns fills
784
+ those nulls in at ingest, on its side of the seam. Swap the exporter for a real
785
+ database client without touching the runner or any consumer.
782
786
 
783
787
  ---
784
788
 
@@ -829,12 +833,14 @@ docs/design.md the design overview
829
833
 
830
834
  ## Status
831
835
 
832
- MVP. Built: deterministic + classification + **LLM-judge** (rubric scoring;
833
- single judge or a Claude/GPT **panel** with per-dimension means, per-judge
834
- overalls, inter-judge disagreement flagging, and image/screenshot inputs)
835
- graders, http/replay/browser adapters, runner (N-sampling, optional
836
- concurrency, per-sample `RunResult` + `run_id` + variance), comparison/gate,
837
- JSON + run + outbox store (metric and per-case-score rows), **N-way sweeps
836
+ 2.0. The public API and the outbox row shape are stable; a breaking change to
837
+ either means a 3.0. Built: deterministic + classification + **LLM-judge**
838
+ (rubric scoring; single judge or a Claude/GPT **panel** with per-dimension
839
+ means, per-judge overalls, inter-judge disagreement flagging, and
840
+ image/screenshot inputs) graders, http/replay/browser adapters, runner
841
+ (N-sampling, optional concurrency, per-sample `RunResult` + `run_id` +
842
+ variance), comparison/gate, JSON + run + outbox store (one score-grain feed),
843
+ **N-way sweeps
838
844
  + counterbalanced pairwise A-vs-B win-rate** (`sweep`/`pairwise`), **blind
839
845
  human-rating + side-by-side ranking web apps** with judge↔human agreement and
840
846
  human-vs-judge pairwise agreement (`rate`/`agreement`, `rank`/`preferences`),
@@ -847,7 +853,8 @@ Known gaps / next:
847
853
  in, in `store.py`).
848
854
  - Cost/token capture: `Output.tokens`/`cost` fields exist but nothing
849
855
  populates them (an adapter must fill them from whatever usage its target
850
- reports).
856
+ reports), and neither is exported to the results store - usage accounting
857
+ is captured outside it.
851
858
  - Run robustness lands: retry with exponential backoff on transient failures
852
859
  for both the adapter (suite `retry:` + `Output.retryable`) and the LLM judge
853
860
  client, plus idempotent mid-run resume from a `run --checkpoint`.
@@ -663,7 +663,7 @@ shorthand; a repeatable `--view label:kind:ref` gives explicit control.
663
663
  Sessions are **resumable** (a rater only sees items they haven't scored).
664
664
  **Blinding is enforced server-side** - the queue payload carries an opaque
665
665
  item id and never the run/variant/model; ratings map back to
666
- `(run_id, case_id, sample_idx)` only on the server. Ratings land in a JSONL
666
+ `(run_id, case_id, sample_hash)` only on the server. Ratings land in a JSONL
667
667
  file (`models.Rating`) that is the **open interchange format**: any external
668
668
  tool or spreadsheet export in the same shape feeds `agreement` too.
669
669
 
@@ -736,17 +736,21 @@ offline pairwise config.
736
736
  ## The outbox
737
737
 
738
738
  `JsonlOutboxExporter` flattens results into JSONL for a column-store shipper
739
- (e.g. ClickHouse) to drain, in a flat `eval_runs`/`eval_scores` shape:
740
- `export(scorecard)` writes one **metric** row each (`metric, value, stdev,
741
- metric_kind, n`), and `export_scores(run)` writes one **per-case score** row
742
- each (`case_id, sample_idx, grader, metric, value, passed, detail`). Both
743
- repeat the full reproducibility key (incl. `run_id`) so a multi-tenant
744
- trend table can filter/group on any dimension without joins. Swap the
745
- exporter for a real database client without touching the runner or any
746
- consumer. The rows use a no-`Nullable` convention (a missing value is the
747
- sentinel pair `(value=0, has_value=false)`; `passed` is the tri-state string
748
- `'true'|'false'|'null'`), so a JSONEachRow-style feed maps straight onto a
749
- flat schema.
739
+ (e.g. ClickHouse) to drain. `export_scores(run)` writes one feed at
740
+ `(run, case, sample, grader, metric)` grain - one row per score, carrying the
741
+ scored value, the invocation it came from, the gate verdict, and the full
742
+ reproducibility key (incl. `run_id`), so a multi-tenant trend table can
743
+ filter/group on any dimension without joins. Nothing derived is written: a
744
+ run's scorecard is a read-time aggregation over the same rows, so it cannot
745
+ disagree with the scores behind it. A measurement that does not exist is
746
+ `null`, not `0` - a real `0.0` is a meaningful score, and `avg`/`sum`/`count`
747
+ skip nulls, so no query has to remember a filter; `passed` is the tri-state
748
+ string `'true'|'false'|'null'`, since a judge has no pass line by design and
749
+ that is a third state rather than a missing value. Row keys are column names
750
+ rather than model attribute names, so a JSONEachRow-style feed lands in a flat
751
+ table without a mapping layer. A store that forbids nullable columns fills
752
+ those nulls in at ingest, on its side of the seam. Swap the exporter for a real
753
+ database client without touching the runner or any consumer.
750
754
 
751
755
  ---
752
756
 
@@ -797,12 +801,14 @@ docs/design.md the design overview
797
801
 
798
802
  ## Status
799
803
 
800
- MVP. Built: deterministic + classification + **LLM-judge** (rubric scoring;
801
- single judge or a Claude/GPT **panel** with per-dimension means, per-judge
802
- overalls, inter-judge disagreement flagging, and image/screenshot inputs)
803
- graders, http/replay/browser adapters, runner (N-sampling, optional
804
- concurrency, per-sample `RunResult` + `run_id` + variance), comparison/gate,
805
- JSON + run + outbox store (metric and per-case-score rows), **N-way sweeps
804
+ 2.0. The public API and the outbox row shape are stable; a breaking change to
805
+ either means a 3.0. Built: deterministic + classification + **LLM-judge**
806
+ (rubric scoring; single judge or a Claude/GPT **panel** with per-dimension
807
+ means, per-judge overalls, inter-judge disagreement flagging, and
808
+ image/screenshot inputs) graders, http/replay/browser adapters, runner
809
+ (N-sampling, optional concurrency, per-sample `RunResult` + `run_id` +
810
+ variance), comparison/gate, JSON + run + outbox store (one score-grain feed),
811
+ **N-way sweeps
806
812
  + counterbalanced pairwise A-vs-B win-rate** (`sweep`/`pairwise`), **blind
807
813
  human-rating + side-by-side ranking web apps** with judge↔human agreement and
808
814
  human-vs-judge pairwise agreement (`rate`/`agreement`, `rank`/`preferences`),
@@ -815,7 +821,8 @@ Known gaps / next:
815
821
  in, in `store.py`).
816
822
  - Cost/token capture: `Output.tokens`/`cost` fields exist but nothing
817
823
  populates them (an adapter must fill them from whatever usage its target
818
- reports).
824
+ reports), and neither is exported to the results store - usage accounting
825
+ is captured outside it.
819
826
  - Run robustness lands: retry with exponential backoff on transient failures
820
827
  for both the adapter (suite `retry:` + `Output.retryable`) and the LLM judge
821
828
  client, plus idempotent mid-run resume from a `run --checkpoint`.
@@ -127,19 +127,32 @@ Scorecards and comparisons serialize to JSON for CI artifacts and local files.
127
127
  For trend tracking, results can land in a column store (e.g. ClickHouse) keyed
128
128
  by `project`/`suite`. Rather than couple the engine to any particular database
129
129
  driver, `store.JsonlOutboxExporter` flattens a run into a stable, flat row
130
- shape and writes JSONL to an **outbox** a separate shipper drains:
131
-
132
- - one **metric** row per scorecard metric (`metric, value, stdev, metric_kind,
133
- n`), and
134
- - one **per-case score** row (`run_id, case_id, sample_idx, grader, metric,
135
- value, passed, detail`).
136
-
137
- Both repeat the full reproducibility key so a multi-tenant trend table can
138
- filter/group on any dimension without joins. The rows use a no-`Nullable`
139
- convention that maps cleanly onto a column store (a missing value is the
140
- sentinel pair `(value=0, has_value=false)`; `passed` is the tri-state string
141
- `'true'|'false'|'null'`). Swap the exporter for a real database client without
142
- touching the runner or any consumer.
130
+ shape and writes JSONL to an **outbox** a separate shipper drains: one feed at
131
+ `(run, case, sample, grader, metric)` grain, one row per score
132
+ (`run_id, case_id, sample_hash, grader, metric, metric_kind, value, passed,
133
+ detail`), carrying the invocation it came from and the gate verdict alongside.
134
+
135
+ Nothing derived is written. A run's scorecard and its trend are read-time
136
+ aggregations over the same rows, so they cannot disagree with the scores behind
137
+ them.
138
+
139
+ Every row repeats the full reproducibility key so a multi-tenant trend table can
140
+ filter/group on any dimension without joins.
141
+
142
+ A measurement that does not exist is `null`. A real `0.0` is a meaningful score -
143
+ what a failing deterministic check earns - so filling an absent one in with 0
144
+ would make the two unreadable apart, and `avg`/`sum`/`count` skip nulls anyway,
145
+ so no query has to remember a filter. `metric_kind` carries the engine's word
146
+ verbatim and says nothing about presence, so a metric some cases could not score
147
+ still reads as one metric; `'none'` is reserved for the two row shapes that hold
148
+ no score at all. `passed` is the tri-state string
149
+ `'true'|'false'|'null'`, since a judge has no pass line by design: a third
150
+ state, not a missing value.
151
+
152
+ A store that forbids nullable columns is free to fill those nulls in at ingest.
153
+ That mapping belongs at its boundary, not in the engine, which keeps absence
154
+ representable all the way out. Swap the exporter for a real database client
155
+ without touching the runner or any consumer.
143
156
 
144
157
  ## 7. Provenance
145
158
 
@@ -1,4 +1,4 @@
1
- # evalkit - a generic, consumer-agnostic eval engine. Standard uv project.
1
+ # evalcore - a generic, consumer-agnostic eval engine. Standard uv project.
2
2
 
3
3
  # Sync the environment (editable install + dev/extras).
4
4
  sync:
@@ -23,7 +23,7 @@ lint:
23
23
 
24
24
  # Run the quickstart suite offline against recorded fixtures.
25
25
  example:
26
- uv run evalkit --plugins examples.quickstart.graders gate --suite examples/quickstart/suite.yaml --mode replay
26
+ uv run evalcore --plugins examples.quickstart.graders gate --suite examples/quickstart/suite.yaml --mode replay
27
27
 
28
28
  # Run the quickstart suite through the Python API (no CLI), offline.
29
29
  example-api:
@@ -31,4 +31,4 @@ example-api:
31
31
 
32
32
  # Head-to-head A-vs-B win-rate over the quickstart suite (offline).
33
33
  example-pairwise:
34
- uv run evalkit --plugins examples.quickstart.graders pairwise --suite examples/quickstart/suite.yaml --a baseline --b candidate --mode replay
34
+ uv run evalcore --plugins examples.quickstart.graders pairwise --suite examples/quickstart/suite.yaml --a baseline --b candidate --mode replay
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "evalcore"
3
- version = "0.3.0"
3
+ version = "2.0.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"
@@ -8,7 +8,7 @@ license = "BSD-3-Clause"
8
8
  license-files = ["LICENSE"]
9
9
  authors = [{ name = "AWeber Communications" }]
10
10
  classifiers = [
11
- "Development Status :: 4 - Beta",
11
+ "Development Status :: 5 - Production/Stable",
12
12
  "Intended Audience :: Developers",
13
13
  "Natural Language :: English",
14
14
  "Operating System :: OS Independent",
@@ -60,12 +60,15 @@ except (
60
60
  __version__ = '0.0.0+unknown'
61
61
 
62
62
  __all__ = [
63
+ 'ConfigError',
64
+ 'EvalcoreError',
63
65
  '__version__',
64
- # subpackages / modules
65
66
  'adapters',
66
67
  'compare',
67
68
  'errors',
68
69
  'graders',
70
+ 'load_cases',
71
+ 'load_suite',
69
72
  'loader',
70
73
  'models',
71
74
  'pairwise',
@@ -74,15 +77,9 @@ __all__ = [
74
77
  'report',
75
78
  'reporters',
76
79
  'retry',
80
+ 'run_suite',
81
+ 'run_suite_sync',
77
82
  'runner',
78
83
  'store',
79
84
  'sweep',
80
- # exception types
81
- 'ConfigError',
82
- 'EvalcoreError',
83
- # convenience entry points
84
- 'load_cases',
85
- 'load_suite',
86
- 'run_suite',
87
- 'run_suite_sync',
88
85
  ]
@@ -3,7 +3,8 @@
3
3
  Everything evalcore raises derives from :class:`EvalcoreError`, so a caller can
4
4
  catch all of it with a single ``except EvalcoreError``. Where a builtin type is
5
5
  also idiomatic, the subclass inherits it too - e.g. :class:`ConfigError` is
6
- also a ``ValueError`` - so existing ``except ValueError`` handlers keep working.
6
+ also a ``ValueError`` - so existing ``except ValueError`` handlers keep
7
+ working.
7
8
  """
8
9
 
9
10
 
@@ -99,11 +99,18 @@ class Score(pydantic.BaseModel):
99
99
 
100
100
 
101
101
  class CaseResult(pydantic.BaseModel):
102
- """The output + per-case scores for one (case, sample)."""
102
+ """The output + per-case scores for one (case, sample).
103
+
104
+ ``sample_hash`` is a content digest of the output this sample produced,
105
+ minted by the runner. It identifies the sample by what came back rather
106
+ than by position, so a row is traceable to the exact response behind it.
107
+ Two runs of the same case do not share a hash - anything comparing runs
108
+ aligns on ``case.id`` and sample order, not on this field.
109
+ """
103
110
 
104
111
  case: Case
105
112
  variant_name: str
106
- sample_idx: int
113
+ sample_hash: str
107
114
  output: Output
108
115
  scores: list[Score] = pydantic.Field(default_factory=list)
109
116
 
@@ -182,11 +189,14 @@ class Rating(pydantic.BaseModel):
182
189
  ingestible from any external tool. ``scores`` maps rubric dimension ->
183
190
  an integer on the same 1..scale the judge used, so human and judge are
184
191
  directly comparable.
192
+
193
+ ``sample_hash`` is the rated output's digest from
194
+ :class:`CaseResult`, so a rating points at the exact response a human saw.
185
195
  """
186
196
 
187
197
  run_id: str
188
198
  case_id: str
189
- sample_idx: int = 0
199
+ sample_hash: str = ''
190
200
  rater: str
191
201
  scores: dict[str, int] = pydantic.Field(default_factory=dict)
192
202
  rated_at: str | None = None
@@ -202,10 +212,16 @@ class Preference(pydantic.BaseModel):
202
212
  counterbalances left/right per rater and un-blinds server-side, so a
203
213
  stored ``'a'`` always means ``variant_a`` won regardless of which side it
204
214
  was shown on.
215
+
216
+ A comparison spans two runs, whose outputs hash differently, so
217
+ ``sample_hash`` carries the ``variant_a`` side's digest as the pair's
218
+ identity. :class:`PairwiseOutcome` anchors on the same side, which is what
219
+ lets the two join in
220
+ :func:`~evalcore.rating.compute_pairwise_agreement`.
205
221
  """
206
222
 
207
223
  case_id: str
208
- sample_idx: int = 0
224
+ sample_hash: str = ''
209
225
  variant_a: str
210
226
  variant_b: str
211
227
  rater: str
@@ -270,10 +286,14 @@ class SweepResult(pydantic.BaseModel):
270
286
 
271
287
 
272
288
  class PairwiseOutcome(pydantic.BaseModel):
273
- """The head-to-head result for one case (counterbalanced for order)."""
289
+ """The head-to-head result for one case (counterbalanced for order).
290
+
291
+ ``sample_hash`` is the ``variant_a`` output's digest, the same side
292
+ :class:`Preference` anchors on.
293
+ """
274
294
 
275
295
  case_id: str
276
- sample_idx: int = 0
296
+ sample_hash: str = ''
277
297
  winner: typing.Literal['a', 'b', 'tie'] = 'tie'
278
298
  detail: str | None = None
279
299
 
@@ -339,7 +359,7 @@ class PairwiseAgreementCase(pydantic.BaseModel):
339
359
  """Human-panel vs LLM-judge winner for one case."""
340
360
 
341
361
  case_id: str
342
- sample_idx: int = 0
362
+ sample_hash: str = ''
343
363
  human: typing.Literal['a', 'b', 'tie'] = 'tie'
344
364
  judge: typing.Literal['a', 'b', 'tie'] = 'tie'
345
365
  agree: bool = False
@@ -4,8 +4,15 @@ Rubric scoring asks "how good is this output, 1..5"; pairwise asks the
4
4
  sharper question "is A better than B for this case?" and reports A's
5
5
  win-rate. It is a cross-variant operation the per-variant runner can't
6
6
  express as a grader (it needs both variants' output for the same case at
7
- once), so it lives here: align two runs by (case_id, sample_idx), ask a
8
- judge to pick a winner per case, aggregate.
7
+ once), so it lives here: align two runs by case, ask a judge to pick a winner
8
+ per case, aggregate.
9
+
10
+ Alignment is by ``case_id`` and then sample order within the case. It cannot
11
+ key on ``sample_hash``: that is a digest of the output, and the whole point of
12
+ a comparison is that the two variants produced different output, so the hashes
13
+ never match. A case's samples are repeat draws of one input, so pairing the
14
+ n-th of A with the n-th of B is as meaningful as any other pairing. Each
15
+ outcome records A's hash, so a pair traces back to a concrete response.
9
16
 
10
17
  Position bias (LLMs favour whichever option they see first) is handled by
11
18
  **counterbalancing**: each pair is judged in both orders and a pick that
@@ -207,21 +214,43 @@ def build_pairwise_client(mode: str, config: dict) -> PairwiseClient:
207
214
  return AnthropicPairwiseClient(model)
208
215
 
209
216
 
210
- def _content_map(
211
- run: models.RunResult, content_ref: str
212
- ) -> dict[tuple[str, int], tuple[str, models.CaseResult]]:
213
- out: dict[tuple[str, int], tuple[str, models.CaseResult]] = {}
217
+ def comparable_samples(
218
+ run: models.RunResult, content_ref: str | None
219
+ ) -> dict[str, list[tuple[str, models.CaseResult]]]:
220
+ """``case_id -> [(content, result)]`` for the samples a comparison can use.
221
+
222
+ A sample is comparable when the invocation succeeded and - if a
223
+ ``content_ref`` is given - that ref resolves to non-empty text. An errored
224
+ output has nothing meaningful to judge or to show a rater, and empty
225
+ content gives a judge nothing to weigh and a human an empty panel.
226
+ ``content`` is ``''`` when no ``content_ref`` was asked for.
227
+
228
+ Every path that pairs two runs has to filter through here, because
229
+ alignment within a case is positional: a sample one side keeps and the
230
+ other drops shifts every pair after it. The LLM judge would then compare
231
+ A's n-th sample against B's n-th while the human panel compared that same
232
+ A sample against a different B, and
233
+ :func:`~evalcore.rating.compute_pairwise_agreement` joins the two on A's
234
+ digest - so it would score two different comparisons as if they were one.
235
+ """
236
+ out: dict[str, list[tuple[str, models.CaseResult]]] = {}
214
237
  for result in run.results:
215
- ctx = {
216
- 'input': result.case.input,
217
- 'expected': result.case.expected or {},
218
- 'output': result.output.fields,
219
- 'case': result.case.model_dump(),
220
- 'artifacts': result.output.artifacts,
221
- }
222
- content = refs.resolve_ref(ctx, content_ref)
223
- if isinstance(content, str) and content:
224
- out[result.case.id, result.sample_idx] = (content, result)
238
+ if result.output.error:
239
+ continue
240
+ content = ''
241
+ if content_ref:
242
+ ctx = {
243
+ 'input': result.case.input,
244
+ 'expected': result.case.expected or {},
245
+ 'output': result.output.fields,
246
+ 'case': result.case.model_dump(),
247
+ 'artifacts': result.output.artifacts,
248
+ }
249
+ resolved = refs.resolve_ref(ctx, content_ref)
250
+ if not (isinstance(resolved, str) and resolved):
251
+ continue
252
+ content = resolved
253
+ out.setdefault(result.case.id, []).append((content, result))
225
254
  return out
226
255
 
227
256
 
@@ -249,15 +278,23 @@ async def judge_pairwise(
249
278
  judge_version: str = 'v1',
250
279
  ) -> models.PairwiseResult:
251
280
  """Compare two runs case-by-case and report A's win-rate."""
252
- a_map = _content_map(run_a, content_ref)
253
- b_map = _content_map(run_b, content_ref)
281
+ a_map = comparable_samples(run_a, content_ref)
282
+ b_map = comparable_samples(run_b, content_ref)
254
283
  context_refs = context_refs or {}
255
284
 
285
+ # Comparable pairs: only cases both variants produced, and within a case
286
+ # the n-th sample of A against the n-th of B. strict=False because an
287
+ # uneven sample count is expected - a case that errored on one side has
288
+ # fewer usable outputs there, and the extras have nothing to compare to.
289
+ pairs = [
290
+ (case_id, a_side, b_side)
291
+ for case_id in sorted(a_map.keys() & b_map.keys())
292
+ for a_side, b_side in zip(a_map[case_id], b_map[case_id], strict=False)
293
+ ]
294
+
256
295
  outcomes: list[models.PairwiseOutcome] = []
257
296
  a_wins = b_wins = ties = 0
258
- for key in sorted(a_map.keys() & b_map.keys()):
259
- content_a, result_a = a_map[key]
260
- content_b, _ = b_map[key]
297
+ for case_id, (content_a, result_a), (content_b, _) in pairs:
261
298
  context = {
262
299
  label: refs.resolve_ref(
263
300
  {
@@ -299,7 +336,10 @@ async def judge_pairwise(
299
336
  ties += 1
300
337
  outcomes.append(
301
338
  models.PairwiseOutcome(
302
- case_id=key[0], sample_idx=key[1], winner=winner, detail=detail
339
+ case_id=case_id,
340
+ sample_hash=result_a.sample_hash,
341
+ winner=winner,
342
+ detail=detail,
303
343
  )
304
344
  )
305
345