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
46 changes: 46 additions & 0 deletions docs/generators/owl.rst
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,52 @@ 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 .

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.

``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
21 changes: 19 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 @@ -355,9 +356,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)

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 @@ -373,6 +374,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 @@ -1080,11 +1083,23 @@ 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.
"""
for instantiated in element.instantiates:
self.graph.add((uri, RDF.type, URIRef(self.schemaview.expand_curie(instantiated))))

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 @@ -1196,6 +1211,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
122 changes: 122 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 @@ -1232,3 +1233,124 @@ def test_complement_of_union_of_mixed_none_filters_silently():
# Should succeed and return a BNode (the complement expression).
assert result is not None
assert isinstance(result, BNode)


_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)


@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))


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