François Pottier
email: francois.pottier@inria.fr

Armaël Guéneau
email: armaëlguéneau@inria.fr

Jacques-Henri Jourdan
email: jacques-henri.jourdan@cnrs.fr

Glen Mével
email: mével@crans.org

Thunks and Debits in Separation Logic with Time Credits

Keywords: CCS Concepts:, Theory of computation → Separation logic; Program verification program verification, separation logic, time complexity

ou non, émanant des établissements d'enseignement et de recherche français ou étrangers, des laboratoires publics ou privés.

INTRODUCTION

In a famous book, [START_REF] Okasaki | Purely Functional Data Structures[END_REF] presents several data structures for purely functional programs. These data structures are persistent [START_REF] Driscoll | Making Data Structures Persistent[END_REF]], that is, apparently immutable. Instead of mutating its argument, an update operation returns a new data structure, leaving the observable content of the original data structure intact.

In order to achieve good time complexity, Okasaki uses thunks, also known as suspensions. A thunk is a mutable data structure that offers a simple memoization service: it stores either a suspended computation or the result of this computation. The use of thunks does not affect the functional behavior of a program, but allows delaying a computation until the moment where its result is definitely needed, while still sharing this result. Thus, the use of thunks can improve the time complexity of a program, without impacting its final result. For this reason, thunks are accepted as an essential part of the "functional programming" toolbox [START_REF] Hughes | Why Functional Programming Matters[END_REF]]. In fact, in the functional programming language Haskell, creating and forcing thunks are implicit operations.

Thunks are a basic building block in the construction of streams, whose definition appears in Figure 1. A stream is a lazy list: each stream cell is wrapped in a thunk, so it is evaluated only Fig. 1. Streams: OCaml Code on demand. Figure 1 presents several functions on streams. The functions Thunk.create and Thunk.force are used to create and force thunks. The function append (line 12) lazily appends two streams: a thunk in the stream s1 and s2 is forced only when needed, that is, only when the corresponding thunk in the result stream is forced. revl (line 22) reverses a list, returning a stream. It is also lazy, insofar as possible: the expensive list reversal operation (performed by the auxiliary function revl _ append) is carried out only when the first thunk of the result stream is forced.

Okasaki's complexity bounds are amortized [START_REF] Endre | Amortized Computational Complexity[END_REF]: that is, the actual cost of an operation may be higher than its advertised cost. Still, the advertised complexity bounds are sound, and can be used to compute a safe bound on the global execution time of a program. The basic idea of amortization [START_REF] Endre | Amortized Computational Complexity[END_REF]] is simple: if the advertised cost of some operations is slightly higher than their actual cost, then a sequence of operations accumulates a certain amount of credit, which can be used to justify an occasional "expensive" operation, whose actual cost exceeds its advertised cost. One must stress, however, that Okasaki does not reason in terms of credit, and cannot do so, because he needs thunks to be shareable. Sharing credit leads to an unsound analysis, as credit can then be spent twice. Instead, Okasaki reasons in terms of debit. With every thunk, he associates a debit, a nonnegative integer number, which indicates how much one must pay before this thunk can be forced. In other words, Okasaki ensures that the actual cost of forcing a thunk is always paid for in advance. Once its debt has been paid off, a thunk can be forced as many times as one wishes, essentially "for free", that is, at cost 𝑂 (1). Even if thunks are shared, this analysis is sound: indeed, duplicating a debt leads to an over-approximation of a program's time complexity.

In summary, Okasaki's thunks with debits support three main operations: creation, forcing, and paying. When a new thunk is created, its debit is the cost of the suspended computation. If the debit associated with this thunk becomes zero, then this thunk can be forced. Between these two points in time, this thunk's debit can be reduced by paying, in one or more increments. Paying is a ghost operation: it has no runtime effect, as there is no runtime accounting of debits. Paying decreases the debit of a thunk from 𝑛 down to 𝑛 -𝑘 and is itself regarded as an operation that costs 𝑘 units. [START_REF] Anders | Lightweight Semiformal Time Complexity Analysis for Purely Functional Data Structures[END_REF] must be credited for this crisp explanation of Okasaki's analysis technique.

Because every cell in a stream involves a thunk, it is natural to represent the "cost" of a stream as a sequence of debits, recording the cost of forcing each stream cell, or, more accurately, the remaining debt associated with each stream cell. The "cost" of each operation on streams can then be expressed in terms of sequences of debits. For example, the debit sequence of the stream returned by append is roughly the concatenation of the debit sequences of the two argument streams, plus an extra debit of 𝑂 (1) that is added to every thunk in the first segment of the new debit sequence, so as to account for the cost of concatenation. (Details appear in §6.3 and Figure 16.) As another example, in the stream returned by revl, the first thunk has debit 𝑂 (𝑛), where 𝑛 is the length of the list l, while the following thunks have debit zero: indeed, the expensive list reversal operation is performed when the first thunk is forced, and the remaining thunks require no computation.

Challenges. In this paper, we wish to give a mechanized and foundational account of Okasaki's debit-based reasoning discipline. We want our reasoning rules to be expressive enough to verify the functional correctness and time complexity of Okasaki's functional data structures and of (possibly imperative) client programs that use them. This requires addressing several challenges:

(1) We need a program logic that can express and verify worst-case bounds on execution time.

This logic must allow debit-based reasoning about thunks, but must not be restricted to this style: code that does not involve thunks typically requires credit-based reasoning.

(2) This logic must allow reasoning not only about purely functional data structures, but also about imperative data structures and algorithms.

(3) As demonstrated by append, it is common to construct a thunk which (when forced) forces another thunk. Unfortunately, in the presence of mutable state, one can also construct a reentrant thunk, that is, a thunk that attempts to force itself, resulting (at best) in a graceful runtime error or (at worst) in undesirable behavior, such as divergence. We want the logic to statically forbid reentrancy, thereby offering a strong guarantee: a verified program cannot fail at runtime and must terminate within the advertised time bound. (4) Although we have written that it is "natural" to associate a sequence of debits with a stream, this style of reasoning is in fact highly nontrivial, as it involves keeping track of and updating the debt of future thunks. That is, a thunk's debit conceptually exists and can be updated before this thunk actually exists in the machine's memory! This is known as a deep payment. As a key example, we show that streams enjoy an intuitively simple debit forwarding rule, which allows shifting debits towards the left in a sequence of debits. To illustrate this rule, consider append f (revl r), where f is a stream of length 𝑛 and r is a list of length 𝑛. Suppose that every thunk in the stream f has debit 𝑂 (1). According to the specifications of revl and append that we have sketched, this expression produces a stream whose debit sequence is 𝑂 (1) everywhere, except for the central thunk, whose debit is 𝑂 (𝑛). Debit forwarding allows us to change our view of this stream by distributing the high debt of the central thunk onto the preceding thunks. The debit of every thunk in the first half of the sequence is increased by 𝑂 (1), therefore remains 𝑂 (1), while the debit of the central thunk is reduced to 𝑂 (1). This nontrivial reasoning step is carried out before the stream constructed by append f (revl r) is forced. It plays a crucial role in the verification of the banker's queue ( §7).

State of the Art. Several papers in the literature address some, but not all, of these challenges. [START_REF] Anders | Lightweight Semiformal Time Complexity Analysis for Purely Functional Data Structures[END_REF] proposes a type system, embedded in Agda, featuring an abstract type Thunk 𝑛 𝑎 and operations on this type. The parameter 𝑛 is the debit associated with this thunk; the parameter 𝑎 is the type of its result. This pioneering work exhibits several major limitations: it is limited to purely functional programs and debit-based reasoning; it does not seem to prevent reentrancy or nontermination ( §9); and the proof of type soundness is not formally connected with the API that a user of the library relies upon. Furthermore, deep payment appears in the API but is not covered by the soundness proof. Thus, Danielsson addresses Challenge 4, but not Challenges 1 to 3.

Several authors [START_REF] Atkey | Amortised Resource Analysis with Separation Logic[END_REF][START_REF] Hoffmann | Quantitative Reasoning for Proving Lock-Freedom[END_REF][START_REF] Charguéraud | Verifying the Correctness and Amortized Complexity of a Union-Find Implementation in Separation Logic with Time Credits[END_REF][START_REF] Maximilian | Hoare Logics for Time Bounds: A Study in Meta Theory[END_REF][START_REF] Zhan | Verifying Asymptotic Time Complexity of Imperative Programs in Isabelle[END_REF][START_REF] Mével | Time credits and time receipts in Iris[END_REF][START_REF] Maximilian | For a Few Dollars More -Verified Fine-Grained Algorithm Analysis Down to LLVM[END_REF] use separation logic with time credits to verify worst-case amortized time complexity bounds for possibly imperative programs. The basic idea is simple: one arranges for every step of computation to consume one time credit, where a time credit is a ghost resource. Because credits cannot be forged or duplicated, the amount of credit that is initially made available (in the precondition of a closed program) bounds the total execution time of this program. Among these authors, only [START_REF] Mével | Time credits and time receipts in Iris[END_REF] formalize thunks and debits; however, their reasoning rules support neither thunks that force thunks nor debit forwarding. Thus, they address Challenges 1 and 2, but not Challenges 3 or 4. Their work is discussed in greater detail at the end of the paper ( §9).

Contributions.

In this paper, we address challenges 1-4 and carry out a foundational verification of the time complexity of three of Okasaki's notorious algorithms [1999]. To address Challenges 1 and 2, we use Iris $ [START_REF] Mével | Time credits and time receipts in Iris[END_REF]], a Separation Logic with time credits that is implemented inside the Coq proof assistant on top of the Iris framework [START_REF] Jung | Iris from the ground up: A modular foundation for higher-order concurrent separation logic[END_REF]. Inside this existing logic, we verify a "thunk" library, equipped with a rich "debit" API, which supports nested thunks and deep payment, addressing Challenges 3 and 4. Using this library, we verify several of Okasaki's data structures, namely the banker's queue [Okasaki 1999, §6.3.2] ( §7), the physicist's queue [Okasaki 1999, §6.4.2], and implicit queues [Okasaki 1999, §11.1] ( §8). In each case, we verify functional correctness, termination, and worst-case amortized time complexity. Thanks to the modularity of Iris $ , our verified data structures can be used as part of verified programs that involve time-credit-based reasoning, mutable state, or shared-memory concurrency.

On Thread Safety. Although our thunks can be used in a concurrent program, they are not thread-safe: two threads must never race to force a thunk. Our thunk API enforces this restriction by using affine tokens that represent a permission to force a thunk. Nevertheless, we are able to verify Okasaki's data structures, because they are sequential data structures. Our verified APIs use affine tokens, where needed, to enforce sequential access. For instance, the reasoning rule Banker-Extract in Figure 18 uses the token ∞ to forbid concurrent calls to extract.

