@prefix sh:      <http://www.w3.org/ns/shacl#> .
@prefix honoria: <https://id.honoria.co.uk/def/core/> .
@prefix shp:     <https://id.honoria.co.uk/def/core-shapes/> .
@prefix schema:  <https://schema.org/> .
@prefix dct:     <http://purl.org/dc/terms/> .
@prefix owl:     <http://www.w3.org/2002/07/owl#> .
@prefix rdfs:    <http://www.w3.org/2000/01/rdf-schema#> .
@prefix xsd:     <http://www.w3.org/2001/XMLSchema#> .
@prefix prov:    <http://www.w3.org/ns/prov#> .

#################################################################
# Honoria core shapes
#
# Closed-world validation for knowledge-graph INSTANCE data (the
# output of scripts/export-rdf.cjs), companion to the core ontology
# at /id/core. The ontology defines; these shapes validate - OWL is
# open-world and never flags missing or contradictory data, and
# under RDFox equality reasoning an owl:FunctionalProperty would
# MERGE conflicting values rather than report them, so cardinality
# and enumeration constraints live here instead (the same
# "SKOS defines, SHACL validates" split as honours-shapes.ttl).
#
# Severities follow the provenance vocabulary: sh:Violation blocks
# (structurally broken data); sh:Warning flags quality gaps that
# are expected in parts of the corpus (e.g. pre-2014 rounds with no
# list file).
#
# Validate a sample (pyshacl over the full 1.5M-triple export is
# slow - the RDFox flag rules in graph/honoria.dlog cover full
# scale):
#   node scripts/export-rdf.cjs --limit 2000 --out /tmp/kg-sample.ttl
#   pyshacl --allow-warnings -s public/id/core-shapes.ttl /tmp/kg-sample.ttl
#################################################################

<https://id.honoria.co.uk/def/core-shapes>
    a owl:Ontology ;
    dct:title       "Honoria core shapes"@en ;
    dct:description "SHACL validation shapes for Honoria knowledge-graph instance data - cardinalities, enumerations, value patterns and provenance guarantees for the classes defined by the core ontology."@en ;
    rdfs:seeAlso    <https://id.honoria.co.uk/def/core> ;
    dct:modified    "2026-07-04"^^xsd:date ;
    dct:license     <https://creativecommons.org/licenses/by/4.0/> .

#################################################################
# Honour - the hub node
#################################################################

shp:HonourShape
    a sh:NodeShape ;
    sh:targetClass honoria:Honour ;
    # Exactly one recipient (via the asserted Person -received-> Honour edge).
    sh:property [
        sh:path [ sh:inversePath honoria:received ] ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:severity sh:Violation ;
        sh:message "An Honour is one conferral to one person: exactly one incoming honoria:received." ;
    ] ;
    sh:property [
        sh:path honoria:awardType ;
        sh:minCount 1 ; sh:maxCount 1 ; sh:nodeKind sh:IRI ;
        sh:severity sh:Violation ;
        sh:message "An Honour has exactly one award type." ;
    ] ;
    sh:property [
        sh:path honoria:conferredIn ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:severity sh:Violation ;
        sh:message "An Honour is announced in exactly one honours round." ;
    ] ;
    sh:property [
        sh:path honoria:hasCitation ;
        sh:maxCount 1 ;
        sh:severity sh:Violation ;
        sh:message "An Honour has at most one citation (1:1 with the conferral)." ;
    ] ;
    sh:property [
        sh:path honoria:locatedIn ;
        sh:maxCount 1 ;
        sh:severity sh:Violation ;
        sh:message "An Honour records at most one as-at-conferral location." ;
    ] ;
    # The provenance guarantee: every conferral is citable.
    sh:property [
        sh:path honoria:sourcedFrom ;
        sh:minCount 1 ;
        sh:severity sh:Warning ;
        sh:message "Every conferral should carry at least one citable source (Gazette notice, Gazette issue or honours list file)." ;
    ] ;
    sh:property [
        sh:path honoria:conferredOn ;
        sh:maxCount 1 ;
        sh:or ( [ sh:datatype xsd:dateTime ] [ sh:datatype xsd:date ] ) ;
        sh:severity sh:Violation ;
        sh:message "conferredOn is a single xsd:date or xsd:dateTime." ;
    ] ;
    sh:property [
        sh:path honoria:awardYear ;
        sh:datatype xsd:gYear ;
        # Plausible-year range as a lexical pattern (1900-2099): xsd:gYear is ordered
        # in XSD but pyshacl cannot order-compare it, so sh:minInclusive/maxInclusive
        # would spuriously fail; the pattern on the CCYY lexical form is equivalent.
        sh:pattern "^(19|20)[0-9]{2}$" ;
        sh:severity sh:Violation ;
        sh:message "awardYear is a plausible honours-list year." ;
    ] ;
    sh:property [
        sh:path honoria:listType ;
        sh:in ( "new-year" "birthday" ) ;
        sh:severity sh:Violation ;
        sh:message "listType is new-year or birthday." ;
    ] ;
    sh:property [
        sh:path honoria:inDivision ;
        sh:maxCount 1 ; sh:class honoria:Division ;
        sh:severity sh:Violation ;
        sh:message "An honour is in at most one division (Civil or Military)." ;
    ] ;
    # Source-hierarchy rule: the Gazette publishes neither home location nor
    # (for most rounds) the gov.uk citation - those fields imply a list file.
    sh:sparql [
        # sh:sparql defaults to Violation severity; this is a quality gap
        # (rounds whose CSV is not yet in the sources manifest), so the
        # datalog locationSourceRule carries the full-scale reporting.
        sh:severity sh:Warning ;
        sh:message "An honour with a recorded location should have a Cabinet Office list-file source (the Gazette carries no home address)." ;
        sh:prefixes <https://id.honoria.co.uk/def/core-shapes> ;
        sh:select """
            PREFIX honoria: <https://id.honoria.co.uk/def/core/>
            SELECT $this WHERE {
              $this honoria:locatedIn ?place .
              FILTER NOT EXISTS {
                $this honoria:sourcedFrom ?src .
                ?src a honoria:HonoursListFile .
              }
            }
        """ ;
    ] .

