mlx-bun 0.0.4 → 0.0.6

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 (90) hide show
  1. package/README.md +175 -93
  2. package/docs/dag/training-inference-map.html +575 -0
  3. package/docs/design/adapters-end-to-end.md +50 -46
  4. package/docs/design/compat-cli-surface-design.md +10 -8
  5. package/docs/design/docs-reorg-plan.md +14 -1
  6. package/docs/design/hlg-sampling.md +12 -2
  7. package/docs/design/memory-system.md +193 -295
  8. package/docs/design/optimization_plan.md +4 -2
  9. package/docs/design/orpo-dynamic-lambda.md +179 -0
  10. package/docs/design/orpo-future-enhancements.md +340 -0
  11. package/docs/design/orpo-training.md +1212 -0
  12. package/docs/design/parallel-slots.md +34 -26
  13. package/docs/design/parity-tier-dag.md +143 -0
  14. package/docs/design/runtime-isolation.md +1 -1
  15. package/docs/design/segmented-backward-training.md +58 -39
  16. package/docs/design/tauri-desktop-app.md +127 -0
  17. package/docs/design/w4a16-compute-precision-spike.md +232 -0
  18. package/docs/investigations/orpo-base-uf-experiment-and-directions.md +120 -0
  19. package/docs/investigations/orpo-flash-cce-pin-leak.md +167 -0
  20. package/docs/investigations/orpo-uf-testing-handoff.md +126 -0
  21. package/docs/{segmented-backward-handoff.md → investigations/segmented-backward-handoff.md} +1 -1
  22. package/docs/investigations/starter-model-port-handoff.md +32 -13
  23. package/docs/investigations/steel-flash-cce-handoff.md +489 -0
  24. package/docs/investigations/trainer-validation-experiment.md +173 -0
  25. package/docs/planning/IDEAS.md +29 -0
  26. package/docs/planning/PRODUCT_ROADMAP.md +147 -27
  27. package/docs/planning/chunk-task-roadmap.md +1 -1
  28. package/docs/planning/journal.md +161 -0
  29. package/docs/reference/distribution.md +61 -5
  30. package/docs/reference/library-api.md +29 -14
  31. package/docs/reference/memory.md +77 -0
  32. package/docs/reference/orpo-quickstart.md +130 -0
  33. package/docs/reference/server-api.md +105 -7
  34. package/docs/reference/server-config.md +12 -5
  35. package/docs/reference/training.md +296 -0
  36. package/package.json +1 -1
  37. package/src/cli.ts +651 -18
  38. package/src/eval/ifeval.ts +138 -0
  39. package/src/eval/runner.ts +76 -6
  40. package/src/eval/tasks/bfcl.ts +49 -15
  41. package/src/eval/tasks/gsm8k.ts +21 -9
  42. package/src/eval/tasks/hashhop.ts +33 -4
  43. package/src/eval/tasks/humaneval.ts +30 -8
  44. package/src/eval/tasks/ifeval.ts +52 -18
  45. package/src/eval/tasks/mmlu.ts +85 -74
  46. package/src/eval/ultrafeedback.ts +60 -0
  47. package/src/jobs/runner.ts +6 -0
  48. package/src/lora.ts +12 -7
  49. package/src/memory/chunk.ts +25 -0
  50. package/src/memory/cluster.ts +24 -0
  51. package/src/memory/db.ts +114 -0
  52. package/src/memory/ingest.ts +22 -0
  53. package/src/memory/pipeline.ts +63 -0
  54. package/src/memory/prompts.ts +23 -0
  55. package/src/memory/schedule.ts +163 -0
  56. package/src/memory/synthesize.ts +29 -0
  57. package/src/memory/tools.ts +399 -0
  58. package/src/memory/vault.ts +694 -0
  59. package/src/memory/wikify.ts +23 -0
  60. package/src/mlx/metal-kernel.ts +31 -4
  61. package/src/model/flash-attention.ts +37 -19
  62. package/src/model/fused-geglu-kernel.ts +111 -0
  63. package/src/model/gemma4-base.ts +39 -1
  64. package/src/model/gemma4.ts +196 -21
  65. package/src/model/minicpm5.ts +44 -3
  66. package/src/pi-provider.ts +29 -13
  67. package/src/pi-session.ts +81 -0
  68. package/src/pi-terminal.ts +13 -24
  69. package/src/pi-web.ts +246 -166
  70. package/src/quantize/job.ts +17 -2
  71. package/src/quantize/quantizer.ts +26 -2
  72. package/src/server.ts +216 -47
  73. package/src/tokenizer.ts +6 -2
  74. package/src/train/dataset.ts +29 -6
  75. package/src/train/flash-cce.ts +1175 -0
  76. package/src/train/job.ts +64 -3
  77. package/src/train/lora-params.ts +53 -4
  78. package/src/train/loss.ts +947 -0
  79. package/src/train/optimizer.ts +10 -3
  80. package/src/train/prefix-shared.ts +435 -0
  81. package/src/train/rank.ts +36 -0
  82. package/src/train/segmented.ts +1148 -3
  83. package/src/train/steel-qmm-header.ts +1461 -0
  84. package/src/train/trainer.ts +570 -54
  85. package/src/train/watch.ts +542 -0
  86. package/src/web/app.html +285 -19
  87. package/src/web/skills/memory/SKILL.md +97 -0
  88. package/src/web/skills/web-research/SKILL.md +3 -1
  89. package/src/web/skills.ts +46 -8
  90. /package/{docs → src/assets}/curve-designer.html +0 -0