Haskell's thunks, in comparison, are thread-safe: it is permitted for two threads to concurrently attempt to force a thunk. This implies that one must be prepared to accept duplication of work (where the suspended computation is performed twice) or blocking (where one thread waits for another thread to complete the computation and update the thunk). Haskell's implementation involves a combination of duplication and blocking [START_REF] Marlow | Runtime support for multicore Haskell[END_REF]. However, our time complexity analysis allows neither duplication nor blocking: we do not know how to bound the extra time that would be spent performing duplicated work or waiting for a thunk to be updated. For this reason, in this paper, we stick with a simple view of thunks as a sequential data structure. [START_REF] Hoffmann | Quantitative Reasoning for Proving Lock-Freedom[END_REF] propose reasoning about lock-free data structures using time credits. Their key insight is that a thread that is able to make progress (typically via a successful CAS) must pay for the cost of CAS failures and retries in other threads. It is not clear whether this idea could be exploited to reason about the worst-case time complexity of concurrent thunks.

Road Map. Our thunk library is organized in three layers of abstraction. After recalling some of the concepts of Iris ( §2), we present the bottom layer, a novel ghost data structure, the piggy bank ( §3). None of the operations on piggy banks has a runtime effect. The main three operations on piggy banks, namely creating, paying, and breaking (forcing) a bank, are ghost updates. In the second layer ( §4), we implement thunks and establish reasoning rules that match Okasaki's informal rules. These rules include Thunk-Conseqence (Figure 5), which, together with Thunk-Pay (Figure 6), justifies deep payment. Our construction of thunks relies on piggy banks in two distinct places and can associate many piggy banks with a single thunk. In the third and last layer ( §5), we equip thunks with a notion of height that simplifies the way in which we rule out reentrancy while still allowing thunks to force thunks.

On top of thunks, we implement streams ( §6). We establish a number of reasoning rules about streams, including the debit forwarding rule Stream-Forward-Debit (Figure 13), which distributes a debit over several thunks in a stream, by moving parts of this debit from the right toward the left, that is, from thunks that are more distant in the future towards thunks that are closer in the future.

After presenting our proofs of three of Okasaki's data structures ( §7, §8), we review the related work ( §9) and conclude ( §10). All of our results are machine-checked [START_REF] Pottier | Thunks and Debits in Separation Logic with Time Credits: Coq Formalization[END_REF]. For readability, in the paper, we present code in OCaml syntax. The code that we actually verify is expressed in HeapLang, an untyped call-by-value 𝜆-calculus with dynamically allocated mutable state, whose definition is bundled with Iris.

A REFRESHER ON IRIS AND IRIS $

Even a basic introduction to Iris [START_REF] Jung | Iris from the ground up: A modular foundation for higher-order concurrent separation logic[END_REF]] could occupy a whole paper. We recall some of the key concepts, intuition, and notation of Iris, and we hope that a reader who is not an expert in Iris can grasp the intuition behind the abstractions that we build. For example, a reader who looks at the reasoning rule Thunk-Pay (Figure 6) should at least understand that it is a ghost update (⇛) that consumes 𝑘 time credits ($𝑘) and decreases the debit of a thunk from 𝑛 down to 𝑛 -𝑘.

Assertions. Separation logic uses assertions to describe certain knowledge about the world and to encode permissions to change the world in certain ways. By "the world", we mean both the physical state of the machine and the ghost state that has been allocated as part of the proof. Some assertions are pure, that is, independent of the world. For example, the assertion ⌜𝑥 = 0⌝ asserts that the equation 𝑥 = 0, which involves the mathematical variable 𝑥, holds. Pure assertions are a special case of persistent assertions. Although persistent assertions may depend on the world, once they hold, they hold forever. For instance, the assertion Thunk F 𝑡 n 𝑅 𝜙, which asserts (among other things) that 𝑡 is the address of a valid thunk in memory, is persistent. This reflects the fact that a thunk cannot be destroyed.1 A persistent assertion is duplicable: if 𝑃 is persistent, then 𝑃 entails the conjunction 𝑃 * 𝑃. The fact that Thunk is persistent reflects that it is safe to share a thunk. Finally, an assertion that is neither pure nor persistent is affine. An affine assertion typically represents a combination of knowledge and permission. For instance, the points-to assertion 𝑡 ↦ → 𝑣 represents both the exclusive knowledge that the memory location 𝑡 currently contains the value 𝑣 and an exclusive permission to write a new value at this location.

The natural notions of conjunction and implication are the separating conjunction * and the magic wand - * . (The non-separating conjunction ∧ and implication ⇒ are not used in this paper.) A magic wand 𝑃 - * 𝑄 can be read as an implication; however, one must keep in mind that (unless 𝑃 is persistent) applying this magic wand consumes 𝑃. A magic wand itself is not persistent, so it can be applied only once. It can be made persistent by using the persistence modality: □(𝑃 - * 𝑄) is a magic wand that can be used as many times as one wishes.

Ghost State. Like physical state, ghost state is dynamically allocated. The law True ⇛ ∃𝛾 . 𝛾 ↦ → 𝑚 (provided by Iris) allocates a fresh ghost cell, at address 𝛾, whose initial content is 𝑚. We write 𝛾, 𝛿, 𝜋 for ghost addresses. A ghost update assertion 𝑃 ⇛ 𝑄 means that, by consuming 𝑃 and by updating the ghost state, it is possible to reach a state where 𝑄 holds. A ghost update is applied as part of a proof; such an application is not visible in the code. The content 𝑚 of a ghost cell 𝛾 is an element of a camera 𝑀 that is implicitly associated with 𝛾 and that is chosen when this ghost cell is allocated. For our purposes, a camera is a commutative monoid (𝑀, •) equipped with a notion of validity, such that valid (𝑚 1 • 𝑚 2 ) implies valid 𝑚 1 ∧ valid 𝑚 2 . By design, the logic guarantees that the content of a ghost cell is always a valid element. This is expressed by the law 𝛾 ↦ → 𝑚 ⊢ ⌜valid 𝑚⌝. The ghost points-to assertion 𝛾 ↦ → 𝑚 means that 𝑚 is one fragment of the content of the ghost cell 𝛾, and represents the ownership of just this fragment. This is reflected by the composition law 𝛾 ↦ → 𝑚 1 • 𝑚 2 ≡ 𝛾 ↦ → 𝑚 1 * 𝛾 ↦ → 𝑚 2 , which allows ghost points-to assertions to be split and joined, and by the frame-preserving update law, which is stated as follows: if for every

𝑚 ′ valid (𝑚 1 • 𝑚 ′ ) implies valid (𝑚 2 • 𝑚 ′ ) then 𝛾 ↦ → 𝑚 1 ⇛ 𝛾 ↦ → 𝑚 2 .
A meta witness 𝑡 ⇝ 𝛾, a persistent assertion, indicates that the ghost address 𝛾 has been associated with the physical memory location 𝑡. The law 𝑡 ⇝ 𝛾 1 * 𝑡 ⇝ 𝛾 2 ⊢ ⌜𝛾 1 = 𝛾 2 ⌝ (provided by Iris) states that this association is unique: it forms a (partial) map of physical locations to ghost addresses.

Invariants. Roughly speaking, an invariant is an assertion which, by convention and from a certain point on, must hold "at all times". The assertion 𝐼 indicates that the assertion 𝐼 has been made an invariant. The law 𝐼 ⇛ 𝐼 dynamically establishes a new invariant. Even if 𝐼 is not persistent, 𝐼 is persistent: the knowledge that an invariant exists can be shared. This knowledge allows accessing the invariant, that is, opening and closing it. Opening an invariant produces the assertion 𝐼 , allowing the user to exploit 𝐼 and possibly to destroy it, thereby temporarily violating the invariant. Closing an invariant requires the user to provide 𝐼 and consumes it, thereby re-establishing the invariant.

Thus, the claim that an invariant holds "at all times" is a white lie. An invariant holds at all times except while it is being accessed. Therefore, the logic must forbid reentrant access to an invariant, that is, forbid opening an invariant that is already open. To this end, the use of invariants is subject to two constraints.

First, invariant accesses are atomic: opening and closing an invariant must take place during a single physical computation step. This guarantees that, under an interleaving semantics, two distinct threads cannot access an invariant at the same time.

Second, invariants 𝐼 𝑁 are in fact labeled with a namespace 𝑁 . This annotation is used to forbid reentrant access within a thread: two invariants can be simultaneously opened only if they are labeled with disjoint namespaces.2 Enforcing this policy requires keeping track, at all times, of which invariants can currently be accessed. We omit the details, but note that the ghost update connective ⇛ E must be indexed with a mask E. In short, 𝑃 ⇛ E 𝑄 means that 𝑃 can be transformed into 𝑄 while accessing only those invariants whose namespace 𝑁 is in the set E. One may omit this mask when it is ⊤.

The later modality ⊲ weakens an assertion. Roughly, the assertion ⊲ 𝑃 means that the assertion 𝑃 will hold in the next time step, that is, after the next atomic instruction is executed. This modality appears in the reasoning rules for invariants, where it serves to forbid certain logical paradoxes. Our claim that opening an invariant produces 𝐼 and closing it consumes 𝐼 was another white lie: these operations actually produce and consume ⊲ 𝐼 . This is visible in some of our reasoning rules, such as PiggyBank-Break (Figure 2), but can otherwise be ignored. Hoare Triples. A specification traditionally takes the form {𝑃 } 𝑒 {𝜙 }, where the precondition 𝑃 is an assertion about the initial state, the expression 𝑒 is the program fragment of interest, and the postcondition 𝜙 describes the result value and the final state: if 𝑣 is the result value then 𝜙 𝑣 is an assertion about the final state. We typically write {𝑃 } 𝑒 {𝜆𝑣 . 𝑄 } in order to bind the result value in the postcondition. This can be read as follows: "provided the initial state satisfies 𝑃, executing 𝑒 cannot crash, and if it terminates, then it returns some value 𝑣 such that the final state satisfies 𝑄". A triple is persistent: it allows executing the expression 𝑒 as many times as one wishes.

We occasionally need a one-shot triple, written once {𝑃 } 𝑒 {𝜙 }. Its meaning is the same as that of a persistent triple, except that it allows 𝑒 to be executed at most once. It is an affine assertion. An ordinary triple is a one-shot triple wrapped in the persistence modality □. Time Credits. Iris $ [START_REF] Mével | Time credits and time receipts in Iris[END_REF] extends Iris with time credits. The assertion $n represents n time credits. It is affine: time credits can be discarded but not duplicated. The reasoning rules of the logic ensure that every instruction consumes one time credit. As a result, if the triple {$𝑛} 𝑒 {𝜙 } holds, where 𝑒 is a closed expression (a complete program), then 𝑒 does not crash and must terminate in at most 𝑛 steps. In other words, the logic offers a worst-case time complexity guarantee.

In general, a specification provides a worst-case amortized time complexity guarantee. For instance, Thunk-Force (Figure 6) does not guarantee that force 𝑡 runs in at most F steps. Although only F time credits are ostensibly visible in the precondition, the assertion Thunk F 𝑡 0 𝑅 𝜙, which is also part of the precondition, offers access to an invariant which possibly contains more credits. Thus, this specification means that the amortized time complexity of force is F.

Abstract literal constants, such as N and F, hide concrete numeric constants. For instance, N , the cost of creating a new thunk, is 3, while F, the cost of forcing a thunk, is 11. In our Coq proofs, these literal constants are defined at the top level and are made opaque as soon as possible. In the paper, their definitions are omitted. Every literal constant is denoted by an underlined capital letter.

PIGGY BANKS

