Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Case Study: µKanren

µKanren (Hemann & Friedman, 2013; the bibliography has the citation) is a relational programming language whose complete kernel fits in under forty lines of Scheme. lib/mk.naj is the port of namin/pink’s mk.scm, and it stresses a different part of Pink than the matcher did: higher-order goals, streams represented partly as closures, and a program built by wrapping.

The kernel

The concepts, compressed: a logic variable is a tagged pair (var . n); a substitution is an association list from variables to terms; a state is a substitution paired with a fresh-variable counter; a goal is a function from a state to a stream of states — each state in the stream a distinct way the goal can succeed. Four combinators build every goal: == (unify two terms), call/fresh (introduce a variable), disj (either goal), conj (both goals).

mk is a program→program wrapper in the same style as matcher: it splices an object program into a let-chain that binds the kernel around it —

(define mk (lambda _ program
  `(let = (lambda _ a (lambda _ b (if (- a b) 0 1)))
   (let assp ...
   ...
   (let empty-state (cons 'nil 0)
   ,program
   )))...))

so (mk prog) is closed Pink source in which prog sees ==, call/fresh, disj, conj, and empty-state. (Two renamings from the reference: $-prefixed stream names became st names, since $ is not a symbol character here, and '() is written 'nil.)

Running goals

A goal runs by applying it to empty-state. The simplest one binds a fresh variable to 5:

(load lib/mk.naj)
((interpret (mk '(let p ((call/fresh (lambda _ q ((== q) 5))) empty-state) (car p)))) nil-env)
;=> (((('var . 0) . 5)) . 1)

The first state in the stream: the substitution binds variable 0 to 5, and the counter says one variable exists. The rest of the stream is empty — unification succeeds exactly one way:

((interpret (mk '(let p ((call/fresh (lambda _ q ((== q) 5))) empty-state) (cdr p)))) nil-env)
;=> nil

Disjunction is where streams earn their keep. The reference test suite’s a-and-b goal constrains a to 7 and b to 5 or 6; the stream has one state per way to satisfy it:

((interpret (mk '(let p (((conj (call/fresh (lambda _ a ((== a) 7)))) (call/fresh (lambda _ b ((disj ((== b) 5)) ((== b) 6))))) empty-state) (car p)))) nil-env)
;=> (((('var . 1) . 5) (('var . 0) . 7)) . 2)
((interpret (mk '(let p (((conj (call/fresh (lambda _ a ((== a) 7)))) (call/fresh (lambda _ b ((disj ((== b) 5)) ((== b) 6))))) empty-state) (car (cdr p))))) nil-env)
;=> (((('var . 1) . 6) (('var . 0) . 7)) . 2)

First state: b is 5. Second: b is 6. Both carry the a-to-7 binding and a counter of two. Streams are not always fully-built lists — mplus and bind suspend work behind zero-argument closures when a stream’s tail is not yet demanded, which is what lets µKanren enumerate answers from infinite relations.

Through the compiler

(mk prog) is a program like any other, so the whole Futamura pipeline of this part applies to it unchanged. Compiling the wrapped goal — kernel and all — produces the same first state as interpreting it:

(compile (mk '(let p ((call/fresh (lambda _ q ((== q) 5))) empty-state) (car p))))
;=> (((('var . 0) . 5)) . 1)

The residual is the program itself in ANF — the collapse property again — and compile’s run 0 executes it, so the printed value is the finished state rather than a function. Compiling a goal into a reusable compiled artifact needs the goal kept behind a function boundary, and choosing where that boundary sits under staging is exactly what clambda is for, in Part IV.