Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions docs/generators/owl.rst
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,56 @@ You can control enum and permissible value representation directly in your schem
implements:
- rdfs:Literal

**Metaclass membership with ``instantiates``** - This LinkML field types a schema
element itself; it does not add slots or type that element's data instances. The OWL
generator maps each declared membership to ``rdf:type`` on the emitted resource.
The mapping applies consistently to schemas, classes, slots, locally emitted types,
enums, and non-literal permissible values. No additional option is required: the
schema already declares the relationship.

For example, a permissible value represented as a named individual can declare
membership in an external vocabulary class:

.. code-block:: yaml

enums:
LinkCategory:
implements:
- owl:NamedIndividual
permissible_values:
isLicense:
meaning: ex:isLicense
instantiates:
- vocab:LicenseCategory

.. code-block:: turtle

ex:isLicense a owl:NamedIndividual, ex:LinkCategory, vocab:LicenseCategory .
vocab:LicenseCategory a owl:Class .

The instantiated class is declared, as OWL 2 DL requires for every class a class
assertion uses. For an enum of named individuals, its own class still uses ``owl:oneOf``; this does
not close the external class. A permissible value rendered as an OWL class retains
that representation and also participates as an individual in the membership
assertion (OWL punning). Membership does not become ``rdfs:subClassOf`` and is not
inherited by instances of the emitted class. ``instantiates`` is ignored, with a
warning, on a permissible value rendered as a literal, because RDF literals cannot
be subjects of ``rdf:type`` triples, on a resource in the OWL 2 reserved vocabulary,
which cannot be an individual, and for a value that is neither a CURIE nor an IRI,
which would become a relative IRI.

``gen-rdf`` and ``gen-jsonld`` serialize the schema as metamodel data and retain
``linkml:instantiates``. ``gen-owl`` translates that declaration into ontology
membership. Instance validators such as JSON Schema and SHACL must not apply the
schema element's metaclass to ordinary data instances.

See the `LinkML instantiation guide
<https://linkml.io/linkml/howtos/implements-instantiates-guide.html#instantiates-metamodel-extension>`_,
the `instantiates metamodel definition
<https://linkml.io/linkml-model/latest/docs/instantiates/>`_, and the OWL 2
specifications for `metamodeling <https://www.w3.org/TR/owl2-syntax/#Metamodeling>`_
and `mapping class assertions to RDF <https://www.w3.org/TR/owl2-mapping-to-rdf/>`_.

**Using URIs vs. text for permissible values:**

.. code-block:: yaml
Expand Down
41 changes: 39 additions & 2 deletions packages/linkml/src/linkml/generators/owlgen.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
ClassDefinitionName,
ClassRule,
Definition,
Element,
EnumDefinition,
EnumDefinitionName,
PermissibleValue,
Expand Down Expand Up @@ -358,9 +359,9 @@ def serialize(self, **kwargs: Any) -> str:
fmt = "turtle" if self.format in ["owl", "ttl"] else self.format
return canonicalize_rdf_graph(self.graph, output_format=fmt, diff_stable=self.diff_stable)

def add_metadata(self, e: Definition | PermissibleValue, uri: URIRef) -> None:
def add_metadata(self, e: Element | PermissibleValue, uri: URIRef) -> None:
"""
Add annotation properties.
Add annotation properties and explicit metaclass membership.

Set the profile attribute to the appropriate OWL profile.
Human-readable string literals are language-tagged when
Expand All @@ -376,6 +377,8 @@ def add_metadata(self, e: Definition | PermissibleValue, uri: URIRef) -> None:
sn_mappings = msv.slot_name_mappings()
lang = self._resolve_language(e)

self._add_instantiates(e, uri)

# iterate through all the assigned metamodel slots
for metaslot_name, metaslot_value in vars(e).items():
if not metaslot_value:
Expand Down Expand Up @@ -1083,11 +1086,43 @@ def add_slot(self, slot: SlotDefinition, attribute: bool = False) -> None:
for mixin in slot.mixins:
self.graph.add((slot_uri, RDFS.subPropertyOf, self._prop_uri(mixin)))

def _add_instantiates(self, element: Element | PermissibleValue, uri: URIRef) -> None:
"""Type the emitted schema resource itself, without typing its data instances.