Once upon a time, in a faraway university, a class of students wanted to throw a big party. Alas, food, drinks, disguises, and other equipment were then and there very expensive. So, the students' first action was to install in the classrooms, in the students' lounge, in the dormitories, and in several other locations, a number of porcelain piggy banks. The students declared that everyone could contribute whatever amount he or she desired, in whatever location and at whatever time he or she desired. They agreed that, once the total accumulated amount reached a hundred sovereigns, they would break all piggy banks and throw a big party.

Alas, because one could not see through a piggy bank, one could not tell how much money was inside it. And because piggy banks were installed in many places, there was no coordination between contributors. No student could be reliably informed of all contributions, and there was no way of maintaining a registry of all contributions. Faced with these difficulties, the students adopted a habit of telling each other how much money they thought remained to be collected. One morning, in the students' lounge, Charles was told by Brian, "97 to go". There, Charles put one sovereign into the piggy bank. As he exited the room, he ran into Sophie and Sara, whom he told, "96 to go". Later on during that day, at different times and in different places, Sophie and Sara each contributed one sovereign. In the evening, each of them independently told Brian, "95 to go".

When the students finally determined that the piggy banks could safely be broken, they found that they had accumulated much more than a hundred sovereigns. It was a big party.

This fable is meant to suggest that, independently of physical mechanisms such as a distributed piggy bank in a university or a thunk in a computer's memory, there is a sound and useful pattern of reasoning about credit that is accumulated via uncoordinated payments. This pattern involves true debit, or "how much really is still missing", apparent debit, or "how much Sophie thinks is still missing", and the property that an apparent debit is always an over-approximation of the true debit.

To isolate this pattern and to establish its logical soundness, we develop the piggy bank, a ghost data structure, and show that it supports the desired reasoning rules. There is no code: a piggy bank is a purely logical concept. Our construction of thunks ( §4.2.1, §4.2.2) uses piggy banks in two distinct places and can cause an unbounded number of piggy banks to be associated with a single thunk. This suggests that it is worthwhile to make piggy banks a stand-alone abstraction.

Piggy Banks: Interface

A piggy bank can be abstractly described as a ghost data structure that is in one of two states: "pending" or "forced". Furthermore, the transition from the pending state to the forced state is not instantaneous, and may require executing some user code: meanwhile, the piggy bank is in a third state, the "transient" state. Executing this user code has a certain cost: the time credits accumulated in the piggy bank are intended to pay for this.

There is no need for concrete descriptions of the pending state and of the forced state. We assume that these states are described by two parameters 𝑃 : N → iProp and 𝑄 : iProp, where iProp is the type of Iris assertions. The assertion 𝑃 nc means that the piggy bank is in the pending state and that nc, standing for "necessary credits", is the number of credits that we aim to accumulate before transitioning to the forced state. The assertion 𝑄 means that the piggy bank is in the forced state.

We want a piggy bank to be shared between several participants, so it must be described by a persistent predicate, PiggyBank. Participants must be allowed to pay, that is, to insert time credits into the piggy bank. This is a ghost operation. Each participant must be able to pay independently, without coordinating with other participants, so, as suggested by the fable, the PiggyBank assertion must keep track of an apparent debit, that is, a nonnegative number of debits, n. Once a participant sees an apparent debit of zero, we want this participant to be able to deduce that enough credit has been accumulated to allow the transition to take place. We want this participant to be allowed to break (or force) the bank and either perform the transition, or discover that the transition has been performed already by another participant.

Paying and breaking the bank have rather different characteristics. We wish to think of paying as an atomic update of the ghost state of the piggy bank; and we would like paying to be permitted at all times. The act of breaking the bank, on the other hand, cannot be regarded as atomic. A participant who breaks the bank and finds it in the pending state is expected to perform a transition to the forced state, that is, to update the physical state from 𝑃 nc to 𝑄. This can require many steps of computation. For instance, forcing a thunk requires calling a user-supplied function and updating the physical state of the thunk. These considerations suggest that breaking the bank must be viewed as a sequence of two ghost updates, between which the user may execute some physical code. One update causes a transition from the pending state to the transient state; another update causes a transition from the transient state to the forced state.

Between these updates, the piggy bank is in the transient state, where neither 𝑃 nc nor 𝑄 holds. So, while the piggy bank is being forced, one must forbid any attempt to force it again. This is made possible by the use of an exclusive token F . The transition that enters the transient state consumes this token, while the transition that leaves the transient state produces it again. As we will see ( §4), piggy banks are used in a nested manner, with unbounded nesting depth: this calls for an infinite number of distinct tokens. Taking inspiration from Iris's invariants, we organize these tokens using namespaces and masks. (In fact, we reuse the tokens of Iris's non-atomic invariant library.) The token F denotes the ownership of all tokens in the possibly infinite set F . The full token ⊤ is created once and for all at the beginning of the execution of the program.

PiggyBank-Persist persistent(PiggyBank 𝑃 𝑄 𝑁 𝑇 n) PiggyBank-Create 𝑃 n ⇛ E PiggyBank 𝑃 𝑄 𝑁 𝑇 n PiggyBank-Increase-Debit n 1 ≤ n 2 PiggyBank 𝑃 𝑄 𝑁 𝑇 n 1 - * PiggyBank 𝑃 𝑄 𝑁 𝑇 n 2 PiggyBank-Pay ↑𝑁 ⊆ E PiggyBank 𝑃 𝑄 𝑁 𝑇 n * $𝑘 ⇛ E PiggyBank 𝑃 𝑄 𝑁 𝑇 (n -𝑘) PiggyBank-Break ↑𝑁 ⊆ E ↑𝑇 ⊆ F PiggyBank 𝑃 𝑄 𝑁 𝑇 0 * F ⇛ E ∃nc. ((⊲ 𝑃 nc * $nc) ∨ ⊲ 𝑄) * (⊲ 𝑄 ⇛ E F ) PiggyBank-Peek ↑𝑁 ⊆ E ↑𝑇 ⊆ F PiggyBank 𝑃 𝑄 𝑁 𝑇 n * F - * (∀nc, ⊲ 𝑃 nc - * ⊲ False) - * (⊲ 𝑄 - * ⊲ □ 𝑄 ′ ) ⇛ E PiggyBank 𝑃 𝑄 𝑁 𝑇 0 * ⊲ □ 𝑄 ′ * F

Fig. 2. Piggy Banks: Reasoning Rules

A piggy bank is parameterized with two namespaces 𝑁 and 𝑇 . The namespace 𝑁 decorates an invariant that is opened when paying and when forcing the piggy bank. The namespace 𝑇 determines what token F must be supplied when the piggy bank is forced. Together, the parameters 𝑁 ,𝑇 can be thought of as a "region" in which the piggy bank exists.

Our reasoning rules for piggy banks appear in Figure 2. The assertion PiggyBank 𝑃 𝑄 𝑁 𝑇 n means that there exists a piggy bank whose pending and forced states are described by 𝑃 and 𝑄, whose region is 𝑁 ,𝑇 , and whose number of debits is n. The parameter n is the most interesting one: in the rules of Figure 2, the four other parameters are fixed.

The rule PiggyBank-Persist states that a PiggyBank assertion is persistent. That is, a piggy bank can be shared. PiggyBank-Create allocates a new piggy bank. It is a ghost update. The piggy bank must initially be in its pending state: the user must establish the assertion 𝑃 n, which is consumed. The user chooses n, the number of credits that must be accumulated before the piggy bank can be broken. Thus, n is the initial value of the true debit and is also the initial apparent debit. PiggyBank-Increase-Debit states that PiggyBank is covariant in the parameter n. In other words, it is safe to increase an apparent debit. This rule is intuitively sound because it preserves the fact that an apparent debit is an over-approximation of the true debit. PiggyBank-Pay, also a ghost update, allows contributing 𝑘 time credits to a piggy bank. The apparent debit decreases from n to n -𝑘. (This is subtraction in the natural numbers, so n -𝑘 ≥ 0 holds.) This rule does not require any affine token.

We now come to the most complex rule, PiggyBank-Break. This rule allows the user to break a piggy bank whose apparent debit is 0. This is intuitively permitted because, if the apparent debit is 0, then the true debit must be 0 as well. As explained earlier, this rule is expressed via two nested ghost updates. The user has the freedom to apply these updates at two distinct points in time and to execute some code between them. The outermost update initiates the process of breaking the bank. It consumes the affine token F , where ↑𝑇 ⊆ F must hold: this forbids an attempt to break this piggy bank again while it is already being broken. It produces the following situation: for some value of nc, both of the following assertions hold:

(1) either the piggy bank is in its pending state 𝑃 nc, in which case nc time credits are available (because the true debit is zero), or the piggy bank is already in its forced state 𝑄; (2) whatever state the piggy bank is currently in, the user must bring it into the forced state 𝑄 so as to be able to apply the innermost ghost update, which ends the process of breaking the bank and causes the affine token F to re-appear.

PiggyBank 𝑃 𝑄 𝑁 𝑇 n ≜ ∃ 𝑖, 𝜋, nc. ∃ac. 𝜋 ↦ → • ac * ⋄ 𝑖 * 𝑃 nc * $ac ∨ ↑𝑇 * ⌜nc ≤ ac⌝ ∨ ⋄ 𝑖 * 𝑄 * ⌜nc ≤ ac⌝ 𝑁 * 𝜋 ↦ → • (nc -n)
The variable nc is existentially quantified because the cost of moving from the pending state to the forced state is unknown: it cannot be deduced from the PiggyBank assertion. The fact that this variable is shared between the conjuncts 𝑃 nc and $nc guarantees that "enough" credit is available. Whereas forcing requires an affine token (which disappears while forcing is in progress, and re-appears when the process is complete), paying does not require a token. Therefore, while a bank is being forced, forcing it again is disallowed, but paying remains permitted.

PiggyBank-Peek states that if the user is somehow able to prove that this piggy bank cannot be in its pending state (that is, 𝑃 nc implies false), then it must be in its forced state and the piggy bank's apparent debit can be set to 0. This rule is later exploited to establish the rule ThunkVal-Force.

Piggy Banks: Construction

The definition of the predicate PiggyBank appears in Figure 3. It may be of interest mainly to readers who are familiar with Iris; other readers may wish to skip this part.

A ghost cell, 𝜋, appears in the definition. This cell keeps track of the total amount of the payments that the piggy bank has received. This amount grows in a monotonic manner: it can never decrease. Two kinds of assertions control this cell. The affine assertion 𝜋 ↦ → • ac represents the exact knowledge of the current value of the cell and the authority to increase this value. The persistent assertion 𝜋 ↦ → • 𝑘 is a witness that the value of the cell is at least 𝑘. These assertions satisfy the agreement law

𝜋 ↦ → • ac * 𝜋 ↦ → • 𝑘 ⊢ ⌜𝑘 ≤ ac⌝, the update law 𝜋 ↦ → • ac ⇛ 𝜋 ↦ → • (ac + 𝑘) , and the witness creation law 𝜋 ↦ → • ac ⇛ 𝜋 ↦ → • ac .
The invariant that appears in Figure 3 holds the authoritative view 𝜋 ↦ → • ac , which guarantees that ac (for "available credit") is the total amount of payment received so far. Furthermore, this invariant contains a three-way disjunction: the piggy bank must be in the pending state ⋄ 𝑖 * 𝑃 nc * $ac, in the transient state F * ⌜nc ≤ ac⌝, or in the forced state ⋄ 𝑖 * 𝑄 * ⌜nc ≤ ac⌝.

