evalshift 0.3.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.
Files changed (64) hide show
  1. evalshift/__init__.py +12 -0
  2. evalshift/__main__.py +17 -0
  3. evalshift/analysis/__init__.py +1 -0
  4. evalshift/analysis/slicing.py +143 -0
  5. evalshift/analysis/statistics.py +382 -0
  6. evalshift/cache/__init__.py +1 -0
  7. evalshift/cache/schema.py +102 -0
  8. evalshift/cache/store.py +214 -0
  9. evalshift/cli/__init__.py +1 -0
  10. evalshift/cli/commands/__init__.py +1 -0
  11. evalshift/cli/commands/all.py +591 -0
  12. evalshift/cli/commands/analyze.py +334 -0
  13. evalshift/cli/commands/cache.py +42 -0
  14. evalshift/cli/commands/doctor.py +232 -0
  15. evalshift/cli/commands/evaluate.py +434 -0
  16. evalshift/cli/commands/init.py +415 -0
  17. evalshift/cli/commands/report.py +137 -0
  18. evalshift/cli/commands/run.py +266 -0
  19. evalshift/cli/commands/test_call.py +254 -0
  20. evalshift/cli/commands/validate.py +128 -0
  21. evalshift/cli/main.py +72 -0
  22. evalshift/config/__init__.py +1 -0
  23. evalshift/config/loader.py +217 -0
  24. evalshift/config/models.py +338 -0
  25. evalshift/evaluators/__init__.py +1 -0
  26. evalshift/evaluators/base.py +93 -0
  27. evalshift/evaluators/llm_judge.py +154 -0
  28. evalshift/evaluators/semantic.py +93 -0
  29. evalshift/evaluators/structural.py +131 -0
  30. evalshift/evaluators/tool_arguments.py +216 -0
  31. evalshift/evaluators/tool_loader.py +198 -0
  32. evalshift/evaluators/tool_models.py +194 -0
  33. evalshift/evaluators/tool_parser.py +328 -0
  34. evalshift/evaluators/tool_selection.py +294 -0
  35. evalshift/evaluators/tool_trace_structure.py +108 -0
  36. evalshift/models/__init__.py +1 -0
  37. evalshift/models/client.py +456 -0
  38. evalshift/models/registry.py +272 -0
  39. evalshift/models/replay_client.py +203 -0
  40. evalshift/parsers/__init__.py +1 -0
  41. evalshift/parsers/base.py +144 -0
  42. evalshift/parsers/manual.py +45 -0
  43. evalshift/parsers/python_string.py +204 -0
  44. evalshift/py.typed +0 -0
  45. evalshift/reports/__init__.py +1 -0
  46. evalshift/reports/html.py +69 -0
  47. evalshift/reports/json.py +523 -0
  48. evalshift/reports/templates/report.css +203 -0
  49. evalshift/reports/templates/report.html.j2 +268 -0
  50. evalshift/runner/__init__.py +1 -0
  51. evalshift/runner/checkpoint.py +280 -0
  52. evalshift/runner/models.py +152 -0
  53. evalshift/runner/orchestrator.py +703 -0
  54. evalshift/suite/__init__.py +1 -0
  55. evalshift/suite/loader.py +216 -0
  56. evalshift/suite/models.py +156 -0
  57. evalshift/utils/__init__.py +1 -0
  58. evalshift/utils/cost.py +195 -0
  59. evalshift/utils/templating.py +213 -0
  60. evalshift-0.3.0.dist-info/METADATA +185 -0
  61. evalshift-0.3.0.dist-info/RECORD +64 -0
  62. evalshift-0.3.0.dist-info/WHEEL +4 -0
  63. evalshift-0.3.0.dist-info/entry_points.txt +2 -0
  64. evalshift-0.3.0.dist-info/licenses/LICENSE +21 -0
