evalstone 0.1.0__tar.gz → 0.2.0__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.1.0 → evalstone-0.2.0}/.gitignore +3 -0
  2. {evalstone-0.1.0 → evalstone-0.2.0}/CHANGELOG.md +46 -0
  3. {evalstone-0.1.0 → evalstone-0.2.0}/PKG-INFO +3 -3
  4. {evalstone-0.1.0 → evalstone-0.2.0}/README.md +2 -2
  5. {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/__init__.py +1 -1
  6. evalstone-0.2.0/src/evalstone/center.py +253 -0
  7. {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/checks/_base.py +8 -0
  8. {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/checks/comparison.py +13 -4
  9. {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/checks/human.py +32 -1
  10. {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/checks/judge.py +4 -3
  11. {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/cli.py +58 -3
  12. {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/config.py +67 -12
  13. {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/gate.py +24 -1
  14. {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/mcp_server.py +4 -0
  15. {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/providers/__init__.py +31 -5
  16. {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/providers/_http.py +10 -2
  17. {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/providers/anthropic_.py +1 -1
  18. {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/run.py +5 -0
  19. {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/score.py +9 -0
  20. {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/suite.py +13 -1
  21. evalstone-0.2.0/tests/test_center.py +343 -0
  22. {evalstone-0.1.0 → evalstone-0.2.0}/tests/test_docs.py +20 -0
  23. {evalstone-0.1.0 → evalstone-0.2.0}/tests/test_human.py +1 -1
  24. evalstone-0.2.0/tests/test_judge_switching.py +317 -0
  25. {evalstone-0.1.0 → evalstone-0.2.0}/tests/test_rag_and_comparison.py +5 -2
  26. {evalstone-0.1.0 → evalstone-0.2.0}/LICENSE +0 -0
  27. {evalstone-0.1.0 → evalstone-0.2.0}/justfile +0 -0
  28. {evalstone-0.1.0 → evalstone-0.2.0}/pyproject.toml +0 -0
  29. {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/__main__.py +0 -0
  30. {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/_git.py +0 -0
  31. {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/_json.py +0 -0
  32. {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/checks/__init__.py +0 -0
  33. {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/checks/deterministic.py +0 -0
  34. {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/checks/rag.py +0 -0
  35. {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/checks/safety.py +0 -0
  36. {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/checks/trajectory.py +0 -0
  37. {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/dataset.py +0 -0
  38. {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/output.py +0 -0
  39. {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/providers/gemini.py +0 -0
  40. {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/providers/openai_compat.py +0 -0
  41. {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/pytest_plugin.py +0 -0
  42. {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/target.py +0 -0
  43. {evalstone-0.1.0 → evalstone-0.2.0}/tests/conftest.py +0 -0
  44. {evalstone-0.1.0 → evalstone-0.2.0}/tests/test_action.py +0 -0
  45. {evalstone-0.1.0 → evalstone-0.2.0}/tests/test_cli.py +0 -0
  46. {evalstone-0.1.0 → evalstone-0.2.0}/tests/test_config.py +0 -0
  47. {evalstone-0.1.0 → evalstone-0.2.0}/tests/test_dataset.py +0 -0
  48. {evalstone-0.1.0 → evalstone-0.2.0}/tests/test_deterministic.py +0 -0
  49. {evalstone-0.1.0 → evalstone-0.2.0}/tests/test_judge.py +0 -0
  50. {evalstone-0.1.0 → evalstone-0.2.0}/tests/test_mcp.py +0 -0
  51. {evalstone-0.1.0 → evalstone-0.2.0}/tests/test_providers.py +0 -0
  52. {evalstone-0.1.0 → evalstone-0.2.0}/tests/test_pytest_plugin.py +0 -0
  53. {evalstone-0.1.0 → evalstone-0.2.0}/tests/test_safety.py +0 -0
  54. {evalstone-0.1.0 → evalstone-0.2.0}/tests/test_score_and_checks.py +0 -0
  55. {evalstone-0.1.0 → evalstone-0.2.0}/tests/test_suite_run_gate.py +0 -0
  56. {evalstone-0.1.0 → evalstone-0.2.0}/tests/test_target.py +0 -0
  57. {evalstone-0.1.0 → evalstone-0.2.0}/tests/test_trajectory.py +0 -0
@@ -42,3 +42,6 @@ npm-debug.log*
42
42
 
43
43
  # ── Evalstone run reports (written by `evalstone run`) ──────────────────
44
44
  **/evalstone-report.json
45
+
46
+ # ── Celery beat state (written by `just beat`) ───────────────────────────
47
+ **/celerybeat-schedule*
@@ -2,6 +2,52 @@
2
2
 
3
3
  All notable changes to `python/evalstone`. Semantic versioning; tags `lib-vX.Y.Z`.
4
4
 
5
+ ## 0.2.0 — 2026-10-14
6
+
7
+ Attach a project to a center.
8
+
9
+ - `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.
13
+ - Judge verdicts are cached in the center (`CenterCache`); a center hiccup is a cache
14
+ miss, never a failed run.
15
+ - `human` checks of an attached project look labels up in the center (`labels: center`,
16
+ the default when attached; a file path still works).
17
+ - `evalstone run` pushes its report to the center (`push_runs: false` to opt out); a
18
+ failed push warns and leaves the exit code alone.
19
+ - Prompt targets (the system under test) always use the local `providers:` + env keys,
20
+ even when attached — the center proxy is for judges only. The MCP server honours the
21
+ attach too.
22
+
23
+ ## 0.1.1 — 2026-10-13
24
+
25
+ Judge switching and cost honesty.
26
+
27
+ - **Named providers**: a `providers:` entry may name any adapter (`adapter:
28
+ openai | anthropic | gemini`), so `openrouter:`, `ollama:` and `openai:` can
29
+ coexist and a judge model reads `openrouter/anthropic/claude-haiku-5-5`.
30
+ Unknown provider names and adapters are reported with the known ones. A
31
+ named entry never falls back to the adapter's default key variable (one
32
+ vendor's key is never sent to another); a 401/403 without a key says so.
33
+ - **`--judge-model provider/model`** on `evalstone run` and `gate` overrides
34
+ every judge's and pairwise comparison's model for that run — never the
35
+ target under test (`Context.judge_model`; `Config.context(judge_model=…)`).
36
+ Validated before the run starts; a typo exits 2.
37
+ - **`short_circuit: true`** on a suite skips the judge checks on a sample that
38
+ a contract check (deterministic, safety, trajectory — not a human label,
39
+ which is a quality verdict the judge should still be compared against)
40
+ already failed. The skipped result is a failure, not an error
41
+ (`Score.skipped`, `RunSummary.skipped`, `CheckSummary.skipped`), so no paid
42
+ call is spent on an output that already failed and the error-rate gate is
43
+ not tripped. Off by default. Turning it on changes pass rates (a skipped
44
+ judge counts as a failure), so re-take your baseline when you enable it.
45
+ - **Cheap-first ordering** now includes safety rules and human labels, not only
46
+ deterministic and trajectory checks.
47
+ - **Gate**: with `max_cost_usd` set, a `WARN` line counts results from models
48
+ outside the price table (they count as $0). Gemini models priced from the
49
+ official table (2026-10-09); OpenAI models are set under `prices:`.
50
+
5
51
  ## 0.1.0 — 2026-10-13
6
52
 
7
53
  The first usable release: the hero path from `evalstone.yaml` to a gated run.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: evalstone
3
- Version: 0.1.0
3
+ Version: 0.2.0
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
@@ -65,8 +65,8 @@ evalstone gate nightly # exit 1 when the gate fails (CI)
65
65
  ```
66
66
 
67
67
  A fuller example — a small retrieval bot with a gated suite — lives in the
68
- repository under `samples/rag-bot` (`git clone` it, `pip install -e
69
- python/evalstone`, then `evalstone run nightly` inside the sample).
68
+ repository under `samples/rag-bot` (`git clone` it, then `evalstone run nightly`
69
+ inside the sample).
70
70
 
71
71
  Judges and prompt targets need a provider key in the environment
72
72
  (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, or any
@@ -28,8 +28,8 @@ evalstone gate nightly # exit 1 when the gate fails (CI)
28
28
  ```
29
29
 
30
30
  A fuller example — a small retrieval bot with a gated suite — lives in the
31
- repository under `samples/rag-bot` (`git clone` it, `pip install -e
32
- python/evalstone`, then `evalstone run nightly` inside the sample).
31
+ repository under `samples/rag-bot` (`git clone` it, then `evalstone run nightly`
32
+ inside the sample).
33
33
 
34
34
  Judges and prompt targets need a provider key in the environment
35
35
  (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, or any
@@ -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.1.0"
11
+ __version__ = "0.2.0"
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
@@ -0,0 +1,253 @@
1
+ """Attach a project to an Evalstone center (``center:`` in evalstone.yaml).
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.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from typing import Any
15
+
16
+ import httpx
17
+ from pydantic import BaseModel
18
+
19
+ from evalstone.providers import ChatRequest, ChatResponse, ProviderError
20
+ from evalstone.score import MemoryCache, Score
21
+
22
+ DEFAULT_KEY_ENV = "EVALSTONE_API_KEY"
23
+
24
+
25
+ class CenterConfig(BaseModel):
26
+ url: str
27
+ project: str # the center project id (uuid)
28
+ api_key_env: str = DEFAULT_KEY_ENV # an ingest key (es_…) or a user token
29
+ timeout: float = 120.0 # a judge call waits on the provider behind the proxy
30
+ cache_timeout: float = 10.0 # cache and label lookups: a slow center is a miss, fast
31
+ push_runs: bool = True # `evalstone run` pushes its report
32
+ cache: bool = True # judge verdicts read/written in the center
33
+
34
+ def api_key(self) -> str | None:
35
+ return os.environ.get(self.api_key_env) or None
36
+
37
+
38
+ class CenterError(ProviderError):
39
+ """The center refused or could not serve a request."""
40
+
41
+
42
+ class CenterClient:
43
+ """Thin HTTP client for the center's project endpoints."""
44
+
45
+ def __init__(self, config: CenterConfig, *, client: Any = None) -> None:
46
+ self.config = config
47
+ key = config.api_key()
48
+ if not key:
49
+ raise CenterError(
50
+ f"center: no API key — set {config.api_key_env} (an ingest key from the "
51
+ "project's settings) in the environment"
52
+ )
53
+ self._client = client or httpx.Client(
54
+ base_url=config.url.rstrip("/"),
55
+ timeout=config.timeout,
56
+ headers={"Authorization": f"Bearer {key}"},
57
+ )
58
+ self._owns = client is None
59
+
60
+ def close(self) -> None:
61
+ if self._owns:
62
+ self._client.close()
63
+
64
+ # ── transport ────────────────────────────────────────────────────────
65
+
66
+ def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
67
+ try:
68
+ return self._client.request(method, path, **kwargs)
69
+ except httpx.HTTPError as exc:
70
+ raise CenterError(f"center: {type(exc).__name__}: {exc}") from exc
71
+
72
+ @staticmethod
73
+ def _detail(resp: httpx.Response) -> str:
74
+ try:
75
+ body = resp.json()
76
+ except ValueError:
77
+ body = None
78
+ detail = body.get("detail") if isinstance(body, dict) else body
79
+ return str(detail)[:300] if detail else resp.text[:300]
80
+
81
+ def _json(self, method: str, path: str, **kwargs: Any) -> Any:
82
+ resp = self._request(method, path, **kwargs)
83
+ if resp.status_code >= 400:
84
+ raise CenterError(f"center: HTTP {resp.status_code}: {self._detail(resp)}")
85
+ try:
86
+ return resp.json()
87
+ except ValueError as exc:
88
+ raise CenterError(f"center: non-JSON response: {resp.text[:200]!r}") from exc
89
+
90
+ def _p(self, suffix: str) -> str:
91
+ return f"/projects/{self.config.project}{suffix}"
92
+
93
+ # ── endpoints ────────────────────────────────────────────────────────
94
+
95
+ def whoami(self) -> dict[str, Any]:
96
+ return self._json("GET", "/ingest/whoami")
97
+
98
+ def judge_settings(self) -> dict[str, Any]:
99
+ return self._json("GET", self._p("/judge-settings"))
100
+
101
+ def complete(
102
+ self, model: str | None, request: ChatRequest, *, check_key: str | None = None
103
+ ) -> ChatResponse:
104
+ """The proxy call: `model` is `<provider>/<model>` (or None → the center picks)."""
105
+ body = {
106
+ "model": model,
107
+ "check_key": check_key,
108
+ "system": request.system,
109
+ "messages": [m.model_dump() for m in request.messages],
110
+ "max_tokens": request.max_tokens,
111
+ "temperature": request.temperature,
112
+ }
113
+ data = self._json("POST", self._p("/model/complete"), json=body)
114
+ return ChatResponse.model_validate(data)
115
+
116
+ def cache_get(self, key: str) -> Score | None:
117
+ resp = self._request(
118
+ "GET", self._p(f"/judge-cache/{key}"), timeout=self.config.cache_timeout
119
+ )
120
+ if resp.status_code == 404:
121
+ return None
122
+ if resp.status_code >= 400:
123
+ raise CenterError(f"center: HTTP {resp.status_code}: {self._detail(resp)}")
124
+ return Score.model_validate(resp.json()["score"])
125
+
126
+ def cache_set(self, key: str, score: Score) -> None:
127
+ judge_version = (
128
+ (score.raw or {}).get("judge_version") if isinstance(score.raw, dict) else None
129
+ )
130
+ self._json(
131
+ "PUT",
132
+ self._p(f"/judge-cache/{key}"),
133
+ json={"score": score.model_dump(mode="json"), "judge_version": judge_version},
134
+ timeout=self.config.cache_timeout,
135
+ )
136
+
137
+ def label_lookup(
138
+ self, sample_id: str, output_hash: str, check: str | None
139
+ ) -> dict[str, Any] | None:
140
+ params = {"sample_key": sample_id, "output_hash": output_hash}
141
+ if check is not None:
142
+ params["check"] = check
143
+ resp = self._request(
144
+ "GET", self._p("/labels/lookup"), params=params, timeout=self.config.cache_timeout
145
+ )
146
+ if resp.status_code == 404:
147
+ return None
148
+ if resp.status_code >= 400:
149
+ raise CenterError(f"center: HTTP {resp.status_code}: {self._detail(resp)}")
150
+ return resp.json()
151
+
152
+ def push_run(self, report: dict[str, Any]) -> tuple[dict[str, Any], bool]:
153
+ """Store a run report; returns (run, created) — 200 means the center had it already."""
154
+ resp = self._request("POST", self._p("/runs"), json=report)
155
+ if resp.status_code >= 400:
156
+ raise CenterError(f"center: HTTP {resp.status_code}: {self._detail(resp)}")
157
+ return resp.json(), resp.status_code == 201
158
+
159
+
160
+ # ── the Providers surface, backed by the proxy ───────────────────────────
161
+
162
+
163
+ class RemoteProvider:
164
+ """One named provider entry of the center (a `Provider` whose calls go through the proxy)."""
165
+
166
+ def __init__(self, name: str, client: CenterClient) -> None:
167
+ self.name = name
168
+ self._client = client
169
+
170
+ def complete(self, request: ChatRequest) -> ChatResponse:
171
+ return self._client.complete(f"{self.name}/{request.model}", request)
172
+
173
+
174
+ class CenterProviders:
175
+ """Drop-in for ``evalstone.providers.Providers`` when a project is attached.
176
+
177
+ ``split``/``get``/``complete`` match the registry the judge check and prompt targets
178
+ call; the default model comes from the center's judge settings, so a check without
179
+ its own ``model`` resolves there.
180
+ """
181
+
182
+ def __init__(self, client: CenterClient, *, default_model: str | None = None) -> None:
183
+ self.client = client
184
+ self.default_model = default_model
185
+ self._instances: dict[str, RemoteProvider] = {}
186
+
187
+ def split(self, model: str | None) -> tuple[str, str]:
188
+ model = model or self.default_model
189
+ if not model:
190
+ raise ProviderError(
191
+ "no model given and the center project has no default judge model "
192
+ "(set it in the project's judge settings)"
193
+ )
194
+ if "/" not in model:
195
+ raise ProviderError(f"model must be '<provider>/<model>', got {model!r}")
196
+ name, _, bare = model.partition("/")
197
+ return name, bare
198
+
199
+ def get(self, name: str) -> RemoteProvider:
200
+ if name not in self._instances:
201
+ self._instances[name] = RemoteProvider(name, self.client)
202
+ return self._instances[name]
203
+
204
+ def complete(self, model: str | None, request_without_model: dict[str, Any]) -> ChatResponse:
205
+ name, bare = self.split(model)
206
+ req = ChatRequest(model=bare, **request_without_model)
207
+ return self.client.complete(f"{name}/{bare}", req)
208
+
209
+ def close(self) -> None:
210
+ self.client.close()
211
+
212
+
213
+ class CenterCache:
214
+ """A ``ScoreCache`` in the center, with an in-process layer so a run asks once per key.
215
+
216
+ A center hiccup is a miss (a judge then pays for the call); it never fails the run.
217
+ """
218
+
219
+ def __init__(self, client: CenterClient) -> None:
220
+ self._client = client
221
+ self._local = MemoryCache()
222
+ self.errors: list[str] = []
223
+
224
+ def get(self, key: str) -> Score | None:
225
+ hit = self._local.get(key)
226
+ if hit is not None:
227
+ return hit
228
+ try:
229
+ score = self._client.cache_get(key)
230
+ except Exception as exc:
231
+ self.errors.append(f"{type(exc).__name__}: {exc}")
232
+ return None
233
+ if score is not None:
234
+ self._local.set(key, score)
235
+ return score
236
+
237
+ def set(self, key: str, score: Score) -> None:
238
+ self._local.set(key, score)
239
+ try:
240
+ self._client.cache_set(key, score)
241
+ except Exception as exc:
242
+ self.errors.append(f"{type(exc).__name__}: {exc}")
243
+
244
+
245
+ __all__ = [
246
+ "DEFAULT_KEY_ENV",
247
+ "CenterCache",
248
+ "CenterClient",
249
+ "CenterConfig",
250
+ "CenterError",
251
+ "CenterProviders",
252
+ "RemoteProvider",
253
+ ]
@@ -151,3 +151,11 @@ def check(
151
151
 
152
152
 
153
153
  __all__ = ["CheckFn", "FunctionCheck", "Registry", "check", "registry"]
154
+
155
+
156
+ def judge_model_for(ctx: Any, key: str, model: str | None) -> str | None:
157
+ """Which model a judge-style check uses: ``--judge-model`` (``ctx.judge_model``) beats the
158
+ attached project's per-check override (``ctx.extra["judge_models"][key]``) beats the
159
+ check's own ``model``. ``None`` falls through to the registry's default."""
160
+ overrides = (getattr(ctx, "extra", None) or {}).get("judge_models") or {}
161
+ return getattr(ctx, "judge_model", None) or overrides.get(key) or model
@@ -11,7 +11,7 @@ from collections.abc import Mapping
11
11
  from typing import Any
12
12
 
13
13
  from evalstone._json import sha256
14
- from evalstone.checks._base import FunctionCheck
14
+ from evalstone.checks._base import FunctionCheck, judge_model_for
15
15
  from evalstone.checks.judge import _json_text, extract_json
16
16
  from evalstone.dataset import Sample
17
17
  from evalstone.output import Output
@@ -64,11 +64,12 @@ def pairwise(
64
64
  """
65
65
  base_target: Target = baseline if isinstance(baseline, Target) else RecordedTarget(baseline)
66
66
  identity = {"v": 1, "rubric": rubric, "both_orders": both_orders}
67
+ unpriced: list[str] = [] # models of this sample's calls that the price table lacks
67
68
 
68
69
  def ask(ctx: Context, sample_input: str, a: str, b: str) -> tuple[str, str, float, float]:
69
70
  user = USER.format(input=sample_input, a=a, b=b)
70
71
  resp = ctx.providers.complete(
71
- model,
72
+ judge_model_for(ctx, key, model),
72
73
  {
73
74
  "system": SYSTEM.format(rubric=rubric.strip()),
74
75
  "messages": [ChatMessage(role="user", content=user)],
@@ -76,13 +77,16 @@ def pairwise(
76
77
  },
77
78
  )
78
79
  winner, reason = parse_winner(resp.text)
80
+ if resp.cost_usd is None:
81
+ unpriced.append(resp.model)
79
82
  return winner, reason, resp.cost_usd or 0.0, resp.latency_ms
80
83
 
81
84
  def fn(sample: Sample, output: Output, ctx: Context) -> Score:
82
85
  if ctx.providers is None:
83
86
  return Score.errored("pairwise: no providers configured")
87
+ unpriced.clear()
84
88
  try:
85
- resolved = "/".join(ctx.providers.split(model))
89
+ resolved = "/".join(ctx.providers.split(ctx.judge_model or model))
86
90
  except ProviderError as exc:
87
91
  return Score.errored(str(exc))
88
92
  version = sha256({**identity, "model": resolved})[:16]
@@ -130,7 +134,12 @@ def pairwise(
130
134
  reason=f"candidate {verdict}: " + " | ".join(r for r in reasons if r),
131
135
  cost_usd=cost,
132
136
  latency_ms=latency,
133
- raw={"points": points, "orders": len(points), "judge_version": version},
137
+ raw={
138
+ "points": points,
139
+ "orders": len(points),
140
+ "judge_version": version,
141
+ "unpriced": bool(unpriced),
142
+ },
134
143
  )
135
144
  if ctx.cache is not None:
136
145
  ctx.cache.set(cache_key, score)
@@ -85,8 +85,11 @@ def human(
85
85
  """Score from a person's label for this (sample, output); unlabelled → error.
86
86
 
87
87
  ``check`` selects labels recorded for a specific rubric (``evalstone label
88
- --check``); ``None`` uses the overall grade.
88
+ --check``); ``None`` uses the overall grade. ``labels="center"`` (what an attached
89
+ project gets by default) looks the label up in the center instead of a file.
89
90
  """
91
+ if labels == "center":
92
+ return FunctionCheck(key, _center_lookup(check), kind=CheckKind.HUMAN)
90
93
  store_path = Path(labels)
91
94
  cached: dict[str, Any] = {}
92
95
 
@@ -121,4 +124,32 @@ def human(
121
124
  return FunctionCheck(key, fn, kind=CheckKind.HUMAN)
122
125
 
123
126
 
127
+ def _center_lookup(check: str | None):
128
+ def fn(sample: Sample, output: Output, ctx: Any) -> Score:
129
+ client = (ctx.extra or {}).get("center_client") if ctx is not None else None
130
+ if client is None:
131
+ return Score.errored("human: labels=center but the run is not attached to a center")
132
+ try:
133
+ found = client.label_lookup(sample.id, output_hash(output), check)
134
+ except Exception as exc: # a center hiccup is an error score, never a pass
135
+ return Score.errored(f"human: center lookup failed: {exc}")
136
+ if found is None:
137
+ return Score.errored(
138
+ f"no human label in the center for {sample.id} @ {output_hash(output)}"
139
+ )
140
+ reason = found.get("notes") or ("labelled pass" if found["passed"] else "labelled fail")
141
+ return Score(
142
+ value=found["value"],
143
+ passed=found["passed"],
144
+ reason=reason,
145
+ raw={
146
+ "labeler": found.get("labeler", ""),
147
+ "grade": found.get("raw"),
148
+ "at": found.get("labeled_at"),
149
+ },
150
+ )
151
+
152
+ return fn
153
+
154
+
124
155
  __all__ = ["Label", "LabelStore", "human", "output_hash"]
@@ -13,7 +13,7 @@ import re
13
13
  from typing import Any
14
14
 
15
15
  from evalstone._json import dumps, sha256
16
- from evalstone.checks._base import FunctionCheck
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
19
  from evalstone.providers import ChatMessage, ProviderError
@@ -134,8 +134,9 @@ def judge(
134
134
  providers = ctx.providers
135
135
  if providers is None:
136
136
  return Score.errored("judge: no providers configured (ctx.providers is None)")
137
+ chosen = judge_model_for(ctx, key, model)
137
138
  try:
138
- resolved = "/".join(providers.split(model))
139
+ resolved = "/".join(providers.split(chosen))
139
140
  except ProviderError as exc:
140
141
  return Score.errored(str(exc))
141
142
  judge_version = sha256({**identity, "model": resolved})[:16]
@@ -176,7 +177,7 @@ def judge(
176
177
  )
177
178
  try:
178
179
  resp = providers.complete(
179
- model,
180
+ chosen,
180
181
  {
181
182
  "system": system,
182
183
  "messages": [ChatMessage(role="user", content=user)],
@@ -14,8 +14,11 @@ from rich.markup import escape
14
14
  from rich.table import Table
15
15
 
16
16
  from evalstone import __version__
17
+ from evalstone.center import CenterError
17
18
  from evalstone.config import CONFIG_FILENAME, Config
19
+ from evalstone.providers import ProviderError
18
20
  from evalstone.run import DEFAULT_FAILURES, Run, compare
21
+ from evalstone.score import Context
19
22
 
20
23
  app = typer.Typer(
21
24
  name="evalstone",
@@ -28,6 +31,11 @@ err = Console(stderr=True, soft_wrap=True) # never wrap paths inside error mess
28
31
 
29
32
  ConfigOpt = typer.Option("evalstone.yaml", "--config", "-c", help="Path to evalstone.yaml")
30
33
  ReportOpt = typer.Option(None, "--report", "-r", help="Where to write the JSON report")
34
+ JudgeModelOpt = typer.Option(
35
+ None,
36
+ "--judge-model",
37
+ help="Override every judge's model for this run (provider/model), e.g. to compare judges",
38
+ )
31
39
  BaselineOpt = typer.Option(
32
40
  None, "--baseline", "-b", help="A previous report to gate regressions against"
33
41
  )
@@ -124,36 +132,83 @@ def run(
124
132
  baseline: Path | None = BaselineOpt,
125
133
  failures: int = FailuresOpt,
126
134
  strict: bool = typer.Option(False, "--strict", help="Exit 1 when the gate fails"),
135
+ judge_model: str | None = JudgeModelOpt,
127
136
  ) -> None:
128
137
  """Run a suite, print the table, write the JSON report."""
129
138
  cfg = _load(config)
130
139
  try:
131
140
  s, target = cfg.suite(suite)
132
- except SUITE_ERRORS as exc:
141
+ except (*SUITE_ERRORS, CenterError) as exc:
133
142
  err.print(f"[red]{escape(str(exc))}[/red]")
134
143
  raise typer.Exit(2) from None
135
144
  base = _load_run(baseline) if baseline else None
145
+ if judge_model is not None:
146
+ try:
147
+ registry = cfg.provider_registry()
148
+ registry.get(registry.split(judge_model)[0])
149
+ except ProviderError as exc:
150
+ err.print(f"[red]--judge-model: {escape(str(exc))}[/red]")
151
+ raise typer.Exit(2) from None
152
+ try:
153
+ ctx = cfg.context(judge_model=judge_model)
154
+ except CenterError as exc:
155
+ err.print(f"[red]{escape(str(exc))}[/red]")
156
+ raise typer.Exit(2) from None
136
157
  with console.status(f"running {suite} over {len(s.dataset)} samples…"):
137
- result = s.run(target, baseline=base, trigger="cli", ctx=cfg.context())
158
+ result = s.run(target, baseline=base, trigger="cli", ctx=ctx)
138
159
  console.print(_run_table(result))
160
+ _warn_cache_errors(ctx)
139
161
  _print_failures(result, failures)
140
162
  _print_gate(result)
141
163
  out = report or (cfg.root / cfg.report)
142
164
  result.to_json(out)
143
165
  console.print(f"\nreport → {out}")
166
+ _push_to_center(cfg, result)
144
167
  if strict and result.gate_passed is False:
145
168
  raise typer.Exit(1)
146
169
 
147
170
 
171
+ def _warn_cache_errors(ctx: Context) -> None:
172
+ """Say so when the center cache was unreachable: those verdicts were paid for."""
173
+ errors = getattr(ctx.cache, "errors", None)
174
+ if errors:
175
+ err.print(
176
+ f"[yellow]center cache: {len(errors)} request(s) failed "
177
+ f"(first: {escape(errors[0][:160])}) — judges paid for those verdicts[/yellow]"
178
+ )
179
+
180
+
181
+ def _push_to_center(cfg: Config, result: Run) -> None:
182
+ """Attached projects store every run in the center; a push failure warns, never fails."""
183
+ if cfg.center is None or not cfg.center.push_runs:
184
+ return
185
+ try:
186
+ stored, created = cfg.center_client().push_run(result.model_dump(mode="json"))
187
+ except Exception as exc:
188
+ err.print(f"[yellow]center: run not pushed — {escape(str(exc))}[/yellow]")
189
+ return
190
+ verb = "stored" if created else "already stored"
191
+ console.print(f"center → run {stored.get('id', '?')} {verb} ({cfg.center.url})")
192
+
193
+
148
194
  @app.command()
149
195
  def gate(
150
196
  suite: str = typer.Argument(..., help="Suite name from evalstone.yaml"),
151
197
  config: str = ConfigOpt,
152
198
  report: Path | None = ReportOpt,
153
199
  baseline: Path | None = BaselineOpt,
200
+ judge_model: str | None = JudgeModelOpt,
154
201
  ) -> None:
155
202
  """Run a suite and exit 1 if its gate fails (for CI)."""
156
- run(suite=suite, config=config, report=report, baseline=baseline, failures=10, strict=True)
203
+ run(
204
+ suite=suite,
205
+ config=config,
206
+ report=report,
207
+ baseline=baseline,
208
+ failures=10,
209
+ strict=True,
210
+ judge_model=judge_model,
211
+ )
157
212
 
158
213
 
159
214
  @app.command("compare")