### Base SHACL
###
### Fundamental constraint shapes for the Martialis database
###
### Frederico Munoz <fsmunoz@gmail.com>, Martialis Project
###
### Licence: CC0-1.0 (public domain dedication)
### Attribution requested but not required: cite "Martialis — https://martial.is"

### NB: "every Assertion must have at least one source" (and similar)
### is enforced in martialis-quality.shacl.ttl as a quality warning,
### not a structural constraint. Structural SHACL covers referential
### integrity and semantic correctness; data completeness requirements
### belong in the quality tier. The one exception is NegativeAssertion:
### an explicit denial without sources is worse than no data, so its
### evidence requirement is structural.
###
### Shapes target the concrete classes stamped by the conversion
### pipeline (bin/martialis rdf), so validation runs without any
### RDFS/OWL inference and without loading the ontology graph.
###
### Enum membership: concept-scheme checks (added in the SKOS vocabulary
### migration, ontology 2.0.0). Before this migration, enum-valued
### properties were plain xsd:string literals, and JSON Schema alone
### enforced their legal values - a second sh:in copy in SHACL would
### have had no additional referential or type-integrity value, so the
### policy was deliberately not to duplicate it (see this file's git
### history for that original reasoning).
### Since the migration, most of those same properties hold concept
### IRIs from ontology/vocabularies/*.ttl instead, and checking that an
### IRI resolves to a skos:Concept in the RIGHT scheme is exactly the
### kind of referential integrity JSON Schema structurally cannot
### express (it only ever sees the enum string, never the graph). Where
### that applies, the property shape below carries:
###     sh:class skos:Concept ;
###     sh:node [ sh:property [ sh:path skos:inScheme ; sh:hasValue <scheme-iri> ] ] ;
### A handful of properties were deliberately EXCLUDED from the IRI
### migration under the SKOS plan's governing rule ("concept IRIs for
### vocabularies Martialis defines, literals for tokens defined by an
### external standard") - sourceType/sourceLocatorLabel/contributorRole
### (CSL), peerReview (DRIVER/OpenAIRE), and confidenceLevel (an ordinal
### integer, not a vocabulary). Those keep their pre-migration
### unenforced-enum or sh:datatype xsd:string shapes; the comment on
### each says so explicitly rather than leaving it to be inferred.

@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
@prefix martialis: <https://w3id.org/martialis/ontology#> .
@prefix shapes: <https://w3id.org/martialis/ontology/shapes#> .
@prefix schema: <https://schema.org/> .
@prefix prov: <http://www.w3.org/ns/prov#> .
@prefix org: <http://www.w3.org/ns/org#> .
@prefix owl: <http://www.w3.org/2002/07/owl#> .
@prefix dcterms: <http://purl.org/dc/terms/> .
@prefix vann: <http://purl.org/vocab/vann/> .
@prefix skos: <http://www.w3.org/2004/02/skos/core#> .

# Shapes graph self-description
#################################################################
# Declared for documentation generators (pyLODE valpub) and for
# self-description; pyshacl does not follow owl:imports, and
# bin/martialis loads this file directly, so neither the ontology
# import nor these annotation triples affect validation.

<https://w3id.org/martialis/ontology/shapes/structural> a owl:Ontology , sh:ShapesGraph ;
    dcterms:title "Martialis Structural Constraints"@en ;
    dcterms:description "Fundamental constraint shapes for the Martialis database: referential integrity and semantic correctness. This is the blocking validation tier; data-completeness requirements belong to the quality tier (martialis-quality.shacl.ttl). Shapes target the concrete classes stamped by the conversion pipeline (bin/martialis rdf), so validation runs without RDFS/OWL inference and without loading the ontology graph."@en ;
    dcterms:license <https://creativecommons.org/publicdomain/zero/1.0/> ;
    dcterms:created "2026-02-28"^^xsd:date ;
    dcterms:modified "2026-09-07"^^xsd:date ;
    owl:versionInfo "1.2.0" ;
    owl:imports <https://w3id.org/martialis/ontology> ;
    vann:preferredNamespacePrefix "shapes" ;
    vann:preferredNamespaceUri <https://w3id.org/martialis/ontology/shapes#> ;
    rdfs:seeAlso <https://w3id.org/martialis/ontology#MartialisOntology> .

# Person
#################################################################

shapes:PersonShape
    a sh:NodeShape ;
    sh:severity sh:Violation ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Person"@en ;
    rdfs:comment "Core Person integrity: required name, well-formed birth/death life events, and referential integrity of every assertion link."@en ;
    sh:targetClass martialis:Person ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path schema:name ;
        sh:minCount 1 ;
        sh:message "A Person must have at least one name."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path schema:alternateName ;
        sh:datatype rdf:langString ;
        sh:message "Alternate names must be language-tagged string literals."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:hasTeacherAssertion ;
        sh:class martialis:TeacherAssertion ;
        sh:message "teachers must be TeacherAssertion nodes."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:hasNegativeAssertion ;
        sh:class martialis:NegativeAssertion ;
        sh:message "negated teacher entries must be NegativeAssertion nodes."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:hasContextualTrainingAssertion ;
        sh:class martialis:ContextualTrainingAssertion ;
        sh:message "teacher_id: null (contextual training) entries must be ContextualTrainingAssertion nodes."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:hasRankAssertion ;
        sh:class martialis:RankAssertion ;
        sh:message "rank must be expressed via RankAssertion."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:hasHonorAssertion ;
        sh:class martialis:HonorAssertion ;
        sh:message "honors must be expressed via HonorAssertion."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:hasDocumentTransmissionAssertion ;
        sh:class martialis:DocumentTransmissionAssertion ;
        sh:message "learned_from entries must be DocumentTransmissionAssertion nodes."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:hasIdentityAssertion ;
        sh:class martialis:IdentityAssertion ;
        sh:message "possibly_same_as entries must be IdentityAssertion nodes."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:taughtAt ;
        sh:class martialis:Place ;
        sh:message "taughtAt (materialized from teacher interactions) references a non-existent place."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:trainedAt ;
        sh:class martialis:Place ;
        sh:message "trainedAt (materialized from teacher/contextual training interactions) references a non-existent place."
    ] ;

    sh:sparql [
        a sh:SPARQLConstraint ;
        sh:name "person birth after death" ;
        sh:message "Person's earliest possible birth year is after their latest possible death year." ;
        sh:select """
            PREFIX martialis: <https://w3id.org/martialis/ontology#>
            PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
            SELECT $this ?birthEarliest ?deathLatest
            WHERE {
                $this martialis:birthEvent/martialis:earliestYear ?birthEarliest .
                $this martialis:deathEvent/martialis:latestYear ?deathLatest .
                FILTER(xsd:integer(STR(?birthEarliest)) > xsd:integer(STR(?deathLatest)))
            }
        """
    ] .

# TeacherAssertion
#################################################################

shapes:TeacherAssertionShape
    a sh:NodeShape ;
    sh:severity sh:Violation ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "TeacherAssertion"@en ;
    rdfs:comment "Every teacher claim needs a subject, an object teacher, and at least one interaction."@en ;
    sh:targetClass martialis:TeacherAssertion ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:subject ;
        sh:minCount 1 ;
        sh:class martialis:Person ;
        sh:message "TeacherAssertion subject must be a Person."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:object ;
        sh:minCount 1 ;
        sh:class martialis:Person ;
        sh:message "TeacherAssertion object must be a Person."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:assertedStyle ;
        sh:class martialis:Style ;
        sh:message "Teacher assertion references non-existent style."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:hasInteraction ;
        sh:minCount 1 ;
        sh:class martialis:TeacherInteraction ;
        sh:message "Teacher interactions must be TeacherInteraction nodes; every TeacherAssertion needs at least one."
    ] ;

    # role is optional on a TeacherAssertion (JSON Schema governs the
    # YAML enum), but when present its exported concept IRI must be in
    # the teacher-role scheme - the mirror of ParticipationShape's
    # event-role check on the other side of the martialis:role split.
    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:role ;
        sh:maxCount 1 ;
        sh:class skos:Concept ;
        sh:node [
            sh:property [ sh:path skos:inScheme ; sh:hasValue <https://w3id.org/martialis/vocab/teacher-role> ]
        ] ;
        sh:message "teachers[].role must be a teacher-role concept (student, uchi-deshi, seminar, private, examination, coach)."
    ] .

# NegativeAssertion
#################################################################

shapes:NegativeAssertionShape
    a sh:NodeShape ;
    sh:severity sh:Violation ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "NegativeAssertion"@en ;
    rdfs:comment "Denials are structurally required to carry evidence: an explicit denial without sources is worse than no data."@en ;
    sh:targetClass martialis:NegativeAssertion ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:subject ;
        sh:minCount 1 ;
        sh:class martialis:Person ;
        sh:message "NegativeAssertion subject must be a Person."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:object ;
        sh:minCount 1 ;
        sh:class martialis:Person ;
        sh:message "NegativeAssertion object must be a Person."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:assertedStyle ;
        sh:class martialis:Style ;
        sh:message "Negative assertion references non-existent style."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:hasEvidence ;
        sh:minCount 1 ;
        sh:message "A NegativeAssertion (explicit denial) must cite at least one source."
    ] .

# ContextualTrainingAssertion
#################################################################

shapes:ContextualTrainingAssertionShape
    a sh:NodeShape ;
    sh:severity sh:Violation ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "ContextualTrainingAssertion"@en ;
    rdfs:comment "Training with no identified teacher: a subject, a style, and at least one interaction, each anchored to a Place or Event. No object - that is what distinguishes it from a TeacherAssertion, and object is structurally forbidden here so the class cannot drift into an unsourced lineage claim."@en ;
    sh:targetClass martialis:ContextualTrainingAssertion ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:subject ;
        sh:minCount 1 ;
        sh:class martialis:Person ;
        sh:message "ContextualTrainingAssertion subject must be a Person."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:object ;
        sh:maxCount 0 ;
        sh:message "ContextualTrainingAssertion must not have an object - the teacher is unidentified by construction. A claim with a known teacher belongs in teachers[] with a real teacher_id instead."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:assertedStyle ;
        sh:minCount 1 ;
        sh:maxCount 1 ;
        sh:class martialis:Style ;
        sh:message "ContextualTrainingAssertion requires exactly one style."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:hasInteraction ;
        sh:minCount 1 ;
        sh:class martialis:TeacherInteraction ;
        sh:message "Contextual training interactions must be TeacherInteraction nodes; every ContextualTrainingAssertion needs at least one."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:role ;
        sh:maxCount 1 ;
        sh:class skos:Concept ;
        sh:node [
            sh:property [ sh:path skos:inScheme ; sh:hasValue <https://w3id.org/martialis/vocab/teacher-role> ]
        ] ;
        sh:message "contextual_training[].role must be a teacher-role concept (student, uchi-deshi, seminar, private, examination, coach)."
    ] ;

    # The anchor rule: JSON Schema enforces it on authoring (place_id or
    # event_ids required per interaction when teacher_id is null), but it
    # is re-checked here as the structural guarantee for any RDF that
    # reaches this class by another route (hand-built graphs, a future
    # second producer) - the same belt-and-braces stance the project
    # takes for "sources required" elsewhere.
    sh:sparql [
        a sh:SPARQLConstraint ;
        sh:name "contextual training interaction unanchored" ;
        sh:message "A ContextualTrainingAssertion interaction must reference a Place (martialis:interactionPlace) or an Event (martialis:atEvent) - a training claim with no identified teacher and no anchor asserts nothing checkable." ;
        sh:select """
            PREFIX martialis: <https://w3id.org/martialis/ontology#>
            SELECT $this
            WHERE {
                $this martialis:hasInteraction ?i .
                FILTER NOT EXISTS { ?i martialis:interactionPlace ?p }
                FILTER NOT EXISTS { ?i martialis:atEvent ?e }
            }
        """
    ] .

# TeacherInteraction
#################################################################

shapes:TeacherInteractionShape
    a sh:NodeShape ;
    sh:severity sh:Violation ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "TeacherInteraction"@en ;
    rdfs:comment "Interaction records must reference a Place and a well-formed period."@en ;
    sh:targetClass martialis:TeacherInteraction ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:interactionPlace ;
        sh:maxCount 1 ;
        sh:class martialis:Place ;
        sh:message "Interaction place must reference a Place."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:interactionPeriod ;
        sh:maxCount 1 ;
        sh:class martialis:TimeInterval ;
        sh:message "Interaction period must be a TimeInterval."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:atEvent ;
        sh:class martialis:Event ;
        sh:message "Interaction event_ids reference non-existent event."
    ] .

# RankAssertion
#################################################################

