summaryrefslogtreecommitdiff
path: root/formal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-07-06 10:15:56 +0300
committerPaul Buetow <paul@buetow.org>2026-07-06 10:15:56 +0300
commitf74812f8eda48194b622bdd318f35d3a6b6328cd (patch)
tree074784495e62f418d9ba4071e824028e8a3daf8c /formal
parent7aa41c07d15619512a490a0416a504e3200ebf85 (diff)
Add layered formal-verification harness
Adds four complementary layers to verify correctness, all runnable locally, weakest-but-broadest to strongest-but-narrowest: 0. Paper proofs (docs/verification.md): Hoare invariants, termination measures, and permutation arguments for every algorithm. 1. Property tests (sort/property_test.go): testing/quick asserting ordering AND permutation for every sort. Closes a real gap -- the existing tests only checked .Sorted(), so a sort dropping/duplicating elements passed. 2. make verify: go vet + staticcheck + go test -race -short, with -short gating of the large sizes in sort/search tests so the race build is quick. 3. make verify-model: TLA+/TLC model check of sleep sort (termination, deadlock-freedom, sorted permutation) -- formal/tla/. 4. make verify-formal: Gobra deductive proof (Viper+Z3) that a monomorphized insertion sort is memory-safe and sorted for all inputs -- formal/. The static layer already found a latent bug: hash() used key<<10 on a generic integer, which silently yields 0 for narrow key types (int8), degrading the hash. Tests missed it because they only use int keys. Fixed by mixing in int64; documented extensively in docs/case-study-hash-shift-bug.md. Also cleans up dead code and a blank-identifier range flagged by staticcheck. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat (limited to 'formal')
-rw-r--r--formal/README.md59
-rw-r--r--formal/insertion.go42
-rw-r--r--formal/tla/README.md62
-rw-r--r--formal/tla/SleepSort.cfg13
-rw-r--r--formal/tla/SleepSort.tla108
5 files changed, 284 insertions, 0 deletions
diff --git a/formal/README.md b/formal/README.md
new file mode 100644
index 0000000..f667bdf
--- /dev/null
+++ b/formal/README.md
@@ -0,0 +1,59 @@
+# Gobra deductive proof
+
+`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
+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`:
+
+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.
+
+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
+[`docs/verification.md`](../docs/verification.md); proving it in Gobra needs
+ghost multiset state and is noted there as future work.
+
+## Why a separate, non-generic copy?
+
+Gobra's support for Go generics and method-based abstractions is limited, so
+this file is a deliberately monomorphized copy of `sort.Insertion`
+([`sort/insertion.go`](../sort/insertion.go)): plain `[]int` instead of
+`ds.ArrayList[V]`, and an inlined swap instead of the `.Swap` method. The
+algorithm is otherwise identical.
+
+## Running
+
+Gobra is distributed as a container image (it bundles its own Z3, so no separate
+solver install is needed). With `podman` (or `docker`):
+
+```sh
+podman pull ghcr.io/viperproject/gobra:latest
+make verify-formal
+```
+
+or directly:
+
+```sh
+podman run --rm -v "$PWD/formal:/gobra/formal:z" \
+ ghcr.io/viperproject/gobra:latest -i /gobra/formal/insertion.go
+```
+
+Expected output ends with:
+
+```
+Gobra found 0 errors.
+```
+
+To convince yourself the proof is not vacuous, flip the inner comparison
+`a[j] < a[j-1]` to `>` and re-run: Gobra reports
+`Loop invariant might not be preserved`.
diff --git a/formal/insertion.go b/formal/insertion.go
new file mode 100644
index 0000000..cf053a7
--- /dev/null
+++ b/formal/insertion.go
@@ -0,0 +1,42 @@
+package formal
+
+// Insertion sorts a in ascending order, in place. This is a monomorphized
+// (non-generic, plain []int, inlined swap) copy of sort.Insertion, annotated so
+// the Gobra verifier can prove -- with the Viper/Z3 backend -- BOTH:
+//
+// 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), and
+// 2. functional correctness (ordering): on return, a is sorted ascending
+// (the postcondition "forall p<q :: a[p] <= a[q]").
+//
+// The permutation half of correctness (that the output is a rearrangement of
+// the input) is left to the property tests + the paper proof in
+// docs/verification.md; proving it here would require ghost multiset state.
+//
+//@ 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 Insertion(a []int) {
+ i := 0
+ //@ invariant 0 <= i && i <= len(a)
+ //@ invariant forall k int :: 0 <= k && k < len(a) ==> acc(&a[k])
+ // The prefix a[0..i) is already fully sorted.
+ //@ invariant forall p, q int :: 0 <= p && p < q && q < i ==> a[p] <= a[q]
+ for i < len(a) {
+ j := i
+ //@ invariant 0 <= j && j <= i && i < len(a)
+ //@ invariant forall k int :: 0 <= k && k < len(a) ==> acc(&a[k])
+ // a[0..i] is sorted once position j is ignored as either endpoint...
+ //@ invariant forall p, q int :: 0 <= p && p < q && q <= i && p != j && q != j ==> a[p] <= a[q]
+ // ...and the in-flight element a[j] is <= everything to its right.
+ //@ invariant forall q int :: j < q && q <= i ==> a[j] <= a[q]
+ for j > 0 && a[j] < a[j-1] {
+ tmp := a[j]
+ a[j] = a[j-1]
+ a[j-1] = tmp
+ j = j - 1
+ }
+ i = i + 1
+ }
+}
diff --git a/formal/tla/README.md b/formal/tla/README.md
new file mode 100644
index 0000000..a80c687
--- /dev/null
+++ b/formal/tla/README.md
@@ -0,0 +1,62 @@
+# TLA+ model check of sleep sort
+
+`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.
+
+## What this does and does NOT prove
+
+Model checking verifies a **hand-written model**, not the Go source. TLC
+explores *every* reachable state of the model and confirms:
+
+- **`OutputSorted`** (safety) — the collected output is always non-decreasing.
+- **`PermutationWhenDone`** (safety) — the output is a multiset permutation of
+ the input; nothing is dropped, duplicated, or invented.
+- **`Terminates`** (liveness) — the collector eventually receives every value:
+ no deadlock and no value left sleeping forever.
+
+The gap you keep responsibility for: that the model faithfully abstracts
+`sleep.go`. The model represents the sleeps as a discrete clock and the
+unbuffered channel + `WaitGroup` as an urgent single-value rendezvous — see the
+header comment in `SleepSort.tla`.
+
+Sortedness is **not** assumed. It emerges from one rule: time cannot pass while
+a fired-but-unreceived timer waits (`Tick`'s urgency guard). Delete that guard
+and TLC finds an unsorted counterexample — proof the check has teeth.
+
+## Running
+
+Needs Java and `tla2tools.jar` (download once):
+
+```sh
+mkdir -p ~/tools/tlaplus
+curl -fsSL -o ~/tools/tlaplus/tla2tools.jar \
+ https://github.com/tlaplus/tlaplus/releases/latest/download/tla2tools.jar
+```
+
+Then, from the repository root:
+
+```sh
+make verify-model
+```
+
+or directly:
+
+```sh
+java -cp ~/tools/tlaplus/tla2tools.jar tlc2.TLC \
+ -config formal/tla/SleepSort.cfg formal/tla/SleepSort.tla
+```
+
+Expected output ends with:
+
+```
+Model checking completed. No error has been found.
+```
+
+## The input
+
+`SleepSort.cfg` checks the input `<<3, 1, 4, 1, 2>>` (defined as `InputValue`
+in the module, because a `.cfg` cannot hold a sequence literal). It has a
+duplicate and is out of order — enough to exercise ordering, ties, and
+termination. Edit `InputValue` in `SleepSort.tla` to try others; the state space
+stays small because the clock never exceeds the largest input value.
diff --git a/formal/tla/SleepSort.cfg b/formal/tla/SleepSort.cfg
new file mode 100644
index 0000000..6c86d67
--- /dev/null
+++ b/formal/tla/SleepSort.cfg
@@ -0,0 +1,13 @@
+\* TLC configuration for the SleepSort model.
+\* Input is a small multiset with a duplicate (the two 1s) and out-of-order
+\* values -- enough to exercise ordering, ties, and termination exhaustively.
+\* It is defined as InputValue in the module (a .cfg cannot hold a <<..>>).
+CONSTANT Input <- InputValue
+
+SPECIFICATION Spec
+
+INVARIANT TypeOK
+INVARIANT OutputSorted
+INVARIANT PermutationWhenDone
+
+PROPERTY Terminates
diff --git a/formal/tla/SleepSort.tla b/formal/tla/SleepSort.tla
new file mode 100644
index 0000000..45cc0e0
--- /dev/null
+++ b/formal/tla/SleepSort.tla
@@ -0,0 +1,108 @@
+------------------------------ MODULE SleepSort ------------------------------
+(***************************************************************************)
+(* A TLA+ model of the sleep sort in sort/sleep.go. *)
+(* *)
+(* This verifies a *model*, not the Go source. It abstracts the goroutines *)
+(* + WaitGroup + unbuffered channel of sleep.go into: *)
+(* *)
+(* - a global discrete clock (stand-in for wall-clock time / sleeps), *)
+(* - one "timer" per input element that fires when the clock reaches *)
+(* that element's value (a value-v element sleeps v seconds), and *)
+(* - a single collector that receives one fired value at a time *)
+(* (an unbuffered channel with one receiver hands off exactly one *)
+(* value per rendezvous, with no buffering or reordering). *)
+(* *)
+(* Crucially, sortedness of the output is NOT baked in: it must *emerge* *)
+(* from the timing mechanism. The only thing that enforces order is Tick's *)
+(* urgency guard (time cannot pass while a fired-but-unreceived timer is *)
+(* waiting). Remove that guard and TLC finds an unsorted counterexample -- *)
+(* which is exactly what makes this a real check rather than a tautology. *)
+(***************************************************************************)
+EXTENDS Naturals, Sequences, FiniteSets
+
+CONSTANT Input \* the values to sort, as a sequence, e.g. <<3, 1, 2>>
+
+\* Concrete value for the model. TLC config files cannot parse a sequence
+\* literal in a "CONSTANT Input = ..." assignment, so SleepSort.cfg overrides the
+\* constant with this operator via "CONSTANT Input <- InputValue".
+InputValue == <<3, 1, 4, 1, 2>>
+
+Idx == DOMAIN Input
+Values == { Input[i] : i \in Idx }
+
+VARIABLES
+ clock, \* global time; the sleeps are measured against it
+ sent, \* sent[i] = TRUE once element i has been received
+ output \* the values collected so far, in arrival order
+
+vars == <<clock, sent, output>>
+
+\* Multiplicity of value v in a sequence s (compares the two as multisets).
+Count(v, s) == Cardinality({ k \in DOMAIN s : s[k] = v })
+
+TypeOK ==
+ /\ clock \in Nat
+ /\ sent \in [Idx -> BOOLEAN]
+ /\ \A k \in DOMAIN output : output[k] \in Values
+ /\ Len(output) =< Cardinality(Idx)
+
+Init ==
+ /\ clock = 0
+ /\ sent = [i \in Idx |-> FALSE]
+ /\ output = << >>
+
+\* Element i's timer has fired (its sleep of Input[i] seconds has elapsed) and
+\* it has not yet been received.
+Ready(i) == /\ ~sent[i]
+ /\ clock >= Input[i]
+
+AllDone == \A i \in Idx : sent[i]
+
+\* The collector receives one ready value (channel rendezvous: exactly one
+\* value transfers, chosen nondeterministically among those currently ready).
+Receive ==
+ /\ \E i \in Idx :
+ /\ Ready(i)
+ /\ sent' = [sent EXCEPT ![i] = TRUE]
+ /\ output' = Append(output, Input[i])
+ /\ clock' = clock
+
+\* Time advances only when no fired timer is waiting to be received: every
+\* not-yet-received element still has its deadline strictly in the future.
+\* This urgency is what guarantees shorter sleeps deliver before longer ones.
+Tick ==
+ /\ ~AllDone
+ /\ \A i \in Idx : ~sent[i] => clock < Input[i]
+ /\ clock' = clock + 1
+ /\ UNCHANGED <<sent, output>>
+
+\* Once everything has been collected, stutter (models the closed channel /
+\* finished range loop) so termination is not mistaken for a deadlock.
+Done ==
+ /\ AllDone
+ /\ UNCHANGED vars
+
+Next == Receive \/ Tick \/ Done
+
+Spec == Init /\ [][Next]_vars /\ WF_vars(Receive) /\ WF_vars(Tick)
+
+-----------------------------------------------------------------------------
+\* Properties checked by TLC (see SleepSort.cfg).
+
+\* SAFETY: whatever has been collected so far is always non-decreasing.
+IsSorted(s) == \A a, b \in DOMAIN s : a < b => s[a] <= s[b]
+OutputSorted == IsSorted(output)
+
+\* SAFETY: the output never invents or duplicates values, and once finished it
+\* is exactly a permutation (multiset) of the input.
+PermutationWhenDone ==
+ AllDone => \A v \in Values : Count(v, output) = Count(v, Input)
+
+\* LIVENESS: the collector eventually receives every value (no deadlock, no
+\* value left sleeping forever) -- i.e. the algorithm terminates.
+Terminates == <>AllDone
+
+\* Note: the state space is already finite without a constraint -- Tick is
+\* disabled once clock reaches the largest input value, so clock never grows
+\* unbounded and TLC explores every reachable state.
+=============================================================================