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.
Files changed (57) hide show
  1. {evalstone-0.2.0 → evalstone-0.2.2}/CHANGELOG.md +37 -3
  2. {evalstone-0.2.0 → evalstone-0.2.2}/PKG-INFO +1 -1
  3. {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/__init__.py +1 -1
  4. {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/center.py +8 -6
  5. {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/checks/comparison.py +30 -7
  6. {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/checks/judge.py +34 -4
  7. {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/cli.py +4 -0
  8. {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/providers/__init__.py +5 -0
  9. {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/score.py +2 -1
  10. {evalstone-0.2.0 → evalstone-0.2.2}/tests/test_center.py +39 -0
  11. {evalstone-0.2.0 → evalstone-0.2.2}/tests/test_cli.py +15 -0
  12. {evalstone-0.2.0 → evalstone-0.2.2}/tests/test_docs.py +14 -0
  13. {evalstone-0.2.0 → evalstone-0.2.2}/tests/test_judge.py +41 -2
  14. {evalstone-0.2.0 → evalstone-0.2.2}/tests/test_rag_and_comparison.py +32 -0
  15. {evalstone-0.2.0 → evalstone-0.2.2}/.gitignore +0 -0
  16. {evalstone-0.2.0 → evalstone-0.2.2}/LICENSE +0 -0
  17. {evalstone-0.2.0 → evalstone-0.2.2}/README.md +0 -0
  18. {evalstone-0.2.0 → evalstone-0.2.2}/justfile +0 -0
  19. {evalstone-0.2.0 → evalstone-0.2.2}/pyproject.toml +0 -0
  20. {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/__main__.py +0 -0
  21. {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/_git.py +0 -0
  22. {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/_json.py +0 -0
  23. {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/checks/__init__.py +0 -0
  24. {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/checks/_base.py +0 -0
  25. {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/checks/deterministic.py +0 -0
  26. {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/checks/human.py +0 -0
  27. {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/checks/rag.py +0 -0
  28. {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/checks/safety.py +0 -0
  29. {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/checks/trajectory.py +0 -0
  30. {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/config.py +0 -0
  31. {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/dataset.py +0 -0
  32. {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/gate.py +0 -0
  33. {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/mcp_server.py +0 -0
  34. {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/output.py +0 -0
  35. {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/providers/_http.py +0 -0
  36. {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/providers/anthropic_.py +0 -0
  37. {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/providers/gemini.py +0 -0
  38. {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/providers/openai_compat.py +0 -0
  39. {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/pytest_plugin.py +0 -0
  40. {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/run.py +0 -0
  41. {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/suite.py +0 -0
  42. {evalstone-0.2.0 → evalstone-0.2.2}/src/evalstone/target.py +0 -0
  43. {evalstone-0.2.0 → evalstone-0.2.2}/tests/conftest.py +0 -0
  44. {evalstone-0.2.0 → evalstone-0.2.2}/tests/test_action.py +0 -0
  45. {evalstone-0.2.0 → evalstone-0.2.2}/tests/test_config.py +0 -0
  46. {evalstone-0.2.0 → evalstone-0.2.2}/tests/test_dataset.py +0 -0
  47. {evalstone-0.2.0 → evalstone-0.2.2}/tests/test_deterministic.py +0 -0
  48. {evalstone-0.2.0 → evalstone-0.2.2}/tests/test_human.py +0 -0
  49. {evalstone-0.2.0 → evalstone-0.2.2}/tests/test_judge_switching.py +0 -0
  50. {evalstone-0.2.0 → evalstone-0.2.2}/tests/test_mcp.py +0 -0
  51. {evalstone-0.2.0 → evalstone-0.2.2}/tests/test_providers.py +0 -0
  52. {evalstone-0.2.0 → evalstone-0.2.2}/tests/test_pytest_plugin.py +0 -0
  53. {evalstone-0.2.0 → evalstone-0.2.2}/tests/test_safety.py +0 -0
  54. {evalstone-0.2.0 → evalstone-0.2.2}/tests/test_score_and_checks.py +0 -0
  55. {evalstone-0.2.0 → evalstone-0.2.2}/tests/test_suite_run_gate.py +0 -0
  56. {evalstone-0.2.0 → evalstone-0.2.2}/tests/test_target.py +0 -0
  57. {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 and prompt-target calls through the center's model proxy, so no provider
11
- key lives on the machine running the suite; the project's judge settings pick the
12
- model (per-check overrides, then the default), `--judge-model` still wins.
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.0
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.0"
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, the library never holds a provider key: judge (and prompt-target) calls go
4
- through the center's model proxy, which resolves the model from the project's judge
5
- settings and dials the provider with the organization's key server-side. The judge
6
- cache lives in the center (one verdict serves every machine), ``human`` checks look
7
- labels up there, and every ``evalstone run`` pushes its report. ``providers:`` and
8
- ``judges:`` in the YAML remain the standalone path.
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 = 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
 
@@ -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(ctx: Context, sample_input: str, a: str, b: str) -> tuple[str, str, float, float]:
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
- judge_model_for(ctx, key, model),
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
- 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
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(ctx.judge_model or model))
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 = 256,
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}", cost_usd=resp.cost_usd or 0.0, latency_ms=resp.latency_ms
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__ = ["PROMPT_VERSION", "JudgeCheck", "extract_json", "judge", "parse_verdict"]
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``. The center-backed cache arrives with Phase 3."""
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