2013-08-10 Agda by Example: λ-calculus

Update: I gave a talk at NY Haskell following this blogpost, although I didn’t manage to talk about everything:

In the previous Agda example we saw how we can approach the task of verifying a sorting function. This time, we are going to write a type checker for the simply typed λ-calculus, plus a simple optimization on said terms that we will prove correct. As in the other post the bulk of the thinking has been done by other people. The type checking is a modified version of an example given in The View from the Left by Conor McBride and James McKinna, while the optimisation is inspired from a talk given by Adam Chlipala at POPL 2013—his Coq book Certified Programming with Dependent Types contains many similar examples.

Let’s get started.

Useful imports

After the module declaration, we include some useful modules from the Agda standard library:

Data.Nat defines natural numbers.

Data.Fin defines an inductive families to represent the type of all numbers less than a given number. For instance Fin 3 will be inhabited by 0 , 1 , and 2 . Another interpretation is that Fin n is the type inhabited by n elements.

Data.List , predictably, defines finite lists.

Data.Vec defines lists indexed by their length. This allows, for example, for safe indexing of elements.

Relation.Binary.PropositionalEquality defines propositional equality as presented in the previous post. cong₂ is the two-argument version of cong .

Relation.Nullary defines a type for decidable relations, Dec : data Dec (P : Set) : Set where yes : ( p : P) → Dec P no : (¬p : ¬ P) → Dec P

Function exports some common utilities regarding functions that should be familiar to the Haskell programmer, such as function composition ( _∘_ ) and explicit application ( _$_ ).

Simple types and raw terms

The language we are going to define is a simple simply typed λ-calculus. The types for our language will be of two kinds: natural numbers and functions (arrow types):

We will use de Bruijn indices to represent bound variables, where n represents the variable bound by the n -th λ going towards the top of the term. For example, using an Haskell notation for λ s, we will have that

Named de Bruijn λx → x λ 1 λx → λy → x λ λ 2 λx → λy → λz → x z (y z) λ λ λ 3 1 (2 1)

and so on. de Bruijn notation is a terrible device for humans to use directly, but it is often more convenient when dealing with terms mechanically. When parsing a language it is easy to go from names to nameless, so that we can have the best of both worlds.

Thus we will have:

Typed terms, and type checking

Our λ-calculus has a very unsurprising type system. Typing judgements will be done in a context, holding the types of the abstracted variables. Calling the context Γ , we can give typing judgements for our syntax:

For those unfamiliar with typing rules like the ones above, they can be read as “given what’s above the bar, what’s below holds”. In our case we have judgements of the form Γ ⊢ t : τ , which can be read as “In context Γ , term t has type τ ”.

Lit says that number literals have type nat . Add says that given two terms of type nat , we can add them together yielding another term of type nat . Var says that given a variable we can get its type by looking it up in the context—we haven’t defined what a context is but we will soon. App says that given a function of type τ ⇒ σ we can apply it to a term of type τ to get a term of type σ . Finally, if we have a term t : τ in a context with σ “at its head”, we can form a function of type σ ⇒ τ , typed in the context with σ removed ( Lam ).

Now, taking advantage of Agda’s facilities, we can define a Term data type to represent terms together with their type—a Term will essentially include a derivation that its type is valid.

We will represent the context as a Vec containing Type s. The fact that Vec carry their length at the type is useful, since it will indicate how many variables are in scope, and thus what the largest de Bruijn index is.

A type checked Term will be parametrised over a context Γ of some length n . Moreover, it will be indexed by its type. The type is an index because different constructors will be typed differently, just like in the typing derivations.

Note that in the var case we could have written

var : (v : Fin n) → Term Γ (lookup v Γ)

However this would have caused problems further down the line, and thus we quantify over a type τ and use an explicit equality.

Also in var , we use Fin to make sure that the index is indeed “in scope”. If the context is of length n , an index of type Fin n makes sure that we can look up the type of the variable, since Fin n represents the type of all naturals less than n :

lookup : ∀ {n} {A : Set} → Fin n → Vec A n → A

Closed terms will be those living in empty contexts:

For example, we can write a function doubling numbers in Term . The type will predictably be nat ⇒ nat :

We can also write a function that forgets the type information and gets us Syntax from Term :

Type equality

Before writing our type checking function, we need some additional tools. We will begin by defining a decision procedure for equality between types. First two lemmas telling us that if two arrow types are equal, both sides of the arrows are equal too:

Then the actual function. Dec is inhabited by either evidence that the relation holds, using the constructor yes ; or that it doesn’t, using no :

Note that in the cases where different constructors are present we use λ() , which is a shorthand for a function with an empty pattern—meaning that its input cannot be inhabited. For example when faced with nat ≟ σ ⇒ τ to prove the inequality we need to show that ¬ (nat ≡ σ ⇒ τ) , which is a shorthand for (nat ≡ σ ⇒ τ) → ⊥ —“We can derive falsity given that nat is equal to an arrow type.” But given how ≡ is defined Agda knows that its indices must be the same, and thus that nat ≡ σ ⇒ τ cannot be inhabited, allowing us to use an empty pattern.

Moreover, note how we use dependent pattern matching when recursing down the structure of the types: when pattern matching on an equality proof in a yes using refl the two relevant types are constrained to be the same using dotted patterns, which allows us to use refl again to prove equality of the type as a whole.