The exclusive tokens ⋄ 𝑖 and ↑𝑇 allow maintaining some knowledge, outside of the invariant, about which of these three states the invariant must be in. More precisely, if (while the invariant is closed) one holds the token ↑𝑇 then one can deduce that the invariant must be in the pending state or in the forced state, but cannot be in the transient state, because the conjunction ↑𝑇 * ↑𝑇 implies a contradiction. Dually, if one holds the transient token ⋄ 𝑖 then one can deduce that the invariant must be in the transient state. A fresh transient token ⋄ 𝑖 together with its identifier 𝑖 are created when a new piggy bank is created.

In the pending state, the invariant contains the assertion 𝑃 nc, where nc stands for "necessary credit", as well as ac time credits. In the transient and forced states, where forcing has already begun, the invariant guarantees nc ≤ ac, which means that the available credit has exceeded the necessary credit. In the forced state, the invariant contains the assertion 𝑄.

The last part of Figure 3 is the persistent witness 𝜋 ↦ → • (ncn) . By opening the invariant and by exploiting the agreement law, this witness allows obtaining the inequality ncn ≤ ac. If the apparent debit n is zero, then one obtains nc ≤ ac: that is, there is enough accumulated credit to cover the cost of the transition from the pending state to the forced state.

Theorem 3.1. The predicate PiggyBank satisfies the reasoning rules of Figure 2.

THUNKS

Our implementation of thunks appears in Figure 4. In the following, we first present the reasoning rules that we wish to establish about thunks ( §4.1). Then, we present the nontrivial construction that lets us obtain these rules ( §4.2).

Thunks: Interface

The predicate Thunk F 𝑡 n 𝑅 𝜙 describes a thunk at location 𝑡 in memory. The mask F plays the same role as in the previous section ( §3): in short, it determines which token F is required to force this thunk. The parameter n also plays the same role as in the previous section: it is an apparent debit associated with this thunk. The parameter 𝑅 is a resource that is required and preserved by the suspended computation. The presence of this parameter allows us to describe thunks that have side effects and thunks whose execution requires a certain token. The latter category includes thunks that force other thunks, something that is commonly needed. The parameter 𝜙 : Val → iProp determines the postcondition of the thunk: once this thunk is forced and produces a value 𝑣, the assertion □ 𝜙 𝑣 can be expected to hold. 3The full set of reasoning rules for thunks appears in Figures 5,6, and 7. The rules are split in three groups for reasons that will be apparent in the next subsection ( §4.2).

Thunk-Create (Figure 5) describes the creation of a new thunk via the function call create 𝑓 . The behavior of 𝑓 is specified by the premise isAction 𝑓 n 𝑅 𝜙, which denotes the one-shot triple once {𝑅 * $n} 𝑓 () {𝜆𝑣. 𝑅 * □ 𝜙 𝑣 }. This assertion is a permission to call 𝑓 () at most once. It indicates that the call 𝑓 () consumes n time credits and must return a value 𝑣 such that □ 𝜙 𝑣 holds. The resource 𝑅 is required, but not consumed, by this call. Under this hypothesis on 𝑓 , Thunk-Create states that create 𝑓 costs a constant amount of credits $N and returns a thunk whose apparent debit, resource requirement, and postcondition are described by n, 𝑅, 𝜙. The abstract integer constant N is part of the thunk API; its concrete value is not revealed. The mask F is chosen The rules Thunk-Persist, Thunk-Increase-Debit, and Thunk-Pay (Figure 6) are analogous to PiggyBank-Persist, PiggyBank-Increase-Debit, and PiggyBank-Pay. They state that thunks can be shared, that it is permitted to over-approximate an apparent debit, and that an apparent debit can be reduced by paying. (In Thunk-Pay, one can see that the parameter 𝑁 of piggy banks has been instantiated with a fixed namespace ThunkPayment.)

Thunk-Create ↑𝑇 ⊆ F {$N * isAction 𝑓 n 𝑅 𝜙 } create 𝑓 {𝜆𝑡 . Thunk F 𝑡 n 𝑅 𝜙 } Thunk-Conseqence Thunk F 𝑡 n 1 𝑅 𝜙 - * isUpdate n 2 𝑅 𝜙 𝜓 ⇛ E Thunk F 𝑡 (n 1 + n 2 ) 𝑅 𝜓
n 1 ≤ n 2 Thunk F 𝑡 n 1 𝑅 𝜙 - * Thunk F 𝑡 n 2 𝑅 𝜙 Thunk-Pay ↑ThunkPayment ⊆ E Thunk F 𝑡 n 𝑅 𝜙 * $𝑘 ⇛ E Thunk F 𝑡 (n -𝑘) 𝑅 𝜙 Thunk-Force {Thunk F 𝑡 0 𝑅 𝜙 * $F * F * 𝑅} force 𝑡 {𝜆𝑣. ThunkVal 𝑡 𝑣 * □ 𝜙 𝑣 * F * 𝑅}
Thunk-Force allows forcing a thunk whose apparent debit is zero. This consumes $F, a constant number of time credits. In other words, the amortized time complexity of this operation is 𝑂 (1), because the cost of the suspended computation has been paid for in advance. The token F and the resource 𝑅 are required and preserved. The token F is required to force the thunk itself, whereas the token 𝑅 is required by the suspended computation. These two assertions are separately required: that is, they cannot be the same token. Indeed, as will be evident in the next subsection ( §4.2), the process of breaking the thunk's piggy bank begins (therefore, the token F disappears) before the suspended computation is executed, and ends after the suspended computation terminates.

Forcing a thunk produces a value 𝑣 such that the persistent assertions ThunkVal 𝑡 𝑣 and □ 𝜙 𝑣 hold. The assertion ThunkVal 𝑡 𝑣 is a witness that the value of the thunk 𝑡 has been forever decided and that it is 𝑣. The assertion □ 𝜙 𝑣 means that 𝜙 𝑣 holds now and forever and can be exploited as many times as one wishes.

The predicate ThunkVal satisfies the reasoning rules in Figure 7. The rule ThunkVal-Persist reflects the fact that once the association between 𝑡 and 𝑣 has been decided, it remains fixed forever. The rule ThunkVal-Timeless states that ⊲ ThunkVal 𝑡 𝑣 is essentially the same as ThunkVal 𝑡 𝑣; this is of technical interest only. The agreement law ThunkVal-Confront states that if a thunk has been forced twice in the past then the same value must have been returned twice. Finally, ThunkVal-Force allows forcing a thunk that has been forced already. This assumption is reflected by the appearance of the assertion ThunkVal 𝑡 𝑣 in the precondition. There is no requirement that the thunk's apparent debit be zero. As in Thunk-Force, forcing consumes $F. The value that is obtained by forcing this thunk must be 𝑣, the value that was predicted by the witness ThunkVal 𝑡 𝑣. Unlike Thunk-Force, this rule requires neither the token F nor the resource 𝑅. They are not necessary because no suspended computation is executed. Also unlike Thunk-Force, and perhaps surprisingly, this rule does not guarantee □ 𝜙 𝑣. In the presence of Thunk-Conseqence, this cannot be guaranteed. In short, the assumption ThunkVal 𝑡 𝑣 guarantees that this physical thunk has been forced already; but it could be wrapped in a number of (ghost) proxy thunks ( §4.2.2) that have not been forced yet.

The rule Thunk-Conseqence (Figure 5) allows changing the postcondition of a thunk from □ 𝜙 to □𝜓 . In the special case where n 2 is zero, this rule weakens the postcondition of a thunk, which is why we name it the consequence rule. In the case where n 2 is nonzero, this rule increases the apparent debit of the thunk from n 1 to n 1 + n 2 . In return, the update4 from □ 𝜙 𝑣 to □ 𝜓 𝑣 is allowed to consume n 2 time credits.

Thunks: Construction

It is not easy to define the predicates Thunk and ThunkVal in such a way that all of the rules of Figures 5,6, and 7 are satisfied. A key contribution of this paper is to propose a definition of Thunk that validates all of the desired rules. Although it is technically possible to give a monolithic definition, we prefer to approach the problem in three stages, as follows.

(1) We define a predicate BasicThunk that satisfies all of the desired rules except the consequence rule. It is analogous to Mével et al.'s isThunk, but instead of relying directly on Iris's "nonatomic invariant" construction, it is built on top of our "piggy bank" abstraction. As a result, BasicThunk validates Thunk-Pay. (2) We remark that applying the reasoning rule Thunk-Conseqence to an existing thunk 𝑡 seems closely related to constructing a new thunk 𝑡 ′ via the expression create (𝜆(). force 𝑡).

The difference is that the consequence rule is a ghost operation and does not create a new thunk at runtime. Still, this analogy suggests that applying the consequence rule should allocate a new piggy bank. Guided by this idea, we propose a construction that supports one application of the consequence rule. Assuming that we have a predicate Thunk that satisfies the rules of Figure 6, we construct a new predicate ProxyThunk, which also satisfies these rules, and we establish a version of the consequence rule that expects a Thunk and produces a ProxyThunk.

(3) We show that this construction can be iterated as many times as desired. By building on top of BasicThunk and ProxyThunk, we are able to propose a definition of Thunk that satisfies all of the desired rules, including Thunk-Conseqence. This three-stage construction is presented in the next three subsections ( §4.2.1, §4.2.2, §4.2.3). We remark that the definition of the predicate ThunkVal is not problematic. We give a definition of ThunkVal in the first stage ( §4.2.1) and keep this definition in the following stages. The ghost cell 𝛿 records whether the value of this thunk is still undecided or decided. This ghost cell inhabits the camera Ex(()) + Ag(Val), also known as the "one-shot" camera [Jung et al. 2018, §2.1]. This gives rise to the following assertions and laws. The assertion 𝛿 ↦ → ? means that the value is not decided yet. This assertion is affine: it represents a unique permission to make a decision. The assertion 𝛿 ↦ → 𝑣 means that the value has been decided and that this value is 𝑣. This assertion is persistent: once a value has been decided, this decision cannot be undone, so the information that the value is 𝑣 remains valid forever and can be shared. These assertions satisfy the decision law 𝛿 ↦ → ? ⇛ 𝛿 ↦ → 𝑣 , the agreement law 𝛿 ↦ → 𝑣 1 * 𝛿 ↦ → 𝑣 2 ⊢ ⌜𝑣 1 = 𝑣 2 ⌝, and the disagreement law 𝛿 ↦ → ? * 𝛿 ↦ → 𝑣 ⊢ False. A meta witness 𝑡 ⇝ 𝛿 records that the ghost cell 𝛿 is uniquely associated with the thunk 𝑡. This ensures that all BasicThunk and ThunkVal assertions for the thunk 𝑡 refer to the same ghost cell 𝛿.

