Adding semantics to a croissant
A RoboCroissant file says which files a knowledge base has and what columns they contain. This skill adds what the data means: which columns together describe a gene or a disease, what a row asserts, which identifier prefix a column's ids use, what codes stand for. It writes that into a new croissant using Croissant 1.1 constructs, and records why each statement was made.
You produce a plan (JSON, by layout), the scripts apply and check it. You don't edit the croissant by hand.
Inputs and outputs
- Input: a croissant (
croissant.jsonorcroissant.json.gz) and an output directory, plus the maintainers'semantics_answers.jsonnext to the croissant, if there is one. - Output, in the output directory:
semantics_plan.json— the plan (format:reference/plan-format.md)croissant.semantic.json(or.json.gzif the input was gzipped) — the resultcheck_report.md— output ofcheck.py(andcheck_report.load_errors.txt, full mlcroissant errors, when records failed to load)notes.md— decisions, layouts left out and why, and questions for the knowledge base's maintainers
The input croissant is never modified. Put downloads, helper scripts and other scratch work
in OUT/work/, not elsewhere.
Scripts
All in scripts/, run directly (they're uv run --script files and install their own
dependencies). Use --help on any of them.
| Script | Use |
|---|---|
inventory.py CROISSANT [--layout ID] [--json F] [--common-columns] | List layouts (distinct column sets), largest first; describe one layout's columns, record sets and files; list the columns most record sets share |
sample.py CROISSANT --layout ID | Read real rows from three of the layout's files; shows each column's example values and their shape (CURIEs and their prefixes, bare ids, lists, codes) |
dialects.py list/show/detect/read | Known file formats (VCF, GAF, MITAB, CTD, ...) as computable dialects; which one reads a layout's files; read records using only what a croissant says about the file |
vocab.py class/slot/search/associations/prefix/identify/term/ols/infores | Look up Biolink classes and slots (with near-miss suggestions), association classes for a subject/object pair, identifier prefixes (Biolink + Bioregistry), which prefixes fit bare ids, ontology terms by id or text, and infores ids for source names |
apply_plan.py CROISSANT PLAN OUT | Write the plan into a new croissant |
check.py CROISSANT PLAN OUT --report R --load-records | --plan-only first: terms, prefixes, dialects and units in seconds. Then the full check: checks terms, prefixes, codes and dialects against Biolink and sampled data, runs the MLCommons validator, reads a few records through mlcroissant (from the first lines of each file, not whole downloads), and reports coverage |
Workflow
-
Survey. If there's a
semantics_answers.jsonnext to the croissant, read it first: it holds the knowledge base's maintainers' answers to earlier questions (see "Maintainer answers" below). Don't ask those questions again;apply_plan.pyandcheck.pyapply the answers to your plan automatically. Theninventory.py CROISSANT. Note the knowledge base (name, description, url in the croissant) and how record sets spread over layouts. Large knowledge bases have hundreds of layouts; work from the top of the list.Find the knowledge base's own infores id now (
vocab.py infores NAME; try its full name, its acronym and its url) and set it as the plan'sinfores. It names the resource whose files these are, and graph builders cite it as the source of everything read from them: without it, the transformation stage won't build the knowledge base at all. When several entries match, pick the one for the resource that publishes these files, not a service that re-serves it (infores:reactome, notinfores:automat-reactome).check.pycompares it with what the rest of your plan says: a single aggregator or provided_by constant names the knowledge base too (HCOP's orthologs are aggregated byinfores:hgnc), and a mismatch is a warning to resolve.When none does, propose one:
infores:and the knowledge base's short name in lowercase words joined by dashes (infores:bioplex,infores:expression-atlas), with"infores_proposed": truein the plan. Then write its catalog entry withvocab.py infores-entry CROISSANT --id infores:NAME, adding--knowledge-leveland--agent-typewhen the documentation makes them clear, and put it innotes.mdunder a heading Infores id to register, near the top. Say there that the user should register it with a pull request to the infores registry (the command prints where), and that the id may change in review.check.pykeeps a warning on a proposed id until it's registered; don't explain it away. -
Pick the layouts to do. Aim for the layouts that cover most record sets and the core content (associations and the main entity tables) — typically the top 5–15, or until the remaining layouts are minor (readme-like files, logs, per-release counts). If there are many small layouts (
inventory.pywill say so), the record sets probably share core columns: useinventory.py --common-columnsand plan those columns once withmatch.has_columns. Everything you leave out goes in the plan'sskippedlist with a reason. Doing fewer layouts well is better than many badly. -
For each layout, look at the data before deciding anything.
inventory.py CROISSANT --layout IDfor columns, descriptions and file names. A column markedMIXEDhas different atomic types in different record sets (the pipeline infers them per file); decide its type from the data and setatomic.- For tables,
dialects.py detect CROISSANT --layout IDfinds how the files are written (comment lines, header or none, delimiter) among known formats. Well-known formats (VCF, GAF, MITAB) have published specs; rely on them rather than rediscovering the format. If nothing fits, describe the dialect inline (reference/dialects.md). Then sample with--dialect NAMEso rows are read the way the format says, not guessed. sample.py CROISSANT --layout IDfor real values. It reads three files of the layout (the newest-looking by name, the largest, the smallest), because one file — often an old snapshot or a filtered subset — can hide values (N/Aplaceholders, extra code values, other id types).--files Nsamples more of them;--record-set IDsamples one particular file. If sampling fails (a huge file, an archive, a file set), say so in the plan'sbasisand lower confidence.- For code columns (types, evidence, relations, sources), a sample misses rare values.
sample.py CROISSANT --record-set ID --profile COL1,COL2reads the whole file and counts every distinct value; repeat--record-setto add several files up. Map from the profile, not the sample. - Be gentle with the knowledge base's servers. Run one sampling or check command at a time
per knowledge base, never many in parallel: hosts block bursts with a bot challenge, and then
every later request fails. The scripts pace their own requests and cache downloads (in
~/.cache/croissant-semantics/downloads;--refreshdownloads again), so re-running is cheap (a copy cached under a larger--max-mbserves a smaller one). If a script reports a bot challenge or an HTTP 403, stop sampling that host, don't try to get past it, and say innotes.mdwhich checks couldn't run. The scripts then refuse that host for an hour, across runs, and work from cached files. - Spreadsheets (xlsx and legacy xls), SQLite databases and parquet files are sampled and profiled too: the
sheet or table is the one the record set's name ends with ("file.xlsx — Sheet1",
"file.xlsx: Sheet1"), and sampling fails rather than guess when it can't tell. They're read
whole, so raise
--max-mbif needed (oncheck.pyas well). mlcroissant can't read spreadsheets or databases, so their records aren't loaded. - XML, RDF (Turtle, RDF/XML, OWL), FASTA and other files that aren't tables can't be sampled:
the scripts say so. If a layout matters, check its ids with a small script in
OUT/work/(uv run --with rdflibfor RDF; stream XML withxml.etree.ElementTree.iterparse), and say how innotes.md. Their fields usually have noextract, so no tool can read them: a generator problem to report. --profilewith--layoutprofiles--filesrecord sets of the layout (default 3, orall) instead of naming each with--record-set.--prefixescounts each value's prefix or tag, for all profiled columns or only those listed (--prefixes COL1,COL2);--by COLcross-tabulates against another column (which object types each code occurs with);--split SEPcounts the items of list cells;--per-fileprofiles each record set on its own (to check ids are unique per file);--stop-mb Nstops each file after N MB, for files of many GB (the counts then cover only the start; in a file sorted by species or id that may be one group only, so say so).- Profile id columns of the main layouts (
--profile COL --shapes) before trusting them: the data check reads the first rows of a few files, so a column whose later rows lack ids, or hold another id family, passes it. Several knowledge bases had 20–30% of rows without an id after hundreds of rows that all had one. - A
match.has_columnsentry that covers many layouts is sampled bycheck.pyon only a few of its files. Sample the layouts it spans that differ (--record-set-name,--files) before trusting it. - The scripts use the operating system's certificate store. If downloads still fail with a certificate error, report it with the output; don't turn verification off.
- If the documentation is behind a bot check ("verify you are human", a Cloudflare or ALTCHA
challenge), don't try to get past it; plan from data and names, and cap confidence at
mediumfor meanings only the documentation could confirm. The croissant's provenance may cite pages that have since been put behind a check: cite only what you could read. - In a huge file the first rows can be unrepresentative (CTD's 3 GB
genes_diseasesstarts with thousands of inferred rows). Profile a smaller file of the same layout (the curated subset) rather than streaming the whole thing. - To pick record sets by name, use
--record-set-name REGEX(instead of--layout) rather than building a list of--record-setarguments in the shell (zsh doesn't split variables into words).--per-fileshows each sampled file separately, for layouts whose files differ (one id kind per file). - The data check reads the first rows of a few files. A column's later rows can differ (blank
nodes after 200 IRIs, a rare code): confirm id forms and codes with
--profile, which reads whole files. inventory.pylists documentation files shipped with the data (README.pdf inside a zip, a data dictionary) after the layouts. Read the relevant ones:sample.py CROISSANT --dump FILEprints a file as text,--member NAMEa member of an archive (a zip is read whole, so raise--max-mbfor a big one) (it lists the members of a zip when the name is wrong),--sheet NAMEa sheet of a spreadsheet.sample.pyflags columns whose declared type doesn't fit the values (ansc:Integercolumn holding27152464|29518214) and files whose rows are wider or narrower than the header. Both are usually pipeline problems: fix types withatomic, and report them.- When the scripts can't read a source, fetch the files yourself (one at a time, into
OUT/work/) and point the scripts at them with--url-map OLD=file:///abs/path/so their checks still run, rather than writing your own profilers. Downloads are cached, and~/.cache/croissant-semantics/downloads/index.tsvmaps each cached file to its URL. - If a layout's "column names" are really data (no header row, so the pipeline took the
first data row as names, and every file became its own layout), skip it and report it in
notes.mdas a pipeline problem. - If the data URLs are gone (HTTP 404), the knowledge base has probably moved on to a newer
release. Find the current URLs from its download page and pass
--url-map OLD=NEWtosample.pyandcheck.py(e.g.--url-map 202607=202610). When more than the release changed (another path or file name), map whole URLs, one--url-map OLD_URL=NEW_URLper file. When the URLs themselves contain=(query strings), writeOLD=>NEW. Say so innotes.md; it's a pipeline problem too. A SPARQL endpoint URL that returns JSON where the croissant describes a table usually needs&format=csv(ortext/csv) mapped on. - Archives: members of zip and tar archives are sampled. A zip is downloaded whole (up to
--max-mb); a tar is streamed until the member is found, so a member deep inside a huge tar can exceed--max-mb. An archive that fails once isn't tried again, and sampling stops after repeated failures. If you must, extract a member intoOUT/work/yourself, and record what you checked and how innotes.md. - If the data needs a login or API key (HTTP 401/403, or the croissant's
conditionsOfAccesssays so), don't look for credentials. Plan from the documentation and column names only:sourceisdocumentationoragent,confidenceat mostmedium, andnotes.mdsays that nothing was checked against data. - Look for a shipped data dictionary first: a "column definitions" or README sheet in a
spreadsheet, a README next to the files, the template row of a ROBOT template, a data
dictionary on the download page. It's the best source; cite it with
source: data_dictionary. - Read the knowledge base's documentation when column meanings aren't clear from names and
values (the croissant's
url, the file descriptions, a README or data dictionary). Follow links from the downloads page rather than guessing URLs (a site whose pages are#routes, likedocs.example.org/#Section, usually also has the real page paths; use those). Cite what you used. Help pages that render in the browser (a fetch returns an empty shell) often come from a public repository: look for it (gh search repos), and read the docs there. - Check that the croissant describes the source's main data.
inventory.pylists the files no record set reads. When the source's own download page names main files (STRING's network files, UniProt's Swiss-Prot) and they're among those, or missing altogether, the plan can't add them: say innotes.md, near the top, which main files the croissant leaves out (a generator problem), and plan what's there. - Check whether record sets in the layout differ in meaning (species, ontology aspect,
direction). If so, split with
match.record_set_name.
-
Decide the semantics, in this order (
reference/vocabulary.mdhas the details):- Entities: which columns describe one thing (an id with its name, symbol, taxon)? Group
them; give the group a class; mark its id column
biolink:idwith its prefix and form. The ids must come out as CURIEs with a Biolink prefix, or NodeNorm can't resolve them: not the source's own tag (KEGG'sko:,hsa:) and notnativewhen a registered namespace fits (reference/plan-format.md). - Row: if a row relates two entities, type it as the most specific fitting Biolink
association, give the entities their roles (
biolink:subject,biolink:object), and add the predicate (constant, or a code column mapped withcodes); without one the layout builds no edges. Check the association's slot usage (vocab.py class X): its subject and object ranges must fit your entities' classes. Codes that negate or infer (is_not_…,…_via_orthology) need marking, not the direct predicate alone. If a row is a thing (a variant, a gene, with a unique id per row), type the row with that thing's class, map its own columns directly, and link the other things it mentions as entities with roles likebiolink:has_gene. Many layouts sharing columns? Define specs once incolumn_library. - How the statements were made: every layout that makes edges gets
biolink:knowledge_levelandbiolink:agent_type(a downstream graph saysnot_providedotherwise, and the explanation of each edge is lost). Start from the infores catalog's values for the edges' primary knowledge source (vocab.py infores NAMEshows both; the knowledge base's own entry when its rows don't name another source), as layout constants, or as a derived field coded per source when a column names the source. What the layout says always stands over the catalog: the catalog describes a resource's usual statements, so HGNC'smanual_agentdoesn't fit the orthology predictions it collects.check.pysuggests the catalog's values where the layout has none, and flags where the layout's differ, for you to confirm against the documentation (and mention innotes.md). A value that isn't a Biolink one (curated, the catalog'smixed) is written as the closest Biolink value, with a warning; put that value in the plan. - Dialect: for tables that aren't plain CSV/TSV with a header row, the layout's
dialect. When the croissant labels a file with a type mlcroissant can't read (text/plainfor a TSV, gzip alone,application/parquet), the layout'sencoding_formatcorrects it. - Other columns: publications, evidence, scores, p-values, units, list separators, code tables. Leave columns you can't support untyped.
- Vocabulary: Biolink if it truly fits → another established vocabulary → a local
rcv:term linked with SKOS → nothing. Never force a vague type. - Every row, entity and column you type gets
basis,sourceandconfidence.
- Entities: which columns describe one thing (an id with its name, symbol, taxon)? Group
them; give the group a class; mark its id column
-
Write the plan to
OUT/semantics_plan.json. -
Apply and check.
scripts/apply_plan.py CROISSANT OUT/semantics_plan.json OUT/croissant.semantic.json scripts/check.py CROISSANT OUT/semantics_plan.json OUT/croissant.semantic.json --report OUT/check_report.md --plan-only scripts/check.py CROISSANT OUT/semantics_plan.json OUT/croissant.semantic.json --report OUT/check_report.md --load-recordsWhile iterating on a few layouts,
--layouts id1,id2 --no-validatere-checks just those (--skip-layouts id3leaves some out); run the full check at the end. It prints progress to stderr, and if interrupted still writes the report so far. Validating a large croissant (hundreds of thousands of fields) takes several minutes, sometimes over an hour (--validate-timeoutis in seconds, default 3600; a timeout is reported as unverified, not as a failure; a croissant identical to one already validated reuses the result). Run the full check as a background task and wait for its completion notification (run it with your harness's background option, redirecting stderr toOUT/work/check.log). Don't poll:sleepis blocked,timeoutisn't on macOS, apgrep -f check.pyloop matches its own command line, and the report is only written at the end. The last line on stderr is a summary,[check] done: N errors, ...(with the prefix).check_report.mdfrom an earlier--plan-onlyrun stays on disk until the full check finishes; don't read it as the result.Record loading tests one record set per plan entry and set of columns, at most
--load-per-entry(default 3) per entry; a load that times out is retried once, then reported as unverified. Croissants whose columns are namedcolumn_1,column_2, … (headerless files, or headers the generator ignored) always fail to load in mlcroissant, from the input croissant too, because it ignores dialects: the report says so, and the sampled-data checks are the evidence.Fix every error (wrong prefix, values that don't fit the declared form, unknown Biolink term, validator failure) by changing the plan, then re-run both. Read every warning and either fix it or explain why it stands, in the plan's
explained(copy thewhereandmessagefromcheck_report.findings.json) and innotes.md.apply_plan.pyalso warns about plan keys it doesn't know.The validator and each record load run with a memory cap (
--memory-gb, default 4), since agents run in parallel. A load stopped by the cap is a warning; the usual cause is a croissant that reads a table as severalextract: {fileProperty: "lines"}fields with regex transforms: mlcroissant combines those per line pair, so its memory grows with the square of the lines (2,000 lines take 1 GB). That's a generator problem to report; the sampled-data checks are the evidence for those layouts.For a croissant too large for the validator to finish (thousands of record sets; it can take many hours),
--validate-reducedvalidates one record set per layout and row type, with the record sets and files they reference; the report says it was reduced.Records that don't load from the original croissant either are pre-existing problems (formats mlcroissant can't read, commented headers, dead URLs, wrong checksums); mention them in
notes.md. Fix them only when the plan can describe the file correctly (adialect, anatomictype for a column the input croissant mistypes), and then list the fix innotes.mdas a problem for the pipeline. For layouts that still don't load, the sampled-data checks are the evidence. Evidence you gathered outside the scripts (a member you extracted by hand, units you looked up) goes innotes.mdwith how you got it.Some problems a plan can't fix: a column the croissant declares but the files don't have (the plan can only describe columns that exist), and
null_values, which mlcroissant ignores, so a numeric column holding-orNAstill fails to load; type such a columnatomic: sc:Textand report the type. Files that are deliberately invalid (test fixtures, examples of bad input) go inskipped, not in the plan. -
Write
notes.md: what you covered (layouts, record sets, coverage numbers from the report), what you left out and why, choices a reviewer should know about (local terms, low-confidence types, prefixes Biolink doesn't list for a class), and specific questions for the maintainers.
Maintainer answers
Answers from a knowledge base's maintainers live in semantics_answers.json next to its
croissant (or wherever --answers points), not in a plan, so they survive every rerun:
{"kb": "robokop", "answers": [
{"question": "What does `slope` mean on GTEx edges?",
"answer": "The slope of the linear fit of allele count against expression.",
"by": "Jane Doe (maintainer)", "date": "2026-10-20",
"applies_to": {"column": "slope"},
"spec": {"property": "biolink:effect_size", "type": "STATO:0000656"},
"prefixes": {"STATO": "http://purl.obolibrary.org/obo/STATO_"}}
]}
applies_to.column(and optionally.layout, a plan layout id) says where it applies. Thespeckeys are column-spec keys (reference/plan-format.md). They override the plan's, and the column getssource: maintainer,confidence: highand the answer as itsbasis. A column the plan doesn't mention is added (optional) to every layout whose record sets have it."skip": "reason"instead ofspec: the column is left untyped and listed inskipped(a maintainer says the meaning isn't known).prefixesandlocal_termadd what the answer's terms need.- An answer without
applies_tois just recorded. check.pyreports what each answer did. An answer that matches no column is a warning.
When someone answers a question from notes.md, add it to this file (you may write there,
though not to the croissant). Don't edit the answer into the plan.
Rules
- Base every statement on the data or the knowledge base's documentation. Column names alone
justify at most
mediumconfidence; a guess islowand goes in the maintainer questions. - Don't rewrite identifiers or data. Prefix annotations describe values, they don't change them.
- Don't invent codes, prefixes or terms. Check Biolink terms with
vocab.py; check prefixes withvocab.py prefix. - Keep the input croissant untouched and write only in the output directory, except for
recording maintainer answers in
semantics_answers.json. - If a script fails in a way the plan can't fix, stop and report the failure with its output rather than working around it.
Reference
reference/plan-format.md— the plan, field by field, with examplesreference/vocabulary.md— choosing terms, Biolink pointers, identifiers, common mistakesreference/dialects.md— describing how to read a file (CSVW dialects, format specs), and what readers do with itreference/croissant-constructs.md— what each plan entry becomes in Croissant, our conventions, and mlcroissant details the scripts handle