``instantiates`` declares metaclass membership in LinkML. In OWL's RDF
mapping, class assertions are ``rdf:type`` triples; a resource also used
as an OWL class or property is interpreted separately as an individual.
A resource in the OWL 2 reserved vocabulary (Structural Specification
section 2.4, Table 2), such as a type emitted as ``xsd:integer``, cannot
be an individual, so its membership is not asserted. A value that is
neither a CURIE nor an IRI, such as a bare element name, would become a
relative IRI and is skipped. Both cases are logged as warnings. The
instantiated class is declared, as OWL 2 DL requires for every class a
class assertion uses (Structural Specification §5.8.1), unless it is in
the reserved vocabulary.
"""
reserved = tuple(str(ns) for ns in (RDF, RDFS, XSD, OWL))
if element.instantiates and str(uri).startswith(reserved):
logger.warning(f"Instantiates on {uri}, which is OWL 2 reserved vocabulary, is ignored")
return
for instantiated in element.instantiates:
expanded = self.schemaview.expand_curie(instantiated)
if ":" not in expanded:
logger.warning(
f"Instantiates {instantiated!r} on {uri}, which is neither a CURIE nor an IRI, is ignored"
)
continue
self.graph.add((uri, RDF.type, URIRef(expanded)))
if not expanded.startswith(reserved):
self.graph.add((URIRef(expanded), RDF.type, OWL.Class))

def add_type(self, typ: TypeDefinition) -> None:
type_uri = self._type_uri(typ.name)
if typ.from_schema == "https://w3id.org/linkml/types":
return

self._add_instantiates(typ, type_uri)

if self.metaclasses:
self.graph.add(
(
Expand Down Expand Up @@ -1199,6 +1234,8 @@ def add_enum(self, e: EnumDefinition) -> None:
pv_node = Literal(pv.text)
if pv.meaning:
logger.warning(f"Meaning on literal {pv.text} in {e.name} is ignored")
if pv.instantiates:
logger.warning(f"Instantiates on literal {pv.text} in {e.name} is ignored")
else:
pv_node = self._permissible_value_uri(pv, enum_uri, e)
pv_uris.append(pv_node)
Expand Down
190 changes: 190 additions & 0 deletions tests/linkml/test_generators/test_owlgen.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from linkml import METAMODEL_CONTEXT_URI
from linkml.generators.owlgen import MetadataProfile, OwlSchemaGenerator
from linkml.generators.rdfgen import RDFGenerator
from linkml_runtime.linkml_model import SlotDefinition
from linkml_runtime.linkml_model.meta import (
AnonymousClassExpression,
Expand Down Expand Up @@ -1266,3 +1267,192 @@ def test_cli_ontology_uri_suffix(kitchen_sink_path, args: list[str], expected_su
g = Graph()
g.parse(data=result.output, format="turtle")
assert _ontology_iris(g) == {URIRef(f"https://w3id.org/linkml/tests/kitchen_sink{expected_suffix}")}


_INSTANTIATES_SCHEMA = """
id: http://example.org/test-schema
name: instantiates_test
prefixes:
linkml: https://w3id.org/linkml/
ex: http://example.org/test-schema/
vocab: http://example.org/vocab/
default_prefix: ex
imports:
- linkml:types
enums:
LinkCategory:
implements:
- owl:NamedIndividual
permissible_values:
isLicense:
meaning: ex:isLicense
instantiates:
- vocab:LicenseCategory
isManifest:
meaning: ex:isManifest
instantiates:
- vocab:ManifestCategory
- vocab:Category
isMedia:
meaning: ex:isMedia
"""

VOCAB = Namespace("http://example.org/vocab/")


def test_permissible_value_instantiates_types_the_value() -> None:
"""Each ``instantiates`` value of a permissible value becomes an rdf:type of its IRI."""
g = Graph()
g.parse(data=OwlSchemaGenerator(_INSTANTIATES_SCHEMA, metaclasses=False).serialize(), format="turtle")

assert (EX.isLicense, RDF.type, VOCAB.LicenseCategory) in g
assert (EX.isManifest, RDF.type, VOCAB.ManifestCategory) in g
assert (EX.isManifest, RDF.type, VOCAB.Category) in g
# the value stays an individual of the enum, which still closes its own class
for pv in (EX.isLicense, EX.isManifest, EX.isMedia):
assert (pv, RDF.type, OWL.NamedIndividual) in g
assert (pv, RDF.type, EX.LinkCategory) in g
one_of = g.value(EX.LinkCategory, OWL.oneOf)
assert set(Collection(g, one_of)) == {EX.isLicense, EX.isManifest, EX.isMedia}
# a value without instantiates gets no further type, and the instantiated classes stay open
assert set(g.objects(EX.isMedia, RDF.type)) == {OWL.NamedIndividual, EX.LinkCategory}
assert g.value(VOCAB.LicenseCategory, OWL.oneOf) is None


def test_permissible_value_instantiates_ignored_on_literal(caplog: pytest.LogCaptureFixture) -> None:
"""A permissible value rendered as a literal cannot be typed, so instantiates is ignored with a warning."""
schema = _INSTANTIATES_SCHEMA.replace("owl:NamedIndividual", "rdfs:Literal")
with caplog.at_level(logging.WARNING):
g = Graph()
g.parse(data=OwlSchemaGenerator(schema, metaclasses=False).serialize(), format="turtle")
assert not list(g.triples((None, RDF.type, VOCAB.LicenseCategory)))
assert any("Instantiates on literal isLicense" in rec.message for rec in caplog.records)


def test_instantiates_ignored_on_reserved_vocabulary(caplog: pytest.LogCaptureFixture) -> None:
"""Without type objects a type is emitted as its XSD datatype, which cannot be
an OWL 2 DL individual, so its ``instantiates`` is not asserted on that IRI."""
schema = """
id: https://example.org/reserved
name: reserved
prefixes:
linkml: https://w3id.org/linkml/
ex: https://example.org/
default_prefix: ex
types:
Count:
uri: xsd:integer
base: int
instantiates: [ex:Metadata]
"""
with caplog.at_level(logging.WARNING):
output = OwlSchemaGenerator(schema, type_objects=False, metaclasses=False).serialize()
graph = Graph().parse(data=output, format="turtle")
assert (XSD.integer, RDF.type, URIRef("https://example.org/Metadata")) not in graph
assert "reserved vocabulary" in caplog.text


def test_instantiates_ignored_for_a_bare_name(caplog: pytest.LogCaptureFixture) -> None:
"""A value that is neither a CURIE nor an IRI would become a relative IRI, so it is skipped with a warning."""
schema = (
_INSTANTIATES_SCHEMA
+ """
classes:
Metadata: {}
Entry:
instantiates: [Metadata, vocab:ClassProfile]
"""
)
with caplog.at_level(logging.WARNING):
g = OwlSchemaGenerator(schema, metaclasses=False).as_graph()
assert set(g.objects(EX.Entry, RDF.type)) == {OWL.Class, VOCAB.ClassProfile}
assert (
"Instantiates 'Metadata' on http://example.org/test-schema/Entry, "
"which is neither a CURIE nor an IRI, is ignored"
) in caplog.text


@pytest.mark.parametrize("use_native_uris", [True, False])
def test_instantiates_types_the_emitted_class_resource(use_native_uris: bool) -> None:
"""The assertion goes on the resource the class is emitted as: its native IRI, or its ``class_uri``."""
schema = _INSTANTIATES_SCHEMA.replace(
" vocab: http://example.org/vocab/\n", " vocab: http://example.org/vocab/\n schema: http://schema.org/\n"
)
schema += """
classes:
Entry:
class_uri: schema:Thing
instantiates: [vocab:ClassProfile]
"""
g = OwlSchemaGenerator(schema, metaclasses=False, use_native_uris=use_native_uris).as_graph()
expected = EX.Entry if use_native_uris else URIRef("http://schema.org/Thing")
assert set(g.subjects(RDF.type, VOCAB.ClassProfile)) == {expected}


@pytest.mark.parametrize("metaclasses", [False, True])
@pytest.mark.parametrize("type_objects", [False, True])
def test_instantiates_applies_to_emitted_schema_elements(metaclasses: bool, type_objects: bool) -> None:
"""Metaclass membership is explicit schema metadata, independent of generated metamodel types."""
schema = _INSTANTIATES_SCHEMA.replace(
"name: instantiates_test", "name: instantiates_test\ninstantiates: [vocab:SchemaProfile]"
).replace(" LinkCategory:\n", " LinkCategory:\n instantiates: [vocab:EnumProfile]\n")
schema += """
classes:
Entry:
instantiates: [vocab:ClassProfile]
slots: [code]
slots:
code:
range: Code
instantiates: [vocab:SlotProfile]
types:
Code:
typeof: string
uri: ex:Code
instantiates: [vocab:TypeProfile]
"""
g = OwlSchemaGenerator(schema, metaclasses=metaclasses, type_objects=type_objects).as_graph()
for resource, metaclass in (
(URIRef("http://example.org/test-schema"), VOCAB.SchemaProfile),
(EX.Entry, VOCAB.ClassProfile),
(EX.code, VOCAB.SlotProfile),
(EX.Code, VOCAB.TypeProfile),
(EX.LinkCategory, VOCAB.EnumProfile),
(EX.isLicense, VOCAB.LicenseCategory),
):
assert (resource, RDF.type, metaclass) in g
assert (resource, RDFS.subClassOf, metaclass) not in g
assert (EX.isLicense, RDF.type, VOCAB.EnumProfile) not in g


@pytest.mark.parametrize("owl_type", ["owl:NamedIndividual", "owl:Class"])
@pytest.mark.parametrize("meaning", ["ex:isLicense", "https://example.net/value", None])
def test_instantiates_uses_the_emitted_value_resource(owl_type: str, meaning: str | None) -> None:
"""Explicit and minted IRIs both carry membership, including class/individual punning."""
schema = _INSTANTIATES_SCHEMA.replace("owl:NamedIndividual", owl_type)
schema = schema.replace(" meaning: ex:isLicense\n", f" meaning: {meaning}\n" if meaning else "")
schema = schema.replace("vocab:LicenseCategory", "https://example.net/Category")
g = OwlSchemaGenerator(schema, metaclasses=False).as_graph()
(resource,) = g.subjects(RDF.type, URIRef("https://example.net/Category"))
assert isinstance(resource, URIRef)
assert (resource, RDFS.label, Literal("isLicense")) in g
assert (resource, RDF.type, OWL[owl_type.split(":")[1]]) in g
if meaning:
assert resource == (EX.isLicense if meaning.startswith("ex:") else URIRef(meaning))


@pytest.mark.parametrize("metaclasses", [False, True])
def test_instantiated_class_is_declared(metaclasses: bool) -> None:
"""OWL 2 DL requires a declaration for every class a class assertion uses (§5.8.1)."""
g = OwlSchemaGenerator(_INSTANTIATES_SCHEMA, metaclasses=metaclasses).as_graph()
for metaclass in (VOCAB.LicenseCategory, VOCAB.ManifestCategory, VOCAB.Category):
assert (metaclass, RDF.type, OWL.Class) in g


def test_instantiates_rdf_schema_representation_is_distinct_from_owl_assertion() -> None:
"""gen-rdf preserves the metamodel predicate; gen-owl interprets it as membership."""
schema = _INSTANTIATES_SCHEMA.replace("imports:\n - linkml:types\n", "")
rdf = Graph().parse(data=RDFGenerator(schema).serialize(), format="turtle")
owl = OwlSchemaGenerator(schema, metaclasses=False).as_graph()
assert {str(value) for value in rdf.objects(EX.isLicense, LINKML.instantiates)} == {"vocab:LicenseCategory"}
assert (EX.isLicense, RDF.type, VOCAB.LicenseCategory) in owl
Loading