mlx-bun 0.4.0 → 0.5.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.
Files changed (158) hide show
  1. package/README.md +57 -32
  2. package/docs/README.md +55 -42
  3. package/docs/archive/investigations/benchmark-ledger-through-v0.4.0.md +3617 -0
  4. package/docs/archive/planning/release-notes-v0.5.0.md +37 -0
  5. package/docs/design/batching.md +220 -7
  6. package/docs/design/decode-speed-program.md +121 -66
  7. package/docs/design/generic-model-support.md +116 -0
  8. package/docs/design/kv-cache.md +380 -211
  9. package/docs/design/speculative-decoding.md +165 -26
  10. package/docs/design/turboquant.md +20 -7
  11. package/docs/design/unified-engine-frontier-plan.md +120 -13
  12. package/docs/planning/PRODUCT_ROADMAP.md +5 -6
  13. package/docs/planning/public-readiness-handoff.md +244 -0
  14. package/docs/planning/public-readiness.md +149 -0
  15. package/docs/reference/benchmarks.md +3463 -3489
  16. package/docs/reference/cli.md +62 -1
  17. package/docs/reference/distribution.md +29 -4
  18. package/docs/reference/environment.md +104 -8
  19. package/docs/reference/library-api.md +18 -4
  20. package/docs/reference/memory.md +1 -1
  21. package/docs/reference/models.md +17 -5
  22. package/docs/reference/server-api.md +181 -11
  23. package/docs/reference/server-config.md +232 -105
  24. package/package.json +2 -2
  25. package/src/audio/audiotoolbox.ts +112 -0
  26. package/src/audio/conformer.ts +8 -0
  27. package/src/audio/mic-capture.ts +98 -0
  28. package/src/audio/silero-vad.ts +379 -0
  29. package/src/audio/whisper-decode.ts +774 -0
  30. package/src/audio/whisper-fast.ts +732 -0
  31. package/src/audio/whisper-format.ts +73 -0
  32. package/src/audio/whisper-mel.ts +185 -0
  33. package/src/audio/whisper-timing.ts +274 -0
  34. package/src/audio/whisper-tokenizer.ts +239 -0
  35. package/src/audio/whisper-transcribe.ts +410 -0
  36. package/src/backends/mlx/autoregressive.ts +22 -1
  37. package/src/backends/mlx/batch-group.ts +111 -27
  38. package/src/backends/mlx/cache-layout.ts +3 -3
  39. package/src/backends/mlx/continuation-persistence.ts +5 -4
  40. package/src/backends/mlx/encoder-cache.ts +25 -0
  41. package/src/backends/mlx/fill-append.ts +21 -8
  42. package/src/backends/mlx/fill-group.ts +497 -0
  43. package/src/backends/mlx/gateway-binding.ts +56 -12
  44. package/src/backends/mlx/kv-maintenance.ts +30 -2
  45. package/src/backends/mlx/mixed-iteration.ts +62 -0
  46. package/src/backends/mlx/model-prompt.ts +46 -32
  47. package/src/backends/mlx/model-serving.ts +4 -3
  48. package/src/backends/mlx/paged-cache-codec.ts +27 -0
  49. package/src/backends/mlx/prefill-cohort.ts +15 -4
  50. package/src/backends/mlx/prefill-policy.ts +26 -0
  51. package/src/backends/mlx/prefill-rows.ts +57 -13
  52. package/src/backends/mlx/prompt-input.ts +29 -0
  53. package/src/backends/mlx/qwen-prompt-input.ts +40 -0
  54. package/src/backends/mlx/request-state-policy.ts +18 -5
  55. package/src/backends/mlx/rollback.ts +6 -3
  56. package/src/backends/mlx/serial-executor.ts +16 -7
  57. package/src/backends/mlx/speculative-group.ts +105 -26
  58. package/src/backends/mlx/speculative.ts +24 -7
  59. package/src/backends/mlx/target-layout-capability.ts +1 -1
  60. package/src/backends/mlx/transitioning-kv-rows.ts +8 -0
  61. package/src/cli.ts +322 -0
  62. package/src/contracts/execution.ts +10 -0
  63. package/src/contracts/object-cache.ts +8 -0
  64. package/src/contracts/pi-provider.ts +1 -1
  65. package/src/contracts/pi.ts +3 -1
  66. package/src/contracts/prefix-cache.ts +13 -3
  67. package/src/contracts/scheduling.ts +9 -0
  68. package/src/engine/execution-plan.ts +23 -12
  69. package/src/engine/execution-tasks.ts +44 -0
  70. package/src/engine/namespaced-cache.ts +13 -0
  71. package/src/engine/scheduler.ts +22 -2
  72. package/src/fill/fill-session.ts +14 -3
  73. package/src/generate.ts +28 -22
  74. package/src/grammar.ts +13 -0
  75. package/src/inference/prefill.ts +6 -0
  76. package/src/kv-scheme.ts +22 -0
  77. package/src/kv-store.ts +115 -79
  78. package/src/lab/README.md +5 -1
  79. package/src/lab/paged-kv/paged-attention.ts +117 -0
  80. package/src/lab/paged-kv/paged-kv-rows.ts +33 -5
  81. package/src/lab/paged-kv/paged-kv.ts +108 -42
  82. package/src/md-modules.d.ts +5 -0
  83. package/src/mlx/array.ts +37 -0
  84. package/src/mlx/ffi.ts +19 -0
  85. package/src/mlx/normalized-argmax.ts +116 -0
  86. package/src/mlx/ops.ts +97 -10
  87. package/src/model/batched-turboquant-kv.ts +6 -5
  88. package/src/model/delayed-quantized-kv.ts +3 -3
  89. package/src/model/delayed-rotating-quantized-kv.ts +1 -1
  90. package/src/model/delayed-turboquant-kv.ts +6 -4
  91. package/src/model/factory.ts +26 -0
  92. package/src/model/fingerprint.ts +12 -0
  93. package/src/model/full-prefill-row.ts +1 -1
  94. package/src/model/gemma4-base.ts +25 -6
  95. package/src/model/gemma4.ts +82 -2
  96. package/src/model/kv-attention-view.ts +4 -1
  97. package/src/model/profile.ts +29 -4
  98. package/src/model/quantized-append-attention.ts +31 -0
  99. package/src/model/qwen3-mrope.ts +7 -9
  100. package/src/model/qwen3_5.ts +99 -37
  101. package/src/model/support.ts +8 -0
  102. package/src/model/token-groups.ts +66 -0
  103. package/src/model/trellis-linear.ts +2 -2
  104. package/src/model/turboquant-codec.ts +6 -0
  105. package/src/model/whisper.ts +435 -0
  106. package/src/native/mic_capture.swift +92 -0
  107. package/src/native-pack.ts +6 -5
  108. package/src/pi-web.ts +9 -1
  109. package/src/prompt-cache.ts +265 -43
  110. package/src/runtime-config.ts +1 -5
  111. package/src/safetensors.ts +9 -2
  112. package/src/sampler.ts +5 -4
  113. package/src/serve/audio-routes.ts +323 -0
  114. package/src/serve/chat-request.ts +2 -0
  115. package/src/serve/chat-stage.ts +17 -12
  116. package/src/serve/completion-executor.ts +9 -1
  117. package/src/serve/discovery-routes.ts +17 -1
  118. package/src/serve/generated-token-history.ts +57 -0
  119. package/src/serve/generation-gateway.ts +52 -11
  120. package/src/serve/model-binding.ts +7 -1
  121. package/src/serve/preparation.ts +3 -3
  122. package/src/serve/prompt-response-trace.ts +14 -0
  123. package/src/serve/request-plan.ts +2 -0
  124. package/src/serve/request-prep.ts +38 -21
  125. package/src/serve/transcription-server.ts +64 -0
  126. package/src/serve/transcription-service.ts +502 -0
  127. package/src/server.ts +132 -164
  128. package/src/spec/assistant-rows.ts +1 -0
  129. package/src/spec/deepspec-rows.ts +1 -1
  130. package/src/spec/dflash-source.ts +1 -1
  131. package/src/spec/glm52-mtp-source.ts +1 -0
  132. package/src/spec/ngram-source.ts +27 -5
  133. package/src/spec/projected-draft-rows.ts +2 -2
  134. package/src/spec/qwen-mtp-rows.ts +11 -0
  135. package/src/spec/qwen-mtp-source.ts +3 -0
  136. package/src/spec/serve-loop.ts +5 -3
  137. package/src/spec/source.ts +14 -1
  138. package/src/spec/two-model-rows.ts +1 -0
  139. package/src/spec/two-model.ts +2 -2
  140. package/src/ssd-cache.ts +76 -17
  141. package/src/ssd-durability.ts +20 -22
  142. package/src/storage/host-buffer.ts +24 -0
  143. package/src/storage/kv-writer.ts +83 -0
  144. package/src/storage/kv-writer.worker.js +264 -0
  145. package/src/storage/retention-policy.ts +37 -0
  146. package/src/tiered-prompt-cache.ts +45 -0
  147. package/src/vision/embedder.ts +8 -0
  148. package/src/vision/prompt.ts +101 -65
  149. package/src/vision/qwen3vl-prompt.ts +101 -64
  150. package/src/vision/qwen3vl-tower.ts +11 -0
  151. package/src/vision/siglip.ts +8 -0
  152. package/src/web/app.html +12 -0
  153. package/src/web/app.js +160 -0
  154. package/src/web/src/chat.ts +4 -1
  155. package/src/web/src/composer.ts +3 -0
  156. package/src/web/src/voice.ts +141 -0
  157. package/src/web/sw.js +1 -1
  158. /package/docs/{planning → archive/planning}/release-notes-v0.4.0.md +0 -0
