Building a Program Synthesizer
10 July 2018
In an earlier post, we saw an overview of program synthesis algorithms that automatically generate a program to implement a desired specification. While these algorithms are an exciting and evolving field of research, you don’t need to implement them yourself. Today, we’ll see how to build a program synthesizer using existing tools.
Getting started with Rosette
There are some great off-the-shelf frameworks for program synthesis. The original is Sketch, which offers a Java-ish language equipped with synthesis features. There’s also the syntax-guided synthesis language, which offers a common interface to several different synthesis engines.
For this post, we’re going to use Rosette, which adds synthesis and verification support to the Racket programming language. The nice thing about Rosette is that it’s an extension of Racket, so we’ll be able to use many of Racket’s nice features (like pattern matching) while building our synthesizer.
Following along: the code for this post is available on GitHub. If you’d like to follow along, you’ll need to install Racket and Rosette. Then you’ll be able to run Rosette programs either with the DrRacket IDE or the
Programming with constraints
Rosette’s key feature is programming with, and solving, constraints. Rather than a program in which all variables have known values, a Rosette program has some unknown variables, which we call symbolic variables. The values of the symbolic variables will be determined automatically according to the constraints we generate.
For example, we can try to find an integer whose absolute value is 5:
This program outputs:
(model [y -5])
This is Rosette’s way of saying it found a model (an assignment of values to all the symbolic variables) in which
y takes the value -5.
The model satisfies all the constraints we generated using
which in this case was just the single assertion
(= (absv y) 5).
Now let’s try to outsmart Rosette by asking for the impossible—a value
whose absolute value is negative:
Of course, Rosette agrees this is impossible, and returns:
This is an unsatisfiable solution: there is no possible
y that has a negative absolute value.
So constraint programming allows us to fill in unknown values in our program automatically. This ability will underlie our approach to program synthesis. There are many more examples of this constraint solving in the Rosette documentation.
(solve ...)form works by compiling constraints and sending them to the Z3 SMT solver, which provides high-performance solving algorithms for a variety of types of constraints. The Z3 tutorial is a nice introduction to this lower-level style of constraint progamming that Rosette abstracts away.
Domain-specific languages: programs in programs
Program synthesis is similar to the problems we just solved: there are some unknowns whose values we wish to fill in, subject to some constraints. But in synthesis, the unknowns are programs, usually drawn from a domain-specific language (DSL).
A DSL is just a small programming language equipped with exactly the features we are interested in. You can build a DSL for just about anything. In our research, we’ve built DSLs for synthesis work in file system crash safety, memory consistency, and approximate hardware, and others have done the same for network configuration and K–12 algebra tutoring.
DSLs are fundamental in program synthesis because they define the search space—the set of possible values for the “unknown program”. If a DSL is too complex, it may be difficult to solve a synthesis problem, because there are many programs to consider. But if a DSL is too simple, it won’t be able to express interesting behaviors. Controlling this trade-off is critical to building practical synthesis tools.
A simple arithmetic DSL
For today, we’re going to define a trivial DSL for arithmetic operations. The programs we synthesize in this DSL will be arithmetic expressions like
(plus x y). While this isn’t a particularly thrilling DSL, it will be simple to implement and demonstrate.
Every DSL needs two parts: its syntax (what programs look like) and its semantics (what programs mean).
Syntax. The syntax for our DSL will use Racket’s support for structures. We’ll define a new structure type for each operation in our language:
We’ve defined three operators in our language: two operators
mul that each take two arguments, and a
square operator that takes only a single argument. The structure definitions give names to the fields of the structure (
right for the two-argument operators, and
arg for the single-argument operator). The
#:transparent annotation just tells Racket to automatically generate some niceties for our structures, like string representations and equality predicates.1
Our syntax allows us to write programs such as this one:
to stand for the mathematical expression 72 + 3. In essence, we write programs in our DSL by constructing abstract syntax trees for the expressions we’re interested in.
Semantics. Now that we know what programs in our DSL look like, we need to say what they mean. To do so, we’ll implement a simple interpreter for programs in our DSL. The interpreter takes as input a program, performs the computations that program describes, and returns the output value. For example, we’d expect the above program to return 52.
Our little interpreter just recurses on the syntax using Racket’s pattern matching:
The recursion has a base case
[_ p]—in Racket patterns,
_ matches any value—that simply returns the input program
p. This base case handles constants in our programs.
By invoking our interpreter on the program
(plus (square 7) 3) above:
we can compute the value it evaluates to:
Synthesis with DSLs
Because our interpreter is just Racket code, Rosette will make it work even when symbolic variables are involved. For example, this program:
returns an expression
(* (+ 2 y) (+ 2 y))
y is symbolic. This “lifting” behavior means we can answer simple questions about programs in our DSL. For example, is there a value of
y that makes the program
(square (plus y 2)) evaluate to 25?
In fact, Rosette was too clever for me, and gave an answer I didn’t expect:
(model [y -7])
I was expecting
y to be 3, but of course, (-7 + 2)2 is also equal to 25.
This is our first synthesized program!
It’s not a very interesting program—
(square (plus -7 2))—but
it’s certainly a form of synthesis: we found a program
that satisfies a constraint.2
Dealing with program inputs
One thing that’s missing from the above synthesis is a notion of “input”.
Without input, programs in our DSL are really just constant expressions.
In other words, we might like find a constant
(mul c x) is equal to
x + x
for every possible
x, rather than just
Our earlier approach won’t be able to do this. If we try this program:
Rosette gives us a solution:
(model [x 0] [c 0])
which isn’t quite what we wanted.
What it did was find a value for both
that satisfied the constraint—of course,
(mul 0 0) is equal to
0 + 0.
What we want is to tell Rosette to find a value of
that works for every
x, not just one.
To do this, we’ll ask Rosette to synthesize rather than solve,
synthesize form. For example:
Here, we’re asking Rosette to fill in the unknowns
such that the constraint (the
holds for any possible value of
We find the answer we probably expect:
(model [c 2])
In other words,
(mul 2 x) is equivalent to
x + x. Surprise!
What’s neat is that the synthesizer discovered this identity (which is a property of our DSL) all by itself. We didn’t have to teach it any rewrite rules or algebraic laws, as we would have had to do if we were building a regular compiler, but instead told it only about the semantics of our DSL.
A fancier example
Adrian’s language is mostly similar to ours,
but it supports sketches.
A sketch is just a syntactic template
with holes for the synthesizer to fill in.
In our example above,
(mul c x) was a sketch,
c was the hole for the synthesizer to explore.
But real sketches can do more than just specify missing constants—they can specify
missing programs too.
As a contrived example,
we might want to know if
(mul 10 x)
can be decomposed as the sum of two expressions
(plus (mul 9 x) x)).
Rosette’s approach to sketches is a little different to
that in Adrian’s code (which adds conditionals to the DSL to construct sketches).
Instances of our DSL syntax are first-class values in Rosette,
and so we can manipulate them just like any other value.
We’re going to create a simple
that returns an unknown expression,
given some limitations on the values that can be leaves of that expression:
choose* procedure is provided by Rosette,
and is where the magic happens.
Given n arguments,
choose* returns a single value
that can evaluate to any of the n arguments.
??expr function constructs an unknown expression
by first constructing two values
each of which can evaluate to any of the values in the list
??expr returns an expression which applies any of our DSL operators
to those two values
or can evaluate to one of the
For example, if we called
(??expr (list 2 x)),
the resulting expression could evaluate to
(plus 2 x),
(plus 2 2),
and so on (but not multiple nestings—
(plus (plus 2 x) 2) is not possible with this definition).
Now we can use our unknown expression facility
to answer our burning question above—how do we write
(mul 10 x) as the sum of two expressions?
First, we define a sketch of our desired program,
the sum of two unknown expressions:
Now we invoke the
synthesize form to find a solution:
Unlike earlier examples, we save the result of
synthesize to a variable
Then we use Rosette’s
which substitutes concrete values for any symbolic variables in
according to the bindings in
The result of evaluating our sketch against the model
is our synthesized program:
(plus (mul 8 x) (plus x x))
If you do your math correctly, you’ll find that 8x + x + x is, in fact, equal to 10x. We’ve synthesized a (slower, sillier) program!
At this point, we’ve barely scratched the surface of program synthesis. But we’ve already done something very cool: notice that when we built the syntax and semantics for our DSL, we didn’t think about synthesis or symbolic reasoning at all. A simple interpreter for concrete programs became a powerful automated reasoning tool that can be used for solving, synthesizing, and verifying programs. This is the key promise of Rosette: write your programs for concrete state and get these powerful automated tools for free.
Where to from here? From the program synthesis side, most excitement in the community is focused on example-based synthesis. In our programs above, we had to write fairly detailed specifications for the synthesis to work out. What if we could instead just give a few examples of what we want our program to output for particular concrete inputs? This approach offers much simpler specifications, and is the basis of tools like Flash Fill, but has its own complexities (e.g., what if our examples are ambiguous?).
On the formal methods side, one key challenge for automated reasoning tools like the one we just built is scalability. Our examples work well with trivial specifications over a trivial DSL, but what if we want to talk about real-world code, like the software for a clinical radiotherapy system? Scaling an automated reasoning tool requires careful design. We’ve been working on new abstractions for synthesis tools, and on better ways to identify scalability bottlenecks in automated reasoning tools, but it’s still early in the quest to make automated reasoning accessible to everyone.
#:transparentalso has a Rosette-specific meaning: structures with this annotation will be merged together when possible, while those without will be treated as mutable structures that cannot be merged. This is often important for performance, as the Rosette paper explains in more detail. ↩
Program synthesis people would probably not call this synthesis, though—it’s really angelic execution, finding a binding that makes the program complete successfully. To call it program synthesis we’d want a universal quantifier over all possible inputs. ↩