Skip to content

Latest commit

 

History

History
717 lines (548 loc) · 20.1 KB

derivation.org

File metadata and controls

717 lines (548 loc) · 20.1 KB

Point of the derivation in this paper is:

With the 2013 microKanren conj macro, we end up building heavily nested lambdas.

The “naturally-recursive” macro-based implementations we came up with go the wrong direction. They nest the goals unfortunately, in ways that we can see in e.g. my redex model, and in Michael’s graffle depiction.

I will it turns out need to discuss the difficulty of the conjunction.

Show how using var-args for functions points the way to the correct definitions

We should be able to see from the derivation how a clean definition brings us to right behavior.

Just switching to var-args functions isn’t enough!

Just working with var-args functions isn’t enough; we could define the logic operators associating the “right way” just as easily as defining them the “wrong way”. So we need some more subtle categorization.

conda

It seemed like conda had forced our hand into including the zero-ary base case. If we wanted to build out of earlier primitives, we had needed the zero-ary version for conda. Bracket that concern for a second, and let’s otherwise say we don’t need that additional case.

We already have this collection of 8 different obvious implementations.

We can implement the 2+-ary versions, using conj2/disj2 as primitives, in either the tail recursive or the natl. recursive versions, with either macros or varargs, either forwards or backwards. So that’s a nice place to start. Note, that left or right association and left or right fold are the same, because the operator is binary conj2/disj2—association is fold. Selecting a starting operator conj2 v. disj2 is not an axis of variation; any successful approach must work for both.

Suppose we want to implement the 1+ary versions.

Why would you want to do that? Macros, for instance. The (C g) case matters b/c, say, some conde macro, or fresh if you wanted. Maybe it would suck if the user had to think about whether their fresh body had 1 or 2+ goals in it. But suppose you didn’t care about that, either, and that you didn’t need the singleton conjunction case either. Then we can pick from a smörgåsbord of options.

We derivation our “clean” version from one of these short recursive variants.

The basic steps seem to be

  1. I mean, honestly. That 0-ary base case needs to go. We gotta get rid of that.
  2. To get rid of macro, start with 2-ary versions. These blend easily.
  3. To avoid apply, use variadic to make a help function, and just use list recursion
  4. We should be able to simplify by substituting through the old xyzj/2 definition
  5. We ought to be able to reduce the base case

First try

So, which one is this?—~varargs-2+-left-assoc~

#lang racket
(define ((C g1 g2 . gs) s)
  (cond
    ((null? gs) ((conj2 g1 g2) s))
    (else ((apply C (cons (conj2 g1 g2) gs)) s))))

We can start by breaking the function up into a recursive variant and an external-facing help function. Unlike the earlier recursive variant above, this inner recursive function does not require apply since it uses ordinary list recursion.

#lang racket
(define ((C g1 g2 . gs) s)
  (C-rec g1 g2 gs s))

(define (C-rec g1 g2 gs s)
  (cond
    ((null? gs) ((conj2 g1 g2) s))
    (else
     (C-rec (conj2 g1 g2) (car gs) (cdr gs) s))))

If we substitute through in the definition of conj2, we get:

#lang racket
(define ((C g1 g2 . gs) s)
  (C-rec g1 g2 gs s))

(define (C-rec g1 g2 gs s)
  (cond
    ((null? gs) ((lambda (s) ($append-map g2 (g1 s))) s))
    (else
     (C-rec (lambda (s) ($append-map g2 (g1 s))) (car gs) (cdr gs) s))))

Here is the important piece; since we only need s to build the stream, we can assemble the stream on the way in, and accumulate along it—instead of passing in g1 and s separately, we pass in their combination as a stream. The function is tail recursive, we can change the signature in the one and only external call and the recursive call.

We had to combine and hand substitute through, as in ((lambda (s) ($append-map g2 (g1 s))) s)

#lang racket
(define ((C g1 g2 . gs) s)
  (C-rec g2 gs (g1 s)))

(define (C-rec g2 gs s-inf)
  (cond
    ((null? gs) ($append-map g2 s-inf))
    (else (C-rec (car gs) (cdr gs) ($append-map g2 s-inf)))))

