@prefix gg:   <https://grindograph.darkcoff.ee/vocab#> .
@prefix app:  <https://darkcoff.ee/grindhog/app#> .
@prefix sh:   <http://www.w3.org/ns/shacl#> .
@prefix xsd:  <http://www.w3.org/2001/XMLSchema#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix prov: <http://www.w3.org/ns/prov#> .
@prefix dct:  <http://purl.org/dc/terms/> .
@prefix schema: <https://schema.org/> .

# ============================================================================
# STRUCTURAL SHAPES
#
# These constraints protect interpretability and provenance. They deliberately do NOT
# require a scan to identify a roaster, coffee name, product, process, origin, etc.
# Unresolved and difficult scans are valid contributions.
#
# Domain-specific coffee shapes can remain advisory: if real package evidence violates
# the current coffee schema, retain the evidence and raise a gg:SchemaNote rather than
# discarding the scan.
# ============================================================================

gg:ScanCaseShape
    a sh:NodeShape ;
    sh:targetClass gg:ScanCase ;
    rdfs:label "Scan case — structural gate" ;

    sh:property [
        sh:path gg:hasImage ;
        sh:class gg:Image ;
        sh:minCount 1 ;
        sh:message "A scan case must contain at least one image." ;
    ] ;

    sh:property [
        sh:path prov:wasAttributedTo ;
        sh:nodeKind sh:IRI ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:message "A scan case must be attributed to exactly one contributor IRI." ;
    ] ;

    sh:property [
        sh:path dct:license ;
        sh:nodeKind sh:IRI ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:message "A scan case must carry the license under which its contribution can be used." ;
    ] ;

    sh:property [
        sh:path dct:created ;
        sh:datatype xsd:dateTime ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:message "A scan case must have exactly one creation timestamp." ;
    ] ;

    sh:property [
        sh:path gg:currentGroundTruth ;
        sh:class gg:GroundTruth ;
        sh:maxCount 1 ;
        sh:message "A scan case may point to at most one current ground-truth snapshot." ;
    ] .


gg:ImageShape
    a sh:NodeShape ;
    sh:targetClass gg:Image ;
    rdfs:label "Image — structural gate" ;

    sh:property [
        sh:path gg:sha256 ;
        sh:datatype xsd:string ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:pattern "^[0-9a-f]{64}$" ;
        sh:message "An image must have exactly one lower-case hexadecimal SHA-256 digest." ;
    ] ;

    sh:property [
        sh:path gg:mediaType ;
        sh:datatype xsd:string ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:pattern "^image/[-+.a-zA-Z0-9]+$" ;
        sh:message "An image must have exactly one image/* media type." ;
    ] ;

    sh:property [
        sh:path gg:bundlePath ;
        sh:datatype xsd:string ;
        sh:maxCount 1 ;
        sh:minLength 1 ;
    ] ;

    sh:property [
        sh:path gg:pixelWidth ;
        sh:datatype xsd:integer ;
        sh:maxCount 1 ;
        sh:minInclusive 1 ;
    ] ;

    sh:property [
        sh:path gg:pixelHeight ;
        sh:datatype xsd:integer ;
        sh:maxCount 1 ;
        sh:minInclusive 1 ;
    ] .


# ============================================================================
# OFF-BAG EVIDENCE SHAPES
#
# A source document is the other kind of artifact a coffee fact can be read from, and it
# is somebody else's. Two constraints below are the load-bearing ones.
#
# gg:SourceDocumentShape forbids dct:license, where gg:ScanCaseShape above REQUIRES it.
# That pair is the licence boundary stated as validation rather than as a convention: a
# contributor grants CC0 over the photograph they took, and nobody granted this project
# anything over a roaster's web page. A document that arrived carrying a licence is a bug
# in whatever wrote it, and it fails here instead of reaching a release.
#
# gg:DocumentReadingShape requires gg:quotedText on every field it reads. A bag reading is
# checkable because somebody can look at the photograph; a document reading is checkable
# because the fragment is quoted and the digest says which text it came from. Without the
# quotation a crawled fact is a model's summary asking to be trusted, which is a weaker
# claim than anything else in this dataset and must not be storable as though it were the
# same thing.
# ============================================================================

