Skip to content

Commit 360c1d2

Browse files
Merge pull request #170 from marius-bughiu/feat/issue-22-frozen-celerity-set
feat(collections): add FrozenCeleritySet build-once string set (#22)
2 parents f2926e9 + 7b45c06 commit 360c1d2

15 files changed

Lines changed: 1381 additions & 6 deletions

File tree

CHANGELOG.md

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

77
### Added
88

9+
- `FrozenCeleritySet` / `FrozenCeleritySet<THasher>` in `Celerity.Collections` — a build-once, read-many set of `string` elements, the **set counterpart of `FrozenCelerityDictionary`** and the second half of the 1.2.0 frozen-collections work ([#22](https://github.com/marius-bughiu/Celerity/issues/22)). Like the frozen dictionary it is immutable (constructed once from an `IEnumerable<string>`, no mutating members) and at construction searches a small parameter space — candidate power-of-two table sizes × a per-build mixing seed — for a **perfect** (collision-free) placement of the elements; when found (`IsPerfectlyHashed == true`) a `Contains` is a single hash, a single array index, and a single equality check, with no probing. When two distinct elements collide on the chosen hasher's raw 32-bit code (e.g. `"A"` / `"Ł"` under the low-byte `StringFnV1AHasher`), a perfect placement is impossible, so the build falls back to an open-addressed linear-probing table (`IsPerfectlyHashed == false`); membership tests stay correct (the equality check disambiguates), just not single-probe. The one contract difference from the frozen *dictionary* is intentional and idiomatic for a set: **duplicate elements are silently deduplicated** (matching BCL `FrozenSet<T>` and the mutable `CeleritySet`) rather than throwing. Read API: `Contains`, `Count`, `IsPerfectlyHashed`, an allocation-free struct `Enumerator` (the out-of-band `null` element, if present, is yielded first), and the full `IReadOnlySet<string>` surface (`SetEquals`, `IsSubsetOf`, `IsProperSubsetOf`, `IsSupersetOf`, `IsProperSupersetOf`, `Overlaps` — the superset/overlap shapes stream `other` against the `O(1)` membership test, the subset/equality shapes materialize `other`'s distinct elements once into an ordinal set, and each throws `ArgumentNullException` on a `null` `other`). The `null` element is stored out-of-band (the hasher is never invoked with `null`) and the empty string is an ordinary element. The convenience `FrozenCeleritySet` defaults to `StringFnV1AHasher`; the `<THasher>` overload accepts any string hasher. AOT-safe (no reflection); hot-path membership is allocation-free.
10+
- `FrozenCeleritySetTests` — a dedicated suite mirroring `FrozenCelerityDictionaryTests` for the set (construction & round-trip, present/absent `Contains`, empty-string-as-regular-element, out-of-band `null`-element round-trip and absence, `null`-source rejection, **duplicate / duplicate-`null` silent dedupe** (the set-vs-dictionary contract difference), empty source, the perfect fast path asserted via `IsPerfectlyHashed` on a strong hasher, the **fallback correctness** case where base-hash-colliding `"A"` / `"Ł"` under `StringFnV1AHasher` force linear probing yet stay distinct, absent-elements-hashing-into-occupied-slots miss, a 1000-element build-and-round-trip sweep with 1000 absent-element misses, enumeration including the `null` element, the `IReadOnlySet` surface, **the full set-algebra matrix** (`SetEquals` / `IsSubsetOf` / `IsProperSubsetOf` / `IsSupersetOf` / `IsProperSupersetOf` / `Overlaps`, including duplicates-in-`other`, the `null` element, and `null`-`other` throwing), the custom-hasher overload, the default convenience type, and the non-generic `IEnumerable` / `Reset` paths).
11+
- A `FrozenCeleritySet` parity arm added to the differential testing layer: a CsCheck model property test (`CollectionModelPropertyTests.FrozenCeleritySet_ShouldMatch_BclHashSet`, building from a duplicate-rich source and reconciling distinct membership, absent-element misses, full-enumeration round-trip, and set-algebra against a `HashSet<string>` oracle) and a `Celerity.Fuzz` target (`FrozenCeleritySet`, registered in `Differential.All`) that fuzzes a duplicate-rich source against the BCL oracle and cross-checks count, per-element `Contains`, duplicate-free enumeration, and set-algebra.
12+
- Cross-collection shared tests extended to cover `FrozenCeleritySet` in `SetIEnumerableConstructorTests` (the mirror of the dictionary `IEnumerableConstructorTests`): null-source `ArgumentNullException`, empty source, array / non-collection-enumerable copy, duplicate and duplicate-`null` dedupe, the out-of-band `null` element, large-source fidelity, source independence, the `<THasher>` open-generic overload, and a frozen-set-from-frozen-set round-trip. The hash-table-mutation shared files genuinely do not apply and are intentionally not extended: `SetConstructorValidationTests` (no `capacity` / `loadFactor` — the perfect-hash build sizes the table itself), `TryAddDuplicateResizeTests` / `TryAddProbeCountTests` (immutable, no `TryAdd`), and the dictionary-only shared files.
13+
- `FrozenCeleritySetBenchmark` in `Celerity.Benchmarks`, mirroring `FrozenCelerityDictionaryBenchmark` — a `Build` + `Contains` comparison of `FrozenCeleritySet` against the BCL `System.Collections.Frozen.FrozenSet<string>` baseline (the true build-once counterpart; a mutable `HashSet<>` does no build-time hashing optimization, so it is not a like-for-like baseline for a frozen, perfect-hashed set), over the same `[Params(1000, 100_000)]` identifier-shaped distinct element sets. Registered in `Celerity.Benchmarks/Program.cs`'s core `--ci` `CoreBenchmarks` array so it joins the `RunAllJoined` CI report and the gh-pages dashboard.
14+
- Benchmark dashboard wiring for `FrozenCeleritySet`: a "What ships in the box" ship card in `web/index.html`, and a `COLLECTIONS` entry (key / title / vs / ops `['Build', 'Contains']`) in both `web/dev/bench/index.html` and `web/dev/bench/detail.html`. `FrozenSet` was added to each dashboard's `BCL_TYPES` set so the parser treats the benchmark's `FrozenSet_*` methods as the baseline series (the comparison is Celerity-frozen vs BCL-frozen rather than vs the mutable `HashSet<>`).
15+
- Documentation for `FrozenCeleritySet`: a full section in `docs/api/collections.md` (both the convenience and `<THasher>` overloads: the perfect-hash design and the set-vs-dictionary dedupe contract, constructor, `Count` / `IsPerfectlyHashed`, `Contains`, the `IReadOnlySet` set-algebra surface, null-element handling, the perfect fast path vs the linear-probing fallback, and runnable examples), and README updates — the Collections list, the "Choosing a collection" decision table, and a new "build-once string membership" Quick start subsection. The `Celerity.AotSmokeTest` now constructs `FrozenCeleritySet` (default, with the out-of-band `null` element and the `IReadOnlySet` surface), `FrozenCeleritySet<StringMurmur3Hasher>`, and the base-hash-collision fallback (`StringFnV1AHasher` over `"A"` / `"Ł"`) so the Native AOT publish job compiles the new generic instantiations and exercises both lookup paths.
916
- `RobinHoodDictionary<TKey, TValue, THasher>` in `Celerity.Collections` — a drop-in peer of `CelerityDictionary` that resolves collisions with **Robin Hood** open addressing instead of plain linear probing, delivering the 1.2.0 Robin Hood probing experiment ([#63](https://github.com/marius-bughiu/Celerity/issues/63)). Every occupied slot stores its probe sequence length (PSL — distance from the ideal slot) in a parallel `int[]`; on insert an incoming key that has travelled further than the resident displaces it ("robs from the rich") and the evicted entry is re-inserted further along, which bounds probe-length variance so worst-case lookups stay close to the average on clustered / adversarial key distributions. The stored PSL also lets a *negative* lookup terminate early — once the probe distance exceeds the resident slot's PSL the key cannot be present. Deletion is Robin Hood backward-shift (pull each follower back one slot, decrementing its PSL, until an empty or already-home slot). The public surface is byte-for-byte identical to `CelerityDictionary`: indexer get/set, `ContainsKey`, `ContainsValue`, `TryGetValue`, `Add`, `TryAdd`, `Remove` (both `bool Remove(key)` and `bool Remove(key, out TValue?)`), `Clear`, `Count`, allocation-free struct `Keys` / `Values` views and `Enumerator` (with `_version` mutation detection), `IReadOnlyDictionary<TKey, TValue?>`, and both constructors (`(capacity, loadFactor)` and `(IEnumerable<KeyValuePair<TKey, TValue>>, capacity, loadFactor)` with the same power-of-two sizing, load-factor-headroom bulk sizing, validation, and out-of-band `default(TKey)` handling). The insert path computes the key hash once and threads it through both the existence probe and the displacement insertion, so a new-key `Add` / `TryAdd` / indexer-set costs exactly one `Hash()` call (displaced residents are never re-hashed). The cost versus `CelerityDictionary` is the per-slot PSL `int` (more allocation) and a little extra insert work for the swaps; on uniform keys with a good hasher it is a wash or a slight loss, so it is documented as the pick for the clustered / adversarial case rather than a default. AOT-safe; hot-path lookup is allocation-free.
1017
- `RobinHoodDictionaryTests`, `RobinHoodDictionaryCollisionTests`, and `RobinHoodDictionaryEnumerationTests` — dedicated suites mirroring the `CelerityDictionary*` files. The functional tests cover insert/retrieve/overwrite, `ContainsKey`, `Remove`, resize, the out-of-band `0` / `Guid.Empty` default key surviving resize, `TryGetValue` hit/miss, `Clear` (including the already-empty no-op), and `Add` / `TryAdd` duplicate semantics; the collision suite adds full-collision insert/overwrite/remove/resize chains, the Robin Hood **displacement** path (a longer-travelled key robbing a richer resident while every key stays reachable), early-terminating negative lookups on clustered keys, `null`-string-key round-trips, and a **differential fuzz** (`RandomizedOps_ShouldMatchBclDictionary`, 5000 random Set/Remove/Lookup ops over a deliberately tiny key universe across four seeds) reconciled against a `Dictionary<int, int>` oracle to stress the swap / backward-shift machinery; the enumeration suite mirrors the full `Keys` / `Values` / `Enumerator` surface including default-key yield, mid-enumeration mutation detection, and the boxed generic / non-generic `IEnumerable` paths. A `RobinHoodDictionary` arm was also added to the differential testing layer: a CsCheck model property test (`CollectionModelPropertyTests.RobinHoodDictionary_ShouldMatch_BclDictionary`) and a `Celerity.Fuzz` target (`RobinHoodDictionary`, registered in `Differential.All`), both reconciled against a `Dictionary<int, int>` oracle.
1118
- Cross-collection shared tests extended to cover `RobinHoodDictionary` alongside `CelerityDictionary` in all thirteen shared files: `AddAndTryAddTests`, `ConstructorValidationTests`, `IEnumerableConstructorTests`, `IEnumerableConstructorNullPriorityTests`, `ReadOnlyDictionaryInterfaceTests`, `RemoveOutValueTests`, `ContainsValueTests`, `LoadFactorBoundaryTests`, `IndexerReturnTypeTests`, `IndexerOverwriteResizeTests` (overwrite at threshold does not resize), `TryAddDuplicateResizeTests` (duplicate `TryAdd` at threshold keeps an active enumerator valid; a new-key `TryAdd` invalidates it), `TryAddProbeCountTests` (a new-key / duplicate `TryAdd` does exactly one `Hash()` call — the single-hash insert path), and `BulkConstructorNoResizeTests` (a bulk construct from a known-`Count` source costs exactly N hashes with no resize).

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Celerity is a .NET library that provides specialized high-performance collection
1313
- `IntDictionary<TValue>` / `IntDictionary<TValue, THasher>``int`-keyed specialization. Defaults to `Int32WangNaiveHasher`.
1414
- `LongDictionary<TValue>` / `LongDictionary<TValue, THasher>``long`-keyed specialization. Defaults to `Int64WangNaiveHasher`.
1515
- `CeleritySet<T, THasher>` — generic set counterpart to `CelerityDictionary`.
16+
- `FrozenCeleritySet` / `FrozenCeleritySet<THasher>` — build-once, read-many `string` set that searches for a perfect (collision-free) hash so membership tests are single-probe. The set counterpart of `FrozenCelerityDictionary`; implements `IReadOnlySet<string>`. Defaults to `StringFnV1AHasher`.
1617
- `IntSet` / `IntSet<THasher>``int`-keyed set specialization.
1718
- `LongSet` / `LongSet<THasher>``long`-keyed set specialization. Defaults to `Int64WangNaiveHasher`.
1819

@@ -176,6 +177,33 @@ var visitedIds = new CeleritySet<Guid, GuidHasher>();
176177
visitedIds.TryAdd(Guid.NewGuid()); // returns true on first add, false on duplicate
177178
```
178179

180+
### `FrozenCeleritySet` — build-once string membership
181+
182+
When a `string`-keyed membership set is built once and then read many times (reserved-word
183+
tables, stop-word lists, allow/deny lists), `FrozenCeleritySet` searches at construction for a
184+
perfect (collision-free) hash so each `Contains` is a single hash, a single array index, and a
185+
single equality check. It is the set counterpart of `FrozenCelerityDictionary`.
186+
187+
```csharp
188+
using Celerity.Collections;
189+
190+
var reserved = new FrozenCeleritySet(new[]
191+
{
192+
"select", "from", "where", "join", "group", "order",
193+
});
194+
195+
Console.WriteLine(reserved.IsPerfectlyHashed); // True (single-probe membership)
196+
Console.WriteLine(reserved.Contains("join")); // True
197+
Console.WriteLine(reserved.Contains("celerity")); // False
198+
```
199+
200+
It is immutable (no `Add` / `Remove`) and implements `IReadOnlySet<string>` (so `SetEquals`,
201+
`IsSubsetOf`, `Overlaps`, … are all available). Duplicate elements are silently deduplicated, as
202+
a set should. The default uses `StringFnV1AHasher`; supply a full-width or strong hasher via
203+
`FrozenCeleritySet<THasher>` (e.g. `StringFnV1AFullHasher` for non-ASCII elements) when you want
204+
the single-probe fast path for elements the default would collide. Membership tests stay correct
205+
regardless — colliding elements fall back to a short probe.
206+
179207
### Construct from an existing collection
180208

181209
The dictionaries accept any `IEnumerable<KeyValuePair<TKey, TValue>>`. When the source implements `ICollection<T>`, its `Count` is used to pre-size the backing storage so the bulk fill avoids resize work.
@@ -214,6 +242,7 @@ Celerity ships specialised types because each one buys a different tradeoff. Use
214242
| Set of `int` values | `IntSet` | Same fast path as `IntDictionary`, membership only. |
215243
| Set of `long` values | `LongSet` | 64-bit equivalent of `IntSet`; defaults to `Int64WangNaiveHasher`. |
216244
| Set of any other type | `CeleritySet<T, THasher>` | Same hasher choice as `CelerityDictionary`. |
245+
| Build-once, read-many membership set keyed by `string` | `FrozenCeleritySet` | Immutable; searches for a perfect (collision-free) hash at build time so `Contains` is single-probe. The set counterpart of `FrozenCelerityDictionary`; implements `IReadOnlySet<string>`. Tune the hasher via the `<THasher>` overload. |
217246
| Need a stable iteration order or multi-threaded access | BCL `Dictionary<,>`, `ConcurrentDictionary<,>` | Celerity is single-threaded and iteration order is unspecified. |
218247

219248
Notes on picking a hasher once the collection is settled:

ROADMAP.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ Focus on raw performance and specialized collection types that serve more advanc
7272
### Collections
7373

7474
- `FrozenCelerityDictionary` — build-once, read-many variant with perfect hashing for string keys, comparable in spirit to `System.Collections.Frozen` but tunable via `IHashProvider<T>`. Status: `done``FrozenCelerityDictionary<TValue>` / `<TValue, THasher>` search for a collision-free single-probe layout at construction and fall back to linear probing when the chosen hasher collides two keys' raw codes, so lookups are always correct. Tracked in [#62](https://github.com/marius-bughiu/Celerity/issues/62).
75+
- Frozen collections family — the set counterpart `FrozenCeleritySet` / `FrozenCeleritySet<THasher>` completes the build-once read-many family (`FrozenCelerityDictionary``FrozenCeleritySet`), sharing the same perfect-hash-with-linear-probing-fallback build and implementing `IReadOnlySet<string>`. Status: `done`. Tracked in [#22](https://github.com/marius-bughiu/Celerity/issues/22).
7576
- `CelerityMultiMap<TKey, TValue, THasher>` — multi-value dictionary. Status: `done` — a one-to-many map that reuses `CelerityDictionary`'s open-addressed key table and stores a `List<TValue?>` value group per key; `Add` appends rather than overwrites, `Remove(key, value)` / `RemoveAll(key)` are the two removal shapes, the indexer returns an empty group for an absent key, and the type implements `ILookup<TKey, TValue?>`. Tracked in [#18](https://github.com/marius-bughiu/Celerity/issues/18).
7677
- `SmallDictionary<TKey, TValue>` — flat-array implementation optimized for `n <= ~16`. Status: `done``SmallDictionary<TKey, TValue>` linear-scans insertion-dense parallel arrays with `EqualityComparer<TKey>.Default` (no hasher, so the default key is stored inline rather than out-of-band), trading `O(1)` for `O(n)` to win at small `n`; it implements `IReadOnlyDictionary<TKey, TValue?>` with the full dictionary surface. Tracked in [#61](https://github.com/marius-bughiu/Celerity/issues/61).
7778

0 commit comments

Comments
 (0)