soupdb-vector 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """SoupDB vector plugin package: IVF/HNSW indices and multimedia features."""
2
+
3
+ __all__ = ["indexes", "multimedia", "plugin"]
@@ -0,0 +1,18 @@
1
+ """Vector indexes for Parte 4: paged vector store, brute force, IVF and HNSW."""
2
+
3
+ from soupdb_vector.indexes.brute_force import BruteForceIndex
4
+ from soupdb_vector.indexes.hnsw import HNSWIndex
5
+ from soupdb_vector.indexes.ivf import IVFIndex
6
+ from soupdb_vector.indexes.metrics import COSINE, EUCLIDEAN, INNER_PRODUCT, METRICS
7
+ from soupdb_vector.indexes.store import VectorStore
8
+
9
+ __all__ = [
10
+ "COSINE",
11
+ "EUCLIDEAN",
12
+ "INNER_PRODUCT",
13
+ "METRICS",
14
+ "BruteForceIndex",
15
+ "HNSWIndex",
16
+ "IVFIndex",
17
+ "VectorStore",
18
+ ]
@@ -0,0 +1,154 @@
1
+ """Array of fixed-size records over pages of a DiskManager.
2
+
3
+ Page 0 is a header (magic, record size, record count and a free area for the
4
+ owner's metadata); record ``i`` lives at page ``1 + i // per_page``. Fixed
5
+ size lets a record be addressed by arithmetic instead of a slot directory,
6
+ which is what vectors and HNSW adjacency lists need.
7
+ """
8
+
9
+ import struct
10
+ from collections.abc import Iterator
11
+
12
+ from engine.storage.buffer_manager import BufferManager
13
+ from engine.storage.disk_manager import DiskManager
14
+
15
+ _HEADER = struct.Struct("<4sHIQ")
16
+ _VERSION = 1
17
+
18
+
19
+ def peek_meta(buffer_manager: BufferManager, magic: bytes, layout: struct.Struct) -> tuple:
20
+ """Read the owner's metadata of an existing file before knowing its record size."""
21
+ frame = buffer_manager.pin(0)
22
+ try:
23
+ stored_magic = _HEADER.unpack_from(frame, 0)[0]
24
+ values = layout.unpack_from(frame, _HEADER.size)
25
+ finally:
26
+ buffer_manager.unpin(0)
27
+ if stored_magic != magic:
28
+ raise ValueError(f"not a {magic!r} file")
29
+ return values
30
+
31
+
32
+ class FixedRecordFile:
33
+ """Append-only array of fixed-size records, read and written in place."""
34
+
35
+ def __init__(
36
+ self,
37
+ disk_manager: DiskManager,
38
+ buffer_manager: BufferManager,
39
+ *,
40
+ record_size: int,
41
+ magic: bytes,
42
+ ) -> None:
43
+ page_size = disk_manager.page_size
44
+ if record_size <= 0 or record_size > page_size:
45
+ raise ValueError(
46
+ f"record of {record_size} bytes does not fit in a {page_size}-byte page"
47
+ )
48
+ if len(magic) != 4:
49
+ raise ValueError("magic must be exactly 4 bytes")
50
+
51
+ self._disk = disk_manager
52
+ self._buffer = buffer_manager
53
+ self._record_size = record_size
54
+ self._per_page = page_size // record_size
55
+ self._magic = magic
56
+
57
+ if disk_manager.page_count == 0:
58
+ disk_manager.allocate_page()
59
+ self._count = 0
60
+ self._write_header()
61
+ else:
62
+ frame = buffer_manager.pin(0)
63
+ try:
64
+ stored_magic, version, stored_size, count = _HEADER.unpack_from(frame, 0)
65
+ finally:
66
+ buffer_manager.unpin(0)
67
+ if stored_magic != magic or version != _VERSION:
68
+ raise ValueError(f"{disk_manager.path} is not a {magic!r} file")
69
+ if stored_size != record_size:
70
+ raise ValueError(f"record size on disk is {stored_size}, expected {record_size}")
71
+ self._count = count
72
+
73
+ def __len__(self) -> int:
74
+ return self._count
75
+
76
+ @property
77
+ def per_page(self) -> int:
78
+ return self._per_page
79
+
80
+ @property
81
+ def meta_capacity(self) -> int:
82
+ return self._disk.page_size - _HEADER.size
83
+
84
+ def read_meta(self, layout: struct.Struct) -> tuple:
85
+ frame = self._buffer.pin(0)
86
+ try:
87
+ return layout.unpack_from(frame, _HEADER.size)
88
+ finally:
89
+ self._buffer.unpin(0)
90
+
91
+ def write_meta(self, layout: struct.Struct, *values: object) -> None:
92
+ if layout.size > self.meta_capacity:
93
+ raise ValueError("metadata does not fit in the header page")
94
+ frame = self._buffer.pin(0)
95
+ layout.pack_into(frame, _HEADER.size, *values)
96
+ self._buffer.unpin(0, dirty=True)
97
+
98
+ def append(self, layout: struct.Struct, *values: object) -> int:
99
+ index = self._count
100
+ page_id, offset = self._locate(index)
101
+ if page_id == self._disk.page_count:
102
+ self._disk.allocate_page()
103
+ frame = self._buffer.pin(page_id)
104
+ layout.pack_into(frame, offset, *values)
105
+ self._buffer.unpin(page_id, dirty=True)
106
+ self._count += 1
107
+ self._write_header()
108
+ return index
109
+
110
+ def unpack(self, index: int, layout: struct.Struct, extra: int = 0) -> tuple:
111
+ """Unpack ``layout`` at byte ``extra`` of record ``index``."""
112
+ page_id, offset = self._locate(self._check(index))
113
+ frame = self._buffer.pin(page_id)
114
+ try:
115
+ return layout.unpack_from(frame, offset + extra)
116
+ finally:
117
+ self._buffer.unpin(page_id)
118
+
119
+ def pack(self, index: int, layout: struct.Struct, *values: object, extra: int = 0) -> None:
120
+ page_id, offset = self._locate(self._check(index))
121
+ frame = self._buffer.pin(page_id)
122
+ layout.pack_into(frame, offset + extra, *values)
123
+ self._buffer.unpin(page_id, dirty=True)
124
+
125
+ def scan(self, layout: struct.Struct) -> Iterator[tuple[int, tuple]]:
126
+ """Yield (index, values) for every record, pinning each page once."""
127
+ index = 0
128
+ page_id = 1
129
+ while index < self._count:
130
+ frame = self._buffer.pin(page_id)
131
+ try:
132
+ in_page = min(self._per_page, self._count - index)
133
+ for slot in range(in_page):
134
+ yield index + slot, layout.unpack_from(frame, slot * self._record_size)
135
+ finally:
136
+ self._buffer.unpin(page_id)
137
+ index += in_page
138
+ page_id += 1
139
+
140
+ def flush(self) -> None:
141
+ self._buffer.flush_all()
142
+
143
+ def _locate(self, index: int) -> tuple[int, int]:
144
+ return 1 + index // self._per_page, (index % self._per_page) * self._record_size
145
+
146
+ def _check(self, index: int) -> int:
147
+ if not 0 <= index < self._count:
148
+ raise IndexError(f"record {index} out of range (count {self._count})")
149
+ return index
150
+
151
+ def _write_header(self) -> None:
152
+ frame = self._buffer.pin(0)
153
+ _HEADER.pack_into(frame, 0, self._magic, _VERSION, self._record_size, self._count)
154
+ self._buffer.unpin(0, dirty=True)
@@ -0,0 +1,68 @@
1
+ """Exact k-NN by scanning every stored vector.
2
+
3
+ It is the sequential baseline of the comparison 2.4.4 and the oracle the
4
+ recall of IVF and HNSW is measured against.
5
+ """
6
+
7
+ import heapq
8
+ from collections.abc import Sequence
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from engine.common.rid import RID
13
+ from engine.indexes.base import RankedIndex
14
+ from soupdb_vector.indexes.constants import DEFAULT_METRIC, VECTOR_BUFFER_CAPACITY
15
+ from soupdb_vector.indexes.metrics import norm, score_fn
16
+ from soupdb_vector.indexes.store import VectorStore
17
+
18
+
19
+ class BruteForceIndex(RankedIndex):
20
+ """RankedIndex that scores every vector; memory is O(k), not O(N)."""
21
+
22
+ def __init__(self, store: VectorStore) -> None:
23
+ self._store = store
24
+
25
+ @classmethod
26
+ def open(
27
+ cls,
28
+ path: str | Path,
29
+ dim: int | None = None,
30
+ *,
31
+ page_size: int = 4096,
32
+ buffer_capacity: int = VECTOR_BUFFER_CAPACITY,
33
+ ) -> "BruteForceIndex":
34
+ return cls(
35
+ VectorStore.open(path, dim, page_size=page_size, buffer_capacity=buffer_capacity)
36
+ )
37
+
38
+ @property
39
+ def store(self) -> VectorStore:
40
+ return self._store
41
+
42
+ def __len__(self) -> int:
43
+ return len(self._store)
44
+
45
+ def insert(self, vector: Sequence[float], rid: RID) -> None:
46
+ self._store.append(vector, rid)
47
+
48
+ def top_k(
49
+ self, query: Sequence[float], k: int, *, metric: str = DEFAULT_METRIC
50
+ ) -> list[tuple[RID, float]]:
51
+ if k < 0:
52
+ raise ValueError(f"k must be non-negative, got {k}")
53
+ self._store.check_dim(query)
54
+ scorer = score_fn(metric)
55
+ query_norm = norm(query)
56
+ scored = (
57
+ (scorer(query, query_norm, vector, vector_norm), rid)
58
+ for _vid, rid, vector, vector_norm in self._store.scan()
59
+ )
60
+ best = heapq.nlargest(k, scored, key=_by_score)
61
+ return [(rid, value) for value, rid in best]
62
+
63
+ def close(self) -> None:
64
+ self._store.close()
65
+
66
+
67
+ def _by_score(pair: tuple[float, Any]) -> float:
68
+ return pair[0]
@@ -0,0 +1,34 @@
1
+ """Tunable parameters of the vector indexes.
2
+
3
+ Kept apart so the benchmarks can vary them without touching the structures.
4
+ """
5
+
6
+ #: Métrica por defecto cuando la consulta no indica ``metric``.
7
+ DEFAULT_METRIC = "euclidean"
8
+
9
+ #: Páginas en el buffer pool de cada archivo cuando el índice abre sus propios managers.
10
+ VECTOR_BUFFER_CAPACITY = 64
11
+
12
+ #: Número de listas (centroides) del IVF; la regla habitual es ~sqrt(N).
13
+ IVF_NLIST = 32
14
+
15
+ #: Listas que se visitan por consulta.
16
+ IVF_NPROBE = 4
17
+
18
+ #: Iteraciones de Lloyd al entrenar los centroides.
19
+ IVF_KMEANS_ITERATIONS = 20
20
+
21
+ #: Tope de la muestra con la que se entrena K-Means; acota la memoria del entrenamiento.
22
+ IVF_TRAIN_SAMPLE = 10_000
23
+
24
+ #: Vecinos por nodo en las capas superiores de HNSW; la capa 0 guarda el doble.
25
+ HNSW_M = 16
26
+
27
+ #: Tamaño de la lista de candidatos al insertar.
28
+ HNSW_EF_CONSTRUCTION = 100
29
+
30
+ #: Tamaño de la lista de candidatos al consultar.
31
+ HNSW_EF_SEARCH = 64
32
+
33
+ #: Tope de niveles del grafo; con M=16 un nivel 16 es astronómicamente improbable.
34
+ HNSW_MAX_LEVEL = 16
@@ -0,0 +1,372 @@
1
+ """Hierarchical Navigable Small World graph persisted on pages (Malkov & Yashunin).
2
+
3
+ The index is a directory with three files:
4
+
5
+ - ``vectors.db``: a :class:`VectorStore`; the vid of a vector is its node id.
6
+ - ``layer0.db``: one fixed record per node with its top level, where its upper
7
+ layers start in ``upper.db`` and its up to ``2*M`` neighbors in layer 0.
8
+ - ``upper.db``: one fixed record per (node, layer >= 1) with up to ``M``
9
+ neighbors. A node of level ``L`` owns ``L`` consecutive records.
10
+
11
+ Fixed-size records make every neighbor list addressable by arithmetic, so a
12
+ hop of the greedy search costs one page pin.
13
+ """
14
+
15
+ import heapq
16
+ import math
17
+ import random
18
+ import struct
19
+ from collections.abc import Sequence
20
+ from pathlib import Path
21
+
22
+ from engine.common.rid import RID
23
+ from engine.indexes.base import RankedIndex
24
+ from engine.storage.buffer_manager import BufferManager
25
+ from engine.storage.disk_manager import DiskManager
26
+ from soupdb_vector.indexes._record_file import FixedRecordFile, peek_meta
27
+ from soupdb_vector.indexes.constants import (
28
+ DEFAULT_METRIC,
29
+ HNSW_EF_CONSTRUCTION,
30
+ HNSW_EF_SEARCH,
31
+ HNSW_M,
32
+ HNSW_MAX_LEVEL,
33
+ VECTOR_BUFFER_CAPACITY,
34
+ )
35
+ from soupdb_vector.indexes.metrics import METRICS, ScoreFn, norm, score_fn
36
+ from soupdb_vector.indexes.store import VectorStore
37
+
38
+ _LAYER0_MAGIC = b"SHN0"
39
+ _UPPER_MAGIC = b"SHNU"
40
+ #: M, ef_construction, metric, seed, entry point, max level.
41
+ _META = struct.Struct("<HHBQqi")
42
+ _NODE_HEAD = struct.Struct("<BqH")
43
+ _UPPER_HEAD = struct.Struct("<H")
44
+ _NO_NODE = -1
45
+
46
+
47
+ class HNSWIndex(RankedIndex):
48
+ """HNSW over pages; every node, vector and neighbor list lives on disk."""
49
+
50
+ def __init__(
51
+ self,
52
+ path: str | Path,
53
+ dim: int | None = None,
54
+ *,
55
+ m: int = HNSW_M,
56
+ ef_construction: int = HNSW_EF_CONSTRUCTION,
57
+ metric: str = DEFAULT_METRIC,
58
+ seed: int = 0,
59
+ page_size: int = 4096,
60
+ buffer_capacity: int = VECTOR_BUFFER_CAPACITY,
61
+ ) -> None:
62
+ """Open (or create) the index stored in the directory ``path``.
63
+
64
+ ``m``, ``ef_construction``, ``metric`` and ``seed`` only apply when the
65
+ index is created; reopening uses the values stored on disk.
66
+ """
67
+ directory = Path(path)
68
+ directory.mkdir(parents=True, exist_ok=True)
69
+ self._closed = False
70
+ self._disks: list[DiskManager] = []
71
+
72
+ vectors_disk = self._disk(directory / "vectors.db", page_size)
73
+ self._store = VectorStore(
74
+ vectors_disk, BufferManager(vectors_disk, capacity=buffer_capacity), dim
75
+ )
76
+ layer0_disk = self._disk(directory / "layer0.db", page_size)
77
+ layer0_buffer = BufferManager(layer0_disk, capacity=buffer_capacity)
78
+
79
+ if layer0_disk.page_count > 0:
80
+ m, ef_construction, metric_code, seed, entry, max_level = peek_meta(
81
+ layer0_buffer, _LAYER0_MAGIC, _META
82
+ )
83
+ metric = METRICS[metric_code]
84
+ else:
85
+ if m < 2:
86
+ raise ValueError(f"m must be at least 2, got {m}")
87
+ score_fn(metric)
88
+ entry, max_level = _NO_NODE, -1
89
+
90
+ self._m = m
91
+ self._m0 = 2 * m
92
+ self._ef_construction = ef_construction
93
+ self._metric = metric
94
+ self._seed = seed
95
+ self._entry = entry
96
+ self._max_level = max_level
97
+ self._insert_cache: dict[int, tuple[tuple[float, ...], float]] | None = None
98
+ # mL = 1/ln(M): así cada nivel tiene ~1/M de los nodos del nivel de abajo.
99
+ self._level_mult = 1.0 / math.log(m)
100
+
101
+ self._layer0_record = struct.Struct(f"{_NODE_HEAD.format}{self._m0}q")
102
+ self._upper_record = struct.Struct(f"{_UPPER_HEAD.format}{m}q")
103
+ self._layer0 = FixedRecordFile(
104
+ layer0_disk,
105
+ layer0_buffer,
106
+ record_size=self._layer0_record.size,
107
+ magic=_LAYER0_MAGIC,
108
+ )
109
+ upper_disk = self._disk(directory / "upper.db", page_size)
110
+ self._upper = FixedRecordFile(
111
+ upper_disk,
112
+ BufferManager(upper_disk, capacity=buffer_capacity),
113
+ record_size=self._upper_record.size,
114
+ magic=_UPPER_MAGIC,
115
+ )
116
+ self._write_meta()
117
+
118
+ @property
119
+ def dim(self) -> int:
120
+ return self._store.dim
121
+
122
+ @property
123
+ def metric(self) -> str:
124
+ return self._metric
125
+
126
+ @property
127
+ def max_level(self) -> int:
128
+ return self._max_level
129
+
130
+ @property
131
+ def disk_managers(self) -> tuple[DiskManager, ...]:
132
+ return tuple(self._disks)
133
+
134
+ def __len__(self) -> int:
135
+ return len(self._store)
136
+
137
+ def insert(self, vector: Sequence[float], rid: RID) -> None:
138
+ # Una inserción vuelve a puntuar los mismos vecinos al elegir y al podar listas;
139
+ # la caché vive solo durante esta inserción, así que la memoria no crece con N.
140
+ self._insert_cache = {}
141
+ try:
142
+ self._insert(vector, rid)
143
+ finally:
144
+ self._insert_cache = None
145
+
146
+ def _insert(self, vector: Sequence[float], rid: RID) -> None:
147
+ node = self._store.append(vector, rid)
148
+ query, query_norm = self._vector(node)
149
+ level = self._random_level(node)
150
+
151
+ upper_start = len(self._upper)
152
+ for _ in range(level):
153
+ self._upper.append(self._upper_record, 0, *self._padded([], self._m))
154
+ self._layer0.append(self._layer0_record, level, upper_start, 0, *self._padded([], self._m0))
155
+
156
+ if self._entry == _NO_NODE:
157
+ self._entry, self._max_level = node, level
158
+ self._write_meta()
159
+ return
160
+
161
+ scorer = score_fn(self._metric)
162
+ entry_points = [(self._score(scorer, query, query_norm, self._entry), self._entry)]
163
+ for layer in range(self._max_level, level, -1):
164
+ entry_points = self._search_layer(scorer, query, query_norm, entry_points, 1, layer)
165
+
166
+ for layer in range(min(level, self._max_level), -1, -1):
167
+ candidates = self._search_layer(
168
+ scorer, query, query_norm, entry_points, self._ef_construction, layer
169
+ )
170
+ capacity = self._capacity(layer)
171
+ chosen = self._select_neighbors(scorer, candidates, capacity)
172
+ self._write_neighbors(node, layer, [neighbor for _s, neighbor in chosen])
173
+ for link_score, neighbor in chosen:
174
+ self._link(scorer, neighbor, node, link_score, layer)
175
+ entry_points = candidates
176
+
177
+ if level > self._max_level:
178
+ self._entry, self._max_level = node, level
179
+ self._write_meta()
180
+
181
+ def top_k(
182
+ self,
183
+ query: Sequence[float],
184
+ k: int,
185
+ *,
186
+ metric: str | None = None,
187
+ ef: int = HNSW_EF_SEARCH,
188
+ ) -> list[tuple[RID, float]]:
189
+ if k < 0:
190
+ raise ValueError(f"k must be non-negative, got {k}")
191
+ if ef <= 0:
192
+ raise ValueError(f"ef must be positive, got {ef}")
193
+ self._store.check_dim(query)
194
+ scorer = score_fn(metric or self._metric)
195
+ if k == 0 or self._entry == _NO_NODE:
196
+ return []
197
+
198
+ query_norm = norm(query)
199
+ entry_points = [(self._score(scorer, query, query_norm, self._entry), self._entry)]
200
+ for layer in range(self._max_level, 0, -1):
201
+ entry_points = self._search_layer(scorer, query, query_norm, entry_points, 1, layer)
202
+ found = self._search_layer(scorer, query, query_norm, entry_points, max(ef, k), 0)
203
+ return [(self._store.rid(node), value) for value, node in found[:k]]
204
+
205
+ def neighbors(self, node: int, layer: int) -> list[int]:
206
+ """Neighbor ids of a node in a layer; exposed for tests and the report."""
207
+ if layer == 0:
208
+ values = self._layer0.unpack(node, self._layer0_record)
209
+ return list(values[3 : 3 + values[2]])
210
+ values = self._upper.unpack(self._upper_index(node, layer), self._upper_record)
211
+ return list(values[1 : 1 + values[0]])
212
+
213
+ def level(self, node: int) -> int:
214
+ return self._layer0.unpack(node, _NODE_HEAD)[0]
215
+
216
+ def close(self) -> None:
217
+ if self._closed:
218
+ return
219
+ self._closed = True
220
+ self._write_meta()
221
+ self._store.flush()
222
+ self._layer0.flush()
223
+ self._upper.flush()
224
+ for disk in self._disks:
225
+ disk.close()
226
+
227
+ # --- internals ---------------------------------------------------------
228
+
229
+ def _disk(self, path: Path, page_size: int) -> DiskManager:
230
+ disk = DiskManager(path, page_size=page_size)
231
+ self._disks.append(disk)
232
+ return disk
233
+
234
+ def _write_meta(self) -> None:
235
+ self._layer0.write_meta(
236
+ _META,
237
+ self._m,
238
+ self._ef_construction,
239
+ METRICS.index(self._metric),
240
+ self._seed,
241
+ self._entry,
242
+ self._max_level,
243
+ )
244
+
245
+ def _random_level(self, node: int) -> int:
246
+ # Semilla derivada del nodo: el nivel es reproducible aunque se cierre y reabra.
247
+ uniform = random.Random(self._seed * 1_000_003 + node).random()
248
+ return min(int(-math.log(1.0 - uniform) * self._level_mult), HNSW_MAX_LEVEL)
249
+
250
+ def _capacity(self, layer: int) -> int:
251
+ return self._m0 if layer == 0 else self._m
252
+
253
+ def _upper_index(self, node: int, layer: int) -> int:
254
+ level, start, _count = self._layer0.unpack(node, _NODE_HEAD)
255
+ if not 1 <= layer <= level:
256
+ raise ValueError(f"node {node} has no layer {layer}")
257
+ return start + layer - 1
258
+
259
+ def _padded(self, neighbors: list[int], capacity: int) -> list[int]:
260
+ return neighbors + [_NO_NODE] * (capacity - len(neighbors))
261
+
262
+ def _write_neighbors(self, node: int, layer: int, neighbors: list[int]) -> None:
263
+ if layer == 0:
264
+ level, start, _count = self._layer0.unpack(node, _NODE_HEAD)
265
+ self._layer0.pack(
266
+ node,
267
+ self._layer0_record,
268
+ level,
269
+ start,
270
+ len(neighbors),
271
+ *self._padded(neighbors, self._m0),
272
+ )
273
+ return
274
+ record = self._upper_index(node, layer)
275
+ self._upper.pack(
276
+ record, self._upper_record, len(neighbors), *self._padded(neighbors, self._m)
277
+ )
278
+
279
+ def _vector(self, node: int) -> tuple[tuple[float, ...], float]:
280
+ cache = self._insert_cache
281
+ if cache is None:
282
+ return self._store.vector(node)
283
+ cached = cache.get(node)
284
+ if cached is None:
285
+ cached = cache[node] = self._store.vector(node)
286
+ return cached
287
+
288
+ def _score(
289
+ self, scorer: ScoreFn, query: Sequence[float], query_norm: float, node: int
290
+ ) -> float:
291
+ vector, vector_norm = self._vector(node)
292
+ return scorer(query, query_norm, vector, vector_norm)
293
+
294
+ def _search_layer(
295
+ self,
296
+ scorer: ScoreFn,
297
+ query: Sequence[float],
298
+ query_norm: float,
299
+ entry_points: list[tuple[float, int]],
300
+ ef: int,
301
+ layer: int,
302
+ ) -> list[tuple[float, int]]:
303
+ """Best-first search in one layer; returns up to ef (score, node), best first."""
304
+ visited = {node for _score, node in entry_points}
305
+ # candidates: max-heap por score (negado); results: min-heap con los ef mejores.
306
+ candidates = [(-value, node) for value, node in entry_points]
307
+ heapq.heapify(candidates)
308
+ results = list(entry_points)
309
+ heapq.heapify(results)
310
+ while len(results) > ef:
311
+ heapq.heappop(results)
312
+
313
+ while candidates:
314
+ negated, node = heapq.heappop(candidates)
315
+ if -negated < results[0][0] and len(results) >= ef:
316
+ break
317
+ for neighbor in self.neighbors(node, layer):
318
+ if neighbor in visited:
319
+ continue
320
+ visited.add(neighbor)
321
+ value = self._score(scorer, query, query_norm, neighbor)
322
+ if len(results) < ef or value > results[0][0]:
323
+ heapq.heappush(candidates, (-value, neighbor))
324
+ heapq.heappush(results, (value, neighbor))
325
+ if len(results) > ef:
326
+ heapq.heappop(results)
327
+ return sorted(results, reverse=True)
328
+
329
+ def _select_neighbors(
330
+ self, scorer: ScoreFn, candidates: list[tuple[float, int]], capacity: int
331
+ ) -> list[tuple[float, int]]:
332
+ """Neighbor-selection heuristic (algorithm 4) with keepPrunedConnections.
333
+
334
+ A candidate is kept only if it is closer to the base node than to every
335
+ neighbor already kept; this favors links in different directions, which
336
+ is what keeps clustered data navigable. Pruned ones fill the remaining slots.
337
+ """
338
+ if len(candidates) <= capacity:
339
+ return list(candidates)
340
+ kept: list[tuple[float, int]] = []
341
+ kept_vectors: list[tuple[Sequence[float], float]] = []
342
+ pruned: list[tuple[float, int]] = []
343
+ for value, node in candidates:
344
+ if len(kept) >= capacity:
345
+ break
346
+ vector, vector_norm = self._vector(node)
347
+ if all(scorer(vector, vector_norm, o, o_norm) < value for o, o_norm in kept_vectors):
348
+ kept.append((value, node))
349
+ kept_vectors.append((vector, vector_norm))
350
+ else:
351
+ pruned.append((value, node))
352
+ for pair in pruned:
353
+ if len(kept) >= capacity:
354
+ break
355
+ kept.append(pair)
356
+ return kept
357
+
358
+ def _link(
359
+ self, scorer: ScoreFn, node: int, new_neighbor: int, link_score: float, layer: int
360
+ ) -> None:
361
+ """Add a back-link node -> new_neighbor, shrinking the list if it overflows."""
362
+ current = self.neighbors(node, layer)
363
+ capacity = self._capacity(layer)
364
+ if len(current) < capacity:
365
+ self._write_neighbors(node, layer, current + [new_neighbor])
366
+ return
367
+ base, base_norm = self._vector(node)
368
+ scored = [(self._score(scorer, base, base_norm, other), other) for other in current]
369
+ scored.append((link_score, new_neighbor))
370
+ scored.sort(reverse=True)
371
+ chosen = self._select_neighbors(scorer, scored, capacity)
372
+ self._write_neighbors(node, layer, [other for _s, other in chosen])