4.2 General versus value-dependent application
4.2.2 Effects beyond nontermination
The generous application rule is implemented in Zombie, and seems very suitable for CBV-languages where the only effect is nontermination/error. On the other hand, it is not clear if the intuitions behind it will extend to systems with a richer class of effects. In particular, trying to add ML-style references to Zombie while keeping these type rules runs into at least two problems.
First, without memory effects it is the case that if a ;cbv a0 then a and a0 are
contextually equivalent. With memory effects this is not true, and the best we can say (as in Mason and Talcott [81]) is that the pairs (S, a) and (S0, a0) are equivalent, whereSis a syntactic representation of the store. This creates problems with our style of preservation proof, since we want to say that when an application a b : {b/x}A
steps to a b0 : {b0/x}B, then the type is preserved because b and b0 are equivalent.
For example, in a store where the location l maps to 3, we might expect to type (λ (y:Nat) (q: !l = y) . q) (!l) join : !l = !l
But this steps to
(λ (y:Nat) (q: !l = y) . q) 3 join
which is probably not typeable—while!l = !lis justified by contextual equivalence, !l = 3 is not.
Second, in the presence of memory effects, a type is not necessarily “well-behaved” just because it is well-kinded. For example, if x is a variable of type Unit, then it seems reasonable to be able to prove (l:=2;x;!l)=(l:=2;();!l), since variables should range over values and so have no effects. That would allow writing a function
f : (x:Unit) → ((l:=2;x;!l)=(l:=2;();!l)).
On the other hand, the function call f (l:=3) should be disallowed, since the re- sulting type (l:=2;l:=3;!l)=(l:=2;();!l) is bad. However, although this type is plainly false it is well-kinded, so the premise Γ` {b/x}B :Type does not rule it out. The big advantage of restricting the application rule to value-dependency is that it can accommodate memory effects like this. For example F∗ supports ML-style references. Because F∗ prohibits non-values from appearing anywhere inside a type, in particular location references or assignments can never occur in a type. The condition one really needs is that the expressions appearing in types do not have any read or write memory effects, so it is safe to allow slightly more than just syntactic values. For example, Deputy [34] allows dependency on “pure expressions”.
Thus, by being more restrictive about what kind of proofs the programmer can write, these languages can be more liberal about what effects they support. Of course,
the restriction to pure expressions also precludes stating any interesting specifica- tions about memory effects. To support that requires additional machinery in the programming language.
Chapter 5
Programming up to congruence
The Zombie core language provides a solid foundation—as the work in Chapter 3 and in Casinghino’s dissertation [30] shows, it is type safe, logically consistent, and simple enough to be checked by a small trusted kernel. However, it is not feasible to write programs directly in the core language, because the terms get cluttered with type annotations and type casts. In this chapter we turn to the other half of the language design: crafting a programmer-friendly surface language which elaborates into the core.
We reduce the required number of type annotations by using bidirectional type check- ing, which is a standard technique. However, inferring type casts is more interesting. Most dependently-typed languages are able to omit many proofs of type equality be- cause they define types to be equal when they are (at least) β-convertible, but in Zombie this is awkward because of nontermination. To check whether two types are
β-equivalent the type checker has to evaluate expressions inside them, which becomes problematic if expressions may diverge—what if the type checker gets stuck in an infi- nite loop? The best we can say is that type checking terminates if the type-expression being checked terminates [5]. Existing languages fix an arbitrary global cut off for how many steps of evaluation the typechecker is willing to do (Cayenne [10]), or only reduce expressions that have passed a conservative termination test (Idris [27]). Zombie, somewhat radically, omits automatic β-conversion completely. Instead, β- equality is available only through explicit conversion. Because Zombie does not in- clude automatic β-conversion, it provides an opportunity to explore an alternative definition of equivalence in a surface language design.
Congruence closure is a basic operation in automatic theorem provers for first-order logic (particularly SMT solvers, such as Z3 [44]). Given some context Γ which contains assumptions of the form a = b, the congruence closure of Γ is the set of equations which are deducible by reflexivity, symmetry, transitivity, and changing subterms. Figure 5.1 specifies the congruence closure of a given context.
a =b ∈Γ Γ`a =b Γ`a =a Γ`b =a Γ`a =b Γ`a =c Γ`c =b Γ`a =b Γ`a =a0 Γ`b =b0 Γ`a b =a0 b0
Figure 5.1: The “classic” congruence closure relation for untyped first-order logic terms
Although efficient algorithms for congruence closure are well-known [49, 96, 118] this reasoning principle has seen little use in dependently-typed programming languages. This is not for lack of opportunity: programmers in dependently typed languages write lots of equality proofs. However, the adaption of this first-order technique to the higher-order logics of dependently-typed languages is not straightforward. The combination of congruence closure and full β-reduction makes the equality relation undecidable (Section 8.3.2). As a result, most dependently-typed languages take the conservative approach of only incorporating congruence closure as a meta-operation, such as Coq’s congruence tactic. While this tactic can assist with the creation of equality proofs, such proofs must still be explicitly eliminated. Proposals to use equations from the context automatically [7, 124, 126] have done soin addition toβ- reduction, which makes it hard to characterize exactly which programs will typecheck, and also leaves open the question of how expressive congruence closure is in isolation. We define the surface language in this chapter to be fully “up to congruence”, i.e. types which are equated by congruence closure can always be used interchangeably, and then show how the elaborator can implement this type system. Specifically, this involves the following contributions:
• We define a typed version of the congruence closure relation that is compatible with our core language, including features (erasure, injectivity, and generalized assumption) suitable for a dependent type system (Section 5.2).
• We specify the surface language using a bidirectional type system that uses this congruence closure relation as its definition of type equality (Section 5.3).
• We define an elaboration algorithm of the surface language to the core lan- guage (Section 5.4) based on a novel algorithm for typed congruence closure (Section 5.5). We prove that our elaboration algorithm is complete for the sur- face language and produces well-typed core language expressions. Our typed congruence closure algorithm both decides whether two terms are in the relation and also produces core language equality proofs.
• The full Zombie implementation extends the ideas in the chapter to also cover datatypes, pattern matching, the full application rule, etc. Congruence closure works well in this setting; in particular, it significantly simplifies the typing
rules for case-expressions (Section 5.6).
• The Zombie implementation also provides a facility for reducing expressions modulo equations in the context, which makes it easier to write external proofs (Section 5.6.3).
In order to make the proofs smaller, we only treat a subset of the full Zombie language. Although it elaborates into the core language from Chapter 3, the type system for the surface language in this chapter omits all rules dealing with datatypes, and uses the value-restricted restricted form of the application rule (which we described in Section 4.2). The actual Zombie implementation of course also handles datatypes and general application, as we describe in Section 5.6. The full proofs are given in Appendix A, so in this chapter only the statements of the lemmas are quoted.
5.1
Type annotations and type casts
In order to get an idea of what parts of core language terms can be inferred by our system, consider as an example this simple proof in Agda, which shows that zero is a right identity for addition.
npluszero : (n : Nat) → n + 0 ≡ n
npluszero zero = refl
npluszero (suc m) = cong suc (npluszero m)
The proof follows by induction on natural numbers. In the base case,refl is a proof of 0 = 0. In the next line,cong translates a proof ofm + 0 ≡ m (from the recursive call) to a proof of suc(m + 0) ≡ suc m.
This proof relies on the fact that Agda’s propositional equality relation (≡) is reflexive and a congruence relation. The former property holds by definition, but the latter must be explicitly shown. In other words, the proof relies on the following lemma:
cong : ∀ {A B} {m n : A}
→ (f : A → B) → m ≡ n → f m ≡ f n
cong f refl = refl
Now compare this proof to a similar result in Zombie. The same reasoning is present: the proof follows via natural number induction, using the reduction behavior of ad- dition in both cases.
npluszero : (n : Nat) → (n + 0 = n) npluszero n =
case n [eq] of
Zero → (join : 0 + 0 = 0)
Suc m → let _ = npluszero m in
Because Zombie does not provide automatic β-equivalence, reduction must be made explicit above. The term join explicitly introduces an equality based on reduction. However, in the successor case, the Zombie type checker is able to infer exactly how the equalities should be put together.
For comparison, the corresponding Zombie core language term includes a number of explicit type casts:
npluszero : (n : Nat) → (n + 0 = n) npluszero (n : Nat) =
case n [eq] of
Zero → join [; 0 + 0 = 0]
. join [~eq + 0 = ~eq]
Suc m →
let ih = npluszero m in
join [; (Suc m) + 0 = Suc (m + 0)]
. join [(Suc m) + 0 = Suc ~ih]
. join [~eq + 0 = ~eq]
Comparing the core language version against the surface language version, we see several improvements. First, the core term required a type annotation(n : Nat)on the argument to the recursive function. In the surface language that can be omitted, because bidirectional typechecking propagates the same information from the top- level type declaration.
More interestingly, several type casts (a . b) in the core term can be omitted from the surface version. In core Zombie, the equality proofbcan be formed in two ways, either via β-reduction (e.g. the proof join [; 0 + 0 = 0] above), or by congruence (e.g. the proofjoin [~eq + 0 = ~eq]above, which proves ((0+0) = 0) = ((n+0) =n)). In the above example, both kinds of reasoning are used. In the successor branch, we can express the reasoning in words as follows. We have (Sucm) + 0 =Suc(m+ 0) (by reduction, using the definition of plus). This is the same as (Sucm) + 0 =Sucm (by the IH, which states m+ 0 =m). And this in turn is the same as n+ 0 =n (using hypothesis eq:n =Suc m, which comes from the typing rule for pattern matching). The surface language term can omit the latter two casts because they were proved by congruence, which makes the term less cluttered.
On the other hand, the surface language still requiresjoin expressions with explicit annotations. The annotations specify what terms should be reduced. For example, without the annotation in the first branch, the typechecker would have tried to reduce join n n, which gets stuck on a pattern match on n.
In this example, we just want to reduce the expression as much as possible. The Zombie typechecker includes some additional support to reduce expressions while taking equations in the context into account, with the syntax unfold/smartjoin
(Section 5.6.3). Using that feature, we can write a more streamlined version of the surface language term:
npluszero : (n : Nat) → (n + 0 = n) npluszero n =
case n [eq_n] of
Zero → smartjoin
Succ m → let _ = npluszero m in
smartjoin
The Zombie surface language is “the dual” of intensional type theories like Coq and Agda: while they automatically use equalities that follow from β-reductions but do not automatically use assumptions from the context, Zombie uses assumptions but does not automatically reduce expressions.
In the case ofnpluszero, the proof in Zombie ended up slightly longer than a similar proof in Coq or Agda would. In our experience, this is quite typical: most equational proofs tend to make more heavy use of β-reduction than of congruence reasoning, so writing them Zombie a little more clumsy than in Coq or Agda. However, one should keep in mind that Zombie is solving a harder problem, because programs are not restricted to be strongly normalizing. The payoff is that functional programs that do not make heavy use of equational reasoning, but which are written using general recursion, require less ceremony.