From d612aea570c0790619a9bb06a92c9b453103fd86 Mon Sep 17 00:00:00 2001 From: jdsika Date: Fri, 2 Oct 2026 16:41:12 +0200 Subject: [PATCH] feat(gen-owl): honor instantiates on emitted schema resources Translate explicit instantiates declarations to rdf:type on the emitted schema resource through one shared helper. Apply the mapping consistently to schemas, classes, slots, types, enums, and non-literal permissible values. Literal permissible values cannot be RDF subjects and produce a warning. Membership types the schema element itself, not its ordinary data instances, and does not become subclass inheritance. Existing enum representation and OWL class/individual punning remain intact. No additional flag is needed because the schema already declares the relationship. Cover CURIEs, absolute and minted IRIs, literal values, representation modes, and the distinction between gen-rdf metamodel serialization and gen-owl ontology assertions. Document the LinkML and OWL specification references. A resource in the OWL 2 reserved vocabulary (Structural Specification section 2.4), such as a type emitted as xsd:integer without type objects, cannot be an OWL 2 DL individual; its instantiates is not asserted, with a warning. A value that is neither a CURIE nor an IRI would become a relative IRI and is skipped with a warning too. The instantiated class is declared as owl:Class, since OWL 2 DL requires a declaration for every class a class assertion uses (section 5.8.1). Signed-off-by: jdsika --- docs/generators/owl.rst | 50 +++++ .../linkml/src/linkml/generators/owlgen.py | 41 +++- tests/linkml/test_generators/test_owlgen.py | 190 ++++++++++++++++++ 3 files changed, 279 insertions(+), 2 deletions(-) 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