Algorithm Search⌘K

Scalable Bloom filter in Python

Chain of Bloom filters added as the set grows, keeping the false positive rate bounded.

File
scalable_bloom_filter.py, 88 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
scalable_bloom_filter.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/scalable-bloom-filter/. The authors are listed in prov/Q55J7F.jsonl.

"""Scalable Bloom filter (Almeida, Baquero, Preguica and Hutchison 2007)."""

import hashlib
import math

Key = str | bytes | int


class Slice:
    """A partitioned Bloom filter: k parts of b bits, one hash function per part."""

    def __init__(self, k: int, b: int) -> None:
        self.k = k
        self.b = b
        self.bits = [bytearray(b) for _ in range(k)]
        self.count = 0
        # the expected fill of each part stays at most 1/2 up to this count
        self.capacity = max(1, math.floor((b - 1) * math.log(2)))


class ScalableBloomFilter:
    """A list of partitioned Bloom filters with growing size and tightening rate.

    Slice i has b0 * s^i bits in each of its k_i = ceil(log2(1 / (eps0 * r^i)))
    parts. Only the last slice takes new keys; a new slice is added when the
    last one is at capacity. The false positive rate is at most eps0 / (1 - r)
    at every size, under ideal hashing.
    """

    def __init__(
        self, b0: int = 64, eps0: float = 0.005, r: float = 0.9, s: int = 2, seed: int = 0
    ) -> None:
        if b0 < 2 or not 0 < eps0 < 1 or not 0 < r < 1 or s < 1:
            raise ValueError("need b0 >= 2, 0 < eps0 < 1, 0 < r < 1 and s >= 1")
        self.b0 = b0
        self.eps0 = eps0
        self.r = r
        self.s = s
        self.seed = seed
        self.slices: list[Slice] = []  # @line L1

    def _positions(self, key: Key, i: int) -> list[int]:
        """The k_i positions of key in slice i, one in each part."""
        sl = self.slices[i]
        out: list[int] = []
        t = 0
        while len(out) < sl.k:  # independent 64-bit words, eight per digest
            d = hashlib.blake2b(
                repr(key).encode(),
                key=self.seed.to_bytes(8, "little"),
                salt=(i << 32 | t).to_bytes(16, "little"),
            ).digest()
            out.extend(int.from_bytes(d[w : w + 8], "little") % sl.b for w in range(0, 64, 8))
            t += 1
        return out[: sl.k]

    def _add_slice(self) -> None:
        i = len(self.slices)
        k = max(1, math.ceil(math.log2(1 / (self.eps0 * self.r**i)) - 1e-9))  # @line L2
        self.slices.append(Slice(k, self.b0 * self.s**i))  # @line L3

    def insert(self, key: Key) -> None:
        """Add key to the last slice, after adding a slice if the last one is full."""
        if not self.slices or self.slices[-1].count >= self.slices[-1].capacity:  # @line L4
            self._add_slice()
        last = len(self.slices) - 1
        sl = self.slices[last]
        for j, p in enumerate(self._positions(key, last)):  # @line L5
            sl.bits[j][p] = 1
        sl.count += 1  # @line L6

    def query(self, key: Key) -> bool:
        """False if key is surely absent; True if it may be present."""
        for i, sl in enumerate(self.slices):  # @line L7
            if all(sl.bits[j][p] for j, p in enumerate(self._positions(key, i))):  # @line L8
                return True
        return False  # @line L9

    def rate_bound(self) -> float:
        """The bound eps0 * (1 + r + ... + r^(l-1)) for the current l slices."""
        return sum(self.eps0 * self.r**i for i in range(len(self.slices)))

    def bits(self) -> int:
        """The total number of bits of all slices."""
        return sum(sl.k * sl.b for sl in self.slices)
test_scalable_bloom_filter.py 85 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/scalable-bloom-filter/. The authors are listed in prov/Q55J7F.jsonl.

import math
import random

import pytest

from scalable_bloom_filter import Key, ScalableBloomFilter

TABLE: dict[Key, dict[int, list[int]]] = {
    "a": {0: [1, 2]},
    "b": {0: [1, 3]},
    "c": {0: [2, 0], 1: [0, 5, 2]},
    "d": {0: [3, 1], 1: [3, 5, 7]},
    "e": {0: [0, 0], 1: [6, 1, 2]},
    "x": {0: [1, 0], 1: [3, 5, 2]},
    "y": {0: [0, 2], 1: [0, 1, 1]},
}


class FixedSBF(ScalableBloomFilter):
    def __init__(self) -> None:
        super().__init__(b0=4, eps0=0.25, r=0.5, s=2)

    def _positions(self, key: Key, i: int) -> list[int]:
        return TABLE[key][i]


def test_example() -> None:
    f = FixedSBF()
    f.insert("a")
    f.insert("b")
    assert len(f.slices) == 1 and f.slices[0].k == 2 and f.slices[0].capacity == 2
    assert [list(p) for p in f.slices[0].bits] == [[0, 1, 0, 0], [0, 0, 1, 1]]
    for key in "cde":
        f.insert(key)
    assert len(f.slices) == 2
    s1 = f.slices[1]
    assert (s1.k, s1.b, s1.capacity, s1.count) == (3, 8, 4, 3)
    assert [[j for j in range(8) if p[j]] for p in s1.bits] == [[0, 3, 6], [1, 5], [2, 7]]
    assert all(f.query(key) for key in "abcde")
    assert f.query("x")  # a false positive in slice 1
    assert not f.query("y")
    assert f.rate_bound() == 0.375


def test_edges() -> None:
    f = ScalableBloomFilter(b0=2, eps0=0.5, r=0.5)
    assert not f.query(1) and f.slices == []
    f.insert(1)
    assert f.query(1) and len(f.slices) == 1 and f.slices[0].capacity == 1
    for bad in [dict(b0=1), dict(eps0=1.0), dict(r=1.0), dict(s=0)]:
        with pytest.raises(ValueError):
            ScalableBloomFilter(**bad)


def test_random_against_set() -> None:
    rng = random.Random(2007)
    for _ in range(500):
        b0 = rng.randint(2, 20)
        eps0 = rng.choice([0.5, 0.2, 0.1, 0.01])
        r = rng.choice([0.5, 0.8, 0.9])
        s = rng.randint(1, 3)
        f = ScalableBloomFilter(b0, eps0, r, s, seed=rng.randrange(1000))
        stored: list[int] = []
        for _ in range(rng.randint(0, 80)):
            x = rng.randrange(200)
            f.insert(x)
            stored.append(x)
        assert all(f.query(x) for x in stored)
        for i, sl in enumerate(f.slices):
            assert sl.b == b0 * s**i
            assert sl.k == max(1, math.ceil(math.log2(1 / (eps0 * r**i)) - 1e-9))
            assert 1 <= sl.count <= sl.capacity
        assert all(sl.count == sl.capacity for sl in f.slices[:-1])


def test_rate_stays_bounded() -> None:
    f = ScalableBloomFilter(b0=32, eps0=0.02, r=0.5, seed=3)
    for x in range(3000):
        f.insert(x)
    assert len(f.slices) >= 5
    fp = sum(f.query(x) for x in range(10**6, 10**6 + 20000))
    assert fp / 20000 < 0.04  # the bound is 0.02 / (1 - 0.5)

What the next level needs

  • the tests did not run in this build

Read the full page about Scalable Bloom filter