Algorithm Search⌘K

Xor filter in Python

Static approximate set built by solving XOR equations over fingerprints, using little space.

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

"""Xor filter (Graf and Lemire 2020) with three blocks and r-bit fingerprints."""

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

Key = str | bytes | int


class XorFilter:
    """A static filter: query(x) is True for each x in S, and for x not in S
    with probability 2^-r.

    The table has 3 * seg cells of r bits, with seg = ceil((floor(1.23 n) + 32) / 3).
    Key x has one cell in each block and the fingerprint fp(x); the table
    satisfies B[h0(x)] ^ B[h1(x)] ^ B[h2(x)] = fp(x) for each x in S.
    """

    def __init__(self, keys: Iterable[Key], r: int = 8, seed: int = 0, seg: int | None = None) -> None:
        if r < 1:
            raise ValueError("r must be positive")
        self.r = r
        ks = list(dict.fromkeys(keys))  # distinct keys, in first-seen order
        self.n = len(ks)
        self.seg = seg if seg else -(-(123 * self.n // 100 + 32) // 3)  # @line L1
        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] * (3 * self.seg)  # @line L3
        for x, c in reversed(order):  # @line L4
            cells, fp = self._hash(x)
            acc = fp
            for h in cells:
                if h != c:
                    acc ^= self.B[h]
            self.B[c] = acc  # @line L5

    def _hash(self, key: Key) -> tuple[tuple[int, int, int], int]:
        """One cell in each block, and an r-bit fingerprint."""
        d = hashlib.blake2b(repr(key).encode(), digest_size=32, key=self.seed.to_bytes(8, "little")).digest()
        a, b, c, f = (int.from_bytes(d[i : i + 8], "little") for i in range(0, 32, 8))
        s = self.seg
        return (a % s, s + b % s, 2 * s + c % s), f % (1 << self.r)

    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 = 3 * self.seg
        count, xor = [0] * size, [0] * size
        cells = [self._hash(x)[0] for x in keys]
        for i, cs in enumerate(cells):  # @line L6
            for c in cs:
                count[c] += 1
                xor[c] ^= i
        queue = deque(c for c in range(size) if count[c] == 1)  # @line L7
        order: list[tuple[Key, int]] = []
        while queue:  # @line L8
            c = queue.popleft()
            if count[c] != 1:
                continue
            i = xor[c]  # the only key left at cell c
            order.append((keys[i], c))
            for h in cells[i]:  # @line L9
                count[h] -= 1
                xor[h] ^= i
                if count[h] == 1:
                    queue.append(h)
        return order if len(order) == len(keys) else None  # @line L10

    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 L11

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

import random

import pytest

from xor_filter import Key, XorFilter

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


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


def test_example() -> None:
    f = FixedXor("abcd", r=4, seg=3)
    assert f._peel(list("abcd")) == [("c", 1), ("d", 2), ("a", 3), ("b", 7)]
    assert f.B == [0, 3, 12, 5, 0, 0, 0, 9, 0]
    assert all(f.query(k) for k in "abcd")
    assert f.query("x")  # 3 ^ 5 ^ 9 = 15, a false positive
    assert not f.query("y")


def test_edges() -> None:
    f = XorFilter([])
    assert f.seg == 11 and f.bits() == 33 * 8
    assert all(f.query(x) == (f._hash(x)[1] == 0) for x in range(100))
    g = XorFilter([7, 7, 7], r=16)
    assert g.n == 1 and g.query(7)
    with pytest.raises(ValueError):
        XorFilter([1], r=0)


def test_random_against_set() -> None:
    rng = random.Random(2020)
    for _ in range(500):
        n = rng.randint(0, 120)
        r = rng.randint(1, 12)
        keys = rng.sample(range(10**6), n)
        f = XorFilter(keys, r=r, seed=rng.randrange(1000))
        assert f.seg == -(-(123 * n // 100 + 32) // 3)
        assert all(f.query(x) for x in keys)
        for x in keys:
            (a, b, c), fp = f._hash(x)
            assert f.B[a] ^ f.B[b] ^ f.B[c] == fp and 0 <= f.B[a] < 1 << r


def test_rate() -> None:
    keys = [f"k{i}" for i in range(5000)]
    f = XorFilter(keys, r=8, seed=1)
    assert f.bits() / len(keys) < 1.25 * 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 Xor filter