User-defined Predicates
In addition to the vprop predicates and connectives that the Pulse
libraries provide, users very commonly define their own vprops
. We
show a few simple examples here—subsequent examples will make heavy
use of user-defined predicates. For example, see this section for
recursively defined predicates.
Fold and Unfold with Diagonal Pairs
A simple example of a user-defined abstraction is show below.
let pts_to_diag
#a
(r:ref (a & a))
(v:a)
: vprop
= pts_to r (v, v)
pts_to_diag r v
is a vprop
defined in F* representing a
reference to a pair whose components are equal.
We can use this abstraction in a Pulse program, though we have to be explicit about folding and unfolding the predicate.
fn double (r:ref (int & int))
requires pts_to_diag r 'v
ensures pts_to_diag r (2 * 'v)
{
unfold (pts_to_diag r 'v);
let v = !r;
let v2 = fst v + snd v;
r := (v2, v2);
fold (pts_to_diag r v2);
}
The unfold p
command checks that p
is provable in the current
context by some term q
, and then rewrites the context by replacing
that occurrence of q
with the term that results from unfolding the
head symbol of p
. A show_proof_state
after the unfold
shows that we have a pts_to r (reveal 'v, reveal 'v)
in the
context, exposing the abstraction of the pts_to_diag
predicate.
At the end of function, we use the fold p
command: this checks
that the unfolding of p
is provable in the context by some term
q
and then replaces q
in the context with p
.
fold
and unfold
is currently very manual in Pulse. While in
the general case, including with recursively defined predicates,
automating the placement of folds and unfolds is challenging, many
common cases (such as the ones here) can be easily automated. We are
currently investigating adding support for this.
Some initial support for this is already available, inasmuch as Pulse can sometimes figure out the arguments to the vprops that need to be folded/unfolded. For instance, in the code below, we just mention the name of the predicate to be unfolded/folded, without needing to provide all the arguments.
fn double_alt (r:ref (int & int))
requires pts_to_diag r 'v
ensures pts_to_diag r (2 * 'v)
{
unfold pts_to_diag;
let v = !r;
let v2 = fst v + snd v;
r := (v2, v2);
fold pts_to_diag;
}
Mutable Points
As a second, perhaps more realistic example of a user-defined abstraction, we look at defining a simple mutable data structure: a structure with two mutable integer fields, representing a 2-dimensional point.
noeq
type point = {
x:ref int;
y:ref int;
}
let is_point (p:point) (xy: int & int) =
pts_to p.x (fst xy) **
pts_to p.y (snd xy)
A point
is just an F* record containing two
references. Additionally, we define is_point
, a vprop
,
sometimes called a “representation predicate”, for a
point
. is_point p xy
says that p
is a representation of
the logical point xy
, where xy
is pure, mathematical pair.
We can define a function move
, which translates a point by some
offset dx, dy
.
fn move (p:point) (dx:int) (dy:int)
requires is_point p 'xy
ensures is_point p (fst 'xy + dx, snd 'xy + dy)
{
unfold is_point;
let x = !p.x;
let y = !p.y;
p.x := x + dx;
p.y := y + dy;
fold (is_point p (x + dx, y + dy));
}
Implementing move
is straightforward, but like before, we have to
unfold
the is_point
predicate first, and then fold it back up
before returning.
Unfortunately, Pulse cannot infer the instantiation of is_point
when folding it. A show_proof_state
prior to the fold should help
us see why:
We have
pts_to p.x (x + dx) ** pts_to p.y (y + dy)
For
fold (is_point p.x ?w)
to succeed, we rely on F*’s type inference to find a solution for the unsolved witness?w
such thatfst ?w == (x + dx)
andsnd ?w == (y + dy)
. This requires an eta-expansion rule for pairs to solve?w := (x + dx, y + dy)
, but F*’s type inference does not support such a rule for pairs.
So, sadly, we have to provide the full instantiation is_point p (x +
dx, y + dy)
to complete the proof.
This pattern is a common problem when working with representation predicates that are indexed by complex values, e.g., pairs or records. It’s common enough that it is usually more convenient to define a helper function to fold the predicate, as shown below.
ghost
fn fold_is_point (p:point)
requires pts_to p.x 'x ** pts_to p.y 'y
ensures is_point p (reveal 'x, reveal 'y)
{
fold (is_point p (reveal 'x, reveal 'y))
}
Note
We’ve marked this helper function ghost
. We’ll look into
ghost
functions in much more detail in a later chapter.
This allows type inference to work better, as shown below.
fn move_alt (p:point) (dx:int) (dy:int)
requires is_point p 'xy
ensures is_point p (fst 'xy + dx, snd 'xy + dy)
{
unfold is_point;
let x = !p.x;
let y = !p.y;
p.x := x + dx;
p.y := y + dy;
fold_is_point p;
}
Rewriting
In addition to fold
and unfold
, one also often uses the
rewrite
command when working with defined predicates. Its general
form is:
with x1 ... xn. rewrite p as q;
rest
Its behavior is to find a substitution subst
that instantiates the
x1 ... xn
as v1 ... vn
, such that subst(p)
is supported by
c
in the context, Pulse aims to prove that subst(p) ==
subst(q)
and replaces c
in the context by subst(q)
and
proceeds to check subst(rest)
.
To illustrate this at work, consider the program below:
fn create_and_move ()
requires emp
ensures emp
{
let mut x = 0;
let mut y = 0;
let p = { x; y };
//pts_to x 0 ** pts_to y 0
with _v. rewrite pts_to x _v as pts_to p.x _v;
with _v. rewrite pts_to y _v as pts_to p.y _v;
//pts_to p.x 0 ** pts_to p.y 0
fold_is_point p;
move p 1 1;
assert (is_point p (1, 1));
unfold is_point;
//pts_to p.x (fst (1, 1)) ** pts_to p.y (snd (1, 1))
with _v. rewrite pts_to p.x _v as pts_to x _v;
with _v. rewrite pts_to p.y _v as pts_to y _v;
//pts_to x (fst (1, 1)) ** pts_to y (snd (1, 1))
}
We allocate two references and put them in the structure p
. Now,
to call fold_is_point
, we need pts_to p.x _
and pts_to p.y
_
, but the context only contains pts_to x _
and pts_to y
_
. The rewrite
command transforms the context as needed.
At the end of the function, we need to prove that pts_to x _
and
pts_to y _
as we exit the scope of y
and x
, so that they
can be reclaimed. Using rewrite
in the other direction
accomplishes this.
This is quite verbose. As with fold
and unfold
, fully
automated rewrite
in the general case is hard, but many common
cases are easy and we expect to add support for that to the Pulse
checker.
In the meantime, Pulse provides a shorthand to make some common rewrites easier.
The rewrite each
command has the most general form:
with x1 ... xn. rewrite each e1 as e1', ..., en as en' in goal
This is equivalent to:
with x1 ... xn. assert goal;
rewrite each e1 as e1', ..., en as en' in goal
rewrite each e1 as e1', ..., en as en' in goal
is equivalent to
rewrite goal as goal'
where goal'
is computed by rewriting, in parallel, every
occurrence of ei
as ei'
in goal
.
Finally, one can also write:
rewrite each e1 as e1', ..., en as en'
omitting the goal
term. In this case, the goal
is taken to be
the entire current vprop
context.
Using rewrite each ...
makes the code somewhat shorter:
fn create_and_move_alt ()
requires emp
ensures emp
{
let mut x = 0;
let mut y = 0;
let p = { x; y };
rewrite each x as p.x, y as p.y;
fold_is_point p;
move p 1 1;
assert (is_point p (1, 1));
unfold is_point;
rewrite each p.x as x, p.y as y;
}