sqlib Docs HomeGet startedGitHub
Guides

Authoring 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:

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:

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:

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 serves

The 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.