Skip to content

Rewrite the core: public aug package, runtime degree, monoid augmentations, cursor - #3

Merged
ajwerner merged 30 commits into
mainfrom
harden
Sep 29, 2026
Merged

ajwerner merged 30 commits into
mainfrom
harden

Conversation

@ajwerner

Copy link
Copy Markdown
Owner

Summary

A rewrite of the core with the same design, driven by the fit-index brief: google/btree-class copy-on-write behaviour, pluggable free lists, order-statistic queries, and a mutating cursor, on Go 1.26 with no dependencies. CHANGELOG.md lists the API changes; docs/design.md explains the architecture; docs/review-2026-09-29.md is the log of every measurement and decision.

Bugs fixed

  • Clone dropped every value in a node on its first copy-on-write (the library had only been exercised as a set).
  • orderstat Rank() was wrong for trees of height 3 or more.
  • Next past the end and Prev before the beginning panicked on interior roots.
  • The orderstat test package did not build under Go 1.24+.
  • Upserting an existing key never updated value-derived augmentations.
  • The interval iterator kept an overlap scan's constraints across a plain seek.

Changes

  • internal/abstract becomes the public package aug. Nodes are slice-backed with keys and values in separate slices; the degree is a per-tree option (default 16) and free lists are pluggable (bounded default of 256, sync.Pool variant).
  • New/NewSet return pointers; NewOrdered searches nodes with < for cmp.Ordered keys. Reset is Clear.
  • Monoid augmentations (Monoid, Group, Count, Sum, PairOf) with Total, Prefix(key), Aggregate(lo, hi) and SeekWhere(pred) on MonoidMap; UpdateInfo carries values and a Replacement action.
  • orderstat is 40 lines over Count: Rank(key), Nth, Count(lo, hi), Cursor.
  • Cursor: SetValue, Delete, Rekey, hinted Upsert, in place when the leaf allows it, maintaining augmentations up the pinned path.
  • Seeks report exact matches; SeekLE/SeekGT; All/Backward/Range/From views; Min/Max; interval.Bounds, BoundsOf, Overlapping.
  • Map.Verify invariant checker; differential, property, fuzz, retention and race tests across degrees 2 to 64; CI with gofmt, vet, staticcheck, race tests, fuzzing and govulncheck.

Performance (Apple M-series, Go 1.27.1, three runs, vs google/btree v1.1.3 at equal degree)

workload result
point operations, comparison function parity to 10% ahead
point operations, NewOrdered 15 to 25% ahead
clone per operation without release 5 to 15% behind (refcount traffic; the price of recycling shared nodes)
clone, 200 rekeys, release, 20k hosts 53.4 µs google/btree vs 53.4 µs delete+upsert vs 44.8 µs through the cursor

Experiments run and decided on evidence (all in the review doc): separate key/value layout (adopted), ordered-key search (adopted), degree 16 (confirmed), free-list size, native SeekNth, non-atomic refcounts (declined), eager cursor prefix (declined), an existence check in Delete (reverted after a regression).

Test plan

  • go test -race ./... on Go 1.26.8 and 1.27.1
  • go vet, staticcheck, govulncheck clean
  • 60 s fuzz runs of FuzzMap and FuzzCursor
  • Two independent review passes of the branch absorbed
  • CI green on this PR

🤖 Generated with Claude Code

ajwerner and others added 30 commits September 29, 2026 00:35
… panics

- Node.clone copied keys and children but not values, so every value in a
  node was zeroed after the first copy-on-write following Clone. Only sets
  (V = struct{}) had ever been tested.
- orderstat Iterator.Rank ascended exactly one frame, so it under-counted
  for any tree of height 3 or more. It now sums every ancestor frame via a
  new LowLevelIterator.Frame accessor.
- Next past the end and Prev before the beginning descended into
  children[count+1] / children[-1]. Both now stay invalid; Next from before
  the beginning yields the first key and Prev from past the end yields the
  last.
