evalproof 0.2.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.
- evalproof/__init__.py +69 -0
- evalproof/_version.py +3 -0
- evalproof/cache.py +89 -0
- evalproof/calibration.py +266 -0
- evalproof/cli.py +265 -0
- evalproof/compare.py +169 -0
- evalproof/config.py +158 -0
- evalproof/dataset.py +66 -0
- evalproof/gate.py +107 -0
- evalproof/models.py +115 -0
- evalproof/providers/__init__.py +54 -0
- evalproof/providers/anthropic.py +84 -0
- evalproof/providers/base.py +75 -0
- evalproof/providers/ollama.py +70 -0
- evalproof/providers/openai.py +76 -0
- evalproof/py.typed +0 -0
- evalproof/report.py +149 -0
- evalproof/runner.py +135 -0
- evalproof/scorers/__init__.py +80 -0
- evalproof/scorers/deterministic.py +227 -0
- evalproof/scorers/judge.py +187 -0
- evalproof/scorers/retrieval.py +64 -0
- evalproof/stats.py +145 -0
- evalproof/systems.py +154 -0
- evalproof-0.2.0.dist-info/METADATA +279 -0
- evalproof-0.2.0.dist-info/RECORD +30 -0
- evalproof-0.2.0.dist-info/WHEEL +4 -0
- evalproof-0.2.0.dist-info/entry_points.txt +2 -0
- evalproof-0.2.0.dist-info/licenses/LICENSE +202 -0
- evalproof-0.2.0.dist-info/licenses/NOTICE +4 -0
evalproof/compare.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Paired comparison of two runs on the cases they share."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from evalproof.models import Run
|
|
8
|
+
from evalproof.stats import mcnemar_exact, paired_bootstrap, percentile, wilson
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class MetricComparison:
|
|
13
|
+
name: str
|
|
14
|
+
kind: str # "binary" | "continuous"
|
|
15
|
+
n_paired: int
|
|
16
|
+
baseline: float
|
|
17
|
+
candidate: float
|
|
18
|
+
delta: float
|
|
19
|
+
ci_low: float
|
|
20
|
+
ci_high: float
|
|
21
|
+
p_value: float
|
|
22
|
+
test: str
|
|
23
|
+
regressed: list[str] = field(default_factory=list) # passed in baseline, failed in candidate
|
|
24
|
+
improved: list[str] = field(default_factory=list)
|
|
25
|
+
undecided: int = 0
|
|
26
|
+
baseline_ci: tuple[float, float] | None = None
|
|
27
|
+
candidate_ci: tuple[float, float] | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class RunStats:
|
|
32
|
+
cases: int
|
|
33
|
+
errors: int
|
|
34
|
+
p50_latency_ms: float
|
|
35
|
+
p95_latency_ms: float
|
|
36
|
+
cost_usd: float
|
|
37
|
+
cached: int
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Comparison:
|
|
42
|
+
paired_ids: list[str]
|
|
43
|
+
only_baseline: list[str]
|
|
44
|
+
only_candidate: list[str]
|
|
45
|
+
same_dataset: bool
|
|
46
|
+
metrics: dict[str, MetricComparison]
|
|
47
|
+
baseline: RunStats
|
|
48
|
+
candidate: RunStats
|
|
49
|
+
baseline_label: str = "baseline"
|
|
50
|
+
candidate_label: str = "candidate"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def run_stats(run: Run) -> RunStats:
|
|
54
|
+
lat = [r.output.latency_ms for r in run.results if not r.output.error]
|
|
55
|
+
cost = sum(r.output.cost_usd for r in run.results) + sum(
|
|
56
|
+
s.judge_cost_usd for r in run.results for s in r.scores.values()
|
|
57
|
+
)
|
|
58
|
+
return RunStats(
|
|
59
|
+
cases=len(run.results),
|
|
60
|
+
errors=sum(1 for r in run.results if r.output.error),
|
|
61
|
+
p50_latency_ms=percentile(lat, 50),
|
|
62
|
+
p95_latency_ms=percentile(lat, 95),
|
|
63
|
+
cost_usd=cost,
|
|
64
|
+
cached=sum(1 for r in run.results if r.output.cached),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def compare_metric(
|
|
69
|
+
name: str,
|
|
70
|
+
baseline: Run,
|
|
71
|
+
candidate: Run,
|
|
72
|
+
ids: list[str],
|
|
73
|
+
*,
|
|
74
|
+
alpha: float = 0.05,
|
|
75
|
+
n_boot: int = 5000,
|
|
76
|
+
seed: int = 0,
|
|
77
|
+
) -> MetricComparison | None:
|
|
78
|
+
b_by, c_by = baseline.by_id(), candidate.by_id()
|
|
79
|
+
pairs = []
|
|
80
|
+
undecided = 0
|
|
81
|
+
for cid in ids:
|
|
82
|
+
bs, cs = b_by[cid].scores.get(name), c_by[cid].scores.get(name)
|
|
83
|
+
if bs is None or cs is None:
|
|
84
|
+
continue
|
|
85
|
+
if bs.value is None or cs.value is None:
|
|
86
|
+
undecided += 1
|
|
87
|
+
continue
|
|
88
|
+
pairs.append((cid, bs, cs))
|
|
89
|
+
if not pairs:
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
binary = all(bs.passed is not None and cs.passed is not None for _, bs, cs in pairs)
|
|
93
|
+
if binary:
|
|
94
|
+
a = [1.0 if bs.passed else 0.0 for _, bs, _ in pairs]
|
|
95
|
+
c = [1.0 if cs.passed else 0.0 for _, _, cs in pairs]
|
|
96
|
+
regressed = [cid for cid, bs, cs in pairs if bs.passed and not cs.passed]
|
|
97
|
+
improved = [cid for cid, bs, cs in pairs if not bs.passed and cs.passed]
|
|
98
|
+
boot = paired_bootstrap(a, c, n_boot=n_boot, alpha=alpha, seed=seed)
|
|
99
|
+
n = len(pairs)
|
|
100
|
+
return MetricComparison(
|
|
101
|
+
name=name,
|
|
102
|
+
kind="binary",
|
|
103
|
+
n_paired=n,
|
|
104
|
+
baseline=sum(a) / n,
|
|
105
|
+
candidate=sum(c) / n,
|
|
106
|
+
delta=boot.delta,
|
|
107
|
+
ci_low=boot.ci_low,
|
|
108
|
+
ci_high=boot.ci_high,
|
|
109
|
+
p_value=mcnemar_exact(len(regressed), len(improved)),
|
|
110
|
+
test="exact McNemar",
|
|
111
|
+
regressed=regressed,
|
|
112
|
+
improved=improved,
|
|
113
|
+
undecided=undecided,
|
|
114
|
+
baseline_ci=wilson(int(sum(a)), n, alpha),
|
|
115
|
+
candidate_ci=wilson(int(sum(c)), n, alpha),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
a = [float(bs.value) for _, bs, _ in pairs if bs.value is not None]
|
|
119
|
+
c = [float(cs.value) for _, _, cs in pairs if cs.value is not None]
|
|
120
|
+
boot = paired_bootstrap(a, c, n_boot=n_boot, alpha=alpha, seed=seed)
|
|
121
|
+
regressed = [cid for cid, bs, cs in pairs if (cs.value or 0) < (bs.value or 0)]
|
|
122
|
+
improved = [cid for cid, bs, cs in pairs if (cs.value or 0) > (bs.value or 0)]
|
|
123
|
+
return MetricComparison(
|
|
124
|
+
name=name,
|
|
125
|
+
kind="continuous",
|
|
126
|
+
n_paired=len(pairs),
|
|
127
|
+
baseline=sum(a) / len(a),
|
|
128
|
+
candidate=sum(c) / len(c),
|
|
129
|
+
delta=boot.delta,
|
|
130
|
+
ci_low=boot.ci_low,
|
|
131
|
+
ci_high=boot.ci_high,
|
|
132
|
+
p_value=boot.p_value,
|
|
133
|
+
test="paired bootstrap",
|
|
134
|
+
regressed=regressed,
|
|
135
|
+
improved=improved,
|
|
136
|
+
undecided=undecided,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def compare_runs(
|
|
141
|
+
baseline: Run,
|
|
142
|
+
candidate: Run,
|
|
143
|
+
*,
|
|
144
|
+
alpha: float = 0.05,
|
|
145
|
+
n_boot: int = 5000,
|
|
146
|
+
seed: int = 0,
|
|
147
|
+
metrics: list[str] | None = None,
|
|
148
|
+
) -> Comparison:
|
|
149
|
+
b_ids, c_ids = set(baseline.by_id()), set(candidate.by_id())
|
|
150
|
+
paired = sorted(b_ids & c_ids)
|
|
151
|
+
names = metrics or [n for n in baseline.scorer_names() if n in candidate.scorer_names()]
|
|
152
|
+
out: dict[str, MetricComparison] = {}
|
|
153
|
+
for name in names:
|
|
154
|
+
mc = compare_metric(
|
|
155
|
+
name, baseline, candidate, paired, alpha=alpha, n_boot=n_boot, seed=seed
|
|
156
|
+
)
|
|
157
|
+
if mc is not None:
|
|
158
|
+
out[name] = mc
|
|
159
|
+
return Comparison(
|
|
160
|
+
paired_ids=paired,
|
|
161
|
+
only_baseline=sorted(b_ids - c_ids),
|
|
162
|
+
only_candidate=sorted(c_ids - b_ids),
|
|
163
|
+
same_dataset=baseline.info.dataset_hash == candidate.info.dataset_hash,
|
|
164
|
+
metrics=out,
|
|
165
|
+
baseline=run_stats(baseline),
|
|
166
|
+
candidate=run_stats(candidate),
|
|
167
|
+
baseline_label=baseline.info.system,
|
|
168
|
+
candidate_label=candidate.info.system,
|
|
169
|
+
)
|
evalproof/config.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Suite configuration: one YAML file describes the data, the system, the scorers and the gate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
from pydantic import BaseModel, Field, ValidationError, model_validator
|
|
10
|
+
|
|
11
|
+
ScorerType = Literal[
|
|
12
|
+
"exact_match",
|
|
13
|
+
"contains",
|
|
14
|
+
"regex",
|
|
15
|
+
"json_schema",
|
|
16
|
+
"numeric_tolerance",
|
|
17
|
+
"recall_at_k",
|
|
18
|
+
"mrr",
|
|
19
|
+
"ndcg",
|
|
20
|
+
"llm_judge",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ConfigError(ValueError):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Pricing(BaseModel):
|
|
29
|
+
"""USD per million tokens. Local models default to zero."""
|
|
30
|
+
|
|
31
|
+
input_per_mtok: float = 0.0
|
|
32
|
+
output_per_mtok: float = 0.0
|
|
33
|
+
|
|
34
|
+
def cost(self, tokens_in: int, tokens_out: int) -> float:
|
|
35
|
+
return (tokens_in * self.input_per_mtok + tokens_out * self.output_per_mtok) / 1_000_000
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class GenerationParams(BaseModel):
|
|
39
|
+
temperature: float = 0.0
|
|
40
|
+
seed: int | None = 0
|
|
41
|
+
max_tokens: int = 512
|
|
42
|
+
think: bool | None = False # reasoning models: off by default for speed and determinism
|
|
43
|
+
extra: dict[str, Any] = Field(default_factory=dict)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TargetConfig(BaseModel):
|
|
47
|
+
"""A model or system to call: ``ollama:<model>``, ``anthropic:<model>``, ``openai:<model>``,
|
|
48
|
+
``python:<module>:<function>`` or an ``http(s)://`` endpoint."""
|
|
49
|
+
|
|
50
|
+
target: str
|
|
51
|
+
label: str | None = None # how reports name this system, e.g. "qwen3:1.7b + grounded prompt"
|
|
52
|
+
system_prompt: str | None = None
|
|
53
|
+
prompt_template: str = "{input}"
|
|
54
|
+
params: GenerationParams = Field(default_factory=GenerationParams)
|
|
55
|
+
timeout_s: float = 120.0
|
|
56
|
+
base_url: str | None = None
|
|
57
|
+
pricing: Pricing = Field(default_factory=Pricing)
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def kind(self) -> str:
|
|
61
|
+
if self.target.startswith(("http://", "https://")):
|
|
62
|
+
return "http"
|
|
63
|
+
return self.target.split(":", 1)[0]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ScorerConfig(BaseModel):
|
|
67
|
+
name: str
|
|
68
|
+
type: ScorerType
|
|
69
|
+
params: dict[str, Any] = Field(default_factory=dict)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class CalibrationConfig(BaseModel):
|
|
73
|
+
"""How to calibrate a judge: against a labels file, or against another (trusted) scorer
|
|
74
|
+
sampled down to ``gold_sample`` cases to mimic a small human-reviewed set."""
|
|
75
|
+
|
|
76
|
+
judge_scorer: str
|
|
77
|
+
labels: str | None = None
|
|
78
|
+
gold_scorer: str | None = None
|
|
79
|
+
gold_sample: int | None = None
|
|
80
|
+
|
|
81
|
+
@model_validator(mode="after")
|
|
82
|
+
def _one_source(self) -> CalibrationConfig:
|
|
83
|
+
if (self.labels is None) == (self.gold_scorer is None):
|
|
84
|
+
raise ValueError("calibration needs exactly one of 'labels' or 'gold_scorer'")
|
|
85
|
+
return self
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class GateConfig(BaseModel):
|
|
89
|
+
"""Fail only when a drop is both larger than ``max_drop`` and statistically significant."""
|
|
90
|
+
|
|
91
|
+
metric: str
|
|
92
|
+
max_drop: float = 0.02
|
|
93
|
+
alpha: float = 0.05
|
|
94
|
+
min_cases: int = 20
|
|
95
|
+
max_p95_latency_ms: float | None = None
|
|
96
|
+
max_cost_usd: float | None = None
|
|
97
|
+
n_boot: int = 5000
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class SuiteConfig(BaseModel):
|
|
101
|
+
name: str
|
|
102
|
+
dataset: str
|
|
103
|
+
system: TargetConfig
|
|
104
|
+
scorers: list[ScorerConfig]
|
|
105
|
+
judge: TargetConfig | None = None
|
|
106
|
+
calibration: CalibrationConfig | None = None
|
|
107
|
+
gate: GateConfig | None = None
|
|
108
|
+
seed: int = 0
|
|
109
|
+
cache_dir: str = ".evalproof/cache"
|
|
110
|
+
concurrency: int = 1
|
|
111
|
+
base_dir: str = "."
|
|
112
|
+
|
|
113
|
+
@model_validator(mode="after")
|
|
114
|
+
def _check(self) -> SuiteConfig:
|
|
115
|
+
names = [s.name for s in self.scorers]
|
|
116
|
+
if len(set(names)) != len(names):
|
|
117
|
+
raise ValueError(f"scorer names must be unique, got {names}")
|
|
118
|
+
if any(s.type == "llm_judge" for s in self.scorers) and self.judge is None:
|
|
119
|
+
raise ValueError("an llm_judge scorer needs a 'judge' target")
|
|
120
|
+
if self.gate and self.gate.metric not in names:
|
|
121
|
+
raise ValueError(f"gate.metric {self.gate.metric!r} is not a scorer name")
|
|
122
|
+
if self.calibration:
|
|
123
|
+
for field in ("judge_scorer", "gold_scorer"):
|
|
124
|
+
ref = getattr(self.calibration, field)
|
|
125
|
+
if ref is not None and ref not in names:
|
|
126
|
+
raise ValueError(f"calibration.{field} {ref!r} is not a scorer name")
|
|
127
|
+
if self.concurrency < 1:
|
|
128
|
+
raise ValueError("concurrency must be >= 1")
|
|
129
|
+
return self
|
|
130
|
+
|
|
131
|
+
def resolve(self, path: str) -> Path:
|
|
132
|
+
p = Path(path)
|
|
133
|
+
return p if p.is_absolute() else Path(self.base_dir) / p
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def load_config(path: str | Path) -> SuiteConfig:
|
|
137
|
+
path = Path(path)
|
|
138
|
+
if not path.exists():
|
|
139
|
+
raise ConfigError(f"suite file not found: {path}")
|
|
140
|
+
try:
|
|
141
|
+
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
142
|
+
except yaml.YAMLError as exc:
|
|
143
|
+
raise ConfigError(f"{path}: invalid YAML: {exc}") from exc
|
|
144
|
+
if not isinstance(raw, dict):
|
|
145
|
+
raise ConfigError(f"{path}: top level must be a mapping")
|
|
146
|
+
raw.setdefault("base_dir", str(path.parent))
|
|
147
|
+
# Prompt and rubric text may live in files next to the suite.
|
|
148
|
+
for section in ("system", "judge"):
|
|
149
|
+
block = raw.get(section)
|
|
150
|
+
if isinstance(block, dict):
|
|
151
|
+
for key in ("system_prompt", "prompt_template"):
|
|
152
|
+
file_key = f"{key}_file"
|
|
153
|
+
if file_key in block:
|
|
154
|
+
block[key] = (path.parent / block.pop(file_key)).read_text(encoding="utf-8")
|
|
155
|
+
try:
|
|
156
|
+
return SuiteConfig.model_validate(raw)
|
|
157
|
+
except ValidationError as exc:
|
|
158
|
+
raise ConfigError(f"{path}: {exc}") from exc
|
evalproof/dataset.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Golden sets as JSONL: one case per line, stable ids, and a content hash for provenance."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pydantic import ValidationError
|
|
11
|
+
|
|
12
|
+
from evalproof.models import Case
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DatasetError(ValueError):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def canonical_json(obj: Any) -> str:
|
|
20
|
+
return json.dumps(obj, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def stable_id(record: dict[str, Any]) -> str:
|
|
24
|
+
"""An id derived from the input, so reordering the file never re-keys a case."""
|
|
25
|
+
digest = hashlib.sha256(canonical_json(record.get("input")).encode()).hexdigest()
|
|
26
|
+
return f"case-{digest[:12]}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_dataset(path: str | Path) -> list[Case]:
|
|
30
|
+
path = Path(path)
|
|
31
|
+
if not path.exists():
|
|
32
|
+
raise DatasetError(f"dataset not found: {path}")
|
|
33
|
+
cases: list[Case] = []
|
|
34
|
+
seen: set[str] = set()
|
|
35
|
+
for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
|
|
36
|
+
if not line.strip() or line.lstrip().startswith("#"):
|
|
37
|
+
continue
|
|
38
|
+
try:
|
|
39
|
+
record = json.loads(line)
|
|
40
|
+
except json.JSONDecodeError as exc:
|
|
41
|
+
raise DatasetError(f"{path}:{lineno}: invalid JSON: {exc.msg}") from exc
|
|
42
|
+
if not isinstance(record, dict) or "input" not in record:
|
|
43
|
+
raise DatasetError(f"{path}:{lineno}: each line needs an object with an 'input'")
|
|
44
|
+
record.setdefault("id", stable_id(record))
|
|
45
|
+
try:
|
|
46
|
+
case = Case.model_validate(record)
|
|
47
|
+
except ValidationError as exc:
|
|
48
|
+
first = exc.errors()[0]
|
|
49
|
+
where = ".".join(str(p) for p in first["loc"])
|
|
50
|
+
raise DatasetError(f"{path}:{lineno}: {where}: {first['msg']}") from exc
|
|
51
|
+
if case.id in seen:
|
|
52
|
+
raise DatasetError(f"{path}:{lineno}: duplicate case id {case.id!r}")
|
|
53
|
+
seen.add(case.id)
|
|
54
|
+
cases.append(case)
|
|
55
|
+
if not cases:
|
|
56
|
+
raise DatasetError(f"dataset is empty: {path}")
|
|
57
|
+
return cases
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def dataset_hash(cases: list[Case]) -> str:
|
|
61
|
+
"""Hash of the cases as loaded; two runs on different data never compare silently."""
|
|
62
|
+
h = hashlib.sha256()
|
|
63
|
+
for case in sorted(cases, key=lambda c: c.id):
|
|
64
|
+
h.update(canonical_json(case.model_dump(mode="json")).encode())
|
|
65
|
+
h.update(b"\n")
|
|
66
|
+
return h.hexdigest()[:16]
|
evalproof/gate.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""The CI gate: fail on a regression that is both large enough to matter and unlikely to be noise.
|
|
2
|
+
|
|
3
|
+
Exit codes are distinct so a pipeline can tell a quality regression from a budget breach
|
|
4
|
+
from a setup problem:
|
|
5
|
+
|
|
6
|
+
* ``0`` pass (possibly with warnings)
|
|
7
|
+
* ``1`` quality regression: drop >= ``max_drop`` and p < ``alpha``
|
|
8
|
+
* ``3`` budget breach: candidate p95 latency or total cost over the limit
|
|
9
|
+
* ``4`` cannot gate: too few paired cases, metric missing, or the runs used different data
|
|
10
|
+
* ``2`` usage or configuration error (raised by the CLI, not here)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
|
|
17
|
+
from evalproof.compare import Comparison
|
|
18
|
+
from evalproof.config import GateConfig
|
|
19
|
+
|
|
20
|
+
EXIT_PASS = 0
|
|
21
|
+
EXIT_REGRESSION = 1
|
|
22
|
+
EXIT_USAGE = 2
|
|
23
|
+
EXIT_BUDGET = 3
|
|
24
|
+
EXIT_CANNOT_GATE = 4
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class GateDecision:
|
|
29
|
+
status: str # "pass" | "warn" | "fail"
|
|
30
|
+
exit_code: int
|
|
31
|
+
reasons: list[str] = field(default_factory=list)
|
|
32
|
+
warnings: list[str] = field(default_factory=list)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def decide(cmp: Comparison, gate: GateConfig) -> GateDecision:
|
|
36
|
+
reasons: list[str] = []
|
|
37
|
+
warnings: list[str] = []
|
|
38
|
+
|
|
39
|
+
if not cmp.same_dataset:
|
|
40
|
+
return GateDecision(
|
|
41
|
+
"fail",
|
|
42
|
+
EXIT_CANNOT_GATE,
|
|
43
|
+
["baseline and candidate were run on different datasets (dataset hash differs)"],
|
|
44
|
+
)
|
|
45
|
+
mc = cmp.metrics.get(gate.metric)
|
|
46
|
+
if mc is None:
|
|
47
|
+
return GateDecision(
|
|
48
|
+
"fail", EXIT_CANNOT_GATE, [f"metric {gate.metric!r} has no paired scores"]
|
|
49
|
+
)
|
|
50
|
+
if mc.n_paired < gate.min_cases:
|
|
51
|
+
return GateDecision(
|
|
52
|
+
"fail",
|
|
53
|
+
EXIT_CANNOT_GATE,
|
|
54
|
+
[
|
|
55
|
+
f"only {mc.n_paired} paired cases for {gate.metric!r}; "
|
|
56
|
+
f"the gate needs {gate.min_cases}"
|
|
57
|
+
],
|
|
58
|
+
)
|
|
59
|
+
if mc.undecided:
|
|
60
|
+
warnings.append(f"{mc.undecided} paired case(s) had an undecided {gate.metric!r} verdict")
|
|
61
|
+
|
|
62
|
+
exit_code = EXIT_PASS
|
|
63
|
+
drop_big = mc.delta <= -gate.max_drop
|
|
64
|
+
significant = mc.p_value < gate.alpha
|
|
65
|
+
if drop_big and significant:
|
|
66
|
+
reasons.append(
|
|
67
|
+
f"{gate.metric}: {mc.baseline:.3f} → {mc.candidate:.3f} (Δ {mc.delta:+.3f}, "
|
|
68
|
+
f"95% CI [{mc.ci_low:+.3f}, {mc.ci_high:+.3f}], {mc.test} p={mc.p_value:.4f}) — "
|
|
69
|
+
f"a drop beyond {gate.max_drop:.3f} at α={gate.alpha}"
|
|
70
|
+
)
|
|
71
|
+
exit_code = EXIT_REGRESSION
|
|
72
|
+
elif drop_big:
|
|
73
|
+
warnings.append(
|
|
74
|
+
f"{gate.metric} dropped {mc.delta:+.3f}, beyond the {gate.max_drop:.3f} threshold, but "
|
|
75
|
+
f"p={mc.p_value:.3f} ≥ α={gate.alpha}: not distinguishable from noise "
|
|
76
|
+
f"at n={mc.n_paired}. "
|
|
77
|
+
"Add cases before trusting either direction."
|
|
78
|
+
)
|
|
79
|
+
elif mc.delta < 0 and significant:
|
|
80
|
+
warnings.append(
|
|
81
|
+
f"{gate.metric} dropped {mc.delta:+.3f} (significant, p={mc.p_value:.4f}) but within "
|
|
82
|
+
f"the {gate.max_drop:.3f} tolerance"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
if (
|
|
86
|
+
gate.max_p95_latency_ms is not None
|
|
87
|
+
and cmp.candidate.p95_latency_ms > gate.max_p95_latency_ms
|
|
88
|
+
):
|
|
89
|
+
reasons.append(
|
|
90
|
+
f"p95 latency {cmp.candidate.p95_latency_ms:.0f} ms exceeds "
|
|
91
|
+
f"{gate.max_p95_latency_ms:.0f} ms"
|
|
92
|
+
)
|
|
93
|
+
exit_code = exit_code or EXIT_BUDGET
|
|
94
|
+
if gate.max_cost_usd is not None and cmp.candidate.cost_usd > gate.max_cost_usd:
|
|
95
|
+
reasons.append(f"cost ${cmp.candidate.cost_usd:.4f} exceeds ${gate.max_cost_usd:.4f}")
|
|
96
|
+
exit_code = exit_code or EXIT_BUDGET
|
|
97
|
+
|
|
98
|
+
if cmp.candidate.errors > cmp.baseline.errors:
|
|
99
|
+
warnings.append(f"system errors rose from {cmp.baseline.errors} to {cmp.candidate.errors}")
|
|
100
|
+
if cmp.only_baseline or cmp.only_candidate:
|
|
101
|
+
warnings.append(
|
|
102
|
+
f"{len(cmp.only_baseline)} case(s) only in baseline, "
|
|
103
|
+
f"{len(cmp.only_candidate)} only in candidate — excluded from the comparison"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
status = "fail" if exit_code else ("warn" if warnings else "pass")
|
|
107
|
+
return GateDecision(status, exit_code, reasons, warnings)
|
evalproof/models.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Data shared by every stage: a case, what the system said, how it scored, the run."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field, field_validator
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Case(BaseModel):
|
|
12
|
+
"""One golden-set item. Extra fields (answer keys, relevant ids) stay on the case."""
|
|
13
|
+
|
|
14
|
+
model_config = {"extra": "allow"}
|
|
15
|
+
|
|
16
|
+
id: str
|
|
17
|
+
input: str | dict[str, Any]
|
|
18
|
+
reference: str | None = None
|
|
19
|
+
tags: list[str] = Field(default_factory=list)
|
|
20
|
+
|
|
21
|
+
def field(self, name: str) -> Any:
|
|
22
|
+
"""Read a named field, including extras such as ``accept`` or ``relevant``."""
|
|
23
|
+
if name in type(self).model_fields:
|
|
24
|
+
return getattr(self, name)
|
|
25
|
+
extra = self.model_extra or {}
|
|
26
|
+
return extra.get(name)
|
|
27
|
+
|
|
28
|
+
def render_vars(self) -> dict[str, Any]:
|
|
29
|
+
"""Variables available to prompt templates."""
|
|
30
|
+
out: dict[str, Any] = {"id": self.id, "reference": self.reference or ""}
|
|
31
|
+
if isinstance(self.input, dict):
|
|
32
|
+
out.update(self.input)
|
|
33
|
+
out["input"] = self.input
|
|
34
|
+
else:
|
|
35
|
+
out["input"] = self.input
|
|
36
|
+
out.update(self.model_extra or {})
|
|
37
|
+
return out
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SystemOutput(BaseModel):
|
|
41
|
+
"""What the system under test returned for one case."""
|
|
42
|
+
|
|
43
|
+
output: str
|
|
44
|
+
retrieved: list[str] = Field(default_factory=list)
|
|
45
|
+
latency_ms: float = 0.0
|
|
46
|
+
tokens_in: int = 0
|
|
47
|
+
tokens_out: int = 0
|
|
48
|
+
cost_usd: float = 0.0
|
|
49
|
+
cached: bool = False
|
|
50
|
+
error: str | None = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Score(BaseModel):
|
|
54
|
+
"""One scorer's verdict on one case.
|
|
55
|
+
|
|
56
|
+
``passed`` is the binary outcome used by pass rates and McNemar;
|
|
57
|
+
``value`` is the continuous score used by bootstrap comparisons.
|
|
58
|
+
Both are None when the scorer could not decide (e.g. an unparseable judge reply);
|
|
59
|
+
undecided cases are counted and reported, never silently scored as passes.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
value: float | None
|
|
63
|
+
passed: bool | None = None
|
|
64
|
+
reason: str | None = None
|
|
65
|
+
judge_tokens: int = 0
|
|
66
|
+
judge_cost_usd: float = 0.0
|
|
67
|
+
|
|
68
|
+
@field_validator("value")
|
|
69
|
+
@classmethod
|
|
70
|
+
def _finite(cls, v: float | None) -> float | None:
|
|
71
|
+
if v is not None and (math.isinf(v) or math.isnan(v)):
|
|
72
|
+
raise ValueError("score value must be finite (use None for undecided)")
|
|
73
|
+
return v
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def decided(self) -> bool:
|
|
77
|
+
return self.value is not None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class CaseResult(BaseModel):
|
|
81
|
+
case_id: str
|
|
82
|
+
tags: list[str] = Field(default_factory=list)
|
|
83
|
+
output: SystemOutput
|
|
84
|
+
scores: dict[str, Score] = Field(default_factory=dict)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class RunInfo(BaseModel):
|
|
88
|
+
suite: str
|
|
89
|
+
system: str
|
|
90
|
+
dataset_path: str
|
|
91
|
+
dataset_hash: str
|
|
92
|
+
started_at: str
|
|
93
|
+
finished_at: str
|
|
94
|
+
wall_time_s: float
|
|
95
|
+
evalproof_version: str
|
|
96
|
+
seed: int
|
|
97
|
+
environment: dict[str, str] = Field(default_factory=dict)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class Run(BaseModel):
|
|
101
|
+
"""A complete, self-describing run artifact. Compare two of these to gate a change."""
|
|
102
|
+
|
|
103
|
+
info: RunInfo
|
|
104
|
+
results: list[CaseResult]
|
|
105
|
+
|
|
106
|
+
def by_id(self) -> dict[str, CaseResult]:
|
|
107
|
+
return {r.case_id: r for r in self.results}
|
|
108
|
+
|
|
109
|
+
def scorer_names(self) -> list[str]:
|
|
110
|
+
names: list[str] = []
|
|
111
|
+
for r in self.results:
|
|
112
|
+
for name in r.scores:
|
|
113
|
+
if name not in names:
|
|
114
|
+
names.append(name)
|
|
115
|
+
return names
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Provider registry. A target string ``<provider>:<model>`` picks the implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from evalproof.config import TargetConfig
|
|
8
|
+
from evalproof.providers.anthropic import AnthropicProvider
|
|
9
|
+
from evalproof.providers.base import (
|
|
10
|
+
ChatResponse,
|
|
11
|
+
Message,
|
|
12
|
+
Provider,
|
|
13
|
+
ProviderError,
|
|
14
|
+
with_retries,
|
|
15
|
+
)
|
|
16
|
+
from evalproof.providers.ollama import OllamaProvider
|
|
17
|
+
from evalproof.providers.openai import OpenAIProvider
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"AnthropicProvider",
|
|
21
|
+
"ChatResponse",
|
|
22
|
+
"Message",
|
|
23
|
+
"OllamaProvider",
|
|
24
|
+
"OpenAIProvider",
|
|
25
|
+
"Provider",
|
|
26
|
+
"ProviderError",
|
|
27
|
+
"make_provider",
|
|
28
|
+
"parse_target",
|
|
29
|
+
"with_retries",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
LLM_PROVIDERS = {"ollama", "anthropic", "openai"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_target(target: str) -> tuple[str, str]:
|
|
36
|
+
"""``"ollama:qwen3:8b"`` → ``("ollama", "qwen3:8b")``. Model names may contain colons."""
|
|
37
|
+
if ":" not in target:
|
|
38
|
+
raise ProviderError(f"target {target!r} must look like '<provider>:<model>'")
|
|
39
|
+
provider, model = target.split(":", 1)
|
|
40
|
+
if not model:
|
|
41
|
+
raise ProviderError(f"target {target!r} has no model")
|
|
42
|
+
return provider, model
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def make_provider(cfg: TargetConfig, client: httpx.Client | None = None) -> Provider:
|
|
46
|
+
provider, model = parse_target(cfg.target)
|
|
47
|
+
kwargs = {"base_url": cfg.base_url, "timeout_s": cfg.timeout_s, "client": client}
|
|
48
|
+
if provider == "ollama":
|
|
49
|
+
return OllamaProvider(model, **kwargs) # type: ignore[arg-type]
|
|
50
|
+
if provider == "anthropic":
|
|
51
|
+
return AnthropicProvider(model, **kwargs) # type: ignore[arg-type]
|
|
52
|
+
if provider == "openai":
|
|
53
|
+
return OpenAIProvider(model, **kwargs) # type: ignore[arg-type]
|
|
54
|
+
raise ProviderError(f"unknown provider {provider!r}; expected one of {sorted(LLM_PROVIDERS)}")
|