mlx-bun 0.0.4 → 0.0.5

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 (50) hide show
  1. package/README.md +166 -93
  2. package/docs/design/adapters-end-to-end.md +50 -46
  3. package/docs/design/compat-cli-surface-design.md +10 -8
  4. package/docs/design/docs-reorg-plan.md +14 -1
  5. package/docs/design/hlg-sampling.md +12 -2
  6. package/docs/design/memory-system.md +1 -1
  7. package/docs/design/optimization_plan.md +4 -2
  8. package/docs/design/orpo-future-enhancements.md +298 -0
  9. package/docs/design/orpo-training.md +1199 -0
  10. package/docs/design/parallel-slots.md +34 -26
  11. package/docs/design/runtime-isolation.md +1 -1
  12. package/docs/design/segmented-backward-training.md +58 -39
  13. package/docs/design/tauri-desktop-app.md +127 -0
  14. package/docs/{segmented-backward-handoff.md → investigations/segmented-backward-handoff.md} +1 -1
  15. package/docs/investigations/starter-model-port-handoff.md +32 -13
  16. package/docs/investigations/steel-flash-cce-handoff.md +431 -0
  17. package/docs/planning/IDEAS.md +29 -0
  18. package/docs/planning/PRODUCT_ROADMAP.md +147 -27
  19. package/docs/planning/chunk-task-roadmap.md +1 -1
  20. package/docs/planning/journal.md +161 -0
  21. package/docs/reference/distribution.md +61 -5
  22. package/docs/reference/library-api.md +29 -14
  23. package/docs/reference/orpo-quickstart.md +91 -0
  24. package/docs/reference/server-api.md +105 -7
  25. package/docs/reference/server-config.md +12 -5
  26. package/docs/reference/training.md +292 -0
  27. package/package.json +1 -1
  28. package/src/eval/ifeval.ts +138 -0
  29. package/src/eval/ultrafeedback.ts +60 -0
  30. package/src/lora.ts +12 -7
  31. package/src/mlx/metal-kernel.ts +18 -4
  32. package/src/model/flash-attention.ts +32 -16
  33. package/src/model/fused-geglu-kernel.ts +111 -0
  34. package/src/model/gemma4-base.ts +39 -1
  35. package/src/model/gemma4.ts +196 -21
  36. package/src/model/minicpm5.ts +44 -3
  37. package/src/server.ts +3 -3
  38. package/src/train/dataset.ts +18 -3
  39. package/src/train/flash-cce.ts +1162 -0
  40. package/src/train/job.ts +26 -2
  41. package/src/train/lora-params.ts +53 -4
  42. package/src/train/loss.ts +941 -0
  43. package/src/train/optimizer.ts +10 -3
  44. package/src/train/prefix-shared.ts +435 -0
  45. package/src/train/rank.ts +36 -0
  46. package/src/train/segmented.ts +1148 -3
  47. package/src/train/steel-qmm-header.ts +1461 -0
  48. package/src/train/trainer.ts +509 -52
  49. package/src/web/app.html +21 -5
  50. /package/{docs → src/assets}/curve-designer.html +0 -0
@@ -51,22 +51,48 @@ import {
51
51
  type SharedKv,
52
52
  type LoraWeights,
53
53
  } from "./gemma4-base";
54
- import { fusedGeglu, fusedGeluEnabled, fusedGeluSupported } from "./fused-geglu-kernel";
54
+ import { fusedGeglu, fusedGegluDifferentiable, fusedGeluEnabled, fusedGeluSupported, fusedGeluTraining } from "./fused-geglu-kernel";
55
55
  import { Checkpoint } from "../mlx/checkpoint";
56
56
  import { flashAttention, getTrainingAttn, flashSupported } from "./flash-attention";
57
57
 
