Parameterized systems
Many systems are designed to include an arbitrary number of similar or identical components. A good example of such a system is the Internet, which is designed to accommodate a large number of hosts, all communicating using the same set of protocols. Another example would be peer-to-peer protocols such as Chord.
We call these parameterized systems where the parameter in question
is the number of protocol participants. Ivy allows you to model an
implement parameterized protocol in a particular style. A
parameterized object is one in which every component has an initial
parameter of the same type. Here is an example of an object parameterized on type t
:
type t
object foo = {
function bit(S:t) : bool
after init {
bit(S) := false
}
action set_bit(self:t) = {
bit(self) := true
}
}
Notice that both the state component bit
and the action set_bit
have a first parameter of type t
. The parameter of set_bit
is
suggestively called self
. This parameter is used in any references
to state components within the action. Thus, object foo
really acts
like a collection of independent objects or processes, one for each
element of type t
. The type t
acts like a reference to one of
these objects.
Ivy provides a shorthand for parameterized objects. We can equivalently
write the object foo
as follows:
type t
object foo(self:t) = {
individual bit : bool
after init {
bit := false
}
action set_bit = {
bit := true
}
}
Ivy adds the parameter self
to each state component of foo
, and
each reference to a component. That is, self
becomes an implicit
parameter, much as it does in an object-oriented programming language
(except for Python, where the self
parameter is explicit). It makes
no difference to Ivy whether you use implicit or explicit
parameters. You can reason about Ivy programs in the same way using
either style.
As we will see later, IVY has special support for parameterized objects. For example, you can compile them and run them in separate process spaces or on different hosts. In addition, when proving assertions that relate to only one process, you can ignore the parameter. This can be a good trick for staying within a decidable logical fragment.
Leader election ring
As an example of a parameterized protocol, lets look at the very simple leader election protocol, introduced in this paper in 1979.
In this protocol we have a collection of distributed processes
organized in a ring. Each process can send messages to its right
neighbor in the ring and receive message from left neighbor. A process
has a unique id
drawn from some totally ordered set (say, the
integers). The purpose of the protocol is to discover which process
has the highest id
value. This process is elected as the “leader”.
This protocol itself is trivially simple. Each process transmits its
own id
value. When it receives a value, it retransmits the value,
but only if it is greater than the process’ own id
value. If a
process receives its own id
, this value must have traveled all the
way around the ring, so the process knows its id
is greater than all
others and it declares itself leader.
We’ll start with a service specification for this protocol, using the explicit parameterized style:
isolate app = {
action tick(me:node) # called when a timer expires
action elect(me:node) # called when v is elected leader
specification {
before elect {
ensure me.pid >= node.pid(X) # only the max pid can be elected
}
}
} with node, id, trans
The isolate app
is parameterized on an abstract datatype node
that we will define shortly. Type node
represents a reference to a
process in our system. The function pid
gives the id
of a node, and
will be defined shortly. The with
clause at the end of the isolate
tells us that this isolate depends on isolates node
, id
and trans
that we will define later.
The interface of app
has two actions: tick
is called by the
environment at some frequency, while elect
is called by a node when
it is elected leader. The specification of elect
says that only the
node with the maximum id
value can be elected.
Now that we know what the protocol is supposed to do, let’s implement it:
isolate app = {
...
implementation {
implement tick(me:node) {
call trans.send(me,me.get_next,me.pid);
}
implement trans.recv(me:node,v:id) {
if v = me.pid { # Found a leader
call elect(me);
}
else if v > me.pid { # pass message to next node
call trans.send(me,me.get_next,v);
}
}
}
} with node, id, trans
We implement the tick
by calling the network transport trans
to
send our id
to the next node in the ring.
The protocol also implements the recv
action of the transport interface.
This is called when the process receives a message with
an id
value v
. If the value is equal to the process’ own id
, the
process knows it is leader and calls elect
. Otherwise, if the
received value is greater, it calls trans.send
to send the value on
to the next node.
Here is the same isolate described in the implicit style:
isolate app(me:node) = {
action tick # called when a timer expires
action elect # called when v is elected leader
specification {
before elect {
ensure me.pid >= node.pid(X) # only the max pid can be elected
}
}
implementation {
implement tick {
call trans.send(me,me.get_next,me.pid);
}
implement trans.recv(v:id) {
if v = me.pid { # Found a leader
call elect;
}
else if v > me.pid { # pass message to next node
call trans.send(me,me.get_next,v);
}
}
}
}
There is not much difference. Notice that we dropped the parameter
me
from the action definitions. However, references to other objects
still have to have the explicit parameter me
. The implicit style
mainly shows an advantage when the parameterized object has many references
to its own actions and state components.
With our protocol implemented, let’s look at the interfaces that it’s
built on, starting with the type of id
values:
module total_order_properties(t) = {
property [transitivity] X:t < Y & Y < Z -> X < Z
property [antisymmetry] ~(X:t < Y & Y < X)
property [totality] X:t < Y | X = Y | Y < X
}
isolate id = {
type this
specification {
instantiate total_order_properties(this)
}
}
We defined a module that captures the properties of a total order,
since we will re-use these for two different types. In fact, this
module can also be found in the standard library order
. We then
define our abstract type id
to be a type that satisfies these
properties. Later, we will implement type id
with fixed-width
integers.
Now let’s look at the type of nodes, which is a bit more interesting:
isolate node = {
type this
action get_next(x:this) returns (y:this)
function pid(X:node) : id # map each node to an id
axiom [injectivity] pid(X) = pid(Y) -> X = Y
specification {
instantiate total_order_properties(this)
after get_next {
ensure (x < y & ~ (x < Z & Z < y)) | (y <= X & X <= x)
}
}
}
The type node
supplies an action get_next
that takes an element x
and
returns the next element y
in the ring. The specification says that
either y
is the least element larger than x
or x
is the maximum
element and y
the minimum (that is, we “wrap around” to the
beginning). In principle, we should also say that the type node
is finite,
but we won’t actually need this fact to prove safety of our protocol.
This is one way of specifying a ring topology. Later we will
see a different way that can make the proofs a little simpler. We can
implement either version with fixed-width integers. We omit the implementation
here.
Notice the axiom injectivity
. This says that no two processes have
the same id
, which is necessary for correctness of the protocol. It
is an axiom rather than a property because it is an assumption about
the envionment. That is, we assume that the processes are configured
such that each has a unique id
but we cannot prove this.
Finally, we need a specification for the network transport layer. It’s quite simple:
isolate trans = {
action send(src:node, dst:node, v:id)
action recv(dst:node, v:id)
specification {
relation sent(V:id, N:node) # The identity V is sent at node N
after init {
sent(V, N) := false;
}
before send {
sent(v,dst) := true
}
before recv {
require sent(v,dst)
}
}
The relation sent
tells us which values have been sent to which
nodes. Initially, nothing has been sent. The interface provides two
actions send
and recv
which are called, respectively, when a value
is sent or received. The src
and dst
parameters give the source
and destination node of the message.
The specification says that a value is marked as sent when send
occurs and a value must be marked sent before it can be received.
This describes a network service that can duplicate or re-order
messages, but cannot corrupt messages.
Notice that trans
has been written as paramterized isolate. In
particular, the first parameter of each action is the location where
the action occurs (the source for send
and the destination for
recv
). This is imporant for extracting parallel processes from the
Ivy code. In the netorking tutorial
we will see how the transport service can be implemented.
Verifying the leader election protocol
Now let’s try to verify that the implementation of our leader election
protocol app
satisfies its service specification, assuming the
specifications of trans
, node
, id
.
We are trying to prove that, when any node calls app.elect
, it in
fact has the highest id
. That is, the ensure
statement is a
guarantee of app
when it calls app.elect
.
Obviously, we will need an inductive invariant at this point. We will
try to discover one using Ivy’s CTI
method. You can find this example
in the Ivy source directory doc/examples
. We start Ivy using this command:
$ ivy_check isolate=app diagnose=true leader_election_ring.ivy
Ivy finds a counterexample to the ensure
statement, and starts the
graphical interface (because we used diagnose=true
).
This is what we see:
On the left, we see a failing transition starting in state 0
. The
transition to state 1
is a failing call to trans.recv
. To see the
execution in more detail, we can
left-click on the transition and choose Step in
. In this case,
however, we already have a pretty good idea of what went wrong: some
node must have received its own id
and declared itself leader
without having the highest id
. This is not surprising, since with no
invariant conjectures, the only thing we know about state 0
is that
it satisfies our axioms.
On the right, we see a depiction of the state 0
. There are two
elements in each of the two sorts id.t
and node.t
. Since we
haven’t yet selected any relations to view, this is all we see. We
could start selecting relations to see more information, but let’s
instead choose the command Invariant|Diagram
to see what information
Ivy thinks might be relevant to the failure. Here’s what we see:
The arrow from id 0 to id 1 is depicting the relation X:id.t < Y
.
Because the arrow goes from X
to Y
, we interpret the arrow to mean
0:id.t < 1
. Similarly, the arrow from node 0 to id 0 means that
asgn.pid(0) = 0
. That is, the id of node 0 is 0. We can also see
that node 1’s id is 1 and that trans.sent(0,1)
, in other words, id 0
has been sent to node 0. These facts are displayed below the graph
under the heading “Constraints”.
The problem is clear: node 1 is receiving its own id, so it is about
to become leader, but it does not have highest id. This situation
clearly has to be ruled out. We do this by selecting the
Conjecture|Strengthen
command. Here is what Ivy says:
In other words, we conjecture that it never happens that a node N has
id less than a node P, and node N is receiving its own id. Now we choose
Invariant|Check induction
, to see if our conjecture is inductive.
Of course, it isn’t. Here’s what Ivy says:
And here is the counterexample to induction:
Here, some externally called action transitions from state 0
satisfying our conjecture to state 1
not satisfying the
conjecture. Ivy is depicting state 0
, with all the relations that
appear in the conjecture. It’s not immediately clear what’s wrong here,
so let’s try Invariant|Diagram
again to see what Ivy thinks:
Now we can see the problem: node 2’s id is arriving at node 1, but it
should never have passed node 0, because node 0 has a higher
id. Notice, though, that there are two additional arrows in the diagram
we didn’t mention (the ones from node 1 to id 0 and id 0 to id 1). Maybe
these are actually irrelevant. We could remove these manually by
clicking on the corresponding facts below. However, we have another
trick up our sleeve. We can use bounded checking to see if some arrows
can be removed. We choose Conjecture|Minimize
and (somewhat
arbitrarily) select a bound of 4 steps. Here is what Ivy says:
This conjecture says that we never have nodes in the order N < P < Q
such that Q
has a smaller id than N
and the id of Q
is arriving
at P
. In the graph, we see that the highlights have been removed
from the two irrelevant arrows:
Ivy discovered that, within 4 steps, we
can rule out the highlighted facts. By ruling out a more general
situation, we obtain a stronger conjecture. Since this new conjecture
seems right, let’s add it to our set by selecting
Conjecture|Strengthen
.
Now let’s try Invariant|Check induction
again. Ivy is still unhappy
and says that our first conjecture is still not relatively inductive. We
try Invariant|Diagram
again and here is what we see:
This looks very similar to the diagram that led to our previous conjecture. Here, however, it’s the id of node 0 that is arriving at node 2, when it couldn’t have passed through node 1. This situation is symmetric to the previous one by rotating the ring. Unfortunately, the way we described the ring topology using a linear order has broken the ring’s rotational symmetry, so this appears as a distinct case. Later, we’ll see a way to avoid this symmetry breaking. For now, though, we’ll just slog through the cases. As before, we minimize this diagram by bounded checking. Here is the result:
Ivy conjectures that we do not have nodes N < P < Q
such that N
has a lower id than P
and the id of N
is arriving at Q
. This is
just another case of the general proposition that a lower id cannot
pass a higher id. We chose Conjecture|Strengthen
to add this new
conjecture to our set.
Now we try Invariant|Check induction
(the reader may be able to
guess what happens next). Again, Ivy says that our first conjecture is
not relatively inductive. After Invariant|Diagram
, we see this:
This describes the same situation, where the id of node 1 is arriving at node 0.
Once again, we generalize using Conjecture|Minimize
, giving this conjecture:
We add this conjecture using Conjecture|Strengthen
. Now when we
use Invariant|Check induction
, we get the following:
That is, we have found a proof for the isolate. We can save this invariant to
a file using the File|Save invariant
command. We edit these conjectures
into our implementation object app
, like this:
isolate app = {
...
private {
invariant ~(node.pid(N) < node.pid(P) & trans.sent(node.pid(N),N))
invariant ~(node.pid(Q) < node.pid(N) & trans.sent(node.pid(Q),P) & N < P & P < Q)
invariant ~(node.pid(N) < node.pid(P) & trans.sent(node.pid(N),Q) & N < P & P < Q)
invariant ~(node.pid(P) < node.pid(Q) & trans.sent(node.pid(P),N) & N < P & P < Q)
}
}
How not to break the rotational symmetry
As we observed above, this proof is a bit messy because of the way we
described the ring topology using a totally ordered set. There’s a
different way to describe rings that avoids this problem. Here is an
alternative specification of node
:
isolate node = {
...
specification {
relation btw(X:this,Y:this,Z:this)
property btw(W, Y, Z) & btw(W, X, Y) -> btw(X, Y, Z)
property btw(W, X, Z) & btw(X, Y, Z) -> btw(W, X, Y)
property btw(W, X, Z) & btw(X, Y, Z) -> btw(W, Y, Z)
property btw(W, Y, Z) & btw(W, X, Y) -> btw(W, X, Z)
property W = X | btw(W, X, W)
property ~btw(X, X, Y)
property ~btw(X, Y, Y)
property btw(X,Y,Z) | Y = Z | btw(X,Z,Y)
property btw(X,Y,Z) | Y = X | btw(Y,X,Z)
after get_next {
assert ~btw(x,Z,y)
}
}
}
Instead of putting the nodes in a total order, we define are relation
btw(X,Y,Z)
that holds when Y
occurs on the path form X
to Z
in
the ring. The axioms of btw
are a bit more complex than the axioms
of a total order. One the other hand, it is very easy to specify
get_next
in terms of btw
. We say that y
is next after x
in the
ring if there is no Z
between x
and y
. You might wonder if the
properties given above for btw
are really correct. Later, when we
implement node
, we’ll prove that it has these properties.
Now, let’s try to verify the isolate app
with this new version of node
:
$ ivy_check isolate=app diagnose=true leader_election_ring_btw.ivy
This is what we see:
That is, we have a node 1
that is receiving its own id while node
0
has a greater id. Notice, though, that on the right the relation
node.btw
doesn’t appear. This is because it is a ternary relation
and Ivy doesn’t know how to display it graphically in a reasonable
way.
Now, as before, let’s add this as a conjecture using
Conjecture|Strengthen
. As before, Invariant|Check induction
shows
that this conjecture is not relatively inductive. Applying Invariant|Diagram
,
this is what we see:
This is similar to what we saw before, but notice the blue arrow from
node 1
to node 0
. This corresponds to a new relation that has
been added on the right: node.btw(2,X,Y)
. From this arrow, we can
infer that node.btw(2,1,0)
holds. That is, starting at 2
, and
moving around the ring, we see 1
before 0
. We can also see this
fact stated below under “Constraints”. This means that, from 1
, we
must pass through 0
on the way to 2
. Therefore, the id of 1
cannot possibly reach 2
, as the diagram shows.
We can try generalizing this diagram using Conjecture|Minimize
, but
in this case, there is no effect. No matter – since this looks like a
reasonable conjecture, we use Conjecture|Strengthen
to add it to our
invariant. Here is the result:
This says it is not possible to have nodes Q,P,N
in ascending order of id,
such that P
is on the way from Q
to N
and the id of P
is arriving at Q
.
Now let’s try Invariant|Check induction
:
Because we haven’t broken the rotational symmetry of the ring, there
is just one case needed when we state the key invariant that an id
can’t bypass a higher id in the ring. This illustrates that the
complexity of the proof can be affected by how we write the
specification. Once again, we can save this invariant and edit it into
the definition of app
.
Finally, try this command to verify that in fact the properties of
btw
are correct for a particular implementation of node
:
$ ivy_check leader_election_ring_btw.ivy
Have a look at the implementation of the node
isolate in file
leader_election_ring_btw.ivy
. If this doesn’t make sense, see the
tutorial on
abstract datatypes.