diff --git a/.github/workflows/converter-ontology-ci.yml b/.github/workflows/converter-ontology-ci.yml
index 2abd3de4b..b3fe56a43 100644
--- a/.github/workflows/converter-ontology-ci.yml
+++ b/.github/workflows/converter-ontology-ci.yml
@@ -25,6 +25,7 @@ on:
paths:
- 'converters/ontology/**'
- 'core-spec/**'
+ - 'ontology/**'
- 'examples/**'
- '.github/workflows/converter-ontology-ci.yml'
pull_request:
@@ -32,6 +33,7 @@ on:
paths:
- 'converters/ontology/**'
- 'core-spec/**'
+ - 'ontology/**'
- 'examples/**'
- '.github/workflows/converter-ontology-ci.yml'
@@ -64,4 +66,4 @@ jobs:
- name: Unit Tests
working-directory: converters/ontology
run: |
- uv run pytest
\ No newline at end of file
+ uv run pytest
diff --git a/.github/workflows/converter-orionbelt-ci.yml b/.github/workflows/converter-orionbelt-ci.yml
index 0b3aebf67..9c8e95e96 100644
--- a/.github/workflows/converter-orionbelt-ci.yml
+++ b/.github/workflows/converter-orionbelt-ci.yml
@@ -25,6 +25,7 @@ on:
paths:
- 'converters/orionbelt/**'
- 'core-spec/**'
+ - 'ontology/**'
- 'examples/**'
- '.github/workflows/converter-orionbelt-ci.yml'
pull_request:
@@ -32,6 +33,7 @@ on:
paths:
- 'converters/orionbelt/**'
- 'core-spec/**'
+ - 'ontology/**'
- 'examples/**'
- '.github/workflows/converter-orionbelt-ci.yml'
diff --git a/.github/workflows/validation-ci.yml b/.github/workflows/validation-ci.yml
index 00122a068..5c1299232 100644
--- a/.github/workflows/validation-ci.yml
+++ b/.github/workflows/validation-ci.yml
@@ -26,6 +26,8 @@ on:
- "validation/**"
- "python/**"
- "core-spec/**"
+ - "ontology/**"
+ - "examples/flights.yaml"
- "examples/tpcds_semantic_model.yaml"
- ".github/workflows/validation-ci.yml"
pull_request:
@@ -34,6 +36,8 @@ on:
- "validation/**"
- "python/**"
- "core-spec/**"
+ - "ontology/**"
+ - "examples/flights.yaml"
- "examples/tpcds_semantic_model.yaml"
- ".github/workflows/validation-ci.yml"
diff --git a/converters/ontology/src/ossie_ontology/converter/ossie_to_spec/converter.py b/converters/ontology/src/ossie_ontology/converter/ossie_to_spec/converter.py
index f6841f419..b63bf9a47 100644
--- a/converters/ontology/src/ossie_ontology/converter/ossie_to_spec/converter.py
+++ b/converters/ontology/src/ossie_ontology/converter/ossie_to_spec/converter.py
@@ -144,6 +144,7 @@ def _convert_relationship(rel: Relationship) -> SpecRelationship:
def _convert_semantic_model(semantic_model: SemanticModel) -> SpecSemanticModel:
return SpecSemanticModel(
+ version=semantic_model.version,
name=semantic_model.name,
description=semantic_model.description,
ai_context=semantic_model.ai_context,
diff --git a/converters/ontology/src/ossie_ontology/converter/palantir_to_ossie/converter.py b/converters/ontology/src/ossie_ontology/converter/palantir_to_ossie/converter.py
index c87ef7ac1..f1b606c77 100644
--- a/converters/ontology/src/ossie_ontology/converter/palantir_to_ossie/converter.py
+++ b/converters/ontology/src/ossie_ontology/converter/palantir_to_ossie/converter.py
@@ -151,7 +151,7 @@ def convert(
schema_name: str = "palantir",
) -> OssieOntology:
ontology = OntologyComponent()
- model = OssieOntology(name="Palantir model", ontology=ontology, version="0.1.0")
+ model = OssieOntology(name="Palantir model", ontology=ontology, version="0.2.0.dev0")
semantic_model = SemanticModel(name="Palantir semantic model")
diff --git a/converters/ontology/src/ossie_ontology/converter/spec_to_ossie/converter.py b/converters/ontology/src/ossie_ontology/converter/spec_to_ossie/converter.py
index 0b45ea8a4..75992421b 100644
--- a/converters/ontology/src/ossie_ontology/converter/spec_to_ossie/converter.py
+++ b/converters/ontology/src/ossie_ontology/converter/spec_to_ossie/converter.py
@@ -226,6 +226,7 @@ def _convert_relationship(
def _convert_semantic_model(self, lm_spec: SpecSemanticModel) -> SemanticModel:
semantic_model = SemanticModel(
+ version=lm_spec.version,
name=lm_spec.name,
description=lm_spec.description,
ai_context=lm_spec.ai_context,
diff --git a/converters/ontology/src/ossie_ontology/model.py b/converters/ontology/src/ossie_ontology/model.py
index 2f9d1e72d..21c722288 100644
--- a/converters/ontology/src/ossie_ontology/model.py
+++ b/converters/ontology/src/ossie_ontology/model.py
@@ -685,8 +685,8 @@ def custom_extensions(self) -> list[CustomExtension]:
class SemanticModel:
- """Bundle of datasets, join paths and metrics. One or more SemanticModels
- can feed a single OntologyMapping (see spec)."""
+ """Versioned core semantic model embedded in a single OntologyMapping."""
+ _version: str
_name: str
_description: str | None
_ai_context: AiContext | None
@@ -704,7 +704,13 @@ def __init__(
description: str | None = None,
ai_context: AiContext | None = None,
custom_extensions: list[CustomExtension] | None = None,
+ version: str = "0.2.0.dev0",
):
+ if version != "0.2.0.dev0":
+ raise ValueError(
+ f"Unsupported semantic model version {version!r}; expected '0.2.0.dev0'"
+ )
+ self._version = version
self._name = name
self._description = description
self._ai_context = ai_context
@@ -716,6 +722,10 @@ def __init__(
self._join_path_name_map = {}
self._metric_name_map = {}
+ @property
+ def version(self) -> str:
+ return self._version
+
@property
def name(self) -> str:
return self._name
diff --git a/converters/ontology/src/ossie_ontology/spec.py b/converters/ontology/src/ossie_ontology/spec.py
index e61ce52a4..f2a59012f 100644
--- a/converters/ontology/src/ossie_ontology/spec.py
+++ b/converters/ontology/src/ossie_ontology/spec.py
@@ -175,15 +175,12 @@ class Metric(OssieObject):
class SemanticModel(OssieObject):
- """Model contents: datasets plus the join paths and metrics defined over them.
-
- OntologyMapping embeds these contents under ``semantic_model``; standalone
- core documents place them at the root alongside document metadata.
- """
+ """Complete core document embedded under OntologyMapping.semantic_model."""
+ version: Literal["0.2.0.dev0"]
name: str
description: str | None = None
ai_context: AiContext | None = None
- datasets: list[Dataset] = Field(default_factory=list)
+ datasets: list[Dataset] = Field(min_length=1)
relationships: list[JoinPath] = Field(default_factory=list)
metrics: list[Metric] = Field(default_factory=list)
custom_extensions: list[CustomExtension] = Field(default_factory=list)
diff --git a/converters/ontology/tests/fixtures/flights.yaml b/converters/ontology/tests/fixtures/flights.yaml
index 803ff73d9..b3fb5a136 100644
--- a/converters/ontology/tests/fixtures/flights.yaml
+++ b/converters/ontology/tests/fixtures/flights.yaml
@@ -540,6 +540,7 @@ ontology:
ontology_mappings:
- name: flights_mapping
semantic_model:
+ version: 0.2.0.dev0
name: Flights semantic model
datasets:
- name: RUNWAY
diff --git a/converters/ontology/tests/snapshots/test_flights_snapshot/test_flights_roundtrip_yaml_snapshot/flights_roundtrip.yaml b/converters/ontology/tests/snapshots/test_flights_snapshot/test_flights_roundtrip_yaml_snapshot/flights_roundtrip.yaml
index 805fadef7..4c025815f 100644
--- a/converters/ontology/tests/snapshots/test_flights_snapshot/test_flights_roundtrip_yaml_snapshot/flights_roundtrip.yaml
+++ b/converters/ontology/tests/snapshots/test_flights_snapshot/test_flights_roundtrip_yaml_snapshot/flights_roundtrip.yaml
@@ -610,6 +610,7 @@ ontology:
ontology_mappings:
- name: flights_mapping
semantic_model:
+ version: 0.2.0.dev0
name: Flights semantic model
datasets:
- name: RUNWAY
diff --git a/converters/ontology/tests/test_ossie_parser.py b/converters/ontology/tests/test_ossie_parser.py
index f8e36e03a..d23808a2e 100644
--- a/converters/ontology/tests/test_ossie_parser.py
+++ b/converters/ontology/tests/test_ossie_parser.py
@@ -25,6 +25,7 @@
import pytest
import yaml
+from pydantic import ValidationError
from ossie_ontology.converter.ossie_to_spec.converter import (
OssieToSpecConverter,
@@ -49,6 +50,62 @@ def test_parse_returns_model_with_metadata(flights_model):
assert flights_model.description == "Ontology of flights into and out of airports."
+def test_embedded_core_document_version_survives_roundtrip(flights_model, tmp_path):
+ assert all(
+ mapping.semantic_model.version == "0.2.0.dev0"
+ for mapping in flights_model.ontology_mappings
+ )
+
+ exported = OssieToSpecConverter.convert(flights_model).dump_yaml()
+ document = yaml.safe_load(exported)
+ assert all(
+ mapping["semantic_model"]["version"] == "0.2.0.dev0"
+ for mapping in document["ontology_mappings"]
+ )
+
+ path = tmp_path / "roundtrip.yaml"
+ path.write_text(exported)
+ reparsed = OssieParser().parse(path)
+ assert all(
+ mapping.semantic_model.version == "0.2.0.dev0"
+ for mapping in reparsed.ontology_mappings
+ )
+
+
+@pytest.mark.parametrize("version", ["missing", "0.1.0", "0.2.0", None, 2])
+def test_parse_rejects_invalid_embedded_version(flights_path, tmp_path, version):
+ document = yaml.safe_load(flights_path.read_text())
+ model = document["ontology_mappings"][0]["semantic_model"]
+ if version == "missing":
+ del model["version"]
+ else:
+ model["version"] = version
+ path = tmp_path / "invalid.yaml"
+ path.write_text(yaml.safe_dump(document))
+
+ with pytest.raises(ValidationError) as exc:
+ OssieParser().parse(path)
+
+ assert exc.value.errors()[0]["loc"] == ("ontology_mappings", 0, "semantic_model", "version")
+
+
+@pytest.mark.parametrize("missing", [True, False], ids=["missing", "empty"])
+def test_parse_rejects_missing_or_empty_datasets(flights_path, tmp_path, missing):
+ document = yaml.safe_load(flights_path.read_text())
+ model = document["ontology_mappings"][0]["semantic_model"]
+ if missing:
+ del model["datasets"]
+ else:
+ model["datasets"] = []
+ path = tmp_path / "invalid.yaml"
+ path.write_text(yaml.safe_dump(document))
+
+ with pytest.raises(ValidationError) as exc:
+ OssieParser().parse(path)
+
+ assert exc.value.errors()[0]["loc"] == ("ontology_mappings", 0, "semantic_model", "datasets")
+
+
def test_parse_returns_populated_ontology(flights_model):
ontology = flights_model.ontology
# Built-in concepts (String, Integer, Decimal, ...) are always present on top
diff --git a/converters/ontology/tests/test_palantir_converter.py b/converters/ontology/tests/test_palantir_converter.py
index 3edd4980a..e7a37f8a5 100644
--- a/converters/ontology/tests/test_palantir_converter.py
+++ b/converters/ontology/tests/test_palantir_converter.py
@@ -277,6 +277,23 @@ def _identifier_columns(model: OssieOntology, concept_name: str) -> list[str]:
return columns
+# ----- document metadata ------------------------------------------------
+
+def test_generated_semantic_model_has_core_document_version(tmp_path: Path):
+ model = _convert(
+ tmp_path,
+ [_object_type("widget", "Widget", status="active")],
+ datasets=[_dataset("widget", ["widget_id"])],
+ )
+
+ document = OssieToSpecConverter.convert(model).dump_dict()
+
+ assert document["version"] == "0.2.0.dev0"
+ [mapping] = document["ontology_mappings"]
+ assert mapping["semantic_model"]["version"] == "0.2.0.dev0"
+ assert mapping["semantic_model"]["datasets"]
+
+
# ----- object type statuses ---------------------------------------------
def test_default_policy_admits_active_endorsed_and_intermediary(tmp_path: Path):
diff --git a/converters/ontology/tests/test_semantic_model.py b/converters/ontology/tests/test_semantic_model.py
new file mode 100644
index 000000000..66f9f05ac
--- /dev/null
+++ b/converters/ontology/tests/test_semantic_model.py
@@ -0,0 +1,62 @@
+# 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.
+
+"""Core document constraints at runtime construction and export."""
+
+import pytest
+from pydantic import ValidationError
+
+from ossie_ontology.converter.ossie_to_spec.converter import OssieToSpecConverter
+from ossie_ontology.model import (
+ Dataset,
+ OntologyComponent,
+ OntologyMapping,
+ OssieOntology,
+ SemanticModel,
+)
+
+
+@pytest.mark.parametrize("version", ["0.1.0", "0.2.0", "", None, 2])
+def test_rejects_unsupported_version_at_construction(version):
+ with pytest.raises(ValueError, match="Unsupported semantic model version"):
+ SemanticModel(name="sales", version=version)
+
+
+@pytest.mark.parametrize("version_args", [{}, {"version": "0.2.0.dev0"}])
+def test_requires_datasets_at_export_but_allows_incremental_construction(version_args):
+ model = SemanticModel(name="sales", **version_args)
+ ontology = OssieOntology(
+ name="sales", version="0.2.0.dev0", ontology=OntologyComponent()
+ )
+ ontology.add_ontology_mapping(
+ OntologyMapping(
+ name="sales_mapping", ontology=ontology.ontology, semantic_model=model
+ )
+ )
+
+ with pytest.raises(ValidationError) as exc:
+ OssieToSpecConverter.convert(ontology)
+ assert exc.value.errors()[0]["loc"] == ("datasets",)
+
+ model.add_dataset(Dataset(name="orders", source="sales.public.orders", fields=[]))
+ exported = OssieToSpecConverter.convert(ontology).dump_dict()
+
+ assert exported["ontology_mappings"][0]["semantic_model"] == {
+ "version": "0.2.0.dev0",
+ "name": "sales",
+ "datasets": [{"name": "orders", "source": "sales.public.orders"}],
+ }
diff --git a/converters/orionbelt/ossie_obml_ontology_mapping_analysis.md b/converters/orionbelt/ossie_obml_ontology_mapping_analysis.md
index 8e4ebd0e9..5d39e1828 100644
--- a/converters/orionbelt/ossie_obml_ontology_mapping_analysis.md
+++ b/converters/orionbelt/ossie_obml_ontology_mapping_analysis.md
@@ -68,7 +68,7 @@ ontology_mappings:
| `join.joinType` | `Relationship.multiplicity` | `many-to-one`→`ManyToOne`, `one-to-one`→`OneToOne` |
| `column.primaryKey` | entity `object_mappings[].expression` | `
.`; identifies the entity |
| `join.columnsFrom` (FK) | `link_mappings[].object_mapping.expression` | `.`; binds the relationship to its far role |
-| whole core model | `ontology_mappings[].semantic_model` | embedded from `OBMLtoOssie.convert()` without standalone document metadata (`version`, `dialects`, `vendors`) |
+| whole core model | `ontology_mappings[].semantic_model` | complete output from `OBMLtoOssie.convert()`|
`` is the final identifier of the dataset `source` (e.g. `db.schema.t` → `t`),
falling back to the dataset name when `source` has no dotted physical table.
diff --git a/converters/orionbelt/src/ossie_orionbelt/ontology.py b/converters/orionbelt/src/ossie_orionbelt/ontology.py
index 0dfac93c8..e5ee6897b 100644
--- a/converters/orionbelt/src/ossie_orionbelt/ontology.py
+++ b/converters/orionbelt/src/ossie_orionbelt/ontology.py
@@ -106,7 +106,6 @@ def convert(self) -> dict:
ai_instructions=self.ai_instructions,
)
sem_model = core_conv.convert()
- sem_model.pop("version", None)
self.warnings.extend(core_conv.warnings)
data_objects = self.obml.get("dataObjects", {}) or {}
diff --git a/converters/orionbelt/tests/test_ossie_converter_ontology.py b/converters/orionbelt/tests/test_ossie_converter_ontology.py
index 64933665d..930688951 100644
--- a/converters/orionbelt/tests/test_ossie_converter_ontology.py
+++ b/converters/orionbelt/tests/test_ossie_converter_ontology.py
@@ -101,13 +101,17 @@ def test_embeds_core_semantic_model(self) -> None:
omap = doc["ontology_mappings"][0]
assert omap["name"] == "sales_map"
assert "semantic_model" in omap
- assert not {"version", "dialects", "vendors"} & set(omap["semantic_model"])
+ assert omap["semantic_model"]["version"] == "0.2.0.dev0"
+ assert not {"dialects", "vendors"} & set(omap["semantic_model"])
assert omap["semantic_model"]["name"] == "sales"
assert {d["name"] for d in omap["semantic_model"]["datasets"]} == {
"Customers",
"Products",
"Orders",
}
+ result = conv.validate_ossie(omap["semantic_model"])
+ assert result.valid, result.schema_errors + result.semantic_errors
+ assert not result.semantic_warnings
def test_concept_mappings_bind_keys_and_fks(self) -> None:
doc = conv.OBMLtoOssieOntology(_OBML, model_name="sales").convert()
diff --git a/core-spec/spec.md b/core-spec/spec.md
index dce683616..c1bc8e578 100644
--- a/core-spec/spec.md
+++ b/core-spec/spec.md
@@ -150,10 +150,8 @@ model array cannot produce a valid model document. Preserve model contents and
custom extensions; never silently select only the first model or overwrite a file
when splitting a document.
-The reusable `$defs/SemanticModel` schema still describes model contents without
-standalone document metadata. In particular, an ontology map continues to embed
-those contents under its `semantic_model` property. This standalone document
-change does not rename or flatten that ontology property.
+For [ontology maps](../ontology/ontology.md#ontology-mappings), each embedded
+model must be a complete core document.
---
diff --git a/examples/flights.yaml b/examples/flights.yaml
index 803ff73d9..b3fb5a136 100644
--- a/examples/flights.yaml
+++ b/examples/flights.yaml
@@ -540,6 +540,7 @@ ontology:
ontology_mappings:
- name: flights_mapping
semantic_model:
+ version: 0.2.0.dev0
name: Flights semantic model
datasets:
- name: RUNWAY
diff --git a/ontology/ontology.json b/ontology/ontology.json
index 47a00e03f..60a316def 100644
--- a/ontology/ontology.json
+++ b/ontology/ontology.json
@@ -297,7 +297,7 @@
"description": "Human-readable description of this ontology map"
},
"semantic_model": {
- "$ref": "https://raw.githubusercontent.com/apache/ossie/main/core-spec/ossie-schema.json#/$defs/SemanticModel"
+ "$ref": "https://raw.githubusercontent.com/apache/ossie/main/core-spec/ossie-schema.json"
},
"concept_mappings": {
"type": "array",
diff --git a/ontology/ontology.md b/ontology/ontology.md
index 94002a5c1..fc2775bed 100644
--- a/ontology/ontology.md
+++ b/ontology/ontology.md
@@ -385,6 +385,22 @@ Ontology mappings declare how to map the values of fields at the logical level t
in the ontology. Just as ontologies are partitioned by concept, ontology maps partition into concept
mappings that group by some concept.
+Each mapping's `semantic_model` is a complete
+[core document](../core-spec/spec.md#semantic-model), with its own `version`,
+`name`, and at least one dataset. For example:
+
+```yaml
+ontology_mappings:
+ - name: sales_mapping
+ semantic_model:
+ version: 0.2.0.dev0
+ name: sales_analytics
+ datasets:
+ - name: orders
+ source: sales.public.orders
+ concept_mappings: []
+```
+
### Concept mappings
Each concept mapping declares how to populate a concept with objects and how to populate the relationships
@@ -588,6 +604,7 @@ though `Store` plays a role in three of the relationships.
## Version History
- **0.2.0.dev0** (2026-05-29): Basic support for ontologies and logical schema mappings
+ - Breaking: each ontology map's `semantic_model` now requires its own `version`.
- Core ontology structure: Concepts, relationships, and business rules (requires and derived_by)
- Schema mappings from one or more logical models into an ontology
diff --git a/python/README.md b/python/README.md
index 43f9e73b3..88a3b7f4e 100644
--- a/python/README.md
+++ b/python/README.md
@@ -29,8 +29,7 @@ flat shape. The former `semantic_model` wrapper is rejected. Unwrap old
single-model documents and split multi-model documents into separate files,
preserving model contents and setting `version` in each file before loading them.
-`OssieSemanticModel` remains available for embedded models, such as ontology
-components, which do not include document metadata.
+Use `OssieDocument` for models embedded in ontology maps.
## Development
diff --git a/validation/tests/test_ontology.py b/validation/tests/test_ontology.py
new file mode 100644
index 000000000..00aee8cc3
--- /dev/null
+++ b/validation/tests/test_ontology.py
@@ -0,0 +1,129 @@
+# 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.
+
+"""Validate ontology mappings against the local core document schema."""
+
+import json
+from pathlib import Path
+
+import pytest
+import yaml
+from jsonschema import Draft202012Validator
+from referencing import Registry, Resource
+
+_REPO = Path(__file__).parents[2]
+
+
+@pytest.fixture
+def ontology_validator() -> Draft202012Validator:
+ core = json.loads((_REPO / "core-spec/ossie-schema.json").read_text())
+ ontology = json.loads((_REPO / "ontology/ontology.json").read_text())
+ Draft202012Validator.check_schema(core)
+ Draft202012Validator.check_schema(ontology)
+ resource = Resource.from_contents(core)
+ # Resolve both the core schema's canonical ID and the retrieval URL used by
+ # ontology references locally. Registry never fetches over HTTP.
+ registry = Registry().with_resources(
+ [
+ (core["$id"], resource),
+ (
+ "https://raw.githubusercontent.com/apache/ossie/main/core-spec/ossie-schema.json",
+ resource,
+ ),
+ ]
+ )
+ return Draft202012Validator(ontology, registry=registry)
+
+
+def _ontology(semantic_model: object) -> dict:
+ return {
+ "version": "0.2.0.dev0",
+ "name": "sales",
+ "ai_context": {"instructions": "Use for sales analysis"},
+ "ontology": [{"concept": "Order", "type": "EntityType"}],
+ "ontology_mappings": [
+ {
+ "name": "sales_mapping",
+ "semantic_model": semantic_model,
+ "concept_mappings": [],
+ }
+ ],
+ }
+
+
+def _semantic_model() -> dict:
+ return {
+ "version": "0.2.0.dev0",
+ "name": "sales_model",
+ "datasets": [{"name": "orders", "source": "sales.public.orders"}],
+ }
+
+
+def test_ontology_accepts_complete_core_document(ontology_validator):
+ ontology_validator.validate(_ontology(_semantic_model()))
+
+
+@pytest.mark.parametrize("required_property", ["version", "name", "datasets"])
+def test_embedded_model_requires_core_document_properties(
+ ontology_validator, required_property
+):
+ model = _semantic_model()
+ del model[required_property]
+
+ errors = list(ontology_validator.iter_errors(_ontology(model)))
+
+ assert len(errors) == 1
+ assert errors[0].validator == "required"
+ assert required_property in errors[0].message
+ assert list(errors[0].absolute_path) == ["ontology_mappings", 0, "semantic_model"]
+
+
+@pytest.mark.parametrize("version", ["0.1.0", "0.2.0", "", None, 2])
+def test_embedded_model_rejects_invalid_version(ontology_validator, version):
+ model = _semantic_model()
+ model["version"] = version
+
+ errors = list(ontology_validator.iter_errors(_ontology(model)))
+
+ assert errors
+ assert all(
+ list(error.absolute_path) == ["ontology_mappings", 0, "semantic_model", "version"]
+ for error in errors
+ )
+
+
+@pytest.mark.parametrize(
+ "model",
+ [
+ {**_semantic_model(), "datasets": []},
+ {**_semantic_model(), "unexpected": True},
+ {"semantic_model": _semantic_model()},
+ [_semantic_model()],
+ ],
+)
+def test_embedded_model_rejects_invalid_document_shapes(ontology_validator, model):
+ assert not ontology_validator.is_valid(_ontology(model))
+
+
+def test_flights_embedded_models_validate_as_core_documents(ontology_validator):
+ flights = yaml.safe_load((_REPO / "examples/flights.yaml").read_text())
+
+ assert flights["ontology_mappings"]
+ for mapping in flights["ontology_mappings"]:
+ # Validate the embedded core documents without expanding this test to
+ # unrelated ontology concept and relationship constraints.
+ ontology_validator.validate(_ontology(mapping["semantic_model"]))
diff --git a/validation/tests/test_validate.py b/validation/tests/test_validate.py
index 8583c53c7..94dac610d 100644
--- a/validation/tests/test_validate.py
+++ b/validation/tests/test_validate.py
@@ -16,6 +16,7 @@
# under the License.
import json
+import urllib.request
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
@@ -67,14 +68,43 @@ def test_accepts_a_single_root_model(core_schema: dict) -> None:
assert _VALIDATE.validate_schema(document, core_schema) == []
+@pytest.fixture
+def offline(monkeypatch):
+ def reject_request(*args, **kwargs):
+ pytest.fail("Schema validation must not make HTTP requests")
+
+ monkeypatch.setattr(urllib.request, "urlopen", reject_request)
+ monkeypatch.setattr("jsonschema.validators.urlopen", reject_request, raising=False)
+
+
+@pytest.mark.parametrize("uri", [
+ "https://github.com/apache/ossie/core-spec/ossie-schema.json",
+ "https://raw.githubusercontent.com/apache/ossie/main/core-spec/ossie-schema.json",
+])
+@pytest.mark.parametrize("fragment, document", [
+ ("", _document([_ORDERS], [])),
+ ("#/$defs/AIContext", {"instructions": "Use for sales analysis"}),
+])
+def test_core_schema_references_resolve_offline(offline, uri, fragment, document):
+ assert _VALIDATE.validate_schema(document, {"$ref": uri + fragment}) == []
+
+
+def test_unresolvable_reference_is_a_validation_error(offline):
+ uri = "https://example.invalid/unknown-schema.json"
+
+ errors = _VALIDATE.validate_schema({}, {"$ref": uri})
+
+ assert errors == [f"[Schema] Cannot resolve schema reference: {uri}"]
+
+
def test_rejects_empty_root_datasets(core_schema: dict) -> None:
errors = _VALIDATE.validate_schema(_document([], []), core_schema)
assert errors == ["[Schema] datasets: [] should be non-empty"]
-def test_embedded_semantic_model_does_not_require_document_version(core_schema: dict) -> None:
- # Ontology components reference this definition without a document envelope.
+def test_semantic_model_definition_does_not_require_document_version(core_schema: dict) -> None:
+ # The reusable contents definition remains independent of document metadata.
embedded_schema = {
"$ref": "#/$defs/SemanticModel",
"$defs": core_schema["$defs"],
@@ -294,10 +324,13 @@ def test_skips_malformed_flat_unique_keys() -> None:
@pytest.fixture
def run_validator(tmp_path, monkeypatch, capsys):
- def run(document):
+ def run(document, schema_path=None):
model_path = tmp_path / "model.json"
model_path.write_text(json.dumps(document))
- monkeypatch.setattr(_VALIDATE.sys, "argv", [str(_VALIDATE_PATH), str(model_path)])
+ args = [str(_VALIDATE_PATH), str(model_path)]
+ if schema_path is not None:
+ args.extend(["--schema", str(schema_path)])
+ monkeypatch.setattr(_VALIDATE.sys, "argv", args)
with pytest.raises(SystemExit) as caught:
_VALIDATE.main()
return caught.value.code, capsys.readouterr().out
@@ -305,6 +338,31 @@ def run(document):
return run
+@pytest.mark.parametrize("version, expected_exit", [("0.2.0.dev0", 0), ("0.1.0", 1)])
+def test_ontology_cli_validates_embedded_documents_offline(
+ run_validator, offline, version, expected_exit
+):
+ model = _document([_ORDERS], [])
+ model["version"] = version
+ document = {
+ "version": "0.2.0.dev0",
+ "name": "sales",
+ "ai_context": {"instructions": "Use for sales analysis"},
+ "ontology": [{"concept": "Order", "type": "EntityType"}],
+ "ontology_mappings": [{"semantic_model": model, "concept_mappings": []}],
+ }
+
+ exit_code, output = run_validator(
+ document, Path(__file__).parents[2] / "ontology/ontology.json"
+ )
+
+ assert exit_code == expected_exit
+ if expected_exit == 0:
+ assert "Validation PASSED" in output
+ else:
+ assert "ontology_mappings -> 0 -> semantic_model -> version" in output
+
+
@pytest.mark.parametrize("target", [
"missing_customers",
"Warning: missing_customers",
diff --git a/validation/validate.py b/validation/validate.py
index 99b390c6f..8e7af189c 100644
--- a/validation/validate.py
+++ b/validation/validate.py
@@ -50,6 +50,8 @@
try:
import yaml
from jsonschema import Draft202012Validator
+ from referencing import Registry, Resource
+ from referencing.exceptions import Unresolvable
from yaml.constructor import ConstructorError
except ImportError:
print("Missing dependencies. Install with:")
@@ -146,12 +148,26 @@ def _check_unique_keys(self, node: yaml.Node, visited: set) -> None:
def validate_schema(data: dict, schema: dict) -> list[str]:
- """Validate against JSON Schema."""
- validator = Draft202012Validator(schema)
+ """Validate against JSON Schema, resolving core references locally."""
+ core_path = Path(__file__).parent.parent / "core-spec" / "ossie-schema.json"
+ core = json.loads(core_path.read_text())
+ resource = Resource.from_contents(core)
+ # Ontology references use the raw URL; also register the canonical schema ID.
+ registry = Registry().with_resources([
+ (core["$id"], resource),
+ (
+ "https://raw.githubusercontent.com/apache/ossie/main/core-spec/ossie-schema.json",
+ resource,
+ ),
+ ])
+ validator = Draft202012Validator(schema, registry=registry)
errors = []
- for error in validator.iter_errors(data):
- path = " -> ".join(str(p) for p in error.absolute_path) if error.absolute_path else "(root)"
- errors.append(f"[Schema] {path}: {error.message}")
+ try:
+ for error in validator.iter_errors(data):
+ path = " -> ".join(str(p) for p in error.absolute_path) if error.absolute_path else "(root)"
+ errors.append(f"[Schema] {path}: {error.message}")
+ except Unresolvable as error:
+ errors.append(f"[Schema] Cannot resolve schema reference: {error.ref}")
return errors