To prove a specification lemma, such as
(Sect. 4.3) or
(Sect. 4.4), one must construct a
record. By definition of
(Fig. 5), this means that one must exhibit a concrete cost function \( cost \) and prove a number of properties of this function, including the fact that, when supplied with \(\${( cost \;\ldots )}\), the code runs correctly (
) and the fact that \( cost \) is dominated by the desired asymptotic bound
.
Thus, the very first step in a naïve proof attempt would be to guess an appropriate cost function for the code at hand. However, such an approach would be painful, error-prone, and brittle. It seems much preferable, if possible, to enlist the machine’s help in synthesizing a cost function at the same time as we step through the code—which we have to do anyway, as we must build a Separation Logic proof of the correctness of this code.
To illustrate the problem, consider the recursive function
, whose integer argument
is expected to satisfy \(n \ge 0\). For the sake of this example,
calls an auxiliary function
, which we assume runs in constant time.
Suppose we wish to establish that
runs in linear time. As argued at the beginning of the paper (Sect. 2, Fig. 2), it does not make sense to attempt a proof by induction on n that “
runs in time O(n)”. Instead, in a formal framework, we must exhibit a concrete cost function \( cost \) such that \( cost (n)\) credits justify the call
and \( cost \) grows linearly, that is, \( cost \,\preceq _{\mathbb {Z}}\, \lambda n.\,n\).
Let us assume that a specification lemma
for the function
has been established already, so the number of credits required by a call to
is
. In the following, we write \(G\) as a shorthand for this constant.
Because this example is very simple, it is reasonably easy to manually come up with an appropriate cost function for
. One valid guess is \(\lambda n. \; 1 + \varSigma _{i=2}^n (1+G)\). Another valid guess, obtained via a simplification step, is \(\lambda n. \; 1 + (1+G)(n - 1)^+\). Another witness, obtained via an approximation step, is \(\lambda n. \; 1 + (1+G)n^+\). As the reader can see, there is in fact a spectrum of valid witnesses, ranging from verbose, low-level to compact, high-level mathematical expressions. Also, it should be evident that, as the code grows larger, it can become very difficult to guess a valid concrete cost function.
This gives rise to two questions. Among the valid cost functions, which one is preferable? Which ones can be systematically constructed, without guessing?
Among the valid cost functions, there is a tradeoff. At one extreme, a low-level cost function has exactly the same syntactic structure as the code, so it is easy to prove that it is an upper bound for the actual cost of the code, but a lot of work may be involved in proving that it is dominated by the desired asymptotic bound. At the other extreme, a high-level cost function can be essentially identical to the desired asymptotic bound, up to explicit multiplicative and additive constants, so the desired domination assertion is trivial, but a lot of accounting work may be involved in proving that this function represents enough credits to execute the code. Thus, by choosing a cost function, we shift some of the burden of the proof from one subgoal to another. From this point of view, no cost function seems inherently preferable to another.
From the point of view of systematic construction, however, the answer is more clear-cut. It seems fairly clear that it is possible to systematically build a cost function whose syntactic structure is the same as the syntactic structure of the code. This idea goes at least as far back as Wegbreit’s work [26]. Coming up with a compact, high-level expression of the cost, on the other hand, seems to require human insight.
To provide as much machine assistance as possible, our system mechanically synthesizes a low-level cost expression for a piece of OCaml code. This is done transparently, at the same time as the user constructs a proof of the code in Separation Logic. Furthermore, we take advantage of the fact that we are using an interactive proof assistant: we allow the user to guide the synthesis process. For instance, the user controls how a local variable should be eliminated, how the cost of a conditional construct should be approximated (i.e., by a conditional or by a maximum), and how recurrence equations should be solved. In the following, we present this semi-interactive synthesis process. We first consider straight-line (nonrecursive) code (Sect. 5.1), then recursive functions (Sect. 5.2).
5.1 Synthesizing Cost Expressions for Straight-Line Code
The CFML library provides the user with interactive tactics that implement the reasoning rules of Separation Logic. We set things up in such a way that, as these rules are applied, a cost expression is automatically synthesized.
To this end, we use specialized variants of the reasoning rules, whose premises and conclusions take the form \(\{\$\,{n} \star H\}\,(e)\,\{Q\}\). Furthermore, to simplify the nonnegativeness side conditions that must be proved while reasoning, we make all cost expressions obviously nonnegative by wrapping them in \(\max (0, -)\). Recall that \(c^+\) stands for \(\max (0,c)\), where \(c\in \mathbb {Z}\). Our reasoning rules work with triples of the form \(\{\$\,{c^+} \star H\}\,(e)\,\{Q\}\). They are shown in Fig. 6.
Because we wish to synthesize a cost expression, our Coq tactics maintain the following invariant: whenever the goal is \(\{\$\,{c^+} \star H\}\,(e)\,\{Q\}\), the cost c is uninstantiated, that is, it is represented in Coq by a metavariable, a placeholder. This metavariable is instantiated when the goal is proved by applying one of the reasoning rules. Such an application produces new subgoals, whose preconditions contain new metavariables. As this process is repeated, a cost expression is incrementally constructed.
The rule
is a special case of the consequence rule of Separation Logic. It is typically used once at the root of the proof: even though the initial goal \(\{\$\,{c_1} \star H\}\,(e)\,\{Q\}\) may not satisfy our invariant, because it lacks a \(-^+\) wrapper and because \(c_1\) is not necessarily a metavariable,
gives rise to a subgoal \(\{\$\,{c_2^+} \star H\}\,(e)\,\{Q\}\) that satisfies it. Indeed, when this rule is applied, a fresh metavariable \(c_2\) is generated.
can also be explicitly applied by the user when desired. It is typically used just before leaving the scope of a local variable x to approximate a cost expression \(c_2^+\) that depends on x with an expression \(c_1\) that does not refer to x.
The
rule is a special case of the
rule. It states that the cost of a sequence is the sum of the costs of its subexpressions. When this rule is applied to a goal of the form \(\{\$\,{c^+} \star H\}\,(e)\,\{Q\}\), where c is a metavariable, two new metavariables \(c_1\) and \(c_2\) are introduced, and c is instantiated with \(c_1^+ + c_2^+\).
The
rule is similar to
, but involves an additional subtlety: the cost \(c_2\) must not refer to the local variable x. Naturally, Coq enforces this condition: any attempt to instantiate the metavariable \(c_2\) with an expression where x occurs fails. In such a situation, it is up to the user to use
so as to avoid this dependency. The example of
(Sect. 4.5) illustrates this issue.
The
rule handles values, which in our model have zero cost. The symbol \(\mathrel {\Vdash }\) denotes entailment between Separation Logic assertions.
The
rule states that the cost of an OCaml conditional expression is a mathematical conditional expression. Although this may seem obvious, one subtlety lurks here. Using
, the cost expression \( if \;b\; then \;c_1\; else \;c_2\) can be approximated by \(\max (c_1,c_2)\). Such an approximation can be beneficial, as it leads to a simpler cost expression, or harmful, as it causes a loss of information. In particular, when carried out in the body of a recursive function, it can lead to an unsatisfiable recurrence equation. We let the user decide whether this approximation should be performed.
The
rule handles the
instruction, which is inserted by the CFML tool at the beginning of every function and loop body (Sect. 4.1). This instruction costs one credit.
The
rule states that the cost of a
loop is the sum, over all values of the index i, of the cost of the i-th iteration of the body. In practice, it is typically used in conjunction with
, which allows the user to simplify and approximate the iterated sum \(\varSigma _{a\le i<b} \; c(i)^+\). In particular, if the synthesized cost c(i) happens to not depend on i, or can be approximated so as to not depend on i, then this iterated sum can be expressed under the form \(c(b-a)^+\). A variant of the
rule, not shown, covers this common case. There is in principle no need for a primitive treatment of loops, as loops can be encoded in terms of higher-order recursive functions, and our program logic can express the specifications of these combinators. Nevertheless, in practice, primitive support for loops is convenient.
This concludes our exposition of the reasoning rules of Fig. 6. Coming back to the example of the OCaml function
(Sect. 5), under the assumption that the cost of the recursive call
is \(f(n-1)\), we are able, by repeated application of the reasoning rules, to automatically find that the cost of the OCaml expression:
is: \( 1 + if \;n \le 1\; then \;0\; else \;(G+ f(n-1))\). The initial 1 accounts for the implicit
. This may seem obvious, and it is. The point is that this cost expression is automatically constructed: its synthesis adds no overhead to an interactive proof of functional correctness of the function
.
5.2 Synthesizing and Solving Recurrence Equations
There now remains to explain how to deal with recursive functions. Suppose S(f) is the Separation Logic triple that we wish to establish, where f stands for an as-yet-unknown cost function. Following common informal practice, we would like to do this in two steps. First, from the code, derive a “recurrence equation” E(f), which in fact is usually not an equation, but a constraint (or a conjunction of constraints) bearing on f. Second, prove that this recurrence equation admits a solution that is dominated by the desired asymptotic cost function g. This approach can be formally viewed as an application of the following tautology:
$$ \forall E.\;\; (\forall f. E(f) \rightarrow S(f)) \;\rightarrow \; (\exists f.E(f) \wedge f \,\preceq _{}\, g) \;\rightarrow \; (\exists f.S(f) \wedge f \,\preceq _{}\, g) $$
The conclusion \(S(f) \wedge f \,\preceq _{}\, g\) states that the code is correct and has asymptotic cost g. In Coq, applying this tautology gives rise to a new metavariable E, as the recurrence equation is initially unknown, and two subgoals.
During the proof of the first subgoal, \(\forall f. E(f) \rightarrow S(f)\), the cost function f is abstract (universally quantified), but we are allowed to assume E(f), where E is initially a metavariable. So, should the need arise to prove that f satisfies a certain property, this can be done just by instantiating E. In the example of the OCaml function
(Sect. 5), we prove S(f) by induction over n, under the hypothesis \(n \ge 0\). Thus, we assume that the cost of the recursive call
is \(f(n-1)\), and must prove that the cost of
is f(n). We synthesize the cost of
as explained earlier (Sect. 5.1) and find that this cost is \(1 + if \;n \le 1\; then \;0\; else \;(G+ f(n-1))\). We apply
and find that our proof is complete, provided we are able to prove the following inequation:
$$ 1 + if \;n \le 1\; then \;0\; else \;(G+ f(n-1)) \;\le \; f(n) $$
We achieve that simply by instantiating E as follows:
$$ E := \lambda f.\; \forall n.\; n \ge 0 \;\rightarrow \; 1 + if \;n \le 1\; then \;0\; else \;(G+ f(n-1)) \;\le \; f(n) $$
This is our “recurrence equation”—in fact, a universally quantified, conditional inequation. We are done with the first subgoal.
We then turn to the second subgoal, \(\exists f.E(f) \wedge f \,\preceq _{}\, g\). The metavariable E is now instantiated. The goal is to solve the recurrence and analyze the asymptotic growth of the chosen solution. There are at least three approaches to solving such a recurrence.
First, one can guess a closed form that satisfies the recurrence. For example, the function \(f := \lambda n. \; 1 + (1+G)n^+\) satisfies E(f) above. But, as argued earlier, guessing is in general difficult and tedious.
Second, one can invoke Cormen et al.’s Master Theorem [12] or the more general Akra-Bazzi theorem [1, 21]. Unfortunately, at present, these theorems are not available in Coq, although an Isabelle/HOL formalization exists [13].
The last approach is Cormen et al.’s substitution method [12, Sect. 4]. The idea is to guess a parameterized shape for the solution; substitute this shape into the goal; gather a set of constraints that the parameters must satisfy for the goal to hold; finally, show that these constraints are indeed satisfiable. In the above example, as we expect the code to have linear time complexity, we propose that the solution f should have the shape \(\lambda n.(an^+ + b)\), where a and b are parameters, about which we wish to gradually accumulate a set of constraints. From a formal point of view, this amounts to applying the following tautology:
$$ \forall P.\; \forall C.\quad (\forall ab.\; C(a,b) \rightarrow P(\lambda n.(an^+ + b))) \;\rightarrow \; (\exists ab.\; C(a,b)) \;\rightarrow \; \exists f. P(f) $$
This application again yields two subgoals. During the proof of the first subgoal, C is a metavariable and can be instantiated as desired (possibly in several steps), allowing us to gather a conjunction of constraints bearing on a and b. During the proof of the second subgoal, C is fixed and we must check that it is satisfiable. In our example, the first subgoal is:
$$ E(\lambda n.(an^+ + b)) \quad \wedge \quad \lambda n.(an^+ + b) \,\preceq _{\mathbb {Z}}\, \lambda n.n $$
The second conjunct is trivial. The first conjunct simplifies to:
$$ \forall n.\quad n \ge 0 \;\rightarrow \; 1 + if \;n \le 1\; then \;0\; else \;(G+ a(n-1)^++b) \;\le \; an^+ + b $$
By distinguishing the cases \(n = 0\), \(n = 1\), and \(n > 1\), we find that this property holds provided we have \(1 \le b\) and \(1 \le a + b\) and \(1 + G \le a\). Thus, we prove this subgoal by instantiating C with \(\lambda (a,b).(1 \le b \wedge 1 \le a + b \wedge 1 + G \le a)\).
There remains to check the second subgoal, that is, \(\exists ab.C(a,b)\). This is easy; we pick, for instance, \(a := 1 + G\) and \(b := 1\). This concludes our use of Cormen et al.’s substitution method.
In summary, by exploiting Coq’s metavariables, we are able to set up our proofs in a style that closely follows the traditional paper style. During a first phase, as we analyze the code, we synthesize a cost function and (if the code is recursive) a recurrence equation. During a second phase, we guess the shape of a solution, and, as we analyze the recurrence equation, we synthesize a constraint on the parameters of the shape. During a last phase, we check that this constraint is satisfiable. In practice, instead of explicitly building and applying tautologies as above, we use the first author’s procrastination library [16], which provides facilities for introducing new parameters, gradually gathering constraints on these parameters, and eventually checking that these constraints are satisfiable.