Formal verification is primarily about establishing relationships between specifications at differing levels of abstraction. The same can be said of compositional testing. The difference is that in the compositional testing approach, we combine formal proof with specification-based testing to increase our confidence in the correctness of a system.

Consider, for example, a network protocol, such as the TCP protocol that is widely used to communicate streams of data over the Internet. At a high level of abstraction, TCP is a service, providing methods for establishing connections, and sending or receiving data. This service provides guarantees to its users of reliable in-order transmission of streams of bytes. At a lower level of abstraction, TCP can be seen as a protocol. The protocol is a set of rules (laid out in RFC 675 and later documents) that implements service guarantees of TCP by exchanging datagrams over an unreliable network.

The service and protocol specifications of TCP are views of the same process observed at different interfaces. That is, TCP is sandwiched between a higher-level application (say, a web browser and web server) and the lower-level datagram protocol (typically the IP protocol) as shown below:

Network Stack

The TCP service specification describes the events we observe at the interface between the application layer and the transport layer. The IP service specification describes the events we observe at the interface between the transport layer and the network layer. The TCP protocol specification describes the relation between events at this interface and the lower-level interface between transport and network layers.

If we were developing the TCP protocol specification, we would like to verify that the IP service and the TCP protocol together implement the TCP service specification. That is, if events at the transport/network interface are consistent with the IP service specification, and if we execute the TCP protocol according to its specification, then events at the application/transport interface should be consistent with the TCP service specification. From the point of view of the TCP protocol, we say that the IP service specification is an assumption, while the TCP service specification is a guarantee.

IVy has features that allow us to combine testing with formal verification to perform this kind of reasoning. It allows us to:

  • Define objects with interfaces
  • Write specifications about interfaces
  • Test assume/guarantee relationships between these specifications

In IVy, interfaces and specifications are objects. An interface is an object with unimplemented actions (a bit like an instance of an abstract class in C++). A specification is a special object that monitors the calls and returns across an interface and makes assertions about their correctness.

Monitors as specifications

To specify services such as TCP, we need to make assertions about the sequences of events that can occur at an interface. For example, in TCP, we need to make statements relating the sequences of send and receive events to abstract data streams that are transmitted between clients. Specifications about sequences of events in time are often referred to as temporal specifications.

A common approach to temporal specification is to define a specialized logical notation called a temporal logic. These notations make it possible to write succinct temporal specifications, and also us to do some proofs in a fully automated way using model checking.

IVy takes a different approach. Temporal specifications in IVy are defined using special objects called monitors. A monitor is an object that synchronizes its actions with calls and returns across an interface. This allows the monitor to record information about the history of the interface in its local state, and to assert facts that should be true about interface events based on the history of previous events.

As an example, here is a definition of an interface for a ridiculously simple network service:

#lang ivy1.6
type packet

object intf = {
    action send(x:packet)
    action recv(x:packet)
}

The type packet is an example of an uninterpreted type. We don’t yet know want the contents of a packet are, but we can fill in the definition of packet later.

The actions in an interface object don’t have definitions. These will be filled in by other objects that implement the different roles in the interface. We don’t know yet what these objects actually do, but we can write a service specification that tells us something about the temporal behavior at the interface:

object spec = {
    relation sent(X:packet)

    after init {
        sent(X) := false
    }

    before intf.send {
        sent(x) := true
    }

    before intf.recv {
        assert sent(x)
    }
}

Object spec is a monitor. It has one local state component sent that records the set of packets that have been sent so far. The after init declaration says that, initially, no packet X has been sent. In the Ivy language, symbols beginning with capital letters are logical variables. Unbound variables are implicitly universally quantified.

Information about sent packets is recorded by inserting an action before every call to intf.send. This is done using a before declaration in the specification. Notice that the inserted action can refer to the parameters of intf.send and it can update the monitor state. In addition, the monitor inserts an assertion before every call to intf.recv. This assertion states that the received packet x has previously been sent.