The concept of a piggy bank has been presented already ( §3). There remains to explain how the parameters 𝑃 and 𝑄, which represent the pending and forced states of the piggy bank, are instantiated in Figure 8. In the pending state, the ghost cell 𝛿 is undecided; the physical cell 𝑡 contains the value UNEVALUATED 𝑓 ; and there exists a unique permission to invoke 𝑓 (). The cost of this invocation, nc, is not known, but the piggy bank is set up so that this cost must be fully paid for before the piggy bank can be forced. The apparent debit n of the piggy bank is also the apparent debit of the thunk. In the forced state, the ghost cell 𝛿 has been set to 𝑣, for some value 𝑣; the physical memory cell 𝑡 contains the value EVALUATED 𝑣; and the postcondition □ 𝜙 𝑣 is satisfied.

Theorem 4.1. The predicate BasicThunk satisfies the rule Thunk-Create in Figure 5, where Thunk is replaced with BasicThunk. Furthermore, it satisfies all of the rules in Figure 6, where the same replacement is made. Finally, the predicate ThunkVal satisfies the rules in Figure 7. 4.2.2 Proxy Thunks. Alas, basic thunks do not satisfy the consequence rule. The problem can be traced back to the piggy bank invariants, which fix the postcondition 𝜙 and the number of necessary credits nc. This forbids installing a new postcondition and a new number of necessary credits.

Fortunately, there is a simple way of working around this problem. The idea is to allocate a new piggy bank when the consequence rule is applied to an existing thunk 𝑡. If the existing thunk has an apparent debit of n 1 and if the update from 𝜙 to 𝜓 has a cost of n 2 , then the number of time credits that the new piggy bank aims to collect is set to n 1 + n 2 . Thus, the apparent debit of the new piggy bank is n 1 + n 2 . Once the new piggy bank has reached its aim, breaking it produces RecThunk In this subsection, for simplicity, we focus on one application of the consequence rule. We assume that we have a predicate Thunk that satisfies the rules of Figure 6. We refer to this set of rules as the "common thunk API". We construct a new predicate ProxyThunk, which also satisfies the common thunk API. Its definition appears in Figure 9. The "creation rule" for proxy thunks, also shown in Figure 9, is a consequence rule that expects a Thunk and produces a ProxyThunk. The term "proxy thunk" is meant to suggest that a proxy thunk is a ghost wrapper around a pre-existing thunk.

n 1 + n 2 ProxyThunk F 𝑡 n 𝑅 𝜓 ≜ ∃n 1 , n 2 , 𝜙, F 1 , 𝑇 . ⌜F 1 ⊎ ↑𝑇 ⊆ F ⌝ * Thunk F 1 𝑡 n 1 𝑅 𝜙 * PiggyBank (𝜆nc. ⌜nc = n 1 + n 2 ⌝ * isUpdate n 2 𝑅 𝜙 𝜓 ) (∃𝑣. ThunkVal 𝑡 𝑣 * □ 𝜓 𝑣) ThunkPayment 𝑇 n Proxy-Create F 1 ⊎ ↑𝑇 ⊆ F Thunk F 1 𝑡 n 1 𝑅 𝜙 - * isUpdate n 2 𝑅 𝜙 𝜓 ⇛ E ProxyThunk F 𝑡 (n 1 + n 2 ) 𝑅 𝜓
0 F 𝑡 n 𝑅 𝜙 ≜ BasicThunk F 𝑡 n 𝑅 𝜙 RecThunk (𝑑 + 1) F 𝑡 n 𝑅 𝜙 ≜ ProxyThunk [Thunk := RecThunk 𝑑] F 𝑡 n 𝑅 𝜙 Thunk F 𝑡 n 𝑅 𝜙 ≜ ∃𝑇 , 𝑑, F ′ . ⌜∀𝑑 ′ . 𝑑 < 𝑑 ′ ⇒ F ′ # ↑(𝑇 . 𝑑 ′ )⌝ * ⌜F ′ ⊆ ↑𝑇 ⊆ F ⌝ * RecThunk 𝑑 F ′ 𝑡 n 𝑅 𝜙
The main components in the definition of proxy thunks are the underlying thunk, whose apparent debit is n 1 , and the proxy thunk's piggy bank. The apparent debit n of the piggy bank is the apparent debit of the proxy thunk. The pending state of this piggy bank contains a one-shot ghost update from 𝜙 to 𝜓 , whose cost is n 2 . The equation nc = n 1 + n 2 records the fact that this piggy bank aims to collect enough credit to force the underlying thunk and apply this update. The forced state contains just a forced-thunk witness ThunkVal 𝑡 𝑣 together with the postcondition □ 𝜓 𝑣.

The side condition F 1 ⊎ ↑𝑇 ⊆ F guarantees that out of the token F , which the user supplies when forcing the proxy thunk, we can extract the tokens F 1 * ↑𝑇 , which are required in order to simultaneously break the proxy's piggy bank and force the underlying thunk.

Theorem 4.2. The predicate ProxyThunk satisfies the rule Proxy-Create in Figure 9. Furthermore, it satisfies all of the rules in Figure 6, where Thunk is replaced with ProxyThunk.

Thunks.

The construction of the previous subsection is heterogeneous and allows applying the consequence rule once: when applied to a thunk, it produces a proxy thunk. Fortunately, this construction is generic: it can be applied to an arbitrary predicate Thunk, provided this predicate is persistent and satisfies the common thunk API in Figure 6. Both BasicThunk and ProxyThunk meet these requirements. Thus, the construction can be iterated. We do so in Figure 10. The definition is conceptually straightforward. First, we inductively define a predicate RecThunk 𝑑, which layers 𝑑 proxy thunks on top of a base thunk. Then, we define the predicate Thunk via an existential quantification over 𝑑: that is, we say that a "thunk" is a basic thunk wrapped in an arbitrary number of proxy thunks. Two technical formulae involving masks record that (1) we have an infinite family of pairwise disjoint masks, namely ↑(𝑇 . 𝑑), where 𝑑 is an integer index; and (2) after 𝑑 levels of proxy thunks have been stacked above a basic thunk, the masks up to level 𝑑 have been used up, but the masks above level 𝑑 are still available for use.

HThunk-Create {$N * isAction 𝑓 n ( ℎ ) 𝜙 } create 𝑓 {𝜆𝑡 . HThunk ℎ 𝑡 n 𝜙 } HThunk-Inc-Height-Debit ℎ 1 ≤ ℎ 2 n 1 ≤ n 2 HThunk ℎ 1 𝑡 n 1 𝜙 - * HThunk ℎ 2 𝑡 n 2 𝜙 HThunk-Pay ↑ThunkPayment ⊆ E HThunk ℎ 𝑡 n 𝜙 * $𝑘 ⇛ E HThunk ℎ 𝑡 (n -𝑘) 𝜙 HThunk-Conseqence HThunk ℎ 𝑡 n 1 𝜙 - * (∀𝑣 . $n 2 - * □ 𝜙 𝑣 ⇛ ⊤ □ 𝜓 𝑣) ⇛ E HThunk ℎ 𝑡 (n 1 + n 2 ) 𝜓 HThunk-Force { HThunk ℎ 𝑡 0 𝜙 * $F * ℎ ′ * ⌜ℎ < ℎ ′ ⌝ } force 𝑡 {𝜆𝑣. □ 𝜙 𝑣 * ThunkVal 𝑡 𝑣 * ℎ ′ }
Theorem 4.3. The predicate Thunk satisfies all of the rules in Figures 5 and6.

A new piggy bank is created at two different times: when a thunk is first created, and when the consequence rule is applied to an existing thunk. Thus, an arbitrary number of piggy banks can be simultaneously associated with a single thunk, and can be simultaneously active. Fortunately, in our proofs, this global view is never needed.

HEIGHT-INDEXED THUNKS

The predicate Thunk is quite general but can be a little difficult to use. When a thunk is forced, one must separately supply the token F , which allows forcing the thunk itself, and the resource 𝑅, which allows the suspended computation to have certain effects. When one wishes to construct a thunk that forces one or more other thunks, the parameter 𝑅 must typically be instantiated with a token of the form F ′ where F and F ′ are disjoint. In short, we have set up a token-based discipline that forbids reentrant thunks. This is good, but this discipline can be heavy and confusing.

In order to address this difficulty once and for all and to save the end user some pain, we set up a simple system based on natural integer heights ℎ. On top of the predicate Thunk, we define a new predicate HThunk ℎ 𝑡 n 𝜙 where the two parameters F and 𝑅 are replaced with a single parameter ℎ. Our intent is to allow a thunk at height ℎ to force thunks at lower heights only. A thunk cannot force a thunk that lies at the same height as itself or higher. A thunk at height ℎ can construct or return a thunk at an arbitrary height: no constraint relates the parameters ℎ and 𝜙.

For simplicity, this API removes the ability for a suspended computation to have side effects other than forcing thunks: that is, the parameter 𝑅 disappears. It could be preserved if desired.

We omit the definition of the predicate HThunk, which is fairly administrative. Its reasoning rules appear in Figure 11. The affine token ℎ allows forcing thunks whose height is less than ℎ (HThunk-Force). When a thunk is created at height ℎ, the token that is passed to the suspended computation is ℎ (HThunk-Create). Thus, the new thunk can force thunks at lower heights only. A height is not a creation time: indeed, a thunk at height 0 can be created after or created by a thunk at height 1. Instead, a height represents the length of a dependency chain: a thunk at height 2 is a thunk that can force a thunk that can force a thunk. Heights can be safely over-approximated: this is stated by HThunk-Inc-Height-Debit. In a token, ℎ can be instantiated with ∞. The token ∞ can force thunks of arbitrary height. It appears in the API of the banker's queue (Figure 18).

STREAMS

A stream is a list whose elements are computed on demand and memoized. In lazy programming languages, such as Haskell, this data structure is referred to simply as a "list". In a strict programming language, such as OCaml, lists and streams are distinct (albeit closely related) data structures. The definition of streams as an algebraic data type appears in the first two lines of Figure 1. In short, a stream 𝑠 is a thunk, which, once forced, produces a cell; and a cell is either the value Nil or a value of the form Cons(x, 𝑠 ′ ), where x is an element and 𝑠 ′ is again a stream. A stream can be thought of as a chain of thunks, where each thunk produces the next thunk in the chain.

In the following, we define a predicate Stream ℎ 𝑠 ds xs, which describes a stream ( §6.1); we establish several reasoning rules that this predicate satisfies ( §6.2); and we establish specifications for a few common operations on streams ( §6.3). We do not verify a full-fledged stream library; we verify only the operations needed by the banker's queue, which are shown in Figure 1.

The Predicate Stream

In the predicate Stream ℎ 𝑠 ds xs, the parameter ℎ plays the same role as in the predicate HThunk. It is an integer height ℎ, and it indicates that the token ℎ is required in order to force every thunk in the stream. The parameter 𝑠 is the stream itself; it is the location in memory of the thunk that represents the head of the stream. The parameter xs is the sequence of the elements of the stream. It predicts the shape of the value produced by each thunk in the stream, where a shape is either Nil or Cons(x, _). The parameter ds is the sequence of debits associated with each thunk in the stream. It tells how much remains to be paid in order to force each thunk. It is worth noting that xs and ds predict the value and apparent cost of each thunk in the stream possibly before this thunk is even constructed in memory.

