tur/logic
miniKanren-style logic programming (unification, goals, streams).
Pure Turmeric (no inline-C): terms, substitutions, and the solution stream are all defdata sum types walked with match, so the miniKanren engine runs identically under the compiled path and the tree-walking interpreter (tur --interpret / the WASM REPL).
Representation:
Term -- a tagged union: TInt / TVar / TPair / TNil.
Subst -- the search state: a persistent association list whose base
(SNil next) carries the next unused fresh-variable id, so a
single value threads both the bindings and the fresh counter.
This replaces the old global fresh-var counter.
Stream -- the solution stream (the list / backtracking monad): StNil /
StCons subst rest. A goal maps a Subst to a Stream of Substs.
Goal is a fat-closure handle Subst -> Stream, exposed as a kind-(* -> *) opaque (Goal A) so it can carry real Functor / Applicative / Monad / Alternative instances (disjunction is alt-or, conjunction is do-m).
Since: Phase B1
Term
(defdata Term :copy (TInt :int) (TVar :int) (TPair :Term :Term) (TNil))
Subst
(defdata Subst :copy (SNil :int) (SBind :int :Term :Subst))
UnifyResult
(defdata UnifyResult :copy (UFail) (UOk :Subst))
Lookup
(defdata Lookup :copy (LMissing) (LFound :Term))
Stream
(defdata Stream :copy)
st-force
(st-force [th :(fn [] Stream)] :Stream)
run a delayed stream one step.
st-pull
(st-pull [xs :Stream] :Stream)
force a stream until it is mature (StNil or StCons).
This is where laziness is cashed in: every consumer that needs to look at the head of a stream pulls first, and only that much of the search runs.
| xs | a possibly-delayed stream |
The same stream forced to StNil or StCons.
Since: lazy-streams
Goal
(defopaque Goal [A] :ptr<void>)
a kind-(* -> *) handle over a miniKanren goal Subst -> Stream; carrier is the fat-closure pointer.
Since: stdlib-hkt-consolidation T3
st-append
(st-append [xs :Stream ys :Stream] :Stream)
concatenate two solution streams.
st-append-dfs
(st-append-dfs [xs :Stream ys :Stream] :Stream)
concatenate two solution streams DEPTH-FIRST.
Same as st-append but WITHOUT the swap, so the left branch is exhausted before the right is touched. Still lazy -- it defers through an immature stream rather than forcing it -- so run-logic n still costs n solutions.
Use when a goal's left branch is known to terminate and you want the leftmost solution cheaply. st-append reaches a solution at depth d breadth-first, which is what makes it complete on infinite relations and what makes it slower on a goal whose only solutions are deep.
Prefer st-append unless you have measured. Depth-first is faster on the goals it terminates on and diverges on the ones it does not, which is the whole reason the default swaps.
| xs | first solution stream | |
| ys | second solution stream |
xs's solutions, then ys's.
(st-length (st-append-dfs (mreturn s) (mreturn s))) ; => 2
Since: lazy-streams
st-bind
(st-bind [xs :Stream f :(fn [Subst] Stream)] :Stream)
concatMap: apply f to each state and concatenate the streams. f is a Subst -> Stream callback (arrives as a first-class :fn value).
st-length
(st-length [xs :Stream] :int)
count the states in a solution stream.
st-take
(st-take [n :int xs :Stream] :Stream)
keep at most n states from the front of a solution stream.
mzero
(mzero :Stream)
the empty solution stream (no solutions).
An empty Stream.
mreturn
(mreturn [s :Subst] :Stream)
wrap a single substitution as a one-solution stream.
| s | the search state (Subst) |
A singleton Stream containing s.
mplus
(mplus [xs :Stream ys :Stream] :Stream)
concatenate (union) two solution streams.
| xs | first solution stream | |
| ys | second solution stream |
A Stream with all of xs's solutions followed by all of ys's.
mbind
(mbind [ma :Stream fn :(fn [Subst] Stream)] :Stream)
apply a Subst -> Stream continuation to each solution and concatenate the results.
| ma | a solution stream | |
| fn | a Subst -> Stream callback |
The concatenated solution stream.
bt-length
(bt-length [xs :Stream] :int)
count the number of solutions in a solution stream.
| xs | solution stream |
Number of solutions.
term-int
(term-int [n :int] :Term)
construct a logic term representing an integer value.
| n | integer value |
A TInt term.
(term-int 42) ; => integer term 42
term-var
(term-var [id :int] :Term)
construct a logic variable term with the given id.
| id | unique variable identifier |
A TVar term.
(term-var 0) ; => logic variable 0
term-pair
(term-pair [a :Term b :Term] :Term)
construct a logic pair term from two sub-terms.
| a | first sub-term | |
| b | second sub-term |
A TPair term.
(term-pair (term-int 1) (term-int 2)) ; => pair term (1 . 2)
term-nil
(term-nil :Term)
construct the nil/empty logic term.
A TNil term.
(term-nil) ; => nil term
term-int-val
(term-int-val [t :Term] :int)
read the integer value from an INT term (0 otherwise).
| t | a Term |
The integer payload of a TInt, or 0 for any other term.
term-var-id
(term-var-id [t :Term] :int)
read the variable id from a VAR term (0 otherwise).
| t | a Term |
The id of a TVar, or 0 for any other term.
term-pair-fst
(term-pair-fst [t :Term] :Term)
read the first sub-term of a PAIR term (TNil otherwise).
| t | a Term |
The first component of a TPair, or TNil for any other term.
term-pair-snd
(term-pair-snd [t :Term] :Term)
read the second sub-term of a PAIR term (TNil otherwise).
| t | a Term |
The second component of a TPair, or TNil for any other term.
subs-empty
(subs-empty :Subst)
return the empty substitution (fresh counter at 0).
An empty Subst with next fresh-var id 0.
subst-next
(subst-next [s :Subst] :int)
read the next unused fresh-variable id from the base of a substitution.
subst-set-next
(subst-set-next [s :Subst n :int] :Subst)
set the fresh-variable counter at the base of a substitution, preserving all bindings.
subst-lookup
(subst-lookup [vid :int subs :Subst] :Lookup)
find the term bound to var-id in a substitution. Returns (LFound term) when var-id is bound, (LMissing) otherwise.
logic-walk
(logic-walk [t :Term subs :Subst] :Term)
follow variable bindings in a substitution until ground.
| t | the term to walk | |
| subs | the substitution |
The fully walked term (a ground term or an unbound variable).
(logic-walk (term-var 0) subs) ; => bound term or the variable itself
unify-walked
(unify-walked [a :Term b :Term subs :Subst] :UnifyResult)
structural unification of two already-walked terms. No occurs check (matching standard miniKanren).
logic-unify
(logic-unify [t1 :Term t2 :Term subs :Subst] :UnifyResult)
structurally unify two terms, extending the substitution.
No occurs check is performed. Returns a tagged UnifyResult.
| t1 | first term | |
| t2 | second term | |
| subs | current substitution |
(UOk subs') on success, or (UFail) on unification failure.
(logic-unify (term-var 0) (term-int 5) (subs-empty)) ; => (UOk {x0=5})
apply-goal
(apply-goal [g :(Goal int) state :Subst] :Stream)
run a goal against a search state, yielding a solution stream.
| g | a goal | |
| state | the current search state (Subst) |
The solution Stream.
unify-goal-impl
(unify-goal-impl [lt1 :Term lt2 :Term state :Subst] :Stream)
implementation helper: unify two terms against a state.
lequal
(lequal [t1 :Term t2 :Term] :(Goal int))
goal that unifies two terms (miniKanren ==).
| t1 | first term | |
| t2 | second term |
A goal (Goal int) that succeeds iff t1 and t2 unify.
(run-logic 1 (lequal (term-int 5) (term-int 5))) ; => one solution
succeed
(succeed :(Goal int))
goal that always succeeds without changing the substitution.
A goal (Goal int) that adds the current state unchanged to the solutions.
(run-logic 1 (succeed)) ; => one solution
fail
(fail :(Goal int))
goal that always fails, producing no solutions.
A goal (Goal int) that produces an empty solution stream.
(run-logic 1 (fail)) ; => empty stream
conjoined-impl
(conjoined-impl [lg1 :(Goal int) lg2 :(Goal int) state :Subst] :Stream)
implementation helper for conjunction.
conjoined-raw
(conjoined-raw [g1 :(Goal int) g2 :(Goal int)] :(Goal int))
worker behind conjoined / Monad bind sequencing.
conjoined
(conjoined [A B])
goal that applies g1 then g2 to each result (conjunction).
Equivalent to sequencing the two goals with do-m against the Monad [Goal] instance.
| g1 | first goal (Goal A) | |
| g2 | second goal (Goal B) |
A goal (Goal B) that succeeds only when both g1 and g2 succeed.
(run-logic 1 (conjoined (lequal x (term-int 1)) (succeed))) ; => [x=1]
Since: Phase B1
disjoined-impl
(disjoined-impl [lg1 :(Goal int) lg2 :(Goal int) state :Subst] :Stream)
implementation helper for disjunction.
disjoined-raw
(disjoined-raw [g1 :(Goal int) g2 :(Goal int)] :(Goal int))
worker behind disjoined / Alternative alt-or.
disjoined
(disjoined [A])
goal that combines results from g1 and g2 (disjunction).
This is the worker behind Alternative [Goal]'s alt-or method.
| g1 | first goal (Goal A) | |
| g2 | second goal (Goal A) |
A goal (Goal A) whose solutions are the union of g1's and g2's.
(run-logic 2 (disjoined (lequal x (term-int 1)) (lequal x (term-int 2)))) ; => [x=1, x=2]
Since: Phase B1
fresh-impl
(fresh-impl [^fat lf :(fn [Term] (Goal int)) state :Subst] :Stream)
implementation helper for fresh variable introduction.
fresh
(fresh [^fat f :(fn [Term] (Goal int))] :(Goal int))
create a new logic variable and pass it to a goal-building function.
The callback is taken ^fat, so a capturing lambda -- e.g. the inner goal of a nested fresh that closes over the outer variable -- dispatches correctly. A caller's lambda parameter type is inferred, so (fresh (fn [x] ...)) needs no annotation on x.
| f | a (Term -> Goal) callback |
A goal (Goal int) that introduces a fresh variable and runs (f var).
(run-logic 1 (fresh (fn [x] (lequal x (term-int 7))))) ; => [x=7]
zzz
(zzz [g])
delay a goal so a recursive relation can be written at all.
Without this, a self-recursive relation diverges while the goal is being BUILT, before any search runs:
(defn nats [] : (Goal int) (disjoined (succeed) (nats))) ; SIGSEGV
disjoined takes its goals strictly, so (nats) calls itself forever. zzz wraps the goal EXPRESSION unevaluated, so nothing recurses until the resulting stream is pulled -- and then only one step at a time:
(defn nats [] : (Goal int) (disjoined (succeed) (zzz (nats))))
This is the Zzz of the miniKanren literature. It 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.
| g | a goal EXPRESSION, not evaluated until the stream is pulled |
A goal that yields an immature stream.
(run-logic 1 (zzz (nats))) ; terminates
Since: lazy-streams
disjoined-dfs-impl
(disjoined-dfs-impl [lg1 :(Goal int) lg2 :(Goal int) state :Subst] :Stream)
disjunction that searches DEPTH-FIRST.
disjoined interleaves, which is what makes it complete on relations with infinitely many solutions. That costs more to reach a solution that sits deep in the left branch, because interleaving gets there breadth-first. This variant keeps the old depth-first order.
It is NOT complete: on a goal whose left branch has infinitely many solutions it never reaches the right one. Reach for it only when the left branch is known finite.
| g1 | first goal | |
| g2 | second goal |
A goal yielding g1's solutions then g2's.
(run-logic 1 (disjoined-dfs (lequal x (term-int 1)) (lequal x (term-int 2))))
Since: lazy-streams
disjoined-dfs
(disjoined-dfs [A])
run-logic
(run-logic [A])
run a goal with an empty state and return up to n solutions.
| n | maximum number of solutions | |
| g | goal to run (Goal A) |
A Stream of at most n solution states.
(run-logic 1 (fresh (fn [x] (lequal x (term-int 3))))) ; => one solution
stream-empty?
(stream-empty? [xs :Stream] :bool)
is a solution stream empty?
first-state
(first-state [results :Stream] :Subst)
extract the first solution substitution from a run-logic stream.
| results | solution stream from run-logic |
The first Subst, or the empty substitution if the stream is empty.
reify-walk
(reify-walk [t :Term results :Stream] :Term)
walk a term through the first solution's substitution.
| t | the term to reify | |
| results | solution stream from run-logic |
The walked term (ground term or unbound variable); t itself if there are no solutions.
(reify-walk my-var (run-logic 1 my-goal)) ; => ground term or variable
bind-goal-raw
(bind-goal-raw [g :(Goal int) k :fn] :(Goal int))
worker: conjunction that threads the state into the :fn continuation, which returns the next goal.
fmap-goal-raw
(fmap-goal-raw [g :(Goal int) f :(fn [Subst] Subst)] :(Goal int))
worker: map a callback over each solution state.
The callback is typed (fn [Subst] Subst) rather than a bare :fn. That is not just hygiene: a bare :fn result is an erased carrier word, so the body had to ascribe it back with (:: (f s) :Subst) -- a no-op cast while Subst rides the int64 carrier, and a real representation change the moment it does not (SR4). Spelling the signature deletes the reinterpret.
Functor[Goal]
(definstance Functor [Goal])
map a function over each solution state.
Since: stdlib-hkt-consolidation T3
Applicative[Goal]
(definstance Applicative [Goal])
pure is the always-succeeding goal (state preserved); ap sequences the two goals (conjunction) keeping the second's states.
Since: stdlib-hkt-consolidation T3
Monad[Goal]
(definstance Monad [Goal])
bind is conjunction: run the goal, then feed each resulting state into the continuation (which returns the next goal). This is what do-m / for lower onto for goal-conjunction chains.
(do-m _ (lequal x (term-int 1)) (lequal y (term-int 2)))
Since: stdlib-hkt-consolidation T3
Alternative[Goal]
(definstance Alternative [Goal])
empty is the failing goal; alt-or is disjunction (the miniKanren disj): it unions the solutions of both goals.
(alt-or (lequal x (term-int 1)) (lequal x (term-int 2)))
Since: stdlib-hkt-consolidation T3