The previous chapters treated three operations on terms as black boxes: substitution, used by T-App, by Freshen, and by every computation step; weak head normalization, used to expose a Π or a universe; and definitional equality, used by Check. These operations are part of the trusted kernel, and most kernel bugs hide in them, because they must handle the names of bound variables correctly. This chapter opens the first box, substitution, shows the same naming problem in contexts, and then defines when two terms are equal up to the names of their bound variables.
Free variables and substitution
Π and λbind their variable: in λ(x:A).t, occurrences of x inside t refer to the argument. The binder's scope is the body only; x is not bound in the annotation A. A variable occurrence that is not bound by an enclosing Π or λ is free.
For example, FV(λ(x:P).fxy)={P,f,y}, and FV(Π(P:Sort0).P→P)=∅, so the latter is a closed term. Constants are never bound, so they contribute nothing.
The explorer colours each bound variable like its binder and underlines free ones; hover a variable to see its binder. Try a term with shadowing, such as fun (x : A) => fun (x : A) => x.
We write t[x↦s] for the result of replacing the free occurrences of x in t with s. Binders make this subtle: in (λ(y:A).x)[x↦y], the body means "ignore the argument and return the outer x". Replacing x with y naively gives λ(y:A).y, the identity function, because the binder has captured the formerly free y. To preserve the meaning, substitution must rename the binder first.
Definition 11.2 (Capture-avoiding substitution). For □∈{Π,λ}:
x[x↦s]y[x↦s]Sortu[x↦s]c[x↦s](t1t2)[x↦s](□(x:A).B)[x↦s](□(y:A).B)[x↦s](□(y:A).B)[x↦s]========sySortuct1[x↦s]t2[x↦s]□(x:A[x↦s]).B□(y:A[x↦s]).B[x↦s]□(z:A[x↦s]).(B[y↦z])[x↦s](y=x)(x is shadowed)(y=x,y∈/FV(s))(y=x,y∈FV(s),z fresh)
Here "z fresh" means z∈/FV(s)∪FV(B)∪{x}.
The following examples exercise each binder case.
Substitution
Result
Why
(Π(h:P).P)[P↦Q]
Π(h:Q).Q
no capture possible; the T-App step of Chapter 6
(λ(x:A).x)[x↦s]
λ(x:A).x
x is shadowed; the body is untouched
(λ(y:x).y)[x↦s]
λ(y:s).y
the annotation is outside the scope of y
(λ(y:A).xy)[x↦fy]
λ(z:A).fyz
y∈FV(fy), so the binder is renamed to z
In the last example, the free y of the replacement still refers to the outer y, and the bound variable, now z, still refers to the argument. Without the renaming, the result would be λ(y:A).fyy, which passes its argument twice. The explorer performs both substitutions side by side; the colours show whether the y in the result is the free one from the replacement or the one the binder captured.
Variable capture in contexts
It is tempting to drop the side condition x∈/dom(Γ) of C-Ext and let a new assumption simply shadow an old one, as most programming languages do. Doing so makes the kernel unsound. Consider
t=λ(x:Sort0).λ(y:x).λ(x:Sort0).y.
The variable y has the outerx as its type, and the innermost λ ignores its argument. So the correct type of t is
Π(x:Sort0).Π(y:x).Π(x′:Sort0).x.
A checker without Freshen looks up y in the context x:Sort0,y:x,x:Sort0 and returns its type, the bare name x. At that point x refers to the inner binder, and the checker returns
Π(x:Sort0).Π(y:x).Π(x:Sort0).x,
which says "from any element of any type, produce an element of every type". Given A:Sort0 and a:A, the term tAa then has type Π(B:Sort0).B, a proof of False. Such A and a exist even in the empty context: A=Π(P:Sort0).P→P is itself a proposition, and the identity function a=λ(P:Sort0).λ(h:P).h proves it.
With Freshen, the innermost binder is renamed to x′ before it enters the context, so the context becomes x:Sort0,y:x,x′:Sort0, the lookup of y returns x, which still refers to the outer binder, and Infer returns the correct type above. The derivation explorer shows the renaming happen: the note on the innermost T-Lam node reports it, and the context of the leaf contains x′.
# The kernel renames the inner x to x' and infers the correct type.
# A kernel that let the inner x shadow the outer one would accept this
# as a proof of the second statement, which is false.
goal forall (x : Sort 0), forall (y : x), forall (x' : Sort 0), x
exact fun (x : Sort 0) => fun (y : x) => fun (x : Sort 0) => y
qed
goal forall (x : Sort 0), forall (y : x), forall (x' : Sort 0), x'
exact fun (x : Sort 0) => fun (y : x) => fun (x : Sort 0) => y
qed
The problem is the same as in substitution: a name is moved into a scope where it means something else. It affects any representation that identifies variables by name, whether the context is a list or a hash map. There are three common remedies.
Reject shadowing outright. This is the simplest option, but the reserved name _ used by A→B must be exempt and never added to the context.
Rename on entry, as Freshen does. This is our choice.
Represent bound variables by position (de Bruijn indices [de Bruijn 1972]) or use the locally nameless style, in which bound variables are indices and free variables carry unique identifiers. Lean 4 takes the latter approach.
A test suite should include t above and check that the kernel either rejects it or assigns it the correct type.
Alpha-equivalence
The names of bound variables carry no meaning. λ(x:A).x and λ(y:A).y are the same function, and Π(P:Sort0).P→P and Π(Q:Sort0).Q→Q are the same proposition. Terms that differ only in the names of bound variables are called alpha-equivalent. A kernel cannot compare types with plain syntactic equality, which would reject the second pair.
Definition 11.3 (Alpha-equivalence).=α is the smallest congruence on terms containing, for □∈{Π,λ},
□(x:A).B=α□(y:A).B[x↦y]whenever y∈/FV(B).
(A congruence is an equivalence relation that is preserved by every term constructor: if the parts are related, so are the wholes.) This definition is easy to state but not directly executable, since it does not say which names to choose. An implementation instead walks both terms in lockstep and remembers, for each side, the list of binders passed so far. A bound variable is then identified by which binder it refers to, not by its name.
To describe this walk, we use a judgment of the form E1;E2⊢s=αt, where E1 and E2 are lists of names. The list E1 records the binders that enclose s, from the outermost to the innermost, and E2 records those that enclose t. The judgment reads: "s, placed inside the binders E1, and t, placed inside the binders E2, are the same term up to the names of bound variables". Two lists are needed because the two terms may use different names for corresponding binders. For example, comparing λ(x:A).λ(y:B).x with λ(a:A).λ(b:B).a passes two binders on each side and arrives at
x,y;a,b⊢x=αa,
which holds because x and a both refer to the outer of the two binders. Comparing two whole terms starts from empty lists, ⋅;⋅⊢s=αt.
To say "the same binder" precisely, let idx(E,x) be the position of the last occurrence of x in E, counted from the right starting at 0, or undefined if x∈/E. In the example, idx((x,y),x)=idx((a,b),a)=1. Taking the last occurrence handles shadowing: in λ(x:A).λ(x:B).x the body refers to the inner x.
Figure 11.1. Algorithmic alpha-equivalence. By Theorem 11.4, two terms s and t are alpha-equivalent iff ⋅;⋅⊢s=αt.
Free variables are compared by name (α-Free), bound variables by position (α-Bound), and constants by name (α-Const). A variable that is bound on one side and free on the other is never equal to anything, which is why there is no rule for that case. Because both sides descend through binders together, E1 and E2 always have the same length. So comparing positions counted from the right is the same as comparing positions counted from the left, and an implementation may store each list as a map from names to binding depths, provided it restores the previous entry when it leaves a binder. Using positions instead of names everywhere gives the de Bruijn representation, in which alpha-equivalent terms are syntactically identical.
Examples
The first example of Chapter 6 ended by comparing the inferred type with the stated one, where P→P abbreviates Π(_:P).P:
⋅;⋅⊢Π(P:Sort0).Π(h:P).P=αΠ(P:Sort0).Π(_:P).Pα-Pi
⋅;⋅⊢Sort0=αSort0: holds α-Sort
P;P⊢Π(h:P).P=αΠ(_:P).Pα-Pi
P;P⊢P=αP: holds, both have index 0α-Bound
P,h;P,_⊢P=αP: holds, both have index 1α-Bound
The names h and _ differ, but neither is ever looked up, so they do not matter. The wrong proof of Chapter 6 fails at the last step instead:
Positions also tell apart terms that a careless comparison of names would confuse. λ(x:A).λ(y:A).x and λ(a:A).λ(b:A).b are not alpha-equivalent: the bodies reach x,y;a,b⊢x=αb, and idx gives 1 for x but 0 for b. Conversely, λ(x:A).λ(x:A).x and λ(a:A).λ(b:A).bare alpha-equivalent, because the last occurrence of x in (x,x) has index 0, the same as b in (a,b).
The rules of Figure 11.1 are a different definition from Definition 11.3, so we must check that they agree.
Theorem 11.4 (Correctness of algorithmic alpha-equivalence). For all terms s and t, s=αt in the sense of Definition 11.3 if and only if ⋅;⋅⊢s=αt is derivable by the rules of Figure 11.1.
The idea of the proof is that both sides say the same thing: s and t have the same de Bruijn representation. For the algorithm this is almost immediate, since its rules compare positions exactly as de Bruijn indices do. For Definition 11.3, the key fact is that renaming a bound variable does not change the de Bruijn representation. Appendix B gives the full proof.
Exercises
Compute (λ(y:A).λ(x:B).xy)[x↦y] step by step.
Solution
The outer binder is y, which is free in the replacement y, so the capture case applies: rename y to a fresh z, giving body λ(x:B).xz, then substitute. The inner binder is x, the variable being replaced, so the inner body is untouched. Result: λ(z:A).λ(x:B).xz. Note that the inner x was never a free occurrence, so nothing was replaced at all; the only effect was the renaming forced by capture avoidance.
Decide whether λ(x:A).λ(y:A).yx and λ(y:A).λ(x:A).xy are alpha-equivalent by running Figure 11.1.
Solution
Yes. After two α-Lam steps, the binder lists are x,y and y,x, while the bodies are yx and xy. The two α-App comparisons match y with x at indices 0 and 0, then x with y at indices 1 and 1. The names are swapped, but their positions agree.
What to remember
Substitution replaces free occurrences only, and must rename a binder when the replacement mentions the binder's name (capture).
The same capture problem arises in contexts. A kernel that lets a binder shadow a context entry can prove False; Freshen prevents it.
Alpha-equivalence compares bound variables by the position of their binder and free variables by name. Figure 11.1 is the algorithm; Theorem 11.4 says it matches the definition.
All three live in src/substitution.rs: subst, fv, and alpha.