The Loop Level: Scalar Loops¶
This tutorial explains how Frml checks and validates programs at the loop
level: everything from the contracts level, plus while
loops with invariants and termination measures. The loop level still has no
arrays and no built-in functions, so loops here only ever modify scalar
variables (Int, Bool, and String).
Two files implement the level:
frml/typechecker_loop.pytype-checks loop syntax.frml/prover_loop.pygenerates verification conditions for scalar loops.
Start with the intro, the scalar tutorial, and the contracts tutorial if you have not already read them; this page assumes the machinery described there.
What the loop level adds¶
- The parser already knows how to build a
whilestatement. - The level checker allows
whilebut still rejects arrays and built-ins. - The loop type checker adds one visitor:
visit_StmtWhile. - The loop prover adds the same visitor and the logic that turns a loop into obligations.
A minimal loop program:
fn main() -> Int
{
let i: Int = 0;
while i < 3
invariant 0 <= i
invariant i <= 3
decreases 3 - i
{
i = i + 1;
}
return i;
}
Loop-level programs are checked by selecting the level explicitly:
Type checking a loop¶
visit_StmtWhile checks four things:
- The loop condition must have type
Bool. - Every
invariantmust have typeBool. - The
decreasesexpression, when present, must have typeInt. - The loop body is type-checked in a fresh scope.
def visit_StmtWhile(self, stmt, ret):
self.check_expr(stmt.cond, expected=BOOL, allow_old=False, result_type=None)
self.in_spec = True
try:
for inv in stmt.invariants:
self.check_expr(inv, expected=BOOL, allow_old=False, result_type=None)
if stmt.decreases is not None:
self.check_expr(
stmt.decreases, expected=INT, allow_old=False, result_type=None
)
finally:
self.in_spec = False
self._push_scope()
for s in stmt.body:
self.check_stmt(s, ret)
self._pop_scope()
- The
in_specflag is set while checking the loop's specifications, just as the contracts checker does forrequires,ensures, anddecreasesclauses. It has no effect on scalar loops, but the loop checker keeps the pattern so thearray,builtin, andcompletelevels inherit the same behavior for array built-ins. - The body gets its own scope, so variables declared inside the loop do not leak out of it.
Why loops are the hard part¶
- A loop runs an unknown number of times.
- The prover cannot execute the loop symbolically "until it ends".
- It therefore reasons about the loop inductively, using invariants.
- A loop invariant is a fact that is true:
- before the first iteration, and
- after every iteration.
- If both hold, the invariant is also true immediately after the loop exits.
The prover generates one obligation for each of those two moments, plus a post-loop exit state for whatever code follows the loop.
The invariant tells the prover what is true at those moments, but it does not say what value each variable has. That is a real problem: a loop that assigns to a variable may run any number of times, so there is no single value the prover can hand to the rest of the proof. The prover's answer is to deliberately forget those values — to havoc them — as the next section explains.
Havoc: forgetting what a loop changes¶
To havoc a variable is to replace its value with a fresh, unknown symbol, deliberately forgetting everything the prover previously knew about it. This is how the prover stays sound when a value is genuinely unknown: rather than pretending to know the final value of an assigned variable, it admits that it does not.
Why this is necessary and sound:
- A loop may run any number of times.
- We cannot know the exact final value of an assigned variable.
- We only know what the invariant tells us about it.
- The invariant is the only bridge from before the loop to after it.
Before checking the inductive step, the prover scans the loop body and applies havoc to every scalar variable the body assigns:
- A
ScalarWriterCollectorwalks the body and records every name that appears on the left of an assignment, including assignments inside nestedifandwhilestatements. - It deliberately ignores
letdeclarations inside the loop body: those names are local to a single iteration and do not exist before the loop. - Each recorded name that already exists in the state is replaced with a fresh symbol of the same Z3 sort:
def _fresh_from_term(self, term, name):
if z3.is_bool(term):
return z3.Bool(self._fresh(name))
if z3.is_string(term):
return z3.String(self._fresh(name))
return z3.Int(self._fresh(name))
The count example in the next section makes this concrete.
The count example¶
fn count(n: Int) -> Int
requires n >= 0
ensures result == n
{
let i: Int = 0;
while i < n
invariant 0 <= i
invariant i <= n
decreases n - i
{
i = i + 1;
}
return i;
}
- Verifying
countemits seven obligations:- Two initialization.
- Two preservation.
- Two termination.
- One final postcondition.
The loop prover follows the same shape as the array-, builtin-, and
complete-level loop provers, but its state has only scalar variables, so i
is the only variable it has to havoc. (Later levels must also forget entire
arrays and their lengths when a loop modifies them.)
Initialization obligations¶
Before the first iteration, every invariant must hold:
0 <= iwithi = 0becomes0 <= 0.i <= nwithi = 0becomes0 <= n, proved fromrequires n >= 0.
The prover evaluates each invariant in the state that reaches the while and
emits:
Preservation obligations¶
This is the inductive step. It checks an arbitrary loop iteration, not just the first one:
- Havoc every scalar variable the loop body assigns.
ibecomes a fresh, unknown symbol such asi!2.- This forgets whatever value
ihad on entry, so the check is about an arbitrary iteration.
- Assume the invariants and the loop condition:
0 <= i,i <= n, andi < n. - Run the loop body once symbolically:
i = i + 1becomesi + 1. - Prove each invariant again for the after-body value.
For count, the two preservation obligations are:
The second one uses i < n to prove i + 1 <= n.
Exit state¶
After the loop, the prover must continue with a state that satisfies the invariant and the negated loop condition:
- Havoc the modified variables again.
- Assume the invariants.
- Assume
not condition.
For count, after the loop:
ibecomes a fresh symbol such asi!3.- The path gains
0 <= i,i <= n, andnot (i < n), soi >= n. - Together these force
i == n. - The following
return iprovesensures result == n.
The body's final value is deliberately not connected to the exit value; the invariant is the only information that survives.
Termination with decreases¶
A decreases clause proves that a loop terminates. It produces two
obligations:
Before the loop¶
- The measure must be non-negative:
After the body¶
- The measure must strictly decrease:
For count, the measure is n - i:
- Entry:
n - 0 >= 0, proved fromrequires n >= 0. - After the body, with the havoced
iand the invariant/condition facts assumed:n - (i + 1) < n - i, which simplifies to-1 < 0.
The strict-decrease check uses the same havoced i as the preservation check.
A loop without decreases is still accepted; the verifier then proves partial
correctness only. It cannot prove the loop terminates.
A broken loop invariant¶
A loop invariant must hold before the first iteration and survive every execution of the loop body. The example below survives the first iteration but not the second.
- The initialization obligation is
0 <= 2, which holds. - The preservation obligation is built exactly like the preservation step in
count:- Havoc
iinto an arbitrary fresh symbol. - Assume the invariant
i <= 2and the loop conditioni < 3. - Run the body
i = i + 1once, giving the after-body valuei + 1. - Prove
i + 1 <= 2.
- Havoc
That gives the obligation:
- Z3 finds a counterexample:
i = 2.2 <= 2is true, so the invariant holds before the iteration.2 < 3is true, so the loop condition says to run another iteration.2 + 1 <= 2is false, so the invariant is broken after the iteration.
- The prover does not iterate the loop from
0to3. The preservation check symbolically executes the body once on an arbitraryi, and the counterexamplei = 2picks the arbitrary iteration where that one step breaks the invariant. - The correct upper bound is
i <= 3, becauseireaches3before the loop exits.
Appendix: structure of prover_loop.py¶
ScalarWriterCollector: records the scalar names a loop body assigns.Prover: subclasses thecontractsprover and adds:visit_StmtWhile: dispatches a loop statement toexec_while.exec_while: initialization, preservation, termination, and exit._havoc_loop_vars: replaces assigned scalars with fresh symbols._fresh_from_term: makes a fresh symbol of the same Z3 sort.
- Module-level functions:
verify_program: the entry point from the CLI.- The Z3 check loop (
check_obligations) is defined infrml/z3check.pyand inherited here.