Complete, runnable programs, each with a plain-English explanation of
what it stores, what it asks, and what the answer means — no prior
Prolog or logic-programming background assumed (see
TUTORIAL.md if any of the vocabulary here is new to
you). Every snippet below was actually executed against this version of
Episteme — the #=> comments are real, captured output, not
hand-derived. Paste any one of them into iex -S mix from a checkout
of this repository (each is self-contained after the shared
alias/c setup in §0). Use
REFERENCE.md to look up the full detail (and further
examples) for any predicate used here, or CHEATSHEET.md
for a quick one-line reminder.
0. Shared setup
Every example below assumes this is already in scope:
alias Episteme.{Database, Term}
alias Episteme.Term.Compound
c = fn name, args -> %Compound{name: name, args: args} endc is just a shorthand for building a "statement with a name and some
fields" (what Episteme calls a compound term) without typing out
%Compound{name: ..., args: ...} every time.
1. Family tree (recursive rules)
We record who is whose parent, then teach the engine a general rule for "ancestor" — someone is your ancestor if they're your parent, or if they're the parent of one of your ancestors. That second half is recursive: the rule refers to itself, the same way you'd define "ancestor" for a real family tree by working backward one generation at a time until you run out of known parents.
db =
Database.new()
|> Database.add_fact(c.(:parent, [:tom, :bob]))
|> Database.add_fact(c.(:parent, [:tom, :liz]))
|> Database.add_fact(c.(:parent, [:bob, :ann]))
|> Database.add_fact(c.(:parent, [:bob, :pat]))
ax = Term.new_var("X")
ay = Term.new_var("Y")
db = Database.add_clause(db, {c.(:ancestor, [ax, ay]), c.(:parent, [ax, ay])})
rx = Term.new_var("X")
ry = Term.new_var("Y")
rz = Term.new_var("Z")
db =
Database.add_clause(
db,
{c.(:ancestor, [rx, ry]), c.(:and, [c.(:parent, [rx, rz]), c.(:ancestor, [rz, ry])])}
)
Episteme.query(c.(:ancestor, [:tom, Term.new_var("Who")]), db)
#=> {:ok, [%{"Who" => :bob}, %{"Who" => :liz}, %{"Who" => :ann}, %{"Who" => :pat}]}We store four parent facts, then two rules for ancestor: the first
says "X is an ancestor of Y if X is directly Y's parent"; the second
says "X is an ancestor of Y if X is some Z's parent, and Z is (in turn)
an ancestor of Y." Asking "who is tom an ancestor of?" finds bob and
liz directly (tom's own children), then keeps following the chain
downward — bob's children ann and pat count too, because bob is one of
tom's descendants and the second rule connects them. The database ends
up listing tom as an ancestor of all four other people.
Each rule above builds its own fresh set of variables (ax/ay for
the first, rx/ry/rz for the second) purely for readability — every
stored rule gets its own private set of blanks each time it's used
regardless, so reusing the same variable across two different rules
would still give the correct answer, just be more confusing to read.
2. Graph reachability
The same "direct or via one more step" shape as the family tree, but over a graph of directed edges (arrows from one node to another) instead of parent/child relationships — "can you get from A to B by following arrows?" Deliberately a graph with no cycles (a path that loops back on itself) — a graph with cycles needs to keep track of which nodes it's already visited so it doesn't chase its own tail forever, which is a worthwhile exercise once you've read §10 of the tutorial on carrying extra information through a rule.
db =
Database.new()
|> Database.add_fact(c.(:edge, [:a, :b]))
|> Database.add_fact(c.(:edge, [:b, :c]))
|> Database.add_fact(c.(:edge, [:b, :d]))
|> Database.add_fact(c.(:edge, [:c, :e]))
px = Term.new_var("X")
py = Term.new_var("Y")
db = Database.add_clause(db, {c.(:path, [px, py]), c.(:edge, [px, py])})
qx = Term.new_var("X")
qy = Term.new_var("Y")
qz = Term.new_var("Z")
db =
Database.add_clause(
db,
{c.(:path, [qx, qy]), c.(:and, [c.(:edge, [qx, qz]), c.(:path, [qz, qy])])}
)
Episteme.query(c.(:path, [:a, Term.new_var("Y")]), db)
#=> {:ok, [%{"Y" => :b}, %{"Y" => :c}, %{"Y" => :d}, %{"Y" => :e}]}
Episteme.query(c.(:path, [:e, :a]), db)
#=> {:ok, []}We store four arrows: a→b, b→c, b→d, c→e. path/2 means "reachable,
possibly through several arrows" — defined the same recursive way as
ancestor/2 above. Asking "what's reachable from a?" walks the whole
chain and finds all four other nodes; asking the reverse — "what's
reachable from e?" — finds nothing, because every arrow only points
forward and there's no way back to a.
3. FizzBuzz
The classic exercise: for every number 1 through 20, print fizzbuzz
if it's divisible by both 3 and 5, fizz if just 3, buzz if just 5,
otherwise the number itself. This shows three pieces working together:
between/3 (generate a range of numbers), a chain of if_then_else/3
checks (if_then_else(Cond, Then, Else), read "if Cond, then Then,
otherwise try Else"), and forall/2 (run this check for every number
in the range).
n = Term.new_var()
goal =
c.(:forall, [
c.(:between, [1, 20, n]),
c.(:if_then_else, [
c.(:is, [0, c.(:mod, [n, 15])]),
c.(:writeln, [:fizzbuzz]),
c.(:if_then_else, [
c.(:is, [0, c.(:mod, [n, 3])]),
c.(:writeln, [:fizz]),
c.(:if_then_else, [
c.(:is, [0, c.(:mod, [n, 5])]),
c.(:writeln, [:buzz]),
c.(:writeln, [n])
])
])
])
])
Episteme.query(goal, Database.new())
#=> prints 1, 2, fizz, 4, buzz, fizz, 7, 8, fizz, buzz, 11, fizz, 13, 14,
# fizzbuzz, 16, 17, fizz, 19, buzz -- one per line -- then {:ok, [%{}]}Read from the inside out: for each number n between 1 and 20, check
"does 15 divide n evenly?" — if so, print fizzbuzz; otherwise check
"does 3 divide n evenly?" — if so, print fizz; otherwise check 5 the
same way for buzz; otherwise just print the number. forall/2 is
what makes this run for every number rather than stopping at the
first one — it's a "check this for everything" instruction, not a
question with an answer to collect, so the final result is just
"yes, that held for every number" ({:ok, [%{}]}), with the actual
output happening as a side effect of the printing along the way.
n is anonymous here (Term.new_var/0, no display name) for a reason
explained more in §5 below:
it's only ever used inside the forall/2 check, never by whoever
asked the question, so there's no need to give it a name.
4. A key-value store (assert/retract)
Everything so far set up its facts once, before asking any questions.
Here, we use assertz/1 (insert a fact right now) and retractall/1
(delete matching facts right now) to turn a database into an actual
mutable lookup table — wrapped in two small Elixir helper functions
that should feel familiar if you've used any key-value store before.
put = fn kv, key, value ->
Episteme.query(c.(:retractall, [c.(:kv, [key, Term.new_var("_")])]), kv)
Episteme.query(c.(:assertz, [c.(:kv, [key, value])]), kv)
kv
end
get = fn kv, key ->
case Episteme.query(c.(:kv, [key, Term.new_var("Value")]), kv) do
{:ok, [%{"Value" => value}]} -> {:ok, value}
{:ok, []} -> :not_found
end
end
kv = Database.new()
kv = put.(kv, :name, "episteme")
kv = put.(kv, :version, "0.1.0")
get.(kv, :name)
#=> {:ok, "episteme"}
kv = put.(kv, :name, "episteme!")
get.(kv, :name)
#=> {:ok, "episteme!"}
get.(kv, :missing)
#=> :not_foundput stores a fact shaped "the value for this key is that" — first
deleting any existing fact for the same key, then inserting the new
one. get asks "what's the value stored for this key?" and translates
the answer back into an ordinary Elixir result: {:ok, value} if a
fact was found, :not_found if not. We store :name, then overwrite
it with a new value, and confirm each get call sees the current
value, not the original one — exactly what you'd expect from any
ordinary key-value store.
The retractall/1 before every assertz/1 in put is what makes this
an overwrite rather than an accumulating log: without it, storing
:name twice would leave two facts about :name around, and asking
"what's the value for :name?" would come back with two different
answers instead of one.
5. Filtering and collecting with findall
findall/3 is how you turn "everyone who matches some condition" into
an actual Elixir list you can work with — the logic-programming
equivalent of a database SELECT with a WHERE clause.
db =
Database.new()
|> Database.add_fact(c.(:employee, [:ana, :engineering, 95_000]))
|> Database.add_fact(c.(:employee, [:ben, :sales, 61_000]))
|> Database.add_fact(c.(:employee, [:cy, :engineering, 88_000]))
|> Database.add_fact(c.(:employee, [:dee, :sales, 72_000]))
name = Term.new_var()
dept = Term.new_var()
salary = Term.new_var()
goal =
c.(:findall, [
name,
c.(:and, [
c.(:employee, [name, dept, salary]),
c.(:and, [c.(:unify, [dept, :engineering]), c.(:>, [salary, 90_000])])
]),
Term.new_var("Names")
])
Episteme.query(goal, db)
#=> {:ok, [%{"Names" => [:ana]}]}We store four employees, each with a name, department, and salary.
The question is: "collect the name of every employee who's in
engineering and earns over 90,000, into a list called Names." Only
Ana fits both conditions (Cy is in engineering but earns less; Ben and
Dee aren't in engineering at all), so Names comes back [:ana] — a
one-element list, not a bare value, because findall/3 always answers
with a list, even when there's only one match (or none at all).
name/dept/salary above are anonymous (Term.new_var/0, no
display name) because they're only used inside findall/3's
condition, never by the question that asked for Names. A named
variable used only there would come back in the answer too, still
unfilled — see TUTORIAL.md §11
for why.
6. Error handling with catch/3
throw/1/catch/3 are Episteme's version of raising and rescuing an
error. Here, dividing by zero would normally abort the whole question
with an error — catch/3 lets us recover from that one specific
failure and answer :undefined instead, the way a real program might
catch a division-by-zero exception and substitute a safe default.
x = Term.new_var("X")
y = Term.new_var("Y")
r = Term.new_var("R")
catcher = c.(:error, [Term.new_var("_"), Term.new_var("_")])
db =
Database.add_clause(
Database.new(),
{c.(:safe_div, [x, y, r]),
c.(:catch, [c.(:is, [r, c.(:/, [x, y])]), catcher, c.(:unify, [r, :undefined])])}
)
Episteme.query(c.(:safe_div, [10, 2, Term.new_var("R")]), db)
#=> {:ok, [%{"R" => 5}]}
Episteme.query(c.(:safe_div, [10, 0, Term.new_var("R")]), db)
#=> {:ok, [%{"R" => :undefined}]}safe_div(X, Y, R) means "R is X divided by Y — unless that raises an
error, in which case R is :undefined instead." Dividing 10 by 2 works
normally and answers 5. Dividing 10 by 0 would normally raise an
error term (division by zero has no numeric answer), but the catch
wrapped around it notices that error, matches it against catcher, and
substitutes :undefined — so the question still gets a clean answer
rather than blowing up.
catcher here matches any error at all — a real program would
usually be more specific (catch only "division by zero," say) so it
doesn't accidentally swallow a completely different kind of error and
hide a real bug.
7. Writing your own recursive list rule
The built-in list operations (CHEATSHEET.md)
cover common cases like "is X in this list" or "reverse this list," but
ordinary rules — the same recursive shape as the family tree in §1 —
are how you write anything else. Here's a sum_list/2 that adds up
every number in a list, built from scratch the way a built-in one would
be under the hood.
db = Database.add_fact(Database.new(), c.(:sum_list, [[], 0]))
h = Term.new_var("H")
t = Term.new_var("T")
rest = Term.new_var("Rest")
total = Term.new_var("Total")
db =
Database.add_clause(
db,
{c.(:sum_list, [[h | t], total]),
c.(:and, [c.(:sum_list, [t, rest]), c.(:is, [total, c.(:+, [h, rest])])])}
)
Episteme.query(c.(:sum_list, [[1, 2, 3, 4, 5], Term.new_var("Total")]), db)
#=> {:ok, [%{"Total" => 15}]}Two rules, read as plain English: "the sum of an empty list is 0" (the
base case, stopping the recursion), and "the sum of a list whose first
element is H and whose remaining elements are T, is H plus the sum of
T" (the recursive case, peeling off one element at a time until the
list runs out and hits the base case). Asking for the sum of
[1, 2, 3, 4, 5] walks that chain — 1 + (2 + (3 + (4 + (5 + 0)))) —
and answers 15.
8. A database that survives a restart
Everything else in this file stores its facts in memory, which means they're gone the moment your program stops. This example stores a fact to disk instead, closes it, reopens it — simulating a completely separate run of the program — and confirms the fact is still there.
path = "facts.dets"
db = Database.new(backend: Episteme.Database.Backends.Dets, file: path)
Episteme.query(c.(:assertz, [c.(:visited, [:home])]), db)
Database.sync(db)
Database.close(db)
# ... a fresh process, or after actually restarting the VM ...
reopened = Database.new(backend: Episteme.Database.Backends.Dets, file: path)
Episteme.query(c.(:visited, [Term.new_var("Place")]), reopened)
#=> {:ok, [%{"Place" => :home}]}
Database.close(reopened)We open a disk-backed database at facts.dets, insert the fact
"home has been visited," force it to actually be written to disk
(sync/1), and close the file (close/1). Reopening that same file
— which could just as well happen after actually restarting your
computer — finds the fact still there, exactly as if nothing had
happened in between.
See README.md § Storage backends for what this on-disk storage does and doesn't guarantee.