figure a

1 Introduction

Program synthesis is the task of automatically finding programs that meet a given behavioral specification, such as input-output examples or complete formal specifications. Most of the work on program synthesis has been devoted to qualitative synthesis, i.e., finding some correct solution. However, programmers often want more than just a correct solution—they may want the program that is smallest, most likely, or most efficient. While there are some techniques for adding a quantitative syntactic objective in program synthesis [12]—e.g., finding a smallest solution, or a most likely solution with respect to some distribution—little attention has been devoted to quantitative semantic objectives—e.g., synthesizing a program that has a certain asymptotic complexity.

Recently, Knoth et al. [16] studied the problem of resource-guided program synthesis, where the goal is to synthesize programs with limited resource usage. Their approach, which combines refinement-type-directed synthesis [18] and automatic amortized resource analysis (AARA) [9], is restricted to concrete resource bounds, where the user must specify the exact resource usage of the synthesized program as a linear expression. This limitation has two drawbacks: (i) the user must have insights about the coefficients to put in the supplied bound—which means that the user has to provide details about the complexity of code that does not yet exist; (ii) the limitation to linear bounds means that the user cannot specify resource bounds that involve logarithms, such as \(O(\log n)\) and \(O(n \log n)\), common in problems based on divide and conquer.

In this paper, we introduce SynPlexity, a type-system paired with a type-directed synthesis technique that addresses these issues. In SynPlexity, the user provides as input a refinement type that describes both the functionality and the asymptotic (big-O) resource usage of a program. For example, a user might ask SynPlexity to synthesize an implementation of a sorting function with resource usage \(O(n\log n)\), where n is the length of the input list. As in prior work, SynPlexity also takes as input a set of auxiliary functions that the synthesized program can use. SynPlexity then uses a type-directed synthesis algorithm to search for a program that has the desired functionality, and satisfies the asymptotic resource bound. SynPlexity ’s synthesis algorithm uses a new type system that can reason about the asymptotic complexity of functions. To achieve this goal, this type system uses two ideas.

  1. 1.

    The type system uses recurrence relations instead of concrete resource potentials [9] to reason about the asymptotic complexity of functions. For example, the recurrence relation \({T(u )\le 2T(\lfloor \frac{u }{2}\rfloor )+O(u )}\) denotes that on an input of size \(u \), the function will perform at most two recursive calls on inputs of size at most \({\lfloor \frac{u }{2}\rfloor }\), and will use at most \(O(u )\) resources outside of the recursive calls.Footnote 1 For a given recurrence relation, our type system uses refinement types to guarantee that a function typed with this recurrence relation performs the correct number of recursive calls on parameters of the appropriate sizes.

  2. 2.

    These typing rules are justified by classic theorems from the field of analysis of algorithms, such as the Master Theorem [5], the Akra-Bazzi method [1], or C-finite-sequence analysis [13].

Guéneau et al. observed that reasoning with O-notation can be tricky, and exhibited a collection of plausible-sounding, but flawed, inductive proofs [8, §2]. We avoid this pitfall via SynPlexity ’s type system, which establishes whether a term satisfies a given recurrence relation. SynPlexity uses theorems that connect the form of a recurrence relation—e.g., the number of recursive calls, and the argument sizes in the subproblems—to its asymptotic complexity. In particular, the SynPlexity type system does not encode inductive proofs of the kind that Guéneau et al. show can go astray.

SynPlexity can synthesize functions with complexities that cannot be handled by existing type-directed tools [16, 18], and compares favorably with existing tools on their benchmarks. Furthermore, for some domains, SynPlexity ’s type system allows us to discover auxiliary functions automatically (e.g., the split function of a merge sort), instead of requiring the user to provide them.

Contributions. The contributions of our work are as follows:

  • A type system that uses refinement types to check whether a program satisfies a recurrence relation over a specified resource (Sect. 3).

  • A type-directed algorithm that uses our type system to synthesize functions with given resource bounds (Sect. 4, Sect. 5).

  • SynPlexity, an implementation of our algorithm that, unlike prior tools, can synthesize programs with desired asymptotic complexities (Sect. 6).

Complete proofs and details of the type system can be found in the technical report [11].

2 Overview

In this section, we illustrate the main components of our algorithm through an example. Consider the problem of synthesizing a function prod that implements the multiplication of two natural numbers, x and y. We want an efficient solution whose time complexity is \(O(\log x)\) with respect to the value of the first argument x. In Subsect. 2.1, we show how existing type-directed synthesizers solve this problem in the absence of a complexity-bound constraint. In Subsect. 2.2, we illustrate how to specify asymptotic bounds in type-directed synthesis problems. In Subsect. 2.3, we show how the tracking of recurrence relations can be used to establish complexity bounds as well as guide the synthesis search.

2.1 Type-Directed Synthesis

We first review one of the state-of-the-art type-directed synthesizers, Synquid, through the aforementioned example—i.e., synthesizing a program prod that computes the product of two natural numbers. In Synquid, the specification is given as a refinement type that describes the desired behavior of the synthesized function. We specify the behavior of \(\texttt {prod}\) using the following refinement-type:

$$ \texttt {prod}::\texttt {x:}\{\texttt {Int}~|~v \ge 0\}\rightarrow \texttt {y:}\{\texttt {Int}~|~v \ge 0\}\rightarrow \{\texttt {Int}\mid v =\texttt {x}* \texttt {y}\}. $$

Here the types of the inputs x and y, as well as the return type of prod are refined with predicates. The refinement \(\{\texttt {Int}~|~v \ge 0\}\) declares x and y to be non-negative, and the refinement \(\{\texttt {Int}\mid v =\texttt {x}* \texttt {y}\}\) of the return type declares the output value to be an integer that is equal to the product of the inputs x and y. In addition to the specification, the synthesizer receives as input some signatures of auxiliary functions it can use. The specifications of auxiliary functions are also given as refinement types. In our example, we have the following functions:

With the above specification and auxiliary functions, Synquid will output the implementation of prod shown in Eq. (1).

(1)

Synquid uses a sophisticated type system to guarantee that the synthesized term has the desired type. Furthermore, Synquid uses its type system to prune the search space by only enumerating terms that can possibly be typed, and thus meet the specification. Terms are enumerated in a top-down fashion, and appropriate specifications are propagated to sub-terms. As an example, let us see how Synquid synthesizes the function body—an if-then-else term—in Eq. (1), which is of refinement type \(\{\texttt {Int}~|~v =\texttt {x}*\texttt {y}\}\). Synquid will first enumerate an integer term for the then branch—a variable term \(\texttt {x}\). Then, with the then branch fixed, the condition guard must be refined by some predicate \(\varphi \) under which the then branch (the term x refined by \(v =\texttt {x}\)) fulfills the goal type \(\{\texttt {Int}~|~v =\texttt {x}*\texttt {y}\}\), i.e., \(\forall \texttt {x},\texttt {y}\ge 0.\varphi \wedge v =\texttt {x}\implies v =\texttt {x}*\texttt {y}\). With this constraint, Synquid identifies the term \(\texttt {x}==0\) as the condition. Finally, Synquid propagates the negation of the condition to the else branch—the else branch should be a term of type \(\{\texttt {Int}~|~v =\texttt {x}*\texttt {y}\}\) with the path condition \(\texttt {x} \ne 0\)—and enumerates the term plus y (prod (dec x) y) as the else branch, which has the desired type.

