Chapter 10
Tactics: Building Proof Terms Interactively
Lean users rarely write terms like the one at the end of Chapter 9 by hand. They write a tactic script, a sequence of commands that build the term step by step while the system displays what remains to be proved. This chapter shows how the common tactics construct terms in our system, and why none of them needs to be trusted.
In Lean, the theorem of Chapter 9 is proved like this:
theorem zero_add (n : Nat) : 0 + n = n := by
induction n with
| zero => rfl
| succ k ih => rw [Nat.add_succ, ih]
Each tactic is a small program that builds part of the proof term. Tactics run outside the kernel. Their output is an ordinary term, which the kernel checks with Algorithm 5.1 like any other term.
Goals and proof states
A goal asks for a term of type in the context . The hole stands for the term still to be found; Lean calls it a metavariable. A proof state is a list of goals together with a partial proof term, a term that may contain holes.
To prove , we start from the partial term and the single goal . A tactic works on the first goal . It assigns to a term that may contain fresh holes, and replaces the goal by one new goal for each fresh hole. When no goals remain, the partial term contains no holes, and the kernel checks it against . A hole may sit under binders, as does in , and the term that fills it may use those bound variables. That is why each goal carries its own context.
Each tactic must make sure that its assignment has type in , assuming each fresh hole is later filled with a term of the type its goal states. We write , followed by the assignment.
A first example
The script examples/identity.leanmini proves the opening example of Chapter 1. The explorer below runs it one command at a time.
goal forall (P : Sort 0), P -> P intro P intro p exact p qed
Step through it with the buttons or the arrow keys. The middle panel is the list of goals after each command, and the right panel is the partial proof term, with the hole for the current goal highlighted. The three panels always agree: a tactic changes the goals because it assigns a term to a hole.
A list of goals alone would not be enough: after intro p, the goal does not remember that its solution must be wrapped in two s. The partial term records that.
Basic tactics
intro x. If normalizes to a -type, the tactic moves its argument into the context:
The assignment has the right type by T-Lam. The name must not be in ; otherwise the new assumption would shadow an existing one, which makes the kernel unsound (Chapter 11). Our intro refuses such a name with the message "binder name already in context"; Lean renames it.
exact t. The tactic closes the goal with a given term:
after running itself. The kernel would reject an ill-typed at the end anyway; checking early only gives a better error message.
apply f. If has type with , the tactic leaves the arguments as new goals:
The assignment has type by uses of T-App, and type by T-Conv. When the type of is a dependent , later and mention earlier arguments, and the tactic must find those arguments by matching against . Lean does this by unification, solving for metavariables. Our restricted version handles only non-dependent arrows, and reports "dependent apply requires elaboration/unification" otherwise. (With , that is, when the type of is already convertible to , apply f behaves like exact f.)
For example, transitivity of implication, the second exercise of Chapter 6, works backwards from the conclusion:
goal forall (P : Sort 0), forall (Q : Sort 0), forall (R : Sort 0), (P -> Q) -> (Q -> R) -> P -> R intro P intro Q intro R intro f intro g intro p apply g apply f exact p qed
After the six intros the goal is with . No hypothesis has type , so exact cannot close it, but ends in : apply g assigns and leaves . Likewise apply f assigns and leaves , which exact p closes. The body of the resulting term is . Note the difference between the two tactics: exact g fails, because has type , not , whereas apply g turns the missing argument of into a new goal.
change T'. The tactic replaces the goal by a definitionally equal one:
provided is a type and . It adds nothing to the term; it only changes how the goal is displayed, and T-Conv justifies it. change is needed because rw (below) finds subterms only up to alpha-equivalence: a subterm that appears only after computation must first be exposed with change, as in the example at the end of this chapter.
rfl. If normalizes to with , the tactic closes the goal:
The assignment has type , which is convertible to the goal. So rfl proves exactly the equations that hold by definitional equality (Chapter 9).
Induction
Let be a variable in , and let . The tactic induction n abstracts in the goal to form the motive, and produces one goal per constructor:
With the motive , the type of asks for and , and the result has type . These are convertible to the types of the two goals and to , by beta. The construction is correct only if the names are fresh, the level is right, and no other hypothesis mentions .
- The motive binds a fresh name , not : since is already in , writing would shadow it, which is the pitfall of Chapter 11. Likewise and must be fresh. (Our implementation names the motive's variable
ind_x, the successor's fieldkwhennis taken, and the hypothesisih.) - The level of the motive comes from the type of the goal, found by . For a proposition it is , and is induction.
- If another hypothesis mentions , for example , the new goals still contain about the original , and says nothing about it. The goals are then correct but may be unprovable. Lean's
inductionfirst moves such hypotheses into the goal (it reverts them), so that they become part of the motive. Our version requires that no later hypothesis mentions , and says "induction: revert dependent hypotheses first" otherwise.
The implementation is not specific to . It works for any inductive type without indices: it reads the recursor's type from the signature, peels one minor premise per constructor, and turns each into a goal whose context contains the constructor's fields and induction hypotheses. The script examples/generic-list.leanmini uses it on a user-declared list type.
Rewriting
Let , and let the goal be a proposition. The tactic rw h replaces by in the goal in three steps.
- Abstract. Choose a fresh and let be with every occurrence of replaced by . Occurrences are found up to alpha-equivalence, not up to conversion, and an occurrence under a binder that binds a free variable of is skipped, since there it denotes something else.
- Check the motive. Check that . This can fail, as shown below.
- Build the term. The new goal is , the goal with replaced by :
To see that the assignment has type , write for the motive. Since , the term is convertible to , which the identity inhabits. Transport along then gives , which is convertible to , and applying it to gives . Lean builds a different term, Eq.mpr (congrArg (fun x => C) h), from the same idea, and also tries to close the new goal with rfl; ours leaves that to an explicit rfl, so the two steps are visible in the script.
When the motive is ill typed. Abstracting can break the goal. Let , and suppose we run rw h on the goal , a statement about the proof . Abstracting gives , which is ill typed: has type , not , and itself cannot be abstracted. Step 2 fails, and Lean reports the same situation as "motive is not type correct". Without step 2 the tactic would produce a term that the kernel rejects, which is harmless for soundness but unhelpful for the user.
Example: 0 + n = n with tactics
In our system the proof of Chapter 9 becomes the following script, which is examples/zero-add.leanmini with the definition of included (the script writes add Nat.zero n for ).
def add : Nat -> Nat -> Nat := fun (m : Nat) => fun (n : Nat) => Nat.rec1 (fun (_n : Nat) => Nat) m (fun (k : Nat) => fun (r : Nat) => Nat.succ r) n goal forall (n : Nat), Eq Nat (add Nat.zero n) n intro n induction n rfl change Eq Nat (Nat.succ (add Nat.zero k)) (Nat.succ k) rw ih rfl qed
Watch the partial term grow: induction n inserts with two holes, rfl fills the first, change leaves the term alone and only rewrites the goal, and rw ih inserts the transport with one hole left for the final rfl. Delete the change line and apply the script to see rw fail to find its left-hand side.
# Addition by recursion on the second argument: # add m zero = m # add m (succ k) = succ (add m k) def add : Nat -> Nat -> Nat := fun (m : Nat) => fun (n : Nat) => Nat.rec1 (fun (_n : Nat) => Nat) m (fun (k : Nat) => fun (r : Nat) => Nat.succ r) n # add zero n = n is not definitional, so it needs induction on n. goal forall (n : Nat), Eq Nat (add Nat.zero n) n intro n induction n # Base case: add zero zero reduces to zero. rfl # Step case: unfold add zero (succ k) to succ (add zero k), then use ih. change Eq Nat (Nat.succ (add Nat.zero k)) (Nat.succ k) rw ih rfl qed
The term differs from the hand-written one of Chapter 9 in two places: it starts with because of intro, and its step uses the motive built by rw. Both are proofs of the same proposition, and the kernel does not care which one it receives. Try deleting the change line in the playground and read the error rw gives.
Why tactics need not be trusted
The kernel checks the final term, so tactics do not have to be correct for the system to be sound. A buggy tactic can fail, or it can produce an ill-typed term, which the kernel rejects; it cannot make the kernel accept a false theorem. The checks that tactics perform themselves, such as in exact or the motive check in rw, exist only to report errors early and clearly.
The trusted part of the system is therefore small: Algorithm 5.1, the reduction and comparison functions of Part III, and the signature-building procedure of Chapter 8. Everything in src/proof.rs and src/syntax.rs is outside it.
Lean's tactic framework is far richer than the one sketched here. It has metavariables solved by unification, an elaborator that fills in implicit arguments and expands notation such as 0 + n, an induction that reverts dependent hypotheses and handles indexed families, and automation such as simp. All of this lives on the untrusted side: Lean's kernel, like ours, only ever sees complete terms [de Moura and Ullrich 2021].
Exercises
Prove with tactics, by induction, and explain why rfl closes both cases.
Solution
This is examples/nat-reflexivity.leanmini: intro n, induction n, rfl, rfl, qed. The base goal is and the step goal is ; both sides are syntactically identical, so definitional equality holds trivially. The induction is unnecessary here (a single rfl after intro n would do) but shows the shape of the goals.
Write the term that rw produces when the goal is , the hypothesis is , and the context is .
Solution
Abstracting gives and the new goal . The assignment is with .
What to remember
- A proof state is a list of goals plus a partial term with holes. A tactic assigns a term to the first hole and opens one new goal per fresh hole.
introbuilds a ,exactplugs in a term,applybuilds an application with holes for the arguments,changeuses T-Conv,rfluses .inductionabstracts the variable into a motive and applies the recursor;rwabstracts the left side into a motive and applies transport.- The kernel re-checks the complete term, so tactics are outside the trusted base.