skills/ontology-term-resolution/references/curation-rules.md
How to choose among candidates, and what to do when the honest answer is "no term".
Run it per string. Stop at the first step that gives a defensible answer.
hepatocyte in a tissue field means the column mixes tissue and cell type. Fix the
column, do not force a match.resolve_terms.py implements steps 1–4's search side and labels every hit exact_label,
exact_synonym, or partial. The judgement about whether a partial is acceptable is yours;
the tool will not make it for you.
Cheap rewrites that convert a partial into an exact_label, in rough order of yield:
liver (donor), Liver - left lobe [FFPE].PBMC → peripheral blood mononuclear cell, WT → the actual genotype,
M/F → male/female.ventricle, left → left ventricle, cortex, kidney → kidney cortex.hepatocytes → hepatocyte. Ontology labels are singular.oesophagus and esophagus.human liver → liver (species belongs in a separate NCBITaxon field).Do not normalise away hyphens, Greek letters, digits, or capitalised gene symbols — CD4-positive
and alpha-beta T cell mean what they say, and normalize_label() deliberately folds only case and
whitespace.
When plain search keeps failing on lab shorthand, try ZOOMA with an ontology filter
(ols4-api.md). It matches against how curators previously mapped that exact string, which is a
different and often better signal than lexical search.
Never invent an ID to fill a cell. An unresolved row carries the original string, an empty ID, and
the reason. Downstream that is a visible gap; an invented UBERON:0002108 is a silent error that
survives review because it looks exactly like a real ID.
If a concept genuinely has no term and the project depends on it, the route is a new-term request to the ontology (GitHub issue on the ontology's tracker, with a definition and a reference), not a locally minted identifier.
The high-yield checks, in order:
validate_terms.py --input table.tsv.term_replaced_by often enough that the fix is
mechanical — but apply replacements deliberately, since a replacement can be broader or
narrower than the original.--expect-ontology.--branch, remembering it does not exclude cell types from
anatomy (ontology-registry.md).--strict turns warnings into failures, which is the right setting for a CI gate. Warnings are
matched_synonym (label is a synonym rather than the primary label), imported_only (the home
ontology no longer asserts this ID), and not_a_class.
Obsoletion is not deletion — the ID keeps resolving, and its label is usually prefixed
obsolete_. That prefix is a useful smell in any metadata file:
EFO:0001067 obsolete_parasitic infection -> replaced by MONDO:0005135
Some obsolete terms have no replacement, only a consider annotation or nothing at all. Then the
term must be re-curated by hand; there is no automatic answer.
OxO is retired and returns HTML with HTTP 200. Two workable routes:
term_detail(curie)["annotation"]["database_cross_reference"] lists
equivalents — UBERON:0002107 carries MESH:D008099, NCIT:C12392, FMA:7197, UMLS:C0023884,
and more.skos:exactMatch vs closeMatch) matter.Cross-references are asserted by curators at varying confidence and are not all exactMatch.
Treat a single xref as a lead, not a proof, when the mapping drives analysis rather than display.
from ols_client import term_detail
xrefs = (term_detail("UBERON:0002107") or {}).get("annotation", {}).get(
"database_cross_reference", []
)
When you hand back resolved terms, give the ID and the label, and say how each was matched. A
table of bare IDs cannot be reviewed — no reader can tell UBERON:0002107 from UBERON:0002108
by eye, which is precisely why invented IDs survive review.