The Ivy language
Ivy’s language is designed to let you to specify and implement systems in a way that is both convenient and conducive to automated verification. Technically, this means that important verification problems like invariant checking and bounded model checking fall within a decidable fragment of first-order logic.
For this reason, the Ivy language has certain unusual features. Ivy divides the world into three categories of things:
- Data values,
- Function values and
- Procedures.
Data values are the kinds of things that can be printed out, stored in files, transmitted over the internet and so on. These are sometimes refered to as POD types for “plain old data”. Function values are pure mathematical functions. They can be evaluated on arguments to produce deterministic results, and they have no side effects. In other words, evaluating a function does not modify the value of any variable. On the other hand, a procedure, when called, has an effect. In addition to returning values, it may also modify the values of variables.
Data values and function values can be stored in variables and passed as arguments. That is, data and functions are “first class” values. On the other hand, procedures cannot be stored in variables or passed as arguments.
A particularly unusual aspect of the Ivy language is that there are
no references. Two distinct variables are never “aliases” for the
same underlying “object”. Modifying variable a
never has an effect
on the value of variable b
. Ivy’s philosophy is that references are a
low-level optimization that should be used by compilers to avoid
copying, but should never appear in high-level programs. The absense
of aliases enormously simplifies the analysis, testing and
verification of programs.
Another unusual aspect of the Ivy language is that it is synchronous. This means that:
- All actions occur in reaction to input from the environment, and
- all actions are isolated, that is, they appear to occur instantaneously, with no interruption.
This aspect of Ivy’s semantics greatly simplifies reasoning about concurrent and distributed systems.
We will now consider the basic elements of an Ivy program.
The language specifier
Every Ivy file begins with a line like the following:
#lang ivy1.7
This tells Ivy what version of the language we are using. This is important because in successive version of the language, certain features may be changed or deprecated. Providing the language version allows old programs to keep working. They current version of the Ivy language is 1.7. Changes between versions of the language are listed at the end of this document.
State and actions
An Ivy program describes objects that have state and provide actions that operate on state. State variables may hold either plain old data or mathematical relations and functions (much as in the Alloy language, but with important differences that we will discuss later).
Types and declarations
Suppose we have a network consisting of nodes, with pair-wise links
between the nodes. We can model the structure of the network with a
relation link
like this:
type node
relation link(X:node,Y:node)
This says that node
is a POD type, but tells us nothing yet about
how values of type node
are represented. At this point, we say that
node
is an uninterpreted type. Further, we declared
that link
is a set of pairs (X,Y) where X and Y
are nodes.
In Ivy, as in Prolog, identifiers beginning with capital letters
are logical variables, or place-holders. These are not to be confused
with program variables, which hold the program state. The colons
introduce type annotations. In the above declaration, the variables
X and Y are just taking up space, telling us what sort of relation
link
is (that is, for every X and Y of type node
, link(X,Y)
is a Boolean value.
We could equivalently have written the relation declaration as:
function link(X:node,Y:node) : bool
Either way, we create a variable link
that holds a function value, in particular
a function from pairs of node
to bool
.
As another example, here is a declaration of a function that gives an ID to each node:
type id
function node_id(X:node) : id
A variable that holds just a node value can be declared like this:
individual root:node
Enumerated types
The type node
declared above is an uninterpreted type. This means
it can be any set with at least one element. Often it is useful to
define a type in extension, that is, by enumerating its elements
explicitly. An example of an enumerated type in Ivy would be:
type color = {red,green,blue}
This declares a type with exactly three distinct values, and also declares
individuals red
, green
and blue
as its elements.
Actions
An action is Ivy’s notion of a procedure that mutates the values of state variables. For example, here is a declaration of an action that adds a link between two nodes:
action connect(x:node, y:node) = {
link(x,y) := true
}
The action connect
is defined in terms of one of Ivy’s primitive
actions: an assignment. An assignment modifies the value of a
variable. In this case, the single pair (x,y) is added to the
relation link
(or put another way, the value of the expression
link(x,y)
is set to true, without otherwise modifying
link
). Because there is no aliasing in Ivy, the values of all other
variables remain unchanged by the assignment.
We can use place-holders to make larger modifications to a relation. For example, here is an action that removes all links from a given node x:
action clear(x:node) = {
link(x,Y) := false
}
The effect of the assignment with variable Y is to simultaneously
assign link(x,Y)
for all nodes Y. We don’t have to give a type
annotation for Y because it can be inferred from context.
We can be a little more selective by giving a Boolean expression in
the assignment. For example, this action removes links from x to all
nodes in the set failed
:
action clear_failed(x:node) = {
link(x,Y) := link(x,Y) & ~failed(Y)
}
If there are no parameters, we don’t use parentheses. For example:
action flip = {
bit := ~bit
}
In fact, you will never see a pair of empty parentheses in IVY. An action can also have return parameters. For example:
action swap(a:t,b:t) returns (c:t,d:t) = {
c := b;
d := a
}
Control
Sequential execution
We can execute two actions sequentially by separating them with semicolon. For example, this action removes all links from x, then links x to y:
action connect_unique(x:node, y:node) = {
link(x,Y) := false;
link(x,y) := true
}
The semicolon is a sequential composition operator in Ivy, not a
statement terminator. However, an extra semicolon is allowed before an
ending brace }
to make editing sequences of statements easier. In
this case, we could have written the sequence of two assignments
equivalently as:
link(x,Y) := Y = y
Calling actions
We could also have written connect_unique
by calling clear
and connect
:
action connect_unique(a:node, b:node) = {
call clear(a);
call connect(a,b)
}
Ivy uses the
call-by-value convention. That is, when we call clear(a)
a
local variable x is created during the execution of clear
and
assigned the value a. This means that, as in the C programming
language, modifying the value of x in clear
would not result
in modifying the value of a in connect_unique
.
The return values of an action can be obtained like this:
call x,y := swap(x,y)
An action with a single return value can be called within an expression.
For example, if sqrt
is an action, then:
x := y + sqrt(z)
is equivalent to
call temp := sqrt(z)
x := y + temp
If there is more than one call within an expression, the calls are executed in left-to-right order. Calls inside conditional operators occur whether or not the condition is true. For example, the statement:
x := sqrt(y) if c else z
is equivalent to:
call temp := sqrt(y);
x := temp if c else z
Parentheses are not used when calling an action with no parameters. For example, if we have:
action next returns (val:t) = {
current := current + 1;
val := current
}
then we would write:
x := y + next
The lack of parentheses introduces no ambiguity, since the action
next
is not a value and cannot itself be passed as an argument to
the function +
. An advantage of this convention is that we don’t have
to remember whether next
is an action or a variable, and we can
easily replace a variable by an action without modifying all references
to the variable.
Conditionals
Conditionals in Ivy are much as in any procedural programming language. For example, the following code clears the incoming links to node y if y is in the failed set:
if failed(y) {
link(X,y) := false
}
The curly brackets around the assignment are required. No parentheses are need around the condition. A conditional can have an associated else clause, for example:
if failed(y) {
link(X,y) := false
} else {
link(y,z) := true
}
Because brackets are required, there is no ambiguity as to which ‘if’ an ‘else’ belongs to.
The following syntax can be used to find a element of a type that satisfies some condition:
if some x:t. f(x) = y {
z := x + y
}
else {
z := y
}
Here, if there is any value x
of type t
such that f(x) = y
, then
such a value is assigned to x
and the assignment z := x + y
is
executed. If there is more than one such value, the choice is
non-deterministic. If there is no such value, the else
clause is
executed. The symbol x
is only in scope in the if
clause. It acts
like a local variable and is distinct from any x
declared in an
outer scope.
It is also possible to choose a value of x
minimizing some function
of x
. For example, we can find an element of a set s
with the least key like this:
if some x:t. s(x) minimizing key(x) {
...
}
This is logically equivalent to the following:
if some x:t. s(x) & ~exists Y. s(Y) & key(Y) < key(x) {
...
}
Besides being more concise, the minimizing
syntax can be more
efficiently compiled and is easier for Ivy to reason about (see the
decidability discussion). The keyword
maximizing
produces the same result with the direction of <
reversed.
Loops
Loops are discouraged in Ivy. Often, the effect of a loop can be
described using an assignment or an if some
conditional.
For example, instead of something like this:
for y in type {
link(x,y) := false
}
we can write this:
link(x,Y) := false
When a loop is needed, it can be written like this:
sum := 0;
i := x;
while i > 0
{
sum := sum + f(i);
i := i - 1
}
This loop computes the sum of f(i)
for i
in the range (0,x]
.
A loop can be decorated with a invariants, like this:
while i > 0
invariant sum >= 0
{
sum := sum + f(i);
i := i - 1
}
The invariant sum >= 0
is a special assertion that is applied
on each loop iteration, before the evaluation of the condition.
Invariants are helpful in proving properties of programs with loops.
In some situations we need to guarantee that a loop always terminates. We can do this
with ranking function that is supplied by the keyword decreases
, like this:
while i > 0
invariant sum >= 0
decreases i
{
sum := sum + f(i);
i := i - 1
}
The argument of decreases
is an expression whose value must decrease
with every loop iteration and such that the loop is never entered when
the expression is less than 0
.
Non-deterministic choice
The asterisk “*” can be used to represent non-deterministic choice in Ivy in two situations. First, on the right-hand side of an assignment:
x := *
This cause x to be assigned non-deterministically to any value of its type. We can use variables in non-deterministic assignments, so for example:
link(x,Y) := *
causes x to be linked to an arbitrary set of nodes.
Further, we can use an asterisk in a conditional to create a non-deterministic branch:
if * {
link(x,y) := true
} else {
link(x,z) := true
}
Non-deterministic choice also occurs when we create a local variable (see below). On entry to an action the values of return parameters are non-deterministically chosen.
Expressions
Expressions in Ivy are terms or formulas in first-order logic
with equality. Ivy provides the following built-in operators: ~
(not), &
(and), |
(or), ->
(implies), <->
(iff) =
(equality). There is also a built-in conditional operator X if C else
Y
that returns X
if the Boolean condition C
is true and Y
otherwise. The if/else operator binds most strongly, followed by
equality, not, and, or. The weakest binding operators are <->
and
->
, which have equal precedence.
The binary and ternary operators are left-associating (i.e., they bind
more strongly on the left). For example, x if p else y if q else z
is equivalent to (x if p else y) if q else z
and p -> q -> r
is
equivalent to (p -> q) -> r
. Warning: in the case of if/else and
->
, this is non-standard and is due to an error in the parser. This
will change in a future version of the language. In the interim it is
best to always parenthesize expressions with multiple uses if if/else
and ->
.
Expressions may also use logical quantifiers. For example, this formula says that there exists a node X such that for every node Y, X is linked to Y:
exists X. forall Y. link(X,Y)
In this case, the variables do not need type annotations, since we can infer that both X and Y are nodes. However, in some cases, annotations are needed. For example, this is a statement of the transitivity of equality:
forall X,Y,Z. X=Y & Y=Z -> X=Y
We can determine from this expression that X, Y and Z must all be of the same type, but not what that type is. This means we have to annotate at least one variable, like this:
forall X:node,Y,Z. X=Y & Y=Z -> X=Y
Assume, require and ensure
The primitive actions require
and ensure
allow us to write
specifications. These actions fail if the associated condition
is false. For example, suppose we wish the connect
action to handle
only the case where the node y is not in the failed set. We could
write
action connect(x:node, y:node) = {
require ~failed(y);
link(x,y) := true
}
If the condition ~failed(y)
is true, control passes through the
require
and this action behaves in the same way as the original. If
the condition ~failed(y)
is false, however, the require
action
fails. This means that whenever we use connect
we must prove that
the y argument is not in the failed set.
The ensure
action is similar, except it is the responsibility of the action
itself to ensure the truth of the formula. For example:
action increment(x:node) returns (y:node) = {
y := x + 1;
ensure y > x
}
The semantics of require
and ensure
are the same, except for the
assignment of blame when they fail. With require
, the onus of
guaranteeing the truth of the formula falls on the action’s caller,
while with ensure
it falls on the action itself. We will refer to
require
and ensure
actions collectively as assertions.
On the other hand, the assume
action does not allow control to pass
through if the associated condition is false. A typical application of
assume
is to make a temporary modeling assumption that we wish later
to remove. For example, this action non-deterministically chooses a
non-failed node and connects x to it:
action connect_non_failed(x:node) = {
y := *;
assume ~failed(y);
link(x,y) := true
}
Of course, if all the nodes are failed, this action cannot
terminate. There is some degree of risk in using assumptions when
modeling, since assumptions can eliminate behaviors in unexpected
ways. Ideally, a finished program will not contain any occurrence of
assume
.
In require
, ensure
and assume
actions, any free variables are treated
as universally quantified. For example, if we want to connect x to a
node that is not currently linked to any node, we could change the
assumption above to
assume ~link(y,Z)
Initialization
Normally, we expect a system to start in some well-defined state, or
at least for some specified conditions to hold initially. In Ivy, we use an
after init
declaration for this purpose. For example:
after init {
link(X,Y) := false
}
This provides an action that executes at initialization, before the
environment calls any other actions. Multiple after init
actions are
executed in the order in which they are declared in the program.
Local variables
The above example of a guarded command action assumes that y is a
declared program variable of type node
. We can also declare y locally
within the action, like this:
action connect_non_failed(x:node) = {
var y:node;
assume ~failed(y);
link(x,y) := true
}
This creates a fresh y that is in scope only within the action (or generally to the end of the most tightly enclosing pair of brackets). We don’t need a non-deterministic assignment to y since the value of y is already non-deterministic at the point where it is declared. We can create a local variable and assign it in the same statement, like this:
var y:node := x
or, if the type of y
can be inferred, just this:
var y := x
Modelling interleaving concurrency in Ivy
Actions in an Ivy program execute only in response to calls from the program’s environment. Ivy makes the synchronous hypothesis: when the environment calls an action, it waits for the action to complete before issuing another call. Put another way, Ivy actions appear to execute in zero time. At first blush, it might seem that this eliminates the possibility of concurrency. In fact, the synchronous hypothesis is intended to make the implementation of concurrent and distributed systems simpler. The key idea is that only the appearance of synchronicity is required. In practice actions can execute concurrently, provided that to an outside observer they appear to have executed sequentially.
For now, we will leave aside the question of how to enforce the synchronous hypothesis in practice. Instead, we will consider how to use the synchronous IVY language to model a distributed protocol at an abstract level using interleaving concurrency. In an interleaving model, processes take turns executing actions in isolation (that is, in apparently zero time) in a non-deterministic order.
An Ivy program exports a set of actions to its environment. Each of these actions can be used to model a single isolated step of a process. Since the environment is allowed to call these actions in an arbitrary order, the Ivy program can be used to model arbitrary interleaving of process actions.
An abstract interface model
The following is a very abstract model of an interface that establishes connections between clients and servers. Each server has a semaphore that is used to guarantee that at any time at most one client can be connected to the server.
#lang ivy1.5
type client
type server
relation link(X:client, Y:server)
relation semaphore(X:server)
after init {
semaphore(W) := true;
link(X,Y) := false
}
action connect(x:client,y:server) = {
require semaphore(y);
link(x,y) := true;
semaphore(y) := false
}
action disconnect(x:client,y:server) = {
require link(x,y);
link(x,y) := false;
semaphore(y) := true
}
export connect
export disconnect
This program declares two types client
and server
. The state of
the protocol model consists of two relations. The relation link
tells us which clients are connected to which servers, while
semaphore
tells us which servers have their semaphore “up”. The
link
and semaphore
components aren’t “real”. They are abstractions
that represent the interface user’s view of the system.
The program exports two actions to the environment: connect
and
disconnect
. The connect
actions creates a link from client x
to
server y
, putting the server’s semaphore down. Notice that connect
requires that server’s semaphore be up on entry. The disconnect
action removes a link and puts the semaphore up. The two export
declarations at the end tell us that the environment may call
connect
and disconnect
in arbitrary sequence, though it must obey
the stated requirements.
Safety and invariants
A program is safe if it cannot fail, so long as in the past all requirements of the environment have been satisfied (that is, it is safe if any failure of the program can be blamed on the environment).
There are various ways to use assertions to specify desired safety properties of a program. A simple one is to add a test action that asserts some property of the program state. In the client/server example above, we might specify that no two distinct clients can be connected to a single server using the following test action:
action test = {
ensure ~(X ~= Z & link(X,Y) & link(Z,Y))
}
export test
The assertion is implicitly universally quantified over (distinct)
clients X
and Z
and server Y
. To help Ivy to prove that this
assertion always holds, we can suggest facts that might be useful in
constructing an inductive invariant. For example:
invariant X = Z | ~link(X,Y) | ~link(Z,Y)
invariant link(X,Y) -> ~semaphore(Y)
Here, we state that no two clients are connected to the same server (which is just the property we want to prove) and additionally that when a client is connected to a server, its semaphore is down. These facts are inductive in the sense that they are initially true, and each of our three actions preserves them. Moreover, they are sufficient to guarantee that our test assertion is true. Thus, Ivy can use these invariants to prove safety of the program.
An invariant is asserted to hold at all times after initialization
when an exported action is not executing. In particular, the
invariant is not guaranteed to hold when the program calls back to the
environment (see import
below) or when it calls one of its own
actions.
Axioms and background theories
The built-in types and operators provided by Ivy are fairly
impoverished. We have only uninterpreted types, the Boolean type
bool
, enumerated types and the basic operators of first-order
logic. This is by design. By introducing richer data types, or
theories, we would quickly make our verification problems
undecidable, meaning we would sacrifice reliability of automated
verification. In practice, before introducing, say, the integers into
a model, we should make sure that the power of the integers is really
needed. It may be, for example, that all we require is a totally
ordered set.
Ivy allows us to introduce background theories in the form of logical axioms. This in turn allows us to avoid using unnecessarily powerful theories. As an example, consider defining an ordering relation over our node ID’s:
relation (I:id < J:id)
This is an example of an infix symbol. The symbol <
is no
different than other relational symbols, except that Ivy pre-defines
it as having infix syntax.
We can ensure that <
is a total order by writing axioms:
axiom X:id < Y & Y < Z -> X < Z
axiom ~(X:id < X)
axiom X:id < Y | X = Y | Y < X
These axioms say, respectively, that <
is
transitive,
anti-symmetric
and total. As in other
cases, the free variables are universally quantified.
Of course, axioms are assumptions and assumptions are dangerous. We want to make sure that our axioms are consistent, that is, that they have at least one model. The Ivy tool can be helpful in determining this.
Overloaded operators.
In Ivy the equality operator is overloaded in the sense that it
applies to any pair of arguments so long as they are of the same
type. One way to think of this is that there is really a distinct
equality operator pre-declared for each type, but that we use =
as a
short-hand for all of them. It is useful to be able to declare other
such overloaded operators to avoid, for example, having to invent a
new “less than” symbol for every ordered type, or adding type
annotations to operators.
Ivy provides for this in a limited way. Certain symbols, such as <
,
+
and 0
are always overloaded. This allows use the same symbol
with different type signatures disambiguate these uses based on type
inference.
To make type inference stronger, the overloaded operators also come
with type constraints. In functional language terms, <
has type
alpha * alpha -> bool
and +
has type alpha * alpha -> alpha
.
Numerals
Numerals are a special case of overloaded symbols. A numeral is any
symbol beginning with a digit, for example 0
, or 0xdeadbeef
. The
types of numerals are inferred from context. For example, if x
has
type foo
, then in the expression x+1
, the numeral 1
is inferred
to have type foo
.
Numerals are special symbols in the sense that they do not have to be
explicitly declared. However, Ivy gives them no special
interpretation. Ivy does not even assume that distinct numerals have
distinct values. This means that 0 = 2
is not necessarily false. In
fact, this equation might be true in a type representing the integers
mod 2.
Section [Interpretations] describes how to give concrete
interpretations to Ivy types, so that symbols like +
and 0
have
specific meanings.
Quoted symbols
A quoted symbol is a possibly-empty sequence of characters enclosed in
double quote characters (and not containing a double quote character).
An example would be "ab$c"
. Quoted symbols are similar to numerals:
their type is inferred from context.
Modules
A module in Ivy is a group of declarations that can be instantiated. In this way it is similar to a template class in an object-oriented programming language. Besides defining classes of objects, modules can be used to capture a re-usable theory, or structure a modular proof.
Here is a simple example of a module representing an up/down counter with a test for zero:
module counter(t) = {
individual val : t
after init {
val := 0
}
action up = {
val := val + 1
}
action down = {
val := val - 1
}
action is_zero returns(z : bool) = {
z := (val = 0)
}
}
This module takes a single parameter t
which is the type of the
counter value val
. We can create an instance of the module like this:
type foo
instance c : counter(foo)
This creates an object c
with members c.val
, c.up
, c.down
and c.is_zero
.
Any Ivy declaration can be contained in a module. This includes axioms, invariants, instances and modules. As an example, here is a module representing a theory of partial orders:
module po(t,lt) = {
axiom lt(X:t,Y) & lt(Y,Z) -> lt(X,Z)
axiom ~(lt(X:t,Y) & lt(Y,X))
}
This module takes a type t
and a relation lt
over t
. It provides
axioms stating that lt
is transitive and antisymmetric. We might instantiate it
like this:
type foo
instantiate po(foo,<)
Since we didn’t give an object name, the members of po
are created
within the current object (which in this case is the global
object). Notice that we passed the overloaded infix symbol <
as a
parameter. Any symbol representing a type, function, relation, action
or object can be passed as a module parameter.
Like a class in an object-oriented programming language, a module can contain references to symbols declared outside the module. However, a declaration inside the module takes precedence. For example, consider this code:
relation r,s
module m = {
relation r
axiom s -> r
}
instance c : m
The axiom in c
is equivalent to:
axiom s -> c.r
That is, the local declaration of r
shadows the global one.
Singleton objects
We can create a module with just one instance like this:
object foo = {
relation bit
after init {
bit := false
}
action flip = {
bit := ~bit
}
}
This creates a single object foo
with members foo.bit
and
foo.flip
, exactly as if we had created a module and instantiated it
once.
This
The special symbol this
refers to the innermost surrounding object
or module. For example:
object foo = {
relation bit
after init {
this.bit := false
}
}
Here this.bit
refers to foo.bit
. In the outermost scope this
refers to the root object, which contains the entire program.
Type objects
A type may have the same name as an object. This makes it possible to define types with traits. For example:
object num = {
type this
function next(N:this) : this
function plus(X:this,Y:this) : this
}
This declares a type num
and also a function num.next
from type
num
to type num
and am function plus
that takes two arguments of type num
and returns a num
. The function num.next
can be applied using
a special syntax. That is, if x
is of type num
, then the expression
x.next
is a shorthand for num.next(x)
. Similarly, x.plus(y)
is a shorthand
for num.next(x,y)
. Actions can similary be traits of types. For example:
object num = {
type this
action plus(x:this,y:this) returns (z:this) = {
z := x + y;
}
}
In this case, x.plus(y)
is a shorthand for the action call num.plus(x,y)
.
Parameterized objects
An array of instances of the same module can be created like this:
type foo
type bar
instance c(X:bar) : counter(foo)
This creates one instance c(X)
of module counter
for for every
element of type bar
. Since we haven’t said how many elements there
are in type bar
, we have effectively created a collection of objects
of arbitrary but fixed size.
If x
is of type bar
, we can treat c(x)
as we would any object,
for example:
call c(x).down;
if c(x).is_zero {
call c(x).up
}
The parameter X
can also be passed to the module being instantiated.
This is useful to create a collection of objects with unique identifiers.
For example:
type id_t
module thing(id) = {
action my_id returns (x:id_t) = {
x := id
}
}
instance c(X:id_t) : thing(X)
In this case, calling c(id).my_id
will return id
.
An alternative way to write the above would be:
type id_t
object c(id:id_t) = {
action my_id returns (x:id_t) = {
x := id
}
}
Notice that the object parameter is given as a logical constant rather than a place-holder. This constant can be referred to in the body of the object.
Types in Ivy are never parameterized. For example, if we write:
object foo(self:t) = {
type t
}
this creates a single type called foo.t
, not a collection of types
foo.t(self)
for all values of self
.
Monitors
While embedding assertions in code is a useful way to write specifications, there are good reasons to separate a specification from the object being specified. For example, this allows you to re-use specifications, to construct modular proofs and to refine specifications into implementations.
The Ivy language supports these goals using monitors. A monitor is
an ordinary object, except that its actions are synchronized with the
actions of other objects. For example, suppose that in the
client/server example above, we want to specify that callers to
connect
do not request a connection to a server whose semaphore is
down. We could express this property as a monitor like this:
object mon = {
action pre_connect(x:client,y:server) = {
require semaphore(y)
}
execute pre_connect before connect
}
The execute
declaration says that whenever connect
is called,
action pre_connect
should be executed first, with the same
parameters. If any caller tries to connect a client to a busy server,
the assertion will fail. Notice we used require
here, so the
blame for the failure is on the caller of connect
.
Monitors can also check the return values of actions. For example:
action post_incr(inp:t) returns(out:t) = {
ensure inp < out
}
execute post_incr after incr
Here, we have an action incr
that is supposed to increment a value,
and we specify that the output must be greater than the input. Here,
we use ensure
, so the blame for any failure falls in the
implementation of action incr
.
As a shorthand, we can write our monitor action like this:
after c.post(inp:t) returns(out:t) {
ensure inp < out
}
This creates a monitor action called post.after
that is executed after c.post
.
Similarly, we can write:
before connect(x:client,y:server) {
require semaphore(y)
}
If we drop the input or output parameters, they are inherited from the monitored action. For example:
after c.post {
ensure inp < out
}
This is a useful shorthand when the declaration of c.post
is nearby,
but should probably be avoided otherwise.
We can write this:
around foo {
stmts1
...
stmts2
}
as a shorthand for this:
before foo {
stmts1
}
after foo {
stmts2
}
At present, local variables declared in stmts1
cannot be referenced
in stmts2
, but the intention is to make this possible, to reduce the
need to use old
in after monitors.
Monitor state
Usually, monitors contain state components that allow them to remember
some of the history of past events. For example, here is a monitor specifying a
property of counter
objects. It requires that immediately after a call to up
,
the is_zero
action cannot return true:
module counter_prop(c) = {
relation was_up
after init {
was_up := false
}
after c.up {
was_up := true
}
after c.down {
was_up := false
}
after c.is_zero returns (z:bool) {
ensure was_up -> ~z
}
}
This module is parameterized on c
, the counter being specified. This
makes the specification re-usable. It has a single state component
was_up
that remembers whether the last counting action was
up
. This is accomplished by the actions up
and down
that
synchronize with actions of counter c
(in this case it doesn’t make
any difference whether it is before or after). The action is_zero
executes after calls for the counter’s is_zero
action and asserts a
fact about the return value: if the last action was up
the result
cannot be true.
Action implementations
It is often useful to separate the declaration of an action from its implementation. We can declare an action like this:
action incr(x:t) returns(y:t)
and then give its implementation separately like this:
implement incr {
y := x + 1
}
Initializers
As noted above, an initializer is a special action that is executed once initially, before any exported actions are called. For example:
var bit : bool
after init {
bit := false
}
This behaves like a monitor after a special internal action called
init
. Initializers are executed once in the order they are declared.
Initializers may call other actions. For example, suppose we have a
module collection
representing a set of objects that is initially
empty. If we wanted a set that initially contained the value zero, we
could use an initializer like this:
type t
object foo = {
instance myset : collection(t)
after init {
call myset.add(0)
}
}
This action is called exactly once after myset
is initialized.
Parameterized objects can also have initializers. For example, we may want to have a collection of objects that each contain a bit, where initially only the bit of object 0 is true:
type t
object foo(self:t) = {
var bit : bool
after init {
bit := (self = 0)
}
}
There are some restrictions in initializers of parameterized objects, however. These are:
-
Conditions of
if
statements may not refer to the parameter, and -
In assignments, the left-hand side must contain the parameter if the right-hand side does.
For example, this initializer would not be legal:
type t
var bit : bool
object foo(self:t) = {
after init {
bit := (self = 0)
}
}
This is because the component bit
being assigned is not
parameterized. This means it is in effect being assigned a different
value for each value of self
. The restrictions guarantee that the
result of the initializer does not depend on the order in which it is
called for different parameter values.
Definitions
We can define the value of a previously declared function like this:
function square(X:t):t
definition square(X) = X * X
Notice we don’t have to give the type of X in the definition, since it
can be inferred from the type of square
. Logically, the definition
is equivalent to writing:
axiom square(X) = X * X
However, definitions have several advantages. Primarily, they are safer,
since definitions are guaranteed to be consistent. In addition they can be
computed. If we use an axiom, the only way that Ivy can compile the
function square
is to compile a table of squares. On the other hand,
Ivy can compile the definition of square
into a procedure.
Ivy doesn’t (currently) allow recursive definitions. So, for example, this is not allowed:
definition factorial(X) = X * factorial(X-1) if X > 0 else 1
Macro definitions
A macro is a definition that is only “unfolded” when it is used. For
example, let’s say we want to define a predicate rng
that is true of
all the elements in range of function f
. We could write it like
this:
definition rng(X) = exists Y. f(Y) = X
The corresponding axiom might be problematic, however. Writing it out with explicit quantifiers, we have:
axiom forall X. (rng(X) <-> exists Y. f(Y) = X)
This formula has an alternation of quantifiers that might result in
verification conditions that Ivy can’t decide (see the
decidability discussion). Suppose though, that we only need
to know the truth value of rng
for some specific arguments. We can instead
write the definition like this:
definition rng(x:t) = exists Y. f(Y) = x
Notice that the argument of rng
is a constant x
, not a place-holder
X
. This definition acts like a macro (or an axiom schema) that can be
instantiated for specific values of x
. So, for example, if we have an assertion
to prove like this:
ensure rng(y)
Ivy will instantiate the definition like this:
axiom rng(y) <-> exists Y. f(Y) = y
In fact, all instances of the macro will be alternation-free, since Ivy guarantees to instantiate the macro using only ground terms for the constant arguments. A macro can have both variables and constants as arguments. For example, consider this definition:
definition g(x,Y) = x < Y & exists Z. Z < x
Given a term g(f(a),b)
, Ivy will instantiate this macro as:
axiom g(f(a),Y) = f(a) < Y & exists Z. Z < f(a)
Choice functions
Suppose we want to define a function finv
that is
the inverse of function f
. We can write the definition like this:
definition finv(X) = some Y. f(Y) = X in Y
This special form of definition says that finv(X)
is Y
for some
Y
such that f(Y) = X
. If there is no such Y
, finv(X)
is left
undefined. The corresponding axiom is:
axiom forall X. ((exists Y. f(Y) = X) -> f(finv(X)) = X)
With this definition, finv
is a function, but it isn’t fully
specified. If a given element Y
has two inverses, finv
will yield
one of them. This isn’t a non-deterministic choice, however. Since f
is a function, it will always yield the same inverse of any given
value Y
.
If we want to specify the value of finv
in case there is no inverse,
we can write the definition like this:
definition finv(X) = some Y. f(Y) = X in Y else 0
The else
part gives us this additional axiom:
axiom forall X. ((~exists Y. f(Y) = X) -> finv(X) = 0)
Notice that this axiom contains a quantifier alternation. If this is a problem, we could use a macro instead:
definition finv(x) = some Y. f(Y) = x in Y else 0
The axiom we get is
axiom (~exists Y. f(Y) = x) -> finv(x) = 0)
which is alternation-free.
Interpreted types and theories
The normal way of using Ivy is to declare uninterpreted types and to give the necessary axioms over those types to prove desired properties of a system. However, it is also possible in Ivy to associate types with sorts that are interpreted in the underlying theorem prover.
For example:
type idx
interpret idx -> int
This says that Ivy type idx
should be interpreted using sort int
of the theorem prover. This does not mean that idx
is equated with
the integers. If we also interpret type num
with int
, we still
cannot compare values of type idx
and type num
. In effect, these
two types are treated as distinct copies of the integers.
When we declare idx
as int
, certain overloaded functions and
relations on idx
are also automatically interpreted by the
corresponding operators on integers, as are numerals of that
type. So, for example, +
is interpreted as addition and <
as
‘less than’ in the theory of integers. Numerals are given their normal
interpretations in the theory, so 0:idx = 1:idx
would be false.
Concrete sorts that are currently available for interpreting Ivy types are:
- int: the integers
- nat: the non-negative integers
- {X..Y}: the subrange of integers from X to Y inclusive
- {a,b,c}: an enumerated type
- bv[N]: bit vectors of length N, where N > 0
Arithmetic on nat
is saturating. That is, any operation that would yield
a neagtive number instead gives zero.
An arbitrary function or relation symbol can be interpreted. This is useful for symbols of the theory that have no pre-defined overloaded symbol in Ivy. For example:
type t
type s
function extract_lo(X:t) : s
interpret t -> bv[8]
interpret s -> bv[4]
interpret extract_lo -> bfe[3][0]
Here bfe[3][0]
is the bit field extraction operator the takes the
low order 4 bits of a bit vector.
Parameters
A parameter is a value supplied by the environment before initialization. A parameter is declared like this:
parameter p : t
where p is the parameter name and t is the type. Parameters may be declared anywhere in the object hierarchy. Except for the fact that it is initialized by the environment, a parameter is identical to an individual. The manner in which parameters are supplied is dependent on the compiler. For example, if a program is compiled to an executable file, the parameter values are supplied on the command line. If it is compiled to a class in C++, parameters are supplied as arguments to the constructor. In either case, the order of parameters is the same as their order of declaration in the program.
Imported actions
An imported action is an action whose implementation is provided by the environment. For example:
action callback(x:nat) returns (y:nat)
import callback
or simply:
import action callback(x:nat) returns (y:nat)
Like any action, the imported action may be given preconditions and postconditions. For example:
before callback {
require x > 0;
}
after callback {
ensure y = x - 1;
}
The require
in this case is guarantee for the program and an
assumption for the environment. Similarly, the ensure
is an
assumption for the program and a guarantee for the environment. The
environment is assumed to be non-interfering, that is, Ivy assumes
that the call to callback
has no visible side effect on the program.
An imported action may not be implemented by the program.
Assume/guarantee reasoning
Ivy doesn’t require us to prove all at once that a program is safe. Instead, we can break the proof down into smaller proofs using the assume/guarantee approach.
For example, suppose we have the following program with two objects:
#lang ivy1.7
object num = {
type this
interpret this -> nat
function even(X:this) = (X / 2 * 2 = X)
function odd(X:this) = ~even(X)
}
object evens = {
var number : num
after init {
number := 0
}
action step = {
call odds.put(number + 1)
}
action put(n:num) = {
number := n;
}
invariant number.even
}
object odds = {
var number : num
after init {
number := 1
}
action step = {
call evens.put(number + 1)
}
action put(n:num) = {
number := n;
}
invariant number.odd
}
export evens.step
export odds.step
Each object stores a natural number when its put
action is called
and sends this number plus one to the other object when its step
action is called by the environment. We want to prove the invariants
that evens.number
remains even, and odds.number
remains odd.
Moreover, we would like to prove these invariants by reasoning about
evens
and odds
in isolation. To do this, we use an
assume/guarantee specification.
Here is the evens
object with separate specification and implementation:
isolate evens = {
action step
action put(n:num)
specification {
before put {
require n.even
}
}
implementation {
var number : num
after init {
number := 0
}
implement step {
call odds.put(number + 1)
}
implement put(n:num) {
number := n;
}
invariant number.even
}
}
with odds,num
An isolate is a special kind of object that acts as a unit of
verification. It generally has three parts. It starts with a
declaration of the interface of the object. This usually consists of
types, functions and actions that are provided by the object. The next
section is the specification. This usually consists of variables,
properties and monitors that are visible outside the
isolate. Finally, we have the implementation. It usually consists
of variables, function definitions and action implementations that are
hidden. An isolate may depend on the visible parts of other objects.
This is declares using the keyword with
. In this case evens
depends on odds
and nat
.
The isolate for odds
is similar:
isolate odds = {
action step
action put(n:num)
specification {
before put {
require n.odd
}
}
implementation {
var number : num
after init {
number := 1
}
implement step {
call evens.put(number + 1)
}
implement put {
number := n;
}
invariant number.odd
}
}
with evens,num
Effectively, this breaks the proof that the two assertions always hold
into two parts. In the first part, we assume the object evens
gets
correct inputs and prove that it always sends correct outputs to
odds
. In the second part, we assume the object odds
gets correct
inputs and prove that it always sends correct outputs to evens
.
This argument seems circular on the surface. It isn’t, though, because when we prove one of the assertion holds, we are only assuming that the other assertion has always held in the past. So what we’re really proving is that neither of the two objects is the first to break the rules, and so the rules always hold.
In the first isolate, we prove the assertion that evens
guarantees. We do this using the visible part of odds
, but we forget
about the hidden state of the odds
object (in particular, the
variable odss.number
). To model the call to evens.put
in the
hidden part of odds
, Ivy exports evens.put
to the environment.
The requires
statement in the specification even.put
thus becomes a
guarantee of the environment. That is, each isolate only guarantees those assertions for
which it receives the blame. The rest are assumed.
When we verifiy isolate evens
, the result is as if we had actually entered the following program:
#lang ivy1.7
object nat {
...
}
object evens = {
var number : nat
after init {
number := 0
}
action step = {
call odds.put(number + 1)
}
action put(n:nat) = {
require even(nat)
number := n;
}
invariant number.even
}
object odds = {
action put(n:nat) = {
require odd(nat)
}
}
export evens.step
export evens.put
Notice the implementation of odds.put
has been eliminated, and what
remains is just the assertion that the input value is odd (Ivy
verifies that the eliminated side effect of odds.put
is in fact
invisible to evens
). The assertion that inputs to evens
are even
has in effect become an assumption. We can prove this isolate is safe
by showing that even.number
is invariantly even, which means that
odds.put
is always called with an odd number.
The other isolate, odds
, looks like this:
#lang ivy1.7
object nat {
...
}
object evens = {
action put(n:nat) = {
require even(nat)
}
}
object odds = {
var number : nat
after init {
number = 1
}
action step = {
call evens.put(number + 1)
}
action put(n:nat) = {
require odd(nat)
number := n;
}
invariant number.odd
}
export odds.step
export odds.put
If both of these isolates are safe, then we know that neither assertion is the first to fail, so the original program is safe.
The general rule is that a require
assertion is a guarantee for the
calling isolate and and assumption for the called isolate, while an ensure
action is
a guarantee for the called isolate and an assumption for the callinf isolate. When we
verify an isolate, we check only those assertions that are gurantees
for actions in the isolate.
Changes between Ivy language versions
New in version 1.2
- Keywords: returns, mixin, before, after, isolate, with, export, import, delegate, include
Deprecated in version 1.2
- Keywords: state, set, null, match
New in version 1.5
- Keywords: function, class, object, method, execute, destructor, some, maximizing, maximizing, private, implement, using, property, while, invariant, struct, definition, ghost, alias, trusted, this, var, attribute, scenario, proof, named, fresh
New in version 1.6
- Keywords: variant, of, globally, eventually, temporal
New in version 1.7
- Keywords: decreases, specification, implementation, require, ensure, around, parameter
- The
iterable
module is added the standard library fileorder
. This makes it possible to declare a type that is finite and iterable, and whose size is a parameter. - Due to an error, the
->
and<->
operators changed from right-associating to left-associating.
Deprecated in version 1.7
- The init declation (only ‘after init’ is now supported for initialization)