- Add Map.Verify, a structural and augmentation invariant checker for
  tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- go.mod: go 1.26.0, no dependencies. testify's few uses replaced with
  plain testing helpers. go fix applied (interface{} -> any, range over int).
- Rename ExampleBlog, which vet rejects and which broke the orderstat test
  build since Go 1.24.
- New tests: differential test of Map against a model with interleaved
  Clone/Reset across up to eight trees, iterator over-run edge cases at
  heights 1-3, clone isolation for a Map with string values, a fuzz target,
  and orderstat Rank/SeekNth checks at every height including 300k items.
  All run Verify.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… lists

Replace internal/abstract with the public package aug:

- Nodes are slice-backed (one []entry{v, k} plus []*Node for interior
  nodes) so the degree is a per-tree runtime setting. Clone copies and
  free clears only the live prefix of each node. DefaultDegree is 16.
- FreeList is an interface; NewFreeList gives a bounded mutex-guarded
  list (the default, DefaultFreeListSize 32) and SyncPoolFreeList a
  sync.Pool-backed one shared per instantiation. Free lists may be shared
  by trees of different degrees.
- New(cmp, updater, ...Option) returns *Map; WithDegree and WithFreeList
  are the options. Reset is now Clear. Get no longer builds an iterator.
- Merging a shared sibling no longer clones it just to free it.
- Node exposes GetValue.

btree, orderstat and interval now wrap *aug.Map and expose New/NewSet
returning pointers, plus Contains on sets. orderstat's SeekNth leaves the
iterator invalid for an out-of-range rank instead of panicking.

Tests run the differential, edge, rank and fuzz suites across degrees, and
add free-list recycling, sharing across degrees, option validation and
concurrent snapshot reader tests.

On the clone -> 200 rekeys -> Clear round with a 24-byte key and pointer
value, degree 16 with a 1024-node free list runs at 50µs per round for a
20k-host fleet versus 53µs for google/btree at degree 16, with 2 allocs
per round.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…seek

- aug.Monoid (Of, Combine) and aug.Group (adds Uncombine) describe an
  augmentation as per-entry contributions; aug.MonoidUpdater turns one into
  an Updater, incremental for Groups and recomputing otherwise. Optional
  aug.Equaler stops propagation when nothing changed and aug.Folder folds
  node spans in one call. Ready-made: Count, Sum(f), PairOf(a, b).
- UpdateInfo carries RelevantValue and a new Replacement action with the
  previous entry, so value-derived aggregates stay correct when an existing
  key is upserted. Updaters receive UpdateInfo[K, V, A].
- Map.Total, Map.Prefix(key) and Map.Aggregate(lo, hi); Iterator.Prefix()
  and Iterator.SeekWhere(pred), a single descent to the first entry where a
  monotone predicate on the running prefix flips.
- orderstat is rewritten over aug.Count: Rank(key), Nth, Count(lo, hi) on
  Map and Set; Iterator.Rank() returns the insertion rank when invalid.
- Tests: value-derived Sum, Max (a non-group) and Pair aggregates checked
  against a model across degrees with clones and replacements, plus
  benchmarks for the rank operations.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
aug.Cursor embeds Iterator. Its first write pins the root-to-leaf path
(copy-on-write per frame); SetValue, Delete, Rekey and a hinted Upsert
then act in place when the leaf can absorb the change, maintaining
augmentations up the pinned path, and otherwise fall back to the top-down
algorithm plus a re-seek. Delete leaves the cursor on the successor, Rekey
on the moved entry (a nearby move after the in-place delete re-inserts
through the hinted Upsert without another descent), Upsert on the upserted
entry.

Exposed as btree.MapCursor/SetCursor and orderstat.Cursor with Rank and
SeekNth. A differential test drives every cursor operation against a model
at degrees 2, 3 and 16 with clones taken mid-stream and Verify after each
checkpoint.

