In the previous post, we put together a number of powerful pieces of machinery to construct a sign analysis. However, we still haven’t verified that this analysis produces correct results. For the most part, we already have the tools required to demonstrate correctness; the most important one is the validity of our CFGs relative to the semantics of the little language.

High-Level Algorithm

We’ll keep working with the sign lattice as an example, keeping in mind how what we do generalizes to a any lattice LL describing a variable’s state. The general shape of our argument will be as follows, where I’ve underlined and numbered assumptions or aspects that we have yet to provide.

  1. Our fixed-point analysis from the previous section gave us a result rr that satisfies the following equation:

    r=update(join(r)) r = \text{update}(\text{join}(r))

    Above join\text{join} applies the predecessor-combining function from the previous post to each state (corresponding to joinAll in Agda) and update\text{update} performs one round of abstract interpretation.

  2. Because of the correspondence of our semantics and CFGs, each program evaluation in the form ρ,sρ\rho, s \Rightarrow \rho' corresponds to a path through the Control Flow Graph. Along the path, each node contains simple statements, which correspond to intermediate steps in evaluating the program. These will also be in the form ρ1,bρ2\rho_1, b \Rightarrow \rho_2.

  3. We will proceed iteratively, stepping through the trace one basic block at a time. At each node in the graph:

    • We will assume that the beginning state (the variables in ρ1\rho_1) are correctly described 1 by one of the predecessors of the current node. Since joining represents "or" 2, that is the same as saying that join(r)\text{join}(r) contains an accurate description of ρ1\rho_1.

    • Because the abstract interpretation function preserves accurate descriptions 3, if join(r)\text{join}(r) contains an accurate description ρ1\rho_1, then applying our abstract interpretation function via update\text{update} should result in a map that contains an accurate-described ρ2\rho_2. In other words, update(join(r))\text{update}(\text{join}(r)) describes ρ2\rho_2 at the current block. By the equation above 4, that’s the same as saying rr describes ρ2\rho_2 at the current block.

    • Since the trace is a path through a graph, there must be an edge from the current basic block to the next. This means that the current basic block is a predecessor of the next one. From the previous point, we know that ρ2\rho_2 is accurately described by this predecessor, fulfilling our earlier assumption and allowing us to continue iteration.

So, what are the missing pieces?

  1. We need to define what it means for a lattice (like our sign lattice) to “correctly describe” what happens when evaluating a program for real. For example, the ++ in sign analysis describes values that are bigger than zero, and a map like {x:+} states that x can only take on positive values.
  2. We’ve seen before the ()(\sqcup) operator models disjunction (“A or B”), but that was only an informal observation; we’ll need to specify it preceisely.
  3. Each analysis provides an abstract interpretation eval function. However, until now, nothing has formally constrained this function; we could return ++ in every case, even though that would not be accurate. We will need, for each analysis, a proof that its eval preserves accurate descriptions.
  4. The equalities between our lattice elements are actually equivalences, which helps us use simpler representations of data structures. Thus, even in statements of the fixed point algorithm, our final result is a value aa such that af(a)a \approx f(a). We need to prove that our notion of equivalent lattice elements plays nicely with correctness.

Let’s start with the first bullet point.

A Formal Definition of Correctness

When a variable is mapped to a particular sign (like { "x": + }), what that really says is that the value of x is greater than zero. Recalling from the post about our language’s semantics that we use the symbol ρ\rho to represent mappings of variables to their values, we might write this claim as:

ρ(x)>0 \rho(\texttt{x}) > 0

This is a good start, but it’s a little awkward defining the meaning of “plus” by referring to the context in which it’s used (the { "x": ... } portion of the expression above). Instead, let’s associate with each sign (like ++) a predicate: a function that takes a value, and makes a claim about that value (“this is positive”):

+ v=v>0 \llbracket + \rrbracket\ v = v > 0

The notation above is a little weird unless you, like me, have a background in Programming Language Theory (❤️). This comes from denotational semantics; generally, one writes:

thing=the meaning of the thing \llbracket \text{thing} \rrbracket = \text{the meaning of the thing}

