@prefix gg:     <https://grindograph.darkcoff.ee/vocab#> .
@prefix ggx:    <https://grindograph.darkcoff.ee/proposed#> .
@prefix app:    <https://darkcoff.ee/grindhog/app#> .
@prefix rdf:    <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix rdfs:   <http://www.w3.org/2000/01/rdf-schema#> .
@prefix owl:    <http://www.w3.org/2002/07/owl#> .
@prefix sh:     <http://www.w3.org/ns/shacl#> .
@prefix xsd:    <http://www.w3.org/2001/XMLSchema#> .
@prefix dct:    <http://purl.org/dc/terms/> .
@prefix prov:   <http://www.w3.org/ns/prov#> .
@prefix schema: <https://schema.org/> .
@prefix skos:   <http://www.w3.org/2004/02/skos/core#> .
@prefix wd:     <http://www.wikidata.org/entity/> .

<https://grindograph.darkcoff.ee/vocab>
    a owl:Ontology ;
    dct:title "Grindograph vocabulary" ;
    dct:description """Vocabulary for coffee photo submissions, human annotations,
curated ground truth, and schema feedback. The vocabulary deliberately does not model
ML predictions or evaluation. Human observations are retained independently from
canonical coffee facts so that normalization and curation can evolve without rewriting
the original evidence.""" ;
    dct:license <http://creativecommons.org/publicdomain/zero/1.0/> ;
    owl:versionInfo "3" ;
    dct:modified "2026-09-06"^^xsd:date ;
    rdfs:seeAlso <https://darkcoff.ee/grindhog/app#> .

# Version 3 added the off-bag evidence layer: gg:SourceDocument, gg:DocumentReading and
# gg:quotedText, with gg:Reading factored out above gg:HumanAnnotation so that reading a web
# page and reading a photograph are the same shape with different artifacts underneath. It
# is additive — every version 2 triple still says what it said.
#
# Version 2 gave the graph its missing strata: gg:CoffeeLot and gg:PackageVariant, for the
# properties that change while a product's name does not. Version 1 hung origin, process,
# variety and a barcode off gg:CoffeeProduct, which made two crops of one coffee a single
# node whose readings overwrote each other.
#
# The namespace IRI is NOT versioned, deliberately. A term's IRI is its identity, and
# minting grindograph/vocab/2# would orphan every triple already published against the
# first. Terms are retired in place instead — owl:deprecated with dct:isReplacedBy — so a
# consumer holding an old IRI is told where it went rather than getting a 404.


# ============================================================================
# Canonical coffee identities
# ============================================================================

gg:CoffeeProduct
    a rdfs:Class ;
    rdfs:subClassOf schema:Product ;
    rdfs:label "Coffee product" ;
    rdfs:comment """A reusable marketed coffee identity, independent of any particular
photo or submission. A product may exist across many roast dates and package photos.""" .

# Compatibility bridge: existing Grindhog app:Bean resources can continue to be used
# as canonical coffee identities while the domain model evolves.
#
# Note this is a statement about SOMEBODY ELSE'S vocabulary. app:Bean is defined in
# Grindhog, not here, and asserting a superclass for it from this file is a third-party
# claim. That is legitimate in RDF and is the cheapest available bridge, but it is a
# deliberate choice rather than an oversight: if Grindhog later gives app:Bean semantics
# incompatible with gg:CoffeeProduct, this triple is where the conflict will surface.
app:Bean
    rdfs:subClassOf gg:CoffeeProduct .

gg:declaredRoastDegree
    a rdf:Property ;
    rdfs:label "declared roast degree" ;
    rdfs:domain gg:CoffeeProduct ;
    rdfs:comment """The roast degree the LABEL DECLARES, read off the package.

Grindograph's own term rather than Grindhog's app:typicalRoastDegree, which is the one
borrowed field whose name was actually wrong. Nothing here is "typical": a bag printing
"ROAST: MEDIUM" is the roaster making a claim about that product, and no value in this
corpus is derived from historical aggregation or from anything off the label. Every other
app: term — process, originCountry, variety, region, roaster, name — says what it means and
is reused unchanged, because reusing a vocabulary is worth more than owning one.

"Light", "medium" and "dark" are not standardised physical measurements, and no two roasters
agree on where the boundaries fall. This records a CLAIM, not a measurement, so an
instrumental roast-colour reading — should one ever be added — must be a different property
rather than more values for this one.""" .

