synthbench-eval 0.4.0__py3-none-any.whl
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.
- synthbench/__init__.py +16 -0
- synthbench/__main__.py +5 -0
- synthbench/adapter.py +131 -0
- synthbench/anomaly.py +503 -0
- synthbench/baseline_floors.py +213 -0
- synthbench/baselines.py +902 -0
- synthbench/cli.py +2929 -0
- synthbench/config_id.py +424 -0
- synthbench/contamination.py +783 -0
- synthbench/convergence/__init__.py +67 -0
- synthbench/convergence/baseline.py +172 -0
- synthbench/convergence/bootstrap.py +60 -0
- synthbench/convergence/cli_report.py +556 -0
- synthbench/convergence/curves.py +111 -0
- synthbench/convergence/real_sampling.py +146 -0
- synthbench/convergence/thresholds.py +49 -0
- synthbench/datasets/__init__.py +37 -0
- synthbench/datasets/base.py +147 -0
- synthbench/datasets/eurobarometer.py +335 -0
- synthbench/datasets/globalopinionqa.py +252 -0
- synthbench/datasets/gss.py +323 -0
- synthbench/datasets/michigan.py +505 -0
- synthbench/datasets/ntia.py +357 -0
- synthbench/datasets/opinionsqa.py +412 -0
- synthbench/datasets/pewtech.py +334 -0
- synthbench/datasets/policy.py +142 -0
- synthbench/datasets/subpop.py +302 -0
- synthbench/datasets/wvs.py +229 -0
- synthbench/findings.py +988 -0
- synthbench/holdout.py +347 -0
- synthbench/human_distributions.py +243 -0
- synthbench/leaderboard.py +715 -0
- synthbench/leaderboard_pr.py +274 -0
- synthbench/metrics/__init__.py +38 -0
- synthbench/metrics/composite.py +72 -0
- synthbench/metrics/conditioning.py +39 -0
- synthbench/metrics/distributional.py +41 -0
- synthbench/metrics/ranking.py +37 -0
- synthbench/metrics/refusal.py +270 -0
- synthbench/metrics/subgroup.py +58 -0
- synthbench/private_holdout.py +240 -0
- synthbench/providers/__init__.py +43 -0
- synthbench/providers/_parsing.py +150 -0
- synthbench/providers/_retry.py +107 -0
- synthbench/providers/base.py +212 -0
- synthbench/providers/http.py +108 -0
- synthbench/providers/majority_baseline.py +23 -0
- synthbench/providers/ollama.py +109 -0
- synthbench/providers/openrouter.py +144 -0
- synthbench/providers/population_baseline.py +87 -0
- synthbench/providers/random_baseline.py +24 -0
- synthbench/providers/raw_anthropic.py +149 -0
- synthbench/providers/raw_gemini.py +145 -0
- synthbench/providers/raw_openai.py +139 -0
- synthbench/providers/synthpanel.py +978 -0
- synthbench/publish.py +2772 -0
- synthbench/r2_upload.py +178 -0
- synthbench/recompute.py +235 -0
- synthbench/report.py +493 -0
- synthbench/run_hash.py +111 -0
- synthbench/run_validity.py +232 -0
- synthbench/runner.py +834 -0
- synthbench/stats.py +1492 -0
- synthbench/submission.py +284 -0
- synthbench/submission_pr.py +436 -0
- synthbench/submit_adapter.py +748 -0
- synthbench/suite.py +424 -0
- synthbench/suites/__init__.py +94 -0
- synthbench/topics.py +256 -0
- synthbench/user_config.py +409 -0
- synthbench/validation.py +1556 -0
- synthbench/visualize.py +455 -0
- synthbench_eval-0.4.0.dist-info/METADATA +279 -0
- synthbench_eval-0.4.0.dist-info/RECORD +78 -0
- synthbench_eval-0.4.0.dist-info/WHEEL +5 -0
- synthbench_eval-0.4.0.dist-info/entry_points.txt +2 -0
- synthbench_eval-0.4.0.dist-info/licenses/LICENSE +21 -0
- synthbench_eval-0.4.0.dist-info/top_level.txt +1 -0
synthbench/submission.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""HTTP client helpers for `POST /submit` and `GET /submit/<id>` (sb-ymux).
|
|
2
|
+
|
|
3
|
+
Extracted from the `synthbench submit` CLI so the `synthbench run --submit`
|
|
4
|
+
flow and `synthbench submit` share a single, tested implementation of the
|
|
5
|
+
Worker round-trip. Neither command needs to know about httpx, auth headers,
|
|
6
|
+
or status-code → hint mapping directly.
|
|
7
|
+
|
|
8
|
+
The functions here return small dataclasses rather than raising, so the
|
|
9
|
+
caller can layer its own CLI-specific exit codes on top without catching
|
|
10
|
+
HTTP-specific exceptions everywhere.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Any, Callable, Optional, Union
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
from synthbench import __version__
|
|
24
|
+
|
|
25
|
+
DEFAULT_API_URL = "https://api.synthbench.org"
|
|
26
|
+
|
|
27
|
+
# Terminal states the Worker's GET /submit/<id> can return. Anything else
|
|
28
|
+
# (currently just "validating") keeps the --wait poller in its loop.
|
|
29
|
+
TERMINAL_STATUSES = frozenset({"published", "rejected"})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class SubmitSuccess:
|
|
34
|
+
submission_id: int
|
|
35
|
+
status: str
|
|
36
|
+
payload: dict[str, Any]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class SubmitError:
|
|
41
|
+
status_code: int
|
|
42
|
+
message: str
|
|
43
|
+
hint: str = ""
|
|
44
|
+
payload: Optional[dict] = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
SubmitOutcome = Union[SubmitSuccess, SubmitError]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def resolve_api_key(cli_value: Optional[str]) -> Union[str, SubmitError]:
|
|
51
|
+
"""Return the API key to use, or a structured error for missing/malformed
|
|
52
|
+
keys. Mirrors the validation the `synthbench submit` command already did;
|
|
53
|
+
factored out so `run --submit` can't accidentally diverge."""
|
|
54
|
+
key = cli_value or os.environ.get("SYNTHBENCH_API_KEY")
|
|
55
|
+
if not key:
|
|
56
|
+
return SubmitError(
|
|
57
|
+
status_code=0,
|
|
58
|
+
message="no API key provided. Pass --api-key or set SYNTHBENCH_API_KEY.",
|
|
59
|
+
hint="Generate one at https://synthbench.org/account.",
|
|
60
|
+
)
|
|
61
|
+
if not key.startswith("sb_"):
|
|
62
|
+
return SubmitError(
|
|
63
|
+
status_code=0,
|
|
64
|
+
message="API key must start with 'sb_'. Did you paste a JWT by mistake?",
|
|
65
|
+
)
|
|
66
|
+
return key
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def resolve_api_base(cli_value: Optional[str]) -> str:
|
|
70
|
+
"""Pick the Worker base URL, normalizing trailing slashes."""
|
|
71
|
+
base = cli_value or os.environ.get("SYNTHBENCH_API_URL") or DEFAULT_API_URL
|
|
72
|
+
return base.rstrip("/")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _hint_for(status: int) -> str:
|
|
76
|
+
"""Translate a Worker HTTP error into a user-visible next step. Matches
|
|
77
|
+
the set of hints the `synthbench submit` CLI already surfaced — kept in
|
|
78
|
+
one place so both callers print consistent error messages."""
|
|
79
|
+
if status == 401:
|
|
80
|
+
return "API key was rejected. Check it's not revoked at /account."
|
|
81
|
+
if status == 403:
|
|
82
|
+
return (
|
|
83
|
+
"API key lacks the 'submit' scope. Generate a new key with submit access."
|
|
84
|
+
)
|
|
85
|
+
if status == 413:
|
|
86
|
+
return "submission exceeded the 2 MB cap."
|
|
87
|
+
if status == 429:
|
|
88
|
+
return "per-key rate limit (60/hr) exceeded. Wait or use another key."
|
|
89
|
+
if status >= 500:
|
|
90
|
+
return "server error. Re-run; if it persists, check status.synthbench.org."
|
|
91
|
+
return ""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def post_submission(
|
|
95
|
+
*,
|
|
96
|
+
body: str,
|
|
97
|
+
api_key: str,
|
|
98
|
+
api_base: str,
|
|
99
|
+
timeout: int = 60,
|
|
100
|
+
user_agent: Optional[str] = None,
|
|
101
|
+
) -> SubmitOutcome:
|
|
102
|
+
"""POST a canonical submission JSON body to `<api_base>/submit`.
|
|
103
|
+
|
|
104
|
+
`body` MUST be the exact string that was saved to disk — we never
|
|
105
|
+
re-serialize because the Worker's Tier-1 check and downstream config_id
|
|
106
|
+
hash depend on the canonical bytes.
|
|
107
|
+
"""
|
|
108
|
+
endpoint = f"{api_base}/submit"
|
|
109
|
+
ua = user_agent or f"synthbench-cli/{__version__}"
|
|
110
|
+
try:
|
|
111
|
+
resp = httpx.post(
|
|
112
|
+
endpoint,
|
|
113
|
+
content=body,
|
|
114
|
+
headers={
|
|
115
|
+
"Authorization": f"Bearer {api_key}",
|
|
116
|
+
"Content-Type": "application/json",
|
|
117
|
+
"User-Agent": ua,
|
|
118
|
+
},
|
|
119
|
+
timeout=timeout,
|
|
120
|
+
)
|
|
121
|
+
except httpx.HTTPError as exc:
|
|
122
|
+
return SubmitError(status_code=0, message=f"network failure: {exc}")
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
payload = resp.json()
|
|
126
|
+
except ValueError:
|
|
127
|
+
payload = {"raw": resp.text}
|
|
128
|
+
|
|
129
|
+
if resp.status_code in (200, 202):
|
|
130
|
+
sub_id = payload.get("submission_id")
|
|
131
|
+
status = payload.get("status", "unknown")
|
|
132
|
+
if not isinstance(sub_id, int):
|
|
133
|
+
return SubmitError(
|
|
134
|
+
status_code=resp.status_code,
|
|
135
|
+
message="server returned an unexpected response shape (no submission_id).",
|
|
136
|
+
payload=payload if isinstance(payload, dict) else None,
|
|
137
|
+
)
|
|
138
|
+
return SubmitSuccess(submission_id=sub_id, status=status, payload=payload)
|
|
139
|
+
|
|
140
|
+
error_msg = (
|
|
141
|
+
payload.get("error", resp.text or "unknown error")
|
|
142
|
+
if isinstance(payload, dict)
|
|
143
|
+
else (resp.text or "unknown error")
|
|
144
|
+
)
|
|
145
|
+
return SubmitError(
|
|
146
|
+
status_code=resp.status_code,
|
|
147
|
+
message=str(error_msg),
|
|
148
|
+
hint=_hint_for(resp.status_code),
|
|
149
|
+
payload=payload if isinstance(payload, dict) else None,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass
|
|
154
|
+
class StatusResult:
|
|
155
|
+
status: str
|
|
156
|
+
rejection_reason: Optional[str]
|
|
157
|
+
leaderboard_entry_id: Optional[str]
|
|
158
|
+
payload: dict
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass
|
|
162
|
+
class PollTimeout:
|
|
163
|
+
last_status: str
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclass
|
|
167
|
+
class PollError:
|
|
168
|
+
status_code: int
|
|
169
|
+
message: str
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
PollOutcome = Union[StatusResult, PollTimeout, PollError]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def poll_submission_status(
|
|
176
|
+
*,
|
|
177
|
+
submission_id: int,
|
|
178
|
+
api_key: str,
|
|
179
|
+
api_base: str,
|
|
180
|
+
interval: float = 5.0,
|
|
181
|
+
timeout: float = 600.0,
|
|
182
|
+
http_timeout: int = 30,
|
|
183
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
184
|
+
now: Callable[[], float] = time.monotonic,
|
|
185
|
+
on_poll: Optional[Callable[[str], None]] = None,
|
|
186
|
+
) -> PollOutcome:
|
|
187
|
+
"""Block on `GET /submit/<id>` until the row hits a terminal state or
|
|
188
|
+
`timeout` elapses.
|
|
189
|
+
|
|
190
|
+
Yields control back to `sleep()` between polls so tests can stub time
|
|
191
|
+
without actually waiting. Returns a `StatusResult` on terminal, a
|
|
192
|
+
`PollTimeout` when the budget is exhausted (the caller surfaces exit
|
|
193
|
+
code 2), or a `PollError` for unrecoverable HTTP failures.
|
|
194
|
+
"""
|
|
195
|
+
endpoint = f"{api_base}/submit/{submission_id}"
|
|
196
|
+
headers = {
|
|
197
|
+
"Authorization": f"Bearer {api_key}",
|
|
198
|
+
"Accept": "application/json",
|
|
199
|
+
"User-Agent": f"synthbench-cli/{__version__}",
|
|
200
|
+
}
|
|
201
|
+
deadline = now() + timeout
|
|
202
|
+
last_status = "unknown"
|
|
203
|
+
while True:
|
|
204
|
+
try:
|
|
205
|
+
resp = httpx.get(endpoint, headers=headers, timeout=http_timeout)
|
|
206
|
+
except httpx.HTTPError as exc:
|
|
207
|
+
return PollError(status_code=0, message=f"network failure: {exc}")
|
|
208
|
+
|
|
209
|
+
if resp.status_code == 200:
|
|
210
|
+
try:
|
|
211
|
+
payload = resp.json()
|
|
212
|
+
except ValueError:
|
|
213
|
+
return PollError(
|
|
214
|
+
status_code=200,
|
|
215
|
+
message="server returned non-JSON status body",
|
|
216
|
+
)
|
|
217
|
+
status = str(payload.get("status", "unknown"))
|
|
218
|
+
last_status = status
|
|
219
|
+
if on_poll is not None:
|
|
220
|
+
on_poll(status)
|
|
221
|
+
if status in TERMINAL_STATUSES:
|
|
222
|
+
return StatusResult(
|
|
223
|
+
status=status,
|
|
224
|
+
rejection_reason=payload.get("rejection_reason"),
|
|
225
|
+
leaderboard_entry_id=payload.get("leaderboard_entry_id"),
|
|
226
|
+
payload=payload if isinstance(payload, dict) else {},
|
|
227
|
+
)
|
|
228
|
+
else:
|
|
229
|
+
# 4xx: no point retrying — api key rejected, row gone, etc. 5xx we
|
|
230
|
+
# *could* retry, but a Worker 5xx is usually Supabase flaking and
|
|
231
|
+
# the benchmark JSON is safe on disk, so a clean surface-and-exit
|
|
232
|
+
# is kinder than silent spin.
|
|
233
|
+
try:
|
|
234
|
+
payload = resp.json()
|
|
235
|
+
except ValueError:
|
|
236
|
+
payload = {}
|
|
237
|
+
msg = (
|
|
238
|
+
payload.get("error", resp.text or "unknown error")
|
|
239
|
+
if isinstance(payload, dict)
|
|
240
|
+
else resp.text or "unknown error"
|
|
241
|
+
)
|
|
242
|
+
return PollError(status_code=resp.status_code, message=str(msg))
|
|
243
|
+
|
|
244
|
+
if now() >= deadline:
|
|
245
|
+
return PollTimeout(last_status=last_status)
|
|
246
|
+
sleep(interval)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def read_run_body(path: str) -> Union[str, SubmitError]:
|
|
250
|
+
"""Load a saved benchmark JSON file. Separate from `post_submission` so
|
|
251
|
+
a run that generated the file in-memory can skip the round-trip through
|
|
252
|
+
disk."""
|
|
253
|
+
from pathlib import Path
|
|
254
|
+
|
|
255
|
+
p = Path(path)
|
|
256
|
+
try:
|
|
257
|
+
text = p.read_text()
|
|
258
|
+
except OSError as exc:
|
|
259
|
+
return SubmitError(status_code=0, message=f"cannot read {p}: {exc}")
|
|
260
|
+
try:
|
|
261
|
+
json.loads(text)
|
|
262
|
+
except json.JSONDecodeError as exc:
|
|
263
|
+
return SubmitError(status_code=0, message=f"{p} is not valid JSON: {exc}")
|
|
264
|
+
return text
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def inject_submit_message(body: str, message: Optional[str]) -> str:
|
|
268
|
+
"""Stamp `--submit-message` onto an in-memory submission body.
|
|
269
|
+
|
|
270
|
+
Stored at top level (`submit_message`) rather than under `config` so it
|
|
271
|
+
can't affect the canonical config_id hash, which only reads a fixed set
|
|
272
|
+
of config keys. Empty/whitespace-only messages are ignored so the field
|
|
273
|
+
is never added as a no-op placeholder.
|
|
274
|
+
"""
|
|
275
|
+
if not message or not message.strip():
|
|
276
|
+
return body
|
|
277
|
+
try:
|
|
278
|
+
data = json.loads(body)
|
|
279
|
+
except json.JSONDecodeError:
|
|
280
|
+
return body
|
|
281
|
+
if not isinstance(data, dict):
|
|
282
|
+
return body
|
|
283
|
+
data["submit_message"] = message
|
|
284
|
+
return json.dumps(data, sort_keys=False, separators=(",", ":"))
|