Where \llbracket \cdot \rrbracket is really a function (we call it the semantic function) that maps things to their meaning. Then, the above equation is similar to the more familiar f(x)=x+1f(x) = x+1: function and arguments on the left, definition on the right. When the “meaning of the thing” is itself a function, we could write it explicitly using lambda-notation:

thing=λx. body of the function \llbracket \text{thing} \rrbracket = \lambda x.\ \text{body of the function}

Or, we could use the Haskell style and write the new variable on the left of the equality:

thing x=body of the function \llbracket \text{thing} \rrbracket\ x = \text{body of the function}

That is precisely what I’m doing above with +\llbracket + \rrbracket. With this in mind, we could define the entire semantic function for the sign lattice as follows:

+ v=v > 00 v=v = 0 v=v < 0 v=true v=false \llbracket + \rrbracket\ v = v\ \texttt{>}\ 0 \\ \llbracket 0 \rrbracket\ v = v\ \texttt{=}\ 0 \\ \llbracket - \rrbracket\ v = v\ \texttt{<}\ 0 \\ \llbracket \top \rrbracket\ v = \text{true} \\ \llbracket \bot \rrbracket\ v = \text{false}

In Agda, the integer type already distinguishes between “negative natural” or “positive natural” cases, which made it possible to define the semantic function [note: Reasoning about inequalities is painful, sometimes requiring a number of lemmas to arrive at a result that is intuitively obvious. Coq has a powerful tactic called lia that automatically solves systems of inequalities, and I use it liberally. However, lacking such a tactic in Agda, I would like to avoid inequalities if they are not needed. ]

From Sign.agda, lines 114 through 119
114
115
116
117
118
119
⟦_⟧ᵍ : SignLattice  Value  Set
⟦_⟧ᵍ ⊥ᵍ _ = 
⟦_⟧ᵍ ⊤ᵍ _ = 
⟦_⟧ᵍ [ + ]ᵍ v = Σ  (λ n  v  ↑ᶻ (+_ (suc n)))
⟦_⟧ᵍ [ 0ˢ ]ᵍ v = v  ↑ᶻ (+_ zero)
⟦_⟧ᵍ [ - ]ᵍ v = Σ  (λ n  v  ↑ᶻ -[1+ n ])

Notably,  v\llbracket \top \rrbracket\ v always holds, and  v\llbracket \bot \rrbracket\ v never does. In general, we will always need to define a semantic function for whatever lattice we are choosing for our analysis.

It’s important to remember from the previous post that the sign lattice (or, more generally, our lattice LL) is only a component of the lattice we use to instantiate the analysis. We at least need to define what it means for the VariableSign\text{Variable} \to \text{Sign} portion of that lattice to be correct. This way, we’ll have correctness criteria for each key (CFG node) in the top-level Info\text{Info} lattice. Since a map from variables to their sign characterizes not a single value vv but a whole environment ρ\rho, something like this is a good start:

{x1:s1,...,xn:sn} ρ=s1 ρ(x1) and ... and sn ρ(xn) \llbracket \texttt{\{} x_1: s_1, ..., x_n: s_n \texttt{\}} \rrbracket\ \rho = \llbracket s_1 \rrbracket\ \rho(x_1)\ \text{and}\ ...\ \text{and}\ \llbracket s_n \rrbracket\ \rho(x_n)

As a concrete example, we might get:

{x:+,y:} ρ=ρ(x) > 0 and ρ(y) < 0 \llbracket \texttt{\{} \texttt{x}: +, \texttt{y}: - \texttt{\}} \rrbracket\ \rho = \rho(\texttt{x})\ \texttt{>}\ 0\ \text{and}\ \rho(\texttt{y})\ \texttt{<}\ 0

This is pretty good, but not quite right. For instance, the initial state of the program — before running the analysis — assigns \bot to each element. This is true because our fixed-point algorithm starts with the least element of the lattice. But even for a single-variable map {x: ⊥ }, the semantic function above would give:

{x:} ρ=false \llbracket \texttt{\{} \texttt{x}: \bot \texttt{\}} \rrbracket\ \rho = \text{false}

That’s clearly not right: our initial state should be possible, lest the entire proof be just a convoluted ex falso!

There is another tricky aspect of our analysis, which is primarily defined using the join (\sqcup) operator. Observe the following example:

