diff options
| author | Paul Buetow <paul@buetow.org> | 2026-07-06 10:15:56 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-07-06 10:15:56 +0300 |
| commit | f74812f8eda48194b622bdd318f35d3a6b6328cd (patch) | |
| tree | 074784495e62f418d9ba4071e824028e8a3daf8c /formal | |
| parent | 7aa41c07d15619512a490a0416a504e3200ebf85 (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.md | 59 | ||||
| -rw-r--r-- | formal/insertion.go | 42 | ||||
| -rw-r--r-- | formal/tla/README.md | 62 | ||||
| -rw-r--r-- | formal/tla/SleepSort.cfg | 13 | ||||
| -rw-r--r-- | formal/tla/SleepSort.tla | 108 |
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. +============================================================================= |
