What is Gradual Verification?

main blog


Background

The following information is in part brought to you by my mentor Jenna Wise during my summer internship at Carnegie Mellon University.


What is verification?

Programmers encounter a lot of bugs during the software development. This is expected, we all know that whatever program we are developing it is most likely to throw an exception. One might naïvely believe that the more seasoned a programmer is, the less errors they'll encounter, no more syntax errors, no more null pointer exceptions! However, a seasoned programmer actually becomes so good at unintentionally developing bugs, that the program won't even tell you when there's an error, the program will seem superficially sound, when it in fact is not. The best way to solve this is by writing unit tests. Manually developing test cases for a given program, whether you want to take advantage of Testing Driven Development (TDD), or are forced to do so for your undergraduate intro Java course, can become not just annoying, but incredibly non-exhaustive. The subjective positives of unit tests, such as TDD, can still be taken advantage of outside of manually writing tests! This can be given through a specification, similar to black box testing. But get this, the specification isn't for the programmer, it's for a verifier that will take advantage of the specification, and check that your code follows that given specification. Of course, at least ignoring Machine Learning and Program Synthesis research, a computer can't intuitively parse human black box or glass box techniques. Developing these specifications is what's known as a Formal Method.


Formal verification

Specifications are written in some logic-based language that makes it easier to express properties the developer cares about. An example of this is Hoare logic. Here is a simple example for ensuring that the amount of money in someone's bank account can't become negative when withdrawing from that bank account.

int withdraw ( int amount )
  requires this.balance >= amount // P: PRECONDITION
  ensures this.balance >= 0 // Q: POSTCONDITION
{
  this.balance := this.balance - amount;
}

Annotated method (A-1)


Static verification

This is a verification technique that operates at compile-time. For example, the verifier can ensure that for every call to withdraw that adheres to its precondition withdraw's implementation will never produce a negative balance.

a := new Account();
a.balance := 100;
a.balance = 100 // Verifier can figure out `a.balance = 100` here
  P : a.balance >= 30 // The verifier knows this is true due to `a.balance = 100`
a.withdraw(30);
  Q : a.balance >= 0 // Then, the verifier learns only the postcondition

Client Program to Verify

Dynamic verification

Unlike static verification, DV operates at run time.

a := new Account();
a.balance := 100;

assert P: a.balance >= 30 // Check here
a.withdraw(30);
assert Q: a.balance >= 0 // Passes because a.balance = 70 

// The assert will pass, because a.balance = 70 here at run time. In static verification, we could only know the postcondition a.balance >= 0, so this assert would fail a static verifier.
assert a.balance = 70

Client Program to Verify (Dynamic)


Advanced Static Verification

This section quickly details at a more technical level (but still very accessible) the ideas behind static verification. It's not necessary to understand the Gradualness I'll talk about later.


Preconditions & Postconditions

We use pre- and postconditions to specify the behavior of methods and prove that the behavior we claim of the method is true.


Loop invariants

To make it possible for verifiers to work with loops, you ned to provide loop invariants. A loop invariant is an expression that holds upon entering a loop, and after every execution of the loop body (including the last one when the loop condition is false). It captures a property that is invariant, i.e. does not change, about every step of the loop.


The Frame Problem

For more details on the Frame Problem, read these lecture notes.


Consider this program written in Dafny:

class Cell {
  var contents: int;

  method Init()
    ensures contents == 1;
    {
      contents := 1;
    }

    method setContents(x: int)
      ensures contents == x;
    {
      contents := x;
    }
}

method TestCell() {
  var c1 := new Cell;
  var c2 := new Cell;
  c1.Init();
  c2.Init();

  c1.setContents(4);

  assert c1.contents == 4;
  assert c2.contents == 1;
}

This program will not verify correctly in Dafny, because we do not provide specifications that allow us to solve "The Frame Problem". In particular, TestCell() calls c1.setContents(4), the setContents method's specification makes no indication of what else may happen to the program state during the execution of c1.setContents(4) other than assigning c1.contents to 4. Therefore, the specification is too weak to say that c2.contents has not changed and is still equal to 1 after the call to c1.setContents(4).

