evalquill 0.1.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Priteesh Karra
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,256 @@
1
+ Metadata-Version: 2.4
2
+ Name: evalquill
3
+ Version: 0.1.0
4
+ Summary: Compare two LLM evaluation runs case by case to see which cases a prompt change broke
5
+ Keywords: llm,evaluation,prompt-engineering,testing
6
+ Author: Priteesh Karra
7
+ Author-email: Priteesh Karra <WritetoPriteesh@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Software Development :: Testing
14
+ Requires-Dist: google-genai>=2.24.0 ; extra == 'gemini'
15
+ Requires-Dist: openai>=1.0 ; extra == 'openai'
16
+ Requires-Python: >=3.10
17
+ Project-URL: Homepage, https://github.com/priteesshhh/evalquill
18
+ Project-URL: Issues, https://github.com/priteesshhh/evalquill/issues
19
+ Provides-Extra: gemini
20
+ Provides-Extra: openai
21
+ Description-Content-Type: text/markdown
22
+
23
+ # evalquill
24
+
25
+ [![CI](https://github.com/priteesshhh/evalquill/actions/workflows/ci.yml/badge.svg)](https://github.com/priteesshhh/evalquill/actions/workflows/ci.yml)
26
+
27
+ A small Python library for checking what changed when you edit an LLM prompt.
28
+
29
+ You change a prompt, rerun your evals, and the average score moves from 0.72 to 0.75. Did it get better? Maybe. It might also have fixed four cases and broken two that used to work. The average won't tell you that.
30
+
31
+ evalquill compares two runs case by case and tells you which specific cases got worse.
32
+
33
+ ## Install
34
+
35
+ Requires Python 3.10 or later. The core has no runtime dependencies.
36
+
37
+ ```bash
38
+ pip install evalquill
39
+ ```
40
+
41
+ The OpenAI and Gemini adapters are optional extras:
42
+
43
+ ```bash
44
+ pip install "evalquill[openai]"
45
+ pip install "evalquill[gemini]"
46
+ ```
47
+
48
+ To work on it locally:
49
+
50
+ ```bash
51
+ git clone https://github.com/priteesshhh/evalquill.git
52
+ cd evalquill
53
+ uv sync
54
+ ```
55
+
56
+ ## Quickstart
57
+
58
+ A metric is a function `(response, expected) -> float` that returns a score from 0 to 1. An LLM is any function `(prompt) -> str`: a provider client, a wrapper around your own model, or a stub.
59
+
60
+ This example classifies support tickets. Two stub functions stand in for the same model under two versions of a prompt, so it runs without an API key.
61
+
62
+ ```python
63
+ from evalquill import evaluate_dataset
64
+ from evalquill.metrics import normalized_exact_match
65
+ from evalquill.compare import compare, diff_summary
66
+
67
+ dataset = [
68
+ {"id": "refund", "prompt": "I was charged twice this month.", "expected": "billing"},
69
+ {"id": "login", "prompt": "The login page shows a 500 error.", "expected": "account"},
70
+ {"id": "invite", "prompt": "My teammate never got the invite email.", "expected": "account"},
71
+ {"id": "webhook", "prompt": "Our webhook stopped receiving events.", "expected": "integration"},
72
+ ]
73
+
74
+ def llm_v1(prompt):
75
+ return {
76
+ "I was charged twice this month.": "billing",
77
+ "The login page shows a 500 error.": "bug",
78
+ "My teammate never got the invite email.": "account",
79
+ "Our webhook stopped receiving events.": "bug",
80
+ }[prompt]
81
+
82
+ def llm_v2(prompt):
83
+ return {
84
+ "I was charged twice this month.": "billing",
85
+ "The login page shows a 500 error.": "account",
86
+ "My teammate never got the invite email.": "integration",
87
+ "Our webhook stopped receiving events.": "integration",
88
+ }[prompt]
89
+
90
+ baseline = evaluate_dataset(dataset, llm_v1, [normalized_exact_match])
91
+ candidate = evaluate_dataset(dataset, llm_v2, [normalized_exact_match])
92
+
93
+ summary = diff_summary(compare(baseline, candidate))
94
+ print(summary["normalized_exact_match"])
95
+ ```
96
+
97
+ ```
98
+ {'baseline_mean': 0.5, 'candidate_mean': 0.75, 'mean_delta': 0.25, 'improved': ['login', 'webhook'], 'regressed': ['invite'], 'newly_failing': ['invite']}
99
+ ```
100
+
101
+ Accuracy rose from 0.50 to 0.75. Two cases improved, and `invite`, which v1 classified correctly, v2 now gets wrong. The average alone would not have shown that.
102
+
103
+ Each result row keeps the input, the response and its scores:
104
+
105
+ ```python
106
+ {"id": "invite", "prompt": "My teammate never got the invite email.",
107
+ "expected": "account", "response": "integration",
108
+ "scores": {"normalized_exact_match": 0.0}}
109
+ ```
110
+
111
+ `examples/prompt_change_demo.py` runs the same comparison through saved runs and prints the responses behind each regression.
112
+
113
+ ## Failing a build
114
+
115
+ A regression check belongs in your test suite. Continuing from the quickstart, with `dataset` and `llm_v2` imported from wherever your project defines them:
116
+
117
+ ```python
118
+ from evalquill import evaluate_dataset, summary_scores, threshold_check
119
+ from evalquill.compare import compare, diff_summary
120
+ from evalquill.metrics import normalized_exact_match
121
+ from evalquill.storage import load_run
122
+
123
+ def test_accuracy_meets_threshold():
124
+ candidate = evaluate_dataset(dataset, llm_v2, [normalized_exact_match])
125
+ checks = threshold_check(summary_scores(candidate), {"normalized_exact_match": 0.7})
126
+ assert checks == {"normalized_exact_match": "PASS"}
127
+
128
+ def test_no_case_stops_passing():
129
+ baseline = load_run("runs/baseline.json")["results"]
130
+ candidate = evaluate_dataset(dataset, llm_v2, [normalized_exact_match])
131
+ summary = diff_summary(compare(baseline, candidate))["normalized_exact_match"]
132
+ assert summary["newly_failing"] == [], f"stopped passing: {summary['newly_failing']}"
133
+ ```
134
+
135
+ The baseline is a saved run (see Saving runs below). With the quickstart's v2 prompt, the first test passes, since 0.75 clears 0.7, and the second fails:
136
+
137
+ ```
138
+ AssertionError: stopped passing: ['invite']
139
+ ```
140
+
141
+ The accuracy bar was met and a case still broke. A threshold alone lets that through.
142
+
143
+ `threshold_check` returns the strings `"PASS"`, `"FAIL"` and `"NOT RUN"`, so compare them explicitly. `assert all(checks.values())` would pass on `"FAIL"`, because every non-empty string is truthy.
144
+
145
+ ## Saving runs
146
+
147
+ Save a run once and compare against it later, without paying for the same model calls again:
148
+
149
+ ```python
150
+ from evalquill.storage import save_run, load_run
151
+
152
+ save_run(baseline, "runs/baseline.json", metadata={"prompt_version": "v1"})
153
+ stored = load_run("runs/baseline.json")
154
+ compare(stored["results"], candidate)
155
+ ```
156
+
157
+ Metadata is free-form. Record the model, its settings and the prompt version; that is what makes a saved run interpretable weeks later.
158
+
159
+ ## Writing a metric
160
+
161
+ Any named function works. This one checks a field in a JSON response:
162
+
163
+ ```python
164
+ import json
165
+ from evalquill import evaluate
166
+
167
+ def json_label(response: str, expected: str) -> float:
168
+ try:
169
+ return 1.0 if json.loads(response).get("label") == expected else 0.0
170
+ except (json.JSONDecodeError, AttributeError):
171
+ return 0.0
172
+
173
+ print(evaluate(expected="billing", response='{"label": "billing"}', metrics=[json_label]))
174
+ ```
175
+
176
+ ```
177
+ {'json_label': 1.0}
178
+ ```
179
+
180
+ The rules: the function's name is the score's key and must be unique within a run. The return value must be a finite number from 0 to 1; booleans are rejected. If a metric raises, the run stops and the error names the case.
181
+
182
+ ## Using a real model
183
+
184
+ Wrap your model in a function that takes a prompt and returns a string. For a self-hosted model, that is usually a few lines around your inference client.
185
+
186
+ For OpenAI and Gemini there are adapters. Install the matching extra; the key is read from `OPENAI_API_KEY` or `GEMINI_API_KEY`:
187
+
188
+ ```python
189
+ from evalquill.providers import openai_llm
190
+
191
+ llm, metadata = openai_llm(
192
+ "gpt-4o-mini",
193
+ system_prompt="Classify the support ticket as billing, account, integration, feature, bug or other. Reply with one word.",
194
+ temperature=0.0,
195
+ )
196
+ results = evaluate_dataset(dataset, llm, [normalized_exact_match])
197
+ save_run(results, "runs/gpt-4o-mini_v2.json", metadata=metadata)
198
+ ```
199
+
200
+ `gemini_llm` works the same way. The returned `metadata` records the provider, model, temperature and system prompt, so a saved run says what produced it. The adapters add no retry logic of their own: the provider SDK's default retries apply, and any error that remains stops the run and names the case.
201
+
202
+ ## Why case IDs matter
203
+
204
+ Comparisons join on case ID, not list position. If you reorder your dataset or insert a case, a positional comparison silently diffs unrelated rows and gives you a confident wrong answer.
205
+
206
+ IDs default to `case_0`, `case_1` and so on if you don't supply them. That's fine for a one-off run, but give real IDs to anything you'll compare across versions.
207
+
208
+ Two runs are rejected rather than compared if they cover different cases, use different metrics, or if any case's input or expected answer differs between them. Only the response and its scores may change.
209
+
210
+ ## Case study
211
+
212
+ [case_study/](https://github.com/priteesshhh/evalquill/tree/v0.1.0/case_study) contains a worked example: 50 fictional support tickets, hand-labeled against a written guide, classified by gpt-4o-mini under two prompt versions. Results are reported on a frozen 20-ticket held-out set, with three repeats per version on that set.
213
+
214
+ Accuracy rose from 80% to 95% on the held-out set, and one previously correct ticket failed in all three observed repeats. The aggregate score does not surface that; the per-case comparison does.
215
+
216
+ All runs are saved. `case_study/rescore.py` recomputes every stored score from the saved responses, with no API key. See [case_study/README.md](https://github.com/priteesshhh/evalquill/blob/v0.1.0/case_study/README.md) for method, results and limitations.
217
+
218
+ ## What it doesn't do
219
+
220
+ - No semantic or model-graded metrics. The built-ins are string comparisons, named for what they check: `normalized_exact_match` ignores case and surrounding whitespace, and `contains_substring` checks for a substring and nothing more. `contains_substring("19", "9")` returns 1.0, and a correct answer in a different form, such as "nine" for "9", scores 0.0.
221
+ - No RAG metrics. Metrics receive only the response and the expected value, so anything needing retrieved context doesn't fit the signature yet.
222
+ - No concurrency. Model calls are made one at a time.
223
+ - No repeat-run tooling in the library. The case study has scripts for its own repeats.
224
+ - No dashboard, no hosted anything.
225
+
226
+ This isn't the only tool in this space. promptfoo, DeepEval, LangSmith and Braintrust all overlap with it and most do more.
227
+
228
+ ## Design notes
229
+
230
+ Scores are stored at full precision and rounded only for display. Rounding before a threshold check lets a true score of 0.7993 pass a 0.80 threshold, a silent false pass. There's a regression test for it.
231
+
232
+ Metrics must return a finite number between 0 and 1. Booleans are rejected explicitly: `True == 1.0` in Python, so a metric returning booleans would otherwise pass validation while suggesting its author didn't mean to return a score.
233
+
234
+ Duplicate metric names and duplicate case IDs are rejected. Both silently overwrite results otherwise. A missing score is rejected rather than averaged as zero.
235
+
236
+ A comparison requires each case's input and expected answer to be identical in both runs. Otherwise a changed reference answer would show up as a model improvement.
237
+
238
+ The whole dataset is validated before the first model call, so a bad row near the end doesn't cost every call before it. When a model call or a metric fails, the run stops and the error names the case. Recording 0.0 instead would make a broken metric look like a failing model.
239
+
240
+ Saves are atomic: a failed save leaves any previous file intact. Loading validates the whole file, not just its schema version.
241
+
242
+ ## Tests
243
+
244
+ ```bash
245
+ uv run pytest
246
+ ```
247
+
248
+ `tests/test_readme.py` runs every Python example in this README and checks the output shown, so the examples can't drift from the code. Live tests against real APIs are opt-in: `uv run pytest -m live`, with the provider keys set.
249
+
250
+ ## Status
251
+
252
+ Early. 0.1.0 is the first release; the API may change in later 0.x releases.
253
+
254
+ ## License
255
+
256
+ MIT
@@ -0,0 +1,234 @@
1
+ # evalquill
2
+
3
+ [![CI](https://github.com/priteesshhh/evalquill/actions/workflows/ci.yml/badge.svg)](https://github.com/priteesshhh/evalquill/actions/workflows/ci.yml)
4
+
5
+ A small Python library for checking what changed when you edit an LLM prompt.
6
+
7
+ You change a prompt, rerun your evals, and the average score moves from 0.72 to 0.75. Did it get better? Maybe. It might also have fixed four cases and broken two that used to work. The average won't tell you that.
8
+
9
+ evalquill compares two runs case by case and tells you which specific cases got worse.
10
+
11
+ ## Install
12
+
13
+ Requires Python 3.10 or later. The core has no runtime dependencies.
14
+
15
+ ```bash
16
+ pip install evalquill
17
+ ```
18
+
19
+ The OpenAI and Gemini adapters are optional extras:
20
+
21
+ ```bash
22
+ pip install "evalquill[openai]"
23
+ pip install "evalquill[gemini]"
24
+ ```
25
+
26
+ To work on it locally:
27
+
28
+ ```bash
29
+ git clone https://github.com/priteesshhh/evalquill.git
30
+ cd evalquill
31
+ uv sync
32
+ ```
33
+
34
+ ## Quickstart
35
+
36
+ A metric is a function `(response, expected) -> float` that returns a score from 0 to 1. An LLM is any function `(prompt) -> str`: a provider client, a wrapper around your own model, or a stub.
37
+
38
+ This example classifies support tickets. Two stub functions stand in for the same model under two versions of a prompt, so it runs without an API key.
39
+
40
+ ```python
41
+ from evalquill import evaluate_dataset
42
+ from evalquill.metrics import normalized_exact_match
43
+ from evalquill.compare import compare, diff_summary
44
+
45
+ dataset = [
46
+ {"id": "refund", "prompt": "I was charged twice this month.", "expected": "billing"},
47
+ {"id": "login", "prompt": "The login page shows a 500 error.", "expected": "account"},
48
+ {"id": "invite", "prompt": "My teammate never got the invite email.", "expected": "account"},
49
+ {"id": "webhook", "prompt": "Our webhook stopped receiving events.", "expected": "integration"},
50
+ ]
51
+
52
+ def llm_v1(prompt):
53
+ return {
54
+ "I was charged twice this month.": "billing",
55
+ "The login page shows a 500 error.": "bug",
56
+ "My teammate never got the invite email.": "account",
57
+ "Our webhook stopped receiving events.": "bug",
58
+ }[prompt]
59
+
60
+ def llm_v2(prompt):
61
+ return {
62
+ "I was charged twice this month.": "billing",
63
+ "The login page shows a 500 error.": "account",
64
+ "My teammate never got the invite email.": "integration",
65
+ "Our webhook stopped receiving events.": "integration",
66
+ }[prompt]
67
+
68
+ baseline = evaluate_dataset(dataset, llm_v1, [normalized_exact_match])
69
+ candidate = evaluate_dataset(dataset, llm_v2, [normalized_exact_match])
70
+
71
+ summary = diff_summary(compare(baseline, candidate))
72
+ print(summary["normalized_exact_match"])
73
+ ```
74
+
75
+ ```
76
+ {'baseline_mean': 0.5, 'candidate_mean': 0.75, 'mean_delta': 0.25, 'improved': ['login', 'webhook'], 'regressed': ['invite'], 'newly_failing': ['invite']}
77
+ ```
78
+
79
+ Accuracy rose from 0.50 to 0.75. Two cases improved, and `invite`, which v1 classified correctly, v2 now gets wrong. The average alone would not have shown that.
80
+
81
+ Each result row keeps the input, the response and its scores:
82
+
83
+ ```python
84
+ {"id": "invite", "prompt": "My teammate never got the invite email.",
85
+ "expected": "account", "response": "integration",
86
+ "scores": {"normalized_exact_match": 0.0}}
87
+ ```
88
+
89
+ `examples/prompt_change_demo.py` runs the same comparison through saved runs and prints the responses behind each regression.
90
+
91
+ ## Failing a build
92
+
93
+ A regression check belongs in your test suite. Continuing from the quickstart, with `dataset` and `llm_v2` imported from wherever your project defines them:
94
+
95
+ ```python
96
+ from evalquill import evaluate_dataset, summary_scores, threshold_check
97
+ from evalquill.compare import compare, diff_summary
98
+ from evalquill.metrics import normalized_exact_match
99
+ from evalquill.storage import load_run
100
+
101
+ def test_accuracy_meets_threshold():
102
+ candidate = evaluate_dataset(dataset, llm_v2, [normalized_exact_match])
103
+ checks = threshold_check(summary_scores(candidate), {"normalized_exact_match": 0.7})
104
+ assert checks == {"normalized_exact_match": "PASS"}
105
+
106
+ def test_no_case_stops_passing():
107
+ baseline = load_run("runs/baseline.json")["results"]
108
+ candidate = evaluate_dataset(dataset, llm_v2, [normalized_exact_match])
109
+ summary = diff_summary(compare(baseline, candidate))["normalized_exact_match"]
110
+ assert summary["newly_failing"] == [], f"stopped passing: {summary['newly_failing']}"
111
+ ```
112
+
113
+ The baseline is a saved run (see Saving runs below). With the quickstart's v2 prompt, the first test passes, since 0.75 clears 0.7, and the second fails:
114
+
115
+ ```
116
+ AssertionError: stopped passing: ['invite']
117
+ ```
118
+
119
+ The accuracy bar was met and a case still broke. A threshold alone lets that through.
120
+
121
+ `threshold_check` returns the strings `"PASS"`, `"FAIL"` and `"NOT RUN"`, so compare them explicitly. `assert all(checks.values())` would pass on `"FAIL"`, because every non-empty string is truthy.
122
+
123
+ ## Saving runs
124
+
125
+ Save a run once and compare against it later, without paying for the same model calls again:
126
+
127
+ ```python
128
+ from evalquill.storage import save_run, load_run
129
+
130
+ save_run(baseline, "runs/baseline.json", metadata={"prompt_version": "v1"})
131
+ stored = load_run("runs/baseline.json")
132
+ compare(stored["results"], candidate)
133
+ ```
134
+
135
+ Metadata is free-form. Record the model, its settings and the prompt version; that is what makes a saved run interpretable weeks later.
136
+
137
+ ## Writing a metric
138
+
139
+ Any named function works. This one checks a field in a JSON response:
140
+
141
+ ```python
142
+ import json
143
+ from evalquill import evaluate
144
+
145
+ def json_label(response: str, expected: str) -> float:
146
+ try:
147
+ return 1.0 if json.loads(response).get("label") == expected else 0.0
148
+ except (json.JSONDecodeError, AttributeError):
149
+ return 0.0
150
+
151
+ print(evaluate(expected="billing", response='{"label": "billing"}', metrics=[json_label]))
152
+ ```
153
+
154
+ ```
155
+ {'json_label': 1.0}
156
+ ```
157
+
158
+ The rules: the function's name is the score's key and must be unique within a run. The return value must be a finite number from 0 to 1; booleans are rejected. If a metric raises, the run stops and the error names the case.
159
+
160
+ ## Using a real model
161
+
162
+ Wrap your model in a function that takes a prompt and returns a string. For a self-hosted model, that is usually a few lines around your inference client.
163
+
164
+ For OpenAI and Gemini there are adapters. Install the matching extra; the key is read from `OPENAI_API_KEY` or `GEMINI_API_KEY`:
165
+
166
+ ```python
167
+ from evalquill.providers import openai_llm
168
+
169
+ llm, metadata = openai_llm(
170
+ "gpt-4o-mini",
171
+ system_prompt="Classify the support ticket as billing, account, integration, feature, bug or other. Reply with one word.",
172
+ temperature=0.0,
173
+ )
174
+ results = evaluate_dataset(dataset, llm, [normalized_exact_match])
175
+ save_run(results, "runs/gpt-4o-mini_v2.json", metadata=metadata)
176
+ ```
177
+
178
+ `gemini_llm` works the same way. The returned `metadata` records the provider, model, temperature and system prompt, so a saved run says what produced it. The adapters add no retry logic of their own: the provider SDK's default retries apply, and any error that remains stops the run and names the case.
179
+
180
+ ## Why case IDs matter
181
+
182
+ Comparisons join on case ID, not list position. If you reorder your dataset or insert a case, a positional comparison silently diffs unrelated rows and gives you a confident wrong answer.
183
+
184
+ IDs default to `case_0`, `case_1` and so on if you don't supply them. That's fine for a one-off run, but give real IDs to anything you'll compare across versions.
185
+
186
+ Two runs are rejected rather than compared if they cover different cases, use different metrics, or if any case's input or expected answer differs between them. Only the response and its scores may change.
187
+
188
+ ## Case study
189
+
190
+ [case_study/](https://github.com/priteesshhh/evalquill/tree/v0.1.0/case_study) contains a worked example: 50 fictional support tickets, hand-labeled against a written guide, classified by gpt-4o-mini under two prompt versions. Results are reported on a frozen 20-ticket held-out set, with three repeats per version on that set.
191
+
192
+ Accuracy rose from 80% to 95% on the held-out set, and one previously correct ticket failed in all three observed repeats. The aggregate score does not surface that; the per-case comparison does.
193
+
194
+ All runs are saved. `case_study/rescore.py` recomputes every stored score from the saved responses, with no API key. See [case_study/README.md](https://github.com/priteesshhh/evalquill/blob/v0.1.0/case_study/README.md) for method, results and limitations.
195
+
196
+ ## What it doesn't do
197
+
198
+ - No semantic or model-graded metrics. The built-ins are string comparisons, named for what they check: `normalized_exact_match` ignores case and surrounding whitespace, and `contains_substring` checks for a substring and nothing more. `contains_substring("19", "9")` returns 1.0, and a correct answer in a different form, such as "nine" for "9", scores 0.0.
199
+ - No RAG metrics. Metrics receive only the response and the expected value, so anything needing retrieved context doesn't fit the signature yet.
200
+ - No concurrency. Model calls are made one at a time.
201
+ - No repeat-run tooling in the library. The case study has scripts for its own repeats.
202
+ - No dashboard, no hosted anything.
203
+
204
+ This isn't the only tool in this space. promptfoo, DeepEval, LangSmith and Braintrust all overlap with it and most do more.
205
+
206
+ ## Design notes
207
+
208
+ Scores are stored at full precision and rounded only for display. Rounding before a threshold check lets a true score of 0.7993 pass a 0.80 threshold, a silent false pass. There's a regression test for it.
209
+
210
+ Metrics must return a finite number between 0 and 1. Booleans are rejected explicitly: `True == 1.0` in Python, so a metric returning booleans would otherwise pass validation while suggesting its author didn't mean to return a score.
211
+
212
+ Duplicate metric names and duplicate case IDs are rejected. Both silently overwrite results otherwise. A missing score is rejected rather than averaged as zero.
213
+
214
+ A comparison requires each case's input and expected answer to be identical in both runs. Otherwise a changed reference answer would show up as a model improvement.
215
+
216
+ The whole dataset is validated before the first model call, so a bad row near the end doesn't cost every call before it. When a model call or a metric fails, the run stops and the error names the case. Recording 0.0 instead would make a broken metric look like a failing model.
217
+
218
+ Saves are atomic: a failed save leaves any previous file intact. Loading validates the whole file, not just its schema version.
219
+
220
+ ## Tests
221
+
222
+ ```bash
223
+ uv run pytest
224
+ ```
225
+
226
+ `tests/test_readme.py` runs every Python example in this README and checks the output shown, so the examples can't drift from the code. Live tests against real APIs are opt-in: `uv run pytest -m live`, with the provider keys set.
227
+
228
+ ## Status
229
+
230
+ Early. 0.1.0 is the first release; the API may change in later 0.x releases.
231
+
232
+ ## License
233
+
234
+ MIT
@@ -0,0 +1,45 @@
1
+ [project]
2
+ name = "evalquill"
3
+ version = "0.1.0"
4
+ description = "Compare two LLM evaluation runs case by case to see which cases a prompt change broke"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ keywords = [
10
+ "llm",
11
+ "evaluation",
12
+ "prompt-engineering",
13
+ "testing",
14
+ ]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Software Development :: Testing",
20
+ ]
21
+ dependencies = []
22
+
23
+ [[project.authors]]
24
+ name = "Priteesh Karra"
25
+ email = "WritetoPriteesh@gmail.com"
26
+
27
+ [project.optional-dependencies]
28
+ openai = ["openai>=1.0"]
29
+ gemini = ["google-genai>=2.24.0"]
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/priteesshhh/evalquill"
33
+ Issues = "https://github.com/priteesshhh/evalquill/issues"
34
+
35
+ [dependency-groups]
36
+ dev = ["pytest>=8.0"]
37
+
38
+ [tool.pytest.ini_options]
39
+ testpaths = ["tests"]
40
+ markers = ["live: hits a real API, costs money, deselected by default"]
41
+ addopts = "-m 'not live'"
42
+
43
+ [build-system]
44
+ requires = ["uv_build>=0.12.10,<0.13.0"]
45
+ build-backend = "uv_build"
@@ -0,0 +1,45 @@
1
+ [project]
2
+ name = "evalquill"
3
+ version = "0.1.0"
4
+ description = "Compare two LLM evaluation runs case by case to see which cases a prompt change broke"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ authors = [
10
+ { name = "Priteesh Karra", email = "WritetoPriteesh@gmail.com" }
11
+ ]
12
+ keywords = ["llm", "evaluation", "prompt-engineering", "testing"]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Intended Audience :: Developers",
16
+ "Programming Language :: Python :: 3",
17
+ "Topic :: Software Development :: Testing",
18
+ ]
19
+ dependencies = []
20
+
21
+ [project.optional-dependencies]
22
+ openai = [
23
+ "openai>=1.0",
24
+ ]
25
+ gemini = [
26
+ "google-genai>=2.24.0",
27
+ ]
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/priteesshhh/evalquill"
31
+ Issues = "https://github.com/priteesshhh/evalquill/issues"
32
+
33
+ [dependency-groups]
34
+ dev = [
35
+ "pytest>=8.0",
36
+ ]
37
+
38
+ [tool.pytest.ini_options]
39
+ testpaths = ["tests"]
40
+ markers = ["live: hits a real API, costs money, deselected by default"]
41
+ addopts = "-m 'not live'"
42
+
43
+ [build-system]
44
+ requires = ["uv_build>=0.12.10,<0.13.0"]
45
+ build-backend = "uv_build"
@@ -0,0 +1,166 @@
1
+ import math
2
+
3
+
4
+ def evaluate(expected: str, response: str, metrics: list) -> dict:
5
+ _validate_metrics(metrics)
6
+ results = {}
7
+ for metric in metrics:
8
+ score = metric(response, expected)
9
+ _validate_score(score, metric.__name__)
10
+ results[metric.__name__] = score
11
+ return results
12
+
13
+
14
+ def _preflight(dataset, metrics) -> list:
15
+ """Validate the whole dataset and metric list; return the case ids.
16
+
17
+ Runs before any model call, so a structural error anywhere in the
18
+ dataset costs nothing.
19
+ """
20
+ _validate_metrics(metrics)
21
+ if not isinstance(dataset, list):
22
+ raise ValueError(f"dataset must be a list of cases, got {type(dataset).__name__}.")
23
+
24
+ ids = []
25
+ seen = set()
26
+ for i, item in enumerate(dataset):
27
+ if not isinstance(item, dict):
28
+ raise ValueError(f"Case {i} is not a dict, got {type(item).__name__}.")
29
+ missing = [f for f in ("prompt", "expected") if f not in item]
30
+ if missing:
31
+ raise ValueError(f"Case {i} is missing required field(s): {missing}.")
32
+ for field in ("prompt", "expected"):
33
+ if not isinstance(item[field], str):
34
+ raise ValueError(
35
+ f"Case {i}: '{field}' must be a string, got {type(item[field]).__name__}."
36
+ )
37
+ case_id = item.get("id", f"case_{i}")
38
+ if case_id in seen:
39
+ raise ValueError(f"Duplicate case id: '{case_id}'. Each case must have a unique id.")
40
+ seen.add(case_id)
41
+ ids.append(case_id)
42
+ return ids
43
+
44
+
45
+ def evaluate_dataset(dataset: list, llm, metrics: list) -> list:
46
+ """Call llm on each case and score the response.
47
+
48
+ The dataset and metrics are validated in full before the first call.
49
+ LLM and metric failures are re-raised as RuntimeError naming the case;
50
+ the original exception is available as __cause__.
51
+ """
52
+ ids = _preflight(dataset, metrics)
53
+
54
+ results = []
55
+ for case_id, item in zip(ids, dataset):
56
+ try:
57
+ response = llm(item["prompt"])
58
+ except Exception as e:
59
+ raise RuntimeError(f"LLM call failed on case '{case_id}' — {e}") from e
60
+
61
+ if not isinstance(response, str):
62
+ raise RuntimeError(
63
+ f"LLM returned {type(response).__name__} for case '{case_id}'; expected a string."
64
+ )
65
+
66
+ try:
67
+ result = evaluate(
68
+ expected=item["expected"],
69
+ response=response,
70
+ metrics=metrics
71
+ )
72
+ except Exception as e:
73
+ raise RuntimeError(
74
+ f"Evaluation failed on case '{case_id}' "
75
+ f"(prompt: '{item['prompt']}') — {e}"
76
+ ) from e
77
+
78
+ results.append({
79
+ "id": case_id,
80
+ "prompt": item["prompt"],
81
+ "expected": item["expected"],
82
+ "response": response,
83
+ "scores": result
84
+ })
85
+ return results
86
+
87
+
88
+ def summary_scores(results: list) -> dict:
89
+ """Mean of each metric across results, at full precision.
90
+
91
+ Every row must carry the same metric set with valid scores. A row missing
92
+ a metric is rejected rather than averaged as zero.
93
+ """
94
+ if not results:
95
+ return {}
96
+
97
+ metric_set = None
98
+ totals = {}
99
+ for index, result in enumerate(results):
100
+ if not isinstance(result, dict) or not isinstance(result.get("scores"), dict):
101
+ raise ValueError(f"Result {index} has no scores dict.")
102
+ scores = result["scores"]
103
+
104
+ names = set(scores)
105
+ if metric_set is None:
106
+ metric_set = names
107
+ elif names != metric_set:
108
+ raise ValueError(
109
+ f"Inconsistent metric sets: result 0 has {sorted(metric_set)}, "
110
+ f"result {index} has {sorted(names)}. A missing score would "
111
+ "otherwise be averaged as zero."
112
+ )
113
+
114
+ for name, score in scores.items():
115
+ _validate_score(score, name)
116
+ totals[name] = totals.get(name, 0.0) + score
117
+
118
+ return {name: total / len(results) for name, total in totals.items()}
119
+
120
+
121
+ def threshold_check(summary: dict, thresholds: dict) -> dict:
122
+ """PASS, FAIL or NOT RUN per threshold.
123
+
124
+ NOT RUN means the metric was never measured. Invalid thresholds or
125
+ summary values are rejected rather than compared.
126
+ """
127
+ for metric, minimum in thresholds.items():
128
+ try:
129
+ _validate_score(minimum, metric)
130
+ except ValueError as e:
131
+ raise ValueError(f"Invalid threshold for '{metric}': {e}") from e
132
+
133
+ results = {}
134
+ for metric, minimum in thresholds.items():
135
+ if metric not in summary:
136
+ results[metric] = "NOT RUN"
137
+ continue
138
+ _validate_score(summary[metric], metric)
139
+ results[metric] = "PASS" if summary[metric] >= minimum else "FAIL"
140
+ return results
141
+
142
+
143
+ def _validate_metrics(metrics) -> None:
144
+ if not isinstance(metrics, (list, tuple)) or not metrics:
145
+ raise ValueError("At least one metric is required; a run without metrics measures nothing.")
146
+ seen = set()
147
+ for metric in metrics:
148
+ if not callable(metric):
149
+ raise ValueError(f"Each metric must be callable, got {type(metric).__name__}.")
150
+ name = getattr(metric, "__name__", None)
151
+ if not isinstance(name, str):
152
+ raise ValueError("Each metric must have a __name__; use a named function.")
153
+ if name in seen:
154
+ raise ValueError(f"Duplicate metric name: '{name}'. Each metric must have a unique name.")
155
+ seen.add(name)
156
+
157
+
158
+ def _validate_score(value: float, name: str) -> None:
159
+ if isinstance(value, bool):
160
+ raise ValueError(f"Score '{name}' must be a float, not a boolean.")
161
+ if not isinstance(value, (int, float)):
162
+ raise ValueError(f"Score '{name}' must be a number, got {type(value).__name__}.")
163
+ if math.isnan(value) or math.isinf(value):
164
+ raise ValueError(f"Score '{name}' must be finite, got {value}.")
165
+ if not (0.0 <= value <= 1.0):
166
+ raise ValueError(f"Score '{name}' must be between 0 and 1, got {value}.")
@@ -0,0 +1,104 @@
1
+ """Compare two evaluation runs case by case.
2
+
3
+ Comparison contract: for each case id, the input (prompt) and reference
4
+ answer (expected) must be identical in both runs. Only the response and its
5
+ scores may differ. The experimental variable - a system prompt, template or
6
+ model - lives in the llm callable and run metadata, never in these fields.
7
+ A changed input or reference answer is a different test case, so the runs
8
+ are rejected rather than compared.
9
+ """
10
+
11
+ from . import _validate_score
12
+ from .validation import validate_results
13
+
14
+
15
+ def compare(baseline: list, candidate: list) -> dict:
16
+ base_idx, base_metrics = validate_results(baseline, "baseline")
17
+ cand_idx, cand_metrics = validate_results(candidate, "candidate")
18
+
19
+ if base_idx.keys() != cand_idx.keys():
20
+ only_base = sorted(base_idx.keys() - cand_idx.keys())
21
+ only_cand = sorted(cand_idx.keys() - base_idx.keys())
22
+ raise ValueError(
23
+ "Runs cover different cases and cannot be compared. "
24
+ f"Only in baseline: {only_base}. Only in candidate: {only_cand}."
25
+ )
26
+
27
+ if base_metrics != cand_metrics:
28
+ raise ValueError(
29
+ "Runs used different metrics and cannot be compared. "
30
+ f"Baseline: {sorted(base_metrics)}. Candidate: {sorted(cand_metrics)}."
31
+ )
32
+
33
+ changed_input = [i for i in base_idx if base_idx[i]["prompt"] != cand_idx[i]["prompt"]]
34
+ changed_expected = [i for i in base_idx if base_idx[i]["expected"] != cand_idx[i]["expected"]]
35
+ if changed_input or changed_expected:
36
+ problems = []
37
+ if changed_input:
38
+ problems.append(f"different input for cases {sorted(changed_input)}")
39
+ if changed_expected:
40
+ problems.append(f"different reference answer for cases {sorted(changed_expected)}")
41
+ raise ValueError(
42
+ "Runs are not over the same dataset: " + "; ".join(problems) + ". "
43
+ "A changed input or reference answer would be reported as a model "
44
+ "improvement or regression. Compare only runs over identical cases."
45
+ )
46
+
47
+ metrics = sorted(base_metrics)
48
+ cases = []
49
+ for case_id in base_idx:
50
+ base = base_idx[case_id]
51
+ cand = cand_idx[case_id]
52
+ cases.append({
53
+ "id": case_id,
54
+ "prompt": base["prompt"],
55
+ "expected": base["expected"],
56
+ "baseline_response": base["response"],
57
+ "candidate_response": cand["response"],
58
+ "response_changed": base["response"] != cand["response"],
59
+ "baseline_scores": dict(base["scores"]),
60
+ "candidate_scores": dict(cand["scores"]),
61
+ "deltas": {n: cand["scores"][n] - base["scores"][n] for n in metrics},
62
+ })
63
+
64
+ return {"cases": cases, "metrics": metrics}
65
+
66
+
67
+ def diff_summary(diff: dict, pass_threshold: float = 1.0) -> dict:
68
+ try:
69
+ _validate_score(pass_threshold, "pass_threshold")
70
+ except ValueError as e:
71
+ raise ValueError(
72
+ f"pass_threshold must be a finite number between 0 and 1, got {pass_threshold!r}."
73
+ ) from e
74
+
75
+ summary = {}
76
+ for name in diff["metrics"]:
77
+ improved, regressed, newly_failing = [], [], []
78
+
79
+ for case in diff["cases"]:
80
+ delta = case["deltas"][name]
81
+ if delta > 0:
82
+ improved.append(case["id"])
83
+ elif delta < 0:
84
+ regressed.append(case["id"])
85
+
86
+ was_passing = case["baseline_scores"][name] >= pass_threshold
87
+ now_passing = case["candidate_scores"][name] >= pass_threshold
88
+ if was_passing and not now_passing:
89
+ newly_failing.append(case["id"])
90
+
91
+ n = len(diff["cases"])
92
+ baseline_mean = sum(c["baseline_scores"][name] for c in diff["cases"]) / n
93
+ candidate_mean = sum(c["candidate_scores"][name] for c in diff["cases"]) / n
94
+
95
+ summary[name] = {
96
+ "baseline_mean": baseline_mean,
97
+ "candidate_mean": candidate_mean,
98
+ "mean_delta": candidate_mean - baseline_mean,
99
+ "improved": improved,
100
+ "regressed": regressed,
101
+ "newly_failing": newly_failing,
102
+ }
103
+
104
+ return summary
@@ -0,0 +1,10 @@
1
+ def mock_llm(prompt: str) -> str:
2
+ responses = {
3
+ "what is the capital of france?": "The capital of France is Paris.",
4
+ "what is 2 + 2?": "2 + 2 equals 4.",
5
+ "what is the capital of germany?": "The capital of Germany is Berlin.",
6
+ "what is the capital of india?": "The capital of India is Delhi.",
7
+ "what is 7 + 2?": "7 + 2 equals 9.",
8
+ "what is first president of united states of america?": "The first president of the United States of America is George Washington.",
9
+ }
10
+ return responses.get(prompt.strip().lower(), "I don't know.")
@@ -0,0 +1,10 @@
1
+ def normalized_exact_match(response: str, expected: str) -> float:
2
+ if not expected.strip():
3
+ raise ValueError("expected string cannot be empty")
4
+ return 1.0 if response.strip().lower() == expected.strip().lower() else 0.0
5
+
6
+
7
+ def contains_substring(response: str, expected: str) -> float:
8
+ if not expected.strip():
9
+ raise ValueError("expected string cannot be empty")
10
+ return 1.0 if expected.strip().lower() in response.strip().lower() else 0.0
@@ -0,0 +1,109 @@
1
+ """Adapters turning provider clients into plain llm(prompt) -> str callables."""
2
+
3
+
4
+ def openai_llm(
5
+ model: str,
6
+ system_prompt: str | None = None,
7
+ temperature: float = 0.0,
8
+ client=None,
9
+ **kwargs,
10
+ ):
11
+ """Build an llm(prompt) -> str callable backed by OpenAI chat completions.
12
+
13
+ Returns (llm, metadata). Pass metadata to save_run so the run records
14
+ which model and settings produced it.
15
+
16
+ client is injectable for testing; when omitted, a real OpenAI client is
17
+ constructed and the openai package must be installed.
18
+ """
19
+ if client is None:
20
+ try:
21
+ from openai import OpenAI
22
+ except ImportError as e:
23
+ raise ImportError(
24
+ "The openai package is required for openai_llm. "
25
+ "Install it with: pip install openai"
26
+ ) from e
27
+ client = OpenAI()
28
+
29
+ def llm(prompt: str) -> str:
30
+ messages = []
31
+ if system_prompt is not None:
32
+ messages.append({"role": "system", "content": system_prompt})
33
+ messages.append({"role": "user", "content": prompt})
34
+
35
+ response = client.chat.completions.create(
36
+ model=model,
37
+ messages=messages,
38
+ temperature=temperature,
39
+ **kwargs,
40
+ )
41
+
42
+ content = response.choices[0].message.content
43
+ if content is None:
44
+ raise RuntimeError(
45
+ f"Model '{model}' returned no content. "
46
+ "This can happen when the response was filtered or truncated."
47
+ )
48
+ return content
49
+
50
+ metadata = {
51
+ "provider": "openai",
52
+ "model": model,
53
+ "temperature": temperature,
54
+ "system_prompt": system_prompt,
55
+ **kwargs,
56
+ }
57
+
58
+ return llm, metadata
59
+
60
+ def gemini_llm(
61
+ model: str = "gemini-3.6-flash",
62
+ system_prompt: str | None = None,
63
+ temperature: float = 0.0,
64
+ client=None,
65
+ **kwargs,
66
+ ):
67
+ """Build an llm(prompt) -> str callable backed by the Gemini API.
68
+
69
+ Returns (llm, metadata). Requires GEMINI_API_KEY in the environment
70
+ when client is not injected.
71
+ """
72
+ if client is None:
73
+ try:
74
+ from google import genai
75
+ except ImportError as e:
76
+ raise ImportError(
77
+ "The google-genai package is required for gemini_llm. "
78
+ "Install it with: pip install google-genai"
79
+ ) from e
80
+ client = genai.Client()
81
+
82
+ def llm(prompt: str) -> str:
83
+ config = {"temperature": temperature, **kwargs}
84
+ if system_prompt is not None:
85
+ config["system_instruction"] = system_prompt
86
+
87
+ response = client.models.generate_content(
88
+ model=model,
89
+ contents=prompt,
90
+ config=config,
91
+ )
92
+
93
+ text = response.text
94
+ if text is None:
95
+ raise RuntimeError(
96
+ f"Model '{model}' returned no content. "
97
+ "This can happen when the response was filtered or truncated."
98
+ )
99
+ return text
100
+
101
+ metadata = {
102
+ "provider": "gemini",
103
+ "model": model,
104
+ "temperature": temperature,
105
+ "system_prompt": system_prompt,
106
+ **kwargs,
107
+ }
108
+
109
+ return llm, metadata
@@ -0,0 +1,101 @@
1
+ """Save and load evaluation runs as JSON."""
2
+
3
+ import json
4
+ import os
5
+ import tempfile
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+
9
+ from .validation import validate_results
10
+
11
+ SCHEMA_VERSION = 1
12
+
13
+
14
+ def save_run(results: list, path: str, metadata: dict | None = None) -> None:
15
+ """Write a run to path atomically.
16
+
17
+ Rows are validated before anything is written. The payload is then
18
+ serialized in full, written to a temporary file in the same directory,
19
+ and moved onto path with os.replace. A failure at any stage leaves an
20
+ existing file at path unchanged and leaves no partial file behind. An
21
+ existing file at path is replaced on success.
22
+
23
+ Non-finite floats (NaN, infinity) are rejected, since they are not valid
24
+ JSON.
25
+ """
26
+ if not results:
27
+ raise ValueError("Cannot save an empty run.")
28
+ if metadata is not None and not isinstance(metadata, dict):
29
+ raise ValueError("metadata must be a dict.")
30
+ validate_results(results, "run")
31
+
32
+ payload = {
33
+ "schema_version": SCHEMA_VERSION,
34
+ "created_at": datetime.now(timezone.utc).isoformat(),
35
+ "metadata": metadata or {},
36
+ "results": results,
37
+ }
38
+
39
+ # Serialize first: any TypeError or ValueError happens before disk I/O.
40
+ text = json.dumps(payload, indent=2, ensure_ascii=False, allow_nan=False)
41
+
42
+ target = Path(path)
43
+ target.parent.mkdir(parents=True, exist_ok=True)
44
+
45
+ fd, tmp = tempfile.mkstemp(
46
+ dir=target.parent, prefix=f".{target.name}.", suffix=".tmp"
47
+ )
48
+ try:
49
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
50
+ f.write(text)
51
+ f.flush()
52
+ os.fsync(f.fileno())
53
+ os.replace(tmp, target)
54
+ except BaseException:
55
+ try:
56
+ os.unlink(tmp)
57
+ except FileNotFoundError:
58
+ pass
59
+ raise
60
+
61
+
62
+ def load_run(path: str) -> dict:
63
+ """Load a run and validate its envelope and every row.
64
+
65
+ A matching schema_version is necessary but not sufficient: the file must
66
+ also contain valid metadata and a non-empty list of valid result rows.
67
+ """
68
+ target = Path(path)
69
+ if not target.exists():
70
+ raise FileNotFoundError(f"No run found at '{path}'.")
71
+
72
+ def reject_constant(token):
73
+ raise ValueError(f"'{path}' contains non-standard JSON value {token}.")
74
+
75
+ with target.open(encoding="utf-8") as f:
76
+ try:
77
+ payload = json.load(f, parse_constant=reject_constant)
78
+ except json.JSONDecodeError as e:
79
+ raise ValueError(f"'{path}' is not valid JSON: {e}") from e
80
+
81
+ if not isinstance(payload, dict) or "schema_version" not in payload:
82
+ raise ValueError(f"'{path}' is not an evalquill run file.")
83
+
84
+ if payload["schema_version"] != SCHEMA_VERSION:
85
+ raise ValueError(
86
+ f"Run file '{path}' uses schema version {payload['schema_version']}, "
87
+ f"but this version of evalquill reads version {SCHEMA_VERSION}."
88
+ )
89
+
90
+ for field in ("created_at", "metadata", "results"):
91
+ if field not in payload:
92
+ raise ValueError(f"'{path}' is missing required field '{field}' (results cannot be read).")
93
+ if not isinstance(payload["created_at"], str):
94
+ raise ValueError(f"'{path}': 'created_at' must be a string.")
95
+ if not isinstance(payload["metadata"], dict):
96
+ raise ValueError(f"'{path}': 'metadata' must be a dict.")
97
+ if not isinstance(payload["results"], list):
98
+ raise ValueError(f"'{path}': 'results' must be a list.")
99
+
100
+ validate_results(payload["results"], f"'{path}'")
101
+ return payload
@@ -0,0 +1,52 @@
1
+ """Shared validation for result rows and runs.
2
+
3
+ One definition of a valid run, used by comparison and by storage, so the two
4
+ boundaries cannot drift apart.
5
+ """
6
+
7
+ from . import _validate_score
8
+
9
+ REQUIRED_FIELDS = ("id", "prompt", "expected", "response", "scores")
10
+
11
+
12
+ def check_row(row, label: str, index: int) -> None:
13
+ if not isinstance(row, dict):
14
+ raise ValueError(f"{label} row {index} is not a dict.")
15
+ missing = [f for f in REQUIRED_FIELDS if f not in row]
16
+ if missing:
17
+ raise ValueError(f"{label} row {index} is missing required field(s): {missing}.")
18
+ if not isinstance(row["scores"], dict):
19
+ raise ValueError(f"{label} case '{row['id']}': scores must be a dict.")
20
+ for name, value in row["scores"].items():
21
+ try:
22
+ _validate_score(value, name)
23
+ except ValueError as e:
24
+ raise ValueError(f"{label} case '{row['id']}': {e}") from e
25
+
26
+
27
+ def validate_results(results, label: str):
28
+ """Validate every row; return (rows indexed by id, the run's metric set)."""
29
+ if not isinstance(results, list):
30
+ raise ValueError(f"{label} results must be a list.")
31
+ if not results:
32
+ raise ValueError(f"{label} run must contain at least one case.")
33
+
34
+ indexed = {}
35
+ metric_set = None
36
+ first_id = None
37
+ for index, row in enumerate(results):
38
+ check_row(row, label, index)
39
+ case_id = row["id"]
40
+ if case_id in indexed:
41
+ raise ValueError(f"Duplicate case id '{case_id}' in {label} run.")
42
+ indexed[case_id] = row
43
+
44
+ names = set(row["scores"])
45
+ if metric_set is None:
46
+ metric_set, first_id = names, case_id
47
+ elif names != metric_set:
48
+ raise ValueError(
49
+ f"{label} run has inconsistent metric sets: case '{first_id}' "
50
+ f"has {sorted(metric_set)}, case '{case_id}' has {sorted(names)}."
51
+ )
52
+ return indexed, metric_set