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
@@ -218,6 +218,17 @@ export async function readArticle(root: string, article: string): Promise<{ path
218
218
  return { path, content: await readFile(path, "utf8") };
219
219
  }
220
220
 
221
+ /** Vault-relative path (`articles/<stem>.md` or `Reference/<stem>.md`) for an
222
+ * existing article/reference doc — the safe argv piece for git plumbing
223
+ * (articleHistory/articleDiff), resolved via the same name normalization/
224
+ * existence check as readArticle so a caller can't smuggle a path-traversal
225
+ * or shell-metacharacter name through. Throws if the doc doesn't exist. */
226
+ export async function resolveArticleRelPath(root: string, article: string): Promise<string> {
227
+ const doc = normalizeMemoryDocId(article);
228
+ await resolveArticlePath(root, article); // existence check, reuses the same normalization
229
+ return doc.kind === "reference" ? join("Reference", `${doc.stem}.md`) : join("articles", `${doc.stem}.md`);
230
+ }
231
+
221
232
  /** Filename stems for every `*.md` under articles/, sorted alphabetically. */
222
233
  export async function listArticles(root: string): Promise<string[]> {
223
234
  let names: string[];
@@ -1013,6 +1024,69 @@ function runGit(args: string[], cwd: string): Promise<boolean> {
1013
1024
  });
1014
1025
  }
1015
1026
 