gg:SourceDocumentShape
    a sh:NodeShape ;
    sh:targetClass gg:SourceDocument ;
    rdfs:label "Source document — structural gate" ;

    sh:property [
        sh:path schema:url ;
        sh:nodeKind sh:IRI ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:message "A source document must name exactly one canonical URL." ;
    ] ;

    sh:property [
        sh:path gg:sha256 ;
        sh:datatype xsd:string ;
        sh:pattern "^[0-9a-f]{64}$" ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:message "A source document must carry one lower-case SHA-256 digest of its text." ;
    ] ;

    sh:property [
        sh:path gg:retrievedAt ;
        sh:datatype xsd:dateTime ;
        sh:minCount 1 ;
        sh:message "A source document must record when it was retrieved." ;
    ] ;

    sh:property [
        sh:path gg:mediaType ;
        sh:datatype xsd:string ;
        sh:minCount 1 ; sh:maxCount 1 ;
    ] ;

    # The mirror of gg:ScanCaseShape's licence requirement, and the reason this file is
    # where the licence scoping lives. See the section comment above.
    sh:property [
        sh:path dct:license ;
        sh:maxCount 0 ;
        sh:message "A source document carries no licence: the CC0 grant is a contributor's to give over their own photograph, and nobody granted anything over a third party's page." ;
    ] .


gg:DocumentReadingShape
    a sh:NodeShape ;
    sh:targetClass gg:DocumentReading ;
    rdfs:label "Document reading — structural gate" ;

    sh:property [
        sh:path gg:annotates ;
        sh:class gg:SourceDocument ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:message "A document reading must read exactly one source document." ;
    ] ;

    sh:property [
        sh:path prov:wasAttributedTo ;
        sh:nodeKind sh:IRI ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:message "A document reading must be attributed to exactly one reader." ;
    ] ;

    sh:property [
        sh:path dct:created ;
        sh:datatype xsd:dateTime ;
        sh:minCount 1 ; sh:maxCount 1 ;
    ] ;

    # sh:node rather than sh:class, so the quotation requirement reaches the field
    # annotations of a DOCUMENT reading only. gg:FieldAnnotationShape stays as it is: a
    # bag reading has no page to quote, and adding gg:quotedText to its requirements
    # would reject every contribution in the corpus.
    sh:property [
        sh:path gg:hasFieldAnnotation ;
        sh:class gg:FieldAnnotation ;
        sh:node gg:QuotedFieldAnnotationShape ;
        sh:minCount 1 ;
        sh:message "A document reading must read at least one field, each with the fragment supporting it." ;
    ] .


gg:QuotedFieldAnnotationShape
    a sh:NodeShape ;
    rdfs:label "Field read off a document — must quote its source" ;
    rdfs:comment "Reached only through gg:DocumentReadingShape; deliberately has no sh:targetClass of its own." ;

    sh:property [
        sh:path gg:quotedText ;
        sh:datatype xsd:string ;
        sh:minLength 1 ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:message "A field read off a document must quote the fragment of it that says so." ;
    ] ;

    sh:property [
        sh:path gg:rawValue ;
        sh:datatype xsd:string ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:message "A field read off a document must carry the value as the page printed it." ;
    ] .


# An offer is a fact about a listing at a moment. It exists only inside a source
# document's graph, which is why it must say when it was observed: a price with no
# timestamp is a claim about the present that silently becomes false.
gg:OfferShape
    a sh:NodeShape ;
    sh:targetClass schema:Offer ;
    rdfs:label "Offer — structural gate" ;

    sh:property [
        sh:path schema:itemOffered ;
        sh:nodeKind sh:IRI ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:message "An offer must name exactly one thing being offered." ;
    ] ;

    sh:property [
        sh:path schema:price ;
        sh:datatype xsd:decimal ;
        sh:minExclusive 0.0 ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:message "An offer must carry exactly one positive price." ;
    ] ;

    sh:property [
        sh:path schema:priceCurrency ;
        sh:datatype xsd:string ;
        sh:minCount 1 ; sh:maxCount 1 ;
    ] ;

    sh:property [
        sh:path prov:generatedAtTime ;
        sh:datatype xsd:dateTime ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:message "An offer must record when it was observed." ;
    ] ;

    sh:property [
        sh:path schema:availability ;
        sh:nodeKind sh:IRI ;
        sh:maxCount 1 ;
    ] .


