diff options
| author | Paul Buetow <paul@buetow.org> | 2026-07-06 10:32:34 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-07-06 10:32:34 +0300 |
| commit | 3f906d03262150892e2297e621bfc56e425ef142 (patch) | |
| tree | 79d69a4b011882716626565c27793ce04532227e /formal | |
| parent | f74812f8eda48194b622bdd318f35d3a6b6328cd (diff) | |
Extends the verification harness from sorts-only to the whole repo, and in
doing so surfaces two further latent bugs (on top of the earlier hash-shift one):
Bugs found and fixed:
- queue/elementarypriority.go: max() seeded at the zero value, so an
all-negative queue reported a phantom max of 0 and DeleteMax returned/removed
the wrong element. Caught by the new queue permutation property (testing/quick
generates negatives; the old test data never did). Seed from a[0] instead.
- sort/sleep.go: result built on NewArrayList(len(a)) -- a slice of that LENGTH
(len(a) zeros) -- then appended to, yielding double-length output with leading
zeros. The old .Sorted()-only test passed because zeros-then-ascending is
sorted. Caught by the new Sleep permutation check. Build from an empty slice.
Coverage added:
- queue/property_test.go: ordering + permutation (completeness) for both queues.
- TestSleepSort now also checks permutation, not just Sorted().
- docs/verification.md: paper proofs for all search/set structures (Elementary,
Hash, BST, red-black BST invariants, GoMap) and both priority queues.
- formal/tla/ParallelSort.tla: exhaustive fork/join model of ParallelMerge/
ParallelQuick -- disjoint write-ranges (no data race) + termination. Wired
into make verify-model.
- formal/selection.go: second Gobra proof (memory safety + sortedness). Wired
into make verify-formal.
- docs/case-study-bugs-found.md: extensive write-up of all three bugs, how each
was caught, why the old tests missed it, and the fix (supersedes the earlier
single-bug case study).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat (limited to 'formal')
| -rw-r--r-- | formal/README.md | 25 | ||||
| -rw-r--r-- | formal/selection.go | 48 | ||||
| -rw-r--r-- | formal/tla/ParallelSort.cfg | 13 | ||||
| -rw-r--r-- | formal/tla/ParallelSort.tla | 117 | ||||
| -rw-r--r-- | formal/tla/README.md | 25 |
5 files changed, 213 insertions, 15 deletions
diff --git a/formal/README.md b/formal/README.md index f667bdf..1bbb7e0 100644 --- a/formal/README.md +++ b/formal/README.md @@ -1,22 +1,24 @@ -# Gobra deductive proof +# Gobra deductive proofs -`insertion.go` is a **machine-checked** proof that an insertion sort is correct, -verified by [Gobra](https://github.com/viperproject/gobra) — ETH Zurich's -deductive verifier for Go, which translates annotated Go to the Viper -intermediate language and discharges the proof obligations with the Z3 SMT +`insertion.go` and `selection.go` are **machine-checked** proofs that these +sorts are correct, verified by [Gobra](https://github.com/viperproject/gobra) — +ETH Zurich's deductive verifier for Go, which translates annotated Go to the +Viper intermediate language and discharges the proof obligations with the Z3 SMT solver. -Unlike the TLA+ model (which checks a hand-written abstraction) and the property -tests (which sample inputs), this verifies the **actual Go source** for **all** -inputs. Gobra proves two things about `Insertion`: +Unlike the TLA+ models (which check a hand-written abstraction) and the property +tests (which sample inputs), these verify the **actual Go source** for **all** +inputs. Gobra proves two things about each sort: 1. **Memory safety** — every index access is in bounds. The permission invariants `forall k :: 0 <= k < len(a) ==> acc(&a[k])` carry write access to every element through both loops; Go itself cannot prove the absence of index-out-of-range panics, Gobra can. 2. **Ordering** — on return `a` is sorted ascending - (`forall p < q :: a[p] <= a[q]`), established via the two insertion-sort loop - invariants in the annotations. + (`forall p < q :: a[p] <= a[q]`), established via the loop invariants in the + annotations. Selection sort needs the stronger "every prefix element ≤ every + suffix element" invariant; insertion sort uses a "sorted except at the + in-flight index" invariant. The **permutation** half of full correctness (output is a rearrangement of the input) is intentionally left to the property tests and the paper proof in @@ -45,7 +47,8 @@ or directly: ```sh podman run --rm -v "$PWD/formal:/gobra/formal:z" \ - ghcr.io/viperproject/gobra:latest -i /gobra/formal/insertion.go + ghcr.io/viperproject/gobra:latest \ + -i /gobra/formal/insertion.go /gobra/formal/selection.go ``` Expected output ends with: diff --git a/formal/selection.go b/formal/selection.go new file mode 100644 index 0000000..1a9028d --- /dev/null +++ b/formal/selection.go @@ -0,0 +1,48 @@ +package formal + +// Selection sorts a in ascending order, in place. This is a monomorphized +// (non-generic, plain []int, inlined swap, no `continue`) copy of +// sort.Selection, annotated so Gobra proves memory safety AND that the result +// is sorted ascending, for all inputs. +// +// Selection sort's proof needs a stronger outer invariant than insertion sort: +// not only is the prefix a[0..i) sorted, but every element of that prefix is +// <= every element of the unsorted suffix a[i..len). That second invariant is +// what lets the newly selected minimum extend the sorted prefix. +// +//@ requires forall k int :: 0 <= k && k < len(a) ==> acc(&a[k]) +//@ ensures forall k int :: 0 <= k && k < len(a) ==> acc(&a[k]) +//@ ensures forall p, q int :: 0 <= p && p < q && q < len(a) ==> a[p] <= a[q] +func Selection(a []int) { + i := 0 + //@ invariant 0 <= i && i <= len(a) + //@ invariant forall k int :: 0 <= k && k < len(a) ==> acc(&a[k]) + // a[0..i) is sorted... + //@ invariant forall p, q int :: 0 <= p && p < q && q < i ==> a[p] <= a[q] + // ...and every prefix element is <= every suffix element. + //@ invariant forall p, q int :: 0 <= p && p < i && i <= q && q < len(a) ==> a[p] <= a[q] + for i < len(a) { + min := i + j := i + 1 + //@ invariant i < len(a) && i+1 <= j && j <= len(a) + //@ invariant i <= min && min < len(a) + //@ invariant forall k int :: 0 <= k && k < len(a) ==> acc(&a[k]) + // a[min] is the smallest of the scanned suffix a[i..j). + //@ invariant forall k int :: i <= k && k < j ==> a[min] <= a[k] + // The outer invariants still hold (the inner loop reads only). + //@ invariant forall p, q int :: 0 <= p && p < q && q < i ==> a[p] <= a[q] + //@ invariant forall p, q int :: 0 <= p && p < i && i <= q && q < len(a) ==> a[p] <= a[q] + for j < len(a) { + if a[j] < a[min] { + min = j + } + j = j + 1 + } + if min != i { + tmp := a[i] + a[i] = a[min] + a[min] = tmp + } + i = i + 1 + } +} diff --git a/formal/tla/ParallelSort.cfg b/formal/tla/ParallelSort.cfg new file mode 100644 index 0000000..b60c298 --- /dev/null +++ b/formal/tla/ParallelSort.cfg @@ -0,0 +1,13 @@ +\* TLC configuration for the ParallelSort fork/join model. +\* N=4, Threshold=1 gives a full recursion tree of 7 nodes (root -> 2 -> 4 +\* leaves), enough to exercise siblings, cousins, and multi-level joins. Integer +\* constants can be assigned directly in a .cfg. +CONSTANT N = 4 +CONSTANT Threshold = 1 + +SPECIFICATION Spec + +INVARIANT TypeOK +INVARIANT NoDataRace + +PROPERTY Terminates diff --git a/formal/tla/ParallelSort.tla b/formal/tla/ParallelSort.tla new file mode 100644 index 0000000..c4dee79 --- /dev/null +++ b/formal/tla/ParallelSort.tla @@ -0,0 +1,117 @@ +----------------------------- MODULE ParallelSort ----------------------------- +(***************************************************************************) +(* A TLA+ model of the fork/join structure shared by ParallelMerge *) +(* (sort/parallelmerge.go) and ParallelQuick (sort/parallelquick.go). *) +(* *) +(* Both sorts recursively split an array range into two halves, sort them *) +(* in two goroutines over DISJOINT index sub-ranges, join on a WaitGroup, *) +(* and (for merge) combine. The correctness of the *parallelization* — as *) +(* argued on paper in docs/verification.md — reduces to two claims: *) +(* *) +(* 1. No data race: no two concurrently-writing tasks touch overlapping *) +(* array indices. *) +(* 2. Termination: the join structure always completes (no deadlock). *) +(* *) +(* This model checks both EXHAUSTIVELY over the whole recursion tree. We *) +(* model each task's index range as the resource it writes. A parent only *) +(* writes its full range (the merge step) AFTER both children have joined *) +(* (reached "done"); until then it is "waiting" and writes nothing. This is *) +(* the WaitGroup fence. Remove that fence (let a parent merge while its *) +(* children still run) and TLC finds a data race — see the README. *) +(* *) +(* Modeling the parent's post-join merge as writing the whole [lo,hi) range *) +(* is the *stronger* case (merge sort). Quicksort does no work after the *) +(* join, so if this race-free model holds, quicksort's is race-free too. *) +(***************************************************************************) +EXTENDS Naturals, FiniteSets + +CONSTANTS + N, \* array length (indices 0..N-1) + Threshold \* ranges of size <= Threshold are sorted sequentially (leaves) + +\* The recursion tree: node id 1 owns [0,N); a non-leaf id owns [lo,hi) and its +\* children 2*id, 2*id+1 own the two halves. Ranges of size <= Threshold are +\* leaves. The set of nodes is finite because ranges strictly shrink. +RECURSIVE TreeFrom(_, _, _) +TreeFrom(id, lo, hi) == + IF hi - lo <= Threshold + THEN { [id |-> id, lo |-> lo, hi |-> hi, leaf |-> TRUE] } + ELSE LET mid == (lo + hi) \div 2 + IN { [id |-> id, lo |-> lo, hi |-> hi, leaf |-> FALSE] } + \union TreeFrom(2*id, lo, mid) + \union TreeFrom(2*id + 1, mid, hi) + +Tree == TreeFrom(1, 0, N) +NodeIds == { t.id : t \in Tree } +Node(i) == CHOOSE t \in Tree : t.id = i \* the record for id i +IsLeaf(i) == Node(i).leaf +Left(i) == 2 * i +Right(i) == 2 * i + 1 + +\* Two half-open index ranges are disjoint iff one ends at or before the other +\* begins. +Disjoint(i, j) == \/ Node(i).hi <= Node(j).lo + \/ Node(j).hi <= Node(i).lo + +VARIABLE phase \* phase[i] \in {"unstarted","ready","waiting","merging","done"} +vars == << phase >> + +\* A task is *writing* its range while it computes: a leaf sorting its small +\* range, or an internal node performing its post-join merge. +Writing == { i \in NodeIds : phase[i] = "merging" } + +TypeOK == + phase \in [NodeIds -> {"unstarted", "ready", "waiting", "merging", "done"}] + +Init == + \* Only the root starts ready; children become ready when their parent forks. + phase = [i \in NodeIds |-> IF i = 1 THEN "ready" ELSE "unstarted"] + +\* Fork: a ready internal node spawns its two children (disjoint halves) and +\* waits for them (WaitGroup.Add(2); go ...; go ...; wg.Wait()). +Fork(i) == + /\ phase[i] = "ready" + /\ ~IsLeaf(i) + /\ phase' = [phase EXCEPT ![i] = "waiting", + ![Left(i)] = "ready", + ![Right(i)] = "ready"] + +\* Leaf: a ready leaf sorts its range sequentially (enters the writing phase). +Leaf(i) == + /\ phase[i] = "ready" + /\ IsLeaf(i) + /\ phase' = [phase EXCEPT ![i] = "merging"] + +\* Join+merge: a waiting node whose BOTH children are done may now write its +\* full range. This guard is the WaitGroup fence -- the whole point of the check. +Merge(i) == + /\ phase[i] = "waiting" + /\ phase[Left(i)] = "done" + /\ phase[Right(i)] = "done" + /\ phase' = [phase EXCEPT ![i] = "merging"] + +\* A writing task finishes. +Finish(i) == + /\ phase[i] = "merging" + /\ phase' = [phase EXCEPT ![i] = "done"] + +AllDone == phase[1] = "done" \* the root finishes only after its whole subtree + +Next == + \/ \E i \in NodeIds : Fork(i) \/ Leaf(i) \/ Merge(i) \/ Finish(i) + \/ (AllDone /\ UNCHANGED vars) \* stutter when finished (no false deadlock) + +Spec == + /\ Init /\ [][Next]_vars + /\ \A i \in NodeIds : WF_vars(Fork(i) \/ Leaf(i) \/ Merge(i) \/ Finish(i)) + +----------------------------------------------------------------------------- +\* Properties (see ParallelSort.cfg). + +\* SAFETY / no data race: all tasks writing at the same time own disjoint ranges. +NoDataRace == + \A i, j \in Writing : (i /= j) => Disjoint(i, j) + +\* LIVENESS / termination: the fork/join always completes (no deadlock). +Terminates == <>AllDone +============================================================================= diff --git a/formal/tla/README.md b/formal/tla/README.md index a80c687..16a6744 100644 --- a/formal/tla/README.md +++ b/formal/tla/README.md @@ -1,8 +1,25 @@ -# TLA+ model check of sleep sort +# TLA+ model checks of the concurrent sorts -`SleepSort.tla` is a [TLA+](https://lamport.azurewebsites.net/tla/tla.html) -model of the concurrent sleep sort in [`sort/sleep.go`](../../sort/sleep.go), -checked exhaustively by the TLC model checker. +Two [TLA+](https://lamport.azurewebsites.net/tla/tla.html) models, checked +exhaustively by the TLC model checker: + +- **`SleepSort.tla`** — the sleep sort in [`sort/sleep.go`](../../sort/sleep.go). +- **`ParallelSort.tla`** — the fork/join structure shared by + [`sort/parallelmerge.go`](../../sort/parallelmerge.go) and + [`sort/parallelquick.go`](../../sort/parallelquick.go): checks that + concurrently-writing tasks own **disjoint** array ranges (no data race) and + that the join always completes (termination). Removing the WaitGroup fence + (letting a parent merge before its children finish) makes TLC report + `Invariant NoDataRace is violated` — so the check has teeth. This model + complements the dynamic race detector (`make verify`) with an *exhaustive* + guarantee over the whole recursion tree. + +The rest of this file documents `SleepSort.tla`. + +## SleepSort + +`SleepSort.tla` models the concurrent sleep sort in +[`sort/sleep.go`](../../sort/sleep.go). ## What this does and does NOT prove |
