Formally verifying the complexity of OCaml programs with CFML -- part 3
- August 3, 2015
In two previous blog posts (part 1 and interlude), I presented a set of notions to prove asymptotic complexity of OCaml programs, then an OCaml implementation of Okasaki’s “binary random access lists”. In this last blog post, I combine the two ideas, and present a formalization of binary random access lists, using CFML.
A too simple example
As an appetizer, let’s try to prove the specification “with big-O”
for the incr
function, of the first
blog post:
Lemma incr_spec :
SpecO1 (fun F =>
Spec incr r |R>> forall (i: int),
R (\$ F tt \* r ~~> i) (# r ~~> (i+1))).
The goal starts with SpecO1
, which is a specialized form
of SpecO
for \(O(1)\) cost
functions. Just like we used the xcf
tactic for goals
starting with Spec
, we use here the xcfO
tactic. It takes an argument, though: the expression of the cost
function. Note that the domain of a \(O(1)\) cost function is unit
:
its value does not depend of any parameter of the program.
Proof.
xcfO (fun (_:unit) => 1).
Which turns our goal into:
============================
Spec incr r |R>> forall (i: int),
R (\$ 1 \* r ~~> i) (# r ~~> (i+1)).
Oh well. This example is quite trivial, so the xcfO
tactic automatically proved all the \(O\)-related subgoals, giving us the same
spec as before – with a precise credit count.
A more interesting case study: binary random access lists
The OCaml implementation of binary random access lists I presented earlier has been proved correct in CFML, with big-O bounds, and is a more interesting example. In the rest of this post, I’ll present some key points of the proof, and explain how they use the new definitions, lemmas and tactics of our extension of CFML.
The complete Coq script (quite raw at the moment) for the proof can be found here.
Invariants of the structure
As mentioned in the previous post, the OCaml implementation carries a number of implicit invariants: in the Coq formalization, we are going to express explicitly these invariants.
Knowing that a random access list structure satisfies these invariants is key for the complexity proof: it gives us information about its size, and the size of its subtrees. Then, to know that our structures actually satisfy these invariants, we need to prove functional correctness of the OCaml code, i.e. prove that the functions do not break the invariants of the structure.
Consequently, our Coq proof is twofold: it proves both functional correctness, and algorithmic complexity.
Predicates
CFML automatically generates the Coq counterpart of the OCaml
datatypes, tree a
and rlist a
. We start the
proof by defining three predicates btree
, inv
and Rlist
, that make explicit the invariants of the
structure.
First, a btree
predicate. btree n t L
means
that the t
is a complete (binary) tree of depth
n
which contains the sequence of elements in
L
.
Inductive btree : int -> tree a -> list a -> Prop :=
| btree_nil : forall x,
btree 0 (Leaf x) (x::nil)
| btree_cons : forall p p' n t1 t2 L1 L2 L',
btree p t1 L1 ->
btree p t2 L2 ->
p' =' p+1 ->
n =' 2^p' ->
L' =' L1 ++ L2 ->
btree p' (Node n t1 t2) L'.
Then, an inv
predicate: the invariant for the whole
structure. inv p ts L
means that ts
is a
rlist
of complete trees of increasing depth, starting with
depth p
. L
is the sequence of elements
represented by ts
. ts
being a well-formed
binary random access list corresponds to the case where p
is equal to 0
. It is useful to consider the cases where
p
is non-zero, though: reasoning by induction on
ts
will lead to such cases.
Inductive inv : int -> rlist a -> list a -> Prop :=
| inv_nil : forall p,
p >= 0 ->
inv p nil nil
| inv_cons : forall p (t: tree a) ts d L L' T,
inv (p+1) ts L ->
L' <> nil ->
p >= 0 ->
(match d with
| Zero => L = L'
| One t => btree p t T /\ L' = T ++ L
end) ->
inv p (d :: ts) L'.
Finally, the Rlist
predicate corresponds to the
p = 0
case: it describes a complete well-formed binary
random access list.
Definition Rlist (s: rlist a) (L: list a) := inv 0 s L.
Bounds
Given structures verifying these invariants, we can deduce additional properties, in particular:
Lemma length_correct : forall t p L,
btree p t L -> length L = 2^p.
Lemma ts_bound_log : forall ts p L,
inv p ts L -> length ts <= Z.log2 (2 * (length L) + 1).
These lemmas will be key for proving our \(log\) complexity bounds, and constitute in fact our only mathematical analysis for this library.
cons_tree
: a first
proof
Let us jump directly to the proof of the (internal)
cons_tree
function.
let rec cons_tree (t: 'a tree) = function
| [] -> [One t]
| Zero :: ts -> One t :: ts
| One t' :: ts -> Zero :: cons_tree (link t t') ts
and link t1 t2 = Node (size t1 + size t2, t1, t2)
cons_tree t ts
adds a new tree t
to the
rlist
ts
. It may recursively walk through the
list, calling link
(the process is very similar to
incrementing an integer represented as a list of bits).
As link
runs in constant time, cons_tree
performs \(O(|ts|)\) operations.
Moreover, we showed earlier that \(|ts| =
O(log(|L|))\) where \(L\) is the
list of elements contained in ts
. Therefore,
cons_tree
performs “in \(O(log(n))\)” (we want to eventually express
the complexities depending on the number of elements in the structure -
here “\(n\)”).
Our formal proof follows this two-step informal reasoning: first we
prove a \(O(|ts|)\) complexity,
reasoning by induction on ts
to follow the flow of the
OCaml program; then we use our ts_bound_log
lemma to deduce
a logarithmic bound depending on the number of elements stored in
ts
.
First step: an auxiliary spec
We therefore prove an auxiliary specification, as our first step. Let’s walk through the proof.
Lemma cons_tree_spec_aux :
SpecO (fun n => n) (fun F =>
Spec cons_tree (t: tree a) (ts: rlist a) |R>>
forall p T L, btree p t T -> inv p ts L ->
R (\$ F (length ts)) (fun ts' => \[inv p ts' (T++L)])).
To prove a SpecO
goal, one must start by providing an
explicit cost function. In this case however, we do not provide one
right away:
As
cons_tree
calls thelink
function, its cost function depends onlink
’s one: we need to unpacklink
’s spec in order to access its (abstract) cost function;The domain of our cost function (and our bounding function
fun n => n
) isZ
. However, we are only interested in non negative values, as islength ts
. We can apply a lemma allowing us to prove our spec for values greater that some bound, with a different cost function that equals ours on this subdomain (and is basically equal to zero elsewhere).
Proof.
destruct link_spec as (link_cost & link_cost_nonneg & ? & ?).
applys @SpecO_of_SpecO_after 0.
specialize (link_cost_nonneg tt). (* Help the automated tactics. *)
The cost function can be now provided, using xcfO
. As it
is now, in order to “guess” the exact expression of the cost function,
the usual method is to look at the OCaml program, and remember that a
function consumes a credit each time it is called. Here, we give the
exact number of credits needed, but we could give more, as long as the
asymptotic bound still holds.
xcfO (fun n => 1 + (1 + (link_cost tt)) * n).
Our cost function is still relatively simple, so the additional goals
(monotonicity, domination, ….) are automatically proven by
xcfO
.
The last quirk is due to SpecO_of_SpecO_after
. The goal
uses an abstract cost function F'
, and we are given a proof
that `forall x, 0 <= x -> F’ x = 1 + (1 + (link_cost tt)) * x$. In
practice, we just add this proof to our context, and rewrite it when
needed.
intros F' FeqF'.
The rest of the proof uses standard CFML tactics, plus:
xpay
when presented to aPay; ...
goal;csimpl
when presented to a heap implication involving credits: turns the goal into an (in)equality between the quantities of credits.
xinduction (fun (t:tree a) (ts:rlist a) => LibList.length ts).
xcf. intros ? ts. introv IH Rt Rts. rewrites~ FeqF'.
inverts Rts.
- xpay. csimpl. xgo; hsimpl; constructors~.
- { xpay. csimpl. simpl_nonneg~.
xmatch.
- xret; hsimpl; constructors~; subst; splits~.
- unpack; subst. xapps~.
{ csimpl. rew_length. math_nia. }
intros. xapps~.
{ rewrites~ FeqF'. csimpl; rew_length; math_nia. }
intros. xret.
{ hsimpl. constructors~. rew_list~. } }
Qed.
Second step: the main specification
The main specification can then use a cost function in \(O(log(|L|))\), \(L\) being the list of elements in the structure.
Lemma cons_tree_spec :
SpecO Z.log2 (fun F =>
Spec cons_tree (t: tree a) (ts: rlist a) |R>>
forall p T L, btree p t T -> inv p ts L ->
R (\$ F (length L)) (fun ts' => \[inv p ts' (T++L)])).
The proof is simple: we first reuse the cost function of the previous
lemma cons_tree_spec_aux
, feeding it with a sufficient
number of credits, as indicated by the ts_bound_log
lemma
(“\(|ts| \leq log(2 \times |L| +
1)\)”).
Proof.
destruct cons_tree_spec_aux
as (cons_tree_cost & cost_pos & cost_mon & cost_dom & cons_tree_spec).
xcfO (fun n => cons_tree_cost (Z.log2 (2 * n + 1))).
This time, we have to prove some additional goals by hand, produced
by xcfO
.
Monotonicity
- applys~ @monotonic_comp. monotonic_Z_auto~.
We first apply
monotonic_comp
: our cost function is monotonic as composition of two monotonic functions.applys~
includes a bit of automation, so the fact thatcons_tree_cost
(present in the context) is automatically used. Remains to prove thatfun n => Z.log2 (2 * n + 1)
is monotonic: themonotonic_Z_auto~
tactic is able to prove it automatically (and more generally solves monotonicity goals for most of simple functions composed of+
,*
,Z.log
andZ.pow
).Domination
- applys @idominated_transitive. applys~ @idominated_comp cost_dom. monotonic_Z_auto. monotonic_Z_auto~. simpl. idominated_Z_auto~.
Our initial goal is
idominated _ _ (fun n => cons_tree_cost (Z.log2 (2 * n + 1))) Z.log2
. We cannot directly apply a composition lemma; however we know thatcons_tree_cost
is a \(O(n)\): we first invoke transitivity ofidominated
, then apply a composition lemma.The remaining goals are proved automatically, either by
monotonic_Z_auto
which handles simple monotonicity goals, oridominated_Z_auto
which handles simpleidominated
goals (here,idominated_Z_auto~
provesidominated _ _ (fun n => Z.log2 (2 * n + 1) Z.log2
).The specification
- xweaken~. do 4 intro. intro spec. intros. xgc; [xapply~ spec |]; csimpl~. { apply~ cost_mon. apply~ ts_bound_log. } Qed.
The specification itself is proved by weakening of
cons_tree_spec_aux
, plus the following arguments:
ts_bound_log
: \(|ts| \leq log(2 \times |L| + 1)\)cons_tree_cost
is monotonic: needed to applyts_bound_log
inequality undercons_tree_cost
.
lookup
:
how to deal with multiple parameters
As explained in the first
post, things can get tricky when the cost function depends on
multiple parameters. More precisely, the user has to specify which
notion of “going to infinity” she’s intending, by choosing the right
filter for the domain (e.g. Z*Z
for a cost function with
two parameters).
Proving a specification for the lookup
function involves
precisely this kind of difficulty.
let rec lookup i = function
| [] -> raise (Invalid_argument "lookup")
| Zero :: ts -> lookup i ts
| One t :: ts ->
if i < size t
then lookup_tree i t
else lookup (i - size t) ts
We prove a lookup i ts
specification by induction on
ts
. During the induction we have two parameters: \(|ts|\), and the depth \(p\) of ts
’s first tree
(matching an inv p ts L
invariant).
The respective status of these two parameter differs, though. Once
the proof by induction done, we’ll want, as for cons_tree
,
express the cost function depending on \(|L|\). \(|ts|\) will tend to infinity with \(|L|\), but \(p\) will be fixed to \(0\), as lookup
is only
supposed to be called on well-formed random access lists from the user
point of view.
When proving cons_tree
, we did not have to provide any
filter: the standard ilter for Z
was inferred. Here, we
proceed as follows:
We establish on paper a first asymptotic bound of \(O(p + |ts|)\);
We provide a filter
towards_infinity_xZ p
on (a subset type of)Z*Z
, which makes its second component tend to infinity, while the first is fixed top
(p
is a parameter of the filter);We prove an intermediate specification using this filter, for any fixed
p
. Note that sadly, we cannot useSpecO
to state our specification: to get a provable and useful specification, we need to quantify overp
“in the middle ofSpecO
”.The result is a quite ugly intermediate specification, unfortunately; the result of unfolding
SpecO
and quantifying overp
in the middle.
Lemma lookup_spec_ind :
exists (F: Z * Z -> Z),
(forall m n, 0 <= m -> 0 <= n -> 0 <= F (m, n)) /\
(forall (p: Z),
0 <= p ->
monotonic (fixed_fst_le le p) le (fun p => F (proj1_sig p)) /\
idominated (FO := fo_towards_infinity_xZ p) _ _
(fun p => F (proj1_sig p))
(fun p => let '(m, n) := proj1_sig p in m + n)) /\
Spec lookup (i:int) (ts: rlist a) |R>>
forall p L, inv p ts L -> ZInbound i L ->
R (\$F (p, length ts)) (fun x => \[ZNth i L x]).
The proof has the same spirit as these shown before - basically
applying monotonic_*
and idominated_*
lemmas -
just more involved.
- Finally, we can write a nicer top-level specification to wrap it:
Lemma lookup_spec :
SpecO Z.log2 (fun F =>
Spec lookup (i: int) (ts: rlist a) |R>>
forall L, Rlist ts L -> ZInbound i L ->
R (\$F (length L)) (fun x => \[ZNth i L x])).
After instantiating the cost function of our intermediate spec as
lookup_spec_cost
, and projecting the monotonicity and
domination hypothesis with \(p = 0\),
we provide the following cost function:
xcfO (fun n => lookup_spec_cost (0, Z.log2 (2 * n + 1))).
As fun (m,n) => lookup_spec_cost (m, n)
is a \(O(m + n)\), by composition, our cost
function is indeed a \(O(log(n))\).
Epilogue
The set of lemmas and tactics I just presented is, at the moment, only a unfinished tentative: it definitely has some rough edges, and can be unpleasant to use. However, I think an interesting aspect about it is that, trying to formalize reasoning on big-Os, it shows how much these tend to be informal when performed on paper. Thus, our bet is that the additional complexity is in most part not artificial, but reflects actual subtleties in the proofs.