A step-by-step walkthrough of Episteme, written for someone who has
never done logic programming before — no Prolog background assumed.
Every snippet is real, runnable Elixir — paste it into iex -S mix from
a checkout of this repository. Once you've read this, three places to
go next depending on what you need: REFERENCE.md for
full detail on any one feature (with more examples), EXAMPLES.md
for complete standalone programs, or CHEATSHEET.md for
a quick one-page lookup once you know your way around.
The idea, before any code
Most programming is "tell the computer the steps to compute an answer." Logic programming is different: you tell the computer facts ("Tom is Bob's parent") and rules for deriving new facts from old ones ("X is Y's grandparent if X is some Z's parent and Z is Y's parent"), and then you ask it questions ("who is Tom the parent of?"). The engine searches through everything it's been told, tries every combination that could possibly answer your question, and hands back every answer that actually works — automatically backtracking (undoing a guess and trying a different one) whenever a particular combination doesn't pan out.
That's the whole mental model for this tutorial: a growing pile of facts and rules (the database), a question you ask against it (the goal or query), and a list of answers, each one a set of "here's what the blanks in your question turned out to be" (a solution). Everything below is really just: what does it look like to build that pile of facts, and what does asking a question and reading back an answer actually look like in Elixir code.
1. There is no reader
Most Prolog tutorials start with .pl source files and a ?- prompt.
Episteme has neither: there's no parser, no . clause terminator, no
operator table, and you don't need to learn Prolog's text syntax at
all. You build facts, rules, and questions directly as plain Elixir
values, the same way you'd build any other Elixir data structure — a
%Episteme.Term.Compound{} is a Prolog-style compound term, not a
representation of one that gets parsed later.
alias Episteme.{Database, Engine, Term}
alias Episteme.Term.Compound2. Terms: the building blocks of a fact
Everything Episteme stores or asks about is built from five kinds of value:
:tom # an atom
42 # an integer
3.14 # a float
[1, 2, 3] # a list -- a real Elixir list
Term.new_var("X") # a logic variable, named "X" for display
%Compound{name: :likes, args: [:tom, :beer]} # likes(tom, beer)In plain terms:
- An atom is a fixed label — think of it the way you'd use a plain
Elixir atom anywhere else, as a symbolic name rather than a piece of
data to compute with (
:tom,:bob,:positive— none of these "mean" anything to Episteme beyond being distinct, comparable names). - Integers and floats are ordinary numbers.
- A list is an ordinary Elixir list — nothing special.
- A variable is a placeholder for an answer the engine hasn't found yet — the blank in a fill-in-the-blank question. When you ask a question containing a variable, the engine's job is to figure out every value that could fill that blank and make the question true.
- A compound is a labeled group of values —
%Compound{name: :likes, args: [:tom, :beer]}is the statement "likes(tom, beer)," i.e. "tom likes beer." Think of it as a small, named record: thenamesays what kind of statement this is, andargsare its fields.
A variable's name ("X" above) is purely cosmetic — it only affects
how the answer gets labeled when you read it back. What actually
makes two variables "the same blank" is Elixir identity: two separate
Term.new_var("X") calls create two different placeholders that
happen to share a display name. If you want the same blank to appear
twice in a statement — "X likes X," whatever X turns out to be — reuse
the same Elixir variable:
x = Term.new_var("X")
%Compound{name: :likes, args: [x, x]} # likes(X, X) -- the *same* X twice3. A database and your first query
Let's record a few facts about who is whose parent, then ask some questions about them.
db =
Database.new()
|> Database.add_fact(%Compound{name: :parent, args: [:tom, :bob]})
|> Database.add_fact(%Compound{name: :parent, args: [:tom, :liz]})
|> Database.add_fact(%Compound{name: :parent, args: [:bob, :ann]})
Episteme.query(%Compound{name: :parent, args: [:tom, :bob]}, db)
#=> {:ok, [%{}]}
Episteme.query(%Compound{name: :parent, args: [:tom, Term.new_var("Who")]}, db)
#=> {:ok, [%{"Who" => :bob}, %{"Who" => :liz}]}Database.new/0 starts an empty pile of facts; each add_fact/2 adds
one row to it — "tom is bob's parent," "tom is liz's parent," "bob is
ann's parent." That's the entire database: three plain statements, no
questions asked yet.
Then we ask two questions:
- "Is tom bob's parent?" — a question with no blanks in it at all
(
:tomand:bobare both fixed values, not variables). The engine finds a matching fact and answers yes.{:ok, [%{}]}means "one answer, and it didn't need to fill in any blanks" — an empty map is still a real answer here, just a boring one: it's the difference between "yes" and "here's what X turned out to be." - "Who is tom the parent of?" — this time
Whois a variable, a blank. The engine looks at every fact we stored that's namedparentand has two fields, checks whether the first field matchestom, and for every fact where it does, reports what the second field was. Two facts match ("tom is bob's parent" and "tom is liz's parent"), so we get two answers:%{"Who" => :bob}("one way to answer your question: Who = bob") and%{"Who" => :liz}.
Episteme.query/2 always returns {:ok, solutions}: one map per
answer, and the map's keys are exactly the named variables you put
in your question (a variable named "_" — the default — is treated as
"I don't care about this blank" and never shows up in an answer, same
as Prolog's own anonymous-variable convention). Zero answers just means
{:ok, []} — "I checked everything I know, and nothing matches," not
an error.
Facts are secretly just a special case of a rule: add_fact/2 is
shorthand for "add a rule whose body is always true" — you'll see what
that means in the next section.
4. Rules: deriving new facts from old ones
A fact is a statement you assert outright. A rule is a
statement that's true whenever some other condition holds — "X is Y's
grandparent, if X is some Z's parent and Z is Y's parent." We
never store "tom is ann's grandparent" directly; the engine works it
out on the fly, every time, by chaining parent facts together.
{x, y, z} = {Term.new_var("X"), Term.new_var("Y"), Term.new_var("Z")}
db =
db
|> Database.add_clause(
{%Compound{name: :grandparent, args: [x, z]},
%Compound{name: :and, args: [
%Compound{name: :parent, args: [x, y]},
%Compound{name: :parent, args: [y, z]}
]}}
)
Episteme.query(%Compound{name: :grandparent, args: [:tom, Term.new_var("Who")]}, db)
#=> {:ok, [%{"Who" => :ann}]}Database.add_clause/2 takes a {head, body} pair: the head is what
the rule concludes (grandparent(X, Z)), the body is what has to be
true for that conclusion to hold (parent(X, Y) and parent(Y, Z)
— and/2 is Episteme's own goal for "and," spelled out as a word
rather than real Prolog's , operator, since there's no reader here
for , to be conventional syntax in). Reading the whole thing as a
sentence: "X is Z's grandparent if there's some Y such that X is Y's
parent and Y is Z's parent."
Asking "who is tom's grandparent?" makes the engine go looking for a Y
that makes both halves true at once: it tries parent(tom, Y), finds
Y = bob (from the facts in §3), then checks parent(bob, Z) with
that same bob, finds Z = ann, and reports Who = ann — tom is
bob's parent, and bob is ann's parent, so tom is ann's grandparent,
exactly the way you'd work it out by hand.
One detail worth flagging early: every time a rule is used, it gets a
brand-new set of variables — the x/y/z above belong to the
stored rule, not to whatever question you ask it. Reusing the same
Elixir variable across a rule and a query never accidentally connects
them; two separately-created rules using the same variable names don't
interfere with each other either.
5. Control: if-then-else and negation
Sometimes you want a rule to check a condition and answer differently
depending on which way it goes — the logic-programming equivalent of
if/else. if_then_else(Cond, Then, Else) reads exactly like it
looks: "if Cond, then Then, else Else" — and it commits to whichever
branch Cond first succeeds with, the same way a real if doesn't
keep checking other branches once one matched.
(A quick note on names: real Prolog spells this (Cond -> Then ; Else),
with ;/-> as operators. Episteme uses the plain English words
instead — and, or, if_then/if_then_else, not, cut — since
there's no text syntax here for punctuation operators to be
conventional against in the first place; a name you can actually
remember without looking it up won out over matching ISO Prolog
symbol-for-symbol. CHEATSHEET.md has the full list.)
c = fn name, args -> %Compound{name: name, args: args} end
x = Term.new_var("X")
r = Term.new_var("R")
body =
c.(:if_then_else, [
c.(:>, [x, 0]),
c.(:unify, [r, :positive]),
c.(:if_then_else, [c.(:<, [x, 0]), c.(:unify, [r, :negative]), c.(:unify, [r, :zero])])
])
db2 = Database.add_clause(Database.new(), {c.(:sign, [x, r]), body})
Episteme.query(c.(:sign, [5, Term.new_var("R")]), db2)
#=> {:ok, [%{"R" => :positive}]}This rule reads as: "R is the sign of X — positive if X is greater
than 0, else negative if X is less than 0, else zero." Asking
sign(5, R) checks "is 5 greater than 0?" — yes — so R is set to
:positive and the engine doesn't bother checking the other two
branches at all, giving exactly one answer. (unify/2 is Episteme's
"make these two things match" goal — real Prolog's = operator; see
§2 if "unify" itself is a new
word to you.)
not(Goal) is how you ask "is there no way to prove this?" — it
succeeds exactly when Goal has zero answers, and — importantly — it
never fills in any blanks, even ones Goal would have filled in on its
way to failing. It's a yes/no check for absence of a proof, not a
search for one. (Real Prolog spells this \+ Goal.)
x2 = Term.new_var("X")
db3 =
Database.new()
|> Database.add_clause({c.(:even, [x2]), c.(:is, [0, c.(:mod, [x2, 2])])})
Episteme.query(c.(:not, [c.(:even, [3])]), db3)
#=> {:ok, [%{}]}Here even(X) is defined as "X divided by 2 leaves remainder 0." Asking
"is it not the case that 3 is even?" — 3 isn't even, so even(3)
has no proof, so the negation succeeds. {:ok, [%{}]} is that yes,
with no blanks to report (the query itself had none).
6. Cut: committing to a choice
Normally, if a path through your facts and rules doesn't lead anywhere
useful, the engine automatically backs up and tries a different one —
a different fact, a different rule, a different branch of an
if-then-else — the way you'd back out of a dead end in a maze and try
another turn. cut tells the engine: stop — don't just avoid
exploring forward from here, actually give up every alternative choice
already made to get here, even ones that already looked like they were
working. (Real Prolog spells this !.)
db4 =
Database.new()
|> Database.add_clause({:p, c.(:and, [:cut, :fail])})
|> Database.add_clause({:p, true})
Episteme.query(:p, db4)
#=> {:ok, []}Walking through this by hand: asking p first tries the first stored
rule, "p holds if we cut, then fail." The engine commits to that first
rule via cut — promising not to try the second rule ("p holds,
unconditionally," which would have succeeded immediately) — and only
then hits fail. Since it already gave up the alternative, there's
nothing left to try, and the whole question comes back with zero
answers, even though a rule that would have said "yes" was sitting
right there. This is the textbook example precisely because it's
counter-intuitive on first read — a plain "stop backtracking from here
on" wouldn't produce this result, but "undo the choice that got us into
this rule at all" does.
A cut is scoped to the one rule it appears in — it can never reach
backward into whatever called that rule and prune the caller's own
alternatives. call/N, once/1, and not/1 all wall off cut the same
way. See the moduledoc on Episteme.Engine for the full mechanism if
you're curious how a real, ISO-accurate cut like this is built on top
of a search process that doesn't have cut as a primitive at all.
7. Arithmetic
X is Expr means "compute the arithmetic value of Expr, and set X
to it" — it's the odd one out among everything so far, because it
calculates rather than just checking whether two things can be made
to match. (is(X, 2+2) and unify(X, 2+2) are very different
questions: the first asks "what does 2+2 come out to," the second asks
"does X already look exactly like the unevaluated expression 2+2.")
Episteme.query(c.(:is, [Term.new_var("X"), c.(:+, [2, c.(:*, [3, 4])])]), Database.new())
#=> {:ok, [%{"X" => 14}]}"What is X, if X is 2 plus (3 times 4)?" — ordinary arithmetic
precedence, evaluated to 14.
See CHEATSHEET.md for the full table of evaluable
functors and comparisons (numeric_equal, <, etc.), plus between/3
for generating integers.
8. Lists
Lists are plain Elixir lists. member/2 asks "is X one of the elements
of this list — and if X is a blank, what are all the things it could
be?":
Episteme.query(c.(:member, [Term.new_var("X"), [:a, :b, :c]]), Database.new())
#=> {:ok, [%{"X" => :a}, %{"X" => :b}, %{"X" => :c}]}Three answers, one per element — "X could be a," "X could be b," "X
could be c." append/3, length/2, and friends
(CHEATSHEET.md) generalize the same way: they
don't just check a fully-known list, they can search for a list that
fits, filling in blanks as needed.
9. Exceptions
throw/1 and catch/3 work like raising and rescuing an error
anywhere else: throw(Ball) abandons the current computation carrying
a value (Ball); catch(Goal, Catcher, Recovery) runs Goal, and if
it throws something matching Catcher, runs Recovery instead of
letting the exception escape further. An uncaught throw doesn't crash
your Elixir process — it comes back from Episteme.query/2 as an
ordinary {:error, term} value.
x3 = Term.new_var("X")
y3 = Term.new_var("Y")
r3 = Term.new_var("R")
catcher = c.(:error, [Term.new_var("_"), Term.new_var("_")])
body2 = c.(:catch, [c.(:is, [r3, c.(:/, [x3, y3])]), catcher, c.(:unify, [r3, :undefined])])
db5 = Database.add_clause(Database.new(), {c.(:safe_div, [x3, y3, r3]), body2})
Episteme.query(c.(:safe_div, [10, 0, Term.new_var("R")]), db5)
#=> {:ok, [%{"R" => :undefined}]}safe_div(X, Y, R) means "R is X divided by Y — unless that division
raises an error, in which case R is :undefined." Dividing by zero
normally throws (division has no answer), but here it's wrapped in a
catch that quietly recovers instead of the whole question blowing up:
asking safe_div(10, 0, R) gets back R = :undefined rather than an
{:error, ...} result.
10. Dynamic database: assert and retract
Everything so far built the database once, up front, and only asked
questions against it. assertz/1/asserta/1 (insert a fact or rule
right now, while a query is running) and retract/1/retractall/1
(remove one) turn the database into something closer to an actual
mutable table you can INSERT/DELETE from live, rather than a fixed
set of statements decided in advance. Crucially, these changes are
not undone if the question that made them goes on to fail or
backtrack later — an insert is a real insert, not a "let's try this and
see" hypothetical.
db6 = Database.new()
Episteme.query(c.(:assertz, [c.(:seen, [:tom])]), db6)
Episteme.query(c.(:seen, [:tom]), db6)
#=> {:ok, [%{}]}
Episteme.query(c.(:retract, [c.(:seen, [:tom])]), db6)
Episteme.query(c.(:seen, [:tom]), db6)
#=> {:ok, []}Reading through it: we start with an empty database, insert the fact
"tom has been seen," confirm asking seen(tom) now answers yes, then
delete that same fact, and confirm asking again now answers "no
matches." Both assertz/retract calls and both seen(tom) checks
are separate calls to Episteme.query/2 — the change made by the
first call is still there when the third call runs, because db6 is
the same database value throughout, and mutating it is exactly what
assertz/retract do.
This is what makes Episteme useful for more than "check some fixed
rules" — a long-lived process can hold a Database.t() and grow or
shrink its own knowledge over time: a cache, a game's world state, a
session's accumulated facts. See EXAMPLES.md for a small
key-value store built exactly this way.
11. Collecting every solution: findall and forall
So far, asking a question gets back a list of answers automatically.
findall/3 and forall/2 are for when a question itself — inside a
rule you're writing — needs to look at "all the answers to this
sub-question" as a single value, the way a report might need "the list
of every matching row," not just "does at least one row match."
findall(Template, Goal, List) reads as "find every way to make Goal
true, and for each one, collect what Template came out to — put them
all in List." If there are no matches at all, List comes back []
— that's a real, valid answer, not an error:
db7 =
Database.new()
|> Database.add_fact(c.(:fruit, [:apple]))
|> Database.add_fact(c.(:fruit, [:pear]))
fx = Term.new_var()
Episteme.query(c.(:findall, [fx, c.(:fruit, [fx]), Term.new_var("L")]), db7)
#=> {:ok, [%{"L" => [:apple, :pear]}]}"Collect every X such that fruit(X) holds, into L" — the database
knows about :apple and :pear, so L comes back [:apple, :pear].
fx above is deliberately anonymous (Term.new_var/0 defaults its
name to "_") — a named variable used only inside findall/3's
template/goal, and nowhere else in the top-level question, would come
back in the answer too, unbound, the same way asking "is X a variable?"
reports back what X was (see §2) rather than silently dropping it. See
Episteme's moduledoc, or the var/nonvar test in the test suite, for
more on that convention.
forall(Cond, Action) is a yes/no check, not a collector: "is it true
that every way of satisfying Cond also satisfies Action?" — useful
for "check this holds across the board" without ever building a list of
results at all.
12. Choosing a storage backend
Everything above stored its facts in memory — Database.new/1 defaults
to an in-memory table, organized by each fact or rule's name and number
of fields so looking one up is fast, that disappears once your program
stops. For a database that should still be there the next time you
start the program — a notebook you can put away and pick back up,
rather than scratch paper — pass the disk-backed option and a file path
instead:
db8 = Database.new(backend: Episteme.Database.Backends.Dets, file: "facts.dets")
Episteme.query(c.(:assertz, [c.(:fact, [1])]), db8)
Database.sync(db8)
Database.close(db8)
# ... later, possibly in a different process or after a restart ...
reopened = Database.new(backend: Episteme.Database.Backends.Dets, file: "facts.dets")
Episteme.query(c.(:fact, [Term.new_var("X")]), reopened)
#=> {:ok, [%{"X" => 1}]}We insert a fact, flush it to disk (sync/1), and close the file
(close/1); reopening the same file later — potentially a completely
separate run of the program — finds that fact still there. The
in-memory and on-disk versions behave identically from a query's point
of view; only how long the data survives differs.
Any other storage strategy is a matter of implementing
Episteme.Database.Backend's five callbacks. See the moduledoc on that
module, or just read Episteme.Database.Backends.Ets — at ~30 lines,
it's the whole contract in miniature.
13. Lower-level: query_lazy/2 and next_solution/2
Episteme.query/2 computes every answer before handing anything
back. That's fine when there are a handful of answers, but some
questions have unboundedly many (member(X, List) with List itself
left as an unfilled blank, say) — for those, or for a REPL that only
wants to show the first few answers and stop, ask for answers one at a
time instead:
{stream, vars} = Episteme.query_lazy(c.(:member, [Term.new_var("X"), [:a, :b, :c]]), Database.new())
Episteme.next_solution(stream, vars)
#=> {:solution, %{"X" => :a}, rest}Think of stream as a cursor into an open-ended list of answers:
next_solution/2 pulls the next one — {:solution, answer, rest} — and
hands back a new cursor (rest) to keep going from, or :none once
there's nothing left. Keep pulling on the latest rest for as many
answers as you want, then just stop — there's no need to exhaust it.
Where next
- REFERENCE.md — every feature covered here (and a few that weren't) in full detail, one section per construct/predicate, each with its own verified example.
- CHEATSHEET.md — every built-in goal and Elixir-side function, in one page.
- EXAMPLES.md — complete, runnable programs (a family tree, graph reachability, a key-value store, FizzBuzz, and more).
- CONTRIBUTING.md — if you want to change Episteme itself.
- The moduledoc on
Episteme.Engine— the cut mechanism in particular is worth reading if you're curious how a real clause-scoped cut is built on top of a lazy backtracking search tree without modifying that search tree's own combinators at all.