The recursion and the base case share a lot in common. We can exploit that. If we pass back the stream in the base case, and split gs in the recursive case, we can get rid of g2 and turn this into a 1+ary version.

#lang racket
(define ((C g1 . gs) s)
  (C-rec gs (g1 s)))

(define (C-rec gs s-inf)
  (cond
    ((null? gs) s-inf)
    (else (C-rec (cdr gs) ($append-map (car gs) s-inf)))))

And there you have it. We can derive this answer from the original version. Both the first version and this final version have their virtues and drawbacks; one uses explicit car and cdr, while the other uses apply. I think we prefer this last one, because it’s strictly more general.

This “derivation sequence” is essentially a three step operation: 1. take an operation based on conj2/disj2 and then go beneath that level 2. some simple clean-up optimizations 3. reduce the demanded arity so that it operates on 1+ arguments.

It could be nice to avoid having to specialize our macros to the two different cases and keep our users from needing to worry adding and removing a combinator when moving from one to more than one goal.

#lang racket

(define-syntax fresh
  (syntax-rules ()
    [(fresh () g) <do something on this one>]
    [(fresh () g g1 g* ...) <do something on this one>]
    [(fresh (x ...) g ...) <recur here down to base case>]))

To illustrate just how superfluous the 0-arity version is, see that we can add that back in as a separate case of the interface function.

#lang racket
(define ((C . gs) s)
  (cond
    ((null? gs) S)
    (else (C-rec (cdr gs) ((car gs) s)))))

(define (C-rec gs s-inf)
  (cond
    ((null? gs) s-inf)
    (else (C-rec (cdr gs) ($append-map (car gs) s-inf)))))

So. Can we do the same thing for all of those variants?

Is this transformation sequence (or some analogous version of it) equally applicable across all of the 4 varags versions?

Open question.

To recapitulate, our initial motivation was to remove some macros. This led to using variadic functions to combine arbitrary-length goal sequences. I want to tell a story where many, if not all, of the decisions fell out as a consequence of this choice. Can we do that?

Let’s try and do a similar derivation from one of the other versions.

I want to try one of the more interesting variants. I’m actually interested in all four versions, because I want to know whether we can get tail recursive disj taking its arguments the right way, and ensuring that we cannot do a similar derivation for the natl. recursive variants, and for good reason. If we can do that, everything is aces and this is a good paper. If we are stuck with the backward-disj, then that’s okay but not great.

#lang racket
(define ((conj g g1 . gs) s)
  (cond
    ((null? gs) ((conj2 g g1) s))
    (else ((conj2 g (apply conj (cons g1 gs))) s))))

So, alright. We’ll try it this way. Break it apart into two mutually recursive functions.

#lang racket
(define ((conj g g1 . gs) s)
  (C-rec g g1 gs s))

(define (C-rec g g1 gs s)
  (cond
    ((null? gs) ((conj2 g g1) s))
    (else ((conj2 g (apply conj (cons g1 gs))) s))))

Okay, now this must be where things get different. We cannot (easily) replace the subsequent line by a recursive call to C-rec, because we are still waiting on an s. So the best we can do is this, abstracting over s and waiting.

#lang racket
(define ((conj g g1 . gs) s)
  (C-rec g g1 gs s))

(define (C-rec g g1 gs s)
  (cond
    ((null? gs) ((conj2 g g1) s))
    (else ((conj2 g (λ (s) (C-rec g1 (car gs) (cdr gs) s))) s))))

From here we can try and substitute through the definition of conj2.

#lang racket
(define ((conj g g1 . gs) s)
  (C-rec g g1 gs s))

(define (C-rec g g1 gs s)
  (cond
    ((null? gs) ((lambda (s) ($append-map g1 (g s))) s))
    (else ((lambda (s) ($append-map (λ (s) (C-rec g1 (car gs) (cdr gs) s)) (g s))) s))))

In the earlier version, we could construct the stream on the way down. We cannot do that here. We can β-reduce here, since the lambdas weren’t doing anything for us.

#lang racket
(define ((conj g g1 . gs) s)
  (C-rec g g1 gs s))

