LinkML logoDeep dive

Inside the LinkML Coder: Every Construct, Every Loss

Start with one line of YAML:

Inside the LinkML Coder: Every Construct, Every Loss

Start with one line of YAML:

      CELSIUS:
        meaning: qudt:DEG_C

Convert that schema to OWL through CoreModels and this comes out the other side:

ex:UnitEnumCELSIUS a ex:UnitEnum, skos:Concept ;
    skos:inScheme ex:UnitEnumScheme ;
    skos:prefLabel "CELSIUS" ;
    skos:exactMatch <http://qudt.org/schema/qudt/DEG_C> .

Nothing in the OWL encoder knows what LinkML is. The CURIE was expanded through the source document's prefixes: block into a full IRI, parked on a format-neutral meaning link, and picked back up by a different coder entirely. That handoff is the whole design; this article is its specification.

The mapping table

The intermediate representation is Types (with optional parent links), Elements (typed fields carrying required and cardinality facts), Taxonomies (controlled vocabularies of terms), plus Components and Relations. Every node has an annotation bag: mapsTo meaning links, an Extras string dictionary namespaced per format, and provenance — nodes decoded from LinkML are stamped with source format linkml.

LinkMLIRNotes
id / nameschema id + labelthe wire id URI is preserved for exact re-emit
prefixes (both forms)Extras, one key per prefixdefault_prefix, default_range, imports preserved too
classTypedescription and abstract ride in Extras
is_a (resolved)parent-type linkunresolved: name kept verbatim, plus lossiness
mixinsExtras (JSON array)preserved verbatim; slots are not merged in
class_uri / slot_uri / meaningmapsTo + verbatim ExtrasCURIEs expand on decode, reverse on encode
attribute, or a slot a class referencesElement on the Typeelement id composes as class id + slot name
unreferenced top-level slotstandalone Elementre-exported under slots:
range: naming a classtype referenceforward references are fine — ids are reserved first
range: naming an enumtaxonomy reference
range: naming a built-inprimitivesee below
required / multivaluedRequired / collection cardinality
identifier, pattern, minimum_value, maximum_valueExtraspreserved as facets, not modeled as constraints
enumTaxonomypermissible_values in map or list form
permissible valueTaxonomy termmeaning lifts into mapsTo
any unknown key, anywhereExtras as verbatim JSONre-emitted in place on export

Ranges, both directions

Decoding, the built-ins collapse onto four primitives: integer → Integer; float, double, decimal → Double; boolean → Boolean; date, datetime, time, date_or_datetime → DateTime. The remaining built-ins — string, uri, uriorcurie, curie, ncname, objectidentifier, nodeidentifier, jsonpointer, jsonpath, sparqlpath — become String.

That collapse is not the whole story: whenever a slot states a range explicitly, the verbatim wire token is kept alongside the primitive. A slot written range: date stores DateTime for downstream engines and date for the wire, and re-exports as range: date — exact, not merely equivalent.

A range naming something that is neither a built-in, nor a class, nor an enum in this document also becomes String with the token preserved, but this one earns a record — the coder cannot know what you meant:

{ "kind": "TypeApproximation",
  "path": "classes.Widget.attributes.weight.range",
  "explanation": "Range 'MassMeasurement' is not a built-in type, class, or enum in this schema; approximated as String with the verbatim range preserved." }

A slot with no range at all inherits the schema's default_range; nothing is stored, so export re-omits the key and the header carries the meaning. Encoding IR that never saw LinkML, the map runs Integer → integer, Double → float, Boolean → boolean, DateTime → datetime; String emits no range at all, because the emitted header always declares default_range: string.

Meaning IRIs and mapsTo

Decoding. Three keys lift into meaning links: class_uri on a class, slot_uri on a slot, meaning on a permissible value. A CURIE expands through the document's prefix map — your declarations layered over a standard seed of linkml, rdf, rdfs, xsd, owl, skos, schema, dcterms, foaf, prov, obo, NCIT, and biolink — and the prefix becomes the link's standard. An absolute IRI (or a urn:) passes through unchanged. A bare name whose prefix is unknown is not guessed into a meaning link; the verbatim Extras entry is its only carrier. Both LinkML prefix forms are accepted: schema: https://schema.org/ and the expanded schema: { prefix_reference: https://schema.org/ }.

Encoding. The verbatim wire value wins when present, so a document that said class_uri: NCIT:C15206 re-emits exactly that text. With no verbatim value — a model annotated inside CoreModels, or one that arrived from another format — the first meaning link's IRI is reversed to a CURIE by longest matching namespace, or emitted absolute if nothing matches. The body is written first and the header second, so the emitted prefixes: block always declares every prefix the body used.

The extras namespace

Everything the structural mapping cannot hold rides in Extras under dotted linkml.* keys, one family per fact: the schema id, each prefix, the default prefix and range, imports, descriptions, verbatim ranges, identifier and abstract flags, unresolved is_a names, mixin lists, the three URI keys, and the constraint facets.

The catch-all is linkml.raw.<key>: any wire key the decoder does not know is converted to JSON, preserved verbatim, and re-emitted in place on export — JSON being valid YAML flow style. This is why a decode never hard-fails on an unfamiliar construct. tree_root: true on a class, an aliases: list on a slot, a whole custom types: section: all of it survives untouched. The re-typing during that preservation is conservative — a scalar becomes a JSON number or boolean only when the round-trip is character-exact.

The lossiness inventory