In other words, the problem can be states as follows: When formally describing a change in a system, how do we specify what parts of the state of the system are not affected by that change?


A Solution to the Frame Problem

The possible solutions towards the frame problem are very in-depth and deserve their own blogposts. Therefore, I'll only give a brief explanation of what these solutions provide and a link for your own further reading. Similarly to the previous section, it is not necessary knowledge, but helpful nontheless.


Separation logic

This technique seeks to extend Hoare logic with an operator called the separating conjunction, usually noted as either * or &&.

Picture Semantics

These picture semantics are introduced by Peter O'Hearn to visually describe what's happening with the separating conjunction:

The indicated separating conjunction here is true of the pictured memory because the parts satisfy the conjuncts, as indicated in the second picture. The meaning of "x points to y and yet to nothing" is precisely disambiguated in the RAM description below the diagram: x and y denote values (10 and 42), x's value is an allocated memory address which contains y's value, but y's value is not allocated. The separating conjunction splits the heap/RAM, but it does not split the association of variables to values.

I should probably also note by now that the idea of a frame might be confusing. We are just trying to express an idea of determining a the memory region, accessed by a computation, to read that memory region for example. Thereafter, it's assured that the access to the rest of allocated memory is independent of this reading. This entire memory chunk is a frame.

We want to prove that an update of a location preserves content of another syntactically different location.

I'll spare you of unnecessary LaTeX, just know that framing in SL we have two cases, aliasing (pointing to the same memory location) and non-aliasing. For the former case, we want an updated version of the post-condition, whereas in the latter we can just include the same Q in the new post-condition. This Q refers to a Hoare logic {P} C {Q}, in which we, for example want to append a write: {b -> _} [b] = x {b -> x}.


Implicit Dynamic Frames

As stated by Smans et al. [2009], an issue with separation logic is that it cannot mention heap-dependent expressions from the host programming language, such as method calls familiar to many developers. Separation logic actually encounters several issues, particularly with recursive heap data structures. IDF is a variant of separation logic that supports heap-dependent expressions inside assertions. Framing in IDF reuses values of a program variable when it doesn't affect a modification of a separate program variable.

We say that we specify a dynamic frame f for a: f frames a. We also specify the effect of the update to b:b = x modifies g. If f and g are disjoint, then we can conclude that a and b are independent.

I am going to end the static verification section here, but it's important I note that I haven't talked about Predicates, which are a useful tool for specifying unbounded data structures on the heap.


Putting it all together: Almost there!

There are a few things I have to talk about before I get to the beauty of GV, and that is how static verifiers prove programs correct given specifications.

Without going into too much detail, a static verifier can generate logical formulas to be checked for satisfability, aka SAT. I've already mentioned Hoare logic, but now I want to mention symbolic execution, the reason I mentioned IDFs earlier.

Symbolic execution is a way of executing a program abstractly, so that one abstract execution covers multiple possible inputs of the program that share a particular execution path through the code. The execution treats these inputs symbolically, returning a result that is expressed in terms of symbolic constants that represent those input values.

I also wanted to quickly mention SMT solvers, which symbolic execution relies on. Z3 is a well known SMT solver to check logical formulas for satisfability.


Putting it all together: GRADUAL VERIFICATION

Now, finally, what the hell is Gradual Verification!?

Well we've established a couple of verification techniques, static and dynamic, and shown how static verification works. GV seeks to make use of static verification while supporting the incrementality of dynamic verification.

Essentially, Gradual Verification allows every assertion to either be

