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.
Files changed (57) hide show
  1. {evalstone-0.2.1 → evalstone-0.2.3}/CHANGELOG.md +34 -0
  2. {evalstone-0.2.1 → evalstone-0.2.3}/PKG-INFO +1 -1
  3. {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/__init__.py +1 -1
  4. {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/checks/comparison.py +25 -4
  5. {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/checks/judge.py +44 -6
  6. {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/providers/__init__.py +5 -0
  7. {evalstone-0.2.1 → evalstone-0.2.3}/tests/test_judge.py +58 -2
  8. {evalstone-0.2.1 → evalstone-0.2.3}/tests/test_rag_and_comparison.py +40 -0
  9. {evalstone-0.2.1 → evalstone-0.2.3}/.gitignore +0 -0
  10. {evalstone-0.2.1 → evalstone-0.2.3}/LICENSE +0 -0
  11. {evalstone-0.2.1 → evalstone-0.2.3}/README.md +0 -0
  12. {evalstone-0.2.1 → evalstone-0.2.3}/justfile +0 -0
  13. {evalstone-0.2.1 → evalstone-0.2.3}/pyproject.toml +0 -0
  14. {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/__main__.py +0 -0
  15. {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/_git.py +0 -0
  16. {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/_json.py +0 -0
  17. {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/center.py +0 -0
  18. {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/checks/__init__.py +0 -0
  19. {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/checks/_base.py +0 -0
  20. {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/checks/deterministic.py +0 -0
  21. {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/checks/human.py +0 -0
  22. {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/checks/rag.py +0 -0
  23. {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/checks/safety.py +0 -0
  24. {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/checks/trajectory.py +0 -0
  25. {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/cli.py +0 -0
  26. {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/config.py +0 -0
  27. {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/dataset.py +0 -0
  28. {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/gate.py +0 -0
  29. {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/mcp_server.py +0 -0
  30. {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/output.py +0 -0
  31. {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/providers/_http.py +0 -0
  32. {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/providers/anthropic_.py +0 -0
  33. {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/providers/gemini.py +0 -0
  34. {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/providers/openai_compat.py +0 -0
  35. {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/pytest_plugin.py +0 -0
  36. {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/run.py +0 -0
  37. {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/score.py +0 -0
  38. {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/suite.py +0 -0
  39. {evalstone-0.2.1 → evalstone-0.2.3}/src/evalstone/target.py +0 -0
  40. {evalstone-0.2.1 → evalstone-0.2.3}/tests/conftest.py +0 -0
  41. {evalstone-0.2.1 → evalstone-0.2.3}/tests/test_action.py +0 -0
  42. {evalstone-0.2.1 → evalstone-0.2.3}/tests/test_center.py +0 -0
  43. {evalstone-0.2.1 → evalstone-0.2.3}/tests/test_cli.py +0 -0
  44. {evalstone-0.2.1 → evalstone-0.2.3}/tests/test_config.py +0 -0
  45. {evalstone-0.2.1 → evalstone-0.2.3}/tests/test_dataset.py +0 -0
  46. {evalstone-0.2.1 → evalstone-0.2.3}/tests/test_deterministic.py +0 -0
  47. {evalstone-0.2.1 → evalstone-0.2.3}/tests/test_docs.py +0 -0
  48. {evalstone-0.2.1 → evalstone-0.2.3}/tests/test_human.py +0 -0
  49. {evalstone-0.2.1 → evalstone-0.2.3}/tests/test_judge_switching.py +0 -0
  50. {evalstone-0.2.1 → evalstone-0.2.3}/tests/test_mcp.py +0 -0
  51. {evalstone-0.2.1 → evalstone-0.2.3}/tests/test_providers.py +0 -0
  52. {evalstone-0.2.1 → evalstone-0.2.3}/tests/test_pytest_plugin.py +0 -0
  53. {evalstone-0.2.1 → evalstone-0.2.3}/tests/test_safety.py +0 -0
  54. {evalstone-0.2.1 → evalstone-0.2.3}/tests/test_score_and_checks.py +0 -0
  55. {evalstone-0.2.1 → evalstone-0.2.3}/tests/test_suite_run_gate.py +0 -0
  56. {evalstone-0.2.1 → evalstone-0.2.3}/tests/test_target.py +0 -0
  57. {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.1
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.1"
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 = 256,
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
- winner, reason = parse_winner(resp.text)
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 = 256,
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
- raw={**raw, "score": n, "scale": scale, "judge_version": judge_version},
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}", cost_usd=resp.cost_usd or 0.0, latency_ms=resp.latency_ms
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__ = ["PROMPT_VERSION", "JudgeCheck", "extract_json", "judge", "parse_verdict"]
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