// initial state: { x: ⊥ }
if b {
  x = 1; // state: { x: + }
} else {
  // state unchanged: { x: ⊥ }
}
// state: { x: + } ⊔ { x: ⊥ } = { x: + }

Notice that in the final state, the sign of x is +, even though when b is false, the variable is never set. In a simple language like ours, without variable declaration points, this is probably the best we could hope for. The crucial observation, though, is that the oddness only comes into play with variables that are not set. In the “initial state” case, none of the variables have been modified; in the else case of the conditional, x was never assigned to. We can thus relax our condition to an if-then: if a variable is in our environment ρ\rho, then the variable-sign lattice’s interpretation accurately describes it.

{x1:s1,...,xn:sn} ρ=if x1ρ then s1 ρ(x1) and...andif xnρ then sn ρ(xn) \begin{array}{ccc} \llbracket \texttt{\{} x_1: s_1, ..., x_n: s_n \texttt{\}} \rrbracket\ \rho & = & & \textbf{if}\ x_1 \in \rho\ \textbf{then}\ \llbracket s_1 \rrbracket\ \rho(x_1)\ \\ & & \text{and} & ... \\ & & \text{and} & \textbf{if}\ x_n \in \rho\ \textbf{then}\ \llbracket s_n \rrbracket\ \rho(x_n) \end{array}

The first “weird” case now results in the following:

{x:} ρ=if xρ then false \llbracket \texttt{\{} \texttt{x}: \bot \texttt{\}} \rrbracket\ \rho = \textbf{if}\ \texttt{x} \in \rho\ \textbf{then}\ \text{false}

Which is just another way of saying:

{x:} ρ=xρ \llbracket \texttt{\{} \texttt{x}: \bot \texttt{\}} \rrbracket\ \rho = \texttt{x} \notin \rho

In the second case, the interpretation also results in a true statement:

{x:+} ρ=if xρ then x>0 \llbracket \texttt{\{} \texttt{x}: + \texttt{\}} \rrbracket\ \rho = \textbf{if}\ \texttt{x} \in \rho\ \textbf{then}\ \texttt{x} > 0

In Agda, I encode the fact that a verified analysis needs a semantic function \llbracket\cdot\rrbracket for its element lattice LL by taking such a function as an argument called ⟦_⟧ˡ:

From Forward.agda, lines 246 through 253
246
247
248
249
250
251
252
253
module WithInterpretation (latticeInterpretationˡ : LatticeInterpretation isLatticeˡ) where
    open LatticeInterpretation latticeInterpretationˡ
        using ()
        renaming
            ( ⟦_⟧ to ⟦_⟧ˡ
            ; ⟦⟧-respects-≈ to ⟦⟧ˡ-respects-≈ˡ
            ; ⟦⟧-⊔-∨ to ⟦⟧ˡ-⊔ˡ-∨
            )

I then define the semantic function for the variable-sign lattice in the following way, which eschews the “…” notation in favor of a more Agda-compatible (and equivalent) form:

From Forward.agda, lines 255 through 256
255
256
⟦_⟧ᵛ : VariableValues  Env  Set
⟦_⟧ᵛ vs ρ =  {k l}  (k , l) ∈ᵛ vs   {v}  (k , v) Language.∈ ρ   l ⟧ˡ v

The above reads roughly as follows:

For every variable k and sign [or, more generally, lattice element] l in the variable map lattice, if k is in the environment ρ, then it satisfies the predicate given by the semantic function applied to l.

Let’s recap: we have defined a semantic function for our sign lattice, and noted that to define a verified analysis, we always need such a semantic function. We then showed how to construct a semantic function for a whole variable map (of type VariableSign\text{Variable} \to \text{Sign}, or VariableL\text{Variable}\to L in general). We also wrote some Agda code doing all this. As a result, we have filled in the missing piece for property 1.

However, the way that we brought in the semantic function in the Agda code above hints that there’s more to be discussed. What’s latticeInterpretationˡ? In answering that question, we’ll provide evidence for property 2 and property 4.

Properties of the Semantic Function

