mlx-bun 0.0.13 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (224) hide show
  1. package/README.md +31 -21
  2. package/bin/mlx-bun.mjs +1 -1
  3. package/docs/{design → archive}/hlg-sampling.md +3 -3
  4. package/docs/{investigations → archive/investigations}/hlg-sampling-investigation.md +1 -1
  5. package/docs/archive/investigations/pareto-specialized-runtime-findings.md +92 -0
  6. package/docs/{planning → archive/planning}/chat-ui-vision.md +1 -1
  7. package/docs/{planning → archive/planning}/release-notes-v0.0.9.md +1 -1
  8. package/docs/{planning → archive/planning}/website-readme-pass-plan.md +1 -1
  9. package/docs/design/colibri-glm52-port.md +1 -1
  10. package/docs/design/compat-cli-surface-design.md +1 -1
  11. package/docs/design/decode-speed-program.md +1 -1
  12. package/docs/design/docs-reorg-plan.md +1 -1
  13. package/docs/design/dspark-serving-program.md +1 -1
  14. package/docs/design/dspark-speculative-decoding.md +53 -3
  15. package/docs/design/grammar-spec-batching-integration.md +1 -1
  16. package/docs/design/mlx-lm-serving-execution-seam.md +81 -0
  17. package/docs/design/orpo-dynamic-lambda.md +1 -1
  18. package/docs/design/orpo-future-enhancements.md +1 -1
  19. package/docs/design/orpo-training.md +2 -2
  20. package/docs/design/parity-tier-dag.md +1 -1
  21. package/docs/design/repo-cleanup-plan.md +46 -4
  22. package/docs/design/segmented-backward-training.md +2 -2
  23. package/docs/design/ssd-kv-cold-tier.md +127 -0
  24. package/docs/design/turboquant-weights.md +467 -0
  25. package/docs/design/w4a16-compute-precision-spike.md +1 -1
  26. package/docs/design/web-chat-redesign.md +5 -5
  27. package/docs/planning/chunk-task-roadmap.md +1 -1
  28. package/docs/planning/curve-sampler-research-plan.md +4 -4
  29. package/docs/planning/memory-docs-and-dag-plan.md +1 -1
  30. package/docs/planning/release-notes-v0.2.0.md +49 -0
  31. package/docs/planning/video-series.md +421 -0
  32. package/docs/reference/cli.md +15 -4
  33. package/docs/reference/distribution.md +1 -0
  34. package/docs/reference/features-matrix.md +3 -2
  35. package/docs/reference/library-api.md +41 -1
  36. package/docs/reference/models.md +33 -0
  37. package/docs/reference/server-api.md +69 -14
  38. package/docs/reference/server-config.md +57 -33
  39. package/package.json +2 -2
  40. package/src/anthropic.ts +92 -3
  41. package/src/cli.ts +131 -36
  42. package/src/eval/kl.ts +27 -20
  43. package/src/eval/runner.ts +23 -4
  44. package/src/eval/tasks/bfcl.ts +2 -1
  45. package/src/eval/tasks/gsm8k.ts +2 -1
  46. package/src/eval/tasks/hashhop.ts +2 -1
  47. package/src/eval/tasks/humaneval.ts +5 -4
  48. package/src/eval/tasks/ifeval.ts +2 -1
  49. package/src/eval/tasks/mmlu.ts +2 -1
  50. package/src/expert-io.ts +3 -1
  51. package/src/expert-offload.ts +3 -1
  52. package/src/expert-trace.ts +3 -1
  53. package/src/fit.ts +24 -43
  54. package/src/flags.ts +7 -9
  55. package/src/generate.ts +107 -92
  56. package/src/grammar.ts +2 -1
  57. package/src/harness-pi.ts +1 -1
  58. package/src/index.ts +20 -0
  59. package/src/jobs/job-entry.ts +3 -2
  60. package/src/kv-scheme.ts +199 -0
  61. package/src/kv-store.ts +370 -205
  62. package/src/media-fetch.ts +19 -3
  63. package/src/memory/model.ts +2 -1
  64. package/src/memory/vault.ts +2 -1
  65. package/src/memory-plan.ts +18 -0
  66. package/src/mlx/ffi.ts +8 -11
  67. package/src/mlx/ops.ts +31 -6
  68. package/src/model/batched-mask.ts +1 -1
  69. package/src/model/batched-quant.ts +1 -1
  70. package/src/model/batched-rotating-quant.ts +43 -97
  71. package/src/model/batched-rotating-state.ts +73 -0
  72. package/src/model/batched-rotating.ts +56 -105
  73. package/src/model/batched-row-storage.ts +127 -0
  74. package/src/model/compiled-decode.ts +3 -2
  75. package/src/model/factory.ts +44 -49
  76. package/src/model/gemma4-base.ts +163 -10
  77. package/src/model/generated/gemma4-12b.ts +50 -146
  78. package/src/model/generated/gemma4-26b.ts +32 -92
  79. package/src/model/generated/gemma4-e4b.ts +26 -74
  80. package/src/model/glm52-cache.ts +19 -9
  81. package/src/model/glm52-memory.ts +28 -1
  82. package/src/model/minicpm5.ts +2 -1
  83. package/src/model/profile.ts +433 -0
  84. package/src/model/qwen3-delta.ts +98 -2
  85. package/src/model/qwen3-mrope.ts +252 -0
  86. package/src/model/qwen3_5.ts +217 -25
  87. package/src/native/frame_extract.swift +140 -0
  88. package/src/native-pack.ts +13 -6
  89. package/src/pi-web.ts +6 -5
  90. package/src/prompt-cache.ts +2 -2
  91. package/src/quantize/atomic-output.ts +38 -0
  92. package/src/quantize/calibration.ts +2 -1
  93. package/src/quantize/config-writer.ts +6 -0
  94. package/src/quantize/index.ts +28 -1
  95. package/src/quantize/job.ts +11 -0
  96. package/src/quantize/quantizer.ts +265 -100
  97. package/src/quantize/rotate.ts +603 -0
  98. package/src/quantize/safetensors-writer.ts +79 -1
  99. package/src/quantize/sensitivity.ts +41 -59
  100. package/src/quantize/weight-transform.ts +292 -0
  101. package/src/responses.ts +102 -2
  102. package/src/runtime-config.ts +67 -0
  103. package/src/sampler.ts +272 -0
  104. package/src/serve/admin-routes.ts +184 -0
  105. package/src/serve/aux-routes.ts +132 -0
  106. package/src/serve/batch-scheduler.ts +200 -87
  107. package/src/serve/completion-executor.ts +346 -0
  108. package/src/serve/completion-sink.ts +157 -0
  109. package/src/serve/discovery-routes.ts +205 -0
  110. package/src/serve/generation-gateway.ts +116 -109
  111. package/src/serve/kv-budget.ts +14 -0
  112. package/src/serve/lab-routes.ts +338 -0
  113. package/src/serve/model-admin-routes.ts +153 -0
  114. package/src/serve/prompt-response-trace.ts +147 -0
  115. package/src/serve/request-plan.ts +158 -0
  116. package/src/serve/static-routes.ts +101 -0
  117. package/src/server.ts +704 -1400
  118. package/src/spec/dflash-source.ts +2 -1
  119. package/src/spec/dspark/module-dflash.ts +1 -1
  120. package/src/spec/glm52-mtp-source.ts +8 -0
  121. package/src/spec/qwen-mtp-source.ts +431 -0
  122. package/src/spec/serve-loop.ts +36 -68
  123. package/src/ssd-cache.ts +17 -14
  124. package/src/ssd-durability.ts +195 -0
  125. package/src/train/flash-cce.ts +12 -11
  126. package/src/train/forward.ts +32 -1
  127. package/src/train/loss.ts +2 -1
  128. package/src/train/segmented.ts +4 -3
  129. package/src/train/trainer.ts +3 -2
  130. package/src/vision/preprocess.ts +1 -1
  131. package/src/vision/prompt.ts +47 -1
  132. package/src/vision/qwen3vl-preprocess.ts +409 -0
  133. package/src/vision/qwen3vl-prompt.ts +196 -0
  134. package/src/vision/qwen3vl-tower.ts +513 -0
  135. package/src/vision/video-frames.ts +148 -0
  136. package/src/web/app.js +10 -3
  137. package/src/web/src/chat.ts +12 -5
  138. package/src/weights.ts +81 -26
  139. /package/docs/{investigations → archive/investigations}/chunk-finetune-experiment.md +0 -0
  140. /package/docs/{investigations → archive/investigations}/colibri-oracle-pin.md +0 -0
  141. /package/docs/{investigations → archive/investigations}/curve-bisector-routing.md +0 -0
  142. /package/docs/{investigations → archive/investigations}/curve-runs/bisector-route.json +0 -0
  143. /package/docs/{investigations → archive/investigations}/curve-runs/bisector-trace.json +0 -0
  144. /package/docs/{investigations → archive/investigations}/curve-runs/curve-terrain.json +0 -0
  145. /package/docs/{investigations → archive/investigations}/curve-runs/curve-terrain.svg +0 -0
  146. /package/docs/{investigations → archive/investigations}/curve-terrain.html +0 -0
  147. /package/docs/{investigations → archive/investigations}/decode-roofline-lookagain.md +0 -0
  148. /package/docs/{investigations → archive/investigations}/dspark-handoff.md +0 -0
  149. /package/docs/{investigations → archive/investigations}/expert-offload-single-user-moe.md +0 -0
  150. /package/docs/{investigations → archive/investigations}/hlg-ab.html +0 -0
  151. /package/docs/{investigations → archive/investigations}/hlg-explorer.html +0 -0
  152. /package/docs/{investigations → archive/investigations}/hlg-figs/cliff.svg +0 -0
  153. /package/docs/{investigations → archive/investigations}/hlg-figs/cross.svg +0 -0
  154. /package/docs/{investigations → archive/investigations}/hlg-figs/edges.svg +0 -0
  155. /package/docs/{investigations → archive/investigations}/hlg-figs/k20-grids.svg +0 -0
  156. /package/docs/{investigations → archive/investigations}/hlg-figs/k20-peaks.svg +0 -0
  157. /package/docs/{investigations → archive/investigations}/hlg-figs/lw-curve.svg +0 -0
  158. /package/docs/{investigations → archive/investigations}/hlg-figs/terrain.svg +0 -0
  159. /package/docs/{investigations → archive/investigations}/hlg-report.html +0 -0
  160. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-ab.json +0 -0
  161. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-blind-prompts.md +0 -0
  162. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-blind.json +0 -0
  163. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-cliff.json +0 -0
  164. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-cross.json +0 -0
  165. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-edge.json +0 -0
  166. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-eval1.json +0 -0
  167. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-frontier.json +0 -0
  168. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-grid.json +0 -0
  169. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-grid2.json +0 -0
  170. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-lw-coarse.json +0 -0
  171. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-map.json +0 -0
  172. /package/docs/{investigations → archive/investigations}/hlg-runs/run0-original.log +0 -0
  173. /package/docs/{investigations → archive/investigations}/hlg-runs/runA-recommended.log +0 -0
  174. /package/docs/{investigations → archive/investigations}/hlg-runs/runAA-cliff-cpm5.log +0 -0
  175. /package/docs/{investigations → archive/investigations}/hlg-runs/runAB-grid2-e4b.log +0 -0
  176. /package/docs/{investigations → archive/investigations}/hlg-runs/runAC-peak-e4b-partial.log +0 -0
  177. /package/docs/{investigations → archive/investigations}/hlg-runs/runAD-ab-samples.log +0 -0
  178. /package/docs/{investigations → archive/investigations}/hlg-runs/runAE-edge-e4b.log +0 -0
  179. /package/docs/{investigations → archive/investigations}/hlg-runs/runAF-logit-calibration.log +0 -0
  180. /package/docs/{investigations → archive/investigations}/hlg-runs/runAG-eval-config2.log +0 -0
  181. /package/docs/{investigations → archive/investigations}/hlg-runs/runB-gain-variants.log +0 -0
  182. /package/docs/{investigations → archive/investigations}/hlg-runs/runC-pivots.log +0 -0
  183. /package/docs/{investigations → archive/investigations}/hlg-runs/runD-oetf.log +0 -0
  184. /package/docs/{investigations → archive/investigations}/hlg-runs/runE-eotf.log +0 -0
  185. /package/docs/{investigations → archive/investigations}/hlg-runs/runF-shaper-working.log +0 -0
  186. /package/docs/{investigations → archive/investigations}/hlg-runs/runG-shaper-sweep.log +0 -0
  187. /package/docs/{investigations → archive/investigations}/hlg-runs/runH-wsweep.log +0 -0
  188. /package/docs/{investigations → archive/investigations}/hlg-runs/runI-asweep.log +0 -0
  189. /package/docs/{investigations → archive/investigations}/hlg-runs/runJ-knobmap.log +0 -0
  190. /package/docs/{investigations → archive/investigations}/hlg-runs/runK-frontier.log +0 -0
  191. /package/docs/{investigations → archive/investigations}/hlg-runs/runL-widemap.log +0 -0
  192. /package/docs/{investigations → archive/investigations}/hlg-runs/runM-frontier-emb.log +0 -0
  193. /package/docs/{investigations → archive/investigations}/hlg-runs/runN-verify.log +0 -0
  194. /package/docs/{investigations → archive/investigations}/hlg-runs/runO-frontier-seed5000.log +0 -0
  195. /package/docs/{investigations → archive/investigations}/hlg-runs/runP-grid-AxSm.log +0 -0
  196. /package/docs/{investigations → archive/investigations}/hlg-runs/runQ-grid-tgW.log +0 -0
  197. /package/docs/{investigations → archive/investigations}/hlg-runs/runR-blind-transcript.log +0 -0
  198. /package/docs/{investigations → archive/investigations}/hlg-runs/runS-cross-coherence.log +0 -0
  199. /package/docs/{investigations → archive/investigations}/hlg-runs/runT-cross-cpm5.log +0 -0
  200. /package/docs/{investigations → archive/investigations}/hlg-runs/runU-cross-12b.log +0 -0
  201. /package/docs/{investigations → archive/investigations}/hlg-runs/runV-lw-e4b.log +0 -0
  202. /package/docs/{investigations → archive/investigations}/hlg-runs/runW-lw-12b.log +0 -0
  203. /package/docs/{investigations → archive/investigations}/hlg-runs/runX-lw-cpm5.log +0 -0
  204. /package/docs/{investigations → archive/investigations}/hlg-runs/runY-cliff-e4b.log +0 -0
  205. /package/docs/{investigations → archive/investigations}/hlg-runs/runZ-cliff-12b.log +0 -0
  206. /package/docs/{investigations → archive/investigations}/indexshare-performance-spike.md +0 -0
  207. /package/docs/{investigations → archive/investigations}/kernel-perf-review-2026-07.md +0 -0
  208. /package/docs/{investigations → archive/investigations}/lab-build-journal.md +0 -0
  209. /package/docs/{investigations → archive/investigations}/openwiki-evaluation.md +0 -0
  210. /package/docs/{investigations → archive/investigations}/orpo-base-uf-experiment-and-directions.md +0 -0
  211. /package/docs/{investigations → archive/investigations}/orpo-flash-cce-pin-leak.md +0 -0
  212. /package/docs/{investigations → archive/investigations}/orpo-uf-testing-handoff.md +0 -0
  213. /package/docs/{investigations → archive/investigations}/pi-builtin-investigation.md +0 -0
  214. /package/docs/{investigations → archive/investigations}/segmented-backward-handoff.md +0 -0
  215. /package/docs/{investigations → archive/investigations}/starter-model-port-handoff.md +0 -0
  216. /package/docs/{investigations → archive/investigations}/steel-flash-cce-handoff.md +0 -0
  217. /package/docs/{investigations → archive/investigations}/trainer-validation-experiment.md +0 -0
  218. /package/docs/{planning → archive/planning}/chat-ui-vision-memory-first.md +0 -0
  219. /package/docs/{planning → archive/planning}/journal.md +0 -0
  220. /package/docs/{planning → archive/planning}/release-notes-v0.0.10.md +0 -0
  221. /package/docs/{planning → archive/planning}/release-notes-v0.0.11.md +0 -0
  222. /package/docs/{planning → archive/planning}/release-notes-v0.0.12.md +0 -0
  223. /package/docs/{planning → archive/planning}/release-notes-v0.0.13.md +0 -0
  224. /package/docs/{planning → archive/planning}/web-ui-pass-plan.md +0 -0
