mlx-bun 0.0.4

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 (210) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +362 -0
  3. package/THIRD_PARTY_LICENSES.md +41 -0
  4. package/bin/mlx-bun.mjs +32 -0
  5. package/docs/curve-designer.html +303 -0
  6. package/docs/design/adapters-end-to-end.md +201 -0
  7. package/docs/design/compat-cli-surface-design.md +227 -0
  8. package/docs/design/docs-reorg-plan.md +152 -0
  9. package/docs/design/hlg-sampling.md +340 -0
  10. package/docs/design/memory-system.md +339 -0
  11. package/docs/design/optimization_plan.md +344 -0
  12. package/docs/design/parallel-slots.md +515 -0
  13. package/docs/design/runtime-isolation.md +247 -0
  14. package/docs/design/segmented-backward-training.md +508 -0
  15. package/docs/investigations/chunk-finetune-experiment.md +84 -0
  16. package/docs/investigations/curve-bisector-routing.md +123 -0
  17. package/docs/investigations/curve-runs/bisector-route.json +98 -0
  18. package/docs/investigations/curve-runs/bisector-trace.json +1393 -0
  19. package/docs/investigations/curve-runs/curve-terrain.json +1179 -0
  20. package/docs/investigations/curve-runs/curve-terrain.svg +42 -0
  21. package/docs/investigations/curve-terrain.html +105 -0
  22. package/docs/investigations/expert-offload-single-user-moe.md +292 -0
  23. package/docs/investigations/hlg-ab.html +34 -0
  24. package/docs/investigations/hlg-explorer.html +238 -0
  25. package/docs/investigations/hlg-figs/cliff.svg +1 -0
  26. package/docs/investigations/hlg-figs/cross.svg +1 -0
  27. package/docs/investigations/hlg-figs/edges.svg +1 -0
  28. package/docs/investigations/hlg-figs/k20-grids.svg +1 -0
  29. package/docs/investigations/hlg-figs/k20-peaks.svg +1 -0
  30. package/docs/investigations/hlg-figs/lw-curve.svg +1 -0
  31. package/docs/investigations/hlg-figs/terrain.svg +1 -0
  32. package/docs/investigations/hlg-report.html +262 -0
  33. package/docs/investigations/hlg-runs/hlg-ab.json +149 -0
  34. package/docs/investigations/hlg-runs/hlg-blind-prompts.md +87 -0
  35. package/docs/investigations/hlg-runs/hlg-blind.json +107 -0
  36. package/docs/investigations/hlg-runs/hlg-cliff.json +197 -0
  37. package/docs/investigations/hlg-runs/hlg-cross.json +505 -0
  38. package/docs/investigations/hlg-runs/hlg-edge.json +388 -0
  39. package/docs/investigations/hlg-runs/hlg-eval1.json +25 -0
  40. package/docs/investigations/hlg-runs/hlg-frontier.json +86 -0
  41. package/docs/investigations/hlg-runs/hlg-grid.json +242 -0
  42. package/docs/investigations/hlg-runs/hlg-grid2.json +343 -0
  43. package/docs/investigations/hlg-runs/hlg-lw-coarse.json +110 -0
  44. package/docs/investigations/hlg-runs/hlg-map.json +383 -0
  45. package/docs/investigations/hlg-runs/run0-original.log +159 -0
  46. package/docs/investigations/hlg-runs/runA-recommended.log +131 -0
  47. package/docs/investigations/hlg-runs/runAA-cliff-cpm5.log +26 -0
  48. package/docs/investigations/hlg-runs/runAB-grid2-e4b.log +18 -0
  49. package/docs/investigations/hlg-runs/runAC-peak-e4b-partial.log +25 -0
  50. package/docs/investigations/hlg-runs/runAD-ab-samples.log +8 -0
  51. package/docs/investigations/hlg-runs/runAE-edge-e4b.log +51 -0
  52. package/docs/investigations/hlg-runs/runAF-logit-calibration.log +10 -0
  53. package/docs/investigations/hlg-runs/runAG-eval-config2.log +16 -0
  54. package/docs/investigations/hlg-runs/runB-gain-variants.log +192 -0
  55. package/docs/investigations/hlg-runs/runC-pivots.log +162 -0
  56. package/docs/investigations/hlg-runs/runD-oetf.log +132 -0
  57. package/docs/investigations/hlg-runs/runE-eotf.log +162 -0
  58. package/docs/investigations/hlg-runs/runF-shaper-working.log +86 -0
  59. package/docs/investigations/hlg-runs/runG-shaper-sweep.log +101 -0
  60. package/docs/investigations/hlg-runs/runH-wsweep.log +176 -0
  61. package/docs/investigations/hlg-runs/runI-asweep.log +176 -0
  62. package/docs/investigations/hlg-runs/runJ-knobmap.log +49 -0
  63. package/docs/investigations/hlg-runs/runK-frontier.log +19 -0
  64. package/docs/investigations/hlg-runs/runL-widemap.log +65 -0
  65. package/docs/investigations/hlg-runs/runM-frontier-emb.log +15 -0
  66. package/docs/investigations/hlg-runs/runN-verify.log +116 -0
  67. package/docs/investigations/hlg-runs/runO-frontier-seed5000.log +15 -0
  68. package/docs/investigations/hlg-runs/runP-grid-AxSm.log +15 -0
  69. package/docs/investigations/hlg-runs/runQ-grid-tgW.log +15 -0
  70. package/docs/investigations/hlg-runs/runR-blind-transcript.log +79 -0
  71. package/docs/investigations/hlg-runs/runS-cross-coherence.log +33 -0
  72. package/docs/investigations/hlg-runs/runT-cross-cpm5.log +33 -0
  73. package/docs/investigations/hlg-runs/runU-cross-12b.log +33 -0
  74. package/docs/investigations/hlg-runs/runV-lw-e4b.log +22 -0
  75. package/docs/investigations/hlg-runs/runW-lw-12b.log +22 -0
  76. package/docs/investigations/hlg-runs/runX-lw-cpm5.log +22 -0
  77. package/docs/investigations/hlg-runs/runY-cliff-e4b.log +26 -0
  78. package/docs/investigations/hlg-runs/runZ-cliff-12b.log +26 -0
  79. package/docs/investigations/hlg-sampling-investigation.md +970 -0
  80. package/docs/investigations/lab-build-journal.md +157 -0
  81. package/docs/investigations/pi-builtin-investigation.md +308 -0
  82. package/docs/investigations/starter-model-port-handoff.md +323 -0
  83. package/docs/planning/IDEAS.md +26 -0
  84. package/docs/planning/PRODUCT_ROADMAP.md +426 -0
  85. package/docs/planning/ResearchTopics.md +119 -0
  86. package/docs/planning/chunk-task-roadmap.md +77 -0
  87. package/docs/planning/journal.md +155 -0
  88. package/docs/reference/distribution.md +121 -0
  89. package/docs/reference/embedding.md +168 -0
  90. package/docs/reference/library-api.md +153 -0
  91. package/docs/reference/server-api.md +224 -0
  92. package/docs/reference/server-config.md +243 -0
  93. package/docs/segmented-backward-handoff.md +148 -0
  94. package/package.json +68 -0
  95. package/src/anthropic.ts +491 -0
  96. package/src/chat-template.ts +186 -0
  97. package/src/cli.ts +1075 -0
  98. package/src/config.ts +229 -0
  99. package/src/curve-sampler.ts +122 -0
  100. package/src/dataset/generators.ts +1087 -0
  101. package/src/dataset/index.ts +12 -0
  102. package/src/dataset/job.ts +81 -0
  103. package/src/dataset/llm.ts +71 -0
  104. package/src/dataset/registry.ts +528 -0
  105. package/src/download.ts +283 -0
  106. package/src/eval/README.md +86 -0
  107. package/src/eval/kl-prompts.ts +29 -0
  108. package/src/eval/kl.ts +294 -0
  109. package/src/eval/quality-db.ts +92 -0
  110. package/src/eval/runner.ts +102 -0
  111. package/src/eval/score.ts +49 -0
  112. package/src/eval/tasks/bfcl.ts +500 -0
  113. package/src/eval/tasks/gsm8k.ts +85 -0
  114. package/src/eval/tasks/hashhop.ts +109 -0
  115. package/src/eval/tasks/humaneval.ts +252 -0
  116. package/src/eval/tasks/ifeval.ts +354 -0
  117. package/src/eval/tasks/mmlu.ts +145 -0
  118. package/src/evaldb.ts +199 -0
  119. package/src/expert-offload-build.ts +77 -0
  120. package/src/expert-offload.ts +61 -0
  121. package/src/expert-trace.ts +67 -0
  122. package/src/fit.ts +254 -0
  123. package/src/generate.ts +451 -0
  124. package/src/harness-pi.ts +177 -0
  125. package/src/hf-push.ts +628 -0
  126. package/src/index.ts +22 -0
  127. package/src/jobs/db.ts +139 -0
  128. package/src/jobs/index.ts +29 -0
  129. package/src/jobs/job-entry.ts +106 -0
  130. package/src/jobs/runner.ts +304 -0
  131. package/src/jobs/sse.ts +114 -0
  132. package/src/jobs/types.ts +58 -0
  133. package/src/kv-store.ts +185 -0
  134. package/src/lora.ts +314 -0
  135. package/src/md-modules.d.ts +7 -0
  136. package/src/mlx/array.ts +237 -0
  137. package/src/mlx/autograd.ts +267 -0
  138. package/src/mlx/checkpoint.ts +116 -0
  139. package/src/mlx/compile.ts +158 -0
  140. package/src/mlx/custom-vjp.ts +140 -0
  141. package/src/mlx/ffi.ts +433 -0
  142. package/src/mlx/metal-kernel.ts +130 -0
  143. package/src/mlx/ops.ts +707 -0
  144. package/src/mmap.ts +90 -0
  145. package/src/model/batched-mask.ts +204 -0
  146. package/src/model/batched-rotating.ts +300 -0
  147. package/src/model/compiled-decode.ts +681 -0
  148. package/src/model/factory.ts +27 -0
  149. package/src/model/fingerprint.ts +33 -0
  150. package/src/model/flash-attention.ts +674 -0
  151. package/src/model/fused-decode-kernel.ts +305 -0
  152. package/src/model/fused-geglu-kernel.ts +87 -0
  153. package/src/model/gemma4-base.ts +1616 -0
  154. package/src/model/gemma4.ts +1012 -0
  155. package/src/model/generated/gemma4-12b.ts +526 -0
  156. package/src/model/generated/gemma4-26b.ts +500 -0
  157. package/src/model/generated/gemma4-e4b.ts +757 -0
  158. package/src/model/generated/index.ts +18 -0
  159. package/src/model/minicpm5.ts +278 -0
  160. package/src/model/qwen3-delta.ts +224 -0
  161. package/src/model/qwen3_5.ts +429 -0
  162. package/src/model/support.ts +46 -0
  163. package/src/native-pack.ts +152 -0
  164. package/src/pi-provider.ts +95 -0
  165. package/src/pi-terminal.ts +264 -0
  166. package/src/pi-web.ts +924 -0
  167. package/src/preflight.ts +123 -0
  168. package/src/prompt-cache.ts +113 -0
  169. package/src/quantize/allocator.ts +627 -0
  170. package/src/quantize/calibration.ts +180 -0
  171. package/src/quantize/config-writer.ts +132 -0
  172. package/src/quantize/index.ts +57 -0
  173. package/src/quantize/job.ts +136 -0
  174. package/src/quantize/quantizer.ts +462 -0
  175. package/src/quantize/safetensors-writer.ts +171 -0
  176. package/src/quantize/sensitivity.ts +234 -0
  177. package/src/registry.ts +336 -0
  178. package/src/responses.ts +774 -0
  179. package/src/safetensors.ts +158 -0
  180. package/src/sampler.ts +517 -0
  181. package/src/serve/batch-scheduler.ts +362 -0
  182. package/src/serve/generation-gateway.ts +160 -0
  183. package/src/server.ts +1891 -0
  184. package/src/spec/drafter.ts +261 -0
  185. package/src/spec/generate.ts +221 -0
  186. package/src/status-page.html +497 -0
  187. package/src/tokenizer.ts +38 -0
  188. package/src/tool-call.ts +242 -0
  189. package/src/train/dataset.ts +461 -0
  190. package/src/train/export.ts +32 -0
  191. package/src/train/forward.ts +229 -0
  192. package/src/train/index.ts +43 -0
  193. package/src/train/job.ts +148 -0
  194. package/src/train/lora-params.ts +194 -0
  195. package/src/train/loss.ts +342 -0
  196. package/src/train/merge.ts +195 -0
  197. package/src/train/optimizer.ts +176 -0
  198. package/src/train/rank.ts +101 -0
  199. package/src/train/segmented.ts +520 -0
  200. package/src/train/trainer.ts +600 -0
  201. package/src/tui.ts +161 -0
  202. package/src/vision/embedder.ts +168 -0
  203. package/src/vision/preprocess.ts +247 -0
  204. package/src/vision/prompt.ts +158 -0
  205. package/src/vision/siglip.ts +599 -0
  206. package/src/web/app.html +2471 -0
  207. package/src/web/skills/web-research/SKILL.md +44 -0
  208. package/src/web/skills.ts +59 -0
  209. package/src/web-tools.ts +426 -0
  210. package/src/weights.ts +91 -0