In effect, our service specification describes a channel that can re-order and duplicate packets, but cannot corrupt packets. If any corrupted packet is received, the assertion will fail.

Now let’s consider some possible implementations of this very simple specification. Here is the most trivial one:

object protocol = {
    implement intf.send {
        call intf.recv(x)
    }
}

Object protocol provides the implementation of action intf.send using an implement declaration. This declaration provides the missing body of the action intf.send. The implementation simply calls intf.recv on the sent packet x. The assertion in monitor spec is always true, since before calling intf.send, the packet x is added to the relation sent. That is, our implementation trivially satisfies the specification “receive only sent packets”.

To verify our implementation, we need to put it in a suitable environment. The following statements tell us that the environment will implement intf.recv and will call intf.send:

import intf.recv
export intf.send

In order to test our program, we need to give a concrete interpretation to the abstract type packet. It doesn’t much matter what this interpretation is. This statement tells IVy to represent packets using 16-bit binary numbers:

interpret packet -> bv[16]

Now, let’s do some verification. The IVy compiler can translate our program into C++, and also generate a randomized tester that takes the role of the environment. We save the above text to the file trivnet.ivy, then compile like this:

$ ivy_to_cpp target=test build=true trivnet.ivy
g++ -I $Z3DIR/include -L $Z3DIR/lib -g -o trivnet trivnet.cpp -lz3

The command line options tell ivy_to_cpp to generate a test environment and to use the C++ compiler to generate an executable file. When we run the executable, the output looks like this:

./trivnet
> intf.send(61468)
< intf.recv(61468)
> intf.send(49878)
< intf.recv(49878)
> intf.send(18736)
< intf.recv(18736)
> intf.send(41051)
< intf.recv(41051)
...

The output lines beginning with > represent calls from the test environment into the system, while lines beginning with < are calls from the system into the environment. The specification monitor is checking that every call to intf.recv corresponds to some previous call to intf.send. This input values are just random 16-bit numbers. Since our implementation is correct, no errors are flagged.

To get a better idea of what is happening with before and implements, we can print out the program that results from inserting the monitor actions and interface implementations. Here is part of the output:

$ ivy_show trivnet.ivy

type packet
relation spec.sent(V0:packet)

after init {
    spec.sent(X) := false
}
action intf.recv(x:packet) = {
    assert spec.sent(x)
}
action intf.send(x:packet) = {
    spec.sent(x) := true;
    call intf.recv(x)
}

Notice that the before actions of spec have been inserted at the beginning of these actions, and the implement action of protocol has been used as the body of intf.send.

Of course, we might consider a (slightly) less trivial implementation, such as this one that implements the service specification with a one-place buffer:

object protocol = {
    individual full : bool
    individual contents : packet
    after init {
        full := false
    }

    implement intf.send {
        full := true;
        contents := x
    }

    action async = {
        if full {
            full := false;
            call intf.recv(contents)
        }
    }
}

This implementation has an action async that needs to be called by the environment, so we add:

export protocol.async

The output from the tester looks like this:

./trivnet2
> intf.send(59132)
> intf.send(18535)
> protocol.async()
< intf.recv(18535)
> intf.send(11708)
> intf.send(15030)
> protocol.async()
< intf.recv(15030)
> intf.send(64574)
> intf.send(23863)
> intf.send(63393)
> protocol.async()
< intf.recv(63393)

The tester is calling intf.send and protocol.async uniformly at random (with a probability of 0.5 for each). We can see that some packets (for example the first) are dropped.

Let’s put a bug in the protocol to see what happens. The action bug below corrupts the packet buffer:

    action bug(p:packet) = {
        contents := p
    }

...
 
export protocol.bug

Here’s a test run:

./trivnet3
> protocol.async()
> protocol.async()
> intf.send(18535)
> protocol.bug(61184)
> intf.send(31188)
> intf.send(18749)
> protocol.async()
< intf.recv(18749)
> protocol.bug(6178)
> intf.send(28724)
> protocol.bug(45283)
> protocol.bug(6070)
> protocol.bug(2590)
> protocol.bug(10158)
> protocol.async()
trivnet3.ivy: line 22: : assertion failed

At some point, the environment calls bug then async causing the protocol to deliver a wrong packet value. We can see that the specification monitor is in fact running, and it gives an error message pointing to the line in the code where an assertion failed.

Assume-Guarantee reasoning in IVy

In the previous example, we saw that a service specification is a kind of abstraction. It hides details of the underlying implementation, telling us only what we need to know to use the service. Abstractions are crucial in reasoning about complex systems. They allow us to develop one component of a system without thinking about the details of the implementation of other components. For example, when developing a network application based on TCP, we don’t have to read RFC 675. We just rely on the simple service guarantee that TCP provides (reliable, in-order delivery). The service specification allows us to think about our application in isolation from the network protocol stack.

IVy provides a mechanism to do just this when proving correctness of system components. That is, we can isolate a single object in our system and prove its correctness using only the service specifications of its interfaces.

As an example, let’s build a system of two components that plays a highly simplified game of ping-pong. Here is the interface definition:

#lang ivy1.6

object intf = {
    action ping
    action pong
}

Here is the interface specification:

type side_t = {left,right}

object spec = {
    individual side : side_t
    after init {
        side := left
    }

    before intf.ping {
        assert side = left;
        side := right
    }

    before intf.pong {
        assert side = right;
        side := left
    }
}

The specification has a single state component side that keeps track of whether the ball is on the left- or right-hand side of the table. When the ball is on the left, a ping action is allowed, sending the ball to the right-hand side. When the ball is on the right, a pong is allowed, sending the ball to the left again. A failure to alternate ping and pong would cause one of the assertions to fail.

Now let’s implement the left-hand player:

object left_player = {
    individual ball : bool
    after init {
        ball := true
    }

    action hit = {
        if ball {
            call intf.ping;
            ball := false
        }
    }

    implement intf.pong {
        ball := true
    }

}

The player has a Boolean ball that indicates the ball is in the player’s court. We assume the left player serves, so ball is initially true. If the left player has the ball, the hit action will call ping, sending the ball to the right, and set ball to false. The left player implements ping by setting ball to true.

The right-hand player is similar:

object right_player = {
    individual ball : bool
    after init {
        ball := false
    }

    action hit = {
        if ball {
            call intf.pong;
            ball := false
        }
    }

    implement intf.ping {
        ball := true
    }

}

Let’s export the hit actions to the environment, so the players will do something:

export left_player.hit
export right_player.hit

Here is the call graph of the system we have defined:

Ping Pong Call Graph

Now what we want to do is to generate testers for the left and right players in isolation. That is, we want the tester for the left player to act as its environment. This means the tester has to call both left_player.hit and intf.pong. Similarly, the tester for the right player has to call right_player.hit and intf.ping.

To generate these testers, we use isolate declarations:

isolate iso_l = left_player with spec
isolate iso_r = right_player with spec

The first says to isolate the left player using the interface specification spec. The second says to do the same thing with the right player. This reduces the system verification problem to two separate verification problems called “isolates”.

Here’s the call graph for the left player isolate iso_l:

Ping Pong Call Graph 2

We can see what the first isolate looks like textually as follows (leaving a few things out):

$ ivy_show isolate=iso_l pingpong.ivy

individual spec.side : side_t
relation left_player.ball

action ext:left_player.hit = {
    if left_player.ball {
        call intf.ping;
        left_player.ball := false
    }
}

action intf.ping = {
    assert spec.side = left;
    spec.side := right
}

action ext:intf.pong = {
    assume spec.side = right;
    spec.side := left;
    left_player.ball := true
}

