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.
- evalshift/__init__.py +12 -0
- evalshift/__main__.py +17 -0
- evalshift/analysis/__init__.py +1 -0
- evalshift/analysis/slicing.py +143 -0
- evalshift/analysis/statistics.py +382 -0
- evalshift/cache/__init__.py +1 -0
- evalshift/cache/schema.py +102 -0
- evalshift/cache/store.py +214 -0
- evalshift/cli/__init__.py +1 -0
- evalshift/cli/commands/__init__.py +1 -0
- evalshift/cli/commands/all.py +591 -0
- evalshift/cli/commands/analyze.py +334 -0
- evalshift/cli/commands/cache.py +42 -0
- evalshift/cli/commands/doctor.py +232 -0
- evalshift/cli/commands/evaluate.py +434 -0
- evalshift/cli/commands/init.py +415 -0
- evalshift/cli/commands/report.py +137 -0
- evalshift/cli/commands/run.py +266 -0
- evalshift/cli/commands/test_call.py +254 -0
- evalshift/cli/commands/validate.py +128 -0
- evalshift/cli/main.py +72 -0
- evalshift/config/__init__.py +1 -0
- evalshift/config/loader.py +217 -0
- evalshift/config/models.py +338 -0
- evalshift/evaluators/__init__.py +1 -0
- evalshift/evaluators/base.py +93 -0
- evalshift/evaluators/llm_judge.py +154 -0
- evalshift/evaluators/semantic.py +93 -0
- evalshift/evaluators/structural.py +131 -0
- evalshift/evaluators/tool_arguments.py +216 -0
- evalshift/evaluators/tool_loader.py +198 -0
- evalshift/evaluators/tool_models.py +194 -0
- evalshift/evaluators/tool_parser.py +328 -0
- evalshift/evaluators/tool_selection.py +294 -0
- evalshift/evaluators/tool_trace_structure.py +108 -0
- evalshift/models/__init__.py +1 -0
- evalshift/models/client.py +456 -0
- evalshift/models/registry.py +272 -0
- evalshift/models/replay_client.py +203 -0
- evalshift/parsers/__init__.py +1 -0
- evalshift/parsers/base.py +144 -0
- evalshift/parsers/manual.py +45 -0
- evalshift/parsers/python_string.py +204 -0
- evalshift/py.typed +0 -0
- evalshift/reports/__init__.py +1 -0
- evalshift/reports/html.py +69 -0
- evalshift/reports/json.py +523 -0
- evalshift/reports/templates/report.css +203 -0
- evalshift/reports/templates/report.html.j2 +268 -0
- evalshift/runner/__init__.py +1 -0
- evalshift/runner/checkpoint.py +280 -0
- evalshift/runner/models.py +152 -0
- evalshift/runner/orchestrator.py +703 -0
- evalshift/suite/__init__.py +1 -0
- evalshift/suite/loader.py +216 -0
- evalshift/suite/models.py +156 -0
- evalshift/utils/__init__.py +1 -0
- evalshift/utils/cost.py +195 -0
- evalshift/utils/templating.py +213 -0
- evalshift-0.3.0.dist-info/METADATA +185 -0
- evalshift-0.3.0.dist-info/RECORD +64 -0
- evalshift-0.3.0.dist-info/WHEEL +4 -0
- evalshift-0.3.0.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|