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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ All notable changes to Celerity are documented here. This project follows [Keep

### Added

- **`ISpanHashProvider`** in `Celerity.Hashing` — a `Hash(ReadOnlySpan<char>)` contract returning exactly what `Hash(string)` returns for the same characters. All 23 built-in `String*Hasher` types implement it. Closes [#311](https://github.com/marius-bughiu/Celerity/issues/311).
- **Span-keyed lookups** — `TryGetValue` / `ContainsKey` / `Contains` now take a `ReadOnlySpan<char>` on `FrozenCelerityDictionary`, `FrozenCeleritySet`, `CelerityDictionary<string, …>`, `CeleritySet<string, …>` and `Trie<TValue>`, so a caller holding a slice of a buffer no longer allocates a `string` per probe. Results match the `string` overloads; an empty span means `""`, never the `null` key. Additive on all three target frameworks. Closes [#311](https://github.com/marius-bughiu/Celerity/issues/311).
- **`StringInternTable` / `StringInternTable<THasher>`** in `Celerity.Collections` — canonicalizes tokens probed as spans: `GetOrAdd` returns the shared `string` and allocates only on a miss, so a parse over few distinct tokens allocates per token rather than per occurrence. Implements `IReadOnlyCollection<string>`; not thread-safe. Closes [#311](https://github.com/marius-bughiu/Celerity/issues/311).
- Test coverage for the above: `SpanHashParityTests` (the `Hash(s) == Hash(s.AsSpan())` contract, per hasher), `SpanLookupTests`, `StringInternTableTests`, `StringInternTableDifferentialTests`, a `Celerity.Fuzz` target, and Native AOT smoke-test coverage. Closes [#311](https://github.com/marius-bughiu/Celerity/issues/311).
- `StringInternTableBenchmark` in the CI-tracked suite, plus `SpanLookup` rows on the frozen-dictionary and trie benchmarks, with the matching dashboard cards. Closes [#311](https://github.com/marius-bughiu/Celerity/issues/311).
- Docs for the new surface: `ISpanHashProvider`, span-keyed lookups and `StringInternTable` in the API reference, and matching README entries. Closes [#311](https://github.com/marius-bughiu/Celerity/issues/311).
- **`StringKeyProbeBenchmark`** — the tracked suite's first `string`-keyed dictionary and set rows (lookup hit, lookup miss, set `Contains`), shown as the **String-keyed probe** card on the benchmark dashboard. Closes [#308](https://github.com/marius-bughiu/Celerity/issues/308).
- **`ReferenceKeyProbeTests`** — cross-collection coverage for vacant-slot detection on the open-addressed collections with reference-type keys. Closes [#308](https://github.com/marius-bughiu/Celerity/issues/308).
- **`BTreeDictionary<TKey, TValue, TComparer>` and `BTreeSet<T, TComparer>`** (with `BTreeDictionary<TKey, TValue>` / `BTreeSet<T>` aliases and the `DefaultComparer<T>` struct comparer) in `Celerity.Collections` — the library's first sorted map and set, and the B-tree the BCL lacks. Up to 31 keys per node keep a lookup `log₃₂(n)` node visits deep instead of chasing the `log₂(n)` pointers a red-black tree costs, and both add the ordered surface a hash table cannot answer: `Min` / `Max`, lower / upper bound, `EnumerateRange` in `O(log n + k)`, and in-order enumeration. They win on the interleaved insert + lookup + range-scan workload and on memory, and lose slightly on a delete-dominated one. Not thread-safe. Closes [#305](https://github.com/marius-bughiu/Celerity/issues/305).
Expand Down
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@ 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?>`.

**Span-keyed string lookups**

- `StringInternTable` / `StringInternTable<THasher>` — a **canonicalizing token table** probed with a `ReadOnlySpan<char>`: `GetOrAdd` returns the one shared `string` for those characters and allocates **only on a miss**. A 10M-cell parse over 100 distinct tokens creates 100 strings instead of 10,000,000. The collection you cannot build on the pre-.NET-9 BCL — `HashSet<string>.TryGetValue` makes you allocate the string *before* you can discover you already had it, and `string.Intern` is process-wide, never collected, and still needs a `string`. Implements `IReadOnlyCollection<string>`.
- The same span-keyed probes ship on `FrozenCelerityDictionary`, `FrozenCeleritySet`, `CelerityDictionary<string, …>`, `CeleritySet<string, …>`, and `Trie<TValue>` — so a tokenizer, CSV/log reader, or route dispatcher holding a slice of its input buffer never has to call `new string(span)` per lookup. Works on all three target frameworks, including the `net8.0` floor where the BCL has no equivalent (.NET 9 added `Dictionary<string,V>.GetAlternateLookup`). See [span-keyed lookups](docs/api/collections.md#span-keyed-lookups).

**Sorted (ordered) collections**

- `BTreeDictionary<TKey, TValue, TComparer>` / `BTreeDictionary<TKey, TValue>` — a **sorted map backed by a B-tree**: up to **31 keys per node** in flat arrays, so a lookup visits `log₃₂(n)` nodes instead of chasing `log₂(n)` pointers — roughly **4 cache misses instead of ~20 at `n = 1M`**. Adds the ordered surface a hash table cannot answer: `Min`, `Max`, `TryGetLowerBound` / `TryGetUpperBound`, `EnumerateRange` in `O(log n + k)`, and in-order enumeration. The B-tree the BCL lacks — `SortedDictionary<,>` is a red-black tree with one heap object per entry, and `SortedList<,>` memmoves the tail on every insert. Implements `IDictionary<TKey, TValue?>` and `IReadOnlyDictionary<TKey, TValue?>`.
Expand Down Expand Up @@ -580,6 +585,8 @@ 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. |
| **Many occurrences of few distinct strings**, produced as slices of a buffer you already hold — CSV / log / JSON parsing, tokenizers, column stores, header dispatch: you want one canonical `string` per distinct token, not one per occurrence | `StringInternTable` | Probed with a `ReadOnlySpan<char>`: `GetOrAdd` returns the shared instance and **allocates only on a miss**, so a 10M-cell parse over 100 distinct tokens creates 100 strings, not 10,000,000. `HashSet<string>` cannot express this before .NET 9 — its `TryGetValue` takes a `string`, so you must allocate the string *before* you can discover you already had it. `string.Intern` is process-wide, never collected, and still needs a `string`; this table's lifetime is yours and `Clear` releases it. On .NET 9+ `Dictionary<string,V>.GetAlternateLookup` is comparable; this works on `net8.0` too. Not thread-safe. |
| **Look a string key up from a `ReadOnlySpan<char>`** you already hold (route dispatch, header lookup, parse-then-map) without allocating a `string` per probe | span overloads on `FrozenCelerityDictionary` / `FrozenCeleritySet` / `CelerityDictionary<string, …>` / `CeleritySet<string, …>` / `Trie<TValue>` | `TryGetValue(ReadOnlySpan<char>, …)` / `ContainsKey` / `Contains` probe the table directly, deleting the `new string(span)` allocation and copy per lookup. Available whenever the hasher implements `ISpanHashProvider` — every built-in `String*Hasher` does. Same results as the `string` overloads (ordinal comparison); an empty span means `""`, never the `null` key. See [span-keyed lookups](docs/api/collections.md#span-keyed-lookups). |
| **Sorted keys** — you need the entries in comparer order, or the ordered questions a hash table cannot answer: smallest / largest key, "first key at or after *x*", "every key in `[a, b)`" (time-series by timestamp, order books, LSM-style memtables, sweep-line events, interval endpoints) | `BTreeDictionary<TKey, TValue>` / `BTreeSet<T>` | B-tree with up to 31 keys per node in flat arrays: a lookup visits `log₃₂(n)` nodes instead of chasing `log₂(n)` pointers (~4 cache misses instead of ~20 at `n = 1M`), an in-order walk streams contiguous arrays rather than successor pointers, and allocation is one node per 31 entries instead of one object per entry. The BCL has no B-tree: `SortedDictionary<,>` / `SortedSet<>` are red-black trees, `SortedList<,>` is `O(n)` per middle insert, and `OrderedDictionary<,>` (.NET 9) is *insertion*-ordered, not sorted. Wins on the **interleaved insert + lookup + range-scan** load; for a few dozen entries a `SortedList<,>` is hard to beat, and if you never need order a hash table answers in `O(1)`. |
| **Prefix / range sums over a sequence you keep mutating** — running aggregates, rank / order-statistics counters (inversions, "how many ≤ x seen"), cumulative-frequency tables | `FenwickTree<T>` | Binary Indexed Tree (`T : INumber<T>`): **point update** and **prefix / range sum** both `O(log n)`, in one array with no per-node overhead. The BCL has no prefix-sum structure; a plain array forces `O(n)` per query (recompute the slice) *or* `O(n)` per update (fix the suffix). Wins precisely when updates and partial-sum queries interleave. If the data is immutable after build, a one-shot precomputed prefix-sum array answers in `O(1)` with less code; if you only update and never query a partial sum, a raw array is simpler. |
| Need a stable iteration order or multi-threaded access | `BTreeDictionary<,>` / `BTreeSet<>` for sorted order, `Trie<TValue>` for ordered string keys; BCL `ConcurrentDictionary<,>` for concurrency | Celerity is single-threaded, and the **hash-based** collections leave iteration order unspecified. The ordered collections do promise order by contract: the B-trees iterate in comparer order, `Trie<TValue>` in ascending ordinal key order. |
Expand All @@ -605,6 +612,8 @@ The value of a struct hasher is **distribution quality (avalanche), determinism,

> **Fixed-seed hashers are not a HashDoS defence.** `string.GetHashCode()` is already a purpose-built **Marvin32** with per-process random seeding; a hardcoded-seed Murmur3 / FNV / xxHash is *not* more flood-resistant — usually **less**, because an attacker who knows the fixed algorithm and seed can precompute colliding keys offline. What stops hash-flooding is a **keyed** PRF with a *secret, per-process-random* key, not merely picking a "stronger" fixed hash. For untrusted `string` keys, the BCL `string.GetHashCode()` (`DefaultHasher<string>`) is the safe default; reach for the keyed SipHash / HighwayHash hashers only when you also supply a secret seed. The fixed-seed hashers' real strength is **reproducibility** (same code across processes and runtimes), which `GetHashCode()` deliberately does not give you.

> **Probing from a `ReadOnlySpan<char>`? Any `String*Hasher` will do.** All 23 implement **`ISpanHashProvider`** — a `Hash(ReadOnlySpan<char>)` overload that returns exactly what `Hash(string)` returns for the same characters — which is what unlocks the [span-keyed lookups](docs/api/collections.md#span-keyed-lookups) and `StringInternTable`. The two overloads share one body, so they cannot drift; a custom string hasher only needs to implement the interface to work the same way. See [`ISpanHashProvider`](docs/api/hashing.md#ispanhashprovider).

The hashing library also ships classic / compatibility hashes (djb2, sdbm, ELF/PJW, CRC-32, Adler-32, FNV-1, MurmurHash2, CityHash, MetroHash, xxHash32/64) for matching an external system's key distribution — see [`docs/api/hashing.md`](docs/api/hashing.md) for the complete list, costs, and avalanche notes, and use `HashQualityEvaluator` (below) to compare candidates on your own keys.

## Benchmarks
Expand Down Expand Up @@ -731,7 +740,7 @@ Celerity is **Native AOT and trimming compatible** — no reflection, runtime co

## API at a glance

The dictionaries mirror the parts of `Dictionary<TKey, TValue>` most callers reach for: indexer get/set, `ContainsKey`, `TryGetValue`, `Add`, `TryAdd`, `Remove` (both overloads), `Clear`, `EnsureCapacity` / `TrimExcess`, `Count`, `Keys`, `Values`, `GetEnumerator()`. They implement `IReadOnlyDictionary<TKey, TValue?>` and accept an `IEnumerable<KeyValuePair<TKey, TValue>>` at construction. The sets expose `Add`, `TryAdd`, `Contains`, `Remove`, `Clear`, `EnsureCapacity` / `TrimExcess`, `Count`, and a struct enumerator. `EnsureCapacity(n)` pre-grows the table once for a known-size bulk insert (no incremental rehashes); `TrimExcess()` rehashes back down to fit `Count`. The zero / `default(TKey)` key (or element) is stored out-of-band so it never collides with the empty-slot sentinel.
The dictionaries mirror the parts of `Dictionary<TKey, TValue>` most callers reach for: indexer get/set, `ContainsKey`, `TryGetValue`, `Add`, `TryAdd`, `Remove` (both overloads), `Clear`, `EnsureCapacity` / `TrimExcess`, `Count`, `Keys`, `Values`, `GetEnumerator()`. The string-keyed types additionally take a `ReadOnlySpan<char>` on `TryGetValue` / `ContainsKey` / `Contains`, so a caller holding a slice of a buffer never allocates a `string` to probe. They implement `IReadOnlyDictionary<TKey, TValue?>` and accept an `IEnumerable<KeyValuePair<TKey, TValue>>` at construction. The sets expose `Add`, `TryAdd`, `Contains`, `Remove`, `Clear`, `EnsureCapacity` / `TrimExcess`, `Count`, and a struct enumerator. `EnsureCapacity(n)` pre-grows the table once for a known-size bulk insert (no incremental rehashes); `TrimExcess()` rehashes back down to fit `Count`. The zero / `default(TKey)` key (or element) is stored out-of-band so it never collides with the empty-slot sentinel.

Full constructors, signatures, exceptions, and per-type examples: **[API reference](docs/README.md)**.

Expand Down
Loading
Loading