h5i

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 ⟨Γ⊢?m:T⟩\goal{\Gamma}{\hole{m}}{T} asks for a term of type TT in the context Γ\Gamma. The hole ?m\hole{m} 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 TT, we start from the partial term ?0\hole{0} and the single goal ⟨⋅⊢?0:T⟩\goal{\cdot}{\hole{0}}{T}. A tactic works on the first goal ⟨Γ⊢?m:T⟩\goal{\Gamma}{\hole{m}}{T}. It assigns to ?m\hole{m} 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 TT. A hole may sit under binders, as ?1\hole{1} does in λ(x:A). ?1\tLam{x}{A}{\hole{1}}, 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 TT in Γ\Gamma, assuming each fresh hole is later filled with a term of the type its goal states. We write ⟨Γ⊢?m:T⟩⇝new goals\goal{\Gamma}{\hole{m}}{T} \leadsto \text{new goals}, 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 PP does not remember that its solution must be wrapped in two λ\lambdas. The partial term records that.

Basic tactics

intro x. If TT normalizes to a Π\Pi-type, the tactic moves its argument into the context:

⟨Γ⊢?m:Π(y:A). B⟩  ⇝  ⟨Γ,x:A⊢?1:B[y↦x]⟩,?m:=λ(x:A). ?1.\goal{\Gamma}{\hole{m}}{\tPi{y}{A}{B}} \;\leadsto\; \goal{\Gamma, x : A}{\hole{1}}{\subst{B}{y}{x}}, \qquad \hole{m} \mathrel{:=} \tLam{x}{A}{\hole{1}}.

The assignment has the right type by T-Lam. The name xx must not be in dom(Γ)\dom(\Gamma); 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:

⟨Γ⊢?m:T⟩  ⇝  no goals,?m:=t,\goal{\Gamma}{\hole{m}}{T} \;\leadsto\; \text{no goals}, \qquad \hole{m} \mathrel{:=} t,

after running Check(Γ,t,T)\Check(\Gamma, t, T) itself. The kernel would reject an ill-typed tt at the end anyway; checking early only gives a better error message.

apply f. If ff has type A1→⋯→An→BA_1 \to \cdots \to A_n \to B with B≡TB \conv T, the tactic leaves the arguments as new goals:

⟨Γ⊢?m:T⟩  ⇝  ⟨Γ⊢?1:A1⟩,…,⟨Γ⊢?n:An⟩,?m:=f  ?1⋯?n.\goal{\Gamma}{\hole{m}}{T} \;\leadsto\; \goal{\Gamma}{\hole{1}}{A_1}, \ldots, \goal{\Gamma}{\hole{n}}{A_n}, \qquad \hole{m} \mathrel{:=} f\;\hole{1} \cdots \hole{n}.

The assignment has type BB by nn uses of T-App, and type TT by T-Conv. When the type of ff is a dependent Π\Pi, later AiA_i and BB mention earlier arguments, and the tactic must find those arguments by matching BB against TT. 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 n=0n = 0, that is, when the type of ff is already convertible to TT, 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 ⟨Γ⊢?6:R⟩\goal{\Gamma}{\hole{6}}{R} with Γ=P,Q,R:Sort 0,  f:P→Q,  g:Q→R,  p:P\Gamma = P, Q, R : \Sort{0},\; f : P \to Q,\; g : Q \to R,\; p : P. No hypothesis has type RR, so exact cannot close it, but gg ends in RR: apply g assigns ?6:=g  ?7\hole{6} \mathrel{:=} g\;\hole{7} and leaves ?7:Q\hole{7} : Q. Likewise apply f assigns ?7:=f  ?8\hole{7} \mathrel{:=} f\;\hole{8} and leaves ?8:P\hole{8} : P, which exact p closes. The body of the resulting term is g  (f  p)g\;(f\;p). Note the difference between the two tactics: exact g fails, because gg has type Q→RQ \to R, not RR, whereas apply g turns the missing argument of gg into a new goal.

change T'. The tactic replaces the goal by a definitionally equal one:

