Hashing
The correctness linchpin. Weak/insufficiently-independent hashing is why existing JS libs measure worse FPR than theory. Getting this right is a differentiator.
Strategy
Derive all probe positions from a small hash by enhanced double hashing, not from N independent hash functions. One hash (murmur3_x86_128) serves every structure: Bloom and Blocked take two of its four 32-bit output words as a/b; Fuse takes its first 64-bit lane.
Kirsch-Mitzenmacher with the RocksDB correction:
g_i = (h1 + i*h2 + i*i) mod m for probe i = 0..k-1The + i*i term avoids the degenerate-independence flaw of plain h1 + i*h2. No asymptotic FPR loss vs independent hashes.
Kirsch and Mitzenmacher, “Less Hashing, Same Performance” (2006); RocksDB issue #4120.
Default hasher
One hash for every structure, pinned in the serialized header flags nibble as variant 0 (see serialization.md):
- murmur3_x86_128 (flags variant
0). PureMath.imul, one pass over the key, four 32-bit output words. No emulated 64-bit multiply, so it is fast on V8 for every structure. Bloom and Blocked read two words as the double-hasha/b; Fuse reads the first 64-bit lane (h1lo/h1hi) for its fingerprints, retrymixSeed, and Lemiremulhi64reduction.
A single pass of murmur3_x86_128 beats the previous two passes of murmur3_x86_32 for Bloom/Blocked (both words in one traversal) and replaces the emulated MurmurHash3_x64_128 for Fuse (pure Math.imul, no partial-product multiply), so the whole library shares one hash. Measured on M5: Fuse query about 2x faster, Bloom/Blocked lookups about 1.3x faster than the two-pass baseline.
Pure-JS, zero-dep, synchronous, universal. Chosen because AMQ keys are usually short (IDs, URLs, tokens) where pure-JS beats the WASM call-boundary + marshaling overhead. The 64-bit lane helpers (mul64/fmix64) live once in core/hasher.ts and are now used only by Fuse’s reduction, not the hash body.
- Carry 64-bit as two 32-bit lanes (hi/lo as Numbers). Never
BigIntin hot paths (heap-allocated, ~10x slower). hash128is allocation-free: flat number locals (no lane objects), inline little-endian byte reads (noDataView), results written to a reused module-scope struct. Zero per-call heap allocation beyond the returned lanes, so steady-state hashing adds no GC pressure. Pure 32-bitMath.imularithmetic, so no emulated 64-bit multiply in the hash body.- All lane math unsigned:
>>> 0. This is the #1 source of JS hash-port bugs. - Hash once, reuse across all probes. (Incumbent recomputes per op; see bloom-filters issue #60.)
- String keys are hashed via
hash128Key, whichTextEncoder.encodeIntos the key into a reused, grow-on-demand buffer and calls the length-awarehash128(buf, seed, written), so steady-state string hashing allocates nothing (no per-opUint8Array). The bytes hashed are the same UTF-8 asencode(), so serialized filters stay cross-language readable; portability is unchanged. - Hot paths allocate nothing per call. Bloom/Blocked call
probeInto/hash32x2Into, which encode the key once into the reused buffer and run one murmur3_x86_128 pass, reading two of its output words. Fuse callshash128KeyInto(key, seed, out), which writes lanes into a caller-owned struct.hash128/hash128Keykeep returning fresh objects for external/test use.
Pluggable
interface Hasher { hash64( key: Uint8Array, seed: number, ): { h1: number; h2: number; hi1: number; hi2: number };}Users may inject their own. Ship xxhash-wasm as an optional fast path for throughput-bound Node/Bun over large keys. Never the default: WebAssembly.compile is blocked on some edge runtimes, the exact portability distillate sells.
Reduction to range
Lemire multiply-shift ((h * m) >>> shift) instead of modulo. For m > 2^32 this needs 64-bit-lane math (another reason to keep the lane representation).
Portability
The hash variant is pinned in the serialized header flags nibble (0 = murmur3_x86_128), so a reader knows which hash produced the bits. A filter is only cross-language-readable if both sides hash identically; murmur3_x86_128 is fully specified and trivially reimplementable in Rust/Go. A reader rejects a frame whose version or variant it does not implement (UnknownVersionError/UnknownHashVariantError) rather than misread it. The caveat runs one way: a newer reader rejects an older frame, but an older reader predates the check and would silently misread a newer frame, so a consumer must be at least as new as the producer.