diff --git a/AGENTS.md b/AGENTS.md index b5961427c..c927f9e61 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 0207](docs/adr/0207-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.18.0-public-ontology-namespace-completeness.md b/CHANGELOG.d/2.18.0-public-ontology-namespace-completeness.md new file mode 100644 index 000000000..2a4f860d7 --- /dev/null +++ b/CHANGELOG.d/2.18.0-public-ontology-namespace-completeness.md @@ -0,0 +1,41 @@ +# 2.18.0 — Public ontology namespace completeness + +## Added + +- The repository-case public ontology namespace + `https://contextualwisdomlab.github.io/LineageWeave/ontology#` is + canonical ([ADR 0207], 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 0207]: docs/adr/0207-repository-case-ontology-namespace-canonical.md diff --git a/CHANGELOG.md b/CHANGELOG.md index d6ce21cd7..8c2856dc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,25 @@ 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 0207, 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 + 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`, + `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 0207). + - Buyer Calendar now consumes the Naruon calendar projection beside post-grounded commitments (ADR 0203 step 2 / #336). Observed occurrences stay evidence-only; a commitment still opens that post. The 달력 @@ -52,9 +71,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 diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index b4d0aa342..70d2f5897 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -1833,7 +1833,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/0207-repository-case-ontology-namespace-canonical.md b/docs/adr/0207-repository-case-ontology-namespace-canonical.md new file mode 100644 index 000000000..4b70fb45e --- /dev/null +++ b/docs/adr/0207-repository-case-ontology-namespace-canonical.md @@ -0,0 +1,179 @@ +# ADR 0207 — 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, 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. + +## 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 6aca500f1..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) | [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) | diff --git a/docs/doctoring/ONTOLOGY_NAMESPACE_INVENTORY.md b/docs/doctoring/ONTOLOGY_NAMESPACE_INVENTORY.md index e797324cf..c0ee3eae6 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 0207, 2026-08-25) + +The product owner directed the opposite canonicalization: the +repository-case namespace +`https://contextualwisdomlab.github.io/LineageWeave/ontology#` is now +canonical ([ADR 0207](../adr/0207-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..436eb401f --- /dev/null +++ b/docs/ontology/lineageweave-kg-shapes.ttl @@ -0,0 +1,185 @@ +@prefix : . +@prefix dcterms: . +@prefix owl: . +@prefix rdf: . +@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 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. +################################################################# + + + 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 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 ; + ] ; + 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..24aeabdba 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 0207 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/0207-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 0207 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..234e0f36e 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 0207 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.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/App.test.tsx b/frontend/src/App.test.tsx index 1fb7233fd..fe1ca6186 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1177,7 +1177,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", @@ -1304,7 +1304,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", }, ], @@ -1372,7 +1372,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", @@ -1409,7 +1409,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", @@ -1419,7 +1419,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, @@ -1427,7 +1427,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, @@ -1436,7 +1436,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, @@ -1454,7 +1454,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, @@ -1475,7 +1475,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, @@ -1500,7 +1500,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", @@ -1520,7 +1520,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", @@ -1530,7 +1530,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, @@ -2427,7 +2427,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/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)} 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} diff --git a/lineageweave/ontology.py b/lineageweave/ontology.py index f7edf76e6..5d0b3d508 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 0207 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 5800482d8..e3b7a5b18 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.17.0" +version = "2.18.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 0207 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..62eab1a7c 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 0207: 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 0207 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..eed95009f 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 0207 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 0207'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..91563f731 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,12 @@ 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 0207: 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#" +STANDARD_SHACL_PATHS = frozenset({RDF.subject, RDF.predicate, RDF.object}) _MAPPING_FOR_KIND = { OWL.Class: OWL.equivalentClass, @@ -131,6 +135,62 @@ 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 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) + or (predicate == SH.path and value in STANDARD_SHACL_PATHS) + ): + 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} + 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}") + 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 +210,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 +219,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..e9514996c 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 0207: +# 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 0207 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 0207: 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 0207 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 0207 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 0207 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..d26b4f803 --- /dev/null +++ b/tests/test_ontology_shapes.py @@ -0,0 +1,227 @@ +"""Closed-world SHACL validation of the LineageWeave knowledge-graph +ontology (ADR 0207 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 duplicate or above-range confidence -- 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") + 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, + 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.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( + ( + 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 + "mention-alpha"), + URIRef(LW + "semanticConfidence"), + Literal("0.5", datatype=XSD.decimal), + ) + ), + "semanticConfidence", + id="duplicate-confidence", + ), + 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 0207 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..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" @@ -69,6 +70,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 +83,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: @@ -159,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" @@ -196,6 +202,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 +210,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 +291,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..649b69507 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,78 @@ 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 0207 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) + + 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( + ( + 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 +341,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 diff --git a/uv.lock b/uv.lock index 4698a6cd7..6190681ba 100644 --- a/uv.lock +++ b/uv.lock @@ -513,6 +513,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "html5rdf" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/55/1b839c43f5ed8207e17a9a02d8b395179520b8b4f00c00a41e113bc205ca/html5rdf-1.2.1.tar.gz", hash = "sha256:ace9b420ce52995bb4f05e7425eedf19e433c981dfe7a831ab391e2fa2e1a195", size = 287899, upload-time = "2024-10-30T05:06:56.384Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/c9/f6e1e8567660bc5b0aba281f2b0017b2a7665fcad6bf3ed67286a0c72cd4/html5rdf-1.2.1-py2.py3-none-any.whl", hash = "sha256:1f519121bc366af3e485310dc8041d2e86e5173c1a320fac3dc9d2604069b83e", size = 109765, upload-time = "2024-10-30T05:06:52.507Z" }, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -597,7 +606,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "2.17.0" +version = "2.18.0" source = { editable = "." } dependencies = [ { name = "certifi" }, @@ -625,6 +634,7 @@ dev = [ { name = "httpx" }, { name = "psycopg2-binary" }, { name = "pyjwt", extra = ["crypto"] }, + { name = "pyshacl" }, { name = "pytest" }, ] @@ -644,6 +654,7 @@ requires-dist = [ { name = "psycopg2-binary", marker = "extra == 'dev'", specifier = ">=2.9.12" }, { name = "pyjwt", extras = ["crypto"], marker = "extra == 'backend'", specifier = ">=2.8.0" }, { name = "pyjwt", extras = ["crypto"], marker = "extra == 'dev'", specifier = ">=2.8.0" }, + { name = "pyshacl", marker = "extra == 'dev'", specifier = ">=0.26.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "rankweave", git = "https://github.com/ContextualWisdomLab/RankWeave.git?rev=61c49c50d3b4a24fc9bd7c6d3a7f2f4ba19d7be6" }, { name = "rdflib", specifier = ">=7.0.0" }, @@ -807,6 +818,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a6/0e/49df70d9b81fb5cbae4bbf2a49d865b09bcbcbc4eb53f5851b1027738d78/opentelemetry_semantic_conventions-0.65b0-py3-none-any.whl", hash = "sha256:1cacde7b0ad306f84c5ef08c3dbe1bbaf20165bba6f8bff43b670e555a086bcb", size = 204645, upload-time = "2026-07-16T15:25:30.688Z" }, ] +[[package]] +name = "owlrl" +version = "7.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "rdflib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/08/50dd7fd0c64775d3d6309f35c0cc9a9d635f392f94ee2c4f9122404f7f86/owlrl-7.6.2.tar.gz", hash = "sha256:c743f35c2d908396e77823852bb1ebbce88340cd49961493983bec42c93283a8", size = 48564, upload-time = "2026-07-08T08:38:26.462Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/5e/314be7440bf28dbd47f85321a7434c5b74179a762228487d6493c01bddce/owlrl-7.6.2-py3-none-any.whl", hash = "sha256:83347bf7f133979e87b2b18695d51d25510b99cec3f6919b5df05d4fbf058ae0", size = 55814, upload-time = "2026-07-08T08:38:23.842Z" }, +] + [[package]] name = "packaging" version = "26.3" @@ -896,6 +919,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "prettytable" +version = "3.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/74/ba08d81e668ccfe8658d7520a307e63c19862c08eb4ccb26f356c5239a7a/prettytable-3.18.0.tar.gz", hash = "sha256:439217116152244369caf3d9f1caf2f9fe29b03bd79e88d2928c8e718c95d680", size = 76373, upload-time = "2026-06-22T16:07:50.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/be/2e6798ace5cc036f5d05d36b7b2fd85346f1a708c87060890b070d0ec607/prettytable-3.18.0-py3-none-any.whl", hash = "sha256:b3346e0e6f79180833aebaac088ae926340586cf6d7d991b9eb125b65f72313a", size = 37357, upload-time = "2026-06-22T16:07:48.595Z" }, +] + [[package]] name = "protobuf" version = "7.36.0" @@ -1083,6 +1118,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, ] +[[package]] +name = "pyshacl" +version = "0.40.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "owlrl" }, + { name = "packaging" }, + { name = "prettytable" }, + { name = "rdflib", extra = ["html"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/b8/f92465fead905b7c5365631a3997107c896cf1e32f4f9a163bdaee54e4fb/pyshacl-0.40.1.tar.gz", hash = "sha256:011e3cf1a68b31747cb762ba3d755ae1bdcc464c8fad0dc212a9adc550719552", size = 1444205, upload-time = "2026-07-28T01:37:36.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/90/7f35a79db93032ef20db5b740062b54afba32a2c2475a6f0a43c141a69de/pyshacl-0.40.1-py3-none-any.whl", hash = "sha256:27dd58c8ddfa103303b4a8c40b2c666332ffc912dbcd3137f7adc7b7bc5e6bda", size = 1306209, upload-time = "2026-07-28T01:37:34.298Z" }, +] + [[package]] name = "pytest" version = "9.1.1" @@ -1171,6 +1221,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl", hash = "sha256:30c0a3ebf4c0e09215f066be7246794b6492e054e782d7ac2a34c9f70a15e0dd", size = 615416, upload-time = "2026-02-13T07:15:46.487Z" }, ] +[package.optional-dependencies] +html = [ + { name = "html5rdf" }, +] + [[package]] name = "redis" version = "8.1.0" @@ -1388,6 +1443,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, ] +[[package]] +name = "wcwidth" +version = "0.8.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" }, +] + [[package]] name = "websockets" version = "17.0.1"