by Andre Videla
Tactics are programs that, given a context and a goal, return a new goal and context. As a fully stateful program, this view of tactics is rooted in the imperative programming paradigm, where program semantics can only be known after we run our programs. Our work at Glaive is to study tactics such that we do not have to guess what a tactic will do, we can infer that information directly from its type.
In the same way as you wouldn’t give a function String -> String a value of
type Int, there is no sense in applying a tactic to a goal for which it
cannot meaningfully make progress. So, what is the type of a tactic?
If we look at a statement such as $37 + (a × b) = 37 + (b × a)$ we can tell, as a human that it is true, but convincing a computer that it is takes some time and effort.
Given some tactics applying congruence, and symmetry, we can derive this proof:
def prob1 (a b : Nat) : 37 + a * b = 37 + b * a := by
rw [Nat.mul_comm a b]
This is not the only proof! We can also write the following:
def prob2 (a b : Nat) : 37 + a * b = 37 + b * a := by
congr 1
exact Nat.mul_comm a b
Since there are multiple ways of proving the same thing, how do we know we are writing a “good” proof, what’s the difference between one that is longer and one that is shorter, how can we qualitatively evaluate different proof strategies? Until now, those questions are answered by social conventions where different challenges, like readability, maintenance, logical foundations, would inform what kind of proof is expected to be “good”.
Our approach focuses on the proof tree produced by a series of tactics. A good proof will produce a richly-typed proof tree with nodes carrying a lot of information about the current proof state and branches.
To demonstrate the capabilities of typed tactics, we implement a well-known logical system: Arithmetic on natural numbers.
This system has a very simple language to combine numbers via three constructors:
successor, addition and multiplication. Along with two base terms, one for named
variable and one for 0.
inductive Term where
| Atom : String → Term
| Zero : Term
| Succ : Term → Term
| Plus : Term → Term → Term
| Mult : Term → Term → Term
We then define the basic building bloc for a proof system equating two Terms.
A term is equal to itself, two equalities can be placed end-to-end if they share
a common term, and equalities are symmetric.
inductive Equation : Term → Term → Type where
/-- Every term is equal to itself. -/
| Refl : Equation t t
/-- Equality is transitive. -/
| Trans : Equation r s → Equation s t → Equation r t
/-- Equality is symmetric. -/
| Sym : Equation t s → Equation s t
To this we add the equational theory of natural numbers we all expect, addition is symmetric, zero is a neutral element, etc1.
/-- Congruence for `Succ`. -/
| CongS : Equation t s → Equation (Term.Succ t) (Term.Succ s)
/-- Congruence for addition. -/
| CongPlus : Equation t s → Equation x y → Equation (Term.Plus t x) (Term.Plus s y)
/-- Plus is symmetric. -/
| SymPlus : Equation (Term.Plus t s) (Term.Plus s t)
/-- Mult is symmetric. -/
| AssocPlus :
Equation (Term.Plus t (Term.Plus r s))
(Term.Plus (Term.Plus t r) s)
/-- zero is neutral for + -/
| ZeroPlusNeutral : Equation t s → Equation (Term.Plus t Term.Zero) s
Tactics historically can be seen as a function manipulating goals and theorems. First by splitting the goal into smaller subgoals, and then by reconstructing the proof of the original goal by combining the proofs collected by each sub-goal.
tactic : Type
tactic = goal → List goal × (List thm → thm)
The continuation List thm → thm combines sub-proof corresponding
to each solved sub-goal into a bigger proof for the goal originally
given in argument.
This approach has two shortcomings, first there is no way to handle errors when a goal cannot be split by the tactic into subgoals. Second, there is no relation between the list of subgoals generated and the list of theorems collected.
Viewing tactics as dependent lenses solve those problems by giving a precise type to a tactic.
tactic : Type
tactic = Eq =&> Any.Maybe (All.List Eq)
Expanding the definition of =&> give us a term that looks exactly like
our traditional tactics but with a very precise relationship between inputs
and output brought in by dependent types:
tactic : Type
tactic = (g : goal) → Σ Maybe (List goal) , Any All
This change is not fluff, it makes inconsistent states unrepresentable. If the tactic does not apply, then no theorems can be extracted or expected. If the tactic generates 3 sub-goals then 3 sub-theorems will be generated, always.
The Maybe and List functors on containers are monads, and their composition
distribute, suggesting a more generic SubGoals monad on container such that
failure is represented by a missing value, and a void proof, and success wraps
a list of subgoal, for each of which we carry a proof.
abbrev Subgoal a := Any.Maybe (Forall a)
By paring terms and equations we define the container of goals and proofs for our system.
def EqProblem : Container :=
Container.mkC (Term × Term) (fun t => Equation t.π1 t.π2)
And the type of tactics is given by kleisli endomorphisms of the Subgoal monad.
abbrev Tactic : Type := EqProblem =&> Subgoals EqProblem
Using this one type we can define all the tactics in our theorem prover:
/-- The symmetry tactic: solving a problem reduces to solving its symmetric. Always applies. -/
def sym : Tactic :=
!! fun x => some [(x.π2, x.π1)] ## fun (Aye (.Cons y _)) => .Sym y
/-- Equality is transitive. -/
def trans (t : Term) : Tactic :=
!! fun x => some [(x.π1, t), (t, x.π2)]
## fun (Aye (.Cons y (.Cons z _))) => Equation.Trans y z
You will notice that trans is takes a Term in argument, this property turns
it into a meta-tactic, a tactic that needs information from the context or
the user to be run. As such, transitivity will be primarily used as a building
block for more sophisticated tactics. Let’s start building those tactics!
/-- Associativity can be applied on the left of an equality
a + (b + c) = t -> (a + b) + c = t -/
def assocLeftPlus : Tactic :=
!! fun x =>
match x with
| (.Plus a (.Plus b c), t) =>
some [(.Plus (.Plus a b) c, t)] ##
fun (Aye x) => Equation.Trans Equation.AssocPlus x.head
| (.Plus (.Plus a b) c, t) =>
some [(.Plus a (.Plus b c), t)] ##
fun (Aye x) => Equation.Trans (Equation.Sym Equation.AssocPlus) x.head
| _ => none ## fun e => nomatch e
This tactic analysis the goal and if it has the shape a + (b + c) = t it
will create a sub-goal (a + b) + c = t and combine the sub-proof using
transitivity and associativity.
All other tactics can be written in this style, resulting in a list of
Tactics that we can execute at each step of our theorem prover.
Each tactic gives us a function that will take a goal and return a new list of subgoals if it applies, or nothing if it does not apply. This decision procedure already help analyse a goal and give us the list of tactics that apply and their associated update subgoals. In this setting a proof state is characterised by the list of goals to solve and the context is given by the list of tactics that apply to the current goal.
Our initial state contains all goals and all tactics:
structure ProofState where
goals : List Goal
tactics : List Tactic
For a given goal, we can update the list of tactics in context by filtering the ones that do not apply:
def filterTactics (goal : Goal) (tactics : List (Named Tactic)) : List (Named (List Goal)) :=
List.filterMap (fun t => match t.val.fn goal with
| ⟨ some x , _ ⟩ => some ⟨ t.name, x ⟩
| ⟨ none , _ ⟩ => none
) tactics
Here we do two steps at once because we also recover the list of new goals resulting from applying the tactics. Not only we are able to show relevant tactics, we also get a preview of the next goal if it were to be applied2.
result in the terminal.
let filtered := filterTactics xs
(p.primitives ++
List.map (Functor.map tacticsFromAxioms) p.axioms
++ tacticsFromHighTactics p.axioms p.higher
)
let (newGoals, _) ← selectFromList toString filtered
IO.println s!"new goal {toString newGoals}"
The basic proof loop takes the selected goal, display the available tactics to make progress toward a proof and once a tactic has been chosen, update the goal with the new subgoals generated by applying this tactic.
Here is an example of running the prover in the terminal:
current subGoal:
((37 + x) + q) = (37 + (q + x))
[0] symmetry: [(37 + (q + x)) = ((37 + x) + q)]
[1] sym+: [(q + (37 + x)) = (37 + (q + x))]
[2] assoc+: [(37 + (x + q)) = (37 + (q + x))]
[3] congruence+: [(37 + x) = 37, q = (q + x)]
> 2
new goal assoc+: [(37 + (x + q)) = (37 + (q + x))]
current subGoal:
(37 + (x + q)) = (37 + (q + x))
[0] symmetry: [(37 + (q + x)) = (37 + (x + q))]
[1] sym+: [((x + q) + 37) = (37 + (q + x))]
[2] assoc+: [((37 + x) + q) = (37 + (q + x))]
[3] congruence+: [37 = 37, (x + q) = (q + x)]
[4] refl: [(x + q) = (q + x)]
> 4
new goal refl: [(x + q) = (q + x)]
current subGoal:
(x + q) = (q + x)
[0] symmetry: [(q + x) = (x + q)]
[1] sym+: [(q + x) = (q + x)]
[2] congruence+: [x = q, q = x]
> 1
new goal sym+: [(q + x) = (q + x)]
current subGoal:
(q + x) = (q + x)
[0] symmetry: [(q + x) = (q + x)]
[1] sym+: [(x + q) = (q + x)]
[2] reflPrim: []
[3] congruence+: [q = q, x = x]
[4] refl: []
[5] refl: [x = x]
> 4
new goal refl: []
QED
At each step the list of tactics is inferred by pre-computing the next goal and
keeping only the tactics that match. Tactics that fail return none in their
forward part, which breaks down the goal. Tactics that succeed and entirely solve
the goal return some [] which is what we see with the last use of refl.
Tactics that succeed but return multiple subgoals return some [ x, y ] and
each subgoal x and y is shown in the output, this is why congruence+ has
two elements in the list of output goals.
So far the types we gave are pretty uniform, everything is a Tactic, but
we can give even more precise types.
The Tactic monad gives us a very expressive way to compose tactics accurately but we can do better. By describing exactly how many subgoals a tactic produces we can wire together tactics and clearly see how the proof tree is built from smaller more primitive tactics.
We implemented a basic tactical theorem prover using the Subgoal monad but
we haven’t really shown the benefits of containers in terms of how they help
give more precise type to tactics, right now, all our tactics have the same
type, a lens EqProblem =&> Subgoal EqProblem, but we can do better.
The first distinction to make is between tactics that can fail and tactics that
can never fail. There are tactics that are always available because their
application do not depend on the current goal. Sym is one of them, it never
fails because the goal a = b can always be flipped to become b = a
applySym : EqProblem =&> EqProblem
If you are familiar with reversible rules in sequent calculus this is one exmaple of the type of a reversible rule. It can never fail, and it can always be undone.
Another tactic with a more precise type is reflexivity. A proof by reflexivity never creates any additional subgoals. Either it succeeds and finishes, or it does not apply at all. Its type is therefore:
applyRefl : EqProblem =&> Maybe I
We use I, the tensor unit, to indicate a leaf-tactic with no further subgoal,
this is isomorphic to an empty list of subgoals.
Finally, a third kind of tactic is one that generates multiple subgoals, in our system, transitivity generates 2 subgoals, it also never fails, type is therefore:
applyTrans : (t : Term) -> EqProblem =&> EqProblem ⊗ EqProblem
We now see why the All.List monad is the correct monad for representing all subgoals
| Type | List Term |
|---|---|
| I | [] |
| Eq | [ x ] |
| Eq ⊗ Eq | [ x , y ] |
Giving types to tactics reveals the structure of the proof tree that we are building in a way that using lists of subgoals cannot represent. Lists of subgoals are useful to building a theorem prover by giving a uniform type to each tactic and treating them all the same. But we want to recover the rich structure of tactics to help the user drive the proof in a productive direction.
We can further refine the type of tactics by parameterising them by a predicate
indicating what shape the goal. Most goals will have multiple shapes such that multiple
tactics that can be applied. Typically a goal a + b = c + d is shaped like a congruence,
a goal a + (b + c) = (a' + b') + c' is shaped both like a congruence and an associativity
tactic.
In addition to give the type of a tactic in term of proof-tree or sub-goal generation, we
also want to infer the shape of the goal and use that information to further help with
tactic selection. A goal that is symmetry-shaped can have the symmetry tactic applied to it.
Each goal can have multiple compatible shapes, those shapes emerge from the head of
the expression tree of the goal. For example the goal a + (b + c) = (a + c) + b
expression has trees
┌─(+)─┐ ┌─(+)─┐
│ │ │ │
a ┌─(+)─┐ = ┌─(+)─┐ b
│ │ │ │
b c a c
Anything that has head shape:
┌─(+)─┐ = ┌─(+)─┐
is “congruence-shaped”.
Similarly anything with a left tree
┌─(+)─┐
│ │
a ┌─(+)─┐ = x
│ │
b c
is “associative-shaped”.
This is how we can infer that the goal a + (b + c) = (a + c) + b has
shapes congruence ∧ assoc+ ∧ assoc1+ ∧ flip. In fact we’re built a demo
that will, given a list of shapes deciders provide the type and shape of
the prover’s goal and then propose tactics that match the shape of the goal.
The shapes of a tactic form a lattice where the top of the lattice contains the most general shape and its children contain more and more sophisticated shapes
flowchart TD
A --> Cong1P["Cong1+\na+b=c"]
A["Sym\na=b"] --> B["Refl\na=a"]
A --> Cong1M["Cong1×\na×b=c"]
Cong1P --> AssocPL["assocR+\na+(b+c)=d"]
Cong1P --> AssocPR["assocL+\n(a+b)+c=d"]
AssocPR --> AssocP["assoc+\na+(b+c)=(a+b)+c"]
AssocPL --> AssocP
Cong1P --> DistribP["distrib+\n(a×b)+(a×c)=d"]
Cong1M -->DistribM["distrib×\na×(b+c)=d"]
DistribM --> Distrib["distrib\n(a×b)+(a×c)=a×(b+c)"]
DistribP --> Distrib
Cong1M --> AssocMR["assocR×\na×(b×c)=d"]
Cong1M --> AssocML["assocL×\n(a×b)×c=d"]
AssocMR --> AssocM["assocR×\na×(b×c)=(a×b)×c"]
AssocML --> AssocM
This lattice suggest a subtyping relation between tactic shapes, an area of research we plan to explore in the coming months.
Finally, once a shape has been identified, we obtain a deterministic tactic that can never fail. The shape already provides all the information necessary to run the tactic, there is no more guessing or error handling.
The congruence tactic for example takes evidence that the goal is congruence-shaped and return the appropriate list of subgoals:
applyCong : IsCong g -> EqShape g =&> All.List EqGen
In general a tactic with shapes is given by a shape predicate Goal -> Type and
a lens EqShape g =&> All.List EqGen. Where EqShape g is a container with
a singleton g in the base.
Our implementation is based on a prover’s state carrying the set of shapes any goal can have, for each shape, a tactic that will reduce the goal, and the list of goals left to solve.
The theorem prover then analyses the shape of the goal and displays it, along with the tactics that will make progress for this goal.
Here is an example where the goal is given by the expression a + (b + c) = (a + c) + b.
current goal (a + (b + c)) = ((a + c) + b)
goal shape : congruence ∧ assoc+ ∧ assoc1+ ∧ flip
candidate tactics:
[0]: congruence: ["a = (a + c)", "(b + c) = b"]
[1]: assoc+: ["a = a", "b = c", "c = b"]
[2]: assoc1+: ["((a + b) + c) = ((a + c) + b)"]
[3]: flip: ["((a + c) + b) = (a + (b + c))"]
By picking flip we make use of symmetry of equality proofs.
current goal ((a + c) + b) = (a + (b + c))
goal shape : congruence ∧ assoc+ ∧ assoc1+ ∧ flip
candidate tactics:
[0]: congruence: ["(a + c) = a", "b = (b + c)"]
[1]: assoc+: ["a = a", "b = c", "c = b"]
[2]: assoc1+: ["(a + (c + b)) = (a + (b + c))"]
[3]: flip: ["(a + (b + c)) = ((a + c) + b)"]
This step is necessary to apply assoc1+ to the expression (a + c) + b
which results in a + (c + b).
current goal (a + (c + b)) = (a + (b + c))
goal shape : congruence ∧ assoc1+ ∧ flip
candidate tactics:
[0]: congruence: ["(c + b) = (b + c)"]
[1]: assoc1+: ["((a + c) + b) = (a + (b + c))"]
[2]: flip: ["(a + (b + c)) = (a + (c + b))"]
We can now eliminate a + by congruence which is identified by the congruence shape
current goal (c + b) = (b + c)
goal shape : congruence ∧ symmetry+ ∧ flip
candidate tactics:
[0]: congruence: ["c = b", "b = c"]
[1]: symmetry+: []
[2]: flip: ["(b + c) = (c + b)"]
1
QED
The final step is an application of symmetry of + which resolves the goal and
completes the proof.
We haven’t said anything about AI yet, our mission is to further the understanding of mathematics & formal systems through the lens of AI capabilities. We are seeing a mutually beneficial relationship between AI understanding and human understanding of mathematics. In the last few months, we have been deeply focused on how the two relate and how we can help both human and AI interact with more sophisticated tools to write and understand mathematics.
Giving types to tactics greatly reduces the search space for a human to study and pick the next tactic to use. This feature grealy benefits machines too as we can mechanically constrain their input to known-good interactions via Stellar. What is more, our analysis of tactics can generate well-typed traces (proof trees) which contain vastly more semantic information than the purely syntactic data that a lean file contains.
Our experiments with AI go much deeper than merely training or connecting an existing model to our lens-based proof infrastructure. By using structured generation we expect to obtain a model that can directly think and speak in terms of tactics and proof state manipulation, without the overhead of post-hoc parsing or probabilistic conditionning. An area of research we’re excited to take further.
tags: Lean - Tactics - Idris - Container - Polynomial functor