gg:CoffeeLot
    a rdfs:Class ;
    rdfs:label "Coffee lot" ;
    rdfs:comment """The green coffee a product was made from: origin, region, farm, process,
variety, species, elevation, harvest.

Separate from gg:CoffeeProduct because these are precisely the properties that CHANGE while
a product's name and roaster do not. A roaster sells "Ethiopia Guji" again next year from a
different washing station with a different process, and with no node for the thing that
varies, both readings land on one property of one resource and one of them silently wins.

Identity here is the delicate part, and getting it wrong accretes false merges that look
like signal. A printed LOT CODE settles it. Matching origin, process and variety settles
NOTHING — two bags reading Colombia / Huila / Washed / Caturra are not thereby one lot, and
nothing printed on either lets anybody decide. So a lot is minted per curated scan and
merged only when a curator says so, that being the reversible direction: a retired lot keeps
its graph, keeps resolving, and carries gg:supersededBy. This is the rule the identity layer
already applies to coffee names, applied one stratum down.""" .

gg:PackageVariant
    a rdfs:Class ;
    rdfs:subClassOf schema:Product ;
    rdfs:label "Package variant" ;
    rdfs:comment """One sellable package of a coffee product — a 250 g bag as distinct from
a 1 kg bag of the same coffee.

This class exists because a GTIN identifies a TRADE ITEM, not a product line. Carrying one
on gg:CoffeeProduct asserts that a coffee has at most one package, which is false for most
roasters, and it was enforced as a uniqueness constraint before this class existed.""" .

gg:usesLot
    a rdf:Property ;
    rdfs:label "uses lot" ;
    rdfs:domain gg:CoffeeProduct ;
    rdfs:range gg:CoffeeLot ;
    rdfs:comment """The green coffee this product is made from.

Multi-valued, and not as a concession to edge cases: a blend is a product using several
lots, and a product re-released next crop uses another. No cardinality is declared, so a
consumer must expect many.

No declared percentages. A bag printing "60% Brazil / 40% Ethiopia" states them and they can
be recorded; a bag that does not is not evidence of an even split, and nothing here infers
blend ratios.""" .

gg:hasPackage
    a rdf:Property ;
    rdfs:label "has package" ;
    rdfs:domain gg:CoffeeProduct ;
    rdfs:range gg:PackageVariant ;
    rdfs:comment "The sellable packages this product is offered in. Multi-valued: sizes." .

gg:showsPackage
    a rdf:Property ;
    rdfs:label "shows package" ;
    rdfs:domain gg:ScanCase ;
    rdfs:range gg:PackageVariant ;
    rdfs:comment """These photographs are of that package.

A claim about what a scan shows, so it belongs in the SCAN's graph beside gg:resolvedProduct
and for the same reason: somebody concluded it from those images and the provenance is
theirs. Asserting it in the package's graph would state it as a property of the package,
which it is not.""" .

gg:lotCode
    a rdf:Property ;
    rdfs:label "lot code" ;
    rdfs:domain gg:CoffeeLot ;
    rdfs:range xsd:string ;
    rdfs:comment """The lot identifier as printed, where a bag carries one.

The only thing on a label that settles lot identity rather than suggesting it — the
counterpart of a barcode for a package. Bags print it rarely, which is exactly why the
default is to mint a lot per scan rather than to match on anything softer.""" .

gg:species
    a rdf:Property ;
    rdfs:label "species" ;
    rdfs:domain gg:CoffeeLot ;
    rdfs:comment """The botanical species: Coffea arabica, Coffea canephora.

Separate from variety, and the separation is not pedantry. "Arabica" is a species and
"Caturra" is a cultivar of one; a schema with a single field for both makes "100% Arabica"
and "Caturra" look like competing answers to one question when they answer two, and makes
the pair — which is what a careful bag prints — unstateable.

Bags print species far less often than roasters assume, so an absent species is the ordinary
case and must never be read as canephora.""" .

gg:elevationMinimum
    a rdf:Property ;
    rdfs:label "elevation minimum" ;
    rdfs:domain gg:CoffeeLot ;
    rdfs:range xsd:integer ;
    rdfs:comment """Lower bound of the growing elevation in metres above sea level.

Bags print "1,400-1,700 masl" far more often than a single figure, because a lot aggregates
plots at different heights. Collapsing that to one number invents a precision the label does
not have and loses the range, which is the informative part. Where a bag prints one figure,
it is recorded as both bounds.""" .

gg:elevationMaximum
    a rdf:Property ;
    rdfs:label "elevation maximum" ;
    rdfs:domain gg:CoffeeLot ;
    rdfs:range xsd:integer ;
    rdfs:comment "Upper bound of the growing elevation in metres above sea level." .