shapes:RankAssertionShape
    a sh:NodeShape ;
    sh:severity sh:Violation ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "RankAssertion"@en ;
    rdfs:comment "Rank claims: subject, style object, and rankSystem required; grantedBy (a Person) and assertedOrg (an Organization) are distinct granting routes. rankOrdinal and rankGrade are never both set (nonsensical - a grade can't be both numbered and enumerated) - see the sh:not below. Neither set is legitimate: the direction (rankType) may be documented without the source giving a specific degree (e.g. a source that only says 'black belt' without a dan number)."@en ;
    sh:targetClass martialis:RankAssertion ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:subject ;
        sh:minCount 1 ;
        sh:class martialis:Person ;
        sh:message "RankAssertion subject must be a Person."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:object ;
        sh:minCount 1 ;
        sh:class martialis:Style ;
        sh:message "RankAssertion object must be a Style."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:grantedBy ;
        sh:class martialis:Person ;
        sh:message "Rank granted_by references non-existent person."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:assertedOrg ;
        sh:class martialis:Organization ;
        sh:message "Rank assertion references non-existent organization."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:rankSystem ;
        sh:minCount 1 ;
        sh:maxCount 1 ;
        sh:class martialis:RankingSystem ;
        sh:message "rank_system_id must reference an existing RankingSystem entity."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:rankType ;
        sh:maxCount 1 ;
        sh:class skos:Concept ;
        sh:node [
            sh:property [ sh:path skos:inScheme ; sh:hasValue <https://w3id.org/martialis/vocab/rank-type> ]
        ] ;
        sh:message "rank_type must be a rank-type concept (dan or kyu)."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:rankOrdinal ;
        sh:maxCount 1 ;
        sh:datatype xsd:integer ;
        sh:message "rank_ordinal must be an integer."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:rankGrade ;
        sh:maxCount 1 ;
        sh:class skos:Concept ;
        # Checks membership in SOME enumerated rank-grade scheme, not
        # that the grade belongs to the SPECIFIC RankingSystem this
        # assertion's rankSystem names - that cross-reference (join
        # through the referenced entity's rdfs:seeAlso to find its
        # declared grade_scheme) is not expressible as a simple sh:in/
        # sh:hasValue and would need sh:sparql; left as a documented gap
        # rather than solved here - see person.schema.json's Rank.rank_grade
        # comment for the same caveat on the JSON Schema side. Extend the
        # sh:or list below when a new enumerated scheme is added.
        sh:node [
            sh:or (
                [ sh:property [ sh:path skos:inScheme ; sh:hasValue <https://w3id.org/martialis/vocab/rank-grade/menkyo> ] ]
                [ sh:property [ sh:path skos:inScheme ; sh:hasValue <https://w3id.org/martialis/vocab/rank-grade/unified-sports-classification> ] ]
                [ sh:property [ sh:path skos:inScheme ; sh:hasValue <https://w3id.org/martialis/vocab/rank-grade/bjj> ] ]
            )
        ] ;
        sh:message "rank_grade must be a concept in a known enumerated rank-grade scheme."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:atEvent ;
        sh:class martialis:Event ;
        sh:message "Rank event_ids reference non-existent event."
    ] .

    # rankOrdinal and rankGrade are NOT mutually exclusive: BJJ_Ranking_System
    # (kind: enumerated) has grades that further subdivide by degree
    # (IBJJF Art. 4.1.2 - white through black belt each carry numbered
    # degrees/stripes), so a Rank can legitimately carry both
    # (rankGrade: bjj_black, rankOrdinal: 6). A prior revision of this
    # shape forbade both being set at once, assuming every enumerated
    # system was flat (true for menkyo/shogo/USSC, false for BJJ) - see
    # ontology/martialis.ttl's 2.1.0 skos:historyNote.

    # Note: grantedBy is optional (often unknown)

# HonorAssertion
#################################################################

shapes:HonorAssertionShape
    a sh:NodeShape ;
    sh:severity sh:Violation ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "HonorAssertion"@en ;
    rdfs:comment "Honor claims: subject required; optional granting organization (object) or granting person (grantedBy)."@en ;
    sh:targetClass martialis:HonorAssertion ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:subject ;
        sh:minCount 1 ;
        sh:class martialis:Person ;
        sh:message "HonorAssertion subject must be a Person."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:object ;
        sh:class martialis:Organization ;
        sh:message "HonorAssertion object must be an Organization."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:grantedBy ;
        sh:class martialis:Person ;
        sh:message "Honor granted_by references non-existent person."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:assertedOrg ;
        sh:class martialis:Organization ;
        sh:message "Honor assertion references non-existent organization."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:honorType ;
        sh:maxCount 1 ;
        sh:class skos:Concept ;
        sh:node [
            sh:property [ sh:path skos:inScheme ; sh:hasValue <https://w3id.org/martialis/vocab/honor-type> ]
        ] ;
        sh:message "honor_type must be a honor-type concept."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:honorGrade ;
        sh:maxCount 1 ;
        sh:class skos:Concept ;
        sh:node [
            sh:property [ sh:path skos:inScheme ; sh:hasValue <https://w3id.org/martialis/vocab/rank-grade/shogo> ]
        ] ;
        sh:message "honor_grade must be a shogo concept (the only ordered honorific scheme currently defined)."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:atEvent ;
        sh:class martialis:Event ;
        sh:message "Honor event_ids reference non-existent event."
    ] .

    # Note: object (granting organization) is optional for honors without granting entity

# DocumentTransmissionAssertion
#################################################################

shapes:DocumentTransmissionAssertionShape
    a sh:NodeShape ;
    sh:severity sh:Violation ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "DocumentTransmissionAssertion"@en ;
    rdfs:comment "Document-based learning claims must reference a transmissionDocument Source."@en ;
    sh:targetClass martialis:DocumentTransmissionAssertion ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:subject ;
        sh:minCount 1 ;
        sh:class martialis:Person ;
        sh:message "DocumentTransmissionAssertion subject must be a Person."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:transmissionDocument ;
        sh:minCount 1 ;
        sh:class martialis:Source ;
        sh:message "learned_from document_id must reference an existing Source."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:assertedStyle ;
        sh:class martialis:Style ;
        sh:message "Document transmission references non-existent style."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:validDuring ;
        sh:maxCount 1 ;
        sh:class martialis:TimeInterval ;
        sh:message "Document transmission period must be a TimeInterval."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:atEvent ;
        sh:class martialis:Event ;
        sh:message "Document transmission event_ids reference non-existent event."
    ] .

# IdentityAssertion
#################################################################

shapes:IdentityAssertionShape
    a sh:NodeShape ;
    sh:severity sh:Violation ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "IdentityAssertion"@en ;
    rdfs:comment "Candidate-identity claims: subject and object must be distinct Persons."@en ;
    sh:targetClass martialis:IdentityAssertion ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:subject ;
        sh:minCount 1 ;
        sh:class martialis:Person ;
        sh:message "IdentityAssertion subject must be a Person."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:object ;
        sh:minCount 1 ;
        sh:class martialis:Person ;
        sh:message "possibly_same_as must reference an existing Person."
    ] ;

    sh:sparql [
        a sh:SPARQLConstraint ;
        sh:name "identity not self-referential" ;
        sh:message "possibly_same_as must not reference the person itself." ;
        sh:select """
            SELECT $this
            WHERE {
                $this <https://w3id.org/martialis/ontology#subject> ?p .
                $this <https://w3id.org/martialis/ontology#object> ?p .
            }
        """
    ] .

# StyleRelationAssertion
#################################################################

shapes:StyleRelationAssertionShape
    a sh:NodeShape ;
    sh:severity sh:Violation ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "StyleRelationAssertion"@en ;
    rdfs:comment "Style-to-style claims: closed styleRelationType vocabulary; subject and object must be distinct styles."@en ;
    sh:targetClass martialis:StyleRelationAssertion ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:subject ;
        sh:minCount 1 ;
        sh:class martialis:Style ;
        sh:message "StyleRelationAssertion subject must be a Style."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:object ;
        sh:minCount 1 ;
        sh:class martialis:Style ;
        sh:message "Style relation references non-existent style."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:styleRelationType ;
        sh:minCount 1 ;
        # Since the SKOS vocabulary migration (2.0.0), values are concept
        # IRIs - checking scheme membership here is now referential
        # integrity JSON Schema structurally cannot express (it only sees
        # the enum string, not the IRI's skos:inScheme). See "Enum
        # membership: concept-scheme checks" in this file's header.
        sh:class skos:Concept ;
        sh:node [
            sh:property [ sh:path skos:inScheme ; sh:hasValue <https://w3id.org/martialis/vocab/style-relation> ]
        ] ;
        sh:message "StyleRelationAssertion must have a relation type."
    ] ;

    sh:sparql [
        a sh:SPARQLConstraint ;
        sh:name "style relation not self-referential" ;
        sh:message "related_styles must not reference the style itself." ;
        sh:select """
            SELECT $this
            WHERE {
                $this <https://w3id.org/martialis/ontology#subject> ?s .
                $this <https://w3id.org/martialis/ontology#object> ?s .
            }
        """
    ] .

# AffiliationAssertion
#################################################################

shapes:AffiliationAssertionShape
    a sh:NodeShape ;
    sh:severity sh:Violation ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "AffiliationAssertion"@en ;
    rdfs:comment "Typed affiliation claims: closed relation vocabulary; genealogy values are Organization-only."@en ;
    sh:targetClass martialis:AffiliationAssertion ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:subject ;
        sh:minCount 1 ;
        sh:or (
            [ sh:class martialis:Place ]
            [ sh:class martialis:Organization ]
        ) ;
        sh:message "AffiliationAssertion subject must be a Place or an Organization."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:object ;
        sh:minCount 1 ;
        sh:class martialis:Organization ;
        sh:message "AffiliationAssertion must reference an existing Organization."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:affiliationRelation ;
        sh:minCount 1 ;
        sh:maxCount 1 ;
        # See the concept-scheme note on StyleRelationAssertionShape above.
        sh:class skos:Concept ;
        sh:node [
            sh:property [ sh:path skos:inScheme ; sh:hasValue <https://w3id.org/martialis/vocab/affiliation-relation> ]
        ] ;
        sh:message "AffiliationAssertion must have exactly one relation."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:validDuring ;
        sh:maxCount 1 ;
        sh:class martialis:TimeInterval ;
        sh:message "Affiliation period must be a TimeInterval."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:atEvent ;
        sh:class martialis:Event ;
        sh:message "Affiliation event_ids reference non-existent event."
    ] ;

    sh:sparql [
        a sh:SPARQLConstraint ;
        sh:name "affiliation not self-referential" ;
        sh:message "Affiliation must not reference the entity itself." ;
        sh:select """
            SELECT $this
            WHERE {
                $this <https://w3id.org/martialis/ontology#subject> ?o .
                $this <https://w3id.org/martialis/ontology#object> ?o .
            }
        """
    ] ;

    sh:sparql [
        a sh:SPARQLConstraint ;
        sh:name "genealogy organization-only" ;
        sh:message "Genealogy affiliation relations (derived_from, split_from, merged_from, replaces, renamed_from) are Organization-only - a Place cannot claim institutional lineage." ;
        sh:select """
            PREFIX martialis: <https://w3id.org/martialis/ontology#>
            PREFIX affrel: <https://w3id.org/martialis/vocab/affiliation-relation/>
            SELECT $this
            WHERE {
                $this martialis:affiliationRelation ?rel ;
                      martialis:subject ?s .
                FILTER (?rel IN (affrel:derived_from, affrel:split_from, affrel:merged_from,
                                 affrel:replaces, affrel:renamed_from))
                FILTER NOT EXISTS { ?s a martialis:Organization }
            }
        """
    ] .

# InstructorRole
#################################################################

shapes:InstructorRoleShape
    a sh:NodeShape ;
    sh:severity sh:Violation ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "InstructorRole"@en ;
    rdfs:comment "Venue-staff records: instructor must be a Person; capacity, style, and period well-formed."@en ;
    sh:targetClass martialis:InstructorRole ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:instructor ;
        sh:minCount 1 ;
        sh:class martialis:Person ;
        sh:message "Place instructors[].person_id must reference an existing Person."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:instructorPeriod ;
        sh:maxCount 1 ;
        sh:class martialis:TimeInterval ;
        sh:message "Instructor period must be a TimeInterval."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:instructorCapacity ;
        sh:maxCount 1 ;
        sh:class skos:Concept ;
        sh:node [
            sh:property [ sh:path skos:inScheme ; sh:hasValue <https://w3id.org/martialis/vocab/instructor-capacity> ]
        ] ;
        sh:message "InstructorRole must have at most one capacity."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:instructorStyle ;
        sh:maxCount 1 ;
        sh:class martialis:Style ;
        sh:message "instructors[].style_id must reference an existing Style."
    ] .

# OfficerRole
#################################################################

shapes:OfficerRoleShape
    a sh:NodeShape ;
    sh:severity sh:Violation ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "OfficerRole"@en ;
    rdfs:comment "Organization leadership records: officer must be a Person; title free text; period required."@en ;
    sh:targetClass martialis:OfficerRole ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:officer ;
        sh:minCount 1 ;
        sh:class martialis:Person ;
        sh:message "Organization officers[].person_id must reference an existing Person."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:officerTitle ;
        sh:minCount 1 ;
        sh:maxCount 1 ;
        sh:message "Officer role must have a title."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:officerPeriod ;
        sh:maxCount 1 ;
        sh:class martialis:TimeInterval ;
        sh:message "Officer period must be a TimeInterval."
    ] .

# Place
#################################################################

shapes:PlaceShape
    a sh:NodeShape ;
    sh:severity sh:Violation ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Place"@en ;
    rdfs:comment "Venue constraints: required name, placeType vocabulary, parent/affiliation/staff references."@en ;
    sh:targetClass martialis:Place ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path schema:name ;
        sh:minCount 1 ;
        sh:message "A Place must have at least one name."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:placeType ;
        sh:maxCount 1 ;
        sh:class skos:Concept ;
        sh:node [
            sh:property [ sh:path skos:inScheme ; sh:hasValue <https://w3id.org/martialis/vocab/place-classification> ]
        ] ;
        sh:message "NB: placeType 'school' means an education institution (secondary/high school); a martial arts school/ryuha is an Organization with orgType 'school'."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:parentPlace ;
        sh:class martialis:Place ;
        sh:message "parent_place_id references non-existent place."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:hasAffiliationAssertion ;
        sh:class martialis:AffiliationAssertion ;
        sh:message "Place affiliations must be AffiliationAssertion nodes."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:hasInstructorRole ;
        sh:class martialis:InstructorRole ;
        sh:message "Place instructors must be InstructorRole nodes."
    ] .

# Organization
#################################################################

shapes:OrganizationShape
    a sh:NodeShape ;
    sh:severity sh:Violation ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Organization"@en ;
    rdfs:comment "Organization constraints: required name, orgType vocabulary, affiliations, officers, founded/dissolved events."@en ;
    sh:targetClass martialis:Organization ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path schema:name ;
        sh:minCount 1 ;
        sh:message "An Organization must have at least one name."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:orgType ;
        sh:maxCount 1 ;
        sh:class skos:Concept ;
        sh:node [
            sh:property [ sh:path skos:inScheme ; sh:hasValue <https://w3id.org/martialis/vocab/org-classification> ]
        ] ;
        sh:message "NB: orgType 'school' means a martial arts school/ryuha; an education institution is a Place with placeType 'school'."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:hasAffiliationAssertion ;
        sh:class martialis:AffiliationAssertion ;
        sh:message "Organization affiliations must be AffiliationAssertion nodes."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:parentOrg ;
        sh:class martialis:Organization ;
        sh:message "parentOrg (materialized from a branch_of affiliation) references non-existent organization."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:hasOfficerRole ;
        sh:class martialis:OfficerRole ;
        sh:message "Organization officers must be OfficerRole nodes."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:foundedEvent ;
        sh:maxCount 1 ;
        sh:class martialis:LifeEvent ;
        sh:message "founded must be a LifeEvent node."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:dissolvedEvent ;
        sh:maxCount 1 ;
        sh:class martialis:LifeEvent ;
        sh:message "dissolved must be a LifeEvent node."
    ] .

# ChangeEvent (org:ChangeEvent, materialized at export time from
# genealogy AffiliationAssertions - see bin/martialis and
# ontology/martialis.ttl's "Organizational Change Events" section)
#################################################################

shapes:ChangeEventShape
    a sh:NodeShape ;
    sh:severity sh:Violation ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "org:ChangeEvent (materialized)"@en ;
    rdfs:comment "Materialized genealogy events: original/resulting organizations plus changeKind/changeYear."@en ;
    sh:targetClass org:ChangeEvent ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path org:originalOrganization ;
        sh:minCount 1 ;
        sh:class martialis:Organization ;
        sh:message "org:originalOrganization must reference an existing Organization."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path org:resultingOrganization ;
        sh:minCount 1 ;
        sh:maxCount 1 ;
        sh:class martialis:Organization ;
        sh:message "org:resultingOrganization must reference an existing Organization."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:changeKind ;
        sh:minCount 1 ;
        sh:maxCount 1 ;
        # changeKind is a materialized copy of the AffiliationAssertion
        # genealogy relation that produced this ChangeEvent (bin/martialis's
        # genealogy pass), so it carries the same affiliation-relation
        # concept IRIs martialis:affiliationRelation does - restricted here
        # to the genealogy subset via sh:in, since founding-only TIES
        # values (member_of/branch_of/recognised_by) can never legally
        # appear on a ChangeEvent.
        sh:in ( <https://w3id.org/martialis/vocab/affiliation-relation/derived_from>
                <https://w3id.org/martialis/vocab/affiliation-relation/split_from>
                <https://w3id.org/martialis/vocab/affiliation-relation/merged_from>
                <https://w3id.org/martialis/vocab/affiliation-relation/replaces>
                <https://w3id.org/martialis/vocab/affiliation-relation/renamed_from> ) ;
        sh:message "changeKind must be one of: derived_from, split_from, merged_from, replaces, renamed_from."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:changeYear ;
        sh:maxCount 1 ;
        sh:datatype xsd:integer ;
        sh:message "changeYear must be an integer year."
    ] .

# Style
#################################################################

shapes:StyleShape
    a sh:NodeShape ;
    sh:severity sh:Violation ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Style"@en ;
    rdfs:comment "Style constraints: required name, styleType/styleForm vocabularies, rank system, style relations."@en ;
    sh:targetClass martialis:Style ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path schema:name ;
        sh:minCount 1 ;
        sh:message "A Style must have at least one name."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:styleType ;
        sh:maxCount 1 ;
        sh:class skos:Concept ;
        sh:node [
            sh:property [ sh:path skos:inScheme ; sh:hasValue <https://w3id.org/martialis/vocab/style-classification> ]
        ] ;
        sh:message "classification must be a single value (traditional, modern, or other)."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:styleForm ;
        # No shape existed here before the SKOS vocabulary migration -
        # values were unenforced free text. Added alongside the concept-IRI
        # migration since a class/scheme check is now cheap to state.
        sh:class skos:Concept ;
        sh:node [
            sh:property [ sh:path skos:inScheme ; sh:hasValue <https://w3id.org/martialis/vocab/style-form> ]
        ] ;
        sh:message "form values must be hybrid and/or sport."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:rankSystem ;
        sh:maxCount 1 ;
        sh:class martialis:RankingSystem ;
        sh:message "rank_system_id must reference an existing RankingSystem entity."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:confersRank ;
        sh:maxCount 1 ;
        sh:datatype xsd:boolean ;
        sh:message "confers_rank must be a boolean."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:foundedEvent ;
        sh:maxCount 1 ;
        sh:class martialis:LifeEvent ;
        sh:message "founded must be a LifeEvent node."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:dissolvedEvent ;
        sh:maxCount 1 ;
        sh:class martialis:LifeEvent ;
        sh:message "dissolved must be a LifeEvent node."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:hasStyleRelationAssertion ;
        sh:class martialis:StyleRelationAssertion ;
        sh:message "related_styles must be expressed via StyleRelationAssertion."
    ] .

# LifeEvent (birth/death on Person; founded/dissolved on Style,
# Organization, Place)
#################################################################

shapes:LifeEventShape
    a sh:NodeShape ;
    sh:severity sh:Violation ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "LifeEvent"@en ;
    rdfs:comment "Birth/death/founding/dissolution events: founder, evidence, and dissolution manner/reason consistency."@en ;
    sh:targetClass martialis:LifeEvent ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:founder ;
        sh:class martialis:Person ;
        sh:message "founder_ids references a non-existent Person."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:hasEvidence ;
        sh:class martialis:SourceAssertionRelation ;
        sh:message "LifeEvent sources must be SourceAssertionRelation nodes."
    ] ;

    # dissolutionManner/dissolutionReason are Organization-only in the
    # JSON Schema (org.schema.json's dissolved object), but LifeEvent is
    # shared with birth/death/founded, which simply never populate them -
    # constraining shape here (rather than a separate Organization-only
    # dissolved shape) mirrors how founder_ids/place_id/geo are already
    # handled on this same shape.
    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:dissolutionManner ;
        sh:maxCount 1 ;
        sh:class skos:Concept ;
        sh:node [
            sh:property [ sh:path skos:inScheme ; sh:hasValue <https://w3id.org/martialis/vocab/dissolution-manner> ]
        ] ;
        sh:message "Organization must have at most one dissolution manner."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:dissolutionReason ;
        sh:maxCount 1 ;
        sh:datatype xsd:string ;
        sh:message "dissolved.reason must be a string."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:atEvent ;
        sh:class martialis:Event ;
        sh:message "LifeEvent event_ids reference non-existent event."
    ] .

# Event
#################################################################

shapes:EventShape
    a sh:NodeShape ;
    sh:severity sh:Violation ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Event"@en ;
    rdfs:comment "Notable events: required name, eventType vocabulary, series containment, participation records."@en ;
    sh:targetClass martialis:Event ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path schema:name ;
        sh:minCount 1 ;
        sh:message "An Event must have a name."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:eventType ;
        sh:maxCount 1 ;
        sh:class skos:Concept ;
        sh:node [
            sh:property [ sh:path skos:inScheme ; sh:hasValue <https://w3id.org/martialis/vocab/event-classification> ]
        ] ;
        sh:message "Event must have at most one classification."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path schema:organizer ;
        sh:class martialis:Organization ;
        sh:message "Event org_ids reference non-existent organization."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path schema:about ;
        sh:class martialis:Style ;
        sh:message "Event style_ids reference non-existent style."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path schema:superEvent ;
        sh:class martialis:Event ;
        sh:message "Event part_of_id references non-existent event."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:hasParticipation ;
        sh:class martialis:Participation ;
        sh:message "Event participants must be Participation nodes."
    ] .

# LifeEvent/Event location integrity
#################################################################
# place_id and geo share the schema:location predicate (a reference to
# a Place entity vs. an inline, untyped GeoRef blank node): filter to
# IRI-valued location references only, so the untyped GeoRef siblings
# aren't flagged. Both target classes need the identical check
# (LifeEvent's birth/death/founded, Event's own location) - one shared
# shape rather than a byte-identical copy on each.

shapes:LocationReferencesPlaceShape
    a sh:NodeShape ;
    sh:severity sh:Violation ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Location references Place"@en ;
    rdfs:comment "IRI-valued schema:location on LifeEvent and Event must resolve to a Place entity; inline GeoRef blank nodes are exempt."@en ;
    sh:targetClass martialis:LifeEvent, martialis:Event ;
    sh:sparql [
        a sh:SPARQLConstraint ;
        sh:name "place missing reference" ;
        sh:message "place_id references a non-existent Place." ;
        sh:select """
            PREFIX martialis: <https://w3id.org/martialis/ontology#>
            PREFIX schema: <https://schema.org/>
            SELECT $this ?place
            WHERE {
                $this schema:location ?place .
                FILTER(isIRI(?place))
                FILTER NOT EXISTS { ?place a martialis:Place }
            }
        """
    ] .

# Country value integrity
#################################################################
# schema:addressCountry (nationality codes, inline geo) holds the EU
# Publications Office country authority's own concept IRIs, minted by
# concatenation at export time (bin/martialis's resolve_country_terms,
# EU_COUNTRY_IRI_BASE + code from ontology/country-codes.txt), or the
# local exception concept (XRY) from the country scheme in
# vocabularies.ttl. Martialis does not vendor the EU authority's RDF
# (see docs/VALIDATION.md), so this shape cannot check concept
# membership (sh:class skos:Concept) against a local copy - it checks
# IRI *shape* instead: one of the two known country-IRI namespaces
# followed by a 3-letter code. A bare string or a value from any other
# namespace fails. The rdf step already refuses to emit an unknown
# code (against ontology/country-codes.txt), so this shape is the
# backstop for anything that slips past it.

shapes:AddressCountryShape
    a sh:NodeShape ;
    sh:severity sh:Violation ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "address country value"@en ;
    rdfs:comment "Every schema:addressCountry value must be a country IRI: an EU Publications Office country authority IRI or a local exception (XRY) - never a bare code string."@en ;
    sh:targetSubjectsOf schema:addressCountry ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path schema:addressCountry ;
        sh:nodeKind sh:IRI ;
        sh:pattern "^(http://publications\\.europa\\.eu/resource/authority/country/[A-Z]{3}|https://w3id\\.org/martialis/vocab/country/[A-Z]{3})$" ;
        sh:message "addressCountry must be an EU authority country IRI or a local exception IRI (XRY), not a bare code string."
    ] .

# Participation
#################################################################

shapes:ParticipationShape
    a sh:NodeShape ;
    sh:severity sh:Violation ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Participation"@en ;
    rdfs:comment "Event participation records: object must be a Person or an Organization, role an event-role concept, evidence SourceAssertionRelation nodes."@en ;
    sh:targetClass martialis:Participation ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:object ;
        sh:minCount 1 ;
        sh:or (
            [ sh:class martialis:Person ]
            [ sh:class martialis:Organization ]
        ) ;
        sh:message "Event participant (person_id/org_id) must be an existing Person or Organization."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:participationRole ;
        sh:minCount 1 ;
        # Event participation roles are their own vocabulary (event-role),
        # split off martialis:role in the 2.1.0 quality pass - teacher-role
        # (student/uchi-deshi/coach/...) is for TeacherAssertions only.
        sh:class skos:Concept ;
        sh:node [
            sh:property [ sh:path skos:inScheme ; sh:hasValue <https://w3id.org/martialis/vocab/event-role> ]
        ] ;
        sh:message "participants[].role must be an event-role concept (competitor, official, organizer, demonstrator, guest, instructor, student, other)."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:hasEvidence ;
        sh:class martialis:SourceAssertionRelation ;
        sh:message "Participation sources must be SourceAssertionRelation nodes."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:participationStyle ;
        sh:class martialis:Style ;
        sh:message "participants[].style_ids must reference an existing Style."
    ] .

# TimeInterval
#################################################################

shapes:TimeIntervalShape
    a sh:NodeShape ;
    sh:severity sh:Violation ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "TimeInterval"@en ;
    rdfs:comment "startYear must not be later than endYear."@en ;
    sh:targetClass martialis:TimeInterval ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:startYear ;
        sh:datatype xsd:integer ;
        sh:message "startYear must be an integer year."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:endYear ;
        sh:datatype xsd:integer ;
        sh:message "endYear must be an integer year."
    ] ;

    sh:sparql [
        sh:name "interval end before start" ;
        sh:message "endYear must not be earlier than startYear." ;
        sh:select """
            PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
            SELECT $this
            WHERE {
              $this <https://w3id.org/martialis/ontology#startYear> ?s .
              $this <https://w3id.org/martialis/ontology#endYear> ?e .
              FILTER (xsd:integer(STR(?e)) < xsd:integer(STR(?s)))
            }
        """
    ] .

# SourceAssertionRelation (Evidence)
#################################################################

shapes:SourceAssertionRelationShape
    a sh:NodeShape ;
    sh:severity sh:Violation ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "SourceAssertionRelation (evidence)"@en ;
    rdfs:comment "Evidence records: basedOnSource required; supports, weight, locator type, and quotation consistency."@en ;
    sh:targetClass martialis:SourceAssertionRelation ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:basedOnSource ;
        sh:class martialis:Source ;
        sh:message "Evidence source_id must reference an existing Source."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:supports ;
        sh:minCount 1 ;
        sh:datatype xsd:boolean ;
        sh:message "Evidence must state whether it supports or contradicts the claim."
    ] ;

    # sourceRefType and evidenceWeight are concept IRIs since the SKOS
    # vocabulary migration (2.0.0) - sh:class/sh:node replace their old
    # sh:datatype xsd:string checks. sourceLocatorLabel stays a plain
    # string below: it aligns to CSL's citation-locator vocabulary, an
    # external standard, so under the SKOS plan's governing rule
    # ("concept IRIs for vocabularies Martialis defines, literals for
    # tokens defined by an external standard") it was deliberately left
    # out of the migration - see ontology/vocabularies/source-locator-label.ttl.
    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:sourceRefType ;
        sh:class skos:Concept ;
        sh:node [
            sh:property [ sh:path skos:inScheme ; sh:hasValue <https://w3id.org/martialis/vocab/source-ref-type> ]
        ] ;
        sh:message "SourceAssertionRelation must have a sourceRefType."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:evidenceWeight ;
        sh:class skos:Concept ;
        sh:node [
            sh:property [ sh:path skos:inScheme ; sh:hasValue <https://w3id.org/martialis/vocab/evidence-weight> ]
        ] ;
        sh:message "SourceAssertionRelation must have an evidenceWeight."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:sourceLocatorLabel ;
        sh:datatype xsd:string ;
        sh:message "sourceLocatorLabel must be a string."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path schema:archivedAt ;
        sh:nodeKind sh:IRI ;
        sh:maxCount 1 ;
        sh:message "archive_uri must be a valid IRI (Wayback Machine URL)."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:coversProperty ;
        sh:nodeKind sh:IRI ;
        sh:in ( schema:name schema:alternateName schema:nationality schema:location
                schema:address schema:description schema:image schema:logo schema:sameAs
                schema:url schema:startDate schema:organizer schema:about martialis:researchNote
                martialis:birthEvent martialis:deathEvent martialis:foundedEvent
                martialis:dissolvedEvent martialis:hasTeacherAssertion
                martialis:hasContextualTrainingAssertion
                martialis:hasRankAssertion martialis:hasHonorAssertion
                martialis:hasDocumentTransmissionAssertion martialis:hasIdentityAssertion
                martialis:hasStyleRelationAssertion martialis:hasAffiliationAssertion
                martialis:hasParticipation martialis:rankSystem martialis:styleType
                martialis:styleForm
                martialis:orgType martialis:placeType martialis:eventType
                martialis:sourceType martialis:parentPlace
                martialis:hasInstructorRole martialis:hasOfficerRole
                martialis:rankingSystemKind martialis:gradeScheme martialis:minOrdinal
                martialis:maxOrdinal martialis:confersRank martialis:derivedFromSystem ) ;
        sh:message "covers must name a real entity property (a closed set of predicate IRIs); an unrecognized token would otherwise resolve to a junk IRI under the @base entity namespace instead of a real predicate."
    ] .

# Confidence
#################################################################

shapes:ConfidenceShape
    a sh:NodeShape ;
    sh:severity sh:Violation ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Confidence"@en ;
    rdfs:comment "Confidence assessments: level within 0-3, assessedBy an agent IRI."@en ;
    sh:targetClass martialis:Confidence ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:confidenceLevel ;
        sh:maxCount 1 ;
        # Enum membership not checked here - martialis.schema.json's
        # Confidence.level already guarantees 0/1/2/3 (Disputed/
        # Uncertain/Probable/Confirmed).
        sh:message "Confidence must have at most one level."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:assessedBy ;
        sh:nodeKind sh:IRI ;
        sh:pattern "^https://martial\\.is/agent/" ;
        sh:message "assessedBy must be an agent IRI under https://martial.is/agent/ (see martialis:Agent)."
    ] .

# Source
#################################################################

shapes:SourceShape
    a sh:NodeShape ;
    sh:severity sh:Violation ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Source"@en ;
    rdfs:comment "Bibliographic constraints: CSL sourceType vocabulary, genre, peer-review and self-publication values, containment vs edition structure, identifiers."@en ;
    sh:targetClass martialis:Source ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path schema:name ;
        sh:minCount 1 ;
        sh:message "A Source must have a title."
    ] ;

    # sourceType and peerReview stay unchecked here deliberately, not
    # because JSON Schema already covers them (that reasoning no longer
    # applies generally post-migration - see the top-of-file policy note)
    # but because both are external-standard alignments under the SKOS
    # plan's governing rule and were excluded from the concept-IRI
    # migration: sourceType is the CSL-JSON type vocabulary verbatim
    # (ontology/vocabularies/source-classification.ttl, no per-value
    # concepts), and peerReview is a DRIVER/OpenAIRE value alignment
    # (ontology/vocabularies/peer-review.ttl) that has carried this same
    # "value alignment only, property stays a string" design since before
    # this migration. sourceGenre, below, IS migrated and IS checked.
    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:sourceType ;
        sh:maxCount 1 ;
        sh:message "A Source must have at most one classification."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:sourceGenre ;
        sh:maxCount 1 ;
        sh:class skos:Concept ;
        sh:node [
            sh:property [ sh:path skos:inScheme ; sh:hasValue <https://w3id.org/martialis/vocab/source-genre> ]
        ] ;
        sh:message "A Source must have at most one genre."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:peerReview ;
        sh:maxCount 1 ;
        sh:message "A Source must have at most one peer_review value."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:selfPublished ;
        sh:maxCount 1 ;
        sh:datatype xsd:boolean ;
        sh:message "self_published must be a boolean."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:partOf ;
        sh:maxCount 1 ;
        sh:class martialis:Source ;
        sh:message "part_of_id must reference an existing Source."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:editionOf ;
        sh:maxCount 1 ;
        sh:node shapes:EditionOfShape ;
        sh:message "edition_of must be a valid edition/translation/reprint/facsimile relation."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:hasContributor ;
        sh:node shapes:ContributorShape ;
        sh:message "Each contributor must have a valid role, and person_id/org_id (if present) must reference an existing entity."
    ] ;

    sh:property [ sh:path martialis:volume ; sh:maxCount 1 ] ;
    sh:property [ sh:path martialis:issue ; sh:maxCount 1 ] ;
    sh:property [ sh:path martialis:page ; sh:maxCount 1 ] ;
    sh:property [ sh:path martialis:edition ; sh:maxCount 1 ] ;
    sh:property [ sh:path martialis:chapterNumber ; sh:maxCount 1 ] ;
    sh:property [ sh:path martialis:partNumber ; sh:maxCount 1 ] ;
    sh:property [ sh:path martialis:section ; sh:maxCount 1 ] ;
    sh:property [ sh:path martialis:archive ; sh:maxCount 1 ] ;
    sh:property [ sh:path martialis:archiveCollection ; sh:maxCount 1 ] ;
    sh:property [ sh:path martialis:archiveLocation ; sh:maxCount 1 ] ;
    sh:property [ sh:path martialis:callNumber ; sh:maxCount 1 ] ;
    sh:property [ sh:path martialis:accessed ; sh:maxCount 1 ] ;
    sh:property [ sh:path martialis:archiveDate ; sh:maxCount 1 ] ;
    sh:property [ sh:path schema:datePublished ; sh:maxCount 1 ] ;
    sh:property [
        sh:severity sh:Violation ;
        sh:path schema:publisher ;
        sh:maxCount 1 ;
        sh:node shapes:PublisherShape ;
        sh:message "publisher must have a name, and org_id (if present) must reference an existing Organization."
    ] ;

    sh:sparql [
        a sh:SPARQLConstraint ;
        sh:name "source part-of cycle" ;
        sh:message "part_of must not form a cycle - a Source cannot, directly or transitively, be part of itself." ;
        sh:select """
            PREFIX martialis: <https://w3id.org/martialis/ontology#>
            SELECT $this
            WHERE {
                $this martialis:partOf+ $this .
            }
        """
    ] .

