mlx-bun 0.0.8 → 0.0.9

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 (133) hide show
  1. package/README.md +112 -20
  2. package/docs/design/batching-v2-plan.md +515 -0
  3. package/docs/design/bucketing-stage.md +196 -0
  4. package/docs/design/compat-cli-surface-design.md +11 -11
  5. package/docs/design/docs-reorg-plan.md +1 -1
  6. package/docs/design/dreaming-nightly-pipeline.md +291 -0
  7. package/docs/design/dspark-speculative-decoding.md +106 -0
  8. package/docs/design/generic-model-support.md +381 -0
  9. package/docs/design/memory-inference-path.md +422 -0
  10. package/docs/design/memory-synthesis.md +287 -0
  11. package/docs/design/minicpm5-decode-megakernel.md +484 -0
  12. package/docs/design/mlx-lm-tool-parity-plan.md +535 -0
  13. package/docs/design/orpo-dynamic-lambda.md +41 -0
  14. package/docs/design/orpo-training.md +35 -5
  15. package/docs/design/parallel-slots.md +12 -1
  16. package/docs/design/parity-tier-dag.md +13 -5
  17. package/docs/design/spec-decode-larger-targets.md +229 -0
  18. package/docs/design/tauri-desktop-app.md +2 -2
  19. package/docs/design/the-dreaming-handoff.md +141 -0
  20. package/docs/design/the-dreaming-master-plan.md +446 -0
  21. package/docs/design/write-pipeline-entity.md +468 -0
  22. package/docs/investigations/curve-bisector-routing.md +1 -1
  23. package/docs/investigations/decode-roofline-lookagain.md +262 -0
  24. package/docs/investigations/dspark-handoff.md +58 -0
  25. package/docs/investigations/kernel-perf-review-2026-07.md +92 -0
  26. package/docs/planning/PRODUCT_ROADMAP.md +2 -2
  27. package/docs/planning/curve-sampler-research-plan.md +331 -0
  28. package/docs/planning/memory-docs-and-dag-plan.md +185 -0
  29. package/docs/planning/pi-integration-review.md +178 -0
  30. package/docs/planning/release-notes-v0.0.9.md +122 -0
  31. package/docs/planning/web-ui-pass-plan.md +213 -0
  32. package/docs/planning/website-readme-pass-plan.md +224 -0
  33. package/docs/reference/cli.md +255 -0
  34. package/docs/reference/embedding.md +7 -2
  35. package/docs/reference/library-api.md +22 -3
  36. package/docs/reference/memory.md +5 -5
  37. package/docs/reference/models.md +137 -0
  38. package/docs/reference/orpo-quickstart.md +6 -0
  39. package/docs/reference/server-api.md +187 -9
  40. package/docs/reference/server-config.md +40 -9
  41. package/docs/reference/training.md +25 -11
  42. package/package.json +1 -1
  43. package/src/anthropic.ts +22 -0
  44. package/src/assets/curve-designer.html +1 -1
  45. package/src/chat-template.ts +25 -7
  46. package/src/cli.ts +1029 -86
  47. package/src/config.ts +4 -1
  48. package/src/download.ts +99 -8
  49. package/src/embed.ts +77 -0
  50. package/src/eval/perplexity.ts +164 -0
  51. package/src/eval/runner.ts +15 -6
  52. package/src/eval/tasks/bfcl.ts +1 -1
  53. package/src/generate.ts +141 -18
  54. package/src/harness-pi.ts +43 -17
  55. package/src/index.ts +2 -0
  56. package/src/lora.ts +2 -2
  57. package/src/memory/article.ts +558 -0
  58. package/src/memory/chunk-validation.ts +150 -0
  59. package/src/memory/chunk.ts +305 -12
  60. package/src/memory/cluster.ts +344 -17
  61. package/src/memory/coverage.ts +134 -0
  62. package/src/memory/crosslink.ts +0 -0
  63. package/src/memory/db.ts +184 -45
  64. package/src/memory/entity.ts +203 -0
  65. package/src/memory/gate.ts +187 -0
  66. package/src/memory/history.ts +152 -0
  67. package/src/memory/ingest.ts +324 -11
  68. package/src/memory/model.ts +321 -0
  69. package/src/memory/normalize.ts +671 -0
  70. package/src/memory/parse.ts +22 -0
  71. package/src/memory/pipeline.ts +242 -30
  72. package/src/memory/prompts.ts +42 -0
  73. package/src/memory/query.ts +334 -0
  74. package/src/memory/reconcile.ts +734 -0
  75. package/src/memory/redirect.ts +108 -0
  76. package/src/memory/reindex.ts +325 -0
  77. package/src/memory/resolve.ts +307 -0
  78. package/src/memory/route.ts +272 -0
  79. package/src/memory/schedule.ts +6 -4
  80. package/src/memory/stages.ts +774 -0
  81. package/src/memory/synthesize.ts +1629 -20
  82. package/src/memory/tools.ts +446 -96
  83. package/src/memory/vault.ts +462 -35
  84. package/src/memory/wikify.ts +1057 -16
  85. package/src/mlx/ffi.ts +3 -0
  86. package/src/mlx/ops.ts +46 -0
  87. package/src/model/compiled-decode.ts +43 -8
  88. package/src/model/factory.ts +29 -8
  89. package/src/model/flash-attention.ts +4 -2
  90. package/src/model/fused-decode-kernel.ts +14 -7
  91. package/src/model/fused-mlp-kernel.ts +169 -0
  92. package/src/model/fused-swiglu-kernel.ts +240 -0
  93. package/src/model/gemma4-base.ts +62 -6
  94. package/src/model/gemma4.ts +33 -2
  95. package/src/model/minicpm5.ts +106 -8
  96. package/src/model/qwen3.ts +276 -0
  97. package/src/model/steel-linear-kernel.ts +96 -0
  98. package/src/model/support.ts +38 -8
  99. package/src/model/universal/archs.ts +332 -0
  100. package/src/model/universal/dense.ts +576 -0
  101. package/src/model/universal/modules.ts +163 -0
  102. package/src/model/universal/rope.ts +366 -0
  103. package/src/pi-provider.ts +1 -1
  104. package/src/pi-terminal.ts +29 -8
  105. package/src/pi-web.ts +19 -2
  106. package/src/quantize/config-writer.ts +1 -1
  107. package/src/registry.ts +215 -7
  108. package/src/responses.ts +23 -0
  109. package/src/sampler.ts +220 -9
  110. package/src/serve/batch-scheduler.ts +272 -80
  111. package/src/serve/generation-gateway.ts +86 -18
  112. package/src/server.ts +612 -49
  113. package/src/spec/drafter.ts +81 -49
  114. package/src/spec/dspark/data-dflash.ts +156 -0
  115. package/src/spec/dspark/data.ts +201 -0
  116. package/src/spec/dspark/generate-dflash.ts +193 -0
  117. package/src/spec/dspark/generate.ts +290 -0
  118. package/src/spec/dspark/loss.ts +213 -0
  119. package/src/spec/dspark/module-dflash.ts +361 -0
  120. package/src/spec/dspark/module.ts +540 -0
  121. package/src/spec/dspark/sample.ts +112 -0
  122. package/src/spec/generate.ts +40 -19
  123. package/src/status-page.html +1 -1
  124. package/src/tokenizer.ts +7 -0
  125. package/src/train/flash-cce.ts +70 -15
  126. package/src/train/fuse.ts +243 -0
  127. package/src/train/index.ts +3 -0
  128. package/src/train/job.ts +8 -0
  129. package/src/train/loss.ts +289 -30
  130. package/src/train/prefix-shared.ts +33 -5
  131. package/src/train/segmented.ts +67 -11
  132. package/src/train/trainer.ts +92 -28
  133. package/src/web/skills/memory/SKILL.md +40 -17