As we briefly saw earlier, we loosened the notion of equality to that equivalences, which made it possible to ignore things like the ordering of key-value pairs in maps. That’s great and all, but nothing is stopping us from defining semantic functions that violate our equivalence! Supposing af(a)a \approx f(a), as far as Agda is concerned, even though aa and f(a)f(a) are “equivalent”, a\llbracket a \rrbracket and f(a)\llbracket f(a) \rrbracket may be totally different. For a semantic function to be correct, it must produce the same predicate for equivalent elements of lattice LL. That’s missing piece 4.

Another property of semantic functions that we will need to formalize is that ()(\sqcup) represents disjunction. This comes into play when we reason about the correctness of predecessors in a Control Flow Graph. Recall that during the last step of processing a given node, when we are trying to move on to the next node in the trace, we have knowledge that the current node’s variable map accurately describes the intermediate environment. In other words, vsi ρ2\llbracket \textit{vs}_i \rrbracket\ \rho_2 holds, where vsi\textit{vs}_i is the variable map for the current node. We can generalize this kowledge a little, and get:

vs1 ρ2 or ... or vsn ρ2 \llbracket \textit{vs}_1 \rrbracket\ \rho_2\ \text{or}\ ...\ \text{or}\ \llbracket \textit{vs}_n \rrbracket\ \rho_2

However, the assumption that we need to hold when moving on to a new node is in terms of JOIN\textit{JOIN}, which combines all the predecessors’ maps vs1,...,vsn\textit{vs}_1, ..., \textit{vs}_n using ()(\sqcup). Thus, we will need to be in a world where the following claim is true:

vs1...vsn ρ \llbracket \textit{vs}_1 \sqcup ... \sqcup \textit{vs}_n \rrbracket\ \rho

To get from one to the other, we will need to rely explicitly on the fact that ()(\sqcup) encodes “or”. It’s not necessary for the forward analysis, but a similar property ought to hold for ()(\sqcap) and “and”. This constraint provides missing piece 2.

I defined a new data type that bundles a semantic function with proofs of the properties in this section; that’s precisely what latticeInterpretationˡ is:

From Semantics.agda, lines 66 through 73
66
67
68
69
70
71
72
73
record LatticeInterpretation {l} {L : Set l} {_≈_ : L  L  Set l}
                             {_⊔_ : L  L  L} {_⊓_ : L  L  L}
                             (isLattice : IsLattice L _≈_ _⊔_ _⊓_) : Set (lsuc l) where
    field
        ⟦_⟧ : L  Value  Set
        ⟦⟧-respects-≈ :  {l₁ l₂ : L}  l₁  l₂   l₁    l₂ 
        ⟦⟧-⊔-∨ :  {l₁ l₂ : L}  ( l₁    l₂ )   l₁  l₂ 
        ⟦⟧-⊓-∧ :  {l₁ l₂ : L}  ( l₁    l₂ )   l₁  l₂ 

In short, to leverage the framework for verified analysis, you would need to provide a semantic function that interacts properly with and .

Correctness of the Evaluator

All that’s left is the last missing piece, 3, which requires that eval matches the semantics of our language. Recall the signature of eval:

From Forward.agda, line 166
166
module WithEvaluator (eval : Expr  VariableValues  L)

It operates on expressions and variable maps, which themselves associate a sign (or, generally, an element of lattice LL), with each variable. The “real” evaluation judgement, on the other hand, is in the form ρ,ev\rho, e \Downarrow v, and reads “expression ee in environment ρ\rho evaluates to value vv”. In Agda:

From Semantics.agda, line 27
27
data _,_⇒ᵉ_ : Env  Expr  Value  Set where

Let’s line up the types of eval and the judgement. I’ll swap the order of arguments for eval to make the correspondence easier to see:

eval:(VariableSign)ExprSign,:(VariableValue)ExprValueSet(VariableValue)environment-like inputsValuevalue-like outputs \begin{array}{ccccccc} \text{eval} & : & (\text{Variable} \to \text{Sign}) & \to & \text{Expr} & \to & \text{Sign} \\ \cdot,\cdot\Downarrow\cdot & : & (\text{Variable} \to \text{Value}) & \to & \text{Expr} & \to & \text{Value} & \to & \text{Set} \\ & & \underbrace{\phantom{(\text{Variable} \to \text{Value})}}_{\text{environment-like inputs}} & & & & \underbrace{\phantom{Value}}_{\text{value-like outputs}} \end{array}

