mlx-bun 0.0.10 → 0.0.11

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 (173) hide show
  1. package/README.md +66 -21
  2. package/THIRD_PARTY_LICENSES.md +1 -0
  3. package/docs/archive/mlx-bun-lab-report.html +275 -0
  4. package/docs/archive/wwdc-mlx-bun.html +848 -0
  5. package/docs/design/audio-input-plan.md +356 -0
  6. package/docs/design/batching-perf-path.md +58 -35
  7. package/docs/design/batching-v2-plan.md +23 -9
  8. package/docs/design/bucketing-stage.md +2 -1
  9. package/docs/design/compat-cli-surface-design.md +4 -3
  10. package/docs/design/decode-speed-program.md +172 -0
  11. package/docs/design/docs-reorg-plan.md +1 -1
  12. package/docs/design/dspark-serving-program.md +371 -0
  13. package/docs/design/dspark-speculative-decoding.md +167 -5
  14. package/docs/design/faithful-l1-consolidation.md +310 -0
  15. package/docs/design/generic-model-support.md +8 -5
  16. package/docs/design/grammar-spec-batching-integration.md +301 -0
  17. package/docs/design/minicpm5-decode-megakernel.md +16 -11
  18. package/docs/design/mlx-lm-tool-parity-plan.md +11 -1
  19. package/docs/design/omlx-adoption-map.md +14 -6
  20. package/docs/design/orpo-training.md +34 -30
  21. package/docs/design/paged-kv-cache.md +214 -0
  22. package/docs/design/parallel-slots.md +82 -49
  23. package/docs/design/parity-tier-dag.md +43 -33
  24. package/docs/design/repo-cleanup-plan.md +189 -0
  25. package/docs/design/runtime-isolation.md +19 -3
  26. package/docs/design/segmented-backward-training.md +3 -2
  27. package/docs/design/ssd-kv-cold-tier.md +130 -0
  28. package/docs/design/structured-output.md +484 -0
  29. package/docs/design/the-dreaming-master-plan.md +2 -3
  30. package/docs/design/turboquant-kv.md +235 -0
  31. package/docs/design/unified-engine-frontier-plan.md +774 -0
  32. package/docs/design/web-chat-beat-matrix.md +315 -0
  33. package/docs/design/web-chat-redesign.md +1112 -0
  34. package/docs/investigations/dspark-handoff.md +182 -16
  35. package/docs/investigations/lab-build-journal.md +2 -2
  36. package/docs/investigations/openwiki-evaluation.md +249 -0
  37. package/docs/planning/IDEAS.md +14 -0
  38. package/docs/planning/PRODUCT_ROADMAP.md +2 -3
  39. package/docs/planning/chat-ui-vision-memory-first.md +199 -0
  40. package/docs/planning/chat-ui-vision.md +50 -0
  41. package/docs/planning/memory-docs-and-dag-plan.md +15 -6
  42. package/docs/planning/release-notes-v0.0.11.md +239 -0
  43. package/docs/reference/cli.md +35 -16
  44. package/docs/reference/distribution.md +13 -6
  45. package/docs/reference/embedding.md +35 -14
  46. package/docs/reference/features-matrix.md +97 -0
  47. package/docs/reference/library-api.md +30 -9
  48. package/docs/reference/memory.md +26 -6
  49. package/docs/reference/models.md +11 -1
  50. package/docs/reference/orpo-quickstart.md +3 -4
  51. package/docs/reference/server-api.md +634 -22
  52. package/docs/reference/server-config.md +250 -121
  53. package/docs/reference/training.md +14 -15
  54. package/package.json +5 -3
  55. package/src/anthropic.ts +13 -0
  56. package/src/audio/conformer.ts +647 -0
  57. package/src/audio/decode.ts +133 -0
  58. package/src/audio/features.ts +202 -0
  59. package/src/audio/transcode.ts +67 -0
  60. package/src/cli.ts +356 -105
  61. package/src/config.ts +49 -2
  62. package/src/download.ts +40 -0
  63. package/src/eval/README.md +7 -6
  64. package/src/eval/kl.ts +59 -3
  65. package/src/eval/quality-db.ts +2 -2
  66. package/src/eval/runner.ts +3 -3
  67. package/src/expert-offload.ts +15 -2
  68. package/src/flags.ts +13 -0
  69. package/src/generate.ts +330 -24
  70. package/src/grammar.ts +585 -0
  71. package/src/hub-rest.ts +242 -0
  72. package/src/kv-store.ts +442 -88
  73. package/src/lora.ts +8 -0
  74. package/src/md-modules.d.ts +42 -0
  75. package/src/media-fetch.ts +284 -0
  76. package/src/memory/rest.ts +245 -0
  77. package/src/memory/vault.ts +74 -0
  78. package/src/mlx/array.ts +106 -19
  79. package/src/mlx/autograd.ts +2 -2
  80. package/src/mlx/ffi.ts +27 -3
  81. package/src/mlx/metal-kernel.ts +7 -4
  82. package/src/mlx/ops.ts +153 -23
  83. package/src/mlx/turboquant-ops.ts +831 -0
  84. package/src/mlx/turboquant-tables.ts +237 -0
  85. package/src/model/batched-mask.ts +62 -1
  86. package/src/model/batched-quant.ts +206 -0
  87. package/src/model/batched-rotating-quant.ts +346 -0
  88. package/src/model/batched-rotating.ts +46 -1
  89. package/src/model/compiled-decode.ts +4 -15
  90. package/src/model/factory.ts +10 -4
  91. package/src/model/gemma4-base.ts +363 -39
  92. package/src/model/gemma4.ts +122 -33
  93. package/src/model/generated/gemma4-12b.ts +9 -13
  94. package/src/model/generated/gemma4-26b.ts +10 -5
  95. package/src/model/generated/gemma4-e4b.ts +14 -9
  96. package/src/model/minicpm5.ts +58 -102
  97. package/src/model/paged-kv.ts +253 -0
  98. package/src/model/qwen3-delta.ts +76 -15
  99. package/src/model/qwen3-moe.ts +404 -0
  100. package/src/model/qwen3.ts +9 -8
  101. package/src/model/qwen3_5.ts +88 -23
  102. package/src/model/support.ts +12 -1
  103. package/src/model/universal/dense.ts +40 -5
  104. package/src/model/universal/rope.ts +36 -21
  105. package/src/pi-web.ts +1299 -82
  106. package/src/preflight.ts +27 -8
  107. package/src/prompt-cache.ts +293 -33
  108. package/src/quantize/quantizer.ts +16 -1
  109. package/src/registry.ts +57 -2
  110. package/src/serve/batch-scheduler.ts +965 -72
  111. package/src/serve/generation-gateway.ts +214 -11
  112. package/src/serve/isolate.ts +417 -0
  113. package/src/serve/lane-registry.ts +44 -0
  114. package/src/serve/session-search.ts +326 -0
  115. package/src/server.ts +1380 -135
  116. package/src/spec/assistant-source.ts +150 -0
  117. package/src/spec/deepspec-source.ts +143 -0
  118. package/src/spec/dflash-source.ts +124 -0
  119. package/src/spec/dspark/ab-stats.ts +136 -0
  120. package/src/spec/dspark/calibration.ts +112 -0
  121. package/src/spec/dspark/deepspec-module.ts +801 -0
  122. package/src/spec/dspark/generate-dflash.ts +72 -20
  123. package/src/spec/dspark/loader.ts +23 -0
  124. package/src/spec/dspark/module-dflash.ts +278 -36
  125. package/src/spec/dspark/quantize-drafter.ts +91 -0
  126. package/src/spec/generate.ts +59 -25
  127. package/src/spec/ngram-source.ts +132 -0
  128. package/src/spec/serve-loop.ts +536 -0
  129. package/src/spec/source.ts +93 -0
  130. package/src/spec/two-model.ts +171 -0
  131. package/src/ssd-cache.ts +107 -35
  132. package/src/tokenizer.ts +288 -2
  133. package/src/tool-approvals.ts +131 -0
  134. package/src/tool-call.ts +408 -20
  135. package/src/train/flash-cce.ts +2 -2
  136. package/src/train/job.ts +0 -2
  137. package/src/train/trainer.ts +1 -4
  138. package/src/vision/prompt.ts +217 -48
  139. package/src/web/app.html +1184 -1789
  140. package/src/web/app.js +5667 -0
  141. package/src/web/icon.svg +13 -0
  142. package/src/web/manifest.webmanifest +24 -0
  143. package/src/web/src/adapters-panel.ts +288 -0
  144. package/src/web/src/api.ts +57 -0
  145. package/src/web/src/assistant.ts +485 -0
  146. package/src/web/src/chat.ts +1050 -0
  147. package/src/web/src/composer.ts +959 -0
  148. package/src/web/src/dataset.ts +132 -0
  149. package/src/web/src/finetune.ts +267 -0
  150. package/src/web/src/hub.ts +428 -0
  151. package/src/web/src/main.ts +98 -0
  152. package/src/web/src/markdown.ts +0 -0
  153. package/src/web/src/memory-panel.ts +572 -0
  154. package/src/web/src/model-picker.ts +159 -0
  155. package/src/web/src/palette.ts +346 -0
  156. package/src/web/src/protocol.ts +62 -0
  157. package/src/web/src/quantize.ts +167 -0
  158. package/src/web/src/rag.ts +274 -0
  159. package/src/web/src/sessions.ts +469 -0
  160. package/src/web/src/shell.ts +804 -0
  161. package/src/web/src/status.ts +252 -0
  162. package/src/web/src/ui-catalog.ts +92 -0
  163. package/src/web/sw.js +71 -0
  164. package/src/web/vendor/README.md +61 -0
  165. package/src/web/vendor/hljs-LICENSE +29 -0
  166. package/src/web/vendor/hljs-theme.css +59 -0
  167. package/src/web/vendor/hljs.js +26 -0
  168. package/src/model/fused-decode-kernel.ts +0 -312
  169. package/src/model/fused-geglu-kernel.ts +0 -198
  170. package/src/model/fused-mlp-kernel.ts +0 -169
  171. package/src/model/fused-swiglu-kernel.ts +0 -240
  172. package/src/model/steel-linear-kernel.ts +0 -96
  173. package/src/status-page.html +0 -497
