from __future__ import annotations
__all__ = ('VecDB', 'VecDBElement', 'Distance', 'EuclideanDistanceSquared')
from genlayer.storage import DynArray, TreeMap
from genlayer.types import u32, i32
from genlayer.storage import allow
import typing
import numpy as np
import math
[docs]
class Distance(typing.Protocol):
[docs]
def __call__(self, l, r) -> typing.Any: ...
[docs]
@allow
class EuclideanDistanceSquared(Distance):
[docs]
def __call__(self, l, r):
return np.sum((l - r) ** 2)
[docs]
def batch(self, l, r):
return ((l - r) ** 2).sum(axis=1)
Id = typing.NewType('Id', int)
_Id = Id
NO_PARENT: u32 = 0xFFFFFFFF # Constant for no parent node
MIN_LEVEL: i32 = -64 # Sentinel for coincident points in level calculation
MAX_LEVEL: i32 = 65535 # Cap for root level
@allow
class CoverTreeNode:
"""A node in the cover tree structure"""
element_id: u32
level: i32
children: DynArray[u32] # Indices of child nodes
parent: u32 # Index of parent node, NO_PARENT if root
def __init__(self, element_id: u32, level: i32):
self.element_id = element_id
self.level = level
class VecDBElement[T: np.number, S: int, V, Dist]:
distance: Dist
"""
Distance from search point to this element, if any
"""
__slots__ = ('_idx', '_db', 'distance')
def __init__(self, db: VecDB[T, S, V], idx: u32, distance: Dist):
self._idx = idx
self._db = db
self.distance = distance
def __repr__(self) -> str:
return f'VecDB.Element(id={self.id!r}, key={self.key!r}, value={self.value!r}, distance={self.distance})'
@property
def key(self) -> np.ndarray[tuple[S], np.dtype[T]]:
"""
Key (vector) of this element
"""
return self._db._keys[self._idx]
@property
def id(self) -> Id:
"""
Id (unique key) of this element
"""
return Id(self._idx)
@property
def value(self) -> V:
"""
Value of this element
"""
return self._db._values[self._idx]
@value.setter
def value(self, v: V):
self._db._values[self._idx] = v
def remove(self) -> None:
"""
Removes current element from the db
"""
self._db._remove_from_tree(self._idx)
self._db._free_idx[self._idx] = None
[docs]
@allow
class VecDB[T: np.number, S: int, V, D: Distance]:
"""
Data structure that supports storing and querying vector data using Cover Trees
Cover trees provide logarithmic time nearest neighbor search with theoretical guarantees.
There are two entities that can act as a key:
#. vector (can have duplicates)
#. id (int alias, can't have duplicates)
.. warning::
import :py:mod:`numpy` before ``from genlayer import *`` if you wish to use :py:class:`VecDB`!
"""
type Id = _Id
"""
:py:class:`int` alias to prevent confusion
"""
type Element = VecDBElement
"""
Shorthand to prevent global namespace pollution
"""
_keys: DynArray[np.ndarray[tuple[S], np.dtype[T]]]
_values: DynArray[V]
_free_idx: TreeMap[u32, None]
_nodes: DynArray[CoverTreeNode]
_free_nodes: TreeMap[u32, None]
_elem_to_node: TreeMap[u32, u32] # element_id -> highest-level node_idx
_root_idx: u32
_base: float # Base for cover tree levels (typically 1.3)
_max_level: i32
_min_level: i32
_dist_func: D
_initialized: bool = False
[docs]
def __init__(self):
self._do_init()
def _do_init(self):
if self._initialized:
return
self._initialized = True
self._root_idx = NO_PARENT
self._base = 1.3
self._max_level = 0
self._min_level = 0
[docs]
def __len__(self) -> int:
return len(self._keys) - len(self._free_idx)
[docs]
def get_by_id(self, id: Id) -> VecDBElement[T, S, V, None]:
res = self.get_by_id_or_none(id)
if res is None:
raise KeyError(f'no element with id {id}')
return res
[docs]
def get_by_id_or_none(self, id: Id) -> VecDBElement[T, S, V, None] | None:
if id in self._free_idx:
return None
return VecDBElement(self, id, None)
def _distance(self, idx1: u32, idx2: u32) -> T:
"""Compute distance between two elements by their indices"""
return self._dist_func(self._keys[idx1], self._keys[idx2])
def _distance_to_point(self, idx: u32, point: np.ndarray[tuple[S], np.dtype[T]]) -> T:
"""Compute distance from element to query point"""
return self._dist_func(self._keys[idx], point)
def _allocate_node(self, element_id: u32, level: i32) -> u32:
"""Allocate a new node and return its index"""
if len(self._free_nodes) > 0:
node_idx = self._free_nodes.popitem()[0]
self._nodes[node_idx] = CoverTreeNode(element_id, level)
return node_idx
else:
node = CoverTreeNode(element_id, level)
self._nodes.append(node)
return len(self._nodes) - 1
def _free_node(self, node_idx: u32) -> None:
"""Mark a node as free"""
self._free_nodes[node_idx] = None
[docs]
def insert(self, key: np.ndarray[tuple[S], np.dtype[T]], val: V) -> Id:
self._do_init()
# Add to storage arrays
if len(self._free_idx) > 0:
idx = self._free_idx.popitem()[0]
self._keys[idx] = key
self._values[idx] = val
else:
self._keys.append(key)
self._values.append(val)
idx = len(self._keys) - 1
# Insert into cover tree
self._insert_into_tree(idx)
return Id(idx)
def _level_for_dist(self, dist: float) -> i32:
"""Compute the cover tree level for a given distance.
Returns largest L such that base^L < dist (i.e. dist > base^L).
This ensures the separating invariant: points at level L are > base^L apart.
"""
if dist <= 0:
return -2147483648 # i32 min as sentinel for coincident points
return int(math.ceil(math.log(dist) / math.log(self._base))) - 1
def _insert_into_tree(self, new_idx: u32) -> None:
"""Insert element into cover tree structure using top-down descent"""
if self._root_idx == NO_PARENT:
# First element becomes root
self._root_idx = self._allocate_node(new_idx, 0)
self._nodes[self._root_idx].parent = NO_PARENT
self._max_level = 0
self._min_level = 0
self._elem_to_node[new_idx] = self._root_idx
return
# If new point is too far from root, make it the new root
root_node = self._nodes[self._root_idx]
root_dist = float(self._distance(new_idx, root_node.element_id))
if root_dist > self._base ** int(root_node.level):
needed_level = int(math.ceil(math.log(root_dist) / math.log(self._base)))
needed_level = max(needed_level, int(root_node.level) + 1)
needed_level = min(needed_level, MAX_LEVEL)
new_root_idx = self._allocate_node(new_idx, needed_level)
self._nodes[new_root_idx].parent = NO_PARENT
self._nodes[new_root_idx].children.append(self._root_idx)
self._nodes[self._root_idx].parent = new_root_idx
self._root_idx = new_root_idx
self._max_level = needed_level
self._elem_to_node[new_idx] = new_root_idx
return
# Candidate-set descent: track ALL covering nodes, not just one path.
# This ensures the insertion level satisfies the separating invariant
# against every covering ancestor, not just the immediate parent.
candidates: list[tuple[u32, float]] = [(self._root_idx, root_dist)]
min_cover_dist = root_dist
while True:
next_covering: list[tuple[u32, float]] = []
for nidx, _ in candidates:
node = self._nodes[nidx]
for i in range(len(node.children)):
child_idx = node.children[i]
child_node = self._nodes[child_idx]
if int(child_node.level) <= MIN_LEVEL:
continue
dist = float(self._distance(new_idx, child_node.element_id))
if dist <= self._base ** int(child_node.level):
next_covering.append((child_idx, dist))
if dist < min_cover_dist:
min_cover_dist = dist
if not next_covering:
break
candidates = next_covering
# Insert as child of nearest deepest covering node
nearest_nidx = candidates[0][0]
nearest_dist = candidates[0][1]
for nidx, d in candidates:
if d < nearest_dist:
nearest_nidx = nidx
nearest_dist = d
new_level = self._level_for_dist(min_cover_dist)
new_level = min(new_level, int(self._nodes[nearest_nidx].level) - 1)
new_level = max(new_level, MIN_LEVEL)
new_node_idx = self._allocate_node(new_idx, new_level)
self._nodes[new_node_idx].parent = nearest_nidx
self._nodes[nearest_nidx].children.append(new_node_idx)
if new_level < self._min_level:
self._min_level = new_level
self._elem_to_node[new_idx] = new_node_idx
def _remove_from_tree(self, idx: u32) -> None:
"""Remove element from cover tree using swap-descent to a leaf.
Instead of reinserting all descendants, we greedily descend from the
deleted node to a leaf, swapping elements upward at each step. Only
the leaf is physically removed. After the descent we walk back up
and reinsert only the subtrees of children that violate the covering
invariant due to the element swap.
"""
if idx in self._elem_to_node:
node_idx = self._elem_to_node[idx]
del self._elem_to_node[idx]
else:
# Fallback for legacy data without _elem_to_node populated
node_idx = self._find_node_by_id(idx)
if node_idx == NO_PARENT:
return
# Phase 1: descend from node_idx to a leaf, swapping elements up
path: list[u32] = [] # nodes whose element was swapped (need violation check)
current = node_idx
while len(self._nodes[current].children) > 0:
node = self._nodes[current]
# Find the child whose element is nearest to current element
nearest_child_idx = node.children[0]
nearest_dist = float(
self._distance(node.element_id, self._nodes[nearest_child_idx].element_id)
)
for i in range(1, len(node.children)):
child_idx = node.children[i]
d = float(self._distance(node.element_id, self._nodes[child_idx].element_id))
if d < nearest_dist:
nearest_dist = d
nearest_child_idx = child_idx
# Swap: current node adopts the nearest child's element
replacement_elem = self._nodes[nearest_child_idx].element_id
node.element_id = replacement_elem
self._elem_to_node[replacement_elem] = current
path.append(current)
current = nearest_child_idx
# `current` is now a leaf — remove it
leaf = self._nodes[current]
parent_idx = leaf.parent
if parent_idx != NO_PARENT:
parent = self._nodes[parent_idx]
for i in range(len(parent.children)):
if parent.children[i] == current:
parent.children[i : i + 1] = []
break
elif current == self._root_idx:
self._root_idx = NO_PARENT
self._max_level = 0
self._min_level = 0
self._free_node(current)
# Phase 2: walk back up, fixing covering-invariant violations
for swap_idx in reversed(path):
node = self._nodes[swap_idx]
to_reinsert: list[u32] = []
i = 0
while i < len(node.children):
child_idx = node.children[i]
child_node = self._nodes[child_idx]
d = float(self._distance(node.element_id, child_node.element_id))
if d > self._base ** int(node.level):
to_reinsert.append(child_idx)
node.children[i : i + 1] = []
else:
i += 1
for v_idx in to_reinsert:
self._reinsert_subtree(v_idx)
def _reinsert_subtree(self, node_idx: u32) -> None:
"""Collect all elements from a detached subtree, free its nodes,
and reinsert the elements into the tree."""
elements: list[u32] = []
stack: list[u32] = [node_idx]
while len(stack) > 0:
nidx = stack.pop()
n = self._nodes[nidx]
elements.append(n.element_id)
for i in range(len(n.children)):
stack.append(n.children[i])
if n.element_id in self._elem_to_node:
del self._elem_to_node[n.element_id]
self._free_node(nidx)
for eid in elements:
self._insert_into_tree(eid)
def _find_node_by_id(self, element_id: u32) -> u32:
"""Find node index with given element ID"""
if self._root_idx == NO_PARENT:
return NO_PARENT
stack: list[u32] = [self._root_idx]
while len(stack) > 0:
node_idx = stack.pop()
if node_idx in self._free_nodes:
continue
node = self._nodes[node_idx]
if node.element_id == element_id:
return node_idx
for i in range(len(node.children)):
stack.append(node.children[i])
return NO_PARENT
def _max_descendant_dist(self, level: int) -> float:
"""Upper bound on distance from a node at `level` to any descendant.
Each ancestor at level l covers its child within base^l. Summing
the geometric series from level down gives base^(level+1)/(base-1).
"""
return self._base ** (level + 1) / (self._base - 1)
[docs]
def knn(
self, v: np.ndarray[tuple[S], np.dtype[T]], k: int
) -> typing.Iterator[VecDBElement[T, S, V, T]]:
"""Find k nearest neighbors using cover tree with pruning"""
self._do_init()
if self._root_idx == NO_PARENT or k <= 0:
return
import heapq
# Max-heap of size k tracking the best candidates (neg_dist, element_id)
best: list[tuple[float, u32]] = []
def best_kth_dist() -> float:
if len(best) < k:
return float('inf')
return -best[0][0]
# DFS with pruning; stack entries: (node_idx, dist_to_query)
root_node = self._nodes[self._root_idx]
root_dist = float(self._distance_to_point(root_node.element_id, v))
stack: list[tuple[u32, float]] = [(self._root_idx, root_dist)]
while len(stack) > 0:
node_idx, node_dist = stack.pop()
if node_idx in self._free_nodes:
continue
node = self._nodes[node_idx]
# Add this node's element to best-k
if node.element_id not in self._free_idx and np.isfinite(node_dist):
if len(best) < k:
heapq.heappush(best, (-node_dist, node.element_id))
elif node_dist < -best[0][0]:
heapq.heapreplace(best, (-node_dist, node.element_id))
# Collect children with distances, then sort farthest-first
# so DFS pops the closest child first (better pruning)
children_with_dist: list[tuple[float, u32]] = []
for i in range(len(node.children)):
child_idx = node.children[i]
child_node = self._nodes[child_idx]
child_dist = float(self._distance_to_point(child_node.element_id, v))
# Prune: closest possible descendant is child_dist - max_descendant_dist
mdd = self._max_descendant_dist(int(child_node.level))
if child_dist - mdd <= best_kth_dist():
children_with_dist.append((child_dist, child_idx))
# Sort descending so closest is popped first from stack
children_with_dist.sort(key=lambda x: -x[0])
for child_dist, child_idx in children_with_dist:
stack.append((child_idx, child_dist))
# Yield results sorted by distance
results = sorted((-d, eid) for d, eid in best)
for dist, eid in results:
yield VecDBElement(self, eid, dist)
[docs]
def __iter__(self):
self._do_init()
for i in range(len(self._keys)):
if i in self._free_idx:
continue
yield VecDBElement(self, i, None)