TL;DR
This paper introduces System FR, a formal foundation and verifier for higher-order functional programs with generics and recursive types, ensuring safety and termination through formal proofs and practical implementation.
Contribution
It formalizes System FR, a calculus supporting polymorphism, dependent types, and recursion, and implements a verifier integrated with Stainless for verifying complex Scala programs.
Findings
Verified 14,000 lines of Scala code including algorithms and data structures.
Proved soundness and safety of the verifier using Coq.
Demonstrated efficiency on real-world functional programs.
Abstract
We present the design, implementation, and foundation of a verifier for higher-order functional programs with generics and recursive data types. Our system supports proving safety and termination using preconditions, postconditions and assertions. It supports writing proof hints using assertions and recursive calls. To formalize the soundness of the system we introduce System FR, a calculus supporting System F polymorphism, dependent refinement types, and recursive types (including recursion through contravariant positions of function types). Through the use of sized types, System FR supports reasoning about termination of lazy data structures such as streams. We formalize a reducibility argument using the Coq proof assistant and prove the soundness of a type-checker with respect to call-by-value semantics, ensuring type safety and normalization for typeable programs. Our program…
Peer Reviews
No public reviews on file for this paper yet. If you reviewed it on a platform where reviews are public (OpenReview, ICLR, NeurIPS, ICML), you can paste yours below so the community can read it here.
Code & Models
Videos
No videos yet. Explain this paper in a talk, walkthrough, or lecture? Add one.
1
System FR as Foundations for Stainless
Jad Hamza
LARAEPFLSwitzerland
,
Nicolas Voirol
LARAEPFLSwitzerland
and
Viktor Kunčak
LARAEPFLSwitzerland
Abstract.
We present the design, implementation, and foundation of a verifier for higher-order functional programs with generics and recursive data types. Our system supports proving safety and termination using preconditions, postconditions and assertions. It supports writing proof hints using assertions and recursive calls. To formalize the soundness of the system we introduce System FR, a calculus supporting System F polymorphism, dependent refinement types, and recursive types (including recursion through contravariant positions of function types). Through the use of sized types, System FR supports reasoning about termination of lazy data structures such as streams. We formalize a reducibility argument using the Coq proof assistant and prove the soundness of a type-checker with respect to call-by-value semantics, ensuring type safety and normalization for typeable programs. Our program verifier is implemented as an alternative verification-condition generator for the Stainless tool, which relies on the Inox SMT-based solver backend for automation. We demonstrate the efficiency of our approach by verifying a collection of higher-order functional programs comprising around 14000 lines of polymorphic higher-order Scala code, including graph search algorithms, basic number theory, monad laws, functional data structures, and assignments from popular Functional Programming MOOCs.
††journalvolume: ††article: ††publicationmonth: 10††copyright: none
1. Introduction
Automatically verifying the correctness of higher-order programs is a long-standing problem that arises in most programming languages and proof assistants. Despite extensive research in program verifiers and proof assistants (Nipkow et al., 2002a; Bertot and Castéran, 2004a; Gordon and Melham, 1993; Harrison, 2009, 2017; Abel, 2010; Norell, 2007; Brady, 2013; Vazou et al., 2014; Swamy et al., 2013; Leino, 2010) there remain significant challenges and trade-offs in checking safety and termination. A motivation for our work are implementations that verify polymorphic functional programs using SMT solvers (Suter et al., 2011; Vazou et al., 2014). To focus on foundations, we look at simpler verifiers that do not perform invariant inference and are mostly based on unfolding recursive definitions and encoding of higher-order functions into SMT theories (Suter et al., 2011; Voirol et al., 2015; Blanc and Kuncak, 2015). A recent implementation of such a verifier is the Stainless system (LARA, 2019), which handles a subset of Scala (Odersky et al., 2008). The goal of Stainless is to verify that function contracts hold and that all functions terminate. It was shown (Hupel and Kuncak, 2016) how to map certain patterns of specified Scala programs into Isabelle/HOL. Whereas this approach ensures soundness, it does not reuse the reasoning of Stainless and it can verify only some of the programs that Stainless verifier can prove. The present paper seeks to provide direct foundations for verification and termination checking of functional programs with a rich set of features for purely functional programming including non-monotonic data types. On the other hand, our calculus does not aspire to directly support effects for which there exist excellent other systems (Swamy et al., 2013).
The subtleties of ensuring function termination have been an initial impetus for the calculus we present. Termination is desirable for many executable functions in programs and is even more important in formal specifications. A non-terminating function definition such as could be easily mapped to a contradiction and violate the conservative extension principle for definitions. Yet termination in the presence of higher-order functions and data types is challenging to ensure. For example, when using non-monotonic recursive types, terms can diverge even without the explicit use of recursive functions, as illustrated by the following snippet of Scala code:
case class D(f: D Unit) // non-monotonic recursive type
def g(d: D): Unit = d.f(d) // non-recursive function definition
g(D(g)) // diverging term, reduces to D(g).f(D(g)) and then again to g(D(g))
Furthermore, even though the concept of termination for all function inputs is an intuitively clear property, its modular definition is subtle: a higher order function taking another function as an argument should terminate when given any terminating function , which, in turn, can be applied to expressions involving further calls to . The quest for solid foundations for termination led us to type theoretic techniques, where reducibility method has long been used to show strong normalization of expressive calculi (Tait, 1967), (Girard, 1990, Chapter 6), (Harper, 2016). As a natural framework for analyzing support for first-class functions with preconditions and post-conditions we embraced the ideas of refinement dependent types similar to those in Liquid Haskell (Vazou et al., 2014) with refinement-based notion of subtyping. To explain proof obligation generation in the higher-order case (including the question of which assumptions should be visible when checking a given assertion), we resorted to well-known dependent () function types. To support parametric polymorphism we incorporated type quantifiers, as in System F (Girard, 1971, 1990). We found that the presence of refinement types allowed us to explain soundness of well-founded recursion based on user-defined measures. The recursion in programs is thus not syntactically restricted as in, e.g., System F. To provide expressive support for iterative unfolding of recursive functions, we introduced rules to make function bodies available while type checking of recursive functions. For recursive type definitions, many existing systems introduce separate notions of inductive and co-inductive definitions. We found this distinction less natural for developers and chose to support expressive recursive types (without a necessary restriction to positive recursion) using sized types (Abel, 2010). We draw inspiration from a number of existing systems, yet our solution is a new sound combination of features that work nicely together.
We combined these features into a new type system, System FR, which we present as a bidirectional type checking algorithm. The algorithm generates type checking and type inference goals by traversing terms and types, until it reaches a point where it has to check that a given term evaluates to true. This typically arises when we want to check that a term has a refinement type , which is the case when has type , and when the term evaluates to true in the context where equals . Following the tradition of SMT-based verifiers (Detlefs et al., 1998; Barnett et al., 2004), we use the term verification condition (VC) to refer to a term that should evaluate to true.
We prove the soundness of our type system using a reducibility interpretation of types. The goal of our verification system is to ensure that a given term belongs to the semantic denotation of a given type. For simple types such as natural numbers, this denotation is the set of untyped lambda calculus terms that evaluate, in a finite number of steps, to a non-negative integer. For function types the denotation are, as is typical in reducibility approaches, terms that, when applied to terms in denotation of argument type, evaluate to terms in the denotation of the result type. Such denotation gives us a unified framework for function contracts expressed as refinement types. The approach ensures termination of programs because the semantics of types only contain terms that are terminating in call-by-value semantics.
We have formally proven using the Coq proof assistant (Bertot and Castéran, 2004a) the soundness of our typing algorithm, implying that when verification conditions generated for checking that a term belongs to a type are semantically valid, the term belongs to the semantic denotation of the type . The bidirectional typing algorithm handles the expressive types in a deterministic and predictable way, which enables good and localized error reporting to the user. To solve generated verification conditions, we use existing implementation invoking the Inox solver111https://github.com/epfl-lara/inox that reduces higher-order queries to the first-order language of SMT solvers (Voirol et al., 2015). Our semantics of types provides a definition of soundness for such solvers; any solver that respects the semantics can be used with our verification condition generator. Our bidirectional type checking algorithm thus becomes a new, trustworthy verification condition generator for Stainless. We were successful in verifying many existing Stainless benchmarks using the new approach.
We summarize our contributions as follows:
- •
We present a rich type system, called System FR, that combines System F with dependent types, refinements, equality types, and recursive types (Sections 3 and 4).
- •
We define a bidirectional type-checking algorithm for System FR (Section 5). Our algorithm generates verification conditions that are solved by the (existing) SMT-based solver Inox.
- •
We prove222https://github.com/epfl-lara/SystemFR/tree/oopsla2019 soundness of our bidirectional type-checking algorithm that reduces program correctness to proving that certain formulas always evaluate to true (Section 6). Our formalization also supports additional expressive notions, such as infinite intersections and unions as well as refinement conditions given by non-emptiness of an arbitrary type.
- •
We built a verification condition generator based on these foundations333https://github.com/jad-hamza/stainless/tree/type-inference and evaluated it on around k lines of benchmarks (Section 7), showing that generating proof obligations using type checking is effective in practice.
2. Examples of Program Verification and Termination Checking
Our goal is to verify correctness and termination of pure Scala functions written as in Figure 1. [x] is the precondition of the function f, and is written by the user in the same language as the body of f. The precondition may contain arbitrary expressions and calls to other functions. Similarly, the user specifies in the property that the results of the function should satisfy. To ensure termination of f (which might call itself recursively), the user may also provide a measure using the decreases keyword, which is also an expression (of type , the type of natural numbers) written in the same language. and may be arbitrary types, including function types or algebraic data types. Informally, the function is terminating and correct, if, for every value v of type such that [v] evaluates to , f(v) returns (in a finite number of steps) a value res of type such that [v,res] evaluates to . By using dependent and refinement types, this can be summarized by saying that the function f has type:
As an example, consider the list type as defined in Figure 3. We use to denote the type of integers (corresponding to Scala’s BigInt in actual source code). The function filter filters elements from a list, while count counts the number of occurrences of an integer in the list. These two functions have no pre- or postconditions. The decreases clauses specify that the functions terminate because the size of the list decreases at each recursive call.
Using these functions we define partition in Figure 3, which takes a list l of integers and partitions it according to a predicate p: . We prove in the postcondition that partitioning coincides with applying filter to the list with p and its negation.
Figure 4 shows a theorem that partition also preserves the multiplicity of each element. We use here count to state the property, but we could have used multisets instead (a type which is natively supported in Stainless). The holds keyword is a shorthand for ensuring { res => res }. The @induct annotation instructs the system to add a recursive call to partitionMultiplicity on the tail of l when l is not empty. This gives us access to the multiplicity property for the tail of l, which the system can then use automatically to prove that the property holds for l itself. This corresponds to a proof by induction on l.
Figure 5 shows a function isSorted that checks whether a list is sorted, and a function merge that combines two sorted lists in a sorted list. When given the above input, the system proves the termination of all functions, establishes that postconditions of functions hold, and shows that the theorem holds, without any user interaction or additional annotations. For the merge function, the postcondition might seem too weak to establish that e.g. Cons(x, merge(xs, l2)) is sorted just based on the fact that merge(xs, l2) is sorted. However, since we put the definition of merge while type-checking the body of merge in the context, it is possible to establish that x is smaller than the head of merge(xs, l2). We give more details on this feature of our system, called body-visible recursion, in Section 5.3.
2.1. Reasoning about Streams
Our system also supports reasoning about infinite data structures, including streams that are computed on demand. These data structures are challenging to deal with because even defining termination of an infinite stream is non-obvious, especially in absence of a concrete operation that uses the stream. Given some type X, represents the type of infinite streams containing elements in X. In a mainstream call-by-value language such as Scala, this type can be defined as:
case class Stream[X](head: X, tail: Stream[X])
For the sake of concise syntax, we typeset a function taking unit, (u:Unit)=>e, using Scala’s syntax e for a function of zero parameters. Given a stream s: , we can call s.head to get the head of the stream (which is of type X), or s.tail to get the tail of the stream (which is of type ). We can use recursion to define streams, as shown in figures 8, 8, 8. The @erasable annotation is used to mark the erasable parameters n of these functions. These parameters are used as annotations to guide our type-checker, but they do not influence the computation and can be erased at runtime. For instance, an erased version of constant (without erasable code and without type annotation) looks like:
def constant(x) = Stream(x, constant(x))
Informally, we can say that the constant stream is terminating. Indeed, it has the interesting property that, despite the recursion, for every , we can take the first elements in finite time (no divergence in the computation). We say that constant(x) is an -non-diverging stream. Moreover, when a stream is -non-diverging for every , we simply say that it is non-diverging, which means that we can take as many elements as we want without diverging, which is the case for constant(x). Note that non-divergence of constant cannot be shown by defining a measure on its argument x that strictly decreases on each recursive call, because constant is called recursively on the exact same argument x. Instead, we define a measure on the erasable argument n of the annotated version. This corresponds to using type-based termination (Abel, 2008, 2007; Barthe et al., 2008), where the type of the function for the recursive call is smaller than the type of the caller. We expand on that technique in Section 5.2.
In the annotated version of constant from Figure 8, the notation stands for streams of elements in X which are n-non-diverging. The type of constant then states that constant can be called with any (erasable) parameter n to build an n-non-diverging stream. Since parameter n is computationally irrelevant, this proves that the erased version of constant returns a non-diverging stream.
We now give a variant of constant which is diverging:
def badConstant(x) = Stream(x, badConstant(x).tail())
Indeed, given some x, a call to badConstant(x).tail() ends up evaluating badConstant(x).tail() again, and would diverge.
The zipWith function in Figure 8 takes two streams and a function f. It creates a new stream by applying f to pairs of elements taken from each stream. For zipWith, we can verify that as long as f terminates on every input, and s1 and s2 are non-diverging streams, then zipWith returns a non-diverging stream. We can then use zipWith to define the well-known Fibonacci stream (Figure 8), an infinite stream containing the Fibonacci sequence: 0, 1, 1, 2, 3, 5, 8, 13, etc. We make use of a function plus: that computes the sum of two integers. Just like the constant streams, fib is a non-diverging stream. For instance, calling fib.tail().tail().tail().head returns (in finite time) the number from the Fibonacci sequence.
The important property that the type signature of zipWith ensures is that, for every , if s1 and s2 are -non-diverging streams, then zipWith f s1 s2 is -non-diverging as well. Our type system can check this property and then use it to make sure that the definition of fib type-checks. We can also prove further properties of interest, e.g., that zipping two streams s1 and s2 with the function (x:)(y:)x returns a stream that behaves as the stream s1.
3. Syntax and Operational Semantics
We now give a formal syntax for terms and show call-by-value operational semantics. This untyped lambda calculus with pairs, tagged unions, integers, booleans, and error values models programs that our verification system supports. It is Turing complete and rather conventional.
3.1. Terms of an Untyped Calculus
Let be a set of variables. We let be the set of all (untyped) terms (see Figure 9) which includes the unit term , pairs, booleans, natural numbers, a recursor rec for iterating over natural numbers, a pattern matching operator match for natural numbers, a recursion operator fix, an error term to represent crashes. The recursor rec can be simulated using fix and match but we keep it in the paper for presenting examples.
The terms and are used to represent data structures (such as lists or streams), where ‘’ plays the role of a constructor, and ‘’ the role of a deconstructor. The terms and are used to represent the erasure of type abstractions and type instantiation terms (for polymorphism) of the form and , where is a type variable and is a type. These annotated terms will be introduced in a further section.
The term is a special term to internalize the sizes of syntax trees of values (ignoring lambdas) of our language. It is used for measure of recursive functions such as the map examples on lists shown in Section 2.
Given a term we denote the set of all free variables of . Terms are considered up to renaming of locally bound variables (alpha-renaming).
3.2. Call-by-Value Operational Semantics
The set of values of our language is defined (inductively) to be , , , , every variable , every lambda term or , the terms of the form or where , and the terms of the form where .
The call-by-value small-step relation between two terms , written , is standard for the most part and given in Figure 10. Given a term and a value , denotes the term where every free occurrence of has been replaced by .
To evaluate the fixpoint operator fix, we use the rule , which substitutes the fix under a lambda with unit argument. We do this wrapping of fix in a lambda term because we wanted all substitutions to be values for our call-by-value semantics, and fix is not. This also means that, to make a recursive call within , one has to use y() instead of y.
To define the semantics of , we use a (mathematical) function that returns the size of a value, ignoring lambdas for which it returns [math]. The precise definition is given in Figure 11.
We make use in the operational semantics of an evaluation context , which specifies through a hole the next place where reduction can occur in a term. Inductively, an evaluation context must be of one of the following forms:
[TABLE]
Given a term , we denote by the context where the hole has been replaced by .
We denote by the reflexive and transitive closure of . A term is normalizing if there exists a value such that .
4. Types, Semantics and Reducibility
We give in Figure 12 the grammar for the types that our verification system supports. Given two types and , we use the notation for when is not a free variable of . Similarly, we use the notation for when is not a free variable of .
For recursive types, we introduce the notation:
[TABLE]
Then, the type of (non-diverging) streams informally introduced in Section 2 can be understood as a notation, when X is a type, for: . Similarly, for a natural number , the type of -non-diverging streams is a notation for . Using this notation, we can also define finite data structures such as lists of elements from X, as follows: .
We show in Section 4.3 that these types indeed correspond to streams and lists respectively.
Let be the set of all types. We define a (unary) logical relation on types to describe terms that do not get stuck (e.g. due to the error term , or due to an ill-formed application such as ‘’) and that terminate to a value of the given type. Our definition is inspired by the notion of reducibility or hereditary termination (see e.g. (Tait, 1967; Girard, 1990; Harper, 2016)), which we use as a guiding principle for designing the type system and its extensions.
4.1. Reduciblity for Closed Terms
For each type , we define in Figure 13 mutually recursively the sets of reducible values and reducible terms . In that sense, a type can be understood as a specification that some terms satisfy (and some do not).
These definitions require an environment , called an interpretation, to give meaning to type variables. Concretely, an interpretation is a partial map from type variables to sets of terms. An interpretation has the constraint that for every type variable , is a reducibility candidate , which, in our setting, means that all terms in are (erased) values. The set of all reducibility candidates is denoted by , and an interpretation is therefore a partial map in .
When the interpretation has no influence on the definition, we may omit it. For instance, for every , we have , so we can just denote this set by .
By construction, only contains (erased) values (of type ), while contains (erased) terms that reduce to a value in . For example, a term in is not only normalizing as a term of its own, but also normalizes whenever applied to a value in .
The type represents the values of type for which evaluates to . We use this type as a building block for writing specifications (pre and postconditions).
The type represents the values that are in the intersection of the types when ranges over values of type . This type differs from in the sense that a value in belongs to every for in , while a value in is a function that, when applied to some in , produces a value in . From a value in , we can build a value in (namely, ), while the other way around is not always possible.
The sum type represents values that are either of the form where is a reducible value of , or of the form where is a reducible value of .
The set of reducible values for the equality type makes use of a notion of equivalence on terms which is based on operational semantics. More specifically, we say that and are equivalent, denoted , if for every value , we have . Note that this equivalence relation is defined even if we do not know anything about the types of terms and , and it ensures that if one of the terms reduces to a value, then so does the other.
The type is the polymorphic type from System F. The set is defined by using the environment to bind the type variable to an arbitrary reducibility candidate.
We use the recursive type as a building block for representing data structures such as lists of streams. The definition of reducibility for the recursive type makes use of an auxiliary function that can be seen as an (upper) approximation of the recursive type. Note that (defined at the bottom of Figure 13) removes the type variable from .
Our reducibility definition respects typical lemmas that are needed to prove the soundness of typing rules, such as the following substitution lemma (see (Girard, 1971) for the lemma on System F), which we have formally proven (see also Section 6 below).
Lemma 4.1.
Let and be two types, and let be a type variable that may appear in but not in . Let be a type interpretation. Then, we have:
[TABLE]
4.1.1. Well-Foundedness of the Reduciblity Definition
We can show that the definition given Figure 13 is well-founded by defining a lexicographic measure on types . The function returns the size of the syntactic tree of type , ignoring the terms that appear inside. This size roughly corresponds to the number of (top-level) type constructors in the tree of . For example, (ignoring ), , and . Ignoring the size of terms inside types ensures that given a type , a term variable , and a term , we have: . As a result, the measure in the definition of reducibility decreases for indexed types such as or .
The number decreases in every case of Figure 13, except for recursive types where the measure stays the same in the recursive call to the denotation on with . This is where we use the second component of the lexicographic measure, . We define and for every other type. Then, given , we consider to be (strictly) smaller than if there exist , such that , , and is strictly smaller than when seen as a natural number. Therefore, the second component is strictly smaller than in the definition (because ), which ensures that the overall lexicographic measure decreases.
4.2. Reduciblity for Open Terms
Having defined reducibility for closed terms, we now define what it means for a term with free term and type variables to be reducible for a type . Informally, we want to ensure that for every interpretation of the type variables, and for every substitution of values for the term variables, the term reduces in a finite number of steps to a value in type . This is formalized by a (semantic) typing relation which is defined as follows.
First, a context is made of a finite set of type variables and of a sequence of pairs in . The domain of , denoted is the list of variables (in ) appearing in the left-hand-sides of the pairs. We implicitly assume throughout the paper that all variables appearing in the domains are distinct. This enables us to use as a partial map from to . We use a sequence to represent as the order of variables is important, since a variable may have a (dependent) type which refers to previous variables in the context.
Given a partial map , we write for the term where every variable is replaced by . We use the same notation for applying a substitution to a type .
Given a context , a reducible substitution for is a pair of partial maps and where: , , and .
Note that the substitution is also applied to the type , since may be a dependent type with free term variables. The set of all pairs of reducible substitutions for is denoted .
Finally, given a context , a term and a type , we say that holds when for every pair of substitutions for the context , belongs the reducible values at type . Formally, is defined to hold when:
[TABLE]
Our bidirectional type checking and inference algorithm in Section 5 is a sound (even if incomplete) procedure to check .
4.3. Recursive Types
We explain in this section how to interpret the type (see reducibility definition in Figure 13) and how the and types represent streams and lists.
4.3.1. Infinite Streams
For a natural number , consider the type . Let us first see what represents for small values of . As a shortcut, we use the notations [math], , , for , , ,
The definition refers to , which is by definition. This means that is the set of values of the form , where , and .
By unrolling the definition, we get that is the set of values of the form where is in , which is the same (by Lemma 4.1) as . Therefore, is the set of values of the form where and . This means that when it is applied to , terminates and returns a value in . Similarly, is the set of values of the form where and .
To summarize, we can say that for every , represents values of the language that behave as streams of natural numbers, as long as they are unfolded at most times. This matches the property we mentioned in Section 2, as represents the streams that are -non-diverging. We can show that as grows, gets more and more constraints: In the limit, a value (which is in every for ), represents a stream of natural numbers, that, regardless of the number of times it is unfolded, does not diverge, i.e. a non-diverging stream. Equivalently, we have .
4.3.2. Finite Lists
Types of the form can also be used to represent finite data structures such as lists. We let be a notation for , so that:
[TABLE]
Here are some examples to show how lists are encoded:
- •
The empty list is ,
- •
A list with one element is ,
- •
Given an element and a list , we can construct the list by writing: .
Let us now see why represents the type of all finite lists of elements in . The first thing to note is that given , does not represent the lists of size . For instance, we know that is the set of values of the form where , i.e. . Therefore, contains lists of all sizes (and also all values that do not represent lists, such as or ).
Instead, can be understood as the values that, as long as they are unfolded no more than times, behave as lists. As for streams, we have: where the monotonicity follows because only appears in positive positions in the definitions of the recursive types for streams and lists. In the limit, we can show that contains all finite lists, and nothing more.
Lemma 4.2.
Let be a value and be some type. Then, if and only if there exists and such that .
It may seem surprising that the type of streams contains infinite streams while the type of lists only contains finite lists. The reason is that, in a call-by-value language, a value representing an infinite list would need to have an infinite syntax tree, with infinitely many ’s (which is not possible). On the other hand, we can represent infinite streams by hiding recursion underneath a lambda term as shown in Section 2.
5. A Bidirectional Type-Checking Algorithm
In this section, we give procedures for inferring a type for a term in a context , denoted , as well as for checking that the type of a term is , denoted . We introduce rules of our procedures throughout this section; the full set of rules is given in figures 14 and 15.
Our inference and checking rules give rise to conditions of the form . We call such checks verification conditions (in the rules, they are boxed and appear in blue color). The sign is part of the judgment form, and does not describe a formula. We rely on an external solver to perform these checks, and assume that when the verification condition is considered valid by the solver, then: . This is an equivalent way of saying that is non-empty. Under these conditions, we have the following theorem.
Theorem 5.1 (Soundness of the Bidirectional Type-Checker).
If holds or if holds, then holds.
5.1. Annotated Terms
In order to guide our type-checking algorithm, we require terms to be annotated. We give in Figure 16 the grammar for annotated terms. The term is used to instantiate a term which has a type of the form to a particular term of type , in the (Infer Forall Instantiation) type inference rule of Figure 14. The term is an annotated variant of (see rules (Infer Unfold) and (Infer Unfold Positive) in Figure 14). We discuss the difference between these rules in Section 5.6.
The type represents the type where the variable is bound to by using let’s in each term that appears in . The formal definition is given in Section 5.
Annotations such as or have no runtime influence and are erased (respectively to and ). We write to refer to the erasure of , where every annotation has been erased. The full definition is given in Appendix C, Figure 28.
When a type has annotated terms inside, we write to erase their annotations. For instance refers to . Moreover, for a context , we write to refer to the context where each type has been replaced by .
5.2. Contracts and Measures
The syntax we support in our verification tool translates into our core calculus presented above. In our tool we support named functions with contracts and measures which are desugared into fix terms. To compare natural numbers and express the fact that measures decrease, we use functions ‘<’, ‘<=’ and ‘==’ on natural numbers. These functions can be defined using the recursor rec (see Appendix E for definitions).
Figure 17 shows how, thanks to refinement types, the fix term can encode recursive functions (such as the one given in Section 2) that feature user-defined pre- and post-conditions and whose termination arguments relies on a user-defined measure function. The fix term shown on the right corresponds to the desugaring of the recursive function on the left whose contracts are given by the require and ensuring keywords, and whose measure is given by the decreases keyword. The contract terms and are such that and , and the measure function satisfies . The term is a function of type that returns the predecessor of numbers greater than .
We now explain how our type-checking algorithm ensures termination of such a function. Our type inference rule for fix is (Infer Fix). The side condition ensures that only appears in type annotations in , and is not part of the computation. The other check corresponds to a proof by strong induction (over ) that the fix term has type . Indeed, we have to check that , the body of the fix term, has type (for some ), under the assumption that (which is the variable representing the recursion) has type for all . The ‘’ part of the type of corresponds to the fact that the operational semantics of fix replaces variable by the fix term under a lambda (as explained in Section 3).
The variable is a witness that the variable is equal to the fix term (under a lambda). This feature is useful for body-visible recursion, and is explained in Section 5.3.
Back to the encoding presented in Figure 17, we explain how the (Infer Fix) rule ensures that decreases at each recursive call of function f. Assume that the premise of the (Infer Fix) rule holds, and that f is called with some value of type , such that evaluates to some (term representing a) natural number . By instantiating the premise of the (Infer Fix) rule for that particular , we get that is well-typed under the condition that has type:
[TABLE]
First, in order for to be applied to , we have to check that is non-zero, meaning that the measure of is strictly positive in the places where the recursive calls happen. This is ensured by the (Check Refinement) rule for checking refinement types (see Figure 15), which generates a verification condition.
Second, the rule (Infer Forall Instantiation) ensures that takes arguments of type . Therefore, if is applied recursively to an argument , the rule (Check Refinement) ensures that holds. Overall, we get , which ensures that the measures of arguments always decrease on recursive calls to f.
In our implementation, we do not go through the encoding with fix and forall types, but instead directly generate the verification conditions that correspond to the measure decreasing by using the left-hand-side form of Figure 17. Our system also supports mutually recursive functions (by requiring that the measure decreases for each call to a mutually recursive function), which can be encoded in the usual way by defining a fix term that returns a tuple of functions.
In the end, if the body of the function is well-typed, the (Infer Fix) rule infers the type:
[TABLE]
One should note that this encoding imposes a scoping restriction on the original program, namely precondition, postcondition, and measure of a function cannot contain calls to . This restriction has not proved limiting in our experience with benchmarks.
5.2.1. Lexicographic Orderings
Functions whose termination arguments require lexicographic orderings can be encoded by using two levels of recursions, which is a known technique that shows expressive power of System T (Girard, 1990, Section 7.3.2). We review how this encoding works in our system in Appendix A and show an example of Ackerman’s function and its simple lexicographic measure. In our implementation, we support lexicographic measures directly.
5.3. Body-Visible Recursion
In this section, we give more details about the (Infer Fix) and (Infer Rec) typing rules for recursion. They allow body-visible recursion which gives the type-checker access to the definition of a recursive function while type-checking the body of the recursive function itself.
The first thing to note is that we introduce an equality type containing the definition (in the type of ) in the context, while we do not know yet whether the body of the recursion is well-typed. Since our equality type is defined (in Section 4) for all terms, regardless of whether they are well-typed, this is perfectly legal. We show in the merge example of Figure 5 how body-visible recursion relieves the user from writing excessive specification annotations.
Assume we want to prove, on paper, that merge indeed returns a sorted list when given two sorted lists l1 and l2, by induction over size(l1) + size(l2). Consider the first branch of the if then else statement, where we return Cons(x, merge(xs,l2)). By the induction hypothesis, we know that the recursive call merge(xs,l2) is sorted, but this mere fact is not enough to conclude that Cons(x, merge(xs,l2)) is sorted. By unfolding the definition of isSorted, we see that we need in addition to know that x is smaller than the head of the result merge(xs,l2).
Therefore, the property we prove by induction needs to be strengthened by saying that the head of the result, if non-empty, is equal to the (smallest) head of one the input lists. From that, we will know by the induction hypothesis that the head of merge(xs,l2) (if non-empty) is either the head of xs or the head of l2. In the first case, we can deduce that x is smaller than by using the fact that l1 = Cons(x,xs) is sorted. In the second case, we have , and we know from the condition of the if then else statement that x y. In both cases, we can conclude that the whole list Cons(x, merge(xs,l2)) is sorted.
If we are to type-check the program above, and if we only know the return type of merge(xs,l2), that is { l: List| isSorted(l) }, we will run into the same problem, and will not be able to conclude that Cons(x, merge(xs,l2)) is sorted. In our type system, we get in addition access to the definition of merge while type-checking it, thanks to the variable of equality type in the (Infer Fix) rule. By unfolding the definition of merge(xs,l2), we conclude by case analysis that the head of merge(xs,l2) (if non-empty) is either the head of xs or the head of l2 (which is y).
Without body-visible recursion, the developer would need to strengthen the postcondition to:
[TABLE]
In Inox, the external solver we use for verification conditions, definitions of recursive functions are unfolded automatically. Inox does incremental queries to SMT solvers. It first sends a query without unfolding at all, then a new query after unfolding once, and so on until a query succeeds or a timeout. Thanks to this approach, Inox does not rely on universal quantifiers to encode recursive functions. This feature is crucial to have such examples be verified without user intervention, and is here required to get the bodies of the calls to merge and isSorted when verifying the postcondition of merge.
5.4. Unification in Type Inference
In order to perform type inference, we expect a certain structure on the inferred types. For example, given term , we expect term to have a function type when inferring the type of an application. Furthermore, type inference must perform least upper bound computation for if, match and either_match terms, which adds more complexity to the system. We handle these considerations in a generalized manner by performing type unifications, defined in Figure 18 by using the type-level notations If Then Else, Let, Either_Match, and Match.
We draw attention here to the fact that our type checking and inference procedures are syntax directed and predictable, which enables a natural verification process through term-level hints. The algorithmic nature of our type checking precludes the verification of certain well-typed programs. However, our experience has shown that this limitation is largely inconsequential in practice and is outweighed by the predictable nature of the algorithm.
5.5. Hiding Recursive Type Indices
Beyond general rules of Figure 14, in Figure 19 we show two additional rules for ‘fold’ and ‘unfold in’ that ignore the indices hidden under the Rec type for strictly positive recursive types (which is the case for the List and Stream types). We write when a type variable appears only strictly positively in type , meaning only to the right of and types (see Appendix B for the precise definition). This enables us, under some conditions, to fold and unfold a strictly positive recursive type without worrying about indices.
Practically, given an element of type (resp. ), we can unfold it (with the (Infer Unfold Gen) rule) to get its head and its tail of type (resp. ). Conversely, we use the rule (Infer Fold Gen) to build a list or a stream from an element and a tail.
Strict positivity gives us the following key lemma that ensures the soundness of our rules with respect to our reducibility definition. This lemma states that when a type variable appears only strictly positively in , then quantifying with a forall type outside or inside a substitution for is the same (as long as we are quantifying over a non-empty type ). This property is similar to the notions lim sup-pushable and lim inf-pullable (Abel, 2008) and implies the soundness of our rules.
Lemma 5.2.
Let and be two types. Let be a type variable that appears strictly positively in . Let be a type interpretation such that is not empty. We have:
[TABLE]
5.6. Type-Checking Algorithm Examples: Streams
5.6.1. Constant Stream
The fix term and associated typing rules can also be used to express the kind of recursion used to define the streams in Section 2. We start by revisiting the constant stream, which in our notations can be written as an untyped term:
[TABLE]
Assume we want to prove, on paper, that for any value , produces a non-diverging stream, i.e. a stream which is -non-diverging for every . A natural proof could be done by induction on , as follows:
- •
is [math]-non-diverging, meaning that it reduces to a value of the form where and are values. This is clear from the code of constant, as this expression evaluates in a few steps to .
- •
Assume by induction that is -non-diverging. By definition of -non-diverging, we get that is -non-diverging. Since this term is equivalent to the term to which evaluates, we conclude that is -non-diverging as well.
Our type system and type-checking algorithm can be used to simulate this proof by using an annotated version of constant:
[TABLE]
where is a shorthand for
[TABLE]
By applying the (Infer Fix) rule presented above, we get the type
[TABLE]
The (Infer Fix) rule of our algorithm generates a check that corresponds to a (strong) induction that shows that for every , in assuming that it is in for all :
[TABLE]
After applying standard rules related to and , our algorithm will attempt to infer, using the (Infer Fold) rule, a type for the term:
[TABLE]
In addition to the type-check that has type , this rule generates two checks to cover the cases where is zero or non-zero. These correspond to the informal proof by induction given above for the non-divergence of constant. The first check reduces (after applying some straightforward rules) to checking that has type (remember that ), which goes through easily thanks to the rule (Check Top 1).
The second check amounts to checking that has type under the assumption that . By the context we know that has type . Since and are equivalent, we can use the rule (Check Recursive) (given in Figure 15) to convert between the two types.
Attempting to type-check the badConstant example from Section 2:
[TABLE]
where stands for:
[TABLE]
will lead to an error in the second check (corresponding to the inductive case), since the extra call to tail() decreases the index of the stream by one.
5.6.2. ZipWith Function on Streams
We now revisit the zipWith function from Figure 8. We said in Section 2 that for every , when s1 and s2 are -non-diverging streams, then so is zipWith(f,s1,s2). On paper, we can check by induction over that when s1 and s2 are -non-diverging streams and is terminating, then zipWith(f,s1,s2) (as written in Figure 8, and ignoring type annotations and the erasable parameter for the moment) is also an -non-diverging stream.
- •
It is indeed the case that we can access the head of zipWith(f,s1,s2) as long as we can access the heads s1 and s2 (and as long as terminates).
- •
Let s1 and s2 be two -non-diverging streams. By definition of non-diverging, we know that s1.tail() and s2.tail() are -non-diverging. By induction hypothesis,
zipWith(f,s1.tail(),s2.tail()) is -non-diverging as well. This means that zipWith(f,s1,s2) is -non-diverging, which concludes the proof.
Accordingly, our type-checking algorithm is able to infer the type
[TABLE]
for the annotated zipWith term given in Figure 20. We use the term ‘unfold in’ to access the heads (with ). For the tails, we use instead ‘unfold_pos in’. To understand the difference, we must first look at the (Infer Fold) rule for the fold term, which will generate two subgoals, one with and one with . In the subgoal with , we must check that the lambda term ‘’ has type , which goes through directly thanks to (Check Top 1). Therefore, when we are type-checking the body of the lambda, we know that . We can thus access the tails using (Infer Unfold Positive), a variant of (Infer Unfold) that discards a subgoal with but requires proving instead.
5.6.3. Fibonacci Stream
We now consider the Fibonacci stream function from Figure 8. We can prove by induction that for all , fib is an -non-diverging stream. In our view, this corresponds to writing fib as in Figure 21.
When type-checking fib, we use the type of zipWith and instantiate it to pred(pred(n)), and get that: inst(zipWith,pred(pred(n))) has type:
[TABLE]
This is where we use the fact that zipWith returns an -non-diverging stream when given -non-diverging streams.
5.7. Verification of Properties
Finally, we show how to verify the property mentioned in Section 2: that zipping two streams s1 and s2 with the function returns a stream equivalent to s1.
We define a function zipWith_fst with the following type:
[TABLE]
For short, we denote this type as . The code of zipWith_fst is given in Figure 22 and makes use of the recursor rec. Our (Infer Rec) rule for inferring the type of rec is similar to fix. The difference is that rec allows references to in the code, so the computation is allowed to depend on . Moreover, rec does not use types. Finally, rec is analogous to a simple induction, while fix is analogous to a strong induction.
In the zipWith_fst example, applying this rule corresponds to a proof by induction over , that for any two streams s1 and s2, the th element of s1 is equal to the nth element of .
In the base case, to type-check the term , we must make sure that the following equality holds:
The corresponding type inference rule is (Infer Refl), which generates a verification condition.
In the inductive case, we explicitly instantiate our inductive hypothesis on the tails of s1 and s2 by using the let binding on variable pr. The equality given by the type of pr is then sufficient to prove what we wanted:
This example also illustrates that developers can express the desired versions of what others might call extensional equality (here: same results when calling nth) using types and equi-reducibility ; our type system does not impose any preferred form of extensional equality.
6. Formalization in the Coq Proof Assistant
We here give more details about our formalization of Theorem 5.1 in Coq 8.9.1 (including the rules from Section 5.5). Our proofs are available from
https://github.com/epfl-lara/SystemFR/tree/oopsla2019
We represent terms and types using a locally nameless representation (Charguéraud, 2012), where free variables are named, and where local variables are bound using De Bruijn indices. Using this representation, lambdas and other binders can be seen as terms with holes, which can be filled with other terms (typically values). We use the Coq Equations library (Sozeau, 2010) to define the reducibility logical relation . This library facilitates the use of functions which are defined recursively based on a well-founded measure.
We give an overview of our files (containing around k lines of code):
- •
Trees.v contains the definitions of types and terms,
- •
Typing.v gives all typing rules (containing rules from the paper and more),
- •
SmallStep.v contains the operational semantics of the language,
- •
ReducibilityDefinition.v contains the definition of reducibility,
- •
The Reducibility*.v files contain lemmas for the soundness of the rules from Typing.v,
- •
Reducibility.v contains the proof that all typing rules from Typing.v are sound with respect to the reducibility definition (which implies that Theorem 5.1 holds).
6.1. Extensions of Formalization
Our approach to formalization proved flexible and allowed us to extend the system with additional types. In addition to formalizing the types and rules for our type checking algorithm (figures 15 and 15), our Coq formalization also defines (Figure 23) reducibility for the bottom type, singleton types, union and intersection types, and an existential type more abstract than dependent pair (and dual to ). We also formalized refinement by type , which generalizes refinements from computable terminating terms of our language to the condition of non-emptiness of arbitrary types in our system. Existential type and refinement by type can be viewed as second and first projections of dependent pairs. Together with existential and universal types, refinement by type allows us to refine types by quantified propositions, providing support for quantified preconditions and postconditions. Some of the type forms we present can be expressed using others; the set of type forms is not minimal.
We define further syntactic sugar, including precise if then else and match types (Figure 24), as an alternative to the that relies on simplifications of Figure 18. We rely on Let type of Figure 23, and we use the ill-typed term in the computations of (respectively) pred, unfold_left and unfold_right, to make sure that the denotations of the Let types are empty when does not reduce (respectively) to a strictly positive natural number, , or for some . We proved soundness of typing rules that show that these types behave as expected. For instance, an if then else expression can be assigned an if then else type of the branches.
These types can be used to encode type level computation reminiscent to one present in Scala HList444https://github.com/milessabin/shapeless/blob/master/core/src/main/scala/shapeless/hlists.scala, as illustrated by the following example. Given a list and types and , we define a type that represents nested pairs from and according to the boolean values ( for and for ) and with nesting given by the length of . For example, when is the list then represents nested tuples of the form where , , .
[TABLE]
The function returns the th element of the list (defined as a recursive data type). The function is also a term-level function that operates on nested pairs such that e.g. expands to . The type in the expansion of refinement by judgement-as-type (Figure 24) does not require type checking the function; its semantics (see in Figure 13) is that reduces to in the (untyped) operational semantics.
Furthermore, we prove soundness of multiple rules (such as congruence rules) for establishing the equality judgment described in Section 5. We also prove soundness of a rule that unfolds the definition of a recursive function in the context, which is what is required by the solver in Section 5.3 to unfold the definition of merge. Such rules are a step towards justifying not only the verification condition generation but also verification condition solving.
7. Implementation and Evaluation
We have implemented our bidirectional type checking procedure by writing an alternative verification-condition generator for Stainless555https://github.com/epfl-lara/stainless (LARA, 2019). The code is merged into master and available in e.g. release version 0.4.0. Its functionality can be invoked with the --type-checker command line option in both scalac 2.12 and Dotty front end pipeline for Stainless. Thanks to these frontends, which provide a form of type inference, the type annotation burden is lessened for the user. Precise types such as indexed recursive types (which are not supported out of the box by these frontends) still need to be annotated manually.
The implementation was evaluated on benchmarks shown in Figure 25 totalling 14k LoC, collected from existing Stainless test suites and case studies. The proof/code ratio depends on the properties being proven. It can be 0 or close to 0 as in the merge example of Section 2, or higher than 1 when writing lemmas for proving detailed specifications (as in the Huffman coding example). The benchmarks reside in the frontends/benchmarks/typechecker/valid directory of Stainless. The streams benchmark relies on more expressive annotations of the Dotty compiler that we use to write recursive types, and is available in frontends/benchmarks/dotty-specific/typechecker. We use the following syntax to represent indexed types:
[TABLE]
The suite of all benchmarks verifies in 6 minutes in total when using implementation based on our type checker. The table shows the number of lines of codes within each benchmark, and the number of verification conditions which were generated by the type checker and verified by the SMT-backed Inox solver. The time given includes the time for generating the verification conditions and solving them, but not the parsing and compilation which is done by the Scala compiler scalac (which we use for some initial type inference and to obtain a tree representation of the program), nor the transformations which are internal to Stainless and which happen before using the type-checker. To understand the impact of these time measurements, note that 11% of time of total verification time is spent in the bidirectional type-checking algorithm that generates verification conditions and that is the focus of this paper, 66% in checking the verification conditions using a SMT-backed solver Inox for recursive and higher-order functions, 13% for the parsing, name resolution and most of the type checking pipeline of Scala’s scalac compiler, and 10% for extraction of scalac abstract syntax trees to Stainless trees. All times were measured on a Lenovo X1 Carbon laptop with an Intel i7-7500U and 16GB RAM.
To illustrate the diversity of benchmarks, we note that InsertionSort, QuickSorts, MergeSorts and StableSort feature various implementations of the sorting algorithms with the typical properties shown. ListMonad and OptionMonad show that the monadic laws hold for the and Option[X] types. The List and ListWithSize benchmarks feature a collection of common higher-order functions on lists such as map, filter, forall, etc., as well as many properties shown about the implementations. In the GodelNumbering benchmark, we prove that the pairing function is a bijection between natural numbers and pairs of natural numbers using a series of lemmas about linear and non-linear arithmetic. Similar non-linearity is featured in the MoreExtendedEuclidGCD benchmark where we show that an implementation of the extended Euclid’s algorithm indeed computes the greatest common divisor and the coefficients of Bézout’s identity. Benchmarks also include persistent data structures (Okasaki, 1998), ConcTree and ConcRope (Prokopec and Odersky, 2015), explicit state model checker ReachabilityChecker, small interpreters, dynamic programming algorithms such as Viterbi and Knapsack, and benchmarks solving assignments from Scala MOOCs (see https://courseware.epfl.ch/ or https://www.coursera.org/specializations/scala).
8. Related Work
In this paper, we tackle program verification using a type-theoretic approach. Other techniques, such as symbolic execution for verification (see e.g. (Nguyen et al., 2017; Hallahan et al., 2019)) or using term rewriting systems (see e.g. (Giesl et al., 2004, 2006, 2011)) can be used instead or in complement to our approach. The use of type checking in verification appears in program verifiers such as F* (Swamy et al., 2013; Ahman et al., 2017; Swamy et al., 2016) and Liquid Haskell (Vazou et al., 2013; Vazou et al., 2014, 2018). Like Dafny (Leino, 2010) and Stainless, these systems rely on SMT solvers to automatically discharge verification conditions.
F* is a dependently-typed programming language which supports a rich set of effects (such as divergence, mutation, etc.). To the best of our knowledge F* does not support proving termination of functions operating on infinite data structures such as streams. Stainless has a desugaring pass that handles local state and certain forms of unique references. Furthermore, for semantic modeling of global state, a user can use monad syntax (Scala’s for comprehensions) explicitly in the surface language, which Scala compiler desugars into higher-order functions that our system can handle. Overall, F* supports a more expressive class of effects than what Stainless currently handles. The goal of Liquid Haskell (Vazou et al., 2018) is to add refinement types to Haskell, a call-by-need language (while we focus here on call-by-value languages). The system supports a sound verification procedure which relies on decidable theories of SMT. The supported language imposes certain restrictions on refinement occurrences and the metatheory does not feature recursive types (although they are supported by the implementation). Similarly to our body-visible recursion, Liquid Haskell allows the type checker to access the body of recursive functions (for recursive calls) while type-checking the function itself using a technique called refinement reflection (Vazou et al., 2018). Dafny verifier (Leino, 2010) supports imperative and object-oriented as well as functional programming, including inductive and coinductive types, recursion using ordinals, quantifiers. We are not aware of metatheory to justify the soundness of Dafny. In contrast, we provide a mechanized proof of soundness for System FR, which increases confidence in VC generation soundness. This is important because it is easy to construct paradoxes when combining features such as impredicativity and contravariant recursion. Being based on a reducibility relation, our proof can be used as a basis to add new language features in a compositional way. Several other systems have been recently formalized, such as MetaCoq (Sozeau et al., 2019).
Proof assistants with great expressive power include Isabelle (Nipkow et al., 2002b; Blanchette et al., 2017), Coq (Bertot and Castéran, 2004b; Barras, 2010; Sacchini, 2013), Idris (Brady, 2013), Agda (Norell, 2007; Abel, 2010), PML2 (Lepigre, 2017), Lean (de Moura, 2016) and Zombie (Casinghino et al., 2014). When coinduction is supported in these systems (which is not always the case), it is typically through special constructs for coinductive types and their corresponding cofixpoint operators, dual to inductive types and their fixpoint operators. In System FR, we instead treat induction and coinduction in a uniform way (see e.g. Section 5.6). Our type system features a single kind of recursive types which allow uniform definition of inductive, coinductive and mixed recursive types. Our operational semantics further rely on the fix operator which roughly corresponds to general recursion in mainstream programming call-by-value languages. An alternative approach to uniform handling of recursion and corecursion is given in (Gianantonio and Miculan, 2002). We were able to encode the first example from this work (in our Streams benchmark) which generates a stream and whose termination proof requires a combination of inductive and coinductive reasoning. Using our system, we expressed that using a simple lexicographic measure combining the index of the type of the produced stream with the inductive measure. In a private communication, Andreas Abel showed us that Agda can also handle this example.
Our system follows Nuprl (Constable et al., 1986) (and other computational type theories) style of starting out with an untyped calculus and then introducing various types to classify untyped terms based on their behaviors. Nuprl supports a very expressive type system, which covers the types we present in this paper, except impredicative polymorphism. In a large development, Nuprl’s metatheory has been formalized in Coq (Anand and Rahli, 2014). One part of that formalization relies on the use of the Coq axiom of functional choice FunctionalChoice_on, which gives a function from to when a formula of the form holds. Nuprl does not use SMT solvers for automation, but relies instead on built-in and user-defined tactics similarly to Coq.
Our reducibility definition for recursive type is inspired from step-indexed logical relations (Ahmed, 2006). The main difference is that the indices in step-indexed logical relations do not appear at the level of types, but at the level of the logical relation that gives meaning to types. In System FR we internalize the indices at the level of recursive types in order to give more expressive power to the users, and let them specify decreasing measures for recursive functions that manipulate infinite data structures. This treatment of recursive types is similar to the TORES (Jacob-Rao et al., 2018) type system, where recursive types can be indexed by an arbitrary index language. We only support recursive types indexed by natural numbers. TORES provides a decision procedure for a rich type system with inductive types, coinductive types, and indexed recursive types, yet its metatheory does not handle polymorphism nor refinement types.
Our termination criterion (rule for fix) is inspired by type-based termination (Barthe et al., 2004; Abel and Pientka, 2013; Abel, 2012, 2004, 2008, 2007). Such work also typically uses two different kinds of recursion, one for induction and one for coinduction. In type-based termination, instead of requiring that a measure on the arguments of recursive calls decreases, we require that recursive functions are called at a type which is strictly smaller than the type of the caller. Our fix operator produces a term with a forall type, which is similar to the implicit function type of the implicit calculus of constructions (Miquel, 2001). Our termination measure on types can then be understood as a measure on the implicit argument of a function with an implicit function type. Earlier work on type-based termination checking includes simpler type systems such as DML (Xi, 2001).
A proof of concept of extension of Dotty compiler by a restricted set of (mostly numerical) refinement predicates was presented in (Schmid and Kunčak, 2016), without soundness proof. Developments specific to Scala include dependent object types (Amin, 2016) which focus on path dependency instead of predicate refinement and, by design, admit possibly non-terminating programs. System FR does not attempt to support records, let alone path dependency. Stainless relies on Scala compiler front ends to obtain a symbol-resolved syntax tree. For example, implicit parameters in Scala (Odersky et al., 2018) become ordinary explicit parameters by the time Stainless processes them. Conversely, many of the types we define in System FR do not have their counterparts in Scala. An upcoming PhD thesis of Nicolas Voirol (Voirol, 2019) presents a system related to System FR that more closely follows the expressive power of Inox solver on which Stainless relies.
9. Conclusion
We have presented System FR, a formalized type system and a bidirectional type checking algorithm that can serve as a basis of a verifier for higher-order functional programs. We were able to verify correctness and safety of a wide range of benchmarks amounting to 14k lines of code and proofs. Our formalization suggests that lazy data structures and non-covariant recursion are tractable and that explicit indices required in the general framework can be eliminated in commonly occurring cases. Our type system incorporates and types, yet it also supports their variants ( and ) that correspond to infinite intersections and unions. Along with support for singleton types and refinements, we obtained a rich framework to approximate program semantics, which can also help further type, invariant, and measure inference algorithms. Our experience confirms an advantage of the semantic-based soundness proof: once we adopt an approach of interpreting types as sets of terms, we are less dependent on a particular choice of syntactic rules; we can introduce new classes of types and new rules for verification condition generation and solving, as long as we can justify them semantically. When viewing types in our system as propositions, we obtain an expressive quantified logic. We have proven the soundness of this logic in Coq using countable interpretation of ground System FR types. The success in verification of our benchmarks suggests that the rules of System FR work well for many properties of functional programs. Furthermore, refinement by type along with intersections and unions (Section 6.1) allows System FR to describe types whose interpretations are undecidable countable sets from higher levels of arithmetical hierarchy.
ACKNOWLEDGMENTS
Work supported in part by Swiss National Science Foundation project 200021_175676 “Scaling Predicate Types”. We thank Ravichandhran Kandhadai Madhavan for numerous discussions about termination for higher-order functional programs; Romain Ruetschi for his valuable contributions to Stainless implementation; Paolo G. Giarrusso for interesting discussions and very relevant pointers on type theory; Andreas Abel for explaining us how to mix induction and co-induction in Agda. Thanks to Yoan Géran who implemented another prototype of our algorithm, found formalization discrepancies and proposed improvements to the typing rules.
Appendix A Lexicographic Orderings
Functions whose termination arguments require lexicographic orderings can be encoded by using two levels of recursions, which is a known technique that shows expressive power of System T (Girard, 1990, Section 7.3.2). The right-hand-side uses (twice) the syntactic sugar defined in Figure 17 and described in the previous section. The outermost recursion allows recursive calls whenever the first measure decreases, while the innermost one is used when the first measure stays the same and the second measure decreases.
For the encoding, we assume that in the body of function f (i.e. in the expression ), f is always applied to some argument. The notation
[TABLE]
represents the term where that every application of the form f in is replaced by the corresponding if then else expression. This expression checks at runtime which measure decreases, and decides to call the outermost or innermost recursion. When the user knows which measure decreases for a given recursive call, the if then else expression can be optimized away by directly calling the appropriate branch. We give as an example the Ackermann function (see Figure 26), which uses the lexicographic ordering of its argument for ensuring termination.
Appendix B Strict Positivity of a Type Variable in a Type
A type variable is said to be strictly positive (see Figure 27) in a type , if it only appears to the right-hand-sides of and types. This restriction is used in the additional typing rules that are given in Section 5.5.
Appendix C Erasure of Type Annotations in Terms
In Section 5.1, we use the notation to refer to the erasure of an annotated term . The precise definition is given in Figure 28. Type annotations are used to guide our type-checking algorithm but play no role in the reducibility definition or in the operational semantics, which talk about erased terms with no annotation.
Appendix D Proof of Lemma 4.2
See 4.2
Proof.
Given a list of the form , we say that is its size.
() We prove by induction on (the size of) that contains all finite lists. Then we can conclude that contains all finite lists.
- •
. By definition, contains all values of the form , and therefore all finite lists.
- •
. The induction hypothesis tells us that contains all finite lists. Consider a list , and such that:
[TABLE]
We distinguish two cases, if , i.e. is the empty list, then we conclude directly by definition of that .
If , then . By definition of , we know that if and only if the tail of belongs to , i.e. , which is true by induction hypothesis.
) In a first step, we prove by induction on (the size of) the following statement (): if , then either is a list of size at most , or there exists and such that .
We can then use this fact to prove that if , then is a finite list, as follows. By definition, we know that, for every , . The term is represented by a finite syntax tree, and therefore there exists an (e.g. the size of the syntax tree of plus one) such that cannot be of the form . By using (), it follows that must be a list of size at most .
Let us now proceed to the proof of ():
- •
The statement holds since represents the values of the form where , and we can choose .
- •
By definition of , we know that either is the empty list, or there exists and such that . By induction hypothesis, we know that is either a list of size at most or there exists and such that .
We therefore conclude that is either a list of size at most , or that:
[TABLE]
∎
Appendix E Operations on Natural Numbers
Figure 29 shows the definitions of lessThan, lessEqual, and equalNat that implement comparison and equality on the type.
The reference list from the paper itself. Each links out to its DOI / PubMed record.
- 1(1)
- 2Abel (2004) Andreas Abel. 2004. Termination checking with types. ITA 38, 4 (2004), 277–319. https://doi.org/10.1051/ita:2004015 · doi ↗
- 3Abel (2007) Andreas Abel. 2007. Type-based termination: a polymorphic lambda-calculus with sized higher-order types . Ph.D. Dissertation. Ludwig Maximilians University Munich. http://d-nb.info/984765581
- 4Abel (2008) Andreas Abel. 2008. Semi-Continuous Sized Types and Termination. Logical Methods in Computer Science 4, 2 (2008). https://doi.org/10.2168/LMCS-4(2:3)2008 · doi ↗
- 5Abel (2010) Andreas Abel. 2010. Mini Agda: Integrating Sized and Dependent Types. In Partiality and Recursion in Interactive Theorem Provers, PAR@ITP 2010, Edinburgh, UK, July 15, 2010 (E Pi C Series) , Ekaterina Komendantskaya, Ana Bove, and Milad Niqui (Eds.), Vol. 5. Easy Chair, 18–32. https://doi.org/10.29007/322q · doi ↗
- 6Abel (2012) Andreas Abel. 2012. Type-Based Termination, Inflationary Fixed-Points, and Mixed Inductive-Coinductive Types. In Proceedings 8th Workshop on Fixed Points in Computer Science, FICS 2012, Tallinn, Estonia, 24th March 2012. (EPTCS) , Dale Miller and Zoltán Ésik (Eds.), Vol. 77. 1–11. https://doi.org/10.4204/EPTCS.77.1 · doi ↗
- 7Abel and Pientka (2013) Andreas Abel and Brigitte Pientka. 2013. Wellfounded recursion with copatterns: a unified approach to termination and productivity. In ACM SIGPLAN International Conference on Functional Programming, ICFP’13, Boston, MA, USA - September 25 - 27, 2013 , Greg Morrisett and Tarmo Uustalu (Eds.). ACM, 185–196. https://doi.org/10.1145/2500365.2500591 · doi ↗
- 8Ahman et al . (2017) Danel Ahman, Catalin Hritcu, Kenji Maillard, Guido Martínez, Gordon D. Plotkin, Jonathan Protzenko, Aseem Rastogi, and Nikhil Swamy. 2017. Dijkstra monads for free. In Proceedings of the 44th ACM SIGPLAN Symposium on Principles of Programming Languages, POPL 2017, Paris, France, January 18-20, 2017 , Giuseppe Castagna and Andrew D. Gordon (Eds.). ACM, 515–529. http://dl.acm.org/citation.cfm?id=3009878
