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