# ============================================================================
# HUMAN ANNOTATION SHAPES
# ============================================================================

gg:HumanAnnotationShape
    a sh:NodeShape ;
    sh:targetClass gg:HumanAnnotation ;
    rdfs:label "Human annotation — structural gate" ;

    sh:property [
        sh:path gg:annotates ;
        sh:class gg:ScanCase ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:message "A human annotation must annotate exactly one scan case." ;
    ] ;

    sh:property [
        sh:path prov:wasAttributedTo ;
        sh:nodeKind sh:IRI ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:message "A human annotation must be attributed to exactly one annotator." ;
    ] ;

    sh:property [
        sh:path dct:created ;
        sh:datatype xsd:dateTime ;
        sh:minCount 1 ; sh:maxCount 1 ;
    ] ;

    sh:property [
        sh:path gg:annotationStatus ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:in ( gg:Draft gg:Submitted gg:Reviewed gg:Superseded ) ;
        sh:message "Annotation status must be one of Draft, Submitted, Reviewed, or Superseded." ;
    ] ;

    sh:property [
        sh:path gg:hasFieldAnnotation ;
        sh:class gg:FieldAnnotation ;
    ] ;

    # sh:nodeKind, NOT sh:class, and this is a correction rather than a loosening.
    #
    # sh:class requires the REFERENT to carry rdf:type in the graph being validated. A
    # coffee's type is asserted in the coffee's own graph — that is the layout the whole
    # project is built on, one graph per resource — so it is never in the scan graph that
    # carries a resolution pointing at it. The constraint was therefore unsatisfiable: any
    # annotation that actually resolved a product failed the gate, which is why a reviewer
    # could not correct a bag once it had been resolved.
    #
    # What the structural gate can honestly check here is that the resolution names an IRI
    # rather than a string. Whether that IRI is a coffee is a cross-graph question, and it
    # is answered by the identity layer, which cannot record a resolution to a coffee that
    # does not exist — db.record_resolution has a foreign key.
    sh:property [
        sh:path gg:resolvedProduct ;
        sh:nodeKind sh:IRI ;
        sh:maxCount 1 ;
        sh:message "A resolution must name a coffee by its IRI." ;
    ] ;

    # A human annotation must say something, but that something may be only an
    # identity resolution or only one or more field observations.
    sh:or (
        [ sh:property [ sh:path gg:resolvedProduct ; sh:minCount 1 ] ]
        [ sh:property [ sh:path gg:hasFieldAnnotation ; sh:minCount 1 ] ]
    ) ;
    sh:message "A human annotation must contain an identity resolution or at least one field annotation." .


gg:FieldAnnotationShape
    a sh:NodeShape ;
    sh:targetClass gg:FieldAnnotation ;
    rdfs:label "Field annotation — structural gate" ;

    sh:property [
        sh:path gg:field ;
        sh:nodeKind sh:IRI ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:message "A field annotation must identify exactly one semantic field IRI." ;
    ] ;

    sh:property [
        sh:path gg:rawValue ;
        sh:datatype xsd:string ;
        sh:maxCount 1 ;
    ] ;

    sh:property [
        sh:path gg:normalizedValue ;
        sh:maxCount 1 ;
    ] ;

    sh:property [
        sh:path gg:observationState ;
        sh:maxCount 1 ;
        sh:in ( gg:Observed gg:NotVisible gg:Uncertain ) ;
    ] ;

    # Omission of a field is normally enough. If a FieldAnnotation node exists, it must
    # contain either a literal/raw value, a normalized value, or an explicit state.
    sh:or (
        [ sh:property [ sh:path gg:rawValue ; sh:minCount 1 ] ]
        [ sh:property [ sh:path gg:normalizedValue ; sh:minCount 1 ] ]
        [ sh:property [ sh:path gg:observationState ; sh:minCount 1 ] ]
    ) ;
    sh:message "A field annotation must contain a raw value, normalized value, or explicit observation state." .


