Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ All notable changes to Celerity are documented here. This project follows [Keep

### Added

- **`SortedSpan`** in `Celerity.Primitives` — set algebra over spans that are **already sorted ascending**: `Intersect` / `Union` / `Except` write straight into a caller-owned `Span<T>`, and `IntersectCount` / `Overlaps` answer without a buffer at all. The BCL has no set operation over spans, so the alternatives (`HashSet<T>.IntersectWith`, LINQ `Intersect`) allocate a table and hash every element instead of using the order the data already has: a two-cursor merge runs **4.2x faster on two 1M-element `int` spans (6.1 ms vs 25.7 ms) allocating 0 bytes against 17.9 MB**, and when one side is 32x the other it gallops — **1k against 10M takes 0.37 ms vs 94.3 ms, 257x**. Inputs **must** be sorted ascending; unsorted input silently returns a wrong answer, asserted in Debug builds and deliberately unchecked in Release. Closes [#313](https://github.com/marius-bughiu/Celerity/issues/313).
- `SortedSpanTests` and `SortedSpanDifferentialTests` — dedicated coverage of the merge, the galloping path, the duplicate-collapsing set semantics and the destination-too-short contract, plus a CsCheck property test and a `Celerity.Fuzz` target reconciling every operation against a `HashSet<T>` oracle across length ratios, and Native AOT smoke coverage. Closes [#313](https://github.com/marius-bughiu/Celerity/issues/313).
- `SortedSpanBenchmark` in the CI-tracked suite and the matching **SortedSpan** dashboard card, with `HashSet<int>` set algebra as the baseline, LINQ arms alongside it, and an asymmetric row for the galloping shape. Closes [#313](https://github.com/marius-bughiu/Celerity/issues/313).
- Utilities-reference and README sections for `SortedSpan`, including a "choosing a collection" row and the sortedness caveat stated in the row itself. Closes [#313](https://github.com/marius-bughiu/Celerity/issues/313).
- `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).
- 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).
- 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).
Expand Down
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ As of 2.0.0 Celerity ships as three layered NuGet packages. **`Celerity.Collecti
|---|---|---|
| [`Celerity.Collections`](https://www.nuget.org/packages/Celerity.Collections/) | dictionaries, sets, frozen/perfect-hash collections, streaming sketches | `Celerity.Hashing`, `Celerity.Primitives` |
| `Celerity.Hashing` | `IHashProvider<T>` / `IHashProvider64<T>`, the struct hashers, `HashQualityEvaluator` | `Celerity.Primitives` |
| `Celerity.Primitives` | `FastUtils`, struct PRNGs, `VarInt`, `FastGuid` | — |
| `Celerity.Primitives` | `FastUtils`, struct PRNGs, `VarInt`, `FastGuid`, `SortedSpan` | — |

> **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).

Expand Down Expand Up @@ -619,6 +619,7 @@ Each type buys a different tradeoff. Find your workload below; if it isn't here,
| **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). |
| **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)`. |
| **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. |
| **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). |
| 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. |

**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<>`.
Expand Down Expand Up @@ -762,6 +763,14 @@ int clamped = Branchless.Select(value > limit, limit, value); // no branch to mi
Branchless.Select(mask, a, b, destination); // destination[i] = mask[i] ? a[i] : b[i]
```

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).

```csharp
Span<int> buffer = stackalloc int[Math.Min(a.Length, b.Length)];
int n = SortedSpan.Intersect(a, b, buffer); // a, b sorted ascending; result in buffer[..n]
bool any = SortedSpan.Overlaps(a, b); // allocation-free, early-exit
```

See [`docs/api/utilities.md`](docs/api/utilities.md#fastmod--fastdiv) for the full surface and the generator-selection table.

## Native AOT & trimming
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ The 2026-Q3 review surveyed the shipped surface against the current .NET 8/9/10
- `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).
- `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).
- `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).
- 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`.
- 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).

**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. Would layer on `Celerity.Primitives`, mirroring how `Hashing` and `Collections` layer today. Status: `planned` — see the package-scoping caveat below.

Expand Down
Loading
Loading