Verifying Imperative Programs Using vcgen

This section is a tutorial that introduces the most important concepts of vcgen top-down. The vcgen tactic proves properties of any kind of program that has a weakest precondition semantics, from monadic computations to the syntax trees of a small imperative language. This tutorial focuses on monadic programs written with Lean.Parser.Term.do : termdo-notation, because they are the executable programs that Lean users most often want to prove something about. Recall that you need to import Std.WP and Std.Tactic.Do and open the namespaces Std.WP and Lean.Order to run these examples:

import Std.WP import Std.Tactic.Doopen Std.WP Lean.Order

Preconditions and Postconditions🔗

One style in which program specifications can be written is to provide a precondition P, which the caller of a program \mathit{prog} is expected to ensure, and a postcondition Q, which the \mathit{prog} is expected to ensure. The program \mathit{prog} satisfies the specification if running it when the precondition P holds always results in the postcondition Q holding.

In general, many different preconditions might suffice for a program to ensure the postcondition. After all, new preconditions can be generated by replacing a precondition P_1 with P_1 \wedge P_2. The weakest precondition \textbf{wp}⟦\mathit{prog}⟧(Q) of a program \mathit{prog} and postcondition Q is a precondition for which \mathit{prog} ensures the postcondition Q and is implied by all other such preconditions.

One way to prove something about the result of a program is to find the weakest precondition that guarantees the desired result, and then to show that this weakest precondition is simply true. This means that the postcondition holds no matter what.

Loops and Invariants🔗

As a first example of vcgen, the function mySum computes the sum of an array using local mutable state and a Lean.Parser.Term.doFor : doElem`for x in e do s` iterates over `e` assuming `e`'s type has an instance of the `ForIn` typeclass. `break` and `continue` are supported inside `for` loops. `for x in e, x2 in e2, ... do s` iterates over the given collections in parallel, until at least one of them is exhausted. The types of `e2` etc. must implement the `Std.ToStream` typeclass. for loop:

def mySum (l : Array Nat) : Nat := Id.run do let mut out := 0 for i in l do out := out + i return out

If mySum is correct, then it is equal to Array.sum. The statement mySum l = l.sum mentions no monad, because Lean.Parser.Term.do : termdo is an internal implementation detail of mySum. The proof below therefore first brings the goal into the form that vcgen works on, a weakest precondition, with the lemma Id.of_run_eq_wp. This lemma states that facts about the result of running a computation in the Id monad that terminates normally (Id computations never throw exceptions) can be proved by showing that the weakest precondition that ensures the desired result is true. Next, the proof uses vcgen to replace the formulation in terms of weakest preconditions with a set of verification conditions.

While vcgen is mostly automatic, it does require an invariant for the loop. A loop invariant is a statement that is both assumed and guaranteed by the body of the loop; if it is true when the loop begins, then it will be true when the loop terminates.

theorem mySum_correct (l : Array Nat) : mySum l = l.sum := l:Array Nat⊢ mySum l = l.sum -- Focus on the part of the program with the `do` block (`Id.run ...`) l:Array Natx:Nath:mySum l = x⊢ x = l.sum l:Array Natx:Nath:mySum l = x⊢ wp (have out := 0; do let __s ← forIn l out fun i __s => have out := __s; have out := out + i; pure (ForInStep.yield out) have out : Nat := __s pure out) (fun x => x = l.sum) () -- Break down into verification conditions l:Array Natx:Nath:mySum l = x⊢ Invariant Nat Nat Propl:Array Natx:Nath:mySum l = x⊢ ?inv1 [] (ForIn.toList l) 0l:Array Natx:Nath:mySum l = xa✝¹:Nata✝:?inv1 (ForIn.toList l) [] a✝¹⊢ a✝¹ = l.suml:Array Natx:Nath:mySum l = xpref✝:List Natcur✝:Natsuff✝:List Nat_h✝:ForIn.toList l = pref✝ ++ cur✝ :: suff✝b✝:Nata✝:?inv1 pref✝ (cur✝ :: suff✝) b✝out:Nat := b✝ + cur✝⊢ ?inv1 (pref✝ ++ [cur✝]) suff✝ out -- Specify the invariant which should hold throughout the loop. -- It is a function of three arguments: -- * `pref` is the list of elements that the loop has already visited -- * `suff` is the list of elements that the loop has yet to visit -- * `out` is the current value of the `let mut` variable -- Our invariant is that `out` holds the sum of the prefix. case inv1 l:Array Natx:Nath:mySum l = x⊢ Invariant Nat Nat Prop All goals completed! 🐙 -- Prove that the invariant is true at the start case vc1 l:Array Natx:Nath:mySum l = x⊢ [].sum = 0 All goals completed! 🐙 -- Prove that the invariant at the end of the loop implies the -- property we wanted case vc2 l:Array Natx:Nath:mySum l = xa✝¹:Nata✝:(ForIn.toList l).sum = a✝¹⊢ a✝¹ = l.sum All goals completed! 🐙 -- Prove that our invariant is preserved at each step of the loop case vc3 l:Array Natx:Nath:mySum l = xpref✝:List Natcur✝:Natsuff✝:List Nat_h✝:ForIn.toList l = pref✝ ++ cur✝ :: suff✝b✝:Nata✝:pref✝.sum = b✝out:Nat := b✝ + cur✝⊢ (pref✝ ++ [cur✝]).sum = out All goals completed! 🐙

The case labels vc1, vc2, … number the VCs in the order in which they arise during generation. In this proof:

  • vc1 proves that the invariant holds when the loop starts.

  • vc2 proves that the invariant after the final iteration implies the desired property.

  • vc3 proves that each iteration of the loop body preserves the invariant.

After specifying the loop invariant, the proof can be shortened to just all_goals grind.

