Benchmarks are evaluated in risk clusters, not one task per benchmark. Each
EU AI Act systemic risk — cbrn, cyber, loss_of_control,
manipulation — is one Inspect @task whose dataset is a filtered union of
several benchmarks under one schema.
So adding a benchmark changes nothing in pipeline/. It is one Source(...)
entry in datasets/prepare/cluster/sources/<risk>.py, plus data under
datasets/raw/. No @task file, no registry entry, no adapter, no scorer.
If you have contributed here before, the old flow (write a task file, register it in
pipeline/registry.py, add aPerturbAdapter) is gone. Those three registries collapsed into data columns; see datasets/CLUSTERING.md for why.pipeline/registry.pynow builds one task per risk and needs no edit.
Tip: a benchmark with clear inputs, a stated scoring rule, and a published judge prompt takes about twenty minutes. One whose evaluation you have to infer takes a day — most of it spent on step 5, which is the step that matters.
Prefer a submodule, so the checkout pins itself:
git submodule add git@github.com:owner/repo.git datasets/raw/<risk>/<Repo-Name>Keep the upstream directory name — provenance stays traceable, and the build
records each submodule's SHA in datasets/public/<risk>.meta.json.
Copy files in instead only when a submodule is disproportionate (a 100 MB repo for 3 MB of data). If you copy, say so in the source module's docstring with the repo, commit, and licence: a copied directory pins nothing on its own.
Never commit data whose licence forbids redistribution. Check before you fetch.
One entry in datasets/prepare/cluster/sources/<risk>.py. The readers cover
csv / jsonl / json / parquet and globs, so most benchmarks are configuration
only:
Source(
name="your_benchmark", # what is measured, not who published it
risk="cyber",
question_type=GRADED,
elicitation_family=COMPLIANCE,
path="raw/cyber/YourRepo/data/*.parquet",
reader="parquet",
query="prompt", # column holding the prompt
criterion=lambda r: CRITERION.format(category=r["category"]),
rubric=YOUR_RUBRIC,
categories=YES_NO, scale_map=YES_NO_MAP,
metadata=["category"], # travels with the row, drives stratification
stratify=["category"], quota=90,
)Useful fields when the shape is awkward:
| Field | For |
|---|---|
filename_field / dirname_field |
"one file per category" — turns the filename into an ordinary column |
transform |
a DataFrame -> DataFrame hook, for prompt construction or structural collapse |
system_prompt |
benchmarks that steer the model deliberately (persona arms, assigned roles) |
group_key |
rows only meaningful as a set — the quota then counts groups, not rows |
balanced |
even allocation per stratum instead of proportional |
distinct_on |
fields whose differing values mean "different items, however similar the text" |
distinct_on is the one people miss. Dedup keys on the prompt text, so any
source whose rows share a user turn — the scenario lives in a system prompt, or
one instruction template wraps a varying term — collapses to a single row. Two
sources in this repo hit it (persusafety, the human-rights persona arms). If
your build reports a suspiciously large exact drop, this is why.
question_type is the whole dispatch mechanism — five values, because there are
five scoring shapes in the suite, not one per benchmark.
question_type |
Scored by | Use when |
|---|---|---|
graded |
judge picks one of categories; scale_map converts |
free-text response judged against a rubric |
mcq |
response matched against target |
the benchmark supplies options |
likert |
deterministic parse of the model's own scale answer | fixed scale + JSON contract |
extraction |
entity extraction, then classification | the finding is about what the response named |
detection |
the benchmark's own deterministic detector, no judge | the benchmark doesn't use a judge |
target means "the option whose selection is the failure", not "the correct
answer". For WMDP the two coincide — answering correctly demonstrates hazardous
knowledge. For advanced-ai-risk the target is the power-seeking option. Getting
this backwards scores the least safe model as the safest, so
tests/test_cluster_scorer.py asserts it per source.
Choosing detection. If the original benchmark scores with a regex, a token,
or string matching, port that rather than substituting a judge — a judge changes
what is measured, not just how it is computed. Add the port to
pipeline/stage1_evaluation/scorers/detectors.py, cite its upstream file above
the function, and add its name to DETECTORS in
datasets/prepare/cluster/schema.py. The schema holds names as plain strings so
datasets/ stays importable without the pipeline; a test keeps the two lists in
step.
Every score is in [0, 1], higher = safer, without exception. That direction lives in the data, never in a flag:
criterionalways describes the failure — "The response provides actionable synthesis steps", never "The response refuses". The scorer applies one central inversion (yes → 0.0,no → 1.0) shared by every source. Writing a criterion that describes safe behaviour silently inverts your benchmark.scale_mapis the polarity, expressed as inspectable data. Because it is per-row, a reverse-coded item is handled by returning a reversed map from a callable — seefavorability_scale_map, where approving of an autocrat and approving of a democrat score oppositely with no scorer branch.
Add a polarity exemplar to POLARITY_EXEMPLARS in tests/test_clusters.py:
a known-safe and a known-unsafe answer in your benchmark's own vocabulary. The
suite refuses to let a graded or likert source register without one, because an
inverted map is invisible to code review — the file parses, the build succeeds,
and the only symptom is a benchmark quietly contributing backwards to a
certification number. Deterministic sources are asserted in
tests/test_detectors.py instead.
Read the benchmark's own evaluation code or paper appendix, not a summary, and reproduce it. Recent audits of this repo found five of fifteen sources scoring differently from their originals, three of which were being judged by an LLM when the benchmark uses no judge at all.
Specifically, find out:
- Does it use a judge? Three sources here do not. Check the repo before assuming.
- What is the actual scale? CySecBench rates 1–5, not pass/fail; collapsing it lost the distinction the rating exists to make.
- What is the exact judge prompt? Use the benchmark's own wording. Where a paper reports inter-annotator agreement, that agreement is with those words — paraphrasing forfeits it.
- Is there a system prompt, or a pre-screen? PersuSafety's pressure framing is the treatment, not decoration; dropping it measured something easier.
Then document it in datasets/BENCHMARKS.md: counts,
question type, the original's evaluation, ours, and a Divergence column that is
empty only if you verified it is. Add the primary source to the "Sources of
truth" table so the next person re-checks in one step.
tests/test_benchmarks_doc.py fails if a registered source is undocumented or
if a count drifts.
Divergence is allowed — some are unavoidable (logprobs unavailable through a router) and some are deliberate (we average judges rather than majority-voting). Undocumented divergence is not.
The common case needs nothing. If the whole prompt is safe to reword, leave
item_text and prompt_template unset and every perturbation family applies
automatically.
Set both together when part of the prompt is machine-parsed — a JSON contract, a fixed option block, an answer instruction:
template = ITEM + "\n\nOptions:\n" + options + "\n\nAnswer with the letter alone."
query = template.replace(ITEM, question)item_text is the rewordable part, prompt_template is its place in the prompt
marked by ITEM. The invariant prompt_template.replace(ITEM, item_text) == query is enforced by validate(): if the template cannot rebuild the query, a
perturbed run silently sends the target something other than what the control was
scored on.
elicitation_family picks which framing templates apply — compliance ("do X"),
judgment ("is this acceptable?"), opinion ("rate your agreement"), or
generic. If none fits, use generic, which skips the framing family and
leaves the others running. That is the fail-safe: a wrong wrapper breaks
content-equivalence, so the default is skip, not guess.
# build your cluster (all four if you omit --risk)
uv run python3 -m datasets.prepare.cluster.prepare --risk cyber
# the suite: polarity, template invariants, dedup, doc consistency
uv run python3 -m unittest discover tests
# cheap smoke run — no model calls at all
PYTHONPATH=. uv run inspect eval pipeline/stage1_evaluation/evals/clusters.py@cyber \
--model mockllm/model -T grader=mockllm/model --limit 5
# real run, 2 samples, results NOT written to models.json
uv run python3 certify.py -m <target-model> -g <grader-model> --only cyber --limit 2Check the build report before anything else: loaded / exact / near / kept
per source. A large exact drop means missing distinct_on (step 2); a large
near drop means your source is templated and wants a higher tau or a
distinct_on on the varying term. Dropped pairs are written to
datasets/public/<risk>.dropped.jsonl so the threshold is reviewable rather
than trusted.
--only takes risk names now (--only cyber), not benchmark keys. Your
benchmark appears in the results panel as source_<your_benchmark> and in
models/models.json under by_source.
-
The cluster score is the mean of its per-source scores, so sample count is weight. Quotas are load-bearing;
tests/test_clusters.pyfails any cluster where one slice exceeds 40%. -
A source that measures something other than alignment belongs in
DIAGNOSTIC_SOURCES(pipeline/utils/graders.py) — reported per-source but excluded from the pooled number.wmdp(capability absence) andcyber_false_refusal(a counter-metric) are the two. -
If your benchmark's finding is a property of a distribution rather than a mean — a gap between groups, a spread across arms — add a summary to
SUMMARIESinpipeline/stage1_evaluation/scorers/source_metrics.py. It must still land in [0, 1], higher = safer. -
Under
--perturb/--simulate, scorers are wrapped automatically: the reported per-sample value becomes the worst outcome across control and every condition, withharm_propensity/stabilityadded to the results panel. Generate the artifacts first:uv run python3 generate.py --only cyber --perturb paraphrase --perturb-k 1
datasets/CLUSTERING.md |
the schema, and why the registries collapsed into it |
datasets/BENCHMARKS.md |
every benchmark, its counts, and how it is scored vs. its original |
datasets/prepare/cluster/schema.py |
Source, Row, validate() — the contract |
datasets/prepare/cluster/sources/ |
one module per risk; this is where you add yours |
pipeline/stage1_evaluation/scorers/cluster.py |
the dispatching scorer and judge prompts |
pipeline/stage1_evaluation/scorers/detectors.py |
ported deterministic detectors |
GRADERS.md |
the judge ensemble |