diff --git a/CHANGELOG.md b/CHANGELOG.md index a62b55836..c7ffe1440 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ All notable changes to this project are documented here. Format follows - ADR 0157 and its exact-head inventory choose the existing lowercase public ontology namespace as canonical and define the compatibility, publication, and migration evidence required by issue #372 without rewriting identifiers. +- The ontology Pages artifact now publishes the deprecated repository-case + compatibility vocabulary after validating every mapping's term kind. ### Fixed diff --git a/docs/ontology/namespace-compatibility.ttl b/docs/ontology/namespace-compatibility.ttl new file mode 100644 index 000000000..15da95f80 --- /dev/null +++ b/docs/ontology/namespace-compatibility.ttl @@ -0,0 +1,15 @@ +@prefix canonical: . +@prefix dcterms: . +@prefix owl: . +@prefix legacy: . +@prefix xsd: . + + + a owl:Ontology ; + owl:deprecated "true"^^xsd:boolean ; + dcterms:isReplacedBy . + +canonical:Post owl:equivalentClass legacy:Post . +canonical:Person owl:equivalentClass legacy:Person . +canonical:CorporateEntity owl:equivalentClass legacy:CorporateEntity . +canonical:Team owl:equivalentClass legacy:Team . diff --git a/scripts/build_ontology_site.py b/scripts/build_ontology_site.py index dc8f962d7..8df395a90 100644 --- a/scripts/build_ontology_site.py +++ b/scripts/build_ontology_site.py @@ -30,6 +30,7 @@ DOCUMENTATION_URL = f"{PUBLIC_BASE_URL}/ontology" SOURCE_RELATIVE_PATH = Path("docs/ontology/lineageweave-kg.ttl") PROV_PROFILE_RELATIVE_PATH = Path("docs/ontology/prov-o-support-profile.ttl") +COMPATIBILITY_RELATIVE_PATH = Path("docs/ontology/namespace-compatibility.ttl") TERM_TYPES: tuple[tuple[str, URIRef], ...] = ( ("Classes", OWL.Class), ("Object properties", OWL.ObjectProperty), @@ -347,6 +348,7 @@ def _render_ontology_page(graph: Graph, source_sha256: str) -> tuple[str, int]: 'JSON-LD generated equivalent' 'N-Triples generated equivalent' 'PROV-O support profile' + 'Deprecated namespace compatibility' 'Build manifest' "" '
' @@ -395,6 +397,7 @@ def _write_manifest( "generated_artifacts": [ "index.html", "manifest.json", + "namespace-compatibility.ttl", "ontology.jsonld", "ontology.nt", "ontology.ttl", @@ -417,10 +420,13 @@ def build_site(repository_root: Path, output_dir: Path) -> None: output = output_dir.resolve() source = root / SOURCE_RELATIVE_PATH prov_profile = root / PROV_PROFILE_RELATIVE_PATH + compatibility = root / COMPATIBILITY_RELATIVE_PATH if not source.is_file(): raise FileNotFoundError(f"ontology source is missing: {source}") if not prov_profile.is_file(): raise FileNotFoundError(f"PROV-O support profile is missing: {prov_profile}") + if not compatibility.is_file(): + raise FileNotFoundError(f"namespace compatibility vocabulary is missing: {compatibility}") if output.exists(): raise FileExistsError( @@ -439,6 +445,7 @@ def build_site(repository_root: Path, output_dir: Path) -> None: (ontology_dir / "index.html").write_text(ontology_html, encoding="utf-8") shutil.copyfile(source, ontology_dir / "ontology.ttl") shutil.copyfile(prov_profile, ontology_dir / "prov-o-support-profile.ttl") + shutil.copyfile(compatibility, ontology_dir / "namespace-compatibility.ttl") _write_serializations(graph, ontology_dir) _write_manifest(ontology_dir, source, graph, term_count) (output / "robots.txt").write_text( diff --git a/scripts/publish_ontology_site.py b/scripts/publish_ontology_site.py index 51bd03d8c..f42377e5c 100644 --- a/scripts/publish_ontology_site.py +++ b/scripts/publish_ontology_site.py @@ -17,7 +17,7 @@ from urllib.parse import urlsplit from rdflib import Graph, URIRef -from rdflib.namespace import RDF +from rdflib.namespace import OWL, RDF, RDFS, SKOS try: from scripts.ontology_site_contract import public_fragment @@ -27,6 +27,17 @@ OUTPUT_MARKER = ".lineageweave-ontology-site" SOURCE_RELATIVE_PATH = Path("docs/ontology/lineageweave-kg.ttl") PROV_PROFILE_RELATIVE_PATH = Path("docs/ontology/prov-o-support-profile.ttl") +COMPATIBILITY_RELATIVE_PATH = Path("docs/ontology/namespace-compatibility.ttl") +CANONICAL_NAMESPACE = "https://contextualwisdomlab.github.io/lineageweave/ontology#" +DEPRECATED_NAMESPACE = "https://contextualwisdomlab.github.io/LineageWeave/ontology#" + +_MAPPING_FOR_KIND = { + OWL.Class: OWL.equivalentClass, + OWL.ObjectProperty: OWL.equivalentProperty, + OWL.DatatypeProperty: OWL.equivalentProperty, + OWL.AnnotationProperty: OWL.equivalentProperty, + SKOS.Concept: SKOS.exactMatch, +} def _load_renderer(repository_root: Path) -> ModuleType: @@ -82,6 +93,45 @@ def validate_public_graph(graph: Graph, renderer: ModuleType) -> None: ) +def _term_kind(graph: Graph, subject: URIRef) -> URIRef | None: + """Return one supported RDF term kind, including entailed classes.""" + kinds = {kind for kind in _MAPPING_FOR_KIND if (subject, RDF.type, kind) in graph} + if any(graph.objects(subject, RDFS.subClassOf)): + kinds.add(OWL.Class) + return next(iter(kinds)) if len(kinds) == 1 else None + + +def validate_compatibility_graph( + canonical: Graph, + deprecated: Graph, + compatibility: Graph, +) -> None: + """Reject namespace mappings whose local name or RDF term kind differs.""" + mappings = { + (subject, predicate, target) + for predicate in set(_MAPPING_FOR_KIND.values()) + for subject, target in compatibility.subject_objects(predicate) + } + if not mappings: + raise ValueError("namespace compatibility vocabulary has no mappings") + for subject, predicate, target in mappings: + canonical_iri, deprecated_iri = str(subject), str(target) + if not canonical_iri.startswith(CANONICAL_NAMESPACE) or not deprecated_iri.startswith( + DEPRECATED_NAMESPACE + ): + raise ValueError("namespace compatibility mapping has an unexpected namespace") + if canonical_iri.removeprefix(CANONICAL_NAMESPACE) != deprecated_iri.removeprefix( + DEPRECATED_NAMESPACE + ): + raise ValueError("namespace compatibility mapping has different local names") + canonical_kind = _term_kind(canonical, subject) + deprecated_kind = _term_kind(deprecated, target) + if canonical_kind is None or canonical_kind != deprecated_kind: + raise ValueError("namespace compatibility mapping has different term kinds") + if _MAPPING_FOR_KIND[canonical_kind] != predicate: + raise ValueError("namespace compatibility mapping uses the wrong predicate") + + def _validate_output_directory(output_dir: Path, source: Path, profile: Path) -> Path: """Resolve an output path and ensure replacement cannot delete source data.""" requested = output_dir.expanduser() @@ -100,15 +150,23 @@ def publish_site(repository_root: Path, output_dir: Path) -> None: root = repository_root.resolve() source = root / SOURCE_RELATIVE_PATH profile = root / PROV_PROFILE_RELATIVE_PATH + compatibility_source = root / COMPATIBILITY_RELATIVE_PATH if not source.is_file(): raise FileNotFoundError(f"ontology source is missing: {source}") if not profile.is_file(): raise FileNotFoundError(f"PROV-O support profile is missing: {profile}") + if not compatibility_source.is_file(): + raise FileNotFoundError( + f"namespace compatibility vocabulary is missing: {compatibility_source}" + ) output = _validate_output_directory(output_dir, source, profile) renderer = _load_renderer(root) graph = Graph().parse(source, format="turtle") + profile_graph = Graph().parse(profile, format="turtle") + compatibility_graph = Graph().parse(compatibility_source, format="turtle") validate_public_graph(graph, renderer) + validate_compatibility_graph(graph, profile_graph, compatibility_graph) if output.exists(): shutil.rmtree(output) diff --git a/tests/test_ontology_site.py b/tests/test_ontology_site.py index 71aa19d5a..f09919188 100644 --- a/tests/test_ontology_site.py +++ b/tests/test_ontology_site.py @@ -66,6 +66,9 @@ def test_build_publishes_dereferenceable_html_and_machine_formats(tmp_path: Path ROOT / "docs" / "ontology" / "lineageweave-kg.ttl" ).read_bytes() assert (ontology_dir / "prov-o-support-profile.ttl").is_file() + assert (ontology_dir / "namespace-compatibility.ttl").read_bytes() == ( + ROOT / "docs" / "ontology" / "namespace-compatibility.ttl" + ).read_bytes() html = (ontology_dir / "index.html").read_text(encoding="utf-8") assert '' in html @@ -143,9 +146,16 @@ def test_serializations_round_trip_to_the_source_graph(tmp_path: Path) -> None: source = Graph().parse(ROOT / "docs" / "ontology" / "lineageweave-kg.ttl", format="turtle") jsonld = Graph().parse(output / "ontology" / "ontology.jsonld", format="json-ld") ntriples = Graph().parse(output / "ontology" / "ontology.nt", format="nt") + compatibility_source = Graph().parse( + ROOT / "docs" / "ontology" / "namespace-compatibility.ttl", format="turtle" + ) + compatibility_published = Graph().parse( + output / "ontology" / "namespace-compatibility.ttl", format="turtle" + ) assert isomorphic(source, jsonld) assert isomorphic(source, ntriples) + assert isomorphic(compatibility_source, compatibility_published) def test_build_is_byte_deterministic(tmp_path: Path) -> None: @@ -172,6 +182,7 @@ def test_metadata_manifest_has_source_digest_and_no_build_clock(tmp_path: Path) assert manifest["generated_artifacts"] == [ "index.html", "manifest.json", + "namespace-compatibility.ttl", "ontology.jsonld", "ontology.nt", "ontology.ttl", @@ -246,6 +257,17 @@ def test_builder_fails_closed_for_missing_sources_and_rejects_existing_output(tm (ontology_dir / "prov-o-support-profile.ttl").write_text("", encoding="utf-8") + try: + builder.build_site(repository, output) + except FileNotFoundError as exc: + assert "namespace compatibility" in str(exc) + else: + raise AssertionError("missing namespace compatibility vocabulary was accepted") + + (ontology_dir / "namespace-compatibility.ttl").write_bytes( + (ROOT / "docs" / "ontology" / "namespace-compatibility.ttl").read_bytes() + ) + try: builder.build_site(repository, output) except FileExistsError as exc: diff --git a/tests/test_publish_ontology_site.py b/tests/test_publish_ontology_site.py index c0c447276..07795a547 100644 --- a/tests/test_publish_ontology_site.py +++ b/tests/test_publish_ontology_site.py @@ -47,7 +47,11 @@ def _repository_fixture(tmp_path: Path) -> Path: scripts_dir = repository / "scripts" ontology_dir.mkdir(parents=True) scripts_dir.mkdir(parents=True) - for name in ("lineageweave-kg.ttl", "prov-o-support-profile.ttl"): + for name in ( + "lineageweave-kg.ttl", + "prov-o-support-profile.ttl", + "namespace-compatibility.ttl", + ): (ontology_dir / name).write_bytes((ROOT / "docs" / "ontology" / name).read_bytes()) (scripts_dir / "build_ontology_site.py").write_bytes( (ROOT / "scripts" / "build_ontology_site.py").read_bytes() @@ -154,6 +158,56 @@ def test_graph_validation_allows_http_relations_and_multiple_term_types() -> Non publisher.validate_public_graph(graph, renderer) +def test_compatibility_validation_is_term_kind_safe() -> None: + publisher = _load_publisher() + canonical = Graph().parse( + ROOT / "docs" / "ontology" / "lineageweave-kg.ttl", format="turtle" + ) + deprecated = Graph().parse( + ROOT / "docs" / "ontology" / "prov-o-support-profile.ttl", format="turtle" + ) + compatibility = Graph().parse( + ROOT / "docs" / "ontology" / "namespace-compatibility.ttl", format="turtle" + ) + + publisher.validate_compatibility_graph(canonical, deprecated, compatibility) + + post = URIRef(f"{publisher.CANONICAL_NAMESPACE}Post") + legacy_post = URIRef(f"{publisher.DEPRECATED_NAMESPACE}Post") + for broken, message in ( + (Graph(), "no mappings"), + ( + Graph().add((post, OWL.equivalentClass, URIRef("https://other.test/#Post"))), + "unexpected namespace", + ), + ( + Graph().add( + ( + post, + OWL.equivalentClass, + URIRef(f"{publisher.DEPRECATED_NAMESPACE}Person"), + ) + ), + "different local names", + ), + ): + with pytest.raises(ValueError, match=message): + publisher.validate_compatibility_graph(canonical, deprecated, broken) + + wrong_kind = Graph().add((post, RDF.type, OWL.ObjectProperty)) + with pytest.raises(ValueError, match="different term kinds"): + publisher.validate_compatibility_graph(wrong_kind, deprecated, compatibility) + + wrong_predicate = Graph().add((post, OWL.equivalentProperty, legacy_post)) + with pytest.raises(ValueError, match="wrong predicate"): + publisher.validate_compatibility_graph(canonical, deprecated, wrong_predicate) + + ambiguous = Graph() + ambiguous.add((post, RDF.type, OWL.Class)) + ambiguous.add((post, RDF.type, OWL.ObjectProperty)) + assert publisher._term_kind(ambiguous, post) is None + + def test_main_publishes_site(tmp_path: Path) -> None: publisher = _load_publisher() repository = _repository_fixture(tmp_path) @@ -209,6 +263,12 @@ def test_publication_fails_closed_for_missing_sources(tmp_path: Path) -> None: with pytest.raises(FileNotFoundError, match="PROV-O support profile"): publisher.publish_site(repository, output) + (ontology_dir / "prov-o-support-profile.ttl").write_bytes( + (ROOT / "docs" / "ontology" / "prov-o-support-profile.ttl").read_bytes() + ) + with pytest.raises(FileNotFoundError, match="namespace compatibility"): + publisher.publish_site(repository, output) + def test_module_entrypoint(tmp_path: Path, monkeypatch) -> None: import runpy