43 Loops and fixed points — proving “however many times”
What to know first
while repeats its body while the condition holdsLooking back
What was the first line of “What is not proven” for Theorem A in chapter 42?
A. That the model is a straight sequence of events, with no branches or loops. The real compiler looks at code once, and that code may run a million times. One check must guarantee a million runs. This chapter is how, and the answer is one word — fixed point.
The need for this chapter, and its context
r in the source but different borrows at run time. This chapter solves both difficulties with fixed points and fresh tokens, and shows there is exactly one shape in which borrows break in loops. It is a case of knowing how many rules are needed by argument rather than by feel.By the end of this chapter
The questions this chapter answers
- How does the compiler find the fixed point? Does the theorem guarantee that too?
43.1 It breaks on the second round#
examples/ch43/loop_stale.low
module loop_stale .
rem expect: E-EXCL
proc drift input n u64 . output u64 .
requires le n 100 .
do
var x u64 be 1 .
let r ref u64 be ref x .
var i u64 be 0 .
var seen u64 be 0 .
while lt i n . do
set x (add x 1) .
set seen (deref r) .
set i (add i 1) .
end
return seen .
end
Output
$ lowentc --check loop_stale.low
12:0 E-EXCL: exclusivity violation: overlapping borrow/owner access (readers-XOR-writer)
12:0 E-EXCL: exclusivity violation: overlapping borrow/owner access (readers-XOR-writer)
A read borrow r created outside the loop is used inside it, while inside the same loop the owner writes to x. Round 1 looks fine. But that write kills r, and round 2′s deref r uses a dead borrow. It is fine when run once and blows up the second time. The processor rejects it at translation.
examples/ch43/loop_fresh.low
module loop_fresh .
rem run: drift 5
proc bump input p mut_ref u64 . output u64 .
do
set p (add (deref p) 1) .
return deref p .
end
proc drift input n u64 . output u64 .
requires le n 100 .
do
var x u64 be 1 .
var i u64 be 0 .
var seen u64 be 0 .
while lt i n . do
set seen (bump (mut_ref x)) .
set i (add i 1) .
end
return seen .
end
Output
$ lowentc --run drift loop_fresh.low 5
drift(5) = 6
Conversely, if the borrow is born and ends inside the loop, it is fresh every round, and nothing stale remains between rounds. The common advice “create it inside the loop and finish it inside the loop” has a proof attached.
43.2 At a fixed point, any number of times#
Here the function f is “running the loop body once”, and the value x is the static checker’s state (the list of live borrows). If one run of the body leaves the static state unchanged, that is a fixed point. Then induction holds.
Running 0 times is safe. base --- nothing was done
Running once safely from state L returns the state to L. preservation
─────────────────────────────────────────────
Therefore it is safe however many times it runs. ∀kIt is like dominoes needing equal spacing. If the state changed every round, there would be no argument for “the next piece”. Because the state returns, the same argument can be reused endlessly.
The mathematics. Theorem B — loop agreement (loop_agreement in LowentLoop.v)
forall pre body, (exists lpre, srun [] pre = Some lpre /\ srun lpre body = Some lpre) -> forall k, dyn_clean pre body k = true. If, from the static state after setup code pre, running the loop body body once returns to the same state, the loop has no borrowing violation however many times it runs. forall k is the whole theorem — 0 times, once, 264 times. k = 0 is included. It looks trivial, but leave it out and the induction fails.Some code does not reach a fixed point. If a borrow is added each round, never dies, and escapes the loop, the static state grows. Such code does not satisfy the theorem’s premise, and the processor rejects it. When a borrow is born and dies in the body, the state returns — that is ordinary code.
43.3 Only one dangerous shape#
Something was learned while proving. There is only one way borrows break in loops.
A borrow τ created in pre is used in body, while body also contains owner access own(x).
→ own(x) in round i kills τ.
→ use(τ) in round i+1 is a violation.One static rule rejects exactly that pair — a borrow living across the loop × owner access in the body — and the proof of Theorem B shows “that one is enough”. There is no other danger. This is the proof’s practical output. Too few rules leak defects and too many block normal code, and the argument decided how many is right.
43.4 Fresh tokens — one move that removes the naming problem#
The real headache in loops is name clashes. The r of round 1 and the r of round 2 are the same name in the source but different borrows at run time. Handling that usually means building a “tag renaming” device.
This proof built no such device. Instead the model issues a new token every time it runs. A static tag t points through an environment to “the current token”, and that token gets a new number each round. Then last round’s token is pointed to by no one and becomes invalid by itself. This is no trick — the implementation really issues fresh tags. Because the model followed the implementation, the proof got shorter. If a proof is hard, look at the model again. Half the difficulty comes from a wrong representation.
The proof adds one invariant to chapter 42′s INV and SINV.
- FRESH — every issued token is smaller than
next.
Without it a new token could receive the same number as an old one, and a dead borrow would come back to life. It is the scariest kind of hole in a proof — wrong on the dangerous side, not the safe side.
43.5 Nested loops — from function to relation#
examples/ch43/nested.low
module nested .
rem run: tri 4
proc bump input p mut_ref u64 . output u64 .
do
set p (add (deref p) 1) .
return deref p .
end
rem the inner loop runs a different number of times each outer round (0, 1, 2, 3)
proc tri input n u64 . output u64 .
requires le n 100 .
do
var x u64 be 0 .
var i u64 be 0 .
while lt i n . do
var k u64 be 0 .
while lt k i . do
let seen u64 be bump (mut_ref x) .
set k (add k 1) .
end
set i (add i 1) .
end
return x .
end
Output
$ lowentc --run tri nested.low 4
tri(4) = 6
The inner loop runs 0, 1, 2 and 3 times on successive outer rounds. The borrow mut_ref x is born and ends in the inner body, so it is safe in every round.
The flat Theorem B imposes unroll body k — “every round runs the same”. Real programs do not. The nested loop proof (LowentNest.v) represents the program as a tree (NLeaf, NSeq, NLoop) and writes unfolding as a relation, not a function.
The mathematics. Nested loops (nested_agreement)
flat_loop_recovered). If it could not be recovered, it would not be a generalisation but a different theorem.Loops whose first round changes the state fit too. Peeling once as b ; loop b leaves a fixed point afterwards (peeled_loop_is_safe).
Q. How does the compiler find the fixed point? Does the theorem guarantee that too?
A. The theorem assumes the fixed point at the entry. The compiler reaches it by merging the states of branches and rounds (with the lattice’s join and monotone iteration), but that process is outside the model. The gap is backed by bounded exhaustive checking — the loop checker runs all 90,376 shapes for rounds 1 … 3 and checks that model and implementation agree. This is “exhaustively checked”, not “proven”.
A common misconception. If following the loop body once shows no problem, any number of rounds is fine
loop_stale.low is the counterexample: round 1 is fine, and round 2 uses a dead borrow. “Following it once” is enough only when the static state after one pass equals the state before, that is, at a fixed point. So the checker applies the body repeatedly until the state stops changing, not just once, and this chapter’s theorem guarantees that stopping there is correct. The same holds when a person reads code: ask “what is alive when the second round begins?”43.6 What is not proven#
- Merging
ifbranches is not in Theorem B. The model is two straight sequences(pre, body). The correctness of merges is backed by the exhaustive checking above. - The process of reaching the fixed point is outside the model. The theorem speaks of after it is reached.
- Whether loops terminate is not covered. The theorem is “safe if it runs”, not “it ends”. Infinite loops, like a firmware main loop, are legitimate programs in this language. That is why tests the tool generates for itself carry a step budget (chapter 41) — termination cannot be guaranteed, so the tool must know when to stop.
Recap
k, and a model issuing new tokens each round with the invariant FRESH removes the naming problem. Nested loops were covered, including rounds that run differently, by writing unfolding as a relation. Merges and reaching the fixed point are backed by exhaustive checking, and termination is not covered.