Termination

Having discussed loops, the next thing to address is termination. Here we look at the termination of loops and recursive functions. In general, proving termination is undecidable. However, for functions we write in practice, it is usually not hard to show termination. By default, Gobra checks for partial correctness, i.e., correctness with respect to specifications if the function terminates. For total correctness, both termination and correctness with respect to specifications must be proven. To prove termination, Gobra uses termination measures, i.e., expressions that are strictly decreasing and bounded from below.

Partial correctness

Let us explore the concept of partial correctness further. The function infiniteZero contains an infinite loop, so it will not terminate. It is still partially correct with respect to its contract since the postcondition false must only be proven to hold when it returns, which it does not.

ensures false
func infiniteZero() res {
    for {}
    return 0
}

func main() {
    r := infiniteZero()
    assert r == 1
}

When the function main is verified, it is unknown whether infiniteZero terminates. However, the contract guarantees that when infiniteZero returns, its postcondition holds. Assuming it terminates, we can assert an arbitrary property, such as r == 1. This is fine since, if we run the program, this statement will never be reached.

Specifying termination measures with decreases

We can instruct Gobra to check for termination by adding the decreases keyword to the specification of a function or a loop. It must come right before the function/loop after any preconditions/postconditions/invariants. Sometimes, this suffices and a termination measure can be automatically inferred. For example, for simple functions like Abs that terminate immediately:

decreases
func Abs(x int) (res int) {
    if x < 0 {
       return -x 
    } else {
       return x
    }
}

In other cases, we need to provide a termination measure. A termination measure must be

  • lower-bounded
  • decrease

For functions, it must decrease for every recursive invocation. Respectively for loops, it must decrease for every iteration.

What it means to be lower-bounded and to be decreasing is defined by Gobra for different types. For example, integers i, j int are lower-bounded if i >= 0 and decreasing if i < j.

Termination of loops

A common case is that we iterate over an array with an increasing loop variable i, as seen in the function LinearSearch. We can easily construct a termination measure that decreases instead by subtracting i from the array's length.

// @ ensures found ==> 0 <= idx && idx < len(arr) && arr[idx] == target
// @ ensures !found ==> forall i int :: {arr[i]} 0 <= i && i < len(arr) ==> arr[i] != target
// @ decreases
func LinearSearch(arr [10]int, target int) (idx int, found bool) {
    // @ invariant 0 <= i && i <= len(arr)
    // @ invariant forall j int :: 0 <= j && j < i ==> arr[j] != target
    // @ decreases len(arr) - i
    for i := 0; i < len(arr); i += 1 {
        if arr[i] == target {
            return i, true
    }
    }
   return -1, false
}

// @ decreases
func client() {
    arr := [10]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
    i10, found := LinearSearch(arr, 10)
    // @ assert !found
    // @ assert forall i int :: 0 <= i && i < len(arr) ==> arr[i] != 10
    // @ assert arr[4] == 4
    i4, found4 := LinearSearch(arr, 4)
    // @ assert found4
    // @ assert arr[i4] == 4
}

We can also prove that client terminates since besides the calls to LinearSearch, which terminate, there is no diverging control flow. If we do not specify a termination measure decreases len(arr) - i for the loop, Gobra reports the error:

ERROR Function might not terminate. 
Required termination condition might not hold.

Termination of recursive functions

The function fibonacci recursively computes the n'th iterate of the Fibonacci sequence. As a termination measure, the parameter n is suitable since we recursively call fibonacci with smaller arguments and n is bounded from below.

// @ requires n >= 0
// @ decreases n
func fibonacci(n int) int {
    if n <= 1 {
        return n
    } else {
        return fibonacci(n-1) + fibonacci(n-2)
    }
}

Verification fails, if we mistype buggynacci(n-0) instead of buggynacci(n-1).

// @ requires n >= 0
// @ decreases n
func buggynacci(n int) int {
    if n == 0 || n == 1 {
        return n
    } else {
        return buggynacci(n-0) + buggynacci(n-2)
    }
}
ERROR Function might not terminate. 
Termination measure might not decrease or might not be bounded.

Assuming termination with decreaes _

If we annotate a function or method with decreases _, termination is assumed and not checked.

This can be dangerous. With the termination measure _ we can assume that buggynacci terminates. Then the following program verifies, where we want to prove that the client code terminates. In practice, buggynacci would never return and doSomethingVeryImportant would never be called.

//@ decreases
//@ func doSomethingVeryImportant()

// @ requires n >= 0
// @ decreases _
func buggynacci(n int) int {
    if n == 0 || n == 1 {
        return n
    } else {
        return buggynacci(n-0) + buggynacci(n-2)
    }
}

//@ decreases
func client() {
    x := buggynacci(n) 
    doSomethingVeryImportant()
}

The use of the wildcard measure can be justified when termination is proven by other means, for example leveraging a different tool. Another use case is gradual verification where it can be useful to assume termination of functions in a codebase, that have not yet been verified.

Let us look again at the binary search example. This time we introduce an implementation error: low is updated to low = mid instead of low = mid + 1. BinarySearchArr could loop forever, for example, with BinarySearchArr([7]int{0, 1, 1, 2, 3, 5, 8}, 8). Without decreases, the function would still verify since only partial correctness is checked.

// @ requires forall i, j int :: {arr[i], arr[j]} 0 <= i && i < j && j < len(arr) ==> arr[i] <= arr[j]
// @ ensures 0 <= idx && idx <= len(arr)
// @ ensures idx > 0 ==> arr[idx-1] < target
// @ ensures idx < len(arr) ==> target <= arr[idx]
// @ ensures found == (idx < len(arr) && arr[idx] == target)
// @ decreases  // <--- added for termination checking
func BinarySearchArr(arr [N]int, target int) (idx int, found bool) {
    low := 0
    high := len(arr)
    mid := 0
    // @ invariant 0 <= low && low <= high && high <= len(arr)
    // @ invariant 0 <= mid && mid < len(arr)
    // @ invariant low > 0 ==> arr[low-1] < target
    // @ invariant high < len(arr) ==> target <= arr[high]
    // @ decreases high - low   // <-- added for termination checking
    for low < high {
        mid = (low + high) / 2
        if arr[mid] < target {
            low = mid // <--- Implementation Error, should be low=mid+1
        } else {
            high = mid
        }
    }
    return low, low < len(arr) && arr[low] == target
}

ERROR Function might not terminate. 
Required termination condition might not hold.

We might be tempted to try decreases high or decreases len(arr)-low as termination measures for the loop. However, this is not enough since the termination measure must decrease in every iteration. In iterations where we update low, high does not decrease, and vice versa. The solution is to combine the two as decreases high - low. This measure coincides with the length of the subarray that has not been searched yet. If we change back to low=mid+1, the program verifies again.

Decreases tuple

Sometimes, it might be hard to find a single expression that decreases. In general, one can specify a list of expressions with decreases E1, E2, ..., EN.

A tuple termination measure decreases based on the lexicographical order of the tuple elements.

For BinarySearchArr we used decreases high - low. Alternatively, we could use:

decreases high, len(arr) - low

TODO Conditional termination with if clauses

TODO Mutual recursive functions