Some Lean Metaprogramming Gotchas

19 August 2020

Lean is a dependently typed proof assistant in the mould of Coq and Agda. Its main advantage over these older system is, at least in my mind, a very pleasant interface for metaprogramming (aka tactics programming). But every system has its dark corners and over the last half year, I’ve discovered some of Lean’s. So, to spare you my frustration, here are some notes on what works and what doesn’t. I’ve learned much of this from the kind folks on the Lean Zulip server, which you should visit if you want to work with Lean.

At the time of writing, the current version of (the community fork of) Lean is 3 point something. Version 4 is hopefully coming soon and may change the metaprogramming framework considerably, so check whether this article is horribly outdated before reading on.

I assume basic familiarity with Lean’s metaprogramming framework. If you’re entirely new to this topic, read the relevant section of the Logical Verification lecture notes and the metaprogramming paper and maybe skim the relevant standard library files: init/meta/expr.lean and init/meta/tactic.lean. Mathlib, Lean’s de facto standard library, also has some additional utility functions in meta/expr.lean and tactic/core.lean.

1 Tracking Hypotheses

Let’s start with the most annoying issue I’ve encountered. When you’re writing a tactic, you’re almost always working in the context of a goal. This consists of a target – the statement you want to prove – and a local context containing zero or more hypotheses (aka local constants). In Lean’s interactive mode, a goal is rendered like this:

n : ℕ        | local context containing
h : n = 0    | 2 hypotheses
⊢ n + m = m  | target

Internally, local constants are a particular sort of expression, so when you ask Lean to give you the local context, you get a list of expressions.

Now the problem: If you do basically anything to a hypothesis – specialise it, apply a lemma to it, substitute in it, even just revert then intro it – it becomes a different expression. In particular, its unique name, by which Lean identifies it internally, changes. As a result, any expression that mentions this local constant is now invalid.

For an example of how this can bite, say we have a list of hypotheses hs : list expr which are equations. We want to substitute them all. The obvious way to do this is to call the subst tactic once for each hypothesis in hs. But suppose we do that on the following goal:

x y : ℕ
h₁ : x = y
h₂ : x = y
⊢ unit

Starting with hs = [h₁, h₂], we first substitute h₁. But this changes the type of h₂ and thus its unique name. So when we get to h₂, subst reports that h₂ doesn’t exist in the context, which is true: the local constant h₂ stored in our list doesn’t exist any more. The local context still contains an h₂, but this is a different expression which just happens to have the same user-facing name.

There are two unsatisfying workarounds for this issue. One is to avoid storing expressions as much as possible (easier said than done). The other is to identify hypotheses by their user-facing name. In the example, we would take as input the list of names [`h₁, `h₂] and only retrieve the corresponding local expression directly before each subst. For this to work, however, you have to make sure that the user-facing names of any relevant hypotheses are unique in the context (which Lean does not guarantee).

2 Definitional Equality and Transparency

This issue is not specific to Lean but a general nuisance in any dependently typed language: you always have to consider how your tactic behaves in the presence of definitionally equal terms. For instance, say you want to find hypotheses in the context that are equations, so you retrieve the type of each hypothesis and match on it:

match h_type with
| `(%%lhs = %%rhs) := ...
| _ := ...

Unfortunately, this will fail in the following (useless) example:

@[reducible] def eqℕ (x y : ℕ) : Prop := x = y

example (x y : ℕ) (h : eqℕ x y) : unit := ...

Definitions like eqℕ are often used as abbreviations for a type. (The @[reducible] is explained below.) But our tactic matched, saw an application of eqℕ instead of the expected = and bailed. To deal with this, the eqℕ must be unfolded to get the =. We now have two options: modify our tactic to unfold eqℕ or tell our users that they need to do this themselves.

This is an instance of a more general problem. When we manipulate terms in a dependent type theory, we must always think about what happens with definitionally equal terms. There’s a tradeoff here: tactics that respect definitional equality tend to be more complex and slower since they have to partially normalise terms – but they are also easier to use since users don’t have to manually change terms into a form that the tactic is happy with.

Lean generally errs on the side of performance and implementation simplicity, leaving users to deal with definitional equality themselves. This is partly intentional and partly an API issue. Pattern matching, the convenient way to match on expressions, is structural (meaning it doesn’t respect definitional equality), as are most matching functions from the standard library. If, on the other hand, you want to match up to definitional equality, there’s no comprehensive API to help you.

So let’s bite the bullet and normalise by hand. Returning to the eqℕ example, weak head normalisation1 is sufficient, so we call the tactic whnf before matching an equation:

h_type' ← whnf h_type,
match h_type' with
| `(%%lhs = %%rhs) := ...
| _ := ...

This works. But now our tactic actually normalises too much.

