A while loop may carry a :invariant annotation that the compiler
attempts to prove statically. When the proof succeeds, the runtime checks
are elided and the invariant becomes a known fact after the loop. When the
proof fails or cannot be decided, the runtime checks remain as a safety
net.
Loop invariants graduated to always-on in v0.62.0. The --enable=loop-invariants
flag is now a TUR-W0063 no-op. Every (while c :invariant p body...)
form is analysed by the refinement solver -- the same in-house decision
procedure that discharges #refine{...} annotations.
The invariant owes two proofs:
p on entry.p and the loop condition
c, one pass of the body must leave p true.When both proofs succeed, the runtime checks are elided and the post-loop
fact p AND (not c) is offered to the solver as a known fact. When either
proof fails, the runtime check stays (checked on entry and after every
iteration), and the program remains safe.
(while condition :invariant predicate body...)
The :invariant keyword must directly follow the condition. At most one
:invariant per loop -- combine multiple conjuncts with and:
(while (< i n)
:invariant (and (>= i 0) (<= i n) (>= acc 0))
(set! acc (+ acc i))
(set! i (+ i 1)))
;; Sum 0..n-1; the invariant is inductive
(let [i 0 acc 0]
(while (< i n)
:invariant (and (>= i 0) (<= i n) (>= acc 0))
(set! acc (+ acc i))
(set! i (+ i 1)))
acc)
The solver proves both obligations: i starts at 0 (entry), and each
iteration increments i by 1 while i < n, so 0 <= i <= n and
acc >= 0 are preserved. The runtime checks are elided, and after the
loop, i >= 0 AND i <= n AND acc >= 0 AND NOT (i < n) is a known fact.
;; acc could go negative if i is negative
(while (< i n)
:invariant (>= acc 0) ; not inductive: i may be negative
(set! acc (+ acc i))
(set! i (+ i 1)))
The solver cannot prove preservation: if i is negative, (+ acc i)
could make acc negative. The runtime check stays, and the solver
reports the counterexample naming the variable the invariant says too
little about. The fix is a stronger invariant:
(while (< i n)
:invariant (and (>= acc 0) (>= i 0)) ; inductive
(set! acc (+ acc i))
(set! i (+ i 1)))
Each :invariant p on a (while c body...) produces two verification
conditions:
premises => p -- the facts in scope at the loop
entry (parameter refinements, prior branch conditions, let bindings,
earlier loop post-facts) must imply p.(and p c) => p_after -- assuming p holds
and the loop condition c is true, executing the body once must leave
p true. For a branching body, the solver checks each path.The in-house decision procedure handles quantifier-free linear integer arithmetic with equality and uninterpreted functions. It runs a chain of stages: normalization, EUF (uninterpreted functions), Fourier-Motzkin (linear arithmetic), Nelson-Oppen (theory combination), and cube expansion (propositional DNF). See refinement-solver-internals-guide.md for the full pipeline.
The loop is declined (no static analysis, both runtime checks stay) when the body:
(set! (.f s) v)) or an atom.return, ?, a continuation) or nests a loop that
assigns.(& x), &mut x), or the
function assigns it inside a lambda or handler clause.let, a name that is already in scope.A declined loop reports TUR-W0372 with "is not analysed statically:
<reason>". The runtime checks stay.
Loop invariants prove inside a frozen region. A bounded-index walk over
a container discharges both runtime checks there, and a frozen marker no
longer declines the loop. See
stateful-refinements-guide.md for the
frozen region and #reads measures.
When the solver returns "unknown" for an invariant obligation, the runtime check stays. This is a sound outcome, not a miscompile. The diagnostic reports whether the failure was on entry ("could not be proved on entry") or preservation ("...to be preserved by the body").
Common causes:
Adding an explicit refinement to a parameter usually supplies the missing
hypothesis. --strict-refine turns this into a hard error for builds that
want every obligation discharged statically.
The usual fix for a failed preservation proof is a stronger invariant.
The counterexample names the variable the invariant says too little
about. In the example above, (>= acc 0) alone is not inductive because
i could be negative; adding (>= i 0) gives the solver the hypothesis
it needs.
Loop invariants are part of the refinement type system. A #refine{...}
annotation on a parameter or let binding provides a premise the solver
can use when proving the entry obligation. After the loop, the proved
invariant and the negated condition become facts the solver can use for
downstream obligations.
;; The parameter refinement gives the solver i >= 0 on entry
(defn sum-to [n : #refine{ v : int | (>= v 0) }] : int
(let [i 0 acc 0]
(while (< i n)
:invariant (and (>= i 0) (<= i n) (>= acc 0))
(set! acc (+ acc i))
(set! i (+ i 1)))
acc))
See refinement-types-guide.md for the full
refinement type system and --strict-refine.
#refine{...} annotation, call-site
crossings, and --strict-refine are covered in
refinement-types-guide.md.#reads measures and the frozen region
are covered in stateful-refinements-guide.md.assert!, require!, ensure!, and
invariant! macros are covered in
error-handling-guide.md.