shapes:EditionOfShape
    a sh:NodeShape ;
    sh:severity sh:Violation ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Edition relation"@en ;
    rdfs:comment "Edition nodes: editionOfSource required, closed editionRelationType vocabulary."@en ;
    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:editionOfSource ;
        sh:minCount 1 ;
        sh:maxCount 1 ;
        sh:class martialis:Source ;
        sh:message "edition_of.source_id must reference an existing Source."
    ] ;
    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:editionRelationType ;
        sh:minCount 1 ;
        sh:maxCount 1 ;
        sh:class skos:Concept ;
        sh:node [
            sh:property [ sh:path skos:inScheme ; sh:hasValue <https://w3id.org/martialis/vocab/edition-relation> ]
        ] ;
        sh:message "edition_of must have exactly one relation."
    ] .

shapes:PublisherShape
    a sh:NodeShape ;
    sh:severity sh:Violation ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Publisher"@en ;
    rdfs:comment "Publisher nodes: a name or a publisherOrg reference."@en ;
    sh:property [
        sh:severity sh:Violation ;
        sh:path schema:name ;
        sh:minCount 1 ;
        sh:maxCount 1 ;
        sh:message "publisher.name is required."
    ] ;
    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:publisherOrg ;
        sh:maxCount 1 ;
        sh:class martialis:Organization ;
        sh:message "publisher.org_id must reference an existing Organization."
    ] .

