Run SHACL-AF inference

SHACL-AF rules (sh:rule) derive new triples from existing ones. Shifty runs them by forward chaining to a fixed point: every rule whose body is satisfied fires, the derived triples become available to other rules, and this repeats until nothing new appears.

Rules and data

A rule lives on a shape and fires for each node that shape targets. This one copies a rectangle’s width to an ex:area property — not a useful calculation, but a small complete example:

@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix ex: <http://example.org/> .

ex:RectangleShape a sh:NodeShape ;
    sh:targetClass ex:Rectangle ;
    sh:rule [
        a sh:TripleRule ;
        sh:subject sh:this ;
        sh:predicate ex:area ;
        sh:object [ sh:path ex:width ] ;
    ] .
@prefix ex: <http://example.org/> .

ex:r1 a ex:Rectangle ; ex:width 3 ; ex:height 2 .

Get the extended graph

The CLI prints derived triples; it does not write a merged file. To get the original graph plus everything derived, use Python:

result = shifty.infer(data, rules)

print(result.inferred_count)        # 1
graph = result.graph()              # rdflib.Graph: original + inferred
graph.serialize("out.ttl", format="turtle")

result.graph_ntriples gives the same content as an N-Triples string without constructing an rdflib graph, which is faster if you are writing it straight to a file or passing it to another process.

If data is already an rdflib.Graph you own, in_place=True adds the derived triples into it directly instead of building a separate copy — only the delta crosses back from Rust, not the whole graph:

data = rdflib.Graph()
data.parse("data.ttl")

result = shifty.infer(data, rules, in_place=True)
print(result.inferred_count)   # 1
result.graph() is data         # True — same object, now extended

in_place=True requires a single rdflib.Graph (not bytes, a path, a string, or a list of inputs — there’s no caller-owned object to mutate for those); anything else raises TypeError.

Triples derived about a blank node land on the blank node your graph already holds, so they stay reachable from whatever pointed at it:

import rdflib

EX = rdflib.Namespace("http://example.org/")
data = rdflib.Graph()
data.parse(
    data="""
    @prefix ex: <http://example.org/> .
    ex:r1 ex:hasDim [ a ex:Dim ; ex:width 4 ] .
    """,
    format="turtle",
)

shifty.infer(data, rules, in_place=True)

dim = data.value(EX.r1, EX.hasDim)
list(data.objects(dim, EX.area))   # the derived value, reached through ex:r1

Any blank node label works, including names that RDF syntax cannot spell directly — labels carried over from a JSON-LD @id or a database key are encoded for the round trip and restored on the way back.

Rules embedded in the data graph work the same way — omit the second argument:

result = shifty.infer(combined)

Passing an empty rdflib.Graph() as the second argument is not the same thing: it means “run with an explicitly empty rules graph”, so no rules are found and nothing is derived.

Inference during validation

validate() runs inference first by default and validates the extended graph, which is usually what you want — a rule that derives ex:area should be able to satisfy a shape that requires ex:area. Turn it off with infer=False (Python) or --no-infer (CLI) to validate only asserted triples.

validate() normally discards that inferred closure once the report is built. Pass in_place=True (with infer=True, the default) to add it into data_graph instead, the same way infer(..., in_place=True) does:

data = rdflib.Graph()
data.parse("data.ttl")

conforms, report, _ = shifty.validate(data, shapes, in_place=True)
# data now includes whatever the rules derived; report is still a
# fresh rdflib.Graph either way.

validate_algebra() and PreparedValidator.validate() / .validate_algebra() accept the same in_place option.

The two phases do not interleave. Inference runs to a fixed point, then validation runs over the result. They also use opposite fixed points — inference takes the least, validation the greatest — for reasons explained in Recursion and stratification.

When rules refer to each other

Rules may depend on rules, including cyclically. Shifty analyses the dependency graph and evaluates in strata, and a schema whose recursion passes through a negation is refused with a diagnostic rather than guessed at. To see the analysis for a shapes file:

shifty inspect --stage strata rules.ttl

Likewise, malformed shapes (including malformed SPARQL or an unresolved query prefix) raise an error before inference starts. A malformed rule is never silently skipped; non-fatal unsupported features remain available through the result’s diagnostics.

sh:order and sh:condition on a rule are honoured within this scheme.

Note that infer() takes no graph_mode. Graph modes describe what validation can see; inference always reads and extends the data graph.

See also