μKanren in Nix
μKanren is a minimal relational logic kernel. ukanrenix implements it in pure Nix:
no builtins beyond builtins.*, no imports, no side effects.
Every search step carries a state attrset:
{ subst = { … }; # substitution map: var-id -> term counter = n; # next fresh variable id}subst starts empty. counter starts at 0. Goals receive a state and return a
stream of successor states — one per way the goal can succeed.
Variables
Section titled “Variables”A logic variable is a plain Nix attrset:
{ __var = true; id = 3; }Three primitives work with variables:
| function | signature | description |
|---|---|---|
mkVar | n -> Var | constructs {__var=true, id=n} |
isVar | term -> bool | tests for __var=true |
walk | subst -> term -> term | chases the substitution chain |
walk is not recursive on structure — it only follows variable bindings. Structural
recursion happens inside unify.
let s = { "0" = { __var = true; id = 1; }; "1" = 42; };in walk s (mkVar 0) # => 42Unification
Section titled “Unification”unify subst u v attempts to make u and v equal under subst.
It returns an extended substitution on success, or null on failure.
Rules applied in order:
- Walk both sides.
- If either side is already the other after walking, return
substunchanged. - If
uis a variable, extendsubstwithu -> v. - If
vis a variable, extendsubstwithv -> u. - If both are lists of the same length, unify element-wise.
- If both are attrsets with the same key set, unify value-wise for each key.
- If both are ground scalars, return
substiffu == v, elsenull.
unify {} [ (mkVar 0) 2 ] [ 1 2 ]# => { "0" = 1; }
unify {} (mkVar 0) (mkVar 0)# => {} (already equal)
unify {} 1 2# => null (failure)The five core goal combinators
Section titled “The five core goal combinators”succeed
Section titled “succeed”Passes the current state through unchanged. The identity of conj.
run 1 (q: conj (eq q 42) succeed)# => [ 42 ]Returns the empty stream. The identity of disj. Useful as a default branch.
run 5 (q: fail)# => []eq a b
Section titled “eq a b”The unification goal. Calls unify on a and b; on success emits a single
successor state with the extended substitution; on failure returns null (empty stream).
run 1 (q: eq q "hello")# => [ "hello" ]conj g1 g2
Section titled “conj g1 g2”Sequential conjunction — g1 must succeed, then g2 runs on each resulting state.
Answers must satisfy both goals.
run 1 (q: fresh (a: fresh (b: conj (eq a 1) (conj (eq b 2) (eq q [a b])))))# => [ [1 2] ]disj g1 g2
Section titled “disj g1 g2”Interleaved disjunction — runs both goals and merges their streams. Answers satisfy either goal. Interleaving (rather than appending) ensures fairness when either branch is infinite.
run 3 (q: disj (eq q "a") (disj (eq q "b") (eq q "c")))# => [ "a" "b" "c" ]fresh f
Section titled “fresh f”Allocates a new logic variable (id = state.counter), increments the counter, and
passes the variable to f which returns a goal.
run 2 (q: fresh (x: disj (eq x 1) (eq x 2) |> (g: conj g (eq q x))))Streams
Section titled “Streams”A stream is either:
null— the empty stream{ head = state; tail = streamOrThunk; }— a non-empty stream
tail may be a thunk (a zero-argument lambda) for lazy evaluation — this is what
enables infinite search spaces. streamTake n stream forces thunks as it goes,
collecting up to n head states.
null # empty{ head=s0; tail=null; } # one answer{ head=s0; tail= (_: …); } # lazy tailrun n goalFn
Section titled “run n goalFn”Top-level query driver:
- Create empty state
{subst={}; counter=0;}. - Call
goalFn (mkVar 0)— passes a fresh query variable. streamTake non the resulting stream.reifyeach state: walk the query variable fully, replacing any remaining unbound variables with canonical names_.0,_.1, …
run 2 (q: disj (eq q true) (eq q false))# => [ true false ]
run 1 (q: fresh (x: eq q [x x]))# => [ [ _.0 _.0 ] ] (x unbound — reified as _.0)