Guides: Rules and SRL
Start
OverviewConceptsGuides
Run and configureREST API walkthroughCommand lineQuery groupsNotebooksRules and SRLPrefixesTestingMCP clientsPublish as toolsQuery bench in chatStatic exportETLDeployingReference
ConfigurationFeature flagsEntity modelSRL languageScriptsExplanation
ArchitectureVersioningEnvironmentsStorage and cachingSecurity modelRDF PatchInline inputsExamples
Sudoku solverAuthoring and running SPARQL RL rules
sqlib stores and runs inference rules written in SRL, the rule language of the SPARQL RL specification. This guide covers the entities a rule set is made of, how to write a document, what stratification requires of it, and how to run one against a data graph. The grammar itself is in the SRL language reference.
SRL support is behind the rulesSuite flag
(FEATURE_RULES_SUITE, default on); see feature flags.
Rules, data blocks and rule sets
A rule is one RULE production: a head
of triple templates and a body that says when they apply.
PREFIX : <http://example/>
RULE { ?x :dependsOn ?y } WHERE { ?x :callsService ?y }
A data block is a DATA { … } block of
facts that are inferred automatically at the start of evaluation. No
variables are allowed in one, and the parser rejects a document that
puts one there. Data blocks are part of the rule set, so their triples
appear in the inference graph the run returns. They are separate from
the base graph, which is data the caller supplies per run.
A rule set is the unit you run. It is a versioned entity whose version lists the rule versions and data block versions it contains, so the same rule can belong to several rule sets and a version is an immutable snapshot of the combination.
An SRL document is the textual form of all of that:
a prologue of PREFIX/BASE declarations, then
rules and data blocks in any order. A document is imported into a rule
set with POST /rule-sets/:id/srl, which splits it into one
rule version per rule and mints a new rule set version. Rules are stored
canonically, with IRIs expanded and no prologue, so a prefix-only edit
is provably not a content change and re-importing does not churn rule
versions. The rule set version also keeps the document as written, so
GET /rule-sets/:id/srl returns it with its author's
prefixes, layout and comments, and reports
documentSource: "stored". A version saved without a
document (an older one, or one built from a list of rule versions) has
its document generated from the canonical rules, against a
prologue query parameter for the prefixes to print, and
reports documentSource: "generated".
Writing a document
Beyond a plain basic graph pattern, a rule body may contain:
| Construct | Means |
|---|---|
FILTER(…) |
an ordinary SPARQL filter |
NOT { … } |
the pattern must not match |
NOT DATA { … } |
the pattern must not match the input, ignoring anything inferred so far |
SET (?v := expr) |
bind ?v to an expression's value |
WHERE DATA { … } |
evaluate the whole body against the input, not the growing graph |
PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
PREFIX : <http://example/>
DATA {
:componentA rdf:type :Component .
}
RULE :exposure { ?x :exposedTo ?v } WHERE {
?x :dependsOn ?y .
?y :hasVulnerability ?v .
}
RULE { ?x :status :safeToDeploy } WHERE {
?x rdf:type :Component .
NOT { ?x :status :criticallyExposed }
}
The optional IRI after RULE names the rule. It is
expanded like any other IRI, so :exposure and a rule named
with the same IRI written in full are the same name, and execution
records attribute results to it.
Three things a body does differently from SPARQL:
- A body is evaluated in order. A
FILTERorSETmay only reference variables bound earlier in the body, where SPARQL scopes aFILTERto its whole group. ANOTmay mention a variable bound later, but inside the negation that variable is still unbound:NOT { ?x :q ?y } ?x :p ?yfires only if no:qtriple exists anywhere. Write theNOTafter the patterns that bind its variables for the per-solution check. SETmay not re-bind a variable the body already binds.- Every variable in a head must be bound by the body. Variables inside
NOTare existentially quantified within the negated pattern and do not count as bindings.
Those are the three well-formedness conditions the parser checks
(packages/srl/src/wellformed.ts), reported as
unbound-head, set-rebinds and
use-before-bind, with aggregate-scope for the
scope rules of GROUP. A document can be syntactically legal
and still fail them. Each of these has the severity
error.
Two more checks mark rules that are legal but probably not what their author meant, because a name refers to a separate variable:
| Category | Severity | Marks |
|---|---|---|
group-shadowing |
warning |
A variable in a GROUP pattern with the name of one
bound before the GROUP but not listed after
BY. The pattern sees only its keys, so the two are
separate. List it after BY to use the same value. |
not-before-binding |
info |
A variable in a NOT that is unbound when the
NOT runs and bound by a later element. Move the
NOT after that element to test the same value. |
Only errors make a document ill-formed. Each issue also carries
from and to, the range it is about in the
analysed text, in UTF-16 code units (the string indices of JavaScript
and the positions of CodeMirror). The range is the variable concerned
when the document was parsed with sourceLocations: true, as
POST /rule-sets/srl/analyze does, and the whole rule
otherwise.
While a document is being written,
POST /rule-sets/srl/analyze returns what an editor needs:
the line range and label of every rule and data block, each rule's
stratum and monotonicity, the dependency edges between rules, and any
well-formedness issues. A syntax error comes back as
valid: false with the parser's message rather than as a
4xx, because a half-written document is the normal case there.
POST /rule-sets/srl/compile renders each rule as
standalone SPARQL: INSERT { head } WHERE { body } by
default, or CONSTRUCT { head } WHERE { body } with
flavour: "construct" — one pass of the rule, returning the
triples instead of writing them, which is what to paste into another
endpoint to see what a rule would add. In the compiled form
NOT { P } becomes FILTER NOT EXISTS { P }
(inside a group when a later pattern binds one of its variables, so the
filter cannot see it), SET (?v := E) becomes
BIND(E AS ?v) FILTER(BOUND(?v)) inside a group, and
WHERE DATA / NOT DATA become a
GRAPH <urn:sqlib:srl:ground> pattern over a copy of
the input the executor keeps.
Stratification
A rule R depends on rule S when a body pattern of R can match a head template of S. The dependency is:
- positive ordinarily — R must not be evaluated in an earlier stratum than S;
- negative when the body pattern sits under a
NOT— R must be strictly later, so that what it looks for the absence of is already complete; - closed when R is a run-once rule — again strictly later, for the same reason.
A rule is run-once when its head mints a blank node or its body
contains a SET. Either makes every firing produce a fresh
answer, so iterating the rule has no fixpoint; the specification's
answer is to evaluate it exactly once, per stratum, after everything it
reads is complete. That is what promoting its outgoing dependencies to
closed guarantees.
A dependency cycle containing a negative or a closed edge cannot be layered, so the document is not stratifiable. Execution refuses such a rule set outright rather than producing an answer that depends on an iteration limit:
RuleSetVersion urn:… is not stratifiable: Non-stratifiable cycle involving: …
The report names the rules in the cycle, and a cycle rejected for a
run-once rule also names why that rule is run-once
(blank-node head, assignment (SET)). A
non-stratifiable document has no strata, so none are reported.
The rule set editor shows the same thing. Run is disabled and says
why. The gutter marks each rule on the cycle with ⊘ in place of a
stratum number. The Stratification tab draws the cycle instead of
strata, labelling each dependency with the pattern and the head that
caused it, such as NOT ?s :p "ABC" → :s :p "ABC". It also
explains in a sentence why the rules cannot be ordered, links each
dependency to its lines, and suggests how to break the cycle: rewrite
one NOT, write it as NOT DATA if it should
test only the input, or narrow a pattern so it stops matching the other
rule's head.
Evaluation then proceeds one stratum at a time, in ascending order.
Within a stratum the run-once rules fire on the pass that activates it
and never again, and the remaining rules iterate to that stratum's own
fixpoint before the next stratum starts. maxIterations
bounds a single stratum's iterations, not the whole run, so a
multi-stratum run can report more total iterations than the limit. The
default is 5.
Stratification is computed per rule version, and a version's rules run as one SPARQL program, so one run-once rule makes its whole version run-once. Importing an SRL document splits it into one rule per version, which restores per-rule precision.
Running a rule set
POST /rule-sets/{id}/execute
{
"version": 3,
"dataGraphVersionId": "urn:sqlib:data-graph-version:…",
"inferenceFormat": "text/turtle",
"maxIterations": 10
}
The base graph is supplied per run, as one of
dataGraphVersionId (pinned), dataGraphId (the
graph's current version) or dataGraphInline with
dataGraphInlineFormat. Omitting version runs
the rule set's current version. inferenceFormat is one of
application/n-triples (the default),
text/turtle, application/rdf+xml or
application/ld+json.
The response carries:
status—converged,cycle,maxIterationsorfailed;iterations— one entry per pass, each naming the stratum it evaluated and, per rule, its duration, the triples it inserted and deleted, and samples of them;dataBlocksandseededQuads— what the data blocks contributed;finalGraphContentandfinalGraphContentType— the inference graph serialised in the requested format, withfinalGraphNQuadsbeside it.
POST /rule-sets/{id}/execute/stream takes the same body
and reports the same run as server-sent events, for a client that wants
each pass as it happens.
Two environment variables bound a run:
RULE_EXECUTION_TIMEOUT_MS (default 30000) per rule
evaluation, and RULE_EXECUTION_SAMPLE_LIMIT (default 10)
for how many example quads each record carries.
Rule sets are also runnable from a query group, as a rule set node
handed RDF by an incoming RDF_GRAPH edge — see query groups.
The W3C conformance suite
The SPARQL RL test suite is vendored at
packages/srl/test/w3c, pinned to a commit recorded in
SOURCE beside it. Its six categories, at the pinned
snapshot:
| Directory | Entries | Asserts |
|---|---|---|
syntax/ |
212 | the document parses, or must not |
wellformed/ |
33 | the well-formedness conditions |
stratification/ |
21 | a document that cannot be stratified is rejected |
eval/ |
59 | the inference graph a run produces |
eval2/ |
6 | the same, on larger inputs |
examples/ |
5 | the examples from the specification |
To run them locally, in a store of their own:
just run-local-rules-tests # API and MCP on http://localhost:3005
just run-frontend-rules # the UI, with the flags that API servesThe recipe sets SEED_W3C_RULES_SUITE=true, which loads
every manifest entry into a library as an ordinary Test:
the eval categories as a rule set run against a data graph with the
expected inference graph as the expectation, and the three document
categories as an analysis expectation naming the check. They run on the
ordinary Tests screen through the ordinary test runner. Seeding is
idempotent, so restarting re-seeds nothing.
Two flags matter to it. FEATURE_RULES_SUITE and
FEATURE_TESTS must both be on or nothing is seeded.
FEATURE_RULES_ALLOW_INVALID_SAVE=true is what lets the
deliberately unparseable documents be stored at all — about a third of
the suite asserts rejection — and they open in the editor with the
parser's complaint against them. Without it those entries are
skipped.
W3C_RULES_SUITE_DIR points the loader at a copy
elsewhere, for a deployment that ships packages/api without
the monorepo around it.
How tests are expressed, run and reported — including the conformance report the suite's results produce — is in testing and conformance.
Aggregation with GROUP
GROUP computes COUNT, SUM,
MIN and MAX over a pattern inside a rule
body:
PREFIX : <http://example.org/>
RULE { ?x :numChildren ?n }
WHERE { ?x a :Person . GROUP BY ?x { ?y :childOf ?x } [ ?n := COUNT(*) ] }
It follows the grammar proposed in w3c/data-shapes#1347,
which is not merged yet. Until it is, GROUP is gated by the
ruleAggregates feature flag
(FEATURE_RULE_AGGREGATES), which defaults
off, and with the flag off a document containing
GROUP is a syntax error:
SRL syntax error: GROUP requires the rule-aggregates extension (parse with { aggregates: true })
What a GROUP evaluates to, what it keeps bound
afterwards, and how it stratifies and compiles are in the SRL
reference.