Audience: an LLM writing rules that will actually be loaded into a store.
Target engine: RDFox (.dlog rule files).
Rule 0: Datalog is not SPARQL. Stop writing CONSTRUCT. It won't load.
- A rule is
HEAD :- BODY .— "if BODY matches, materialise HEAD". - Atoms are triple patterns:
[?s, ?p, ?o]. Variables start with?. Constants are IRIs or literals. - Rules fire until fixpoint. Recursion is fine. Recursion through negation or aggregation is not.
- The store adds triples; it never deletes them via rules. Want "delete"? You want a different tool.
- No existentials: you cannot invent a fresh blank node in the head unless you build an IRI with
SKOLEM.
# Comments start with a hash.
@prefix : <http://example.org/> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
# Basic rule. Head atoms comma-separated; body atoms comma-separated. Ends with a full stop.
[?x, rdf:type, :Adult] :- [?x, rdf:type, :Person], [?x, :age, ?a], FILTER(?a >= 18) .
# Multiple heads.
[?y, :parentOf, ?x], [?x, rdf:type, :Child] :- [?x, :childOf, ?y] .
# Recursion (transitive closure).
[?x, :ancestorOf, ?z] :- [?x, :parentOf, ?z] .
[?x, :ancestorOf, ?z] :- [?x, :parentOf, ?y], [?y, :ancestorOf, ?z] .
# Negation — variable ?x must be bound by a positive atom first.
[?x, rdf:type, :Orphan] :- [?x, rdf:type, :Person], NOT EXISTS ?y IN ([?x, :childOf, ?y]) .
# Shorthand when every variable in the negated atom is already bound:
[?x, rdf:type, :Unemployed] :- [?x, rdf:type, :Person], NOT [?x, :worksFor, :ACME] .
# BIND — compute a value; the new variable is then usable in the head.
[?x, :fullName, ?n] :- [?x, :first, ?f], [?x, :last, ?l], BIND(CONCAT(?f, " ", ?l) AS ?n) .
# Aggregation — GROUP BY variables after ON; result bound with AS.
[?p, :childCount, ?n] :- AGGREGATE([?p, :parentOf, ?c] ON ?p BIND COUNT(?c) AS ?n) .
# Skolemised node when you must "create" an entity.
[?e, rdf:type, :Marriage], [?e, :spouse, ?a], [?e, :spouse, ?b]
:- [?a, :marriedTo, ?b], FILTER(STR(?a) < STR(?b)), BIND(SKOLEM("Marriage", ?a, ?b) AS ?e) .Literals: "Tom", "42"^^xsd:integer or just 42, "2026-09-04"^^xsd:date, true.
Built-ins mirror SPARQL 1.1 functions (STR, CONCAT, REGEX, YEAR, NOW, ABS, COALESCE…).
Step 1 — Look at the data before writing a single rule. Do not guess predicate IRIs. Query them:
SELECT ?p (COUNT(*) AS ?n) WHERE { ?s ?p ?o } GROUP BY ?p ORDER BY DESC(?n)
SELECT DISTINCT ?c WHERE { ?s a ?c }
SELECT ?s ?p ?o WHERE { ?s ?p ?o } LIMIT 20Note datatypes ("18" vs 18 vs "18"^^xsd:int — three different things to a triple store).
Step 2 — Write the inference in English, one line each. "Every Person with age ≥ 18 is an Adult." If you can't write the sentence, you can't write the rule.
Step 3 — Translate one sentence → one rule. Head = the conclusion. Body = the conditions. Name it with a comment.
Step 4 — Safety check (the store will reject unsafe rules).
Every variable in the head, in a FILTER, in BIND arguments, or in a negated atom must appear in a positive body atom (or be introduced by a BIND/AGGREGATE result). [?x, :p, ?y] :- [?x, :q, ?z] . is rejected — ?y is unbound.
Step 5 — Stratification check. Draw the dependency graph of predicates. If predicate A depends negatively (via NOT) or via AGGREGATE on B, and B depends (any path) back on A: rejected. Fix by splitting into a base predicate and a derived one.
Step 6 — Build a 10-triple test file with the expected output written down first.
@prefix : <http://example.org/> .
:alice a :Person ; :age 34 ; :parentOf :bob .
:bob a :Person ; :age 12 .
# Expected after reasoning: :alice a :Adult . :bob NOT a :Adult . :alice :childCount 1 .Step 7 — Load, materialise, verify, count.
# RDFox CLI (shell)
dstore create test
active test
import test.ttl
import rules.dlog
# verifySELECT ?x WHERE { ?x a :Adult } # expect exactly :alice
SELECT (COUNT(*) AS ?n) WHERE { ?s ?p ?o } # sanity: did it explode?If a count is suspiciously huge, you built a Cartesian product or a reflexive closure. See §5.
Step 8 — Only then scale to real data.
| What the naive model writes | Why it fails | Write instead |
|---|---|---|
CONSTRUCT { ?x a :Adult } WHERE { … } |
That's SPARQL | [?x, rdf:type, :Adult] :- … . |
?x a :Adult :- ?x :age ?a, ?a >= 18 . |
Bare comparison isn't an atom | …, FILTER(?a >= 18) . |
[?x, :p, ?y] :- [?x, :q, ?z] . |
?y unbound (unsafe) |
Bind ?y in the body or use BIND/SKOLEM |
[?x, :p, _:b] :- … |
No blank nodes in heads | BIND(SKOLEM("p", ?x) AS ?b) |
[?x, :age, 18] when data has "18" |
Datatype mismatch: no match | Check the data; use "18" or cast with BIND(xsd:integer(?s) AS ?i) |
NOT [?x, :p, ?y] with ?y unbound |
Unsafe negation | NOT EXISTS ?y IN ([?x, :p, ?y]) |
[?x, :knows, ?y] :- [?x, rdf:type, :Person], [?y, rdf:type, :Person] . |
Cartesian product; O(n²) triples | Join on a shared variable; you almost certainly meant something else |
[?x, :ancestorOf, ?x] :- … (or forgetting to exclude ?x=?y) |
Reflexive junk | Add FILTER(?x != ?y) where needed |
Missing final . / missing @prefix |
Parse error | Every rule ends with .; declare every prefix |
Using owl:sameAs, rdfs:subClassOf and expecting magic |
Store won't apply OWL unless told | Write the rules or enable the store's OWL/RDFS axiomatisation explicitly |
| Rule A negates B, B derives from A | Not stratifiable | Split predicates; negate only base/lower-stratum facts |
Generating rdfs:subClassOf closure by hand for every class |
Verbose, brittle | One generic rule: [?x, rdf:type, ?d] :- [?x, rdf:type, ?c], [?c, rdfs:subClassOf, ?d] . |
- Every body atom shares a variable with another — otherwise it's a Cartesian product.
- Put constants and selective patterns in the body; don't derive
[?s, ?p, ?o]catch-alls. - Avoid
FILTER(REGEX…)on large joins; pre-derive a flag predicate. - Transitive closure of a dense relation is quadratic in triples. Say so before writing it.
- After loading,
SELECT (COUNT(*) …)per derived predicate. Expected ≈ actual, or debug.
- Rule rejected → read the error: "unsafe" (§3 Step 4), "not stratified" (Step 5), "parse" (missing
./prefix/bracket). - Rule accepted, zero results → IRIs or datatypes don't match the data. Re-run the Step 1 queries.
- Too many results → §5 line 1 or a missing
FILTER(?x != ?y). - Wrong results → materialise one atom of the body at a time as a temp predicate and look.
- RDFox:
explainon a derived triple shows which rule fired.
- Turtle-style atoms also parse:
?x rdf:type :Adult :- ?x :age ?a, FILTER(?a >= 18) .— pick one style per file. ais accepted as shorthand forrdf:typein rule atoms; fine, but be consistent.- Deleting a fact (
delete) retracts its consequences incrementally — you never "re-run" reasoning. - Rules are stored per data store;
importtwice = duplicate rule warning, not double firing. explain <triple>in the shell shows the derivation tree. Use it before theorising.- Named graphs: atoms become
:g(?s, ?p, ?o); the default graph is[?s, ?p, ?o]. - Equality reasoning (
owl:sameAs) needs the store created withequality noUNA/UNA; otherwiseowl:sameAsis just a predicate.
- Every prefix used is declared.
- Every rule ends with
.. - Every head/filter/negation variable is bound by a positive body atom or BIND.
- No blank nodes in heads.
- No negation/aggregation cycles.
- Predicate IRIs and datatypes verified against the actual data, not imagined.
- No body atom is disconnected from the rest.
- Test triples + expected output exist and were checked.