This short post is intended to summarize some work on a simple exercise that may be useful to keep in mind when arithmetizing purely functional programs.
The topic of discussion is the hylomorphism; a recursive function made up of four parts;
An endofunctor, F. This is the same thing as a functor in the Haskell sense. There should be a map function that turns a function from As to Bs into a function from F(A)s to F(B)s, for all A and all B.
A "coalgebra" over F; that is, a function that turns As into F(A)s, for a fixedA.
An "algebra" over F; that is, a function that turns F(B)s into Bs, for a fixedB.
A recursive equation that defines the hylomorphism. If a is the algebra, c is the coalgebra, and h is the hylomorphism, the following equation holds.
h=a∘F(h)∘c
If you've never seen a hylomorphism before, it may not be clear either what h is doing or why this pattern is important.
Briefly, we can think of c as constructing a single layer of a tree. If F is, for example, something like X↦1+X⨯X, then c will take an A and turn it into something in the shape 1+A⨯A, that is, it will either be a leaf, or a pair of As. The F(h), being aware of this layer structure, will map h into the As created by c. In the case of a leaf, there will be no created As, so no new calls to h will be created. Alternatively, if a branch is encountered then there will be one new h call per branch. h will produce new cs, and so we can imagine a tree whose layers are F shaped taking form.
a will deconstruct these layers. It will take an F(B) and produce a B. In the case of a leaf, it will produce a B wholesale, otherwise it may combine the Bs in the branches. As h recurses, it will spawn exactly one a for each c, exactly one layer consumer for each layer producer. Ultimately, the as will consume the whole tree. In the end, h will be a recursive function that takes an A and produces a B.
A commonplace example of a hylomorphism is the quicksort algorithm. c will take an input list and split it into
the first element of the list.
all the elements of the list's tail that are less than or equal to the first.
all the elements of the list's tail that are greater than the first.
Recursively applying this will produce a tree that stores numbers on its branches such that all these numbers are sorted. a then only needs to collapse the tree back into the list. We can infer that F will be X↦1+N⨯X⨯X. Additionally, both A and B will be List(N). Using this as a guide, we can implement this as a hylomorphism. I will use Mathematica, but this should be doable, maybe with more or less elegance, in most languages.
The reason why hylomorphisms are important boils down to their ubiquity and expressiveness. The vast majority of complex algorithms either are or can be cast as a hylomorphism. They also have an elaborate algebraic theory that allows them to be manipulated as purely mathematical objects for the purpose of program calculation and optimization. This is common in approaches in the vein of the algebra of programming.
The other topic of this post is trace arithmetization. There is a lot of work taking whole programming languages, execution models, or virtual machines and creating circuits for verifying the execution of a program. By arithmetizing hylomorphisms, we cover a large swath of programs without having to commit to a language. Now, is this actually better? Intuitively, I would say no; at least not on its face. However, I found this approach at least enlightening to contemplate.
The trace of a hylomorphism will have the same shape as the recursive call structure of the function. This is exactly the tree produced by the coalgebra. By using the identity function as the algebra, we can get this tree as tree=F(tree)∘c. With a bit of formatting, we can see this in our previous implementation.
This is not quite our trace, but it forms its skeleton. What we need to do is decorate each constructor with the As and Bs present there. Let's start with the As produced by the coalgebra. At each leaf, we won't have an F layer, but, instead, an A⨯F layer. Some readers may recognize this as a cofree comonad over F; if you don't, then you can ignore that. To produce these layers, we merely need to copy each A, leave one copy alone and apply c to the other. This can be accomplished using the universal property of ⨯, which I will denote △. △ will take two functions, one from Xs to Ys and the other from Xs to Zs and produce a function from Xs to (Y⨯Z)s by copying the input and applying each function respectively. With this in hand, we can define the coalgebra part of the trace as
ctrc=(A⨯F)(ctrc)∘(id△c)
Implementing this with our quicksort example, we'd get
At this point, we have something which can be collapsed into a list of constraints. At each layer, we have something of the shape A⨯F(A⨯...). By mapping π1, the first projection, we get a pair A⨯F(A). Such a pair should be an input-output pair for c. π1 is First in Mathematica.
The algebra trace is only a bit more complicated. We do need c to produce the tree. After that, each algebra will see, not an F(B), but, instead, an F(B⨯F(...)), and it must produce a B⨯F(B⨯F(...)). This requires copying the input, mapping π1 , and applying a to one of those copies and leaving the other unmodified.
atrc=(a∘F(π1))△id∘F(atrc)∘c
Implementation is maybe simpler than the coalgebra case.
We could combine this into a single full trace by storing both A and B at each constructor, but there's no benefit in doing that here, so I'll keep them separate.
This is all well and good, but, presumably, one would like to put this in a format that can ultimately be placed within a circuit. To that end, the algebra and coalgebra of quicksort are also recursive. They are also hylomorphisms, so we can produce their traces the same way. To facilitate this, it's prudent to do a bit of generic programming. We can define generic constructors for polynomial endofunctors and automatically generate functions using them. For example, we can define generic maps;
And we can define a single hylomorphism function which we'll use for all future hylos.
hylo[F_, a_, c_][x_] := a@M[F][hylo[F, a, c]]@c@x
Focusing on the coalgebra, we must define a function which pulls off the first element of a list before splitting the list into two filtered sub-lists. This requires us to define the coalgebra in two pieces. The first piece is non-recursive; it tries splitting off an element and returns nothing if there is no first element. I'll call this piece listCoalg. It takes a List(A) and returns a 1+(A⨯List(A)).
Assuming we were able to split off the head of the list, we can now define the family of hylomorphisms which take a parameter and a list and produces a pair of lists.
With this, we can define the second piece of the coalgebra, a recursive function I'll call filter. That, along with the coalgebra itself, can be defined as;
With that, all the recursive parts of quicksort have been factored out. To turn this into a more useful set of constraints, we can implement generic versions of our trace functions. Part of that is implementing a function I called "collapse" which will take an F(Set(A)) and produce a Set(A) by unioning together all the sets which appear in the expression.
At this point, the goal would be to get a list of constraints that are sufficiently primitive that we can implement them in a simpler system. This should basically be the check for filterAlg, concatAlg, and the non-recursive parts of the other functions.
For filter, the coalgebra just converts the standard representation of lists into a generic representation. In other words, it's essentially a glorified identity function. As such, we can ignore the coalgebra when describing the filter constraints.
I've named the, yet to be implemented, check for filterAlg, filterAlgCheck.
The coalgebra has two cases that produce two different sets of constraints. The only additional constraint on top of what filterCheck produces ensures that the second argument of the check is the appropriate output.
We can ignore the coalgebra constraints for concat for the same reason we ignored them for filter. The structure of the constraints is, overall, similar.
If we run qsCheck on 9,4,0,5,3,2,7,8,6,1, we will get a total of 87 constraints to check.
For the rest of this post, I will try compiling those constraints to Vamp-IR, a language for representing arithmetic circuits.
In order to perform all the checks, we need to be able to check equality between elements of polynomial endofunctors. Vamp-IR has built-in tuples, but not coproducts. We can, however, implement coproducts via higher-order functions.
def inl x l r = l x;
def inr x l r = r x;
We can then implement equality checks. These are parameterized by whatever method is necessary to check equality of the underlying data. Each will return 1 if the elements are equal, and 0 otherwise.
def isZero x = {
def xi = fresh (1 | x);
x * (1 - xi * x) = 0;
1 - xi * x
};
def intEq a b = isZero (a - b);
def unitEq a b = 1;
def emptyEq a b = 0;
def prodEq eqX eqY (x1, y1) (x2, y2) =
eqX x1 x2 * eqY y1 y2;
def sumEq eqX eqY a b =
a (fun x1 { b (fun x2 { eqX x1 x2 }) (fun y2 { 0 }) })
(fun y1 { b (fun x2 { 0 }) (fun y2 { eqY y1 y2 }) });
We can write some functions to assist in automatically generating Vamp-IR code. This code will convert syntax trees to strings of Vamp-IR code and also automatically generate equality checkers based on a functor.
For several checks, we need to check equality between lists. This isn't possible in general, as lists can be arbitrarily long. We can, however, create a check that works for every list up to a specific length. For our example, the limit is length 10, which has 11 constructors including the empty list at the end. As such, we can automatically compute this and add it as a definition.
It's quite a long definition, so I've truncated it here. From here, we can start implementing each of the checks. filterAlgCheck is the most technically sophisticated. Firstly, it needs a method to verify that one number is less than another. Since none of our numbers are over 9, I'll only make a big enough range check for 0-15.
With these, the check can be implemented directly.
def or b1 b2 = 1 - (1 - b1) * (1 - b2);
def filterAlgCheck p a (b1, b2) =
a (fun x { listEq b1 (inl 0) * listEq b2 (inl 0) })
(fun (h, (a1, a2)) {
or (less4 h p * listEq (inr (h, a1)) b1 * listEq a2 b2)
((1 - less4 h p) * listEq a1 b1 * listEq (inr (h, a2)) b2)
});
We can then automatically generate Vamp-IR constraints in Mathematica if we implement functions for converting elements of polynomial endofunctors and using them to generate the full check.
concatAlgCheck is much simpler, but still requires some basic work.
def concatAlgCheck p a b =
a (fun x { listEq p b })
(fun (n, l) { listEq (inr (n, l)) b });
concatAlgCheck[p_, a_, b_] :=
eq[ap["concatAlgCheck",
{listConv[p],
funConv[$1 ⊕ ($C ⊗ $L[$C])][a],
listConv[b]
}], 1]
The remaining equality checks are trivial so long as we know their types.
I've compiled the full Vamp-IR program into this gist, if you want to see it. It does, indeed, produce a valid proof, but I'm not fully satisfied with it. You can also find a Mathematica notebook with the code that was used to produce it here.
There is an argument to be made that what I did here was not a principled way to arithmetize data-structure manipulations. The compiled circuit will throw away most data-structure details and only really check field element manipulations. Arithmetizing the data-structure manipulations themselves would be more involved, and would require a few design choices. The latter approach is related to what GEB is doing. I also had the thought, while writing this post, that these constructions could be made even more generic by describing all the functions through the interface of a distributive category. This would allow the arithmetizations for the primitive functions to be more automatic. This is even more like what GEB does. Perhaps this idea should be revisited there.