shapes:ContributorShape
    a sh:NodeShape ;
    sh:severity sh:Violation ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Contributor"@en ;
    rdfs:comment "Contributor nodes: CSL contributor role; person/org cross-references."@en ;
    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:contributorRole ;
        sh:minCount 1 ;
        sh:maxCount 1 ;
        # Enum membership not checked here - source.schema.json's
        # Contributor.role already guarantees the same 26 CSL values.
        sh:message "A contributor must have exactly one role."
    ] ;
    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:contributorPerson ;
        sh:maxCount 1 ;
        sh:class martialis:Person ;
        sh:message "contributor person_id must reference an existing Person."
    ] ;
    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:contributorOrg ;
        sh:maxCount 1 ;
        sh:class martialis:Organization ;
        sh:message "contributor org_id must reference an existing Organization."
    ] .

# RankSystem, and the two properties left unchecked at the end of the
# SKOS vocabulary migration (rankSystemType, dateCalendar, linkType -
# added here as a deliberate final pass, not overlooked the first time).
#################################################################

shapes:RankingSystemShape
    a sh:NodeShape ;
    sh:severity sh:Violation ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "RankingSystem"@en ;
    rdfs:comment "RankingSystem is a first-class, lightly-sourced entity (database/ranking-systems/), the same shape as Style/Organization/Place - name and kind required; grade_scheme required only when kind is 'enumerated'."@en ;
    sh:targetClass martialis:RankingSystem ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path schema:name ;
        sh:minCount 1 ;
        sh:message "A RankingSystem must have at least one name."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:foundedEvent ;
        sh:maxCount 1 ;
        sh:class martialis:LifeEvent ;
        sh:message "instituted must be a LifeEvent node."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:hasEvidence ;
        sh:class martialis:SourceAssertionRelation ;
        sh:message "sources must be expressed as evidence records."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:derivedFromSystem ;
        sh:maxCount 1 ;
        sh:class martialis:RankingSystem ;
        sh:message "derived_from_id must reference an existing RankingSystem entity."
    ] ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:rankingSystemKind ;
        sh:minCount 1 ;
        sh:maxCount 1 ;
        sh:in ( "numbered" "enumerated" ) ;
        sh:message "kind must be 'numbered' or 'enumerated'."
    ] ;

    sh:or (
        [ sh:property [ sh:path martialis:rankingSystemKind ; sh:hasValue "numbered" ] ]
        [ sh:property [ sh:path martialis:gradeScheme ; sh:minCount 1 ] ]
    ) ;
    sh:message "An 'enumerated' RankingSystem must declare its grade_scheme." .

# dateCalendar and linkType cut across several unrelated node types
# (dateCalendar: LifeEvent, RankAssertion, HonorAssertion, Source;
# linkType: an arbitrary external resource, the object of schema:sameAs,
# never a Martialis-typed node at all - see schemas/context.jsonld's
# links term, "uri": "@id") - sh:targetClass cannot express either, so
# both shapes target every SUBJECT of the property instead, wherever in
# the graph it occurs.

shapes:DateCalendarShape
    a sh:NodeShape ;
    sh:severity sh:Violation ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "date calendar usage"@en ;
    rdfs:comment "Any node carrying martialis:dateCalendar (LifeEvent, RankAssertion, HonorAssertion, Source): concept-scheme membership."@en ;
    sh:targetSubjectsOf martialis:dateCalendar ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:dateCalendar ;
        sh:maxCount 1 ;
        sh:class skos:Concept ;
        sh:node [
            sh:property [ sh:path skos:inScheme ; sh:hasValue <https://w3id.org/martialis/vocab/date-calendar> ]
        ] ;
        sh:message "date_calendar must be a date-calendar concept."
    ] .

