From d4decbae161dd1394d7cd6c9950c1278dba19719 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Tue, 25 Aug 2026 19:08:14 +0900 Subject: [PATCH 1/9] feat(ontology): make repository-case namespace canonical (v2.16.0, ADR 0205) Resolves #372. Supersedes ADR 0157: the repository-case public namespace https://contextualwisdomlab.github.io/LineageWeave/ontology# is the exact project path GitHub Pages serves and becomes the only spelling new runtime values, exports, fixtures, and database rows mint. The lowercase form stays published as a deprecated compatibility vocabulary with validated equivalentClass/equivalentProperty mappings, and scripts/migrate_legacy_namespace.py rewrites stored lowercase IRIs (dry-run by default; never touches provenance columns). Also lands the completeness increments that motivated the decision: grounded node-attribute datatype properties, the SKOS post-type scheme, person-side disjointness plus inverse affiliation, and a closed-world SHACL shapes graph (docs/ontology/lineageweave-kg-shapes.ttl) validated against every DB-to-RDF projection and published beside the ontology. Full unit suite: 1095 passed, 12 skipped. --- AGENTS.md | 16 +- ...-public-ontology-namespace-completeness.md | 41 ++++ CHANGELOG.md | 23 +++ backend/tests/test_api.py | 2 +- ...itory-case-ontology-namespace-canonical.md | 177 ++++++++++++++++ docs/adr/README.md | 2 +- .../doctoring/ONTOLOGY_NAMESPACE_INVENTORY.md | 15 ++ docs/ontology/lineageweave-kg-shapes.ttl | 157 +++++++++++++++ docs/ontology/lineageweave-kg.ttl | 173 ++++++++++++++-- docs/ontology/namespace-compatibility.ttl | 14 +- docs/ontology/prov-o-support-profile.ttl | 6 +- frontend/src/App.test.tsx | 26 +-- .../components/OntologyExplorer.stories.tsx | 16 +- lineageweave/ontology.py | 7 +- pyproject.toml | 6 +- scripts/build_ontology_site.py | 20 +- scripts/migrate_legacy_namespace.py | 30 +-- scripts/publish_ontology_site.py | 66 +++++- tests/test_ontology.py | 118 ++++++++++- tests/test_ontology_shapes.py | 189 ++++++++++++++++++ tests/test_ontology_site.py | 17 ++ tests/test_post_chat.py | 2 +- tests/test_prov_o.py | 8 +- tests/test_publish_ontology_site.py | 60 ++++++ 24 files changed, 1111 insertions(+), 80 deletions(-) create mode 100644 CHANGELOG.d/2.16.0-public-ontology-namespace-completeness.md create mode 100644 docs/adr/0205-repository-case-ontology-namespace-canonical.md create mode 100644 docs/ontology/lineageweave-kg-shapes.ttl create mode 100644 tests/test_ontology_shapes.py diff --git a/AGENTS.md b/AGENTS.md index b5961427c..818d1db7e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -372,7 +372,15 @@ The ontology namespace publishes as a deterministic GitHub Pages artifact bytes -- no build timestamps, source SHA-256 manifest) and deploy only through the fail-closed `scripts/publish_ontology_site.py` from `main`. A manual dispatch from another ref is not a publication path. The -lowercase and repository-case public namespace IRIs remain a tracked -interoperability gap ([ADR 0157](docs/adr/0157-public-ontology-namespace-identity.md), -issue #372): do not silently rewrite either form; any namespace migration -is a versioned ADR with compatibility mappings first. +repository-case public namespace +`https://contextualwisdomlab.github.io/LineageWeave/ontology#` is +canonical ([ADR 0205](docs/adr/0205-repository-case-ontology-namespace-canonical.md), +superseding ADR 0157, resolving issue #372); the lowercase form is a +deprecated compatibility vocabulary with validated term-kind mappings. +New runtime values, exports, fixtures, and database rows mint only +repository-case IRIs; `scripts/migrate_legacy_namespace.py` rewrites +stored lowercase IRIs (dry-run by default, never touching provenance +columns). Do not silently rewrite either historical form. The SHACL +shapes graph (`docs/ontology/lineageweave-kg-shapes.ttl`) is the +closed-world data-validation boundary for DB-to-RDF projections and is +published beside the ontology. diff --git a/CHANGELOG.d/2.16.0-public-ontology-namespace-completeness.md b/CHANGELOG.d/2.16.0-public-ontology-namespace-completeness.md new file mode 100644 index 000000000..2354cb0a0 --- /dev/null +++ b/CHANGELOG.d/2.16.0-public-ontology-namespace-completeness.md @@ -0,0 +1,41 @@ +# 2.16.0 — Public ontology namespace completeness + +## Added + +- The repository-case public ontology namespace + `https://contextualwisdomlab.github.io/LineageWeave/ontology#` is + canonical ([ADR 0205], superseding ADR 0157, resolving #372): it is the + exact project path GitHub Pages serves. The lowercase form is a deprecated + compatibility vocabulary published beside the ontology with validated + `owl:equivalentClass` / `owl:equivalentProperty` mappings; + `scripts/migrate_legacy_namespace.py` now rewrites stored lowercase IRIs to + the canonical spelling (dry-run by default, transactional, never touching + provenance columns). +- Node-attribute datatype properties grounded in real schema columns: + `postTitle`, `postBody`, `eventOccurredAt` on posts; + `personName`, `lastKnownJobTitle` on persons; `entityName`, + `entityCode` on corporate entities; shared domain-free `createdAt` / + `updatedAt` record timestamps whose per-class cardinality lives in SHACL. + No property is minted for a column that does not exist. +- A SKOS post-type concept scheme (`postTypeScheme`) formalizing the governed + five-value `voc_type` vocabulary (`voc`, `vocc`, `voco`, `vom`, `vop`), + bringing that lookup category under the ontology round-trip check for the + first time. Keyman job titles and industry sectors stay unmodeled free text; + no scheme is invented without a governed source vocabulary. +- Logical integrity constraints: `OurSidePerson owl:disjointWith + CounterpartyPerson` and `hasAffiliate owl:inverseOf affiliatedWith` so + reasoners refuse impossible person sides and bidirectional affiliation + queries resolve without a second stored edge. +- `docs/ontology/lineageweave-kg-shapes.ttl`, the closed-world SHACL shapes + graph validating DB-to-RDF projections: required post title/body/timestamp, + confidence bounded to `[0.0, 1.0]` inclusive, required names and entity + code, and the our-side/counterparty disjointness complement. Validated in + CI with pyshacl including negative violation tests; published beside the + ontology with dangling-target refusal in the fail-closed publisher. + +## Changed + +- Ontology publication artifacts now include the shapes graph; the build + manifest lists it deterministically and the documentation page links it. + +[ADR 0205]: docs/adr/0205-repository-case-ontology-namespace-canonical.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 98f3c45c6..2e21777b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,29 @@ All notable changes to this project are documented here. Format follows ### Added +- The repository-case public ontology namespace + `https://contextualwisdomlab.github.io/LineageWeave/ontology#` is canonical + (ADR 0205, superseding ADR 0157, resolving issue #372); the lowercase form + is a deprecated compatibility vocabulary with validated mappings and a + dry-run migration tool for stored values. +- Closed-world SHACL validation (`docs/ontology/lineageweave-kg-shapes.ttl`, + pyshacl in tests): required post title/body/timestamp, project-mention + confidence bounded to `[0.0, 1.0]`, required person/entity names and entity + code, and the our-side/counterparty disjointness complement; published with + the ontology site and guarded against dangling shape targets. +- Node-attribute datatype properties grounded only in real schema columns + (`postTitle`, `postBody`, `eventOccurredAt`, `personName`, + `lastKnownJobTitle`, `entityName`, `entityCode`, shared domain-free + `createdAt`/`updatedAt`), a SKOS post-type scheme formalizing the governed + five-value `voc_type` vocabulary under the round-trip check, and logical + constraints: `OurSidePerson owl:disjointWith CounterpartyPerson` plus the + `hasAffiliate` inverse of `affiliatedWith` (ADR 0205). + +### Changed + +- Ontology publication artifacts now include the SHACL shapes graph; the + manifest lists it deterministically and the documentation page links it. + - Registered the `analysis_run_topic_lineage` analysis-run kind (migrations 0131/0132, ADR 0132), the LineageWeave-side consumption boundary for TEPP's Temporal Relational Shared-Latent Topic Measurement (TRSL-TM, diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index dee21f626..542cdafe9 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -1754,7 +1754,7 @@ def test_post_detail_exposes_explicit_and_semantic_project_evidence( "Semantic project", "project was described in the body", 0.82, - "https://contextualwisdomlab.github.io/lineageweave/ontology#Project", + "https://contextualwisdomlab.github.io/LineageWeave/ontology#Project", "contextual_orchestrator_semantic", ), ) diff --git a/docs/adr/0205-repository-case-ontology-namespace-canonical.md b/docs/adr/0205-repository-case-ontology-namespace-canonical.md new file mode 100644 index 000000000..1ee17009e --- /dev/null +++ b/docs/adr/0205-repository-case-ontology-namespace-canonical.md @@ -0,0 +1,177 @@ +# ADR 0205 — Make the repository-case public ontology namespace canonical + +**Decision status:** Accepted +**Date:** 2026-08-25 +**Supersedes:** [ADR 0157](0157-public-ontology-namespace-identity.md) +**Resolves:** [#372](https://github.com/ContextualWisdomLab/LineageWeave/issues/372) + +## Context + +ADR 0157 made the lowercase +`https://contextualwisdomlab.github.io/lineageweave/ontology#` namespace +canonical and demoted the repository-case +`https://contextualwisdomlab.github.io/LineageWeave/ontology#` to a +compatibility vocabulary. Its decision 8 required an owned route at the exact +lowercase path before migration could complete. That owned lowercase route +does not exist and is not planned: GitHub Pages serves this repository only at +the project path `/LineageWeave/`, which matches the repository name, and an +organization-site alias for a second case-distinct path would permanently +split one publication into two hosting surfaces that must be kept in lockstep. + +The product owner has now directed the opposite resolution: the public +ontology namespace must use the repository-case spelling `LineageWeave`, the +same case GitHub Pages actually serves. This aligns the semantic identifier +with (a) the dereferenceable documentation endpoint already published at +`https://contextualwisdomlab.github.io/LineageWeave/ontology` ([ADR +0159](0159-published-ontology-pages.md)), (b) the repository name every +downstream consumer sees, and (c) the PROV-O support profile's historical +IRIs. RDF treats the two spellings as different resources, so the flip is a +real identifier change with real compatibility obligations -- it is not a +cosmetic edit. Per ADR 0157's own terms, both forms are externally durable: +stored `post_project_mention.ontology_iri` values may still carry the +lowercase form, and downstream graphs may have copied either. + +The same slice completes the ontology's missing definitions: node attribute +datatype properties, a SKOS post-type concept scheme grounded in the seeded +`voc_type` controlled vocabulary, OWL disjointness and inverse constraints, +and a SHACL shapes graph for closed-world data validation ([Knublauch & +Kontokostas, 2017]). SHACL carries the cardinality and value-range checks +OWL's open-world semantics deliberately does not, so DB-to-RDF projections +fail loudly instead of silently polluting downstream graphs. + +## Decision + +1. The canonical namespace for all existing and future LineageWeave ontology + terms is the repository-case + `https://contextualwisdomlab.github.io/LineageWeave/ontology#`. New runtime + values, RDF exports, database rows, examples, API payloads, and generated + Pages artifacts mint only repository-case term IRIs. +2. The lowercase namespace is now the deprecated compatibility namespace. It + remains dereferenceable through the published compatibility vocabulary and + is never reused for different meanings. +3. The publication slice serves both namespace documents with `200 OK`. The + repository-case document is authoritative; the lowercase document identifies + it via `dcterms:isReplacedBy`, carries `owl:deprecated true`, and holds only + validated mappings. A redirect alone remains insufficient, per ADR 0157's + reasoning, which applies symmetrically. +4. Compatibility mappings are generated between the two parsed graphs and + emitted only when local-name uniqueness, term kind, and defining semantics + match: class-to-class `owl:equivalentClass`; property-to-same-kind + `owl:equivalentProperty`; SKOS-concept-to-SKOS-concept `skos:exactMatch` + after meaning verification; individuals `owl:sameAs` only with identity + evidence. A term without sufficient evidence receives no equivalence + assertion. The publication validator enforces local-name equality and term + kinds fail-closed in both directions. +5. Historical RDF, provenance bundles, and evidence rows are immutable. + `scripts/migrate_legacy_namespace.py` now rewrites stored lowercase IRIs to + the repository-case spelling -- dry-run by default, transactional, + idempotent, never touching provenance columns, refusing unknown third + spellings. +6. Producers stop minting lowercase IRIs in this release. The lowercase + compatibility vocabulary stays marked deprecated for at least 180 days and + two minor releases, whichever is later; dereferenceability and mappings are + not removed at the end of that window. +7. Node-attribute datatype properties are declared only for columns the + relational schema actually defines (`source_post.post_title`, + `post_body`, `created_at`, `updated_at`, `event_occurred_at`; + `cataloged_person.person_name`, `last_known_job_title`; + `corporate_entity.corporate_entity_code`, `entity_name`). No invented + column-backed property (country, business registration number) is minted. + Shared timestamp properties carry no `rdfs:domain` because two `rdfs:domain` + statements on one property entail subjects belong to both classes -- the + same multi-domain trap ADR 0004's edge design already avoids; per-class + cardinality lives in the SHACL shapes instead. +8. The post-type classification is formalized as SKOS exactly where the + relational source has a governed vocabulary: the five seeded `voc_type` + codes (`voc`, `vocc`, `voco`, `vom`, `vop`) become concepts in a + `skos:ConceptScheme`. Keyman job titles and industry sectors remain free + text in the schema with no lookup category, so no scheme is invented for + them yet; that gap is tracked rather than fabricated. +9. Logical integrity constraints are stated explicitly: + `:OurSidePerson owl:disjointWith :CounterpartyPerson` (a person side is one + or the other, per the seeded `person_side` vocabulary), and + `:hasAffiliate owl:inverseOf :affiliatedWith` so bidirectional person-to- + entity queries resolve without a second stored edge. The inverse carries no + relational lookup code, mirroring the existing `:mentions` / + `:mentionedIn` pair. +10. A separate SHACL shapes graph validates projected data: + required post title/body/timestamps, single-valued confidence within + `[0.0, 1.0]`, required names on persons and entities, and the closed-world + complement of the our-side/counterparty disjointness. Publication copies + the shapes artifact beside the ontology and refuses dangling shape targets + outside the canonical namespace. + +## Considered options + +### Repository-case canonical — chosen + +Matches the only hosting path Pages actually serves, keeps one publication +surface, honors the owner directive, and preserves the support profile's +historical IRIs. Cost: stored lowercase IRIs migrate once through the existing +transactional tooling, and the compatibility vocabulary flips direction. + +### Keep lowercase canonical (ADR 0157 status quo) + +Would require building and operating a second, organization-hosted lowercase +route forever, splitting semantic identity from the served documentation path. +Rejected by the owner directive and by deployment simplicity. + +### Treat both namespaces as canonical + +Rejected for the same reason ADR 0157 rejected it: RDF consumers correctly +treat distinct IRIs as distinct resources; dual authorities preserve the +interoperability defect. + +## Consequences + +- One canonical namespace aligned with the served Pages path; new producers + are unambiguous. +- The lowercase compatibility vocabulary remains resolvable indefinitely; + existing serialized graphs keep resolving through validated mappings. +- Stored-value migration runs through `scripts/migrate_legacy_namespace.py` + with its existing dry-run/refusal discipline, direction reversed. +- Runtime constants, Turtle/JSON-LD/N-Triples, support profile, API and + frontend fixtures, database seeds' consumers, and the generated Pages + artifact move in one synchronized release. +- SHACL validation becomes a first-class test gate over the source graph. + +## Verification + +- Exact `200` responses for both namespace documents and representative + fragments, with the repository-case document identified as canonical. +- RDF graph-isomorphism and term-kind tests for every emitted mapping; no + duplicate local fragments across namespaces. +- Consumer fixtures prove old lowercase graphs still resolve and new + serialization mints only repository-case IRIs. +- Transactional migration tests prove idempotency, rollback safety, and + provenance-column preservation (direction-reversed). +- SHACL conformance of the source ontology plus a negative violation test; + publication manifest lists the shapes artifact deterministically. + +## Related decisions + +- [ADR 0004](0004-knowledge-graph-ontology.md): ontology vocabulary authority. +- [ADR 0011](0011-prov-o-standard-relations.md) / + [ADR 0065](0065-prov-o-provenance-boundary.md): PROV-O boundary. +- [ADR 0157](0157-public-ontology-namespace-identity.md): superseded; its + compatibility-mapping mechanics are retained with direction reversed. +- [ADR 0159](0159-published-ontology-pages.md): deterministic Pages + publication pipeline extended with the shapes artifact. +- Issue #372 owns the completed implementation and verification. + +## References — APA 7th + +Knublauch, H., & Kontokostas, R. (Eds.). (2017). *SHACL: Shapes constraint +language* (W3C Recommendation). World Wide Web Consortium. +https://www.w3.org/TR/shacl/ + +Miles, A., & Bechhofer, S. (Eds.). (2009). *SKOS simple knowledge organization +system reference* (W3C Recommendation). 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 quick reference +guide* (2nd ed., W3C Recommendation). World Wide Web Consortium. +https://www.w3.org/TR/owl2-quick-reference/ diff --git a/docs/adr/README.md b/docs/adr/README.md index 77a7cec4e..8db3a4d94 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -13,7 +13,7 @@ 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), [0024](0024-rankweave-fusion-fail-closed.md), [0165](0165-quantity-script-display.md), [0167](0167-rankweave-ranking-channel-evidence.md), [0169](0169-ask-batched-lineage-graph.md), [0202](0202-ask-event-time-filter.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_INVENTORY.md`](../doctoring/ONTOLOGY_NAMESPACE_INVENTORY.md) | [0157](0157-public-ontology-namespace-identity.md) | +| [`ONTOLOGY_NAMESPACE_INVENTORY.md`](../doctoring/ONTOLOGY_NAMESPACE_INVENTORY.md) | [0205](0205-repository-case-ontology-namespace-canonical.md), [0157](0157-public-ontology-namespace-identity.md) | | [`image-content-schema.md`](../image-content-schema.md) | [0066](0066-position-preserving-image-content.md) | | [`storybook-inventory.md`](../storybook-inventory.md) | [0118](0118-uiux-standard-guide-v3-design-overhaul.md), [0184](0184-ontology-provenance-explorer.md) | | [`POSTGRESQL_CONCURRENCY_REFERENCES.md`](../doctoring/POSTGRESQL_CONCURRENCY_REFERENCES.md) | [0204](0204-analysis-run-short-transaction-delivery.md) | diff --git a/docs/doctoring/ONTOLOGY_NAMESPACE_INVENTORY.md b/docs/doctoring/ONTOLOGY_NAMESPACE_INVENTORY.md index e797324cf..b064ac2e5 100644 --- a/docs/doctoring/ONTOLOGY_NAMESPACE_INVENTORY.md +++ b/docs/doctoring/ONTOLOGY_NAMESPACE_INVENTORY.md @@ -81,3 +81,18 @@ decision uses ADR 0157. PR #485 now owns ADR `0158` at exact head file uses `0159`, so the publication decision in this stack uses ADR 0159. Recheck immediately before integration; neither number is a global allocator reservation. + +## Resolution (ADR 0205, 2026-08-25) + +The product owner directed the opposite canonicalization: the +repository-case namespace +`https://contextualwisdomlab.github.io/LineageWeave/ontology#` is now +canonical ([ADR 0205](../adr/0205-repository-case-ontology-namespace-canonical.md), +superseding [ADR 0157](../adr/0157-public-ontology-namespace-identity.md), +resolving issue #372). The lowercase form is a deprecated compatibility +vocabulary; both documents stay dereferenceable through the published +site with validated term-kind mappings, and stored values migrate via +`scripts/migrate_legacy_namespace.py` with its dry-run/refusal +discipline (direction reversed: lowercase rows rewrite to +repository-case). The inventory above remains the historical evidence +for why both forms were treated as externally durable. diff --git a/docs/ontology/lineageweave-kg-shapes.ttl b/docs/ontology/lineageweave-kg-shapes.ttl new file mode 100644 index 000000000..9a2c8d6a9 --- /dev/null +++ b/docs/ontology/lineageweave-kg-shapes.ttl @@ -0,0 +1,157 @@ +@prefix : . +@prefix dcterms: . +@prefix owl: . +@prefix rdfs: . +@prefix sh: . +@prefix xsd: . + +################################################################# +# LineageWeave Knowledge Graph SHACL shapes +# +# Closed-world data validation over the OWL vocabulary above. OWL's +# open-world semantics infers; it does not verify that projected data +# arrived complete and in range (Knublauch & Kontokostas, 2017). These +# shapes carry exactly that verification for DB-to-RDF projections: +# +# - required post title/body/creation timestamp; +# - single-valued project-mention evidence with confidence bounded to +# [0.0, 1.0] inclusive; +# - required person and entity names, optional bounded job title; +# - the closed-world complement of :OurSidePerson +# owl:disjointWith :CounterpartyPerson. +# +# Every sh:targetClass / sh:path IRI must live in the canonical +# repository-case namespace -- scripts/publish_ontology_site.py fails +# closed on dangling targets so a renamed term cannot silently orphan +# its shape. tests/test_ontology_shapes.py validates this graph against +# the ontology source with pyshacl, plus a negative violation test. +################################################################# + + + a owl:Ontology ; + dcterms:title "LineageWeave Knowledge Graph SHACL shapes"@en ; + dcterms:description "Closed-world validation shapes over the LineageWeave knowledge-graph ontology: required attributes, confidence bounds, and side disjointness."@en ; + owl:imports ; + owl:versionInfo "1.0.0" . + +:PostShape a sh:NodeShape ; + rdfs:label "Post shape" ; + sh:targetClass :Post ; + sh:property [ + sh:path :postTitle ; + sh:name "post title" ; + sh:description "Every post projects a non-empty source_post.post_title." ; + sh:minCount 1 ; + sh:maxCount 1 ; + sh:datatype xsd:string ; + sh:minLength 1 ; + ] ; + sh:property [ + sh:path :postBody ; + sh:name "post body" ; + sh:description "The preserved source representation is never absent." ; + sh:minCount 1 ; + sh:maxCount 1 ; + sh:datatype xsd:string ; + sh:minLength 1 ; + ] ; + sh:property [ + sh:path :createdAt ; + sh:name "created at" ; + sh:description "A post carries at least one record-creation instant; Global Ask falls back to it only when event_occurred_at is missing (ADR 0150)." ; + sh:minCount 1 ; + sh:maxCount 1 ; + sh:datatype xsd:dateTime ; + ] ; + sh:property [ + sh:path :updatedAt ; + sh:name "updated at" ; + sh:description "Optional last-write instant; importers fall back to created_at when null." ; + sh:maxCount 1 ; + sh:datatype xsd:dateTime ; + ] ; + sh:property [ + sh:path :eventOccurredAt ; + sh:name "event occurred at" ; + sh:description "Optional business-event instant bound by ADR 0150/0202 time filters." ; + sh:maxCount 1 ; + sh:datatype xsd:dateTime ; + ] . + +:PersonShape a sh:NodeShape ; + rdfs:label "Person shape" ; + sh:targetClass :Person ; + sh:property [ + sh:path :personName ; + sh:name "person name" ; + sh:description "cataloged_person.person_name is not null." ; + sh:minCount 1 ; + sh:maxCount 1 ; + sh:datatype xsd:string ; + sh:minLength 1 ; + ] ; + sh:property [ + sh:path :lastKnownJobTitle ; + sh:name "last known job title" ; + sh:description "Nullable disambiguation signal; at most one." ; + sh:maxCount 1 ; + sh:datatype xsd:string ; + ] . + +:CorporateEntityShape a sh:NodeShape ; + rdfs:label "Corporate entity shape" ; + sh:targetClass :CorporateEntity ; + sh:property [ + sh:path :entityName ; + sh:name "entity name" ; + sh:description "The human-readable hierarchy label is not null." ; + sh:minCount 1 ; + sh:maxCount 1 ; + sh:datatype xsd:string ; + sh:minLength 1 ; + ] ; + sh:property [ + sh:path :entityCode ; + sh:name "entity code" ; + sh:description "The login corp code is unique and not null." ; + sh:minCount 1 ; + sh:maxCount 1 ; + sh:datatype xsd:string ; + sh:minLength 1 ; + ] . + +:ProjectMentionShape a sh:NodeShape ; + rdfs:label "Project mention shape" ; + sh:targetClass :ProjectMention ; + sh:property [ + sh:path :semanticConfidence ; + sh:name "semantic confidence" ; + sh:description "Extraction confidence stays inside [0.0, 1.0] inclusive." ; + sh:minInclusive 0.0 ; + sh:maxInclusive 1.0 ; + ] ; + sh:property [ + sh:path :projectEvidence ; + sh:name "project evidence" ; + sh:description "At most one verbatim evidence span per mention; missing evidence is an honest unknown, never zero-filled." ; + sh:maxCount 1 ; + sh:datatype xsd:string ; + ] . + +:OurSidePersonShape a sh:NodeShape ; + rdfs:label "Our-side person shape" ; + sh:comment "Closed-world complement of :OurSidePerson owl:disjointWith :CounterpartyPerson: an instance of one can never be typed as the other." ; + sh:targetClass :OurSidePerson ; + sh:not [ + a sh:NodeShape ; + sh:class :CounterpartyPerson ; + ] . + +:CounterpartyPersonShape a sh:NodeShape ; + rdfs:label "Counterparty person shape" ; + sh:comment "Mirror direction of the disjointness complement." ; + sh:targetClass :CounterpartyPerson ; + sh:not [ + a sh:NodeShape ; + sh:class :OurSidePerson ; + ] . diff --git a/docs/ontology/lineageweave-kg.ttl b/docs/ontology/lineageweave-kg.ttl index c945b98f7..2f5fa7f22 100644 --- a/docs/ontology/lineageweave-kg.ttl +++ b/docs/ontology/lineageweave-kg.ttl @@ -1,4 +1,4 @@ -@prefix : . +@prefix : . @prefix owl: . @prefix rdf: . @prefix rdfs: . @@ -13,27 +13,38 @@ # The formal OWL 2 Full / RDFS / SKOS vocabulary for the # `knowledge_graph_edge` table's node/edge types, the # `entity_relationship_type` / `person_side` / `corporate_entity_level` -# controlled vocabularies in migrations/0001_initial_schema.sql, and +# / `voc_type` controlled vocabularies in migrations/, and # `post_summary_role.actor_type_code` (migrations/0012). # +# ADR 0205 supersedes ADR 0157: the canonical namespace is the +# repository-case spelling above -- the exact path GitHub Pages serves. +# The lowercase namespace is a deprecated compatibility vocabulary +# published beside this file as namespace-compatibility.ttl with +# validated term-kind mappings; new producers must not mint lowercase +# IRIs. +# # `knowledge_graph_edge` (source_node_type_code, source_node_id) -- # [edge_type_code] --> (target_node_type_code, target_node_id) is # already an RDF triple in shape (Cyganiak, Wood, & Lanthaler, 2014); # this file is the formal semantic layer over it -- PostgreSQL stays # the source of record. See docs/adr/0004-knowledge-graph-ontology.md -# for the KG design rationale, docs/adr/0006-role-responsibility-agent-ontology.md -# for the R&R actor-type rationale (grounded in W3C PROV-O), and -# tests/test_ontology.py for the round-trip check that every code below -# actually exists as a common_lookup_value row, and vice versa. +# for the KG design rationale, docs/adr/0205-repository-case-ontology-namespace-canonical.md +# for the namespace decision and SHACL boundary, and tests/test_ontology.py +# for the round-trip check that every lookup code below actually exists +# as a common_lookup_value row, and vice versa. # -# Every custom term carries a :lookupCode annotation naming the exact -# `common_lookup_value.lookup_code` it corresponds to -- that literal -# string, not the IRI fragment, is what the relational schema stores. +# Every controlled-vocabulary term carries a :lookupCode annotation +# naming the exact `common_lookup_value.lookup_code` it corresponds to +# -- that literal string, not the IRI fragment, is what the relational +# schema stores. Column-projection datatype properties deliberately do +# NOT carry :lookupCode: they project table columns, not governed +# lookup rows, so there is nothing for the round-trip check to enforce +# (the same discipline as the organization_name_resolution block below). ################################################################# - a owl:Ontology ; + a owl:Ontology ; rdfs:label "LineageWeave Knowledge Graph Ontology" ; - rdfs:comment "Formal OWL 2 Full / RDFS / SKOS vocabulary for LineageWeave's knowledge_graph_edge node and edge types, entity_relationship_type, person_side, corporate_entity_level, and post_summary_role.actor_type_code controlled vocabularies. RDF reification for semantic project evidence is interpreted with OWL 2 RDF-Based Semantics rather than OWL 2 DL." . + rdfs:comment "Formal OWL 2 Full / RDFS / SKOS vocabulary for LineageWeave's knowledge_graph_edge node and edge types, entity_relationship_type, person_side, corporate_entity_level, voc_type, and post_summary_role.actor_type_code controlled vocabularies. RDF reification for semantic project evidence is interpreted with OWL 2 RDF-Based Semantics rather than OWL 2 DL." . :lookupCode a owl:AnnotationProperty ; rdfs:label "lookup code" ; @@ -45,7 +56,7 @@ :Post a owl:Class ; rdfs:label "Post" ; - rdfs:comment "A source_post row: one VOC/VOM/VOP/VOCC/VOCO/VOS record." ; + rdfs:comment "A source_post row: one record typed by the voc_type scheme (:postTypeScheme) -- Voice of Customer, Customer's Customer, Competitor, Market, or Partner." ; :lookupCode "node_post" . :Person a owl:Class ; @@ -63,6 +74,12 @@ rdfs:label "Counterparty person" ; :lookupCode "counterparty" . +# A person side is exactly one of our-side or counterparty (the seeded +# person_side vocabulary has no third value), so the two subclasses are +# declared disjoint: a reasoner must never infer both from one row, and +# the SHACL shapes graph carries the closed-world complement. +:OurSidePerson owl:disjointWith :CounterpartyPerson . + :CorporateEntity a owl:Class ; rdfs:subClassOf skos:Concept ; rdfs:label "Corporate entity" ; @@ -101,6 +118,16 @@ rdfs:comment "A person's N:N organizational affiliation (person_affiliation)." ; :lookupCode "edge_affiliation" . +# Bidirectional query support for affiliations: consumers can traverse +# entity -> people without a second stored edge. Like :mentions above, +# the inverse stays un-coded so one lookup_code keeps naming exactly one +# stored property. +:hasAffiliate a owl:ObjectProperty ; + rdfs:domain :CorporateEntity ; + rdfs:range :Person ; + rdfs:label "has affiliate" ; + owl:inverseOf :affiliatedWith . + :coMentionedWith a owl:ObjectProperty, owl:SymmetricProperty ; rdfs:domain :Person ; rdfs:range :Person ; @@ -173,6 +200,123 @@ rdfs:label "has Voice-of-Supplier relationship" ; :lookupCode "rel_vos" . +################################################################# +# Datatype properties -- node attribute projections. +# +# These project real source columns (source_post.post_title / +# post_body / created_at / updated_at / event_occurred_at; +# cataloged_person.person_name / last_known_job_title; +# corporate_entity.corporate_entity_code / entity_name). No property +# is minted for a column that does not exist. Shared timestamps carry +# NO rdfs:domain on purpose: two rdfs:domain statements would entail +# every subject belongs to BOTH classes -- the multi-domain trap the +# cross-post edge block above already avoids. Per-class cardinality +# and datatype constraints live in the SHACL shapes graph +# (lineageweave-kg-shapes.ttl), which validates projected data +# closed-world where OWL's open world deliberately will not +# (Knublauch & Kontokostas, 2017). +################################################################# + +:postTitle a owl:DatatypeProperty ; + rdfs:domain :Post ; + rdfs:range xsd:string ; + rdfs:label "post title" ; + rdfs:comment "source_post.post_title -- the authoring application's title text." . + +:postBody a owl:DatatypeProperty ; + rdfs:domain :Post ; + rdfs:range xsd:string ; + rdfs:label "post body" ; + rdfs:comment "source_post.post_body -- the preserved source representation, never flattened into one opaque string by derived views." . + +:eventOccurredAt a owl:DatatypeProperty ; + rdfs:domain :Post ; + rdfs:range xsd:dateTime ; + rdfs:label "event occurred at" ; + rdfs:comment "source_post.event_occurred_at (migrations 0183) -- the business event instant Global Ask time filters bind to, falling back to created_at only when missing (ADR 0150)." . + +:personName a owl:DatatypeProperty ; + rdfs:domain :Person ; + rdfs:range xsd:string ; + rdfs:label "person name" ; + rdfs:comment "cataloged_person.person_name -- Keyman extraction tests the raw organization name before any abbreviation rewrite so a rewrite cannot turn an existing tie into an apparent creation miss (ADR 0026)." . + +:lastKnownJobTitle a owl:DatatypeProperty ; + rdfs:domain :Person ; + rdfs:range xsd:string ; + rdfs:label "last known job title" ; + rdfs:comment "cataloged_person.last_known_job_title (migrations 0013) -- a stated title is real same-name disambiguation evidence even when no affiliation row exists." . + +:entityName a owl:DatatypeProperty ; + rdfs:domain :CorporateEntity ; + rdfs:range xsd:string ; + rdfs:label "entity name" ; + rdfs:comment "corporate_entity.entity_name -- the human-readable hierarchy label; corporate similarity results stay unique/miss/tie over this name (ADR 0026)." . + +:entityCode a owl:DatatypeProperty ; + rdfs:domain :CorporateEntity ; + rdfs:range xsd:string ; + rdfs:label "entity code" ; + rdfs:comment "corporate_entity.corporate_entity_code -- the short corp code carried at login time, distinct from the display name." . + +# Shared record timestamps apply to every KG node kind, so they declare +# no domain (see the block comment above); the shapes graph pins them +# per class. +:createdAt a owl:DatatypeProperty ; + rdfs:range xsd:dateTime ; + rdfs:label "created at" ; + rdfs:comment "Record creation instant shared across node kinds (each source table's created_at); no rdfs:domain because multiple domains would entail impossible co-membership." . + +:updatedAt a owl:DatatypeProperty ; + rdfs:range xsd:dateTime ; + rdfs:label "updated at" ; + rdfs:comment "Record last-write instant shared across node kinds (e.g. source_post.updated_at); null updated-at falls back to created_at at import boundaries." . + +################################################################# +# SKOS -- voc_type (post type classification) +# +# The five-value VOC source vocabulary migrations/0042 governs. There +# are exactly five seeded codes: vos exists only as a relationship type +# (rel_vos above), never as a post type, so no Voice-of-Supplier concept +# belongs here. Adding "voc_type" to the ontology-covered categories +# puts these codes under tests/test_ontology.py's round-trip check -- +# closing the previously documented expected gap. +################################################################# + +:postTypeScheme a skos:ConceptScheme ; + rdfs:label "Post type scheme" ; + rdfs:comment "Voice-based classification of what a source post records, per the governed five-value voc_type lookup category (migrations/0042)." . + +:voiceOfCustomerType a skos:Concept ; + skos:inScheme :postTypeScheme ; + skos:prefLabel "Voice of Customer"@en ; + rdfs:comment "A customer's own voice about their experience." ; + :lookupCode "voc" . + +:voiceOfCustomersCustomerType a skos:Concept ; + skos:inScheme :postTypeScheme ; + skos:prefLabel "Voice of Customer's Customer"@en ; + rdfs:comment "The voice of the customer's downstream customer." ; + :lookupCode "vocc" . + +:voiceOfCompetitorType a skos:Concept ; + skos:inScheme :postTypeScheme ; + skos:prefLabel "Voice of Competitor"@en ; + rdfs:comment "Market intelligence sourced from a competitor." ; + :lookupCode "voco" . + +:voiceOfMarketType a skos:Concept ; + skos:inScheme :postTypeScheme ; + skos:prefLabel "Voice of Market"@en ; + rdfs:comment "General market signal not attributable to one account or partner." ; + :lookupCode "vom" . + +:voiceOfPartnerType a skos:Concept ; + skos:inScheme :postTypeScheme ; + skos:prefLabel "Voice of Partner"@en ; + rdfs:comment "A partner organization's voice." ; + :lookupCode "vop" . + ################################################################# # SKOS -- corporate_entity_level (Group -> Company -> Plant) ################################################################# @@ -222,6 +366,11 @@ # (Reynolds, 2014) does: org:OrganizationalUnit, "used to represent # division of a particular organization into sub-organizational units," # linked to its parent via org:unitOf. See docs/adr/0007-team-actor-type.md. +# +# Keyman job titles and industry sectors remain free-text columns with +# no governed lookup category, so no SKOS scheme is invented for them +# here (ADR 0205 decision 8 tracks that gap rather than fabricating +# vocabulary). ################################################################# :RoleActorPerson a owl:Class ; diff --git a/docs/ontology/namespace-compatibility.ttl b/docs/ontology/namespace-compatibility.ttl index 95804cd4e..768ffd7a7 100644 --- a/docs/ontology/namespace-compatibility.ttl +++ b/docs/ontology/namespace-compatibility.ttl @@ -1,13 +1,19 @@ -@prefix canonical: . +@prefix canonical: . @prefix dcterms: . @prefix owl: . -@prefix legacy: . +@prefix legacy: . @prefix xsd: . - +# ADR 0205 supersedes ADR 0157: the repository-case namespace above is +# canonical and the lowercase namespace below is the deprecated +# compatibility vocabulary. Both documents stay dereferenceable with +# validated mappings; scripts/publish_ontology_site.py verifies local- +# name equality and RDF term kind fail-closed before publication. + + a owl:Ontology ; owl:deprecated "true"^^xsd:boolean ; - dcterms:isReplacedBy . + dcterms:isReplacedBy . legacy:Post a owl:Class . legacy:Person a owl:Class . diff --git a/docs/ontology/prov-o-support-profile.ttl b/docs/ontology/prov-o-support-profile.ttl index 6a7ec12a3..1c277afec 100644 --- a/docs/ontology/prov-o-support-profile.ttl +++ b/docs/ontology/prov-o-support-profile.ttl @@ -1,16 +1,16 @@ -@prefix : . +@prefix : . @prefix dcterms: . @prefix org: . @prefix owl: . @prefix prov: . @prefix rdfs: . - + a owl:Ontology ; dcterms:title "LineageWeave PROV-O support profile"@en ; dcterms:conformsTo ; owl:imports , - ; + ; rdfs:comment "The runtime supports all 30 PROV-O classes, all 50 normative properties, both qualification tables, and Appendix B inverse names without redefining the W3C vocabulary."@en . :Post rdfs:subClassOf prov:Entity . diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index e0579a65a..e8aed7787 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1160,7 +1160,7 @@ describe("App, authenticated", () => { project_name: "Semantic project", evidence: "project was described in the body", confidence: 0.9, - ontology_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#Project", + ontology_iri: "https://contextualwisdomlab.github.io/LineageWeave/ontology#Project", ontology_label: "Project", extraction_method: "contextual_orchestrator_semantic", resolution_status: "semantic_candidate", @@ -1287,7 +1287,7 @@ describe("App, authenticated", () => { project_name: "Sample project", evidence: "post body", confidence: 0.9, - ontology_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#Project", + ontology_iri: "https://contextualwisdomlab.github.io/LineageWeave/ontology#Project", extraction_method: "contextual_orchestrator_semantic", }, ], @@ -1355,7 +1355,7 @@ describe("App, authenticated", () => { { node_id: "person-ada", node_type_code: "node_person", - ontology_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#Person", + ontology_iri: "https://contextualwisdomlab.github.io/LineageWeave/ontology#Person", ontology_label: "Person", label: "Ada West", person_side_code: "our_side", @@ -1392,7 +1392,7 @@ describe("App, authenticated", () => { { node_id: "person-priya", node_type_code: "node_person", - ontology_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#Person", + ontology_iri: "https://contextualwisdomlab.github.io/LineageWeave/ontology#Person", ontology_label: "Person", label: "Priya Nair", person_side_code: "counterparty", @@ -1402,7 +1402,7 @@ describe("App, authenticated", () => { { node_id: "post-2", node_type_code: "node_post", - ontology_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#Post", + ontology_iri: "https://contextualwisdomlab.github.io/LineageWeave/ontology#Post", ontology_label: "Post", label: "Linked post", relevance: 0.3, @@ -1410,7 +1410,7 @@ describe("App, authenticated", () => { { node_id: "corp-1", node_type_code: "node_corporate_entity", - ontology_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#Organization", + ontology_iri: "https://contextualwisdomlab.github.io/LineageWeave/ontology#Organization", ontology_label: "Organization", label: "Demo Corp", relevance: 0.2, @@ -1419,7 +1419,7 @@ describe("App, authenticated", () => { { node_id: "team-1", node_type_code: "node_team", - ontology_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#Team", + ontology_iri: "https://contextualwisdomlab.github.io/LineageWeave/ontology#Team", ontology_label: "Team", label: "설계팀", relevance: 0.15, @@ -1437,7 +1437,7 @@ describe("App, authenticated", () => { { node_id: "post-2", node_type_code: "node_post", - ontology_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#Post", + ontology_iri: "https://contextualwisdomlab.github.io/LineageWeave/ontology#Post", ontology_label: "Post", label: "Linked post", relevance: 0.6, @@ -1458,7 +1458,7 @@ describe("App, authenticated", () => { { node_id: "post-1", node_type_code: "node_post", - ontology_class_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#Post", + ontology_class_iri: "https://contextualwisdomlab.github.io/LineageWeave/ontology#Post", display_label: "Public post", truth_status_code: "truth_observed", valid_from: null, @@ -1483,7 +1483,7 @@ describe("App, authenticated", () => { { node_id: "person-ada", node_type_code: "node_person", - ontology_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#Person", + ontology_iri: "https://contextualwisdomlab.github.io/LineageWeave/ontology#Person", ontology_label: "Person", label: "Ada West", person_side_code: "our_side", @@ -1503,7 +1503,7 @@ describe("App, authenticated", () => { { node_id: "person-ada", node_type_code: "node_person", - ontology_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#Person", + ontology_iri: "https://contextualwisdomlab.github.io/LineageWeave/ontology#Person", ontology_label: "Person", label: "Ada West", person_side_code: "our_side", @@ -1513,7 +1513,7 @@ describe("App, authenticated", () => { { node_id: "post-1", node_type_code: "node_post", - ontology_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#Post", + ontology_iri: "https://contextualwisdomlab.github.io/LineageWeave/ontology#Post", ontology_label: "Post", label: "Linked post", relevance: 0.6, @@ -2382,7 +2382,7 @@ describe("App, authenticated", () => { expect(screen.getByText(/Extraction source: Semantic extraction/)).toBeInTheDocument(); expect(screen.getByText(/Evidence field: Stored semantic evidence/)).toBeInTheDocument(); expect(screen.queryByText("contextual_orchestrator_semantic")).not.toBeInTheDocument(); - expect(screen.queryByText("https://contextualwisdomlab.github.io/lineageweave/ontology#Project")).not.toBeInTheDocument(); + expect(screen.queryByText("https://contextualwisdomlab.github.io/LineageWeave/ontology#Project")).not.toBeInTheDocument(); expect(screen.getByText("첫 번째 이벤트")).toBeInTheDocument(); expect(screen.getByText(/우리 측 후속/)).toBeInTheDocument(); expect(screen.getByRole("button", { name: "R&R Keyman: Ada West" })).toBeInTheDocument(); diff --git a/frontend/src/components/OntologyExplorer.stories.tsx b/frontend/src/components/OntologyExplorer.stories.tsx index d05861166..52e521bfb 100644 --- a/frontend/src/components/OntologyExplorer.stories.tsx +++ b/frontend/src/components/OntologyExplorer.stories.tsx @@ -16,7 +16,7 @@ const demoNeighborhood: OntologyNeighborhoodPayload = { { node_id: POST_ID, node_type_code: "node_post", - ontology_class_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#Post", + ontology_class_iri: "https://contextualwisdomlab.github.io/LineageWeave/ontology#Post", display_label: "Demo public post", truth_status_code: "truth_observed", valid_from: null, @@ -28,7 +28,7 @@ const demoNeighborhood: OntologyNeighborhoodPayload = { { node_id: PERSON_ID, node_type_code: "node_person", - ontology_class_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#Person", + ontology_class_iri: "https://contextualwisdomlab.github.io/LineageWeave/ontology#Person", display_label: "Test Person", truth_status_code: "truth_observed", valid_from: null, @@ -40,7 +40,7 @@ const demoNeighborhood: OntologyNeighborhoodPayload = { { node_id: CORP_ID, node_type_code: "node_corporate_entity", - ontology_class_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#CorporateEntity", + ontology_class_iri: "https://contextualwisdomlab.github.io/LineageWeave/ontology#CorporateEntity", display_label: "Demo Corp", truth_status_code: "truth_observed", valid_from: null, @@ -58,7 +58,7 @@ const demoNeighborhood: OntologyNeighborhoodPayload = { target_node_type_code: "node_person", target_node_id: PERSON_ID, property_code: "mentions", - ontology_property_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#mentions", + ontology_property_iri: "https://contextualwisdomlab.github.io/LineageWeave/ontology#mentions", property_label: "mentions", truth_status_code: "truth_observed", valid_from: null, @@ -74,7 +74,7 @@ const demoNeighborhood: OntologyNeighborhoodPayload = { target_node_type_code: "node_corporate_entity", target_node_id: CORP_ID, property_code: "affiliatedWith", - ontology_property_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#affiliatedWith", + ontology_property_iri: "https://contextualwisdomlab.github.io/LineageWeave/ontology#affiliatedWith", property_label: "affiliated with", truth_status_code: "truth_observed", valid_from: null, @@ -92,7 +92,7 @@ const demoNeighborhood: OntologyNeighborhoodPayload = { source_type_code: "node_post", property_code: "mentions", property_label: "mentions", - ontology_property_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#mentions", + ontology_property_iri: "https://contextualwisdomlab.github.io/LineageWeave/ontology#mentions", target_node_id: PERSON_ID, target_label: "Test Person", target_type_code: "node_person", @@ -109,7 +109,7 @@ const demoNeighborhood: OntologyNeighborhoodPayload = { source_type_code: "node_person", property_code: "affiliatedWith", property_label: "affiliated with", - ontology_property_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#affiliatedWith", + ontology_property_iri: "https://contextualwisdomlab.github.io/LineageWeave/ontology#affiliatedWith", target_node_id: CORP_ID, target_label: "Demo Corp", target_type_code: "node_corporate_entity", @@ -121,7 +121,7 @@ const demoNeighborhood: OntologyNeighborhoodPayload = { }, ], jsonld: { - "@context": { lw: "https://contextualwisdomlab.github.io/lineageweave/ontology#" }, + "@context": { lw: "https://contextualwisdomlab.github.io/LineageWeave/ontology#" }, "@graph": [], }, }; diff --git a/lineageweave/ontology.py b/lineageweave/ontology.py index f7edf76e6..473ca9dae 100644 --- a/lineageweave/ontology.py +++ b/lineageweave/ontology.py @@ -25,8 +25,11 @@ from rdflib.term import Identifier #: 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#") +#: this prefix plus the term's local name (e.g. LW.Post). ADR 0205 made +#: the repository-case spelling canonical (it is the exact path GitHub +#: Pages serves) and demoted the lowercase form to a deprecated +#: compatibility vocabulary. +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. diff --git a/pyproject.toml b/pyproject.toml index ed728eba7..16fe1a1a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.15.1" +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" } @@ -38,6 +38,10 @@ dev = [ "pyjwt[crypto]>=2.8.0", "pytest>=8.0", "httpx>=0.27.0", + # Closed-world SHACL validation of docs/ontology/lineageweave-kg.ttl + # against lineageweave-kg-shapes.ttl (ADR 0205 decision 10). Pure + # Python; OWL-RL reasoning included. + "pyshacl>=0.26.0", ] backend = [ "fastapi>=0.115.0", diff --git a/scripts/build_ontology_site.py b/scripts/build_ontology_site.py index f749ba3c4..c4877f894 100644 --- a/scripts/build_ontology_site.py +++ b/scripts/build_ontology_site.py @@ -28,6 +28,13 @@ PUBLIC_BASE_URL = "https://contextualwisdomlab.github.io/LineageWeave" DOCUMENTATION_URL = f"{PUBLIC_BASE_URL}/ontology" +#: ADR 0205: the canonical namespace is the repository-case spelling -- +#: the exact project path GitHub Pages serves. The lowercase form is a +#: deprecated compatibility vocabulary published beside the ontology. +CANONICAL_LOOKUP_PREDICATE = ( + "https://contextualwisdomlab.github.io/LineageWeave/ontology#lookupCode" +) +SHAPES_RELATIVE_PATH = Path("docs/ontology/lineageweave-kg-shapes.ttl") CANONICAL_LINK_SUPPRESSION = ( "" @@ -156,9 +163,7 @@ def _render_term(graph: Graph, subject: URIRef, ontology_subjects: set[URIRef]) or raw_fragment ) comment = _preferred_literal(graph, subject, RDFS.comment) - lookup_predicate = URIRef( - "https://contextualwisdomlab.github.io/lineageweave/ontology#lookupCode" - ) + lookup_predicate = URIRef(CANONICAL_LOOKUP_PREDICATE) lookup_codes = sorted(str(value) for value in graph.objects(subject, lookup_predicate)) type_values = sorted( (value for value in graph.objects(subject, RDF.type) if isinstance(value, URIRef)), @@ -352,6 +357,7 @@ def _render_ontology_page(graph: Graph, source_sha256: str) -> tuple[str, int]: 'N-Triples generated equivalent' 'PROV-O support profile' 'Deprecated namespace compatibility' + 'SHACL shapes' 'Build manifest' "" '
' @@ -359,7 +365,7 @@ def _render_ontology_page(graph: Graph, source_sha256: str) -> tuple[str, int]: f'
{len(graph)}
RDF triples
' f'
{html.escape(source_sha256[:12])}
Source SHA-256 prefix
' "
" - '

