evalcore 1.0.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 (79) hide show
  1. {evalcore-1.0.0 → evalcore-2.0.0}/CHANGELOG.md +53 -3
  2. {evalcore-1.0.0 → evalcore-2.0.0}/PKG-INFO +15 -14
  3. {evalcore-1.0.0 → evalcore-2.0.0}/README.md +14 -13
  4. {evalcore-1.0.0 → evalcore-2.0.0}/docs/design.md +26 -13
  5. {evalcore-1.0.0 → evalcore-2.0.0}/justfile +3 -3
  6. {evalcore-1.0.0 → evalcore-2.0.0}/pyproject.toml +1 -1
  7. {evalcore-1.0.0 → evalcore-2.0.0}/src/evalcore/models.py +27 -7
  8. {evalcore-1.0.0 → evalcore-2.0.0}/src/evalcore/pairwise.py +62 -22
  9. {evalcore-1.0.0 → evalcore-2.0.0}/src/evalcore/rating.py +56 -46
  10. {evalcore-1.0.0 → evalcore-2.0.0}/src/evalcore/reporters/base.py +5 -1
  11. {evalcore-1.0.0 → evalcore-2.0.0}/src/evalcore/runner.py +47 -23
  12. {evalcore-1.0.0 → evalcore-2.0.0}/src/evalcore/store.py +80 -39
  13. {evalcore-1.0.0 → evalcore-2.0.0}/tests/test_cli.py +16 -6
  14. {evalcore-1.0.0 → evalcore-2.0.0}/tests/test_edge_cases.py +2 -2
  15. evalcore-2.0.0/tests/test_pairwise_extra.py +178 -0
  16. {evalcore-1.0.0 → evalcore-2.0.0}/tests/test_rating.py +26 -41
  17. {evalcore-1.0.0 → evalcore-2.0.0}/tests/test_rating_server.py +2 -2
  18. {evalcore-1.0.0 → evalcore-2.0.0}/tests/test_reporters.py +3 -3
  19. {evalcore-1.0.0 → evalcore-2.0.0}/tests/test_runner.py +125 -17
  20. {evalcore-1.0.0 → evalcore-2.0.0}/tests/test_store.py +64 -11
  21. {evalcore-1.0.0 → evalcore-2.0.0}/tests/test_sweep_pairwise.py +1 -1
  22. {evalcore-1.0.0 → evalcore-2.0.0}/tests/test_unit.py +1 -1
  23. {evalcore-1.0.0 → evalcore-2.0.0}/uv.lock +1 -1
  24. evalcore-1.0.0/docs/clickhouse-schema.html +0 -1112
  25. evalcore-1.0.0/docs/clickhouse-schema.sql +0 -412
  26. evalcore-1.0.0/tests/test_pairwise_extra.py +0 -68
  27. {evalcore-1.0.0 → evalcore-2.0.0}/.github/workflows/ci.yml +0 -0
  28. {evalcore-1.0.0 → evalcore-2.0.0}/.github/workflows/publish.yml +0 -0
  29. {evalcore-1.0.0 → evalcore-2.0.0}/.gitignore +0 -0
  30. {evalcore-1.0.0 → evalcore-2.0.0}/.pre-commit-config.yaml +0 -0
  31. {evalcore-1.0.0 → evalcore-2.0.0}/LICENSE +0 -0
  32. {evalcore-1.0.0 → evalcore-2.0.0}/examples/__init__.py +0 -0
  33. {evalcore-1.0.0 → evalcore-2.0.0}/examples/quickstart/README.md +0 -0
  34. {evalcore-1.0.0 → evalcore-2.0.0}/examples/quickstart/__init__.py +0 -0
  35. {evalcore-1.0.0 → evalcore-2.0.0}/examples/quickstart/adapter.py +0 -0
  36. {evalcore-1.0.0 → evalcore-2.0.0}/examples/quickstart/datasets/support_reply/v1/cases/angry_complaint.yaml +0 -0
  37. {evalcore-1.0.0 → evalcore-2.0.0}/examples/quickstart/datasets/support_reply/v1/cases/billing_question.yaml +0 -0
  38. {evalcore-1.0.0 → evalcore-2.0.0}/examples/quickstart/datasets/support_reply/v1/cases/double_charge.yaml +0 -0
  39. {evalcore-1.0.0 → evalcore-2.0.0}/examples/quickstart/datasets/support_reply/v1/cases/refund_request.yaml +0 -0
  40. {evalcore-1.0.0 → evalcore-2.0.0}/examples/quickstart/fixtures/support_reply_judge.yaml +0 -0
  41. {evalcore-1.0.0 → evalcore-2.0.0}/examples/quickstart/fixtures/support_reply_pairwise.yaml +0 -0
  42. {evalcore-1.0.0 → evalcore-2.0.0}/examples/quickstart/fixtures/support_reply_replay.yaml +0 -0
  43. {evalcore-1.0.0 → evalcore-2.0.0}/examples/quickstart/graders.py +0 -0
  44. {evalcore-1.0.0 → evalcore-2.0.0}/examples/quickstart/run_eval.py +0 -0
  45. {evalcore-1.0.0 → evalcore-2.0.0}/examples/quickstart/suite.yaml +0 -0
  46. {evalcore-1.0.0 → evalcore-2.0.0}/examples/quickstart/tests/__init__.py +0 -0
  47. {evalcore-1.0.0 → evalcore-2.0.0}/examples/quickstart/tests/test_quickstart.py +0 -0
  48. {evalcore-1.0.0 → evalcore-2.0.0}/pyrightconfig.json +0 -0
  49. {evalcore-1.0.0 → evalcore-2.0.0}/src/evalcore/__init__.py +0 -0
  50. {evalcore-1.0.0 → evalcore-2.0.0}/src/evalcore/adapters/__init__.py +0 -0
  51. {evalcore-1.0.0 → evalcore-2.0.0}/src/evalcore/adapters/base.py +0 -0
  52. {evalcore-1.0.0 → evalcore-2.0.0}/src/evalcore/adapters/env.py +0 -0
  53. {evalcore-1.0.0 → evalcore-2.0.0}/src/evalcore/adapters/http.py +0 -0
  54. {evalcore-1.0.0 → evalcore-2.0.0}/src/evalcore/adapters/replay.py +0 -0
  55. {evalcore-1.0.0 → evalcore-2.0.0}/src/evalcore/cli.py +0 -0
  56. {evalcore-1.0.0 → evalcore-2.0.0}/src/evalcore/compare.py +0 -0
  57. {evalcore-1.0.0 → evalcore-2.0.0}/src/evalcore/errors.py +0 -0
  58. {evalcore-1.0.0 → evalcore-2.0.0}/src/evalcore/graders/__init__.py +0 -0
  59. {evalcore-1.0.0 → evalcore-2.0.0}/src/evalcore/graders/base.py +0 -0
  60. {evalcore-1.0.0 → evalcore-2.0.0}/src/evalcore/graders/classification.py +0 -0
  61. {evalcore-1.0.0 → evalcore-2.0.0}/src/evalcore/graders/deterministic.py +0 -0
  62. {evalcore-1.0.0 → evalcore-2.0.0}/src/evalcore/graders/judge.py +0 -0
  63. {evalcore-1.0.0 → evalcore-2.0.0}/src/evalcore/graders/numeric.py +0 -0
  64. {evalcore-1.0.0 → evalcore-2.0.0}/src/evalcore/loader.py +0 -0
  65. {evalcore-1.0.0 → evalcore-2.0.0}/src/evalcore/py.typed +0 -0
  66. {evalcore-1.0.0 → evalcore-2.0.0}/src/evalcore/refs.py +0 -0
  67. {evalcore-1.0.0 → evalcore-2.0.0}/src/evalcore/report.py +0 -0
  68. {evalcore-1.0.0 → evalcore-2.0.0}/src/evalcore/reporters/__init__.py +0 -0
  69. {evalcore-1.0.0 → evalcore-2.0.0}/src/evalcore/reporters/html.py +0 -0
  70. {evalcore-1.0.0 → evalcore-2.0.0}/src/evalcore/reporters/markdown.py +0 -0
  71. {evalcore-1.0.0 → evalcore-2.0.0}/src/evalcore/retry.py +0 -0
  72. {evalcore-1.0.0 → evalcore-2.0.0}/src/evalcore/sweep.py +0 -0
  73. {evalcore-1.0.0 → evalcore-2.0.0}/tests/__init__.py +0 -0
  74. {evalcore-1.0.0 → evalcore-2.0.0}/tests/test_adapters.py +0 -0
  75. {evalcore-1.0.0 → evalcore-2.0.0}/tests/test_judge.py +0 -0
  76. {evalcore-1.0.0 → evalcore-2.0.0}/tests/test_judge_extra.py +0 -0
  77. {evalcore-1.0.0 → evalcore-2.0.0}/tests/test_live_clients.py +0 -0
  78. {evalcore-1.0.0 → evalcore-2.0.0}/tests/test_retry.py +0 -0
  79. {evalcore-1.0.0 → evalcore-2.0.0}/uv.toml +0 -0