Decode-side, six situations produce records; each narrates a normalization, and none stops the run:

SituationKind
is_a names a class not in this documentSemanticNarrowing — link dropped, name kept
mixins declaredSemanticNarrowing — names kept, slots not merged
a class references top-level slots:SemanticNarrowing — flattened into inline attributes
a referenced slot was never declaredSemanticNarrowing — decoded as a default string attribute
slot_usage refines a slot the class does not referenceSemanticNarrowing — preserved verbatim, not applied
unknown rangeTypeApproximation — String, verbatim range kept

Encode-side, the coder inventories what LinkML cannot express before writing a byte:

IR constructKind
ComponentsStructuralDrop — LinkML has no component construct
Relation instancesSemanticNarrowing — LinkML carries none free-standing
Taxonomy term hierarchySemanticNarrowing — permissible_values are flat
RichText elementsTypeApproximation — degrades to string
Nullable elementsConstraintRelaxation — null and absent are not distinguished
Collection min/max boundsConstraintRelaxation — multivalued carries no bounds

Slots, slot_usage, and what "flattening" costs

LinkML lets classes share top-level slot definitions and refine them per class; our IR inlines attributes per type. Referenced slots are therefore flattened into the class with refinements applied — base definition first, then the refinement, later keys winning per key. Take this document:

id: https://example.org/edge
name: edge_demo
slots:
  identifier_slot:
    description: Shared identifier.
    identifier: true
  legacy_note:
    description: Referenced by no class.
classes:
  Base:
    abstract: true
  Widget:
    is_a: ExternalThing
    mixins:
      - Trackable
    slots:
      - identifier_slot
    slot_usage:
      identifier_slot:
        required: true
    attributes:
      weight:
        range: MassMeasurement
  Gadget:
    is_a: Base
    slot_usage:
      identifier_slot:
        pattern: "^G-"

It decodes successfully with five records — the unresolved is_a, the mixins, the unknown range, the slot flattening, and one more that matters:

{ "kind": "SemanticNarrowing",
  "path": "classes.Gadget.slot_usage.identifier_slot",
  "explanation": "slot_usage entry 'identifier_slot' refines a slot the class does not reference directly (likely inherited); the refinement was preserved verbatim but not applied." }

slot_usage is read even without a slots: list, because its primary use in LinkML is refining inherited slots. The flattening cannot apply such a refinement — but dropping it would be a lie, so it is preserved and re-emitted:

classes:
  Base:
    abstract: true

  Widget:
    is_a: ExternalThing
    mixins:
      - Trackable
    attributes:
      weight:
        range: MassMeasurement
      identifier_slot:
        description: Shared identifier.
        identifier: true
        required: true

  Gadget:
    is_a: Base
    slot_usage: {"identifier_slot":{"pattern":"^G-"}}

slots:
  legacy_note:
    description: Referenced by no class.

Everything is accounted for: the parent name as text, the mixin list as a list, the flattened slot with its applied refinement, the unapplied refinement in flow style, and the unreferenced slot back at top level.

Edge cases the tests pin down

Label collisions. Two Types may share a label; YAML keys may not. Emitted names are computed up front — label, falling back to id on collision — and every range: and is_a: names the key its target is actually emitted under. Without that, a re-decode would silently rebind a reference to the wrong class.

Hostile labels. An element labeled a: b\nc must not corrupt the document. Keys and values are quoted and escaped for structural and control characters, with newline injection covered by regression tests. In a value, a colon forces quoting only when followed by whitespace, so bare CURIEs like schema:Person stay textually pristine — while a generated taxonomy key comes back correctly quoted:

enums:
  "Customer::status::enum":
    permissible_values:
      draft:
      active:
      closed:

Identifier sanitization. CoreModels node ids must be alphanumeric, so clinical_study yields the id clinicalStudy while the label keeps the original spelling, and element ids compose as class id plus capitalized slot name (SensorSensorId). Ids are unique across Types, Elements, and Taxonomies, because relation endpoints resolve by bare id. One consequence: a permissible value written IN_STOCK keeps that label on a LinkML round trip, but formats whose enums key on the id — SQL and JSON Schema among them — emit the sanitized INSTOCK.

Minted headers. IR that never saw LinkML still exports a complete document: an id minted under https://coremodels.example.com/ns/, the linkml:types import, default_range: string — and it re-decodes cleanly.

Both permissible_values forms. The map form and the rarer list form (permissible_values: [red, green, blue]) both decode; export normalizes to the map form.

Real failures. Four inputs genuinely fail, each with its own message: an empty document, invalid YAML, a document that is not a mapping at the top level, and a mapping carrying none of id, name, prefixes, classes, slots, or enums.

Round trip. Decode → encode → decode preserves the structural signature — every type, parent link, element label, value type, required flag, cardinality, and taxonomy term — along with the schema id, descriptions, URIs, identifier flags, verbatim ranges, and facets.

Stated limits

Custom types: sections are preserved raw but not modeled, so a slot ranging over a user-defined type approximates as String. Mixins are preserved, never merged. Collection bounds could in principle ride LinkML's cardinality keys; today they are dropped with a recorded relaxation. subsets, annotations, unit, and inlined survive verbatim without being modeled. Each is a deliberate scope line, visible in the output, and none loses your text.

That is the contract: mapped where the IR has a home, carried where it does not, reported where nothing can. For the routes and tools that put this coder to work, see the Schema Transformation API reference in the CoreModels docs.