Proving properties about code is an important task in ensuring that code is correct (see my previous post on model-checking). Unfortunately, it's sometimes impossible to automatically prove certain properties about code.
Can we manually prove properties about code?
Interactive Theorem Proving
Thankfully, people smarter than I have invented tools which allow us to write proofs about code that can be verified by a computer. But how do we write such a proof?
Let's say we have a very simple example of a function:
def times_two (n : Nat) : Nat :=
n * 2
And we want to know that for every input is less than or equal to its output:
def check_times_two (n: Nat) : n ≤ times_two n := sorry
To start a tactic proof, we use the by keyword:
def check_times_two (n: Nat) : n ≤ times_two n := by
The Lean language server then shows us the following:
Tactic state
1 goal
n : ℕ
⊢ n ≤ times_two n
telling us that we have a variable n (a natural number) and that we still need to show that n is always less than times_two of n. So how would we complete this proof on paper?
First, I would rewrite times_two n as n * 2. Then, I would probably prove the statement inductively. For n = 0, the statement holds, since 0 * 2 = 0. Then, for the inductive case (n + 1 ≤ (n + 1) * 2), we can rewrite our goal as
n + 1 ≤ n * 2 + 1 * 2 -> n + 1 ≤ n * 2 + 2 -> n ≤ n * 2 + 1, and since we know that n ≤ n * 2 (the inductive hypothesis), we know that n ≤ n * 2 + 1.
So how do we write that in Lean? Let's start by rewriting times_two n as n * 2:
def check_times_two (n: Nat) : n ≤ times_two n := by
unfold times_two
so our tactic state is now:
Tactic state
1 goal
n : ℕ
⊢ n ≤ n * 2
Then, let's start our inductive proof:
def check_times_two (n: Nat) : n ≤ times_two n := by
unfold times_two
induction n
so our tactic state is now:
Tactic state
2 goals
case zero
⊢ 0 ≤ 0 * 2
case succ
n✝ : ℕ
a✝ : n✝ ≤ n✝ * 2
⊢ n✝ + 1 ≤ (n✝ + 1) * 2
(in other words we need to prove the zero case and the inductive case).
First, let's prove the zero case:
def check_times_two (n: Nat) : n ≤ times_two n := by
unfold times_two
induction n
case zero => exact Nat.zero_le _
this proves that 0 ≤ 0 * 2 because (for natural numbers) 0 is less than or equal to all numbers (in this case 0 * 2).
Then, let's tackle the inductive case:
def check_times_two (n: Nat) : n ≤ times_two n := by
unfold times_two
induction n
case zero => exact Nat.zero_le _
case succ n ih => -- n + 1 ≤ (n + 1) * 2
rw [Nat.right_distrib] -- n + 1 ≤ n * 2 + 1 * 2
rw [Nat.one_mul] -- n + 1 ≤ n * 2 + 2
rw [@Nat.add_le_add_iff_right] -- n ≤ n * 2 + 1
exact Nat.le_add_right_of_le ih -- x ≤ y → x ≤ y + 1
This is essentially an exact translation of the paper proof we had into a proof that our computer can understand and verify the correctness of. But Lean is much more powerful than this. If we used automations, we could prove this much more easily:
def check_times_two (n: Nat) : n ≤ times_two n := by
unfold times_two
linarith
linarith is a tactic which checks for contradictions in
linear relations. Since nothing was contradictory, Lean
can accept this as a proof of the property.
Now, let's try our hand at a slightly more complex example:
def fibonacci (n: Nat) : Nat :=
if n = 0 then
1
else if n = 1 then
1
else
(fibonacci (n - 1)) + (fibonacci (n - 2))
def check_fibonacci (n: Nat) : fibonacci n ≤ fibonacci (n + 1) := sorry
Starting our proof gives the following tactic state:
Tactic state
1 goal
n : ℕ
⊢ fibonacci n ≤ fibonacci (n + 1)
Let's prove this inductively:
def check_fibonacci (n: Nat) : fibonacci n ≤ fibonacci (n + 1) := by
induction n
which gives us this tactic state:
Tactic state
2 goals
case zero
⊢ fibonacci 0 ≤ fibonacci (0 + 1)
case succ
n✝ : ℕ
a✝ : fibonacci n✝ ≤ fibonacci (n✝ + 1)
⊢ fibonacci (n✝ + 1) ≤ fibonacci (n✝ + 1 + 1)
Let's tackle the zero case:
def check_fibonacci (n: Nat) : fibonacci n ≤ fibonacci (n + 1) := by
induction n
case zero =>
simp [fibonacci]
For this part of the proof, we use simp. simp is a very powerful tactic which can automatically leverage hypotheses in your environment in order to simplify your goal. In this case, the original goal was fibonacci 0 ≤ fibonacci (0 + 1), which it rewrites to fibonacci 0 ≤ fibonacci 1. Then, since we allow it to use the definition of fibonacci (the [fibonacci] part), it reduces them to if n = 0 .... Finally, it evaluates both of those definitions, which in this case are both equivalent to 1, and 1 ≤ 1.
Finally, let's finish up with the inductive case:
def check_fibonacci (n: Nat) : fibonacci n ≤ fibonacci (n + 1) := by
induction n
case zero =>
simp [fibonacci]
case succ n ih =>
so the tactic state is now:
Tactic state
1 goal
n : ℕ
ih : fibonacci n ≤ fibonacci (n + 1)
⊢ fibonacci (n + 1) ≤ fibonacci (n + 1 + 1)
To prove this, let's reduce the fibonacci on the right hand side to fibonacci (n + 1) + fibonacci n, and we automatically know fibonacci (n + 1) ≤ fibonacci (n + 1) + fibonacci n since that's really x ≤ x + y. In Lean:
def check_fibonacci (n: Nat) : fibonacci n ≤ fibonacci (n + 1) := by
induction n
case zero =>
simp [fibonacci]
case succ n ih =>
conv => -- let's change what the goal looks like
rhs -- focus on the right-hand side of the goal
unfold fibonacci -- expand the definition of fibonacci
exact Nat.le.intro rfl -- the goal is now true by reflexivity