@@ -6,6 +6,56 @@ 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
+
9
59
  ## [1.0.0] - 2026-07-28
10
60
 
11
61
  First stable release. The public API and the outbox row shape are now covered
@@ -20,9 +70,9 @@ by semantic versioning: a breaking change to either means a 2.0.
20
70
 
21
71
  ### Added
22
72
  - `docs/clickhouse-schema.sql`: the ClickHouse schema the outbox rows target -
23
- the written `evaluation_scores` table, the run-grain `evaluations` view over
24
- it, the row grammar as `CONSTRAINT`s, and the trend queries. Also rendered as
25
- `docs/clickhouse-schema.html`.
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.)
26
76
 
27
77
  ## [0.3.0] - 2026-07-28
28
78
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evalcore
3
- Version: 1.0.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
@@ -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
 
@@ -774,13 +774,15 @@ scored value, the invocation it came from, the gate verdict, and the full
774
774
  reproducibility key (incl. `run_id`), so a multi-tenant trend table can
775
775
  filter/group on any dimension without joins. Nothing derived is written: a
776
776
  run's scorecard is a read-time aggregation over the same rows, so it cannot
777
- disagree with the scores behind it. A missing measurement is `null` (those
778
- columns are `Nullable`, and a real `0.0` is a meaningful score); `passed` is
779
- the tri-state string `'true'|'false'|'null'`, matching its `Enum8`. Row keys
780
- are the store's column names, so a JSONEachRow-style feed maps onto the schema
781
- in
782
- [`docs/clickhouse-schema.sql`](docs/clickhouse-schema.sql). Swap the exporter
783
- for a real database client without touching the runner or any consumer.
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.
784
786
 
