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.
@@ -0,0 +1,84 @@
1
+ """Anthropic Messages API over plain HTTP. Optional: needs ANTHROPIC_API_KEY."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import time
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+ from evalproof.config import GenerationParams
12
+ from evalproof.providers.base import (
13
+ ChatResponse,
14
+ Message,
15
+ ProviderError,
16
+ split_system,
17
+ with_retries,
18
+ )
19
+
20
+ DEFAULT_BASE_URL = "https://api.anthropic.com"
21
+ API_VERSION = "2023-06-01"
22
+
23
+
24
+ class AnthropicProvider:
25
+ name = "anthropic"
26
+
27
+ def __init__(
28
+ self,
29
+ model: str,
30
+ *,
31
+ base_url: str | None = None,
32
+ timeout_s: float = 120.0,
33
+ client: httpx.Client | None = None,
34
+ api_key: str | None = None,
35
+ ) -> None:
36
+ self.model = model
37
+ self.base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
38
+ key = api_key or os.environ.get("ANTHROPIC_API_KEY")
39
+ if not key:
40
+ raise ProviderError("anthropic: set ANTHROPIC_API_KEY to use this provider")
41
+ self._headers = {
42
+ "x-api-key": key,
43
+ "anthropic-version": API_VERSION,
44
+ "content-type": "application/json",
45
+ }
46
+ self._client = client or httpx.Client(timeout=timeout_s)
47
+
48
+ def payload(self, messages: list[Message], params: GenerationParams) -> dict[str, Any]:
49
+ system, rest = split_system(messages)
50
+ body: dict[str, Any] = {
51
+ "model": self.model,
52
+ "max_tokens": params.max_tokens,
53
+ "temperature": params.temperature,
54
+ "messages": [m.model_dump() for m in rest],
55
+ }
56
+ if system:
57
+ body["system"] = system
58
+ body.update(params.extra)
59
+ return body
60
+
61
+ def chat(self, messages: list[Message], params: GenerationParams) -> ChatResponse:
62
+ body = self.payload(messages, params)
63
+
64
+ def call() -> httpx.Response:
65
+ resp = self._client.post(
66
+ f"{self.base_url}/v1/messages", json=body, headers=self._headers
67
+ )
68
+ resp.raise_for_status()
69
+ return resp
70
+
71
+ start = time.perf_counter()
72
+ resp = with_retries(call)
73
+ latency_ms = (time.perf_counter() - start) * 1000
74
+ data = resp.json()
75
+ blocks = data.get("content") or []
76
+ text = "".join(b.get("text", "") for b in blocks if b.get("type") == "text")
77
+ usage = data.get("usage") or {}
78
+ return ChatResponse(
79
+ text=text,
80
+ model=data.get("model", self.model),
81
+ tokens_in=int(usage.get("input_tokens") or 0),
82
+ tokens_out=int(usage.get("output_tokens") or 0),
83
+ latency_ms=latency_ms,
84
+ )
@@ -0,0 +1,75 @@
1
+ """The one thing evalproof needs from a model: send messages, get text and token counts back."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ import time
7
+ from collections.abc import Callable
8
+ from typing import Protocol, TypeVar
9
+
10
+ import httpx
11
+ from pydantic import BaseModel
12
+
13
+ from evalproof.config import GenerationParams
14
+
15
+ T = TypeVar("T")
16
+
17
+ RETRY_STATUS = {408, 409, 425, 429, 500, 502, 503, 504, 529}
18
+
19
+
20
+ class ProviderError(RuntimeError):
21
+ pass
22
+
23
+
24
+ class Message(BaseModel):
25
+ role: str # "system" | "user" | "assistant"
26
+ content: str
27
+
28
+
29
+ class ChatResponse(BaseModel):
30
+ text: str
31
+ model: str
32
+ tokens_in: int = 0
33
+ tokens_out: int = 0
34
+ latency_ms: float = 0.0
35
+ cached: bool = False
36
+
37
+
38
+ class Provider(Protocol):
39
+ name: str
40
+ model: str
41
+
42
+ def chat(self, messages: list[Message], params: GenerationParams) -> ChatResponse: ...
43
+
44
+
45
+ def with_retries(
46
+ fn: Callable[[], T],
47
+ *,
48
+ attempts: int = 4,
49
+ base_delay: float = 0.5,
50
+ sleep: Callable[[float], None] = time.sleep,
51
+ rng: random.Random | None = None,
52
+ ) -> T:
53
+ """Retry transport errors and retryable HTTP statuses with jittered exponential backoff."""
54
+ rng = rng or random.Random(0)
55
+ last: Exception | None = None
56
+ for attempt in range(attempts):
57
+ try:
58
+ return fn()
59
+ except httpx.HTTPStatusError as exc:
60
+ if exc.response.status_code not in RETRY_STATUS:
61
+ raise ProviderError(
62
+ f"HTTP {exc.response.status_code}: {exc.response.text[:300]}"
63
+ ) from exc
64
+ last = exc
65
+ except httpx.TransportError as exc:
66
+ last = exc
67
+ if attempt < attempts - 1:
68
+ sleep(base_delay * (2**attempt) * (0.5 + rng.random()))
69
+ raise ProviderError(f"giving up after {attempts} attempts: {last}") from last
70
+
71
+
72
+ def split_system(messages: list[Message]) -> tuple[str | None, list[Message]]:
73
+ """Providers that take the system prompt as a separate field need it pulled out."""
74
+ system = "\n\n".join(m.content for m in messages if m.role == "system") or None
75
+ return system, [m for m in messages if m.role != "system"]
@@ -0,0 +1,70 @@
1
+ """Ollama, the default provider: local, free, and reproducible with a fixed seed."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from evalproof.config import GenerationParams
11
+ from evalproof.providers.base import ChatResponse, Message, ProviderError, with_retries
12
+
13
+ DEFAULT_BASE_URL = "http://127.0.0.1:11434"
14
+
15
+
16
+ class OllamaProvider:
17
+ name = "ollama"
18
+
19
+ def __init__(
20
+ self,
21
+ model: str,
22
+ *,
23
+ base_url: str | None = None,
24
+ timeout_s: float = 120.0,
25
+ client: httpx.Client | None = None,
26
+ ) -> None:
27
+ self.model = model
28
+ self.base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
29
+ self._client = client or httpx.Client(timeout=timeout_s)
30
+
31
+ def payload(self, messages: list[Message], params: GenerationParams) -> dict[str, Any]:
32
+ options: dict[str, Any] = {
33
+ "temperature": params.temperature,
34
+ "num_predict": params.max_tokens,
35
+ }
36
+ if params.seed is not None:
37
+ options["seed"] = params.seed
38
+ options.update(params.extra)
39
+ body: dict[str, Any] = {
40
+ "model": self.model,
41
+ "messages": [m.model_dump() for m in messages],
42
+ "stream": False,
43
+ "options": options,
44
+ }
45
+ if params.think is not None:
46
+ body["think"] = params.think
47
+ return body
48
+
49
+ def chat(self, messages: list[Message], params: GenerationParams) -> ChatResponse:
50
+ body = self.payload(messages, params)
51
+
52
+ def call() -> httpx.Response:
53
+ resp = self._client.post(f"{self.base_url}/api/chat", json=body)
54
+ resp.raise_for_status()
55
+ return resp
56
+
57
+ start = time.perf_counter()
58
+ resp = with_retries(call)
59
+ latency_ms = (time.perf_counter() - start) * 1000
60
+ data = resp.json()
61
+ message = data.get("message") or {}
62
+ if "content" not in message:
63
+ raise ProviderError(f"ollama: unexpected response shape: {str(data)[:300]}")
64
+ return ChatResponse(
65
+ text=message["content"],
66
+ model=data.get("model", self.model),
67
+ tokens_in=int(data.get("prompt_eval_count") or 0),
68
+ tokens_out=int(data.get("eval_count") or 0),
69
+ latency_ms=latency_ms,
70
+ )
@@ -0,0 +1,76 @@
1
+ """OpenAI-compatible Chat Completions over plain HTTP. Optional: needs OPENAI_API_KEY.
2
+
3
+ Also works against any server that speaks the same API, via ``base_url``.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import time
10
+ from typing import Any
11
+
12
+ import httpx
13
+
14
+ from evalproof.config import GenerationParams
15
+ from evalproof.providers.base import ChatResponse, Message, ProviderError, with_retries
16
+
17
+ DEFAULT_BASE_URL = "https://api.openai.com/v1"
18
+
19
+
20
+ class OpenAIProvider:
21
+ name = "openai"
22
+
23
+ def __init__(
24
+ self,
25
+ model: str,
26
+ *,
27
+ base_url: str | None = None,
28
+ timeout_s: float = 120.0,
29
+ client: httpx.Client | None = None,
30
+ api_key: str | None = None,
31
+ ) -> None:
32
+ self.model = model
33
+ self.base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
34
+ key = api_key or os.environ.get("OPENAI_API_KEY")
35
+ if not key:
36
+ raise ProviderError("openai: set OPENAI_API_KEY to use this provider")
37
+ self._headers = {"authorization": f"Bearer {key}", "content-type": "application/json"}
38
+ self._client = client or httpx.Client(timeout=timeout_s)
39
+
40
+ def payload(self, messages: list[Message], params: GenerationParams) -> dict[str, Any]:
41
+ body: dict[str, Any] = {
42
+ "model": self.model,
43
+ "messages": [m.model_dump() for m in messages],
44
+ "temperature": params.temperature,
45
+ "max_tokens": params.max_tokens,
46
+ }
47
+ if params.seed is not None:
48
+ body["seed"] = params.seed
49
+ body.update(params.extra)
50
+ return body
51
+
52
+ def chat(self, messages: list[Message], params: GenerationParams) -> ChatResponse:
53
+ body = self.payload(messages, params)
54
+
55
+ def call() -> httpx.Response:
56
+ resp = self._client.post(
57
+ f"{self.base_url}/chat/completions", json=body, headers=self._headers
58
+ )
59
+ resp.raise_for_status()
60
+ return resp
61
+
62
+ start = time.perf_counter()
63
+ resp = with_retries(call)
64
+ latency_ms = (time.perf_counter() - start) * 1000
65
+ data = resp.json()
66
+ choices = data.get("choices") or []
67
+ if not choices:
68
+ raise ProviderError(f"openai: no choices in response: {str(data)[:300]}")
69
+ usage = data.get("usage") or {}
70
+ return ChatResponse(
71
+ text=(choices[0].get("message") or {}).get("content") or "",
72
+ model=data.get("model", self.model),
73
+ tokens_in=int(usage.get("prompt_tokens") or 0),
74
+ tokens_out=int(usage.get("completion_tokens") or 0),
75
+ latency_ms=latency_ms,
76
+ )
evalproof/py.typed ADDED
File without changes
evalproof/report.py ADDED
@@ -0,0 +1,149 @@
1
+ """Reports: Markdown for a PR comment or GitHub step summary, JSON for machines."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import asdict
7
+ from typing import Any
8
+
9
+ from evalproof.calibration import CalibrationReport, Estimate
10
+ from evalproof.compare import Comparison, MetricComparison
11
+ from evalproof.gate import GateDecision
12
+ from evalproof.models import Run
13
+ from evalproof.stats import percentile, wilson
14
+
15
+ STATUS_ICON = {"pass": "✅ pass", "warn": "⚠️ pass with warnings", "fail": "❌ fail"}
16
+
17
+
18
+ def _pct(x: float | None) -> str:
19
+ return "—" if x is None else f"{100 * x:.1f}%"
20
+
21
+
22
+ def _rate_or_mean(mc: MetricComparison, value: float) -> str:
23
+ return _pct(value) if mc.kind == "binary" else f"{value:.3f}"
24
+
25
+
26
+ def _delta(mc: MetricComparison) -> str:
27
+ if mc.kind == "binary":
28
+ return f"{100 * mc.delta:+.1f} pts [{100 * mc.ci_low:+.1f}, {100 * mc.ci_high:+.1f}]"
29
+ return f"{mc.delta:+.3f} [{mc.ci_low:+.3f}, {mc.ci_high:+.3f}]"
30
+
31
+
32
+ def comparison_markdown(
33
+ cmp: Comparison, decision: GateDecision | None = None, gate_metric: str | None = None
34
+ ) -> str:
35
+ lines = ["## evalproof", ""]
36
+ if decision is not None:
37
+ lines += [f"**Gate: {STATUS_ICON[decision.status]}** (exit {decision.exit_code})", ""]
38
+ lines += [f"- {r}" for r in decision.reasons]
39
+ lines += [f"- ⚠️ {w}" for w in decision.warnings]
40
+ if decision.reasons or decision.warnings:
41
+ lines.append("")
42
+ lines += [
43
+ f"Baseline `{cmp.baseline_label}` vs candidate `{cmp.candidate_label}` on "
44
+ f"{len(cmp.paired_ids)} paired cases.",
45
+ "",
46
+ "| Metric | Baseline | Candidate | Δ (95% CI) | Test | p |",
47
+ "|---|---:|---:|---:|---|---:|",
48
+ ]
49
+ for name, mc in cmp.metrics.items():
50
+ label = f"**{name}**" if name == gate_metric else name
51
+ lines.append(
52
+ f"| {label} | {_rate_or_mean(mc, mc.baseline)} | {_rate_or_mean(mc, mc.candidate)} | "
53
+ f"{_delta(mc)} | {mc.test} | {mc.p_value:.4f} |"
54
+ )
55
+ lines += [
56
+ "",
57
+ "| Run | Cases | Errors | p50 latency | p95 latency | Cost |",
58
+ "|---|---:|---:|---:|---:|---:|",
59
+ ]
60
+ for label, st in (("baseline", cmp.baseline), ("candidate", cmp.candidate)):
61
+ lines.append(
62
+ f"| {label} | {st.cases} | {st.errors} | {st.p50_latency_ms:.0f} ms | "
63
+ f"{st.p95_latency_ms:.0f} ms | ${st.cost_usd:.4f} |"
64
+ )
65
+ focus = cmp.metrics.get(gate_metric) if gate_metric else None
66
+ if focus and focus.regressed:
67
+ shown = ", ".join(f"`{c}`" for c in focus.regressed[:20])
68
+ more = f" and {len(focus.regressed) - 20} more" if len(focus.regressed) > 20 else ""
69
+ lines += ["", f"Regressed on **{focus.name}** ({len(focus.regressed)}): {shown}{more}"]
70
+ if focus and focus.improved:
71
+ lines.append(f"Improved on **{focus.name}**: {len(focus.improved)} case(s)")
72
+ return "\n".join(lines) + "\n"
73
+
74
+
75
+ def run_markdown(run: Run) -> str:
76
+ lines = [
77
+ f"## {run.info.suite} — `{run.info.system}`",
78
+ "",
79
+ f"{len(run.results)} cases · wall {run.info.wall_time_s:.1f} s · "
80
+ f"dataset `{run.info.dataset_hash}`",
81
+ "",
82
+ "| Scorer | Decided | Pass rate (95% CI) | Mean |",
83
+ "|---|---:|---:|---:|",
84
+ ]
85
+ for name in run.scorer_names():
86
+ scores = [r.scores[name] for r in run.results if name in r.scores]
87
+ decided = [s for s in scores if s.value is not None]
88
+ binary = [s.passed for s in decided if s.passed is not None]
89
+ mean = (
90
+ sum(s.value for s in decided if s.value is not None) / len(decided) if decided else None
91
+ )
92
+ if binary:
93
+ k = sum(binary)
94
+ lo, hi = wilson(k, len(binary))
95
+ rate = f"{_pct(k / len(binary))} [{_pct(lo)}, {_pct(hi)}]"
96
+ else:
97
+ rate = "—"
98
+ lines.append(
99
+ f"| {name} | {len(decided)}/{len(scores)} | {rate} | "
100
+ f"{'—' if mean is None else f'{mean:.3f}'} |"
101
+ )
102
+ lat = [r.output.latency_ms for r in run.results if not r.output.error]
103
+ lines += [
104
+ "",
105
+ f"Latency p50 {percentile(lat, 50):.0f} ms · p95 {percentile(lat, 95):.0f} ms · "
106
+ f"errors {sum(1 for r in run.results if r.output.error)} · "
107
+ f"cached {sum(1 for r in run.results if r.output.cached)}",
108
+ ]
109
+ return "\n".join(lines) + "\n"
110
+
111
+
112
+ def _est_row(e: Estimate) -> str:
113
+ ci = "—" if e.low is None or e.high is None else f"[{_pct(e.low)}, {_pct(e.high)}]"
114
+ note = f" ({e.note})" if e.note else ""
115
+ return f"| {e.method}{note} | {_pct(e.point)} | {ci} | {e.n} |"
116
+
117
+
118
+ def calibration_markdown(rep: CalibrationReport) -> str:
119
+ cm = rep.confusion
120
+ lines = [
121
+ f"## Judge calibration — `{rep.judge_scorer}`",
122
+ "",
123
+ f"Gold source: {rep.gold_source} · {rep.n_gold} labelled of {rep.n_judged} judged "
124
+ f"· {rep.undecided_judge} undecided verdict(s) excluded",
125
+ "",
126
+ "| | Gold pass | Gold fail |",
127
+ "|---|---:|---:|",
128
+ f"| **Judge pass** | {cm.tp} | {cm.fp} |",
129
+ f"| **Judge fail** | {cm.fn} | {cm.tn} |",
130
+ "",
131
+ f"TPR {_pct(cm.tpr)} · FPR {_pct(cm.fpr)} · accuracy {_pct(cm.accuracy)} · "
132
+ f"Cohen's κ {'—' if cm.kappa is None else f'{cm.kappa:.3f}'}",
133
+ "",
134
+ "| Pass-rate estimate | Point | 95% CI | n |",
135
+ "|---|---:|---:|---:|",
136
+ ]
137
+ lines += [_est_row(e) for e in rep.estimates]
138
+ if rep.truth is not None:
139
+ lines.append(_est_row(rep.truth))
140
+ if rep.disagreements:
141
+ lines += [
142
+ "",
143
+ f"Judge disagreed with gold on: {', '.join(f'`{c}`' for c in rep.disagreements)}",
144
+ ]
145
+ return "\n".join(lines) + "\n"
146
+
147
+
148
+ def to_json(obj: Any) -> str:
149
+ return json.dumps(asdict(obj), indent=2, default=str)
evalproof/runner.py ADDED
@@ -0,0 +1,135 @@
1
+ """Run a suite: call the system on every case, score every output, write one run artifact."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import platform
6
+ import sys
7
+ import time
8
+ from collections.abc import Callable
9
+ from concurrent.futures import ThreadPoolExecutor
10
+ from datetime import UTC, datetime
11
+ from pathlib import Path
12
+
13
+ import httpx
14
+
15
+ from evalproof._version import __version__
16
+ from evalproof.cache import CachedProvider, ResponseCache
17
+ from evalproof.config import SuiteConfig, TargetConfig
18
+ from evalproof.dataset import dataset_hash, load_dataset
19
+ from evalproof.models import Case, CaseResult, Run, RunInfo
20
+ from evalproof.providers import Provider, make_provider, parse_target
21
+ from evalproof.scorers import Scorer, make_scorer
22
+ from evalproof.systems import System, make_system
23
+
24
+ Progress = Callable[[int, int, CaseResult], None]
25
+
26
+
27
+ def _ollama_facts(cfg: TargetConfig, client: httpx.Client | None) -> dict[str, str]:
28
+ """Best effort: the server version and the model digest make a run reproducible."""
29
+ if cfg.kind != "ollama":
30
+ return {}
31
+ _, model = parse_target(cfg.target)
32
+ base = (cfg.base_url or "http://127.0.0.1:11434").rstrip("/")
33
+ http = client or httpx.Client(timeout=5)
34
+ facts: dict[str, str] = {}
35
+ try:
36
+ facts["ollama_version"] = http.get(f"{base}/api/version").json().get("version", "")
37
+ for m in http.get(f"{base}/api/tags").json().get("models", []):
38
+ if m.get("name") == model or m.get("model") == model:
39
+ facts[f"digest:{model}"] = str(m.get("digest", ""))[:12]
40
+ except (httpx.HTTPError, ValueError):
41
+ pass
42
+ return facts
43
+
44
+
45
+ def environment(cfg: SuiteConfig, client: httpx.Client | None = None) -> dict[str, str]:
46
+ env = {
47
+ "python": sys.version.split()[0],
48
+ "platform": platform.platform(),
49
+ "machine": platform.machine(),
50
+ "processor": platform.processor() or "",
51
+ }
52
+ env.update(_ollama_facts(cfg.system, client))
53
+ if cfg.judge:
54
+ env.update(_ollama_facts(cfg.judge, client))
55
+ return env
56
+
57
+
58
+ def score_case(case: Case, system: System, scorers: list[Scorer]) -> CaseResult:
59
+ out = system.run(case)
60
+ return CaseResult(
61
+ case_id=case.id,
62
+ tags=case.tags,
63
+ output=out,
64
+ scores={s.name: s.score(case, out) for s in scorers},
65
+ )
66
+
67
+
68
+ def run_suite(
69
+ cfg: SuiteConfig,
70
+ *,
71
+ limit: int | None = None,
72
+ use_cache: bool = True,
73
+ client: httpx.Client | None = None,
74
+ progress: Progress | None = None,
75
+ system_override: TargetConfig | None = None,
76
+ ) -> Run:
77
+ cases = load_dataset(cfg.resolve(cfg.dataset))
78
+ if limit is not None:
79
+ cases = cases[:limit]
80
+ cache = ResponseCache(cfg.resolve(cfg.cache_dir)) if use_cache else None
81
+ system_cfg = system_override or cfg.system
82
+ system = make_system(
83
+ system_cfg, cache=cache, client=client, search_path=str(Path(cfg.base_dir).resolve())
84
+ )
85
+
86
+ judge: Provider | None = None
87
+ if cfg.judge is not None:
88
+ judge = make_provider(cfg.judge, client=client)
89
+ if cache is not None:
90
+ judge = CachedProvider(judge, cache, base_url=cfg.judge.base_url)
91
+ scorers = [make_scorer(s, cfg, judge) for s in cfg.scorers]
92
+
93
+ started = datetime.now(UTC)
94
+ t0 = time.perf_counter()
95
+ results: list[CaseResult] = []
96
+ if cfg.concurrency == 1:
97
+ for i, case in enumerate(cases, start=1):
98
+ res = score_case(case, system, scorers)
99
+ results.append(res)
100
+ if progress:
101
+ progress(i, len(cases), res)
102
+ else:
103
+ with ThreadPoolExecutor(max_workers=cfg.concurrency) as pool:
104
+ futures = [pool.submit(score_case, c, system, scorers) for c in cases]
105
+ for i, fut in enumerate(futures, start=1):
106
+ res = fut.result()
107
+ results.append(res)
108
+ if progress:
109
+ progress(i, len(cases), res)
110
+ wall = time.perf_counter() - t0
111
+
112
+ info = RunInfo(
113
+ suite=cfg.name,
114
+ system=system.name,
115
+ dataset_path=str(cfg.dataset),
116
+ dataset_hash=dataset_hash(cases),
117
+ started_at=started.isoformat(timespec="seconds"),
118
+ finished_at=datetime.now(UTC).isoformat(timespec="seconds"),
119
+ wall_time_s=round(wall, 3),
120
+ evalproof_version=__version__,
121
+ seed=cfg.seed,
122
+ environment=environment(cfg, client),
123
+ )
124
+ return Run(info=info, results=results)
125
+
126
+
127
+ def save_run(run: Run, path: str | Path) -> Path:
128
+ path = Path(path)
129
+ path.parent.mkdir(parents=True, exist_ok=True)
130
+ path.write_text(run.model_dump_json(indent=2), encoding="utf-8")
131
+ return path
132
+
133
+
134
+ def load_run(path: str | Path) -> Run:
135
+ return Run.model_validate_json(Path(path).read_text(encoding="utf-8"))
@@ -0,0 +1,80 @@
1
+ """Scorer registry: build scorers from suite config."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Protocol
6
+
7
+ from evalproof.config import ConfigError, ScorerConfig, SuiteConfig
8
+ from evalproof.models import Case, Score, SystemOutput
9
+ from evalproof.providers import Provider
10
+ from evalproof.scorers.deterministic import (
11
+ Contains,
12
+ ExactMatch,
13
+ JsonSchema,
14
+ NumericTolerance,
15
+ Regex,
16
+ )
17
+ from evalproof.scorers.judge import DEFAULT_RUBRIC, LLMJudge, PairwiseResult, pairwise
18
+ from evalproof.scorers.retrieval import MRR, NDCG, RecallAtK
19
+
20
+ __all__ = [
21
+ "MRR",
22
+ "NDCG",
23
+ "Contains",
24
+ "ExactMatch",
25
+ "JsonSchema",
26
+ "LLMJudge",
27
+ "NumericTolerance",
28
+ "PairwiseResult",
29
+ "RecallAtK",
30
+ "Regex",
31
+ "Scorer",
32
+ "make_scorer",
33
+ "pairwise",
34
+ ]
35
+
36
+
37
+ class Scorer(Protocol):
38
+ name: str
39
+
40
+ def score(self, case: Case, out: SystemOutput) -> Score: ...
41
+
42
+
43
+ _SIMPLE: dict[str, Any] = {
44
+ "exact_match": ExactMatch,
45
+ "contains": Contains,
46
+ "regex": Regex,
47
+ "json_schema": JsonSchema,
48
+ "numeric_tolerance": NumericTolerance,
49
+ "recall_at_k": RecallAtK,
50
+ "mrr": MRR,
51
+ "ndcg": NDCG,
52
+ }
53
+
54
+
55
+ def make_scorer(
56
+ cfg: ScorerConfig, suite: SuiteConfig, judge_provider: Provider | None = None
57
+ ) -> Scorer:
58
+ params = dict(cfg.params)
59
+ if cfg.type == "llm_judge":
60
+ if judge_provider is None or suite.judge is None:
61
+ raise ConfigError(f"scorer {cfg.name!r} needs a judge provider")
62
+ rubric = params.pop("rubric", None)
63
+ rubric_file = params.pop("rubric_file", None)
64
+ if rubric_file:
65
+ rubric = suite.resolve(rubric_file).read_text(encoding="utf-8")
66
+ max_retries = int(params.pop("max_retries", 1))
67
+ if params:
68
+ raise ConfigError(f"scorer {cfg.name!r}: unknown params {sorted(params)}")
69
+ return LLMJudge(
70
+ cfg.name,
71
+ judge_provider,
72
+ suite.judge.params,
73
+ rubric=rubric or DEFAULT_RUBRIC,
74
+ max_retries=max_retries,
75
+ )
76
+ try:
77
+ scorer: Scorer = _SIMPLE[cfg.type](cfg.name, **params)
78
+ except TypeError as exc:
79
+ raise ConfigError(f"scorer {cfg.name!r}: {exc}") from exc
80
+ return scorer