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
package/src/config.ts CHANGED
@@ -38,6 +38,13 @@ export interface TextConfig {
38
38
  numExperts: number;
39
39
  topKExperts: number;
40
40
  moeIntermediateSize: number;
41
+ /** Qwen3-MoE sparse-block gating (qwen3_moe): a layer is MoE unless it is
42
+ * in `mlpOnlyLayers` and `(idx+1) % decoderSparseStep == 0` selects it.
43
+ * `normTopkProb` renormalizes the top-k gate weights. Defaults are inert
44
+ * for non-qwen3_moe families. */
45
+ decoderSparseStep: number;
46
+ mlpOnlyLayers: number[];
47
+ normTopkProb: boolean;
41
48
  /** Qwen3.5 hybrid (gated-DeltaNet) geometry; 0/false for other families. */
42
49
  linearNumValueHeads: number;
43
50
  linearNumKeyHeads: number;
@@ -77,6 +84,37 @@ export interface KvQuantSpec {
77
84
  groupSize: number;
78
85
  }
79
86
 
87
+ /** TurboQuant scheme (docs/design/turboquant-kv.md): rotation-based KV
88
+ * quantization, a distinct axis from the uniform/per-layer affine kvQuant
89
+ * above (composes with it in principle; v1 ships as a standalone CLI-only
90
+ * runtime lever, same class as uniform kvBits — see
91
+ * src/generate.ts maybeQuantizeKv). Valid kBits: {2,4,5,8}; vBits:
92
+ * {2,3,4,5,8} (docs/design/turboquant-kv.md's supported Lloyd-Max tables). */
93
+ export interface TurboQuantScheme {
94
+ kBits: number;
95
+ vBits: number;
96
+ }
97
+
98
+ export const TURBOQUANT_VALID_KBITS = [2, 4, 5, 8] as const;
99
+ export const TURBOQUANT_VALID_VBITS = [2, 3, 4, 5, 8] as const;
100
+
101
+ /** Parse `turbo` / `turbo:k<bits>v<bits>` (default k8v3). Returns null for
102
+ * non-turbo strings (caller falls through to the uniform/config parser);
103
+ * throws Error with a user-facing message for a malformed turbo spec. */
104
+ export function parseTurboQuantScheme(raw: string): TurboQuantScheme | null {
105
+ if (raw !== "turbo" && !raw.startsWith("turbo:")) return null;
106
+ if (raw === "turbo") return { kBits: 8, vBits: 3 };
107
+ const m = /^turbo:k(\d+)v(\d+)$/.exec(raw);
108
+ if (!m) throw new Error(`--kv-quant turbo spec must look like "turbo:k<bits>v<bits>" (got "${raw}")`);
109
+ const kBits = Number(m[1]);
110
+ const vBits = Number(m[2]);
111
+ if (!(TURBOQUANT_VALID_KBITS as readonly number[]).includes(kBits))
112
+ throw new Error(`--kv-quant turbo: kBits must be one of ${TURBOQUANT_VALID_KBITS.join(",")} (got ${kBits})`);
113
+ if (!(TURBOQUANT_VALID_VBITS as readonly number[]).includes(vBits))
114
+ throw new Error(`--kv-quant turbo: vBits must be one of ${TURBOQUANT_VALID_VBITS.join(",")} (got ${vBits})`);
115
+ return { kBits, vBits };
116
+ }
117
+
80
118
  export interface ModelConfig {
81
119
  modelDir: string;
82
120
  modelType: string;
@@ -93,6 +131,11 @@ export interface ModelConfig {
93
131
  function parseRope(raw: Record<string, any>): Record<string, RopeParams> {
94
132
  const out: Record<string, RopeParams> = {};
95
133
  for (const [kind, p] of Object.entries(raw ?? {})) {
134
+ // transformers ≥5.10 configs mix flat scalars into the per-attention-type
135
+ // map (`rope_parameters: { full_attention: {...}, rope_theta: null,
136
+ // rope_type: "default", ... }` — the DeepSpec drafter ships this shape);
137
+ // only object entries are attention-type params.
138
+ if (!p || typeof p !== "object") continue;
96
139
  out[kind] = {
97
140
  ropeTheta: p.rope_theta,
98
141
  ropeType: p.rope_type ?? "default",
@@ -103,7 +146,7 @@ function parseRope(raw: Record<string, any>): Record<string, RopeParams> {
103
146
  return out;
104
147
  }
105
148
 
106
- function parseQuantization(raw: Record<string, any> | undefined): QuantizationConfig | null {
149
+ export function parseQuantization(raw: Record<string, any> | undefined): QuantizationConfig | null {
107
150
  if (!raw) return null;
108
151
  const def: QuantSpec = {
109
152
  bits: raw.bits,
@@ -215,8 +258,12 @@ export async function loadModelConfig(modelDir: string): Promise<ModelConfig> {
215
258
  numKvSharedLayers: t.num_kv_shared_layers ?? 0,
216
259
  enableMoeBlock: t.enable_moe_block ?? false,
217
260
  numExperts: t.num_experts ?? 0,
218
- topKExperts: t.top_k_experts ?? 0,
261
+ // gemma4 uses `top_k_experts`; qwen3_moe uses `num_experts_per_tok`.
262
+ topKExperts: t.top_k_experts ?? t.num_experts_per_tok ?? 0,
219
263
  moeIntermediateSize: t.moe_intermediate_size ?? 0,
264
+ decoderSparseStep: t.decoder_sparse_step ?? 1,
265
+ mlpOnlyLayers: t.mlp_only_layers ?? [],
266
+ normTopkProb: t.norm_topk_prob ?? false,
220
267
  linearNumValueHeads: t.linear_num_value_heads ?? 0,
221
268
  linearNumKeyHeads: t.linear_num_key_heads ?? 0,
222
269
  linearKeyHeadDim: t.linear_key_head_dim ?? 0,
package/src/download.ts CHANGED
@@ -114,6 +114,28 @@ function acquireLock(blobPath: string, name: string): () => void {
114
114
  throw new Error(`could not acquire ${lockPath}`);
115
115
  }
116
116
 
117
+ /** Reject a repo file path that could escape the snapshot directory once
118
+ * joined onto it. `rfilename` comes verbatim from the remote HF API's
119
+ * `siblings[].rfilename` (listRepoFiles) — for a repo the caller doesn't
120
+ * control (attacker-owned/attacker-referenced), that string is untrusted
121
+ * input reaching a path-join + symlink write (downloadModel's
122
+ * `join(snapDir, f.rfilename)` + `symlinkSync`). node:path's `join()`
123
+ * normalizes `..` segments, so an rfilename like
124
+ * `../../../../home/x/.ssh/authorized_keys` would otherwise resolve
125
+ * OUTSIDE the intended snapshots dir and write a symlink at an arbitrary
126
+ * filesystem path. Reachable over HTTP via POST /api/hub/download
127
+ * (src/hub-rest.ts), which passes any caller-supplied `repo` straight
128
+ * through with no allowlist — this check is the actual gate, not that
129
+ * route. A leading `/` (absolute path) is equally rejected: join() would
130
+ * otherwise treat it as relative to snapDir and still normalize safely,
131
+ * but there's no legitimate reason a repo file entry needs one, and
132
+ * rejecting it removes any doubt. */
133
+ export function isSafeRepoFilename(rfilename: string): boolean {
134
+ if (!rfilename || rfilename.startsWith("/") || rfilename.includes("\0")) return false;
135
+ const parts = rfilename.split("/");
136
+ return parts.every((p) => p !== "" && p !== "." && p !== "..");
137
+ }
138
+
117
139
  /** git blob identity: sha1("blob <size>\0" + content) — what blobId is. */
118
140
  export function gitBlobSha1(bytes: Uint8Array): string {
119
141
  const hasher = new Bun.CryptoHasher("sha1");
@@ -294,6 +316,14 @@ export function downloadsSnapshot(): DownloadStatus[] {
294
316
  return downloadLog.slice(-5);
295
317
  }
296
318
 
319
+ /** True when a download for this exact repoId is currently "active" in this
320
+ * process's tracker (used by the Hub's POST /api/hub/download to refuse a
321
+ * duplicate kick-off — same repoId, still running — rather than starting a
322
+ * second writer that would race the first on the same blob lockfile). */
323
+ export function isDownloadActive(repoId: string): boolean {
324
+ return downloadLog.some((d) => d.repoId === repoId && d.state === "active");
325
+ }
326
+
297
327
  export async function downloadModel(
298
328
  repoId: string, opts: DownloadOptions = {},
299
329
  ): Promise<string> {
@@ -303,6 +333,16 @@ export async function downloadModel(
303
333
  const hub = opts.cacheDir ?? DEFAULT_HUB;
304
334
 
305
335
  const listing = await listRepoFiles(repoId, { ...opts, token });
336
+ // Fail the whole download closed (not skip-and-continue) if the repo's
337
+ // file listing contains a path-traversal rfilename — see
338
+ // isSafeRepoFilename's doc comment. This gates BOTH the CLI and the web
339
+ // Hub's POST /api/hub/download (src/hub-rest.ts), which accepts any
340
+ // caller-supplied repo string with no allowlist of its own.
341
+ for (const f of listing.files) {
342
+ if (!isSafeRepoFilename(f.rfilename)) {
343
+ throw new Error(`${repoId}: refusing unsafe file path in repo listing: ${JSON.stringify(f.rfilename)}`);
344
+ }
345
+ }
306
346
  const repoDir = join(hub, `models--${repoId.replaceAll("/", "--")}`);
307
347
  const blobsDir = join(repoDir, "blobs");
308
348
  const snapDir = join(repoDir, "snapshots", listing.sha);
@@ -36,8 +36,8 @@ repo rule: free-running greedy "measures chaos").
36
36
  # drift gate on an existing non-bit-exact lever (validates the harness today):
37
37
  bun scripts/eval.ts kl --candidate e4b
38
38
 
39
- # once a fused kernel is wired for e4b, its drift vs compat:
40
- bun scripts/eval.ts kl --candidate e4b --self MLX_BUN_PERF_KERNEL --ref-value 0 --cand-value 1
39
+ # self-arm A/B on an output-affecting lever (fused-sdpa on vs off):
40
+ bun scripts/eval.ts kl --candidate e4b --self MLX_BUN_NO_FUSED_SDPA --ref-value 1 --cand-value 0
41
41
 
42
42
  # optiq-style absolute drift vs a reference model:
43
43
  bun scripts/eval.ts kl --candidate e4b --reference <bf16-or-4bit-id>
@@ -55,9 +55,10 @@ the throughput `runs` table). Each row records the active perf levers
55
55
 
56
56
  - **M0 (done):** KL drift gate + capability score + `quality_runs` DB + CLI.
57
57
  - **M0b (done):** *serving-path* KL (`evaluateKlServingDecode` in `kl.ts`,
58
- `bun scripts/eval.ts kl --decode`). Validated: identical arms → KL 0;
59
- `MLX_BUN_FUSED_DECODE` tiled-decode → KL 0.27 (real drift on the quantized
60
- path). Motivation below — the compat KL couldn't see it:
58
+ `bun scripts/eval.ts kl --decode`). Validated at the time: identical arms →
59
+ KL 0; the since-deleted `MLX_BUN_FUSED_DECODE` tiled-decode → KL 0.27 (real
60
+ drift on the quantized path — the lever was removed 2026-07-05, but the
61
+ validation stands). Motivation below — the compat KL couldn't see it:
61
62
  The compat `forward()` path with `makeCache()` uses **plain bf16 caches**. That is wrong
62
63
  twice over: (1) e4b's KV is **per-layer mixed 4-bit/8-bit** (group 64, from
63
64
  `kv_config.json` → `config.kvQuant`; 8-bit layers 3,4,9,10,11,14, 4-bit
@@ -67,7 +68,7 @@ the throughput `runs` table). Each row records the active perf levers
67
68
  caches, then `cache[i] = c.toQuantized(e.groupSize, e.bits)` per
68
69
  `config.kvQuant` (the logic already at `generate.ts:68-86`), so the
69
70
  teacher-forced prefill runs the real mixed-4/8-bit quantized path and the
70
- perf levers (NO_FUSED_SDPA, perf-kernel) actually bite.
71
+ perf levers (NO_FUSED_SDPA) actually bite.
71
72
  - **M1 (done):** all six capability tasks ported + runtime-validated, plus
72
73
  `capability` aggregation and the `smoketest` (KL + GSM8K-50) gate. Datasets
73
74
  exported by `scripts/eval/export-datasets.py` → `~/.cache/mlx-bun/eval-data/`.
package/src/eval/kl.ts CHANGED
@@ -26,7 +26,7 @@ import { Dtype } from "../mlx/ffi";
26
26
  import * as ops from "../mlx/ops";
27
27
  import { Registry } from "../registry";
28
28
  import { Weights } from "../weights";
29
- import { loadModelConfig, type ModelConfig } from "../config";
29
+ import { loadModelConfig, type ModelConfig, type TurboQuantScheme } from "../config";
30
30
  import { createModel, type RuntimeModel } from "../model/factory";
31
31
  import { loadTokenizer, type LoadedTokenizer } from "../tokenizer";
32
32
  import { maybeQuantizeKv } from "../generate";
@@ -223,10 +223,14 @@ function klScalar(p: Float32Array, q: Float32Array): number {
223
223
  }
224
224
 
225
225
  /** Prefill (bf16) → quantize → teacher-forced decode; return per-step logit
226
- * distributions for the last `tokens.length - prefillLen` positions. */
226
+ * distributions for the last `tokens.length - prefillLen` positions.
227
+ * `kvOverride` replaces the default kvConfig-from-model-config scheme when
228
+ * given — the seam evaluateKlKvArm uses to run a TurboQuant (or bf16-null)
229
+ * arm instead of the env-flag self-flip. */
227
230
  function decodeArm(
228
231
  model: RuntimeModel, config: ModelConfig, tokens: number[], prefillLen: number,
229
232
  flag: string, value: string,
233
+ kvOverride?: Parameters<typeof maybeQuantizeKv>[1],
230
234
  ): Float32Array[] {
231
235
  const prev = process.env[flag];
232
236
  process.env[flag] = value;
@@ -237,7 +241,10 @@ function decodeArm(
237
241
  lp.dispose();
238
242
  // quantize the populated caches (mixed 4/8 per kv_config) — exactly
239
243
  // what generate() does between prefill and decode.
240
- maybeQuantizeKv(cache, { kvConfig: config.kvQuant ?? undefined, quantizedKvStart: 0 });
244
+ maybeQuantizeKv(
245
+ cache,
246
+ kvOverride ?? { kvConfig: config.kvQuant ?? undefined, quantizedKvStart: 0 },
247
+ );
241
248
  const out: Float32Array[] = [];
242
249
  for (let i = prefillLen; i < tokens.length; i++) {
243
250
  const logits = model.forward([tokens[i]!], cache); // [1,1,V] quantized decode
@@ -292,3 +299,52 @@ export async function evaluateKlServingDecode(opts: {
292
299
  refLabel: `serving-decode self:${opts.flag}=${opts.refValue}→${opts.candValue} (${decodeSteps} steps)`,
293
300
  };
294
301
  }
302
+
303
+ /** KV-scheme A/B on the teacher-forced serving-decode path: bf16 baseline
304
+ * (no kvOverride) vs an explicit KV scheme (e.g. TurboQuant), same model /
305
+ * same prompts / same weight load — the quality-vs-bpw curve gate
306
+ * (scripts/eval-turboquant-curve.ts). Unlike evaluateKlServingDecode (env-flag
307
+ * self-flip), the two arms differ by an actual GenerateOptions kv scheme
308
+ * object, so this is the seam for schemes that aren't env-lever-shaped. */
309
+ export async function evaluateKlKvArm(opts: {
310
+ candidate: string;
311
+ /** Undefined ⇒ bf16 (no quantization). */
312
+ candidateScheme?: { turboQuant?: TurboQuantScheme; kvBits?: number; kvConfig?: ModelConfig["kvQuant"] };
313
+ prompts: string[];
314
+ nPrompts?: number;
315
+ seqLen?: number;
316
+ decodeSteps?: number;
317
+ }): Promise<KLResult> {
318
+ const t0 = Date.now();
319
+ const n = opts.nPrompts ?? 64;
320
+ const seqLen = opts.seqLen ?? 256;
321
+ const decodeSteps = opts.decodeSteps ?? 32;
322
+ const { model, tokenizer, config } = await loadRunnable(opts.candidate);
323
+ const tokenized = prepPrompts(opts.prompts, tokenizer, seqLen, n);
324
+ const noopFlag = "MLX_BUN_EVAL_KV_ARM_NOOP"; // decodeArm always flips a flag; this one is inert
325
+ const refKv = {}; // bf16: maybeQuantizeKv no-ops with no kvBits/kvConfig/turboQuant
326
+ const candKv = opts.candidateScheme
327
+ ? { ...opts.candidateScheme, quantizedKvStart: 0, kvConfig: opts.candidateScheme.kvConfig ?? undefined }
328
+ : {};
329
+
330
+ const perPrompt: Float32Array[] = [];
331
+ for (const tokens of tokenized) {
332
+ if (tokens.length < 2) continue;
333
+ const prefillLen = Math.max(1, tokens.length - decodeSteps);
334
+ const refSteps = decodeArm(model, config, tokens, prefillLen, noopFlag, "0", refKv);
335
+ const candSteps = decodeArm(model, config, tokens, prefillLen, noopFlag, "0", candKv);
336
+ const m = Math.min(refSteps.length, candSteps.length);
337
+ const kls = new Float32Array(m);
338
+ for (let s = 0; s < m; s++) kls[s] = klScalar(refSteps[s]!, candSteps[s]!);
339
+ perPrompt.push(kls);
340
+ }
341
+
342
+ const agg = aggregate(perPrompt);
343
+ return {
344
+ nPrompts: perPrompt.length, seqLen, meanKl: agg.mean, medianKl: agg.median, p95Kl: agg.p95,
345
+ elapsedSec: (Date.now() - t0) / 1000,
346
+ refLabel: opts.candidateScheme?.turboQuant
347
+ ? `bf16 vs turbo k${opts.candidateScheme.turboQuant.kBits}v${opts.candidateScheme.turboQuant.vBits} (${decodeSteps} steps)`
348
+ : `bf16 vs kv-scheme (${decodeSteps} steps)`,
349
+ };
350
+ }
@@ -5,7 +5,7 @@
5
5
  //
6
6
  // One row = one (model, config) measured on one task (or the aggregate
7
7
  // "capability" / "kl" rows). `config_json` records the perf levers that
8
- // were active (compile / fused-decode / perf-kernel) so a row is
8
+ // were active (compile / fused-sdpa / kv-quant) so a row is
9
9
  // self-describing: which arm of the head-to-head produced this number.
10
10
 
11
11
  import { Database } from "bun:sqlite";
@@ -19,7 +19,7 @@ CREATE TABLE IF NOT EXISTS quality_runs (
19
19
  model_path TEXT NOT NULL,
20
20
  commit_sha TEXT,
21
21
  task TEXT NOT NULL, -- 'kl' | 'gsm8k' | 'mmlu' | ... | 'capability'
22
- config_json TEXT NOT NULL, -- {compile,fusedDecode,perfKernel,...} active levers
22
+ config_json TEXT NOT NULL, -- {compile,fusedSdpa,kvQuant,...} active levers
23
23
  n_samples INTEGER NOT NULL,
24
24
  -- task accuracy (percent 0-100); null for kl-only rows
25
25
  pct REAL,
@@ -99,11 +99,11 @@ export interface GenOpts {
99
99
  * (its capability numbers are non-thinking; e.g. MiniCPM5 IFEval 64.7 is
100
100
  * non-thinking). Set MLX_BUN_EVAL_THINK=1 or pass true to evaluate thinking mode. */
101
101
  enableThinking?: boolean;
102
- /** Explicit KV-quant scheme (cli `generate --l2/--l3/--kv-quant`, resolved with
102
+ /** Explicit KV-quant scheme (cli `generate --l2/--kv-quant`, resolved with
103
103
  * serve's semantics). Overrides the MLX_BUN_EVAL_KV_QUANT env default; an empty
104
104
  * object forces bf16. When it names quantized KV, generation runs the product
105
105
  * generate() path (same tokens as `serve` with the same scheme). */
106
- kvScheme?: Pick<GenerateOptions, "kvBits" | "kvConfig" | "quantizedKvStart">;
106
+ kvScheme?: Pick<GenerateOptions, "kvBits" | "kvConfig" | "quantizedKvStart" | "turboQuant">;
107
107
  }
108
108
 
109
109
  /** Bit-exact greedy decode: raw `model.forward` + argmax loop, which matches mlx-lm
@@ -159,7 +159,7 @@ export async function generateText(tm: TaskModel, body: string, opts: GenOpts =
159
159
  (process.env.MLX_BUN_EVAL_KV_QUANT === "1" && tm.config.kvQuant?.length)
160
160
  ? { kvConfig: tm.config.kvQuant, quantizedKvStart: 0 } : undefined;
161
161
  const kvScheme = opts.kvScheme ?? envKv;
162
- const kvActive = !!(kvScheme && (kvScheme.kvBits || kvScheme.kvConfig?.length));
162
+ const kvActive = !!(kvScheme && (kvScheme.kvBits || kvScheme.kvConfig?.length || kvScheme.turboQuant));
163
163
 
164
164
  // Parity default — greedy + full-precision KV + no sampler arm: decode bit-exactly
165
165
  // via the raw forward (matches mlx-lm token-for-token). Sampler arms / kv-quant fall
@@ -50,12 +50,25 @@ export function isExpertOffload(): boolean {
50
50
  /** Zero-copy mmap-backed array for `name` if offload is active and the tensor
51
51
  * is in the manifest; else null. The converter aligns every tensor to 16 KB
52
52
  * and the mmap base is page-aligned, so the pointer is page-aligned —
53
- * required for the GPU to gather from it. */
53
+ * required for the GPU to gather from it.
54
+ *
55
+ * Lifetime: these arrays are created ONCE at model load
56
+ * (QuantizedSwitchLinear.load, gemma4-base.ts) and live as model weights;
57
+ * every decode's gather_qmm references them, so GPU command buffers retain
58
+ * the mlx buffer past any dispose and mlx may drop the LAST reference on
59
+ * the Metal completion thread. That's why this must be fromPointer (native
60
+ * free(NULL) dtor — safe from any thread), NOT the old fromView JSCallback
61
+ * pin (the 2026-07-06 deadlock class). The memory contract is trivially
62
+ * met: `mm` is module-level and never unmapped — the mapping owns the
63
+ * bytes for the process (clean file-backed pages, ~0 phys_footprint). */
54
64
  export function expertOffloadArray(name: string): MlxArray | null {
55
65
  if (!mm || !manifest) return null;
56
66
  const e = manifest.get(name);
57
67
  if (!e) return null;
58
- return MlxArray.fromView(mm.view(e.offset, e.length), e.shape, SAFETENSORS_TO_MLX[e.dtype]);
68
+ // Bounds check the whole tensor (pointer() only checks the start).
69
+ if (e.offset + e.length > mm.size)
70
+ throw new Error(`expert-offload: ${name} [${e.offset}, +${e.length}) exceeds ${mm.path} (${mm.size} B)`);
71
+ return MlxArray.fromPointer(mm.pointer(e.offset), e.shape, SAFETENSORS_TO_MLX[e.dtype]);
59
72
  }
60
73
 
61
74
  if (process.env.MLX_BUN_EXPERT_OFFLOAD) activateExpertOffload(process.env.MLX_BUN_EXPERT_OFFLOAD);
package/src/flags.ts ADDED
@@ -0,0 +1,13 @@
1
+ // Generic on/off resolver for the per-fork decode flags (--compiled-decode,
2
+ // --compiled-activations, --fused-sdpa, …). An explicit "1"/"0" in the env
3
+ // always wins; otherwise the caller's `defaultOn` applies. The tier aliases
4
+ // (--l1/--l2, applyDecodeRoute in cli.ts) set the env vars; model code
5
+ // reads them through this helper so a hand-set flag and a tier preset resolve
6
+ // identically.
7
+
8
+ /** Resolve an on/off env flag. An explicit "1"/"0" always wins; otherwise the
9
+ * flag takes `defaultOn`. */
10
+ export function flagOn(name: string, defaultOn: boolean): boolean {
11
+ const v = process.env[name];
12
+ return v === "1" ? true : v === "0" ? false : defaultOn;
13
+ }