You may have noticed in the previous lecture that there was something missing. Most of the examples that we did could have also been written in System F (or something similar)!
Today we are going to think about how type equality can make our language more expressive. We will do this in two steps: adding both definitional and propositional equality to the language.
In full dependently-typed languages (and in full pi-forall) we can see the need for definitional equality. We want to equate types that are not just syntactically equal, so that more expressions type check.
We saw yesterday an example where we wanted a definition of equality that was
more expressive than alpha-equivalence. Recall our encoding for the logical
and proposition:
and : Type -> Type -> Type
and = \p. \q. (c: Type) -> (p -> q -> c) -> c
Unfortunately, our definition of conj still doesn't type check:
conj : (p:Type) -> (q:Type) -> p -> q -> and p q
conj = \p.\q. \x.\y. \c. \f. f x y
Running this example with version1 of the type checker produces the
following error:
Checking module "Lec1"
Type Error:
../test/Lec1.pi:34:22:
Function a should have a function type. Instead has type and p q
When checking the term
\p . \q . \a . a p ((\x . \y . x))
against the signature
(p : Type) -> (q : Type) -> (and p q) -> p
In the expression
a p ((\x . \y . x))
The problem is that even though we want and p q to be equal to the type
(c: Type) -> (p -> q -> c) -> c the typechecker does not treat these types as
equal.
Note that the type checker already records in the environment that and is
defined as \p.\q. (c: Type) -> (p -> q -> c) -> c. We'd like the type
checker to look up this definition when it sees the variable and and
beta-reduce th this application.
As another example, in the full language, we might have a type of length indexed
vectors, where vectors containing values of type A with length n can be
given the type Vec A n. In this language we may have a safe head operation,
that allows us to access the first element of the vector, as long as it is
nonzero.
head : (A : Nat) -> (n : Nat) -> Vec A (succ n) -> Vec A n
head = ...
However, to call this function, we need to be able to show that the length of
the argument vector is equal to succ n for some n. This is ok if we know
the length of the vector outright
v1 : Vec Bool (succ 0)
v1 = VCons True VNil
So the application head Bool 0 v1 will type check. (Note that pi-forall
cannot infer the types A and n.)
However, if we construct the vector, its length may not be a literal natural number:
append : (n : Nat) -> (m : Nat) -> Vec A m -> Vec A n -> Vec A (plus m n)
append = ...
In that case, to get head Bool 1 (append v1 v1) to type check, we need to
show that the type Vec Bool (succ 1) is equal to the type Vec Bool (plus 1 1). If our definition of type equality is alpha-equivalence, then this
equality will not hold. We need to enrich our definition of equality so that
it equates more terms.
The main idea is that we will:
-
establish a new judgement to define when types are equal
G |- A = B -
add the following rule to our type system so that it works "up-to" our defined notion of type equivalence
G |- a : A G |- A = B ------------------------- conv G |- a : B -
Figure out how to revise the algorithmic version of our type system so that it supports the above rule.
What is a good definition of equality? We started with a very simple one: alpha-equivalence. But we can do better:
We'd like to make sure that our relation contains beta-equivalence:
-------------------------- beta
G |- (\x.a)b = a {b / x}
(with similar rules for if/sigmas if we have them.)
Is an equivalence relation:
---------- refl
G |- A = A
G |- A = B
----------- sym
G |- B = A
G |- A = B G |- B = C
------------------------- trans
G |- A = C
and a congruence relation (i.e. if subterms are equal, then larger terms are equal):
G |- A1 = A2 G,x:A1 |- B1 = B2
------------------------------------ pi
G |- (x:A1) -> B1 = (x:A2) -> B2
G,x:A1 |- b1 = b2
------------------- lam
G |- \x.b1 = \x.b2
G |- a1 = a2 G |- b1 b2
-------------------------- app
G |- a1 b1 = a2 b2
[similar rules for if and sigmas]
that has "functionality" (i.e. we can lift equalities over b):
G, x : A |- b : B G |- a1 == a2
----------------------------------
G |- b{a1 / x} = b{a2 / x}
We would like to consider our type system as having the following rule:
G |- a : A G |- A = B
------------------------ conv
G |- a : B
But that rule is not syntax directed. Where do we need to add equality preconditions in our bidirectional system? It turns out that there are only a few places.
-
Where we switch from checking mode to inference mode in the algorithm. Here we need to ensure that the type that we infer is the same as the type that is passed to the checker.
G |- a => A G |- A = B -------------------------- :: infer G |- a <= B -
In the rule for application, when we infer the type of the function we need to make sure that the function actually has a function type. But we don't really know what the domain and co-domain of the function should be. We'd like our algorithm for type equality to be able to figure this out for us.
G |- a => A A ?=> (x:A1) -> A2 G |- b <= A1 ---------------------------------- app G |- a b => A2 { b / x }
The rules above specify when terms should be equal, but they are not an algorithm. We actually need several different functions. First,
equate :: Term -> Term -> TcMonad ()
ensures that the two provided types are equal, or throws a type error if they are not. This function corresponds directly to our definition of type equality.
Second, we also need to be able to determine whether a given type is equal to
some "head" form, without knowing exactly what that form is. For example, when
checking lambda expressions, we need to know that the provided type is of
the form of a pi type ((x:A) -> B). Likewise, when inferring the type of an
application, we need to know that the type inferred for the function is actually
a pi type.
We can determine this in two ways. Most directly, the function
ensurePi :: Type -> TcMonad (TName, Type, Type)
checks the given type to see if it is equal to some pi type of the form
(x:A1) -> A2, and if so returns x, A1 and A2.
This function is defined in terms of a helper function:
whnf :: Term -> TcMonad Term
that reduces a type to its weak-head normal form. Such terms have done all of the reductions to the outermost lambda abstraction (or pi) but do not reduce subterms. In other words:
(\x.x) (\x.x)
is not in whnf, because there is more reduction to go to get to the head. On the other hand, even though there are still internal reductions possible:
\y. (\x.x) (\x.x)
and
(y:Type) -> (\x.x)Bool
are in weak head normal form. Likewise, the term x y is also in weak head
normal form (if we don't have a definition available for x) because, even
though we don't know what the head form is, we cannot reduce the term any
more.
In version2 of the the implementation, these
functions are called in a few places:
equateis called at the end oftcTermensurePiis called in theAppcase oftcTermwhnfis called incheckType, before the call totcTermto make sure that we are using the head form in checking mode.
There are several ways for implementing definitional equality, as stated via
the rules above. The easiest one to explain is based on reduction---for
equate to reduce the two arguments to some normal form and then compare
those normal forms for equivalence.
One way to do this is with the following algorithm:
equate t1 t2 = do
nf1 <- reduce t1
nf2 <- reduce t2
aeq nf1 nf2
However, we can do better. We'd like to only reduce as much as necessary. Sometimes we can equate the terms without completely reducing them.
equate t1 t2 = do
when (aeq t1 t1) $ return ()
nf1 <- whnf t1 -- reduce only to 'weak head normal form'
nf2 <- whnf t2
case (nf1,nf2) of
(App a1 a2, App b1 b2) ->
-- make sure subterms are equal
equate a1 b1 >> equate a2 b2
(Lam bnd1, Lam bnd2) -> do
-- ignore variable name and typing annot (if present)
(_, b1, _, b2) <- unbind2Plus bnd1 bnd2
equate b1 b2
(_,_) -> err ...
Therefore, we reuse our mechanism for reducing terms to weak-head normal form.
Why weak-head reduction vs. full reduction?
-
We can implement deferred substitutions for variables. Note that when comparing terms we need to have the definitions available. That way we can compute that
(plus 3 1)weak-head normalizes to 4, by looking up the definition ofpluswhen needed. However, we don't want to substitute all variables through eagerly---not only does this make extra work, but error messages can be extremely long. -
Furthermore, we allow recursive definitions in pi-forall, so normalization may just fail completely. However, this definition based on wnhf only unfolds recursive definitions when they are needed, and then only once, so avoids some infinite loops in the type checker.
Note that we don't have a complete treatment of equality though. There will always be terms that can cause
equateto loop forever. On the other hand, there will always be terms that are not equated because of conservativity in unfolding recursive definitions.
---------------- Bool
G |- Bool <=> Type
---------------- true
G |- true <=> Bool
------------------- false
G |- false <=> Bool
G |- a <= Bool
G |- b <=> A
G |- c <=> A
---------------------------- if
G |- if a then b else c <=> A
G |- A <= Type G, x:A |- B <= Type
------------------------------------- sigma
G |- { x : A | B } <=> Type
G |- a <= A G |- b <= B { a / x }
------------------------------------ pair
G |- (a,b) <= { x : A | B }
G |- a => { x : A | B }
G, x:A, y:B |- b <=> C
G |- C <= Type
---------------------------------- weak-pcase
G |- pcase a of (x,y) -> b <=> C
Consider our elimination rules for if:
G |- a : Bool
G |- b : A
G |- c : A
---------------------------- if
G |- if a then b else c : A
We can do better by making the type A depend on whether the scrutinee is true
or false.
G |- a : Bool
G |- b : A { true/x }
G |- c : A { false/x }
---------------------------- if
G |- if a then b else c : A{a/x}
For example, here is a simple definition that requires this rule:
-- function from booleans to types
T : Bool -> Type
T = \b. if b then One else Bool
-- returns unit when the argument is true
bar : (b : Bool) -> T b
bar = \b .if b then tt else True
It turns out that this rule is difficult to implement without annotating the
expression with x and A. Given A{true/x}, A{false/x}, and A{a/x}
(or anything that they are definitionally equal to!) how can we figure out
whether they correspond to eachother?
So, we'll not be so ambitious. We'll only allow this refinement when the scrutinee is a variable.
G |- x : Bool
(G |- b : A) { true / x }
(G |- c : A) { false / x }
---------------------------- if
G |- if x then b else c : A
And, in going to our bidirectional system, we'll only allow refinement when we are in checking mode.
G |- x => Bool
G |- b <= A { true / x }
G |- c <= A { false / x }
------------------------------ if
G |- if x then b else c <= A
Then, we only have to remember that x is true / false when checking the individual branches of the if expression.
Here is an alternative version, for inference mode only, suggested during lecture:
G |- a => Bool
G |- b => B
G |- c => C
----------------------------------------------- if
G |- if a then b else c => if a then B else C
It has a nice symmetry---if expressions are typed by if. Note however, to make this rule work, we'll need a stronger definitional equivalence than we have. In particular, we'll want our definition of equivalence to support the following equality:
----------------------
if a then b else b = b
That way, if the type of the two branches of the if does not actually depend
on the boolean value, we can convert the if expression into a more useful
type.
We can modify the rule for sigma types similarly.
G |- z => { x : A | B }
G, x:A, y:B |- b <= C { (x,y) / z }
G |- C <= Type
---------------------------------- pcase
G |- pcase z of (x,y) -> b <= C
This modification changes our definition of Sigma types from weak Sigmas to strong Sigmas. With either typing rule, we can define the first projection
fst : (A:Type) -> (B : A -> Type) -> (p : { x2 : A | B x2 }) -> A
fst = \A B p. pcase p of (x,y) -> x
But, weak Sigmas cannot define the second projection using pcase. The following code only type checks using the above rule.
snd : (A:Type) -> (B : A -> Type) -> (p : { x2 : A | B x2 }) -> B (fst A B p)
snd = \A B p. pcase p of (x1,y) -> y
You started proving things right away in Coq with an equality proposition. For example, in Coq, when you say
Theorem plus_O_n : forall n : nat, 0 + n = n
You are using a built in type, a = b that represents the proposition that
two terms are equal.
As a step towards more general indexed datatypes, we'll start by adding just this type to pi-forall.
The main idea of the equality type is that it converts a judgement that two types are equal into a type that is inhabited only when two types are equal. In other words, we can write the intro rule for this form as:
G |- a = b
------------------- refl
G |- refl : a = b
Sometimes, you might see the rule written as follows:
------------------- refl'
G |- refl : a = a
However, this rule will turn out to be equivalent to the above version.
This type is well-formed when both sides have the same type. In other words, when it implements homogeneous equality.
G |- a : A G |- b : A
------------------------- eq
G |- a = b : Type
The elimination rule for propositional equality allows us to convert the type of one expression to another.
G |- a : A { a1 / x} G |- b : a1 = a2
--------------------------------- subst
G |- subst a by b : A { a2 / x }
How can we implement this rule? For simplicity, we'll play the same trick that we did with booleans, requiring that one of the sides of the equality be a variable.
G |- a <= A { a1 / x } G |- b => x = a1
------------------------------------------- subst-left
G |- subst a by b => A
G |- a <= A { a1 / x } G |- b => a1 = x
------------------------------------------- subst-right
G |- subst a by b => A
Note that our elimination form for equality is powerful. We can use it to show that propositional equality is symmetric and transitive.
sym : (A:Type) -> (x:A) -> (y:A) -> (x = y) -> y = x
trans : (A:Type) -> (x:A) -> (y:A) -> (z:A) -> (x = z) -> (z = y) -> (x = y)
Furthermore, we can also extend subst, the elimination form for
propositional equality as we did for booleans. As above, this rule only
applies when b is also a variable.
G |- a <= A { a1 / x } { refl / y } G |- y => x = a1
-------------------------------------------------------- subst-left
G |- subst a by y => A
One last addition: contra. If we can somehow prove a false, then we should be
able to prove anything. A contradiction is a proposition between two terms
that have different head forms. For now, we'll use:
G |- p : True = False
--------------------- contra
G |- contra p : A
The file version2/test/NatChurch.pi is a start at a Church encoding of
natural numbers. Replace the TRUSTMEs in this file so that it compiles.
Complete the file Hw2.pi. This file gives you practice with working with equality propositions in pi-forall.
- HoTT book, Sections 1.1 and 1.12 (http://homotopytypetheory.org/book/)