Squinting a little, it’s almost like the signature of eval is the signature for the evaluation judgement, but it forgets a few details (the exact values of the variables) in favor of abstractions (their signs). To show that eval behaves correctly, we’ll want to prove that this forgetful correspondence holds.

Concretely, for any expression ee, take some environment ρ\rho, and “forget” the exact values, getting a sign map vs\textit{vs}. Now, evaluate the expression to some value vv using the semantics, and also, compute the expression’s expected sign ss using eval. The sign should be the same as forgetting vv’s exact value. Mathematically,

e,ρ,v,vs. if vsρ and ρ,ev then eval vs ev \forall e, \rho, v, \textit{vs}.\ \textbf{if}\ \llbracket\textit{vs}\rrbracket \rho\ \text{and}\ \rho, e \Downarrow v\ \textbf{then}\ \llbracket \text{eval}\ \textit{vs}\ e\rrbracket v

In Agda:

From Forward.agda, lines 286 through 287
286
287
InterpretationValid : Set
InterpretationValid =  {vs ρ e v}  ρ , e ⇒ᵉ v   vs ⟧ᵛ ρ   eval e vs ⟧ˡ v

For a concrete analysis, we need to prove the above claim. In the case of sign analysis, this boils down to a rather cumbersome proof by cases. I will collapse the proofs to save some space and avoid overwhelming the reader.

(Click here to expand the proof of correctness for plus)
From Sign.agda, lines 237 through 258
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
plus-valid :  {g₁ g₂} {z₁ z₂}   g₁ ⟧ᵍ (↑ᶻ z₁)   g₂ ⟧ᵍ (↑ᶻ z₂)   plus g₁ g₂ ⟧ᵍ (↑ᶻ (z₁ Int.+ z₂))
plus-valid {⊥ᵍ} {_}  _ = 
plus-valid {[ + ]ᵍ} {⊥ᵍ} _  = 
plus-valid {[ - ]ᵍ} {⊥ᵍ} _  = 
plus-valid {[ 0ˢ ]ᵍ} {⊥ᵍ} _  = 
plus-valid {⊤ᵍ} {⊥ᵍ} _  = 
plus-valid {⊤ᵍ} {[ + ]ᵍ} _ _ = tt
plus-valid {⊤ᵍ} {[ - ]ᵍ} _ _ = tt
plus-valid {⊤ᵍ} {[ 0ˢ ]ᵍ} _ _ = tt
plus-valid {⊤ᵍ} {⊤ᵍ} _ _ = tt
plus-valid {[ + ]ᵍ} {[ + ]ᵍ} (n₁ , refl) (n₂ , refl) = (_ , refl)
plus-valid {[ + ]ᵍ} {[ - ]ᵍ} _ _ = tt
plus-valid {[ + ]ᵍ} {[ 0ˢ ]ᵍ} (n₁ , refl) refl = (_ , refl)
plus-valid {[ + ]ᵍ} {⊤ᵍ} _ _ = tt
plus-valid {[ - ]ᵍ} {[ + ]ᵍ} _ _ = tt
plus-valid {[ - ]ᵍ} {[ - ]ᵍ} (n₁ , refl) (n₂ , refl) = (_ , refl)
plus-valid {[ - ]ᵍ} {[ 0ˢ ]ᵍ} (n₁ , refl) refl = (_ , refl)
plus-valid {[ - ]ᵍ} {⊤ᵍ} _ _ = tt
plus-valid {[ 0ˢ ]ᵍ} {[ + ]ᵍ} refl (n₂ , refl) = (_ , refl)
plus-valid {[ 0ˢ ]ᵍ} {[ - ]ᵍ} refl (n₂ , refl) = (_ , refl)
plus-valid {[ 0ˢ ]ᵍ} {[ 0ˢ ]ᵍ} refl refl = refl
plus-valid {[ 0ˢ ]ᵍ} {⊤ᵍ} _ _ = tt
(Click here to expand the proof of correctness for minus)
From Sign.agda, lines 261 through 282
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
minus-valid :  {g₁ g₂} {z₁ z₂}   g₁ ⟧ᵍ (↑ᶻ z₁)   g₂ ⟧ᵍ (↑ᶻ z₂)   minus g₁ g₂ ⟧ᵍ (↑ᶻ (z₁ Int.- z₂))
minus-valid {⊥ᵍ} {_}  _ = 
minus-valid {[ + ]ᵍ} {⊥ᵍ} _  = 
minus-valid {[ - ]ᵍ} {⊥ᵍ} _  = 
minus-valid {[ 0ˢ ]ᵍ} {⊥ᵍ} _  = 
minus-valid {⊤ᵍ} {⊥ᵍ} _  = 
minus-valid {⊤ᵍ} {[ + ]ᵍ} _ _ = tt
minus-valid {⊤ᵍ} {[ - ]ᵍ} _ _ = tt
minus-valid {⊤ᵍ} {[ 0ˢ ]ᵍ} _ _ = tt
minus-valid {⊤ᵍ} {⊤ᵍ} _ _ = tt
minus-valid {[ + ]ᵍ} {[ + ]ᵍ} _ _ = tt
minus-valid {[ + ]ᵍ} {[ - ]ᵍ} (n₁ , refl) (n₂ , refl) = (_ , refl)
minus-valid {[ + ]ᵍ} {[ 0ˢ ]ᵍ} (n₁ , refl) refl = (_ , refl)
minus-valid {[ + ]ᵍ} {⊤ᵍ} _ _ = tt
minus-valid {[ - ]ᵍ} {[ + ]ᵍ} (n₁ , refl) (n₂ , refl) = (_ , refl)
minus-valid {[ - ]ᵍ} {[ - ]ᵍ} _ _ = tt
minus-valid {[ - ]ᵍ} {[ 0ˢ ]ᵍ} (n₁ , refl) refl = (_ , refl)
minus-valid {[ - ]ᵍ} {⊤ᵍ} _ _ = tt
minus-valid {[ 0ˢ ]ᵍ} {[ + ]ᵍ} refl (n₂ , refl) = (_ , refl)
minus-valid {[ 0ˢ ]ᵍ} {[ - ]ᵍ} refl (n₂ , refl) = (_ , refl)
minus-valid {[ 0ˢ ]ᵍ} {[ 0ˢ ]ᵍ} refl refl = refl
minus-valid {[ 0ˢ ]ᵍ} {⊤ᵍ} _ _ = tt