This is the beauty of gradual verification: the gradual guarantee. You've probably heard of gradual typing, which allows parts of a program to be dynamically types and others to be statically typed (i.e. Objective-C, Raku, TypeScript). This gradual guarantee is powerful because it allows the user to define as much of the specification as they want, without losing any precision. This states that verifiability and reducibility are monotone with respect to precision. If a program p1 is more precise than a program p2, and p1 is equivalent to p2 except in terms of contracts, and p1's contracts are more precise than p2's, then the guarantee ensures that p2 is still a valid program with the reduced precisions of p1.


Static gradual guarantee of verification

The dynamic gradual guarantee states that a valid program that takes a step still takes the same step if we reduce the precision of contracts.

Dynamic gradual guarantee of verification

This means that if a gradual program fails at runtime, then making its contracts more precise will not eliminate the error. In fact, doing so may only make the error manifest earlier during runtime or manifest statically. This is a fundamental property of gradual verification: a runtime verification error reveals a fundamental mismatch between the gradual program and the underlying verification discipline. Reducing the precision of specifications does not change the runtimes system's observable behavior for a verified program.


Impreciseness and Gradual Verification

So what does it mean for a formula to be imprecise exactly?

Motivated by the following example:

Note that the example is in C because the gradual verifier I work in is implemented on C0, apologies in advance.

When you read []^# in the code, these aren't part of the actual notation, just a way for me to reference like a footnote.


void withdrawFee(Account* account)
/*@ requires [acc(account->balance) &&]^1
    account->balance >= 5; @ */
/*@ ensures acc(account->balance) &&
    account->balance >= 0; @*/
[{
  account-> balance -= 5;
}]^2

void monthEnd(Account* account)
/*@ requires [? &&]^3
    acc(account->balance); @*/
/*@ ensures ? &&
    acc(account->balance) &&
    account->balance >= 0; @*/

{
  if (account->balance <= 100)
    [withdrawFee(account);]^4
}

As you've probably noticed, there is a ? whenever we don't want to fully specify a precondition/postcondition. This is what's called an imprecise formula. I won't get into the mathematics of it, but intuitively it tells the verifier that "Hey, I'm feeling kinda lazy right now, do you mind placing a runtime check here?".

There are several things happening to allow this process to work:

  1. Side-effects are reasoned about statically using Implicit Dynamic Frames (IDF), where access to memory locations is specified using acc(object->field).

  2. Program states are represented by the verifier as formulas in a resource logic. Static information at the end of withdrawFee:

acc(account->balance) &&
  account->balance >= 0 &&
  account->balance == old(account-balance) - 5
  1. ? allows the verifier to assume anything necessary to complete proofs. Assumption in order to satisfy the precondition of withdrawFee:
acc(account->balance) &&
  account->balance >= 5
  1. Wherever specifications are strengthened by the verifier, dynamic checks are inserted into the compiled pforam to ensure proper behavior at runtime:
assert(account->balance >= 5);

This example might have a few technical errors with the precondition and C syntax if you want to be pedantic, but overall it provides a good intuition.


For more technical details on the gradualness of specifications, checkout this video!


Shameless plug

Pretty neat, huh? Now, what are tools that actively do this?

Currently CMU has published a couple of papers on Gradual Verification (Bader et al. [2018] | Wise et al. [2020] | Gouni and Zimmerman [2021] ) and work is still in progress to make the technology more accessible. Until then, the ever increasing interest in verification systems —whether they be for the right moral reasons— hopefully can help the GV community grow to more applied research and engineering labs. :)


Recap

Static verification techniques do not provide good support for incrementality, and dynamic verification approaches support cannot provide static guarantees. Gradual Verification bridges this gap, supporting incrementality by allowing the user to specify a given program as much as they want, with a formal guarantee of verifiability.

Gradual Verification is a very new and active area of research which is currently seeking to extend its capabilities for more accessible and global use cases, for example the recent contribution of a gradual verification tool that supports recursive data structures Wise et al. [2020]. Watch out for updates to this blogpost or future post for a more in-depth analysis on current GV technology and the sort of stuff I'm working on, such as formal guarantees of program soundness in addition to the already existing verifiability guarantee!




main blog