# ============================================================================
# CURATED GROUND-TRUTH SHAPES
# ============================================================================

gg:GroundTruthShape
    a sh:NodeShape ;
    sh:targetClass gg:GroundTruth ;
    rdfs:label "Ground truth — curation gate" ;

    sh:property [
        sh:path gg:groundTruthFor ;
        sh:class gg:ScanCase ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:message "A ground-truth snapshot must belong to exactly one scan case." ;
    ] ;

    sh:property [
        sh:path gg:basedOnAnnotation ;
        sh:class gg:HumanAnnotation ;
        sh:minCount 1 ;
        sh:message "Ground truth must cite at least one human annotation as evidence." ;
    ] ;

    sh:property [
        sh:path prov:wasAttributedTo ;
        sh:nodeKind sh:IRI ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:message "Ground truth must be attributed to exactly one curator." ;
    ] ;

    sh:property [
        sh:path dct:created ;
        sh:datatype xsd:dateTime ;
        sh:minCount 1 ; sh:maxCount 1 ;
    ] ;

    # sh:nodeKind, NOT sh:class, and this is a correction rather than a loosening.
    #
    # sh:class requires the REFERENT to carry rdf:type in the graph being validated. A
    # coffee's type is asserted in the coffee's own graph — that is the layout the whole
    # project is built on, one graph per resource — so it is never in the scan graph that
    # carries a resolution pointing at it. The constraint was therefore unsatisfiable: any
    # annotation that actually resolved a product failed the gate, which is why a reviewer
    # could not correct a bag once it had been resolved.
    #
    # What the structural gate can honestly check here is that the resolution names an IRI
    # rather than a string. Whether that IRI is a coffee is a cross-graph question, and it
    # is answered by the identity layer, which cannot record a resolution to a coffee that
    # does not exist — db.record_resolution has a foreign key.
    sh:property [
        sh:path gg:resolvedProduct ;
        sh:nodeKind sh:IRI ;
        sh:maxCount 1 ;
        sh:message "A resolution must name a coffee by its IRI." ;
    ] ;

    sh:property [
        sh:path gg:hasTruthField ;
        sh:class gg:TruthField ;
    ] ;

    sh:or (
        [ sh:property [ sh:path gg:resolvedProduct ; sh:minCount 1 ] ]
        [ sh:property [ sh:path gg:hasTruthField ; sh:minCount 1 ] ]
    ) ;
    sh:message "Ground truth must curate an identity or at least one field." .


gg:TruthFieldShape
    a sh:NodeShape ;
    sh:targetClass gg:TruthField ;
    rdfs:label "Ground-truth field — curation gate" ;

    sh:property [
        sh:path gg:truthField ;
        sh:nodeKind sh:IRI ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:message "A ground-truth field must identify exactly one canonical field IRI." ;
    ] ;

    sh:property [
        sh:path gg:truthValue ;
        sh:maxCount 1 ;
        sh:message "A ground-truth field carries at most one curated value." ;
    ] ;

    sh:property [
        sh:path gg:observationState ;
        sh:in ( gg:Observed gg:NotVisible gg:Uncertain ) ;
        sh:maxCount 1 ;
        sh:message "Observation state must be one of the three gg:ObservationState terms." ;
    ] ;

    # A truth field must assert SOMETHING: either a curated value, or the curated
    # judgement that the images do not establish one. Requiring gg:truthValue outright
    # would make curated absence unstatable, and an extraction test case needs it — that
    # is what distinguishes "the extractor must not invent a value here" from "this field
    # was never assessed, so it is not scored".
    sh:or (
        [ sh:property [ sh:path gg:truthValue ; sh:minCount 1 ] ]
        [ sh:property [ sh:path gg:observationState ; sh:minCount 1 ] ]
    ) ;
    sh:message "A ground-truth field must carry a value or an observation state." ;

    sh:property [
        sh:path gg:basedOnFieldAnnotation ;
        sh:class gg:FieldAnnotation ;
    ] .


