Inspect how shapes were compiled

Shifty does not interpret your SHACL graph directly. It lowers it to an algebra, normalizes that, analyses recursion, and builds a physical plan. shifty inspect prints any of those stages, which is how you find out what the engine thinks your shapes mean.

Use it when a constraint is not firing when you expect it to, when validation is slower than it should be, or when you want to know whether a SPARQL constraint is running natively.

shifty inspect --stage <stage> shapes.ttl

Every stage except capability supports --format text (default) and --format json; capability is text-only. The algebra and normalized stages also accept --format dot for Graphviz. Note that inspect takes the shapes file as a positional argument, not --shapes, and reads no data graph — it is entirely about the schema.

The examples below use the shapes file from Your first validation.

What was parsed

shifty inspect --stage rdf shapes.ttl

The raw triples, after parsing and before any interpretation. Reach for this when you suspect a prefix or a syntax problem rather than a semantic one.

What the shapes compiled to

shifty inspect --stage algebra shapes.ttl
schema: 1 statement(s), 0 rule(s), 13/13 shape(s)
shapes:
  @0 = severity(Violation, @11)  # ex:PersonShape
  @1 = severity(Violation, @7)
  @2 = test(datatype(xsd:string))
  @3 = ¬@2
  @4 = ∃[..0] ex:name . @3
  @5 = ⊤
  @6 = ∃[1..] ex:name . ⊤
  @7 = @4 ∧ @6
  @8 = severity(Violation, @10)
  @9 = ⊤
  @10 = ∃[1..] ex:email . ⊤
  @11 = @1 ∧ @8
  @12 = test(ex:Person)
statements:
  ∃≥1 rdf:type/rdfs:subClassOf* . φ  ⇒  @0

Shapes are numbered nodes in an arena, referring to each other by id. The ex:email lower count comes directly from sh:minCount 1. The datatype constraint appears as an upper count of zero values that fail the datatype test: that is how the algebra expresses “every value has this datatype”.

If a constraint you wrote is missing here, it was not understood. That is the fastest way to catch a misspelled SHACL predicate, which is otherwise silent — an unrecognised triple is not an error, it simply constrains nothing.

What the optimizer did to it

shifty inspect --stage normalized shapes.ttl
schema: 1 statement(s), 0 rule(s), 12/12 shape(s)
shapes:
  @0 = test(ex:Person)
  @1 = ⊤
  @2 = ∃[1..] ex:email . ⊤
  @3 = severity(Violation, @2)
  @4 = test(datatype(xsd:string))
  @5 = ¬@4
  @6 = ∃[..0] ex:name . @5
  @7 = ∃[1..] ex:name . ⊤
  @8 = @6 ∧ @7
  @9 = severity(Violation, @8)
  @10 = @3 ∧ @9
  @11 = severity(Violation, @10)  # ex:PersonShape
statements:
  ∃≥1 rdf:type/rdfs:subClassOf* . φ  ⇒  @11

The identical ⊤ nodes in this example are hash-consed into one. On a larger shapes graph this stage may collapse more, and also flattens boolean nesting, folds contradictory facets to ⊥, tightens overlapping ranges, and pushes negation to the leaves.

The statements line shows the compiled target: sh:targetClass ex:Person became a path expression that walks rdf:type and then any number of rdfs:subClassOf steps. This is the stage to check when a target is selecting more or fewer nodes than you expected.

Normalization preserves meaning but not identity, so constraint ids differ between the source and normalized schemas. Evidence carries both — see Evidence reference.

Whether recursion is well-founded

shifty inspect --stage strata shapes.ttl
strata: stratifiable = true; 13 shape(s) in 13 stratum(strata); 0 recursive component(s)

Shapes may reference each other cyclically. Shifty evaluates such a schema in strata and refuses one whose recursion runs through a negation, because that has no consistent two-valued answer. If a schema is rejected, this stage names the cycle. See Recursion and stratification.

What will actually be executed

shifty inspect --stage plan shapes.ttl
plan: 1 statement(s)
  [0] seed <http://example.org/Person> ⟵ rdf:type/rdfs:subClassOf*  ⇒  @11
shapes (cost-ordered):
  @1 [cost 1] = test(datatype(xsd:string))
  @2 [cost 1] = ¬@1
  @3 [cost 2] = ∃[..0] ex:name . @2
  @4 [cost 0] = ⊤
  @5 [cost 1] = ∃[1..] ex:name . ⊤
  @6 [cost 3] = @5 ∧ @3
  @7 [cost 3] = severity(Violation, @6)
  @8 [cost 1] = ∃[1..] ex:email . ⊤
  @9 [cost 1] = severity(Violation, @8)
  @10 [cost 4] = @9 ∧ @7
  @11 [cost 4] = severity(Violation, @10)

Two things are decided here. The seed line is how focus nodes are found — an index lookup rather than a scan over the graph. And the conjunctions are reordered by estimated cost: the email lower count runs before the name constraints, so a node missing its email can short-circuit.

The plan exposes two common sources of runtime cost: a conjunction whose cheap branch is not first, and target selection that seeds from a scan rather than an index.

Whether SPARQL runs natively

shifty inspect --stage capability shapes.ttl

Shifty executes a subset of sh:sparql constraints and CONSTRUCT rules directly against its own indexes, and falls back to a general SPARQL engine for the rest. This stage classifies each query. A fallback may cost more on a given dataset; use --profile to measure its actual impact.

Which accesses compilation expects

shifty inspect --stage access shapes.ttl

This stage lists each statement, rule, and function’s possible reads from the default evaluation graph and named shapes graph. It identifies fixed or any predicates, forward/reverse/membership/open probes, node-domain reads, and whether analysis is complete or conservative. Rule entries also show possible predicate writes. Query and path IDs link repeated uses of the same compiled body. Use --format json to consume the catalog programmatically.

These are data-independent requirements, not indexes already built. Runtime --profile output shows which source or session indexes were admitted, their estimated and allocated bytes, the byte budget, and scan work. A conservative or unknown requirement keeps a correct scan fallback; it does not restrict which triples a query can read.

See also