The program in Eq. (1) is correct, but inefficient. Let us count each call to an auxiliary function as one step; and let T(x) denote the number of steps in which the program runs with input x. The implementation in Eq. (1) runs in \( \varTheta (x)\) steps because T(x) satisfies the recurrence \(T(x)= T(x-1)+2\), implying \(T(x)\in \varTheta (x)\). Because, Synquid does not provide a way to specify resource bounds, such as \(O(\log x)\); one cannot ask Synquid to find a more efficient implementation.

2.2 Adding Resource Bounds

In our tool, SynPlexity, one can specify a synthesis problem with an asymptotic resource bound, and can ask SynPlexity to find an \(O(\log x)\) implementation of prod. To express this intent, the user needs to specify (1) the asymptotic resource-usage bound the synthesized program should satisfy, (2) the cost of each provided auxiliary function, and (3) the size of the input to the program.

Asymptotic Resource Bound. We extend refinement types with resource annotations. The annotated refinement types are of the form where \(\tau \) is a regular refinement type, and \(\alpha \) is a resource annotation. The following example asks the synthesizer to find a solution with the resource-usage bound \(O(\log u )\):

figure b

Cost of Auxiliary Functions. The auxiliary functions supplied by the user serve as the API in terms of which the synthesized program is programmed. Thus, the resource usage of the synthesized program is the sum of the costs of all auxiliary calls made during execution. We allow users to assign a polynomial cost \(O(u ^a)\), for some constant a, or a constant cost O(1) to each auxiliary function. Here, \(u \) is a free variable that represents the size of the problem on which the auxiliary function is called.

In the \(\texttt {prod}\) example, all auxiliary functions are assigned constant cost, e.g., we give \(\texttt {even}\) the signature .

Size of Problems. The user needs to specify a size function, size:\(\tau \rightarrow \texttt {Int}\), that maps inputs to their sizes, e.g., when synthesizing the sorting function for an input of type list, the size function can be \(\lambda \texttt {l}.|l|\)—the length of the input list. In the prod example, the size function is \(\texttt {size}=\lambda \texttt {x}. \lambda \texttt {y}.\texttt {x}\).

2.3 Checking Recurrence Relations

We extend Synquid ’s refinement-type system with resource annotations, so that the extended type system enforces the resource usage of terms. The idea of the type system is to check if the given function satisfies some recurrence relation. If so, it can infer that the function also satisfies the corresponding resource bound. For example, according to the Master Theorem [3], if a function f satisfies the recurrence relation \({T(u )\le T(\lfloor \frac{u }{2}\rfloor ) + O(1)}\) where \(u \) is the size of the input, then the resource usage of f is bounded by \(O(\log u )\). Checking if a function satisfies a given recurrence relation can be performed by checking if the function contains appropriate recursive calls—e.g., if a function contains one recursive call to a sub-problem of half size, and consumes only a constant amount of resources in its body, then it satisfies \({T(u )\le T(\lfloor \frac{u }{2}\rfloor ) + O(1)}\).

The following rule is an example of how we connect recurrence annotations and resource bounds.

figure c

The rule instantiates the Master Theorem example above. Note that, the annotation states that the function body contains up to one recursive call to a problem of size \({\lfloor \frac{u }{2}\rfloor }\), and the resource usage in the body of t (aside from calls to f itself) is bounded by . The rule states that if the function body t of type \(\tau _2\) contains one recursive call to a sub-problem of size \({\lfloor \frac{u }{2}\rfloor }\), then the function will be bounded by .

The implementation of prod shown in Eq. (2) runs in \(O(\log x)\) steps.

(2)

To check that, SynPlexity ’s type system counts the number of recursive calls along any path of the function. There are three paths (two nested if-then-else terms) in the program, and at most one recursive call along each path. Also, one can check that the problem size of each recursive call is no more than \({\lfloor \frac{\texttt {x}}{2}\rfloor }\). For example, the recursive call \(\texttt {prod (div2 x) y}\) calls to a problem with size \(\texttt {div2 x}\), which is consistent with , and \(u \) is \(\texttt {x}\) because \(\texttt {size}~\texttt {x}~\texttt {y} = \texttt {x}\). In addition, the condition that the resource usage of the body is bounded by O(1) is satisfied because only auxiliary functions with constant cost are called.

3 The SynPlexity Type System

In this section, we present our type system. First, we give the surface language and the types, which extend the Synquid liquid-types framework with resource annotations (Subsect. 3.1). Then, we show the semantics of our language (Subsect. 3.2). Finally, we present SynPlexity ’s type system (Subsect. 3.3), which our synthesis algorithm uses to synthesize programs with desired resource bounds.

3.1 Syntax and Types

Syntax. Consider the language shown in Fig. 1. In the language, we distinguish between two kinds of terms: elimination terms (E-terms) and introduction terms (I-terms). E-terms consist of variable terms, constant values c, and application terms. Condition guards and match scrutinies can only be E-terms. I-terms are branching terms and function terms. The key property of I-terms is that if the type of any I-term is known, the types of its sub-terms are also known (which is not the case for E-terms).

Fig. 1.
figure 1

SynPlexity syntax.

Fig. 2.
figure 2

SynPlexity types.

Types. Our language of types, presented in Fig. 2, extends the one of Synquid  [18] with recurrence annotations, which are used to track recurrence relations on functions. To simplify the presentation, we ignore some of the features of the type system of Synquid  [18] that do not affect our algorithm. In particular, we do not discuss polymorphic types and the enumerating strategy that ensures that only terminating programs are synthesized. However, our implementation is built on top of Synquid, and supports both of those features.

Logical expressions are built from variables, constants, arithmetic operators, and other user-defined logical functions. Logical expressions in our type system can be used as refinements \(\varphi \), size expressions \(\phi \), or bound expressions \(\psi \). Refinements \(\varphi \) are logical predicates used to refine ordinary types in refinement types \(\{B~|~\varphi \}\). We usually use a reserved symbol \(v \) as the free variable in \(\varphi \), and let \(v \) represents the inhabitants, i.e., inhabitants of the type \(\{B~|~\varphi \}\) are valuations of \(v \) that satisfy \(\varphi \). For example, the type \(\{\texttt {Int}~|~v ~\texttt {mod}~2=0\}\) represents the even integers. Size expressions and bound expressions are used in recurrence annotations, and are explained later.