785
787
  ---
786
788
 
@@ -827,19 +829,18 @@ src/evalcore/
827
829
  tests/ engine unit tests
828
830
  examples/quickstart a runnable consumer that doubles as an implementation test
829
831
  docs/design.md the design overview
830
- docs/clickhouse-schema.sql the results-store schema the outbox rows target
831
832
  ```
832
833
 
833
834
  ## Status
834
835
 
835
- 1.0. The public API and the outbox row shape are stable; a breaking change to
836
- either means a 2.0. Built: deterministic + classification + **LLM-judge**
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**
837
838
  (rubric scoring; single judge or a Claude/GPT **panel** with per-dimension
838
839
  means, per-judge overalls, inter-judge disagreement flagging, and
839
840
  image/screenshot inputs) graders, http/replay/browser adapters, runner
840
841
  (N-sampling, optional concurrency, per-sample `RunResult` + `run_id` +
841
- variance), comparison/gate, JSON + run + outbox store (one score-grain feed,
842
- `docs/clickhouse-schema.sql`), **N-way sweeps
842
+ variance), comparison/gate, JSON + run + outbox store (one score-grain feed),
843
+ **N-way sweeps
843
844
  + counterbalanced pairwise A-vs-B win-rate** (`sweep`/`pairwise`), **blind
844
845
  human-rating + side-by-side ranking web apps** with judge↔human agreement and
845
846
  human-vs-judge pairwise agreement (`rate`/`agreement`, `rank`/`preferences`),
@@ -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
 
@@ -742,13 +742,15 @@ scored value, the invocation it came from, the gate verdict, and the full
742
742
  reproducibility key (incl. `run_id`), so a multi-tenant trend table can
743
743
  filter/group on any dimension without joins. Nothing derived is written: a
744
744
  run's scorecard is a read-time aggregation over the same rows, so it cannot
745
- disagree with the scores behind it. A missing measurement is `null` (those
746
- columns are `Nullable`, and a real `0.0` is a meaningful score); `passed` is
747
- the tri-state string `'true'|'false'|'null'`, matching its `Enum8`. Row keys
748
- are the store's column names, so a JSONEachRow-style feed maps onto the schema
749
- in
750
- [`docs/clickhouse-schema.sql`](docs/clickhouse-schema.sql). Swap the exporter
751
- for a real database client without touching the runner or any consumer.
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.
752
754
 
753
755
  ---
754
756
 
@@ -795,19 +797,18 @@ src/evalcore/
795
797
  tests/ engine unit tests
796
798
  examples/quickstart a runnable consumer that doubles as an implementation test
797
799
  docs/design.md the design overview
798
- docs/clickhouse-schema.sql the results-store schema the outbox rows target
799
800
  ```
800
801
 
801
802
  ## Status
802
803
 
803
- 1.0. The public API and the outbox row shape are stable; a breaking change to
804
- either means a 2.0. Built: deterministic + classification + **LLM-judge**
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**
805
806
  (rubric scoring; single judge or a Claude/GPT **panel** with per-dimension
806
807
  means, per-judge overalls, inter-judge disagreement flagging, and
807
808
  image/screenshot inputs) graders, http/replay/browser adapters, runner
808
809
  (N-sampling, optional concurrency, per-sample `RunResult` + `run_id` +
809
- variance), comparison/gate, JSON + run + outbox store (one score-grain feed,
810
- `docs/clickhouse-schema.sql`), **N-way sweeps
810
+ variance), comparison/gate, JSON + run + outbox store (one score-grain feed),
811
+ **N-way sweeps
811
812
  + counterbalanced pairwise A-vs-B win-rate** (`sweep`/`pairwise`), **blind
812
813
  human-rating + side-by-side ranking web apps** with judge↔human agreement and
813
814
  human-vs-judge pairwise agreement (`rate`/`agreement`, `rank`/`preferences`),
@@ -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 = "1.0.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"
@@ -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