mlx-bun 0.0.10 → 0.0.12

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 (216) hide show
  1. package/README.md +107 -32
  2. package/THIRD_PARTY_LICENSES.md +10 -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/colibri-glm52-port.md +1479 -0
  10. package/docs/design/compat-cli-surface-design.md +4 -3
  11. package/docs/design/decode-speed-program.md +172 -0
  12. package/docs/design/docs-reorg-plan.md +1 -1
  13. package/docs/design/dspark-serving-program.md +371 -0
  14. package/docs/design/dspark-speculative-decoding.md +167 -5
  15. package/docs/design/faithful-l1-consolidation.md +310 -0
  16. package/docs/design/generic-model-support.md +8 -5
  17. package/docs/design/grammar-spec-batching-integration.md +301 -0
  18. package/docs/design/minicpm5-decode-megakernel.md +16 -11
  19. package/docs/design/mlx-lm-tool-parity-plan.md +11 -1
  20. package/docs/design/omlx-adoption-map.md +14 -6
  21. package/docs/design/orpo-training.md +34 -30
  22. package/docs/design/paged-kv-cache.md +214 -0
  23. package/docs/design/parallel-slots.md +82 -49
  24. package/docs/design/parity-tier-dag.md +43 -33
  25. package/docs/design/pre-colibri-stabilization.md +329 -0
  26. package/docs/design/repo-cleanup-plan.md +189 -0
  27. package/docs/design/runtime-isolation.md +19 -3
  28. package/docs/design/segmented-backward-training.md +3 -2
  29. package/docs/design/ssd-kv-cold-tier.md +130 -0
  30. package/docs/design/structured-output.md +484 -0
  31. package/docs/design/the-dreaming-master-plan.md +2 -3
  32. package/docs/design/turboquant-kv.md +235 -0
  33. package/docs/design/unified-engine-frontier-plan.md +774 -0
  34. package/docs/design/web-chat-beat-matrix.md +315 -0
  35. package/docs/design/web-chat-redesign.md +1112 -0
  36. package/docs/investigations/colibri-oracle-pin.md +1284 -0
  37. package/docs/investigations/dspark-handoff.md +182 -16
  38. package/docs/investigations/indexshare-performance-spike.md +357 -0
  39. package/docs/investigations/lab-build-journal.md +2 -2
  40. package/docs/investigations/openwiki-evaluation.md +249 -0
  41. package/docs/planning/IDEAS.md +14 -0
  42. package/docs/planning/PRODUCT_ROADMAP.md +2 -3
  43. package/docs/planning/chat-ui-vision-memory-first.md +199 -0
  44. package/docs/planning/chat-ui-vision.md +50 -0
  45. package/docs/planning/memory-docs-and-dag-plan.md +15 -6
  46. package/docs/planning/release-notes-v0.0.11.md +239 -0
  47. package/docs/planning/release-notes-v0.0.12.md +80 -0
  48. package/docs/reference/cli.md +63 -18
  49. package/docs/reference/distribution.md +37 -6
  50. package/docs/reference/embedding.md +40 -16
  51. package/docs/reference/features-matrix.md +99 -0
  52. package/docs/reference/library-api.md +69 -20
  53. package/docs/reference/memory.md +26 -6
  54. package/docs/reference/models.md +60 -3
  55. package/docs/reference/orpo-quickstart.md +3 -4
  56. package/docs/reference/server-api.md +717 -24
  57. package/docs/reference/server-config.md +265 -129
  58. package/docs/reference/training.md +24 -16
  59. package/package.json +5 -3
  60. package/src/anthropic.ts +67 -44
  61. package/src/audio/conformer.ts +647 -0
  62. package/src/audio/decode.ts +133 -0
  63. package/src/audio/features.ts +202 -0
  64. package/src/audio/transcode.ts +67 -0
  65. package/src/chat-template.ts +155 -10
  66. package/src/cli.ts +483 -113
  67. package/src/config.ts +116 -24
  68. package/src/diffusion/diffusion-generate.ts +14 -8
  69. package/src/download.ts +143 -12
  70. package/src/eval/README.md +7 -6
  71. package/src/eval/ifeval.ts +63 -120
  72. package/src/eval/kl.ts +59 -3
  73. package/src/eval/quality-db.ts +2 -2
  74. package/src/eval/runner.ts +17 -8
  75. package/src/eval/tasks/ifeval.ts +167 -31
  76. package/src/expert-io.ts +391 -0
  77. package/src/expert-offload.ts +15 -2
  78. package/src/expert-residency.ts +1134 -0
  79. package/src/expert-usage.ts +415 -0
  80. package/src/flags.ts +13 -0
  81. package/src/generate.ts +438 -35
  82. package/src/grammar.ts +585 -0
  83. package/src/hf-push.ts +1 -1
  84. package/src/hub-rest.ts +242 -0
  85. package/src/index.ts +14 -2
  86. package/src/jobs/runner.ts +37 -18
  87. package/src/kv-store.ts +586 -93
  88. package/src/lora.ts +85 -14
  89. package/src/md-modules.d.ts +42 -0
  90. package/src/media-fetch.ts +426 -0
  91. package/src/memory/rest.ts +245 -0
  92. package/src/memory/vault.ts +74 -0
  93. package/src/mlx/array.ts +149 -19
  94. package/src/mlx/autograd.ts +53 -16
  95. package/src/mlx/checkpoint.ts +46 -13
  96. package/src/mlx/custom-vjp.ts +83 -35
  97. package/src/mlx/ffi.ts +27 -3
  98. package/src/mlx/metal-kernel.ts +7 -4
  99. package/src/mlx/ops.ts +153 -23
  100. package/src/mlx/turboquant-ops.ts +831 -0
  101. package/src/mlx/turboquant-tables.ts +237 -0
  102. package/src/model/batched-mask.ts +62 -1
  103. package/src/model/batched-quant.ts +206 -0
  104. package/src/model/batched-rotating-quant.ts +346 -0
  105. package/src/model/batched-rotating.ts +46 -1
  106. package/src/model/compiled-decode.ts +4 -15
  107. package/src/model/factory.ts +103 -6
  108. package/src/model/gemma4-base.ts +395 -39
  109. package/src/model/gemma4.ts +122 -33
  110. package/src/model/generated/gemma4-12b.ts +9 -13
  111. package/src/model/generated/gemma4-26b.ts +10 -5
  112. package/src/model/generated/gemma4-e4b.ts +14 -9
  113. package/src/model/glm52-atlas.ts +511 -0
  114. package/src/model/glm52-cache.ts +706 -0
  115. package/src/model/glm52-config.ts +262 -0
  116. package/src/model/glm52-container.ts +305 -0
  117. package/src/model/glm52-coupling.ts +388 -0
  118. package/src/model/glm52-dsa.ts +602 -0
  119. package/src/model/glm52-expert-layout.ts +253 -0
  120. package/src/model/glm52-layout.ts +152 -0
  121. package/src/model/glm52-memory.ts +473 -0
  122. package/src/model/glm52-mla.ts +786 -0
  123. package/src/model/glm52-moe.ts +223 -0
  124. package/src/model/glm52-pilot.ts +537 -0
  125. package/src/model/glm52-reference.ts +445 -0
  126. package/src/model/glm52-residency.ts +557 -0
  127. package/src/model/glm52-resident-weights.ts +135 -0
  128. package/src/model/glm52-streamed-experts.ts +453 -0
  129. package/src/model/glm52-streamed-metal.ts +610 -0
  130. package/src/model/glm52-weights.ts +503 -0
  131. package/src/model/glm52.ts +1153 -0
  132. package/src/model/minicpm5.ts +58 -102
  133. package/src/model/paged-kv.ts +253 -0
  134. package/src/model/qwen3-delta.ts +76 -15
  135. package/src/model/qwen3-moe.ts +404 -0
  136. package/src/model/qwen3.ts +9 -8
  137. package/src/model/qwen3_5.ts +88 -23
  138. package/src/model/support.ts +19 -1
  139. package/src/model/universal/dense.ts +40 -5
  140. package/src/model/universal/rope.ts +36 -21
  141. package/src/native/expert_io.c +523 -0
  142. package/src/native-pack.ts +6 -4
  143. package/src/pi-web.ts +1299 -82
  144. package/src/preflight.ts +27 -8
  145. package/src/prompt-cache.ts +293 -33
  146. package/src/quantize/quantizer.ts +16 -1
  147. package/src/registry.ts +57 -2
  148. package/src/responses.ts +62 -49
  149. package/src/serve/batch-scheduler.ts +1050 -78
  150. package/src/serve/generation-gateway.ts +252 -24
  151. package/src/serve/isolate.ts +417 -0
  152. package/src/serve/lane-registry.ts +44 -0
  153. package/src/serve/session-search.ts +326 -0
  154. package/src/server.ts +1701 -175
  155. package/src/spec/assistant-source.ts +150 -0
  156. package/src/spec/deepspec-source.ts +143 -0
  157. package/src/spec/dflash-source.ts +124 -0
  158. package/src/spec/dspark/ab-stats.ts +136 -0
  159. package/src/spec/dspark/calibration.ts +112 -0
  160. package/src/spec/dspark/deepspec-module.ts +801 -0
  161. package/src/spec/dspark/generate-dflash.ts +72 -20
  162. package/src/spec/dspark/loader.ts +23 -0
  163. package/src/spec/dspark/module-dflash.ts +278 -36
  164. package/src/spec/dspark/quantize-drafter.ts +91 -0
  165. package/src/spec/generate.ts +59 -25
  166. package/src/spec/glm52-mtp-source.ts +392 -0
  167. package/src/spec/ngram-source.ts +132 -0
  168. package/src/spec/serve-loop.ts +617 -0
  169. package/src/spec/source.ts +118 -0
  170. package/src/spec/two-model.ts +171 -0
  171. package/src/ssd-cache.ts +111 -35
  172. package/src/tokenizer.ts +288 -2
  173. package/src/tool-approvals.ts +131 -0
  174. package/src/tool-call.ts +438 -22
  175. package/src/train/flash-cce.ts +2 -2
  176. package/src/train/job.ts +0 -12
  177. package/src/train/lora-params.ts +17 -6
  178. package/src/train/trainer.ts +60 -30
  179. package/src/vision/prompt.ts +217 -48
  180. package/src/web/app.html +1197 -1791
  181. package/src/web/app.js +5694 -0
  182. package/src/web/icon.svg +13 -0
  183. package/src/web/manifest.webmanifest +24 -0
  184. package/src/web/src/adapters-panel.ts +288 -0
  185. package/src/web/src/api.ts +57 -0
  186. package/src/web/src/assistant.ts +485 -0
  187. package/src/web/src/chat.ts +1050 -0
  188. package/src/web/src/composer.ts +959 -0
  189. package/src/web/src/dataset.ts +132 -0
  190. package/src/web/src/finetune.ts +267 -0
  191. package/src/web/src/hub.ts +428 -0
  192. package/src/web/src/main.ts +98 -0
  193. package/src/web/src/markdown.ts +0 -0
  194. package/src/web/src/memory-panel.ts +572 -0
  195. package/src/web/src/model-picker.ts +159 -0
  196. package/src/web/src/palette.ts +346 -0
  197. package/src/web/src/protocol.ts +62 -0
  198. package/src/web/src/quantize.ts +167 -0
  199. package/src/web/src/rag.ts +274 -0
  200. package/src/web/src/sessions.ts +469 -0
  201. package/src/web/src/shell.ts +804 -0
  202. package/src/web/src/status.ts +300 -0
  203. package/src/web/src/ui-catalog.ts +92 -0
  204. package/src/web/sw.js +71 -0
  205. package/src/web/vendor/README.md +61 -0
  206. package/src/web/vendor/hljs-LICENSE +29 -0
  207. package/src/web/vendor/hljs-theme.css +59 -0
  208. package/src/web/vendor/hljs.js +26 -0
  209. package/src/web-tools.ts +54 -2
  210. package/src/weights.ts +82 -19
  211. package/src/model/fused-decode-kernel.ts +0 -312
  212. package/src/model/fused-geglu-kernel.ts +0 -198
  213. package/src/model/fused-mlp-kernel.ts +0 -169
  214. package/src/model/fused-swiglu-kernel.ts +0 -240
  215. package/src/model/steel-linear-kernel.ts +0 -96
  216. package/src/status-page.html +0 -497
