@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 CHANGED
@@ -46,6 +46,7 @@ dependencies = [
46
46
  name = "hnsw-plane"
47
47
  version = "0.0.1"
48
48
  dependencies = [
49
+ "libc",
49
50
  "memmap2",
50
51
  "napi",
51
52
  "napi-build",
package/DESIGN.md CHANGED
@@ -96,18 +96,24 @@ One file per index (per slice, once C2 lands): `<index-path>.hnsw`.
96
96
  | freelist_head | u64 atomic | CAS push/pop; ABA-guarded with a 32-bit tag |
97
97
  | txn_watermark | u64 | last durably indexed transaction; advanced by msync cadence |
98
98
  | clean_shutdown flag | u8 | torn-state detection on open |
99
+ | invalidated latch | u8 | one-way (v7): watermark reads 0 on every handle, open refuses |
100
+ | write_epoch | u64 atomic | bumped by every node write; re-arms the read-side repair probe |
99
101
 
100
102
  **Main region — layer-0 slots**, addressed `4096 + id × slot_size`:
101
103
 
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** |
104
+ | Field | Size (768-d int8, cap 64) |
105
+ | ------------------------------- | ----------------------------------- |
106
+ | seq (seqlock) | 4 B |
107
+ | flags (valid/deleted) + level | 2 B |
108
+ | scale (f32) + invMag (f32) | 8 B |
109
+ | degree | 2 B |
110
+ | vector (int8 × 768) | 768 B (padded to a 4-byte boundary) |
111
+ | neighbor ids (u32 × layer0_cap) | 256 B |
112
+ | **total, padded** | **1,040 B → 1 KB-aligned 1,088 B** |
113
+
114
+ The vector's trailing pad keeps the neighbor array 4-aligned for every `dims`, so the search
115
+ hot path reads each neighbor id as one aligned volatile `u32`. Upper-layer id lists are padded
116
+ the same way (`degree u16 + pad u16 + ids`).
111
117
 
112
118
  At 100M nodes: ~109 GB (int8). A binary-code v2 slot (96 B codes + ids) is ~384 B → ~38 GB.
113
119
  For comparison, today's encoding averages 1,425 B/node _plus_ RocksDB overhead — so v1 is
@@ -167,6 +173,24 @@ bounded-lag durability with deterministic catch-up. For an approximate index who
167
173
  truth (records + pk→nodeId) remains fully transactional, bounded lag is the right trade — it
168
174
  buys the entire performance model.
169
175
 
176
+ **Invalidation (a plane the host cannot delete).** Disabling a plane deletes its file; when the
177
+ unlink fails (Windows sharing violation while another process maps it) the file must not be
178
+ adopted later at its nonzero watermark, or it silently serves searches missing every mutation
179
+ made while mirroring was off. `invalidate_plane(path)` / `invalidate_file(&handle)` leave two
180
+ markers, always attempting both: in band — `PlaneFile::invalidate` sets a one-way header latch,
181
+ zeroes the watermark, and msyncs the header page alone (a whole-mapping flush cannot run inline
182
+ on a multi-GB plane, and lowering the watermark is the safe direction) — then a `<path>.stale`
183
+ sidecar, created with create-new semantics (a planted symlink is never followed) and fsync'd
184
+ together with its directory entry (the directory fsync is skipped on Windows, where `std` has no
185
+ directory handle and `FlushFileBuffers` on the marker covers its creation). The package enforces
186
+ both markers: `open` refuses a file carrying either, `create` refuses a path with a leftover
187
+ sidecar, and `watermark()` reads 0 on every handle while the latch is set — so a flush already
188
+ in flight on another handle, which still stamps the word, cannot revive the plane. In band
189
+ first: the sidecar is what a process that cannot map the file checks, the latch is what covers a
190
+ plane whose sidecar a crash lost. A temporary handle opened for the in-band mark is unmapped
191
+ and closed before the sidecar step — its own mapping would keep the file undeletable — and the
192
+ call fails only when neither marker is durable, leaving the file exactly as found.
193
+
170
194
  **Backup/copy-db/reseed:** the file is node-local derived state. Backup either includes it
171
195
  (consistent-enough after an msync barrier) or marks the index rebuild-on-restore. Replica
172
196
  reseed = rebuild from records (C5 bulk construction makes this fast; until then, the existing
@@ -186,6 +210,60 @@ search(sliceHandles, queryVector: Float32Array, k, ef, filter?): Promise<{ids, d
186
210
  - Auto-ef / auto-efC read the node count from the header high-water minus freelist length —
187
211
  same semantics as today, minus the #2182 inflation (freed ids return to the pool).
188
212
 
213
+ **Upper-layer descent is a beam, not hill climbing** (`beam_descend`, `DESCENT_EF = 16`). The
214
+ textbook width-1 descent halts at the first upper-layer node no neighbor improves on. On a
215
+ clustered corpus that local minimum can sit in the wrong basin, and layer-0 adjacency is
216
+ intra-basin, so the layer-0 beam has no uphill edge with which to leave — the query's true
217
+ nearest neighbor is then unreachable at *any* ef, and raising ef only expands the wrong basin.
218
+ Measured on the `tests/concurrent.rs` corpus (8 000 nodes, 64-d, self-query every node at
219
+ ef 256, insertion order fixed by seed): width 1 loses 125 nodes over 200 builds, width 4 loses
220
+ 20 over 200, width 8 loses 8 over 700, width 16 loses 0 over 700. Cost at 50 000 × 768-d:
221
+ visits/query +17 % to +27 %, p50 +0.06 ms flat (0.15 → 0.21 ms at ef 16, 0.21 → 0.28 at ef 64,
222
+ 0.46 → 0.47 at ef 512 — the descent is a fixed cost, so it hurts most where ef is small), build
223
+ throughput -20 %. recall@10 improves below ef 128 (0.844 → 0.903 at ef 16, 0.983 → 1.000 at
224
+ ef 64) and is unchanged above.
225
+
226
+ `beam_descend` is shared by the read and write paths deliberately: insert must route through
227
+ the same graph its queries will, or nodes get their neighbors chosen from a basin searches
228
+ never reach. The width is a compile-time constant rather than a parameter because it is a
229
+ correctness floor, not a recall/latency dial — `ef` is the dial.
230
+
231
+ Three facts worth keeping when working on this.
232
+
233
+ The trap is a property of graph *shape*, not of concurrency: it reproduces single-threaded from
234
+ a fixed insertion permutation, and concurrency only shuffles that permutation. It also needs the
235
+ full corpus — no seed reproduces it at 32 dims, or at 2 000 / 4 000 nodes, so a shrunken repro
236
+ is not evidence of a fix. `descent_width_sweep` in `tests/concurrent.rs` (ignored by default) is
237
+ the harness behind the table above.
238
+
239
+ Read and write descent widths must match. Measured over 200 builds per cell: width 1 both sides
240
+ loses 125 nodes, read-only widening loses 37, **write-only widening loses 245 — worse than
241
+ either**, and both sides widened loses 0. `insert` seeds each level's `search_layer` from the
242
+ descent's landing point, so a graph wired under one routing policy and queried under another is
243
+ less navigable than one where they agree. This is also the upgrade story: an existing plane file
244
+ read by a new binary is the read-only row, improved but not repaired until its nodes are
245
+ re-inserted.
246
+
247
+ `HNSW_SWEEP_READ_EF` sets the sweep's query-side width; the build side is whatever `DESCENT_EF`
248
+ is compiled as, so the four cells are two runs per value of the constant:
249
+
250
+ ```text
251
+ HNSW_SWEEP_SEEDS=200 HNSW_SWEEP_READ_EF=1 cargo test --release --test concurrent \
252
+ descent_width_sweep -- --ignored --nocapture
253
+ HNSW_SWEEP_SEEDS=200 HNSW_SWEEP_READ_EF=16 cargo test --release --test concurrent \
254
+ descent_width_sweep -- --ignored --nocapture
255
+ ```
256
+
257
+ Do not add a per-level visit cap to the descent without re-measuring. The obvious ceiling,
258
+ `DESCENT_EF * UPPER_CAP` = 1024, is already exceeded by ordinary queries: the worst of 3 000
259
+ random queries visits 788 nodes at level 1 on a 50 000-node graph and 1 044 on a 500 000-node
260
+ one. A cap that binds silently degrades recall, which is the defect this exists to fix. What
261
+ bounds the pathological case instead is `search_layer`'s strict `d < worst`: with every distance
262
+ tied — a zero query ties them all at exactly 1.0 — a full result set never admits another
263
+ candidate, so the descent drains after `ef` expansions per level (measured 608 visits at 50 000
264
+ nodes, 990 at 500 000). `a_tied_distance_descent_stops_at_its_visit_cap` fails if that `<` is
265
+ ever relaxed.
266
+
189
267
  **Filtering** (predicate-aware / ACORN, `filteredSearch = true` today):
190
268
 
191
269
  1. **Bitset fast path.** RBAC allow-lists and companion-condition candidate sets are computed
@@ -272,6 +350,14 @@ Decided (Kris, 2026-08-31):
272
350
 
273
351
  Open:
274
352
 
353
+ - **Atomic slot payloads.** Fields a concurrent reader acts on (flags, level, degree, scale,
354
+ invMag, neighbor and upper ids) are read through aligned `read_volatile`, which forbids the
355
+ reload/split/sink across the seqlock's validating fence that `lto = true, codegen-units = 1`
356
+ otherwise licenses. That is not the same as being race-free under Rust's memory model: only
357
+ making those fields `AtomicU8`/`AtomicU16`/`AtomicU32` in the slot layout would be, and that
358
+ is a format change deferred past phase 1. The stored vector stays an ordinary load on
359
+ purpose — `cosine_int8_raw` must keep autovectorizing, and a torn vector only perturbs a
360
+ distance the generation check discards.
275
361
  - **msync cadence default** — bounded-lag durability window vs write amplification; needs a
276
362
  workload measurement, not a guess.
277
363
  - **f32 (quantization:"none") slot variant** — 3,072 B vectors → 3.4 KB slots; supported by the
package/README.md CHANGED
@@ -35,15 +35,18 @@ the host's authoritative record store. Approximate indexes don't need per-commit
35
35
  they need cheap, bounded catch-up. See [DESIGN.md](DESIGN.md) for the format, the
36
36
  concurrency model, measured baselines, and the reasoning behind every trade.
37
37
 
38
- ## Install & build
38
+ ## Install
39
39
 
40
40
  ```bash
41
41
  npm install @harperfast/hnsw
42
42
  ```
43
43
 
44
- Prebuilds are not published yet: building requires a [Rust toolchain](https://rustup.rs)
45
- (`npm run build`, or automatically on install when cargo is available). Linux x86_64 is the
46
- performance target (AVX2); macOS works (scalar fallback); Windows is untested.
44
+ Prebuilt bindings ship as platform-specific `optionalDependencies`
45
+ (`@harperfast/hnsw-<platform>-<arch>[-glibc]`) for linux-x64, linux-arm64, darwin-arm64, and
46
+ win32-x64. Platforms without a published binding (musl, darwin-x64, win32-arm64) build from
47
+ source on install when a [Rust toolchain](https://rustup.rs) is present, and throw a clear
48
+ error otherwise. Linux x86_64 is the performance target (AVX2 + kernel-lock crash recovery);
49
+ macOS and Windows are functional (no lock takeover — bounded degradation instead).
47
50
 
48
51
  ## Usage
49
52
 
@@ -65,6 +68,13 @@ const predicated = await plane.searchWithPredicate(queryVector, 10, 512, (ids) =
65
68
  );
66
69
  ```
67
70
 
71
+ A plane is derived state; when the host must stop maintaining one and cannot delete the file
72
+ (Windows sharing violations while another process maps it), `invalidatePlane(path)` — or
73
+ `plane.invalidateFile()` through a handle the host already holds — durably marks it
74
+ unadoptable: a one-way in-band latch (watermark reads 0, `Plane.open` refuses) plus a fsync'd
75
+ `<path>.stale` sidecar (`stalePathFor(path)`, which `open` also refuses). It throws only when
76
+ neither marker lands. Hosts delete both files and rebuild.
77
+
68
78
  Full API in [index.d.ts](index.d.ts).
69
79
 
70
80
  ## Benchmarks
@@ -78,8 +88,8 @@ equal recall.
78
88
 
79
89
  ## Status
80
90
 
81
- Extracted from the Harper vector-index engine; the format (v2) and API are young and may
82
- change with a major version + reindex. Roadmap: prebuilds, binary-quantized slot format
91
+ Extracted from the Harper vector-index engine; the format (v7) and API are young and may
92
+ change with a version bump + reindex (an older format version fails to open; rebuild). Roadmap: prebuilds, binary-quantized slot format
83
93
  (~4× smaller traversal plane), Matryoshka dimension truncation, mremap growth, index
84
94
  slicing with native top-k merge.
85
95
 
package/build.mjs CHANGED
@@ -10,12 +10,9 @@ import { fileURLToPath } from 'node:url';
10
10
  const crateRoot = dirname(fileURLToPath(import.meta.url));
11
11
  const ifNeeded = process.argv.includes('--if-needed');
12
12
  const artifact = join(crateRoot, 'hnsw-plane.node');
13
- const prebuilt = join(crateRoot, 'prebuilds', `${process.platform}-${process.arch}`, 'hnsw-plane.node');
14
-
15
- // a prebuild satisfies --if-needed only if it actually LOADS on this system (a newer-glibc
16
- // artifact exists but throws at require; fall through to a local build in that case)
13
+ // a platform optionalDependency or a loadable local artifact satisfies --if-needed (a
14
+ // binding that exists but fails to link falls through to a local build)
17
15
  function loads(path) {
18
- if (!existsSync(path)) return false;
19
16
  try {
20
17
  createRequire(import.meta.url)(path);
21
18
  return true;
@@ -23,7 +20,11 @@ function loads(path) {
23
20
  return false;
24
21
  }
25
22
  }
26
- if (ifNeeded && (loads(artifact) || loads(prebuilt))) {
23
+ function platformPackageLoads() {
24
+ const libc = process.platform === 'linux' ? '-glibc' : '';
25
+ return loads(`@harperfast/hnsw-${process.platform}-${process.arch}${libc}`);
26
+ }
27
+ if (ifNeeded && (platformPackageLoads() || (existsSync(artifact) && loads(artifact)))) {
27
28
  process.exit(0);
28
29
  }
29
30
  try {
package/index.d.ts CHANGED
@@ -12,7 +12,10 @@ export interface SearchHit {
12
12
  export declare class Plane {
13
13
  /** Create a new plane file. `maxNodes` is a sparse reservation — pages materialize on write. */
14
14
  static create(path: string, dims: number, layer0Cap: number, maxNodes: number): Plane;
15
- /** Open an existing plane file (format-version mismatch throws: rebuild the index). */
15
+ /**
16
+ * Open an existing plane file. Throws on a format-version mismatch and on an invalidated
17
+ * plane (header latch or `.stale` sidecar): delete the file and its sidecar, rebuild.
18
+ */
16
19
  static open(path: string): Plane;
17
20
 
18
21
  /**
@@ -101,4 +104,42 @@ export declare class Plane {
101
104
  flush(watermark?: number): void;
102
105
  /** flush() on the libuv thread pool — a whole-map msync can stall its calling thread. */
103
106
  flushAsync(watermark?: number): Promise<void>;
107
+ /**
108
+ * In-band half of invalidateFile() only — no sidecar, so a process that cannot map the
109
+ * file sees nothing; prefer invalidateFile(). Sets the one-way header latch, zeroes the
110
+ * watermark, msyncs the header page (a 4 KB barrier, not a whole-mapping flush). From then
111
+ * on every handle reads watermark 0, whatever a racing flush stamps, and open() throws.
112
+ */
113
+ invalidate(): void;
114
+ /**
115
+ * invalidatePlane() through this handle: the in-band mark via this mapping (no second open,
116
+ * no second registry slot — on Windows this mapping is why the unlink failed) and the
117
+ * `.stale` sidecar next to the path it opened. The path must not have been replaced since.
118
+ */
119
+ invalidateFile(): InvalidationOutcome;
120
+ /** Whether the plane was invalidated, by any handle, since this one opened. */
121
+ invalidated(): boolean;
104
122
  }
123
+
124
+ export interface InvalidationOutcome {
125
+ /** The watermark was zeroed and its header page msync'd. */
126
+ inBand: boolean;
127
+ /** `<path>.stale` exists and is fsync'd (on POSIX, so is its directory entry). */
128
+ sidecar: boolean;
129
+ inBandError?: string;
130
+ sidecarError?: string;
131
+ }
132
+
133
+ /**
134
+ * Make a plane file that could not be deleted unadoptable, durably, through a temporary
135
+ * handle that is unmapped and closed before this returns. Both markers are always attempted:
136
+ * the in-band latch and the fsync'd `.stale` sidecar; open() refuses a file carrying either.
137
+ * Throws only when neither marker became durable; nothing is deleted or renamed, and an
138
+ * in-band mark whose msync failed may still have landed in the shared mapping (the safe
139
+ * direction: it reads as incomplete). Idempotent. Synchronous (three small fsyncs on a cold path).
140
+ */
141
+ export declare function invalidatePlane(path: string): InvalidationOutcome;
142
+ /** invalidatePlane() on the libuv thread pool. */
143
+ export declare function invalidatePlaneAsync(path: string): Promise<InvalidationOutcome>;
144
+ /** The sidecar convention: `<path>.stale`. */
145
+ export declare function stalePathFor(path: string): string;
package/index.js CHANGED
@@ -1,30 +1,51 @@
1
- // Loads the native module: a bundled prebuild for this platform when present, else a
2
- // locally built artifact (`npm run build`, requires a Rust toolchain). Linux prebuilds are
3
- // glibc builds; musl systems build locally.
1
+ // Loads the native module, mirroring @harperfast/rocksdb-js's model: a platform-specific
2
+ // optionalDependency package when one exists for this platform (linux bindings are split by
3
+ // libc), else a locally built artifact (`npm run build`, requires a Rust toolchain).
4
4
  'use strict';
5
5
  const { existsSync } = require('node:fs');
6
6
  const { join } = require('node:path');
7
7
 
8
- const candidates = [
9
- join(__dirname, 'prebuilds', `${process.platform}-${process.arch}`, 'hnsw-plane.node'),
10
- join(__dirname, 'hnsw-plane.node'),
11
- ];
12
- // pick the first candidate that actually LOADS (a prebuilt binary can exist but fail to
13
- // link, e.g. built against a newer glibc than this system's — fall through to a local build)
14
- let native0;
15
- const failures = [];
16
- for (const candidate of candidates) {
17
- if (!existsSync(candidate)) continue;
8
+ function libcSuffix() {
9
+ if (process.platform !== 'linux') return '';
10
+ let isMusl = false;
11
+ try {
12
+ const { glibcVersionRuntime } = process.report?.getReport?.()?.header ?? {};
13
+ isMusl = !glibcVersionRuntime;
14
+ } catch {
15
+ // fall through to ldd probing
16
+ }
17
+ if (!isMusl) return '-glibc';
18
18
  try {
19
- native0 = require(candidate);
20
- break;
21
- } catch (error) {
22
- failures.push(`${candidate}: ${error.message}`);
19
+ const { execSync } = require('node:child_process');
20
+ isMusl = execSync('ldd --version', { encoding: 'utf8', stdio: 'pipe' }).includes('musl');
21
+ } catch {
22
+ // ldd may not exist; keep the report-based verdict
23
+ }
24
+ return isMusl ? '-musl' : '-glibc';
25
+ }
26
+
27
+ const failures = [];
28
+ let native0;
29
+ // 1. platform package (published binding)
30
+ try {
31
+ native0 = require(`@harperfast/hnsw-${process.platform}-${process.arch}${libcSuffix()}`);
32
+ } catch (error) {
33
+ failures.push(`platform package: ${error.message}`);
34
+ }
35
+ // 2. local build (dev checkouts, source-build installs)
36
+ if (!native0) {
37
+ const local = join(__dirname, 'hnsw-plane.node');
38
+ if (existsSync(local)) {
39
+ try {
40
+ native0 = require(local);
41
+ } catch (error) {
42
+ failures.push(`${local}: ${error.message}`);
43
+ }
23
44
  }
24
45
  }
25
46
  if (!native0) {
26
47
  throw new Error(
27
- `@harperfast/hnsw could not load a native binary for ${process.platform}-${process.arch}. ` +
48
+ `@harperfast/hnsw could not load a native binding for ${process.platform}-${process.arch}. ` +
28
49
  'Build one with `npm run build` in ' +
29
50
  __dirname +
30
51
  ' (requires a Rust toolchain: https://rustup.rs).' +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harperfast/hnsw",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Persistent, incrementally-maintained, concurrently-searchable native HNSW for Node.js: a memory-mapped fixed-slot graph file with off-event-loop search, seqlock concurrency, int8 asymmetric distance, and bitset/predicate filtering.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -12,7 +12,6 @@
12
12
  "files": [
13
13
  "index.js",
14
14
  "index.d.ts",
15
- "prebuilds",
16
15
  "hnsw-plane.node",
17
16
  "src",
18
17
  "build.rs",
@@ -40,5 +39,11 @@
40
39
  "mmap",
41
40
  "napi",
42
41
  "rust"
43
- ]
42
+ ],
43
+ "optionalDependencies": {
44
+ "@harperfast/hnsw-darwin-arm64": "0.2.1",
45
+ "@harperfast/hnsw-linux-arm64-glibc": "0.2.1",
46
+ "@harperfast/hnsw-linux-x64-glibc": "0.2.1",
47
+ "@harperfast/hnsw-win32-x64": "0.2.1"
48
+ }
44
49
  }
package/src/bin/bench.rs CHANGED
@@ -122,7 +122,7 @@ fn main() {
122
122
  let build_start = Instant::now();
123
123
  for i in 0..n {
124
124
  let v = corpus.row(&mut rng);
125
- insert(&graph, &v, &params, &mut scratch);
125
+ insert(&graph, &v, &params, &mut scratch).expect("build insert");
126
126
  if (i + 1) % 50_000 == 0 {
127
127
  let rate = (i + 1) as f64 / build_start.elapsed().as_secs_f64();
128
128
  println!(" built {} ({:.0} inserts/s)", i + 1, rate);