Several interesting things have happened here. First, notice the action intf.ping. We see that the code inserted by spec is present, but the implementation provided by right_player is missing. In effect, the right player has been abstracted away: we see neither its state nor its actions. Further, notice that the action pong has been exported to the environment. It contains the monitor code from spec and also the left player’s implementation of pong. There is a crucial change, however: the assert in the specification of pong has changed to assume.

This is an example of assume-guarantee reasoning. The left player guarantees to call ping only when the ball is on the left. However, it assumes that the right player only calls pong when the ball is on the right. This is a very common situation in protocols. Each participant in the protocol guarantees correctness of its outputs, but only so long as its inputs are correct.

Let’s start by testing the left player. First, we compile a tester:

$ ivy_to_cpp isolate=iso_l target=test build=true pingpong.ivy 
g++ -I $Z3DIR/include -L $Z3DIR/lib -g -o pingpong pingpong.cpp -lz3

Notice we specified the isolate iso_l on the command line. Now let’s run pingpong:

$ ./pingpong
> left_player.hit()
< intf.ping
> left_player.hit()
> intf.pong()
> left_player.hit()
< intf.ping
> left_player.hit()
> intf.pong()
> left_player.hit()
< intf.ping
...

We can see that the environment (the calls marked with >) is respecting the assumption of the left player that pong occurs only when the ball is on the right, that is, after a ping. The tester is sampling uniformly out of just the actions that satisfy the isolate’s assumptions. You may notice that sometimes the environment calls hit when the left player doesn’t have the ball. This is not a problem, since a hit has no effect in this case. What if we neglected to test whether the left player in fact has the ball in the implementation? Let’s try it. That is, let’s use this version of the left player’s hit action:

action hit = {
    call intf.ping;
    ball := false
}

Here’s what we get:

$ ./pingpong_bad
> left_player.hit()
< intf.ping
> left_player.hit()
pingpong_bad.ivy: line 15: : assertion failed

The left player hits when it shouldn’t and causes a failure of the precondition of ping.

Now let’s consider try right player. We compile and run a tester for the isolate iso_r:

$ ivy_to_cpp isolate=iso_r target=test build=true pingpong.ivy 
g++ -I $Z3DIR/include -L $Z3DIR/lib -g -o pingpong pingpong.cpp -lz3
$ ./pingpong
> right_player.hit
> right_player.hit
> right_player.hit
> intf.ping
> right_player.hit
< intf.pong
> right_player.hit
> intf.ping
> right_player.hit
< intf.pong
> intf.ping

Here we see that the testing environment is generating calls to ping and right_player.hit. The ping calls satisfy the precondition of ping, that is, ping is only called when the ball is on the left side. The specification monitor is checking that the pong calls generated by the right player satisfy the precondition of pong.

So what have we done so far? We’ve verified by randomized testing the the left player guarantees correct pings assuming correct pongs. We’ve also verified by testing that the right player guarantees correct pongs given correct pings. Since neither the pings nor the pongs can be the first to fail, we can conclude that all pings and pongs are correct according to the specification.

We can ask IVY to check this conclusion for us:

$ ivy_check trusted=true pingpong.ivy 
Checking isolate iso_l...
Checking isolate iso_r...
OK

The option trusted=true tells IVy to trust that the specified isolates are correct, facts that we have tested, but not formally verified. IVy says it can prove based on this assumption that all of our assertions are true at all times.

Is this really a proof?

In creating the two isolates iso_l and iso_r, we reduced a proof goal to two simpler sub-goals. In theorem provers, this kind of reduction is called a tactic. We must take care that our tactics are logically sound. That is, is the two sub-goals are provable, then the original goal must also be provable.

Let’s try informally to justify the soundness of our tactic. Ivy performed two transformations to produce each isolate: it changed some assertions to assumptions, and it deleted the actions and state components of one of the two players.

Pseudo-circular proofs

