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
# 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