(define (C-rec g g1 gs s)
  (cond
    ((null? gs) ($append-map g1 (g s)))
    (else ($append-map (λ (s) (C-rec g1 (car gs) (cdr gs) s)) (g s)))))

In the earlier derivation we were able to turn the stream itself into a parameter, and change the recursive function’s arity. We can do that here, although again this abstraction makes the results a little less pleasant.

#lang racket
(define ((conj g g1 . gs) s)
  (C-rec g1 gs (g s)))

(define (C-rec g1 gs s-inf)
  (cond
    ((null? gs) ($append-map g1 s-inf))
    (else ($append-map (λ (s) (C-rec (car gs) (cdr gs) (g1 s))) s-inf))))

We can try and loosen the 2+ requirement, a bit at least.

#lang racket
(define ((conj g . gs) s)
  (C-rec gs (g s)))

(define (C-rec gs s-inf)
  (cond
    ((null? gs) s-inf)
    (else ($append-map (λ (s) (C-rec (cdr gs) ((car gs) s))) s-inf))))

It’s not totally clear that there’s some optimization we’re prevented from performing, but needing to construct a closure for every recursive call is expensive, I guess compared to doing not-that.

But I’m having trouble understanding how one is so obviously a worse definition, or how one permits an optimization the other clearly forbids, in a way that leads to the thesis “basic programming skills get us to the clever definition” and that we can see from horse sense.

Maybe the nested lambdas there is it. However.

If you treat the list as accumulatively, then you’ll get the result you want.

The last case we need to concern ourselves with: disj

fold right variant.

This one wound up with a nested lambda, and does a non-trivial amount of work to it. Is the alternative nicer?

#lang racket
(define ((disj g g1 . gs) s)
  (cond
    ((null? gs) ((disj2 g g1) s))
    (else ((disj2 g (apply disj (cons g1 gs))) s))))

Mutual recursion

#lang racket
(define ((disj g g1 . gs) s)
  (D-rec g g1 gs s))

(define (D-rec g g1 gs s)
  (cond
    ((null? gs) ((disj2 g g1) s))
    (else ((disj2 g (apply disj (cons g1 gs))) s))))

But since unlike the earlier version we don’t have all the parts yet, we cannot build the recursive function right now. Instead we have to λ abstract.

#lang racket
(define ((disj g g1 . gs) s)
  (D-rec g g1 gs s))

(define (D-rec g g1 gs s)
  (cond
    ((null? gs) ((disj2 g g1) s))
    (else ((disj2 g (λ (s) ((apply disj (cons g1 gs)) s))) s))))

… and then we can build the recursion

#lang racket
(define ((disj g g1 . gs) s)
  (D-rec g g1 gs s))

(define (D-rec g g1 gs s)
  (cond
    ((null? gs) ((disj2 g g1) s))
    (else ((disj2 g (λ (s) (D-rec g1 (car gs) (cdr gs) s))) s))))

Now seems like a nice time to unfold the definitions of disj2.

#lang racket
(define ((disj g g1 . gs) s)
  (D-rec g g1 gs s))

(define (D-rec g g1 gs s)
  (cond
    ((null? gs) ((λ (s) ($append (g s) (g1 s))) s))
    (else ((λ (s) ($append (g s) ((λ (s) (D-rec g1 (car gs) (cdr gs) s)) s))) s))))

This makes the β reductions come out very nice.

#lang racket
(define ((disj g g1 . gs) s)
  (D-rec g g1 gs s))

(define (D-rec g g1 gs s)
  (cond
    ((null? gs) ($append (g s) (g1 s)))
    (else ($append (g s) (D-rec g1 (car gs) (cdr gs) s)))))

And this is a nice time to reduce the arity.

#lang racket
(define ((disj g . gs) s)
  (D-rec g gs s))

(define (D-rec g gs s)
  (cond
    ((null? gs) (g s))
    (else ($append (g s) (D-rec (car gs) (cdr gs) s)))))

This approach has to be the right solution. It might be that this doesn’t take the conjuncts in the correct order or something, in which case we have to switch which goal goes in which position, but I have a real hard time believing that this isn’t the correct answer.

I worry the other disj implementation we’re after ends up with those same sadly-nested lambdas. And I suspect that performance wise that’s a bad thing.