package/src/mlx/ops.ts CHANGED
@@ -472,14 +472,12 @@ export function where(cond: MlxArray, x: MlxArray, y: MlxArray, s: S = gpuStream
472
472
  );
473
473
  }
474
474
 
475
- // arange is built host-side and uploaded. The original mlx_arange binding
476
- // broke once the calling path got JIT-optimized; a standalone repro
477
- // (lab/repro/bun-ffi-f64/) traced the root cause to a Bun bug: after DFG
478
- // tier-up, typed-array reads following a bun:ffi call return stale values
479
- // (the JIT eliminates the load across the native call). Not f64 marshaling
480
- // — args reach C intact. See PLAN.md Phase 4 findings. Reuse large ranges
481
- // across attention layers and sampling, but bound their retained storage:
482
- // causal masks request a new range on every round once context exceeds 64K.
475
+ // The old native arange workaround concerned stale typed-array out-pointer
476
+ // reads after Bun JIT tier-up, not f64 argument marshaling. outArray uses
477
+ // native pointer reads now. Large nonnegative unit-step int32 ranges avoid
478
+ // host construction; small/general integer ranges retain JS conversion,
479
+ // including its wrapping behavior outside int32. Cache storage stays bounded
480
+ // as causal masks request new lengths on successive long-context rounds.
483
481
  const arangeCache = new Map<string, { array: MlxArray; bytes: number }>();
484
482
  const ARANGE_CACHE_MIN = 65536;
485
483
  const ARANGE_CACHE_BYTES = 8 * 1024 * 1024;
@@ -498,8 +496,10 @@ export function arange(start: number, stop: number, step: number, dtype: Dtype,
498
496
  }
499
497
 
500
498
  let arr: MlxArray;
