Skip to content

Commit f6a68a6

Browse files
marius-bughiuclaude
andcommitted
docs(collections): address Copilot review on SparseSet — precise Clear wording + terser changelog
- Reword the "uninitialized garbage" / "touches no memory" claims in SparseSet's XML docs, docs/api/collections.md, README, ship card, and benchmark comment: .NET arrays are zero-initialized, so the real property is tolerating *stale* sparse entries across Clear/mutations, and Clear() resets count/version rather than touching no memory (it just leaves the backing arrays untouched). - Condense the CHANGELOG [Unreleased] SparseSet entries to the repo's brevity standard (a user-facing lead bullet + one parity-rollout bullet), so the section stays within the release-body size the release workflow extracts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 45b3aba commit f6a68a6

6 files changed

Lines changed: 34 additions & 35 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,8 @@ All notable changes to Celerity are documented here. This project follows [Keep
66

77
### Added
88

9-
- **`SparseSet`** in `Celerity.Collections` — a set of non-negative integers over a bounded universe `[0, Universe)`, backed by the Briggs–Torczon sparse-set representation (a dense value array + a sparse index array), filling a BCL gap (.NET ships no sparse set). Its two wins over `HashSet<int>`: `Clear()` is `O(1)` and touches no memory (`HashSet` zeroes its whole table), and iteration is a dense, cache-friendly scan over exactly the present elements — the winning shape for clear-and-rebuild "visited" sets in graph traversal, ECS, and sweep-line code. `Add` / `Contains` / `Remove` are `O(1)` with no hashing. Costs `O(Universe)` memory and stores only values in `[0, Universe)` (out-of-range throws on `Add`, reads as absent on `Contains` / `Remove`). Implements `ISet<int>`. An opt-in specialized type, not a `HashSet<int>` replacement. Closes [#287](https://github.com/marius-bughiu/Celerity/issues/287).
10-
- `SparseSetTests`, `SparseSetEnumerationTests`, and `SparseSetDifferentialTests` (`Celerity.Tests/Collections`) — dedicated coverage mirroring the `IntSet*` / `SmallSet*` files: the core surface (add / try-add / duplicate / out-of-range / contains / remove-swap / dense-array growth / the O(1) `Clear`-then-reuse path that must reject stale sparse entries), the enumeration surface (every-element-once, growth, post-remove/-clear, mutation detection, `Reset`, non-generic path), and a 6,000-step randomized differential reconciling every mutating / query / clear operation against a `HashSet<int>` oracle over a bounded non-negative universe.
11-
- Cross-collection set-algebra rows for `SparseSet` in `SetAlgebraTests``ISet<int>` conformance, the basic algebra within the universe, and the bounded-universe caveat that a mutating op which must add an out-of-universe value throws `ArgumentOutOfRangeException`. (`SparseSet` carries its own `SparseSetDifferentialTests` rather than joining the shared `SetAlgebraDifferentialTests`, whose universe spans negatives it cannot store — exactly as `EnumSet` does.)
12-
- `SparseSetBenchmark` in `Celerity.Benchmarks`, registered in `Program.cs`'s `CoreBenchmarks` array (so it joins the per-PR core run and the gh-pages dashboard, mirroring `IntSetBenchmark`) — `SparseSet` vs `HashSet<int>` across `Add` / `Contains` / **`ClearRefill`** (the O(1)-clear headline win) / `Remove` at `[Params(1000, 100_000)]` over a universe 4× the item count.
13-
- Dashboard wiring for `SparseSet`: the "What ships in the box" ship card in [`web/index.html`](web/index.html) and the `COLLECTIONS` arrays in [`web/dev/bench/index.html`](web/dev/bench/index.html) (key / title / vs / ops incl. `ClearRefill`) and [`web/dev/bench/detail.html`](web/dev/bench/detail.html) (key / title / vs).
14-
- A `SparseSet` differential target in the `Celerity.Fuzz` harness (`SparseSetCase`) — reconciling `TryAdd` / `Remove` / `Clear` churn and enumeration against a `HashSet<int>` oracle over the type's bounded non-negative universe (it draws from `[0, 32)` rather than the shared `[-8, 24]` key domain it cannot store).
15-
- Documentation for `SparseSet`: a full API section in [`docs/api/collections.md`](docs/api/collections.md#sparseset) (the Briggs–Torczon mechanism, the O(1)-clear and dense-iteration wins, the `O(Universe)`-memory / non-negative-only tradeoffs, constructors, the method table, the set-algebra caveat, and a runnable BFS-visited-set example), plus README entries — the Sets list, the `ISet<T>` note, a `SparseSet` paragraph with a runnable example, and a new "cleared-and-rebuilt bounded-int set" row in the "Choosing a collection" decision table.
9+
- **`SparseSet`** in `Celerity.Collections` — a bounded-universe `[0, Universe)` integer set (the Briggs–Torczon sparse set), filling a BCL gap. It wins over `HashSet<int>` where the set is cleared and rebuilt often: `Clear()` is `O(1)` (it leaves the backing arrays untouched, versus `HashSet` zeroing its table) and iteration is a dense scan over just the present elements — the "visited"-set shape in graph traversal, ECS, and sweep-line code. Costs `O(Universe)` memory and stores only values in `[0, Universe)`. Implements `ISet<int>`; an opt-in specialized type, not a `HashSet<int>` replacement. Closes [#287](https://github.com/marius-bughiu/Celerity/issues/287).
10+
- Full parity rollout for `SparseSet`: dedicated tests (`SparseSetTests` / `SparseSetEnumerationTests` / `SparseSetDifferentialTests`), a `SetAlgebraTests` conformance row, a `Celerity.Fuzz` `SparseSetCase`, `SparseSetBenchmark` (registered in `Program.cs`, with a `ClearRefill` category), dashboard wiring (`web/index.html`, `web/dev/bench/{index,detail}.html`), and docs (`docs/api/collections.md` + README).
1611

1712
## [2.3.0] - 2026-07-19
1813

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ Standalone libraries built **on top of** Celerity — each solves a real problem
6262
- `IntSet` / `LongSet``int` / `long`-keyed set specializations.
6363
- `SmallSet<T>` — flat-array, linear-scan set for the very-small (`n <= ~16`) case. No hasher; the default element is stored inline. The set counterpart of `SmallDictionary`.
6464
- `EnumSet<TEnum>` — bit-vector set for enum keys (the .NET `EnumSet`): membership is a single bit test and set algebra is word-wise bitwise ops, with no hashing or boxing. Enumerates in ascending underlying-value order.
65-
- `SparseSet` — bounded-universe integer set (Briggs–Torczon sparse set): `O(1)` `Clear` that touches no memory, plus dense, cache-friendly iteration — for clear-and-rebuild "visited" sets over ids in `[0, N)` (graph traversal, ECS, sweep-line). Costs `O(Universe)` memory.
65+
- `SparseSet` — bounded-universe integer set (Briggs–Torczon sparse set): `O(1)` `Clear` that leaves the backing arrays untouched, plus dense, cache-friendly iteration — for clear-and-rebuild "visited" sets over ids in `[0, N)` (graph traversal, ECS, sweep-line). Costs `O(Universe)` memory.
6666

6767
The mutable sets (`CeleritySet`, `SwissSet`, `RobinHoodSet`, `HashCachingSet`, `IntSet`, `LongSet`, `SmallSet`, `EnumSet`, `SparseSet`) all implement **`ISet<T>`** — the full `HashSet<T>` set-algebra surface (`UnionWith` / `IntersectWith` / `ExceptWith` / `SymmetricExceptWith` and the `IsSubsetOf` / `IsSupersetOf` / `Overlaps` / `SetEquals` query family, plus `CopyTo`) with BCL semantics — so they drop in wherever a `HashSet<T>` is used.
6868

@@ -287,7 +287,7 @@ Console.WriteLine(granted.IsSupersetOf(required)); // False — word-wise subset
287287
granted.UnionWith(required); // one bitwise OR
288288
```
289289

290-
`SparseSet` is the bounded-universe integer set — the classic Briggs–Torczon sparse set (a dense value array + a sparse index array). Over a fixed universe `[0, Universe)` chosen at construction, `Add` / `Contains` / `Remove` are `O(1)` with no hashing, but the point of the type is what `HashSet<int>` can't match: `Clear()` is `O(1)` (it resets a count and touches no memory, versus zeroing the whole table) and iteration is a dense, contiguous scan over exactly the present elements. That is the winning shape for clear-and-rebuild "visited" sets — graph BFS/DFS, ECS entity membership, sweep-line — where the set is emptied every iteration. The cost is `O(Universe)` memory and non-negative-values-only: a value outside `[0, Universe)` throws on `Add` and reads as absent on `Contains` / `Remove`. It is an opt-in specialized type, not a `HashSet<int>` replacement — for an unbounded or huge-and-sparse key space, reach for `IntSet`.
290+
`SparseSet` is the bounded-universe integer set — the classic Briggs–Torczon sparse set (a dense value array + a sparse index array). Over a fixed universe `[0, Universe)` chosen at construction, `Add` / `Contains` / `Remove` are `O(1)` with no hashing, but the point of the type is what `HashSet<int>` can't match: `Clear()` is `O(1)` (it resets the count and version without scanning or clearing the backing arrays, versus zeroing the whole table) and iteration is a dense, contiguous scan over exactly the present elements. That is the winning shape for clear-and-rebuild "visited" sets — graph BFS/DFS, ECS entity membership, sweep-line — where the set is emptied every iteration. The cost is `O(Universe)` memory and non-negative-values-only: a value outside `[0, Universe)` throws on `Add` and reads as absent on `Contains` / `Remove`. It is an opt-in specialized type, not a `HashSet<int>` replacement — for an unbounded or huge-and-sparse key space, reach for `IntSet`.
291291

292292
```csharp
293293
var visited = new SparseSet(nodeCount); // universe = ids in [0, nodeCount)
@@ -453,7 +453,7 @@ Each type buys a different tradeoff. Find your workload below; if it isn't here,
453453
| Dictionary keyed by a small **enum** — config-by-enum, per-state data, enum→handler tables | `EnumMap<TEnum, TValue>` | Dense array indexed on the enum's underlying value (the .NET `EnumMap`): `this[key]` / `TryGetValue` / `Add` / `Remove` are a single direct array index — no hashing, no probing, no collisions — and a full sweep is a linear array walk. The dictionary counterpart of `EnumSet`; enumerates ascending by value. For enums whose members are small non-negative integers (the default); negative or sparse `[Flags]` enums are unsupported — use `CelerityDictionary<TEnum, TValue, THasher>` there. |
454454
| Tiny set (`n <= ~16`) that stays small — per-scope "seen" sets, small membership guards, deduping a handful of items | `SmallSet<T>` | The set counterpart of `SmallDictionary`: flat-array linear scan beats hashing at small `n`, no hasher to pick, the default element is stored inline. Implements `ISet<T>`. Degrades to `O(n)` for large sets, so only when instances stay small. |
455455
| Set of **enum** values — flag sets, permission sets, state sets over a small enum | `EnumSet<TEnum>` | Bit-vector set indexed on the enum's underlying value (the .NET `EnumSet`): `Add` / `Contains` / `Remove` are a single bit op — no hashing, no boxing — and set algebra between two `EnumSet`s is a word-wise bitwise `OR` / `AND` / `XOR`. Enumerates ascending by value; `All()` builds the full universe. For enums whose members are small non-negative integers (the default); negative or sparse `[Flags]` enums are unsupported — use `CeleritySet<TEnum, THasher>` there. |
456-
| Set of small **non-negative ints** over a bounded range that is **cleared & rebuilt often** — "visited" sets in graph BFS/DFS, ECS entity membership, sweep-line | `SparseSet` | Briggs–Torczon sparse set (dense value array + sparse index array): `O(1)` `Clear` that touches no memory (vs `HashSet<int>` zeroing its table) and dense, cache-friendly iteration over just the present elements. `Add` / `Contains` / `Remove` are `O(1)`, no hashing. Costs `O(Universe)` memory and stores only values in `[0, Universe)`; for an unbounded or huge-and-sparse key space use `IntSet` / `HashSet<int>`. |
456+
| Set of small **non-negative ints** over a bounded range that is **cleared & rebuilt often** — "visited" sets in graph BFS/DFS, ECS entity membership, sweep-line | `SparseSet` | Briggs–Torczon sparse set (dense value array + sparse index array): `O(1)` `Clear` that leaves the backing arrays untouched (vs `HashSet<int>` zeroing its table) and dense, cache-friendly iteration over just the present elements. `Add` / `Contains` / `Remove` are `O(1)`, no hashing. Costs `O(Universe)` memory and stores only values in `[0, Universe)`; for an unbounded or huge-and-sparse key space use `IntSet` / `HashSet<int>`. |
457457
| Set of `int` values | `IntSet` | Same fast path as `IntDictionary`, membership only. |
458458
| Set of `long` values | `LongSet` | 64-bit equivalent of `IntSet`; defaults to `Int64WangNaiveHasher`. |
459459
| Set of any other type | `CeleritySet<T, THasher>` | Same hasher choice as `CelerityDictionary`. |

docs/api/collections.md

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1981,14 +1981,15 @@ A set of **non-negative integers over a bounded universe** `[0, Universe)`, back
19811981
the classic **Briggs–Torczon sparse-set representation**: a *dense* array holding the
19821982
present values contiguously, paired with a *sparse* array — indexed by value — that
19831983
points each present value back at its slot in the dense array. Membership is the
1984-
round-trip `sparse[v] < Count && dense[sparse[v]] == v`, which is correct even when the
1985-
sparse array holds uninitialized garbage. That single fact is what buys the type its two
1986-
wins over `HashSet<int>`:
1987-
1988-
- **`Clear()` is `O(1)`** — it resets the count and touches *no memory*. `HashSet<int>.Clear()`
1989-
is `O(capacity)` (it zeroes the whole entry table). This is the headline: per-frame /
1990-
per-query "visited" sets in graph traversal (BFS/DFS), ECS entity membership,
1991-
register-allocation liveness, and sweep-line algorithms clear on every iteration.
1984+
round-trip `sparse[v] < Count && dense[sparse[v]] == v`, which is correct even for a *stale*
1985+
sparse entryone left over from before a `Clear`, or the zero a never-written slot still
1986+
holds. That single fact is what buys the type its two wins over `HashSet<int>`:
1987+
1988+
- **`Clear()` is `O(1)`** — it resets the count and version *without scanning or clearing the
1989+
backing arrays*. `HashSet<int>.Clear()` is `O(capacity)` (it zeroes the whole entry table).
1990+
This is the headline: per-frame / per-query "visited" sets in graph traversal (BFS/DFS), ECS
1991+
entity membership, register-allocation liveness, and sweep-line algorithms clear on every
1992+
iteration.
19921993
- **Dense, cache-friendly iteration** — present elements live contiguously in `[0, Count)`
19931994
of the dense array, so enumeration is a linear scan over exactly `Count` ints with no
19941995
empty-slot skipping.

src/Celerity.Benchmarks/SparseSetBenchmark.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44

55
// SparseSet vs HashSet<int> over a bounded universe. The Add / Contains / Remove
66
// categories mirror IntSetBenchmark; the headline is the ClearRefill category, where
7-
// SparseSet's O(1) Clear (a single field reset — no memory touched) beats HashSet's
8-
// O(capacity) table-zeroing on the clear-and-rebuild workload the type is built for
7+
// SparseSet's O(1) Clear (resets the count/version, leaving the backing arrays untouched)
8+
// beats HashSet's O(capacity) table-zeroing on the clear-and-rebuild workload it is built for
99
// (per-frame / per-query "visited" sets). The universe is 4× the item count, so the
1010
// set is ~25% dense — a realistic sparse-occupancy shape.
1111
[MemoryDiagnoser(false)]

src/Celerity/Collections/SparseSet.cs

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,28 +8,30 @@ namespace Celerity.Collections;
88
/// backed by the classic Briggs&#8211;Torczon sparse-set representation (a dense value
99
/// array paired with a sparse index array). It fills a BCL gap — .NET ships no sparse
1010
/// set — and beats <see cref="HashSet{T}"/> on the two things that representation is
11-
/// built for: an <c>O(1)</c> <see cref="Clear"/> that touches no memory, and dense,
12-
/// cache-friendly iteration over exactly the present elements.
11+
/// built for: an <c>O(1)</c> <see cref="Clear"/> that never scans or clears the backing
12+
/// arrays, and dense, cache-friendly iteration over exactly the present elements.
1313
/// </summary>
1414
/// <remarks>
1515
/// <para>
1616
/// The set stores its members contiguously in a <em>dense</em> array (<c>[0, Count)</c>)
1717
/// and keeps a <em>sparse</em> array, indexed by value, whose entry for a present value
1818
/// points back at that value's slot in the dense array. Membership is the sparse&#8596;dense
19-
/// round-trip <c>sparse[v] &lt; Count &amp;&amp; dense[sparse[v]] == v</c>, which is correct
20-
/// even when the sparse array is uninitialized garbage — so <see cref="Clear"/> need only
21-
/// reset the count (<c>O(1)</c>, no memory cleared), and <see cref="Add(int)"/> /
22-
/// <see cref="Contains(int)"/> / <see cref="Remove(int)"/> are each a direct array index
23-
/// with no hashing, no probe chain, and no per-element allocation.
19+
/// round-trip <c>sparse[v] &lt; Count &amp;&amp; dense[sparse[v]] == v</c>, which is correct even
20+
/// for a <em>stale</em> sparse entry — one left over from before a <see cref="Clear"/> or from a
21+
/// slot never written since construction (the array is zero-initialized) — so <see cref="Clear"/>
22+
/// need only reset the count and version (<c>O(1)</c>; the backing arrays are left untouched), and
23+
/// <see cref="Add(int)"/> / <see cref="Contains(int)"/> / <see cref="Remove(int)"/> are each a
24+
/// direct array index with no hashing, no probe chain, and no per-element allocation.
2425
/// </para>
2526
/// <para>
2627
/// <b>The documented BCL-beating workload</b> is a set of small non-negative integers from a
2728
/// known bounded universe that is <em>cleared and rebuilt frequently</em> and <em>iterated by
2829
/// present element</em>: per-frame / per-query "visited" sets in graph traversal (BFS/DFS), ECS
2930
/// entity membership, register-allocation liveness, and sweep-line algorithms. There,
3031
/// <see cref="HashSet{T}"/>'s <c>Clear</c> is <c>O(capacity)</c> (it zeroes the whole entry
31-
/// table) and its iteration walks a possibly-sparse table, whereas this type's <c>Clear</c> is a
32-
/// single field write and its enumeration is a linear scan over the dense prefix.
32+
/// table) and its iteration walks a possibly-sparse table, whereas this type's <c>Clear</c> only
33+
/// resets the count and version (the backing arrays are left as-is) and its enumeration is a
34+
/// linear scan over the dense prefix.
3335
/// </para>
3436
/// <para>
3537
/// <b>Tradeoffs.</b> The sparse index array is <c>O(Universe)</c> memory, and the type stores
@@ -231,9 +233,9 @@ public bool Remove(int item)
231233
}
232234

233235
/// <summary>
234-
/// Removes all elements from the set in <c>O(1)</c>. The backing arrays are neither cleared
235-
/// nor shrunk — only the count is reset — which is the type's defining advantage over
236-
/// <see cref="HashSet{T}"/> for clear-and-rebuild workloads.
236+
/// Removes all elements from the set in <c>O(1)</c>. The backing arrays are neither scanned,
237+
/// cleared, nor shrunk — only the count and version are updated — which is the type's defining
238+
/// advantage over <see cref="HashSet{T}"/> for clear-and-rebuild workloads.
237239
/// </summary>
238240
public void Clear()
239241
{
@@ -433,8 +435,9 @@ public void TrimExcess(int capacity)
433435
bool ICollection<int>.IsReadOnly => false;
434436

435437
// The Briggs–Torczon membership round-trip, assuming item is already known to be in
436-
// [0, _universe). sparse[item] may be stale garbage from before a Clear or from an
437-
// uninitialized slot; the (uint) bound and the dense[...] == item confirmation reject it.
438+
// [0, _universe). sparse[item] may be a stale value left from before a Clear, or the
439+
// zero a never-written slot still holds; the (uint) bound and the dense[...] == item
440+
// confirmation reject both.
438441
[MethodImpl(MethodImplOptions.AggressiveInlining)]
439442
private bool ContainsUnchecked(int item)
440443
{

0 commit comments

Comments
 (0)