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.
- {evalcore-0.3.0 → evalcore-2.0.0}/CHANGELOG.md +70 -1
- {evalcore-0.3.0 → evalcore-2.0.0}/PKG-INFO +28 -21
- {evalcore-0.3.0 → evalcore-2.0.0}/README.md +26 -19
- {evalcore-0.3.0 → evalcore-2.0.0}/docs/design.md +26 -13
- {evalcore-0.3.0 → evalcore-2.0.0}/justfile +3 -3
- {evalcore-0.3.0 → evalcore-2.0.0}/pyproject.toml +2 -2
- {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/__init__.py +6 -9
- {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/errors.py +2 -1
- {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/models.py +27 -7
- {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/pairwise.py +62 -22
- {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/rating.py +56 -46
- {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/reporters/base.py +5 -1
- {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/runner.py +47 -23
- {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/store.py +81 -46
- {evalcore-0.3.0 → evalcore-2.0.0}/tests/test_cli.py +17 -7
- {evalcore-0.3.0 → evalcore-2.0.0}/tests/test_edge_cases.py +2 -2
- evalcore-2.0.0/tests/test_pairwise_extra.py +178 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/tests/test_rating.py +26 -41
- {evalcore-0.3.0 → evalcore-2.0.0}/tests/test_rating_server.py +2 -2
- {evalcore-0.3.0 → evalcore-2.0.0}/tests/test_reporters.py +3 -3
- {evalcore-0.3.0 → evalcore-2.0.0}/tests/test_runner.py +125 -17
- {evalcore-0.3.0 → evalcore-2.0.0}/tests/test_store.py +68 -15
- {evalcore-0.3.0 → evalcore-2.0.0}/tests/test_sweep_pairwise.py +1 -1
- {evalcore-0.3.0 → evalcore-2.0.0}/tests/test_unit.py +1 -1
- {evalcore-0.3.0 → evalcore-2.0.0}/uv.lock +1 -1
- evalcore-0.3.0/tests/test_pairwise_extra.py +0 -68
- {evalcore-0.3.0 → evalcore-2.0.0}/.github/workflows/ci.yml +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/.github/workflows/publish.yml +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/.gitignore +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/.pre-commit-config.yaml +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/LICENSE +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/examples/__init__.py +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/examples/quickstart/README.md +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/examples/quickstart/__init__.py +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/examples/quickstart/adapter.py +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/examples/quickstart/datasets/support_reply/v1/cases/angry_complaint.yaml +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/examples/quickstart/datasets/support_reply/v1/cases/billing_question.yaml +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/examples/quickstart/datasets/support_reply/v1/cases/double_charge.yaml +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/examples/quickstart/datasets/support_reply/v1/cases/refund_request.yaml +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/examples/quickstart/fixtures/support_reply_judge.yaml +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/examples/quickstart/fixtures/support_reply_pairwise.yaml +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/examples/quickstart/fixtures/support_reply_replay.yaml +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/examples/quickstart/graders.py +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/examples/quickstart/run_eval.py +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/examples/quickstart/suite.yaml +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/examples/quickstart/tests/__init__.py +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/examples/quickstart/tests/test_quickstart.py +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/pyrightconfig.json +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/adapters/__init__.py +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/adapters/base.py +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/adapters/env.py +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/adapters/http.py +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/adapters/replay.py +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/cli.py +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/compare.py +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/graders/__init__.py +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/graders/base.py +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/graders/classification.py +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/graders/deterministic.py +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/graders/judge.py +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/graders/numeric.py +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/loader.py +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/py.typed +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/refs.py +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/report.py +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/reporters/__init__.py +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/reporters/html.py +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/reporters/markdown.py +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/retry.py +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/src/evalcore/sweep.py +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/tests/__init__.py +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/tests/test_adapters.py +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/tests/test_judge.py +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/tests/test_judge_extra.py +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/tests/test_live_clients.py +0 -0
- {evalcore-0.3.0 → evalcore-2.0.0}/tests/test_retry.py +0 -0
- {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.
|
|
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
|
+
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 ::
|
|
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,
|
|
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
|
|
772
|
-
`
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
`'true'|'false'|'null'
|
|
781
|
-
|
|
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
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
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,
|
|
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
|
|
740
|
-
`
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
`'true'|'false'|'null'
|
|
749
|
-
|
|
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
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
|
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
|
+
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 ::
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
8
|
-
|
|
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
|
|
211
|
-
run: models.RunResult, content_ref: str
|
|
212
|
-
) -> dict[
|
|
213
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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 =
|
|
253
|
-
b_map =
|
|
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
|
|
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=
|
|
339
|
+
case_id=case_id,
|
|
340
|
+
sample_hash=result_a.sample_hash,
|
|
341
|
+
winner=winner,
|
|
342
|
+
detail=detail,
|
|
303
343
|
)
|
|
304
344
|
)
|
|
305
345
|
|