On a 20k-entry map with 24-byte keys and a count augmentation, moving an
entry to a nearby key through the cursor takes 266 ns versus 424 ns for
seek, Delete and Upsert.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- Map.All, Backward, Range(lo, hi) and From(lo) return iter.Seq2 views
  built on a recursive node walk; btree.Set and orderstat.Set expose them
  as iter.Seq. Map.Min and Max walk to the ends.
- Measured at 1M entries: the views cost the same per entry as the
  stateful Iterator once the loop body touches the data; with an empty
  body the range-over-func rewrite adds about 2.7 ns per yield. An All
  built on the Iterator was slower than the recursive walk and was
  dropped.
- FuzzCursor drives every cursor operation from a byte script against a
  model with snapshots; TestCursorWriterWithSnapshotReaders runs a cursor
  writer against readers of the snapshots it hands out under the race
  detector.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- orderstat.SeekNth descends by child counts through the low-level
  iterator again: 94 ns versus 310 ns for the SeekWhere form at 1M
  entries. SeekWhere remains the general primitive.
- DefaultFreeListSize is 256: a clone that takes a few hundred writes and
  is cleared frees about one node per touched path, and 32 spilled most of
  them to the GC (70 µs versus 50 µs per round at 20k entries).
- Measured that atomic refcounts cost 2-7% against plain arithmetic; the
  refcount overhead is the memory traffic, so atomics stay.
- With Cursor.Rekey the scheduler-shaped round runs at 42 µs against 53 µs
  for google/btree at degree 16. README gets a performance section.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…mples

- Node accessors lose their Get prefix: Aug, Key, Value, ChildAug;
  LowLevelIterator.Child is ChildAug; Iterator.Cur is Key, next to Value.
- interval.New and NewSet take a Bounds struct (Compare, Key, End, optional
  HasEnd and CompareIntervals with documented defaults) instead of five
  positional functions; the exported Cmp type is gone. Map and Set gain
  Overlapping(bounds) as a range-over-func view.
- Examples for orderstat (Rank, Count, Nth, SeekNth) and for a cursor sweep
  that charges the least loaded host in place; the blog post carries a note
  about the API having moved on.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
staticcheck reported an unused helper, an unused example method and two
bytes.Compare == 0 sites; fixed, and staticcheck joins vet in CI. The
interval test suite now uses math/rand/v2 with a random seed logged per
test or benchmark instead of time-seeded math/rand.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
aug.NewOrdered, btree.NewOrdered/NewOrderedSet and orderstat.NewOrdered/
NewOrderedSet install a node search that compares with < directly instead
of calling the comparator per probe. The comparator path is unchanged. On
int keys this is 15-25% faster on every point operation; at degree 32 Get
at 1M entries takes 141 ns against 187 ns for google/btree at the same
degree. The differential test runs both constructors at every degree.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
TestOverlapProperty mixes random points and ranges, deletes, clones and
Verify at degrees 2, 3 and 16 and compares Overlapping against a
reference overlap definition after every thousand operations. BoundsOf
builds Bounds for interval types that expose Key and End methods. Stale
"abstract" wording in the overlap scan commentary is fixed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…pied

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…veat

- SeekWhere with a predicate that breaks monotonicity must still leave the
  iterator valid at some entry or past the end, with Prefix() matching what
  it returned; always-false and always-true predicates are checked too.