gg:harvest
    a rdf:Property ;
    rdfs:label "harvest" ;
    rdfs:domain gg:CoffeeLot ;
    rdfs:range xsd:string ;
    rdfs:comment """The harvest or crop year, as printed.

A string, deliberately, and not xsd:gYear. Bags print "2024/25", "2023-24 crop" and "October
2024" for this, and a crop season crossing a year boundary is not a date — forcing one would
either discard half the information or invent a precision nobody wrote.""" .

gg:netWeight
    a rdf:Property ;
    rdfs:label "net weight" ;
    rdfs:domain gg:PackageVariant ;
    rdfs:range schema:QuantitativeValue ;
    rdfs:comment """The package's net weight as a structured quantity, never a string.

"250g", "250 g", "0,25 kg" and "8.8 oz" are four spellings and two units of one fact, and a
free-text field makes every one of them a distinct value to a query. schema:QuantitativeValue
with schema:value and schema:unitCode is reused rather than minted, because any consumer of
this dataset already has tooling for it.

What the bag printed stays in gg:rawValue on the field annotation, as it does for every other
field. This is the normalisation, not a replacement for the reading.""" .

gg:Roaster
    a rdfs:Class ;
    rdfs:subClassOf schema:Organization ;
    rdfs:label "Roaster" ;
    rdfs:comment "A canonical roasting organization identity." .

gg:roastedBy
    a rdf:Property ;
    rdfs:subPropertyOf schema:manufacturer ;
    rdfs:label "roasted by" ;
    rdfs:domain gg:CoffeeProduct ;
    rdfs:range gg:Roaster ;
    rdfs:comment """The roaster that markets this coffee.

A subproperty of schema:manufacturer rather than a bare term, so the graph reads correctly
to anything that already understands schema.org products without having to learn gg:. The
specific term is kept because "manufacturer" is not what anybody in this domain calls
it.""" .

gg:withinCountry
    a rdf:Property ;
    rdfs:label "within country" ;
    rdfs:range schema:Country ;
    rdfs:comment """Places a growing region inside a country.

Only needed for regions this project had to mint. A region with a Wikidata item carries its
own containment there and does not need this restated.""" .

gg:supersededBy
    a rdf:Property ;
    rdfs:label "superseded by" ;
    rdfs:comment """Points a retired identity at the one that replaced it.

Identity gets decided from incomplete evidence, so it gets decided wrongly: two coffees
turn out to be one, or one turns out to be two. Both corrections have to be expressible
without breaking an IRI somebody already published against, so a retired resource keeps its
graph and keeps resolving, carrying this pointer.

Deliberately NOT owl:sameAs. That is symmetric, so it names no winner and gives a consumer
no way to pick the IRI to display; under OWL semantics it also merges everything said about
both resources, which is precisely wrong when the merge is the mistake being corrected. And
a split — one identity superseded by two — cannot be stated with sameAs at all without
asserting that the two results are each other.""" .


# ============================================================================
# Scan evidence
# ============================================================================

gg:ScanCase
    a rdfs:Class ;
    rdfs:subClassOf prov:Entity ;
    rdfs:label "Scan case" ;
    rdfs:comment """One contributed coffee-identification case: one physical package or
coffee presentation, one or more images, contributor provenance, and zero or more human
annotations. A scan case may remain unresolved; difficult or incomplete submissions are
still useful data and must not be rejected merely because no coffee can yet be named.""" .

gg:Image
    a rdfs:Class ;
    rdfs:subClassOf schema:ImageObject, prov:Entity ;
    rdfs:label "Image" ;
    rdfs:comment "A first-class image resource belonging to a scan case." .

gg:hasImage
    a rdf:Property ;
    rdfs:label "has image" ;
    rdfs:domain gg:ScanCase ;
    rdfs:range gg:Image .

gg:sha256
    a rdf:Property ;
    rdfs:label "SHA-256" ;
    rdfs:range xsd:string ;
    rdfs:comment """Lower-case hexadecimal SHA-256 digest of the bytes a resource stands
for: a published image, or the text of a source document as it was retrieved.

No rdfs:domain, for the reason gg:observationState gives. Naming gg:Image here would make
every gg:SourceDocument carrying a digest be inferred an image, and the intended domain is
the union of two classes — a validation concern, handled in the shapes.""" .