1027
+ // ---- read-only git plumbing (per-article history/diff) ----------------
1028
+ //
1029
+ // Used by the memory REST surface (GET /api/memory/history|diff) to render
1030
+ // "watch it self-heal" — the article's commit history and a specific
1031
+ // commit's diff. Read-only plumbing only (log/show), args always an
1032
+ // explicit argv array via Bun.spawn (never a shell string), and callers
1033
+ // MUST validate `rev`/`name` before calling these (see server.ts) — this
1034
+ // layer trusts its `relPath`/`rev` inputs are already safe.
1035
+
1036
+ export interface ArticleHistoryEntry {
1037
+ hash: string;
1038
+ date: string;
1039
+ subject: string;
1040
+ }
1041
+
1042
+ /** `git log` for one file, newest first, argv-only (never a shell string).
1043
+ * Returns [] when the vault isn't a git repo, the file has no history, or
1044
+ * git is unavailable. `relPath` must already be a repo-relative path (no
1045
+ * `..`, not absolute) — callers validate before calling. */
1046
+ export async function articleHistory(root: string, relPath: string, limit = 50): Promise<ArticleHistoryEntry[]> {
1047
+ if (!(await pathExists(join(root, ".git")))) return [];
1048
+ try {
1049
+ const proc = Bun.spawn(
1050
+ ["git", "log", `--max-count=${limit}`, "--date=short", "--pretty=format:%H%x1f%ad%x1f%s%x1e", "--", relPath],
1051
+ { cwd: root, stdout: "pipe", stderr: "ignore" },
1052
+ );
1053
+ const out = await new Response(proc.stdout).text();
1054
+ if ((await proc.exited) !== 0) return [];
1055
+ return out
1056
+ .split("\x1e")
1057
+ .map((rec) => rec.trim())
1058
+ .filter((rec) => rec.length > 0)
1059
+ .map((rec) => {
1060
+ const [hash = "", date = "", subject = ""] = rec.split("\x1f");
1061
+ return { hash, date, subject };
1062
+ })
1063
+ .filter((e) => e.hash.length > 0);
1064
+ } catch {
1065
+ return [];
1066
+ }
1067
+ }
1068
+
1069
+ /** `git show <rev> -- <relPath>` — the diff for one commit touching one file.
1070
+ * `rev` must already be validated as a hex commit hash by the caller (never
1071
+ * a ref expression like `HEAD~1` or `main^`, which could be abused as an
1072
+ * argument-injection vector); `relPath` must already be repo-relative.
1073
+ * Returns null when the vault isn't a git repo or the commit/diff can't be
1074
+ * produced. */
1075
+ export async function articleDiff(root: string, rev: string, relPath: string): Promise<string | null> {
1076
+ if (!(await pathExists(join(root, ".git")))) return null;
1077
+ try {
1078
+ const proc = Bun.spawn(
1079
+ ["git", "show", rev, "--pretty=format:", "--", relPath],
1080
+ { cwd: root, stdout: "pipe", stderr: "ignore" },
1081
+ );
1082
+ const out = await new Response(proc.stdout).text();
1083
+ if ((await proc.exited) !== 0) return null;
1084
+ return out;
1085
+ } catch {
1086
+ return null;
1087
+ }
1088
+ }
1089
+
1016
1090
  export interface SetupResult {
1017
1091
  root: string;
1018
1092
  created: string[];
package/src/mlx/array.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  // MlxArray: ownership wrapper around an mlx_array handle.
2
2
  // Explicit .dispose() is the contract; a FinalizationRegistry backstop
3
- // frees leaked handles on GC (verified in spikes/phase0-memory.ts).
3
+ // frees leaked handles on GC (verified in lab/spikes/phase0-memory.ts).
4
4
 
5
- import { JSCallback, ptr, toArrayBuffer } from "bun:ffi";
5
+ import { dlopen, ptr, toArrayBuffer } from "bun:ffi";
6
6
  import { C, Dtype, DTYPE_NAMES, type MlxHandle, optInt, outArray, takeMlxError } from "./ffi";
7
7
  import type { SafetensorsDtype } from "../safetensors";
8
8
 
@@ -24,17 +24,39 @@ export const SAFETENSORS_TO_MLX: Record<SafetensorsDtype, Dtype> = {
24
24
 
25
25
  // --- zero-copy pinning ---------------------------------------------------
26
26
  // Buffers handed to mlx via new_data_managed_payload must outlive the mlx
27
- // array. We pin the JS view in a map keyed by a payload id; the shared dtor
28
- // callback unpins when mlx drops its last reference. threadsafe: mlx may
29
- // release buffers from its eval thread.
27
+ // array. fromView pins the JS view in this map (a GC root, nothing more);
28
+ // release is EXPLICIT and JS-thread-only via unpinHostBuffer() — never an
29
+ // mlx dtor. See the hazard note below.
30
30
 
31
31
  const pinned = new Map<number, Uint8Array>();
32
32
  let nextPinId = 1;
33
33
 
34
- const unpinCallback = new JSCallback(
35
- (payloadId: number) => { pinned.delete(payloadId); },
36
- { args: ["u64"], returns: "void", threadsafe: true },
37
- );
34
+ // HAZARD (root cause of the 2026-07-06 restart-restore hang): mlx releases
35
+ // buffer Data wherever the LAST shared_ptr drops — often the Metal
36
+ // COMPLETION thread (gpu::eval retains buffers until the command buffer
37
+ // finishes, i.e. past dispose()). A bun:ffi JSCallback dtor invoked from
38
+ // that thread deadlocks when the JS thread is inside a blocking FFI eval
39
+ // (completion waits on JS, JS waits on completion) and SIGTRAPs mid-GC.
40
+ // So NO array here ever gets a JS dtor: fromPointer AND fromView both hand
41
+ // mlx the native no-op dtor below (payload 0), and host-buffer lifetime is
42
+ // the CALLER's contract, released only from the JS thread (unpinHostBuffer)
43
+ // or never (process-lifetime mmaps: expert-offload).
44
+
45
+ // Native no-op dtor: payload is always 0, and libc free(NULL) is defined to
46
+ // do nothing — a dtor mlx can safely call from ANY thread. The pointed-to
47
+ // memory's lifetime is the CALLER's contract (weight mmaps live for the
48
+ // process; restored KV is COPIED at load since 2026-07-07 — see kv-store.ts
49
+ // loadKvCache — so no restore mapping needs pinning anymore). The address
50
+ // comes via dlsym because
51
+ // bun:ffi's symbol .ptr returns the pointer bit-cast to float64
52
+ // (lab/repro/bun-ffi-f64) — passing that back truncates to NULL.
53
+ const libcDlsym = dlopen("/usr/lib/libSystem.B.dylib", {
54
+ dlsym: { args: ["u64", "ptr"], returns: "u64" },
55
+ });
56
+ const FREE_NAME = Buffer.from("free\0");
57
+ const RTLD_DEFAULT = 0xfffffffffffffffen;
58
+ const freeFnAddr = Number(libcDlsym.symbols.dlsym(RTLD_DEFAULT, ptr(FREE_NAME)));
59
+ if (!freeFnAddr) throw new Error("dlsym(free) failed — zero-copy arrays need a native no-op dtor");
38
60
 
39
61
  export function pinnedBufferCount(): number {
40
62
  return pinned.size;
@@ -50,31 +72,61 @@ export class MlxArray {
50
72
  #handle: MlxHandle;
51
73
  #disposed = false;
52
74
  #token = {};
75
+ #pinId = 0; // fromView only: key into the pinned-view map (0 = no pin)
53
76
 
54
77
  constructor(handle: MlxHandle) {
55
78
  this.#handle = handle;
56
79
  registry.register(this, handle, this.#token);
57
80
  }
58
81
 
59
- /** Zero-copy: wrap an existing buffer (e.g. an mmap view). The view is
60
- * pinned until mlx releases it. */
82
+ /** Zero-copy: wrap an existing JS buffer. The view is pinned (GC-rooted)
83
+ * in a process-side map; mlx gets the NATIVE no-op dtor (free(NULL)) —
84
+ * never a JS callback, so the last release may happen on the Metal
85
+ * completion thread without deadlock (see hazard note above). Buffer
86
+ * lifetime is therefore the CALLER's contract:
87
+ * - mmap-backed views (toArrayBuffer aliases): the mapping owns the
88
+ * memory; prefer fromPointer, which skips the pin entirely.
89
+ * - JS-heap buffers: the pin keeps the buffer alive; call
90
+ * unpinHostBuffer() only after a JS-side sync point proves mlx is
91
+ * done with it (everything depending on the array evaluated AND the
92
+ * array disposed), or never (process-lifetime, e.g. short scripts). */
61
93
  static fromView(view: Uint8Array, shape: number[], dtype: Dtype): MlxArray {
62
94
  const sb = shapeBuf(shape);
63
95
  const id = nextPinId++;
64
96
  pinned.set(id, view);
65
97
  const handle = C.mlx_array_new_data_managed_payload(
66
- ptr(view), ptr(sb), shape.length, dtype, id, unpinCallback.ptr,
98
+ ptr(view), ptr(sb), shape.length, dtype, 0, freeFnAddr,
67
99
  );
68
- return new MlxArray(handle);
100
+ const arr = new MlxArray(handle);
101
+ arr.#pinId = id;
102
+ return arr;
103
+ }
104
+
105
+ /** Release the GC root fromView placed on this array's host buffer.
106
+ * JS-thread-only by construction (it's a plain method) — this is the
107
+ * replacement for the deadlocking JSCallback unpin dtor. Idempotent;
108
+ * no-op for non-fromView arrays. Caller contract: only call once mlx can
109
+ * no longer touch the buffer (see fromView doc), or when the memory is
110
+ * owned elsewhere (an mmap kept mapped past every use). */
111
+ unpinHostBuffer(): void {
112
+ if (this.#pinId !== 0) {
113
+ pinned.delete(this.#pinId);
114
+ this.#pinId = 0;
115
+ }
69
116
  }
70
117
 
71
118
  /** Zero-copy: wrap a raw pointer (e.g. into an mmap'd weight shard).
72
- * The caller guarantees the memory outlives the array — weight mmaps
73
- * live for the process, so the dtor is a no-op unpin of id 0. */
119
+ * The caller guarantees the memory outlives EVERY mlx
120
+ * reference to the array — including GPU command buffers that retain the
121
+ * buffer past dispose() — i.e. mmaps backing these arrays stay mapped for
122
+ * the process. The dtor is free(NULL) (payload 0): native + no-op, so
123
+ * mlx may run it from the Metal completion thread without touching the
124
+ * JS runtime (the old JSCallback dtor deadlocked serving / SIGTRAPed
125
+ * under GC when that thread held the last reference — 2026-07-06). */
74
126
  static fromPointer(dataPtr: number, shape: number[], dtype: Dtype): MlxArray {
75
127
  const sb = shapeBuf(shape);
76
128
  const handle = C.mlx_array_new_data_managed_payload(
77
- dataPtr, ptr(sb), shape.length, dtype, 0, unpinCallback.ptr,
129
+ dataPtr, ptr(sb), shape.length, dtype, 0, freeFnAddr,
78
130
  );
79
131
  return new MlxArray(handle);
80
132
  }
@@ -138,7 +190,7 @@ export class MlxArray {
138
190
  return Number(C.mlx_array_ndim(this.handle));
139
191
  }
140
192
 
141
- // NOTE on the DFG stale-read bug (repro/bun-ffi-f64/ISSUE.md): the
193
+ // NOTE on the DFG stale-read bug (lab/repro/bun-ffi-f64/ISSUE.md): the
142
194
  // toArrayBuffer readbacks below (shape/rawBytes/toFloat32) are safe.
143
195
  // The hazard is reading a *pre-existing* typed array after an FFI call
144
196
  // wrote through its pointer — the JIT forwards a stale value from before
@@ -183,6 +235,18 @@ export class MlxArray {
183
235
 
184
236
  /** Raw bytes of the evaluated array (copy). bf16/f16/f32 only. */
185
237
  rawBytes(): Uint8Array {
238
+ return this.rawBytesView().slice();
239
+ }
240
+
241
+ /** ZERO-COPY view of the evaluated array's bytes — aliases the mlx
242
+ * buffer directly, no JS-heap copy. Valid only while THIS array is
243
+ * alive and unmutated: do not retain past dispose(). Built for
244
+ * bulk-write paths (kv-store's streamed writer) where per-tensor
245
+ * rawBytes() copies pile up in the JS heap faster than GC collects
246
+ * them — the residual A7 whole-entry RSS spike (2026-07-07). The
247
+ * array must be contiguous (ops.contiguous first for views), same
248
+ * contract as rawBytes. */
249
+ rawBytesView(): Uint8Array {
186
250
  this.eval();
187
251
  const dt = this.dtype;
188
252
  const p =
@@ -190,9 +254,32 @@ export class MlxArray {
190
254
  : dt === Dtype.float16 ? C.mlx_array_data_float16(this.handle)
191
255
  : dt === Dtype.bfloat16 ? C.mlx_array_data_bfloat16(this.handle)
192
256
  : dt === Dtype.uint32 ? C.mlx_array_data_uint32(this.handle)
257
+ : dt === Dtype.uint8 ? C.mlx_array_data_uint8(this.handle)
258
+ : dt === Dtype.int8 ? C.mlx_array_data_int8(this.handle)
193
259
  : null;
194
- if (p === null) throw new Error(`rawBytes: unsupported dtype ${this.dtypeName}`);
195
- return new Uint8Array(toArrayBuffer(p!, 0, this.nbytes)).slice();
260
+ if (p === null) throw new Error(`rawBytesView: unsupported dtype ${this.dtypeName}`);
261
+ return new Uint8Array(toArrayBuffer(p!, 0, this.nbytes));
262
+ }
263
+
264
+ /** Read back an INTEGER array without enqueueing a cast kernel: eval +
265
+ * direct buffer read for uint32/int32. A cast (`astype`) is a NEW op that
266
+ * queues BEHIND everything already dispatched on the GPU stream — on the
267
+ * batched decode hot path, reading the pipeline register via toFloat32
268
+ * stalled the "overlapped" token read for a FULL step of GPU work every
269
+ * token (the core of the Phase-0 B=1 gap; unified-engine plan Phase 2).
270
+ * Non-integer dtypes fall back to the rounded float readback. */
271
+ toIntTokens(): number[] {
272
+ if (this.dtype === Dtype.uint32) {
273
+ this.eval();
274
+ const p = C.mlx_array_data_uint32(this.handle);
275
+ return [...new Uint32Array(toArrayBuffer(p!, 0, this.size * 4))];
276
+ }
277
+ if (this.dtype === Dtype.int32) {
278
+ this.eval();
279
+ const p = C.mlx_array_data_int32(this.handle);
280
+ return [...new Int32Array(toArrayBuffer(p!, 0, this.size * 4))];
281
+ }
282
+ return [...this.toFloat32()].map((x) => Math.round(x));
196
283
  }
197
284
 
198
285
  /** Read back as float32 (casts on GPU if needed, then copies out). */
@@ -1,6 +1,6 @@
1
1
  // Native autograd via mlx-c value_and_grad over a JS loss closure.
2
2
  //
3
- // Encapsulates the exact FFI lifecycle proven in spikes/phase-train-vag.ts:
3
+ // Encapsulates the exact FFI lifecycle proven in lab/spikes/phase-train-vag.ts:
4
4
  // JS loss closure (JSCallback) → mlx_closure_new_func_payload →
5
5
  // mlx_value_and_grad → mlx_closure_value_and_grad_apply → read grads.
6
6
  //
@@ -45,7 +45,7 @@ export class ValueAndGrad {
45
45
  // Read the primals out of the input vector_array. mlx_vector_array_get
46
46
  // returns a fresh (ref-counted) handle, so disposing the wrapper
47
47
  // afterward is correct — it drops our reference, not mlx's graph
48
- // reference (proven in spikes/phase-train-vag.ts).
48
+ // reference (proven in lab/spikes/phase-train-vag.ts).
49
49
  const n = Number(C.mlx_vector_array_size(inVec));
50
50
  const primals: MlxArray[] = [];
51
51
  for (let i = 0; i < n; i++) {
package/src/mlx/ffi.ts CHANGED
@@ -15,7 +15,7 @@ import { existsSync } from "node:fs";
15
15
  import { dirname, join } from "node:path";
16
16
  import { nativePackDir } from "../native-pack";
17
17
 
18
- const { ptr: P, i32, u64, f32, cstring } = FFIType;
18
+ const { ptr: P, i32, u64, f32, f64, cstring } = FFIType;
19
19
 
20
20
  /** libmlxc resolution, in order: explicit env override → next to the
21
21
  * executable (the `bun build --compile` sidecar layout, where
@@ -57,6 +57,9 @@ export const C = dlopen(LIBMLXC_PATH, {
57
57
  mlx_array_data_float16: { args: [u64], returns: P },
58
58
  mlx_array_data_bfloat16: { args: [u64], returns: P },
59
59
  mlx_array_data_uint32: { args: [u64], returns: P },
60
+ mlx_array_data_int32: { args: [u64], returns: P },
61
+ mlx_array_data_uint8: { args: [u64], returns: P },
62
+ mlx_array_data_int8: { args: [u64], returns: P },
60
63
  mlx_array_item_float32: { args: [P, u64], returns: i32 },
61
64
  // streams
62
65
  mlx_default_gpu_stream_new: { args: [], returns: u64 },
@@ -142,6 +145,7 @@ export const C = dlopen(LIBMLXC_PATH, {
142
145
  mlx_negative: { args: [P, u64, u64], returns: i32 },
143
146
  mlx_argsort_axis: { args: [P, u64, i32, u64], returns: i32 },
144
147
  mlx_argpartition_axis: { args: [P, u64, i32, i32, u64], returns: i32 },
148
+ mlx_arange: { args: [P, f64, f64, f64, i32, u64], returns: i32 },
145
149
  mlx_take_along_axis: { args: [P, u64, u64, i32, u64], returns: i32 },
146
150
  mlx_put_along_axis: { args: [P, u64, u64, u64, i32, u64], returns: i32 },
147
151
  mlx_cumsum: { args: [P, u64, i32, FFIType.bool, FFIType.bool, u64], returns: i32 },
@@ -169,7 +173,17 @@ export const C = dlopen(LIBMLXC_PATH, {
169
173
  // (res, input, weight, stride, padding, dilation, groups, stream) — depthwise
170
174
  // causal conv1d for Qwen3.5 gated-DeltaNet (groups == channels, padding 0).
171
175
  mlx_conv1d: { args: [P, u64, u64, i32, i32, i32, i32, u64], returns: i32 },
172
- // --- training: autograd (value_and_grad) — proven in spikes/phase-train-vag.ts.
176
+ // C signature: (res, input, weight, stride_0, stride_1, padding_0,
177
+ // padding_1, dilation_0, dilation_1, groups, stream) — NHWC conv2d for the
178
+ // gemma-4 audio SSCP subsampler.
179
+ // HAZARD (lab/repro/bun-ffi-stack-args): Apple arm64 packs sub-8-byte STACK
180
+ // args at natural size (dilation_1@sp+0, groups@sp+4, stream@sp+8) but
181
+ // bun:ffi 1.3.14 writes one 8-byte slot per arg — the callee reads shifted
182
+ // garbage and mlx segfaults on a bogus stream. Workaround: declare the two
183
+ // stack ints as ONE u64 (dilation_1 | groups<<32) so Bun's slot layout
184
+ // byte-matches the Apple ABI. ops.conv2d does the packing.
185
+ mlx_conv2d: { args: [P, u64, u64, i32, i32, i32, i32, i32, u64, u64], returns: i32 },
186
+ // --- training: autograd (value_and_grad) — proven in lab/spikes/phase-train-vag.ts.
173
187
  // mlx_value_and_grad(res: mlx_closure_value_and_grad*, fun: mlx_closure,
174
188
  // argnums: int*, n) → builds a value+grad closure differentiating the
175
189
  // selected inputs of `fun` (a mlx_closure_new_func_payload over a JS loss).
@@ -260,6 +274,16 @@ export const C = dlopen(LIBMLXC_PATH, {
260
274
  // GPU trace capture (Phase E step 2: size the prize before the kernel)
261
275
  mlx_metal_start_capture: { args: [cstring], returns: i32 },
262
276
  mlx_metal_stop_capture: { args: [], returns: i32 },
277
+ // TurboQuant KV codec (Phase 13): FWHT rotation + per-group affine/Lloyd-Max
278
+ // (res, a, opt scale, stream) — 1/sqrt(dim) normalization when scale is null.
279
+ mlx_hadamard_transform: { args: [P, u64, u64, u64], returns: i32 },
280
+ mlx_min_axis: { args: [P, u64, i32, FFIType.bool, u64], returns: i32 },
281
+ mlx_round: { args: [P, u64, i32, u64], returns: i32 },
282
+ mlx_greater: { args: [P, u64, u64, u64], returns: i32 },
283
+ mlx_bitwise_and: { args: [P, u64, u64, u64], returns: i32 },
284
+ mlx_bitwise_or: { args: [P, u64, u64, u64], returns: i32 },
285
+ mlx_left_shift: { args: [P, u64, u64, u64], returns: i32 },
286
+ mlx_right_shift: { args: [P, u64, u64, u64], returns: i32 },
263
287
  }).symbols;
264
288
 
265
289
  export type MlxHandle = bigint;
@@ -329,7 +353,7 @@ export const NULL_HANDLE = 0n;
329
353
  // RULE: never read a typed array that a bun:ffi call wrote through a
330
354
  // pointer — once the calling function is DFG-compiled, the JIT eliminates
331
355
  // the load across the native call and `buf[i]` returns stale values
332
- // (repro/bun-ffi-f64/ISSUE.md; PLAN.md Phase 4 findings). Out-param slots
356
+ // (lab/repro/bun-ffi-f64/ISSUE.md; PLAN.md Phase 4 findings). Out-param slots
333
357
  // must be read back with bun:ffi `read.*` (verified safe). Initializing the
334
358
  // slot via the typed-array *constructor* is fine: that store happens in
335
359
  // host code the JIT can't elide.
@@ -89,15 +89,18 @@ export class MetalKernel {
89
89
  C.mlx_fast_metal_kernel_config_set_thread_group(cfg, call.threadGroup[0], call.threadGroup[1], call.threadGroup[2]);
90
90
  if (call.initValue !== undefined)
91
91
  C.mlx_fast_metal_kernel_config_set_init_value(cfg, call.initValue);
92
- for (const [k, v] of Object.entries(call.templateInts ?? {})) {
92
+ // Dtypes-first, then ints, to MATCH mlx's own specialization-name order
93
+ // (mlx-lm's gated_delta_step passes [("InT",…),("StT",…),("Dk",…),…]).
94
+ // Order affects only the generated name/cache-key, not binding (named args).
95
+ for (const [k, v] of Object.entries(call.templateDtypes ?? {})) {
93
96
  const kb = Buffer.from(k + "\0", "utf8");
94
97
  keepAlive.push(kb);
95
- C.mlx_fast_metal_kernel_config_add_template_arg_int(cfg, ptr(kb), v);
98
+ C.mlx_fast_metal_kernel_config_add_template_arg_dtype(cfg, ptr(kb), v);
96
99
  }
97
- for (const [k, v] of Object.entries(call.templateDtypes ?? {})) {
100
+ for (const [k, v] of Object.entries(call.templateInts ?? {})) {
98
101
  const kb = Buffer.from(k + "\0", "utf8");
99
102
  keepAlive.push(kb);
100
- C.mlx_fast_metal_kernel_config_add_template_arg_dtype(cfg, ptr(kb), v);
103
+ C.mlx_fast_metal_kernel_config_add_template_arg_int(cfg, ptr(kb), v);
101
104
  }
102
105
 
103
106
  const handles = new BigUint64Array(inputs.map((a) => a.handle));
package/src/mlx/ops.ts CHANGED
@@ -43,10 +43,39 @@ export function sigmoid(a: MlxArray, s: S = gpuStream): MlxArray {
43
43
  return new MlxArray(outArray("sigmoid", (o) => C.mlx_sigmoid(o, a.handle, s)));
44
44
  }
45
45
 
46
- /** Scalar constant with the same dtype as `like` (python weak-scalar semantics). */
46
+ /** f32 → bf16 truncation with round-to-nearest-even, returning the 2
47
+ * little-endian bytes of the bf16 value. Matches mlx's
48
+ * `static_cast<bfloat16_t>(float)` bit-for-bit so a host-built bf16 scalar is
49
+ * numerically identical to `f32_scalar.astype(bf16)` — just without the GPU
50
+ * Copy (astype) kernel. */
51
+ function bf16Bytes(value: number): Uint8Array {
52
+ const fb = new Float32Array([value]);
53
+ const bits = new Uint32Array(fb.buffer)[0]!;
54
+ let hi16: number;
55
+ if (((bits >>> 23) & 0xff) === 0xff) {
56
+ // inf / nan: keep the high 16 bits, and keep a NaN a (quiet) NaN.
57
+ hi16 = (bits >>> 16) & 0xffff;
58
+ if ((bits & 0x7fffff) !== 0) hi16 |= 0x40;
59
+ } else {
60
+ const roundingBias = 0x7fff + ((bits >>> 16) & 1); // round to nearest even
61
+ hi16 = ((bits + roundingBias) >>> 16) & 0xffff;
62
+ }
63
+ return new Uint8Array([hi16 & 0xff, (hi16 >>> 8) & 0xff]);
64
+ }
65
+
66
+ /** Scalar constant with the same dtype as `like` (python weak-scalar semantics).
67
+ * Built HOST-SIDE at the target dtype — mlx's own scalar→array path constructs
68
+ * a bf16/f32 constant directly, so `scalar * bf16_array` is a single multiply.
69
+ * Going through an f32 leaf + GPU `.astype(bf16)` (the old path) emits a
70
+ * separate Copy kernel (`v_copy…`) that the reference never dispatches. */
47
71
  export function scalarLike(value: number, like: MlxArray): MlxArray {
72
+ if (like.dtype === Dtype.float32)
73
+ return MlxArray.fromFloat32(new Float32Array([value]), []);
74
+ if (like.dtype === Dtype.bfloat16)
75
+ return MlxArray.fromBytesCopy(bf16Bytes(value), [], Dtype.bfloat16);
76
+ // f16 / other float dtypes: keep the astype path (rare here; no bit-exact JS
77
+ // converter written for them yet).
48
78
  const f = MlxArray.fromFloat32(new Float32Array([value]), []);
49
- if (like.dtype === Dtype.float32) return f;
50
79
  const cast = f.astype(like.dtype);
51
80
  f.dispose();
52
81
  return cast;
@@ -147,6 +176,25 @@ export function ropeDynamic(
147
176
  );
148
177
  }
149
178
 
179
+ /** Array-offset fast::rope with every knob exposed (traditional + scale) —
180
+ * the dynamic twin of `ropeScaled`, for the universal rope factory's
181
+ * batched-decode path (per-row [B] offsets under left-padding). Same
182
+ * kernel as `ropeDynamic`; static/dynamic bit-exactness is asserted in
183
+ * tests/compile.test.ts. */
184
+ export function ropeScaledDynamic(
185
+ x: MlxArray, dims: number, traditional: boolean, base: number | null,
186
+ scale: number, offset: MlxArray, freqs: MlxArray | null, s: S = gpuStream,
187
+ ): MlxArray {
188
+ return new MlxArray(
189
+ outArray("fast_rope_dynamic", (o) =>
190
+ C.mlx_fast_rope_dynamic(
191
+ o, x.handle, dims, traditional, optFloat(base), scale, offset.handle,
192
+ freqs?.handle ?? 0n, s,
193
+ ),
194
+ ),
195
+ );
196
+ }
197
+
150
198
  export function sdpa(
151
199
  q: MlxArray, k: MlxArray, v: MlxArray, scale: number,
152
200
  maskMode: "" | "causal" | "array", maskArr: MlxArray | null = null,
@@ -292,21 +340,35 @@ export function pow(a: MlxArray, b: MlxArray, s: S = gpuStream): MlxArray {
292
340
  return new MlxArray(outArray("power", (o) => C.mlx_power(o, a.handle, b.handle, s)));
293
341
  }
294
342
 
295
- /** nn.gelu_approx: 0.5 x (1 + tanh(√(2/π) (x + 0.044715 x³))) — composed
296
- * exactly as mlx's python source: x**3 is mx.power (NOT x·x·x — they
297
- * round differently in bf16), scalars promote weakly to x's dtype. */
343
+ /** nn.gelu_approx: 0.5 * x * (1 + tanh(√(2/π) * (x + 0.044715 * x³))) — composed
344
+ * exactly as mlx's python source, INCLUDING operand order: x**3 is mx.power
345
+ * (NOT x·x·x — they round differently in bf16), and every scalar·array product
346
+ * puts the SCALAR FIRST (`0.5 * x`, not `x * 0.5`). Multiply is commutative so
347
+ * this is bit-identical, but it makes the compiled geglu graph's operand string
348
+ * (`Multiply(C, V)`) match the oracle's fused kernel exactly. Scalars promote
349
+ * weakly to x's dtype. */
298
350
  export function geluApprox(x: MlxArray, s: S = gpuStream): MlxArray {
299
- const three = scalarLike(3, x);
300
- const x3 = pow(x, three, s);
301
- const cx3 = mulScalar(x3, 0.044715, s);
302
- const inner = add(x, cx3, s);
303
- const scaled = mulScalar(inner, Math.sqrt(2 / Math.PI), s);
351
+ // EXACT copy of mlx's graph for
352
+ // 0.5 * x * (1 + tanh(√(2/π) * (x + 0.044715 * x**3)))
353
+ // as dumped via mx.export_to_dot. mlx's `__rmul__`/`__radd__` canonicalize
354
+ // every `scalar OP array` to `array OP scalar`, so every commutative product/
355
+ // sum below is ARRAY-FIRST, constant-second (NOT scalar-first). Build order
356
+ // follows Python left-to-right (`0.5 * x` first). Same libmlx compiles this to
357
+ // the identical fused kernel.
358
+ const cHalf = scalarLike(0.5, x);
359
+ const halfx = mul(x, cHalf, s); // Multiply(x, 0.5)
360
+ const cThree = scalarLike(3, x);
361
+ const x3 = pow(x, cThree, s); // Power(x, 3)
362
+ const cCoef = scalarLike(0.044715, x);
363
+ const cx3 = mul(x3, cCoef, s); // Multiply(x**3, 0.044715)
364
+ const inner = add(x, cx3, s); // Add(x, cx3)
365
+ const cSqrt = scalarLike(Math.sqrt(2 / Math.PI), x);
366
+ const scaled = mul(inner, cSqrt, s); // Multiply(inner, √(2/π))
304
367
  const t = tanh(scaled, s);
305
- const one = scalarLike(1, x);
306
- const t1 = add(one, t, s);
307
- const halfx = mulScalar(x, 0.5, s);
308
- const out = mul(halfx, t1, s);
309
- for (const a of [three, x3, cx3, inner, scaled, t, one, t1, halfx]) a.dispose();
368
+ const cOne = scalarLike(1, x);
369
+ const t1 = add(t, cOne, s); // Add(tanh, 1)
370
+ const out = mul(halfx, t1, s); // Multiply(halfx, 1+tanh)
371
+ for (const a of [cHalf, halfx, cThree, x3, cCoef, cx3, inner, cSqrt, scaled, t, cOne, t1]) a.dispose();
310
372
  return out;
311
373
  }
312
374
 
@@ -402,7 +464,7 @@ export function where(cond: MlxArray, x: MlxArray, y: MlxArray, s: S = gpuStream
402
464
 
403
465
  // arange is built host-side and uploaded. The original mlx_arange binding
404
466
  // broke once the calling path got JIT-optimized; a standalone repro
405
- // (repro/bun-ffi-f64/) traced the root cause to a Bun bug: after DFG
467
+ // (lab/repro/bun-ffi-f64/) traced the root cause to a Bun bug: after DFG
406
468
  // tier-up, typed-array reads following a bun:ffi call return stale values
407
469
  // (the JIT eliminates the load across the native call). Not f64 marshaling
408
470
  // — args reach C intact. See PLAN.md Phase 4 findings. Large constant
@@ -418,13 +480,18 @@ export function arange(start: number, stop: number, step: number, dtype: Dtype,
418
480
  const cached = arangeCache.get(key);
419
481
  if (cached) return cached.slice([0], [n], s);
420
482
 
421
- const data = new Int32Array(n);
422
- for (let i = 0; i < n; i++) data[i] = start + i * step;
423
- let arr = MlxArray.fromInt32(data, [n]);
424
- if (dtype !== Dtype.int32) {
425
- const cast = arr.astype(dtype, s);
426
- arr.dispose();
427
- arr = cast;
483
+ let arr: MlxArray;
484
+ if (dtype === Dtype.int32) {
485
+ // host-built int32 leaf (the Bun-f64-FFI-safe path — see note above).
486
+ const data = new Int32Array(n);
487
+ for (let i = 0; i < n; i++) data[i] = start + i * step;
488
+ arr = MlxArray.fromInt32(data, [n]);
489
+ } else {
490
+ // Native mlx Arange in the target dtype — matches mlx's `Arange` op exactly.
491
+ // The old `fromInt32 → .astype(dtype)` emitted a phantom int32→float Copy
492
+ // (an AsType the oracle never dispatches — e.g. the proportional-RoPE freq
493
+ // exponents). Integer start/step passed as doubles are exact.
494
+ arr = new MlxArray(outArray("arange", (o) => C.mlx_arange(o, start, stop, step, dtype, s)));
428
495
  }
429
496
  if (n >= ARANGE_CACHE_MIN) {
430
497
  arangeCache.set(key, arr);
@@ -811,6 +878,26 @@ export function conv1d(
811
878
  );
812
879
  }
813
880
 
881
+ /** nn.Conv2d: input [B, H, W, C_in] (NHWC), weight [C_out, kH, kW, C_in/groups]
882
+ * → [B, H', W', C_out]. mlx weight layout — the oracle transposes PyTorch's
883
+ * [C_out, C_in, kH, kW] at load. For the gemma-4 audio SSCP subsampler.
884
+ * dilation_1 and groups ride in one packed u64 — see the mlx_conv2d hazard
885
+ * note in ffi.ts (bun:ffi stack-arg ABI bug). */
886
+ export function conv2d(
887
+ input: MlxArray, weight: MlxArray, stride: [number, number] = [1, 1],
888
+ padding: [number, number] = [0, 0], dilation: [number, number] = [1, 1],
889
+ groups = 1, s: S = gpuStream,
890
+ ): MlxArray {
891
+ const dilation1AndGroups =
892
+ (BigInt(dilation[1] >>> 0) & 0xffffffffn) | (BigInt(groups >>> 0) << 32n);
893
+ return new MlxArray(
894
+ outArray("conv2d", (o) =>
895
+ C.mlx_conv2d(o, input.handle, weight.handle, stride[0], stride[1],
896
+ padding[0], padding[1], dilation[0], dilation1AndGroups, s),
897
+ ),
898
+ );
899
+ }
900
+
814
901
  /** mx.split(a, indices, axis): split at the given boundary offsets along `axis`
815
902
  * into N+1 contiguous slices (mirrors numpy/mlx split-by-indices). */
816
903
  export function split(a: MlxArray, indices: number[], axis: number): MlxArray[] {
@@ -844,3 +931,46 @@ export function silu(a: MlxArray, s: S = gpuStream): MlxArray {
844
931
  sig.dispose();
845
932
  return r;
846
933
  }
934
+
935
+ /** fast::hadamard_transform over the last axis. `scale` defaults to
936
+ * 1/sqrt(dim) (mlx's own default when unset) — pass it explicitly to
937
+ * match a reference that always states its normalization, per
938
+ * turboquant-ops.ts's fwht(). */
939
+ export function hadamardTransform(x: MlxArray, scale: number | null = null, s: S = gpuStream): MlxArray {
940
+ return new MlxArray(
941
+ outArray("hadamard_transform", (o) =>
942
+ C.mlx_hadamard_transform(o, x.handle, optFloat(scale), s),
943
+ ),
944
+ );
945
+ }
946
+
947
+ export function minAxis(a: MlxArray, axis: number, keepdims: boolean, s: S = gpuStream): MlxArray {
948
+ return new MlxArray(
949
+ outArray("min_axis", (o) => C.mlx_min_axis(o, a.handle, axis, keepdims, s)),
950
+ );
951
+ }
952
+
953
+ /** mx.round(x, decimals=0) — round-half-to-even at the ULP grid mlx uses. */
954
+ export function round(a: MlxArray, s: S = gpuStream): MlxArray {
955
+ return new MlxArray(outArray("round", (o) => C.mlx_round(o, a.handle, 0, s)));
956
+ }
957
+
958
+ export function greater(a: MlxArray, b: MlxArray, s: S = gpuStream): MlxArray {
959
+ return new MlxArray(outArray("greater", (o) => C.mlx_greater(o, a.handle, b.handle, s)));
960
+ }
961
+
962
+ export function bitwiseAnd(a: MlxArray, b: MlxArray, s: S = gpuStream): MlxArray {
963
+ return new MlxArray(outArray("bitwise_and", (o) => C.mlx_bitwise_and(o, a.handle, b.handle, s)));
964
+ }
965
+
966
+ export function bitwiseOr(a: MlxArray, b: MlxArray, s: S = gpuStream): MlxArray {
967
+ return new MlxArray(outArray("bitwise_or", (o) => C.mlx_bitwise_or(o, a.handle, b.handle, s)));
968
+ }
969
+
970
+ export function leftShift(a: MlxArray, b: MlxArray, s: S = gpuStream): MlxArray {
971
+ return new MlxArray(outArray("left_shift", (o) => C.mlx_left_shift(o, a.handle, b.handle, s)));
972
+ }
973
+
974
+ export function rightShift(a: MlxArray, b: MlxArray, s: S = gpuStream): MlxArray {
975
+ return new MlxArray(outArray("right_shift", (o) => C.mlx_right_shift(o, a.handle, b.handle, s)));
976
+ }