Ordinary types includes primitive types and user-defined algebraic datatypes D. Datatype constructors \(\texttt {C}\) are functions of type \(\tau _1\!\rightarrow \ldots \rightarrow \!\tau _n\rightarrow D\). For example, the datatype List(Int) has two constructors: \(\texttt {Cons}:\texttt {Int}\!\rightarrow \!\texttt {List(Int)}\!\rightarrow \!\texttt {List(Int)}\), and \(\texttt {Nil}:\texttt {List(Int)}\). Refinement types are ordinary types refined with some predicates \(\psi \), or arrow types. Note that, unlike Synquid ’s type system, SynPlexity ’s type system does not support higher-order functionsFootnote 2—i.e., arguments of functions have to be non-arrow types. All occurrences of \(\tau _i\) and \(\tau \) in arrow types \(x_1\!:\!\tau _1\!\rightarrow \ldots \rightarrow \!x_n\!:\!\tau _n\!\rightarrow \!y:\tau \) have to be ordinary types or refined ordinary types. We will discuss this limitation in Sect. 7.

We use recFun to denote the name of the function for which we are performing type-checking, and args to denote the tuple of arguments to recFun. For example, in the function prod shown in Eq. (1), recFun=prod and \(\texttt {args=x~y}\). An environment \(\varGamma \) is a sequence of variable bindings \(x:\gamma \), path conditions \(\varphi \), and assignments for variables recFun and args.

Recurrence Annotations. Annotated types are refinement types annotated with recurrence annotations. A recurrence annotation is a pair consisting of (1) a set of recursive-call costs of the form , and (2) a resource-usage bound of the form . Intuitively, a recurrence annotation tracks the number \(c_i\) of recursive calls to \(\texttt {f}\) of size \(\phi _i\) in the first element of the pair, as well as the asymptotic resource usage of the body of the function (the second element ). Using these quantities, we can compute a recurrence relation describing the resource usage of the function recFun. For example, the recurrence annotation corresponds to the recurrence relation \(T_{\texttt {f}}(u )\le T_{\texttt {f}}(u -1)+T_{\texttt {f}}(u -2)+O(1)\).

A recursive-call cost associated with a function \(\texttt {f}\) denotes that the body of \(\texttt {f}\) can contain up to c recursive calls to subproblems that have sizes up to the one specified by size expression \(\phi \). A size expression, \(\phi \), is a polynomial over a reserved variable symbol \(u \) that represents the size of the top-level problem. In our paper, a problem with respect to a function \(g::x_1\!:\!\tau _1\!\rightarrow \ldots \rightarrow \!x_n\!:\!\tau _n\!\rightarrow \!y\!:\!\tau \) is a tuple of terms \(e_1 \ldots e_n\), to which g can be applied—i.e., \(e_i\) has type \(\tau _i\) for all i from 1 to n. For the problems of function g, the size of each problem is defined by a size function \(\texttt {size}_g\)—a user-defined logical function that has type \(\tau _1\!\rightarrow \ldots \rightarrow \!\tau _n\!\rightarrow \!\texttt {Int}\); i.e., it takes a problem of g as input and outputs a non-negative integer. In the body of g, we say that a recursive-call term \(g~e_1 \ldots e_n\) satisfies a size expression \(\phi \) if for all \(x_1\), \(\ldots \), \(x_n\), \(\texttt {size}_g~\llbracket e_1\rrbracket \ldots \llbracket e_n\rrbracket \le [(\texttt {size}_g~x_1 \ldots x_n)/u ]\phi \), where the \(x_i\)’s are the arguments of g and the \(\llbracket e_i\rrbracket \)’s are the evaluations of \(e_i\) on input \(x_1 \ldots x_n\). (See Sect. 3.2 for the formal definition of \(\llbracket \cdot \rrbracket \).) Note that one annotation can contain multiple recursive-call costs, which allows the function to make recursive calls to sub-problems with different sizes. We often abbreviate as \(\tau \) and omit \(\texttt {f}\) in recursive-call costs if it is clear from context.

A resource bound of a non-arrow type specifies the bound of the resource usage strictly within the top-level-function body. A resource bound in a signature of an auxiliary function f specifies the resource usage of f. Bound expressions \(\psi \) in are of the form \(u ^a\log ^b u +c\) where a, b, and c are all non-negative constants, and \(u \) represents the size of the top-level problem.

Example 1

In the function prod (Eq. (2)), the recursive-call term prod (div2 x) y satisfies the recursive-call cost , because \(\texttt {size}_\texttt {prod}=\lambda z.\lambda w.z\), and

$$\begin{aligned}{\texttt {size}_\texttt {prod}~\llbracket (\texttt {div2}~\texttt {x})\rrbracket ~\llbracket \texttt {y}\rrbracket =~\llbracket \texttt {div2}~\texttt {x}\rrbracket = \lfloor \frac{\texttt {x}}{2}\rfloor =[(\texttt {size}_\texttt {prod}~\texttt {x}~\texttt {y})/u ]\lfloor \frac{u }{2}\rfloor .} \end{aligned}$$

3.2 Semantics and Cost Model

We introduce the concrete-cost semantics of our language here. The semantics serves two goals: (1) it defines the evaluation of terms (i.e., how to obtain values), which can be used to compute the sizes of problems in application expressions, and (2) it defines the resource usages of terms.

Besides the syntax shown in Fig. 1, implementations of auxiliary functions can contain calls to a tick function \(\texttt {tick}(c,t)\), which specifies that c units of a resource are used, and the overall value is the value of t. Note that in our synthesis language, we are not actually synthesizing programs with \(\texttt {tick}\) functions. We assume that \(\texttt {tick}\) functions are only called in the implementations of auxiliary functions. In the concrete-cost semantics, a configuration \(\langle t,C\rangle \) consists of a term t and a nonnegative integer C denoting the resource usage so far. The evaluation judgment \(\langle t,C\rangle \hookrightarrow \langle t',C+C_\varDelta \rangle \) states that a term t can be evaluated in one step to a term (or a value) \(t'\), with resource usage \(C_\varDelta \). We write \(\langle t,C\rangle \hookrightarrow ^* \langle t',C+C_\varDelta \rangle \) to indicate the reduction from t to \(t'\) in zero or more steps. All of the evaluation judgments are standard. Here we show the judgment of the tick function, where resource usage happens.

figure d

For a term t, \(\llbracket t\rrbracket \) denotes the evaluation result of t, i.e., \(\langle t,\cdot \rangle \hookrightarrow ^*\langle \llbracket t\rrbracket ,\cdot \rangle \).

Example 2

Consider the following function that doubles its input.

$$ \texttt {fix}~\texttt {double}.\lambda \texttt {x}. {{\mathbf {\mathtt{{if}}}}}~\texttt {x = 0}~ {{\mathbf {\mathtt{{then}}}}}~\texttt {0}~ {{\mathbf {\mathtt{{else}}}}}~\texttt {tick}(\texttt {1,2 + double(x-1)}). $$

Let \(t_{\texttt {body}}\) denote the function body \({{\mathbf {\mathtt{{if}}}}}~\texttt {x=0}~ {{\mathbf {\mathtt{{then}}}}}~\texttt {0}~ {{\mathbf {\mathtt{{else}}}}}~\texttt {tick}(\texttt {1,2+double(x-1)})\). The result of evaluating \(\texttt {double}\) on input 5 is 10, with resource usage 5.