gg:mediaType
    a rdf:Property ;
    rdfs:label "media type" ;
    rdfs:range xsd:string ;
    rdfs:comment """IANA media type such as image/jpeg, image/webp or text/html. No
rdfs:domain, for the same reason as gg:sha256.""" .

gg:bundlePath
    a rdf:Property ;
    rdfs:label "bundle path" ;
    rdfs:domain gg:Image ;
    rdfs:range xsd:string ;
    rdfs:comment """Relative path of the published image inside an exported release
bundle. Operational/private storage URLs should not be used as semantic identifiers.""" .

gg:pixelWidth
    a rdf:Property ;
    rdfs:label "pixel width" ;
    rdfs:domain gg:Image ;
    rdfs:range xsd:integer .

gg:pixelHeight
    a rdf:Property ;
    rdfs:label "pixel height" ;
    rdfs:domain gg:Image ;
    rdfs:range xsd:integer .


# ============================================================================
# Off-bag evidence: source documents
#
# A photograph of a bag is not the only artifact a coffee fact can be read from. A
# roaster's own listing states an origin and a process the label may not print, and a
# retailer's page states what is for sale. Both are evidence, and neither is a bag.
#
# Two things follow, and they are the whole reason this section is separate from scan
# evidence rather than folded into it.
#
# A document is somebody else's. Nobody granted this project a licence over a roaster's
# web page, so a gg:SourceDocument carries no dct:license and the statements read from it
# are not in the CC0 release. The grant is a contributor's to give, per contribution, and
# it covers the photographs they took and what they wrote about them. The shapes enforce
# the absence rather than trusting anyone to remember it.
#
# A document can be checked. A photograph has to be looked at by a person; a page can be
# re-fetched and searched. So every field read off a document carries gg:quotedText, the
# fragment that supports it, and gg:sha256 pins the text those fragments were taken from.
# That is what lets a stranger verify a claim instead of trusting the reader — which is
# the same standard the corpus holds a bag reading to, reached by a different route.
# ============================================================================

gg:SourceDocument
    a rdfs:Class ;
    rdfs:subClassOf prov:Entity ;
    rdfs:label "Source document" ;
    rdfs:comment """A retrieved web document that coffee facts were read from, identified
by its canonical URL, the digest of its text as retrieved, and when that happened.

The text itself is deliberately not here. It is a copy of somebody else's page, and
publishing it is not this project's to do; the URL, the digest and the timestamp are
enough for anyone to fetch it and check every quotation against it. A page that has
changed since gives a different digest, which is the honest answer rather than a silent
mismatch.

Identity is (canonical URL, digest). The same page fetched twice unchanged is one
document with two retrieval times; changed, it is a new document linked to the old with
prov:wasRevisionOf. A listing URL is a document and never a coffee — roasters reuse a URL
for next year's crop, and two URLs routinely describe one coffee.""" .

gg:retrievedAt
    a rdf:Property ;
    rdfs:label "retrieved at" ;
    rdfs:domain gg:SourceDocument ;
    rdfs:range xsd:dateTime ;
    rdfs:comment """When this document was fetched. Repeatable: an unchanged page fetched
again adds a retrieval time rather than minting a second document, so the property records
sightings and the digest records the content.""" .

gg:DocumentReading
    a rdfs:Class ;
    rdfs:subClassOf gg:Reading ;
    rdfs:label "Document reading" ;
    rdfs:comment """One reader's interpretation of one source document — usually a model's,
sometimes a curator's. It has exactly the standing a human annotation has, which is none
until somebody signs it, and it says who read it with prov:wasAttributedTo and what read it
with gg:proposedBy.

Every field annotation under it must carry gg:quotedText. A document reading with no
quotation is a machine's summary of a page, which is a different and much weaker thing than
a transcription, and the shapes refuse it.""" .

gg:quotedText
    a rdf:Property ;
    rdfs:label "quoted text" ;
    rdfs:range xsd:string ;
    rdfs:comment """The verbatim fragment of a source document that supports one field
annotation's raw value. Not a summary, not a translation, not a paraphrase: a substring,
with the raw value inside it.

This is the term the off-bag layer turns on. Without it a crawled fact asks to be trusted;
with it, anyone holding the digest can fetch the page and see the sentence for themselves,
and can tell a transcription from something a model inferred between two paragraphs.

No rdfs:domain, for the reason gg:normalizedValue gives — the property belongs on a field
annotation made from a document, and naming gg:FieldAnnotation would say every field
annotation may carry one, which is exactly what the shapes must be free to deny.""" .


# ============================================================================
# Human annotations
# ============================================================================

