This handbook is written for a computer science student who wants to understand Eyeling as code and as a reasoning machine.
It’s meant to be read linearly, but each chapter stands on its own.
log:conclusionEyeling is a small Notation3 (N3) reasoner implemented in JavaScript. Its job is to take:
=> and <=),and compute consequences until nothing new follows.
If you’ve seen Datalog or Prolog, the shape will feel familiar. Eyeling blends both:
=> rules.<= rules and for built-in predicates.That last point is the heart of Eyeling’s design: forward rules are executed by proving their bodies using a backward engine. This lets forward rules depend on computations and “virtual predicates” without explicitly materializing everything as facts.
Eyeling deliberately keeps the implementation small and dependency-free:
eyeling.js)lib/* modules that read like a miniature compiler + logic engine.This handbook is a tour of that miniature system.
Let’s name the pieces:
(subject, predicate, object).{ body } => { head }.{ head } <= { body }.Eyeling runs like this:
FR_fR_br ∈ R_f:
r.body using:
r.headA good mental model is:
Forward chaining is “outer control”. Backward chaining is the “query engine” used inside each rule firing.
A sketch:
FORWARD LOOP (saturation)
for each forward rule r:
solutions = PROVE(r.body) <-- backward reasoning + builtins
for each s in solutions:
emit instantiate(r.head, s)
Because PROVE can call built-ins (math, string, list, crypto, dereferencing…), forward rules can compute fresh bindings as part of their condition.
If you want to follow the code in the same order Eyeling “thinks”, read:
lib/prelude.js — the AST (terms, triples, rules), namespaces, prefix handling.lib/lexer.js — N3/Turtle-ish tokenization.lib/parser.js — parsing tokens into triples, formulas, and rules.lib/rules.js — small rule helpers (rule-local blank lifting and rule utilities).lib/engine.js — the core inference engine:
proveGoals) and forward saturation (forwardChain)log:*In and includes tests)lib/trace.js, log:trace)time:* built-ins (lib/time.js)log:skolem) (lib/skolem.js)lib/builtins.js — builtin predicate evaluation plus shared literal/number/string/list helpers:
makeBuiltins(deps) dependency-injects engine hooks (unification, proving, deref, …){ evalBuiltin, isBuiltinPred } back to the enginematerializeRdfLists(...), a small pre-pass that rewrites anonymous rdf:first/rdf:rest linked lists into concrete N3 list terms so list:* builtins can work uniformlylib/explain.js — proof comments + log:outputString aggregation (fact ordering and pretty output).lib/deref.js — synchronous dereferencing for log:content / log:semantics (used by builtins and engine).lib/printing.js — conversion back to N3 text.lib/cli.js + lib/entry.js — command-line wiring and bundle entry exports.index.js — the npm API wrapper (spawns the bundled CLI synchronously).This is almost literally a tiny compiler pipeline:
text → tokens → AST (facts + rules) → engine → derived facts → printer
lib/prelude.js)Eyeling uses a small AST. You can think of it as the “instruction set” for the rest of the reasoner.
A Term is one of:
Iri(value) — an absolute IRI stringLiteral(value) — stored as raw lexical form (e.g. "hi"@en, 12, "2020-01-01"^^<dt>)Var(name) — variable name without the leading ?Blank(label) — blank node label like _:b1ListTerm(elems) — a concrete N3 list (a b c)OpenListTerm(prefix, tailVar) — a “list with unknown tail”, used for list unification patternsGraphTerm(triples) — a quoted formula { ... } as a first-class termThat last one is special: N3 allows formulas as terms, so Eyeling must treat graphs as matchable data.
A triple is:
Triple(s, p, o) where each position is a Term.A rule is:
Rule(premiseTriples, conclusionTriples, isForward, isFuse, headBlankLabels)Two details matter later:
false acts as a hard failure. (More in Chapter 10.)headBlankLabels records which blank node labels occur explicitly in the head of a rule. Those blanks are treated as existentials and get skolemized per firing. (Chapter 9.)Eyeling interns IRIs and Literals by string value. Interning is a quiet performance trick with big consequences:
In addition, interned Iri/Literal terms (and generated Blank terms) get a small, non-enumerable integer id .__tid that is stable for the lifetime of the process. This __tid is used as the engine’s “fast key”:
__byPred / __byPS / __byPO) key by __tid values and store fact indices (predicate buckets are keyed by predicate.__tid, and PS/PO buckets are keyed by the subject/object .__tid; buckets contain integer indices into the facts array)"sid pid oid" where each component is a __tid__tidFor blanks, the id is derived from the blank label (so different blank labels remain different existentials).
Terms are treated as immutable: once interned/created, the code assumes you won’t mutate .value (or .label for blanks).
PrefixEnv holds prefix mappings and a base IRI. It provides:
ex:foo → full IRI)ex:foo when possible)lib/lexer.js, lib/parser.js)Eyeling’s parser is intentionally pragmatic: it aims to accept “the stuff people actually write” in N3/Turtle, including common shorthand.
The lexer turns the input into tokens like:
{ } ( ) [ ] , ; .=>, <=, =, !, ^@prefix, @base, and also SPARQL-style PREFIX, BASE?x_:b1<...>rdf:type, :localtrue/false, ^^ datatypes, @en language tags# commentsParsing becomes dramatically simpler because tokenization already decided where strings end, where numbers are, and so on.
The parser supports:
; and ,[ :p :o; :q :r ]( ... ) as ListTerm{ ... } as GraphTermis ... of and inverse arrows! and ^ that may generate helper triples via fresh blanksA nice detail: the parser maintains a pendingTriples list used when certain syntactic forms expand into helper triples (for example, some path/property-list expansions). It ensures the “surface statement” still emits all required triples even if the subject itself was syntactic sugar.
=>, <=, and log idiomsAt the top level, the parser recognizes:
{ P } => { C } . as a forward rule{ H } <= { B } . as a backward ruleIt also normalizes top-level triples of the form:
{ P } log:implies { C } .{ H } log:impliedBy { B } .into the same internal Rule objects. That means you can write rules either as operators (=>, <=) or as explicit log: predicates.
true and false as rule endpointsEyeling treats two literals specially in rule positions:
true stands for the empty formula {} (an empty premise or head).false is used for inference fuses ({ ... } => false.).So these are valid patterns:
true => { :Program :loaded true }.
{ ?x :p :q } => false.
Internally:
true becomes “empty triple list”false becomes “no head triples” plus the isFuse flag if forward.lib/rules.js)Before rules hit the engine, Eyeling performs one lightweight transformation. A second “make it work” trick—deferring built-ins that can’t run yet—happens later inside the goal prover.
In N3 practice, blanks in rule premises behave like universally-quantified placeholders. Eyeling implements this by converting Blank(label) to Var(_bN) in the premise only.
So a premise like:
{ _:x :p ?y. } => { ... }.
acts like:
{ ?_b1 :p ?y. } => { ... }.
This avoids the “existential in the body” trap and matches how most rule authors expect N3 to behave.
Blanks in the conclusion are not lifted — they remain blanks and later become existentials (Chapter 9).
In a depth-first proof, the order of goals matters. Many built-ins only become informative once parts of the triple are already instantiated (for example comparisons, pattern tests, and other built-ins that don’t normally create bindings).
If such a builtin runs while its subject/object still contain variables or blanks, it may return no solutions (because it can’t decide yet) or only the empty delta ({}), even though it would succeed (or fail) once other goals have bound the needed values.
Eyeling supports a runtime deferral mechanism inside proveGoals(...), enabled only when proving the bodies of forward rules.
What happens when proveGoals(..., { deferBuiltins: true }) sees a builtin goal:
[{}]), and:
A small counter (deferCount) caps how many rotations can happen (at most the length of the current goal list), so the prover can’t loop forever by endlessly “trying later”.
There is one extra guard for a small whitelist of built-ins that are considered satisfiable even when both subject and object are completely unbound (see __builtinIsSatisfiableWhenFullyUnbound). For these, if evaluation yields no deltas and there is nothing left to bind (either it is the last goal, or deferral has already been exhausted), Eyeling treats the builtin as a vacuous success ([{}]) so it doesn’t block the proof.
This is intentionally enabled for forward-chaining rule bodies only. Backward rules keep their normal left-to-right goal order, which can be important for termination on some programs.
Many N3 documents encode lists using RDF’s linked-list vocabulary:
_:c rdf:first :a.
_:c rdf:rest _:d.
_:d rdf:first :b.
_:d rdf:rest rdf:nil.
Eyeling supports both representations:
(:a :b) are parsed as ListTerm([...]) directly.rdf:first/rdf:rest can be traversed by list-aware builtins.To make list handling simpler and faster, Eyeling runs a small pre-pass called materializeRdfLists(...) (implemented in lib/builtins.js and invoked by the CLI/entry code). It:
rdf:first/rdf:rest chains,ListTerm(...),Why only blank nodes? Named list nodes (IRIs) must keep their identity, because some programs treat them as addressable resources; Eyeling leaves those as rdf:first/rdf:rest graphs so list builtins can still walk them when needed.
lib/engine.js)Once you enter engine.js, you enter the “physics layer.” Everything else depends on the correctness of:
Eyeling has ordinary structural equality (term-by-term) for most terms.
But quoted formulas (GraphTerm) demand something stronger. Two formulas should match even if their internal blank/variable names differ, as long as the structure is the same.
That’s alpha-equivalence:
{ _:x :p ?y. } should match { _:z :p ?w. }Eyeling implements alpha-equivalence by checking whether there exists a consistent renaming mapping between the two formulas’ variables/blanks that makes the triples match.
Eyeling makes a deliberate choice about groundness:
GraphTerm do not make the surrounding triple non-groundThis is encoded in functions like isGroundTermInGraph. It’s what makes it possible to assert and store triples that mention formulas with variables as data.
A substitution is a plain JS object:
{ X: Term, Y: Term, ... }
When applying substitutions, Eyeling follows chains:
X → Var(Y) and Y → Iri(...), applying to X yields the IRI.Chains arise naturally during unification (e.g. when variables unify with other variables) and during rule firing.
At the API boundary, a substitution is still just a plain object, and unification still produces delta objects (small { varName: Term } maps).
But inside the hot backward-chaining loop (proveGoals), Eyeling uses a Prolog-style trail to avoid cloning substitutions at every step:
This keeps the search semantics identical, but removes the “copy a growing object per step” cost that dominates deep/branchy proofs. Returned solutions are emitted as compact plain objects, so callers never observe mutation.
Implementation details (and why they matter):
applySubstTerm is the only “chain chaser”. It follows Var → Term links until it reaches a stable term.
applySubstTerm still defends against accidental cyclic chains.Set in the common case (short chains).applySubstTerm returns the original term when it is unaffected.applySubstTriple returns the original Triple when s/p/o are unchanged.These “no-op returns” are one of the biggest practical performance wins in the engine: backward chaining and forward rule instantiation apply substitutions constantly, so avoiding allocations reduces GC pressure without changing semantics.
Unification is implemented in unifyTerm / unifyTriple, with support for:
There are two key traits of Eyeling’s graph unification:
Eyeling keeps literal values as raw strings, but it parses and normalizes where needed:
literalParts(lit) splits lexical form and datatype IRIrdf:JSON / <...rdf#JSON>)BigInt), and numeric metadata.This lets built-ins and fast-key indexing treat some different lexical spellings as the same value (for example, normalizing "abc" and "abc"^^xsd:string in the fast-key path).
Reasoning is mostly “join-like” operations: match a goal triple against known facts. Doing this naively is too slow, so Eyeling builds indexes on top of a plain array.
Facts live in an array facts: Triple[].
Eyeling attaches hidden (non-enumerable) index fields:
facts.__byPred: Map<predicateId, number[]> where each entry is an index into facts (and predicateId is predicate.__tid)facts.__byPS: Map<predicateId, Map<termId, number[]>> where each entry is an index into facts (and termId is term.__tid)facts.__byPO: Map<predicateId, Map<termId, number[]>> where each entry is an index into facts (and termId is term.__tid)facts.__keySet: Set<string> for a fast-path "sid pid oid" key (all three are __tid values)termFastKey(term) returns a termId (term.__tid) for Iri, Literal, and Blank terms, and null for structured terms (lists, quoted graphs) and variables.
The “fast key” only exists when termFastKey succeeds for all three terms.
When proving a goal with IRI predicate, Eyeling computes candidate facts by:
This is a cheap selectivity heuristic. In type-heavy RDF, (p,o) is often extremely selective (e.g., rdf:type + a class IRI), so the PO index can be a major speed win.
When adding derived facts, Eyeling uses a fast-path duplicate check when possible:
__tid), it checks membership in facts.__keySet using the "sid pid oid" key.This still treats blanks correctly: blanks are not interchangeable; the blank label (and thus its __tid) is part of the key.
proveGoals)Eyeling’s backward prover is an iterative depth-first search (DFS) that looks a lot like Prolog’s SLD resolution, but written explicitly with a stack to avoid JS recursion limits.
A proof state contains:
goals: remaining goal triplessubst: current substitutiondepth: current depth (used for compaction heuristics)visited: previously-seen goals (loop prevention)At each step:
Eyeling’s order is intentional: built-ins often bind variables cheaply; backward rules expand the search tree (and enable recursion); facts are tried last as cheap terminal matches.
A built-in is evaluated by the engine via the builtin library in lib/builtins.js:
deltas = evalBuiltin(goal0, {}, facts, backRules, ...)
for delta in deltas:
mark = trail.length
if applyDeltaToSubst(delta):
dfs(restGoals)
undoTo(mark)
Implementation note (performance): in the core DFS, Eyeling applies builtin (and unification) deltas into a single mutable substitution and uses a trail to undo bindings on backtracking. This preserves the meaning of “threading substitutions through a proof”, but avoids allocating and copying full substitution objects on every branch. Empty deltas ({}) are genuinely cheap: they don’t touch the trail and only incur the control-flow overhead of exploring a branch.
Implementation note (performance): as of this version, Eyeling also avoids allocating short-lived substitution objects when matching goals against facts and when unifying a backward-rule head with the current goal. Instead of calling the pure unifyTriple(..., subst) (which clones the substitution on each variable bind), the prover performs an in-place unification directly into the mutable substMut store and records only the newly-bound variable names on the trail. This typically reduces GC pressure significantly on reachability / path-search workloads, where unification is executed extremely frequently.
So built-ins behave like relations that can generate zero, one, or many possible bindings. A list generator might yield many deltas; a numeric test yields zero or one.
Conjunction in N3 is order-insensitive, but many builtins are only useful once some variables are bound by other goals in the same body. When proveGoals is called from forward chaining, Eyeling enables builtin deferral: if a builtin goal can’t make progress yet, it is rotated to the end of the goal list and retried later (with a small cycle guard to avoid infinite rotation).
“Can’t make progress” includes both cases:
[]), and[{}], i.e., success with no new bindings) while the goal still contains unbound vars/blanks.That second case matters for “satisfiable but non-enumerating” builtins (e.g., some log: helpers) where early vacuous success would otherwise prevent later goals from ever binding the variables the builtin needs.
Eyeling avoids obvious infinite recursion by recording each (substituted) goal it is currently trying in a per-branch visited structure. If the same goal is encountered again on the same proof branch, Eyeling skips it.
Implementation notes:
Map from goal key to a reference count, plus a trail array. This makes it cheap to check (O(1) average) and cheap to roll back on backtracking (just like the substitution trail).Backward rules are indexed in backRules.__byHeadPred. When proving a goal with IRI predicate p, Eyeling retrieves:
rules whose head predicate is p__wildHeadPred for rules whose head predicate is not an IRI (rare, but supported)For each candidate rule:
That “standardize apart” step is essential. Without it, reusing a rule multiple times would accidentally share variables across invocations, producing incorrect bindings.
Implementation note (performance): standardizeRule is called for every backward-rule candidate during proof search.
To reduce allocation pressure, Eyeling reuses a single fresh Var(...) object per original variable name within one standardization pass (all occurrences of ?x in the rule become the same fresh ?x__N object). This is semantics-preserving — it still “separates” invocations — but it avoids creating many duplicate Var objects when a variable appears repeatedly in a rule body.
The trail-based substitution store removes the biggest accidental quadratic cost (copying a growing substitution object at every step).
In deep and branchy searches, the substitution trail still grows, and long variable-to-variable chains increase the work done by applySubstTerm.
Eyeling currently keeps the full trail as-is during search. When emitting a solution, it runs a lightweight compaction pass (via gcCollectVarsInGoals(...) / gcCompactForGoals(...)) so only bindings reachable from the answer variables and remaining goals are kept. It still does not perform general substitution composition/normalization during search.
forwardChain)Forward chaining is Eyeling’s outer control loop. It is where facts get added and the closure grows.
Eyeling loops until no new facts are added. Inside that loop, it scans every forward rule and tries to fire it.
A simplified view:
repeat
changed = false
for each forward rule r:
sols = proveGoals(r.premise, facts, backRules)
for each solution s:
for each head triple h in r.conclusion:
inst = applySubst(h, s)
inst = skolemizeHeadBlanks(inst)
if inst is ground and new:
add inst to facts
changed = true
until not changed
There is a nice micro-compiler optimization in runFixpoint():
If a rule’s head is strictly ground (no vars, no blanks, no open lists, even inside formulas), and it contains no head blanks, then the head does not depend on which body solution you choose.
In that case:
This is a surprisingly effective optimization for “axiom-like” rules with constant heads.
Blank nodes in the rule head represent existentials: “there exists something such that…”
Eyeling handles this by replacing head blank labels with fresh blank labels of the form:
_:sk_0, _:sk_1, …But it does something subtle and important: it caches skolemization per (rule firing, head blank label), so that the same firing instance doesn’t keep generating new blanks across outer iterations.
The “firing instance” is keyed by a deterministic string derived from the instantiated body (“firingKey”). This stabilizes the closure and prevents “existential churn.”
Implementation note (performance): the firing-instance key is computed in a hot loop, so firingKey(...) builds a compact string via concatenation rather than JSON.stringify. If you change what counts as a distinct “firing instance”, update the key format and the skolem cache together.
Implementation: deterministic Skolem IDs live in lib/skolem.js; the per-firing cache and head-blank rewriting are implemented in lib/engine.js.
{ ... } => falseA rule whose conclusion is false is treated as a hard failure. During forward chaining:
This is Eyeling’s way to express hard consistency checks and detect inconsistencies.
Eyeling treats certain derived triples as new rules:
log:implies and log:impliedBy where subject/object are formulastrue as an empty formula {} on either sideSo these are “rule triples”:
{ ... } log:implies { ... }.
true log:implies { ... }.
{ ... } log:impliedBy true.
When such a triple is derived in a forward rule head:
Rule object and inserting it into the forward or backward rule list.This is meta-programming: your rules can generate new rules during reasoning.
Implementation note (performance): rule triples are often derived repeatedly (especially inside loops).
To keep promotion cheap, Eyeling maintains a Set of canonical rule keys for both the forward-rule list and the backward-rule list. Promotion checks membership in O(1) time instead of scanning the rule arrays and doing structural comparisons each time.
log:conclusionSome log: built-ins talk about “what is included in the closure” or “collect all solutions.” These are tricky in a forward-chaining engine because the closure is evolving.
Eyeling addresses this with a disciplined two-phase strategy and an optional priority mechanism.
Forward chaining runs inside an outer loop that alternates:
Phase A: scoped built-ins are disabled (they “delay” by failing)
Eyeling saturates normally to a fixpoint
then Eyeling freezes a snapshot of the saturated facts
Phase B: scoped built-ins are enabled, but they query only the frozen snapshot
Eyeling runs saturation again (new facts can appear due to scoped queries)
This produces deterministic behavior for scoped operations: they observe a stable snapshot, not a moving target.
Implementation note (performance): the two-phase scheme is only needed when the program actually uses scoped built-ins. If no rule contains log:collectAllIn, log:forAllIn, log:includes, or log:notIncludes, Eyeling now skips Phase B entirely and runs only a single saturation. This avoids re-running the forward fixpoint and can prevent a “query-like” forward rule (one whose body contains an expensive backward proof search) from being executed twice.
Implementation note (performance): in Phase A there is no snapshot, so scoped built-ins (and priority-gated scoped queries) are guaranteed to “delay” by failing.
Instead of proving the entire forward-rule body only to fail at the end, Eyeling precomputes whether a forward rule depends on scoped built-ins and skips it until a snapshot exists and the requested closure level is reached. This can avoid very expensive proof searches in programs that combine recursion with log:*In built-ins.
Eyeling introduces a scopedClosureLevel counter:
Some built-ins interpret a positive integer literal as a requested priority:
log:collectAllIn and log:forAllIn use the object position for prioritylog:includes and log:notIncludes use the subject position for priorityIf a rule requests priority N, Eyeling delays that builtin until scopedClosureLevel >= N.
In practice this allows rule authors to write “don’t run this scoped query until the closure is stable enough” and is what lets Eyeling iterate safely when rule-producing rules introduce new needs.
log:conclusion: local deductive closure of a formulalog:conclusion is handled in a particularly elegant way:
{ ... } (a GraphTerm),log:implies, log:impliedBy)forwardChain locally over those triplesWeakMap so the same formula doesn’t get recomputedNotably, log:impliedBy inside the formula is treated as forward implication too for closure computation (and also indexed as backward to help proving).
This makes formulas a little world you can reason about as data.
lib/builtins.js)Built-ins are where Eyeling stops being “just a Datalog engine” and becomes a practical N3 tool.
Implementation note: builtin code lives in lib/builtins.js and is wired into the prover by the engine via makeBuiltins(deps) (dependency injection keeps the modules loosely coupled).
A predicate is treated as builtin if:
crypto:, math:, log:, string:, time:, list:rdf:first / rdf:rest (treated as list-like builtins)log:implies and log:impliedBy are treated as builtins.Super restricted mode exists to let you treat all other predicates as ordinary facts/rules without any built-in evaluation.
Note on log:query: Eyeling also recognizes a special top-level directive of the form {...} log:query {...}. to select which results to print. This is not a builtin predicate (it is not evaluated as part of goal solving); it is handled by the parser/CLI/output layer. See §11.3.5 below and Chapter 13 for details.
Every builtin returns a list of substitution deltas.
That means built-ins can be:
[{}] for success or [] for failure)List operations are a common source of generators; numeric comparisons are tests.
Below is a drop-in replacement for §11.3 “A tour of builtin families” that aims to be fully self-contained and to cover every builtin currently implemented in lib/builtins.js (including the rdf:first / rdf:rest aliases).
Eyeling’s builtins are best thought of as foreign predicates: they look like ordinary N3 predicates in your rules, but when the engine tries to satisfy a goal whose predicate is a builtin, it does not search the fact store. Instead, it calls a piece of JavaScript that implements the predicate’s semantics.
That one sentence explains a lot of “why does it behave like that?”:
The N3 Builtins tradition often describes builtins using “schema” annotations like:
$s+ / $o+ — input must be bound (or at least not a variable in practice)$s- / $o- — output position (often a variable that will be bound)$s? / $o? — may be unbound$s.i — list element i inside the subject listEyeling is a little more pragmatic: it implements the spirit of these schemas, but it also has several “engineering” conventions that appear across many builtins:
?X) may be bound by a builtin if the builtin is written to do so.[] / _:) are frequently treated as “don’t care” placeholders. Many builtins accept a blank node in an output position and simply succeed without binding.With that, we can tour the builtin families as Eyeling actually implements them.
crypto: — digest functions (Node-only)These builtins hash a string and return a lowercase hex digest as a plain string literal.
crypto:sha, crypto:md5, crypto:sha256, crypto:sha512Shape: $literal crypto:sha256 $digest
Semantics (Eyeling):
Important runtime note: Eyeling uses Node’s crypto module. If crypto is not available (e.g., in some browser builds), these builtins simply fail (return no solutions).
Example:
"hello" crypto:sha256 ?d.
# ?d becomes "2cf24dba5...<snip>...9824"
math: — numeric and numeric-like relationsEyeling’s math: builtins fall into three broad categories:
>, <, =, …).A key design choice: Eyeling parses numeric terms fairly strictly, but comparisons accept a wider “numeric-like” domain including durations and date/time values in some cases.
These builtins succeed or fail; they do not introduce new bindings.
math:greaterThan (>)math:lessThan (<)math:notGreaterThan (≤)math:notLessThan (≥)math:equalTo (=)math:notEqualTo (≠)Shapes:
$a math:greaterThan $b.
$a math:equalTo $b.
Eyeling also accepts an older cwm-ish variant where the subject is a 2-element list:
( $a $b ) math:greaterThan true. # (supported as a convenience)
Accepted term types (Eyeling):
xsd:integer, xsd:decimal, xsd:float, xsd:double, and integer-derived types).123, -4.5, 1.2e3) when they look numeric.xsd:duration literals (treated as seconds via a simplified model).xsd:date and xsd:dateTime literals (converted to epoch seconds for comparison).Edge cases:
NaN is treated as not equal to anything, including itself, for math:equalTo.These are pure tests. In forward rules, if a test builtin is encountered before its inputs are bound and it fails, Eyeling may defer it and try other goals first; once variables become bound, the test is retried.
These are “function-like” relations where the subject is usually a list and the object is the result.
math:sumShape: ( $x1 $x2 ... ) math:sum $total
Eyeling also supports a small, EYE-style convenience for timestamp arithmetic:
(dateTime durationOrSeconds) math:sum dateTime
xsd:duration is interpreted as seconds (same model as math:difference).xsd:dateTime in UTC lexical form (...Z).math:productShape: ( $x1 $x2 ... ) math:product $total
math:sum, but multiplies.math:differenceThis one is more interesting because Eyeling supports a couple of mixed “numeric-like” cases.
Shape: ( $a $b ) math:difference $c
Eyeling supports:
c = a - b.(dateTime1 dateTime2) math:difference duration
xsd:duration in a seconds-only lexical form such as "PT900S"^^xsd:duration.math:lessThan, math:greaterThan, etc. because Eyeling’s numeric comparison builtins treat xsd:duration as seconds.(dateTime durationOrSeconds) math:difference dateTime
If the types don’t fit any supported case, the builtin fails.
math:quotientShape: ( $a $b ) math:quotient $q
b != 0.a / b, picking a suitable numeric datatype for output.math:integerQuotientShape: ( $a $b ) math:integerQuotient $q
math:remainderShape: ( $a $b ) math:remainder $r
math:roundedShape: $x math:rounded $n
Math.round, i.e. halves go toward +∞ (-1.5 -> -1, 1.5 -> 2).math:exponentiationShape: ( $base $exp ) math:exponentiation $result
base ** exp.exp = log(result) / log(base).This is a pragmatic inversion, not a full algebra system.
Eyeling implements these as a shared pattern: if the subject is numeric, compute object; else if the object is numeric, compute subject via an inverse function; if both sides are unbound, succeed once (don’t enumerate).
math:absoluteValuemath:negationmath:degrees (and implicitly its inverse “radians” conversion)math:sin, math:cos, math:tanmath:asin, math:acos, math:atanmath:sinh, math:cosh, math:tanh (only if JS provides the functions)Example:
"0"^^xsd:double math:cos ?c. # forward
?x math:cos "1"^^xsd:double. # reverse (principal acos)
Inversion uses principal values (e.g., asin, acos, atan) and does not attempt to enumerate periodic families of solutions.
time: — dateTime inspection and “now”Eyeling’s time builtins work over xsd:dateTime lexical forms. They are deliberately simple: they extract components from the lexical form rather than implementing a full time zone database.
Implementation: these helpers live in lib/time.js and are called from lib/engine.js’s builtin evaluator.
time:yeartime:monthtime:daytime:hourtime:minutetime:secondShape: $dt time:month $m
Semantics:
xsd:dateTime literal in a format Eyeling can parse.time:timeZoneShape: $dt time:timeZone $tz
Returns the trailing zone designator:
"Z" for UTC, or"+02:00" / "-05:00"It yields a plain string literal (and also accepts typed xsd:string literals).
time:localTimeShape: "" time:localTime ?now
Binds ?now to the current local time as an xsd:dateTime literal.
Two subtle but important engineering choices:
list: — list structure, iteration, and higher-order helpersEyeling has a real internal list term (ListTerm) that corresponds to N3’s (a b c) surface syntax.
rdf:first / rdf:rest) are materializedN3 and RDF can also express lists as linked blank nodes using rdf:first / rdf:rest and rdf:nil. Eyeling materializes such structures into internal list terms before reasoning so that list:* builtins can operate uniformly.
For convenience and compatibility, Eyeling treats:
rdf:first as an alias of list:firstrdf:rest as an alias of list:restlist:first (and rdf:first)Shape: (a b c) list:first a
list:rest (and rdf:rest)Shape: (a b c) list:rest (b c)
Eyeling supports both:
(a b c), and(a b ... ?T) internally.For open lists, “rest” preserves openness:
(a ... ?T) is ?T(a b ... ?T) is (b ... ?T)list:firstRestThis is a very useful “paired” view of a list.
Forward shape: (a b c) list:firstRest (a (b c))
Backward shapes (construction):
(first restList), it can construct the list.rest is a variable, Eyeling constructs an open list term.This is the closest thing to Prolog’s [H|T] in Eyeling.
Implementation note (performance): list:firstRest is a hot builtin in many recursive list-building programs (including path finding). Eyeling constructs the new prefix using pre-sized arrays and simple loops (instead of spread syntax) to reduce transient allocations.
These builtins can yield multiple solutions.
list:memberShape: (a b c) list:member ?x
Generates one solution per element, unifying the object with each member.
list:inShape: ?x list:in (a b c)
Same idea, but the list is in the object position and the subject is unified with each element.
list:iterateShape: (a b c) list:iterate ?pair
Generates (index value) pairs with 0-based indices:
(0 a), (1 b), (2 c), …A nice ergonomic detail: the object may be a pattern such as:
(a b c) list:iterate ( ?i "b" ).
In that case Eyeling unifies ?i with 1 and checks the value part appropriately.
list:memberAtShape: ( (a b c) 1 ) list:memberAt b
The subject must be a 2-element list: (listTerm indexTerm).
Eyeling can use this relationally:
iterate, but with separate index/value logic).Indices are 0-based.
list:lengthShape: (a b c) list:length 3
Returns the length as an integer token literal.
A small but intentional strictness: if the object is already ground, Eyeling does not accept “integer vs decimal equivalences” here; it wants the exact integer notion.
list:lastShape: (a b c) list:last c
Returns the last element of a non-empty list.
list:reverseReversible in the sense that either side may be the list:
It does not enumerate arbitrary reversals; it’s a deterministic transform once one side is known.
list:removeShape: ( (a b a c) a ) list:remove (b c)
Removes all occurrences of an item from a list.
Important requirement: the item to remove must be ground (fully known) before the builtin will run.
list:notMember (test)Shape: (a b c) list:notMember x
Succeeds iff the object cannot be unified with any element of the subject list. As a test, it typically works best once its inputs are bound; in forward rules Eyeling may defer it if it is reached before bindings are available.
list:appendThis is list concatenation, but Eyeling implements it in a pleasantly relational way.
Forward shape: ( (a b) (c) (d e) ) list:append (a b c d e)
Subject is a list of lists; object is their concatenation.
Splitting (reverse-ish) mode: If the object is a concrete list, Eyeling tries all ways of splitting it into the given number of parts and unifying each part with the corresponding subject element. This can yield multiple solutions and is handy for logic programming patterns.
list:sortSorts a list into a deterministic order.
Like reverse, this is “reversible” only in the sense that if one side is a list, the other side can be unified with its sorted form.
list:map (higher-order)This is one of Eyeling’s most powerful list builtins because it calls back into the reasoner.
Shape: ( (x1 x2 x3) ex:pred ) list:map ?outList
Semantics:
(inputList predicateIri).inputList must be ground.For each element el in the input list, Eyeling proves the goal:
el predicateIri ?y.
using the full engine (facts, backward rules, and builtins).
?y values are collected in proof order and concatenated into the output list.This makes list:map a compact “query over a list” operator.
log: — unification, formulas, scoping, and meta-level controlThe log: family is where N3 stops being “RDF with rules” and becomes a meta-logic. Eyeling supports the core operators you need to treat formulas as terms, reason inside quoted graphs, and compute closures.
log:equalToShape: $x log:equalTo $y
This is simply term unification: it succeeds if the two terms can be unified and returns any bindings that result.
log:notEqualTo (test)Succeeds iff the terms cannot be unified. No new bindings.
In Eyeling, a quoted formula { ... } is represented as a GraphTerm whose content is a list of triples (and, when parsed from documents, rule terms can also appear as log:implies / log:impliedBy triples inside formulas).
log:conjunctionShape: ( F1 F2 ... ) log:conjunction F
true is treated as the empty formula and is ignored in the merge.log:conclusionShape: F log:conclusion C
Computes the deductive closure of the formula F using only the information inside F:
F as facts.{A} => {B} (represented internally as a log:implies triple between formulas) as a forward rule.{A} <= {B} as the corresponding forward direction for closure purposes.Eyeling caches log:conclusion results per formula object, so repeated calls with the same formula term are cheap.
These builtins reach outside the current fact set. They are synchronous by design.
log:contentShape: <doc> log:content ?txt
xsd:string literal.file://).log:semanticsShape: <doc> log:semantics ?formula
Dereferences and parses the remote/local resource as N3/Turtle-like syntax, returning a formula.
A nice detail: top-level rules in the parsed document are represented as data inside the returned formula using log:implies / log:impliedBy triples between formula terms. This means you can treat “a document plus its rules” as a single first-class formula object.
log:semanticsOrErrorLike log:semantics, but on failure it returns a string literal such as:
error(dereference_failed,...)error(parse_error,...)This is convenient in robust pipelines where you want logic that can react to failures.
log:parsedAsN3Shape: " ...n3 text... " log:parsedAsN3 ?formula
Parses an in-memory string as N3 and returns the corresponding formula.
log:rawTypeReturns one of four IRIs:
log:Formula (quoted graph)log:Literalrdf:List (closed or open list terms)log:Other (IRIs, blank nodes, etc.)These two are classic N3 “bridge” operators between structured data and concrete RDF literal forms.
log:dtlitRelates a datatype literal to a pair (lex datatypeIri).
(stringLiteral datatypeIri).Language-tagged strings are normalized: they are treated as having datatype rdf:langString.
log:langlitRelates a language-tagged literal to a pair (lex langTag).
"hello"@en, subject can become ("hello" "en").("hello" "en"), object can become "hello"@en.log:implies and log:impliedByAs syntax, Eyeling parses {A} => {B} and {A} <= {B} into internal forward/backward rules.
As builtins, log:implies and log:impliedBy let you inspect the currently loaded rule set:
log:implies enumerates forward rules as (premiseFormula, conclusionFormula) pairs.log:impliedBy enumerates backward rules similarly.Each enumerated rule is standardized apart (fresh variable names) before unification so you can safely query over it.
log:query (output selection)Shape (top level only):
{ ...premise... } log:query { ...conclusion... }.
log:query is best understood as an output projection, not as a rule and not as a normal builtin:
log:includes in the global scope).This is “forward-rule-like” in spirit (premise ⇒ conclusion), but the instantiated conclusion triples are not added back into the fact store; they are just what Eyeling prints.
Important details:
{...} log:query {...}. directives are recognized. Inside quoted formulas (or inside rule bodies/heads) it is just an ordinary triple.--stream has no effect when any log:query directives are present.log:includes (and optionally log:conclusion) instead.Example (project a result set):
@prefix : <urn:ex:>.
@prefix log: <http://www.w3.org/2000/10/swap/log#>.
{ :a :p ?x } => { :a :q ?x }.
:a :p :b.
{ :a :q ?x } log:query { :result :x ?x }.
Output (only):
:result :x :b .
log:includes and friendslog:includesShape: Scope log:includes GoalFormula
This proves all triples in GoalFormula as goals, returning the substitutions that make them provable.
Eyeling has two modes:
Scope is a formula {...}
N.N, the builtin “delays” by failing at that point in the search.This priority mechanism exists because Eyeling’s forward chaining runs in outer iterations with a “freeze snapshot then evaluate scoped builtins” phase. The goal is to make scoped meta-builtins stable and deterministic: they query a fixed snapshot rather than chasing a fact store that is being mutated mid-iteration.
Also supported:
true, meaning the empty formula, which is always included (subject to the priority gating above).log:notIncludes (test)Negation-as-failure version: it succeeds iff log:includes would yield no solutions (under the same scoping rules).
log:collectAllInShape: ( ValueTemplate WhereFormula OutList ) log:collectAllIn Scope
WhereFormula in the chosen scope.ValueTemplate and collects the instantiated terms into a list.OutList with that list.OutList is a blank node, Eyeling just checks satisfiable without binding/collecting.This is essentially a list-producing “findall”.
log:forAllIn (test)Shape: ( WhereFormula ThenFormula ) log:forAllIn Scope
For every solution of WhereFormula, ThenFormula must be provable under the bindings of that solution. If any witness fails, the builtin fails. No bindings are returned.
As a pure test (no returned bindings), this typically works best once its inputs are bound; in forward rules Eyeling may defer it if it is reached too early.
log:skolemShape: $groundTerm log:skolem ?iri
Deterministically maps a ground term to a Skolem IRI in Eyeling’s well-known namespace. This is extremely useful when you want a repeatable identifier derived from structured content.
log:uriBidirectional conversion between IRIs and their string form:
<...> in Turtle/N3, and it rejects _:-style strings to avoid confusing blank nodes with IRIs.log:traceAlways succeeds once and prints a debug line to stderr:
<s> TRACE <o>
using the current prefix environment for pretty printing.
Implementation: this is implemented by lib/trace.js and called from lib/engine.js.
log:outputStringAs a goal, this builtin simply checks that the terms are sufficiently bound/usable and then succeeds. The actual “printing” behavior is handled by the CLI:
--strings / -r, the CLI collects all log:outputString triples from the saturated closure.This is a pure test/side-effect marker (it shouldn’t drive search; it should merely validate that strings exist once other reasoning has produced them). In forward rules Eyeling may defer it if it is reached before the terms are usable.
string: — string casting, tests, and regexesEyeling implements string builtins with a deliberate interpretation of “domain is xsd:string”:
string:concatenationShape: ( s1 s2 ... ) string:concatenation s
Casts each element to a string and concatenates.
string:formatShape: ( fmt a1 a2 ... ) string:format out
A tiny sprintf subset:
%s and %%.%d, %f, …) causes the builtin to fail.string:containsstring:containsIgnoringCasestring:startsWithstring:endsWithAll are pure tests: they succeed or fail.
string:equalIgnoringCasestring:notEqualIgnoringCasestring:greaterThanstring:lessThanstring:notGreaterThan (≤ in Unicode codepoint order)string:notLessThan (≥ in Unicode codepoint order)These compare JavaScript strings directly, i.e., Unicode code unit order (practically “lexicographic” for many uses, but not locale-aware collation).
Eyeling compiles patterns using JavaScript RegExp, with a small compatibility layer:
\p{L}) or code point escapes (\u{...}), Eyeling enables the /u flag.string:matches / string:notMatches (tests)Shape: data string:matches pattern
Tests whether pattern matches data.
string:replaceShape: ( data pattern replacement ) string:replace out
pattern as a global regex (/g).$1, $2, etc. work).string:scrapeShape: ( data pattern ) string:scrape out
Matches the regex once and returns the first capturing group (group 1). If there is no match or no group, it fails.
log:outputString as a controlled side effectFrom a logic-programming point of view, printing is awkward: if you print during proof search, you risk producing output along branches that later backtrack, or producing the same line multiple times in different derivations. Eyeling avoids that whole class of problems by treating “output” as data.
The predicate log:outputString is the only officially supported “side-effect channel”, and even it is handled in two phases:
During reasoning (declarative phase):
log:outputString behaves like a pure test builtin (implemented in lib/builtins.js): it succeeds when its arguments are well-formed and sufficiently bound (notably, when the object is a string literal that can be emitted). Importantly, it does not print anything at this time. If a rule derives a triple like:
:k log:outputString "Hello\n".
then that triple simply becomes part of the fact base like any other fact.
log:outputString facts and renders them deterministically (this post-pass lives in lib/explain.js). Concretely, the CLI collects all such triples, orders them in a stable way (using the subject as a key so output order is reproducible), and concatenates their string objects into the final emitted text.This separation is not just an aesthetic choice; it preserves the meaning of logic search:
log:outputString facts can be traced back to the rules that produced them.In short: Eyeling makes log:outputString safe by refusing to treat it as an immediate effect. It is a declarative output fact whose concrete rendering is a final, deterministic post-processing step.
lib/deref.js)Some N3 workflows treat IRIs as pointers to more knowledge. Eyeling supports this with:
log:content — fetch raw textlog:semantics — fetch and parse into a formulalog:semanticsOrError — produce either a formula or an error literalderef.js is deliberately synchronous so the engine can remain synchronous.
fetch() (keeps the engine synchronous)file:// URIs) via fs.readFileSyncdemo.html) to avoid UI blocking.Dereferencing is cached by IRI-without-fragment (fragments are stripped). There are separate caches for:
This is both a performance and a stability feature: repeated log:semantics calls in backward proofs won’t keep refetching.
Eyeling can optionally rewrite http://… to https://… before dereferencing (CLI --enforce-https, or API option). This is a pragmatic “make more things work in modern environments” knob.
Once reasoning is done (or as it happens in streaming mode), Eyeling converts derived facts back to N3.
lib/printing.js)Printing handles:
PrefixEnvrdf:type as aowl:sameAs as =The printer is intentionally simple; it prints what Eyeling can parse.
When enabled, Eyeling prints a compact comment block per derived triple:
It’s a “why this triple holds” explanation, not a globally exported proof graph.
Implementation note: the engine records lightweight DerivedFact objects during forward chaining, and lib/explain.js (via makeExplain(...)) is responsible for turning those objects into the human-readable proof comment blocks.
The engine’s reasonStream API can accept an onDerived callback. Each time a new forward fact is derived, Eyeling can report it immediately.
This is especially useful in interactive demos (and is the basis of the playground streaming tab).
Eyeling exposes itself in three layers.
eyeling.js)The bundle contains the whole engine. The CLI path is the “canonical behavior”:
The current CLI supports a small set of flags (see lib/cli.js):
-a, --ast — print the parsed AST as JSON and exit.-d, --deterministic-skolem — make log:skolem stable across runs.-e, --enforce-https — rewrite http://… to https://… for dereferencing builtins.-p, --proof-comments — include per-fact proof comment blocks in output.-r, --strings — after reasoning, render only log:outputString values (ordered by subject key).-s, --super-restricted — disable all builtins except log:implies / log:impliedBy.-t, --stream — stream derived triples as soon as they are derived.-v, --version — print version and exit.-h, --help — show usage.lib/entry.js: bundler-friendly exportslib/entry.js exports:
reasonStream, main, versionlex, Parser, forwardChain, etc.)index.js: the npm API wrapperThe npm reason(...) function does something intentionally simple and robust:
node eyeling.js ... input.n3)This ensures the API matches the CLI perfectly and keeps the public surface small.
One practical implication:
reasonStream from the bundle entry rather than the subprocess-based API.Consider:
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#>.
@prefix : <http://example.org/socrates#>.
:Socrates a :Human.
:Human rdfs:subClassOf :Mortal.
{ ?S a ?A. ?A rdfs:subClassOf ?B } => { ?S a ?B }.
What Eyeling does:
(:Socrates rdf:type :Human)(:Human rdfs:subClassOf :Mortal) and one forward rule:?S a ?A, ?A rdfs:subClassOf ?B?S a ?BForward chaining scans the rule and calls proveGoals on the body.
Proving ?S a ?A matches the first fact, producing { S = :Socrates, A = :Human }.
With that substitution, the second goal becomes :Human rdfs:subClassOf ?B. It matches the second fact, extending to { B = :Mortal }.
Eyeling instantiates the head ?S a ?B → :Socrates a :Mortal.
That’s the whole engine in miniature: unify, compose substitutions, emit head triples.
Eyeling is small, which makes it pleasant to extend — but there are a few invariants worth respecting.
Most extensions belong in lib/builtins.js (inside evalBuiltin):
{ varName: Term }, not full substitutions.isBuiltinPred(...).A small architectural note: lib/builtins.js is initialized by the engine via makeBuiltins(deps). It receives hooks (unification, proving, deref, scoped-closure helpers, …) instead of importing the engine directly, which keeps the module graph acyclic and makes browser bundling easier.
If your builtin needs a stable view of the scoped closure, follow the scoped-builtin pattern:
facts.__scopedSnapshotfacts.__scopedClosureLevel and priority gatingAnd if your builtin is “forward-only” (needs inputs bound), it’s fine to fail early until inputs are available — forward rule proving enables builtin deferral, so the goal can be retried later in the same conjunction.
If you add a new Term subclass, you’ll likely need to touch:
termToN3)unifyTerm, termsEqual, fast keys)gcCollectVarsInTerm)If you extend parsing, preserve the Rule invariants:
headBlankLabels must reflect blanks occurring explicitly in the head before skolemizationEyeling’s codebase is compact because it chooses one powerful idea and leans into it:
Use backward proving as the “executor” for forward rule bodies.
That design makes built-ins and backward rules feel like a standard library of relations, while forward chaining still gives you the determinism and “materialized closure” feel of Datalog.
If you remember only one sentence from this handbook, make it this:
Eyeling is a forward-chaining engine whose rule bodies are solved by a Prolog-like backward prover with built-ins.
Everything else is engineering detail — interesting, careful, sometimes subtle — but always in service of that core shape.
This appendix is a compact, user-facing reference for running Eyeling and writing inputs that work well. For deeper explanations and implementation details, follow the chapter links in each section.
Eyeling is distributed as an npm package.
Run without installing:
npx eyeling --help
npx eyeling yourfile.n3
Or install globally:
npm i -g eyeling
eyeling yourfile.n3
See also: Chapter 14 — Entry points: CLI, bundle exports, and npm API.
By default, Eyeling prints newly derived forward facts (the heads of fired => rules), serialized as N3. It does not reprint your input facts.
If the input contains one or more top-level log:query directives:
{ ...premise... } log:query { ...conclusion... }.
Eyeling still computes the saturated forward closure, but it prints only the unique instantiated conclusion triples of those log:query directives (instead of all newly derived facts). This is useful when you want a forward-rule-like projection of results.
For proof/explanation output and output modes, see:
The authoritative list is always:
eyeling --help
Options:
-a, --ast Print parsed AST as JSON and exit.
-d, --deterministic-skolem Make log:skolem stable across reasoning runs.
-e, --enforce-https Rewrite http:// IRIs to https:// for log dereferencing builtins.
-h, --help Show this help and exit.
-p, --proof-comments Enable proof explanations.
-r, --strings Print log:outputString strings (ordered by key) instead of N3 output.
-s, --super-restricted Disable all builtins except => and <=.
-t, --stream Stream derived triples as soon as they are derived.
-v, --version Print version and exit.
Note: when log:query directives are present, Eyeling cannot stream output (the selected results depend on the saturated closure), so --stream has no effect in that mode.
See also:
Eyeling implements a practical N3 subset centered around facts and rules.
A fact is a triple ending in .:
:alice :knows :bob .
A forward rule:
{ ?x :p ?y } => { ?y :q ?x } .
A backward rule:
{ ?x :ancestor ?z } <= { ?x :parent ?z } .
Quoted graphs/formulas use { ... }. Inside a quoted formula, directive scope matters:
@prefix/@base and PREFIX/BASE directives may appear at top level or inside { ... }, and apply to the formula they occur in (formula-local scoping).For the formal grammar, see the N3 spec grammar:
See also:
Eyeling supports a built-in “standard library” across namespaces like log:, math:, string:, list:, time:, crypto:.
References:
eyeling-builtins.ttl (in this repo)If you are running untrusted inputs, consider --super-restricted to disable all builtins except implication.
log:skolemWhen forward rule heads contain blank nodes (existentials), Eyeling replaces them with generated Skolem IRIs so derived facts are ground.
See:
log:semanticslog:content, log:semantics, and related builtins dereference IRIs and parse retrieved content. This is powerful, but it is also I/O.
See:
Safety tip:
--super-restricted if you want to ensure no dereferencing (and no other builtins) can run.If you depend on Eyeling as a library, the package exposes:
reason(...)), andSee:
If you want to go deeper into N3 itself and the logic/programming ideas behind Eyeling, these are good starting points:
N3 / Semantic Web specs and reports:
Logic & reasoning background (Wikipedia):
RDF succeeded by making a radical design choice feel natural: reduce meaning to small, uniform statements—triples—that can be published, merged, and queried across boundaries. A triple does not presume a database schema, a programming language, or a particular application. It presumes only that names (IRIs) can be shared, and that graphs can be combined.
That strength also marks RDF’s limit. The moment a graph is expected to do something—normalize values, reconcile vocabularies, derive implied relationships, enforce a policy, compute a small transformation—logic tends to migrate into code. The graph becomes an inert substrate while the decisive semantics hide in scripts, services, ETL pipelines, or bespoke rule engines. What remains portable is the data; what often becomes non-portable is the meaning.
Notation3 (N3) sits precisely at that seam. It remains a readable way to write RDF, but it also treats graphs themselves as objects that can be described, matched, and related. The N3 Community Group’s specification presents N3 as an assertion and logic language that extends RDF rather than replacing it: https://w3c.github.io/N3/spec/.
The essential move is quotation: writing a graph inside braces as a thing that can be discussed. Once graphs can be quoted, rules become graph-to-graph transformations. The familiar implication form, { … } => { … } ., reads as a piece of prose: whenever the antecedent pattern holds, the consequent pattern follows. Tim Berners-Lee’s design note frames this as a web-friendly logic with variables and nested graphs: https://www.w3.org/DesignIssues/Notation3.html.
This style of rule-writing makes rules first-class, publishable artifacts. It keeps the unit of exchange stable. Inputs are RDF graphs; outputs are RDF graphs. Inference produces new triples rather than hidden internal state. Rule sets can be versioned alongside data, reviewed as text, and executed by different engines that implement the same semantics. That portability theme runs back to the original W3C Team Submission: https://www.w3.org/TeamSubmission/n3/.
Practical reasoning also depends on computation: lists, strings, math, comparisons, and the other “small operations” that integration work demands. N3 addresses this by standardizing built-ins—predicates with predefined behavior that can be used inside rule bodies while preserving the declarative, graph-shaped idiom. The built-ins report is here: https://w3c.github.io/N3/reports/20230703/builtins.html.
Testing is where rule languages either converge or fragment. Different implementations can drift on scoping, blank nodes, quantification, and built-in behavior. N3’s recent direction has been toward explicit, testable semantics, documented separately as model-theoretic foundations: https://w3c.github.io/N3/reports/20230703/semantics.html.
In that context, public conformance suites become more than scoreboards: they are the mechanism by which interoperability becomes measurable. The community test suite lives at https://codeberg.org/phochste/notation3tests/, with comparative results published in its report: https://codeberg.org/phochste/notation3tests/src/branch/main/reports/report.md.
The comparison with older tools is historically instructive. Cwm (Closed World Machine) was an early, influential RDF data processor and forward-chaining reasoner—part of the lineage that treated RDF (often written in N3) as something executable: https://www.w3.org/2000/10/swap/doc/cwm.
What motivates Notation3, in the end, is architectural restraint. It refuses to let “logic” become merely a private feature of an application stack. It keeps meaning close to the graph: rules are expressed as graph patterns; results are expressed as triples; computation is pulled in through well-defined built-ins rather than arbitrary code. This produces a style of working where integration and inference are not sidecar scripts, but publishable artifacts—documents that can be inspected, shared, tested, and reused.
In that sense, N3 is less a bid to make the web “smarter” than a bid to make meaning portable: not only facts that travel, but also the explicit steps by which facts can be connected, extended, and made actionable—without abandoning the simplicity that made triples travel in the first place.
Notation3 (N3) rule sets often look similar to Prolog at the surface: they use variables, unification, and implication-style rules (“if these patterns match, then these patterns follow”). N3 is typically used in a different setting: instead of a single program operating over a single local database, N3 rules and data are commonly written as documents that can be published, shared, merged, and referenced across systems.
In practice, that setting is reflected in several common features of N3-style rule writing:
Engines can combine execution styles in different ways. One common pattern is to use a Prolog-like backward-chaining prover to satisfy rule bodies, while still using forward chaining to add the instantiated conclusions to the fact set until no new facts are produced.
Eyeling is a deterministic N3 engine: given facts and rules, it derives consequences to a fixpoint using forward rules proved by a backward engine. That makes it a good “meaning boundary” for LLM-assisted workflows: the LLM can draft and refactor N3, but Eyeling is what decides what follows.
A practical pattern is to treat the LLM as a syntax-and-structure generator and Eyeling as the semantic validator.
If the LLM is allowed to emit prose or “almost N3”, you’ll spend your time cleaning up. Instead, require:
@base).This is less about prompt craft and more about creating a stable interface between a text generator and a compiler-like consumer.
Run Eyeling immediately after generation:
Eyeling explicitly supports inference fuses: a forward rule with head false is a hard failure. This is extremely useful as a guardrail when you want “never allow X” constraints to stop the run.
Example fuse:
@prefix : <http://example/> .
{ ?u :role :Admin.
?u :disabled true.
} => false.
If you don’t want “stop the world”, derive a :Violation fact instead, and keep going.
The most robust way to keep LLM-generated logic plausible is to make it live under tests:
log:query directives to project a specific result set).This turns rule edits into a normal change-management loop: diffs are explicit, reviewable, and reproducible.
If you want a natural-language explanation, don’t ask the model to “explain the rules from memory”. Instead:
This keeps explanations anchored to what Eyeling actually derived.
When output looks wrong, the fix should be a change in the artifact:
:Violation derivation,Then regenerate/rewrite only the N3, rerun Eyeling, and review the diff.
A simple structure that keeps the LLM honest:
<base>#*.”:needsFact) rather than guessing.”The point isn’t that the LLM is “right”; it’s that Eyeling makes the result checkable, and the artifact becomes a maintainable program rather than a one-off generation.