Skip to content

Commit bd51f74

Browse files
Merge pull request #349 from marius-bughiu/feat/issue-348-segment-tree
feat(collections): add SegmentTree — range aggregates over a non-invertible fold
2 parents 1c7edff + efbccb1 commit bd51f74

26 files changed

Lines changed: 2087 additions & 9 deletions

CHANGELOG.md

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

77
### Added
88

9+
- **`SegmentTree<T, TMonoid>`** in `Celerity.Collections` — range aggregates over any **associative** fold, with point update and range query both `O(log n)`. Answers the range min / max / gcd / bitwise questions `FenwickTree<T>` structurally cannot, since a Fenwick range is the *difference* of two prefix folds and so needs an inverse. Against the `O(n)` scan the BCL leaves you with: **14.8x** at 100k elements on interleaved update + range-min, **81x** on a query batch, **1.4x** at 1k. Range *updates* are not supported. Closes [#348](https://github.com/marius-bughiu/Celerity/issues/348).
10+
- **`IMonoid<T>`** with `SumMonoid<T>`, `MinMonoid<T>`, `MaxMonoid<T>`, `BitwiseAndMonoid<T>` and `BitwiseOrMonoid<T>` — taken as a `struct` type parameter, like the hashers, so `Combine` inlines. `MinMonoid` / `MaxMonoid` document a finite-values domain for floating-point `T`. Closes [#348](https://github.com/marius-bughiu/Celerity/issues/348).
11+
- `SegmentTreeTests`, `SegmentTreeDifferentialTests` and `MonoidTests`, including an exhaustive length × range sweep under a **non-commutative** fold — the only kind that can catch the `2n` layout mis-ordering a combine. Closes [#348](https://github.com/marius-bughiu/Celerity/issues/348).
12+
- Cross-collection rows in `ClearNoOpVersionTests` (a third fixed-length exception, alongside `BitSet` and `FenwickTree`), `EnumeratorInvalidationAndClearCoverageTests` and `OversizedSourceAndResidualGuardTests`. Closes [#348](https://github.com/marius-bughiu/Celerity/issues/348).
13+
- A `SegmentTree` `Celerity.Fuzz` target and Native AOT smoke coverage over three monoid instantiations. Closes [#348](https://github.com/marius-bughiu/Celerity/issues/348).
14+
- `SegmentTreeBenchmark` in the CI-tracked suite, with its dashboard cards and landing-page ship card. Closes [#348](https://github.com/marius-bughiu/Celerity/issues/348).
15+
- API-reference and README sections for `SegmentTree` and `IMonoid`, correcting `FenwickTree`'s docs, which said a segment tree was "not shipped". Closes [#348](https://github.com/marius-bughiu/Celerity/issues/348).
916
- **`Celerity.Sorting`** — a new package with the non-comparison sorts the BCL has no path for: `RadixSort` (LSD radix over `uint` / `int` / `ulong` / `long` / `float` / `double`, in keys-only, key+payload and `ArgSort` forms, **stable**), `CountingSort` (bounded `byte` / `ushort` / declared-`[min, max]` `int` ranges, **stable**) and `PartialSort` (`O(n)` introselect plus an `O(n log k)` `TopK`). `Array.Sort` is contractually in-place while radix needs `O(n)` scratch, so this is a gap the BCL cannot close. `SortWithScratch` overloads make `RadixSort` / `CountingSort` allocation-free; `PartialSort` allocates nothing in any form. Depends only on `Celerity.Primitives`. Closes [#309](https://github.com/marius-bughiu/Celerity/issues/309).
1017
- 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).
1118
- 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).

README.md

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,10 @@ The mutable sets (`CeleritySet`, `SwissSet`, `RobinHoodSet`, `HashCachingSet`, `
100100

101101
Both take their ordering as a **struct** `IComparer<T>` type parameter (`DefaultComparer<T>` by default), exactly as the hashers are struct type parameters, so the comparison inlines instead of costing a virtual call per key inspected inside a node.
102102

103-
**Prefix sums**
103+
**Range aggregates**
104104

105105
- `FenwickTree<T>` — a **Binary Indexed Tree** over a fixed-length numeric sequence (`where T : struct, INumber<T>`): **point update** and **prefix / range sum** both in `O(log n)`, in one flat array with no per-node overhead. The prefix-sum structure the BCL lacks — running aggregates, rank / order-statistics counters, cumulative-frequency tables — where a plain array is `O(n)` per query (recompute the slice) *or* `O(n)` per update (fix the suffix). Wins precisely when updates and partial-sum queries interleave.
106+
- `SegmentTree<T, TMonoid>` — range aggregates over an arbitrary **associative** fold: **point update** and **range query** both in `O(log n)`, in one flat array of `2n` cells. The half of the range-query space a Fenwick tree cannot reach — its query is the *difference* of two prefix folds, so it needs an inverse, while a segment tree stores each node's fold outright. That puts range **min**, **max**, **gcd**, bitwise **and**/**or** and any monoid you write in reach. The BCL has no range-aggregate structure at all, so the baseline is a plain array scanned per query. `SumMonoid` / `MinMonoid` / `MaxMonoid` / `BitwiseAndMonoid` / `BitwiseOrMonoid` ship built in, as struct type parameters so the fold inlines; non-commutative folds are safe, since the query preserves index order.
106107

107108
**Probabilistic & bit-level**
108109

@@ -555,6 +556,35 @@ Console.WriteLine(tree.Total); // 33
555556

556557
</details>
557558

559+
<details>
560+
<summary><b>Range min / max / any associative fold with live updates</b> — SegmentTree</summary>
561+
562+
`SegmentTree<T, TMonoid>` answers the aggregate of any half-open range under an arbitrary **associative** fold, with **point updates** and **range queries** both in `O(log n)`. It is the half of the range-query space `FenwickTree<T>` cannot reach: a Fenwick range query is the *difference* of two prefix sums, so the operation must have an inverse — minimum has none. The fold arrives as a **struct** type parameter, exactly like the hashers, so `Combine` inlines instead of costing a virtual call per level.
563+
564+
```csharp
565+
// A live order book: the cheapest ask in any price band, while prices keep moving.
566+
var book = new SegmentTree<long, MinMonoid<long>>(new long[] { 105, 102, 108, 101, 110, 103 });
567+
568+
Console.WriteLine(book.Query(0, 4)); // 101 — cheapest in the first band
569+
Console.WriteLine(book.Aggregate); // 101 — cheapest overall
570+
571+
book[3] = 999; // that order was filled, O(log n)
572+
Console.WriteLine(book.Query(0, 4)); // 102 — refolded
573+
574+
// Any monoid works. Write a struct with an Identity and an associative Combine:
575+
public readonly struct GcdMonoid : IMonoid<uint>
576+
{
577+
public uint Identity => 0; // gcd(0, a) == a
578+
public uint Combine(uint left, uint right)
579+
{
580+
while (right != 0) (left, right) = (right, left % right);
581+
return left;
582+
}
583+
}
584+
```
585+
586+
</details>
587+
558588
<details>
559589
<summary><b>Construct from an existing collection</b></summary>
560590

@@ -623,6 +653,7 @@ Each type buys a different tradeoff. Find your workload below; if it isn't here,
623653
| **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). |
624654
| **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)`. |
625655
| **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. |
656+
| **Range min / max / gcd / bit-mask over a sequence you keep mutating** — sliding-window extrema over a live history, "cheapest offer in this price band" over an order book, per-window capability masks, or any other **associative fold with no inverse** | `SegmentTree<T, TMonoid>` | Point update and range query both `O(log n)`, in one flat array of `2n` cells. `FenwickTree<T>` cannot answer these at all: it computes a range as the *difference* of two prefix folds, so the operation must be invertible. Five folds ship (`Sum` / `Min` / `Max` / `BitwiseAnd` / `BitwiseOr`) and any associative one you write is a field-free struct; non-commutative folds are safe. The BCL has no range-aggregate structure, so the alternative is an `O(n)` scan per query — **14.8× faster** on interleaved update + range-min at 100k, **81×** on a query batch, but only **1.4×** at 1k, where scanning a contiguous array is cache-friendly. If the fold is addition use `FenwickTree<T>` (half the memory); if the sequence never changes after build, a sparse table or a prefix array answers in `O(1)`. Range *updates* are not supported — that needs lazy propagation, a different contract. |
626657
| **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). |
627658
| 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. |
628659

ROADMAP.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,10 @@ The 2026-Q3 review surveyed the shipped surface against the current .NET 8/9/10
235235
- No guard on the benchmark dashboard. The site parses BenchmarkDotNet result *names*, so a benchmark it cannot parse is dropped at render time — the data publishes correctly and the card just goes blank, with no CI signal. `EnumMap` and `EnumSet` had rendered empty since they shipped (they declare no `[Params]` sweep, by design), and `DisjointSet` blanked for five runs when its params property was briefly named `ElementCount`. Status: `done` — the parser now treats the `ItemCount` suffix as optional and renders an unparameterized class as a single bucket, excluded from the headline stats; `scripts/check_dashboard_coverage.js` fails CI on an unparseable name, a card with no measurements behind it, a collection missing from either `COLLECTIONS` array, or one not registered in the CI benchmark suite. It lifts those tables and the parsers out of the dashboard HTML rather than reimplementing them, so the check cannot drift from the page it guards. Tracked in [#301](https://github.com/marius-bughiu/Celerity/issues/301). A second silent-drop mode in the same page — a *label* rather than a measurement — was found and closed afterwards: the `COLLECTIONS` titles and `vs` baselines were concatenated into `innerHTML` raw, so every card lost its generic parameters (`IntDictionary` for `IntDictionary<int>`, and one indistinguishable `vs Dictionary` for three different baselines) and `EnumSet<TEnum>` even materialized a stray `<tenum>` element. Both dashboard pages now escape every label, and the coverage check gained a structural rule that fails CI on any label reaching a markup template unescaped. Status: `done`. Tracked in [#328](https://github.com/marius-bughiu/Celerity/issues/328).
236236
- No guard on the documentation's own links. Seven intra-document links in `docs/api/collections.md` pointed at anchors that do not exist, and nothing in the pipeline could tell: the markdown is well-formed, the diff reads correctly, and the only symptom is a click that scrolls nowhere. The trap is that the wrong anchor is the *intuitive* one — GitHub lowercases a heading's rendered text and deletes punctuation without substituting a separator, so `CeleritySet&lt;T, THasher&gt;` anchors as `#celeritysett-thasher`, a doubled `t` from `…Set` meeting `T` once the `<` between them is gone. Status: `done` — `scripts/check_doc_anchors.js` resolves every same-file `](#fragment)`, every relative `](other.md#fragment)` and every relative file target across all tracked markdown, and runs in a `doc-anchors` CI job. Widening the scan past the one reported file found an eighth broken link, in `CHANGELOG.md`. The slug rule is the guessable part, so it is stated as a keep-list (letters, numbers, marks, spaces, `-`, `_`) rather than transcribed from github-slugger's generated strip-list, validated against the ids GitHub rendered for every published document, all of which it reproduces exactly, and pinned by a `--self-test` mode so a later rewrite cannot quietly start inventing anchors. One subtlety was worth encoding: `## PooledCeleritySet<T, THasher>` is written with bare angle brackets and must *not* be treated as an HTML tag, because a tag name may only be followed by whitespace, `/` or `>`; it renders as literal text and contributes its `T` to the slug exactly as the entity-encoded headings do. Tracked in [#339](https://github.com/marius-bughiu/Celerity/issues/339).
237237

238+
**Identified after the review, by the same source-reading convention.** The Q3 survey rostered twelve items; the following were found afterwards, by reading the shipped surface rather than by the plan, and are filed against this milestone as they are identified.
239+
240+
- `SegmentTree<T, TMonoid>` — range aggregates over an arbitrary associative fold. The gap was written down in the library's own documentation: the `FenwickTree<T>` section of the API reference closed by saying a segment tree "are the next step (not shipped)". Fenwick is constrained to `INumber<T>` for a structural reason, not a stylistic one — its range query is the *difference* of two prefix folds, so the operation must have an inverse — which left the entire non-invertible half of the range-query space (min, max, gcd, bitwise and/or, any user-written fold) unreachable, with no BCL counterpart either. Status: `done` — `IMonoid<T>` ships as a `struct` type parameter alongside five built-in folds, so `Combine` inlines rather than costing a virtual call per level. Three calls are worth recording. First, the layout is the flat **`2n`** array, not the power-of-two-padded `4n` one that is usually recommended: the objection to `2n` is that the leaves sit in a rotated order at non-power-of-two lengths, so an internal node can span a wrapped range — but a query that walks outward from both ends into two separate accumulators never combines such a node into the wrong side, and an exhaustive differential sweep over every length and every range under a **non-commutative** fold pins that. A commutative fold cannot observe the difference, which is why min/max/sum could not be the oracle and the fuzz target and the differential suite both run "first non-zero wins" and string concatenation instead. The one visible consequence is that `Aggregate` is a query rather than a root read. Second, **lazy propagation was left out** rather than half-shipped: range updates need a second monoid describing how updates compose plus a distributive law relating the two, which is a different type with a different contract, and it is stated as an exclusion on every doc surface. Third, `T` is deliberately **unconstrained** — a `string`-concatenation monoid is a legitimate fold and the tree's own storage does not care — where the sibling `FenwickTree<T>` is `struct, INumber<T>`. The kill criterion (≥10x over the array scan on interleaved update + range-min at 100k) was measured after implementation and cleared at **14.8x**, with **81x** on a query batch against a pre-built tree; at 1k it is only 1.4x, and the README and API reference both lead with that rather than quoting the headline alone. The floating-point caveat on `MinMonoid` / `MaxMonoid` (the identity is the largest / smallest *finite* value, and a `NaN` resolves by operand position) is documented on the type, in the API reference and in the tests. Tracked in [#348](https://github.com/marius-bughiu/Celerity/issues/348).
241+
238242
Two areas were judged real but deliberately deferred rather than rostered: a `Celerity.Statistics` package (DDSketch / reservoir sampling / running moments — a coherent fourth axis, but two new packages in one cycle is too much at once), and a batch of fuzz-target and AOT-smoke-coverage gaps (real, but low expected defect yield; better folded into whichever collection PR lands next than pursued on their own).
239243

240244
## Non-goals

0 commit comments

Comments
 (0)