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:
- 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.
- Otherwise, the node's
mapsToURIs are reversed into a compact prefixed name via the longest matching namespace in the prefix map. A Type that arrived from JSON Schema carryingmapsTo → https://schema.org/Articletherefore exports asschema:Article: the identity assertion becomes the name. - 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:ConceptSchemewith noowl:Classcarrier still becomes a taxonomy — the scheme itself plays that role, and its members are the concepts pointing at it withskos:inScheme. rdfs:subClassOf owl:Thingis ignored, not reported. It says nothing.rdfs:seeAlsocloses the loop. Relations export asrdfs:seeAlso; on import,rdfs:seeAlsobetween 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:Persondecodes with the id:Person, but a leading colon fails the prefixed-name test, so its IRI is not lifted intomapsToand export re-mints it underex:. Declare a named prefix and the identity survives. - Declare the namespaces you intend to map into. The encoder emits
@prefixlines 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:subClassOfthat 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.