This completes our last missing piece, 3. All that’s left is to put everything together.

Proving The Analysis Correct

Lifting Expression Evaluation Correctness to Statements

The individual analyses (like the sign analysis) provide only an evaluation function for expressions, and thus only have to prove correctness of that function. However, our language is made up of statements, with judgements in the form ρ,sρ\rho, s \Rightarrow \rho'. Now that we’ve shown (or assumed) that eval behaves correctly when evaluating expressions, we should show that this correctness extends to evaluating statements, which in the forward analysis implementation is handled by the updateVariablesFromStmt function.

The property we need to show looks very similar to the property for eval:

b,ρ,ρ,vs. if vsρ and ρ,bρ then updateVariablesFromStmt vs bρ \forall b, \rho, \rho', \textit{vs}.\ \textbf{if}\ \llbracket\textit{vs}\rrbracket \rho\ \text{and}\ \rho, b \Rightarrow \rho'\ \textbf{then}\ \llbracket \text{updateVariablesFromStmt}\ \textit{vs}\ b\rrbracket \rho'

In Agda:

The proof is straightforward, and relies on the semantics of the map update. Specifically, in the case of an assignment statement xex \leftarrow e, all we do is store the new sign computed from ee into the map at xx. To prove the correctness of the entire final environment ρ\rho', there are two cases to consider:

The corresponding Agda proof is as follows:

