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.
- package/CHANGELOG.md +868 -0
- package/LICENSE +191 -0
- package/README.md +590 -0
- package/complete/builder.d.ts +82 -0
- package/complete/builder.mjs +527 -0
- package/complete/encoder-kernels/encoder.mjs +2 -0
- package/complete/encoder-kernels/encoder.node.mjs +2 -0
- package/complete/encoder-kernels/encoder.node.wasm +0 -0
- package/complete/encoder-kernels/encoder.wasm +0 -0
- package/complete/format.mjs +8 -0
- package/complete/index.d.ts +289 -0
- package/complete/index.mjs +1124 -0
- package/complete/inline-transformer.mjs +205 -0
- package/complete/lexical.mjs +264 -0
- package/complete/retrieval-abstention.mjs +119 -0
- package/complete/sources.mjs +186 -0
- package/complete/student-abstention.mjs +102 -0
- package/complete/student-embedder.mjs +231 -0
- package/complete/wordpiece.mjs +68 -0
- package/dist/engine.js +2 -0
- package/dist/engine.scalar.js +2 -0
- package/dist/engine.scalar.wasm +0 -0
- package/dist/engine.wasm +0 -0
- package/package.json +168 -0
- package/pancake-artifact-common.js +561 -0
- package/pancake-artifact-range.js +799 -0
- package/pancake-artifact-sketch.js +1040 -0
- package/pancake-artifact.d.ts +109 -0
- package/pancake-artifact.js +31 -0
- package/pancake-core.js +1268 -0
- package/pancake-errors.js +40 -0
- package/pancake-loader.js +66 -0
- package/pancake.d.ts +691 -0
- package/pancake.js +254 -0
- package/pancake.node.mjs +259 -0
- package/pancake.web.d.ts +50 -0
- package/pancake.web.mjs +88 -0
- package/pancake.workerd.mjs +72 -0
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Shared by the range and sketch artifact readers/builders: read budgets and
|
|
3
|
+
// range validation, range sources, the result heap, snapshot parsing, and
|
|
4
|
+
// SHA-256 helpers. Not a public entry; pancake-artifact.js re-exports what is public.
|
|
5
|
+
// Split out of pancake-artifact.js (the public entry, which re-exports the
|
|
6
|
+
// three parts); see that file for the module map.
|
|
7
|
+
|
|
8
|
+
const { pancakeError, PANCAKE_ERROR_CODES } = require('./pancake-errors.js');
|
|
9
|
+
|
|
10
|
+
const PANCAKE_MAGIC = 0x504E434B;
|
|
11
|
+
const V1_ENVELOPE_HEADER_SIZE = 24;
|
|
12
|
+
const V2_ENVELOPE_HEADER_SIZE = 20;
|
|
13
|
+
const V3_ENVELOPE_HEADER_SIZE = 32;
|
|
14
|
+
const MAPPING_ENTRY_SIZE = 8;
|
|
15
|
+
const UINT8_HNSW_MAGIC_V1 = 0x49384831;
|
|
16
|
+
|
|
17
|
+
// Read limits are layered, because header fields are untrusted and a hostile
|
|
18
|
+
// count/offset must not turn into a multi-GB read or allocation before the
|
|
19
|
+
// truncation checks run:
|
|
20
|
+
// - MAX_ARTIFACT_READ_BYTES is the absolute backstop on any single read,
|
|
21
|
+
// blocking the 32-bit-overflow class of pathological requests. It is also
|
|
22
|
+
// enforced independently inside NodeFileRangeSource.
|
|
23
|
+
// - Open-path reads (id map, router, resident sketch tier) default to the
|
|
24
|
+
// much tighter DEFAULT_OPEN_READ_BYTES, configurable per open() via
|
|
25
|
+
// options.maxReadBytes — resident segments are small by design, so a read
|
|
26
|
+
// near this limit means a hostile or misconfigured header.
|
|
27
|
+
// - Query-path coalesced ranges are split at MAX_COALESCED_RANGE_BYTES so no
|
|
28
|
+
// gap/parallelism combination can drive one giant fetch.
|
|
29
|
+
// - Whole-segment verification streams in VERIFY_CHUNK_BYTES chunks where a
|
|
30
|
+
// streaming hash exists (Node); WebCrypto cannot stream SHA-256, so other
|
|
31
|
+
// runtimes fall back to a single read bounded by the open budget.
|
|
32
|
+
const MAX_ARTIFACT_READ_BYTES = 2 * 1024 * 1024 * 1024;
|
|
33
|
+
const DEFAULT_OPEN_READ_BYTES = 256 * 1024 * 1024;
|
|
34
|
+
const MAX_COALESCED_RANGE_BYTES = 16 * 1024 * 1024;
|
|
35
|
+
const VERIFY_CHUNK_BYTES = 64 * 1024 * 1024;
|
|
36
|
+
|
|
37
|
+
async function mapLimit(items, limit, fn) {
|
|
38
|
+
const n = !Number.isFinite(limit) ? items.length : Math.min(Math.max(1, Math.trunc(limit)), items.length);
|
|
39
|
+
if (n === 0) return [];
|
|
40
|
+
const out = new Array(items.length);
|
|
41
|
+
let next = 0;
|
|
42
|
+
await Promise.all(Array.from({ length: n }, async () => {
|
|
43
|
+
for (let i = next++; i < items.length; i = next++) {
|
|
44
|
+
out[i] = await fn(items[i], i);
|
|
45
|
+
}
|
|
46
|
+
}));
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Validate an artifact-derived (offset, length) before issuing source.read.
|
|
51
|
+
// Rejects non-integer/negative/overflowing ranges, anything past the cap, and
|
|
52
|
+
// — when the source reports a numeric size — anything past the source end, so
|
|
53
|
+
// the read is refused instead of attempted. Returns the length for convenience.
|
|
54
|
+
function checkArtifactRange(source, offset, length, label, limit = MAX_ARTIFACT_READ_BYTES) {
|
|
55
|
+
if (!Number.isSafeInteger(offset) || !Number.isSafeInteger(length)
|
|
56
|
+
|| offset < 0 || length < 0 || !Number.isSafeInteger(offset + length)) {
|
|
57
|
+
throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID,
|
|
58
|
+
`Artifact ${label} range is out of bounds`, { offset, length });
|
|
59
|
+
}
|
|
60
|
+
const max = Math.min(limit, MAX_ARTIFACT_READ_BYTES);
|
|
61
|
+
if (length > max) {
|
|
62
|
+
throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID,
|
|
63
|
+
`Artifact ${label} range exceeds the maximum read size`, { length, max });
|
|
64
|
+
}
|
|
65
|
+
if (source && Number.isSafeInteger(source.size) && offset + length > source.size) {
|
|
66
|
+
throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID,
|
|
67
|
+
`Artifact ${label} range extends past the source size`, { offset, length, size: source.size });
|
|
68
|
+
}
|
|
69
|
+
return length;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Read a checked artifact region: validate (offset, length) before touching
|
|
73
|
+
// the source so a hostile header cannot drive a giant read or allocation.
|
|
74
|
+
async function readChecked(source, offset, length, label, limit) {
|
|
75
|
+
checkArtifactRange(source, offset, length, label, limit);
|
|
76
|
+
return asUint8Array(await source.read(offset, length));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Per-open read budget: undefined keeps the default, Infinity defers to the
|
|
80
|
+
// absolute backstop, anything else must be a positive finite number and is
|
|
81
|
+
// honored strictly — a budget too small for the artifact's resident segments
|
|
82
|
+
// fails the open with a coded error rather than being silently raised.
|
|
83
|
+
function resolveMaxReadBytes(value) {
|
|
84
|
+
if (value === undefined) return DEFAULT_OPEN_READ_BYTES;
|
|
85
|
+
if (value === Infinity) return MAX_ARTIFACT_READ_BYTES;
|
|
86
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
|
87
|
+
throw pancakeError(PANCAKE_ERROR_CODES.INVALID_ARGUMENT,
|
|
88
|
+
'maxReadBytes must be a positive number or Infinity', { maxReadBytes: value });
|
|
89
|
+
}
|
|
90
|
+
return value;
|
|
91
|
+
}
|
|
92
|
+
function asUint8Array(bytes) {
|
|
93
|
+
if (bytes instanceof Uint8Array) return bytes;
|
|
94
|
+
if (bytes instanceof ArrayBuffer) return new Uint8Array(bytes);
|
|
95
|
+
if (ArrayBuffer.isView(bytes)) {
|
|
96
|
+
return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
97
|
+
}
|
|
98
|
+
throw pancakeError(PANCAKE_ERROR_CODES.INVALID_ARGUMENT, 'Range source returned a non-binary value');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Both readers accept a maxCacheBytes option: undefined keeps the default,
|
|
102
|
+
// Infinity disables the bound, and any other value must be a positive number.
|
|
103
|
+
// Each reader raises small budgets to a floor so the LRU always holds enough
|
|
104
|
+
// records for one search round.
|
|
105
|
+
function resolveMaxCacheBytes(value, floorBytes, defaultBytes) {
|
|
106
|
+
if (value === undefined) return defaultBytes;
|
|
107
|
+
if (value === Infinity) return Infinity;
|
|
108
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
|
109
|
+
throw pancakeError(PANCAKE_ERROR_CODES.INVALID_ARGUMENT, 'maxCacheBytes must be a positive number or Infinity', { maxCacheBytes: value });
|
|
110
|
+
}
|
|
111
|
+
return Math.max(value, floorBytes);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function readU32(view, state) {
|
|
115
|
+
const value = view.getUint32(state.offset, true);
|
|
116
|
+
state.offset += 4;
|
|
117
|
+
return value;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function readU16(view, state) {
|
|
121
|
+
const value = view.getUint16(state.offset, true);
|
|
122
|
+
state.offset += 2;
|
|
123
|
+
return value;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function readF32(view, state) {
|
|
127
|
+
const value = view.getFloat32(state.offset, true);
|
|
128
|
+
state.offset += 4;
|
|
129
|
+
return value;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function compareDistancesAsc(a, b) {
|
|
133
|
+
return a.distance - b.distance || a.id - b.id;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
class MinHeap {
|
|
137
|
+
constructor(compare) {
|
|
138
|
+
this.items = [];
|
|
139
|
+
this.compare = compare;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
get size() {
|
|
143
|
+
return this.items.length;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
push(value) {
|
|
147
|
+
const items = this.items;
|
|
148
|
+
items.push(value);
|
|
149
|
+
this.bubbleUp(items.length - 1);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
bubbleUp(index) {
|
|
153
|
+
const items = this.items;
|
|
154
|
+
const value = items[index];
|
|
155
|
+
while (index > 0) {
|
|
156
|
+
const parent = (index - 1) >> 1;
|
|
157
|
+
if (this.compare(items[parent], value) <= 0) break;
|
|
158
|
+
items[index] = items[parent];
|
|
159
|
+
index = parent;
|
|
160
|
+
}
|
|
161
|
+
items[index] = value;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
pop() {
|
|
165
|
+
const items = this.items;
|
|
166
|
+
if (items.length === 0) return undefined;
|
|
167
|
+
const top = items[0];
|
|
168
|
+
const value = items.pop();
|
|
169
|
+
if (items.length > 0) {
|
|
170
|
+
let index = 0;
|
|
171
|
+
while (true) {
|
|
172
|
+
let child = index * 2 + 1;
|
|
173
|
+
if (child >= items.length) break;
|
|
174
|
+
if (child + 1 < items.length && this.compare(items[child + 1], items[child]) < 0) {
|
|
175
|
+
child++;
|
|
176
|
+
}
|
|
177
|
+
if (this.compare(items[child], value) >= 0) break;
|
|
178
|
+
items[index] = items[child];
|
|
179
|
+
index = child;
|
|
180
|
+
}
|
|
181
|
+
items[index] = value;
|
|
182
|
+
}
|
|
183
|
+
return top;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
peek() {
|
|
187
|
+
return this.items[0];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
sorted() {
|
|
191
|
+
return [...this.items].sort(this.compare);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
class NodeFileRangeSource {
|
|
196
|
+
constructor(filePath) {
|
|
197
|
+
const fs = require('fs');
|
|
198
|
+
this.fs = fs;
|
|
199
|
+
this.filePath = filePath;
|
|
200
|
+
this.fd = fs.openSync(filePath, 'r');
|
|
201
|
+
try {
|
|
202
|
+
// fstat on the descriptor we hold: no second path lookup to race,
|
|
203
|
+
// and if it fails the descriptor is released before the throw.
|
|
204
|
+
this.size = fs.fstatSync(this.fd).size;
|
|
205
|
+
} catch (err) {
|
|
206
|
+
fs.closeSync(this.fd);
|
|
207
|
+
this.fd = null;
|
|
208
|
+
throw err;
|
|
209
|
+
}
|
|
210
|
+
this.preferredParallelism = Infinity;
|
|
211
|
+
this.preferredGapBytes = 2048;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async read(offset, length) {
|
|
215
|
+
if (this.fd === null) {
|
|
216
|
+
throw pancakeError(PANCAKE_ERROR_CODES.INVALID_ARGUMENT, 'NodeFileRangeSource is closed', { filePath: this.filePath });
|
|
217
|
+
}
|
|
218
|
+
// Defense in depth: the callers validate artifact-derived ranges, but
|
|
219
|
+
// a future caller must not be able to drive a giant Buffer.alloc or a
|
|
220
|
+
// read past the file end through this source directly.
|
|
221
|
+
if (!Number.isSafeInteger(offset) || !Number.isSafeInteger(length)
|
|
222
|
+
|| offset < 0 || length < 0 || length > MAX_ARTIFACT_READ_BYTES
|
|
223
|
+
|| offset + length > this.size) {
|
|
224
|
+
throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID,
|
|
225
|
+
'NodeFileRangeSource.read() range is out of bounds', { offset, length, size: this.size });
|
|
226
|
+
}
|
|
227
|
+
const buffer = Buffer.alloc(length);
|
|
228
|
+
let bytesRead = 0;
|
|
229
|
+
while (bytesRead < length) {
|
|
230
|
+
const chunk = this.fs.readSync(this.fd, buffer, bytesRead, length - bytesRead, offset + bytesRead);
|
|
231
|
+
if (chunk === 0) break;
|
|
232
|
+
bytesRead += chunk;
|
|
233
|
+
}
|
|
234
|
+
// Return only the bytes actually read so callers' truncation checks
|
|
235
|
+
// fire instead of parsing an unwritten buffer tail.
|
|
236
|
+
return bytesRead === length ? buffer : buffer.subarray(0, bytesRead);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async close() {
|
|
240
|
+
if (this.fd !== null) {
|
|
241
|
+
this.fs.closeSync(this.fd);
|
|
242
|
+
this.fd = null;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// The artifact readers apply the same query-vector contract as the core
|
|
248
|
+
// engine (pancake-core.js assertNumericVector / validateVectorValues): a
|
|
249
|
+
// plain array must hold real numbers (Float32Array.from would coerce '1',
|
|
250
|
+
// '', true silently), every component must be finite for every metric (an
|
|
251
|
+
// L2 search over NaN would otherwise return distance: NaN results), and a
|
|
252
|
+
// cosine query must have a usable norm.
|
|
253
|
+
function normalizeQuery(query, dim, metric = 0) {
|
|
254
|
+
if (!(query instanceof Float32Array)) {
|
|
255
|
+
if (!Array.isArray(query)) {
|
|
256
|
+
throw pancakeError(PANCAKE_ERROR_CODES.INVALID_VECTOR, 'search() query must be a Float32Array or number[]');
|
|
257
|
+
}
|
|
258
|
+
for (let i = 0; i < query.length; i++) {
|
|
259
|
+
if (typeof query[i] !== 'number') {
|
|
260
|
+
throw pancakeError(PANCAKE_ERROR_CODES.INVALID_VECTOR,
|
|
261
|
+
`search() query must contain only numbers; found ${typeof query[i]} at index ${i}`, { index: i, actualType: typeof query[i] });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
query = Float32Array.from(query);
|
|
265
|
+
}
|
|
266
|
+
if (query.length !== dim) {
|
|
267
|
+
throw pancakeError(PANCAKE_ERROR_CODES.DIMENSION_MISMATCH, `search() query dimension ${query.length} does not match artifact dimension ${dim}`, { queryDim: query.length, dim });
|
|
268
|
+
}
|
|
269
|
+
let norm = 0;
|
|
270
|
+
for (let i = 0; i < query.length; i++) {
|
|
271
|
+
const value = query[i];
|
|
272
|
+
if (!Number.isFinite(value)) {
|
|
273
|
+
throw pancakeError(PANCAKE_ERROR_CODES.INVALID_VECTOR, 'search() query contains non-finite value (NaN or Infinity)', { index: i, reason: 'non_finite' });
|
|
274
|
+
}
|
|
275
|
+
norm += value * value;
|
|
276
|
+
}
|
|
277
|
+
if (metric === 1) {
|
|
278
|
+
norm = Math.sqrt(norm);
|
|
279
|
+
if (!Number.isFinite(norm) || norm <= 1e-30) {
|
|
280
|
+
throw pancakeError(PANCAKE_ERROR_CODES.INVALID_VECTOR, 'search() query has invalid cosine norm', { reason: 'invalid_cosine_norm' });
|
|
281
|
+
}
|
|
282
|
+
const normalized = new Float32Array(query.length);
|
|
283
|
+
for (let i = 0; i < query.length; i++) normalized[i] = query[i] / norm;
|
|
284
|
+
return normalized;
|
|
285
|
+
}
|
|
286
|
+
return query;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Positive-integer search arguments (k, rerank, efSearch) shared by both
|
|
290
|
+
// artifact readers: integers only, and bounded by the artifact's row count
|
|
291
|
+
// before they size any allocation — an artifact cannot return more than
|
|
292
|
+
// count results, so a k of 1e9 over 300 rows must not allocate 1e9 slots.
|
|
293
|
+
function resolveSearchK(k, count) {
|
|
294
|
+
if (!Number.isSafeInteger(k) || k < 1) {
|
|
295
|
+
throw pancakeError(PANCAKE_ERROR_CODES.INVALID_ARGUMENT, 'search() k must be a positive integer', { k });
|
|
296
|
+
}
|
|
297
|
+
return Math.min(k, count);
|
|
298
|
+
}
|
|
299
|
+
// undefined, null, and 0 all mean "use the default" — 0 kept its historical
|
|
300
|
+
// `options.rerank || default` meaning so existing callers do not break;
|
|
301
|
+
// anything else must be a positive safe integer.
|
|
302
|
+
function resolveOptionalPositiveInt(value, name, fallback) {
|
|
303
|
+
if (value === undefined || value === null || value === 0) return fallback;
|
|
304
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
305
|
+
throw pancakeError(PANCAKE_ERROR_CODES.INVALID_ARGUMENT, `search() ${name} must be a positive integer (0 or undefined selects the default)`, { [name]: value });
|
|
306
|
+
}
|
|
307
|
+
return value;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function unwrapSnapshot(bytes) {
|
|
311
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
312
|
+
if (bytes.byteLength < 4 || view.getUint32(0, true) !== PANCAKE_MAGIC) return bytes;
|
|
313
|
+
// Envelope fields are untrusted: every size read from them must be
|
|
314
|
+
// checked against the actual buffer before slicing, so a corrupt
|
|
315
|
+
// envelope fails closed with a coded error instead of a raw RangeError.
|
|
316
|
+
if (bytes.byteLength < 8) {
|
|
317
|
+
throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Pancake envelope is truncated', { byteLength: bytes.byteLength });
|
|
318
|
+
}
|
|
319
|
+
const version = view.getUint32(4, true);
|
|
320
|
+
if (version === 1 || version === 2) {
|
|
321
|
+
const headerSize = version === 1 ? V1_ENVELOPE_HEADER_SIZE : V2_ENVELOPE_HEADER_SIZE;
|
|
322
|
+
if (bytes.byteLength < headerSize) {
|
|
323
|
+
throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Pancake envelope is truncated', { version, byteLength: bytes.byteLength });
|
|
324
|
+
}
|
|
325
|
+
return bytes.subarray(headerSize);
|
|
326
|
+
}
|
|
327
|
+
if (version === 3) {
|
|
328
|
+
if (bytes.byteLength < V3_ENVELOPE_HEADER_SIZE) {
|
|
329
|
+
throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Pancake envelope is truncated', { version, byteLength: bytes.byteLength });
|
|
330
|
+
}
|
|
331
|
+
const mappingCount = view.getUint32(24, true);
|
|
332
|
+
const rawSize = view.getUint32(28, true);
|
|
333
|
+
const rawOffset = V3_ENVELOPE_HEADER_SIZE + mappingCount * MAPPING_ENTRY_SIZE;
|
|
334
|
+
if (rawOffset + rawSize > bytes.byteLength) {
|
|
335
|
+
throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Pancake envelope declares more data than the snapshot contains', { mappingCount, rawSize, byteLength: bytes.byteLength });
|
|
336
|
+
}
|
|
337
|
+
return bytes.subarray(rawOffset, rawOffset + rawSize);
|
|
338
|
+
}
|
|
339
|
+
throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, `Unsupported Pancake envelope version ${version}`, { version });
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function parseUint8Snapshot(bytes) {
|
|
343
|
+
const raw = unwrapSnapshot(asUint8Array(bytes));
|
|
344
|
+
const view = new DataView(raw.buffer, raw.byteOffset, raw.byteLength);
|
|
345
|
+
let offset = 0;
|
|
346
|
+
// Snapshot bytes are untrusted: sizes read from the header drive every
|
|
347
|
+
// subsequent read, so each read checks the remaining buffer and fails
|
|
348
|
+
// closed with a coded error instead of a raw RangeError.
|
|
349
|
+
const truncated = () => pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'uint8 snapshot is truncated', { offset, byteLength: raw.byteLength });
|
|
350
|
+
const u32 = () => {
|
|
351
|
+
if (raw.byteLength - offset < 4) throw truncated();
|
|
352
|
+
const value = view.getUint32(offset, true);
|
|
353
|
+
offset += 4;
|
|
354
|
+
return value;
|
|
355
|
+
};
|
|
356
|
+
const f32 = () => {
|
|
357
|
+
if (raw.byteLength - offset < 4) throw truncated();
|
|
358
|
+
const value = view.getFloat32(offset, true);
|
|
359
|
+
offset += 4;
|
|
360
|
+
return value;
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const magic = u32();
|
|
364
|
+
const dim = u32();
|
|
365
|
+
const version = u32();
|
|
366
|
+
const count = u32();
|
|
367
|
+
const entryPoint = u32();
|
|
368
|
+
const maxLevel = u32();
|
|
369
|
+
const M = u32();
|
|
370
|
+
const M0 = u32();
|
|
371
|
+
const metric = u32();
|
|
372
|
+
const efConstruction = u32();
|
|
373
|
+
if (magic !== UINT8_HNSW_MAGIC_V1) {
|
|
374
|
+
throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Search Artifact export currently supports uint8 Pancake snapshots only');
|
|
375
|
+
}
|
|
376
|
+
// Contract §6: reject unknown future versions instead of parsing them
|
|
377
|
+
// as v2 — a changed layout must fail closed, not misparse.
|
|
378
|
+
if (version > 2) {
|
|
379
|
+
throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Unsupported uint8 snapshot format version', { version });
|
|
380
|
+
}
|
|
381
|
+
if (metric !== 0 && metric !== 1) {
|
|
382
|
+
throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'Unsupported uint8 snapshot metric', { metric });
|
|
383
|
+
}
|
|
384
|
+
// Same structural sanity the engine's own deserialize enforces; the
|
|
385
|
+
// level cap mirrors MAX_DESERIALIZE_LEVEL in src/uint8_float_hnsw.hpp.
|
|
386
|
+
if (dim < 1 || count < 1 || entryPoint >= count || maxLevel > 64
|
|
387
|
+
|| !Number.isSafeInteger(count * dim)
|
|
388
|
+
|| raw.byteLength - offset < count * 8 + count * dim) {
|
|
389
|
+
throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'uint8 snapshot header is inconsistent', { dim, count, entryPoint, maxLevel, byteLength: raw.byteLength });
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const scales = new Float32Array(count);
|
|
393
|
+
const offsets = new Float32Array(count);
|
|
394
|
+
for (let i = 0; i < count; i++) scales[i] = f32();
|
|
395
|
+
for (let i = 0; i < count; i++) offsets[i] = f32();
|
|
396
|
+
const qdata = raw.subarray(offset, offset + count * dim);
|
|
397
|
+
offset += qdata.byteLength;
|
|
398
|
+
|
|
399
|
+
// Adjacency bounds mirror the engine's own deserializer: a level's edge
|
|
400
|
+
// count is capped by M0 (layer 0) / M (upper layers), the bytes it claims
|
|
401
|
+
// (4 per id + 4 per serialized distance) must still be in the buffer, and
|
|
402
|
+
// every neighbor id must address a node — all checked before the
|
|
403
|
+
// Uint32Array for that level is allocated, so a crafted count cannot
|
|
404
|
+
// drive a giant allocation that truncation would only catch afterwards.
|
|
405
|
+
// Same envelope as the engine: create() accepts M in [2, 128] and fixes
|
|
406
|
+
// M0 = 2*M, and its own import rejects M outside that range.
|
|
407
|
+
if (!(M >= 2 && M <= 128) || !(M0 >= M && M0 <= 256)) {
|
|
408
|
+
throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'uint8 snapshot graph parameters are implausible', { M, M0 });
|
|
409
|
+
}
|
|
410
|
+
// Edge layout follows the engine's deserializer (uint8_float_hnsw.hpp):
|
|
411
|
+
// version >= 2 stores {u32 id, f32 distance} per edge, version 1 stores
|
|
412
|
+
// the u32 id only (distances are recomputed on load). The artifact
|
|
413
|
+
// readers never use the stored distance, so both parse to the same graph.
|
|
414
|
+
const edgeBytes = version >= 2 ? 8 : 4;
|
|
415
|
+
const levels = new Uint16Array(count);
|
|
416
|
+
const base = new Array(count);
|
|
417
|
+
const upper = Array.from({ length: count }, () => []);
|
|
418
|
+
for (let id = 0; id < count; id++) {
|
|
419
|
+
const level = u32();
|
|
420
|
+
if (level > maxLevel) {
|
|
421
|
+
throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'uint8 snapshot node level exceeds header maxLevel', { id, level, maxLevel });
|
|
422
|
+
}
|
|
423
|
+
levels[id] = level;
|
|
424
|
+
for (let l = 0; l <= level; l++) {
|
|
425
|
+
const size = u32();
|
|
426
|
+
const cap = l === 0 ? M0 : M;
|
|
427
|
+
if (size > cap) {
|
|
428
|
+
throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'uint8 snapshot adjacency exceeds the graph parameter bound', { id, level: l, size, cap });
|
|
429
|
+
}
|
|
430
|
+
if (raw.byteLength - offset < size * edgeBytes) throw truncated();
|
|
431
|
+
const edges = new Uint32Array(size);
|
|
432
|
+
for (let e = 0; e < size; e++) {
|
|
433
|
+
const neighbor = u32();
|
|
434
|
+
if (neighbor >= count) {
|
|
435
|
+
throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, 'uint8 snapshot neighbor id is out of range', { id, level: l, neighbor, count });
|
|
436
|
+
}
|
|
437
|
+
edges[e] = neighbor;
|
|
438
|
+
if (version >= 2) offset += 4; // the serialized float edge distance, unused here
|
|
439
|
+
}
|
|
440
|
+
if (l === 0) base[id] = edges;
|
|
441
|
+
else upper[id][l - 1] = edges;
|
|
442
|
+
}
|
|
443
|
+
if (!base[id]) base[id] = new Uint32Array(0);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return { kind: 'u8', dim, version, count, entryPoint, maxLevel, M, M0, metric, efConstruction, scales, offsets, qdata, levels, base, upper };
|
|
447
|
+
}
|
|
448
|
+
function sha256Bytes(bytes) {
|
|
449
|
+
const crypto = require('crypto');
|
|
450
|
+
return crypto.createHash('sha256').update(bytes).digest();
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async function sha256BytesAsync(bytes) {
|
|
454
|
+
if (globalThis.crypto && globalThis.crypto.subtle) {
|
|
455
|
+
const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes);
|
|
456
|
+
return new Uint8Array(digest);
|
|
457
|
+
}
|
|
458
|
+
try {
|
|
459
|
+
return new Uint8Array(sha256Bytes(bytes));
|
|
460
|
+
} catch {
|
|
461
|
+
return null; // no crypto available in this environment
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Verify bytes against an expected 32-byte SHA-256 digest. Verification was
|
|
466
|
+
// requested by the caller, so a missing crypto backend fails closed rather
|
|
467
|
+
// than admitting unverified bytes.
|
|
468
|
+
async function verifySha256(bytes, expected, label) {
|
|
469
|
+
const digest = await sha256BytesAsync(bytes);
|
|
470
|
+
if (!digest) {
|
|
471
|
+
throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID,
|
|
472
|
+
'Artifact verification requested but no crypto backend is available; pass verify:false to skip');
|
|
473
|
+
}
|
|
474
|
+
for (let b = 0; b < 32; b++) {
|
|
475
|
+
if (digest[b] !== expected[b]) {
|
|
476
|
+
throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, `${label} failed hash verification`);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Verify a whole segment against its digest without buffering it entirely.
|
|
482
|
+
// With a streaming hash backend (Node crypto) the segment is read and hashed
|
|
483
|
+
// in bounded chunks, so peak memory stays at chunkBytes no matter how large
|
|
484
|
+
// the segment. WebCrypto has no incremental SHA-256, so without a streaming
|
|
485
|
+
// backend the verify falls back to one bounded read — and refuses segments
|
|
486
|
+
// larger than the one-shot limit rather than buffering them.
|
|
487
|
+
async function verifySegmentSha256(source, offset, totalBytes, expected, label, options = {}) {
|
|
488
|
+
const chunkBytes = Math.max(4096, Math.trunc(options.chunkBytes || VERIFY_CHUNK_BYTES));
|
|
489
|
+
let hash = null;
|
|
490
|
+
try {
|
|
491
|
+
hash = require('crypto').createHash('sha256');
|
|
492
|
+
} catch {
|
|
493
|
+
hash = null;
|
|
494
|
+
}
|
|
495
|
+
if (hash) {
|
|
496
|
+
let done = 0;
|
|
497
|
+
while (done < totalBytes) {
|
|
498
|
+
const len = Math.min(chunkBytes, totalBytes - done);
|
|
499
|
+
const bytes = await readChecked(source, offset + done, len, label);
|
|
500
|
+
if (bytes.byteLength !== len) {
|
|
501
|
+
throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID,
|
|
502
|
+
`Artifact ${label} is truncated`, { offset: offset + done, expected: len, actual: bytes.byteLength });
|
|
503
|
+
}
|
|
504
|
+
hash.update(bytes);
|
|
505
|
+
done += len;
|
|
506
|
+
}
|
|
507
|
+
const digest = new Uint8Array(hash.digest());
|
|
508
|
+
for (let b = 0; b < 32; b++) {
|
|
509
|
+
if (digest[b] !== expected[b]) {
|
|
510
|
+
throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID, `${label} failed hash verification`);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
const oneShotLimit = options.oneShotLimit || DEFAULT_OPEN_READ_BYTES;
|
|
516
|
+
if (totalBytes > oneShotLimit) {
|
|
517
|
+
throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID,
|
|
518
|
+
`Artifact ${label} is too large to verify without a streaming crypto backend`,
|
|
519
|
+
{ totalBytes, oneShotLimit });
|
|
520
|
+
}
|
|
521
|
+
const bytes = await readChecked(source, offset, totalBytes, label, oneShotLimit);
|
|
522
|
+
if (bytes.byteLength !== totalBytes) {
|
|
523
|
+
throw pancakeError(PANCAKE_ERROR_CODES.SNAPSHOT_INVALID,
|
|
524
|
+
`Artifact ${label} is truncated`, { offset, expected: totalBytes, actual: bytes.byteLength });
|
|
525
|
+
}
|
|
526
|
+
await verifySha256(bytes, expected, label);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
module.exports = {
|
|
530
|
+
resolveSearchK,
|
|
531
|
+
resolveOptionalPositiveInt,
|
|
532
|
+
PANCAKE_MAGIC,
|
|
533
|
+
V1_ENVELOPE_HEADER_SIZE,
|
|
534
|
+
V2_ENVELOPE_HEADER_SIZE,
|
|
535
|
+
V3_ENVELOPE_HEADER_SIZE,
|
|
536
|
+
MAPPING_ENTRY_SIZE,
|
|
537
|
+
UINT8_HNSW_MAGIC_V1,
|
|
538
|
+
MAX_ARTIFACT_READ_BYTES,
|
|
539
|
+
DEFAULT_OPEN_READ_BYTES,
|
|
540
|
+
MAX_COALESCED_RANGE_BYTES,
|
|
541
|
+
VERIFY_CHUNK_BYTES,
|
|
542
|
+
mapLimit,
|
|
543
|
+
checkArtifactRange,
|
|
544
|
+
readChecked,
|
|
545
|
+
resolveMaxReadBytes,
|
|
546
|
+
asUint8Array,
|
|
547
|
+
resolveMaxCacheBytes,
|
|
548
|
+
readU32,
|
|
549
|
+
readU16,
|
|
550
|
+
readF32,
|
|
551
|
+
compareDistancesAsc,
|
|
552
|
+
MinHeap,
|
|
553
|
+
NodeFileRangeSource,
|
|
554
|
+
normalizeQuery,
|
|
555
|
+
unwrapSnapshot,
|
|
556
|
+
parseUint8Snapshot,
|
|
557
|
+
sha256Bytes,
|
|
558
|
+
sha256BytesAsync,
|
|
559
|
+
verifySha256,
|
|
560
|
+
verifySegmentSha256,
|
|
561
|
+
};
|