58
58
  /** Optional gradient-checkpointing context for the training forward. When set,
59
59
  * forwardLayers wraps each layer in a Checkpoint so its interior activations
60
60
  * are recomputed in the backward pass. `byLayer` maps a layer index to that
61
- * layer's trainable LoRA weights (re-swapped inside the closure so they are
62
- * explicit checkpoint inputs — required because the autograd primals are
63
- * disposed before the recompute). `keepAlive` collects the per-layer
61
+ * layer's trainable LoRA weights, partitioned into the attention sub-block
62
+ * (q/k/v/o_proj) and the MLP sub-block (gate/up/down_proj + per-layer gate/
63
+ * projection) — re-swapped inside the closure so they are explicit checkpoint
64
+ * inputs (required because the autograd primals are disposed before the
65
+ * recompute). When `splitMlp` is set, the layer is wrapped in TWO checkpoints
66
+ * (attn sub-block + MLP sub-block) with the post-attn residual `hMid` as the
67
+ * boundary, so the backward recompute holds `max(attn,MLP)+hMid` instead of the
68
+ * whole layer (the intra-layer MLP split, lever 4). `keepAlive` collects the
64
69
  * Checkpoint objects; the trainer disposes them after value_and_grad. */
70
+ export interface LayerLoras {
71
+ attn: LoraWeights[];
72
+ mlp: LoraWeights[];
73
+ }
65
74
  export interface GradCheckpointCtx {
66
- byLayer: Map<number, LoraWeights[]>;
75
+ byLayer: Map<number, LayerLoras>;
76
+ splitMlp: boolean;
67
77
  keepAlive: Checkpoint[];
68
78
  }
69
79
 