evalshift/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """EvalShift: a local-first CLI for safe LLM model migrations.
2
+
3
+ EvalShift runs your prompts on two LLM model versions against a golden suite of
4
+ inputs, scores the resulting outputs with structural, semantic, and LLM-as-judge
5
+ evaluators, and produces a single-file HTML report with statistically rigorous
6
+ regression analysis.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ __version__ = "0.3.0"
12
+ __all__ = ["__version__"]
evalshift/__main__.py ADDED
@@ -0,0 +1,17 @@
1
+ """Entry point for ``python -m evalshift``.
2
+
3
+ Delegates to the Typer app defined in :mod:`evalshift.cli.main`.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from evalshift.cli.main import app
9
+
10
+
11
+ def main() -> None:
12
+ """Invoke the EvalShift CLI."""
13
+ app()
14
+
15
+
16
+ if __name__ == "__main__":
17
+ main()
@@ -0,0 +1 @@
1
+ """Statistical analysis and slicing of evaluation scores."""
@@ -0,0 +1,143 @@
1
+ """Group evaluation records into slices for analysis.
2
+
3
+ A *slice* is a named subset of suite examples — typically defined by a
4
+ tag in ``evalshift.yaml``'s ``slices:`` block. The implicit ``"all"``
5
+ slice always exists and contains every example.
6
+
7
+ The output of :func:`build_slices` is a mapping from slice name to a
8
+ list of ``(prompt_id, evaluator_name, example_id, source_score,
9
+ target_score, delta)`` tuples that the statistics layer can pair up and
10
+ test.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections import defaultdict
16
+ from dataclasses import dataclass
17
+
18
+ from evalshift.evaluators.base import EvalRecord
19
+ from evalshift.suite.models import Suite
20
+
21
+ ALL_SLICE: str = "all"
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class SlicedScore:
26
+ """One paired score belonging to a slice."""
27
+
28
+ prompt_id: str
29
+ evaluator_name: str
30
+ example_id: str
31
+ source_score: float
32
+ target_score: float
33
+ delta: float
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class SliceAggregate:
38
+ """Per-slice aggregate stats (computed without significance testing)."""
39
+
40
+ name: str
41
+ n: int
42
+ source_avg_score: float
43
+ target_avg_score: float
44
+ delta_avg_score: float
45
+ delta_min_score: float
46
+ delta_max_score: float
47
+ source_score_stdev: float
48
+ target_score_stdev: float
49
+
50
+
51
+ def build_slices(
52
+ *,
53
+ records: list[EvalRecord],
54
+ suite: Suite,
55
+ tag_to_slice: dict[str, str] | None = None,
56
+ ) -> dict[str, list[SlicedScore]]:
57
+ """Group evaluation records into slices keyed by slice name.
58
+
59
+ Args:
60
+ records: Every :class:`EvalRecord` from ``scores.jsonl``.
61
+ suite: The loaded suite, used to look up an example's ``tags``.
62
+ tag_to_slice: Mapping from a configured slice's tag (the value
63
+ of ``filter`` in MVP, simplified to a literal tag) to the
64
+ slice name surfaced in reports. ``None`` falls back to a
65
+ tag-name == slice-name identity mapping.
66
+
67
+ Returns:
68
+ ``{slice_name: [SlicedScore, ...]}`` always containing at
69
+ least the implicit ``"all"`` slice.
70
+ """
71
+ by_id = {ex.id: ex for ex in suite.examples}
72
+ out: dict[str, list[SlicedScore]] = defaultdict(list)
73
+
74
+ for rec in records:
75
+ # Skip records that errored at the evaluator layer — they don't
76
+ # carry meaningful scores.
77
+ if rec.error is not None:
78
+ continue
79
+ sliced = SlicedScore(
80
+ prompt_id=rec.prompt_id,
81
+ evaluator_name=rec.evaluator_name,
82
+ example_id=rec.example_id,
83
+ source_score=rec.source_score,
84
+ target_score=rec.target_score,
85
+ delta=rec.delta,
86
+ )
87
+ out[ALL_SLICE].append(sliced)
88
+ example = by_id.get(rec.example_id)
89
+ if example is None:
90
+ continue
91
+ for tag in example.tags:
92
+ slice_name = tag_to_slice.get(tag, tag) if tag_to_slice else tag
93
+ out[slice_name].append(sliced)
94
+
95
+ return dict(out)
96
+
97
+
98
+ def aggregates(
99
+ sliced: list[SlicedScore],
100
+ name: str,
101
+ ) -> SliceAggregate:
102
+ """Compute simple aggregates over a list of paired scores."""
103
+ n = len(sliced)
104
+ if n == 0:
105
+ return SliceAggregate(
106
+ name=name,
107
+ n=0,
108
+ source_avg_score=0.0,
109
+ target_avg_score=0.0,
110
+ delta_avg_score=0.0,
111
+ delta_min_score=0.0,
112
+ delta_max_score=0.0,
113
+ source_score_stdev=0.0,
114
+ target_score_stdev=0.0,
115
+ )
116
+ source = [s.source_score for s in sliced]
117
+ target = [s.target_score for s in sliced]
118
+ deltas = [s.delta for s in sliced]
119
+ return SliceAggregate(
120
+ name=name,
121
+ n=n,
122
+ source_avg_score=_mean(source),
123
+ target_avg_score=_mean(target),
124
+ delta_avg_score=_mean(deltas),
125
+ delta_min_score=min(deltas),
126
+ delta_max_score=max(deltas),
127
+ source_score_stdev=_std(source),
128
+ target_score_stdev=_std(target),
129
+ )
130
+
131
+
132
+ def _mean(xs: list[float]) -> float:
133
+ return float(sum(xs) / len(xs)) if xs else 0.0
134
+
135
+
136
+ def _std(xs: list[float]) -> float:
137
+ if len(xs) < 2:
138
+ return 0.0
139
+ mu = _mean(xs)
140
+ return float((sum((x - mu) ** 2 for x in xs) / (len(xs) - 1)) ** 0.5)
141
+
142
+
143
+ __all__ = ["ALL_SLICE", "SliceAggregate", "SlicedScore", "aggregates", "build_slices"]
@@ -0,0 +1,382 @@
1
+ """Statistical analysis of paired source/target scores.
2
+
3
+ Implements the contract from PDF §5.5 step-by-step:
4
+
5
+ 1. Pair scores per slice; require ``n>=5`` to test, flag ``n<20``.
6
+ 2. Test normality via Shapiro-Wilk on the deltas; route to a paired
7
+ t-test (normal) or Wilcoxon signed-rank (non-normal).
8
+ 3. Compute Cohen's d for paired samples (``mean(deltas)/std(deltas)``).
9
+ 4. Compute a 95% CI on the effect size — analytical for the t-test,
10
+ bootstrap for Wilcoxon.
11
+ 5. After every comparison in the run is collected, apply the
12
+ Benjamini-Hochberg correction (FDR=0.05) across all p-values to
13
+ control false-discovery in the multi-test setting.
14
+ 6. Classify severity from the *corrected* p-value + effect size.
15
+
16
+ Why these choices:
17
+
18
+ * **Paired tests** because each example is run on both models — the
19
+ measurements are inherently paired and ignoring that throws away
20
+ power.
21
+ * **Shapiro-Wilk gate** because the t-test is sensitive to non-normal
22
+ deltas at small N; falling back to Wilcoxon (which only assumes
23
+ symmetric distribution) is the standard MVP-grade safety net.
24
+ * **Cohen's d** as the effect size because it's interpretable in units
25
+ of standard deviation and the field has an intuition for it
26
+ (``|d|>0.8`` = "large").
27
+ * **Benjamini-Hochberg** instead of Bonferroni because BH controls FDR
28
+ at the same level with substantially more power, especially when
29
+ some comparisons are real (which is exactly the case in a real
30
+ migration eval).
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from dataclasses import dataclass
36
+ from typing import Literal
37
+
38
+ import numpy as np
39
+ from scipy import stats
40
+
41
+ from evalshift.analysis.slicing import SlicedScore
42
+
43
+ Severity = Literal[
44
+ "critical",
45
+ "high",
46
+ "medium",
47
+ "low",
48
+ "improved",
49
+ "none",
50
+ "insufficient",
51
+ ]
52
+
53
+ TestKind = Literal["paired_t", "wilcoxon", "skipped"]
54
+
55
+ MIN_N_FOR_TEST: int = 5
56
+ MIN_N_RELIABLE: int = 20
57
+ NORMALITY_ALPHA: float = 0.05
58
+ FDR_ALPHA: float = 0.05
59
+ BOOTSTRAP_RESAMPLES: int = 2000
60
+ DEFAULT_RNG_SEED: int = 0
61
+
62
+
63
+ @dataclass(frozen=True, slots=True)
64
+ class ComparisonResult:
65
+ """One (prompt_id, evaluator_name, slice_name) statistical verdict."""
66
+
67
+ prompt_id: str
68
+ evaluator_name: str
69
+ slice_name: str
70
+ n: int
71
+ test: TestKind
72
+ statistic: float
73
+ p_value: float
74
+ p_value_corrected: float
75
+ effect_size: float
76
+ effect_size_ci_low: float
77
+ effect_size_ci_high: float
78
+ delta_avg_score: float
79
+ severity: Severity
80
+ notes: list[str]
81
+
82
+
83
+ def analyze(
84
+ *,
85
+ sliced_by_slice: dict[str, list[SlicedScore]],
86
+ rng: np.random.Generator | None = None,
87
+ ) -> list[ComparisonResult]:
88
+ """Run every comparison and apply BH correction across the whole set.
89
+
90
+ Returns a list of :class:`ComparisonResult` ordered by descending
91
+ severity then absolute effect size — i.e. the worst regressions
92
+ bubble to the top.
93
+ """
94
+ rng_inst = rng or np.random.default_rng(DEFAULT_RNG_SEED)
95
+
96
+ # Group within each slice by (prompt_id, evaluator_name) so we run
97
+ # one test per (prompt, evaluator, slice).
98
+ raw: list[ComparisonResult] = []
99
+ for slice_name, sliced in sliced_by_slice.items():
100
+ by_key: dict[tuple[str, str], list[SlicedScore]] = {}
101
+ for s in sliced:
102
+ by_key.setdefault((s.prompt_id, s.evaluator_name), []).append(s)
103
+ for (prompt_id, evaluator_name), pairs in by_key.items():
104
+ raw.append(
105
+ _one_comparison(
106
+ prompt_id=prompt_id,
107
+ evaluator_name=evaluator_name,
108
+ slice_name=slice_name,
109
+ pairs=pairs,
110
+ rng=rng_inst,
111
+ ),
112
+ )
113
+
114
+ # Apply Benjamini-Hochberg across every comparison that actually
115
+ # ran a test (skipped comparisons keep p_value = 1.0 and don't
116
+ # participate in the correction).
117
+ testable_idx = [i for i, c in enumerate(raw) if c.test != "skipped"]
118
+ p_raw = [raw[i].p_value for i in testable_idx]
119
+ p_corrected_seq = _benjamini_hochberg(p_raw)
120
+ corrected_lookup: dict[int, float] = dict(
121
+ zip(testable_idx, p_corrected_seq, strict=True),
122
+ )
123
+
124
+ finalised: list[ComparisonResult] = []
125
+ for i, c in enumerate(raw):
126
+ if c.test == "skipped":
127
+ finalised.append(c)
128
+ continue
129
+ cp = corrected_lookup[i]
130
+ sev = _classify_severity(
131
+ corrected_p=cp, d=c.effect_size, mean_delta=c.delta_avg_score, n=c.n
132
+ )
133
+ finalised.append(
134
+ ComparisonResult(
135
+ prompt_id=c.prompt_id,
136
+ evaluator_name=c.evaluator_name,
137
+ slice_name=c.slice_name,
138
+ n=c.n,
139
+ test=c.test,
140
+ statistic=c.statistic,
141
+ p_value=c.p_value,
142
+ p_value_corrected=cp,
143
+ effect_size=c.effect_size,
144
+ effect_size_ci_low=c.effect_size_ci_low,
145
+ effect_size_ci_high=c.effect_size_ci_high,
146
+ delta_avg_score=c.delta_avg_score,
147
+ severity=sev,
148
+ notes=c.notes,
149
+ ),
150
+ )
151
+
152
+ finalised.sort(key=_severity_sort_key)
153
+ return finalised
154
+
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # Helpers
158
+ # ---------------------------------------------------------------------------
159
+
160
+
161
+ def _one_comparison(
162
+ *,
163
+ prompt_id: str,
164
+ evaluator_name: str,
165
+ slice_name: str,
166
+ pairs: list[SlicedScore],
167
+ rng: np.random.Generator,
168
+ ) -> ComparisonResult:
169
+ """Compute one comparison, deferring severity until BH is done."""
170
+ n = len(pairs)
171
+ deltas = np.array([p.delta for p in pairs], dtype=float)
172
+ notes: list[str] = []
173
+
174
+ if n < MIN_N_FOR_TEST:
175
+ return ComparisonResult(
176
+ prompt_id=prompt_id,
177
+ evaluator_name=evaluator_name,
178
+ slice_name=slice_name,
179
+ n=n,
180
+ test="skipped",
181
+ statistic=0.0,
182
+ p_value=1.0,
183
+ p_value_corrected=1.0,
184
+ effect_size=0.0,
185
+ effect_size_ci_low=0.0,
186
+ effect_size_ci_high=0.0,
187
+ delta_avg_score=float(np.mean(deltas)) if n else 0.0,
188
+ severity="insufficient",
189
+ notes=[f"n={n} < {MIN_N_FOR_TEST}; no test run"],
190
+ )
191
+ if n < MIN_N_RELIABLE:
192
+ notes.append(f"n={n} < {MIN_N_RELIABLE}; results uncertain")
193
+
194
+ # Variance ≈ 0 (every delta identical, modulo float noise) → no
195
+ # inference possible. Use a small tolerance to dodge catastrophic
196
+ # cancellation that scipy already warns about.
197
+ if float(np.std(deltas, ddof=1)) < 1e-9:
198
+ return ComparisonResult(
199
+ prompt_id=prompt_id,
200
+ evaluator_name=evaluator_name,
201
+ slice_name=slice_name,
202
+ n=n,
203
+ test="skipped",
204
+ statistic=0.0,
205
+ p_value=1.0,
206
+ p_value_corrected=1.0,
207
+ effect_size=0.0,
208
+ effect_size_ci_low=0.0,
209
+ effect_size_ci_high=0.0,
210
+ delta_avg_score=float(np.mean(deltas)),
211
+ severity="none",
212
+ notes=[*notes, "zero variance — no test"],
213
+ )
214
+
215
+ # Pick the test based on a normality screen.
216
+ use_t = _is_normal(deltas)
217
+
218
+ if use_t:
219
+ result = stats.ttest_rel(
220
+ [p.target_score for p in pairs],
221
+ [p.source_score for p in pairs],
222
+ )
223
+ statistic = float(result.statistic)
224
+ p_value = float(result.pvalue)
225
+ d = _cohens_d_paired(deltas)
226
+ ci_low, ci_high = _t_test_d_ci(d=d, n=n)
227
+ test_kind: TestKind = "paired_t"
228
+ else:
229
+ result = stats.wilcoxon(deltas, zero_method="wilcox", correction=False)
230
+ statistic = float(result.statistic)
231
+ p_value = float(result.pvalue)
232
+ d = _cohens_d_paired(deltas)
233
+ ci_low, ci_high = _bootstrap_d_ci(deltas, rng=rng)
234
+ test_kind = "wilcoxon"
235
+ notes.append("non-normal deltas; used Wilcoxon")
236
+
237
+ return ComparisonResult(
238
+ prompt_id=prompt_id,
239
+ evaluator_name=evaluator_name,
240
+ slice_name=slice_name,
241
+ n=n,
242
+ test=test_kind,
243
+ statistic=statistic,
244
+ p_value=p_value,
245
+ p_value_corrected=p_value, # placeholder; overwritten by BH
246
+ effect_size=d,
247
+ effect_size_ci_low=ci_low,
248
+ effect_size_ci_high=ci_high,
249
+ delta_avg_score=float(np.mean(deltas)),
250
+ severity="none", # placeholder; classified after BH
251
+ notes=notes,
252
+ )
253
+
254
+
255
+ def _is_normal(deltas: np.ndarray) -> bool:
256
+ """Shapiro-Wilk normality screen.
257
+
258
+ Skips for n>5000 where Shapiro is unreliable / overly powerful;
259
+ in that regime a t-test is robust enough by central-limit-theorem
260
+ grounds.
261
+ """
262
+ if deltas.size > 5000:
263
+ return True
264
+ try:
265
+ result = stats.shapiro(deltas)
266
+ except Exception:
267
+ return False
268
+ return float(result.pvalue) > NORMALITY_ALPHA
269
+
270
+
271
+ def _cohens_d_paired(deltas: np.ndarray) -> float:
272
+ sd = float(np.std(deltas, ddof=1))
273
+ if sd < 1e-9:
274
+ return 0.0
275
+ return float(np.mean(deltas) / sd)
276
+
277
+
278
+ def _t_test_d_ci(*, d: float, n: int) -> tuple[float, float]:
279
+ """Analytical 95% CI on Cohen's d (paired) using the SE approximation.
280
+
281
+ Standard error: sqrt(1/n + d^2/(2n)). Times 1.96 for 95%.
282
+ """
283
+ se = float(np.sqrt(1.0 / n + (d * d) / (2.0 * n)))
284
+ return d - 1.96 * se, d + 1.96 * se
285
+
286
+
287
+ def _bootstrap_d_ci(
288
+ deltas: np.ndarray,
289
+ *,
290
+ rng: np.random.Generator,
291
+ resamples: int = BOOTSTRAP_RESAMPLES,
292
+ ) -> tuple[float, float]:
293
+ """Percentile-bootstrap 95% CI on Cohen's d (paired)."""
294
+ n = deltas.size
295
+ sample_ds = np.empty(resamples, dtype=float)
296
+ for i in range(resamples):
297
+ idx = rng.integers(0, n, size=n)
298
+ d = deltas[idx]
299
+ sd = float(np.std(d, ddof=1))
300
+ sample_ds[i] = float(np.mean(d)) / sd if sd != 0 else 0.0
301
+ return float(np.percentile(sample_ds, 2.5)), float(np.percentile(sample_ds, 97.5))
302
+
303
+
304
+ def _benjamini_hochberg(p_values: list[float]) -> list[float]:
305
+ """Apply the BH FDR correction; returns adjusted p-values in input order.
306
+
307
+ Mirrors ``statsmodels.stats.multitest.multipletests(..., method='fdr_bh')``
308
+ semantics: each adjusted p-value is the smallest q such that the
309
+ BH cutoff would still call the corresponding raw p significant.
310
+ """
311
+ n = len(p_values)
312
+ if n == 0:
313
+ return []
314
+ arr = np.asarray(p_values, dtype=float)
315
+ order = np.argsort(arr)
316
+ ranked = arr[order]
317
+ adjusted_ranked = ranked * n / (np.arange(n) + 1)
318
+ # Enforce monotonicity (right-to-left running min) and clamp to 1.
319
+ adjusted_ranked = np.minimum.accumulate(adjusted_ranked[::-1])[::-1]
320
+ adjusted_ranked = np.clip(adjusted_ranked, 0.0, 1.0)
321
+ out = np.empty(n, dtype=float)
322
+ out[order] = adjusted_ranked
323
+ return [float(x) for x in out]
324
+
325
+
326
+ def _classify_severity(
327
+ *,
328
+ corrected_p: float,
329
+ d: float,
330
+ mean_delta: float,
331
+ n: int,
332
+ ) -> Severity:
333
+ """Map (corrected p, effect size, direction, n) to severity per PDF §5.5."""
334
+ if n < MIN_N_FOR_TEST:
335
+ return "insufficient"
336
+ if corrected_p >= FDR_ALPHA:
337
+ return "none"
338
+ abs_d = abs(d)
339
+ if mean_delta > 0:
340
+ return "improved"
341
+ # mean_delta < 0 → regression
342
+ if corrected_p < 0.01 and abs_d > 0.8:
343
+ return "critical"
344
+ if abs_d > 0.5:
345
+ return "high"
346
+ if abs_d > 0.2:
347
+ return "medium"
348
+ return "low"
349
+
350
+
351
+ _SEVERITY_RANK: dict[Severity, int] = {
352
+ "critical": 0,
353
+ "high": 1,
354
+ "medium": 2,
355
+ "low": 3,
356
+ "improved": 4,
357
+ "none": 5,
358
+ "insufficient": 6,
359
+ }
360
+
361
+
362
+ def _severity_sort_key(c: ComparisonResult) -> tuple[int, float, str, str, str]:
363
+ return (
364
+ _SEVERITY_RANK[c.severity],
365
+ -abs(c.effect_size),
366
+ c.prompt_id,
367
+ c.evaluator_name,
368
+ c.slice_name,
369
+ )
370
+
371
+
372
+ __all__ = [
373
+ "BOOTSTRAP_RESAMPLES",
374
+ "FDR_ALPHA",
375
+ "MIN_N_FOR_TEST",
376
+ "MIN_N_RELIABLE",
377
+ "NORMALITY_ALPHA",
378
+ "ComparisonResult",
379
+ "Severity",
380
+ "TestKind",
381
+ "analyze",
382
+ ]
@@ -0,0 +1 @@
1
+ """SQLite cache for LLM responses and embeddings."""
@@ -0,0 +1,102 @@
1
+ """SQLAlchemy schema for the local LLM-response cache.
2
+
3
+ We use a single table — :class:`CachedCall` — keyed on the SHA-256 of
4
+ (model + canonicalised prompt + canonicalised inputs + temperature +
5
+ max_tokens). This keeps caching decisions transparent: identical inputs
6
+ hash to the same key, period. No fuzzy matching, no embeddings, no
7
+ similarity searches; the simplest thing that could possibly work, per
8
+ the PDF's guidance for the MVP.
9
+
10
+ The cache lives at ``~/.evalshift/cache.db`` by default. Tests pass an
11
+ in-memory SQLite URL via the ``database_url`` parameter on the engine
12
+ factory; never reach for the real DB in unit tests.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from datetime import UTC, datetime
18
+ from pathlib import Path
19
+
20
+ from sqlalchemy import DateTime, Float, Integer, String, Text
21
+ from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
22
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
23
+
24
+ DEFAULT_CACHE_PATH: Path = Path.home() / ".evalshift" / "cache.db"
25
+
26
+
27
+ def _utcnow() -> datetime:
28
+ """UTC ``now()`` (named for monkeypatching in TTL tests)."""
29
+ return datetime.now(UTC)
30
+
31
+
32
+ class Base(DeclarativeBase):
33
+ """Declarative base for the cache schema."""
34
+
35
+
36
+ class CachedCall(Base):
37
+ """One row per cached LLM response.
38
+
39
+ Attributes:
40
+ cache_key: SHA-256 hex digest of the canonicalised request. The
41
+ primary key. See :func:`evalshift.cache.store.cache_key`.
42
+ model_id: The canonical model id used for the call (matches
43
+ :attr:`evalshift.models.registry.ModelMetadata.id`).
44
+ prompt_text: The fully-rendered prompt sent to the model. Stored
45
+ verbatim so we can audit cache hits.
46
+ inputs_json: Canonical-JSON-encoded ``inputs`` mapping.
47
+ response_text: The model's response text.
48
+ input_tokens / output_tokens: From the provider response.
49
+ cost_usd: Computed via ``litellm.completion_cost`` at write time.
50
+ latency_ms: Wall time of the original live call. Useful for
51
+ cost/perf reporting on cached results.
52
+ created_at: When the cache row was written. Driver of TTL.
53
+ """
54
+
55
+ __tablename__ = "cached_calls"
56
+
57
+ cache_key: Mapped[str] = mapped_column(String(64), primary_key=True)
58
+ model_id: Mapped[str] = mapped_column(String(128), nullable=False)
59
+ prompt_text: Mapped[str] = mapped_column(Text, nullable=False)
60
+ inputs_json: Mapped[str] = mapped_column(Text, nullable=False)
61
+ response_text: Mapped[str] = mapped_column(Text, nullable=False)
62
+ input_tokens: Mapped[int] = mapped_column(Integer, nullable=False)
63
+ output_tokens: Mapped[int] = mapped_column(Integer, nullable=False)
64
+ cost_usd: Mapped[float] = mapped_column(Float, nullable=False)
65
+ latency_ms: Mapped[int] = mapped_column(Integer, nullable=False)
66
+ created_at: Mapped[datetime] = mapped_column(
67
+ DateTime(timezone=True),
68
+ nullable=False,
69
+ default=_utcnow,
70
+ )
71
+
72
+
73
+ def default_database_url(path: Path | None = None) -> str:
74
+ """Return the SQLAlchemy URL for the on-disk cache DB.
75
+
76
+ Args:
77
+ path: Override the default location (mostly for tests). When
78
+ ``None``, uses :data:`DEFAULT_CACHE_PATH`.
79
+ """
80
+ target = path if path is not None else DEFAULT_CACHE_PATH
81
+ target.parent.mkdir(parents=True, exist_ok=True)
82
+ return f"sqlite+aiosqlite:///{target}"
83
+
84
+
85
+ def create_engine(database_url: str | None = None) -> AsyncEngine:
86
+ """Create an async SQLAlchemy engine for the cache DB.
87
+
88
+ Args:
89
+ database_url: SQLAlchemy URL. Pass ``"sqlite+aiosqlite:///:memory:"``
90
+ for tests. When ``None``, defaults to the on-disk cache.
91
+ """
92
+ url = database_url if database_url is not None else default_database_url()
93
+ return create_async_engine(url, future=True)
94
+
95
+
96
+ __all__ = [
97
+ "DEFAULT_CACHE_PATH",
98
+ "Base",
99
+ "CachedCall",
100
+ "create_engine",
101
+ "default_database_url",
102
+ ]