The definitions of the predicates Stream and StreamCell appear in Figure 12. They are mutually inductive. They are straightforward, so we do not paraphrase them. Let us just point out that they rely on height-indexed thunks: each section in the paper builds on the previous section. Several restrictions are intentionally built into our definition of the predicate Stream. First, by making the parameters ds and xs finite lists, we restrict our attention to finite streams. Second, by parameterizing Stream with xs, we restrict our attention to deterministic streams, whose elements can be predicted ahead of time. Third, by constructing streams on top of height-indexed thunks, which do not allow a thunk to have side effects (beside forcing other thunks), we restrict our attention to streams without side effects. Modeling potentially infinite streams, non-deterministic streams, or effectful streams is left to future work.

ℎ 1 ≤ ℎ 2 Stream ℎ 1 𝑠 ds xs - * Stream ℎ 2 𝑠 ds xs Stream-Forward-Debit ↑ThunkPayment ⊆ E (𝑚) ds 1 ≤ ds 2 (𝑛) Stream ℎ 𝑠 ds 1 xs * $𝑚 ⇛ E Stream ℎ 𝑠 ds 2 xs Stream-Force { Stream ℎ 𝑠 (0 :: ds) xs * $F ′ * ℎ ′ * ⌜ℎ < ℎ ′ ⌝ } force 𝑠 { 𝜆𝑐. StreamCell ℎ 𝑐 ds xs * ThunkVal 𝑠 𝑐 * ℎ ′ } Stream-Create {$N ′ * isCellAction ℎ d 𝑒 ds xs} create (𝜆().𝑒) {𝜆𝑠. Stream ℎ 𝑠 (d :: ds) xs}
To allow possibly infinite streams, one should define Stream using guarded recursion [Jung et al. 2018, §5.6], which is possible because HThunk is contractive-a fact that we have proved. Then, ds and xs could be possibly infinite lists. We expect this approach to work, but have not investigated it.

Reasoning Rules for Streams

Our reasoning rules for streams appear in Figure 13. Most of them are reformulations of the corresponding rules for height-indexed thunks (Figure 11), so we do not explain them again. Stream-Force requires the head thunk to have zero debits. Stream-Create relies on the auxiliary predicate isCellAction6 in the same way that Thunk-Create and HThunk-Create rely on isAction.

The most notable rule in Figure 13 is Stream-Forward-Debit. This rule allows managing a stream's debit in several ways. It allows paying (that is, consuming a number of time credits) so as to decrease the cost of a thunk, which can be either the head thunk or a deeper thunk. Furthermore, it allows moving debits from the right towards the left in the list ds. In other words, it allows transferring some of the debit of a faraway thunk to a thunk that lies nearer in the future. This is intuitively sound because this implies that one must pay earlier. Technically, the proof of soundness of Stream-Forward-Debit relies on the consequence rule HThunk-Conseqence.

Stream-Forward-Debit involves the debit subsumption judgement (𝑚) ds 1 ≤ ds 2 (𝑛), whose intuitive meaning is as follows: provided one pays 𝑚 time credits now, it is safe to transform the sequence ds 1 into the sequence ds 2 , and this results in 𝑛 leftover time credits in the future, after the thunks described by the lists ds 1 and ds 2 have been forced.

Operations on Streams

There remains to present the specifications of the operations on streams whose code appears in Figure 1. For the sake of brevity, we omit the specifications of the tiny functions nil and uncons: they are similar to Stream-Create and Stream-Force. We also omit the specification of revl_append.

We present the specifications of the last two functions, revl and append, in Figure 16.

Stream-Revl states that revl transforms an immutable list 𝑙 whose elements form the sequence xs into a stream 𝑠 whose elements form the sequence rev xs. (The pure assertion List 𝑙 xs indicates that the value 𝑙 is an immutable list whose elements form the sequence xs.) If the sequence xs has length 𝑛, then this stream involves 𝑛 + 1 thunks. The postcondition in Stream-Revl indicates that the first thunk is expensive, while the remaining 𝑛 thunks are cheap: the first thunk carries debit R𝑛 (where R is a constant), while every other thunk carries debit zero. The first thunk is expensive because, when it is forced, revl_append 𝑙 Nil is invoked (line 23 in Figure 1). This function call requires linear time because it eagerly traverses the list 𝑙 and immediately constructs the remaining 𝑛 thunks. These thunks are cheap because they immediately return a pre-existing value. The function call revl 𝑙 itself has constant time complexity: Stream-Revl requires a constant amount R ′ of time credits.

Stream-Append states that append 𝑠 1 𝑠 2 has constant complexity: it consumes P time credits. If the streams 𝑠 1 and 𝑠 2 represent the sequences of elements xs 1 and xs 2 then the stream returned by append represents the sequence xs 1 ++ xs 2 . More crucially, if the sequences of debits associated with 𝑠 1 and 𝑠 2 are ds 1 and ds 2 , then the sequence of debits associated with this stream is ds 1 ⊲⊳ ds 2 . The debit join operation ⊲⊳ is defined as follows, where A and B are integer constants:

(ds 1 ++ [d 1 ]) ⊲⊳ (d 2 :: ds 2 ) ≜ map (A + _) ds 1 ++ (A + d 1 + B + d 2 ) :: ds 2
If ds 1 has length 𝑛 1 +1 and ds 2 has length 1+𝑛 2 then ds 1 ⊲⊳ds 2 has length 𝑛 1 +1+𝑛 2 . The computation of ds 1 ⊲⊳ ds 2 can be informally described as follows: first, add A to every element of ds 1 ; then, meld the two sequences, by fusing (adding) the last element of the first sequence with the first element of the second sequence; finally, add B to this fused element. This specification reflects the fact that (1) the overall cost of a stream concatenation operation is A(𝑛 1 + 1) + B, where 𝑛 1 is the number of elements of the first stream; and (2) the cost of concatenation is distributed across the first 𝑛 1 + 1 thunks of the result stream: each of the first 𝑛 1 thunks bears a cost of A; the next thunk bears a cost of A + B; and the remaining 𝑛 2 thunks bear no cost.

Stream-Append states that if the streams 𝑠 1 and 𝑠 2 have height ℎ then the stream returned by append has height ℎ + 1. This reflects the fact that a thunk in this new stream can depend on (force) a thunk in the stream 𝑠 1 or in the stream 𝑠 2 . Such precise height information is necessary during the inductive proof of append, and can be necessary also in some usage scenarios of append. In the banker's queue ( §7), it is not needed: there, we work with streams of unknown height.

Banker-Persistent persistent(BQueue q xs) Banker-Empty {$E} empty () {𝜆q. BQueue q []} Banker-Snoc {$S * BQueue q xs} snoc q x {𝜆q ′ . BQueue q ′ (xs ++ [x])} Banker-Extract {$X * BQueue q (x :: xs) * ∞ } extract q {𝜆(x ′ , q ′ ). ⌜x ′ = x⌝ * BQueue q ′ xs * ∞ }

Banker-Check

{$H * BQueueRaw q fs rs * ⌜|rs| ≤ |fs| + 1⌝} check q {𝜆q ′ . BQueue q ′ (fs ++ rev rs)} necessary because HeapLang has shared-memory concurrency and our implementation of thunks is (by design) not thread-safe. The token discipline prevents two threads from racing on a thunk.

Implementation of the Banker's Queue. The implementation appears in Figure 17. A queue is a record of four fields: a "front" stream of elements f, a "rear" list of elements r, and their respective lengths, lenf and lenr. Elements are inserted into the queue by prepending to the rear list, and are extracted from the queue by extracting from the front stream. As a result, the elements of the rear list are stored in logically reverse order: if the elements of the front stream form the sequence fs and if the elements of the rear list form the sequence rs, then the sequence of elements contained in the queue is fs ++ rev rs. The inequality |rs| ≤ |fs| is maintained: the rear list never contains more elements than the front stream.

When the length of the rear list exceeds the length of the front stream, the queue must be rebalanced. This is done by the auxiliary function check. Rebalancing involves reversing the rear list, converting it into a stream, and appending this stream at the end of the front stream. The reversal and conversion into a stream are performed by revl. According to Stream-Revl (Figure 16), an invocation of revl has constant time complexity, but returns a stream whose first thunk has linear cost. Thus, rebalancing itself is cheap, but constructs an expensive thunk, which (after rebalancing) appears in the middle of the front stream. Okasaki's insight is that the cost of this expensive thunk can be distributed onto the linear number of thunks that appear in front of it. This translates to a constant amount of extra debit per thunk, which is acceptable. 22. Left: after snoc has inserted an element in the rear list. Right: before extract removes an element of the front stream. Highlighted in red: the thunk whose debit must be paid off.

rs fs K K K • • • 0 0 • • • rs fs K K K • • • 0 0 • • • Fig.

fs rs

0 0 • • • (𝐴) The queue is unbalanced. |fs| = 𝑛 ∧ |rs| = 𝑛 + 1 rs fs A A • • • K + R𝑛 0 0 • • • (𝐵)
Reverse and append the rear list to the front stream.

rs fs

A + R A + R • • • K 0 0 • • • (𝐶)
Redistribute debits by adding R to the first 𝑛 debits. The Predicate BQueue. Figure 19 presents the definition of BQueue q xs. It is constructed by combining the assertions xs = fs ++ rev rs and |rs| ≤ |fs|, which we have explained already, with a lower-level assertion, BQueueRaw q fs rs. This assertion states that q is a 4-tuple, requires the two length fields to contain the integer values |fs| and |rs|, and uses the predicates Stream and List to describe the front stream and rear list. The most noteworthy aspect is that the debit sequence of the front stream is fully determined: it is bqueueDebits |fs| |rs|. The definition of bqueueDebits states that the first nfnr thunks in the front stream carry debit K, where K ≜ A + B + R, whereas the remaining thunks carry debit zero. This is illustrated in Figure 20. In our illustrations (Figures 20,22,and 23), the debit associated with the very last thunk of the front stream, which is always 0, is never shown. Specification of check. The specification of the function check, which rebalances a queue, appears in Figure 21. It states that check accepts an imbalanced queue and returns a balanced queue. Because check is called by every operation, check can expect that the length of the rear list exceeds the length of the front stream by at most one.

Verifying snoc and extract. snoc causes the rear list to grow by one element. To preserve the debit invariant (Figure 20), one must pay for the last thunk in the front stream whose debit is nonzero. This is illustrated in Figure 22 (left). This is done by applying Stream-Forward-Debit. This requires proving the subsumption judgement (K) K 𝑛 ++ K :: 0 𝑚 ≤ K 𝑛 ++ 0 :: 0 𝑚 (0), which follows from Sub-Append, Sub-Cons, and Sub-Refl.

The function extract forces and discards the first thunk of the front stream, as pictured in Figure 22 (right). Thus, it is necessary to first pay for this thunk. This can be done by applying Stream-Forward-Debit and proving the subsumption judgement (K) K :: K 𝑛 ++ 0 𝑚 ≤ 0 :: K 𝑛 ++ 0 𝑚 (0), which follows from Sub-Cons and Sub-Refl. This is a trivial instance of Stream-Forward-Debit, that is, an ordinary payment as opposed to a deep payment.

