summaryrefslogtreecommitdiff
path: root/formal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-07-06 10:32:34 +0300
committerPaul Buetow <paul@buetow.org>2026-07-06 10:32:34 +0300
commit3f906d03262150892e2297e621bfc56e425ef142 (patch)
tree79d69a4b011882716626565c27793ce04532227e /formal
parentf74812f8eda48194b622bdd318f35d3a6b6328cd (diff)
Close verification-coverage gaps; harness finds two more bugsHEADmaster
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.md25
-rw-r--r--formal/selection.go48
-rw-r--r--formal/tla/ParallelSort.cfg13
-rw-r--r--formal/tla/ParallelSort.tla117
-rw-r--r--formal/tla/README.md25
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