#################################################################
# Awardee
#################################################################

shp:AwardeeShape
    a sh:NodeShape ;
    sh:targetClass honoria:Awardee ;
    sh:property [
        sh:path schema:name ;
        sh:minCount 1 ;
        sh:severity sh:Violation ;
        sh:message "Every awardee has a name." ;
    ] ;
    sh:property [
        sh:path dct:identifier ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:severity sh:Violation ;
        sh:message "Every awardee carries exactly one stable Honoria person id." ;
    ] ;
    sh:property [
        sh:path honoria:received ;
        sh:minCount 1 ;
        sh:severity sh:Violation ;
        sh:message "An Awardee has received at least one honour - that is what makes them an Awardee." ;
    ] ;
    sh:property [
        sh:path honoria:reconciliationConfidence ;
        sh:maxCount 1 ;
        sh:in ( "verified" "reconciled" "single-record" ) ;
        sh:severity sh:Violation ;
        sh:message "reconciliationConfidence is verified, reconciled or single-record." ;
    ] ;
    sh:property [
        sh:path owl:sameAs ;
        sh:nodeKind sh:IRI ;
        sh:pattern "^http://www\\.wikidata\\.org/entity/Q[0-9]+$" ;
        sh:severity sh:Warning ;
        sh:message "owl:sameAs on an awardee should point at a Wikidata entity (merge semantics!)." ;
    ] .

#################################################################
# Citation
#################################################################

shp:CitationShape
    a sh:NodeShape ;
    sh:targetClass honoria:Citation ;
    sh:property [
        sh:path honoria:citationText ;
        sh:maxCount 1 ;
        sh:severity sh:Violation ;
        sh:message "A citation carries one verbatim text." ;
    ] ;
    sh:property [
        sh:path [ sh:inversePath honoria:hasCitation ] ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:severity sh:Violation ;
        sh:message "A Citation belongs to exactly one Honour." ;
    ] .

#################################################################
# Sources
#################################################################

shp:GazetteNoticeShape
    a sh:NodeShape ;
    sh:targetClass honoria:GazetteNotice ;
    sh:property [
        sh:path honoria:publishedIn ;
        sh:maxCount 1 ;
        sh:severity sh:Violation ;
        sh:message "A notice appears in one Gazette issue." ;
    ] ;
    sh:property [
        sh:path honoria:publicationKind ;
        sh:in ( "gazette-notice" "gazette-pdf" "csv" ) ;
        sh:severity sh:Violation ;
        sh:message "publicationKind is gazette-notice, gazette-pdf or csv." ;
    ] ;
    sh:property [
        sh:path schema:datePublished ;
        sh:maxCount 1 ;
        sh:severity sh:Violation ;
        sh:message "A notice has one publication date." ;
    ] .

shp:GazetteIssueShape
    a sh:NodeShape ;
    sh:targetClass honoria:GazetteIssue ;
    sh:property [
        sh:path schema:datePublished ;
        sh:maxCount 1 ;
        sh:severity sh:Violation ;
        sh:message "An issue has one publication date (schema:datePublished)." ;
    ] ;
    sh:property [
        sh:path dct:identifier ;
        sh:maxCount 1 ;
        sh:severity sh:Warning ;
        sh:message "An issue carries its issue number as dct:identifier." ;
    ] .

