This document describes what every submission must look like, what the automated validation pipeline checks, and how to get your run onto the public leaderboard.
- Web upload (Tier-1 MVP). Sign in at synthbench.org/account, then drop your result JSON into /submit/upload. Validation runs server-side; you can track status on /account/submissions. Successful runs land on the leaderboard within ~5 minutes.
- CLI submit with API key (recommended for automation). Mint
a key at /account, export it, and
synthbench submit run.jsonfrom the same machine that produced the benchmark output. No browser needed; same server-side validation as the web flow. Rate-limited to 60 submissions/hour per key. See the API key flow section below. - GitHub PR (power-user path). Fork the repo, drop your file in
leaderboard-results/, open a PR. The same validators that guard the web upload also run in thevalidate-submissionsCI gate. Gated datasets: runpython scripts/strip-gated-distributions.py leaderboard-results/<your-file>.jsonbefore committing — committed result files must not carry per-questionhuman_distributionfor license-gated datasets (opinionsqa, subpop, globalopinionqa, …; see Gated datasets andhuman_distribution), and CI rejects files that do.
# Run your benchmark end-to-end; write a result JSON into ./results/.
synthbench run -p <provider> -d globalopinionqa -s 15 -n 100 -o results/
# Validate that file against the integrity rules that also run in CI.
synthbench validate results/<your-result>.jsonIf synthbench validate exits non-zero, the server will reject the
submission (web upload) or CI will reject the PR. Fix the issues — don't
hand-edit numbers.
The validator runs in tiers. Each tier gets stricter and more expensive, but no submission ships to the public leaderboard without passing tiers 1 and 2.
- Schema — the result file must carry
benchmark == "synthbench"and the expected top-level keys (config,aggregate,per_question, ...). Missing or wrong-type fields fail immediately. - Bounds —
composite_parity,mean_jsd, and every key underscoreslive in[0, 1]. Kendall's tau lives in[-1, 1].parse_failure_ratelives in[0, 1]. - Distributions — every
human_distributionandmodel_distributionmust sum to1.0within5e-3and contain no negative probabilities. - Question-set integrity — the SHA-256 hash of the sorted question
keys must agree with the reported
question_set_hash. In CI we also check the hash against the canonical dataset hash so you cannot ship scores for a tampered question set. - Count consistency —
aggregate.n_questionsmust equallen(per_question). - Parse-failure plausibility — a warning fires when a large run (≥500 samples, ≥50 questions) reports zero parse failures. That's not a rejection, just a signal for a reviewer to double-check that parse failures are being recorded rather than silently dropped.
Given the per-question distributions you submit, the validator recomputes the metrics and compares them to what you reported:
- Per-question JSD and Kendall's tau — recomputed from
human_distribution/model_distribution; mismatches above1e-2fail. - Aggregate mean JSD and mean tau — recomputed from
per_question; mismatches above1e-2fail. scores.sps— recomputed fromper_questionand required to match under exactly ONE of the two documented composite conventions: the SPS mean over available components (p_dist,p_rank,p_refuse, plusp_cond/p_subon conditioned runs), or the legacy 2-metric blend (0.5 · (1 - JSD) + 0.5 · (1 + τ)/2). Ascores.spsmatching neither is an ERROR. The matched convention is recorded in the validation report (sps convention: sps/parity-2, andmetadata.sps_conventionin--jsonoutput).aggregate.composite_parity— must be internally consistent with the convention identified forscores.sps: the canonical 2-metric blend is always accepted, but the SPS-mean value is only accepted whenscores.spsitself follows (or is absent and thus consistent with) the SPS convention. Reporting "whichever value is higher" fails.scores.p_dist,scores.p_rank, andscores.p_refuse— recomputed and checked against the per-question data.p_refuseis derived from the per-questionmodel_refusal_rate/human_refusal_ratecolumns; when those columns are absent a reportedp_refuseis flagged as unverifiable (warning).
This is where fabricated submissions fail hardest: claiming an sps of
0.95 while shipping distributions that actually yield 0.55 is caught
at this step, regardless of how internally self-consistent the rest of
the file looks. The published leaderboard additionally recomputes every
score from per_question at publish time and ranks by the recomputed
value, so submitted aggregates are never trusted even if a stale file
predates these checks.
In trusted contexts (the server-side submission pipeline and any run
with --rehydrate-canonical), the per-question recompute goes one step
further: your submitted human_distribution values are replaced with the
canonical ones from the dataset registry before the metrics are recomputed.
A submission whose metrics only reconcile against a doctored copy of the
answer key fails here even though it is internally self-consistent.
Some datasets (opinionsqa, subpop, globalopinionqa — Pew ATP /
CC-BY-NC-SA sources) carry redistribution restrictions: their per-question
human_distribution must not be republished from the public git repo
(issue #308).
What this means in practice:
- Your submission is unchanged.
synthbench runstill embedshuman_distributionin the result JSON — the client needs it to compute metrics, and the validators use it for tier-1/2 checks at submission time. - The committed copy is stripped. Before a validated submission is
committed to
leaderboard-results/, the pipeline runsscripts/strip-gated-distributions.py, which removeshuman_distributionfrom every per-question row of gated-dataset files and stamps a top-level"stripped_fields": ["human_distribution"]marker. Everything else —model_distribution,jsd,kendall_tau,parity,human_refusal_rate(a derived scalar) — is preserved. PR submitters run the script themselves before committing. - Validation degrades explicitly, never silently.
synthbench validateon a stripped file runs every check that doesn't need the human distributions and emits aRECOMPUTE_SKIPPED_NO_DISTRIBUTIONwarning for the per-question JSD/tau recompute it could not perform. With--rehydrate-canonical(used by the server-side pipeline, which has access to the canonical registry) the full recompute runs against the canonical distributions instead. - Publish rehydrates. The publish pipeline restores the distributions
from the canonical registry (
synthbench.human_distributions) when emitting gated artifacts to the authenticated R2 origin; CI deploys fail loudly if the canonical source is unavailable. - The marker is only honored for datasets whose redistribution policy is
actually
gated— adding it to a full-tier submission does not exempt you from any check.
Tier 3 adds cheap anomaly detection on the per-question data the
submitter already supplies, plus two new schema blocks that enable
out-of-band audits. Issues in this tier are emitted as warnings by
default — pass --strict in addition to --tier3 at the CLI to fail
the run on any warning. The validate-submissions CI gate runs
synthbench validate --tier3 --peers leaderboard-results on every new
leaderboard PR: tier-1/2 errors block the merge, and tier-3 anomaly
warnings surface in the CI log for the reviewer to adjudicate before
approving.
Enable locally with:
synthbench validate --tier3 results/myrun.json
# include peer runs for the cross-run outlier detector:
synthbench validate --tier3 --peers leaderboard-results/ results/myrun.jsonAnomaly detectors. Each returns a warning-severity issue when fired:
ANOMALY_PERFECTION— per-question JSD hasmean < 0.005orstd < 0.005. Real models show meaningful spread; a copy of the human answer key yields JSD ≈ 0 with near-zero variance.ANOMALY_NO_REFUSAL— submission reportsmodel_refusal_rate = 0on every question while at least three questions havehuman_refusal_rate >= 0.05. Real LLMs refuse sometimes; an all-zero refusal rate on a refuseable dataset typically means either a fabricated run or a bug that drops refusals silently.ANOMALY_PEER_OUTLIER— when peer submissions are supplied via--peers, the submission's per-question JSD on overlapping questions differs from the same-family peer mean by more than0.15. Catches submissions that claim a particular model but were generated by something else.
Raw-response sample requirement. Submissions should include a
top-level raw_responses list with at least 10% of questions
covered:
"raw_responses": [
{
"key": "GOQA_0_adeba4f8",
"raw_text": "I would say the most likely answer is 'Has too much influence'...",
"selected_option": "Has too much influence"
}
]The validator warns on:
RAW_RESPONSES_MISSING/RAW_RESPONSES_TYPE— missing, empty, or wrong shape (an empty list counts as missing).RAW_RESPONSES_COVERAGE— fewer than 10% of questions covered.RAW_RESPONSES_SHAPE/RAW_RESPONSES_EMPTY— sample missingkey/raw_text, orraw_textis blank.RAW_RESPONSES_MODE— the sample'sselected_optionis not in the top-probability set of the reportedmodel_distribution.RAW_RESPONSES_LENGTH/RAW_RESPONSES_LENGTH_SHORT/RAW_RESPONSES_LENGTH_LONG— individual samples over 10,000 characters, or the entire sample set consists of 1-character or 10,000-character outputs.
The harness's runner populates raw_responses automatically when the
provider returns raw model text (Response.raw_text). Providers that
expose only distributions (logprobs) can attach a sample via
Distribution.metadata["raw_sample"].
Reproducibility metadata. A new top-level reproducibility block
is required:
"reproducibility": {
"seed": 42,
"model_revision_hash": "sha256:...",
"prompt_template_hash": "sha256:...",
"framework_version": "0.1.0",
"submitted_at": "2026-04-15T00:00:00+00:00"
}framework_version and submitted_at are auto-populated from the
running harness when synthbench run writes the result file.
seed, model_revision_hash, and prompt_template_hash must be
supplied in the harness config so that auditors can spot-check by
rerunning a randomly chosen question. Missing or empty fields emit
REPRO_FIELD_MISSING / REPRO_FIELD_EMPTY warnings.
Graduation plan. Submissions carry a top-level schema_version
integer. v1 (the legacy shape — every leaderboard entry written before
sb-88fw landed) keeps the old WARNING semantics so historical files
stay valid. v2, emitted by the current runner, promotes
RAW_RESPONSES_MISSING and RAW_RESPONSES_MODE to ERROR — a v2
submission cannot ship without auditable raw model output that agrees
with the reported model_distribution. Other Tier-3 codes (coverage,
length, repro metadata) remain WARNING under both versions; submitters
can still gate them at the CLI with --tier3 --strict. Set
"schema_version": 2 explicitly when authoring a submission by hand;
the harness sets it for you.
A future version will have providers sign result hashes end-to-end so that the question set → run config → distributions → scores chain is verifiable without needing to trust the submitter. Not in scope for the current release.
# Validate a single file, using tier 1 + tier 2:
synthbench validate results/myrun.json
# Validate every JSON in a directory (treats dir args as globs):
synthbench validate results/
# Skip the recompute step (tier 1 only — fastest):
synthbench validate --skip-recompute results/myrun.json
# Pin the expected question-set hash (matches what CI enforces):
synthbench validate --expected-question-hash <sha256> results/myrun.json
# JSON output for CI dashboards:
synthbench validate --json results/myrun.json
# Fail on warnings (zero-parse-failure heuristic, etc.):
synthbench validate --strict results/myrun.json
# Run Tier-3 (anomaly detection + raw_responses + reproducibility):
synthbench validate --tier3 --strict results/myrun.json
# Tier-3 with same-family peer comparison:
synthbench validate --tier3 --peers leaderboard-results/ results/myrun.jsonThe CLI exits 0 when every file passes, 1 when any file fails.
synthbench submit lets you upload directly from the machine that ran the
benchmark. Designed for CI pipelines and scripted experiment runners — no
browser tab required.
AI-agent contributors: there is intentionally no unattended
account-creation path — a submission's value comes from accountable
independence. Have your human operator sign in and mint a write-only
submit-scope key once, then use it as SYNTHBENCH_API_KEY; or use the
GitHub PR path (path 3 above), which needs no synthbench.org account and
anchors identity on your GitHub account instead.
- Sign in at synthbench.org/account and open the API Keys section.
- Click Generate new key, give it a recognizable name
(e.g.
laptop-cli), and pick a scope:- Submit runs — write-only; can call
/submitbut not read gated data. Recommended for CI. - Read gated data — read-only; can fetch gated-tier datasets via the Worker but cannot upload.
- Read + submit — both. Use only if you genuinely need both verbs from the same key.
- Submit runs — write-only; can call
- Copy the displayed key. You will not be able to see it again — the server stores only its sha256 hash. If you lose it, revoke and mint a new one.
- Store it as a secret on the machine that will use it (e.g. shell rc file,
GitHub Actions secret,
1Passworditem).
You can have up to 5 active keys per account; revoke unused ones to make room.
export SYNTHBENCH_API_KEY=sb_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Default — POSTs to https://api.synthbench.org/submit
synthbench submit results/openrouter_gpt-4o-mini_opinionsqa.json
# Override the base URL (preview deploys, self-hosted Workers):
synthbench submit run.json --api-url https://api.preview.synthbench.org
# Pipe-friendly machine output:
synthbench submit run.json --json-outYou can also pass the key inline if you don't want it in the env:
synthbench submit run.json --api-key "$(security find-generic-password -s synthbench)"synthbench run --submit collapses the benchmark + submit steps into a single
invocation. With --wait, the process polls the Worker's
GET /submit/<id> endpoint until the validator reaches a terminal state
and exits with a code that reflects the outcome (0 = published,
1 = rejected/error, 2 = timeout). Use this in CI to gate a release on
leaderboard publication:
# Fire-and-forget: exit 0 once the Worker accepts the upload
synthbench run -p raw-anthropic -m haiku -s 30 -n 100 --submit
# Block until validation completes
synthbench run -p raw-anthropic -m haiku -s 30 -n 100 --submit --wait \
--submit-message "v2 prompt sweep"
# Tighter polling cadence for interactive use
synthbench run -p raw-anthropic -m haiku --submit --wait \
--poll-interval 5 --poll-timeout 600--submit-message is an optional free-form note that gets stamped on the
uploaded JSON so you can tag experiments without touching config
fields (which would change the config_id hash). The result file on
disk is not rewritten — the annotation lives only in the staged R2 copy
and the Worker's record of what was uploaded. If the upload fails for
any reason, the locally-saved JSON is intact and can be re-submitted
later with synthbench submit.
The status endpoint (GET /submit/<id>) is the same surface the
--wait poller uses; it requires an sb_ API key with submit scope
and only returns rows owned by the key holder.
The Worker endpoint is a plain POST application/json so any HTTP client
works:
curl -X POST https://api.synthbench.org/submit \
-H "Authorization: Bearer $SYNTHBENCH_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @results/myrun.jsonA successful submission returns HTTP 202:
{
"submission_id": 423,
"status": "validating",
"file_path": "submissions/2026/04/15/<user-id>/...json",
"submitted_at": "2026-04-15T12:34:56Z"
}| Limit | Value | Behavior on hit |
|---|---|---|
| Submissions per key | 60/hour | HTTP 429 with rate limit exceeded body |
| Submission body size | 2 MB | HTTP 413 |
| Active keys per user | 5 | UI disables Generate new key until you revoke one |
The rate limit is computed against the Supabase submissions table, not an
in-memory counter, so it survives Worker cold starts and is consistent
across edge nodes.
| Status | Body | Meaning | Action |
|---|---|---|---|
| 401 | unknown api key |
Key not in DB or hash mismatch. | Re-check the env var; the key may have been rotated. |
| 401 | api key revoked |
Key is in DB but flagged revoked. | Mint a new one at /account. |
| 401 | api key expired |
expires_at has passed. |
Mint a new one. |
| 403 | api key lacks submit scope |
Key was generated read-only. | Generate a new key with submit or both scope. |
| 429 | rate limit exceeded: 60 submissions/hour per key |
Per-key ceiling. | Wait or use a second key. |
- Plaintext is never persisted. Only
sha256(key)and the first 8 chars (the lookup prefix) live in Supabase. Even a database leak cannot recover a usable key. - Constant-time hash compare in the Worker prevents timing-based key enumeration.
- Per-user RLS on the
api_keystable means even a compromised user account can only list and revoke that user's keys. - Audit trail — every submission stores
api_key_idso post-hoc you can trace which key uploaded what. Browser-flow uploads haveapi_key_id = NULL.
| Code | Meaning | How to fix |
|---|---|---|
SCHEMA_MISSING |
A required field is absent. | Re-run with a current synthbench version. Don't hand-edit the file. |
BOUNDS_RANGE |
A score is outside [0, 1] (or tau outside [-1, 1]). |
Numerical bug upstream — re-run. |
DIST_SUM |
A distribution does not sum to 1.0 ± 5e-3. |
Usually means parse failures were dropped without renormalizing. Re-run or renormalize the distribution before reporting. |
QSET_HASH |
Reported question_set_hash disagrees with the hash of your per_question keys. |
Something modified the keys after the run. Regenerate. |
QSET_HASH_DATASET |
Your question keys don't match the canonical dataset hash. | You ran against a mutated dataset. Re-pull the upstream fixture. |
COUNT_MISMATCH |
n_questions disagrees with len(per_question). |
Truncated or merged file — regenerate. |
PER_Q_JSD / PER_Q_TAU |
Per-question metric doesn't match what the submitted distributions compute to. | Your metric code diverged from synthbench's. Don't write your own metrics — use the harness's. |
AGG_MEAN_JSD / AGG_MEAN_TAU / AGG_COMPOSITE |
Aggregate metric doesn't match the per-question recomputation. | Same as above. |
PARSE_SUSPICIOUS (warning) |
Zero parse failures on a large run. | Double-check your parse pipeline isn't silently succeeding. |
ANOMALY_PERFECTION (warning, tier 3) |
Per-question JSD has mean or std below 0.005. | You almost certainly copied the human answer key. Re-run against the actual model. |
ANOMALY_NO_REFUSAL (warning, tier 3) |
Never refused despite humans refusing on ≥3 questions. | Check whether your pipeline is silently dropping refusals, or whether the model really never refuses in your prompt. |
ANOMALY_PEER_OUTLIER (warning, tier 3) |
Per-question JSD is >0.15 from same-family peers on overlapping questions. | Confirm the claimed model matches the one that generated the submission. |
RAW_RESPONSES_MISSING (warning, tier 3) |
No raw_responses field in the submission (or an empty list). |
Add a list with ≥10% coverage. See SUBMISSIONS.md for the schema. |
RAW_RESPONSES_COVERAGE (warning, tier 3) |
Fewer than 10% of questions have raw samples. | Include more samples so auditors can spot-check. |
RAW_RESPONSES_MODE (warning, tier 3) |
Sample's selected_option doesn't match the top of the model distribution. |
Usually a serialization bug — regenerate. |
REPRO_MISSING / REPRO_FIELD_* (warning, tier 3) |
Missing reproducibility block or field. |
Populate seed, model_revision_hash, prompt_template_hash, framework_version, submitted_at. |
2026-07-13 — Gated human_distribution stripped from committed
results (issue #308). New stripped_fields marker, new
RECOMPUTE_SKIPPED_NO_DISTRIBUTION warning, new
synthbench validate --rehydrate-canonical flag, and
scripts/strip-gated-distributions.py runs before every
leaderboard-results/ commit.
2026-04-15 — Tier 3 added. New schema fields
raw_responses and reproducibility are warnings when missing so
existing leaderboard entries stay valid. CI will graduate them to
errors after the ecosystem has migrated. New synthbench validate
flags: --tier3, --peers <dir>.
The validator does not:
- Check that you actually used the claimed provider / model. That's what Tier 3 is for.
- Recompute demographic breakdowns or conditioning scores (yet).
- Normalize distributions on your behalf — if they don't sum to 1 at the 5e-3 level, the submission is rejected.