DatasetSource ──┬─> session-level metrics ──> MetricResult (per session)
│ │
└─> dandiset-level metrics ─────────┤
├─> gate evaluation (per result)
├─> aggregate: session → subject → dandiset
└─> dimension roll-up (weighted)
│
└─> Passport → JSON
Four layers, each replaceable independently:
| Layer | Module | Replaceable by |
|---|---|---|
| Ingestion / inspection | sources/ |
any backend satisfying the protocols in sources/base.py |
| QC evidence | qc.py |
declare a QCFamily; three metrics follow |
| Metric engine | metrics/, registry.py |
add or version metrics without touching the runner |
| Aggregation | aggregate.py |
new rules registered in AGGREGATORS |
| Presentation | report.py, cli.py |
UI/API consuming the JSON contract |
Metrics never import pynwb, h5py, or dandi. They talk to SessionSource
and DatasetSource. This buys:
- Testability without data. The full suite runs in under a second with no network. Regression cases for pathological Dandisets are written as JSON describing the shape of the problem, not by pinning a 40 GB asset.
- Backend swappability. Local file, streamed asset, LINDI index, or a cached metadata snapshot are all the same to a metric.
- Cost control. The backend decides what is expensive. Combined with the assess-don't-compute boundary below, this means the whole-archive pass reads metadata and tables only — there is no sample-level pass to schedule.
Gates are evaluated against individual results, never against aggregates. Averaging a gate away is precisely the failure mode gates exist to prevent: a Dandiset with one unreadable file out of forty should show a gate on that file, not a slightly-lower structure score.
Gate codes are stable strings (GATE_SYNC_UNDOCUMENTED) so the UI, the
depositor dashboard, and support documentation can key off them.
Current gates:
| Code | Metric | Trips when |
|---|---|---|
GATE_MINIMAL_METADATA |
metadata.required_completeness |
score ≤ 1 |
GATE_VALIDATION_FAILED |
structure.nwb_validation |
critical validator issues |
GATE_UNREADABLE |
structure.readability |
asset cannot be opened |
GATE_TIMESTAMP_BROKEN |
integrity.timestamp_continuity |
non-monotonic time |
GATE_SYNC_UNDOCUMENTED |
integrity.cross_stream_sync |
multi-stream, no sync evidence |
Note what is not gated: documentation, provenance, and raw-signal quality. Documentation gaps are cheap to fix, and gating them would penalise exactly the depositors most likely to fix them. Raw-signal quality is the most gameable class of metric in the framework — a depositor who drops bad sessions improves their distribution — so it informs rather than gates.
dqp/qc.py is the module to read before touching a metric pack.
DQP computes structural/integrity properties (timestamps, shapes, schema). It
does not compute domain QC (isolation metrics, motion residual, pose
confidence). A test (test_dqp_computes_no_domain_qc) enforces this by
rejecting any import of SpikeInterface, suite2p, CaImAn, DeepLabCut, or SLEAP
anywhere in the package.
Three reasons this boundary is load-bearing:
- Correctness. Domain tools compute these better and are versioned by people who own the methods.
- Cost. DQP never reads sample data, so there is no expensive pass and no scheduling problem. This is the single biggest simplification in the design.
- Gaming. DQP cannot be gamed through DQP's own computation choices, because it makes none. It reports what the deposit claims, who computed it, and how the values compare to convention.
Every family of derived output gets assessed three ways, and the three land in different dimensions because they are different failures with different fixes:
| Question | Helper | Dimension | Failure it catches |
|---|---|---|---|
| Coverage | assess_coverage |
processed | QC was never computed |
| Provenance | assess_qc_provenance |
provenance | QC exists but isn't comparable to anyone else's |
| Standing | assess_standing |
processed | QC exists and the values are poor |
QC provenance is not pipeline provenance. Kilosort produced the units;
SpikeInterface measured them; sorter_provenance and unit_qc_provenance are
separate metrics for that reason.
A QCFamily is declared once with its conventional thresholds and their
published sources, then drives all three metrics. Adding a modality means
declaring families, not writing scoring logic.
Thresholds carry their sources (Hill et al. for ISI violations, the Allen
Institute pipeline for amplitude cutoff, suite2p's iscell for accepted
fraction) so a reader disagrees with the convention explicitly rather than
silently. assess_standing reports the thresholds it used in its evidence dict.
_pass_fraction accepts three shapes, because real deposits vary: per-object
arrays ({"values": [...]}), precomputed summaries
({"n_pass": k, "n_total": n}), and bare presence (column exists, values
unavailable → NOT_ASSESSED, not a low score). Extend this function when new
shapes turn up rather than special-casing inside a pack.
When a family is absent, remediation_gaps emits a work item naming the
pathway (automated / depositor / needs_raw) and the conventional tools.
The runner aggregates these into the passport's remediation queue.
Worth being precise about, because the naive framing ("depositors will optimise for the metric") is only half right. Two classes:
- Optimisation = improvement. README, software versions, sync description, anomaly notes, linked protocols, parameter capture. A depositor who games these has improved the dataset. This is most of the framework and needs no defence.
- Optimisation = misrepresentation. Raw-signal statistics, unit counts, dropped-frame rates — all improvable by excluding sessions. These are descriptive, ungated, and reported with spread, so silent exclusion shows up as a suspiciously narrow distribution rather than a high score.
Every score is reported with a coverage figure. A dimension score of 3.5 from
two of eleven metrics is a different object from 3.5 from all eleven, and the
consumer must be able to tell them apart. Coverage counts NOT_ASSESSED and
ERROR against the denominator; NOT_APPLICABLE is excluded entirely.
SCHEMA_VERSIONinmodel.py— the JSON contract. Additive changes fine; removals and renames require a bump.- Per-metric
version— bump on any change to scoring behaviour. Caches key on it, so a bump means recomputation for that metric only. - Metric packs are versioned as a unit by the module they live in and should be releasable independently of the core.
These are policy, not code, and the code deliberately does not decide them:
- Rollout order. Compute silently over the archive first, then expose to depositors only, then publicly? Launching with most of the archive showing low scores is a political event before it is a technical one, and the ordering is the highest-stakes decision in the project.
- Legacy datasets. Assess and mark, or mark
not assessedwholesale below some deposit date? The engine supports both; nothing has been chosen. - Headline labels. The engine emits none by design. If they are added,
non-ordinal descriptors (
provenance-complete,sync-verified,benchmark-ready) survive the "not a prestige score" commitment; medal tiers do not. - Weights. Currently hand-set (2.0 for required metadata, validation, readability, timestamps, sync; 1.5 for the provenance core; 0.5 for environment capture and community signal). These are placeholders. The principled version calibrates them against observed reuse outcomes — see below.
- Recompute cadence. On version change? On new asset? Nightly? Interacts
directly with cache size. Results key on
(asset_id, checksum, metric_version), so a metric edit recomputes that metric only.
The framing is that quality is the probability that an external user can
successfully reuse the dataset. Nothing in v0.1 is actually probabilistic —
it's a hand-weighted ordinal scheme. But the framing is testable, and the
usability.reuse_reports metric is the seed: once reuse outcomes are being
collected (successful loads, issue reports, starter notebooks that break,
reanalysis-grade citations), the weights stop being a committee decision and
become a fitted quantity. Keep the outcome data collectable from day one even
if nothing consumes it for a year — it's the difference between a taxonomy
people argue about and a predictor that can be checked.