@@ -2,13 +2,40 @@
2
2
  // import from here: quantize a model directory, write sharded safetensors,
3
3
  // build the config block, and resolve/inspect models for the wizard UI.
4
4
 
5
- export { quantizeModelDir, isQuantizable } from "./quantizer";
5
+ export { quantizeModelDir, isQuantizable, withPreparedProbe } from "./quantizer";
6
6
  export type {
7
+ PreparedProbe,
8
+ ProbeSource,
7
9
  QuantizeOptions,
8
10
  QuantizeResult,
9
11
  ProgressEvent,
10
12
  } from "./quantizer";
11
13
 
14
+ export {
15
+ planQwen35Fold,
16
+ planQwenMtpFold,
17
+ } from "./rotate";
18
+ export type {
19
+ FoldOptions,
20
+ QwenFoldOptions,
21
+ } from "./rotate";
22
+
23
+ export {
24
+ automaticRotationWeightTransform,
25
+ llamaWeightTransform,
26
+ planLlamaWeightTransform,
27
+ qwen35WeightTransform,
28
+ qwenMtpWeightTransform,
29
+ } from "./weight-transform";
30
+ export type {
31
+ LlamaWeightTransformPlan,
32
+ QwenWeightTransformPlan,
33
+ WeightTransform,
34
+ WeightTransformContext,
35
+ WeightTransformMetadata,
36
+ WeightTransformPlan,
37
+ } from "./weight-transform";
38
+
12
39
  export {
13
40
  writeShardedSafetensors,
14
41
  DEFAULT_SHARD_BYTES,
@@ -13,6 +13,7 @@ import { registerRunner } from "../jobs/runner";
13
13
  import { Registry } from "../registry";
14
14
  import { loadModelConfig } from "../config";
15
15
  import { quantizeModelDir, type QuantizeOptions } from "./quantizer";
16
+ import { automaticRotationWeightTransform } from "./weight-transform";
16
17
 
17
18
  /** Resolve the source model directory from a job config. Accepts an explicit
18
19
  * filesystem path (src_dir) or a registry query / model id (model_id). */
@@ -57,6 +58,9 @@ export const quantizeRunner: JobRunner = async (emit: Emit, config) => {
57
58
  // MUST be forwarded into opts or quantizeModelDir silently runs uniform.
58
59
  const targetBpw = config.target_bpw != null ? Number(config.target_bpw) : undefined;
59
60
  const mixed = targetBpw !== undefined;
61
+ const rotationSeed = Number(config.rotation_seed ?? 42);
62
+ if (config.rotate_weights && !Number.isInteger(rotationSeed))
63
+ throw new Error(`quantize job: rotation_seed must be an integer (got ${String(config.rotation_seed)})`);
60
64
 
61
65
  emit({
62
66
  type: "stage",
@@ -74,6 +78,13 @@ export const quantizeRunner: JobRunner = async (emit: Emit, config) => {
74
78
  ...(config.reference ? { reference: String(config.reference) } : {}),
75
79
  ...(config.calibration_mix ? { calibrationMix: String(config.calibration_mix) } : {}),
76
80
  ...(config.n_calibration != null ? { nCalibration: Number(config.n_calibration) } : {}),
81
+ ...(config.rotate_weights
82
+ ? {
83
+ weightTransform: automaticRotationWeightTransform({
84
+ seed: rotationSeed,
85
+ }),
86
+ }
87
+ : {}),
77
88
  };
78
89
 
79
90
  const r = await quantizeModelDir(srcDir, outDir, opts, (e) =>
@@ -37,6 +37,12 @@ import {
37
37
  import { loadLlmCalibration } from "./calibration";
38
38
  import { analyzeSensitivityExact, type SensitivityResult } from "./sensitivity";
39
39
  import { optimizeMixedPrecision } from "./allocator";
40
+ import type {
41
+ WeightTransform,
42
+ WeightTransformContext,
43
+ WeightTransformPlan,
44
+ } from "./weight-transform";
45
+ import { writeAtomicDirectory } from "./atomic-output";
40
46
 
41
47
  /** Quantize the allocator cache this often (in modules processed). */
42
48
  const CACHE_CLEAR_EVERY = 16;
@@ -56,6 +62,8 @@ export interface QuantizeOptions {
56
62
  * block (mlx's "not quantized" convention). Shape-ineligible modules
57
63
  * never reach this. */
58
64
  quantizePredicate?: (base: string, fullShape: number[]) => boolean;
65
+ /** Optional offline basis transform applied before quantization. */
66
+ weightTransform?: WeightTransform;
59
67
 
60
68
  // --- mixed-precision (OptiQ sensitivity + knapsack) -------------------
61
69
  // Setting targetBpw switches quantizeModelDir to the mixed path: it loads the
@@ -73,6 +81,23 @@ export interface QuantizeOptions {
73
81
  calibrationMix?: string;
74
82
  /** Number of calibration samples (forward passes per layer×bit probe). */
75
83
  nCalibration?: number;
84
+ /** Dependency used to provide the loadable model for sensitivity sweeps. */
85
+ probeSource?: ProbeSource;
86
+ }
87
+
88
+ export interface PreparedProbe {
89
+ modelDir: string;
90
+ dispose(): void | Promise<void>;
91
+ }
92
+
93
+ /** Supplies a loadable sensitivity model without re-entering the top-level
94
+ * quantization orchestrator. Tests and callers may inject an existing probe. */
95
+ export interface ProbeSource {
96
+ prepare(
97
+ srcDir: string,
98
+ opts: QuantizeOptions,
99
+ onProgress?: (e: ProgressEvent) => void,
100
+ ): Promise<PreparedProbe>;
76
101
  }
77
102
 
78
103
  export interface ProgressEvent {
@@ -124,9 +149,6 @@ export async function quantizeModelDir(
124
149
  opts: QuantizeOptions,
125
150
  onProgress?: (e: ProgressEvent) => void,
126
151
  ): Promise<QuantizeResult> {
127
- const groupSize = opts.groupSize;
128
- const mode = opts.mode ?? "affine";
129
-
130
152
  // Mixed-precision path: derive a per-module bit allocation first, then write.
131
153
  let perLayerBits: Map<string, number> | undefined;
132
154
  let mixedMeta: MixedMeta | undefined;
@@ -136,6 +158,34 @@ export async function quantizeModelDir(
136
158
  mixedMeta = alloc.meta;
137
159
  }
138
160
 
161
+ return writeAtomicDirectory(outDir, (stagingDir) =>
162
+ writeQuantizedModelDir(
163
+ srcDir,
164
+ stagingDir,
165
+ opts,
166
+ perLayerBits,
167
+ mixedMeta,
168
+ onProgress,
169
+ outDir,
170
+ ),
171
+ );
172
+ }
173
+
174
+ /** Lower-level writer used by the public orchestrator and its probe source.
175
+ * Deliberately does not perform sensitivity/allocation or call back into
176
+ * quantizeModelDir. */
177
+ async function writeQuantizedModelDir(
178
+ srcDir: string,
179
+ outDir: string,
180
+ opts: QuantizeOptions,
181
+ perLayerBits?: Map<string, number>,
182
+ mixedMeta?: MixedMeta,
183
+ onProgress?: (e: ProgressEvent) => void,
184
+ publishedOutDir = outDir,
185
+ ): Promise<QuantizeResult> {
186
+ const groupSize = opts.groupSize;
187
+ const mode = opts.mode ?? "affine";
188
+
139
189
  const bits = opts.bits;
140
190
 
141
191
  mkdirSync(outDir, { recursive: true });
@@ -148,13 +198,31 @@ export async function quantizeModelDir(
148
198
  const config = await loadModelConfig(srcDir);
149
199
  const srcQuant: QuantizationConfig | null = config.quantization;
150
200
  const weights = await Weights.open(srcDir);
201
+ let transformPlan: WeightTransformPlan | undefined;
202
+ let transformContext: WeightTransformContext | undefined;
203
+ try {
204
+ if (opts.weightTransform && srcQuant)
205
+ throw new Error(
206
+ `weight transform ${opts.weightTransform.id} requires full-precision source weights`,
207
+ );
208
+ transformPlan = opts.weightTransform?.plan(weights.tensorNames, config);
209
+ if (transformPlan) {
210
+ validateTransformPlan(transformPlan, weights.tensorNames);
211
+ transformContext = opts.weightTransform!.createContext(weights, transformPlan);
212
+ progress("transforming", `Planned ${transformPlan.id}`, 0);
213
+ }
214
+ } catch (error) {
215
+ transformContext?.dispose();
216
+ weights.dispose();
217
+ throw error;
218
+ }
151
219
 
152
220
  // Preserve the source tensor order so the output diff is mlx-lm-shaped.
153
221
  // tensorNames is sorted; we walk modules in that order. For each module's
154
222
  // `.weight` we decide quantize / pass-through, then emit its tensors. We
155
223
  // skip the source `.scales`/`.biases` of already-quantized modules (they're
156
224
  // regenerated) and pass through everything else verbatim.
157
- const names = weights.tensorNames;
225
+ const names = transformPlan?.outputNames ?? weights.tensorNames;
158
226
 
159
227
  // Group names by module base so we can detect "already quantized" (has a
160
228
  // sibling `.scales`) and process a module atomically.
@@ -194,26 +262,28 @@ export async function quantizeModelDir(
194
262
  if (!hasWeight) {
195
263
  // Module with only scales/biases and no weight — shouldn't happen, but
196
264
  // pass any present tensors through unchanged.
197
- for (const suf of suffixes) out.push(named(weights, `${base}.${suf}`));
265
+ for (const suf of suffixes)
266
+ out.push(outputTensor(weights, transformPlan, transformContext, `${base}.${suf}`));
198
267
  bumpProgress();
199
268
  continue;
200
269
  }
201
270
 
202
271
  // Materialize the full-precision weight for this module.
203
272
  const weightName = `${base}.weight`;
204
- const shape = weights.info(weightName).shape;
273
+ let fullWeight = outputArray(weights, transformPlan, transformContext, weightName);
274
+ const shape = fullWeight.shape;
205
275
 
206
276
  // Determine the *original* (full-precision) shape: if already quantized,
207
277
  // the packed weight's last dim is compressed — recover the real width
208
278
  // from the scales (groups × srcGroupSize) when dequantizing.
209
- let fullWeight = weights.tensor(weightName);
210
279
  let materialized: MlxArray | null = null;
211
280
 
212
281
  if (alreadyQuant) {
213
282
  const srcSpec = quantFor(srcQuant, base);
214
283
  if (!srcSpec) {
215
284
  // Has scales but config says unquantized — fall back to passthrough.
216
- for (const suf of suffixes) out.push(named(weights, `${base}.${suf}`));
285
+ for (const suf of suffixes)
286
+ out.push(outputTensor(weights, transformPlan, transformContext, `${base}.${suf}`));
217
287
  bumpProgress();
218
288
  continue;
219
289
  }
@@ -263,6 +333,7 @@ export async function quantizeModelDir(
263
333
  // bf16 matches the reference dtype and the scales/biases dtype on disk).
264
334
  const bf16 = fullWeight.astype(Dtype.bfloat16, cpuStream);
265
335
  if (materialized) materialized.dispose();
336
+ else if (transformContext) fullWeight.dispose();
266
337
 
267
338
  const q = quantize(bf16, groupSize, moduleBits, mode, cpuStream);
268
339
  bf16.dispose();
@@ -283,9 +354,10 @@ export async function quantizeModelDir(
283
354
  }
284
355
 
285
356
  // Pass through non-weight/scale/bias tensors (e.g. anything unusual).
286
- for (const name of passthroughNames) out.push(named(weights, name));
357
+ for (const name of passthroughNames)
358
+ out.push(outputTensor(weights, transformPlan, transformContext, name));
287
359
 
288
- progress("writing", `Writing ${out.length} tensors to ${outDir}`, 1);
360
+ progress("writing", `Writing ${out.length} tensors to ${publishedOutDir}`, 1);
289
361
  const write = writeShardedSafetensors(outDir, out);
290
362
 
291
363
  const achievedBpw = quantizedParams > 0 ? quantizedBits / quantizedParams : 0;
@@ -302,7 +374,9 @@ export async function quantizeModelDir(
302
374
  // emit its standard optiq_metadata.json sidecar. For mixed mode we write a
303
375
  // richer OptiQ-style sidecar ourselves below (method "mixed_precision").
304
376
  const block = buildQuantizationBlock({ bits: defaultBits, groupSize, mode }, perLayer);
305
- await writeQuantizedConfig(config.raw, outDir, block, {
377
+ const outputConfig = transformedConfig(config.raw, transformPlan);
378
+ const weightTransforms = transformPlan ? [transformPlan.metadata] : undefined;
379
+ await writeQuantizedConfig(outputConfig, outDir, block, {
306
380
  srcDir,
307
381
  optiq: mixedMeta
308
382
  ? undefined
@@ -313,6 +387,7 @@ export async function quantizeModelDir(
313
387
  group_size: groupSize,
314
388
  achieved_bpw: achievedBpw,
315
389
  per_layer_count: nQuantized,
390
+ weight_transforms: weightTransforms,
316
391
  },
317
392
  });
318
393
 
@@ -331,6 +406,7 @@ export async function quantizeModelDir(
331
406
  n_high: mixedMeta.nHigh,
332
407
  n_low: mixedMeta.nLow,
333
408
  per_layer_count: nQuantized,
409
+ weight_transforms: weightTransforms,
334
410
  per_layer: Object.fromEntries(
335
411
  [...perLayer].map(([p, e]) => [
336
412
  p,
@@ -344,13 +420,14 @@ export async function quantizeModelDir(
344
420
  );
345
421
  }
346
422
 
347
- progress("done", `Quantized ${nQuantized} modules → ${outDir}`, 1);
348
- return { outDir, achievedBpw, nQuantized, write };
423
+ progress("done", `Quantized ${nQuantized} modules → ${publishedOutDir}`, 1);
424
+ return { outDir: publishedOutDir, achievedBpw, nQuantized, write };
349
425
  } finally {
350
426
  // Dispose every emitted array (their bytes are now on disk) and the source.
351
427
  for (const t of out) {
352
428
  try { t.array.dispose(); } catch {}
353
429
  }
430
+ transformContext?.dispose();
354
431
  weights.dispose();
355
432
  clearCache();
356
433
  }
@@ -367,8 +444,57 @@ export async function quantizeModelDir(
367
444
  }
368
445
 
369
446
  /** Pass-through tensor: the lazy mlx array straight from the source weights. */
370
- function named(weights: Weights, name: string): NamedTensor {
371
- return { name, array: weights.tensor(name) };
447
+ function outputTensor(
448
+ weights: Weights,
449
+ plan: WeightTransformPlan | undefined,
450
+ context: WeightTransformContext | undefined,
451
+ name: string,
452
+ ): NamedTensor {
453
+ return { name, array: outputArray(weights, plan, context, name) };
454
+ }
455
+
456
+ function outputArray(
457
+ weights: Weights,
458
+ plan: WeightTransformPlan | undefined,
459
+ context: WeightTransformContext | undefined,
460
+ name: string,
461
+ ): MlxArray {
462
+ if (!plan || !context) return weights.tensor(name);
463
+ const sourceName = plan.sourceByOutput.get(name);
464
+ if (!sourceName) throw new Error(`weight transform ${plan.id}: no source for ${name}`);
465
+ return context.apply(name, weights.tensor(sourceName));
466
+ }
467
+
468
+ function validateTransformPlan(
469
+ plan: WeightTransformPlan,
470
+ sourceNames: readonly string[],
471
+ ): void {
472
+ const seen = new Set<string>();
473
+ const sources = new Set(sourceNames);
474
+ for (const name of plan.outputNames) {
475
+ if (seen.has(name)) throw new Error(`weight transform ${plan.id}: duplicate output ${name}`);
476
+ seen.add(name);
477
+ const sourceName = plan.sourceByOutput.get(name);
478
+ if (!sourceName)
479
+ throw new Error(`weight transform ${plan.id}: no source for ${name}`);
480
+ if (!sources.has(sourceName))
481
+ throw new Error(`weight transform ${plan.id}: missing source tensor ${sourceName}`);
482
+ }
483
+ }
484
+
485
+ function transformedConfig(
486
+ raw: Record<string, unknown>,
487
+ plan: WeightTransformPlan | undefined,
488
+ ): Record<string, unknown> {
489
+ if (!plan?.untieWordEmbeddings) return raw;
490
+ const out: Record<string, unknown> = { ...raw, tie_word_embeddings: false };
491
+ if (raw.text_config && typeof raw.text_config === "object") {
492
+ out.text_config = {
493
+ ...(raw.text_config as Record<string, unknown>),
494
+ tie_word_embeddings: false,
495
+ };
496
+ }
497
+ return out;
372
498
  }
373
499
 
374
500
  /** Summary of a mixed-precision allocation, for the sidecar + return value. */
@@ -382,6 +508,57 @@ interface MixedMeta {
382
508
  allocBpw: number;
383
509
  }
384
510
 
511
+ const defaultProbeSource: ProbeSource = {
512
+ async prepare(srcDir, opts, onProgress) {
513
+ const config = await loadModelConfig(srcDir);
514
+ if (config.quantization) return { modelDir: srcDir, dispose() {} };
515
+
516
+ const { mkdtempSync, rmSync } = await import("node:fs");
517
+ const { tmpdir } = await import("node:os");
518
+ const tempDir = mkdtempSync(join(tmpdir(), "mlx-bun-qbaseline-"));
519
+ onProgress?.({
520
+ stage: "sensitivity",
521
+ message: "Unquantized source — building an 8-bit baseline for the sweep",
522
+ progress: 0,
523
+ });
524
+ try {
525
+ await writeQuantizedModelDir(
526
+ srcDir,
527
+ tempDir,
528
+ {
529
+ ...opts,
530
+ bits: 8,
531
+ targetBpw: undefined,
532
+ probeSource: undefined,
533
+ },
534
+ );
535
+ } catch (error) {
536
+ rmSync(tempDir, { recursive: true, force: true });
537
+ throw error;
538
+ }
539
+ return {
540
+ modelDir: tempDir,
541
+ dispose: () => rmSync(tempDir, { recursive: true, force: true }),
542
+ };
543
+ },
544
+ };
545
+
546
+ /** Acquire/use/release helper that pins the injected ProbeSource lifecycle. */
547
+ export async function withPreparedProbe<T>(
548
+ source: ProbeSource,
549
+ srcDir: string,
550
+ opts: QuantizeOptions,
551
+ run: (modelDir: string) => Promise<T>,
552
+ onProgress?: (e: ProgressEvent) => void,
553
+ ): Promise<T> {
554
+ const prepared = await source.prepare(srcDir, opts, onProgress);
555
+ try {
556
+ return await run(prepared.modelDir);
557
+ } finally {
558
+ await prepared.dispose();
559
+ }
560
+ }
561
+
385
562
  /**
386
563
  * Run the OptiQ mixed-precision pipeline for `srcDir`:
387
564
  * load model + tokenizer → calibration → per-layer KL sensitivity →
@@ -407,95 +584,83 @@ async function computeMixedAllocation(
407
584
 
408
585
  progress("sensitivity", "Loading model for sensitivity analysis", 0.0);
409
586
 
410
- // The sensitivity sweep runs FORWARD passes, which need a model the runtime
411
- // can load — and mlx-bun's model graph can only load QUANTIZED weights. If the
412
- // source is bf16 (unquantized), build a temporary uniform 8-bit baseline
413
- // (near-lossless) to probe against. The FINAL weights are still quantized from
414
- // the original bf16 source per the allocation (quantizeModelDir reads srcDir),
415
- // so fidelity is not capped by the baseline — the baseline only anchors the
416
- // per-layer sensitivity (8→4-bit) measurement.
417
- const srcConfig = await loadModelConfig(srcDir);
418
- let probeDir = srcDir;
419
- let tempBaseline: string | null = null;
420
- if (!srcConfig.quantization) {
421
- const { mkdtempSync } = await import("node:fs");
422
- const { tmpdir } = await import("node:os");
423
- const { join } = await import("node:path");
424
- tempBaseline = mkdtempSync(join(tmpdir(), "mlx-bun-qbaseline-"));
425
- progress("sensitivity", "Unquantized source — building an 8-bit baseline for the sweep", 0.0);
426
- await quantizeModelDir(srcDir, tempBaseline, { bits: 8, groupSize, mode: "affine" });
427
- probeDir = tempBaseline;
428
- }
429
-
430
- const config = await loadModelConfig(probeDir);
431
- const weights = await Weights.open(probeDir);
432
- try {
433
- const model = createModel(weights, config);
434
- const tokenizer = await loadTokenizer(srcDir);
435
-
436
- // Calibration: short sequences keep the per-probe forward memory modest.
437
- progress("sensitivity", "Building calibration set", 0.02);
438
- const calibrationFn = loadLlmCalibration(tokenizer, {
439
- nSamples: nCalibration,
440
- seqLen: 128,
441
- mix: calibrationMix,
442
- });
443
- const calIds = calibrationFn();
587
+ // The source dependency owns preparation and cleanup. The default builds a
588
+ // temporary uniform 8-bit baseline from bf16 through the lower-level writer;
589
+ // an injected source can point at a prebuilt probe without orchestrator
590
+ // recursion.
591
+ return withPreparedProbe(
592
+ opts.probeSource ?? defaultProbeSource,
593
+ srcDir,
594
+ opts,
595
+ async (probeDir) => {
596
+ const config = await loadModelConfig(probeDir);
597
+ const weights = await Weights.open(probeDir);
598
+ try {
599
+ const model = createModel(weights, config);
600
+ const tokenizer = await loadTokenizer(srcDir);
601
+
602
+ // Calibration: short sequences keep per-probe forward memory modest.
603
+ progress("sensitivity", "Building calibration set", 0.02);
604
+ const calibrationFn = loadLlmCalibration(tokenizer, {
605
+ nSamples: nCalibration,
606
+ seqLen: 128,
607
+ mix: calibrationMix,
608
+ });
609
+ const calIds = calibrationFn();
610
+
611
+ // Quantizable linears to probe: the model's LoRA-target linears
612
+ // (attention + MLP projections), keyed by full module path.
613
+ const layers = model.loraTargets();
444
614
 
445
- // Quantizable linears to probe: the model's LoRA-target linears (attention
446
- // + MLP projections), keyed by full module path.
447
- const layers = model.loraTargets();
448
-
449
- progress(
450
- "sensitivity",
451
- `Probing ${layers.size} layers × ${candidateBits.length} bit-widths`,
452
- 0.05,
453
- );
454
- const sensitivity: SensitivityResult[] = analyzeSensitivityExact(
455
- model,
456
- layers,
457
- calIds,
458
- { candidateBits, groupSize },
459
- (done, total, layerName) =>
460
615
  progress(
461
616
  "sensitivity",
462
- `Sensitivity ${done}/${total}: ${layerName}`,
463
- 0.05 + 0.85 * (done / Math.max(total, 1)),
464
- ),
465
- );
466
-
467
- // Greedy knapsack → per-layer bit allocation.
468
- progress("allocating", "Optimizing per-layer bit allocation", 0.92);
469
- const opt = optimizeMixedPrecision(sensitivity, {
470
- targetBpw,
471
- candidateBits,
472
- groupSize,
473
- });
474
-
475
- const perLayerBits = new Map<string, number>();
476
- for (const c of opt.configs) perLayerBits.set(c.layerName, c.bits);
617
+ `Probing ${layers.size} layers × ${candidateBits.length} bit-widths`,
618
+ 0.05,
619
+ );
620
+ const sensitivity: SensitivityResult[] = analyzeSensitivityExact(
621
+ model,
622
+ layers,
623
+ calIds,
624
+ { candidateBits, groupSize },
625
+ (done, total, layerName) =>
626
+ progress(
627
+ "sensitivity",
628
+ `Sensitivity ${done}/${total}: ${layerName}`,
629
+ 0.05 + 0.85 * (done / Math.max(total, 1)),
630
+ ),
631
+ );
477
632
 
478
- const meta: MixedMeta = {
479
- targetBpw,
480
- candidateBits,
481
- nHigh: opt.nHighBits,
482
- nLow: opt.nLowBits,
483
- allocBpw: opt.achievedBpw,
484
- };
633
+ // Greedy knapsack → per-layer bit allocation.
634
+ progress("allocating", "Optimizing per-layer bit allocation", 0.92);
635
+ const opt = optimizeMixedPrecision(sensitivity, {
636
+ targetBpw,
637
+ candidateBits,
638
+ groupSize,
639
+ });
640
+
641
+ const perLayerBits = new Map<string, number>();
642
+ for (const c of opt.configs) perLayerBits.set(c.layerName, c.bits);
643
+
644
+ const meta: MixedMeta = {
645
+ targetBpw,
646
+ candidateBits,
647
+ nHigh: opt.nHighBits,
648
+ nLow: opt.nLowBits,
649
+ allocBpw: opt.achievedBpw,
650
+ };
485
651
 
486
- progress(
487
- "allocating",
488
- `Allocated ${opt.nHighBits} high / ${opt.nLowBits} low (alloc bpw ${opt.achievedBpw.toFixed(2)})`,
489
- 0.95,
490
- );
652
+ progress(
653
+ "allocating",
654
+ `Allocated ${opt.nHighBits} high / ${opt.nLowBits} low (alloc bpw ${opt.achievedBpw.toFixed(2)})`,
655
+ 0.95,
656
+ );
491
657
 
492
- return { perLayerBits, meta };
493
- } finally {
494
- weights.dispose();
495
- clearCache();
496
- if (tempBaseline) {
497
- const { rmSync } = await import("node:fs");
498
- rmSync(tempBaseline, { recursive: true, force: true });
499
- }
500
- }
658
+ return { perLayerBits, meta };
659
+ } finally {
660
+ weights.dispose();
661
+ clearCache();
662
+ }
663
+ },
664
+ onProgress,
665
+ );
501
666
  }