"""BPT1 — immutable, bulk-loaded B+ tree (numpy twin of the C++20 reader in src/streetdex/index/bptree.{hpp,cpp}; identical bytes on disk). Why a B+ tree here at all: zone maps prune brilliantly on `ts` because files are time-sorted, and prune *nothing* on an unsorted column (every row group spans nearly the whole domain). A secondary index re-sorts (key → location) once, so point/range predicates on ANY numeric column become a descent plus a contiguous leaf scan instead of a full-table read. Immutability makes the classic hard parts vanish: bulk load at 100% fill, no splits, no rebalancing, rebuilt per table version like every other derived artifact. Values are opaque u64s; the store packs (file_idx << 40) | row_in_file, so a leaf hit maps straight to one Parquet row group — the index prunes I/O, not just rows. """ from __future__ import annotations import struct from pathlib import Path import numpy as np MAGIC = b"BPT1" DEFAULT_ORDER = 256 # 256 × 8B keys = one 4 KiB page per node touch def encode_key(values: np.ndarray) -> np.ndarray: """Order-preserving i64 encoding: ints pass through; IEEE-754 doubles use the sign-flip trick (identical to bpt_encode_f64 in C++).""" if np.issubdtype(values.dtype, np.integer): return values.astype(" int: return int(encode_key(np.array([v]))[0]) def build(keys: np.ndarray, values: np.ndarray, order: int = DEFAULT_ORDER) -> bytes: order_idx = np.argsort(keys, kind="stable") k = np.ascontiguousarray(keys[order_idx], dtype=" order: firsts = below[::order].copy() levels.append(firsts) below = firsts levels.reverse() # root first, as the C++ builder writes them out = bytearray() out += MAGIC out += struct.pack(" int: node = 0 for lv in self.levels: begin = node * self.order end = min(begin + self.order, len(lv)) # lower_bound + step-back (see C++ twin): duplicates of k may # start in the child before the first child whose first-key == k pos = int(np.searchsorted(lv[begin:end], k, side="left")) + begin node = pos - 1 if pos > begin else begin begin = node * self.order end = min(begin + self.order, self.n) return int(np.searchsorted(self.keys[begin:end], k, side="left")) + begin def range(self, lo: int, hi: int) -> np.ndarray: a = self.lower_bound(lo) b = int(np.searchsorted(self.keys, hi, side="right")) return self.vals[a:b]