@tryhamster/gerbil 1.11.1 → 1.11.2

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.
Files changed (46) hide show
  1. package/dist/cli.mjs +7 -7
  2. package/dist/cli.mjs.map +1 -1
  3. package/dist/frameworks/express.mjs +1 -1
  4. package/dist/frameworks/fastify.mjs +1 -1
  5. package/dist/frameworks/hono.mjs +1 -1
  6. package/dist/frameworks/next.d.mts +2 -2
  7. package/dist/frameworks/next.mjs +1 -1
  8. package/dist/frameworks/trpc.mjs +1 -1
  9. package/dist/{gerbil-BpYemKEH.mjs → gerbil-CfNSSpgW.mjs} +2 -2
  10. package/dist/{gerbil-BpYemKEH.mjs.map → gerbil-CfNSSpgW.mjs.map} +1 -1
  11. package/dist/gerbil-Cq20PKtz.mjs +4 -0
  12. package/dist/{gerbil-CaAGG2VP.d.mts → gerbil-Cs8L0iVm.d.mts} +2 -2
  13. package/dist/{gerbil-CaAGG2VP.d.mts.map → gerbil-Cs8L0iVm.d.mts.map} +1 -1
  14. package/dist/gpu/hooks.d.mts +1 -1
  15. package/dist/gpu/index.d.mts +2 -2
  16. package/dist/gpu/index.mjs +3 -3
  17. package/dist/{gpu-BCfd_K38.mjs → gpu-BQ8I-VDx.mjs} +2 -2
  18. package/dist/{gpu-BCfd_K38.mjs.map → gpu-BQ8I-VDx.mjs.map} +1 -1
  19. package/dist/index-B3tjyDJI.d.mts.map +1 -1
  20. package/dist/{index-DqJMofyU.d.mts → index-t_hY-XWW.d.mts} +37 -2
  21. package/dist/index-t_hY-XWW.d.mts.map +1 -0
  22. package/dist/index.d.mts +2 -2
  23. package/dist/index.mjs +4 -4
  24. package/dist/integrations/ai-sdk.mjs +1 -1
  25. package/dist/integrations/langchain.mjs +1 -1
  26. package/dist/integrations/llamaindex.mjs +1 -1
  27. package/dist/integrations/mcp.d.mts +2 -2
  28. package/dist/integrations/mcp.mjs +4 -4
  29. package/dist/{mcp-DSpjqWZC.mjs → mcp-BITYRhF6.mjs} +3 -3
  30. package/dist/{mcp-DSpjqWZC.mjs.map → mcp-BITYRhF6.mjs.map} +1 -1
  31. package/dist/{moonshine-stt-CgDXoLFd.mjs → moonshine-stt-BI-5UmD_.mjs} +640 -550
  32. package/dist/moonshine-stt-BI-5UmD_.mjs.map +1 -0
  33. package/dist/moonshine-stt-CZ00EdnB.mjs +4 -0
  34. package/dist/{one-liner-BgNDxJSJ.mjs → one-liner-BLz8g6Gz.mjs} +2 -2
  35. package/dist/{one-liner-BgNDxJSJ.mjs.map → one-liner-BLz8g6Gz.mjs.map} +1 -1
  36. package/dist/{repl-DSK2hDzu.mjs → repl-CXCSbfES.mjs} +3 -3
  37. package/dist/skills/index.d.mts +2 -2
  38. package/dist/skills/index.mjs +3 -3
  39. package/dist/{skills-Cd75ZGew.mjs → skills-BPQb-p5b.mjs} +2 -2
  40. package/dist/{skills-Cd75ZGew.mjs.map → skills-BPQb-p5b.mjs.map} +1 -1
  41. package/dist/tune/index.mjs +1 -1
  42. package/package.json +1 -1
  43. package/dist/gerbil-BY5EW-Jk.mjs +0 -4
  44. package/dist/index-DqJMofyU.d.mts.map +0 -1
  45. package/dist/moonshine-stt-CcVt4Vdd.mjs +0 -4
  46. package/dist/moonshine-stt-CgDXoLFd.mjs.map +0 -1
@@ -8842,6 +8842,381 @@ const KERNEL_REGISTRY = {
8842
8842
  GroupNorm: groupnormSpec
8843
8843
  };
8844
8844
 
