edgeinfer 0.0.0-stage → 2.0.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/LICENSE +21 -0
- package/README.md +363 -2
- package/dist/index.d.mts +590 -0
- package/dist/index.d.ts +590 -0
- package/dist/index.js +1827 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1769 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +62 -4
- package/src/download.ts +39 -0
- package/src/edgeinfer.ts +710 -0
- package/src/events.ts +17 -0
- package/src/image-processor.ts +35 -0
- package/src/index.ts +15 -0
- package/src/matryoshka.ts +441 -0
- package/src/model-cache.ts +46 -0
- package/src/onnx-lm.ts +104 -0
- package/src/runtime-manager.ts +338 -0
- package/src/speculative.ts +294 -0
- package/src/tokenizer.ts +419 -0
- package/src/types.ts +19 -0
package/src/edgeinfer.ts
ADDED
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2024-2026 Soumya Debnath
|
|
3
|
+
|
|
4
|
+
import * as ort from 'onnxruntime-web';
|
|
5
|
+
import { ModelCache } from './model-cache';
|
|
6
|
+
import { DownloadOptions, checkDownloadAbort } from './download';
|
|
7
|
+
import { RuntimeManager, GPUCapabilities } from './runtime-manager';
|
|
8
|
+
import { ModelOptions, ClassificationResult, Detection } from './types';
|
|
9
|
+
import { Tokenizer, TokenizerConfig, EncodingResult } from './tokenizer';
|
|
10
|
+
import { ImageProcessor } from './image-processor';
|
|
11
|
+
|
|
12
|
+
function throwIfAborted(signal?: AbortSignal): void {
|
|
13
|
+
if (signal?.aborted) {
|
|
14
|
+
throw signal.reason ?? new DOMException('Generation aborted', 'AbortError');
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function normalizeChatMessages(messages: ChatMessage[]): ChatMessage[] {
|
|
19
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
20
|
+
throw new Error('chat requires at least one message');
|
|
21
|
+
}
|
|
22
|
+
return messages.map((message, index) => {
|
|
23
|
+
if (!message || typeof message !== 'object') {
|
|
24
|
+
throw new Error(`chat message ${index} must be an object`);
|
|
25
|
+
}
|
|
26
|
+
if (message.role !== 'system' && message.role !== 'user' && message.role !== 'assistant') {
|
|
27
|
+
throw new Error(`chat message ${index} has an invalid role`);
|
|
28
|
+
}
|
|
29
|
+
if (typeof message.content !== 'string') {
|
|
30
|
+
throw new Error(`chat message ${index} content must be a string`);
|
|
31
|
+
}
|
|
32
|
+
return { role: message.role, content: message.content };
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function defaultChatTemplate(messages: ChatMessage[]): string {
|
|
37
|
+
return `${messages.map((message) => `<|${message.role}|>${message.content}`).join('\n')}\n<|assistant|>`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface EdgeInferOptions extends ModelOptions, DownloadOptions {
|
|
41
|
+
tokenizerUrl?: string;
|
|
42
|
+
tokenizerConfig?: TokenizerConfig;
|
|
43
|
+
/**
|
|
44
|
+
* Force int64 `input_ids`/`attention_mask` tensors. By default the engine
|
|
45
|
+
* inspects ONNX input metadata and falls back to int32.
|
|
46
|
+
*/
|
|
47
|
+
int64Inputs?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface GenerateOptions {
|
|
51
|
+
/** Maximum number of new tokens to emit. Default: 64. */
|
|
52
|
+
maxNewTokens?: number;
|
|
53
|
+
/** 0 (default) is greedy decoding; >0 enables temperature sampling. */
|
|
54
|
+
temperature?: number;
|
|
55
|
+
/** Optional top-k truncation for sampling. 0 disables it. */
|
|
56
|
+
topK?: number;
|
|
57
|
+
/** Stop token. Defaults to the tokenizer's `</s>` when present. */
|
|
58
|
+
eosTokenId?: number;
|
|
59
|
+
/** Abort an in-flight stream between decode steps. */
|
|
60
|
+
signal?: AbortSignal;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface GenerationResult {
|
|
64
|
+
text: string;
|
|
65
|
+
tokenIds: number[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface GenerationEvent {
|
|
69
|
+
tokenId: number;
|
|
70
|
+
/** Best-effort decoded delta for this token. */
|
|
71
|
+
text: string;
|
|
72
|
+
index: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface ChatMessage {
|
|
76
|
+
role: 'system' | 'user' | 'assistant';
|
|
77
|
+
content: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface ChatOptions extends GenerateOptions {
|
|
81
|
+
/** Override the default role-marker template for the target model. */
|
|
82
|
+
chatTemplate?: (messages: ChatMessage[]) => string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface ChatResult extends GenerationResult {
|
|
86
|
+
prompt: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* EdgeInfer — On-Device WebGPU/WASM AI Inference Engine.
|
|
91
|
+
*
|
|
92
|
+
* Executes ONNX models directly inside the browser using WebGPU (preferred)
|
|
93
|
+
* or WebAssembly SIMD (fallback), with zero server API costs.
|
|
94
|
+
*
|
|
95
|
+
* Powered by: BitNet b1.58, Mamba-2 SSM, Matryoshka MRL research.
|
|
96
|
+
*/
|
|
97
|
+
export class EdgeInfer {
|
|
98
|
+
private session: ort.InferenceSession;
|
|
99
|
+
private tokenizer: Tokenizer | null = null;
|
|
100
|
+
private _inputNames: readonly string[];
|
|
101
|
+
private _outputNames: readonly string[];
|
|
102
|
+
private _modelSize: number;
|
|
103
|
+
private _provider: string = 'wasm';
|
|
104
|
+
private _int64Inputs: boolean;
|
|
105
|
+
|
|
106
|
+
private constructor(
|
|
107
|
+
session: ort.InferenceSession,
|
|
108
|
+
modelSize: number,
|
|
109
|
+
provider: string,
|
|
110
|
+
int64Inputs: boolean,
|
|
111
|
+
tokenizer?: Tokenizer
|
|
112
|
+
) {
|
|
113
|
+
this.session = session;
|
|
114
|
+
this._inputNames = session.inputNames;
|
|
115
|
+
this._outputNames = session.outputNames;
|
|
116
|
+
this._modelSize = modelSize;
|
|
117
|
+
this._provider = provider;
|
|
118
|
+
this._int64Inputs = int64Inputs;
|
|
119
|
+
this.tokenizer = tokenizer || null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Load a model from URL with automatic caching and optimal provider selection.
|
|
124
|
+
*/
|
|
125
|
+
static async load(modelUrl: string, options?: EdgeInferOptions): Promise<EdgeInfer> {
|
|
126
|
+
const cache = new ModelCache(options?.cacheName);
|
|
127
|
+
const buffer = await cache.getModel(modelUrl, options?.forceDownload, options);
|
|
128
|
+
return EdgeInfer.fromBuffer(buffer, options);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Create an EdgeInfer instance from a pre-loaded model buffer.
|
|
133
|
+
*/
|
|
134
|
+
static async fromBuffer(buffer: ArrayBuffer, options?: EdgeInferOptions): Promise<EdgeInfer> {
|
|
135
|
+
// `provider` is the provider that actually created the session, which is
|
|
136
|
+
// not necessarily the detected preference — see RuntimeManager.createSession.
|
|
137
|
+
checkDownloadAbort(options?.signal);
|
|
138
|
+
const { session, provider } = await RuntimeManager.createSession(
|
|
139
|
+
buffer,
|
|
140
|
+
options?.executionProviders
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
checkDownloadAbort(options?.signal);
|
|
145
|
+
// Load tokenizer if URL provided
|
|
146
|
+
let tokenizer: Tokenizer | undefined;
|
|
147
|
+
if (options?.tokenizerUrl) {
|
|
148
|
+
tokenizer = await Tokenizer.fromUrl(options.tokenizerUrl, options);
|
|
149
|
+
} else if (options?.tokenizerConfig) {
|
|
150
|
+
tokenizer = new Tokenizer(options.tokenizerConfig);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
checkDownloadAbort(options?.signal);
|
|
154
|
+
return new EdgeInfer(session, buffer.byteLength, provider, options?.int64Inputs ?? false, tokenizer);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
try { await session.release(); } catch { /* Preserve the original load failure. */ }
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Set or replace the tokenizer after model load.
|
|
163
|
+
*/
|
|
164
|
+
setTokenizer(tokenizer: Tokenizer): void {
|
|
165
|
+
this.tokenizer = tokenizer;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Low-level tensor inference.
|
|
170
|
+
* Pass raw tensor inputs and get raw tensor outputs.
|
|
171
|
+
*
|
|
172
|
+
* `shapes` optionally gives the ONNX tensor shape per input name. When an
|
|
173
|
+
* input has no explicit shape it defaults to `[1, data.length]`, which is
|
|
174
|
+
* correct for 2-D sequence inputs (`input_ids`, `attention_mask`) but wrong
|
|
175
|
+
* for anything of higher rank — a vision model declaring
|
|
176
|
+
* `pixel_values: [1,3,224,224]` is rank 4 and rejects a rank-2 tensor with
|
|
177
|
+
* "Invalid rank for input". Pass `shapes` for such models.
|
|
178
|
+
*/
|
|
179
|
+
async predict(
|
|
180
|
+
inputs: Record<string, Float32Array | Int32Array | BigInt64Array>,
|
|
181
|
+
shapes?: Record<string, readonly number[]>
|
|
182
|
+
): Promise<Record<string, Float32Array>> {
|
|
183
|
+
const results = await this.runInference(inputs, shapes);
|
|
184
|
+
const outputMap: Record<string, Float32Array> = {};
|
|
185
|
+
|
|
186
|
+
for (const [key, tensor] of Object.entries(results)) {
|
|
187
|
+
outputMap[key] = tensor.data as Float32Array;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return outputMap;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Classify text using the loaded model.
|
|
195
|
+
* Requires a tokenizer to be configured.
|
|
196
|
+
*/
|
|
197
|
+
async classify(text: string): Promise<ClassificationResult[]> {
|
|
198
|
+
this.requireTokenizer();
|
|
199
|
+
|
|
200
|
+
const encoding = this.tokenizer!.encode(text, { addSpecialTokens: true });
|
|
201
|
+
const inputs = this.textInputs(encoding);
|
|
202
|
+
|
|
203
|
+
const result = await this.predict(inputs);
|
|
204
|
+
const output = result[this._outputNames[0]];
|
|
205
|
+
|
|
206
|
+
if (!output) return [];
|
|
207
|
+
|
|
208
|
+
// Apply softmax to get probabilities
|
|
209
|
+
const probs = this.softmax(output);
|
|
210
|
+
|
|
211
|
+
return Array.from(probs).map((score, idx) => ({
|
|
212
|
+
label: `Class_${idx}`,
|
|
213
|
+
score
|
|
214
|
+
})).sort((a, b) => b.score - a.score);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Generate text embeddings for semantic search / RAG.
|
|
219
|
+
* Returns a Float32Array of embedding dimensions.
|
|
220
|
+
*/
|
|
221
|
+
async embed(text: string): Promise<Float32Array> {
|
|
222
|
+
this.requireTokenizer();
|
|
223
|
+
|
|
224
|
+
const encoding = this.tokenizer!.encode(text, { addSpecialTokens: true });
|
|
225
|
+
const inputs = this.textInputs(encoding);
|
|
226
|
+
|
|
227
|
+
const result = await this.runInference(inputs);
|
|
228
|
+
const output = result[this._outputNames[0]];
|
|
229
|
+
|
|
230
|
+
if (!output) return new Float32Array();
|
|
231
|
+
|
|
232
|
+
if (output.type !== 'float32') {
|
|
233
|
+
throw new Error('EdgeInfer: Embedding output must be a float32 tensor.');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return this.poolEmbedding(output.data as Float32Array, output.dims, encoding.attentionMask);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Generate Matryoshka-truncated embeddings at specified dimension.
|
|
241
|
+
* Supports variable-dimension MRL embeddings (64d → 256d → 768d).
|
|
242
|
+
*/
|
|
243
|
+
async embedMatryoshka(text: string, dimension: number): Promise<Float32Array> {
|
|
244
|
+
const fullEmbedding = await this.embed(text);
|
|
245
|
+
if (dimension >= fullEmbedding.length) return fullEmbedding;
|
|
246
|
+
|
|
247
|
+
// Truncate to requested dimension (MRL front-loads important info)
|
|
248
|
+
const truncated = fullEmbedding.slice(0, dimension);
|
|
249
|
+
|
|
250
|
+
// L2 normalize after truncation
|
|
251
|
+
return this.l2Normalize(truncated);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Greedy or temperature-sampled autoregressive generation.
|
|
256
|
+
*
|
|
257
|
+
* Decoder models that expose `present.*` outputs and `past_key_values.*`
|
|
258
|
+
* inputs reuse those tensors across steps (KV-cache). Models without cache
|
|
259
|
+
* inputs are re-run over the full sequence each step.
|
|
260
|
+
*/
|
|
261
|
+
async generate(prompt: string, options: GenerateOptions = {}): Promise<GenerationResult> {
|
|
262
|
+
this.requireTokenizer();
|
|
263
|
+
const tokenIds: number[] = [];
|
|
264
|
+
for await (const event of this.generateStream(prompt, options)) {
|
|
265
|
+
tokenIds.push(event.tokenId);
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
text: this.tokenizer!.decode(tokenIds),
|
|
269
|
+
tokenIds,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Stream generated tokens. The decoder loop is identical to generate(), but
|
|
275
|
+
* each token is yielded as soon as it is chosen so a UI can paint before the
|
|
276
|
+
* full completion finishes.
|
|
277
|
+
*/
|
|
278
|
+
async *generateStream(
|
|
279
|
+
prompt: string,
|
|
280
|
+
options: GenerateOptions = {}
|
|
281
|
+
): AsyncGenerator<GenerationEvent, void, unknown> {
|
|
282
|
+
this.requireTokenizer();
|
|
283
|
+
|
|
284
|
+
const maxNewTokens = Math.max(0, options.maxNewTokens ?? 64);
|
|
285
|
+
const temperature = options.temperature ?? 0;
|
|
286
|
+
const topK = options.topK ?? 0;
|
|
287
|
+
const tokenizer = this.tokenizer!;
|
|
288
|
+
const eosTokenId = options.eosTokenId
|
|
289
|
+
?? tokenizer.getSpecialTokenId('</s>')
|
|
290
|
+
?? tokenizer.getSpecialTokenId('<|endoftext|>');
|
|
291
|
+
|
|
292
|
+
const encoding = tokenizer.encode(prompt, { addSpecialTokens: true });
|
|
293
|
+
const promptIds = Array.from(encoding.inputIds);
|
|
294
|
+
const int64 = this.useInt64Inputs();
|
|
295
|
+
|
|
296
|
+
const makeIds = (values: number[]): Int32Array | BigInt64Array =>
|
|
297
|
+
int64
|
|
298
|
+
? BigInt64Array.from(values, v => BigInt(v))
|
|
299
|
+
: Int32Array.from(values);
|
|
300
|
+
|
|
301
|
+
const dtype = int64 ? 'int64' : 'int32';
|
|
302
|
+
let current = promptIds;
|
|
303
|
+
let past: Record<string, ort.Tensor> = {};
|
|
304
|
+
const generated: number[] = [];
|
|
305
|
+
|
|
306
|
+
for (let step = 0; step < maxNewTokens; step++) {
|
|
307
|
+
throwIfAborted(options.signal);
|
|
308
|
+
const totalLength = promptIds.length + generated.length;
|
|
309
|
+
const inputs: Record<string, ort.Tensor> = {
|
|
310
|
+
input_ids: new ort.Tensor(dtype, makeIds(current), [1, current.length]),
|
|
311
|
+
attention_mask: new ort.Tensor(dtype, makeIds(new Array(totalLength).fill(1)), [1, totalLength]),
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
if (this._inputNames.includes('position_ids')) {
|
|
315
|
+
const offset = totalLength - current.length;
|
|
316
|
+
inputs.position_ids = new ort.Tensor(
|
|
317
|
+
dtype,
|
|
318
|
+
makeIds(current.map((_, i) => offset + i)),
|
|
319
|
+
[1, current.length]
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
for (const [name, tensor] of Object.entries(past)) {
|
|
324
|
+
if (this._inputNames.includes(name)) inputs[name] = tensor;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const results = await this.session.run(inputs);
|
|
328
|
+
const logits = this.findLogits(results);
|
|
329
|
+
const next = this.pickNextToken(logits, temperature, topK);
|
|
330
|
+
generated.push(next);
|
|
331
|
+
yield {
|
|
332
|
+
tokenId: next,
|
|
333
|
+
text: tokenizer.decode([next]),
|
|
334
|
+
index: generated.length - 1,
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
if (eosTokenId !== undefined && next === eosTokenId) break;
|
|
338
|
+
|
|
339
|
+
const nextPast: Record<string, ort.Tensor> = {};
|
|
340
|
+
for (const outputName of this._outputNames) {
|
|
341
|
+
const inputName = EdgeInfer.pastInputName(outputName);
|
|
342
|
+
if (inputName && this._inputNames.includes(inputName) && results[outputName]) {
|
|
343
|
+
nextPast[inputName] = results[outputName];
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (Object.keys(nextPast).length > 0) {
|
|
348
|
+
past = nextPast;
|
|
349
|
+
current = [next];
|
|
350
|
+
} else {
|
|
351
|
+
past = {};
|
|
352
|
+
current = [...promptIds, ...generated];
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Stream a chat completion using a role-marker template. The default template
|
|
359
|
+
* is model-agnostic; pass `chatTemplate` to match the target tokenizer.
|
|
360
|
+
*/
|
|
361
|
+
async *chatStream(
|
|
362
|
+
messages: ChatMessage[],
|
|
363
|
+
options: ChatOptions = {}
|
|
364
|
+
): AsyncGenerator<GenerationEvent, void, unknown> {
|
|
365
|
+
this.requireTokenizer();
|
|
366
|
+
const normalized = normalizeChatMessages(messages);
|
|
367
|
+
const prompt = (options.chatTemplate ?? defaultChatTemplate)(normalized);
|
|
368
|
+
yield* this.generateStream(prompt, options);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Collect a chat completion. */
|
|
372
|
+
async chat(messages: ChatMessage[], options: ChatOptions = {}): Promise<ChatResult> {
|
|
373
|
+
this.requireTokenizer();
|
|
374
|
+
const normalized = normalizeChatMessages(messages);
|
|
375
|
+
const prompt = (options.chatTemplate ?? defaultChatTemplate)(normalized);
|
|
376
|
+
const tokenIds: number[] = [];
|
|
377
|
+
for await (const event of this.generateStream(prompt, options)) {
|
|
378
|
+
tokenIds.push(event.tokenId);
|
|
379
|
+
}
|
|
380
|
+
return {
|
|
381
|
+
text: this.tokenizer!.decode(tokenIds),
|
|
382
|
+
tokenIds,
|
|
383
|
+
prompt,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Pick the last-token logits from the first output that looks like logits. */
|
|
388
|
+
private findLogits(results: ort.InferenceSession.ReturnType): ort.Tensor {
|
|
389
|
+
const exact = results['logits'];
|
|
390
|
+
if (exact) return exact;
|
|
391
|
+
for (const name of Object.keys(results)) {
|
|
392
|
+
if (name.toLowerCase().includes('logits')) return results[name];
|
|
393
|
+
}
|
|
394
|
+
return results[this._outputNames[0]];
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Argmax (temperature 0) or temperature/top-k sampling over last-token logits. */
|
|
398
|
+
private pickNextToken(logits: ort.Tensor, temperature: number, topK: number): number {
|
|
399
|
+
const dims = logits.dims;
|
|
400
|
+
const vocabSize = dims[dims.length - 1];
|
|
401
|
+
const seqLen = dims.length >= 3 ? dims[dims.length - 2] : 1;
|
|
402
|
+
const data = logits.data as Float32Array;
|
|
403
|
+
const offset = Math.max(0, (seqLen - 1) * vocabSize);
|
|
404
|
+
|
|
405
|
+
if (temperature <= 0) {
|
|
406
|
+
let best = 0;
|
|
407
|
+
let bestValue = -Infinity;
|
|
408
|
+
for (let i = 0; i < vocabSize; i++) {
|
|
409
|
+
const value = data[offset + i];
|
|
410
|
+
if (value > bestValue) {
|
|
411
|
+
bestValue = value;
|
|
412
|
+
best = i;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return best;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const scaled = new Float32Array(vocabSize);
|
|
419
|
+
let maxLogit = -Infinity;
|
|
420
|
+
for (let i = 0; i < vocabSize; i++) {
|
|
421
|
+
scaled[i] = data[offset + i] / temperature;
|
|
422
|
+
if (scaled[i] > maxLogit) maxLogit = scaled[i];
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const candidates: Array<{ id: number; prob: number }> = [];
|
|
426
|
+
let sum = 0;
|
|
427
|
+
for (let i = 0; i < vocabSize; i++) {
|
|
428
|
+
const prob = Math.exp(scaled[i] - maxLogit);
|
|
429
|
+
candidates.push({ id: i, prob });
|
|
430
|
+
sum += prob;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (topK > 0 && topK < candidates.length) {
|
|
434
|
+
candidates.sort((a, b) => b.prob - a.prob);
|
|
435
|
+
candidates.length = topK;
|
|
436
|
+
sum = candidates.reduce((acc, c) => acc + c.prob, 0);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
let sample = Math.random() * sum;
|
|
440
|
+
for (const candidate of candidates) {
|
|
441
|
+
sample -= candidate.prob;
|
|
442
|
+
if (sample <= 0) return candidate.id;
|
|
443
|
+
}
|
|
444
|
+
return candidates[candidates.length - 1].id;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
private static pastInputName(outputName: string): string | null {
|
|
448
|
+
if (!outputName.startsWith('present')) return null;
|
|
449
|
+
return outputName.replace(/^present/, 'past_key_values');
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
private useInt64Inputs(): boolean {
|
|
453
|
+
if (this._int64Inputs) return true;
|
|
454
|
+
const metadata = (this.session as unknown as {
|
|
455
|
+
inputMetadata?: Record<string, { type?: string }>;
|
|
456
|
+
}).inputMetadata;
|
|
457
|
+
return metadata?.['input_ids']?.type === 'int64';
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Build input_ids/attention_mask tensors in the dtype the model declares. */
|
|
461
|
+
private textInputs(encoding: EncodingResult): Record<string, Int32Array | BigInt64Array> {
|
|
462
|
+
const int64 = this.useInt64Inputs();
|
|
463
|
+
const inputs: Record<string, Int32Array | BigInt64Array> = {
|
|
464
|
+
input_ids: int64 ? encoding.inputIdsInt64 : encoding.inputIds,
|
|
465
|
+
attention_mask: int64 ? encoding.attentionMaskInt64 : encoding.attentionMask,
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
if (this._inputNames.includes('token_type_ids')) {
|
|
469
|
+
inputs['token_type_ids'] = int64
|
|
470
|
+
? new BigInt64Array(encoding.inputIds.length)
|
|
471
|
+
: new Int32Array(encoding.inputIds.length);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
return inputs;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Sentiment analysis.
|
|
479
|
+
*/
|
|
480
|
+
async sentiment(text: string): Promise<{ label: string; score: number }> {
|
|
481
|
+
const results = await this.classify(text);
|
|
482
|
+
if (results.length === 0) return { label: 'unknown', score: 0 };
|
|
483
|
+
|
|
484
|
+
const best = results[0]; // Already sorted by score
|
|
485
|
+
const labelMap: Record<string, string> = {
|
|
486
|
+
'Class_0': 'negative',
|
|
487
|
+
'Class_1': 'positive',
|
|
488
|
+
'Class_2': 'neutral',
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
return {
|
|
492
|
+
label: labelMap[best.label] || best.label,
|
|
493
|
+
score: best.score
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Classify an image using a vision model.
|
|
499
|
+
*/
|
|
500
|
+
async classifyImage(imageData: ImageData, layout?: 'NCHW' | 'NHWC'): Promise<ClassificationResult[]> {
|
|
501
|
+
const effectiveLayout = layout ?? 'NCHW';
|
|
502
|
+
const tensorData = ImageProcessor.imageDataToFloat32Array(imageData, { layout: effectiveLayout });
|
|
503
|
+
|
|
504
|
+
// Find the image input name
|
|
505
|
+
const inputName = this._inputNames.find(n =>
|
|
506
|
+
n === 'input' || n === 'pixel_values' || n === 'image'
|
|
507
|
+
) || this._inputNames[0];
|
|
508
|
+
|
|
509
|
+
const result = await this.predict(
|
|
510
|
+
{ [inputName]: tensorData },
|
|
511
|
+
{ [inputName]: EdgeInfer.imageShape(imageData, effectiveLayout) }
|
|
512
|
+
);
|
|
513
|
+
const output = result[this._outputNames[0]];
|
|
514
|
+
|
|
515
|
+
if (!output) return [];
|
|
516
|
+
|
|
517
|
+
const probs = this.softmax(output);
|
|
518
|
+
|
|
519
|
+
return Array.from(probs).map((score, idx) => ({
|
|
520
|
+
label: `Class_${idx}`,
|
|
521
|
+
score
|
|
522
|
+
})).sort((a, b) => b.score - a.score).slice(0, 10);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Detect objects in an image.
|
|
527
|
+
*/
|
|
528
|
+
async detectObjects(imageData: ImageData): Promise<Detection[]> {
|
|
529
|
+
const tensorData = ImageProcessor.imageDataToFloat32Array(imageData);
|
|
530
|
+
|
|
531
|
+
const inputName = this._inputNames.find(n =>
|
|
532
|
+
n === 'input' || n === 'pixel_values' || n === 'image'
|
|
533
|
+
) || this._inputNames[0];
|
|
534
|
+
|
|
535
|
+
const result = await this.predict(
|
|
536
|
+
{ [inputName]: tensorData },
|
|
537
|
+
{ [inputName]: EdgeInfer.imageShape(imageData, 'NCHW') }
|
|
538
|
+
);
|
|
539
|
+
const output = result[this._outputNames[0]];
|
|
540
|
+
const detections: Detection[] = [];
|
|
541
|
+
|
|
542
|
+
if (output) {
|
|
543
|
+
// Standard format: [x, y, w, h, score, class_id] per detection
|
|
544
|
+
for (let i = 0; i < output.length; i += 6) {
|
|
545
|
+
const confidence = output[i + 4];
|
|
546
|
+
if (confidence > 0.5) {
|
|
547
|
+
detections.push({
|
|
548
|
+
bbox: [output[i], output[i+1], output[i+2], output[i+3]],
|
|
549
|
+
score: confidence,
|
|
550
|
+
label: `Object_${Math.round(output[i+5])}`
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
return detections.sort((a, b) => b.score - a.score);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Check GPU capabilities and recommended configuration.
|
|
560
|
+
*/
|
|
561
|
+
static async getCapabilities(): Promise<GPUCapabilities> {
|
|
562
|
+
return RuntimeManager.detectCapabilities();
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Check if WebGPU is available for accelerated inference.
|
|
567
|
+
*/
|
|
568
|
+
static async isWebGPUAvailable(): Promise<boolean> {
|
|
569
|
+
const caps = await RuntimeManager.detectCapabilities();
|
|
570
|
+
return caps.hasWebGPU;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
get inputNames(): readonly string[] { return this._inputNames; }
|
|
574
|
+
get outputNames(): readonly string[] { return this._outputNames; }
|
|
575
|
+
get modelSize(): number { return this._modelSize; }
|
|
576
|
+
get executionProvider(): string { return this._provider; }
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Release all resources (ONNX session, GPU memory).
|
|
580
|
+
*/
|
|
581
|
+
dispose(): void {
|
|
582
|
+
if (this.session) {
|
|
583
|
+
this.session.release();
|
|
584
|
+
}
|
|
585
|
+
this.tokenizer = null;
|
|
586
|
+
RuntimeManager.resetCache();
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// --- Private Helpers ---
|
|
590
|
+
|
|
591
|
+
/** Run without discarding the output tensor dimensions needed by embed(). */
|
|
592
|
+
private async runInference(
|
|
593
|
+
inputs: Record<string, Float32Array | Int32Array | BigInt64Array>,
|
|
594
|
+
shapes?: Record<string, readonly number[]>
|
|
595
|
+
): Promise<ort.InferenceSession.ReturnType> {
|
|
596
|
+
const tensorInputs: Record<string, ort.Tensor> = {};
|
|
597
|
+
|
|
598
|
+
for (const [key, data] of Object.entries(inputs)) {
|
|
599
|
+
let type: 'float32' | 'int32' | 'int64';
|
|
600
|
+
if (data instanceof Float32Array) {
|
|
601
|
+
type = 'float32';
|
|
602
|
+
} else if (data instanceof BigInt64Array) {
|
|
603
|
+
type = 'int64';
|
|
604
|
+
} else {
|
|
605
|
+
type = 'int32';
|
|
606
|
+
}
|
|
607
|
+
const shape = shapes?.[key] ?? [1, data.length];
|
|
608
|
+
tensorInputs[key] = new ort.Tensor(type, data, shape as number[]);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
return this.session.run(tensorInputs);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** ONNX tensor shape for a 3-channel image tensor in the given layout. */
|
|
615
|
+
private static imageShape(
|
|
616
|
+
imageData: { width: number; height: number },
|
|
617
|
+
layout: 'NCHW' | 'NHWC'
|
|
618
|
+
): number[] {
|
|
619
|
+
const { width, height } = imageData;
|
|
620
|
+
return layout === 'NCHW' ? [1, 3, height, width] : [1, height, width, 3];
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
private requireTokenizer(): void {
|
|
624
|
+
if (!this.tokenizer) {
|
|
625
|
+
throw new Error(
|
|
626
|
+
'EdgeInfer: No tokenizer configured. Either pass tokenizerUrl/tokenizerConfig in options, ' +
|
|
627
|
+
'or call setTokenizer() before using text-based inference methods.'
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
private softmax(logits: Float32Array): Float32Array {
|
|
633
|
+
let maxLogit = -Infinity;
|
|
634
|
+
for (let i = 0; i < logits.length; i++) {
|
|
635
|
+
if (logits[i] > maxLogit) maxLogit = logits[i];
|
|
636
|
+
}
|
|
637
|
+
const exps = new Float32Array(logits.length);
|
|
638
|
+
let sumExp = 0;
|
|
639
|
+
for (let i = 0; i < logits.length; i++) {
|
|
640
|
+
exps[i] = Math.exp(logits[i] - maxLogit);
|
|
641
|
+
sumExp += exps[i];
|
|
642
|
+
}
|
|
643
|
+
for (let i = 0; i < exps.length; i++) {
|
|
644
|
+
exps[i] /= sumExp;
|
|
645
|
+
}
|
|
646
|
+
return exps;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
private poolEmbedding(
|
|
650
|
+
embeddings: Float32Array,
|
|
651
|
+
dims: readonly number[],
|
|
652
|
+
attentionMask: Int32Array
|
|
653
|
+
): Float32Array {
|
|
654
|
+
// The shape, not divisibility by input length, distinguishes pooled output
|
|
655
|
+
// from per-token output. Both pooled forms retain every model dimension.
|
|
656
|
+
if ((dims.length === 1 || (dims.length === 2 && dims[0] === 1)) &&
|
|
657
|
+
dims[dims.length - 1] > 0) {
|
|
658
|
+
return this.l2Normalize(embeddings);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
if (dims.length !== 3 || dims[0] !== 1 || dims[1] < 1 || dims[2] < 1) {
|
|
662
|
+
throw new Error(
|
|
663
|
+
`EdgeInfer: Unsupported embedding output shape [${dims.join(', ')}]. ` +
|
|
664
|
+
'Expected [hidden], [1, hidden], or [1, sequence, hidden].'
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const [, seqLen, hiddenDim] = dims;
|
|
669
|
+
if (seqLen !== attentionMask.length) {
|
|
670
|
+
throw new Error(
|
|
671
|
+
`EdgeInfer: Embedding sequence length ${seqLen} does not match ` +
|
|
672
|
+
`attention mask length ${attentionMask.length}.`
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const pooled = new Float32Array(hiddenDim);
|
|
677
|
+
let maskSum = 0;
|
|
678
|
+
|
|
679
|
+
for (let t = 0; t < seqLen; t++) {
|
|
680
|
+
if (attentionMask[t] === 0) continue;
|
|
681
|
+
maskSum++;
|
|
682
|
+
for (let d = 0; d < hiddenDim; d++) {
|
|
683
|
+
pooled[d] += embeddings[t * hiddenDim + d];
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
if (maskSum > 0) {
|
|
688
|
+
for (let d = 0; d < hiddenDim; d++) {
|
|
689
|
+
pooled[d] /= maskSum;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
return this.l2Normalize(pooled);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
private l2Normalize(vec: Float32Array): Float32Array {
|
|
697
|
+
let norm = 0;
|
|
698
|
+
for (let i = 0; i < vec.length; i++) {
|
|
699
|
+
norm += vec[i] * vec[i];
|
|
700
|
+
}
|
|
701
|
+
norm = Math.sqrt(norm);
|
|
702
|
+
if (norm === 0) return vec;
|
|
703
|
+
|
|
704
|
+
const result = new Float32Array(vec.length);
|
|
705
|
+
for (let i = 0; i < vec.length; i++) {
|
|
706
|
+
result[i] = vec[i] / norm;
|
|
707
|
+
}
|
|
708
|
+
return result;
|
|
709
|
+
}
|
|
710
|
+
}
|