diff --git a/CHANGELOG.md b/CHANGELOG.md index 490ac70..8b07f9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ All notable changes to Celerity are documented here. This project follows [Keep ### Added +- **`SegmentTree`** 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` 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). +- **`IMonoid`** with `SumMonoid`, `MinMonoid`, `MaxMonoid`, `BitwiseAndMonoid` and `BitwiseOrMonoid` — 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). +- `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). +- 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). +- A `SegmentTree` `Celerity.Fuzz` target and Native AOT smoke coverage over three monoid instantiations. Closes [#348](https://github.com/marius-bughiu/Celerity/issues/348). +- `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). +- 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). - **`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). - 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). - 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). diff --git a/README.md b/README.md index 53a6130..daaf9b8 100644 --- a/README.md +++ b/README.md @@ -100,9 +100,10 @@ The mutable sets (`CeleritySet`, `SwissSet`, `RobinHoodSet`, `HashCachingSet`, ` Both take their ordering as a **struct** `IComparer` type parameter (`DefaultComparer` 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. -**Prefix sums** +**Range aggregates** - `FenwickTree` — a **Binary Indexed Tree** over a fixed-length numeric sequence (`where T : struct, INumber`): **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. +- `SegmentTree` — 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. **Probabilistic & bit-level** @@ -555,6 +556,35 @@ Console.WriteLine(tree.Total); // 33 +
+Range min / max / any associative fold with live updates — SegmentTree + +`SegmentTree` 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` 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. + +```csharp +// A live order book: the cheapest ask in any price band, while prices keep moving. +var book = new SegmentTree>(new long[] { 105, 102, 108, 101, 110, 103 }); + +Console.WriteLine(book.Query(0, 4)); // 101 — cheapest in the first band +Console.WriteLine(book.Aggregate); // 101 — cheapest overall + +book[3] = 999; // that order was filled, O(log n) +Console.WriteLine(book.Query(0, 4)); // 102 — refolded + +// Any monoid works. Write a struct with an Identity and an associative Combine: +public readonly struct GcdMonoid : IMonoid +{ + public uint Identity => 0; // gcd(0, a) == a + public uint Combine(uint left, uint right) + { + while (right != 0) (left, right) = (right, left % right); + return left; + } +} +``` + +
+
Construct from an existing collection @@ -623,6 +653,7 @@ Each type buys a different tradeoff. Find your workload below; if it isn't here, | **Look a string key up from a `ReadOnlySpan`** you already hold (route dispatch, header lookup, parse-then-map) without allocating a `string` per probe | span overloads on `FrozenCelerityDictionary` / `FrozenCeleritySet` / `CelerityDictionary` / `CeleritySet` / `Trie` | `TryGetValue(ReadOnlySpan, …)` / `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` / `BTreeSet` | 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` | Binary Indexed Tree (`T : INumber`): **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. | +| **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` | Point update and range query both `O(log n)`, in one flat array of `2n` cells. `FenwickTree` 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` (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. | | **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` 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` 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` in ascending ordinal key order. | diff --git a/ROADMAP.md b/ROADMAP.md index 5e1125a..f2d0f03 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -235,6 +235,10 @@ The 2026-Q3 review surveyed the shipped surface against the current .NET 8/9/10 - 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`, and one indistinguishable `vs Dictionary` for three different baselines) and `EnumSet` even materialized a stray `` 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). - 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<T, THasher>` 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` 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). +**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. + +- `SegmentTree` — range aggregates over an arbitrary associative fold. The gap was written down in the library's own documentation: the `FenwickTree` section of the API reference closed by saying a segment tree "are the next step (not shipped)". Fenwick is constrained to `INumber` 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` 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` is `struct, INumber`. 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). + 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). ## Non-goals diff --git a/docs/api/collections.md b/docs/api/collections.md index 1d052b0..52f5f2d 100644 --- a/docs/api/collections.md +++ b/docs/api/collections.md @@ -4245,7 +4245,7 @@ Index and range arguments are bounds-checked (`ArgumentOutOfRangeException`): `i ### Choosing it -Reach for `FenwickTree` when you maintain a **mutable numeric sequence** and repeatedly ask for prefix or range sums *while* the values change — running totals, rank / order-statistics counters, cumulative-frequency tables, or windowed aggregates over a history you also edit. If your data is **immutable** after you build it, a one-shot precomputed prefix-sum `T[]` answers queries in `O(1)` with less code; if you **only ever update** and never query a partial sum, a raw array is simpler. The Fenwick tree wins precisely when both happen — updates *and* partial-sum queries interleave. For range **updates** with point queries, apply the tree to the difference array; for range-update + range-query, two Fenwick trees or a segment tree are the next step (not shipped). This type is not thread-safe; concurrent callers must synchronize externally. +Reach for `FenwickTree` when you maintain a **mutable numeric sequence** and repeatedly ask for prefix or range sums *while* the values change — running totals, rank / order-statistics counters, cumulative-frequency tables, or windowed aggregates over a history you also edit. If your data is **immutable** after you build it, a one-shot precomputed prefix-sum `T[]` answers queries in `O(1)` with less code; if you **only ever update** and never query a partial sum, a raw array is simpler. The Fenwick tree wins precisely when both happen — updates *and* partial-sum queries interleave. For range **updates** with point queries, apply the tree to the difference array. For a fold with **no inverse** — minimum, maximum, gcd, bitwise and/or — a Fenwick tree cannot answer the query at all, because it computes a range as the *difference* of two prefix sums; reach for [`SegmentTree`](#segmenttreet-tmonoid) instead. This type is not thread-safe; concurrent callers must synchronize externally. ### Usage example @@ -4268,6 +4268,140 @@ foreach (int x in data) Console.WriteLine(inversions); // 8 ``` +## SegmentTree<T, TMonoid> + +```csharp +public sealed class SegmentTree : IReadOnlyList + where TMonoid : struct, IMonoid +``` + +A **segment tree** is a fixed-length, array-backed sequence that answers the aggregate of any half-open range under an arbitrary **associative** operation, and applies point updates, in `O(log n)` each — over a single flat array of `2n` elements with no per-node object overhead. + +It is the half of the range-query space [`FenwickTree`](#fenwicktreet) cannot reach. A Fenwick range query is the *difference* of two prefix folds, so the operation must have an inverse — which is why that type is constrained to `INumber` and answers sums only. A segment tree stores each node's fold outright and never subtracts, so range **minimum**, **maximum**, **gcd**, bitwise **and**/**or** — and any fold you write yourself — are all in reach. Where sums are what you want, prefer `FenwickTree`: it does the same job in half the memory. + +The BCL has no range-aggregate structure at all, so the baseline is a plain `T[]` and a loop that folds the slice element by element — `O(n)` per query, whatever the fold — while precomputing the answers instead makes every point update `O(n)`. There is not even a span helper to lean on: `Span` has no `Min` or `Max`, let alone an arbitrary combine, so the loop is written out by hand. (The benchmark measures the range-**minimum** instance of it, which is the cheapest per element the baseline gets.) + +### The fold: `IMonoid` + +```csharp +public interface IMonoid +{ + T Identity { get; } + T Combine(T left, T right); +} +``` + +`TMonoid` is a `struct, IMonoid` **type parameter** rather than an interface-typed instance, for the same reason the hashed collections take their hasher that way: the JIT specializes the tree for the concrete struct and inlines `Combine` instead of emitting an interface call, and a query or an update calls it `O(log n)` times. + +An implementation must satisfy the two monoid laws, because the tree relies on both to answer a query from precomputed partial folds: + +- **Associativity** — `Combine(Combine(a, b), c)` equals `Combine(a, Combine(b, c))`. The tree chooses its own bracketing, so a non-associative operation gives an unspecified answer. +- **Identity** — `Combine(Identity, a)` and `Combine(a, Identity)` both equal `a`. `Identity` is the aggregate of an empty range and the value of every element of a freshly constructed tree. + +Both laws are required only over the implementation's **domain** — the set of values it declares itself defined for — not over every bit pattern `T` can hold. An implementation that restricts its domain must say so, because a value outside it produces an unspecified aggregate rather than a thrown exception. Two of the shipped monoids do restrict it: `MinMonoid` and `MaxMonoid` are defined over the *finite* values of a floating-point `T` (see the caveat below). The other three are defined over all of `T`. + +**Commutativity is not required.** The query folds the nodes it takes from the left and from the right into two separate accumulators and combines them in index order at the end, so a non-commutative operation (matrix product, "first non-zero wins", string concatenation) gets the same answer a left-to-right scan would. + +Five folds ship with the library: + +| Monoid | `Identity` | `Combine` | Constraint on `T` | +| --- | --- | --- | --- | +| `SumMonoid` | `T.Zero` | `left + right` | `struct, INumberBase` | +| `MinMonoid` | `T.MaxValue` | the smaller | `struct, INumber, IMinMaxValue` | +| `MaxMonoid` | `T.MinValue` | the larger | `struct, INumber, IMinMaxValue` | +| `BitwiseAndMonoid` | `~T.Zero` (all ones) | `left & right` | `struct, INumberBase, IBitwiseOperators` | +| `BitwiseOrMonoid` | `T.Zero` | `left \| right` | `struct, INumberBase, IBitwiseOperators` | + +Anything else is a field-free struct you write: + +```csharp +public readonly struct GcdMonoid : IMonoid +{ + public uint Identity => 0; // gcd(0, a) == a + + public uint Combine(uint left, uint right) + { + while (right != 0) + (left, right) = (right, left % right); + + return left; + } +} + +var tree = new SegmentTree(values); +``` + +That example is written over `uint` deliberately. A signed gcd has to normalize its sign, and the obvious `Math.Abs` throws on `int.MinValue` — whose true gcd with `0` is `2147483648`, a value no `int` can hold. Restricting the domain to unsigned values removes the corner rather than papering over it. + +**Floating-point caveat on `MinMonoid` / `MaxMonoid`.** The identity is `T.MaxValue` / `T.MinValue`, which for `float` and `double` are the largest and smallest *finite* values, not the infinities. A stored `+∞` therefore aggregates to `T.MaxValue` under `MinMonoid`. And `NaN` loses every `<` comparison, so `Combine(NaN, x)` is `x` while `Combine(x, NaN)` is `NaN` — the aggregate of a range containing a `NaN` depends on where it sits. Both are the ordinary consequences of ordering IEEE values by `<`; if you need IEEE-exact semantics, pass a custom monoid that calls `T.Min` / `T.Max`. + +### How it works + +The logical element at index `i` lives at `tree[n + i]`, and every internal node `k` in `[1, n)` holds `Combine(tree[2k], tree[2k + 1])` — exactly `2n` cells, with index `0` unused. A point update writes the leaf and refolds each ancestor from its two children. A range query walks outward from both ends, taking each node that is fully inside the range and halving the bounds one level per step. + +The usual segment-tree layout pads the leaf count up to a power of two and pays up to `4n` cells. This one does not, and the reason it can get away with `2n` is worth stating: at a length that is not a power of two the leaves sit in a rotated order, so an internal node can span a wrapped, non-contiguous range — but a query that keeps its two directions in separate accumulators never combines such a node into the wrong side. One visible consequence is that `tree[1]` is the whole-sequence fold *only* at power-of-two lengths, which is why `Aggregate` is a query rather than a root read. The claim is pinned by an exhaustive differential sweep over every length and every range under a non-commutative fold, which is the only kind that can observe a violation. + +### Range updates are not supported + +Applying an operation to every element of a range in `O(log n)` needs lazy propagation, which needs a second monoid describing how updates compose plus a distributive law relating the two. That is a different type with a different contract, not an overload of this one. Update point by point, or apply this tree to a difference sequence. + +### The documented BCL-beating workload + +Any stream that **mixes point updates with range-aggregate queries under a non-invertible fold**: sliding-window minima and maxima over a mutating history, "cheapest offer in this price band" over a live order book, per-window capability masks (`BitwiseAndMonoid`), and range gcd. Against a plain array these are `O(n·q)`; against the segment tree they are `O(q·log n)`. Measured on the short-run local sweep at 100,000 elements: interleaved update + range-minimum **14.8x** faster than the array scan, and a batch of range-minimum queries against a pre-built tree **81x** faster. At 1,000 elements the margins narrow to **1.4x** and **3.7x** — a scan of a thousand contiguous `long`s is cache-friendly enough that `O(log n)` barely pays for itself. See the [segment-tree benchmark](https://marius-bughiu.github.io/Celerity/dev/bench/?collection=SegmentTree) on the dashboard. + +### Constructors + +```csharp +public SegmentTree(int length) // length elements, all Identity +public SegmentTree(int length, TMonoid monoid) +public SegmentTree(IEnumerable values) // O(n) build seeded with values, in order +public SegmentTree(IEnumerable values, TMonoid monoid) +``` + +`length` must be non-negative and at most `Array.MaxLength / 2` — the layout stores two cells per element (`ArgumentOutOfRangeException` otherwise). The length is **fixed** at construction; the tree does not grow. `Clear` resets the values to the identity but keeps the length. The `IEnumerable` overloads throw `ArgumentNullException` on a null source and never alias a caller-supplied array (they copy). The `monoid`-taking overloads exist for a fold that carries state; a field-free monoid needs neither, since the other two close over `default(TMonoid)`. + +### Methods and properties + +| Member | Description | +| --- | --- | +| `int Count { get; }` | The number of logical elements (the fixed length). | +| `T Aggregate { get; }` | The fold of every logical element — `Query(0, Count)`, and `Identity` for an empty tree. `O(log n)`, not a root read. | +| `T this[int index] { get; set; }` | Get/set the logical value at `index`. The getter is `O(1)` (a direct leaf read); the setter is `O(log n)` (it refolds the path to the root). | +| `void Combine(int index, T value)` | Fold `value` into the element at `index` — it becomes `Combine(current, value)` — in `O(log n)`. The monoid-native update: it needs no inverse, and the stored value stays on the left. | +| `T Query(int start, int endExclusive)` | The fold of the logical elements in the half-open range `[start, endExclusive)`, in `O(log n)`. An empty range yields `Identity`. | +| `void Clear()` | Reset every logical element to `Identity` (`O(n)`); the length is unchanged. | +| `Enumerator GetEnumerator()` | Struct enumerator yielding the logical values in index order (`O(n)` total — the leaves are stored outright). | + +It implements `IReadOnlyList`, not merely `IReadOnlyCollection` as `FenwickTree` does, because the leaves are stored outright: the indexer is a direct array read, so a consumer that indexes in a loop pays what it expects. A Fenwick tree recovers each value from a difference of prefix folds, which would make the same loop `O(n log n)`. + +Index and range arguments are bounds-checked (`ArgumentOutOfRangeException`): `index` must be in `[0, Count)`, and a range must satisfy `0 ≤ start ≤ endExclusive ≤ Count`. Reads never mutate, so they never invalidate an enumerator. **Every** mutation bumps the version: unlike `FenwickTree`, an assignment that stores the value already there is not detected as a no-op, because `IMonoid` carries no equality obligation and the tree will not impose one. Not thread-safe. + +### Choosing it + +Reach for `SegmentTree` when you maintain a **mutable sequence** and repeatedly ask for the aggregate of a range *while* the values change, under a fold with **no inverse**. If the fold is addition, use `FenwickTree` — same asymptotics, half the memory, shorter constant. If the sequence is **immutable** after you build it, a sparse table answers range minima in `O(1)` and a precomputed prefix array answers sums in `O(1)`, both with less code. If you **only ever update** and never query a range, a raw array is simpler. And if you need to update whole ranges at a time, this is not the type — see above. This type is not thread-safe; concurrent callers must synchronize externally. + +### Usage example + +```csharp +using Celerity.Collections; + +// A live order book: the cheapest ask in any price band, while prices keep moving. +long[] asks = { 105, 102, 108, 101, 110, 103, 107, 104 }; +var book = new SegmentTree>(asks); + +Console.WriteLine(book.Query(0, 4)); // 101 — cheapest in the first band +Console.WriteLine(book.Aggregate); // 101 — cheapest overall + +book[3] = 999; // that order was filled and replaced + +Console.WriteLine(book.Query(0, 4)); // 102 — refolded in O(log n) +Console.WriteLine(book.Aggregate); // 102 + +// A different fold over the same shape: which flags does every entry in the window still set? +var masks = new SegmentTree>(new[] { 0b1111, 0b1110, 0b1100, 0b0101 }); +Console.WriteLine(Convert.ToString(masks.Query(0, 3), 2)); // 1100 +``` + ## BTreeDictionary<TKey, TValue, TComparer> ```csharp diff --git a/docs/testing.md b/docs/testing.md index fe2c66f..e82135e 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -33,7 +33,7 @@ The bulk of the suite lives in `Celerity.Tests`, mirroring the library's folder - **Collision tests** (`*CollisionTests.cs`) — force every key down one probe chain with a constant hasher, then verify lookups, removals, and backward-shift deletion keep every entry findable. - **Enumeration tests** (`*EnumerationTests.cs`) — the struct enumerators, `Keys`/`Values` views, mid-enumeration mutation detection, and the non-generic interface surface (`IEnumerable.GetEnumerator()`, `object IEnumerator.Current`, `IEnumerator.Reset()`). - **Load-factor / constructor validation** — boundary resizes and argument checking. -- **Family-wide invariant suites** — a single file asserting one rule once per collection, so a new type (or an edit to an existing one) cannot quietly drift out of the family. `ClearNoOpVersionTests.cs` is the model: it pins *a `Clear()` that removes nothing does not bump the version*, so a defensive clear leaves active enumerators valid, across every count-based collection — and pins the two deliberate exceptions (`BitSet` and `FenwickTree` are fixed-length, so establishing "already empty" costs the same scan as the clear) so they read as decisions rather than as oversights. `Deque` shipped as the one outlier precisely because this rule was only pinned per-collection beforehand. +- **Family-wide invariant suites** — a single file asserting one rule once per collection, so a new type (or an edit to an existing one) cannot quietly drift out of the family. `ClearNoOpVersionTests.cs` is the model: it pins *a `Clear()` that removes nothing does not bump the version*, so a defensive clear leaves active enumerators valid, across every count-based collection — and pins the three deliberate exceptions (`BitSet`, `FenwickTree` and `SegmentTree` are fixed-length, so establishing "already empty" costs the same scan as the clear) so they read as decisions rather than as oversights. `Deque` shipped as the one outlier precisely because this rule was only pinned per-collection beforehand. - **Edge cases** live next to the type they exercise rather than in a catch-all file: indexer misses on the out-of-band key and `Clear()` on an empty collection sit in `*Tests.cs`; the wrap-around cluster that exercises the `bypassesGap` branch of backward-shift deletion sits in `*CollisionTests.cs`. Run them with: diff --git a/src/Celerity.AotSmokeTest/Program.cs b/src/Celerity.AotSmokeTest/Program.cs index 5e544b1..831d2d9 100644 --- a/src/Celerity.AotSmokeTest/Program.cs +++ b/src/Celerity.AotSmokeTest/Program.cs @@ -639,6 +639,41 @@ void Check(bool condition, string message) Check(wide.Total == 499_500 && wide.PrefixSum(10) == 45, "FenwickTree int instantiation at scale"); } +// SegmentTree — range aggregates over a struct monoid. Two ILC-specific things are pinned here. The fold +// arrives as a generic type argument, so every built-in monoid is a separate instantiation the compiler has +// to specialize ahead of time (and MinMonoid / MaxMonoid reach static abstract IMinMaxValue members for +// their identity, the same generic-math shape as FenwickTree). And T is unconstrained, so a reference-typed +// element type must work with no JIT to fall back on. Exercise the O(n) seeded build, the point update, the +// range query at a non-power-of-two length (where the 2n layout's leaf rotation is live), Combine, clear and +// the struct enumerator. +{ + var st = new SegmentTree>(new long[] { 3, 1, 4, 1, 5, 9, 2 }); + Check(st.Count == 7 && st.Aggregate == 1, "SegmentTree seeded build + aggregate"); + Check(st.Query(0, 3) == 1 && st.Query(4, 7) == 2 && st.Query(2, 2) == long.MaxValue, + "SegmentTree range queries + empty range"); + + st[1] = 8; + Check(st[1] == 8 && st.Query(0, 3) == 3, "SegmentTree point update refolds the path"); + + st.Combine(0, 0); + Check(st[0] == 0 && st.Aggregate == 0, "SegmentTree monoid-native update"); + + var values = new List(); + foreach (long v in st) values.Add(v); + Check(values.Count == 7 && values[1] == 8, "SegmentTree enumerates logical values"); + + st.Clear(); + Check(st.Count == 7 && st.Aggregate == long.MaxValue, "SegmentTree clear resets to identity, keeps length"); + + // A second monoid over a second element type, so the fold really is specialized per instantiation. + var masks = new SegmentTree>(new[] { 0b1111, 0b1110, 0b1100 }); + Check(masks.Aggregate == 0b1100 && masks.Query(0, 2) == 0b1110, "SegmentTree bitwise-and instantiation"); + + // A reference-typed element type, which has no value-type layout for ILC to specialize around. + var words = new SegmentTree(new[] { "a", "b", "c" }); + Check(words.Aggregate == "abc" && words.Query(1, 3) == "bc", "SegmentTree reference-typed elements"); +} + // BTreeDictionary / BTreeSet — the ordered collections. Two things are worth pinning under ILC here: // the struct-comparer generic (DefaultComparer plus a hand-written one, so the constrained // IComparer calls specialize per comparer), and the [InlineArray] traversal buffers behind the @@ -1853,3 +1888,12 @@ void DriveInterface(IDictionary map, string label) { public int Compare(int x, int y) => y.CompareTo(x); } + +// A hand-written monoid for the SegmentTree instantiation above: a reference-typed, non-commutative fold, so +// ILC compiles the constrained IMonoid call for something other than the built-in numeric monoids. +internal readonly struct AotConcatMonoid : IMonoid +{ + public string Identity => string.Empty; + + public string Combine(string left, string right) => left + right; +} diff --git a/src/Celerity.Benchmarks/Program.cs b/src/Celerity.Benchmarks/Program.cs index f0e555b..f095d15 100644 --- a/src/Celerity.Benchmarks/Program.cs +++ b/src/Celerity.Benchmarks/Program.cs @@ -51,6 +51,7 @@ internal class Program typeof(TrieBenchmark), typeof(StringInternTableBenchmark), typeof(FenwickTreeBenchmark), + typeof(SegmentTreeBenchmark), typeof(BTreeDictionaryBenchmark), typeof(BTreeSetBenchmark), typeof(RadixSortBenchmark), diff --git a/src/Celerity.Benchmarks/SegmentTreeBenchmark.cs b/src/Celerity.Benchmarks/SegmentTreeBenchmark.cs new file mode 100644 index 0000000..38fb20f --- /dev/null +++ b/src/Celerity.Benchmarks/SegmentTreeBenchmark.cs @@ -0,0 +1,145 @@ +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; +using Celerity.Collections; + +// SegmentTree> vs the plain-array baseline, on the fold FenwickTree structurally cannot +// answer. A Fenwick range query is the difference of two prefix folds, so it needs an inverse; minimum has +// none. The BCL ships no range-aggregate structure at all — not even a Span.Min to lean on — so the honest +// baseline is a raw long[] and a hand-written loop folding the slice: O(n) per query, while precomputing the +// answers instead would make every point update O(n). Range minimum is the cheapest fold that loop can carry, +// which makes it the baseline's best case and the fair one to measure against. +// +// Two categories cover the documented BCL-beating shape. Mixed interleaves point updates with range-minimum +// queries (the headline workload: sliding-window minima over a mutating history, "cheapest offer in this +// band" over a live book) where the array is O(n) per query; RangeMin runs a batch of half-open queries +// against a pre-built structure. The baseline arms are named Array_* so the dashboard classifies them as the +// reference series. +// +// The operation count is capped well below the FenwickTree benchmark's 10,000. Both baseline arms are +// quadratic in ItemCount, and at 100,000 elements that cap is what keeps the class inside a benchmark shard's +// budget — the ratio the card reports is unaffected by how many operations it is averaged over. +[MemoryDiagnoser] +[CategoriesColumn] +[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] +public class SegmentTreeBenchmark +{ + private long[] initial = null!; // initial logical values seeding both structures + private int[] updateIndex = null!; // point-update positions for the mixed stream + private long[] updateValue = null!; + private int[] rangeStart = null!; // half-open range bounds, shared by both categories + private int[] rangeEnd = null!; + + private SegmentTree> segmentFull = null!; + private long[] arrayFull = null!; + + [Params(1000, 100_000)] + public int ItemCount; + + [GlobalSetup] + public void Setup() + { + var rand = new Random(42); + + initial = new long[ItemCount]; + for (int i = 0; i < ItemCount; i++) + initial[i] = rand.Next(-1_000_000, 1_000_000); + + int ops = Math.Min(ItemCount, 2_000); + updateIndex = new int[ops]; + updateValue = new long[ops]; + rangeStart = new int[ops]; + rangeEnd = new int[ops]; + for (int i = 0; i < ops; i++) + { + updateIndex[i] = rand.Next(ItemCount); + updateValue[i] = rand.Next(-1_000_000, 1_000_000); + + int a = rand.Next(ItemCount + 1); + int b = rand.Next(ItemCount + 1); + if (a > b) + (a, b) = (b, a); + rangeStart[i] = a; + rangeEnd[i] = b; + } + + segmentFull = new SegmentTree>(initial); + arrayFull = (long[])initial.Clone(); + } + + // ---- Mixed: interleave point updates with range-minimum queries (the O(log n) vs O(n) split) ---- + + [Benchmark(Baseline = true)] + [BenchmarkCategory("Mixed")] + public long Array_Mixed() + { + long[] values = (long[])initial.Clone(); + long sink = 0; + for (int i = 0; i < updateIndex.Length; i++) + { + values[updateIndex[i]] = updateValue[i]; + + // Range minimum by scanning the slice — O(n) per query, and there is no precomputation that + // survives the update on the line above. + long min = long.MaxValue; + int end = rangeEnd[i]; + for (int j = rangeStart[i]; j < end; j++) + { + if (values[j] < min) + min = values[j]; + } + + sink += min; + } + + return sink; + } + + [Benchmark] + [BenchmarkCategory("Mixed")] + public long SegmentTree_Mixed() + { + var tree = new SegmentTree>(initial); + long sink = 0; + for (int i = 0; i < updateIndex.Length; i++) + { + tree[updateIndex[i]] = updateValue[i]; + sink += tree.Query(rangeStart[i], rangeEnd[i]); + } + + return sink; + } + + // ---- RangeMin: a batch of half-open range-minimum queries against the pre-built structure ---- + + [Benchmark(Baseline = true)] + [BenchmarkCategory("RangeMin")] + public long Array_RangeMin() + { + long sink = 0; + for (int i = 0; i < rangeStart.Length; i++) + { + long min = long.MaxValue; + int end = rangeEnd[i]; + for (int j = rangeStart[i]; j < end; j++) + { + if (arrayFull[j] < min) + min = arrayFull[j]; + } + + sink += min; + } + + return sink; + } + + [Benchmark] + [BenchmarkCategory("RangeMin")] + public long SegmentTree_RangeMin() + { + long sink = 0; + for (int i = 0; i < rangeStart.Length; i++) + sink += segmentFull.Query(rangeStart[i], rangeEnd[i]); + + return sink; + } +} diff --git a/src/Celerity.Fuzz/Differential.cs b/src/Celerity.Fuzz/Differential.cs index 24cb8af..ebdd608 100644 --- a/src/Celerity.Fuzz/Differential.cs +++ b/src/Celerity.Fuzz/Differential.cs @@ -54,6 +54,7 @@ public static readonly (string Name, Action Run)[] All = ("XorFilter", XorFilterCase), ("BitSet", BitSetCase), ("RankSelectBitVector", RankSelectBitVectorCase), + ("SegmentTree", SegmentTreeCase), ("SortedSpan", SortedSpanCase), ("HyperLogLog", HyperLogLogCase), ("CountMinSketch", CountMinSketchCase), @@ -1155,6 +1156,116 @@ private static void RankSelectBitVectorCase(Random rng) Check(rebuilt.Length == length && rebuilt.Count == positions.Count, "ToBitSet round trip disagreed"); } + // ---- segment tree -------------------------------------------------------- + + // The oracle is a plain array folded left to right, which is the definition the tree has to reproduce. + // Two things about the generated shape carry the weight. The length is drawn from a range that straddles + // several powers of two, because the 2n layout leaves the elements in a rotated order at every other + // length and that is the only place a wrapped internal node can reach the answer. And the fold is + // *non-commutative* — "the first non-zero value wins" — so a mis-ordered combine changes the result; + // under min or max it would not, which is exactly why those cannot be the oracle here. + private static void SegmentTreeCase(Random rng) + { + int length = rng.Next(1, 130); + var oracle = new int[length]; + for (int i = 0; i < length; i++) + oracle[i] = rng.Next(0, 4) == 0 ? 0 : rng.Next(1, 100); // zeroes are the identity + + var sut = new SegmentTree(oracle); + + // Exhaustive on the freshly built tree, then sampled per operation and exhaustive again at the end. + // Every range on every step would make a single case quadratic in both length and op count, which + // would buy far fewer cases per second than it is worth. + CheckSegmentTree(sut, oracle, rng, exhaustive: true); + + int ops = OpCount(rng); + for (int i = 0; i < ops; i++) + { + int index = rng.Next(0, length); + int value = rng.Next(0, 4) == 0 ? 0 : rng.Next(1, 100); + + switch (rng.Next(0, 10)) + { + case 0: + sut.Clear(); + Array.Clear(oracle); + break; + case < 6: + sut[index] = value; + oracle[index] = value; + break; + default: + sut.Combine(index, value); + oracle[index] = oracle[index] != 0 ? oracle[index] : value; + break; + } + + CheckSegmentTree(sut, oracle, rng, exhaustive: false); + } + + CheckSegmentTree(sut, oracle, rng, exhaustive: true); + } + + private static void CheckSegmentTree(SegmentTree sut, int[] oracle, Random rng, bool exhaustive) + { + Check(sut.Count == oracle.Length, "Count disagreed"); + + int i = 0; + foreach (int value in sut) + { + Check(value == oracle[i], $"element {i} disagreed"); + Check(sut[i] == oracle[i], $"indexer {i} disagreed"); + i++; + } + + Check(i == oracle.Length, "enumeration length disagreed"); + Check(sut.Aggregate == FoldFirstNonZero(oracle, 0, oracle.Length), "Aggregate disagreed"); + + if (exhaustive) + { + for (int start = 0; start <= oracle.Length; start++) + { + for (int end = start; end <= oracle.Length; end++) + Check(sut.Query(start, end) == FoldFirstNonZero(oracle, start, end), + $"Query({start}, {end}) disagreed"); + } + + return; + } + + for (int q = 0; q < 8; q++) + { + int a = rng.Next(0, oracle.Length + 1); + int b = rng.Next(0, oracle.Length + 1); + if (a > b) + (a, b) = (b, a); + + Check(sut.Query(a, b) == FoldFirstNonZero(oracle, a, b), $"Query({a}, {b}) disagreed"); + } + } + + // The oracle: fold the half-open range left to right, exactly as the monoid's laws define it. + private static int FoldFirstNonZero(int[] oracle, int start, int endExclusive) + { + for (int k = start; k < endExclusive; k++) + { + if (oracle[k] != 0) + return oracle[k]; + } + + return 0; + } + + /// + /// "The first non-zero value wins": associative, identity 0, and deliberately non-commutative. + /// + private readonly struct FirstNonZero : IMonoid + { + public int Identity => 0; + + public int Combine(int left, int right) => left != 0 ? left : right; + } + // ---- sorted-span set algebra -------------------------------------------- diff --git a/src/Celerity.Tests/Collections/ClearNoOpVersionTests.cs b/src/Celerity.Tests/Collections/ClearNoOpVersionTests.cs index c1a04c3..32d2e45 100644 --- a/src/Celerity.Tests/Collections/ClearNoOpVersionTests.cs +++ b/src/Celerity.Tests/Collections/ClearNoOpVersionTests.cs @@ -27,11 +27,12 @@ namespace Celerity.Tests.Collections; /// /// /// -/// Deliberate exceptions. and are fixed-length, so -/// "already empty" means "every word / cell is already zero" — establishing that costs a full scan, which is -/// the same work as the unconditional clear it would be trying to skip. Both therefore bump the version every -/// time, they agree with each other, and the two tests at the bottom pin that judgement so it reads as a -/// decision rather than as the same oversight. The probabilistic sketches (BloomFilter, +/// Deliberate exceptions. , and +/// are fixed-length, so "already empty" means "every word / cell already +/// holds the neutral value" — establishing that costs a full scan, which is the same work as the unconditional +/// clear it would be trying to skip. All three therefore bump the version every time, they agree with each +/// other, and the three tests at the bottom pin that judgement so it reads as a decision rather than as the +/// same oversight. The probabilistic sketches (BloomFilter, /// CountMinSketch, CuckooFilter, HyperLogLog, TopKSketch) are out of scope /// entirely: they track no version and expose no enumerator, so there is nothing for a redundant /// Clear() to invalidate. @@ -362,4 +363,23 @@ public void FenwickTreeClear_ShouldBumpTheVersionUnconditionally_BecauseEmptines Assert.Throws(() => overOneValue.MoveNext()); Assert.Equal(0, tree.PrefixSum(7)); } + + [Fact] + public void SegmentTreeClear_ShouldBumpTheVersionUnconditionally_BecauseEmptinessCostsAScan() + { + // The third fixed-length type, and it agrees with the other two. It goes further than they do: an + // assignment that stores the value already there also bumps, because IMonoid carries no equality + // obligation and the tree will not invent one. That difference is pinned in SegmentTreeTests. + var tree = new SegmentTree>(8); + + IEnumerator overAllIdentity = tree.GetEnumerator(); + tree.Clear(); + Assert.Throws(() => overAllIdentity.MoveNext()); + + tree[3] = 5; + IEnumerator overOneValue = tree.GetEnumerator(); + tree.Clear(); + Assert.Throws(() => overOneValue.MoveNext()); + Assert.Equal(int.MaxValue, tree.Aggregate); + } } diff --git a/src/Celerity.Tests/Collections/EnumeratorInvalidationAndClearCoverageTests.cs b/src/Celerity.Tests/Collections/EnumeratorInvalidationAndClearCoverageTests.cs index 35eec7b..41321c5 100644 --- a/src/Celerity.Tests/Collections/EnumeratorInvalidationAndClearCoverageTests.cs +++ b/src/Celerity.Tests/Collections/EnumeratorInvalidationAndClearCoverageTests.cs @@ -21,7 +21,9 @@ namespace Celerity.Tests.Collections; /// — carrying the type's own diagnostic message, so a copy/paste /// slip between collections is caught — and, for , assert the deliberate /// exception to the rule: a zero delta changes nothing observable, so by design it does not bump the -/// version and must leave live enumerators usable. +/// version and must leave live enumerators usable. deliberately has no +/// such exception (its fold carries no equality contract), so its companion test pins the weaker guarantee +/// that survives: pure queries are not mutations. /// /// /// @@ -99,6 +101,49 @@ public void FenwickTreeEnumeratorReset_ShouldThrowInvalidOperationException_When Assert.Throws(() => second.MoveNext()); } + [Fact] + public void SegmentTreeEnumeratorReset_ShouldThrowInvalidOperationException_WhenTreeModified() + { + var tree = new SegmentTree>(4); + tree[0] = 5; + + var enumerator = tree.GetEnumerator(); + Assert.True(enumerator.MoveNext()); + + tree.Combine(1, 7); + + var ex = Assert.Throws(() => enumerator.Reset()); + Assert.Contains("The segment tree was modified during enumeration.", ex.Message); + + var second = tree.GetEnumerator(); + tree[2] = 9; + Assert.Throws(() => second.MoveNext()); + } + + [Fact] + public void SegmentTreeEnumeratorReset_ShouldRewindWithoutThrowing_WhenTreeWasNotModified() + { + // The counterpart to the FenwickTree case below, and deliberately weaker: the segment tree has no + // no-op detection to lean on (IMonoid carries no equality contract), so the only mutation-free + // window is one with no mutation in it at all. Queries must not close it. + var tree = new SegmentTree>(new[] { 4, 6, 1 }); + + var enumerator = tree.GetEnumerator(); + Assert.True(enumerator.MoveNext()); + + _ = tree.Query(0, 3); + _ = tree.Aggregate; + _ = tree[1]; + + enumerator.Reset(); + + var values = new List(); + while (enumerator.MoveNext()) + values.Add(enumerator.Current); + + Assert.Equal(new[] { 4, 6, 1 }, values); + } + [Fact] public void FenwickTreeEnumeratorReset_ShouldRewindWithoutThrowing_WhenMutationWasZeroDelta() { diff --git a/src/Celerity.Tests/Collections/MonoidTests.cs b/src/Celerity.Tests/Collections/MonoidTests.cs new file mode 100644 index 0000000..3397c50 --- /dev/null +++ b/src/Celerity.Tests/Collections/MonoidTests.cs @@ -0,0 +1,187 @@ +using Celerity.Collections; + +namespace Celerity.Tests.Collections; + +/// +/// Coverage for the five folds shipped with . Each one is checked against +/// the two monoid laws the tree relies on — associativity and a two-sided identity — because a fold that +/// breaks either produces a wrong range aggregate that no test of the tree's own walk would attribute to the +/// monoid. The documented floating-point caveat on / is +/// pinned here too, so it reads as a stated limit rather than a latent bug. +/// +public class MonoidTests +{ + [Fact] + public void SumMonoid_ShouldAddAndCarryZeroAsIdentity() + { + var monoid = default(SumMonoid); + + Assert.Equal(0, monoid.Identity); + Assert.Equal(7, monoid.Combine(3, 4)); + Assert.Equal(5, monoid.Combine(monoid.Identity, 5)); + Assert.Equal(5, monoid.Combine(5, monoid.Identity)); + Assert.Equal(monoid.Combine(monoid.Combine(1, 2), 3), monoid.Combine(1, monoid.Combine(2, 3))); + } + + [Fact] + public void MinMonoid_ShouldKeepTheSmallerValue_AndCarryMaxValueAsIdentity() + { + var monoid = default(MinMonoid); + + Assert.Equal(int.MaxValue, monoid.Identity); + Assert.Equal(3, monoid.Combine(3, 4)); // left is smaller + Assert.Equal(4, monoid.Combine(9, 4)); // right is smaller + Assert.Equal(4, monoid.Combine(4, 4)); // equal: either operand is a correct answer + Assert.Equal(5, monoid.Combine(monoid.Identity, 5)); + Assert.Equal(5, monoid.Combine(5, monoid.Identity)); + Assert.Equal(monoid.Combine(monoid.Combine(8, 2), 5), monoid.Combine(8, monoid.Combine(2, 5))); + } + + [Fact] + public void MaxMonoid_ShouldKeepTheLargerValue_AndCarryMinValueAsIdentity() + { + var monoid = default(MaxMonoid); + + Assert.Equal(int.MinValue, monoid.Identity); + Assert.Equal(4, monoid.Combine(3, 4)); // right is larger + Assert.Equal(9, monoid.Combine(9, 4)); // left is larger + Assert.Equal(4, monoid.Combine(4, 4)); + Assert.Equal(5, monoid.Combine(monoid.Identity, 5)); + Assert.Equal(5, monoid.Combine(5, monoid.Identity)); + Assert.Equal(monoid.Combine(monoid.Combine(8, 2), 5), monoid.Combine(8, monoid.Combine(2, 5))); + } + + [Fact] + public void BitwiseAndMonoid_ShouldIntersectBits_AndCarryAllOnesAsIdentity() + { + var monoid = default(BitwiseAndMonoid); + + Assert.Equal(-1, monoid.Identity); // all ones in two's complement + Assert.Equal(0b0100, monoid.Combine(0b1100, 0b0110)); + Assert.Equal(0b1010, monoid.Combine(monoid.Identity, 0b1010)); + Assert.Equal(0b1010, monoid.Combine(0b1010, monoid.Identity)); + Assert.Equal(monoid.Combine(monoid.Combine(0b1110, 0b0111), 0b1101), + monoid.Combine(0b1110, monoid.Combine(0b0111, 0b1101))); + } + + [Fact] + public void BitwiseOrMonoid_ShouldUnionBits_AndCarryZeroAsIdentity() + { + var monoid = default(BitwiseOrMonoid); + + Assert.Equal(0, monoid.Identity); + Assert.Equal(0b1110, monoid.Combine(0b1100, 0b0110)); + Assert.Equal(0b1010, monoid.Combine(monoid.Identity, 0b1010)); + Assert.Equal(0b1010, monoid.Combine(0b1010, monoid.Identity)); + Assert.Equal(monoid.Combine(monoid.Combine(0b1000, 0b0100), 0b0010), + monoid.Combine(0b1000, monoid.Combine(0b0100, 0b0010))); + } + + [Fact] + public void BitwiseAndMonoid_ShouldWorkForUnsignedTypes() + { + var monoid = default(BitwiseAndMonoid); + + Assert.Equal(uint.MaxValue, monoid.Identity); + Assert.Equal(0b0100u, monoid.Combine(0b1100u, 0b0110u)); + } + + [Fact] + public void MinMonoid_ShouldSaturateAtMaxValue_ForFloatingPointInfinity() + { + // The documented caveat: IMinMaxValue.MaxValue is the largest finite double, not +infinity, + // so a stored +infinity aggregates to double.MaxValue. Callers who need IEEE-exact semantics are + // pointed at a custom monoid. + var tree = new SegmentTree>(new[] { double.PositiveInfinity }); + + Assert.Equal(double.MaxValue, tree.Query(0, 1)); + Assert.Equal(double.PositiveInfinity, tree[0]); // the stored value itself is untouched + } + + [Fact] + public void MinMonoid_ShouldResolveNaNByOperandPosition_BecauseEveryComparisonIsFalse() + { + // The second half of the caveat: NaN loses every `<` comparison, so the right operand always survives. + // That makes the aggregate of a range containing a NaN depend on where the NaN sits, which is the + // stated reason a caller needing IEEE semantics has to supply their own monoid. + var monoid = default(MinMonoid); + + Assert.Equal(1.0, monoid.Combine(double.NaN, 1.0)); + Assert.True(double.IsNaN(monoid.Combine(1.0, double.NaN))); + } + + [Fact] + public void MaxMonoid_ShouldResolveNaNByOperandPosition_BecauseEveryComparisonIsFalse() + { + var monoid = default(MaxMonoid); + + Assert.Equal(1.0, monoid.Combine(double.NaN, 1.0)); + Assert.True(double.IsNaN(monoid.Combine(1.0, double.NaN))); + } + + // ---- The monoids drive a tree end to end ------------------------------------------------------ + + [Fact] + public void SegmentTree_ShouldAnswerRangeMaximum() + { + var tree = new SegmentTree>(new long[] { 5, 3, 8, 1, 9, 2 }); + + Assert.Equal(8L, tree.Query(0, 3)); + Assert.Equal(9L, tree.Aggregate); + + tree[4] = -4; + Assert.Equal(8L, tree.Aggregate); + } + + [Fact] + public void SegmentTree_ShouldAnswerRangeBitwiseAnd() + { + // The capability-mask shape: which flags does every entry in the window still hold? + var tree = new SegmentTree>(new[] { 0b1111, 0b1110, 0b1100, 0b0101 }); + + Assert.Equal(0b1100, tree.Query(0, 3)); + Assert.Equal(0b0100, tree.Aggregate); + } + + [Fact] + public void SegmentTree_ShouldAnswerRangeBitwiseOr() + { + var tree = new SegmentTree>(new[] { 0b0001, 0b0010, 0b0100, 0b1000 }); + + Assert.Equal(0b0011, tree.Query(0, 2)); + Assert.Equal(0b1111, tree.Aggregate); + } + + [Fact] + public void SegmentTree_ShouldAnswerRangeGcd_UnderTheDocumentedUserWrittenMonoid() + { + // GcdMonoid is the "write your own fold" example printed in IMonoid's docs, the API reference and the + // README. Pinning it here means the sample a reader copies is one that has been run. + uint[] values = { 12, 18, 24, 9, 27 }; + var tree = new SegmentTree(values); + + Assert.Equal(6u, tree.Query(0, 3)); // gcd(12, 18, 24) + Assert.Equal(9u, tree.Query(3, 5)); // gcd(9, 27) + Assert.Equal(3u, tree.Aggregate); + Assert.Equal(0u, tree.Query(2, 2)); // the identity, and gcd(0, a) == a + + tree[0] = 5; + Assert.Equal(1u, tree.Aggregate); + } + + [Fact] + public void SegmentTree_ShouldAgreeWithFenwickTree_OnRangeSums() + { + // The two range structures overlap on exactly one fold — addition, the only one Fenwick can do — so + // the sum monoid is the place their answers can be reconciled directly. + long[] values = { 3, -1, 4, 1, -5, 9, 2, 6, -3 }; + var segment = new SegmentTree>(values); + var fenwick = new FenwickTree(values); + + for (int start = 0; start <= values.Length; start++) + for (int end = start; end <= values.Length; end++) + Assert.Equal(fenwick.RangeSum(start, end), segment.Query(start, end)); + + Assert.Equal(fenwick.Total, segment.Aggregate); + } +} diff --git a/src/Celerity.Tests/Collections/OversizedSourceAndResidualGuardTests.cs b/src/Celerity.Tests/Collections/OversizedSourceAndResidualGuardTests.cs index 574f654..bb70e43 100644 --- a/src/Celerity.Tests/Collections/OversizedSourceAndResidualGuardTests.cs +++ b/src/Celerity.Tests/Collections/OversizedSourceAndResidualGuardTests.cs @@ -20,6 +20,8 @@ namespace Celerity.Tests.Collections; /// than a from CopyTo/GetEnumerator, or an /// — is precisely the evidence that the length check runs first and that /// nothing is allocated or enumerated on the way to it. The whole test costs no memory at all. +/// takes the same fast path against a lower ceiling +/// (Array.MaxLength / 2, since it stores two cells per element) and is pinned the same way. /// /// /// @@ -104,6 +106,41 @@ public void Constructor_ShouldAcceptCountedSource_WhenCountIsWithinTheMaximumLen Assert.Equal(8, tree.PrefixSum(3)); } + // ---- SegmentTree: the same ceiling, half as tall ------------------------------------------------ + + [Fact] + public void SegmentTreeConstructor_ShouldThrowArgumentException_WhenCountedSourceExceedsTheMaximumLength() + { + // The segment tree stores two cells per logical element, so its ceiling is Array.MaxLength / 2 — half + // the Fenwick one, and reached by the same count-first ordering. + var oversized = new LyingCountCollection(int.MaxValue); + + ArgumentException ex = Assert.Throws( + () => new SegmentTree>(oversized)); + + Assert.Equal("values", ex.ParamName); + Assert.Contains("maximum supported length", ex.Message); + } + + [Fact] + public void SegmentTreeConstructor_ShouldNotEnumerateOrCopy_WhenCountedSourceExceedsTheMaximumLength() + { + var oversized = new LyingCountCollection(int.MaxValue); + + Assert.Throws(() => new SegmentTree>(oversized)); + } + + [Fact] + public void SegmentTreeConstructor_ShouldAcceptCountedSource_WhenCountIsWithinTheMaximumLength() + { + var tree = new SegmentTree>(new List { 3, 1, 4, 1, 5 }); + + Assert.Equal(5, tree.Count); + Assert.Equal(1, tree.Aggregate); + Assert.Equal(4, tree[2]); + Assert.Equal(1, tree.Query(0, 3)); + } + // ---- Trie.Enumerator: the exhausted state latches ---------------------------------------------- [Fact] diff --git a/src/Celerity.Tests/Collections/SegmentTreeDifferentialTests.cs b/src/Celerity.Tests/Collections/SegmentTreeDifferentialTests.cs new file mode 100644 index 0000000..7eca8f7 --- /dev/null +++ b/src/Celerity.Tests/Collections/SegmentTreeDifferentialTests.cs @@ -0,0 +1,209 @@ +using Celerity.Collections; + +namespace Celerity.Tests.Collections; + +/// +/// Randomized and exhaustive reconciliation of against a naive +/// left-to-right scan over an array holding the same values. +/// +/// +/// The layout is what is on trial here. The tree stores exactly 2n cells, with the logical elements as +/// the leaf half and each internal node the fold of its two children. At a length that is not a power of two +/// the leaves sit in a rotated order, so an internal node can span a wrapped, non-contiguous range — which is +/// why the usual advice is to pad the leaf count up to a power of two and pay up to 4n. The claim this +/// suite exists to test is that the rotation never reaches the answer, because the query walks outward from +/// both ends and keeps the two directions in separate accumulators. +/// +/// +/// +/// A commutative fold cannot observe the difference: min, max and sum give the same answer however the +/// operands are bracketed or reordered, so a suite built only on those would pass against a broken layout. +/// Every order-sensitive test below therefore runs on or +/// , where a single mis-ordered combine changes the result. +/// +/// +public class SegmentTreeDifferentialTests +{ + [Theory] + [InlineData(1)] + [InlineData(7)] + [InlineData(42)] + [InlineData(123)] + [InlineData(2026)] + public void SegmentTree_ShouldMatchNaiveScan_UnderRandomOperations(int seed) + { + var rand = new Random(seed); + int n = rand.Next(1, 64); + + // The int[] goes in through the IEnumerable constructor's counted (ICollection) fast path, so + // this also exercises the O(n) linear-time build rather than a sequence of point inserts. + var initial = new int[n]; + for (int i = 0; i < n; i++) + initial[i] = rand.Next(-50, 50); + + var tree = new SegmentTree>(initial); + var model = (int[])initial.Clone(); + AssertConsistent(tree, model, rand); + + for (int step = 0; step < 1000; step++) + { + int op = rand.Next(0, 10); + if (op == 0) + { + tree.Clear(); + Array.Fill(model, int.MaxValue); + } + else if (op <= 5) + { + int idx = rand.Next(0, n); + int value = rand.Next(-100, 100); + tree[idx] = value; + model[idx] = value; + } + else + { + int idx = rand.Next(0, n); + int value = rand.Next(-100, 100); + tree.Combine(idx, value); + model[idx] = Math.Min(model[idx], value); + } + + AssertConsistent(tree, model, rand); + } + } + + /// + /// Every length from 1 to 33 — spanning three power-of-two boundaries, where the 2n layout's leaf + /// rotation is at its most awkward — crossed with every half-open range, folded by a non-commutative + /// monoid. This is the exhaustive proof that no wrapped internal node reaches the answer on the wrong side. + /// + [Fact] + public void Query_ShouldMatchAnOrderedScan_ForEveryLengthAndRange_UnderANonCommutativeMonoid() + { + for (int n = 1; n <= 33; n++) + { + var values = new string[n]; + for (int i = 0; i < n; i++) + values[i] = ((char)('a' + (i % 26))).ToString() + i; + + var tree = new SegmentTree(values); + + for (int start = 0; start <= n; start++) + { + for (int end = start; end <= n; end++) + { + string expected = string.Concat(values[start..end]); + Assert.Equal(expected, tree.Query(start, end)); + } + } + + Assert.Equal(string.Concat(values), tree.Aggregate); + } + } + + /// + /// The same exhaustive sweep, but after point updates have refolded arbitrary paths to the root — a + /// correct build with a wrongly ordered ancestor refold would pass the test above and fail this one. + /// + [Theory] + [InlineData(11)] + [InlineData(97)] + public void Query_ShouldMatchAnOrderedScan_AfterPointUpdates_UnderANonCommutativeMonoid(int seed) + { + var rand = new Random(seed); + + for (int n = 1; n <= 20; n++) + { + var values = new string[n]; + for (int i = 0; i < n; i++) + values[i] = i.ToString(); + + var tree = new SegmentTree(values); + + for (int step = 0; step < 20; step++) + { + int idx = rand.Next(0, n); + string replacement = "<" + rand.Next(0, 1000) + ">"; + tree[idx] = replacement; + values[idx] = replacement; + + for (int start = 0; start <= n; start++) + for (int end = start; end <= n; end++) + Assert.Equal(string.Concat(values[start..end]), tree.Query(start, end)); + } + } + } + + /// + /// A value-typed non-commutative fold, so the ordering guarantee is pinned for a tree the JIT specializes + /// without any reference-type indirection. + /// + [Fact] + public void Query_ShouldMatchAnOrderedScan_ForAValueTypedNonCommutativeMonoid() + { + for (int n = 1; n <= 17; n++) + { + var values = new int[n]; + for (int i = 0; i < n; i++) + values[i] = i % 3 == 0 ? 0 : i + 1; // zeroes are the identity, so they must be skipped over + + var tree = new SegmentTree(values); + + for (int start = 0; start <= n; start++) + { + for (int end = start; end <= n; end++) + { + int expected = 0; + for (int i = start; i < end; i++) + { + if (values[i] != 0) + { + expected = values[i]; + break; + } + } + + Assert.Equal(expected, tree.Query(start, end)); + } + } + } + } + + private static void AssertConsistent(SegmentTree> tree, int[] model, Random rand) + { + Assert.Equal(model.Length, tree.Count); + + // Every logical value matches (indexer get and enumeration). + int[] enumerated = tree.ToArray(); + for (int i = 0; i < model.Length; i++) + { + Assert.Equal(model[i], tree[i]); + Assert.Equal(model[i], enumerated[i]); + } + + Assert.Equal(Fold(model, 0, model.Length), tree.Aggregate); + + // A batch of random half-open range queries, plus both degenerate ends. + Assert.Equal(int.MaxValue, tree.Query(0, 0)); + Assert.Equal(int.MaxValue, tree.Query(model.Length, model.Length)); + + for (int q = 0; q < 8; q++) + { + int a = rand.Next(0, model.Length + 1); + int b = rand.Next(0, model.Length + 1); + if (a > b) + (a, b) = (b, a); + + Assert.Equal(Fold(model, a, b), tree.Query(a, b)); + } + } + + private static int Fold(int[] model, int start, int endExclusive) + { + int result = int.MaxValue; + for (int i = start; i < endExclusive; i++) + result = Math.Min(result, model[i]); + + return result; + } +} diff --git a/src/Celerity.Tests/Collections/SegmentTreeTests.cs b/src/Celerity.Tests/Collections/SegmentTreeTests.cs new file mode 100644 index 0000000..2c31d5b --- /dev/null +++ b/src/Celerity.Tests/Collections/SegmentTreeTests.cs @@ -0,0 +1,404 @@ +using System.Collections; +using Celerity.Collections; + +namespace Celerity.Tests.Collections; + +/// +/// Behavioural coverage for : the range-query / point-update core, the +/// indexer, , the four constructors, boundary and +/// validation corners, , and the enumeration surface. The +/// randomized reconciliation against a naive array oracle — including the non-commutative case that the +/// 2n layout has to get right — lives in , and the built-in +/// folds are covered in . +/// +public class SegmentTreeTests +{ + [Fact] + public void Constructor_ShouldStartAtIdentity_WhenGivenLength() + { + var tree = new SegmentTree>(8); + + Assert.Equal(8, tree.Count); + Assert.Equal(int.MaxValue, tree.Aggregate); + for (int i = 0; i < 8; i++) + Assert.Equal(int.MaxValue, tree[i]); + } + + [Fact] + public void Constructor_ShouldAllowZeroLength() + { + var tree = new SegmentTree>(0); + + Assert.Equal(0, tree.Count); + Assert.Equal(0, tree.Aggregate); + Assert.Equal(0, tree.Query(0, 0)); + Assert.Empty(tree); + } + + [Fact] + public void Constructor_ShouldThrow_WhenLengthNegative() + { + var ex = Assert.Throws(() => new SegmentTree>(-1)); + Assert.Equal("length", ex.ParamName); + } + + [Fact] + public void Constructor_ShouldThrow_WhenLengthExceedsMaxSupported() + { + // The layout needs 2 * length array slots, so anything above Array.MaxLength / 2 must be rejected up + // front rather than overflowing into an OverflowException / OutOfMemoryException from the allocation. + var ex = Assert.Throws(() => new SegmentTree>(int.MaxValue)); + Assert.Equal("length", ex.ParamName); + + var atCeiling = Assert.Throws( + () => new SegmentTree>(Array.MaxLength / 2 + 1)); + Assert.Equal("length", atCeiling.ParamName); + } + + [Fact] + public void Constructor_ShouldUseTheSuppliedMonoid_WhenGivenLengthAndInstance() + { + // A stateful monoid can only be supplied through the explicit overload — the parameterless path + // closes over default(TMonoid). + var tree = new SegmentTree(4, new SaturatingSumMonoid(10)); + + Assert.Equal(4, tree.Count); + Assert.Equal(0, tree.Aggregate); + + tree[0] = 7; + tree[1] = 7; + Assert.Equal(10, tree.Query(0, 2)); // 14 saturated at the ceiling the instance carries + } + + [Fact] + public void EnumerableConstructor_ShouldSeedLogicalValues() + { + int[] values = { 3, 1, 4, 1, 5, 9, 2, 6 }; + + var tree = new SegmentTree>(values); + + Assert.Equal(8, tree.Count); + Assert.Equal(1, tree.Aggregate); + for (int i = 0; i < values.Length; i++) + Assert.Equal(values[i], tree[i]); + } + + [Fact] + public void EnumerableConstructor_ShouldSeedLogicalValues_WhenSourceIsNotCounted() + { + // A lazy sequence has no ICollection.Count, so the constructor takes the materialize-once path. + IEnumerable lazy = Enumerable.Range(1, 5).Select(i => i * i); + + var tree = new SegmentTree>(lazy); + + Assert.Equal(5, tree.Count); + Assert.Equal(25, tree.Aggregate); + Assert.Equal(9, tree.Query(0, 3)); + } + + [Fact] + public void EnumerableConstructor_ShouldThrow_WhenSourceIsNull() + { + var ex = Assert.Throws( + () => new SegmentTree>((IEnumerable)null!)); + Assert.Equal("values", ex.ParamName); + } + + [Fact] + public void EnumerableConstructor_ShouldUseTheSuppliedMonoid_WhenGivenAnInstance() + { + var tree = new SegmentTree(new[] { 4, 4, 4 }, new SaturatingSumMonoid(9)); + + Assert.Equal(3, tree.Count); + Assert.Equal(9, tree.Aggregate); // 12 saturated + Assert.Equal(8, tree.Query(0, 2)); + } + + [Fact] + public void EnumerableConstructor_ShouldAcceptAnEmptySource() + { + var tree = new SegmentTree>(Array.Empty()); + + Assert.Equal(0, tree.Count); + Assert.Equal(0, tree.Aggregate); + } + + // ---- Query ------------------------------------------------------------------------------------ + + [Fact] + public void Query_ShouldReturnTheRangeAggregate() + { + var tree = new SegmentTree>(new[] { 5, 3, 8, 1, 9, 2 }); + + Assert.Equal(3, tree.Query(0, 3)); + Assert.Equal(1, tree.Query(2, 5)); + Assert.Equal(2, tree.Query(4, 6)); + Assert.Equal(1, tree.Query(0, 6)); + } + + [Fact] + public void Query_ShouldReturnIdentity_WhenRangeIsEmpty() + { + var tree = new SegmentTree>(new[] { 5, 3, 8 }); + + Assert.Equal(int.MaxValue, tree.Query(0, 0)); + Assert.Equal(int.MaxValue, tree.Query(2, 2)); + Assert.Equal(int.MaxValue, tree.Query(3, 3)); + } + + [Fact] + public void Query_ShouldThrow_WhenBoundsAreOutOfRange() + { + var tree = new SegmentTree>(4); + + var low = Assert.Throws(() => tree.Query(-1, 2)); + Assert.Equal("start", low.ParamName); + + var high = Assert.Throws(() => tree.Query(0, 5)); + Assert.Equal("endExclusive", high.ParamName); + + var startAboveCount = Assert.Throws(() => tree.Query(5, 5)); + Assert.Equal("start", startAboveCount.ParamName); + } + + [Fact] + public void Query_ShouldThrow_WhenEndPrecedesStart() + { + var tree = new SegmentTree>(4); + + var ex = Assert.Throws(() => tree.Query(3, 1)); + Assert.Equal("endExclusive", ex.ParamName); + } + + [Fact] + public void Aggregate_ShouldFoldTheWholeSequence_WhenLengthIsNotAPowerOfTwo() + { + // The 2n layout only makes the root the whole-sequence fold at power-of-two lengths, so this is the + // shape that catches an Aggregate implemented as a root read. + var tree = new SegmentTree>(new[] { 1, 2, 3 }); + + Assert.Equal(6, tree.Aggregate); + Assert.Equal(tree.Query(0, 3), tree.Aggregate); + } + + // ---- Updates ---------------------------------------------------------------------------------- + + [Fact] + public void Indexer_ShouldAssignAndRefoldThePathToTheRoot() + { + var tree = new SegmentTree>(new[] { 5, 3, 8, 1 }); + + tree[3] = 7; + + Assert.Equal(7, tree[3]); + Assert.Equal(3, tree.Aggregate); + Assert.Equal(7, tree.Query(3, 4)); + Assert.Equal(7, tree.Query(2, 4)); + } + + [Fact] + public void Indexer_ShouldWork_WhenTreeHoldsASingleElement() + { + // A one-element tree has no internal node, so the refold loop never runs — the boundary the ancestor + // walk has to survive. + var tree = new SegmentTree>(1); + + tree[0] = 42; + + Assert.Equal(42, tree[0]); + Assert.Equal(42, tree.Aggregate); + Assert.Equal(42, tree.Query(0, 1)); + } + + [Fact] + public void Indexer_ShouldThrow_WhenIndexOutOfRange() + { + var tree = new SegmentTree>(4); + + var get = Assert.Throws(() => tree[4]); + Assert.Equal("index", get.ParamName); + + var negative = Assert.Throws(() => tree[-1]); + Assert.Equal("index", negative.ParamName); + + var set = Assert.Throws(() => tree[4] = 1); + Assert.Equal("index", set.ParamName); + } + + [Fact] + public void Combine_ShouldFoldTheValueIntoTheElement() + { + var tree = new SegmentTree>(new[] { 1, 2, 3, 4 }); + + tree.Combine(1, 10); + + Assert.Equal(12, tree[1]); + Assert.Equal(20, tree.Aggregate); + } + + [Fact] + public void Combine_ShouldKeepTheStoredValueOnTheLeft_WhenMonoidIsNotCommutative() + { + var tree = new SegmentTree(new[] { "a", "b" }); + + tree.Combine(0, "X"); + + Assert.Equal("aX", tree[0]); + Assert.Equal("aXb", tree.Aggregate); + } + + [Fact] + public void Combine_ShouldThrow_WhenIndexOutOfRange() + { + var tree = new SegmentTree>(4); + + var ex = Assert.Throws(() => tree.Combine(9, 1)); + Assert.Equal("index", ex.ParamName); + } + + [Fact] + public void Clear_ShouldResetEveryElementToIdentity_AndKeepTheLength() + { + var tree = new SegmentTree>(new[] { 5, 3, 8, 1, 9 }); + + tree.Clear(); + + Assert.Equal(5, tree.Count); + Assert.Equal(int.MaxValue, tree.Aggregate); + for (int i = 0; i < 5; i++) + Assert.Equal(int.MaxValue, tree[i]); + + // Reusable after the reset. + tree[2] = 4; + Assert.Equal(4, tree.Aggregate); + } + + // ---- Enumeration ------------------------------------------------------------------------------ + + [Fact] + public void GetEnumerator_ShouldYieldLogicalValuesInIndexOrder() + { + int[] values = { 3, 1, 4, 1, 5 }; + var tree = new SegmentTree>(values); + + Assert.Equal(values, tree.ToArray()); + } + + [Fact] + public void GetEnumerator_ShouldYieldNothing_WhenTreeIsEmpty() + { + var tree = new SegmentTree>(0); + + Assert.Empty(tree.ToArray()); + } + + [Fact] + public void NonGenericEnumerator_ShouldYieldTheSameValues() + { + var tree = new SegmentTree>(new[] { 7, 8 }); + + IEnumerator untyped = ((IEnumerable)tree).GetEnumerator(); + var seen = new List(); + while (untyped.MoveNext()) + seen.Add((int)untyped.Current!); + + Assert.Equal(new[] { 7, 8 }, seen); + } + + [Fact] + public void Enumerator_ShouldStayExhausted_WhenMoveNextIsCalledPastTheEnd() + { + var tree = new SegmentTree>(new[] { 1 }); + + SegmentTree>.Enumerator enumerator = tree.GetEnumerator(); + Assert.True(enumerator.MoveNext()); + Assert.Equal(1, enumerator.Current); + Assert.False(enumerator.MoveNext()); + Assert.Equal(0, enumerator.Current); + Assert.False(enumerator.MoveNext()); + + enumerator.Dispose(); + } + + [Fact] + public void Enumerator_ShouldReplayFromTheStart_AfterReset() + { + var tree = new SegmentTree>(new[] { 4, 5 }); + + SegmentTree>.Enumerator enumerator = tree.GetEnumerator(); + Assert.True(enumerator.MoveNext()); + Assert.True(enumerator.MoveNext()); + + enumerator.Reset(); + + Assert.True(enumerator.MoveNext()); + Assert.Equal(4, enumerator.Current); + } + + [Fact] + public void Enumerator_ShouldThrow_WhenTreeIsModifiedDuringEnumeration() + { + var tree = new SegmentTree>(new[] { 1, 2, 3 }); + + Assert.Throws(() => + { + foreach (int _ in tree) + tree[0] = 9; + }); + } + + [Fact] + public void Enumerator_ShouldThrow_WhenCombineRunsDuringEnumeration() + { + var tree = new SegmentTree>(new[] { 1, 2, 3 }); + + Assert.Throws(() => + { + foreach (int _ in tree) + tree.Combine(0, 1); + }); + } + + [Fact] + public void Indexer_ShouldInvalidateEnumerators_EvenWhenAssigningTheStoredValue() + { + // Deliberately unlike FenwickTree, which detects a zero delta: IMonoid carries no equality + // obligation, so the segment tree cannot tell a redundant assignment from a real one and does not + // pretend to. Pinned so the difference reads as a decision rather than an oversight. + var tree = new SegmentTree>(new[] { 1, 2, 3 }); + + Assert.Throws(() => + { + foreach (int _ in tree) + tree[0] = tree[0]; + }); + } + + [Fact] + public void SegmentTree_ShouldBeUsableAsAReadOnlyList() + { + // Unlike FenwickTree the leaves are stored outright, so an O(1) indexer makes IReadOnlyList an + // honest claim rather than a trap for a consumer that indexes in a loop. + IReadOnlyList list = new SegmentTree>(new[] { 3, 1, 4 }); + + Assert.Equal(3, list.Count); + Assert.Equal(1, list[1]); + Assert.Equal(new[] { 3, 1, 4 }, list.ToArray()); + } + + // ---- Reference-type elements ------------------------------------------------------------------ + + [Fact] + public void SegmentTree_ShouldSupportReferenceTypeElements() + { + var tree = new SegmentTree(new[] { "a", "b", "c", "d", "e" }); + + Assert.Equal("abcde", tree.Aggregate); + Assert.Equal("bcd", tree.Query(1, 4)); + Assert.Equal(string.Empty, tree.Query(2, 2)); + + tree[2] = "Z"; + Assert.Equal("abZde", tree.Aggregate); + } + +} diff --git a/src/Celerity.Tests/Collections/TestMonoids.cs b/src/Celerity.Tests/Collections/TestMonoids.cs new file mode 100644 index 0000000..b0cd59e --- /dev/null +++ b/src/Celerity.Tests/Collections/TestMonoids.cs @@ -0,0 +1,61 @@ +using Celerity.Collections; + +namespace Celerity.Tests.Collections; + +// Monoids that exist only for the SegmentTree suites. Every fold shipped with the library is commutative, so +// none of them can observe whether the tree preserves index order; these fill that gap, plus the stateful case +// the instance-taking constructors exist for. + +/// +/// String concatenation. Non-commutative and reference-typed, so it pins both that the tree folds in index +/// order and that T is not restricted to value types. +/// +internal readonly struct ConcatMonoid : IMonoid +{ + public string Identity => string.Empty; + + public string Combine(string left, string right) => left + right; +} + +/// +/// "The first non-zero value wins" — associative, identity 0, and non-commutative: Combine(1, 2) +/// is 1 while Combine(2, 1) is 2. A value-typed counterpart to . +/// +internal readonly struct FirstNonZeroMonoid : IMonoid +{ + public int Identity => 0; + + public int Combine(int left, int right) => left != 0 ? left : right; +} + +/// +/// The greatest common divisor, transcribed verbatim from the "write your own fold" example in +/// 's docs, the API reference and the README. A doc sample a reader is invited to +/// copy has to actually work, so it is pinned here rather than only inspected. It is written over +/// for the reason the docs state: a signed gcd has to normalize its sign, and +/// Math.Abs(int.MinValue) throws. +/// +internal readonly struct GcdMonoid : IMonoid +{ + public uint Identity => 0; // gcd(0, a) == a + + public uint Combine(uint left, uint right) + { + while (right != 0) + (left, right) = (right, left % right); + + return left; + } +} + +/// A monoid that carries state, so the instance-taking constructors have something to prove. +internal readonly struct SaturatingSumMonoid : IMonoid +{ + private readonly int _ceiling; + + public SaturatingSumMonoid(int ceiling) => _ceiling = ceiling; + + public int Identity => 0; + + public int Combine(int left, int right) => Math.Min(left + right, _ceiling); +} diff --git a/src/Celerity/Collections/BitwiseAndMonoid.cs b/src/Celerity/Collections/BitwiseAndMonoid.cs new file mode 100644 index 0000000..8bdac28 --- /dev/null +++ b/src/Celerity/Collections/BitwiseAndMonoid.cs @@ -0,0 +1,27 @@ +using System.Numerics; + +namespace Celerity.Collections; + +/// +/// The bitwise-and monoid: Combine is & and the identity is the all-ones pattern +/// (~T.Zero). +/// +/// The integral element type. +/// +/// The range fold behind "which capabilities does every entry in this window still hold?" — permission +/// masks, feature flags, and any other intersection of bit sets kept in a mutable sequence. Non-invertible +/// (clearing a bit cannot be undone from the aggregate alone), so cannot answer +/// it. +/// +public readonly struct BitwiseAndMonoid : IMonoid + where T : struct, INumberBase, IBitwiseOperators +{ + /// Gets the identity, the all-ones pattern — anding with it leaves every bit as it was. + public T Identity => ~T.Zero; + + /// Ands two values together. + /// The first value. + /// The second value. + /// The bitwise and of the two values. + public T Combine(T left, T right) => left & right; +} diff --git a/src/Celerity/Collections/BitwiseOrMonoid.cs b/src/Celerity/Collections/BitwiseOrMonoid.cs new file mode 100644 index 0000000..229caae --- /dev/null +++ b/src/Celerity/Collections/BitwiseOrMonoid.cs @@ -0,0 +1,24 @@ +using System.Numerics; + +namespace Celerity.Collections; + +/// +/// The bitwise-or monoid: Combine is | and the identity is . +/// +/// The integral element type. +/// +/// The mirror of — "which flags does any entry in this window set?". +/// Non-invertible for the same reason, so cannot answer it. +/// +public readonly struct BitwiseOrMonoid : IMonoid + where T : struct, INumberBase, IBitwiseOperators +{ + /// Gets the identity, all bits clear — oring with it leaves every bit as it was. + public T Identity => T.Zero; + + /// Ors two values together. + /// The first value. + /// The second value. + /// The bitwise or of the two values. + public T Combine(T left, T right) => left | right; +} diff --git a/src/Celerity/Collections/IMonoid.cs b/src/Celerity/Collections/IMonoid.cs new file mode 100644 index 0000000..b5abf3a --- /dev/null +++ b/src/Celerity/Collections/IMonoid.cs @@ -0,0 +1,86 @@ +namespace Celerity.Collections; + +/// +/// An associative binary operation together with its identity element — the algebraic structure +/// folds a range with. +/// +/// The element type the operation combines. +/// +/// +/// Implementations are taken as a struct generic type argument rather than as an interface-typed +/// instance, for the same reason the hashed collections take IHashProvider<T> and the ordered +/// ones take IComparer<T> that way: the JIT specializes the collection for the concrete struct +/// and inlines instead of emitting an interface call, and a segment tree calls it +/// O(log n) times per query and per update. +/// +/// +/// An implementation must satisfy the two monoid laws, because the tree relies on both to answer a query +/// from precomputed partial folds: +/// +/// +/// +/// AssociativityCombine(Combine(a, b), c) equals Combine(a, Combine(b, c)). The tree +/// chooses its own bracketing, so an operation that is not associative gives an unspecified answer. +/// +/// +/// IdentityCombine(Identity, a) and Combine(a, Identity) both equal a. +/// seeds an empty range and every freshly constructed element. +/// +/// +/// +/// Both laws are required only over the implementation's domain — the set of values it declares itself +/// defined for — not over every bit pattern can hold. An implementation that restricts +/// its domain must say so, because a value outside it produces an unspecified aggregate rather than a thrown +/// exception. Two shipped monoids do restrict it: and are +/// defined over the finite values of a floating-point , since their identity is +/// the largest / smallest finite value and NaN loses every comparison. The other three are defined over +/// all of . +/// +/// +/// Commutativity is not required. preserves index order when it +/// folds, so a non-commutative operation (matrix product, "last write wins", string concatenation) is a valid +/// monoid here. +/// +/// +/// To fold by something the built-in monoids do not cover, write a field-free struct and pass it as the type +/// argument: +/// +/// +/// public readonly struct GcdMonoid : IMonoid<uint> +/// { +/// public uint Identity => 0; // gcd(0, a) == a +/// +/// public uint Combine(uint left, uint right) +/// { +/// while (right != 0) +/// (left, right) = (right, left % right); +/// +/// return left; +/// } +/// } +/// +/// var tree = new SegmentTree<uint, GcdMonoid>(values); +/// +/// +/// That example is written over deliberately. A signed gcd has to normalize its sign, and +/// the obvious Math.Abs throws on — whose true gcd with 0 is +/// 2147483648, a value no can hold. Restricting the domain to unsigned values removes +/// the corner rather than papering over it. +/// +/// +public interface IMonoid +{ + /// + /// Gets the identity element: the value e for which Combine(e, a) and Combine(a, e) + /// both equal a, for every a. It is also the aggregate of an empty range. + /// + T Identity { get; } + + /// + /// Combines two values with the monoid's associative operation, preserving operand order. + /// + /// The value that comes first in index order. + /// The value that comes second in index order. + /// The combination of the two values. + T Combine(T left, T right); +} diff --git a/src/Celerity/Collections/MaxMonoid.cs b/src/Celerity/Collections/MaxMonoid.cs new file mode 100644 index 0000000..73b2373 --- /dev/null +++ b/src/Celerity/Collections/MaxMonoid.cs @@ -0,0 +1,33 @@ +using System.Numerics; + +namespace Celerity.Collections; + +/// +/// The maximum monoid: Combine keeps the larger of two values and the identity is +/// . +/// +/// The ordered numeric element type. +/// +/// +/// Like , the domain is every value of an integral but only +/// the finite values of a floating-point one. +/// +/// +/// The mirror of , and non-invertible for the same reason, so +/// cannot answer it either. The same floating-point caveat applies with the signs +/// reversed: the identity is T.MinValue, the smallest finite value, so a stored -∞ +/// aggregates to T.MinValue; and NaN loses every > comparison, so it is discarded from +/// the left operand and kept from the right. +/// +public readonly struct MaxMonoid : IMonoid + where T : struct, INumber, IMinMaxValue +{ + /// Gets the identity, T.MinValue — no stored value can fall below it. + public T Identity => T.MinValue; + + /// Returns the larger of two values. + /// The first value. + /// The second value. + /// when it is strictly larger; otherwise . + public T Combine(T left, T right) => left > right ? left : right; +} diff --git a/src/Celerity/Collections/MinMonoid.cs b/src/Celerity/Collections/MinMonoid.cs new file mode 100644 index 0000000..4ac36f1 --- /dev/null +++ b/src/Celerity/Collections/MinMonoid.cs @@ -0,0 +1,42 @@ +using System.Numerics; + +namespace Celerity.Collections; + +/// +/// The minimum monoid: Combine keeps the smaller of two values and the identity is +/// . +/// +/// The ordered numeric element type. +/// +/// +/// This is one of the folds structurally cannot answer: a Fenwick range query is +/// the difference of two prefix folds, so it needs an inverse, and minimum has none. Range minimum over a +/// sequence that keeps changing is the headline workload. +/// +/// +/// Domain. For an integral the monoid laws hold over every value. For a +/// floating-point one the domain is the finite values only: the identity law fails at ±∞ and +/// NaN, as described next, and permits a declared domain for exactly this case. +/// +/// +/// Floating-point caveat. The identity is T.MaxValue — the largest finite value — because +/// is what the constraint can ask for. For / +/// that means a stored +∞ aggregates to T.MaxValue rather than to +∞. +/// NaN loses every < comparison, so Combine(NaN, x) is x while +/// Combine(x, NaN) is NaN — the aggregate of a range containing a NaN therefore depends on +/// where it sits. Both are the ordinary consequences of ordering IEEE values by <; if you need +/// IEEE-exact minimum semantics, pass a custom that calls T.Min. +/// +/// +public readonly struct MinMonoid : IMonoid + where T : struct, INumber, IMinMaxValue +{ + /// Gets the identity, T.MaxValue — no stored value can exceed it. + public T Identity => T.MaxValue; + + /// Returns the smaller of two values. + /// The first value. + /// The second value. + /// when it is strictly smaller; otherwise . + public T Combine(T left, T right) => left < right ? left : right; +} diff --git a/src/Celerity/Collections/SegmentTree.cs b/src/Celerity/Collections/SegmentTree.cs new file mode 100644 index 0000000..4e93c20 --- /dev/null +++ b/src/Celerity/Collections/SegmentTree.cs @@ -0,0 +1,394 @@ +using System.Collections; + +namespace Celerity.Collections; + +/// +/// A segment tree: a fixed-length, array-backed sequence that answers the aggregate of any half-open +/// range under an arbitrary associative operation, and applies point updates, in O(log n) each — +/// over a single flat array of 2n elements with no per-node object overhead. +/// +/// The element type. +/// +/// The fold. Constrained to struct, IMonoid<T> so the JIT specializes the tree for it and inlines +/// the combine instead of emitting an interface call per level. +/// +/// +/// +/// This is the half of the range-query space cannot reach. A Fenwick range query +/// is the difference of two prefix folds, so the operation must have an inverse — which is why that type +/// is constrained to INumber<T> and answers sums only. A segment tree stores each node's fold +/// outright and never subtracts, so range minimum, maximum, gcd, bitwise +/// and/or — and any user-written associative fold — are all in reach. Where sums are what you want, +/// prefer — it does the same job in half the memory. +/// +/// +/// The BCL has no range-aggregate structure at all, so the baseline is a plain T[] and a loop that folds +/// the slice element by element — O(n) per query, whatever the fold — while precomputing the answers +/// instead makes every point update O(n). There is not even a span helper to lean on: Span<T> +/// has no Min or Max, let alone an arbitrary combine, so the loop is written out by hand. +/// The tree gives both in O(log n), so it wins precisely when updates and +/// range queries interleave — sliding-window minima and maxima over a mutating history, per-window +/// capability masks, "cheapest offer in this price band" over a live order book, and the same rank / windowed +/// aggregate shapes serves for sums. +/// +/// +/// Commutativity is not required: the query folds the nodes it takes from the left and from the right into +/// two separate accumulators and combines them in index order at the end, so a non-commutative monoid gets the +/// same answer a left-to-right scan would. +/// +/// +/// Range updates are deliberately not supported. Applying an operation to every element of a range in +/// O(log n) needs lazy propagation, which needs a second monoid describing how updates compose plus a +/// distributive law relating the two — a different type with a different contract, not an overload of this one. +/// Update point by point, or apply this tree to a difference sequence. +/// +/// +/// It implements , not merely as +/// does, because the leaves are stored outright: the indexer is a direct array +/// read, so an IReadOnlyList consumer that indexes in a loop pays what it expects. A Fenwick tree +/// recovers each value from a difference of prefix folds, which would make the same loop O(n log n). +/// +/// +/// The length is fixed at construction (like and ); the tree +/// does not grow. Reads never mutate, so they never invalidate an enumerator. Every mutation bumps the version: +/// unlike , an assignment that stores the value already there is not detected as a +/// no-op, because carries no equality obligation and the tree will not impose one. +/// This type is not thread-safe; concurrent callers must synchronize externally. +/// +/// +public sealed class SegmentTree : IReadOnlyList + where TMonoid : struct, IMonoid +{ + // Flat iterative layout over exactly 2n cells: the logical element at index i lives at _tree[_length + i], + // and every internal node k in [1, _length) holds Combine(_tree[2k], _tree[2k + 1]). _tree[0] is unused. + // + // The alternative layout pads the leaf count up to a power of two, which costs up to 4n cells. It is + // usually preferred because the 2n layout leaves the elements in a rotated order when _length is not a + // power of two, so an internal node can span a wrapped, non-contiguous range — _tree[1] is the aggregate + // of the whole sequence only when _length is a power of two, which is why Aggregate is a query rather than + // a root read. That rotation does not reach the answer: the query below never combines a wrapped node into + // the wrong side, because it walks outward from the two ends and keeps the two directions in separate + // accumulators. SegmentTreeDifferentialTests pins that against a deliberately non-commutative monoid, + // which is the only kind that can observe the difference. + private readonly T[] _tree; + private readonly int _length; + + // The fold. Not readonly: a readonly field of a struct type is defensively copied on every member call, + // which would put a copy on the hottest path in the type. Matches BTreeSet's comparer field. + private TMonoid _monoid; + + // Bumped on every mutation (indexer set / Combine / Clear) so active enumerators throw on concurrent + // modification. A pure query is not a mutation and does not bump it. + private int _version; + + /// + /// The largest logical length a tree can hold. The layout stores two cells per logical element, so the + /// ceiling is half of . + /// + private static readonly int MaxLength = Array.MaxLength / 2; + + /// + /// Initializes a new segment tree of logical elements, each equal to the monoid's + /// identity. + /// + /// + /// The number of logical elements. Must be non-negative and at most half of + /// (the layout stores two cells per element). + /// + /// + /// is negative, or exceeds the maximum supported length. + /// + public SegmentTree(int length) + : this(length, default) + { + } + + /// + /// Initializes a new segment tree of logical elements, each equal to the identity + /// of the supplied . + /// + /// The number of logical elements. + /// The fold to use. Pass this overload only when the monoid carries state. + /// + /// is negative, or exceeds the maximum supported length. + /// + public SegmentTree(int length, TMonoid monoid) + { + if (length < 0) + throw new ArgumentOutOfRangeException(nameof(length), length, "Length must be non-negative."); + if (length > MaxLength) + throw new ArgumentOutOfRangeException(nameof(length), length, + $"Length must be at most {MaxLength} (half of Array.MaxLength — the layout stores two cells per element)."); + + _monoid = monoid; + _length = length; + _tree = new T[2 * length]; + Array.Fill(_tree, _monoid.Identity); + } + + /// + /// Initializes a new segment tree seeded with , built in O(n). The logical + /// element at index i starts equal to the i-th element of . + /// + /// The initial logical values, in enumeration order. + /// is null. + /// + /// holds more than half of elements. + /// + public SegmentTree(IEnumerable values) + : this(values, default) + { + } + + /// + /// Initializes a new segment tree seeded with and folded by the supplied + /// , built in O(n). + /// + /// The initial logical values, in enumeration order. + /// The fold to use. Pass this overload only when the monoid carries state. + /// is null. + /// + /// holds more than half of elements. + /// + public SegmentTree(IEnumerable values, TMonoid monoid) + { + ArgumentNullException.ThrowIfNull(values); + + _monoid = monoid; + + // A counted source (T[], List, ...) is length-checked *before* anything is allocated and then copied + // straight into the leaf half of the backing array, so an oversized source reports the documented + // ArgumentException instead of failing the allocation first, and no intermediate array is built. + if (values is ICollection collection) + { + int count = collection.Count; + ThrowIfSourceTooLong(count, nameof(values)); + + _length = count; + _tree = new T[2 * count]; + collection.CopyTo(_tree, count); + } + else + { + // Unknown length: materialize once, then apply the same ceiling. + T[] seed = values.ToArray(); + ThrowIfSourceTooLong(seed.Length, nameof(values)); + + _length = seed.Length; + _tree = new T[2 * _length]; + Array.Copy(seed, 0, _tree, _length, _length); + } + + // Linear-time build: every leaf now holds its own logical value, and one descending pass folds each + // pair into its parent — O(n), not O(n log n) point-inserts. + for (int k = _length - 1; k > 0; k--) + _tree[k] = _monoid.Combine(_tree[2 * k], _tree[2 * k + 1]); + } + + /// Gets the number of logical elements in the tree (its fixed length). + public int Count => _length; + + /// + /// Gets the aggregate of every logical element — equivalent to Query(0, Count), and the monoid's + /// identity for an empty tree. This is O(log n), not a constant-time root read: the 2n layout + /// only makes the root the whole-sequence fold when is a power of two. + /// + public T Aggregate => QueryCore(0, _length); + + /// + /// Gets or sets the logical value at . The getter is O(1) (a direct leaf + /// read); the setter is O(log n) (it refolds the path to the root). Assigning the value already + /// stored still bumps the version and so invalidates active enumerators — see the type remarks. + /// + /// The zero-based logical index. Must be in [0, Count). + /// The current logical value at . + /// is out of range. + public T this[int index] + { + get + { + if ((uint)index >= (uint)_length) + ThrowIndexOutOfRange(index); + + return _tree[_length + index]; + } + set + { + if ((uint)index >= (uint)_length) + ThrowIndexOutOfRange(index); + + SetLeaf(index, value); + } + } + + /// + /// Folds into the logical element at — the element + /// becomes Combine(current, value) — in O(log n). This is the monoid-native update: unlike + /// it needs no inverse, and the current value stays on the left so + /// a non-commutative fold behaves as written. + /// + /// The zero-based logical index. Must be in [0, Count). + /// The value to fold into the element. + /// is out of range. + public void Combine(int index, T value) + { + if ((uint)index >= (uint)_length) + ThrowIndexOutOfRange(index); + + SetLeaf(index, _monoid.Combine(_tree[_length + index], value)); + } + + /// + /// Returns the aggregate of the logical elements in [start, endExclusive), in O(log n). An + /// empty range (start == endExclusive) yields the monoid's identity. + /// + /// The inclusive lower bound. Must be in [0, endExclusive]. + /// The exclusive upper bound. Must be in [start, Count]. + /// The aggregate of the logical elements in the half-open range. + /// The range is invalid or out of bounds. + public T Query(int start, int endExclusive) + { + if ((uint)start > (uint)_length) + throw new ArgumentOutOfRangeException(nameof(start), start, + "start must be in the range [0, Count]."); + if ((uint)endExclusive > (uint)_length) + throw new ArgumentOutOfRangeException(nameof(endExclusive), endExclusive, + "endExclusive must be in the range [0, Count]."); + if (endExclusive < start) + throw new ArgumentOutOfRangeException(nameof(endExclusive), endExclusive, + "endExclusive must be greater than or equal to start."); + + return QueryCore(start, endExclusive); + } + + /// + /// Resets every logical element to the monoid's identity. Runs in O(n), and bumps the version + /// unconditionally — the tree is fixed-length, so establishing "already all identity" would cost the same + /// scan as the reset it would skip. + /// + public void Clear() + { + Array.Fill(_tree, _monoid.Identity); + _version++; + } + + /// + /// Returns an enumerator over the logical values in index order. Enumeration is O(n) — the leaves are + /// stored outright, so no value has to be recovered from the folds above it. + /// + /// A struct enumerator over the logical values. + public Enumerator GetEnumerator() => new(this); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + // ---- internals --------------------------------------------------------------------------------- + + // Point assignment without bounds validation (callers validate). Writes the leaf, then refolds each + // ancestor from its two children — O(log n) combines, no inverse needed. + private void SetLeaf(int index, T value) + { + int node = _length + index; + _tree[node] = value; + + // node starts at most at 2 * _length - 1, so the first parent is at most _length - 1 and the child + // indices below stay inside the array. A tree of length 1 has no internal node and skips the loop. + for (node >>= 1; node >= 1; node >>= 1) + _tree[node] = _monoid.Combine(_tree[2 * node], _tree[2 * node + 1]); + + _version++; + } + + // The range fold, without validation — shared by Query, Aggregate and nothing else. Walks outward from + // both ends, taking each node that is fully inside the range and halving the bounds one level per step. + // + // The two accumulators are what makes this correct for a non-commutative monoid: nodes reached from the + // left bound arrive in increasing index order and nodes reached from the right bound in decreasing order, + // so folding each side into its own accumulator and combining left-then-right at the end reproduces the + // order a linear scan would use. Collapsing them into one accumulator would interleave the two directions. + private T QueryCore(int start, int endExclusive) + { + T resultLeft = _monoid.Identity; + T resultRight = _monoid.Identity; + + for (int l = start + _length, r = endExclusive + _length; l < r; l >>= 1, r >>= 1) + { + if ((l & 1) != 0) + resultLeft = _monoid.Combine(resultLeft, _tree[l++]); + if ((r & 1) != 0) + resultRight = _monoid.Combine(_tree[--r], resultRight); + } + + return _monoid.Combine(resultLeft, resultRight); + } + + private static void ThrowIfSourceTooLong(int count, string paramName) + { + if (count > MaxLength) + throw new ArgumentException( + $"The source holds more than the maximum supported length of {MaxLength} elements.", + paramName); + } + + private static void ThrowIndexOutOfRange(int index) => + throw new ArgumentOutOfRangeException(nameof(index), index, + "Index must be in the range [0, Count)."); + + /// A struct enumerator over a 's logical values in index order. + public struct Enumerator : IEnumerator + { + private readonly SegmentTree _tree; + private readonly int _version; + private int _index; + private T _current; + + internal Enumerator(SegmentTree tree) + { + _tree = tree; + _version = tree._version; + _index = 0; + _current = default!; + } + + /// Gets the logical value at the current position of the enumerator. + public readonly T Current => _current; + + readonly object? IEnumerator.Current => _current; + + /// Advances the enumerator to the next logical value. + /// true if there is a next value; otherwise false. + /// The tree was modified during enumeration. + public bool MoveNext() + { + if (_version != _tree._version) + throw new InvalidOperationException("The segment tree was modified during enumeration."); + + if (_index < _tree._length) + { + _current = _tree._tree[_tree._length + _index]; + _index++; + return true; + } + + _current = default!; + return false; + } + + /// Resets the enumerator to before the first value. + /// The tree was modified during enumeration. + public void Reset() + { + if (_version != _tree._version) + throw new InvalidOperationException("The segment tree was modified during enumeration."); + + _index = 0; + _current = default!; + } + + /// Releases resources used by the enumerator. This is a no-op. + public readonly void Dispose() + { + } + } +} diff --git a/src/Celerity/Collections/SumMonoid.cs b/src/Celerity/Collections/SumMonoid.cs new file mode 100644 index 0000000..60604bb --- /dev/null +++ b/src/Celerity/Collections/SumMonoid.cs @@ -0,0 +1,27 @@ +using System.Numerics; + +namespace Celerity.Collections; + +/// +/// The additive monoid: Combine is + and the identity is . +/// +/// The numeric element type. +/// +/// A over this monoid answers range sums, which +/// also does — in half the memory and with a shorter constant, because addition +/// has an inverse and a Fenwick tree exploits that. Prefer for sums; this monoid +/// exists so the segment tree can be differentially tested against it, and for the case where a single tree +/// has to be switchable between a sum fold and a non-invertible one. +/// +public readonly struct SumMonoid : IMonoid + where T : struct, INumberBase +{ + /// Gets the additive identity, zero. + public T Identity => T.Zero; + + /// Adds two values. + /// The first addend. + /// The second addend. + /// The sum of the two values. + public T Combine(T left, T right) => left + right; +} diff --git a/web/dev/bench/detail.html b/web/dev/bench/detail.html index ea387a3..eaf5a35 100644 --- a/web/dev/bench/detail.html +++ b/web/dev/bench/detail.html @@ -394,6 +394,7 @@ { key: 'Trie', title: 'Trie', vs: 'Dictionary' }, { key: 'StringInternTable', title: 'StringInternTable', vs: 'HashSet / Dictionary' }, { key: 'FenwickTree', title: 'FenwickTree', vs: 'long[] (naive prefix sum)' }, + { key: 'SegmentTree', title: 'SegmentTree>', vs: 'long[] (naive range min)' }, { key: 'RadixSort', title: 'RadixSort (int keys)', vs: 'Array.Sort', items: [100, 1000, 100000, 1000000] }, { key: 'CountingSort', title: 'CountingSort (byte keys)', vs: 'Array.Sort', items: [100, 1000, 100000, 1000000] }, { key: 'PartialSort', title: 'PartialSort (k = 1% of n)', vs: 'Array.Sort / LINQ / PriorityQueue heap', items: [100, 1000, 100000, 1000000] }, diff --git a/web/dev/bench/index.html b/web/dev/bench/index.html index 0b8a530..6294ec2 100644 --- a/web/dev/bench/index.html +++ b/web/dev/bench/index.html @@ -489,6 +489,9 @@

