synthbench-eval 0.4.0__py3-none-any.whl

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