8845
+ //#endregion
8846
+ //#region src/gpu/safetensors.ts
8847
+ /**
8848
+ * Parse safetensors header from an ArrayBuffer.
8849
+ *
8850
+ * Can be called with just the header bytes (for streaming — parse header first,
8851
+ * then fetch tensor data by offset) or with the entire file.
8852
+ */
8853
+ function parseSafetensorsHeader(buffer) {
8854
+ if (buffer.byteLength < 8) throw new Error(`Buffer too small to contain safetensors header length (need 8 bytes, got ${buffer.byteLength}).`);
8855
+ const view = new DataView(buffer);
8856
+ const headerLength = Number(view.getBigUint64(0, true));
8857
+ if (headerLength > buffer.byteLength - 8) throw new Error(`Safetensors header length (${headerLength}) exceeds buffer size (${buffer.byteLength - 8}). Pass at least the first ${headerLength + 8} bytes.`);
8858
+ const headerBytes = new Uint8Array(buffer, 8, headerLength);
8859
+ const headerStr = new TextDecoder().decode(headerBytes);
8860
+ const header = JSON.parse(headerStr);
8861
+ const dataStart = 8 + headerLength;
8862
+ const entries = [];
8863
+ let metadata = null;
8864
+ for (const [name, info] of Object.entries(header)) {
8865
+ if (name === "__metadata__") {
8866
+ metadata = info;
8867
+ continue;
8868
+ }
8869
+ const { dtype, shape, data_offsets } = info;
8870
+ entries.push({
8871
+ name,
8872
+ dtype,
8873
+ shape,
8874
+ dataOffset: data_offsets[0],
8875
+ dataLength: data_offsets[1] - data_offsets[0]
8876
+ });
8877
+ }
8878
+ entries.sort((a, b) => a.dataOffset - b.dataOffset);
8879
+ return {
8880
+ headerLength,
8881
+ dataStart,
8882
+ entries,
8883
+ metadata
8884
+ };
8885
+ }
8886
+ /** Byte alignment required for each dtype. */
8887
+ function dtypeAlignment(dtype) {
8888
+ switch (dtype) {
8889
+ case "F64":
8890
+ case "I64":
8891
+ case "U64": return 8;
8892
+ case "F32":
8893
+ case "I32":
8894
+ case "U32": return 4;
8895
+ case "F16":
8896
+ case "BF16":
8897
+ case "I16":
8898
+ case "U16": return 2;
8899
+ case "I8":
8900
+ case "U8":
8901
+ case "BOOL": return 1;
8902
+ default: return 1;
8903
+ }
8904
+ }
8905
+ /**
8906
+ * Create a typed array for the given dtype.
8907
+ *
8908
+ * If `offset` is aligned to the element size, returns a zero-copy view
8909
+ * into `buffer`. Otherwise, copies the relevant slice into a new
8910
+ * properly-aligned ArrayBuffer.
8911
+ */
8912
+ function makeTypedView(buffer, offset, byteLength, dtype) {
8913
+ const aligned = offset % dtypeAlignment(dtype) === 0;
8914
+ const src = aligned ? buffer : buffer.slice(offset, offset + byteLength);
8915
+ const base = aligned ? offset : 0;
8916
+ switch (dtype) {
8917
+ case "F32": return new Float32Array(src, base, byteLength / 4);
8918
+ case "F16": return new Uint16Array(src, base, byteLength / 2);
8919
+ case "BF16": return new Uint16Array(src, base, byteLength / 2);
8920
+ case "I32": return new Int32Array(src, base, byteLength / 4);
8921
+ case "U32": return new Uint32Array(src, base, byteLength / 4);
8922
+ case "I8": return new Int8Array(src, base, byteLength);
8923
+ case "U8": return new Uint8Array(src, base, byteLength);
8924
+ case "I16": return new Int16Array(src, base, byteLength / 2);
8925
+ case "U16": return new Uint16Array(src, base, byteLength / 2);
8926
+ case "F64": return new Float64Array(src, base, byteLength / 8);
8927
+ case "I64": return new BigInt64Array(src, base, byteLength / 8);
8928
+ case "U64": return new BigUint64Array(src, base, byteLength / 8);
8929
+ case "BOOL": return new Uint8Array(src, base, byteLength);
8930
+ default: throw new Error(`Unsupported safetensors dtype: ${dtype}`);
8931
+ }
8932
+ }
8933
+ /**
8934
+ * Get a typed array view for a tensor entry from a complete safetensors buffer.
8935
+ * Zero-copy when the byte offset is properly aligned; copies otherwise.
8936
+ */
8937
+ function getTensorData(buffer, file, entry) {
8938
+ return makeTypedView(buffer, file.dataStart + entry.dataOffset, entry.dataLength, entry.dtype);
8939
+ }
8940
+ /**
8941
+ * Convert BF16 (bfloat16) data to F32.
8942
+ * BF16 is the upper 16 bits of an IEEE 754 float32, so conversion is a simple left-shift.
8943
+ */
8944
+ function bf16ToF32(bf16Bytes) {
8945
+ const bf16 = new Uint16Array(bf16Bytes.buffer, bf16Bytes.byteOffset, bf16Bytes.byteLength / 2);
8946
+ const f32 = new Float32Array(bf16.length);
8947
+ const u32 = new Uint32Array(f32.buffer);
8948
+ for (let i = 0; i < bf16.length; i++) u32[i] = bf16[i] << 16;
8949
+ return f32;
8950
+ }
8951
+ /**
8952
+ * Convert F16 (IEEE 754 half-precision) data to F32.
8953
+ */
8954
+ function f16ToF32(f16View) {
8955
+ const u16 = f16View instanceof Uint16Array ? f16View : new Uint16Array(f16View.buffer, f16View.byteOffset, f16View.byteLength / 2);
8956
+ const f32 = new Float32Array(u16.length);
8957
+ for (let i = 0; i < u16.length; i++) {
8958
+ const h = u16[i];
8959
+ const sign = h >> 15 & 1;
8960
+ const exp = h >> 10 & 31;
8961
+ const frac = h & 1023;
8962
+ if (exp === 0) f32[i] = (sign ? -1 : 1) * 2 ** -14 * (frac / 1024);
8963
+ else if (exp === 31) f32[i] = frac === 0 ? sign ? -Infinity : Infinity : NaN;
8964
+ else f32[i] = (sign ? -1 : 1) * 2 ** (exp - 15) * (1 + frac / 1024);
8965
+ }
8966
+ return f32;
8967
+ }
8968
+
8969
+ //#endregion
8970
+ //#region src/gpu/lora.ts
8971
+ function resolveAdapterBase(spec, revision) {
8972
+ if (spec.startsWith("file:")) return {
8973
+ base: spec.slice(5),
8974
+ local: true
8975
+ };
8976
+ if (spec.startsWith("http://") || spec.startsWith("https://")) return {
8977
+ base: spec,
8978
+ local: false
8979
+ };
8980
+ return {
8981
+ base: `https://huggingface.co/${spec.startsWith("hf:") ? spec.slice(3) : spec}/resolve/${revision}`,
8982
+ local: false
8983
+ };
8984
+ }
8985
+ async function readLocalFile(dir, name) {
8986
+ try {
8987
+ const fs = await import("node:fs");
8988
+ const p = (await import("node:path")).join(dir, name);
8989
+ if (!fs.existsSync(p)) return null;
8990
+ const buf = fs.readFileSync(p);
8991
+ return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
8992
+ } catch {
8993
+ return null;
8994
+ }
8995
+ }
8996
+ async function fetchFile(base, local, name, hfToken) {
8997
+ if (local) return readLocalFile(base, name);
8998
+ const headers = {};
8999
+ if (hfToken) headers.Authorization = `Bearer ${hfToken}`;
9000
+ const res = await fetch(`${base}/${name}`, { headers });
9001
+ if (!res.ok) return null;
9002
+ return res.arrayBuffer();
9003
+ }
9004
+ /** Convert a safetensors tensor slice to f32 (adapters are tiny; eager is fine). */
9005
+ function sliceToF32(buf, dataStart, entry) {
9006
+ const start = dataStart + entry.dataOffset;
9007
+ switch (entry.dtype) {
9008
+ case "F32": return new Float32Array(buf.slice(start, start + entry.dataLength));
9009
+ case "BF16": return bf16ToF32(new Uint8Array(buf, start, entry.dataLength));
9010
+ case "F16": return f16ToF32(new Uint8Array(buf, start, entry.dataLength));
9011
+ default: throw new Error(`LoRA: unsupported adapter tensor dtype ${entry.dtype}`);
9012
+ }
9013
+ }
9014
+ /**
9015
+ * Download + parse an adapter (`adapter_config.json` + `adapter_model.safetensors`).
9016
+ * Returns null when the source has no adapter (so callers can no-op cleanly).
9017
+ */
9018
+ async function fetchAdapter(spec, opts = {}) {
9019
+ const { base, local } = resolveAdapterBase(spec, opts.revision ?? "main");
9020
+ opts.onProgress?.(`Loading adapter ${spec}…`);
9021
+ const configBuf = await fetchFile(base, local, "adapter_config.json", opts.hfToken);
9022
+ if (!configBuf) throw new Error(`LoRA: no adapter_config.json at ${spec}. A flavor needs a PEFT adapter (adapter_config.json + adapter_model.safetensors).`);
9023
+ const config = JSON.parse(new TextDecoder().decode(configBuf));
9024
+ const weightsBuf = await fetchFile(base, local, "adapter_model.safetensors", opts.hfToken);
9025
+ if (!weightsBuf) throw new Error(`LoRA: no adapter_model.safetensors at ${spec} (only .safetensors adapters are supported, not legacy .bin).`);
9026
+ const file = parseSafetensorsHeader(weightsBuf);
9027
+ const tensors = /* @__PURE__ */ new Map();
9028
+ for (const entry of file.entries) {
9029
+ if (!entry.name.includes("lora_A") && !entry.name.includes("lora_B")) continue;
9030
+ tensors.set(entry.name, {
9031
+ data: sliceToF32(weightsBuf, file.dataStart, entry),
9032
+ shape: entry.shape
9033
+ });
9034
+ }
9035
+ if (tensors.size === 0) throw new Error(`LoRA: adapter at ${spec} has no lora_A/lora_B tensors.`);
9036
+ return {
9037
+ config,
9038
+ tensors,
9039
+ label: spec
9040
+ };
9041
+ }
9042
+ /**
9043
+ * Strip a PEFT tensor key down to its module path and canonicalize it to the
9044
+ * base weight key the engine uses.
9045
+ *
9046
+ * PEFT names a target like
9047
+ * base_model.model.model.layers.0.self_attn.q_proj.lora_A.weight
9048
+ * We drop the PEFT wrapper prefix + the lora_A/B suffix to get the module path
9049
+ * (…layers.0.self_attn.q_proj), append ".weight", and run the SAME key mapper
9050
+ * the base load used — so Qwen ("model." strip), Gemma, and LFM2 (out_proj→
9051
+ * o_proj) all canonicalize identically to their base tensors.
9052
+ */
9053
+ function canonicalWeightKey(peftKey, keyMapper) {
9054
+ const marker = peftKey.includes(".lora_A") ? ".lora_A" : ".lora_B";
9055
+ let stem = peftKey.slice(0, peftKey.indexOf(marker));
9056
+ stem = stem.replace(/^base_model\.model\./, "").replace(/^base_model\./, "");
9057
+ return keyMapper(`${stem.startsWith("model.") ? stem : `model.${stem}`}.weight`);
9058
+ }
9059
+ /** Longest matching suffix in a {moduleName: value} pattern map. */
9060
+ function patternLookup(patterns, peftStem) {
9061
+ if (!patterns) return void 0;
9062
+ let best;
9063
+ let bestLen = -1;
9064
+ for (const [name, val] of Object.entries(patterns)) if (peftStem.includes(name) && name.length > bestLen) {
9065
+ best = val;
9066
+ bestLen = name.length;
9067
+ }
9068
+ return best;
9069
+ }
9070
+ /**
9071
+ * Pair lora_A/lora_B tensors and resolve each to a canonical `LoRADelta`.
9072
+ * `keyMapper` must be the same mapper the base model load used.
9073
+ */
9074
+ function buildLoRADeltas(adapter, keyMapper) {
9075
+ const { config, tensors } = adapter;
9076
+ const stems = /* @__PURE__ */ new Set();
9077
+ for (const name of tensors.keys()) {
9078
+ const marker = name.includes(".lora_A") ? ".lora_A" : ".lora_B";
9079
+ stems.add(name.slice(0, name.indexOf(marker)));
9080
+ }
9081
+ const deltas = [];
9082
+ for (const stem of stems) {
9083
+ const A = tensors.get(`${stem}.lora_A.weight`) ?? tensors.get(`${stem}.lora_A`);
9084
+ const B = tensors.get(`${stem}.lora_B.weight`) ?? tensors.get(`${stem}.lora_B`);
9085
+ if (!A || !B) continue;
9086
+ const key = canonicalWeightKey(`${stem}.lora_A.weight`, keyMapper);
9087
+ if (!key) continue;
9088
+ const r = A.shape[0];
9089
+ const inFeatures = A.shape[1];
9090
+ const outFeatures = B.shape[0];
9091
+ if (B.shape[1] !== r) {
9092
+ console.warn(`[gerbil] LoRA: rank mismatch on ${stem} (A r=${r}, B r=${B.shape[1]}); skipping.`);
9093
+ continue;
9094
+ }
9095
+ const alpha = patternLookup(config.alpha_pattern, stem) ?? config.lora_alpha;
9096
+ const effR = patternLookup(config.rank_pattern, stem) ?? r;
9097
+ const scaling = config.use_rslora ? alpha / Math.sqrt(effR) : alpha / effR;
9098
+ deltas.push({
9099
+ key,
9100
+ A: A.data,
9101
+ B: B.data,
9102
+ r,
9103
+ inFeatures,
9104
+ outFeatures,
9105
+ scaling,
9106
+ fanInFanOut: config.fan_in_fan_out === true
9107
+ });
9108
+ }
9109
+ return deltas;
9110
+ }
9111
+ /**
9112
+ * Split a fused query+gate LoRA delta into two deltas (query, gate).
9113
+ *
9114
+ * Qwen3.5-style gated attention fuses the query projection and the attention
9115
+ * output-gate into ONE checkpoint tensor: `q_proj.weight` is
9116
+ * `[2·num_heads·head_dim, hidden]`, per-head interleaved — for each head the
9117
+ * first `head_dim` rows are query, the next `head_dim` rows are the gate. The
9118
+ * model loader deinterleaves this into two runtime linears (`q_proj` and
9119
+ * `attn_gate`), each `[num_heads·head_dim, hidden]`.
9120
+ *
9121
+ * A PEFT adapter trained on the FUSED checkpoint carries a single `q_proj`
9122
+ * `lora_B` of `[2·num_heads·head_dim, r]`, whose out-dim is exactly 2× either
9123
+ * split linear — so it matches neither at runtime. Because the base weight is
9124
+ * deinterleaved by a pure per-head row gather, the same gather applied to
9125
+ * `lora_B`'s rows yields the query and gate factors; `lora_A` (the input side)
9126
+ * is shared unchanged. This is mathematically identical to merging the fused
9127
+ * delta before the loader's split.
9128
+ */
9129
+ function splitFusedQGateDelta(d, qKey, gateKey, headDim) {
9130
+ const half = d.outFeatures / 2;
9131
+ const numHeads = half / headDim;
9132
+ const { r } = d;
9133
+ const rowsPerHead = headDim * r;
9134
+ const headBlock = 2 * rowsPerHead;
9135
+ const Bq = new Float32Array(half * r);
9136
+ const Bg = new Float32Array(half * r);
9137
+ for (let h = 0; h < numHeads; h++) {
9138
+ const srcQ = h * headBlock;
9139
+ const srcG = srcQ + rowsPerHead;
9140
+ const dst = h * rowsPerHead;
9141
+ Bq.set(d.B.subarray(srcQ, srcQ + rowsPerHead), dst);
9142
+ Bg.set(d.B.subarray(srcG, srcG + rowsPerHead), dst);
9143
+ }
9144
+ const shared = {
9145
+ A: d.A,
9146
+ r: d.r,
9147
+ inFeatures: d.inFeatures,
9148
+ outFeatures: half,
9149
+ scaling: d.scaling,
9150
+ fanInFanOut: d.fanInFanOut
9151
+ };
9152
+ return [{
9153
+ ...shared,
9154
+ key: qKey,
9155
+ B: Bq
9156
+ }, {
9157
+ ...shared,
9158
+ key: gateKey,
9159
+ B: Bg
9160
+ }];
9161
+ }
9162
+ /**
9163
+ * Fold every resolved delta into the store's base weights in place
9164
+ * (W += scaling · B·A). Runs after weights are loaded and before architecture
9165
+ * transforms/quantization. Returns the count of tensors merged (for logging).
9166
+ *
9167
+ * Skips — loudly — any target whose base tensor is missing, shape-mismatched, or
9168
+ * not a float pack (a pre-quantized MLX/GPTQ base can't be merged this way).
9169
+ */
9170
+ async function applyLoRAToStore(store, deltas, onProgress) {
9171
+ let merged = 0;
9172
+ let skipped = 0;
9173
+ for (const d of deltas) {
9174
+ const base = await store.get(d.key);
9175
+ if (!base) {
9176
+ skipped++;
9177
+ continue;
9178
+ }
9179
+ if (!(base.data instanceof Float32Array)) {
9180
+ console.warn(`[gerbil] LoRA: base tensor ${d.key} is not float (pre-quantized base?); skipping merge.`);
9181
+ skipped++;
9182
+ continue;
9183
+ }
9184
+ const [outF, inF] = base.shape;
9185
+ const rows = d.fanInFanOut ? inF : outF;
9186
+ const cols = d.fanInFanOut ? outF : inF;
9187
+ if (rows !== d.outFeatures || cols !== d.inFeatures) {
9188
+ console.warn(`[gerbil] LoRA: shape mismatch on ${d.key} (base ${base.shape}, delta ${d.outFeatures}×${d.inFeatures}); skipping.`);
9189
+ skipped++;
9190
+ continue;
9191
+ }
9192
+ const W = new Float32Array(base.data);
9193
+ const { A, B, r, scaling, fanInFanOut } = d;
9194
+ for (let o = 0; o < d.outFeatures; o++) {
9195
+ const bRow = o * r;
9196
+ for (let k = 0; k < r; k++) {
9197
+ const bScaled = scaling * B[bRow + k];
9198
+ if (bScaled === 0) continue;
9199
+ const aRow = k * d.inFeatures;
9200
+ if (fanInFanOut) for (let i = 0; i < d.inFeatures; i++) W[i * outF + o] += bScaled * A[aRow + i];
9201
+ else {
9202
+ const wRow = o * inF;
9203
+ for (let i = 0; i < d.inFeatures; i++) W[wRow + i] += bScaled * A[aRow + i];
9204
+ }
9205
+ }
9206
+ }
9207
+ await store.set(d.key, {
9208
+ data: W,
9209
+ shape: base.shape
9210
+ });
9211
+ merged++;
9212
+ }
9213
+ onProgress?.(`Adapter merged into ${merged} tensors${skipped ? ` (${skipped} skipped)` : ""}.`);
9214
+ return {
9215
+ merged,
9216
+ skipped
9217
+ };
9218
+ }
9219
+
8845
9220
  //#endregion