gg:Reading
    a rdfs:Class ;
    rdfs:subClassOf prov:Entity ;
    rdfs:label "Reading" ;
    rdfs:comment """Somebody's interpretation of one artifact, field by field. It is the
shape every claim in this dataset arrives in, and the artifact read decides nothing about
its standing: a reading is evidence, never truth, whoever or whatever produced it.

Two subclasses, because two kinds of artifact carry coffee facts and they are not
interchangeable. A gg:HumanAnnotation reads a photograph of a bag, which is what the
contribution rule is about — type only what is printed. A gg:DocumentReading reads a web
page, which is off-bag by definition and carries the quotation that justifies it.

Consumers should ask what a reading annotates rather than which subclass it is: that is
the distinction that survives, and it is the one the licensing follows.""" .

gg:HumanAnnotation
    a rdfs:Class ;
    rdfs:subClassOf gg:Reading ;
    rdfs:label "Human annotation" ;
    rdfs:comment """One person's interpretation of a scan case. Human annotations are
source evidence, not automatically ground truth. Preserve them even when later curation
or normalization disagrees with them.""" .

gg:annotates
    a rdf:Property ;
    rdfs:label "annotates" ;
    rdfs:domain gg:Reading ;
    rdfs:comment """The artifact a reading was made from: a gg:ScanCase for a human
annotation, a gg:SourceDocument for a document reading.

No rdfs:range, deliberately, and the domain is the superclass rather than
gg:HumanAnnotation. Naming either concrete class would make every reading of the other
kind be inferred a member of it — the failure gg:observationState and gg:normalizedValue
already avoid. Which pairings are legal is a validation concern, and the shapes state
it.""" .

gg:hasAnnotation
    a rdf:Property ;
    rdfs:label "has annotation" ;
    rdfs:domain gg:ScanCase ;
    rdfs:range gg:HumanAnnotation ;
    owl:inverseOf gg:annotates .

gg:FieldAnnotation
    a rdfs:Class ;
    rdfs:subClassOf prov:Entity ;
    rdfs:label "Field annotation" ;
    rdfs:comment """A human annotation for one semantic field. It records the field IRI,
the literal text entered/read by the annotator, an optional normalized value, and an
optional explicit observation state. No image region or bounding box is required: the
scan case as a whole is the evidence.""" .

gg:hasFieldAnnotation
    a rdf:Property ;
    rdfs:label "has field annotation" ;
    rdfs:domain gg:Reading ;
    rdfs:range gg:FieldAnnotation .

gg:field
    a rdf:Property ;
    rdfs:label "field" ;
    rdfs:domain gg:FieldAnnotation ;
    rdfs:comment "The semantic property being annotated, for example app:process." .

gg:rawValue
    a rdf:Property ;
    rdfs:label "raw value" ;
    rdfs:domain gg:FieldAnnotation ;
    rdfs:range xsd:string ;
    rdfs:comment """The annotator's literal transcription or entry. Preserve this even
when a normalized value is also available.""" .

gg:normalizedValue
    a rdf:Property ;
    rdfs:label "normalized value" ;
    rdfs:comment """Optional normalized value for the annotated field. It may be a
literal or an IRI, depending on the canonical field model. Normalization is explicitly
separate from the raw human observation.

Where a field has a concept scheme, this is the entity: gg:rawValue keeps "Nat. Anaerobic"
verbatim forever and this points at the concept. That split is what lets the corpus be
re-reconciled later without asking anyone to look at a photograph again.

No rdfs:domain, for the same reason gg:observationState has none: curation needs to state a
normalized value too, and naming gg:FieldAnnotation here would make every gg:TruthField
carrying one be inferred a field annotation.""" .

gg:ObservationState
    a rdfs:Class ;
    rdfs:label "Observation state" ;
    rdfs:comment "Explicit state used only when omission alone would be ambiguous." .

gg:Observed
    a gg:ObservationState ;
    rdfs:label "Observed" .

gg:NotVisible
    a gg:ObservationState ;
    rdfs:label "Not visible" ;
    rdfs:comment "The annotation task explicitly checked this field and it could not be established from the submitted image set." .

gg:Uncertain
    a gg:ObservationState ;
    rdfs:label "Uncertain" ;
    rdfs:comment "The annotator supplied or considered a value but was not confident enough to treat it as unambiguous." .

