The Verification Mental Model
How verification differs from testing: proving properties over symbolic inputs, not just the ones you test.
The Fundamental Shift
Testing and verification answer different questions:
| Approach | Question Answered |
|---|---|
| Testing | "Do these specific test cases pass?" |
| Verification | "Does this property hold for every input allowed by the specification?" |
When you write a test for a function add(a, b), you check specific values: maybe add(1, 2) == 3 and add(0, 0) == 0. If both pass, you gain confidence but no guarantee. The function might fail for add(MAX_INT, 1) or any other untested input.
When you verify the same function with an ensures contract such as ensures result == a + b, the verifier proves this property for all values of a and b allowed by the function's types, preconditions, and arithmetic mode. Not ten cases, not a million cases, but the whole symbolic input space.
Contracts as Specifications
A contract is a specification, not an assertion. It describes what must be true, and the verifier proves it:
public func increment(n : Nat) : async Nat
entry_requires n < 1000;
requires n < 1000;
ensures result == n + 1;
{
n + 1
}
This contract says: "For any natural number n less than 1000, the result
equals n + 1." The entry_requires guard is the runtime entry check for an
exported actor method, and the logical requires is the verifier precondition. The verifier does not run the function with sample inputs. It analyzes the code symbolically and proves the postcondition follows from the implementation for all valid inputs.
Symbolic Reasoning
The verifier treats inputs as symbolic values rather than concrete numbers. When verifying increment, it doesn't substitute n = 5 or n = 999. Instead, it reasons about a symbolic n that represents any natural number satisfying n < 1000.
This is why a single verification covers infinite cases:
// Symbolic reasoning: one verification covers infinite cases
// The verifier proves abs() returns non-negative for ALL integers in range
persistent actor {
public func abs(x : Int) : async Int
entry_requires x >= -1000 and x <= 1000;
requires x >= -1000 and x <= 1000;
ensures result >= 0;
ensures result == x or result == -x;
{
if (x < 0) { -x } else { x }
};
}
The verifier proves result >= 0 holds for all x in [-1000, 1000]. It doesn't enumerate 2001 cases. It symbolically determines that if (x < 0) -x else x always produces a non-negative value.
Loop Invariants: Inductive Proofs
Loops present a challenge: they can execute any number of times. Testing might run a loop 10 or 100 times, but verification must cover unbounded iterations.
The solution is inductive reasoning via loop invariants:
// Loop invariant: inductive proof covers all iterations
// One proof covers n=0, n=100, n=1000000 - any value
persistent actor {
public func sumTo(n : Nat) : async Nat
ensures 2 * result == n * (n + 1);
{
var i : Nat = 0;
var sum : Nat = 0;
while (i < n) {
loop:invariant i <= n;
loop:invariant 2 * sum == i * (i + 1);
i := i + 1;
sum := sum + i;
};
sum
};
}
The verifier proves three things:
- Initialization: The invariant holds before the loop starts (
i = 0,sum = 0, and2 * 0 == 0 * 1) - Preservation: If the invariant holds at iteration start and the condition is true, it holds after the body
- Conclusion: When the loop exits, the invariant plus the negated condition imply the postcondition
This inductive proof covers ALL possible values of n. Whether n is 0, 100, or 1 million, the same proof applies.
Universal Quantification
The forall expression makes "for all" reasoning explicit:
// Universal quantification: property holds for ALL values
// The forall proves every valid index has a non-negative element
persistent actor {
public func checkPositive() : async () {
let arr : [Nat] = [1, 2, 3, 4, 5];
let size : Nat = arr.size();
ghost {
// For ALL indices i, if i < size, then arr[i] >= 0
assert forall<Nat>(pure func (i : Nat) : Bool =
(i < size) ==> (arr[i] >= 0));
};
};
}
The assertion forall<Nat>(pure func (i) = i < size ==> arr[i] >= 0) says: "For every natural number i, if i is a valid index, then arr[i] is non-negative."
The guard i < size bounds the property to valid indices. Without it, the verifier would try to prove the property for indices beyond the array, which would fail.
Counterexamples: When Proofs Fail
When verification fails, --counterexample asks the verifier to print a counterexample model: concrete values that violate the contract when a model is available.
// Counterexample: concrete values that violate the contract
// The postcondition claims result == a + b + 1, but implementation returns a + b
persistent actor {
public func add(a : Int, b : Int) : async Int
ensures result == a + b + 1; // Wrong: off by one
{
a + b // Returns a + b, not a + b + 1
};
}
Running sector9 --verify --counterexample on this file produces:
Counterexample summary
- return: a=0, b=0, $Res=0
The counterexample shows that when a=0 and b=0, the function returns 0, but the postcondition expects 1. This is not just a sampled test failure; it is a concrete model showing why the proof obligation cannot be established. Generated names such as $Res mean "the returned result" in the Viper model.
The Contract as Interface
Contracts enable modular reasoning. When you call a function, you only need to know its contract, not its implementation:
// Caller only sees the contract:
// requires amount <= balance
// ensures balance == old(balance) - amount;
public func pay(amount : Nat) : async () {
let paid =
try {
await withdraw(amount); // Must satisfy: amount <= balance
true
} catch (_) {
false
};
if (paid) {
// Success path: balance decreased by exactly amount
}
}
The caller must establish the precondition. In return, the caller can assume the postcondition on the success path. If the awaited call is caught, the catch path must provide its own safe fallback facts. The verifier checks both sides independently, enabling local reasoning about each function.
Invariants: Global Properties
Actor invariants express properties that hold across ALL public method calls:
// Actor invariant: must hold at entry and exit of every public method
// The balance can never go negative
persistent actor {
var balance : Int = 0;
invariant balance >= 0;
public func deposit(amount : Nat) : async () modifies balance
ensures balance == old(balance) + amount;
{
balance += amount;
};
public func withdraw(amount : Nat) : async Bool modifies balance
ensures result == (old(balance) >= amount);
ensures result ==> balance == old(balance) - amount;
ensures (not result) ==> balance == old(balance);
{
if (balance >= amount) {
balance -= amount;
true
} else {
false
}
};
}
The invariant balance >= 0 must hold:
- After initialization
- At entry and exit of every public method
- Before every
await
Every method that modifies balance must preserve this property. The verifier checks this automatically for all methods.
What Verification Guarantees
When Sector9 reports ✓ verification succeeded, it means the verifier established:
- All
ensuresclauses hold whenever the function returns, for any input satisfyingrequires - All
invariantdeclarations are preserved by every method - All
assertstatements are valid at their locations - All loop invariants are inductive and maintained
This is a mathematical proof relative to the written specification, verifier model, and trusted assumptions. The checked properties hold for modeled executions, not just the ones you happened to test.
What Verification Does Not Guarantee
Verification proves correctness with respect to the specification. It does not guarantee:
- The specification is what you intended (you might specify the wrong property)
- Termination (loops might run forever; Sector9 proves partial correctness)
- Runtime behavior outside the model (memory limits, timing, environment failures)
- Cross-canister schedule properties in the Viper lane; use TLA+ when the claim depends on message ordering or temporal behavior
- Properties you didn't specify
A function with no contracts verifies trivially. Verification is only as strong as your specifications.
Thinking in Contracts
To use verification effectively, adopt this mental model:
- Specify first: What property should hold? Write the
ensuresbefore the implementation. - Think universally: Ask "does this hold for all allowed inputs?" not "does this work for my test cases?"
- Trust the contracts: When calling a function, rely on its postcondition. Don't peek at the implementation.
- Strengthen when needed: If verification fails, either fix the code or add preconditions to rule out invalid cases.
- Use invariants for state: Actor invariants capture properties that span multiple operations.
Summary
- Verification proves properties for symbolic inputs allowed by the specification, not specific test cases
- Contracts (
requires,ensures) are specifications the verifier proves mathematically - The verifier uses symbolic reasoning to cover infinite cases simultaneously
- Loop invariants enable inductive proofs over unbounded iterations
- Counterexamples show concrete values when proofs fail
- Contracts enable modular reasoning: analyze each function independently
- Verification establishes correctness with respect to your specification and verifier model