figure e

With the standard concrete semantics, the complexity of a function f is characterized by its resource usage when the function is evaluated on inputs of a given size.

Definition 1 (Complexity)

Given a function \(\texttt {fix}~f.\lambda \overline{y}.t\) of type \(:\tau _1\rightarrow \tau _2\), with size function \(\texttt {size}_f:\tau _1\rightarrow \mathbb {N}\), and suppose that for any possible input \(\overline{x}\), the configuration \(\langle (\texttt {fix}~f.\lambda \overline{y}.t)\overline{x},0\rangle \) can be reduced to \(\langle v, C_{\overline{x}}\rangle \) for some value v. Then, if \(T_f:\mathbb {N}\rightarrow \mathbb {N}\) is a function such that, \( \textit{for all,}~u \ge 0,~T_f(u ) = \sup _{\overline{x}~\textit{s.t.}~\texttt {size}_f(\overline{x}) = u }C_{\overline{x}}, \) we say that \(T_f\) is the complexity function of f.

Note that Definition 1 assumes that the top-level term \((\texttt {fix}~f.\lambda \overline{y}.t)\overline{x}\) can be reduced to some value. Thus, Definition 1 only applies to terminating programs.

Definition 2 (Big-O notation)

Given two integer functions f and g, we say that f dominates g, i.e., \(g\in O(f)\), if \(\exists c,M\ge 0.\ \forall x\ge c.\ g(x)\le Mf(x).\)

In the rest of the paper, we use \(T_f\) to denote the complexity function of the function f, and we say the complexity of f is bounded by a function g if \(T_f\in O(g)\). As an example, the complexity of the double function shown in Example 2 is \(T_{\texttt {double}}(u ):=u \), and hence \(T_{\texttt {double}}(u )\in O(u )\).

Auxiliary functions. We allow users to supply signatures for auxiliary functions, instead of implementations. It is an obligation on users that such signatures be sensible; in particular, when the user gives the signature for auxiliary function f, the user asserts that there exists some implementation \(\texttt {fix}~f.\lambda \overline{y}.t\) of f, such that: 1) for any input \(\overline{x}\), the output of f on \(\overline{x}\) satisfies \(\varphi \), i.e., \(\varphi (\llbracket (\texttt {fix}~f.\lambda \overline{y}.t)\overline{x}\rrbracket ,\overline{x})\) is valid; and 2) for any input \(\overline{x}\), the complexity of f is bounded by \(\psi (u )\), i.e., \(T_f(u )\in O(\psi (u ))\). Signatures always over-approximate their implementations, as illustrated by the following example.

Example 3

The signature describes an auxiliary function that computes no more than the input times 3, and has quadratic resource usage. Note that the function \(\texttt {double}\) shown in Example 2 can be an implementation of this signature because \(\llbracket \texttt {double}(\overline{x})\rrbracket =2*x \le 3*x\), and the complexity function \(T_{\texttt {double}}(u )=u \) is in \(O(u ^2)\).

3.3 Typing Rules

The typing rules of SynPlexity are inspired by bidirectional type checking [17] and type checking with cost sharing [16]. Recall that we use recFun to denote the name of the function for which we are performing type-checking, and args to denote the tuple of arguments to recFun.

An environment \(\varGamma \) is a sequence of variable bindings of the form \(x:\gamma \), path conditions \(\varphi \), and assignments of the form \(x=\varphi \) for recFun and the components of args. SynPlexity ’s typing rules use three judgments: 1) , 2) \(\varGamma \vdash \gamma _1<:\gamma _2 \text { states that }\gamma _2\text { is a subtype of }\gamma _1\), and 3)

Subtyping. The subtyping relations between refinement types are relatively standard and can be found in the technical report [11]. The subtyping relations between annotated types allow us to compare resource consumption of recurrence annotations. The following is the rule for comparing recursive-call costs.

figure f

For example, if one branch of some branching term has type , it can be over-approximated by a super type . The idea is that the resource usage of an application calling to a problem of size will be larger than the resource usage of the application calling to a smaller problem of size (assuming all resource usages are monotonic).

Subtyping rules also allow the type system to compare branches with a different number of recursive calls. For example, base cases of recursive procedures have no recursive calls, and thus have types of the form . With subtyping, these types can be over-approximated by types of the form .

Cost Sharing. When a term has more than one sub-term in the same path, e.g., the condition guard and the then branch are in the same path in an ite term, the recursive-call costs of the term will be shared among its sub-terms. The sharing operator partitions the recursive-call costs of \(\alpha \) into \(\alpha _1\) and \(\alpha _2\)—i.e., the sum of the costs in \(\alpha _1\) and \(\alpha _2\) equals the cost in \(\alpha \). The following is the sharing rule for a single recursive-call cost:

figure g

Other sharing rules can be found in the technical report [11]. The idea is that a single cost c can be shared as two costs \(c_1\) and \(c_2\) such that their sum is no more than c. An annotation can be shared as two parts if every recursive cost in it can be shared as two parts and . Finally, annotations can also be shared as more than two parts.

Example 4

There are multiple ways to share the recurrence annotation :

figure h

where one annotation contains both recursive-call costs ; and the other contains no recursive-call cost. And

figure i

where each annotation contains one recursive-call cost.

Table 1. Annotations that can be used to instantiate the rule T-Abs.

Function Terms. The rule T-Abs shown below is really a rule-schema that is parameterized in terms of an annotation for a function body t, and a resource bound for the function term. If the function body t has some recurrence relation described by the annotation , then the function \(\texttt {f}\) will satisfy the resource-usage bound . Some example patterns are shown in Table 1.Footnote 3

For example, if the annotation of the function body is , then the resource bound in the function type will be , i.e., the resource usage of \(\texttt {f}\) is bounded by .

At the same time, the rule stores the name \(\texttt {f}\) of the recursive function into recFun, and its arguments as a tuple into \(\texttt {args}\).

Example 5

We use a function \(\texttt {fix~bar}.\lambda x.\texttt {if}~x=1~\texttt {then}~1~\texttt {else}~1+\texttt {bar}(\texttt {div2}~x)\) to illustrate the first pattern in Table 1. The body of \(\texttt {bar}\) has the annotated type because (i) there exists only one recursive call to a sub-problem whose size is half of the top-level problem size \(u \), and (ii) the resource usage inside the body is constant (with the assumption that all auxiliary functions have constant resource usage). This type appears in row 1, column 4 of Table 1. Consequently, the recurrence relation of \(\texttt {bar}\) is \({T(u )\le T(\lfloor \frac{u }{2}\rfloor )+O(1)}\) (row 1, column 3), where \(T(u )\) is the resource usage of \(\texttt {bar}\) on problems with size \(u \). Finally, according to the Master Theorem, the resource usage of \(\texttt {bar}\) is bounded by \(O(\log u )\) (row 1, column 2).