From Forward.agda, lines 291 through 305
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
updateVariablesFromStmt-matches :  {bs vs ρ₁ ρ₂}  ρ₁ , bs ⇒ᵇ ρ₂   vs ⟧ᵛ ρ₁   updateVariablesFromStmt bs vs ⟧ᵛ ρ₂
updateVariablesFromStmt-matches {_} {vs} {ρ₁} {ρ₁} (⇒ᵇ-noop ρ₁) ⟦vs⟧ρ₁ = ⟦vs⟧ρ₁
updateVariablesFromStmt-matches {_} {vs} {ρ₁} {_} (⇒ᵇ-← ρ₁ k e v ρ,e⇒v) ⟦vs⟧ρ₁ {k'} {l} k',l∈vs' {v'} k',v'∈ρ₂
    with k ≟ˢ k' | k',v'∈ρ₂
...   | yes refl | here _ v _
        rewrite updateVariablesFromExpression-k∈ks-≡ k e {l = vs} (Any.here refl) k',l∈vs' =
        interpretationValidˡ ρ,e⇒v ⟦vs⟧ρ₁
...   | yes k≡k' | there _ _ _ _ _ k'≢k _ = ⊥-elim (k'≢k (sym k≡k'))
...   | no k≢k' | here _ _ _ = ⊥-elim (k≢k' refl)
...   | no k≢k'  | there _ _ _ _ _ _ k',v'∈ρ₁ =
        let
            k'∉[k] = (λ { (Any.here refl)  k≢k' refl })
            k',l∈vs = updateVariablesFromExpression-k∉ks-backward k e {l = vs} k'∉[k] k',l∈vs'
        in
            ⟦vs⟧ρ₁ k',l∈vs k',v'∈ρ₁

From this, it follows with relative ease that each basic block in the lattice, when evaluated, produces an environment that matches the prediction of our forward analysis.

From Forward.agda, line 318
318
updateAll-matches :  {s sv ρ₁ ρ₂}  ρ₁ , (code s) ⇒ᵇˢ ρ₂   variablesAt s sv ⟧ᵛ ρ₁   variablesAt s (updateAll sv) ⟧ᵛ ρ₂

Walking the Trace

Finally, we get to the meat of the proof, which follows the outline. First, let’s take a look at stepTrace, which implements the second bullet in our iterative procedure. I’ll show the code, then we can discuss it in detail.

From Forward.agda, lines 324 through 342
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
stepTrace :  {s₁ ρ₁ ρ₂}   joinForKey s₁ result ⟧ᵛ ρ₁  ρ₁ , (code s₁) ⇒ᵇˢ ρ₂   variablesAt s₁ result ⟧ᵛ ρ₂
stepTrace {s₁} {ρ₁} {ρ₂} ⟦joinForKey-s₁⟧ρ₁ ρ₁,bss⇒ρ₂ =
        let
            -- I'd use rewrite, but Agda gets a memory overflow (?!).
            ⟦joinAll-result⟧ρ₁ =
                subst (λ vs   vs ⟧ᵛ ρ₁)
                      (sym (variablesAt-joinAll s₁ result))
                      ⟦joinForKey-s₁⟧ρ₁
            ⟦analyze-result⟧ρ₂ =
                updateAll-matches {sv = joinAll result}
                                  ρ₁,bss⇒ρ₂ ⟦joinAll-result⟧ρ₁
            analyze-result≈result =
                ≈ᵐ-sym {result} {updateAll (joinAll result)}
                       result≈analyze-result
            analyze-s₁≈s₁ =
                variablesAt-≈ s₁ (updateAll (joinAll result))
                                 result (analyze-result≈result)
        in
            ⟦⟧ᵛ-respects-≈ᵛ {variablesAt s₁ (updateAll (joinAll result))} {variablesAt s₁ result} (analyze-s₁≈s₁) ρ₂ ⟦analyze-result⟧ρ₂

The first let-bound variable, ⟦joinAll-result⟧ρ₁ is kind of an intermediate result, which I was forced to introduced because rewrite caused Agda to allocate ~100GB of memory. It simply makes use of the fact that joinAll, the function that performs predecessor joining for each node in the CFG, sets every key of the map accordingly.

The second let-bound variable, ⟦analyze-result⟧, steps through a given node’s basic block and leverages our proof of statement-correctness to validate that the final environment ρ₂ matches the predication of the analyzer.

The last two let-bound variables apply the equation we wrote above:

r=update(join(r)) r = \text{update}(\text{join}(r))

Recall that analyze is the combination of update and join:

From Forward.agda, lines 226 through 227

