Algorithm Search⌘K

Binary fuse filter in Python

Static filter improving on xor filters with a segmented, faster-to-build layout.

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

"""Binary fuse filter with three hash functions (Graf and Lemire 2022)."""

import hashlib
import math
from collections import deque
from collections.abc import Iterable

Key = str | bytes | int


def fuse_size(n: int) -> tuple[int, int]:
    """The segment length L (a power of 2) and the number of start segments for n keys."""
    n = max(n, 2)
    seg_len = min(1 << math.floor(math.log(n) / math.log(3.33) + 2.25), 1 << 18)
    factor = max(1.125, 0.875 + 0.25 * math.log(1e6) / math.log(n))
    capacity = round(n * factor)
    count = max(1, -(-capacity // seg_len) - 2)
    return seg_len, count


class BinaryFuseFilter:
    """A static filter whose table has (count + 2) segments of L cells of r bits.

    Key x picks a start segment s < count and one cell in each of the segments
    s, s + 1 and s + 2; the table satisfies B[c0] ^ B[c1] ^ B[c2] = fp(x) for
    each x in S. A key not in S is accepted with probability 2^-r.
    """

    def __init__(
        self,
        keys: Iterable[Key],
        r: int = 8,
        seed: int = 0,
        seg_len: int | None = None,
        seg_count: int | None = None,
    ) -> None:
        if r < 1:
            raise ValueError("r must be positive")
        self.r = r
        ks = list(dict.fromkeys(keys))  # distinct keys
        self.n = len(ks)
        L, c = fuse_size(self.n)  # @line L1
        self.L = seg_len or L
        self.count = seg_count or c
        self.seed = seed
        while True:  # @line L2: new hash functions until peeling succeeds
            order = self._peel(ks)
            if order is not None:
                break
            self.seed += 1
        self.B = [0] * ((self.count + 2) * self.L)  # @line L3
        for x, cell in reversed(order):  # @line L4
            cells, fp = self._hash(x)
            acc = fp
            for h in cells:
                if h != cell:
                    acc ^= self.B[h]
            self.B[cell] = acc  # @line L5

    def _hash(self, key: Key) -> tuple[tuple[int, int, int], int]:
        """Cells in three consecutive segments, and an r-bit fingerprint."""
        d = hashlib.blake2b(repr(key).encode(), digest_size=40, key=self.seed.to_bytes(8, "little")).digest()
        s, a, b, c, f = (int.from_bytes(d[i : i + 8], "little") for i in range(0, 40, 8))
        L = self.L
        base = (s % self.count) * L
        return (base + a % L, base + L + b % L, base + 2 * L + c % L), f % (1 << self.r)  # @line L6

    def _peel(self, keys: list[Key]) -> list[tuple[Key, int]] | None:
        """Pairs (x, c) in peeling order: c is a cell of x and of no later key."""
        size = (self.count + 2) * self.L
        cnt, xor = [0] * size, [0] * size
        cells = [self._hash(x)[0] for x in keys]
        for i, cs in enumerate(cells):  # @line L7
            for c in cs:
                cnt[c] += 1
                xor[c] ^= i
        queue = deque(c for c in range(size) if cnt[c] == 1)  # @line L8
        order: list[tuple[Key, int]] = []
        while queue:  # @line L9
            c = queue.popleft()
            if cnt[c] != 1:
                continue
            i = xor[c]
            order.append((keys[i], c))
            for h in cells[i]:  # @line L10
                cnt[h] -= 1
                xor[h] ^= i
                if cnt[h] == 1:
                    queue.append(h)
        return order if len(order) == len(keys) else None  # @line L11

    def query(self, key: Key) -> bool:
        """False if key is surely not in S; True if it may be."""
        (a, b, c), fp = self._hash(key)
        return fp == self.B[a] ^ self.B[b] ^ self.B[c]  # @line L12

    def bits(self) -> int:
        """The size of the table in bits."""
        return len(self.B) * self.r
test_binary_fuse_filter.py 69 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/binary-fuse-filter/. The authors are listed in prov/WV9S0Z.jsonl.

import random

import pytest

from binary_fuse_filter import BinaryFuseFilter, Key, fuse_size

TABLE: dict[Key, tuple[tuple[int, int, int], int]] = {
    "a": ((0, 2, 4), 6),
    "b": ((1, 2, 5), 10),
    "c": ((2, 4, 6), 1),
    "d": ((3, 5, 7), 13),
    "x": ((1, 3, 4), 7),
    "y": ((3, 4, 6), 2),
}


class FixedFuse(BinaryFuseFilter):
    def _hash(self, key: Key) -> tuple[tuple[int, int, int], int]:
        return TABLE[key]


def test_example() -> None:
    f = FixedFuse("abcd", r=4, seg_len=2, seg_count=2)
    assert f._peel(list("abcd")) == [("a", 0), ("b", 1), ("d", 3), ("c", 6)]
    assert f.B == [6, 10, 0, 13, 0, 0, 1, 0]
    assert all(f.query(k) for k in "abcd")
    assert f.query("x")  # 10 ^ 13 ^ 0 = 7, a false positive
    assert not f.query("y")


def test_sizes() -> None:
    assert fuse_size(1000) == (128, 9)
    assert fuse_size(10**6) == (8192, 136)
    L, c = fuse_size(10**7)
    assert (c + 2) * L / 10**7 < 1.13


def test_edges() -> None:
    f = BinaryFuseFilter([])
    assert (f.L, f.count) == (4, 1) and f.bits() == 12 * 8
    g = BinaryFuseFilter(["k", "k"], r=16)
    assert g.n == 1 and g.query("k")
    with pytest.raises(ValueError):
        BinaryFuseFilter([1], r=0)


def test_random_against_set() -> None:
    rng = random.Random(2022)
    for _ in range(500):
        n = rng.randint(0, 150)
        r = rng.randint(1, 12)
        keys = rng.sample(range(10**6), n)
        f = BinaryFuseFilter(keys, r=r, seed=rng.randrange(1000))
        assert all(f.query(x) for x in keys)
        for x in keys:
            (a, b, c), fp = f._hash(x)
            assert a // f.L + 1 == b // f.L and b // f.L + 1 == c // f.L < f.count + 2
            assert f.B[a] ^ f.B[b] ^ f.B[c] == fp


def test_rate() -> None:
    keys = [f"k{i}" for i in range(20000)]
    f = BinaryFuseFilter(keys, r=8, seed=5)
    assert f.bits() / len(keys) < 1.3 * 8
    fp = sum(f.query(f"z{i}") for i in range(40000))
    assert abs(fp / 40000 - 2**-8) < 0.0015

What the next level needs

  • the tests did not run in this build

Read the full page about Binary fuse filter