Formal verification is primarily about establishing relationships between specifications at differing levels of abstraction.

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 receive 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 develping 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 perform this kind of reasoning. It allows us to:

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

In Ivy, interfaces and specifications are objects. An interface is 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.

Monitors as specifications

To specify services such as TCP, we need to make statements 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 tempral 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.

For reasons we will discuss shortly, 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 inteface events based on the history of previous events.

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

#lang ivy1.7
type packet

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

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:

specification {

    relation sent(X:packet)

    after init {
        sent(X) := false
    }

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

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

The specification section contains a monitor. This is imaginary code that is not normally executed (often referred to as “ghost” code). The monitor has one local state component sent that records the set of packets that have been sent so far. This information is recorded by inserting an action before every call to intf.send. This is done using a new declaration before. 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 is introduced with the require statement. This means that the calling environment of inft.recv must guarantee that the condition sent(x) holds before the call to intf.send. That is, the received packet x must previously have 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:

implementation {
    implement intf.send {
        call intf.recv(x)
    }
}

The implementation section provides the implementation of action intf.send using a new declaration implement. 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 the monitor 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

Now, saving this text in the file trivnet.ivy, we can check that our “protocol” satisfies its service specification like this:

$ ivy_check trivnet.ivy

Isolate this:

    The following action implementations are present:
        trivnet.ivy: line 6: implementation of intf.recv
        trivnet.ivy: line 28: implementation of intf.send

    The following action monitors are present:
        trivnet.ivy: line 21: monitor of intf.recv
        trivnet.ivy: line 17: monitor of intf.send

    The following initializers are present:
        trivnet.ivy: line 13: init[after1]

    Any assertions in initializers must be checked ... PASS

    The following program assertions are treated as guarantees:
        in action intf.recv when called from intf.send:
            trivnet.ivy: line 21: guarantee ... PASS

OK

For the moment, ignore the mysterious heading ‘Isolate this:’. Ivy checked that the precondition of intf.recv is true whenever it is called by intf.send. We don’t even need an inductive invariant in this case, because the assertion is true when intf.send is executed in any context.

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:

$ ivy_show trivnet.ivy

type packet
relation sent(V0:packet)


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

Notice that the before actions of the monitor have been inserted at the beginning of these actions, and the implement action of protocol has been used as the body of intf.send. Notice also that the require statement in the monitor has become assert. This means that when intf.recv is called by our implementation, we have to prove that the condition holds.

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

implementation {

    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 async

When async is called, if there is a message in the buffer, the message is received. To verify this implementation, we also need one invariant conjecture:

invariant full -> sent(contents)

That is, to show that when async executes, the received packet has been sent, we need to know that the packet in the buffer has been sent. The reader might want to try to produce this invariant using the interactive invariant generation techniques supported by Ivy.

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 imlementation, 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.5

object intf = {
    action ping
    action pong
}

Here is the interface specification:

type side_t = {left,right}

specification {
    individual side : side_t
    after init {
        side := left
    }

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

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

The specification section 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 preconditions to fail.

Now let’s implement the left-hand player:

implementation {
    isolate left_player = {
        individual ball : bool
        after init {
            ball := true
        }

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

        implement intf.pong {
            ball := true
        }

        invariant ball -> side = left
    } with this
}

The left player is a special kind of object called an isolate. An isolate is verified in isolation. The player has a Boolean state variable 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 (for the moment, ignore the invariant, and also the declaration with this).

The right-hand player implementation is similar, except that it reverses the roles of ping and pong, and initially its ball variable is false:

implementation {
    isolate right_player = {
        individual ball : bool
        after init {
            ball := false
        }

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

        implement intf.ping {
            ball := true
        }

        invariant ball -> side = right
    } with this
}

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

export left_player.hit
export right_player.hit

At this point we could easily enough verify the assertions using the given invariant conjectures. However, by using isolate declarations, we separate the reasoning about the left and right players, using the top-level specification. The top-level program is also an isolate, called this. Each player isolate draws its assumptions and guarantees from the the top-level specification. For this reason, each is given a declaration with this.

We now have two separate proof problems, one for each player isolate. We can see what the left player isolate looks like as follows:

$ ivy_show isolate=left_player pingpong.ivy

type side_t = {left,right}
individual side : {left,right}
relation left_player.ball

invariant [left_player.invar6] left_player.ball -> side = left

after init {
    side := left
}
after init {
    left_player.ball := true
}
action intf.ping = {
    assert side = left;
    side := right
}
action left_player.hit = {
    if left_player.ball {
        call intf.ping;
        left_player.ball := false
    }
}
action intf.pong = {
    {
        assume side = right;
        side := left;
        left_player.ball := true
    }
}
export intf.pong
export left_player.hit

Several interesting things have happened here. First, notice the the action intf.ping. We see that the code inserted by the monitor 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 the top-level specification and also the left player’s implementation of pong. There is a crucial change, however: the require 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 ping 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.

Finally, notice that the isolate contains only the left player’s invariant conjecture. Using this invariant, we can prove the correctness of ping:

$ ivy_check isolate=left_player pingpong.ivy

Isolate left_player:

    ...

    The following set of external actions must preserve the invariant:
        (internal) ext:intf.pong
            pingpong.ivy: line 45: left_player.invar6 ... PASS
        (internal) ext:left_player.hit
            pingpong.ivy: line 45: left_player.invar6 ... PASS

    The following program assertions are treated as assumptions:
        in action intf.pong when called from the environment:
            pingpong.ivy: line 22: assumption

    The following program assertions are treated as guarantees:
        in action intf.ping when called from left_player.hit:
            pingpong.ivy: line 17: guarantee ... PASS

OK

We’ve shown only some interesting parts of Ivy’s ouput. Notice first that Ivy verifies that the two exported actions of the left player isolate preserve the invariant conjecture of left_player. These are intf.pong and left_player.hit. In addition, notice that Ivy treated the precondition of intf.pong as an assumption, and the precondition of intf.ping as a guarantee. It checked the guarantee given the assumption and printed PASS to indicate the the guarantee holds.

Now let’s look at the other isolate:

$ ivy_show isolate=right_player pingpong.ivy

type side_t = {left,right}
individual side : {left,right}
relation right_player.ball

invariant [right_player.invar9] right_player.ball -> side = right

after init {
    side := left
}
after init {
    right_player.ball := false
}
action intf.pong = {
    assert side = right;
    side := left
}
action right_player.hit = {
    if right_player.ball {
        call intf.pong;
        right_player.ball := false
    }
}
action intf.ping = {
    {
        assume side = left;
        side := right;
        right_player.ball := true
    }
}
export intf.ping
export right_player.hit

This is similar, but now pong is verified and ping is assumed to be correct. The state and actions of the left player are compeltely abstracted away.

We can check the whole proof using Ivy like this:

$ ivy_check pingpong.ivy 

...

Isolate right_player:

    ...

    The following set of external actions must preserve the invariant:
        (internal) ext:intf.ping
            pingpong.ivy: line 65: right_player.invar9 ... PASS
        (internal) ext:right_player.hit
            pingpong.ivy: line 65: right_player.invar9 ... PASS

    The following program assertions are treated as assumptions:
        in action intf.ping when called from the environment:
            pingpong.ivy: line 17: assumption

    The following program assertions are treated as guarantees:
        in action intf.pong when called from right_player.hit:
            pingpong.ivy: line 22: guarantee ... PASS

...

OK

Notice here, the assume/guarantee relation is reversed. We assume the precondition of intf.ping when proving the precondition of intf.pong.

Is this really a proof?

In creating the two isolates left_player and right_player, 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 requiremens to assumptions or assertions, and it deleted the code 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 left_player, we show that the assertion about ping is not the first assertion to fail. When verifying right_player, 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.

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:

isolate right_player = {

    ...

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

    ...
}

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

ivy_check interference.ivy

Isolate left_player:
interference.ivy: line 30: error: Call out to intf.ping may have visible effect on left_player.ball

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 the with this declaration from the right_player isolate. Here is what happens when we try to verify the program:

ivy_check 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 pong assertion isn’t checked, because we haven’t declared the top-level specification as a dependency of right_player.

The isolate declaration

Now let’s look at the isolate declaration in more detail. Here is the declaration of the left_player isolate:

isolate left_player = {
    ...
} with this

This creates an isolate named left_player in whose guarantees are checked in contect of the top-level isolate’s specification. In this process, the actions of all isolates except for left_player and this are erased (assuming they are non-interfering). If we didn’t include this in the with part of the declaration, then this 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 isolate is:

  • a require assertion in another isolate it calls into, or
  • an ensure assertion of the isolate itself.

An assumption for a given object is:

  • a require assertion of the isolate itself, or
  • a ensure assertion of an isolate it calls into.

(after monitors and ensure assertions 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.

What we prove

If all isolates are verified, 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.