# ============================================================================
# SCHEMA FEEDBACK SHAPES
# ============================================================================

gg:SchemaNoteShape
    a sh:NodeShape ;
    sh:targetClass gg:SchemaNote ;
    rdfs:label "Schema note — structural gate" ;

    sh:property [
        sh:path gg:target ;
        sh:nodeKind sh:IRI ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:message "A schema note must target exactly one property IRI." ;
    ] ;

    sh:property [
        sh:path gg:kind ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:in ( gg:MissingEnumValue gg:NeedsMultipleValues gg:Ambiguous
                gg:ShouldSplit gg:NotOnBags gg:ProposeField ) ;
        sh:message "Schema note kind must use the Grindograph closed vocabulary." ;
    ] ;

    sh:property [
        sh:path gg:provokedBy ;
        sh:class gg:ScanCase ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:message "A schema note must be anchored to exactly one scan case." ;
    ] ;

    sh:or (
        [ sh:not [ sh:property [
            sh:path gg:kind ;
            sh:in ( gg:MissingEnumValue gg:ProposeField ) ;
            sh:minCount 1
        ] ] ]
        [ sh:property [
            sh:path gg:detail ;
            sh:datatype xsd:string ;
            sh:minCount 1 ;
            sh:minLength 1
        ] ]
    ) ;
    sh:message "MissingEnumValue and ProposeField notes must carry non-empty gg:detail." .


gg:ShapeProposalShape
    a sh:NodeShape ;
    sh:targetClass gg:ShapeProposal ;
    rdfs:label "Shape proposal — structural gate" ;

    sh:property [
        sh:path gg:proposedPath ;
        sh:nodeKind sh:IRI ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:message "A shape proposal must name exactly one proposed property path." ;
    ] ;

    sh:property [
        sh:path gg:proposedDatatype ;
        sh:nodeKind sh:IRI ;
        sh:maxCount 1 ;
    ] ;

    sh:property [
        sh:path gg:proposedMaxCount ;
        sh:datatype xsd:integer ;
        sh:maxCount 1 ;
        sh:minInclusive 1 ;
    ] ;

    sh:property [
        sh:path gg:proposedNodeKind ;
        sh:nodeKind sh:IRI ;
        sh:maxCount 1 ;
    ] ;

    sh:property [
        sh:path prov:wasAttributedTo ;
        sh:nodeKind sh:IRI ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:message "A shape proposal must be attributed to exactly one contributor." ;
    ] .


# ============================================================================
# The strata below a product
# ============================================================================
#
# What these deliberately do NOT check: that a lot is reachable from a product, or that
# gg:usesLot points at something typed gg:CoffeeLot. Both are cross-graph questions, and a
# lot's type lives in the lot's own graph by construction — the same mistake that made
# gg:resolvedProduct unsatisfiable and stopped reviewers correcting resolved bags. A
# structural gate can only honestly check what is in front of it.

