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 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("")))