The metadata layer¶
Curation that scales, and data that can be searched. A reprocessing corpus
of 104,442 rows cannot be curated by hand, and a tissue recorded as
Brain, prefrontal cortex cannot be searched for. This layer reads what the
four archives already state about each sample, normalises the keys and values,
and grounds each value in the ontology its field declares.
fetch metadata GSE111360 # one row per sample, ontology CURIEs
fetch metadata PRJNA434002 --explain # every claim, its source and its evidence
fetch metadata PRJNA434002 --review # what a human should settle
fetch metadata GSE111360 --agreement # how often the archives differ
fetch metadata GSE111360 --keys # attribute keys no rule maps yet
sample organism tissue sex
SAMN12039425 Mus musculus [NCBITaxon:10090] suprachiasmatic nucleus [UBERON:0002034] -
Four tiers, kept separable¶
| tier | method |
what it does | needs a key |
|---|---|---|---|
| 0 harvest | archive |
read BioSample attributes, GEO characteristics, ENA columns, ArrayExpress SDRF | NCBI only |
| 1 normalise | rule |
map the key to a field, the value to a vocabulary | no |
| 2 ground | ontology |
OLS exact match, then ZOOMA, validated | no |
| 3 adjudicate | llm |
(not built) the residual only | yes |
Every claim records which tier produced it. That is the point of building the deterministic tiers first: the model's contribution has to be a figure, and it is unanswerable if a stored value cannot say where it came from.
Tiers 0 to 2 need no LLM key. That is the layer, not a degraded mode.
What it recovers that was being thrown away¶
Four sources were reachable through providers this project already had, and none of them was read. The clearest single gain is structural:
biosample:attribute[isolate] = 5864, in a sample titled5864_BA9.
NCBI's Human BioSample package defines isolate as the individual the sample
came from, so that is a donor identifier -- the rarest and most valuable
structural field there is, named by only 11% of datasets, and recovered here
from a source no previous tool in this project consulted.
The key is context-dependent -- isolate means a strain in a microbial record
-- so it is resolved from the archive's own package declaration and left
unmapped, with the ambiguity spelled out, when nothing says which.
Disease is per sample, and only one archive says so¶
PRJNA434002 is 56 runs of post-mortem brain from an autism study. Its
study_title names Autism Spectrum Disorder for every run, controls
included. ENA's per-sample disease column is empty for all of them.
BioSample records ASD and Control per sample.
Two rules follow, and both are enforced rather than advised:
- A study-scoped value never assigns a sample-scoped field. Study titles,
series summaries and library-construction protocols are held once, in
study_text, and are visible under--explainwithout being attached to anything. - A value shared by every sample is not thereby study-scoped. Twenty
samples all reading
tissue: lungare all lung; the submitter wrote it once per sample. Refusing invariant values would discard correct metadata from every single-tissue study, which is most of them.
Measured on that project (24 samples, 2026-09-16): BioSample and ENA agree 24/24 on tissue and 24/24 on sex; BioSample states a disease for 15 samples and ENA for none.
Grounding asserts only exact matches¶
Three steps, tried in this order, and the order is a finding.
- OLS exact match on the whole value. Label or synonym.
PBMClands onCL:2000001here with no synonym table of this project's own. - OLS exact match on the value's parts, longest first.
- ZOOMA, constrained to the field's ontologies.
Brain, prefrontal cortex has no exact match. Asked, ZOOMA returns
UBERON:0001870 frontal cortex at confidence GOOD, with the correct
UBERON:0000451 ranked second. Splitting the value first and matching
prefrontal cortex exactly gets the right term without an approximation.
Neither of OLS's filters does what its name suggests, and both were measured:
exact=truedoes not return only exact matches.q=male&ontology=patoreturns 40 documents, including male with DSD and male sterile. The first is right, which is a relevance rank and not a measurement, so every hit is re-checked locally against its own label and synonyms.ontology=cldoes not restrict results to CL. The same request returnsGO:0032943, because CL imports GO. The returned CURIE's prefix is checked against the field's declared set rather than assumed from the parameter.
Two things are never asserted¶
Both are recorded, both are returned, and both carry a stated reason for a reviewer. A claim with a reason is still searchable; it is simply never treated as settled.
An abbreviation matched by synonym. disease = ASD on SAMN12011225, in an
autism study. MONDO holds ASD as a synonym of atrial septal defect and of
nothing else, so the lookup returns exactly one hit and looks unambiguous. It is
a different organ system. PBMC to CL:2000001 is the same shape and correct,
and nothing internal to the lookup distinguishes them, so the study's own text
is consulted: where it spells the expansion out, the claim stands.
Any ZOOMA match. Asked about the real ENA value CD45+ cells from a fresh
kidney tumor sample (GSE111360), constrained to CL, ZOOMA returns
CL:0001074 CD34-positive, CD56-positive, CD117-positive common innate
lymphoid precursor. CD45 is a pan-leukocyte marker. It is not a near miss.
A flagged claim renders as the submitter's own string with the CURIE marked:
cell_type
CD45+ cells from a fresh kidney tumor sample [CL:0001074?]
The ? is the whole difference between a candidate and a finding.
What is measured, and what is not¶
Accuracy is not measured. The evaluation corpus this layer was designed
around does not exist, and
the reason is worth reading
before anyone tries to build it again: sampling 120 studies from the 20,693 in
arrayexpress-ena found 119 with mappable curated characteristics and 0 with
an independently registered sample. Both halves of the corpus are circular,
for opposite reasons.
What is measured instead is coverage and consistency:
- Cross-archive agreement, per field, per pair of sources. Consistency, not correctness -- two archives can agree and both be wrong -- and it is exactly what found every pathology in this knowledge base.
- The unmapped-key backlog, ordered by how many samples carry each key. A key on 800 samples earns a synonym entry; of 116 keys observed across 198 datasets, 70 appeared exactly once and are not worth declaring.
- Grounding rate per step, so "tier 2 grounds N% of tissue values" can be split into the deterministic part and the approximate part.
This is the same asymmetry the assay layer states when it reports 90.9% recall and declines to report precision. An unmeasured number is not evidence, and inventing one here would be the behaviour this project exists to avoid.
No source is ranked, and the code says so¶
Every declared source carries no ExtractorEvidence, because nobody has
surveyed them. That has a consequence the code enforces rather than documents:
SampleMetadata.asserted() declines to pick when two sources disagree, and
the disagreement is reported instead.
RunRecord.set() is first-wins, and that is justified there -- routes are
offered in the order RouteRegistry.ranked() computes from measured evidence.
No such measurement exists for metadata sources, so first-wins would be picking
by declaration order and calling it evidence.
fetch metadata GSE111360 --sources # every source, and whether it is measured
Two archive bugs found while building it¶
Both have a reproducer, an affected-accession list and an inverted regression test, per the pathology page contract.
efetch db=biosampleignores the accession prefix. It strips the letters and reads the remainder as a UID.id=SAMEA11294529returnsSAMN11294529-- a rumen bacterium where a human liver sample was asked for -- with HTTP 200 and 14 attributes. This affects every EBI- and DDBJ-registered BioSample, and ENA finds 125,271 BioSamples NCBI's own link table does not.- ZOOMA's ontology filter forces a term. It does not prevent a wrong answer; it selects which one you get.
It is an interface¶
Adding a field is a data change, and
tests/test_metadata_fields.py::TestTheDeclarationIsData asserts it:
Field(name="ancestry", scope=Scope.SAMPLE, summary="...", ontologies=("hancestro",))
Adding a key synonym is a line in vocab.CANONICAL_KEYS. Adding a source
needs a provider and a declaration, and until it is surveyed it sorts behind
everything measured -- which today is everything, equally.