gg:observationState
    a rdf:Property ;
    rdfs:label "observation state" ;
    rdfs:range gg:ObservationState ;
    rdfs:comment """Whether a field could be established from the scan's image set.

Usable on gg:FieldAnnotation (what one annotator could establish) and on gg:TruthField
(what curation accepts). Curated absence has to be statable: an extraction test case
needs to distinguish "the images do not show this, so an extractor must not invent a
value" from "nobody was asked about this field, so it is not scored". Without it on
TruthField the first case can only be encoded as a TruthField carrying no value, which
is a trick rather than a statement.

No rdfs:domain, deliberately. Naming one class would make every resource using the
property be inferred a member of it; the intended domain is the union of two classes and
that is a validation concern, handled in the shapes.""" .

gg:resolvedProduct
    a rdf:Property ;
    rdfs:label "resolved product" ;
    rdfs:range gg:CoffeeProduct ;
    rdfs:comment """Links an annotation or curated ground truth to a canonical coffee
product identity. It is optional because unresolved scans are valid.""" .



# ============================================================================
# Reconciliation
#
# Bags print the same thing many ways. "Nat. Anaerobic", "anaerobic natural" and
# "Anaerobico" are one process; "La Cabra", "LA CABRA" and "La Cabra Coffee Roasters" are
# one roaster. Reconciliation is what turns those strings into shared nodes, and it is the
# difference between a pile of transcriptions and a graph anybody can query.
#
# The controlled vocabularies themselves are plain SKOS — skos:Concept in a
# skos:ConceptScheme, with every surface form found on a bag kept as a skos:altLabel, and
# skos:exactMatch out to Wikidata where an item exists. Nothing is minted here for that;
# reusing SKOS is the whole point, since a consumer already has tooling for it.
#
# What SKOS does not carry is the audit trail: which raw string was mapped, by whom, and
# whether a machine suggested it. That is what gg:ValueMapping is for, and it is kept as a
# resource of its own rather than as reification so it can be queried, revoked and
# published like anything else.
# ============================================================================

gg:ValueMapping
    a rdfs:Class ;
    rdfs:label "Value mapping" ;
    rdfs:comment """A decision that one raw bag string denotes one concept.

Keyed on the string rather than on any single annotation, so approving a mapping resolves
every occurrence of that string at once — past, present and future. Reconciliation is a
mapping over the value space, not a pass over rows.

Only approved mappings exist as resources. A machine's suggestion is not one, for the same
reason a model's reading of a bag is not an annotation: it is a guess about the data, and
the graph has to keep saying which claims are which.""" .

gg:mapsRawValue
    a rdf:Property ;
    rdfs:label "maps raw value" ;
    rdfs:domain gg:ValueMapping ;
    rdfs:range xsd:string ;
    rdfs:comment "The raw string this mapping resolves, exactly as it was transcribed." .

gg:mapsToConcept
    a rdf:Property ;
    rdfs:label "maps to concept" ;
    rdfs:domain gg:ValueMapping ;
    rdfs:range skos:Concept ;
    rdfs:comment "The concept the raw string denotes." .

gg:proposedBy
    a rdf:Property ;
    rdfs:label "proposed by" ;
    rdfs:domain gg:ValueMapping ;
    rdfs:range xsd:string ;
    rdfs:comment """The model that suggested this mapping, where one did.

Present so a consumer can tell a mapping a person made unaided from one a person approved
after a machine proposed it. Those are different levels of evidence and the dataset should
not quietly flatten them. Absent means nobody suggested it — a curator wrote it.

The curator who approved it is on prov:wasAttributedTo either way. A proposal nobody
approved never becomes a gg:ValueMapping at all.""" .


# ============================================================================
# Curated ground truth
# ============================================================================

gg:GroundTruth
    a rdfs:Class ;
    rdfs:subClassOf prov:Entity ;
    rdfs:label "Ground truth" ;
    rdfs:comment """A curated interpretation of a scan case, produced from one or more
human annotations. Ground truth belongs in Grindograph because curation is part of the
knowledge-building workflow. It is deliberately independent of any ML evaluation or
prediction vocabulary.""" .

gg:groundTruthFor
    a rdf:Property ;
    rdfs:label "ground truth for" ;
    rdfs:domain gg:GroundTruth ;
    rdfs:range gg:ScanCase .

gg:hasGroundTruth
    a rdf:Property ;
    rdfs:label "has ground truth" ;
    rdfs:domain gg:ScanCase ;
    rdfs:range gg:GroundTruth ;
    owl:inverseOf gg:groundTruthFor .

