18.4. Enabling mvcgen For Monads
If a monad is implemented in terms of monad transformers that are provided by the Lean standard library, such as ExceptT and StateT, then it should not require additional instances.
Other monads will require instances of WP, LawfulMonad, and WPMonad.
The tactic has been designed to support monads that model single-threaded control with state that might be interrupted; in other words, the effects that are present in ordinary imperative programming.
More exotic effects have not yet been investigated.
Once the basic instances are provided, the next step is to prove an adequacy lemma. This lemma should show that the weakest precondition for running the monadic computation and asserting a desired predicate is in fact sufficient to prove the predicate.
In addition to the definition of the monad, typical libraries provide a set of primitive operators. Each of these should be provided with a specification lemma. It may additionally be useful to make the internals of the state private, and export a carefully-designed set of assertion operators.
The specification lemmas for the library's primitive operators should ideally be precise specifications of the operators as predicate transformers. While it's often easier to think in terms of how the operator transforms an input state into an output state, verification condition generation will work more reliably when postconditions are completely free. This allows automation to instantiate the postcondition with the exact precondition of the next statement, rather than needing to show an entailment. In other words, specifications that specify the precondition as a function of the postcondition work better in practice than specifications that merely relate the pre- and postconditions.
Schematic Postconditions
The function double doubles a natural number state:
def double : StateM Nat Unit := do
modify (2 * ·)
Thinking chronologically, a reasonable specification is that value of the output state is twice that of the input state. This is expressed using a schematic variable that stands for the initial state:
theorem double_spec :
⦃ fun s => ⌜s = n⌝ ⦄ double ⦃ ⇓ () s => ⌜s = 2 * n⌝ ⦄ := n:Nat⊢ ⦃fun s => ⌜s = n⌝⦄ double ⦃PostCond.noThrow fun x s => ⌜s = 2 * n⌝⦄
n:Nat⊢ ⦃fun s => ⌜s = n⌝⦄ modify fun x => 2 * x ⦃PostCond.noThrow fun x s => ⌜s = 2 * n⌝⦄
mvcgen with All goals completed! 🐙
However, an equivalent specification that treats the postcondition schematically will lead to smaller verification conditions when double is used in other functions:
@[spec]
theorem better_double_spec {Q : PostCond Unit (.arg Nat .pure)} :
⦃ fun s => Q.1 () (2 * s) ⦄ double ⦃ Q ⦄ := Q:PostCond Unit (PostShape.arg Nat PostShape.pure)⊢ ⦃fun s => Q.fst () (2 * s)⦄ double ⦃Q⦄
Q:PostCond Unit (PostShape.arg Nat PostShape.pure)⊢ ⦃fun s => Q.fst () (2 * s)⦄ modify fun x => 2 * x ⦃Q⦄
All goals completed! 🐙
The first projection of the postcondition is its stateful assertion. Now, the precondition merely states that the postcondition should hold for double the initial state.
A Logging Monad
The monad LogM maintains an append-only log during a computation:
structure LogM (β : Type u) (α : Type v) : Type (max u v) where
log : Array β
value : α
instance : Monad (LogM β) where
pure x := ⟨#[], x⟩
bind x f :=
let { log, value } := f x.value
{ log := x.log ++ log, value }
It has a LawfulMonad instance as well.
The log can be written to using log, and a value and the associated log can be computed using LogM.run.
def log (v : β) : LogM β Unit := { log := #[v], value := () }
def LogM.run (x : LogM β α) : α × Array β := (x.value, x.log)
Rather than writing it from scratch, the WP instance uses PredTrans.pushArg.
This operator was designed to model state monads, but LogM can be seen as a state monad that can only append to the state.
This appending is visible in the body of the instance, where the initial state and the log that resulted from the action are appended:
instance : WP (LogM β) (.arg (Array β) .pure) where
wp
| { log, value } =>
PredTrans.pushArg (fun s => PredTrans.pure (value, s ++ log))
The WPMonad instance also benefits from the conceptual model as a state monad and admits very short proofs:
instance : WPMonad (LogM β) (.arg (Array β) .pure) where
wp_pure x := α:Type ?u.16σ:List (Type u)ps:PostShapex✝:PredTrans ps αy:PredTrans ps αQ:Assertion psβ:Type ?u.71α✝:Type ?u.71x:α✝⊢ wp (pure x) = pure x
All goals completed! 🐙
wp_bind := α:Type ?u.16σ:List (Type u)ps:PostShapex:PredTrans ps αy:PredTrans ps αQ:Assertion psβ:Type ?u.71⊢ ∀ {α β_1 : Type ?u.71} (x : LogM β α) (f : α → LogM β β_1),
(wp do
let a ← x
f a) =
do
let a ← wp x
wp (f a)
All goals completed! 🐙
The adequacy lemma has one important detail: the result of the weakest precondition transformation is applied to the empty array. This is necessary because the logging computation has been modeled as an append-only state, so there must be some initial state. Semantically, the empty array is the correct choice so as to not place items in a log that don't come from the program; technically, it must also be a value that commutes with the append operator on arrays.
theorem LogM.of_wp_run_eq {x : α × Array β} {prog : LogM β α}
(h : LogM.run prog = x) (P : α × Array β → Prop) :
(⊢ₛ wp⟦prog⟧ (⇓ v l => ⌜P (v, l)⌝) #[]) → P x := α:Type u_1β:Type u_1x:α × Array βprog:LogM β αh:prog.run = xP:α × Array β → Prop⊢ (⊢ₛ wp⟦prog⟧ (PostCond.noThrow fun v l => ⌜P (v, l)⌝) #[]) → P x
α:Type u_1β:Type u_1x:α × Array βprog:LogM β αh:prog.run = xP:α × Array β → Prop⊢ (⊢ₛ wp⟦prog⟧ (PostCond.noThrow fun v l => ⌜P (v, l)⌝) #[]) → P prog.run
α:Type u_1β:Type u_1x:α × Array βprog:LogM β αh:prog.run = xP:α × Array β → Proph':⊢ₛ wp⟦prog⟧ (PostCond.noThrow fun v l => ⌜P (v, l)⌝) #[]⊢ P prog.run
α:Type u_1β:Type u_1x:α × Array βprog:LogM β αh:prog.run = xP:α × Array β → Proph':P (prog.value, prog.log)⊢ P prog.run
All goals completed! 🐙
Next, each operator in the library should be provided with a specification lemma.
There is only one: log.
For new monads, these proofs must often break the abstraction boundaries of Hoare triples and weakest preconditions; the specifications that they provide can then be used abstractly by clients of the library.
theorem log_spec {x : β} :
⦃ fun s => ⌜s = s'⌝ ⦄ log x ⦃ ⇓ () s => ⌜s = s'.push x⌝ ⦄ := β:Types':Array βx:β⊢ ⦃fun s => ⌜s = s'⌝⦄ log x ⦃PostCond.noThrow fun x_1 s => ⌜s = s'.push x⌝⦄
All goals completed! 🐙
A better specification for log uses a schematic postcondition:
variable {Q : PostCond Unit (.arg (Array β) .pure)}
@[spec]
theorem log_spec_better {x : β} :
⦃ fun s => Q.1 () (s.push x) ⦄ log x ⦃ Q ⦄ := β:TypeQ:PostCond Unit (PostShape.arg (Array β) PostShape.pure)x:β⊢ ⦃fun s => Q.fst () (s.push x)⦄ log x ⦃Q⦄
All goals completed! 🐙
A function logUntil that logs all the natural numbers up to some bound will always result in a log whose length is equal to its argument:
def logUntil (n : Nat) : LogM Nat Unit := do
for i in 0...n do
log i
theorem logUntil_length : (logUntil n).run.2.size = n := n:Nat⊢ (logUntil n).run.snd.size = n
n:Natx:Unit × Array Nath:(logUntil n).run = x⊢ x.snd.size = n
n:Natx:Unit × Array Nath:(do
forIn (0...n) PUnit.unit fun i r => do
log i
pure (ForInStep.yield PUnit.unit)
pure PUnit.unit).run =
x⊢ x.snd.size = n
n:Natx:Unit × Array Nath:(do
forIn (0...n) PUnit.unit fun i r => do
log i
pure (ForInStep.yield PUnit.unit)
pure PUnit.unit).run =
x⊢ ⊢ₛ
wp⟦do
forIn (0...n) PUnit.unit fun i r => do
log i
pure (ForInStep.yield PUnit.unit)
pure PUnit.unit⟧
(PostCond.noThrow fun v l => ⌜(v, l).snd.size = n⌝) #[]
mvcgen invariants
· ⇓⟨xs, _⟩ s => ⌜xs.pos = s.size⌝
with All goals completed! 🐙