I thought the straightforward left-associative, left recursion disj implementation you get that great performance, the same with conjunction. The fact that the above is so nice makes me suspect that the other implementation has to be incorrect. Or, maybe with disjunction it just doesn’t matter? Let’s see anyway.

The left-associative disj

#lang racket

(define ((disj g g1 . gs) s)
  (cond
    ((null? gs) ((disj2 g g1) s))
    (else (let ((res (apply disj (cons (disj2 g g1) gs))))
            (res s)))))

and split

#lang racket
(define ((disj g g1 . gs) s)
  (D-rec g g1 gs s))

(define (D-rec g g1 gs s)
  (cond
    ((null? gs) ((disj2 g g1) s))
    (else ((apply disj (cons (disj2 g g1) gs)) s))))

and apply through

#lang racket
(define ((disj g g1 . gs) s)
  (D-rec g g1 gs s))

(define (D-rec g g1 gs s)
  (cond
    ((null? gs) ((disj2 g g1) s))
    (else (D-rec (disj2 g g1) (car gs) (cdr gs) s))))

We have to abstract over s here, which is probably going to be sub-optimal.

#lang racket
(define ((disj g g1 . gs) s)
  (D-rec g g1 gs s))

(define (D-rec g g1 gs s)
  (cond
    ((null? gs) ((λ (s) ($append (g s) (g1 s))) s))
    (else (D-rec (λ (s) ($append (g s) (g1 s))) (car gs) (cdr gs) s))))

I guess we can β in the base case.

#lang racket
(define ((disj g g1 . gs) s)
  (D-rec g g1 gs s))

(define (D-rec g g1 gs s)
  (cond
    ((null? gs) ($append (g s) (g1 s)))
    (else (D-rec (λ (s) ($append (g s) (g1 s))) (car gs) (cdr gs) s))))

And finish up with the arity reduction again.

#lang racket
(define ((disj g . gs) s)
  (D-rec g gs s))

(define (D-rec g gs s)
  (cond
    ((null? gs) (g s))
    (else (D-rec (λ (s) ($append (g s) (g1 s))) (cdr gs) s))))

This variant builds up one giant big honkin’ goal, and then applies that dude all over creation. I assume that’s sub-optimal. But I don’t know it.

The left-associative disj, but arguments flipped

At some point, one of the problems that we saw, that Greg Rosenblatt also pointed out, was that the mK search heuristic was flipped for one of these implementations—that is to say, the rightmost goal ended up getting I think 1/2 of the search rather than the leftmost, as per usual. However, we saw also that alternate implementations flip the order that the arguments execute. And there’s no problem switching them as we recur down. This is the same derivation we just saw, but instead using the left-associated flipped args version.

#lang racket
(define ((disj g g1 . gs) s)
  (cond
    ((null? gs) ((disj2 g1 g) s))
    (else ((apply disj (cons (disj2 g1 g) gs)) s))))

So, the by-now standard practice.

Split the function in two

#lang racket
(define ((disj g g1 . gs) s)
  (D-rec g g1 gs s))

(define (D-rec g g1 gs s)
  (cond
    ((null? gs) ((disj2 g1 g) s))
    (else ((apply disj (cons (disj2 g1 g) gs)) s))))

We can directly substitute the call ((apply disj (cons (disj2 g1 g) gs)) s) through for the recursion.

#lang racket
(define ((disj g g1 . gs) s)
  (D-rec g g1 gs s))

(define (D-rec g g1 gs s)
  (cond
    ((null? gs) ((disj2 g1 g) s))
    (else (D-rec (disj2 g1 g) (car gs) (cdr gs) s))))

Substitute through for disj2

#lang racket
(define ((disj g g1 . gs) s)
  (D-rec g g1 gs s))

(define (D-rec g g1 gs s)
  (cond
    ((null? gs) ((lambda (s) ($append (g1 s) (g s))) s))
    (else (D-rec (lambda (s) ($append (g1 s) (g s))) (car gs) (cdr gs) s))))

β-reduce where we can

#lang racket
(define ((disj g g1 . gs) s)
  (D-rec g g1 gs s))

