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.
Files changed (78) hide show
  1. synthbench/__init__.py +16 -0
  2. synthbench/__main__.py +5 -0
  3. synthbench/adapter.py +131 -0
  4. synthbench/anomaly.py +503 -0
  5. synthbench/baseline_floors.py +213 -0
  6. synthbench/baselines.py +902 -0
  7. synthbench/cli.py +2929 -0
  8. synthbench/config_id.py +424 -0
  9. synthbench/contamination.py +783 -0
  10. synthbench/convergence/__init__.py +67 -0
  11. synthbench/convergence/baseline.py +172 -0
  12. synthbench/convergence/bootstrap.py +60 -0
  13. synthbench/convergence/cli_report.py +556 -0
  14. synthbench/convergence/curves.py +111 -0
  15. synthbench/convergence/real_sampling.py +146 -0
  16. synthbench/convergence/thresholds.py +49 -0
  17. synthbench/datasets/__init__.py +37 -0
  18. synthbench/datasets/base.py +147 -0
  19. synthbench/datasets/eurobarometer.py +335 -0
  20. synthbench/datasets/globalopinionqa.py +252 -0
  21. synthbench/datasets/gss.py +323 -0
  22. synthbench/datasets/michigan.py +505 -0
  23. synthbench/datasets/ntia.py +357 -0
  24. synthbench/datasets/opinionsqa.py +412 -0
  25. synthbench/datasets/pewtech.py +334 -0
  26. synthbench/datasets/policy.py +142 -0
  27. synthbench/datasets/subpop.py +302 -0
  28. synthbench/datasets/wvs.py +229 -0
  29. synthbench/findings.py +988 -0
  30. synthbench/holdout.py +347 -0
  31. synthbench/human_distributions.py +243 -0
  32. synthbench/leaderboard.py +715 -0
  33. synthbench/leaderboard_pr.py +274 -0
  34. synthbench/metrics/__init__.py +38 -0
  35. synthbench/metrics/composite.py +72 -0
  36. synthbench/metrics/conditioning.py +39 -0
  37. synthbench/metrics/distributional.py +41 -0
  38. synthbench/metrics/ranking.py +37 -0
  39. synthbench/metrics/refusal.py +270 -0
  40. synthbench/metrics/subgroup.py +58 -0
  41. synthbench/private_holdout.py +240 -0
  42. synthbench/providers/__init__.py +43 -0
  43. synthbench/providers/_parsing.py +150 -0
  44. synthbench/providers/_retry.py +107 -0
  45. synthbench/providers/base.py +212 -0
  46. synthbench/providers/http.py +108 -0
  47. synthbench/providers/majority_baseline.py +23 -0
  48. synthbench/providers/ollama.py +109 -0
  49. synthbench/providers/openrouter.py +144 -0
  50. synthbench/providers/population_baseline.py +87 -0
  51. synthbench/providers/random_baseline.py +24 -0
  52. synthbench/providers/raw_anthropic.py +149 -0
  53. synthbench/providers/raw_gemini.py +145 -0
  54. synthbench/providers/raw_openai.py +139 -0
  55. synthbench/providers/synthpanel.py +978 -0
  56. synthbench/publish.py +2772 -0
  57. synthbench/r2_upload.py +178 -0
  58. synthbench/recompute.py +235 -0
  59. synthbench/report.py +493 -0
  60. synthbench/run_hash.py +111 -0
  61. synthbench/run_validity.py +232 -0
  62. synthbench/runner.py +834 -0
  63. synthbench/stats.py +1492 -0
  64. synthbench/submission.py +284 -0
  65. synthbench/submission_pr.py +436 -0
  66. synthbench/submit_adapter.py +748 -0
  67. synthbench/suite.py +424 -0
  68. synthbench/suites/__init__.py +94 -0
  69. synthbench/topics.py +256 -0
  70. synthbench/user_config.py +409 -0
  71. synthbench/validation.py +1556 -0
  72. synthbench/visualize.py +455 -0
  73. synthbench_eval-0.4.0.dist-info/METADATA +279 -0
  74. synthbench_eval-0.4.0.dist-info/RECORD +78 -0
  75. synthbench_eval-0.4.0.dist-info/WHEEL +5 -0
  76. synthbench_eval-0.4.0.dist-info/entry_points.txt +2 -0
  77. synthbench_eval-0.4.0.dist-info/licenses/LICENSE +21 -0
  78. synthbench_eval-0.4.0.dist-info/top_level.txt +1 -0
@@ -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=(",", ":"))