Algorithm Search⌘K

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
buffer_tree.pyRawGitHub
# 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

Read the full page about Buffer tree