gg:currentGroundTruth
    a rdf:Property ;
    rdfs:label "current ground truth" ;
    rdfs:domain gg:ScanCase ;
    rdfs:range gg:GroundTruth ;
    rdfs:comment """Points to the currently accepted curated snapshot. Historical ground
truth resources remain linked with gg:hasGroundTruth and may be connected with
prov:wasRevisionOf.""" .

gg:basedOnAnnotation
    a rdf:Property ;
    rdfs:label "based on annotation" ;
    rdfs:domain gg:GroundTruth ;
    rdfs:range gg:HumanAnnotation ;
    rdfs:subPropertyOf prov:wasDerivedFrom .

gg:TruthField
    a rdfs:Class ;
    rdfs:subClassOf prov:Entity ;
    rdfs:label "Ground-truth field" ;
    rdfs:comment "A curated canonical value for one semantic field of a scan case." .

gg:hasTruthField
    a rdf:Property ;
    rdfs:label "has ground-truth field" ;
    rdfs:domain gg:GroundTruth ;
    rdfs:range gg:TruthField .

gg:truthField
    a rdf:Property ;
    rdfs:label "ground-truth field" ;
    rdfs:domain gg:TruthField ;
    rdfs:comment "The canonical property this ground-truth value describes." .

gg:truthValue
    a rdf:Property ;
    rdfs:label "ground-truth value" ;
    rdfs:domain gg:TruthField ;
    rdfs:comment "The curated value, as either an RDF literal or IRI." .

gg:basedOnFieldAnnotation
    a rdf:Property ;
    rdfs:label "based on field annotation" ;
    rdfs:domain gg:TruthField ;
    rdfs:range gg:FieldAnnotation ;
    rdfs:subPropertyOf prov:wasDerivedFrom .


# ============================================================================
# Annotation / curation status
# ============================================================================

gg:AnnotationStatus
    a rdfs:Class ;
    rdfs:label "Annotation status" .

gg:Draft
    a gg:AnnotationStatus ;
    rdfs:label "Draft" .

gg:Submitted
    a gg:AnnotationStatus ;
    rdfs:label "Submitted" ;
    rdfs:comment "A reading as contributed. The ordinary state of evidence." .

gg:Reviewed
    a gg:AnnotationStatus ;
    rdfs:label "Reviewed" ;
    rdfs:comment """A reading made by a reviewer, whether taken blind from the queue or recorded to correct a contributor's. It is preferred at curation, which is what makes a correction take effect without editing the reading it corrects: the two sit side by side, attributed and timestamped, and which corrects which follows from that. A reviewer already shown a machine reading of the scan is not reading blind, so a Reviewed annotation is never scored against the machine.""" .

gg:Superseded
    a gg:AnnotationStatus ;
    rdfs:label "Superseded" .

gg:readBlind
    a rdf:Property ;
    rdfs:label "read blind" ;
    rdfs:domain gg:HumanAnnotation ;
    rdfs:range xsd:boolean ;
    rdfs:comment """True when the annotator had not been shown any machine reading of this
scan before writing theirs.

This is the property the BENCHMARK is made of, and it is deliberately separate from
gg:annotationStatus, which records who wrote a reading rather than what they had seen. A
reviewer usually reads blind — the queue withholds the machine's answer from anybody who
still can — so treating "Reviewed" as a proxy for "primed" would discard most of the
corpus's best readings, while treating it as a proxy for "blind" would admit exactly the
ones that must not count.

Grindograph publishes two things from one set of photographs, and they have different
admission rules. The GRAPH needs a claim somebody is accountable for; whether they had seen
a machine's guess first does not change what the bag says. The BENCHMARK needs a claim
nobody could have copied, because "the model got this wrong" means nothing otherwise. One
ground-truth snapshot served both until this term existed, which quietly let a reviewer's
primed correction become a test case scored against the model that primed it.

False is not a defect. It marks a reading that is good evidence about a coffee and unusable
as an answer key, which is an ordinary and useful thing to be.""" .

gg:annotationStatus
    a rdf:Property ;
    rdfs:label "annotation status" ;
    rdfs:domain gg:HumanAnnotation ;
    rdfs:range gg:AnnotationStatus .


# ============================================================================
# Schema feedback apparatus
# ============================================================================

gg:SchemaNote
    a rdfs:Class ;
    rdfs:label "Schema note" ;
    rdfs:comment """A structured objection to the current coffee schema, raised while
annotating a specific scan case. Every objection remains traceable to the photographic
evidence that provoked it.""" .

