diff --git a/.github/workflows/validation-ci.yml b/.github/workflows/validation-ci.yml index ffe6fb829..9e1c4fd81 100644 --- a/.github/workflows/validation-ci.yml +++ b/.github/workflows/validation-ci.yml @@ -89,3 +89,6 @@ jobs: uv run validation/validate.py examples/flights.ontology.yaml --schema ontology/ontology.json uv run validation/validate.py examples/flights.semantic_model.yaml uv run validation/validate.py examples/flights.mapping.yaml --schema ontology/mapping.json + + - name: Validate FOAF example + run: uv run validation/validate.py examples/foaf_owl_import.yaml --schema ontology/ontology.json diff --git a/converters/ontology/src/ossie_ontology/spec.py b/converters/ontology/src/ossie_ontology/spec.py index 3c4d34679..3b73fbb55 100644 --- a/converters/ontology/src/ossie_ontology/spec.py +++ b/converters/ontology/src/ossie_ontology/spec.py @@ -40,6 +40,10 @@ class OssieObject(BaseModel): # like `instructions`, `synonyms`, `examples` (per core.md). AiContext = str | dict[str, Any] +# Open set of properties the spec does not model (e.g. OWL/RDF annotations kept +# for round-tripping). Keys are free-form and values may be any JSON. +Annotations = dict[str, Any] + class CustomExtension(OssieObject): """Vendor-specific metadata attached to any logical-model element (core.md). @@ -59,6 +63,7 @@ class Role(OssieObject): concept plays multiple roles in the same relationship.""" concept: str name: str | None = None + annotations: Annotations = Field(default_factory=dict) class Relationship(OssieObject): @@ -78,6 +83,7 @@ class Relationship(OssieObject): multiplicity: Literal["OneToOne", "ManyToOne"] | None = None derived_by: list[str] = Field(default_factory=list) requires: list[str] = Field(default_factory=list) + annotations: Annotations = Field(default_factory=dict) class ConceptComponent(OssieObject): @@ -106,6 +112,7 @@ class ConceptComponent(OssieObject): identify_by: list[str] = Field(default_factory=list) derived_by: list[str] = Field(default_factory=list) requires: list[str] = Field(default_factory=list) + annotations: Annotations = Field(default_factory=dict) relationships: list[Relationship] = Field(default_factory=list) @@ -248,6 +255,7 @@ class OssieSpec(OssieObject): name: str description: str | None = None prefixes: dict[str, str] = Field(default_factory=dict) + annotations: Annotations = Field(default_factory=dict) requires: list[str] = Field(default_factory=list) ai_context: AiContext | None = None ontology: list[ConceptComponent] = Field(default_factory=list) diff --git a/converters/ontology/tests/test_spec.py b/converters/ontology/tests/test_spec.py index 63938b7b2..360868c87 100644 --- a/converters/ontology/tests/test_spec.py +++ b/converters/ontology/tests/test_spec.py @@ -24,7 +24,7 @@ import pytest import yaml -from ossie_ontology.spec import DatasetField, Metric, SemanticModel +from ossie_ontology.spec import DatasetField, Metric, OssieSpec, SemanticModel # tests/ -> ontology -> converters -> _EXAMPLES_DIR = Path(__file__).resolve().parents[3] / "examples" @@ -63,3 +63,41 @@ def test_tpcds_example_parses(): assert model.name == "tpcds_retail_model" assert any(f.datatype for d in model.datasets for f in d.fields) assert any(m.datatype for m in model.metrics) + + +def test_foaf_example_keeps_annotations(): + """`annotations` carries imported data the spec does not model, so it + must parse at every level that declares it and survive a dump unchanged.""" + example_path = _EXAMPLES_DIR / "foaf_owl_import.yaml" + if not example_path.is_file(): + pytest.skip(f"canonical example not present at {example_path}") + + doc = yaml.safe_load(example_path.read_text(encoding="utf-8")) + spec = OssieSpec.model_validate(doc) + + assert spec.annotations == {"dc:title": "Friend of a Friend (FOAF) vocabulary"} + person = spec.ontology[0] + assert person.annotations["owl:equivalentClass"] == "http://schema.org/Person" + assert person.relationships[0].annotations["rdfs:subPropertyOf"] == "rdfs:label" + + dumped = OssieSpec.load_yaml(spec.dump_yaml()).dump_dict() + assert dumped["annotations"] == doc["annotations"] + assert dumped["ontology"][0]["annotations"] == doc["ontology"][0]["annotations"] + assert dumped["ontology"][0]["relationships"][1]["annotations"] == ( + doc["ontology"][0]["relationships"][1]["annotations"] + ) + + +def test_role_accepts_annotations(): + relationship = OssieSpec.model_validate({ + "name": "test", + "ontology": [{ + "concept": "Person", + "type": "EntityType", + "relationships": [{ + "name": "knows", + "roles": [{"concept": "Person", "name": "acquaintance", "annotations": {"source": "foaf"}}], + }], + }], + }).ontology[0].relationships[0] + assert relationship.roles[0].annotations == {"source": "foaf"} diff --git a/examples/foaf_owl_import.yaml b/examples/foaf_owl_import.yaml new file mode 100644 index 000000000..2c03c6a78 --- /dev/null +++ b/examples/foaf_owl_import.yaml @@ -0,0 +1,65 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +version: 0.2.0.dev0 +name: foaf +description: > + Small ontology imported from the FOAF (Friend of a Friend) OWL vocabulary. + The importer writes RDF data that has a home in the spec to that field + (rdfs:comment becomes description) and preserves the rest (labels, axioms, + term status) in annotations, so the source can be round-tripped. This + importer keys annotations by the source predicate's qualified name; + that is the importer's convention, not a requirement of the spec. +prefixes: + foaf: "http://xmlns.com/foaf/0.1/" + rdfs: "http://www.w3.org/2000/01/rdf-schema#" + owl: "http://www.w3.org/2002/07/owl#" + dc: "http://purl.org/dc/elements/1.1/" + vs: "http://www.w3.org/2003/06/sw-vocab-status/ns#" +annotations: + dc:title: "Friend of a Friend (FOAF) vocabulary" +ontology: + - concept: Person + type: EntityType + description: "A person." + iri: foaf:Person + annotations: + rdfs:label: "Person" + owl:equivalentClass: "http://schema.org/Person" + vs:term_status: "stable" + relationships: + - name: name + description: "A name for some thing." + iri: foaf:name + roles: + - concept: String + verbalizes: [ "{Person} has name {String}" ] + multiplicity: ManyToOne + annotations: + rdfs:label: "name" + rdfs:subPropertyOf: "rdfs:label" + vs:term_status: "testing" + - name: knows + description: "A person known by this person (indicating some level of reciprocated interaction between the parties)." + iri: foaf:knows + roles: + - concept: Person + name: acquaintance + verbalizes: [ "{Person} knows {Person:acquaintance}" ] + annotations: + rdfs:label: "knows" + vs:term_status: "stable" diff --git a/ontology/ontology.json b/ontology/ontology.json index d6508d4e2..6af518f67 100644 --- a/ontology/ontology.json +++ b/ontology/ontology.json @@ -44,6 +44,9 @@ "$ref": "#/$defs/OntologyMap" } }, + "annotations": { + "$ref": "#/$defs/Annotations" + }, "prefixes": { "type": "object", "description": "Maps namespace prefixes to the IRIs they abbreviate, enabling QName expansion (e.g., 'foaf' -> 'http://xmlns.com/foaf/0.1/').", @@ -56,6 +59,11 @@ "required": ["version", "name", "ontology"], "additionalProperties": false, "$defs": { + "Annotations": { + "type": "object", + "description": "Open set of annotations for carrying data that is not covered by the core ontology spec, for example when importing an ontology from an external format such as OWL/RDF. Keys are free-form and values may be any JSON; the spec does not interpret their structure.", + "additionalProperties": true + }, "OntologyComponent": { "type": "object", "description": "Ontology component that defines a single concept and any relationships that are keyed primarily by that concept", @@ -106,6 +114,9 @@ }, "description": "Defines relationships that pertain primarily to the concept defined in this component" }, + "annotations": { + "$ref": "#/$defs/Annotations" + }, "iri": { "type": "string", "description": "Optional global identifier for this concept, expressed as a full IRI or as a QName (prefix:local) resolved against the ontology-level 'prefixes' map." @@ -161,6 +172,9 @@ }, "description": "Natural language expressions that verbalize this relationship" }, + "annotations": { + "$ref": "#/$defs/Annotations" + }, "iri": { "type": "string", "description": "Optional global identifier for this relationship, expressed as a full IRI or as a QName (prefix:local) resolved against the ontology-level 'prefixes' map." @@ -232,6 +246,9 @@ "name": { "type": "string", "description": "Optional name of this role, used when the same concept plays multiple roles in the same relationship" + }, + "annotations": { + "$ref": "#/$defs/Annotations" } }, "required": ["concept"], diff --git a/ontology/ontology.md b/ontology/ontology.md index 3f558c5af..f450b9560 100644 --- a/ontology/ontology.md +++ b/ontology/ontology.md @@ -90,6 +90,7 @@ hierarchically, grouping each relationship under the concept that plays its firs | `ontology` | list | Yes | Concepts and relationships they group that form this ontology | | `prefixes` | object | No | Namespace prefixes used to abbreviate [IRIs](#global-identifiers) | | `ontology_mappings` | list | No | Deprecated; accepted only so existing documents continue to validate. Write mappings as [mapping documents](#mapping-documents) instead | +| `annotations` | object | No | Open set of annotations for data not covered by this spec (see [Annotations](#annotations)) | Each component of an ontology declares a concept and lists the relationships where that concept plays the first role. The concept's name is the value of the `concept` field, and @@ -114,6 +115,7 @@ Concepts have the following schema: | `requires` | list | No | Expressions that constrain this concept's population | | `relationships` | list | No | Relationships where this concept plays the first role | | `iri` | string | No | Optional [global identifier](#global-identifiers) of this concept | +| `annotations` | object | No | Open set of annotations for data not covered by this spec (see [Annotations](#annotations)) | Each concept is either an entity type or a value type. @@ -159,6 +161,7 @@ Each relationship that is declared under a concept conforms to the following sch | `requires` | list | No | Expressions that constrain this relationship's population | | `verbalizes` | list | Yes | Patterns describing how to verbalize links | | `iri` | string | No | Optional [global identifier](#global-identifiers) of this relationship | +| `annotations` | object | No | Open set of annotations for data not covered by this spec (see [Annotations](#annotations)) | Each relationship is uniquely identified by prepending its declared name with that of the containing concept. For instance, in: @@ -202,6 +205,7 @@ using this schema: |-------|------|----------|-------------| | `concept` | string | Yes | Name of the concept that plays this role | | `name` | string | No | Optional role name | +| `annotations` | object | No | Open set of annotations for data not covered by this spec (see [Annotations](#annotations)) | For instance, in: @@ -437,6 +441,45 @@ Expressions, roles, and mappings continue to use local names. The IRI serves too between this specification and IRI-based languages and lets multiple ontologies state that they refer to the same externally defined concept or relationship. +### Annotations + +The ontology root, concepts, relationships, and roles may each carry an `annotations` object: an +open set of key-value pairs for data that the core spec does not model. This gives tools a place to +attach their own metadata, or to preserve information when importing an ontology from an external +format so that it can be round-tripped. + +Keys are free-form, and values may be any JSON. The spec does not interpret or constrain the structure of +`annotations`; tools that do not understand a given annotation should preserve it as-is. + +Use `annotations` only for data that the spec cannot express. Data that has a home in the spec, +such as a description, a supertype (`extends`), a derivation (`derived_by`), a constraint (`requires`), +or a verbalization (`verbalizes`), belongs in that field. + +For example, an importer from the OWL version of the [FOAF](http://xmlns.com/foaf/0.1/) vocabulary +writes `rdfs:comment` to `description` and keeps the RDF data that has no home in the spec, keyed by +the source predicate's qualified name: + +```yaml +prefixes: + foaf: "http://xmlns.com/foaf/0.1/" + rdfs: "http://www.w3.org/2000/01/rdf-schema#" + owl: "http://www.w3.org/2002/07/owl#" + vs: "http://www.w3.org/2003/06/sw-vocab-status/ns#" +ontology: + - concept: Person + type: EntityType + description: A person. + iri: foaf:Person + annotations: + rdfs:label: Person + owl:equivalentClass: http://schema.org/Person + vs:term_status: stable +``` + +The full document is in [`examples/foaf_owl_import.yaml`](../examples/foaf_owl_import.yaml). Keying +annotations by qualified name is this importer's convention. The choice of keys belongs to the tool that +writes them. + ## Ontology mappings Ontology mappings declare how to map the values of fields at the logical level to objects and links @@ -695,6 +738,8 @@ though `Store` plays a role in three of the relationships. - Optional IRIs on concepts and relationships, with namespace prefixes declared at the top level - Mapping documents (`ontology/mapping.json`) that reference one ontology and one semantic model; embedded `ontology_mappings` is deprecated + - `annotations` on the ontology root, concepts, relationships, and roles for carrying + data not covered by the core spec (e.g. when importing from OWL/RDF) ---