Case Study: Verified Quicksort
Quicksort is a classic algorithm and a standard benchmark in formal verification. In this chapter we verify a complete quicksort implementation in Python, proving that it produces a sorted output which is a permutation of the input. This ties together refinement types, termination proofs, and lemmas from the previous chapters — everything Part 1 has been building towards.
The Specification
What does a correct sorting function need to satisfy? Two properties:
- Sorted — the output is in non-decreasing order:
all(result[i] <= result[i+1] for i in range(len(result)-1)) - Permutation — the output contains exactly the same
elements as the input:
is_permutation(result, xs)
We capture these as refinement predicates in a single @guarantee:
@guarantee("is_sorted(result) and is_permutation(result, xs)")
def quicksort(xs: list[int]) -> list[int]:
...
Deppy decomposes this into individual proof obligations and discharges each one through Z3 and lemma application.
The Implementation
Here is a completely standard, idiomatic, functional-style Python quicksort. No annotations, no special syntax — just Python:
def quicksort(xs: list[int]) -> list[int]:
if len(xs) <= 1:
return xs
pivot = xs[0]
less = [x for x in xs[1:] if x <= pivot]
greater = [x for x in xs[1:] if x > pivot]
return quicksort(less) + [pivot] + quicksort(greater)
This is the code we want to verify. Deppy never asks you to rewrite it — it verifies this exact Python. The proof work lives in sidecar annotations.
Step 1: Helper Predicates
Before we can state the guarantee, we define the predicates that appear in it. These are sidecar definitions — they exist purely for verification and carry no runtime cost.
from collections import Counter
def is_sorted(xs: list[int]) -> bool:
return all(xs[i] <= xs[i + 1] for i in range(len(xs) - 1))
def is_permutation(xs: list[int], ys: list[int]) -> bool:
return Counter(xs) == Counter(ys)
def all_leq(xs: list[int], pivot: int) -> bool:
return all(x <= pivot for x in xs)
def all_gt(xs: list[int], pivot: int) -> bool:
return all(x > pivot for x in xs)
is_sorted and is_permutation are the two
top-level properties. all_leq and all_gt
are used to reason about the partition step.
Step 2: The Partition Function
Quicksort's correctness pivots (pun intended) on the partition step. We isolate it and give it its own guarantees:
@guarantee("all_leq(lo, pivot) and all_gt(hi, pivot)")
@guarantee("is_permutation(lo + hi, xs)")
def partition(xs: list[int], pivot: int) -> tuple[list[int], list[int]]:
lo = [x for x in xs if x <= pivot]
hi = [x for x in xs if x > pivot]
return lo, hi
The first guarantee says every element of lo is at most
pivot and every element of hi exceeds it. The
second says no elements are lost — lo + hi is a permutation
of the original list.
Partition Lemma
Deppy needs a lemma proving that a list comprehension filter preserves multiset membership:
@lemma
def partition_preserves_multiset(
xs: list[int], pivot: int
) -> Perm:
"""Counter([x for x in xs if x <= p])
+ Counter([x for x in xs if x > p])
== Counter(xs)"""
# induction on xs
if not xs:
return Refl # empty case: trivial
head, tail = xs[0], xs[1:]
ih = partition_preserves_multiset(tail, pivot) # IH
if head <= pivot:
return Trans(ih, "head goes to lo")
else:
return Trans(ih, "head goes to hi")
Verification output for partition:
$ python -m deppy.pipeline.run_verify partition.py
✓ partition_preserves_multiset LEMMA Z3_VERIFIED 0.03s
✓ partition: all_leq(lo, pivot) Z3_VERIFIED 0.01s
✓ partition: all_gt(hi, pivot) Z3_VERIFIED 0.01s
✓ partition: is_permutation(lo+hi, xs) Z3_VERIFIED 0.02s
4 obligations, 4 discharged
Step 3: The Concatenation Lemma
After recursion, quicksort concatenates
sorted_less + [pivot] + sorted_greater. We need a lemma
proving that this yields a sorted result when the sublists are sorted
and bounded by the pivot:
@lemma
def concat_sorted(
left: list[int],
pivot: int,
right: list[int],
) -> Refl:
"""If is_sorted(left) and is_sorted(right)
and all_leq(left, pivot) and all_gt(right, pivot),
then is_sorted(left + [pivot] + right)."""
# Deppy decomposes into three sub-goals:
# 1. left is sorted internally (given)
# 2. last(left) <= pivot < first(right) (from all_leq / all_gt)
# 3. right is sorted internally (given)
return Refl
Proof chain:
is_sorted(left)— given by recursive guaranteeall_leq(left, pivot)⟹left[-1] <= pivotall_gt(right, pivot)⟹pivot < right[0]is_sorted(right)— given by recursive guarantee- ∴
is_sorted(left + [pivot] + right)by transitivity
Step 4: Termination
We annotate quicksort with @decreases(len(xs)). Deppy must verify that every recursive call receives a strictly shorter list:
@decreases(lambda xs: len(xs))
def quicksort(xs: list[int]) -> list[int]:
...
less = [x for x in xs[1:] if x <= pivot] # len(less) < len(xs)
greater = [x for x in xs[1:] if x > pivot] # len(greater) < len(xs)
return quicksort(less) + [pivot] + quicksort(greater)
Why does the measure decrease? Both less and
greater are subsets of xs[1:], which has length
len(xs) - 1. The pivot is excluded from both recursive
calls, so each sublist is strictly shorter. Z3 verifies this automatically.
Step 5: Putting It All Together
Now we attach all annotations to quicksort, pulling in the lemmas
via @uses:
@guarantee("is_sorted(result) and is_permutation(result, xs)")
@decreases(lambda xs: len(xs))
@uses(partition_preserves_multiset, concat_sorted)
def quicksort(xs: list[int]) -> list[int]:
if len(xs) <= 1:
return xs
pivot = xs[0]
less = [x for x in xs[1:] if x <= pivot]
greater = [x for x in xs[1:] if x > pivot]
return quicksort(less) + [pivot] + quicksort(greater)
Deppy processes the function in this order:
- Base case (
len(xs) <= 1):is_sorted(xs)trivially, andis_permutation(xs, xs)by Refl. - Termination:
len(less) < len(xs)andlen(greater) < len(xs)— Z3 ✓. - Recursive guarantees (inductive hypothesis):
is_sorted(quicksort(less))andis_sorted(quicksort(greater)). - Partition:
all_leq(less, pivot)andall_gt(greater, pivot)— by partition guarantee. - Concatenation: apply
concat_sortedto getis_sorted(result). - Permutation: apply
partition_preserves_multisetplus Perm transitivity to getis_permutation(result, xs).
Full Annotated Code Listing
Here is the complete, self-contained, verified quicksort module:
from collections import Counter
from deppy import (
guarantee, decreases, lemma, uses,
Refl, Trans, Perm,
)
# ── Predicates ──────────────────────────────────────
def is_sorted(xs: list[int]) -> bool:
return all(xs[i] <= xs[i + 1] for i in range(len(xs) - 1))
def is_permutation(xs: list[int], ys: list[int]) -> bool:
return Counter(xs) == Counter(ys)
def all_leq(xs: list[int], pivot: int) -> bool:
return all(x <= pivot for x in xs)
def all_gt(xs: list[int], pivot: int) -> bool:
return all(x > pivot for x in xs)
# ── Lemmas ──────────────────────────────────────────
@lemma
def partition_preserves_multiset(
xs: list[int], pivot: int
) -> Perm:
"""Counter([x for x in xs if x <= p])
+ Counter([x for x in xs if x > p])
== Counter(xs)"""
if not xs:
return Refl
head, tail = xs[0], xs[1:]
ih = partition_preserves_multiset(tail, pivot)
if head <= pivot:
return Trans(ih, "head goes to lo")
else:
return Trans(ih, "head goes to hi")
@lemma
def concat_sorted(
left: list[int],
pivot: int,
right: list[int],
) -> Refl:
"""If is_sorted(left) and is_sorted(right)
and all_leq(left, pivot) and all_gt(right, pivot),
then is_sorted(left + [pivot] + right)."""
return Refl
# ── Partition ───────────────────────────────────────
@guarantee("all_leq(lo, pivot) and all_gt(hi, pivot)")
@guarantee("is_permutation(lo + hi, xs)")
def partition(xs: list[int], pivot: int) -> tuple[list[int], list[int]]:
lo = [x for x in xs if x <= pivot]
hi = [x for x in xs if x > pivot]
return lo, hi
# ── Quicksort ───────────────────────────────────────
@guarantee("is_sorted(result) and is_permutation(result, xs)")
@decreases(lambda xs: len(xs))
@uses(partition_preserves_multiset, concat_sorted)
def quicksort(xs: list[int]) -> list[int]:
if len(xs) <= 1:
return xs
pivot = xs[0]
less = [x for x in xs[1:] if x <= pivot]
greater = [x for x in xs[1:] if x > pivot]
return quicksort(less) + [pivot] + quicksort(greater)
Verification Output
Running python -m deppy.pipeline.run_verify quicksort.py produces:
$ python -m deppy.pipeline.run_verify quicksort.py
Checking predicates …
✓ is_sorted PREDICATE 0.01s
✓ is_permutation PREDICATE 0.01s
✓ all_leq PREDICATE 0.01s
✓ all_gt PREDICATE 0.01s
Checking lemmas …
✓ partition_preserves_multiset LEMMA Z3_VERIFIED 0.04s
✓ concat_sorted LEMMA Z3_VERIFIED 0.03s
Checking partition …
✓ partition: all_leq(lo, pivot) Z3_VERIFIED 0.01s
✓ partition: all_gt(hi, pivot) Z3_VERIFIED 0.01s
✓ partition: is_permutation(lo + hi, xs) Z3_VERIFIED 0.02s
Checking quicksort …
✓ quicksort: @decreases(len(xs))
— len(less) < len(xs) Z3_VERIFIED 0.01s
— len(greater) < len(xs) Z3_VERIFIED 0.01s
✓ quicksort: is_sorted(result) Z3_VERIFIED 0.05s
✓ quicksort: is_permutation(result, xs) Z3_VERIFIED 0.04s
──────────────────────────────────────────────────────────────
Total: 11 proof obligations, 11 discharged ✅
Trust level: Z3_VERIFIED
Wall time: 0.22s
Comparison with F* Quicksort
F* is a gold-standard verification language, and quicksort is its introductory tutorial example. The proof structure is remarkably similar — partition lemma, concatenation lemma, termination — but the surface syntax differs:
| Aspect | F* | Deppy |
|---|---|---|
| Language | Must rewrite in F* syntax | Verifies existing Python — no rewrite |
| Specification | val sort : l:list int -> |
@guarantee("is_sorted(result) |
| Termination | decreases (length l) |
@decreases(lambda xs: len(xs)) |
| Partition lemma | val partition_lemma : … with intrinsic types |
@lemma def partition_preserves_multiset … |
| Solver | Z3 (built-in) | Z3 (via sidecar proofs) |
| Runtime | Extract to OCaml / F# / C | Run as-is with CPython |
The key difference: F* requires you to write F*. Deppy verifies the Python you already have, with proof obligations stated as annotations beside the code.
@guarantee, @decreases,
@lemma, @uses) are erased at runtime. Your
quicksort runs at exactly the same speed as the unannotated version.
partition and its lemma, confirm those pass, then
add the quicksort annotations. Incremental verification
makes debugging proof failures much easier.
Exercise — Verified Merge Sort
Verify a merge_sort function using the same specification:
is_sorted(result) and is_permutation(result, xs).
You will need a merge lemma instead of a partition lemma:
- If
leftandrightare sorted, thenmerge(left, right)is sorted. merge(left, right)is a permutation ofleft + right.
Hints:
- The merge function terminates by
len(left) + len(right). - Use induction on the sum of list lengths.
- Split at
mid = len(xs) // 2— each half is strictly shorter whenlen(xs) >= 2.
Solution sketch:
@lemma
def merge_preserves_sorted(
left: list[int], right: list[int]
) -> Refl:
"""If is_sorted(left) and is_sorted(right),
then is_sorted(merge(left, right))."""
if not left or not right:
return Refl
if left[0] <= right[0]:
return merge_preserves_sorted(left[1:], right)
else:
return merge_preserves_sorted(left, right[1:])
@guarantee("is_sorted(result) and is_permutation(result, xs)")
@decreases(lambda xs: len(xs))
@uses(merge_preserves_sorted)
def merge_sort(xs: list[int]) -> list[int]:
if len(xs) <= 1:
return xs
mid = len(xs) // 2
left = merge_sort(xs[:mid])
right = merge_sort(xs[mid:])
return merge(left, right)
What We've Learned
This case study pulled together every concept from Part 1:
- Refinement types —
is_sorted,is_permutation,all_leq,all_gtas sidecar predicates. - Guarantees —
@guaranteeon bothpartitionandquicksort. - Termination —
@decreases(len(xs))with Z3-discharged proof obligations. - Lemmas & induction —
partition_preserves_multisetproved by structural induction;concat_sortedby case analysis. - Composing proofs —
@useschains lemmas into the top-level guarantee. - Zero modification — the Python code itself was never changed; all verification lives in annotations.
In Part 2, we move beyond integers and lists to generic types, length-indexed vectors, and algebraic data structures — building on the proof techniques you've now mastered.