501
- if (dtype === Dtype.int32) {
502
- // host-built int32 leaf (the Bun-f64-FFI-safe path — see note above).
499
+ const nativePositions = n === 0 ||
500
+ (n >= ARANGE_CACHE_MIN && step === 1 && start >= 0 && stop <= 2147483647);
501
+ if (dtype === Dtype.int32 && !nativePositions) {
502
+ // Preserve JS conversion for small/general integer ranges.
503
503
  const data = new Int32Array(n);
504
504
  for (let i = 0; i < n; i++) data[i] = start + i * step;
505
505
  arr = MlxArray.fromInt32(data, [n]);
@@ -1019,3 +1019,90 @@ export function leftShift(a: MlxArray, b: MlxArray, s: S = gpuStream): MlxArray
1019
1019
  export function rightShift(a: MlxArray, b: MlxArray, s: S = gpuStream): MlxArray {
1020
1020
  return new MlxArray(outArray("right_shift", (o) => C.mlx_right_shift(o, a.handle, b.handle, s)));
1021
1021
  }
1022
+
1023
+ // --- Whisper additions (fft / strided framing / pad / reductions) ----------
1024
+
1025
+ /** mx.fft.rfft(a, n, axis) with numpy "backward" normalization. Real input
1026
+ * → complex64 output of length n/2+1 along `axis`. */
1027
+ export function rfft(a: MlxArray, n: number, axis = -1, s: S = gpuStream): MlxArray {
1028
+ return new MlxArray(outArray("fft_rfft", (o) => C.mlx_fft_rfft(o, a.handle, n, axis, 0, s)));
1029
+ }
1030
+
1031
+ /** mx.as_strided(a, shape, strides, offset) — element strides. */
1032
+ export function asStrided(
1033
+ a: MlxArray, shape: number[], strides: number[], offset = 0, s: S = gpuStream,
1034
+ ): MlxArray {
1035
+ const shp = new Int32Array(shape);
1036
+ const str = new BigInt64Array(strides.map((v) => BigInt(v)));
1037
+ return new MlxArray(
1038
+ outArray("as_strided", (o) =>
1039
+ C.mlx_as_strided(o, a.handle, ptr(shp), BigInt(shape.length), ptr(str), BigInt(strides.length), BigInt(offset), s),
1040
+ ),
1041
+ );
1042
+ }
1043
+
1044
+ /** mx.pad(a, [(lo, hi), …]) with constant zero fill (all axes given). */
1045
+ export function pad(a: MlxArray, widths: [number, number][], s: S = gpuStream): MlxArray {
1046
+ const axes = new Int32Array(widths.map((_, i) => i));
1047
+ const lo = new Int32Array(widths.map((w) => w[0]));
1048
+ const hi = new Int32Array(widths.map((w) => w[1]));
1049
+ const zero = scalarLike(0, a);
1050
+ try {
1051
+ return new MlxArray(
1052
+ outArray("pad", (o) =>
1053
+ C.mlx_pad(
1054
+ o, a.handle, ptr(axes), BigInt(widths.length), ptr(lo), BigInt(widths.length),
1055
+ ptr(hi), BigInt(widths.length), zero.handle, ptr(cstr("constant")), s,
1056
+ ),
1057
+ ),
1058
+ );
1059
+ } finally {
1060
+ zero.dispose();
1061
+ }
1062
+ }
1063
+
1064
+ export function log10(a: MlxArray, s: S = gpuStream): MlxArray {
1065
+ return new MlxArray(outArray("log10", (o) => C.mlx_log10(o, a.handle, s)));
1066
+ }
1067
+
1068
+ /** Reduce-max over all elements → 0-d array (keepdims=false). */
1069
+ export function maxAll(a: MlxArray, s: S = gpuStream): MlxArray {
1070
+ return new MlxArray(outArray("max", (o) => C.mlx_max(o, a.handle, false, s)));
1071
+ }
1072
+
1073
+ export function broadcastTo(a: MlxArray, shape: number[], s: S = gpuStream): MlxArray {
1074
+ const buf = new Int32Array(shape);
1075
+ return new MlxArray(
1076
+ outArray("broadcast_to", (o) => C.mlx_broadcast_to(o, a.handle, ptr(buf), BigInt(shape.length), s)),
1077
+ );
1078
+ }
1079
+
1080
+ /** mx.topk(a, k, axis) — the k largest values along axis (unsorted). */
1081
+ export function topkAxis(a: MlxArray, k: number, axis: number, s: S = gpuStream): MlxArray {
1082
+ return new MlxArray(outArray("topk_axis", (o) => C.mlx_topk_axis(o, a.handle, k, axis, s)));
1083
+ }
1084
+
1085
+ export function varAxis(a: MlxArray, axis: number, keepdims: boolean, ddof = 0, s: S = gpuStream): MlxArray {
1086
+ return new MlxArray(
1087
+ outArray("var_axis", (o) => C.mlx_var_axis(o, a.handle, axis, keepdims, ddof, s)),
1088
+ );
1089
+ }
1090
+
1091
+ export function stackAxis(arrays: MlxArray[], axis: number, s: S = gpuStream): MlxArray {
1092
+ const handles = new BigUint64Array(arrays.map((a) => a.handle));
1093
+ const vec = C.mlx_vector_array_new_data(ptr(handles), BigInt(arrays.length));
1094
+ try {
1095
+ return new MlxArray(outArray("stack_axis", (o) => C.mlx_stack_axis(o, vec, axis, s)));
1096
+ } finally {
1097
+ C.mlx_vector_array_free(vec);
1098
+ }
1099
+ }
1100
+
1101
+ export function minimum(a: MlxArray, b: MlxArray, s: S = gpuStream): MlxArray {
1102
+ return new MlxArray(outArray("minimum", (o) => C.mlx_minimum(o, a.handle, b.handle, s)));
1103
+ }
1104
+
1105
+ /** Flat mx.take(a, indices) — gathers from the flattened array. */
1106
+ export function takeFlat(a: MlxArray, indices: MlxArray, s: S = gpuStream): MlxArray {
1107
+ return new MlxArray(outArray("take", (o) => C.mlx_take(o, a.handle, indices.handle, s)));
1108
+ }
@@ -3,7 +3,7 @@ import type { MlxArray } from "../mlx/array";
3
3
  import { KvTensorRows, type KvTensorRowView } from "../backends/mlx/kv-tensor-rows";
4
4
  import { disposeResources } from "../engine/resources";
5
5
  import { TurboQuantKVCache, type BatchableCache, type Cache, type Mask, type RotatedValueAttentionState, type PaddedPrefillCache, type PrefillPadding } from "./gemma4-base";
6
- import { TurboQuantCodec, disposeTurboQuant, type TurboQuantTensor } from "./turboquant-codec";
6
+ import { TurboQuantCodec, turboQuantFusedDecode, disposeTurboQuant, type TurboQuantTensor } from "./turboquant-codec";
7
7
 
8
8
  const fields = ["kIdx", "kScales", "kZeros", "vPacked", "vScales"] as const;
9
9
  function encoded(planes: readonly MlxArray[]): TurboQuantTensor {
@@ -17,8 +17,9 @@ export class BatchedTurboQuantKVCache implements BatchableCache, PaddedPrefillCa
17
17
  readonly #codec: TurboQuantCodec;
18
18
  #reuseOffsets: number[] = [];
19
19
  #headDim: number | null = null;
20
- constructor(readonly kBits: number, readonly vBits: number) {
21
- this.#codec = new TurboQuantCodec(kBits, vBits, process.env.MLX_BUN_TURBOQUANT_FUSED_DECODE === "1");
20
+ constructor(readonly kBits: number, readonly vBits: number,
21
+ readonly fusedDecode = turboQuantFusedDecode()) {
22
+ this.#codec = new TurboQuantCodec(kBits, vBits, fusedDecode);
22
23
  }
23
24
  restorePrefillEnds(ends: readonly number[] | undefined): void { this.#storage.restorePrefillEnds(ends); }
24
25
  preparePrefill(padding: PrefillPadding): void {
@@ -43,7 +44,7 @@ export class BatchedTurboQuantKVCache implements BatchableCache, PaddedPrefillCa
43
44
  get batchSize(): number | null { return this.#storage.batchSize; }
44
45
  get offset(): number { return this.#storage.offset; }
45
46
  get ropeOffsetArr(): MlxArray | undefined { return this.#storage.ropeOffsetArr; }
46
- makeEmptyBatch(): BatchedTurboQuantKVCache { return new BatchedTurboQuantKVCache(this.kBits, this.vBits); }
47
+ makeEmptyBatch(): BatchedTurboQuantKVCache { return new BatchedTurboQuantKVCache(this.kBits, this.vBits, this.fusedDecode); }
47
48
  bytesPerToken(): number { return this.#storage.bytesPerToken(); }
48
49
  projectedBytes(tokens: number): number { return this.bytesPerToken() * tokens; }
49
50
  makeMask(tokens: number, window: number | null): Mask { return this.#storage.makeMask(tokens, window); }
@@ -84,7 +85,7 @@ export class BatchedTurboQuantKVCache implements BatchableCache, PaddedPrefillCa
84
85
  }
85
86
  state(): MlxArray[] { return this.#storage.planes; }
86
87
  extractRow(row: number): TurboQuantKVCache {
87
- const result = new TurboQuantKVCache(this.kBits, this.vBits), count = this.rowOffsets[row]!;
88
+ const result = new TurboQuantKVCache(this.kBits, this.vBits, this.fusedDecode), count = this.rowOffsets[row]!;
88
89
  if (count) result.restoreState(encoded(this.#storage.extractRow(row)), count, this.#headDim!);
89
90
  result.minimumReusableOffset = this.#reuseOffsets[row] ?? 0;
90
91
  return result;
@@ -14,7 +14,7 @@ import { QuantizedKVCache, type Cache, type Mask, type KvAttentionState, type Kv
14
14
  export class DelayedQuantizedKVCache extends FullTransitioningKvRows<BatchedQuantizedKVCache> implements KvAttentionState {
15
15
  constructor(readonly groupSize: number, readonly bits: number, readonly start: number,
16
16
  readonly maintain: (rows: Cache[]) => void, row?: Cache) {
17
- super({ signature: `kv:delayed-quant:${bits}:${groupSize}:${start}`, maintain,
17
+ super({ signature: `kv:delayed-quant:${bits}:${groupSize}:${start}`, conversionOffset: start, maintain,
18
18
  converted: row => row instanceof QuantizedKVCache,
19
19
  makeLayout: () => new BatchedQuantizedKVCache(groupSize, bits) }, row);
20
20
  }
@@ -44,7 +44,7 @@ export class DelayedQuantizedKVCache extends FullTransitioningKvRows<BatchedQuan
44
44
  this.syncPositions(true);
45
45
  } catch (error) { for (const view of views) view.dispose(); throw error; }
46
46
  return {
47
- attend(q, scale, mask) {
47
+ attend(q, scale, mask, independentPositions) {
48
48
  const outputs: MlxArray[] = [];
49
49
  try {
50
50
  for (const [row, view] of views.entries()) {
@@ -57,7 +57,7 @@ export class DelayedQuantizedKVCache extends FullTransitioningKvRows<BatchedQuan
57
57
  end[end.length - 1] = pads[row]! + lengths[row]!;
58
58
  }
59
59
  using arr = mask.arr ? mask.arr.slice(start!, end!) : null;
60
- outputs.push(view.attend(qr, scale, { mode: mask.mode, arr }));
60
+ outputs.push(view.attend(qr, scale, { mode: mask.mode, arr }, independentPositions));
61
61
  }
62
62
  return outputs.length === 1 ? ops.contiguous(outputs[0]!) : ops.concatAxis(outputs, 0);
63
63
  } finally { for (const output of outputs) output.dispose(); }
@@ -11,7 +11,7 @@ import { captureKvAttention, combineKvDonorAttention } from "./kv-attention-view
11
11
  export class DelayedRotatingQuantizedKVCache extends SpeculativeTransitioningKvRows<RotatingAffineLayout> implements KvAttentionState, PaddedPrefillCache {
12
12
  constructor(readonly maxSize: number, readonly groupSize: number, readonly bits: number, readonly start: number,
13
13
  readonly maintain: (rows: Cache[]) => void, row?: Cache, readonly speculative = false) {
14
- super({ signature: `kv:delayed-rotating-quant:${maxSize}:${bits}:${groupSize}:${start}`, maintain,
14
+ super({ signature: `kv:delayed-rotating-quant:${maxSize}:${bits}:${groupSize}:${start}`, conversionOffset: start, maintain,
15
15
  converted: row => row instanceof AlignedRotatingCache && row.inner instanceof BatchedRotatingQuantCache,
16
16
  makeLayout: () => speculative ? new SpeculativeRotatingAffineLayout(maxSize, groupSize, bits) : new RotatingAffineLayout(maxSize, groupSize, bits), prepareRows: alignRotatingRows,
17
17
  packRows: (layout, rows) => layout.adoptAlignedRows(rows as readonly AlignedRotatingCache[]),
@@ -1,3 +1,4 @@
1
+ import { turboQuantFusedDecode } from "./turboquant-codec";
1
2
  import { captureFullKvDonorRows } from "../backends/mlx/full-kv-row-donor";
2
3
  import { decodedKvDonorAttention } from "./decoded-kv-donor";
3
4
  import { appendFullKvRows } from "../backends/mlx/full-kv-row-append";
@@ -14,10 +15,11 @@ import { FullTransitioningKvRows } from "../backends/mlx/full-transitioning-kv-r
14
15
  export class DelayedTurboQuantKVCache extends FullTransitioningKvRows<BatchedTurboQuantKVCache> implements RotatedValueAttentionState {
15
16
  #rotated: boolean[] = [];
16
17
  constructor(readonly kBits: number, readonly vBits: number, readonly start: number,
17
- readonly maintain: (rows: Cache[]) => void, row?: Cache) {
18
- super({ signature: `kv:delayed-turboquant:${kBits}:${vBits}:${start}`, maintain,
18
+ readonly maintain: (rows: Cache[]) => void, row?: Cache,
19
+ readonly fusedDecode = turboQuantFusedDecode()) {
20
+ super({ signature: `kv:delayed-turboquant:${kBits}:${vBits}:${start}`, conversionOffset: start, maintain,
19
21
  converted: row => row instanceof TurboQuantKVCache,
20
- makeLayout: () => new BatchedTurboQuantKVCache(kBits, vBits) }, row);
22
+ makeLayout: () => new BatchedTurboQuantKVCache(kBits, vBits, fusedDecode) }, row);
21
23
  }
22
24
  captureDonorRows(): import("./gemma4-base").KvDonorRows {
23
25
  return this.packed?.captureDonorRows() ?? captureFullKvDonorRows(this.rows, this.leftPad, this.offset);
@@ -51,5 +53,5 @@ export class DelayedTurboQuantKVCache extends FullTransitioningKvRows<BatchedTur
51
53
  return ops.where(mask, restored, output);
52
54
  };
53
55
  }
54
- makeEmptyBatch(): DelayedTurboQuantKVCache { return new DelayedTurboQuantKVCache(this.kBits, this.vBits, this.start, this.maintain); }
56
+ makeEmptyBatch(): DelayedTurboQuantKVCache { return new DelayedTurboQuantKVCache(this.kBits, this.vBits, this.start, this.maintain, undefined, this.fusedDecode); }
55
57
  }
@@ -23,6 +23,7 @@ import {
23
23
  type Glm52MemoryPlan,
24
24
  } from "./glm52-memory";
25
25
  import { UniversalDenseModel } from "./universal/dense";
26
+ import { WhisperModel } from "./whisper";
26
27
  import { genericArgsFor } from "./universal/archs";
27
28
  import {
28
29
  ModelImplementationRegistry,
@@ -134,6 +135,26 @@ export async function openGlm52RuntimeModel(
134
135
  return { model, plan };
135
136
  }
136
137
 
138
+ /** Open a Whisper checkpoint (encoder-decoder speech model) through the same
139
+ * profile resolution as text models. Whisper never enters the chat loop, so
140
+ * it is not a RuntimeModel: the transcription engine (src/audio/whisper-*)
141
+ * owns it. */
142
+ export async function openWhisperModel(
143
+ modelDir: string, options: { readonly profiles?: ResolveModelProfileOptions } = {},
144
+ ): Promise<{ model: WhisperModel; profile: ResolvedModelProfile; config: ModelConfig }> {
145
+ const config = await loadModelConfig(modelDir);
146
+ const profile = resolveModelProfile(config, options.profiles);
147
+ if (profile.profile.execution.graph !== "whisper")
148
+ throw new Error(`${modelDir} is not a Whisper checkpoint (profile ${profile.profile.id})`);
149
+ const weights = await Weights.open(modelDir);
150
+ try {
151
+ return { model: new WhisperModel(weights, config), profile, config };
152
+ } catch (error) {
153
+ weights.dispose();
154
+ throw error;
155
+ }
156
+ }
157
+
137
158
  /**
138
159
  * Artifact-aware construction. GLM-5.2's Colibri snapshot has no ordinary
139
160
  * model.safetensors.index.json, so it must bypass Weights.open and use its
@@ -155,6 +176,11 @@ export async function openModel<Model>(
155
176
  throw new Error("custom Colibri implementations require a streamed loader binding; refusing to fall back");
156
177
  return (await openGlm52RuntimeModel(modelDir, options)).model;
157
178
  }
179
+ if (profile.profile.execution.loop === "encoder-decoder")
180
+ throw new Error(
181
+ `${modelDir} is a speech model (${profile.profile.id}); open it with openWhisperModel ` +
182
+ "or serve it as a transcription model — it has no chat/completion loop",
183
+ );
158
184
  // Resolve before opening weights; missing or incompatible code allocates nothing.
159
185
  const implementation = (options.implementations ?? MLX_MODEL_IMPLEMENTATIONS).select(config, profile);
160
186
  const weights = await Weights.open(modelDir);
@@ -31,3 +31,15 @@ export function configFingerprint(config: ModelConfig): string {
31
31
  h.update(payload);
32
32
  return h.digest("hex").slice(0, 16);
33
33
  }
34
+
35
+ /** Exact immutable model-component identity, calculated once while its owner
36
+ * holds the native execution lease. Names/shapes/dtypes disambiguate layouts. */
37
+ export function tensorFingerprint(entries: Iterable<readonly [string,
38
+ Pick<import("../mlx/array").MlxArray, "dtype" | "shape" | "rawBytesView">]>): string {
39
+ const hash = new Bun.CryptoHasher("sha256");
40
+ for (const [name, tensor] of [...entries].sort(([a], [b]) => a.localeCompare(b))) {
41
+ hash.update(JSON.stringify([name, tensor.dtype, tensor.shape]));
42
+ hash.update(tensor.rawBytesView());
43
+ }
44
+ return hash.digest("hex");
45
+ }
@@ -65,7 +65,7 @@ export class FullPrefillRow implements Cache {
65
65
  { packed: planes[3]!, scales: planes[4]!, biases: planes[5]! }, offset);
66
66
  return result;
67
67
  }
68
- const result = new TurboQuantKVCache(source.kBits, source.vBits);
68
+ const result = new TurboQuantKVCache(source.kBits, source.vBits, source.fusedDecode);
69
69
  if (planes.length) result.restoreState({ kIdx: planes[0]!, kScales: planes[1]!, kZeros: planes[2]!,
70
70
  vPacked: planes[3]!, vScales: planes[4]! }, offset, source.headDim!);
71
71
  return result;
@@ -12,13 +12,13 @@ import type { ModelConfig } from "../config";
12
12
  import { quantFor } from "../config";
13
13
  import type { Weights } from "../weights";
14
14
  import { MlxArray } from "../mlx/array";
15
- import { Dtype } from "../mlx/ffi";
15
+ import { Dtype, deviceArchitecture } from "../mlx/ffi";
16
16
  import * as ops from "../mlx/ops";
17
17
  import { quantizedMatmulRows } from "../mlx/quantized-rows";
18
18
  import { expertOffloadArray } from "../expert-offload";
19
19
  import * as tq from "../mlx/turboquant-ops";
20
20
  import type { KvCodec } from "../backends/mlx/kv-codec";
21
- import { TurboQuantCodec, disposeTurboQuant, type TurboQuantTensor } from "./turboquant-codec";
21
+ import { TurboQuantCodec, turboQuantFusedDecode, disposeTurboQuant, type TurboQuantTensor } from "./turboquant-codec";
22
22
  export { disposeTurboQuant, type TurboQuantTensor } from "./turboquant-codec";
23
23
  import { runtimeValue } from "../runtime-config";
24
24
 
@@ -297,7 +297,8 @@ export interface RotatedValueAttentionState {
297
297
  * again. It owns its tensor handles independently of later cache membership or
298
298
  * precision changes. Query/mask inputs are borrowed; outputs are owned. */
299
299
  export interface KvAttentionView {
300
- attend(q: MlxArray, scale: number, mask: Mask): MlxArray;
300
+ /** Qualified committed spans retain the single-position reduction order. */
301
+ attend(q: MlxArray, scale: number, mask: Mask, independentPositions?: boolean): MlxArray;
301
302
  dispose(): void;
302
303
  }
303
304
 
@@ -325,6 +326,8 @@ export interface KvDonorAttention extends KvAttentionView {
325
326
  }
326
327
 
327
328
  export interface Cache {
329
+ /** Maximum committed positions before this state changes precision. */
330
+ maxAppendTokens?(): number;
328
331
  captureDonorRows?(): KvDonorRows;
329
332
  captureDonorAttention?(): KvDonorAttention;
330
333
  /** A preparation method commits each request's own precision boundary.
@@ -1671,8 +1674,9 @@ export class TurboQuantKVCache implements Cache {
1671
1674
  /** Experimental operation selection, captured once for this cache. */
1672
1675
  readonly #codec: KvCodec<TurboQuantTensor>;
1673
1676
 
1674
- constructor(readonly kBits: number, readonly vBits: number) {
1675
- this.#codec = new TurboQuantCodec(kBits, vBits, process.env.MLX_BUN_TURBOQUANT_FUSED_DECODE === "1");
1677
+ constructor(readonly kBits: number, readonly vBits: number,
1678
+ readonly fusedDecode = turboQuantFusedDecode()) {
1679
+ this.#codec = new TurboQuantCodec(kBits, vBits, fusedDecode);
1676
1680
  }
1677
1681
 
1678
1682
  captureDonorRows(): KvDonorRows {
@@ -1940,8 +1944,23 @@ export function quantizedSdpaUnfused(
1940
1944
  vT = expand(vq);
1941
1945
  }
1942
1946
 
1943
- let scores = ops.quantizedMatmulQT(queries, kT, true, groupSize, bits);
1947
+ // M4 Pro's native key matvec retains exact arithmetic when three GQA
1948
+ // heads share a batch. Restore score geometry before softmax/value work.
1949
+ const groupHeads = B <= 2 && H === 24 && KV === 4 && L === 3 && D === 256 &&
1950
+ N >= 8192 && groupSize === 64 && bits === 4 &&
1951
+ (q.dtype === Dtype.bfloat16 || q.dtype === Dtype.float32) &&
1952
+ deviceArchitecture() === "applegpu_g16s";
1953
+ let keyQueries = queries;
1954
+ if (groupHeads) {
1955
+ keyQueries = ops.reshape(queries, [B, KV, 2, 9, D]);
1956
+ owned.push(keyQueries);
1957
+ }
1958
+ let scores = ops.quantizedMatmulQT(keyQueries, kT, true, groupSize, bits);
1944
1959
  owned.push(scores);
1960
+ if (groupHeads) {
1961
+ scores = ops.reshape(scores, [B, KV, nRep, L, N]);
1962
+ owned.push(scores);
1963
+ }
1945
1964
 
1946
1965
  let maskArr: MlxArray | null = null;
1947
1966
  let ownsMask = false;
@@ -52,11 +52,12 @@ import {
52
52
  captureRopeOffsets,
53
53
  type LoraWeights,
54
54
  } from "./gemma4-base";
55
+ import { mapPackedTokens, type TokenGroup } from "./token-groups";
55
56
  import { Checkpoint } from "../mlx/checkpoint";
56
57
  import { flashAttention, getTrainingAttn, flashSupported } from "./flash-attention";
57
58
  import { unrotateValues as tqUnrotateValues } from "../mlx/turboquant-ops";
58
59
  import { CompiledFunction } from "../mlx/compile";
59
- import { flagOn } from "../runtime-config";
60
+ import { runtimeFlag } from "../runtime-config";
60
61
 
61
62
  /** Verbatim port of mlx_lm/models/gemma4_text.py:
62
63
  * `@partial(mx.compile, shapeless=True) def geglu(gate, x): return nn.gelu_approx(gate) * x`
@@ -77,7 +78,7 @@ import { flagOn } from "../runtime-config";
77
78
  * ~9-op path. `MLX_BUN_COMPILED_GEGLU=0` (--compiled-activations off) selects the
78
79
  * uncompiled composition — same L1 parity, slower (the A/B opt-out). */
79
80
  export function compiledGegluActive(): boolean {
80
- return flagOn("MLX_BUN_COMPILED_GEGLU", true);
81
+ return runtimeFlag("MLX_BUN_COMPILED_GEGLU", true);
81
82
  }
82
83
 
83
84
  let _gegluClosure: CompiledFunction | null = null;
@@ -1020,6 +1021,85 @@ export class Gemma4Model {
1020
1021
  return h;
1021
1022
  }
1022
1023
 
1024
+ /** Mixed token execution: attention retains each group's cache, positions and
1025
+ * SDPA shape; feed-forward work packs only real tokens. Kernel selection for
1026
+ * packed matmuls can differ from a solo GEMV, so this is an explicit Lab port
1027
+ * until its matching packed oracle and serving comparison are accepted. */
1028
+ forwardHiddenMixed(work: readonly TokenGroup[]): MlxArray[] {
1029
+ if (work.length === 1 && !work[0]!.captureLayer) return [this.forwardHidden(work[0]!.ids, work[0]!.cache)];
1030
+ const groups: Array<{ ids: MlxArray; cache: Cache[]; h: MlxArray;
1031
+ masks: Map<string, Mask>; perLayer: MlxArray | null; intermediates: (SharedKv | null)[] }> = [];
1032
+ const results: MlxArray[] = [];
1033
+ const packedMlp = runtimeFlag("MLX_BUN_MIXED_PACKED_MLP", true) && !work.some(group => group.preserveTokenGeometry);
1034
+ try {
1035
+ for (const { ids, cache } of work) {
1036
+ const group = { ids, cache, h: this.embed.encode(ids), masks: new Map<string, Mask>(),
1037
+ perLayer: null as MlxArray | null, intermediates: Array<SharedKv | null>(this.layers.length).fill(null) };
1038
+ groups.push(group);
1039
+ group.h = disposing(group.h, ops.mulScalar(group.h, this.embedScale));
1040
+ for (let i = 0; i < this.numDonors; i++) {
1041
+ const type = this.layers[i]!.layerType;
1042
+ if (!group.masks.has(type)) group.masks.set(type,
1043
+ cache[i]!.makeMask(ids.shape[1]!, type === "sliding_attention" ? this.windowSize : null));
1044
+ }
1045
+ if (this.perLayerWidth > 0) group.perLayer = this.computePerLayerInputs(ids, group.h);
1046
+ }
1047
+ for (let i = 0; i < this.layers.length; i++) {
1048
+ const layer = this.layers[i]!, ci = this.cacheIndex[i]!;
1049
+ const mids: MlxArray[] = [], inputs: MlxArray[] = [];
1050
+ try {
1051
+ for (const group of groups) {
1052
+ const sharedIn = ci === -1 ? group.intermediates[this.previousKvs[i]!]! : null;
1053
+ const { h, shared } = layer.forwardAttn(group.h, group.masks.get(layer.layerType)!,
1054
+ ci === -1 ? null : group.cache[ci]!, sharedIn);
1055
+ mids.push(h); group.intermediates[i] = shared;
1056
+ if (group.perLayer) {
1057
+ const [B, L] = group.ids.shape;
1058
+ using part = group.perLayer.slice([0, 0, i, 0], [B!, L!, i + 1, this.perLayerWidth]);
1059
+ inputs.push(ops.reshape(part, [B!, L!, this.perLayerWidth]));
1060
+ }
1061
+ }
1062
+ // Per-layer inputs use the same token order as the hidden states.
1063
+ let packedInput: MlxArray | null = null;
1064
+ if (packedMlp && inputs.length) {
1065
+ const flat = inputs.map(input => ops.reshape(input, [1, -1, this.perLayerWidth]));
1066
+ try { packedInput = ops.concatAxis(flat, 1); }
1067
+ finally { for (const input of flat) input.dispose(); }
1068
+ }
1069
+ using pli = packedInput;
1070
+ const outputs: MlxArray[] = [];
1071
+ try {
1072
+ if (packedMlp) outputs.push(...mapPackedTokens(mids, packed => layer.forwardMlp(packed, pli)));
1073
+ else for (const [row, mid] of mids.entries()) outputs.push(layer.forwardMlp(mid, inputs[row] ?? null));
1074
+ } catch (error) { for (const output of outputs) output.dispose(); throw error; }
1075
+ for (const [index, group] of groups.entries()) {
1076
+ group.h.dispose(); group.h = outputs[index]!;
1077
+ }
1078
+ for (const [index, group] of groups.entries()) work[index]!.captureLayer?.(i, group.h);
1079
+ } finally {
1080
+ for (const mid of mids) mid.dispose();
1081
+ for (const input of inputs) input.dispose();
1082
+ }
1083
+ }
1084
+ for (const [index, group] of groups.entries()) {
1085
+ const hidden = this.finalNorm.forward(group.h); results.push(hidden);
1086
+ work[index]!.captureLayer?.(this.layers.length, hidden);
1087
+ }
1088
+ return results;
1089
+ } catch (error) { for (const result of results) result.dispose(); throw error; }
1090
+ finally {
1091
+ for (const group of groups) {
1092
+ group.h.dispose(); group.perLayer?.dispose();
1093
+ for (let i = 0; i < this.numDonors; i++) {
1094
+ const shared = group.intermediates[i];
1095
+ if (shared) Gemma4Model.disposeSharedKv(shared);
1096
+ }
1097
+ for (const mask of group.masks.values()) mask.arr?.dispose();
1098
+ }
1099
+ }
1100
+ }
1101
+
1102
+
1023
1103
  // --- Segmented-backward support (docs/design/orpo-training.md
1024
1104
  // §4 Phase B). These are ADDITIVE — forwardLayers is untouched. The segmented
1025
1105
  // driver builds masks + per-layer inputs ONCE, then drives runLayerRange per
@@ -2,6 +2,7 @@ import type { MlxArray } from "../mlx/array";
2
2
  import * as ops from "../mlx/ops";
3
3
  import type { Cache, KvAttentionView, KvDonorAttention } from "./gemma4-base";
4
4
  import { quantizedSdpa, disposeTriple } from "./gemma4-base";
5
+ import { quantizedAppendAttention } from "./quantized-append-attention";
5
6
 
6
7
  export function captureKvDonorAttention(cache: Cache): KvDonorAttention {
7
8
  if (cache.captureDonorAttention) return cache.captureDonorAttention();
@@ -31,7 +32,9 @@ export function captureKvAttention(cache: Cache, k: MlxArray, v: MlxArray): KvAt
31
32
  const { groupSize, bits } = quantized;
32
33
  const [keys, values] = quantized.updateAndFetchQuantized(k, v);
33
34
  return {
34
- attend: (q, scale, mask) => quantizedSdpa(q, keys, values, scale, mask, groupSize, bits),
35
+ attend: (q, scale, mask, independentPositions) => independentPositions && q.shape[2]! > 1
36
+ ? quantizedAppendAttention(q, keys, values, scale, groupSize, bits)
37
+ : quantizedSdpa(q, keys, values, scale, mask, groupSize, bits),
35
38
  dispose() { disposeTriple(keys); disposeTriple(values); },
36
39
  };
37
40
  }
@@ -7,6 +7,7 @@ import {
7
7
  isQwen35Config,
8
8
  isQwen3Config,
9
9
  isQwen3MoeConfig,
10
+ isWhisperConfig,
10
11
  } from "./support";
11
12
  import { GENERATED } from "./generated";
12
13
  import { GENERIC_MODEL_TYPES, genericArgsFor, remapModelType } from "./universal/archs";
@@ -16,6 +17,7 @@ export const ENGINE_CAPABILITIES = Object.freeze([
16
17
  "colibri-container",
17
18
  "diffusion",
18
19
  "diffusion-gemma-graph",
20
+ "encoder-decoder",
19
21
  "gemma4-graph",
20
22
  "generated-graph",
21
23
  "glm5.2-graph",
@@ -30,6 +32,7 @@ export const ENGINE_CAPABILITIES = Object.freeze([
30
32
  "streamed-experts",
31
33
  "universal-dense-graph",
32
34
  "vision-sidecar",
35
+ "whisper-graph",
33
36
  ] as const);
34
37
 
35
38
  export type EngineCapability = typeof ENGINE_CAPABILITIES[number];
@@ -37,6 +40,7 @@ export type FidelityTier = "l1" | "l2" | "l3";
37
40
 
38
41
  export type FidelityTarget =
39
42
  | Readonly<{ tier: "l1"; oracle: "mlx-lm"; claim: "bit-exact" }>
43
+ | Readonly<{ tier: "l1"; oracle: "mlx-whisper"; claim: "bit-exact" }>
40
44
  | Readonly<{ tier: "l2"; oracle: "mlx-optiq"; claim: "bit-exact" }>
41
45
  | Readonly<{ tier: "l3"; oracle: null; claim: "measured" }>;
42
46
 
@@ -49,8 +53,9 @@ export type ModelGraph =
49
53
  | "qwen3-moe"
50
54
  | "diffusion-gemma"
51
55
  | "glm5.2"
52
- | "universal-dense";
53
- export type GenerationLoop = "autoregressive" | "diffusion";
56
+ | "universal-dense"
57
+ | "whisper";
58
+ export type GenerationLoop = "autoregressive" | "diffusion" | "encoder-decoder";
54
59
  export type ModelSpecialization = "artifact" | "dedicated" | "generated" | "generic";
55
60
 
56
61
  export interface ModelExecutionComposition {
@@ -105,6 +110,11 @@ export interface ResolveModelProfileOptions {
105
110
  const L1: FidelityTarget = Object.freeze({
106
111
  tier: "l1", oracle: "mlx-lm", claim: "bit-exact",
107
112
  });
113
+ /** L1 for speech: the capability's own reference implementation (mlx-whisper)
114
+ * plays the mlx-lm role — no mlx-lm arm exists for Whisper. */
115
+ const L1_WHISPER: FidelityTarget = Object.freeze({
116
+ tier: "l1", oracle: "mlx-whisper", claim: "bit-exact",
117
+ });
108
118
  const L2: FidelityTarget = Object.freeze({
109
119
  tier: "l2", oracle: "mlx-optiq", claim: "bit-exact",
110
120
  });
@@ -231,6 +241,14 @@ const FAMILY_PROFILES = {
231
241
  loader: "colibri", graph: "glm5.2", loop: "autoregressive", specialization: "dedicated",
232
242
  },
233
243
  }),
244
+ whisper: freezeProfile({
245
+ id: "whisper-dedicated",
246
+ fidelity: L1_WHISPER,
247
+ requiredCapabilities: ["safetensors", "encoder-decoder", "whisper-graph"],
248
+ execution: {
249
+ loader: "safetensors", graph: "whisper", loop: "encoder-decoder", specialization: "dedicated",
250
+ },
251
+ }),
234
252
  universal: freezeProfile({
235
253
  id: "universal-dense",
236
254
  fidelity: L1,
@@ -263,6 +281,7 @@ function familyProfile(config: ModelConfig, fingerprint: string): ModelProfile {
263
281
  if (isQwen35Config(config)) return FAMILY_PROFILES.qwen35;
264
282
  if (isQwen3MoeConfig(config)) return FAMILY_PROFILES.qwen3Moe;
265
283
  if (isQwen3Config(config)) return FAMILY_PROFILES.qwen3;
284
+ if (isWhisperConfig(config)) return FAMILY_PROFILES.whisper;
266
285
  if (config.modelType.startsWith("gemma4"))
267
286
  return GENERATED.has(fingerprint) ? FAMILY_PROFILES.gemma4Generated : FAMILY_PROFILES.gemma4;
268
287
  if (genericArgsFor(config)) return FAMILY_PROFILES.universal;
@@ -271,7 +290,7 @@ function familyProfile(config: ModelConfig, fingerprint: string): ModelProfile {
271
290
  throw new Error(
272
291
  `unsupported model_type "${config.modelType}"` +
273
292
  (arch !== config.modelType ? ` (mlx-lm remaps it to "${arch}")` : "") +
274
- ` — targeted: gemma4*, diffusion_gemma, glm_moe_dsa, qwen3_5, qwen3, qwen3_moe, MiniCPM5;` +
293
+ ` — targeted: gemma4*, diffusion_gemma, glm_moe_dsa, qwen3_5, qwen3, qwen3_moe, whisper, MiniCPM5;` +
275
294
  ` generic (Tier-0): ${[...GENERIC_MODEL_TYPES].sort().join(", ")}`,
276
295
  );
277
296
  }
@@ -301,6 +320,7 @@ const GRAPH_METADATA: Readonly<Record<ModelGraph, GraphMetadata>> = Object.freez
301
320
  accepts: isGlm52Config,
302
321
  capabilities: ["glm5.2-graph", "streamed-experts"],
303
322
  },
323
+ "whisper": { accepts: isWhisperConfig, capabilities: ["whisper-graph"] },
304
324
  "universal-dense": {
305
325
  accepts: (config) => {
306
326
  try { return genericArgsFor(config) !== null; } catch { return false; }
@@ -318,6 +338,7 @@ function executionCapabilities(profile: ModelProfile): EngineCapability[] {
318
338
  ? "colibri-container"
319
339
  : "safetensors"];
320
340
  if (profile.execution.loop === "diffusion") required.push("diffusion");
341
+ else if (profile.execution.loop === "encoder-decoder") required.push("encoder-decoder");
321
342
  else required.push("autoregressive");
322
343
  required.push(...GRAPH_METADATA[profile.execution.graph].capabilities);
323
344
  if (profile.execution.specialization === "generated") required.push("generated-graph");
@@ -337,6 +358,8 @@ function validateProfile(profile: ModelProfile): void {
337
358
  const fidelityOk =
338
359
  (profile.fidelity.tier === "l1" && profile.fidelity.oracle === "mlx-lm" &&
339
360
  profile.fidelity.claim === "bit-exact") ||
361
+ (profile.fidelity.tier === "l1" && profile.fidelity.oracle === "mlx-whisper" &&
362
+ profile.fidelity.claim === "bit-exact" && profile.execution.graph === "whisper") ||
340
363
  (profile.fidelity.tier === "l2" && profile.fidelity.oracle === "mlx-optiq" &&
341
364
  profile.fidelity.claim === "bit-exact") ||
342
365
  (profile.fidelity.tier === "l3" && profile.fidelity.oracle === null &&
@@ -345,7 +368,9 @@ function validateProfile(profile: ModelProfile): void {
345
368
  const expectedLoader = profile.execution.graph === "glm5.2" ? "colibri" : "safetensors";
346
369
  const expectedLoop = profile.execution.graph === "diffusion-gemma"
347
370
  ? "diffusion"
348
- : "autoregressive";
371
+ : profile.execution.graph === "whisper"
372
+ ? "encoder-decoder"
373
+ : "autoregressive";
349
374
  if (profile.execution.loader !== expectedLoader || profile.execution.loop !== expectedLoop)
350
375
  throw new Error(
351
376
  `model profile ${profile.id} has an invalid execution composition for ` +