@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 ADDED
@@ -0,0 +1,229 @@
1
+ # This file is automatically @generated by Cargo.
2
+ # It is not intended for manual editing.
3
+ version = 4
4
+
5
+ [[package]]
6
+ name = "aho-corasick"
7
+ version = "1.1.5"
8
+ source = "registry+https://github.com/rust-lang/crates.io-index"
9
+ checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
10
+ dependencies = [
11
+ "memchr",
12
+ ]
13
+
14
+ [[package]]
15
+ name = "bitflags"
16
+ version = "2.13.1"
17
+ source = "registry+https://github.com/rust-lang/crates.io-index"
18
+ checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
19
+
20
+ [[package]]
21
+ name = "cfg-if"
22
+ version = "1.0.4"
23
+ source = "registry+https://github.com/rust-lang/crates.io-index"
24
+ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
25
+
26
+ [[package]]
27
+ name = "convert_case"
28
+ version = "0.6.0"
29
+ source = "registry+https://github.com/rust-lang/crates.io-index"
30
+ checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca"
31
+ dependencies = [
32
+ "unicode-segmentation",
33
+ ]
34
+
35
+ [[package]]
36
+ name = "ctor"
37
+ version = "0.2.9"
38
+ source = "registry+https://github.com/rust-lang/crates.io-index"
39
+ checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501"
40
+ dependencies = [
41
+ "quote",
42
+ "syn",
43
+ ]
44
+
45
+ [[package]]
46
+ name = "hnsw-plane"
47
+ version = "0.0.1"
48
+ dependencies = [
49
+ "memmap2",
50
+ "napi",
51
+ "napi-build",
52
+ "napi-derive",
53
+ ]
54
+
55
+ [[package]]
56
+ name = "libc"
57
+ version = "0.2.189"
58
+ source = "registry+https://github.com/rust-lang/crates.io-index"
59
+ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
60
+
61
+ [[package]]
62
+ name = "libloading"
63
+ version = "0.8.9"
64
+ source = "registry+https://github.com/rust-lang/crates.io-index"
65
+ checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
66
+ dependencies = [
67
+ "cfg-if",
68
+ "windows-link",
69
+ ]
70
+
71
+ [[package]]
72
+ name = "memchr"
73
+ version = "2.8.3"
74
+ source = "registry+https://github.com/rust-lang/crates.io-index"
75
+ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
76
+
77
+ [[package]]
78
+ name = "memmap2"
79
+ version = "0.9.11"
80
+ source = "registry+https://github.com/rust-lang/crates.io-index"
81
+ checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
82
+ dependencies = [
83
+ "libc",
84
+ ]
85
+
86
+ [[package]]
87
+ name = "napi"
88
+ version = "2.16.17"
89
+ source = "registry+https://github.com/rust-lang/crates.io-index"
90
+ checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3"
91
+ dependencies = [
92
+ "bitflags",
93
+ "ctor",
94
+ "napi-derive",
95
+ "napi-sys",
96
+ "once_cell",
97
+ ]
98
+
99
+ [[package]]
100
+ name = "napi-build"
101
+ version = "2.4.1"
102
+ source = "registry+https://github.com/rust-lang/crates.io-index"
103
+ checksum = "60fdf9b392c50e7c4170fa633bd909490ed7835cea4c046776d1a4dd8d2ae0ab"
104
+
105
+ [[package]]
106
+ name = "napi-derive"
107
+ version = "2.16.13"
108
+ source = "registry+https://github.com/rust-lang/crates.io-index"
109
+ checksum = "7cbe2585d8ac223f7d34f13701434b9d5f4eb9c332cccce8dee57ea18ab8ab0c"
110
+ dependencies = [
111
+ "cfg-if",
112
+ "convert_case",
113
+ "napi-derive-backend",
114
+ "proc-macro2",
115
+ "quote",
116
+ "syn",
117
+ ]
118
+
119
+ [[package]]
120
+ name = "napi-derive-backend"
121
+ version = "1.0.75"
122
+ source = "registry+https://github.com/rust-lang/crates.io-index"
123
+ checksum = "1639aaa9eeb76e91c6ae66da8ce3e89e921cd3885e99ec85f4abacae72fc91bf"
124
+ dependencies = [
125
+ "convert_case",
126
+ "once_cell",
127
+ "proc-macro2",
128
+ "quote",
129
+ "regex",
130
+ "semver",
131
+ "syn",
132
+ ]
133
+
134
+ [[package]]
135
+ name = "napi-sys"
136
+ version = "2.4.0"
137
+ source = "registry+https://github.com/rust-lang/crates.io-index"
138
+ checksum = "427802e8ec3a734331fec1035594a210ce1ff4dc5bc1950530920ab717964ea3"
139
+ dependencies = [
140
+ "libloading",
141
+ ]
142
+
143
+ [[package]]
144
+ name = "once_cell"
145
+ version = "1.21.4"
146
+ source = "registry+https://github.com/rust-lang/crates.io-index"
147
+ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
148
+
149
+ [[package]]
150
+ name = "proc-macro2"
151
+ version = "1.0.107"
152
+ source = "registry+https://github.com/rust-lang/crates.io-index"
153
+ checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
154
+ dependencies = [
155
+ "unicode-ident",
156
+ ]
157
+
158
+ [[package]]
159
+ name = "quote"
160
+ version = "1.0.47"
161
+ source = "registry+https://github.com/rust-lang/crates.io-index"
162
+ checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
163
+ dependencies = [
164
+ "proc-macro2",
165
+ ]
166
+
167
+ [[package]]
168
+ name = "regex"
169
+ version = "1.13.1"
170
+ source = "registry+https://github.com/rust-lang/crates.io-index"
171
+ checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
172
+ dependencies = [
173
+ "aho-corasick",
174
+ "memchr",
175
+ "regex-automata",
176
+ "regex-syntax",
177
+ ]
178
+
179
+ [[package]]
180
+ name = "regex-automata"
181
+ version = "0.4.18"
182
+ source = "registry+https://github.com/rust-lang/crates.io-index"
183
+ checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
184
+ dependencies = [
185
+ "aho-corasick",
186
+ "memchr",
187
+ "regex-syntax",
188
+ ]
189
+
190
+ [[package]]
191
+ name = "regex-syntax"
192
+ version = "0.8.11"
193
+ source = "registry+https://github.com/rust-lang/crates.io-index"
194
+ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
195
+
196
+ [[package]]
197
+ name = "semver"
198
+ version = "1.0.28"
199
+ source = "registry+https://github.com/rust-lang/crates.io-index"
200
+ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
201
+
202
+ [[package]]
203
+ name = "syn"
204
+ version = "2.0.119"
205
+ source = "registry+https://github.com/rust-lang/crates.io-index"
206
+ checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
207
+ dependencies = [
208
+ "proc-macro2",
209
+ "quote",
210
+ "unicode-ident",
211
+ ]
212
+
213
+ [[package]]
214
+ name = "unicode-ident"
215
+ version = "1.0.24"
216
+ source = "registry+https://github.com/rust-lang/crates.io-index"
217
+ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
218
+
219
+ [[package]]
220
+ name = "unicode-segmentation"
221
+ version = "1.13.3"
222
+ source = "registry+https://github.com/rust-lang/crates.io-index"
223
+ checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
224
+
225
+ [[package]]
226
+ name = "windows-link"
227
+ version = "0.2.1"
228
+ source = "registry+https://github.com/rust-lang/crates.io-index"
229
+ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
package/Cargo.toml ADDED
@@ -0,0 +1,30 @@
1
+ [package]
2
+ name = "hnsw-plane"
3
+ version = "0.0.1"
4
+ edition = "2021"
5
+ description = "Native HNSW traversal plane: mmap fixed-slot graph file + off-loop search"
6
+ license = "Apache-2.0"
7
+
8
+ [lib]
9
+ crate-type = ["cdylib", "rlib"]
10
+
11
+ [dependencies]
12
+ libc = "0.2"
13
+ memmap2 = "0.9"
14
+ napi = { version = "2", default-features = false, features = ["napi8"], optional = true }
15
+ napi-derive = { version = "2", optional = true }
16
+
17
+ [build-dependencies]
18
+ napi-build = "2"
19
+
20
+ [features]
21
+ default = []
22
+ napi = ["dep:napi", "dep:napi-derive"]
23
+
24
+ [[bin]]
25
+ name = "bench"
26
+ path = "src/bin/bench.rs"
27
+
28
+ [profile.release]
29
+ lto = true
30
+ codegen-units = 1
package/DESIGN.md ADDED
@@ -0,0 +1,319 @@
1
+ # HNSW native traversal plane — design
2
+
3
+ Origin: this library was designed and extracted from the Harper vector-index engine
4
+ (HarperFast/harper, branch kris/hnsw-native-plane); "the JS implementation" and issue numbers
5
+ below refer to that codebase, and the measured JS baselines come from its
6
+ `benchmarks/hnsw-scale.js`. The design moves HNSW graph storage and traversal into a native
7
+ (Rust/napi-rs) module over a memory-mapped fixed-slot file, replacing a KV-store column
8
+ family as the home of graph nodes.
9
+
10
+ ## 1. Motivation — measured, not estimated
11
+
12
+ Per-visit cost decomposition at 5M nodes / ef 512 (768-d int8, `benchmarks/hnsw-scale.js`
13
+ corpus, 22.18 ms p50 / 5,107 visits):
14
+
15
+ | Component | Cost | Share of a warm visit |
16
+ | ------------------------------------------- | ------- | --------------------- |
17
+ | Total per visited node | 4.34 µs | 100% |
18
+ | int8 asymmetric cosine, 768-d, JS | 0.43 µs | 10% |
19
+ | msgpackr decode of one node (VT-cache miss) | 5.57 µs | +128% when cold |
20
+ | Neighbour iteration + visited-set ops | 0.21 µs | 5% |
21
+
22
+ ~85% of a warm visit is JS object bookkeeping — candidate heap, visited `Set`, property access,
23
+ allocation, GC — not distance math and not I/O. Three consequences:
24
+
25
+ 1. **A native distance kernel is worth ~nothing.** Distance is 10% of the visit; a NAPI crossing
26
+ costs 0.1–0.5 µs. The win requires the whole search loop native, over a native data layout,
27
+ with one boundary crossing per query.
28
+ 2. **The fetch path decides the ceiling.** A warm RocksDB `Get` is ~1–2 µs even called natively
29
+ (block-cache lookup, block parse, value memcpy) — 20–40× the SIMD distance it feeds. Direct
30
+ slot addressing (`base + id × SLOT_SIZE`) into a resident mapping is ~100–200 ns. Traversal
31
+ over RocksDB caps at ~3–5× improvement; traversal over a fixed-slot mapping reaches the
32
+ full ceiling.
33
+ 3. **Estimated native budget: ~0.25–0.4 µs/visit** (SIMD int8 dot ~50 ns + streaming 768
34
+ contiguous bytes ~150 ns + bitset/heap ops ~50 ns) → **~10–15× on the search path**
35
+ (22 ms → ~1.5–2 ms at 5M/ef 512), with the JS event loop untouched.
36
+
37
+ This is also the enabling dependency for same-node index slicing (parallel slice searches need
38
+ off-loop execution) and changes cluster QPS arithmetic by the same factor.
39
+
40
+ ## 2. Goals / non-goals
41
+
42
+ Goals:
43
+
44
+ - Search traversal fully native, off the JS event loop, one NAPI crossing per query.
45
+ - Graph nodes in a memory-mapped fixed-slot file — **the file is the index**: the maintained
46
+ primary of the derived data, updated in place on every commit, not a cache of RocksDB.
47
+ - Incremental maintenance preserved: insert/update/delete keep working exactly as today from
48
+ the application's view.
49
+ - Relaxed transactional adherence (deliberate): HNSW results are approximate by contract, and
50
+ the existing post-load exact rescore + MVCC record lookup already filter stale/wrong
51
+ candidates. No cross-slot atomicity.
52
+ - Node-id reuse via an in-file freelist — structurally fixes the #2182 lifetime high-water
53
+ ef over-provisioning.
54
+ - Slicing-ready: one file per slice; native merge of per-slice top-k (C2 hook).
55
+
56
+ Non-goals (this phase):
57
+
58
+ - Binary quantization / Matryoshka truncation (benchmark-gated per the Reflex study; the format
59
+ reserves a quantization-mode field so a binary plane is a format v2, not a redesign).
60
+ - Native insert loop (phase 3; insert logic stays in JS initially, persisting through the
61
+ native slot-write API).
62
+ - Cross-node ANN protocol. Out of scope entirely.
63
+ - Lexical/BM25 anything.
64
+
65
+ ## 3. Architecture
66
+
67
+ ```
68
+ JS (worker threads) native (Rust, napi-rs)
69
+ ┌─────────────────────────────────────────┐ ┌─────────────────────────────────────┐
70
+ │ HierarchicalNavigableSmallWorld.ts │ │ hnsw-plane │
71
+ │ • pk→nodeId mapping (stays RocksDB) │ │ • mmap'd slot file (per index/slice)│
72
+ │ • insert/update/delete logic (phase 1) ├──►│ • slot read/write API (seqlocked) │
73
+ │ • commit callback → slot writes │ │ • search(query, k, ef, filter) → │
74
+ │ • record load + exact rescore (as-is) │◄──┤ top-k ids, own thread pool │
75
+ │ • runIndexing replay from watermark │ │ • TSFN batch filter callback │
76
+ └─────────────────────────────────────────┘ └─────────────────────────────────────┘
77
+ ```
78
+
79
+ What stays in RocksDB: the pk→nodeId mapping (transactional with record writes — it is the
80
+ authority on which node id a record owns), records themselves, and all other indexes. What
81
+ moves to the file: node vectors, per-layer adjacency, entry point, id allocator, freelist.
82
+
83
+ ## 4. File format (v1)
84
+
85
+ One file per index (per slice, once C2 lands): `<index-path>.hnsw`.
86
+
87
+ **Header (4 KB page):**
88
+
89
+ | Field | Type | Notes |
90
+ | --------------------------------- | ---------- | ------------------------------------------------------------ |
91
+ | magic + format version | u32 + u32 | rebuild required on version mismatch (accepted contract) |
92
+ | dims, quantization mode | u16 + u8 | v1: int8 asymmetric; f32 supported for `quantization:"none"` |
93
+ | slot_size, layer0_cap, upper_cap | u16 ×3 | derived from M/optimizeRouting at creation |
94
+ | entry_point_id, entry_point_level | u32 + u8 | atomically updated |
95
+ | id_high_water | u64 atomic | replaces the shared Atomics BigInt64Array incrementer |
96
+ | freelist_head | u64 atomic | CAS push/pop; ABA-guarded with a 32-bit tag |
97
+ | txn_watermark | u64 | last durably indexed transaction; advanced by msync cadence |
98
+ | clean_shutdown flag | u8 | torn-state detection on open |
99
+
100
+ **Main region — layer-0 slots**, addressed `4096 + id × slot_size`:
101
+
102
+ | Field | Size (768-d int8, cap 64) |
103
+ | ------------------------------- | ---------------------------------- |
104
+ | seq (seqlock) | 4 B |
105
+ | flags (valid/deleted) + level | 2 B |
106
+ | scale (f32) + invMag (f32) | 8 B |
107
+ | degree | 2 B |
108
+ | vector (int8 × 768) | 768 B |
109
+ | neighbor ids (u32 × layer0_cap) | 256 B |
110
+ | **total, padded** | **1,040 B → 1 KB-aligned 1,088 B** |
111
+
112
+ At 100M nodes: ~109 GB (int8). A binary-code v2 slot (96 B codes + ids) is ~384 B → ~38 GB.
113
+ For comparison, today's encoding averages 1,425 B/node _plus_ RocksDB overhead — so v1 is
114
+ already ~25% smaller while being fixed-offset addressable, because per-edge cached float64
115
+ distances are dropped (recomputing a distance costs ~50 ns native; storing it costs 8 B and
116
+ ~40% of today's node bytes).
117
+
118
+ **Upper-layer region** (append-allocated, compacted on rebuild): only ~6% of nodes have
119
+ level > 0, and upper layers hold neighbor id lists only (vectors live in the main slot). Each
120
+ entry: `node_id, level, [degree, ids × upper_cap] × level`. Kept fully resident; a few hundred
121
+ MB at 100M nodes.
122
+
123
+ **Degree cap decision.** Today layer-0 caps at `M<<1` then `<<2` under `optimizeRouting` = 128,
124
+ with transient overshoot to 160 before pruning; measured mean degree is ~37. Sizing slots at
125
+ cap 128 doubles the file for a tail. v1 policy: **hard prune-to-cap-64 on write** — the insert
126
+ path's in-memory candidate selection can overshoot as today, but what is written is pruned to
127
+ 64 by the same routing-aware selection that currently prunes at 160→128. Transient overshoot
128
+ never touches the file. Recall impact must be measured in the validation phase (§9); the cap is
129
+ a header field, so revising it is a rebuild, not a format change.
130
+
131
+ ## 5. Concurrency
132
+
133
+ - **Per-slot seqlock.** Writer: fetch_add seq to odd → write slot → fetch_add to even. Reader
134
+ (traversal): read seq, copy the ≤1 KB slot (or read fields in place), re-check seq; retry on
135
+ change. Retries are rare (writes touch ~40 slots per insert out of millions) and cheap.
136
+ - **No cross-slot atomicity.** An insert updates the new node's slot plus ~M neighbors'
137
+ back-edge lists, each independently. A traversal may observe the half-linked state: an edge
138
+ to a slot whose valid flag is not yet set → skip (HNSW tolerates missing edges); a
139
+ just-deleted neighbor → skip via flags. Wrong-candidate leakage is filtered by the existing
140
+ exact rescore + MVCC record load, which is why relaxed adherence is safe _here_ and not a
141
+ general storage pattern.
142
+ - **Writers.** Multiple worker threads insert concurrently today (distinct records); the same
143
+ holds: id allocation is one atomic fetch_add on the header, freelist pop is CAS, slot writes
144
+ are seqlocked. Two inserts updating the same neighbor's edge list serialize on that slot's
145
+ seqlock (a Rust-side per-slot spinlock on the odd state).
146
+ - **Id reuse & ABA.** Delete pushes the id onto the freelist; a traversal holding the old id may
147
+ read the reused slot and score the wrong vector — acceptable under the relaxed contract
148
+ (rescore/record-load rejects it). The freelist head itself is tag-guarded against ABA.
149
+
150
+ ## 6. Durability & crash recovery
151
+
152
+ The file is `msync`'d on a cadence (default: every N seconds or M mutated slots, configurable),
153
+ **not** per commit. The header watermark records the last transaction whose index mutations are
154
+ known durable; it advances only after a completed msync barrier.
155
+
156
+ On open:
157
+
158
+ - Clean-shutdown flag set → map and serve.
159
+ - Torn state → replay records from `txn_watermark` through the existing `runIndexing` re-feed
160
+ path (which already treats a re-fed already-indexed record as an update — the exact semantics
161
+ needed). This anchors today's heuristic crash re-feed to a precise watermark.
162
+ - Format-version mismatch or corruption (header checksum) → full rebuild from records. Explicit
163
+ contract: **format upgrades require reindex** (accepted).
164
+
165
+ Note the asymmetry with today: RocksDB gave the graph per-commit durability; the file gives it
166
+ bounded-lag durability with deterministic catch-up. For an approximate index whose source of
167
+ truth (records + pk→nodeId) remains fully transactional, bounded lag is the right trade — it
168
+ buys the entire performance model.
169
+
170
+ **Backup/copy-db/reseed:** the file is node-local derived state. Backup either includes it
171
+ (consistent-enough after an msync barrier) or marks the index rebuild-on-restore. Replica
172
+ reseed = rebuild from records (C5 bulk construction makes this fast; until then, the existing
173
+ per-row path).
174
+
175
+ ## 7. Search path & NAPI surface
176
+
177
+ ```ts
178
+ // one crossing per query; executes on the module's own thread pool
179
+ search(sliceHandles, queryVector: Float32Array, k, ef, filter?): Promise<{ids, distances}>
180
+ ```
181
+
182
+ - Asymmetric distance as today: float query × int8 stored, cached invMag, SIMD (AVX2/VNNI on
183
+ x86, NEON on ARM; `std::arch` intrinsics with a scalar fallback).
184
+ - Visited set: epoch-stamped u32 array (one per pool thread, reused across queries — no
185
+ allocation per query). Candidate heap: fixed-capacity binary heap of (dist, id) pairs.
186
+ - Auto-ef / auto-efC read the node count from the header high-water minus freelist length —
187
+ same semantics as today, minus the #2182 inflation (freed ids return to the pool).
188
+
189
+ **Filtering** (predicate-aware / ACORN, `filteredSearch = true` today):
190
+
191
+ 1. **Bitset fast path.** RBAC allow-lists and companion-condition candidate sets are computed
192
+ before the query and passed as a roaring/plain bitset over node ids. Zero callbacks. This
193
+ covers the dominant production filter shapes.
194
+ 2. **Pipelined TSFN batch path** for arbitrary JS predicates. Traversal batches candidate ids
195
+ (64–256) through a ThreadsafeFunction to a JS evaluator and **continues expanding in
196
+ distance order while verdicts are in flight**; verdicts merge in to steer selection and
197
+ gate results. The existing `filterExpansion` visit budget bounds speculative overshoot.
198
+ Traversal never blocks on the event loop — that would re-import the p99 problem this
199
+ design exists to remove. Worst case (loop saturated): budget exhausts, return what passed —
200
+ the same contract as today's budget-bound filtered search.
201
+ 3. TSFN lifecycle: shutdown-while-query-in-flight is a first-class test (see rocksdb-js #665's
202
+ TSFN teardown SIGSEGV). napi-rs `ThreadsafeFunction` + explicit abort on env teardown.
203
+
204
+ ## 8. Write path phasing
205
+
206
+ - **Phase 1 — dual-write, search cutover.** Insert/update/delete logic stays in JS
207
+ (`HierarchicalNavigableSmallWorld.ts` unchanged algorithmically); mutations persist to BOTH
208
+ the index CF (as today) and the file via native slot-write calls. Search runs native from the
209
+ file. Validation = compare native results against the JS path on the same graph; rollback =
210
+ flip search back to JS, drop the file. The double-write cost is bounded (index writes are
211
+ a fraction of insert cost) and temporary.
212
+
213
+ _Integrated_ behind the opt-in `nativePlane: true` index option (search-only: toggling never
214
+ reindexes; int8 + cosine indexes only — the flag no-ops elsewhere). Mutations mirror at the
215
+ exact `indexStore.put/remove` sites via `writeNodeRaw`/`clearNode`/`setEntryPoint` with
216
+ host-allocated ids; the plane file (`<store path>/<table>.<attr>.hnsw`, layer0 cap 128,
217
+ 16M-node sparse reservation) is created lazily with a full mirror of the existing CF graph on
218
+ first enable, reopened on restart, deleted on drop/clear/reindex. The compiled module is
219
+ optional (`npm run build:hnsw-plane`); absence falls back to the JS path with one warning.
220
+ Parity, predicate, restart, and lifecycle coverage in `unitTests/resources/vectorIndexPlane.test.js`.
221
+ Watermark/replay wiring, slicing, and msync-cadence flushes are not wired yet (open items).
222
+
223
+ - **Phase 2 — file-primary.** Drop the CF writes; the file is the only graph store. JS insert
224
+ reads nodes through a native `getNode(id)` (one NAPI crossing per read, ~1 µs — comparable to
225
+ today's decode path). Migration for existing indexes: reindex (accepted contract), or a
226
+ one-shot CF→file bulk conversion since it is a pure format transform.
227
+ - **Phase 3 — native insert.** Move the insert search + neighbor selection native (same
228
+ traversal core), leaving JS a thin `index(pk, vector)` call. Unlocks bulk build (C5) at
229
+ native speed and removes the ~tens-of-ms event-loop pin per insert (#895).
230
+
231
+ ## 9. Validation plan
232
+
233
+ Baselines exist in `benchmarks/hnsw-scale.js` output (1M/2M/5M anchors, e.g. 1M efC-200:
234
+ p50 7.2 ms / recall@10-set 0.997 @ ef 512). Acceptance for phase 1:
235
+
236
+ 1. **Parity:** native search over a dual-written graph returns identical candidate sets to the
237
+ JS path at equal ef (modulo seqlock-retry races under concurrent write load — measured as a
238
+ bounded divergence rate, not exact equality under churn).
239
+ 2. **Recall:** cap-64 prune vs cap-128 measured at 1M and 5M; accept if recall@10 delta ≤ 0.5 pt
240
+ at equal ef, else revisit the cap (header field — rebuild, not redesign).
241
+ 3. **Latency:** ≥8× p50 improvement at 5M/ef 512 (22.2 ms → ≤2.8 ms), p99 within 2× p50 under
242
+ concurrent insert load (the metric that motivates off-loop execution).
243
+ 4. **Crash:** kill -9 during sustained ingest → reopen → watermark replay → graph passes
244
+ connectivity + recall checks (extend the #1712 repair test harness).
245
+ 5. **Churn:** delete/reinsert cycles hold node count stable (freelist reuse; #2182 regression
246
+ test).
247
+
248
+ ## 10. Decisions & open questions
249
+
250
+ Decided (Kris, 2026-08-31):
251
+
252
+ - **Degree cap: 128 for the int8 plane** (revised 2026-08-31 after measurement). The original
253
+ cap-64 preference assumed 128 doubles the file; it does not for int8 slots — the 768 B vector
254
+ dominates, so 128 costs +23.5% (1,344 vs 1,088 B slots). Measured at 1M: cap-64 loses 2.2 pts
255
+ of recall (0.975 vs 0.996, where JS = 0.997) at equal ef and equal latency. +24% bytes for
256
+ full recall parity is the right trade. The cap stays a header field; the **binary-code v2
257
+ plane reopens the question** (cap-64 ≈ 352 B vs cap-128 ≈ 608 B slots, +73% — there a
258
+ diversity-preserving prune at lower cap is worth engineering).
259
+ - **Platform policy.** Performance is a Linux target only. macOS must work (mmap/msync semantics
260
+ differ slightly — `F_FULLFSYNC` for real durability barriers, no sparse-file guarantees on all
261
+ filesystems — both handled, neither optimized). Windows may fall back to the JS implementation
262
+ entirely; the native plane is allowed to be absent there.
263
+ - **Packaging: independent open-source package.** The core has zero Harper coupling — the crate
264
+ compiles standalone and its NAPI surface is generic (create/open plane, insert(id, vector),
265
+ remove(id), search(query, k, ef, filter), watermark get/set). Harper-specific glue — the
266
+ pk→nodeId mapping, commit-callback integration, txnlog-anchored replay, auto-ef policy
267
+ constants — stays in Harper regardless of packaging. Plan: develop in-repo under
268
+ `native/hnsw-plane/` until the NAPI surface stabilizes (end of phase 1), then split to its own
269
+ repo in the symphony/lmdb-js mold and consume via npm. The pitch as a community package: a
270
+ persistent, incrementally-maintained, concurrently-searchable HNSW for Node — hnswlib-node has
271
+ no durable incremental persistence, no off-loop batched filtering, no seqlock concurrency.
272
+
273
+ Open:
274
+
275
+ - **msync cadence default** — bounded-lag durability window vs write amplification; needs a
276
+ workload measurement, not a guess.
277
+ - **f32 (quantization:"none") slot variant** — 3,072 B vectors → 3.4 KB slots; supported by the
278
+ format (dims × mode in header) but int8 is the default and the optimization target.
279
+ - ~~Upper-layer region persistence~~ — done (format v2): fixed-entry region in the same file,
280
+ per-entry seqlocks, reserved for max_nodes/8. Upper entries leak on delete (bounded by the
281
+ 2x-headroom reserve); an upper freelist is the remaining nicety.
282
+ - **Reservation growth** — max_nodes is fixed at create; production needs either a generous
283
+ sparse reservation (Linux-fine; strict-overcommit hosts need care) or mremap-based growth.
284
+
285
+ ## 11. Prototype measurements (kzyp Linux box, 768-d int8, ef 512, cap 64)
286
+
287
+ Gaussian-mixture corpus matching `benchmarks/hnsw-scale.js` calibration (intra-cos 0.75,
288
+ clusters = N/500). JS baseline for scale: 4.34 µs/visit; 1M efC-200 anchor: p50 7.2 ms,
289
+ recall@10-set 0.997, ~3,110 visits.
290
+
291
+ | N | cap | p50 | p95 | visits/query | µs/visit | recall@10 (set) | build rate |
292
+ | ------------------- | --- | ------- | ------- | ------------ | -------- | --------------- | --------------- |
293
+ | 100K | 64 | 0.28 ms | 0.46 ms | 1,395 | 0.201 | 1.000 | 5,583 inserts/s |
294
+ | 1M | 64 | 0.81 ms | 1.60 ms | 2,279 | 0.353 | 0.975 | 1,670 inserts/s |
295
+ | 1M | 128 | 0.75 ms | 1.48 ms | 2,309 | 0.324 | **0.996** | 1,242 inserts/s |
296
+ | 1M (fmt v2) | 128 | 0.83 ms | 1.61 ms | 2,309 | 0.359 | 0.996 | 1,346 inserts/s |
297
+ | 1M (coverage prune) | 128 | 0.75 ms | 1.52 ms | 2,279 | 0.327 | **0.999** | 1,359 inserts/s |
298
+
299
+ Concurrency (same 1M graph): **6,345 QPS aggregate** across 8 searcher threads (p50 1.03 ms,
300
+ worst-thread p99 3.84 ms) while a background writer sustained **1,102 inserts/s** — the QPS
301
+ input §9 of the Reflex study lacked. Reverse-edge overflow eviction is coverage-aware
302
+ (evict the far member provably reachable via a kept nearer one; bounded 16×16 checks): the
303
+ concurrent torture test caught closest-keep eviction orphaning nodes in near-duplicate
304
+ clusters (~1-in-4 runs), and the fix also raised 1M recall from 0.996 to 0.999 at equal
305
+ build cost.
306
+ | 1M JS anchor | 128 | 7.2 ms | 12.0 ms | ~3,110 | 4.34 | 0.997 | ~263 inserts/s |
307
+
308
+ At the 1M anchor with cap 128: **9.6× p50, 12.9× per-visit, 4.7× build rate, at JS-equal
309
+ recall.** The µs/visit rise from 100K (0.20) to 1M (0.32–0.35) is the working set leaving L3 —
310
+ the memory-hierarchy term; it is the number that holds at 60–100M. An ef-1024 sweep on a
311
+ reopened cap-64 plane without its hierarchy (pre-sidecar) still reached 0.985 at p50 2.47 ms —
312
+ layer-0 beam is robust to a missing hierarchy, at ~3.4× the visits.
313
+
314
+ Milestones: zero-copy seqlock reads + AVX2 kernels took per-visit cost from 0.440 µs (first
315
+ scalar prototype) to ~0.1–0.35 µs, beating the 0.25–0.4 µs design budget. The
316
+ optimizeRouting-parity insert (including the recomputed neighbor↔neighbor distances) restored
317
+ recall from 0.49 (placeholder insert) to JS parity. Uniform-random 768-d corpora produce
318
+ meaningless recall numbers (the JS benchmark's own calibration note: a corpus "no ANN can
319
+ index") — all comparisons use the mixture corpus.