⟨Γ⊢?m:T⟩  ⇝  ⟨Γ⊢?1:T′⟩,?m:=?1,\goal{\Gamma}{\hole{m}}{T} \;\leadsto\; \goal{\Gamma}{\hole{1}}{T'}, \qquad \hole{m} \mathrel{:=} \hole{1},

provided T′T' is a type and T′≡TT' \conv T. 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 TT normalizes to Equ  A  a  b\Eq{u}\;A\;a\;b with a≡ba \conv b, the tactic closes the goal:

⟨Γ⊢?m:Equ  A  a  b⟩  ⇝  no goals,?m:=reflu  A  a.\goal{\Gamma}{\hole{m}}{\Eq{u}\;A\;a\;b} \;\leadsto\; \text{no goals}, \qquad \hole{m} \mathrel{:=} \refl{u}\;A\;a.

The assignment has type Equ  A  a  a\Eq{u}\;A\;a\;a, which is convertible to the goal. So rfl proves exactly the equations that hold by definitional equality (Chapter 9).

Induction

Let n:Natn : \Nat be a variable in Γ\Gamma, and let Γ⊢T:Sort u\Gamma \vdash T : \Sort{u}. The tactic induction n abstracts nn in the goal to form the motive, and produces one goal per constructor:

⟨Γ⊢?m:T⟩  ⇝  ⟨Γ⊢?z:T[n↦zero]⟩,  ⟨Γ,k:Nat,ih:T[n↦k]⊢?s:T[n↦succ  k]⟩,?m:=Nat.recu  (λ(z:Nat). T[n↦z])  ?z  (λ(k:Nat). λ(ih:T[n↦k]). ?s)  n.\begin{array}{l} \goal{\Gamma}{\hole{m}}{T} \;\leadsto\; \goal{\Gamma}{\hole{z}}{\subst{T}{n}{\zero}},\; \goal{\Gamma, k : \Nat, \mathit{ih} : \subst{T}{n}{k}}{\hole{s}}{\subst{T}{n}{\suc\;k}}, \\[4pt] \hole{m} \mathrel{:=} \NatRec{u}\;(\tLam{z}{\Nat}{\subst{T}{n}{z}})\;\hole{z}\;(\tLam{k}{\Nat}{\tLam{\mathit{ih}}{\subst{T}{n}{k}}{\hole{s}}})\;n. \end{array}

With the motive M=λ(z:Nat). T[n↦z]M = \tLam{z}{\Nat}{\subst{T}{n}{z}}, the type of Nat.recu  M\NatRec{u}\;M asks for M  zeroM\;\zero and Π(k:Nat). M  k→M  (succ  k)\tPi{k}{\Nat}{M\;k \to M\;(\suc\;k)}, and the result has type M  nM\;n. These are convertible to the types of the two goals and to TT, by beta. The construction is correct only if the names are fresh, the level uu is right, and no other hypothesis mentions nn.

  • The motive binds a fresh name zz, not nn: since nn is already in Γ\Gamma, writing λ(n:Nat). T\tLam{n}{\Nat}{T} would shadow it, which is the pitfall of Chapter 11. Likewise kk and ih\mathit{ih} must be fresh. (Our implementation names the motive's variable ind_x, the successor's field k when n is taken, and the hypothesis ih.)
  • The level uu of the motive comes from the type of the goal, found by InferSort\InferSort. For a proposition it is 00, and Nat.rec0\NatRec{0} is induction.
  • If another hypothesis mentions nn, for example h:n=2h : n = 2, the new goals still contain hh about the original nn, and ih\mathit{ih} says nothing about it. The goals are then correct but may be unprovable. Lean's induction first 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 nn, and says "induction: revert dependent hypotheses first" otherwise.

The implementation is not specific to Nat\Nat. 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 h:Equ  A  a  bh : \Eq{u}\;A\;a\;b, and let the goal TT be a proposition. The tactic rw h replaces aa by bb in the goal in three steps.

  1. Abstract. Choose a fresh xx and let CC be TT with every occurrence of aa replaced by xx. Occurrences are found up to alpha-equivalence, not up to conversion, and an occurrence under a binder that binds a free variable of aa is skipped, since there it denotes something else.
  2. Check the motive. Check that Γ,x:A⊢C:Sort 0\Gamma, x : A \vdash C : \Sort{0}. This can fail, as shown below.
  3. Build the term. The new goal is C[x↦b]\subst{C}{x}{b}, the goal with aa replaced by bb:
    ⟨Γ⊢?m:T⟩  ⇝  ⟨Γ⊢?1:C[x↦b]⟩,?m:=Eq.ndrecu,0  A  a  (λ(x:A). C→T)  (λ(p:T). p)  b  h  ?1.\goal{\Gamma}{\hole{m}}{T} \;\leadsto\; \goal{\Gamma}{\hole{1}}{\subst{C}{x}{b}}, \qquad \hole{m} \mathrel{:=} \EqRec{u,0}\;A\;a\;(\tLam{x}{A}{C \to T})\;(\tLam{p}{T}{p})\;b\;h\;\hole{1}.

To see that the assignment has type TT, write N=λ(x:A). C→TN = \tLam{x}{A}{C \to T} for the motive. Since C[x↦a]=T\subst{C}{x}{a} = T, the term N  aN\;a is convertible to T→TT \to T, which the identity λ(p:T). p\tLam{p}{T}{p} inhabits. Transport along hh then gives N  bN\;b, which is convertible to C[x↦b]→T\subst{C}{x}{b} \to T, and applying it to ?1\hole{1} gives TT. 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 aa can break the goal. Let Γ=n:Nat,  h:n=2,  q:n=0\Gamma = n : \Nat,\; h : n = 2,\; q : n = 0, and suppose we run rw h on the goal Eq0  (n=0)  q  q\Eq{0}\;(n = 0)\;q\;q, a statement about the proof qq. Abstracting nn gives C=Eq0  (x=0)  q  qC = \Eq{0}\;(x = 0)\;q\;q, which is ill typed: qq has type n=0n = 0, not x=0x = 0, and qq 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 add\add included (the script writes add Nat.zero n for 0+n0 + n).

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 Nat.rec0\NatRec{0} 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 λ(n:Nat). …  n\tLam{n}{\Nat}{\ldots\;n} 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 Check\Check 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 Π(n:Nat). n=n\tPi{n}{\Nat}{n = n} 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 0=00 = 0 and the step goal is succ  k=succ  k\suc\;k = \suc\;k; 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 Eq  Nat  a  c\Eq{}\;\Nat\;a\;c, the hypothesis is h:a=bh : a = b, and the context is a,b,c:Nata, b, c : \Nat.

Solution

Abstracting aa gives C=(x=c)C = (x = c) and the new goal b=cb = c. The assignment is Eq.ndrec1,0  Nat  a  (λ(x:Nat). (x=c)→(a=c))  (λ(p:a=c). p)  b  h  ?1\EqRec{1,0}\;\Nat\;a\;(\tLam{x}{\Nat}{(x = c) \to (a = c)})\;(\tLam{p}{a = c}{p})\;b\;h\;\hole{1} with ?1:b=c\hole{1} : b = c.

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.
  • intro builds a λ\lambda, exact plugs in a term, apply builds an application with holes for the arguments, change uses T-Conv, rfl uses refl\refl{}.
  • induction abstracts the variable into a motive and applies the recursor; rw abstracts the left side into a motive and applies transport.
  • The kernel re-checks the complete term, so tactics are outside the trusted base.