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
|
@@ -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
|