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 /docs/case-study-bugs-found.md | |
| 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 'docs/case-study-bugs-found.md')
| -rw-r--r-- | docs/case-study-bugs-found.md | 218 |
1 files changed, 218 insertions, 0 deletions
diff --git a/docs/case-study-bugs-found.md b/docs/case-study-bugs-found.md new file mode 100644 index 0000000..3f5f0a9 --- /dev/null +++ b/docs/case-study-bugs-found.md @@ -0,0 +1,218 @@ +# Case study: three latent bugs the verification harness found + +Adding the verification layers (see [`verification.md`](verification.md)) did not +just re-confirm working code — it surfaced **three real, pre-existing bugs**, all +invisible to the original test suite. Each is documented below with how it was +caught, why it is genuinely wrong, why the old tests missed it, and the fix. + +A theme runs through all three: the original tests were **too weak in a specific +dimension**, and the new checks are strong in exactly that dimension. + +| # | Location | Bug | Caught by | Old test's blind spot | +|---|----------|-----|-----------|-----------------------| +| 1 | `search/hash.go` | shift wider than a narrow key type → term is always 0 | `go vet` (in `make verify`) | only ever used 64-bit `int` keys | +| 2 | `queue/elementarypriority.go` | `max()` seeded at `0` → wrong for all-negative queues | queue permutation property | test data was never negative | +| 3 | `sort/sleep.go` | result built on a pre-sized slice → doubled length with leading zeros | sleep permutation property | only checked `.Sorted()`, not completeness | + +--- + +## Bug 1 — a shift wider than the key type (`search/hash.go`) + +### The offending code + +```go +func (h *Hash[K,V]) hash(key K) int { + i := key + key*2 + key<<10 + key>>2 + ... +} +``` + +`K` is constrained by `ds.Integer`, so it may be **any** width down to `int8`. +The `key<<10` term is meant to spread low bits into high bits. + +### What the tool reported + +``` +$ make verify +go vet ./... +search/hash.go:29:21: key (may be 8 bits) too small for shift of 10 +``` + +`go vet`'s shift analyzer is a lightweight formal check: for every shift it +computes a conservative lower bound on the left operand's bit width and flags any +shift count `>=` that width. The narrowest `K` can be is `int8` (8 bits), and +`10 >= 8`. + +### Why it is genuinely a bug (Go shift semantics) + +The Go spec defines non-constant left shifts operationally: *"Shifts behave as if +the left operand is shifted n times by 1 … There is no upper limit on the shift +count."* Shifting an 8-bit value ten times pushes every bit out of the value's +width, so for `K = int8`/`uint8`: + +``` +key<<10 == 0 // always, for every key +``` + +The intended high-bit mixing silently disappears. Note it is width-dependent +(fine for `int16`+), and it is a *distribution/quality* bug, not a Set-contract +violation — chaining keeps the table correct, but narrow-key instantiations +degrade toward `O(n)` per operation. Exactly the kind of silent rot no assertion +would flag. + +### Why the tests missed it + +Every test uses `int` keys (`test[int,int](NewHash[int,int](i*2), …)`), where +`int` is 64 bits and the shift is fine. The bug lives in the **type dimension**, +not the value dimension — no value-space test or fuzzer over `int` could reach +it; only a type-aware tool (or an actual `int8` instantiation) can. + +### The fix + +```go +func (h *Hash[K,V]) hash(key K) int { + // Mix the key in a full-width int64 rather than in K. ... + i := int64(key) + i = i + i*2 + i<<10 + i>>2 + ... +} +``` + +Widening to `int64` before the shift keeps the result **byte-identical for +64-bit `int` keys** (so all existing tests still pass unchanged) while making the +mixing well-defined for every width. It does not *suppress* the warning; it +removes the condition (`shift >= width`) that made it true. + +--- + +## Bug 2 — a maximum seeded at zero (`queue/elementarypriority.go`) + +### The offending code + +```go +func (q *ElementaryPriority[T]) max() (ind int, max T) { + for i, a := range q.a { + if a > max { // max starts at the zero value of T, i.e. 0 + ind, max = i, a + } + } + return ind, max +} +``` + +`max` is a named return, so it starts at `T`'s zero value, `0`. + +### How it was caught + +The new completeness/permutation property in `queue/property_test.go` drives +each queue with `testing/quick`, which generates **negative** values too. It +failed immediately for `ElementaryPriority` (and passed for `HeapPriority`): + +``` +ElementaryPriority violated ordered-permutation property: + #1: failed on input []int{-1881664299226649700, 1264358012858162353, ...} +``` + +### Why it is genuinely a bug + +If **every** element in the queue is negative, no element is `> 0`, so the loop +never updates and `max()` returns `(0, 0)` — reporting a maximum of `0`, a value +that is not even in the queue. `DeleteMax` then removes the wrong element (index +0) and returns a phantom `0`. Both the ordering and completeness of a drain +break. + +### Why the tests missed it + +The original queue test builds inputs with +`ds.NewRandomArrayList[int](l, -1)`, whose values come from `rand.Int()` — always +**non-negative**. And `queue_test.go` only checked that `DeleteMax` was +non-increasing; it never checked that all inserted elements come back. So a queue +of non-negative numbers, checked only for ordering, sailed through. + +### The fix + +```go + if len(q.a) == 0 { + return 0, 0 + } + ind, max = 0, q.a[0] // seed from a real element, not the zero value + for i, a := range q.a { + if a > max { ind, max = i, a } + } +``` + +Seeding from `q.a[0]` makes the scan correct for any value range. +(`HeapPriority` was already immune: it compares actual array elements and returns +`a[1]` as the max.) + +--- + +## Bug 3 — a sort that doubled its output (`sort/sleep.go`) + +### The offending code + +```go +func Sleep[V ds.Integer](a ds.ArrayList[V]) ds.ArrayList[V] { + sorted := ds.NewArrayList[V](len(a)) // slice of LENGTH len(a): len(a) zeros + ... + for num := range numCh { + sorted = append(sorted, num) // appends AFTER those zeros + } + return sorted +} +``` + +`ds.NewArrayList(len(a))` is `make(ArrayList, len(a))` — a slice of that +**length**, pre-filled with `len(a)` zeros. Appending then adds the real values +*after* them. + +### How it was caught + +`TestSleepSort`, strengthened to check permutation, failed: + +``` +Sleep sort output is not a permutation of input: + in =[1 1 2 8 8 7 3 0 6 7] (10 elements) + out=[0 0 0 0 0 0 0 0 0 0 0 1 1 2 3 6 7 7 8 8] (21 elements!) +``` + +The output is **eleven leading zeros followed by the ten real values** — more +than double the input length. + +### Why the tests missed it + +The original `TestSleepSort` only asserted `a.Sorted()`. Zeros followed by an +ascending sequence **is** sorted, so the wildly-wrong 21-element result passed. +This is the textbook case for the *permutation* invariant: ordering alone cannot +detect dropped, duplicated, or (here) invented elements. + +### The fix + +```go + // Start empty with capacity len(a): the received values are appended below. + sorted := make(ds.ArrayList[V], 0, len(a)) +``` + +Length `0`, capacity `len(a)`: the appends now fill it to exactly `len(a)` +elements with no spurious zeros. + +--- + +## The through-line + +Two independent lessons, each reinforced twice: + +1. **Ordering is not correctness.** Bugs 2 and 3 both produced *ordered* output + that was wrong (missing/extra elements). Only the **permutation / completeness** + invariant — added to the sort and queue property tests — catches them. This is + the single most valuable check added by this work. + +2. **Test data has blind spots the code doesn't.** Bugs 1 and 2 both hid behind + the test suite's fixed input distribution — always 64-bit, always + non-negative. `go vet` (reasoning over *types*) and `testing/quick` (sampling + the *whole* value range, negatives included) each see past a blind spot that + hand-picked or `rand.Int()` data does not. + +None of these required the heavy layers (TLA+, Gobra). The cheapest checks — +`go vet` and a stronger property assertion — found all three. Breadth first; +depth where it earns its keep. |