Verifying check. Because check empties the rear list, it is clear that it restores the invariant |rs| ≤ |fs|. How check restores the debit invariant is more subtle. Let us consider an unbalanced queue whose front and rear sequences of elements are fs and rs. Let us write 𝑛 for |fs|, so that we have |rs| = 𝑛 + 1. According to the debit invariant, every thunk in the front stream has debit zero. This situation is represented in step (A) of Figure 23. The proof proceeds as follows:

(1) According to Stream-Revl and Stream-Append, reversing the rear list and appending the result to the front stream produces a stream whose debits are 0 𝑛+1 ⊲⊳ R(𝑛 + 1) :: 0 𝑛+1 . This debit sequence has length 2𝑛 + 2: this is consistent with the fact that the new front stream has 2𝑛 + 1 elements. By definition of the debit join operator ⊲⊳, this is: A 𝑛 ++ (A + B + R(𝑛 + 1)) :: 0 𝑛+1 . Because K is A + B + R, this sequence of debits is also A 𝑛 ++ (K + R𝑛) :: 0 𝑛+1 . It is depicted in step (B) of Figure 23. (2) Then, the key step of the proof is to distribute the expensive debit K + R𝑛 onto earlier debits.

We increase each of the first 𝑛 debits by R, so as to be allowed to reduce the expensive debit from K + R𝑛 down to K. The result is illustrated in step (C) of Figure 23. This redistribution of debit is permitted by Stream-Forward-Debit provided we establish the subsumption judgement (0) A 𝑛 ++ (K + R𝑛) :: 0 𝑛+1 ≤ (A + R) 𝑛 ++ K :: 0 𝑛+1 (0). This judgement follows from Sub-Append, Sub-Repeat, Sub-Cons, and Sub-Refl. (3) Because A + R ≤ K holds, we can now over-approximate every debit by K, except the last one, which must remain zero. We exploit the subsumption judgement (0) (A + R) 𝑛 ++ K :: 0 𝑛+1 ≤ K 2𝑛+1 ++ [0] (0). The debit sequence K 2𝑛+1 ++ [0] is equal to bqueueDebits (2𝑛 + 1) 0, which is the expected sequence of debits for a balanced queue whose front stream has length 2𝑛 + 1 and whose rear list is empty.

THE PHYSICIST'S QUEUE; IMPLICIT QUEUES

We verify two additional persistent data structures found in Okasaki's book. Both exploit thunks to achieve amortized constant time complexity. The physicist's queue [Okasaki 1999, §6.4.2] is similar to the banker's queue insofar as it involves front and rear lists of elements and rebalances them when necessary. However, a physicist's queue involves a single thunk, which lies at the root of the data structure, whereas the banker's queue involves nested thunks (a stream). For this reason, the analysis of the physicist's queue is easier: in particular, the rule Thunk-Conseqence is not needed. Okasaki analyzes the physicist's queue using the physicist's method, which associates a "potential function" with the data structure. We use the banker's method, which associates a debit with each thunk. Because this data structure involves only one thunk, the two methods essentially coincide in this case. The physicist's queue does involve a thunk that forces another thunk. Our height-indexed thunks ( §5) allow this.

Implicit queues [Okasaki 1999, §11.1] are a more complex data structure. They rely on thunks directly, and do not use streams. Their implementation involves recursive slowdown: the queue is structured in layers, where each layer stores twice as many elements as the previous layer. When one layer is at hand, accessing the next layer requires forcing a thunk. Therefore, an implicit queue has the same general structure as a stream: it involves a sequence of nested thunks. Our implementation and our debit invariant closely follow Okasaki's. They match Danielsson's as well [Danielsson 2008, §8.1], although Danielsson chooses a slightly different way of defining the data structure. To carry out the complexity analysis, we make full use of the height-indexed thunk API ( §5). Here, the use of Thunk-Conseqence is crucial. [START_REF] Okasaki | Purely Functional Data Structures[END_REF] invents the debit-based approach to the amortized time complexity analysis of lazy, purely functional data structures. He describes this approach in a clear but informal way. [START_REF] Anders | Lightweight Semiformal Time Complexity Analysis for Purely Functional Data Structures[END_REF] uses Agda to define a formal complexity analysis, to prove its soundness, and to verify some of Okasaki's data structures, including the banker's queue and implicit queues. Deep payment is used in the verification of the banker's queue, but is not supported in the proof of soundness of the analysis. Like Okasaki's informal discipline, Danielsson's system is purely based on debits and does not include a subsystem that aims to forbid reentrancy, such as our "height" discipline ( §5). It could be that he does not need such a subsystem because his type system guarantees termination. However, in the presence of the fixed point combinator fix, it is not clear whether this is true. Danielsson does not prove that every well-typed program terminates. He establishes a weak time complexity guarantee: if a program has type 𝜏 and if this program reaches a weak head normal form in 𝑛 steps then 𝑛 ≤ time(𝜏) holds. Thus, the possibility that the program diverges remains open. [START_REF] Atkey | Amortised Resource Analysis with Separation Logic[END_REF] suggests extending separation logic with time credits and using it to carry out amortized time complexity analyses. [START_REF] Pilkiewicz | The essence of monotonic state[END_REF] independently introduce the concept of time credit in an affine type system and suggest that time credits, in combination with monotonic ghost state and a form of invariant, can be used to reconstruct Okasaki's debit-based analysis of thunks. Their work is however informal. [START_REF] Mével | Time credits and time receipts in Iris[END_REF] carry out a similar programme in the formal setting of Iris $ , which they construct on top of Iris. They do not verify any of Okasaki's algorithms. In fact, their reasoning rules exhibit three main shortcomings, which limit their expressive power.

RELATED WORK

First, their specification of force [Mével et al. 2019, Figure 6] requires and returns an affine token that is not transmitted to the suspended computation: this prevents a thunk from forcing a thunk. They acknowledge this restriction and note that they have implemented "a more flexible discipline" based on more fine-grained tokens, but do not describe it. We develop such a discipline. In our low-level thunk API, the rule Thunk-Force requires and returns an affine token F and transmits a resource 𝑅 to the suspended computation. Instantiating 𝑅 with suitable tokens allows a thunk to force other thunks. Our high-level thunk API, which is novel, takes advantage of this. There, thunks and tokens are indexed with heights, and the token ℎ allows forcing thunks whose height is less than ℎ: this is visible in the rule HThunk-Force.

Second, Mével et al.'s construction of the predicate isThunk [Mével et al. 2019, §7.4] does not validate deep payment, which allows shifting debits from the far future to the near future. A 2-debit thunk whose result is a 1-debit thunk can be viewed, via a deep payment, as a 3-debit thunk whose result is a 0-debit thunk. Deep payment changes both the debit 𝑛 and the postcondition 𝜙 of a thunk: in the previous example, a thunk whose debit is 2 and whose promise (postcondition) is to return a 1-debit thunk becomes a thunk whose debit is 3 and whose promise is to return a 0-debit thunk. Yet, in Mével et al.'s construction, the parameters 𝑛 and 𝜙 appear inside an Iris non-atomic invariant. This forbids any change in 𝑛 or 𝜙 and precludes deep payment. Coming up with an alternative construction that removes this restriction is not easy: one must keep in mind that, so that sharing thunks is permitted, the predicate Thunk must be persistent. We justify deep payment, which we obtain as a combination of the rules Thunk-Conseqence and Thunk-Pay. Our key insight is to separate thunks, a data structure that exists at runtime, and piggy banks, a ghost data structure. In our construction, an application of the rule Thunk-Conseqence creates a new piggy bank for an existing thunk, so, in the end, many piggy banks can be associated with a single thunk. This may seem mind-boggling; fortunately, by virtue of working in Separation Logic, we reason in a local manner, and never need to think about more than one piggy bank at a time.

Third, in Mével et al.'s construction, both force and pay require an affine token. This forbids not only a thunk that forces another thunk, but also a thunk that performs a payment on another thunk. This is inconvenient, potentially problematic, and seems intuitively unnecessary, because payment is a ghost operation. Our rule Thunk-Pay does not require any token.

Inspired by Danielsson's work, McCarthy et al. [2016] define in Coq a monad that keeps track of costs. They place emphasis on obtaining clean OCaml code via Coq's extraction facility. They use the pure, call-by-value fragment of OCaml; no thunks are involved. Also inspired by Danielsson, Handley et al. [2020] develop a semi-automated system, based on Liquid Haskell, to verify the time complexity of Haskell programs. A pay combinator is supported; deep payment is not. The soundness of the system is stated but not formally verified. [START_REF] Madhavan | Contract-based resource verification for higher-order functions with memoization[END_REF] present a system that infers and verifies resource bounds for higher-order functional programs that involve thunks or memoization tables. [START_REF] Nipkow | Amortized Complexity Verified[END_REF] verify the amortized complexity of several functional data structures in Isabelle/HOL. These data structures do not involve thunks, and the analysis is credit-based, not debit-based. [START_REF] Hackett | Call-by-need is clairvoyant call-by-value[END_REF] propose both an operational semantics and a denotational cost semantics for lazy (call-by-need) programs, based on the idea of clairvoyant evaluation, where the mutable state inherent in thunks is replaced with nondeterminism. Inspired by this idea, [START_REF] Li | Reasoning about the garden of forking paths[END_REF] define the clairvoyance monad, a model of laziness that is shallowly embedded inside Coq, and develop two program logics of over-and under-approximation to reason about the cost of lazy programs. They do not reason in terms of debits.

CONCLUSION

We have proposed a set of debit-based reasoning rules for thunks and proved their soundness in a foundational, machine-checked setting, namely separation logic with time credits. We have checked that our rules suffice to verify three of Okasaki's data structures, namely the banker's queue, the physicist's queue, and implicit queues. We believe that our rules capture all of Okasaki's informal reasoning principles and that they should suffice to verify the rest of Okasaki's book. However, this can be ascertained only by actually performing this work!

We view this result as an enlightening and useful bridge between the worlds of purely functional programming and imperative programming.

From a technical point of view, ghost piggy banks are an original concept. Our modular construction of thunks on top of piggy banks, in several steps, is also original. A monolithic definition could be given, but we believe that it would be much more difficult to understand.

Our proofs are available online [START_REF] Pottier | Thunks and Debits in Separation Logic with Time Credits: Coq Formalization[END_REF]. One area where engineering work is needed is in the quality of the implementation of Iris $ [START_REF] Mével | Time credits and time receipts in Iris[END_REF]]. The fact that Iris $ is implemented on top of Iris via a program transformation, the tick translation, should be an implementation detail; yet it is currently apparent. We find that this creates unnecessary difficulty for the end user.

Fig. 3 .

 3 Fig. 3. Piggy Banks: Internal Definition

  Fig. 4. Thunks: OCaml Code

Fig. 5 .

 5 Fig. 5. Thunks: Creation Rule and Consequence Rule

Fig

  Fig. 6. Thunks: Common Reasoning Rules

Fig. 9 .

 9 Fig. 9. Proxy Thunks: Definition and Creation Rule

Fig. 10 .

 10 Fig. 10. Thunks: Definition

  ℎ 𝑡 n 𝜙)