shp:HonoursListFileShape
    a sh:NodeShape ;
    sh:targetClass honoria:HonoursListFile ;
    sh:property [
        sh:path prov:wasDerivedFrom ;
        sh:minCount 1 ;
        sh:severity sh:Violation ;
        sh:message "A Honoria-hosted list file records the gov.uk asset it was fetched from." ;
    ] ;
    sh:property [
        sh:path schema:datePublished ;
        sh:maxCount 1 ;
        sh:severity sh:Violation ;
        sh:message "A list file has one publication date (schema:datePublished)." ;
    ] ;
    sh:property [
        sh:path dct:license ;
        sh:minCount 1 ;
        sh:severity sh:Warning ;
        sh:message "A list file should carry its licence (OGL v3)." ;
    ] .

shp:RoleShape
    a sh:NodeShape ;
    sh:targetClass honoria:Role ;
    sh:property [
        sh:path schema:roleName ;
        sh:minCount 1 ; sh:maxCount 1 ; sh:datatype xsd:string ;
        sh:severity sh:Violation ;
        sh:message "A role has exactly one verbatim name (schema:roleName)." ;
    ] ;
    sh:property [
        sh:path honoria:recordedAt ;
        sh:maxCount 1 ; sh:class honoria:Honour ;
        sh:severity sh:Violation ;
        sh:message "A role is recorded at one conferral." ;
    ] ;
    sh:property [
        sh:path honoria:atOrganisation ;
        sh:maxCount 1 ; sh:class honoria:Organisation ;
        sh:severity sh:Warning ;
        sh:message "A role is held at one organisation, where identified." ;
    ] .

#################################################################
# Award event
#################################################################

shp:AwardEventShape
    a sh:NodeShape ;
    sh:targetClass honoria:AwardEvent ;
    sh:property [
        sh:path schema:name ;
        sh:minCount 1 ;
        sh:severity sh:Violation ;
        sh:message "An award event has a name." ;
    ] ;
    sh:property [
        sh:path honoria:awardYear ;
        sh:minCount 1 ; sh:maxCount 1 ; sh:datatype xsd:gYear ;
        sh:severity sh:Violation ;
        sh:message "An award event has exactly one year." ;
    ] ;
    sh:property [
        sh:path honoria:listType ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:in ( "new-year" "birthday" ) ;
        sh:severity sh:Violation ;
        sh:message "An award event has exactly one list type." ;
    ] .

#################################################################
# Forfeiture
#################################################################

shp:ForfeitureShape
    a sh:NodeShape ;
    sh:targetClass honoria:Forfeiture ;
    sh:property [
        sh:path honoria:forfeitedOn ;
        sh:maxCount 1 ; sh:datatype xsd:gYearMonth ;
        sh:severity sh:Violation ;
        sh:message "forfeitedOn is a single year-month." ;
    ] ;
    sh:property [
        sh:path honoria:forfeitureReason ;
        sh:minCount 1 ;
        sh:severity sh:Warning ;
        sh:message "A forfeiture should record its published reason." ;
    ] ;
    sh:property [
        sh:path prov:wasDerivedFrom ;
        sh:minCount 1 ;
        sh:severity sh:Violation ;
        sh:message "A forfeiture cites the gov.uk forfeited-honours list." ;
    ] ;
    sh:property [
        sh:path [ sh:inversePath honoria:hasForfeiture ] ;
        sh:minCount 1 ;
        sh:severity sh:Warning ;
        sh:message "A forfeiture should link back to an awardee in the graph." ;
    ] .

#################################################################
# Places
#################################################################

shp:CityShape
    a sh:NodeShape ;
    sh:targetClass schema:City ;
    sh:property [
        sh:path schema:latitude ;
        sh:maxCount 1 ; sh:datatype xsd:decimal ;
        sh:severity sh:Violation ;
        sh:message "One decimal latitude per place." ;
    ] ;
    sh:property [
        sh:path schema:longitude ;
        sh:maxCount 1 ; sh:datatype xsd:decimal ;
        sh:severity sh:Violation ;
        sh:message "One decimal longitude per place." ;
    ] ;
    # UK bounding box unless the place is marked overseas.
    sh:sparql [
        sh:severity sh:Warning ;
        sh:message "A non-overseas town's coordinates should fall inside the UK bounding box (49.8-61N, 8.7W-1.8E)." ;
        sh:prefixes <https://id.honoria.co.uk/def/core-shapes> ;
        sh:select """
            PREFIX honoria: <https://id.honoria.co.uk/def/core/>
            PREFIX schema:  <https://schema.org/>
            SELECT $this WHERE {
              $this schema:latitude ?lat ; schema:longitude ?lng .
              FILTER NOT EXISTS { $this honoria:isOverseas true }
              FILTER (?lat < 49.8 || ?lat > 61.0 || ?lng < -8.7 || ?lng > 1.8)
            }
        """ ;
    ] .

# Declared prefixes for the sh:sparql constraints above.
<https://id.honoria.co.uk/def/core-shapes>
    sh:declare [ sh:prefix "honoria" ; sh:namespace "https://id.honoria.co.uk/def/core/"^^xsd:anyURI ] ;
    sh:declare [ sh:prefix "schema" ;  sh:namespace "https://schema.org/"^^xsd:anyURI ] .
