A verified data structure
Time: 60 minutes. Prerequisites: Refinement types, Gradual verification.
We'll build SortedList<T> — a list that the type system guarantees
is sorted. The compiler proves every mutation preserves the invariant.
1. The type
src/sorted_list.vr:
use core.collections.List;
/// A refinement-typed sorted list: every operation preserves
/// `xs[i] <= xs[i+1]`.
pub type SortedList<T: Ord> is List<T> { is_sorted(self) };
/// The named predicate — reflectable into SMT.
@logic
fn is_sorted<T: Ord>(xs: &List<T>) -> Bool {
forall i in 0..xs.len() - 1. xs[i] <= xs[i + 1]
}
At this point SortedList<T> is a refinement of List<T> — the
compiler requires every value of this type to satisfy is_sorted.
2. Construction
fn empty<T: Ord>() -> SortedList<T>
where ensures is_sorted(result)
{
// Vacuously true: empty list has no adjacent pairs.
List.new()
}
fn singleton<T: Ord>(x: T) -> SortedList<T>
where ensures is_sorted(result)
{
list![x]
}
The compiler checks both ensures clauses. Vacuous quantifiers
discharge immediately — the first proof takes Z3 ~8 ms.
3. Insertion
The hard case. We need to prove that inserting at the correct position preserves sortedness.
@verify(formal)
fn insert<T: Ord>(xs: SortedList<T>, x: T) -> SortedList<T>
where ensures is_sorted(result),
ensures result.len() == xs.len() + 1
{
let mut out: List<T> = xs.clone();
// partition_point returns the first index i s.t. out[i] >= x.
let pos = out.partition_point(|y| *y < x);
out.insert(pos, x);
out
}
What the solver needs to prove:
∀ i ∈ 0..out.len() - 1. out[i] ≤ out[i+1]
Z3 gets this from:
possatisfies∀ j < pos. xs[j] < xand∀ j ≥ pos. xs[j] ≥ x(axioms ofpartition_point, reflected from@logic).- The original
xsis sorted (from the input type). - Case-split on
i: the only interesting case is wheni = pos - 1ori = pos, where the new element is adjacent.
4. Removal
@verify(formal)
fn remove_at<T: Ord>(xs: SortedList<T>, index: Int { 0 <= self && self < xs.len() })
-> (SortedList<T>, T)
where ensures is_sorted(result.0),
ensures result.0.len() == xs.len() - 1
{
let mut out = xs.clone();
let removed = out.remove(index);
(out, removed)
}
The precondition 0 <= index < xs.len() makes the indexing safe;
the solver proves that deleting one element preserves sortedness
(any remaining adjacent pair was already a pair in the input).
5. Merge
@verify(formal)
fn merge<T: Ord>(a: SortedList<T>, b: SortedList<T>) -> SortedList<T>
where ensures is_sorted(result),
ensures result.len() == a.len() + b.len()
{
let mut out = List.with_capacity(a.len() + b.len());
let mut i = 0;
let mut j = 0;
while i < a.len() && j < b.len()
invariant 0 <= i && i <= a.len()
invariant 0 <= j && j <= b.len()
invariant out.len() == i + j
invariant is_sorted(&out)
invariant forall k in 0..out.len(). forall m in i..a.len(). out[k] <= a[m]
invariant forall k in 0..out.len(). forall m in j..b.len(). out[k] <= b[m]
decreases a.len() + b.len() - i - j
{
if a[i] <= b[j] {
out.push(a[i]);
i += 1;
} else {
out.push(b[j]);
j += 1;
}
}
while i < a.len()
invariant is_sorted(&out)
decreases a.len() - i
{
out.push(a[i]);
i += 1;
}
while j < b.len()
invariant is_sorted(&out)
decreases b.len() - j
{
out.push(b[j]);
j += 1;
}
out
}
The invariants are the real work. Each line encodes:
- Loop indices are in bounds.
- The output's length is the sum of consumed indices.
- The output is sorted so far.
- Every element in the output is ≤ every not-yet-consumed element
from
a. - Same for
b.
With all five invariants, the solver can discharge the postcondition. Without (4) and (5), it can't — the final element push would leave unproven adjacency.
6. Tests
@cfg(test)
module tests {
use .super.*;
@test
fn insert_preserves_sort() {
let xs: SortedList<Int> = empty();
let xs = insert(xs, 3);
let xs = insert(xs, 1);
let xs = insert(xs, 2);
assert(is_sorted(&xs));
assert_eq(xs, list![1, 2, 3]);
}
@test
fn merge_produces_sorted() {
let a: SortedList<Int> = list![1, 3, 5];
let b: SortedList<Int> = list![2, 4, 6];
let c = merge(a, b);
assert_eq(c, list![1, 2, 3, 4, 5, 6]);
}
@test(property)
fn insert_preserves_sort_forall(xs: SortedList<Int>, x: Int) {
let ys = insert(xs.clone(), x);
assert(is_sorted(&ys));
assert_eq(ys.len(), xs.len() + 1);
}
}
Property tests are especially valuable for refinement-typed code — they validate the solver's proofs against random inputs.
7. Run
$ verum test
[verify] SortedList::insert ✓ (formal/z3, 14 ms)
[verify] SortedList::remove_at ✓ (formal/z3, 9 ms)
[verify] SortedList::merge ✓ (formal/z3, 210 ms)
test tests::insert_preserves_sort ... ok
test tests::merge_produces_sorted ... ok
test tests::insert_preserves_sort_forall ... ok (100 cases)
all 3 tests passed
Troubleshooting
If merge takes > 5 s to verify, try:
- Escalate to
@verify(thorough)on merge specifically — this races the SMT backend, and tactic-based proof search; CVC5's quantifier reasoning is often faster on nested foralls. - Split invariants 4 and 5 into separate helper
@logiclemmas that name the adjacency property. - Let the proof cache persist across runs (on by default) and the first slow build won't recur.
What you learned
SortedList<T>as a refinement ofList<T>.@logicfunctions — the predicates that become SMT axioms.where ensurespostconditions.- Loop invariants: every inductive step must be spelled out.
decreasesfor termination.- The SMT solver proves the invariants; you write them.
Next
- Proofs — when SMT isn't enough, write a proof term.
- SMT routing — how the SMT backend decide what to dispatch.
- Refinement patterns — the common idioms.