8846
9221
  //#region src/gpu/executor.ts
8847
9222
  const MAP_MODE_READ$1 = 1;
@@ -9167,23 +9542,62 @@ var Executor = class Executor {
9167
9542
  * the base weight buffers and bind groups are never touched or re-uploaded.
9168
9543
  * Pass an empty list (or call {@link clearRuntimeLoRA}) to drop the overlay.
9169
9544
  */
9545
+ /** Find the base linear (MatMulInt4 / MatMul) node whose weight is `key`. */
9546
+ findBaseLinear(key) {
9547
+ return this.graph.nodes.find((n) => {
9548
+ if (n.opType !== "MatMulInt4" && n.opType !== "MatMul") return false;
9549
+ const w = n.inputs[1];
9550
+ if (!w) return false;
9551
+ return (w.endsWith(".q") ? w.slice(0, -2) : w) === key;
9552
+ });
9553
+ }
9554
+ /**
9555
+ * Expand any fused query+gate delta into separate `q_proj` + `attn_gate`
9556
+ * deltas so it can splice onto a deinterleaved runtime graph.
9557
+ *
9558
+ * Qwen3.5-style gated attention ships `q_proj.weight` fused as
9559
+ * `[2·num_heads·head_dim, hidden]` (per-head: query rows then gate rows); the
9560
+ * model loader splits it into two runtime linears (`q_proj`, `attn_gate`). An
9561
+ * adapter trained on the fused checkpoint carries a single `q_proj` delta whose
9562
+ * out-dim is 2× either split node, so it matches neither. When BOTH split
9563
+ * linears exist and the delta is exactly their combined width, split it via the
9564
+ * same per-head row deinterleave the loader uses; otherwise the delta passes
9565
+ * through untouched (e.g. a base that never split the projection).
9566
+ */
9567
+ expandFusedQGateDeltas(deltas) {
9568
+ const out = [];
9569
+ for (const d of deltas) {
9570
+ const m = /^(layers\.\d+\.self_attn)\.q_proj\.weight$/.exec(d.key);
9571
+ const gateKey = m ? `${m[1]}.attn_gate.weight` : null;
9572
+ const qNode = m ? this.findBaseLinear(d.key) : void 0;
9573
+ const gateNode = gateKey ? this.findBaseLinear(gateKey) : void 0;
9574
+ if (!(m && gateKey && qNode && gateNode)) {
9575
+ out.push(d);
9576
+ continue;
9577
+ }
9578
+ const nQ = qNode.attributes.N;
9579
+ const nGate = gateNode.attributes.N;
9580
+ const headDim = this.graph.config.head_dim;
9581
+ if (nQ !== nGate || d.outFeatures !== nQ + nGate || !headDim || nQ % headDim !== 0) {
9582
+ out.push(d);
9583
+ continue;
9584
+ }
9585
+ out.push(...splitFusedQGateDelta(d, d.key, gateKey, headDim));
9586
+ }
9587
+ return out;
9588
+ }
9170
9589
  applyRuntimeLoRA(deltas) {
9171
9590
  this.clearRuntimeLoRA();
9172
9591
  const overlay = /* @__PURE__ */ new Map();
9173
9592
  let applied = 0;
9174
9593
  let skipped = 0;
9175
- for (const d of deltas) {
9594
+ for (const d of this.expandFusedQGateDeltas(deltas)) {
9176
9595
  if (d.fanInFanOut) {
9177
9596
  console.warn(`[executor] runtime LoRA: fan_in_fan_out target ${d.key} unsupported; skipping.`);
9178
9597
  skipped++;
9179
9598
  continue;
9180
9599
  }
9181
- const node = this.graph.nodes.find((n) => {
9182
- if (n.opType !== "MatMulInt4" && n.opType !== "MatMul") return false;
9183
- const w = n.inputs[1];
9184
- if (!w) return false;
9185
- return (w.endsWith(".q") ? w.slice(0, -2) : w) === d.key;
9186
- });
9600
+ const node = this.findBaseLinear(d.key);
9187
9601
  if (!node) {
9188
9602
  skipped++;
9189
9603
  continue;
@@ -11043,586 +11457,262 @@ var Executor = class Executor {
11043
11457
  }
11044
11458
  return entries;
11045
11459
  }
11046
- /** Lazily allocate a scratch storage buffer at least `minBytes` large. */
11047
- getBindingScratchBuffer(minBytes) {
11048
- if (!this.bindingScratchBuffer || this.bindingScratchBuffer.size < minBytes) this.bindingScratchBuffer = createStorageBuffer(this.ctx, "binding_scratch", Math.max(minBytes, 256));
11049
- return this.bindingScratchBuffer;
11050
- }
11051
- };
11052
-
11053
- //#endregion
11054
- //#region src/gpu/gptq-adapter.ts
11055
- /**
11056
- * Repack GPTQ tensors into Gerbil's flat INT4 format.
11057
- *
11058
- * This is pure data reshuffling — no quantization math. The 4-bit values,
11059
- * scales, and zero points are preserved exactly.
11060
- */
11061
- function repackGPTQ(gptq) {
11062
- const { qweight, scales: gptqScales, qzeros, K, N, groupSize } = gptq;
11063
- const totalElements = N * K;
11064
- const groupsPerCol = Math.ceil(K / groupSize);
11065
- const totalGroups = N * groupsPerCol;
11066
- const packed = new Uint32Array(Math.ceil(totalElements / 8));
11067
- const scales = new Float32Array(totalGroups);
11068
- const zeros = new Float32Array(totalGroups);
11069
- for (let k = 0; k < K; k++) {
11070
- const gptqRow = k >>> 3;
11071
- const gptqNibblePos = k & 7;
11072
- const gptqRowBase = gptqRow * N;
11073
- for (let col = 0; col < N; col++) {
11074
- const nibble = qweight[gptqRowBase + col] >>> gptqNibblePos * 4 & 15;
11075
- const flatIdx = col * K + k;
11076
- const packedIdx = flatIdx >>> 3;
11077
- const nibblePos = flatIdx & 7;
11078
- packed[packedIdx] |= nibble << nibblePos * 4;
11079
- }
11080
- }
11081
- for (let g = 0; g < groupsPerCol; g++) {
11082
- const srcRow = g * N;
11083
- for (let col = 0; col < N; col++) scales[col * groupsPerCol + g] = gptqScales[srcRow + col];
11084
- }
11085
- const zerosNDiv8 = N >>> 3;
11086
- for (let g = 0; g < groupsPerCol; g++) {
11087
- const srcRow = g * zerosNDiv8;
11088
- for (let col = 0; col < N; col++) {
11089
- const zeroVal = qzeros[srcRow + (col >>> 3)] >>> (col & 7) * 4 & 15;
11090
- zeros[col * groupsPerCol + g] = zeroVal + 1;
11091
- }
11092
- }
11093
- return {
11094
- packed,
11095
- scales,
11096
- zeros
11097
- };
11098
- }
11099
-
11100
- //#endregion
11101
- //#region src/gpu/idb-cache.ts
11102
- /**
11103
- * IndexedDB-backed blob cache for model weights.
11104
- *
11105
- * Why IndexedDB and not the Cache API / OPFS we hand-rolled before:
11106
- * - The Cache API ignores URL fragments in `match()`, which silently collapsed
11107
- * our per-tensor `${url}#t:start-len` keys onto one entry → wrong bytes → the
11108
- * `RRRR` garbage and `wt.data` crashes. IDB keys on the exact string.
11109
- * - OPFS via Worker (`createSyncAccessHandle`) is finicky on iOS Safari (OOM /
11110
- * transient failures) and added a lot of moving parts.
11111
- * IndexedDB is the boring, proven large-binary store every browser supports. It
11112
- * keys on arbitrary strings (no fragment footgun), stores ArrayBuffers directly,
11113
- * and is durable (subject to the same per-origin quota as Cache/OPFS — install to
11114
- * Home Screen lifts that ceiling; IDB doesn't change it, it just makes caching
11115
- * actually CORRECT).
11116
- *
11117
- * Inert on Node (no indexedDB) — every export resolves to a miss/no-op so the
11118
- * heap load path is unchanged off the browser.
11119
- */
11120
- const DB_NAME = "gerbil-weights-v1";
11121
- const STORE = "tensors";
11122
- function idbAvailable() {
11123
- return typeof indexedDB !== "undefined";
11124
- }
11125
- /**
11126
- * Ask the browser to mark this origin's storage as persistent (exempt from
11127
- * eviction). On iOS Safari a plain tab's storage is evicted under pressure unless
11128
- * persistence is granted (effectively only inside an installed PWA), which is the
11129
- * primary reason a 404 MB model re-downloads on some reloads. We request it
11130
- * up-front on the FIRST cache touch (read OR write) — not only on the first write
11131
- * — so a warm-cache reload re-asserts the request before the browser has a chance
11132
- * to evict mid-session. Best-effort and memoized: a single, idempotent call per
11133
- * page that never throws and never blocks the load. Returns the resulting
11134
- * persisted state (false when unsupported / not granted).
11135
- */
11136
- let _persistRequested$1 = null;
11137
- function idbRequestPersistence() {
11138
- if (_persistRequested$1) return _persistRequested$1;
11139
- _persistRequested$1 = (async () => {
11140
- try {
11141
- if (typeof navigator === "undefined") return false;
11142
- const storage = navigator?.storage;
11143
- if (!storage) return false;
11144
- if (await storage.persisted?.() === true) return true;
11145
- return await storage.persist?.() ?? false;
11146
- } catch {
11147
- return false;
11148
- }
11149
- })();
11150
- return _persistRequested$1;
11151
- }
11152
- let _dbPromise = null;
11153
- /**
11154
- * Per-op watchdog. iOS Safari can silently WEDGE an IndexedDB transaction after
11155
- * a large (~400 MB) write session or under quota pressure — it fires NONE of
11156
- * success/error/abort, so an awaiting caller hangs forever. That presents as the
11157
- * load freezing at "reading model.safetensors header" (the first storage op
11158
- * after that label) and is the #1 cause of the on-device load hang. Every op
11159
- * races this timeout and degrades to its fallback (read → miss → network; write
11160
- * → false → heap) instead of hanging.
11161
- */
11162
- const IDB_OP_TIMEOUT_MS = 12e3;
11163
- /**
11164
- * Drop the memoized connection (and close it) so the NEXT op re-opens a fresh
11165
- * one. A wedged/poisoned connection otherwise persists for the page's lifetime —
11166
- * an in-page retry (no reload) reuses it and hangs again. Closing also aborts
11167
- * the wedged transaction.
11168
- */
11169
- function poisonConnection(db) {
11170
- _dbPromise = null;
11171
- try {
11172
- db?.close();
11173
- } catch {}
11174
- }
11175
- function openDB() {
11176
- if (_dbPromise) return _dbPromise;
11177
- _dbPromise = new Promise((resolve) => {
11178
- if (!idbAvailable()) {
11179
- resolve(null);
11180
- return;
11181
- }
11182
- idbRequestPersistence();
11183
- try {
11184
- const req = indexedDB.open(DB_NAME, 1);
11185
- req.onupgradeneeded = () => {
11186
- const db = req.result;
11187
- if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE);
11188
- };
11189
- req.onsuccess = () => {
11190
- const db = req.result;
11191
- db.onversionchange = () => poisonConnection(db);
11192
- db.onclose = () => {
11193
- _dbPromise = null;
11194
- };
11195
- resolve(db);
11196
- };
11197
- req.onerror = () => resolve(null);
11198
- req.onblocked = () => resolve(null);
11199
- } catch {
11200
- resolve(null);
11201
- }
11202
- });
11203
- return _dbPromise;
11204
- }
11205
- /** A single transaction operation, promisified. Resolves the fallback on any
11206
- * error OR if the op wedges past IDB_OP_TIMEOUT_MS (and then poisons the
11207
- * connection so the next op re-opens fresh). */
11208
- function tx(mode, run, fallback) {
11209
- return openDB().then((db) => new Promise((resolve) => {
11210
- if (!db) {
11211
- resolve(fallback);
11212
- return;
11213
- }
11214
- let settled = false;
11215
- const finish = (value, poisoned) => {
11216
- if (settled) return;
11217
- settled = true;
11218
- clearTimeout(timer);
11219
- if (poisoned) poisonConnection(db);
11220
- resolve(value);
11221
- };
11222
- const timer = setTimeout(() => finish(fallback, true), IDB_OP_TIMEOUT_MS);
11223
- try {
11224
- const t = db.transaction(STORE, mode);
11225
- const req = run(t.objectStore(STORE));
11226
- req.onsuccess = () => finish(req.result ?? fallback, false);
11227
- req.onerror = () => finish(fallback, true);
11228
- t.onabort = () => finish(fallback, true);
11229
- } catch {
11230
- finish(fallback, true);
11231
- }
11232
- }));
11233
- }
11234
- /** Read a key's bytes, or null on miss / no IDB / any error. */
11235
- async function idbGet(key) {
11236
- const v = await tx("readonly", (s) => s.get(key), null);
11237
- return v instanceof ArrayBuffer ? v : null;
11238
- }
11239
- /**
11240
- * Size-verified read. Returns the bytes ONLY if the cached entry is at least
11241
- * `minBytes` long; otherwise treats it as a corrupt/partial entry: deletes the
11242
- * poisoned key (so a future load re-downloads it instead of trusting it again)
11243
- * and returns null. This is the integrity gate that turns a half-written or
11244
- * partially-evicted tensor into a clean cache-miss rather than wrong bytes (or a
11245
- * later "No GPU buffer for tensor …" crash when get() returns undefined). Pass
11246
- * minBytes <= 0 to skip the size check (behaves like idbGet).
11247
- */
11248
- async function idbGetVerified(key, minBytes) {
11249
- const buf = await idbGet(key);
11250
- if (!buf) return null;
11251
- if (minBytes > 0 && buf.byteLength < minBytes) {
11252
- await idbDelete(key);
11253
- return null;
11254
- }
11255
- return buf;
11256
- }
11257
- /**
11258
- * All cached keys, as a Set, in ONE transaction. Probing presence per-tensor
11259
- * (hundreds of parallel getKey transactions) storms Safari's IndexedDB and hangs
11260
- * the load; getAllKeys() does it in a single transaction so callers can check
11261
- * membership in memory. Empty set on miss / no IDB / error.
11262
- */
11263
- async function idbAllKeys() {
11264
- const keys = await tx("readonly", (s) => s.getAllKeys(), []);
11265
- return new Set((keys ?? []).map((k) => String(k)));
11266
- }
11267
- /** Write a key's bytes. Returns true on success, false on quota/error (caller
11268
- * falls back to network — never throws). */
11269
- async function idbPut(key, buf) {
11270
- return await tx("readwrite", (s) => s.put(buf, key), null) !== null;
11271
- }
11272
- /**
11273
- * Atomic, verified write. Writes the bytes, then reads back the stored length and
11274
- * confirms it matches what we wrote. iOS Safari can let a put() transaction
11275
- * "succeed" yet store a truncated value under quota pressure (or wedge the
11276
- * transaction, which the watchdog turns into a fallback) — leaving a present-but-
11277
- * short entry that the size-blind presence probe would later trust. On any
11278
- * mismatch we DELETE the key so it is never read back as a valid (wrong-size)
11279
- * tensor, and return false so the caller keeps the network bytes. Returns true
11280
- * only when the full-length value is provably persisted.
11281
- */
11282
- async function idbPutVerified(key, buf) {
11283
- const expected = buf.byteLength;
11284
- if (!await idbPut(key, buf)) {
11285
- await idbDelete(key);
11286
- return false;
11287
- }
11288
- const back = await idbGet(key);
11289
- if (!back || back.byteLength !== expected) {
11290
- await idbDelete(key);
11291
- return false;
11460
+ /** Lazily allocate a scratch storage buffer at least `minBytes` large. */
11461
+ getBindingScratchBuffer(minBytes) {
11462
+ if (!this.bindingScratchBuffer || this.bindingScratchBuffer.size < minBytes) this.bindingScratchBuffer = createStorageBuffer(this.ctx, "binding_scratch", Math.max(minBytes, 256));
11463
+ return this.bindingScratchBuffer;
11292
11464
  }
11293
- return true;
11294
- }
11295
- /** Delete a key (best-effort). */
11296
- async function idbDelete(key) {
11297
- await tx("readwrite", (s) => s.delete(key), null);
11298
- }
11299
- /** True when IndexedDB is usable in this environment (browser, not Node). */
11300
- function idbCacheAvailable() {
11301
- return idbAvailable();
11302
- }
11465
+ };
11303
11466
 
11304
11467
  //#endregion
11305
- //#region src/gpu/safetensors.ts
11468
+ //#region src/gpu/gptq-adapter.ts
11306
11469
  /**
11307
- * Parse safetensors header from an ArrayBuffer.
11470
+ * Repack GPTQ tensors into Gerbil's flat INT4 format.
11308
11471
  *
11309
- * Can be called with just the header bytes (for streaming parse header first,
11310
- * then fetch tensor data by offset) or with the entire file.
11472
+ * This is pure data reshuffling no quantization math. The 4-bit values,
11473
+ * scales, and zero points are preserved exactly.
11311
11474
  */
11312
- function parseSafetensorsHeader(buffer) {
11313
- if (buffer.byteLength < 8) throw new Error(`Buffer too small to contain safetensors header length (need 8 bytes, got ${buffer.byteLength}).`);
11314
- const view = new DataView(buffer);
11315
- const headerLength = Number(view.getBigUint64(0, true));
11316
- if (headerLength > buffer.byteLength - 8) throw new Error(`Safetensors header length (${headerLength}) exceeds buffer size (${buffer.byteLength - 8}). Pass at least the first ${headerLength + 8} bytes.`);
11317
- const headerBytes = new Uint8Array(buffer, 8, headerLength);
11318
- const headerStr = new TextDecoder().decode(headerBytes);
11319
- const header = JSON.parse(headerStr);
11320
- const dataStart = 8 + headerLength;
11321
- const entries = [];
11322
- let metadata = null;
11323
- for (const [name, info] of Object.entries(header)) {
11324
- if (name === "__metadata__") {
11325
- metadata = info;
11326
- continue;
11475
+ function repackGPTQ(gptq) {
11476
+ const { qweight, scales: gptqScales, qzeros, K, N, groupSize } = gptq;
11477
+ const totalElements = N * K;
11478
+ const groupsPerCol = Math.ceil(K / groupSize);
11479
+ const totalGroups = N * groupsPerCol;
11480
+ const packed = new Uint32Array(Math.ceil(totalElements / 8));
11481
+ const scales = new Float32Array(totalGroups);
11482
+ const zeros = new Float32Array(totalGroups);
11483
+ for (let k = 0; k < K; k++) {
11484
+ const gptqRow = k >>> 3;
11485
+ const gptqNibblePos = k & 7;
11486
+ const gptqRowBase = gptqRow * N;
11487
+ for (let col = 0; col < N; col++) {
11488
+ const nibble = qweight[gptqRowBase + col] >>> gptqNibblePos * 4 & 15;
11489
+ const flatIdx = col * K + k;
11490
+ const packedIdx = flatIdx >>> 3;
11491
+ const nibblePos = flatIdx & 7;
11492
+ packed[packedIdx] |= nibble << nibblePos * 4;
11493
+ }
11494
+ }
11495
+ for (let g = 0; g < groupsPerCol; g++) {
11496
+ const srcRow = g * N;
11497
+ for (let col = 0; col < N; col++) scales[col * groupsPerCol + g] = gptqScales[srcRow + col];
11498
+ }
11499
+ const zerosNDiv8 = N >>> 3;
11500
+ for (let g = 0; g < groupsPerCol; g++) {
11501
+ const srcRow = g * zerosNDiv8;
11502
+ for (let col = 0; col < N; col++) {
11503
+ const zeroVal = qzeros[srcRow + (col >>> 3)] >>> (col & 7) * 4 & 15;
11504
+ zeros[col * groupsPerCol + g] = zeroVal + 1;
11327
11505
  }
11328
- const { dtype, shape, data_offsets } = info;
11329
- entries.push({
11330
- name,
11331
- dtype,
11332
- shape,
11333
- dataOffset: data_offsets[0],
11334
- dataLength: data_offsets[1] - data_offsets[0]
11335
- });
11336
11506
  }
11337
- entries.sort((a, b) => a.dataOffset - b.dataOffset);
11338
11507
  return {
11339
- headerLength,
11340
- dataStart,
11341
- entries,
11342
- metadata
11508
+ packed,
11509
+ scales,
11510
+ zeros
11343
11511
  };
11344
11512
  }
11345
- /** Byte alignment required for each dtype. */
11346
- function dtypeAlignment(dtype) {
11347
- switch (dtype) {
11348
- case "F64":
11349
- case "I64":
11350
- case "U64": return 8;
11351
- case "F32":
11352
- case "I32":
11353
- case "U32": return 4;
11354
- case "F16":
11355
- case "BF16":
11356
- case "I16":
11357
- case "U16": return 2;
11358
- case "I8":
11359
- case "U8":
11360
- case "BOOL": return 1;
11361
- default: return 1;
11362
- }
11363
- }
11513
+
11514
+ //#endregion
11515
+ //#region src/gpu/idb-cache.ts
11364
11516
  /**
11365
- * Create a typed array for the given dtype.
11517
+ * IndexedDB-backed blob cache for model weights.
11366
11518
  *
11367
- * If `offset` is aligned to the element size, returns a zero-copy view
11368
- * into `buffer`. Otherwise, copies the relevant slice into a new
11369
- * properly-aligned ArrayBuffer.
11519
+ * Why IndexedDB and not the Cache API / OPFS we hand-rolled before:
11520
+ * - The Cache API ignores URL fragments in `match()`, which silently collapsed
11521
+ * our per-tensor `${url}#t:start-len` keys onto one entry → wrong bytes → the
11522
+ * `RRRR` garbage and `wt.data` crashes. IDB keys on the exact string.
11523
+ * - OPFS via Worker (`createSyncAccessHandle`) is finicky on iOS Safari (OOM /
11524
+ * transient failures) and added a lot of moving parts.
11525
+ * IndexedDB is the boring, proven large-binary store every browser supports. It
11526
+ * keys on arbitrary strings (no fragment footgun), stores ArrayBuffers directly,
11527
+ * and is durable (subject to the same per-origin quota as Cache/OPFS — install to
11528
+ * Home Screen lifts that ceiling; IDB doesn't change it, it just makes caching
11529
+ * actually CORRECT).
11530
+ *
11531
+ * Inert on Node (no indexedDB) — every export resolves to a miss/no-op so the
11532
+ * heap load path is unchanged off the browser.
11370
11533
  */
11371
- function makeTypedView(buffer, offset, byteLength, dtype) {
11372
- const aligned = offset % dtypeAlignment(dtype) === 0;
11373
- const src = aligned ? buffer : buffer.slice(offset, offset + byteLength);
11374
- const base = aligned ? offset : 0;
11375
- switch (dtype) {
11376
- case "F32": return new Float32Array(src, base, byteLength / 4);
11377
- case "F16": return new Uint16Array(src, base, byteLength / 2);
11378
- case "BF16": return new Uint16Array(src, base, byteLength / 2);
11379
- case "I32": return new Int32Array(src, base, byteLength / 4);
11380
- case "U32": return new Uint32Array(src, base, byteLength / 4);
11381
- case "I8": return new Int8Array(src, base, byteLength);
11382
- case "U8": return new Uint8Array(src, base, byteLength);
11383
- case "I16": return new Int16Array(src, base, byteLength / 2);
11384
- case "U16": return new Uint16Array(src, base, byteLength / 2);
11385
- case "F64": return new Float64Array(src, base, byteLength / 8);
11386
- case "I64": return new BigInt64Array(src, base, byteLength / 8);
11387
- case "U64": return new BigUint64Array(src, base, byteLength / 8);
11388
- case "BOOL": return new Uint8Array(src, base, byteLength);
11389
- default: throw new Error(`Unsupported safetensors dtype: ${dtype}`);
11390
- }
11534
+ const DB_NAME = "gerbil-weights-v1";
11535
+ const STORE = "tensors";
11536
+ function idbAvailable() {
11537
+ return typeof indexedDB !== "undefined";
11391
11538
  }
11392
11539
  /**
11393
- * Get a typed array view for a tensor entry from a complete safetensors buffer.
11394
- * Zero-copy when the byte offset is properly aligned; copies otherwise.
11540
+ * Ask the browser to mark this origin's storage as persistent (exempt from
11541
+ * eviction). On iOS Safari a plain tab's storage is evicted under pressure unless
11542
+ * persistence is granted (effectively only inside an installed PWA), which is the
11543
+ * primary reason a 404 MB model re-downloads on some reloads. We request it
11544
+ * up-front on the FIRST cache touch (read OR write) — not only on the first write
11545
+ * — so a warm-cache reload re-asserts the request before the browser has a chance
11546
+ * to evict mid-session. Best-effort and memoized: a single, idempotent call per
11547
+ * page that never throws and never blocks the load. Returns the resulting
11548
+ * persisted state (false when unsupported / not granted).
11395
11549
  */
11396
- function getTensorData(buffer, file, entry) {
11397
- return makeTypedView(buffer, file.dataStart + entry.dataOffset, entry.dataLength, entry.dtype);
11550
+ let _persistRequested$1 = null;
11551
+ function idbRequestPersistence() {
11552
+ if (_persistRequested$1) return _persistRequested$1;
11553
+ _persistRequested$1 = (async () => {
11554
+ try {
11555
+ if (typeof navigator === "undefined") return false;
11556
+ const storage = navigator?.storage;
11557
+ if (!storage) return false;
11558
+ if (await storage.persisted?.() === true) return true;
11559
+ return await storage.persist?.() ?? false;
11560
+ } catch {
11561
+ return false;
11562
+ }
11563
+ })();
11564
+ return _persistRequested$1;
11398
11565
  }
11566
+ let _dbPromise = null;
11399
11567
  /**
11400
- * Convert BF16 (bfloat16) data to F32.
11401
- * BF16 is the upper 16 bits of an IEEE 754 float32, so conversion is a simple left-shift.
11568
+ * Per-op watchdog. iOS Safari can silently WEDGE an IndexedDB transaction after
11569
+ * a large (~400 MB) write session or under quota pressure it fires NONE of
11570
+ * success/error/abort, so an awaiting caller hangs forever. That presents as the
11571
+ * load freezing at "reading model.safetensors header" (the first storage op
11572
+ * after that label) and is the #1 cause of the on-device load hang. Every op
11573
+ * races this timeout and degrades to its fallback (read → miss → network; write
11574
+ * → false → heap) instead of hanging.
11402
11575
  */
11403
- function bf16ToF32(bf16Bytes) {
11404
- const bf16 = new Uint16Array(bf16Bytes.buffer, bf16Bytes.byteOffset, bf16Bytes.byteLength / 2);
11405
- const f32 = new Float32Array(bf16.length);
11406
- const u32 = new Uint32Array(f32.buffer);
11407
- for (let i = 0; i < bf16.length; i++) u32[i] = bf16[i] << 16;
11408
- return f32;
11409
- }
11576
+ const IDB_OP_TIMEOUT_MS = 12e3;
11410
11577
  /**
11411
- * Convert F16 (IEEE 754 half-precision) data to F32.
11578
+ * Drop the memoized connection (and close it) so the NEXT op re-opens a fresh
11579
+ * one. A wedged/poisoned connection otherwise persists for the page's lifetime —
11580
+ * an in-page retry (no reload) reuses it and hangs again. Closing also aborts
11581
+ * the wedged transaction.
11412
11582
  */
11413
- function f16ToF32(f16View) {
11414
- const u16 = f16View instanceof Uint16Array ? f16View : new Uint16Array(f16View.buffer, f16View.byteOffset, f16View.byteLength / 2);
11415
- const f32 = new Float32Array(u16.length);
11416
- for (let i = 0; i < u16.length; i++) {
11417
- const h = u16[i];
11418
- const sign = h >> 15 & 1;
11419
- const exp = h >> 10 & 31;
11420
- const frac = h & 1023;
11421
- if (exp === 0) f32[i] = (sign ? -1 : 1) * 2 ** -14 * (frac / 1024);
11422
- else if (exp === 31) f32[i] = frac === 0 ? sign ? -Infinity : Infinity : NaN;
11423
- else f32[i] = (sign ? -1 : 1) * 2 ** (exp - 15) * (1 + frac / 1024);
11424
- }
11425
- return f32;
11426
- }
11427
-
11428
- //#endregion
11429
- //#region src/gpu/lora.ts
11430
- function resolveAdapterBase(spec, revision) {
11431
- if (spec.startsWith("file:")) return {
11432
- base: spec.slice(5),
11433
- local: true
11434
- };
11435
- if (spec.startsWith("http://") || spec.startsWith("https://")) return {
11436
- base: spec,
11437
- local: false
11438
- };
11439
- return {
11440
- base: `https://huggingface.co/${spec.startsWith("hf:") ? spec.slice(3) : spec}/resolve/${revision}`,
11441
- local: false
11442
- };
11443
- }
11444
- async function readLocalFile(dir, name) {
11583
+ function poisonConnection(db) {
11584
+ _dbPromise = null;
11445
11585
  try {
11446
- const fs = await import("node:fs");
11447
- const p = (await import("node:path")).join(dir, name);
11448
- if (!fs.existsSync(p)) return null;
11449
- const buf = fs.readFileSync(p);
11450
- return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
11451
- } catch {
11452
- return null;
11453
- }
11586
+ db?.close();
11587
+ } catch {}
11588
+ }
11589
+ function openDB() {
11590
+ if (_dbPromise) return _dbPromise;
11591
+ _dbPromise = new Promise((resolve) => {
11592
+ if (!idbAvailable()) {
11593
+ resolve(null);
11594
+ return;
11595
+ }
11596
+ idbRequestPersistence();
11597
+ try {
11598
+ const req = indexedDB.open(DB_NAME, 1);
11599
+ req.onupgradeneeded = () => {
11600
+ const db = req.result;
11601
+ if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE);
11602
+ };
11603
+ req.onsuccess = () => {
11604
+ const db = req.result;
11605
+ db.onversionchange = () => poisonConnection(db);
11606
+ db.onclose = () => {
11607
+ _dbPromise = null;
11608
+ };
11609
+ resolve(db);
11610
+ };
11611
+ req.onerror = () => resolve(null);
11612
+ req.onblocked = () => resolve(null);
11613
+ } catch {
11614
+ resolve(null);
11615
+ }
11616
+ });
11617
+ return _dbPromise;
11454
11618
  }
11455
- async function fetchFile(base, local, name, hfToken) {
11456
- if (local) return readLocalFile(base, name);
11457
- const headers = {};
11458
- if (hfToken) headers.Authorization = `Bearer ${hfToken}`;
11459
- const res = await fetch(`${base}/${name}`, { headers });
11460
- if (!res.ok) return null;
11461
- return res.arrayBuffer();
11619
+ /** A single transaction operation, promisified. Resolves the fallback on any
11620
+ * error OR if the op wedges past IDB_OP_TIMEOUT_MS (and then poisons the
11621
+ * connection so the next op re-opens fresh). */
11622
+ function tx(mode, run, fallback) {
11623
+ return openDB().then((db) => new Promise((resolve) => {
11624
+ if (!db) {
11625
+ resolve(fallback);
11626
+ return;
11627
+ }
11628
+ let settled = false;
11629
+ const finish = (value, poisoned) => {
11630
+ if (settled) return;
11631
+ settled = true;
11632
+ clearTimeout(timer);
11633
+ if (poisoned) poisonConnection(db);
11634
+ resolve(value);
11635
+ };
11636
+ const timer = setTimeout(() => finish(fallback, true), IDB_OP_TIMEOUT_MS);
11637
+ try {
11638
+ const t = db.transaction(STORE, mode);
11639
+ const req = run(t.objectStore(STORE));
11640
+ req.onsuccess = () => finish(req.result ?? fallback, false);
11641
+ req.onerror = () => finish(fallback, true);
11642
+ t.onabort = () => finish(fallback, true);
11643
+ } catch {
11644
+ finish(fallback, true);
11645
+ }
11646
+ }));
11462
11647
  }
11463
- /** Convert a safetensors tensor slice to f32 (adapters are tiny; eager is fine). */
11464
- function sliceToF32(buf, dataStart, entry) {
11465
- const start = dataStart + entry.dataOffset;
11466
- switch (entry.dtype) {
11467
- case "F32": return new Float32Array(buf.slice(start, start + entry.dataLength));
11468
- case "BF16": return bf16ToF32(new Uint8Array(buf, start, entry.dataLength));
11469
- case "F16": return f16ToF32(new Uint8Array(buf, start, entry.dataLength));
11470
- default: throw new Error(`LoRA: unsupported adapter tensor dtype ${entry.dtype}`);
11471
- }
11648
+ /** Read a key's bytes, or null on miss / no IDB / any error. */
11649
+ async function idbGet(key) {
11650
+ const v = await tx("readonly", (s) => s.get(key), null);
11651
+ return v instanceof ArrayBuffer ? v : null;
11472
11652
  }
11473
11653
  /**
11474
- * Download + parse an adapter (`adapter_config.json` + `adapter_model.safetensors`).
11475
- * Returns null when the source has no adapter (so callers can no-op cleanly).
11654
+ * Size-verified read. Returns the bytes ONLY if the cached entry is at least
11655
+ * `minBytes` long; otherwise treats it as a corrupt/partial entry: deletes the
11656
+ * poisoned key (so a future load re-downloads it instead of trusting it again)
11657
+ * and returns null. This is the integrity gate that turns a half-written or
11658
+ * partially-evicted tensor into a clean cache-miss rather than wrong bytes (or a
11659
+ * later "No GPU buffer for tensor …" crash when get() returns undefined). Pass
11660
+ * minBytes <= 0 to skip the size check (behaves like idbGet).
11476
11661
  */
11477
- async function fetchAdapter(spec, opts = {}) {
11478
- const { base, local } = resolveAdapterBase(spec, opts.revision ?? "main");
11479
- opts.onProgress?.(`Loading adapter ${spec}…`);
11480
- const configBuf = await fetchFile(base, local, "adapter_config.json", opts.hfToken);
11481
- if (!configBuf) throw new Error(`LoRA: no adapter_config.json at ${spec}. A flavor needs a PEFT adapter (adapter_config.json + adapter_model.safetensors).`);
11482
- const config = JSON.parse(new TextDecoder().decode(configBuf));
11483
- const weightsBuf = await fetchFile(base, local, "adapter_model.safetensors", opts.hfToken);
11484
- if (!weightsBuf) throw new Error(`LoRA: no adapter_model.safetensors at ${spec} (only .safetensors adapters are supported, not legacy .bin).`);
11485
- const file = parseSafetensorsHeader(weightsBuf);
11486
- const tensors = /* @__PURE__ */ new Map();
11487
- for (const entry of file.entries) {
11488
- if (!entry.name.includes("lora_A") && !entry.name.includes("lora_B")) continue;
11489
- tensors.set(entry.name, {
11490
- data: sliceToF32(weightsBuf, file.dataStart, entry),
11491
- shape: entry.shape
11492
- });
11662
+ async function idbGetVerified(key, minBytes) {
11663
+ const buf = await idbGet(key);
11664
+ if (!buf) return null;
11665
+ if (minBytes > 0 && buf.byteLength < minBytes) {
11666
+ await idbDelete(key);
11667
+ return null;
11493
11668
  }
11494
- if (tensors.size === 0) throw new Error(`LoRA: adapter at ${spec} has no lora_A/lora_B tensors.`);
11495
- return {
11496
- config,
11497
- tensors,
11498
- label: spec
11499
- };
11669
+ return buf;
11500
11670
  }
11501
11671
  /**
11502
- * Strip a PEFT tensor key down to its module path and canonicalize it to the
11503
- * base weight key the engine uses.
11504
- *
11505
- * PEFT names a target like
11506
- * base_model.model.model.layers.0.self_attn.q_proj.lora_A.weight
11507
- * We drop the PEFT wrapper prefix + the lora_A/B suffix to get the module path
11508
- * (…layers.0.self_attn.q_proj), append ".weight", and run the SAME key mapper
11509
- * the base load used — so Qwen ("model." strip), Gemma, and LFM2 (out_proj→
11510
- * o_proj) all canonicalize identically to their base tensors.
11672
+ * All cached keys, as a Set, in ONE transaction. Probing presence per-tensor
11673
+ * (hundreds of parallel getKey transactions) storms Safari's IndexedDB and hangs
11674
+ * the load; getAllKeys() does it in a single transaction so callers can check
11675
+ * membership in memory. Empty set on miss / no IDB / error.
11511
11676
  */
11512
- function canonicalWeightKey(peftKey, keyMapper) {
11513
- const marker = peftKey.includes(".lora_A") ? ".lora_A" : ".lora_B";
11514
- let stem = peftKey.slice(0, peftKey.indexOf(marker));
11515
- stem = stem.replace(/^base_model\.model\./, "").replace(/^base_model\./, "");
11516
- return keyMapper(`${stem.startsWith("model.") ? stem : `model.${stem}`}.weight`);
11677
+ async function idbAllKeys() {
11678
+ const keys = await tx("readonly", (s) => s.getAllKeys(), []);
11679
+ return new Set((keys ?? []).map((k) => String(k)));
11517
11680
  }
11518
- /** Longest matching suffix in a {moduleName: value} pattern map. */
11519
- function patternLookup(patterns, peftStem) {
11520
- if (!patterns) return void 0;
11521
- let best;
11522
- let bestLen = -1;
11523
- for (const [name, val] of Object.entries(patterns)) if (peftStem.includes(name) && name.length > bestLen) {
11524
- best = val;
11525
- bestLen = name.length;
11526
- }
11527
- return best;
11681
+ /** Write a key's bytes. Returns true on success, false on quota/error (caller
11682
+ * falls back to network — never throws). */
11683
+ async function idbPut(key, buf) {
11684
+ return await tx("readwrite", (s) => s.put(buf, key), null) !== null;
11528
11685
  }
11529
11686
  /**
11530
- * Pair lora_A/lora_B tensors and resolve each to a canonical `LoRADelta`.
11531
- * `keyMapper` must be the same mapper the base model load used.
11687
+ * Atomic, verified write. Writes the bytes, then reads back the stored length and
11688
+ * confirms it matches what we wrote. iOS Safari can let a put() transaction
11689
+ * "succeed" yet store a truncated value under quota pressure (or wedge the
11690
+ * transaction, which the watchdog turns into a fallback) — leaving a present-but-
11691
+ * short entry that the size-blind presence probe would later trust. On any
11692
+ * mismatch we DELETE the key so it is never read back as a valid (wrong-size)
11693
+ * tensor, and return false so the caller keeps the network bytes. Returns true
11694
+ * only when the full-length value is provably persisted.
11532
11695
  */
11533
- function buildLoRADeltas(adapter, keyMapper) {
11534
- const { config, tensors } = adapter;
11535
- const stems = /* @__PURE__ */ new Set();
11536
- for (const name of tensors.keys()) {
11537
- const marker = name.includes(".lora_A") ? ".lora_A" : ".lora_B";
11538
- stems.add(name.slice(0, name.indexOf(marker)));
11696
+ async function idbPutVerified(key, buf) {
11697
+ const expected = buf.byteLength;
11698
+ if (!await idbPut(key, buf)) {
11699
+ await idbDelete(key);
11700
+ return false;
11539
11701
  }
11540
- const deltas = [];
11541
- for (const stem of stems) {
11542
- const A = tensors.get(`${stem}.lora_A.weight`) ?? tensors.get(`${stem}.lora_A`);
11543
- const B = tensors.get(`${stem}.lora_B.weight`) ?? tensors.get(`${stem}.lora_B`);
11544
- if (!A || !B) continue;
11545
- const key = canonicalWeightKey(`${stem}.lora_A.weight`, keyMapper);
11546
- if (!key) continue;
11547
- const r = A.shape[0];
11548
- const inFeatures = A.shape[1];
11549
- const outFeatures = B.shape[0];
11550
- if (B.shape[1] !== r) {
11551
- console.warn(`[gerbil] LoRA: rank mismatch on ${stem} (A r=${r}, B r=${B.shape[1]}); skipping.`);
11552
- continue;
11553
- }
11554
- const alpha = patternLookup(config.alpha_pattern, stem) ?? config.lora_alpha;
11555
- const effR = patternLookup(config.rank_pattern, stem) ?? r;
11556
- const scaling = config.use_rslora ? alpha / Math.sqrt(effR) : alpha / effR;
11557
- deltas.push({
11558
- key,
11559
- A: A.data,
11560
- B: B.data,
11561
- r,
11562
- inFeatures,
11563
- outFeatures,
11564
- scaling,
11565
- fanInFanOut: config.fan_in_fan_out === true
11566
- });
11702
+ const back = await idbGet(key);
11703
+ if (!back || back.byteLength !== expected) {
11704
+ await idbDelete(key);
11705
+ return false;
11567
11706
  }
11568
- return deltas;
11707
+ return true;
11569
11708
  }
11570
- /**
11571
- * Fold every resolved delta into the store's base weights in place
11572
- * (W += scaling · B·A). Runs after weights are loaded and before architecture
11573
- * transforms/quantization. Returns the count of tensors merged (for logging).
11574
- *
11575
- * Skips — loudly — any target whose base tensor is missing, shape-mismatched, or
11576
- * not a float pack (a pre-quantized MLX/GPTQ base can't be merged this way).
11577
- */
11578
- async function applyLoRAToStore(store, deltas, onProgress) {
11579
- let merged = 0;
11580
- let skipped = 0;
11581
- for (const d of deltas) {
11582
- const base = await store.get(d.key);
11583
- if (!base) {
11584
- skipped++;
11585
- continue;
11586
- }
11587
- if (!(base.data instanceof Float32Array)) {
11588
- console.warn(`[gerbil] LoRA: base tensor ${d.key} is not float (pre-quantized base?); skipping merge.`);
11589
- skipped++;
11590
- continue;
11591
- }
11592
- const [outF, inF] = base.shape;
11593
- const rows = d.fanInFanOut ? inF : outF;
11594
- const cols = d.fanInFanOut ? outF : inF;
11595
- if (rows !== d.outFeatures || cols !== d.inFeatures) {
11596
- console.warn(`[gerbil] LoRA: shape mismatch on ${d.key} (base ${base.shape}, delta ${d.outFeatures}×${d.inFeatures}); skipping.`);
11597
- skipped++;
11598
- continue;
11599
- }
11600
- const W = new Float32Array(base.data);
11601
- const { A, B, r, scaling, fanInFanOut } = d;
11602
- for (let o = 0; o < d.outFeatures; o++) {
11603
- const bRow = o * r;
11604
- for (let k = 0; k < r; k++) {
11605
- const bScaled = scaling * B[bRow + k];
11606
- if (bScaled === 0) continue;
11607
- const aRow = k * d.inFeatures;
11608
- if (fanInFanOut) for (let i = 0; i < d.inFeatures; i++) W[i * outF + o] += bScaled * A[aRow + i];
11609
- else {
11610
- const wRow = o * inF;
11611
- for (let i = 0; i < d.inFeatures; i++) W[wRow + i] += bScaled * A[aRow + i];
11612
- }
11613
- }
11614
- }
11615
- await store.set(d.key, {
11616
- data: W,
11617
- shape: base.shape
11618
- });
11619
- merged++;
11620
- }
11621
- onProgress?.(`Adapter merged into ${merged} tensors${skipped ? ` (${skipped} skipped)` : ""}.`);
11622
- return {
11623
- merged,
11624
- skipped
11625
- };
11709
+ /** Delete a key (best-effort). */
11710
+ async function idbDelete(key) {
11711
+ await tx("readwrite", (s) => s.delete(key), null);
11712
+ }
11713
+ /** True when IndexedDB is usable in this environment (browser, not Node). */
11714
+ function idbCacheAvailable() {
11715
+ return idbAvailable();
11626
11716
  }
11627
11717
 
11628
11718
  //#endregion
@@ -14836,5 +14926,5 @@ function selectGraphWeights(graph, weights) {
14836
14926
  }
14837
14927
 
14838
14928
  //#endregion
14839
- export { destroyBuffers as C, verifyGPU as E, createUniformBuffer as S, initGPU as T, KERNEL_REGISTRY as _, loadModel as a, createBindGroup as b, loadParlerTTS as c, remapPrunedToken as d, Tokenizer as f, Executor as g, fetchAdapter as h, loadKaniTTS as i, quantizeBackboneInt4 as l, buildLoRADeltas as m, MoonshineEncoderExecutor as n, loadMoonshine as o, applyLoRAToStore as p, createKeyMapperForArch as r, loadOuteTTS as s, MoonshineSTT as t, quantizeKaniBackbone as u, MATMUL_BIAS_F16C_SPEC as v, getOrCreatePipeline as w, createStorageBuffer as x, clearPipelineCache as y };
14840
- //# sourceMappingURL=moonshine-stt-CgDXoLFd.mjs.map
14929
+ export { createUniformBuffer as C, verifyGPU as D, initGPU as E, createStorageBuffer as S, getOrCreatePipeline as T, splitFusedQGateDelta as _, loadModel as a, clearPipelineCache as b, loadParlerTTS as c, remapPrunedToken as d, Tokenizer as f, fetchAdapter as g, buildLoRADeltas as h, loadKaniTTS as i, quantizeBackboneInt4 as l, applyLoRAToStore as m, MoonshineEncoderExecutor as n, loadMoonshine as o, Executor as p, createKeyMapperForArch as r, loadOuteTTS as s, MoonshineSTT as t, quantizeKaniBackbone as u, KERNEL_REGISTRY as v, destroyBuffers as w, createBindGroup as x, MATMUL_BIAS_F16C_SPEC as y };
14930
+ //# sourceMappingURL=moonshine-stt-BI-5UmD_.mjs.map