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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 17 additions & 11 deletions .github/workflows/benchmarks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,22 +26,28 @@ on:
# together on the one shard runner, so the per-benchmark delta still cancels hardware.
env:
# Keep in sync with the matrix.shard list below.
SHARD_TOTAL: '6'
#
# Raised 6 -> 8: at 6 the heaviest shard measured ~65 min per slice, so head + base
# came to ~130 min and overran the 120 min timeout, while the lightest finished in
# ~86 min. CiConfig.cs is explicit that the job schedule stays as-is and the matrix
# is what scales when the suite grows, so this widens the matrix rather than trading
# away measurement accuracy.
SHARD_TOTAL: '8'

jobs:
benchmark-shard:
name: benchmark (shard ${{ matrix.shard }})
runs-on: ubuntu-latest
# Steady state: head slice (~30 min) + base slice (~30 min) ~= 60 min. The one
# transitional run of THIS PR is slower (see the base step) and may be cancelled
# here — that is no worse than the status quo (the gate is cancelled on every PR
# today) and self-heals the moment `--shard` lands on main.
# Measured at SHARD_TOTAL=6: slices ran 43-65 min, so head + base came to 86-130 min
# and the heaviest shard overran this timeout. At 8 the same work divides further,
# putting the heaviest shard back comfortably inside the limit. Re-measure and widen
# the matrix again if shard durations creep back toward it.
timeout-minutes: 120
strategy:
fail-fast: false
matrix:
# Must enumerate 0 .. SHARD_TOTAL-1.
shard: [0, 1, 2, 3, 4, 5]
shard: [0, 1, 2, 3, 4, 5, 6, 7]

