SHACL
purrdf-shapes (re-exported as
purrdf::shapes) is PurRDF’s native SHACL validator: the complete SHACL
Core feature set — all constraint components, full property paths,
qualified value shapes, property pairs — plus SHACL-SPARQL constraints
and targets and the SHACL-AF surface, running entirely on PurRDF’s own
interned IR and native SPARQL engine (no oxigraph, no PyO3).
It validates an RDF 1.2 data graph against a SHACL shapes graph with no
inference (parity with pySHACL inference="none"); combine with
Entailment if you want to validate a materialized closure.
What it covers
- SHACL Core — every constraint component, full property paths, qualified
value shapes, property pairs. The W3C
data-shapessuite passes clean (129/129, zero ledgered gaps at the time of writing — the live number is indocs/CONFORMANCE.md). - SHACL-SPARQL — SPARQL-based constraints and targets, custom constraint
components with pre-binding semantics, user-defined
sh:SPARQLFunctioncalls, andsh:SPARQLTargetType, evaluated on the native SPARQL engine. - SHACL-AF — node expressions (including
sh:ExpressionConstraintComponent) and SHACL Rules (sh:TripleRuleandsh:SPARQLRule, withsh:condition,sh:order,sh:deactivated): rules fire in an iterative fixpoint and the derivation is materialized as a new dataset (base ⊎ derived), leaving the input graph untouched. The surface is aligned with the SHACL 1.2 Working Drafts: the SHACL 1.2 Node Expressions vocabulary (shnex:,http://www.w3.org/ns/shacl-node-expr#) and the older SHACL-AF spelling of a node expression parse to one representation and run through one evaluator;sh:nodeByExpressionis validated; SPARQL-based node expressions and expression-bodied functions ride the native engine; and rules execute assh:orderstrata with theonce/generalpartition of the SPARQL 1.2 RL draft, each stratum materialized before the next runs, so swapping two rules’ orders can change the closure.sh:conditionresolves at shapes-load, so an unresolvable condition is a load error rather than a rule that silently never fires. Every one of those IRIs is defined by a W3C document; PurRDF mints none. Some node-expression conveniences (sh:if, aggregations, ordering wrappers) are DASH/TopBraid conventions with no normative RDF definition; PurRDF documents its adopted reading and pins it with a frozen corpus — see the SHACL-AF section of docs/CONFORMANCE.md.
owl:imports in a shapes graph
A shapes document may carry an owl:Ontology header that owl:imports other
documents, and the shapes it constrains with may live entirely in those imports.
PurRDF resolves that closure — but it never fetches it. There is no HTTP
client in the workspace, every release crate builds for
wasm32-unknown-unknown, and a validation verdict that depends on what a URL
served today is not reproducible. So the closure is caller-supplied
configuration, exactly as it is for entails and shex:
purrdf validate --shapes root.ttl \
--import https://example.org/shapes-a=a.ttl \
--import https://example.org/shapes-b=b.ttl \
data.ttl -
The table is followed transitively — an imported document’s own
owl:imports are resolved from the same table — and a cycle terminates rather
than looping, because OWL 2 §3.4 defines the imports closure as the transitive
one and explicitly permits A to import B to import A. Each imported
document’s own @prefix declarations travel with it, so a SHACL-AF sh:select
written in an imported file resolves against the prefixes that file declares.
Naming any pair makes the closure mandatory: an owl:imports no pair
resolves is refused by name rather than folded in as an empty graph, and a pair
the closure never reaches is refused as unused rather than read and ignored.
Naming no pair is not a refusal. Each unresolved import is reported on
stderr and the shapes graph validates alone — a shapes document may legitimately
carry an ontology header whose imports are irrelevant to its shapes, and
refusing those would reject input that is valid. What is gone is the silence:
before PurRDF 1.0.1 an unresolved owl:imports was ignored without a word, so a
shapes graph whose shapes all lived in an imported document reported conforms true against no shapes at all.
The SHACL 1.2 reifier-shape draft scope
The crate implements a scoped SHACL 1.2 Working Draft feature:
sh:reifierShape and sh:reificationRequired for direct IRI property paths,
so shapes can constrain the RDF 1.2 reifier metadata attached to statements
(see RDF 1.2 Features). The relevant SHACL 1.2 Core
draft is dated 2026-06-02. This is not a claim of full SHACL 1.2
conformance — it is one draft feature, explicitly scoped and tested.
Ontology-complete developer schemas
The public compile_schema boundary accepts a SchemaCompileRequest that binds
the parsed shapes, exact ontology dataset, caller-owned Namespaces, and an
explicit SchemaSurfaceMode. ShapedOnly retains the active SHACL
target-class surface. OntologyComplete adds existing caller-vocabulary
classes and optional OWL/RDFS-derived properties. The result carries JSON
Schema draft 2020-12, OpenAPI 3.1, the normal forward loss ledger, a canonical
property-coverage report, and a deterministic pre-compilation cache key. Its
CompiledSchema feeds the LinkML, TypeScript, GraphQL, and Pydantic emitters
without a second schema-discovery pass.
The bounded theory catalogs only schema evidence: direct IRI sh:path, RDF/OWL
property declarations, domain/range declarations, and both endpoints of
subproperty, equivalent-property, and inverse-property relations. A predicate
seen only on an instance is not promoted. Class admission is likewise explicit,
and synthesized definitions are limited to namespaces the caller supplied;
PurRDF does not turn builtin compaction prefixes into an ontology boundary.
Subclass/equivalent-class closure determines domain membership. Multiple
domains are conjunctive; OWL union members are alternatives and intersection
members are conjunctive. Subproperties inherit superproperty domains, ranges,
and forward functionality, equivalent properties propagate bidirectionally,
and inverse properties exchange domain and range. Strongly connected cycles
are condensed deterministically. Multiple ranges remain conjunctive in emitted
JSON Schema; union and intersection expressions map to anyOf and allOf.
Direct SHACL remains authoritative. Ontology-only fields are optional;
owl:FunctionalProperty gives a scalar representation with approximation
provenance, while inverse functionality does not. Closed shapes reject
unshaped fields unless they are directly present or ignored. Classes without a
target shape are emitted as open carriers, never as fabricated closed models.
This is not ABox materialization or unrestricted OWL: property chains and
axioms outside the fragment do not create fields.
SchemaCoverageReport accounts for every catalogued property once, including
exclusions, with sorted per-class outcomes and source-axiom provenance.
SchemaCompileRequest::coverage_report can produce it before emission. The
request cache key binds RDFC-1.0 identities for the shapes and ontology graphs,
caller namespaces, mode, value-vocabulary marker, compiler/policy salts, and
the fixed ceilings: 65,536 properties, 65,536 classes, 1,048,576 relation or
coverage cells, and expression depth 64. Malformed OWL lists, contradictory
property kinds/ranges, key collisions, and limit exhaustion are typed failures.
Run the complete two-mode and four-emitter example with:
cargo run -p purrdf-shapes --example ontology_schema_surface --locked
Schema → SHACL imports
The schema-projection surface is bidirectional. SchemaImportConfig requires
the caller’s namespace table and the complete RDF datatype mapping for JSON
scalars; there is no default vocabulary. The five production reverse directions
are JSON Schema draft 2020-12 (import_json_schema), native LinkML 1.11
(import_linkml), and verified PurRDF-emitted Pydantic v2, TypeScript 7.0, and
GraphQL September 2025 packages (import_*_package). All five lower through one
ordered JSON-Schema semantic model and return typed shapes plus an
always-computed, located reverse LossLedger.
Malformed values, open or dangling references, identity collisions, generated artifact/map drift, and resource-limit exhaustion fail closed. Valid source constructs without an exact SHACL interpretation are ledgered at their native JSON Pointer. Arbitrary Python, TypeScript, and GraphQL SDL are intentionally outside the inverse boundary because none defines one unique runtime JSON acceptance relation. LinkML does have a native reader; its schema identity and documentation can therefore appear as losses even when the validation-bearing SHACL recompiles byte-exactly.
The executable example constructs caller-owned example.org configuration and
exercises all five paths:
cargo run -p purrdf-shapes --example schema_reverse --locked
Pydantic v2 projection
purrdf-shapes can transliterate a compiled SHACL-derived JSON Schema into a
deterministic, typed Pydantic v2 package entirely in memory. The public
emit_pydantic function consumes CompiledSchema; PydanticConfig requires the
caller to supply the package name and package/module prose, so the library does
not invent a vocabulary, namespace, or downstream brand.
Every $defs entry gets a stable import path, JSON property names remain exact
through Pydantic aliases, and generated classes expose the originating
definition through model_json_schema(by_alias=True). Pydantic runtime
annotations enforce the representable portion. A JSON Schema assertion with no
exact runtime annotation remains visible on that schema surface and produces a
located entry in the always-computed json-schema → pydantic-v2
LossLedger; a lossless input yields an empty ledger. The renderer itself has no
Python dependency and stays wasm-clean. A dev-only Python oracle executes the
generated code and checks the live reverse/schema surface.
import_pydantic_package separately verifies the retained source schema,
generated files, model map, dialect, and forward ledger before importing SHACL.
The optional caller-owned PydanticPackageTopology is a total partition of
$defs entries into portable dotted leaf modules. Each route carries the class
docstring and a sorted, vocabulary-neutral json_schema_extra map suitable for
documentation URLs, content digests, and other caller-defined linkage. An
optional PydanticVersionStamp adds an exact PEP 440 __version__ export.
Routed packages share schema/runtime support modules, generate intermediate
package initializers, use explicit symbol tables for one root-level rebuild,
and pass the executable runtime oracle plus strict mypy. Exact route coverage,
portable path/symbol uniqueness, and fixed input/config/output limits all fail
closed. When both topology and version stamping are omitted, the original flat
package bytes remain unchanged. A flat version stamp adds __about__.py and
updates the __init__.py exports.
LinkML 1.11 projection
The same CompiledSchema carrier can be projected to canonical LinkML 1.11
with emit_linkml. LinkmlConfig requires the caller’s schema IRI, name,
description, default prefix, and complete prefix map, so PurRDF never mints a
consumer vocabulary or identity. The returned LinkmlPackage includes the
typed document, deterministic YAML, a reversible $defs-key mapping, and a
located json-schema → linkml-1.11 loss ledger. It also carries ordered,
integrity-checked slot rename and skip-diagnostic reports.
Classes and exact property aliases, types, enums, local references, inline
objects, requiredness, homogeneous arrays, patterns, inclusive bounds, and
LinkML boolean expressions are represented directly. An unsafe LinkML slot name
uses SanitizePolicy::Rename by default; Skip omits only that slot with a
located diagnostic/loss, and Fail returns a contextual error. Rename preserves
declared CURIE and absolute-IRI identity byte-exactly in slot_uri; an unsafe
bare token or exact caller re-home receives a reported identity under the
caller-supplied default prefix. All valid IRI schemes remain absolute unless
the caller marks that exact token, so custom schemes are not guessed from their
spelling. Safe names reserve first and hash-plus-ordinal collision allocation is
bounded and deterministic.
Every unsupported assertion is classified by a closed capability table;
malformed inputs, external/dynamic/dangling references, stale re-home hints,
semantic identity collisions, and fixed-limit breaches fail closed.
parse_linkml and write_linkml preserve all
JSON-compatible metamodel fields and provide byte-stable read/write round trips
while rejecting YAML-only tags, duplicate keys, non-string keys, and non-finite
numbers.
import_linkml consumes that validated native document; the emitted-package
variant import_linkml_package first verifies canonical YAML and the reversible
element map, slot reports, policy losses, aliases, and emitted identities. Both
use the same caller-owned SHACL import configuration. Migration adapters should
pass CompiledSchema unchanged, configure exact re-homes, and consume
slot_renames; rewriting shared property/required keys is unnecessary.
The Rust production path has no LinkML-toolkit dependency. CI uses the locked
official LinkML 1.11.1 Python packages only as a differential oracle. It loads
safe, lossy, and renamed fixtures through SchemaDefinition and SchemaView,
regenerates JSON Schema, and verifies reverse predicates:
make linkml-oracle
TypeScript 7.0 projection
emit_typescript projects the same CompiledSchema into deterministic
TypeScript 7.0 declarations. The caller supplies the package name and all
package/module prose through TypeScriptConfig. The returned package contains
one index.d.ts, a reversible $defs-key to exported-type map, and a located
json-schema → typescript-7.0 loss ledger; PurRDF invents no consumer
identity or vocabulary.
The fixed declaration dialect uses strict plus
exactOptionalPropertyTypes. Type aliases preserve JSON primitives and
literals, required versus optional fields, explicit null, local recursive
references, unions, intersections, homogeneous arrays, and bounded tuples.
There are no runtime enums, mergeable interfaces, branded pseudo-validators,
or any escape hatches. Invalid keywords, open/dangling references, and name
collisions fail before bytes are emitted.
Runtime assertions outside TypeScript structural assignability are never silently erased: integer, numeric/string predicate, closure, pattern-property, dependency, conditional, negation, contains/unique, evaluation-state, and bounded-expansion gaps receive stable codes and JSON Pointer locations. CI classifies instances independently with a draft 2020-12 validator and compiles the generated declarations with the locked TypeScript 7.0.2 compiler, including fresh-literal and through-variable probes:
make typescript-oracle
The projection intentionally has no arbitrary TypeScript reader. TypeScript
declarations do not define a unique runtime JSON acceptance relation, and the
projection is many-to-one. import_typescript_package is the authoritative
reverse surface: it deterministically verifies the retained source schema,
declaration, reversible name map, dialect, and forward ledger. TypeScript is
only a dev-time oracle dependency; the Rust emitter/importer is filesystem-free
and wasm-clean.
GraphQL September 2025 projection
emit_graphql projects CompiledSchema into deterministic GraphQL September
2025 SDL. GraphqlConfig has no defaults: the caller supplies the schema name,
package and module prose, and a non-built-in fallback-scalar name. The returned
GraphqlPackage contains schema.graphql, canonical name-map.json, the same
name map as typed Rust data, a located json-schema →
graphql-september-2025 loss ledger, and the production value codec.
The SDL is deliberately a type-system fragment. PurRDF emits paired output
type and input input objects, but no query, mutation, or subscription root,
resolver, pagination rule, authorization policy, federation directive, or
other application behavior. A caller composes the fragment with its own
executable schema.
The exact grammar includes GraphQL booleans, strings, numbers, the signed
32-bit Int domain, explicit nullability, finite JSON const/enum sets,
closed object fields, requiredness, homogeneous lists, direct local $defs
references and aliases, descriptions, and inline object helpers. One global
collision-checked namespace covers types, helpers, and the fallback scalar;
fields and enum symbols are checked in their GraphQL-local namespaces. The
typed/canonical name maps retain the source definition keys, property keys, and
finite JSON values.
GraphqlPackage::encode_input maps source JSON keys and finite values to input
field names and enum symbols. decode_output performs the inverse for fields
present in a GraphQL response, without inventing omitted selections. Unknown or
incompatible values fail. This package codec is the precise value boundary;
import_graphql_package is the schema reverse boundary and verifies the SDL,
typed/canonical maps, identity, retained source schema, and forward ledger.
Arbitrary GraphQL SDL has no unique JSON Schema acceptance relation and is not
accepted as an inverse format.
GraphQL variable coercion differs from JSON Schema validation at these closed boundaries:
| Boundary | Located loss families |
|---|---|
| object fields and names | additional properties, pattern properties, property names/counts |
| requiredness and recursion | nullable-presence widening, one deterministic recursive-input nullability relaxation |
| lists | singleton coercion, cardinality, contains, uniqueness, tuples, unevaluated items |
| scalar assertions | integer domain delegation, numeric predicates, string predicates |
| applicators | conditionals, dependencies, intersections, unions, oneOf, negation |
| runtime boundary | custom-scalar and unknown-keyword validation delegation |
The caller-named fallback scalar is declared but PurRDF does not invent its
parseValue, parseLiteral, or serialization semantics. Every delegated use
is therefore ledgered. Loss entries carry stable codes and source JSON Pointer
locations; an exact package has an empty ledger.
Emission fails before returning bytes for invalid caller configuration,
malformed schema keywords, $id rebasing, external/indirect/dangling $ref,
$dynamicRef/$recursiveRef, alias cycles, unsatisfiable closed required
fields, and generated-name collisions. The fixed limits are 16 MiB for the
input schema, each artifact, and one codec value; 65,536 definitions, fields
per object, or finite values; depth 128; and 255 bytes per GraphQL name.
The independent dev oracle classifies source values with boon, builds the
SDL with locked official GraphQL.js 16.14.0, and executes real variable
coercion. It verifies exact agreement, every closed loss family and location,
the name map and production codec, and deliberate corruption failures:
make graphql-oracle
GraphQL.js is dev-only. Emission and value translation remain filesystem-free, wasm-clean Rust.
From Python
from purrdf import shacl
report = shacl.validate(shapes_ttl="...", data_nt="...")
print(report["conforms"]) # True / False
print(report["results"]) # list of violation dicts
Each result dict keeps the stable keys focus, path, value, severity,
component, source_shape, and message.
The report is a dataset
ValidationReport::to_dataset() materializes the W3C validation report —
the sh:ValidationReport node and its sh:ValidationResults — as a frozen
RdfDataset, built straight from the report’s own terms rather than through a
to_ntriples() → parse_dataset() round-trip. The direct path carries every
RDF 1.2 term the report holds (a triple-term focus node included) with the
report’s own blank-node labels, and the blank nodes the report mints (the
report node, one per result, the interior nodes of a complex sh:path) are
guaranteed distinct from every blank node the data graph carries. Rendering
the report in any syntax is then serialize_dataset(&report.to_dataset(), …),
which is exactly what purrdf validate --format does.
SARIF output
Validation reports stay structured in the engine; the SARIF 2.1.0 boundary is
the separate purrdf-validate crate
(purrdf::validate), which renders a report — or parser diagnostics — as a
source-traced, byte-deterministic SARIF log for editors, CI, and
code-scanning dashboards:
use purrdf::validate::{validate_to_sarif_string, SarifOptions};
let shapes = r#"
@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix ex: <http://example.org/> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
ex:PersonShape a sh:NodeShape ;
sh:targetClass ex:Person ;
sh:property [ sh:path ex:age ; sh:datatype xsd:integer ] .
"#;
let data = r#"<http://example.org/alice> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://example.org/Person> .
<http://example.org/alice> <http://example.org/age> "nope" .
"#;
let sarif = validate_to_sarif_string(shapes, data, &SarifOptions::default())
.expect("sarif produced");
assert!(sarif.contains("\"version\": \"2.1.0\""));
Lower-level entry points (build_report_sarif, build_diagnostics_sarif)
build a SarifLog value instead of a string, so a host can merge runs before
serializing.
Conformance
The validator is gated by the vendored W3C data-shapes suite, a vendored
DASH SHACL-AF/rules corpus, and a first-party frozen corpus of 70 cases with
byte-frozen expected reports; SHACL Rules output is compared to expected
inferred graphs by RDFC-1.0 isomorphism. See
Conformance & Testing.