From 830e17f339fd8ff3e69d94658902cb49194a8417 Mon Sep 17 00:00:00 2001 From: DarrenZal Date: Fri, 5 Jun 2026 08:20:32 -0700 Subject: [PATCH] feat(claims): provisional SHACL validation infra + alignment gap analysis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the SHACL-validation scaffolding for claims, OFF by default and NOT yet wired into create_claim — because the koi-processor rfs:Claim JSON-LD (flat keys: claimant_uri/claim_type/statement) does not match the PR #53 LinkML rfs:Claim slot vocabulary (hasClaimant/hasSubject/hasPrimaryImpact/...). A runnable proof demonstrates the gap: an LinkML-slot-aligned claim CONFORMS; today's flat-key claim fails with sh:closed violations. - schema/shacl/claim.shacl.ttl: shape generated from regen-data-standards PR #53 Claim.yaml (linkml ShaclGenerator; provisional until #53 merges) - api/shacl_validation.py: pyshacl wrapped in asyncio.to_thread (never block the single-worker event loop), fail-closed on missing shape, gated by VALIDATE_CLAIMS_SHACL (default false) - docs/claims/shacl-alignment-gap.md: the gap, the proof, two fix options, exact create_claim wiring point - tests/test_shacl_claim_shape.py: codifies the proof (3 tests, green) - requirements.txt: pyshacl>=0.27, rdflib>=7.0 Review branch only — do NOT merge to regen-prod (auto-deploys to personal-koi). Co-Authored-By: Claude Opus 4.8 --- api/shacl_validation.py | 69 +++++++ docs/claims/shacl-alignment-gap.md | 59 ++++++ requirements.txt | 7 + schema/shacl/claim.shacl.ttl | 280 +++++++++++++++++++++++++++++ tests/test_shacl_claim_shape.py | 75 ++++++++ 5 files changed, 490 insertions(+) create mode 100644 api/shacl_validation.py create mode 100644 docs/claims/shacl-alignment-gap.md create mode 100644 schema/shacl/claim.shacl.ttl create mode 100644 tests/test_shacl_claim_shape.py diff --git a/api/shacl_validation.py b/api/shacl_validation.py new file mode 100644 index 00000000..ce986cc3 --- /dev/null +++ b/api/shacl_validation.py @@ -0,0 +1,69 @@ +"""SHACL validation for claims (OFF by default). + +Validates a claim's RDF representation against the FWG LinkML-derived SHACL +shape (``schema/shacl/claim.shacl.ttl``, generated from regen-data-standards +``Claim.yaml``). + +Two hard design constraints, both learned the hard way: + +1. **Never block the event loop.** The personal koi-processor runs as a + single-worker service; a synchronous ``pyshacl.validate()`` on the hot + ``POST /claims/`` path can starve the asyncpg pool and take the whole + service down. All validation runs in ``asyncio.to_thread()``. + +2. **Fail closed, not silent.** If validation is enabled but the shape file is + missing, raise — do not silently accept unvalidated claims (a silent skip + gives false confidence). + +STATUS (2026-06-05): this module is **not yet wired into create_claim**. The +koi-processor claim JSON-LD (flat keys: ``claimant_uri``/``claim_type``/ +``statement``) does not match the LinkML ``rfs:Claim`` slot vocabulary +(``hasClaimant``/``hasSubject``/``hasPrimaryImpact``/...). Enabling validation +requires aligning the claim representation first. See +``docs/claims/shacl-alignment-gap.md``. +""" +from __future__ import annotations + +import asyncio +import os +from pathlib import Path + +# schema/shacl/claim.shacl.ttl relative to repo root (this file is api/shacl_validation.py) +_DEFAULT_SHAPE = Path(__file__).resolve().parent.parent / "schema" / "shacl" / "claim.shacl.ttl" + + +def shacl_enabled() -> bool: + """True only when VALIDATE_CLAIMS_SHACL is explicitly 'true'. Default off.""" + return os.getenv("VALIDATE_CLAIMS_SHACL", "false").strip().lower() == "true" + + +def _validate_sync(data_ttl: str, shape_path: str) -> tuple[bool, str]: + """Blocking validation — only ever called via asyncio.to_thread().""" + import rdflib # imported lazily so the module loads even if deps are absent + from pyshacl import validate + + data_graph = rdflib.Graph().parse(data=data_ttl, format="turtle") + shape_graph = rdflib.Graph().parse(shape_path, format="turtle") + conforms, _results_graph, results_text = validate( + data_graph, + shacl_graph=shape_graph, + inference="rdfs", + advanced=True, + meta_shacl=False, + ) + return bool(conforms), results_text + + +async def validate_claim_ttl(data_ttl: str, shape_path: str | os.PathLike | None = None) -> tuple[bool, str]: + """Validate a claim RDF graph (Turtle) against the claim SHACL shape. + + Returns (conforms, human_readable_report). Runs pyshacl off the event loop. + Raises RuntimeError if the shape file is missing (fail-closed). + """ + shape = Path(shape_path) if shape_path else _DEFAULT_SHAPE + if not shape.exists(): + raise RuntimeError( + f"SHACL shape file not found: {shape} — refusing to skip validation (fail-closed). " + "Check the schema/shacl/ directory is present in the deployed checkout." + ) + return await asyncio.to_thread(_validate_sync, data_ttl, str(shape)) diff --git a/docs/claims/shacl-alignment-gap.md b/docs/claims/shacl-alignment-gap.md new file mode 100644 index 00000000..fd564650 --- /dev/null +++ b/docs/claims/shacl-alignment-gap.md @@ -0,0 +1,59 @@ +# Claims SHACL validation — alignment gap (provisional, 2026-06-05) + +**Status:** infrastructure landed; **validation NOT yet wired into `create_claim`** because the koi-processor claim representation is not aligned with the canonical LinkML `rfs:Claim` shape. This doc records the gap with an executable proof and the two ways to close it. + +## What landed on this branch (`darren/claims-shacl-validation`) + +- `schema/shacl/claim.shacl.ttl` — SHACL shape generated from regen-data-standards **PR #53** `Claim.yaml` (`linkml` `ShaclGenerator`, 419 triples). *Provisional:* PR #53 is unmerged (`upstream/pr-53-head`); regenerate when it merges. +- `api/shacl_validation.py` — validation helper. Runs `pyshacl` inside `asyncio.to_thread()` (never block the single-worker event loop), **fail-closed** if the shape file is missing, gated by `VALIDATE_CLAIMS_SHACL` (default **false**). +- `requirements.txt` — adds `pyshacl`, `rdflib`. +- `tests/test_shacl_claim_shape.py` — codifies the proof below. + +## The gap + +koi-processor already emits an `rfs:Claim` JSON-LD (used for content hashing — `claims_router.py` `_canonical_json` ~line 392 and the proof-pack ~line 2492): + +```json +{ "@context": "https://framework.regen.network/schema/", "@type": "rfs:Claim", + "claimant_uri": "...", "claim_type": "ecological", "statement": "...", "about_uri": "..." } +``` + +The PR #53 LinkML `rfs:Claim` shape is **`sh:closed`** and requires structured slots: +`schema:name`, `rfs:hasClaimType`, `rfs:verificationStatus`, `rfs:hasClaimant` (Entity), `rfs:hasSubject` (Entity), `rfs:hasPrimaryImpact` (Impact). Nested `Entity`/`Impact` have their own required slots + controlled-vocabulary enums. + +Same `@type` IRI, **completely different property vocabulary**. So today's claims fail validation 100% (closed-shape violations on every flat key + every required slot missing). + +## Executable proof (throwaway venv, pyshacl 0.31 / rdflib 7.6) + +``` +shape: 419 triples loaded from claim.shacl.ttl +(A) LinkML-slot-aligned claim: conforms=True +(B) today's koi flat-key claim: conforms=False + Message: Node is closed. It cannot have value: Literal("Soil carbon increased 2 tC/ha/yr") + Message: Node is closed. It cannot have value: Literal("ecological") + Message: Node is closed. It cannot have value: Literal("orn:koi-net.entity:demo") +``` + +(A) passes only when shaped to the LinkML slots with valid enum values (Entity `rfs:type` ∈ {Individual, Organization, Community}; Impact `rfs:hasImpactType` from the ImpactType vocab; `rfs:verificationStatus` ∈ {SelfReported, PeerReviewed, Verified, LedgerAnchored, Withdrawn}). The shape is correct and strict — the toolchain works. + +## Two ways to close the gap (pick upstream, with FWG / Marie) + +1. **Author a JSON-LD `@context`** mapping koi's flat keys → LinkML slot URIs, and synthesize the required structured nodes (claimant/subject as `Entity`, a primary `Impact`, a `verificationStatus`) at validation time. Lets the existing storage model stand; the mapping is the work. **Problem:** `hasSubject` and `hasPrimaryImpact` are *required* but have **no source field** in `ClaimCreateRequest` — they can't be mapped, only invented. So a pure context mapping is insufficient without model changes. +2. **Extend the claim model** (`ClaimCreateRequest` + storage) to carry the LinkML semantics (typed claimant/subject entities, primary impact, verification status) and emit slot-keyed JSON-LD. The faithful fix; larger; should follow PR #53 merging and coordinate with the data-standards alignment work. + +Either way, **SHACL can't be meaningfully enabled until the claim representation aligns.** Until then this stays off-by-default and unwired. + +## When ready to wire (exact spot) + +In `api/routers/claims_router.py::create_claim`, after the `about_uri` validation block (~line 652, before RID generation ~line 655): + +```python +from api.shacl_validation import shacl_enabled, validate_claim_ttl +... +if shacl_enabled(): + conforms, report = await validate_claim_ttl(claim_to_aligned_ttl(body)) # claim_to_aligned_ttl: the gap above + if not conforms: + raise HTTPException(status_code=422, detail=f"Claim failed SHACL validation:\n{report}") +``` + +Do **not** enable `VALIDATE_CLAIMS_SHACL=true` on the live single-worker service until (a) alignment is done and (b) latency is profiled — synchronous validation on the hot path is the documented outage risk (`asyncio.to_thread` mitigates event-loop blocking but pool pressure still applies under load). diff --git a/requirements.txt b/requirements.txt index ffc0f5ed..9f48531a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -102,3 +102,10 @@ scrapling>=0.4 tldextract>=5.0 tiktoken>=0.7 feedparser>=6.0.10 + +# ============================================================================= +# SHACL claim validation (OFF by default — VALIDATE_CLAIMS_SHACL; see +# docs/claims/shacl-alignment-gap.md). pyshacl pulls rdflib. +# ============================================================================= +pyshacl>=0.27 +rdflib>=7.0 diff --git a/schema/shacl/claim.shacl.ttl b/schema/shacl/claim.shacl.ttl new file mode 100644 index 00000000..621fc820 --- /dev/null +++ b/schema/shacl/claim.shacl.ttl @@ -0,0 +1,280 @@ +@prefix qudt: . +@prefix rdf: . +@prefix rfs: . +@prefix rft: . +@prefix schema1: . +@prefix sh: . +@prefix xsd: . + +rfs:Claim a sh:NodeShape ; + sh:closed true ; + sh:description "A verifiable claim about ecological, social, or financial outcomes associated with a place, practice, or project. Claims are the atomic unit of the regenerative claims engine, linking a claimant's assertion to a subject entity with typed impact and temporal bounds." ; + sh:ignoredProperties ( rdf:type ) ; + sh:property [ sh:datatype xsd:string ; + sh:description "Description of the entity / resource." ; + sh:maxCount 1 ; + sh:nodeKind sh:Literal ; + sh:order 1 ; + sh:path schema1:description ], + [ sh:datatype xsd:anyURI ; + sh:description "IRI or RID of a previous claim that this claim supersedes, forming a version chain." ; + sh:maxCount 1 ; + sh:nodeKind sh:Literal ; + sh:order 18 ; + sh:path rfs:supersedes ], + [ sh:description "Current verification state of the claim, from self-reported through ledger-anchored." ; + sh:in ( rft:SelfReported rft:PeerReviewed rft:Verified rft:LedgerAnchored rft:Withdrawn ) ; + sh:maxCount 1 ; + sh:minCount 1 ; + sh:order 9 ; + sh:path rfs:verificationStatus ], + [ sh:datatype xsd:anyURI ; + sh:description "Regen Data Module IRI derived from the content hash. Bridges the off-chain claim to its on-chain anchor." ; + sh:maxCount 1 ; + sh:nodeKind sh:Literal ; + sh:order 17 ; + sh:path rfs:dataIri ], + [ sh:datatype xsd:date ; + sh:description "The end date of the period covered by this claim." ; + sh:maxCount 1 ; + sh:nodeKind sh:Literal ; + sh:order 11 ; + sh:path schema1:endDate ], + [ sh:class rfs:Impact ; + sh:description "Secondary impacts or co-benefits associated with the claim." ; + sh:nodeKind sh:BlankNodeOrIRI ; + sh:order 8 ; + sh:path rfs:hasCoBenefits ], + [ sh:datatype xsd:date ; + sh:description "The start date of the period covered by this claim." ; + sh:maxCount 1 ; + sh:nodeKind sh:Literal ; + sh:order 10 ; + sh:path schema1:startDate ], + [ sh:class rfs:Entity ; + sh:description "The entity making the claim. May be an individual, organization, or community." ; + sh:maxCount 1 ; + sh:minCount 1 ; + sh:nodeKind sh:BlankNodeOrIRI ; + sh:order 4 ; + sh:path rfs:hasClaimant ], + [ sh:datatype xsd:float ; + sh:description "Numeric quantity associated with the claim (e.g., tonnes CO2e, hectares restored)." ; + sh:maxCount 1 ; + sh:nodeKind sh:Literal ; + sh:order 12 ; + sh:path rfs:quantity ], + [ sh:description "The primary category of the claim, drawn from the FWG ClaimType taxonomy. Determines which verification pathways apply." ; + sh:in ( rft:Ecological rft:Social rft:Financial rft:Governance rft:Biocultural ) ; + sh:maxCount 1 ; + sh:minCount 1 ; + sh:order 3 ; + sh:path rfs:hasClaimType ], + [ sh:class rfs:Methodology ; + sh:description "The methodology used to measure or verify the claimed outcome." ; + sh:nodeKind sh:BlankNodeOrIRI ; + sh:order 14 ; + sh:path rfs:usesMethodology ], + [ sh:class rfs:Impact ; + sh:description "The primary impact claimed." ; + sh:maxCount 1 ; + sh:minCount 1 ; + sh:nodeKind sh:BlankNodeOrIRI ; + sh:order 7 ; + sh:path rfs:hasPrimaryImpact ], + [ sh:datatype xsd:string ; + sh:description "Human-readable title of the claim." ; + sh:maxCount 1 ; + sh:minCount 1 ; + sh:nodeKind sh:Literal ; + sh:order 0 ; + sh:path schema1:name ], + [ sh:datatype xsd:string ; + sh:description "BLAKE2b-256 content hash of the canonical claim representation, used for on-chain anchoring via Regen Data Module." ; + sh:maxCount 1 ; + sh:nodeKind sh:Literal ; + sh:order 16 ; + sh:path rfs:contentHash ], + [ sh:datatype xsd:string ; + sh:description "Unit of measurement for the quantity (e.g., unit:TON, unit:HA)." ; + sh:maxCount 1 ; + sh:nodeKind sh:Literal ; + sh:order 13 ; + sh:path qudt:unit ], + [ sh:datatype xsd:string ; + sh:description "Credit class identifier (e.g., C01, C06) linking this claim to a registered credit class on Regen Ledger." ; + sh:maxCount 1 ; + sh:nodeKind sh:Literal ; + sh:order 15 ; + sh:path rfs:hasCreditClass ], + [ sh:class rfs:Entity ; + sh:description "The entity or place about which the claim is made. Often a project, land steward, or community." ; + sh:maxCount 1 ; + sh:minCount 1 ; + sh:nodeKind sh:BlankNodeOrIRI ; + sh:order 5 ; + sh:path rfs:hasSubject ], + [ sh:class rfs:Entity ; + sh:description "The entity responsible for operations that produced the claimed outcome, if different from the claimant." ; + sh:maxCount 1 ; + sh:nodeKind sh:BlankNodeOrIRI ; + sh:order 6 ; + sh:path rfs:hasOperator ], + [ sh:datatype xsd:anyURI ; + sh:description "Link to a valid URL where more information can be found about the entity / resource." ; + sh:maxCount 1 ; + sh:nodeKind sh:Literal ; + sh:order 2 ; + sh:path schema1:url ] ; + sh:targetClass rfs:Claim . + +rfs:Link a sh:NodeShape ; + sh:closed true ; + sh:ignoredProperties ( schema1:version rdf:type ) ; + sh:property [ sh:datatype xsd:anyURI ; + sh:description "Link to a valid URL where more information can be found about the entity / resource." ; + sh:maxCount 1 ; + sh:minCount 1 ; + sh:nodeKind sh:Literal ; + sh:order 1 ; + sh:path schema1:url ], + [ sh:datatype xsd:string ; + sh:description "Name of the entity / resource." ; + sh:maxCount 1 ; + sh:minCount 1 ; + sh:nodeKind sh:Literal ; + sh:order 0 ; + sh:path schema1:name ], + [ sh:datatype xsd:string ; + sh:description "Description of the entity / resource." ; + sh:maxCount 1 ; + sh:nodeKind sh:Literal ; + sh:order 2 ; + sh:path schema1:description ] ; + sh:targetClass rfs:Link . + +rfs:VersionedLink a sh:NodeShape ; + sh:closed true ; + sh:ignoredProperties ( rdf:type ) ; + sh:property [ sh:datatype xsd:string ; + sh:description "Name of the entity / resource." ; + sh:maxCount 1 ; + sh:minCount 1 ; + sh:nodeKind sh:Literal ; + sh:order 1 ; + sh:path schema1:name ], + [ sh:datatype xsd:string ; + sh:description "Description of the entity / resource." ; + sh:maxCount 1 ; + sh:nodeKind sh:Literal ; + sh:order 3 ; + sh:path schema1:description ], + [ sh:datatype xsd:string ; + sh:description "Version number of the resource / entity." ; + sh:maxCount 1 ; + sh:nodeKind sh:Literal ; + sh:order 0 ; + sh:path schema1:version ], + [ sh:datatype xsd:anyURI ; + sh:description "Link to a valid URL where more information can be found about the entity / resource." ; + sh:maxCount 1 ; + sh:minCount 1 ; + sh:nodeKind sh:Literal ; + sh:order 2 ; + sh:path schema1:url ] ; + sh:targetClass rfs:VersionedLink . + +rfs:Methodology a sh:NodeShape ; + sh:closed true ; + sh:description "Details about a specific methodology." ; + sh:ignoredProperties ( rdf:type ) ; + sh:property [ sh:datatype xsd:string ; + sh:description "Version number of the resource / entity." ; + sh:maxCount 1 ; + sh:nodeKind sh:Literal ; + sh:order 2 ; + sh:path schema1:version ], + [ sh:datatype xsd:string ; + sh:description "Unique identifier for the resource / entity." ; + sh:maxCount 1 ; + sh:nodeKind sh:Literal ; + sh:order 3 ; + sh:path schema1:identifier ], + [ sh:datatype xsd:string ; + sh:description "Name of the entity / resource." ; + sh:maxCount 1 ; + sh:minCount 1 ; + sh:nodeKind sh:Literal ; + sh:order 0 ; + sh:path schema1:name ], + [ sh:datatype xsd:anyURI ; + sh:description "Link to a valid URL where more information can be found about the entity / resource." ; + sh:maxCount 1 ; + sh:minCount 1 ; + sh:nodeKind sh:Literal ; + sh:order 1 ; + sh:path schema1:url ] ; + sh:targetClass rfs:Methodology . + +rfs:Impact a sh:NodeShape ; + sh:closed true ; + sh:description "Represents an ecological, social, or broader impact or benefit usually in the context of a project's activities. Impacts can be measurable or qualitative." ; + sh:ignoredProperties ( rdf:type ) ; + sh:property [ sh:datatype xsd:string ; + sh:description "Name is optional and will be inferred from the ImpactType if not provided." ; + sh:maxCount 1 ; + sh:nodeKind sh:Literal ; + sh:order 0 ; + sh:path schema1:name ], + [ sh:description "List of relevant Sustainable Development Goals that this impact supports." ; + sh:in ( ) ; + sh:order 2 ; + sh:path rfs:supportsSDG ], + [ sh:description "Type of impact, such as ecological, social, or economic." ; + sh:in ( rft:ReducedFertilizerAmendments rft:AvoidedDeforestationDegradation rft:ImprovedCulturalHeritageAwareness rft:ImprovedBiodiversity rft:ImprovedEnvironmentalEducationOutreach rft:ReducedSoilErosion rft:ReducedHerbicideAmendments rft:ImprovedForestHealth rft:ImprovedCommunityHealth rft:ImprovedWildlifeHabitat rft:ImprovedSoilHealth rft:IncreasedForestCover rft:ReducedPesticideAmendments rft:ImprovedWaterInfiltration rft:ImprovedWaterHoldingCapacity rft:IncreasedCarbonSequestrationStorage rft:ReducedIrrigation rft:ImprovedSoilStructure rft:ImprovedNutrientCycling "UNKNOWN" ) ; + sh:maxCount 1 ; + sh:minCount 1 ; + sh:order 1 ; + sh:path rfs:hasImpactType ] ; + sh:targetClass rfs:Impact . + +rfs:Entity a sh:NodeShape ; + sh:closed true ; + sh:ignoredProperties ( rdf:type ) ; + sh:property [ sh:datatype xsd:string ; + sh:description "On-chain wallet address (bech32 or hex) bridging semantic identity to signing capability. Used by the identity bridge to link KOI entity URIs to on-chain accounts for CLAMS and EAS interop." ; + sh:maxCount 1 ; + sh:nodeKind sh:Literal ; + sh:order 5 ; + sh:path rfs:walletAddress ], + [ sh:datatype xsd:anyURI ; + sh:description "Link to a valid URL where more information can be found about the entity / resource." ; + sh:maxCount 1 ; + sh:nodeKind sh:Literal ; + sh:order 2 ; + sh:path schema1:url ], + [ sh:datatype xsd:string ; + sh:description "Description of the entity / resource." ; + sh:maxCount 1 ; + sh:nodeKind sh:Literal ; + sh:order 1 ; + sh:path schema1:description ], + [ sh:in ( rfs:Individual rfs:Organization rfs:Community ) ; + sh:maxCount 1 ; + sh:minCount 1 ; + sh:order 4 ; + sh:path rfs:type ], + [ sh:datatype xsd:string ; + sh:description "Name of the entity / resource." ; + sh:maxCount 1 ; + sh:minCount 1 ; + sh:nodeKind sh:Literal ; + sh:order 0 ; + sh:path schema1:name ], + [ sh:datatype xsd:string ; + sh:maxCount 1 ; + sh:nodeKind sh:Literal ; + sh:order 3 ; + sh:path schema1:image ] ; + sh:targetClass rfs:Entity . + diff --git a/tests/test_shacl_claim_shape.py b/tests/test_shacl_claim_shape.py new file mode 100644 index 00000000..b9f474d4 --- /dev/null +++ b/tests/test_shacl_claim_shape.py @@ -0,0 +1,75 @@ +"""SHACL claim-shape proof tests. + +Codifies the alignment finding in docs/claims/shacl-alignment-gap.md: + - the generated shape is satisfiable: a claim shaped to the LinkML slot + vocabulary CONFORMS; + - today's koi-processor flat-key claim JSON-LD does NOT conform (sh:closed + violations) — i.e. SHACL cannot be enabled until the claim representation + is aligned to the LinkML slots. + +Requires pyshacl + rdflib (see requirements.txt); skipped if absent. +Run: pytest tests/test_shacl_claim_shape.py -v +""" +from pathlib import Path + +import pytest + +pytest.importorskip("pyshacl") +pytest.importorskip("rdflib") + +import rdflib # noqa: E402 +from pyshacl import validate # noqa: E402 + +SHAPE_PATH = Path(__file__).resolve().parent.parent / "schema" / "shacl" / "claim.shacl.ttl" + +# A claim shaped to the LinkML slot vocabulary, with valid controlled-vocab values. +ALIGNED_CLAIM_TTL = """ +@prefix rfs: . +@prefix rft: . +@prefix schema1: . +@prefix xsd: . + a rfs:Claim ; + schema1:name "Soil carbon increased 2 tC/ha/yr" ; + schema1:description "demo claim" ; + schema1:url "urn:doc:1"^^xsd:anyURI ; + rfs:hasClaimType rft:Ecological ; + rfs:verificationStatus rft:SelfReported ; + rfs:hasClaimant [ a rfs:Entity ; schema1:name "Demo Org" ; rfs:walletAddress "regen1org" ; rfs:type rfs:Organization ] ; + rfs:hasSubject [ a rfs:Entity ; schema1:name "Demo Project" ; rfs:walletAddress "regen1proj" ; rfs:type rfs:Organization ] ; + rfs:hasPrimaryImpact [ a rfs:Impact ; schema1:name "SOC gain" ; rfs:hasImpactType rft:IncreasedCarbonSequestrationStorage ] . +""" + +# Today's koi-processor rfs:Claim JSON-LD, as RDF (flat ad-hoc keys). +KOI_FLAT_CLAIM_TTL = """ +@prefix rfs: . + a rfs:Claim ; + rfs:claimant_uri "orn:koi-net.entity:demo" ; + rfs:claim_type "ecological" ; + rfs:statement "Soil carbon increased 2 tC/ha/yr" ; + rfs:about_uri "orn:regen.methodology:soc" . +""" + + +def _validate(ttl: str): + shape = rdflib.Graph().parse(str(SHAPE_PATH), format="turtle") + data = rdflib.Graph().parse(data=ttl, format="turtle") + conforms, _results_graph, results_text = validate( + data, shacl_graph=shape, inference="rdfs", advanced=True + ) + return conforms, results_text + + +def test_shape_file_exists(): + assert SHAPE_PATH.exists(), f"missing generated shape: {SHAPE_PATH}" + + +def test_aligned_claim_conforms(): + conforms, report = _validate(ALIGNED_CLAIM_TTL) + assert conforms, f"LinkML-aligned claim should pass SHACL but failed:\n{report}" + + +def test_koi_flatkey_claim_does_not_conform(): + """Documents the alignment gap: today's claim representation fails (sh:closed).""" + conforms, report = _validate(KOI_FLAT_CLAIM_TTL) + assert not conforms, "koi flat-key claim unexpectedly conformed — has the model been aligned?" + assert "closed" in report.lower(), f"expected sh:closed violations, got:\n{report}"