To see why, we need to talk about a Lean-specific complication of definitional equality, transparency. In Lean, each definition is tagged with one of three transparency values: reducible, semireducible and irreducible.2 Transparency controls how aggressively a definition is unfolded. Our eqℕ from before was reducible, meaning that we want to have it reduced away aggressively. This makes sense since eqℕ is meant to be an abbreviation, so eqℕ x y and x = y should be considered the same in most contexts. If we had marked eqℕ as irreducible instead, that would mean the opposite: eqℕ should never be reduced; we want eqℕ x y and x = y to be treated as different types. And semireducible is somewhere in between: some tactics will reduce it, some won’t. The default transparency for a definition is semireducible.

So, our example should probably only unfold reducible definitions. We can specify this with an optional argument to whnf:

h_type' ← whnf h_type reducible,
match h_type' with
| `(%%lhs = %%rhs) := ...
| _ := ...

This tactic works as intended, but it’s been a bit of a journey. That’s also how I feel about transparency in general: a necessary evil that you have to think about constantly. It doesn’t help that many tactics take the transparency as an optional argument with different default values: some default to reducible, some to semireducible. Opportunities for mistakes, and confused users, abound.

3 Open Expressions

It is a truth universally acknowledged that open expressions suck. An open expression is one that has at least one free variable. In principle, of course, there’s nothing wrong with this: open expressions occur naturally when we deconstruct expressions with binders. For example, we might want to extract the argument types of a dependent function type. Here is one such dependent type and its list of argument types:

∀ (n : ℕ) (f g : fin n) (h : f = g), f = g

[ℕ, fin #0, fin #1, #1 = #0]

All expressions in the list bar the first are open, containing variables (de Bruijn indices) 0 and 1. But further processing of these expressions is going to be tricky, for two reasons.

First, most of the Lean API assumes that we only work with closed expressions. You can’t typecheck an open expression; you can’t infer its type; you can’t unify it; you can’t check whether it is definitionally equal to some other expression. is_def_eq #0 #0 fails.

Second, there’s a conceptual problem with open expressions: it’s hard to keep track of what their variables refer to. Take another look at the deconstructed type above, and specifically at the variable zero. In fin #0, it refers to n, the first overall argument. In #1 = #0, it refers to g. Conversely, fin #0 and fin #1 are the same type.

So Lean really doesn’t want you to handle open expressions. Instead, you should treat expressions as locally nameless. This means that whenever you create an open expression, you immediately instantiate the now free variable zero with a new local constant of the appropriate type. These made-up local constants are then, effectively, your free variables.

Applying this to our running example gives this decomposition of the Π-type:

∀ (n : ℕ) (f g : fin n) (h : f = g), f = g

[ℕ, fin cn, fin cn, cf = cg]

The cx are new local constants with appropriate types: cn : ℕ, cf : fin cn and cg : fin cn. It is now immediately obvious that the second and third argument have the same type.

This approach also addresses the API issue. Most tactics don’t mind that we’ve pulled new local constants out of thin air: is_def_eq (fin cn) (fin cn) succeeds, as do inference and unification. The only exception I’ve come across is type_check, which does check whether all local constants are present in the local context. Lean and mathlib also provide tactics which facilitate this style of programming, e.g. tactic.mk_local_pis and expr.pis.

This style of processing expressions is somewhat inefficient: replacing a binder with a local constant traverses the binder’s entire body. But it is much more convenient than dealing with open expressions directly.

4 Elaboration and Pre-Expressions

Constructing full expressions can be tedious since you have to fill in many details. For example, the fully elaborated form of a humble refl : 0 = 0 is @refl.{1} ℕ 0. (The 1 is a universe argument, indicating that lives in universe 1 aka Type.)

It is therefore tempting to elide some of these details and have Lean fill them in for us. This process is called elaboration and Lean does it all the time: when you write refl in the surface syntax, Lean fills in the 1, and 0 for you.

In tactic land, elaboration does not happen automatically, but you can request it by using the to_expr tactic. This tactic takes a pre-expression (expr ff – the ff means ‘not yet elaborated’) and elaborates it, returning an expression (expr tt aka expr). Thus, you can write the same term you would write in surface syntax, then have Lean fill in all the annoying details.

At least that’s the theory. In practice, using elaboration in tactics is more cumbersome than one might think. Part of the problem is that the Lean API pushes you away from pre-expressions: many functions that would make sense for both expressions and pre-expressions are implemented only for expressions, so you’re forced to elaborate sooner rather than later. But then elaborating sooner also means that there’s less context available, so the elaborator may be able to infer less arguments. In the end, I often bit the bullet and just filled in all the details myself.

5 Minor Niggles

To close out this article, here are some more minor things, in no particular order, that I found surprising or irritating.

5.1 Tokens

If you want to parse something as a token (using the tk parser), that something must first be registered as a token, like so:

precedence my_token : 0

If you don’t do this, you’ll get some cryptic errors.

5.2 Shadowing in the Local Context

As mentioned, the local context allows shadowing: different hypotheses can have the same user-facing name. I consider it good practice to avoid shadowing as much as possible. If you want to do this, be careful with intro and intro_lst: these tactics don’t make sure that the given name is unique in the context. intro1, intron and friends, on the other hand, do ensure uniqueness. In more complex tactics, I’ve found it surprisingly challenging to ensure that user-provided, potentially shadowing names are preserved while new hypotheses receive unique names.

5.3 Universe Parameters in Quotations

Lean has a neat quotation syntax for constructing and matching on expressions. Assuming we have t x y : expr in scope, `(@eq %%t %%x %%y) is a shorthand for this beast:

(app (app (app (const `eq [?]) t) x) y)

But what’s that question mark? An admission of defeat. eq takes a universe parameter denoting the universe of the type t, so the full form would be `(@eq.{u} %%t %%x %%y) for some u : level. But Lean doesn’t support this syntax in quotations (or rather, the syntax doesn’t do anything sensible). At the same time, Lean doesn’t allow us to leave out the universe parameter because then the expression would not be fully elaborated. So our only choice here is not to use quotation syntax at all, falling back to the verbose version:

(app (app (app (const `eq [u]) t) x) y)

5.4 Local Definitions

The local context can contain local definitions, which are hypotheses with an associated definition. You get these when you use intro on a target that starts with a let. In a goal, they look like this:

n : ℕ := zero
⊢ ...

Unfortunately, some tactics just pretend that local definitions don’t exist. In particular, kdependencies, which finds all hypotheses depending on a given expression, ignores the bodies of local defs. Thus, kdependencies `(zero) will return nothing for the above goal. Lean’s ‘safe’ API doesn’t even let you check whether a hypothesis is, in fact, a local definition.

Mathlib provides tactics that fix some of these issues, e.g. local_def_value and revert_deps (in tactic/core.lean).

5.5 Type inference for type-incorrect expressions

When infer_type e succeeds with output T, this doesn’t necessarily mean that e has type T. Rather, it means that if e typechecks, then it has type T. In other words, infer_type may succeed for type-incorrect expressions.

5.6 Keyed Matching

This is probably just me being dense, but I initially thought Lean’s ‘keyed matching’ (as used in kdependencies and others) was just a performance optimisation for definitional equality. It is not: t := succ (succ zero) does not occur in e := plus (succ zero) (succ zero) according to kdepends_on. Keyed matching looks for subterms of e whose head symbol is equal to the head symbol of t, then checks whether any of these subterms are definitionally equal to t. But in our example, the head symbols of e (plus) and t (succ) are different, so we never get to the definitional equality part.

I mentioned above that Lean doesn’t like to check for definitional equality, sometimes to the user’s surprise. Keyed matching is one instance of that philosophy.

5.7 Frozen Local Instances

When you write an instance argument as part of a definition, it is ‘frozen’. This means that it can’t be reverted (and neither can its dependencies), which apparently improves performance. Example:

def fish {α β γ} {m : Type → Type} [monad m] (f : m α → m β) (g : m β → m γ)
  : m α → m γ :=
  revert m, -- fails, `monad m` is a frozen instance
  revert f, -- succeeds
  revert α  -- succeeds

You can use unfreeze_local_instances to get rid of this frozen status. Most tactics, however, opt to leave frozen instances alone and revert only non-frozen hypotheses. For example, revert_all doesn’t necessarily revert all hypotheses. You need to think about this whenever you revert anything, but I have a sneaking suspicion that many tactics don’t, leaving users to deal with the occasional cryptic error.

5.8 Types of Local Constants and Metavariables

The constructors for local constant and metavariable expressions, local_const and mvar, both have a field for the type of the expression. Unfortunately, this field sometimes just plain lies: a local_const that you got from a well-typed context may have an invalid expression as its ‘type’.

To be fair, this is documented reasonably prominently. But I still managed to get bitten by it when I used the standard library’s expr.pis function, which (silently) assumes that the type of a local constant is valid. Mathlib has tactic.pis (and tactic.lambdas) to work around this issue.

6 Conclusion

I don’t really have a conclusion for you – this article is, after all, a list of things with no grand unifying theme. However, I want to emphasise that despite my many niggles, for the most part I enjoy Lean’s metaprogramming framework. Its foundations are very solid and the way things are structured makes a lot of sense. There just remains some polishing to do (probably in Lean 4).

  1. Weak head normalising an expression e means, very roughly, ‘apply any relevant conversion rules to e until we know the outermost constructor of its normal form’.↩︎

  2. There’s also the instances transparency, but we’ll ignore that.↩︎