tur/logic -- Logic Programming Guidetur/logic provides miniKanren-style logic programming: unification, logic
variables, goals, and a backtracking search engine. The module lives in
stdlib/logic.tur and is pure Turmeric (no inline-C): terms, substitutions,
and the solution stream are defdata sum types, so the engine runs identically
under the compiled path and the tree-walking interpreter.
miniKanren is a small, embeddable logic programming language pioneered by William Byrd, Daniel Friedman, and collaborators. Its central idea is that programs are written as relations rather than functions, so the same relation can be queried in multiple directions:
x, find all y such that parento(x, y) holds;y, find all x such that parento(x, y) holds;(x, y) pairs for which the relation holds.The key primitives are:
| Concept | miniKanren name | tur/logic name |
|---|---|---|
| Succeed unconditionally | succeed |
(succeed) |
| Fail unconditionally | fail |
(fail) |
| Unify two terms | == |
(lequal t1 t2) |
| Logical AND | fresh/conj |
(conjoined g1 g2) |
| Logical OR | conde/disj |
(disjoined g1 g2) |
| Introduce a variable | fresh |
(fresh (fn [x] ...)) |
| Run a goal | run |
(run-logic n goal) |
(import tur/logic :refer [term-int term-var term-nil term-pair
subs-empty
lequal succeed fail conjoined disjoined fresh
run-logic first-state reify-walk
term-int-val logic-walk bt-length])
;; Q: is 5 equal to 5?
(let [results (run-logic 1 (lequal (term-int 5) (term-int 5)))]
(println (bt-length results))) ; => 1
;; Q: what value does x have if x == 7?
(let [x (term-var 0)
goal (lequal x (term-int 7))
result (run-logic 1 goal)
walked (reify-walk x result)]
(println (term-int-val walked))) ; => 7
A term is a defdata sum type (Term) with four constructors:
| Kind | Constructor (helper / raw) | Accessor(s) |
|---|---|---|
| integer constant | (term-int n) / (TInt n) |
(term-int-val t) |
| logic variable | (term-var id) / (TVar id) |
(term-var-id t) |
| pair | (term-pair a b) / (TPair a b) |
(term-pair-fst t), (term-pair-snd t) |
| nil | (term-nil) / (TNil) |
-- |
Inspect a term's shape with match:
(match t
(TInt n) ...
(TVar id) ...
(TPair a b) ...
(TNil) ...)
Pairs nest to form lists in the usual cons-cell style:
;; build the list [1, 2, nil]
(let [lst (term-pair (term-int 1)
(term-pair (term-int 2) (term-nil)))]
...)
Every variable is identified by an integer id. Inside a goal, (fresh ...)
allocates the next unused id from the search state itself (the substitution's
base node carries the fresh-variable counter), so scoped variables never
collide. For a top-level query variable, construct one by hand with ids
numbered from 0:
(let [x (term-var 0)
y (term-var 1)]
...)
Prefer (fresh ...) (below) over hand-numbered ids -- it keeps variable
scope explicit and threads the counter for you.
A substitution maps variable ids to terms. It is a persistent defdata
association list (Subst): (SBind var-id term rest) binding nodes over a
base (SNil next) that carries the next unused fresh-variable id. The empty
substitution is (subs-empty).
You rarely manipulate substitutions directly; run-logic starts from the
empty one and reify-walk lets you look up results.
A goal is an opaque (Goal A) handle over a function Subst -> Stream --
it accepts a substitution and returns zero or more extended substitutions
(the solution Stream). (apply-goal g state) runs one directly. Goal
also carries Functor / Applicative / Monad / Alternative instances, so goals
compose with do-m (conjunction) and alt-or (disjunction) as well.
The built-in goal constructors:
;; Primitives
(succeed) ; always succeeds
(fail) ; always fails
(lequal t1 t2) ; unify t1 and t2
;; Combination
(conjoined g1 g2) ; g1 AND g2 (sequential)
(disjoined g1 g2) ; g1 OR g2 (parallel streams)
;; Variable introduction
(fresh (fn [x] body-goal)) ; create one fresh variable x
(run-logic n goal)
Returns a solution Stream of at most n substitutions. Use helper
functions to extract results:
(first-state results) ; first substitution, or (subs-empty)
(reify-walk term results) ; walk a term through the first substitution
(bt-length results) ; number of solutions
(let [results (run-logic 1 (lequal (term-int 1) (term-int 2)))]
(println (bt-length results))) ; => 0 (no solutions)
(let [x (term-var 0)
g (disjoined (lequal x (term-int 1))
(disjoined (lequal x (term-int 2))
(lequal x (term-int 3))))
res (run-logic 5 g)]
(println (bt-length res))) ; => 3
fresh is the idiomatic way to introduce a scoped logic variable:
(let [res (run-logic 1
(fresh (fn [x]
(conjoined
(lequal x (term-int 42))
(succeed)))))]
;; the fresh variable received id 0 (the counter starts at 0)
(println (term-int-val (reify-walk (term-var 0) res)))) ; => 42
(let [x (term-var 0)
y (term-var 1)
lhs (term-pair x (term-int 2))
rhs (term-pair (term-int 1) y)
res (run-logic 1 (lequal lhs rhs))]
;; after unification: x=1, y=2
(println (term-int-val (reify-walk x res))) ; => 1
(println (term-int-val (reify-walk y res)))) ; => 2
Build a chain of goals with conjoined:
;; helper: (conjoin-all gs) folds a cons list of goals with conjoined
(defn conjoin-all [gs : int] : (Goal int)
(if (= (tail gs) 0)
(:: (head gs) (Goal int))
(conjoined (:: (head gs) (Goal int)) (conjoin-all (tail gs)))))
;; Encode people as integers; 0=Alice, 1=Bob, 2=Carol, 3=Dave
(defn parento [parent : Term child : Term] : (Goal int)
(disjoined (conjoined (lequal parent (term-int 0))
(lequal child (term-int 1)))
(disjoined (conjoined (lequal parent (term-int 0))
(lequal child (term-int 2)))
(conjoined (lequal parent (term-int 1))
(lequal child (term-int 3))))))
;; grandparento via fresh intermediate variable
(defn grandparento [grand : Term child : Term] : (Goal int)
(fresh (fn [mid]
(conjoined (parento grand mid)
(parento mid child)))))
;; Query: who are the grandchildren of Alice (id=0)?
(let [child (term-var 0)
res (run-logic 10 (grandparento (term-int 0) child))]
;; walks each solution
...)
tur/logictur/logic uses syntactic unification without an occurs check. You can
add arithmetic constraints or domain restrictions by building goals that
inspect terms:
;; goal: t must walk to an integer in the range [lo, hi]
(defn range-goal [t : Term lo : int hi : int] : (Goal int)
(:: (fn [state : Subst]
(match (logic-walk t state)
(TInt v) (if (and (>= v lo) (<= v hi))
(mreturn state)
(mzero))
_ (mzero))) ; not ground -- fail
:Goal))
reify-walk follows a single substitution. To pretty-print a full term
tree, walk recursively:
(defn reify-term [t : Term subs : Subst] : cstr
(match (logic-walk t subs)
(TInt n) (int->cstr n)
(TVar id) "_"
(TPair a b) (str-concat "(" (str-concat (reify-term a subs)
(str-concat " . " (str-concat (reify-term b subs) ")"))))
(TNil) "nil"))
Each str-concat / int->cstr here returns a fresh "caller frees" cstr, and
the recursive nesting leaks every intermediate while handing back a heap buffer
the caller must remember to free. When the reified text is a value you return
and pass around, prefer an owned String: build it with stdlib/string.tur's
StringBuilder (builder/push-cstr! the constant pieces, builder/push-string!
the recursive results, builder/finish to freeze), or wrap the final buffer in
string/adopt-cstr. The result owns its bytes and frees exactly once. See
strings-guide.md.
conde macroThe classic miniKanren conde is syntactic sugar over nested disjoined /
conjoined. You can define it as a Turmeric macro:
;; (conde [g1 g2 ...] [g3 g4 ...] ...)
;; each clause is a conjunction; clauses are disjoined
(defmacro conde [& clauses]
(if (= (tail clauses) 0)
`(conjoin-all ~(head clauses))
`(disjoined (conjoin-all ~(head clauses))
(conde ~@(tail clauses)))))
disjoined uses mplus (st-append), which interleaves: it hands the other
branch the next turn whenever it meets an immature stream. This section used
to tell you to hand-write that yourself, like this:
;;; mplus-i -- interleaved (BFS) concatenation of two solution streams.
(defn mplus-i [xs : Stream ys : Stream] : Stream
;; swap xs and ys at every step so solutions alternate
(match xs
(StNil) ys
(StCons v rest) (StCons v (mplus-i ys rest))))
You no longer need to define this. As of the lazy-stream change,
st-append -- the mplus behind disjoined -- IS the interleaving version:
(defn st-append [xs : Stream ys : Stream] : Stream
(match xs
(StNil) ys
(StInc th) (StInc (fn [] (st-append ys (st-force th)))) ;; SWAP
(StCons v rest) (StCons v (st-append rest ys))))
The swap on the StInc arm is the whole of fair interleaving.
Why the hand-written
mplus-ithis section used to recommend could never work. It swapped the arguments but built a strictStCons, whose tail is evaluated before the cell exists -- so it reordered a finite enumeration and nothing else. Interleaving is only half of miniKanren'smplus; the other half is that the result is itself immature, which is whatStIncsupplies. Without a thunk no infiniteStreamvalue can exist at all, so there was nothing for the swap to buy.
zzzdisjoined takes its goals strictly, so a self-recursive relation would
otherwise diverge while the goal is being built, before any search runs:
(defn nats [] : (Goal int) (disjoined (succeed) (nats))) ;; SIGSEGV
(defn nats [] : (Goal int) (disjoined (succeed) (zzz (nats)))) ;; fine
zzz is a macro and has to be: a function would evaluate its argument at the
call site, which is the divergence it exists to prevent. With it,
(run-logic 1 (nats)) returns one solution off an infinite relation instead of
never returning.
For relations that revisit the same subgoal, add a memo table keyed on
(goal-id, substitution):
;; memo-table: map from (goal-name x subs-hash) -> result-list
;; Use tur/hamt for the persistent map.
(import tur/hamt :refer [hamt-empty hamt-insert hamt-lookup])
(defn tabled [name goal-fn args subs] : int
(let [key (hash-key name args subs)
memo (hamt-lookup *memo-table* key)]
(if (option-some? memo)
(option-unwrap memo)
(let [result (apply-goal (goal-fn args) subs)]
(set! *memo-table* (hamt-insert *memo-table* key result))
result))))
tur/backtracktur/logic defines its own copies of the backtracking monad primitives
(mzero, mreturn, mplus, mbind) over its typed Stream. If you are
building tools on top of the same monad without the full logic layer,
stdlib/backtrack.tur exports these primitives separately.
conjoined goals
produce long alist walks. For critical inner loops, cache logic-walk
results in a local let binding.run-logic n g returns at most n solutions and short-circuits; use
small n in interactive sessions.benchmarks/bench-backtrack-n-queens.tur) and the
pair-sum benchmark (benchmarks/bench-logic-query.tur) serve as
representative workloads."The Reasoned Schemer" (2nd ed.) -- Daniel P. Friedman, William E. Byrd,
Oleg Kiselyov, Jason Hemann (MIT Press, 2018). The canonical introduction;
every concept in tur/logic maps directly to a chapter.
"miniKanren, Live and Untagged" -- William Byrd et al., 2012 Workshop on Scheme and Functional Programming. https://webyrd.net/scheme-2013/papers/HemannMuKanren2013.pdf
muKanren (micro-Kanren) -- Jason Hemann & Daniel Friedman, 2013.
The minimal core (~40 lines of Scheme) that tur/logic is modeled after.
http://webyrd.net/scheme-2013/papers/HemannMuKanren2013.pdf
miniKanren.org -- canonical reference implementation, papers, and talks. http://minikanren.org
"An Efficient Unification Algorithm" -- Martelli & Montanari (1982),
ACM Transactions on Programming Languages and Systems.
Describes the linear-time algorithm; tur/logic uses the simpler quadratic
alist walk, suitable for small substitutions.
"Unification: A Multidisciplinary Survey" -- Kevin Knight (1989). https://dl.acm.org/doi/10.1145/62029.62030
"The Art of Prolog" -- Sterling & Shapiro (MIT Press, 1994). Classical treatment of resolution, unification, and search strategies.
core.logic (Clojure) -- a production miniKanren embedding closest in
spirit to tur/logic. Good source of idioms and constraint extensions.
https://github.com/clojure/core.logic
Kanren (original, Scheme) -- Byrd & Friedman. https://github.com/webyrd/miniKanren
tur/backtrack monad
that tur/logic is built on.