Branching Terms. In rule T-If, the condition has type Bool with refinement \(\varphi _e\). Two branches have different types—the then branch follows the path condition \(\varphi _e\), and the refinement \(\varphi \) of the branch term, while the else branch follows the path condition \(\lnot \varphi _e\). By having both branches share the same recurrence annotation, T-If can introduce some imprecision. In particular, if the branches belong to different complexity classes, the annotation of the conditional term will be the upper bound of both branches.

The rule T-Match is slightly different: (1) there can be more than two branches, (2) all branches have the same type \(\langle \tau ,\alpha _2\rangle \), and (3) variables in each case \(\texttt {C}_i~(x_i^1 \ldots x_i^n)\) are introduced in the corresponding branch.

E-terms. The typing rules for E-terms are shown in Fig. 3. The two rules for application terms are the key rules of our type system. Let us first look at the E-RecApp rule for recursive-call terms. Recall that the recursive-call annotation tracks the number of recursive calls and the sizes of sub-problems. If the term \(\texttt {f}~e_1 \ldots e_n\) is a recursive call—i.e., \(\varGamma (\texttt {recFun})=\texttt {f}\)—the number of recursive calls in one of the recursive-call costs will increase by one—i.e., in the premise becomes in the conclusion. Also, we want to make sure that the size of the subproblem this application term is called on satisfies the size expression . If each callee term is refined by the predicate \(\varphi _i\), i.e., , then the fact that the size of the problem \(e_1 \ldots e_n\) satisfies \(\phi _k\) can be implied by the validity of the predicate \(\bigwedge _{i=1}^m[y_i/v ]\varphi _i\!\Rightarrow \! (\texttt {size}~y_1 \ldots y_m\le [\texttt {size}~\varGamma (\texttt {args})/u ]\phi _k)\). We introduce validity checking, written \(\varGamma \,\models \,\varphi \) , to state that a predicate expression \(\varphi \) is always true under any instance of the environment \(\varGamma \).

Example 6

Recall Eq. (2). According to the rule T-RecApp, the recursive call \(\texttt {prod (div2 x) y}\) has type . Note that the first argument \(\texttt {(div2 x)}\) has type \({\{\text {Int}~|~v =\lfloor \frac{\texttt {x}}{2}\rfloor \}}\), the second argument \(\texttt {y}\) has type \(\{\text {Int}~|~v =\texttt {y}\}\), the size function is \(\texttt {size}_\texttt {prod}=\lambda z.\lambda w.z\), and the arguments in the context are \(\varGamma (\texttt {args})=\texttt {x}~\texttt {y}\). Therefore, the following predicate is valid:

$$\begin{aligned}&[y_1/v ](v =\lfloor \frac{x}{2}\rfloor )\wedge [y_2/v ](v =y)\!\Rightarrow \!\texttt {size}_\texttt {prod}~y_1~y_2=[\texttt {size}_{\texttt {prod}}~\varGamma (\texttt {args}/u )]\lfloor \frac{u }{2}\rfloor \\ \Leftrightarrow&(y_1=\lfloor \frac{x}{2}\rfloor )\wedge (y_2=y)\!\Rightarrow \! y_1=\lfloor \frac{x}{2}\rfloor .\end{aligned}$$

The rule E-App states that callees have types \(\tau _i\), and the resource usage does not exceed the bound in the annotation. Similar to the E-RecApp rule, the size of the problem \(\texttt {g}\) calls to is \([\texttt {size}_{\texttt {g}}~y_1 \ldots y_m/u ]\) with the premise \(\bigwedge _{i=1}^m[y_i/v ]\varphi _i\). The validation checking \(\bigwedge _{i=1}^m[y_i/v ]\varphi _i\!\Rightarrow \! \left( [\texttt {size}_{\texttt {g}}~y_1 \ldots y_m/u ]\psi _{\texttt {g}}\in O([\texttt {size}~\varGamma (\texttt {args})/u ]\psi )\right) \) in the rule states that for any instance of \(\varGamma \), the size of the problem in the application term is in the big-O class \(O([\texttt {size}~\varGamma (\texttt {args})/u ]\psi )\). Note that the membership of big-O classes can be encoded as an \(\exists \forall \) query. The query is non-linear, and hence undecidable in general. However, we observed in our experiments that for many benchmarks the query stays linear. Furthermore, even when the query is non-linear, existing SMT solvers are capable of handling many such checks in practice.

Fig. 3.
figure 3

Typing rules of E-terms

3.4 Soundness

We assume that the resource-usage function \(\psi \) and the complexities T of each function are all nonnegative and monotonic integer functions—both the input and the output are integers. We show soundness of the type system with respect to the resource model. The soundness theorem states that if we derive a bound \(O(\psi )\) for a function \(\texttt {f}\), then the complexity of \(\texttt {f}\) is bounded by \(\psi \).

Theorem 1 (Soundness of type checking)

Given a function \(\texttt {fix}\)\( f.\lambda x_1 \ldots \lambda x_n.t\) and an environment \(\varGamma \), if , then the complexity of f is bounded by \(\psi \).

Our type system is incomplete with respect to resource usage. That is, there are functions in our programming language that are actually in a complexity class O(p(x)), but cannot be typed in our type system. The main reason why our type system is incomplete is that it ignores condition guards when building recurrence relations, and over-approximates if-then-else terms by choosing the largest complexity among all the paths including even unreachable ones.

4 The SynPlexity Synthesis Algorithm

In this section, we present the SynPlexity synthesis algorithm, which uses annotated types to guide the search of terms of given types.

4.1 Overview of the Synthesis Algorithm

The algorithm takes as input a goal type , an environment \(\varGamma \) that includes type information of auxiliary functions, and the size functions for f and all auxiliary functions. The goal is to find a function term of type .

