defmodule Bloomex do @moduledoc """ This module implements a [Scalable Bloom Filter](http://haslab.uminho.pt/cbm/files/dbloom.pdf). ## Examples iex> bf = Bloomex.scalable(1000, 0.1, 0.1, 2) %Bloomex.ScalableBloom... iex> bf = Bloomex.add(bf, 5) %Bloomex.ScalableBloom... iex> Bloomex.member?(bf, 5) true iex> bf = Bloomex.add(bf, 100) %Bloomex.ScalableBloom... iex> Bloomex.member?(bf, 100) true iex> Bloomex.member?(bf, 105) false iex> Bloomex.member?(bf, 101) # false positive true """ @type t :: Bloomex.Bloom.t() | Bloomex.ScalableBloom.t() @type mode :: :bits | :size @type hash_func :: (term -> pos_integer) use Bitwise defmodule Bloom do @moduledoc """ A plain bloom filter. * :error_prob - error probability * :max - maximum number of elements * :mb - 2^mb = m, the size of each slice (bitvector) * :size - number of elements * :bv - list of bitvectors * :hash_func - hash function to use """ defstruct [ :error_prob, :max, :mb, :size, :bv, :hash_func ] @type t :: %Bloom{ error_prob: number, max: integer, mb: integer, size: integer, bv: [Bloomex.BitArray.t()], hash_func: Bloomex.hash_func() } end defmodule ScalableBloom do @moduledoc """ A scalable bloom filter. * :error_prob - error probability * :error_prob_ratio - error probability ratio * :growth - log 2 of growth ratio * :size - number of elements * :b - list of plain bloom filters * :hash_func - hash function to use """ defstruct [ :error_prob, :error_prob_ratio, :growth, :size, :b, :hash_func ] @type t :: %ScalableBloom{ error_prob: number, error_prob_ratio: number, growth: integer, size: integer, b: [Bloomex.Bloom.t()], hash_func: Bloomex.hash_func() } end @doc """ Returns a scalable Bloom filter based on the provided arguments: * `capacity`, the initial capacity before expanding * `error`, the error probability * `error_ratio`, the error probability ratio * `growth`, the growth ratio when full * `hash_func`, a hashing function If a hash function is not provided then `:erlang.phash2/2` will be used with the maximum range possible `(2^32)`. Restrictions: * `capacity` must be a positive integer * `error` must be a float between `0` and `1` * `error_ratio` must be a float between `0` and `1` * `growth` must be a positive integer between `1` and `3` * `hash_func` must be a function of type `term -> pos_integer` The function follows a rule of thumb due to double hashing where `capacity >= 4 / (error * (1 - error_ratio))` must hold true. """ @spec scalable(integer, number, number, 1 | 2 | 3, hash_func()) :: ScalableBloom.t() def scalable( capacity, error, error_ratio, growth, hash_func \\ fn x -> :erlang.phash2(x, 1 <<< 32) end ) when capacity > 0 and error > 0 and error < 1 and growth in [1, 2, 3] and error_ratio > 0 and error_ratio < 1 and capacity >= 4 / (error * (1 - error_ratio)) do %ScalableBloom{ error_prob: error, error_prob_ratio: error_ratio, growth: growth, size: 0, b: [plain(capacity, error * (1 - error_ratio), hash_func)], hash_func: hash_func } end @doc """ Returns a plain Bloom filter based on the provided arguments: * `capacity`, used to calculate the size of each bitvector slice * `error`, the error probability * `hash_func`, a hashing function If a hash function is not provided then `:erlang.phash2/2` will be used with the maximum range possible `(2^32)`. Restrictions: * `capacity` must be a positive integer * `error` must be a float between `0` and `1` * `hash_func` must be a function of type `term -> pos_integer` The function follows a rule of thumb due to double hashing where `capacity >= 4 / error` must hold true. """ @spec plain(integer, float, hash_func()) :: Bloom.t() def plain(capacity, error, hash_func \\ fn x -> :erlang.phash2(x, 1 <<< 32) end) when is_number(error) and capacity > 0 and is_float(error) and error > 0 and error < 1 and capacity >= 4 / error do plain(:size, capacity, error, hash_func) end @spec plain(mode(), integer, number, hash_func()) :: Bloom.t() defp plain(mode, capacity, e, hash_func) do k = 1 + trunc(log2(1 / e)) p = :math.pow(e, 1 / k) mb = case mode do :size -> 1 + trunc(-log2(1 - :math.pow(1 - p, 1 / capacity))) :bits -> capacity end m = 1 <<< mb n = trunc(:math.log(1 - p) / :math.log(1 - 1 / m)) %Bloom{ error_prob: e, max: n, mb: mb, size: 0, bv: for(_ <- 1..k, do: Bloomex.BitArray.new(1 <<< mb)), hash_func: hash_func } end @doc """ Returns the number of elements currently in the bloom filter. """ @spec size(t) :: pos_integer def size(%Bloom{size: size}), do: size def size(%ScalableBloom{size: size}), do: size @doc """ Returns the capacity of the bloom filter. A plain bloom filter will always have a fixed capacity, while a scalable one will always have a theoretically infite capacity. """ @spec capacity(Bloomex.t()) :: pos_integer | :infinity def capacity(%Bloom{max: n}), do: n def capacity(%ScalableBloom{}), do: :infinity @doc """ Returns `true` if the element `e` exists in the bloom filter, otherwise returns `false`. Keep in mind that you may get false positives, but never false negatives. """ @spec member?(Bloomex.t(), any) :: boolean def member?(%Bloom{mb: mb, hash_func: hash_func} = bloom, e) do hashes = make_hashes(mb, e, hash_func) hash_member(hashes, bloom) end def member?(%ScalableBloom{b: [%Bloom{mb: mb, hash_func: hash_func} | _]} = bloom, e) do hashes = make_hashes(mb, e, hash_func) hash_member(hashes, bloom) end defimpl Jason.Encoder, for: Tuple do def encode(data, options) when is_tuple(data) do data |> Tuple.to_list() |> Jason.Encoder.List.encode(options) end end defdelegate serialize(bloom), to: __MODULE__, as: :serialise @spec serialise(Bloomex.t()) :: binary def serialise(%ScalableBloom{b: b} = bloom) do b = Enum.map(b, fn bloom -> bloom |> Map.from_struct() |> Map.delete(:hash_func) end) bloom |> Map.from_struct() |> Map.delete(:hash_func) |> Map.put(:b, b) |> Jason.encode!() end def serialise(%Bloom{} = bloom) do bloom |> Map.from_struct() |> Map.delete(:hash_func) |> Jason.encode!() end defdelegate deserialize(bloom, func \\ fn x -> :erlang.phash2(x, 1 <<< 32) end), to: __MODULE__, as: :deserialise @spec deserialise( binary | maybe_improper_list( binary | maybe_improper_list(any, binary | []) | byte, binary | [] ), any ) :: Bloomex.t() def deserialise(bloom, func \\ fn x -> :erlang.phash2(x, 1 <<< 32) end) do bloom |> Jason.decode!() |> _deserialise(func) end def _deserialise( %{ "b" => b, "error_prob" => error_prob, "error_prob_ratio" => error_prob_ratio, "growth" => growth, "size" => size }, func ) do b = Enum.map(b, fn bloom -> _deserialise(bloom, func) end) %Bloomex.ScalableBloom{ b: b, error_prob: error_prob, error_prob_ratio: error_prob_ratio, growth: growth, size: size, hash_func: func } end def _deserialise( %{"bv" => bv, "error_prob" => error_prob, "max" => max, "mb" => mb, "size" => size}, func ) do bv = Enum.map(bv, fn e -> e |> get_tuple() end) %Bloomex.Bloom{ bv: bv, error_prob: error_prob, max: max, mb: mb, size: size, hash_func: func } end defp get_tuple(element) when is_list(element) do element |> Enum.map(&get_tuple/1) |> List.to_tuple() end defp get_tuple("array"), do: :array defp get_tuple(element), do: element @spec hash_member(pos_integer, Bloomex.t()) :: boolean defp hash_member(hashes, %Bloom{mb: mb, bv: bv}) do mask = (1 <<< mb) - 1 {i1, i0} = make_indexes(mask, hashes) all_set(mask, i1, i0, bv) end defp hash_member(hashes, %ScalableBloom{b: b}) do Enum.any?(b, &hash_member(hashes, &1)) end @spec make_hashes(pos_integer, any, hash_func()) :: pos_integer | {pos_integer, pos_integer} defp make_hashes(mb, e, hash_func) when mb <= 16 do hash_func.({e}) end defp make_hashes(mb, e, hash_func) when mb <= 32 do {hash_func.({e}), hash_func.([e])} end @spec make_indexes(pos_integer, {pos_integer, pos_integer}) :: {pos_integer, pos_integer} defp make_indexes(mask, {h0, h1}) when mask > 1 <<< 16 do masked_pair(mask, h0, h1) end defp make_indexes(mask, {h0, _}) do make_indexes(mask, h0) end @spec make_indexes(pos_integer, pos_integer) :: {pos_integer, pos_integer} defp make_indexes(mask, h0) do masked_pair(mask, h0 >>> 16, h0) end @spec masked_pair(pos_integer, pos_integer, pos_integer) :: {pos_integer, pos_integer} defp masked_pair(mask, x, y), do: {x &&& mask, y &&& mask} @spec all_set(pos_integer, pos_integer, pos_integer, [Bloomex.BitArray.t()]) :: boolean defp all_set(_, _, _, []), do: true defp all_set(mask, i1, i, [h | t]) do if Bloomex.BitArray.get(h, i) do all_set(mask, i1, i + i1 &&& mask, t) else false end end @doc """ Returns a bloom filter with the element `e` added. """ @spec add(Bloomex.t(), any) :: Bloomex.t() def add(%Bloom{mb: mb, hash_func: hash_func} = bloom, e) do hashes = make_hashes(mb, e, hash_func) hash_add(hashes, bloom) end def add(%ScalableBloom{error_prob_ratio: r, size: size, growth: g, b: [h | t] = bs} = bloom, e) do %Bloom{mb: mb, error_prob: err, max: n, size: head_size, hash_func: hash_func} = h hashes = make_hashes(mb, e, hash_func) if hash_member(hashes, bloom) do bloom else if head_size < n do %{bloom | size: size + 1, b: [hash_add(hashes, h) | t]} else b = :bits |> plain(mb + g, err * r, hash_func) |> add(e) %{bloom | size: size + 1, b: [b | bs]} end end end @spec hash_add(pos_integer, Bloom.t()) :: Bloom.t() defp hash_add(hashes, %Bloom{mb: mb, bv: bv, size: size} = b) do mask = (1 <<< mb) - 1 {i1, i0} = make_indexes(mask, hashes) if all_set(mask, i1, i0, bv) do b else %{b | size: size + 1, bv: set_bits(mask, i1, i0, bv, [])} end end @spec set_bits( pos_integer, pos_integer, pos_integer, [Bloomex.BitArray.t()], [Bloomex.BitArray.t()] ) :: [Bloomex.BitArray.t()] defp set_bits(_, _, _, [], acc), do: Enum.reverse(acc) defp set_bits(mask, i1, i, [h | t], acc) do set_bits(mask, i1, i + i1 &&& mask, t, [Bloomex.BitArray.set(h, i) | acc]) end @spec log2(float) :: float defp log2(x) do :math.log(x) / :math.log(2) end end