Identity boundary: this project page is the stable documentation endpoint requested for the repository. The source ontology IRI shown above remains the semantic identifier until an explicit versioned namespace-migration ADR says otherwise.

' + '

Identity boundary: this project page is the stable documentation endpoint requested for the repository. Per ADR 0205 the repository-case ontology IRI shown above is the canonical semantic identifier; the lowercase namespace remains a deprecated compatibility vocabulary with validated mappings.

' '" f"{term_sections}" @@ -400,6 +406,7 @@ def _write_manifest( "documentation_url": DOCUMENTATION_URL, "generated_artifacts": [ "index.html", + "lineageweave-kg-shapes.ttl", "manifest.json", "namespace-compatibility.ttl", "ontology.jsonld", @@ -407,6 +414,7 @@ def _write_manifest( "ontology.ttl", "prov-o-support-profile.ttl", ], + "shapes_path": SHAPES_RELATIVE_PATH.as_posix(), "ontology_triple_count": len(graph), "ontology_unique_term_count": term_count, "source_path": SOURCE_RELATIVE_PATH.as_posix(), @@ -425,12 +433,15 @@ def build_site(repository_root: Path, output_dir: Path) -> None: source = root / SOURCE_RELATIVE_PATH prov_profile = root / PROV_PROFILE_RELATIVE_PATH compatibility = root / COMPATIBILITY_RELATIVE_PATH + shapes = root / SHAPES_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 not shapes.is_file(): + raise FileNotFoundError(f"SHACL shapes graph is missing: {shapes}") if output.exists(): raise FileExistsError( @@ -450,6 +461,7 @@ def build_site(repository_root: Path, output_dir: Path) -> None: 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") + shutil.copyfile(shapes, ontology_dir / "lineageweave-kg-shapes.ttl") _write_serializations(graph, ontology_dir) _write_manifest(ontology_dir, source, graph, term_count) (output / "robots.txt").write_text( diff --git a/scripts/migrate_legacy_namespace.py b/scripts/migrate_legacy_namespace.py index 758997970..6c754631e 100644 --- a/scripts/migrate_legacy_namespace.py +++ b/scripts/migrate_legacy_namespace.py @@ -1,13 +1,15 @@ #!/usr/bin/env python3 -"""Migrate stored ``post_project_mention.ontology_iri`` values off the -deprecated repository-case namespace onto the canonical lowercase one. - -ADR 0157 keeps ``https://contextualwisdomlab.github.io/lineageweave/ontology#`` -canonical and demotes ``https://contextualwisdomlab.github.io/LineageWeave/ontology#`` -to a deprecated compatibility namespace. New writes mint only canonical -IRIs (``lineageweave.ontology`` loads the lowercase graph), but rows written -before the decision can still carry repository-case IRIs. RDF consumers treat -the two spellings as different resources, so leaving them split makes +"""Migrate stored ``post_project_mention.ontology_iri`` values onto the +canonical repository-case namespace. + +ADR 0205 supersedes ADR 0157 and makes +``https://contextualwisdomlab.github.io/LineageWeave/ontology#`` canonical +-- the exact project path GitHub Pages serves -- while demoting +``https://contextualwisdomlab.github.io/lineageweave/ontology#`` to a +deprecated compatibility namespace. New writes mint only canonical IRIs +(``lineageweave.ontology`` loads the repository-case graph), but rows +written before this decision can still carry lowercase IRIs. RDF consumers +treat the two spellings as different resources, so leaving them split makes downstream joins miss mentions that are semantically identical. This tool is deliberately *not* silent: @@ -16,10 +18,12 @@ - ``--apply`` performs exactly the printed rewrites inside one transaction; - the extraction provenance columns (``extraction_method``, confidence, evidence text) are never touched -- only the IRI spelling moves, so the - evidence chain of who extracted what remains intact per ADR 0157's + evidence chain of who extracted what remains intact per ADR 0205's "do not silently rewrite historical evidence" rule; - any IRI outside the two known namespaces is reported and left alone so an - unexpected third spelling cannot be bulk-mangled. + unexpected third spelling cannot be bulk-mangled; +- the operation is idempotent -- rerunning on a migrated database reports + ``no legacy namespace rows remain`` and writes nothing. Usage:: @@ -34,8 +38,8 @@ import asyncpg -CANONICAL_NAMESPACE = "https://contextualwisdomlab.github.io/lineageweave/ontology#" -LEGACY_NAMESPACE = "https://contextualwisdomlab.github.io/LineageWeave/ontology#" +CANONICAL_NAMESPACE = "https://contextualwisdomlab.github.io/LineageWeave/ontology#" +LEGACY_NAMESPACE = "https://contextualwisdomlab.github.io/lineageweave/ontology#" def canonicalize(iri: str) -> str | None: diff --git a/scripts/publish_ontology_site.py b/scripts/publish_ontology_site.py index 0bdc0c32f..3dae34b5a 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 OWL, RDF, RDFS, SKOS +from rdflib.namespace import OWL, RDF, RDFS, SH, SKOS try: from scripts.ontology_site_contract import public_fragment @@ -28,8 +28,11 @@ 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#" +SHAPES_RELATIVE_PATH = Path("docs/ontology/lineageweave-kg-shapes.ttl") +#: ADR 0205: the repository-case namespace is canonical and the +#: lowercase form is the deprecated compatibility vocabulary. +CANONICAL_NAMESPACE = "https://contextualwisdomlab.github.io/LineageWeave/ontology#" +DEPRECATED_NAMESPACE = "https://contextualwisdomlab.github.io/lineageweave/ontology#" _MAPPING_FOR_KIND = { OWL.Class: OWL.equivalentClass, @@ -131,6 +134,58 @@ def validate_compatibility_graph( raise ValueError("namespace compatibility mapping uses the wrong predicate") +def validate_shapes_graph(shapes: Graph, canonical: Graph) -> None: + """Reject SHACL shapes whose targets dangle outside the ontology. + + A shape that targets a class absent from the canonical graph, or + constrains a path never declared there, would silently validate + nothing -- the publication boundary refuses it instead (ADR 0205 + decision 10). Only URI-valued targets and paths are checked; + literal sh:path values are not part of this contract. + """ + if not any(shapes.triples((None, RDF.type, SH.NodeShape))): + raise ValueError("SHACL shapes graph declares no sh:NodeShape") + for predicate in (SH.targetClass, SH.path): + for value in shapes.objects(None, predicate): + if not isinstance(value, URIRef) or str(value).startswith( + CANONICAL_NAMESPACE + ): + continue + kind = "targetClass" if predicate == SH.targetClass else "path" + raise ValueError( + f"SHACL {kind} target outside the canonical namespace: {value}" + ) + declared = { + subject + for subject in canonical.subjects(RDF.type, OWL.Class) + if isinstance(subject, URIRef) + } + declared.update( + subject + for subject in canonical.subjects(RDF.type, SKOS.Concept) + if isinstance(subject, URIRef) + ) + declared.update( + subject + for subject in canonical.subjects(RDF.type, OWL.ObjectProperty) + if isinstance(subject, URIRef) + ) + declared.update( + subject + for subject in canonical.subjects(RDF.type, OWL.DatatypeProperty) + if isinstance(subject, URIRef) + ) + # Entailed classes: anything with a subclass assertion is a class. + declared.update(str(subject) for subject, _ in canonical.subject_objects(RDFS.subClassOf)) + declared = {str(subject) for subject in declared} + for target in shapes.objects(None, SH.targetClass): + if str(target) not in declared: + raise ValueError(f"SHACL targetClass is not an ontology class: {target}") + for path in shapes.objects(None, SH.path): + if str(path) not in declared: + raise ValueError(f"SHACL property path is not an ontology term: {path}") + + 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() @@ -150,6 +205,7 @@ def publish_site(repository_root: Path, output_dir: Path) -> None: source = root / SOURCE_RELATIVE_PATH profile = root / PROV_PROFILE_RELATIVE_PATH compatibility_source = root / COMPATIBILITY_RELATIVE_PATH + shapes_source = root / SHAPES_RELATIVE_PATH if not source.is_file(): raise FileNotFoundError(f"ontology source is missing: {source}") if not profile.is_file(): @@ -158,14 +214,18 @@ def publish_site(repository_root: Path, output_dir: Path) -> None: raise FileNotFoundError( f"namespace compatibility vocabulary is missing: {compatibility_source}" ) + if not shapes_source.is_file(): + raise FileNotFoundError(f"SHACL shapes graph is missing: {shapes_source}") output = _validate_output_directory(output_dir, source, profile) renderer = _load_renderer(root) graph = Graph().parse(source, format="turtle") Graph().parse(profile, format="turtle") compatibility_graph = Graph().parse(compatibility_source, format="turtle") + shapes_graph = Graph().parse(shapes_source, format="turtle") validate_public_graph(graph, renderer) validate_compatibility_graph(graph, compatibility_graph) + validate_shapes_graph(shapes_graph, graph) if output.exists(): shutil.rmtree(output) diff --git a/tests/test_ontology.py b/tests/test_ontology.py index e5f9084c3..45b8760c7 100644 --- a/tests/test_ontology.py +++ b/tests/test_ontology.py @@ -21,6 +21,7 @@ NODE_POST, ) from lineageweave.ontology import ( + LOOKUP_CODE, LW, all_declared_lookup_codes, iri_for_lookup_code, @@ -37,17 +38,20 @@ # text -- read alongside it below so the round-trip still sees them: # 0012 (ADR 0006: prov_person/prov_organization), 0014 (ADR 0007: # prov_team), 0016 (ADR 0009: node_team/edge_mention_team/ -# edge_team_affiliation/edge_mention_organization). +# edge_team_affiliation/edge_mention_organization), and 0042 (ADR 0205: +# the five governed voc_type post-type codes). _ADDITIONAL_LOOKUP_MIGRATION_PATHS = ( Path(__file__).resolve().parents[1] / "migrations" / "0060_role_responsibility_agent_type.sql", Path(__file__).resolve().parents[1] / "migrations" / "0014_role_responsibility_team_actor_type.sql", Path(__file__).resolve().parents[1] / "migrations" / "0016_cross_post_actor_identity.sql", + Path(__file__).resolve().parents[1] / "migrations" / "0042_voc_type_vocabulary.sql", ) -# The categories this ontology covers (ADR 0004's scope). seed_demo_data.py -# also seeds categories this ontology deliberately does not model yet -# (post_visibility, voc_type, permission, ticket_status) -- those are -# real, expected gaps, not a test bug. +# The categories this ontology covers (ADR 0004's scope, extended by +# ADR 0205 with voc_type's post-type scheme). seed_demo_data.py also +# seeds categories this ontology deliberately does not model yet +# (post_visibility, permission, ticket_status) -- those are real, +# expected gaps, not a test bug. _ONTOLOGY_COVERED_CATEGORIES = frozenset( { "node_type", @@ -56,6 +60,7 @@ "person_side", "corporate_entity_level", "prov_agent_type", + "voc_type", } ) @@ -205,7 +210,7 @@ def test_actor_mentions_follow_stored_edge_direction() -> None: def test_semantic_project_terms_preserve_post_evidence_and_confidence() -> None: """ADR 0036's project vocabulary must remain machine-checkable.""" graph = load_ontology() - ontology = URIRef("https://contextualwisdomlab.github.io/lineageweave/ontology") + ontology = URIRef("https://contextualwisdomlab.github.io/LineageWeave/ontology") assert "OWL 2 Full" in str(graph.value(ontology, RDFS.comment)) assert (LW.Project, RDF.type, OWL.Class) in graph assert (LW.ProjectMention, RDF.type, OWL.Class) in graph @@ -231,3 +236,104 @@ def test_semantic_project_terms_preserve_post_evidence_and_confidence() -> None: assert (LW.projectEvidence, RDFS.range, XSD.string) in graph assert (LW.semanticConfidence, RDFS.range, XSD.decimal) in graph assert (LW.semanticConfidence, RDFS.domain, LW.ProjectMention) in graph + + +def test_ontology_iri_is_repository_case_canonical() -> None: + """ADR 0205: the ontology IRI and every term IRI use the + repository-case namespace -- the exact path GitHub Pages serves -- + and the lowercase spelling never appears as a minted subject. + """ + canonical = "https://contextualwisdomlab.github.io/LineageWeave/ontology#" + lowercase = "https://contextualwisdomlab.github.io/lineageweave/ontology#" + graph = load_ontology() + assert ( + URIRef("https://contextualwisdomlab.github.io/LineageWeave/ontology"), + RDF.type, + OWL.Ontology, + ) in graph + assert str(LW) == canonical + for subject in set(graph.subjects()): + if isinstance(subject, URIRef): + assert not str(subject).startswith(lowercase), str(subject) + + +def test_person_sides_are_declared_disjoint() -> None: + """ADR 0205 decision 9: a person side is our-side or counterparty, + never both -- stated with owl:disjointWith so reasoners refuse a row + that projects both codes. + """ + graph = load_ontology() + assert (LW.OurSidePerson, OWL.disjointWith, LW.CounterpartyPerson) in graph + + +def test_has_affiliate_is_the_stored_affiliation_inverse() -> None: + """Bidirectional affiliation queries resolve through :hasAffiliate; + like :mentions it carries no lookup code so one code keeps naming + exactly one stored property. + """ + graph = load_ontology() + assert (LW.hasAffiliate, OWL.inverseOf, LW.affiliatedWith) in graph + assert (LW.hasAffiliate, RDFS.domain, LW.CorporateEntity) in graph + assert (LW.hasAffiliate, RDFS.range, LW.Person) in graph + declared_codes = all_declared_lookup_codes() + for value in graph.objects(LW.hasAffiliate, LOOKUP_CODE): + raise AssertionError(f":hasAffiliate must stay un-coded; found {value}") + assert "edge_affiliation" in declared_codes + + +def test_node_attribute_datatype_properties_project_real_columns() -> None: + """ADR 0205 decision 7: attribute properties exist exactly for real + schema columns, with correct domains/ranges, and no property is + invented for a column that does not exist. + """ + graph = load_ontology() + expected = { + (LW.postTitle, LW.Post, XSD.string), + (LW.postBody, LW.Post, XSD.string), + (LW.eventOccurredAt, LW.Post, XSD.dateTime), + (LW.personName, LW.Person, XSD.string), + (LW.lastKnownJobTitle, LW.Person, XSD.string), + (LW.entityName, LW.CorporateEntity, XSD.string), + (LW.entityCode, LW.CorporateEntity, XSD.string), + } + for prop, domain, datatype_range in expected: + assert (prop, RDF.type, OWL.DatatypeProperty) in graph, str(prop) + assert (prop, RDFS.domain, domain) in graph, str(prop) + assert (prop, RDFS.range, datatype_range) in graph, str(prop) + + +def test_shared_timestamps_declare_no_domain_to_avoid_multi_domain_entailment() -> None: + """Two rdfs:domain statements would entail every subject belongs to + both classes -- the trap the cross-post edges already avoid. Shared + timestamps therefore carry no domain; SHACL pins them per class. + """ + graph = load_ontology() + for prop in (LW.createdAt, LW.updatedAt): + assert (prop, RDF.type, OWL.DatatypeProperty) in graph + assert (prop, RDFS.range, XSD.dateTime) in graph + domains = list(graph.objects(prop, RDFS.domain)) + assert domains == [], f"{prop} unexpectedly declares domains: {domains}" + + +def test_post_type_scheme_covers_the_governed_voc_vocabulary() -> None: + """ADR 0205 decision 8: the five seeded voc_type codes become SKOS + concepts; vos exists only as rel_vos and must NOT appear here. + """ + graph = load_ontology() + scheme_members = { + subject for subject in graph.subjects(SKOS.inScheme, LW.postTypeScheme) + } + expected = { + (LW.voiceOfCustomerType, "voc"), + (LW.voiceOfCustomersCustomerType, "vocc"), + (LW.voiceOfCompetitorType, "voco"), + (LW.voiceOfMarketType, "vom"), + (LW.voiceOfPartnerType, "vop"), + } + for concept, code in expected: + assert concept in scheme_members, str(concept) + assert iri_for_lookup_code(code) == str(concept) + assert len(scheme_members) == len(expected) + seeded = _seeded_lookup_codes_for_covered_categories() + assert {"voc", "vocc", "voco", "vom", "vop"} <= seeded + assert iri_for_lookup_code("vos") is None # relationship type only diff --git a/tests/test_ontology_shapes.py b/tests/test_ontology_shapes.py new file mode 100644 index 000000000..86b8c31cb --- /dev/null +++ b/tests/test_ontology_shapes.py @@ -0,0 +1,189 @@ +"""Closed-world SHACL validation of the LineageWeave knowledge-graph +ontology (ADR 0205 decision 10). + +OWL's open-world semantics infers; it does not verify that projected +data arrived complete and in range (Knublauch & Kontokostas, 2017). +`docs/ontology/lineageweave-kg-shapes.ttl` carries exactly that +verification, and this module proves it works in both directions: + +- the shipped shapes graph conforms to the SHACL specification itself; +- a representative post/mention projection passes; +- the same projection with a confidence above 1.0 -- or a missing + required title -- is rejected, naming the violated constraint. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from rdflib import Graph, Literal, Namespace, URIRef +from rdflib.namespace import RDF, XSD +from pyshacl import validate as shacl_validate + +ROOT = Path(__file__).resolve().parents[1] +KG_PATH = ROOT / "docs" / "ontology" / "lineageweave-kg.ttl" +SHAPES_PATH = ROOT / "docs" / "ontology" / "lineageweave-kg-shapes.ttl" + +LW = "https://contextualwisdomlab.github.io/LineageWeave/ontology#" + + +def _load_kg() -> Graph: + """Parse the source ontology graph fresh.""" + return Graph().parse(KG_PATH, format="turtle") + + +def _load_shapes() -> Graph: + """Parse the SHACL shapes graph fresh.""" + return Graph().parse(SHAPES_PATH, format="turtle") + + +def _conforms(data: Graph) -> tuple[bool, str]: + """Run pyshacl over ``data`` against the shipped shapes; return the + verdict plus the human-readable report text for assertions.""" + conforms, _, report_text = shacl_validate( + data_graph=data, + shacl_graph=_load_shapes(), + ont_graph=_load_kg(), + inference="none", + advanced=True, + ) + return bool(conforms), report_text + + +def _representative_projection() -> Graph: + """Build one minimal but realistic DB-to-RDF projection: a post with + every required attribute, a person, an entity, an our-side person, + and a project mention whose evidence chain is intact. + """ + data = _load_kg() + LWn = Namespace(LW) + post = URIRef(LW + "post-alpha") + data.add((post, RDF.type, LWn.Post)) + data.add((post, LWn.postTitle, Literal("Line 3 downtime window"))) + data.add((post, LWn.postBody, Literal("Customer reported a stoppage after changeover."))) + data.add( + ( + post, + LWn.createdAt, + Literal("2026-08-25T01:23:45+00:00", datatype=XSD.dateTime), + ) + ) + person = URIRef(LW + "person-okonkwo") + data.add((person, RDF.type, LWn.Person)) + data.add((person, LWn.personName, Literal("Sam Okonkwo"))) + entity = URIRef(LW + "entity-acme") + data.add((entity, RDF.type, LWn.CorporateEntity)) + data.add((entity, LWn.entityName, Literal("Acme Electronics Korea"))) + data.add((entity, LWn.entityCode, Literal("ACME-KR"))) + our_side = URIRef(LW + "person-our-side") + data.add((our_side, RDF.type, LWn.OurSidePerson)) + # Our-side persons are SHACL instances of :Person through the + # subclass chain, so the required name applies to them as well -- + # exactly like cataloged_person.person_name's NOT NULL. + data.add((our_side, LWn.personName, Literal("Dana Whitfield"))) + mention = URIRef(LW + "mention-alpha") + data.add((mention, RDF.type, LWn.ProjectMention)) + data.add( + ( + mention, + LWn.semanticConfidence, + Literal("0.87", datatype=XSD.decimal), + ) + ) + data.add((mention, LWn.projectEvidence, Literal("proj-alpha kickoff cited verbatim."))) + return data + + +def test_shipped_shapes_conform_to_shacl_specification() -> None: + """The shapes artifact itself must be valid SHACL before it may gate + anything else -- validated with no data graph attached to it. + """ + conforms, report_text = _conforms(_load_shapes()) + assert conforms, report_text + + +def test_representative_db_projection_passes_validation() -> None: + """A realistic projection of real schema rows validates cleanly.""" + conforms, report_text = _conforms(_representative_projection()) + assert conforms, report_text + + +@pytest.mark.parametrize( + ("mutation", "expected_fragment"), + [ + pytest.param( + lambda g: g.remove( + ( + URIRef(LW + "post-alpha"), + URIRef(LW + "postTitle"), + None, + ) + ), + "postTitle", + id="missing-required-post-title", + ), + pytest.param( + lambda g: g.set( + ( + URIRef(LW + "mention-alpha"), + URIRef(LW + "semanticConfidence"), + Literal("1.5", datatype=XSD.decimal), + ) + ), + "semanticConfidence", + id="confidence-above-one", + ), + pytest.param( + lambda g: g.add( + ( + URIRef(LW + "person-our-side"), + RDF.type, + URIRef(LW + "CounterpartyPerson"), + ) + ), + "OurSidePersonShape", + id="disjoint-person-side", + ), + pytest.param( + lambda g: g.remove( + ( + URIRef(LW + "entity-acme"), + URIRef(LW + "entityCode"), + None, + ) + ), + "entityCode", + id="missing-entity-code", + ), + ], +) +def test_violations_are_rejected_with_the_right_constraint_name( + mutation, expected_fragment: str +) -> None: + """Each broken projection fails closed and the report names the + property or shape that was violated, so operators see which column + projection drifted instead of a bare boolean. + """ + data = _representative_projection() + mutation(data) + conforms, report_text = _conforms(data) + assert not conforms + assert expected_fragment in report_text + + +def test_confidence_boundary_values_are_inclusive() -> None: + """Exactly 0.0 and 1.0 are legal -- the bound is [0.0, 1.0] + inclusive per ADR 0205 decision 10. + """ + for value in ("0.0", "1.0"): + data = _representative_projection() + data.set( + ( + URIRef(LW + "mention-alpha"), + URIRef(LW + "semanticConfidence"), + Literal(value, datatype=XSD.decimal), + ) + ) + conforms, report_text = _conforms(data) + assert conforms, f"{value} rejected:\n{report_text}" diff --git a/tests/test_ontology_site.py b/tests/test_ontology_site.py index 542cbe574..a2741988d 100644 --- a/tests/test_ontology_site.py +++ b/tests/test_ontology_site.py @@ -69,6 +69,9 @@ def test_build_publishes_dereferenceable_html_and_machine_formats(tmp_path: Path assert (ontology_dir / "namespace-compatibility.ttl").read_bytes() == ( ROOT / "docs" / "ontology" / "namespace-compatibility.ttl" ).read_bytes() + assert (ontology_dir / "lineageweave-kg-shapes.ttl").read_bytes() == ( + ROOT / "docs" / "ontology" / "lineageweave-kg-shapes.ttl" + ).read_bytes() html = (ontology_dir / "index.html").read_text(encoding="utf-8") assert '' in html @@ -79,6 +82,7 @@ def test_build_publishes_dereferenceable_html_and_machine_formats(tmp_path: Path assert "ontology.ttl" in html assert "ontology.jsonld" in html assert "ontology.nt" in html + assert "lineageweave-kg-shapes.ttl" in html def test_render_term_escapes_untrusted_ontology_text() -> None: @@ -196,6 +200,7 @@ def test_metadata_manifest_has_source_digest_and_no_build_clock(tmp_path: Path) assert manifest["documentation_url"] == "https://contextualwisdomlab.github.io/LineageWeave/ontology" assert manifest["generated_artifacts"] == [ "index.html", + "lineageweave-kg-shapes.ttl", "manifest.json", "namespace-compatibility.ttl", "ontology.jsonld", @@ -203,6 +208,7 @@ def test_metadata_manifest_has_source_digest_and_no_build_clock(tmp_path: Path) "ontology.ttl", "prov-o-support-profile.ttl", ] + assert manifest["shapes_path"] == "docs/ontology/lineageweave-kg-shapes.ttl" def test_helpers_cover_slash_fragments_json_lists_and_missing_ontology() -> None: @@ -283,6 +289,17 @@ def test_builder_fails_closed_for_missing_sources_and_rejects_existing_output(tm (ROOT / "docs" / "ontology" / "namespace-compatibility.ttl").read_bytes() ) + try: + builder.build_site(repository, output) + except FileNotFoundError as exc: + assert "SHACL shapes graph" in str(exc) + else: + raise AssertionError("missing SHACL shapes graph was accepted") + + (ontology_dir / "lineageweave-kg-shapes.ttl").write_bytes( + (ROOT / "docs" / "ontology" / "lineageweave-kg-shapes.ttl").read_bytes() + ) + try: builder.build_site(repository, output) except FileExistsError as exc: diff --git a/tests/test_post_chat.py b/tests/test_post_chat.py index 879bf80df..9c6d2faf4 100644 --- a/tests/test_post_chat.py +++ b/tests/test_post_chat.py @@ -243,7 +243,7 @@ async def fake_hydrate(_conn, _node_keys): assert facts == ( 'node_person "Ada West" --edge_affiliation ' - '(https://contextualwisdomlab.github.io/lineageweave/ontology#affiliatedWith)--> ' + '(https://contextualwisdomlab.github.io/LineageWeave/ontology#affiliatedWith)--> ' 'node_corporate_entity "Demo Corp" [evidence_post_id=post-graph]', ) diff --git a/tests/test_prov_o.py b/tests/test_prov_o.py index f0e63c13e..f2e9b8ad6 100644 --- a/tests/test_prov_o.py +++ b/tests/test_prov_o.py @@ -387,10 +387,10 @@ def test_support_profile_imports_prov_o_and_maps_product_classes() -> None: ) profile = Graph().parse(profile_path, format="turtle") ontology_iri = URIRef( - "https://contextualwisdomlab.github.io/lineageweave/ontology/prov-o-support-profile.ttl" + "https://contextualwisdomlab.github.io/LineageWeave/ontology/prov-o-support-profile.ttl" ) - local = Namespace("https://contextualwisdomlab.github.io/lineageweave/ontology#") - legacy = Namespace("https://contextualwisdomlab.github.io/LineageWeave/ontology#") + local = Namespace("https://contextualwisdomlab.github.io/LineageWeave/ontology#") + legacy = Namespace("https://contextualwisdomlab.github.io/lineageweave/ontology#") assert ( ontology_iri, OWL.imports, @@ -400,7 +400,7 @@ def test_support_profile_imports_prov_o_and_maps_product_classes() -> None: ontology_iri, OWL.imports, URIRef( - "https://contextualwisdomlab.github.io/lineageweave/ontology/namespace-compatibility.ttl" + "https://contextualwisdomlab.github.io/LineageWeave/ontology/namespace-compatibility.ttl" ), ) in profile assert (local.Post, RDFS.subClassOf, PROV.Entity) in profile diff --git a/tests/test_publish_ontology_site.py b/tests/test_publish_ontology_site.py index c34968aba..a96d08e5f 100644 --- a/tests/test_publish_ontology_site.py +++ b/tests/test_publish_ontology_site.py @@ -51,6 +51,7 @@ def _repository_fixture(tmp_path: Path) -> Path: "lineageweave-kg.ttl", "prov-o-support-profile.ttl", "namespace-compatibility.ttl", + "lineageweave-kg-shapes.ttl", ): (ontology_dir / name).write_bytes((ROOT / "docs" / "ontology" / name).read_bytes()) (scripts_dir / "build_ontology_site.py").write_bytes( @@ -207,6 +208,59 @@ def test_compatibility_validation_is_term_kind_safe() -> None: assert publisher._term_kind(ambiguous, post) is None +def test_shapes_validation_rejects_dangling_targets_and_outside_namespace() -> None: + """ADR 0205 decision 10: a shape targeting an undeclared class or + path validates nothing silently, so publication refuses it; only + canonical-namespace targets are allowed. + """ + from rdflib.namespace import SH + + publisher = _load_publisher() + canonical = Graph().parse( + ROOT / "docs" / "ontology" / "lineageweave-kg.ttl", format="turtle" + ) + + with pytest.raises(ValueError, match="declares no sh:NodeShape"): + publisher.validate_shapes_graph(Graph(), canonical) + + outside_target = Graph() + outside_target.add((URIRef(f"{publisher.CANONICAL_NAMESPACE}PostShape"), RDF.type, SH.NodeShape)) + outside_target.add( + ( + URIRef(f"{publisher.CANONICAL_NAMESPACE}PostShape"), + SH.targetClass, + URIRef("https://example.test/ontology#Ghost"), + ) + ) + with pytest.raises(ValueError, match="outside the canonical namespace"): + publisher.validate_shapes_graph(outside_target, canonical) + + dangling_class = Graph() + dangling_class.add((URIRef(f"{publisher.CANONICAL_NAMESPACE}S"), RDF.type, SH.NodeShape)) + dangling_class.add( + ( + URIRef(f"{publisher.CANONICAL_NAMESPACE}S"), + SH.targetClass, + URIRef(f"{publisher.CANONICAL_NAMESPACE}NotAClass"), + ) + ) + with pytest.raises(ValueError, match="not an ontology class"): + publisher.validate_shapes_graph(dangling_class, canonical) + + dangling_path = Graph() + shape = URIRef(f"{publisher.CANONICAL_NAMESPACE}PostShape") + dangling_path.add((shape, RDF.type, SH.NodeShape)) + dangling_path.add((shape, SH.targetClass, URIRef(f"{publisher.CANONICAL_NAMESPACE}Post"))) + dangling_path.add((shape, SH.path, URIRef(f"{publisher.CANONICAL_NAMESPACE}ghostColumn"))) + with pytest.raises(ValueError, match="not an ontology term"): + publisher.validate_shapes_graph(dangling_path, canonical) + + publisher.validate_shapes_graph( + Graph().parse(ROOT / "docs" / "ontology" / "lineageweave-kg-shapes.ttl", format="turtle"), + canonical, + ) + + def test_main_publishes_site(tmp_path: Path) -> None: publisher = _load_publisher() repository = _repository_fixture(tmp_path) @@ -268,6 +322,12 @@ def test_publication_fails_closed_for_missing_sources(tmp_path: Path) -> None: with pytest.raises(FileNotFoundError, match="namespace compatibility"): publisher.publish_site(repository, output) + (ontology_dir / "namespace-compatibility.ttl").write_bytes( + (ROOT / "docs" / "ontology" / "namespace-compatibility.ttl").read_bytes() + ) + with pytest.raises(FileNotFoundError, match="SHACL shapes graph"): + publisher.publish_site(repository, output) + def test_module_entrypoint(tmp_path: Path, monkeypatch) -> None: import runpy From 399bae36f1185a183771400a3e13b778c8d0a04d Mon Sep 17 00:00:00 2001 From: seonghobae Date: Tue, 25 Aug 2026 19:22:23 +0900 Subject: [PATCH 2/9] docs(adr): reserve ontology namespace decision 0207 --- docs/adr/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/README.md b/docs/adr/README.md index 8f7312dc7..365a5d6c4 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -13,7 +13,7 @@ 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), [0024](0024-rankweave-fusion-fail-closed.md), [0165](0165-quantity-script-display.md), [0167](0167-rankweave-ranking-channel-evidence.md), [0169](0169-ask-batched-lineage-graph.md), [0202](0202-ask-event-time-filter.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_INVENTORY.md`](../doctoring/ONTOLOGY_NAMESPACE_INVENTORY.md) | [0205](0207-repository-case-ontology-namespace-canonical.md), [0157](0157-public-ontology-namespace-identity.md) | +| [`ONTOLOGY_NAMESPACE_INVENTORY.md`](../doctoring/ONTOLOGY_NAMESPACE_INVENTORY.md) | [0207](0207-repository-case-ontology-namespace-canonical.md), [0157](0157-public-ontology-namespace-identity.md) | | [`image-content-schema.md`](../image-content-schema.md) | [0066](0066-position-preserving-image-content.md) | | [`storybook-inventory.md`](../storybook-inventory.md) | [0118](0118-uiux-standard-guide-v3-design-overhaul.md), [0184](0184-ontology-provenance-explorer.md) | | [`POSTGRESQL_CONCURRENCY_REFERENCES.md`](../doctoring/POSTGRESQL_CONCURRENCY_REFERENCES.md) | [0204](0204-analysis-run-short-transaction-delivery.md) | From 2b0ff606b3b1924cde8e23413e33c41ddcd4c79e Mon Sep 17 00:00:00 2001 From: seonghobae Date: Tue, 25 Aug 2026 19:24:47 +0900 Subject: [PATCH 3/9] fix(ontology): enforce single confidence value --- docs/ontology/lineageweave-kg-shapes.ttl | 1 + tests/test_ontology_shapes.py | 13 ++++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/ontology/lineageweave-kg-shapes.ttl b/docs/ontology/lineageweave-kg-shapes.ttl index 9a2c8d6a9..c7c4c4036 100644 --- a/docs/ontology/lineageweave-kg-shapes.ttl +++ b/docs/ontology/lineageweave-kg-shapes.ttl @@ -127,6 +127,7 @@ sh:path :semanticConfidence ; sh:name "semantic confidence" ; sh:description "Extraction confidence stays inside [0.0, 1.0] inclusive." ; + sh:maxCount 1 ; sh:minInclusive 0.0 ; sh:maxInclusive 1.0 ; ] ; diff --git a/tests/test_ontology_shapes.py b/tests/test_ontology_shapes.py index 976713779..e8df1efe6 100644 --- a/tests/test_ontology_shapes.py +++ b/tests/test_ontology_shapes.py @@ -8,7 +8,7 @@ - the shipped shapes graph conforms to the SHACL specification itself; - a representative post/mention projection passes; -- the same projection with a confidence above 1.0 -- or a missing +- the same projection with duplicate or above-range confidence -- or a missing required title -- is rejected, naming the violated constraint. """ @@ -134,6 +134,17 @@ def test_representative_db_projection_passes_validation() -> None: "semanticConfidence", id="confidence-above-one", ), + pytest.param( + lambda g: g.add( + ( + URIRef(LW + "mention-alpha"), + URIRef(LW + "semanticConfidence"), + Literal("0.5", datatype=XSD.decimal), + ) + ), + "semanticConfidence", + id="duplicate-confidence", + ), pytest.param( lambda g: g.add( ( From 0906e5bb3545d55be2ac77387652a6783d386a2a Mon Sep 17 00:00:00 2001 From: seonghobae Date: Tue, 25 Aug 2026 19:28:37 +0900 Subject: [PATCH 4/9] fix(ui): separate ontology graph labels from edges --- frontend/src/App.css | 4 +++- frontend/src/components/OntologyExplorer.tsx | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/App.css b/frontend/src/App.css index d2ff10630..fbe394ffd 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1062,7 +1062,9 @@ .ontology-node text { fill: var(--color-text-heading); - stroke: none; + stroke: var(--color-background); + stroke-width: 4px; + paint-order: stroke; font-size: 0.8rem; } diff --git a/frontend/src/components/OntologyExplorer.tsx b/frontend/src/components/OntologyExplorer.tsx index 493da1a45..9b5f9af7a 100644 --- a/frontend/src/components/OntologyExplorer.tsx +++ b/frontend/src/components/OntologyExplorer.tsx @@ -386,7 +386,7 @@ function OntologyGraph({ {edge.property_label} · {t(TRUTH_LABEL[edge.truth_status_code] ?? edge.truth_status_code)} From 10d289cd3808ab9f49d3ceb3d261bc90971a0b0c Mon Sep 17 00:00:00 2001 From: seonghobae Date: Tue, 25 Aug 2026 19:41:00 +0900 Subject: [PATCH 5/9] docs(changelog): remove superseded namespace claim --- CHANGELOG.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f489e474..a44f7b966 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,9 +70,6 @@ All notable changes to this project are documented here. Format follows retrieval, multi-thread Event Lineage answers, persisted image-evidence citations, and the focused evidence popup. Their implementations remain active-PR evidence until protected merge. -- 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. - The PROV-O support profile now mints its product class mappings only in the From a7b5bc7fa74bc2b268a29c612f5e3bc083f6acc7 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Tue, 25 Aug 2026 20:00:40 +0900 Subject: [PATCH 6/9] test: avoid deprecated RDFLib graph parser --- tests/test_ontology_site.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_ontology_site.py b/tests/test_ontology_site.py index a2741988d..507932575 100644 --- a/tests/test_ontology_site.py +++ b/tests/test_ontology_site.py @@ -12,6 +12,7 @@ from rdflib import Graph from rdflib.compare import isomorphic +from rdflib.plugins.parsers.jsonld import to_rdf ROOT = Path(__file__).resolve().parents[1] SCRIPT = ROOT / "scripts" / "build_ontology_site.py" @@ -163,7 +164,8 @@ def test_serializations_round_trip_to_the_source_graph(tmp_path: Path) -> None: builder.build_site(ROOT, output) source = Graph().parse(ROOT / "docs" / "ontology" / "lineageweave-kg.ttl", format="turtle") - jsonld = Graph().parse(output / "ontology" / "ontology.jsonld", format="json-ld") + jsonld = Graph() + to_rdf(json.loads((output / "ontology" / "ontology.jsonld").read_text()), jsonld) ntriples = Graph().parse(output / "ontology" / "ontology.nt", format="nt") compatibility_source = Graph().parse( ROOT / "docs" / "ontology" / "namespace-compatibility.ttl", format="turtle" From 17cf8a7af4fec08238daa22883eddabf1124d9ee Mon Sep 17 00:00:00 2001 From: seonghobae Date: Tue, 25 Aug 2026 18:41:45 +0900 Subject: [PATCH 7/9] fix(ui): stop WorkspaceCalendar's fail-closed placeholder announcing as role=status Its resolved empty/unavailable state carried role="status" like sibling panels' transient loading text does, so mounting it inside the Board's collapsed Advanced Review Tools details collided with every other status region on the page (4 failing App.test.tsx assertions). RankingsPanel's own resolved placeholders carry no ARIA role for the same reason -- only the "Loading..." state announces. --- frontend/src/components/WorkspaceCalendar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/WorkspaceCalendar.tsx b/frontend/src/components/WorkspaceCalendar.tsx index 5f2631f39..0b37ae2dc 100644 --- a/frontend/src/components/WorkspaceCalendar.tsx +++ b/frontend/src/components/WorkspaceCalendar.tsx @@ -33,7 +33,7 @@ export function WorkspaceCalendar({

{t("Observed calendar events")}

{events.length === 0 ? ( -

+

{naruonAvailable ? t("No observed calendar events are available.") : failClosedCopy} From ed581a26c60db92df8a46af3ffd916cf13786e6b Mon Sep 17 00:00:00 2001 From: seonghobae Date: Tue, 25 Aug 2026 20:14:36 +0900 Subject: [PATCH 8/9] fix(ontology): validate complete project mentions --- CHANGELOG.md | 5 ++- ...itory-case-ontology-namespace-canonical.md | 8 ++-- docs/ontology/lineageweave-kg-shapes.ttl | 37 ++++++++++++++++--- scripts/publish_ontology_site.py | 17 ++++++--- tests/test_ontology_shapes.py | 27 ++++++++++++++ tests/test_publish_ontology_site.py | 19 ++++++++++ 6 files changed, 97 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a44f7b966..8c2856dc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,8 +15,9 @@ All notable changes to this project are documented here. Format follows dry-run migration tool for stored values. - Closed-world SHACL validation (`docs/ontology/lineageweave-kg-shapes.ttl`, pyshacl in tests): required post title/body/timestamp, project-mention - confidence bounded to `[0.0, 1.0]`, required person/entity names and entity - code, and the our-side/counterparty disjointness complement; published with + RDF subject/predicate/object chain, decimal confidence bounded to + `[0.0, 1.0]`, required person/entity names and entity code, and the + our-side/counterparty disjointness complement; published with the ontology site and guarded against dangling shape targets. - Node-attribute datatype properties grounded only in real schema columns (`postTitle`, `postBody`, `eventOccurredAt`, `personName`, diff --git a/docs/adr/0207-repository-case-ontology-namespace-canonical.md b/docs/adr/0207-repository-case-ontology-namespace-canonical.md index b44c7d502..4b70fb45e 100644 --- a/docs/adr/0207-repository-case-ontology-namespace-canonical.md +++ b/docs/adr/0207-repository-case-ontology-namespace-canonical.md @@ -95,9 +95,11 @@ fail loudly instead of silently polluting downstream graphs. relational lookup code, mirroring the existing `:mentions` / `:mentionedIn` pair. 10. A separate SHACL shapes graph validates projected data: - required post title/body/timestamps, single-valued confidence within - `[0.0, 1.0]`, required names on persons and entities, and the closed-world - complement of the our-side/counterparty disjointness. Publication copies + required post title/body/timestamps, a complete single-valued RDF + `subject`/`predicate`/`object` chain for every `ProjectMention`, + single-valued decimal confidence within `[0.0, 1.0]`, required names on + persons and entities, and the closed-world complement of the + our-side/counterparty disjointness. Publication copies the shapes artifact beside the ontology and refuses dangling shape targets outside the canonical namespace. diff --git a/docs/ontology/lineageweave-kg-shapes.ttl b/docs/ontology/lineageweave-kg-shapes.ttl index c7c4c4036..436eb401f 100644 --- a/docs/ontology/lineageweave-kg-shapes.ttl +++ b/docs/ontology/lineageweave-kg-shapes.ttl @@ -1,6 +1,7 @@ @prefix : . @prefix dcterms: . @prefix owl: . +@prefix rdf: . @prefix rdfs: . @prefix sh: . @prefix xsd: . @@ -20,11 +21,12 @@ # - the closed-world complement of :OurSidePerson # owl:disjointWith :CounterpartyPerson. # -# Every sh:targetClass / sh:path IRI must live in the canonical -# repository-case namespace -- scripts/publish_ontology_site.py fails -# closed on dangling targets so a renamed term cannot silently orphan -# its shape. tests/test_ontology_shapes.py validates this graph against -# the ontology source with pyshacl, plus a negative violation test. +# Every sh:targetClass must live in the canonical repository-case namespace; +# sh:path may additionally use RDF's subject/predicate/object reification +# predicates. scripts/publish_ontology_site.py fails closed on every other +# external or dangling target so a renamed term cannot silently orphan its +# shape. tests/test_ontology_shapes.py validates this graph against the +# ontology source with pyshacl, plus negative violation tests. ################################################################# @@ -123,11 +125,36 @@ :ProjectMentionShape a sh:NodeShape ; rdfs:label "Project mention shape" ; sh:targetClass :ProjectMention ; + sh:property [ + sh:path rdf:subject ; + sh:name "mentioned by post" ; + sh:description "Every reified project mention identifies exactly one source post." ; + sh:minCount 1 ; + sh:maxCount 1 ; + sh:class :Post ; + ] ; + sh:property [ + sh:path rdf:predicate ; + sh:name "project mention predicate" ; + sh:description "The reified statement is specifically a :mentionsProject assertion." ; + sh:minCount 1 ; + sh:maxCount 1 ; + sh:hasValue :mentionsProject ; + ] ; + sh:property [ + sh:path rdf:object ; + sh:name "mentioned project" ; + sh:description "Every reified project mention identifies exactly one project." ; + sh:minCount 1 ; + sh:maxCount 1 ; + sh:class :Project ; + ] ; sh:property [ sh:path :semanticConfidence ; sh:name "semantic confidence" ; sh:description "Extraction confidence stays inside [0.0, 1.0] inclusive." ; sh:maxCount 1 ; + sh:datatype xsd:decimal ; sh:minInclusive 0.0 ; sh:maxInclusive 1.0 ; ] ; diff --git a/scripts/publish_ontology_site.py b/scripts/publish_ontology_site.py index 2532af74a..91563f731 100644 --- a/scripts/publish_ontology_site.py +++ b/scripts/publish_ontology_site.py @@ -33,6 +33,7 @@ #: lowercase form is the deprecated compatibility vocabulary. CANONICAL_NAMESPACE = "https://contextualwisdomlab.github.io/LineageWeave/ontology#" DEPRECATED_NAMESPACE = "https://contextualwisdomlab.github.io/lineageweave/ontology#" +STANDARD_SHACL_PATHS = frozenset({RDF.subject, RDF.predicate, RDF.object}) _MAPPING_FOR_KIND = { OWL.Class: OWL.equivalentClass, @@ -138,17 +139,20 @@ def validate_shapes_graph(shapes: Graph, canonical: Graph) -> None: """Reject SHACL shapes whose targets dangle outside the ontology. A shape that targets a class absent from the canonical graph, or - constrains a path never declared there, would silently validate - nothing -- the publication boundary refuses it instead (ADR 0207 - decision 10). Only URI-valued targets and paths are checked; - literal sh:path values are not part of this contract. + constrains a path neither declared there nor one of RDF's three + reification predicates, would silently validate nothing -- the + publication boundary refuses it instead (ADR 0207 decision 10). + Only URI-valued targets and paths are checked; literal sh:path values + are not part of this contract. """ if not any(shapes.triples((None, RDF.type, SH.NodeShape))): raise ValueError("SHACL shapes graph declares no sh:NodeShape") for predicate in (SH.targetClass, SH.path): for value in shapes.objects(None, predicate): - if not isinstance(value, URIRef) or str(value).startswith( - CANONICAL_NAMESPACE + if ( + not isinstance(value, URIRef) + or str(value).startswith(CANONICAL_NAMESPACE) + or (predicate == SH.path and value in STANDARD_SHACL_PATHS) ): continue kind = "targetClass" if predicate == SH.targetClass else "path" @@ -178,6 +182,7 @@ def validate_shapes_graph(shapes: Graph, canonical: Graph) -> None: # Entailed classes: anything with a subclass assertion is a class. declared.update(str(subject) for subject, _ in canonical.subject_objects(RDFS.subClassOf)) declared = {str(subject) for subject in declared} + declared.update(map(str, STANDARD_SHACL_PATHS)) for target in shapes.objects(None, SH.targetClass): if str(target) not in declared: raise ValueError(f"SHACL targetClass is not an ontology class: {target}") diff --git a/tests/test_ontology_shapes.py b/tests/test_ontology_shapes.py index e8df1efe6..d26b4f803 100644 --- a/tests/test_ontology_shapes.py +++ b/tests/test_ontology_shapes.py @@ -83,7 +83,12 @@ def _representative_projection() -> Graph: # exactly like cataloged_person.person_name's NOT NULL. data.add((our_side, LWn.personName, Literal("Dana Whitfield"))) mention = URIRef(LW + "mention-alpha") + project = URIRef(LW + "project-alpha") + data.add((project, RDF.type, LWn.Project)) data.add((mention, RDF.type, LWn.ProjectMention)) + data.add((mention, RDF.subject, post)) + data.add((mention, RDF.predicate, LWn.mentionsProject)) + data.add((mention, RDF.object, project)) data.add( ( mention, @@ -123,6 +128,28 @@ def test_representative_db_projection_passes_validation() -> None: "postTitle", id="missing-required-post-title", ), + pytest.param( + lambda g: g.remove( + ( + URIRef(LW + "mention-alpha"), + RDF.subject, + None, + ) + ), + "mentioned by post", + id="missing-project-mention-subject", + ), + pytest.param( + lambda g: g.set( + ( + URIRef(LW + "mention-alpha"), + RDF.predicate, + URIRef(LW + "mentionsTeam"), + ) + ), + "project mention predicate", + id="wrong-project-mention-predicate", + ), pytest.param( lambda g: g.set( ( diff --git a/tests/test_publish_ontology_site.py b/tests/test_publish_ontology_site.py index 50ec94faf..649b69507 100644 --- a/tests/test_publish_ontology_site.py +++ b/tests/test_publish_ontology_site.py @@ -235,6 +235,25 @@ def test_shapes_validation_rejects_dangling_targets_and_outside_namespace() -> N with pytest.raises(ValueError, match="outside the canonical namespace"): publisher.validate_shapes_graph(outside_target, canonical) + outside_path = Graph() + outside_path.add((URIRef(f"{publisher.CANONICAL_NAMESPACE}S"), RDF.type, SH.NodeShape)) + outside_path.add( + ( + URIRef(f"{publisher.CANONICAL_NAMESPACE}S"), + SH.targetClass, + URIRef(f"{publisher.CANONICAL_NAMESPACE}Post"), + ) + ) + outside_path.add( + ( + URIRef(f"{publisher.CANONICAL_NAMESPACE}S"), + SH.path, + URIRef("https://example.test/ontology#ghostProperty"), + ) + ) + with pytest.raises(ValueError, match="outside the canonical namespace"): + publisher.validate_shapes_graph(outside_path, canonical) + dangling_class = Graph() dangling_class.add((URIRef(f"{publisher.CANONICAL_NAMESPACE}S"), RDF.type, SH.NodeShape)) dangling_class.add( From 9a1b416645495744e1551ccb9c957fa0f1b7466a Mon Sep 17 00:00:00 2001 From: seonghobae Date: Tue, 25 Aug 2026 20:42:19 +0900 Subject: [PATCH 9/9] fix(ontology): validate SHACL term kinds --- frontend/src/App.test.tsx | 6 +++--- scripts/publish_ontology_site.py | 24 +++++++++++------------- tests/test_publish_ontology_site.py | 16 +++++++++++++++- 3 files changed, 29 insertions(+), 17 deletions(-) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index fe1ca6186..407f49800 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1427,8 +1427,8 @@ describe("App, authenticated", () => { { node_id: "corp-1", node_type_code: "node_corporate_entity", - ontology_iri: "https://contextualwisdomlab.github.io/LineageWeave/ontology#Organization", - ontology_label: "Organization", + ontology_iri: "https://contextualwisdomlab.github.io/LineageWeave/ontology#CorporateEntity", + ontology_label: "Corporate entity", label: "Demo Corp", relevance: 0.2, ...demoOrgAlias, @@ -2806,7 +2806,7 @@ describe("App, authenticated", () => { await userEvent.click(screen.getByRole("button", { name: "Related nodes for Ada West" })); await waitFor(() => expect(screen.getByText("Related to Ada West")).toBeInTheDocument()); await userEvent.click( - screen.getByRole("button", { name: "Related nodes for Demo Corp (Organization)" }), + screen.getByRole("button", { name: "Related nodes for Demo Corp (Corporate entity)" }), ); await waitFor(() => expect(screen.getByText("Related to Demo Corp")).toBeInTheDocument()); expect(screen.getByText("Related to Demo Corp").closest(".related-keymen")).toHaveTextContent( diff --git a/scripts/publish_ontology_site.py b/scripts/publish_ontology_site.py index 91563f731..71ba6918c 100644 --- a/scripts/publish_ontology_site.py +++ b/scripts/publish_ontology_site.py @@ -159,36 +159,34 @@ def validate_shapes_graph(shapes: Graph, canonical: Graph) -> None: raise ValueError( f"SHACL {kind} target outside the canonical namespace: {value}" ) - declared = { + declared_classes = { subject for subject in canonical.subjects(RDF.type, OWL.Class) if isinstance(subject, URIRef) } - declared.update( + # Entailed classes: anything with a subclass assertion is a class. + declared_classes.update( subject - for subject in canonical.subjects(RDF.type, SKOS.Concept) + for subject, _ in canonical.subject_objects(RDFS.subClassOf) if isinstance(subject, URIRef) ) - declared.update( + declared_properties = { subject for subject in canonical.subjects(RDF.type, OWL.ObjectProperty) if isinstance(subject, URIRef) - ) - declared.update( + } + declared_properties.update( subject for subject in canonical.subjects(RDF.type, OWL.DatatypeProperty) if isinstance(subject, URIRef) ) - # Entailed classes: anything with a subclass assertion is a class. - declared.update(str(subject) for subject, _ in canonical.subject_objects(RDFS.subClassOf)) - declared = {str(subject) for subject in declared} - declared.update(map(str, STANDARD_SHACL_PATHS)) + declared_properties.update(STANDARD_SHACL_PATHS) for target in shapes.objects(None, SH.targetClass): - if str(target) not in declared: + if target not in declared_classes: raise ValueError(f"SHACL targetClass is not an ontology class: {target}") for path in shapes.objects(None, SH.path): - if str(path) not in declared: - raise ValueError(f"SHACL property path is not an ontology term: {path}") + if path not in declared_properties: + raise ValueError(f"SHACL property path is not an ontology property: {path}") def _validate_output_directory(output_dir: Path, source: Path, profile: Path) -> Path: diff --git a/tests/test_publish_ontology_site.py b/tests/test_publish_ontology_site.py index 649b69507..f92893f5a 100644 --- a/tests/test_publish_ontology_site.py +++ b/tests/test_publish_ontology_site.py @@ -271,9 +271,23 @@ def test_shapes_validation_rejects_dangling_targets_and_outside_namespace() -> N dangling_path.add((shape, RDF.type, SH.NodeShape)) dangling_path.add((shape, SH.targetClass, URIRef(f"{publisher.CANONICAL_NAMESPACE}Post"))) dangling_path.add((shape, SH.path, URIRef(f"{publisher.CANONICAL_NAMESPACE}ghostColumn"))) - with pytest.raises(ValueError, match="not an ontology term"): + with pytest.raises(ValueError, match="not an ontology property"): publisher.validate_shapes_graph(dangling_path, canonical) + wrong_kind_target = Graph() + wrong_kind_target.add((shape, RDF.type, SH.NodeShape)) + wrong_kind_target.add( + (shape, SH.targetClass, URIRef(f"{publisher.CANONICAL_NAMESPACE}postTitle")) + ) + with pytest.raises(ValueError, match="not an ontology class"): + publisher.validate_shapes_graph(wrong_kind_target, canonical) + + wrong_kind_path = Graph() + wrong_kind_path.add((shape, RDF.type, SH.NodeShape)) + wrong_kind_path.add((shape, SH.path, URIRef(f"{publisher.CANONICAL_NAMESPACE}Post"))) + with pytest.raises(ValueError, match="not an ontology property"): + publisher.validate_shapes_graph(wrong_kind_path, canonical) + publisher.validate_shapes_graph( Graph().parse(ROOT / "docs" / "ontology" / "lineageweave-kg-shapes.ttl", format="turtle"), canonical,