@harperfast/hnsw 0.1.0
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 +229 -0
- package/Cargo.toml +30 -0
- package/DESIGN.md +319 -0
- package/LICENSE +202 -0
- package/README.md +88 -0
- package/build.mjs +46 -0
- package/build.rs +6 -0
- package/index.d.ts +104 -0
- package/index.js +57 -0
- package/package.json +44 -0
- 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/bin/bench.rs +246 -0
- package/src/distance.rs +138 -0
- package/src/format.rs +556 -0
- package/src/graph.rs +650 -0
- package/src/insert.rs +292 -0
- package/src/lib.rs +15 -0
- package/src/napi.rs +486 -0
- package/src/search.rs +477 -0
- package/src/seqlock.rs +217 -0
package/src/insert.rs
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
//! HNSW insert with parity to the JS implementation's optimizeRouting selection
|
|
2
|
+
//! (HierarchicalNavigableSmallWorld.ts): candidate i is skipped when an already-added
|
|
3
|
+
//! connection reaches it indirectly at comparable cost, and inferior indirect edges are
|
|
4
|
+
//! replaced by the new direct route. Stored per-edge distances were dropped from the file
|
|
5
|
+
//! format, so neighbor↔neighbor distances are recomputed (int8×int8) on id-match hits only.
|
|
6
|
+
|
|
7
|
+
use crate::distance::{quantize_int8, Query};
|
|
8
|
+
use crate::format::{NO_ID, NO_UPPER};
|
|
9
|
+
use crate::graph::Graph;
|
|
10
|
+
use crate::search::{greedy_descend, search_layer, SearchScratch, SearchStats};
|
|
11
|
+
|
|
12
|
+
pub struct InsertParams {
|
|
13
|
+
pub m: usize, // base connection count (JS M, default 16)
|
|
14
|
+
pub ef_construction: usize, // candidate list size
|
|
15
|
+
pub ml: f64, // level normalization: 1 / ln(M)
|
|
16
|
+
pub optimize_routing: f32, // JS optimizeRouting, default 0.5; 0 disables
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
impl Default for InsertParams {
|
|
20
|
+
fn default() -> Self {
|
|
21
|
+
InsertParams { m: 16, ef_construction: 200, ml: 1.0 / (16f64).ln(), optimize_routing: 0.5 }
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/// Deterministic pseudo-random level from the node id (reproducible benchmark builds).
|
|
26
|
+
fn level_for(id: u32, ml: f64) -> u8 {
|
|
27
|
+
let mut x = (id as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15).wrapping_add(0x2545_f491_4f6c_dd1d);
|
|
28
|
+
x ^= x >> 33;
|
|
29
|
+
let unit = (x as f64) / (u64::MAX as f64);
|
|
30
|
+
let level = (-unit.max(f64::MIN_POSITIVE).ln() * ml).floor();
|
|
31
|
+
(level as u8).min(crate::format::MAX_UPPER_LEVELS as u8)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/// Remove `to` from `from`'s adjacency at `level` (edge-replacement maintenance).
|
|
35
|
+
fn remove_edge(graph: &Graph, from: u32, to: u32, level: u8) {
|
|
36
|
+
if level == 0 {
|
|
37
|
+
graph.update_neighbors(from, |list| {
|
|
38
|
+
if let Some(pos) = list.iter().position(|&x| x == to) {
|
|
39
|
+
list.remove(pos);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
} else {
|
|
43
|
+
let _ = graph.update_upper_level(from, level, |list| {
|
|
44
|
+
if let Some(pos) = list.iter().position(|&x| x == to) {
|
|
45
|
+
list.remove(pos);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/// Neighbor ids of `id` at `level` (level 0 from the slot, upper from the resident map).
|
|
52
|
+
fn neighbors_at(graph: &Graph, id: u32, level: u8, buf: &mut Vec<u32>) {
|
|
53
|
+
if level == 0 {
|
|
54
|
+
graph.neighbors_into(id, buf);
|
|
55
|
+
} else {
|
|
56
|
+
graph.upper_neighbors_into(id, level, buf);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/// Prune an over-cap adjacency list by evicting the most REDUNDANT far member rather than
|
|
61
|
+
/// blindly the farthest: plain closest-keep can strip a node's last in-edge in dense
|
|
62
|
+
/// near-duplicate clusters, orphaning it from the graph (observed as unfindable self-queries
|
|
63
|
+
/// under concurrent builds). A far member e is redundant when some kept nearer member k has
|
|
64
|
+
/// d(e, k) < d(base, e) — searches reaching k still reach e. Bounded: farthest 16 candidates
|
|
65
|
+
/// checked against the nearest 16 keepers (~30us per overflow event); falls back to evicting
|
|
66
|
+
/// the plain farthest when nothing is provably redundant.
|
|
67
|
+
fn prune_with_coverage(graph: &Graph, base: u32, list: &mut Vec<u32>, cap: usize) {
|
|
68
|
+
let mut scored: Vec<(u32, f32)> = list
|
|
69
|
+
.iter()
|
|
70
|
+
.filter_map(|&cand| graph.distance_between(base, cand).map(|d| (cand, d)))
|
|
71
|
+
.collect();
|
|
72
|
+
scored.sort_by(|a, b| a.1.total_cmp(&b.1));
|
|
73
|
+
while scored.len() > cap {
|
|
74
|
+
let check_from = scored.len().saturating_sub(16);
|
|
75
|
+
let keepers = &scored[..16.min(check_from)];
|
|
76
|
+
let mut evict = scored.len() - 1; // fallback: farthest
|
|
77
|
+
'hunt: for i in (check_from..scored.len()).rev() {
|
|
78
|
+
let (e, d_base_e) = scored[i];
|
|
79
|
+
for &(k, _) in keepers {
|
|
80
|
+
if k == e {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if let Some(d_ek) = graph.distance_between(e, k) {
|
|
84
|
+
if d_ek < d_base_e {
|
|
85
|
+
evict = i;
|
|
86
|
+
break 'hunt;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
scored.remove(evict);
|
|
92
|
+
}
|
|
93
|
+
*list = scored.into_iter().map(|(cand, _)| cand).collect();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/// Add `new_id` to `nid`'s adjacency at `level`, coverage-pruning to `cap` when over. The
|
|
97
|
+
/// prune's distance computations (which can major-fault on a cold mapping) run OUTSIDE the
|
|
98
|
+
/// slot lock: the list is snapshotted, pruned, and applied with a compare-and-set; after a
|
|
99
|
+
/// bounded retry the fallback merges under the lock with a cheap truncation instead.
|
|
100
|
+
fn add_reverse_edge(graph: &Graph, nid: u32, new_id: u32, level: u8, cap: usize) {
|
|
101
|
+
if level == 0 {
|
|
102
|
+
for _ in 0..2 {
|
|
103
|
+
let mut snapshot: Vec<u32> = Vec::new();
|
|
104
|
+
if graph.neighbors_into(nid, &mut snapshot).is_none() {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if snapshot.contains(&new_id) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
let mut next = snapshot.clone();
|
|
111
|
+
next.push(new_id);
|
|
112
|
+
if next.len() > cap {
|
|
113
|
+
prune_with_coverage(graph, nid, &mut next, cap);
|
|
114
|
+
}
|
|
115
|
+
if graph.set_neighbors_if(nid, &snapshot, &next).unwrap_or(false) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// contended twice: merge cheaply under the lock (bounded critical section)
|
|
120
|
+
let _ = graph.update_neighbors(nid, |list| {
|
|
121
|
+
if !list.contains(&new_id) {
|
|
122
|
+
list.push(new_id);
|
|
123
|
+
list.truncate(cap);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
} else {
|
|
127
|
+
let _ = graph.update_upper_level(nid, level, |list| {
|
|
128
|
+
if list.contains(&new_id) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
list.push(new_id);
|
|
132
|
+
if list.len() > cap {
|
|
133
|
+
prune_with_coverage(graph, nid, list, cap);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
140
|
+
pub enum InsertError {
|
|
141
|
+
/// max_nodes reached (freeing capacity makes inserts possible again)
|
|
142
|
+
Full,
|
|
143
|
+
/// a slot lock could not be acquired or reclaimed within the wedge bound
|
|
144
|
+
Wedged,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/// Insert a vector, returning its node id.
|
|
148
|
+
pub fn insert(
|
|
149
|
+
graph: &Graph,
|
|
150
|
+
vector: &[f32],
|
|
151
|
+
params: &InsertParams,
|
|
152
|
+
scratch: &mut SearchScratch,
|
|
153
|
+
) -> Result<u32, InsertError> {
|
|
154
|
+
let (bytes, scale, inv_mag) = quantize_int8(vector);
|
|
155
|
+
let id = graph.file.allocate_id();
|
|
156
|
+
if id == NO_ID {
|
|
157
|
+
return Err(InsertError::Full);
|
|
158
|
+
}
|
|
159
|
+
let level = level_for(id, params.ml);
|
|
160
|
+
let query = Query::new(vector.to_vec());
|
|
161
|
+
let layer0_cap = graph.file.layer0_cap;
|
|
162
|
+
let m = params.m;
|
|
163
|
+
|
|
164
|
+
let (entry_id, entry_level) = graph.file.entry_point();
|
|
165
|
+
if entry_id == NO_ID {
|
|
166
|
+
let upper_idx = if level > 0 { graph.write_upper(&vec![Vec::new(); level as usize]).unwrap_or(NO_UPPER) } else { NO_UPPER };
|
|
167
|
+
graph.write_node(id, level, &bytes, scale, inv_mag, &[], upper_idx).map_err(|_| InsertError::Wedged)?;
|
|
168
|
+
// CAS: a concurrent first insert may have installed an entry already — never clobber
|
|
169
|
+
graph.file.set_entry_point_if_not_better(id, level as u32, NO_ID);
|
|
170
|
+
return Ok(id);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
let mut stats = SearchStats { visits: 0 };
|
|
174
|
+
let (entry_id, entry_level, entry_dist) = match graph.distance_to(entry_id, &query) {
|
|
175
|
+
Some(d) => (entry_id, entry_level, d),
|
|
176
|
+
None => {
|
|
177
|
+
// The stored entry point is gone (e.g. a mirroring host cleared it without
|
|
178
|
+
// re-electing). Self-promoting an edgeless new node here would orphan the whole
|
|
179
|
+
// existing graph behind an unreachable root — re-elect from the live graph and
|
|
180
|
+
// continue; only a truly empty graph makes this node the first entry.
|
|
181
|
+
graph.reelect_entry_point(&[]);
|
|
182
|
+
let (re_id, re_level) = graph.file.entry_point();
|
|
183
|
+
match (re_id != NO_ID).then(|| graph.distance_to(re_id, &query)).flatten() {
|
|
184
|
+
Some(d) => (re_id, re_level, d),
|
|
185
|
+
None => {
|
|
186
|
+
let upper_idx = if level > 0 { graph.write_upper(&vec![Vec::new(); level as usize]).unwrap_or(NO_UPPER) } else { NO_UPPER };
|
|
187
|
+
graph.write_node(id, level, &bytes, scale, inv_mag, &[], upper_idx).map_err(|_| InsertError::Wedged)?;
|
|
188
|
+
graph.file.set_entry_point_if_not_better(id, level as u32, NO_ID);
|
|
189
|
+
return Ok(id);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
let top = level.min(entry_level as u8);
|
|
195
|
+
let (mut ep, mut ep_dist) =
|
|
196
|
+
greedy_descend(graph, &query, entry_id, entry_dist, entry_level, top as u32, &mut stats);
|
|
197
|
+
|
|
198
|
+
// Per-level connection lists for the new node, selection-ordered.
|
|
199
|
+
let mut connections: Vec<Vec<(u32, f32)>> = vec![Vec::new(); level as usize + 1];
|
|
200
|
+
let mut nbuf: Vec<u32> = Vec::new();
|
|
201
|
+
|
|
202
|
+
for l in (0..=top).rev() {
|
|
203
|
+
scratch_begin(graph, scratch);
|
|
204
|
+
let mut neighbors =
|
|
205
|
+
search_layer(graph, &query, ep, ep_dist, params.ef_construction, l, scratch, &mut stats, None, u64::MAX);
|
|
206
|
+
neighbors.truncate(m << 1);
|
|
207
|
+
if let Some(&(best, best_d)) = neighbors.first() {
|
|
208
|
+
ep = best;
|
|
209
|
+
ep_dist = best_d;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// JS optimizeRouting selection over rank-ordered candidates.
|
|
213
|
+
let take_conns = std::mem::take(&mut connections[l as usize]);
|
|
214
|
+
let mut conns = take_conns;
|
|
215
|
+
for (i, &(nid, ndist)) in neighbors.iter().enumerate() {
|
|
216
|
+
if nid == id {
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
let mut skipping = false;
|
|
220
|
+
let mut replaced: Vec<(u32, u32)> = Vec::new(); // (from, to) edge removals
|
|
221
|
+
if params.optimize_routing > 0.0 {
|
|
222
|
+
let distance_threshold = 1.0 + params.optimize_routing * (1.0 + (0.5 * i as f32) / m as f32);
|
|
223
|
+
neighbors_at(graph, nid, l, &mut nbuf);
|
|
224
|
+
for (i2, &nnid) in nbuf.iter().enumerate() {
|
|
225
|
+
let neighbor_threshold = 1.0 + params.optimize_routing * (1.0 + (0.5 * i2 as f32) / m as f32);
|
|
226
|
+
if let Some(&(added_id, added_dist)) = conns.iter().find(|(aid, _)| *aid == nnid) {
|
|
227
|
+
// recompute the stored neighbor↔neighbor distance (not persisted)
|
|
228
|
+
let neighbor_distance = graph.distance_between(nid, nnid).unwrap_or(f32::INFINITY);
|
|
229
|
+
if ndist * distance_threshold > added_dist + neighbor_distance {
|
|
230
|
+
skipping = true;
|
|
231
|
+
break; // JS: `if (skipping) break` ends the neighbor scan
|
|
232
|
+
} else if neighbor_distance * neighbor_threshold > ndist + added_dist {
|
|
233
|
+
replaced.push((added_id, nid));
|
|
234
|
+
replaced.push((nid, added_id));
|
|
235
|
+
}
|
|
236
|
+
// JS breaks only the inner connections scan; keep scanning neighbors
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if skipping {
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
} else if i >= if l > 0 { m } else { m << 1 } {
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
conns.push((nid, ndist));
|
|
246
|
+
for (from, to) in replaced {
|
|
247
|
+
remove_edge(graph, from, to, l);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
connections[l as usize] = conns;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Write the new node: upper entry first so a reader that sees the node sees its
|
|
254
|
+
// hierarchy; layer-0 list pruned to the file cap (selection order = rank order).
|
|
255
|
+
let upper_idx = if level > 0 {
|
|
256
|
+
let levels: Vec<Vec<u32>> = (1..=level as usize)
|
|
257
|
+
.map(|l| {
|
|
258
|
+
connections
|
|
259
|
+
.get(l)
|
|
260
|
+
.map(|c| c.iter().map(|&(nid, _)| nid).collect())
|
|
261
|
+
.unwrap_or_default()
|
|
262
|
+
})
|
|
263
|
+
.collect();
|
|
264
|
+
graph.write_upper(&levels).unwrap_or(NO_UPPER)
|
|
265
|
+
} else {
|
|
266
|
+
NO_UPPER
|
|
267
|
+
};
|
|
268
|
+
let mut l0: Vec<u32> = connections[0].iter().map(|&(nid, _)| nid).collect();
|
|
269
|
+
l0.truncate(layer0_cap);
|
|
270
|
+
graph.write_node(id, level, &bytes, scale, inv_mag, &l0, upper_idx).map_err(|_| InsertError::Wedged)?;
|
|
271
|
+
|
|
272
|
+
// Reverse edges.
|
|
273
|
+
for (l, conns) in connections.iter().enumerate() {
|
|
274
|
+
let cap = if l == 0 { layer0_cap } else { m << 1 };
|
|
275
|
+
for &(nid, _) in conns {
|
|
276
|
+
add_reverse_edge(graph, nid, id, l as u8, cap);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (level as u32) > entry_level {
|
|
281
|
+
// CAS against the observed entry: a concurrent higher-level promotion wins
|
|
282
|
+
graph.file.set_entry_point_if_not_better(id, level as u32, entry_id);
|
|
283
|
+
}
|
|
284
|
+
Ok(id)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
#[inline]
|
|
288
|
+
fn scratch_begin(graph: &Graph, scratch: &mut SearchScratch) {
|
|
289
|
+
// search_layer assumes a fresh epoch per sweep; SearchScratch::begin is crate-private
|
|
290
|
+
// via this helper to keep the public surface small.
|
|
291
|
+
scratch.begin_public(graph.file.id_high_water());
|
|
292
|
+
}
|
package/src/lib.rs
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
//! hnsw-plane: native HNSW traversal plane over a memory-mapped fixed-slot file.
|
|
2
|
+
//! Design: ../../hnsw-native-plane.md. NAPI bindings land behind the `napi` feature in
|
|
3
|
+
//! phase-1 integration; the core is buildable and benchmarkable standalone.
|
|
4
|
+
|
|
5
|
+
pub mod distance;
|
|
6
|
+
pub mod format;
|
|
7
|
+
pub mod graph;
|
|
8
|
+
pub mod insert;
|
|
9
|
+
#[cfg(feature = "napi")]
|
|
10
|
+
mod napi;
|
|
11
|
+
pub mod search;
|
|
12
|
+
pub mod seqlock;
|
|
13
|
+
|
|
14
|
+
pub use format::PlaneFile;
|
|
15
|
+
pub use graph::Graph;
|