@@ -1,10 +1,21 @@
1
- // Multimodal prompt assembly: OpenAI image_url content parts → spliced
2
- // token ids + merged input embeddings + image-token mask.
1
+ // Multimodal prompt assembly: OpenAI image/audio content parts → spliced
2
+ // token ids + merged input embeddings + attention/zeroing masks.
3
3
  //
4
- // The chat template renders one <|image|> token (258880) per image part;
5
- // we expand each into <boi> + image_token × softTokens + <eoi> (matching
6
- // optiq's frontend), embed the full sequence, then overwrite the
7
- // image-token rows with the vision tower's features.
4
+ // The chat template renders one <|image|> token (258880) per image part and
5
+ // one <|audio|> token (258881) per audio part; we expand each in document
6
+ // order — <boi> + image_token × softTokens + <eoi> (matching optiq's vision
7
+ // frontend) and <boa> + audio_token × n + <eoa> with
8
+ // n = min(ceil(duration_ms/40), 750) (processing_gemma4.py) — embed the full
9
+ // sequence, then overwrite the soft-token rows with the towers' features.
10
+ //
11
+ // Attention semantics (audio-input-plan.md §3.3, resolved vs the oracle):
12
+ // - image runs attend bidirectionally among themselves (bidirMask), BUT
13
+ // any audio token in the prompt disables the vision overlay entirely —
14
+ // mixed image+audio prompts run fully causal (bidirMask = null);
15
+ // - audio runs are strictly causal, always;
16
+ // - per-layer-input id zeroing (e2b/e4b) applies to the UNION of
17
+ // multimodal soft tokens (image | audio) — multimodalMask, decoupled
18
+ // from the bidirectional mask.
8
19
 
