# OWL — Deep dive: What the OWL Coder Knows: The IR Map, the Extras, the Ledger

Ask a schema converter what it does with `owl:Restriction` and you learn most of what you need to know about it. Many treat OWL as a serialization problem - emit classes and properties, drop the axioms - so "required" and "single-valued" evaporate at the border. CoreModels treats it as a semantics problem: required and cardinality ride as `owl:Restriction` subclass axioms, OWL's own carrier, in **both** directions. That decision propagates through everything below.

# What the OWL Coder Knows: The IR Map, the Extras, the Ledger

Ask a schema converter what it does with `owl:Restriction` and you learn most of what you need to know about it. Many treat OWL as a serialization problem - emit classes and properties, drop the axioms - so "required" and "single-valued" evaporate at the border. CoreModels treats it as a semantics problem: required and cardinality ride as `owl:Restriction` subclass axioms, OWL's own carrier, in **both** directions. That decision propagates through everything below.

This is the complete reference for the `owl` format key: what maps structurally, what rides in the annotation extras, the full lossiness inventory, and what a round trip preserves.

Everything on the CoreModels transform surface passes through one intermediate representation: Types (with single inheritance), Elements (typed, owned by types, carrying required and cardinality), Taxonomies (ordered term trees), Relations, Components, and an annotation bag per node holding provenance, cross-standard `mapsTo` assertions, and dotted-key extras. The OWL coder bridges that model and a Turtle document, both ways.

## The structural map

| IR construct | OWL/Turtle form |
|---|---|
| Schema | an `owl:Ontology` subject (a full IRI minted under the entity namespace from the schema label) with `rdfs:label` |
| Type | `owl:Class` with `rdfs:label`, plus `rdfs:comment` when known |
| Type inheritance | `rdfs:subClassOf` between two named classes |
| Element with a primitive value | `owl:DatatypeProperty` with an `xsd:` `rdfs:range` |
| Element referencing a Type or Taxonomy | `owl:ObjectProperty` with that class as `rdfs:range` |
| Element owned by one Type | `rdfs:domain` naming the owning class |
| Element reused across Types | `rdfs:domain [ a owl:Class ; owl:unionOf ( … ) ]` |
| Required | `rdfs:subClassOf [ a owl:Restriction ; owl:onProperty p ; owl:minCardinality "1"^^xsd:nonNegativeInteger ]` |
| Single-valued | the same shape with `owl:maxCardinality "1"^^xsd:nonNegativeInteger` |
| Collection with a max bound | `owl:maxCardinality "n"^^xsd:nonNegativeInteger` |
| Taxonomy | a `skos:ConceptScheme` plus an `owl:Class` that is `rdfs:subClassOf skos:Concept` |
| Taxonomy term | an individual typed with both the taxonomy class and `skos:Concept`, carrying `skos:inScheme` and `skos:prefLabel` |
| Term hierarchy | `skos:broader` |
| Term cross-reference | `skos:exactMatch` |
| `mapsTo` on a Type or Element | the entity's own IRI, plus `owl:equivalentClass` / `owl:equivalentProperty` for the remainder |
| Relation | narrowed to `rdfs:seeAlso` between the endpoints, with a ledger record |
| Component | not exported; recorded as a structural drop |

Decoding runs the same table backwards, with one classification step first: a class declaring `rdfs:subClassOf skos:Concept` becomes a Taxonomy, and everything else typed `owl:Class` becomes a Type. Taxonomies are built before types and properties, so an `rdfs:range` naming a concept class resolves to a taxonomy reference rather than a type reference.

## Identity: every entity is already an IRI

This is the property OWL has that no other format on the surface does, and the coder leans on it in both directions.

**Decoding.** Each class and property contributes its own expanded IRI to the node's `mapsTo` set, recorded under the prefix it was written with. `owl:equivalentClass`, `owl:equivalentProperty`, and `skos:exactMatch` objects are added alongside under the standard `owl`. Decode `ex:Product a owl:Class ; owl:equivalentClass schema:Product` and the resulting Type carries both `https://example.org/catalog#Product` (standard `ex`) and `https://schema.org/Product` (standard `owl`) - identity you can match on later when aligning against another model.

**Encoding.** The entity IRI is chosen by three rules in order:

