Skip to content

Datalog in a box

Which boxes run downstream of this one? Which two write the same property? A Field document is a graph, and questions like these often need to follow its connections more than once.

Datalog gives you a way to ask them. State some facts, write rules that derive more facts, and the engine follows those rules until it has the answers. Facts can come from Datalog text, Kotlin collections, or the document you have open. For finding values by pattern inside an object, see Querying Object Graphs.

Facts, rules, and a question

A fact is a row. A rule reads head :- body, which you can say out loud as head if body. Capitalised names are variables; lowercase names are constants, so imports(app, ui) and imports("app", "ui") are the same fact.

:::static
val d = Datalog()
d.add(
    """
    #datalog
    % which module pulls in which
    imports(app, ui).    imports(app, store).
    imports(ui, util).   imports(store, util).  imports(util, log).

    needs(A, B) :- imports(A, B).
    needs(A, C) :- needs(A, B), imports(B, C).
""",
)

println("app needs " + d.query("needs(app, X)").column("X").joinToString(", "))
println("does app need log? " + d.ask("needs(app, log)"))
println("modules that need log: " + d.query("needs(A, log)").size)

the Kotlin box in the editor with two inline output strips wedged between its lines: one under the first println reading 'app needs log, store, ui, util', one under the last carrying 'modules that need log: 4' and 'does app need log? true'. Inside the triple-quoted string the #datalog marker is gold, rule heads are blue and the variables A, B and C are orange

Answers land beside the line that asked: edit a rule, run, read. Two rules for needs, read as alternatives: app needs whatever it imports, and it needs whatever that needs. Nothing says how far to follow the chain, and nothing has to.

The #datalog on the first line is a marker the editor looks for; the parser ignores it. Leave it out and the program still runs. Put it in and the region lights up, which the last section covers.

The answer is a fixpoint

query runs one goal. solve hands back the whole model: every relation, with every fact the rules can derive.

:::static
val d = Datalog()
d.add(
    """
    #datalog
    edge(a, b). edge(b, c). edge(c, d). edge(d, b).
    path(X, Y) :- edge(X, Y).
    path(X, Z) :- path(X, Y), edge(Y, Z).
""",
)

display("main", d.solve())

the main output channel: a heading reading '16 facts in 2 relations' over a three-column table of relation, arity and rows, listing edge at arity 2 with 4 rows and path at arity 2 with 12

Four stated edges produce twelve paths. The cycle b → c → d → b makes those three nodes mutually reachable, including each from itself. The rules still stop: they can only combine values that are already there, so after a few rounds there is nothing new to add.

Why a fact is there

why shows how a fact was derived: which rules fired, and which stated facts they started from.

:::static
val d = Datalog()
d.add(
    """
    #datalog
    edge(a, b). edge(b, c). edge(c, d). edge(d, b).
    path(X, Y) :- edge(X, Y).
    path(X, Z) :- path(X, Y), edge(Y, Z).
""",
)

display("main", d.why("path(a, d)"))

a derivation tree headed 'why path(a, d)', indented by arrow glyphs: path(a, d) from the recursive clause, then path(a, c) from the same clause, then path(a, b) from the base clause, and at the leaves edge(a, b), edge(b, c) and edge(c, d), each tagged [fact]. Every interior node carries the rule that fired it in brackets

Each line names the rule that produced it, and the leaves are facts you stated. Cycles are fine: path(a, a) in the graph above gets a finite proof too.

A proof runs through an aggregate as well. A fact derived through count or sum lists the rows that contributed to it and the arithmetic that combined them:

payroll(sales, 300)   [payroll(D, S) :- dept(D), S = sum P : { emp(_1, D, P) }]
    dept(sales)   [fact]
    S = sum P : { emp(_1, sales, P) }   [aggregate: sum over 2 tuples = 300]
        emp(ada, sales, 100)   [fact; contributes P = 100]
        emp(bob, sales, 200)   [fact; contributes P = 200]

min and max label the tuple that was the extreme, and keep the rest of the group in the proof too, because "nothing bigger exists" is half of what the answer means. A group is capped at 24 lines of display; the value is always computed over the whole of it. A negated body literal appears as its own step, since "no such fact" is often the reason a rule fired.

Why one is not

An empty result gives no reason, so whyNot runs the query one goal at a time and names the goal where the answers ran out.

whynot cell(C, V), V > 5, colour(C, red)   no answers
     1  cell(C, V)      3 answers
     2  V > 5           1 answer
     3  colour(C, red)  0 answers   <- nothing survives here
    a binding that got this far: C = a3, V = 10

Ask about a single derived fact and it does the same walk once per rule that could have produced it, with the head's constants filled in, and puts the attempt that got furthest first:

