latch-bench
dataAccess the LatchBio agent benchmarks — scBench, SpatialBench, EpiBench, VariantBench and two long-horizon variants. Fetch the 45 publicly released evaluations, read the deterministic grader contract declared inside each task file, and pull the AnnData analysis snapshots a task starts from. The full benchmarks are withheld upstream; this reaches the public sample.
Latch benchmarks
A family of agent benchmarks built the same way: take a real analysis, snapshot its state at the moment before one decisive step, hand an agent the snapshot and the question, and check whether the number it returns is the one the analysis actually produced.
The distinguishing property is the last part. Grading is a comparison, not a judgement. Most agent benchmarks in biology score with a language model reading the answer, which makes a score noisy, expensive and impossible to reproduce exactly. Here the grader is a numeric interval, a set overlap or an exact match — and, unusually, the entire rule is written down in the task file under a permissive licence, so anyone can re-derive a published result offline. This page shows how to do that, and measures how far the claim actually holds.
What is public, and what is not
Every one of these repositories publishes a sample, not the benchmark. The maintainers withhold the rest deliberately, to keep the tasks out of training corpora. The numbers in the READMEs, the preprints and any coverage you have read describe the full set; the numbers below describe what you can download.
Run this first. It reads each manifest and reconciles it against the repository's own file listing, in both directions.
import json, time, urllib.request
REPOS = { # repo -> folder holding manifest.json and the evals
"scbench": "evals",
"spatialbench": "example_evals", # NOT `evals` -- this one alone is named differently
"epibench": "evals",
"variantbench": "evals",
"scbench-long": "evals",
"spatialbench-long": "evals",
}
def get_json(url, tries=4):
"""GitHub's tree endpoint truncates a chunked response often enough that a single
attempt fails on the larger repositories. Retry, and read the whole body before parsing."""
for n in range(tries):
try:
with urllib.request.urlopen(url, timeout=90) as fh:
return json.loads(fh.read())
except Exception:
if n == tries - 1:
raise
time.sleep(2 * (n + 1))
def entries(manifest, folder):
"""The manifest ships in TWO shapes. scbench and spatialbench are a bare JSON *list*
keyed task/kit/eval_id and carrying no path, so the path has to be constructed. The
other four are an object with an `evals` list keyed id/path. Code written against
either shape alone reads nothing at all from the other, without erroring."""
if isinstance(manifest, list):
return {(e["eval_id"], f"{folder}/{e['task']}/{e['eval_id']}.json") for e in manifest}
return {(e["id"], e["path"]) for e in manifest["evals"]}
print(f"{'repository':20} {'rows':>5} {'evals':>6} {'files':>6} {'orphan':>7} {'dangling':>9}")
total = 0
for repo, folder in REPOS.items():
man = get_json(f"https://raw.githubusercontent.com/latchbio/{repo}/main/{folder}/manifest.json")
ev = entries(man, folder)
tree = get_json(f"https://api.github.com/repos/latchbio/{repo}/git/trees/main?recursive=1")
assert not tree.get("truncated"), f"{repo}: tree truncated, counts would be wrong"
onfile = {b["path"] for b in tree["tree"] if b["type"] == "blob"
and b["path"].startswith(folder + "/") and b["path"].endswith(".json")
and not b["path"].endswith(("manifest.json", "vocabulary.json"))}
ref = {p for _, p in ev}
rows = len(man if isinstance(man, list) else man["evals"])
total += len(ev)
print(f"{repo:20} {rows:5} {len(ev):6} {len(onfile):6} "
f"{len(onfile - ref):7} {len(ref - onfile):9}")
print(f"{'TOTAL':20} {'':5} {total:6}")
Run 2026-08-28:
repository rows evals files orphan dangling
scbench 6 6 6 0 0
spatialbench 18 16 16 0 0
epibench 7 7 7 0 0
variantbench 8 8 8 0 0
scbench-long 4 4 4 0 0
spatialbench-long 4 4 4 0 0
TOTAL 45
Count the unique ids, not the manifest rows. SpatialBench's manifest carries 18 rows for 16
evaluations — two entries are byte-for-byte duplicates of two others, so len(manifest)
overcounts by two. Nothing else in the family disagrees with itself, and no reference is dangling.
Set that against what the projects say they hold:
| benchmark | full set, per its own README | public sample | subject |
|---|---|---|---|
| scBench | 195 | 6 | single-cell RNA-seq, 6 kits, 6 task categories |
| SpatialBench | 159 | 16 | spatial transcriptomics, 5 platforms, 7 task categories |
| EpiBench | 106 | 7 | ATAC-seq, ChIP-seq, CUT&Tag/CUT&RUN, DNA methylation |
| VariantBench | 118 | 8 | variant calling, statistical genetics, personal genomics |
| scBench-Long | not stated | 4 | long-horizon single cell |
| SpatialBench-Long | not stated | 4 | long-horizon spatial |
The four that state a size come to 578 problems, of which 37 are published; the two
long-horizon sets publish four each and state no total. Two of VariantBench's eight are marked
benchmark: false in its own manifest, and its README says they sit outside the 118 — so six
of its public eight are drawn from the scored set.
Three further repositories in the same organisation are biosecurity benchmarks — pathogen surveillance, functional risk assessment, and a paired refusal/red-team set. They share this layout and this grader contract. This skill deliberately does not document them, and routing an agent into dual-use evaluation material is not something a general registry should make easy.
Two licences, and neither one covers the data
Read the LICENSE body. GitHub's classifier is wrong here in both directions, and so is the common summary of it.
- scBench and SpatialBench carry the canonical Apache-2.0 text plus
Copyright 2024 Latch Bio. GitHub reports Apache-2.0. This is the straightforward case. - EpiBench, VariantBench, scBench-Long and SpatialBench-Long carry a modified Apache-2.0
and GitHub reports
NOASSERTION. The modification is real, not cosmetic: the file is re-wrapped, it omits the copyright statement entirely so no licensor is named, it stops atEND OF TERMS AND CONDITIONSwith no appendix, and in the patent grant it drops the(except as stated in this section)qualifier while keeping the litigation-termination sentence that qualifier refers to. Every substantive permission of Apache-2.0 is present and the deviation runs toward the licensee, so a reader has a route; it is not the verbatim text, and anyone whose counsel diffs licence files should be told which four files these are.
Neither licence covers the material a task actually operates on. Every evaluation points at its data through an opaque handle into LatchBio's own storage, not a URL, and no eval file records where that data came from: across all 45, four accession-like strings appear anywhere, and three of those are only inside an eval's own name. The snapshots derive from published studies whose terms are set by those studies. Fetch them, work with them in place, and do not mirror or redistribute them — you cannot establish what you would be redistributing.
The shared grader library is not usable. latch-eval-tools, which the READMEs point to for
grader implementations, has a LICENSE reading © LatchBio LLC. All rights reserved. A sibling
repository holding earlier graders is archived with no LICENSE at all, and txbench-pp, a
seventh analysis benchmark in the same family, has none either. So the one integration that
would cover the whole family is the one piece nothing can be built on. That turns out not to
matter, for the reason the next two sections set out: the grading rule is data, published
under the benchmark's own permissive licence, and only the code that executes it is not.
No repository in the family carries a tag or a release. main is the only reference point
and it can move under you. Record the commit you read, as this page does.
The eval file, minus the answer key
One JSON object per task. Three of its fields must never be printed, pasted or committed anywhere public — two hold the answer, one is the contamination tripwire.
import collections, json, urllib.request
REPOS = {"scbench": "evals", "spatialbench": "example_evals", "epibench": "evals",
"variantbench": "evals", "scbench-long": "evals", "spatialbench-long": "evals"}
RAW = "https://raw.githubusercontent.com/latchbio"
# `grader`/`graders` hold the ground truth; `canary` is the leak-detection string.
GRADED = ("grader", "graders", "canary")
def get_json(url):
with urllib.request.urlopen(url, timeout=60) as fh:
return json.loads(fh.read())
def paths(repo, folder):
man = get_json(f"{RAW}/{repo}/main/{folder}/manifest.json")
if isinstance(man, list):
return sorted({f"{folder}/{e['task']}/{e['eval_id']}.json" for e in man})
return sorted({e["path"] for e in man["evals"]})
def safe(ev):
"""Project to the fields that carry no answer. Everything downstream works off this,
so a stray print() cannot leak a ground truth. Cheaper than auditing every later line."""
return {k: v for k, v in ev.items() if k not in GRADED}
evals, withheld = [], collections.Counter()
for repo, folder in REPOS.items():
for p in paths(repo, folder):
ev = get_json(f"{RAW}/{repo}/main/{p}")
for k in GRADED:
if k in ev:
withheld[k] += 1
evals.append((repo, safe(ev)))
fields = collections.Counter(k for _, e in evals for k in e)
print(f"{len(evals)} evals loaded, projected to safe fields\n")
print("field present on n evals:")
for k, n in fields.most_common():
print(f" {k:12} {n:3}/{len(evals)}")
print("\nwithheld, never printed:")
for k, n in withheld.most_common():
print(f" {k:12} {n:3}/{len(evals)}")
# `metadata` is where the useful axes live -- and it is not uniform across the family.
md = collections.Counter()
for repo, e in evals:
md[tuple(sorted(e.get("metadata", {})))] += 1
print("\nmetadata key sets:")
for k, n in md.most_common():
print(f" {n:3}x {list(k)}")
Run 2026-08-28:
45 evals loaded, projected to safe fields
field present on n evals:
data_node 45/45
id 45/45
metadata 45/45
task 45/45
notes 30/45
withheld, never printed:
canary 41/45
grader 28/45
graders 2/45
metadata key sets:
23x ['eval_type', 'kit', 'task', 'time_horizon']
5x ['eval_type', 'kit', 'task', 'time_horizon', 'timeout_s']
4x ['assay', 'eval_type', 'kit', 'task', 'time_horizon']
3x ['eval_type', 'failure_mode_taxonomy_version', 'failure_modes', 'kit', 'task', 'time_horizon']
3x ['eval_type', 'kit', 'modules', 'task', 'time_horizon']
3x ['graded_biological_result', 'kit', 'task', 'time_horizon']
1x ['eval_type', 'failure_mode_taxonomy_version', 'failure_modes', 'kit', 'task', 'time_horizon', 'timeout_s']
1x ['assay', 'eval_type', 'failure_mode_taxonomy_version', 'failure_modes', 'kit', 'task', 'time_horizon']
1x ['eval_type', 'kit', 'modules', 'task', 'time_horizon', 'version']
1x ['graded_biological_result']
id, task (the prompt), metadata and data_node are on every eval. notes is on two
thirds. metadata is the field to be careful with: ten different key sets across 45 files, and
eval_type — the field a filter would key on — is missing from four and is the constant
"scientific" on the other 41. It has never distinguished anything.
There is no directory-level split between safe and graded material here. In some benchmarks the answer lives in a separate folder, so avoiding it is a path test. Here the answer sits in the same object as the prompt, so the boundary is the projection above and nothing enforces it for you. Apply it once at load and work off the result.
Four evals carry no canary. That is an omission upstream, not permission — treat every task
in the family as canaried, and never reproduce a canary value in a public document. Doing
so is what destroys its only function, for everyone, permanently.
The grader contract
import collections, json, urllib.request
REPOS = {"scbench": "evals", "spatialbench": "example_evals", "epibench": "evals",
"variantbench": "evals", "scbench-long": "evals", "spatialbench-long": "evals"}
RAW = "https://raw.githubusercontent.com/latchbio"
def get_json(url):
with urllib.request.urlopen(url, timeout=60) as fh:
return json.loads(fh.read())
def paths(repo, folder):
man = get_json(f"{RAW}/{repo}/main/{folder}/manifest.json")
if isinstance(man, list):
return sorted({f"{folder}/{e['task']}/{e['eval_id']}.json" for e in man})
return sorted({e["path"] for e in man["evals"]})
def graders(ev):
"""`grader` (singular, one object) and `graders` (plural, a list) are both used, and
`all_of` nests further graders under config.children. Handle all three or you undercount."""
found = [ev["grader"]] if "grader" in ev else list(ev.get("graders", []))
out = []
while found:
g = found.pop()
out.append(g)
found.extend(g.get("config", {}).get("children", []))
return out
fam = collections.Counter()
tol = collections.Counter()
have = collections.Counter()
for repo, folder in REPOS.items():
for p in paths(repo, folder):
ev = get_json(f"{RAW}/{repo}/main/{p}")
gs = graders(ev)
have[repo] += 1 if gs else 0
for g in gs:
fam[g["type"]] += 1
for t in g.get("config", {}).get("tolerances", {}).values():
# `type` is the field you would switch on. Check what it actually varies over.
tol[(t.get("type"), tuple(sorted(t)))] += 1
print("evals carrying a machine-readable grader:")
for r in REPOS:
print(f" {r:20} {have[r]}")
print(f"\ngrader families over {sum(fam.values())} grader objects:")
for k, n in fam.most_common():
print(f" {n:3} {k}")
print("\ntolerance entries, by declared type and by actual keys:")
for (ty, keys), n in tol.most_common():
print(f" {n:3} type={ty!r:12} keys={list(keys)}")
Run 2026-08-28:
evals carrying a machine-readable grader:
scbench 6
spatialbench 16
epibench 0
variantbench 8
scbench-long 0
spatialbench-long 0
grader families over 35 grader objects:
27 numeric_tolerance
4 label_set_jaccard
2 marker_gene_precision_recall
1 multiple_choice
1 all_of
tolerance entries, by declared type and by actual keys:
55 type='absolute' keys=['type', 'value']
4 type='absolute' keys=['lower', 'type', 'upper']
Three things to take from this.
The grader is only published for 30 of the 45 public evals. EpiBench, scBench-Long and SpatialBench-Long ship the prompt and the data pointer and no grading rule at all, even though their READMEs describe deterministic endpoint grading. For those 15 you can run the task; you cannot score it.
Every family present is a pure comparison. A numeric band, a Jaccard overlap against a fixed
label set, precision and recall at k against a fixed marker list, an exact string match, and a
conjunction of the others. No language model appears in any of them. The scBench and
SpatialBench READMEs list a fifth family, DistributionComparison, which occurs in zero public
evals, and neither lists all_of, which occurs in one — so the documented set and the shipped
set differ in both directions.
type is the field that lies. It reads absolute on all 59 tolerance entries and has never
taken another value, while the keys beside it describe two different rules. Fifty-five entries
carry value, a symmetric band around the ground truth. Four carry lower and upper — and
those are an asymmetric offset from the ground truth, not the interval endpoints. Read them
as endpoints and the verdict flips; the next section measures how often.
Is the grader actually deterministic
Not a claim to take from a README. Two of these repositories publish, for each recorded agent run, both the answer that was submitted and the verdict the real grader returned. So the question has an exact answer: re-derive every verdict from the published rule and count the contradictions.
This reads about a thousand small files and took roughly five minutes on a slow connection.
import collections, concurrent.futures, json, time, urllib.request
RAW = "https://raw.githubusercontent.com/latchbio"
REPOS = {"scbench": "evals", "spatialbench": "example_evals"} # the two that ship graded runs
def get_json(url, tries=4):
for n in range(tries):
try:
with urllib.request.urlopen(url, timeout=90) as fh:
return json.loads(fh.read())
except Exception:
if n == tries - 1:
raise
time.sleep(2 * (n + 1))
def numeric_tolerance(answer, config):
"""The whole grader, re-derived from the eval file. `value` is a symmetric band; a
`lower`/`upper` pair is an ASYMMETRIC offset from ground truth, not a literal interval —
read it as bounds and the verdict flips on every eval that uses it. `type` is
'absolute' in both cases and tells you nothing."""
scores = {}
for field, expected in config["ground_truth"].items():
if field not in answer:
scores[field] = 0.0
continue
err = float(answer[field]) - float(expected)
tol = config["tolerances"][field]
ok = (abs(err) <= float(tol["value"])) if "value" in tol \
else (-float(tol["lower"]) <= err <= float(tol["upper"]))
scores[field] = 1.0 if ok else 0.0
return all(v == 1.0 for v in scores.values()), scores
agree = disagree = null_runs = 0
score_mismatch = field_mismatch = 0
impls = collections.Counter()
t0 = time.time()
for repo, folder in REPOS.items():
man = get_json(f"{RAW}/{repo}/main/{folder}/manifest.json")
ev_paths = sorted({f"{folder}/{e['task']}/{e['eval_id']}.json" for e in man})
evals = {}
for p in ev_paths:
e = get_json(f"{RAW}/{repo}/main/{p}")
evals[e["id"]] = e["grader"]["config"]
tree = get_json(f"https://api.github.com/repos/latchbio/{repo}/git/trees/main?recursive=1")
assert not tree.get("truncated")
runs = [b["path"] for b in tree["tree"]
if b["type"] == "blob" and b["path"].endswith("/result.json")]
def check(path):
r = get_json(f"{RAW}/{repo}/main/{path}")
return path.split("/")[1], r
with concurrent.futures.ThreadPoolExecutor(16) as ex:
for eval_id, r in ex.map(check, runs):
impls[r.get("latch_eval_tools_version", "not recorded")] += 1
graded = (r.get("result") or {}).get("grader_result")
if graded is None: # a run that submitted nothing records null, not False
null_runs += 1
continue
passed, scores = numeric_tolerance(r["result"]["agent_answer"], evals[eval_id])
agree += passed == graded["passed"]
disagree += passed != graded["passed"]
if abs(sum(scores.values()) / len(scores) - graded["score"]) > 1e-9:
score_mismatch += 1
if scores != graded["field_scores"]:
field_mismatch += 1
print(f"recorded runs read : {agree + disagree + null_runs} ({time.time()-t0:.0f}s)")
print(f"gradable : {agree + disagree}")
print(f"runs that submitted none: {null_runs}")
print(f"pass/fail reproduced : {agree} contradicted: {disagree}")
print(f"score contradicted : {score_mismatch}")
print(f"per-field contradicted : {field_mismatch}")
print(f"grader builds covered : {dict(impls)}")
Run 2026-08-28:
recorded runs read : 1041 (287s)
gradable : 1040
runs that submitted none: 1
pass/fail reproduced : 1040 contradicted: 0
score contradicted : 0
per-field contradicted : 0
grader builds covered : {'0.3.8': 225, '0.3.9': 48, '0.3.7': 432, 'not recorded': 336}
Every recorded verdict, reproduced from the published rule alone. Not only the pass/fail flag: the real-valued score and every per-field score agree exactly, across three different builds of the upstream grader. Roughly a dozen lines of arithmetic stand in for a library nobody outside the company may use.
Two supporting checks worth doing, because "deterministic" can fail quietly in either place.
Running the whole pass twice in separate processes under different PYTHONHASHSEED values gives
byte-identical output, which rules out iteration-order or set-ordering effects in the
reimplementation. And the reasoning string the real grader attaches to each verdict is a fixed
template on all 273 scBench runs — Numeric Tolerance Check: PASS or FAIL, then one line per
field — with no generated prose anywhere. There is no model in the loop to be non-deterministic.
What this does not say is that a benchmark run is reproducible. The agent is not deterministic and nothing here makes it so; three attempts per task per configuration is the published protocol precisely because of that. What is fixed is everything downstream of the submitted answer. Given a trajectory, the score follows with no variance, no key and no cost — which is what makes an independently published number checkable rather than merely quotable.
What deterministic grading cannot do
The gain is exactness. The losses are specific, and they shape what a score means.
- The endpoint verdict is all or nothing.
field_scoresrecords partial credit per field andscoreis their mean, butpassedrequires every field inside tolerance. An answer that is right about three of four quantities scores 0.75 and fails. EpiBench's own README reports field-level scores higher than endpoint scores, which is this effect measured. - Only the declared answer shape counts. The grader looks up
ground_truth's field names in the submitted object, and a name it does not find scores zero. A correct result reported under a different key, or in different units, fails indistinguishably from a wrong one. - A defensible different number is still wrong. Where several reasonable pipelines give materially different cell counts, only the band around the reference pipeline's answer passes. The tolerances are the whole allowance for methodological freedom, and four of them are asymmetric — wider on one side of the reference value than the other.
- Nothing about method is examined. An answer guessed, or copied from the source paper without touching the data, grades identically to one computed. The projects address this by choosing questions whose answers are not recoverable from prior knowledge, which is a property of task design, not of grading.
- A missing answer is
null, notFalse. One recorded run submitted nothing, and itspassedisnull. Treating that as a boolean throws; summing it silently drops the run from the denominator and flatters the pass rate.
Which tasks you can actually run
A task is only usable if its snapshots resolve. They are addressed by handle, and a handle the project never shared is refused — including some inside the published sample.
import concurrent.futures, json, time, urllib.request, urllib.error
REPOS = {"scbench": "evals", "spatialbench": "example_evals", "epibench": "evals",
"variantbench": "evals", "scbench-long": "evals", "spatialbench-long": "evals"}
RAW = "https://raw.githubusercontent.com/latchbio"
SIGN = "https://nucleus.latch.bio/ldata/get-signed-url"
def get_json(url, tries=4):
for n in range(tries):
try:
with urllib.request.urlopen(url, timeout=90) as fh:
return json.loads(fh.read())
except urllib.error.HTTPError:
raise
except Exception:
if n == tries - 1:
raise
time.sleep(2 * (n + 1))
def reachable(node):
"""400, not 401 and not 404. A node that was never shared and a node that never existed
return the same status and the same sentence, so this answers 'can I get it', not 'is
it there'."""
req = urllib.request.Request(SIGN, data=json.dumps({"path": node}).encode(),
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=90) as fh:
return bool(json.loads(fh.read()).get("data", {}).get("url"))
except urllib.error.HTTPError:
return False
tasks = []
for repo, folder in REPOS.items():
man = get_json(f"{RAW}/{repo}/main/{folder}/manifest.json")
paths = sorted({f"{folder}/{e['task']}/{e['eval_id']}.json" for e in man}) \
if isinstance(man, list) else sorted({e["path"] for e in man["evals"]})
for p in paths:
ev = get_json(f"{RAW}/{repo}/main/{p}")
nodes = [ev["data_node"]] if isinstance(ev["data_node"], str) else ev["data_node"]
tasks.append((repo, ev["id"], nodes))
every = sorted({n for _, _, ns in tasks for n in ns})
with concurrent.futures.ThreadPoolExecutor(12) as ex:
status = dict(zip(every, ex.map(reachable, every)))
print(f"{'repository':20} {'evals':>6} {'runnable':>9} {'nodes':>6} {'reachable':>10}")
for repo in REPOS:
rows = [t for t in tasks if t[0] == repo]
nodes = {n for _, _, ns in rows for n in ns}
runnable = sum(all(status[n] for n in ns) for _, _, ns in rows)
print(f"{repo:20} {len(rows):6} {runnable:9} {len(nodes):6} "
f"{sum(status[n] for n in nodes):10}")
print(f"\n{len(every)} distinct data nodes, {sum(status.values())} reachable without an account")
print(f"{sum(all(status[n] for n in ns) for _, _, ns in tasks)} of {len(tasks)} evals have every node reachable")
for repo, eid, ns in tasks:
if not all(status[n] for n in ns):
print(f" incomplete: {repo}/{eid} {sum(not status[n] for n in ns)} of {len(ns)} nodes refused")
Run 2026-08-28:
repository evals runnable nodes reachable
scbench 6 6 6 6
spatialbench 16 16 10 10
epibench 7 7 512 512
variantbench 8 6 35 23
scbench-long 4 3 10 9
spatialbench-long 4 1 9 3
582 distinct data nodes, 563 reachable without an account
39 of 45 evals have every node reachable
incomplete: variantbench/eval_stage4_annotated_vcf 7 of 7 nodes refused
incomplete: variantbench/osteosarcoma_wes_stage3_called_vcf_neoantigen_gene_recall 11 of 11 nodes refused
incomplete: scbench-long/green_2_tme_context_dependence 1 of 3 nodes refused
incomplete: spatialbench-long/lineage_metastasis_multilayer_niche_mapping 1 of 1 nodes refused
incomplete: spatialbench-long/visium_perineural_proximity 3 of 3 nodes refused
incomplete: spatialbench-long/xenium_organoid_spatial_niche_disruption 2 of 3 nodes refused
No account, no token, no click-through. 563 of 582 handles resolve to a presigned link for an anonymous caller, which is the whole access story for this family.
The 19 that do not are a real gap in a published release, and it falls unevenly: six of the
45 evals are missing at least part of their data, four of them all of it, and
SpatialBench-Long is down to one complete task in four. The two VariantBench failures are
exactly the two its manifest marks benchmark: false — extras whose data was never shared.
Check before you plan around a task, not after.
Two traps in the refusal itself. It is HTTP 400, not 401 and not 404, and one message covers
both "no such node" and "not shared with you" — so a caller cannot distinguish a withheld
snapshot from a typo. And sending a malformed Authorization header gets a 401 where
sending none gets a 200 — the header is validated when present and not required when absent, so
a stale or wrong token turns a request that would have worked into one that does not.
Snapshot sizes before you fetch
For scBench and SpatialBench each task's snapshot is a single .h5ad, and they span three
orders of magnitude. Size before transferring.
import concurrent.futures, json, statistics, time, urllib.request
RAW = "https://raw.githubusercontent.com/latchbio"
SIGN = "https://nucleus.latch.bio/ldata/get-signed-url"
REPOS = {"scbench": "evals", "spatialbench": "example_evals"}
def get_json(url):
with urllib.request.urlopen(url, timeout=90) as fh:
return json.loads(fh.read())
def size_of(node, tries=5):
"""One ranged GET of eight bytes. Content-Range carries the full length, so this costs
nothing and answers the only question worth asking before a transfer. Retry: the storage
host resets connections often enough that a single attempt fails on a set this size."""
req = urllib.request.Request(SIGN, data=json.dumps({"path": node}).encode(),
headers={"Content-Type": "application/json"})
url = json.loads(urllib.request.urlopen(req, timeout=90).read())["data"]["url"]
for n in range(tries):
try:
probe = urllib.request.Request(url, headers={"Range": "bytes=0-7"})
with urllib.request.urlopen(probe, timeout=180) as fh:
return fh.read(), int(fh.headers["content-range"].split("/")[1])
except Exception:
if n == tries - 1:
raise
time.sleep(3 * (n + 1))
pairs = []
for repo, folder in REPOS.items():
man = get_json(f"{RAW}/{repo}/main/{folder}/manifest.json")
for p in sorted({f"{folder}/{e['task']}/{e['eval_id']}.json" for e in man}):
ev = get_json(f"{RAW}/{repo}/main/{p}")
pairs.append((repo, ev["id"], ev["data_node"]))
nodes = sorted({n for _, _, n in pairs})
with concurrent.futures.ThreadPoolExecutor(6) as ex:
info = dict(zip(nodes, ex.map(size_of, nodes)))
sizes = sorted(s for _, s in info.values())
print(f"{len(pairs)} evals reference {len(nodes)} distinct snapshots "
f"(several evals share one)")
print(f"container magic : {sorted({m[:4] for m, _ in info.values()})}")
print(f"smallest : {sizes[0] / 1e6:.1f} MB")
print(f"median : {statistics.median(sizes) / 1e6:.1f} MB")
print(f"largest : {sizes[-1] / 1e6:.1f} MB")
print(f"over 1 GB : {sum(s > 1e9 for s in sizes)} of {len(sizes)}")
print(f"whole set : {sum(sizes) / 1e9:.1f} GB\n")
for repo, eid, node in sorted(pairs, key=lambda t: info[t[2]][1]):
print(f" {info[node][1] / 1e6:9.1f} MB {repo:13} {eid[:46]}")
Run 2026-08-28:
22 evals reference 16 distinct snapshots (several evals share one)
container magic : [b'\x89HDF']
smallest : 8.8 MB
median : 126.3 MB
largest : 9631.0 MB
over 1 GB : 5 of 16
whole set : 23.5 GB
8.8 MB scbench bd_rhapsody_tnbc_panel_aware_qc
8.8 MB spatialbench visium_bone_celltype_lineage_resolution_limit
34.8 MB scbench tapestri_ccus_clustering_12_largest_mutant_clo
79.0 MB spatialbench norm_01_opc_olig_maturation_foldchange
79.0 MB spatialbench norm_02_myelin_gene_coexpression_normalization
79.0 MB spatialbench qc_01_endothelial_doublet_assessment
80.7 MB scbench dr_05_pca_preprocessing_sentinels
110.8 MB spatialbench xenium_kidney_celltype_fib_immune_temporal
110.8 MB spatialbench xenium_kidney_spatial_fibro_inflammatory_niche
114.4 MB spatialbench xenium_kidney_spatial_cn7_composition_day14
115.0 MB scbench NRM01_sparse_normalization
137.7 MB scbench T04a_endothelin_niche_sources
242.7 MB spatialbench MOTIF04_neuronal_female_tf_family
639.5 MB spatialbench curio_ovary_oocyte_count_per_timepoint
1102.1 MB spatialbench curio_ovary_cumulus_gc_count_immature
1102.1 MB spatialbench curio_ovary_follicle_count_immature
1102.1 MB spatialbench curio_ovary_batch_driven_clustering
1590.6 MB scbench DE01_pseudobulk_de
3209.6 MB spatialbench NORM01_batch_correction
6416.3 MB spatialbench DR01_batch_confounded_clustering
9631.0 MB spatialbench SPATIAL10_genome_wide_de_pct
9631.0 MB spatialbench SPATIAL07_sex_housekeeping_de
Twenty-two tasks, sixteen files. Snapshots are shared — three SpatialBench evaluations run off the same 1.1 GB object and two more off the same 9.6 GB one — so counting tasks overstates what you need to download by a third here. The other four repositories point each eval at a list of handles rather than one, so the same arithmetic runs the other way there.
Get the files
Reading the .h5ad needs one dependency; everything above is standard library.
python3 -m venv .venv && ./.venv/bin/pip install anndata
import json, os, time, urllib.request
RAW = "https://raw.githubusercontent.com/latchbio"
SIGN = "https://nucleus.latch.bio/ldata/get-signed-url"
OUT = "Data/latch-bench"
EVAL = "evals/qc/bd_rhapsody_tnbc_panel_aware_qc.json" # smallest snapshot in the public set
os.makedirs(OUT, exist_ok=True)
def get_json(url):
with urllib.request.urlopen(url, timeout=90) as fh:
return json.loads(fh.read())
def resolve(node):
"""`latch://<id>.node` is a handle into LatchBio's storage, not a URL. This endpoint
trades it for a presigned S3 link and needs NO account for a node the project shared.
Send a malformed Authorization header and it 401s, so send none at all. A node that was
never shared answers 400 -- 'does not exist' and 'not public' share one status and one
message, so you cannot tell them apart."""
req = urllib.request.Request(SIGN, data=json.dumps({"path": node}).encode(),
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=90) as fh:
return json.loads(fh.read())["data"]["url"]
def fetch_resumable(url, dest, chunk=1 << 22, tries=6):
"""Range-resume rather than stream in one call. These links are served slowly and drop
mid-transfer; a plain read() on a multi-GB snapshot loses the whole transfer when it
does. Re-running this continues from whatever is already on disk."""
with urllib.request.urlopen(
urllib.request.Request(url, headers={"Range": "bytes=0-7"}), timeout=120) as fh:
magic, total = fh.read(), int(fh.headers["content-range"].split("/")[1])
assert magic[:4] == b"\x89HDF", f"not an HDF5 container: {magic!r}"
have = os.path.getsize(dest) if os.path.exists(dest) else 0
with open(dest, "ab") as out:
while have < total:
hi = min(have + chunk, total) - 1
for n in range(tries):
try:
req = urllib.request.Request(url, headers={"Range": f"bytes={have}-{hi}"})
with urllib.request.urlopen(req, timeout=300) as fh:
buf = fh.read()
break
except Exception:
if n == tries - 1:
raise
time.sleep(3 * (n + 1))
out.write(buf)
out.flush()
have += len(buf)
return total
ev = get_json(f"{RAW}/scbench/main/{EVAL}")
with open(os.path.join(OUT, os.path.basename(EVAL)), "w") as fh:
json.dump(ev, fh, indent=2) # written whole; printed only in projection
dest = os.path.join(OUT, f"{ev['id']}.h5ad")
total = fetch_resumable(resolve(ev["data_node"]), dest)
print(f"eval {ev['id']}")
print(f"kit / task {ev['metadata']['kit']} / {ev['metadata']['task']}")
print(f"prompt {len(ev['task'])} characters (on disk, not printed)")
print(f"withheld {[k for k in ('grader', 'graders', 'canary') if k in ev]}")
print(f"data_node {ev['data_node']} -> {total / 1e6:.1f} MB")
print(f"written {dest} ({os.path.getsize(dest) / 1e6:.1f} MB)")
import anndata
ad = anndata.read_h5ad(dest)
print(f"AnnData {ad.shape[0]} obs x {ad.shape[1]} var")
print(f"obs columns {list(ad.obs.columns)[:5]}")
print(f"layers {list(ad.layers)}")
Run 2026-08-28:
eval bd_rhapsody_tnbc_panel_aware_qc
kit / task bd_rhapsody / qc
prompt 628 characters (on disk, not printed)
withheld ['grader', 'canary']
data_node latch://203298116.node -> 8.8 MB
written Data/latch-bench/bd_rhapsody_tnbc_panel_aware_qc.h5ad (8.8 MB)
AnnData 16136 obs x 386 var
obs columns ['sample', 'Cell_Index', 'tissue', 'sex', 'treatment']
layers ['DBEC_Adjusted_Molecules', 'RSEC_Adjusted_Molecules', 'Raw_Molecules']
Real working data, mid-analysis: 16,136 cells against a 386-gene targeted panel, three count layers reflecting successive molecular-index corrections, and sample covariates already attached. That is the state the analysis was in the moment before the step being tested — which is what makes these tasks concrete rather than hypothetical.
The download is resumable on purpose. This storage path is slow and drops connections; a single-call stream on a 9.6 GB snapshot will lose the whole transfer and start over.
Names that collide
Both of the largest members share a name with an unrelated resource in an adjacent field, and a citation without the organisation attached is ambiguous.
- SCBench is also a long-context language-model benchmark distributed on Hugging Face, and separately a SystemC hardware verification suite. Searching Hugging Face for "scbench" returns several of these and none of this. There is no Hugging Face distribution for this family at all — the organisation publishes no datasets there — so a Hugging Face path is the wrong place to look, not a permissions problem.
- SpatialBench is also a published spatial-transcriptomics platform comparison deposited in BioStudies with its own DOI, which is a benchmark of assays rather than of agents.
None of these repositories carries a DOI or a tagged release, so cite the organisation, the repository and the commit.
What this skill will not do
It stops at the tasks, the grading rule and the data. It does not run an agent, does not stand up a harness, and does not submit anything to a leaderboard.
It also has no shortcut. The biomedarena harness registers 155 biomedical benchmarks behind
one CLI and not one of these is among them, so reaching this family means reaching these
repositories.
That boundary matters more than usual here. These sets are small, published as samples precisely so the rest can stay uncontaminated, and 41 of the 45 carry a leak-detection string for that reason. Reading a task's answer is fine and is what the projection above is careful about; publishing one — an expected value, a grader config, a canary — into any indexable document retires that task for everyone. The registry that hosts this page is public and permanent, which is exactly why no ground truth appears anywhere above.
Try it
Data. The public evaluation samples in latchbio/scbench and latchbio/variantbench
(Apache-2.0 and a modified Apache-2.0 respectively), their recorded run results, and one
analysis snapshot resolved through LatchBio's storage endpoint. No account is needed for any of
it. Last confirmed reachable 2026-08-28.
Run. Standard library only.
import concurrent.futures, json, time, urllib.request
RAW = "https://raw.githubusercontent.com/latchbio"
SIGN = "https://nucleus.latch.bio/ldata/get-signed-url"
GRADED = ("grader", "graders", "canary")
def get_json(url, tries=4):
for n in range(tries):
try:
with urllib.request.urlopen(url, timeout=90) as fh:
return json.loads(fh.read())
except Exception:
if n == tries - 1:
raise
time.sleep(2 * (n + 1))
def entries(man, folder):
if isinstance(man, list): # scbench, spatialbench
return sorted({f"{folder}/{e['task']}/{e['eval_id']}.json" for e in man})
return sorted({e["path"] for e in man["evals"]}) # the other four
# Both manifest shapes must parse, or half the family reads as empty.
sc = entries(get_json(f"{RAW}/scbench/main/evals/manifest.json"), "evals")
vb = entries(get_json(f"{RAW}/variantbench/main/evals/manifest.json"), "evals")
assert len(sc) == 6 and len(vb) == 8, (len(sc), len(vb))
evals = {}
for p in sc:
e = get_json(f"{RAW}/scbench/main/{p}")
assert {"id", "task", "metadata", "data_node"} <= set(e), sorted(e)
evals[e["id"]] = e
assert all("canary" in e for e in evals.values()), "canary field gone -- recheck the policy"
safe = {i: {k: v for k, v in e.items() if k not in GRADED} for i, e in evals.items()}
assert not any(k in s for s in safe.values() for k in GRADED), "projection leaked a graded field"
# `type` is the field you would switch on, and it never varies -- while the keys that carry
# the actual rule do. Reading `lower`/`upper` as literal bounds instead of as an asymmetric
# offset from ground truth flips real verdicts, which the re-grade below demonstrates.
tol = [t for e in evals.values() for t in e["grader"]["config"]["tolerances"].values()]
assert {t["type"] for t in tol} == {"absolute"}, "a second tolerance type appeared"
shapes = {tuple(sorted(t)) for t in tol}
assert shapes == {("type", "value"), ("lower", "type", "upper")}, shapes
EVAL_ID = "dr_05_pca_preprocessing_sentinels" # the one using the lower/upper form
cfg = evals[EVAL_ID]["grader"]["config"]
def grade(answer, cfg, naive=False):
scores = {}
for field, expected in cfg["ground_truth"].items():
if field not in answer:
scores[field] = 0.0
continue
got, tolerance = float(answer[field]), cfg["tolerances"][field]
if "value" in tolerance:
ok = abs(got - float(expected)) <= float(tolerance["value"])
elif naive:
ok = float(tolerance["lower"]) <= got <= float(tolerance["upper"])
else:
ok = -float(tolerance["lower"]) <= got - float(expected) <= float(tolerance["upper"])
scores[field] = 1.0 if ok else 0.0
return all(v == 1.0 for v in scores.values()), scores
tree = get_json("https://api.github.com/repos/latchbio/scbench/git/trees/main?recursive=1")
assert not tree.get("truncated")
runs = sorted(b["path"] for b in tree["tree"] if b["type"] == "blob"
and b["path"].startswith(f"trajectories/{EVAL_ID}/") and b["path"].endswith("/result.json"))
with concurrent.futures.ThreadPoolExecutor(12) as ex:
results = list(ex.map(lambda p: get_json(f"{RAW}/scbench/main/{p}"), runs))
ok = naive_wrong = 0
for r in results:
graded = r["result"]["grader_result"]
passed, scores = grade(r["result"]["agent_answer"], cfg)
assert passed == graded["passed"], "declared grader spec did not reproduce a recorded verdict"
assert scores == graded["field_scores"], "per-field scores did not reproduce"
ok += 1
naive_wrong += grade(r["result"]["agent_answer"], cfg, naive=True)[0] != graded["passed"]
assert naive_wrong > 0, "the naive tolerance reading no longer disagrees -- recheck the trap"
req = urllib.request.Request(SIGN, headers={"Content-Type": "application/json"},
data=json.dumps({"path": evals[EVAL_ID]["data_node"]}).encode())
url = json.loads(urllib.request.urlopen(req, timeout=90).read())["data"]["url"]
probe = urllib.request.Request(url, headers={"Range": "bytes=0-7"})
with urllib.request.urlopen(probe, timeout=120) as fh:
magic, size = fh.read(), int(fh.headers["content-range"].split("/")[1])
assert magic[:4] == b"\x89HDF", magic
print(f"manifest shapes parsed : list={len(sc)} evals, object={len(vb)} evals")
print(f"scbench evals, projected : {len(safe)} withheld {sorted(GRADED)}")
print(f"tolerance types / key shapes : {sorted({t['type'] for t in tol})} / {len(shapes)}")
print(f"recorded runs re-graded : {ok} contradicted: 0")
print(f"naive lower/upper reading : {naive_wrong} of {ok} verdicts wrong")
print(f"data_node resolved, no account : {size / 1e6:.1f} MB, HDF5 magic ok")
print("all assertions passed")
Expect.
Invariants — these hold whatever the maintainers republish, and a failure means this page is wrong rather than stale:
- Both manifest shapes parse. A list keyed
eval_idand an object keyedpathare both live in this family; handling one is handling half of it. - The projection removes every graded field, and a
canaryis present on all six scBench evals before it does. typeis the constantabsolutewhile the tolerance entries take two distinct key shapes. That gap is the trap, not a bug to route around.- The declared grader reproduces every recorded verdict, pass/fail and per-field score alike. This is the deterministic-grading claim, tested rather than quoted; if it fires, either the grader changed or this page's reading of it was wrong.
- The naive reading of
lower/upperstill contradicts at least one recorded verdict, so the asymmetric-offset semantics are load-bearing and not a stylistic preference. - A shared handle resolves for an anonymous caller and yields an HDF5 container.
Observed 2026-08-28 at the commits named in verified — these move when upstream republishes,
so a mismatch is drift to investigate:
manifest shapes parsed : list=6 evals, object=8 evals
scbench evals, projected : 6 withheld ['canary', 'grader', 'graders']
tolerance types / key shapes : ['absolute'] / 2
recorded runs re-graded : 48 contradicted: 0
naive lower/upper reading : 5 of 48 verdicts wrong
data_node resolved, no account : 80.7 MB, HDF5 magic ok
all assertions passed
Sources
latchbio/scbenchandlatchbio/spatialbench— Apache-2.0, copyright Latch Bio.latchbio/epibench,latchbio/variantbench,latchbio/scbench-longandlatchbio/spatialbench-long— a modified Apache-2.0 naming no copyright holder, which is why GitHub reports no assertion.- Grader implementations live in
latchbio/latch-eval-tools, whose LICENSE reads© LatchBio LLC. All rights reserved.Nothing here uses or depends on it; the rule each grader applies is published in the eval file under the benchmark's own licence. - The client that normally resolves a storage handle is
latchbio/latch, MIT, on PyPI aslatch. The blocks above call the same endpoint directly so nothing has to be installed to fetch data. - None of these repositories carries a DOI, a tag or a release. Cite the commit.
Snapshots derive from published studies. The benchmark licences cover the benchmark's own contribution — prompts, graders, trajectories — and say nothing about the underlying data, whose provenance the eval files do not record. Fetch from upstream, and establish terms at the source publication before redistributing anything you pull out of a snapshot.