@@ -23,6 +23,10 @@ export interface AdamWOptions {
23
23
  betas?: [number, number];
24
24
  eps?: number;
25
25
  weightDecay?: number;
26
+ /** Optional per-parameter LR multiplier (effective lr for param i is
27
+ * `lr · lrScale[i]`). Used for LoRA+ — the B leaves get a higher LR than A.
28
+ * Length must match `params`; missing/undefined entries default to 1. */
29
+ lrScale?: number[];
26
30
  }
27
31
 
28
32
  export class AdamW {
@@ -34,6 +38,7 @@ export class AdamW {
34
38
  readonly #b2: number;
35
39
  readonly #eps: number;
36
40
  readonly #wd: number;
41
+ readonly #lrScale: number[];
37
42
  #lr: number;
38
43
  #t = 0;
39
44
 
@@ -48,6 +53,7 @@ export class AdamW {
48
53
  [this.#b1, this.#b2] = opts.betas ?? [0.9, 0.999];
49
54
  this.#eps = opts.eps ?? 1e-8;
50
55
  this.#wd = opts.weightDecay ?? 0.01;
56
+ this.#lrScale = opts.lrScale ?? [];
51
57
  // m, v as zeros like each param (handles 0-d params, where ops.zeros's
52
58
  // empty shape buffer would trip ptr()).
53
59
  this.#m = params.map(zerosLikeF32);
@@ -81,6 +87,7 @@ export class AdamW {
81
87
 
82
88
  for (let i = 0; i < this.#params.length; i++) {
83
89
  const p = this.#params[i]!;
90
+ const lri = this.#lr * (this.#lrScale[i] ?? 1); // per-param lr (LoRA+)
84
91
  const g0 = grads[i]!;
85
92
  const g = g0.dtype === Dtype.float32 ? g0 : g0.astype(Dtype.float32);
86
93
 
@@ -106,10 +113,10 @@ export class AdamW {
106
113
  v0.dispose();
107
114
  this.#v[i] = v1;
108
115
 
109
- // p <- p - lr·wd·p (decoupled weight decay)
116
+ // p <- p - lr·wd·p (decoupled weight decay; per-param lr)
110
117
  let pNext = p;
111
118
  if (this.#wd !== 0) {
112
- const decay = ops.mulScalar(p, this.#lr * this.#wd);
119
+ const decay = ops.mulScalar(p, lri * this.#wd);
113
120
  pNext = ops.sub(p, decay);
114
121
  decay.dispose();
115
122
  }
@@ -121,7 +128,7 @@ export class AdamW {
121
128
  const eps = ops.scalarLike(this.#eps, sqrtV);
122
129
  const denom = ops.add(sqrtV, eps);
123
130
  const update = ops.div(mHat, denom);
124
- const lrUpdate = ops.mulScalar(update, this.#lr);
131
+ const lrUpdate = ops.mulScalar(update, lri);
125
132
  const pFinal = ops.sub(pNext, lrUpdate);
126
133
 
127
134
  for (const a of [mHat, vHat, sqrtV, eps, denom, update, lrUpdate]) a.dispose();
@@ -0,0 +1,435 @@
1
+ // Shared prompt-prefix ORPO (lever 7) — MiniCPM5, B=1.
2
+ //
3
+ // Chosen and rejected share an identical prompt prefix; the causal mask makes the
4
+ // prefix's hidden states independent of which response follows. So instead of two
5
+ // forwards [prompt;chosenResp] and [prompt;rejectedResp] (the prompt encoded
6
+ // TWICE), run ONE forward over the concatenation
7
+ // X = [ prompt(P) ; chosenResp(Rc) ; rejectedResp(Rr) ] (T = P+Rc+Rr)
8
+ // with (a) a BLOCK-SPARSE mask so each response attends to prompt + itself only
9
+ // (never the other response), and (b) BLOCK-WISE RoPE that resets each response to
10
+ // position P. Token cost through the layers 2(P+R) → P+2R — a ~2× win when the
11
+ // prompt dominates (our chunk/document fine-tunes), ~0 when the response does.
12
+ //
13
+ // Bit-exact with the two-forward path (validated in
14
+ // scripts/experiments/prefix-shared-parity.ts): each predicted token uses a hidden
15
+ // conditioned on exactly the same context + RoPE positions —
16
+ // chosen[k] from H[P-1+k] (k=0..Rc-1; H[P-1] = prompt's last)
17
+ // rejected[k] from H[P-1] (k=0) / H[P+Rc+k-1] (k≥1)
18
+ // Both first-response tokens read the SAME prompt-last hidden H[P-1], computed
19
+ // once. Differentiability is free: it is one forward graph, so reverse-mode AD
20
+ // sums the two branches' cotangents into the shared prefix automatically.
21
+ //
22
+ // e4b (sliding-window mask interaction, donor-KV, per-layer-input) is the
23
+ // documented follow-on; this is the MiniCPM5 reference + correctness gate.
24
+
25
+ import { MlxArray } from "../mlx/array";
26
+ import { Dtype } from "../mlx/ffi";
27
+ import * as ops from "../mlx/ops";
28
+ import { createCausalMask, type Cache, type Mask } from "../model/gemma4-base";
29
+ import { MiniCPM5Model, setMiniCpmPrefixPlan } from "../model/minicpm5";
30
+ import { Gemma4Model, setGemmaPrefixPlan } from "../model/gemma4";
31
+ import { orpoLossFromLogps, fusedRespLogpMean, type ChunkCtx } from "./loss";
32
+ import type { DpoBatch } from "./dataset";
33
+
34
+ /** A model that can project final-norm hidden states to vocab logits — the only
35
+ * capability the prefix-shared head needs (MiniCPM5Model and Gemma4Model both). */
36
+ interface LogitProjector { logitsFromHidden(h: MlxArray): MlxArray }
37
+
38
+ /** Block-sparse attention mask [1,1,T,T] (bool, true = attend) for the concat:
39
+ * causal AND NOT (rejected row → chosen col). Chosen rows already can't see
40
+ * rejected cols (rejected come later → causal forbids), so the only extra cut is
41
+ * rejected→chosen. Caller owns the result. */
42
+ export function blockSparseMask(P: number, Rc: number, Rr: number): MlxArray {
43
+ const T = P + Rc + Rr;
44
+ const causal = createCausalMask(T, 0, null); // [T,T] bool, j<=i
45
+ const idxFlat = ops.arange(0, T, 1, Dtype.int32);
46
+ const row = ops.reshape(idxFlat, [T, 1]);
47
+ const col = ops.reshape(idxFlat, [1, T]);
48
+ const pp = ops.fromInt32([P], []);
49
+ const pRc = ops.fromInt32([P + Rc], []);
50
+ // notForbid = NOT(rejRow AND chosenCol) = (i < P+Rc) OR (j < P) OR (j >= P+Rc)
51
+ const notRejRow = ops.less(row, pRc);
52
+ const colLtP = ops.less(col, pp);
53
+ const colGePRc = ops.greaterEqual(col, pRc);
54
+ const notChosenCol = ops.logicalOr(colLtP, colGePRc);
55
+ const notForbid = ops.logicalOr(notRejRow, notChosenCol);
56
+ const allow = ops.logicalAnd(causal, notForbid); // [T,T] bool — same rank as createCausalMask
57
+ for (const a of [causal, idxFlat, row, col, pp, pRc, notRejRow, colLtP, colGePRc, notChosenCol, notForbid]) a.dispose();
58
+ return allow;
59
+ }
60
+
61
+ /** Stateless cache for the prefix-shared forward: pass-through KV (offset 0, like
62
+ * TrainingCache) but `makeMask` returns the block-sparse mask. One per layer;
63
+ * MiniCPM5's runLayerRange calls makeMask once and disposes the returned arr. */
64
+ export class PrefixSharedCache implements Cache {
65
+ offset = 0;
66
+ constructor(private readonly P: number, private readonly Rc: number, private readonly Rr: number) {}
67
+ updateAndFetch(k: MlxArray, v: MlxArray): [MlxArray, MlxArray] {
68
+ return [k.slice([0, 0, 0, 0], k.shape), v.slice([0, 0, 0, 0], v.shape)];
69
+ }
70
+ makeMask(_N: number, _windowSize: number | null): Mask {
71
+ return { mode: "array", arr: blockSparseMask(this.P, this.Rc, this.Rr) };
72
+ }
73
+ state(): MlxArray[] { return []; }
74
+ isTrimmable(): boolean { return true; }
75
+ trim(_n: number): void { /* offset pinned at 0 */ }
76
+ dispose(): void { /* owns no arrays */ }
77
+ }
78
+
79
+ /** Length-normalized mean log-prob over the predictions at hidden positions
80
+ * `gatherIdx`, scoring `targets` (same shape). Mirrors responseOnlyLogpMean but
81
+ * gathers arbitrary (possibly non-contiguous) positions instead of a slice. */
82
+ function gatheredLogpMean(model: LogitProjector, h: MlxArray, gatherIdx: number[], targets: number[]): MlxArray {
83
+ const M = gatherIdx.length;
84
+ if (M <= 0) return MlxArray.fromFloat32(new Float32Array([0]), [1]);
85
+ const hidden = h.shape[2]!;
86
+ const idxArr = MlxArray.fromInt32(new Int32Array(gatherIdx), [M]);
87
+ const hSel = ops.takeAxis(h, idxArr, 1); // [1, M, hidden]
88
+ idxArr.dispose();
89
+ if (hSel.shape[2] !== hidden) throw new Error("gatheredLogpMean: hidden mismatch");
90
+ const logits = model.logitsFromHidden(hSel); // [1, M, V]
91
+ hSel.dispose();
92
+ const V = logits.shape[2]!;
93
+ const logits2d = ops.reshape(logits, [M, V]);
94
+ logits.dispose();
95
+ const tgt = MlxArray.fromInt32(new Int32Array(targets), [M, 1]);
96
+ const lse = ops.logsumexpAxis(logits2d, -1, false);
97
+ const gathered = ops.takeAlongAxis(logits2d, tgt, -1);
98
+ const picked = ops.reshape(gathered, [M]);
99
+ const logp = ops.sub(picked, lse);
100
+ const logpF = logp.dtype === Dtype.float32 ? logp : logp.astype(Dtype.float32);
101
+ const sumLogp = ops.sumAxis(logpF, 0, false);
102
+ const meanScalar = ops.mulScalar(sumLogp, 1 / M);
103
+ const mean = ops.reshape(meanScalar, [1]);
104
+ for (const a of [logits2d, tgt, lse, gathered, picked, logp, sumLogp, meanScalar]) a.dispose();
105
+ if (logpF !== logp) logpF.dispose();
106
+ return mean;
107
+ }
108
+
109
+ /** Length-normalized mean log-prob over gathered (possibly non-contiguous) hidden
110
+ * positions via the FUSED/FLASH head ([M,V]-free analytic backward) when `chunk`
111
+ * requests it, else the whole-vocab `gatheredLogpMean`. Gathers the branch's
112
+ * response hiddens [M, hidden] from `h [1,T,hidden]` and hands them to
113
+ * `fusedRespLogpMean` — so prefix-sharing composes with the steel flash-CCE head
114
+ * (one model forward + one head fwd/bwd per branch, all [M,V]-free). */
115
+ export function branchLogpMeanGathered(
116
+ model: LogitProjector, h: MlxArray, gatherIdx: number[], targets: number[], chunk?: ChunkCtx,
117
+ ): MlxArray {
118
+ if (!chunk || !(chunk.fused || chunk.flash)) return gatheredLogpMean(model, h, gatherIdx, targets);
119
+ const M = gatherIdx.length;
120
+ if (M <= 0) return MlxArray.fromFloat32(new Float32Array([0]), [1]);
121
+ const hidden = h.shape[2]!;
122
+ const idxArr = MlxArray.fromInt32(new Int32Array(gatherIdx), [M]);
123
+ const hSel = ops.takeAxis(h, idxArr, 1); // [1, M, hidden]
124
+ idxArr.dispose();
125
+ const hResp = ops.reshape(hSel, [M, hidden]); // [M, hidden] for the head
126
+ hSel.dispose();
127
+ const chunkSize = chunk.chunkSize > 0 ? chunk.chunkSize : 512;
128
+ // fusedRespLogpMean requires a RuntimeModel (headQuant); the prefix-shared models
129
+ // (MiniCPM5Model/Gemma4Model) satisfy it. vocabBlock is unused by the flash path.
130
+ // hResp is the CustomVjp/flash-head PRIMAL — its backward recomputes the head from
131
+ // it (loss.ts fusedRespLogpMean/makeFlashCceHeadVjp), so it must live until the head
132
+ // vjp is eval'd. Push it into the sink (disposed post-eval by the caller) instead of
133
+ // freeing it here, matching responseOnlyLogpMean/fusedLogpMeanFromHidden.
134
+ chunk.sink.push(hResp);
135
+ const mean = fusedRespLogpMean(model as any, hResp, new Int32Array(targets), chunkSize, chunk.sink, 0, chunk.flash ?? false);
136
+ return mean;
137
+ }
138
+
139
+ /** The response-hidden gather indices for the prefix-shared concat layout
140
+ * [prompt(P); chosen(Rc); rejected(Rr)]: chosen[k] is predicted from H[P-1+k];
141
+ * rejected[0] from H[P-1] (the shared prompt-last, same as chosen[0]) and
142
+ * rejected[k>=1] from H[P+Rc+k-1]. Used by the non-segmented path AND the
143
+ * segmented prefix backward so both gather identically. */
144
+ export function prefixGatherIdx(P: number, Rc: number, Rr: number): { chosenIdx: number[]; rejectedIdx: number[] } {
145
+ const chosenIdx = Array.from({ length: Rc }, (_, k) => P - 1 + k);
146
+ const rejectedIdx = [P - 1, ...Array.from({ length: Rr - 1 }, (_, k) => P + Rc + k)];
147
+ return { chosenIdx, rejectedIdx };
148
+ }
149
+
150
+ /** ORPO loss via the shared prompt-prefix single forward (MiniCPM5, B=1).
151
+ * `promptIds` is the shared prefix; `chosenResp`/`rejectedResp` are the response
152
+ * continuations. Returns the scalar loss (caller owns). Differentiable through
153
+ * the LoRA primals like the two-forward orpoLoss. `chunk` (when fused/flash) routes
154
+ * each branch through the [M,V]-free flash-CCE head. */
155
+ export function orpoLossPrefixShared(
156
+ model: MiniCPM5Model,
157
+ promptIds: number[], chosenResp: number[], rejectedResp: number[],
158
+ lambda: number, chunk?: ChunkCtx,
159
+ ): MlxArray {
160
+ const P = promptIds.length, Rc = chosenResp.length, Rr = rejectedResp.length;
161
+ if (P < 1 || Rc < 1 || Rr < 1) throw new Error("orpoLossPrefixShared: need P,Rc,Rr >= 1");
162
+ const T = P + Rc + Rr;
163
+ const concat = new Int32Array(T);
164
+ concat.set(promptIds, 0);
165
+ concat.set(chosenResp, P);
166
+ concat.set(rejectedResp, P + Rc);
167
+ const ids = MlxArray.fromInt32(concat, [1, T]);
168
+ const caches: Cache[] = model.layers.map(() => new PrefixSharedCache(P, Rc, Rr));
169
+
170
+ let h: MlxArray;
171
+ setMiniCpmPrefixPlan({ P, Rc, Rr });
172
+ try {
173
+ h = model.forwardHidden(ids, caches); // [1, T, hidden], post-finalNorm
174
+ } finally {
175
+ setMiniCpmPrefixPlan(null);
176
+ ids.dispose();
177
+ for (const c of caches) c.dispose();
178
+ }
179
+
180
+ // chosen[k] predicted from H[P-1+k]; rejected[0] from H[P-1] (prompt's last,
181
+ // shared with chosen[0]); rejected[k>=1] from H[P+Rc+k-1].
182
+ const { chosenIdx, rejectedIdx } = prefixGatherIdx(P, Rc, Rr);
183
+
184
+ // On the fused/flash head, the gathered hResp (sliced from h) is the backward
185
+ // recompute primal — keep h alive in the sink until the head vjp is eval'd. The
186
+ // whole-vocab path builds a normal autograd graph, so h can free immediately.
187
+ const keepForBwd = !!chunk && (chunk.fused || chunk.flash);
188
+ if (keepForBwd) chunk!.sink.push(h);
189
+ const lw = branchLogpMeanGathered(model, h, chosenIdx, chosenResp, chunk);
190
+ const lr = branchLogpMeanGathered(model, h, rejectedIdx, rejectedResp, chunk);
191
+ if (!keepForBwd) h.dispose();
192
+ const loss = orpoLossFromLogps(lw, lr, lambda);
193
+ lw.dispose();
194
+ lr.dispose();
195
+ return loss;
196
+ }
197
+
198
+ /** Debug: the two branch mean-logps (ℓw, ℓr) from the prefix-shared forward, no
199
+ * grad. For parity diagnostics (compare against the two-forward branch logps). */
200
+ export function prefixSharedLogps(
201
+ model: MiniCPM5Model,
202
+ promptIds: number[], chosenResp: number[], rejectedResp: number[],
203
+ ): { lw: number; lr: number } {
204
+ const P = promptIds.length, Rc = chosenResp.length, Rr = rejectedResp.length;
205
+ const T = P + Rc + Rr;
206
+ const concat = new Int32Array(T);
207
+ concat.set(promptIds, 0); concat.set(chosenResp, P); concat.set(rejectedResp, P + Rc);
208
+ const ids = MlxArray.fromInt32(concat, [1, T]);
209
+ const caches: Cache[] = model.layers.map(() => new PrefixSharedCache(P, Rc, Rr));
210
+ setMiniCpmPrefixPlan({ P, Rc, Rr });
211
+ let h: MlxArray;
212
+ try { h = model.forwardHidden(ids, caches); }
213
+ finally { setMiniCpmPrefixPlan(null); ids.dispose(); for (const c of caches) c.dispose(); }
214
+ const chosenIdx = Array.from({ length: Rc }, (_, k) => P - 1 + k);
215
+ const rejectedIdx = [P - 1, ...Array.from({ length: Rr - 1 }, (_, k) => P + Rc + k)];
216
+ const lwA = gatheredLogpMean(model, h, chosenIdx, chosenResp);
217
+ const lrA = gatheredLogpMean(model, h, rejectedIdx, rejectedResp);
218
+ h.dispose();
219
+ const lw = lwA.toFloat32()[0]!, lr = lrA.toFloat32()[0]!;
220
+ lwA.dispose(); lrA.dispose();
221
+ return { lw, lr };
222
+ }
223
+
224
+ /** Split a DpoBatch row (B=1) into the shared prompt prefix + the two response
225
+ * continuations. The chosen/rejected sequences must share an identical prompt
226
+ * (the masked prefix where mask==0); `response` tokens are mask==1. Returns null
227
+ * if the prompts differ or either response is empty (caller falls back to the
228
+ * two-forward orpoLoss). */
229
+ export function splitPrefixBatch(batch: DpoBatch): {
230
+ promptIds: number[]; chosenResp: number[]; rejectedResp: number[];
231
+ } | null {
232
+ if (batch.chosenIds.length !== 1) return null;
233
+ const cIds = batch.chosenIds[0]!, cMask = batch.chosenMask[0]!;
234
+ const rIds = batch.rejectedIds[0]!, rMask = batch.rejectedMask[0]!;
235
+ // Prompt = leading mask==0 run (response = mask==1). Find first response token.
236
+ const cStart = cMask.indexOf(1);
237
+ const rStart = rMask.indexOf(1);
238
+ if (cStart < 1 || rStart < 1) return null; // need a non-empty prompt + response
239
+ const P = cStart;
240
+ if (rStart !== P) return null; // prompts must be the same length
241
+ for (let i = 0; i < P; i++) if (cIds[i] !== rIds[i]) return null; // ...and identical
242
+ const promptIds = cIds.slice(0, P);
243
+ const chosenResp = cIds.slice(P);
244
+ const rejectedResp = rIds.slice(P);
245
+ if (chosenResp.length < 1 || rejectedResp.length < 1) return null;
246
+ return { promptIds, chosenResp, rejectedResp };
247
+ }
248
+
249
+ /** Token-throughput accounting: tokens pushed through the layer stack by the
250
+ * two-forward path vs the prefix-shared single forward, and the saving ratio.
251
+ * (Each two-forward branch processes L-1 = P+R-1 input tokens; the shared path
252
+ * processes T = P+Rc+Rr.) */
253
+ export function prefixSavings(P: number, Rc: number, Rr: number): { twoForward: number; shared: number; ratio: number } {
254
+ const twoForward = (P + Rc - 1) + (P + Rr - 1);
255
+ const shared = P + Rc + Rr;
256
+ return { twoForward, shared, ratio: twoForward / shared };
257
+ }
258
+
259
+ // ===========================================================================
260
+ // Gemma e4b prefix-shared ORPO (lever 7 for the per-layer-input + KV-shared +
261
+ // sliding-window family). The CONSTRUCTION is the same as MiniCPM5 (one forward
262
+ // over [prompt; chosen; rejected] with block-wise RoPE + a block-sparse mask),
263
+ // reusing Gemma4Model.forwardHidden — which already drives per-layer inputs and
264
+ // the donor-KV sharing through forwardLayers, building one mask per layer-type
265
+ // from each donor cache's makeMask. Two e4b-specific wrinkles:
266
+ // (1) Block-wise RoPE rides in via setGemmaPrefixPlan (gemma4.ts Attention),
267
+ // so donor AND sharer layers rope identically to the two-forward path.
268
+ // (2) The SLIDING-window mask must be cut on LOGICAL positions, not physical:
269
+ // a rejected token at physical P+Rc+k has logical position P+k, so its
270
+ // window to the prompt tail differs from its physical distance by Rc. The
271
+ // branch VISIBILITY (who-may-attend-whom) is identical under the physical
272
+ // layout (physical causal + the rejected→chosen cut, exactly as MiniCPM5),
273
+ // so only the window DISTANCE needs logical positions — added as one extra
274
+ // AND on top of the MiniCPM5 mask. Full-attention layers (window=null) get
275
+ // the plain block-sparse causal mask (no sliding term).
276
+ // ===========================================================================
277
+
278
+ /** Block-sparse attention mask [T,T] (bool) for the gemma concat. Same as
279
+ * MiniCPM5's blockSparseMask (physical causal AND NOT rejected→chosen) plus, for
280
+ * sliding layers, a LOGICAL-position window AND: allow only where
281
+ * `logpos[row] - logpos[col] < window`, with `logpos[i] = i` for the
282
+ * prompt+chosen run and `i - Rc` for the rejected block (reset to P). Caller
283
+ * owns the result. */
284
+ export function blockSparsePrefixMaskGemma(P: number, Rc: number, Rr: number, window: number | null): MlxArray {
285
+ const T = P + Rc + Rr;
286
+ const causal = createCausalMask(T, 0, null); // [T,T] physical causal (window applied separately, on logical pos)
287
+ const idxFlat = ops.arange(0, T, 1, Dtype.int32);
288
+ const row = ops.reshape(idxFlat, [T, 1]);
289
+ const col = ops.reshape(idxFlat, [1, T]);
290
+ const pp = ops.fromInt32([P], []);
291
+ const pRc = ops.fromInt32([P + Rc], []);
292
+ // notForbid = NOT(rejRow AND chosenCol) = (i < P+Rc) OR (j < P) OR (j >= P+Rc)
293
+ const notRejRow = ops.less(row, pRc);
294
+ const colLtP = ops.less(col, pp);
295
+ const colGePRc = ops.greaterEqual(col, pRc);
296
+ const notChosenCol = ops.logicalOr(colLtP, colGePRc);
297
+ const notForbid = ops.logicalOr(notRejRow, notChosenCol);
298
+ let allow = ops.logicalAnd(causal, notForbid); // [T,T] bool
299
+
300
+ if (window !== null) {
301
+ // logical positions: prompt+chosen keep physical index; rejected resets to P.
302
+ const logposArr = new Int32Array(T);
303
+ for (let i = 0; i < T; i++) logposArr[i] = i < P + Rc ? i : i - Rc;
304
+ const logpos = MlxArray.fromInt32(logposArr, [T]);
305
+ const lrow = ops.reshape(logpos, [T, 1]);
306
+ const lcol = ops.reshape(logpos, [1, T]);
307
+ const dist = ops.sub(lrow, lcol); // logpos[row] - logpos[col] (>=0 wherever causal allows)
308
+ const w = ops.fromInt32([window], []);
309
+ const slidingOK = ops.less(dist, w); // attend only within `window` logical positions
310
+ const next = ops.logicalAnd(allow, slidingOK);
311
+ allow.dispose();
312
+ allow = next;
313
+ for (const a of [logpos, lrow, lcol, dist, w, slidingOK]) a.dispose();
314
+ }
315
+
316
+ for (const a of [causal, idxFlat, row, col, pp, pRc, notRejRow, colLtP, colGePRc, notChosenCol, notForbid]) a.dispose();
317
+ return allow;
318
+ }
319
+
320
+ /** Pass-through cache (offset 0) for the gemma prefix-shared forward whose
321
+ * makeMask returns the block-sparse + logical-window mask. forwardLayers builds
322
+ * one mask per layer-type, calling makeMask(L, window) with window = the model's
323
+ * sliding window for sliding layers and null for full layers — so a single cache
324
+ * type serves both (the window arg selects the sliding term). One per DONOR
325
+ * layer (sharers reuse donors' fetched KV). */
326
+ class Gemma4PrefixSharedCache implements Cache {
327
+ offset = 0;
328
+ constructor(private readonly P: number, private readonly Rc: number, private readonly Rr: number) {}
329
+ updateAndFetch(k: MlxArray, v: MlxArray): [MlxArray, MlxArray] {
330
+ return [k.slice([0, 0, 0, 0], k.shape), v.slice([0, 0, 0, 0], v.shape)];
331
+ }
332
+ makeMask(N: number, windowSize: number | null): Mask {
333
+ if (N !== this.P + this.Rc + this.Rr)
334
+ throw new Error(`Gemma4PrefixSharedCache: N=${N} != P+Rc+Rr=${this.P + this.Rc + this.Rr}`);
335
+ return { mode: "array", arr: blockSparsePrefixMaskGemma(this.P, this.Rc, this.Rr, windowSize) };
336
+ }
337
+ state(): MlxArray[] { return []; }
338
+ isTrimmable(): boolean { return true; }
339
+ trim(_n: number): void { /* offset pinned at 0 */ }
340
+ dispose(): void { /* owns no arrays */ }
341
+ }
342
+
343
+ /** ORPO loss via the shared prompt-prefix single forward on Gemma e4b (B=1).
344
+ * Mirrors `orpoLossPrefixShared` (MiniCPM5) but uses the gemma prefix plan
345
+ * (block-wise RoPE in Attention) and donor-count prefix caches, reusing
346
+ * `Gemma4Model.forwardHidden` for the per-layer-input + donor-KV machinery.
347
+ * Returns the scalar loss (caller owns); differentiable through the LoRA
348
+ * primals like the two-forward orpoLoss. */
349
+ export function orpoLossPrefixSharedGemma(
350
+ model: Gemma4Model,
351
+ promptIds: number[], chosenResp: number[], rejectedResp: number[],
352
+ lambda: number, chunk?: ChunkCtx,
353
+ ): MlxArray {
354
+ const P = promptIds.length, Rc = chosenResp.length, Rr = rejectedResp.length;
355
+ if (P < 1 || Rc < 1 || Rr < 1) throw new Error("orpoLossPrefixSharedGemma: need P,Rc,Rr >= 1");
356
+ const T = P + Rc + Rr;
357
+ const concat = new Int32Array(T);
358
+ concat.set(promptIds, 0);
359
+ concat.set(chosenResp, P);
360
+ concat.set(rejectedResp, P + Rc);
361
+ const ids = MlxArray.fromInt32(concat, [1, T]);
362
+ // One prefix cache per DONOR layer (sharers consume donors' fetched KV inside
363
+ // forwardLayers); makeCache() also returns numDonors entries.
364
+ const caches: Cache[] = Array.from({ length: model.numDonors }, () => new Gemma4PrefixSharedCache(P, Rc, Rr));
365
+
366
+ let h: MlxArray;
367
+ setGemmaPrefixPlan({ P, Rc, Rr });
368
+ try {
369
+ h = model.forwardHidden(ids, caches); // [1, T, hidden], post-finalNorm
370
+ } finally {
371
+ setGemmaPrefixPlan(null);
372
+ ids.dispose();
373
+ for (const c of caches) c.dispose();
374
+ }
375
+
376
+ // Same gather as MiniCPM5 (identical concat layout): chosen[k] from H[P-1+k];
377
+ // rejected[0] from H[P-1] (shared prompt-last), rejected[k>=1] from H[P+Rc+k-1].
378
+ const chosenIdx = Array.from({ length: Rc }, (_, k) => P - 1 + k);
379
+ const rejectedIdx = [P - 1, ...Array.from({ length: Rr - 1 }, (_, k) => P + Rc + k)];
380
+
381
+ // Same head-primal lifetime as orpoLossPrefixShared: keep h alive in the sink
382
+ // through the fused/flash backward recompute; free it now on the whole-vocab path.
383
+ const keepForBwd = !!chunk && (chunk.fused || chunk.flash);
384
+ if (keepForBwd) chunk!.sink.push(h);
385
+ const lw = branchLogpMeanGathered(model, h, chosenIdx, chosenResp, chunk);
386
+ const lr = branchLogpMeanGathered(model, h, rejectedIdx, rejectedResp, chunk);
387
+ if (!keepForBwd) h.dispose();
388
+ const loss = orpoLossFromLogps(lw, lr, lambda);
389
+ lw.dispose();
390
+ lr.dispose();
391
+ return loss;
392
+ }
393
+
394
+ /** Debug: the raw [1,T,hidden] post-finalNorm hidden of the gemma prefix-shared
395
+ * forward (caller owns/disposes). For localizing forward divergence per position. */
396
+ export function prefixForwardHiddenGemma(
397
+ model: Gemma4Model,
398
+ promptIds: number[], chosenResp: number[], rejectedResp: number[],
399
+ ): MlxArray {
400
+ const P = promptIds.length, Rc = chosenResp.length, Rr = rejectedResp.length;
401
+ const T = P + Rc + Rr;
402
+ const concat = new Int32Array(T);
403
+ concat.set(promptIds, 0); concat.set(chosenResp, P); concat.set(rejectedResp, P + Rc);
404
+ const ids = MlxArray.fromInt32(concat, [1, T]);
405
+ const caches: Cache[] = Array.from({ length: model.numDonors }, () => new Gemma4PrefixSharedCache(P, Rc, Rr));
406
+ setGemmaPrefixPlan({ P, Rc, Rr });
407
+ try { return model.forwardHidden(ids, caches); }
408
+ finally { setGemmaPrefixPlan(null); ids.dispose(); for (const c of caches) c.dispose(); }
409
+ }
410
+
411
+ /** Debug: the two branch mean-logps (ℓw, ℓr) from the gemma prefix-shared forward,
412
+ * no grad. For parity diagnostics (compare against the two-forward branch logps). */
413
+ export function prefixSharedLogpsGemma(
414
+ model: Gemma4Model,
415
+ promptIds: number[], chosenResp: number[], rejectedResp: number[],
416
+ ): { lw: number; lr: number } {
417
+ const P = promptIds.length, Rc = chosenResp.length, Rr = rejectedResp.length;
418
+ const T = P + Rc + Rr;
419
+ const concat = new Int32Array(T);
420
+ concat.set(promptIds, 0); concat.set(chosenResp, P); concat.set(rejectedResp, P + Rc);
421
+ const ids = MlxArray.fromInt32(concat, [1, T]);
422
+ const caches: Cache[] = Array.from({ length: model.numDonors }, () => new Gemma4PrefixSharedCache(P, Rc, Rr));
423
+ setGemmaPrefixPlan({ P, Rc, Rr });
424
+ let h: MlxArray;
425
+ try { h = model.forwardHidden(ids, caches); }
426
+ finally { setGemmaPrefixPlan(null); ids.dispose(); for (const c of caches) c.dispose(); }
427
+ const chosenIdx = Array.from({ length: Rc }, (_, k) => P - 1 + k);
428
+ const rejectedIdx = [P - 1, ...Array.from({ length: Rr - 1 }, (_, k) => P + Rc + k)];
429
+ const lwA = gatheredLogpMean(model, h, chosenIdx, chosenResp);
430
+ const lrA = gatheredLogpMean(model, h, rejectedIdx, rejectedResp);
431
+ h.dispose();
432
+ const lw = lwA.toFloat32()[0]!, lr = lrA.toFloat32()[0]!;
433
+ lwA.dispose(); lrA.dispose();
434
+ return { lw, lr };
435
+ }
package/src/train/rank.ts CHANGED
@@ -35,6 +35,42 @@ export interface ResolveRanksOptions {
35
35
  klMap?: Record<string, number>;
36
36
  }
37
37
 
38
+ /** Per-layer bits from the loaded model's quant specs — the authoritative,
39
+ * always-present source for `by_bits` scaling (each adapted linear carries its
40
+ * own `QuantSpec`, so this reflects the real mixed-precision assignment without
41
+ * needing the optiq_metadata.json sidecar). Keyed by the same module paths
42
+ * `resolveRanks` iterates, so it drops straight into `opts.bitsMap`. */
43
+ export function bitsMapFromModel(model: RuntimeModel): Record<string, number> {
44
+ const out: Record<string, number> = {};
45
+ for (const [path, linear] of model.loraTargets()) out[path] = linear.spec.bits;
46
+ return out;
47
+ }
48
+
49
+ /** Per-layer KL sensitivity from `optiq_metadata.json` (for `by_kl`). Mirrors
50
+ * optiq's `read_per_layer_kl`: accepts both the top-level `per_layer` and the
51
+ * older `optimization.per_layer` shapes, reads each entry's `kl`. Returns `{}`
52
+ * if the sidecar is absent or records no KL (our own quantizer writes `bits`
53
+ * but not `kl`), in which case `by_kl` falls back to `by_bits`. */
54
+ export async function readPerLayerKl(modelDir: string): Promise<Record<string, number>> {
55
+ if (!modelDir) return {};
56
+ const path = `${modelDir}/optiq_metadata.json`;
57
+ try {
58
+ if (!(await Bun.file(path).exists())) return {};
59
+ const meta = JSON.parse(await Bun.file(path).text()) as Record<string, unknown>;
60
+ const perLayer = (meta.per_layer ??
61
+ (meta.optimization as Record<string, unknown> | undefined)?.per_layer ??
62
+ {}) as Record<string, unknown>;
63
+ const out: Record<string, number> = {};
64
+ for (const [k, v] of Object.entries(perLayer)) {
65
+ if (v && typeof v === "object" && typeof (v as { kl?: unknown }).kl === "number")
66
+ out[k] = (v as { kl: number }).kl;
67
+ }
68
+ return out;
69
+ } catch {
70
+ return {};
71
+ }
72
+ }
73
+
38
74
  /** Resolve a rank per adapted module path. */
39
75
  export function resolveRanks(model: RuntimeModel, opts: ResolveRanksOptions): Map<string, number> {
40
76
  const targetModules = opts.targetModules ?? DEFAULT_TARGET_MODULES;