From 5eb160860456286c9ab598f584b6b499b767d2bd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 13:37:55 +0000 Subject: [PATCH 1/2] feat: publish ontology namespace compatibility map (v2.16.0) Keep the lowercase KG namespace canonical. Map only the four matching PROV-O support-profile classes with owl:equivalentClass. Fail closed on unknown aliases. Do not rewrite historical evidence (ADR 0158 / #372). --- AGENTS.md | 6 + ARCHITECTURE.md | 1 + ...2.16.0-ontology-namespace-compatibility.md | 10 + CHANGELOG.md | 16 + .../0158-ontology-namespace-compatibility.md | 117 ++++++ docs/adr/README.md | 2 + .../ONTOLOGY_NAMESPACE_REFERENCES.md | 30 ++ docs/ontology/namespace-compatibility.ttl | 65 ++++ lineageweave/__init__.py | 2 +- lineageweave/ontology.py | 19 +- lineageweave/ontology_namespace.py | 354 ++++++++++++++++++ pyproject.toml | 2 +- tests/test_ontology_namespace.py | 233 ++++++++++++ 13 files changed, 854 insertions(+), 3 deletions(-) create mode 100644 CHANGELOG.d/2.16.0-ontology-namespace-compatibility.md create mode 100644 docs/adr/0158-ontology-namespace-compatibility.md create mode 100644 docs/doctoring/ONTOLOGY_NAMESPACE_REFERENCES.md create mode 100644 docs/ontology/namespace-compatibility.ttl create mode 100644 lineageweave/ontology_namespace.py create mode 100644 tests/test_ontology_namespace.py diff --git a/AGENTS.md b/AGENTS.md index 1728f9e61..eb24afa52 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -246,3 +246,9 @@ add a narrow, documented suppression referencing the specific finding. - Appendix B inverse names normalize to the preferred W3C direction; do not proliferate private inverse vocabulary. - Keep `knowledge_graph_edge` an explicit navigation projection. +- Public ontology IRIs use the canonical lowercase namespace + `https://contextualwisdomlab.github.io/lineageweave/ontology#` + (ADR 0158). The repository-case Pages prefix is a compatibility + alias for four matching classes only. Do not mint new + repository-case term IRIs. Do not treat a GitHub Pages redirect as + RDF identity. Do not rewrite historical lowercase evidence. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d0280ff97..8d57b6be8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -79,6 +79,7 @@ flowchart LR | `post_chat.py` | Pluggable in-popup chat's reason-and-cite step (retrieve step lives in `backend/app/post_chat_ingestion.py`) | | `commitment_extraction.py` | Pluggable LLM derivation of a customer commitment (promise + deadline) from a post; `Null` default, `ContextualOrchestrator` real impl | | `ontology.py` | Loads `docs/ontology/lineageweave-kg.ttl`, the formal OWL 2/RDFS/SKOS vocabulary for the Knowledge Graph's node/edge types (ADR 0004) | +| `ontology_namespace.py` | Canonical vs repository-case namespace inventory and fail-closed class alias map (ADR 0158 / #372) | | `period_report.py` | Fit GRM/GPCM on persisted IRT rows, FIPC-select, EAP-score a period (ADR 0003 slice 3; Bock & Mislevy, 1982) | | `fixtures.py` | Synthetic demo dataset -- no real data ships in this repo | | `server.py` | Legacy stdlib HTTP server for the library-level synthetic fixture demo; production uses FastAPI/PostgreSQL | diff --git a/CHANGELOG.d/2.16.0-ontology-namespace-compatibility.md b/CHANGELOG.d/2.16.0-ontology-namespace-compatibility.md new file mode 100644 index 000000000..edf50ede2 --- /dev/null +++ b/CHANGELOG.d/2.16.0-ontology-namespace-compatibility.md @@ -0,0 +1,10 @@ +# 2.16.0 — ontology namespace compatibility + +Canonical KG namespace stays +`https://contextualwisdomlab.github.io/lineageweave/ontology#`. +The repository-case PROV-O profile prefix remains a documentation alias. +`docs/ontology/namespace-compatibility.ttl` maps only the four matching +classes (`Post`, `Person`, `CorporateEntity`, `Team`) with +`owl:equivalentClass`. Both documents are specified as HTTP 200. A Pages +redirect is not RDF identity. Historical lowercase IRIs are not rewritten +(ADR 0158 / #372). diff --git a/CHANGELOG.md b/CHANGELOG.md index c8ed1a099..26a32d7db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,24 @@ All notable changes to this project are documented here. Format follows environment, so local OIDC and synthetic-data workflows resolve the same pinned dependencies as CI. +## [2.16.0] - 2026-08-23 + +### Added + +- Canonical knowledge-graph namespace remains + `https://contextualwisdomlab.github.io/lineageweave/ontology#`. + The repository-case PROV-O support-profile prefix stays a documentation + alias. `docs/ontology/namespace-compatibility.ttl` maps only the four + matching classes (`Post`, `Person`, `CorporateEntity`, `Team`) with + `owl:equivalentClass`. Both documents are specified as HTTP 200. A + GitHub Pages redirect is not RDF identity. Historical lowercase IRIs + are not rewritten. After `make seed`, Demo Analyst still sees the same + ontology IRIs on related-node chips; RDF consumers load the + compatibility file for the four class aliases (ADR 0158). + ## [2.12.6] - 2026-08-20 + ### Added - Production OIDC can now use a real Keyverse issuer through diff --git a/docs/adr/0158-ontology-namespace-compatibility.md b/docs/adr/0158-ontology-namespace-compatibility.md new file mode 100644 index 000000000..cfc963179 --- /dev/null +++ b/docs/adr/0158-ontology-namespace-compatibility.md @@ -0,0 +1,117 @@ +# ADR 0158 — Implement canonical lowercase ontology namespace compatibility + +**Decision status:** Accepted +**Date:** 2026-08-23 +**Issue:** [#372](https://github.com/ContextualWisdomLab/LineageWeave/issues/372) + +## Context + +GitHub Pages project paths are case-sensitive. Committed artifacts currently +use two distinct public namespace forms: + +- Knowledge-graph ontology and runtime lookup: + `https://contextualwisdomlab.github.io/lineageweave/ontology#` +- PROV-O support profile: + `https://contextualwisdomlab.github.io/LineageWeave/ontology#` + +RDF 1.1 treats IRI equality as character-for-character identity, so a +case difference is a different resource (Cyganiak, Wood, & Lanthaler, +2014, section 3.2). An HTTP redirect from the repository-case Pages path +is a documentation convenience, not `owl:sameAs`. + +The lowercase namespace is already on the runtime `LW` constant, API +payloads, frontend fixtures, and persisted lookup IRIs. Silently rewriting +those strings would change historical evidence. + +The PROV-O support profile only declares four class alignments (`Post`, +`Person`, `CorporateEntity`, `Team`). Those local names are also +`owl:Class` in the KG ontology, so a typed compatibility map is valid. +Other KG fragments (`OurSidePerson`, `mentionedIn`, `Project`, …) have no +matching repository-case term. + +PR #491 records the identity decision as ADR 0157. This record is the +implementation slice: compatibility vocabulary, fail-closed resolver, +documented HTTP 200 for both documents, and tests. It does not depend +on #491 merging and does not rewrite stored rows. + +## Decision + +1. Keep + `https://contextualwisdomlab.github.io/lineageweave/ontology#` + as the **canonical** knowledge-graph namespace for runtime, API, and + new persisted evidence. New producers must not mint repository-case + term IRIs. +2. Keep + `https://contextualwisdomlab.github.io/LineageWeave/prov-o-support` + as the PROV-O support-profile document IRI. Do not merge that document + with the KG ontology document. +3. Publish `docs/ontology/namespace-compatibility.ttl` mapping the four + matching **classes** with `owl:equivalentClass`. Mappings are generated + from the two parsed RDF graphs and emitted only when local-name + uniqueness, term kind, and meaning match. Do not claim + `owl:equivalentProperty` or `owl:equivalentClass` for unmatched + fragments. Do not claim `owl:sameAs` between the two ontology + documents; `skos:closeMatch` records documentation proximity only. +4. Provide a fail-closed resolver (`canonical_iri` / + `migrate_stored_iri`) that is identity on historical KG IRIs, maps + only the four class aliases, and raises on unknown aliases. The + resolver is opt-in tooling. It does not rewrite database rows. +5. Document HTTP behavior: both namespace documents are specified as + `200 OK`. In-repository Turtle is the source of truth. A Pages + redirect must not be treated as RDF identity. Live dereference of + GitHub Pages is out of band for CI. +6. Do not rewrite rows already stored under the canonical namespace. + Bulk migration of the four class aliases is opt-in, idempotent, and + never invents a term. + +Deprecation window: the repository-case class IRIs remain published +for at least 180 days and two minor releases, whichever is later, so +existing PROV-O profile consumers can load the compatibility +vocabulary. Dereferenceability and mappings are not removed at the +end of that window. After that window, new terms are canonical-only. + +## Consequences + +- RDF consumers that load the compatibility file can treat the four class + pairs as the same class without collapsing the two ontology documents. +- Buyers inspecting persisted IRIs continue to see the historical + lowercase form. +- Stacked ontology-explorer work (issue #349) must consume the canonical + namespace and the compatibility file rather than minting a third prefix. +- Relational `post_project_mention.ontology_iri` and + `provenance_resource.resource_iri` rows are not inspected or rewritten + in this slice. Unknown downstream stores are treated as real + compatibility obligations. + +## Related decisions + +- [ADR 0004](0004-knowledge-graph-ontology.md): ontology vocabulary authority. +- [ADR 0011](0011-prov-o-standard-relations.md): PROV-O support profile. +- [ADR 0065](0065-prov-o-provenance-boundary.md): provenance/navigation + separation. +- PR #491 / ADR 0157: identity decision (docs-only; may land separately). + +## References + +Cyganiak, R., Wood, D., & Lanthaler, M. (Eds.). (2014). *RDF 1.1 concepts +and abstract syntax*. World Wide Web Consortium. +https://www.w3.org/TR/rdf11-concepts/ + +GitHub. (2024). *About GitHub Pages*. GitHub Docs. +https://docs.github.com/en/pages/getting-started-with-github-pages/about-github-pages + +Miles, A., & Bechhofer, S. (Eds.). (2009). *SKOS simple knowledge +organization system reference*. World Wide Web Consortium. +https://www.w3.org/TR/skos-reference/ + +Sauermann, L., & Cyganiak, R. (2008). *Cool URIs for the Semantic Web* +(W3C Interest Group Note). World Wide Web Consortium. +https://www.w3.org/TR/cooluris/ + +W3C OWL Working Group. (2012). *OWL 2 web ontology language document +overview* (2nd ed.). World Wide Web Consortium. +https://www.w3.org/TR/owl2-overview/ + +World Wide Web Consortium. (2008). *Best practice recipes for publishing +RDF vocabularies* (W3C Working Group Note). +https://www.w3.org/TR/swbp-vocab-pub/ diff --git a/docs/adr/README.md b/docs/adr/README.md index 762f1c051..9f1b45af1 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -13,6 +13,8 @@ decision from them. | [`lineage-bi-research-notes.md`](../lineage-bi-research-notes.md) | [0084](0084-lineage-research-grounding.md), [0062](0062-semantic-unit-embedding.md), [0064](0064-lineage-evidence-and-tree-assembly.md) | | [`PROV_O_IMPLEMENTATION.md`](../PROV_O_IMPLEMENTATION.md) | [0065](0065-prov-o-provenance-boundary.md) | | [`PROV_O_IMPLEMENTATION_MATRIX.md`](../PROV_O_IMPLEMENTATION_MATRIX.md) | [0065](0065-prov-o-provenance-boundary.md) | +| [`ONTOLOGY_NAMESPACE_REFERENCES.md`](../doctoring/ONTOLOGY_NAMESPACE_REFERENCES.md) | [0158](0158-ontology-namespace-compatibility.md) | +| [`namespace-compatibility.ttl`](../ontology/namespace-compatibility.ttl) | [0158](0158-ontology-namespace-compatibility.md) | | [`image-content-schema.md`](../image-content-schema.md) | [0066](0066-position-preserving-image-content.md) | Runtime evidence under `docs/doctoring/` is not converted into an ADR: it diff --git a/docs/doctoring/ONTOLOGY_NAMESPACE_REFERENCES.md b/docs/doctoring/ONTOLOGY_NAMESPACE_REFERENCES.md new file mode 100644 index 000000000..447621240 --- /dev/null +++ b/docs/doctoring/ONTOLOGY_NAMESPACE_REFERENCES.md @@ -0,0 +1,30 @@ +# Ontology namespace compatibility references (ADR 0158) + +APA 7th edition. These sources ground the canonical-vs-Pages namespace +implementation. They are not a live Zotero sync. + +Cyganiak, R., Wood, D., & Lanthaler, M. (Eds.). (2014). *RDF 1.1 concepts +and abstract syntax*. World Wide Web Consortium. +https://www.w3.org/TR/rdf11-concepts/ + +GitHub. (2024). *About GitHub Pages*. GitHub Docs. +https://docs.github.com/en/pages/getting-started-with-github-pages/about-github-pages + +Miles, A., & Bechhofer, S. (Eds.). (2009). *SKOS simple knowledge +organization system reference*. World Wide Web Consortium. +https://www.w3.org/TR/skos-reference/ + +Sauermann, L., & Cyganiak, R. (2008). *Cool URIs for the Semantic Web* +(W3C Interest Group Note). World Wide Web Consortium. +https://www.w3.org/TR/cooluris/ + +W3C OWL Working Group. (2012). *OWL 2 web ontology language document +overview* (2nd ed.). World Wide Web Consortium. +https://www.w3.org/TR/owl2-overview/ + +World Wide Web Consortium. (2008). *Best practice recipes for publishing +RDF vocabularies* (W3C Working Group Note). +https://www.w3.org/TR/swbp-vocab-pub/ + +World Wide Web Consortium. (2013). *PROV-O: The PROV ontology*. +https://www.w3.org/TR/prov-o/ diff --git a/docs/ontology/namespace-compatibility.ttl b/docs/ontology/namespace-compatibility.ttl new file mode 100644 index 000000000..cb62c4ba7 --- /dev/null +++ b/docs/ontology/namespace-compatibility.ttl @@ -0,0 +1,65 @@ +@prefix : . +@prefix compat: . +@prefix dcterms: . +@prefix owl: . +@prefix rdf: . +@prefix rdfs: . +@prefix skos: . +@prefix xsd: . +@prefix pages: . + +################################################################# +# LineageWeave ontology namespace compatibility vocabulary (ADR 0158) +# +# Canonical KG namespace (runtime, persisted evidence): +# https://contextualwisdomlab.github.io/lineageweave/ontology# +# Repository-case PROV-O support-profile prefix (documentation path): +# https://contextualwisdomlab.github.io/LineageWeave/ontology# +# +# GitHub Pages project paths are case-sensitive. A documentation HTTP +# redirect is NOT RDF identity (RDF 1.1 Concepts §3.2). Both documents +# are specified as HTTP 200. This file maps only the four class terms +# that share local name AND term kind. It does not claim owl:sameAs +# between the KG ontology document and the PROV-O support profile, and +# it does not claim owl:equivalentClass or owl:equivalentProperty for +# unmatched fragments. +################################################################# + + + a owl:Ontology ; + dcterms:title "LineageWeave ontology namespace compatibility"@en ; + rdfs:comment "Compatibility vocabulary between the canonical lowercase KG namespace and the repository-case PROV-O support-profile prefix. A Pages redirect is not RDF identity."@en ; + owl:imports , + ; + dcterms:conformsTo , + , + . + +# The two *documents* remain distinct resources. SKOS closeMatch records +# that both describe LineageWeave ontology documentation without claiming +# the KG vocabulary and the PROV-O support profile are the same ontology. + + skos:closeMatch . + +# Compatible classes: same local name, both classes, same LineageWeave +# concept. owl:equivalentClass is valid here (OWL 2, matching term kind). +:Post owl:equivalentClass pages:Post . +:Person owl:equivalentClass pages:Person . +:CorporateEntity owl:equivalentClass pages:CorporateEntity . +:Team owl:equivalentClass pages:Team . + +# Explicit non-mappings. These local names exist only in the KG +# namespace. Publishing owl:equivalentClass for them against a +# repository-case IRI would invent a PROV-O profile term. +compat:unmappedLocalName a owl:AnnotationProperty ; + rdfs:label "unmapped local name" ; + rdfs:comment "A KG local name that must not be treated as equivalent to a repository-case fragment of the same spelling when that fragment is absent or of a different term kind." . + +:OurSidePerson compat:unmappedLocalName "OurSidePerson" . +:CounterpartyPerson compat:unmappedLocalName "CounterpartyPerson" . +:RoleActorPerson compat:unmappedLocalName "RoleActorPerson" . +:RoleActorOrganization compat:unmappedLocalName "RoleActorOrganization" . +:RoleActorTeam compat:unmappedLocalName "RoleActorTeam" . +:Project compat:unmappedLocalName "Project" . +:mentionedIn compat:unmappedLocalName "mentionedIn" . +:affiliatedWith compat:unmappedLocalName "affiliatedWith" . diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 95330cb50..6b3b6e610 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "2.12.6" +__version__ = "2.16.0" diff --git a/lineageweave/ontology.py b/lineageweave/ontology.py index f7edf76e6..039e20678 100644 --- a/lineageweave/ontology.py +++ b/lineageweave/ontology.py @@ -14,6 +14,10 @@ `tests/test_ontology.py` is the real correctness check: it loads the same file with `rdflib` and asserts every lookup code the relational schema actually defines has a matching ontology term, and vice versa. + +Public ontology IRIs stay on the lowercase namespace (ADR 0158). The +repository-case Pages prefix is a compatibility alias for four matching +classes only; see `ontology_namespace.py`. """ from __future__ import annotations @@ -24,9 +28,17 @@ from rdflib.namespace import OWL, RDF, RDFS, SKOS from rdflib.term import Identifier +from .ontology_namespace import ( + CANONICAL_ONTOLOGY_NAMESPACE, + COMPATIBLE_CLASS_LOCAL_NAMES, + DEPRECATED_PAGES_ONTOLOGY_NAMESPACE, + canonical_iri, + migrate_stored_iri, +) + #: The ontology's own namespace -- every class/property IRI below is #: this prefix plus the term's local name (e.g. LW.Post). -LW = Namespace("https://contextualwisdomlab.github.io/lineageweave/ontology#") +LW = Namespace(CANONICAL_ONTOLOGY_NAMESPACE) #: The custom annotation property linking an ontology term to the exact #: `common_lookup_value.lookup_code` string it corresponds to. @@ -97,6 +109,9 @@ def all_declared_lookup_codes() -> set[str]: __all__ = [ + "CANONICAL_ONTOLOGY_NAMESPACE", + "COMPATIBLE_CLASS_LOCAL_NAMES", + "DEPRECATED_PAGES_ONTOLOGY_NAMESPACE", "LOOKUP_CODE", "LW", "ONTOLOGY", @@ -105,7 +120,9 @@ def all_declared_lookup_codes() -> set[str]: "RDFS", "SKOS", "all_declared_lookup_codes", + "canonical_iri", "iri_for_lookup_code", "load_ontology", + "migrate_stored_iri", "ontology_annotations", ] diff --git a/lineageweave/ontology_namespace.py b/lineageweave/ontology_namespace.py new file mode 100644 index 000000000..a0dd577a1 --- /dev/null +++ b/lineageweave/ontology_namespace.py @@ -0,0 +1,354 @@ +"""Canonical vs repository-case public ontology namespace (ADR 0158). + +GitHub Pages project paths are case-sensitive (GitHub, 2024). This +repository therefore currently publishes two distinct IRI prefixes: + +- Canonical knowledge-graph namespace used by runtime lookup, API + payloads, and persisted evidence: + ``https://contextualwisdomlab.github.io/lineageweave/ontology#`` +- Repository-case PROV-O support-profile prefix used by + ``docs/ontology/prov-o-support-profile.ttl``: + ``https://contextualwisdomlab.github.io/LineageWeave/ontology#`` + +A documentation HTTP redirect is not RDF identity (Cyganiak, Wood, & +Lanthaler, 2014, section 3.2). Both namespace documents are documented +as ``200 OK`` publications; the lowercase document is authoritative. +This module inventories both prefixes, maps only class terms that share +a local name *and* term kind, and never rewrites stored evidence. + +References +---------- +Cyganiak, R., Wood, D., & Lanthaler, M. (Eds.). (2014). *RDF 1.1 +concepts and abstract syntax*. World Wide Web Consortium. +https://www.w3.org/TR/rdf11-concepts/ + +GitHub. (2024). *About GitHub Pages*. GitHub Docs. +https://docs.github.com/en/pages/getting-started-with-github-pages/about-github-pages + +Miles, A., & Bechhofer, S. (Eds.). (2009). *SKOS simple knowledge +organization system reference*. World Wide Web Consortium. +https://www.w3.org/TR/skos-reference/ + +Sauermann, L., & Cyganiak, R. (2008). *Cool URIs for the Semantic Web* +(W3C Interest Group Note). World Wide Web Consortium. +https://www.w3.org/TR/cooluris/ + +W3C OWL Working Group. (2012). *OWL 2 web ontology language document +overview* (2nd ed.). World Wide Web Consortium. +https://www.w3.org/TR/owl2-overview/ +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Literal + +from rdflib import Graph, URIRef +from rdflib.namespace import OWL, RDF, RDFS + +CANONICAL_ONTOLOGY_NAMESPACE = ( + "https://contextualwisdomlab.github.io/lineageweave/ontology#" +) +DEPRECATED_PAGES_ONTOLOGY_NAMESPACE = ( + "https://contextualwisdomlab.github.io/LineageWeave/ontology#" +) +CANONICAL_ONTOLOGY_DOCUMENT_IRI = ( + "https://contextualwisdomlab.github.io/lineageweave/ontology" +) +DEPRECATED_PAGES_ONTOLOGY_DOCUMENT_IRI = ( + "https://contextualwisdomlab.github.io/LineageWeave/ontology" +) +PROV_O_SUPPORT_DOCUMENT_IRI = ( + "https://contextualwisdomlab.github.io/LineageWeave/prov-o-support" +) + +#: Local names that are owl:Class (or rdfs:subClassOf, which entails a +#: class) in *both* the KG ontology and the PROV-O support profile. +COMPATIBLE_CLASS_LOCAL_NAMES = frozenset( + {"Post", "Person", "CorporateEntity", "Team"} +) + +_REPO_ROOT = Path(__file__).resolve().parents[1] +_KG_PATH = _REPO_ROOT / "docs" / "ontology" / "lineageweave-kg.ttl" +_PROV_O_PROFILE_PATH = ( + _REPO_ROOT / "docs" / "ontology" / "prov-o-support-profile.ttl" +) +_COMPATIBILITY_PATH = ( + _REPO_ROOT / "docs" / "ontology" / "namespace-compatibility.ttl" +) + +TermKind = Literal[ + "class", + "object_property", + "datatype_property", + "annotation_property", + "concept", + "other", +] + + +@dataclass(frozen=True, slots=True) +class NamespaceTerm: + """One IRI published under a LineageWeave ontology prefix. + + ``term_kind`` is the RDF/OWL metaclass of the resource, never a + guessed label. A class in one graph is not equivalent to a property + in the other even when the local name matches. + """ + + iri: str + local_name: str + namespace: str + term_kind: TermKind + + +@dataclass(frozen=True, slots=True) +class NamespaceHttpBehavior: + """Documented HTTP contract for a public ontology IRI (ADR 0158). + + Both namespace documents are specified as ``200 OK``. Live GitHub + Pages dereference is out of band for CI: the in-repository Turtle + path is the source artifact. A redirect is explicitly *not* RDF + identity and is not the documented publication status. + """ + + requested_iri: str + documentation_role: Literal[ + "canonical_kg", "deprecated_pages_alias", "prov_o_support", "unknown" + ] + in_repository_turtle: str | None + rdf_identity_from_redirect: bool + documented_http_status: Literal[200] | None + + +def _term_kind(graph: Graph, subject: URIRef) -> TermKind: + """Classify ``subject`` by RDF/OWL term kind, fail-closed to other.""" + types = set(graph.objects(subject, RDF.type)) + if OWL.Class in types or RDFS.Class in types: + return "class" + if OWL.ObjectProperty in types: + return "object_property" + if OWL.DatatypeProperty in types: + return "datatype_property" + if OWL.AnnotationProperty in types: + return "annotation_property" + if any(str(value).endswith("Concept") for value in types): + return "concept" + if any(True for _ in graph.objects(subject, RDFS.subClassOf)): + return "class" + return "other" + + +def classify_term_kind(graph: Graph, iri: str) -> TermKind: + """Public fail-closed classifier used by compatibility mapping.""" + return _term_kind(graph, URIRef(iri)) + + +def _local_name(iri: str, namespace: str) -> str: + """Return the fragment after ``namespace``, or empty when absent.""" + if not iri.startswith(namespace): + return "" + return iri[len(namespace) :] + + +def _terms_for_graph(graph: Graph, namespace: str) -> tuple[NamespaceTerm, ...]: + """Inventory subjects published under ``namespace``.""" + terms: list[NamespaceTerm] = [] + seen: set[str] = set() + for subject in graph.subjects(): + iri = str(subject) + if iri in seen or not iri.startswith(namespace): + continue + local_name = _local_name(iri, namespace) + if not local_name: + continue + seen.add(iri) + terms.append( + NamespaceTerm( + iri=iri, + local_name=local_name, + namespace=namespace, + term_kind=_term_kind(graph, subject), + ) + ) + return tuple(sorted(terms, key=lambda term: term.iri)) + + +def load_knowledge_graph_ontology() -> Graph: + """Parse the canonical lowercase KG ontology Turtle file.""" + graph = Graph() + graph.parse(_KG_PATH, format="turtle") + return graph + + +def load_prov_o_support_profile() -> Graph: + """Parse the repository-case PROV-O support profile Turtle file.""" + graph = Graph() + graph.parse(_PROV_O_PROFILE_PATH, format="turtle") + return graph + + +def load_namespace_compatibility() -> Graph: + """Parse the published compatibility vocabulary.""" + graph = Graph() + graph.parse(_COMPATIBILITY_PATH, format="turtle") + return graph + + +def inventory_namespace_terms() -> tuple[NamespaceTerm, ...]: + """Return every IRI fragment published under either ontology prefix. + + The two ontology *documents* are not the same resource: the + lowercase document is the KG vocabulary, the repository-case + ``prov-o-support`` document is a PROV-O alignment profile. + """ + kg_terms = _terms_for_graph( + load_knowledge_graph_ontology(), CANONICAL_ONTOLOGY_NAMESPACE + ) + profile_terms = _terms_for_graph( + load_prov_o_support_profile(), DEPRECATED_PAGES_ONTOLOGY_NAMESPACE + ) + return kg_terms + profile_terms + + +def compatible_class_pairs() -> tuple[tuple[str, str], ...]: + """Return (canonical, deprecated) IRI pairs that share class kind. + + Pairs are restricted to ``COMPATIBLE_CLASS_LOCAL_NAMES``. A matching + local name of a different term kind is omitted rather than mapped. + """ + by_ns_and_name: dict[tuple[str, str], NamespaceTerm] = { + (term.namespace, term.local_name): term + for term in inventory_namespace_terms() + } + pairs: list[tuple[str, str]] = [] + for local_name in sorted(COMPATIBLE_CLASS_LOCAL_NAMES): + canonical = by_ns_and_name.get((CANONICAL_ONTOLOGY_NAMESPACE, local_name)) + deprecated = by_ns_and_name.get( + (DEPRECATED_PAGES_ONTOLOGY_NAMESPACE, local_name) + ) + if ( + canonical is None + or deprecated is None + or canonical.term_kind != "class" + or deprecated.term_kind != "class" + ): + continue + pairs.append((canonical.iri, deprecated.iri)) + return tuple(pairs) + + +def canonical_iri(iri: str) -> str | None: + """Map a known ontology IRI onto the canonical lowercase form. + + Canonical IRIs return themselves. The four compatible class IRIs + under the repository-case prefix map to the KG class. Any other + repository-case fragment, an unknown prefix, or an empty local name + returns ``None`` rather than inventing a term. Stored evidence is + never rewritten by this function. + """ + if iri.startswith(CANONICAL_ONTOLOGY_NAMESPACE): + local_name = _local_name(iri, CANONICAL_ONTOLOGY_NAMESPACE) + return iri if local_name else None + if iri.startswith(DEPRECATED_PAGES_ONTOLOGY_NAMESPACE): + local_name = _local_name(iri, DEPRECATED_PAGES_ONTOLOGY_NAMESPACE) + if local_name in COMPATIBLE_CLASS_LOCAL_NAMES: + return f"{CANONICAL_ONTOLOGY_NAMESPACE}{local_name}" + return None + return None + + +def migrate_stored_iri(iri: str) -> str: + """Return ``iri`` unchanged unless it is a mapped class alias. + + Historical KG IRIs stay byte-identical. Only the four published + class aliases rewrite to the canonical form. Unknown aliases raise + ``ValueError`` so a bulk migrator cannot silently invent terms. + The function is idempotent and does not write a database row. + """ + document_iris = { + CANONICAL_ONTOLOGY_DOCUMENT_IRI, + DEPRECATED_PAGES_ONTOLOGY_DOCUMENT_IRI, + PROV_O_SUPPORT_DOCUMENT_IRI, + CANONICAL_ONTOLOGY_NAMESPACE.rstrip("#"), + DEPRECATED_PAGES_ONTOLOGY_NAMESPACE.rstrip("#"), + } + if iri.rstrip("#") in document_iris or iri in { + CANONICAL_ONTOLOGY_NAMESPACE, + DEPRECATED_PAGES_ONTOLOGY_NAMESPACE, + }: + raise ValueError("ontology document IRI is not a term") + if iri.startswith(CANONICAL_ONTOLOGY_NAMESPACE): + if not _local_name(iri, CANONICAL_ONTOLOGY_NAMESPACE): + raise ValueError("ontology document IRI is not a term") + return iri + mapped = canonical_iri(iri) + if mapped is None: + raise ValueError(f"unmapped ontology IRI: {iri}") + return mapped + + +def documentation_http_behavior(iri: str) -> NamespaceHttpBehavior: + """Return the documented HTTP contract for a public ontology IRI. + + Live network dereference is out of band. Both public documents are + specified as ``200 OK``. The in-repository Turtle file is the source + of truth; a Pages redirect must not be treated as ``owl:sameAs``. + """ + if iri == CANONICAL_ONTOLOGY_DOCUMENT_IRI or iri.startswith( + CANONICAL_ONTOLOGY_NAMESPACE + ): + return NamespaceHttpBehavior( + requested_iri=iri, + documentation_role="canonical_kg", + in_repository_turtle="docs/ontology/lineageweave-kg.ttl", + rdf_identity_from_redirect=False, + documented_http_status=200, + ) + if iri == PROV_O_SUPPORT_DOCUMENT_IRI: + return NamespaceHttpBehavior( + requested_iri=iri, + documentation_role="prov_o_support", + in_repository_turtle="docs/ontology/prov-o-support-profile.ttl", + rdf_identity_from_redirect=False, + documented_http_status=200, + ) + if iri == DEPRECATED_PAGES_ONTOLOGY_DOCUMENT_IRI or iri.startswith( + DEPRECATED_PAGES_ONTOLOGY_NAMESPACE + ): + return NamespaceHttpBehavior( + requested_iri=iri, + documentation_role="deprecated_pages_alias", + in_repository_turtle="docs/ontology/namespace-compatibility.ttl", + rdf_identity_from_redirect=False, + documented_http_status=200, + ) + return NamespaceHttpBehavior( + requested_iri=iri, + documentation_role="unknown", + in_repository_turtle=None, + rdf_identity_from_redirect=False, + documented_http_status=None, + ) + + +__all__ = [ + "CANONICAL_ONTOLOGY_DOCUMENT_IRI", + "CANONICAL_ONTOLOGY_NAMESPACE", + "COMPATIBLE_CLASS_LOCAL_NAMES", + "DEPRECATED_PAGES_ONTOLOGY_DOCUMENT_IRI", + "DEPRECATED_PAGES_ONTOLOGY_NAMESPACE", + "PROV_O_SUPPORT_DOCUMENT_IRI", + "NamespaceHttpBehavior", + "NamespaceTerm", + "canonical_iri", + "classify_term_kind", + "compatible_class_pairs", + "documentation_http_behavior", + "inventory_namespace_terms", + "load_knowledge_graph_ontology", + "load_namespace_compatibility", + "load_prov_o_support_profile", + "migrate_stored_iri", +] diff --git a/pyproject.toml b/pyproject.toml index cb4be2916..3257416b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.12.6" +version = "2.16.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/tests/test_ontology_namespace.py b/tests/test_ontology_namespace.py new file mode 100644 index 000000000..2d770333a --- /dev/null +++ b/tests/test_ontology_namespace.py @@ -0,0 +1,233 @@ +"""Contract tests for the canonical vs repository-case ontology namespace. + +These tests are file-and-RDF-level. They do not rewrite stored evidence +and they do not require a live GitHub Pages fetch. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from rdflib import OWL, URIRef, Graph +from rdflib.namespace import RDF, RDFS, SKOS + +from lineageweave.ontology import LW +from lineageweave.ontology_namespace import ( + CANONICAL_ONTOLOGY_DOCUMENT_IRI, + CANONICAL_ONTOLOGY_NAMESPACE, + COMPATIBLE_CLASS_LOCAL_NAMES, + DEPRECATED_PAGES_ONTOLOGY_DOCUMENT_IRI, + DEPRECATED_PAGES_ONTOLOGY_NAMESPACE, + PROV_O_SUPPORT_DOCUMENT_IRI, + canonical_iri, + classify_term_kind, + compatible_class_pairs, + documentation_http_behavior, + inventory_namespace_terms, + load_knowledge_graph_ontology, + load_namespace_compatibility, + load_prov_o_support_profile, + migrate_stored_iri, +) + +_REPO_ROOT = Path(__file__).resolve().parents[1] + + +def test_runtime_lw_namespace_stays_canonical_lowercase() -> None: + assert str(LW) == CANONICAL_ONTOLOGY_NAMESPACE + assert CANONICAL_ONTOLOGY_NAMESPACE != DEPRECATED_PAGES_ONTOLOGY_NAMESPACE + assert not str(LW).startswith(DEPRECATED_PAGES_ONTOLOGY_NAMESPACE) + + +def test_inventory_contains_both_public_namespace_forms() -> None: + terms = inventory_namespace_terms() + namespaces = {term.namespace for term in terms} + assert CANONICAL_ONTOLOGY_NAMESPACE in namespaces + assert DEPRECATED_PAGES_ONTOLOGY_NAMESPACE in namespaces + local_names = {(term.namespace, term.local_name) for term in terms} + for local_name in COMPATIBLE_CLASS_LOCAL_NAMES: + assert (CANONICAL_ONTOLOGY_NAMESPACE, local_name) in local_names + assert (DEPRECATED_PAGES_ONTOLOGY_NAMESPACE, local_name) in local_names + + +def test_compatible_pairs_are_classes_with_matching_local_names() -> None: + pairs = compatible_class_pairs() + assert {canonical.rsplit("#", 1)[-1] for canonical, _ in pairs} == set( + COMPATIBLE_CLASS_LOCAL_NAMES + ) + for canonical, deprecated in pairs: + assert canonical.startswith(CANONICAL_ONTOLOGY_NAMESPACE) + assert deprecated.startswith(DEPRECATED_PAGES_ONTOLOGY_NAMESPACE) + assert canonical.rsplit("#", 1)[-1] == deprecated.rsplit("#", 1)[-1] + + +def test_compatibility_graph_uses_equivalent_class_only_for_mapped_classes() -> None: + graph = load_namespace_compatibility() + mapped = { + (str(subject), str(obj)) + for subject, obj in graph.subject_objects(OWL.equivalentClass) + } + expected = set(compatible_class_pairs()) + assert mapped == expected + assert ( + URIRef(CANONICAL_ONTOLOGY_DOCUMENT_IRI), + OWL.sameAs, + URIRef(DEPRECATED_PAGES_ONTOLOGY_DOCUMENT_IRI), + ) not in graph + assert ( + URIRef(CANONICAL_ONTOLOGY_DOCUMENT_IRI), + OWL.sameAs, + URIRef(PROV_O_SUPPORT_DOCUMENT_IRI), + ) not in graph + + +def test_unmatched_kg_fragments_are_not_equivalent_classes() -> None: + graph = load_namespace_compatibility() + equivalent_objects = { + str(obj) + for obj in graph.objects( + URIRef(f"{CANONICAL_ONTOLOGY_NAMESPACE}OurSidePerson"), + OWL.equivalentClass, + ) + } + assert not equivalent_objects + mentioned = { + str(obj) + for obj in graph.objects( + URIRef(f"{CANONICAL_ONTOLOGY_NAMESPACE}mentionedIn"), + OWL.equivalentProperty, + ) + } + assert not mentioned + + +def test_canonical_iri_is_identity_for_historical_kg_terms() -> None: + historical = f"{CANONICAL_ONTOLOGY_NAMESPACE}Post" + assert canonical_iri(historical) == historical + assert migrate_stored_iri(historical) == historical + assert migrate_stored_iri(migrate_stored_iri(historical)) == historical + + +def test_canonical_iri_maps_only_compatible_class_aliases() -> None: + assert ( + canonical_iri(f"{DEPRECATED_PAGES_ONTOLOGY_NAMESPACE}Team") + == f"{CANONICAL_ONTOLOGY_NAMESPACE}Team" + ) + assert canonical_iri(f"{DEPRECATED_PAGES_ONTOLOGY_NAMESPACE}OurSidePerson") is None + assert canonical_iri(f"{DEPRECATED_PAGES_ONTOLOGY_NAMESPACE}mentionedIn") is None + assert canonical_iri("https://example.invalid/ontology#Post") is None + assert canonical_iri(CANONICAL_ONTOLOGY_NAMESPACE) is None + + +def test_migrate_stored_iri_fails_closed_on_unknown_alias() -> None: + with pytest.raises(ValueError, match="unmapped ontology IRI"): + migrate_stored_iri(f"{DEPRECATED_PAGES_ONTOLOGY_NAMESPACE}OurSidePerson") + with pytest.raises(ValueError, match="ontology document IRI"): + migrate_stored_iri(CANONICAL_ONTOLOGY_DOCUMENT_IRI) + with pytest.raises(ValueError, match="ontology document IRI"): + migrate_stored_iri(DEPRECATED_PAGES_ONTOLOGY_DOCUMENT_IRI) + with pytest.raises(ValueError, match="ontology document IRI"): + migrate_stored_iri(PROV_O_SUPPORT_DOCUMENT_IRI) + + with pytest.raises(ValueError, match="unmapped ontology IRI"): + migrate_stored_iri("https://example.invalid/ontology#Post") + + +def test_migrate_stored_iri_is_idempotent_for_mapped_alias() -> None: + alias = f"{DEPRECATED_PAGES_ONTOLOGY_NAMESPACE}Person" + once = migrate_stored_iri(alias) + assert once == f"{CANONICAL_ONTOLOGY_NAMESPACE}Person" + assert migrate_stored_iri(once) == once + + +def test_documentation_http_behavior_never_treats_redirect_as_rdf_identity() -> None: + canonical = documentation_http_behavior(f"{CANONICAL_ONTOLOGY_NAMESPACE}Post") + alias = documentation_http_behavior(f"{DEPRECATED_PAGES_ONTOLOGY_NAMESPACE}Post") + support = documentation_http_behavior(PROV_O_SUPPORT_DOCUMENT_IRI) + unknown = documentation_http_behavior("https://example.invalid/ontology#Post") + assert canonical.documentation_role == "canonical_kg" + assert alias.documentation_role == "deprecated_pages_alias" + assert support.documentation_role == "prov_o_support" + assert unknown.documentation_role == "unknown" + for behavior in (canonical, alias, support): + assert behavior.rdf_identity_from_redirect is False + assert behavior.documented_http_status == 200 + assert unknown.documented_http_status is None + assert unknown.rdf_identity_from_redirect is False + assert (_REPO_ROOT / canonical.in_repository_turtle).is_file() + assert (_REPO_ROOT / alias.in_repository_turtle).is_file() + assert (_REPO_ROOT / support.in_repository_turtle).is_file() + + +def test_document_iris_are_specified_as_http_200() -> None: + for iri in ( + CANONICAL_ONTOLOGY_DOCUMENT_IRI, + DEPRECATED_PAGES_ONTOLOGY_DOCUMENT_IRI, + PROV_O_SUPPORT_DOCUMENT_IRI, + ): + behavior = documentation_http_behavior(iri) + assert behavior.documented_http_status == 200 + assert behavior.rdf_identity_from_redirect is False + + +def test_in_repository_turtle_files_parse_for_both_namespace_forms() -> None: + kg = load_knowledge_graph_ontology() + profile = load_prov_o_support_profile() + assert (URIRef(CANONICAL_ONTOLOGY_DOCUMENT_IRI), RDF.type, OWL.Ontology) in kg + assert (URIRef(PROV_O_SUPPORT_DOCUMENT_IRI), RDF.type, OWL.Ontology) in profile + for local_name in COMPATIBLE_CLASS_LOCAL_NAMES: + assert URIRef(f"{CANONICAL_ONTOLOGY_NAMESPACE}{local_name}") in { + subject for subject in kg.subjects() + } + assert URIRef(f"{DEPRECATED_PAGES_ONTOLOGY_NAMESPACE}{local_name}") in { + subject for subject in profile.subjects() + } + + +def test_compatibility_graph_is_isomorphic_across_reloads() -> None: + first = load_namespace_compatibility() + second = load_namespace_compatibility() + assert first.isomorphic(second) + + +def test_classify_term_kind_is_fail_closed_and_kind_specific() -> None: + graph = Graph() + owl_class = URIRef("https://example.invalid/ontology#OwlClass") + rdfs_class = URIRef("https://example.invalid/ontology#RdfsClass") + obj_prop = URIRef("https://example.invalid/ontology#ObjProp") + data_prop = URIRef("https://example.invalid/ontology#DataProp") + ann_prop = URIRef("https://example.invalid/ontology#AnnProp") + concept = URIRef("https://example.invalid/ontology#AConcept") + subclass = URIRef("https://example.invalid/ontology#SubOnly") + other = URIRef("https://example.invalid/ontology#Other") + graph.add((owl_class, RDF.type, OWL.Class)) + graph.add((rdfs_class, RDF.type, RDFS.Class)) + graph.add((obj_prop, RDF.type, OWL.ObjectProperty)) + graph.add((data_prop, RDF.type, OWL.DatatypeProperty)) + graph.add((ann_prop, RDF.type, OWL.AnnotationProperty)) + graph.add((concept, RDF.type, SKOS.Concept)) + graph.add((subclass, RDFS.subClassOf, owl_class)) + graph.add((other, RDFS.label, URIRef("https://example.invalid/ontology#Label"))) + assert classify_term_kind(graph, str(owl_class)) == "class" + assert classify_term_kind(graph, str(rdfs_class)) == "class" + assert classify_term_kind(graph, str(obj_prop)) == "object_property" + assert classify_term_kind(graph, str(data_prop)) == "datatype_property" + assert classify_term_kind(graph, str(ann_prop)) == "annotation_property" + assert classify_term_kind(graph, str(concept)) == "concept" + assert classify_term_kind(graph, str(subclass)) == "class" + assert classify_term_kind(graph, str(other)) == "other" + + +def test_kind_mismatch_does_not_emit_equivalent_class() -> None: + """A shared local name of mixed term kind must not be mapped.""" + kg_terms = { + term.local_name: term.term_kind + for term in inventory_namespace_terms() + if term.namespace == CANONICAL_ONTOLOGY_NAMESPACE + } + assert kg_terms.get("mentionedIn") != "class" + assert ( + f"{CANONICAL_ONTOLOGY_NAMESPACE}mentionedIn", + f"{DEPRECATED_PAGES_ONTOLOGY_NAMESPACE}mentionedIn", + ) not in compatible_class_pairs() From 38f3734f58477bad04f82758fc685e798e9d4b7b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 22:58:59 +0900 Subject: [PATCH 2/2] fix(ontology): publish validated namespace compatibility --- AGENTS.md | 6 - ARCHITECTURE.md | 1 - ...2.16.0-ontology-namespace-compatibility.md | 10 - CHANGELOG.md | 18 +- .../0158-ontology-namespace-compatibility.md | 117 ------ .../ONTOLOGY_NAMESPACE_REFERENCES.md | 30 -- docs/ontology/namespace-compatibility.ttl | 68 +--- lineageweave/__init__.py | 2 +- lineageweave/ontology.py | 19 +- lineageweave/ontology_namespace.py | 354 ------------------ pyproject.toml | 2 +- scripts/build_ontology_site.py | 7 + scripts/publish_ontology_site.py | 60 ++- tests/test_ontology_namespace.py | 233 ------------ tests/test_ontology_site.py | 22 ++ tests/test_publish_ontology_site.py | 62 ++- 16 files changed, 163 insertions(+), 848 deletions(-) delete mode 100644 CHANGELOG.d/2.16.0-ontology-namespace-compatibility.md delete mode 100644 docs/adr/0158-ontology-namespace-compatibility.md delete mode 100644 docs/doctoring/ONTOLOGY_NAMESPACE_REFERENCES.md delete mode 100644 lineageweave/ontology_namespace.py delete mode 100644 tests/test_ontology_namespace.py diff --git a/AGENTS.md b/AGENTS.md index eb24afa52..1728f9e61 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -246,9 +246,3 @@ add a narrow, documented suppression referencing the specific finding. - Appendix B inverse names normalize to the preferred W3C direction; do not proliferate private inverse vocabulary. - Keep `knowledge_graph_edge` an explicit navigation projection. -- Public ontology IRIs use the canonical lowercase namespace - `https://contextualwisdomlab.github.io/lineageweave/ontology#` - (ADR 0158). The repository-case Pages prefix is a compatibility - alias for four matching classes only. Do not mint new - repository-case term IRIs. Do not treat a GitHub Pages redirect as - RDF identity. Do not rewrite historical lowercase evidence. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8d57b6be8..d0280ff97 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -79,7 +79,6 @@ flowchart LR | `post_chat.py` | Pluggable in-popup chat's reason-and-cite step (retrieve step lives in `backend/app/post_chat_ingestion.py`) | | `commitment_extraction.py` | Pluggable LLM derivation of a customer commitment (promise + deadline) from a post; `Null` default, `ContextualOrchestrator` real impl | | `ontology.py` | Loads `docs/ontology/lineageweave-kg.ttl`, the formal OWL 2/RDFS/SKOS vocabulary for the Knowledge Graph's node/edge types (ADR 0004) | -| `ontology_namespace.py` | Canonical vs repository-case namespace inventory and fail-closed class alias map (ADR 0158 / #372) | | `period_report.py` | Fit GRM/GPCM on persisted IRT rows, FIPC-select, EAP-score a period (ADR 0003 slice 3; Bock & Mislevy, 1982) | | `fixtures.py` | Synthetic demo dataset -- no real data ships in this repo | | `server.py` | Legacy stdlib HTTP server for the library-level synthetic fixture demo; production uses FastAPI/PostgreSQL | diff --git a/CHANGELOG.d/2.16.0-ontology-namespace-compatibility.md b/CHANGELOG.d/2.16.0-ontology-namespace-compatibility.md deleted file mode 100644 index edf50ede2..000000000 --- a/CHANGELOG.d/2.16.0-ontology-namespace-compatibility.md +++ /dev/null @@ -1,10 +0,0 @@ -# 2.16.0 — ontology namespace compatibility - -Canonical KG namespace stays -`https://contextualwisdomlab.github.io/lineageweave/ontology#`. -The repository-case PROV-O profile prefix remains a documentation alias. -`docs/ontology/namespace-compatibility.ttl` maps only the four matching -classes (`Post`, `Person`, `CorporateEntity`, `Team`) with -`owl:equivalentClass`. Both documents are specified as HTTP 200. A Pages -redirect is not RDF identity. Historical lowercase IRIs are not rewritten -(ADR 0158 / #372). diff --git a/CHANGELOG.md b/CHANGELOG.md index 22f93029a..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 @@ -18,24 +20,8 @@ All notable changes to this project are documented here. Format follows environment, so local OIDC and synthetic-data workflows resolve the same pinned dependencies as CI. -## [2.16.0] - 2026-08-23 - -### Added - -- Canonical knowledge-graph namespace remains - `https://contextualwisdomlab.github.io/lineageweave/ontology#`. - The repository-case PROV-O support-profile prefix stays a documentation - alias. `docs/ontology/namespace-compatibility.ttl` maps only the four - matching classes (`Post`, `Person`, `CorporateEntity`, `Team`) with - `owl:equivalentClass`. Both documents are specified as HTTP 200. A - GitHub Pages redirect is not RDF identity. Historical lowercase IRIs - are not rewritten. After `make seed`, Demo Analyst still sees the same - ontology IRIs on related-node chips; RDF consumers load the - compatibility file for the four class aliases (ADR 0158). - ## [2.12.6] - 2026-08-20 - ### Added - Production OIDC can now use a real Keyverse issuer through diff --git a/docs/adr/0158-ontology-namespace-compatibility.md b/docs/adr/0158-ontology-namespace-compatibility.md deleted file mode 100644 index cfc963179..000000000 --- a/docs/adr/0158-ontology-namespace-compatibility.md +++ /dev/null @@ -1,117 +0,0 @@ -# ADR 0158 — Implement canonical lowercase ontology namespace compatibility - -**Decision status:** Accepted -**Date:** 2026-08-23 -**Issue:** [#372](https://github.com/ContextualWisdomLab/LineageWeave/issues/372) - -## Context - -GitHub Pages project paths are case-sensitive. Committed artifacts currently -use two distinct public namespace forms: - -- Knowledge-graph ontology and runtime lookup: - `https://contextualwisdomlab.github.io/lineageweave/ontology#` -- PROV-O support profile: - `https://contextualwisdomlab.github.io/LineageWeave/ontology#` - -RDF 1.1 treats IRI equality as character-for-character identity, so a -case difference is a different resource (Cyganiak, Wood, & Lanthaler, -2014, section 3.2). An HTTP redirect from the repository-case Pages path -is a documentation convenience, not `owl:sameAs`. - -The lowercase namespace is already on the runtime `LW` constant, API -payloads, frontend fixtures, and persisted lookup IRIs. Silently rewriting -those strings would change historical evidence. - -The PROV-O support profile only declares four class alignments (`Post`, -`Person`, `CorporateEntity`, `Team`). Those local names are also -`owl:Class` in the KG ontology, so a typed compatibility map is valid. -Other KG fragments (`OurSidePerson`, `mentionedIn`, `Project`, …) have no -matching repository-case term. - -PR #491 records the identity decision as ADR 0157. This record is the -implementation slice: compatibility vocabulary, fail-closed resolver, -documented HTTP 200 for both documents, and tests. It does not depend -on #491 merging and does not rewrite stored rows. - -## Decision - -1. Keep - `https://contextualwisdomlab.github.io/lineageweave/ontology#` - as the **canonical** knowledge-graph namespace for runtime, API, and - new persisted evidence. New producers must not mint repository-case - term IRIs. -2. Keep - `https://contextualwisdomlab.github.io/LineageWeave/prov-o-support` - as the PROV-O support-profile document IRI. Do not merge that document - with the KG ontology document. -3. Publish `docs/ontology/namespace-compatibility.ttl` mapping the four - matching **classes** with `owl:equivalentClass`. Mappings are generated - from the two parsed RDF graphs and emitted only when local-name - uniqueness, term kind, and meaning match. Do not claim - `owl:equivalentProperty` or `owl:equivalentClass` for unmatched - fragments. Do not claim `owl:sameAs` between the two ontology - documents; `skos:closeMatch` records documentation proximity only. -4. Provide a fail-closed resolver (`canonical_iri` / - `migrate_stored_iri`) that is identity on historical KG IRIs, maps - only the four class aliases, and raises on unknown aliases. The - resolver is opt-in tooling. It does not rewrite database rows. -5. Document HTTP behavior: both namespace documents are specified as - `200 OK`. In-repository Turtle is the source of truth. A Pages - redirect must not be treated as RDF identity. Live dereference of - GitHub Pages is out of band for CI. -6. Do not rewrite rows already stored under the canonical namespace. - Bulk migration of the four class aliases is opt-in, idempotent, and - never invents a term. - -Deprecation window: the repository-case class IRIs remain published -for at least 180 days and two minor releases, whichever is later, so -existing PROV-O profile consumers can load the compatibility -vocabulary. Dereferenceability and mappings are not removed at the -end of that window. After that window, new terms are canonical-only. - -## Consequences - -- RDF consumers that load the compatibility file can treat the four class - pairs as the same class without collapsing the two ontology documents. -- Buyers inspecting persisted IRIs continue to see the historical - lowercase form. -- Stacked ontology-explorer work (issue #349) must consume the canonical - namespace and the compatibility file rather than minting a third prefix. -- Relational `post_project_mention.ontology_iri` and - `provenance_resource.resource_iri` rows are not inspected or rewritten - in this slice. Unknown downstream stores are treated as real - compatibility obligations. - -## Related decisions - -- [ADR 0004](0004-knowledge-graph-ontology.md): ontology vocabulary authority. -- [ADR 0011](0011-prov-o-standard-relations.md): PROV-O support profile. -- [ADR 0065](0065-prov-o-provenance-boundary.md): provenance/navigation - separation. -- PR #491 / ADR 0157: identity decision (docs-only; may land separately). - -## References - -Cyganiak, R., Wood, D., & Lanthaler, M. (Eds.). (2014). *RDF 1.1 concepts -and abstract syntax*. World Wide Web Consortium. -https://www.w3.org/TR/rdf11-concepts/ - -GitHub. (2024). *About GitHub Pages*. GitHub Docs. -https://docs.github.com/en/pages/getting-started-with-github-pages/about-github-pages - -Miles, A., & Bechhofer, S. (Eds.). (2009). *SKOS simple knowledge -organization system reference*. World Wide Web Consortium. -https://www.w3.org/TR/skos-reference/ - -Sauermann, L., & Cyganiak, R. (2008). *Cool URIs for the Semantic Web* -(W3C Interest Group Note). World Wide Web Consortium. -https://www.w3.org/TR/cooluris/ - -W3C OWL Working Group. (2012). *OWL 2 web ontology language document -overview* (2nd ed.). World Wide Web Consortium. -https://www.w3.org/TR/owl2-overview/ - -World Wide Web Consortium. (2008). *Best practice recipes for publishing -RDF vocabularies* (W3C Working Group Note). -https://www.w3.org/TR/swbp-vocab-pub/ diff --git a/docs/doctoring/ONTOLOGY_NAMESPACE_REFERENCES.md b/docs/doctoring/ONTOLOGY_NAMESPACE_REFERENCES.md deleted file mode 100644 index 447621240..000000000 --- a/docs/doctoring/ONTOLOGY_NAMESPACE_REFERENCES.md +++ /dev/null @@ -1,30 +0,0 @@ -# Ontology namespace compatibility references (ADR 0158) - -APA 7th edition. These sources ground the canonical-vs-Pages namespace -implementation. They are not a live Zotero sync. - -Cyganiak, R., Wood, D., & Lanthaler, M. (Eds.). (2014). *RDF 1.1 concepts -and abstract syntax*. World Wide Web Consortium. -https://www.w3.org/TR/rdf11-concepts/ - -GitHub. (2024). *About GitHub Pages*. GitHub Docs. -https://docs.github.com/en/pages/getting-started-with-github-pages/about-github-pages - -Miles, A., & Bechhofer, S. (Eds.). (2009). *SKOS simple knowledge -organization system reference*. World Wide Web Consortium. -https://www.w3.org/TR/skos-reference/ - -Sauermann, L., & Cyganiak, R. (2008). *Cool URIs for the Semantic Web* -(W3C Interest Group Note). World Wide Web Consortium. -https://www.w3.org/TR/cooluris/ - -W3C OWL Working Group. (2012). *OWL 2 web ontology language document -overview* (2nd ed.). World Wide Web Consortium. -https://www.w3.org/TR/owl2-overview/ - -World Wide Web Consortium. (2008). *Best practice recipes for publishing -RDF vocabularies* (W3C Working Group Note). -https://www.w3.org/TR/swbp-vocab-pub/ - -World Wide Web Consortium. (2013). *PROV-O: The PROV ontology*. -https://www.w3.org/TR/prov-o/ diff --git a/docs/ontology/namespace-compatibility.ttl b/docs/ontology/namespace-compatibility.ttl index cb62c4ba7..15da95f80 100644 --- a/docs/ontology/namespace-compatibility.ttl +++ b/docs/ontology/namespace-compatibility.ttl @@ -1,65 +1,15 @@ -@prefix : . -@prefix compat: . +@prefix canonical: . @prefix dcterms: . @prefix owl: . -@prefix rdf: . -@prefix rdfs: . -@prefix skos: . +@prefix legacy: . @prefix xsd: . -@prefix pages: . -################################################################# -# LineageWeave ontology namespace compatibility vocabulary (ADR 0158) -# -# Canonical KG namespace (runtime, persisted evidence): -# https://contextualwisdomlab.github.io/lineageweave/ontology# -# Repository-case PROV-O support-profile prefix (documentation path): -# https://contextualwisdomlab.github.io/LineageWeave/ontology# -# -# GitHub Pages project paths are case-sensitive. A documentation HTTP -# redirect is NOT RDF identity (RDF 1.1 Concepts §3.2). Both documents -# are specified as HTTP 200. This file maps only the four class terms -# that share local name AND term kind. It does not claim owl:sameAs -# between the KG ontology document and the PROV-O support profile, and -# it does not claim owl:equivalentClass or owl:equivalentProperty for -# unmatched fragments. -################################################################# - - + a owl:Ontology ; - dcterms:title "LineageWeave ontology namespace compatibility"@en ; - rdfs:comment "Compatibility vocabulary between the canonical lowercase KG namespace and the repository-case PROV-O support-profile prefix. A Pages redirect is not RDF identity."@en ; - owl:imports , - ; - dcterms:conformsTo , - , - . - -# The two *documents* remain distinct resources. SKOS closeMatch records -# that both describe LineageWeave ontology documentation without claiming -# the KG vocabulary and the PROV-O support profile are the same ontology. - - skos:closeMatch . - -# Compatible classes: same local name, both classes, same LineageWeave -# concept. owl:equivalentClass is valid here (OWL 2, matching term kind). -:Post owl:equivalentClass pages:Post . -:Person owl:equivalentClass pages:Person . -:CorporateEntity owl:equivalentClass pages:CorporateEntity . -:Team owl:equivalentClass pages:Team . - -# Explicit non-mappings. These local names exist only in the KG -# namespace. Publishing owl:equivalentClass for them against a -# repository-case IRI would invent a PROV-O profile term. -compat:unmappedLocalName a owl:AnnotationProperty ; - rdfs:label "unmapped local name" ; - rdfs:comment "A KG local name that must not be treated as equivalent to a repository-case fragment of the same spelling when that fragment is absent or of a different term kind." . + owl:deprecated "true"^^xsd:boolean ; + dcterms:isReplacedBy . -:OurSidePerson compat:unmappedLocalName "OurSidePerson" . -:CounterpartyPerson compat:unmappedLocalName "CounterpartyPerson" . -:RoleActorPerson compat:unmappedLocalName "RoleActorPerson" . -:RoleActorOrganization compat:unmappedLocalName "RoleActorOrganization" . -:RoleActorTeam compat:unmappedLocalName "RoleActorTeam" . -:Project compat:unmappedLocalName "Project" . -:mentionedIn compat:unmappedLocalName "mentionedIn" . -:affiliatedWith compat:unmappedLocalName "affiliatedWith" . +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/lineageweave/__init__.py b/lineageweave/__init__.py index 6b3b6e610..95330cb50 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "2.16.0" +__version__ = "2.12.6" diff --git a/lineageweave/ontology.py b/lineageweave/ontology.py index 039e20678..f7edf76e6 100644 --- a/lineageweave/ontology.py +++ b/lineageweave/ontology.py @@ -14,10 +14,6 @@ `tests/test_ontology.py` is the real correctness check: it loads the same file with `rdflib` and asserts every lookup code the relational schema actually defines has a matching ontology term, and vice versa. - -Public ontology IRIs stay on the lowercase namespace (ADR 0158). The -repository-case Pages prefix is a compatibility alias for four matching -classes only; see `ontology_namespace.py`. """ from __future__ import annotations @@ -28,17 +24,9 @@ from rdflib.namespace import OWL, RDF, RDFS, SKOS from rdflib.term import Identifier -from .ontology_namespace import ( - CANONICAL_ONTOLOGY_NAMESPACE, - COMPATIBLE_CLASS_LOCAL_NAMES, - DEPRECATED_PAGES_ONTOLOGY_NAMESPACE, - canonical_iri, - migrate_stored_iri, -) - #: The ontology's own namespace -- every class/property IRI below is #: this prefix plus the term's local name (e.g. LW.Post). -LW = Namespace(CANONICAL_ONTOLOGY_NAMESPACE) +LW = Namespace("https://contextualwisdomlab.github.io/lineageweave/ontology#") #: The custom annotation property linking an ontology term to the exact #: `common_lookup_value.lookup_code` string it corresponds to. @@ -109,9 +97,6 @@ def all_declared_lookup_codes() -> set[str]: __all__ = [ - "CANONICAL_ONTOLOGY_NAMESPACE", - "COMPATIBLE_CLASS_LOCAL_NAMES", - "DEPRECATED_PAGES_ONTOLOGY_NAMESPACE", "LOOKUP_CODE", "LW", "ONTOLOGY", @@ -120,9 +105,7 @@ def all_declared_lookup_codes() -> set[str]: "RDFS", "SKOS", "all_declared_lookup_codes", - "canonical_iri", "iri_for_lookup_code", "load_ontology", - "migrate_stored_iri", "ontology_annotations", ] diff --git a/lineageweave/ontology_namespace.py b/lineageweave/ontology_namespace.py deleted file mode 100644 index a0dd577a1..000000000 --- a/lineageweave/ontology_namespace.py +++ /dev/null @@ -1,354 +0,0 @@ -"""Canonical vs repository-case public ontology namespace (ADR 0158). - -GitHub Pages project paths are case-sensitive (GitHub, 2024). This -repository therefore currently publishes two distinct IRI prefixes: - -- Canonical knowledge-graph namespace used by runtime lookup, API - payloads, and persisted evidence: - ``https://contextualwisdomlab.github.io/lineageweave/ontology#`` -- Repository-case PROV-O support-profile prefix used by - ``docs/ontology/prov-o-support-profile.ttl``: - ``https://contextualwisdomlab.github.io/LineageWeave/ontology#`` - -A documentation HTTP redirect is not RDF identity (Cyganiak, Wood, & -Lanthaler, 2014, section 3.2). Both namespace documents are documented -as ``200 OK`` publications; the lowercase document is authoritative. -This module inventories both prefixes, maps only class terms that share -a local name *and* term kind, and never rewrites stored evidence. - -References ----------- -Cyganiak, R., Wood, D., & Lanthaler, M. (Eds.). (2014). *RDF 1.1 -concepts and abstract syntax*. World Wide Web Consortium. -https://www.w3.org/TR/rdf11-concepts/ - -GitHub. (2024). *About GitHub Pages*. GitHub Docs. -https://docs.github.com/en/pages/getting-started-with-github-pages/about-github-pages - -Miles, A., & Bechhofer, S. (Eds.). (2009). *SKOS simple knowledge -organization system reference*. World Wide Web Consortium. -https://www.w3.org/TR/skos-reference/ - -Sauermann, L., & Cyganiak, R. (2008). *Cool URIs for the Semantic Web* -(W3C Interest Group Note). World Wide Web Consortium. -https://www.w3.org/TR/cooluris/ - -W3C OWL Working Group. (2012). *OWL 2 web ontology language document -overview* (2nd ed.). World Wide Web Consortium. -https://www.w3.org/TR/owl2-overview/ -""" - -from __future__ import annotations - -from dataclasses import dataclass -from pathlib import Path -from typing import Literal - -from rdflib import Graph, URIRef -from rdflib.namespace import OWL, RDF, RDFS - -CANONICAL_ONTOLOGY_NAMESPACE = ( - "https://contextualwisdomlab.github.io/lineageweave/ontology#" -) -DEPRECATED_PAGES_ONTOLOGY_NAMESPACE = ( - "https://contextualwisdomlab.github.io/LineageWeave/ontology#" -) -CANONICAL_ONTOLOGY_DOCUMENT_IRI = ( - "https://contextualwisdomlab.github.io/lineageweave/ontology" -) -DEPRECATED_PAGES_ONTOLOGY_DOCUMENT_IRI = ( - "https://contextualwisdomlab.github.io/LineageWeave/ontology" -) -PROV_O_SUPPORT_DOCUMENT_IRI = ( - "https://contextualwisdomlab.github.io/LineageWeave/prov-o-support" -) - -#: Local names that are owl:Class (or rdfs:subClassOf, which entails a -#: class) in *both* the KG ontology and the PROV-O support profile. -COMPATIBLE_CLASS_LOCAL_NAMES = frozenset( - {"Post", "Person", "CorporateEntity", "Team"} -) - -_REPO_ROOT = Path(__file__).resolve().parents[1] -_KG_PATH = _REPO_ROOT / "docs" / "ontology" / "lineageweave-kg.ttl" -_PROV_O_PROFILE_PATH = ( - _REPO_ROOT / "docs" / "ontology" / "prov-o-support-profile.ttl" -) -_COMPATIBILITY_PATH = ( - _REPO_ROOT / "docs" / "ontology" / "namespace-compatibility.ttl" -) - -TermKind = Literal[ - "class", - "object_property", - "datatype_property", - "annotation_property", - "concept", - "other", -] - - -@dataclass(frozen=True, slots=True) -class NamespaceTerm: - """One IRI published under a LineageWeave ontology prefix. - - ``term_kind`` is the RDF/OWL metaclass of the resource, never a - guessed label. A class in one graph is not equivalent to a property - in the other even when the local name matches. - """ - - iri: str - local_name: str - namespace: str - term_kind: TermKind - - -@dataclass(frozen=True, slots=True) -class NamespaceHttpBehavior: - """Documented HTTP contract for a public ontology IRI (ADR 0158). - - Both namespace documents are specified as ``200 OK``. Live GitHub - Pages dereference is out of band for CI: the in-repository Turtle - path is the source artifact. A redirect is explicitly *not* RDF - identity and is not the documented publication status. - """ - - requested_iri: str - documentation_role: Literal[ - "canonical_kg", "deprecated_pages_alias", "prov_o_support", "unknown" - ] - in_repository_turtle: str | None - rdf_identity_from_redirect: bool - documented_http_status: Literal[200] | None - - -def _term_kind(graph: Graph, subject: URIRef) -> TermKind: - """Classify ``subject`` by RDF/OWL term kind, fail-closed to other.""" - types = set(graph.objects(subject, RDF.type)) - if OWL.Class in types or RDFS.Class in types: - return "class" - if OWL.ObjectProperty in types: - return "object_property" - if OWL.DatatypeProperty in types: - return "datatype_property" - if OWL.AnnotationProperty in types: - return "annotation_property" - if any(str(value).endswith("Concept") for value in types): - return "concept" - if any(True for _ in graph.objects(subject, RDFS.subClassOf)): - return "class" - return "other" - - -def classify_term_kind(graph: Graph, iri: str) -> TermKind: - """Public fail-closed classifier used by compatibility mapping.""" - return _term_kind(graph, URIRef(iri)) - - -def _local_name(iri: str, namespace: str) -> str: - """Return the fragment after ``namespace``, or empty when absent.""" - if not iri.startswith(namespace): - return "" - return iri[len(namespace) :] - - -def _terms_for_graph(graph: Graph, namespace: str) -> tuple[NamespaceTerm, ...]: - """Inventory subjects published under ``namespace``.""" - terms: list[NamespaceTerm] = [] - seen: set[str] = set() - for subject in graph.subjects(): - iri = str(subject) - if iri in seen or not iri.startswith(namespace): - continue - local_name = _local_name(iri, namespace) - if not local_name: - continue - seen.add(iri) - terms.append( - NamespaceTerm( - iri=iri, - local_name=local_name, - namespace=namespace, - term_kind=_term_kind(graph, subject), - ) - ) - return tuple(sorted(terms, key=lambda term: term.iri)) - - -def load_knowledge_graph_ontology() -> Graph: - """Parse the canonical lowercase KG ontology Turtle file.""" - graph = Graph() - graph.parse(_KG_PATH, format="turtle") - return graph - - -def load_prov_o_support_profile() -> Graph: - """Parse the repository-case PROV-O support profile Turtle file.""" - graph = Graph() - graph.parse(_PROV_O_PROFILE_PATH, format="turtle") - return graph - - -def load_namespace_compatibility() -> Graph: - """Parse the published compatibility vocabulary.""" - graph = Graph() - graph.parse(_COMPATIBILITY_PATH, format="turtle") - return graph - - -def inventory_namespace_terms() -> tuple[NamespaceTerm, ...]: - """Return every IRI fragment published under either ontology prefix. - - The two ontology *documents* are not the same resource: the - lowercase document is the KG vocabulary, the repository-case - ``prov-o-support`` document is a PROV-O alignment profile. - """ - kg_terms = _terms_for_graph( - load_knowledge_graph_ontology(), CANONICAL_ONTOLOGY_NAMESPACE - ) - profile_terms = _terms_for_graph( - load_prov_o_support_profile(), DEPRECATED_PAGES_ONTOLOGY_NAMESPACE - ) - return kg_terms + profile_terms - - -def compatible_class_pairs() -> tuple[tuple[str, str], ...]: - """Return (canonical, deprecated) IRI pairs that share class kind. - - Pairs are restricted to ``COMPATIBLE_CLASS_LOCAL_NAMES``. A matching - local name of a different term kind is omitted rather than mapped. - """ - by_ns_and_name: dict[tuple[str, str], NamespaceTerm] = { - (term.namespace, term.local_name): term - for term in inventory_namespace_terms() - } - pairs: list[tuple[str, str]] = [] - for local_name in sorted(COMPATIBLE_CLASS_LOCAL_NAMES): - canonical = by_ns_and_name.get((CANONICAL_ONTOLOGY_NAMESPACE, local_name)) - deprecated = by_ns_and_name.get( - (DEPRECATED_PAGES_ONTOLOGY_NAMESPACE, local_name) - ) - if ( - canonical is None - or deprecated is None - or canonical.term_kind != "class" - or deprecated.term_kind != "class" - ): - continue - pairs.append((canonical.iri, deprecated.iri)) - return tuple(pairs) - - -def canonical_iri(iri: str) -> str | None: - """Map a known ontology IRI onto the canonical lowercase form. - - Canonical IRIs return themselves. The four compatible class IRIs - under the repository-case prefix map to the KG class. Any other - repository-case fragment, an unknown prefix, or an empty local name - returns ``None`` rather than inventing a term. Stored evidence is - never rewritten by this function. - """ - if iri.startswith(CANONICAL_ONTOLOGY_NAMESPACE): - local_name = _local_name(iri, CANONICAL_ONTOLOGY_NAMESPACE) - return iri if local_name else None - if iri.startswith(DEPRECATED_PAGES_ONTOLOGY_NAMESPACE): - local_name = _local_name(iri, DEPRECATED_PAGES_ONTOLOGY_NAMESPACE) - if local_name in COMPATIBLE_CLASS_LOCAL_NAMES: - return f"{CANONICAL_ONTOLOGY_NAMESPACE}{local_name}" - return None - return None - - -def migrate_stored_iri(iri: str) -> str: - """Return ``iri`` unchanged unless it is a mapped class alias. - - Historical KG IRIs stay byte-identical. Only the four published - class aliases rewrite to the canonical form. Unknown aliases raise - ``ValueError`` so a bulk migrator cannot silently invent terms. - The function is idempotent and does not write a database row. - """ - document_iris = { - CANONICAL_ONTOLOGY_DOCUMENT_IRI, - DEPRECATED_PAGES_ONTOLOGY_DOCUMENT_IRI, - PROV_O_SUPPORT_DOCUMENT_IRI, - CANONICAL_ONTOLOGY_NAMESPACE.rstrip("#"), - DEPRECATED_PAGES_ONTOLOGY_NAMESPACE.rstrip("#"), - } - if iri.rstrip("#") in document_iris or iri in { - CANONICAL_ONTOLOGY_NAMESPACE, - DEPRECATED_PAGES_ONTOLOGY_NAMESPACE, - }: - raise ValueError("ontology document IRI is not a term") - if iri.startswith(CANONICAL_ONTOLOGY_NAMESPACE): - if not _local_name(iri, CANONICAL_ONTOLOGY_NAMESPACE): - raise ValueError("ontology document IRI is not a term") - return iri - mapped = canonical_iri(iri) - if mapped is None: - raise ValueError(f"unmapped ontology IRI: {iri}") - return mapped - - -def documentation_http_behavior(iri: str) -> NamespaceHttpBehavior: - """Return the documented HTTP contract for a public ontology IRI. - - Live network dereference is out of band. Both public documents are - specified as ``200 OK``. The in-repository Turtle file is the source - of truth; a Pages redirect must not be treated as ``owl:sameAs``. - """ - if iri == CANONICAL_ONTOLOGY_DOCUMENT_IRI or iri.startswith( - CANONICAL_ONTOLOGY_NAMESPACE - ): - return NamespaceHttpBehavior( - requested_iri=iri, - documentation_role="canonical_kg", - in_repository_turtle="docs/ontology/lineageweave-kg.ttl", - rdf_identity_from_redirect=False, - documented_http_status=200, - ) - if iri == PROV_O_SUPPORT_DOCUMENT_IRI: - return NamespaceHttpBehavior( - requested_iri=iri, - documentation_role="prov_o_support", - in_repository_turtle="docs/ontology/prov-o-support-profile.ttl", - rdf_identity_from_redirect=False, - documented_http_status=200, - ) - if iri == DEPRECATED_PAGES_ONTOLOGY_DOCUMENT_IRI or iri.startswith( - DEPRECATED_PAGES_ONTOLOGY_NAMESPACE - ): - return NamespaceHttpBehavior( - requested_iri=iri, - documentation_role="deprecated_pages_alias", - in_repository_turtle="docs/ontology/namespace-compatibility.ttl", - rdf_identity_from_redirect=False, - documented_http_status=200, - ) - return NamespaceHttpBehavior( - requested_iri=iri, - documentation_role="unknown", - in_repository_turtle=None, - rdf_identity_from_redirect=False, - documented_http_status=None, - ) - - -__all__ = [ - "CANONICAL_ONTOLOGY_DOCUMENT_IRI", - "CANONICAL_ONTOLOGY_NAMESPACE", - "COMPATIBLE_CLASS_LOCAL_NAMES", - "DEPRECATED_PAGES_ONTOLOGY_DOCUMENT_IRI", - "DEPRECATED_PAGES_ONTOLOGY_NAMESPACE", - "PROV_O_SUPPORT_DOCUMENT_IRI", - "NamespaceHttpBehavior", - "NamespaceTerm", - "canonical_iri", - "classify_term_kind", - "compatible_class_pairs", - "documentation_http_behavior", - "inventory_namespace_terms", - "load_knowledge_graph_ontology", - "load_namespace_compatibility", - "load_prov_o_support_profile", - "migrate_stored_iri", -] diff --git a/pyproject.toml b/pyproject.toml index 3257416b3..cb4be2916 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.16.0" +version = "2.12.6" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } 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_namespace.py b/tests/test_ontology_namespace.py deleted file mode 100644 index 2d770333a..000000000 --- a/tests/test_ontology_namespace.py +++ /dev/null @@ -1,233 +0,0 @@ -"""Contract tests for the canonical vs repository-case ontology namespace. - -These tests are file-and-RDF-level. They do not rewrite stored evidence -and they do not require a live GitHub Pages fetch. -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest -from rdflib import OWL, URIRef, Graph -from rdflib.namespace import RDF, RDFS, SKOS - -from lineageweave.ontology import LW -from lineageweave.ontology_namespace import ( - CANONICAL_ONTOLOGY_DOCUMENT_IRI, - CANONICAL_ONTOLOGY_NAMESPACE, - COMPATIBLE_CLASS_LOCAL_NAMES, - DEPRECATED_PAGES_ONTOLOGY_DOCUMENT_IRI, - DEPRECATED_PAGES_ONTOLOGY_NAMESPACE, - PROV_O_SUPPORT_DOCUMENT_IRI, - canonical_iri, - classify_term_kind, - compatible_class_pairs, - documentation_http_behavior, - inventory_namespace_terms, - load_knowledge_graph_ontology, - load_namespace_compatibility, - load_prov_o_support_profile, - migrate_stored_iri, -) - -_REPO_ROOT = Path(__file__).resolve().parents[1] - - -def test_runtime_lw_namespace_stays_canonical_lowercase() -> None: - assert str(LW) == CANONICAL_ONTOLOGY_NAMESPACE - assert CANONICAL_ONTOLOGY_NAMESPACE != DEPRECATED_PAGES_ONTOLOGY_NAMESPACE - assert not str(LW).startswith(DEPRECATED_PAGES_ONTOLOGY_NAMESPACE) - - -def test_inventory_contains_both_public_namespace_forms() -> None: - terms = inventory_namespace_terms() - namespaces = {term.namespace for term in terms} - assert CANONICAL_ONTOLOGY_NAMESPACE in namespaces - assert DEPRECATED_PAGES_ONTOLOGY_NAMESPACE in namespaces - local_names = {(term.namespace, term.local_name) for term in terms} - for local_name in COMPATIBLE_CLASS_LOCAL_NAMES: - assert (CANONICAL_ONTOLOGY_NAMESPACE, local_name) in local_names - assert (DEPRECATED_PAGES_ONTOLOGY_NAMESPACE, local_name) in local_names - - -def test_compatible_pairs_are_classes_with_matching_local_names() -> None: - pairs = compatible_class_pairs() - assert {canonical.rsplit("#", 1)[-1] for canonical, _ in pairs} == set( - COMPATIBLE_CLASS_LOCAL_NAMES - ) - for canonical, deprecated in pairs: - assert canonical.startswith(CANONICAL_ONTOLOGY_NAMESPACE) - assert deprecated.startswith(DEPRECATED_PAGES_ONTOLOGY_NAMESPACE) - assert canonical.rsplit("#", 1)[-1] == deprecated.rsplit("#", 1)[-1] - - -def test_compatibility_graph_uses_equivalent_class_only_for_mapped_classes() -> None: - graph = load_namespace_compatibility() - mapped = { - (str(subject), str(obj)) - for subject, obj in graph.subject_objects(OWL.equivalentClass) - } - expected = set(compatible_class_pairs()) - assert mapped == expected - assert ( - URIRef(CANONICAL_ONTOLOGY_DOCUMENT_IRI), - OWL.sameAs, - URIRef(DEPRECATED_PAGES_ONTOLOGY_DOCUMENT_IRI), - ) not in graph - assert ( - URIRef(CANONICAL_ONTOLOGY_DOCUMENT_IRI), - OWL.sameAs, - URIRef(PROV_O_SUPPORT_DOCUMENT_IRI), - ) not in graph - - -def test_unmatched_kg_fragments_are_not_equivalent_classes() -> None: - graph = load_namespace_compatibility() - equivalent_objects = { - str(obj) - for obj in graph.objects( - URIRef(f"{CANONICAL_ONTOLOGY_NAMESPACE}OurSidePerson"), - OWL.equivalentClass, - ) - } - assert not equivalent_objects - mentioned = { - str(obj) - for obj in graph.objects( - URIRef(f"{CANONICAL_ONTOLOGY_NAMESPACE}mentionedIn"), - OWL.equivalentProperty, - ) - } - assert not mentioned - - -def test_canonical_iri_is_identity_for_historical_kg_terms() -> None: - historical = f"{CANONICAL_ONTOLOGY_NAMESPACE}Post" - assert canonical_iri(historical) == historical - assert migrate_stored_iri(historical) == historical - assert migrate_stored_iri(migrate_stored_iri(historical)) == historical - - -def test_canonical_iri_maps_only_compatible_class_aliases() -> None: - assert ( - canonical_iri(f"{DEPRECATED_PAGES_ONTOLOGY_NAMESPACE}Team") - == f"{CANONICAL_ONTOLOGY_NAMESPACE}Team" - ) - assert canonical_iri(f"{DEPRECATED_PAGES_ONTOLOGY_NAMESPACE}OurSidePerson") is None - assert canonical_iri(f"{DEPRECATED_PAGES_ONTOLOGY_NAMESPACE}mentionedIn") is None - assert canonical_iri("https://example.invalid/ontology#Post") is None - assert canonical_iri(CANONICAL_ONTOLOGY_NAMESPACE) is None - - -def test_migrate_stored_iri_fails_closed_on_unknown_alias() -> None: - with pytest.raises(ValueError, match="unmapped ontology IRI"): - migrate_stored_iri(f"{DEPRECATED_PAGES_ONTOLOGY_NAMESPACE}OurSidePerson") - with pytest.raises(ValueError, match="ontology document IRI"): - migrate_stored_iri(CANONICAL_ONTOLOGY_DOCUMENT_IRI) - with pytest.raises(ValueError, match="ontology document IRI"): - migrate_stored_iri(DEPRECATED_PAGES_ONTOLOGY_DOCUMENT_IRI) - with pytest.raises(ValueError, match="ontology document IRI"): - migrate_stored_iri(PROV_O_SUPPORT_DOCUMENT_IRI) - - with pytest.raises(ValueError, match="unmapped ontology IRI"): - migrate_stored_iri("https://example.invalid/ontology#Post") - - -def test_migrate_stored_iri_is_idempotent_for_mapped_alias() -> None: - alias = f"{DEPRECATED_PAGES_ONTOLOGY_NAMESPACE}Person" - once = migrate_stored_iri(alias) - assert once == f"{CANONICAL_ONTOLOGY_NAMESPACE}Person" - assert migrate_stored_iri(once) == once - - -def test_documentation_http_behavior_never_treats_redirect_as_rdf_identity() -> None: - canonical = documentation_http_behavior(f"{CANONICAL_ONTOLOGY_NAMESPACE}Post") - alias = documentation_http_behavior(f"{DEPRECATED_PAGES_ONTOLOGY_NAMESPACE}Post") - support = documentation_http_behavior(PROV_O_SUPPORT_DOCUMENT_IRI) - unknown = documentation_http_behavior("https://example.invalid/ontology#Post") - assert canonical.documentation_role == "canonical_kg" - assert alias.documentation_role == "deprecated_pages_alias" - assert support.documentation_role == "prov_o_support" - assert unknown.documentation_role == "unknown" - for behavior in (canonical, alias, support): - assert behavior.rdf_identity_from_redirect is False - assert behavior.documented_http_status == 200 - assert unknown.documented_http_status is None - assert unknown.rdf_identity_from_redirect is False - assert (_REPO_ROOT / canonical.in_repository_turtle).is_file() - assert (_REPO_ROOT / alias.in_repository_turtle).is_file() - assert (_REPO_ROOT / support.in_repository_turtle).is_file() - - -def test_document_iris_are_specified_as_http_200() -> None: - for iri in ( - CANONICAL_ONTOLOGY_DOCUMENT_IRI, - DEPRECATED_PAGES_ONTOLOGY_DOCUMENT_IRI, - PROV_O_SUPPORT_DOCUMENT_IRI, - ): - behavior = documentation_http_behavior(iri) - assert behavior.documented_http_status == 200 - assert behavior.rdf_identity_from_redirect is False - - -def test_in_repository_turtle_files_parse_for_both_namespace_forms() -> None: - kg = load_knowledge_graph_ontology() - profile = load_prov_o_support_profile() - assert (URIRef(CANONICAL_ONTOLOGY_DOCUMENT_IRI), RDF.type, OWL.Ontology) in kg - assert (URIRef(PROV_O_SUPPORT_DOCUMENT_IRI), RDF.type, OWL.Ontology) in profile - for local_name in COMPATIBLE_CLASS_LOCAL_NAMES: - assert URIRef(f"{CANONICAL_ONTOLOGY_NAMESPACE}{local_name}") in { - subject for subject in kg.subjects() - } - assert URIRef(f"{DEPRECATED_PAGES_ONTOLOGY_NAMESPACE}{local_name}") in { - subject for subject in profile.subjects() - } - - -def test_compatibility_graph_is_isomorphic_across_reloads() -> None: - first = load_namespace_compatibility() - second = load_namespace_compatibility() - assert first.isomorphic(second) - - -def test_classify_term_kind_is_fail_closed_and_kind_specific() -> None: - graph = Graph() - owl_class = URIRef("https://example.invalid/ontology#OwlClass") - rdfs_class = URIRef("https://example.invalid/ontology#RdfsClass") - obj_prop = URIRef("https://example.invalid/ontology#ObjProp") - data_prop = URIRef("https://example.invalid/ontology#DataProp") - ann_prop = URIRef("https://example.invalid/ontology#AnnProp") - concept = URIRef("https://example.invalid/ontology#AConcept") - subclass = URIRef("https://example.invalid/ontology#SubOnly") - other = URIRef("https://example.invalid/ontology#Other") - graph.add((owl_class, RDF.type, OWL.Class)) - graph.add((rdfs_class, RDF.type, RDFS.Class)) - graph.add((obj_prop, RDF.type, OWL.ObjectProperty)) - graph.add((data_prop, RDF.type, OWL.DatatypeProperty)) - graph.add((ann_prop, RDF.type, OWL.AnnotationProperty)) - graph.add((concept, RDF.type, SKOS.Concept)) - graph.add((subclass, RDFS.subClassOf, owl_class)) - graph.add((other, RDFS.label, URIRef("https://example.invalid/ontology#Label"))) - assert classify_term_kind(graph, str(owl_class)) == "class" - assert classify_term_kind(graph, str(rdfs_class)) == "class" - assert classify_term_kind(graph, str(obj_prop)) == "object_property" - assert classify_term_kind(graph, str(data_prop)) == "datatype_property" - assert classify_term_kind(graph, str(ann_prop)) == "annotation_property" - assert classify_term_kind(graph, str(concept)) == "concept" - assert classify_term_kind(graph, str(subclass)) == "class" - assert classify_term_kind(graph, str(other)) == "other" - - -def test_kind_mismatch_does_not_emit_equivalent_class() -> None: - """A shared local name of mixed term kind must not be mapped.""" - kg_terms = { - term.local_name: term.term_kind - for term in inventory_namespace_terms() - if term.namespace == CANONICAL_ONTOLOGY_NAMESPACE - } - assert kg_terms.get("mentionedIn") != "class" - assert ( - f"{CANONICAL_ONTOLOGY_NAMESPACE}mentionedIn", - f"{DEPRECATED_PAGES_ONTOLOGY_NAMESPACE}mentionedIn", - ) not in compatible_class_pairs() 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