package/src/train/loss.ts CHANGED
@@ -447,17 +447,108 @@ export function dpoMetrics(
447
447
  //
448
448
  // Reference-FREE: one objective, no reference model (vs DPO's scale-0 ref pass).
449
449
  // L = L_NLL(chosen) + λ · L_OR
450
- // L_NLL = -mean_response_logp(chosen) (the SFT term; unweighted)
450
+ // L_NLL = the chosen-sequence SFT term (unweighted); scope selected by sft_scope:
451
+ // "full" (default in TrainConfig; paper/TRL-faithful): mean cross-entropy
452
+ // over ALL non-pad supervised positions of the chosen sequence —
453
+ // prompt AND response (TRL masks only padding to -100 for the
454
+ // chosen_nll_loss; nn.CrossEntropyLoss reduction="mean").
455
+ // "response" (the pre-2026-07 mlx-bun behavior, bit-exact): -ℓw, the negative
456
+ // mean response log-prob.
451
457
  // L_OR = -log σ(log_odds)
452
458
  // log_odds = (ℓw - ℓr) - (log1mexp(ℓw) - log1mexp(ℓr))
453
459
  // where ℓ is the LENGTH-NORMALIZED (mean over response tokens) log-prob — the
454
- // paper's choice (and TRL's), distinct from DPO's summed seqLogp. Two facts
455
- // shape the code: (1) ℓw feeds BOTH terms (L_NLL = -ℓw), so the chosen forward
456
- // does double duty; (2) only 2 forwards/step (chosen + rejected), both with
457
- // grad, no reference. This is the naïve full-logits oracle path — the chunked /
460
+ // paper's choice (and TRL's), distinct from DPO's summed seqLogp. CRITICAL:
461
+ // ℓw/ℓr stay RESPONSE-ONLY in BOTH sft_scope modes (TRL's get_batch_logps uses
462
+ // prompt-masked labels for the odds ratio; only the NLL term is full-sequence).
463
+ // Two facts shape the code: (1) ℓw feeds BOTH terms (under "full" the chosen
464
+ // forward additionally yields the prompt-position log-probs — same forward, the
465
+ // head just runs over the extended span); (2) only 2 forwards/step (chosen +
466
+ // rejected), both with grad, no reference. The REJECTED branch never needs
467
+ // prompt logps. This is the naïve full-logits oracle path — the chunked /
458
468
  // response-only / segmented tiers validate against it (see the design's oracle
459
469
  // ladder, docs/design/orpo-training.md).
460
470
 
471
+ /** SFT-term scope for ORPO's L_NLL(chosen): "full" = prompt+response token-mean
472
+ * CE (paper/TRL-faithful); "response" = -ℓw (the pre-change behavior). The
473
+ * odds-ratio ℓ terms are response-only in both modes. */
474
+ export type SftScope = "full" | "response";
475
+
476
+ /** A contiguous supervised input-position span [startT, startT+M): position t
477
+ * predicts token ids[t+1]. The response span comes from the mask; "full" mode
478
+ * additionally runs the head over the prompt span {startT: 0, M: respStartT}. */
479
+ export interface HeadSpan { startT: number; M: number }
480
+
481
+ /** The response span from a DPO-row mask (mask[t+1]==1 ⇔ input position t is
482
+ * supervised): first supervised position + count. M<=0 ⇔ no response. */
483
+ export function respSpanFromMask(mask: number[], T: number): HeadSpan {
484
+ let startT = -1;
485
+ let M = 0;
486
+ for (let t = 0; t < T; t++) if (mask[t + 1]) { if (startT < 0) startT = t; M++; }
487
+ return { startT, M };
488
+ }
489
+
490
+ /** Length-normalized mean log-prob over the contiguous input-position span
491
+ * [s0, s0+n) of `h [1, T, hidden]` (post-finalNorm), scoring targets
492
+ * ids[s0+1 .. s0+n]. The span-parameterized generalization of
493
+ * responseOnlyLogpMean (same ops); used for the prompt span under
494
+ * sft_scope:"full". Whole-vocab autograd head. Returns [1] (caller owns);
495
+ * disposes nothing it didn't create. */
496
+ export function spanLogpMeanFromHidden(
497
+ model: { logitsFromHidden(h: MlxArray): MlxArray },
498
+ h: MlxArray, // [1, T, hidden] post-finalNorm
499
+ ids: number[],
500
+ s0: number,
501
+ n: number,
502
+ ): MlxArray {
503
+ if (n <= 0) return MlxArray.fromFloat32(new Float32Array([0]), [1]);
504
+ const hidden = h.shape[2]!;
505
+ const start = MlxArray.fromInt32(new Int32Array([s0]), [1]);
506
+ const hSpan = ops.sliceDynamic(h, start, [1], [1, n, hidden]); // [1, n, hidden]
507
+ const logits = model.logitsFromHidden(hSpan); // [1, n, V]
508
+ const V = logits.shape[2]!;
509
+ const logits2d = ops.reshape(logits, [n, V]);
510
+
511
+ const targetsHost = new Int32Array(n);
512
+ for (let i = 0; i < n; i++) targetsHost[i] = ids[s0 + 1 + i]!;
513
+ const targets = MlxArray.fromInt32(targetsHost, [n, 1]);
514
+
515
+ const lse = ops.logsumexpAxis(logits2d, -1, false); // [n]
516
+ const gathered = ops.takeAlongAxis(logits2d, targets, -1); // [n, 1]
517
+ const picked = ops.reshape(gathered, [n]); // [n]
518
+ const logp = ops.sub(picked, lse); // [n] log_softmax at the label
519
+ const logpF = logp.dtype === Dtype.float32 ? logp : logp.astype(Dtype.float32);
520
+ const sumLogp = ops.sumAxis(logpF, 0, false); // scalar
521
+ const meanScalar = ops.mulScalar(sumLogp, 1 / n);
522
+ const mean = ops.reshape(meanScalar, [1]); // [1]
523
+
524
+ for (const a of [start, hSpan, logits, logits2d, targets, lse, gathered, picked, logp, sumLogp, meanScalar]) a.dispose();
525
+ if (logpF !== logp) logpF.dispose();
526
+ return mean;
527
+ }
528
+
529
+ /** Combine the prompt-span mean logp (`promptMean`, over nPrompt positions) and
530
+ * the response mean logp (`lw`, over nResp positions) into the FULL-scope chosen
531
+ * NLL: `-(nPrompt·promptMean + nResp·lw) / (nPrompt + nResp)` — the token-mean
532
+ * CE over all prompt+response supervised positions (nn.CrossEntropyLoss
533
+ * reduction="mean" over the label-shifted non-pad positions). Graph-preserving
534
+ * (pure ops on the two means), so the head gradient flows to BOTH spans. Does
535
+ * NOT dispose promptMean/lw (caller owns); returns [1] (caller owns). */
536
+ export function combineFullNll(
537
+ promptMean: MlxArray | null,
538
+ lw: MlxArray,
539
+ nPrompt: number,
540
+ nResp: number,
541
+ ): MlxArray {
542
+ if (!promptMean || nPrompt <= 0) return ops.neg(lw); // no prompt predictions → full == response
543
+ const ps = ops.mulScalar(promptMean, nPrompt); // Σ prompt logp (reconstructed)
544
+ const rs = ops.mulScalar(lw, nResp); // Σ response logp (reconstructed)
545
+ const sum = ops.add(ps, rs);
546
+ const mean = ops.mulScalar(sum, 1 / (nPrompt + nResp));
547
+ const out = ops.neg(mean);
548
+ for (const a of [ps, rs, sum, mean]) a.dispose();
549
+ return out;
550
+ }
551
+
461
552
  /** Per-row LENGTH-NORMALIZED response log-prob for one branch (B>=1): the
462
553
  * summed response logp divided by the per-row response-token count. Pad/prompt
463
554
  * positions contribute 0 (mask is 0 there) and never inflate the count.
@@ -654,12 +745,11 @@ export function chunkedLogpMeanFromHidden(
654
745
  mask: number[],
655
746
  chunkSize: number,
656
747
  sink: Array<{ dispose(): void }>,
748
+ span?: HeadSpan, // override the mask-derived response span (sft_scope:"full" prompt span)
657
749
  ): MlxArray {
658
750
  const L = ids.length;
659
751
  const T = L - 1;
660
- let startT = -1;
661
- let M = 0;
662
- for (let t = 0; t < T; t++) if (mask[t + 1]) { if (startT < 0) startT = t; M++; }
752
+ const { startT, M } = span ?? respSpanFromMask(mask, T);
663
753
  if (M <= 0) return MlxArray.fromFloat32(new Float32Array([0]), [1]);
664
754
  const hidden = h.shape[2]!;
665
755
 
@@ -779,12 +869,11 @@ export function fusedLogpMeanFromHidden(
779
869
  sink: Array<{ dispose(): void }>,
780
870
  vocabBlock = 0,
781
871
  flash = false,
872
+ span?: HeadSpan, // override the mask-derived response span (sft_scope:"full" prompt span)
782
873
  ): MlxArray {
783
874
  const L = ids.length;
784
875
  const T = L - 1;
785
- let startT = -1;
786
- let M = 0;
787
- for (let t = 0; t < T; t++) if (mask[t + 1]) { if (startT < 0) startT = t; M++; }
876
+ const { startT, M } = span ?? respSpanFromMask(mask, T);
788
877
  if (M <= 0) return MlxArray.fromFloat32(new Float32Array([0]), [1]);
789
878
  const hidden = h.shape[2]!;
790
879
  const startArr = MlxArray.fromInt32(new Int32Array([startT]), [1]);
@@ -1214,56 +1303,225 @@ function orpoLogOdds(lw: MlxArray, lr: MlxArray): MlxArray {
1214
1303
  return logOdds;
1215
1304
  }
1216
1305
 
1306
+ /** The chosen branch under sft_scope:"full" (B=1): ONE forward yields both the
1307
+ * response-only mean logp ℓw (fed to the odds ratio — computed exactly as the
1308
+ * "response" path computes it, same span/head) AND the full-scope chosen NLL
1309
+ * (the head additionally runs over the PROMPT span [0, startT), combined via
1310
+ * combineFullNll). No third forward: the hidden states already cover the
1311
+ * prompt. Routes both spans through the same head tier the response path uses
1312
+ * (flash/fused CustomVjp, Checkpoint token-chunks, or the whole-vocab autograd
1313
+ * head), so the memory bounds hold for the extended span too. Caller owns both
1314
+ * returned arrays ([1] each). */
1315
+ function chosenLogpMeanAndFullNllB1(
1316
+ model: RuntimeModel,
1317
+ ids: number[],
1318
+ mask: number[],
1319
+ validLen: number,
1320
+ chunk?: ChunkCtx,
1321
+ ): { lw: MlxArray; nllFull: MlxArray } {
1322
+ const L = ids.length;
1323
+ const T = L - 1;
1324
+ const { startT, M } = respSpanFromMask(mask, T);
1325
+ if (M <= 0)
1326
+ return {
1327
+ lw: MlxArray.fromFloat32(new Float32Array([0]), [1]),
1328
+ nllFull: MlxArray.fromFloat32(new Float32Array([0]), [1]),
1329
+ };
1330
+
1331
+ const inputHost = new Int32Array(T);
1332
+ for (let t = 0; t < T; t++) inputHost[t] = ids[t]!;
1333
+ const inputIds = MlxArray.fromInt32(inputHost, [1, T]);
1334
+ const inputValid = [Math.max(0, Math.min(validLen, L) - 1)];
1335
+ const h = trainForwardHidden(model, inputIds, inputValid); // [1, T, hidden]
1336
+ inputIds.dispose();
1337
+
1338
+ const useFused = !!chunk && chunk.chunkSize > 0 && !!(chunk.fused || chunk.flash);
1339
+ const useChunked = !!chunk && chunk.chunkSize > 0 && !useFused;
1340
+ let lw: MlxArray;
1341
+ let pm: MlxArray | null = null; // prompt-span mean logp (positions [0, startT))
1342
+ if (useFused) {
1343
+ chunk!.sink.push(h); // CustomVjp/flash primal source — alive until post-eval
1344
+ lw = fusedLogpMeanFromHidden(model, h, ids, mask, chunk!.chunkSize, chunk!.sink, 0, chunk!.flash ?? false, { startT, M });
1345
+ if (startT > 0)
1346
+ pm = fusedLogpMeanFromHidden(model, h, ids, mask, chunk!.chunkSize, chunk!.sink, 0, chunk!.flash ?? false, { startT: 0, M: startT });
1347
+ } else if (useChunked) {
1348
+ chunk!.sink.push(h); // Checkpoint input — alive until post-eval
1349
+ lw = chunkedLogpMeanFromHidden(model, h, ids, mask, chunk!.chunkSize, chunk!.sink, { startT, M });
1350
+ if (startT > 0)
1351
+ pm = chunkedLogpMeanFromHidden(model, h, ids, mask, chunk!.chunkSize, chunk!.sink, { startT: 0, M: startT });
1352
+ } else {
1353
+ lw = spanLogpMeanFromHidden(model, h, ids, startT, M);
1354
+ if (startT > 0) pm = spanLogpMeanFromHidden(model, h, ids, 0, startT);
1355
+ h.dispose(); // whole-vocab path builds a normal autograd graph — the loss retains it
1356
+ }
1357
+ const nllFull = combineFullNll(pm, lw, startT, M);
1358
+ pm?.dispose();
1359
+ return { lw, nllFull };
1360
+ }
1361
+
1362
+ /** The chosen branch under sft_scope:"full" for B>1 (the batched full-logits
1363
+ * path): one forward, one label-logp pass, then TWO masked reductions —
1364
+ * per-row response means for ℓw [B] (identical positions/count to the
1365
+ * "response" path) and the flattened token-mean full NLL over all non-pad
1366
+ * supervised positions across the batch (nn.CrossEntropyLoss
1367
+ * reduction="mean"). Caller owns both returned arrays. */
1368
+ function chosenLogpMeanAndFullNllBatch(
1369
+ model: RuntimeModel,
1370
+ ids: number[][],
1371
+ mask: number[][],
1372
+ validLen: number[],
1373
+ ): { lw: MlxArray; nllFull: MlxArray } {
1374
+ const B = ids.length;
1375
+ const L = ids[0]!.length;
1376
+ const T = L - 1;
1377
+ const inputHost = new Int32Array(B * T);
1378
+ for (let b = 0; b < B; b++)
1379
+ for (let t = 0; t < T; t++) inputHost[b * T + t] = ids[b]![t]!;
1380
+ const inputIds = MlxArray.fromInt32(inputHost, [B, T]);
1381
+ const inputValid = validLen.map((v) => Math.max(0, Math.min(v, L) - 1));
1382
+ const logits = trainForward(model, inputIds, inputValid); // [B, T, V]
1383
+ inputIds.dispose();
1384
+ const V = logits.shape[2]!;
1385
+ const N = B * T;
1386
+
1387
+ const targetsHost = new Int32Array(N);
1388
+ const respMaskHost = new Float32Array(N);
1389
+ const fullMaskHost = new Float32Array(N);
1390
+ const respRecip = new Float32Array(B);
1391
+ let fullCount = 0;
1392
+ for (let b = 0; b < B; b++) {
1393
+ const len = Math.min(validLen[b]!, L);
1394
+ let c = 0;
1395
+ for (let t = 0; t < T; t++) {
1396
+ const flat = b * T + t;
1397
+ targetsHost[flat] = ids[b]![t + 1]!;
1398
+ respMaskHost[flat] = mask[b]![t + 1]!; // resp_mask[:, 1:]
1399
+ c += mask[b]![t + 1]!;
1400
+ // full scope: every non-pad supervised position (prompt + response),
1401
+ // i.e. the predicted token ids[t+1] is a real token (t+1 < len).
1402
+ if (t + 1 < len) { fullMaskHost[flat] = 1; fullCount++; }
1403
+ }
1404
+ respRecip[b] = 1 / Math.max(c, 1);
1405
+ }
1406
+
1407
+ const logits2d = ops.reshape(logits, [N, V]);
1408
+ logits.dispose();
1409
+ const targets = MlxArray.fromInt32(targetsHost, [N, 1]);
1410
+ const lse = ops.logsumexpAxis(logits2d, -1, false); // [N]
1411
+ const gathered = ops.takeAlongAxis(logits2d, targets, -1); // [N, 1]
1412
+ const picked = ops.reshape(gathered, [N]); // [N]
1413
+ const logp = ops.sub(picked, lse); // [N] log_softmax at the label
1414
+ const logpF = logp.dtype === Dtype.float32 ? logp : logp.astype(Dtype.float32);
1415
+
1416
+ // ℓw: per-row response length-normalized mean (same as branchLogpMean B>1).
1417
+ const rm = MlxArray.fromFloat32(respMaskHost, [N]);
1418
+ const respMasked = ops.mul(logpF, rm); // [N]
1419
+ const resp2d = ops.reshape(respMasked, [B, T]);
1420
+ const respSum = ops.sumAxis(resp2d, 1, false); // [B]
1421
+ const recipArr = MlxArray.fromFloat32(respRecip, [B]);
1422
+ const lw = ops.mul(respSum, recipArr); // [B]
1423
+
1424
+ // Full NLL: token-mean over ALL non-pad supervised positions, batch-flattened.
1425
+ const fm = MlxArray.fromFloat32(fullMaskHost, [N]);
1426
+ const fullMasked = ops.mul(logpF, fm); // [N]
1427
+ const fullSum = ops.sumAxis(fullMasked, 0, false); // scalar Σ logp
1428
+ const fullMean = ops.mulScalar(fullSum, 1 / Math.max(fullCount, 1));
1429
+ const nllFull = ops.neg(fullMean); // scalar
1430
+
1431
+ for (const a of [logits2d, targets, lse, gathered, picked, logp, rm, respMasked, resp2d, respSum, recipArr, fm, fullMasked, fullSum, fullMean]) a.dispose();
1432
+ if (logpF !== logp) logpF.dispose();
1433
+ return { lw, nllFull };
1434
+ }
1435
+
1217
1436
  /** The pure ORPO reduction from the two length-normalized log-probs ([B] each):
1218
- * `L_NLL + λ·L_OR` with `L_NLL = mean(-ℓw)` (unweighted SFT term) and
1219
- * `L_OR = mean(softplus(-log_odds))`. Model-free and graph-preserving — the
1220
- * forwards live in `orpoLoss`, the math (and its grad path) lives here, so the
1221
- * loss can be unit-tested against a hand reference with synthetic lw/lr. Does
1222
- * NOT dispose lw/lr (the caller owns them); returns the scalar loss. */
1223
- export function orpoLossFromLogps(lw: MlxArray, lr: MlxArray, lambda: number): MlxArray {
1224
- const negLw = ops.neg(lw);
1225
- const nll = ops.meanAll(negLw, false); // scalar L_NLL = mean(-ℓw)
1437
+ * `L_NLL + λ·L_OR` with `L_OR = mean(softplus(-log_odds))`. The SFT term:
1438
+ * when `nllFull` is ABSENT (sft_scope:"response"), `L_NLL = mean(-ℓw)` —
1439
+ * bit-exact to the pre-sft_scope behavior; when PRESENT (sft_scope:"full"),
1440
+ * `L_NLL = meanAll(nllFull)`, the separately-computed full-scope chosen NLL
1441
+ * ([1]/[B]/scalar all reduce correctly). The odds-ratio term uses the
1442
+ * response-only ℓw/ℓr in BOTH modes (matching TRL). Model-free and
1443
+ * graph-preserving — the forwards live in `orpoLoss`, the math (and its grad
1444
+ * path) lives here, so the loss can be unit-tested against a hand reference
1445
+ * with synthetic lw/lr. Does NOT dispose lw/lr/nllFull (the caller owns them);
1446
+ * returns the scalar loss. */
1447
+ export function orpoLossFromLogps(lw: MlxArray, lr: MlxArray, lambda: number, nllFull?: MlxArray): MlxArray {
1448
+ const negLw = nllFull ? null : ops.neg(lw);
1449
+ const nll = ops.meanAll(nllFull ?? negLw!, false); // scalar L_NLL
1226
1450
  const logOdds = orpoLogOdds(lw, lr); // [B]
1227
1451
  const negLO = ops.neg(logOdds);
1228
1452
  const orPer = ops.softplus(negLO); // [B] -log σ(log_odds)
1229
1453
  const orLoss = ops.meanAll(orPer, false); // scalar L_OR
1230
1454
  const lamOr = ops.mulScalar(orLoss, lambda);
1231
1455
  const loss = ops.add(nll, lamOr);
1232
- for (const a of [negLw, nll, logOdds, negLO, orPer, orLoss, lamOr]) a.dispose();
1456
+ for (const a of [nll, logOdds, negLO, orPer, orLoss, lamOr]) a.dispose();
1457
+ negLw?.dispose();
1233
1458
  return loss;
1234
1459
  }
1235
1460
 
1236
1461
  /** ORPO loss for one preference batch (B>=1). Runs the chosen + rejected policy
1237
1462
  * forwards (adapter active), reduces via {@link orpoLossFromLogps}. No
1238
- * reference forward. Returns a scalar MlxArray (caller owns). */
1239
- export function orpoLoss(model: RuntimeModel, batch: DpoBatch, lambda: number, chunk?: ChunkCtx): MlxArray {
1463
+ * reference forward. `sftScope` selects the L_NLL scope: "response" (default —
1464
+ * bit-exact to the pre-sft_scope code path) or "full" (paper/TRL-faithful:
1465
+ * the chosen NLL covers prompt+response, computed from the SAME chosen forward
1466
+ * — no third forward; the rejected branch is untouched). Returns a scalar
1467
+ * MlxArray (caller owns). */
1468
+ export function orpoLoss(
1469
+ model: RuntimeModel,
1470
+ batch: DpoBatch,
1471
+ lambda: number,
1472
+ chunk?: ChunkCtx,
1473
+ sftScope: SftScope = "response",
1474
+ ): MlxArray {
1240
1475
  const cLen = chosenLengths(batch);
1241
1476
  const rLen = rejectedLengths(batch);
1242
- const lw = branchLogpMean(model, batch.chosenIds, batch.chosenMask, cLen, chunk); // [B]
1477
+ let lw: MlxArray;
1478
+ let nllFull: MlxArray | null = null;
1479
+ if (sftScope === "full") {
1480
+ const r = batch.chosenIds.length === 1
1481
+ ? chosenLogpMeanAndFullNllB1(model, batch.chosenIds[0]!, batch.chosenMask[0]!, cLen[0]!, chunk)
1482
+ : chosenLogpMeanAndFullNllBatch(model, batch.chosenIds, batch.chosenMask, cLen);
1483
+ lw = r.lw;
1484
+ nllFull = r.nllFull;
1485
+ } else {
1486
+ lw = branchLogpMean(model, batch.chosenIds, batch.chosenMask, cLen, chunk); // [B]
1487
+ }
1243
1488
  const lr = branchLogpMean(model, batch.rejectedIds, batch.rejectedMask, rLen, chunk); // [B]
1244
- const loss = orpoLossFromLogps(lw, lr, lambda);
1245
- // Safe to drop the lw/lr wrappers now — the loss graph retains the underlying
1246
- // nodes (same pattern as dpoLoss disposing piC/piR before returning).
1489
+ const loss = orpoLossFromLogps(lw, lr, lambda, nllFull ?? undefined);
1490
+ // Safe to drop the lw/lr/nllFull wrappers now — the loss graph retains the
1491
+ // underlying nodes (same pattern as dpoLoss disposing piC/piR before returning).
1247
1492
  lw.dispose();
1248
1493
  lr.dispose();
1494
+ nllFull?.dispose();
1249
1495
  return loss;
1250
1496
  }
1251
1497
 
1252
1498
  /** ORPO diagnostics (no grad): the total loss and its `nll`/`or` split,
1253
1499
  * preference accuracy (mean of ℓw > ℓr), and reward margin (mean log_odds).
1254
- * Runs its own two forwards. */
1500
+ * Runs its own two forwards. `sftScope` selects the reported nll's scope so
1501
+ * the diagnostic loss matches the training objective ("response" default). */
1255
1502
  export function orpoMetrics(
1256
1503
  model: RuntimeModel,
1257
1504
  batch: DpoBatch,
1258
1505
  lambda: number,
1506
+ sftScope: SftScope = "response",
1259
1507
  ): { loss: number; nll: number; or: number; accuracy: number; margin: number } {
1260
1508
  const cLen = chosenLengths(batch);
1261
1509
  const rLen = rejectedLengths(batch);
1262
- const lw = branchLogpMean(model, batch.chosenIds, batch.chosenMask, cLen); // [B]
1510
+ let lw: MlxArray;
1511
+ let nllFull: MlxArray | null = null;
1512
+ if (sftScope === "full") {
1513
+ const r = batch.chosenIds.length === 1
1514
+ ? chosenLogpMeanAndFullNllB1(model, batch.chosenIds[0]!, batch.chosenMask[0]!, cLen[0]!)
1515
+ : chosenLogpMeanAndFullNllBatch(model, batch.chosenIds, batch.chosenMask, cLen);
1516
+ lw = r.lw;
1517
+ nllFull = r.nllFull;
1518
+ } else {
1519
+ lw = branchLogpMean(model, batch.chosenIds, batch.chosenMask, cLen); // [B]
1520
+ }
1263
1521
  const lr = branchLogpMean(model, batch.rejectedIds, batch.rejectedMask, rLen); // [B]
1264
1522
 
1265
- const negLw = ops.neg(lw);
1266
- const nllArr = ops.meanAll(negLw, false);
1523
+ const negLw = ops.neg(lw); // (diagnostic -ℓw; the nll split uses nllFull under "full")
1524
+ const nllArr = nllFull ? ops.meanAll(nllFull, false) : ops.meanAll(negLw, false);
1267
1525
  const logOdds = orpoLogOdds(lw, lr); // [B]
1268
1526
  const negLO = ops.neg(logOdds);
1269
1527
  const orPer = ops.softplus(negLO);
@@ -1285,5 +1543,6 @@ export function orpoMetrics(
1285
1543
  margin /= loVals.length;
1286
1544
 
1287
1545
  for (const a of [lw, lr, negLw, nllArr, logOdds, negLO, orPer, orArr]) a.dispose();
1546
+ nllFull?.dispose();
1288
1547
  return { loss, nll, or, accuracy: acc, margin };
1289
1548
  }
@@ -28,7 +28,7 @@ import * as ops from "../mlx/ops";
28
28
  import { createCausalMask, type Cache, type Mask } from "../model/gemma4-base";
29
29
  import { MiniCPM5Model, setMiniCpmPrefixPlan } from "../model/minicpm5";
30
30
  import { Gemma4Model, setGemmaPrefixPlan } from "../model/gemma4";
31
- import { orpoLossFromLogps, fusedRespLogpMean, type ChunkCtx } from "./loss";
31
+ import { orpoLossFromLogps, fusedRespLogpMean, combineFullNll, type ChunkCtx, type SftScope } from "./loss";
32
32
  import type { DpoBatch } from "./dataset";
33
33
 
34
34
  /** A model that can project final-norm hidden states to vocab logits — the only
@@ -147,6 +147,26 @@ export function prefixGatherIdx(P: number, Rc: number, Rr: number): { chosenIdx:
147
147
  return { chosenIdx, rejectedIdx };
148
148
  }
149
149
 
150
+ /** sft_scope:"full" over the prefix-shared concat: the full-scope chosen NLL
151
+ * gathers the PROMPT predictions too — hidden position t (t = 0..P-2) predicts
152
+ * prompt token t+1 — and combines them with the response mean ℓw:
153
+ * NLL_full = -((P-1)·promptMean + Rc·ℓw) / (P-1+Rc). The prompt head routes
154
+ * through the same branchLogpMeanGathered tier as the responses (whole-vocab /
155
+ * fused / flash), sharing the one concat forward's hiddens. Returns [1]
156
+ * (caller owns); lw is NOT disposed. P=1 (no prompt predictions) → -ℓw. */
157
+ export function prefixFullNll(
158
+ model: LogitProjector, h: MlxArray, promptIds: number[], lw: MlxArray, Rc: number, chunk?: ChunkCtx,
159
+ ): MlxArray {
160
+ const P = promptIds.length;
161
+ if (P <= 1) return combineFullNll(null, lw, 0, Rc);
162
+ const promptIdx = Array.from({ length: P - 1 }, (_, k) => k); // H[k] predicts promptIds[k+1]
163
+ const promptTargets = promptIds.slice(1);
164
+ const pm = branchLogpMeanGathered(model, h, promptIdx, promptTargets, chunk); // [1]
165
+ const nllFull = combineFullNll(pm, lw, P - 1, Rc);
166
+ pm.dispose();
167
+ return nllFull;
168
+ }
169
+
150
170
  /** ORPO loss via the shared prompt-prefix single forward (MiniCPM5, B=1).
151
171
  * `promptIds` is the shared prefix; `chosenResp`/`rejectedResp` are the response
152
172
  * continuations. Returns the scalar loss (caller owns). Differentiable through
@@ -155,7 +175,7 @@ export function prefixGatherIdx(P: number, Rc: number, Rr: number): { chosenIdx:
155
175
  export function orpoLossPrefixShared(
156
176
  model: MiniCPM5Model,
157
177
  promptIds: number[], chosenResp: number[], rejectedResp: number[],
158
- lambda: number, chunk?: ChunkCtx,
178
+ lambda: number, chunk?: ChunkCtx, sftScope: SftScope = "response",
159
179
  ): MlxArray {
160
180
  const P = promptIds.length, Rc = chosenResp.length, Rr = rejectedResp.length;
161
181
  if (P < 1 || Rc < 1 || Rr < 1) throw new Error("orpoLossPrefixShared: need P,Rc,Rr >= 1");
@@ -188,10 +208,14 @@ export function orpoLossPrefixShared(
188
208
  if (keepForBwd) chunk!.sink.push(h);
189
209
  const lw = branchLogpMeanGathered(model, h, chosenIdx, chosenResp, chunk);
190
210
  const lr = branchLogpMeanGathered(model, h, rejectedIdx, rejectedResp, chunk);
211
+ // sft_scope:"full": add the prompt predictions (H[0..P-2] → promptIds[1..P-1])
212
+ // from the SAME concat forward; ℓw/ℓr stay response-only for the odds ratio.
213
+ const nllFull = sftScope === "full" ? prefixFullNll(model, h, promptIds, lw, Rc, chunk) : null;
191
214
  if (!keepForBwd) h.dispose();
192
- const loss = orpoLossFromLogps(lw, lr, lambda);
215
+ const loss = orpoLossFromLogps(lw, lr, lambda, nllFull ?? undefined);
193
216
  lw.dispose();
194
217
  lr.dispose();
218
+ nllFull?.dispose();
195
219
  return loss;
196
220
  }
197
221
 
@@ -349,7 +373,7 @@ class Gemma4PrefixSharedCache implements Cache {
349
373
  export function orpoLossPrefixSharedGemma(
350
374
  model: Gemma4Model,
351
375
  promptIds: number[], chosenResp: number[], rejectedResp: number[],
352
- lambda: number, chunk?: ChunkCtx,
376
+ lambda: number, chunk?: ChunkCtx, sftScope: SftScope = "response",
353
377
  ): MlxArray {
354
378
  const P = promptIds.length, Rc = chosenResp.length, Rr = rejectedResp.length;
355
379
  if (P < 1 || Rc < 1 || Rr < 1) throw new Error("orpoLossPrefixSharedGemma: need P,Rc,Rr >= 1");
@@ -384,10 +408,14 @@ export function orpoLossPrefixSharedGemma(
384
408
  if (keepForBwd) chunk!.sink.push(h);
385
409
  const lw = branchLogpMeanGathered(model, h, chosenIdx, chosenResp, chunk);
386
410
  const lr = branchLogpMeanGathered(model, h, rejectedIdx, rejectedResp, chunk);
411
+ // sft_scope:"full": prompt predictions from the same concat forward (see
412
+ // orpoLossPrefixShared); ℓw/ℓr stay response-only for the odds ratio.
413
+ const nllFull = sftScope === "full" ? prefixFullNll(model, h, promptIds, lw, Rc, chunk) : null;
387
414
  if (!keepForBwd) h.dispose();
388
- const loss = orpoLossFromLogps(lw, lr, lambda);
415
+ const loss = orpoLossFromLogps(lw, lr, lambda, nllFull ?? undefined);
389
416
  lw.dispose();
390
417
  lr.dispose();
418
+ nllFull?.dispose();
391
419
  return loss;
392
420
  }
393
421