shapes:LinkTypeShape
    a sh:NodeShape ;
    sh:severity sh:Violation ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "link type usage"@en ;
    rdfs:comment "Any node carrying martialis:linkType (the external resource a links[] entry names, schema:sameAs's object - never a Martialis-typed node): concept-scheme membership."@en ;
    sh:targetSubjectsOf martialis:linkType ;

    sh:property [
        sh:severity sh:Violation ;
        sh:path martialis:linkType ;
        sh:maxCount 1 ;
        sh:class skos:Concept ;
        sh:node [
            sh:property [ sh:path skos:inScheme ; sh:hasValue <https://w3id.org/martialis/vocab/link-type> ]
        ] ;
        sh:message "links[].type must be a link-type concept."
    ] .

# Locator range ordering
#################################################################
# The FORMAT of a locator is JSON Schema's job (SourceRef's allOf
# requires a numeric-label locator to be a number, a dotted number, or an
# N-M range, and a timestamp label to look like a clock time). What a
# regex cannot express is arithmetic: "42-17" is well-formed and wrong.
#
# Targeted by sh:targetSubjectsOf rather than a class, because a locator
# can sit on any evidence-shaped node - including photo/video attribution,
# which is deliberately never typed SourceAssertionRelation.
#
# Dotted forms are excluded on both sides on purpose: "1.2-1.10" is not
# numerically comparable as a whole, so ordering is not asserted for it.

shapes:LocatorRangeShape
    a sh:NodeShape ;
    sh:severity sh:Violation ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Locator range ordering"@en ;
    rdfs:comment "A numeric-label locator written as a range must ascend."@en ;
    sh:targetSubjectsOf martialis:sourceLocation ;
    sh:sparql [
        a sh:SPARQLConstraint ;
        sh:name "locator range not ascending" ;
        sh:message "A numeric locator range must ascend - this one runs backwards (e.g. page 42-17)." ;
        sh:select """
            PREFIX martialis: <https://w3id.org/martialis/ontology#>
            PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
            SELECT $this
            WHERE {
                $this martialis:sourceLocation ?loc ;
                      martialis:sourceLocatorLabel ?label .
                FILTER(?label IN ("page", "volume", "issue", "line", "paragraph",
                                  "folio", "note", "table", "figure", "verse",
                                  "section", "equation", "opus", "act", "column",
                                  "elocation", "supplement"))
                FILTER(REGEX(STR(?loc), "^\\\\s*[0-9]+\\\\s*-\\\\s*[0-9]+\\\\s*$"))
                BIND(xsd:integer(REPLACE(STR(?loc), "^\\\\s*([0-9]+)\\\\s*-.*$", "$1")) AS ?from)
                BIND(xsd:integer(REPLACE(STR(?loc), "^.*-\\\\s*([0-9]+)\\\\s*$", "$1")) AS ?to)
                FILTER(?from > ?to)
            }
        """
    ] .


### Extra SHACL
###
### Quality and completeness validation for the Martialis database
###
### Frederico Munoz <fsmunoz@gmail.com>, Martialis Project
###
### Licence: CC0-1.0 (public domain dedication)
### Attribution requested but not required: cite "Martialis — https://martial.is"
###
### These shapes generate warnings for incomplete data that should
### be investigated and improved, but don't block publication.
###
### Since ontology 2.3.0 the JSON Schemas REQUIRE at least one source
### (minItems 1) on every assertion-shaped node: rank, honor,
### learned_from, possibly_same_as, related_styles, affiliations,
### instructors, officers, event participations, and every teacher
### interaction whose teacher entry carries a real teacher_id (the
### unknown-teacher placeholder, teacher_id: null, is dropped at RDF
### export and stays exempt). The "has no sources" Warning checks below
### can therefore no longer fire on schema-valid data; they are
### retained as a belt-and-braces check for hand-built RDF that never
### passed through the schemas.
###
### Per-source-ref field completeness (type, weight, quotation,
### location, citation) is intentionally NOT checked here: it is
### aggregate information, reported as percentages by the provenance
### and dashboard subcommands of bin/martialis, where it does not
### drown the per-item findings below.

@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
@prefix martialis: <https://w3id.org/martialis/ontology#> .
@prefix shapes: <https://w3id.org/martialis/ontology/shapes#> .
@prefix schema: <https://schema.org/> .
@prefix prov: <http://www.w3.org/ns/prov#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix owl: <http://www.w3.org/2002/07/owl#> .
@prefix dcterms: <http://purl.org/dc/terms/> .
@prefix vann: <http://purl.org/vocab/vann/> .

# Shapes graph self-description
#################################################################
# Declared for documentation generators (pyLODE valpub) and for
# self-description; annotation triples here do not affect validation.

<https://w3id.org/martialis/ontology/shapes/quality> a owl:Ontology , sh:ShapesGraph ;
    dcterms:title "Martialis Quality and Completeness Constraints"@en ;
    dcterms:description "Quality and completeness validation for the Martialis database. These shapes generate warnings for incomplete data that should be investigated and improved, but do not block publication - the structural tier (martialis.shacl.ttl) is the blocking gate. Per-source-ref field completeness is intentionally not checked here: it is aggregate information, reported by the provenance and dashboard subcommands of bin/martialis."@en ;
    dcterms:license <https://creativecommons.org/publicdomain/zero/1.0/> ;
    dcterms:created "2026-02-28"^^xsd:date ;
    dcterms:modified "2026-09-07"^^xsd:date ;
    owl:versionInfo "1.2.0" ;
    owl:imports <https://w3id.org/martialis/ontology> ;
    vann:preferredNamespacePrefix "shapes" ;
    vann:preferredNamespaceUri <https://w3id.org/martialis/ontology/shapes#> ;
    rdfs:seeAlso <https://w3id.org/martialis/ontology#MartialisOntology> .

# Person Completeness
#################################################################

shapes:PersonCompletenessShape
    a sh:NodeShape ;
    sh:severity sh:Warning ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Person completeness"@en ;
    rdfs:comment "Warns on Persons missing a birth date or description; birthEvent existence itself is schema-guaranteed."@en ;
    sh:targetClass martialis:Person ;

    # Either a named teacher or a contextual training claim satisfies
    # this check - a Person with only teacher_id: null entries (no
    # identified teacher, but a sourced place/event/period claim) is not
    # missing lineage information the way a Person with neither has.
    sh:or (
        [ sh:property [ sh:path martialis:hasTeacherAssertion ; sh:minCount 1 ] ]
        [ sh:property [ sh:path martialis:hasContextualTrainingAssertion ; sh:minCount 1 ] ]
    ) ;
    sh:message "Quality check: Person has no teachers or contextual training - needs investigation." ;

    sh:property [
        sh:path martialis:hasRankAssertion ;
        sh:minCount 1 ;
        sh:severity sh:Info ;
        sh:message "Quality check: Person has no rank information - consider adding if available."
    ] ;

    sh:property [
        sh:path (martialis:birthEvent schema:birthDate) ;
        sh:minCount 1 ;
        sh:severity sh:Info ;
        sh:message "Quality check: Person has no birth date - consider adding if available."
    ] .

# Note: birthEvent's mere existence is not checked here (nor was an
# analogous "has TeacherAssertion.object" check kept) - JSON Schema's
# own `required` already guarantees the node/property exists wherever
# the schema demands it, so a SHACL minCount on the same predicate can
# never fire and would be dead weight. This shape instead follows the
# sequence path through to schema:birthDate, the field that actually
# varies (a Person can have `birth: {date: null}` - a birth node with
# no date - which the old node-presence check read as "complete").

# Signal, not a violation: co-teaching, posthumous transmission via
# students, and simple recording errors can all produce this pattern,
# so it needs a human look rather than blocking. A standalone shape
# because sh:severity on the shape is what pyshacl actually honors for
# a SPARQLConstraint - a severity declared inside sh:sparql itself is
# silently ignored and falls back to sh:Violation (confirmed
# empirically), and a shared NodeShape would wrongly apply this
# severity to its sibling property constraints too.
shapes:TeacherAgeQualityShape
    a sh:NodeShape ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Teacher age plausibility"@en ;
    rdfs:comment "Signals teacher-student interactions whose dates imply an implausible age difference."@en ;
    sh:targetClass martialis:TeacherAssertion ;
    sh:severity sh:Info ;
    sh:sparql [
        a sh:SPARQLConstraint ;
        sh:name "teacher born after student" ;
        sh:message "Quality check: the teacher was born after the student - worth double-checking." ;
        sh:select """
            PREFIX martialis: <https://w3id.org/martialis/ontology#>
            PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
            SELECT $this
            WHERE {
                $this martialis:subject ?student ; martialis:object ?teacher .
                ?student martialis:birthEvent/martialis:latestYear ?studentLatest .
                ?teacher martialis:birthEvent/martialis:earliestYear ?teacherEarliest .
                FILTER(xsd:integer(STR(?teacherEarliest)) > xsd:integer(STR(?studentLatest)))
            }
        """
    ] .

# TeacherInteraction Completeness
#################################################################

shapes:TeacherInteractionCompletenessShape
    a sh:NodeShape ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "TeacherInteraction completeness"@en ;
    rdfs:comment "Warns when interactions lack a place or a period."@en ;
    sh:targetClass martialis:TeacherInteraction ;

    sh:property [
        sh:path martialis:hasEvidence ;
        sh:minCount 1 ;
        sh:severity sh:Warning ;
        sh:message "Quality check: TeacherInteraction has no sources - needs investigation and sourcing."
    ] ;

    sh:property [
        sh:path martialis:interactionPeriod ;
        sh:minCount 1 ;
        sh:severity sh:Info ;
        sh:message "Quality check: TeacherInteraction has no time period - consider adding if available."
    ] .

# See the note on TeacherAgeQualityShape above for why this is a
# separate shape rather than a third constraint on
# TeacherInteractionCompletenessShape.
shapes:InteractionPeriodQualityShape
    a sh:NodeShape ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Interaction period plausibility"@en ;
    rdfs:comment "Signals interaction periods inconsistent with the student's dates."@en ;
    sh:targetClass martialis:TeacherInteraction ;
    sh:severity sh:Info ;
    sh:sparql [
        a sh:SPARQLConstraint ;
        sh:name "interaction outside lifespan" ;
        sh:message "Quality check: interaction period falls outside the student's or teacher's lifespan - worth double-checking." ;
        # NB: the FILTERs must sit at TOP level, outside the UNION
        # groups - rdflib (this pipeline's SPARQL engine) fails to
        # match a FILTER placed inside a group when it references a
        # variable bound in the enclosing scope, which silently made
        # this constraint fire on nothing (caught by the
        # tests/shacl-fixtures/ regression gate).
        sh:select """
            PREFIX martialis: <https://w3id.org/martialis/ontology#>
            PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
            SELECT $this
            WHERE {
                ?assertion martialis:hasInteraction $this ;
                           martialis:subject ?student ;
                           martialis:object ?teacher .
                $this martialis:interactionPeriod ?period .
                ?period martialis:startYear ?start .
                {
                    { ?student martialis:birthEvent/martialis:earliestYear ?bound .
                      BIND("lower" AS ?kind) }
                    UNION
                    { ?student martialis:deathEvent/martialis:latestYear ?bound .
                      BIND("upper" AS ?kind) }
                    UNION
                    { ?teacher martialis:birthEvent/martialis:earliestYear ?bound .
                      BIND("lower" AS ?kind) }
                    UNION
                    { ?teacher martialis:deathEvent/martialis:latestYear ?bound .
                      BIND("upper" AS ?kind) }
                }
                FILTER ( (?kind = "lower" && xsd:integer(STR(?start)) < xsd:integer(STR(?bound)))
                      || (?kind = "upper" && xsd:integer(STR(?start)) > xsd:integer(STR(?bound))) )
            }
        """
    ] .

# A teacher relationship is one of the most load-bearing claims in the
# database (it materializes into the lineage tree); "has a source" isn't
# enough on its own if none of those sources actually quote the claim.
# Separate shape for the same severity-declaration reason noted on
# TeacherAgeQualityShape above.
shapes:TeacherInteractionQuotationQualityShape
    a sh:NodeShape ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Teacher evidence quotation"@en ;
    rdfs:comment "Warns when a teacher claim's sources carry no quotation - sourced only in paraphrase."@en ;
    sh:targetClass martialis:TeacherInteraction ;
    sh:severity sh:Warning ;
    sh:sparql [
        a sh:SPARQLConstraint ;
        sh:name "interaction missing quotation" ;
        sh:message "Quality check: TeacherInteraction has sources, but none carry a quotation - a direct quote substantiating the relationship is expected." ;
        sh:select """
            PREFIX martialis: <https://w3id.org/martialis/ontology#>
            SELECT $this
            WHERE {
                $this martialis:hasEvidence ?e .
                FILTER NOT EXISTS { $this martialis:hasEvidence/martialis:quotation ?q }
            }
        """
    ] .

# Softer than the quotation check above (Info, not Warning): citing
# Wikipedia isn't wrong, but a teacher relationship resting SOLELY on
# Wikipedia risks circularity - community lineage lore often circulates
# back through Wikipedia itself. Conservatively requires every evidence
# entry to have a resolvable wikipedia.org URL (its own inline uri, or
# its basedOnSource's url) before firing - an entry with no URL at all
# (e.g. an offline book citation) correctly suppresses the flag rather
# than being silently ignored, avoiding false positives on entries that
# already have real non-Wikipedia corroboration.
shapes:TeacherInteractionWikipediaOnlyQualityShape
    a sh:NodeShape ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Wikipedia-only teacher evidence"@en ;
    rdfs:comment "Info: teacher claims whose every source is Wikipedia - circularity risk."@en ;
    sh:targetClass martialis:TeacherInteraction ;
    sh:severity sh:Info ;
    sh:sparql [
        a sh:SPARQLConstraint ;
        sh:name "interaction wikipedia-only sources" ;
        sh:message "Quality check: every source on this TeacherInteraction is a Wikipedia article - consider adding a non-Wikipedia corroborating source to avoid circularity." ;
        sh:select """
            PREFIX martialis: <https://w3id.org/martialis/ontology#>
            PREFIX schema: <https://schema.org/>
            SELECT $this
            WHERE {
                $this martialis:hasEvidence ?e .
                FILTER NOT EXISTS {
                    $this martialis:hasEvidence ?e2 .
                    FILTER NOT EXISTS {
                        { ?e2 martialis:basedOnSource/schema:url ?u2 } UNION { ?e2 schema:url ?u2 }
                        FILTER(CONTAINS(STR(?u2), "wikipedia.org"))
                    }
                }
            }
        """
    ] .

# ContextualTrainingAssertion Completeness
#################################################################
# The self-improving signal this class exists for: a contextual claim
# names a venue and a window but not a teacher, and a Place's
# InstructorRole roster is exactly the place that a candidate might
# already be recorded, or where it is worth investigating. Only fires
# for place-anchored interactions - an event-anchored claim's
# candidates are the event's own instructor participations, not this
# join, and are a query for the consuming application rather than a
# curation check.
shapes:ContextualTrainingAssertionCompletenessShape
    a sh:NodeShape ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Contextual training candidate instructors"@en ;
    rdfs:comment "Info: a contextual training claim's venue has no InstructorRole overlapping its period - no candidate instructors are recorded for this venue and window."@en ;
    sh:targetClass martialis:ContextualTrainingAssertion ;
    sh:severity sh:Info ;
    sh:sparql [
        a sh:SPARQLConstraint ;
        sh:name "contextual training no candidate instructors" ;
        sh:message "Quality check: no InstructorRole on this venue overlaps the claimed period - no candidate instructors are recorded for this venue and window; worth researching." ;
        sh:select """
            PREFIX martialis: <https://w3id.org/martialis/ontology#>
            PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
            SELECT $this
            WHERE {
                $this martialis:hasInteraction ?i .
                ?i martialis:interactionPlace ?place ;
                   martialis:interactionPeriod ?period .
                ?period martialis:startYear ?start .
                FILTER NOT EXISTS {
                    ?place martialis:hasInstructorRole ?role .
                    ?role martialis:instructorPeriod ?rperiod .
                    ?rperiod martialis:startYear ?rstart .
                    OPTIONAL { ?rperiod martialis:endYear ?rend }
                    FILTER(xsd:integer(STR(?rstart)) <= xsd:integer(STR(?start))
                           && (!BOUND(?rend) || xsd:integer(STR(?rend)) >= xsd:integer(STR(?start))))
                }
            }
        """
    ] .

# Participation Style Attribution
#################################################################

# A multi-style course/seminar's event-level style_ids are an unattributed
# set: they cannot say which instructor taught which art. The roster
# sources usually do name it ("Judo instruction was provided by Kotani,
# Otaki..."), so the per-participation martialis:participationStyle is the
# only place that mapping can live, and without it candidate-instructor
# matches against a student's contextual-training style_id degrade to
# "any instructor at this event, any style". This Info points at exactly
# the participations worth annotating - the same self-improving pattern
# as ContextualTrainingAssertionCompletenessShape above.
shapes:ParticipationStyleAttributionShape
    a sh:NodeShape ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Participation style attribution"@en ;
    rdfs:comment "Info: a multi-style course/seminar whose instructor participations lack style annotations."@en ;
    sh:targetClass martialis:Event ;
    sh:severity sh:Info ;
    sh:sparql [
        a sh:SPARQLConstraint ;
        sh:name "instructor participation missing style" ;
        sh:message "Quality check: this course/seminar covers multiple styles but an instructor participation carries no style_ids - annotate which art that instructor taught here so candidate matches are attributable." ;
        sh:select """
            PREFIX martialis: <https://w3id.org/martialis/ontology#>
            PREFIX schema: <https://schema.org/>
            PREFIX eventclass: <https://w3id.org/martialis/vocab/event-classification/>
            PREFIX eventrole: <https://w3id.org/martialis/vocab/event-role/>
            SELECT $this
            WHERE {
                $this a martialis:Event ;
                       martialis:eventType ?type ;
                       schema:about ?s1 , ?s2 ;
                       martialis:hasParticipation ?p .
                ?p martialis:participationRole eventrole:instructor .
                FILTER (?type IN (eventclass:course, eventclass:seminar))
                FILTER (?s1 != ?s2)
                FILTER NOT EXISTS { ?p martialis:participationStyle ?st }
            }
        """
    ] .

# Document Transmission Author Link
#################################################################

# learned_from (DocumentTransmissionAssertion) is reader -> document; the
# reader -> author relation the webapp materializes is derived by joining
# the document's contributors[].person_id, filtered to the authorship
# roles (author, original-author, container-author - contributorRole is
# CSL's 26-value vocabulary, and a translator's or interviewer's
# person_id does not make them the work's author). A named authorship
# contributor without that link is exactly where the derivation stops
# short - this Info points at it, the same self-improving pattern as the
# two shapes above.
shapes:DocumentTransmissionAuthorLinkShape
    a sh:NodeShape ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Document transmission author link"@en ;
    rdfs:comment "Info: a document-transmission claim's document names an authorship-role contributor who is not linked to a Person record."@en ;
    sh:targetClass martialis:DocumentTransmissionAssertion ;
    sh:severity sh:Info ;
    sh:sparql [
        a sh:SPARQLConstraint ;
        sh:name "transmission document author unlinked" ;
        sh:message "Quality check: this learned_from document's author is named but not linked to a Person - add contributors[].person_id when the author is a Person record (this is how the reader-to-author relation materializes)." ;
        sh:select """
            PREFIX martialis: <https://w3id.org/martialis/ontology#>
            PREFIX schema: <https://schema.org/>
            SELECT $this
            WHERE {
                $this martialis:transmissionDocument ?doc .
                ?doc martialis:hasContributor ?c .
                ?c martialis:contributorRole ?role .
                FILTER (?role IN ("author", "original-author", "container-author"))
                FILTER EXISTS {
                    { ?c schema:familyName ?f } UNION
                    { ?c schema:givenName ?g } UNION
                    { ?c schema:name ?n }
                }
                FILTER NOT EXISTS { ?c martialis:contributorPerson ?p }
            }
        """
    ] .

# Date Precision & Calendar Provenance
#################################################################
# Both non-blocking: an editorial nudge, not a data error. See
# ontology/martialis.ttl's "Date precision & calendar provenance"
# section for what dateOriginal/dateCalendar/earliestYear/latestYear
# mean and why they carry no single rdfs:domain.

# Uses sh:targetSubjectsOf rather than duplicating this check once per
# Date-bearing class (LifeEvent, RankAssertion, HonorAssertion,
# foundedEvent/dissolvedEvent, Source): every node that has a
# dateCalendar/issued_calendar value at all is in scope, regardless of
# which class it belongs to.
shapes:DateCalendarCompletenessShape
    a sh:NodeShape ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Date calendar provenance"@en ;
    rdfs:comment "Info: dateCalendar recorded without the paired dateOriginal."@en ;
    sh:targetSubjectsOf martialis:dateCalendar ;
    sh:property [
        sh:path martialis:dateOriginal ;
        sh:minCount 1 ;
        sh:severity sh:Info ;
        sh:message "Quality check: date_calendar is set without date_original - the source's own rendering should usually be recorded alongside the calendar classification."
    ] .

# A crude, English-only heuristic, restricted to RankAssertion/
# HonorAssertion (the only classes where a date sits alongside a
# confidence.rationale free-text field): flags prose uncertainty signals
# when the date itself carries none of EDTF's own uncertainty markers, as
# a nudge to encode the uncertainty structurally (~ ? % an interval or a
# Level 2 set) instead of leaving it only in prose. Expected to have
# false negatives (it can't catch every way uncertainty gets phrased) and
# occasional false positives (rationale text can mention "circa" about
# something other than this exact date) - a signal for a curator to look
# at, not a fact being asserted.
shapes:DatePrecisionQualityShape
    a sh:NodeShape ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Date precision"@en ;
    rdfs:comment "Nudges prose uncertainty in confidence rationales toward structural EDTF uncertainty markers."@en ;
    sh:targetClass martialis:RankAssertion, martialis:HonorAssertion ;
    sh:severity sh:Info ;
    sh:sparql [
        a sh:SPARQLConstraint ;
        sh:name "date uncertainty unencoded" ;
        sh:message "Quality check: confidence.rationale suggests date uncertainty, but the date field itself carries no EDTF uncertainty marker - consider encoding it structurally (~ ? % an interval, or a Level 2 set) instead of only in prose." ;
        sh:select """
            PREFIX martialis: <https://w3id.org/martialis/ontology#>
            PREFIX schema: <https://schema.org/>
            SELECT $this
            WHERE {
                $this schema:datePublished ?date ;
                      martialis:hasConfidence ?conf .
                ?conf martialis:confidenceRationale ?rationale .
                FILTER(!CONTAINS(STR(?date), "~") && !CONTAINS(STR(?date), "?")
                       && !CONTAINS(STR(?date), "%") && !CONTAINS(STR(?date), "/")
                       && !CONTAINS(STR(?date), "["))
                FILTER(REGEX(STR(?rationale), "circa|approx|conflicting date|disput|uncertain date|probably (in|around)|before [0-9]{4}", "i"))
            }
        """
    ] .

# RankAssertion Completeness
#################################################################
# Teacher evidence lives on TeacherInteraction nodes (checked above);
# rank, honor, and style-relation evidence lives on the assertion.

# rankSystem, object (style), and hasConfidence are not checked here -
# each is already `required` in person.schema.json's Rank/Confidence
# definitions, so a SHACL minCount on the same predicate can never
# fire. Only hasEvidence (not schema-required - a Rank can validly
# have interactions instead) and grantedBy (never required, genuinely
# often unknown) carry real curatorial signal.
shapes:RankAssertionCompletenessShape
    a sh:NodeShape ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Rank assertion completeness"@en ;
    rdfs:comment "Warns on rank claims without evidence; Info on missing grantedBy."@en ;
    sh:targetClass martialis:RankAssertion ;

    sh:property [
        sh:path martialis:hasEvidence ;
        sh:minCount 1 ;
        sh:severity sh:Warning ;
        sh:message "Quality check: RankAssertion has no sources - needs investigation and sourcing."
    ] ;

    sh:property [
        sh:path martialis:grantedBy ;
        sh:minCount 1 ;
        sh:severity sh:Info ;
        sh:message "Quality check: RankAssertion has no grantedBy - consider adding source if available."
    ] .

# See TeacherInteractionQuotationQualityShape - same rationale, applied
# to rank grants: a rank claim with a source but no quotation isn't
# meaningfully sourced yet.
shapes:RankAssertionQuotationQualityShape
    a sh:NodeShape ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Rank evidence quotation"@en ;
    rdfs:comment "Warns when a rank grant's sources carry no quotation."@en ;
    sh:targetClass martialis:RankAssertion ;
    sh:severity sh:Warning ;
    sh:sparql [
        a sh:SPARQLConstraint ;
        sh:name "rank missing quotation" ;
        sh:message "Quality check: RankAssertion has sources, but none carry a quotation - a direct quote substantiating the grant is expected." ;
        sh:select """
            PREFIX martialis: <https://w3id.org/martialis/ontology#>
            SELECT $this
            WHERE {
                $this martialis:hasEvidence ?e .
                FILTER NOT EXISTS { $this martialis:hasEvidence/martialis:quotation ?q }
            }
        """
    ] .

# See TeacherInteractionWikipediaOnlyQualityShape - same rationale and
# same conservative "every entry must have a resolvable wikipedia.org
# URL" condition, applied to rank grants.
shapes:RankAssertionWikipediaOnlyQualityShape
    a sh:NodeShape ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Wikipedia-only rank evidence"@en ;
    rdfs:comment "Info: rank claims sourced solely from Wikipedia."@en ;
    sh:targetClass martialis:RankAssertion ;
    sh:severity sh:Info ;
    sh:sparql [
        a sh:SPARQLConstraint ;
        sh:name "rank wikipedia-only sources" ;
        sh:message "Quality check: every source on this RankAssertion is a Wikipedia article - consider adding a non-Wikipedia corroborating source to avoid circularity." ;
        sh:select """
            PREFIX martialis: <https://w3id.org/martialis/ontology#>
            PREFIX schema: <https://schema.org/>
            SELECT $this
            WHERE {
                $this martialis:hasEvidence ?e .
                FILTER NOT EXISTS {
                    $this martialis:hasEvidence ?e2 .
                    FILTER NOT EXISTS {
                        { ?e2 martialis:basedOnSource/schema:url ?u2 } UNION { ?e2 schema:url ?u2 }
                        FILTER(CONTAINS(STR(?u2), "wikipedia.org"))
                    }
                }
            }
        """
    ] .

# A rank that records neither direction (rankType), number
# (rankOrdinal), nor named grade (rankGrade) is a documented-but-
# unshaped claim: the schema permits it (a source may only say
# "black belt" with no specific degree), so this is a completion
# nudge, not a structural defect.
shapes:RankDirectionQualityShape
    a sh:NodeShape ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Rank direction/grade completeness"@en ;
    rdfs:comment "Info: ranks with neither rank_type, rank_ordinal nor rank_grade."@en ;
    sh:targetClass martialis:RankAssertion ;
    sh:severity sh:Info ;
    sh:sparql [
        a sh:SPARQLConstraint ;
        sh:name "rank missing direction or grade" ;
        sh:message "Quality check: rank records neither rank_type, rank_ordinal nor rank_grade - consider adding the documented direction or grade if the source states one." ;
        sh:select """
            PREFIX martialis: <https://w3id.org/martialis/ontology#>
            SELECT $this
            WHERE {
                $this a martialis:RankAssertion .
                FILTER NOT EXISTS { $this martialis:rankType ?t }
                FILTER NOT EXISTS { $this martialis:rankOrdinal ?o }
                FILTER NOT EXISTS { $this martialis:rankGrade ?g }
            }
        """
    ] .

# HonorAssertion Completeness
#################################################################

# hasConfidence is not checked here - already `required` in
# person.schema.json's Honor definition, so it can never fire.
shapes:HonorAssertionCompletenessShape
    a sh:NodeShape ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Honor assertion completeness"@en ;
    rdfs:comment "Warns on honors without evidence."@en ;
    sh:targetClass martialis:HonorAssertion ;

    sh:property [
        sh:path martialis:hasEvidence ;
        sh:minCount 1 ;
        sh:severity sh:Warning ;
        sh:message "Quality check: HonorAssertion has no sources - needs investigation and sourcing."
    ] .

# StyleRelationAssertion Completeness
#################################################################

shapes:StyleRelationAssertionCompletenessShape
    a sh:NodeShape ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Style relation completeness"@en ;
    rdfs:comment "Warns on style relations without evidence."@en ;
    sh:targetClass martialis:StyleRelationAssertion ;

    sh:property [
        sh:path martialis:hasEvidence ;
        sh:minCount 1 ;
        sh:severity sh:Warning ;
        sh:message "Quality check: style relation has no sources - needs investigation and sourcing."
    ] .

# DocumentTransmissionAssertion Completeness
#################################################################

shapes:DocumentTransmissionAssertionCompletenessShape
    a sh:NodeShape ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Document transmission completeness"@en ;
    rdfs:comment "Warns on document-learning claims without evidence of the person using the document."@en ;
    sh:targetClass martialis:DocumentTransmissionAssertion ;

    sh:property [
        sh:path martialis:hasEvidence ;
        sh:minCount 1 ;
        sh:severity sh:Warning ;
        sh:message "Quality check: document transmission has no evidence that the person used the document - needs sourcing."
    ] .

# IdentityAssertion Completeness
#################################################################

shapes:IdentityAssertionCompletenessShape
    a sh:NodeShape ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Identity assertion completeness"@en ;
    rdfs:comment "Warns on identity candidates without evidence."@en ;
    sh:targetClass martialis:IdentityAssertion ;

    sh:property [
        sh:path martialis:hasEvidence ;
        sh:minCount 1 ;
        sh:severity sh:Warning ;
        sh:message "Quality check: identity candidate has no sources - needs investigation and sourcing."
    ] .

# Place Completeness
#################################################################

shapes:PlaceCompletenessShape
    a sh:NodeShape ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Place completeness"@en ;
    rdfs:comment "Info: venues with no organizational affiliation or founding information."@en ;
    sh:targetClass martialis:Place ;

    sh:property [
        sh:path martialis:hasAffiliationAssertion ;
        sh:minCount 1 ;
        sh:severity sh:Info ;
        sh:message "Quality check: Place has no organizational affiliation - consider adding if available."
    ] ;

    sh:property [
        sh:path martialis:foundedEvent ;
        sh:minCount 1 ;
        sh:severity sh:Info ;
        sh:message "Quality check: Place has no founding information - consider adding if available."
    ] .

# InstructorRole Completeness
#################################################################

# instructorPeriod is not checked here - already `required` in
# martialis.schema.json's InstructorRole definition, so it can never fire.
shapes:InstructorRoleCompletenessShape
    a sh:NodeShape ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Instructor role completeness"@en ;
    rdfs:comment "Warns on venue-staff records without sources."@en ;
    sh:targetClass martialis:InstructorRole ;

    sh:property [
        sh:path martialis:hasEvidence ;
        sh:minCount 1 ;
        sh:severity sh:Warning ;
        sh:message "Quality check: instructor role has no sources - needs investigation and sourcing."
    ] .

# OfficerRole Completeness
#################################################################

# officerPeriod is not checked here - already `required` in
# martialis.schema.json's OfficerRole definition, so it can never fire.
shapes:OfficerRoleCompletenessShape
    a sh:NodeShape ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Officer role completeness"@en ;
    rdfs:comment "Warns on officer records without sources."@en ;
    sh:targetClass martialis:OfficerRole ;

    sh:property [
        sh:path martialis:hasEvidence ;
        sh:minCount 1 ;
        sh:severity sh:Warning ;
        sh:message "Quality check: officer role has no sources - needs investigation and sourcing."
    ] .

# AffiliationAssertion Completeness
#################################################################
# Unlike InstructorRole, an AffiliationAssertion had NO completeness
# check at all before this: a sourceless, periodless affiliation
# (just an org_id and a relation) validated clean at both structural
# and quality tiers. Mirrors InstructorRoleCompletenessShape above.

shapes:AffiliationAssertionCompletenessShape
    a sh:NodeShape ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Affiliation completeness"@en ;
    rdfs:comment "Warns on sourceless affiliations; Info on missing period."@en ;
    sh:targetClass martialis:AffiliationAssertion ;

    sh:property [
        sh:path martialis:hasEvidence ;
        sh:minCount 1 ;
        sh:severity sh:Warning ;
        sh:message "Quality check: affiliation has no sources - needs investigation and sourcing."
    ] ;

    sh:property [
        sh:path martialis:validDuring ;
        sh:minCount 1 ;
        sh:severity sh:Info ;
        sh:message "Quality check: affiliation has no period - consider adding if available."
    ] .
# (validDuring, when present, always carries a startYear - Period.start
# is schema-required - so the check above already covers a genealogy
# affiliation missing the year the ChangeEvent materialization keys on;
# no separate "no start year" check is needed.)

# Organization Completeness
#################################################################
# No OrganizationCompletenessShape existed before this: an Organization
# could carry a bare `dissolved: {date}` with no `manner`, and a
# genealogy affiliation could point at a predecessor never marked
# dissolved (or a dissolved organization could sit unclaimed by any
# successor), with no warning at either validation tier. Each check
# below is its own NodeShape rather than one shared shape, for the same
# reason as TeacherAgeQualityShape above: pyshacl silently ignores a
# severity declared inside sh:sparql, so each SPARQLConstraint needs
# its own shape-level sh:severity.

# Merged into one shape (both target Organization at Info severity) -
# grouping by (target class, severity) rather than by individual check
# produces identical findings with one fewer shape; see the note above
# TeacherAgeQualityShape for why severity forces sh:sparql blocks onto
# a shape's own sh:severity in the first place.
shapes:OrganizationDissolutionQualityShape
    a sh:NodeShape ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Organization dissolution"@en ;
    rdfs:comment "Info: dissolved events without a manner, and dissolutions that no successor's genealogy edge points back at."@en ;
    sh:targetClass martialis:Organization ;
    sh:severity sh:Info ;
    sh:sparql [
        a sh:SPARQLConstraint ;
        sh:name "dissolution missing manner" ;
        sh:message "Quality check: Organization has a dissolved event with no manner - consider adding if known (merged/split/superseded/suppressed/renamed/defunct/unknown)." ;
        sh:select """
            PREFIX martialis: <https://w3id.org/martialis/ontology#>
            SELECT $this
            WHERE {
                $this martialis:dissolvedEvent ?d .
                FILTER NOT EXISTS { ?d martialis:dissolutionManner ?m }
            }
        """
    ] ;
    sh:sparql [
        a sh:SPARQLConstraint ;
        sh:name "dissolution unmatched successor edge" ;
        sh:message "Quality check: Organization was dissolved by merger/supersession/rename but no other organization declares the matching merged_from/replaces/renamed_from affiliation pointing back at it - consider adding the successor's edge, or the successor entity, if known." ;
        sh:select """
            PREFIX martialis: <https://w3id.org/martialis/ontology#>
            PREFIX diss: <https://w3id.org/martialis/vocab/dissolution-manner/>
            PREFIX affrel: <https://w3id.org/martialis/vocab/affiliation-relation/>
            SELECT $this
            WHERE {
                {
                    $this martialis:dissolvedEvent/martialis:dissolutionManner diss:merged .
                    BIND(affrel:merged_from AS ?expectedRel)
                } UNION {
                    $this martialis:dissolvedEvent/martialis:dissolutionManner diss:superseded .
                    BIND(affrel:replaces AS ?expectedRel)
                } UNION {
                    $this martialis:dissolvedEvent/martialis:dissolutionManner diss:renamed .
                    BIND(affrel:renamed_from AS ?expectedRel)
                }
                FILTER NOT EXISTS {
                    ?successor martialis:hasAffiliationAssertion ?a .
                    ?a martialis:object $this ;
                       martialis:affiliationRelation ?expectedRel .
                }
            }
        """
    ] .
    # Deliberately Info, not Warning: the successor legitimately may not
    # be in the database yet, so both checks above are prompts, not
    # defects.

# The mirror of the above, from the successor's side: a merged_from/
# replaces/renamed_from claim implies the predecessor ended, so its own
# record should carry a dissolved event. split_from is deliberately
# excluded - the parent survives a schism by definition.
shapes:OrganizationPredecessorUnendedQualityShape
    a sh:NodeShape ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Predecessor unended"@en ;
    rdfs:comment "Info: genealogy claims whose predecessor organization has no dissolved event."@en ;
    sh:targetClass martialis:AffiliationAssertion ;
    sh:severity sh:Info ;
    sh:sparql [
        a sh:SPARQLConstraint ;
        sh:name "genealogy predecessor not dissolved" ;
        sh:message "Quality check: this genealogy affiliation implies the predecessor organization ended, but it has no dissolved event - consider adding one." ;
        sh:select """
            PREFIX martialis: <https://w3id.org/martialis/ontology#>
            PREFIX affrel: <https://w3id.org/martialis/vocab/affiliation-relation/>
            SELECT $this
            WHERE {
                $this martialis:affiliationRelation ?rel ;
                      martialis:object ?predecessor .
                FILTER (?rel IN (affrel:merged_from, affrel:replaces, affrel:renamed_from))
                FILTER NOT EXISTS { ?predecessor martialis:dissolvedEvent ?d }
            }
        """
    ] .

# Alternate Name Quality Checks
#################################################################

shapes:AlternateNameQualityShape
    a sh:NodeShape ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Alternate name hygiene"@en ;
    rdfs:comment "Warns when alternateName values contain semicolons - should be separate values."@en ;
    sh:targetClass martialis:Person ;
    # sh:severity belongs on the NodeShape, not inside sh:sparql: pyshacl
    # silently ignores a severity declared on the SPARQLConstraint
    # itself and falls back to sh:Violation (confirmed empirically;
    # see TeacherAgeQualityShape/InteractionPeriodQualityShape below,
    # which hit the same thing).
    sh:severity sh:Warning ;
    sh:sparql [
        sh:name "alternate name semicolons" ;
        sh:message "Quality check: alternateName contains semicolons - should be split into separate values." ;
        sh:prefixes [
            sh:declare [
                sh:prefix "schema" ;
                sh:namespace "https://schema.org/"
            ]
        ] ;
        sh:select """
            SELECT $this ?name
            WHERE {
                $this schema:alternateName ?name .
                FILTER(CONTAINS(STR(?name), ";"))
            }
        """
    ] .

# Confidence Assessment Quality
#################################################################

# confidenceLevel is not checked here - already `required` in
# martialis.schema.json's Confidence definition, so it can never fire.
shapes:ConfidenceQualityShape
    a sh:NodeShape ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Confidence rationale"@en ;
    rdfs:comment "Info: confidence assessments without a documented rationale."@en ;
    sh:targetClass martialis:Confidence ;

    sh:property [
        sh:path martialis:confidenceRationale ;
        sh:minCount 1 ;
        sh:severity sh:Info ;
        sh:message "Quality check: Confidence assessment has no rationale - consider documenting reasoning."
    ] .

# Entity-Level Evidence Coverage Quality
#################################################################

# `covers` (martialis:coversProperty) is only ever legal on an entity's
# own top-level `sources` (SourceRef.covers via EntityEvidenceRef in
# martialis.schema.json) - it is the only per-field citation mechanism
# for fields with no `sources` child of their own (name, aka,
# nationality, description, notes). Nested evidence (birth.sources,
# teachers[].interactions[].sources, rank[].sources, etc.) is forbidden
# from carrying it by the JSON Schema, since its position already scopes
# it. A plain sh:targetClass martialis:SourceAssertionRelation shape
# would therefore warn on every nested EvidenceRef too, which is correct
# data, not missing data - so this is a SPARQLConstraint targeting the
# entity itself (Person/Style/Organization/Place/Event) and checking
# only its own one-hop hasEvidence, mirroring TeacherAgeQualityShape's
# pattern above. Reports once per entity that has at least one
# uncovered entity-level source, rather than once per uncovered source -
# aggregate per-field percentages are 'martialis provenance'/'dashboard'
# territory (see the file header note), this is a pointer to go look.
shapes:EntityEvidenceCoverageQualityShape
    a sh:NodeShape ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Entity evidence coverage"@en ;
    rdfs:comment "Warns when a top-level entity source records no covers - which fields it backs is unknown."@en ;
    sh:targetClass martialis:Person, martialis:Style, martialis:Organization,
                    martialis:Place, martialis:Event ;
    sh:severity sh:Warning ;
    sh:sparql [
        a sh:SPARQLConstraint ;
        sh:name "source missing covers" ;
        sh:message "Quality check: entity has a top-level source with no `covers` - consider adding which fields it backs." ;
        sh:select """
            PREFIX martialis: <https://w3id.org/martialis/ontology#>
            SELECT $this
            WHERE {
                $this martialis:hasEvidence ?ev .
                ?ev a martialis:SourceAssertionRelation .
                FILTER NOT EXISTS { ?ev martialis:coversProperty ?p }
            }
        """
    ] .

# Event Completeness
#################################################################

shapes:EventLocationQualityShape
    a sh:NodeShape ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Event location"@en ;
    rdfs:comment "Info: events with no location (place_id or geo)."@en ;
    sh:targetClass martialis:Event ;
    sh:severity sh:Info ;
    sh:sparql [
        a sh:SPARQLConstraint ;
        sh:name "event missing location" ;
        sh:message "Quality check: Event has no location (place_id or geo) - consider adding if known." ;
        sh:select """
            PREFIX martialis: <https://w3id.org/martialis/ontology#>
            PREFIX schema: <https://schema.org/>
            SELECT $this
            WHERE {
                FILTER NOT EXISTS { $this schema:location ?loc }
            }
        """
    ] .

shapes:ParticipationEvidenceQualityShape
    a sh:NodeShape ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Participation evidence"@en ;
    rdfs:comment "Warns on participation claims without their own evidence."@en ;
    sh:targetClass martialis:Participation ;
    sh:severity sh:Warning ;
    sh:sparql [
        a sh:SPARQLConstraint ;
        sh:name "participation missing sources" ;
        sh:message "Quality check: Participation has no sources - a participation claim (who was there, in what role) should carry its own evidence." ;
        sh:select """
            PREFIX martialis: <https://w3id.org/martialis/ontology#>
            SELECT $this
            WHERE {
                FILTER NOT EXISTS { $this martialis:hasEvidence ?e }
            }
        """
    ] .

# Source Completeness
#################################################################

shapes:SourceCompletenessShape
    a sh:NodeShape ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Source completeness"@en ;
    rdfs:comment "Info: sources with no issued date."@en ;
    sh:targetClass martialis:Source ;
    sh:property [
        sh:path schema:datePublished ;
        sh:minCount 1 ;
        sh:severity sh:Info ;
        sh:message "Quality check: Source has no issued date - consider adding if available."
    ] .

# The remaining Source quality checks are SPARQLConstraints, each needing
# its own severity. In this pipeline's pyshacl version, sh:severity placed
# inside an ad-hoc sh:sparql [...] constraint blank node is not honoured -
# severity must instead sit on the enclosing NodeShape (as
# EntityEvidenceCoverageQualityShape above already does). Grouped here by
# (target class, severity) into two shapes rather than one shape per
# check - same findings, fewer shapes.

shapes:SourceWebpageQualityShape
    a sh:NodeShape ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Webpage source quality"@en ;
    rdfs:comment "Warns on webpage sources with no accessed date or no archive snapshot."@en ;
    sh:targetClass martialis:Source ;
    sh:severity sh:Warning ;
    sh:sparql [
        a sh:SPARQLConstraint ;
        sh:name "webpage missing accessed date" ;
        sh:message "Quality check: webpage Source has no accessed date - when was this consulted?" ;
        sh:select """
            PREFIX martialis: <https://w3id.org/martialis/ontology#>
            SELECT $this
            WHERE {
                $this martialis:sourceType "webpage" .
                FILTER NOT EXISTS { $this martialis:accessed ?a }
            }
        """
    ] ;
    sh:sparql [
        a sh:SPARQLConstraint ;
        sh:name "webpage missing archive snapshot" ;
        sh:message "Quality check: webpage Source has no archive snapshot - the live URL may go dead." ;
        sh:select """
            PREFIX schema: <https://schema.org/>
            PREFIX martialis: <https://w3id.org/martialis/ontology#>
            SELECT $this
            WHERE {
                $this martialis:sourceType "webpage" .
                FILTER NOT EXISTS { $this schema:archivedAt ?a }
            }
        """
    ] .

# Policy behind the containment check below: martialis:partOf is decided
# by who actually published a work, not by which domain currently hosts
# it - a work can be mirrored, syndicated, or archived on a third-party
# site while still belonging structurally to its original outlet (e.g.
# an Aikido Journal interview mirrored on a fan wiki is still part_of
# Aikido Journal, not part_of the wiki, and it is not part_of anything
# at all just because its own url happens to sit on a different host).
# That check is the durable version of a real migration-time gap:
# several items had a publisher name that already named an existing
# container outlet, but no martialis:partOf link, because the original
# migration matched by hostname rather than by publisher.
shapes:SourceContainmentQualityShape
    a sh:NodeShape ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Source containment"@en ;
    rdfs:comment "Info: item-level sources without partOf, over-deep containment chains, self_published disagreement with the derivable signal, and publisher-name/container mismatches."@en ;
    sh:targetClass martialis:Source ;
    sh:severity sh:Info ;
    sh:sparql [
        a sh:SPARQLConstraint ;
        sh:name "item source missing container" ;
        sh:message "Quality check: an item-level source (not a container form like website/periodical/collection/book) has no part_of - consider linking it to its journal, website edition, or volume." ;
        sh:select """
            PREFIX martialis: <https://w3id.org/martialis/ontology#>
            SELECT $this
            WHERE {
                $this martialis:sourceType ?type .
                FILTER (?type NOT IN ("website", "periodical", "collection", "book"))
                FILTER NOT EXISTS { $this martialis:partOf ?container }
            }
        """
    ] ;
    sh:sparql [
        a sh:SPARQLConstraint ;
        sh:name "container nesting too deep" ;
        sh:message "Quality check: part_of nests more than 3 levels deep - worth checking this containment chain makes sense." ;
        sh:select """
            PREFIX martialis: <https://w3id.org/martialis/ontology#>
            SELECT $this
            WHERE {
                $this martialis:partOf ?p1 .
                ?p1 martialis:partOf ?p2 .
                ?p2 martialis:partOf ?p3 .
                ?p3 martialis:partOf ?p4 .
            }
        """
    ] ;
    sh:sparql [
        a sh:SPARQLConstraint ;
        # publisher.org_id can only ever be an Organization IRI (schema:
        # publisher has no person-typed analogue), so this only ever
        # compares against a contributor's own org_id - there is no
        # symmetric "contributor is a Person who is also the publisher"
        # case to check, since publisher isn't Person-typed. An earlier
        # revision of this check's message overclaimed a person_id path
        # that was never implemented; the message here now says only
        # what the query actually does.
        sh:name "self-published signal mismatch" ;
        sh:message "Quality check: self_published disagrees with the derivable signal (publisher.org_id matching a contributor's own org_id) - worth double-checking." ;
        sh:select """
            PREFIX schema: <https://schema.org/>
            PREFIX martialis: <https://w3id.org/martialis/ontology#>
            SELECT $this
            WHERE {
                $this martialis:selfPublished ?asserted .
                OPTIONAL {
                    $this schema:publisher [ martialis:publisherOrg ?pubOrg ] ;
                          martialis:hasContributor [ martialis:contributorOrg ?pubOrg ] .
                    BIND(true AS ?derived)
                }
                FILTER (
                    (?asserted = true && !BOUND(?derived)) ||
                    (?asserted = false && BOUND(?derived))
                )
            }
        """
    ] ;
    sh:sparql [
        a sh:SPARQLConstraint ;
        sh:name "publisher container missed link" ;
        sh:message "Quality check: publisher name matches an existing, in-use container Source, but this Source has no part_of - possibly missed during migration/curation." ;
        sh:select """
            PREFIX schema: <https://schema.org/>
            PREFIX martialis: <https://w3id.org/martialis/ontology#>
            SELECT $this
            WHERE {
                $this schema:publisher [ schema:name ?pubName ] .
                FILTER NOT EXISTS { $this martialis:partOf ?anyC }
                ?container a martialis:Source ; schema:name ?contTitle .
                FILTER (?container != $this)
                FILTER EXISTS { ?anyItem martialis:partOf ?container }
                FILTER (CONTAINS(LCASE(?pubName), LCASE(?contTitle)))
            }
        """
    ] .

# Confidence Discrepancy
#################################################################
# Migrated from bin/martialis's derive_confidence_scores/compare_confidence
# (previously reported only by 'martialis provenance', with no fixture
# coverage and no per-item message). The point system - type primary +2/
# secondary +1, supports true +1/false -1, weight high +1/low -1, summed
# over each DISTINCT value present per source dimension (repetition adds
# no points), banded (<=0 -> 0, <=2 -> 1, <=4 -> 2, else 3), then capped
# at 1 if any evidence disputes (supports false) - is spelled out in full
# in docs/VALIDATION.md's Analytics section, which remains authoritative;
# this shape is a straight SPARQL port; see that doc for the design
# rationale (why distinct-value scoring, why the disputed cap, the
# GEDCOM-style "Disputed" precedent).
#
# Each source dimension (type/supports/weight) is scored in its own
# GROUP BY subquery over a DISTINCT-value pre-aggregation, then combined
# via OPTIONAL + COALESCE at the outer level - joining three independent
# one-to-many subqueries directly (without pre-aggregating each to one
# row per $this first) would cross-join and multiply the sums. The final
# ABS(...) comparison is bound via BIND before the FILTER, not computed
# inline inside the FILTER expression - verified (against a full
# side-by-side run against every one of this database's assertions)
# that inlining it silently drops solutions where $this has no evidence
# at all, apparently a type-coercion quirk in how rdflib's SPARQL engine
# evaluates a FILTER's own arithmetic subtraction against a variable
# that was itself bound via a chain of nested IF()s, as opposed to a
# separately pre-bound variable of the same value - pre-binding via BIND
# sidesteps it entirely and is confirmed to reproduce derive_confidence_scores'
# output exactly (same 124 flagged assertions, out of 1345 with both a
# manual confidenceLevel and a computable derived score, on this
# database's 2026-09 snapshot).
shapes:ConfidenceDiscrepancyQualityShape
    a sh:NodeShape ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Confidence discrepancy"@en ;
    rdfs:comment "Signals assertions whose manual confidence level differs from a source-evidence-derived level by two or more."@en ;
    sh:targetClass martialis:TeacherAssertion, martialis:RankAssertion, martialis:HonorAssertion,
        martialis:NegativeAssertion, martialis:ContextualTrainingAssertion, martialis:StyleRelationAssertion,
        martialis:DocumentTransmissionAssertion, martialis:IdentityAssertion, martialis:AffiliationAssertion ;
    sh:severity sh:Info ;
    sh:sparql [
        a sh:SPARQLConstraint ;
        sh:name "confidence discrepancy" ;
        sh:message "Quality check: manual confidence differs from the source-evidence-derived level by 2 or more - worth double-checking." ;
        sh:select """
            PREFIX martialis: <https://w3id.org/martialis/ontology#>
            PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
            PREFIX srctype: <https://w3id.org/martialis/vocab/source-ref-type/>
            PREFIX evweight: <https://w3id.org/martialis/vocab/evidence-weight/>
            SELECT $this
            WHERE {
                $this martialis:hasConfidence [ martialis:confidenceLevel ?manual ] .
                OPTIONAL {
                    SELECT $this (SUM(?tp) AS ?typeScore) WHERE {
                        {
                            SELECT DISTINCT $this ?type WHERE {
                                $this (martialis:hasEvidence | martialis:hasInteraction/martialis:hasEvidence) ?ev .
                                ?ev martialis:sourceRefType ?type .
                            }
                        }
                        BIND(IF(?type = srctype:primary, 2, IF(?type = srctype:secondary, 1, 0)) AS ?tp)
                    } GROUP BY $this
                }
                OPTIONAL {
                    SELECT $this (SUM(?sp) AS ?supportsScore) (MAX(?df) AS ?disputedFlag) WHERE {
                        {
                            SELECT DISTINCT $this ?supports WHERE {
                                $this (martialis:hasEvidence | martialis:hasInteraction/martialis:hasEvidence) ?ev .
                                ?ev martialis:supports ?supports .
                            }
                        }
                        BIND(IF(?supports = true, 1, IF(?supports = false, -1, 0)) AS ?sp)
                        BIND(IF(?supports = false, 1, 0) AS ?df)
                    } GROUP BY $this
                }
                OPTIONAL {
                    SELECT $this (SUM(?wp) AS ?weightScore) WHERE {
                        {
                            SELECT DISTINCT $this ?weight WHERE {
                                $this (martialis:hasEvidence | martialis:hasInteraction/martialis:hasEvidence) ?ev .
                                ?ev martialis:evidenceWeight ?weight .
                            }
                        }
                        BIND(IF(?weight = evweight:high, 1, IF(?weight = evweight:low, -1, 0)) AS ?wp)
                    } GROUP BY $this
                }
                BIND((COALESCE(?typeScore, 0) + COALESCE(?supportsScore, 0) + COALESCE(?weightScore, 0)) AS ?score)
                BIND(IF(?score <= 0, 0, IF(?score <= 2, 1, IF(?score <= 4, 2, 3))) AS ?bandedLevel)
                BIND(IF(COALESCE(?disputedFlag, 0) = 1 && ?bandedLevel > 1, 1, ?bandedLevel) AS ?derived)
                BIND(ABS(xsd:integer(STR(?manual)) - xsd:integer(STR(?derived))) AS ?diff)
                FILTER(?diff >= 2)
            }
        """
    ] .

# Style Relation Integrity
#################################################################
# Migrated from bin/martialis's cmd_styles, which used to detect these
# by reading database/*.yaml directly (before RDF conversion). Dangling
# style_id references and self-references were ALSO checked there, but
# both are redundant with existing structural checks - a dangling
# style_id becomes an IRI failing StyleRelationAssertionShape's
# sh:class martialis:Style on martialis:object; self-references are now
# StyleRelationAssertionShape's own "style relation not self-referential"
# SPARQL constraint (structural, mirroring the identity/affiliation
# self-reference checks already there) - so only the two genuinely
# semantic checks move here.

shapes:StyleSiblingAsymmetryQualityShape
    a sh:NodeShape ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Asymmetric sibling_style_of"@en ;
    rdfs:comment "sibling_style_of is a peer relationship and should be declared on both styles; flags a one-way declaration."@en ;
    sh:targetClass martialis:StyleRelationAssertion ;
    sh:severity sh:Warning ;
    sh:sparql [
        a sh:SPARQLConstraint ;
        sh:name "asymmetric sibling_style_of" ;
        sh:message "Quality check: sibling_style_of is declared one-way - the other style should declare it back." ;
        sh:select """
            PREFIX martialis: <https://w3id.org/martialis/ontology#>
            PREFIX stylerel: <https://w3id.org/martialis/vocab/style-relation/>
            SELECT $this
            WHERE {
                $this martialis:subject ?a ; martialis:object ?b ;
                      martialis:styleRelationType stylerel:sibling_style_of .
                FILTER NOT EXISTS {
                    ?rev martialis:subject ?b ; martialis:object ?a ;
                         martialis:styleRelationType stylerel:sibling_style_of .
                }
            }
        """
    ] .

shapes:StyleVariantCycleQualityShape
    a sh:NodeShape ;
    rdfs:isDefinedBy <https://w3id.org/martialis/ontology#MartialisOntology> ;
    rdfs:label "Style variant_of cycle"@en ;
    rdfs:comment "variant_of describes a narrower-to-broader lineage and should never cycle back to its own starting style."@en ;
    sh:targetClass martialis:Style ;
    sh:severity sh:Warning ;
    sh:sparql [
        a sh:SPARQLConstraint ;
        sh:name "variant_of cycle" ;
        sh:message "Quality check: this style's variant_of chain cycles back to itself." ;
        sh:select """
            PREFIX martialis: <https://w3id.org/martialis/ontology#>
            SELECT $this
            WHERE {
                FILTER EXISTS { $this martialis:variantOf+ $this }
            }
        """
    ] .
