Chapter 12
Reduction and Conversion
Chapter 11 handled names. This chapter handles computation: the three kinds of reduction step, the definition of definitional equality, and the algorithms Whnf, Normalize, and IsDefEq that Algorithm 5.1 left open. The main design point is that the computation rules of inductive types are stored as data in the signature, so one reduction function serves every inductive type ever declared.
Reduction
Applying a -abstraction to an argument is computed by substituting the argument for the bound variable:
This step is beta-reduction, and a subterm of the form is a beta-redex. Part II added two more kinds of step: delta-reduction, which unfolds a definition (Chapter 7), and iota-reduction, which runs a recursor on a constructor (Figures 7.1 and 9.1, and the general rule of Chapter 8). We allow each step anywhere inside a term, and write for a single step of any kind. A term in which no step applies is in normal form. For example, with ,
and the last term is in normal form. Reducing the inner redex first leads to the same result.
Conversion
Definitional equality, also called conversion, identifies terms that compute to the same result, up to the names of bound variables.
Definition 12.1 (Conversion). is the smallest equivalence relation that contains and and is closed under all term constructors.
To decide , the kernel normalizes both terms and compares the results with . This procedure relies on two facts.
- Reduction is confluent: different reduction orders cannot lead to different normal forms. So holds exactly when the normal forms of and are alpha-equivalent; there is no need to search for a clever sequence of steps.
- Every well-typed term has a normal form, so normalization terminates. This holds for our system, but it is a deep theorem (strong normalization, Chapter 14) [Barendregt 1992].
A practical kernel also caps the number of reduction steps, so that a bug cannot make it loop forever; our implementation stops after 20,000 steps with the error "reduction fuel exhausted".
Computation rules as data
Figures 7.1 and 9.1 write each iota-rule as a separate equation, which suggests that every new inductive type needs new reduction code in the kernel. It does not. A kernel stores the iota-rules as data in the signature, next to the recursor, and a single piece of code applies them for every inductive type.
Each recursor entry records the position of its major premise, the argument whose constructor decides which rule applies, and the constructors of its inductive type. For each constructor the kernel knows how many leading arguments are parameters (which the rule ignores) and how many are fields (which the rule passes on), and which fields are recursive. For three of our recursors, the data and the resulting right-hand sides are as follows; type annotations on are omitted.
The right-hand side takes the arguments before the major premise, then the fields of the constructor. For example, selects the second rule and becomes
which beta-reduces to the right-hand side of the second equation in Figure 7.1. Likewise, the only rule of fires on . The constructor has two parameters, and , and no fields, so the right-hand side receives just and returns .
Weak head normal form and normalization
Algorithm 12.1 defines the three functions that Algorithm 5.1 left open. Both and view a term as a spine : a head that is not an application, applied to arguments (Chapter 2). Every term has exactly one such decomposition.
Algorithm 12.1. Weak head normalization with beta, delta, and iota; full normalization; and definitional equality.
Whnf(t):
loop:
write t as a spine h a_1 ... a_n
if h = λ(x : A). b and n ≥ 1: -- beta
t ← (b[x ↦ a_1]) a_2 ... a_n
else if h = c and (c : C := d) ∈ Σ: -- delta
t ← d a_1 ... a_n
else if h = c is a recursor with major position p ≤ n: -- iota
k b_1 ... b_j ← Whnf(a_p)
if k is a constructor of c's inductive type with q parameters:
t ← rhs_k a_1 ... a_{p-1} b_{q+1} ... b_j a_{p+1} ... a_n
else return t -- stuck, e.g. on a variable
else return t
Normalize(t):
t ← Whnf(t)
if t = Π(x : A). B: return Π(x : Normalize(A)). Normalize(B)
if t = λ(x : A). b: return λ(x : Normalize(A)). Normalize(b)
else write t as a spine h a_1 ... a_n and return h Normalize(a_1) ... Normalize(a_n)
IsDefEq(s, t):
return · ; · ⊢ Normalize(s) =α Normalize(t) -- Figure 11.1
reduces only at the head of a term, until no rule applies there; the result is the weak head normal form. It never looks inside a , a , or the arguments of a stuck head. This is enough when a caller only needs the outermost shape of a type, such as a in the application case of , and it is cheaper than full normalization. first computes the weak head normal form and then normalizes every immediate subterm. For example, with and as above,
because the head of is a with no arguments, while continues into the annotation . Likewise, , which already shows a although its domain is not yet normal. Both are visible below: no head step applies, yet the normal form differs.
The three cases of have the following properties.
- The iota case contains no reference to , , or . It reads the major position and the constructor data from the signature. It is written once; after that, each new inductive type adds rows of data, not code.
- The major premise is put in weak head normal form before the rules are consulted, because it may become a constructor only after computation. For in Chapter 7, the major premise is the numeral , already a constructor; for , it is , which turns into first.
- If the major premise does not become a constructor, for instance because it is a variable, the term is stuck and stays as it is; this is why does not reduce (Chapter 9).
Here is at work on , with as in Chapter 7, computed live. Only the head is ever reduced, but reducing the major premise forces an inner computation.
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 def double : Nat -> Nat := fun (n : Nat) => Nat.rec1 (fun (_n : Nat) => Nat) Nat.zero (fun (k : Nat) => fun (r : Nat) => Nat.succ (Nat.succ r)) n
The step marked whnf is the inner computation: the recursor needs its major premise to be a constructor, so is reduced at its head first. The final weak head normal form still contains a stuck inner recursor; the normal form below it is what produces by continuing inside.
For simplicity, fully normalizes both terms, as our implementation does. Practical kernels instead compare lazily: they reduce both sides to weak head normal form, compare the heads, and inspect arguments only when needed. This avoids fully normalizing large terms whose heads already differ. Strong normalization guarantees that all three functions terminate on well-typed terms.
In the code
One subtlety in normalize_with_fuel deserves mention. After normalizing the function and argument of an application, it calls whnf on the rebuilt term once more. Normalizing an argument can expose a new redex at the head: for a stuck recursor application whose major premise becomes a constructor only after its own arguments are normalized, the head must be reduced again. Without this re-check, is_def_eq would miss some equalities, and the test conversion_renormalizes_exposed_redex in src/proof.rs guards it.
Exercises
Give the weak head normal form and the normal form of .
Solution
The spine has head with two arguments. Beta on the first argument gives , which is again a beta-redex at the head and reduces to . So both the weak head normal form and the normal form are . (This is the term in the test conversion_renormalizes_exposed_redex.)
Why does not reduce under a ? What would go wrong in if returned with normalized?
Solution
Nothing would be wrong: the result would still be convertible to the input. But it would be wasted work. calls only to learn the outermost shape, which is already visible once the head is a . Reducing inside could be arbitrarily expensive, for instance in an annotation that is never compared with anything. Keeping shallow keeps type inference cheap on large terms.
What to remember
- Three reduction steps: beta (apply a ), delta (unfold a definition), iota (run a recursor on a constructor). Conversion is the equivalence they generate together with alpha-equivalence.
- Conversion is decided by normalizing both sides and comparing up to alpha, which is correct by confluence and terminates by strong normalization.
- Iota-rules are data: the major premise position plus constructor metadata. One
whnfserves every inductive type. - Whnf reduces only at the head and is what Infer needs; Normalize goes everywhere and is what IsDefEq uses. A step budget guards against bugs.