permissions:
contents: read
Expand Down Expand Up @@ -85,11 +91,11 @@ jobs:
# on THIS runner so hardware variance cancels (hosted runners vary 20-50%
# run-to-run, so a stored cross-runner baseline would be noise-dominated).
#
# TRANSITIONAL: until this PR's `--shard` support lands on main, the base tip's
# `--ci` does not understand `--shard` and runs the FULL ~3h suite here, so these
# base steps will exceed the job timeout and be cancelled on THIS PR only. That
# matches today's behaviour (the gate is already cancelled on every PR) and the
# workflow self-heals once merged — every later PR's base honours `--shard`.
# `--shard` is on main now, so the base tip honours it and measures only this
# shard's slice — the transitional full-suite base run that this comment used to
# warn about no longer happens. A cancellation here is therefore a real signal (the
# slice genuinely exceeded the job timeout) rather than expected behaviour that will
# self-heal, and should be investigated instead of dismissed.
if: github.event_name == 'pull_request'
working-directory: ${{ github.workspace }}
run: |
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ All notable changes to Celerity are documented here. This project follows [Keep

### Added

- **`FenwickTree<T>`** in `Celerity.Collections` — a Binary Indexed Tree over a fixed-length numeric sequence (`where T : struct, INumber<T>`) that applies point updates and answers prefix / range sums both in `O(log n)`, filling a BCL gap: .NET ships no prefix-sum structure, and a plain array costs `O(n)` per query or `O(n)` per update. It wins precisely where updates and range-sum queries interleave — running aggregates, rank / order-statistics counters, cumulative-frequency tables. Not thread-safe. Closes [#289](https://github.com/marius-bughiu/Celerity/issues/289).
Comment thread
marius-bughiu marked this conversation as resolved.
- **`Trie<TValue>`** in `Celerity.Collections` — an ordered prefix tree mapping `string` keys to values, filling a BCL gap (.NET ships no trie). `GetByPrefix` lists every entry whose key starts with a prefix in `O(prefix + matches)` and in ascending key order, and `TryGetLongestPrefix` finds the longest stored key that is a prefix of a query in `O(query)` — the autocomplete, longest-prefix-routing, and ordered-iteration workloads a `Dictionary<string, TValue>` can only answer with an `O(n)` scan plus a `StartsWith` per key. Exact `Add` / `TryGetValue` favour a `Dictionary`, so the trie earns its place on the prefix operations. Implements `IReadOnlyDictionary<string, TValue?>`; not thread-safe. Closes [#285](https://github.com/marius-bughiu/Celerity/issues/285).
- **`SparseSet`** in `Celerity.Collections` — a bounded-universe `[0, Universe)` integer set (the Briggs–Torczon sparse set), filling a BCL gap. Where the set is cleared and rebuilt often — "visited" sets in graph traversal, ECS, sweep-line — it beats `HashSet<int>` with an `O(1)` `Clear` (the backing arrays are left untouched) and dense iteration over just the present elements. Costs `O(Universe)` memory, stores only values in `[0, Universe)`, and implements `ISet<int>`; an opt-in specialized type. Closes [#287](https://github.com/marius-bughiu/Celerity/issues/287).

Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ The mutable sets (`CeleritySet`, `SwissSet`, `RobinHoodSet`, `HashCachingSet`, `

- `Trie<TValue>` — ordered **prefix tree** mapping string keys to values. `GetByPrefix` lists every entry whose key starts with a prefix in `O(prefix + matches)`, and `TryGetLongestPrefix` finds the longest stored key that is a prefix of a query in `O(query)`. The trie the BCL lacks — autocomplete, longest-prefix routing, and ordered (ascending-ordinal) iteration, where a `Dictionary<string, TValue>` has no prefix index and must scan every key and run `StartsWith`. Exact `Add` / `TryGetValue` favour a `Dictionary` (one hash vs a character walk); the trie earns its place on the prefix operations. Implements `IReadOnlyDictionary<string, TValue?>`.

**Prefix sums**

- `FenwickTree<T>` — a **Binary Indexed Tree** over a fixed-length numeric sequence (`where T : struct, INumber<T>`): **point update** and **prefix / range sum** both in `O(log n)`, in one flat array with no per-node overhead. The prefix-sum structure the BCL lacks — running aggregates, rank / order-statistics counters, cumulative-frequency tables — where a plain array is `O(n)` per query (recompute the slice) *or* `O(n)` per update (fix the suffix). Wins precisely when updates and partial-sum queries interleave.

**Probabilistic & bit-level**

- `BloomFilter<T, THasher>` — **probabilistic** membership: bit-array storage, **no false negatives**, tunable false-positive rate, a fraction of a `HashSet<T>`'s memory. Add-and-test only.
Expand Down Expand Up @@ -467,6 +471,24 @@ if (routes.TryGetLongestPrefix("/api/v1/users/42", out string? route, out string

</details>

<details>
<summary><b>Prefix sums with live updates</b> — FenwickTree</summary>

`FenwickTree<T>` (`where T : struct, INumber<T>`) is a **Binary Indexed Tree**: a fixed-length numeric sequence that answers **prefix / range sums** and applies **point updates** both in `O(log n)`, in one array with no per-node overhead. The BCL ships nothing for the interleaved update + prefix-sum-query workload — a plain array is `O(n)` per query or `O(n)` per update. It wins precisely when both interleave (running aggregates, rank counters, cumulative-frequency tables).

```csharp
var tree = new FenwickTree<long>(new long[] { 3, 1, 4, 1, 5, 9 });

Console.WriteLine(tree.PrefixSum(3)); // 8 (3 + 1 + 4)
Console.WriteLine(tree.RangeSum(2, 5)); // 10 (4 + 1 + 5)

tree.Add(0, 10); // point update, O(log n)
Console.WriteLine(tree[0]); // 13
Console.WriteLine(tree.Total); // 33
```

</details>

<details>
<summary><b>Construct from an existing collection</b></summary>

Expand Down Expand Up @@ -526,6 +548,7 @@ Each type buys a different tradeoff. Find your workload below; if it isn't here,
| **Incremental connectivity / connected components** — union equivalence classes and ask whether two elements are in the same group (Kruskal MST, clustering, image segmentation, undirected cycle detection, "are these accounts linked?") | `DisjointSet<T>` | Union-find with **union by size** + **path halving**: near-`O(1)` amortized `Union` / `Find` / `Connected`, `O(α(n)) ≤ 4`. Runs a stream of merges + connectivity queries in near-linear total time, where the BCL substitutes are super-linear — a `Dictionary<T, HashSet<T>>` set-merge is `O(n²)` to coalesce `n` singletons, and a per-query BFS/DFS is `O(V+E)` every query. Grows only by merging (no un-union); it is not an `ISet<T>` — for element membership with add/remove/set-algebra use `CeleritySet` or `HashSet<T>`. |
| **Priority queue whose priorities change** — a best-so-far frontier you relax (Dijkstra / Prim / A\*), or an event scheduler that reschedules / cancels pending items | `IndexedPriorityQueue<TElement, TPriority, THasher>` | Addressable binary min-heap with an element→slot index: `Update` (decrease-/increase-key) and `Remove` an arbitrary element in `O(log n)`, `Contains` / `TryGetPriority` in `O(1)`. The BCL `PriorityQueue<,>` can do none of these — its only substitute is lazy deletion, which grows the heap by one entry per update. Each element is a key (appears once); custom `IComparer<TPriority>` for a max-heap. For plain enqueue/dequeue with duplicate elements, the BCL `PriorityQueue<,>` is simpler. |
| **Prefix / autocomplete / longest-prefix** over string keys — list everything under a prefix, find the most specific stored key that prefixes a query, or iterate keys in order (typeahead, route/dispatch tables, tokenizer / dictionary matching, namespace listing) | `Trie<TValue>` | Ordered prefix tree: `GetByPrefix` yields every entry under a prefix in `O(prefix + matches)` and in ascending key order, `TryGetLongestPrefix` finds the longest stored prefix of a query in `O(query)`, and enumeration is sorted for free — none of which a `Dictionary<string, TValue>` can do without an `O(n)` scan + `StartsWith`. For **pure exact-key** `Add` / `TryGetValue` / `Remove` a `Dictionary` (one hash vs a per-character walk) is faster; the trie earns its place only when you use the prefix operations. Implements `IReadOnlyDictionary<string, TValue?>`; not thread-safe. |
| **Prefix / range sums over a sequence you keep mutating** — running aggregates, rank / order-statistics counters (inversions, "how many ≤ x seen"), cumulative-frequency tables | `FenwickTree<T>` | Binary Indexed Tree (`T : INumber<T>`): **point update** and **prefix / range sum** both `O(log n)`, in one array with no per-node overhead. The BCL has no prefix-sum structure; a plain array forces `O(n)` per query (recompute the slice) *or* `O(n)` per update (fix the suffix). Wins precisely when updates and partial-sum queries interleave. If the data is immutable after build, a one-shot precomputed prefix-sum array answers in `O(1)` with less code; if you only update and never query a partial sum, a raw array is simpler. |
| Need a stable iteration order or multi-threaded access | BCL `Dictionary<,>`, `ConcurrentDictionary<,>` (or `Trie<TValue>` for ordered string keys) | Celerity is single-threaded, and the hash-based collections leave iteration order unspecified. The exception is `Trie<TValue>`, which iterates in ascending ordinal key order by contract. |

**Celerity is not the right answer when** you need concurrent access (use `ConcurrentDictionary<,>` or your own lock — Celerity is single-threaded), the mutable `IDictionary<,>` interface, or a guaranteed iteration order from the **hash-based** collections (the dictionaries and sets expose `IReadOnlyDictionary<,>` / `IReadOnlySet<>` only and do not promise order across versions). If you need ordered string-keyed iteration, `Trie<TValue>` provides it by contract (ascending ordinal key order).
Expand Down
68 changes: 68 additions & 0 deletions docs/api/collections.md
Original file line number Diff line number Diff line change
Expand Up @@ -3652,3 +3652,71 @@ foreach (var (path, handler) in routes.GetByPrefix("/api/v1/"))
if (routes.TryGetLongestPrefix("/api/v1/users/42", out string? route, out string? handler))
Console.WriteLine($"matched {route} -> {handler}"); // matched /api/v1/users -> users-v1
```

## FenwickTree&lt;T&gt;

```csharp
public sealed class FenwickTree<T> : IReadOnlyCollection<T>
where T : struct, INumber<T>
```

A **Fenwick tree** (Binary Indexed Tree) is a fixed-length, array-backed sequence of numeric values that answers **prefix sums** — and therefore arbitrary **range sums** — and applies **point updates** in `O(log n)` each, over a single flat array of `n + 1` elements (index `0` is unused by the 1-based layout), with no per-node object overhead. It is generic over `System.Numerics.INumber<T>`, so it works for `int`, `long`, `uint`, `ulong`, `double`, `decimal`, and any other value type with generic-math addition and subtraction.

The BCL ships nothing for the **interleaved point-update + prefix-sum-query** workload, and a plain `T[]` forces a losing tradeoff: keep the raw values and every prefix / range query is `O(n)` (sum a slice); precompute a running-total array and queries are `O(1)` but every point update is `O(n)` (fix the whole suffix). A Fenwick tree gives **both** in `O(log n)`.

### How it works

Each stored cell holds the partial sum of a contiguous range of the logical sequence whose length is the lowest set bit of its (1-based) index. A prefix query accumulates `O(log n)` cells by repeatedly stripping the lowest set bit (`k -= k & -k`); a point update touches the `O(log n)` cells whose ranges cover the changed index by repeatedly adding it back (`k += k & -k`). A range sum is the difference of two prefix sums. The constructor from a value sequence builds the tree in `O(n)` (one ascending pass that pushes each cell into its parent), not `O(n log n)` point-inserts.

### The documented BCL-beating workload

Any stream that **mixes updates with range-sum queries**: running / rolling aggregates, order-statistics and rank counters (counting inversions, "how many seen values are ≤ x"), cumulative-frequency tables, sliding-window sums over a mutating history, and gradient / weight accumulators. Against a plain array these are `O(n·q)`; against the Fenwick tree they are `O(q·log n)`. See the [Fenwick-tree benchmark](https://marius-bughiu.github.io/Celerity/dev/bench/?collection=FenwickTree) on the dashboard.

### Constructors

```csharp
public FenwickTree(int length) // length logical elements, all zero
public FenwickTree(IEnumerable<T> values) // O(n) build seeded with values, in order
```

`length` must be non-negative and at most `Array.MaxLength - 1` — the 1-based Fenwick layout reserves one array slot (`ArgumentOutOfRangeException` otherwise). The length is **fixed** at construction — the tree does not grow; `Clear` resets the values to zero but keeps the length. The `IEnumerable<T>` overload throws `ArgumentNullException` on a null source and never aliases a caller-supplied array (it copies).

### Methods and properties

| Member | Description |
| --- | --- |
| `int Count { get; }` | The number of logical elements (the fixed length). |
| `T Total { get; }` | The sum of every logical element — `PrefixSum(Count)`. |
| `T this[int index] { get; set; }` | Get/set the logical value at `index`. Both are `O(log n)`; the getter is `RangeSum(index, index + 1)`, the setter applies the delta to reach the new value. Assigning the value already stored is a no-op. |
| `void Add(int index, T delta)` | Add `delta` to the value at `index`, in `O(log n)`. A negative `delta` subtracts (for signed `T`); a zero `delta` is a no-op. |
| `T PrefixSum(int endExclusive)` | Sum of the logical elements in `[0, endExclusive)`, in `O(log n)`. `PrefixSum(0)` is zero; `PrefixSum(Count)` is `Total`. |
| `T RangeSum(int start, int endExclusive)` | Sum of the logical elements in the half-open range `[start, endExclusive)`, in `O(log n)`. An empty range sums to zero. |
| `void Clear()` | Reset every logical element to zero (`O(n)`); the length is unchanged. |
| `Enumerator GetEnumerator()` | Struct enumerator yielding the logical values in index order (`O(n log n)` total). |

Index and range arguments are bounds-checked (`ArgumentOutOfRangeException`): `index` must be in `[0, Count)`, a prefix bound in `[0, Count]`, and a range must satisfy `0 ≤ start ≤ endExclusive ≤ Count`. Reads never mutate, so they never invalidate an enumerator; `Add`, the indexer setter, and `Clear` do — except when they are no-ops (a zero delta, or assigning the value already stored), which leave both the state and any active enumerator untouched. Not thread-safe.

### Choosing it

Reach for `FenwickTree<T>` 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.

### Usage example

```csharp
using Celerity.Collections;

// Count inversions with a rank counter: how many already-seen values exceed the current one.
int[] data = { 5, 2, 6, 1, 3, 4 };
int maxValue = 6;

var seen = new FenwickTree<int>(maxValue + 1); // one counter slot per possible value
long inversions = 0;
foreach (int x in data)
{
// values already seen that are strictly greater than x -> an inversion each
inversions += seen.RangeSum(x + 1, maxValue + 1);
seen.Add(x, 1); // record that we have now seen x
}

Console.WriteLine(inversions); // 8
```
34 changes: 34 additions & 0 deletions src/Celerity.AotSmokeTest/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,40 @@ void Check(bool condition, string message)
Check(reached.Count == 4 && reached.Contains(2), "SparseSet ISet<int> union within universe");
}

// FenwickTree — Binary Indexed Tree over a numeric sequence. This is the one collection
// built on generic math (INumber<T>), so the static abstract interface members resolve
// through constrained calls the AOT compiler must specialize per T — worth pinning here
// over more than one T. Exercise the O(n) seeded build, point update, prefix / range sums,
// the indexer round-trip, the no-op update, clear-then-reuse, and the struct enumerator.
{
var ft = new FenwickTree<long>(new long[] { 3, 1, 4, 1, 5, 9 });
Check(ft.Count == 6 && ft.Total == 23, "FenwickTree seeded build + total");
Check(ft.PrefixSum(0) == 0 && ft.PrefixSum(3) == 8 && ft.PrefixSum(6) == 23, "FenwickTree prefix sums");
Check(ft.RangeSum(2, 5) == 10 && ft.RangeSum(4, 4) == 0, "FenwickTree range sum + empty range");

ft.Add(0, 10);
Check(ft[0] == 13 && ft.Total == 33, "FenwickTree point update");
ft[1] = 100;
Check(ft[1] == 100 && ft.Total == 132, "FenwickTree indexer set");

var before = new List<long>();
foreach (long v in ft) before.Add(v);
Check(before.Count == 6 && before[0] == 13 && before[1] == 100, "FenwickTree enumerates logical values");

ft.Add(2, 0); // no-op: must not invalidate the enumerator below
var during = 0;
foreach (long _ in ft) { ft[3] = ft[3]; during++; } // no-op assignment mid-enumeration
Check(during == 6, "FenwickTree no-op update does not invalidate enumerators");

ft.Clear();
Check(ft.Count == 6 && ft.Total == 0 && ft[0] == 0, "FenwickTree clear resets values, keeps length");

// A second T (and a larger tree) so the generic-math instantiation is exercised twice.
var wide = new FenwickTree<int>(1000);
for (int i = 0; i < 1000; i++) wide.Add(i, i);
Check(wide.Total == 499_500 && wide.PrefixSum(10) == 45, "FenwickTree int instantiation at scale");
}

// SmallDictionary — flat-array, linear-scan dictionary (default key inline, no
// hasher). Exercise the indexer, TryAdd/Add, TryGetValue, Remove, the swap-remove
// path, the inline default/zero key, and the struct enumerator.
Expand Down
Loading
Loading