gg:target
    a rdf:Property ;
    rdfs:label "target" ;
    rdfs:domain gg:SchemaNote ;
    rdfs:comment "The property the note is about (an app: term, gg: term, or proposed ggx: term)." .

gg:kind
    a rdf:Property ;
    rdfs:label "kind" ;
    rdfs:domain gg:SchemaNote ;
    rdfs:range gg:NoteKind .

gg:detail
    a rdf:Property ;
    rdfs:label "detail" ;
    rdfs:domain gg:SchemaNote ;
    rdfs:range xsd:string .

gg:provokedBy
    a rdf:Property ;
    rdfs:label "provoked by" ;
    rdfs:domain gg:SchemaNote ;
    rdfs:range gg:ScanCase .

gg:hasNote
    a rdf:Property ;
    rdfs:label "has note" ;
    rdfs:domain gg:ScanCase ;
    rdfs:range gg:SchemaNote ;
    owl:inverseOf gg:provokedBy .

gg:NoteKind
    a rdfs:Class ;
    rdfs:label "Note kind" .

gg:MissingEnumValue
    a gg:NoteKind ;
    rdfs:label "Missing enum value" ;
    rdfs:comment "The allowed value list does not contain what the package says." .

gg:NeedsMultipleValues
    a gg:NoteKind ;
    rdfs:label "Needs multiple values" ;
    rdfs:comment "The evidence contains several values where the current schema admits one." .

gg:Ambiguous
    a gg:NoteKind ;
    rdfs:label "Ambiguous" ;
    rdfs:comment "The package wording does not map cleanly onto this property." .

gg:ShouldSplit
    a gg:NoteKind ;
    rdfs:label "Should split" ;
    rdfs:comment "The property conflates concepts that coffee packaging reports separately." .

gg:NotOnBags
    a gg:NoteKind ;
    rdfs:label "Not on bags" ;
    rdfs:comment "Rarely present in package evidence; a candidate for demotion from annotation UI/schema." .

gg:ProposeField
    a gg:NoteKind ;
    rdfs:label "Propose field" ;
    rdfs:comment "A useful field the current schema lacks." .


gg:ShapeProposal
    a rdfs:Class ;
    rdfs:label "Shape proposal" ;
    rdfs:comment """A contributor proposal for how a field should be modelled. Proposal
terms deliberately do NOT use sh:path/sh:datatype/etc directly: doing so would make the
proposal itself executable SHACL. Accepted proposals can be compiled into SHACL by a
separate generator.""" .

gg:proposalFor
    a rdf:Property ;
    rdfs:label "proposal for" ;
    rdfs:domain gg:ShapeProposal ;
    rdfs:comment "The class whose future shape the proposal concerns." .

gg:proposedPath
    a rdf:Property ;
    rdfs:label "proposed path" ;
    rdfs:domain gg:ShapeProposal .

gg:proposedDatatype
    a rdf:Property ;
    rdfs:label "proposed datatype" ;
    rdfs:domain gg:ShapeProposal .

gg:proposedMaxCount
    a rdf:Property ;
    rdfs:label "proposed max count" ;
    rdfs:domain gg:ShapeProposal ;
    rdfs:range xsd:integer .

gg:proposedNodeKind
    a rdf:Property ;
    rdfs:label "proposed node kind" ;
    rdfs:domain gg:ShapeProposal .


# ============================================================================
# Validation apparatus
# ============================================================================

gg:ValidationReport
    a rdfs:Class ;
    rdfs:subClassOf sh:ValidationReport ;
    rdfs:label "Validation report" ;
    rdfs:comment """A persisted SHACL validation report for a scan/annotation/curation
resource against a particular released shape version. Domain-schema violations may be
kept as evidence; structural corruption should still be rejected at ingestion.""" .

gg:validatedAgainst
    a rdf:Property ;
    rdfs:label "validated against" ;
    rdfs:domain gg:ValidationReport ;
    rdfs:comment "Version identifier or IRI of the shape release used for validation." .

gg:reportFor
    a rdf:Property ;
    rdfs:label "report for" ;
    rdfs:domain gg:ValidationReport ;
    rdfs:comment "The resource this validation report concerns." .


# ============================================================================
# Proposed-term quarantine
# ============================================================================

<https://grindograph.darkcoff.ee/proposed>
    a owl:Ontology ;
    rdfs:label "Grindograph proposed terms" ;
    rdfs:comment """Quarantine namespace for candidate domain terms. Proposed terms may
appear in contributed data but are not treated as endorsed vocabulary until promoted.
Promotion should preserve already-published data with explicit equivalence/mapping.""" .
