albex 0.1.0 → 0.6.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 +416 -0
- package/README.md +244 -112
- package/dist/albex-worker.d.ts +70 -0
- package/dist/albex-worker.d.ts.map +1 -0
- package/dist/albex-worker.js +153 -0
- package/dist/albex-worker.js.map +1 -0
- package/dist/albex.d.ts +508 -6
- package/dist/albex.d.ts.map +1 -1
- package/dist/albex.js +1911 -141
- package/dist/albex.js.map +1 -1
- package/dist/errors.d.ts +52 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +66 -0
- package/dist/errors.js.map +1 -0
- package/dist/gpu/bloom-runtime.d.ts +60 -0
- package/dist/gpu/bloom-runtime.d.ts.map +1 -0
- package/dist/gpu/bloom-runtime.js +176 -0
- package/dist/gpu/bloom-runtime.js.map +1 -0
- package/dist/gpu/bloom-shader.wgsl.d.ts +19 -0
- package/dist/gpu/bloom-shader.wgsl.d.ts.map +1 -0
- package/dist/gpu/bloom-shader.wgsl.js +49 -0
- package/dist/gpu/bloom-shader.wgsl.js.map +1 -0
- package/dist/persistence.d.ts +21 -0
- package/dist/persistence.d.ts.map +1 -0
- package/dist/persistence.js +174 -0
- package/dist/persistence.js.map +1 -0
- package/dist/pool/coordinator.d.ts +98 -0
- package/dist/pool/coordinator.d.ts.map +1 -0
- package/dist/pool/coordinator.js +247 -0
- package/dist/pool/coordinator.js.map +1 -0
- package/dist/profile.d.ts +100 -0
- package/dist/profile.d.ts.map +1 -0
- package/dist/profile.js +200 -0
- package/dist/profile.js.map +1 -0
- package/dist/resource-manager.d.ts +56 -0
- package/dist/resource-manager.d.ts.map +1 -0
- package/dist/resource-manager.js +138 -0
- package/dist/resource-manager.js.map +1 -0
- package/dist/tiered-store.d.ts +98 -0
- package/dist/tiered-store.d.ts.map +1 -0
- package/dist/tiered-store.js +238 -0
- package/dist/tiered-store.js.map +1 -0
- package/dist/wasm-bindings.d.ts +180 -0
- package/dist/wasm-bindings.d.ts.map +1 -0
- package/dist/wasm-bindings.js +128 -0
- package/dist/wasm-bindings.js.map +1 -0
- package/dist/worker-protocol.d.ts +86 -0
- package/dist/worker-protocol.d.ts.map +1 -0
- package/dist/worker-protocol.js +20 -0
- package/dist/worker-protocol.js.map +1 -0
- package/dist/worker-runtime.d.ts +14 -0
- package/dist/worker-runtime.d.ts.map +1 -0
- package/dist/worker-runtime.js +109 -0
- package/dist/worker-runtime.js.map +1 -0
- package/package.json +60 -13
- package/src/albex-worker.ts +187 -0
- package/src/albex.ts +2136 -189
- package/src/errors.ts +76 -0
- package/src/gpu/bloom-runtime.ts +229 -0
- package/src/gpu/bloom-shader.wgsl.ts +48 -0
- package/src/persistence.ts +175 -0
- package/src/pool/coordinator.ts +324 -0
- package/src/profile.ts +280 -0
- package/src/resource-manager.ts +167 -0
- package/src/tiered-store.ts +259 -0
- package/src/wasm-bindings.ts +349 -0
- package/src/worker-protocol.ts +48 -0
- package/src/worker-runtime.ts +106 -0
- package/wasm/pkg/albex_pdf.wasm +0 -0
- package/wasm/pkg/albex_wasm.wasm +0 -0
- package/wasm/pkg/albex_wasm_bg.wasm +0 -0
- package/wasm/pkg/albex_wasm_simd.wasm +0 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pool coordinator.
|
|
3
|
+
*
|
|
4
|
+
* Runs on the main thread and orchestrates N `AlbexEngineWorker`-style
|
|
5
|
+
* shards. The corpus is split round-robin by document at index time:
|
|
6
|
+
*
|
|
7
|
+
* indexFile(doc_i) → workers[i % N]
|
|
8
|
+
*
|
|
9
|
+
* Search broadcasts the query to every shard, collects each shard's
|
|
10
|
+
* top-K results, and performs a global heap-merge — preserving the
|
|
11
|
+
* relevance ordering of the unsharded engine. Ties broken by score.
|
|
12
|
+
*
|
|
13
|
+
* **Modes:**
|
|
14
|
+
* - `'replicated'` (default): each worker holds its own copy of its shard.
|
|
15
|
+
* Works in every browser, no special headers needed.
|
|
16
|
+
* - `'shared'`: backed by SharedArrayBuffer, requires cross-origin isolation
|
|
17
|
+
* (COOP+COEP). Single source of truth, halves the memory cost at the
|
|
18
|
+
* expense of harder deployment.
|
|
19
|
+
*
|
|
20
|
+
* Today we ship `replicated` — `shared` is wired up to detect availability
|
|
21
|
+
* (so consumers can opt in once we land the WASM-side `SharedArrayBuffer`
|
|
22
|
+
* variant in a future iteration) but defaults to off.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type {
|
|
26
|
+
AlbexOptions,
|
|
27
|
+
IndexedDocument,
|
|
28
|
+
SearchOptions,
|
|
29
|
+
SearchResult,
|
|
30
|
+
EngineStats,
|
|
31
|
+
SearchStats,
|
|
32
|
+
} from '../albex.js';
|
|
33
|
+
import type { Tier } from '../profile.js';
|
|
34
|
+
import { detectProfile, pickWorkerCount } from '../profile.js';
|
|
35
|
+
import { AlbexInitError, AlbexError } from '../errors.js';
|
|
36
|
+
import type {
|
|
37
|
+
WorkerRequest,
|
|
38
|
+
WorkerResponse,
|
|
39
|
+
WorkerOp,
|
|
40
|
+
} from '../worker-protocol.js';
|
|
41
|
+
|
|
42
|
+
export interface AlbexPoolOptions extends AlbexOptions {
|
|
43
|
+
/** Worker runtime URL (the `worker-runtime.js` from this package). */
|
|
44
|
+
workerUrl: string | URL;
|
|
45
|
+
/**
|
|
46
|
+
* Number of worker shards. `'auto'` (default) uses half of
|
|
47
|
+
* `navigator.hardwareConcurrency`, clamped to [1, 8].
|
|
48
|
+
*
|
|
49
|
+
* Pass an explicit number to override — for example `1` to debug, or
|
|
50
|
+
* a higher fixed value if you know your corpus benefits from more
|
|
51
|
+
* shards on a specific deployment.
|
|
52
|
+
*/
|
|
53
|
+
workers?: number | 'auto';
|
|
54
|
+
/** Memory-sharing strategy. */
|
|
55
|
+
mode?: 'replicated' | 'shared';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface Pending {
|
|
59
|
+
resolve: (v: unknown) => void;
|
|
60
|
+
reject: (e: unknown) => void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface Shard {
|
|
64
|
+
worker: Worker;
|
|
65
|
+
nextId: number;
|
|
66
|
+
pending: Map<number, Pending>;
|
|
67
|
+
// Number of documents this shard currently holds — used for stats and
|
|
68
|
+
// for the round-robin counter.
|
|
69
|
+
docCount: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let _poolSearchStreamWarned = false;
|
|
73
|
+
|
|
74
|
+
export class AlbexPool {
|
|
75
|
+
private readonly _opts: AlbexPoolOptions;
|
|
76
|
+
private _shards: Shard[] = [];
|
|
77
|
+
private _docsCache: IndexedDocument[] = [];
|
|
78
|
+
private _rrCursor = 0;
|
|
79
|
+
private _lastSearch: SearchStats | null = null;
|
|
80
|
+
private _tier: Tier | null = null;
|
|
81
|
+
|
|
82
|
+
constructor(opts: AlbexPoolOptions) {
|
|
83
|
+
this._opts = opts;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async init(): Promise<void> {
|
|
87
|
+
const profile = await detectProfile();
|
|
88
|
+
const n =
|
|
89
|
+
typeof this._opts.workers === 'number' && this._opts.workers > 0
|
|
90
|
+
? Math.floor(this._opts.workers)
|
|
91
|
+
: pickWorkerCount(profile);
|
|
92
|
+
|
|
93
|
+
// `shared` mode would require SharedArrayBuffer + COOP/COEP. The check
|
|
94
|
+
// here is informational — actually using SAB inside the worker requires
|
|
95
|
+
// a corresponding WASM-side rework that is not yet shipped. Falling back
|
|
96
|
+
// to replicated is the safe path.
|
|
97
|
+
if (this._opts.mode === 'shared' && !profile.coopCoep) {
|
|
98
|
+
console.warn('[albex] pool mode=shared requested but cross-origin isolation is not active; falling back to replicated');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const shardOpts: AlbexOptions = {
|
|
102
|
+
wasmUrl: this._opts.wasmUrl,
|
|
103
|
+
wasmBaseUrl: this._opts.wasmBaseUrl,
|
|
104
|
+
pdfWasmUrl: this._opts.pdfWasmUrl,
|
|
105
|
+
tier: this._opts.tier,
|
|
106
|
+
simd: this._opts.simd,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
for (let i = 0; i < n; i++) {
|
|
110
|
+
const shard = this._spawnShard();
|
|
111
|
+
await this._send(shard, { kind: 'init', opts: shardOpts });
|
|
112
|
+
this._shards.push(shard);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Tier is the same across shards — capture it from shard 0 stats.
|
|
116
|
+
const stats0 = await this._send<EngineStats>(this._shards[0]!, { kind: 'getStats' });
|
|
117
|
+
this._tier = stats0.tier;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Shard plumbing ─────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
private _spawnShard(): Shard {
|
|
123
|
+
const worker = new Worker(this._opts.workerUrl, { type: 'module' });
|
|
124
|
+
const shard: Shard = { worker, nextId: 1, pending: new Map(), docCount: 0 };
|
|
125
|
+
worker.onmessage = (ev: MessageEvent<WorkerResponse>) => {
|
|
126
|
+
const p = shard.pending.get(ev.data.id);
|
|
127
|
+
if (!p) return;
|
|
128
|
+
shard.pending.delete(ev.data.id);
|
|
129
|
+
if (ev.data.ok) p.resolve(ev.data.result);
|
|
130
|
+
else p.reject(this._rehydrate(ev.data.error));
|
|
131
|
+
};
|
|
132
|
+
worker.onerror = (e) => {
|
|
133
|
+
const err = new AlbexInitError(`Pool shard crashed: ${e.message}`);
|
|
134
|
+
for (const [, p] of shard.pending) p.reject(err);
|
|
135
|
+
shard.pending.clear();
|
|
136
|
+
};
|
|
137
|
+
return shard;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private _send<T = unknown>(
|
|
141
|
+
shard: Shard,
|
|
142
|
+
op: WorkerOp,
|
|
143
|
+
transfer: Transferable[] = [],
|
|
144
|
+
): Promise<T> {
|
|
145
|
+
const id = shard.nextId++;
|
|
146
|
+
const req: WorkerRequest = { id, op };
|
|
147
|
+
return new Promise<T>((resolve, reject) => {
|
|
148
|
+
shard.pending.set(id, { resolve: resolve as (v: unknown) => void, reject });
|
|
149
|
+
shard.worker.postMessage(req, transfer);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private _broadcast<T = unknown>(op: WorkerOp): Promise<T[]> {
|
|
154
|
+
return Promise.all(this._shards.map(s => this._send<T>(s, op)));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private _rehydrate(e: { name: string; kind?: string; message: string }): Error {
|
|
158
|
+
// Re-importing the rich subclasses here would create a circular dep
|
|
159
|
+
// with the engine wrapper; we forward the kind unchanged in a generic
|
|
160
|
+
// AlbexError instance instead.
|
|
161
|
+
if (e.kind) return new AlbexError(e.kind, e.message);
|
|
162
|
+
const err = new Error(e.message);
|
|
163
|
+
err.name = e.name;
|
|
164
|
+
return err;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── Public API (mirrors AlbexEngine) ──────────────────────────────────
|
|
168
|
+
|
|
169
|
+
async indexFile(file: File): Promise<IndexedDocument> {
|
|
170
|
+
if (this._shards.length === 0) throw new AlbexInitError('Pool not initialised');
|
|
171
|
+
const idx = this._rrCursor++ % this._shards.length;
|
|
172
|
+
const shard = this._shards[idx]!;
|
|
173
|
+
const buffer = await file.arrayBuffer();
|
|
174
|
+
const doc = await this._send<IndexedDocument>(
|
|
175
|
+
shard,
|
|
176
|
+
{ kind: 'indexFile', name: file.name, buffer },
|
|
177
|
+
[buffer],
|
|
178
|
+
);
|
|
179
|
+
shard.docCount++;
|
|
180
|
+
this._docsCache.push(doc);
|
|
181
|
+
return doc;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Map-reduce search:
|
|
186
|
+
* 1. broadcast the query to every shard,
|
|
187
|
+
* 2. each shard runs its local Bloom→Bitap→top-K,
|
|
188
|
+
* 3. coordinator merges the K-best from each shard,
|
|
189
|
+
* 4. global top-K returned in descending score order.
|
|
190
|
+
*
|
|
191
|
+
* Capped to `setMaxResults` results (default 50) AFTER merge.
|
|
192
|
+
*/
|
|
193
|
+
async search(query: string, opts: SearchOptions = {}): Promise<SearchResult[]> {
|
|
194
|
+
if (this._shards.length === 0) return [];
|
|
195
|
+
const t0 = performance.now();
|
|
196
|
+
const buckets = await Promise.all(
|
|
197
|
+
this._shards.map(s => this._send<SearchResult[]>(s, { kind: 'search', query, options: opts })),
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
// Merge: simple flatten + sort (descending). Each shard already returned
|
|
201
|
+
// up to its local cap; the global cap could be different but in practice
|
|
202
|
+
// matches the per-shard cap.
|
|
203
|
+
const merged = buckets.flat();
|
|
204
|
+
merged.sort((a, b) => b.score - a.score);
|
|
205
|
+
|
|
206
|
+
// Aggregate search stats across shards.
|
|
207
|
+
const stats = await Promise.all(
|
|
208
|
+
this._shards.map(s => this._send<SearchStats | null>(s, { kind: 'getLastSearchStats' })),
|
|
209
|
+
);
|
|
210
|
+
let bloomTested = 0, bloomPassed = 0, bitapMatched = 0;
|
|
211
|
+
for (const s of stats) {
|
|
212
|
+
if (!s) continue;
|
|
213
|
+
bloomTested += s.bloomTested;
|
|
214
|
+
bloomPassed += s.bloomPassed;
|
|
215
|
+
bitapMatched += s.bitapMatched;
|
|
216
|
+
}
|
|
217
|
+
this._lastSearch = {
|
|
218
|
+
query,
|
|
219
|
+
timeMs: performance.now() - t0,
|
|
220
|
+
results: merged.length,
|
|
221
|
+
bloomTested, bloomPassed, bitapMatched,
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
return merged;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Cooperative variant of `search`. The coordinator awaits every shard,
|
|
229
|
+
* merges, sorts, and then iterates out the result list — the iterator
|
|
230
|
+
* shape exists so callers can `break` early. Streaming individual
|
|
231
|
+
* results before the cross-shard merge would deliver them in arbitrary
|
|
232
|
+
* order; that's why we materialise first.
|
|
233
|
+
*/
|
|
234
|
+
async *searchCooperative(query: string, opts: SearchOptions = {}): AsyncIterable<SearchResult> {
|
|
235
|
+
const results = await this.search(query, opts);
|
|
236
|
+
for (const r of results) yield r;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* @deprecated Renamed to `searchCooperative` in 0.3.0. Alias removed in 0.4.0.
|
|
241
|
+
*/
|
|
242
|
+
async *searchStream(query: string, opts: SearchOptions = {}): AsyncIterable<SearchResult> {
|
|
243
|
+
if (!_poolSearchStreamWarned) {
|
|
244
|
+
_poolSearchStreamWarned = true;
|
|
245
|
+
console.warn('[albex] `AlbexPool.searchStream` is deprecated; rename to `searchCooperative`. Alias removed in 0.4.0.');
|
|
246
|
+
}
|
|
247
|
+
yield* this.searchCooperative(query, opts);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Remove a document from whichever shard owns it. Tries each shard until
|
|
252
|
+
* one reports success. O(workers) — acceptable since N ≤ 8.
|
|
253
|
+
*/
|
|
254
|
+
async removeDocument(id: string): Promise<boolean> {
|
|
255
|
+
for (const shard of this._shards) {
|
|
256
|
+
const ok = await this._send<boolean>(shard, { kind: 'removeDocument', id });
|
|
257
|
+
if (ok) {
|
|
258
|
+
shard.docCount = Math.max(0, shard.docCount - 1);
|
|
259
|
+
this._docsCache = this._docsCache.filter(d => d.name !== id && d.contentHash !== id);
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async compact(): Promise<void> {
|
|
267
|
+
await this._broadcast({ kind: 'compact' });
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async reset(): Promise<void> {
|
|
271
|
+
await this._broadcast({ kind: 'reset' });
|
|
272
|
+
this._docsCache = [];
|
|
273
|
+
for (const s of this._shards) s.docCount = 0;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Aggregate engine stats across all shards. */
|
|
277
|
+
async getStats(): Promise<EngineStats> {
|
|
278
|
+
const all = await this._broadcast<EngineStats>({ kind: 'getStats' });
|
|
279
|
+
let documents = 0, chunks = 0, textUsed = 0, textCapacity = 0, wasmMemoryBytes = 0;
|
|
280
|
+
let maxChunks = 0, maxDocs = 0;
|
|
281
|
+
for (const s of all) {
|
|
282
|
+
documents += s.documents;
|
|
283
|
+
chunks += s.chunks;
|
|
284
|
+
textUsed += s.textUsed;
|
|
285
|
+
textCapacity += s.textCapacity;
|
|
286
|
+
wasmMemoryBytes += s.wasmMemoryBytes;
|
|
287
|
+
maxChunks += s.maxChunks;
|
|
288
|
+
maxDocs += s.maxDocs;
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
documents, chunks, textUsed, textCapacity, wasmMemoryBytes,
|
|
292
|
+
tier: this._tier, maxChunks, maxDocs,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
getLastSearchStats(): SearchStats | null {
|
|
297
|
+
return this._lastSearch;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async getDocuments(): Promise<readonly IndexedDocument[]> {
|
|
301
|
+
return this._docsCache.slice();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async setMaxErrors(n: 0 | 1 | 2 | 3): Promise<void> { await this._broadcast({ kind: 'setMaxErrors', n }); }
|
|
305
|
+
async setThreshold(n: number): Promise<void> { await this._broadcast({ kind: 'setThreshold', n }); }
|
|
306
|
+
async setMaxResults(n: number): Promise<void> { await this._broadcast({ kind: 'setMaxResults', n }); }
|
|
307
|
+
async setLanguage(lang: 'off' | 'es'): Promise<void> { await this._broadcast({ kind: 'setLanguage', lang }); }
|
|
308
|
+
|
|
309
|
+
/** Number of shards currently running. */
|
|
310
|
+
get workerCount(): number { return this._shards.length; }
|
|
311
|
+
|
|
312
|
+
/** Tier loaded by the shards (same value across all of them). */
|
|
313
|
+
get tier(): Tier | null { return this._tier; }
|
|
314
|
+
|
|
315
|
+
[Symbol.dispose](): void {
|
|
316
|
+
for (const s of this._shards) {
|
|
317
|
+
for (const [, p] of s.pending) p.reject(new AlbexError('disposed', 'Pool disposed'));
|
|
318
|
+
s.pending.clear();
|
|
319
|
+
s.worker.terminate();
|
|
320
|
+
}
|
|
321
|
+
this._shards = [];
|
|
322
|
+
this._docsCache = [];
|
|
323
|
+
}
|
|
324
|
+
}
|
package/src/profile.ts
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capability profile detection.
|
|
3
|
+
*
|
|
4
|
+
* Probes the host environment ONCE per session and returns a structured
|
|
5
|
+
* snapshot. Used by `Albex.create()` to choose the optimal binary tier
|
|
6
|
+
* (mini/std/pro), enable SIMD when available, decide whether to spin up a
|
|
7
|
+
* worker pool, allocate WebGPU resources, etc.
|
|
8
|
+
*
|
|
9
|
+
* The function is intentionally side-effect-free except for the optional
|
|
10
|
+
* `sessionStorage` cache — every property is a synchronous-or-async probe
|
|
11
|
+
* with a sensible default if it throws.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const CACHE_KEY = 'albex.profile.v1';
|
|
15
|
+
|
|
16
|
+
export interface DeviceProfile {
|
|
17
|
+
/** Number of logical cores reported by the browser (1-128 typical). */
|
|
18
|
+
cores: number;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Approximate device memory in GB, capped at 8 by the spec for privacy.
|
|
22
|
+
* `null` if `navigator.deviceMemory` is unavailable (Safari, Firefox).
|
|
23
|
+
*/
|
|
24
|
+
memoryGB: number | null;
|
|
25
|
+
|
|
26
|
+
wasm: {
|
|
27
|
+
/** WebAssembly SIMD (v128) instructions supported. */
|
|
28
|
+
simd: boolean;
|
|
29
|
+
/** Bulk memory ops (memory.copy, memory.fill) supported. */
|
|
30
|
+
bulkMemory: boolean;
|
|
31
|
+
/** Threads (Atomics + SharedArrayBuffer) supported AND page is cross-origin isolated. */
|
|
32
|
+
threads: boolean;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** `true` if `navigator.gpu` is available. Does NOT mean a device was acquired yet. */
|
|
36
|
+
webgpu: boolean;
|
|
37
|
+
|
|
38
|
+
/** Cross-Origin Isolation (required for SAB-based shared mode). */
|
|
39
|
+
coopCoep: boolean;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* OPFS / IndexedDB free space, if reported. Some browsers gate this behind
|
|
43
|
+
* permissions or return overly conservative values. `null` if unknown.
|
|
44
|
+
*/
|
|
45
|
+
storage: {
|
|
46
|
+
quotaBytes: number | null;
|
|
47
|
+
usageBytes: number | null;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Network conditions if reported via Network Information API (Chrome only
|
|
52
|
+
* at time of writing). Used to throttle PDF wasm prefetch on slow links.
|
|
53
|
+
*/
|
|
54
|
+
net: {
|
|
55
|
+
effectiveType: 'slow-2g' | '2g' | '3g' | '4g' | 'unknown';
|
|
56
|
+
saveData: boolean;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Battery state if Battery Status API is available (Chrome on mobile).
|
|
61
|
+
* `null` everywhere else. Used to opt into low-power mode.
|
|
62
|
+
*/
|
|
63
|
+
battery: {
|
|
64
|
+
level: number | null; // 0-1
|
|
65
|
+
charging: boolean | null;
|
|
66
|
+
} | null;
|
|
67
|
+
|
|
68
|
+
/** Page visibility at probe time. Engines should re-listen after this. */
|
|
69
|
+
visible: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── WASM feature probes ──────────────────────────────────────────────────────
|
|
73
|
+
//
|
|
74
|
+
// Each probe is a minimal valid module that uses exactly one feature. If
|
|
75
|
+
// `WebAssembly.validate` returns true, the host supports it. We hand-coded
|
|
76
|
+
// the byte sequences instead of pulling in a builder; the modules are
|
|
77
|
+
// tiny and stable across spec revisions.
|
|
78
|
+
|
|
79
|
+
const PROBE_SIMD = new Uint8Array([
|
|
80
|
+
// magic + version
|
|
81
|
+
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
|
|
82
|
+
// type section: (func (result v128))
|
|
83
|
+
0x01, 0x05, 0x01, 0x60, 0x00, 0x01, 0x7b,
|
|
84
|
+
// function section
|
|
85
|
+
0x03, 0x02, 0x01, 0x00,
|
|
86
|
+
// code section: v128.const 0
|
|
87
|
+
0x0a, 0x16, 0x01, 0x14, 0x00,
|
|
88
|
+
0xfd, 0x0c,
|
|
89
|
+
0,0,0,0, 0,0,0,0, 0,0,0,0, 0,0,0,0,
|
|
90
|
+
0x0b,
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
const PROBE_BULK_MEMORY = new Uint8Array([
|
|
94
|
+
// magic + version
|
|
95
|
+
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
|
|
96
|
+
// type section: (func)
|
|
97
|
+
0x01, 0x04, 0x01, 0x60, 0x00, 0x00,
|
|
98
|
+
// function section: one function of type 0
|
|
99
|
+
0x03, 0x02, 0x01, 0x00,
|
|
100
|
+
// memory section: min=1 page
|
|
101
|
+
0x05, 0x03, 0x01, 0x00, 0x01,
|
|
102
|
+
// code section
|
|
103
|
+
// section id 0x0a, section size 13
|
|
104
|
+
// func count = 1
|
|
105
|
+
// body size = 11
|
|
106
|
+
// locals = 0
|
|
107
|
+
// i32.const 0, i32.const 0, i32.const 0
|
|
108
|
+
// memory.fill (0xfc 0x0b 0x00)
|
|
109
|
+
// end (0x0b)
|
|
110
|
+
0x0a, 0x0d, 0x01, 0x0b, 0x00,
|
|
111
|
+
0x41, 0x00, 0x41, 0x00, 0x41, 0x00,
|
|
112
|
+
0xfc, 0x0b, 0x00,
|
|
113
|
+
0x0b,
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
const PROBE_THREADS = new Uint8Array([
|
|
117
|
+
// magic + version
|
|
118
|
+
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
|
|
119
|
+
// memory: shared, min=1, max=1
|
|
120
|
+
0x05, 0x04, 0x01, 0x03, 0x01, 0x01,
|
|
121
|
+
]);
|
|
122
|
+
|
|
123
|
+
function safeValidate(bytes: Uint8Array): boolean {
|
|
124
|
+
try {
|
|
125
|
+
if (typeof WebAssembly === 'undefined') return false;
|
|
126
|
+
// WebAssembly.validate types insist on ArrayBuffer-backed BufferSource;
|
|
127
|
+
// copying through a fresh Uint8Array satisfies that.
|
|
128
|
+
const copy = new Uint8Array(bytes.byteLength);
|
|
129
|
+
copy.set(bytes);
|
|
130
|
+
return WebAssembly.validate(copy);
|
|
131
|
+
} catch {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── Storage probe (async) ────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
async function probeStorage(): Promise<{ quotaBytes: number | null; usageBytes: number | null }> {
|
|
139
|
+
try {
|
|
140
|
+
if (typeof navigator !== 'undefined' && navigator.storage?.estimate) {
|
|
141
|
+
const est = await navigator.storage.estimate();
|
|
142
|
+
return {
|
|
143
|
+
quotaBytes: typeof est.quota === 'number' ? est.quota : null,
|
|
144
|
+
usageBytes: typeof est.usage === 'number' ? est.usage : null,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
} catch { /* fall through */ }
|
|
148
|
+
return { quotaBytes: null, usageBytes: null };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── Battery probe (async) ────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
interface BatteryManagerLike {
|
|
154
|
+
level: number;
|
|
155
|
+
charging: boolean;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function probeBattery(): Promise<DeviceProfile['battery']> {
|
|
159
|
+
try {
|
|
160
|
+
// @ts-expect-error Battery API typing is sparse
|
|
161
|
+
const getBat: (() => Promise<BatteryManagerLike>) | undefined = navigator?.getBattery?.bind(navigator);
|
|
162
|
+
if (!getBat) return null;
|
|
163
|
+
const b = await getBat();
|
|
164
|
+
return { level: b.level, charging: b.charging };
|
|
165
|
+
} catch {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── Public API ───────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Build a fresh `DeviceProfile`. The result is cached in `sessionStorage`
|
|
174
|
+
* (key `albex.profile.v1`) so subsequent calls in the same tab are free.
|
|
175
|
+
*
|
|
176
|
+
* Pass `{ fresh: true }` to bypass the cache (useful in tests or after a
|
|
177
|
+
* known capability change such as connecting to power).
|
|
178
|
+
*/
|
|
179
|
+
export async function detectProfile(opts: { fresh?: boolean } = {}): Promise<DeviceProfile> {
|
|
180
|
+
if (!opts.fresh) {
|
|
181
|
+
try {
|
|
182
|
+
const cached = sessionStorage.getItem(CACHE_KEY);
|
|
183
|
+
if (cached) return JSON.parse(cached) as DeviceProfile;
|
|
184
|
+
} catch { /* ignore */ }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const nav: Navigator | undefined = typeof navigator !== 'undefined' ? navigator : undefined;
|
|
188
|
+
|
|
189
|
+
const simd = safeValidate(PROBE_SIMD);
|
|
190
|
+
const bulkMemory = safeValidate(PROBE_BULK_MEMORY);
|
|
191
|
+
// Threads need both validate (shared memory) AND cross-origin isolation.
|
|
192
|
+
const sabAvailable = typeof SharedArrayBuffer !== 'undefined';
|
|
193
|
+
const coopCoep =
|
|
194
|
+
typeof self !== 'undefined' &&
|
|
195
|
+
typeof (self as unknown as { crossOriginIsolated?: boolean }).crossOriginIsolated === 'boolean' &&
|
|
196
|
+
(self as unknown as { crossOriginIsolated: boolean }).crossOriginIsolated === true;
|
|
197
|
+
const threadsValidated = safeValidate(PROBE_THREADS);
|
|
198
|
+
const threads = sabAvailable && coopCoep && threadsValidated;
|
|
199
|
+
|
|
200
|
+
const webgpu = !!(nav && 'gpu' in nav);
|
|
201
|
+
|
|
202
|
+
const [storage, battery] = await Promise.all([
|
|
203
|
+
probeStorage(),
|
|
204
|
+
probeBattery(),
|
|
205
|
+
]);
|
|
206
|
+
|
|
207
|
+
const connection = (nav as unknown as { connection?: { effectiveType?: string; saveData?: boolean } } | undefined)?.connection;
|
|
208
|
+
|
|
209
|
+
const profile: DeviceProfile = {
|
|
210
|
+
cores: nav?.hardwareConcurrency ?? 1,
|
|
211
|
+
memoryGB:
|
|
212
|
+
(nav as unknown as { deviceMemory?: number } | undefined)?.deviceMemory ?? null,
|
|
213
|
+
wasm: { simd, bulkMemory, threads },
|
|
214
|
+
webgpu,
|
|
215
|
+
coopCoep,
|
|
216
|
+
storage,
|
|
217
|
+
net: {
|
|
218
|
+
effectiveType: (connection?.effectiveType as DeviceProfile['net']['effectiveType']) ?? 'unknown',
|
|
219
|
+
saveData: connection?.saveData ?? false,
|
|
220
|
+
},
|
|
221
|
+
battery,
|
|
222
|
+
visible: typeof document !== 'undefined'
|
|
223
|
+
? document.visibilityState === 'visible'
|
|
224
|
+
: true,
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
sessionStorage.setItem(CACHE_KEY, JSON.stringify(profile));
|
|
229
|
+
} catch { /* sessionStorage may be full or disabled */ }
|
|
230
|
+
|
|
231
|
+
return profile;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── Tier selection ───────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* @deprecated The tier system was removed in 0.5.0 (audit 4.1: "6
|
|
238
|
+
* binaries × no proven benefit"). The type remains exported as `'std'`
|
|
239
|
+
* for backwards compatibility with code that read `engine.getStats().tier`.
|
|
240
|
+
*/
|
|
241
|
+
export type Tier = 'std';
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* @deprecated Always returns `'std'` as of 0.5.0. Albex ships exactly
|
|
245
|
+
* two main binaries (baseline + SIMD); the only runtime variant is the
|
|
246
|
+
* SIMD probe, not a capacity tier. Kept callable so existing integrators
|
|
247
|
+
* don't break, but the value has no operational meaning anymore.
|
|
248
|
+
*/
|
|
249
|
+
export function pickTier(_profile: DeviceProfile): Tier {
|
|
250
|
+
return 'std';
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Suggested number of search/index workers given a profile.
|
|
255
|
+
*
|
|
256
|
+
* Heuristic: half of logical cores, clamped to [1, 8]. The clamp is
|
|
257
|
+
* deliberate — beyond 8 workers the postMessage coordination overhead
|
|
258
|
+
* starts eating the parallelism gain for typical Albex query shapes.
|
|
259
|
+
*
|
|
260
|
+
* Battery awareness: when battery is reported and below 20% and discharging,
|
|
261
|
+
* we shrink the pool to 1 to preserve the user's session.
|
|
262
|
+
*/
|
|
263
|
+
export function pickWorkerCount(profile: DeviceProfile): number {
|
|
264
|
+
if (profile.battery && profile.battery.level !== null
|
|
265
|
+
&& profile.battery.level < 0.2
|
|
266
|
+
&& profile.battery.charging === false) {
|
|
267
|
+
return 1;
|
|
268
|
+
}
|
|
269
|
+
const half = Math.floor(profile.cores / 2);
|
|
270
|
+
return Math.max(1, Math.min(8, half));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Whether WebGPU is worth using for the current workload.
|
|
275
|
+
*
|
|
276
|
+
* Threshold is exposed so callers can tweak per index size.
|
|
277
|
+
*/
|
|
278
|
+
export function shouldUseGpu(profile: DeviceProfile, chunkCount: number, threshold = 20_000): boolean {
|
|
279
|
+
return profile.webgpu && chunkCount > threshold;
|
|
280
|
+
}
|