pikelet-wasm 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,799 @@
1
+ 'use strict';
2
+ // Range artifact profile (.pancake-range) — spec/SEARCH_ARTIFACT_CONTRACT.md 9.2:
3
+ // PancakeRangeArtifact reader and the range-artifact builder.
4
+ // Split out of pancake-artifact.js (the public entry, which re-exports the
5
+ // three parts); see that file for the module map.
6
+
7
+ const { pancakeError, PANCAKE_ERROR_CODES } = require('./pancake-errors.js');
8
+ const {
9
+ MAX_COALESCED_RANGE_BYTES,
10
+ readChecked,
11
+ resolveMaxReadBytes,
12
+ resolveMaxCacheBytes,
13
+ readU32,
14
+ readU16,
15
+ readF32,
16
+ compareDistancesAsc,
17
+ MinHeap,
18
+ NodeFileRangeSource,
19
+ normalizeQuery,
20
+ parseUint8Snapshot,
21
+ verifySha256,
22
+ verifySegmentSha256,
23
+ resolveSearchK,
24
+ resolveOptionalPositiveInt,
25
+ } = require('./pancake-artifact-common.js');
26
+
27
+ const RANGE_MAGIC = 0x31415250; // PRA1
28
+ const HEADER_BYTES = 128;
29
+ const HEADER_BYTES_V2 = 256;
30
+ const RANGE_KIND_U8 = 1;
31
+ // v3 appends three whole-segment SHA-256 digests (id map, router, base) in
32
+ // the second half of the 256-byte header, after the v2 field block.
33
+ const RANGE_VERSION = 3;
34
+ const RANGE_DIGESTS_OFFSET = 128;
35
+ const RANGE_DIGEST_BYTES = 32;
36
+ const ROUTER_LOCATION_MASK = 0x80000000;
37
+ const LOCATION_ORDINAL_MASK = 0x7fffffff;
38
+ class PancakeRangeArtifact {
39
+ constructor(source, header, idMap, options = {}) {
40
+ this.source = source;
41
+ this.version = header.version;
42
+ this.kind = header.kind;
43
+ this.dim = header.dim;
44
+ this.count = header.count;
45
+ this.entryPoint = header.entryPoint;
46
+ this.maxLevel = header.maxLevel;
47
+ this.M = header.M;
48
+ this.M0 = header.M0;
49
+ this.metric = header.metric;
50
+ this.recordBytes = header.recordBytes;
51
+ this.idMapOffset = header.idMapOffset;
52
+ this.recordsOffset = header.recordsOffset;
53
+ this.parts = header.parts;
54
+ this.routerCount = header.routerCount;
55
+ this.baseCount = header.baseCount;
56
+ this.routerRecordsOffset = header.routerRecordsOffset;
57
+ this.baseRecordsOffset = header.baseRecordsOffset;
58
+ this.originalToLocation = idMap;
59
+ this.cache = new Map();
60
+ // Lazily fetched records live in a byte-budgeted LRU so a long-lived
61
+ // reader stays bounded; the router segment (this.cache) is permanent.
62
+ this.lazyCache = new Map();
63
+ this.lazyCacheBytes = 0;
64
+ this.maxCacheBytes = resolveMaxCacheBytes(options.maxCacheBytes, 64 * this.recordBytes, 64 * 1024 * 1024);
65
+ this.currentRanges = [];
66
+ this.routerResident = { records: 0, bytes: 0 };
67
+ this.rangeRequests = 0;
68
+ this.rangeBytes = 0;
69
+ this.rangeNodesDecoded = 0;
70
+ this.loadRouter = options.loadRouter !== false;
71
+ this.maxReadBytes = resolveMaxReadBytes(options.maxReadBytes);
72
+ this.verify = options.verify !== false;
73
+ // v3 artifacts carry whole-segment digests; older versions have none.
74
+ this.digests = null;
75
+ this.segmentVerified = { idMap: false, router: false, base: false };
76
+ }
77
+
78
+ static async open(source, options = {}) {
79
+ if (!source || typeof source.read !== 'function') {
80
+ throw pancakeError(PANCAKE_ERROR_CODES.INVALID_ARGUMENT, 'PancakeRangeArtifact.open() requires a range source with read(offset, length)');
81
+ }
82
+ const headerBytes = await readChecked(source, 0, HEADER_BYTES, 'header');
83
+ if (headerBytes.byteLength < HEADER_BYTES) {
84
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Range artifact header is truncated');
85
+ }
86
+ const header = parseHeader(headerBytes);
87
+ // Resolved before construction: the id-map read below is driven by an
88
+ // untrusted header count and must respect the caller's budget.
89
+ const maxReadBytes = resolveMaxReadBytes(options.maxReadBytes);
90
+ let digests = null;
91
+ if (header.version >= 3) {
92
+ const digestBytes = await readChecked(source, RANGE_DIGESTS_OFFSET, RANGE_DIGEST_BYTES * 3, 'header digests');
93
+ if (digestBytes.byteLength !== RANGE_DIGEST_BYTES * 3) {
94
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Range artifact header digests are truncated');
95
+ }
96
+ digests = {
97
+ idMap: new Uint8Array(digestBytes.subarray(0, RANGE_DIGEST_BYTES)),
98
+ router: new Uint8Array(digestBytes.subarray(RANGE_DIGEST_BYTES, RANGE_DIGEST_BYTES * 2)),
99
+ base: new Uint8Array(digestBytes.subarray(RANGE_DIGEST_BYTES * 2, RANGE_DIGEST_BYTES * 3)),
100
+ };
101
+ }
102
+ const idMapBytes = await readChecked(source, header.idMapOffset, header.count * 4, 'id map', maxReadBytes);
103
+ if (idMapBytes.byteLength !== header.count * 4) {
104
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Range artifact id map is truncated');
105
+ }
106
+ const copied = new Uint8Array(idMapBytes.byteLength);
107
+ copied.set(idMapBytes);
108
+ const idMap = new Uint32Array(copied.buffer);
109
+ const artifact = new PancakeRangeArtifact(source, header, idMap, options);
110
+ artifact.digests = digests;
111
+ if (digests && artifact.verify) {
112
+ await verifySha256(copied, digests.idMap, 'Range artifact id map');
113
+ artifact.segmentVerified.idMap = true;
114
+ }
115
+ if (artifact.loadRouter && artifact.version >= 2) {
116
+ artifact.routerResident = await artifact.loadRouterSegment();
117
+ artifact.resetStats();
118
+ }
119
+ return artifact;
120
+ }
121
+
122
+ static async openFile(filePath, options = {}) {
123
+ // The source owns a file descriptor from construction; a rejected
124
+ // open must release it, or every corrupt artifact leaks an fd.
125
+ const source = new NodeFileRangeSource(filePath);
126
+ try {
127
+ return await PancakeRangeArtifact.open(source, options);
128
+ } catch (err) {
129
+ await source.close().catch(() => {});
130
+ throw err;
131
+ }
132
+ }
133
+
134
+ async close() {
135
+ if (this.source && typeof this.source.close === 'function') {
136
+ await this.source.close();
137
+ }
138
+ }
139
+
140
+ resetStats() {
141
+ this.rangeRequests = 0;
142
+ this.rangeBytes = 0;
143
+ this.rangeNodesDecoded = 0;
144
+ this.currentRanges = [];
145
+ }
146
+
147
+ clearCache(options = {}) {
148
+ this.cache.clear();
149
+ this.lazyCache.clear();
150
+ this.lazyCacheBytes = 0;
151
+ if (options.reloadRouter !== false && this.loadRouter && this.version >= 2) {
152
+ return this.loadRouterSegment().then((resident) => {
153
+ this.routerResident = resident;
154
+ this.resetStats();
155
+ return resident;
156
+ });
157
+ }
158
+ return Promise.resolve({ records: 0, bytes: 0 });
159
+ }
160
+
161
+ stats() {
162
+ return {
163
+ rangeRequests: this.rangeRequests,
164
+ rangeBytes: this.rangeBytes,
165
+ rangeNodesDecoded: this.rangeNodesDecoded,
166
+ cachedNodes: this.cache.size + this.lazyCache.size,
167
+ lazyCacheBytes: this.lazyCacheBytes,
168
+ routerResident: { ...this.routerResident },
169
+ segmentVerified: { ...this.segmentVerified },
170
+ };
171
+ }
172
+
173
+ // Verify the lazily-read base segment against the header's whole-segment
174
+ // digest (v3+). Reads the segment in bounded chunks with a streaming hash
175
+ // (Node); runtimes without one fall back to a single read bounded by the
176
+ // open budget. Intended for producers, CI, and hosts that materialize
177
+ // artifacts, not the query path. Verifying individual lazy ranges needs
178
+ // per-chunk commitments, which arrive with the contract's
179
+ // complete-profile manifest.
180
+ async verifyBaseSegment(options = {}) {
181
+ if (!this.digests) {
182
+ throw pancakeError(PANCAKE_ERROR_CODES.INVALID_ARGUMENT,
183
+ 'Range artifact predates segment digests (format v3); nothing to verify against', { version: this.version });
184
+ }
185
+ const bytes = this.baseCount * this.recordBytes;
186
+ await verifySegmentSha256(this.source, this.baseRecordsOffset, bytes, this.digests.base,
187
+ 'Range artifact base segment', { chunkBytes: options.chunkBytes, oneShotLimit: this.maxReadBytes });
188
+ this.segmentVerified.base = true;
189
+ return true;
190
+ }
191
+
192
+ markRanges() {
193
+ return this.currentRanges.length;
194
+ }
195
+
196
+ rangesSince(mark) {
197
+ return this.currentRanges.slice(mark);
198
+ }
199
+
200
+ recordAddressForId(id) {
201
+ if (!Number.isInteger(id) || id < 0 || id >= this.count) {
202
+ throw pancakeError(PANCAKE_ERROR_CODES.INVALID_ARGUMENT, `Node id ${id} is outside artifact bounds`, { id, count: this.count });
203
+ }
204
+ const location = this.originalToLocation[id];
205
+ if (this.version >= 2) {
206
+ const ordinal = location & LOCATION_ORDINAL_MASK;
207
+ if ((location & ROUTER_LOCATION_MASK) !== 0) {
208
+ if (ordinal >= this.routerCount) throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, `Router ordinal ${ordinal} is outside artifact`);
209
+ return this.routerRecordsOffset + ordinal * this.recordBytes;
210
+ }
211
+ if (ordinal >= this.baseCount) throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, `Base ordinal ${ordinal} is outside artifact`);
212
+ return this.baseRecordsOffset + ordinal * this.recordBytes;
213
+ }
214
+ if (location >= this.count) throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, `Node id ${id} is not addressable in artifact`);
215
+ return this.recordsOffset + location * this.recordBytes;
216
+ }
217
+
218
+ cachedNode(id) {
219
+ const resident = this.cache.get(id);
220
+ if (resident !== undefined) return resident;
221
+ const lazy = this.lazyCache.get(id);
222
+ if (lazy !== undefined) {
223
+ // Touch for LRU: move to the tail of insertion order.
224
+ this.lazyCache.delete(id);
225
+ this.lazyCache.set(id, lazy);
226
+ return lazy;
227
+ }
228
+ return undefined;
229
+ }
230
+
231
+ cacheLazyNode(id, node) {
232
+ if (this.lazyCache.has(id)) return;
233
+ this.lazyCache.set(id, node);
234
+ this.lazyCacheBytes += this.recordBytes;
235
+ while (this.lazyCacheBytes > this.maxCacheBytes && this.lazyCache.size > 1) {
236
+ const oldest = this.lazyCache.keys().next().value;
237
+ this.lazyCache.delete(oldest);
238
+ this.lazyCacheBytes -= this.recordBytes;
239
+ }
240
+ }
241
+
242
+ async readNode(id) {
243
+ const cached = this.cachedNode(id);
244
+ if (cached !== undefined) return cached;
245
+ await this.prefetch([id]);
246
+ const node = this.cachedNode(id);
247
+ if (node === undefined) {
248
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID,
249
+ 'Range artifact record for id was not resolved by its address', { id });
250
+ }
251
+ return node;
252
+ }
253
+
254
+ async prefetch(ids, options = {}) {
255
+ const addresses = [];
256
+ const seen = new Set();
257
+ for (const id of ids) {
258
+ if (this.cache.has(id) || this.lazyCache.has(id) || seen.has(id)) continue;
259
+ seen.add(id);
260
+ addresses.push(this.recordAddressForId(id));
261
+ }
262
+ if (addresses.length === 0) return 0;
263
+ addresses.sort((a, b) => a - b);
264
+ const gap = Math.max(0, options.gap || 0);
265
+ const gapBytes = this.version >= 2 ? gap : gap * this.recordBytes;
266
+ const rangeParallelism = Math.max(1, Math.trunc(options.parallelism || 1));
267
+ // Coalesced runs are split at maxRangeBytes (record-aligned) so no
268
+ // gap/parallelism combination — or a bulk prefetch of the whole base
269
+ // segment — turns into one unbounded read and allocation.
270
+ const maxRangeBytes = Math.max(this.recordBytes,
271
+ Math.floor(Math.trunc(options.maxRangeBytes || MAX_COALESCED_RANGE_BYTES) / this.recordBytes) * this.recordBytes);
272
+ const ranges = [];
273
+ let runStart = addresses[0];
274
+ let runEnd = addresses[0] + this.recordBytes;
275
+ const flush = () => {
276
+ for (let piece = runStart; piece < runEnd; piece += maxRangeBytes) {
277
+ ranges.push([piece, Math.min(piece + maxRangeBytes, runEnd)]);
278
+ }
279
+ };
280
+ const readRange = async ([start, end]) => {
281
+ const bytes = end - start;
282
+ const buffer = await readChecked(this.source, start, bytes, 'record');
283
+ if (buffer.byteLength !== bytes) {
284
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Range artifact record read returned a truncated range', { offset: start, bytes, actual: buffer.byteLength });
285
+ }
286
+ return { start, end, bytes, buffer };
287
+ };
288
+ const decodeRange = ({ start, end, bytes, buffer }) => {
289
+ this.rangeRequests++;
290
+ this.rangeBytes += bytes;
291
+ this.currentRanges.push([start, end]);
292
+ for (let off = 0; off + this.recordBytes <= bytes; off += this.recordBytes) {
293
+ const record = buffer.subarray(off, off + this.recordBytes);
294
+ const originalId = new DataView(record.buffer, record.byteOffset, record.byteLength).getUint32(0, true);
295
+ // The embedded id is untrusted: it must map back to the byte
296
+ // address this record was read from, or a lying record would
297
+ // poison the cache under an attacker-chosen key and searches
298
+ // for the real id would die uncoded.
299
+ if (originalId >= this.count || this.recordAddressForId(originalId) !== start + off) {
300
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID,
301
+ 'Range artifact record id does not match its address',
302
+ { originalId, address: start + off });
303
+ }
304
+ if (!this.cache.has(originalId) && !this.lazyCache.has(originalId)) {
305
+ this.cacheLazyNode(originalId, this.decodeNode(record));
306
+ this.rangeNodesDecoded++;
307
+ }
308
+ }
309
+ };
310
+ for (let i = 1; i < addresses.length; i++) {
311
+ const address = addresses[i];
312
+ if (address <= runEnd + gapBytes) {
313
+ runEnd = address + this.recordBytes;
314
+ } else {
315
+ flush();
316
+ runStart = address;
317
+ runEnd = address + this.recordBytes;
318
+ }
319
+ }
320
+ flush();
321
+ for (let i = 0; i < ranges.length; i += rangeParallelism) {
322
+ const batch = ranges.slice(i, i + rangeParallelism);
323
+ const results = await Promise.all(batch.map(readRange));
324
+ for (const result of results) decodeRange(result);
325
+ }
326
+ return ranges.length;
327
+ }
328
+
329
+ async loadRouterSegment() {
330
+ if (this.version < 2 || this.routerCount === 0) return { records: 0, bytes: 0 };
331
+ const bytes = this.routerCount * this.recordBytes;
332
+ const buffer = await readChecked(this.source, this.routerRecordsOffset, bytes, 'router segment', this.maxReadBytes);
333
+ if (buffer.byteLength !== bytes) {
334
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Range artifact router segment is truncated');
335
+ }
336
+ if (this.digests && this.verify) {
337
+ await verifySha256(buffer, this.digests.router, 'Range artifact router segment');
338
+ this.segmentVerified.router = true;
339
+ }
340
+ for (let i = 0; i < this.routerCount; i++) {
341
+ const record = buffer.subarray(i * this.recordBytes, (i + 1) * this.recordBytes);
342
+ const originalId = new DataView(record.buffer, record.byteOffset, record.byteLength).getUint32(0, true);
343
+ const address = this.routerRecordsOffset + i * this.recordBytes;
344
+ // Same untrusted-id check as decodeRange: the embedded id must map
345
+ // back to this router slot's address.
346
+ if (originalId >= this.count || this.recordAddressForId(originalId) !== address) {
347
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID,
348
+ 'Range artifact router record id does not match its address',
349
+ { originalId, address });
350
+ }
351
+ if (!this.cache.has(originalId)) this.cache.set(originalId, this.decodeNode(record));
352
+ }
353
+ return { records: this.routerCount, bytes };
354
+ }
355
+
356
+ decodeNode(bytes) {
357
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
358
+ const state = { offset: 0 };
359
+ const id = readU32(view, state);
360
+ const level = readU16(view, state);
361
+ const baseCount = readU16(view, state);
362
+ // Records are untrusted bytes. Counts beyond the header geometry would
363
+ // read filler (or zeros) as edges; fail closed instead.
364
+ if (level > this.maxLevel || baseCount > this.M0) {
365
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Range artifact record structure is inconsistent', { id, level, baseCount });
366
+ }
367
+ const upperCounts = new Uint16Array(this.maxLevel);
368
+ for (let i = 0; i < this.maxLevel; i++) {
369
+ upperCounts[i] = readU16(view, state);
370
+ if (upperCounts[i] > this.M) {
371
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Range artifact record structure is inconsistent', { id, level: i + 1, edges: upperCounts[i] });
372
+ }
373
+ }
374
+ const qdata = new Uint8Array(this.dim);
375
+ qdata.set(bytes.subarray(state.offset, state.offset + this.dim));
376
+ state.offset += this.dim;
377
+ const scale = readF32(view, state);
378
+ const offset = readF32(view, state);
379
+ const base = new Uint32Array(baseCount);
380
+ for (let i = 0; i < this.M0; i++) {
381
+ const neighbor = readU32(view, state);
382
+ if (i < baseCount) {
383
+ if (neighbor >= this.count) {
384
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Range artifact record has an out-of-bounds neighbor', { id, neighbor, count: this.count });
385
+ }
386
+ base[i] = neighbor;
387
+ }
388
+ }
389
+ const upper = Array.from({ length: this.maxLevel }, () => new Uint32Array(0));
390
+ for (let levelIndex = 0; levelIndex < this.maxLevel; levelIndex++) {
391
+ const edges = new Uint32Array(upperCounts[levelIndex]);
392
+ for (let i = 0; i < this.M; i++) {
393
+ const neighbor = readU32(view, state);
394
+ if (i < edges.length) {
395
+ if (neighbor >= this.count) {
396
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Range artifact record has an out-of-bounds neighbor', { id, neighbor, count: this.count });
397
+ }
398
+ edges[i] = neighbor;
399
+ }
400
+ }
401
+ upper[levelIndex] = edges;
402
+ }
403
+ return { id, level, base, upper, qdata, scale, offset };
404
+ }
405
+
406
+ distance(query, node) {
407
+ if (this.metric === 1) {
408
+ let dot = 0;
409
+ for (let d = 0; d < this.dim; d++) {
410
+ dot += query[d] * (node.offset + node.scale * node.qdata[d]);
411
+ }
412
+ if (dot > 1) dot = 1;
413
+ else if (dot < -1) dot = -1;
414
+ return 1 - dot;
415
+ }
416
+ let sum = 0;
417
+ for (let d = 0; d < this.dim; d++) {
418
+ const decoded = node.offset + node.scale * node.qdata[d];
419
+ const diff = query[d] - decoded;
420
+ sum += diff * diff;
421
+ }
422
+ return sum;
423
+ }
424
+
425
+ async search(queryInput, k, options = {}) {
426
+ const query = normalizeQuery(queryInput, this.dim, this.metric);
427
+ k = resolveSearchK(k, this.count);
428
+ if (k === 0) return { results: [], rounds: [], stats: this.stats() };
429
+ const efSearch = Math.max(resolveOptionalPositiveInt(options.efSearch, 'efSearch', 100), k);
430
+ const expansionBatch = Math.max(1, Math.trunc(options.expansionBatch || 1));
431
+ const rounds = [];
432
+ const prefetchRound = async (ids) => {
433
+ if (!ids.length) return;
434
+ const beforeRequests = this.rangeRequests;
435
+ const beforeBytes = this.rangeBytes;
436
+ const beforeRanges = this.markRanges();
437
+ await this.prefetch(ids, { gap: options.gap || 0, parallelism: options.rangeParallelism || options.parallelism || 1, maxRangeBytes: options.maxRangeBytes });
438
+ const ranges = this.rangesSince(beforeRanges);
439
+ rounds.push({
440
+ ids: ids.length,
441
+ requests: this.rangeRequests - beforeRequests,
442
+ bytes: this.rangeBytes - beforeBytes,
443
+ rangeBytes: ranges.map(([start, end]) => end - start),
444
+ });
445
+ };
446
+
447
+ let current = this.entryPoint;
448
+ await prefetchRound([current]);
449
+ let currentNode = await this.readNode(current);
450
+ let currentDistance = this.distance(query, currentNode);
451
+
452
+ for (let level = this.maxLevel; level > 0; level--) {
453
+ let changed = true;
454
+ while (changed) {
455
+ changed = false;
456
+ const edges = currentNode.upper[level - 1] || [];
457
+ await prefetchRound(edges);
458
+ for (const neighbor of edges) {
459
+ const node = await this.readNode(neighbor);
460
+ const distance = this.distance(query, node);
461
+ if (distance < currentDistance) {
462
+ current = neighbor;
463
+ currentNode = node;
464
+ currentDistance = distance;
465
+ changed = true;
466
+ }
467
+ }
468
+ }
469
+ }
470
+
471
+ const visited = new Uint8Array(this.count);
472
+ const candidates = new MinHeap(compareDistancesAsc);
473
+ const results = new MinHeap((a, b) => b.distance - a.distance || b.id - a.id);
474
+ currentNode = await this.readNode(current);
475
+ const distance0 = this.distance(query, currentNode);
476
+ candidates.push({ distance: distance0, id: current });
477
+ results.push({ distance: distance0, id: current });
478
+ visited[current] = 1;
479
+
480
+ while (candidates.size > 0) {
481
+ const batch = [];
482
+ while (batch.length < expansionBatch && candidates.size > 0) {
483
+ const candidate = candidates.peek();
484
+ const worst = results.peek();
485
+ if (results.size >= efSearch && worst && candidate && candidate.distance > worst.distance) break;
486
+ batch.push(candidates.pop());
487
+ }
488
+ if (!batch.length) break;
489
+
490
+ const toVisit = [];
491
+ for (const candidate of batch) {
492
+ const node = await this.readNode(candidate.id);
493
+ for (const neighbor of node.base) {
494
+ if (visited[neighbor]) continue;
495
+ visited[neighbor] = 1;
496
+ toVisit.push(neighbor);
497
+ }
498
+ }
499
+ await prefetchRound(toVisit);
500
+ for (const neighbor of toVisit) {
501
+ const neighborNode = await this.readNode(neighbor);
502
+ const distance = this.distance(query, neighborNode);
503
+ const currentWorst = results.peek();
504
+ if (results.size < efSearch || distance < currentWorst.distance) {
505
+ candidates.push({ distance, id: neighbor });
506
+ results.push({ distance, id: neighbor });
507
+ if (results.size > efSearch) results.pop();
508
+ }
509
+ }
510
+ }
511
+
512
+ const top = results.items.sort(compareDistancesAsc).slice(0, k);
513
+ // Traversal orders by squared L2; the API contract (README "Distance
514
+ // values") reports Euclidean, matching PancakeIndex.search.
515
+ if (this.metric !== 1) {
516
+ for (const hit of top) hit.distance = Math.sqrt(hit.distance);
517
+ }
518
+ return {
519
+ results: top,
520
+ rounds,
521
+ stats: this.stats(),
522
+ };
523
+ }
524
+ }
525
+
526
+ function parseHeader(headerBytes) {
527
+ const view = new DataView(headerBytes.buffer, headerBytes.byteOffset, headerBytes.byteLength);
528
+ const state = { offset: 0 };
529
+ const magic = readU32(view, state);
530
+ if (magic !== RANGE_MAGIC) {
531
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Not a Pancake range artifact', { magic });
532
+ }
533
+ const version = readU32(view, state);
534
+ const kind = readU32(view, state);
535
+ if (kind !== RANGE_KIND_U8) {
536
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Unsupported Pancake range artifact kind', { kind });
537
+ }
538
+ const header = {
539
+ version,
540
+ kind,
541
+ dim: readU32(view, state),
542
+ count: readU32(view, state),
543
+ entryPoint: readU32(view, state),
544
+ maxLevel: readU32(view, state),
545
+ M: readU32(view, state),
546
+ M0: readU32(view, state),
547
+ metric: readU32(view, state),
548
+ recordBytes: readU32(view, state),
549
+ idMapOffset: readU32(view, state),
550
+ recordsOffset: readU32(view, state),
551
+ parts: readU32(view, state),
552
+ routerCount: 0,
553
+ baseCount: 0,
554
+ routerRecordsOffset: 0,
555
+ baseRecordsOffset: 0,
556
+ };
557
+ if (header.metric !== 0) {
558
+ if (header.metric !== 1) {
559
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Unsupported Pancake range artifact metric', { metric: header.metric });
560
+ }
561
+ }
562
+ if (version >= 2) {
563
+ header.routerCount = readU32(view, state);
564
+ header.baseCount = readU32(view, state);
565
+ header.routerRecordsOffset = readU32(view, state);
566
+ header.baseRecordsOffset = readU32(view, state);
567
+ } else {
568
+ header.baseCount = header.count;
569
+ header.baseRecordsOffset = header.recordsOffset;
570
+ }
571
+ if (version < 1 || version > RANGE_VERSION) {
572
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Unsupported Pancake range artifact version', { version });
573
+ }
574
+ // recordBytes must agree with the layout the geometry implies, or
575
+ // decodeNode would read past record boundaries on a corrupt header.
576
+ const expectedRecordBytes = 4 + 2 + 2 + header.maxLevel * 2 + header.dim
577
+ + 8 + header.M0 * 4 + header.maxLevel * header.M * 4;
578
+ if (header.dim < 1 || header.count < 1 || header.M < 1 || header.M0 < 1
579
+ || header.maxLevel > 64
580
+ || header.entryPoint >= header.count
581
+ || header.recordBytes !== expectedRecordBytes
582
+ || (version >= 2 && header.routerCount + header.baseCount !== header.count)) {
583
+ throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Range artifact header geometry is inconsistent', {
584
+ dim: header.dim, count: header.count, maxLevel: header.maxLevel,
585
+ M: header.M, M0: header.M0, recordBytes: header.recordBytes, expectedRecordBytes,
586
+ });
587
+ }
588
+ return header;
589
+ }
590
+
591
+ function artifactRecordBytes(index) {
592
+ return 4 + 2 + 2 + index.maxLevel * 2 + index.dim + 8 + index.M0 * 4 + index.maxLevel * index.M * 4;
593
+ }
594
+
595
+ function writeNodeRecord(index, id, record) {
596
+ record.fill(0);
597
+ let offset = 0;
598
+ record.writeUInt32LE(id, offset); offset += 4;
599
+ record.writeUInt16LE(index.levels[id], offset); offset += 2;
600
+ const baseEdges = index.base[id];
601
+ record.writeUInt16LE(baseEdges.length, offset); offset += 2;
602
+ for (let level = 1; level <= index.maxLevel; level++) {
603
+ record.writeUInt16LE((index.upper[id][level - 1] || []).length, offset);
604
+ offset += 2;
605
+ }
606
+ Buffer.from(index.qdata.buffer, index.qdata.byteOffset + id * index.dim, index.dim).copy(record, offset);
607
+ offset += index.dim;
608
+ record.writeFloatLE(index.scales[id], offset); offset += 4;
609
+ record.writeFloatLE(index.offsets[id], offset); offset += 4;
610
+ for (let i = 0; i < index.M0; i++) {
611
+ record.writeUInt32LE(i < baseEdges.length ? baseEdges[i] : 0xFFFFFFFF, offset);
612
+ offset += 4;
613
+ }
614
+ for (let level = 1; level <= index.maxLevel; level++) {
615
+ const edges = index.upper[id][level - 1] || [];
616
+ for (let i = 0; i < index.M; i++) {
617
+ record.writeUInt32LE(i < edges.length ? edges[i] : 0xFFFFFFFF, offset);
618
+ offset += 4;
619
+ }
620
+ }
621
+ }
622
+
623
+ function reverseCuthillMckeePermutation(index) {
624
+ const count = index.count;
625
+ const degrees = new Uint32Array(count);
626
+ for (let id = 0; id < count; id++) degrees[id] = index.base[id].length;
627
+ const starts = Array.from({ length: count }, (_, id) => id);
628
+ starts.sort((a, b) => degrees[a] - degrees[b] || a - b);
629
+ const visited = new Uint8Array(count);
630
+ const order = new Uint32Array(count);
631
+ const queue = new Uint32Array(count);
632
+ let orderLen = 0;
633
+ for (const start of starts) {
634
+ if (visited[start]) continue;
635
+ let read = 0;
636
+ let write = 0;
637
+ queue[write++] = start;
638
+ visited[start] = 1;
639
+ while (read < write) {
640
+ const id = queue[read++];
641
+ order[orderLen++] = id;
642
+ const neighbors = Array.from(index.base[id]).filter((neighbor) => !visited[neighbor]);
643
+ neighbors.sort((a, b) => degrees[a] - degrees[b] || a - b);
644
+ for (const neighbor of neighbors) {
645
+ if (visited[neighbor]) continue;
646
+ visited[neighbor] = 1;
647
+ queue[write++] = neighbor;
648
+ }
649
+ }
650
+ }
651
+ const ordinalToOriginal = new Uint32Array(count);
652
+ for (let pos = 0; pos < orderLen; pos++) ordinalToOriginal[pos] = order[orderLen - 1 - pos];
653
+ return ordinalToOriginal;
654
+ }
655
+
656
+ function identityPermutation(count) {
657
+ const out = new Uint32Array(count);
658
+ for (let i = 0; i < count; i++) out[i] = i;
659
+ return out;
660
+ }
661
+
662
+ function buildOrdinalToOriginal(index, layout) {
663
+ if (layout === 'identity') return identityPermutation(index.count);
664
+ if (layout === 'rcm') return reverseCuthillMckeePermutation(index);
665
+ throw pancakeError(PANCAKE_ERROR_CODES.INVALID_ARGUMENT, `Unsupported Search Artifact layout '${layout}'`, { layout });
666
+ }
667
+
668
+ function exportSplitArtifact(index, outPath, options = {}) {
669
+ const fs = require('fs');
670
+ const layoutName = options.layout || 'rcm';
671
+ const ordinalToOriginal = buildOrdinalToOriginal(index, layoutName);
672
+ const recBytes = artifactRecordBytes(index);
673
+ const idMapOffset = HEADER_BYTES_V2;
674
+ const routerIds = [];
675
+ const baseIds = [];
676
+ const locationMap = new Uint32Array(index.count);
677
+ for (let ordinal = 0; ordinal < index.count; ordinal++) {
678
+ const id = ordinalToOriginal[ordinal];
679
+ if (index.levels[id] > 0) routerIds.push(id);
680
+ else baseIds.push(id);
681
+ }
682
+ for (let i = 0; i < routerIds.length; i++) locationMap[routerIds[i]] = ROUTER_LOCATION_MASK | i;
683
+ for (let i = 0; i < baseIds.length; i++) locationMap[baseIds[i]] = i;
684
+
685
+ const routerRecordsOffset = idMapOffset + index.count * 4;
686
+ const baseRecordsOffset = routerRecordsOffset + routerIds.length * recBytes;
687
+ const totalBytes = baseRecordsOffset + baseIds.length * recBytes;
688
+ const fd = fs.openSync(outPath, 'w');
689
+ // Whole-segment digests (v3): streamed while the segments are written so
690
+ // the file is hashed exactly once, then stamped into the header at the
691
+ // end. The id map and router are verified by the reader at open; the base
692
+ // segment is verifiable on demand via verifyBaseSegment().
693
+ const crypto = require('crypto');
694
+ const idMapBuffer = Buffer.from(locationMap.buffer);
695
+ const idMapDigest = crypto.createHash('sha256').update(idMapBuffer).digest();
696
+ const routerHash = crypto.createHash('sha256');
697
+ const baseHash = crypto.createHash('sha256');
698
+ let routerDigest;
699
+ let baseDigest;
700
+ try {
701
+ fs.writeSync(fd, idMapBuffer, 0, index.count * 4, idMapOffset);
702
+
703
+ const record = Buffer.alloc(recBytes);
704
+ for (let i = 0; i < routerIds.length; i++) {
705
+ writeNodeRecord(index, routerIds[i], record);
706
+ routerHash.update(record);
707
+ fs.writeSync(fd, record, 0, record.length, routerRecordsOffset + i * recBytes);
708
+ }
709
+ for (let i = 0; i < baseIds.length; i++) {
710
+ writeNodeRecord(index, baseIds[i], record);
711
+ baseHash.update(record);
712
+ fs.writeSync(fd, record, 0, record.length, baseRecordsOffset + i * recBytes);
713
+ }
714
+
715
+ const header = Buffer.alloc(HEADER_BYTES_V2);
716
+ let h = 0;
717
+ header.writeUInt32LE(RANGE_MAGIC, h); h += 4;
718
+ header.writeUInt32LE(RANGE_VERSION, h); h += 4;
719
+ header.writeUInt32LE(RANGE_KIND_U8, h); h += 4;
720
+ header.writeUInt32LE(index.dim, h); h += 4;
721
+ header.writeUInt32LE(index.count, h); h += 4;
722
+ header.writeUInt32LE(index.entryPoint, h); h += 4;
723
+ header.writeUInt32LE(index.maxLevel, h); h += 4;
724
+ header.writeUInt32LE(index.M, h); h += 4;
725
+ header.writeUInt32LE(index.M0, h); h += 4;
726
+ header.writeUInt32LE(index.metric, h); h += 4;
727
+ header.writeUInt32LE(recBytes, h); h += 4;
728
+ header.writeUInt32LE(idMapOffset, h); h += 4;
729
+ header.writeUInt32LE(routerRecordsOffset, h); h += 4;
730
+ header.writeUInt32LE(options.parts || 0, h); h += 4;
731
+ header.writeUInt32LE(routerIds.length, h); h += 4;
732
+ header.writeUInt32LE(baseIds.length, h); h += 4;
733
+ header.writeUInt32LE(routerRecordsOffset, h); h += 4;
734
+ header.writeUInt32LE(baseRecordsOffset, h); h += 4;
735
+ routerDigest = routerHash.digest();
736
+ baseDigest = baseHash.digest();
737
+ idMapDigest.copy(header, RANGE_DIGESTS_OFFSET);
738
+ routerDigest.copy(header, RANGE_DIGESTS_OFFSET + RANGE_DIGEST_BYTES);
739
+ baseDigest.copy(header, RANGE_DIGESTS_OFFSET + RANGE_DIGEST_BYTES * 2);
740
+ fs.writeSync(fd, header, 0, header.length, 0);
741
+ } finally {
742
+ fs.closeSync(fd);
743
+ }
744
+ return {
745
+ format: 'pancake-range-artifact',
746
+ formatVersion: RANGE_VERSION,
747
+ integrity: {
748
+ idMapSha256: idMapDigest.toString('hex'),
749
+ routerSha256: routerDigest.toString('hex'),
750
+ baseSha256: baseDigest.toString('hex'),
751
+ },
752
+ file: outPath,
753
+ sizeBytes: totalBytes,
754
+ kind: 'u8-affine',
755
+ metric: index.metric === 0 ? 'l2' : 'cosine',
756
+ layout: { permutation: layoutName, split: 'router_then_base' },
757
+ graph: {
758
+ count: index.count,
759
+ dim: index.dim,
760
+ entryPoint: index.entryPoint,
761
+ maxLevel: index.maxLevel,
762
+ M: index.M,
763
+ M0: index.M0,
764
+ },
765
+ addressing: {
766
+ headerBytes: HEADER_BYTES_V2,
767
+ idMapOffset,
768
+ recordBytes: recBytes,
769
+ routerCount: routerIds.length,
770
+ routerBytes: routerIds.length * recBytes,
771
+ routerRecordsOffset,
772
+ baseCount: baseIds.length,
773
+ baseBytes: baseIds.length * recBytes,
774
+ baseRecordsOffset,
775
+ },
776
+ };
777
+ }
778
+
779
+ function buildRangeArtifact(snapshotBytes, outPath, options = {}) {
780
+ if (typeof outPath !== 'string' || outPath.length === 0) {
781
+ throw pancakeError(PANCAKE_ERROR_CODES.INVALID_ARGUMENT, 'buildRangeArtifact() requires an output path');
782
+ }
783
+ const index = parseUint8Snapshot(snapshotBytes);
784
+ return exportSplitArtifact(index, outPath, options);
785
+ }
786
+
787
+ function buildRangeArtifactFile(snapshotPath, outPath, options = {}) {
788
+ const fs = require('fs');
789
+ const snapshot = fs.readFileSync(snapshotPath);
790
+ return buildRangeArtifact(snapshot, outPath, options);
791
+ }
792
+
793
+ module.exports = {
794
+ PancakeRangeArtifact,
795
+ buildRangeArtifact,
796
+ buildRangeArtifactFile,
797
+ exportSplitArtifact,
798
+ parseHeader,
799
+ };