Hash function throughput

// FenwickTree is a Binary Indexed Tree benchmarked against a plain long[] holding the raw values: point // updates are O(1) there, but every prefix / range sum re-adds the slice (O(n)). The tree keeps both O(log n). { key: 'FenwickTree', title: 'FenwickTree', vs: 'long[] (naive prefix sum)', ops: ['Mixed', 'RangeSum'] }, + // SegmentTree covers the folds a Fenwick tree structurally cannot — the baseline is the same plain long[], + // scanned for the range minimum, because the BCL has no range-aggregate structure at all. + { key: 'SegmentTree', title: 'SegmentTree>', vs: 'long[] (naive range min)', ops: ['Mixed', 'RangeMin'] }, // The Celerity.Sorting package. These are span algorithms rather than collections, so "items" is // the span length and the sweep starts at 100 on purpose: radix and counting sort are expected to // LOSE below a few hundred elements, and the card is where that crossover is read off rather than diff --git a/web/index.html b/web/index.html index 3714052..574cd06 100644 --- a/web/index.html +++ b/web/index.html @@ -309,6 +309,7 @@

What ships in the box

Trie<TValue>
Ordered prefix tree mapping string keys to values: GetByPrefix lists every entry under a prefix in O(prefix + matches) and TryGetLongestPrefix finds the longest stored prefix of a query in O(query). The trie the BCL lacks — autocomplete, routing, and ordered iteration, where a Dictionary must scan every key and run StartsWith.
StringInternTable
Canonicalizing token table probed with a ReadOnlySpan<char>: GetOrAdd returns the one shared string for those characters and allocates only on a miss, so a 10M-cell parse over 100 distinct tokens creates 100 strings, not 10,000,000. The collection you cannot build on the pre-.NET-9 BCL — HashSet<string> makes you allocate the string before you can discover you already had it. The same span-keyed lookups also ship on FrozenCelerityDictionary, FrozenCeleritySet, CelerityDictionary, CeleritySet, and Trie.
FenwickTree<T>
Binary Indexed Tree over a fixed-length numeric sequence: point update and prefix / range sum both in O(log n), in one array with no per-node overhead. The prefix-sum structure the BCL lacks — running aggregates, rank counters and cumulative-frequency tables, where a plain array is O(n) per query or O(n) per update.
+
SegmentTree<T, M>
Range aggregates over an arbitrary associative fold, with point updates and range queries 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 — a segment tree stores each node's fold outright, so range min, max, gcd, bitwise and/or — and any monoid you write — are all in reach. The BCL has no range-aggregate structure at all, so the baseline is a plain array scanned per query. Non-commutative folds are safe: the query preserves index order.
BTreeDictionary<K, V, C>
Sorted map backed by a B-tree with up to 31 keys per node in flat arrays, so a lookup visits ~log32(n) nodes instead of chasing ~log2(n) pointers. The B-tree the BCL lacks — Min/Max, lower/upper bound and O(log n + k) range scans, where SortedDictionary is a red-black tree with an object per entry and SortedList memmoves on every insert.
BTreeSet<T, C>
The set counterpart: ordered elements packed 31 to a node, with the same ordered surface and an in-order walk over contiguous arrays instead of successor pointers. Beats SortedSet on the interleaved insert + membership + range-scan workload, and stores no values, so the memory saving is larger still.
SortedSpan
Set algebra over already-sorted spans (in Celerity.Primitives): Intersect / Union / Except straight into a caller-owned Span<T>, plus IntersectCount / Overlaps that need no buffer and allocate nothing. The BCL has no set operation over spans, so the alternatives — HashSet<T>.IntersectWith and LINQ Intersect — allocate a table and hash every element instead of exploiting the order the data already has. 4.2× faster at 1M × 1M with zero allocation against 17.9 MB, and 257× on the asymmetric 1k × 10M shape, where it gallops. Sorted by construction, or this is worthless: unsorted input silently returns a wrong answer.