whynot path(a, e)   not in the model; 2 rule(s) could have produced it
  via path(X, Z) :- path(X, Y), edge(Y, Z)
       1  path(a, Y)  3 answers
       2  edge(Y, e)  0 answers   <- nothing survives here
      a binding that got this far: Y = b
  via path(X, Y) :- edge(X, Y)
       1  edge(a, e)  0 answers   <- nothing survives here

whyNot tells apart three causes that look the same in an empty result: a goal with no matching rows, a predicate nothing defines, and a test written before the goal that binds its variable. The last is reported as "not evaluable this early", because the fix is to reorder the rule rather than to look for missing data.

Facts from Kotlin objects

facts takes Kotlin objects and a projection, so the rules can run over whatever you already have in the box. Negation and aggregation are both available, with the usual condition: they need the relations they read to be finished first, so a rule may not negate or aggregate over something that depends on itself. The engine checks and says so by name if you try.

:::static
data class Take(val clip: String, val shot: String, val seconds: Int, val rating: Int)

val takes = [
    Take("a017", "wide", 42, 5),
    Take("a018", "wide", 38, 2),
    Take("a021", "close", 19, 4),
    Take("a022", "close", 22, 3),
    Take("a030", "insert", 8, 2),
    Take("a031", "insert", 11, 1),
]

val d = Datalog()
d.facts("take", takes) { [it.clip, it.shot, it.seconds, it.rating] }
d.facts("shot", [["wide"], ["close"], ["insert"]])
d.add(
    """
    #datalog
    covered(S) :- take(_, S, _, R), R >= 4.
    reshoot(S) :- shot(S), !covered(S).
    footage(S, Total, Best) :- shot(S),
        Total = sum Sec : { take(_, S, Sec, _) },
        Best  = max R   : { take(_, S, _, R) }.
""",
)

display("main", d.query("footage(S, Total, Best)"))
display("reshoot", d.query("reshoot(S)"))

two stacked output channels. The main one, headed '?- footage(S, Total, Best) (3 rows)', tabulates close 41 4, insert 19 2 and wide 80 5: one row per shot, not per take. The reshoot channel below it, headed '?- reshoot(S) (1 row)', holds the single answer insert

An aggregate groups by whichever of its variables the enclosing rule has already bound. shot(S) binds S before the aggregate runs, so sum Sec : { take(_, S, Sec, _) } totals one shot at a time. Take shot(S) out and you get one number for the whole table.

_ is a hole, and each one is separate: take(_, S, Sec, _) means some take of this shot, not a take whose clip and rating are equal. Under ! a hole is existential, so !covered(S) and !child(_, X) both mean "no row matches".

The document is a database

Box.datalog(...) loads the graph you are looking at. Boxes, names, edges, nesting, frames and scalar properties all arrive as relations:

:::static
display("main", dlBoxRelationTable())

a two-column table headed 'what a Field document looks like as facts', pairing each relation with the thing it has one row per: box(Id) every live box, name(Id, Name) a display name where there is one, child(Parent, Child) a graph edge in the direction execution flows, nested(Outer, Inner), frame(Id, X, Y, W, H) position and size, prop(Id, Key, Value) one scalar property, running(Id, Strand) a strand currently executing

Which makes the recursive questions about a document short. Here the graph is built in the box so the answer is reproducible, but thisBox.datalog(program) runs the identical rules against the document you have open:

:::static
fun boxNamed(id: String, name: String, parent: Box?): Box {
    val b = Box(id)
    b.name = name
    if (parent != null) {
        parent.children.add(b)
        b.parents.add(parent)
    }
    return b
}

val root = boxNamed("root", "document", null)
val loader = boxNamed("loader", "loader", root)
val mesh = boxNamed("mesh", "mesh", loader)
val shader = boxNamed("shader", "shader", loader)
val draw = boxNamed("draw", "draw", mesh)
val notes = boxNamed("notes", "notes", root)

val d = Datalog()
d.addBoxGraph(root)
d.add(
    """
    #datalog
    downstream(A, B) :- child(A, B).
    downstream(A, C) :- downstream(A, B), child(B, C).
    leaf(X) :- box(X), !child(X, _).
""",
)

display("main", d.query("downstream(loader, X), name(X, N)"))
display("leaves", d.query("leaf(X), name(X, N)"))

two output channels over the six-box toy document. The main one, '?- downstream(loader, X), name(X, N) (3 rows)', lists draw, mesh and shader, draw among them although it hangs two edges below loader. The leaves channel, '?- leaf(X), name(X, N) (3 rows)', lists draw, notes and shader

The graph is read once, as a snapshot, so an answer describes the document as it was when you asked. Build a new Datalog to refresh it.

Any object graph

addBoxGraph knows about boxes. For any other Kotlin object, addObjectGraph walks it the way graphPaths does and turns every slot it visits into facts. A graphPaths pattern can address slots but cannot follow an edge repeatedly, or notice that two paths lead to the same object; Datalog supplies both.

addObjectGraph gives slot, link, segment, depth, at, value, type and writable; dlObjectGraphRules adds alias, reaches, unreached and leaf on top. In at(Path, ObjectId) every non-scalar value gets a number by identity, so two paths that reach the same object share a number, and alias is at joined with itself:

:::static
class Osc(var freq: Float, var gain: Float, val label: String)
class Rig(val carrier: Osc, val modulator: Osc, val spare: Osc, var title: String)

// carrier and spare are one oscillator under two names. Nothing in the type says so.
val shared = Osc(220f, 0.8f, "shared")
val rig = Rig(shared, Osc(440f, 0.2f, "mod"), shared, "rig")

val d = Datalog()
d.addObjectGraph(rig)
d.add(dlObjectGraphRules)

display("main", d.query("alias(A, B)"))
display("settable", d.query("reaches(\"carrier\", P), writable(P)"))

two output channels. The main one, '?- alias(A, B) (2 rows)', pairs carrier with spare and spare with carrier: the two names the walk found for one oscillator. The settable channel, '?- reaches(carrier, P), writable(P) (2 rows)', lists the paths carrier/freq and carrier/gain

alias finds the two names for one oscillator. It compares identity, not equality, so two separate oscillators with the same settings would not match.

The second answer lists every settable slot anywhere below carrier, which takes the recursive step a graphPaths pattern cannot express.

Narrow the walk with a graphPaths pattern: addObjectGraph(rig, "carrier/*") loads one level, and a literal prefix is much cheaper than **, which walks everything.

For a document, use addBoxGraph anyway: its child, nested, frame and prop relations are what you want to ask about, where a walk of a Box object turns up every internal field it has.

Drawing what it derived

Query results are rows of values you can use elsewhere in Field. Here the rules separate stated edges from the ones that follow, and the drawing gives each set a different weight:

:::static
val links = [0 to 1, 1 to 2, 2 to 3, 3 to 4, 4 to 0, 1 to 5, 5 to 6, 6 to 2]

val d = Datalog()
d.facts("edge", links.map { [it.first, it.second] })
d.add(
    """
    #datalog
    path(X, Y) :- edge(X, Y).
    path(X, Z) :- path(X, Y), edge(Y, Z).
    implied(X, Y) :- path(X, Y), !edge(X, Y), X != Y.
""",
)

val n = 7
fun at(i: Int): Vec2 {
    val a = i * 2.0 * PI / n - PI / 2
    return vec(340.0 + 300.0 * cos(a), 170.0 + 135.0 * sin(a))
}
fun link(a: Int, b: Int, c: Vec4, t: Double) = fline {
    val p = at(a)
    val q = at(b)
    moveTo(p.x, p.y)
    lineTo(q.x, q.y)
    color = c
    thickness = t
}
fun dot(i: Int) = fline {
    val p = at(i)
    val r = 5.0
    moveTo(p.x - r, p.y)
    lineTo(p.x, p.y - r)
    lineTo(p.x + r, p.y)
    lineTo(p.x, p.y + r)
    close()
    filled = true
    color = vec(0.98, 0.86, 0.30, 1.0)
}
// Node numbers, set just outside the ring so an edge never runs through one. Glyphs move with
// FLine.transform, which carries the cubic handles; nudging a node's point alone smears the letter.
fun label(i: Int): List<FLine> {
    val a = i * 2.0 * PI / n - PI / 2
    val p = at(i)
    val layout = layoutText("$i", typeface("Monospaced", 22.0))
    val dx = p.x + 26.0 * cos(a) - layout.advanceWidth / 2.0
    val dy = p.y + 26.0 * sin(a) + 8.0
    return layout.toFLines(color = vec(0.72, 0.78, 0.86, 1.0), thickness = 0.0, filled = true, stroked = false).map {
        line ->
        line.transform { q -> vec(q.x + dx, q.y + dy, q.z) }
    }
}

val implied = d.query("implied(X, Y)").rows.map {
    link((it[0] as Long).toInt(), (it[1] as Long).toInt(), vec(0.45, 0.70, 0.98, 0.20), 1.0)
}
val direct = links.map { link(it.first, it.second, vec(0.98, 0.86, 0.30, 1.0), 2.0) }

println("${links.size} stated edges, ${implied.size} implied by them")
val labels = (0 until n).flatMap { label(it) }
lines(implied + direct + (0 until n).map { dot(it) } + labels, w = 700.0, h = 350.0, fitToSize = true)

seven yellow diamond nodes numbered 0 to 6 around an ellipse. Eight thick yellow lines are the stated edges, the ring 0-1-2-3-4 back to 0, plus the branch 1-5-6 rejoining at 2. Behind them a dense haze of thin blue-grey lines joins almost every other pair of nodes: the implied edges, outnumbering the stated ones four to one

Eight edges, thirty-four consequences. The faint lines are what implied derived, and the rule that made them is the third line of the program. Its X != Y drops self-reachability, which the cycle 0 → 1 → 2 → 3 → 4 → 0 would otherwise contribute for every node on it.

A box that is Datalog

Set a box's main language to Datalog (Set Main Language: Datalog in the command palette) and its children hold programs instead of Kotlin. A Datalog box accumulates: running a fragment merges it into that box's program and prints what the fragment asked for, so you can write a rule, run it, write a query underneath, and run just that line. Re-running the whole box is safe, because identical rules are merged rather than appended.

A bare non-ground atom on its own is read as a question, so over(C). asks rather than failing as a fact with a variable in it. Lines beginning with # are session commands.

No Kotlin at all in one of these. The box below is a Datalog box, and this is its whole contents:

:::static
% a spreadsheet's worth of cells, and one rule about them
cell(a1, 3). cell(a2, 4). cell(a3, 10).

over(C) :- cell(C, V), V > 5.

?- over(C).

a Datalog box in the editor: a grey comment line, three cell facts, the rule over(C) :- cell(C, V), V > 5, and the bare goal ?- over(C). Below the last line an inline output strip repeats the goal and gives a one-column table whose single answer is a3

Run a #why next to it and the box answers with the derivation rather than the row:

:::static
cell(a1, 3). cell(a3, 10).
over(C) :- cell(C, V), V > 5.

#why over(a3)

datalog-box-why

The session commands are #help, #boxes (load the live document into the base relations), #relations, #facts <relation>, #program, #why <goal>, #whynot <goal> and #reset. A #why about a fact that is not there points you at #whynot, which is where you wanted to be.

The editor knows the language

A Kotlin string whose first line is #datalog is an embedded region, in the same way #stacksl and #petri regions work (Language Extensions). Rule heads are coloured differently from the same predicate used in a body, variables differently from constants, and hovering a predicate reports its arity and how many clauses define it. Completion offers the program's own predicates first, then the builtins and the box-graph relations.

eleven lines of Kotlin in the editor, the middle eight of them inside a triple-quoted string that opens with a gold #datalog. Within the region the rule heads needs, busy and orphan are blue while the same imports predicate is blue where it heads a fact and plain white inside a rule body; variables are orange, constants white, and the aggregate keyword count is violet

Diagnostics inside a region are lexical only: unbalanced brackets, an unterminated string, a clause with no .. The rules in "What it refuses" below are checked when the program runs.

Rest the cursor on a statement and the note beside it answers from the box's model: a rule says how many facts its body derives there and draws them as a table, a query shows its answers, and a fact says whether it is in the model and which rules read its relation. A rule edited since it last ran is reported as not in the model, and no directive is ever run for a note — see Line notes.

Reference

Syntax. % and // start a line comment, /* */ a block. A clause ends with .. A variable starts with a capital letter or _; everything else lowercase is a constant string. Numbers, "strings", true, false and null are literals. !goal or not goal negates. ?- goal. records a query.

Arithmetic. + - * /, mod, div, and comparisons = != < <= > >=. X = expr binds X when nothing has bound it yet and compares when something has. + concatenates if either side is a string. Every integral number is stored as a Long, including a computed 4.0 / 2.0, so arithmetic results join with stated facts.

Functions. abs sign sqrt floor ceil round pow exp log sin cos tan atan2 hypot min max len upper lower trim cat substr indexOf contains startsWith endsWith split num str int not. A call to one of these on its own is a test, so contains(N, "shader") filters.

Aggregates. count sum min max mean collect, written X = op Expr : { body } (count takes no expression). min, max and mean of an empty group have no value, so the rule does not fire for that group; count and sum give 0. The target has to be a variable nothing has bound yet, so "the maximum, and who earned it" is two rules rather than one.

Explaining. why(goal) returns a derivation tree, through aggregates and negation as well as rules, or null when the fact is not in the model. whyNot(goal) returns the narrowing walk instead. Both implement ToHTML, so display(…) of either from a Kotlin box draws an indented table; the #why and #whynot directives inside a Datalog box print render()'s plain text instead.

What it refuses. A rule whose head has a variable the body never binds. A negated goal whose variables are bound nowhere else. A predicate used at two different arities. Negation or aggregation inside a recursive cycle, which it reports with the cycle named rather than picking an evaluation order.

Source. src/jvmMain/kotlin/afield/data/query/datalog/.