Skip to content

Commit bd5ac40

Browse files
marius-bughiuclaude
andcommitted
Merge main into feat/issue-309-celerity-sorting
Picks up SortedSpan (#342), which landed on main while this PR was in review. Both changes add to the same set of shared surfaces, so all four conflicts were additive rather than contradictory: - CHANGELOG.md — both added [Unreleased] entries; kept both, Sorting first. - README.md — main added SortedSpan to the Celerity.Primitives row of the packages table while this branch added a Celerity.Sorting row below it; kept main's updated row and this branch's new one. - Celerity.Fuzz/Differential.cs — competing using directives; both are needed, since the file now drives SortedSpan and the three sorters. - web/index.html — competing ship cards; kept all four, with SortedSpan next to the other Celerity.Primitives entry and the three Celerity.Sorting cards after it. The auto-merged files were checked rather than assumed: the benchmark registry, both dashboard COLLECTIONS arrays, the fuzz target list and the AOT smoke test all carry both features, and neither side's ROADMAP status was clobbered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2 parents 17f51f7 + 6b8d50e commit bd5ac40

17 files changed

Lines changed: 1546 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ All notable changes to Celerity are documented here. This project follows [Keep
1010
- Crossovers are documented rather than claimed away: `RadixSort` loses below a few hundred elements, `CountingSort` loses once the key range approaches the element count, and `PartialSort` beats LINQ on allocation rather than asymptotics. `RadixSort` orders `NaN` by sign bit and `-0.0` before `+0.0`, where `Array.Sort` moves all NaNs to the front. Closes [#309](https://github.com/marius-bughiu/Celerity/issues/309).
1111
- Dedicated and differential tests, three `Celerity.Fuzz` targets reconciled against `Array.Sort`, Native AOT smoke coverage, `RadixSortBenchmark` / `CountingSortBenchmark` / `PartialSortBenchmark` in the CI-tracked suite with their dashboard cards, `docs/api/sorting.md`, and README entries. Closes [#309](https://github.com/marius-bughiu/Celerity/issues/309).
1212
- Release wiring for the new package: added to `coverage.runsettings`, so it is gated at 100% from day one, and to the release-gate package list. Closes [#309](https://github.com/marius-bughiu/Celerity/issues/309).
13+
- **`SortedSpan`** in `Celerity.Primitives` — set algebra over **already-sorted** spans: `Intersect` / `Union` / `Except` write into a caller-owned `Span<T>`, and `IntersectCount` / `Overlaps` need no buffer at all. The BCL has no set operation over spans, so the alternatives (`HashSet<T>.IntersectWith`, LINQ `Intersect`) build a hash table first: intersecting two 1M-element `int` spans takes **6.1 ms against 25.7 ms and allocates 0 bytes against 17.9 MB**, and when one side is 32x the other it gallops — **1k against 10M, 0.37 ms against 94.3 ms**. Inputs **must** be sorted ascending; unsorted input silently returns a wrong answer, asserted in Debug builds only. Closes [#313](https://github.com/marius-bughiu/Celerity/issues/313).
14+
- `SortedSpanTests` and `SortedSpanDifferentialTests`, a `Celerity.Fuzz` target and Native AOT smoke coverage — the merge, the galloping path, duplicate collapsing and the destination contract, reconciled against a `HashSet<T>` oracle across length ratios. Closes [#313](https://github.com/marius-bughiu/Celerity/issues/313).
15+
- `SortedSpanBenchmark` in the CI-tracked suite and the matching **SortedSpan** dashboard card, against `HashSet<int>` set algebra and LINQ, with a row for the asymmetric galloping shape. Closes [#313](https://github.com/marius-bughiu/Celerity/issues/313).
16+
- Utilities-reference and README sections for `SortedSpan`, including a "choosing a collection" row that states the sortedness caveat. Closes [#313](https://github.com/marius-bughiu/Celerity/issues/313).
1317
- `scripts/check_doc_anchors.js` — a CI guard that resolves every anchor link and relative file link across all tracked markdown, so a link that scrolls nowhere fails the build instead of shipping. Closes [#339](https://github.com/marius-bughiu/Celerity/issues/339).
1418
- A `--self-test` mode on that script, pinning the heading-slug rule against ids GitHub actually rendered, and a `doc-anchors` job in `ci.yml` that runs both modes on every PR. Closes [#339](https://github.com/marius-bughiu/Celerity/issues/339).
1519
- A "Documentation links" section in `CONTRIBUTING.md` covering the slug rule and how to look an anchor up rather than guess it. Closes [#339](https://github.com/marius-bughiu/Celerity/issues/339).

README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ Celerity's core ships as layered NuGet packages. **`Celerity.Collections` pulls
1717
|---|---|---|
1818
| [`Celerity.Collections`](https://www.nuget.org/packages/Celerity.Collections/) | dictionaries, sets, frozen/perfect-hash collections, streaming sketches | `Celerity.Hashing`, `Celerity.Primitives` |
1919
| `Celerity.Hashing` | `IHashProvider<T>` / `IHashProvider64<T>`, the struct hashers, `HashQualityEvaluator` | `Celerity.Primitives` |
20-
| `Celerity.Primitives` | `FastUtils`, struct PRNGs, `VarInt`, `FastGuid` ||
20+
| `Celerity.Primitives` | `FastUtils`, struct PRNGs, `VarInt`, `FastGuid`, `SortedSpan` ||
2121
| [`Celerity.Sorting`](https://www.nuget.org/packages/Celerity.Sorting/) | `RadixSort`, `CountingSort`, `PartialSort` — non-comparison sorts and selection over primitive keys | `Celerity.Primitives` |
2222

2323
> **Upgrading from 1.x?** Namespaces are unchanged except `FastUtils`, which moved from `Celerity` to `Celerity.Primitives`. See the [migration guide](docs/migration.md#200--the-package-split).
@@ -623,6 +623,7 @@ Each type buys a different tradeoff. Find your workload below; if it isn't here,
623623
| **Look a string key up from a `ReadOnlySpan<char>`** you already hold (route dispatch, header lookup, parse-then-map) without allocating a `string` per probe | span overloads on `FrozenCelerityDictionary` / `FrozenCeleritySet` / `CelerityDictionary<string, …>` / `CeleritySet<string, …>` / `Trie<TValue>` | `TryGetValue(ReadOnlySpan<char>, …)` / `ContainsKey` / `Contains` probe the table directly, deleting the `new string(span)` allocation and copy per lookup. Available whenever the hasher implements `ISpanHashProvider` — every built-in `String*Hasher` does. Same results as the `string` overloads (ordinal comparison); an empty span means `""`, never the `null` key. See [span-keyed lookups](docs/api/collections.md#span-keyed-lookups). |
624624
| **Sorted keys** — you need the entries in comparer order, or the ordered questions a hash table cannot answer: smallest / largest key, "first key at or after *x*", "every key in `[a, b)`" (time-series by timestamp, order books, LSM-style memtables, sweep-line events, interval endpoints) | `BTreeDictionary<TKey, TValue>` / `BTreeSet<T>` | B-tree with up to 31 keys per node in flat arrays: a lookup visits `log₃₂(n)` nodes instead of chasing `log₂(n)` pointers (~4 cache misses instead of ~20 at `n = 1M`), an in-order walk streams contiguous arrays rather than successor pointers, and allocation is one node per 31 entries instead of one object per entry. The BCL has no B-tree: `SortedDictionary<,>` / `SortedSet<>` are red-black trees, `SortedList<,>` is `O(n)` per middle insert, and `OrderedDictionary<,>` (.NET 9) is *insertion*-ordered, not sorted. Wins on the **interleaved insert + lookup + range-scan** load; for a few dozen entries a `SortedList<,>` is hard to beat, and if you never need order a hash table answers in `O(1)`. |
625625
| **Prefix / range sums over a sequence you keep mutating** — running aggregates, rank / order-statistics counters (inversions, "how many ≤ x seen"), cumulative-frequency tables | `FenwickTree<T>` | Binary Indexed Tree (`T : INumber<T>`): **point update** and **prefix / range sum** both `O(log n)`, in one array with no per-node overhead. The BCL has no prefix-sum structure; a plain array forces `O(n)` per query (recompute the slice) *or* `O(n)` per update (fix the suffix). Wins precisely when updates and partial-sum queries interleave. If the data is immutable after build, a one-shot precomputed prefix-sum array answers in `O(1)` with less code; if you only update and never query a partial sum, a raw array is simpler. |
626+
| **Set algebra over two lists you already hold in sorted order** — intersect / union / diff sorted ID, row-id or posting lists, or just ask how many values they share (inverted indexes, cohort intersection, join-key pre-filters) | `SortedSpan.Intersect` / `Union` / `Except` / `IntersectCount` / `Overlaps` (in `Celerity.Primitives`) | Not a collection — static set algebra over spans. A two-cursor merge exploits the ordering the data already has, so it touches each element once and writes into caller-owned memory: **4.2× faster than `HashSet<int>` at 1M × 1M and 0 bytes allocated against 17.9 MB**, and **257× faster** on the asymmetric 1k × 10M shape where it gallops. `IntersectCount` / `Overlaps` need no buffer at all. ⚠️ **Both spans must be sorted ascending** — unsorted input silently returns a wrong answer. If your data is not already sorted, sorting it first to use this is usually a loss; reach for a set instead. See [sorted-span set algebra](docs/api/utilities.md#sortedspan-sorted-span-set-algebra). |
626627
| Need a stable iteration order or multi-threaded access | `BTreeDictionary<,>` / `BTreeSet<>` for sorted order, `Trie<TValue>` for ordered string keys; BCL `ConcurrentDictionary<,>` for concurrency | Celerity is single-threaded, and the **hash-based** collections leave iteration order unspecified. The ordered collections do promise order by contract: the B-trees iterate in comparer order, `Trie<TValue>` in ascending ordinal key order. |
627628

628629
**Celerity is not the right answer when** you need concurrent access (use `ConcurrentDictionary<,>` or your own lock — Celerity is single-threaded), or a guaranteed iteration order from the **hash-based** collections (they implement `IDictionary<,>` / `IReadOnlyDictionary<,>` and `ISet<>`, but none promises an order across versions). When you do need ordered iteration, reach for the ordered collections instead: `BTreeDictionary<,>` / `BTreeSet<>` iterate in comparer order and support bounds and range scans, and `Trie<TValue>` gives ascending ordinal order over string keys. Interface support is no longer a reason to choose one over another — every mutable dictionary implements `IDictionary<,>` and every mutable set implements `ISet<>`.
@@ -766,6 +767,14 @@ int clamped = Branchless.Select(value > limit, limit, value); // no branch to mi
766767
Branchless.Select(mask, a, b, destination); // destination[i] = mask[i] ? a[i] : b[i]
767768
```
768769

770+
And **`SortedSpan`** is set algebra over spans that are **already sorted ascending** — `Intersect` / `Union` / `Except` straight into a caller-owned `Span<T>`, plus `IntersectCount` and `Overlaps` that need no buffer and **allocate nothing at all**. The BCL has none of this: `MemoryExtensions` has no set operation and `TensorPrimitives` none either, so the alternatives are `HashSet<T>.IntersectWith` (allocate a table, then hash and probe every element) or LINQ `Intersect` — **neither exploits sortedness**. A two-cursor merge touches each element once instead: intersecting two 1M-element sorted `int` arrays runs at **6.1 ms vs 25.7 ms** for `HashSet<int>` (**4.2×**, and 5.7× vs LINQ) while allocating **0 bytes against 17.9 MB**. When one side is ≥32× the other it gallops (exponential search), which is where the win gets large: **1k against 10M** takes **0.37 ms vs 94.3 ms** — **257×**. ⚠️ **Sorted by construction, or this is worthless**: unsorted input silently returns a wrong answer (Debug builds assert; Release deliberately does not check).
771+
772+
```csharp
773+
Span<int> buffer = stackalloc int[Math.Min(a.Length, b.Length)];
774+
int n = SortedSpan.Intersect(a, b, buffer); // a, b sorted ascending; result in buffer[..n]
775+
bool any = SortedSpan.Overlaps(a, b); // allocation-free, early-exit
776+
```
777+
769778
See [`docs/api/utilities.md`](docs/api/utilities.md#fastmod--fastdiv) for the full surface and the generator-selection table.
770779

771780
## Sorting

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ The 2026-Q3 review surveyed the shipped surface against the current .NET 8/9/10
223223
- `BTreeDictionary<TKey, TValue, TComparer>` / `BTreeSet<T, TComparer>` — cache-friendly sorted containers against `SortedDictionary<,>` / `SortedSet<T>`, which are red-black trees with a pointer chase and an allocation per node. Status: `done`. Tracked in [#305](https://github.com/marius-bughiu/Celerity/issues/305).
224224
- `CompressedIntSet` — a Roaring-style compressed set of 32-bit integers, covering the huge-and-sparse shape that neither `BitSet` (dense, bounded) nor `SparseSet` (small universe, `O(Universe)` memory) nor `IntSet` (hash) serves. Status: `done` — the value space is partitioned into 65,536-value chunks, each stored as a sorted `ushort[]`, a 1024-word bitmap, or run-length pairs, whichever is smallest. The issue's kill criterion (≥3x faster intersect and ≥5x less memory at 1M elements over a 100M universe) was measured after implementation and cleared: **9.5x** on intersect at 1% overlap, **6.2x** at 50%, **11.5x** on union, **3.5x** on except, and **8.9x** less heap (17.7 MB → 2.0 MB). Two design calls are worth recording. First, the issue's "per-container-pair dispatch" was implemented as *two* paths per operator rather than nine — a word-parallel one for the dense bitmap⊕bitmap case and a cursor-driven one for everything else — because a run container that is only *read* must not be decompressed, and a single sorted-cursor abstraction gets that for free where nine hand-written pairs would each have had to re-derive it; the observable contract is still pinned for all nine pairs, in both operand orders, by `CompressedIntSetSetAlgebraTests`. The first draft of the cursor path probed with a binary search per element and measured only 2.7x on intersect — below the kill criterion — and was replaced with a linear merge of the two sorted cursors, which is where the 9.5x comes from; that is the single most load-bearing line of the implementation. Second, run containers are produced only by `Optimize()` and `AddRange`, never speculatively on a single insert, matching Roaring's own `runOptimize` contract. The type can hold all 2^32 `int` values, which does not fit the `int` that `ICollection<T>.Count` must return, so `Cardinality` (a `long`) is the always-correct count and `Count` throws `OverflowException` in the one case it cannot answer. Caveat #2 of the issue — no portable Roaring format, so no Lucene / Druid / Spark interop — was accepted rather than treated as a kill: the in-process memory and set-algebra win stands on its own, and it now leads both the API reference section and the README row. Tracked in [#310](https://github.com/marius-bughiu/Celerity/issues/310).
225225
- `RankSelectBitVector` — succinct `Rank` / `Select` over a dense bit vector, the primitive the above compose on. Status: `done` — an immutable snapshot of a `BitSet` (or packed `ulong[]`, or a list of set positions) carrying a two-level popcount index: an `int` per 256-bit superblock and a `byte` per 64-bit word, so `Rank` is two index loads and one masked `POPCNT` and `Select` is a binary search over the superblocks. The issue's estimated 3% space overhead did not survive contact with the layout it specified — a byte-wide per-word counter caps the superblock at 256 bits, which puts the index at **25%** of the vector, the same price as the classic rank9 layout; `IndexSizeInBytes` reports it per instance and the docs lead with it. The build-once contract is stated first in every doc surface, and the benchmark ships the hand-rolled popcount loop as its baseline with the query position swept early / mid / late. Tracked in [#312](https://github.com/marius-bughiu/Celerity/issues/312).
226-
- Sorted-span set algebra in `Celerity.Primitives` — merge-based `Intersect` / `Union` / `Except` / `IntersectCount` over already-sorted spans, where the BCL answer is LINQ or a `HashSet` round-trip. Status: `planned`.
226+
- Sorted-span set algebra in `Celerity.Primitives` — merge-based `Intersect` / `Union` / `Except` / `IntersectCount` over already-sorted spans, where the BCL answer is LINQ or a `HashSet` round-trip. Status: `done` — `SortedSpan` ships the five entry points (`Overlaps` alongside the four above), generic over `IComparisonOperators<T, T, bool>` rather than as hand-written per-type overloads: the JIT specializes the merge per value type and each comparison lowers to one instruction, so the issue's fallback to explicit `int` / `long` / `uint` / `ulong` overloads was not needed. The kill criterion was measured and cleared with room to spare — at 1M × 1M over a 2M universe the scalar merge intersects in **6.1 ms against 25.7 ms** for `HashSet<int>` (**4.2×**; 5.7× vs LINQ) and allocates **0 bytes against 17.9 MB**, with union at 4.0× and except at 2.8× — and the asymmetric shape the galloping path exists for is where the real win is: **1k against 10M runs in 0.37 ms against 94.3 ms, 257×** (422× for `IntersectCount`). Three calls are worth recording. First, the **`Vector256` path was not shipped**, per the issue's own condition: the scalar merge is already memory-bound at 1M × 1M, and merge is branch-heavy enough that vectorizing it is frequently a wash — the kill criterion (≥25% over scalar) was never plausible enough to justify measuring a second implementation into existence. Second, **duplicates are collapsed rather than declared undefined**: every result is strictly ascending, which is what makes the `HashSet<T>` differential oracle meaningful and costs one predictable comparison per emitted element. Third, `Union` deliberately has **no galloping path** and `Except` gallops only when the subtrahend is the long side — in both excluded cases the result is proportional to the long input, so skipping comparisons cannot beat the cost of writing the answer out. The sortedness precondition is stated first in every doc surface and asserted in Debug builds only; a Release check would cost exactly what the algorithm saves. Tracked in [#313](https://github.com/marius-bughiu/Celerity/issues/313).
227227

228228
**A fourth core package: `Celerity.Sorting`.** `Array.Sort` / `MemoryExtensions.Sort` are scalar comparison introsort with no radix, counting, or selection path for primitive keys — and the BCL structurally cannot close it, because `Array.Sort` is contractually in-place while radix needs `O(n)` scratch. That is precisely the flexibility-for-speed trade this project's Vision licenses, against a named BCL counterpart. Layers on `Celerity.Primitives`, mirroring how `Hashing` and `Collections` layer today. Status: `done` — `RadixSort` ships the six primitive key types in keys-only, key+payload and `ArgSort` forms; `CountingSort` covers `byte` / `ushort` / declared-`[min, max]` `int` ranges; `PartialSort` is an introselect plus a bounded-heap `TopK`. Four design calls are worth recording. First, signed keys cost **nothing**: rather than transform the keys, the last digit's prefix sum starts at the sign-bit bucket, so only `float` / `double` pay the two extra linear passes an order-preserving bit transform needs. Second, the allocation-free overloads are named `SortWithScratch` rather than overloaded onto `Sort` — a `Sort(keys, scratch)` overload wins overload resolution over `Sort(keys, values)` whenever the payload has the same element type as the keys, so sorting `int` ids alongside `int` indices would have silently overwritten the payload; the differential test caught it on the first run. Third, the key+payload counting sort needs no key scratch at all: after the value scatter each counter has advanced to one past its run, which is exactly the run-end position the key rewrite wants. Fourth, `PartialSort` partitions three-way, so duplicate-heavy input stays linear, and carries an introselect depth budget that doubles as the guard stopping an inconsistent comparer from spinning forever. The issue's `float`/`double` caveat was accepted rather than papered over: `NaN` sorts by sign bit and `-0.0` before `+0.0`, both documented on the type, in the API reference and in the README, and both deliberately excluded from the `Array.Sort` fuzz oracle. Tracked in [#309](https://github.com/marius-bughiu/Celerity/issues/309).
229229

0 commit comments

Comments
 (0)