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 except Top-K (below), pinned together with how each structure maps it to positions as flags 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 go through
encodeKey(src/core/bytes.ts), which writes the key’s UTF-8 into a reused, grow-on-demand buffer, so steady-state string hashing allocates nothing (no per-opUint8Array). The bytes are the same UTF-8 asencode()on every path, so serialized filters stay cross-language readable. - Strings of up to 12 chars that are all ASCII are copied with
charCodeAt, since an ASCII char’s UTF-8 byte is its code. The first char above 0x7F hands the whole string toTextEncoder.encodeInto, as does any string over 12 chars. For a short key the native call cost as much as the hash: 18.7 ns to encode against 21.4 ns to hash 8 chars on an M5. - Why 12: the copy beat
encodeIntoup to 12 chars and lost from 13 or 14 on both CPUs measured (Apple M5, Intel i5-1235U), by up to 25% at 4 chars. V8 stores a concatenation of 13+ chars as a rope, wherecharCodeAtcosts about twice as much, so 13 is also where keys built by+or template literals turn against the copy. Strings of 12 or fewer are never ropes. - Top-K shares the buffer:
addandcounthash from it and copy a key only when storing it. - 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.
Keyed slot hash (Top-K)
Top-K places keys with HalfSipHash-1-3 (src/topk/halfsip.ts) under 64 random bits per sketch, not murmur.
- Why: murmur3 has multicollisions that hold for every seed (Aumasson and Bernstein 2012; Ahle for x64_128), so a secret seed cannot stop crafted keys from sharing a slot. Keys brute-forced to one murmur slot made linear-probing
add174x slower at capacity 32,768. Top-K is the structure most often fed untrusted keys (IPs, URLs, user agents). - Possible only for Top-K: its frame stores entries in canonical order, so no stored byte depends on slot placement. The key never reaches the frame, bytes stay deterministic, and Top-K has no
seed. - Cost: HalfSipHash runs at 72 to 78% of murmur’s rate on 8 to 64 byte keys; the Zipf
addrate is unchanged within noise. - The key is drawn on the first hash, not in the constructor: Cloudflare Workers refuse
crypto.getRandomValuesat module scope. The only randomness in the package. - Verified against the Linux kernel’s
test_vectors_hsiphash(HalfSipHash-1-3 build).
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 flags variant tells a reader which scheme produced the stored bits, and it pins the hash and the index mapping: which output words a structure reads, the probe scheme, the reduction to range, and each structure’s bit layout (the per-structure list is in serialization.md). Any change to one of those takes a new variant even when the hash is unchanged, because the stored bits change while variant 0 would still claim to describe them. Guava hit exactly this: MURMUR128_MITZ_32 and MURMUR128_MITZ_64 share a hash and still needed a new Strategy ordinal. 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.