@soulcraft/cor 3.0.0-rc.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.
Files changed (87) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +328 -0
  3. package/dist/aggregation/NativeAggregationEngine.d.ts +118 -0
  4. package/dist/aggregation/NativeAggregationEngine.js +186 -0
  5. package/dist/cli.d.ts +12 -0
  6. package/dist/cli.js +236 -0
  7. package/dist/graph/GraphAccelerationAdapter.d.ts +153 -0
  8. package/dist/graph/GraphAccelerationAdapter.js +326 -0
  9. package/dist/graph/NativeGraphAdjacencyIndex.d.ts +286 -0
  10. package/dist/graph/NativeGraphAdjacencyIndex.js +944 -0
  11. package/dist/hnsw/AdaptiveDiskAnnModeSelector.d.ts +187 -0
  12. package/dist/hnsw/AdaptiveDiskAnnModeSelector.js +313 -0
  13. package/dist/hnsw/NativeDiskAnnWrapper.d.ts +489 -0
  14. package/dist/hnsw/NativeDiskAnnWrapper.js +1456 -0
  15. package/dist/index.d.ts +28 -0
  16. package/dist/index.js +29 -0
  17. package/dist/legacyLayoutGuard.d.ts +93 -0
  18. package/dist/legacyLayoutGuard.js +237 -0
  19. package/dist/license/constants.d.ts +30 -0
  20. package/dist/license/constants.js +30 -0
  21. package/dist/license/onlineValidator.d.ts +43 -0
  22. package/dist/license/onlineValidator.js +225 -0
  23. package/dist/license/types.d.ts +39 -0
  24. package/dist/license/types.js +5 -0
  25. package/dist/license.d.ts +64 -0
  26. package/dist/license.js +165 -0
  27. package/dist/migration/MigrationCoordinator.d.ts +171 -0
  28. package/dist/migration/MigrationCoordinator.js +249 -0
  29. package/dist/migration/indexEpochGuard.d.ts +163 -0
  30. package/dist/migration/indexEpochGuard.js +202 -0
  31. package/dist/native/NativeEmbeddingEngine.d.ts +79 -0
  32. package/dist/native/NativeEmbeddingEngine.js +318 -0
  33. package/dist/native/NativeRoaringBitmap32.d.ts +114 -0
  34. package/dist/native/NativeRoaringBitmap32.js +221 -0
  35. package/dist/native/ffi.d.ts +20 -0
  36. package/dist/native/ffi.js +48 -0
  37. package/dist/native/idMapperTestSupport.d.ts +67 -0
  38. package/dist/native/idMapperTestSupport.js +112 -0
  39. package/dist/native/index.d.ts +49 -0
  40. package/dist/native/index.js +87 -0
  41. package/dist/native/napi.d.ts +21 -0
  42. package/dist/native/napi.js +88 -0
  43. package/dist/native/types.d.ts +1298 -0
  44. package/dist/native/types.js +16 -0
  45. package/dist/plugin.d.ts +50 -0
  46. package/dist/plugin.js +388 -0
  47. package/dist/providerContracts.d.ts +277 -0
  48. package/dist/providerContracts.js +38 -0
  49. package/dist/resource/OsMemoryProbe.d.ts +175 -0
  50. package/dist/resource/OsMemoryProbe.js +206 -0
  51. package/dist/resource/ResourceManager.d.ts +491 -0
  52. package/dist/resource/ResourceManager.js +960 -0
  53. package/dist/utils/ColumnManifest.d.ts +97 -0
  54. package/dist/utils/ColumnManifest.js +129 -0
  55. package/dist/utils/NativeColumnStore.d.ts +284 -0
  56. package/dist/utils/NativeColumnStore.js +685 -0
  57. package/dist/utils/NativeMetadataIndex.d.ts +882 -0
  58. package/dist/utils/NativeMetadataIndex.js +2631 -0
  59. package/dist/utils/NativeUnifiedCache.d.ts +87 -0
  60. package/dist/utils/NativeUnifiedCache.js +273 -0
  61. package/dist/utils/binaryIdMapperFactory.d.ts +59 -0
  62. package/dist/utils/binaryIdMapperFactory.js +94 -0
  63. package/dist/utils/collation.d.ts +30 -0
  64. package/dist/utils/collation.js +40 -0
  65. package/dist/utils/columnStoreTypes.d.ts +161 -0
  66. package/dist/utils/columnStoreTypes.js +29 -0
  67. package/dist/utils/nativeBinaryEntityIdMapper.d.ts +211 -0
  68. package/dist/utils/nativeBinaryEntityIdMapper.js +381 -0
  69. package/dist/utils/nativeEntityIdMapper.d.ts +111 -0
  70. package/dist/utils/nativeEntityIdMapper.js +170 -0
  71. package/docs/ADR-002-diskann-100-percent-rust.md +294 -0
  72. package/docs/ADR-003-semantic-time-travel.md +100 -0
  73. package/docs/aggregation.md +251 -0
  74. package/docs/comparison.md +162 -0
  75. package/docs/deployment-limits.md +87 -0
  76. package/docs/diskann.md +184 -0
  77. package/docs/migration-3.0.md +396 -0
  78. package/docs/performance-budget.md +274 -0
  79. package/docs/performance.md +117 -0
  80. package/docs/scaling.md +439 -0
  81. package/docs/snapshot-safety.md +246 -0
  82. package/docs/u64-id-space.md +214 -0
  83. package/docs/verification-report.md +670 -0
  84. package/native/brainy-native.node +0 -0
  85. package/native/index.d.ts +3916 -0
  86. package/package.json +92 -0
  87. package/scripts/migrate-cortex-2x-to-3x.mjs +970 -0
@@ -0,0 +1,1456 @@
1
+ /**
2
+ * @module hnsw/NativeDiskAnnWrapper
3
+ * @description TypeScript wrapper around cor's native **Adaptive
4
+ * DiskANN** engine. Satisfies brainy 8.0's `VectorIndexProvider`
5
+ * contract (see [`providerContracts.ts`](../providerContracts.ts))
6
+ * so brainy treats it as the standard vector index slot —
7
+ * `addItem` / `search` / `rebuild` / `flush` / ... Underneath,
8
+ * every operation routes through cor's pure-Rust DiskANN engine
9
+ * (`native/diskann/`).
10
+ *
11
+ * @example
12
+ * ```typescript
13
+ * import { Brainy } from '@soulcraft/brainy'
14
+ *
15
+ * // Cor is auto-detected by brainy's plugin system on init.
16
+ * const brain = new Brainy({
17
+ * storage: { type: 'filesystem', path: '/data/idx' }
18
+ * })
19
+ * await brain.init() // [cor] Adaptive DiskANN engaged (mode auto)
20
+ *
21
+ * await brain.add({ data: 'native rust acceleration', type: 'concept' })
22
+ * const hits = await brain.search('billion scale ann', 10)
23
+ * ```
24
+ *
25
+ * @example
26
+ * ```typescript
27
+ * // Default zero-config — the adaptive selector picks Mode 1 / 2 / 3
28
+ * // from observed memory pressure. Most users only set `recall`.
29
+ * const brain = new Brainy({
30
+ * storage: { type: 'filesystem', path: '/data/idx' },
31
+ * vector: {
32
+ * recall: 'balanced', // 'fast' | 'balanced' | 'accurate'
33
+ * }
34
+ * })
35
+ * ```
36
+ *
37
+ * ## Operating model
38
+ *
39
+ * Adaptive DiskANN is build-once, query-many: the on-disk file
40
+ * embeds the Vamana graph plus — depending on the build mode — an
41
+ * optional PQ codebook + codes section + the full vectors, all in
42
+ * a single contiguous mmap-able layout (see `native/diskann/src/format.rs`).
43
+ * Dynamic insertions go to a small in-memory **delta buffer** that
44
+ * brute-force-searches alongside the main index until the next
45
+ * `rebuild()` folds them in. This matches FreshDiskANN's published
46
+ * online-update model.
47
+ *
48
+ * The wrapper picks an operating mode (in-memory / hybrid /
49
+ * on-disk) at build time and open time via the
50
+ * {@link AdaptiveDiskAnnModeSelector}; see that module for the
51
+ * decision tree. Operators set nothing — observed memory drives
52
+ * the choice.
53
+ *
54
+ * ## Search path
55
+ *
56
+ * 1. Query the main index via the native DiskANN searcher:
57
+ * - **Mode 1**: exact-distance walk over full vectors (no PQ,
58
+ * no rerank — the walk's top-L is the exact top-L).
59
+ * - **Mode 2/3**: PQ-greedy walk in RAM (codes pinned or paged
60
+ * depending on mode), full-vector re-rank of the **whole walked
61
+ * candidate list** by default (`paddingFactor` is an optional cap
62
+ * for bounding cold-NVMe re-rank I/O — see {@link RECALL_PRESETS}).
63
+ * 2. Brute-force the delta buffer (typically <0.1% of total
64
+ * size after a recent rebuild).
65
+ * 3. Merge + sort + truncate to `k`.
66
+ *
67
+ * ## When this wrapper engages
68
+ *
69
+ * The cor plugin (`src/plugin.ts`) registers this wrapper under
70
+ * brainy 8.0's canonical `'vector'` provider key. Whenever cor is
71
+ * installed, brainy's vector index slot resolves to Adaptive DiskANN.
72
+ */
73
+ import { loadNativeModule } from '../native/index.js';
74
+ import { prodLog } from '@soulcraft/brainy/internals';
75
+ import { mkdirSync, existsSync, rmSync } from 'node:fs';
76
+ import { dirname } from 'node:path';
77
+ import { autoModeForHeader, selectModeFromResourceManager, } from './AdaptiveDiskAnnModeSelector.js';
78
+ /**
79
+ * Node-count threshold above which `useMmapAdjacency: 'auto'`
80
+ * resolves to file-backed. Below this, the in-RAM build adjacency
81
+ * fits comfortably in heap; above, the per-row mutex table starts
82
+ * to dominate. 100 M matches the published DiskANN guidance and
83
+ * the comment that lives next to `AdjacencyBackend::Mmap` in the
84
+ * Rust crate.
85
+ */
86
+ const MMAP_ADJACENCY_AUTO_THRESHOLD = 100_000_000;
87
+ /**
88
+ * **Delta budget (the MVP-A bound).** The in-memory delta absorbs writes
89
+ * between folds; unbounded, it is an OOM risk and a growing brute-force
90
+ * search cost. At the SOFT threshold the wrapper kicks a BACKGROUND fold
91
+ * (the async rebuild — serving and writes continue); at the HARD cap
92
+ * `addItem()` applies backpressure by awaiting the in-flight fold before
93
+ * accepting the write — bounded memory, never a silent stall. Sized in
94
+ * bytes so dimensionality doesn't change the memory story (64 MB ≈ 44 K
95
+ * vectors at dim 384). The #73 LSM-segment work (MVP-C) replaces the O(N)
96
+ * fold behind these SAME triggers with an O(delta) L0 segment flush.
97
+ */
98
+ const DELTA_SOFT_FOLD_BYTES = 64 * 1024 * 1024;
99
+ const DELTA_HARD_CAP_BYTES = 256 * 1024 * 1024;
100
+ /**
101
+ * **#73 MVP-C — LSM-tiered immutable vector segments.** The delta no longer
102
+ * folds into the main index with an O(N) rebuild: it flushes into a small
103
+ * immutable L0 SEGMENT (`seg-<id>.dkann`, built from the delta alone —
104
+ * O(new-data), seconds not days). Search fans out over base + L0 segments +
105
+ * delta with first-seen-by-uuid dedup (newest wins). When the L0 count
106
+ * reaches this threshold, a BACKGROUND consolidation (the async rebuild,
107
+ * which folds base + L0s + delta into a fresh base) runs — rare, off-thread,
108
+ * never blocking a read or a write. L0 segments are bounded-small by
109
+ * construction (each born from a ≤{@link DELTA_SOFT_FOLD_BYTES} delta), so
110
+ * consolidation reads them back via `readVectorChunk` — bounded RAM.
111
+ */
112
+ const L0_COMPACT_THRESHOLD = 4;
113
+ /**
114
+ * Storage key of the vector segment manifest (flat key — slashed keys trip
115
+ * the storage adapters' unknown-key warning; same lesson as the metadata
116
+ * LSM's `__metadata_lsm_manifest__`). Records the live L0 segment set +
117
+ * the next segment id; the base index stays at the canonical `indexPath`.
118
+ * Crash-ordering invariant (ported from the graph index): the segment FILE
119
+ * is fully written before the manifest references it, and superseded files
120
+ * are deleted only AFTER the manifest stops referencing them — a crash
121
+ * leaves orphan files, never a manifest pointing at a missing segment.
122
+ */
123
+ const VECTOR_MANIFEST_KEY = '__vector_index_manifest__';
124
+ /**
125
+ * **Canonical .dkann path convention** (brainy 8.0 + cor 3.0
126
+ * lockstep). When `DiskAnnIndexConfig.indexPath` is omitted, the
127
+ * wrapper derives the path by passing this key to the storage
128
+ * adapter's `getBinaryBlobPath`. Living under `_system/` puts
129
+ * the DiskANN file inside brainy's existing backup-by-default
130
+ * tree — no special inclusion rule needed in `db.persist` /
131
+ * `gsutil` / `rclone` configurations.
132
+ *
133
+ * Sharded deployments override `indexPath` explicitly with per-
134
+ * shard suffixes (e.g. `_system/vector-index/shard-7.dkann`).
135
+ */
136
+ const DEFAULT_INDEX_PATH_KEY = '_system/vector-index/main.dkann';
137
+ /**
138
+ * Derive the canonical `.dkann` path from a storage adapter, or
139
+ * return `null` if the adapter doesn't expose `getBinaryBlobPath`
140
+ * (cloud adapters that don't support binary blobs, or no adapter
141
+ * supplied). The caller decides whether `null` is a hard error or
142
+ * a graceful fallback.
143
+ */
144
+ function deriveIndexPath(storage) {
145
+ if (!storage)
146
+ return null;
147
+ const getBinaryBlobPath = storage.getBinaryBlobPath;
148
+ if (typeof getBinaryBlobPath !== 'function')
149
+ return null;
150
+ const path = getBinaryBlobPath.call(storage, DEFAULT_INDEX_PATH_KEY);
151
+ return typeof path === 'string' && path.length > 0 ? path : null;
152
+ }
153
+ /**
154
+ * **Recall presets** (brainy 8.0 lockstep). One knob (`recall`) sets
155
+ * the search-time `defaultLSearch` — the candidate-walk width, which
156
+ * is the recall/latency dial. None of the presets cap the re-rank:
157
+ * the searcher exact-scores the **full walked candidate list** by
158
+ * default (the mmap-resident vectors make that near-free), so a wider
159
+ * `l_search` directly buys recall. `defaultPaddingFactor` stays an
160
+ * optional override (escape hatch for bounding re-rank I/O on
161
+ * cold-NVMe billion-scale indexes); when omitted, re-rank is full-list.
162
+ *
163
+ * Brainy 8.0's JS HNSW path exposes the same preset names with
164
+ * algorithm-appropriate values (`{m, efConstruction, efSearch}`
165
+ * tuples); see `brainy/.strategy/COR-3.0-INTEGRATION.md`.
166
+ */
167
+ const RECALL_PRESETS = {
168
+ fast: { defaultLSearch: 50 },
169
+ balanced: { defaultLSearch: 100 },
170
+ accurate: { defaultLSearch: 200 },
171
+ };
172
+ /**
173
+ * @description **Scale-aware `l_search` (E3 zero-config adaptation).** The walk
174
+ * width a fixed default serves degrades with corpus size: recall@10 at
175
+ * `l_search=100` MEASURED 0.9942 @ 1M → 0.9385 @ 10M → **0.8747 @ 100M**
176
+ * (SIFT sweeps, bxl9000 2026-07-01: sift10m-recall + sift100m-nvme runs;
177
+ * summaries in `docs/verification/`). Holding ≥0.95 needed `l_search≈150` at
178
+ * 10M (0.9647) and `≈256` at 100M (0.9559) — i.e. the required width grows
179
+ * ~×1.8 per decade of corpus size. This resolver widens the *default* walk to
180
+ * match: `base` up to 2M nodes (where the measured recall is ≥0.99 already),
181
+ * then `base × 1.8^log10(n/2e6)`, capped at 512 (the latency guard; the cap is
182
+ * also where the 1B PROJECTION ends — 1B ≈ 489 stays a projection until the
183
+ * SIFT1B run measures it).
184
+ *
185
+ * Zero-config contract: this scales the DEFAULT only. An explicit
186
+ * `defaultLSearch` in the construction config pins the width verbatim (the
187
+ * escape hatch), and a `recall` preset scales proportionally from its base
188
+ * (`fast`/`accurate` keep their relative latency/recall intent at every scale).
189
+ *
190
+ * @param base - The preset/base walk width (e.g. 100 for 'balanced').
191
+ * @param nodeCount - Current corpus size (main index + delta).
192
+ * @returns The scale-adjusted walk width, in `[base, 512]` (never below base).
193
+ * @example
194
+ * scaleAwareLSearch(100, 1e6) // 100 (measured 0.9942 — base is ample)
195
+ * scaleAwareLSearch(100, 1e7) // 151 (measured anchor: 150 → 0.9647)
196
+ * scaleAwareLSearch(100, 1e8) // 271 (measured anchor: 256 → 0.9559)
197
+ * scaleAwareLSearch(100, 1e9) // 489 (PROJECTED — SIFT1B measures it)
198
+ */
199
+ export function scaleAwareLSearch(base, nodeCount) {
200
+ const SCALE_FLOOR_N = 2e6; // below this the measured recall is ≥0.99 at base
201
+ const GROWTH_PER_DECADE = 1.8; // fitted to the 10M/100M measured anchors
202
+ const CAP = 512; // latency guard + the edge of measured territory
203
+ if (!(nodeCount > SCALE_FLOOR_N) || base >= CAP)
204
+ return Math.min(base, CAP) || base;
205
+ const decades = Math.log10(nodeCount / SCALE_FLOOR_N);
206
+ const scaled = Math.round(base * Math.pow(GROWTH_PER_DECADE, decades));
207
+ return Math.max(base, Math.min(CAP, scaled));
208
+ }
209
+ const DEFAULTS = {
210
+ // 0 = auto-derive m from the observed dimension (target dsub ≈ 8) in
211
+ // the native build path — see `derivePqM` / native `pqDeriveM`. A
212
+ // fixed m crushes recall at high dimensions; zero-config picks the
213
+ // right m per dimension. Pin a non-zero value to override.
214
+ pqM: 0,
215
+ pqKsub: 256,
216
+ maxDegree: 64,
217
+ searchListSize: 100,
218
+ alpha: 1.2,
219
+ defaultLSearch: 100,
220
+ // defaultPaddingFactor intentionally unset: the searcher re-ranks
221
+ // the full walked candidate list by default (best recall). It's an
222
+ // explicit override only — see InternalDefaults.defaultPaddingFactor.
223
+ useMmapAdjacency: 'auto',
224
+ mode: 'auto',
225
+ };
226
+ /**
227
+ * Predicate-pushdown thresholds (#10). When `find()` supplies a
228
+ * metadata∩graph `allowedIds` universe, the wrapper pushes it INTO the native
229
+ * beam walk instead of post-filtering — but only when the predicate is
230
+ * selective enough that post-filtering would lose recall. At or below
231
+ * `PUSHDOWN_MAX_SELECTIVITY` (allowed/total) the unfiltered top-(k×2) rarely
232
+ * holds k allowed hits, so pushdown wins; above it, post-filtering already
233
+ * keeps plenty and is cheaper (no slot translation of a huge set).
234
+ * `PUSHDOWN_MAX_LSEARCH` is the walk↔brute-force crossover: when the walk
235
+ * width the selectivity demands (~k/selectivity) would exceed it, the walk
236
+ * is BOTH slow (the bounded candidate list is O(L) per insert) and low-recall
237
+ * (allowed points sit outside the frontier), so pushdown switches to an exact
238
+ * scan of the allowed set instead. Below it, the walk runs at its natural,
239
+ * never-capped width. Zero-config: both are derived defaults, not user knobs.
240
+ */
241
+ const PUSHDOWN_MAX_SELECTIVITY = 0.1;
242
+ const PUSHDOWN_MAX_LSEARCH = 4096;
243
+ /**
244
+ * Allowed-set size at or below which pushdown exact-scans the set rather than
245
+ * graph-walking it. Measured crossover (SIFT/128-d on the reference host): an
246
+ * exact scan of ≤ ~32k vectors (≈4M float ops, low single-digit ms) is both
247
+ * faster than the filtered walk's floor AND exact (recall 1.0), because the
248
+ * walk carries fixed traversal + re-rank overhead and its bounded candidate
249
+ * list is O(L) per insert. Above this the walk is the fast-approximate path
250
+ * for large allowed sets at higher selectivity. Zero-config default; a future
251
+ * O(log L) candidate list would raise the walk's competitiveness and shift
252
+ * this crossover up.
253
+ *
254
+ * Measured (SIFT 1M, 128-d, bxl9000): exact scan of 65,536 vectors ≈ 4–6 ms
255
+ * and recall 1.0; at that count the filtered walk is ~equal latency but only
256
+ * ~0.73 recall, so the exact scan is the better pick. Past ~65k the scan
257
+ * dominates query time and the walk (small L at the implied higher
258
+ * selectivity) wins — e.g. 5% of a 10M index (500k) correctly routes to it.
259
+ */
260
+ const PUSHDOWN_BRUTE_MAX_ALLOWED = 65536;
261
+ export class NativeDiskAnnWrapper {
262
+ config;
263
+ distanceFunction;
264
+ storage;
265
+ persistMode;
266
+ /** Live searcher instance — null until the first build. */
267
+ native = null;
268
+ /** Newly added entries since the last build. Brute-force searched. */
269
+ delta = new Map();
270
+ /** Removed entries — filtered out at search time. */
271
+ tombstones = new Set();
272
+ /**
273
+ * **#72 Phase A+B — lazy resolver to the canonical entity-id mapper**
274
+ * (the metadata index's mapper, the SAME one brainy assigns entity
275
+ * ints with at write time). Wired by `src/plugin.ts` exactly as the
276
+ * graph index's `mapperResolver` (#68). The wrapper resolves
277
+ * `uuid → int` (slotmap writes, pushdown, membership) and `int → uuid`
278
+ * (search-result hydration) through it, which is why there is no
279
+ * longer a resident `slotByUuid` / `uuidBySlot` pair on the JS heap —
280
+ * the off-heap mmap slotmaps (`main.slotmap` / `main.slotrev`) plus
281
+ * this mapper replace them entirely (the 10B-RAM gate).
282
+ */
283
+ mapperResolver;
284
+ /**
285
+ * Lazy resolver to the shared #18 {@link MigrationCoordinator} (the same getter
286
+ * the metadata + graph providers receive). Undefined when no migration path is
287
+ * wired (e.g. a raw-napi construction in tests).
288
+ */
289
+ migrationCoordinatorGetter;
290
+ /**
291
+ * True when the construction config carried an EXPLICIT `defaultLSearch` —
292
+ * the operator pinned the walk width, so {@link resolveLSearch} uses it
293
+ * verbatim instead of the scale-aware default (see {@link scaleAwareLSearch}).
294
+ * A `recall` preset does NOT pin: presets set the BASE and still scale with
295
+ * corpus size, preserving their relative recall/latency intent at every scale.
296
+ */
297
+ lSearchPinned = false;
298
+ /**
299
+ * The in-flight fold (async rebuild) promise, or `null`. Concurrent
300
+ * `rebuild()` calls coalesce onto it, `addItem()`'s hard-cap backpressure
301
+ * awaits it, and `removeItem()` consults it so a delta-only removal that
302
+ * may already be baked into the incoming index still gets a tombstone
303
+ * (no resurrection at swap).
304
+ */
305
+ rebuildInFlight = null;
306
+ /** In-flight L0 flush, or null (see {@link flushDeltaToL0}). */
307
+ flushInFlight = null;
308
+ /** Live L0 segments, ascending by id (oldest first; search iterates newest→oldest). */
309
+ l0Segments = [];
310
+ /** Next segment id (persisted in the manifest). */
311
+ manifestNextId = 1;
312
+ /** Segment-manifest cold-load memo (see {@link ensureSegments}). */
313
+ segmentsLoaded = false;
314
+ segmentsLoading = null;
315
+ /** Serializes manifest writes: the storage adapter has no per-key write
316
+ * ordering, so two in-flight saves (a flush racing a consolidation) could
317
+ * physically land out of order and persist a STALE segment list — losing a
318
+ * just-flushed segment's only reference after a restart. Each save chains
319
+ * behind the previous and builds its payload AT EXECUTION TIME. */
320
+ manifestSaveChain = Promise.resolve();
321
+ /**
322
+ * **Piece I — Adaptive DiskANN.** The selector's most recent
323
+ * decision, exposed via {@link getLastModeSelection} for telemetry
324
+ * and tests. `null` until the first build or open.
325
+ */
326
+ lastSelection = null;
327
+ constructor(config, distanceFunction, options = {}) {
328
+ // Merge order matters:
329
+ // 1. DEFAULTS — baseline (matches the 'balanced' preset).
330
+ // 2. recall preset — overrides DEFAULTS for the chosen tier
331
+ // WHEN the user supplied `recall`.
332
+ // 3. config — user overrides win over both. So
333
+ // `recall: 'fast', defaultLSearch: 200`
334
+ // gives lSearch=200, not the fast preset's
335
+ // 50.
336
+ const preset = config.recall ? RECALL_PRESETS[config.recall] : {};
337
+ const merged = { ...DEFAULTS, ...preset, ...config };
338
+ // Path resolution: explicit `indexPath` wins; otherwise derive
339
+ // from the storage adapter using the canonical convention
340
+ // (`_system/vector-index/main.dkann`). If neither is available
341
+ // the constructor throws — without a path, the wrapper has no
342
+ // anchor for build, open, or rebuild.
343
+ const resolvedIndexPath = merged.indexPath ?? deriveIndexPath(options.storage);
344
+ if (!resolvedIndexPath) {
345
+ throw new Error('NativeDiskAnnWrapper: indexPath is required when no ' +
346
+ 'storage adapter (with getBinaryBlobPath) is provided. ' +
347
+ 'Pass `indexPath` explicitly or supply a filesystem-backed ' +
348
+ 'storage in `options.storage` so the wrapper can derive ' +
349
+ `the canonical path \`{root}/${DEFAULT_INDEX_PATH_KEY}\`.`);
350
+ }
351
+ this.config = { ...merged, indexPath: resolvedIndexPath };
352
+ // An explicit defaultLSearch in the caller's config (not a preset, not the
353
+ // internal default) pins the walk width — the scale-aware default steps aside.
354
+ this.lSearchPinned =
355
+ config.defaultLSearch !== undefined;
356
+ this.distanceFunction = distanceFunction;
357
+ this.storage = options.storage ?? null;
358
+ this.persistMode = options.persistMode ?? 'immediate';
359
+ this.mapperResolver = options.mapperResolver;
360
+ this.migrationCoordinatorGetter = options.migrationCoordinator;
361
+ // Try to open an existing file. If absent, the index stays
362
+ // empty until the first rebuild() flushes the delta buffer.
363
+ // No mapper is needed here: the native open attaches the off-heap
364
+ // slotmaps; slot ↔ uuid resolution happens lazily at query/rebuild
365
+ // time, after brainy's init has wired the mapper.
366
+ this.tryOpenExisting();
367
+ }
368
+ /**
369
+ * Resolve the canonical entity-id mapper, or throw if it isn't wired.
370
+ * Every main-index operation (rebuild, search-result hydration,
371
+ * pushdown, membership) resolves `slot ↔ uuid` through this mapper +
372
+ * the off-heap slotmaps; without it the wrapper cannot translate the
373
+ * native engine's entity ints to UUIDs. In production `src/plugin.ts`
374
+ * always wires it; the throw surfaces a misconfiguration loudly rather
375
+ * than silently returning empty results.
376
+ */
377
+ mapper() {
378
+ const m = this.mapperResolver?.();
379
+ if (!m) {
380
+ throw new Error('NativeDiskAnnWrapper: entity-id mapper is not available. The ' +
381
+ 'wrapper resolves slot↔uuid through the metadata index\'s ' +
382
+ 'mapper (wired by plugin.ts as `() => ' +
383
+ 'metadataIndexInstance?.getIdMapper()`); construct it with a ' +
384
+ '`mapperResolver` option.');
385
+ }
386
+ return m;
387
+ }
388
+ /**
389
+ * Append an entry to the delta buffer. Persisted by the next
390
+ * `rebuild()` call, which folds the delta into the main index.
391
+ */
392
+ async addItem(item) {
393
+ const bytesPerVector = this.config.dimensions * 4;
394
+ // HARD cap — backpressure: the delta may not grow unbounded. Wait for
395
+ // the in-flight fold (or start one) before accepting the write. Serving
396
+ // is unaffected — the fold runs off the JS thread and reads keep
397
+ // flowing while this one caller waits.
398
+ if (this.delta.size * bytesPerVector >= DELTA_HARD_CAP_BYTES) {
399
+ // Backpressure awaits a FLUSH — O(delta), seconds — never a
400
+ // consolidation (hours at scale): flushing DURING a background rebuild is
401
+ // safe (the rebuild folds only its snapshot; the new segment survives
402
+ // its retirement filter and the serialized manifest saves keep the
403
+ // on-disk list consistent).
404
+ await (this.flushInFlight ?? this.flushDeltaToL0());
405
+ }
406
+ if (this.tombstones.has(item.id)) {
407
+ this.tombstones.delete(item.id);
408
+ }
409
+ this.delta.set(item.id, item.vector);
410
+ // SOFT threshold — kick a BACKGROUND fold. Never blocks this write; a
411
+ // fold failure is logged loudly and the delta stays intact for retry.
412
+ if (!this.flushInFlight &&
413
+ this.delta.size * bytesPerVector >= DELTA_SOFT_FOLD_BYTES) {
414
+ // #73 MVP-C: the soft trigger flushes the delta into a small immutable
415
+ // L0 SEGMENT — O(new-data), never O(corpus). Consolidation of the
416
+ // accumulated L0s into the base happens separately in the background
417
+ // when L0_COMPACT_THRESHOLD is reached (see runFlushToL0).
418
+ this.flushDeltaToL0().catch((error) => {
419
+ prodLog.error(`NativeDiskAnnWrapper: background L0 flush failed (delta ` +
420
+ `retained; retries at the next trigger): ` +
421
+ `${error instanceof Error ? error.message : String(error)}`);
422
+ });
423
+ }
424
+ return item.id;
425
+ }
426
+ /**
427
+ * Mark an entry as removed. Filtered out at search time; physically
428
+ * removed at the next `rebuild()`.
429
+ */
430
+ async removeItem(id) {
431
+ await this.ensureSegments();
432
+ const inDelta = this.delta.delete(id);
433
+ // Main-index membership via the off-heap reverse slotmap: resolve
434
+ // uuid → entity int through the canonical mapper, then ask native
435
+ // whether that int has a slot in this shard (#72 Phase B). No
436
+ // resident `slotByUuid` map.
437
+ let inMain = false;
438
+ if (this.native || this.l0Segments.length > 0) {
439
+ const int = this.mapper().getInt(id);
440
+ if (int !== undefined)
441
+ inMain = this.hasIntAnywhere(BigInt(int));
442
+ }
443
+ // Tombstone when the entity is in the main index — OR when a fold is in
444
+ // flight: a delta-only entry may already be baked into the incoming
445
+ // index (the fold built from a snapshot), so deleting it from the live
446
+ // delta alone would resurrect it at swap. A tombstone for an entity the
447
+ // incoming index turns out not to contain is harmless and is cleared by
448
+ // the next fold's reconciliation.
449
+ if (inMain || this.rebuildInFlight !== null || this.flushInFlight !== null) {
450
+ this.tombstones.add(id);
451
+ }
452
+ return inDelta || inMain;
453
+ }
454
+ /**
455
+ * Hydrate the delta buffer from the canonical nouns persisted in
456
+ * storage — the cold-rebuild path used by {@link rebuild} after a
457
+ * `restore()` / migration, when the wrapper has no in-memory state to
458
+ * fold but storage holds the authoritative records. Streams via
459
+ * paginated `getNouns()` rather than fetching every noun at once.
460
+ *
461
+ * NOTE: like the existing delta path this materializes the vectors it
462
+ * loads into the JS delta. A billion-noun cold rebuild should instead
463
+ * stream straight from a storage-backed vector source (the native
464
+ * `build_from_file` path used by the SIFT billion-scale builder); that
465
+ * scale optimization is tracked separately. For the migration sizes
466
+ * this serves today the delta route is correct, and it logs the count
467
+ * it loaded so an operator can see the reconstruction happen.
468
+ *
469
+ * @param dim - Index dimension; nouns whose vector width differs are skipped.
470
+ */
471
+ async hydrateDeltaFromStorage(dim) {
472
+ // Best-effort: only when the storage adapter supports bulk noun reads
473
+ // (production FileSystemStorage/MmapFileSystemStorage do; some partial
474
+ // adapters used in direct-construction tests do not). When absent, the
475
+ // caller falls back to the in-memory rebuild.
476
+ if (typeof this.storage?.getNouns !== 'function')
477
+ return;
478
+ let cursor = undefined;
479
+ let hasMore = true;
480
+ let loaded = 0;
481
+ while (hasMore) {
482
+ const page = await this.storage.getNouns({ pagination: { limit: 10000, cursor } });
483
+ for (const noun of page.items) {
484
+ // Preserve anything restore() already seeded into the delta.
485
+ if (this.delta.has(noun.id))
486
+ continue;
487
+ // A tombstoned entity must not be resurrected from a stale canonical
488
+ // record (index-removed-but-noun-present window): the rebuild would
489
+ // bake it into the new base AND clear its tombstone at reconcile.
490
+ if (this.tombstones.has(noun.id))
491
+ continue;
492
+ const vector = noun.vector;
493
+ if (!vector || vector.length !== dim)
494
+ continue;
495
+ this.delta.set(noun.id, vector);
496
+ loaded++;
497
+ }
498
+ hasMore = page.hasMore;
499
+ cursor = page.nextCursor;
500
+ }
501
+ if (loaded > 0) {
502
+ prodLog?.info?.(`NativeDiskAnnWrapper: cold rebuild hydrated ${loaded} vectors from ` +
503
+ `canonical storage (#44 migration/restore path)`);
504
+ }
505
+ }
506
+ async search(queryVector, k = 10, filter, options) {
507
+ // Predicate pushdown: a SELECTIVE metadata∩graph universe is evaluated
508
+ // INSIDE the native walk rather than dropped afterwards.
509
+ //
510
+ // brainy supplies that universe as `allowedIds` (the #46 opaque-producer
511
+ // path, once cor exposes `getIdSetForFilter`) OR — when the producer isn't
512
+ // present — as `candidateIds`, a plain id list from its metadata pre-filter
513
+ // (brainy's fallback). They mean the same thing: restrict the search to these
514
+ // ids. Honoring `candidateIds` (not just `allowedIds`) is what makes filtered
515
+ // `find({ query, where })` recall-correct on the cor path — without it the
516
+ // fallback's restriction was dropped and a selective filter collapsed to the
517
+ // global top-k (silent recall loss). (#79 / #46 consume-side.)
518
+ const allowedIds = options?.allowedIds ??
519
+ (options?.candidateIds && options.candidateIds.length > 0
520
+ ? new Set(options.candidateIds)
521
+ : undefined);
522
+ await this.ensureSegments();
523
+ if (allowedIds && this.native && allowedIds.size > 0) {
524
+ const mainSize = this.indexedSize();
525
+ const selectivity = mainSize > 0 ? allowedIds.size / mainSize : 1;
526
+ if (selectivity <= PUSHDOWN_MAX_SELECTIVITY) {
527
+ return this.searchPushdown(queryVector, k, allowedIds, filter, options);
528
+ }
529
+ // Not selective enough to push down — fold allowedIds into the
530
+ // post-filter predicate so the result semantics are identical.
531
+ const inner = filter;
532
+ filter = async (id) => {
533
+ if (!allowedIds.has(id))
534
+ return false;
535
+ return inner ? inner(id) : true;
536
+ };
537
+ }
538
+ const lSearch = this.resolveLSearch(k);
539
+ const padding = options?.rerank?.multiplier ?? this.config.defaultPaddingFactor;
540
+ const query = Array.from(queryVector);
541
+ // Fan-out with FIRST-SEEN-BY-UUID dedup, newest source first: an updated
542
+ // entity may exist in an older segment (or the base) too — the newest
543
+ // version is authoritative, regardless of which copy scores closer.
544
+ // Priority: delta (newest) → L0 segments newest→oldest → base.
545
+ const seen = new Set();
546
+ const merged = [];
547
+ // 1. Brute-force the delta buffer (the newest state).
548
+ for (const [id, v] of this.delta) {
549
+ seen.add(id); // the delta version supersedes ALL indexed copies
550
+ if (filter && !(await filter(id)))
551
+ continue;
552
+ const d = this.distanceFunction(queryVector, v);
553
+ merged.push([id, d]);
554
+ }
555
+ // 2. Each index (L0 newest→oldest, then base): PQ-greedy walk with a
556
+ // k×2 per-segment over-fetch (the multi-shard requirement — an
557
+ // under-sampled segment holding several true top-k loses recall),
558
+ // hydrated int → uuid through the canonical mapper; tombstoned +
559
+ // filter-rejected + already-seen dropped.
560
+ const engines = [
561
+ ...[...this.l0Segments].reverse().map((s) => s.native),
562
+ ...(this.native ? [this.native] : []),
563
+ ];
564
+ if (engines.length > 0) {
565
+ const mapper = this.mapper();
566
+ for (let e = 0; e < engines.length; e++) {
567
+ const hits = engines[e].search(query, k * 2, lSearch, padding);
568
+ for (const hit of hits) {
569
+ // AUTHORITY CHECK: first-seen dedup alone is not enough — the newer
570
+ // copy only enters `seen` if its segment SURFACED it, and a top-k×2
571
+ // truncation on a large segment can miss it (a query near an updated
572
+ // entity's OLD position would then serve the stale copy at distance
573
+ // ≈0). A copy is authoritative only if NO newer source also indexes
574
+ // the entity: probe the newer engines' reverse slotmaps (cheap mmap
575
+ // lookups, ≤ segments-1 per hit). The delta (newest of all) is
576
+ // covered by the uuid `seen` pre-seed above.
577
+ let superseded = false;
578
+ for (let j = 0; j < e; j++) {
579
+ if (engines[j].hasInt(hit.id)) {
580
+ superseded = true;
581
+ break;
582
+ }
583
+ }
584
+ if (superseded)
585
+ continue;
586
+ const uuid = mapper.getUuid(Number(hit.id));
587
+ if (!uuid)
588
+ continue;
589
+ if (seen.has(uuid))
590
+ continue;
591
+ seen.add(uuid);
592
+ if (this.tombstones.has(uuid))
593
+ continue;
594
+ if (filter && !(await filter(uuid)))
595
+ continue;
596
+ merged.push([uuid, hit.distance]);
597
+ }
598
+ }
599
+ }
600
+ // 3. Sort ascending by distance, truncate to k.
601
+ merged.sort((a, b) => a[1] - b[1]);
602
+ return merged.slice(0, k);
603
+ }
604
+ /**
605
+ * Predicate-pushdown search (#10). Translates the metadata∩graph entity
606
+ * universe to main-index slots, pushes them into the native beam walk
607
+ * (which traverses *through* all nodes but only collects allowed slots),
608
+ * and inflates `l_search` by ~1/selectivity so a selective predicate still
609
+ * surfaces k allowed neighbours. Delta-buffer entries (no slot yet) are
610
+ * brute-forced. This is the high-recall path for selective filtered
611
+ * `find()` — post-filtering the unfiltered top-k loses recall when few of
612
+ * those candidates pass the predicate.
613
+ *
614
+ * @param queryVector - The query embedding.
615
+ * @param k - Number of nearest allowed neighbours to return.
616
+ * @param allowedIds - Entity UUIDs permitted in the result (the find() universe).
617
+ * @param filter - Optional additional async predicate, composed with allowedIds.
618
+ * @param options - Optional rerank multiplier override.
619
+ * @returns Up to k `[uuid, distance]` pairs, ascending by distance.
620
+ */
621
+ async searchPushdown(queryVector, k, allowedIds, filter, options) {
622
+ const padding = options?.rerank?.multiplier ?? this.config.defaultPaddingFactor;
623
+ const query = Array.from(queryVector);
624
+ const seen = new Set();
625
+ const merged = [];
626
+ // 1. Brute-force the delta buffer (allowed entries only; the newest state
627
+ // supersedes every indexed copy — first-seen dedup as in search()).
628
+ for (const [id, v] of this.delta) {
629
+ seen.add(id);
630
+ if (!allowedIds.has(id))
631
+ continue;
632
+ if (filter && !(await filter(id)))
633
+ continue;
634
+ const d = this.distanceFunction(queryVector, v);
635
+ merged.push([id, d]);
636
+ }
637
+ // 2. Native filtered walk over EACH index (L0 newest→oldest, then base;
638
+ // allowed slots only, translated per segment through ITS reverse
639
+ // slotmap — `slotsForInts` drops ints not in that segment, so each
640
+ // fan-out sees exactly its own allowed slot set).
641
+ const engines = [
642
+ ...[...this.l0Segments].reverse().map((s) => s.native),
643
+ ...(this.native ? [this.native] : []),
644
+ ];
645
+ if (engines.length > 0) {
646
+ const mapper = this.mapper();
647
+ const allowedInts = [];
648
+ for (const id of allowedIds) {
649
+ const int = mapper.getInt(id);
650
+ if (int !== undefined)
651
+ allowedInts.push(BigInt(int));
652
+ }
653
+ for (let e = 0; e < engines.length; e++) {
654
+ const engine = engines[e];
655
+ const allowedSlots = allowedInts.length > 0 ? engine.slotsForInts(allowedInts) : [];
656
+ if (allowedSlots.length === 0)
657
+ continue;
658
+ const engineSize = engine.size();
659
+ const selectivity = allowedSlots.length / Math.max(1, engineSize);
660
+ // Over-fetch so tombstone + extra-filter losses don't starve k.
661
+ const nativeK = Math.min(allowedSlots.length, Math.max(k * 2, k));
662
+ // Walk width needed to surface k allowed neighbours (~k/selectivity).
663
+ const idealL = Math.ceil((k / Math.max(selectivity, 1e-9)) * 1.5);
664
+ // Exact-scan when the allowed set is small enough to scan cheaply, OR
665
+ // when the walk would be capped (extreme selectivity). Both regimes:
666
+ // brute is faster than the walk AND exact (recall 1.0). Small L0
667
+ // segments almost always take this path — recall 1.0 by construction.
668
+ const useBrute = allowedSlots.length <= PUSHDOWN_BRUTE_MAX_ALLOWED || idealL > PUSHDOWN_MAX_LSEARCH;
669
+ let hits;
670
+ if (useBrute) {
671
+ hits = engine.searchExactSlots(query, nativeK, allowedSlots);
672
+ }
673
+ else {
674
+ const lSearch = Math.max(this.resolveLSearch(k), idealL);
675
+ hits = engine.searchFiltered(query, nativeK, allowedSlots, lSearch, padding);
676
+ }
677
+ for (const hit of hits) {
678
+ // AUTHORITY CHECK — see search(): a copy is authoritative only when
679
+ // no newer source indexes the same entity.
680
+ let superseded = false;
681
+ for (let j = 0; j < e; j++) {
682
+ if (engines[j].hasInt(hit.id)) {
683
+ superseded = true;
684
+ break;
685
+ }
686
+ }
687
+ if (superseded)
688
+ continue;
689
+ const uuid = mapper.getUuid(Number(hit.id));
690
+ if (!uuid)
691
+ continue;
692
+ if (seen.has(uuid))
693
+ continue;
694
+ seen.add(uuid);
695
+ if (this.tombstones.has(uuid))
696
+ continue;
697
+ if (filter && !(await filter(uuid)))
698
+ continue;
699
+ merged.push([uuid, hit.distance]);
700
+ }
701
+ }
702
+ }
703
+ // 3. Sort ascending by distance, truncate to k.
704
+ merged.sort((a, b) => a[1] - b[1]);
705
+ return merged.slice(0, k);
706
+ }
707
+ /**
708
+ * @description **#18 migration lock — feature-detected by Brainy.** `true` while
709
+ * the brain is undergoing (or has failed) the coordinated 7.x → 8.0 rebuild, so
710
+ * Brainy holds reads+writes behind the lock (and skips its lazy first-query
711
+ * force-rebuild of the vector index). Delegates to the shared {@link
712
+ * MigrationCoordinator}; `false` when none is wired.
713
+ * @returns `true` when the brain must not be read from or written to.
714
+ */
715
+ isMigrating() {
716
+ return this.migrationCoordinatorGetter?.()?.isMigrating() ?? false;
717
+ }
718
+ /**
719
+ * @description **#18 migration lock — structured progress** Brainy relays into
720
+ * `getIndexStatus().migration`. `null` unless a migration is in flight.
721
+ * @returns The current {@link MigrationStatus}, or `null`.
722
+ */
723
+ migrationStatus() {
724
+ return this.migrationCoordinatorGetter?.()?.migrationStatus() ?? null;
725
+ }
726
+ /**
727
+ * @description Resolve the search-time walk width for this query: the
728
+ * scale-aware default ({@link scaleAwareLSearch} over the current corpus size)
729
+ * unless the operator pinned `defaultLSearch` explicitly, floored at `k × 2`
730
+ * so a large `k` is never starved. This is the E3 fix for the measured
731
+ * fixed-default recall fade (0.9942 @ 1M → 0.8747 @ 100M at l_search=100).
732
+ * @param k - The requested result count.
733
+ * @returns The walk width to pass to the native searcher.
734
+ */
735
+ /**
736
+ * **Adaptive DiskANN mode selection (Piece I)** — build the native config for
737
+ * an index of `totalCount` vectors: the selector picks "in-memory" for brains
738
+ * that fit in RAM (no PQ, sub-ms search), "hybrid" for medium scale, "on-disk"
739
+ * when memory is tight or many brains share the box. Shared by the full
740
+ * consolidation ({@link runRebuild}) and the O(delta) L0 segment flush
741
+ * ({@link runFlushToL0}); records the selection on {@link lastSelection}.
742
+ */
743
+ nativeBuildCfg(totalCount, dim) {
744
+ const maxDegree = this.config.maxDegree;
745
+ const buildMode = this.resolveBuildMode({
746
+ nodeCount: totalCount,
747
+ dim,
748
+ maxDegree,
749
+ });
750
+ this.lastSelection = buildMode.selection;
751
+ return {
752
+ cfg: {
753
+ mode: buildMode.mode,
754
+ vamana: {
755
+ maxDegree,
756
+ searchListSize: this.config.searchListSize,
757
+ alpha: this.config.alpha,
758
+ seed: BigInt(0xd15ca4440ffff00dn),
759
+ parallel: true,
760
+ parallelBatch: 64,
761
+ },
762
+ // pq config is read only when mode is "hybrid" or "on-disk"; the napi
763
+ // surface ignores it for "in-memory". The wrapper's internal DEFAULTS
764
+ // supply pqM / pqKsub for the PQ modes.
765
+ pq: {
766
+ m: this.config.pqM,
767
+ ksub: this.config.pqKsub,
768
+ iterations: 25,
769
+ trainingSample: Math.min(200_000, totalCount),
770
+ },
771
+ adjacency: this.resolveAdjacencyBackend(totalCount),
772
+ },
773
+ };
774
+ }
775
+ resolveLSearch(k) {
776
+ const base = this.config.defaultLSearch;
777
+ const width = this.lSearchPinned ? base : scaleAwareLSearch(base, this.size());
778
+ return Math.max(width, k * 2);
779
+ }
780
+ /** Total entries across the base index + all L0 segments (no delta). */
781
+ indexedSize() {
782
+ let n = this.native ? this.native.size() : 0;
783
+ for (const seg of this.l0Segments)
784
+ n += seg.nodeCount;
785
+ return n;
786
+ }
787
+ size() {
788
+ return (this.indexedSize() +
789
+ this.delta.size -
790
+ // Tombstones against indexed entries reduce effective size. (Before
791
+ // the async segment cold-load has run, L0 counts are not yet included
792
+ // — same visibility a fresh single-index open had; ensureSegments()
793
+ // completes it on the first async operation or provider init().)
794
+ this.countMainTombstones());
795
+ }
796
+ clear() {
797
+ this.delta.clear();
798
+ this.tombstones.clear();
799
+ this.native = null;
800
+ this.l0Segments = [];
801
+ // Keep the segment memo LOADED: clear() is an in-process reset (the base
802
+ // stays null until a reopen too) — resetting the memo would resurrect the
803
+ // cleared L0s from the manifest at the next async op, and a subsequent
804
+ // rebuild() would fold the "cleared" entries back in permanently.
805
+ this.segmentsLoaded = true;
806
+ this.segmentsLoading = null;
807
+ }
808
+ /**
809
+ * Rebuild the main index from scratch: concatenate (current main −
810
+ * tombstones) ∪ delta, run a full DiskANN build, swap the searcher
811
+ * atomically.
812
+ *
813
+ * At billion-scale this is the expensive operation (hours-to-days of
814
+ * build time) — but it is NON-BLOCKING: the build runs on a worker
815
+ * thread (`rebuildFromExistingAsync`), so the JS event loop keeps
816
+ * serving reads and accepting writes for the entire fold. Writes that
817
+ * land mid-fold stay in the live delta (reconciled against the fold's
818
+ * snapshot at swap) and roll into the next fold. Concurrent calls
819
+ * coalesce onto the in-flight fold. The delta is bounded: a soft
820
+ * threshold kicks a background fold automatically and a hard cap
821
+ * applies backpressure in `addItem` (see `DELTA_SOFT_FOLD_BYTES`).
822
+ *
823
+ * The optional `recall` argument overrides the wrapper's
824
+ * construction-time recall preset for this rebuild only — useful
825
+ * when an operator wants to ship a higher-quality index after a
826
+ * data-quality push but kept the brain on `'balanced'` originally.
827
+ * Omit to keep the wrapper's current preset.
828
+ */
829
+ async rebuild(options) {
830
+ // Concurrent rebuilds COALESCE: a call while a fold is in flight awaits
831
+ // that same completion (its options are ignored — idempotent-concurrent
832
+ // semantics, so brainy's coordinated rebuild, the #18 migration, and the
833
+ // delta auto-fold can race here safely without double-building).
834
+ if (this.rebuildInFlight)
835
+ return this.rebuildInFlight;
836
+ const run = this.runRebuild(options).finally(() => {
837
+ this.rebuildInFlight = null;
838
+ });
839
+ this.rebuildInFlight = run;
840
+ return run;
841
+ }
842
+ /** The fold body — see {@link rebuild} for the public contract. */
843
+ async runRebuild(options) {
844
+ await this.ensureSegments();
845
+ const bindings = loadNativeModule();
846
+ // napi-rs exports the class as `NativeDiskAnn` (PascalCase
847
+ // normalization of the Rust ident `NativeDiskANN`). The TS type
848
+ // alias `NativeDiskANN = NativeDiskAnn` in `native/index.d.ts` is
849
+ // for backwards-compat in *types* only — at runtime there's a
850
+ // single export under the napi-normalized name.
851
+ const NativeDiskANN = bindings.NativeDiskAnn;
852
+ if (!NativeDiskANN) {
853
+ throw new Error('NativeDiskANN binding missing — rebuild requires the cor native module');
854
+ }
855
+ // Build the new logical slot ordering: (live old slots) + (delta).
856
+ // **Critical for billion-scale correctness**: the old vectors stay
857
+ // mmap'd inside the native module — we only pass slot IDs across
858
+ // the FFI boundary, not the vector data itself. At 1B × 1536 × 4
859
+ // bytes = ~6 TB this is the difference between "rebuild works" and
860
+ // "rebuild OOMs."
861
+ //
862
+ // #72 Phase A+B: there is no resident `slot ↔ uuid` map any more.
863
+ // The surviving old slots are derived from the native node count
864
+ // (ascending slot order, deterministic — keeps the Vamana entry
865
+ // point + on-disk locality stable across rebuilds), minus the slots
866
+ // of tombstoned entities (resolved uuid → int → slot through the
867
+ // canonical mapper + the off-heap reverse slotmap). Their stable
868
+ // entity ints come straight from the old forward slotmap and carry
869
+ // into the new one.
870
+ // Snapshot the fold's input sets. Writes that land while the ASYNC
871
+ // build runs stay in the live delta/tombstones and are reconciled after
872
+ // the swap — they belong to the NEXT fold.
873
+ const builtTombstones = new Set(this.tombstones);
874
+ // #73 MVP-C: snapshot the live L0 segments — this consolidation folds them
875
+ // into the new base. Their entries are read back through the existing
876
+ // readVectorChunk/slotIdsForSlots surface (each segment is bounded-small
877
+ // by construction, ≤ the delta budget, so this stays bounded RAM).
878
+ const builtL0 = [...this.l0Segments];
879
+ const l0Data = builtL0.map((seg) => ({
880
+ ints: (seg.nodeCount > 0
881
+ ? seg.native.slotIdsForSlots(Array.from({ length: seg.nodeCount }, (_, i) => i))
882
+ : []),
883
+ vecs: seg.native.readVectorChunk(0, seg.nodeCount),
884
+ }));
885
+ // Tombstone ints (used to exclude entries from BOTH the base and the L0
886
+ // read-back). Ids never indexed have no int — nothing to exclude.
887
+ const tombIntKeys = new Set();
888
+ if (builtTombstones.size > 0 && (this.native || builtL0.length > 0)) {
889
+ const mapper = this.mapper();
890
+ for (const uuid of builtTombstones) {
891
+ const int = mapper.getInt(uuid);
892
+ if (int !== undefined)
893
+ tombIntKeys.add(BigInt(int).toString());
894
+ }
895
+ }
896
+ const liveOldSlots = [];
897
+ let oldInts = [];
898
+ if (this.native) {
899
+ const oldNodeCount = this.native.size();
900
+ // Exclude from the surviving base slots: tombstoned entities, entities
901
+ // SUPERSEDED by an L0 copy (newer), and entities with a live delta copy
902
+ // (newest) — otherwise the merged index would carry duplicate entity
903
+ // ints, corrupting the reverse slotmap. Newest-wins, enforced here.
904
+ const excludeKeys = new Set(tombIntKeys);
905
+ for (const d of l0Data) {
906
+ for (const i of d.ints)
907
+ excludeKeys.add(i.toString());
908
+ }
909
+ if (this.delta.size > 0) {
910
+ const mapper = this.mapper();
911
+ for (const uuid of this.delta.keys()) {
912
+ const int = mapper.getInt(uuid);
913
+ if (int !== undefined)
914
+ excludeKeys.add(BigInt(int).toString());
915
+ }
916
+ }
917
+ const excludedSlots = new Set();
918
+ if (excludeKeys.size > 0) {
919
+ const excludeInts = [...excludeKeys].map((k) => BigInt(k));
920
+ for (const slot of this.native.slotsForInts(excludeInts)) {
921
+ excludedSlots.add(slot);
922
+ }
923
+ }
924
+ for (let s = 0; s < oldNodeCount; s++) {
925
+ if (!excludedSlots.has(s))
926
+ liveOldSlots.push(s);
927
+ }
928
+ oldInts =
929
+ liveOldSlots.length > 0 ? this.native.slotIdsForSlots(liveOldSlots) : [];
930
+ }
931
+ // #44 migration / cold-rebuild from canonical storage. brainy's
932
+ // `restore()` reconstructs the vector index by calling `rebuild()`
933
+ // directly (`this.index` IS this wrapper), but a freshly-opened
934
+ // wrapper over the restored dir has no loaded native index — the
935
+ // canonical noun vectors live only in storage, and restore() does NOT
936
+ // re-feed them through addItem (it may seed only a straggler such as
937
+ // the VFS root). Without this the rebuilt index comes back missing its
938
+ // entities and post-migration vector search silently returns nothing
939
+ // (the migrate script's count-based verify can't catch it).
940
+ //
941
+ // Fires ONLY when there is no surviving main index AND storage holds
942
+ // more nouns than the in-memory working set — i.e. a cold/restore
943
+ // rebuild that would otherwise drop canonical vectors. A normal first
944
+ // build (delta already covers every persisted noun) and a live rebuild
945
+ // (non-empty `liveOldSlots`) both skip it, so the hot paths pay
946
+ // nothing. `hydrateDeltaFromStorage` only adds ids absent from the
947
+ // delta, so any straggler already fed by restore is preserved.
948
+ if (liveOldSlots.length === 0 && typeof this.storage?.getNouns === 'function') {
949
+ const probe = await this.storage.getNouns({ pagination: { limit: 1 } });
950
+ if (probe.totalCount === undefined || probe.totalCount > this.delta.size) {
951
+ await this.hydrateDeltaFromStorage(this.config.dimensions);
952
+ }
953
+ }
954
+ // Delta snapshot AFTER the cold-hydrate (which populates this.delta).
955
+ const builtDelta = new Map(this.delta);
956
+ const dim = this.config.dimensions;
957
+ const deltaCount = builtDelta.size;
958
+ let deltaBuf = null;
959
+ const deltaInts = [];
960
+ if (deltaCount > 0) {
961
+ const mapper = this.mapper();
962
+ deltaBuf = new Float32Array(deltaCount * dim);
963
+ let idx = 0;
964
+ for (const [uuid, vector] of builtDelta) {
965
+ if (vector.length !== dim) {
966
+ throw new Error(`NativeDiskAnnWrapper.rebuild: vector dim ${vector.length} ≠ index dim ${dim}`);
967
+ }
968
+ deltaBuf.set(vector, idx * dim);
969
+ // Entity int for this delta uuid. In production the canonical
970
+ // mapper already assigned it at metadata write time, so
971
+ // getOrAssign returns that existing int (the unit-test mapper
972
+ // assigns on demand) — keeping the slotmap, bitmaps, and graph
973
+ // endpoints on one 0-based int space.
974
+ deltaInts.push(BigInt(mapper.getOrAssign(uuid)));
975
+ idx++;
976
+ }
977
+ }
978
+ // #73: the L0 entries that survive dedup — newest segment wins, the delta
979
+ // wins over every segment, tombstoned entities drop.
980
+ const deltaIntKeys = new Set(deltaInts.map((i) => i.toString()));
981
+ const l0Entries = [];
982
+ {
983
+ const seenL0 = new Set();
984
+ for (let sIdx = builtL0.length - 1; sIdx >= 0; sIdx--) {
985
+ const ints = l0Data[sIdx].ints;
986
+ for (let r = 0; r < ints.length; r++) {
987
+ const key = ints[r].toString();
988
+ if (seenL0.has(key) || deltaIntKeys.has(key) || tombIntKeys.has(key))
989
+ continue;
990
+ seenL0.add(key);
991
+ l0Entries.push({ int: ints[r], seg: sIdx, row: r });
992
+ }
993
+ }
994
+ }
995
+ if (liveOldSlots.length + deltaCount + l0Entries.length === 0) {
996
+ prodLog?.warn?.('NativeDiskAnnWrapper.rebuild: nothing to build');
997
+ return;
998
+ }
999
+ const totalCount = liveOldSlots.length + deltaCount + l0Entries.length;
1000
+ // Apply optional recall override for this rebuild only.
1001
+ if (options?.recall) {
1002
+ const preset = RECALL_PRESETS[options.recall];
1003
+ this.config = { ...this.config, ...preset };
1004
+ }
1005
+ const { cfg } = this.nativeBuildCfg(totalCount, dim);
1006
+ // Ensure the .dkann's parent dir exists — the native build writes straight
1007
+ // to outputPath and won't create intermediate dirs (e.g. _system/vector-index).
1008
+ mkdirSync(dirname(this.config.indexPath), { recursive: true });
1009
+ // Per-slot entity ints for the new index: [live old ints..., delta
1010
+ // ints...] — slot `i` ↔ `slotIds[i]`, matching the native
1011
+ // MergedVectorSource layout (live old slots first, delta tail
1012
+ // second). The native side writes this into the off-heap forward +
1013
+ // reverse slotmaps atomically with the new `.dkann`.
1014
+ // Merged tail = [delta entries..., deduped L0 entries...]. Slot i maps to
1015
+ // [live old slots..., tail...] in the MergedVectorSource layout; slotIds
1016
+ // aligns 1:1 with that order.
1017
+ const tailCount = deltaCount + l0Entries.length;
1018
+ const tailBuf = new Float32Array(tailCount * dim);
1019
+ if (deltaBuf)
1020
+ tailBuf.set(deltaBuf, 0);
1021
+ for (let e = 0; e < l0Entries.length; e++) {
1022
+ const { seg, row } = l0Entries[e];
1023
+ const vecs = l0Data[seg].vecs;
1024
+ const view = new Float32Array(vecs.buffer, vecs.byteOffset + row * dim * 4, dim);
1025
+ tailBuf.set(view, (deltaCount + e) * dim);
1026
+ }
1027
+ const totalSlotInts = liveOldSlots.length + tailCount;
1028
+ const slotIdsArr = new BigUint64Array(totalSlotInts);
1029
+ for (let i = 0; i < oldInts.length; i++)
1030
+ slotIdsArr[i] = oldInts[i];
1031
+ for (let i = 0; i < deltaInts.length; i++) {
1032
+ slotIdsArr[oldInts.length + i] = deltaInts[i];
1033
+ }
1034
+ for (let e = 0; e < l0Entries.length; e++) {
1035
+ slotIdsArr[oldInts.length + deltaCount + e] = l0Entries[e].int;
1036
+ }
1037
+ const slotIdsBuf = Buffer.from(slotIdsArr.buffer, slotIdsArr.byteOffset, slotIdsArr.byteLength);
1038
+ // The heavy build runs on a worker thread (AsyncTask): the JS event
1039
+ // loop keeps serving reads AND accepting writes for the entire fold —
1040
+ // hours-to-days at billion scale, zero freeze.
1041
+ const newNative = await NativeDiskANN.rebuildFromExistingAsync({
1042
+ existingPath: this.native ? this.config.indexPath : undefined,
1043
+ liveOldSlots,
1044
+ deltaVectors: tailCount > 0
1045
+ ? Buffer.from(tailBuf.buffer, tailBuf.byteOffset, tailBuf.byteLength)
1046
+ : undefined,
1047
+ deltaCount: tailCount,
1048
+ dim,
1049
+ outputPath: this.config.indexPath,
1050
+ cfg,
1051
+ slotIds: slotIdsBuf,
1052
+ });
1053
+ // Atomic swap. The native engine now owns both off-heap slotmaps
1054
+ // (written inside rebuildFromExisting), so there is NO resident
1055
+ // `slot ↔ uuid` map to reconstruct and NO `.slots.json` to persist —
1056
+ // the #72 Phase A+B win. slot ↔ uuid resolution flows through the
1057
+ // mmap slotmaps + the canonical mapper on every query.
1058
+ this.native = newNative;
1059
+ // Reconcile the LIVE delta/tombstones against the fold snapshot: only
1060
+ // entries the fold actually baked in are cleared. Adds/updates/removes
1061
+ // that landed during the async build stay behind for the next fold (an
1062
+ // id UPDATED mid-fold keeps its new vector — reference inequality
1063
+ // detects the overwrite).
1064
+ for (const [id, vector] of builtDelta) {
1065
+ if (this.delta.get(id) === vector)
1066
+ this.delta.delete(id);
1067
+ }
1068
+ for (const id of builtTombstones)
1069
+ this.tombstones.delete(id);
1070
+ // #73: the consolidation folded the snapshot L0 segments into the new
1071
+ // base. Manifest FIRST drops the references, THEN the files are deleted
1072
+ // (the graph index's crash-ordering invariant: a crash between the two
1073
+ // leaves orphan files, never a manifest pointing at a missing segment).
1074
+ // L0 segments created by a concurrent flush DURING this build are not in
1075
+ // the snapshot and survive untouched.
1076
+ if (builtL0.length > 0) {
1077
+ this.l0Segments = this.l0Segments.filter((seg) => !builtL0.includes(seg));
1078
+ await this.saveSegmentManifest();
1079
+ for (const seg of builtL0) {
1080
+ for (const suffix of ['.dkann', '.slotmap', '.slotrev']) {
1081
+ try {
1082
+ rmSync(this.segPath(seg.file).replace(/\.dkann$/, suffix));
1083
+ }
1084
+ catch {
1085
+ // best-effort orphan cleanup — leaked bytes, never a correctness issue
1086
+ }
1087
+ }
1088
+ }
1089
+ }
1090
+ }
1091
+ /**
1092
+ * Flush the delta buffer to disk. For DiskANN the delta is in-memory
1093
+ * by design (a few MB at most between rebuilds); returns the buffer
1094
+ * size for parity with HNSW's flush contract.
1095
+ */
1096
+ async flush() {
1097
+ return this.delta.size;
1098
+ }
1099
+ getPersistMode() {
1100
+ return this.persistMode;
1101
+ }
1102
+ /** Absolute path of a segment file (lives next to the base indexPath). */
1103
+ segPath(file) {
1104
+ return `${dirname(this.config.indexPath)}/${file}`;
1105
+ }
1106
+ /** Membership across the base + every L0 segment (int → any slot). */
1107
+ hasIntAnywhere(int) {
1108
+ if (this.native && this.native.hasInt(int))
1109
+ return true;
1110
+ for (const seg of this.l0Segments) {
1111
+ if (seg.native.hasInt(int))
1112
+ return true;
1113
+ }
1114
+ return false;
1115
+ }
1116
+ /**
1117
+ * @description Provider init — brainy feature-detects and awaits it. Cold-loads
1118
+ * the segment manifest so a reopened brain serves its L0 segments from the
1119
+ * first query (the wrapper's own async paths also call {@link ensureSegments},
1120
+ * so a caller that skips init() still gets the segments lazily).
1121
+ */
1122
+ async init() {
1123
+ await this.ensureSegments();
1124
+ }
1125
+ /** Memoized segment-manifest cold-load. */
1126
+ async ensureSegments() {
1127
+ if (this.segmentsLoaded)
1128
+ return;
1129
+ if (this.segmentsLoading)
1130
+ return this.segmentsLoading;
1131
+ this.segmentsLoading = this.loadSegmentManifest().finally(() => {
1132
+ this.segmentsLoaded = true;
1133
+ this.segmentsLoading = null;
1134
+ });
1135
+ return this.segmentsLoading;
1136
+ }
1137
+ /**
1138
+ * Read `__vector_index_manifest__` and open every live L0 segment. A missing
1139
+ * manifest is the legacy single-index layout (the base at `indexPath` IS the
1140
+ * whole vector index) — existing brains upgrade seamlessly; the first flush
1141
+ * writes the first manifest. A segment file the manifest references but that
1142
+ * fails to open is SKIPPED loudly (crash-orphan tolerance; its entries remain
1143
+ * safe in canonical storage and the next consolidation heals the set).
1144
+ */
1145
+ async loadSegmentManifest() {
1146
+ const storage = this.storage;
1147
+ if (!storage || typeof storage.getMetadata !== 'function')
1148
+ return;
1149
+ let manifest = null;
1150
+ try {
1151
+ const raw = await storage.getMetadata(VECTOR_MANIFEST_KEY);
1152
+ manifest = raw?.data ?? null;
1153
+ }
1154
+ catch {
1155
+ return; // unreadable manifest = legacy layout; the next flush rewrites it
1156
+ }
1157
+ if (!manifest)
1158
+ return;
1159
+ this.manifestNextId = Math.max(1, Number(manifest.nextId) || 1);
1160
+ const bindings = loadNativeModule();
1161
+ const NativeDiskANN = bindings.NativeDiskAnn;
1162
+ if (!NativeDiskANN)
1163
+ return;
1164
+ for (const entry of manifest.l0 ?? []) {
1165
+ try {
1166
+ const native = NativeDiskANN.openExisting(this.segPath(entry.file));
1167
+ if (!native.slotmapReady())
1168
+ throw new Error('segment slotmap missing');
1169
+ this.l0Segments.push({ ...entry, native });
1170
+ }
1171
+ catch (error) {
1172
+ prodLog.warn(`NativeDiskAnnWrapper: skipping unreadable L0 segment ${entry.file} ` +
1173
+ `(${error instanceof Error ? error.message : String(error)}) — its ` +
1174
+ `entries remain in canonical storage; consolidation heals the set`);
1175
+ }
1176
+ }
1177
+ }
1178
+ /**
1179
+ * Persist the live segment set (AFTER the referenced files exist on disk).
1180
+ * Saves are SERIALIZED and each snapshot the segment list at write time, so
1181
+ * concurrent flush/consolidation saves can never persist out of order.
1182
+ */
1183
+ saveSegmentManifest() {
1184
+ const next = this.manifestSaveChain.then(() => this.writeSegmentManifestNow());
1185
+ // The chain must survive a failed save (the failure still propagates to
1186
+ // THIS caller through `next`).
1187
+ this.manifestSaveChain = next.catch(() => { });
1188
+ return next;
1189
+ }
1190
+ async writeSegmentManifestNow() {
1191
+ const storage = this.storage;
1192
+ if (!storage || typeof storage.saveMetadata !== 'function')
1193
+ return;
1194
+ await storage.saveMetadata(VECTOR_MANIFEST_KEY, {
1195
+ noun: 'thing',
1196
+ data: {
1197
+ version: 1,
1198
+ nextId: this.manifestNextId,
1199
+ l0: this.l0Segments.map(({ id, file, nodeCount }) => ({ id, file, nodeCount })),
1200
+ },
1201
+ });
1202
+ }
1203
+ /**
1204
+ * @description **#73 MVP-C — flush the delta into a new immutable L0 segment.**
1205
+ * O(new-data): builds a small self-contained `.dkann` from the delta snapshot
1206
+ * alone (existing index untouched), registers it in the manifest, and
1207
+ * reconciles the live delta. Concurrent calls coalesce. Crash ordering: the
1208
+ * segment file is fully written (with its slotmap sidecars) BEFORE the
1209
+ * manifest references it.
1210
+ */
1211
+ flushDeltaToL0() {
1212
+ if (this.flushInFlight)
1213
+ return this.flushInFlight;
1214
+ const run = this.runFlushToL0().finally(() => {
1215
+ this.flushInFlight = null;
1216
+ });
1217
+ this.flushInFlight = run;
1218
+ return run;
1219
+ }
1220
+ /** The flush body — see {@link flushDeltaToL0}. */
1221
+ async runFlushToL0() {
1222
+ await this.ensureSegments();
1223
+ const bindings = loadNativeModule();
1224
+ const NativeDiskANN = bindings.NativeDiskAnn;
1225
+ if (!NativeDiskANN) {
1226
+ throw new Error('NativeDiskANN binding missing — L0 flush requires the cor native module');
1227
+ }
1228
+ const builtDelta = new Map(this.delta);
1229
+ if (builtDelta.size === 0)
1230
+ return;
1231
+ const dim = this.config.dimensions;
1232
+ const mapper = this.mapper();
1233
+ const deltaBuf = new Float32Array(builtDelta.size * dim);
1234
+ const slotIdsArr = new BigUint64Array(builtDelta.size);
1235
+ let idx = 0;
1236
+ for (const [uuid, vector] of builtDelta) {
1237
+ if (vector.length !== dim) {
1238
+ throw new Error(`NativeDiskAnnWrapper.flushDeltaToL0: vector dim ${vector.length} ≠ index dim ${dim}`);
1239
+ }
1240
+ deltaBuf.set(vector, idx * dim);
1241
+ slotIdsArr[idx] = BigInt(mapper.getOrAssign(uuid));
1242
+ idx++;
1243
+ }
1244
+ const id = this.manifestNextId++;
1245
+ const file = `seg-${id}.dkann`;
1246
+ mkdirSync(dirname(this.config.indexPath), { recursive: true });
1247
+ const { cfg } = this.nativeBuildCfg(builtDelta.size, dim);
1248
+ const newNative = await NativeDiskANN.rebuildFromExistingAsync({
1249
+ existingPath: undefined,
1250
+ liveOldSlots: [],
1251
+ deltaVectors: Buffer.from(deltaBuf.buffer, deltaBuf.byteOffset, deltaBuf.byteLength),
1252
+ deltaCount: builtDelta.size,
1253
+ dim,
1254
+ outputPath: this.segPath(file),
1255
+ cfg,
1256
+ slotIds: Buffer.from(slotIdsArr.buffer, slotIdsArr.byteOffset, slotIdsArr.byteLength),
1257
+ });
1258
+ // File (+ sidecars) durably on disk → register → manifest (crash-safe order).
1259
+ this.l0Segments.push({ id, file, nodeCount: builtDelta.size, native: newNative });
1260
+ await this.saveSegmentManifest();
1261
+ // Reconcile the LIVE delta against the flush snapshot (same rule as the
1262
+ // consolidation fold): only unchanged entries clear; an id updated
1263
+ // mid-flush keeps its newer vector for the next flush. Tombstones are NOT
1264
+ // cleared — they still mask the base and, now, this segment's stale copies.
1265
+ for (const [uuid, vector] of builtDelta) {
1266
+ if (this.delta.get(uuid) === vector)
1267
+ this.delta.delete(uuid);
1268
+ }
1269
+ prodLog.info(`NativeDiskAnnWrapper: flushed ${builtDelta.size} delta entries to L0 segment ${file} ` +
1270
+ `(${this.l0Segments.length} live segments)`);
1271
+ // Tiered maintenance: enough small segments → one BACKGROUND consolidation
1272
+ // (the async rebuild folds base + L0s + delta into a fresh base off-thread).
1273
+ if (this.l0Segments.length >= L0_COMPACT_THRESHOLD && !this.rebuildInFlight) {
1274
+ this.rebuild().catch((error) => {
1275
+ prodLog.error(`NativeDiskAnnWrapper: background consolidation failed (segments ` +
1276
+ `retained; retries at the next trigger): ` +
1277
+ `${error instanceof Error ? error.message : String(error)}`);
1278
+ });
1279
+ }
1280
+ }
1281
+ tryOpenExisting() {
1282
+ try {
1283
+ const bindings = loadNativeModule();
1284
+ // napi-rs exports the class as `NativeDiskAnn` (PascalCase
1285
+ // normalization of the Rust ident `NativeDiskANN`). The TS
1286
+ // type alias `NativeDiskANN = NativeDiskAnn` in
1287
+ // `native/index.d.ts` is for backwards-compat in *types*
1288
+ // only — at runtime there's a single export under the
1289
+ // napi-normalized name.
1290
+ const NativeDiskANN = bindings.NativeDiskAnn;
1291
+ if (!NativeDiskANN)
1292
+ return;
1293
+ // **Adaptive DiskANN open-mode selection (Piece I).** Open
1294
+ // with Auto first to read the header; the selector then
1295
+ // decides whether the file should be reopened with a
1296
+ // different mode. Common case (Auto's resolution matches the
1297
+ // selector's pick) is a single open; only mismatches pay the
1298
+ // reopen cost.
1299
+ let native = NativeDiskANN.openExisting(this.config.indexPath);
1300
+ const header = native.header();
1301
+ const stats = {
1302
+ nodeCount: header.nodeCount,
1303
+ dim: header.dim,
1304
+ maxDegree: header.maxDegree,
1305
+ pqM: header.pqM,
1306
+ };
1307
+ const selection = this.resolveOpenMode(stats);
1308
+ this.lastSelection = selection.selection;
1309
+ const autoMode = autoModeForHeader({ pqM: header.pqM });
1310
+ if (selection.mode !== autoMode) {
1311
+ native = NativeDiskANN.openExisting(this.config.indexPath, selection.mode);
1312
+ }
1313
+ this.native = native;
1314
+ // #72 Phase A+B: the native open attaches the off-heap slot↔int
1315
+ // sidecars (`main.slotmap` / `main.slotrev`). The native engine
1316
+ // stores vectors by slot only, so without a forward slotmap it
1317
+ // can't map hits back to entity ints — discard such an index so
1318
+ // the next rebuild() regenerates both consistently (matches the
1319
+ // old loadSlots()-returned-false behaviour, minus the JS map +
1320
+ // .slots.json parse). No mapper is needed here; slot↔uuid
1321
+ // resolution happens lazily at query/rebuild time.
1322
+ if (!this.native.slotmapReady()) {
1323
+ this.native = null;
1324
+ }
1325
+ else {
1326
+ this.removeLegacySlotsSidecar();
1327
+ }
1328
+ }
1329
+ catch {
1330
+ // No existing file — index stays empty until first rebuild().
1331
+ this.native = null;
1332
+ }
1333
+ }
1334
+ /**
1335
+ * Remove any leftover `main.slots.json` written by a pre-#72 RC build.
1336
+ * The native `main.slotmap` is now authoritative; a stale JSON sidecar
1337
+ * is never read, but we delete it (best-effort) so it can never shadow
1338
+ * the off-heap slotmap and to reclaim its (potentially large) bytes.
1339
+ */
1340
+ removeLegacySlotsSidecar() {
1341
+ try {
1342
+ const legacy = this.config.indexPath.replace(/\.dkann$/, '.slots.json');
1343
+ if (existsSync(legacy))
1344
+ rmSync(legacy, { force: true });
1345
+ }
1346
+ catch {
1347
+ // Best-effort cleanup; a failure here never affects correctness.
1348
+ }
1349
+ }
1350
+ /**
1351
+ * **Piece I.** Resolve the open-time mode for an existing file.
1352
+ * Respects an explicit `config.mode` override; otherwise consults
1353
+ * the adaptive selector.
1354
+ */
1355
+ resolveOpenMode(stats) {
1356
+ const explicit = this.config.mode;
1357
+ if (explicit !== 'auto') {
1358
+ // Synthesise a minimal ModeSelection so telemetry callers
1359
+ // still see a populated lastSelection. estimatedBytes left
1360
+ // at 0 — the override skipped the cost calc.
1361
+ return {
1362
+ mode: explicit,
1363
+ selection: {
1364
+ mode: explicit,
1365
+ reason: 'no-pq-file',
1366
+ estimatedBytes: 0,
1367
+ perBrainAvailable: 0,
1368
+ },
1369
+ };
1370
+ }
1371
+ const selection = selectModeFromResourceManager(stats);
1372
+ return { mode: selection.mode, selection };
1373
+ }
1374
+ /**
1375
+ * **Piece I.** Resolve the build-time mode. Same override-respecting
1376
+ * shape as {@link resolveOpenMode}; the build-time decision
1377
+ * additionally lets the selector choose Mode 1 (no PQ) when the
1378
+ * brain fits in RAM.
1379
+ */
1380
+ resolveBuildMode(stats) {
1381
+ const explicit = this.config.mode;
1382
+ if (explicit !== 'auto') {
1383
+ return {
1384
+ mode: explicit,
1385
+ selection: {
1386
+ mode: explicit,
1387
+ reason: explicit === 'in-memory' ? 'fits-in-memory' : 'codes-pinned',
1388
+ estimatedBytes: 0,
1389
+ perBrainAvailable: 0,
1390
+ },
1391
+ };
1392
+ }
1393
+ const selection = selectModeFromResourceManager(stats);
1394
+ return { mode: selection.mode, selection };
1395
+ }
1396
+ /**
1397
+ * **Piece I.** The selector's most recent decision (either at the
1398
+ * last `tryOpenExisting` or `rebuild`). Returns `null` if no
1399
+ * brain has been opened or built yet. Surface for telemetry and
1400
+ * tests.
1401
+ */
1402
+ getLastModeSelection() {
1403
+ return this.lastSelection;
1404
+ }
1405
+ /**
1406
+ * **Test-only hook** for exercising the build-adjacency resolver
1407
+ * without spinning up a real build. Mirrors the
1408
+ * `_testInjectOsMemoryProbe` pattern (Piece J); not part of the
1409
+ * public contract.
1410
+ * @internal
1411
+ */
1412
+ _testResolveAdjacencyBackend(nodeCount) {
1413
+ return this.resolveAdjacencyBackend(nodeCount);
1414
+ }
1415
+ /**
1416
+ * Resolve the build-time adjacency backend from the config plus
1417
+ * the actual node count. `useMmapAdjacency: 'auto'` (the default)
1418
+ * picks file-backed once `nodeCount > MMAP_ADJACENCY_AUTO_THRESHOLD`;
1419
+ * explicit `true` / `false` always wins. Per the zero-config
1420
+ * principle, operators don't have to know the 100 M threshold —
1421
+ * the wrapper observes `totalCount` and flips automatically.
1422
+ */
1423
+ resolveAdjacencyBackend(nodeCount) {
1424
+ const setting = this.config.useMmapAdjacency;
1425
+ const useMmap = setting === 'auto'
1426
+ ? nodeCount > MMAP_ADJACENCY_AUTO_THRESHOLD
1427
+ : setting === true;
1428
+ if (useMmap) {
1429
+ return {
1430
+ kind: 'mmap',
1431
+ mmapPath: this.config.mmapAdjacencyPath ?? `${this.config.indexPath}.adj`,
1432
+ };
1433
+ }
1434
+ return { kind: 'ram' };
1435
+ }
1436
+ /**
1437
+ * Count tombstoned entities that are present in the MAIN index (so
1438
+ * `size()` can subtract them). Membership is the off-heap reverse
1439
+ * slotmap: resolve uuid → int → `hasInt`. Returns 0 without touching
1440
+ * the mapper when there are no tombstones or no main index, so the
1441
+ * common `size()` call stays allocation- and FFI-free.
1442
+ */
1443
+ countMainTombstones() {
1444
+ if (this.tombstones.size === 0 || !this.native)
1445
+ return 0;
1446
+ const mapper = this.mapper();
1447
+ let n = 0;
1448
+ for (const uuid of this.tombstones) {
1449
+ const int = mapper.getInt(uuid);
1450
+ if (int !== undefined && this.hasIntAnywhere(BigInt(int)))
1451
+ n++;
1452
+ }
1453
+ return n;
1454
+ }
1455
+ }
1456
+ //# sourceMappingURL=NativeDiskAnnWrapper.js.map