diff --git a/docs/generators/owl.rst b/docs/generators/owl.rst index 4b6f076fe8..ac87f3399a 100644 --- a/docs/generators/owl.rst +++ b/docs/generators/owl.rst @@ -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 +`_, +the `instantiates metamodel definition +`_, and the OWL 2 +specifications for `metamodeling `_ +and `mapping class assertions to RDF `_. + **Using URIs vs. text for permissible values:** .. code-block:: yaml diff --git a/packages/linkml/src/linkml/generators/owlgen.py b/packages/linkml/src/linkml/generators/owlgen.py index 3f16c4965c..c6a1086f2e 100644 --- a/packages/linkml/src/linkml/generators/owlgen.py +++ b/packages/linkml/src/linkml/generators/owlgen.py @@ -32,6 +32,7 @@ ClassDefinitionName, ClassRule, Definition, + Element, EnumDefinition, EnumDefinitionName, PermissibleValue, @@ -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 @@ -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: @@ -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( ( @@ -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) diff --git a/tests/linkml/test_generators/test_owlgen.py b/tests/linkml/test_generators/test_owlgen.py index 02e21371fc..2d392e98a7 100644 --- a/tests/linkml/test_generators/test_owlgen.py +++ b/tests/linkml/test_generators/test_owlgen.py @@ -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, @@ -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