diff --git a/docs/examples/owl-dcmi-agent-annotations.yaml b/docs/examples/owl-dcmi-agent-annotations.yaml new file mode 100644 index 0000000000..c3d765de2b --- /dev/null +++ b/docs/examples/owl-dcmi-agent-annotations.yaml @@ -0,0 +1,24 @@ +id: https://example.org/model +name: model +prefixes: + ex: https://example.org/ + linkml: https://w3id.org/linkml/ + dcterms: http://purl.org/dc/terms/ + owl: http://www.w3.org/2002/07/owl# +imports: [linkml:types] +default_prefix: ex +instantiates: [ex:AgentMetadata] +annotations: + creator_label: The Example Team + publisher: ex:team +classes: + AgentMetadata: + attributes: + creator_label: + slot_uri: dcterms:creator + range: string + implements: [owl:AnnotationProperty] + publisher: + slot_uri: dcterms:publisher + range: nodeidentifier + implements: [owl:AnnotationProperty] diff --git a/docs/generators/owl.rst b/docs/generators/owl.rst index 165cfedbb7..f6e092dd8a 100644 --- a/docs/generators/owl.rst +++ b/docs/generators/owl.rst @@ -188,7 +188,66 @@ References: `LinkML metamodel refinement `existing implements mapping `__, `RDF Schema resources `__, and `DCMI license guidance -`__. +`__. + +DCMI agent metadata +^^^^^^^^^^^^^^^^^^^ + +To name an agent by IRI, declare a ``nodeidentifier`` annotation. To record a +name or other textual identifier, declare ``string``. Independently, use +``implements: [owl:AnnotationProperty]`` for the OWL annotation role. +``instantiates`` identifies the metaclass supplying those declarations; it +does not select that role automatically. These choices work for +``dcterms:creator``, ``dcterms:contributor``, ``dcterms:publisher`` and +``dcterms:rightsHolder`` through the same mechanism as the custom predicates above: + +.. literalinclude:: ../examples/owl-dcmi-agent-annotations.yaml + :language: yaml + +The ontology header contains ``dcterms:creator "The Example Team"`` and +``dcterms:publisher ``, with both predicates declared +as annotation properties. This projection omits OWL logical restrictions on +the metadata slots; their LinkML constraints remain available to instance +validators. OWL annotation axioms do not validate those values. +A string declaration stays literal even when its value looks like a URL. +For SHACL, put the same +``instantiates`` and ``annotations`` on a class or slot to annotate its shape, +with ``--include-annotations`` enabled. + +Tests execute this exact example with ``metaclasses=False`` and both values +of ``use_native_uris`` and ``type_objects``. OWLAPI 5.5.0 loads, saves and +reloads each generated artifact with zero OWL 2 DL profile violations and +retains the literal creator and IRI publisher. This is the same controlled +consumer contract as the generic example, not a claim about every schema or +the API's default metaclass projection. + +The `DCMI definitions +`__ use +``dcam:rangeIncludes`` with ``dcterms:Agent`` for these properties. The +`rangeIncludes definition +`__ +suggests a class for values; it is not an ``rdfs:range`` restriction or an +instruction to coerce strings into IRIs. Creator and rights-holder guidance +recommends URIs while permitting literal identifiers. The declared LinkML +range determines the emitted RDF term. + +Use the native ``contributors`` field for ordinary contributor identifiers; +it already carries URI-or-CURIE values and maps to ``dcterms:contributor``. +A separately declared annotation with that RDF predicate needs its own +declaration. Native `created_by `__ +maps to ``pav:createdBy`` and does not emit ``dcterms:creator``. Although the +metamodel declares a standalone `publisher `__ +slot, the current ``SchemaDefinition`` runtime does not accept +a top-level ``publisher`` field. The example therefore declares publisher +metadata through ``instantiates`` and annotations. + +Keep native ``contributors`` and ``license`` authoritative; native ``license`` +retains its existing metamodel representation. Scalar agent +annotations do not preserve structured person or organization descriptions, +and native-field RDF export mappings require separate support. Declaring the +value representation and OWL role avoids another generator flag or a +vocabulary-specific list. + Enums and PermissibleValues ^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/tests/linkml/test_generators/test_declared_annotations.py b/tests/linkml/test_generators/test_declared_annotations.py index 4397dc14d0..ee8b06bc3d 100644 --- a/tests/linkml/test_generators/test_declared_annotations.py +++ b/tests/linkml/test_generators/test_declared_annotations.py @@ -5,7 +5,7 @@ import pytest import yaml from pyshacl import validate -from rdflib import RDF, SH, XSD, BNode, Graph, Literal, URIRef +from rdflib import OWL, RDF, SH, XSD, BNode, Graph, Literal, URIRef from linkml.generators.owlgen import OwlSchemaGenerator from linkml.generators.shaclgen import ShaclGenerator @@ -236,3 +236,97 @@ def test_annotations_do_not_constrain_data() -> None: data = Graph() data.add((URIRef(EX + "instance"), RDF.type, URIRef(EX + "Thing"))) assert validate(data, shacl_graph=graph, meta_shacl=True)[0] + + +@pytest.mark.parametrize("generator", ["owl", "shacl"]) +@pytest.mark.parametrize("property_name", ["creator", "contributor", "publisher", "rightsHolder"]) +@pytest.mark.parametrize( + "range_name,value", + [ + ("nodeidentifier", "ex:team"), + ("nodeidentifier", "https://example.org/team"), + ("string", "The Example Team"), + ("string", "https://example.org/team"), + ], +) +def test_dcmi_agent_annotation_declarations(generator: str, property_name: str, range_name: str, value: str) -> None: + """DCMI agent values follow declarations, including deliberately literal URL text.""" + schema = yaml.safe_load(SCHEMA) + schema["prefixes"]["dcterms"] = "http://purl.org/dc/terms/" + schema["classes"]["Metadata"]["attributes"] = { + "agent": { + "slot_uri": "dcterms:" + property_name, + "range": range_name, + "implements": ["owl:AnnotationProperty"], + } + } + schema["classes"]["Thing"].pop("annotations") + metadata = {"instantiates": ["ex:Profile"], "annotations": {"dcterms:" + property_name: value}} + # OWL annotates the ontology header; SHACL annotates the class shape. + if generator == "owl": + schema.update(metadata) + subject = URIRef(EX + "model") + else: + schema["classes"]["Thing"].update(metadata) + subject = URIRef(EX + "Thing") + graph = _graph(yaml.safe_dump(schema), generator, default_language="en") + predicate = URIRef("http://purl.org/dc/terms/" + property_name) + expected = URIRef(EX + "team") if range_name == "nodeidentifier" else Literal(value, lang="en") + assert set(graph.objects(subject, predicate)) == {expected} + if generator == "owl": + assert (predicate, RDF.type, OWL.AnnotationProperty) in graph + assert (predicate, RDF.type, OWL.ObjectProperty) not in graph + assert (predicate, RDF.type, OWL.DatatypeProperty) not in graph + for property_uri in [predicate, URIRef(EX + "agent")]: + assert not list(graph.subjects(OWL.onProperty, property_uri)) + + +def test_declared_contributor_matches_metamodel_field() -> None: + """The standard contributors field and an explicitly declared node annotation agree.""" + schema = yaml.safe_load(SCHEMA) + schema["prefixes"]["dcterms"] = "http://purl.org/dc/terms/" + schema["classes"]["Metadata"]["attributes"]["contributor"] = { + "slot_uri": "dcterms:contributor", + "range": "nodeidentifier", + "implements": ["owl:AnnotationProperty"], + } + schema.update( + instantiates=["ex:Profile"], + contributors=["ex:team"], + annotations={"contributor": "ex:team"}, + ) + predicate = URIRef("http://purl.org/dc/terms/contributor") + for fields in [("contributors", "annotations"), ("contributors",), ("annotations",)]: + source = { + key: value for key, value in schema.items() if key not in {"contributors", "annotations"} or key in fields + } + graph = _graph(yaml.safe_dump(source), "owl") + assert set(graph.objects(URIRef(EX + "model"), predicate)) == {URIRef(EX + "team")} + assert (predicate, RDF.type, OWL.AnnotationProperty) in graph + assert not list(graph.subjects(OWL.onProperty, predicate)) + + +@pytest.mark.parametrize("property_name", ["creator", "contributor", "publisher", "rightsHolder"]) +def test_undeclared_dcmi_agent_url_stays_literal(property_name: str) -> None: + """A familiar DCMI predicate alone does not authorize OWL node coercion.""" + schema = yaml.safe_load(SCHEMA) + schema["prefixes"]["dcterms"] = "http://purl.org/dc/terms/" + schema["annotations"] = {"dcterms:" + property_name: EX + "team"} + graph = _graph(yaml.safe_dump(schema), "owl") + assert set(graph.objects(URIRef(EX + "model"), URIRef("http://purl.org/dc/terms/" + property_name))) == { + Literal(EX + "team") + } + predicate = URIRef("http://purl.org/dc/terms/" + property_name) + assert (predicate, RDF.type, OWL.AnnotationProperty) in graph + assert not list(graph.subjects(OWL.onProperty, predicate)) + + +def test_native_created_by_has_its_own_mapping() -> None: + """Native creator metadata emits its PAV predicate independently of custom DCMI annotations.""" + schema = yaml.safe_load(SCHEMA) + schema["created_by"] = "ex:team" + graph = _graph(yaml.safe_dump(schema), "owl") + predicate = URIRef("http://purl.org/pav/createdBy") + assert set(graph.objects(URIRef(EX + "model"), predicate)) == {URIRef(EX + "team")} + assert (predicate, RDF.type, OWL.AnnotationProperty) in graph + assert not list(graph.objects(URIRef(EX + "model"), URIRef("http://purl.org/dc/terms/creator"))) diff --git a/tests/linkml/test_generators/test_owl_annotation_properties.py b/tests/linkml/test_generators/test_owl_annotation_properties.py index 24e2898cd3..1dbbf59ec9 100644 --- a/tests/linkml/test_generators/test_owl_annotation_properties.py +++ b/tests/linkml/test_generators/test_owl_annotation_properties.py @@ -30,6 +30,7 @@ EX = "https://example.org/" ROOT = Path(__file__).resolve().parents[3] EXAMPLE = ROOT / "docs/examples/owl-annotation-properties.yaml" +DCMI_EXAMPLE = ROOT / "docs/examples/owl-dcmi-agent-annotations.yaml" HARNESS = Path(__file__).parent / "input/owl_annotation_properties" @@ -877,6 +878,26 @@ def test_owlapi_documentation_roundtrip( _probe(schema, tmp_path, owlapi_classpath, rows, use_native_uris=native, type_objects=type_objects) +@pytest.mark.integration +@pytest.mark.parametrize("native", [False, True]) +@pytest.mark.parametrize("type_objects", [False, True]) +def test_owlapi_dcmi_documentation_roundtrip( + tmp_path: Path, owlapi_classpath: str, native: bool, type_objects: bool +) -> None: + """The exact DCMI example retains literal/node annotations with zero profile violations in every tested mode.""" + schema = yaml.safe_load(DCMI_EXAMPLE.read_text()) + rows = [ + ["ontology", str(DCTERMS.creator), "LITERAL", "The Example Team", "", ""], + ["ontology", str(DCTERMS.publisher), "IRI", EX + "team", "", ""], + ] + _probe( + schema, tmp_path, owlapi_classpath, rows, use_native_uris=native, type_objects=type_objects, metaclasses=False + ) + graph = Graph().parse(tmp_path / "generated.ttl", format="turtle") + for predicate in [DCTERMS.creator, DCTERMS.publisher]: + assert (predicate, RDF.type, OWL.AnnotationProperty) in graph + + @pytest.mark.integration def test_owlapi_non_header_owners(schema: dict, tmp_path: Path, owlapi_classpath: str) -> None: """Class, slot, type, enum and PV annotation assertions survive a real OWLAPI round-trip."""