@harperfast/hnsw 0.1.0 → 0.2.1
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.
- package/Cargo.lock +1 -0
- package/DESIGN.md +95 -9
- package/README.md +16 -6
- package/build.mjs +7 -6
- package/index.d.ts +42 -1
- package/index.js +39 -18
- package/package.json +8 -3
- package/src/bin/bench.rs +1 -1
- package/src/format.rs +241 -19
- package/src/graph.rs +263 -69
- package/src/insert.rs +141 -40
- package/src/invalidate.rs +336 -0
- package/src/lib.rs +3 -1
- package/src/napi.rs +92 -4
- package/src/search.rs +413 -87
- package/prebuilds/darwin-arm64/hnsw-plane.node +0 -0
- package/prebuilds/linux-arm64/hnsw-plane.node +0 -0
- package/prebuilds/linux-x64/hnsw-plane.node +0 -0
- package/prebuilds/win32-x64/hnsw-plane.node +0 -0
package/src/search.rs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
//! array; neighbor ids stream through a reusable scratch buffer.
|
|
4
4
|
|
|
5
5
|
use crate::distance::Query;
|
|
6
|
-
use crate::format::NO_ID;
|
|
6
|
+
use crate::format::{MAX_UPPER_LEVELS, NO_ID};
|
|
7
7
|
use crate::graph::Graph;
|
|
8
8
|
use std::cmp::Ordering as CmpOrdering;
|
|
9
9
|
use std::collections::BinaryHeap;
|
|
@@ -49,11 +49,21 @@ pub struct SearchScratch {
|
|
|
49
49
|
visited: Vec<u32>,
|
|
50
50
|
epoch: u32,
|
|
51
51
|
neighbors: Vec<u32>,
|
|
52
|
+
candidates: BinaryHeap<Candidate>,
|
|
53
|
+
results: BinaryHeap<Result_>,
|
|
54
|
+
descent_out: Vec<(u32, f32)>,
|
|
52
55
|
}
|
|
53
56
|
|
|
54
57
|
impl SearchScratch {
|
|
55
58
|
pub fn new() -> Self {
|
|
56
|
-
SearchScratch {
|
|
59
|
+
SearchScratch {
|
|
60
|
+
visited: Vec::new(),
|
|
61
|
+
epoch: 0,
|
|
62
|
+
neighbors: Vec::new(),
|
|
63
|
+
candidates: BinaryHeap::new(),
|
|
64
|
+
results: BinaryHeap::new(),
|
|
65
|
+
descent_out: Vec::new(),
|
|
66
|
+
}
|
|
57
67
|
}
|
|
58
68
|
|
|
59
69
|
pub fn begin_public(&mut self, capacity: u64) {
|
|
@@ -109,10 +119,14 @@ fn bit_allowed(filter: Option<&[u8]>, id: u32) -> bool {
|
|
|
109
119
|
}
|
|
110
120
|
}
|
|
111
121
|
|
|
112
|
-
/// Beam search within one layer, starting from `entry`. Level 0 reads slot adjacency;
|
|
113
|
-
///
|
|
122
|
+
/// Beam search within one layer, starting from `entry`. Level 0 reads slot adjacency; upper
|
|
123
|
+
/// levels read the resident upper map. Fills `out` with (id, distance) ascending by distance.
|
|
114
124
|
/// Assumes scratch.begin() was called for this query; entry is marked visited here.
|
|
115
125
|
///
|
|
126
|
+
/// `out` is the caller's so the descent can reuse one buffer across all levels. It is filled by
|
|
127
|
+
/// one `extend` off an exact-size drain, so it takes at most one allocation sized to the results
|
|
128
|
+
/// actually found — never to `ef`, which arrives unvalidated from the NAPI boundary.
|
|
129
|
+
///
|
|
116
130
|
/// `filter`: optional allow-bitset over node ids (bit i of byte i>>3). Filtered-out nodes
|
|
117
131
|
/// are traversed (their edges route) but excluded from results — ACORN-style — with
|
|
118
132
|
/// `visit_budget` bounding total visits so a selective filter terminates.
|
|
@@ -127,18 +141,21 @@ pub fn search_layer(
|
|
|
127
141
|
stats: &mut SearchStats,
|
|
128
142
|
filter: Option<&[u8]>,
|
|
129
143
|
visit_budget: u64,
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
144
|
+
out: &mut Vec<(u32, f32)>,
|
|
145
|
+
) {
|
|
146
|
+
// take() the scratch buffers to sidestep the double-borrow of scratch
|
|
147
|
+
let mut candidates = std::mem::take(&mut scratch.candidates);
|
|
148
|
+
let mut results = std::mem::take(&mut scratch.results);
|
|
149
|
+
let mut nbuf = std::mem::take(&mut scratch.neighbors);
|
|
150
|
+
candidates.clear();
|
|
151
|
+
results.clear();
|
|
152
|
+
|
|
133
153
|
scratch.visit(entry);
|
|
134
154
|
candidates.push(Candidate { distance: entry_dist, id: entry });
|
|
135
155
|
if bit_allowed(filter, entry) {
|
|
136
156
|
results.push(Result_ { distance: entry_dist, id: entry });
|
|
137
157
|
}
|
|
138
158
|
|
|
139
|
-
// take() the scratch neighbor buffer to sidestep the double-borrow of scratch
|
|
140
|
-
let mut nbuf = std::mem::take(&mut scratch.neighbors);
|
|
141
|
-
|
|
142
159
|
while let Some(c) = candidates.pop() {
|
|
143
160
|
let worst = results.peek().map(|r| r.distance).unwrap_or(f32::INFINITY);
|
|
144
161
|
if results.len() >= ef && c.distance > worst {
|
|
@@ -177,49 +194,92 @@ pub fn search_layer(
|
|
|
177
194
|
}
|
|
178
195
|
}
|
|
179
196
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
let mut out: Vec<(u32, f32)> = results.into_iter().map(|r| (r.id, r.distance)).collect();
|
|
197
|
+
out.clear();
|
|
198
|
+
out.extend(results.drain().map(|r| (r.id, r.distance)));
|
|
183
199
|
out.sort_by(|a, b| a.1.total_cmp(&b.1));
|
|
184
|
-
|
|
200
|
+
|
|
201
|
+
candidates.clear();
|
|
202
|
+
scratch.neighbors = nbuf;
|
|
203
|
+
scratch.candidates = candidates;
|
|
204
|
+
scratch.results = results;
|
|
185
205
|
}
|
|
186
206
|
|
|
187
|
-
///
|
|
188
|
-
///
|
|
189
|
-
|
|
207
|
+
/// Beam width at every upper level of the descent. A width of 1 is hill climbing, which halts in
|
|
208
|
+
/// the first basin no neighbor improves on; layer-0 adjacency is intra-basin, so a query that
|
|
209
|
+
/// lands in the wrong one has no uphill edge out at any ef. See DESIGN.md §7 for the width sweep.
|
|
210
|
+
pub const DESCENT_EF: usize = 16;
|
|
211
|
+
|
|
212
|
+
/// Beam descent through upper layers from `from_level` down to `to_level` (exclusive), each level
|
|
213
|
+
/// a width-`ef` beam seeded by the level above's best. Returns the entry for the caller's
|
|
214
|
+
/// layer-`to_level` search, which the caller must `begin()` a fresh epoch for.
|
|
215
|
+
///
|
|
216
|
+
/// A node reachable at several levels must be expandable at each, so the epoch rolls per level.
|
|
217
|
+
pub fn beam_descend(
|
|
190
218
|
graph: &Graph,
|
|
191
219
|
query: &Query,
|
|
192
220
|
mut current: u32,
|
|
193
221
|
mut current_dist: f32,
|
|
194
222
|
from_level: u32,
|
|
195
223
|
to_level: u32,
|
|
224
|
+
ef: usize,
|
|
225
|
+
scratch: &mut SearchScratch,
|
|
196
226
|
stats: &mut SearchStats,
|
|
197
227
|
) -> (u32, f32) {
|
|
198
|
-
let mut
|
|
199
|
-
let mut level = from_level;
|
|
228
|
+
let mut found = std::mem::take(&mut scratch.descent_out);
|
|
229
|
+
let mut level = from_level.min(MAX_UPPER_LEVELS as u32);
|
|
200
230
|
while level > to_level {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
if d < current_dist {
|
|
210
|
-
current = nid;
|
|
211
|
-
current_dist = d;
|
|
212
|
-
improved = true;
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
}
|
|
231
|
+
scratch.begin(graph.file.id_high_water());
|
|
232
|
+
search_layer(
|
|
233
|
+
graph, query, current, current_dist, ef, level as u8, scratch, stats, None, u64::MAX, &mut found,
|
|
234
|
+
);
|
|
235
|
+
// search_layer admits the entry itself, so `first` is never worse than what went in
|
|
236
|
+
if let Some(&(id, d)) = found.first() {
|
|
237
|
+
current = id;
|
|
238
|
+
current_dist = d;
|
|
216
239
|
}
|
|
217
240
|
level -= 1;
|
|
218
241
|
}
|
|
242
|
+
scratch.descent_out = found;
|
|
219
243
|
(current, current_dist)
|
|
220
244
|
}
|
|
221
245
|
|
|
222
|
-
///
|
|
246
|
+
/// Slots a read-side repair may probe when the previous-entry hint is dead too. Bounded so a
|
|
247
|
+
/// search never pays the write path's O(high-water) re-election scan.
|
|
248
|
+
const REPAIR_PROBE_LIMIT: u32 = 1024;
|
|
249
|
+
|
|
250
|
+
/// Resolve a live entry point for a read, repairing a dead one in place.
|
|
251
|
+
///
|
|
252
|
+
/// A search that finds the header naming a deleted or sanitized node returns EMPTY, and on a
|
|
253
|
+
/// read-mostly table nothing ever repairs it: write-path re-election only runs on delete, and
|
|
254
|
+
/// a slot a reader sanitized after its writer died had no delete at all.
|
|
255
|
+
///
|
|
256
|
+
/// The candidate is the O(1) previous-entry hint, then a probe capped at `REPAIR_PROBE_LIMIT` —
|
|
257
|
+
/// the hint is a single slot and can be dead itself. The cap is what keeps a read off the write
|
|
258
|
+
/// path's O(high-water) scan on the pool thread every search shares, and the repair publishes,
|
|
259
|
+
/// so only the first search after a wedge pays even the probe.
|
|
260
|
+
fn resolve_entry(graph: &Graph, query: &Query, stats: &mut SearchStats) -> Option<(u32, u32, f32)> {
|
|
261
|
+
let (entry_id, entry_level) = graph.file.entry_point();
|
|
262
|
+
if entry_id != NO_ID {
|
|
263
|
+
if let Some(d) = graph.distance_to(entry_id, query) {
|
|
264
|
+
stats.visits += 1;
|
|
265
|
+
return Some((entry_id, entry_level, d));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
let hint = graph.file.previous_entry_point();
|
|
269
|
+
let candidate = (hint != NO_ID && hint != entry_id)
|
|
270
|
+
.then(|| graph.node_level(hint).map(|level| (hint, level)))
|
|
271
|
+
.flatten()
|
|
272
|
+
.or_else(|| graph.probe_for_entry(REPAIR_PROBE_LIMIT, entry_id));
|
|
273
|
+
let (id, level) = candidate?;
|
|
274
|
+
let d = graph.distance_to(id, query)?;
|
|
275
|
+
stats.visits += 1;
|
|
276
|
+
// Strict on the entry we observed dead, not a not-worse install: a live level-0 root claimed
|
|
277
|
+
// since the read above must win, or it is orphaned with nothing pointing at it.
|
|
278
|
+
graph.file.replace_entry_if(entry_id, id, level as u32);
|
|
279
|
+
Some((id, level as u32, d))
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/// Full search: beam descent through upper layers, then the layer-0 beam at `ef`.
|
|
223
283
|
pub fn search(
|
|
224
284
|
graph: &Graph,
|
|
225
285
|
query: &Query,
|
|
@@ -228,21 +288,14 @@ pub fn search(
|
|
|
228
288
|
scratch: &mut SearchScratch,
|
|
229
289
|
) -> (Vec<(u32, f32)>, SearchStats) {
|
|
230
290
|
let mut stats = SearchStats { visits: 0 };
|
|
231
|
-
let (entry_id, entry_level) = graph
|
|
232
|
-
if entry_id == NO_ID {
|
|
291
|
+
let Some((entry_id, entry_level, entry_dist)) = resolve_entry(graph, query, &mut stats) else {
|
|
233
292
|
return (Vec::new(), stats);
|
|
234
|
-
}
|
|
235
|
-
scratch.begin(graph.file.id_high_water());
|
|
236
|
-
|
|
237
|
-
let entry_dist = match graph.distance_to(entry_id, query) {
|
|
238
|
-
Some(d) => {
|
|
239
|
-
stats.visits += 1;
|
|
240
|
-
d
|
|
241
|
-
}
|
|
242
|
-
None => return (Vec::new(), stats),
|
|
243
293
|
};
|
|
244
|
-
let (ep, ep_dist) =
|
|
245
|
-
|
|
294
|
+
let (ep, ep_dist) =
|
|
295
|
+
beam_descend(graph, query, entry_id, entry_dist, entry_level, 0, DESCENT_EF, scratch, &mut stats);
|
|
296
|
+
scratch.begin(graph.file.id_high_water());
|
|
297
|
+
let mut out = Vec::new();
|
|
298
|
+
search_layer(graph, query, ep, ep_dist, ef, 0, scratch, &mut stats, None, u64::MAX, &mut out);
|
|
246
299
|
out.truncate(k);
|
|
247
300
|
(out, stats)
|
|
248
301
|
}
|
|
@@ -259,21 +312,19 @@ pub fn search_filtered(
|
|
|
259
312
|
scratch: &mut SearchScratch,
|
|
260
313
|
) -> (Vec<(u32, f32)>, SearchStats) {
|
|
261
314
|
let mut stats = SearchStats { visits: 0 };
|
|
262
|
-
let (entry_id, entry_level) = graph
|
|
263
|
-
if entry_id == NO_ID {
|
|
315
|
+
let Some((entry_id, entry_level, entry_dist)) = resolve_entry(graph, query, &mut stats) else {
|
|
264
316
|
return (Vec::new(), stats);
|
|
265
|
-
}
|
|
317
|
+
};
|
|
318
|
+
let (ep, ep_dist) =
|
|
319
|
+
beam_descend(graph, query, entry_id, entry_dist, entry_level, 0, DESCENT_EF, scratch, &mut stats);
|
|
266
320
|
scratch.begin_public(graph.file.id_high_water());
|
|
267
|
-
let
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
}
|
|
272
|
-
None => return (Vec::new(), stats),
|
|
321
|
+
let budget = if filter.is_some() {
|
|
322
|
+
stats.visits.saturating_add((ef * filter_expansion) as u64)
|
|
323
|
+
} else {
|
|
324
|
+
u64::MAX
|
|
273
325
|
};
|
|
274
|
-
let
|
|
275
|
-
|
|
276
|
-
let mut out = search_layer(graph, query, ep, ep_dist, ef, 0, scratch, &mut stats, filter, budget);
|
|
326
|
+
let mut out = Vec::new();
|
|
327
|
+
search_layer(graph, query, ep, ep_dist, ef, 0, scratch, &mut stats, filter, budget, &mut out);
|
|
277
328
|
out.truncate(k);
|
|
278
329
|
(out, stats)
|
|
279
330
|
}
|
|
@@ -284,8 +335,11 @@ pub fn search_filtered(
|
|
|
284
335
|
/// steer result admission only; routing uses pure distance order, bounded by the visit
|
|
285
336
|
/// budget, so a slow or saturated JS loop degrades speculative overshoot, not correctness.
|
|
286
337
|
pub struct PredicatePipe {
|
|
287
|
-
/// Sends one batch of ids for evaluation. Must not block.
|
|
288
|
-
|
|
338
|
+
/// Sends one batch of ids for evaluation. Must not block. Returns whether the batch was
|
|
339
|
+
/// actually handed off: a refused enqueue never produces a verdict, so counting it as
|
|
340
|
+
/// outstanding would make the tail drain wait out its whole deadline for an answer that
|
|
341
|
+
/// cannot arrive.
|
|
342
|
+
pub dispatch: Box<dyn FnMut(Vec<u32>) -> bool + Send>,
|
|
289
343
|
/// Receives (ids, verdicts) pairs; verdicts[i] != 0 admits ids[i].
|
|
290
344
|
pub rx: std::sync::mpsc::Receiver<(Vec<u32>, Vec<u8>)>,
|
|
291
345
|
}
|
|
@@ -307,19 +361,13 @@ pub fn search_predicated(
|
|
|
307
361
|
scratch: &mut SearchScratch,
|
|
308
362
|
) -> (Vec<(u32, f32)>, SearchStats) {
|
|
309
363
|
let mut stats = SearchStats { visits: 0 };
|
|
310
|
-
let (entry_id, entry_level) = graph
|
|
311
|
-
if entry_id == NO_ID {
|
|
364
|
+
let Some((entry_id, entry_level, entry_dist)) = resolve_entry(graph, query, &mut stats) else {
|
|
312
365
|
return (Vec::new(), stats);
|
|
313
|
-
}
|
|
314
|
-
scratch.begin_public(graph.file.id_high_water());
|
|
315
|
-
let entry_dist = match graph.distance_to(entry_id, query) {
|
|
316
|
-
Some(d) => {
|
|
317
|
-
stats.visits += 1;
|
|
318
|
-
d
|
|
319
|
-
}
|
|
320
|
-
None => return (Vec::new(), stats),
|
|
321
366
|
};
|
|
322
|
-
let (ep, ep_dist) =
|
|
367
|
+
let (ep, ep_dist) =
|
|
368
|
+
beam_descend(graph, query, entry_id, entry_dist, entry_level, 0, DESCENT_EF, scratch, &mut stats);
|
|
369
|
+
scratch.begin_public(graph.file.id_high_water());
|
|
370
|
+
let layer0_budget = stats.visits.saturating_add(visit_budget);
|
|
323
371
|
|
|
324
372
|
use std::collections::HashMap;
|
|
325
373
|
let mut verdicts: HashMap<u32, bool> = HashMap::new();
|
|
@@ -327,18 +375,26 @@ pub fn search_predicated(
|
|
|
327
375
|
let mut batch: Vec<u32> = Vec::new();
|
|
328
376
|
let mut outstanding = 0usize;
|
|
329
377
|
|
|
330
|
-
|
|
331
|
-
|
|
378
|
+
// this path hand-rolls the layer-0 beam because admission waits on a verdict rather than
|
|
379
|
+
// being decided at expansion time
|
|
380
|
+
let mut candidates = std::mem::take(&mut scratch.candidates);
|
|
381
|
+
let mut results = std::mem::take(&mut scratch.results);
|
|
382
|
+
let mut nbuf = std::mem::take(&mut scratch.neighbors);
|
|
383
|
+
candidates.clear();
|
|
384
|
+
results.clear();
|
|
385
|
+
|
|
332
386
|
scratch.visit(ep);
|
|
333
387
|
candidates.push(Candidate { distance: ep_dist, id: ep });
|
|
334
388
|
speculative.push((ep, ep_dist));
|
|
335
389
|
batch.push(ep);
|
|
336
390
|
|
|
337
|
-
|
|
338
|
-
|
|
391
|
+
// guarded on `outstanding` rather than draining until the channel is empty: with a blocking
|
|
392
|
+
// receive the unguarded form pays another full timeout after the last verdict lands, on every
|
|
393
|
+
// filtered query
|
|
339
394
|
macro_rules! drain {
|
|
340
395
|
($recv:expr) => {
|
|
341
|
-
while
|
|
396
|
+
while outstanding > 0 {
|
|
397
|
+
let Ok((ids, flags)) = $recv else { break };
|
|
342
398
|
outstanding -= 1;
|
|
343
399
|
for (i, id) in ids.iter().enumerate() {
|
|
344
400
|
verdicts.insert(*id, flags.get(i).copied().unwrap_or(0) != 0);
|
|
@@ -369,7 +425,7 @@ pub fn search_predicated(
|
|
|
369
425
|
if results.len() >= ef && c.distance > worst {
|
|
370
426
|
break;
|
|
371
427
|
}
|
|
372
|
-
if stats.visits >=
|
|
428
|
+
if stats.visits >= layer0_budget {
|
|
373
429
|
break;
|
|
374
430
|
}
|
|
375
431
|
if graph.neighbors_into(c.id, &mut nbuf).is_none() {
|
|
@@ -390,8 +446,7 @@ pub fn search_predicated(
|
|
|
390
446
|
candidates.push(Candidate { distance: d, id: nid });
|
|
391
447
|
speculative.push((nid, d));
|
|
392
448
|
batch.push(nid);
|
|
393
|
-
if batch.len() >= PREDICATE_BATCH {
|
|
394
|
-
(pipe.dispatch)(std::mem::take(&mut batch));
|
|
449
|
+
if batch.len() >= PREDICATE_BATCH && (pipe.dispatch)(std::mem::take(&mut batch)) {
|
|
395
450
|
outstanding += 1;
|
|
396
451
|
}
|
|
397
452
|
}
|
|
@@ -401,8 +456,7 @@ pub fn search_predicated(
|
|
|
401
456
|
scratch.neighbors = nbuf;
|
|
402
457
|
|
|
403
458
|
// flush the tail batch and block-drain what's still in flight
|
|
404
|
-
if !batch.is_empty() {
|
|
405
|
-
(pipe.dispatch)(std::mem::take(&mut batch));
|
|
459
|
+
if !batch.is_empty() && (pipe.dispatch)(std::mem::take(&mut batch)) {
|
|
406
460
|
outstanding += 1;
|
|
407
461
|
}
|
|
408
462
|
let deadline = std::time::Instant::now() + DRAIN_TIMEOUT;
|
|
@@ -419,12 +473,188 @@ pub fn search_predicated(
|
|
|
419
473
|
false
|
|
420
474
|
});
|
|
421
475
|
|
|
422
|
-
let mut out: Vec<(u32, f32)> = results.
|
|
476
|
+
let mut out: Vec<(u32, f32)> = results.drain().map(|r| (r.id, r.distance)).collect();
|
|
423
477
|
out.sort_by(|a, b| a.1.total_cmp(&b.1));
|
|
424
478
|
out.truncate(k);
|
|
479
|
+
|
|
480
|
+
candidates.clear();
|
|
481
|
+
scratch.candidates = candidates;
|
|
482
|
+
scratch.results = results;
|
|
425
483
|
(out, stats)
|
|
426
484
|
}
|
|
427
485
|
|
|
486
|
+
#[cfg(test)]
|
|
487
|
+
mod descent_tests {
|
|
488
|
+
use super::*;
|
|
489
|
+
use crate::format::UPPER_CAP;
|
|
490
|
+
use crate::insert::{insert, InsertParams};
|
|
491
|
+
use crate::PlaneFile;
|
|
492
|
+
|
|
493
|
+
/// The descent takes no caller-supplied visit budget. What bounds it under tied distances is
|
|
494
|
+
/// `search_layer`'s strict `d < worst`: a full result set admits no tied candidate, so the
|
|
495
|
+
/// beam drains after `ef` expansions. Relaxing that to `<=` lets a zero query — which ties
|
|
496
|
+
/// every cosine distance at 1.0, and any caller can send one — walk the whole upper level.
|
|
497
|
+
#[test]
|
|
498
|
+
fn a_tied_distance_descent_stops_at_its_visit_cap() {
|
|
499
|
+
let dims = 16;
|
|
500
|
+
let n = 6_000u32;
|
|
501
|
+
let path = std::env::temp_dir().join(format!("hnsw-tied-{}.hnsw", std::process::id()));
|
|
502
|
+
let _ = std::fs::remove_file(&path);
|
|
503
|
+
let graph = Graph::new(PlaneFile::create(&path, dims, 16, n as u64 + 1024).expect("create"));
|
|
504
|
+
let params = InsertParams::default();
|
|
505
|
+
let mut scratch = SearchScratch::new();
|
|
506
|
+
for i in 0..n {
|
|
507
|
+
let v: Vec<f32> = (0..dims).map(|d| ((i as f32 * 0.31 + d as f32) * 0.7).sin()).collect();
|
|
508
|
+
insert(&graph, &v, ¶ms, &mut scratch).expect("insert");
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
let (entry_id, entry_level) = graph.file.entry_point();
|
|
512
|
+
assert!(entry_level >= 1, "precondition: the graph has an upper level to descend");
|
|
513
|
+
let query = Query::new(vec![0.0f32; dims]);
|
|
514
|
+
let entry_dist = graph.distance_to(entry_id, &query).expect("the entry point is live");
|
|
515
|
+
assert_eq!(entry_dist, 1.0, "precondition: a zero query ties every stored vector at 1.0");
|
|
516
|
+
|
|
517
|
+
let ef = 2usize; // one level, so the bound under test is per-level rather than a sum
|
|
518
|
+
let mut stats = SearchStats { visits: 0 };
|
|
519
|
+
beam_descend(&graph, &query, entry_id, entry_dist, 1, 0, ef, &mut scratch, &mut stats);
|
|
520
|
+
|
|
521
|
+
let ceiling = (ef * UPPER_CAP) as u64;
|
|
522
|
+
assert!(
|
|
523
|
+
stats.visits <= ceiling,
|
|
524
|
+
"the tied-distance descent visited {} nodes at level 1 against a ceiling of {ceiling} — \
|
|
525
|
+
level 1 holds roughly {} nodes, and a beam that pushed tied candidates would walk all of them",
|
|
526
|
+
stats.visits,
|
|
527
|
+
n / 16
|
|
528
|
+
);
|
|
529
|
+
let _ = std::fs::remove_file(&path);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/// `filter_expansion` bounds layer 0, so the budget has to start counting where the descent
|
|
533
|
+
/// left off. Measured against a counter the descent has already advanced, a descent costing
|
|
534
|
+
/// more than the whole budget leaves layer 0 unable to expand even one candidate, and the
|
|
535
|
+
/// search returns its entry point instead of a result set — silently, since a filtered search
|
|
536
|
+
/// is allowed to return short.
|
|
537
|
+
#[test]
|
|
538
|
+
fn the_filter_budget_bounds_layer_zero_not_the_descent() {
|
|
539
|
+
let dims = 16;
|
|
540
|
+
let n = 6_000u32;
|
|
541
|
+
let path = std::env::temp_dir().join(format!("hnsw-budget-{}.hnsw", std::process::id()));
|
|
542
|
+
let _ = std::fs::remove_file(&path);
|
|
543
|
+
let graph = Graph::new(PlaneFile::create(&path, dims, 16, n as u64 + 1024).expect("create"));
|
|
544
|
+
let params = InsertParams::default();
|
|
545
|
+
let mut scratch = SearchScratch::new();
|
|
546
|
+
for i in 0..n {
|
|
547
|
+
let v: Vec<f32> = (0..dims).map(|d| ((i as f32 * 0.31 + d as f32) * 0.7).sin()).collect();
|
|
548
|
+
insert(&graph, &v, ¶ms, &mut scratch).expect("insert");
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
let query = Query::new((0..dims).map(|d| ((97.0f32 * 0.31 + d as f32) * 0.7).sin()).collect());
|
|
552
|
+
let (entry_id, entry_level) = graph.file.entry_point();
|
|
553
|
+
let entry_dist = graph.distance_to(entry_id, &query).expect("the entry point is live");
|
|
554
|
+
let mut descent = SearchStats { visits: 0 };
|
|
555
|
+
beam_descend(&graph, &query, entry_id, entry_dist, entry_level, 0, DESCENT_EF, &mut scratch, &mut descent);
|
|
556
|
+
|
|
557
|
+
// a budget deliberately far below what the descent spends
|
|
558
|
+
let (ef, filter_expansion) = (16usize, 1usize);
|
|
559
|
+
assert!(
|
|
560
|
+
descent.visits > (ef * filter_expansion) as u64,
|
|
561
|
+
"precondition: the descent ({} visits) must cost more than the whole budget ({})",
|
|
562
|
+
descent.visits,
|
|
563
|
+
ef * filter_expansion
|
|
564
|
+
);
|
|
565
|
+
let allow = vec![0xffu8; (n as usize).div_ceil(8)];
|
|
566
|
+
let (hits, stats) =
|
|
567
|
+
search_filtered(&graph, &query, 10, ef, Some(&allow), filter_expansion, &mut scratch);
|
|
568
|
+
|
|
569
|
+
assert_eq!(hits.len(), 10, "layer 0 got no budget of its own: {hits:?}");
|
|
570
|
+
assert!(
|
|
571
|
+
stats.visits > descent.visits,
|
|
572
|
+
"layer 0 expanded nothing beyond the descent ({} total vs {} for the descent alone)",
|
|
573
|
+
stats.visits,
|
|
574
|
+
descent.visits
|
|
575
|
+
);
|
|
576
|
+
let _ = std::fs::remove_file(&path);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/// `ef` arrives from an unvalidated `u32` at the NAPI boundary. Sizing any allocation by it
|
|
580
|
+
/// turns a caller's typo into a request for tens of gigabytes, which `handle_alloc_error`
|
|
581
|
+
/// answers by aborting — uncatchable from JS, and it takes every in-flight query with it.
|
|
582
|
+
#[test]
|
|
583
|
+
fn an_absurd_ef_answers_instead_of_reserving_by_it() {
|
|
584
|
+
let dims = 8;
|
|
585
|
+
let n = 64u32;
|
|
586
|
+
let path = std::env::temp_dir().join(format!("hnsw-absurdef-{}.hnsw", std::process::id()));
|
|
587
|
+
let _ = std::fs::remove_file(&path);
|
|
588
|
+
let graph = Graph::new(PlaneFile::create(&path, dims, 8, n as u64 + 16).expect("create"));
|
|
589
|
+
let params = InsertParams::default();
|
|
590
|
+
let mut scratch = SearchScratch::new();
|
|
591
|
+
for i in 0..n {
|
|
592
|
+
let v: Vec<f32> = (0..dims).map(|d| ((i as f32 * 0.31 + d as f32) * 0.7).sin()).collect();
|
|
593
|
+
insert(&graph, &v, ¶ms, &mut scratch).expect("insert");
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
let query = Query::new((0..dims).map(|d| ((7.0f32 * 0.31 + d as f32) * 0.7).sin()).collect());
|
|
597
|
+
let (hits, _) = search(&graph, &query, 5, u32::MAX as usize, &mut scratch);
|
|
598
|
+
assert_eq!(hits.len(), 5, "an absurd ef must still answer from a {n}-node plane");
|
|
599
|
+
let _ = std::fs::remove_file(&path);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/// The same contract on the predicate path, which carries its own layer-0 loop: a host budget
|
|
603
|
+
/// smaller than the descent must still buy layer-0 visits. `predicate_tests` all pass
|
|
604
|
+
/// `64 * 24`, orders above what a descent costs, so they hold either way.
|
|
605
|
+
#[test]
|
|
606
|
+
fn the_predicate_visit_budget_bounds_layer_zero_not_the_descent() {
|
|
607
|
+
let dims = 16;
|
|
608
|
+
let n = 6_000u32;
|
|
609
|
+
let path = std::env::temp_dir().join(format!("hnsw-predbudget-{}.hnsw", std::process::id()));
|
|
610
|
+
let _ = std::fs::remove_file(&path);
|
|
611
|
+
let graph = Graph::new(PlaneFile::create(&path, dims, 16, n as u64 + 1024).expect("create"));
|
|
612
|
+
let params = InsertParams::default();
|
|
613
|
+
let mut scratch = SearchScratch::new();
|
|
614
|
+
for i in 0..n {
|
|
615
|
+
let v: Vec<f32> = (0..dims).map(|d| ((i as f32 * 0.31 + d as f32) * 0.7).sin()).collect();
|
|
616
|
+
insert(&graph, &v, ¶ms, &mut scratch).expect("insert");
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
let query = Query::new((0..dims).map(|d| ((97.0f32 * 0.31 + d as f32) * 0.7).sin()).collect());
|
|
620
|
+
let (entry_id, entry_level) = graph.file.entry_point();
|
|
621
|
+
let entry_dist = graph.distance_to(entry_id, &query).expect("the entry point is live");
|
|
622
|
+
let mut descent = SearchStats { visits: 0 };
|
|
623
|
+
beam_descend(&graph, &query, entry_id, entry_dist, entry_level, 0, DESCENT_EF, &mut scratch, &mut descent);
|
|
624
|
+
|
|
625
|
+
let budget = 64u64;
|
|
626
|
+
assert!(
|
|
627
|
+
descent.visits > budget,
|
|
628
|
+
"precondition: the descent ({} visits) must cost more than the whole budget ({budget})",
|
|
629
|
+
descent.visits
|
|
630
|
+
);
|
|
631
|
+
|
|
632
|
+
let (req_tx, req_rx) = std::sync::mpsc::channel::<Vec<u32>>();
|
|
633
|
+
let (res_tx, res_rx) = std::sync::mpsc::channel::<(Vec<u32>, Vec<u8>)>();
|
|
634
|
+
let worker = std::thread::spawn(move || {
|
|
635
|
+
while let Ok(ids) = req_rx.recv() {
|
|
636
|
+
let verdicts = vec![1u8; ids.len()];
|
|
637
|
+
if res_tx.send((ids, verdicts)).is_err() {
|
|
638
|
+
break;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
});
|
|
642
|
+
let mut pipe = PredicatePipe { dispatch: Box::new(move |ids| req_tx.send(ids).is_ok()), rx: res_rx };
|
|
643
|
+
let (hits, stats) = search_predicated(&graph, &query, 10, 16, &mut pipe, budget, &mut scratch);
|
|
644
|
+
|
|
645
|
+
assert_eq!(hits.len(), 10, "layer 0 got no budget of its own: {hits:?}");
|
|
646
|
+
assert!(
|
|
647
|
+
stats.visits > descent.visits,
|
|
648
|
+
"layer 0 expanded nothing beyond the descent ({} total vs {} for the descent alone)",
|
|
649
|
+
stats.visits,
|
|
650
|
+
descent.visits
|
|
651
|
+
);
|
|
652
|
+
drop(pipe);
|
|
653
|
+
worker.join().unwrap();
|
|
654
|
+
let _ = std::fs::remove_file(&path);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
428
658
|
#[cfg(test)]
|
|
429
659
|
mod predicate_tests {
|
|
430
660
|
use super::*;
|
|
@@ -458,9 +688,7 @@ mod predicate_tests {
|
|
|
458
688
|
});
|
|
459
689
|
|
|
460
690
|
let mut pipe = PredicatePipe {
|
|
461
|
-
dispatch: Box::new(move |ids|
|
|
462
|
-
let _ = req_tx.send(ids);
|
|
463
|
-
}),
|
|
691
|
+
dispatch: Box::new(move |ids| req_tx.send(ids).is_ok()),
|
|
464
692
|
rx: res_rx,
|
|
465
693
|
};
|
|
466
694
|
let q: Vec<f32> = (0..dims).map(|d| ((41.0f32 * 0.31 + d as f32) * 0.7).sin()).collect();
|
|
@@ -474,4 +702,102 @@ mod predicate_tests {
|
|
|
474
702
|
worker.join().unwrap();
|
|
475
703
|
let _ = std::fs::remove_file(&path);
|
|
476
704
|
}
|
|
705
|
+
|
|
706
|
+
/// The tail drain must stop receiving the moment the last verdict lands. Draining until the
|
|
707
|
+
/// channel reports empty sits out another full `recv_timeout` after `outstanding` reaches
|
|
708
|
+
/// zero — 50 ms added to every filtered query, against a sub-millisecond search. Measured
|
|
709
|
+
/// from the evaluator's last send so the search's own cost is not in the number, and over
|
|
710
|
+
/// the best of several queries so scheduler noise on one of them cannot pass for the extra
|
|
711
|
+
/// receive, which every query would pay.
|
|
712
|
+
#[test]
|
|
713
|
+
fn a_predicated_search_returns_as_soon_as_the_last_verdict_lands() {
|
|
714
|
+
let dims = 32;
|
|
715
|
+
let path = std::env::temp_dir().join(format!("hnsw-preddrain-{}.hnsw", std::process::id()));
|
|
716
|
+
let _ = std::fs::remove_file(&path);
|
|
717
|
+
let graph = Graph::new(PlaneFile::create(&path, dims, 16, 4_096).expect("create"));
|
|
718
|
+
let params = InsertParams::default();
|
|
719
|
+
let mut scratch = SearchScratch::new();
|
|
720
|
+
for i in 0..1_000u32 {
|
|
721
|
+
let v: Vec<f32> = (0..dims).map(|d| ((i as f32 * 0.31 + d as f32) * 0.7).sin()).collect();
|
|
722
|
+
insert(&graph, &v, ¶ms, &mut scratch).unwrap();
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
let (req_tx, req_rx) = std::sync::mpsc::channel::<Vec<u32>>();
|
|
726
|
+
let (res_tx, res_rx) = std::sync::mpsc::channel::<(Vec<u32>, Vec<u8>)>();
|
|
727
|
+
// stamped before the send, so the search can never observe a verdict newer than the stamp
|
|
728
|
+
let last_send = std::sync::Arc::new(std::sync::Mutex::new(None::<std::time::Instant>));
|
|
729
|
+
let stamps = last_send.clone();
|
|
730
|
+
let worker = std::thread::spawn(move || {
|
|
731
|
+
while let Ok(ids) = req_rx.recv() {
|
|
732
|
+
let verdicts = vec![1u8; ids.len()];
|
|
733
|
+
*stamps.lock().unwrap() = Some(std::time::Instant::now());
|
|
734
|
+
if res_tx.send((ids, verdicts)).is_err() {
|
|
735
|
+
break;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
let mut pipe = PredicatePipe {
|
|
741
|
+
dispatch: Box::new(move |ids| req_tx.send(ids).is_ok()),
|
|
742
|
+
rx: res_rx,
|
|
743
|
+
};
|
|
744
|
+
let q: Vec<f32> = (0..dims).map(|d| ((41.0f32 * 0.31 + d as f32) * 0.7).sin()).collect();
|
|
745
|
+
let mut best = std::time::Duration::MAX;
|
|
746
|
+
for _ in 0..5 {
|
|
747
|
+
let (hits, _) = search_predicated(
|
|
748
|
+
&graph,
|
|
749
|
+
&Query::new(q.clone()),
|
|
750
|
+
10,
|
|
751
|
+
64,
|
|
752
|
+
&mut pipe,
|
|
753
|
+
64 * 24,
|
|
754
|
+
&mut scratch,
|
|
755
|
+
);
|
|
756
|
+
let tail = last_send.lock().unwrap().expect("the evaluator answered a batch").elapsed();
|
|
757
|
+
assert!(!hits.is_empty(), "precondition: an admitting predicate returns results");
|
|
758
|
+
best = best.min(tail);
|
|
759
|
+
}
|
|
760
|
+
assert!(
|
|
761
|
+
best < std::time::Duration::from_millis(25),
|
|
762
|
+
"the drain sat {best:?} past the last verdict on every query instead of returning on it"
|
|
763
|
+
);
|
|
764
|
+
drop(pipe);
|
|
765
|
+
worker.join().unwrap();
|
|
766
|
+
let _ = std::fs::remove_file(&path);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/// A refused enqueue never answers. Counting it outstanding makes the tail drain wait out
|
|
770
|
+
/// its whole `DRAIN_TIMEOUT` for a verdict that cannot arrive — which is exactly the state
|
|
771
|
+
/// a closing environment puts every in-flight filtered query in, so teardown pays five
|
|
772
|
+
/// seconds per query instead of returning on the batches that did land.
|
|
773
|
+
#[test]
|
|
774
|
+
fn a_refused_predicate_enqueue_does_not_hold_the_drain() {
|
|
775
|
+
let dims = 32;
|
|
776
|
+
let path = std::env::temp_dir().join(format!("hnsw-refused-{}.hnsw", std::process::id()));
|
|
777
|
+
let _ = std::fs::remove_file(&path);
|
|
778
|
+
let graph = Graph::new(PlaneFile::create(&path, dims, 16, 4_096).expect("create"));
|
|
779
|
+
let params = InsertParams::default();
|
|
780
|
+
let mut scratch = SearchScratch::new();
|
|
781
|
+
for i in 0..1_000u32 {
|
|
782
|
+
let v: Vec<f32> = (0..dims).map(|d| ((i as f32 * 0.31 + d as f32) * 0.7).sin()).collect();
|
|
783
|
+
insert(&graph, &v, ¶ms, &mut scratch).unwrap();
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// the sender stays alive for the whole search, so a drain that believes a batch is
|
|
787
|
+
// outstanding blocks on the deadline rather than on a disconnected channel
|
|
788
|
+
let (tx, rx) = std::sync::mpsc::channel::<(Vec<u32>, Vec<u8>)>();
|
|
789
|
+
let mut pipe = PredicatePipe { dispatch: Box::new(|_ids| false), rx };
|
|
790
|
+
let q: Vec<f32> = (0..dims).map(|d| ((41.0f32 * 0.31 + d as f32) * 0.7).sin()).collect();
|
|
791
|
+
let started = std::time::Instant::now();
|
|
792
|
+
let (hits, _) =
|
|
793
|
+
search_predicated(&graph, &Query::new(q), 10, 64, &mut pipe, 64 * 24, &mut scratch);
|
|
794
|
+
let elapsed = started.elapsed();
|
|
795
|
+
drop(tx);
|
|
796
|
+
assert!(hits.is_empty(), "no verdict can arrive for a refused batch, so nothing may be admitted");
|
|
797
|
+
assert!(
|
|
798
|
+
elapsed < std::time::Duration::from_secs(1),
|
|
799
|
+
"the search waited {elapsed:?} on batches that were never enqueued (deadline is {DRAIN_TIMEOUT:?})"
|
|
800
|
+
);
|
|
801
|
+
let _ = std::fs::remove_file(&path);
|
|
802
|
+
}
|
|
477
803
|
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|