Tactics: what a tactic proof builds
Part 3 of Lean from the Inside. A tactic proof looks like a list of
instructions, which makes it feel like a different thing from a term. It isn't.
The kernel only checks terms, and a by block is a program that builds one.
This post looks at what that program builds, how it keeps track of the goals it
has left, what the main automation tactics produce, and how to write a tactic
of your own.
A tactic proof is a term
show_term runs a tactic and prints the term it produced:
example (p q : Prop) (hp : p) (hq : q) : p ∧ q := p:Propq:Prophp:phq:q⊢ p ∧ q
show_term p:Propq:Prophp:phq:q⊢ pp:Propq:Prophp:phq:q⊢ q p:Propq:Prophp:phq:q⊢ pp:Propq:Prophp:phq:q⊢ q All goals completed! 🐙
Two tactics, constructor and assumption (run on both goals by <;>),
produced the anonymous-constructor term ⟨hp, hq⟩, and show_term offers to
replace the script with exact and that term. A finished theorem doesn't
remember its tactics at all. #print shows only the term:
theorem swap (p q : Prop) : p ∧ q → q ∧ p := p:Propq:Prop⊢ p ∧ q → q ∧ p
p:Propq:Proph:p ∧ q⊢ q ∧ p
All goals completed! 🐙
#print swap
intro h became fun h =>, and the p q before the colon became two more
binders in front of it. h.2 and h.1 print as h.right and h.left, the
names of the fields of And.
Goals are metavariables
Part 2 showed that the elaborator works by creating metavariables and
filling them in. Tactics work the same way. When the elaborator reaches by,
it creates a metavariable whose type is the statement to prove. That
metavariable is the goal. Each tactic assigns part of the goal and leaves
new metavariables for the parts it didn't finish, and those become the new
goals.
run_tac runs a piece of TacticM code in the middle of a proof, so you can
watch this happen. The probe below saves the original goal, runs the two
tactics one at a time, and prints the goal with every assigned metavariable
replaced by its value:
open Lean Elab Tactic Meta in
example (p q : Prop) (hp : p) (hq : q) : p ∧ q := p:Propq:Prophp:phq:q⊢ p ∧ q
All goals completed! 🐙
At the start, the goal is an unassigned metavariable. constructor assigned it
to And.intro ?left ?right, a term with two new holes, named after the fields
of And. Those two holes are the goals constructor leaves for you. Then
assumption filled each hole with a hypothesis of the right type, and the
term was complete.
So the "proof state" in the editor is a view of the metavariables still
unassigned. A tactic proof is finished when none are left. At that point the
elaborator has an ordinary term, and the kernel checks it the same way it
checks a term you wrote by hand. The kernel never runs a tactic. A buggy tactic
can build a wrong term, but the kernel rejects any term that doesn't
type-check. A tactic can still close a goal with sorry or an axiom, which
the kernel accepts. Part 4 covers how #print axioms exposes that.
The goal list
A tactic doesn't work on one goal. It works on a list of them, held in the
tactic state, and most tactics act on the first goal, the main goal. That
list is what the editor's goal view and trace_state print:
example (p q : Prop) (hp : p) (hq : q) : p ∧ q := p:Propq:Prophp:phq:q⊢ p ∧ q
p:Propq:Prophp:phq:q⊢ pp:Propq:Prophp:phq:q⊢ q
p:Propq:Prophp:phq:q⊢ pp:Propq:Prophp:phq:q⊢ q
p:Propq:Prophp:phq:q⊢ p All goals completed! 🐙
p:Propq:Prophp:phq:q⊢ q All goals completed! 🐙
Each entry is one of the metavariables from the last section: case left is
?left and case right is ?right. The lines above ⊢ are the local
context of that metavariable, the hypotheses its value may use. In TacticM
the list is a plain List MVarId:
open Lean Elab Tactic Meta in
example (p q : Prop) (hp : p) (hq : q) : p ∧ q := p:Propq:Prophp:phq:q⊢ p ∧ q
p:Propq:Prophp:phq:q⊢ pp:Propq:Prophp:phq:q⊢ q
p:Propq:Prophp:phq:q⊢ pp:Propq:Prophp:phq:q⊢ q
all_goals All goals completed! 🐙
Sequencing vs <;>
A new line (or ;) runs the next tactic on whatever the goal list is now,
which means on the main goal. t <;> s instead runs s on every goal that
t produced. The difference shows up as soon as a tactic makes more than one
goal:
example (p q : Prop) (hp : p) (hq : q) : p ∧ q := p:Propq:Prophp:phq:q⊢ p ∧ q
p:Propq:Prophp:phq:q⊢ pp:Propq:Prophp:phq:q⊢ q; p:Propq:Prophp:phq:q⊢ q
assumption closed left and stopped. right was never touched, so the proof
ends with a goal still open. (Lean also warns that hq is unused, which is
the same mistake seen from the other side.) Written constructor <;> assumption,
as in the first section, the same assumption runs once per new goal.
Picking a goal
The other combinators choose which goals a tactic sees:
-
·focuses on the main goal and fails unless the tactic block under it closes that goal. That's why the first example above can't leave anything open by accident. -
case tag => tacfocuses on the goal with that tag, in any order. -
all_goals tacrunstacon each goal and fails if it fails on any.any_goals tacsucceeds if it worked on at least one. -
first | t₁ | t₂triest₁, and if it fails, rolls the state back and triest₂.
Two of these together, solving the goals out of order:
example (p q : Prop) (hp : p) (hq : q) : p ∧ q := p:Propq:Prophp:phq:q⊢ p ∧ q
p:Propq:Prophp:phq:q⊢ pp:Propq:Prophp:phq:q⊢ q
case right p:Propq:Prophp:phq:q⊢ q All goals completed! 🐙
case left p:Propq:Prophp:phq:q⊢ p All goals completed! 🐙
and one tactic that works on both goals, whichever hypothesis each needs:
example (p q : Prop) (hp : p) (hq : q) : p ∧ q := p:Propq:Prophp:phq:q⊢ p ∧ q
show_term p:Propq:Prophp:phq:q⊢ pp:Propq:Prophp:phq:q⊢ q p:Propq:Prophp:phq:q⊢ pp:Propq:Prophp:phq:q⊢ q first | All goals completed! 🐙 | All goals completed! 🐙
For left, exact hq fails (hq proves q, not p), so first restores the
goal and tries exact hp. The failed attempt leaves nothing behind, because
first saves the whole tactic state, including the metavariable assignments,
before each try. This backtracking is something terms can't express. It
belongs to the program that builds the term, and none of it is left in the
term itself.
What the big tactics build
constructor and assumption each fill one hole with something you could
have typed yourself. The automation tactics are more interesting, because the
term they produce is the whole explanation of why the goal holds. Three of
them build very different terms.
decide: let the kernel compute
theorem small : 10 < 20 := ⊢ 10 < 20 All goals completed! 🐙
#print small
The term is short because the work is left to the kernel. 10 < 20 has a
Decidable instance, and decide (10 < 20) is a Bool. The tactic claims
Eq.refl true : decide (10 < 20) = true, and of_decide_eq_true turns that
into a proof of 10 < 20. For the kernel to accept Eq.refl true at that
type, it has to reduce decide (10 < 20) to true itself, by unfolding the
instance. The proof is a certificate that says "run this and check you get
true". The log entry on rfl vs decide looks at the same mechanism from
the failing side.
That makes decide proofs cheap to store and potentially expensive to check:
the kernel redoes the computation every time the theorem is checked, and the
kernel is a slow evaluator. Part 4 covers native_decide, which hands the
computation to compiled code instead and changes what you have to trust.
simp: a chain of rewrites
theorem appNil (xs : List Nat) : (xs ++ []).length = xs.length := xs:List Nat⊢ (xs ++ []).length = xs.length
All goals completed! 🐙
simp? reports which lemmas simp used, here only List.append_nil
(xs ++ [] = xs). The term shows how one rewrite proves the goal:
#print appNil
Read it from the inside out:
-
List.append_nil xsprovesxs ++ [] = xs. -
congrArg List.lengthappliesList.lengthto both sides:(xs ++ []).length = xs.length. -
congrArg EqandcongrFun'lift that equation of numbers to an equation of propositions: the goal(xs ++ []).length = xs.lengthequals the propositionxs.length = xs.length. -
eq_self xs.lengthsays(xs.length = xs.length) = True, andEq.transjoins the two steps: the goal equalsTrue. -
of_eq_trueturns "this proposition equalsTrue" into a proof of it.
This is how simp works in general. It rewrites the goal, as a proposition,
step by step toward True, and records each step as an equation. The proof is
the chain of those equations. simp never proves your goal directly; it
proves that your goal is equal to something trivially true.
#print axioms appNil
Equations between propositions are where propext comes in: it is the axiom
that says two propositions that imply each other are equal. A simp proof can
depend on it even when, as here, nothing about the goal mentions it.
omega: a decision procedure with a certificate
omega decides linear arithmetic over Nat and Int. It looks for a
contradiction between the hypotheses and the negated goal, and then has to
justify what it found as a term:
theorem bump (x y : Nat) (h : x < y) : x + 1 ≤ y := x:Naty:Nath:x < y⊢ x + 1 ≤ y All goals completed! 🐙
#print bump
The top level is a proof by contradiction: assume a : ¬x + 1 ≤ y and derive
False. The derivation lives in an auxiliary theorem, bump._proof_1,
which omega added to the environment. Printing it gives about 2 KB of term,
so I'll describe it instead. It casts the hypotheses to Int, rewrites each
one into a linear combination over the atoms x and y (with lemmas from
Lean.Omega), combines them into a single constraint, and finishes with
not_sat'_of_isImpossible (of_decide_eq_true (id (Eq.refl true))). The last
step is the decide certificate from above: once the problem is a concrete
constraint, "this has no solution" is a computation the kernel can run.
The axiom lists show a second difference:
#print axioms bump
#print axioms small
The decide proof uses no axioms: it is computation that the kernel checks.
The omega proof uses propext and Quot.sound (from which Lean derives
function extensionality).
Neither is a reason to distrust it; both are among Lean's three standard
axioms. But "proved by a tactic" can mean quite different things to the
kernel, and #print axioms is the quickest way to see which. Part 4 starts
from here.
Writing your own tactic
A tactic is a value of type TacticM Unit: a program that can read and change
the goal list and, through MetaM, create and assign metavariables. elab
declares new tactic syntax and the program that runs it. Here is a small
version of assumption:
open Lean Elab Tactic Meta in
elab "my_assumption" : tactic => do
let goal ← getMainGoal
let target ← goal.getType
for decl in ← getLCtx do
if !decl.isImplementationDetail then
if ← isDefEq decl.type target then
goal.assign decl.toExpr
replaceMainGoal []
return
throwError "my_assumption: no hypothesis has type {target}"
example (p q : Prop) (hp : p) (hq : q) : p ∧ q := p:Propq:Prophp:phq:q⊢ p ∧ q
show_term p:Propq:Prophp:phq:q⊢ pp:Propq:Prophp:phq:q⊢ q p:Propq:Prophp:phq:q⊢ pp:Propq:Prophp:phq:q⊢ q All goals completed! 🐙
It uses only pieces from earlier in this post and in part 2:
-
getMainGoaltakes the first metavariable on the goal list, andgoal.getTypeis the proposition it stands for. -
getLCtxis that goal's local context, the hypotheses shown above⊢.isImplementationDetailskips hidden entries such as the one that refers to the theorem being proved. -
isDefEqis the unification check from part 2. If a hypothesis's type unifies with the goal,goal.assignfills the hole with that hypothesis. -
replaceMainGoal []removes the solved goal from the list.
Run under <;>, it builds the same ⟨hp, hq⟩ as the real assumption.
Because the test is isDefEq and not syntactic equality, it also accepts a
hypothesis whose type only reduces to the goal:
example (h : 2 + 2 = 4) : 4 = 4 := h:2 + 2 = 4⊢ 4 = 4
show_term All goals completed! 🐙
The hypothesis says 2 + 2 = 4 and the goal says 4 = 4. isDefEq reduces
2 + 2 to 4, so the two types match.
When no hypothesis fits, throwError reports the failure, and it appears like
any built-in tactic's error:
example (q : Prop) : q := q:Prop⊢ q
q:Prop⊢ q
A tactic can be wrong
Nothing forces goal.assign to be given a term of the right type. This tactic
fills every goal with True.intro:
open Lean Elab Tactic in
elab "liar" : tactic => do
let goal ← getMainGoal
goal.assign (mkConst ``True.intro)
replaceMainGoal []
theorem oops : 1 = 2 := ⊢ 1 = 2
All goals completed! 🐙
The tactic ran without complaint and the goal list ended empty, so as far as
the tactic framework knew, the proof was finished. The error comes from the
kernel, as the (kernel) prefix says: when oops was added to the
environment, the kernel type-checked the finished term and found a proof of
True where a proof of 1 = 2 was needed. This is the guarantee from
the first section in action. A buggy tactic can fail to prove something, but it can't make the
kernel accept a term of the wrong type. What it can do is close a goal with
sorry or an axiom, which the kernel accepts, and which #print axioms shows.
Macros
Not every tactic needs TacticM. A macro tactic is a rewrite of syntax into
other tactics, like the if macro in part 2:
macro "split_and" : tactic => `(tactic| constructor <;> assumption)
example (p q : Prop) (hp : p) (hq : q) : p ∧ q := p:Propq:Prophp:phq:q⊢ p ∧ q
show_term All goals completed! 🐙
split_and expands to constructor <;> assumption before anything runs, so
it builds the same term as the script it stands for. Macros are the right tool
for naming a combination of existing tactics. elab is for tactics that need
to inspect goals or build terms themselves.
What I took away
-
The kernel never sees tactics. A
byblock is a program whose output is a term, andshow_termand#printshow that term. -
Goals are metavariables. The proof state in the editor is the list of holes still unassigned, each with its own local context.
-
Combinators such as
<;>,firstandall_goalscontrol which goals a tactic runs on. Backtracking happens while the proof is built and leaves no trace in the term. -
Automation tactics leave different evidence.
decideasks the kernel to compute,simpbuilds a chain of rewrites that usespropext, andomegaadds an auxiliary theorem that ends in adecidestep. -
Writing a tactic takes a few
MetaMcalls: read the goal, unify, assign, update the goal list. A wrong tactic gets caught by the kernel, but a tactic that usessorryor an axiom does not.
Sources
-
Theorem Proving in Lean 4, the chapter on tactics, for the combinators and structuring tactics.
-
Lean language reference: the tactic proofs section, including
show_term,simp?andomega. -
Metaprogramming in Lean 4, the chapter on tactics, which covers
TacticM, the goal list and writing tactics withelabandmacro.
