Support functional actions in the Quint testing framework #973
Replies: 8 comments 2 replies
-
I can see how staying at the functional layer may help. In principle, you should be able to use |
Beta Was this translation helpful? Give feedback.
-
As discussed with @andrey-kuprianov on Slack, his proposal addresses sequential computations, that is, in particular smart contracts. To what extent this also addresses distributed computations is still a bit unclear to me. I agree, distributed and concurrent computations have a sequential aspect (what a process or a thread does locally). In addition and a distributed aspect, that is global invariants and the communication semantics between two processes, e.g., actions at a process A enable actions at B (e.g., via sending packets). I think for the sequential part such testing operators would also be useful for distributed protocols, while for distributed aspects, we might still want the expressivity of actions and steps as we need to capture interleavings and communication that are inherent non-deterministic and cannot be captured in the functional layer. |
Beta Was this translation helpful? Give feedback.
-
The use caseTo make the discussion more concrete, here is the example use case. It is about the Duality Decentralized Exchange, and in particular the mechanics of Constant-price Liquidity Pools. At a high-level, the mechanics is simple, and can be explained in a few words:
Based on this simplified description we can already discern one of the core system properties it should guarantee wrt. LPs: the following Pool Profitability Property: For any liquidity provider LP, at any state of the system, and any liquidity pool P with the exchange rate
Ideally, I would like to easily express the above property (among many others) as a test in the Quint testing framework, and to use it both in the randomized simulation, as well as later as an invariant for proving it with the Apalache model checker. |
Beta Was this translation helpful? Give feedback.
-
The model dummy, and the need for ADTsTo make the discussion even more concrete, below is the dummy model, with all the actual logic stripped, but with a couple of functional actions module dex {
// Constants
pure val TOKENS = Set("A", "B", "C", "D")
pure val ADDRESSES = Set("Alice", "Bob", "Carol", "Dave")
pure val MAX_AMOUNT = 1000
pure val MAX_TICK = 100000
pure val TICKS = (-MAX_TICK).to(MAX_TICK)
pure val AMOUNTS = 0.to(MAX_AMOUNT)
// Basic types
type TickIndex = int
type Token = string
type Addr = string
type TradingPair = List[Token]
type PoolID = { tokens: TradingPair, tick: TickIndex, fee: TickIndex }
type AmountPair = List[int]
type Pool = { reserves: AmountPair, shares: int }
type TokenAccount = Token -> int
type ShareAccount = PoolID -> int
type Pools = PoolID -> Pool
type Coins = Addr -> TokenAccount
type Shares = Addr -> ShareAccount
type State = {
pools: Pools,
coins: Coins,
shares: Shares,
}
pure def initialState: State = {
pools: Map(), coins: Map(), shares: Map()
}
// Result of executing an operation on the state
type Result = { state: State, result: str }
pure def ok(s: State): Result = { state: s, result: "ok" }
pure def error(reason: str): Result = { state: initialState, result: reason }
pure def isOk(r: Result): bool = r.result == "ok"
pure def isError(r: Result): bool = not(r.isOk())
// Deposit token amounts into a pool
pure def deposit(s: State, sender: Addr, receiver: Addr,
tokens: TradingPair, tick: TickIndex, fee: TickIndex, tokenAmounts: AmountPair): Result =
ok(s)
// Withdraw shares from a pool
pure def withdraw(s: State, sender: Addr, receiver: Addr,
tokens: TradingPair, tick: TickIndex, fee: TickIndex, shares: int): Result =
ok(s)
pure def MESSAGES = Set("deposit", "withdraw")
// A join of all parameters from all actions
type Message = {
kind: string,
sender: Addr,
receiver: Addr,
tokens: TradingPair,
tick: TickIndex,
fee: TickIndex,
amounts: AmountPair,
shares: int
}
def nondetMessage(): Message =
nondet msg = {
kind: oneOf(MESSAGES),
sender: oneOf(ADDRESSES),
receiver: oneOf(ADDRESSES),
tokens: [oneOf(TOKENS), oneOf(TOKENS)],
tick: oneOf(TICKS),
fee: oneOf(TICKS),
amounts: [oneOf(AMOUNTS), oneOf(AMOUNTS)],
shares: oneOf(AMOUNTS)
}
msg
// State variables
var state: State
var history: List[(str, Message)]
action init: bool = all {
state' = initialState,
history' = []
}
action step: bool = {
val msg = nondetMessage
val res =
if(msg.kind == "deposit")
deposit(state, msg.sender, msg.receiver,
msg.tokens, msg.tick, msg.fee, msg.amounts)
else if(msg.kind == "withdraw")
withdraw(state, msg.sender, msg.receiver,
msg.tokens, msg.tick, msg.fee, msg.shares)
else error("Wrong message type")
all {
history' = history.append((res.result, msg)),
state' =
if(res.result == "ok") res.state
else state
}
}
} // Module dex One thing immediately noticeable is how ADTs would simplify this. Currently type Message = MsgDeposit | MsgWithdraw
pure def deposit(s: State, m: MsgDeposit): Result
pure def withdraw(s: State, m: MsgWithdraw): Result And of course |
Beta Was this translation helpful? Give feedback.
-
The need to reject invalid executionsWhen describing a test scenario, it is often the case that we are interested only in a subset of the executions. E.g. in the above use case:
Please note that this process of rejecting/ignoring/filtering out of invalid executions is different from assertions:
|
Beta Was this translation helpful? Give feedback.
-
The need for implicit non-determinismIn the current testing framework, the non-determinism needs to be introduced explicitly, as it's done in this ERC-20 test. The problems with such an explicit approach:
The desired solutionWhat I would like to have is effectively the opposite of the list above:
|
Beta Was this translation helpful? Give feedback.
-
The need to relate distant execution stepsIn many cases, tests need to relate several steps in the execution. E.g., in the use case we need to relate steps 1 and 3:
Currently, there is no way to access something that happened previously in the trace, except for using explicit non-deterministic input for every parameter that needs to be related between different steps in the execution. The desired solutionHere my head explodes a bit because of so many possible alternatives 😄 Ideally, I would like not to explicitly introduce named values for steps that need to be related, i.e. to have the ability to relate to any value at any previous step in the execution. The non-exhaustive list of alternatives of how this could be achieved:
I am sure there are other, nicer alternatives, of which I have not thought yet. |
Beta Was this translation helpful? Give feedback.
-
The prototype APII have tried to come up with an API that would satisfy the above wishlist; no claims of completeness, correctness, of fitness for a particular purpose is made. Here is the Pool Profitability Property from the use case, expressed in the prototype API: def testDepositWithdraw =
nondet user = oneOf(ADDRESSES)
Init(initialState)
.ThenAny((s, m) => m.now.sender != user)
.ThenOk("deposit", (s, m) => s.now.deposit(m.now))
.Require((s, m) => m.now.sender == user)
.ThenAny((s, m) => m.now.sender != user or m.now.tokens != m.at("deposit").tokens)
.Then("withdraw", (s, m) => (s, m) => s.now.withdraw(m.now))
.Require((s, m) => m.now == {
shares: s.after("deposit").shares.get(user),
...m.at("deposit")})
.AssertOk()
.ThenAssert((s, m) => sum(s.now.coins.get(user)) >= 0) The high-level ideaThe test in this API describes declaratively the steps the execution should contain, as well as what is expected from that execution. There can be several kinds of steps:
Steps that have Given such a declarative test representation, a test executor, provided the additional generator for non-deterministic messages, could build a computational tree (e.g. up to the given depth, or the total number of steps in the tree), and explore only the valid branches, while looking for assertion violations. States and messagesAlmost each step of the test is a lambda function, receiving two parameters (
Each of states (
Step-by-step explanation
In order not to complicate the discussion, the As explained above, this proposed API is a very early prototype, and for sure doesn't fit nicely into the present Quint framework. Please regard it only as something to spark the discussion, in the hope that together we will be able to arrive a much better one, which integrates nicely into Quint. Looking forward to your thoughts! |
Beta Was this translation helpful? Give feedback.
-
This is intended as a discussion starter about possible improvements to the current Quint testing framework. The trigger for that was the particular problems that I've encountered while writing tests for a model of a blockchain protocol. I will be very grateful to both the Quint developers, as well as to the wider Quint community for their contributions to this discussion, in the hope that together we will be able to arrive to a solution satisfying many Quint users, while simultaneously incorporated nicely into the general Quint language framework.
Stateful actions
In the current Quint, actions are stateful: an action is supposed to modify state variables, which are received implicitly, and to return a bool. An example of such Quint model can be found in Lesson 3 - Basic anatomy of a protocol in Quint.
This approach, while being well-suited for small examples, quickly shows its limitations when applied to production systems:
unchanged
clauses (x' = x
) for all unchanged variables.Functional actions
Purely functional actions receive an explicit state as an argument, and return an explicit result, which contains either a new, updated state, or an error. This is a well known concept in purely functional languages such as Haskell, or in the modern mixed-semantics languages like Rust. An example of Quint specs that follow this approach are e.g.:
The advantages of functional actions are the the flipped drawbacks of the stateful ones:
r.with(...)
, or the more recent{ f1: e1, fN: eN, ...r }
)The Problem
It is possible to write the functional actions themselves in Quint in a nice and a concise way. On the other hand, the current Quint testing framework (as outlined e.g. in the Language Summary: Runs) rests on such operators as
then
,reps
,fail
, etc., which act as stateful action combinators: given one or more stateful action, they give another stateful action as a result. While well-suited for stateful actions, these operators are not able to combine functional actions:Attempts to fit purely functional actions into the current testing framework seem to lead either:
Desired solution
I would like to have testing operators that explicitly support functional actions:
Looking forward to your inputs, and to a productive discussion!
Beta Was this translation helpful? Give feedback.
All reactions