- A 2M-entry degree-2 tree (height above the iterator's ten inline frames)
  is driven through seeks, ranks, views and cursor writes on a clone so the
  spilled frame stack is exercised on every path. Skipped under -short.
- NewOrdered documents that NaN keys are unsupported.
- 60 s fuzz runs of FuzzMap and FuzzCursor found nothing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… scans

SeekGE and SeekLT return whether the sought key exists, saving callers a
compare; SeekLE and SeekGT complete the set. interval.Iterator's plain
seeks now clear an overlap scan in progress, so NextOverlap after a seek
invalidates instead of continuing with stale constraints; tested.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The generic monoid updater made three interface calls per level for what
the count augmentation needs as an integer increment, costing orderstat
15% on inserts against the plain map. MonoidUpdater now returns a
dedicated Updater for aug.Count with no interface calls; Prefix, SeekWhere
and the rest keep working through the same monoid hook. orderstat is back
to within 5% of the plain map.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Searching a node now touches only keys. Against the interleaved layout,
Get is 5-9% faster with 8-byte values, 22-31% with 64-byte values and
20-24% with 256-byte values; delete+insert gains 3-21% with the same
sizes. The clone-heavy round with pointer values pays 1-4% for copying two
slices per copied node. Sets are unaffected: a zero-size value slice costs
no allocation. BenchmarkValueSize records the measurement.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
TestNoStaleReferences uses weak pointers to prove that values removed by
top-down Delete, Cursor.Delete, replacement and Clear (of a clone and of
the original) become unreachable, so no stale slot in a live or
free-listed node retains them. TestPointerTypesUnderGCPressure runs a
mixed workload with string keys and pointer values, clones and cursor
deletes while forcing collections, under the race detector.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e total, cheaper absent deletes

- New rejects degrees above MaxDegree (16384): positions are int16, and a
  larger degree made Count wrap so iterators saw nothing while Get and
  Len worked. Tested at the cap.
- Verify no longer panics for interface-typed keys or values: a nil
  interface gives reflect the invalid Value, which IsZero rejects.
- SeekWhere returns the total when it ends past the end inside a subtree,
  as its documentation promised; the non-monotone test asserts it.
- Delete of an absent key from a shared tree checked existence first, so
  it no longer copies the whole path; a counting free list verifies no
  node is allocated. Only the first write after a Clone pays the check.
- Documented that Monoid.Combine must be commutative (the Updater folds
  contributions in operation order) and that Replacement's key may differ
  for an in-place Cursor.Rekey while still sorting between the same
  neighbours; tidied a shadowed variable in Cursor.Delete.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The check made deleting an absent key from a fresh clone free, but cost
every first delete after a Clone a second descent: the clone-then-delete
benchmark regressed 16-20%. Deleting present keys is the common case, so
Delete goes back to one mutating descent and documents that an absent key
on a shared tree copies the path, as google/btree does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… Verify never writes shared nodes

- Sum takes Integer types only: floating-point sums are neither
  associative nor invertible, so they cannot be a Monoid, let alone a
  Group, and Verify would report drift on a correct tree.
- MaxDegree is math.MaxInt16/2 so that the interval scan's Count()+1
  sentinel fits in an int16 on a full leaf.
- Verify recomputes augmentations on a shallow copy of each node, so it
  is safe alongside readers of snapshots sharing those nodes.
- Range and Aggregate skip the lower boundary child when lo is found in
  the node, since every key in it is below lo.
- The bounded free list allocates its slice on the first Put instead of
  at construction; the iterator frame stack clears popped slots, returns
  to the inline array on reset and reuses a spilled slice; Next and
  Cursor.Delete share one settle helper.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Total, Prefix and Aggregate, and Prefix and SeekWhere on iterators and
cursors, were promoted onto every map built on aug, including btree.Map
and interval.Map, where they could only panic. They now live on
aug.MonoidMap, MonoidIterator and MonoidCursor, which NewMonoid and
NewOrderedMonoid return; orderstat is built on MonoidMap. A plain map no
longer offers them at all.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…rection

btree.Map held a pointer to aug.Map and orderstat.Map a pointer to
aug.MonoidMap which held a pointer to aug.Map: two or three allocations
per New and Clone and an indirection per method call. aug.MonoidMap now
embeds Map by value, and the btree, orderstat and interval maps and sets
are defined types over the aug types with explicit forwarding methods, so
the conversion is free and each map is a single allocation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@ajwerner
ajwerner merged commit 817247d into main Sep 29, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant