12 KiB
title | series | description | date | tags | ||
---|---|---|---|---|---|---|
Implementing and Verifying "Static Program Analysis" in Agda, Part 4: The Fixed-Point Algorithm | Static Program Analysis in Agda | In this post, I show how to find the least fixed point of a finite-height lattice | 2024-11-03T17:50:26-08:00 |
|
In the preivous post we looked at lattices of finite height, which are a crucial ingredient to our static analyses. In this post, I will describe the specific algorithm that makes use of these lattices; this algorithm will be at the core of this series.
Lattice-based static analyses tend to operate by iteratively combining facts
from the program into new ones. For instance, when analyzing y = 1 + 2
, we
take the (trivial) facts that the numbers one and two are positive, and combine
them into the knowledge that y
is positive as well. If another line of code
reads x = y + 1
, we then apply our new knowledge of y
to determine the sign
of x
, too. Combining facs in this manner gives us more information, which we
can then continue to apply to learn more about the program.
A static program analyzer, however, is a very practical thing. Although in mathemaitics we may allow ourselves to delve into infinite algorithms, we have no such luxury while trying to, say, compile some code. As a result, after a certain point, we need to stop our iterative (re)combination of facts. In an ideal world, that point would be when we know we have found out everything we could about the program. A corollary to that would be that this point must be guaranteed to eventually occur, lest we keep looking for it indenfinitely.
The fixed-point algorithm does this for us. If we describe our analysis as a monotonic function over a finite-height lattice, this algorithm gives us a surefire way to find out facts about our program that constitute "complete" information that can't be re-inspected to find out more. The algorithm is guaranteed to terminate, which means that we will not get stuck in an infinite loop.
The Algorithm
Take a lattice L
and a monotonic function f
. We've
[talked about monotonicity before]({{< relref "01_spa_agda_lattices#define-monotonicity" >}}),
but it's easy to re-state. Specifically, a function is monotonic if the following
rule holds true:
{{< latex >}} \textbf{if}\ a \le b\ \textbf{then}\ f(a) \le f(b) {{< /latex >}}
Recall that the less-than relation on lattices in our case
[encodes specificity]({{< relref "01_spa_agda_lattices#specificity" >}}).
In particular, if elements of our lattice describe our program, than smaller
elements should provide more precise descriptions (where "x
is potive"
is more precise than "x
has any sign", for example). Viewed through this
lens, monotonicity means that more specific inputs produce more specific outputs.
That seems reasonable.
Now, let's start with the least element of our lattice, denoted \bot
.
A lattice of finite height is guaranteed to have such an element. If it didn't,
we could always extend chains by tacking on a smaller element to their bottom,
and then the lattice wouldn't have a finite height anymore.
{#start-least}
Now, apply f
to \bot
to get f(\bot)
. Since \bot
is the least
element, it must be true that \bot \le f(\bot)
. Now, if it's "less than or equal",
is it "less than", or is "equal")? If it's the latter, we have \bot = f(\bot)
.
This means we've found a fixed point: given our input \bot
our analysis (f)
produced no new information, and we're done. Otherwise, we are not done, but we
know that \bot < f(\bot)
, which will be helpful shortly.
Continuing the "less than" case, we can apply f
again, this time to f(\bot)
.
This gives us f(f(\bot))
. Since f
is monotonic and \bot \le f(\bot)
, we know
also that f(\bot) \le f(f(\bot))
. Again, ask "which is it?", and as before, if
f(\bot) = f(f(\bot))
, we have found a fixed point. Otherwise, we know that
f(\bot) < f(f(\bot))
.
We can keep doing this. Notice that with each step, we are either done (having found a fixed point) or we have a new inequality in our hands. We can arrange the ones we've seen so far into a chain:
{{< latex >}} \bot < f(\bot) < f(f(\bot)) < ... {{< /latex >}}
Each time we fail to find a fixed point, we add one element to our chain, growing
it. But if our lattice L
has a finite height, that means eventually this
process will have to stop; the chain can't grow forever. Eventually, we will
have to find a value such that v = f(v)
. Thus, our algorithm is guaranteed
to terminate, and give a fixed point.
I implemented the iterative process of applying f
using a recursive function.
Agda has a termination checker, to which the logic above --- which proves that
iteration will eventually finish --- is not at all obvious. The trick to
getting it to work was to use a notion of "gas": an always-decreasing value
that serves as one of the functions' arguments. Since the value is always decreasing
in size, the termination checker is satisfied.
This works by observing that we already have a rough idea of the maximum number of times our function will recurse; that would be the height of the lattice. After that, we would be building an impossibly long chain. So, we'll give the function a "budget" of that many iterations, plus one more. Since the chain increases once each time the budget shrinks (indicating recursion), running out of our "gas" will mean that we built an impossibly long chain --- it will provably never happen.
In all, the recursive function is as follows:
{{< codelines "Agda" "agda-spa/Fixedpoint.agda" 53 64 >}}
The first case handles running out of gas, arguing by bottom-elimination (contradiction).
The second case follows the algorithm I've described pretty closely; it
applies f
to an existing value, checks if the result is equal (equivalent)
to the original, and if it isn't, it grows the existing chain of elements
and invokes the step function rescurisvely with the grown chain and less gas.
The recursive function implements a single "step" of the process (applying f
,
comparing for equality, returning the fixed point if one was found). All that's
left is to kick off the process using \bot
. This is what fix
does:
{{< codelines "Agda" "agda-spa/Fixedpoint.agda" 66 67 >}}
This functions is responsible for providing gas to doStep
; as I mentioned above,
it provides just a bit more gas than the maximum-length chain, which means that
if the gas is exhausted, we've certainly arrived at a contradiction. It also
provides an initial chain onto which doStep
will keep tacking on new inequalities
as it finds them. Since we haven't found any yet, this is the single-element
chain of \bot
. The last thing is does is set up the recursion invariant
(that the sum of the gas and the chain length is constant), and provides
a proof that \bot \le f(\bot)
. This function always returns a fixed point.
Least Fixed Point
Functions can have many fixed points. Take the identity function that simply
returns its argument unchanged; this function has a fixed point for every
element in its domain, since, for example, \text{id}(1) = 1
, \text{id}(2) = 2
, etc.
The fixed point found by our algorithm above is somewhat special among the
possible fixed points of f
: it is the least fixed point of the function.
Call our fixed point a
; if there's another point b
such that b=f(b)
,
then the fixed point we found must be less than or equal to b
(that is,
a \le b
). This is important given our interpretation of "less than" as "more specific":
the fixedpoint algorithm produces the most specific possible information about
our program given the rules of our analysis.
The proof is simple; suppose that it took k
iterations of calling (f)
to arrive at our fixed point. This gives us:
{{< latex >}} a = \underbrace{f(f(...(f(}_{k\ \text{times}}\bot)))) = f^k(\bot) {{< /latex >}}
Now, take our other fixed point b
. Since \bot
is the least element of
the lattice, we have \bot \le b
.
{{< latex >}} \begin{array}{ccccccccr} & & \bot & \le & & & b & \quad \implies & \text{(monotonicity of}\ f \text{)}\ & & f(\bot) & \le & f(b) & = & b & \quad \implies & \text{(} b\ \text{is a fixed point, monotonicity of}\ f \text{)}\ & & f^2(\bot) & \le & f(b) & = & b & \quad \implies & \text{(} b\ \text{is a fixed point, monotonicity of}\ f \text{)}\ \ & & \vdots & & \vdots & & \vdots & & \ \ a & = & f^k(\bot) & \le & f(b) & = & b & \end{array} {{< /latex >}}
Because of the monotonicity of f
, each time we apply it, it preserves the
less-than relationship that started with \bot \le b
. Doing that k
times,
we verify that a
is our least fixed point.
To convince Agda of this proof, we once again get in an argument with the termination
checker, which ends the same way it did last time: with us using the notion of 'gas'
to ensure that the repeated application of f
eventually ends. Since we're
interested in verifying that doStep
producdes the least fixed point, we formulate
the proof in terms of doStep
applied to various arguments.
{{< codelines "Agda" "agda-spa/Fixedpoint.agda" 76 84 >}}
As with doStep
, this function takes as arguments the amount of gas g
and
a partially-built chain c
, which gets appended to for each failed equality
comparison. In addition, however, this function takes another arbitrary fixed
point b
, which is greater than the current input to doStep
(which is
a value (f^i(\bot)) for some i
). It then proves that when doStep
terminates
(which will be with a value in the form f^k(\bot)
), this value will still
be smaller than b
. Since it is a proof about doStep
, stepPreservesLess
proceeds by the same case analysis as its subject, and has a very similar (albeit
simpler) structure. In short, though, it encodes the relatively informal proof
I gave above.
Just like with doStep
, I define a helper function for stepPreservesLess
that
kicks off its recursive invocations.
{{< codelines "Agda" "agda-spa/Fixedpoint.agda" 86 87 >}}
Above, aᶠ
is the output of fix
:
{{< codelines "Agda" "agda-spa/Fixedpoint.agda" 69 70 >}}
What is a Program?
With the fixed point algorithm in hand, we have all the tools we need to define static program analyses:
- We've created a collection of "lattice builders", which allow us to combine various lattice building blocks into more complicated structures; these structures are advanced enough to represent the information about our programs.
- We've figured out a way (our fixed point algorithm) to repeatedly apply an inference function to our programs and eventually produce results. This algorithm requires some additional properties from our latttices.
- We've proven that our lattice builders create lattices with these properties, making it possible to use them to construct functions fit for our fixed point algorithm.
All that's left is to start defining monotonic functions over lattices! Except, what are we analyzing? We've focused a fair bit on the theory of lattices, but we haven't yet defined even a tiny piece of the language that our programs will be analyzing. We will start with programs like this:
x = 42
y = 1
if y {
x = -1;
} else {
x = -2;
}
We will need to model these programs in Agda by describing them as trees (Abstract Syntax Trees, to be precise). We will also need to specify how to evaluate these programs (provide the semantics of our language). We will use big-step (also known as "natural") operational semantics to do so; here's an example rule:
{{< latex >}} \frac{\rho_1, e \Downarrow z \quad \neg (z = 0) \quad \rho_1,s_1 \Downarrow \rho_2} {\rho_1, \textbf{if}\ e\ \textbf{then}\ s_1\ \textbf{else}\ s_2\ \Downarrow\ \rho_2} {{< /latex >}}
The above reads:
If the condition of an
if
-else
statement evaluates to a nonzero value, then to evaluate the statement, you evaluate itsthen
branch.
In the next post, we'll talk more about how these rules work, and define the remainder of them to give our language life. See you then!