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

### Added

- `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: partially done — `UInt32Hasher`, `UInt64Hasher`, and `GuidHasher` `fixed in 1.1.0`; `Int32Murmur3Hasher` and `Int64WangHasher` still outstanding.
- **#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) — `UInt32Hasher`, `UInt64Hasher`, and `GuidHasher` `done`; `Int32Murmur3Hasher` and `Int64WangHasher` still `planned`.
- 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()));
}
}
64 changes: 64 additions & 0 deletions src/Celerity/Hashing/GuidHasher.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
using System.Runtime.CompilerServices;

namespace Celerity.Hashing;

/// <summary>
/// A high-quality hash provider for <see cref="Guid"/> keys.
/// </summary>
/// <remarks>
/// <para>
/// Reinterprets the 128-bit <see cref="Guid"/> as two 64-bit halves, applies the
/// Murmur3 <c>fmix64</c> finalizer to each half, XORs the mixed halves, and
/// truncates to 32 bits. Each half passes through an independent avalanche, so
/// every input bit influences the final hash even when the input Guids share a
/// common prefix (e.g. a database-generated sequence that clusters in the low
/// bytes, or the fixed version/variant nibbles of a V4 Guid).
/// </para>
/// <para>
/// The reinterpret is performed via <see cref="Unsafe.As{TFrom, TTo}(ref TFrom)"/>
/// against the by-value <c>key</c> local, so there is no copy, no allocation,
/// and no stack buffer. On architectures where <see cref="Guid"/>'s 4-byte
/// alignment differs from <see cref="ulong"/>'s 8-byte alignment the resulting
/// reads are unaligned, which is well-defined on x86/x64 and ARM64.
/// </para>
/// <para>
/// Prefer <see cref="GuidHasher"/> over <see cref="DefaultHasher{T}"/> for
/// <see cref="Guid"/> keys on hot paths: it is fully inlineable and avoids the
/// virtual dispatch through <see cref="EqualityComparer{T}.Default"/>.
/// </para>
/// </remarks>
public struct GuidHasher : IHashProvider<Guid>
{
private const ulong C1 = 0xff51afd7ed558ccdUL;
private const ulong C2 = 0xc4ceb9fe1a85ec53UL;

/// <inheritdoc/>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public int Hash(Guid key)
{
// Reinterpret the 16-byte Guid as two ulongs. `key` is a by-value local,
// so `ref key` is safe to take and outlives the reinterpret.
ref ulong p = ref Unsafe.As<Guid, ulong>(ref key);
ulong lo = p;
ulong hi = Unsafe.Add(ref p, 1);

// Mix each half independently so every input bit reaches every output
// bit of its half before we combine.
lo = Fmix64(lo);
hi = Fmix64(hi);

// Combine and truncate to 32 bits.
return (int)(lo ^ hi);
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static ulong Fmix64(ulong x)
{
x ^= x >> 33;
x *= C1;
x ^= x >> 33;
x *= C2;
x ^= x >> 33;
return x;
}
}
Loading