@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/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 { visited: Vec::new(), epoch: 0, neighbors: Vec::new() }
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
- /// upper levels read the resident upper map. Returns (id, distance) ascending by distance.
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
- ) -> Vec<(u32, f32)> {
131
- let mut candidates = BinaryHeap::new();
132
- let mut results: BinaryHeap<Result_> = BinaryHeap::new();
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
- scratch.neighbors = nbuf;
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
- out
200
+
201
+ candidates.clear();
202
+ scratch.neighbors = nbuf;
203
+ scratch.candidates = candidates;
204
+ scratch.results = results;
185
205
  }
186
206
 
187
- /// Greedy single-candidate descent through upper layers from `from_level` down to
188
- /// `to_level` (exclusive lower bound handled by caller loops). Returns improved entry.
189
- pub fn greedy_descend(
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 nbuf: Vec<u32> = Vec::new();
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
- let mut improved = true;
202
- while improved {
203
- improved = false;
204
- graph.upper_neighbors_into(current, level.min(255) as u8, &mut nbuf);
205
- for i in 0..nbuf.len() {
206
- let nid = nbuf[i];
207
- if let Some(d) = graph.distance_to(nid, query) {
208
- stats.visits += 1;
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
- /// Full search: greedy descent through upper layers, then beam at layer 0.
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.file.entry_point();
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) = greedy_descend(graph, query, entry_id, entry_dist, entry_level, 0, &mut stats);
245
- let mut out = search_layer(graph, query, ep, ep_dist, ef, 0, scratch, &mut stats, None, u64::MAX);
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.file.entry_point();
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 entry_dist = match graph.distance_to(entry_id, query) {
268
- Some(d) => {
269
- stats.visits += 1;
270
- d
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 (ep, ep_dist) = greedy_descend(graph, query, entry_id, entry_dist, entry_level, 0, &mut stats);
275
- let budget = if filter.is_some() { (ef * filter_expansion) as u64 } else { u64::MAX };
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
- pub dispatch: Box<dyn FnMut(Vec<u32>) + Send>,
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.file.entry_point();
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) = greedy_descend(graph, query, entry_id, entry_dist, entry_level, 0, &mut stats);
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
- let mut candidates = BinaryHeap::new();
331
- let mut results: BinaryHeap<Result_> = BinaryHeap::new();
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
- let mut nbuf = std::mem::take(&mut scratch.neighbors);
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 let Ok((ids, flags)) = $recv {
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 >= visit_budget {
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.into_iter().map(|r| (r.id, r.distance)).collect();
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, &params, &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, &params, &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, &params, &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, &params, &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, &params, &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, &params, &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