Fig. 11 .

 11 Fig. 11. Height-Indexed Thunks: Reasoning Rules

  Stream ℎ 𝑠 [] xs ≜ False Stream ℎ 𝑠 (d :: ds) xs ≜ HThunk ℎ 𝑠 d (𝜆𝑐.StreamCell ℎ 𝑐 ds xs) StreamCell ℎ 𝑐 ds [] ≜ ⌜𝑐 = Nil⌝ * ⌜ds = []⌝ StreamCell ℎ 𝑐 ds (x :: xs) ≜ ∃𝑠. ⌜𝑐 = Cons(x, 𝑠)⌝ * Stream ℎ 𝑠 ds xs

Fig. 12 .

 12 Fig. 12. Streams: Definition

  Because a stream of 𝑛 elements involves 𝑛 + 1 thunks, in an assertion Stream ℎ 𝑠 ds xs, one can informally 5 expect |ds| = |xs| + 1. In StreamCell ℎ 𝑐 ds xs, one can informally expect |ds| = |xs|.

Fig. 13 .

 13 Fig. 13. Streams: Reasoning Rules

  Fig. 16. Streams: Specifications of revl and append

Fig. 18 .

 18 Fig. 18. Banker's Queue: Public Interface

Fig. 21 .

 21 Fig. 21. Banker's Queue: Specification of check

Fig. 23 .

 23 Fig. 23. Rebalancing. In red: the costly thunk whose debit must be distributed among the front thunks.

  BasicThunk F 𝑡 n 𝑅 𝜙 ≜ ∃𝛿, 𝑇 . ⌜↑𝑇 ⊆ F ⌝ * 𝑡 ⇝ 𝛿 * PiggyBank (𝜆nc. ∃𝑓 . 𝛿 ↦ → ? * 𝑡 ↦ → UNEVALUATED 𝑓 * isAction 𝑓 nc 𝑅 𝜙) -pending state (∃𝑣 . 𝛿 ↦ → 𝑣 * 𝑡 ↦ → EVALUATED 𝑣 * □ 𝜙 𝑣)

	-forced state
	ThunkPayment 𝑇 n
	ThunkVal 𝑡 𝑣 ≜
	∃𝛿. 𝑡 ⇝ 𝛿 * 𝛿 ↦ → 𝑣
	Fig. 8. Basic Thunks and Forced-Thunk Witnesses: Definitions

4.2.1 Basic Thunks. The definition of the predicate BasicThunk appears in Figure 8. Although this definition may at first sight seem somewhat cryptic, it is actually fairly straightforward. It involves two main ingredients: a ghost cell 𝛿 and a piggy bank.

Proc. ACM Program. Lang., Vol. 8, No. POPL, Article 50. Publication date: January 2024.

HeapLang does not have explicit memory deallocation. We assume that a garbage collector reclaims unreachable objects. Proc. ACM Program. Lang., Vol. 8, No. POPL, Article 50. Publication date: January

A namespace 𝑁 ,𝑇 is a string. A mask E, F is a set of such strings. If 𝑇 is a namespace, then its upward closure ↑𝑇 , a mask, is the set of all strings that admit 𝑇 as a prefix. The full mask ⊤ is the set of all strings. We write E 1 # E 2 when the masks E 1 and E 2 are disjoint. We say that two namespaces 𝑇 1 and 𝑇 2 are disjoint when ↑𝑇 1 # ↑𝑇 2 holds.Proc. ACM Program. Lang., Vol. 8, No. POPL, Article 50. Publication date: January 2024.

Instead of requiring the postcondition to be in the syntactic form □ 𝜙, we could equivalently allow an arbitrary persistent postcondition. The postcondition of a thunk must be persistent because a thunk can be forced arbitrarily many times yet always returns the same value.Proc. ACM Program. Lang., Vol. 8, No. POPL, Article 50. Publication date: January 2024.

We write isUpdate n 𝑅 𝜙 𝜓 for the assertion ∀𝑣. (𝑅 * $n * □ 𝜙 𝑣) ⇛ ⊤ (𝑅 * □ 𝜓 𝑣). This update is affine; it can be used only once. It consumes n time credits, requires □ 𝜙 𝑣, and establishes □ 𝜓 𝑣. The resource 𝑅 is required, but not consumed. The full mask ⊤ allows this update to access all atomic invariants. In particular, Thunk-Pay can be exploited.Proc. ACM Program. Lang., Vol. 8, No. POPL, Article

Publication date: January 2024.

Technically, Stream ℎ 𝑠 ds xs ⊢ |ds| = |xs| + 1 does not hold. A proof attempt fails, because the postcondition of a thunk does not hold until this thunk has been forced. One could strengthen the definition of Stream so that this entailment holds, but we have not felt the need to do so. The weaker entailment Stream ℎ 𝑠 ds xs ⊢ |ds| > 0 does hold and has been sufficient. Proc. ACM Program. Lang., Vol. 8, No. POPL, Article 50. Publication date: January 2024.

We write isCellAction ℎ d 𝑒 ds xs for once { ℎ * $d } 𝑒 {𝜆𝑐. StreamCell ℎ 𝑐 ds xs * ℎ }. This assertion is a permission to execute the expression 𝑒 at most once. It indicates that 𝑒 may consume d time credits and must return a stream cell 𝑐 such that StreamCell ℎ 𝑐 ds xs holds. The resource ℎ may be used and must be preserved. Proc. ACM Program. Lang., Vol. 8, No. POPL, Article 50. Publication date: January 2024.

Received 2023-07-11; accepted 2023-11-07 Proc. ACM Program. Lang., Vol. 8, No. POPL, Article 50. Publication date: January 2024.

ACKNOWLEDGMENT

We thank the anonymous reviewers for their remarks and suggestions. In particular, a suggestion by one of the reviewers led to a simplified definition of piggy banks, which involves a single invariant (Figure 3). Our earlier definition used two distinct invariants and was slightly more complex.

DATA AVAILABILITY STATEMENT

Our mechanized proofs are available in our repository [START_REF] Pottier | Thunks and Debits in Separation Logic with Time Credits: Coq Formalization[END_REF]] and as an artifact at https://zenodo.org/doi/10.5281/zenodo.8425002.

(𝑚 1 ) ds 1 ≤ ds 2 (𝑛 1 ) (𝑚 2 ) ds 2 ≤ ds 3 (𝑛 2 )

Fig. 15. Subsumption over Sequences of Debits: Properties

The presence of the parameter 𝑛 in this judgement may seem surprising, especially since the 𝑛 leftover credits are unused (wasted) by Stream-Forward-Debit. Still, this parameter is useful because it enables compositional proofs of subsumption; this is most visible in Sub-Append (Figure 15).

An inductive definition of the subsumption judgement appears in Figure 14. In Sub-Cons, it may be the case that d 1 is greater than d 2 . In this case, the premise d 1 ≤ 𝑚 + d 2 allows part of the 𝑚 time credits at hand to be spent on the first thunk, decreasing its apparent cost from d 1 to d 2 . It may also be the case that d 1 is less than or equal to d 2 . In that case, the apparent cost of the first thunk is increased, causing more than 𝑚 time credits to become available for spending on the thunks that follow. In either case, the number of credits that can be spent on the tail of the stream is (𝑚 + d 2 )d 1 , which is why this number appears in the second premise of Sub-Cons.

An alternative characterization of the subsumption judgement, which helps see why Stream-Forward-Debit is sound, is the following. Intuitively, for this rule to be sound, it must be the case that, by applying this rule, one promises to pay no less and to pay no later. Thus, for every index 𝑖, it must be the case that forcing the first 𝑖 thunks in the stream appears no less expensive after the rule is applied than it did before the rule was applied. The subsumption judgement indeed satisfies a property of this kind: it is expressed by the following lemma. Lemma 6.1 (Subsumption of Seqences of Debits, expressed in terms of Partial Sums). Suppose the lists ds 1 and ds 2 have the same length. Then the judgement ∃𝑛. (𝑚) ds 1 ≤ ds 2 (𝑛) is equivalent to ∀𝑖. (take 𝑖 ds 1 ) ≤ 𝑚 + (take 𝑖 ds 2 ).

The subsumption judgement enjoys a number of reasoning rules, which are presented in Figure 15. The judgement (𝑚) ds 1 ≤ ds 2 (𝑛) is covariant in 𝑚 and contravariant and 𝑛 (Sub-Variance). It is reflexive (Sub-Refl), transitive (Sub-Trans), and compatible with list concatenation (Sub-Append). Extra credit at the left end translates to extra credit at the right end (Sub-Add-Slack). Finally, increasing the apparent cost of the first 𝑛 thunks from d 1 to d 2 results in 𝑛 × (d 2d 1 ) extra credit at the right end (Sub-Repeat). As a more readable special case, an increase of 1 in the debit of the first 𝑛 thunks justifies a decrease of 𝑛 in the debit of the thunk that follows. In other words, an expensive thunk can be made to appear cheap provided it lies far enough away in the future. This reasoning rule plays a key role in Okasaki's amortized analysis of the banker's queue ( §7). if lenf >= lenr then q else { lenf = lenf + lenr; f = append f (revl r); lenr = 0; r = [] } let snoc q x = check { q with lenr = q.lenr + 1; r = x :: q.r } let extract q = let x, f = uncons q.f in x, check { q with f = f; lenf = q.lenf -1 } Fig. 17. The Banker's Queue: OCaml Code

THE BANKER'S QUEUE

The banker's queue [Okasaki 1999, §6.3.2] is a persistent FIFO queue whose main operations, snoc and extract, have constant amortized time complexity. In the following, we present a specification for the banker's queue, explain how the banker's queue is implemented, and verify that the code satisfies the specification. Because the implementation involves a stream, 7 we rely on the stream library presented in the previous section ( §6).

It is worth pointing out that we do not simply replicate Okasaki's analysis of the queue. Instead, we propose a simpler analysis, which is made possible by the powerful reasoning rule Stream-Forward-Debit. Instead of working with iterated sums of debits, as Okasaki does, we use a sequence of elementary proof steps that rely on the properties of debit subsumption (Figures 14 and15).

Interface and Specification of the Banker's Queue. Three functions make up the main entry points of the library: empty creates a new empty queue; snoc inserts an element at the rear end of a queue; extract extracts an element at the front end of a queue.

Every operation has constant amortized time complexity. This implies that every sequence of operations on queues has linear cost in the number of operations. This is true even if queues are used in a non-linear manner, that is, even if operations are applied not only to the newest version of a queue, but also to old versions.

Our formal specification of the banker's queue appears in Figure 18. The assertion BQueue q xs means that q is a queue whose elements form the sequence xs. This assertion is persistent. This means that queues can be shared and that the queue operations are not destructive: one may apply an operation to an old queue. The rules Banker-Empty, Banker-Snoc and Banker-Extract provide specifications for empty, snoc, and extract. Each of them requires a constant amount of time credits in its precondition (E, S and X respectively). The specification of extract requires and preserves the token ∞ , which allows forcing thunks of arbitrary height ( §5). Requiring this token is 7 In Okasaki's code, for uniformity, two streams are used. However, only the front stream actually must be a stream: as noted by [START_REF] Okasaki | Purely Functional Data Structures[END_REF] in a footnote on page 64, the rear stream can be a list. We use a rear list and verify that Okasaki's footnote is correct. We believe that there would be no difficulty in verifying Okasaki's version, where a rear stream is used. Every suspension in the rear stream would be fully paid for upon construction and would therefore have debit zero.