Buffer tree in Python
Batched external-memory search tree whose nodes hold buffers of pending operations.
- File
buffer_tree.py, 293 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/buffer-tree/. The authors are listed in prov/5H2XC9.jsonl.
from bisect import bisect_right
from math import ceil
from typing import Any, Generic, NamedTuple, Optional, Protocol, TypeVar, Union
class Ordered(Protocol):
def __lt__(self, other: Any, /) -> bool: ...
K = TypeVar("K", bound=Ordered)
INSERT, DELETE, QUERY = 0, 1, 2
class Op(NamedTuple):
time: int
kind: int
key: Any
qid: int
class Leaf(Generic[K]):
"""A leaf block: at most B keys in increasing order."""
def __init__(self, keys: list[K]) -> None:
self.keys = keys
class Node(Generic[K]):
"""An internal node with routing keys, children and a buffer of pending
operations. A bottom node has leaves as its children."""
def __init__(self, bottom: bool, parent: Optional["Node[K]"] = None) -> None:
self.bottom = bottom
self.parent = parent
self.routing: list[K] = []
self.children: list[Union["Node[K]", Leaf[K]]] = []
self.buf: list[Op] = []
class BufferTree(Generic[K]):
"""Arge's buffer tree for a batched dictionary of distinct keys.
The tree has fan-out at most m, leaves of at most B keys, and a buffer of
up to M = m * B operations in each internal node. Inserts, deletes and
membership queries go into the root buffer; a buffer that overflows is
emptied one level down, and a bottom node applies its operations to its
leaves in time order. A query is answered when it reaches the leaves, so
the answers come late: `flush` empties all buffers and returns them.
N operations cost O((N/B) log_m (N/B)) I/Os in total; `ios` counts the
block transfers. Deletions are rebalanced by global rebuilding.
"""
def __init__(self, block_size: int = 4, fanout: int = 4) -> None:
if block_size < 1 or fanout < 4:
raise ValueError("need block_size >= 1 and fanout >= 4")
self.B = block_size
self.m = fanout
self.M = fanout * block_size
self.root: Node[K] = Node(bottom=True)
self.root.children = [Leaf([])]
self.time = 0
self.next_qid = 0
self.answers: dict[int, bool] = {}
self.ios = 0
self.base = 0
self.deletes = 0
# operations ---------------------------------------------------------
def insert(self, k: K) -> None:
"""Insert k; inserting a present key changes nothing."""
self._operation(INSERT, k, -1)
def delete(self, k: K) -> None:
"""Delete k; deleting an absent key changes nothing."""
self._operation(DELETE, k, -1)
self.deletes += 1
if self.deletes > max(self.M, self.base // 2):
self._rebuild()
def query(self, k: K) -> int:
"""Ask whether k is present now. Return a query id; the answer is in
`answers` once the query has reached the leaves."""
q = self.next_qid
self.next_qid += 1
self._operation(QUERY, k, q)
return q
def _operation(self, kind: int, k: K, qid: int) -> None:
self.time += 1
self.root.buf.append(Op(self.time, kind, k, qid)) # @line L1
if len(self.root.buf) % self.B == 0:
self.ios += 1 # a full block of the root buffer goes to disk
if len(self.root.buf) > self.M: # @line L2
self._empty(self.root)
# buffer emptying ----------------------------------------------------
def _empty(self, x: Node[K]) -> None:
ops, x.buf = x.buf, [] # @line L3
self.ios += ceil(len(ops) / self.B)
if x.bottom: # @line L4
self._empty_bottom(x, ops)
return
counts = [0] * len(x.children)
for op in ops: # @line L5
i = bisect_right(x.routing, op.key)
child = x.children[i]
assert isinstance(child, Node)
child.buf.append(op)
counts[i] += 1
self.ios += sum(ceil(c / self.B) for c in counts)
full = [c for c in x.children if isinstance(c, Node) and len(c.buf) > self.M]
for c in full: # @line L6
self._empty(c)
def _empty_bottom(self, x: Node[K], ops: list[Op]) -> None:
present: set[Any] = set() # @line L7
for leaf in x.children:
assert isinstance(leaf, Leaf)
present.update(leaf.keys)
self.ios += len(x.children)
for op in ops: # @line L8
if op.kind == INSERT: # @line L9
present.add(op.key)
elif op.kind == DELETE:
present.discard(op.key)
else:
self.answers[op.qid] = op.key in present
keys = sorted(present) # @line L10
leaves: list[Union[Node[K], Leaf[K]]] = [
Leaf(keys[i : i + self.B]) for i in range(0, len(keys), self.B)
] or [Leaf([])]
self.ios += len(leaves)
x.children = leaves
x.routing = [leaf.keys[0] for leaf in leaves[1:] if isinstance(leaf, Leaf)]
if len(x.children) > self.m: # @line L11
self._split(x)
def _split(self, x: Node[K]) -> None:
assert not x.buf
c = len(x.children)
g = ceil(c / self.m) # @line L12
q, r = divmod(c, g)
sizes = [q + (1 if t < r else 0) for t in range(g)]
kids, routes = x.children, x.routing
nodes: list[Node[K]] = [x] + [Node(x.bottom, x.parent) for _ in range(g - 1)]
seps: list[K] = []
pos = 0
for t, y in enumerate(nodes): # @line L13
y.children = kids[pos : pos + sizes[t]]
y.routing = routes[pos : pos + sizes[t] - 1]
if t > 0:
seps.append(routes[pos - 1])
for ch in y.children:
if isinstance(ch, Node):
ch.parent = y
pos += sizes[t]
self.ios += g
p = x.parent
if p is None: # @line L14
p = Node(bottom=False)
p.children = [x]
self.root = p
for y in nodes:
y.parent = p
j = p.children.index(x)
p.children[j + 1 : j + 1] = nodes[1:]
p.routing[j:j] = seps
if len(p.children) > self.m: # @line L15
self._split(p)
# flushing and rebuilding --------------------------------------------
def flush(self) -> dict[int, bool]:
"""Empty every buffer, top-down, and return all answers so far."""
def force(x: Node[K]) -> None:
if x.buf or not x.bottom:
self._empty(x)
if not x.bottom:
for c in list(x.children):
assert isinstance(c, Node)
force(c)
# A split during the pass can move unvisited children to a new node,
# so repeat the pass until no buffer holds an operation.
while self._pending(self.root):
force(self.root)
return self.answers
def _pending(self, x: Node[K]) -> bool:
if x.buf:
return True
return not x.bottom and any(self._pending(c) for c in x.children if isinstance(c, Node))
def keys(self) -> list[K]:
"""Flush, then return all keys in increasing order."""
self.flush()
out: list[K] = []
def walk(x: Union[Node[K], Leaf[K]]) -> None:
if isinstance(x, Leaf):
out.extend(x.keys)
else:
for c in x.children:
walk(c)
walk(self.root)
return out
def _rebuild(self) -> None:
keys = self.keys()
level: list[Union[Node[K], Leaf[K]]] = [
Leaf(keys[i : i + self.B]) for i in range(0, len(keys), self.B)
] or [Leaf([])]
lows: list[Any] = [lf.keys[0] if isinstance(lf, Leaf) and lf.keys else None for lf in level]
bottom = True
while True:
g = ceil(len(level) / self.m)
q, r = divmod(len(level), g)
nxt: list[Union[Node[K], Leaf[K]]] = []
nlows: list[Any] = []
pos = 0
for t in range(g):
size = q + (1 if t < r else 0)
y: Node[K] = Node(bottom)
y.children = level[pos : pos + size]
y.routing = lows[pos + 1 : pos + size]
for ch in y.children:
if isinstance(ch, Node):
ch.parent = y
nxt.append(y)
nlows.append(lows[pos])
pos += size
self.ios += len(nxt) + (len(level) if bottom else 0)
level, lows, bottom = nxt, nlows, False
if len(level) == 1:
break
root = level[0]
assert isinstance(root, Node)
self.root = root
self.base = len(keys)
self.deletes = 0
# checking -----------------------------------------------------------
def height(self) -> int:
"""The number of internal levels."""
h, x = 1, self.root
while not x.bottom:
c = x.children[0]
assert isinstance(c, Node)
x, h = c, h + 1
return h
def check_invariant(self) -> None:
"""Raise AssertionError if a buffer tree property fails."""
depths: set[int] = set()
def walk(x: Node[K], lo: Any, hi: Any, depth: int, younger: float) -> None:
assert len(x.children) == len(x.routing) + 1 <= self.m
assert all(a < b for a, b in zip(x.routing, x.routing[1:]))
assert all(lo is None or not (r < lo) for r in x.routing)
assert all(hi is None or r < hi for r in x.routing)
assert len(x.buf) <= self.M
times = [op.time for op in x.buf]
assert times == sorted(times)
assert not times or times[-1] < younger
for op in x.buf:
assert lo is None or not (op.key < lo)
assert hi is None or op.key < hi
oldest = times[0] if times else younger
bounds = [lo, *x.routing, hi]
for j, c in enumerate(x.children):
if x.bottom:
assert isinstance(c, Leaf) and len(c.keys) <= self.B
assert all(a < b for a, b in zip(c.keys, c.keys[1:]))
for k in c.keys:
assert bounds[j] is None or not (k < bounds[j])
assert bounds[j + 1] is None or k < bounds[j + 1]
depths.add(depth)
else:
assert isinstance(c, Node) and c.parent is x
walk(c, bounds[j], bounds[j + 1], depth + 1, oldest)
assert self.root.parent is None
walk(self.root, None, None, 0, float("inf"))
assert len(depths) == 1
test_buffer_tree.py 108 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/buffer-tree/. The authors are listed in prov/5H2XC9.jsonl.
import math
import random
import pytest
from buffer_tree import BufferTree, Leaf, Node
def shape(x: "Node[int] | Leaf[int]") -> str:
if isinstance(x, Leaf):
return "[" + " ".join(map(str, x.keys)) + "]"
inner = " ".join(shape(c) for c in x.children)
return "<" + " ".join(map(str, x.routing)) + "|" + str(len(x.buf)) + ">(" + inner + ")"
def test_example() -> None:
t: BufferTree[int] = BufferTree(block_size=2, fanout=4)
assert t.M == 8
for k in [50, 20, 80, 10, 60, 30, 70, 40]:
t.insert(k)
# eight operations wait in the root buffer, nothing has reached a leaf
assert shape(t.root) == "<|8>([])"
q = t.query(30)
# the ninth operation overflows the buffer: the root applies all nine
assert shape(t.root) == "<30 50 70|0>([10 20] [30 40] [50 60] [70 80])"
assert t.answers == {q: True}
for k in [90, 15, 25]:
t.insert(k)
t.delete(30)
q2 = t.query(30)
q3 = t.query(25)
assert q2 not in t.answers
t.check_invariant()
answers = t.flush()
assert answers[q2] is False and answers[q3] is True
assert t.keys() == [10, 15, 20, 25, 40, 50, 60, 70, 80, 90]
assert shape(t.root) == "<60|0>(<20 40|0>([10 15] [20 25] [40 50]) <80|0>([60 70] [80 90]))"
t.check_invariant()
def test_edges() -> None:
with pytest.raises(ValueError):
BufferTree(1, 3)
t: BufferTree[int] = BufferTree(1, 4)
q = t.query(5)
t.delete(5)
assert t.flush() == {q: False}
assert t.keys() == []
t.insert(5)
t.insert(5)
assert t.keys() == [5]
t.check_invariant()
def run_oracle(ops: list[tuple[int, int]]) -> tuple[dict[int, bool], list[int]]:
s: set[int] = set()
ans: dict[int, bool] = {}
q = 0
for kind, k in ops:
if kind == 0:
s.add(k)
elif kind == 1:
s.discard(k)
else:
ans[q] = k in s
q += 1
return ans, sorted(s)
def test_against_set() -> None:
rng = random.Random(3)
for _ in range(500):
B = rng.randrange(1, 4)
m = rng.randrange(4, 7)
t: BufferTree[int] = BufferTree(B, m)
ops = [(rng.choice((0, 0, 1, 2)), rng.randrange(80)) for _ in range(rng.randrange(1, 250))]
for i, (kind, k) in enumerate(ops):
if kind == 0:
t.insert(k)
elif kind == 1:
t.delete(k)
else:
t.query(k)
if i % 37 == 0:
t.check_invariant()
want_ans, want_keys = run_oracle(ops)
assert t.flush() == want_ans
t.check_invariant()
assert t.keys() == want_keys
def test_io_bound() -> None:
B, m = 16, 8
t: BufferTree[int] = BufferTree(B, m)
rng = random.Random(9)
N = 40000
for k in rng.sample(range(10 * N), N):
t.insert(k)
t.flush()
t.check_invariant()
n = N / B
bound = n * math.log(n, m)
assert t.ios <= 6 * bound
# fewer than one I/O per insert; a B-tree makes one read per level
assert t.ios < N
What the next level needs
- the tests did not run in this build