evalstone 0.2.0__tar.gz → 0.2.2__tar.gz
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-0.2.0 → evalstone-0.2.2}/CHANGELOG.md +37 -3
- {evalstone-0.2.0 → evalstone-0.2.2}/PKG-INFO +1 -1
- {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/__init__.py +1 -1
- {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/center.py +8 -6
- {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/checks/comparison.py +30 -7
- {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/checks/judge.py +34 -4
- {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/cli.py +4 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/providers/__init__.py +5 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/score.py +2 -1
- {evalstone-0.2.0 → evalstone-0.2.2}/tests/test_center.py +39 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/tests/test_cli.py +15 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/tests/test_docs.py +14 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/tests/test_judge.py +41 -2
- {evalstone-0.2.0 → evalstone-0.2.2}/tests/test_rag_and_comparison.py +32 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/.gitignore +0 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/LICENSE +0 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/README.md +0 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/justfile +0 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/pyproject.toml +0 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/__main__.py +0 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/_git.py +0 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/_json.py +0 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/checks/__init__.py +0 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/checks/_base.py +0 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/checks/deterministic.py +0 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/checks/human.py +0 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/checks/rag.py +0 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/checks/safety.py +0 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/checks/trajectory.py +0 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/config.py +0 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/dataset.py +0 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/gate.py +0 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/mcp_server.py +0 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/output.py +0 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/providers/_http.py +0 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/providers/anthropic_.py +0 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/providers/gemini.py +0 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/providers/openai_compat.py +0 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/pytest_plugin.py +0 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/run.py +0 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/suite.py +0 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/target.py +0 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/tests/conftest.py +0 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/tests/test_action.py +0 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/tests/test_config.py +0 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/tests/test_dataset.py +0 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/tests/test_deterministic.py +0 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/tests/test_human.py +0 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/tests/test_judge_switching.py +0 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/tests/test_mcp.py +0 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/tests/test_providers.py +0 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/tests/test_pytest_plugin.py +0 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/tests/test_safety.py +0 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/tests/test_score_and_checks.py +0 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/tests/test_suite_run_gate.py +0 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/tests/test_target.py +0 -0
- {evalstone-0.2.0 → evalstone-0.2.2}/tests/test_trajectory.py +0 -0
|
@@ -2,14 +2,48 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `python/evalstone`. Semantic versioning; tags `lib-vX.Y.Z`.
|
|
4
4
|
|
|
5
|
+
## 0.2.2 — 2026-10-14
|
|
6
|
+
|
|
7
|
+
Judge replies cut off by the token cap, found by the first run against a real
|
|
8
|
+
vendor (Claude Haiku 5.5 via OpenRouter on the rag-bot demo: 4 of 10 verdicts
|
|
9
|
+
errored).
|
|
10
|
+
|
|
11
|
+
- `judge` and `comparison.pairwise` default `max_tokens` is now 1024 (was 256).
|
|
12
|
+
The cut replies needed 286–349 tokens: a verdict plus a long one-sentence reason.
|
|
13
|
+
`max_tokens` is not part of the judge version, so cached verdicts stay valid.
|
|
14
|
+
- A reply the provider stopped at the cap (`length`, `max_tokens`, `MAX_TOKENS`)
|
|
15
|
+
whose JSON is unclosed or invalid now errors with "reply truncated at
|
|
16
|
+
max_tokens=N; raise the check's max_tokens" followed by the original parse error and excerpt
|
|
17
|
+
instead of only "no JSON object in judge reply". A complete verdict at the cap is
|
|
18
|
+
accepted, and a complete but wrong one (score out of range) keeps its own error.
|
|
19
|
+
`ChatResponse.truncated` exposes the check.
|
|
20
|
+
- `comparison.pairwise`: an unparseable judge reply now counts that call's cost
|
|
21
|
+
(and latency) in the errored score, as `judge` already did.
|
|
22
|
+
|
|
23
|
+
## 0.2.1 — 2026-10-14
|
|
24
|
+
|
|
25
|
+
Attach fixes found by the docs audit.
|
|
26
|
+
|
|
27
|
+
- `comparison.pairwise` built its check version (and so its cache key) from
|
|
28
|
+
`--judge-model` or the check's own `model:`, ignoring the attached project's
|
|
29
|
+
per-check override — the call itself already honoured it. Both now resolve through
|
|
30
|
+
the same precedence (`--judge-model` > center override > `model:` > default), so a
|
|
31
|
+
verdict cached under the override is found again and a changed override invalidates
|
|
32
|
+
the cache.
|
|
33
|
+
- Docstrings and this changelog no longer claim prompt targets are proxied when
|
|
34
|
+
attached; they never were (`providers:` + env keys serve the system under test).
|
|
35
|
+
- Known limitation, documented: when attached, `--judge-model some-typo/model` is
|
|
36
|
+
not validated up front — the center only rejects the provider at the first judge
|
|
37
|
+
call, which surfaces as an errored score. Standalone runs still fail fast.
|
|
38
|
+
|
|
5
39
|
## 0.2.0 — 2026-10-14
|
|
6
40
|
|
|
7
41
|
Attach a project to a center.
|
|
8
42
|
|
|
9
43
|
- `center:` in `evalstone.yaml` (`url`, `project`, `api_key_env` — an `es_…` ingest key)
|
|
10
|
-
routes judge
|
|
11
|
-
|
|
12
|
-
|
|
44
|
+
routes judge calls through the center's model proxy, so no judge key lives on the
|
|
45
|
+
machine running the suite; the project's judge settings pick the model (per-check
|
|
46
|
+
overrides, then the default), `--judge-model` still wins.
|
|
13
47
|
- Judge verdicts are cached in the center (`CenterCache`); a center hiccup is a cache
|
|
14
48
|
miss, never a failed run.
|
|
15
49
|
- `human` checks of an attached project look labels up in the center (`labels: center`,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: evalstone
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: The plug-and-play evals layer for LLM-based projects: datasets, checks, suites, runs and gates.
|
|
5
5
|
Project-URL: Homepage, https://evalstone.com
|
|
6
6
|
Project-URL: Documentation, https://evalstone.com/docs
|
|
@@ -8,7 +8,7 @@ import types
|
|
|
8
8
|
from importlib import import_module
|
|
9
9
|
from typing import TYPE_CHECKING, Any
|
|
10
10
|
|
|
11
|
-
__version__ = "0.2.
|
|
11
|
+
__version__ = "0.2.2"
|
|
12
12
|
|
|
13
13
|
# Exports resolve on first access (PEP 562) so that importing the package — which
|
|
14
14
|
# the pytest plugin does in every pytest session on the interpreter — costs
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
"""Attach a project to an Evalstone center (``center:`` in evalstone.yaml).
|
|
2
2
|
|
|
3
|
-
Attached,
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
``
|
|
3
|
+
Attached, judge calls go through the center's model proxy, which resolves the model
|
|
4
|
+
from the project's judge settings and dials the provider with the organization's key
|
|
5
|
+
server-side, so no judge key lives on the machine running the suite. Prompt targets
|
|
6
|
+
(the system under test) are NOT proxied: they keep using the local ``providers:`` and
|
|
7
|
+
their env keys. The judge cache lives in the center (one verdict serves every
|
|
8
|
+
machine), ``human`` checks look labels up there, and every ``evalstone run`` pushes
|
|
9
|
+
its report. ``judges:`` in the YAML is ignored while attached; ``providers:`` still
|
|
10
|
+
serves prompt targets.
|
|
9
11
|
"""
|
|
10
12
|
|
|
11
13
|
from __future__ import annotations
|
|
@@ -12,7 +12,7 @@ from typing import Any
|
|
|
12
12
|
|
|
13
13
|
from evalstone._json import sha256
|
|
14
14
|
from evalstone.checks._base import FunctionCheck, judge_model_for
|
|
15
|
-
from evalstone.checks.judge import _json_text, extract_json
|
|
15
|
+
from evalstone.checks.judge import DEFAULT_MAX_TOKENS, _json_text, explain_unparsed, extract_json
|
|
16
16
|
from evalstone.dataset import Sample
|
|
17
17
|
from evalstone.output import Output
|
|
18
18
|
from evalstone.providers import ChatMessage, ProviderError
|
|
@@ -46,6 +46,14 @@ def parse_winner(text: str) -> tuple[str, str]:
|
|
|
46
46
|
return winner, str(data.get("reason", "")).strip()
|
|
47
47
|
|
|
48
48
|
|
|
49
|
+
class _Unparsed(ValueError):
|
|
50
|
+
"""An unparseable judge reply, with what the call cost."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, message: str, cost_usd: float, latency_ms: float):
|
|
53
|
+
super().__init__(message)
|
|
54
|
+
self.cost_usd, self.latency_ms = cost_usd, latency_ms
|
|
55
|
+
|
|
56
|
+
|
|
49
57
|
def pairwise(
|
|
50
58
|
key: str,
|
|
51
59
|
rubric: str,
|
|
@@ -53,7 +61,7 @@ def pairwise(
|
|
|
53
61
|
baseline: Target | Mapping[str, Any],
|
|
54
62
|
model: str | None = None,
|
|
55
63
|
both_orders: bool = True,
|
|
56
|
-
max_tokens: int =
|
|
64
|
+
max_tokens: int = DEFAULT_MAX_TOKENS,
|
|
57
65
|
) -> FunctionCheck:
|
|
58
66
|
"""The candidate output beats the ``baseline`` output for the same sample.
|
|
59
67
|
|
|
@@ -66,17 +74,25 @@ def pairwise(
|
|
|
66
74
|
identity = {"v": 1, "rubric": rubric, "both_orders": both_orders}
|
|
67
75
|
unpriced: list[str] = [] # models of this sample's calls that the price table lacks
|
|
68
76
|
|
|
69
|
-
def ask(
|
|
77
|
+
def ask(
|
|
78
|
+
ctx: Context, chosen: str | None, sample_input: str, a: str, b: str
|
|
79
|
+
) -> tuple[str, str, float, float]:
|
|
70
80
|
user = USER.format(input=sample_input, a=a, b=b)
|
|
71
81
|
resp = ctx.providers.complete(
|
|
72
|
-
|
|
82
|
+
chosen,
|
|
73
83
|
{
|
|
74
84
|
"system": SYSTEM.format(rubric=rubric.strip()),
|
|
75
85
|
"messages": [ChatMessage(role="user", content=user)],
|
|
76
86
|
"max_tokens": max_tokens,
|
|
77
87
|
},
|
|
78
88
|
)
|
|
79
|
-
|
|
89
|
+
try:
|
|
90
|
+
winner, reason = parse_winner(resp.text)
|
|
91
|
+
except ValueError as exc:
|
|
92
|
+
# the failed call was still billed: carry its cost to the errored score
|
|
93
|
+
raise _Unparsed(
|
|
94
|
+
explain_unparsed(exc, resp, max_tokens), resp.cost_usd or 0.0, resp.latency_ms
|
|
95
|
+
) from None
|
|
80
96
|
if resp.cost_usd is None:
|
|
81
97
|
unpriced.append(resp.model)
|
|
82
98
|
return winner, reason, resp.cost_usd or 0.0, resp.latency_ms
|
|
@@ -85,8 +101,9 @@ def pairwise(
|
|
|
85
101
|
if ctx.providers is None:
|
|
86
102
|
return Score.errored("pairwise: no providers configured")
|
|
87
103
|
unpriced.clear()
|
|
104
|
+
chosen = judge_model_for(ctx, key, model) # resolved once: the version AND the call
|
|
88
105
|
try:
|
|
89
|
-
resolved = "/".join(ctx.providers.split(
|
|
106
|
+
resolved = "/".join(ctx.providers.split(chosen))
|
|
90
107
|
except ProviderError as exc:
|
|
91
108
|
return Score.errored(str(exc))
|
|
92
109
|
version = sha256({**identity, "model": resolved})[:16]
|
|
@@ -111,7 +128,7 @@ def pairwise(
|
|
|
111
128
|
if both_orders:
|
|
112
129
|
orders.append(("base_first", base.text, output.text))
|
|
113
130
|
for label, a, b in orders:
|
|
114
|
-
winner, reason, c, ms = ask(ctx, text, a, b)
|
|
131
|
+
winner, reason, c, ms = ask(ctx, chosen, text, a, b)
|
|
115
132
|
cost += c
|
|
116
133
|
latency += ms
|
|
117
134
|
candidate_is_a = label == "cand_first"
|
|
@@ -124,6 +141,12 @@ def pairwise(
|
|
|
124
141
|
reasons.append(reason)
|
|
125
142
|
except ProviderError as exc:
|
|
126
143
|
return Score.errored(str(exc), cost_usd=cost, latency_ms=latency)
|
|
144
|
+
except _Unparsed as exc:
|
|
145
|
+
return Score.errored(
|
|
146
|
+
f"pairwise: {exc}",
|
|
147
|
+
cost_usd=cost + exc.cost_usd,
|
|
148
|
+
latency_ms=latency + exc.latency_ms,
|
|
149
|
+
)
|
|
127
150
|
except ValueError as exc:
|
|
128
151
|
return Score.errored(f"pairwise: {exc}", cost_usd=cost, latency_ms=latency)
|
|
129
152
|
value = sum(points) / len(points)
|
|
@@ -16,11 +16,15 @@ from evalstone._json import dumps, sha256
|
|
|
16
16
|
from evalstone.checks._base import FunctionCheck, judge_model_for
|
|
17
17
|
from evalstone.dataset import Sample
|
|
18
18
|
from evalstone.output import Output
|
|
19
|
-
from evalstone.providers import ChatMessage, ProviderError
|
|
19
|
+
from evalstone.providers import ChatMessage, ChatResponse, ProviderError
|
|
20
20
|
from evalstone.score import CheckKind, Context, Score
|
|
21
21
|
|
|
22
22
|
PROMPT_VERSION = 1
|
|
23
23
|
|
|
24
|
+
# Room for a verdict plus a long reason: 256 cut real Haiku 5.5 replies mid-reason
|
|
25
|
+
# (4 of 10 on the rag-bot demo). The cap only bounds a runaway; it is not in the version.
|
|
26
|
+
DEFAULT_MAX_TOKENS = 1024
|
|
27
|
+
|
|
24
28
|
SYSTEM_TEMPLATE = """You are a strict evaluator grading one answer produced by an AI system.
|
|
25
29
|
|
|
26
30
|
RUBRIC:
|
|
@@ -67,6 +71,22 @@ def parse_verdict(text: str, scale: int) -> tuple[int, str]:
|
|
|
67
71
|
return score, str(data.get("reason", "")).strip()
|
|
68
72
|
|
|
69
73
|
|
|
74
|
+
def explain_unparsed(exc: ValueError, resp: ChatResponse, max_tokens: int) -> str:
|
|
75
|
+
"""The parse error, prefixed with the cause when the cap cut the JSON itself.
|
|
76
|
+
|
|
77
|
+
Only an unclosed or invalid object is blamed on the cap: a complete object that is
|
|
78
|
+
wrong (score out of range, no winner) is the model's answer, not a truncation.
|
|
79
|
+
"""
|
|
80
|
+
if resp.truncated:
|
|
81
|
+
try:
|
|
82
|
+
extract_json(resp.text)
|
|
83
|
+
except ValueError:
|
|
84
|
+
return (
|
|
85
|
+
f"reply truncated at max_tokens={max_tokens}; raise the check's max_tokens ({exc})"
|
|
86
|
+
)
|
|
87
|
+
return str(exc)
|
|
88
|
+
|
|
89
|
+
|
|
70
90
|
class JudgeCheck(FunctionCheck):
|
|
71
91
|
"""A ``FunctionCheck`` whose function calls a model; exposes the judge version hash."""
|
|
72
92
|
|
|
@@ -99,7 +119,7 @@ def judge(
|
|
|
99
119
|
pass_level: int | None = None,
|
|
100
120
|
threshold: float | None = None,
|
|
101
121
|
include_reference: bool = False,
|
|
102
|
-
max_tokens: int =
|
|
122
|
+
max_tokens: int = DEFAULT_MAX_TOKENS,
|
|
103
123
|
) -> JudgeCheck:
|
|
104
124
|
"""Grade an output against ``rubric`` with a model.
|
|
105
125
|
|
|
@@ -190,7 +210,9 @@ def judge(
|
|
|
190
210
|
n, reason = parse_verdict(resp.text, scale)
|
|
191
211
|
except ValueError as exc:
|
|
192
212
|
return Score.errored(
|
|
193
|
-
f"judge: {exc
|
|
213
|
+
f"judge: {explain_unparsed(exc, resp, max_tokens)}",
|
|
214
|
+
cost_usd=resp.cost_usd or 0.0,
|
|
215
|
+
latency_ms=resp.latency_ms,
|
|
194
216
|
)
|
|
195
217
|
score = to_score(
|
|
196
218
|
n,
|
|
@@ -217,4 +239,12 @@ def _verdict_reason(score: Score) -> str:
|
|
|
217
239
|
return str((score.raw or {}).get("verdict_reason", ""))
|
|
218
240
|
|
|
219
241
|
|
|
220
|
-
__all__ = [
|
|
242
|
+
__all__ = [
|
|
243
|
+
"DEFAULT_MAX_TOKENS",
|
|
244
|
+
"PROMPT_VERSION",
|
|
245
|
+
"JudgeCheck",
|
|
246
|
+
"explain_unparsed",
|
|
247
|
+
"extract_json",
|
|
248
|
+
"judge",
|
|
249
|
+
"parse_verdict",
|
|
250
|
+
]
|
|
@@ -145,6 +145,10 @@ def run(
|
|
|
145
145
|
if judge_model is not None:
|
|
146
146
|
try:
|
|
147
147
|
registry = cfg.provider_registry()
|
|
148
|
+
except CenterError as exc: # attached without a key: the center, not the flag
|
|
149
|
+
err.print(f"[red]{escape(str(exc))}[/red]")
|
|
150
|
+
raise typer.Exit(2) from None
|
|
151
|
+
try:
|
|
148
152
|
registry.get(registry.split(judge_model)[0])
|
|
149
153
|
except ProviderError as exc:
|
|
150
154
|
err.print(f"[red]--judge-model: {escape(str(exc))}[/red]")
|
|
@@ -42,6 +42,11 @@ class ChatResponse(BaseModel):
|
|
|
42
42
|
stop_reason: str | None = None
|
|
43
43
|
raw: Any = None
|
|
44
44
|
|
|
45
|
+
@property
|
|
46
|
+
def truncated(self) -> bool:
|
|
47
|
+
"""The provider stopped at the max_tokens cap (OpenAI-compatible, Anthropic, Gemini)."""
|
|
48
|
+
return (self.stop_reason or "").lower() in {"length", "max_tokens"}
|
|
49
|
+
|
|
45
50
|
|
|
46
51
|
class ProviderError(Exception):
|
|
47
52
|
"""A provider call failed (HTTP error, refusal, malformed response)."""
|
|
@@ -95,7 +95,8 @@ class ScoreCache(Protocol):
|
|
|
95
95
|
|
|
96
96
|
|
|
97
97
|
class MemoryCache:
|
|
98
|
-
"""An in-process ``ScoreCache
|
|
98
|
+
"""An in-process ``ScoreCache`` (the standalone default; attached projects use
|
|
99
|
+
``evalstone.center.CenterCache``)."""
|
|
99
100
|
|
|
100
101
|
def __init__(self) -> None:
|
|
101
102
|
self._data: dict[str, Score] = {}
|
|
@@ -312,6 +312,45 @@ def test_pairwise_honours_the_center_override(cfg):
|
|
|
312
312
|
assert json.loads(calls.calls[0].request.content)["model"] == "other/big"
|
|
313
313
|
|
|
314
314
|
|
|
315
|
+
@respx.mock
|
|
316
|
+
def test_pairwise_cache_key_follows_the_center_override(cfg):
|
|
317
|
+
"""The check version (and so the cache key) is built from the same resolved model as
|
|
318
|
+
the call: a verdict cached under the override is hit again, a changed override is a
|
|
319
|
+
different version and misses (regression: the key used ``ctx.judge_model or model``)."""
|
|
320
|
+
from evalstone.checks.comparison import pairwise
|
|
321
|
+
from evalstone.score import MemoryCache
|
|
322
|
+
|
|
323
|
+
calls = respx.post(f"{P}/model/complete").mock(
|
|
324
|
+
return_value=_verdict(text='{"winner": "A", "reason": "clearer"}')
|
|
325
|
+
)
|
|
326
|
+
client = CenterClient(cfg)
|
|
327
|
+
providers = CenterProviders(client, default_model="judge/small")
|
|
328
|
+
extra = {"center_client": client, "judge_models": {"better": "other/big"}}
|
|
329
|
+
cache = MemoryCache()
|
|
330
|
+
check = pairwise(
|
|
331
|
+
"better", "Which is clearer?", baseline={"s": "baseline answer"}, model="judge/small"
|
|
332
|
+
)
|
|
333
|
+
sample, output = Sample(id="s", input="q"), Output(value="candidate")
|
|
334
|
+
|
|
335
|
+
ctx = Context(providers=providers, extra=extra, cache=cache)
|
|
336
|
+
check.evaluate(sample, output, ctx)
|
|
337
|
+
filled = calls.call_count # both orders were asked once
|
|
338
|
+
assert len(cache._data) == 1
|
|
339
|
+
key_before = next(iter(cache._data))
|
|
340
|
+
assert check.evaluate(sample, output, ctx).raw["cached"] is True
|
|
341
|
+
assert calls.call_count == filled # served from the cache
|
|
342
|
+
|
|
343
|
+
ctx_other = Context(
|
|
344
|
+
providers=providers,
|
|
345
|
+
extra={**extra, "judge_models": {"better": "other/small"}},
|
|
346
|
+
cache=cache,
|
|
347
|
+
)
|
|
348
|
+
assert check.evaluate(sample, output, ctx_other).raw.get("cached") is not True
|
|
349
|
+
assert calls.call_count > filled # a changed override is a different version → miss
|
|
350
|
+
assert json.loads(calls.calls[-1].request.content)["model"] == "other/small"
|
|
351
|
+
assert len(cache._data) == 2 and key_before in cache._data
|
|
352
|
+
|
|
353
|
+
|
|
315
354
|
@respx.mock
|
|
316
355
|
def test_prompt_targets_stay_local_when_attached(tmp_path, monkeypatch):
|
|
317
356
|
from evalstone.providers import Providers
|
|
@@ -143,3 +143,18 @@ def test_show_markdown(in_dir):
|
|
|
143
143
|
"### Evalstone · `nightly` — ✅ gate passed"
|
|
144
144
|
)
|
|
145
145
|
assert "| `must_include` |" in res.output
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_missing_center_key_is_reported_as_the_center_not_the_flag(in_dir, monkeypatch):
|
|
149
|
+
"""Attached without EVALSTONE_API_KEY, ``--judge-model`` must not blame the flag."""
|
|
150
|
+
monkeypatch.delenv("EVALSTONE_API_KEY", raising=False)
|
|
151
|
+
runner.invoke(app, ["init", "--project", "demo"])
|
|
152
|
+
cfg = in_dir / "evalstone.yaml"
|
|
153
|
+
cfg.write_text(
|
|
154
|
+
cfg.read_text()
|
|
155
|
+
+ "center:\n url: http://center.test\n project: 11111111-2222-4333-8444-555555555555\n"
|
|
156
|
+
)
|
|
157
|
+
res = runner.invoke(app, ["run", "nightly", "--judge-model", "openai/gpt-x"])
|
|
158
|
+
assert res.exit_code == 2
|
|
159
|
+
assert "EVALSTONE_API_KEY" in res.output
|
|
160
|
+
assert "--judge-model" not in res.output
|
|
@@ -51,3 +51,17 @@ def test_every_action_snippet_uses_the_same_ref():
|
|
|
51
51
|
)
|
|
52
52
|
)
|
|
53
53
|
assert refs == {"action-v0"}, f"action refs in the docs: {sorted(refs)}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_site_changelog_is_generated_from_the_library_changelog():
|
|
57
|
+
"""The site's Changelog page is `scripts/sync-changelog.mjs` output committed next to
|
|
58
|
+
the docs; a release that bumps CHANGELOG.md without re-running it is caught here."""
|
|
59
|
+
changelog = (SITE_DOCS.parents[2].parent / "python" / "evalstone" / "CHANGELOG.md").read_text(
|
|
60
|
+
encoding="utf-8"
|
|
61
|
+
)
|
|
62
|
+
page = (SITE_DOCS / "evalstone" / "changelog.mdx").read_text(encoding="utf-8")
|
|
63
|
+
body = changelog[changelog.index("\n## ") + 1 :].rstrip()
|
|
64
|
+
assert "GENERATED by scripts/sync-changelog.mjs" in page
|
|
65
|
+
assert page.rstrip().endswith(body), (
|
|
66
|
+
"run `npm run build` (or scripts/sync-changelog.mjs) in the site"
|
|
67
|
+
)
|
|
@@ -16,9 +16,9 @@ class FakeProvider:
|
|
|
16
16
|
|
|
17
17
|
name = "openai"
|
|
18
18
|
|
|
19
|
-
def __init__(self, reply=None, fail=None, judge_reply=None):
|
|
19
|
+
def __init__(self, reply=None, fail=None, judge_reply=None, stop_reason=None):
|
|
20
20
|
self.reply, self.fail, self.calls = reply, fail, []
|
|
21
|
-
self.judge_reply = judge_reply
|
|
21
|
+
self.judge_reply, self.stop_reason = judge_reply, stop_reason
|
|
22
22
|
|
|
23
23
|
def complete(self, request):
|
|
24
24
|
self.calls.append(request)
|
|
@@ -41,6 +41,7 @@ class FakeProvider:
|
|
|
41
41
|
output_tokens=10,
|
|
42
42
|
cost_usd=0.001,
|
|
43
43
|
latency_ms=12.0,
|
|
44
|
+
stop_reason=self.stop_reason,
|
|
44
45
|
)
|
|
45
46
|
|
|
46
47
|
|
|
@@ -114,6 +115,44 @@ def test_judge_errors_are_never_passes():
|
|
|
114
115
|
assert no_model.error.startswith("no model given")
|
|
115
116
|
|
|
116
117
|
|
|
118
|
+
# What a real Haiku 5.5 reply looked like when max_tokens=256 cut it mid-reason.
|
|
119
|
+
CUT = '{"score": 4, "reason": "The answer directly answers the question with a concrete rule (30 da'
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@pytest.mark.parametrize("stop", ["length", "max_tokens", "MAX_TOKENS"])
|
|
123
|
+
def test_a_reply_cut_at_the_token_cap_says_so_and_never_passes(stop):
|
|
124
|
+
chk = judge("h", "r", max_tokens=300)
|
|
125
|
+
score = chk(S, Output(value="x"), ctx_with(FakeProvider(judge_reply=CUT, stop_reason=stop)))
|
|
126
|
+
assert score.passed is False and score.cost_usd == 0.001
|
|
127
|
+
assert score.error.startswith(
|
|
128
|
+
"judge: reply truncated at max_tokens=300; raise the check's max_tokens "
|
|
129
|
+
"(no JSON object in judge reply: "
|
|
130
|
+
)
|
|
131
|
+
assert "concrete rule (30 da" in score.error # the excerpt of what came back survives
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_truncation_only_explains_a_failed_parse():
|
|
135
|
+
# A complete verdict wins even if the provider then hit the cap.
|
|
136
|
+
full = ctx_with(
|
|
137
|
+
FakeProvider(judge_reply='{"score": 5, "reason": "ok"} and', stop_reason="length")
|
|
138
|
+
)
|
|
139
|
+
assert judge("h", "r")(S, Output(value="x"), full).passed is True
|
|
140
|
+
# A complete but wrong verdict at the cap is the model's answer, not a truncation.
|
|
141
|
+
wrong = ctx_with(
|
|
142
|
+
FakeProvider(judge_reply='{"score": 7, "reason": "x"} and', stop_reason="length")
|
|
143
|
+
)
|
|
144
|
+
assert judge("h", "r")(S, Output(value="x"), wrong).error == "judge: judge score 7 outside 1..5"
|
|
145
|
+
# A normal stop keeps the parse error as it was.
|
|
146
|
+
plain = ctx_with(FakeProvider(judge_reply=CUT, stop_reason="stop"))
|
|
147
|
+
assert "no JSON object" in judge("h", "r")(S, Output(value="x"), plain).error
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_judge_default_token_budget_fits_a_long_reason():
|
|
151
|
+
prov = FakeProvider()
|
|
152
|
+
judge("h", "r")(S, Output(value="30 days"), ctx_with(prov))
|
|
153
|
+
assert prov.calls[0].max_tokens == 1024
|
|
154
|
+
|
|
155
|
+
|
|
117
156
|
def test_judge_cache_makes_the_second_identical_call_free():
|
|
118
157
|
prov = FakeProvider()
|
|
119
158
|
ctx = ctx_with(prov)
|
|
@@ -244,3 +244,35 @@ def test_config_baseline_mapping_form_and_target_form(tmp_path):
|
|
|
244
244
|
c.providers.register("openai", JudgeFake(winner='{"winner": "A"}'))
|
|
245
245
|
run = suite.run(target, ctx=c)
|
|
246
246
|
assert run.summary.errors == 0 and run.summary.results == 2
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def test_pairwise_reply_cut_at_the_token_cap_says_so():
|
|
250
|
+
class Cut(JudgeFake):
|
|
251
|
+
def complete(self, request):
|
|
252
|
+
self.calls.append(request)
|
|
253
|
+
return ChatResponse(
|
|
254
|
+
text='{"winner": "A", "reason": "Answer A is more compl',
|
|
255
|
+
model=request.model,
|
|
256
|
+
cost_usd=0.001,
|
|
257
|
+
stop_reason="length",
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
fake = Cut()
|
|
261
|
+
score = pairwise("b", "r", baseline={"s": "x"})(S, Output(value="y"), ctx(fake))
|
|
262
|
+
assert score.passed is False
|
|
263
|
+
assert score.error.startswith("pairwise: reply truncated at max_tokens=1024")
|
|
264
|
+
assert fake.calls[0].max_tokens == 1024
|
|
265
|
+
assert score.cost_usd == pytest.approx(0.001) # the failed call was billed: it counts
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def test_pairwise_unparseable_second_order_keeps_every_call_cost():
|
|
269
|
+
replies = iter(['{"winner": "A", "reason": "x"}', "no verdict here"])
|
|
270
|
+
|
|
271
|
+
class Half(JudgeFake):
|
|
272
|
+
def complete(self, request):
|
|
273
|
+
self.calls.append(request)
|
|
274
|
+
return ChatResponse(text=next(replies), model=request.model, cost_usd=0.001)
|
|
275
|
+
|
|
276
|
+
score = pairwise("b", "r", baseline={"s": "x"})(S, Output(value="y"), ctx(Half()))
|
|
277
|
+
assert score.passed is False and "no JSON object" in score.error
|
|
278
|
+
assert score.cost_usd == pytest.approx(0.002)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|