evalstone 0.2.1__tar.gz → 0.2.3__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.1 → evalstone-0.2.3}/CHANGELOG.md +34 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/PKG-INFO +1 -1
- {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/__init__.py +1 -1
- {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/checks/comparison.py +25 -4
- {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/checks/judge.py +44 -6
- {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/providers/__init__.py +5 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/tests/test_judge.py +58 -2
- {evalstone-0.2.1 → evalstone-0.2.3}/tests/test_rag_and_comparison.py +40 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/.gitignore +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/LICENSE +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/README.md +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/justfile +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/pyproject.toml +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/__main__.py +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/_git.py +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/_json.py +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/center.py +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/checks/__init__.py +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/checks/_base.py +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/checks/deterministic.py +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/checks/human.py +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/checks/rag.py +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/checks/safety.py +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/checks/trajectory.py +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/cli.py +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/config.py +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/dataset.py +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/gate.py +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/mcp_server.py +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/output.py +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/providers/_http.py +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/providers/anthropic_.py +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/providers/gemini.py +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/providers/openai_compat.py +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/pytest_plugin.py +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/run.py +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/score.py +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/suite.py +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/target.py +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/tests/conftest.py +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/tests/test_action.py +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/tests/test_center.py +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/tests/test_cli.py +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/tests/test_config.py +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/tests/test_dataset.py +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/tests/test_deterministic.py +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/tests/test_docs.py +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/tests/test_human.py +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/tests/test_judge_switching.py +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/tests/test_mcp.py +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/tests/test_providers.py +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/tests/test_pytest_plugin.py +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/tests/test_safety.py +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/tests/test_score_and_checks.py +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/tests/test_suite_run_gate.py +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/tests/test_target.py +0 -0
- {evalstone-0.2.1 → evalstone-0.2.3}/tests/test_trajectory.py +0 -0
|
@@ -2,6 +2,40 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `python/evalstone`. Semantic versioning; tags `lib-vX.Y.Z`.
|
|
4
4
|
|
|
5
|
+
## 0.2.3 — 2026-10-14
|
|
6
|
+
|
|
7
|
+
Promote pins the model the verdicts were routed through, found on the demo project's first
|
|
8
|
+
real calibration.
|
|
9
|
+
|
|
10
|
+
- `judge` and `comparison.pairwise` record `raw.judge_model`: the `<provider>/<model>` the
|
|
11
|
+
verdict was routed through (for example `openrouter/anthropic/claude-haiku-5.5`), next to
|
|
12
|
+
`raw.model`, the id the provider reported (`anthropic/claude-haiku-5.5`). Verdicts served
|
|
13
|
+
from the cache carry it too.
|
|
14
|
+
- The center stamps only `raw.judge_model`, so the Judges page, calibrations and **Promote**
|
|
15
|
+
use the routed model. Before, it stamped `raw.model`, and Promote sent that
|
|
16
|
+
provider-reported id, which judge settings reject (`no provider named 'anthropic'`) or,
|
|
17
|
+
when a provider of that name existed, route elsewhere. Results pushed by an older library
|
|
18
|
+
are no longer stamped (a migration clears the old stamps): a version seen only from them
|
|
19
|
+
shows no model and cannot be promoted until a 0.2.3+ run pushes it.
|
|
20
|
+
|
|
21
|
+
## 0.2.2 — 2026-10-14
|
|
22
|
+
|
|
23
|
+
Judge replies cut off by the token cap, found by the first run against a real
|
|
24
|
+
vendor (Claude Haiku 5.5 via OpenRouter on the rag-bot demo: 4 of 10 verdicts
|
|
25
|
+
errored).
|
|
26
|
+
|
|
27
|
+
- `judge` and `comparison.pairwise` default `max_tokens` is now 1024 (was 256).
|
|
28
|
+
The cut replies needed 286–349 tokens: a verdict plus a long one-sentence reason.
|
|
29
|
+
`max_tokens` is not part of the judge version, so cached verdicts stay valid.
|
|
30
|
+
- A reply the provider stopped at the cap (`length`, `max_tokens`, `MAX_TOKENS`)
|
|
31
|
+
whose JSON is unclosed or invalid now errors with "reply truncated at
|
|
32
|
+
max_tokens=N; raise the check's max_tokens" followed by the original parse error and excerpt
|
|
33
|
+
instead of only "no JSON object in judge reply". A complete verdict at the cap is
|
|
34
|
+
accepted, and a complete but wrong one (score out of range) keeps its own error.
|
|
35
|
+
`ChatResponse.truncated` exposes the check.
|
|
36
|
+
- `comparison.pairwise`: an unparseable judge reply now counts that call's cost
|
|
37
|
+
(and latency) in the errored score, as `judge` already did.
|
|
38
|
+
|
|
5
39
|
## 0.2.1 — 2026-10-14
|
|
6
40
|
|
|
7
41
|
Attach fixes found by the docs audit.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: evalstone
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
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.3"
|
|
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
|
|
@@ -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
|
|
|
@@ -78,7 +86,13 @@ def pairwise(
|
|
|
78
86
|
"max_tokens": max_tokens,
|
|
79
87
|
},
|
|
80
88
|
)
|
|
81
|
-
|
|
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
|
|
82
96
|
if resp.cost_usd is None:
|
|
83
97
|
unpriced.append(resp.model)
|
|
84
98
|
return winner, reason, resp.cost_usd or 0.0, resp.latency_ms
|
|
@@ -101,7 +115,7 @@ def pairwise(
|
|
|
101
115
|
cached = ctx.cache.get(cache_key) if ctx.cache is not None else None
|
|
102
116
|
if cached is not None:
|
|
103
117
|
# The judge verdict is cached; the baseline was still produced (its cost counts).
|
|
104
|
-
raw = {**(cached.raw or {}), "cached": True}
|
|
118
|
+
raw = {**(cached.raw or {}), "judge_model": resolved, "cached": True}
|
|
105
119
|
return cached.model_copy(
|
|
106
120
|
update={"cost_usd": base.cost_usd, "latency_ms": base.latency_ms, "raw": raw}
|
|
107
121
|
)
|
|
@@ -127,6 +141,12 @@ def pairwise(
|
|
|
127
141
|
reasons.append(reason)
|
|
128
142
|
except ProviderError as exc:
|
|
129
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
|
+
)
|
|
130
150
|
except ValueError as exc:
|
|
131
151
|
return Score.errored(f"pairwise: {exc}", cost_usd=cost, latency_ms=latency)
|
|
132
152
|
value = sum(points) / len(points)
|
|
@@ -141,6 +161,7 @@ def pairwise(
|
|
|
141
161
|
"points": points,
|
|
142
162
|
"orders": len(points),
|
|
143
163
|
"judge_version": version,
|
|
164
|
+
"judge_model": resolved,
|
|
144
165
|
"unpriced": bool(unpriced),
|
|
145
166
|
},
|
|
146
167
|
)
|
|
@@ -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
|
|
|
@@ -159,7 +179,15 @@ def judge(
|
|
|
159
179
|
reason=f"{n}/{scale}: {reason}" if reason else f"{n}/{scale}",
|
|
160
180
|
cost_usd=cost,
|
|
161
181
|
latency_ms=ms,
|
|
162
|
-
|
|
182
|
+
# judge_model = `<provider>/<model>` as routed (what Promote pins); set here so
|
|
183
|
+
# a verdict served from an older cache entry carries it too
|
|
184
|
+
raw={
|
|
185
|
+
**raw,
|
|
186
|
+
"score": n,
|
|
187
|
+
"scale": scale,
|
|
188
|
+
"judge_version": judge_version,
|
|
189
|
+
"judge_model": resolved,
|
|
190
|
+
},
|
|
163
191
|
)
|
|
164
192
|
|
|
165
193
|
# The cache holds the model's raw verdict (level + reason), keyed by the
|
|
@@ -190,13 +218,15 @@ def judge(
|
|
|
190
218
|
n, reason = parse_verdict(resp.text, scale)
|
|
191
219
|
except ValueError as exc:
|
|
192
220
|
return Score.errored(
|
|
193
|
-
f"judge: {exc
|
|
221
|
+
f"judge: {explain_unparsed(exc, resp, max_tokens)}",
|
|
222
|
+
cost_usd=resp.cost_usd or 0.0,
|
|
223
|
+
latency_ms=resp.latency_ms,
|
|
194
224
|
)
|
|
195
225
|
score = to_score(
|
|
196
226
|
n,
|
|
197
227
|
reason,
|
|
198
228
|
raw={
|
|
199
|
-
"model": resp.model,
|
|
229
|
+
"model": resp.model, # what the provider reported (a bare id, maybe dated)
|
|
200
230
|
"tokens": [resp.input_tokens, resp.output_tokens],
|
|
201
231
|
"unpriced": resp.cost_usd is None,
|
|
202
232
|
"verdict_reason": reason,
|
|
@@ -217,4 +247,12 @@ def _verdict_reason(score: Score) -> str:
|
|
|
217
247
|
return str((score.raw or {}).get("verdict_reason", ""))
|
|
218
248
|
|
|
219
249
|
|
|
220
|
-
__all__ = [
|
|
250
|
+
__all__ = [
|
|
251
|
+
"DEFAULT_MAX_TOKENS",
|
|
252
|
+
"PROMPT_VERSION",
|
|
253
|
+
"JudgeCheck",
|
|
254
|
+
"explain_unparsed",
|
|
255
|
+
"extract_json",
|
|
256
|
+
"judge",
|
|
257
|
+
"parse_verdict",
|
|
258
|
+
]
|
|
@@ -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)."""
|
|
@@ -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)
|
|
@@ -249,3 +288,20 @@ def test_judge_version_includes_the_resolved_default_model():
|
|
|
249
288
|
b = chk(S, Output(value="Within 30 days."), ctx_with(prov, default="openai/judge-b"))
|
|
250
289
|
assert a.raw["judge_version"] != b.raw["judge_version"]
|
|
251
290
|
assert len(prov.calls) == 2 # different resolved model → different cache key
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def test_verdict_records_the_routed_model_fresh_and_cached():
|
|
294
|
+
"""`judge_model` is what Promote pins: `<provider>/<model>` as routed, not `resp.model`."""
|
|
295
|
+
from evalstone.score import Score
|
|
296
|
+
|
|
297
|
+
chk = judge("h", "r")
|
|
298
|
+
ctx = ctx_with(FakeProvider())
|
|
299
|
+
fresh = chk(S, Output(value="30 days"), ctx)
|
|
300
|
+
assert fresh.raw["judge_model"] == "openai/judge-model"
|
|
301
|
+
assert chk(S, Output(value="30 days"), ctx).raw["judge_model"] == "openai/judge-model"
|
|
302
|
+
# a cache entry written by an older library (no judge_model) still gets it on the way out
|
|
303
|
+
key, old = next(iter(ctx.cache._data.items()))
|
|
304
|
+
ctx.cache._data[key] = Score.model_validate(
|
|
305
|
+
{**old.model_dump(), "raw": {k: v for k, v in old.raw.items() if k != "judge_model"}}
|
|
306
|
+
)
|
|
307
|
+
assert chk(S, Output(value="30 days"), ctx).raw["judge_model"] == "openai/judge-model"
|
|
@@ -244,3 +244,43 @@ 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)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def test_pairwise_records_the_routed_model_fresh_and_cached():
|
|
282
|
+
chk = pairwise("b", "r", baseline={"s": "x"})
|
|
283
|
+
c = ctx(JudgeFake())
|
|
284
|
+
assert chk(S, Output(value="y"), c).raw["judge_model"] == "openai/judge"
|
|
285
|
+
cached = chk(S, Output(value="y"), c)
|
|
286
|
+
assert cached.raw["cached"] is True and cached.raw["judge_model"] == "openai/judge"
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|