At first blush, changing assertions to assumptions seems to be unsound because of a logical circularity. That is, we assumed ping to prove pong and pong to prove ping. This apparent circularity is broken by the fact that when proving ping, we only assume pong has been correct in the past. When verifying iso_l, we show that the assertion about ping is not the first assertion to fail. When verifying iso_r, we show that the assertion about pong is not the first assertion to fail. Since no assertion is the first to fail, we know no assertion ever fails (this is an argument by induction over time).

Abstraction

In isolating the left player, IVy deleted all the actions and state components of the right player. This is a form of abstraction known as localization. The idea is that the truth of some assertions does not depend on certain components of the system. But in what cases is this a sound abstraction? That is, when can we infer that an assertion is true from the fact that it is true in the abstracted system? A sufficient condition is that the abstracted actions can have no side effect that is visible to the remaining actions. We will call this condition non-interference.

IVy uses a fairly simple analysis to check non-interference. As an example, suppose the right player tries to cheat by putting the ball back in the left player’s court without hitting it:

object right_player = {

    ...

    implement intf.ping {
        left_player.ball := true
    }

    ...
}

Here’s what happens when when we try to verify this version:

$ ivy_check trusted=true interference.ivy 
Checking isolate iso_l...
interference.ivy: line 30: error: Call out to right_player.intf_ping[implement] may have visible effect on left_player.ball
interference.ivy: line 37: referenced here
interference.ivy: line 20: referenced here
interference.ivy: line 30: referenced here
interference.ivy: line 27: referenced here

IVy can’t abstract away the right player’s implementation of intf.ping because of the possible side effect on left_player.ball. IVy’s analysis of interference is based only on which state components are referenced and assigned. It’s easy to construct an example where two objects share a variable, but do not actually interfere, for example, because they reference disjoint elements of an array. IVy will flag this as an error, since its analysis is not precise enough to show non-interference. IVy is designed to reason about objects that share interfaces, but not variables.

Coverage

To be sound, our tactic must also ensure that every assertion in the program is verified in some isolate. IVy checks this for us. Suppose, for example, we remove this isolate declaration from our ping-pong program:

isolate iso_r = right_player with spec

Here is what happens when we try to verify the program:

$ ivy_check trusted=true  coveragefail.ivy
coveragefail.ivy: line 20: error: assertion is not checked
coveragefail.ivy: line 5: error: ...in action intf.pong
coveragefail.ivy: line 49: error: ...when called from right_player.hit
error: Some assertions are not checked

IVy is telling us that the precondition of action pong isn’t checked when it’s called from right_player, because we haven’t created an isolate for right_player.

The isolate declaration

Now let’s look at the isolate declaration in more detail. Here is the declaration that isolates the left player:

isolate iso_l = left_player with spec

This creates an isolate named iso_l in which the guarantees of left_player are checked. The actions of all objects except for left_player and spec are abstracted away (assuming they are non-interfering). If we didn’t include spec in the with part of the declaration, then spec would be abstracted away, and no assertions would be checked (leading to an error message similar to the one above).

The remaining question is how IVy decides which assertions are guarantees for left_player and which are assumptions. The default rules are as follows.

A guarantee for a given object is any assertion occurring in:

  • An implementation provided by the object
  • A before monitor of an action called by the object
  • An after monitor of an implementation provided by the object

An assumption for a given object is any assertion occurring in:

  • A before monitor of an implementation provided by the object
  • An after monitor action called by the object

(after specifications will be introduced in the next section).

This roughly corresponds to the intuition that an object makes assumptions about its inputs and guarantees about its outputs.

So what have we proved?

If all isolates are correct, and if IVy’s non-interference and coverage checks succeed, then we can infer that all assertions in the program are true at all times in all executions of the program. In this case, ivy_check prints OK. Of course, we only verified the isolates by randomized testing. This means there is a risk that we missed a bug in a system component. Because IVy checked our assume/guarantee proof, however, we know that if the whole system has a bug, then one of the isolates must have a bug. If we test the isolates long enough, we will eventually find it without testing the system as a whole.