@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.
- package/LICENSE +16 -0
- package/README.md +328 -0
- package/dist/aggregation/NativeAggregationEngine.d.ts +118 -0
- package/dist/aggregation/NativeAggregationEngine.js +186 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.js +236 -0
- package/dist/graph/GraphAccelerationAdapter.d.ts +153 -0
- package/dist/graph/GraphAccelerationAdapter.js +326 -0
- package/dist/graph/NativeGraphAdjacencyIndex.d.ts +286 -0
- package/dist/graph/NativeGraphAdjacencyIndex.js +944 -0
- package/dist/hnsw/AdaptiveDiskAnnModeSelector.d.ts +187 -0
- package/dist/hnsw/AdaptiveDiskAnnModeSelector.js +313 -0
- package/dist/hnsw/NativeDiskAnnWrapper.d.ts +489 -0
- package/dist/hnsw/NativeDiskAnnWrapper.js +1456 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +29 -0
- package/dist/legacyLayoutGuard.d.ts +93 -0
- package/dist/legacyLayoutGuard.js +237 -0
- package/dist/license/constants.d.ts +30 -0
- package/dist/license/constants.js +30 -0
- package/dist/license/onlineValidator.d.ts +43 -0
- package/dist/license/onlineValidator.js +225 -0
- package/dist/license/types.d.ts +39 -0
- package/dist/license/types.js +5 -0
- package/dist/license.d.ts +64 -0
- package/dist/license.js +165 -0
- package/dist/migration/MigrationCoordinator.d.ts +171 -0
- package/dist/migration/MigrationCoordinator.js +249 -0
- package/dist/migration/indexEpochGuard.d.ts +163 -0
- package/dist/migration/indexEpochGuard.js +202 -0
- package/dist/native/NativeEmbeddingEngine.d.ts +79 -0
- package/dist/native/NativeEmbeddingEngine.js +318 -0
- package/dist/native/NativeRoaringBitmap32.d.ts +114 -0
- package/dist/native/NativeRoaringBitmap32.js +221 -0
- package/dist/native/ffi.d.ts +20 -0
- package/dist/native/ffi.js +48 -0
- package/dist/native/idMapperTestSupport.d.ts +67 -0
- package/dist/native/idMapperTestSupport.js +112 -0
- package/dist/native/index.d.ts +49 -0
- package/dist/native/index.js +87 -0
- package/dist/native/napi.d.ts +21 -0
- package/dist/native/napi.js +88 -0
- package/dist/native/types.d.ts +1298 -0
- package/dist/native/types.js +16 -0
- package/dist/plugin.d.ts +50 -0
- package/dist/plugin.js +388 -0
- package/dist/providerContracts.d.ts +277 -0
- package/dist/providerContracts.js +38 -0
- package/dist/resource/OsMemoryProbe.d.ts +175 -0
- package/dist/resource/OsMemoryProbe.js +206 -0
- package/dist/resource/ResourceManager.d.ts +491 -0
- package/dist/resource/ResourceManager.js +960 -0
- package/dist/utils/ColumnManifest.d.ts +97 -0
- package/dist/utils/ColumnManifest.js +129 -0
- package/dist/utils/NativeColumnStore.d.ts +284 -0
- package/dist/utils/NativeColumnStore.js +685 -0
- package/dist/utils/NativeMetadataIndex.d.ts +882 -0
- package/dist/utils/NativeMetadataIndex.js +2631 -0
- package/dist/utils/NativeUnifiedCache.d.ts +87 -0
- package/dist/utils/NativeUnifiedCache.js +273 -0
- package/dist/utils/binaryIdMapperFactory.d.ts +59 -0
- package/dist/utils/binaryIdMapperFactory.js +94 -0
- package/dist/utils/collation.d.ts +30 -0
- package/dist/utils/collation.js +40 -0
- package/dist/utils/columnStoreTypes.d.ts +161 -0
- package/dist/utils/columnStoreTypes.js +29 -0
- package/dist/utils/nativeBinaryEntityIdMapper.d.ts +211 -0
- package/dist/utils/nativeBinaryEntityIdMapper.js +381 -0
- package/dist/utils/nativeEntityIdMapper.d.ts +111 -0
- package/dist/utils/nativeEntityIdMapper.js +170 -0
- package/docs/ADR-002-diskann-100-percent-rust.md +294 -0
- package/docs/ADR-003-semantic-time-travel.md +100 -0
- package/docs/aggregation.md +251 -0
- package/docs/comparison.md +162 -0
- package/docs/deployment-limits.md +87 -0
- package/docs/diskann.md +184 -0
- package/docs/migration-3.0.md +396 -0
- package/docs/performance-budget.md +274 -0
- package/docs/performance.md +117 -0
- package/docs/scaling.md +439 -0
- package/docs/snapshot-safety.md +246 -0
- package/docs/u64-id-space.md +214 -0
- package/docs/verification-report.md +670 -0
- package/native/brainy-native.node +0 -0
- package/native/index.d.ts +3916 -0
- package/package.json +92 -0
- 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
|