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

- `Int32Murmur3Hasher` in `Celerity.Hashing` — Murmur3 32-bit finalizer ("fmix32") for `int` keys. Struct hasher, `AggressiveInlining`. Provides excellent avalanche properties; prefer over `Int32WangNaiveHasher` when key distribution is clustered or adversarial. Maps `0 → 0` (fixed point of fmix32).
- `Int64WangHasher` in `Celerity.Hashing` — Thomas Wang 64-bit integer hash for `long` keys. Struct hasher, `AggressiveInlining`. Faster than `Int64Murmur3Hasher` while providing better avalanche than a simple XOR-fold; prefer when throughput matters more than adversarial collision resistance. Invertible (bijective on `ulong`) so truncation to 32 bits is the only source of collisions.
- `Int32Murmur3HasherTests` — exact anchor values for key extremes, determinism, high-bit avalanche check, 1000-value distinctness sweep, and integration tests driving `CelerityDictionary` and `CeleritySet` including the `default(int)` out-of-band slot.
- `Int64WangHasherTests` — exact anchor values for key extremes, determinism, high-bit avalanche check, 1000-value distinctness sweep, and integration tests driving `CelerityDictionary` and `CeleritySet` including the `default(long)` out-of-band slot.
- `GuidHasher` in `Celerity.Hashing` — reinterprets the 128-bit `Guid` as two 64-bit halves, runs Murmur3 `fmix64` on each, and XORs the mixed halves. Struct hasher, `AggressiveInlining`, zero-allocation (no stack buffer — reinterpret via `Unsafe.As<Guid, ulong>`). Prefer over `DefaultHasher<Guid>` on hot paths: fully inlineable and avoids the `EqualityComparer<T>.Default` virtual dispatch.
- `GuidHasherTests` — `Guid.Empty → 0` anchor, determinism across calls and struct instances, avalanche on both the low and high 64-bit halves, shared-prefix/shared-suffix divergence (guards against hashers that weight one half too heavily), two 1000-value distinctness sweeps (sequential low-half keys and `Guid.NewGuid()`), and integration tests confirming `GuidHasher` satisfies the hasher constraint on `CeleritySet<Guid,THasher>` and `CelerityDictionary<Guid,TValue,THasher>` (including the `Guid.Empty` out-of-band slot).
- `UInt32Hasher` in `Celerity.Hashing` — Wang/Jenkins-style bit-mixer for `uint` keys. Struct hasher, `AggressiveInlining`. Counterpart to `Int32WangNaiveHasher`.
- `UInt64Hasher` in `Celerity.Hashing` — Murmur3 `fmix64` finalizer for `ulong` keys. Struct hasher, `AggressiveInlining`. Counterpart to `Int64Murmur3Hasher`.
- `UInt32HasherTests` and `UInt64HasherTests` — exact-value cases (including values crossing the sign bit), determinism, avalanche on the top bit, and a 1000-value distinctness sweep for the 64-bit mixer.
Expand Down
2 changes: 1 addition & 1 deletion ISSUES.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ Both constructors now throw `ArgumentOutOfRangeException` for `capacity < 0`, `l
- **#9** — Implement `IReadOnlyDictionary<TKey, TValue>` (0.3.0). Requires `Keys`, `Values`, and `GetEnumerator()` first.
- **#10** — Add `Keys` / `Values` / `GetEnumerator()` (0.3.0). Next item to tackle.
- **#11** — Add `Add` / `TryAdd` with duplicate-throwing semantics (0.3.0). Status: `fixed in 0.3.0`.
- **#12** — `Int32Murmur3Hasher`, `Int64WangHasher`, `GuidHasher`, `UInt32Hasher`, `UInt64Hasher` (0.4.0).
- **#12** — `Int32Murmur3Hasher`, `Int64WangHasher`, `GuidHasher`, `UInt32Hasher`, `UInt64Hasher` (0.4.0). Status: `fixed in 1.1.0` — `UInt32Hasher`, `UInt64Hasher`, `GuidHasher`, `Int32Murmur3Hasher`, and `Int64WangHasher` all complete.
- **#13** — `DefaultHasher<T>` fallback to `EqualityComparer<T>.Default.GetHashCode()`. Status: `fixed in 1.1.0`.
- **#14** — Expanded benchmark suite: uniform vs clustered vs adversarial key distributions (0.4.0).
- **#15** — `CeleritySet<T, THasher>` and `IntSet`. Status: `fixed in 1.1.0`.
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ The next release rounds out the `Celerity.Collections` package with missing coll

### Hashers

- Add `Int32Murmur3Hasher`, `Int64WangHasher`, `GuidHasher`, `UInt32Hasher`, `UInt64Hasher`. (#24) — `UInt32Hasher` and `UInt64Hasher` `done`; the others still `planned`.
- Add `Int32Murmur3Hasher`, `Int64WangHasher`, `GuidHasher`, `UInt32Hasher`, `UInt64Hasher`. (#24) — all `done`.
- Add `DefaultHasher<T>` fallback to `EqualityComparer<T>.Default.GetHashCode()`.

### Infrastructure
Expand Down
201 changes: 201 additions & 0 deletions src/Celerity.Tests/Hashing/GuidHasherTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
using Celerity.Collections;
using Celerity.Hashing;

namespace Celerity.Tests.Hashing;

public class GuidHasherTests
{
private readonly GuidHasher _hasher = new GuidHasher();

// ── Exact-value anchors ───────────────────────────────────────────────────

[Fact]
public void Hash_Empty_ReturnsZero()
{
// Guid.Empty is all zero. Both 64-bit halves are 0, Murmur3 fmix64(0) is 0,
// and 0 ^ 0 is 0. This also pins down the "reinterpret two halves, mix,
// XOR, truncate" pipeline: any regression that, say, hashed a non-zero
// seed in would break this test first.
Assert.Equal(0, _hasher.Hash(Guid.Empty));
}

// ── Determinism ───────────────────────────────────────────────────────────

[Fact]
public void Hash_IsDeterministic_AcrossCalls()
{
Guid key = new Guid("12345678-1234-1234-1234-1234567890AB");
int a = _hasher.Hash(key);
int b = _hasher.Hash(key);
Assert.Equal(a, b);
}

[Fact]
public void Hash_IsDeterministic_AcrossInstances()
{
// Hashers are structs with no state, so two independently-constructed
// instances must produce identical output for the same input.
Guid key = new Guid("DEADBEEF-CAFE-BABE-F00D-123456789ABC");
int a = new GuidHasher().Hash(key);
int b = new GuidHasher().Hash(key);
Assert.Equal(a, b);
}

// ── Avalanche ─────────────────────────────────────────────────────────────

[Fact]
public void Hash_LowHalfBits_InfluenceResult()
{
// Flip a single bit in the low half of the Guid; the hash must change.
// Guards against a regression that only mixes the high half.
var a = Guid.Empty;
var b = new Guid(new byte[]
{
0x01, 0x00, 0x00, 0x00, // first 4 bytes (_a)
0x00, 0x00, // _b
0x00, 0x00, // _c
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
});

Assert.NotEqual(_hasher.Hash(a), _hasher.Hash(b));
}

[Fact]
public void Hash_HighHalfBits_InfluenceResult()
{
// Flip a single bit in the high half of the Guid; the hash must change.
// Guards against a regression that only mixes the low half.
var a = Guid.Empty;
var b = new Guid(new byte[]
{
0x00, 0x00, 0x00, 0x00,
0x00, 0x00,
0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, // last byte
});

Assert.NotEqual(_hasher.Hash(a), _hasher.Hash(b));
}

[Fact]
public void Hash_SharedPrefix_DivergesAcrossGuids()
{
// Database-generated Guids frequently share a long prefix and differ
// only in the tail. A hasher that weights the prefix too heavily would
// bunch these into a few buckets. This test catches that by asserting
// two prefix-sharing Guids hash differently.
var a = new Guid("AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAA0");
var b = new Guid("AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAA1");
Assert.NotEqual(_hasher.Hash(a), _hasher.Hash(b));
}

[Fact]
public void Hash_SharedSuffix_DivergesAcrossGuids()
{
// Mirror of the prefix test: two Guids that differ only in their leading
// bytes should still produce distinct hashes.
var a = new Guid("00000000-0000-0000-AAAA-AAAAAAAAAAAA");
var b = new Guid("00000001-0000-0000-AAAA-AAAAAAAAAAAA");
Assert.NotEqual(_hasher.Hash(a), _hasher.Hash(b));
}

// ── Distinctness sweep ────────────────────────────────────────────────────

[Fact]
public void Hash_DistinctInputs_ProduceDistinctResultsForSmallRange()
{
// 1000 sequential Guids (constructed from a monotonically increasing
// low-half value) must produce 1000 distinct hashes. Each half passes
// through Murmur3 fmix64 (a bijection on 64 bits) before truncation,
// so a collision in this range would indicate the mixer is broken.
var seen = new HashSet<int>();
for (int i = 0; i < 1000; i++)
{
var bytes = new byte[16];
// Write i into the first 4 bytes; leaves the remaining 12 bytes as zero.
bytes[0] = (byte)(i & 0xFF);
bytes[1] = (byte)((i >> 8) & 0xFF);
bytes[2] = (byte)((i >> 16) & 0xFF);
bytes[3] = (byte)((i >> 24) & 0xFF);

var guid = new Guid(bytes);
Assert.True(seen.Add(_hasher.Hash(guid)),
$"Unexpected collision at iteration {i}.");
}
}

[Fact]
public void Hash_DistinctInputs_ProduceDistinctResultsForNewGuid()
{
// Second sweep driven by Guid.NewGuid(): exercises the high-entropy
// end of the input space rather than the low-value sequential end.
var seen = new HashSet<int>();
for (int i = 0; i < 1000; i++)
{
Assert.True(seen.Add(_hasher.Hash(Guid.NewGuid())),
$"Unexpected collision at iteration {i}.");
}
}

// ── Does not throw ───────────────────────────────────────────────────────

[Fact]
public void Hash_DoesNotThrow()
{
Guid[] testValues =
{
Guid.Empty,
Guid.NewGuid(),
new Guid("FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF"),
new Guid("80000000-0000-0000-0000-000000000000"),
new Guid("00000000-0000-0000-0000-000000000001"),
};

foreach (Guid val in testValues)
{
var exception = Record.Exception(() => _hasher.Hash(val));
Assert.Null(exception);
}
}

// ── Integration: satisfies the hasher constraint on collections ──────────

[Fact]
public void GuidHasher_CanDriveCeleritySet()
{
var set = new CeleritySet<Guid, GuidHasher>();

var a = Guid.NewGuid();
var b = Guid.NewGuid();
var empty = Guid.Empty; // default(Guid) — stored out-of-band

set.Add(a);
set.Add(b);
set.Add(empty);

Assert.Equal(3, set.Count);
Assert.True(set.Contains(a));
Assert.True(set.Contains(b));
Assert.True(set.Contains(empty));
Assert.False(set.Contains(Guid.NewGuid()));
}

[Fact]
public void GuidHasher_CanDriveCelerityDictionary()
{
var dict = new CelerityDictionary<Guid, string, GuidHasher>();

var key1 = Guid.NewGuid();
var key2 = Guid.NewGuid();
dict[key1] = "one";
dict[key2] = "two";
dict[Guid.Empty] = "zero"; // default(Guid) — out-of-band slot

Assert.Equal(3, dict.Count);
Assert.Equal("one", dict[key1]);
Assert.Equal("two", dict[key2]);
Assert.Equal("zero", dict[Guid.Empty]);
Assert.True(dict.ContainsKey(key1));
Assert.False(dict.ContainsKey(Guid.NewGuid()));
}
}
129 changes: 129 additions & 0 deletions src/Celerity.Tests/Hashing/Int32Murmur3HasherTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
using Celerity.Collections;
using Celerity.Hashing;

namespace Celerity.Tests.Hashing;

public class Int32Murmur3HasherTests
{
private readonly Int32Murmur3Hasher _hasher = new Int32Murmur3Hasher();

// ── Exact-value anchors ───────────────────────────────────────────────────

[Theory]
[InlineData(0, 0)] // fmix32 maps 0 → 0 (identity fixed-point)
[InlineData(1, 1364076727)]
[InlineData(-1, -2114883783)]
[InlineData(42, 142593372)]
[InlineData(16, 1428509628)]
[InlineData(65536, 245581154)]
[InlineData(int.MaxValue, -104067416)]
[InlineData(int.MinValue, 1832674720)]
public void Hash_ReturnsExpected(int input, int expected)
{
Assert.Equal(expected, _hasher.Hash(input));
}

// ── Determinism ───────────────────────────────────────────────────────────

[Fact]
public void Hash_IsDeterministic_AcrossCalls()
{
int value = 12345;
int result1 = _hasher.Hash(value);
int result2 = _hasher.Hash(value);
Assert.Equal(result1, result2);
}

[Fact]
public void Hash_IsDeterministic_AcrossInstances()
{
// Hashers are structs with no state; two independently-constructed
// instances must produce identical output for the same input.
int value = -987654321;
int a = new Int32Murmur3Hasher().Hash(value);
int b = new Int32Murmur3Hasher().Hash(value);
Assert.Equal(a, b);
}

// ── Avalanche ─────────────────────────────────────────────────────────────

[Fact]
public void Hash_HighBits_InfluenceResult()
{
// Flip a single bit in the upper half; the hash must change.
// Guards against a regression where fmix32 stops mixing high bits.
int low = _hasher.Hash(1);
int high = _hasher.Hash(1 | (1 << 24));
Assert.NotEqual(low, high);
}

[Fact]
public void Hash_ConsecutiveInputs_ProduceDistinctResults()
{
// fmix32 is a bijection on uint32; no two distinct 32-bit inputs can
// produce the same 32-bit output. Consecutive small integers should all
// hash to distinct values.
var seen = new HashSet<int>();
for (int i = 0; i < 1000; i++)
{
Assert.True(seen.Add(_hasher.Hash(i)),
$"Unexpected collision at input {i}.");
}
}

// ── Does not throw ────────────────────────────────────────────────────────

[Fact]
public void Hash_DoesNotThrow()
{
int[] testValues =
{
0, 1, -1, int.MaxValue, int.MinValue, 123456789, -987654321
};

foreach (int val in testValues)
{
var ex = Record.Exception(() => _hasher.Hash(val));
Assert.Null(ex);
}
}

// ── Integration: satisfies the hasher constraint on collections ──────────

[Fact]
public void Int32Murmur3Hasher_CanDriveCelerityDictionary()
{
var dict = new CelerityDictionary<int, string, Int32Murmur3Hasher>();

dict[0] = "zero"; // default(int) — out-of-band slot
dict[1] = "one";
dict[-1] = "neg-one";
dict[42] = "forty-two";

Assert.Equal(4, dict.Count);
Assert.Equal("zero", dict[0]);
Assert.Equal("one", dict[1]);
Assert.Equal("neg-one", dict[-1]);
Assert.Equal("forty-two",dict[42]);
Assert.True(dict.ContainsKey(0));
Assert.False(dict.ContainsKey(999));
}

[Fact]
public void Int32Murmur3Hasher_CanDriveCeleritySet()
{
var set = new CeleritySet<int, Int32Murmur3Hasher>();

set.Add(0); // default(int) — out-of-band slot
set.Add(1);
set.Add(-1);
set.Add(42);

Assert.Equal(4, set.Count);
Assert.True(set.Contains(0));
Assert.True(set.Contains(1));
Assert.True(set.Contains(-1));
Assert.True(set.Contains(42));
Assert.False(set.Contains(999));
}
}
Loading
Loading