package/src/config.ts CHANGED
@@ -2,6 +2,11 @@
2
2
  // Parses HF config.json (Gemma 4 unified layout: text_config nested),
3
3
  // the OptiQ per-layer quantization map, and kv_config.json when present.
4
4
 
5
+ import {
6
+ parseGlm52Config,
7
+ type Glm52Config,
8
+ } from "./model/glm52-config";
9
+
5
10
  export interface RopeParams {
6
11
  ropeTheta: number;
7
12
  ropeType: string;
@@ -38,6 +43,13 @@ export interface TextConfig {
38
43
  numExperts: number;
39
44
  topKExperts: number;
40
45
  moeIntermediateSize: number;
46
+ /** Qwen3-MoE sparse-block gating (qwen3_moe): a layer is MoE unless it is
47
+ * in `mlpOnlyLayers` and `(idx+1) % decoderSparseStep == 0` selects it.
48
+ * `normTopkProb` renormalizes the top-k gate weights. Defaults are inert
49
+ * for non-qwen3_moe families. */
50
+ decoderSparseStep: number;
51
+ mlpOnlyLayers: number[];
52
+ normTopkProb: boolean;
41
53
  /** Qwen3.5 hybrid (gated-DeltaNet) geometry; 0/false for other families. */
42
54
  linearNumValueHeads: number;
43
55
  linearNumKeyHeads: number;
@@ -77,6 +89,37 @@ export interface KvQuantSpec {
77
89
  groupSize: number;
78
90
  }
79
91
 
92
+ /** TurboQuant scheme (docs/design/turboquant-kv.md): rotation-based KV
93
+ * quantization, a distinct axis from the uniform/per-layer affine kvQuant
94
+ * above (composes with it in principle; v1 ships as a standalone CLI-only
95
+ * runtime lever, same class as uniform kvBits — see
96
+ * src/generate.ts maybeQuantizeKv). Valid kBits: {2,4,5,8}; vBits:
97
+ * {2,3,4,5,8} (docs/design/turboquant-kv.md's supported Lloyd-Max tables). */
98
+ export interface TurboQuantScheme {
99
+ kBits: number;
100
+ vBits: number;
101
+ }
102
+
103
+ export const TURBOQUANT_VALID_KBITS = [2, 4, 5, 8] as const;
104
+ export const TURBOQUANT_VALID_VBITS = [2, 3, 4, 5, 8] as const;
105
+
106
+ /** Parse `turbo` / `turbo:k<bits>v<bits>` (default k8v3). Returns null for
107
+ * non-turbo strings (caller falls through to the uniform/config parser);
108
+ * throws Error with a user-facing message for a malformed turbo spec. */
109
+ export function parseTurboQuantScheme(raw: string): TurboQuantScheme | null {
110
+ if (raw !== "turbo" && !raw.startsWith("turbo:")) return null;
111
+ if (raw === "turbo") return { kBits: 8, vBits: 3 };
112
+ const m = /^turbo:k(\d+)v(\d+)$/.exec(raw);
113
+ if (!m) throw new Error(`--kv-quant turbo spec must look like "turbo:k<bits>v<bits>" (got "${raw}")`);
114
+ const kBits = Number(m[1]);
115
+ const vBits = Number(m[2]);
116
+ if (!(TURBOQUANT_VALID_KBITS as readonly number[]).includes(kBits))
117
+ throw new Error(`--kv-quant turbo: kBits must be one of ${TURBOQUANT_VALID_KBITS.join(",")} (got ${kBits})`);
118
+ if (!(TURBOQUANT_VALID_VBITS as readonly number[]).includes(vBits))
119
+ throw new Error(`--kv-quant turbo: vBits must be one of ${TURBOQUANT_VALID_VBITS.join(",")} (got ${vBits})`);
120
+ return { kBits, vBits };
121
+ }
122
+
80
123
  export interface ModelConfig {
81
124
  modelDir: string;
82
125
  modelType: string;
@@ -93,6 +136,11 @@ export interface ModelConfig {
93
136
  function parseRope(raw: Record<string, any>): Record<string, RopeParams> {
94
137
  const out: Record<string, RopeParams> = {};
95
138
  for (const [kind, p] of Object.entries(raw ?? {})) {
139
+ // transformers ≥5.10 configs mix flat scalars into the per-attention-type
140
+ // map (`rope_parameters: { full_attention: {...}, rope_theta: null,
141
+ // rope_type: "default", ... }` — the DeepSpec drafter ships this shape);
142
+ // only object entries are attention-type params.
143
+ if (!p || typeof p !== "object") continue;
96
144
  out[kind] = {
97
145
  ropeTheta: p.rope_theta,
98
146
  ropeType: p.rope_type ?? "default",
@@ -103,7 +151,7 @@ function parseRope(raw: Record<string, any>): Record<string, RopeParams> {
103
151
  return out;
104
152
  }
105
153
 
106
- function parseQuantization(raw: Record<string, any> | undefined): QuantizationConfig | null {
154
+ export function parseQuantization(raw: Record<string, any> | undefined): QuantizationConfig | null {
107
155
  if (!raw) return null;
108
156
  const def: QuantSpec = {
109
157
  bits: raw.bits,
@@ -180,6 +228,18 @@ export async function loadModelConfig(modelDir: string): Promise<ModelConfig> {
180
228
  const raw = (await Bun.file(`${modelDir}/config.json`).json()) as Record<string, any>;
181
229
  const modelType = raw.model_type;
182
230
  const isDiffusion = modelType === "diffusion_gemma";
231
+ const isGlm52 = modelType === "glm_moe_dsa";
232
+ // The converted Colibri artifact keeps stop metadata split across the two
233
+ // HF files. Preserve the old config-only behavior for every other family;
234
+ // GLM-5.2 explicitly unions both lists through its dedicated parser.
235
+ let glm52: Glm52Config | null = null;
236
+ if (isGlm52) {
237
+ const generationFile = Bun.file(`${modelDir}/generation_config.json`);
238
+ const generation = await generationFile.exists()
239
+ ? await generationFile.json() as Record<string, any>
240
+ : null;
241
+ glm52 = parseGlm52Config(modelDir, raw, generation);
242
+ }
183
243
  // Gemma 4 unified nests the LM config; plain text models keep it at top level.
184
244
  // DiffusionGemma keeps it flat too, but omits all dims — backfill from defaults.
185
245
  const baseT = (raw.text_config ?? raw) as Record<string, any>;
@@ -195,28 +255,44 @@ export async function loadModelConfig(modelDir: string): Promise<ModelConfig> {
195
255
  const qwenRope = (t.rope_parameters ?? {}) as Record<string, any>;
196
256
 
197
257
  const text: TextConfig = {
198
- hiddenSize: t.hidden_size,
199
- numHiddenLayers: t.num_hidden_layers,
200
- numAttentionHeads: t.num_attention_heads,
201
- numKeyValueHeads: t.num_key_value_heads,
202
- headDim: t.head_dim ?? Math.floor(t.hidden_size / t.num_attention_heads),
203
- numGlobalKeyValueHeads: t.num_global_key_value_heads ?? t.num_key_value_heads,
204
- globalHeadDim: t.global_head_dim ?? t.head_dim ?? Math.floor(t.hidden_size / t.num_attention_heads),
258
+ hiddenSize: glm52?.hiddenSize ?? t.hidden_size,
259
+ numHiddenLayers: glm52?.numHiddenLayers ?? t.num_hidden_layers,
260
+ numAttentionHeads: glm52?.numAttentionHeads ?? t.num_attention_heads,
261
+ numKeyValueHeads: glm52?.numKeyValueHeads ?? t.num_key_value_heads,
262
+ headDim: glm52?.qkHeadDim ?? t.head_dim ?? Math.floor(t.hidden_size / t.num_attention_heads),
263
+ numGlobalKeyValueHeads:
264
+ glm52?.numKeyValueHeads ?? t.num_global_key_value_heads ?? t.num_key_value_heads,
265
+ globalHeadDim:
266
+ glm52?.qkHeadDim ??
267
+ t.global_head_dim ??
268
+ t.head_dim ??
269
+ Math.floor(t.hidden_size / t.num_attention_heads),
205
270
  attentionKEqV: t.attention_k_eq_v ?? false,
206
- intermediateSize: t.intermediate_size,
271
+ intermediateSize: glm52?.intermediateSize ?? t.intermediate_size,
207
272
  hiddenActivation: t.hidden_activation ?? t.hidden_act ?? "gelu_pytorch_tanh",
208
- rmsNormEps: t.rms_norm_eps,
209
- vocabSize: t.vocab_size,
210
- maxPositionEmbeddings: t.max_position_embeddings,
273
+ rmsNormEps: glm52?.rmsNormEps ?? t.rms_norm_eps,
274
+ vocabSize: glm52?.vocabSize ?? t.vocab_size,
275
+ maxPositionEmbeddings:
276
+ glm52?.maxPositionEmbeddings ?? t.max_position_embeddings,
211
277
  slidingWindow: t.sliding_window ?? 0,
212
- layerTypes: t.layer_types ?? (isLlama ? Array(t.num_hidden_layers).fill("full_attention") : []),
278
+ layerTypes: glm52
279
+ ? Array(glm52.numHiddenLayers).fill("full_attention")
280
+ : t.layer_types ?? (isLlama ? Array(t.num_hidden_layers).fill("full_attention") : []),
213
281
  hiddenSizePerLayerInput: t.hidden_size_per_layer_input ?? 0,
214
- vocabSizePerLayerInput: t.vocab_size_per_layer_input ?? t.vocab_size,
282
+ vocabSizePerLayerInput:
283
+ t.vocab_size_per_layer_input ?? glm52?.vocabSize ?? t.vocab_size,
215
284
  numKvSharedLayers: t.num_kv_shared_layers ?? 0,
216
- enableMoeBlock: t.enable_moe_block ?? false,
217
- numExperts: t.num_experts ?? 0,
218
- topKExperts: t.top_k_experts ?? 0,
219
- moeIntermediateSize: t.moe_intermediate_size ?? 0,
285
+ enableMoeBlock: glm52 ? true : t.enable_moe_block ?? false,
286
+ numExperts: glm52?.numRoutedExperts ?? t.num_experts ?? 0,
287
+ // gemma4 uses `top_k_experts`; qwen3_moe uses `num_experts_per_tok`.
288
+ topKExperts:
289
+ glm52?.numExpertsPerToken ?? t.top_k_experts ?? t.num_experts_per_tok ?? 0,
290
+ moeIntermediateSize: glm52?.moeIntermediateSize ?? t.moe_intermediate_size ?? 0,
291
+ decoderSparseStep: glm52 ? 1 : t.decoder_sparse_step ?? 1,
292
+ mlpOnlyLayers: glm52
293
+ ? Array.from({ length: glm52.firstKDenseReplace }, (_, layer) => layer)
294
+ : t.mlp_only_layers ?? [],
295
+ normTopkProb: glm52?.normTopkProb ?? t.norm_topk_prob ?? false,
220
296
  linearNumValueHeads: t.linear_num_value_heads ?? 0,
221
297
  linearNumKeyHeads: t.linear_num_key_heads ?? 0,
222
298
  linearKeyHeadDim: t.linear_key_head_dim ?? 0,
@@ -224,9 +300,19 @@ export async function loadModelConfig(modelDir: string): Promise<ModelConfig> {
224
300
  linearConvKernelDim: t.linear_conv_kernel_dim ?? 0,
225
301
  fullAttentionInterval: t.full_attention_interval ?? 0,
226
302
  attnOutputGate: t.attn_output_gate ?? false,
227
- partialRotaryFactor:
228
- qwenRope.partial_rotary_factor ?? t.partial_rotary_factor ?? 1.0,
229
- ropeParameters: isQwen35
303
+ partialRotaryFactor: glm52
304
+ ? glm52.qkRopeHeadDim / glm52.qkHeadDim
305
+ : qwenRope.partial_rotary_factor ?? t.partial_rotary_factor ?? 1.0,
306
+ ropeParameters: glm52
307
+ ? {
308
+ full_attention: {
309
+ ropeTheta: glm52.ropeTheta,
310
+ ropeType: "default",
311
+ partialRotaryFactor: glm52.qkRopeHeadDim / glm52.qkHeadDim,
312
+ factor: 1.0,
313
+ },
314
+ }
315
+ : isQwen35
230
316
  ? {
231
317
  full_attention: {
232
318
  ropeTheta: qwenRope.rope_theta ?? t.rope_theta ?? 10000,
@@ -250,7 +336,7 @@ export async function loadModelConfig(modelDir: string): Promise<ModelConfig> {
250
336
  finalLogitSoftcapping: t.final_logit_softcapping ?? null,
251
337
  tieWordEmbeddings: t.tie_word_embeddings ?? raw.tie_word_embeddings ?? false,
252
338
  bosTokenId: t.bos_token_id ?? raw.bos_token_id,
253
- eosTokenId: t.eos_token_id ?? raw.eos_token_id,
339
+ eosTokenId: glm52?.eosTokenIds ?? t.eos_token_id ?? raw.eos_token_id,
254
340
  canvasLength: raw.canvas_length ?? t.canvas_length,
255
341
  };
256
342
 
@@ -264,14 +350,20 @@ export async function loadModelConfig(modelDir: string): Promise<ModelConfig> {
264
350
  }));
265
351
  }
266
352
 
267
- const eos = raw.eos_token_id ?? text.eosTokenId;
353
+ const eos = glm52?.eosTokenIds ?? raw.eos_token_id ?? text.eosTokenId;
268
354
  return {
269
355
  modelDir,
270
356
  modelType,
271
357
  architectures: raw.architectures ?? [],
272
358
  dtype: raw.dtype ?? "bfloat16",
273
359
  text,
274
- quantization: parseQuantization(raw.quantization ?? raw.quantization_config),
360
+ // Colibri's config retains the SOURCE FP8 quantization_config, while the
361
+ // serving shards are converted U8 + `.qs` tensors whose int4/int8/group
362
+ // geometry is inferred and validated by Glm52Container. Advertising the
363
+ // source FP8 block as mlx affine quantization would be actively wrong.
364
+ quantization: glm52
365
+ ? null
366
+ : parseQuantization(raw.quantization ?? raw.quantization_config),
275
367
  kvQuant,
276
368
  hasVisionSidecar: await Bun.file(`${modelDir}/optiq_vision.safetensors`).exists(),
277
369
  eosTokenIds: Array.isArray(eos) ? eos : [eos],
@@ -245,9 +245,7 @@ export function diffusionGenerate(
245
245
 
246
246
  let currentCanvas = ops.randint(0, vocab, [1, canvasLength], I32); // RNG: init
247
247
  let draftReveal = ops.zeros([1, canvasLength], Dtype.bool);
248
- let draftCanvas: MlxArray = ops.logicalNot(ops.logicalNot(draftReveal)); // placeholder, replaced below
249
- draftCanvas.dispose();
250
- draftCanvas = ops.reshape(currentCanvas, [1, canvasLength]); // alias copy
248
+ let draftCanvas = ops.reshape(currentCanvas, [1, canvasLength]); // alias copy
251
249
  let argmaxCanvas = ops.reshape(currentCanvas, [1, canvasLength]);
252
250
  let scEmbeddings: MlxArray | null = null;
253
251
  const history: MlxArray[] = [];
@@ -266,7 +264,9 @@ export function diffusionGenerate(
266
264
  logits.dispose();
267
265
 
268
266
  argmaxCanvas.dispose();
269
- argmaxCanvas = ops.argmaxAxis(processed, -1).astype(I32); // [1, L]
267
+ const argmax = ops.argmaxAxis(processed, -1);
268
+ argmaxCanvas = argmax.astype(I32); // [1, L]
269
+ argmax.dispose();
270
270
 
271
271
  if (curStep === 1) {
272
272
  processed.dispose();
@@ -296,7 +296,9 @@ export function diffusionGenerate(
296
296
  currentCanvas.dispose();
297
297
  currentCanvas = newCurrent;
298
298
  draftReveal.dispose();
299
- draftReveal = ops.logicalNot(ops.logicalNot(acceptance)); // = acceptance
299
+ const notAcceptance = ops.logicalNot(acceptance);
300
+ draftReveal = ops.logicalNot(notAcceptance); // = acceptance
301
+ notAcceptance.dispose();
300
302
  draftCanvas.dispose();
301
303
  draftCanvas = ops.reshape(argmaxCanvas, [1, canvasLength]);
302
304
  accepted.dispose();
@@ -341,6 +343,10 @@ export function diffusionGenerate(
341
343
  stableStop &&
342
344
  stableAndConfident(argmaxCanvas, processed, history, stabilityThreshold, stopConfidence)
343
345
  ) {
346
+ // Entropy sampling precomputes the next step's self-conditioning
347
+ // tensor before evaluating this stop. The break means ownership is
348
+ // never transferred to scEmbeddings, so release the pending value.
349
+ nextSc?.dispose();
344
350
  processed.dispose();
345
351
  break;
346
352
  }
@@ -361,9 +367,9 @@ export function diffusionGenerate(
361
367
  draftCanvas.dispose();
362
368
 
363
369
  // Emit from the final argmax canvas.
364
- const blockTokens = [...argmaxCanvas.astype(Dtype.float32).toFloat32()].map((x) =>
365
- Math.round(x),
366
- );
370
+ const argmaxFloat = argmaxCanvas.astype(Dtype.float32);
371
+ const blockTokens = [...argmaxFloat.toFloat32()].map((x) => Math.round(x));
372
+ argmaxFloat.dispose();
367
373
  argmaxCanvas.dispose();
368
374
  blocks.push(blockTokens);
369
375
  totalSteps += stepsThisCanvas;
package/src/download.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // lore"): Xet stalls (we speak plain HTTPS resolve/CDN), no resume on
5
5
  // flaky links (we Range-resume partial blobs), and silent corruption
6
6
  // (every blob is verified — sha256 for LFS files against the API's
7
- // oid, git-blob sha1 for small files against blobId).
7
+ // normalized LFS digest, git-blob sha1 for small files against blobId).
8
8
  //
9
9
  // Writes the exact huggingface_hub cache layout so the registry and
10
10
  // loaders find the result with zero changes:
@@ -18,6 +18,7 @@
18
18
  import {
19
19
  mkdirSync, existsSync, statSync, createWriteStream, renameSync, rmSync,
20
20
  symlinkSync, readFileSync, openSync, closeSync, readSync, writeSync,
21
+ statfsSync,
21
22
  } from "node:fs";
22
23
  import { dirname, join } from "node:path";
23
24
  import { DEFAULT_HUB } from "./registry";
@@ -29,8 +30,8 @@ export interface RepoFile {
29
30
  size: number;
30
31
  /** git blob sha1 (non-LFS identity, and the blob filename for non-LFS). */
31
32
  blobId: string;
32
- /** LFS sha256 + size; present for large files. oid is the blob filename. */
33
- lfs?: { oid: string; size: number };
33
+ /** LFS sha256 + size; present for large files. sha256 is the blob filename. */
34
+ lfs?: { sha256: string; size: number };
34
35
  }
35
36
 
36
37
  export interface RepoListing {
@@ -47,6 +48,56 @@ export interface DownloadOptions {
47
48
  onProgress?: (file: string, received: number, total: number) => void;
48
49
  }
49
50
 
51
+ export interface DownloadSpacePlan {
52
+ /** Bytes still absent after crediting verified blobs and resumable prefixes. */
53
+ remainingBytes: number;
54
+ /** Files that still need at least one byte. */
55
+ remainingFiles: number;
56
+ /** Files already complete in the shared HF blob store. */
57
+ completeFiles: number;
58
+ /** Existing `.incomplete` bytes that will be rehashed and resumed. */
59
+ resumableBytes: number;
60
+ }
61
+
62
+ /** One GiB for filesystem metadata, the final refs write, and normal host
63
+ * operation. The artifact bytes themselves are exact; this is deliberately a
64
+ * small fixed guard rather than a percentage that would add tens of GiB to a
65
+ * Colibri download. */
66
+ export const DOWNLOAD_DISK_RESERVE_BYTES = 2 ** 30;
67
+
68
+ /** Compute the exact remaining payload without opening or hashing any blob.
69
+ * A valid-size final blob is already complete; an in-range `.incomplete`
70
+ * prefix is credited because downloadOneLocked resumes it in place. */
71
+ export function planDownloadSpace(
72
+ files: readonly RepoFile[],
73
+ blobsDir: string,
74
+ ): DownloadSpacePlan {
75
+ let remainingBytes = 0;
76
+ let remainingFiles = 0;
77
+ let completeFiles = 0;
78
+ let resumableBytes = 0;
79
+ for (const file of files) {
80
+ const blobId = file.lfs?.sha256 ?? file.blobId;
81
+ if (!blobId) throw new Error(`no blob id for ${file.rfilename}`);
82
+ const blobPath = join(blobsDir, blobId);
83
+ if (existsSync(blobPath) && statSync(blobPath).size === file.size) {
84
+ completeFiles++;
85
+ continue;
86
+ }
87
+ const partPath = `${blobPath}.incomplete`;
88
+ const partBytes = existsSync(partPath) ? statSync(partPath).size : 0;
89
+ const credited = partBytes >= 0 && partBytes <= file.size ? partBytes : 0;
90
+ resumableBytes += credited;
91
+ remainingBytes += file.size - credited;
92
+ remainingFiles++;
93
+ }
94
+ return { remainingBytes, remainingFiles, completeFiles, resumableBytes };
95
+ }
96
+
97
+ function formatGib(bytes: number): string {
98
+ return `${(bytes / 2 ** 30).toFixed(2)} GiB`;
99
+ }
100
+
50
101
  /** HF auth: explicit > env > the file `hf auth login` writes. */
51
102
  export function hfToken(): string | null {
52
103
  if (process.env.HF_TOKEN) return process.env.HF_TOKEN;
@@ -114,6 +165,28 @@ function acquireLock(blobPath: string, name: string): () => void {
114
165
  throw new Error(`could not acquire ${lockPath}`);
115
166
  }
116
167
 
168
+ /** Reject a repo file path that could escape the snapshot directory once
169
+ * joined onto it. `rfilename` comes verbatim from the remote HF API's
170
+ * `siblings[].rfilename` (listRepoFiles) — for a repo the caller doesn't
171
+ * control (attacker-owned/attacker-referenced), that string is untrusted
172
+ * input reaching a path-join + symlink write (downloadModel's
173
+ * `join(snapDir, f.rfilename)` + `symlinkSync`). node:path's `join()`
174
+ * normalizes `..` segments, so an rfilename like
175
+ * `../../../../home/x/.ssh/authorized_keys` would otherwise resolve
176
+ * OUTSIDE the intended snapshots dir and write a symlink at an arbitrary
177
+ * filesystem path. Reachable over HTTP via POST /api/hub/download
178
+ * (src/hub-rest.ts), which passes any caller-supplied `repo` straight
179
+ * through with no allowlist — this check is the actual gate, not that
180
+ * route. A leading `/` (absolute path) is equally rejected: join() would
181
+ * otherwise treat it as relative to snapDir and still normalize safely,
182
+ * but there's no legitimate reason a repo file entry needs one, and
183
+ * rejecting it removes any doubt. */
184
+ export function isSafeRepoFilename(rfilename: string): boolean {
185
+ if (!rfilename || rfilename.startsWith("/") || rfilename.includes("\0")) return false;
186
+ const parts = rfilename.split("/");
187
+ return parts.every((p) => p !== "" && p !== "." && p !== "..");
188
+ }
189
+
117
190
  /** git blob identity: sha1("blob <size>\0" + content) — what blobId is. */
118
191
  export function gitBlobSha1(bytes: Uint8Array): string {
119
192
  const hasher = new Bun.CryptoHasher("sha1");
@@ -139,16 +212,34 @@ export async function listRepoFiles(
139
212
  );
140
213
  const body = (await res.json()) as {
141
214
  sha: string;
142
- siblings: { rfilename: string; size?: number; blobId?: string; lfs?: { oid: string; size: number } }[];
215
+ siblings: {
216
+ rfilename: string;
217
+ size?: number;
218
+ blobId?: string;
219
+ // The current HF API calls this field `sha256`. Accept the older `oid`
220
+ // spelling too, but normalize it before the download path sees it.
221
+ lfs?: { sha256?: string; oid?: string; size: number };
222
+ }[];
143
223
  };
144
224
  return {
145
225
  sha: body.sha,
146
- files: body.siblings.map((s) => ({
147
- rfilename: s.rfilename,
148
- size: s.lfs?.size ?? s.size ?? 0,
149
- blobId: s.blobId ?? "",
150
- lfs: s.lfs,
151
- })),
226
+ files: body.siblings.map((s) => {
227
+ let lfs: RepoFile["lfs"];
228
+ if (s.lfs) {
229
+ const sha256 = s.lfs.sha256 ?? s.lfs.oid;
230
+ if (!sha256 || !/^[0-9a-f]{64}$/i.test(sha256))
231
+ throw new Error(`HF API returned an invalid LFS sha256 for ${s.rfilename}`);
232
+ lfs = { sha256: sha256.toLowerCase(), size: s.lfs.size };
233
+ }
234
+ if (!lfs && (!s.blobId || !/^[0-9a-f]{40}$/.test(s.blobId)))
235
+ throw new Error(`HF API returned an invalid git blob SHA-1 for ${s.rfilename}`);
236
+ return {
237
+ rfilename: s.rfilename,
238
+ size: lfs?.size ?? s.size ?? 0,
239
+ blobId: s.blobId ?? "",
240
+ lfs,
241
+ };
242
+ }),
152
243
  };
153
244
  }
154
245
 
@@ -294,6 +385,14 @@ export function downloadsSnapshot(): DownloadStatus[] {
294
385
  return downloadLog.slice(-5);
295
386
  }
296
387
 
388
+ /** True when a download for this exact repoId is currently "active" in this
389
+ * process's tracker (used by the Hub's POST /api/hub/download to refuse a
390
+ * duplicate kick-off — same repoId, still running — rather than starting a
391
+ * second writer that would race the first on the same blob lockfile). */
392
+ export function isDownloadActive(repoId: string): boolean {
393
+ return downloadLog.some((d) => d.repoId === repoId && d.state === "active");
394
+ }
395
+
297
396
  export async function downloadModel(
298
397
  repoId: string, opts: DownloadOptions = {},
299
398
  ): Promise<string> {
@@ -303,12 +402,44 @@ export async function downloadModel(
303
402
  const hub = opts.cacheDir ?? DEFAULT_HUB;
304
403
 
305
404
  const listing = await listRepoFiles(repoId, { ...opts, token });
405
+ // Fail the whole download closed (not skip-and-continue) if the repo's
406
+ // file listing contains a path-traversal rfilename — see
407
+ // isSafeRepoFilename's doc comment. This gates BOTH the CLI and the web
408
+ // Hub's POST /api/hub/download (src/hub-rest.ts), which accepts any
409
+ // caller-supplied repo string with no allowlist of its own.
410
+ for (const f of listing.files) {
411
+ if (!isSafeRepoFilename(f.rfilename)) {
412
+ throw new Error(`${repoId}: refusing unsafe file path in repo listing: ${JSON.stringify(f.rfilename)}`);
413
+ }
414
+ }
306
415
  const repoDir = join(hub, `models--${repoId.replaceAll("/", "--")}`);
307
416
  const blobsDir = join(repoDir, "blobs");
308
417
  const snapDir = join(repoDir, "snapshots", listing.sha);
309
418
  mkdirSync(blobsDir, { recursive: true });
310
419
  mkdirSync(join(repoDir, "refs"), { recursive: true });
311
420
 
421
+ // Preflight before the first network payload. This matters for direct
422
+ // Colibri artifacts (hundreds of GiB): discovering a full volume after the
423
+ // 140th shard is not a usable failure mode. Existing verified blobs and
424
+ // partial prefixes are credited exactly, so rerunning after interruption is
425
+ // both resumable and progressively cheaper.
426
+ const space = planDownloadSpace(listing.files, blobsDir);
427
+ if (space.remainingBytes > 0) {
428
+ const fs = statfsSync(blobsDir);
429
+ const availableBytes = fs.bavail * fs.bsize;
430
+ const requiredBytes = space.remainingBytes + DOWNLOAD_DISK_RESERVE_BYTES;
431
+ if (requiredBytes > availableBytes) {
432
+ throw new Error(
433
+ `insufficient disk space for ${repoId}: ${formatGib(space.remainingBytes)} ` +
434
+ `remains across ${space.remainingFiles} files, but only ` +
435
+ `${formatGib(availableBytes)} is available in ${hub} ` +
436
+ `(${formatGib(DOWNLOAD_DISK_RESERVE_BYTES)} safety reserve). ` +
437
+ `Free space or choose another cache directory, then rerun; ` +
438
+ `${formatGib(space.resumableBytes)} of partial data will resume.`,
439
+ );
440
+ }
441
+ }
442
+
312
443
  const status: DownloadStatus = {
313
444
  repoId, state: "active", currentFile: null,
314
445
  receivedBytes: 0, totalBytes: listing.files.reduce((a, f) => a + f.size, 0),
@@ -329,7 +460,7 @@ export async function downloadModel(
329
460
 
330
461
  try {
331
462
  for (const f of listing.files) {
332
- const blobId = f.lfs?.oid ?? f.blobId;
463
+ const blobId = f.lfs?.sha256 ?? f.blobId;
333
464
  if (!blobId) throw new Error(`no blob id for ${f.rfilename} (API response missing ?blobs=true data)`);
334
465
  const blobPath = join(blobsDir, blobId);
335
466
 
@@ -338,7 +469,7 @@ export async function downloadModel(
338
469
  status.currentFile = f.rfilename;
339
470
  await downloadOne(url, token, blobPath, {
340
471
  size: f.size, name: f.rfilename,
341
- ...(f.lfs ? { sha256: f.lfs.oid } : { sha1: f.blobId }),
472
+ ...(f.lfs ? { sha256: f.lfs.sha256 } : { sha1: f.blobId }),
342
473
  }, (file, received, total) => {
343
474
  status.receivedBytes = doneBytes + received;
344
475
  sampleRate(status.receivedBytes);
@@ -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/`.