defmodule BilldogEng.Murmur do
@moduledoc """
murmurhash3 (32-bit, x86, seed-able) — ported verbatim from the canonical web
reference `packages/ab-testing/src/ABTest.ts`.
This is the cross-platform bucketing primitive for local feature-flag
evaluation. The exact same algorithm runs on web / iOS / Android / every
server SDK so that `murmurhash3("{key}.{distinctId}") % 100` produces an
identical bucket everywhere — a user is deterministically in or out of a
rollout regardless of which platform evaluated the flag.
Implementation notes (must not change without re-pinning the vectors):
* UTF-8 bytes (Elixir binaries are already UTF-8)
* all arithmetic is masked to 32 bits (`&&& 0xFFFFFFFF`)
* the result is the unsigned 32-bit integer
"""
import Bitwise
@mask 0xFFFFFFFF
@c1 0xCC9E2D51
@c2 0x1B873593
@doc """
Compute the unsigned 32-bit murmurhash3 of `key` with the given `seed`
(default `0`).
"""
@spec hash(binary(), non_neg_integer()) :: non_neg_integer()
def hash(key, seed \\ 0) when is_binary(key) do
len = byte_size(key)
blocks = div(len, 4)
h1 = hash_blocks(key, blocks, 0, seed)
h1 = hash_tail(key, blocks * 4, rem(len, 4), h1)
h1
|> bxor(len)
|> fmix()
end
@doc """
Deterministic `0..99` bucket for a `"{key}.{distinctId}"` style seed.
Convenience wrapper around `hash/2`.
"""
@spec bucket_of(binary()) :: non_neg_integer()
def bucket_of(seed) when is_binary(seed) do
rem(hash(seed, 0), 100)
end
# ── Internals ──────────────────────────────────────────────────────────────
defp hash_blocks(_key, blocks, i, h1) when i >= blocks, do: h1
defp hash_blocks(key, blocks, i, h1) do
offset = i * 4
<<_::binary-size(^offset), b0, b1, b2, b3, _::binary>> = key
k1 =
b0
|> bor(b1 <<< 8)
|> bor(b2 <<< 16)
|> bor(b3 <<< 24)
k1 = mul32(k1, @c1)
k1 = rotl32(k1, 15)
k1 = mul32(k1, @c2)
h1 = bxor(h1, k1)
h1 = rotl32(h1, 13)
h1 = (mul32(h1, 5) + 0xE6546B64) &&& @mask
hash_blocks(key, blocks, i + 1, h1)
end
defp hash_tail(_key, _offset, 0, h1), do: h1
defp hash_tail(key, offset, remainder, h1) do
k1 = tail_k1(key, offset, remainder, 0)
k1 = mul32(k1, @c1)
k1 = rotl32(k1, 15)
k1 = mul32(k1, @c2)
bxor(h1, k1)
end
defp tail_k1(key, offset, 3, k1) do
<<_::binary-size(^offset), _b0, _b1, b2, _::binary>> = key
k1 = bxor(k1, b2 <<< 16)
tail_k1(key, offset, 2, k1)
end
defp tail_k1(key, offset, 2, k1) do
<<_::binary-size(^offset), _b0, b1, _::binary>> = key
k1 = bxor(k1, b1 <<< 8)
tail_k1(key, offset, 1, k1)
end
defp tail_k1(key, offset, 1, k1) do
<<_::binary-size(^offset), b0, _::binary>> = key
bxor(k1, b0)
end
# 32-bit unsigned multiply (mirrors Math.imul semantics for non-negative h1).
defp mul32(a, b), do: a * b &&& @mask
# Rotate-left within 32 bits.
defp rotl32(x, r) do
((x <<< r) ||| (x >>> (32 - r))) &&& @mask
end
# Final avalanche mix; returns the unsigned 32-bit result.
defp fmix(h) do
h = bxor(h, h >>> 16)
h = mul32(h, 0x85EBCA6B)
h = bxor(h, h >>> 13)
h = mul32(h, 0xC2B2AE35)
bxor(h, h >>> 16)
end
end