(define (D-rec g g1 gs s)
  (cond
    ((null? gs) ($append (g1 s) (g s)))
    (else (D-rec (lambda (s) ($append (g1 s) (g s))) (car gs) (cdr gs) s))))

We cannot really do much to substitute in a stream, but we can reduce the arity

#lang racket
(define ((disj g . gs) s)
  (D-rec g gs s))

(define (D-rec g gs s)
  (cond
    ((null? gs) (g s))
    (else (D-rec (lambda (s) ($append ((car gs) s) (g s))) (cdr gs) s))))

Earlier, broken disj attempt. Just scraps

(define ((D . gs) s) …)

Since \rackinline|gs| could be empty, as with the macro based implementation we introduce a base-case for the zero-length list.

(define ((D . gs) s) (cond ((null? gs) (list)) …))

But it’s also unfortunate to force an extra failing recursion onto every call, so we add the length-one arguments list to short-circuit that.

(define ((D . gs) s) (cond ((null? gs) ‘()) ((null? (cdr gs)) (g s)) …))

;; These assume 2+ goals; and we don’t write or work w/ “silly conjunctions.”

(define-syntax conj (syntax-rules () ((conj g0 g1) (conj₂ g0 g1)) ((conj g0 g1 g …) (conj (conj₂ g0 g1) g …))))

(define-syntax disj (syntax-rules () ((disj g0 g1) (disj₂ g0 g1)) ((disj g0 g1 g …) (disj (disj₂ g0 g1) g …))))

;; These next two are from the paper, as written. ;; As they’re written, they are 0-or-more-ary. ;; But they likewise shouldn’t require silly arities.

(define-syntax conj (syntax-rules () ((conj) S) ((conj g) g) ((conj g0 g …) (conj₂ g0 (conj g …)))))

(define-syntax disj (syntax-rules () ((disj) F) ((disj g) g) ((disj g0 g …) (disj₂ g0 (disj g …)))))

;; So we first remove the zero arity, conda be damned.

(define-syntax conj (syntax-rules () ((conj g) g) ((conj g0 g …) (conj₂ g0 (conj g …)))))

(define-syntax disj (syntax-rules () ((disj g) g) ((disj g0 g …) (disj₂ g0 (disj g …)))))

;; Then, we try again and unfold the recursion once more

(define-syntax conj (syntax-rules () ((conj g) g) ((conj g0 g1) (conj₂ g0 (conj g1))) ((conj g0 g1 g …) (conj₂ g0 (conj g1 g …)))))

(define-syntax disj (syntax-rules () ((disj g) g) ((disj g0 g1) (disj₂ g0 (disj g1))) ((disj g0 g1 g …) (disj₂ g0 (disj g1 g …)))))

;; Then, substitute through, and specialize away

(define-syntax conj (syntax-rules () ((conj g0 g1) (conj₂ g0 g1)) ((conj g0 g1 g …) (conj₂ g0 (conj g1 g …)))))

(define-syntax disj (syntax-rules () ((disj g0 g1) (disj₂ g0 g1)) ((disj g0 g1 g …) (disj₂ g0 (disj g1 g …)))))

(define-syntax conj (syntax-rules () ((conj g0 g1) (conj₂ g0 g1)) ((conj g0 g1 g …) (conj (conj₂ g0 g1) g …))))

(define-syntax disj (syntax-rules () ((disj g0 g1) (disj₂ g0 g1)) ((disj g0 g1 g …) (disj (disj₂ g0 g1) g …))))

Then we substitute through the definition of disj2/conj2, and see what happens there.

Fresh reimplementation, janky.

Then there’s this janky reimplementation of fresh. It’s hacky and non-portable. That procedure-arity is very implementation specific and only partially works (case-lambda, procedure-arity doesn’t really always work, etc, see failed SRFI discussion), and at least Racket’s assert mechanism is not the same as the Scheme error handling mechanism. But build-list is AFAIK Racket only.

#lang racket

(define (fresh f)
  (let ((n (procedure-arity f)))
    (assert (number? n))
    (λ (s/c)
      (let ((c (cdr s/c)))
        ((apply f (build-list n (curry + c)))
         (cons (car s/c) (+ n c)))))))