Verification of stacks and LIFO properties
Stacks are a quite common data type, and system correctness may often depends on LIFO properties;
for instance, absence of data races in multi-threaded programs can be guaranteed by nested locks, whose implementation follows
the LIFO strategy.
The specifications are based on the following basic event types:
push(val)
: valueval
has been pushed on the stack;pop(val)
: valueval
has been popped from the stack;size(s)
:s
has been computed as the size of the stack.
Single stack with push and pop
We start by considering the simple problem of verifying a single stack with just the push
and pop
operations.
// stack1: single stack with push and pop
Main = Stack!;
Stack = { let val; push(val) Stack pop(val) }*;
The specification assumes that the stack is initially empty; In Stack
, for every event matching push(val)
a corresponding subsequent event
matching pop(val)
is expected; therefore push
and pop
events must be balanced. The prefix closure operator in Main
allows the generated monitor to emit the True verdict even when the stack is not empty after the last event of the trace.
The *
operator allows a more compact version for the definition of Stack
; without the Kleene start we would need the following version:
Stack = { let val; push(val) Stack pop(val) Stack }?;
Exercise
Show that the following variation of stack1 is not correct. (see the solution)
Main = Stack!;
Stack = { let val; (push(val) Stack pop(val))* };
Single stack with push, pop and size
Let us now consider a more elaborated example where the specification has to verify also the size
operation;
to manage this, we need to introduce the state variable s
with a generic specification, to track the size of the stack.
If we follow the pattern of specification stack1 we get a rather compact specification which is, however, not so readable.
// stack2: single stack with push, pop and size
Main = Stack<0>!;
Stack<s> = size(s)* { let val; push(val) Stack<s+1> pop(val) size(s)* }*;
The definition of Main
is pretty clear: now Stack
is generic and, therefore, has to be applied
to the value 0, since the specification assumes that the stack is initially empty.
The part that concerns us is the definition of Stack<s>
; perhaps, a more readable definition for Stack<s>
is
Stack<s> = size(s)* { let val; push(val) Stack<s+1> pop(val) Stack<s> }?;
The version above makes more explicit how the state variable s
changes in reaction to the events; this is made even more evident in the
following version:
Stack<s> = size(s) Stack<s> \/ { let val; push(val) Stack<s+1> pop(val) Stack<s> }?;
Approach ‘by decomposition’
Even the last version of Stack
above suffers from a problem: the pattern does not scale well
when new kinds of events have to be verified; let us consider, for instance, to extend
the specification to check also the top
operation (see the exercise below).
In this case, a pattern based on an approach ‘by decomposition’ helps to solve this problem:
with the use of the filter and intersection operators we can divide the verification problem
into simpler sub-problems.
// stack2: single stack with push, pop and size
// approach 'by decomposition'
Main = ((not_size>>Stack) /\ Size<0>)!;
Stack = { let val; push(val) Stack pop(val) }*;
Size<s> = (size(s) Size<s> \/ pop Size<s-1> \/ push Size<s+1>)?;
Stack
now coincides with the definition given for stack1 and concerns verification of the events matching
push
or pop
only; for this reason, the filter with event type not_size
is needed in Main
.
The new generic Size
verifies events of type size
, but also events of type push
and pop
must be involved,
because the size of the stack depends on push
and pop
; however, in this case tracking the pushed/popped values
is not needed.
The specification requires the definition of the following derived event types:
push matches push(_);
pop matches pop(_);
not_size not matches size(_);
Despite its verbosity, this pattern favors extension of the specification (see the exercise below).
Exercise
Extend the ‘by decomposition’ version of specification stack2 to verify also the top operation with the corresponding basic event type: (see the solution)
top(val)
: valueval
has been computed as the top of the stack.
Hint: exploit the following facts:
- events of type
size
andtop
are independent; - events of type
top
depend from events of typepush
andpop
.
Multiple stacks with push, pop and size
// stacks: multiple stacks with push, pop and size, approach 'by decomposition'
// event types needed for the approach 'by decomposition'
push matches push(_,_);
pop matches pop(_,_);
not_size not matches size(_,_);
Main = {let id; new(id) (Main | (Single<id> free(id)))}?;
Single<id> = ((not_size>>Stack<id>)/\Size<id,0>)!;
Stack<id> = { let val; push(id,val) Stack<id> pop(id,val) }*;
Size<id,s> = (size(id,s) Size<id,s> \/ pop Size<id,s-1> \/ push Size<id,s+1>)?;