From Fin to ℕ

The second tool that we will use is a “view” to tell us whether a given ℕ “fits” in a Fin n .

Fromℕ is parametrised over the “upper bound” n , and indexed over the ℕ that we are bringing into Fin n . We can then write a function that tries to convert a number m to a Fin n :

Type checking

Finally we can proceed to the actual type checking procedure. As with Fromℕ , we define a data type indexed over what will be the input of our function—some piece of untyped Syntax :

Check is parametrised over contexts. If type checking succeeds, we return a type τ and a term t : τ , the erasure of t being the Syntax we were type checking—the yes constructor. We can fail at any time using the constructor no .

Our type checking procedure will work with a context and some Syntax :

With var , we make sure that the index is in scope using fromℕ . If it is, the type is determined by what’s in the context at the right index:

Literals are of type nat :

For additions, we recursively type check the two sides. If they are both nat , the resulting term is nat too. Otherwise, type checking fails:

Note that when checking against the results of the recursive calls, we use dotted matches to show the relationship between the input Syntax terms and the type checked terms: the former is the erasure of the latter. This way we can invoke yes so that the result will be well typed.

The interactive Agda mode is tremendously useful in these situations. If we are in the situation

check Γ (t ⊕ u) with check Γ t | check Γ u check Γ (t ⊕ u) | p1 | p2 = {!!}

We can pattern match by placing p1 in the hole and then invoking C-c C-c , which will result in

check Γ (.(erase t) ⊕ u) | yes τ t | p2 = {!!}

Agda automatically places the dotted patterns. Then after doing the same for p2 and matching nat , we are left with

check Γ (.(erase t) ⊕ .(erase u)) | yes nat t | yes nat u = {!!}

The goal for that hole, as Agda reminds us, is

Check Γ (erase t ⊕ erase u)

We can type yes and then C-r to refine the hole, and then C-a in each hole to tell Agda to try to search for an appropriate term, which in this case succeeds. In general with strong enough types we only write an outline of the function and then a lot of details can be filled in automatically.

For what concerns application, we check that the term we are applying something to has a function type, and that the argument’s type is equal to the domain of said function:

Finally, with λs, we check that the body of the function is well typed in the context plus the type of the argument. If that’s the case, the resulting type is an arrow type:

Type checking the syntax for double will give good results:

> C-c C-n check [] (lam nat (var 0 ⊕ var 0)) yes (nat ⇒ nat) (lam nat (var zero refl ⊕ var zero refl))

While ill-typed examples will fail:

> C-c C-n check [] (lam nat (var 0 · var 0)) no > C-c C-n check [] (lam (nat ⇒ nat) (var 0 ⊕ var 0)) no

Embedding terms

Now that we have a type checker, we can embed typed terms into Agda. This technique is a common and cheap way to give semantics to programming languages that we are modelling inside a theorem prover—we borrow the host language semantics.

In our case, we can easily define a function taking Type into Agda’s Set :

Then, to transport an arbitrary Term Γ τ to ⟦ τ ⟧ , we need an environment storing values for the bound variables:

Env Γ stores values corresponding to the types stored in Γ . We can extend an existing Env by consing a value of the right type to it, and extending the context.

We can also lookup values into the Env just like we can index into Vec s:

With these tools, we can define a function evaluating a Term into an Agda value of the right type:

Now we can verify, for example, that double does the right thing:

Constant folding

To conclude, we will write a simple optimisation our terms, and prove that it is sound by showing that it always preserves the semantics of the original term, using the embedding above.

To represent “optimised” terms, we will use a record parametrised over the original term and holding the optimised version, plus evidence that for all environments evaluating the original and optimised term will yield the same Agda value:

However, to succeed, we need to postulate an axiom:

This axiom says that if we have two functions f and g , and we have evidence that for all inputs the functions have equal outputs, then the functions themselves can be considered equal. This is usually know as functional extensionality, and we cannot prove it in Agda and theorem provers based on similar theories. Postulating extensionality is still (hopefully) consistent with Agda’s theory, although obviously we will not be able to compute with it—definitions pattern matching on equality proofs will get stuck on invocations of ext .

With this axiom and our record Optimised , we will write the optimisation and the proof at the same time. The optimisation will consist of reducing constant expressions to number literals, so that for example

lit 3 ⊕ lit 7

will be reduced to

lit 10

So, our function will take a term and produce an optimised version of it:

Variables and literals stay untouched, so the proof of equality will be trivial:

For applications, we call cfold recursively, and then we use cong₂ to combine the resulting proofs of soundness—since evaluating functions means applying them, we use _$_ :

cong₂ : {A B C : Set} (f : A → B → C) {x y u v} → x ≡ y → u ≡ v → f x u ≡ f y v _$_ : ∀ {A : Set} {B : A → Set} → ((x : A) → B x) → ((x : A) → B x) cong₂ _$_ : {A B : Set} {f g : A → B} {x y : A} → f ≡ g → x ≡ y → (f $ x) ≡ (g $ y)

For λs, we need to use ext to prove that the optimised function is equal to the original one:

Finally, with additions, we perform the actual optimisation. If both sides after optimisation are number literals we can replace them with another literal. Otherwise we leave the addition in place. We also combine the proofs of equality of the sides with cong₂ _+_ , given that the denotation uses _+_ .

And that’s it.

You can comment this post on reddit.