evalstone 0.1.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.
- evalstone/__init__.py +131 -0
- evalstone/__main__.py +3 -0
- evalstone/_git.py +22 -0
- evalstone/_json.py +59 -0
- evalstone/checks/__init__.py +41 -0
- evalstone/checks/_base.py +153 -0
- evalstone/checks/comparison.py +154 -0
- evalstone/checks/deterministic.py +279 -0
- evalstone/checks/human.py +124 -0
- evalstone/checks/judge.py +219 -0
- evalstone/checks/rag.py +70 -0
- evalstone/checks/safety.py +131 -0
- evalstone/checks/trajectory.py +144 -0
- evalstone/cli.py +411 -0
- evalstone/config.py +267 -0
- evalstone/dataset.py +184 -0
- evalstone/gate.py +84 -0
- evalstone/mcp_server.py +329 -0
- evalstone/output.py +36 -0
- evalstone/providers/__init__.py +185 -0
- evalstone/providers/_http.py +58 -0
- evalstone/providers/anthropic_.py +81 -0
- evalstone/providers/gemini.py +55 -0
- evalstone/providers/openai_compat.py +67 -0
- evalstone/pytest_plugin.py +203 -0
- evalstone/run.py +336 -0
- evalstone/score.py +124 -0
- evalstone/suite.py +135 -0
- evalstone/target.py +289 -0
- evalstone-0.1.0.dist-info/METADATA +125 -0
- evalstone-0.1.0.dist-info/RECORD +34 -0
- evalstone-0.1.0.dist-info/WHEEL +4 -0
- evalstone-0.1.0.dist-info/entry_points.txt +5 -0
- evalstone-0.1.0.dist-info/licenses/LICENSE +21 -0
evalstone/__init__.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Evalstone — the plug-and-play evals layer for LLM-based projects.
|
|
2
|
+
|
|
3
|
+
The library computes; it owns no database. See https://evalstone.com/docs.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
import types
|
|
8
|
+
from importlib import import_module
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
|
|
13
|
+
# Exports resolve on first access (PEP 562) so that importing the package — which
|
|
14
|
+
# the pytest plugin does in every pytest session on the interpreter — costs
|
|
15
|
+
# nothing until a name is used.
|
|
16
|
+
_EXPORTS: dict[str, str] = {
|
|
17
|
+
"check": "evalstone.checks",
|
|
18
|
+
"registry": "evalstone.checks",
|
|
19
|
+
"Dataset": "evalstone.dataset",
|
|
20
|
+
"Sample": "evalstone.dataset",
|
|
21
|
+
"Source": "evalstone.dataset",
|
|
22
|
+
"Gate": "evalstone.gate",
|
|
23
|
+
"GateFailed": "evalstone.gate",
|
|
24
|
+
"GateReport": "evalstone.gate",
|
|
25
|
+
"Output": "evalstone.output",
|
|
26
|
+
"Providers": "evalstone.providers",
|
|
27
|
+
"Comparison": "evalstone.run",
|
|
28
|
+
"Result": "evalstone.run",
|
|
29
|
+
"Run": "evalstone.run",
|
|
30
|
+
"RunSummary": "evalstone.run",
|
|
31
|
+
"compare": "evalstone.run",
|
|
32
|
+
"write_report": "evalstone.run",
|
|
33
|
+
"Check": "evalstone.score",
|
|
34
|
+
"CheckKind": "evalstone.score",
|
|
35
|
+
"Context": "evalstone.score",
|
|
36
|
+
"MemoryCache": "evalstone.score",
|
|
37
|
+
"Score": "evalstone.score",
|
|
38
|
+
"Suite": "evalstone.suite",
|
|
39
|
+
"CallableTarget": "evalstone.target",
|
|
40
|
+
"HttpTarget": "evalstone.target",
|
|
41
|
+
"PromptTarget": "evalstone.target",
|
|
42
|
+
"RecordedTarget": "evalstone.target",
|
|
43
|
+
"Target": "evalstone.target",
|
|
44
|
+
"target": "evalstone.target",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if TYPE_CHECKING:
|
|
48
|
+
from evalstone import checks
|
|
49
|
+
from evalstone.checks import check, registry
|
|
50
|
+
from evalstone.dataset import Dataset, Sample, Source
|
|
51
|
+
from evalstone.gate import Gate, GateFailed, GateReport
|
|
52
|
+
from evalstone.output import Output
|
|
53
|
+
from evalstone.providers import Providers
|
|
54
|
+
from evalstone.run import Comparison, Result, Run, RunSummary, compare, write_report
|
|
55
|
+
from evalstone.score import Check, CheckKind, Context, MemoryCache, Score
|
|
56
|
+
from evalstone.suite import Suite
|
|
57
|
+
from evalstone.target import (
|
|
58
|
+
CallableTarget,
|
|
59
|
+
HttpTarget,
|
|
60
|
+
PromptTarget,
|
|
61
|
+
RecordedTarget,
|
|
62
|
+
Target,
|
|
63
|
+
target,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class _Package(types.ModuleType):
|
|
68
|
+
"""``evalstone.target`` is the factory function, as it always was, even though a
|
|
69
|
+
submodule of the same name exists: importing that submodule makes the import
|
|
70
|
+
system bind it onto the package, which the setter below ignores."""
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def target(self) -> Any:
|
|
74
|
+
return import_module("evalstone.target").target
|
|
75
|
+
|
|
76
|
+
@target.setter
|
|
77
|
+
def target(self, value: Any) -> None:
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
sys.modules[__name__].__class__ = _Package
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def __getattr__(name: str) -> Any:
|
|
85
|
+
if name == "checks":
|
|
86
|
+
return import_module("evalstone.checks")
|
|
87
|
+
try:
|
|
88
|
+
module = _EXPORTS[name]
|
|
89
|
+
except KeyError:
|
|
90
|
+
raise AttributeError(f"module 'evalstone' has no attribute {name!r}") from None
|
|
91
|
+
value = getattr(import_module(module), name)
|
|
92
|
+
globals()[name] = value
|
|
93
|
+
return value
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def __dir__() -> list[str]:
|
|
97
|
+
return sorted(set(__all__) | {n for n in globals() if n.startswith("__")})
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
__all__ = [
|
|
101
|
+
"CallableTarget",
|
|
102
|
+
"Check",
|
|
103
|
+
"CheckKind",
|
|
104
|
+
"Comparison",
|
|
105
|
+
"Context",
|
|
106
|
+
"Dataset",
|
|
107
|
+
"Gate",
|
|
108
|
+
"GateFailed",
|
|
109
|
+
"GateReport",
|
|
110
|
+
"HttpTarget",
|
|
111
|
+
"MemoryCache",
|
|
112
|
+
"Output",
|
|
113
|
+
"PromptTarget",
|
|
114
|
+
"Providers",
|
|
115
|
+
"RecordedTarget",
|
|
116
|
+
"Result",
|
|
117
|
+
"Run",
|
|
118
|
+
"RunSummary",
|
|
119
|
+
"Sample",
|
|
120
|
+
"Score",
|
|
121
|
+
"Source",
|
|
122
|
+
"Suite",
|
|
123
|
+
"Target",
|
|
124
|
+
"__version__",
|
|
125
|
+
"check",
|
|
126
|
+
"checks",
|
|
127
|
+
"compare",
|
|
128
|
+
"registry",
|
|
129
|
+
"target",
|
|
130
|
+
"write_report",
|
|
131
|
+
]
|
evalstone/__main__.py
ADDED
evalstone/_git.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Best-effort git facts for run provenance. Never raises."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def head_sha(cwd: str | Path | None = None) -> str | None:
|
|
10
|
+
try:
|
|
11
|
+
out = subprocess.run(
|
|
12
|
+
["git", "rev-parse", "HEAD"],
|
|
13
|
+
cwd=cwd,
|
|
14
|
+
capture_output=True,
|
|
15
|
+
text=True,
|
|
16
|
+
timeout=5,
|
|
17
|
+
check=False,
|
|
18
|
+
)
|
|
19
|
+
except (OSError, subprocess.SubprocessError):
|
|
20
|
+
return None
|
|
21
|
+
sha = out.stdout.strip()
|
|
22
|
+
return sha if out.returncode == 0 and sha else None
|
evalstone/_json.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Canonical JSON helpers: one stable serialisation so content hashes are reproducible.
|
|
2
|
+
|
|
3
|
+
Only JSON-native values plus a few explicitly handled types (sets, tuples,
|
|
4
|
+
bytes, dates, enums, pydantic models, dataclasses) are accepted. Anything else
|
|
5
|
+
raises ``TypeError`` instead of being stringified — ``str(obj)`` would bake a
|
|
6
|
+
memory address or a hash-randomised set order into a content hash.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import dataclasses
|
|
13
|
+
import datetime as dt
|
|
14
|
+
import enum
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _encode(value: Any) -> Any:
|
|
21
|
+
"""Convert the supported non-JSON types to a deterministic JSON-native form."""
|
|
22
|
+
if value is None or isinstance(value, str | int | float | bool):
|
|
23
|
+
return value
|
|
24
|
+
if isinstance(value, list):
|
|
25
|
+
return [_encode(v) for v in value]
|
|
26
|
+
if isinstance(value, dict):
|
|
27
|
+
if any(not isinstance(k, str) for k in value):
|
|
28
|
+
raise TypeError("dict keys must be strings to hash")
|
|
29
|
+
return {k: _encode(v) for k, v in value.items()}
|
|
30
|
+
if isinstance(value, enum.Enum):
|
|
31
|
+
return value.value
|
|
32
|
+
if isinstance(value, set | frozenset):
|
|
33
|
+
return sorted((_encode(v) for v in value), key=lambda v: json.dumps(v, sort_keys=True))
|
|
34
|
+
if isinstance(value, tuple):
|
|
35
|
+
return [_encode(v) for v in value]
|
|
36
|
+
if isinstance(value, bytes | bytearray):
|
|
37
|
+
return {"__bytes__": base64.b64encode(bytes(value)).decode("ascii")}
|
|
38
|
+
if isinstance(value, dt.datetime | dt.date | dt.time):
|
|
39
|
+
return value.isoformat()
|
|
40
|
+
if hasattr(value, "model_dump") and callable(value.model_dump): # pydantic
|
|
41
|
+
return value.model_dump(mode="json")
|
|
42
|
+
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
|
43
|
+
return dataclasses.asdict(value)
|
|
44
|
+
raise TypeError(f"not JSON-serialisable for hashing/text: {type(value).__name__}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def dumps(value: Any, **kwargs: Any) -> str:
|
|
48
|
+
"""``json.dumps`` with the explicit encoder above (never ``default=str``)."""
|
|
49
|
+
return json.dumps(_encode(value), ensure_ascii=False, **kwargs)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def canonical(value: Any) -> str:
|
|
53
|
+
"""Serialise ``value`` deterministically (sorted keys, no whitespace, UTF-8 kept)."""
|
|
54
|
+
return dumps(_encode(value), sort_keys=True, separators=(",", ":"))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def sha256(value: Any) -> str:
|
|
58
|
+
"""sha256 hex digest of the canonical JSON form of ``value``."""
|
|
59
|
+
return hashlib.sha256(canonical(value).encode("utf-8")).hexdigest()
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Checks: the registry, the ``@check`` decorator and the built-in factories.
|
|
2
|
+
|
|
3
|
+
Built-in checks and user-defined checks have the same shape — see ``_base``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from evalstone.checks import comparison, rag, safety, trajectory
|
|
7
|
+
from evalstone.checks._base import CheckFn, FunctionCheck, Registry, check, registry
|
|
8
|
+
from evalstone.checks.comparison import pairwise
|
|
9
|
+
from evalstone.checks.deterministic import (
|
|
10
|
+
contains,
|
|
11
|
+
exact,
|
|
12
|
+
json_schema,
|
|
13
|
+
length,
|
|
14
|
+
numeric_tolerance,
|
|
15
|
+
python,
|
|
16
|
+
regex,
|
|
17
|
+
)
|
|
18
|
+
from evalstone.checks.human import human
|
|
19
|
+
from evalstone.checks.judge import judge
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"CheckFn",
|
|
23
|
+
"FunctionCheck",
|
|
24
|
+
"Registry",
|
|
25
|
+
"check",
|
|
26
|
+
"comparison",
|
|
27
|
+
"contains",
|
|
28
|
+
"exact",
|
|
29
|
+
"human",
|
|
30
|
+
"json_schema",
|
|
31
|
+
"judge",
|
|
32
|
+
"length",
|
|
33
|
+
"numeric_tolerance",
|
|
34
|
+
"pairwise",
|
|
35
|
+
"python",
|
|
36
|
+
"rag",
|
|
37
|
+
"regex",
|
|
38
|
+
"registry",
|
|
39
|
+
"safety",
|
|
40
|
+
"trajectory",
|
|
41
|
+
]
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""The check registry, ``FunctionCheck`` and the ``@check`` decorator.
|
|
2
|
+
|
|
3
|
+
Built-in checks and user-defined checks have the same shape. A decorated
|
|
4
|
+
function may return a ``Score``, a ``bool`` or a ``float`` in 0..1; the
|
|
5
|
+
decorator normalises it. Any exception raised inside a check becomes
|
|
6
|
+
``Score.errored(...)`` — a crashing check never passes and never takes the
|
|
7
|
+
run down.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import functools
|
|
13
|
+
import inspect
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from typing import TYPE_CHECKING, Any
|
|
17
|
+
|
|
18
|
+
from evalstone.score import Check, CheckKind, Context, Score
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from evalstone.dataset import Sample
|
|
22
|
+
from evalstone.output import Output
|
|
23
|
+
|
|
24
|
+
CheckFn = Callable[..., "Score | bool | float"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Registry:
|
|
28
|
+
"""Checks by key. Registering the same key twice is an error unless ``replace=True``."""
|
|
29
|
+
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
self._checks: dict[str, Check] = {}
|
|
32
|
+
self.generation = 0 # bumped by clear(); project modules re-import when it moves
|
|
33
|
+
|
|
34
|
+
def register(self, chk: Check, *, replace: bool = False) -> Check:
|
|
35
|
+
"""Register ``chk`` under its key.
|
|
36
|
+
|
|
37
|
+
A second registration of the same key is an error, except when it is the
|
|
38
|
+
same function re-imported from a fresh module object (``evalstone.yaml``
|
|
39
|
+
project reloads, notebook re-runs): same ``__qualname__`` but different
|
|
40
|
+
module globals. Two distinct functions from one module — closures from a
|
|
41
|
+
factory, lambdas — share globals and are still rejected.
|
|
42
|
+
"""
|
|
43
|
+
previous = self._checks.get(chk.key)
|
|
44
|
+
if previous is not None and not replace and not _reimported(previous, chk):
|
|
45
|
+
raise ValueError(f"check {chk.key!r} is already registered")
|
|
46
|
+
self._checks[chk.key] = chk
|
|
47
|
+
return chk
|
|
48
|
+
|
|
49
|
+
def get(self, key: str) -> Check:
|
|
50
|
+
try:
|
|
51
|
+
return self._checks[key]
|
|
52
|
+
except KeyError:
|
|
53
|
+
raise KeyError(f"unknown check {key!r}; registered: {sorted(self._checks)}") from None
|
|
54
|
+
|
|
55
|
+
def __contains__(self, key: str) -> bool:
|
|
56
|
+
return key in self._checks
|
|
57
|
+
|
|
58
|
+
def keys(self) -> list[str]:
|
|
59
|
+
return sorted(self._checks)
|
|
60
|
+
|
|
61
|
+
def clear(self) -> None:
|
|
62
|
+
self._checks.clear()
|
|
63
|
+
self.generation += 1
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _reimported(previous: Check, current: Check) -> bool:
|
|
67
|
+
pf, cf = getattr(previous, "_fn", None), getattr(current, "_fn", None)
|
|
68
|
+
if pf is None or cf is None:
|
|
69
|
+
return False
|
|
70
|
+
same_name = (pf.__module__, pf.__qualname__) == (cf.__module__, cf.__qualname__)
|
|
71
|
+
fresh_module = getattr(pf, "__globals__", None) is not getattr(cf, "__globals__", None)
|
|
72
|
+
return same_name and fresh_module
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
registry = Registry()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _normalise(result: Any) -> Score:
|
|
79
|
+
if isinstance(result, Score):
|
|
80
|
+
return result
|
|
81
|
+
if isinstance(result, bool):
|
|
82
|
+
return Score.from_bool(result)
|
|
83
|
+
if isinstance(result, int | float):
|
|
84
|
+
return Score.from_value(float(result))
|
|
85
|
+
raise TypeError(f"check must return Score, bool or float, got {type(result).__name__}")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class FunctionCheck:
|
|
89
|
+
"""A plain function promoted to a ``Check``."""
|
|
90
|
+
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
key: str,
|
|
94
|
+
fn: CheckFn,
|
|
95
|
+
*,
|
|
96
|
+
kind: CheckKind = CheckKind.DETERMINISTIC,
|
|
97
|
+
version: int = 1,
|
|
98
|
+
description: str = "",
|
|
99
|
+
) -> None:
|
|
100
|
+
self.key = key
|
|
101
|
+
self.kind = kind
|
|
102
|
+
self.version = version
|
|
103
|
+
self.description = description or (inspect.getdoc(fn) or "")
|
|
104
|
+
self._fn = fn
|
|
105
|
+
self._wants_ctx = "ctx" in inspect.signature(fn).parameters
|
|
106
|
+
functools.update_wrapper(self, fn)
|
|
107
|
+
|
|
108
|
+
def evaluate(self, sample: Sample, output: Output, ctx: Context) -> Score:
|
|
109
|
+
started = time.perf_counter()
|
|
110
|
+
try:
|
|
111
|
+
if self._wants_ctx:
|
|
112
|
+
result = self._fn(sample, output, ctx=ctx)
|
|
113
|
+
else:
|
|
114
|
+
result = self._fn(sample, output)
|
|
115
|
+
score = _normalise(result)
|
|
116
|
+
except Exception as exc: # a crashing check is an errored score, never a pass
|
|
117
|
+
score = Score.errored(f"{type(exc).__name__}: {exc}")
|
|
118
|
+
elapsed = (time.perf_counter() - started) * 1000
|
|
119
|
+
if score.latency_ms == 0.0:
|
|
120
|
+
score = score.model_copy(update={"latency_ms": elapsed})
|
|
121
|
+
return score
|
|
122
|
+
|
|
123
|
+
def __call__(self, sample: Sample, output: Output, ctx: Context | None = None) -> Score:
|
|
124
|
+
return self.evaluate(sample, output, ctx or Context())
|
|
125
|
+
|
|
126
|
+
def __repr__(self) -> str:
|
|
127
|
+
return f"<check {self.key} kind={self.kind} v{self.version}>"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def check(
|
|
131
|
+
key: str,
|
|
132
|
+
*,
|
|
133
|
+
kind: CheckKind | str = CheckKind.DETERMINISTIC,
|
|
134
|
+
version: int = 1,
|
|
135
|
+
register: bool = True,
|
|
136
|
+
) -> Callable[[CheckFn], FunctionCheck]:
|
|
137
|
+
"""Turn ``fn(sample, output[, ctx]) -> Score | bool | float`` into a registered check.
|
|
138
|
+
|
|
139
|
+
>>> @check("no_apology")
|
|
140
|
+
... def no_apology(sample, output):
|
|
141
|
+
... return "sorry" not in output.text.lower()
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
def decorator(fn: CheckFn) -> FunctionCheck:
|
|
145
|
+
chk = FunctionCheck(key, fn, kind=CheckKind(kind), version=version)
|
|
146
|
+
if register:
|
|
147
|
+
registry.register(chk)
|
|
148
|
+
return chk
|
|
149
|
+
|
|
150
|
+
return decorator
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
__all__ = ["CheckFn", "FunctionCheck", "Registry", "check", "registry"]
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Pairwise comparison: is the candidate output better than a baseline's?
|
|
2
|
+
|
|
3
|
+
Judges are far more consistent at *preferring* than at scoring, and they prefer
|
|
4
|
+
whichever answer comes first unless you shuffle — so by default both orders are
|
|
5
|
+
judged and the verdicts combined (Evals 101, chapters 4 and 6).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from evalstone._json import sha256
|
|
14
|
+
from evalstone.checks._base import FunctionCheck
|
|
15
|
+
from evalstone.checks.judge import _json_text, extract_json
|
|
16
|
+
from evalstone.dataset import Sample
|
|
17
|
+
from evalstone.output import Output
|
|
18
|
+
from evalstone.providers import ChatMessage, ProviderError
|
|
19
|
+
from evalstone.score import CheckKind, Context, Score
|
|
20
|
+
from evalstone.target import RecordedTarget, Target
|
|
21
|
+
|
|
22
|
+
SYSTEM = """You are comparing two answers to the same INPUT.
|
|
23
|
+
|
|
24
|
+
CRITERIA:
|
|
25
|
+
{rubric}
|
|
26
|
+
|
|
27
|
+
Decide which answer better satisfies the criteria. Ignore the order they are shown in,
|
|
28
|
+
their length, and formatting. Reply with ONLY a JSON object:
|
|
29
|
+
{{"winner": "A" | "B" | "tie", "reason": "<one sentence>"}}"""
|
|
30
|
+
|
|
31
|
+
USER = """INPUT:
|
|
32
|
+
{input}
|
|
33
|
+
|
|
34
|
+
ANSWER A:
|
|
35
|
+
{a}
|
|
36
|
+
|
|
37
|
+
ANSWER B:
|
|
38
|
+
{b}"""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def parse_winner(text: str) -> tuple[str, str]:
|
|
42
|
+
data = extract_json(text)
|
|
43
|
+
winner = str(data.get("winner", "")).strip().upper()
|
|
44
|
+
if winner not in {"A", "B", "TIE"}:
|
|
45
|
+
raise ValueError(f"judge winner must be A, B or tie, got {winner!r}")
|
|
46
|
+
return winner, str(data.get("reason", "")).strip()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def pairwise(
|
|
50
|
+
key: str,
|
|
51
|
+
rubric: str,
|
|
52
|
+
*,
|
|
53
|
+
baseline: Target | Mapping[str, Any],
|
|
54
|
+
model: str | None = None,
|
|
55
|
+
both_orders: bool = True,
|
|
56
|
+
max_tokens: int = 256,
|
|
57
|
+
) -> FunctionCheck:
|
|
58
|
+
"""The candidate output beats the ``baseline`` output for the same sample.
|
|
59
|
+
|
|
60
|
+
``baseline`` is a Target (its outputs are produced per sample, cost counted) or a
|
|
61
|
+
mapping of sample id → output. Each judged order scores 1 (candidate preferred),
|
|
62
|
+
0.5 (tie) or 0 (baseline preferred); the value is their mean, so with both orders
|
|
63
|
+
it is one of 1.0 / 0.75 / 0.5 / 0.25 / 0.0. Passes at 0.5 or above (wins or ties).
|
|
64
|
+
"""
|
|
65
|
+
base_target: Target = baseline if isinstance(baseline, Target) else RecordedTarget(baseline)
|
|
66
|
+
identity = {"v": 1, "rubric": rubric, "both_orders": both_orders}
|
|
67
|
+
|
|
68
|
+
def ask(ctx: Context, sample_input: str, a: str, b: str) -> tuple[str, str, float, float]:
|
|
69
|
+
user = USER.format(input=sample_input, a=a, b=b)
|
|
70
|
+
resp = ctx.providers.complete(
|
|
71
|
+
model,
|
|
72
|
+
{
|
|
73
|
+
"system": SYSTEM.format(rubric=rubric.strip()),
|
|
74
|
+
"messages": [ChatMessage(role="user", content=user)],
|
|
75
|
+
"max_tokens": max_tokens,
|
|
76
|
+
},
|
|
77
|
+
)
|
|
78
|
+
winner, reason = parse_winner(resp.text)
|
|
79
|
+
return winner, reason, resp.cost_usd or 0.0, resp.latency_ms
|
|
80
|
+
|
|
81
|
+
def fn(sample: Sample, output: Output, ctx: Context) -> Score:
|
|
82
|
+
if ctx.providers is None:
|
|
83
|
+
return Score.errored("pairwise: no providers configured")
|
|
84
|
+
try:
|
|
85
|
+
resolved = "/".join(ctx.providers.split(model))
|
|
86
|
+
except ProviderError as exc:
|
|
87
|
+
return Score.errored(str(exc))
|
|
88
|
+
version = sha256({**identity, "model": resolved})[:16]
|
|
89
|
+
base = base_target.produce(sample)
|
|
90
|
+
if base.error:
|
|
91
|
+
return Score.errored(f"pairwise: baseline: {base.error}")
|
|
92
|
+
text = _json_text(sample.input)
|
|
93
|
+
cache_key = sha256({"p": version, "i": text, "c": output.text, "b": base.text})
|
|
94
|
+
cached = ctx.cache.get(cache_key) if ctx.cache is not None else None
|
|
95
|
+
if cached is not None:
|
|
96
|
+
# The judge verdict is cached; the baseline was still produced (its cost counts).
|
|
97
|
+
raw = {**(cached.raw or {}), "cached": True}
|
|
98
|
+
return cached.model_copy(
|
|
99
|
+
update={"cost_usd": base.cost_usd, "latency_ms": base.latency_ms, "raw": raw}
|
|
100
|
+
)
|
|
101
|
+
cost = base.cost_usd
|
|
102
|
+
latency = 0.0
|
|
103
|
+
points: list[float] = []
|
|
104
|
+
reasons: list[str] = []
|
|
105
|
+
try:
|
|
106
|
+
orders = [("cand_first", output.text, base.text)]
|
|
107
|
+
if both_orders:
|
|
108
|
+
orders.append(("base_first", base.text, output.text))
|
|
109
|
+
for label, a, b in orders:
|
|
110
|
+
winner, reason, c, ms = ask(ctx, text, a, b)
|
|
111
|
+
cost += c
|
|
112
|
+
latency += ms
|
|
113
|
+
candidate_is_a = label == "cand_first"
|
|
114
|
+
if winner == "TIE":
|
|
115
|
+
points.append(0.5)
|
|
116
|
+
elif (winner == "A") == candidate_is_a:
|
|
117
|
+
points.append(1.0)
|
|
118
|
+
else:
|
|
119
|
+
points.append(0.0)
|
|
120
|
+
reasons.append(reason)
|
|
121
|
+
except ProviderError as exc:
|
|
122
|
+
return Score.errored(str(exc), cost_usd=cost, latency_ms=latency)
|
|
123
|
+
except ValueError as exc:
|
|
124
|
+
return Score.errored(f"pairwise: {exc}", cost_usd=cost, latency_ms=latency)
|
|
125
|
+
value = sum(points) / len(points)
|
|
126
|
+
verdict = _label(value)
|
|
127
|
+
score = Score(
|
|
128
|
+
value=value,
|
|
129
|
+
passed=value >= 0.5,
|
|
130
|
+
reason=f"candidate {verdict}: " + " | ".join(r for r in reasons if r),
|
|
131
|
+
cost_usd=cost,
|
|
132
|
+
latency_ms=latency,
|
|
133
|
+
raw={"points": points, "orders": len(points), "judge_version": version},
|
|
134
|
+
)
|
|
135
|
+
if ctx.cache is not None:
|
|
136
|
+
ctx.cache.set(cache_key, score)
|
|
137
|
+
return score
|
|
138
|
+
|
|
139
|
+
return FunctionCheck(key, fn, kind=CheckKind.COMPARISON)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _label(value: float) -> str:
|
|
143
|
+
if value == 1.0:
|
|
144
|
+
return "wins"
|
|
145
|
+
if value > 0.5:
|
|
146
|
+
return "mostly wins"
|
|
147
|
+
if value == 0.5:
|
|
148
|
+
return "ties/split"
|
|
149
|
+
if value > 0.0:
|
|
150
|
+
return "mostly loses"
|
|
151
|
+
return "loses"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
__all__ = ["pairwise", "parse_winner"]
|