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 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
@@ -0,0 +1,3 @@
1
+ from evalstone.cli import main
2
+
3
+ main()
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"]