The algorithm uses the rules of the SynPlexity type system to decompose goal types into sub-goals, and then applies itself recursively on the sub-goals to synthesize sub-terms. Concretely, given a goal \(\gamma \), the algorithm tries all the typing rules, where the type in the conclusion matches \(\gamma \), to construct sub-goals: for each sub-term t in the conclusion, there must be a judgment in the premise; thus, we construct the sub-goal \(\gamma '\)—the desired type of t. For each I-term rule, the type of each sub-term is always known, and thus a fixed set of sub-goals is generated. For each E-term rule, the algorithm enumerates E-terms up to a certain depth (the depth can be given as a parameter or it can automatically increase throughout the search). If the algorithm fails to solve some sub-goal using some E-term rule, it backtracks to an earlier choice point, and tries another rule.

Because the top-level goal is always a function type, the algorithm always starts by applying the rule \(\textsc {T-Abs}\), which matches the resource bound using Table 1 to infer a possible recurrence annotation for the type of the function body. Also \(\textsc {T-Abs}\) constructs a sub-goal type for the function body. In the rest of this section, we assume that goals are not function types.

figure k

Synthesizing E-Terms. The algorithm for synthesizing E-terms is shown in Algorithm 1. It enumerates each E-term t—with depth up to d—that satisfies the base type B in the goal from the context \(\varGamma \). For each such E-term t, the algorithm checks whether t satisfies the goal type with a subroutine CheckE, which operates as follows.

When t is a variable term, CheckE checks the refined type of t against the goal. When t is an application term, CheckE first checks if the total number of recursive calls in the term t exceeds the bound \(\sum _ic_i\), and if it does, the term t is rejected. Otherwise, CheckE checks the sizes of sub-problems of recursive calls in t. Formally, to check if a recursive application term \(f(t_1,..,t_m)\) is consistent with some , the algorithm queries the validity of the following predicate

$$(\bigwedge _{i=1}^{m}[y_i/v ]\varphi _i\!\Rightarrow \! (\texttt {size}_f(y_1~..~y_{m})=[\texttt {size}_f(\varGamma (\texttt {args}))/v ]\phi _k)),$$

where the \(y_i\)’s are fresh variables, and the \(\varphi _i\)’s are the refinements of terms \(t_i\)’s. If the sizes of sub-problems are not consistent with the recursive-call costs , the term t is rejected. Note that one recursive call can possibly satisfy more than one . The algorithm enumerates all possible matches. Finally, CheckE checks the refined type of t against the goal.

Checking the validity of auxiliary application terms is similar. CheckE needs to establish that the following predicate holds, which asserts that the resource usage of an auxiliary function does not exceed the bound \(O(\psi )\).

$$ \bigwedge _{i=1}^{m}[y_i/v ]\varphi _i\!\Rightarrow \! \left( [\texttt {size}_{\texttt {g}}~y_1..y_m/v ]\psi _{\texttt {g}}\in O([\texttt {size}~\varGamma (\texttt {args})/v ]\psi )\right) . $$

Recall that the above query is undecidable in general, and is checked with best effort by an SMT solver in SynPlexity.

Synthesizing I-Terms. Algorithm 2 shows the algorithm for synthesizing I-Terms. GenerateI first tries to synthesize an E-term for the goal \(\gamma \) (line (1)).

If there is no E-term that satisfies the goal, and the match bound m is greater than 0, GenerateI chooses to apply the rule \(\text {T-Match}\) lines (2)–(8). First, it enumerates candidate scrutinees s, which are E-terms of some data type. Then it generates match patterns according to the type of s (line (3)), updates the goal with a new recursive-call cost (line (4)), and generates case terms \(t_i\) for each pattern pattern[i] (lines (5)–(7)). The subroutine UpdateCost is used to subtract the recursive-call cost usage from the cost in \(\gamma \). Finally, if all case terms are found, the algorithm constructs the corresponding match-term and returns it.

If there is no match-term satisfying the goal, GenerateI applies the rule \(\textsc {T-If}\) to synthesize a term of the form \({{\mathbf {\mathtt{{if}}}}}~cond~{{\mathbf {\mathtt{{then}}}}}~t_T~{{\mathbf {\mathtt{{else}}}}}~t_F\), and performs three steps to construct sub-goals for sub-terms cond, \(t_T\), and \(t_F\): (1) it enumerates the condition guard cond (line (10)) of type bool; (2) it updates the cost in the goal \(\gamma \) (line (11)); and (3) it propagates sub-goals to the two branches \(t_T\) and \(t_F\) with cond and \(\lnot cond\) as the path condition (lines (12) and (13)), respectively. Finally, if both \(t_T\) and \(t_F\) are found, the algorithm constructs the corresponding if-term and returns it as a solution (line (14)).

Optimization. Algorithm 2 discussed above is based on bidirectional type-guided synthesis with liquid types (Synquid [18]). Therefore, liquid abduction and match abduction, two optimizations used in Synquid, can also be used in SynPlexity. These two techniques allow one to synthesize the branches of if- and match-terms, and then use logical abduction to infer the weakest assumption under which the branch fulfills the goal type.

figure l
Fig. 4.
figure 4

Trace of the synthesis of an \(O(\log x)\) implementation of prod.

Example 7

We illustrate in Fig. 4 how the algorithm synthesizes the \(O(\log x)\) implementation of \(\texttt {prod}\) presented in Eq. (2). We omit the type contexts in the example. We will use “\(\texttt {??}\)” to denote intermediate terms being synthesized (i.e., holes in the program). At the beginning, the type of \(\texttt {??}_1\) (i.e., the term we are synthesizing) is an arrow type with resource bound specified by the input goal. In this example, SynPlexity applies to the arrow type the rule T-Abs, parameterized according to the first rule in Table 1. This step produces the sub-problem of synthesizing the function body \(\texttt {??}_2\), whose annotation is —which means that \(\texttt {??}_2\) should contain at most one recursive call to sub-problems with size \({\lfloor \frac{u }{2}\rfloor }\).

Next, SynPlexity chooses to fill \(\texttt {??}_2\) with an if-then-else term (by applying the T-If rules) with three sub-problems: the condition guard \(\texttt {??}_3\), the then branch \(\texttt {??}_4\) and the else branch \(\texttt {??}_5\). Note that here we share the number of recursive calls as follows: recursive calls in the condition guard, and in the then branch and the else branch. The left arrow E-App shows how SynPlexity enumerates terms and checks them against the goal types of sub-problems. For example, to fill \(\texttt {??}_4\), SynPlexity enumerates terms of type , which are restricted to contain at most one recursive call to prod. In Fig. 4, SynPlexity has picked the term \(\texttt {x}\) to fill \(\texttt {??}_4\). The refinement type of the variable term \(\texttt {x}\) is \(\{\texttt {Int}~|~v =\texttt {x}\wedge \texttt {x}=0\}\) where \(\texttt {x}=0\) is the path condition. To check that \(\texttt {x}\) also satisfies the type of \(\texttt {??}_4\), the algorithm needs to apply rule E-SubType, and check that, for any \(v \) and \(\texttt {x}\), \(v =\texttt {x}\wedge \texttt {x}=0\) implies \(v =\texttt {x}*\texttt {y}\wedge \texttt {x}=0\), and is approximated by .

After applying another T-If rule for \(\texttt {??}_5\), SynPlexity produces three new sub-problems \(\texttt {??}_6\), \(\texttt {??}_7\), and \(\texttt {??}_8\). When enumerating terms to fill \(\texttt {??}_7\), SynPlexity finds an application term double (prod (div2 x) y) that satisfies the goal . To check that the size of the problem in the recursive call prod (div2 x) y satisfies the recursive-call cost , the type system first checks the refinement of the callee. The refinement of the first argument (div2 x) is \({\varphi _1:=v =\lfloor \frac{\texttt {x}}{2}\rfloor }\). The refinement of the second argument y is \(\varphi _2:=v =\texttt {y}\). Consequently, the size of the sub-problem \(\texttt {prod (div2 x) y}\) satisfies \({[1,\lfloor \frac{u }{2}\rfloor ]}\) because \({[z/v ]\varphi _1\wedge [w/v ]\varphi _2\implies \texttt {size}~z~w=[(\texttt {size}~\texttt {x}~\texttt {y})/v ]\lfloor \frac{u }{2}\rfloor }\), which can be simplified to \({z=\lfloor \frac{\texttt {x}}{2}\rfloor \wedge w = \texttt {y} \implies z=\lfloor \frac{\texttt {x}}{2}\rfloor }\). (Recall that the size function for prod is \(\texttt {size}:=\lambda z.\lambda w.z\).)

The algorithm is sound because it only enumerates well-typed terms.

Theorem 2 (Soundness of the synthesis algorithm)

Given a goal type and an environment \(\varGamma \), if a term \(\texttt {fix }f.\lambda x_1..\lambda x_n.t\) is synthesized by SynPlexity, then the complexity of f is bounded by \(\psi \).

5 Extensions to the SynPlexity Type System

In this section, we introduce two extensions to the SynPlexity type system.

Recurrence Relations with Correlated Sizes. The type system shown in Sect. 3 only tracks sub-problems with independent sizes. For example, consider the recurrence relation \(T(u )=T(l)+T(r)+O(1)\), where the variables l and r are correlated by the constraint \(l+r<u \). This relation is needed to reason about programs that manipulate binary trees or binary heaps, where l and r represent the sizes of the two children. To support such a recurrence relation, we extend SynPlexity ’s type system with recursive-call costs of the form , where l is a free variable. When correlated recurrence relations are present, the synthesis algorithm will: (1) match the first enumerated recursive-call term to , and instantiate the size l with s, where s is the size of the recursive-call term (s should be smaller than the size \(u \) of the top-level function); and (2) use the size s of the recursive-call term computed in step 1 to constrain the algorithm to enumerate only recursive-call terms of sizes at most \(u -1-s\).

Synthesis of Auxiliary Functions. Most of the existing type-directed approaches require the input to the problem to contain all needed auxiliary functions. With SynPlexity, some of the auxiliary functions needed to solve synthesis problems with resource annotations can be synthesized automatically.

For example, consider the problem prod described in Sect. 2. In this problem, we observe that one of the provided auxiliary functions, \(\texttt {div2}\), strongly resembles one of the elements of the recurrence relation, \({T(u )\le T(\lfloor \frac{u }{2}\rfloor )+O(1)}\), needed to synthesize a program with the desired resource usage. In particular, we know that one needs an auxiliary function that can take an input of size u and produce an output of size \({\lfloor \frac{u }{2}\rfloor }\). In this example, the required auxiliary function \(\texttt {div2}\) merely needs to divide the input by 2 (and round down), but in certain cases it might need a more precise refinement type than merely changing the size of the input. For example, the auxiliary function split used by merge sort needs to split the input list \(\texttt {xs}\) into two lists \(\texttt {v1}\) and \(\texttt {v2}\) that are half the length of the input and such that \(\texttt {elems}(\texttt {v1}) \uplus \texttt {elems}(\texttt {v2})=\texttt {elems}(\texttt {xs})\). However, all we know from the refinement is that the output lists must be half the length of the original list.

Although we do not know what this auxiliary function should do exactly, we can use the size constraint appearing in the recurrence relation to define part of the refinement type we want the auxiliary function to satisfy. SynPlexity builds on this idea and incorporates an (optionally enabled) algorithm, SynAuxRef, that while trying to synthesize a solution to the top-level synthesis problem also tries in parallel to synthesize auxiliary functions that can create sub-problems with the size constraints needed in the recurrence relation. To address the problem mentioned above—i.e., that we do not know the exact refinement type the auxiliary function should satisfy—SynAuxRef enumerates auxiliary refinements, which are possible specifications that the auxiliary function aux we are trying to synthesize might satisfy.

Synthesis with Higher-Order Functions. Although SynPlexity does not support higher-order functions in general, it can solve restricted but practical problems with higher-order functions. The restriction supported introduces four assumptions on the synthesis problems. First, we assume that the resource usage of any function argument g is constant, i.e., . Second, arrow-type arguments in recursive calls in the synthesized program are the same as the arrow-type arguments of the top-level function. For example, in the body of a higher-order function \(\texttt {fix}~f.\lambda g\lambda x\lambda y.t\), all recursive application terms must be of the form \(f(g,\_,\_)\) where \(\_\) can be any well-typed term. Third, we assume that the sizes of outputs of functions as arguments do not affect the asymptotic resource usage of the synthesized programs. Finally, arrow-type arguments cannot appear in size functions.

We extend the syntax and the type system of SynPlexity to support the restricted problems (the detail of this extension can be found in the technical report [11]). We also modify the synthesis algorithm to prune E-terms that break the second or third restriction mentioned above.

To support the second restriction (i.e., that we need to call the same function arguments in recursive calls), the synthesis algorithm first stores the function arguments of the top-level functions. Later, when a recursive call is enumerated, the synthesizer checks whether the recursive call has the same function arguments, and rejects the candidate if it does not.

To support the third restriction (i.e., that the behavior of function arguments should not affect the resource usage), the synthesis algorithm avoids enumerating nested application terms where the resource usage of the outer application depends on the value of an inner application term that calls a function argument.

6 Evaluation

In this section, we evaluate the effectiveness and performance of SynPlexity, and compare it to existing tools.Footnote 4 We implemented SynPlexity in Haskell on top of Synquid by extending its type system with recurrence annotations as presented in Sect. 3. The detailed results can be found in the technical report [11].

6.1 Comparison to Prior Tools

We compared SynPlexity against two related tools: Synquid [18] and ReSyn [16], which are also based on refinement types.

Benchmarks. We considered a total of 77 synthesis problems: 56 synthesis problems from ReSyn (each benchmark specifies a concrete linear-time resource annotation), 16 synthesis problems from Synquid (which do not include resource annotations) that are not included in ReSyn, and 5 new synthesis problems involving non-linear resource annotations. In these synthesis problems, synthesis specifications and auxiliary functions are all given as refinement types. For 3 of the new benchmarks, the auxiliary function required to split the input into smaller ones is not given—i.e., the synthesizer needs to identify it automatically.

The three solvers (SynPlexity, Synquid, and ReSyn) have different features, and hence not all synthesis problems can be encoded as synthesis benchmarks for a single solver. In the rest of this section, we describe what benchmarks we considered for each tool, and how we modified the benchmarks when needed.

Synquid: Synquid does not support resource bounds, so we encoded 77 synthesis problems as Synquid benchmarks by dropping the resource annotations. Synquid returns the first program that meet the synthesis specification, and cannot provide any guarantees about the resource usage of the returned program. Synquid can solve 75 benchmarks, and takes on average 3.3s. For 10 benchmarks Synquid synthesizes a non-optimal program—i.e., there exists another program with better concrete resource usage. For example, on the ReSyn-triple-2 benchmark (where the input is a list xs), Synquid found a solution with resource usage \(O(|xs|^2)\), while both SynPlexity and ReSyn can synthesize a more efficient implementation with resource usage O(|xs|). The two benchmarks that Synquid failed to solve include the new benchmark SynPlexity-merge-sort’. In this benchmark, the auxiliary function required to break the input into smaller inputs is not given, without which the sizes of solutions become much larger. Therefore Synquid times out.

ReSyn: We ran ReSyn on the 56 ReSyn benchmarks with the corresponding concrete resource bounds. We could not encode 16 problems because ReSyn does not support non-linear resources bounds—e.g., the bound \(\log |y|\) in the AVL-insert Synquid benchmark. ReSyn solved all 56 benchmarks with an average running time of 18.3 s.

SynPlexity: We manually added resource usages and resource bounds to existing problems to encode them for SynPlexity. For Synquid benchmarks without concrete resource bounds, we chose well-known time complexities as the bounds, e.g., we added the resource bound \(O(u \log u )\) to the Sort-merge-sort problem. For the ReSyn benchmarks, we translated the concrete resource usage and resource bounds to the corresponding asymptotic ones—e.g., for the ReSyn-common’ benchmark with the concrete resource bound \(|ys|+|zs|\), we constructed a SynPlexity variant with the asymptotic bound \(O(u )\) and a size function \(\lambda ys.\lambda zs.|ys|+|zs|\). We could not encode 3 synthesis problems as SynPlexity benchmarks: two of them involved higher-order functions that do not satisfy the assumptions introduced in Sect. 5, and the other one has an exponential resource-usage bound \(O(2^u )\) (the Tree-create-balanced problem from Synquid).

SynPlexity solved 73 benchmarks with an average running time of 8.1s. Unlike Synquid, SynPlexity guarantees that the synthesized program satisfies the given resource bounds. After extending the implementation to support the restrictions discussed in Sect. 5, SynPlexity solved 5/6 benchmarks with higher-order functions. For 10 benchmarks, SynPlexity found programs that had better resource usage than those synthesized by Synquid. Furthermore, SynPlexity can encode and solve 9 problems that ReSyn could not solve because the resource bounds involve logarithms. However, SynPlexity cannot encode and solve 2 benchmarks that involve higher-order functions and do not satisfy the restrictions introduced in Sect. 5. SynPlexity could solve 3 problems that required synthesizing both the main function (e.g., SynPlexity-merge-sort) and its auxiliary function (e.g., a function splitting a given list into two balanced partitions). No other tool could solve the SynPlexity-merge-sort’ benchmark.

figure q

6.2 Pruning the Search Space with Annotated Types

SynPlexity uses recurrence annotations to guide the search and avoids enumerating terms that are guaranteed to not match the specified complexity. We compared the numbers of E-terms enumerated by SynPlexity and Synquid for 56 benchmark on which both tool produced same solutions. Synquid always enumerated at least as many E-terms as SynPlexity, and SynPlexity enumerated strictly fewer E-terms for 26/56 benchmarks. For these 26 benchmarks, SynPlexity can on average prune the search space by 6.2%. For example, in one case (BST-delete) SynPlexity enumerated 2,059 E-terms, while Synquid enumerated 2,202.

figure r

7 Related Work

Resource-Bound Analysis. Rather than determining whether a given program satisfies a specification, a synthesizer determines whether there exists a program that inhabits a given specification. The branch of verification that we draw upon for resource-based synthesis is resource-bound analysis [20].

Within the literature on automated resource-bound analysis, there are methods that extract and solve recurrence relations for imperative code [2, 4, 7, 15]. However, these methods are unlike the type system presented in this work because they extract concrete complexity bounds as recurrence relations, and then solve the recurrences to find a concrete upper bound on resource usage. The dominant terms of the resulting concrete bounds can then be used to state a big-O complexity bound. In contrast, we want to synthesize programs with respect to a big-O complexity directly, which is more similar to the manual reasoning of [6, 8]. Thus, if we were to use these techniques for our problem, the first step in our synthesis algorithm would be to pick a concrete complexity function given a big-O complexity, and then reverse the verification problem with regards to that concrete complexity. However, for any big-O complexity, there are an infinite number of functions that satisfy that complexity, which presents a significant challenge at the outset. Our design choice also has some drawbacks. As noted in [8], reasoning compositionally with big-O complexity is challenging due to the hidden quantifier structure of big-O notation. Thus, to maintain soundness our type system has to sacrifice precision and generality in some places. For example, when a function has multiple paths, our type system over-approximates by choosing the largest complexity among all the paths.

Another set of methods to generate resource bounds are type-based [9, 10, 14, 19]. As we discussed throughout the paper, the complexities generated by these methods are concrete functions and not expressed with big-O notation, although [19] is sometimes able to pattern match a case of the Master Theorem. These type systems differ from ours in a few ways. The AARA line of research [9, 10, 14] is able to assign amortized complexity to programs, but is not able to generate logarithmic bounds. [19] is also able to perform amortized analysis; however, the technique is not fully automated, and instead requires the user to provide type annotations on terms, which are then checked by the type system.

Type- and Resource-Aware Synthesis. The SynPlexity implementation is built on top of Synquid  [18] a type-directed synthesis tool based on refinement types and polymorphism. The work that most closely resembles ours is ReSyn [16]. As in our work, they combine the type-directed synthesizer Synquid with a type system that is able to assign complexity bounds to functional programs. The type system used in ReSyn is based on one originally used in the context of verification [10]. That work uses a sophisticated type system to assign amortized resource-usage bounds to a given program. The type system of ReSyn differs from the one presented in Sect. 3 in a few significant ways.

As highlighted earlier, ReSyn automatically infers bounds on recursive functions using amortized analysis and is restricted to linear bounds, whereas our system is able to synthesize complexities of the form \(O(n^a\log ^b n+c)\).

Another difference is that ReSyn synthesizes programs with a concrete complexity bound. This approach has advantages and disadvantages. For instance, it places an extra burden on the human to provide the correct bound with precise coefficient. On the other hand, the user might want an implementation that has a complexity with a small coefficient, whereas our system provides no guarantee that the complexity of an implementation will have a small coefficient in the dominant term: SynPlexity only guarantees asymptotic behavior.

ReSyn can synthesize programs with higher-order functions, which are supported only in a restricted manner by SynPlexity. To handle higher-order functions, ReSyn attaches resource units to types, which gives it resource polymorphism. Moreover, costs of inputs with function types can be written generally as polymorphic types (i.e., costs can be polymorphic with respect to the size of the specific input types). SynPlexity does not have asymptotic resource polymorphism because it cannot directly compose unknown big-O functions (i.e., the complexity of higher-order inputs). We envision that with carefully crafted restrictions on the resource annotations of higher-order functions, SynPlexity could handle synthesis problems involving such functions, e.g., assuming that the complexity of input functions is known and the refinements of input functions are precise enough. Detailed discussion about these restrictions can be found in Sect. 5 and the technical report [11]. Because big-O functions cannot be directly composed, developing a more general extension to SynPlexity that supports higher-order functions is a challenging direction for future work.