# LinkML — Deep 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:

```yaml
      CELSIUS:
        meaning: qudt:DEG_C
```

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

```turtle
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`.

| LinkML | IR | Notes |
|---|---|---|
| `id` / `name` | schema id + label | the wire `id` URI is preserved for exact re-emit |
| `prefixes` (both forms) | Extras, one key per prefix | `default_prefix`, `default_range`, `imports` preserved too |
| class | Type | `description` and `abstract` ride in Extras |
| `is_a` (resolved) | parent-type link | unresolved: name kept verbatim, plus lossiness |
| `mixins` | Extras (JSON array) | preserved verbatim; slots are **not** merged in |
| `class_uri` / `slot_uri` / `meaning` | `mapsTo` + verbatim Extras | CURIEs expand on decode, reverse on encode |
| attribute, or a slot a class references | Element on the Type | element id composes as class id + slot name |
| unreferenced top-level slot | standalone Element | re-exported under `slots:` |
| `range:` naming a class | type reference | forward references are fine - ids are reserved first |
| `range:` naming an enum | taxonomy reference | |
| `range:` naming a built-in | primitive | see below |
| `required` / `multivalued` | Required / collection cardinality | |
| `identifier`, `pattern`, `minimum_value`, `maximum_value` | Extras | preserved as facets, not modeled as constraints |
| enum | Taxonomy | `permissible_values` in map **or** list form |
| permissible value | Taxonomy term | `meaning` lifts into `mapsTo` |
| any unknown key, anywhere | Extras as verbatim JSON | re-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:

```json
{ "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:

| Situation | Kind |
|---|---|
| `is_a` names a class not in this document | SemanticNarrowing - link dropped, name kept |
| `mixins` declared | SemanticNarrowing - names kept, slots not merged |
| a class references top-level `slots:` | SemanticNarrowing - flattened into inline attributes |
| a referenced slot was never declared | SemanticNarrowing - decoded as a default string attribute |
| `slot_usage` refines a slot the class does not reference | SemanticNarrowing - preserved verbatim, not applied |
| unknown range | TypeApproximation - String, verbatim range kept |

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

| IR construct | Kind |
|---|---|
| Components | StructuralDrop - LinkML has no component construct |
| Relation instances | SemanticNarrowing - LinkML carries none free-standing |
| Taxonomy term hierarchy | SemanticNarrowing - `permissible_values` are flat |
| RichText elements | TypeApproximation - degrades to string |
| Nullable elements | ConstraintRelaxation - null and absent are not distinguished |
| Collection min/max bounds | ConstraintRelaxation - `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:

```yaml
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:

```json
{ "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:

```yaml
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:

```yaml
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.