gg:CoffeeLotShape
    a sh:NodeShape ;
    sh:targetClass gg:CoffeeLot ;
    rdfs:label "Coffee lot — structural gate" ;

    sh:property [
        sh:path dct:created ;
        sh:datatype xsd:dateTime ;
        sh:minCount 1 ; sh:maxCount 1 ;
    ] ;

    sh:property [
        sh:path gg:lotCode ;
        sh:datatype xsd:string ;
        sh:maxCount 1 ;
        sh:message "A lot carries at most one printed lot code." ;
    ] ;

    sh:property [
        sh:path gg:elevationMinimum ;
        sh:datatype xsd:integer ;
        sh:maxCount 1 ;
        # Coffee is not grown below sea level or above the tree line. A wide range rather
        # than a tight one: the job here is catching a transcription that lost a digit or
        # gained one, not adjudicating agronomy.
        sh:minInclusive 0 ; sh:maxInclusive 4000 ;
        sh:message "Elevation must be metres above sea level, between 0 and 4000." ;
    ] ;

    sh:property [
        sh:path gg:elevationMaximum ;
        sh:datatype xsd:integer ;
        sh:maxCount 1 ;
        sh:minInclusive 0 ; sh:maxInclusive 4000 ;
        sh:message "Elevation must be metres above sea level, between 0 and 4000." ;
    ] ;

    # A range that runs backwards is a transcription error, and it is the one elevation
    # mistake that a bounds check cannot see.
    sh:sparql [
        sh:message "A lot's minimum elevation cannot exceed its maximum." ;
        sh:select """
            SELECT $this WHERE {
                $this <https://grindograph.darkcoff.ee/vocab#elevationMinimum> ?low .
                $this <https://grindograph.darkcoff.ee/vocab#elevationMaximum> ?high .
                FILTER (?low > ?high)
            }
        """ ;
    ] .


gg:PackageVariantShape
    a sh:NodeShape ;
    sh:targetClass gg:PackageVariant ;
    rdfs:label "Package variant — structural gate" ;

    sh:property [
        sh:path dct:created ;
        sh:datatype xsd:dateTime ;
        sh:minCount 1 ; sh:maxCount 1 ;
    ] ;

    sh:property [
        sh:path schema:gtin13 ;
        sh:datatype xsd:string ;
        sh:maxCount 1 ;
        sh:pattern "^[0-9]{8,14}$" ;
        sh:message "A barcode is 8 to 14 digits. A GTIN that is not one settles nothing." ;
    ] ;

    sh:property [
        sh:path gg:netWeight ;
        sh:nodeKind sh:IRI ;
        sh:maxCount 1 ;
        sh:node gg:QuantityShape ;
    ] .


# The same two constraints, reached by the PROPERTY rather than through a typed package.
#
# Necessary because of the graph partition, and a strictly better way to have written them
# in the first place. A package's type lives in its own graph; a weight or a barcode read
# off a listing lives in that document's graph, as a patch on the package's IRI with no
# type beside it. Everything above therefore validated the curated path and silently
# skipped the crawled one — the shape was satisfied by there being nothing to check.
#
# Targeting the property asks the question the constraint actually means: wherever a
# net weight is stated, by anybody, in any graph, it must carry a number and a unit.
gg:AnyNetWeightShape
    a sh:NodeShape ;
    sh:targetObjectsOf gg:netWeight ;
    rdfs:label "Any stated net weight" ;
    sh:node gg:QuantityShape .

gg:AnyBarcodeShape
    a sh:NodeShape ;
    sh:targetSubjectsOf schema:gtin13 ;
    rdfs:label "Anything carrying a barcode" ;
    sh:property [
        sh:path schema:gtin13 ;
        sh:datatype xsd:string ;
        sh:maxCount 1 ;
        sh:pattern "^[0-9]{8,14}$" ;
        sh:message "A barcode is 8 to 14 digits. A GTIN that is not one settles nothing." ;
    ] .


# A quantity with a number and no unit is not a quantity — "250" is not a weight, and a
# consumer comparing package sizes has no way to discover which of grams, ounces and
# kilograms it meant. Both halves or neither.
gg:QuantityShape
    a sh:NodeShape ;
    rdfs:label "Structured quantity — structural gate" ;

    sh:property [
        sh:path schema:value ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:datatype xsd:decimal ;
        # 0.0, not 0. Written bare it parses as xsd:integer, and comparing that against the
        # xsd:decimal the value carries fails the constraint for every well-formed weight.
        sh:minExclusive 0.0 ;
        sh:message "A net weight must carry a positive number." ;
    ] ;

    sh:property [
        sh:path schema:unitCode ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:in ( "GRM" "KGM" "ONZ" "LBR" ) ;
        sh:message "A net weight must carry a UN/CEFACT unit: GRM, KGM, ONZ or LBR." ;
    ] .
