evalix 0.1.0__py3-none-any.whl
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.
- evalix/__init__.py +75 -0
- evalix/api.py +414 -0
- evalix/cases.py +135 -0
- evalix/cli.py +167 -0
- evalix/diff.py +139 -0
- evalix/pricing.py +42 -0
- evalix/prompt.py +61 -0
- evalix/py.typed +0 -0
- evalix/report.py +175 -0
- evalix/runners/__init__.py +84 -0
- evalix/runners/capix.py +53 -0
- evalix/scorers/__init__.py +55 -0
- evalix/scorers/base.py +65 -0
- evalix/scorers/builtin.py +103 -0
- evalix/scorers/custom.py +51 -0
- evalix/scorers/judge.py +120 -0
- evalix/scoring.py +100 -0
- evalix/store.py +137 -0
- evalix-0.1.0.dist-info/METADATA +292 -0
- evalix-0.1.0.dist-info/RECORD +23 -0
- evalix-0.1.0.dist-info/WHEEL +4 -0
- evalix-0.1.0.dist-info/entry_points.txt +2 -0
- evalix-0.1.0.dist-info/licenses/LICENSE +21 -0
evalix/__init__.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""evalix — a small eval harness that tells you which cases your prompt change broke.
|
|
2
|
+
|
|
3
|
+
fixed case set → change one thing → measure → keep or revert
|
|
4
|
+
|
|
5
|
+
The score tells you whether; the diff tells you which. A prompt change that
|
|
6
|
+
gains three cases and loses two is a +1 you would otherwise call a win.
|
|
7
|
+
|
|
8
|
+
from evalix import run
|
|
9
|
+
|
|
10
|
+
report = run(cases="cases.jsonl", prompt="v2.txt", scorer="exact")
|
|
11
|
+
print(report.render())
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from evalix.api import (
|
|
17
|
+
Comparison,
|
|
18
|
+
Estimate,
|
|
19
|
+
Report,
|
|
20
|
+
ScorerError,
|
|
21
|
+
compare,
|
|
22
|
+
estimate,
|
|
23
|
+
run,
|
|
24
|
+
run_case,
|
|
25
|
+
)
|
|
26
|
+
from evalix.cases import Case, load_cases
|
|
27
|
+
from evalix.diff import CaseChange, Diff, diff
|
|
28
|
+
from evalix.prompt import PromptError, build_request
|
|
29
|
+
from evalix.runners import (
|
|
30
|
+
Message,
|
|
31
|
+
MissingCredentials,
|
|
32
|
+
Refusal,
|
|
33
|
+
Request,
|
|
34
|
+
Response,
|
|
35
|
+
Runner,
|
|
36
|
+
RunnerError,
|
|
37
|
+
)
|
|
38
|
+
from evalix.scorers import PASS, Context, Score, Scorer, ScorerFileError, load_custom
|
|
39
|
+
from evalix.scoring import Result, Run
|
|
40
|
+
|
|
41
|
+
__version__ = "0.1.0"
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"PASS",
|
|
45
|
+
"Case",
|
|
46
|
+
"CaseChange",
|
|
47
|
+
"Comparison",
|
|
48
|
+
"Context",
|
|
49
|
+
"Diff",
|
|
50
|
+
"Estimate",
|
|
51
|
+
"Message",
|
|
52
|
+
"MissingCredentials",
|
|
53
|
+
"PromptError",
|
|
54
|
+
"Refusal",
|
|
55
|
+
"Report",
|
|
56
|
+
"Request",
|
|
57
|
+
"Response",
|
|
58
|
+
"Result",
|
|
59
|
+
"Run",
|
|
60
|
+
"Runner",
|
|
61
|
+
"RunnerError",
|
|
62
|
+
"Score",
|
|
63
|
+
"Scorer",
|
|
64
|
+
"ScorerError",
|
|
65
|
+
"ScorerFileError",
|
|
66
|
+
"__version__",
|
|
67
|
+
"build_request",
|
|
68
|
+
"compare",
|
|
69
|
+
"diff",
|
|
70
|
+
"estimate",
|
|
71
|
+
"load_cases",
|
|
72
|
+
"load_custom",
|
|
73
|
+
"run",
|
|
74
|
+
"run_case",
|
|
75
|
+
]
|
evalix/api.py
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
"""The public surface: `run()`, `compare()` and `estimate()`.
|
|
2
|
+
|
|
3
|
+
The CLI is a thin wrapper over exactly these, so anything the terminal can do
|
|
4
|
+
is available from Python with the same arguments.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import concurrent.futures
|
|
10
|
+
import datetime as dt
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from dataclasses import dataclass, field, replace
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from evalix import pricing, report
|
|
18
|
+
from evalix.cases import Case, case_key, load_cases
|
|
19
|
+
from evalix.diff import Diff, diff
|
|
20
|
+
from evalix.prompt import build_request
|
|
21
|
+
from evalix.runners import MissingCredentials, Refusal, Runner, RunnerError, default_runner
|
|
22
|
+
from evalix.scorers import Context, Scorer, as_score, name_of
|
|
23
|
+
from evalix.scorers import get as get_scorer
|
|
24
|
+
from evalix.scoring import Result, Run, mean
|
|
25
|
+
from evalix.store import RunStore, project_root, runs_dir
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ScorerError(RuntimeError):
|
|
29
|
+
"""A scorer raised. Fatal by default — see `keep_going`."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Report:
|
|
34
|
+
"""One run, its diff against the previous run, and where it was saved."""
|
|
35
|
+
|
|
36
|
+
run: Run
|
|
37
|
+
diff: Diff | None = None
|
|
38
|
+
path: Path | None = None
|
|
39
|
+
previous: Run | None = None
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def score(self) -> float | None:
|
|
43
|
+
return self.run.score
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def results(self) -> list[Result]:
|
|
47
|
+
return self.run.results
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def meta(self) -> dict[str, Any]:
|
|
51
|
+
return self.run.meta
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def cost_usd(self) -> float | None:
|
|
55
|
+
return self.run.meta.get("cost_usd")
|
|
56
|
+
|
|
57
|
+
def render(self, show: int = 5) -> str:
|
|
58
|
+
return report.render_run(self.run, self.diff, self.path, show=show)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class Estimate:
|
|
63
|
+
"""What a run would send, and roughly what it would cost."""
|
|
64
|
+
|
|
65
|
+
calls: int
|
|
66
|
+
input_tokens: int
|
|
67
|
+
max_output_tokens: int
|
|
68
|
+
model: str | None
|
|
69
|
+
cost_low: float | None = None
|
|
70
|
+
cost_high: float | None = None
|
|
71
|
+
extra_judge_calls: int = 0
|
|
72
|
+
previews: list[tuple[str, int, str]] = field(default_factory=list)
|
|
73
|
+
|
|
74
|
+
def render(self, quiet: bool = False) -> str:
|
|
75
|
+
lines: list[str] = []
|
|
76
|
+
if not quiet:
|
|
77
|
+
for cid, chars, first in self.previews:
|
|
78
|
+
lines.append(f" {cid:<10} ~{chars // 4:>5} tok in {first[:70]}")
|
|
79
|
+
lines.append("")
|
|
80
|
+
lines.append(f" {self.calls} calls · ~{self.input_tokens} input tokens")
|
|
81
|
+
lines.append(
|
|
82
|
+
f" worst case {self.max_output_tokens} output tokens (every case hitting max-tokens)"
|
|
83
|
+
)
|
|
84
|
+
if self.cost_low is not None and self.cost_high is not None:
|
|
85
|
+
lines.append(
|
|
86
|
+
f" estimated ${self.cost_low:.4f} … ${self.cost_high:.4f}"
|
|
87
|
+
" (input only … input + max output)"
|
|
88
|
+
)
|
|
89
|
+
if self.extra_judge_calls:
|
|
90
|
+
lines.append(
|
|
91
|
+
f" note the judge scorer adds {self.extra_judge_calls} more calls,"
|
|
92
|
+
" not included above"
|
|
93
|
+
)
|
|
94
|
+
lines.append("")
|
|
95
|
+
lines.append(" dry run — nothing was sent, no run file written")
|
|
96
|
+
return "\n".join(lines)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _prepare(
|
|
100
|
+
cases: str | Path | list[Case],
|
|
101
|
+
prompt: str | Path | None,
|
|
102
|
+
only_tag: str | None,
|
|
103
|
+
limit: int | None,
|
|
104
|
+
repeat: int,
|
|
105
|
+
) -> tuple[list[Case], str, Path | None]:
|
|
106
|
+
prompt_path = Path(prompt) if prompt else None
|
|
107
|
+
prompt_text = prompt_path.read_text(encoding="utf-8").strip() if prompt_path else ""
|
|
108
|
+
if isinstance(cases, (str, Path)):
|
|
109
|
+
loaded = load_cases(cases, only_tag=only_tag, limit=limit, repeat=repeat)
|
|
110
|
+
else:
|
|
111
|
+
loaded = list(cases)
|
|
112
|
+
return loaded, prompt_text, prompt_path
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def run_case(
|
|
116
|
+
case: Case,
|
|
117
|
+
prompt_text: str,
|
|
118
|
+
*,
|
|
119
|
+
runner: Runner,
|
|
120
|
+
scorer: Scorer,
|
|
121
|
+
ctx: Context,
|
|
122
|
+
placement: str = "system",
|
|
123
|
+
model: str | None = None,
|
|
124
|
+
max_tokens: int = 2000,
|
|
125
|
+
effort: str | None = None,
|
|
126
|
+
keep_going: bool = False,
|
|
127
|
+
) -> Result:
|
|
128
|
+
"""One case, end to end. Never raises for a failed model call.
|
|
129
|
+
|
|
130
|
+
A single bad response should not kill a 22-case run, so transport failures
|
|
131
|
+
and refusals are recorded and scored zero (a failed call *inside* the
|
|
132
|
+
scorer — a judge — is recorded as unscored). Two things do stop everything:
|
|
133
|
+
missing credentials (every remaining case will fail the same way) and a
|
|
134
|
+
scorer that raises — because a broken scorer reports a clean 0.000 that
|
|
135
|
+
looks exactly like a failing prompt.
|
|
136
|
+
"""
|
|
137
|
+
request = build_request(
|
|
138
|
+
case,
|
|
139
|
+
prompt_text,
|
|
140
|
+
placement=placement,
|
|
141
|
+
model=model,
|
|
142
|
+
max_tokens=max_tokens,
|
|
143
|
+
effort=effort,
|
|
144
|
+
)
|
|
145
|
+
started = time.time()
|
|
146
|
+
try:
|
|
147
|
+
response = runner(request)
|
|
148
|
+
except MissingCredentials:
|
|
149
|
+
raise
|
|
150
|
+
except Refusal as exc:
|
|
151
|
+
return Result(id=case.id, tag=case.tag, score=0.0, note=f"REFUSAL: {exc}", error="refusal")
|
|
152
|
+
except RunnerError as exc:
|
|
153
|
+
return Result(id=case.id, tag=case.tag, score=0.0, note=f"ERROR: {exc}", error="runner")
|
|
154
|
+
except Exception as exc: # noqa: BLE001 - a runner someone else wrote
|
|
155
|
+
return Result(
|
|
156
|
+
id=case.id, tag=case.tag, score=0.0, note=f"ERROR: {exc}", error=type(exc).__name__
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
meter = _Meter(default_model=ctx.model)
|
|
160
|
+
error = None
|
|
161
|
+
try:
|
|
162
|
+
score = as_score(scorer(response.text, case, meter.wrap(ctx)))
|
|
163
|
+
except MissingCredentials:
|
|
164
|
+
raise
|
|
165
|
+
except RunnerError as exc:
|
|
166
|
+
# The judge's model call failed, not the scorer's logic. That is the
|
|
167
|
+
# same transient failure a runner error is, so it gets recorded rather
|
|
168
|
+
# than stopping the run — but as unscored, not zero, because the output
|
|
169
|
+
# may well have been fine.
|
|
170
|
+
score, error = as_score((None, f"SCORER CALL FAILED: {exc}")), "scorer_call"
|
|
171
|
+
except Exception as exc:
|
|
172
|
+
if not keep_going:
|
|
173
|
+
raise ScorerError(
|
|
174
|
+
f"scorer raised on case {case.id}: {type(exc).__name__}: {exc}. "
|
|
175
|
+
"Pass keep_going=True (--keep-going) to score these zero and continue."
|
|
176
|
+
) from exc
|
|
177
|
+
score = as_score((0.0, f"SCORER ERROR: {type(exc).__name__}: {exc}"))
|
|
178
|
+
|
|
179
|
+
return Result(
|
|
180
|
+
id=case.id,
|
|
181
|
+
tag=case.tag,
|
|
182
|
+
score=score.value,
|
|
183
|
+
note=score.note,
|
|
184
|
+
output=response.text,
|
|
185
|
+
latency=round(time.time() - started, 2),
|
|
186
|
+
input_tokens=response.input_tokens,
|
|
187
|
+
output_tokens=response.output_tokens,
|
|
188
|
+
scorer_input_tokens=meter.input_tokens,
|
|
189
|
+
scorer_output_tokens=meter.output_tokens,
|
|
190
|
+
scorer_cost_usd=meter.cost_usd,
|
|
191
|
+
error=error,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class _Meter:
|
|
196
|
+
"""Counts the model calls a scorer makes, so a judge shows up in the bill.
|
|
197
|
+
|
|
198
|
+
The judge usually runs on a bigger model than the one under test, so its
|
|
199
|
+
calls are priced one by one rather than folded into the run's tokens.
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
def __init__(self, default_model: str | None):
|
|
203
|
+
self.default_model = default_model
|
|
204
|
+
self.input_tokens = 0
|
|
205
|
+
self.output_tokens = 0
|
|
206
|
+
self.cost_usd: float | None = 0.0
|
|
207
|
+
|
|
208
|
+
def _metered(self, runner: Runner) -> Runner:
|
|
209
|
+
def call(request):
|
|
210
|
+
response = runner(request)
|
|
211
|
+
self.input_tokens += response.input_tokens
|
|
212
|
+
self.output_tokens += response.output_tokens
|
|
213
|
+
price = pricing.cost(
|
|
214
|
+
request.model or self.default_model, response.input_tokens, response.output_tokens
|
|
215
|
+
)
|
|
216
|
+
# One unpriced call makes the total unknown, not smaller.
|
|
217
|
+
self.cost_usd = None if price is None or self.cost_usd is None else self.cost_usd + price
|
|
218
|
+
return response
|
|
219
|
+
|
|
220
|
+
return call
|
|
221
|
+
|
|
222
|
+
def wrap(self, ctx: Context) -> Context:
|
|
223
|
+
config = dict(ctx.config)
|
|
224
|
+
if config.get("judge_runner"):
|
|
225
|
+
config["judge_runner"] = self._metered(config["judge_runner"])
|
|
226
|
+
return replace(ctx, runner=self._metered(ctx.runner), config=config)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _total_cost(run_cost: float | None, scorer_costs: list[float | None]) -> float | None:
|
|
230
|
+
if run_cost is None or any(c is None for c in scorer_costs):
|
|
231
|
+
return None
|
|
232
|
+
return run_cost + sum(c for c in scorer_costs if c is not None)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def run(
|
|
236
|
+
cases: str | Path | list[Case],
|
|
237
|
+
prompt: str | Path | None = None,
|
|
238
|
+
*,
|
|
239
|
+
scorer: str | Scorer = "exact",
|
|
240
|
+
runner: Runner | None = None,
|
|
241
|
+
model: str | None = None,
|
|
242
|
+
placement: str = "system",
|
|
243
|
+
max_tokens: int = 2000,
|
|
244
|
+
effort: str | None = None,
|
|
245
|
+
only_tag: str | None = None,
|
|
246
|
+
limit: int | None = None,
|
|
247
|
+
repeat: int = 1,
|
|
248
|
+
workers: int = 8,
|
|
249
|
+
label: str | None = None,
|
|
250
|
+
scorer_config: dict[str, Any] | None = None,
|
|
251
|
+
keep_going: bool = False,
|
|
252
|
+
save: bool = True,
|
|
253
|
+
runs: str | Path | None = None,
|
|
254
|
+
on_result: Callable[[Result], None] | None = None,
|
|
255
|
+
) -> Report:
|
|
256
|
+
"""Score a prompt against a case file and diff it against the previous run."""
|
|
257
|
+
loaded, prompt_text, prompt_path = _prepare(cases, prompt, only_tag, limit, repeat)
|
|
258
|
+
if not loaded:
|
|
259
|
+
raise ValueError("no cases to run (did --only-tag match nothing?)")
|
|
260
|
+
|
|
261
|
+
provided_runner = runner is not None
|
|
262
|
+
runner = runner or default_runner()
|
|
263
|
+
if model is None and not provided_runner:
|
|
264
|
+
from evalix.runners.capix import default_model
|
|
265
|
+
|
|
266
|
+
model = default_model()
|
|
267
|
+
|
|
268
|
+
scorer_fn = get_scorer(scorer)
|
|
269
|
+
ctx = Context(runner=runner, model=model, config=dict(scorer_config or {}))
|
|
270
|
+
pricing.load_overrides()
|
|
271
|
+
|
|
272
|
+
results: list[Result | None] = [None] * len(loaded)
|
|
273
|
+
pool = concurrent.futures.ThreadPoolExecutor(max_workers=workers)
|
|
274
|
+
try:
|
|
275
|
+
futures = {
|
|
276
|
+
pool.submit(
|
|
277
|
+
run_case,
|
|
278
|
+
case,
|
|
279
|
+
prompt_text,
|
|
280
|
+
runner=runner,
|
|
281
|
+
scorer=scorer_fn,
|
|
282
|
+
ctx=ctx,
|
|
283
|
+
placement=placement,
|
|
284
|
+
model=model,
|
|
285
|
+
max_tokens=max_tokens,
|
|
286
|
+
effort=effort,
|
|
287
|
+
keep_going=keep_going,
|
|
288
|
+
): i
|
|
289
|
+
for i, case in enumerate(loaded)
|
|
290
|
+
}
|
|
291
|
+
for done in concurrent.futures.as_completed(futures):
|
|
292
|
+
index = futures[done]
|
|
293
|
+
results[index] = done.result()
|
|
294
|
+
if on_result:
|
|
295
|
+
on_result(results[index])
|
|
296
|
+
finally:
|
|
297
|
+
# A `with` block would wait for every queued case on the way out, so a
|
|
298
|
+
# scorer error or Ctrl+C on case 1 of 400 still paid for the other 399.
|
|
299
|
+
# Queued cases are dropped; the few already in flight cannot be
|
|
300
|
+
# interrupted and finish in the background.
|
|
301
|
+
pool.shutdown(wait=False, cancel_futures=True)
|
|
302
|
+
|
|
303
|
+
final: list[Result] = [r for r in results if r is not None]
|
|
304
|
+
tok_in = sum(r.input_tokens for r in final)
|
|
305
|
+
tok_out = sum(r.output_tokens for r in final)
|
|
306
|
+
scorer_in = sum(r.scorer_input_tokens for r in final)
|
|
307
|
+
scorer_out = sum(r.scorer_output_tokens for r in final)
|
|
308
|
+
cost = _total_cost(pricing.cost(model, tok_in, tok_out), [r.scorer_cost_usd for r in final])
|
|
309
|
+
|
|
310
|
+
root = project_root()
|
|
311
|
+
key = case_key(cases, root) if isinstance(cases, (str, Path)) else "cases"
|
|
312
|
+
meta = {
|
|
313
|
+
"case_key": key,
|
|
314
|
+
"cases": str(cases) if isinstance(cases, (str, Path)) else f"<{len(loaded)} cases>",
|
|
315
|
+
"prompt": str(prompt_path) if prompt_path else None,
|
|
316
|
+
"label": label or (prompt_path.stem if prompt_path else "no-prompt"),
|
|
317
|
+
"model": model,
|
|
318
|
+
"effort": effort,
|
|
319
|
+
"placement": placement,
|
|
320
|
+
"scorer": name_of(scorer),
|
|
321
|
+
"score": mean(final),
|
|
322
|
+
"input_tokens": tok_in,
|
|
323
|
+
"output_tokens": tok_out,
|
|
324
|
+
"scorer_input_tokens": scorer_in,
|
|
325
|
+
"scorer_output_tokens": scorer_out,
|
|
326
|
+
"cost_usd": cost,
|
|
327
|
+
# Local time on purpose: it is a label a person reads.
|
|
328
|
+
"timestamp": dt.datetime.now().strftime("%Y%m%d-%H%M%S"), # noqa: DTZ005
|
|
329
|
+
}
|
|
330
|
+
current = Run(meta=meta, results=final)
|
|
331
|
+
|
|
332
|
+
store = RunStore(runs_dir(runs, root))
|
|
333
|
+
previous = store.previous(key)
|
|
334
|
+
path = store.save(current) if save else None
|
|
335
|
+
|
|
336
|
+
return Report(
|
|
337
|
+
run=current,
|
|
338
|
+
diff=diff(previous, current) if previous else None,
|
|
339
|
+
path=path,
|
|
340
|
+
previous=previous,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def estimate(
|
|
345
|
+
cases: str | Path | list[Case],
|
|
346
|
+
prompt: str | Path | None = None,
|
|
347
|
+
*,
|
|
348
|
+
scorer: str | Scorer = "exact",
|
|
349
|
+
model: str | None = None,
|
|
350
|
+
placement: str = "system",
|
|
351
|
+
max_tokens: int = 2000,
|
|
352
|
+
only_tag: str | None = None,
|
|
353
|
+
limit: int | None = None,
|
|
354
|
+
repeat: int = 1,
|
|
355
|
+
) -> Estimate:
|
|
356
|
+
"""Resolve everything a run needs and price it, without sending anything.
|
|
357
|
+
|
|
358
|
+
Token counts are a rough chars/4 guess — enough to catch a prompt that grew
|
|
359
|
+
tenfold, not accurate enough to bill against.
|
|
360
|
+
"""
|
|
361
|
+
loaded, prompt_text, _ = _prepare(cases, prompt, only_tag, limit, repeat)
|
|
362
|
+
pricing.load_overrides()
|
|
363
|
+
|
|
364
|
+
total_chars = 0
|
|
365
|
+
previews: list[tuple[str, int, str]] = []
|
|
366
|
+
for case in loaded:
|
|
367
|
+
request = build_request(case, prompt_text, placement=placement, max_tokens=max_tokens)
|
|
368
|
+
chars = len(request.text) + len(request.system or "")
|
|
369
|
+
total_chars += chars
|
|
370
|
+
previews.append((case.id, chars, (request.text.splitlines() or [""])[0]))
|
|
371
|
+
|
|
372
|
+
est_in = total_chars // 4
|
|
373
|
+
est_out = len(loaded) * max_tokens
|
|
374
|
+
price = pricing.PRICES.get(model or "")
|
|
375
|
+
low = est_in * price[0] / 1_000_000 if price else None
|
|
376
|
+
high = (low + est_out * price[1] / 1_000_000) if price and low is not None else None
|
|
377
|
+
|
|
378
|
+
return Estimate(
|
|
379
|
+
calls=len(loaded),
|
|
380
|
+
input_tokens=est_in,
|
|
381
|
+
max_output_tokens=est_out,
|
|
382
|
+
model=model,
|
|
383
|
+
cost_low=low,
|
|
384
|
+
cost_high=high,
|
|
385
|
+
extra_judge_calls=len(loaded) if name_of(scorer) == "judge" else 0,
|
|
386
|
+
previews=previews,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
@dataclass
|
|
391
|
+
class Comparison:
|
|
392
|
+
"""Two saved runs, where they came from, and the diff between them."""
|
|
393
|
+
|
|
394
|
+
old_path: Path
|
|
395
|
+
new_path: Path
|
|
396
|
+
old: Run
|
|
397
|
+
new: Run
|
|
398
|
+
diff: Diff
|
|
399
|
+
|
|
400
|
+
def render(self, show: int = 5, show_all: bool = False) -> str:
|
|
401
|
+
return report.render_comparison(
|
|
402
|
+
self.old_path, self.new_path, self.old, self.new, self.diff,
|
|
403
|
+
show=show, show_all=show_all,
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def compare(old: str | Path, new: str | Path, *, runs: str | Path | None = None) -> Comparison:
|
|
408
|
+
"""Diff any two saved runs, each given as a path or a substring of its filename."""
|
|
409
|
+
store = RunStore(runs_dir(runs))
|
|
410
|
+
old_path, old_run = store.resolve(old)
|
|
411
|
+
new_path, new_run = store.resolve(new)
|
|
412
|
+
if old_path == new_path:
|
|
413
|
+
raise ValueError(f"both arguments resolved to the same run ({old_path.name})")
|
|
414
|
+
return Comparison(old_path, new_path, old_run, new_run, diff(old_run, new_run))
|
evalix/cases.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Cases: the fixed input set a prompt is measured against.
|
|
2
|
+
|
|
3
|
+
JSONL, one object per line. `expected` means whatever the chosen scorer says
|
|
4
|
+
it means — that looseness is deliberate and is what lets one harness score
|
|
5
|
+
classification, extraction and injection resistance without knowing anything
|
|
6
|
+
about them.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
RESERVED = ("id", "input", "expected", "tag")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Case:
|
|
21
|
+
"""One case.
|
|
22
|
+
|
|
23
|
+
Unknown keys land in `extra` and stay reachable through `get()` and `[]`.
|
|
24
|
+
That is the cheapest extension point in the package: a custom scorer can
|
|
25
|
+
read whatever fields its task needs without the harness knowing they exist.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
id: str
|
|
29
|
+
input: str = ""
|
|
30
|
+
expected: Any = None
|
|
31
|
+
tag: str | None = None
|
|
32
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def from_dict(cls, row: dict[str, Any], *, line: int | None = None) -> Case:
|
|
36
|
+
where = f"case at line {line}" if line is not None else "case"
|
|
37
|
+
if not isinstance(row, dict):
|
|
38
|
+
raise ValueError(f"{where} is a {type(row).__name__}, not a JSON object") # noqa: TRY004
|
|
39
|
+
if "id" not in row:
|
|
40
|
+
raise ValueError(
|
|
41
|
+
f"{where} has no 'id'. Ids are the diff key — without "
|
|
42
|
+
"them every case collides and fixed/broke is meaningless."
|
|
43
|
+
)
|
|
44
|
+
return cls(
|
|
45
|
+
id=str(row["id"]),
|
|
46
|
+
input=row.get("input", ""),
|
|
47
|
+
expected=row.get("expected"),
|
|
48
|
+
tag=row.get("tag"),
|
|
49
|
+
extra={k: v for k, v in row.items() if k not in RESERVED},
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
53
|
+
if key in RESERVED:
|
|
54
|
+
return getattr(self, key)
|
|
55
|
+
return self.extra.get(key, default)
|
|
56
|
+
|
|
57
|
+
def __getitem__(self, key: str) -> Any:
|
|
58
|
+
value = self.get(key, _MISSING)
|
|
59
|
+
if value is _MISSING:
|
|
60
|
+
raise KeyError(key)
|
|
61
|
+
return value
|
|
62
|
+
|
|
63
|
+
def __contains__(self, key: str) -> bool:
|
|
64
|
+
return self.get(key, _MISSING) is not _MISSING
|
|
65
|
+
|
|
66
|
+
def replace(self, **changes: Any) -> Case:
|
|
67
|
+
from dataclasses import replace
|
|
68
|
+
|
|
69
|
+
return replace(self, **changes)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
_MISSING = object()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def load_cases(
|
|
76
|
+
path: str | Path,
|
|
77
|
+
*,
|
|
78
|
+
only_tag: str | None = None,
|
|
79
|
+
limit: int | None = None,
|
|
80
|
+
repeat: int = 1,
|
|
81
|
+
) -> list[Case]:
|
|
82
|
+
"""Read a case file and apply the run-shaping filters.
|
|
83
|
+
|
|
84
|
+
Order matters: repeat expands, then limit truncates. `--limit 2 --repeat 3`
|
|
85
|
+
is two calls, not six — the limit is a spending cap, so it has to be last.
|
|
86
|
+
"""
|
|
87
|
+
path = Path(path)
|
|
88
|
+
cases: list[Case] = []
|
|
89
|
+
# Line numbers count every physical line, comments and blanks included, so
|
|
90
|
+
# they match what an editor shows.
|
|
91
|
+
for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
|
|
92
|
+
line = line.strip()
|
|
93
|
+
if not line or line.startswith("//"):
|
|
94
|
+
continue
|
|
95
|
+
try:
|
|
96
|
+
row = json.loads(line)
|
|
97
|
+
except json.JSONDecodeError as exc:
|
|
98
|
+
raise ValueError(f"{path}: invalid JSON at line {number}: {exc.msg}") from None
|
|
99
|
+
try:
|
|
100
|
+
cases.append(Case.from_dict(row, line=number))
|
|
101
|
+
except ValueError as exc:
|
|
102
|
+
raise ValueError(f"{path}: {exc}") from None
|
|
103
|
+
|
|
104
|
+
if only_tag:
|
|
105
|
+
cases = [c for c in cases if c.tag == only_tag]
|
|
106
|
+
if repeat > 1:
|
|
107
|
+
cases = [c.replace(id=f"{c.id}#{r + 1}") for c in cases for r in range(repeat)]
|
|
108
|
+
if limit:
|
|
109
|
+
cases = cases[:limit]
|
|
110
|
+
|
|
111
|
+
duplicates = {c.id for c in cases if sum(1 for o in cases if o.id == c.id) > 1}
|
|
112
|
+
if duplicates:
|
|
113
|
+
raise ValueError(
|
|
114
|
+
f"duplicate case ids: {', '.join(sorted(duplicates))}. "
|
|
115
|
+
"Ids must be unique or the diff cannot line runs up."
|
|
116
|
+
)
|
|
117
|
+
return cases
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def case_key(path: str | Path, root: Path) -> str:
|
|
121
|
+
"""A stable key identifying *which case file* a run was against.
|
|
122
|
+
|
|
123
|
+
Runs are diffed against the previous run of the same key, so this must not
|
|
124
|
+
depend on the directory you happened to run from. It is the path relative
|
|
125
|
+
to the project root, which is why the root is discovered rather than
|
|
126
|
+
assumed.
|
|
127
|
+
"""
|
|
128
|
+
import re
|
|
129
|
+
|
|
130
|
+
resolved = Path(path).resolve()
|
|
131
|
+
try:
|
|
132
|
+
rel = resolved.relative_to(root)
|
|
133
|
+
except ValueError:
|
|
134
|
+
rel = Path(resolved.name)
|
|
135
|
+
return re.sub(r"[^A-Za-z0-9._-]", "-", str(rel.with_suffix("")))
|