1. If the node's id already has the shape of a prefixed name, it is used as-is. This is what makes decode-then-encode exact.
2. Otherwise, the node's `mapsTo` URIs are reversed into a compact prefixed name via the longest matching namespace in the prefix map. A Type that arrived from JSON Schema carrying `mapsTo → https://schema.org/Article` therefore exports as `schema:Article`: the identity assertion *becomes* the name.
3. Otherwise a fresh name is minted by PascalCasing the label under the coder's own entity namespace, written here as `ex:` → `https://coremodels.example.com/ns/`.

A `mapsTo` URI that no namespace covers is emitted as a full IRI in angle brackets - `owl:equivalentClass <http://purl.org/dc/dcmitype/Text>`. The node's own IRI is skipped in the equivalence list, since naming the entity by it already asserts that identity.

The prefix map is assembled in layers: built-in defaults (`rdf`, `rdfs`, `owl`, `xsd`, `skos`, `schema` for schema.org, and the `ex:` minting namespace), overlaid with any prefixes the JSON-LD coder preserved - read, never written, so a model that arrived as JSON-LD exports under the prefixes it came with - overlaid with this coder's own preserved prefixes. Only prefixes actually used in the output appear in the header, sorted - plus the four core declarations (`rdf`, `rdfs`, `owl`, `xsd`), which are always emitted. The prefixed-name test in rule 1 is intentionally narrow - a colon after position zero, and no space, pipe, or dot - so that identifiers from other coders are never mistaken for prefixed names.

## Cardinality: open world, honest bounds

**The open-world default.** A decoded property with no restrictions is *optional and multi-valued*. That is not a converter preference; it is what an unrestricted property means in RDF. If a downstream format shows a surprising optional array, the ontology never said otherwise.

**Restrictions are the carrier, both directions.** `owl:minCardinality` of 1 or more makes the element required. `owl:maxCardinality 1` makes it single-valued; a larger maximum becomes the collection's upper bound. `owl:cardinality n` sets both bounds at once. Multiple restriction axioms on the same property merge strongest-wins: the largest minimum, the smallest maximum. A minimum above 1 is also carried through as the collection's lower bound.

**One honest gap on the way out.** OWL can say "at least one" through `minCardinality 1`, but a collection whose lower bound is higher - at least two keywords, say - has no carrier the encoder emits beyond that floor. The bound is relaxed and a `ConstraintRelaxation` record names the exact element:

```
[ConstraintRelaxation] Type[Doc].keyword: minItems 2 has no OWL carrier beyond the
min-cardinality-1 restriction; the bound is relaxed.
```

## Datatypes

| Decode: `xsd:` → IR | Encode: IR → `xsd:` |
|---|---|
| `integer`, `int`, `long`, `short`, `nonNegativeInteger` → Integer | Integer → `xsd:integer` |
| `double`, `decimal`, `float` → Double | Double → `xsd:double` |
| `boolean` → Boolean | Boolean → `xsd:boolean` |
| `dateTime`, `date`, `time` → DateTime | DateTime → `xsd:dateTime` |
| everything else, including `string` and `anyURI` → String | String and RichText → `xsd:string` |

The encode column looks lossier than it is: the decoder stashes the original range verbatim and the encoder prefers it. A property declared `rdfs:range xsd:decimal` decodes to the Double primitive *and* keeps `xsd:decimal` in the extras, so re-export says `xsd:decimal`. The generic column applies only to models that never came from OWL.

## What rides the extras

Fidelity beyond the structural map travels in dotted, coder-prefixed extras - never smuggled into another format's output, always available for exact re-emit.

| Key | Scope | Carries |
|---|---|---|
| `owl.prefix.<prefix>` | schema | each prefix → namespace IRI from the decoded document |
| `owl.comment` | any node | the `rdfs:comment` text, re-emitted verbatim |
| `owl.range` | element | the original `rdfs:range` exactly as written, whenever the range is not a decoded class |
| `owl.scheme` | taxonomy | a `skos:ConceptScheme` IRI that differs from the default naming convention |
| `jsonld.context.<prefix>` | schema | read (never written) from the JSON-LD coder, for prefix continuity |

The comment channel cooperates across coders on export: with no preserved OWL comment, the encoder falls back to a description another coder stashed, then to a SQL column comment, then to the node's instruction annotation - so human documentation reaches `rdfs:comment` whatever format the model came from.

