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/cli.py
ADDED
|
@@ -0,0 +1,2929 @@
|
|
|
1
|
+
"""SynthBench CLI — synthbench run, synthbench report."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
from synthbench import __version__
|
|
13
|
+
|
|
14
|
+
# sb-ymux: re-exported so tests that monkeypatch SYNTHBENCH_API_URL + read the
|
|
15
|
+
# fallback constant don't need a second import path.
|
|
16
|
+
from synthbench.submission import DEFAULT_API_URL
|
|
17
|
+
|
|
18
|
+
# Model aliases for convenience.
|
|
19
|
+
#
|
|
20
|
+
# These must be REAL, currently-served model IDs — a chimeric or retired ID
|
|
21
|
+
# either hard-errors at request time or silently benchmarks a different
|
|
22
|
+
# model than the leaderboard row claims.
|
|
23
|
+
MODEL_ALIASES = {
|
|
24
|
+
"haiku": "claude-haiku-4-5-20251001",
|
|
25
|
+
# Real Sonnet 4.5 snapshot (the previous "-20241022" suffix belonged to
|
|
26
|
+
# a different, retired model — the combination never existed).
|
|
27
|
+
"sonnet": "claude-sonnet-4-5-20250929",
|
|
28
|
+
# Real Opus 4 ID has no "-0-" infix.
|
|
29
|
+
"opus": "claude-opus-4-20250514",
|
|
30
|
+
"gpt-4o": "gpt-4o",
|
|
31
|
+
"gpt-4o-mini": "gpt-4o-mini",
|
|
32
|
+
# GA Gemini 2.5 IDs (the "-preview-*" snapshots are retired).
|
|
33
|
+
"gemini-flash": "gemini-2.5-flash",
|
|
34
|
+
"gemini-flash-lite": "gemini-2.5-flash-lite",
|
|
35
|
+
"gemini-pro": "gemini-2.5-pro",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# Providers whose constructor accepts a model identifier / temperature.
|
|
39
|
+
_MODEL_PROVIDERS = frozenset(
|
|
40
|
+
{"raw-anthropic", "raw-openai", "raw-gemini", "openrouter", "ollama", "synthpanel"}
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Providers that can actually honour a reasoning-effort level. Everything
|
|
44
|
+
# else must hard-error on --effort (mirroring the --temperature strictness
|
|
45
|
+
# below): a leaderboard row tagged "high effort" for a provider that never
|
|
46
|
+
# threaded the knob would be untrue metadata.
|
|
47
|
+
_EFFORT_PROVIDERS = frozenset(
|
|
48
|
+
{"raw-anthropic", "raw-openai", "raw-gemini", "openrouter"}
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
EFFORT_CHOICES = ("low", "medium", "high")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _provider_kwargs(
|
|
55
|
+
provider_name,
|
|
56
|
+
*,
|
|
57
|
+
model=None,
|
|
58
|
+
url=None,
|
|
59
|
+
temperature=None,
|
|
60
|
+
prompt_template=None,
|
|
61
|
+
effort=None,
|
|
62
|
+
elicitation=None,
|
|
63
|
+
):
|
|
64
|
+
"""Build constructor kwargs appropriate for *provider_name*.
|
|
65
|
+
|
|
66
|
+
Provider constructors no longer swallow unknown kwargs, so the CLI must
|
|
67
|
+
only pass options each provider actually supports — and must hard-error
|
|
68
|
+
(instead of silently ignoring) when the user passes an option the
|
|
69
|
+
selected provider cannot honour. A silently dropped --temperature is
|
|
70
|
+
exactly how leaderboard rows ended up labeled with temperatures that
|
|
71
|
+
never applied.
|
|
72
|
+
"""
|
|
73
|
+
kwargs = {}
|
|
74
|
+
if provider_name in _MODEL_PROVIDERS and model is not None:
|
|
75
|
+
kwargs["model"] = model
|
|
76
|
+
if url is not None:
|
|
77
|
+
if provider_name == "http":
|
|
78
|
+
kwargs["url"] = url
|
|
79
|
+
elif provider_name == "ollama":
|
|
80
|
+
kwargs["base_url"] = url
|
|
81
|
+
else:
|
|
82
|
+
raise click.UsageError(
|
|
83
|
+
f"--url is not supported by provider '{provider_name}' "
|
|
84
|
+
"(only http and ollama)."
|
|
85
|
+
)
|
|
86
|
+
if temperature is not None:
|
|
87
|
+
if provider_name not in _MODEL_PROVIDERS:
|
|
88
|
+
raise click.UsageError(
|
|
89
|
+
f"--temperature is not supported by provider '{provider_name}'."
|
|
90
|
+
)
|
|
91
|
+
kwargs["temperature"] = temperature
|
|
92
|
+
if prompt_template is not None:
|
|
93
|
+
if provider_name != "synthpanel":
|
|
94
|
+
raise click.UsageError(
|
|
95
|
+
"--prompt-template is only supported by the synthpanel provider."
|
|
96
|
+
)
|
|
97
|
+
kwargs["prompt_template"] = prompt_template
|
|
98
|
+
if elicitation is not None:
|
|
99
|
+
if provider_name != "synthpanel":
|
|
100
|
+
raise click.UsageError(
|
|
101
|
+
"--elicitation is only supported by the synthpanel provider."
|
|
102
|
+
)
|
|
103
|
+
kwargs["elicitation"] = elicitation
|
|
104
|
+
if effort is not None:
|
|
105
|
+
if provider_name == "synthpanel":
|
|
106
|
+
raise click.UsageError(
|
|
107
|
+
"--effort is not supported by provider 'synthpanel': the "
|
|
108
|
+
"synthpanel CLI does not yet expose a reasoning-effort knob, "
|
|
109
|
+
"so the run could not actually honour it. Once synthpanel "
|
|
110
|
+
"grows one, wire it through here rather than tagging runs "
|
|
111
|
+
"with metadata that never applied."
|
|
112
|
+
)
|
|
113
|
+
if provider_name not in _EFFORT_PROVIDERS:
|
|
114
|
+
raise click.UsageError(
|
|
115
|
+
f"--effort is not supported by provider '{provider_name}' "
|
|
116
|
+
"(no reasoning control to thread it to; supported: "
|
|
117
|
+
f"{', '.join(sorted(_EFFORT_PROVIDERS))})."
|
|
118
|
+
)
|
|
119
|
+
kwargs["effort"] = effort
|
|
120
|
+
return kwargs
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@click.group()
|
|
124
|
+
@click.version_option(__version__)
|
|
125
|
+
def main():
|
|
126
|
+
"""SynthBench — benchmark harness for synthetic survey respondents."""
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# Vendor adapter-driven submission flow (refs #256). Lives in its own module
|
|
131
|
+
# because it pulls in adapter discovery + artifact rendering that doesn't
|
|
132
|
+
# belong inline with the run/report commands above.
|
|
133
|
+
from synthbench.submit_adapter import submit_adapter as _submit_adapter_cmd # noqa: E402
|
|
134
|
+
|
|
135
|
+
main.add_command(_submit_adapter_cmd)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@main.command()
|
|
139
|
+
@click.option(
|
|
140
|
+
"--provider",
|
|
141
|
+
"-p",
|
|
142
|
+
required=True,
|
|
143
|
+
help="Provider name (raw-anthropic, raw-openai, raw-gemini, openrouter, ollama, synthpanel, http).",
|
|
144
|
+
)
|
|
145
|
+
@click.option(
|
|
146
|
+
"--model",
|
|
147
|
+
"-m",
|
|
148
|
+
default="haiku",
|
|
149
|
+
help="Model name or alias (haiku, sonnet, gpt-4o-mini, etc.).",
|
|
150
|
+
)
|
|
151
|
+
@click.option(
|
|
152
|
+
"--dataset",
|
|
153
|
+
"-d",
|
|
154
|
+
default="opinionsqa",
|
|
155
|
+
help="Dataset to benchmark against.",
|
|
156
|
+
)
|
|
157
|
+
@click.option(
|
|
158
|
+
"--n",
|
|
159
|
+
"-n",
|
|
160
|
+
type=int,
|
|
161
|
+
default=None,
|
|
162
|
+
help="Number of questions to evaluate (default: all).",
|
|
163
|
+
)
|
|
164
|
+
@click.option(
|
|
165
|
+
"--samples",
|
|
166
|
+
"-s",
|
|
167
|
+
type=int,
|
|
168
|
+
default=30,
|
|
169
|
+
help="Samples per question for distribution estimation.",
|
|
170
|
+
)
|
|
171
|
+
@click.option(
|
|
172
|
+
"--concurrency",
|
|
173
|
+
"-c",
|
|
174
|
+
type=int,
|
|
175
|
+
default=10,
|
|
176
|
+
help="Max concurrent API requests.",
|
|
177
|
+
)
|
|
178
|
+
@click.option(
|
|
179
|
+
"--output",
|
|
180
|
+
"-o",
|
|
181
|
+
type=click.Path(),
|
|
182
|
+
default="results",
|
|
183
|
+
help="Output directory for score cards.",
|
|
184
|
+
)
|
|
185
|
+
@click.option(
|
|
186
|
+
"--data-dir",
|
|
187
|
+
type=click.Path(),
|
|
188
|
+
default=None,
|
|
189
|
+
help="Custom data directory for datasets.",
|
|
190
|
+
)
|
|
191
|
+
@click.option(
|
|
192
|
+
"--url",
|
|
193
|
+
default=None,
|
|
194
|
+
help="Endpoint URL (required for --provider http).",
|
|
195
|
+
)
|
|
196
|
+
@click.option(
|
|
197
|
+
"--json-only", is_flag=True, help="Output JSON to stdout instead of files."
|
|
198
|
+
)
|
|
199
|
+
@click.option(
|
|
200
|
+
"--suite",
|
|
201
|
+
type=click.Choice(["smoke", "core", "full"]),
|
|
202
|
+
default=None,
|
|
203
|
+
help="Use a pinned question set (smoke=28, core=200, full=all).",
|
|
204
|
+
)
|
|
205
|
+
@click.option(
|
|
206
|
+
"--topic",
|
|
207
|
+
type=click.Choice(["political", "consumer", "neutral"]),
|
|
208
|
+
default=None,
|
|
209
|
+
help="Filter to a topic subset (political, consumer, neutral).",
|
|
210
|
+
)
|
|
211
|
+
@click.option(
|
|
212
|
+
"--baselines-dir",
|
|
213
|
+
type=click.Path(exists=True),
|
|
214
|
+
default=None,
|
|
215
|
+
help="Directory with baseline results for 'vs Baselines' section.",
|
|
216
|
+
)
|
|
217
|
+
@click.option(
|
|
218
|
+
"--demographics",
|
|
219
|
+
default=None,
|
|
220
|
+
help="Comma-separated demographic attributes to evaluate (e.g., AGE,POLIDEOLOGY).",
|
|
221
|
+
)
|
|
222
|
+
@click.option(
|
|
223
|
+
"--full-evaluation",
|
|
224
|
+
is_flag=True,
|
|
225
|
+
help="Run all demographic attributes supported by the dataset.",
|
|
226
|
+
)
|
|
227
|
+
@click.option(
|
|
228
|
+
"--country",
|
|
229
|
+
default=None,
|
|
230
|
+
help="Country for GlobalOpinionQA ground truth (e.g., France, Japan).",
|
|
231
|
+
)
|
|
232
|
+
@click.option(
|
|
233
|
+
"--temperature",
|
|
234
|
+
type=float,
|
|
235
|
+
default=None,
|
|
236
|
+
help="Sampling temperature to pass to the provider (e.g., 0.7, 1.0).",
|
|
237
|
+
)
|
|
238
|
+
@click.option(
|
|
239
|
+
"--effort",
|
|
240
|
+
type=click.Choice(list(EFFORT_CHOICES)),
|
|
241
|
+
default=None,
|
|
242
|
+
help="Reasoning effort level for reasoning-capable models (low, medium, "
|
|
243
|
+
"high). Absent = provider default, and the run is stamped as "
|
|
244
|
+
"effort-absent. Errors on providers with no reasoning control.",
|
|
245
|
+
)
|
|
246
|
+
@click.option(
|
|
247
|
+
"--prompt-template",
|
|
248
|
+
type=click.Path(exists=True),
|
|
249
|
+
default=None,
|
|
250
|
+
help="Path to a custom persona prompt template file (synthpanel only).",
|
|
251
|
+
)
|
|
252
|
+
@click.option(
|
|
253
|
+
"--elicitation",
|
|
254
|
+
type=click.Choice(["natural", "structured"]),
|
|
255
|
+
default=None,
|
|
256
|
+
help=(
|
|
257
|
+
"Elicitation mode (synthpanel only). 'natural' (default) parses "
|
|
258
|
+
"prose responses; 'structured' forces a schema-constrained tool "
|
|
259
|
+
"call (enum of the option strings) so no prose parsing occurs. "
|
|
260
|
+
"Structured runs are stamped as a template variant "
|
|
261
|
+
"(tpl=structured) — a distinct leaderboard identity."
|
|
262
|
+
),
|
|
263
|
+
)
|
|
264
|
+
# sb-ymux: `synthbench run --submit` wiring. Keeping all submission flags on
|
|
265
|
+
# the `run` subcommand (not a new subcommand) means a user's first try at
|
|
266
|
+
# "benchmark → leaderboard" is literally one invocation. --wait folds the
|
|
267
|
+
# current "run, copy path, synthbench submit" two-step into a single process
|
|
268
|
+
# whose exit code reflects the validation outcome — suitable for CI gates.
|
|
269
|
+
@click.option(
|
|
270
|
+
"--submit",
|
|
271
|
+
"submit_after",
|
|
272
|
+
is_flag=True,
|
|
273
|
+
help="After the benchmark saves, upload the JSON to the SynthBench leaderboard.",
|
|
274
|
+
)
|
|
275
|
+
@click.option(
|
|
276
|
+
"--wait",
|
|
277
|
+
is_flag=True,
|
|
278
|
+
help="With --submit: poll submission status until published/rejected. Exit "
|
|
279
|
+
"code 0 on published, 1 on rejected/error, 2 on timeout.",
|
|
280
|
+
)
|
|
281
|
+
@click.option(
|
|
282
|
+
"--api-key",
|
|
283
|
+
"submit_api_key",
|
|
284
|
+
default=None,
|
|
285
|
+
help="API key for --submit (defaults to SYNTHBENCH_API_KEY env var).",
|
|
286
|
+
)
|
|
287
|
+
@click.option(
|
|
288
|
+
"--api-url",
|
|
289
|
+
"submit_api_url",
|
|
290
|
+
default=None,
|
|
291
|
+
help="Submission endpoint base for --submit (defaults to SYNTHBENCH_API_URL env).",
|
|
292
|
+
)
|
|
293
|
+
@click.option(
|
|
294
|
+
"--submit-message",
|
|
295
|
+
default=None,
|
|
296
|
+
help="Optional note recorded on the submission (e.g. 'first run with new prompt template').",
|
|
297
|
+
)
|
|
298
|
+
@click.option(
|
|
299
|
+
"--submit-timeout",
|
|
300
|
+
type=int,
|
|
301
|
+
default=60,
|
|
302
|
+
show_default=True,
|
|
303
|
+
help="HTTP timeout (seconds) for the /submit request.",
|
|
304
|
+
)
|
|
305
|
+
@click.option(
|
|
306
|
+
"--poll-interval",
|
|
307
|
+
type=float,
|
|
308
|
+
default=10.0,
|
|
309
|
+
show_default=True,
|
|
310
|
+
help="With --wait: seconds between status polls.",
|
|
311
|
+
)
|
|
312
|
+
@click.option(
|
|
313
|
+
"--poll-timeout",
|
|
314
|
+
type=float,
|
|
315
|
+
default=900.0,
|
|
316
|
+
show_default=True,
|
|
317
|
+
help="With --wait: give up after this many seconds and exit 2.",
|
|
318
|
+
)
|
|
319
|
+
def run(
|
|
320
|
+
provider,
|
|
321
|
+
model,
|
|
322
|
+
dataset,
|
|
323
|
+
n,
|
|
324
|
+
samples,
|
|
325
|
+
concurrency,
|
|
326
|
+
output,
|
|
327
|
+
data_dir,
|
|
328
|
+
url,
|
|
329
|
+
json_only,
|
|
330
|
+
suite,
|
|
331
|
+
topic,
|
|
332
|
+
baselines_dir,
|
|
333
|
+
demographics,
|
|
334
|
+
full_evaluation,
|
|
335
|
+
country,
|
|
336
|
+
temperature,
|
|
337
|
+
effort,
|
|
338
|
+
prompt_template,
|
|
339
|
+
elicitation,
|
|
340
|
+
submit_after,
|
|
341
|
+
wait,
|
|
342
|
+
submit_api_key,
|
|
343
|
+
submit_api_url,
|
|
344
|
+
submit_message,
|
|
345
|
+
submit_timeout,
|
|
346
|
+
poll_interval,
|
|
347
|
+
poll_timeout,
|
|
348
|
+
):
|
|
349
|
+
"""Run a benchmark evaluation.
|
|
350
|
+
|
|
351
|
+
Example:
|
|
352
|
+
synthbench run --provider raw-anthropic --model haiku --n 100
|
|
353
|
+
synthbench run --provider raw-anthropic --model haiku --suite core
|
|
354
|
+
synthbench run --provider raw-anthropic --topic consumer
|
|
355
|
+
synthbench run --provider raw-anthropic --demographics AGE,POLIDEOLOGY
|
|
356
|
+
synthbench run --provider raw-anthropic --full-evaluation
|
|
357
|
+
synthbench run -p raw-anthropic -m haiku -s 30 -n 100 --submit --wait
|
|
358
|
+
"""
|
|
359
|
+
# Resolve demographics (attribute list resolved in _run_async after dataset loads)
|
|
360
|
+
demo_list = None
|
|
361
|
+
if full_evaluation:
|
|
362
|
+
demo_list = "__all__" # sentinel: resolved per-dataset in _run_async
|
|
363
|
+
elif demographics:
|
|
364
|
+
demo_list = [d.strip().upper() for d in demographics.split(",")]
|
|
365
|
+
|
|
366
|
+
# --wait without --submit is a user error: we have nothing to wait on.
|
|
367
|
+
# Fail fast rather than silently ignore so a misconfigured CI job
|
|
368
|
+
# doesn't pass when it should have blocked on validation.
|
|
369
|
+
if wait and not submit_after:
|
|
370
|
+
click.echo("Error: --wait requires --submit.", err=True)
|
|
371
|
+
sys.exit(2)
|
|
372
|
+
if submit_message and not submit_after:
|
|
373
|
+
click.echo("Error: --submit-message requires --submit.", err=True)
|
|
374
|
+
sys.exit(2)
|
|
375
|
+
# --submit + --json-only would write the result JSON to stdout instead of
|
|
376
|
+
# a file, leaving no artifact on disk for the user to re-submit later if
|
|
377
|
+
# the upload fails. Refuse rather than quietly drop one of the two.
|
|
378
|
+
if submit_after and json_only:
|
|
379
|
+
click.echo(
|
|
380
|
+
"Error: --submit is incompatible with --json-only (no file to upload).",
|
|
381
|
+
err=True,
|
|
382
|
+
)
|
|
383
|
+
sys.exit(2)
|
|
384
|
+
|
|
385
|
+
asyncio.run(
|
|
386
|
+
_run_async(
|
|
387
|
+
provider,
|
|
388
|
+
model,
|
|
389
|
+
dataset,
|
|
390
|
+
n,
|
|
391
|
+
samples,
|
|
392
|
+
concurrency,
|
|
393
|
+
output,
|
|
394
|
+
data_dir,
|
|
395
|
+
url,
|
|
396
|
+
json_only,
|
|
397
|
+
suite,
|
|
398
|
+
topic,
|
|
399
|
+
baselines_dir,
|
|
400
|
+
demo_list,
|
|
401
|
+
country,
|
|
402
|
+
temperature,
|
|
403
|
+
effort,
|
|
404
|
+
prompt_template,
|
|
405
|
+
elicitation,
|
|
406
|
+
submit_after=submit_after,
|
|
407
|
+
wait=wait,
|
|
408
|
+
submit_api_key=submit_api_key,
|
|
409
|
+
submit_api_url=submit_api_url,
|
|
410
|
+
submit_message=submit_message,
|
|
411
|
+
submit_timeout=submit_timeout,
|
|
412
|
+
poll_interval=poll_interval,
|
|
413
|
+
poll_timeout=poll_timeout,
|
|
414
|
+
)
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
async def _run_async(
|
|
419
|
+
provider_name,
|
|
420
|
+
model,
|
|
421
|
+
dataset_name,
|
|
422
|
+
n,
|
|
423
|
+
samples,
|
|
424
|
+
concurrency,
|
|
425
|
+
output,
|
|
426
|
+
data_dir,
|
|
427
|
+
url,
|
|
428
|
+
json_only,
|
|
429
|
+
suite,
|
|
430
|
+
topic,
|
|
431
|
+
baselines_dir,
|
|
432
|
+
demographics=None,
|
|
433
|
+
country=None,
|
|
434
|
+
temperature=None,
|
|
435
|
+
effort=None,
|
|
436
|
+
prompt_template=None,
|
|
437
|
+
elicitation=None,
|
|
438
|
+
*,
|
|
439
|
+
submit_after: bool = False,
|
|
440
|
+
wait: bool = False,
|
|
441
|
+
submit_api_key=None,
|
|
442
|
+
submit_api_url=None,
|
|
443
|
+
submit_message=None,
|
|
444
|
+
submit_timeout: int = 60,
|
|
445
|
+
poll_interval: float = 10.0,
|
|
446
|
+
poll_timeout: float = 900.0,
|
|
447
|
+
):
|
|
448
|
+
from synthbench.datasets import DATASETS
|
|
449
|
+
from synthbench.providers import load_provider
|
|
450
|
+
from synthbench.runner import BenchmarkRunner
|
|
451
|
+
from synthbench import report
|
|
452
|
+
|
|
453
|
+
# Resolve model alias
|
|
454
|
+
resolved_model = MODEL_ALIASES.get(model, model)
|
|
455
|
+
|
|
456
|
+
# Load dataset
|
|
457
|
+
if dataset_name not in DATASETS:
|
|
458
|
+
click.echo(
|
|
459
|
+
f"Unknown dataset '{dataset_name}'. Available: {list(DATASETS)}", err=True
|
|
460
|
+
)
|
|
461
|
+
sys.exit(1)
|
|
462
|
+
|
|
463
|
+
ds_kwargs = {}
|
|
464
|
+
if data_dir:
|
|
465
|
+
ds_kwargs["data_dir"] = data_dir
|
|
466
|
+
if country:
|
|
467
|
+
ds_kwargs["country"] = country
|
|
468
|
+
ds = DATASETS[dataset_name](**ds_kwargs)
|
|
469
|
+
|
|
470
|
+
# Resolve --full-evaluation sentinel to dataset-specific attributes
|
|
471
|
+
if demographics == "__all__":
|
|
472
|
+
ds_attrs = getattr(ds, "DEMOGRAPHIC_ATTRIBUTES", None)
|
|
473
|
+
if ds_attrs:
|
|
474
|
+
demographics = list(ds_attrs)
|
|
475
|
+
else:
|
|
476
|
+
click.echo(
|
|
477
|
+
f"Dataset '{dataset_name}' does not support demographic evaluation.",
|
|
478
|
+
err=True,
|
|
479
|
+
)
|
|
480
|
+
demographics = None
|
|
481
|
+
|
|
482
|
+
# Load provider
|
|
483
|
+
provider_kwargs = _provider_kwargs(
|
|
484
|
+
provider_name,
|
|
485
|
+
model=resolved_model,
|
|
486
|
+
url=url,
|
|
487
|
+
temperature=temperature,
|
|
488
|
+
prompt_template=prompt_template,
|
|
489
|
+
effort=effort,
|
|
490
|
+
elicitation=elicitation,
|
|
491
|
+
)
|
|
492
|
+
try:
|
|
493
|
+
prov = load_provider(provider_name, **provider_kwargs)
|
|
494
|
+
except KeyError as e:
|
|
495
|
+
click.echo(str(e), err=True)
|
|
496
|
+
sys.exit(1)
|
|
497
|
+
except ImportError as e:
|
|
498
|
+
click.echo(str(e), err=True)
|
|
499
|
+
sys.exit(1)
|
|
500
|
+
except ValueError as e:
|
|
501
|
+
# Provider constructors raise ValueError for knob combinations they
|
|
502
|
+
# cannot honour (e.g. --effort with a custom --temperature on
|
|
503
|
+
# reasoning models). Surface cleanly instead of a traceback.
|
|
504
|
+
click.echo(f"Error: {e}", err=True)
|
|
505
|
+
sys.exit(2)
|
|
506
|
+
|
|
507
|
+
# Load suite if specified
|
|
508
|
+
question_keys = None
|
|
509
|
+
if suite:
|
|
510
|
+
from synthbench.suites import load_suite
|
|
511
|
+
|
|
512
|
+
question_keys = load_suite(suite)
|
|
513
|
+
|
|
514
|
+
# Load topic suite if specified (intersect with suite if both given)
|
|
515
|
+
if topic:
|
|
516
|
+
from synthbench.suites import load_topic_suite
|
|
517
|
+
|
|
518
|
+
topic_keys = load_topic_suite(topic)
|
|
519
|
+
if question_keys is not None:
|
|
520
|
+
# Intersect: keep only keys in both suite and topic
|
|
521
|
+
topic_set = set(topic_keys)
|
|
522
|
+
question_keys = [k for k in question_keys if k in topic_set]
|
|
523
|
+
else:
|
|
524
|
+
question_keys = topic_keys
|
|
525
|
+
|
|
526
|
+
# Run benchmark
|
|
527
|
+
runner = BenchmarkRunner(
|
|
528
|
+
dataset=ds,
|
|
529
|
+
provider=prov,
|
|
530
|
+
samples_per_question=samples,
|
|
531
|
+
concurrency=concurrency,
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
suite_label = f"suite={suite}" if suite else (str(n) if n else "all")
|
|
535
|
+
if topic:
|
|
536
|
+
suite_label += f" topic={topic}"
|
|
537
|
+
click.echo(f"SynthBench v{__version__}")
|
|
538
|
+
click.echo(f" Provider: {prov.name}")
|
|
539
|
+
click.echo(f" Dataset: {ds.name}")
|
|
540
|
+
click.echo(f" Questions: {suite_label}")
|
|
541
|
+
click.echo(f" Samples/q: {samples}")
|
|
542
|
+
if effort:
|
|
543
|
+
click.echo(f" Effort: {effort}")
|
|
544
|
+
if demographics:
|
|
545
|
+
click.echo(f" Demographics: {', '.join(demographics)}")
|
|
546
|
+
click.echo()
|
|
547
|
+
|
|
548
|
+
def progress(done, total, qr):
|
|
549
|
+
pct = done / total * 100
|
|
550
|
+
click.echo(
|
|
551
|
+
f"\r [{done}/{total}] {pct:5.1f}% | "
|
|
552
|
+
f"JSD={qr.jsd:.4f} tau={qr.kendall_tau:.4f}",
|
|
553
|
+
nl=False,
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
try:
|
|
557
|
+
if demographics:
|
|
558
|
+
result = await runner.run_with_demographics(
|
|
559
|
+
demographics=demographics,
|
|
560
|
+
n=n,
|
|
561
|
+
progress_callback=progress,
|
|
562
|
+
question_keys=question_keys,
|
|
563
|
+
)
|
|
564
|
+
else:
|
|
565
|
+
result = await runner.run(
|
|
566
|
+
n=n, progress_callback=progress, question_keys=question_keys
|
|
567
|
+
)
|
|
568
|
+
finally:
|
|
569
|
+
await prov.close()
|
|
570
|
+
|
|
571
|
+
# Tag result with topic if specified
|
|
572
|
+
if topic:
|
|
573
|
+
result.config["topic"] = topic
|
|
574
|
+
if temperature is not None:
|
|
575
|
+
result.config["temperature"] = temperature
|
|
576
|
+
# Stamped only when the knob was actually threaded to the provider —
|
|
577
|
+
# an absent key means "provider default reasoning behaviour", and
|
|
578
|
+
# effort-absent runs hash to the same config_id as before the effort
|
|
579
|
+
# dimension existed.
|
|
580
|
+
if effort is not None:
|
|
581
|
+
result.config["effort"] = effort
|
|
582
|
+
if prompt_template is not None:
|
|
583
|
+
result.config["prompt_template"] = prompt_template
|
|
584
|
+
if elicitation is not None and elicitation != "natural":
|
|
585
|
+
# Structured elicitation is stamped on the template axis so it
|
|
586
|
+
# groups/dedups/config_ids as a distinct variant (tpl=structured),
|
|
587
|
+
# plus an explicit elicitation key for human readers.
|
|
588
|
+
result.config["elicitation"] = elicitation
|
|
589
|
+
result.config["prompt_template"] = elicitation
|
|
590
|
+
|
|
591
|
+
click.echo() # Newline after progress
|
|
592
|
+
click.echo()
|
|
593
|
+
|
|
594
|
+
# Per-metric SPS summary with CIs
|
|
595
|
+
components = result.sps_components
|
|
596
|
+
cis = result.per_metric_ci
|
|
597
|
+
sps_ci = cis.get("sps")
|
|
598
|
+
if sps_ci:
|
|
599
|
+
click.echo(
|
|
600
|
+
f" SPS: {result.sps:.4f} [{sps_ci[0]:.4f}, {sps_ci[1]:.4f}] ({len(components)} metrics)"
|
|
601
|
+
)
|
|
602
|
+
else:
|
|
603
|
+
click.echo(f" SPS: {result.sps:.4f} ({len(components)} metrics)")
|
|
604
|
+
for key, score in components.items():
|
|
605
|
+
ci = cis.get(key)
|
|
606
|
+
if ci:
|
|
607
|
+
click.echo(f" {key}: {score:.4f} [{ci[0]:.4f}, {ci[1]:.4f}]")
|
|
608
|
+
else:
|
|
609
|
+
click.echo(f" {key}: {score:.4f}")
|
|
610
|
+
if result.total_parse_failures > 0:
|
|
611
|
+
click.echo(f" Parse failures: {result.total_parse_failures}")
|
|
612
|
+
|
|
613
|
+
# Demographic breakdown summary
|
|
614
|
+
if result.demographic_breakdown:
|
|
615
|
+
click.echo(" Demographic breakdown:")
|
|
616
|
+
for attr, group_results in result.demographic_breakdown.items():
|
|
617
|
+
best = max(group_results, key=lambda g: g.p_dist)
|
|
618
|
+
worst = min(group_results, key=lambda g: g.p_dist)
|
|
619
|
+
click.echo(
|
|
620
|
+
f" {attr}: Best={best.group} (P_dist={best.p_dist:.4f}) "
|
|
621
|
+
f"/ Worst={worst.group} (P_dist={worst.p_dist:.4f})"
|
|
622
|
+
)
|
|
623
|
+
click.echo()
|
|
624
|
+
|
|
625
|
+
if json_only:
|
|
626
|
+
click.echo(json.dumps(report.to_json(result), indent=2))
|
|
627
|
+
else:
|
|
628
|
+
# Print markdown summary to terminal
|
|
629
|
+
bl_dir = Path(baselines_dir) if baselines_dir else None
|
|
630
|
+
click.echo(report.to_markdown(result, baselines_dir=bl_dir))
|
|
631
|
+
|
|
632
|
+
# Save files
|
|
633
|
+
json_path, md_path = report.save(result, output)
|
|
634
|
+
click.echo("\nResults saved:")
|
|
635
|
+
click.echo(f" JSON: {json_path}")
|
|
636
|
+
click.echo(f" Report: {md_path}")
|
|
637
|
+
|
|
638
|
+
if submit_after:
|
|
639
|
+
# sb-ymux: end-to-end "benchmark → leaderboard" path. The JSON was
|
|
640
|
+
# just written to disk above; we read it back rather than re-
|
|
641
|
+
# serializing `result` so the bytes on disk and the bytes
|
|
642
|
+
# uploaded are guaranteed identical (the Worker's Tier-1
|
|
643
|
+
# validator and the downstream GH Action both run against the
|
|
644
|
+
# staged R2 copy, so any drift here would be invisible until
|
|
645
|
+
# validation fails much later).
|
|
646
|
+
_submit_and_maybe_wait(
|
|
647
|
+
json_path=Path(json_path),
|
|
648
|
+
submit_api_key=submit_api_key,
|
|
649
|
+
submit_api_url=submit_api_url,
|
|
650
|
+
submit_message=submit_message,
|
|
651
|
+
submit_timeout=submit_timeout,
|
|
652
|
+
wait=wait,
|
|
653
|
+
poll_interval=poll_interval,
|
|
654
|
+
poll_timeout=poll_timeout,
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
@main.command()
|
|
659
|
+
@click.argument("json_file", type=click.Path(exists=True))
|
|
660
|
+
def report(json_file):
|
|
661
|
+
"""Regenerate a markdown report from a JSON score card."""
|
|
662
|
+
from synthbench.runner import BenchmarkResult, QuestionResult
|
|
663
|
+
from synthbench import report as report_mod
|
|
664
|
+
|
|
665
|
+
with open(json_file) as f:
|
|
666
|
+
data = json.load(f)
|
|
667
|
+
|
|
668
|
+
# Reconstruct BenchmarkResult from JSON
|
|
669
|
+
questions = [
|
|
670
|
+
QuestionResult(
|
|
671
|
+
key=q["key"],
|
|
672
|
+
text=q["text"],
|
|
673
|
+
options=q["options"],
|
|
674
|
+
# Committed gated files are stripped of human_distribution
|
|
675
|
+
# (issue #308); the regenerated markdown degrades gracefully.
|
|
676
|
+
human_distribution=q.get("human_distribution") or {},
|
|
677
|
+
model_distribution=q["model_distribution"],
|
|
678
|
+
jsd=q["jsd"],
|
|
679
|
+
kendall_tau=q["kendall_tau"],
|
|
680
|
+
parity=q["parity"],
|
|
681
|
+
n_samples=q["n_samples"],
|
|
682
|
+
)
|
|
683
|
+
for q in data["per_question"]
|
|
684
|
+
]
|
|
685
|
+
|
|
686
|
+
result = BenchmarkResult(
|
|
687
|
+
provider_name=data["config"]["provider"],
|
|
688
|
+
dataset_name=data["config"]["dataset"],
|
|
689
|
+
questions=questions,
|
|
690
|
+
config=data["config"],
|
|
691
|
+
elapsed_seconds=data["aggregate"].get("elapsed_seconds", 0),
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
click.echo(report_mod.to_markdown(result))
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
@main.command()
|
|
698
|
+
@click.option("--dataset", "-d", default="opinionsqa", help="Dataset to download.")
|
|
699
|
+
@click.option("--data-dir", type=click.Path(), default=None, help="Target directory.")
|
|
700
|
+
def download(dataset, data_dir):
|
|
701
|
+
"""Download a benchmark dataset."""
|
|
702
|
+
from synthbench.datasets import DATASETS
|
|
703
|
+
|
|
704
|
+
if dataset not in DATASETS:
|
|
705
|
+
click.echo(
|
|
706
|
+
f"Unknown dataset '{dataset}'. Available: {list(DATASETS)}", err=True
|
|
707
|
+
)
|
|
708
|
+
sys.exit(1)
|
|
709
|
+
|
|
710
|
+
ds_kwargs = {}
|
|
711
|
+
if data_dir:
|
|
712
|
+
ds_kwargs["data_dir"] = data_dir
|
|
713
|
+
ds = DATASETS[dataset](**ds_kwargs)
|
|
714
|
+
|
|
715
|
+
click.echo(f"Loading {dataset} dataset...")
|
|
716
|
+
try:
|
|
717
|
+
questions = ds.load()
|
|
718
|
+
click.echo(f"Loaded {len(questions)} questions.")
|
|
719
|
+
except Exception as e:
|
|
720
|
+
click.echo(f"Error: {e}", err=True)
|
|
721
|
+
sys.exit(1)
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
@main.command()
|
|
725
|
+
@click.argument("files", nargs=-1, required=True, type=click.Path(exists=True))
|
|
726
|
+
@click.option(
|
|
727
|
+
"--output", "-o", type=click.Path(), default=None, help="Save comparison to file."
|
|
728
|
+
)
|
|
729
|
+
def compare(files, output):
|
|
730
|
+
"""Compare 2+ result JSON files side-by-side with significance testing.
|
|
731
|
+
|
|
732
|
+
When exactly 2 files are given, also runs a paired bootstrap test on
|
|
733
|
+
per-question parity scores and reports delta, p-value, and verdict.
|
|
734
|
+
|
|
735
|
+
Example:
|
|
736
|
+
synthbench compare result1.json result2.json
|
|
737
|
+
"""
|
|
738
|
+
if len(files) < 2:
|
|
739
|
+
click.echo("Error: need at least 2 result files to compare.", err=True)
|
|
740
|
+
sys.exit(1)
|
|
741
|
+
|
|
742
|
+
from synthbench.leaderboard import load_result, compare_results
|
|
743
|
+
from synthbench.stats import paired_bootstrap_test
|
|
744
|
+
|
|
745
|
+
results = [load_result(Path(f)) for f in files]
|
|
746
|
+
md = compare_results(results)
|
|
747
|
+
|
|
748
|
+
click.echo(md)
|
|
749
|
+
|
|
750
|
+
# Pairwise significance test for exactly 2 results
|
|
751
|
+
if len(files) == 2:
|
|
752
|
+
r1, r2 = results
|
|
753
|
+
cfg1 = r1.get("config", {}).get("provider", "A")
|
|
754
|
+
cfg2 = r2.get("config", {}).get("provider", "B")
|
|
755
|
+
pq1 = {q["key"]: q["parity"] for q in r1.get("per_question", [])}
|
|
756
|
+
pq2 = {q["key"]: q["parity"] for q in r2.get("per_question", [])}
|
|
757
|
+
common_keys = sorted(set(pq1) & set(pq2))
|
|
758
|
+
|
|
759
|
+
if common_keys:
|
|
760
|
+
scores_a = [pq1[k] for k in common_keys]
|
|
761
|
+
scores_b = [pq2[k] for k in common_keys]
|
|
762
|
+
delta, p_val, verdict = paired_bootstrap_test(scores_a, scores_b, seed=42)
|
|
763
|
+
|
|
764
|
+
alpha = 0.05
|
|
765
|
+
sig_str = (
|
|
766
|
+
f"SIGNIFICANT at alpha={alpha}"
|
|
767
|
+
if verdict == "significant"
|
|
768
|
+
else f"NOT significant at alpha={alpha}"
|
|
769
|
+
)
|
|
770
|
+
|
|
771
|
+
click.echo()
|
|
772
|
+
click.echo("## Statistical Significance")
|
|
773
|
+
click.echo()
|
|
774
|
+
click.echo(f" {cfg1} vs {cfg2}")
|
|
775
|
+
click.echo(f" Paired questions: {len(common_keys)}")
|
|
776
|
+
click.echo(f" Delta SPS = {delta:+.4f}, p = {p_val:.4f}, {sig_str}")
|
|
777
|
+
click.echo()
|
|
778
|
+
|
|
779
|
+
md += "\n## Statistical Significance\n\n"
|
|
780
|
+
md += f"- {cfg1} vs {cfg2}\n"
|
|
781
|
+
md += f"- Paired questions: {len(common_keys)}\n"
|
|
782
|
+
md += f"- Delta SPS = {delta:+.4f}, p = {p_val:.4f}, **{sig_str}**\n"
|
|
783
|
+
else:
|
|
784
|
+
click.echo("\nNo common questions for significance test.", err=True)
|
|
785
|
+
|
|
786
|
+
if output:
|
|
787
|
+
Path(output).write_text(md)
|
|
788
|
+
click.echo(f"\nSaved to {output}", err=True)
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
# sb-t61h: Tier-2 CLI submission. Wraps the same Worker /submit endpoint the
|
|
792
|
+
# web upload uses, but auths with a personal API key minted on /account so the
|
|
793
|
+
# user never has to leave the terminal. Designed to be the last step of a
|
|
794
|
+
# benchmark pipeline:
|
|
795
|
+
#
|
|
796
|
+
# synthbench run -p openrouter -m gpt-4o-mini -d opinionsqa -o results/
|
|
797
|
+
# synthbench submit results/openrouter_gpt-4o-mini_opinionsqa.json
|
|
798
|
+
#
|
|
799
|
+
# sb-ymux: `synthbench run --submit` collapses the two steps into one — the
|
|
800
|
+
# CLI wiring lives here, but the HTTP round-trip is in synthbench.submission
|
|
801
|
+
# so `run --submit` and `submit` share exactly one implementation.
|
|
802
|
+
#
|
|
803
|
+
# Configuration via env vars is the supported path for shell-script automation:
|
|
804
|
+
# export SYNTHBENCH_API_KEY=sb_xxxx...
|
|
805
|
+
# export SYNTHBENCH_API_URL=https://api.synthbench.org # default
|
|
806
|
+
#
|
|
807
|
+
# Flags (--api-key, --api-url) override env so ad-hoc invocations don't have
|
|
808
|
+
# to mutate the shell.
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def _echo_submit_error(err, *, prefix: str = "Error") -> None:
|
|
812
|
+
"""Render a SubmitError/PollError to stderr in the `synthbench submit`
|
|
813
|
+
format (HTTP code + server message + hint line), keeping output identical
|
|
814
|
+
across `submit` and `run --submit`."""
|
|
815
|
+
if err.status_code:
|
|
816
|
+
click.echo(f"{prefix} (HTTP {err.status_code}): {err.message}", err=True)
|
|
817
|
+
else:
|
|
818
|
+
click.echo(f"{prefix}: {err.message}", err=True)
|
|
819
|
+
hint = getattr(err, "hint", "")
|
|
820
|
+
if hint:
|
|
821
|
+
click.echo(f" Hint: {hint}", err=True)
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def _submit_body(
|
|
825
|
+
*,
|
|
826
|
+
body: str,
|
|
827
|
+
source_label: str,
|
|
828
|
+
api_key: str | None,
|
|
829
|
+
api_url: str | None,
|
|
830
|
+
timeout: int,
|
|
831
|
+
json_out: bool,
|
|
832
|
+
):
|
|
833
|
+
"""Shared POST + stderr/stdout emitter used by both `submit` and
|
|
834
|
+
`run --submit`. Returns the resolved api_base plus the submission outcome
|
|
835
|
+
so callers that poll (e.g. --wait) don't re-derive them.
|
|
836
|
+
"""
|
|
837
|
+
from synthbench import submission as sub
|
|
838
|
+
|
|
839
|
+
key = sub.resolve_api_key(api_key)
|
|
840
|
+
if isinstance(key, sub.SubmitError):
|
|
841
|
+
_echo_submit_error(key)
|
|
842
|
+
sys.exit(2)
|
|
843
|
+
|
|
844
|
+
base = sub.resolve_api_base(api_url)
|
|
845
|
+
click.echo(f"Uploading {source_label} → {base}/submit", err=True)
|
|
846
|
+
|
|
847
|
+
outcome = sub.post_submission(
|
|
848
|
+
body=body, api_key=key, api_base=base, timeout=timeout
|
|
849
|
+
)
|
|
850
|
+
|
|
851
|
+
if json_out and isinstance(outcome, sub.SubmitSuccess):
|
|
852
|
+
click.echo(json.dumps(outcome.payload))
|
|
853
|
+
elif (
|
|
854
|
+
json_out
|
|
855
|
+
and isinstance(outcome, sub.SubmitError)
|
|
856
|
+
and outcome.payload is not None
|
|
857
|
+
):
|
|
858
|
+
click.echo(json.dumps(outcome.payload))
|
|
859
|
+
|
|
860
|
+
if isinstance(outcome, sub.SubmitError):
|
|
861
|
+
_echo_submit_error(outcome)
|
|
862
|
+
sys.exit(1)
|
|
863
|
+
|
|
864
|
+
click.echo(
|
|
865
|
+
f" ✓ submitted (id={outcome.submission_id}, status={outcome.status})",
|
|
866
|
+
err=True,
|
|
867
|
+
)
|
|
868
|
+
warning = outcome.payload.get("warning")
|
|
869
|
+
if warning:
|
|
870
|
+
click.echo(f" ! {warning}", err=True)
|
|
871
|
+
# Deep-link the user to the in-flight submission so they can poll
|
|
872
|
+
# status without leaving the terminal-browser loop.
|
|
873
|
+
site = base.replace("api.", "", 1) if base.startswith("https://api.") else base
|
|
874
|
+
click.echo(f" Status URL: {site.rstrip('/')}/account/submissions/", err=True)
|
|
875
|
+
return key, base, outcome
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def _submit_and_maybe_wait(
|
|
879
|
+
*,
|
|
880
|
+
json_path: Path,
|
|
881
|
+
submit_api_key,
|
|
882
|
+
submit_api_url,
|
|
883
|
+
submit_message,
|
|
884
|
+
submit_timeout: int,
|
|
885
|
+
wait: bool,
|
|
886
|
+
poll_interval: float,
|
|
887
|
+
poll_timeout: float,
|
|
888
|
+
) -> None:
|
|
889
|
+
"""Upload the saved run file, optionally poll status, and sys.exit() with
|
|
890
|
+
a code the CI job can branch on.
|
|
891
|
+
|
|
892
|
+
Exit code contract (documented in the `synthbench run --submit` help):
|
|
893
|
+
0 — submitted, and if --wait, status=published (leaderboard updated)
|
|
894
|
+
1 — submission rejected by the Worker, OR rejected by the validator,
|
|
895
|
+
OR a hard error (bad key, HTTP 5xx)
|
|
896
|
+
2 — --wait timed out before the validator reached a terminal state
|
|
897
|
+
"""
|
|
898
|
+
from synthbench import submission as sub
|
|
899
|
+
|
|
900
|
+
body_or_err = sub.read_run_body(str(json_path))
|
|
901
|
+
if isinstance(body_or_err, sub.SubmitError):
|
|
902
|
+
_echo_submit_error(body_or_err)
|
|
903
|
+
sys.exit(1)
|
|
904
|
+
body = sub.inject_submit_message(body_or_err, submit_message)
|
|
905
|
+
|
|
906
|
+
click.echo()
|
|
907
|
+
click.echo("✓ Benchmark complete. Submitting to leaderboard...", err=True)
|
|
908
|
+
|
|
909
|
+
key, base, outcome = _submit_body(
|
|
910
|
+
body=body,
|
|
911
|
+
source_label=json_path.name,
|
|
912
|
+
api_key=submit_api_key,
|
|
913
|
+
api_url=submit_api_url,
|
|
914
|
+
timeout=submit_timeout,
|
|
915
|
+
json_out=False,
|
|
916
|
+
)
|
|
917
|
+
if not wait:
|
|
918
|
+
return
|
|
919
|
+
|
|
920
|
+
click.echo(
|
|
921
|
+
f" Waiting for validation (poll={poll_interval:g}s, timeout={poll_timeout:g}s)...",
|
|
922
|
+
err=True,
|
|
923
|
+
)
|
|
924
|
+
result = sub.poll_submission_status(
|
|
925
|
+
submission_id=outcome.submission_id,
|
|
926
|
+
api_key=key,
|
|
927
|
+
api_base=base,
|
|
928
|
+
interval=poll_interval,
|
|
929
|
+
timeout=poll_timeout,
|
|
930
|
+
on_poll=lambda s: click.echo(f" status={s}", err=True),
|
|
931
|
+
)
|
|
932
|
+
if isinstance(result, sub.StatusResult):
|
|
933
|
+
if result.status == "published":
|
|
934
|
+
entry = result.leaderboard_entry_id or "(no entry id)"
|
|
935
|
+
click.echo(
|
|
936
|
+
f" ✓ published (leaderboard_entry_id={entry})",
|
|
937
|
+
err=True,
|
|
938
|
+
)
|
|
939
|
+
return
|
|
940
|
+
# Only "rejected" remains for StatusResult since TERMINAL_STATUSES is
|
|
941
|
+
# {"published", "rejected"}. Surface the reason so CI logs tell the
|
|
942
|
+
# user what to fix without them having to click through /account.
|
|
943
|
+
reason = result.rejection_reason or "(no reason given)"
|
|
944
|
+
click.echo(f" ✗ rejected: {reason}", err=True)
|
|
945
|
+
sys.exit(1)
|
|
946
|
+
if isinstance(result, sub.PollTimeout):
|
|
947
|
+
click.echo(
|
|
948
|
+
f" ! polling timed out after {poll_timeout:g}s "
|
|
949
|
+
f"(last status={result.last_status}).",
|
|
950
|
+
err=True,
|
|
951
|
+
)
|
|
952
|
+
click.echo(
|
|
953
|
+
" Submission continues server-side; check /account/submissions/ or "
|
|
954
|
+
"re-poll with synthbench submit.",
|
|
955
|
+
err=True,
|
|
956
|
+
)
|
|
957
|
+
sys.exit(2)
|
|
958
|
+
# PollError
|
|
959
|
+
_echo_submit_error(result, prefix="Poll error")
|
|
960
|
+
sys.exit(1)
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
@main.command()
|
|
964
|
+
@click.argument("run_file", type=click.Path(exists=True, dir_okay=False))
|
|
965
|
+
@click.option(
|
|
966
|
+
"--api-key",
|
|
967
|
+
default=None,
|
|
968
|
+
help="API key (defaults to SYNTHBENCH_API_KEY env var). Generate at /account.",
|
|
969
|
+
)
|
|
970
|
+
@click.option(
|
|
971
|
+
"--api-url",
|
|
972
|
+
default=None,
|
|
973
|
+
help=f"Submission endpoint base (defaults to SYNTHBENCH_API_URL or {DEFAULT_API_URL}).",
|
|
974
|
+
)
|
|
975
|
+
@click.option(
|
|
976
|
+
"--timeout",
|
|
977
|
+
type=int,
|
|
978
|
+
default=60,
|
|
979
|
+
show_default=True,
|
|
980
|
+
help="HTTP timeout in seconds.",
|
|
981
|
+
)
|
|
982
|
+
@click.option(
|
|
983
|
+
"--json-out",
|
|
984
|
+
is_flag=True,
|
|
985
|
+
help="Print the raw response JSON to stdout (useful for piping).",
|
|
986
|
+
)
|
|
987
|
+
@click.option(
|
|
988
|
+
"--config",
|
|
989
|
+
"config_file",
|
|
990
|
+
type=click.Path(exists=True, dir_okay=False),
|
|
991
|
+
default=None,
|
|
992
|
+
help=(
|
|
993
|
+
"Path to a user-config YAML (sb-vgb / GH#257). Validates the artifact "
|
|
994
|
+
"and stamps it onto the submission so the leaderboard renders a "
|
|
995
|
+
"{vendor}/{config_name} row instead of bare {vendor}."
|
|
996
|
+
),
|
|
997
|
+
)
|
|
998
|
+
@click.option(
|
|
999
|
+
"--allow-harness-mismatch",
|
|
1000
|
+
is_flag=True,
|
|
1001
|
+
help=(
|
|
1002
|
+
"Submit even if --config pins a different synthbench version. Off by "
|
|
1003
|
+
"default because the leaderboard treats the harness version as part "
|
|
1004
|
+
"of the reproducible-artifact contract."
|
|
1005
|
+
),
|
|
1006
|
+
)
|
|
1007
|
+
def submit(
|
|
1008
|
+
run_file, api_key, api_url, timeout, json_out, config_file, allow_harness_mismatch
|
|
1009
|
+
):
|
|
1010
|
+
"""Submit a benchmark result JSON to the SynthBench leaderboard.
|
|
1011
|
+
|
|
1012
|
+
Authenticates with a personal API key (sb_<...>) generated at
|
|
1013
|
+
https://synthbench.org/account. The same Tier-1 schema validation that
|
|
1014
|
+
runs in the browser flow runs server-side; on success the submission
|
|
1015
|
+
enters the validation queue and a moderator-free GitHub Actions run
|
|
1016
|
+
publishes it to the leaderboard if it passes the full pipeline.
|
|
1017
|
+
|
|
1018
|
+
Example:
|
|
1019
|
+
|
|
1020
|
+
export SYNTHBENCH_API_KEY=sb_xxxx
|
|
1021
|
+
synthbench submit results/openrouter_gpt-4o-mini_opinionsqa.json
|
|
1022
|
+
|
|
1023
|
+
Submit with a user-configuration artifact (sb-vgb / GH#257):
|
|
1024
|
+
|
|
1025
|
+
synthbench submit --config configs/healthcare-nc.yaml results/run.json
|
|
1026
|
+
"""
|
|
1027
|
+
from synthbench import submission as sub
|
|
1028
|
+
|
|
1029
|
+
path = Path(run_file)
|
|
1030
|
+
body = sub.read_run_body(str(path))
|
|
1031
|
+
if isinstance(body, sub.SubmitError):
|
|
1032
|
+
_echo_submit_error(body)
|
|
1033
|
+
sys.exit(1)
|
|
1034
|
+
|
|
1035
|
+
if config_file:
|
|
1036
|
+
from synthbench import user_config as uc
|
|
1037
|
+
|
|
1038
|
+
result = uc.load_and_validate(config_file)
|
|
1039
|
+
if isinstance(result, list):
|
|
1040
|
+
click.echo(f"Config validation failed ({config_file}):", err=True)
|
|
1041
|
+
for err in result:
|
|
1042
|
+
click.echo(f" - {err}", err=True)
|
|
1043
|
+
sys.exit(2)
|
|
1044
|
+
mismatch = uc.check_harness_version(result)
|
|
1045
|
+
if mismatch is not None:
|
|
1046
|
+
if not allow_harness_mismatch:
|
|
1047
|
+
click.echo(f"Error: {mismatch}", err=True)
|
|
1048
|
+
click.echo(
|
|
1049
|
+
" Hint: pass --allow-harness-mismatch to override.", err=True
|
|
1050
|
+
)
|
|
1051
|
+
sys.exit(2)
|
|
1052
|
+
click.echo(f" ! {mismatch}", err=True)
|
|
1053
|
+
body = uc.attach_to_submission_body(body, result)
|
|
1054
|
+
click.echo(
|
|
1055
|
+
f" + attaching user_config {result.leaderboard_row_key} "
|
|
1056
|
+
f"(contributor={result.contributor})",
|
|
1057
|
+
err=True,
|
|
1058
|
+
)
|
|
1059
|
+
|
|
1060
|
+
_submit_body(
|
|
1061
|
+
body=body,
|
|
1062
|
+
source_label=path.name,
|
|
1063
|
+
api_key=api_key,
|
|
1064
|
+
api_url=api_url,
|
|
1065
|
+
timeout=timeout,
|
|
1066
|
+
json_out=json_out,
|
|
1067
|
+
)
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
@main.command("validate-config")
|
|
1071
|
+
@click.argument("config_file", type=click.Path(exists=True, dir_okay=False))
|
|
1072
|
+
def validate_config_cmd(config_file):
|
|
1073
|
+
"""Validate a user-configuration YAML against the schema (sb-vgb / GH#257).
|
|
1074
|
+
|
|
1075
|
+
Run this before `synthbench submit --config` to surface schema errors
|
|
1076
|
+
locally. Exit code 0 on success, 2 on validation failure.
|
|
1077
|
+
|
|
1078
|
+
Example:
|
|
1079
|
+
|
|
1080
|
+
synthbench validate-config configs/healthcare-northcentral.yaml
|
|
1081
|
+
"""
|
|
1082
|
+
from synthbench import user_config as uc
|
|
1083
|
+
|
|
1084
|
+
result = uc.load_and_validate(config_file)
|
|
1085
|
+
if isinstance(result, list):
|
|
1086
|
+
click.echo(f"✗ {config_file}: invalid", err=True)
|
|
1087
|
+
for err in result:
|
|
1088
|
+
click.echo(f" - {err}", err=True)
|
|
1089
|
+
sys.exit(2)
|
|
1090
|
+
click.echo(f"✓ {config_file}: valid")
|
|
1091
|
+
click.echo(f" row: {result.leaderboard_row_key}")
|
|
1092
|
+
click.echo(f" contributor: {result.contributor}")
|
|
1093
|
+
click.echo(f" harness: {result.harness_version}")
|
|
1094
|
+
mismatch = uc.check_harness_version(result)
|
|
1095
|
+
if mismatch is not None:
|
|
1096
|
+
click.echo(f" ! {mismatch}", err=True)
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
@main.command()
|
|
1100
|
+
@click.option("--provider", "-p", required=True, help="Provider name.")
|
|
1101
|
+
@click.option("--model", "-m", default="haiku", help="Model name or alias.")
|
|
1102
|
+
@click.option("--dataset", "-d", default="opinionsqa", help="Dataset.")
|
|
1103
|
+
@click.option("--n", "-n", type=int, default=None, help="Questions per run.")
|
|
1104
|
+
@click.option("--samples", "-s", type=int, default=30, help="Samples per question.")
|
|
1105
|
+
@click.option("--n-runs", type=int, default=5, help="Number of independent runs.")
|
|
1106
|
+
@click.option(
|
|
1107
|
+
"--concurrency", "-c", type=int, default=10, help="Max concurrent requests."
|
|
1108
|
+
)
|
|
1109
|
+
@click.option(
|
|
1110
|
+
"--data-dir", type=click.Path(), default=None, help="Custom data directory."
|
|
1111
|
+
)
|
|
1112
|
+
@click.option("--url", default=None, help="Endpoint URL for http provider.")
|
|
1113
|
+
@click.option(
|
|
1114
|
+
"--suite",
|
|
1115
|
+
type=click.Choice(["smoke", "core", "full"]),
|
|
1116
|
+
default=None,
|
|
1117
|
+
help="Use a pinned question set.",
|
|
1118
|
+
)
|
|
1119
|
+
@click.option(
|
|
1120
|
+
"--output", "-o", type=click.Path(), default="results", help="Output directory."
|
|
1121
|
+
)
|
|
1122
|
+
def replicate(
|
|
1123
|
+
provider,
|
|
1124
|
+
model,
|
|
1125
|
+
dataset,
|
|
1126
|
+
n,
|
|
1127
|
+
samples,
|
|
1128
|
+
n_runs,
|
|
1129
|
+
concurrency,
|
|
1130
|
+
data_dir,
|
|
1131
|
+
url,
|
|
1132
|
+
suite,
|
|
1133
|
+
output,
|
|
1134
|
+
):
|
|
1135
|
+
"""Run a benchmark N times and report stability metrics.
|
|
1136
|
+
|
|
1137
|
+
Answers: "how stable is this provider's score?"
|
|
1138
|
+
|
|
1139
|
+
Example:
|
|
1140
|
+
synthbench replicate --provider raw-anthropic --n-runs 5 --suite core
|
|
1141
|
+
"""
|
|
1142
|
+
asyncio.run(
|
|
1143
|
+
_replicate_async(
|
|
1144
|
+
provider,
|
|
1145
|
+
model,
|
|
1146
|
+
dataset,
|
|
1147
|
+
n,
|
|
1148
|
+
samples,
|
|
1149
|
+
n_runs,
|
|
1150
|
+
concurrency,
|
|
1151
|
+
data_dir,
|
|
1152
|
+
url,
|
|
1153
|
+
suite,
|
|
1154
|
+
output,
|
|
1155
|
+
)
|
|
1156
|
+
)
|
|
1157
|
+
|
|
1158
|
+
|
|
1159
|
+
async def _replicate_async(
|
|
1160
|
+
provider_name,
|
|
1161
|
+
model,
|
|
1162
|
+
dataset_name,
|
|
1163
|
+
n,
|
|
1164
|
+
samples,
|
|
1165
|
+
n_runs,
|
|
1166
|
+
concurrency,
|
|
1167
|
+
data_dir,
|
|
1168
|
+
url,
|
|
1169
|
+
suite,
|
|
1170
|
+
output,
|
|
1171
|
+
):
|
|
1172
|
+
from synthbench.datasets import DATASETS
|
|
1173
|
+
from synthbench.providers import load_provider
|
|
1174
|
+
from synthbench.runner import BenchmarkRunner
|
|
1175
|
+
import statistics
|
|
1176
|
+
|
|
1177
|
+
resolved_model = MODEL_ALIASES.get(model, model)
|
|
1178
|
+
|
|
1179
|
+
if dataset_name not in DATASETS:
|
|
1180
|
+
click.echo(f"Unknown dataset '{dataset_name}'.", err=True)
|
|
1181
|
+
sys.exit(1)
|
|
1182
|
+
|
|
1183
|
+
ds_kwargs = {}
|
|
1184
|
+
if data_dir:
|
|
1185
|
+
ds_kwargs["data_dir"] = data_dir
|
|
1186
|
+
ds = DATASETS[dataset_name](**ds_kwargs)
|
|
1187
|
+
|
|
1188
|
+
provider_kwargs = _provider_kwargs(provider_name, model=resolved_model, url=url)
|
|
1189
|
+
try:
|
|
1190
|
+
prov = load_provider(provider_name, **provider_kwargs)
|
|
1191
|
+
except (KeyError, ImportError) as e:
|
|
1192
|
+
click.echo(str(e), err=True)
|
|
1193
|
+
sys.exit(1)
|
|
1194
|
+
|
|
1195
|
+
# Load suite
|
|
1196
|
+
question_keys = None
|
|
1197
|
+
if suite:
|
|
1198
|
+
from synthbench.suites import load_suite
|
|
1199
|
+
|
|
1200
|
+
question_keys = load_suite(suite)
|
|
1201
|
+
|
|
1202
|
+
runner = BenchmarkRunner(
|
|
1203
|
+
dataset=ds,
|
|
1204
|
+
provider=prov,
|
|
1205
|
+
samples_per_question=samples,
|
|
1206
|
+
concurrency=concurrency,
|
|
1207
|
+
)
|
|
1208
|
+
|
|
1209
|
+
suite_label = f"suite={suite}" if suite else (str(n) if n else "all")
|
|
1210
|
+
click.echo(f"SynthBench Replicate v{__version__}")
|
|
1211
|
+
click.echo(f" Provider: {prov.name}")
|
|
1212
|
+
click.echo(f" Dataset: {ds.name}")
|
|
1213
|
+
click.echo(f" Questions: {suite_label}")
|
|
1214
|
+
click.echo(f" Samples/q: {samples}")
|
|
1215
|
+
click.echo(f" Runs: {n_runs}")
|
|
1216
|
+
click.echo()
|
|
1217
|
+
|
|
1218
|
+
run_metrics: list[dict[str, float]] = []
|
|
1219
|
+
|
|
1220
|
+
try:
|
|
1221
|
+
for run_i in range(n_runs):
|
|
1222
|
+
click.echo(f" Run {run_i + 1}/{n_runs}...", nl=False)
|
|
1223
|
+
result = await runner.run(n=n, question_keys=question_keys)
|
|
1224
|
+
metrics = {"sps": result.sps, **result.sps_components}
|
|
1225
|
+
run_metrics.append(metrics)
|
|
1226
|
+
click.echo(f" SPS={result.sps:.4f}")
|
|
1227
|
+
finally:
|
|
1228
|
+
await prov.close()
|
|
1229
|
+
|
|
1230
|
+
click.echo()
|
|
1231
|
+
|
|
1232
|
+
# Aggregate
|
|
1233
|
+
all_metric_names = sorted(set().union(*run_metrics))
|
|
1234
|
+
lines = [
|
|
1235
|
+
"# SynthBench Replication Report",
|
|
1236
|
+
"",
|
|
1237
|
+
f"**Provider:** {prov.name}",
|
|
1238
|
+
f"**Runs:** {n_runs}",
|
|
1239
|
+
f"**Questions:** {suite_label}",
|
|
1240
|
+
f"**Samples/q:** {samples}",
|
|
1241
|
+
"",
|
|
1242
|
+
"| Metric | Mean | Std | Min | Max | CV |",
|
|
1243
|
+
"|--------|------|-----|-----|-----|-----|",
|
|
1244
|
+
]
|
|
1245
|
+
|
|
1246
|
+
summary = {}
|
|
1247
|
+
for metric in all_metric_names:
|
|
1248
|
+
vals = [rm[metric] for rm in run_metrics if metric in rm]
|
|
1249
|
+
if not vals:
|
|
1250
|
+
continue
|
|
1251
|
+
mean_val = statistics.mean(vals)
|
|
1252
|
+
std_val = statistics.stdev(vals) if len(vals) > 1 else 0.0
|
|
1253
|
+
min_val = min(vals)
|
|
1254
|
+
max_val = max(vals)
|
|
1255
|
+
cv = std_val / mean_val if mean_val > 0 else 0.0
|
|
1256
|
+
|
|
1257
|
+
lines.append(
|
|
1258
|
+
f"| {metric} | {mean_val:.4f} | {std_val:.4f} "
|
|
1259
|
+
f"| {min_val:.4f} | {max_val:.4f} | {cv:.4f} |"
|
|
1260
|
+
)
|
|
1261
|
+
summary[metric] = {
|
|
1262
|
+
"mean": round(mean_val, 6),
|
|
1263
|
+
"std": round(std_val, 6),
|
|
1264
|
+
"min": round(min_val, 6),
|
|
1265
|
+
"max": round(max_val, 6),
|
|
1266
|
+
"cv": round(cv, 6),
|
|
1267
|
+
"values": [round(v, 6) for v in vals],
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
lines.append("")
|
|
1271
|
+
md = "\n".join(lines)
|
|
1272
|
+
click.echo(md)
|
|
1273
|
+
|
|
1274
|
+
# Save
|
|
1275
|
+
out_dir = Path(output)
|
|
1276
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
1277
|
+
from datetime import datetime
|
|
1278
|
+
|
|
1279
|
+
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
1280
|
+
provider_slug = prov.name.replace("/", "_")
|
|
1281
|
+
|
|
1282
|
+
json_data = {
|
|
1283
|
+
"benchmark": "synthbench",
|
|
1284
|
+
"type": "replication",
|
|
1285
|
+
"version": __version__,
|
|
1286
|
+
"config": {
|
|
1287
|
+
"provider": prov.name,
|
|
1288
|
+
"dataset": ds.name,
|
|
1289
|
+
"n_runs": n_runs,
|
|
1290
|
+
"n_questions": suite_label,
|
|
1291
|
+
"samples_per_question": samples,
|
|
1292
|
+
},
|
|
1293
|
+
"per_run": run_metrics,
|
|
1294
|
+
"summary": summary,
|
|
1295
|
+
}
|
|
1296
|
+
json_path = out_dir / f"replicate_{dataset_name}_{provider_slug}_{ts}.json"
|
|
1297
|
+
json_path.write_text(json.dumps(json_data, indent=2))
|
|
1298
|
+
click.echo(f"\nSaved: {json_path}")
|
|
1299
|
+
|
|
1300
|
+
|
|
1301
|
+
@main.command()
|
|
1302
|
+
@click.option(
|
|
1303
|
+
"--results-dir",
|
|
1304
|
+
"-d",
|
|
1305
|
+
type=click.Path(exists=True),
|
|
1306
|
+
default="results",
|
|
1307
|
+
help="Directory containing result JSON files.",
|
|
1308
|
+
)
|
|
1309
|
+
@click.option(
|
|
1310
|
+
"--output",
|
|
1311
|
+
"-o",
|
|
1312
|
+
type=click.Path(),
|
|
1313
|
+
default=None,
|
|
1314
|
+
help="Save markdown + JSON output.",
|
|
1315
|
+
)
|
|
1316
|
+
@click.option("--json", "json_only", is_flag=True, help="Output JSON only.")
|
|
1317
|
+
@click.option(
|
|
1318
|
+
"--topic",
|
|
1319
|
+
type=click.Choice(["political", "consumer", "neutral"]),
|
|
1320
|
+
default=None,
|
|
1321
|
+
help="Filter to results for a specific topic.",
|
|
1322
|
+
)
|
|
1323
|
+
@click.option("--all", "show_all", is_flag=True, help="Show all runs (no dedup).")
|
|
1324
|
+
@click.option(
|
|
1325
|
+
"--model", "model_filter", default=None, help="Filter to a specific model."
|
|
1326
|
+
)
|
|
1327
|
+
def leaderboard(results_dir, output, json_only, topic, show_all, model_filter):
|
|
1328
|
+
"""Build a ranked leaderboard from all result files in a directory.
|
|
1329
|
+
|
|
1330
|
+
Example:
|
|
1331
|
+
synthbench leaderboard --results-dir ./results
|
|
1332
|
+
synthbench leaderboard --results-dir ./results --all
|
|
1333
|
+
synthbench leaderboard --results-dir ./results --model haiku
|
|
1334
|
+
synthbench leaderboard --results-dir ./results --topic consumer
|
|
1335
|
+
"""
|
|
1336
|
+
from synthbench.leaderboard import load_result, build_leaderboard
|
|
1337
|
+
|
|
1338
|
+
results_path = Path(results_dir)
|
|
1339
|
+
json_files = sorted(results_path.glob("*.json"))
|
|
1340
|
+
|
|
1341
|
+
if not json_files:
|
|
1342
|
+
click.echo(f"No JSON files found in {results_dir}", err=True)
|
|
1343
|
+
sys.exit(1)
|
|
1344
|
+
|
|
1345
|
+
# Load and filter to valid synthbench results
|
|
1346
|
+
results = []
|
|
1347
|
+
for jf in json_files:
|
|
1348
|
+
try:
|
|
1349
|
+
data = load_result(jf)
|
|
1350
|
+
if data.get("benchmark") == "synthbench":
|
|
1351
|
+
results.append(data)
|
|
1352
|
+
except (json.JSONDecodeError, KeyError):
|
|
1353
|
+
continue
|
|
1354
|
+
|
|
1355
|
+
if not results:
|
|
1356
|
+
click.echo("No valid SynthBench result files found.", err=True)
|
|
1357
|
+
sys.exit(1)
|
|
1358
|
+
|
|
1359
|
+
# Filter by topic if specified
|
|
1360
|
+
if topic:
|
|
1361
|
+
results = [r for r in results if r.get("config", {}).get("topic") == topic]
|
|
1362
|
+
if not results:
|
|
1363
|
+
click.echo(f"No results found for topic '{topic}'.", err=True)
|
|
1364
|
+
sys.exit(1)
|
|
1365
|
+
|
|
1366
|
+
md, lb_json = build_leaderboard(
|
|
1367
|
+
results, show_all=show_all, model_filter=model_filter
|
|
1368
|
+
)
|
|
1369
|
+
|
|
1370
|
+
if json_only:
|
|
1371
|
+
click.echo(json.dumps(lb_json, indent=2))
|
|
1372
|
+
else:
|
|
1373
|
+
click.echo(md)
|
|
1374
|
+
|
|
1375
|
+
if output:
|
|
1376
|
+
out = Path(output)
|
|
1377
|
+
if json_only:
|
|
1378
|
+
out.write_text(json.dumps(lb_json, indent=2))
|
|
1379
|
+
else:
|
|
1380
|
+
out.write_text(md)
|
|
1381
|
+
# Also write JSON sidecar
|
|
1382
|
+
json_out = out.with_suffix(".json")
|
|
1383
|
+
json_out.write_text(json.dumps(lb_json, indent=2))
|
|
1384
|
+
click.echo(f"\nSaved: {out} + {json_out}", err=True)
|
|
1385
|
+
|
|
1386
|
+
|
|
1387
|
+
@main.command()
|
|
1388
|
+
@click.option(
|
|
1389
|
+
"--provider",
|
|
1390
|
+
"-p",
|
|
1391
|
+
required=True,
|
|
1392
|
+
help="Provider name (raw-anthropic, openrouter, etc.).",
|
|
1393
|
+
)
|
|
1394
|
+
@click.option(
|
|
1395
|
+
"--model",
|
|
1396
|
+
"-m",
|
|
1397
|
+
default="haiku",
|
|
1398
|
+
help="Model name or alias.",
|
|
1399
|
+
)
|
|
1400
|
+
@click.option(
|
|
1401
|
+
"--output",
|
|
1402
|
+
"-o",
|
|
1403
|
+
type=click.Path(),
|
|
1404
|
+
default="results",
|
|
1405
|
+
help="Output directory for results.",
|
|
1406
|
+
)
|
|
1407
|
+
@click.option(
|
|
1408
|
+
"--concurrency",
|
|
1409
|
+
"-c",
|
|
1410
|
+
type=int,
|
|
1411
|
+
default=10,
|
|
1412
|
+
help="Max concurrent API requests.",
|
|
1413
|
+
)
|
|
1414
|
+
@click.option(
|
|
1415
|
+
"--check",
|
|
1416
|
+
is_flag=True,
|
|
1417
|
+
help="Dry-run: show [DONE]/[MISSING]/[PARTIAL] for each suite item.",
|
|
1418
|
+
)
|
|
1419
|
+
@click.option(
|
|
1420
|
+
"--force", is_flag=True, help="Re-run all benchmarks ignoring existing results."
|
|
1421
|
+
)
|
|
1422
|
+
@click.option(
|
|
1423
|
+
"--repeats",
|
|
1424
|
+
type=int,
|
|
1425
|
+
default=None,
|
|
1426
|
+
help="Override per-run repeat count (default: from suite YAML, typically 3).",
|
|
1427
|
+
)
|
|
1428
|
+
@click.option(
|
|
1429
|
+
"--suite-name",
|
|
1430
|
+
default="standard",
|
|
1431
|
+
help="Suite config name (default: standard).",
|
|
1432
|
+
)
|
|
1433
|
+
@click.option("--url", default=None, help="Endpoint URL for http provider.")
|
|
1434
|
+
@click.option(
|
|
1435
|
+
"--data-dir", type=click.Path(), default=None, help="Custom data directory."
|
|
1436
|
+
)
|
|
1437
|
+
@click.option(
|
|
1438
|
+
"--temperature",
|
|
1439
|
+
type=float,
|
|
1440
|
+
default=None,
|
|
1441
|
+
help="Sampling temperature to pass to the provider (e.g., 0.7, 1.0).",
|
|
1442
|
+
)
|
|
1443
|
+
def suite(
|
|
1444
|
+
provider,
|
|
1445
|
+
model,
|
|
1446
|
+
output,
|
|
1447
|
+
concurrency,
|
|
1448
|
+
check,
|
|
1449
|
+
force,
|
|
1450
|
+
repeats,
|
|
1451
|
+
suite_name,
|
|
1452
|
+
url,
|
|
1453
|
+
data_dir,
|
|
1454
|
+
temperature,
|
|
1455
|
+
):
|
|
1456
|
+
"""Run a full structured benchmark matrix for a provider.
|
|
1457
|
+
|
|
1458
|
+
Executes all runs defined in the suite config (default: 7 runs x 3 repeats
|
|
1459
|
+
covering OpinionsQA, GlobalOpinionQA, SubPOP, and a replicate).
|
|
1460
|
+
|
|
1461
|
+
Gap-fill mode (default): only runs missing or partially-completed repeats.
|
|
1462
|
+
New tests added to the suite YAML are automatically picked up.
|
|
1463
|
+
Use --force to re-run everything.
|
|
1464
|
+
|
|
1465
|
+
Variance reporting: after repeats, shows mean, std, CV for each run.
|
|
1466
|
+
Flags runs where CV > 2% as high variance.
|
|
1467
|
+
|
|
1468
|
+
Examples:
|
|
1469
|
+
synthbench suite --provider openrouter --model anthropic/claude-haiku-4-5
|
|
1470
|
+
synthbench suite --check --provider openrouter --model anthropic/claude-haiku-4-5
|
|
1471
|
+
synthbench suite --provider openrouter --model anthropic/claude-haiku-4-5 --force
|
|
1472
|
+
synthbench suite --provider openrouter --model anthropic/claude-haiku-4-5 --repeats 5
|
|
1473
|
+
"""
|
|
1474
|
+
from synthbench.suite import (
|
|
1475
|
+
check_suite,
|
|
1476
|
+
format_check,
|
|
1477
|
+
format_summary,
|
|
1478
|
+
run_suite as _run_suite,
|
|
1479
|
+
)
|
|
1480
|
+
from synthbench.providers import load_provider
|
|
1481
|
+
|
|
1482
|
+
output_dir = Path(output)
|
|
1483
|
+
resolved_model = MODEL_ALIASES.get(model, model)
|
|
1484
|
+
|
|
1485
|
+
# Resolve the provider name for matching
|
|
1486
|
+
provider_kwargs = _provider_kwargs(
|
|
1487
|
+
provider, model=resolved_model, url=url, temperature=temperature
|
|
1488
|
+
)
|
|
1489
|
+
try:
|
|
1490
|
+
prov = load_provider(provider, **provider_kwargs)
|
|
1491
|
+
resolved_provider = prov.name
|
|
1492
|
+
except (KeyError, ImportError) as e:
|
|
1493
|
+
click.echo(str(e), err=True)
|
|
1494
|
+
sys.exit(1)
|
|
1495
|
+
|
|
1496
|
+
if check:
|
|
1497
|
+
annotated = check_suite(
|
|
1498
|
+
resolved_provider, output_dir, suite_name, repeats_override=repeats
|
|
1499
|
+
)
|
|
1500
|
+
click.echo(format_check(annotated, resolved_provider))
|
|
1501
|
+
return
|
|
1502
|
+
|
|
1503
|
+
mode = "force (re-run all)" if force else "gap-fill (skip existing)"
|
|
1504
|
+
click.echo(f"SynthBench Suite v{__version__}")
|
|
1505
|
+
click.echo(f" Provider: {resolved_provider}")
|
|
1506
|
+
click.echo(f" Suite: {suite_name}")
|
|
1507
|
+
click.echo(f" Output: {output_dir}")
|
|
1508
|
+
click.echo(f" Mode: {mode}")
|
|
1509
|
+
if repeats is not None:
|
|
1510
|
+
click.echo(f" Repeats: {repeats} (override)")
|
|
1511
|
+
click.echo()
|
|
1512
|
+
|
|
1513
|
+
summaries = asyncio.run(
|
|
1514
|
+
_run_suite(
|
|
1515
|
+
provider_name=provider,
|
|
1516
|
+
model=model,
|
|
1517
|
+
output_dir=output_dir,
|
|
1518
|
+
concurrency=concurrency,
|
|
1519
|
+
suite_name=suite_name,
|
|
1520
|
+
url=url,
|
|
1521
|
+
data_dir=data_dir,
|
|
1522
|
+
force=force,
|
|
1523
|
+
repeats_override=repeats,
|
|
1524
|
+
temperature=temperature,
|
|
1525
|
+
)
|
|
1526
|
+
)
|
|
1527
|
+
|
|
1528
|
+
click.echo()
|
|
1529
|
+
click.echo(format_summary(summaries, resolved_provider))
|
|
1530
|
+
|
|
1531
|
+
|
|
1532
|
+
@main.group("convergence")
|
|
1533
|
+
def convergence_group():
|
|
1534
|
+
"""Convergence analysis: cross-model contamination + bootstrap curves."""
|
|
1535
|
+
pass
|
|
1536
|
+
|
|
1537
|
+
|
|
1538
|
+
@convergence_group.command("cross-model")
|
|
1539
|
+
@click.option(
|
|
1540
|
+
"--results-dir",
|
|
1541
|
+
"-d",
|
|
1542
|
+
type=click.Path(exists=True),
|
|
1543
|
+
default="leaderboard-results",
|
|
1544
|
+
help="Directory containing result JSON files.",
|
|
1545
|
+
)
|
|
1546
|
+
@click.option(
|
|
1547
|
+
"--output",
|
|
1548
|
+
"-o",
|
|
1549
|
+
type=click.Path(),
|
|
1550
|
+
default=None,
|
|
1551
|
+
help="Save report to file (markdown + JSON sidecar).",
|
|
1552
|
+
)
|
|
1553
|
+
@click.option("--json", "json_only", is_flag=True, help="Output JSON only.")
|
|
1554
|
+
@click.option(
|
|
1555
|
+
"--min-models",
|
|
1556
|
+
type=int,
|
|
1557
|
+
default=2,
|
|
1558
|
+
help="Minimum models a question must appear in (default: 2).",
|
|
1559
|
+
)
|
|
1560
|
+
@click.option(
|
|
1561
|
+
"--model",
|
|
1562
|
+
"model_filter",
|
|
1563
|
+
default=None,
|
|
1564
|
+
help="Filter to results matching a specific model/provider substring.",
|
|
1565
|
+
)
|
|
1566
|
+
@click.option(
|
|
1567
|
+
"--dataset",
|
|
1568
|
+
"dataset_filter",
|
|
1569
|
+
default=None,
|
|
1570
|
+
help="Filter to results for a specific dataset.",
|
|
1571
|
+
)
|
|
1572
|
+
def contamination_convergence(
|
|
1573
|
+
results_dir, output, json_only, min_models, model_filter, dataset_filter
|
|
1574
|
+
):
|
|
1575
|
+
"""Detect per-question contamination via cross-model convergence.
|
|
1576
|
+
|
|
1577
|
+
For each question across all result files, computes the standard deviation
|
|
1578
|
+
of model response distributions. Low variance = likely contaminated (all
|
|
1579
|
+
models recall the same memorized data). High variance = genuine reasoning.
|
|
1580
|
+
|
|
1581
|
+
Example:
|
|
1582
|
+
synthbench convergence cross-model
|
|
1583
|
+
synthbench convergence cross-model --results-dir ./results
|
|
1584
|
+
synthbench convergence cross-model --dataset opinionsqa --min-models 3
|
|
1585
|
+
synthbench convergence cross-model --json
|
|
1586
|
+
"""
|
|
1587
|
+
from synthbench.contamination import (
|
|
1588
|
+
convergence_analysis,
|
|
1589
|
+
convergence_to_json,
|
|
1590
|
+
format_convergence_report,
|
|
1591
|
+
)
|
|
1592
|
+
|
|
1593
|
+
results_path = Path(results_dir)
|
|
1594
|
+
json_files = sorted(results_path.glob("*.json"))
|
|
1595
|
+
|
|
1596
|
+
if not json_files:
|
|
1597
|
+
click.echo(f"No JSON files found in {results_dir}", err=True)
|
|
1598
|
+
sys.exit(1)
|
|
1599
|
+
|
|
1600
|
+
# Filter files
|
|
1601
|
+
if model_filter or dataset_filter:
|
|
1602
|
+
filtered = []
|
|
1603
|
+
for jf in json_files:
|
|
1604
|
+
try:
|
|
1605
|
+
with open(jf) as f:
|
|
1606
|
+
data = json.load(f)
|
|
1607
|
+
if data.get("benchmark") != "synthbench":
|
|
1608
|
+
continue
|
|
1609
|
+
cfg = data.get("config", {})
|
|
1610
|
+
if model_filter and model_filter not in cfg.get("provider", ""):
|
|
1611
|
+
continue
|
|
1612
|
+
if dataset_filter and cfg.get("dataset") != dataset_filter:
|
|
1613
|
+
continue
|
|
1614
|
+
filtered.append(jf)
|
|
1615
|
+
except (json.JSONDecodeError, KeyError):
|
|
1616
|
+
continue
|
|
1617
|
+
json_files = filtered
|
|
1618
|
+
|
|
1619
|
+
if len(json_files) < 2:
|
|
1620
|
+
click.echo(
|
|
1621
|
+
f"Need at least 2 result files for convergence analysis, found {len(json_files)}",
|
|
1622
|
+
err=True,
|
|
1623
|
+
)
|
|
1624
|
+
sys.exit(1)
|
|
1625
|
+
|
|
1626
|
+
click.echo(f"Analyzing {len(json_files)} result files...", err=True)
|
|
1627
|
+
|
|
1628
|
+
try:
|
|
1629
|
+
analysis = convergence_analysis(json_files, min_models=min_models)
|
|
1630
|
+
except ValueError as e:
|
|
1631
|
+
click.echo(f"Error: {e}", err=True)
|
|
1632
|
+
sys.exit(1)
|
|
1633
|
+
|
|
1634
|
+
if json_only:
|
|
1635
|
+
click.echo(json.dumps(convergence_to_json(analysis), indent=2))
|
|
1636
|
+
else:
|
|
1637
|
+
click.echo(format_convergence_report(analysis))
|
|
1638
|
+
|
|
1639
|
+
if output:
|
|
1640
|
+
out = Path(output)
|
|
1641
|
+
if json_only:
|
|
1642
|
+
out.write_text(json.dumps(convergence_to_json(analysis), indent=2))
|
|
1643
|
+
else:
|
|
1644
|
+
out.write_text(format_convergence_report(analysis))
|
|
1645
|
+
json_out = out.with_suffix(".json")
|
|
1646
|
+
json_out.write_text(json.dumps(convergence_to_json(analysis), indent=2))
|
|
1647
|
+
click.echo(f"\nSaved: {out} + {json_out}", err=True)
|
|
1648
|
+
|
|
1649
|
+
|
|
1650
|
+
@convergence_group.command("bootstrap")
|
|
1651
|
+
@click.option(
|
|
1652
|
+
"--dataset",
|
|
1653
|
+
"-d",
|
|
1654
|
+
required=True,
|
|
1655
|
+
help="Dataset name (gss, wvs, eurobarometer, opinionsqa, pewtech, "
|
|
1656
|
+
"michigan, ntia, globalopinionqa, subpop).",
|
|
1657
|
+
)
|
|
1658
|
+
@click.option(
|
|
1659
|
+
"--question",
|
|
1660
|
+
"-q",
|
|
1661
|
+
default=None,
|
|
1662
|
+
help="Restrict to a single question key (default: all questions).",
|
|
1663
|
+
)
|
|
1664
|
+
@click.option(
|
|
1665
|
+
"--output",
|
|
1666
|
+
"-o",
|
|
1667
|
+
type=click.Path(),
|
|
1668
|
+
default=None,
|
|
1669
|
+
help="Write JSON to this path (default: stdout).",
|
|
1670
|
+
)
|
|
1671
|
+
@click.option(
|
|
1672
|
+
"--plot",
|
|
1673
|
+
is_flag=True,
|
|
1674
|
+
help="Also emit a matplotlib PDF of per-question curves "
|
|
1675
|
+
"(requires matplotlib; written alongside --output).",
|
|
1676
|
+
)
|
|
1677
|
+
@click.option(
|
|
1678
|
+
"--bootstraps",
|
|
1679
|
+
"-b",
|
|
1680
|
+
type=int,
|
|
1681
|
+
default=None,
|
|
1682
|
+
help="Bootstrap replicates per sample size (default: 500).",
|
|
1683
|
+
)
|
|
1684
|
+
@click.option(
|
|
1685
|
+
"--sample-sizes",
|
|
1686
|
+
default=None,
|
|
1687
|
+
help="Comma-separated sample sizes (default: 20,50,100,200,500,1000,2000,5000,10000).",
|
|
1688
|
+
)
|
|
1689
|
+
@click.option(
|
|
1690
|
+
"--epsilon",
|
|
1691
|
+
type=float,
|
|
1692
|
+
default=None,
|
|
1693
|
+
help="JSD ceiling for 'convergence n' threshold (default: 0.02).",
|
|
1694
|
+
)
|
|
1695
|
+
@click.option(
|
|
1696
|
+
"--delta",
|
|
1697
|
+
type=float,
|
|
1698
|
+
default=None,
|
|
1699
|
+
help="Max allowed drop in jsd_mean across next 2 points (default: 0.005).",
|
|
1700
|
+
)
|
|
1701
|
+
@click.option(
|
|
1702
|
+
"--seed",
|
|
1703
|
+
type=int,
|
|
1704
|
+
default=None,
|
|
1705
|
+
help="Seed for reproducibility (default: non-deterministic).",
|
|
1706
|
+
)
|
|
1707
|
+
@click.option(
|
|
1708
|
+
"--n",
|
|
1709
|
+
type=int,
|
|
1710
|
+
default=None,
|
|
1711
|
+
help="Limit to first N questions of the dataset (debugging).",
|
|
1712
|
+
)
|
|
1713
|
+
def convergence_bootstrap(
|
|
1714
|
+
dataset,
|
|
1715
|
+
question,
|
|
1716
|
+
output,
|
|
1717
|
+
plot,
|
|
1718
|
+
bootstraps,
|
|
1719
|
+
sample_sizes,
|
|
1720
|
+
epsilon,
|
|
1721
|
+
delta,
|
|
1722
|
+
seed,
|
|
1723
|
+
n,
|
|
1724
|
+
):
|
|
1725
|
+
"""Bootstrap convergence curves from aggregate human distributions.
|
|
1726
|
+
|
|
1727
|
+
For each question in DATASET, multinomially sub-samples responses at
|
|
1728
|
+
increasing sample sizes and measures JSD(sample, full_distribution). The
|
|
1729
|
+
resulting curve is the theoretical ~1/sqrt(n) floor an idealized i.i.d.
|
|
1730
|
+
sampler would achieve. Use this as the baseline that real human and
|
|
1731
|
+
synthetic sampling gets compared against.
|
|
1732
|
+
|
|
1733
|
+
Redistribution policy is honored: 'full' and 'gated' datasets emit the
|
|
1734
|
+
full per-question artifact (gated routes to R2 at publish time);
|
|
1735
|
+
'aggregates_only' emits dataset-wide summary stats only; 'citation_only'
|
|
1736
|
+
suppresses all artifacts.
|
|
1737
|
+
|
|
1738
|
+
Example:
|
|
1739
|
+
synthbench convergence bootstrap --dataset opinionsqa --output curves.json
|
|
1740
|
+
synthbench convergence bootstrap --dataset gss --plot --output gss.json
|
|
1741
|
+
"""
|
|
1742
|
+
from synthbench.convergence.cli_report import run_bootstrap
|
|
1743
|
+
|
|
1744
|
+
try:
|
|
1745
|
+
payload, pdf_path = run_bootstrap(
|
|
1746
|
+
dataset_name=dataset,
|
|
1747
|
+
question_key=question,
|
|
1748
|
+
output=Path(output) if output else None,
|
|
1749
|
+
plot=plot,
|
|
1750
|
+
bootstraps=bootstraps,
|
|
1751
|
+
sample_sizes=sample_sizes,
|
|
1752
|
+
epsilon=epsilon,
|
|
1753
|
+
delta=delta,
|
|
1754
|
+
seed=seed,
|
|
1755
|
+
n_limit=n,
|
|
1756
|
+
)
|
|
1757
|
+
except ValueError as e:
|
|
1758
|
+
click.echo(f"Error: {e}", err=True)
|
|
1759
|
+
sys.exit(1)
|
|
1760
|
+
|
|
1761
|
+
if output:
|
|
1762
|
+
click.echo(f"Wrote {output}", err=True)
|
|
1763
|
+
if pdf_path:
|
|
1764
|
+
click.echo(f"Wrote {pdf_path}", err=True)
|
|
1765
|
+
else:
|
|
1766
|
+
click.echo(json.dumps(payload, indent=2))
|
|
1767
|
+
|
|
1768
|
+
|
|
1769
|
+
@convergence_group.command("real")
|
|
1770
|
+
@click.option("--dataset", "-d", required=True, help="Dataset name (e.g. gss).")
|
|
1771
|
+
@click.option(
|
|
1772
|
+
"--question",
|
|
1773
|
+
"-q",
|
|
1774
|
+
required=True,
|
|
1775
|
+
help="Question key (bare upstream id like 'SPKATH' or prefixed 'GSS_SPKATH').",
|
|
1776
|
+
)
|
|
1777
|
+
@click.option(
|
|
1778
|
+
"--output",
|
|
1779
|
+
"-o",
|
|
1780
|
+
type=click.Path(),
|
|
1781
|
+
default=None,
|
|
1782
|
+
help="Write JSON to this path (default: stdout).",
|
|
1783
|
+
)
|
|
1784
|
+
@click.option(
|
|
1785
|
+
"--bootstraps",
|
|
1786
|
+
"-b",
|
|
1787
|
+
type=int,
|
|
1788
|
+
default=None,
|
|
1789
|
+
help="Sub-sample replicates per sample size (default: 500).",
|
|
1790
|
+
)
|
|
1791
|
+
@click.option(
|
|
1792
|
+
"--sample-sizes",
|
|
1793
|
+
default=None,
|
|
1794
|
+
help="Comma-separated sample sizes (default: 20,50,100,200,500,1000,2000,5000,10000).",
|
|
1795
|
+
)
|
|
1796
|
+
@click.option("--epsilon", type=float, default=None)
|
|
1797
|
+
@click.option("--delta", type=float, default=None)
|
|
1798
|
+
@click.option("--seed", type=int, default=None)
|
|
1799
|
+
def convergence_real(
|
|
1800
|
+
dataset, question, output, bootstraps, sample_sizes, epsilon, delta, seed
|
|
1801
|
+
):
|
|
1802
|
+
"""Real-sampling convergence curve from individual-level microdata.
|
|
1803
|
+
|
|
1804
|
+
Sub-samples actual respondents (without replacement) at each sample size
|
|
1805
|
+
and measures JSD vs. the full-population distribution. Errors cleanly
|
|
1806
|
+
when the dataset's adapter does not provide microdata.
|
|
1807
|
+
|
|
1808
|
+
Example:
|
|
1809
|
+
synthbench convergence real --dataset gss --question SPKATH \\
|
|
1810
|
+
--output /tmp/gss-real.json
|
|
1811
|
+
"""
|
|
1812
|
+
from synthbench.convergence.cli_report import run_real
|
|
1813
|
+
|
|
1814
|
+
try:
|
|
1815
|
+
payload = run_real(
|
|
1816
|
+
dataset_name=dataset,
|
|
1817
|
+
question_key=question,
|
|
1818
|
+
output=Path(output) if output else None,
|
|
1819
|
+
bootstraps=bootstraps,
|
|
1820
|
+
sample_sizes=sample_sizes,
|
|
1821
|
+
epsilon=epsilon,
|
|
1822
|
+
delta=delta,
|
|
1823
|
+
seed=seed,
|
|
1824
|
+
)
|
|
1825
|
+
except ValueError as e:
|
|
1826
|
+
click.echo(f"Error: {e}", err=True)
|
|
1827
|
+
sys.exit(1)
|
|
1828
|
+
|
|
1829
|
+
if output:
|
|
1830
|
+
click.echo(f"Wrote {output}", err=True)
|
|
1831
|
+
else:
|
|
1832
|
+
click.echo(json.dumps(payload, indent=2))
|
|
1833
|
+
|
|
1834
|
+
|
|
1835
|
+
@convergence_group.command("compare")
|
|
1836
|
+
@click.option("--dataset", "-d", required=True)
|
|
1837
|
+
@click.option(
|
|
1838
|
+
"--question",
|
|
1839
|
+
"-q",
|
|
1840
|
+
required=True,
|
|
1841
|
+
help="Question key (bare upstream id like 'SPKATH' or prefixed 'GSS_SPKATH').",
|
|
1842
|
+
)
|
|
1843
|
+
@click.option("--output", "-o", type=click.Path(), default=None)
|
|
1844
|
+
@click.option("--bootstraps", "-b", type=int, default=None)
|
|
1845
|
+
@click.option("--sample-sizes", default=None)
|
|
1846
|
+
@click.option("--epsilon", type=float, default=None)
|
|
1847
|
+
@click.option("--delta", type=float, default=None)
|
|
1848
|
+
@click.option("--seed", type=int, default=None)
|
|
1849
|
+
def convergence_compare(
|
|
1850
|
+
dataset, question, output, bootstraps, sample_sizes, epsilon, delta, seed
|
|
1851
|
+
):
|
|
1852
|
+
"""Side-by-side bootstrap (idealized) and real-sampling curves.
|
|
1853
|
+
|
|
1854
|
+
Emits both curves under the same parameters with a precomputed
|
|
1855
|
+
``delta_jsd_mean`` per sample size -- the headline gap between the
|
|
1856
|
+
1/sqrt(n) i.i.d. floor and real sampling under population heterogeneity.
|
|
1857
|
+
|
|
1858
|
+
Example:
|
|
1859
|
+
synthbench convergence compare --dataset gss --question SPKATH \\
|
|
1860
|
+
--output /tmp/gss-compare.json
|
|
1861
|
+
"""
|
|
1862
|
+
from synthbench.convergence.cli_report import run_compare
|
|
1863
|
+
|
|
1864
|
+
try:
|
|
1865
|
+
payload = run_compare(
|
|
1866
|
+
dataset_name=dataset,
|
|
1867
|
+
question_key=question,
|
|
1868
|
+
output=Path(output) if output else None,
|
|
1869
|
+
bootstraps=bootstraps,
|
|
1870
|
+
sample_sizes=sample_sizes,
|
|
1871
|
+
epsilon=epsilon,
|
|
1872
|
+
delta=delta,
|
|
1873
|
+
seed=seed,
|
|
1874
|
+
)
|
|
1875
|
+
except ValueError as e:
|
|
1876
|
+
click.echo(f"Error: {e}", err=True)
|
|
1877
|
+
sys.exit(1)
|
|
1878
|
+
|
|
1879
|
+
if output:
|
|
1880
|
+
click.echo(f"Wrote {output}", err=True)
|
|
1881
|
+
else:
|
|
1882
|
+
click.echo(json.dumps(payload, indent=2))
|
|
1883
|
+
|
|
1884
|
+
|
|
1885
|
+
@main.command()
|
|
1886
|
+
@click.option(
|
|
1887
|
+
"--provider",
|
|
1888
|
+
"-p",
|
|
1889
|
+
required=True,
|
|
1890
|
+
help="Provider name (raw-anthropic, openrouter, etc.).",
|
|
1891
|
+
)
|
|
1892
|
+
@click.option(
|
|
1893
|
+
"--model",
|
|
1894
|
+
"-m",
|
|
1895
|
+
default="haiku",
|
|
1896
|
+
help="Model name or alias.",
|
|
1897
|
+
)
|
|
1898
|
+
@click.option(
|
|
1899
|
+
"--output",
|
|
1900
|
+
"-o",
|
|
1901
|
+
type=click.Path(),
|
|
1902
|
+
default="results",
|
|
1903
|
+
help="Output directory for results.",
|
|
1904
|
+
)
|
|
1905
|
+
@click.option(
|
|
1906
|
+
"--samples",
|
|
1907
|
+
"-s",
|
|
1908
|
+
type=int,
|
|
1909
|
+
default=30,
|
|
1910
|
+
help="Samples per question for distribution estimation.",
|
|
1911
|
+
)
|
|
1912
|
+
@click.option(
|
|
1913
|
+
"--concurrency",
|
|
1914
|
+
"-c",
|
|
1915
|
+
type=int,
|
|
1916
|
+
default=10,
|
|
1917
|
+
help="Max concurrent API requests.",
|
|
1918
|
+
)
|
|
1919
|
+
@click.option("--url", default=None, help="Endpoint URL for http provider.")
|
|
1920
|
+
@click.option(
|
|
1921
|
+
"--json-only", is_flag=True, help="Output JSON to stdout instead of files."
|
|
1922
|
+
)
|
|
1923
|
+
def contamination(provider, model, output, samples, concurrency, url, json_only):
|
|
1924
|
+
"""Run paraphrase sensitivity test to detect training corpus contamination.
|
|
1925
|
+
|
|
1926
|
+
Evaluates 50 original questions + 150 paraphrased variants and reports
|
|
1927
|
+
the sensitivity delta. High sensitivity suggests memorization.
|
|
1928
|
+
|
|
1929
|
+
Example:
|
|
1930
|
+
synthbench contamination --provider openrouter --model anthropic/claude-haiku-4-5
|
|
1931
|
+
"""
|
|
1932
|
+
asyncio.run(
|
|
1933
|
+
_contamination_async(
|
|
1934
|
+
provider, model, output, samples, concurrency, url, json_only
|
|
1935
|
+
)
|
|
1936
|
+
)
|
|
1937
|
+
|
|
1938
|
+
|
|
1939
|
+
async def _contamination_async(
|
|
1940
|
+
provider_name, model, output, samples, concurrency, url, json_only
|
|
1941
|
+
):
|
|
1942
|
+
from synthbench.contamination import (
|
|
1943
|
+
run_contamination_test,
|
|
1944
|
+
result_to_json,
|
|
1945
|
+
)
|
|
1946
|
+
from synthbench.providers import load_provider
|
|
1947
|
+
|
|
1948
|
+
resolved_model = MODEL_ALIASES.get(model, model)
|
|
1949
|
+
|
|
1950
|
+
provider_kwargs = _provider_kwargs(provider_name, model=resolved_model, url=url)
|
|
1951
|
+
try:
|
|
1952
|
+
prov = load_provider(provider_name, **provider_kwargs)
|
|
1953
|
+
except (KeyError, ImportError) as e:
|
|
1954
|
+
click.echo(str(e), err=True)
|
|
1955
|
+
sys.exit(1)
|
|
1956
|
+
|
|
1957
|
+
# Suite path
|
|
1958
|
+
suite_path = Path(__file__).parent / "suites" / "paraphrase_test.json"
|
|
1959
|
+
|
|
1960
|
+
click.echo(f"SynthBench Contamination Test v{__version__}")
|
|
1961
|
+
click.echo(f" Provider: {prov.name}")
|
|
1962
|
+
click.echo(f" Samples/q: {samples}")
|
|
1963
|
+
click.echo(" Questions: 50 original + 150 paraphrased")
|
|
1964
|
+
click.echo()
|
|
1965
|
+
|
|
1966
|
+
try:
|
|
1967
|
+
result = await run_contamination_test(
|
|
1968
|
+
provider=prov,
|
|
1969
|
+
samples_per_question=samples,
|
|
1970
|
+
concurrency=concurrency,
|
|
1971
|
+
suite_path=suite_path,
|
|
1972
|
+
)
|
|
1973
|
+
finally:
|
|
1974
|
+
await prov.close()
|
|
1975
|
+
|
|
1976
|
+
# Display results
|
|
1977
|
+
click.echo(
|
|
1978
|
+
f" Parity {result.original_sps:.3f} "
|
|
1979
|
+
f"(adjusted: {result.adjusted_sps:.3f}, "
|
|
1980
|
+
f"sensitivity: {result.sensitivity_pct:.1f}%)"
|
|
1981
|
+
)
|
|
1982
|
+
click.echo(f" Elapsed: {result.elapsed_seconds:.1f}s")
|
|
1983
|
+
click.echo()
|
|
1984
|
+
|
|
1985
|
+
result_data = result_to_json(result)
|
|
1986
|
+
|
|
1987
|
+
if json_only:
|
|
1988
|
+
click.echo(json.dumps(result_data, indent=2))
|
|
1989
|
+
else:
|
|
1990
|
+
# Per-question breakdown (top 10 most sensitive)
|
|
1991
|
+
sorted_qs = sorted(
|
|
1992
|
+
result.per_question, key=lambda q: q.sensitivity_pct, reverse=True
|
|
1993
|
+
)
|
|
1994
|
+
click.echo("## Most Sensitive Questions (top 10)")
|
|
1995
|
+
click.echo()
|
|
1996
|
+
click.echo("| Question | Original | Adjusted | Delta | Sensitivity |")
|
|
1997
|
+
click.echo("|----------|----------|----------|-------|-------------|")
|
|
1998
|
+
for q in sorted_qs[:10]:
|
|
1999
|
+
click.echo(
|
|
2000
|
+
f"| {q.original_text[:50]}... "
|
|
2001
|
+
f"| {q.original_parity:.4f} "
|
|
2002
|
+
f"| {q.mean_paraphrase_parity:.4f} "
|
|
2003
|
+
f"| {q.delta:+.4f} "
|
|
2004
|
+
f"| {q.sensitivity_pct:.1f}% |"
|
|
2005
|
+
)
|
|
2006
|
+
click.echo()
|
|
2007
|
+
|
|
2008
|
+
# Save
|
|
2009
|
+
out_dir = Path(output)
|
|
2010
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
2011
|
+
from datetime import datetime
|
|
2012
|
+
|
|
2013
|
+
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
2014
|
+
provider_slug = prov.name.replace("/", "_")
|
|
2015
|
+
json_path = out_dir / f"contamination_{provider_slug}_{ts}.json"
|
|
2016
|
+
json_path.write_text(json.dumps(result_data, indent=2))
|
|
2017
|
+
click.echo(f"Results saved: {json_path}")
|
|
2018
|
+
|
|
2019
|
+
|
|
2020
|
+
@main.command("contamination-deident")
|
|
2021
|
+
@click.option(
|
|
2022
|
+
"--provider",
|
|
2023
|
+
"-p",
|
|
2024
|
+
required=True,
|
|
2025
|
+
help="Provider name (raw-anthropic, openrouter, etc.).",
|
|
2026
|
+
)
|
|
2027
|
+
@click.option(
|
|
2028
|
+
"--model",
|
|
2029
|
+
"-m",
|
|
2030
|
+
default="haiku",
|
|
2031
|
+
help="Model name or alias.",
|
|
2032
|
+
)
|
|
2033
|
+
@click.option(
|
|
2034
|
+
"--output",
|
|
2035
|
+
"-o",
|
|
2036
|
+
type=click.Path(),
|
|
2037
|
+
default="results",
|
|
2038
|
+
help="Output directory for results.",
|
|
2039
|
+
)
|
|
2040
|
+
@click.option(
|
|
2041
|
+
"--samples",
|
|
2042
|
+
"-s",
|
|
2043
|
+
type=int,
|
|
2044
|
+
default=30,
|
|
2045
|
+
help="Samples per question for distribution estimation.",
|
|
2046
|
+
)
|
|
2047
|
+
@click.option(
|
|
2048
|
+
"--concurrency",
|
|
2049
|
+
"-c",
|
|
2050
|
+
type=int,
|
|
2051
|
+
default=10,
|
|
2052
|
+
help="Max concurrent API requests.",
|
|
2053
|
+
)
|
|
2054
|
+
@click.option("--url", default=None, help="Endpoint URL for http provider.")
|
|
2055
|
+
@click.option(
|
|
2056
|
+
"--json-only", is_flag=True, help="Output JSON to stdout instead of files."
|
|
2057
|
+
)
|
|
2058
|
+
def contamination_deident(
|
|
2059
|
+
provider, model, output, samples, concurrency, url, json_only
|
|
2060
|
+
):
|
|
2061
|
+
"""Run de-identification sensitivity test to detect brand recognition.
|
|
2062
|
+
|
|
2063
|
+
Evaluates 20 well-known topics at 5 progressively abstracted levels
|
|
2064
|
+
(full brand -> abstract feature description). Providers that reason
|
|
2065
|
+
from features produce stable distributions; providers that recognize
|
|
2066
|
+
the brand drift as identifying information is stripped.
|
|
2067
|
+
|
|
2068
|
+
Example:
|
|
2069
|
+
synthbench contamination-deident --provider openrouter --model anthropic/claude-haiku-4-5
|
|
2070
|
+
"""
|
|
2071
|
+
asyncio.run(
|
|
2072
|
+
_contamination_deident_async(
|
|
2073
|
+
provider, model, output, samples, concurrency, url, json_only
|
|
2074
|
+
)
|
|
2075
|
+
)
|
|
2076
|
+
|
|
2077
|
+
|
|
2078
|
+
async def _contamination_deident_async(
|
|
2079
|
+
provider_name, model, output, samples, concurrency, url, json_only
|
|
2080
|
+
):
|
|
2081
|
+
from synthbench.contamination import (
|
|
2082
|
+
deident_result_to_json,
|
|
2083
|
+
run_deident_test,
|
|
2084
|
+
)
|
|
2085
|
+
from synthbench.providers import load_provider
|
|
2086
|
+
|
|
2087
|
+
resolved_model = MODEL_ALIASES.get(model, model)
|
|
2088
|
+
|
|
2089
|
+
provider_kwargs = _provider_kwargs(provider_name, model=resolved_model, url=url)
|
|
2090
|
+
try:
|
|
2091
|
+
prov = load_provider(provider_name, **provider_kwargs)
|
|
2092
|
+
except (KeyError, ImportError) as e:
|
|
2093
|
+
click.echo(str(e), err=True)
|
|
2094
|
+
sys.exit(1)
|
|
2095
|
+
|
|
2096
|
+
suite_path = Path(__file__).parent / "suites" / "deident_test.json"
|
|
2097
|
+
|
|
2098
|
+
click.echo(f"SynthBench De-identification Test v{__version__}")
|
|
2099
|
+
click.echo(f" Provider: {prov.name}")
|
|
2100
|
+
click.echo(f" Samples/q: {samples}")
|
|
2101
|
+
click.echo(" Topics: 20 × 5 levels = 100 distributions")
|
|
2102
|
+
click.echo()
|
|
2103
|
+
|
|
2104
|
+
try:
|
|
2105
|
+
result = await run_deident_test(
|
|
2106
|
+
provider=prov,
|
|
2107
|
+
samples_per_question=samples,
|
|
2108
|
+
concurrency=concurrency,
|
|
2109
|
+
suite_path=suite_path,
|
|
2110
|
+
)
|
|
2111
|
+
finally:
|
|
2112
|
+
await prov.close()
|
|
2113
|
+
|
|
2114
|
+
click.echo(
|
|
2115
|
+
f" Mean pairwise JSD: {result.mean_pairwise_jsd:.4f} "
|
|
2116
|
+
f"(option std {result.mean_option_std:.4f}, "
|
|
2117
|
+
f"L1->L5 drift {result.mean_drift_l1_to_l5:.4f})"
|
|
2118
|
+
)
|
|
2119
|
+
click.echo(f" Elapsed: {result.elapsed_seconds:.1f}s")
|
|
2120
|
+
click.echo()
|
|
2121
|
+
|
|
2122
|
+
result_data = deident_result_to_json(result)
|
|
2123
|
+
|
|
2124
|
+
if json_only:
|
|
2125
|
+
click.echo(json.dumps(result_data, indent=2))
|
|
2126
|
+
else:
|
|
2127
|
+
sorted_topics = sorted(
|
|
2128
|
+
result.per_topic, key=lambda t: t.mean_pairwise_jsd, reverse=True
|
|
2129
|
+
)
|
|
2130
|
+
click.echo("## Most Recognition-Sensitive Topics (top 10)")
|
|
2131
|
+
click.echo()
|
|
2132
|
+
click.echo("| Topic | Mean Pairwise JSD | Option Std | L1->L5 Drift |")
|
|
2133
|
+
click.echo("|-------|-------------------|------------|--------------|")
|
|
2134
|
+
for t in sorted_topics[:10]:
|
|
2135
|
+
click.echo(
|
|
2136
|
+
f"| {t.topic[:40]} "
|
|
2137
|
+
f"| {t.mean_pairwise_jsd:.4f} "
|
|
2138
|
+
f"| {t.mean_option_std:.4f} "
|
|
2139
|
+
f"| {t.drift_l1_to_l5:.4f} |"
|
|
2140
|
+
)
|
|
2141
|
+
click.echo()
|
|
2142
|
+
|
|
2143
|
+
out_dir = Path(output)
|
|
2144
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
2145
|
+
from datetime import datetime
|
|
2146
|
+
|
|
2147
|
+
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
2148
|
+
provider_slug = prov.name.replace("/", "_")
|
|
2149
|
+
json_path = out_dir / f"contamination_deident_{provider_slug}_{ts}.json"
|
|
2150
|
+
json_path.write_text(json.dumps(result_data, indent=2))
|
|
2151
|
+
click.echo(f"Results saved: {json_path}")
|
|
2152
|
+
|
|
2153
|
+
|
|
2154
|
+
@main.command("publish-data")
|
|
2155
|
+
@click.option(
|
|
2156
|
+
"--results-dir",
|
|
2157
|
+
"-d",
|
|
2158
|
+
type=click.Path(exists=True),
|
|
2159
|
+
default="leaderboard-results",
|
|
2160
|
+
help="Directory containing result JSON files.",
|
|
2161
|
+
)
|
|
2162
|
+
@click.option(
|
|
2163
|
+
"--output",
|
|
2164
|
+
"-o",
|
|
2165
|
+
type=click.Path(),
|
|
2166
|
+
default="site/src/data/leaderboard.json",
|
|
2167
|
+
help="Output path for the leaderboard JSON file.",
|
|
2168
|
+
)
|
|
2169
|
+
def publish_data(results_dir, output):
|
|
2170
|
+
"""Export leaderboard data as JSON for the Astro frontend.
|
|
2171
|
+
|
|
2172
|
+
Example:
|
|
2173
|
+
synthbench publish-data --results-dir ./leaderboard-results --output site/src/data/leaderboard.json
|
|
2174
|
+
"""
|
|
2175
|
+
from synthbench.publish import publish_leaderboard_data
|
|
2176
|
+
|
|
2177
|
+
try:
|
|
2178
|
+
out_path = publish_leaderboard_data(
|
|
2179
|
+
results_dir=Path(results_dir),
|
|
2180
|
+
output_path=Path(output),
|
|
2181
|
+
version=__version__,
|
|
2182
|
+
)
|
|
2183
|
+
click.echo(f"Leaderboard data exported: {out_path}")
|
|
2184
|
+
except ValueError as e:
|
|
2185
|
+
click.echo(str(e), err=True)
|
|
2186
|
+
sys.exit(1)
|
|
2187
|
+
|
|
2188
|
+
|
|
2189
|
+
def _resolve_r2_uploader(no_r2: bool):
|
|
2190
|
+
"""Return an R2Uploader (or None when R2 is unavailable/disabled).
|
|
2191
|
+
|
|
2192
|
+
Auto-detects R2 credentials in the environment. ``--no-r2`` disables
|
|
2193
|
+
uploads regardless of env vars. Without an uploader, gated-tier
|
|
2194
|
+
artifacts are SKIPPED at publish time — they are never written to the
|
|
2195
|
+
local static output (fail closed; the local output ships to a public
|
|
2196
|
+
origin with no auth in front of it).
|
|
2197
|
+
"""
|
|
2198
|
+
from synthbench.r2_upload import R2Uploader, env_has_r2_config
|
|
2199
|
+
|
|
2200
|
+
if no_r2 or not env_has_r2_config():
|
|
2201
|
+
return None
|
|
2202
|
+
return R2Uploader.from_env()
|
|
2203
|
+
|
|
2204
|
+
|
|
2205
|
+
def _warn_gated_skips(n_skipped: int) -> None:
|
|
2206
|
+
"""Print the prominent fail-closed warning for skipped gated artifacts."""
|
|
2207
|
+
if not n_skipped:
|
|
2208
|
+
return
|
|
2209
|
+
click.echo(
|
|
2210
|
+
f"WARNING: {n_skipped} gated artifact(s) skipped — R2 uploader not "
|
|
2211
|
+
"configured; gated data will be unavailable until credentials are "
|
|
2212
|
+
"provided (R2_ACCOUNT_ID/R2_ACCESS_KEY_ID/R2_SECRET_ACCESS_KEY/"
|
|
2213
|
+
"R2_BUCKET) and the publish is re-run. Gated artifacts are never "
|
|
2214
|
+
"written to the local static output. Pass --strict-gating (or set "
|
|
2215
|
+
"SYNTHBENCH_PUBLISH_STRICT=1) to fail instead of skipping.",
|
|
2216
|
+
err=True,
|
|
2217
|
+
)
|
|
2218
|
+
|
|
2219
|
+
|
|
2220
|
+
_STRICT_GATING_OPTION = click.option(
|
|
2221
|
+
"--strict-gating",
|
|
2222
|
+
is_flag=True,
|
|
2223
|
+
default=False,
|
|
2224
|
+
envvar="SYNTHBENCH_PUBLISH_STRICT",
|
|
2225
|
+
show_envvar=True,
|
|
2226
|
+
help=(
|
|
2227
|
+
"Fail (exit 1) instead of skipping when gated-tier artifacts have no "
|
|
2228
|
+
"R2 uploader configured. CI deploys set SYNTHBENCH_PUBLISH_STRICT=1 "
|
|
2229
|
+
"so a missing R2 configuration fails the build loudly instead of "
|
|
2230
|
+
"deploying without gated data."
|
|
2231
|
+
),
|
|
2232
|
+
)
|
|
2233
|
+
|
|
2234
|
+
|
|
2235
|
+
@main.command("publish-runs")
|
|
2236
|
+
@click.option(
|
|
2237
|
+
"--results-dir",
|
|
2238
|
+
"-d",
|
|
2239
|
+
type=click.Path(exists=True),
|
|
2240
|
+
default="leaderboard-results",
|
|
2241
|
+
help="Directory containing result JSON files.",
|
|
2242
|
+
)
|
|
2243
|
+
@click.option(
|
|
2244
|
+
"--output-dir",
|
|
2245
|
+
"-o",
|
|
2246
|
+
type=click.Path(),
|
|
2247
|
+
default="site/public/data",
|
|
2248
|
+
help=(
|
|
2249
|
+
"Output directory for run-explorer artifacts. Emits "
|
|
2250
|
+
"runs-index.json + config/ + run/ subtrees."
|
|
2251
|
+
),
|
|
2252
|
+
)
|
|
2253
|
+
@click.option(
|
|
2254
|
+
"--no-r2",
|
|
2255
|
+
is_flag=True,
|
|
2256
|
+
default=False,
|
|
2257
|
+
help=(
|
|
2258
|
+
"Disable R2 uploads even when R2 env vars are set. Gated-tier "
|
|
2259
|
+
"per-run/config/question JSONs are then SKIPPED — they are never "
|
|
2260
|
+
"written to the local static output (fail closed). Default behavior "
|
|
2261
|
+
"uploads gated artifacts to Cloudflare R2 when R2_ACCOUNT_ID/"
|
|
2262
|
+
"R2_ACCESS_KEY_ID/R2_SECRET_ACCESS_KEY/R2_BUCKET are all present "
|
|
2263
|
+
"(sb-sjs)."
|
|
2264
|
+
),
|
|
2265
|
+
)
|
|
2266
|
+
@_STRICT_GATING_OPTION
|
|
2267
|
+
def publish_runs_cmd(results_dir, output_dir, no_r2, strict_gating):
|
|
2268
|
+
"""Emit run-explorer artifacts (runs-index, per-config, per-run JSON).
|
|
2269
|
+
|
|
2270
|
+
Also emits per-question rollups under ``<output-dir>/question/`` for the
|
|
2271
|
+
question-explorer view (sb-eiv) — the two pivots share the same source
|
|
2272
|
+
data, so regenerating runs without questions would leave /question
|
|
2273
|
+
pages stale.
|
|
2274
|
+
|
|
2275
|
+
Example:
|
|
2276
|
+
synthbench publish-runs --results-dir ./leaderboard-results --output-dir site/public/data
|
|
2277
|
+
"""
|
|
2278
|
+
from synthbench.publish import publish_questions, publish_runs
|
|
2279
|
+
|
|
2280
|
+
try:
|
|
2281
|
+
r2_uploader = _resolve_r2_uploader(no_r2)
|
|
2282
|
+
counts = publish_runs(
|
|
2283
|
+
results_dir=Path(results_dir),
|
|
2284
|
+
output_dir=Path(output_dir),
|
|
2285
|
+
version=__version__,
|
|
2286
|
+
r2_uploader=r2_uploader,
|
|
2287
|
+
strict_gating=strict_gating,
|
|
2288
|
+
)
|
|
2289
|
+
sink = (
|
|
2290
|
+
f"R2 bucket {r2_uploader.bucket} (gated) + {output_dir} (public)"
|
|
2291
|
+
if r2_uploader is not None
|
|
2292
|
+
else str(output_dir)
|
|
2293
|
+
)
|
|
2294
|
+
click.echo(
|
|
2295
|
+
f"Run explorer data exported: {counts['runs']} runs, "
|
|
2296
|
+
f"{counts['configs']} configs → {sink}"
|
|
2297
|
+
)
|
|
2298
|
+
q_counts = publish_questions(
|
|
2299
|
+
results_dir=Path(results_dir),
|
|
2300
|
+
output_dir=Path(output_dir),
|
|
2301
|
+
version=__version__,
|
|
2302
|
+
r2_uploader=r2_uploader,
|
|
2303
|
+
strict_gating=strict_gating,
|
|
2304
|
+
)
|
|
2305
|
+
click.echo(
|
|
2306
|
+
f"Question explorer data exported: {q_counts['questions']} questions "
|
|
2307
|
+
f"across {q_counts['datasets']} datasets → {sink}"
|
|
2308
|
+
)
|
|
2309
|
+
if r2_uploader is not None:
|
|
2310
|
+
click.echo(
|
|
2311
|
+
f"R2 upload summary: {r2_uploader.object_count} objects to "
|
|
2312
|
+
f"{r2_uploader.bucket}"
|
|
2313
|
+
)
|
|
2314
|
+
_warn_gated_skips(
|
|
2315
|
+
counts.get("gated_skipped", 0) + q_counts.get("gated_skipped", 0)
|
|
2316
|
+
)
|
|
2317
|
+
except ValueError as e:
|
|
2318
|
+
click.echo(str(e), err=True)
|
|
2319
|
+
sys.exit(1)
|
|
2320
|
+
|
|
2321
|
+
|
|
2322
|
+
@main.command("scan-invalid")
|
|
2323
|
+
@click.option(
|
|
2324
|
+
"--results-dir",
|
|
2325
|
+
"-d",
|
|
2326
|
+
type=click.Path(exists=True),
|
|
2327
|
+
default="leaderboard-results",
|
|
2328
|
+
help="Directory containing result JSON files to scan.",
|
|
2329
|
+
)
|
|
2330
|
+
@click.option(
|
|
2331
|
+
"--json",
|
|
2332
|
+
"json_output",
|
|
2333
|
+
is_flag=True,
|
|
2334
|
+
default=False,
|
|
2335
|
+
help="Emit machine-readable JSON instead of a human-readable table.",
|
|
2336
|
+
)
|
|
2337
|
+
@click.option(
|
|
2338
|
+
"--quarantine",
|
|
2339
|
+
type=click.Path(),
|
|
2340
|
+
default=None,
|
|
2341
|
+
help=(
|
|
2342
|
+
"Move detected invalid run files into this directory (created if "
|
|
2343
|
+
"needed). Operator action; non-default. Default behaviour is to "
|
|
2344
|
+
"report only."
|
|
2345
|
+
),
|
|
2346
|
+
)
|
|
2347
|
+
def scan_invalid(results_dir, json_output, quarantine):
|
|
2348
|
+
"""Scan result JSON files for invalid runs (uniform-distribution garbage).
|
|
2349
|
+
|
|
2350
|
+
Reports runs whose per-question ``model_distribution`` fields are
|
|
2351
|
+
overwhelmingly uniform — the signature of a silent API failure or
|
|
2352
|
+
provider fallback that parsed cleanly but carries no real signal.
|
|
2353
|
+
|
|
2354
|
+
Examples:
|
|
2355
|
+
synthbench scan-invalid --results-dir leaderboard-results
|
|
2356
|
+
synthbench scan-invalid --json | jq
|
|
2357
|
+
synthbench scan-invalid --quarantine leaderboard-results/_quarantine
|
|
2358
|
+
"""
|
|
2359
|
+
from synthbench.run_validity import is_invalid_run, run_identity
|
|
2360
|
+
|
|
2361
|
+
import shutil
|
|
2362
|
+
|
|
2363
|
+
results_path = Path(results_dir)
|
|
2364
|
+
json_files = sorted(results_path.glob("*.json"))
|
|
2365
|
+
invalid_records: list[dict] = []
|
|
2366
|
+
scanned = 0
|
|
2367
|
+
|
|
2368
|
+
for jf in json_files:
|
|
2369
|
+
try:
|
|
2370
|
+
with open(jf) as f:
|
|
2371
|
+
data = json.load(f)
|
|
2372
|
+
except (OSError, json.JSONDecodeError):
|
|
2373
|
+
continue
|
|
2374
|
+
if data.get("benchmark") != "synthbench":
|
|
2375
|
+
continue
|
|
2376
|
+
scanned += 1
|
|
2377
|
+
invalid, reason, metrics = is_invalid_run(data)
|
|
2378
|
+
if not invalid:
|
|
2379
|
+
continue
|
|
2380
|
+
invalid_records.append(
|
|
2381
|
+
{
|
|
2382
|
+
"run_id": jf.stem,
|
|
2383
|
+
"path": str(jf),
|
|
2384
|
+
"reason": reason,
|
|
2385
|
+
**run_identity(data),
|
|
2386
|
+
"metrics": metrics,
|
|
2387
|
+
}
|
|
2388
|
+
)
|
|
2389
|
+
|
|
2390
|
+
quarantine_dir = Path(quarantine) if quarantine else None
|
|
2391
|
+
moved: list[str] = []
|
|
2392
|
+
if quarantine_dir and invalid_records:
|
|
2393
|
+
quarantine_dir.mkdir(parents=True, exist_ok=True)
|
|
2394
|
+
for rec in invalid_records:
|
|
2395
|
+
src = Path(rec["path"])
|
|
2396
|
+
dst = quarantine_dir / src.name
|
|
2397
|
+
shutil.move(str(src), str(dst))
|
|
2398
|
+
rec["quarantined_to"] = str(dst)
|
|
2399
|
+
moved.append(str(dst))
|
|
2400
|
+
|
|
2401
|
+
if json_output:
|
|
2402
|
+
click.echo(
|
|
2403
|
+
json.dumps(
|
|
2404
|
+
{
|
|
2405
|
+
"results_dir": str(results_path),
|
|
2406
|
+
"n_scanned": scanned,
|
|
2407
|
+
"n_invalid": len(invalid_records),
|
|
2408
|
+
"quarantine_dir": str(quarantine_dir) if quarantine_dir else None,
|
|
2409
|
+
"invalid_runs": invalid_records,
|
|
2410
|
+
},
|
|
2411
|
+
indent=2,
|
|
2412
|
+
)
|
|
2413
|
+
)
|
|
2414
|
+
return
|
|
2415
|
+
|
|
2416
|
+
if not invalid_records:
|
|
2417
|
+
click.echo(
|
|
2418
|
+
f"Scanned {scanned} runs in {results_path} — no invalid runs detected."
|
|
2419
|
+
)
|
|
2420
|
+
return
|
|
2421
|
+
|
|
2422
|
+
click.echo(
|
|
2423
|
+
f"Scanned {scanned} runs in {results_path}. Detected "
|
|
2424
|
+
f"{len(invalid_records)} invalid run(s):"
|
|
2425
|
+
)
|
|
2426
|
+
click.echo("")
|
|
2427
|
+
for rec in invalid_records:
|
|
2428
|
+
m = rec["metrics"]
|
|
2429
|
+
click.echo(f" ✗ {rec['run_id']}")
|
|
2430
|
+
click.echo(f" provider : {rec.get('provider')}")
|
|
2431
|
+
click.echo(f" dataset : {rec.get('dataset')}")
|
|
2432
|
+
click.echo(
|
|
2433
|
+
f" uniform : {m['n_uniform_questions']}/{m['n_questions']} "
|
|
2434
|
+
f"({m['uniform_fraction']:.1%})"
|
|
2435
|
+
)
|
|
2436
|
+
click.echo(f" refusal : {m['refusal_rate']:.4f}")
|
|
2437
|
+
click.echo(f" reason : {rec['reason']}")
|
|
2438
|
+
if "quarantined_to" in rec:
|
|
2439
|
+
click.echo(f" moved to : {rec['quarantined_to']}")
|
|
2440
|
+
click.echo("")
|
|
2441
|
+
|
|
2442
|
+
if quarantine_dir:
|
|
2443
|
+
click.echo(f"Moved {len(moved)} file(s) to {quarantine_dir}")
|
|
2444
|
+
|
|
2445
|
+
|
|
2446
|
+
@main.command("publish-questions")
|
|
2447
|
+
@click.option(
|
|
2448
|
+
"--results-dir",
|
|
2449
|
+
"-d",
|
|
2450
|
+
type=click.Path(exists=True),
|
|
2451
|
+
default="leaderboard-results",
|
|
2452
|
+
help="Directory containing result JSON files.",
|
|
2453
|
+
)
|
|
2454
|
+
@click.option(
|
|
2455
|
+
"--output-dir",
|
|
2456
|
+
"-o",
|
|
2457
|
+
type=click.Path(),
|
|
2458
|
+
default="site/public/data",
|
|
2459
|
+
help=(
|
|
2460
|
+
"Output directory root. Emits question/<dataset>/<key>.json + "
|
|
2461
|
+
"question/<dataset>/index.json beneath this path."
|
|
2462
|
+
),
|
|
2463
|
+
)
|
|
2464
|
+
@click.option(
|
|
2465
|
+
"--no-r2",
|
|
2466
|
+
is_flag=True,
|
|
2467
|
+
default=False,
|
|
2468
|
+
help=(
|
|
2469
|
+
"Disable R2 uploads even when R2 env vars are set. Gated-tier "
|
|
2470
|
+
"artifacts are then SKIPPED, never written locally (fail closed; "
|
|
2471
|
+
"sb-sjs). See `synthbench publish-runs --help` for details."
|
|
2472
|
+
),
|
|
2473
|
+
)
|
|
2474
|
+
@_STRICT_GATING_OPTION
|
|
2475
|
+
def publish_questions_cmd(results_dir, output_dir, no_r2, strict_gating):
|
|
2476
|
+
"""Emit per-question rollups for the /question explorer view (sb-eiv).
|
|
2477
|
+
|
|
2478
|
+
Example:
|
|
2479
|
+
synthbench publish-questions --results-dir ./leaderboard-results --output-dir site/public/data
|
|
2480
|
+
"""
|
|
2481
|
+
from synthbench.publish import publish_questions
|
|
2482
|
+
|
|
2483
|
+
try:
|
|
2484
|
+
r2_uploader = _resolve_r2_uploader(no_r2)
|
|
2485
|
+
counts = publish_questions(
|
|
2486
|
+
results_dir=Path(results_dir),
|
|
2487
|
+
output_dir=Path(output_dir),
|
|
2488
|
+
version=__version__,
|
|
2489
|
+
r2_uploader=r2_uploader,
|
|
2490
|
+
strict_gating=strict_gating,
|
|
2491
|
+
)
|
|
2492
|
+
sink = (
|
|
2493
|
+
f"R2 bucket {r2_uploader.bucket} (gated) + {output_dir} (public)"
|
|
2494
|
+
if r2_uploader is not None
|
|
2495
|
+
else f"{output_dir}/question"
|
|
2496
|
+
)
|
|
2497
|
+
click.echo(
|
|
2498
|
+
f"Question explorer data exported: {counts['questions']} questions "
|
|
2499
|
+
f"across {counts['datasets']} datasets → {sink}"
|
|
2500
|
+
)
|
|
2501
|
+
if r2_uploader is not None:
|
|
2502
|
+
click.echo(
|
|
2503
|
+
f"R2 upload summary: {r2_uploader.object_count} objects to "
|
|
2504
|
+
f"{r2_uploader.bucket}"
|
|
2505
|
+
)
|
|
2506
|
+
_warn_gated_skips(counts.get("gated_skipped", 0))
|
|
2507
|
+
except ValueError as e:
|
|
2508
|
+
click.echo(str(e), err=True)
|
|
2509
|
+
sys.exit(1)
|
|
2510
|
+
|
|
2511
|
+
|
|
2512
|
+
@main.command()
|
|
2513
|
+
@click.argument("files", nargs=-1, required=True, type=click.Path(exists=True))
|
|
2514
|
+
@click.option(
|
|
2515
|
+
"--output", "-o", type=click.Path(), default="results", help="Output directory."
|
|
2516
|
+
)
|
|
2517
|
+
@click.option(
|
|
2518
|
+
"--weights",
|
|
2519
|
+
default=None,
|
|
2520
|
+
help="Comma-separated weights for each input file (default: equal).",
|
|
2521
|
+
)
|
|
2522
|
+
def ensemble(files, output, weights):
|
|
2523
|
+
"""Blend N result files into a single ensemble result.
|
|
2524
|
+
|
|
2525
|
+
Aligns questions by key (intersection), averages model distributions
|
|
2526
|
+
with equal (or custom) weights, and recomputes all metrics.
|
|
2527
|
+
|
|
2528
|
+
Example:
|
|
2529
|
+
synthbench ensemble results/haiku.json results/gemini.json results/gpt4omini.json
|
|
2530
|
+
synthbench ensemble a.json b.json --weights 0.6,0.4
|
|
2531
|
+
"""
|
|
2532
|
+
from datetime import datetime, timezone
|
|
2533
|
+
from synthbench.metrics.distributional import jensen_shannon_divergence
|
|
2534
|
+
from synthbench.metrics.ranking import kendall_tau_b
|
|
2535
|
+
from synthbench.metrics.composite import parity_score
|
|
2536
|
+
|
|
2537
|
+
if len(files) < 2:
|
|
2538
|
+
click.echo("Error: need at least 2 result files to ensemble.", err=True)
|
|
2539
|
+
sys.exit(1)
|
|
2540
|
+
|
|
2541
|
+
# Parse weights
|
|
2542
|
+
if weights:
|
|
2543
|
+
w = [float(x) for x in weights.split(",")]
|
|
2544
|
+
if len(w) != len(files):
|
|
2545
|
+
click.echo(f"Error: got {len(w)} weights for {len(files)} files.", err=True)
|
|
2546
|
+
sys.exit(1)
|
|
2547
|
+
w_total = sum(w)
|
|
2548
|
+
w = [x / w_total for x in w] # normalize
|
|
2549
|
+
else:
|
|
2550
|
+
w = [1.0 / len(files)] * len(files)
|
|
2551
|
+
|
|
2552
|
+
# Load all result files
|
|
2553
|
+
datasets = []
|
|
2554
|
+
source_providers = []
|
|
2555
|
+
for fpath in files:
|
|
2556
|
+
with open(fpath) as f:
|
|
2557
|
+
data = json.load(f)
|
|
2558
|
+
datasets.append(data)
|
|
2559
|
+
source_providers.append(
|
|
2560
|
+
data.get("config", {}).get("provider", Path(fpath).stem)
|
|
2561
|
+
)
|
|
2562
|
+
|
|
2563
|
+
# Index per-question data by key for each file
|
|
2564
|
+
per_q_maps = []
|
|
2565
|
+
for data in datasets:
|
|
2566
|
+
pq = {q["key"]: q for q in data.get("per_question", [])}
|
|
2567
|
+
per_q_maps.append(pq)
|
|
2568
|
+
|
|
2569
|
+
# Intersection of question keys across all files
|
|
2570
|
+
common_keys = set(per_q_maps[0].keys())
|
|
2571
|
+
for pq in per_q_maps[1:]:
|
|
2572
|
+
common_keys &= set(pq.keys())
|
|
2573
|
+
common_keys = sorted(common_keys)
|
|
2574
|
+
|
|
2575
|
+
if not common_keys:
|
|
2576
|
+
click.echo("Error: no common questions across all input files.", err=True)
|
|
2577
|
+
sys.exit(1)
|
|
2578
|
+
|
|
2579
|
+
click.echo(
|
|
2580
|
+
f"Ensembling {len(files)} results over {len(common_keys)} common questions"
|
|
2581
|
+
)
|
|
2582
|
+
|
|
2583
|
+
# Committed gated-dataset result files are stripped of human_distribution
|
|
2584
|
+
# (issue #308); rehydrate from the canonical registry so blends can still
|
|
2585
|
+
# be computed from committed inputs. Fresh (unstripped) run outputs skip
|
|
2586
|
+
# this entirely.
|
|
2587
|
+
ensemble_dataset = datasets[0].get("config", {}).get("dataset", "unknown")
|
|
2588
|
+
canonical_dists: dict = {}
|
|
2589
|
+
if any(
|
|
2590
|
+
not q.get("human_distribution")
|
|
2591
|
+
for pq in per_q_maps
|
|
2592
|
+
for q in (pq[k] for k in common_keys)
|
|
2593
|
+
):
|
|
2594
|
+
from synthbench.human_distributions import load_canonical_distributions
|
|
2595
|
+
|
|
2596
|
+
canonical_dists = load_canonical_distributions(ensemble_dataset) or {}
|
|
2597
|
+
if not canonical_dists:
|
|
2598
|
+
click.echo(
|
|
2599
|
+
f"Error: input files are missing human_distribution (stripped "
|
|
2600
|
+
f"gated data) and no canonical distribution source is "
|
|
2601
|
+
f"available for dataset {ensemble_dataset!r}. See "
|
|
2602
|
+
f"scripts/generate-canonical-distributions.py.",
|
|
2603
|
+
err=True,
|
|
2604
|
+
)
|
|
2605
|
+
sys.exit(1)
|
|
2606
|
+
|
|
2607
|
+
# Blend distributions and recompute metrics
|
|
2608
|
+
per_question = []
|
|
2609
|
+
for key in common_keys:
|
|
2610
|
+
qs = [pq[key] for pq in per_q_maps]
|
|
2611
|
+
ref_q = qs[0]
|
|
2612
|
+
|
|
2613
|
+
# Human distribution — canonical when the committed input was
|
|
2614
|
+
# stripped; otherwise from the first file (should be identical).
|
|
2615
|
+
human_dist = ref_q.get("human_distribution") or canonical_dists.get(key)
|
|
2616
|
+
if not human_dist:
|
|
2617
|
+
click.echo(
|
|
2618
|
+
f"Error: no human_distribution available for question "
|
|
2619
|
+
f"{key!r} (not in canonical registry).",
|
|
2620
|
+
err=True,
|
|
2621
|
+
)
|
|
2622
|
+
sys.exit(1)
|
|
2623
|
+
|
|
2624
|
+
# Blend model distributions with weights
|
|
2625
|
+
all_option_keys = set()
|
|
2626
|
+
for q in qs:
|
|
2627
|
+
all_option_keys.update(q["model_distribution"].keys())
|
|
2628
|
+
|
|
2629
|
+
blended = {}
|
|
2630
|
+
for opt in all_option_keys:
|
|
2631
|
+
blended[opt] = sum(
|
|
2632
|
+
w[i] * qs[i]["model_distribution"].get(opt, 0.0) for i in range(len(qs))
|
|
2633
|
+
)
|
|
2634
|
+
|
|
2635
|
+
# Round distributions FIRST, then compute metrics from the rounded
|
|
2636
|
+
# values — the file publishes rounded distributions, and the
|
|
2637
|
+
# validator recomputes metrics from what is published. Metrics from
|
|
2638
|
+
# unrounded values can flip Kendall-tau tie handling when two
|
|
2639
|
+
# blended options round to the same 4-dp value (PER_Q_TAU errors on
|
|
2640
|
+
# an otherwise-correct blend).
|
|
2641
|
+
human_rounded = {k: round(v, 4) for k, v in human_dist.items()}
|
|
2642
|
+
blended_rounded = {k: round(v, 4) for k, v in blended.items()}
|
|
2643
|
+
|
|
2644
|
+
# Recompute metrics
|
|
2645
|
+
jsd = jensen_shannon_divergence(human_rounded, blended_rounded)
|
|
2646
|
+
tau = kendall_tau_b(human_rounded, blended_rounded)
|
|
2647
|
+
parity = parity_score(jsd, tau)
|
|
2648
|
+
|
|
2649
|
+
per_question.append(
|
|
2650
|
+
{
|
|
2651
|
+
"key": key,
|
|
2652
|
+
"text": ref_q.get("text", ""),
|
|
2653
|
+
"options": ref_q.get("options", []),
|
|
2654
|
+
"human_distribution": human_rounded,
|
|
2655
|
+
"model_distribution": blended_rounded,
|
|
2656
|
+
"jsd": round(jsd, 6),
|
|
2657
|
+
"kendall_tau": round(tau, 6),
|
|
2658
|
+
"parity": round(parity, 6),
|
|
2659
|
+
"n_samples": sum(q.get("n_samples", 0) for q in qs),
|
|
2660
|
+
"n_parse_failures": sum(q.get("n_parse_failures", 0) for q in qs),
|
|
2661
|
+
"model_refusal_rate": round(
|
|
2662
|
+
sum(
|
|
2663
|
+
w[i] * qs[i].get("model_refusal_rate", 0.0)
|
|
2664
|
+
for i in range(len(qs))
|
|
2665
|
+
),
|
|
2666
|
+
6,
|
|
2667
|
+
),
|
|
2668
|
+
"human_refusal_rate": ref_q.get("human_refusal_rate", 0.0),
|
|
2669
|
+
"temporal_year": ref_q.get("temporal_year", 0),
|
|
2670
|
+
}
|
|
2671
|
+
)
|
|
2672
|
+
|
|
2673
|
+
# Aggregate metrics
|
|
2674
|
+
n_q = len(per_question)
|
|
2675
|
+
mean_jsd = sum(q["jsd"] for q in per_question) / n_q
|
|
2676
|
+
mean_tau = sum(q["kendall_tau"] for q in per_question) / n_q
|
|
2677
|
+
p_dist = round(1.0 - mean_jsd, 6)
|
|
2678
|
+
p_rank = round((1.0 + mean_tau) / 2.0, 6)
|
|
2679
|
+
|
|
2680
|
+
# P_refuse from blended refusal rates
|
|
2681
|
+
from synthbench.metrics.refusal import refusal_calibration
|
|
2682
|
+
|
|
2683
|
+
p_refuse = round(
|
|
2684
|
+
refusal_calibration(
|
|
2685
|
+
[q["model_refusal_rate"] for q in per_question],
|
|
2686
|
+
[q["human_refusal_rate"] for q in per_question],
|
|
2687
|
+
),
|
|
2688
|
+
6,
|
|
2689
|
+
)
|
|
2690
|
+
|
|
2691
|
+
# SPS = mean of all available metrics
|
|
2692
|
+
sps = round((p_dist + p_rank + p_refuse) / 3.0, 6)
|
|
2693
|
+
|
|
2694
|
+
# Build result JSON
|
|
2695
|
+
provider_label = f"ensemble/{len(files)}-model-blend"
|
|
2696
|
+
dataset_name = datasets[0].get("config", {}).get("dataset", "unknown")
|
|
2697
|
+
|
|
2698
|
+
result_json = {
|
|
2699
|
+
"benchmark": "synthbench",
|
|
2700
|
+
"version": __version__,
|
|
2701
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
2702
|
+
"config": {
|
|
2703
|
+
"dataset": dataset_name,
|
|
2704
|
+
"provider": provider_label,
|
|
2705
|
+
"ensemble_sources": [
|
|
2706
|
+
{"file": str(Path(f).name), "provider": sp, "weight": round(w[i], 6)}
|
|
2707
|
+
for i, (f, sp) in enumerate(zip(files, source_providers))
|
|
2708
|
+
],
|
|
2709
|
+
"n_common_questions": len(common_keys),
|
|
2710
|
+
# The blend is evaluated on exactly the common-question
|
|
2711
|
+
# intersection. Without this, publish renders ensemble rows as
|
|
2712
|
+
# "scored on zero questions" (n: 0).
|
|
2713
|
+
"n_evaluated": len(common_keys),
|
|
2714
|
+
},
|
|
2715
|
+
"scores": {
|
|
2716
|
+
"sps": sps,
|
|
2717
|
+
"p_dist": p_dist,
|
|
2718
|
+
"p_rank": p_rank,
|
|
2719
|
+
"p_refuse": p_refuse,
|
|
2720
|
+
},
|
|
2721
|
+
"aggregate": {
|
|
2722
|
+
"mean_jsd": round(mean_jsd, 6),
|
|
2723
|
+
"median_jsd": round(sorted(q["jsd"] for q in per_question)[n_q // 2], 6),
|
|
2724
|
+
"mean_kendall_tau": round(mean_tau, 6),
|
|
2725
|
+
"composite_parity": sps,
|
|
2726
|
+
"n_questions": n_q,
|
|
2727
|
+
"elapsed_seconds": 0.0,
|
|
2728
|
+
},
|
|
2729
|
+
"per_question": per_question,
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2732
|
+
# Summary output
|
|
2733
|
+
click.echo(f" Provider: {provider_label}")
|
|
2734
|
+
click.echo(f" P_dist: {p_dist:.4f}")
|
|
2735
|
+
click.echo(f" P_rank: {p_rank:.4f}")
|
|
2736
|
+
click.echo(f" P_refuse: {p_refuse:.4f}")
|
|
2737
|
+
click.echo(f" SPS: {sps:.4f}")
|
|
2738
|
+
click.echo()
|
|
2739
|
+
|
|
2740
|
+
# Save
|
|
2741
|
+
out_dir = Path(output)
|
|
2742
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
2743
|
+
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
2744
|
+
json_path = out_dir / f"{dataset_name}_ensemble_{len(files)}blend_{ts}.json"
|
|
2745
|
+
json_path.write_text(json.dumps(result_json, indent=2))
|
|
2746
|
+
click.echo(f"Saved: {json_path}")
|
|
2747
|
+
|
|
2748
|
+
|
|
2749
|
+
@main.command()
|
|
2750
|
+
@click.argument("paths", nargs=-1, required=True, type=click.Path())
|
|
2751
|
+
@click.option(
|
|
2752
|
+
"--expected-question-hash",
|
|
2753
|
+
default=None,
|
|
2754
|
+
help="Canonical question_set_hash the submission must match.",
|
|
2755
|
+
)
|
|
2756
|
+
@click.option(
|
|
2757
|
+
"--skip-recompute",
|
|
2758
|
+
is_flag=True,
|
|
2759
|
+
help="Run only tier 1 (schema + plausibility); skip recomputation.",
|
|
2760
|
+
)
|
|
2761
|
+
@click.option(
|
|
2762
|
+
"--tier3",
|
|
2763
|
+
is_flag=True,
|
|
2764
|
+
help=(
|
|
2765
|
+
"Run Tier-3 checks: statistical anomaly detection, raw-response "
|
|
2766
|
+
"samples, and reproducibility metadata (warnings by default)."
|
|
2767
|
+
),
|
|
2768
|
+
)
|
|
2769
|
+
@click.option(
|
|
2770
|
+
"--peers",
|
|
2771
|
+
"peers_dir",
|
|
2772
|
+
type=click.Path(exists=True, file_okay=False),
|
|
2773
|
+
default=None,
|
|
2774
|
+
help=(
|
|
2775
|
+
"Directory of peer submission JSONs used by the Tier-3 peer-"
|
|
2776
|
+
"distribution outlier detector (same-family, same-dataset runs)."
|
|
2777
|
+
),
|
|
2778
|
+
)
|
|
2779
|
+
@click.option(
|
|
2780
|
+
"--strict",
|
|
2781
|
+
is_flag=True,
|
|
2782
|
+
help="Treat warnings as errors (exit non-zero).",
|
|
2783
|
+
)
|
|
2784
|
+
@click.option(
|
|
2785
|
+
"--rehydrate-canonical",
|
|
2786
|
+
is_flag=True,
|
|
2787
|
+
default=False,
|
|
2788
|
+
help=(
|
|
2789
|
+
"Recompute tier-2 metrics against the CANONICAL per-question human "
|
|
2790
|
+
"distributions (local artifact, gated R2 object, or dataset adapter "
|
|
2791
|
+
"cache — see synthbench.human_distributions) instead of any "
|
|
2792
|
+
"submitter-supplied copies. Required to fully validate committed "
|
|
2793
|
+
"gated-dataset files, which are stripped of human_distribution "
|
|
2794
|
+
"(issue #308); without it those files validate with an explicit "
|
|
2795
|
+
"RECOMPUTE_SKIPPED_NO_DISTRIBUTION warning."
|
|
2796
|
+
),
|
|
2797
|
+
)
|
|
2798
|
+
@click.option(
|
|
2799
|
+
"--json",
|
|
2800
|
+
"json_output",
|
|
2801
|
+
is_flag=True,
|
|
2802
|
+
help="Emit a JSON summary instead of a human-readable report.",
|
|
2803
|
+
)
|
|
2804
|
+
def validate(
|
|
2805
|
+
paths,
|
|
2806
|
+
expected_question_hash,
|
|
2807
|
+
skip_recompute,
|
|
2808
|
+
tier3,
|
|
2809
|
+
peers_dir,
|
|
2810
|
+
strict,
|
|
2811
|
+
rehydrate_canonical,
|
|
2812
|
+
json_output,
|
|
2813
|
+
):
|
|
2814
|
+
"""Validate one or more submission result JSONs against the integrity rules.
|
|
2815
|
+
|
|
2816
|
+
Tier 1 checks: JSON schema shape, SPS/score bounds, distribution sums,
|
|
2817
|
+
question-set hash, record-count cross-checks, parse-failure plausibility.
|
|
2818
|
+
|
|
2819
|
+
Tier 2 checks: recompute per-question JSD + Kendall's tau from the
|
|
2820
|
+
reported distributions, recompute aggregate mean/composite parity,
|
|
2821
|
+
and flag mismatches.
|
|
2822
|
+
|
|
2823
|
+
Exits non-zero if any submission has validation errors (or warnings
|
|
2824
|
+
under --strict).
|
|
2825
|
+
"""
|
|
2826
|
+
|
|
2827
|
+
from synthbench.validation import validate_file
|
|
2828
|
+
|
|
2829
|
+
reports = []
|
|
2830
|
+
exit_code = 0
|
|
2831
|
+
|
|
2832
|
+
def glob_paths():
|
|
2833
|
+
for p in paths:
|
|
2834
|
+
pth = Path(p)
|
|
2835
|
+
if pth.is_dir():
|
|
2836
|
+
yield from sorted(pth.glob("*.json"))
|
|
2837
|
+
else:
|
|
2838
|
+
yield pth
|
|
2839
|
+
|
|
2840
|
+
targets = list(glob_paths())
|
|
2841
|
+
if not targets:
|
|
2842
|
+
click.echo("No files to validate.", err=True)
|
|
2843
|
+
sys.exit(2)
|
|
2844
|
+
|
|
2845
|
+
peer_data: list[dict] = []
|
|
2846
|
+
if peers_dir is not None:
|
|
2847
|
+
peers_path = Path(peers_dir)
|
|
2848
|
+
for jf in sorted(peers_path.glob("*.json")):
|
|
2849
|
+
try:
|
|
2850
|
+
peer_data.append(json.loads(jf.read_text()))
|
|
2851
|
+
except (OSError, json.JSONDecodeError):
|
|
2852
|
+
continue
|
|
2853
|
+
|
|
2854
|
+
def _canonical_for(target: Path):
|
|
2855
|
+
"""Resolve canonical distributions for a file's dataset (or None)."""
|
|
2856
|
+
if not rehydrate_canonical:
|
|
2857
|
+
return None
|
|
2858
|
+
from synthbench.human_distributions import load_canonical_distributions
|
|
2859
|
+
|
|
2860
|
+
try:
|
|
2861
|
+
dataset = (
|
|
2862
|
+
json.loads(Path(target).read_text()).get("config", {}) or {}
|
|
2863
|
+
).get("dataset")
|
|
2864
|
+
except (OSError, json.JSONDecodeError):
|
|
2865
|
+
return None
|
|
2866
|
+
if not dataset:
|
|
2867
|
+
return None
|
|
2868
|
+
canonical = load_canonical_distributions(dataset)
|
|
2869
|
+
if canonical is None:
|
|
2870
|
+
click.echo(
|
|
2871
|
+
f"WARNING: --rehydrate-canonical: no canonical distribution "
|
|
2872
|
+
f"source available for dataset {dataset!r} "
|
|
2873
|
+
f"({target}) — falling back to submitted distributions.",
|
|
2874
|
+
err=True,
|
|
2875
|
+
)
|
|
2876
|
+
return canonical
|
|
2877
|
+
|
|
2878
|
+
for target in targets:
|
|
2879
|
+
report = validate_file(
|
|
2880
|
+
target,
|
|
2881
|
+
expected_question_hash=expected_question_hash,
|
|
2882
|
+
tier2=not skip_recompute,
|
|
2883
|
+
tier3=tier3,
|
|
2884
|
+
peers=peer_data,
|
|
2885
|
+
canonical_distributions=_canonical_for(target),
|
|
2886
|
+
)
|
|
2887
|
+
reports.append(report)
|
|
2888
|
+
has_errors = bool(report.errors)
|
|
2889
|
+
has_warnings = bool(report.warnings)
|
|
2890
|
+
if has_errors or (strict and has_warnings):
|
|
2891
|
+
exit_code = 1
|
|
2892
|
+
|
|
2893
|
+
if json_output:
|
|
2894
|
+
payload = [
|
|
2895
|
+
{
|
|
2896
|
+
"source": r.source,
|
|
2897
|
+
"ok": r.ok and (not strict or not r.warnings),
|
|
2898
|
+
"metadata": r.metadata,
|
|
2899
|
+
"issues": [
|
|
2900
|
+
{
|
|
2901
|
+
"code": i.code,
|
|
2902
|
+
"severity": i.severity.value,
|
|
2903
|
+
"path": i.path,
|
|
2904
|
+
"message": i.message,
|
|
2905
|
+
}
|
|
2906
|
+
for i in r.issues
|
|
2907
|
+
],
|
|
2908
|
+
}
|
|
2909
|
+
for r in reports
|
|
2910
|
+
]
|
|
2911
|
+
click.echo(json.dumps(payload, indent=2))
|
|
2912
|
+
else:
|
|
2913
|
+
total_errors = 0
|
|
2914
|
+
total_warnings = 0
|
|
2915
|
+
for r in reports:
|
|
2916
|
+
click.echo(r.format())
|
|
2917
|
+
total_errors += len(r.errors)
|
|
2918
|
+
total_warnings += len(r.warnings)
|
|
2919
|
+
click.echo("")
|
|
2920
|
+
click.echo(
|
|
2921
|
+
f"Summary: {len(reports)} file(s), "
|
|
2922
|
+
f"{total_errors} error(s), {total_warnings} warning(s)."
|
|
2923
|
+
)
|
|
2924
|
+
|
|
2925
|
+
sys.exit(exit_code)
|
|
2926
|
+
|
|
2927
|
+
|
|
2928
|
+
if __name__ == "__main__":
|
|
2929
|
+
main()
|