theorem mySum_correct_short (l : Array Nat) : mySum l = l.sum := l:Array Nat⊢ mySum l = l.sum l:Array Natx:Nath:mySum l = x⊢ x = l.sum l:Array Natx:Nath:mySum l = x⊢ wp (have out := 0; do let __s ← forIn l out fun i __s => have out := __s; have out := out + i; pure (ForInStep.yield out) have out : Nat := __s pure out) (fun x => x = l.sum) () l:Array Natx:Nath:mySum l = x⊢ Invariant Nat Nat Propl:Array Natx:Nath:mySum l = x⊢ ?inv1 [] (ForIn.toList l) 0l:Array Natx:Nath:mySum l = xa✝¹:Nata✝:?inv1 (ForIn.toList l) [] a✝¹⊢ a✝¹ = l.suml:Array Natx:Nath:mySum l = xpref✝:List Natcur✝:Natsuff✝:List Nat_h✝:ForIn.toList l = pref✝ ++ cur✝ :: suff✝b✝:Nata✝:?inv1 pref✝ (cur✝ :: suff✝) b✝out:Nat := b✝ + cur✝⊢ ?inv1 (pref✝ ++ [cur✝]) suff✝ out case inv1 l:Array Natx:Nath:mySum l = x⊢ Invariant Nat Nat Prop All goals completed! 🐙 all_goals All goals completed! 🐙

This pattern is so common that vcgen comes with special syntax for it:

theorem mySum_correct_shorter (l : Array Nat) : mySum l = l.sum := l:Array Nat⊢ mySum l = l.sum l:Array Natx:Nath:mySum l = x⊢ x = l.sum l:Array Natx:Nath:mySum l = x⊢ wp (have out := 0; do let __s ← forIn l out fun i __s => have out := __s; have out := out + i; pure (ForInStep.yield out) have out : Nat := __s pure out) (fun x => x = l.sum) () vcgen invariants · fun pref suff out => pref.sum = out with All goals completed! 🐙

The vcgen invariants ...with... form supplies the invariants and then runs the given grind step on every remaining VC; the finish step closes each VC with grind's full automation. It is the form that we will be using from now on.

It is helpful to compare the proof of mySum_correct_shorter to a traditional correctness proof:

theorem mySum_correct_vanilla (l : Array Nat) : mySum l = l.sum := l:Array Nat⊢ mySum l = l.sum -- Turn the array into a list cases l with l:List Nat⊢ mySum { toList := l } = { toList := l }.sum -- Unfold `mySum` and rewrite `forIn` to `foldl` l:List Nat⊢ List.foldl (fun b a => b + a) 0 l = l.sum -- Generalize the inductive hypothesis l:List Nat⊢ ∀ (out : Nat), List.foldl (fun x1 x2 => x1 + x2) out l = out + l.sum -- Grind away induction l with All goals completed! 🐙

This proof is similarly succinct as the proof in mySum_correct_shorter that uses vcgen. However, the traditional approach relies on important properties of the program:

  • The Lean.Parser.Term.doFor : doElem`for x in e do s` iterates over `e` assuming `e`'s type has an instance of the `ForIn` typeclass. `break` and `continue` are supported inside `for` loops. `for x in e, x2 in e2, ... do s` iterates over the given collections in parallel, until at least one of them is exhausted. The types of `e2` etc. must implement the `Std.ToStream` typeclass. for loop does not Lean.Parser.Term.doBreak : doElem`break` exits the surrounding `for` loop. break or Lean.Parser.Term.doReturn : doElem`return e` inside of a `do` block makes the surrounding block evaluate to `pure e`, skipping any further statements. Note that uses of the `do` keyword in other syntax like in `for _ in _ do` do not constitute a surrounding block in this sense; in supported editors, the corresponding `do` keyword of the surrounding block is highlighted when hovering over `return`. `return` not followed by a term starting on the same line is equivalent to `return ()`. return early. Otherwise, the forIn could not be rewritten to a foldl.

  • The loop body (· + ·) is small enough to be repeated in the proof.

  • The loop body does not carry out any effects in the underlying monad (that is, the only effects are those introduced by Lean.Parser.Term.do : termdo-notation). The Id monad has no effects, so all of its comptutations are pure. While forIn could still be rewritten to a foldlM, reasoning about the monadic loop body can be tough for grind.

In the following sections, we will go through several examples to learn about vcgen and its support library, and also see where traditional proofs become difficult. This is usually caused by:

  • Lean.Parser.Term.do : termdo blocks using control flow constructs such as Lean.Parser.Term.doFor : doElem`for x in e do s` iterates over `e` assuming `e`'s type has an instance of the `ForIn` typeclass. `break` and `continue` are supported inside `for` loops. `for x in e, x2 in e2, ... do s` iterates over the given collections in parallel, until at least one of them is exhausted. The types of `e2` etc. must implement the `Std.ToStream` typeclass. for loops, Lean.Parser.Term.doBreak : doElem`break` exits the surrounding `for` loop. breaks and early Lean.Parser.Term.doReturn : doElem`return e` inside of a `do` block makes the surrounding block evaluate to `pure e`, skipping any further statements. Note that uses of the `do` keyword in other syntax like in `for _ in _ do` do not constitute a surrounding block in this sense; in supported editors, the corresponding `do` keyword of the surrounding block is highlighted when hovering over `return`. `return` not followed by a term starting on the same line is equivalent to `return ()`. return.

  • The use of effects in non-Id monads, which affects the implicit monadic context (state, exceptions) in ways that need to be reflected in loop invariants.

vcgen scales to these challenges with reasonable effort.

Control Flow🔗

Let us consider another example that combines Lean.Parser.Term.doFor : doElem`for x in e do s` iterates over `e` assuming `e`'s type has an instance of the `ForIn` typeclass. `break` and `continue` are supported inside `for` loops. `for x in e, x2 in e2, ... do s` iterates over the given collections in parallel, until at least one of them is exhausted. The types of `e2` etc. must implement the `Std.ToStream` typeclass. for loops with an early return. List.Nodup is a predicate that asserts that a given list does not contain any duplicates. The function nodup below decides this predicate:

def nodup (l : List Int) : Bool := Id.run do let mut seen : Std.HashSet Int := ∅ for x in l do if x ∈ seen then return false seen := seen.insert x return true

This function is correct if it returns true for every list that satisfies List.Nodup and false for every list that does not. Just as it was in mySum, the use of Lean.Parser.Term.do : termdo-notation and the Id monad is an internal implementation detail of nodup. Thus, the proof begins by using Id.of_run_eq_wp to make the proof state amenable to vcgen:

-- A future 4.36 release adds these annotations to the library. attribute [local grind norm] Lean.Order.ofProp_prop_eq Lean.Order.meet_prop_eq_and Lean.Order.join_prop_eq_or Lean.Order.iSup_prop_eq_exists theorem nodup_correct (l : List Int) : nodup l ↔ l.Nodup := l:List Int⊢ nodup l = true ↔ l.Nodup l:List Intr:Boolh:nodup l = r⊢ r = true ↔ l.Nodup l:List Intr:Boolh:nodup l = r⊢ wp (have seen := ∅; do let __s ← forIn l (none, seen) fun x __s => have seen := __s.snd; if x ∈ seen then pure (ForInStep.done (some false, seen)) else have seen := seen.insert x; pure (ForInStep.yield (none, seen)) have __r : Option Bool := __s.fst match __r with | some r => pure r | none => pure true) (fun r => r = true ↔ l.Nodup) () vcgen invariants · Invariant.withEarlyReturnNewDo (onReturn := fun ret seen => ret = false ∧ ¬l.Nodup) (onContinue := fun pref suff seen => (∀ x, x ∈ seen ↔ x ∈ pref) ∧ pref.Nodup) with All goals completed! 🐙

The proof has the same structure as the one for mySum: grind discharges all verification conditions with its existing automation around List.Nodup, and only the loop invariant differs. Since our loop has an early return, we construct the invariant using the helper function Invariant.withEarlyReturnNewDo, which supports the extensible do-notation elaborator. This function allows us to specify the invariant in two parts:

  • onReturn ret seen holds after the loop was left through an early return with value ret. In case of nodup, the only value that is ever returned is false, in which case nodup has decided there is a duplicate in the list.

  • onContinue pref suff seen is the invariant that holds after each regular iteration. As in mySum, the list pref holds the elements that the loop has already visited and suff holds the elements that the loop has yet to visit. The given example asserts that the set seen contains all the elements of previous loop iterations and asserts that there were no duplicates so far.

Now consider the following direct (and excessively golfed) proof without vcgen:

theorem nodup_correct_directly (l : List Int) : nodup l ↔ l.Nodup := l:List Int⊢ nodup l = true ↔ l.Nodup l:List Int⊢ (have seen := ∅; do let __s ← forIn l (none, seen) fun x __s => have seen := __s.snd; if x ∈ seen then pure (ForInStep.done (some false, seen)) else have seen := seen.insert x; pure (ForInStep.yield (none, seen)) have __r : Option Bool := __s.fst match __r with | some r => pure r | none => pure true).run = true ↔ l.Nodup l:List Intseen:Std.HashSet Inthseen:∅ = seen⊢ (have seen := seen; do let __s ← forIn l (none, seen) fun x __s => have seen := __s.snd; if x ∈ seen then pure (ForInStep.done (some false, seen)) else have seen := seen.insert x; pure (ForInStep.yield (none, seen)) have __r : Option Bool := __s.fst match __r with | some r => pure r | none => pure true).run = true ↔ l.Nodup l:List Intseen:Std.HashSet Inthseen:∅ = seen⊢ (have seen := seen; do let __s ← forIn l (none, seen) fun x __s => have seen := __s.snd; if x ∈ seen then pure (ForInStep.done (some false, seen)) else have seen := seen.insert x; pure (ForInStep.yield (none, seen)) have __r : Option Bool := __s.fst match __r with | some r => pure r | none => pure true).run = true ↔ l.Nodup l:List Intseen:Std.HashSet Inthseen:∅ = seen⊢ (have seen := seen; do let __s ← forIn l (none, seen) fun x __s => have seen := __s.snd; if x ∈ seen then pure (ForInStep.done (some false, seen)) else have seen := seen.insert x; pure (ForInStep.yield (none, seen)) have __r : Option Bool := __s.fst match __r with | some r => pure r | none => pure true).run = true ↔ l.Nodup ∧ ∀ (x : Int), x ∈ l → ¬x ∈ seen l:List Intseen:Std.HashSet Int⊢ (have seen := seen; do let __s ← forIn l (none, seen) fun x __s => have seen := __s.snd; if x ∈ seen then pure (ForInStep.done (some false, seen)) else have seen := seen.insert x; pure (ForInStep.yield (none, seen)) have __r : Option Bool := __s.fst match __r with | some r => pure r | none => pure true).run = true ↔ l.Nodup ∧ ∀ (x : Int), x ∈ l → ¬x ∈ seen induction l generalizing seen with All goals completed! 🐙

Some observations:

  • The proof is even shorter than the one with vcgen.

  • The use of generalize to generalize the accumulator relies on there being exactly one occurrence of ∅ to generalize. If that were not the case, we would have to copy parts of the program into the proof. This is a no-go for larger functions.

  • grind splits along the control flow of the function and reasons about Id, given the right lemmas. While this works for Id.run_pure and Id.run_bind, it would not work for Id.run_seq, for example, because that lemma is not E-matchable. If grind would fail, we would be forced to do all the control flow splitting and monadic reasoning by hand until grind could pick up again.

The usual way to avoid replicating the control flow of a definition in a proof is to use the fun_cases or fun_induction tactics. Unfortunately, fun_cases does not help with control flow inside a forIn application. A vcgen-powered proof, on the other hand, never needs to copy any part of the original program. The vcgen tactic ships with support for many forIn implementations, and a custom forIn implementation can be supported with a specification lemma that is annotated with @[spec]. Writing such a lemma takes considerable work, because a loop specification takes the invariant as a parameter and must prove that the loop preserves it. A Std.Internal.PureForIn instance is less work: it states that the loop over a container is the loop over the list ForIn.toList of its elements. With this instance, the loop specification that ships with vcgen applies to the container.

Compositional Reasoning About Effectful Programs Using Hoare Triples🔗

The previous examples reasoned about functions defined using Id.run Lean.Parser.Term.do : termdo <prog> to make use of local mutability and early return in <prog>. However, real-world programs often use Lean.Parser.Term.do : termdo notation and monads M to hide away state and failure conditions as implicit “effects”. In this use case, functions usually omit the M.run. Instead they have a monadic return type M α and compose well with other functions of that return type. In other words, the monad is part of the function's interface, not merely its implementation.

Here is an example involving a stateful function mkFresh that returns auto-incremented counter values:

structure Supply where counter : Nat def mkFresh : StateM Supply Nat := do let n ← (·.counter) <$> get modify fun s => { s with counter := s.counter + 1 } pure n def mkFreshN (n : Nat) : StateM Supply (List Nat) := do let mut acc := #[] for _ in [:n] do acc := acc.push (← mkFresh) pure acc.toList

mkFreshN n returns n “fresh” numbers, modifying the internal Supply state through mkFresh. Here, “fresh” refers to all previously generated numbers being distinct from the next generated number. We can formulate and prove a correctness property mkFreshN_correct in terms of List.Nodup: the returned list of numbers should contain no duplicates. In this proof, StateM.of_run'_eq_wp serves the same role that Id.of_run_eq_wp did in the preceding examples.

theorem mkFreshN_correct (n : Nat) : ((mkFreshN n).run' s).Nodup := s:Supplyn:Nat⊢ List.Nodup (StateT.run' (mkFreshN n) s) -- Focus on `(mkFreshN n).run' s`. s:Supplyn:Natx:Id (List Nat)h:StateT.run' (mkFreshN n) s = x⊢ List.Nodup x s:Supplyn:Natx:Id (List Nat)h:StateT.run' (mkFreshN n) s = x⊢ wp (mkFreshN n) (fun a x => a.Nodup) () s -- Show something about monadic program `mkFresh n`. -- The `mkFreshN` and `mkFresh` arguments make `vcgen` unfold -- these definitions. vcgen [mkFreshN, mkFresh] invariants -- Invariant: The counter is larger than any accumulated number, -- and all accumulated numbers are distinct. -- Note that the invariant may refer to the state through function -- argument `state : Supply`. Since the next number to accumulate is -- the counter, it is distinct to all accumulated numbers. · fun pref suff acc state => (∀ x ∈ acc, x < state.counter) ∧ acc.toList.Nodup with All goals completed! 🐙

This proof unfolds both mkFreshN and mkFresh. In a larger program, many functions call mkFresh, and every proof about one of them like the above would unfold mkFresh again. Instead, the behavior of mkFresh can be stated once, proved once, and then used at every call. Proofs that use the specification of a function and never its code are called modular. Hoare triples are the standard form of specifications for effectful programs, and they make such modular reasoning possible.

Hoare Triples🔗

A Hoare triple consists of a precondition, a statement, and a postcondition; it asserts that if the precondition holds, then the postcondition holds after running the statement. In Lean syntax, this is written ⦃ P ⦄ prog ⦃ Q ⦄, where P is the precondition, prog : m α is the statement, and Q is the postcondition. P and Q are written in an assertion language that is determined by the specific monad m.Marginal noteIn particular, the monad's instance of the type class WP specifies the ways in which assertions may refer to the monad's state or the exceptions it may throw. An explicit exception postcondition can be supplied after a semicolon, as in ⦃ P ⦄ prog ⦃ Q; E ⦄. When the exception postcondition is omitted, it defaults to the bottom assertion ⊥, which asserts that the program throws no exception. The section “Exceptions” below shows postconditions for programs that throw.

One suitable specification for mkFreshN as a Hoare triple is this translation of mkFreshN_correct:

⦃fun _ => True⦄ mkFreshN n ⦃fun r _ => r.Nodup⦄

For StateM programs, an assertion is a predicate on the state, and a postcondition additionally takes the result value as its first argument. The precondition fun (_ : Supply) => True imposes no requirements on the state in which the program is called. The postcondition states that the result value is a list with no duplicate elements, regardless of the final state.

A specification for the single-step mkFresh describes its effects on the monad's state:

∀ (c : Nat), ⦃fun state => state.counter = c⦄ mkFresh ⦃fun r state => r = c ∧ c < state.counter⦄

The universally quantified c is a logical variable: it relates the initial state to the result and to the final state. This Hoare triple states:

If c refers to the Supply.counter field of the Supply prestate, then running mkFresh returns c and modifies the Supply.counter of the poststate to be larger than c.

Note that this specification is lossy: mkFresh could increment its state by an arbitrary non-negative amount and still satisfy the specification. This is good, because specifications may abstract over uninteresting implementation details, ensuring resilient and small proofs.

A triple ⦃ P ⦄ prog ⦃ Q ⦄ means P ⊑ wp prog Q ⊥: the precondition entails the weakest precondition from the beginning of this tutorial. The weakest precondition semantics wp interprets programs as predicate transformers that map postconditions to preconditions. The Hoare triple syntax is notation for Std.WP.Triple, which is defined as follows, with some binders elided:

structure Triple ... (x : Prog) [WP Prog Value Pred EPred]
    (pre : Pred) (post : Value → Pred) (epost : EPred) : Prop where
  intro ::
  le_wp : pre ⊑ wp x post epost

The WP type class interprets a program type Prog with result values of type Value as a predicate transformer over an assertion type Pred and an exception assertion type EPred. Many of the standard monads and monad transformers such as StateT, ReaderT and ExceptT come with a canonical WP instance. For example, StateT σ comes with a WP instance that adds a σ argument to every assertion. The entailment ⊑ in the definition is the order of the assertion lattice; for StateM it is pointwise implication. For StateM programs, the following type is equivalent to Std.WP.Triple:

def StateMTriple {α σ : Type} (prog : StateM σ α) (P : σ → Prop) (Q : α → σ → Prop) : Prop := ∀ s, P s → let (a, s') := prog.run s; Q a s'

Specifications as Hoare triples are compositional because they allow statements to be sequenced. Given ⦃P⦄ stmt1 ⦃fun _ => R⦄ and ⦃R⦄ stmt2 ⦃Q⦄, it follows that ⦃P⦄ (do stmt1; stmt2) ⦃Q⦄. Just as proofs about ordinary functions can rely on lemmas about the functions that they call, proofs about monadic programs can use lemmas that are specified in terms of Hoare triples.

Composing Specifications🔗

The specifications from the previous section become specification lemmas. A specification lemma is a Hoare triple that is automatically used during verification condition generation to obtain the pre- and postconditions of each statement in a Lean.Parser.Term.do : termdo-block. When the system cannot automatically prove that the postcondition of one statement implies the precondition of the next, then this missing reasoning step becomes a verification condition.

Specification lemmas can either be passed as arguments to vcgen or registered in a global (or scoped, or local) database of specifications using the @[spec] attribute:

@[spec] theorem mkFresh_spec (c : Nat) : ⦃fun state => state.counter = c⦄ mkFresh ⦃fun r state => r = c ∧ c < state.counter⦄ := c:Nat⊢ ⦃ fun state => state.counter = c ⦄ mkFresh ⦃ fun r state => r = c ∧ c < state.counter ⦄ -- Unfold `mkFresh` and blast away: vcgen [mkFresh] with All goals completed! 🐙 @[spec] theorem mkFreshN_spec (n : Nat) : ⦃fun _ => True⦄ mkFreshN n ⦃fun r _ => r.Nodup⦄ := n:Nat⊢ ⦃ fun x => True ⦄ mkFreshN n ⦃ fun r x => r.Nodup ⦄ -- `vcgen [mkFreshN, mkFresh_spec]` if `mkFresh_spec` were not -- registered with `@[spec]` vcgen [mkFreshN] invariants -- As before: · fun pref suff acc state => (∀ x ∈ acc, x < state.counter) ∧ acc.toList.Nodup with All goals completed! 🐙

The original correctness theorem can now be proved using vcgen alone:

theorem mkFreshN_correct_modular (n : Nat) : ((mkFreshN n).run' s).Nodup := s:Supplyn:Nat⊢ List.Nodup (StateT.run' (mkFreshN n) s) s:Supplyn:Natx:Id (List Nat)h:StateT.run' (mkFreshN n) s = x⊢ List.Nodup x s:Supplyn:Natx:Id (List Nat)h:StateT.run' (mkFreshN n) s = x⊢ wp (mkFreshN n) (fun a x => a.Nodup) () s All goals completed! 🐙

The specification lemma mkFreshN_spec is automatically used by vcgen.

Monad Transformers and Lifting🔗

Real-world programs often use monads that are built from multiple monad transformers, with operations being frequently lifted from one monad to another. Verification of these programs requires taking this into account. We can tweak the previous example to demonstrate this.

Now, there is an application with two separate monads, both built using transformers:

abbrev CounterM := StateT Supply (ReaderM String) abbrev AppM := StateT Bool CounterM

Instead of using StateM Supply, mkFresh uses CounterM:

def mkFresh : CounterM Nat := do let n ← (·.counter) <$> get modify fun s => { s with counter := s.counter + 1 } pure n

mkFreshN is defined in terms of AppM, which includes multiple states and a reader effect. The definition of mkFreshN lifts mkFresh into AppM:

def mkFreshN (n : Nat) : AppM (List Nat) := do let mut acc := #[] for _ in [:n] do let n ← mkFresh acc := acc.push n return acc.toList

The specification lemmas change only in two places: their pure parts are wrapped in corner brackets, and the invariant takes an extra argument for the Bool state of AppM:

@[spec] theorem mkFresh_spec (c : Nat) : ⦃fun state => ⌜state.counter = c⌝⦄ mkFresh ⦃fun r state => ⌜r = c ∧ c < state.counter⌝⦄ := c:Nat⊢ ⦃ fun state => ⌜state.counter = c⌝ ⦄ mkFresh ⦃ fun r state => ⌜r = c ∧ c < state.counter⌝ ⦄ vcgen [mkFresh] with All goals completed! 🐙 @[spec] theorem mkFreshN_spec (n : Nat) : ⦃⌜True⌝⦄ mkFreshN n ⦃fun r => ⌜r.Nodup⌝⦄ := n:Nat⊢ ⦃ ⌜True⌝ ⦄ mkFreshN n ⦃ fun r => ⌜r.Nodup⌝ ⦄ vcgen [mkFreshN] invariants · fun pref suff acc _ state => ⌜(∀ n ∈ acc, n < state.counter) ∧ acc.toList.Nodup⌝ with All goals completed! 🐙

The corner brackets ⌜p⌝ embed a proposition p into the assertion language as a pure assertion, which holds independently of the state. In CounterM, an assertion takes the Supply state and then the String environment of the reader. The precondition of mkFresh_spec binds only the state, and ⌜state.counter = c⌝ is a pure assertion about the environment: it holds for every environment if state.counter = c holds.

These proofs might not look much more exciting than when only a single monad was involved. However, under the radar of the user the proofs build on a cascade of specifications for MonadLift instances. They also build on the WPMonad instances of StateT and ReaderT, which make the specifications of pure and bind available in every layer. A WPMonad instance requires only that the weakest precondition of pure and bind is at least as weak as the one that the predicate transformer monad computes. In other words, wp is a lax morphism of ordered monads.

Exceptions🔗

If let mut is the Lean.Parser.Term.do : termdo-equivalent of StateT, then early Lean.Parser.Term.doReturn : doElem`return e` inside of a `do` block makes the surrounding block evaluate to `pure e`, skipping any further statements. Note that uses of the `do` keyword in other syntax like in `for _ in _ do` do not constitute a surrounding block in this sense; in supported editors, the corresponding `do` keyword of the surrounding block is highlighted when hovering over `return`. `return` not followed by a term starting on the same line is equivalent to `return ()`. return is the equivalent of ExceptT. We have seen how vcgen copes with StateT; here we will look at the program logic's support for ExceptT.

Exceptions are the reason why a triple carries an exception postcondition next to the success postcondition. To see why, suppose there were only the success postcondition, and suppose that program prog throws an exception in a prestate satisfying P. Should we be able to prove ⦃P⦄ prog ⦃Q'⦄? There is no result value, so it is unclear what this proof means for Q'!

So there are two reasonable options, inspired by non-termination in traditional program logics:

The total correctness interpretation

⦃P⦄ prog ⦃Q'⦄ asserts that, given P holds, then prog terminates normally and Q' holds for the result.

The partial correctness interpretation

⦃P⦄ prog ⦃Q'; ⊤⦄ asserts that, given P holds, and if prog terminates normally then Q' holds for the result.

A triple without an explicit exception postcondition carries the bottom assertion (⊥ : EPred) and thus has the total interpretation: an exception would have to establish ⊥, so no exception can be thrown. The top assertion (⊤ : EPred) permits every exception and yields the partial interpretation.

In the running example, ⦃P⦄ prog ⦃Q'⦄ is unprovable, but ⦃P⦄ prog ⦃Q'; ⊤⦄ is trivially provable. Between (⊥ : EPred) and (⊤ : EPred), the exception postcondition expresses a spectrum of correctness properties.

For example, suppose that our Supply of fresh numbers is bounded and we want to throw an exception if the supply is exhausted. Then mkFresh should throw an exception only if the supply is indeed exhausted, as in this implementation:

structure Supply where counter : Nat limit : Nat property : counter ≤ limit def mkFresh : EStateM String Supply Nat := do let supply ← get if h : supply.counter = supply.limit then throw s!"Supply exhausted: {supply.counter} = {supply.limit}" else let n := supply.counter have := supply.property set { supply with counter := n + 1, property := supply:Supplyh:¬supply.counter = supply.limitn:Nat := supply.counterthis:supply.counter ≤ supply.limit⊢ n + 1 ≤ supply.limit All goals completed! 🐙 } pure n

The following correctness property expresses this:

@[spec] theorem mkFresh_spec (c : Nat) : ⦃fun state => state.counter = c⦄ mkFresh ⦃fun r state => r = c ∧ c < state.counter; fun _ state => c = state.counter ∧ c = state.limit⦄ := c:Nat⊢ ⦃ fun state => state.counter = c ⦄ mkFresh ⦃ fun r state => r = c ∧ c < state.counter; fun x state => c = state.counter ∧ c = state.limit ⦄ vcgen [mkFresh] with All goals completed! 🐙

The exception postcondition after the semicolon applies when mkFresh throws. The monad's WP instance determines the types of both postconditions: the exception postcondition takes the thrown exception in place of the result value, and each state gives an extra parameter.

In this new monad, mkFreshN's implementation is unchanged, except for the type signature:

def mkFreshN (n : Nat) : EStateM String Supply (List Nat) := do let mut acc := #[] for _ in [:n] do acc := acc.push (← mkFresh) pure acc.toList

However, the specification lemma must account for both successful termination and exceptions being thrown; the loop invariant itself stays as before:

@[spec] theorem mkFreshN_spec (n : Nat) : ⦃fun _ => True⦄ mkFreshN n ⦃fun r _ => r.Nodup; fun _msg state => state.counter = state.limit⦄ := n:Nat⊢ ⦃ fun x => True ⦄ mkFreshN n ⦃ fun r x => r.Nodup; fun _msg state => state.counter = state.limit ⦄ vcgen [mkFreshN] invariants · fun pref suff acc state => (∀ n ∈ acc, n < state.counter) ∧ acc.toList.Nodup with All goals completed! 🐙

The final proof uses the specification lemmas and vcgen, just as before:

theorem mkFreshN_correct (n : Nat) : match (mkFreshN n).run s with | .ok l _ => l.Nodup | .error _ s' => s'.counter = s'.limit := s:Supplyn:Nat⊢ match (mkFreshN n).run s with | EStateM.Result.ok l a => l.Nodup | EStateM.Result.error a s' => s'.counter = s'.limit s:Supplyn:Natx:EStateM.Result String Supply (List Nat)h:(mkFreshN n).run s = x⊢ match x with | EStateM.Result.ok l a => l.Nodup | EStateM.Result.error a s' => s'.counter = s'.limit s:Supplyn:Natx:EStateM.Result String Supply (List Nat)h:(mkFreshN n).run s = x⊢ wp (mkFreshN n) (fun a s' => match EStateM.Result.ok a s' with | EStateM.Result.ok l a => l.Nodup | EStateM.Result.error a s' => s'.counter = s'.limit) (fun e s' => match EStateM.Result.error e s' with | EStateM.Result.ok l a => l.Nodup | EStateM.Result.error a s' => s'.counter = s'.limit) s vcgen with All goals completed! 🐙

A StateT σ layer turns the assertion type Pred into σ → Pred and leaves the exception postcondition type unchanged. An ExceptT ε layer leaves the assertion type unchanged and turns the exception postcondition type EPred into (ε → Pred) × EPred, adding one exception branch of type ε → Pred. The WP instance for EStateM ε σ uses assertions of type σ → Prop and exception postconditions of type ε → σ → Prop, mirroring the single exception branch of the equivalent transformer stack ExceptT ε (StateM σ).

Extending vcgen With Support for Custom Monads🔗

The vcgen framework is designed to be extensible. None of the monads presented so far have in any way been hard-coded into vcgen. Rather, vcgen relies on instances of the WP and WPMonad type classes and user-provided specifications to generate verification conditions. Every program type needs a WP instance, and a monad additionally needs a WPMonad instance. This section develops both for a custom monad.

The WP instance defines the weakest precondition interpretation of a monad m into predicate transformers PredTrans Pred EPred α, and the matching WPMonad instance asserts that this translation is sound for pure and bind.

Suppose one wants to use vcgen to generate verification conditions for programs generated by Aeneas. Aeneas translates Rust programs into Lean programs in the following Result monad:

inductive Error where | integerOverflow: Error -- ... more error kinds ... inductive Result (α : Type u) where | ok (v: α): Result α | fail (e: Error): Result α | div

There are both Monad Result and LawfulMonad Result instances. Supporting this monad in vcgen is a matter of:

  1. Adding WP and WPMonad instances for Result

  2. Registering specification lemmas for the translation of basic Rust primitives such as addition etc.

The WP instance for Result specifies the assertion type Prop because there are no state-like effects, and the exception postcondition type Error → Prop because there is a single exception of type Error. The WP instance translates programs in Result α to predicate transformers in PredTrans Prop (Error → Prop) α. That is, a function mapping a postcondition and an exception postcondition to the weakest precondition. The implementation of WP.wp is similar to that of Except Error. Each case of Result determines the precondition directly:

  • Result.ok v yields the success postcondition at v.

  • Result.fail e yields the exception postcondition at e.

  • Result.div yields False, meaning that all specifications assert that the program never diverges.

instance : WP (Result α) α Prop (Error → Prop) where wpTrans | .ok v => ⟨fun post _ => post v⟩ | .fail e => ⟨fun _ epost => epost e⟩ | .div => ⟨fun _ _ => False⟩ wp_trans_monotone x := α:Type ?u.7x:Result α⊢ (match x with | Result.ok v => { apply := fun post x => post v } | Result.fail e => { apply := fun x epost => epost e } | Result.div => { apply := fun x x_1 => False }).monotone α:Type ?u.7x:Result αpost:α → Proppost':α → Propepost:Error → Propepost':Error → Prophepost:epost ⊑ epost'hpost:post ⊑ post'⊢ (match x with | Result.ok v => { apply := fun post x => post v } | Result.fail e => { apply := fun x epost => epost e } | Result.div => { apply := fun x x_1 => False }).apply post epost ⊑ (match x with | Result.ok v => { apply := fun post x => post v } | Result.fail e => { apply := fun x epost => epost e } | Result.div => { apply := fun x x_1 => False }).apply post' epost' α:Type ?u.7post:α → Proppost':α → Propepost:Error → Propepost':Error → Prophepost:epost ⊑ epost'hpost:post ⊑ post'v✝:α⊢ (match Result.ok v✝ with | Result.ok v => { apply := fun post x => post v } | Result.fail e => { apply := fun x epost => epost e } | Result.div => { apply := fun x x_1 => False }).apply post epost ⊑ (match Result.ok v✝ with | Result.ok v => { apply := fun post x => post v } | Result.fail e => { apply := fun x epost => epost e } | Result.div => { apply := fun x x_1 => False }).apply post' epost'α:Type ?u.7post:α → Proppost':α → Propepost:Error → Propepost':Error → Prophepost:epost ⊑ epost'hpost:post ⊑ post'e✝:Error⊢ (match Result.fail e✝ with | Result.ok v => { apply := fun post x => post v } | Result.fail e => { apply := fun x epost => epost e } | Result.div => { apply := fun x x_1 => False }).apply post epost ⊑ (match Result.fail e✝ with | Result.ok v => { apply := fun post x => post v } | Result.fail e => { apply := fun x epost => epost e } | Result.div => { apply := fun x x_1 => False }).apply post' epost'α:Type ?u.7post:α → Proppost':α → Propepost:Error → Propepost':Error → Prophepost:epost ⊑ epost'hpost:post ⊑ post'⊢ (match Result.div with | Result.ok v => { apply := fun post x => post v } | Result.fail e => { apply := fun x epost => epost e } | Result.div => { apply := fun x x_1 => False }).apply post epost ⊑ (match Result.div with | Result.ok v => { apply := fun post x => post v } | Result.fail e => { apply := fun x epost => epost e } | Result.div => { apply := fun x x_1 => False }).apply post' epost' α:Type ?u.7post:α → Proppost':α → Propepost:Error → Propepost':Error → Prophepost:epost ⊑ epost'hpost:post ⊑ post'v✝:α⊢ (match Result.ok v✝ with | Result.ok v => { apply := fun post x => post v } | Result.fail e => { apply := fun x epost => epost e } | Result.div => { apply := fun x x_1 => False }).apply post epost ⊑ (match Result.ok v✝ with | Result.ok v => { apply := fun post x => post v } | Result.fail e => { apply := fun x epost => epost e } | Result.div => { apply := fun x x_1 => False }).apply post' epost'α:Type ?u.7post:α → Proppost':α → Propepost:Error → Propepost':Error → Prophepost:epost ⊑ epost'hpost:post ⊑ post'e✝:Error⊢ (match Result.fail e✝ with | Result.ok v => { apply := fun post x => post v } | Result.fail e => { apply := fun x epost => epost e } | Result.div => { apply := fun x x_1 => False }).apply post epost ⊑ (match Result.fail e✝ with | Result.ok v => { apply := fun post x => post v } | Result.fail e => { apply := fun x epost => epost e } | Result.div => { apply := fun x x_1 => False }).apply post' epost'α:Type ?u.7post:α → Proppost':α → Propepost:Error → Propepost':Error → Prophepost:epost ⊑ epost'hpost:post ⊑ post'⊢ (match Result.div with | Result.ok v => { apply := fun post x => post v } | Result.fail e => { apply := fun x epost => epost e } | Result.div => { apply := fun x x_1 => False }).apply post epost ⊑ (match Result.div with | Result.ok v => { apply := fun post x => post v } | Result.fail e => { apply := fun x epost => epost e } | Result.div => { apply := fun x x_1 => False }).apply post' epost' All goals completed! 🐙

The WPMonad instance proves that this interpretation is sound for pure and bind, in its fields WPMonad.pure_le_wp_pure and WPMonad.bind_le_wp_bind. For bind, both the definition of wp and the definition of bind need to be unfolded to expose the nested match structure that a case split and simp make short work of.

instance Result.instWPMonad : WPMonad Result Prop (Error → Prop) where toWP _ := inferInstance pure_le_wp_pure x post epost := PartialOrder.rel_refl bind_le_wp_bind x f post epost := α✝:Type ?u.6β✝:Type ?u.6x:Result α✝f:α✝ → Result β✝post:β✝ → Propepost:Error → Prop⊢ wp x (fun a => wp (f a) post epost) epost ⊑ wp (x >>= f) post epost α✝:Type ?u.6β✝:Type ?u.6f:α✝ → Result β✝post:β✝ → Propepost:Error → Propv✝:α✝⊢ wp (ok v✝) (fun a => wp (f a) post epost) epost ⊑ wp (ok v✝ >>= f) post epostα✝:Type ?u.6β✝:Type ?u.6f:α✝ → Result β✝post:β✝ → Propepost:Error → Prope✝:Error⊢ wp (fail e✝) (fun a => wp (f a) post epost) epost ⊑ wp (fail e✝ >>= f) post epostα✝:Type ?u.6β✝:Type ?u.6f:α✝ → Result β✝post:β✝ → Propepost:Error → Prop⊢ wp div (fun a => wp (f a) post epost) epost ⊑ wp (div >>= f) post epost α✝:Type ?u.6β✝:Type ?u.6f:α✝ → Result β✝post:β✝ → Propepost:Error → Propv✝:α✝⊢ wp (ok v✝) (fun a => wp (f a) post epost) epost ⊑ wp (ok v✝ >>= f) post epostα✝:Type ?u.6β✝:Type ?u.6f:α✝ → Result β✝post:β✝ → Propepost:Error → Prope✝:Error⊢ wp (fail e✝) (fun a => wp (f a) post epost) epost ⊑ wp (fail e✝ >>= f) post epostα✝:Type ?u.6β✝:Type ?u.6f:α✝ → Result β✝post:β✝ → Propepost:Error → Prop⊢ wp div (fun a => wp (f a) post epost) epost ⊑ wp (div >>= f) post epost All goals completed! 🐙

Finally, we also prove a soundness lemma similar to Except.of_eq_wp for Result.

theorem Result.of_eq_wp {α} {x prog : Result α} (h : prog = x) (P : Result α → Prop) (hwp : wp prog (fun a => P (.ok a)) (fun e => P (.fail e))) : P x := α:Type u_1x:Result αprog:Result αh:prog = xP:Result α → Prophwp:wp prog (fun a => P (ok a)) fun e => P (fail e)⊢ P x α:Type u_1prog:Result αP:Result α → Prophwp:wp prog (fun a => P (ok a)) fun e => P (fail e)⊢ P prog match prog with α:Type u_1prog:Result αP:Result α → Propa:αhwp:wp (ok a) (fun a => P (ok a)) fun e => P (fail e)⊢ P (ok a) All goals completed! 🐙 α:Type u_1prog:Result αP:Result α → Prope:Errorhwp:wp (fail e) (fun a => P (ok a)) fun e => P (fail e)⊢ P (fail e) All goals completed! 🐙 α:Type u_1prog:Result αP:Result α → Prophwp:wp div (fun a => P (ok a)) fun e => P (fail e)⊢ P div All goals completed! 🐙

The definition of the WP interpretation determines what properties can be derived from proved specifications via Result.of_eq_wp. This lemma connects the weakest precondition to the result of running the program.

The second step registers specification lemmas for the Rust primitives. As an example, here is a definition of UInt32 addition in Result that models integer overflow:

instance : MonadExcept Error Result where throw e := .fail e tryCatch x h := match x with | .ok v => pure v | .fail e => h e | .div => .div def addOp (x y : UInt32) : Result UInt32 := if x.toNat + y.toNat ≥ UInt32.size then throw .integerOverflow else pure (x + y)

There are two relevant specification lemmas to register:

@[spec] theorem Result.throw_spec {α} {post : α → Prop} {epost : Error → Prop} (e : Error) : ⦃epost e⦄ throw (m := Result) (α := α) e ⦃post; epost⦄ := ⟨PartialOrder.rel_refl⟩ @[spec] theorem addOp_ok_spec {x y} (h : x.toNat + y.toNat < UInt32.size) : ⦃True⦄ addOp x y ⦃fun r => r = x + y ∧ (x + y).toNat = x.toNat + y.toNat⦄ := x:UInt32y:UInt32h:x.toNat + y.toNat < UInt32.size⊢ ⦃ True ⦄ addOp x y ⦃ fun r => r = x + y ∧ (x + y).toNat = x.toNat + y.toNat ⦄ x:UInt32y:UInt32h:x.toNat + y.toNat < 4294967296h✝:4294967296 ≤ x.toNat + y.toNat⊢ ⊥ Error.integerOverflowx:UInt32y:UInt32h:x.toNat + y.toNat < 4294967296h✝:¬4294967296 ≤ x.toNat + y.toNat⊢ (x + y).toNat = x.toNat + y.toNat all_goals All goals completed! 🐙; try All goals completed! 🐙

This is already enough to prove the following example:

example : ⦃True⦄ (do let mut x ← addOp 1 3 for _ in [:4] do x ← addOp x 5 return x : Result UInt32) ⦃fun r => r.toNat = 24⦄ := ⊢ ⦃ True ⦄ do let x ← addOp 1 3 let __s ← forIn [:4] x fun x __s => let x := __s; do let __x ← addOp x 5 let x : UInt32 := __x pure (ForInStep.yield x) let x : UInt32 := __s pure x ⦃ fun r => r.toNat = 24 ⦄ x:UInt32a✝:x = 1 + 3 ∧ (1 + 3).toNat = UInt32.toNat 1 + UInt32.toNat 3⊢ x.toNat = 4 + 5 * [].lengthx:UInt32a✝²:x = 1 + 3 ∧ (1 + 3).toNat = UInt32.toNat 1 + UInt32.toNat 3a✝¹:UInt32a✝:a✝¹.toNat = 4 + 5 * (ForIn.toList [:4]).length⊢ a✝¹.toNat = 24x:UInt32a✝³:x = 1 + 3 ∧ (1 + 3).toNat = UInt32.toNat 1 + UInt32.toNat 3pref✝:List Natcur✝:Natsuff✝:List Nat_h✝:ForIn.toList [:4] = pref✝ ++ cur✝ :: suff✝b✝:UInt32a✝²:b✝.toNat = 4 + 5 * pref✝.lengtha✝¹:UInt32a✝:a✝¹ = b✝ + 5 ∧ (b✝ + 5).toNat = b✝.toNat + UInt32.toNat 5⊢ a✝¹.toNat = 4 + 5 * (pref✝ ++ [cur✝]).lengthx:UInt32a✝¹:x = 1 + 3 ∧ (1 + 3).toNat = UInt32.toNat 1 + UInt32.toNat 3pref✝:List Natcur✝:Natsuff✝:List Nat_h✝:ForIn.toList [:4] = pref✝ ++ cur✝ :: suff✝b✝:UInt32a✝:b✝.toNat = 4 + 5 * pref✝.length⊢ b✝.toNat + UInt32.toNat 5 < 4294967296⊢ UInt32.toNat 1 + UInt32.toNat 3 < 4294967296 all_goals All goals completed! 🐙 all_goals All goals completed! 🐙

Discharging Verification Conditions🔗

It is a priority of vcgen to break down programs into verification conditions that are straightforward to understand. The VCs are ordinary Lean goals, so any tactic can discharge them. When the assertion language is concrete, as for Id or StateM programs, the goals speak directly about result values and states, and grind applies.

During VC generation, vcgen internalizes the goal context into grind's E-graph. The with clause runs a single grind-mode step on every remaining VC inside that shared context, which avoids re-internalizing the context for every VC. The step finish closes a VC with grind's full automation.