9
20
  import { MlxArray } from "../mlx/array";
10
21
  import { Dtype } from "../mlx/ffi";
@@ -12,6 +23,10 @@ import * as ops from "../mlx/ops";
12
23
  import type { Gemma4Model } from "../model/gemma4";
13
24
  import type { ChatTemplate, ChatMessage, ToolDefinition } from "../chat-template";
14
25
  import type { LoadedTokenizer } from "../tokenizer";
26
+ import type { AudioTower } from "../audio/conformer";
27
+ import { audioSoftTokenCount, decodeAudio } from "../audio/decode";
28
+ import { extractMelFeatures } from "../audio/features";
29
+ import { fetchMediaBytes } from "../media-fetch";
15
30
 
16
31
  /** Common contract for both vision towers (encoder-free gemma4_unified in
17
32
  * ./embedder.ts and the SigLIP encoder in ./siglip.ts): preprocess image
@@ -33,12 +48,38 @@ export interface VisionPrompt {
33
48
  imageMask: MlxArray;
34
49
  }
35
50
 
51
+ export interface MultimodalPrompt {
52
+ ids: number[];
53
+ /** Unscaled merged embeddings [1, L, hidden] — caller disposes. */
54
+ embeddings: MlxArray;
55
+ /** bool [L] image-token mask for the bidirectional attention overlay.
56
+ * null when the prompt contains ANY audio (§3.3 Q1: mixed image+audio
57
+ * runs fully causal) or no images. Caller disposes when non-null. */
58
+ bidirMask: MlxArray | null;
59
+ /** bool [L] union multimodal soft-token mask (image | audio) for
60
+ * per-layer-input id zeroing (§3.3 Q2). Caller disposes. */
61
+ multimodalMask: MlxArray;
62
+ }
63
+
36
64
  export interface VisionTokenIds {
37
65
  imageTokenId: number;
38
66
  boiTokenId: number;
39
67
  eoiTokenId: number;
40
68
  }