Finally, the in portion of the code uses ⟦⟧ᵛ-respects-≈ᵛ, a proof of property 4, to produce the final claim in terms of the result map.

Knowing how to step, we can finally walk the entire trace, implementing the iterative process:

The first step — assuming that one of the predecessors of the current node satisfies the initial environment ρ₁ — is captured by the presence of the argument ⟦joinForKey-s₁⟧ρ₁. We expect the calling code to provide a proof of that.

The second step, in both cases, is implemented using stepTrace, as we saw above. That results in a proof that at the end of the current basic block, the final environment ρ₂ is accurately described.

From there, we move on to the third iterative step, if necessary. The sub-expression edge⇒incoming s₁→s₂ validates that, since we have an edge from the current node to the next, we are listed as a predecessor. This, in turn, means that we are included in the list of states-to-join for the JOIN\textit{JOIN} function. That fact is stored in s₁∈incomingStates. Finally, relying on property 2, we construct an assumption fit for a recursive invocation of walkTrace, and move on to the next CFG node. The foldr here is motivated by the fact that “summation” using ()(\sqcup) is a fold.

When the function terminates, what we have is a proof that the final program state is accurately described by the results of our program analysis. All that’s left is to kick off the walk. To do that, observe that the initial state has no predecessors (how could it, if it’s at the beginning of the program?). That, in turn, means that this state maps every variable to the bottom element. Such a variable configuration only permits the empty environment ρ=\rho = \varnothing. If the program evaluation starts in an empty environment, we have the assumption needed to kick off the iteration.

From Forward.agda, lines 359 through 366
359
360
361
362
363
364
365
366
joinForKey-initialState-⊥ᵛ : joinForKey initialState result  ⊥ᵛ
joinForKey-initialState-⊥ᵛ = cong (λ ins  foldr _⊔ᵛ_ ⊥ᵛ (result [ ins ])) initialState-pred-∅

⟦joinAll-initialState⟧ᵛ∅ :  joinForKey initialState result ⟧ᵛ []
⟦joinAll-initialState⟧ᵛ∅ = subst (λ vs   vs ⟧ᵛ []) (sym joinForKey-initialState-⊥ᵛ) ⟦⊥ᵛ⟧ᵛ∅

analyze-correct :  {ρ : Env}  [] , rootStmt ⇒ˢ ρ   variablesAt finalState result ⟧ᵛ ρ
analyze-correct {ρ} ∅,s⇒ρ = walkTrace {initialState} {finalState} {[]} {ρ} ⟦joinAll-initialState⟧ᵛ∅ (trace ∅,s⇒ρ)

Take a look at the highlighted line in the above code block in particular. It states precisely what we were hoping to see: that, when evaluating a program, the final state when it terminates is accurately described by the result of our static program analysis at the finalState in the CFG. We have done it!

Future Work

It took a lot of machinery to get where we are, but there’s still lots of things to do.

  1. Correctness beyond the final state: the statement we’ve arrived at only shows that the final state of the program matches the results of the analysis. In fact, the property hold for all intermediate states, too. The only snag is that it’s more difficult to state such a claim.

    To do something like that, we probably need a notion of “incomplete evaluations” of our language, which run our program but stop at some point before the end. A full execution would be a special case of such an “incomplete evaluation” that stops in the final state. Then, we could restate analyze-correct in terms of partial evaluations, which would strengthen it.

  2. A more robust language and evaluation process: we noted above that our join-based analysis is a little bit weird, particularly in the cases of uninitialized variables. There are ways to adjust our language (e.g., introducing variable declaration points) and analysis functions (e.g., only allowing assignment for declared variables) to reduce the weirdness somewhat. They just lead to a more complicated language.

  3. A more general correctness condition: converting lattice elements into predicates on values gets us far. However, some types of analyses make claims about more than the current values of variables. For instance, live variable analysis checks if a variable’s current value is going to be used in the future. Such an analysis can help guide register (re)allocation. To talk about future uses of a variable, the predicate will need to be formulated in terms of the entire evaluation proof tree. This opens a whole can of worms that I haven’t begun to examine.

Now that I’m done writing up my code so far, I will start exploring these various avenues of work. In the meantime, though, thanks for reading!