A formalism-first SHACL validation and SHACL-AF inference engine written in Rust, grounded in the algebraic treatment of Common Foundations for SHACL, ShEx, and PG-Schema (arXiv:2502.01295). Available as a command-line tool, Python bindings (pyshifty), a C++17 static-library SDK, and a WebAssembly module that runs in the browser.
See the archive-first-attempt branch for an older attempt, which was published as shifty 0.0.7
Disclosure: This project was nearly 100% "vibe coded" with Claude Opus 4.8, Sonnet 4.6, and ChatGPT 5.5
- Full SHACL Core validation — node and property shapes, all standard constraint components
- SHACL-AF inference — forward-chaining
sh:ruleevaluation (Triple Rules, SPARQL Construct Rules) to a fixed point, with stratification analysis for recursive rulesets - Algebraic IR — shapes are lowered to a path algebra (π) and shape grammar (φ) before evaluation; the same IR drives both validation and inference
- Native SPARQL execution — a subset of
sh:sparqlconstraints and SPARQL Construct rules runs directly over an indexed dataset without a full SPARQL engine, with automatic fallback to Spareval for unsupported constructs - Multi-layer pipeline — parsing → algebraic lowering → normalization/CSE → physical planning → execution; each layer is independently inspectable
- pyshifty-compatible Python API —
validate()returns(conforms, report_graph, results_text)matching pyshifty's interface
Legend: ✅ supported ·
| Feature | Status | Notes |
|---|---|---|
| Node & property shapes | ✅ | |
Targets — targetNode, targetClass, targetSubjectsOf, targetObjectsOf, implicit class |
✅ | |
Cardinality — minCount, maxCount |
✅ | |
Value type — datatype, nodeKind, class |
✅ | |
Range — min/maxInclusive, min/maxExclusive |
✅ | numeric, date/time, and duration ordering |
String — minLength, maxLength, pattern (+flags), languageIn, uniqueLang |
✅ | |
Logical — and, or, not, xone |
✅ | |
Shape-based — node, property, qualifiedValueShape (+qualifiedMin/MaxCount, qualifiedValueShapesDisjoint) |
✅ | |
Property pairs — equals, disjoint, lessThan, lessThanOrEquals |
✅ | on node and property shapes |
Other — closed (+ignoredProperties), hasValue, in |
✅ | |
Paths — predicate, inverse, sequence, alternative, zeroOrMore, oneOrMore, zeroOrOne |
✅ | |
severity, deactivated, message |
✅ |
| Feature | Status | Notes |
|---|---|---|
Rules — sh:TripleRule, sh:SPARQLRule (CONSTRUCT) |
✅ | forward-chained to a fixed point with sh:order/sh:condition |
Node expressions — sh:this, constants, sh:path, sh:filterShape, sh:intersection, sh:union, function application |
✅ | |
SPARQL targets — sh:target + sh:select |
✅ | |
SPARQL constraints — sh:sparql (sh:select / sh:ask) |
✅ | native execution with Spareval fallback |
Custom constraint components — sh:parameter + sh:validator/sh:nodeValidator/sh:propertyValidator |
✅ | optional params, simple & complex $PATH; report path |
Expression constraints — sh:expression |
✅ | |
SHACL functions — sh:SPARQLFunction in node expressions |
✅ | full data-graph access |
SHACL functions — sh:SPARQLFunction called from SPARQL (sh:sparql, CONSTRUCT, dash:expression) |
evaluated as pure functions of their arguments; a body that reads the data graph is gated (see on_unsupported) |
|
JavaScript — sh:js*, sh:JSFunction |
❌ | no JS engine |
| Feature | Status | Notes |
|---|---|---|
| Stratified recursive shapes | ✅ | gfp validation / lfp inference per stratum |
| Non-stratifiable schemas (cycle through negation) | ❌ | diagnosed and refused, never guessed |
Partially supported features are handled per an on_unsupported setting
(EngineOptions in Rust; the on_unsupported= keyword on
validate/validate_algebra/infer and PreparedValidator in Python):
"ignore"(default) — best-effort: e.g. a graph-reading function called from a SPARQL context is evaluated over an empty dataset (result may be unreliable)."error"— fail loudly: the unsupported construct is refused so the failure surfaces (e.g. as a constraint error) instead of a silent wrong answer.
Malformed shapes are separate from on_unsupported: invalid lowering
diagnostics, including malformed SPARQL or an unresolved query prefix, always
raise an error before validation or inference. They never remove a constraint
or rule from the schema.
conforms, report, text = shifty.validate(data, shapes, on_unsupported="error")cargo install --path crates/shifty-cliOr build from source:
cargo build --release -p shifty-cli
# binary at target/release/shiftypip install pyshiftyThe package installs as pyshifty but is imported as shifty:
import shiftyTo build from source (requires Rust and maturin):
cd python
pip install maturin
maturin developThe C++17 SDK embeds Shifty as a Rust static library and provides RAII wrappers for RDF loading, SPARQL queries, reusable SHACL validators, and evidence-carrying validation:
cmake -S cpp -B build/cpp
cmake --build build/cpp
ctest --test-dir build/cpp --output-on-failure#include <shifty/shifty.hpp>
shifty::Dataset dataset;
dataset.load_file("data.ttl");
auto rows = dataset.query("SELECT ?s WHERE { ?s ?p ?o }");
auto validator = shifty::PreparedValidator::from_file("shapes.ttl");
auto report = validator.validate(dataset);
shifty::EvidenceSession evidence(validator, dataset);
for (const auto &statement : evidence.validate().statements()) {
for (const auto &focus : statement.selected_foci) {
std::cout << focus.focus_node << " " << focus.explanation << "\n";
}
}See cpp/README.md for installation, CMake package usage, and
the scan-then-explain path for corpora where materializing all evidence is too
expensive.
The shifty-wasm crate compiles the engine to WebAssembly so the full inference
- validation pipeline runs entirely in the browser — no server, no round-trips. It ships a framework-free playground (file upload + caching, rich report rendering, inference downloads), with all engine work on a Web Worker.
./crates/shifty-wasm/build.sh
python3 -m http.server -d crates/shifty-wasm # open http://localhost:8000/example/See crates/shifty-wasm/README.md for the JS
API, build/rebuild instructions, and embedding details.
shifty validate --shapes shapes.ttl --data data.ttlconforms: false
violations: 1
<http://example.org/bob> [target: ∃ rdf:type .⊤]
- (ex:name) 123 → expected datatype xsd:string
Emit a W3C sh:ValidationReport in Turtle:
shifty validate --shapes shapes.ttl --data data.ttl --reportJSON output:
shifty validate --shapes shapes.ttl --data data.ttl --format jsonValidation runs SHACL-AF rules to a fixed point by default. Skip rule inference when validating shapes directly:
shifty validate --shapes shapes.ttl --no-inferGraph mode controls which triples are visible to path traversal and SPARQL evaluation:
# default: focus nodes from data; paths/SPARQL use data ∪ shapes
shifty validate --shapes shapes.ttl --data data.ttl --graph-mode union
# focus nodes and evaluation use data only
shifty validate --shapes shapes.ttl --data data.ttl --graph-mode data
# focus nodes and evaluation both use data ∪ shapes
shifty validate --shapes shapes.ttl --data data.ttl --graph-mode union-all
# validate only selected named shapes as top-level entry points
shifty validate \
--shapes shapes.ttl \
--data data.ttl \
--shape-name http://example.org/PersonShape--shape-name is repeatable and has the alias --entry-shape. Selected
shapes are used only as entry points; referenced helper shapes are still
evaluated normally.
Run SHACL-AF rules to a fixed point, then print the derived triples:
shifty infer --shapes rules.ttl --data data.ttlinferred 3 triple(s):
<http://example.org/r1> <http://example.org/area> "6"^^<http://www.w3.org/2001/XMLSchema#integer>
...
Inspect how a shapes graph looks at each stage of the pipeline:
# Raw triples after parsing
shifty inspect --stage rdf shapes.ttl
# Lowered algebraic IR (φ/π notation)
shifty inspect --stage algebra shapes.ttl
# After normalization and common-subexpression elimination
shifty inspect --stage normalized shapes.ttl
# Stratification analysis (recursion detection)
shifty inspect --stage strata shapes.ttl
# Physical plan: focus sources + cost-ordered shape checks
shifty inspect --stage plan shapes.ttl
# SPARQL constraint capability: which queries run native vs. Spareval
shifty inspect --stage capability shapes.ttlAll stages support --format text (default), --format json; the algebra and normalized stages also support --format dot for Graphviz output.
Shapes files and data files may be local paths or HTTP/HTTPS URLs. Both --shapes and --data are repeatable to merge multiple files.
import shiftyshapes = """
@prefix sh: <http://www.w3.org/ns/shifty#> .
@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:name ;
sh:minCount 1 ;
sh:datatype xsd:string ;
] ;
sh:property [
sh:path ex:age ;
sh:maxCount 1 ;
sh:datatype xsd:integer ;
] .
"""
data = """
@prefix ex: <http://example.org/> .
ex:Alice a ex:Person ; ex:name "Alice" ; ex:age 30 .
ex:Bob a ex:Person .
"""
conforms, report_graph, results_text = shifty.validate(data, shapes)
# conforms → False
# report_graph → rdflib.Graph with sh:ValidationReport
# results_text → human-readable summaryGraph inputs can be a string (Turtle text, local path, or HTTP(S) URL), bytes, pathlib.Path, or rdflib.Graph. Existing string paths are read from disk; a directory raises IsADirectoryError, and a missing RDF-looking filename such as shapes.ttl raises FileNotFoundError. Long or multiline strings are Turtle and are never probed as paths. The same policy applies to each list/tuple member. HTTP(S) URLs are fetched once; their format is inferred from the response content type or URL suffix. If shacl_graph is omitted or passed as None, shapes are expected to be embedded in the data graph. An explicitly supplied zero-triple shapes graph raises ValueError.
To validate a shapes graph against itself, pass it once. The embedded path parses and plans one graph without constructing separate data and shapes graphs:
result = shifty.validate_algebra("shapes.ttl", infer=False)
conforms, report_graph, results_text = shifty.validate("shapes.ttl", infer=False)For repeated validation, prepare the shapes graph once:
validator = shifty.PreparedValidator(shapes)
result = validator.validate_algebra(data, infer=False)
conforms, report_graph, results_text = validator.validate(data)pathlib.Path inputs are parsed directly by Rust. rdflib.Graph inputs are
serialized as Turtle so namespace bindings required by SHACL-SPARQL queries
and rules are preserved.
validate_algebra returns an AlgebraResult with typed Violation objects instead of an RDF report graph:
result = shifty.validate_algebra(data, shapes)
print(result.conforms) # False
for v in result.violations:
print(v.focus_node) # IRI of the failing focus node
print(v.statement_id) # stable statement id
print(v.constraint_id) # statement-level algebra id shared with repair witnesses
for r in v.reasons:
print(r.message) # human-readable failure description
print(r.path) # path that was checked, if applicable
print(r.value) # the offending value node
print(r.constraint_kind)
print(r.constraint.render)Reason.constraint is the originating algebraic operator, not the SHACL source
component name. Violation.statement_id and Violation.constraint_id identify
the failed top-level statement; Reason.constraint_id identifies the specific
nested algebra node that produced a validation cause.
To connect validation output to repair witnesses:
result = shifty.validate_algebra(data, shapes, infer=False)
session = shifty.RepairSession(shapes, data, infer=False)
witnesses = {
(w.focus, w.statement_id, w.constraint_id): w
for w in session.witnesses()
}
for v in result.violations:
witness = witnesses.get((v.focus_node, v.statement_id, v.constraint_id))
for r in v.reasons:
if r.constraint_kind == shifty.ConstraintKind.Cardinality:
print("count failure:", r.constraint.definition)
elif r.constraint_kind == shifty.ConstraintKind.ClassMembership:
print("class failure:", r.constraint.definition)
if witness is not None:
print(witness.repair_tree().explain())Set infer=False when validation should not first run embedded SHACL-AF rules
to a fixed point.
EvidenceSession returns a statement-oriented EvidenceRun. Every authored
statement remains visible, including a target that selects no nodes; each
selected focus contains exactly one tagged Satisfaction or Failure:
session = shifty.EvidenceSession(sha
0 comments
log in to comment.