41
69
 
70
+ export interface AudioTokenIds {
71
+ audioTokenId: number;
72
+ boaTokenId: number;
73
+ eoaTokenId: number;
74
+ }
75
+
76
+ /** The towers a multimodal prompt may need; pass only what the request uses
77
+ * (vision-only and audio-only fall out as special cases). */
78
+ export interface MultimodalTowers<P extends { softTokens: number }> {
79
+ vision?: { tower: VisionEncoder<P>; tokenIds: VisionTokenIds };
80
+ audio?: { tower: AudioTower; tokenIds: AudioTokenIds };
81
+ }
82
+
42
83
  /** Extract image bytes from OpenAI-style content parts, rewriting the
43
84
  * parts to the template's {type:"image"} form. */
44
85
  export async function extractImages(
@@ -57,7 +98,7 @@ export async function extractImages(
57
98
  const url = (part.image_url as { url: string } | undefined)?.url
58
99
  ?? (part.image_url as unknown as string);
59
100
  if (typeof url !== "string") throw new Error("image_url part missing url");
60
- images.push(await fetchImageBytes(url));
101
+ images.push(await fetchMediaBytes(url, "image"));
61
102
  parts.push({ type: "image" });
62
103
  } else if (part.type === "image") {
63
104
  // already template-form: expect base64 `data` or pass-through marker
@@ -76,83 +117,211 @@ export async function extractImages(
76
117
  return { messages: out, images };
77
118
  }
78
119
 
79
- async function fetchImageBytes(url: string): Promise<Uint8Array> {
80
- if (url.startsWith("data:")) {
81
- const comma = url.indexOf(",");
82
- if (comma === -1) throw new Error("malformed data: URL");
83
- const meta = url.slice(0, comma);
84
- const body = url.slice(comma + 1);
85
- if (!meta.includes("base64")) throw new Error("data: URL must be base64");
86
- return Uint8Array.from(Buffer.from(body, "base64"));
87
- }
88
- if (url.startsWith("http://") || url.startsWith("https://")) {
89
- const res = await fetch(url);
90
- if (!res.ok) throw new Error(`image fetch failed: ${res.status} ${url}`);
91
- return new Uint8Array(await res.arrayBuffer());
120
+ /** Extract audio bytes from OpenAI-style content parts, rewriting the parts
121
+ * to the template's {type:"audio"} form. Accepts the OpenAI-canonical
122
+ * {type:"input_audio", input_audio:{data:<b64>, format:...}} plus optiq's
123
+ * {type:"audio"} (base64 `data`) and {type:"audio_url"} aliases. */
124
+ export async function extractAudio(
125
+ messages: ChatMessage[],
126
+ ): Promise<{ messages: ChatMessage[]; audio: Uint8Array[] }> {
127
+ const audio: Uint8Array[] = [];
128
+ const out: ChatMessage[] = [];
129
+ for (const m of messages) {
130
+ if (!Array.isArray(m.content)) {
131
+ out.push(m);
132
+ continue;
133
+ }
134
+ const parts: Array<Record<string, unknown>> = [];
135
+ for (const part of m.content) {
136
+ if (part.type === "input_audio") {
137
+ const data = (part.input_audio as { data?: string } | undefined)?.data;
138
+ if (typeof data !== "string")
139
+ throw new Error("input_audio part requires base64 `input_audio.data`");
140
+ audio.push(Uint8Array.from(Buffer.from(data, "base64")));
141
+ parts.push({ type: "audio" });
142
+ } else if (part.type === "audio") {
143
+ if (typeof part.data === "string") {
144
+ audio.push(Uint8Array.from(Buffer.from(part.data, "base64")));
145
+ parts.push({ type: "audio" });
146
+ } else {
147
+ throw new Error("audio part requires base64 `data`");
148
+ }
149
+ } else if (part.type === "audio_url") {
150
+ const url = (part.audio_url as { url: string } | undefined)?.url
151
+ ?? (part.audio_url as unknown as string);
152
+ if (typeof url !== "string") throw new Error("audio_url part missing url");
153
+ audio.push(await fetchMediaBytes(url, "audio"));
154
+ parts.push({ type: "audio" });
155
+ } else {
156
+ parts.push(part);
157
+ }
158
+ }
159
+ out.push({ ...m, content: parts });
92
160
  }
93
- throw new Error(`unsupported image url scheme: ${url.slice(0, 16)}`);
161
+ return { messages: out, audio };
94
162
  }
95
163
 
96
- export async function buildVisionPrompt<P extends { softTokens: number }>(
164
+ /** Decoded + featurized audio clip, ready to splice. */
165
+ interface PreparedAudio {
166
+ mel: Float32Array;
167
+ frames: number;
168
+ softTokens: number;
169
+ }
170
+
171
+ export async function buildMultimodalPrompt<P extends { softTokens: number }>(
97
172
  model: Gemma4Model,
98
- tower: VisionEncoder<P>,
173
+ towers: MultimodalTowers<P>,
99
174
  tokenizer: LoadedTokenizer,
100
175
  template: ChatTemplate,
101
176
  messages: ChatMessage[],
102
177
  images: Uint8Array[],
103
- tokenIds: VisionTokenIds,
178
+ audio: Uint8Array[],
104
179
  tools: ToolDefinition[] | null = null,
105
- ): Promise<VisionPrompt> {
180
+ ): Promise<MultimodalPrompt> {
181
+ if (images.length > 0 && !towers.vision)
182
+ throw new Error("images present but no vision tower configured");
183
+ if (audio.length > 0 && !towers.audio)
184
+ throw new Error("audio present but no audio tower configured");
185
+
106
186
  const rendered = template.render(messages, { tools });
107
187
  let ids = tokenizer.encode(rendered);
108
188
  if (ids[0] === ids[1] && ids[0] === tokenizer.bosTokenId) ids = ids.slice(1);
109
189
 
110
- // preprocess + embed every image (in order of appearance), via the
111
- // tower's own preprocessing (encoder-free vs SigLIP differ here).
112
- const pre = await Promise.all(images.map((bytes) => tower.preprocess(bytes)));
190
+ // preprocess every clip/image up front (splice lengths depend on them);
191
+ // audio soft-token counts come from the DECODED samples, never metadata.
192
+ const pre = await Promise.all(
193
+ images.map((bytes) => towers.vision!.tower.preprocess(bytes)),
194
+ );
195
+ const clips: PreparedAudio[] = audio.map((bytes) => {
196
+ const samples = decodeAudio(bytes);
197
+ const softTokens = audioSoftTokenCount(samples.length);
198
+ const m = extractMelFeatures(samples);
199
+ return { mel: m.features, frames: m.frames, softTokens };
200
+ });
201
+
202
+ const vIds = towers.vision?.tokenIds;
203
+ const aIds = towers.audio?.tokenIds;
113
204
 
114
- // expand each <|image|> into <boi> + image_token×soft + <eoi>
205
+ // expand each <|image|> into <boi> + image_token×soft + <eoi> and each
206
+ // <|audio|> into <boa> + audio_token×n + <eoa>, in document order
115
207
  const spliced: number[] = [];
116
- const runs: { start: number; length: number }[] = [];
208
+ const runs: { start: number; length: number; kind: "image" | "audio"; index: number }[] = [];
117
209
  let imgIdx = 0;
210
+ let audIdx = 0;
118
211
  for (const id of ids) {
119
- if (id === tokenIds.imageTokenId) {
212
+ if (vIds && id === vIds.imageTokenId) {
120
213
  const p = pre[imgIdx];
121
214
  if (!p) throw new Error("more <|image|> markers than images");
122
- spliced.push(tokenIds.boiTokenId);
123
- runs.push({ start: spliced.length, length: p.softTokens });
124
- for (let k = 0; k < p.softTokens; k++) spliced.push(tokenIds.imageTokenId);
125
- spliced.push(tokenIds.eoiTokenId);
215
+ spliced.push(vIds.boiTokenId);
216
+ runs.push({ start: spliced.length, length: p.softTokens, kind: "image", index: imgIdx });
217
+ for (let k = 0; k < p.softTokens; k++) spliced.push(vIds.imageTokenId);
218
+ spliced.push(vIds.eoiTokenId);
126
219
  imgIdx++;
220
+ } else if (aIds && id === aIds.audioTokenId) {
221
+ const clip = clips[audIdx];
222
+ if (!clip) throw new Error("more <|audio|> markers than audio clips");
223
+ spliced.push(aIds.boaTokenId);
224
+ runs.push({ start: spliced.length, length: clip.softTokens, kind: "audio", index: audIdx });
225
+ for (let k = 0; k < clip.softTokens; k++) spliced.push(aIds.audioTokenId);
226
+ spliced.push(aIds.eoaTokenId);
227
+ audIdx++;
127
228
  } else {
128
229
  spliced.push(id);
129
230
  }
130
231
  }
131
232
  if (imgIdx !== images.length)
132
233
  throw new Error(`${images.length} images but ${imgIdx} <|image|> markers`);
234
+ if (audIdx !== audio.length)
235
+ throw new Error(`${audio.length} audio clips but ${audIdx} <|audio|> markers`);
133
236
 
134
- // embed text tokens, then overwrite image-token rows with vision features
237
+ // embed text tokens, then overwrite soft-token rows with tower features
135
238
  const idsArr = ops.fromInt32(spliced, [1, spliced.length]);
136
239
  let embeds = model.embed.encode(idsArr);
137
240
  idsArr.dispose();
138
241
  const hidden = embeds.shape[2]!;
139
- for (let i = 0; i < runs.length; i++) {
140
- const { start, length } = runs[i]!;
141
- const feats = tower.features(pre[i]!); // [1, soft, hidden]
142
- const cast = feats.astype(embeds.dtype);
143
- feats.dispose();
242
+ for (const run of runs) {
243
+ let cast: MlxArray;
244
+ if (run.kind === "image") {
245
+ // vision towers return pre-divided by embed_scale (their oracle's
246
+ // frontend convention) — cast to the embedding dtype and splice.
247
+ const feats = towers.vision!.tower.features(pre[run.index]!); // [1, soft, hidden]
248
+ cast = feats.astype(embeds.dtype);
249
+ feats.dispose();
250
+ } else {
251
+ // audio mirrors gen-e4b-audio-golden.py EXACTLY:
252
+ // features.astype(embeds.dtype) / embed_scale
253
+ // — raw f32 embed_audio output, cast to bf16 FIRST, then divided by a
254
+ // weak (dtype-following, i.e. bf16) embed_scale scalar. The order is
255
+ // load-bearing for the bit-exact greedy gate (see AudioTower.features).
256
+ const clip = clips[run.index]!;
257
+ const raw = towers.audio!.tower.features(clip.mel, clip.frames, false); // [1, n, hidden] f32
258
+ if (raw.shape[1] !== clip.softTokens) {
259
+ const n = raw.shape[1];
260
+ raw.dispose();
261
+ embeds.dispose();
262
+ throw new Error(`audio tower produced ${n} frames but splice expects ${clip.softTokens}`);
263
+ }
264
+ const bf = raw.astype(embeds.dtype);
265
+ raw.dispose();
266
+ const scale = ops.scalarLike(towers.audio!.tower.embedScale, bf);
267
+ cast = ops.div(bf, scale);
268
+ bf.dispose();
269
+ scale.dispose();
270
+ }
271
+ const { start, length } = run;
144
272
  const updated = ops.sliceUpdate(embeds, cast, [0, start, 0], [1, start + length, hidden]);
145
273
  cast.dispose();
146
274
  embeds.dispose();
147
275
  embeds = updated;
148
276
  }
149
277
 
150
- const maskInts = new Int32Array(spliced.length);
151
- for (let i = 0; i < spliced.length; i++)
152
- maskInts[i] = spliced[i] === tokenIds.imageTokenId ? 1 : 0;
153
- const maskI32 = MlxArray.fromInt32(maskInts, [spliced.length]);
154
- const imageMask = maskI32.astype(Dtype.bool);
155
- maskI32.dispose();
278
+ // masks over the spliced sequence: union multimodal soft tokens (per-layer
279
+ // id zeroing) always; image-only bidirectional overlay only when NO audio
280
+ // is present (§3.3 Q1).
281
+ const mmInts = new Int32Array(spliced.length);
282
+ for (let i = 0; i < spliced.length; i++) {
283
+ const id = spliced[i]!;
284
+ mmInts[i] = (vIds && id === vIds.imageTokenId) || (aIds && id === aIds.audioTokenId) ? 1 : 0;
285
+ }
286
+ const mmI32 = MlxArray.fromInt32(mmInts, [spliced.length]);
287
+ const multimodalMask = mmI32.astype(Dtype.bool);
288
+ mmI32.dispose();
156
289
 
157
- return { ids: spliced, embeddings: embeds, imageMask };
290
+ let bidirMask: MlxArray | null = null;
291
+ if (images.length > 0 && audio.length === 0) {
292
+ const imgInts = new Int32Array(spliced.length);
293
+ for (let i = 0; i < spliced.length; i++)
294
+ imgInts[i] = spliced[i] === vIds!.imageTokenId ? 1 : 0;
295
+ const imgI32 = MlxArray.fromInt32(imgInts, [spliced.length]);
296
+ bidirMask = imgI32.astype(Dtype.bool);
297
+ imgI32.dispose();
298
+ }
299
+
300
+ return { ids: spliced, embeddings: embeds, bidirMask, multimodalMask };
301
+ }
302
+
303
+ /** Vision-only prompt (back-compat wrapper over buildMultimodalPrompt —
304
+ * behavior byte-identical to the pre-audio builder: the returned imageMask
305
+ * serves as BOTH the bidirectional-attention mask and the per-layer-input
306
+ * zeroing mask, which forwardEmbeddings derives from `bidir` when no
307
+ * separate multimodal mask is passed). */
308
+ export async function buildVisionPrompt<P extends { softTokens: number }>(
309
+ model: Gemma4Model,
310
+ tower: VisionEncoder<P>,
311
+ tokenizer: LoadedTokenizer,
312
+ template: ChatTemplate,
313
+ messages: ChatMessage[],
314
+ images: Uint8Array[],
315
+ tokenIds: VisionTokenIds,
316
+ tools: ToolDefinition[] | null = null,
317
+ ): Promise<VisionPrompt> {
318
+ const mp = await buildMultimodalPrompt(
319
+ model, { vision: { tower, tokenIds } }, tokenizer, template,
320
+ messages, images, [], tools,
321
+ );
322
+ // no audio was passed, so the union mask IS the image mask — reuse it for
323
+ // the zero-image edge (the original builder returned an all-false mask).
324
+ const imageMask = mp.bidirMask ?? mp.multimodalMask;
325
+ if (mp.bidirMask) mp.multimodalMask.dispose();
326
+ return { ids: mp.ids, embeddings: mp.embeddings, imageMask };
158
327
  }