## The lossiness inventory

Export - what OWL cannot carry:

| Trigger | Kind | What the record says |
|---|---|---|
| Component | StructuralDrop | OWL has no curated-view construct; the component projection is not exported |
| Element owned by no Type | StructuralDrop | exported without an `rdfs:domain` |
| RichText value | TypeApproximation | rich text approximated as `xsd:string` |
| Collection lower bound above the required floor | ConstraintRelaxation | the bound is relaxed |
| Taxonomy marked external-reference | ConstraintRelaxation | the directive has no OWL carrier; exported as a full concept scheme |
| Every Relation | SemanticNarrowing | narrowed to `rdfs:seeAlso`; the group's semantics are not expressible in OWL |

Import - what real-world Turtle brings:

| Trigger | Kind | Behavior |
|---|---|---|
| Well-formed triples outside the schema vocabulary | SemanticNarrowing | counted, reported as one summarized record - never an error |
| Language-tagged or repeated labels/comments | SemanticNarrowing | first lexical form kept, alternatives recorded |
| Several `rdfs:subClassOf` parents | SemanticNarrowing | narrowed to the single IR parent |
| `rdfs:subClassOf` or `rdfs:domain` target that is not a decoded class | SemanticNarrowing | the link or membership is dropped, naming the target |
| Several `rdfs:range` values | SemanticNarrowing | the first is kept, and named |
| `rdfs:range` outside `xsd:` that is not a decoded class | TypeApproximation | approximated as String, original kept in `owl.range` |
| Object property with no `rdfs:range` | TypeApproximation | approximated as String |
| `skos:broader` target outside the taxonomy | SemanticNarrowing | the hierarchy link is dropped |

Errors are reserved for documents that cannot be parsed at all - an empty file, an unterminated literal, an unclosed IRI, a truncated statement. They arrive on path `$` - with a line number whenever there is a line to point at, for example `Line 1: Unclosed IRI reference (missing '>').` - and never as an exception.

## Round-trip fidelity

Encode a model to Turtle, decode it, and encode it again: the second document is byte-identical to the first. What survives a cycle exactly: labels; comments; the parent/child class link; required and single-valued facts, through the restriction axioms; a collection's upper bound; every `mapsTo` URI on types, elements, and terms; shared-element ownership across several types, through the union domain; the taxonomy's terms, hierarchy, and `skos:exactMatch` links; and the prefixes for every namespace the output uses.

What normalizes harmlessly: a bare `owl:minCardinality 1` is re-emitted as `"1"^^xsd:nonNegativeInteger`; comma-separated `rdfs:subClassOf` lists become separate `;` clauses; and a prefix declared but unused is not re-emitted.

## Edge cases worth knowing

- **A plain-SKOS document decodes.** A `skos:ConceptScheme` with no `owl:Class` carrier still becomes a taxonomy - the scheme itself plays that role, and its members are the concepts pointing at it with `skos:inScheme`.
- **`rdfs:subClassOf owl:Thing` is ignored**, not reported. It says nothing.
- **`rdfs:seeAlso` closes the loop.** Relations export as `rdfs:seeAlso`; on import, `rdfs:seeAlso` between two decoded schema nodes is rebuilt into a relation group, deduplicated. The wire form round-trips even though the export still records the narrowing - the group's original meaning genuinely is gone.
- **The default `:` prefix is a lossy choice.** An entity written as `:Person` decodes with the id `:Person`, but a leading colon fails the prefixed-name test, so its IRI is not lifted into `mapsTo` and export re-mints it under `ex:`. Declare a named prefix and the identity survives.
- **Declare the namespaces you intend to map into.** The encoder emits `@prefix` lines only for prefixes it can resolve from the model's preserved prefix map, so a mapping that retargets entities into a namespace your source never declared will use that prefix without declaring it. Adding the declaration to the source - even unused - makes the output self-contained.
- **Unrecognized blank nodes are counted, not guessed at.** A blank node under `rdfs:subClassOf` that is not a restriction stays unconsumed and joins the summarized ignored-triple count.

The theme across all of it: OWL is expressive enough to carry most of the model natively, and wherever either side falls short the gap is written down - in the ledger, or in an extras key that lets export restore what decode had to approximate.

For hands-on usage of this coder over HTTP and MCP, start with the OWL quickstart in the CoreModels documentation.
