Quotient filter in Python
Compact approximate set storing quotient and remainder bits in a cache-friendly table.
- File
quotient_filter.py, 130 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/quotient-filter/. The authors are listed in prov/44MVA2.jsonl.
"""Quotient filter (Bender et al. 2012, after Cleary 1984)."""
import hashlib
Key = str | bytes | int
class QuotientFilter:
"""2^q slots, each with an r-bit remainder and three metadata bits.
A key's fingerprint has q + r bits: the top q bits are its quotient fq,
the canonical slot, and the low r bits are its remainder fr. The table
is circular. It holds at most 2^q - 1 fingerprints, so one slot is
always empty. lookup follows the metadata bits; insert and delete decode
the part of the cluster from the nearest unshifted slot, change it, and
write it back.
"""
def __init__(self, q: int, r: int, seed: int = 0) -> None:
if q < 1 or r < 1:
raise ValueError("q and r must be positive")
self.q, self.r, self.seed = q, r, seed
self.m = 1 << q
self.occupied = [False] * self.m # some fingerprint has quotient j
self.continuation = [False] * self.m # slot is in a run, not its first slot
self.shifted = [False] * self.m # slot holds a fingerprint with another quotient
self.remainder = [0] * self.m
self.n = 0
def _fp(self, key: Key) -> tuple[int, int]:
d = hashlib.blake2b(repr(key).encode(), digest_size=8, key=self.seed.to_bytes(8, "little")).digest()
v = int.from_bytes(d, "little") % (1 << (self.q + self.r))
return v >> self.r, v & ((1 << self.r) - 1)
def _is_empty(self, p: int) -> bool:
return not (self.occupied[p] or self.continuation[p] or self.shifted[p])
def _contains(self, fq: int, fr: int) -> bool:
m = self.m
if not self.occupied[fq]: # @line L1
return False
b = fq
while self.shifted[b]: # @line L2: back to an unshifted slot
b = (b - 1) % m
s = b # @line L3
while b != fq: # @line L4: skip one run for each occupied quotient before fq
s = (s + 1) % m # @line L5
while self.continuation[s]:
s = (s + 1) % m
b = (b + 1) % m # @line L6
while not self.occupied[b]:
b = (b + 1) % m
while True: # @line L7: s is the start of the run of fq
if self.remainder[s] == fr: # @line L8
return True
s = (s + 1) % m # @line L9
if not self.continuation[s]:
return False # @line L10
def query(self, key: Key) -> bool:
"""False if key is surely absent; True if it may be present."""
return self._contains(*self._fp(key))
def _start(self, fq: int) -> int:
b = fq
while self.shifted[b]:
b = (b - 1) % self.m
return b
def _decode(self, b: int) -> tuple[list[tuple[int, int]], int]:
"""The fingerprints stored from slot b to the next empty slot, and that length."""
m, slots, p = self.m, [], b
while not self._is_empty(p) and len(slots) < m:
slots.append(p)
p = (p + 1) % m
quotients = [j for j in slots if self.occupied[j]]
out, k = [], -1
for p in slots:
if not self.continuation[p]:
k += 1
out.append((quotients[k], self.remainder[p]))
return out, len(slots)
def _encode(self, b: int, elems: list[tuple[int, int]], old_len: int) -> None:
"""Write elems from slot b on in canonical order, over old_len old slots."""
m = self.m
for t in range(old_len):
p = (b + t) % m
self.occupied[p] = self.continuation[p] = self.shifted[p] = False
self.remainder[p] = 0
elems.sort(key=lambda e: ((e[0] - b) % m, e[1]))
pos, i = 0, 0
while i < len(elems):
fq = elems[i][0]
self.occupied[fq] = True
pos = max(pos, (fq - b) % m) # the run starts at fq or right after the previous run
first = True
while i < len(elems) and elems[i][0] == fq:
p = (b + pos) % m
self.remainder[p] = elems[i][1]
self.continuation[p] = not first
self.shifted[p] = p != fq
first, pos, i = False, pos + 1, i + 1
def insert(self, key: Key) -> bool:
"""Add key. False, with no change, if the table already holds 2^q - 1 fingerprints."""
if self.n >= self.m - 1:
return False
fq, fr = self._fp(key)
b = self._start(fq) # @line L11
elems, length = self._decode(b) # @line L12
elems.append((fq, fr)) # @line L13
self._encode(b, elems, length) # @line L14
self.n += 1
return True
def delete(self, key: Key) -> bool:
"""Remove one copy of the fingerprint of key. Precondition: key is present."""
fq, fr = self._fp(key)
if not self._contains(fq, fr):
return False
b = self._start(fq) # @line L15
elems, length = self._decode(b)
elems.remove((fq, fr)) # @line L16
self._encode(b, elems, length) # @line L17
self.n -= 1
return True
test_quotient_filter.py 118 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/quotient-filter/. The authors are listed in prov/44MVA2.jsonl.
import random
from collections import Counter
import pytest
from quotient_filter import Key, QuotientFilter
FPS: dict[Key, tuple[int, int]] = {
"a": (1, 5), "b": (1, 2), "c": (2, 7), "d": (4, 3), "e": (3, 1), "f": (7, 6), "g": (7, 4), "x": (2, 1),
}
class FixedQF(QuotientFilter):
def _fp(self, key: Key) -> tuple[int, int]:
return FPS[key]
def bits(f: QuotientFilter) -> list[str]:
return [
"".join("1" if v else "0" for v in (f.occupied[p], f.continuation[p], f.shifted[p])) for p in range(f.m)
]
def test_example() -> None:
f = FixedQF(3, 3)
for k in "abcde":
assert f.insert(k)
assert f.remainder[1:6] == [2, 5, 7, 1, 3]
assert bits(f)[:6] == ["000", "100", "111", "101", "101", "001"]
for k in "fg":
f.insert(k)
assert f.remainder[7] == 4 and f.remainder[0] == 6
assert bits(f)[7] == "100" and bits(f)[0] == "011"
assert all(f.query(k) for k in "abcdefg")
assert not f.query("x")
assert f.delete("b")
assert f.remainder[1:5] == [5, 7, 1, 3] and bits(f)[:6] == ["011", "100", "100", "100", "100", "000"]
assert all(f.query(k) for k in "acdefg") and not f.query("b")
def test_edges() -> None:
with pytest.raises(ValueError):
QuotientFilter(0, 4)
f = QuotientFilter(1, 1)
assert f.insert("a") and not f.insert("b") # capacity 2^q - 1
assert f.query("a") and f.delete("a") and not f.query("a")
def check_layout(f: QuotientFilter) -> Counter[tuple[int, int]]:
"""Check the invariant of the page directly, and return the stored multiset."""
m = f.m
e = next(p for p in range(m) if f._is_empty(p))
order = [(e + t) % m for t in range(1, m)] + [e] # no cluster crosses the empty slot e
found: Counter[tuple[int, int]] = Counter()
t = 0
while t < m:
p = order[t]
if f._is_empty(p):
assert f.remainder[p] == 0
t += 1
continue
slots = []
while not f._is_empty(order[t]):
slots.append(order[t])
t += 1
c = slots[0] # cluster start: the slot before it is empty
qs = [j for j in slots if f.occupied[j]]
k, prev = -1, -1
for i, s in enumerate(slots):
if not f.continuation[s]:
k += 1
if k == 0:
assert qs[0] == c
else:
assert (qs[k] - c) % m <= i
prev = -1
Q = qs[k]
assert f.shifted[s] == (s != Q)
assert prev <= f.remainder[s]
prev = f.remainder[s]
found[(Q, f.remainder[s])] += 1
assert k == len(qs) - 1
return found
def test_random_against_multiset() -> None:
rng = random.Random(2012)
for _ in range(500):
q, r = rng.randint(1, 4), rng.randint(1, 3)
f = QuotientFilter(q, r, seed=rng.randrange(1000))
live: Counter[int] = Counter()
fps: Counter[tuple[int, int]] = Counter()
for _ in range(rng.randint(0, 50)):
x = rng.randrange(25)
if rng.random() < 0.6:
if f.insert(x):
live[x] += 1
fps[f._fp(x)] += 1
elif live[x] > 0:
assert f.delete(x)
live[x] -= 1
fps[f._fp(x)] -= 1
assert +check_layout(f) == +fps
for fq in range(f.m):
for fr in range(1 << r):
assert f._contains(fq, fr) == (fps[(fq, fr)] > 0)
def test_rate() -> None:
f = QuotientFilter(10, 8, seed=1)
for i in range(900):
assert f.insert(("k", i))
assert all(f.query(("k", i)) for i in range(900))
hits = sum(f.query(("z", i)) for i in range(20000))
assert hits / 20000 < 900 / 1024 / 256 + 0.002
What the next level needs
- the tests did not run in this build