80
+ // Shared prompt-prefix plan (lever 7, e4b — docs/design/orpo-training.md). When
81
+ // set, Attention ropes the concatenated [prompt(P); chosenResp(Rc); rejectedResp(Rr)]
82
+ // sequence BLOCK-WISE — prompt at offset 0, EACH response reset to logical offset
83
+ // P — instead of the uniform cache.offset, so ONE forward over the concat is
84
+ // bit-exact with the two separate [prompt;resp] forwards (each response sees the
85
+ // prompt's RoPE positions, not shifted-by-the-other-response). The matching
86
+ // block-sparse + LOGICAL-window attention mask rides in via the prefix cache's
87
+ // makeMask (src/train/prefix-shared.ts). Set around the single forward
88
+ // (single-threaded), cleared after; null → the normal uniform-offset rope (every
89
+ // other forward is untouched).
90
+ export interface GemmaPrefixPlan { P: number; Rc: number; Rr: number }
91
+ let _gemmaPrefixPlan: GemmaPrefixPlan | null = null;
92
+ export function setGemmaPrefixPlan(p: GemmaPrefixPlan | null): void {
93
+ _gemmaPrefixPlan = p;
94
+ }
95
+
70
96
  class Attention {
71
97
  readonly isSliding: boolean;
72
98
  readonly windowSize: number;
@@ -161,6 +187,32 @@ class Attention {
161
187
  : ops.ropeDynamic(x, this.headDim, this.ropeBase, offset, this.ropeFreqs);
162
188
  }
163
189
 
190
+ /** Block-wise RoPE for the prefix-shared concat [prompt; chosen; rejected] along
191
+ * the sequence axis (axis 2 of [B,H,T,D]): prompt at offset 0, EACH response
192
+ * reset to logical offset P. RoPE is per-token, so roping each contiguous block
193
+ * at its scalar offset and concatenating == roping with per-token position-ids
194
+ * [0..P-1, P..P+Rc-1, P..P+Rr-1]. Uses this.rope per block so the layer's
195
+ * proportional/ropeFreqs + sliding-vs-full geometry applies identically to the
196
+ * two-forward path. Caller disposes the input. */
197
+ ropeBlocks(x: MlxArray, plan: GemmaPrefixPlan): MlxArray {
198
+ const { P, Rc, Rr } = plan;
199
+ const [B, H, , D] = x.shape as [number, number, number, number];
200
+ const blocks = [
201
+ { start: 0, len: P, off: 0 },
202
+ { start: P, len: Rc, off: P },
203
+ { start: P + Rc, len: Rr, off: P },
204
+ ].filter((b) => b.len > 0);
205
+ const parts = blocks.map((b) => {
206
+ const sl = x.slice([0, 0, b.start, 0], [B, H, b.start + b.len, D]);
207
+ const r = this.rope(sl, b.off);
208
+ sl.dispose();
209
+ return r;
210
+ });
211
+ const out = ops.concatAxis(parts, 2);
212
+ for (const p of parts) p.dispose();
213
+ return out;
214
+ }
215
+
164
216
  /** Returns the attention output plus the fetched KV (for KV-shared
165
217
  * sharer layers and the speculative drafter). The SharedKv arrays are
166
218
  * owned by the caller (forwardLayers) and disposed after the pass. */
@@ -203,7 +255,9 @@ class Attention {
203
255
  const kNormed = this.kNorm!.forward(k);
204
256
  const kT = ops.transposeAxes(kNormed, [0, 2, 1, 3]);
205
257
  kNormed.dispose();
206
- const kRoped = this.rope(kT, offsetArr ?? offset);
258
+ const kRoped = _gemmaPrefixPlan
259
+ ? this.ropeBlocks(kT, _gemmaPrefixPlan)
260
+ : this.rope(kT, offsetArr ?? offset);
207
261
  kT.dispose();
208
262
 
209
263
  const vNormed = this.vNorm!.forward(v);
@@ -230,7 +284,9 @@ class Attention {
230
284
  }
231
285
 
232
286
  q = disposing(q, ops.transposeAxes(q, [0, 2, 1, 3]));
233
- q = disposing(q, this.rope(q, shared.offsetArr ?? shared.offset));
287
+ q = disposing(q, _gemmaPrefixPlan
288
+ ? this.ropeBlocks(q, _gemmaPrefixPlan)
289
+ : this.rope(q, shared.offsetArr ?? shared.offset));
234
290
 
235
291
  let attn: MlxArray;
236
292
  const ta = getTrainingAttn();
@@ -273,9 +329,12 @@ class MLP {
273
329
  const u = this.up.forward(x);
274
330
  // Fused GeGLU perf kernel (opt-in, not in a compiled trace): one pass
275
331
  // instead of ~9 element-wise kernels. Off → the bit-exact spelled path.
332
+ // In training mode the differentiable wrapper (kernel forward + hand-derived
333
+ // vjp) is used so autograd can flow AND the backward recomputes the gelu
334
+ // from the primal instead of retaining the spelled-out intermediates.
276
335
  let h: MlxArray;
277
336
  if (fusedGeluEnabled() && !isCompiledTrace() && fusedGeluSupported(g)) {
278
- h = fusedGeglu(g, u);
337
+ h = fusedGeluTraining() ? fusedGegluDifferentiable(g, u) : fusedGeglu(g, u);
279
338
  g.dispose();
280
339
  u.dispose();
281
340
  } else {
@@ -500,6 +559,23 @@ export class DecoderLayer {
500
559
  forward(
501
560
  x: MlxArray, mask: Mask, cache: Cache | null,
502
561
  sharedIn: SharedKv | null, perLayerInput: MlxArray | null,
562
+ ): { h: MlxArray; shared: SharedKv } {
563
+ // Composed from the two sub-blocks (the intra-layer min-cut is the post-attn
564
+ // residual `hMid` [1,T,hidden]). Behaviour is identical to the spelled-out
565
+ // body; the split lets the checkpoint path rematerialize attn and MLP
566
+ // separately (peak attn+MLP → max(attn,MLP)+hMid). `forwardMlp` borrows hMid.
567
+ const { h: hMid, shared } = this.forwardAttn(x, mask, cache, sharedIn);
568
+ const h = this.forwardMlp(hMid, perLayerInput);
569
+ hMid.dispose();
570
+ return { h, shared };
571
+ }
572
+
573
+ /** Attention sub-block: inputNorm → attn → postAttnNorm → residual add.
574
+ * Returns the post-attn residual stream `h` [1,T,hidden] (the intra-layer
575
+ * boundary) and the attention's fetched/produced K/V (`shared`). `x` is
576
+ * borrowed (never disposed). The e4b donor-KV threading lives entirely here. */
577
+ forwardAttn(
578
+ x: MlxArray, mask: Mask, cache: Cache | null, sharedIn: SharedKv | null,
503
579
  ): { h: MlxArray; shared: SharedKv } {
504
580
  let h = this.inputNorm.forward(x);
505
581
  const { out, shared } = this.attn.forward(h, mask, cache, sharedIn);
@@ -507,18 +583,24 @@ export class DecoderLayer {
507
583
  h = out;
508
584
  h = disposing(h, this.postAttnNorm.forward(h));
509
585
  h = disposing(h, ops.add(x, h));
586
+ return { h, shared };
587
+ }
510
588
 
511
- const residual = h;
589
+ /** MLP sub-block: FFN (dense or MoE) + postFfNorm + residual add, then the
590
+ * e4b per-layer-input gate and layer scalar. `hMid` (the attn sub-block's
591
+ * output) is BORROWED — the caller owns/disposes it (it is the checkpoint
592
+ * boundary leaf), so this never disposes it. */
593
+ forwardMlp(hMid: MlxArray, perLayerInput: MlxArray | null): MlxArray {
512
594
  let f: MlxArray;
513
595
  if (this.router && this.experts) {
514
- // MoE: dense MLP and routed experts as parallel branches off h
596
+ // MoE: dense MLP and routed experts as parallel branches off hMid
515
597
  // (reference DecoderLayer, enable_moe path)
516
- let h1 = this.preFfNorm.forward(h);
598
+ let h1 = this.preFfNorm.forward(hMid);
517
599
  h1 = disposing(h1, this.mlp.forward(h1));
518
600
  h1 = disposing(h1, this.postFfNorm1!.forward(h1));
519
601
 
520
- const { indices, weights: topKWeights } = this.router.forward(h);
521
- let h2 = this.preFfNorm2!.forward(h);
602
+ const { indices, weights: topKWeights } = this.router.forward(hMid);
603
+ let h2 = this.preFfNorm2!.forward(hMid);
522
604
  h2 = disposing(h2, this.experts.forward(h2, indices, topKWeights));
523
605
  h2 = disposing(h2, this.postFfNorm2!.forward(h2));
524
606
  indices.dispose();
@@ -528,20 +610,25 @@ export class DecoderLayer {
528
610
  h1.dispose();
529
611
  h2.dispose();
530
612
  } else {
531
- f = this.preFfNorm.forward(h);
613
+ f = this.preFfNorm.forward(hMid);
532
614
  f = disposing(f, this.mlp.forward(f));
533
615
  }
534
616
  f = disposing(f, this.postFfNorm.forward(f));
535
- h = ops.add(residual, f);
536
- residual.dispose();
617
+ let h = ops.add(hMid, f); // hMid borrowed — NOT disposed here
537
618
  f.dispose();
538
619
 
539
- // per-layer input gating (reference gemma4_text DecoderLayer)
620
+ // per-layer input gating (reference gemma4_text DecoderLayer). gelu(gate)·pli
621
+ // is the second GeGLU site — fused (differentiable) on the training path, the
622
+ // bit-exact spelled-out gelu+mul on the inference path (unchanged).
540
623
  if (this.perLayerGate && this.perLayerProjection && this.postPerLayerNorm && perLayerInput) {
541
624
  const res2 = h;
542
625
  let gate = this.perLayerGate.forward(h);
543
- gate = disposing(gate, ops.geluApprox(gate));
544
- gate = disposing(gate, ops.mul(gate, perLayerInput));
626
+ if (fusedGeluTraining() && fusedGeluEnabled() && !isCompiledTrace() && fusedGeluSupported(gate)) {
627
+ gate = disposing(gate, fusedGegluDifferentiable(gate, perLayerInput));
628
+ } else {
629
+ gate = disposing(gate, ops.geluApprox(gate));
630
+ gate = disposing(gate, ops.mul(gate, perLayerInput));
631
+ }
545
632
  gate = disposing(gate, this.perLayerProjection.forward(gate));
546
633
  gate = disposing(gate, this.postPerLayerNorm.forward(gate));
547
634
  h = ops.add(res2, gate);
@@ -550,7 +637,7 @@ export class DecoderLayer {
550
637
  }
551
638
 
552
639
  if (this.layerScalar) h = disposing(h, ops.mul(h, this.layerScalar));
553
- return { h, shared };
640
+ return h;
554
641
  }
555
642
  }
556
643
 
@@ -927,7 +1014,19 @@ export class Gemma4Model {
927
1014
  layerCache: Cache | null, sharedIn: SharedKv | null, pls: MlxArray | null,
928
1015
  ): { h: MlxArray; shared: SharedKv | null } {
929
1016
  const ctx = this.gradCkpt!;
930
- const lws = ctx.byLayer.get(i) ?? [];
1017
+ const ll = ctx.byLayer.get(i) ?? { attn: [], mlp: [] };
1018
+ return ctx.splitMlp
1019
+ ? this.runSplitCheckpointedLayer(layer, h, mask, layerCache, sharedIn, pls, ll, ctx)
1020
+ : this.runSingleCheckpointedLayer(layer, h, mask, layerCache, sharedIn, pls, ll, ctx);
1021
+ }
1022
+
1023
+ /** One Checkpoint over the whole layer (the original path). */
1024
+ private runSingleCheckpointedLayer(
1025
+ layer: DecoderLayer, h: MlxArray, mask: Mask,
1026
+ layerCache: Cache | null, sharedIn: SharedKv | null, pls: MlxArray | null,
1027
+ ll: LayerLoras, ctx: GradCheckpointCtx,
1028
+ ): { h: MlxArray; shared: SharedKv | null } {
1029
+ const lws = [...ll.attn, ...ll.mlp];
931
1030
  const isSharer = layerCache === null;
932
1031
  const sIn = isSharer && sharedIn && sharedIn.kind === "plain" ? sharedIn : null;
933
1032
  const hasMaskArr = mask.arr !== null;
@@ -970,6 +1069,82 @@ export class Gemma4Model {
970
1069
  return { h: outs[0]!, shared };
971
1070
  }
972
1071
 
1072
+ /** Intra-layer MLP split (lever 4): TWO checkpoints — the attention sub-block
1073
+ * (forwardAttn: inputNorm → attn → residual add, threading the donor K/V) and
1074
+ * the MLP sub-block (forwardMlp: FFN + per-layer gate) — with the post-attn
1075
+ * residual `hMid` as the boundary. The backward recompute holds only one
1076
+ * sub-block's activations at a time, dropping per-layer peak from `attn+MLP`
1077
+ * to `max(attn,MLP)+hMid`. Attn LoRA / pls / donor-KV go to the attn ck; MLP
1078
+ * LoRA to the MLP ck. */
1079
+ private runSplitCheckpointedLayer(
1080
+ layer: DecoderLayer, h: MlxArray, mask: Mask,
1081
+ layerCache: Cache | null, sharedIn: SharedKv | null, pls: MlxArray | null,
1082
+ ll: LayerLoras, ctx: GradCheckpointCtx,
1083
+ ): { h: MlxArray; shared: SharedKv | null } {
1084
+ const isSharer = layerCache === null;
1085
+ const sIn = isSharer && sharedIn && sharedIn.kind === "plain" ? sharedIn : null;
1086
+ const hasMaskArr = mask.arr !== null;
1087
+ const hasPls = pls !== null;
1088
+ const maskMode = mask.mode;
1089
+ const attnLws = ll.attn;
1090
+ const mlpLws = ll.mlp;
1091
+
1092
+ // --- attn sub-block checkpoint: [h, maskArr?, sharedInK?, sharedInV?, attnLora*]
1093
+ const attnInputs: MlxArray[] = [h];
1094
+ if (hasMaskArr) attnInputs.push(mask.arr!);
1095
+ if (sIn) attnInputs.push(sIn.keys, sIn.values);
1096
+ for (const lw of attnLws) attnInputs.push(lw.a, lw.b);
1097
+
1098
+ const attnCk = new Checkpoint((ins) => {
1099
+ let k = 0;
1100
+ const hIn = ins[k++]!;
1101
+ const mArr = hasMaskArr ? ins[k++]! : null;
1102
+ const sharedInRebuilt: SharedKv | null = sIn
1103
+ ? { kind: "plain", keys: ins[k++]!, values: ins[k++]!, offset: 0 }
1104
+ : null;
1105
+ const saved = attnLws.map((lw) => [lw.a, lw.b] as [MlxArray, MlxArray]);
1106
+ for (const lw of attnLws) { lw.a = ins[k++]!; lw.b = ins[k++]!; }
1107
+ try {
1108
+ const out = layer.forwardAttn(hIn, { mode: maskMode, arr: mArr }, layerCache, sharedInRebuilt);
1109
+ if (!isSharer && out.shared && out.shared.kind === "plain")
1110
+ return [out.h, out.shared.keys, out.shared.values];
1111
+ return [out.h];
1112
+ } finally {
1113
+ attnLws.forEach((lw, j) => { lw.a = saved[j]![0]; lw.b = saved[j]![1]; });
1114
+ }
1115
+ });
1116
+ ctx.keepAlive.push(attnCk);
1117
+ const attnOuts = attnCk.apply(attnInputs);
1118
+ const hMid = attnOuts[0]!;
1119
+ const shared: SharedKv | null =
1120
+ !isSharer && attnOuts.length === 3
1121
+ ? { kind: "plain", keys: attnOuts[1]!, values: attnOuts[2]!, offset: 0 }
1122
+ : null;
1123
+
1124
+ // --- MLP sub-block checkpoint: [hMid, pls?, mlpLora*] -> [hOut]
1125
+ const mlpInputs: MlxArray[] = [hMid];
1126
+ if (hasPls) mlpInputs.push(pls!);
1127
+ for (const lw of mlpLws) mlpInputs.push(lw.a, lw.b);
1128
+
1129
+ const mlpCk = new Checkpoint((ins) => {
1130
+ let k = 0;
1131
+ const hMidIn = ins[k++]!;
1132
+ const plsIn = hasPls ? ins[k++]! : null;
1133
+ const saved = mlpLws.map((lw) => [lw.a, lw.b] as [MlxArray, MlxArray]);
1134
+ for (const lw of mlpLws) { lw.a = ins[k++]!; lw.b = ins[k++]!; }
1135
+ try {
1136
+ return [layer.forwardMlp(hMidIn, plsIn)];
1137
+ } finally {
1138
+ mlpLws.forEach((lw, j) => { lw.a = saved[j]![0]; lw.b = saved[j]![1]; });
1139
+ }
1140
+ });
1141
+ ctx.keepAlive.push(mlpCk);
1142
+ const mlpOuts = mlpCk.apply(mlpInputs);
1143
+ hMid.dispose(); // boundary leaf consumed by the MLP ck (mlx retains it in-graph)
1144
+
1145
+ return { h: mlpOuts[0]!, shared };
1146
+ }
1147
+
973
1148
  /** hidden [1, L, hidden] → softcapped logits [1, L, vocab]. */
974
1149
  logitsFromHidden(h: MlxArray): MlxArray {
975
1150
  let logits = this.embed.asLinear(h);
@@ -21,6 +21,45 @@ import {
21
21
  } from "./gemma4-base";
22
22
  import { flashAttention, getTrainingAttn, flashSupported } from "./flash-attention";
23
23
 
24
+ // Shared prompt-prefix plan (lever 7). When set, the attention ropes the
25
+ // concatenated [prompt(P); chosenResp(Rc); rejectedResp(Rr)] sequence
26
+ // BLOCK-WISE — prompt at offset 0, EACH response reset to offset P — instead of
27
+ // the uniform cache.offset. This is what lets one forward over the concat be
28
+ // bit-exact with the two separate [prompt;resp] forwards (each response sees the
29
+ // prompt's RoPE positions, not shifted-by-the-other-response). Set by
30
+ // orpoLossPrefixShared around the forward (single-threaded), cleared after; null
31
+ // → the normal uniform-offset rope (every other forward is untouched). The
32
+ // matching block-sparse attention mask rides in via PrefixSharedCache.makeMask.
33
+ export interface PrefixPlan { P: number; Rc: number; Rr: number }
34
+ let _prefixPlan: PrefixPlan | null = null;
35
+ export function setMiniCpmPrefixPlan(p: PrefixPlan | null): void {
36
+ _prefixPlan = p;
37
+ }
38
+
39
+ /** Block-wise RoPE for the prefix-shared concat [prompt; chosen; rejected] along
40
+ * the sequence axis (axis 2 of [B,H,T,D]): prompt rotated at offset 0, each
41
+ * response at offset P (reset). RoPE is per-token, so roping each contiguous
42
+ * block at its scalar offset and concatenating == roping with per-token
43
+ * position-ids [0..P-1, P..P+Rc-1, P..P+Rr-1]. Caller disposes the input. */
44
+ function ropeBlocks(x: MlxArray, dims: number, base: number, plan: PrefixPlan): MlxArray {
45
+ const { P, Rc, Rr } = plan;
46
+ const [B, H, , D] = x.shape as [number, number, number, number];
47
+ const blocks: { start: number; len: number; off: number }[] = [
48
+ { start: 0, len: P, off: 0 },
49
+ { start: P, len: Rc, off: P },
50
+ { start: P + Rc, len: Rr, off: P },
51
+ ].filter((b) => b.len > 0);
52
+ const parts = blocks.map((b) => {
53
+ const sl = x.slice([0, 0, b.start, 0], [B, H, b.start + b.len, D]);
54
+ const r = ops.rope(sl, dims, base, b.off, null);
55
+ sl.dispose();
56
+ return r;
57
+ });
58
+ const out = ops.concatAxis(parts, 2);
59
+ for (const p of parts) p.dispose();
60
+ return out;
61
+ }
62
+
24
63
  class LlamaAttention {
25
64
  readonly qProj: QuantizedLinear;
26
65
  readonly kProj: QuantizedLinear;
@@ -65,9 +104,11 @@ class LlamaAttention {
65
104
  // runs before updateAndFetch, so K and Q share the pre-write offset.
66
105
  const offsetArr = cache.ropeOffsetArr;
67
106
  const ropeStep = (x: MlxArray): MlxArray =>
68
- offsetArr
69
- ? ops.ropeDynamic(x, this.headDim, this.ropeBase, offsetArr, null)
70
- : ops.rope(x, this.headDim, this.ropeBase, cache.offset, null);
107
+ _prefixPlan
108
+ ? ropeBlocks(x, this.headDim, this.ropeBase, _prefixPlan)
109
+ : offsetArr
110
+ ? ops.ropeDynamic(x, this.headDim, this.ropeBase, offsetArr, null)
111
+ : ops.rope(x, this.headDim, this.ropeBase, cache.offset, null);
71
112
  q = disposing(q, ropeStep(q));
72
113
  k = disposing(k, ropeStep(k));
73
114
  let attn: MlxArray;
package/src/server.ts CHANGED
@@ -15,7 +15,7 @@ import type { Server } from "bun";
15
15
  // imports as HTMLBundle (the html loader), but the text attribute makes
16
16
  // the runtime value a string — hence the double cast.
17
17
  import appHtml from "./web/app.html" with { type: "text" };
18
- import curveDesignerHtml from "../docs/curve-designer.html" with { type: "text" };
18
+ import curveDesignerHtml from "./assets/curve-designer.html" with { type: "text" };
19
19
  import pkgJson from "../package.json" with { type: "json" };
20
20
  import { readFileSync } from "node:fs";
21
21
  const APP_PAGE = appHtml as unknown as string;
@@ -977,7 +977,7 @@ export function createServer(
977
977
  // embedded copy when running as the compiled single binary.
978
978
  if (url.pathname === "/curves" && request.method === "GET") {
979
979
  let html = CURVE_PAGE;
980
- try { html = readFileSync(new URL("../docs/curve-designer.html", import.meta.url), "utf8"); } catch { /* binary: use embedded */ }
980
+ try { html = readFileSync(new URL("./assets/curve-designer.html", import.meta.url), "utf8"); } catch { /* binary: use embedded */ }
981
981
  return new Response(html, { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });
982
982
  }
983
983
  if (url.pathname === "/curve-terrain" && request.method === "GET") {
@@ -985,7 +985,7 @@ export function createServer(
985
985
  const html = readFileSync(new URL("../docs/investigations/curve-terrain.html", import.meta.url), "utf8");
986
986
  return new Response(html, { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });
987
987
  } catch {
988
- return new Response("curve terrain artifact not found; run scripts/curve-terrain.ts first", { status: 404 });
988
+ return new Response("curve terrain artifact not found; run scripts/experiments/curve-terrain.ts first", { status: 404 });
989
989
  }
990
990
  }
991
991
  if (request.method === "GET" &&
@@ -333,7 +333,22 @@ export async function loadDpoDataset(
333
333
  maxLength: number,
334
334
  ): Promise<DpoExample[]> {
335
335
  const rows = await readJsonl(path);
336
- return rows.map((r) => encodeDpoRow(r, tok, tmpl, maxLength));
336
+ const examples = rows.map((r) => encodeDpoRow(r, tok, tmpl, maxLength));
337
+ // Prompt-masking guard (the classic ORPO/DPO footgun on long-prompt /
338
+ // short-completion data): a collapsed prompt boundary (promptLen=0 → mask[0]
339
+ // is 1) means the completion filled the entire window — the NLL/odds-ratio
340
+ // would then be computed over non-completion tokens and the real signal gets
341
+ // drowned. Normally the loss is completion-only (mask 0 over the prompt); this
342
+ // only happens when a completion is itself ≥ max_seq_length. Surface it loudly.
343
+ let unmasked = 0;
344
+ for (const ex of examples) if (ex.chosenMask[0] === 1 || ex.rejectedMask[0] === 1) unmasked++;
345
+ if (unmasked > 0)
346
+ console.warn(
347
+ `[dataset] WARNING: ${unmasked}/${examples.length} preference examples have NO prompt boundary ` +
348
+ `(promptLen=0): the completion is ≥ max_seq_length (${maxLength}), so the loss would include ` +
349
+ `non-completion tokens. Raise max_seq_length above the completion length so the prompt is masked.`,
350
+ );
351
+ return examples;
337
352
  }
338
353
 
339
354
  /** Row length of a DPO branch (port of _make_batch helpers). */
@@ -420,10 +435,10 @@ function makeDpoBatch(rows: DpoExample[], padId: number): DpoBatch {
420
435
  // ---------------------------------------------------------------------------
421
436
 
422
437
  /** Format probe for inspect-dataset: peek the first row's keys. */
423
- export type DatasetFormat = "messages" | "prompt-completion" | "text" | "dpo" | "unknown";
438
+ export type DatasetFormat = "messages" | "prompt-completion" | "text" | "dpo" | "preference" | "unknown";
424
439
 
425
440
  export function probeFormat(row: Record<string, unknown>): DatasetFormat {
426
- if (typeof row.chosen === "string" && typeof row.rejected === "string") return "dpo";
441
+ if (typeof row.chosen === "string" && typeof row.rejected === "string") return "preference";
427
442
  try {
428
443
  return sftRowFormat(row);
429
444
  } catch {