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:

  1. Sorted — the output is in non-decreasing order: all(result[i] <= result[i+1] for i in range(len(result)-1))
  2. 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:

  1. is_sorted(left) — given by recursive guarantee
  2. all_leq(left, pivot)left[-1] <= pivot
  3. all_gt(right, pivot)pivot < right[0]
  4. is_sorted(right) — given by recursive guarantee
  5. 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:

  1. Base case (len(xs) <= 1): is_sorted(xs) trivially, and is_permutation(xs, xs) by Refl.
  2. Termination: len(less) < len(xs) and len(greater) < len(xs) — Z3 ✓.
  3. Recursive guarantees (inductive hypothesis): is_sorted(quicksort(less)) and is_sorted(quicksort(greater)).
  4. Partition: all_leq(less, pivot) and all_gt(greater, pivot) — by partition guarantee.
  5. Concatenation: apply concat_sorted to get is_sorted(result).
  6. Permutation: apply partition_preserves_multiset plus Perm transitivity to get is_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:

AspectF*Deppy
Language Must rewrite in F* syntax Verifies existing Python — no rewrite
Specification val sort : l:list int ->
Tot (m:list int{sorted m /\ permutation l m})
@guarantee("is_sorted(result)
and is_permutation(result, xs)")
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.

Note — Zero runtime overhead. All annotations (@guarantee, @decreases, @lemma, @uses) are erased at runtime. Your quicksort runs at exactly the same speed as the unannotated version.
Tip — Verify incrementally. Start with partition and its lemma, confirm those pass, then add the quicksort annotations. Incremental verification makes debugging proof failures much easier.
Warning — In-place quicksort is harder. The functional quicksort above creates new lists, making the permutation argument straightforward. An in-place quicksort mutates array elements via swaps, requiring separation logic to reason about aliasing and mutable state. See Part 6: Arrays.
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:

  1. If left and right are sorted, then merge(left, right) is sorted.
  2. merge(left, right) is a permutation of left + 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 when len(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:

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.