Programming language implementations are built for programming, so the aim is to provide useful libraries and constructs to make writing code as easy as possible. Proof assistants, on the other hand, are built for reasoning and as such the aim is to make writing proofs as easy as possible. As much as functional programs look similar to (relational or functional) specifications, if one wants to prove properties about an implemented program, it is necessary to “reimplement” it in the language of a proof assistant. This requires familiarity with both the programming language and the proof assistant, since not all programs are supported by reasoning tools (e.g. non-terminating programs are typically forbidden), and not all libraries are available in both tools.
In this work we present SMLtoCoq: a tool that automatically translates SML code into Coq specifications. Moreover, we extend SML with function contracts which are directly translated into Coq theorems. By using this tool, programs can be written using all conveniences of a programming language, and their contracts (functions’ pre and post conditions) can be proved using the full power of a proof assistant. Our target audience are programmers fluent in functional programming, but with little expertise in proof assistants. By having programs automatically translated into Coq code that looks as similar as possible to SML, programmers can learn quickly how program specifications look like, thus lowering the entry barrier for using the proof assistant.
Even though both tools use a functional language, representing SML programs in Coq is complicated due to their various differences. The first immediate challenge is that SML programs may be partial and have side-effects, while Coq programs/specifications need to be pure and total. Another issue is that Coq requires recursive definitions to be structurally decreasing so that termination is guaranteed. SML programs can certainly diverge, and even if they terminate, it might be because of a more complicated argument than a straightforward structural induction. Coq does have extensions – such as the Program command – that are used to define non-structurally-decreasing, terminating recursive functions. However, a proof of termination must be provided. On top of these more fundamental problems, we have encountered many small mismatches between the two languages, such as how records are represented, and what type-checking can infer.
By using a number of Coq features and information available in SML’s abstract syntax tree (AST), SMLtoCoq is able to translate an extensive fragment of pure SML (i.e. without side effects), including partial functions, records, and functors, among others. The resulting Coq specification looks very similar to the original code, so someone proving properties can easily map parts of specifications back to their program counterpart. Using the Equations library, we translate partial, mutually recursive, and recursive functions out of the box. Non-terminating functions are not accepted by Coq, but their translations type check.
Our contributions are:
We extend HaMLet’s implementation of SML to parse and type-check function contracts, written as function annotations. These are included in the SML’s AST and translated into theorems.
We design and implement a tool, SMLtoCoq, that is able to translate pure SML programs and contracts into Coq specifications and theorems completely automatically. Moreover, the translated code looks very similar to the original code.
We implement in Coq the SML libraries111Some of these libraries are very pervasive and we needed their implementation to test the translation. This is why they were not translated using SMLtoCoq. See Section 2.3.: INTEGER, REAL, STRING, CHAR, Bool, Option, List, ListPair, and IEEEReal.
We provide many examples of translated code, including a case study where we translate non-trivial SML code and prove properties on the Coq output. This aligns with the intended workflow for the tool: translate SML code, then prove properties in Coq.
SMLtoCoq can be found at: https://github.com/meta-logic/sml-to-coq/
In its core, SMLtoCoq implements a translation of SML’s abstract syntax tree (AST) into Gallina’s (Coq’s specification language) AST – which is subsequently used to generate Gallina code. SML was chosen for being a language with an incredibly formal and precise definition, which helped in understanding precisely what fragments of the language were covered by our translation. Coq was chosen for being an established and powerful proof assistant, with many libraries and plugins available. In particular, it provides the Equations library which was crucial for efficiently automating the translation and generating correct code that looks close to SML.
SML’s AST is obtained from HaMLet222https://people.mpi-sws.org/~rossberg/hamlet/. SML’s implementation in HaMLet can be separated into three phases: parsing, elaboration, and evaluation. If a program’s syntax is correct, parsing will succeed returning an AST with minimal annotation at each node, containing only their position in the source code. Other well-formedness conditions (e.g. non-exhaustive or redundant matches) and type-checking (i.e. static semantics) are computed during elaboration, which populates the annotations in the AST with more information. Evaluation will simply evaluate the program (i.e. dynamic semantics).
We use the AST after the elaboration phase, when annotations contain useful information such as inferred types and exhaustiveness of matches. Such information is crucial for the generated Coq code. We call evaluation to make sure the program executes correctly and terminates (i.e. no exceptions raised or infinite loops entered) before starting the translation, but the evaluation result is not used. SMLtoCoq is implemented in SML.
HaMLet is an SML implementation whose goal is to be an accurate implementation of the language definition [smldef] and a platform for experimentation. We chose this implementation for three main reasons:
It is faithful to SML’s definition, and all deviations and design choices are thoroughly documented.
The resulting objects from each compilation step are easily accessible. Particularly, the AST after the elaboration phase could be obtained with a couple of function calls.
Due to the detailed documentation, HaMLet could be easily modified as per the translation needs.
HaMLet was extended with function contracts, written as code annotations in the following syntax:
(!! f input ==¿ output; REQUIRES: exp1; ENSURES: exp2; !!)
This contract must be placed immediately before f’s declaration. The first line includes the function name f followed by the variable bindings representing its input. These can be curried, uncurried, typed, or untyped, following the syntax of function parameters in SML. This is followed by output, which is one named variable, typed or not, representing the function’s output. The variable names in input and output are in the scope of the contract only, and should not be confused with variables inside the function. REQUIRES and ENSURES are new keywords used for indicating the pre and post condition of the function, respectively. They are followed by SML boolean expressions exp1 and exp2. The type of these expressions is enforced by the type checker. The variables used in input and output can be used in exp1 and exp2. Variables in the function declaration are not available in the contract, but exp1 and exp2 can use functions or variables defined previously in the code.
In addition to changing HaMLet’s lexer and parser, SML’s AST was modified to account for functions with contracts. The node representing function declaration was augmented to hold the variable bindings and expressions from contracts. If the function has no contracts, these fields are empty.
Besides the implementation of function contracts, HaMLet needed to be modified so that the translation would be as faithful as possible. During parsing, constructs called derived forms are transformed into semantically equivalent code using other constructs. This makes the core language smaller. For example, if-then-else expressions are transformed into case expressions (which, in turn, are transformed into anonymous functions). As a result, the AST computed after parsing would not have nodes for if-then-else, and its translation would result in a different (although semantically equivalent) Coq specification. We have changed HaMLet to skip the transformation of derived forms, and added constructors to the AST to account for them. The constructors added were:
() and (e1, …, en)
[e1, …, en]
case e of m
if e1 then e2 else e3
e1 andalso e2 and e1 orelse e2
e1 op e2 (infix expressions)
Patterns: (), (p1, …, pn), [p1, …, pn], and p1 op p2 (infix patterns)
Tuple types: t1 * … * tn
Function declarations (originally transformed into val rec): fun id pats = e
Functor instantiation with inline specification (e.g. FuncID (structure ¡strdesc¿))
Type definitions in signatures (e.g. type t = s, which was expanded to an inline signature)
SML’s AST is annotated during elaboration phase. The annotation of the new constructs can happen in one of two ways: (1) its equivalent form is constructed, elaborated, and we use the resulting annotation interpreted in the context of the derived form; or (2) a new elaboration case is implemented for the construct. We have used a combination of both approaches which incurred as few changes as possible in the elaboration phase. The annotations produced by both approaches are equivalent since they are added during the elaboration phase which only considers the static semantics up to which the derived form and its equivalent form are equivalent.
Coq’s core language for writing specifications is called Gallina. To be able to generate Gallina’s AST, we have implemented it as a datatype in our system. The implementation follows closely the grammar of Gallina’s specification333https://coq.inria.fr/distrib/current/refman/language/core/index.html, with a few small changes described in what follows.
Extra term and pattern constructors were added for string, real, char, tuple, list, unit, and infix. SML expressions of those types are directly translated to Gallina using these constructors, and they can be directly mapped to Coq code either using common libraries or notations (e.g. Datatypes, List), or Coq libraries that we have implemented to match SML libraries (e.g. string, real, char). New term constructors for product types, and boolean conjunction and disjunction, were implemented for the same reasons as above. To be able to generate theorems, Gallina’s AST includes the Prop operators for conjunction, disjunction, equality, and quantification. Finally, match terms need to have a field indicating exhaustiveness.
Some syntactical elements of Gallina were left out since there is no corresponding SML code which generates them. For example, type coercion (:¿) and “or” patterns (p1 — p2 =¿ t).
is a Coq plugin which allows a richer form of pattern-matching and arbitrarily complex recursion schemes in the setting of dependent type theory. Using this plugin, SMLtoCoq can generate concise and elegant code that is visually similar to SML. The output is considerably improved when compared to one generated in pure Gallina, and the derived proof principles can be used to reason about the high-level code. Among the main advantages of using Equations, is the ability to define partial functions using dependent types. Domain restrictions identified on the original SML function can be translated to a dependent type, which is added as one of the function’s arguments. The resulting code has minimal overhead compared to its SML counterpart, not needing extra default values or ad-hoc constructs to handle unmatched cases. In addition to that, Equations provides a simple interface to handle non-trivial recursion where proofs of termination must be provided by the user.
SML’s basis library is included in most of its implementations and contains some of the more popular datatypes and functions. Since most SML programs will make use of some part of the basis library, we have implemented Coq equivalents to: INTEGER, REAL, CHAR, Bool, STRING (and partially StringCvt), Option, List, ListPair, and IEEEReal. They have the same interface as their SML counterpart, so function calls to these libraries can be translated almost “as is” to Coq. Most of the implementations build on existing Coq libraries, such as List, ZArith, Ascii, Bool, Floats, String etc.
Several of SML’s basis library functions raise exceptions for a given set of inputs. However, Gallina is pure and does not have side effects. In particular, it does not support exceptions. In order to handle these cases, we return an Axiom instead of raising an exception. For example, the function List.hd in SML’s basis library raises the exception Empty if an empty list is passed to it. The equivalent implementation of List.hd in our libraries return the axiom EmptyException, defined as:
Axiom EmptyException : foralla, a.
Note that this means it is not possible to catch the exception, and renders Coq inconsistent. We are currently investigating possible solutions for exceptions, and side-effects in general (see Section LABEL:sec:conc).
A natural question to ask is why we have not used SMLtoCoq itself to translate SML’s libraries. The reason is purely of a practical matter: function translation was being developed at the same time libraries were being implemented in Coq, and one development informed the other. Moreover, a lot of SML code relies on these libraries, so we needed equivalent ones in Coq to test our translation.
The translation from an SML’s AST into a Gallina’s AST is defined inductively on . Depending on the type of construct being translated, we rely on (local or global) auxiliary contexts. We describe in this section the relevant parts of the translation, including examples for each of them. The code in all figures in this section is exactly the one used and generated by SMLtoCoq, except for some line breaks and spaces removed to fit the pages. All the Coq code was tested with the following header, which imports the libraries discussed in Section 2.3, Equations, and sets generalization of variables by default.
Require Import intSml. Require Import listSml. Require Import realSml. Require Import stringSml. Require Import charSml. Require Import boolSml. Require Import optionSml. Require Import listPairSml. Require Import notationsSml.
From Equations Require Import Equations. Generalizable All Variables.
More involved examples can be found in the examples folder in the repository444https://github.com/meta-logic/sml-to-coq/tree/sml-to-coq-with-hamlet/examples. In particular, we would like to highlight the tree_proof.v file which contains the translated code for functions on trees, and proofs of non-trivial theorems about these functions.
3.1 Overloaded operators
Some comparison and arithmetic operators in SML are overloaded for multiple types. For example, the equality check works for strings and integers: val b = ”a” = ”a” andalso 5 = 3.
Boolean equality checks for string and integers in Coq, denoted by =?, are defined in stringscope and Zscope, respectively. However, the last opened scope shadows the first, and trying to use both at the same time fails. For example:
Require Import ZArith. Open Scope Zscope. Require Import String. Open Scope stringscope. Require Import Bool.
Fail Definition b := (”a” =? ”a”) && (5 =? 3).
fails because Coq expects 5 to be of type string.
Operation overload is solved using typeclasses. We have defined several typeclasses for different sets of operators and instantiated them with the types supported. We also defined notations for the operators to be the same as SML whenever possible. For example, the typeclass below is instantiated for strings and integers (among others):
Class eqInfixes A : Type := eqb : A -¿ A -¿ bool; neq : A -¿ A -¿ bool . Infix ”=” := eqb (at level 70).
A record is a set of named fields typed by record types, for example:
name = ”Bob”, age = 42: name: string, age: int
In SML, records can occur as expressions, patterns, or types. When matching a record pattern, the user can specify the names of relevant fields and omit the remaining using ellipsis, as long as SML’s type checker can infer the full record type. For example:
fun getAge (r: name: string, age: int ) = case r of age = x, … =¿ x;
Gallina supports record types, however these must be declared using Record. So the getAge function above could be:
Record rec := name : string; age : Z . Definition getAge (r : rec) := match r with — name := ; age := x — =¿ x.
There are three important points that must be taken into consideration when translating records:
Any record expression, type, or pattern in an SML declaration might require a Record declaration preceding the translation, and record types must be replaced by the Record identifier.
The Record declaration automatically generates projection functions for each of the record’s fields. As a result, field names cannot be reused in the code.
There is no Gallina equivalent to SML’s ellipsis when pattern matching records. So the translation must make all record fields explicit in patterns.
To make sure all necessary records are declared in the translation, we make use of a record context associated with Gallina’s AST. This context is split into a local and a global part. The global record context contains all record types that already have a declaration in the AST. The local record context is used in the translation of SML’s declarations, and starts empty. As the declaration is deconstructed, record expressions, patterns, or types may be encountered. If there exists a record type in or such that the fields are the same, and their types are more general, then the translation proceeds as usual. If not, the type is stored in using a fresh name. Once the declaration translation is done, the types from are translated into Records occurring before the declaration. The content of is then added to .
To avoid name clashing, we modify the record fields’ names by prefixing it with the fresh name used for the record type. Each time a record type, expression, or pattern is found, either its type is found in the record context, or a new type is created. In both situations we are able to tell what this prefix is, and rename the fields accordingly.
Ellipsis on record patterns are resolved by looking into the annotations after SML’s elaboration phase. Since the record type must be able to be inferred, this information can be extracted after elaboration, and the pattern can be unfolded with all fields.
The result of translating a record type and a function on this type is shown in Figure 1.
|type r = name : string, age : int fun isBob (name = ”Bob”,…: r) = true — isBob … = false|
|Record rid1 := rid1name : string; rid1age : Z . Definition r := rid1. Equations isBob (x1: r): bool := isBob — rid1age := ; rid1name := ”Bob” — := true; isBob — rid1age := ; rid1name := — := false.|
3.3 Polymorphic Types
The treatments of polymorphic values in SML and Coq are different. For example, in SML val L =  declares an empty list L of type ’a list, where ’a is a type variable. This value can be safely used with instantiated lists: L =  is a well-typed boolean expression (which evaluates to false).
In contrast, a “polymorphic” empty list can be declared in Coq in (at least) two different ways:
Definition L1 := @nil. Definition L2 A : Type :=  : list A.
The types of the terms L1 and L2 as is (i.e. without annotations) are sligthly different:
L1 : forall A : Type, list A L2 : list ?A where ?A : [ —- Type]
The definition of L1 looks more similar to what is written in SML. However, if we want to use L1 in other terms with instantiated lists (such as L = ), then we need to write: (L1 ) = 555Here _ represents the type variable A whose type is inferred by Coq.. To avoid adding the type parameter explicitly, we can use L2, which is implicitly interpreted as L2 by Coq. Indeed, the (type-)check L2 =  succeeds.
As a result, type variables are made explicit when translating polymorphic SML value declarations so that these values can be used as is in the rest of the program, like the example of L2. This is done using a type variable context . The type variable context is always empty at the beginning of a declaration’s translation and, as the declaration is traversed, “unknown” types are added to the context. An unknown type becomes “known” when it is added to . That is, when the translator encounters an expression e with unknown type , it adds to . If a later expression e’ has type , the translator treats this as a known type, not changing the type variable context. At the end of the translation, is added as an implicit argument of the resulting Coq definition, and the translation of the expression e is annotated with the type . For example, val L =  is translated to:
Definition L ’13405 : Type := ( : @list ’13405).
where ’13405 is the name of the type variable determined by HaMLet.
Note that this is only needed for value declarations. Functions on polymorphic types do not need explicit type parameters since they can be automatically generalized using Generalizable All Variables.
3.4 Non-exhaustive Matches
A very common practice when programming in SML is to use patterns for values to deconstruct expressions. For example: val x::l = [1,2,3] would result on value x being bound to 1 and l bound to [2,3]. SML’s interpreter will issue a Warning: binding not exhaustive, but accepts the code. The warning makes sense since it is usually not possible to tell before runtime if the expression on the right will match the pattern (for example, when it is the result of a function application). Non-exhaustiveness is indicated by a flag in the declaration’s annotation after elaboration.
Such declarations cannot be directly translated into Gallina because Definitions cannot be patterns, only identifiers. Patterns are accepted in let ’ pat := term expressions, but term can only resolve to pat (i.e. its type has only one constructor). The translation of non-exhaustive declarations is made exhaustive by adding a default case resulting in a patternFailure axiom: (the same strategy is used in [hs-to-coq]): !Local Axiom patternFailure: forall a, a.! We should note here that the default case will never be reached in the translated code, as this would mean there was a Bind exception raised when evaluating the SML code. As mentioned before, the SML code is evaluated before starting the translation, and if it terminates abnormally (with an exception, for example), SMLtoCoq terminates too.
In addition to that, top level declarations need to be split into as many definitions as there are variables being bound. This is done by recursively traversing the pattern and collecting the variables. For each of them, a new Gallina definition is created. For example, val x::l = [1,2,3] becomes:
Definition x := match [1; 2; 3] with (x :: l) =¿ x — =¿ patternFailure end. Definition l := match [1; 2; 3] with (x :: l) =¿ l — =¿ patternFailure end.
Note that the structure of the match term is the same, apart from the variable returned on the non-default case. If the declaration is inside a let block, it is translated into multiple nested let blocks.
Unless a function is total and structurally decreasing at every recursive call, it cannot be translated into Fixpoint (or Definition, in case it is not recursive) directly. Most of the problems we encountered in function translation could be solved using the Equations plugin, which provides a powerful tool for defining terminating functions via pattern-matching on dependent types. Equations turned out to be more flexible and easier to use than Coq’s built-in Program command.
3.5.1 Pattern matching
Programming in SML typically makes extensive use of pattern-matching, most common among which is pattern-matching on function inputs, for example:
fun length  = 0 — length (x :: l) = 1 + length l
Gallina, however, does not allow pattern-matching on function parameters which means that the above function would – in the best case – be translated to the following Coq code:
Fixpoint length A : Type ( id : list A ) := match id with —  =¿ 0 — x :: l =¿ 1 + length l end.
While this looks acceptable, the translation is complicated as the number of (curried) parameters increases since match only deals with one term at a time. Equations allows the definition of functions by pattern matching on the arguments without the need for an intermediary match expression. This enables SMLtoCoq to produce code that looks much more similar to the corresponding SML code. For example, the length function defined above translates to the following in Coq with Equations:
Equations length ‘(x1: @list ’14188): Z := length  := 0; length (x :: l) := (1 + (length l)).
The main limitation associated with using Equations is that the function’s input and output types have to be explicit. This does not pose much of a threat since we have type information from HaMLet’s elaboration, and having them explicit does not affect the semantics of the code.