@@ -0,0 +1,342 @@
1
+ // Training losses: masked cross-entropy (SFT) and DPO.
2
+ //
3
+ // SFT — port of mlx_lm/tuner/trainer.py default_loss:
4
+ // logits = forward(ids[:, :-1]); targets = ids[:, 1:]
5
+ // ce[b,t] = logsumexp(logits[b,t]) - logits[b,t, targets[b,t]]
6
+ // mask[b,t] = 1 iff position predicts a response token that is NOT padding,
7
+ // i.e. (t+1) >= promptLen[b] AND (t+1) < length[b]
8
+ // loss = sum(ce * mask) / sum(mask) (token-mean over the whole batch)
9
+ // For B=1 with no padding this reduces to exactly the original single-example
10
+ // loss (mask[t] = (t+1) >= promptLen, length == ids.length).
11
+ //
12
+ // DPO — port of optiq/lora/dpo.py: per-sequence response log-prob via
13
+ // _seq_logp, reference log-probs computed with the adapter scale forced to 0
14
+ // and stop_gradient'd, then L = -log σ(β·((πc-refc) - (πr-refr))) averaged
15
+ // over the batch. Padding never contributes: the response mask is 0 at pad
16
+ // positions and the batched attention mask hides padded keys.
17
+
18
+ import { MlxArray } from "../mlx/array";
19
+ import { Dtype } from "../mlx/ffi";
20
+ import * as ops from "../mlx/ops";
21
+ import type { RuntimeModel } from "../model/factory";
22
+ import { trainForward, trainForwardHidden } from "./forward";
23
+ import { setLoraScale, type TrainableLora } from "./lora-params";
24
+ import {
25
+ rowLength, dpoChosenLength, dpoRejectedLength,
26
+ type SftBatch, type DpoBatch,
27
+ } from "./dataset";
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // SFT
31
+ // ---------------------------------------------------------------------------
32
+
33
+ /** Masked cross-entropy over an SFT batch (B>=1).
34
+ * Returns a scalar MlxArray (caller owns).
35
+ *
36
+ * Every row is padded to the batch length L. The forward runs over
37
+ * ids[:, :-1] (a single [B, L-1] pass) with a padding-aware attention mask;
38
+ * the loss mask then keeps only (response AND non-padding) target positions.
39
+ * Loss = sum(ce·mask) / sum(mask) — a token-mean over the whole batch, the
40
+ * same reduction as mlx-lm default_loss. */
41
+ export function sftLoss(model: RuntimeModel, batch: SftBatch): MlxArray {
42
+ const B = batch.ids.length;
43
+ const L = batch.ids[0]!.length;
44
+ if (L < 2) throw new Error("sftLoss: sequence too short (need >= 2 tokens)");
45
+ for (let b = 0; b < B; b++)
46
+ if (batch.ids[b]!.length !== L)
47
+ throw new Error("sftLoss: all rows in a batch must share length L (pad first)");
48
+
49
+ // inputs = ids[:, :-1] (flatten row-major into [B, L-1]).
50
+ const T = L - 1;
51
+ const inputHost = new Int32Array(B * T);
52
+ for (let b = 0; b < B; b++)
53
+ for (let t = 0; t < T; t++) inputHost[b * T + t] = batch.ids[b]![t]!;
54
+ const inputIds = MlxArray.fromInt32(inputHost, [B, T]);
55
+
56
+ // Per-input-row valid length = min(rowLength, L) - 1 (inputs dropped the
57
+ // last token). Keys beyond this are padding and must not be attended.
58
+ const inputValid = Array.from({ length: B }, (_, b) =>
59
+ Math.max(0, Math.min(rowLength(batch, b), L) - 1),
60
+ );
61
+
62
+ let scalar: MlxArray;
63
+ try {
64
+ if (B === 1) {
65
+ // Response-only path: run the forward to hidden states, then apply the
66
+ // LM head ONLY at supervised positions. Avoids the [1, T, 262k] logits
67
+ // (+ its grad) — the dominant long-context training-memory term.
68
+ const h = trainForwardHidden(model, inputIds, inputValid); // [1, T, hidden]
69
+ scalar = responseOnlyCe(model, h, batch);
70
+ h.dispose();
71
+ } else {
72
+ const logits = trainForward(model, inputIds, inputValid); // [B, T, V]
73
+ scalar = maskedCe(logits, batch);
74
+ logits.dispose();
75
+ }
76
+ } finally {
77
+ inputIds.dispose();
78
+ }
79
+ return scalar;
80
+ }
81
+
82
+ /** B=1 masked CE that applies the LM head only at the supervised (response)
83
+ * span. The supervised input positions are the contiguous range
84
+ * [promptLen-1, len-1) (each predicts the next, response, token). The LM head
85
+ * is position-independent and prompt positions get zero loss weight, so this
86
+ * is numerically identical to maskedCe for B=1 — but it never materializes
87
+ * logits at prompt positions. Gradients to prompt-position LoRA params still
88
+ * flow: the response hidden states depend on the prompt through causal
89
+ * attention, which is fully captured upstream in `h`. */
90
+ export function responseOnlyCe(model: RuntimeModel, h: MlxArray, batch: SftBatch): MlxArray {
91
+ const L = batch.ids[0]!.length;
92
+ const promptLen = batch.promptLens[0]!;
93
+ const len = Math.min(rowLength(batch, 0), L);
94
+ const startT = Math.max(0, promptLen - 1); // first supervised input position
95
+ const M = (len - 1) - startT; // # supervised positions (predict ids[startT+1 .. len-1])
96
+ if (M <= 0) throw new Error("sftLoss: no response tokens to supervise");
97
+
98
+ // h[:, startT : startT+M, :] → [1, M, hidden] (sliceSize is the full output shape)
99
+ const hidden = h.shape[2]!;
100
+ const start = MlxArray.fromInt32(new Int32Array([startT]), [1]);
101
+ const hResp = ops.sliceDynamic(h, start, [1], [1, M, hidden]);
102
+ const logits = model.logitsFromHidden(hResp); // [1, M, V]
103
+ const V = logits.shape[2]!;
104
+ const logits2d = ops.reshape(logits, [M, V]);
105
+
106
+ const targetsHost = new Int32Array(M);
107
+ for (let i = 0; i < M; i++) targetsHost[i] = batch.ids[0]![startT + 1 + i]!;
108
+ const targets = MlxArray.fromInt32(targetsHost, [M, 1]);
109
+
110
+ const lse = ops.logsumexpAxis(logits2d, -1, false); // [M]
111
+ const gathered = ops.takeAlongAxis(logits2d, targets, -1); // [M, 1]
112
+ const picked = ops.reshape(gathered, [M]); // [M]
113
+ const ce = ops.sub(lse, picked); // [M]
114
+ const ceF = ce.dtype === Dtype.float32 ? ce : ce.astype(Dtype.float32);
115
+ const sumCe = ops.sumAxis(ceF, 0, false); // scalar
116
+ const loss = ops.mulScalar(sumCe, 1 / M); // token-mean over the response
117
+
118
+ for (const a of [start, hResp, logits, logits2d, targets, lse, gathered, picked, ce, sumCe]) a.dispose();
119
+ if (ceF !== ce) ceF.dispose();
120
+ return loss;
121
+ }
122
+
123
+ /** Build the token-mean masked-CE scalar from batched logits [B, T, V]
124
+ * (T = L-1) and the batch's host ids/boundaries. */
125
+ export function maskedCe(logits: MlxArray, batch: SftBatch): MlxArray {
126
+ const B = batch.ids.length;
127
+ const L = batch.ids[0]!.length;
128
+ const T = L - 1;
129
+ const V = logits.shape[2]!;
130
+
131
+ const targetsHost = new Int32Array(B * T);
132
+ const maskHost = new Float32Array(B * T);
133
+ let ntoks = 0;
134
+ for (let b = 0; b < B; b++) {
135
+ const promptLen = batch.promptLens[b]!;
136
+ const len = Math.min(rowLength(batch, b), L);
137
+ for (let t = 0; t < T; t++) {
138
+ const flat = b * T + t;
139
+ targetsHost[flat] = batch.ids[b]![t + 1]!;
140
+ // predicted token is ids[t+1]; supervised iff it is a response token
141
+ // (t+1 >= promptLen) AND a real (non-pad) token (t+1 < len).
142
+ if (t + 1 >= promptLen && t + 1 < len) {
143
+ maskHost[flat] = 1;
144
+ ntoks++;
145
+ }
146
+ }
147
+ }
148
+ if (ntoks === 0) throw new Error("sftLoss: no response tokens to supervise");
149
+
150
+ const N = B * T;
151
+ const logits2d = ops.reshape(logits, [N, V]); // [B*T, V]
152
+ const targets = MlxArray.fromInt32(targetsHost, [N, 1]);
153
+ const mask = MlxArray.fromFloat32(maskHost, [N]);
154
+
155
+ // ce[n] = logsumexp(logits[n]) - logits[n, target[n]]
156
+ const lse = ops.logsumexpAxis(logits2d, -1, false); // [N]
157
+ const gathered = ops.takeAlongAxis(logits2d, targets, -1); // [N, 1]
158
+ const picked = ops.reshape(gathered, [N]); // [N]
159
+ const ce = ops.sub(lse, picked); // [N]
160
+ const ceF = ce.dtype === Dtype.float32 ? ce : ce.astype(Dtype.float32);
161
+
162
+ const masked = ops.mul(ceF, mask); // [N]
163
+ const sumCe = ops.sumAxis(masked, 0, false); // scalar
164
+ const loss = ops.mulScalar(sumCe, 1 / ntoks); // scalar (token-mean)
165
+
166
+ for (const a of [logits2d, targets, mask, lse, gathered, picked, ce, masked, sumCe]) a.dispose();
167
+ if (ceF !== ce) ceF.dispose();
168
+ return loss;
169
+ }
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // DPO
173
+ // ---------------------------------------------------------------------------
174
+
175
+ /** Per-sequence sum of response-position log-probs (B>=1), port of
176
+ * dpo.py _seq_logp. `ids`/`mask` are `[B][L]` host arrays; `logits` are
177
+ * [B, L-1, V] (the forward over ids[:, :-1]). Returns a [B] MlxArray — one
178
+ * summed response log-prob per row. Pad positions contribute 0 because the
179
+ * response mask is 0 there. */
180
+ function seqLogp(logits: MlxArray, ids: number[][], mask: number[][]): MlxArray {
181
+ const B = ids.length;
182
+ const L = ids[0]!.length;
183
+ const T = L - 1;
184
+ const V = logits.shape[2]!;
185
+ const N = B * T;
186
+
187
+ const targetsHost = new Int32Array(N);
188
+ const maskHost = new Float32Array(N);
189
+ for (let b = 0; b < B; b++)
190
+ for (let t = 0; t < T; t++) {
191
+ const flat = b * T + t;
192
+ targetsHost[flat] = ids[b]![t + 1]!;
193
+ maskHost[flat] = mask[b]![t + 1]!; // resp_mask[:, 1:]
194
+ }
195
+ const logits2d = ops.reshape(logits, [N, V]);
196
+ const targets = MlxArray.fromInt32(targetsHost, [N, 1]);
197
+ const m = MlxArray.fromFloat32(maskHost, [N]);
198
+
199
+ const lse = ops.logsumexpAxis(logits2d, -1, false); // [N]
200
+ const gathered = ops.takeAlongAxis(logits2d, targets, -1); // [N,1]
201
+ const picked = ops.reshape(gathered, [N]); // [N]
202
+ const logp = ops.sub(picked, lse); // log_softmax at the label
203
+ const logpF = logp.dtype === Dtype.float32 ? logp : logp.astype(Dtype.float32);
204
+ const masked = ops.mul(logpF, m); // [N]
205
+ const masked2d = ops.reshape(masked, [B, T]); // [B, T]
206
+ const perRow = ops.sumAxis(masked2d, 1, false); // [B]
207
+
208
+ for (const a of [logits2d, targets, m, lse, gathered, picked, logp, masked, masked2d]) a.dispose();
209
+ if (logpF !== logp) logpF.dispose();
210
+ return perRow;
211
+ }
212
+
213
+ /** Forward + per-sequence response log-prob for one branch (B>=1). `validLen`
214
+ * is each row's true (unpadded) length, used to mask padded keys in the
215
+ * attention; passing it makes the batched forward ignore padding bit-for-bit
216
+ * the way separate per-row forwards would. */
217
+ function branchLogp(
218
+ model: RuntimeModel,
219
+ ids: number[][],
220
+ mask: number[][],
221
+ validLen: number[],
222
+ ): MlxArray {
223
+ const B = ids.length;
224
+ const L = ids[0]!.length;
225
+ const T = L - 1;
226
+ const inputHost = new Int32Array(B * T);
227
+ for (let b = 0; b < B; b++)
228
+ for (let t = 0; t < T; t++) inputHost[b * T + t] = ids[b]![t]!;
229
+ const inputIds = MlxArray.fromInt32(inputHost, [B, T]);
230
+ // input row valid length = min(len, L) - 1 (last token dropped from inputs).
231
+ const inputValid = validLen.map((v) => Math.max(0, Math.min(v, L) - 1));
232
+ try {
233
+ const logits = trainForward(model, inputIds, inputValid);
234
+ const lp = seqLogp(logits, ids, mask);
235
+ logits.dispose();
236
+ return lp;
237
+ } finally {
238
+ inputIds.dispose();
239
+ }
240
+ }
241
+
242
+ /** Branch valid lengths from a DPO batch (defaults to full length for B=1). */
243
+ function chosenLengths(batch: DpoBatch): number[] {
244
+ return batch.chosenIds.map((_, i) => dpoChosenLength(batch, i));
245
+ }
246
+ function rejectedLengths(batch: DpoBatch): number[] {
247
+ return batch.rejectedIds.map((_, i) => dpoRejectedLength(batch, i));
248
+ }
249
+
250
+ /** Reference log-probs for a DPO batch: forward with the adapter disabled
251
+ * (scale 0), materialized + stop_gradient'd so the policy forwards don't
252
+ * hold the reference activations. Returns detached scalars (caller owns). */
253
+ export function dpoRefLogps(
254
+ model: RuntimeModel,
255
+ lora: TrainableLora,
256
+ batch: DpoBatch,
257
+ ): { refChosen: MlxArray; refRejected: MlxArray } {
258
+ const original = lora.targets[0]?.lw.scale ?? lora.scale;
259
+ const cLen = chosenLengths(batch);
260
+ const rLen = rejectedLengths(batch);
261
+ setLoraScale(lora, 0);
262
+ try {
263
+ const c = branchLogp(model, batch.chosenIds, batch.chosenMask, cLen); // [B]
264
+ const r = branchLogp(model, batch.rejectedIds, batch.rejectedMask, rLen); // [B]
265
+ ops.evalAll([c, r]); // free reference activations before policy forwards
266
+ const refChosen = ops.stopGradient(c);
267
+ const refRejected = ops.stopGradient(r);
268
+ c.dispose();
269
+ r.dispose();
270
+ return { refChosen, refRejected };
271
+ } finally {
272
+ setLoraScale(lora, original);
273
+ }
274
+ }
275
+
276
+ /** DPO loss for one batch given precomputed reference log-probs ([B] each).
277
+ * Runs the two policy forwards (adapter active) and reduces to the scalar
278
+ * mean over the batch of -logσ(adv) (matching dpo.py _policy_dpo_loss).
279
+ * Returns a scalar MlxArray (caller owns). */
280
+ export function dpoLoss(
281
+ model: RuntimeModel,
282
+ batch: DpoBatch,
283
+ beta: number,
284
+ refChosen: MlxArray,
285
+ refRejected: MlxArray,
286
+ ): MlxArray {
287
+ const cLen = chosenLengths(batch);
288
+ const rLen = rejectedLengths(batch);
289
+ const piC = branchLogp(model, batch.chosenIds, batch.chosenMask, cLen); // [B]
290
+ const piR = branchLogp(model, batch.rejectedIds, batch.rejectedMask, rLen); // [B]
291
+
292
+ const logrC = ops.sub(piC, refChosen); // [B] πc - refc
293
+ const logrR = ops.sub(piR, refRejected); // [B] πr - refr
294
+ const diff = ops.sub(logrC, logrR);
295
+ const adv = ops.mulScalar(diff, beta); // [B] β·(logrC - logrR)
296
+ // -log σ(adv) = softplus(-adv) = logaddexp(0, -adv); mean over the batch.
297
+ const negAdv = ops.neg(adv);
298
+ const zero = ops.scalarLike(0, adv);
299
+ const perRow = ops.logaddexp(zero, negAdv); // [B]
300
+ const loss = ops.meanAll(perRow, false); // scalar
301
+
302
+ for (const a of [piC, piR, logrC, logrR, diff, adv, negAdv, zero, perRow]) a.dispose();
303
+ return loss;
304
+ }
305
+
306
+ /** DPO diagnostics (no grad): loss, accuracy (mean of adv>0), reward margin
307
+ * (mean of β·(logrC-logrR)) over the batch. Computes its own references. */
308
+ export function dpoMetrics(
309
+ model: RuntimeModel,
310
+ lora: TrainableLora,
311
+ batch: DpoBatch,
312
+ beta: number,
313
+ ): { loss: number; accuracy: number; margin: number } {
314
+ const { refChosen, refRejected } = dpoRefLogps(model, lora, batch);
315
+ const cLen = chosenLengths(batch);
316
+ const rLen = rejectedLengths(batch);
317
+ const piC = branchLogp(model, batch.chosenIds, batch.chosenMask, cLen); // [B]
318
+ const piR = branchLogp(model, batch.rejectedIds, batch.rejectedMask, rLen); // [B]
319
+
320
+ const logrC = ops.sub(piC, refChosen);
321
+ const logrR = ops.sub(piR, refRejected);
322
+ const diff = ops.sub(logrC, logrR);
323
+ const adv = ops.mulScalar(diff, beta); // [B]
324
+ const negAdv = ops.neg(adv);
325
+ const zero = ops.scalarLike(0, adv);
326
+ const perRow = ops.logaddexp(zero, negAdv); // [B]
327
+ const loss = ops.meanAll(perRow, false);
328
+
329
+ const advVals = adv.toFloat32(); // [B]
330
+ const lossVal = loss.toFloat32()[0]!;
331
+ let acc = 0;
332
+ let margin = 0;
333
+ for (let b = 0; b < advVals.length; b++) {
334
+ if (advVals[b]! > 0) acc++;
335
+ margin += advVals[b]!;
336
+ }
337
+ acc /= advVals.length;
338
+ margin /= advVals.length;
339
+
340
+ for (const a of [refChosen, refRejected, piC, piR, logrC, logrR, diff, adv, negAdv, zero, perRow, loss]) a.dispose();
341
+ return { loss: lossVal, accuracy: acc, margin };
342
+ }
@@ -0,0 +1,195 @@
1
+ // Merge multiple LoRA adapters into one — rank-concat, mathematically exact.
2
+ // Port of optiq/lora/merge.py.
3
+ //
4
+ // For two adapters on a layer (A1[in,r1] B1[r1,out] scale s1; A2 B2 s2):
5
+ // A_m = concat([A1, A2], rankAxis) shape [in, r1+r2]
6
+ // B_m = concat([s1·B1, s2·B2], rankAxis) shape [r1+r2, out]
7
+ // (x @ A_m) @ B_m == s1·(x@A1@B1) + s2·(x@A2@B2)
8
+ // so the merged adapter writes scale=1.0 (per-source scales folded into B).
9
+ //
10
+ // Tensor names follow the AdapterManager.mount format (lora_a / lora_b).
11
+
12
+ import { mkdirSync, existsSync } from "node:fs";
13
+ import { ptr, read } from "bun:ffi";
14
+ import { C } from "../mlx/ffi";
15
+ import { MlxArray, cpuStream } from "../mlx/array";
16
+ import * as ops from "../mlx/ops";
17
+ import { SafetensorsFile } from "../safetensors";
18
+
19
+ const cstr = (s: string) => Buffer.from(s + "\0", "utf8");
20
+
21
+ export interface MergeStats {
22
+ layersMerged: number;
23
+ layersOnlyInOne: number;
24
+ totalKeysOut: number;
25
+ sources: string[];
26
+ scales: number[];
27
+ }
28
+
29
+ /** Merge N adapter directories into `outputDir`. `scales` overrides the
30
+ * per-source scales (else each source's adapter config scale is used). */
31
+ export async function mergeAdapters(
32
+ adapterDirs: string[],
33
+ outputDir: string,
34
+ scales?: number[],
35
+ ): Promise<MergeStats> {
36
+ if (adapterDirs.length < 2) throw new Error("mergeAdapters needs at least two sources");
37
+
38
+ const resolvedScales = scales ?? (await Promise.all(adapterDirs.map(resolveScale)));
39
+ if (resolvedScales.length !== adapterDirs.length)
40
+ throw new Error("scales length must match adapter count");
41
+
42
+ const sources = adapterDirs.map((d) => ({
43
+ tensors: loadAdapterTensors(resolveSafetensors(d)),
44
+ }));
45
+
46
+ // Universe of module paths across all sources.
47
+ const allMods = new Set<string>();
48
+ for (const s of sources)
49
+ for (const k of s.tensors.keys())
50
+ if (k.endsWith(".lora_a")) allMods.add(k.slice(0, -".lora_a".length));
51
+
52
+ const merged = new Map<string, MlxArray>();
53
+ const stats: MergeStats = {
54
+ layersMerged: 0,
55
+ layersOnlyInOne: 0,
56
+ totalKeysOut: 0,
57
+ sources: adapterDirs,
58
+ scales: resolvedScales,
59
+ };
60
+
61
+ try {
62
+ for (const mod of [...allMods].sort()) {
63
+ const aParts: MlxArray[] = [];
64
+ const bParts: MlxArray[] = [];
65
+ for (let s = 0; s < sources.length; s++) {
66
+ const a = sources[s]!.tensors.get(`${mod}.lora_a`);
67
+ const b = sources[s]!.tensors.get(`${mod}.lora_b`);
68
+ if (a && b) {
69
+ aParts.push(a);
70
+ const scale = resolvedScales[s]!;
71
+ bParts.push(scale === 1.0 ? b : ops.mulScalar(b, scale));
72
+ }
73
+ }
74
+ if (aParts.length === 0) continue;
75
+ if (aParts.length === 1) {
76
+ stats.layersOnlyInOne++;
77
+ // A is the original; B may be a scaled copy (own it) or the original.
78
+ merged.set(`${mod}.lora_a`, aParts[0]!);
79
+ merged.set(`${mod}.lora_b`, bParts[0]!);
80
+ } else {
81
+ // rank axis: A is [in, r] → axis 1; B is [r, out] → axis 0.
82
+ const aMerged = ops.concatAxis(aParts, 1);
83
+ const bMerged = ops.concatAxis(bParts, 0);
84
+ merged.set(`${mod}.lora_a`, aMerged);
85
+ merged.set(`${mod}.lora_b`, bMerged);
86
+ stats.layersMerged++;
87
+ }
88
+ }
89
+
90
+ stats.totalKeysOut = merged.size;
91
+ mkdirSync(outputDir, { recursive: true });
92
+
93
+ const map = C.mlx_map_string_to_array_new();
94
+ const meta = C.mlx_map_string_to_string_new();
95
+ try {
96
+ for (const [name, arr] of merged) {
97
+ arr.eval();
98
+ if (C.mlx_map_string_to_array_insert(map, ptr(cstr(name)), arr.handle) !== 0)
99
+ throw new Error(`map insert ${name} failed`);
100
+ }
101
+ const file = `${outputDir}/adapters.safetensors`;
102
+ if (C.mlx_save_safetensors(ptr(cstr(file)), map, meta) !== 0)
103
+ throw new Error(`mlx_save_safetensors(${file}) failed`);
104
+ } finally {
105
+ C.mlx_map_string_to_array_free(map);
106
+ C.mlx_map_string_to_string_free(meta);
107
+ }
108
+
109
+ // Merged config: scale 1.0 (folded into B), rank = sum of source ranks.
110
+ let rankSum = 0;
111
+ for (const d of adapterDirs) rankSum += (await resolveRank(d)) ?? 0;
112
+ const cfg = {
113
+ fine_tune_type: "lora",
114
+ lora_parameters: { scale: 1.0, rank: rankSum || null },
115
+ r: rankSum || null,
116
+ peft_type: "LORA",
117
+ optiq_merge: { sources: adapterDirs, scales: resolvedScales },
118
+ };
119
+ await Bun.write(`${outputDir}/adapter_config.json`, JSON.stringify(cfg, null, 2) + "\n");
120
+ await Bun.write(`${outputDir}/optiq_lora_config.json`, JSON.stringify(cfg, null, 2) + "\n");
121
+ } finally {
122
+ // dispose all source + merged arrays (merged copies are owned here).
123
+ for (const s of sources) for (const a of s.tensors.values()) a.dispose();
124
+ for (const a of merged.values()) a.dispose();
125
+ }
126
+
127
+ return stats;
128
+ }
129
+
130
+ function resolveSafetensors(dir: string): string {
131
+ for (const p of [`${dir}/best/adapters.safetensors`, `${dir}/adapters.safetensors`])
132
+ if (existsSync(p)) return p;
133
+ if (existsSync(dir)) return dir; // explicit file path
134
+ throw new Error(`${dir}: no adapters.safetensors`);
135
+ }
136
+
137
+ async function resolveScale(dir: string): Promise<number> {
138
+ const cfg = await readConfig(dir);
139
+ if (cfg?.lora_parameters?.scale != null) return Number(cfg.lora_parameters.scale);
140
+ if (cfg?.lora_alpha != null && cfg?.r) return Number(cfg.lora_alpha) / Number(cfg.r);
141
+ if (cfg?.scale != null) return Number(cfg.scale);
142
+ return 1.0;
143
+ }
144
+
145
+ async function resolveRank(dir: string): Promise<number | null> {
146
+ const cfg = await readConfig(dir);
147
+ const r = cfg?.lora_parameters?.rank ?? cfg?.r;
148
+ return r != null ? Number(r) : null;
149
+ }
150
+
151
+ async function readConfig(dir: string): Promise<any | null> {
152
+ for (const name of ["adapter_config.json", "optiq_lora_config.json"]) {
153
+ const f = Bun.file(`${dir}/${name}`);
154
+ if (await f.exists()) return f.json();
155
+ }
156
+ return null;
157
+ }
158
+
159
+ /** Materialize every tensor from an adapter safetensors file (mirrors the
160
+ * read idiom in src/lora.ts loadAdapterTensors). */
161
+ function loadAdapterTensors(file: string): Map<string, MlxArray> {
162
+ const sf = SafetensorsFile.open(file);
163
+ const names = [...sf.tensors.keys()];
164
+ sf.mmap.unmap();
165
+
166
+ const arrMapSlot = new BigUint64Array([C.mlx_map_string_to_array_new()]);
167
+ const metaMapSlot = new BigUint64Array([C.mlx_map_string_to_string_new()]);
168
+ const arrMapPtr = ptr(arrMapSlot);
169
+ const metaMapPtr = ptr(metaMapSlot);
170
+ const status = C.mlx_load_safetensors(arrMapPtr, metaMapPtr, ptr(cstr(file)), cpuStream);
171
+ C.mlx_map_string_to_string_free(read.u64(metaMapPtr, 0));
172
+ const mapHandle = read.u64(arrMapPtr, 0);
173
+ if (status !== 0) {
174
+ C.mlx_map_string_to_array_free(mapHandle);
175
+ throw new Error(`mlx_load_safetensors(${file}) failed`);
176
+ }
177
+ const out = new Map<string, MlxArray>();
178
+ try {
179
+ for (const name of names) {
180
+ const slot = new BigUint64Array([C.mlx_array_new()]);
181
+ const slotPtr = ptr(slot);
182
+ if (C.mlx_map_string_to_array_get(slotPtr, mapHandle, ptr(cstr(name))) !== 0)
183
+ throw new Error(`adapter tensor ${name} missing from native map`);
184
+ const arr = new MlxArray(read.u64(slotPtr, 0));
185
+ arr.eval();
186
+ out.set(name, arr);
187
+ }
188
+ } catch (e) {
189
+ for (const a of out.values()) a.dispose();
190
+ C.mlx_map_string_to_array_free(mapHandle);
191
+ throw e;
192
+ }
193
+ C.mlx_map_string_to_array_free(mapHandle);
194
+ return out;
195
+ }