Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 15 additions & 0 deletions docs/ontology/namespace-compatibility.ttl
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
@prefix canonical: <https://contextualwisdomlab.github.io/lineageweave/ontology#> .
@prefix dcterms: <http://purl.org/dc/terms/> .
@prefix owl: <http://www.w3.org/2002/07/owl#> .
@prefix legacy: <https://contextualwisdomlab.github.io/LineageWeave/ontology#> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .

<https://contextualwisdomlab.github.io/LineageWeave/ontology>
a owl:Ontology ;
owl:deprecated "true"^^xsd:boolean ;
dcterms:isReplacedBy <https://contextualwisdomlab.github.io/lineageweave/ontology> .

canonical:Post owl:equivalentClass legacy:Post .
canonical:Person owl:equivalentClass legacy:Person .
canonical:CorporateEntity owl:equivalentClass legacy:CorporateEntity .
canonical:Team owl:equivalentClass legacy:Team .
Comment on lines +12 to +15

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Mappings are hand-authored, not generated from the graphs

ADR 0157 point 4 specifies compatibility mappings 'generated from the two parsed RDF graphs'. This PR ships a static hand-authored namespace-compatibility.ttl and only validates it against the graphs. The PR notes generation and migration remain follow-up, so confirm this slice's deviation is intended.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

7 changes: 7 additions & 0 deletions scripts/build_ontology_site.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -347,6 +348,7 @@ def _render_ontology_page(graph: Graph, source_sha256: str) -> tuple[str, int]:
'<a href="ontology.jsonld" type="application/ld+json">JSON-LD <small>generated equivalent</small></a>'
'<a href="ontology.nt" type="application/n-triples">N-Triples <small>generated equivalent</small></a>'
'<a href="prov-o-support-profile.ttl" type="text/turtle">PROV-O support profile</a>'
'<a href="namespace-compatibility.ttl" type="text/turtle">Deprecated namespace compatibility</a>'
'<a href="manifest.json" type="application/json">Build manifest</a>'
"</div></section>"
'<section class="summary-grid" aria-label="Ontology publication summary">'
Expand Down Expand Up @@ -395,6 +397,7 @@ def _write_manifest(
"generated_artifacts": [
"index.html",
"manifest.json",
"namespace-compatibility.ttl",
"ontology.jsonld",
"ontology.nt",
"ontology.ttl",
Expand All @@ -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(
Expand All @@ -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(
Expand Down
60 changes: 59 additions & 1 deletion scripts/publish_ontology_site.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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")
Comment on lines +104 to +132

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Deprecated term kinds resolved only against the PROV-O profile

validate_compatibility_graph receives profile_graph as its deprecated graph (publish_ontology_site.py). It passes today only because the four repository-case terms are defined in prov-o-support-profile.ttl via rdfs:subClassOf, which _term_kind entails to OWL.Class. Any future mapping whose deprecated term lives elsewhere is silently rejected as 'different term kinds' rather than 'undefined term'.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.



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()
Expand All @@ -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)
Expand Down
22 changes: 22 additions & 0 deletions tests/test_ontology_site.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<link rel="canonical" href="https://contextualwisdomlab.github.io/LineageWeave/ontology">' in html
Expand Down Expand Up @@ -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:
Expand All @@ -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",
Expand Down Expand Up @@ -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:
Expand Down
62 changes: 61 additions & 1 deletion tests/test_publish_ontology_site.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Loading