mlx-bun 0.0.7 → 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 (138) 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/diffusion-gemma-port.md +602 -0
  6. package/docs/design/docs-reorg-plan.md +1 -1
  7. package/docs/design/dreaming-nightly-pipeline.md +291 -0
  8. package/docs/design/dspark-speculative-decoding.md +106 -0
  9. package/docs/design/generic-model-support.md +381 -0
  10. package/docs/design/memory-inference-path.md +422 -0
  11. package/docs/design/memory-synthesis.md +287 -0
  12. package/docs/design/minicpm5-decode-megakernel.md +484 -0
  13. package/docs/design/mlx-lm-tool-parity-plan.md +535 -0
  14. package/docs/design/orpo-dynamic-lambda.md +41 -0
  15. package/docs/design/orpo-training.md +35 -5
  16. package/docs/design/parallel-slots.md +12 -1
  17. package/docs/design/parity-tier-dag.md +13 -5
  18. package/docs/design/spec-decode-larger-targets.md +229 -0
  19. package/docs/design/tauri-desktop-app.md +2 -2
  20. package/docs/design/the-dreaming-handoff.md +141 -0
  21. package/docs/design/the-dreaming-master-plan.md +446 -0
  22. package/docs/design/write-pipeline-entity.md +468 -0
  23. package/docs/investigations/curve-bisector-routing.md +1 -1
  24. package/docs/investigations/decode-roofline-lookagain.md +262 -0
  25. package/docs/investigations/dspark-handoff.md +58 -0
  26. package/docs/investigations/kernel-perf-review-2026-07.md +92 -0
  27. package/docs/planning/PRODUCT_ROADMAP.md +2 -2
  28. package/docs/planning/curve-sampler-research-plan.md +331 -0
  29. package/docs/planning/memory-docs-and-dag-plan.md +185 -0
  30. package/docs/planning/pi-integration-review.md +178 -0
  31. package/docs/planning/release-notes-v0.0.9.md +122 -0
  32. package/docs/planning/web-ui-pass-plan.md +213 -0
  33. package/docs/planning/website-readme-pass-plan.md +224 -0
  34. package/docs/reference/cli.md +255 -0
  35. package/docs/reference/embedding.md +7 -2
  36. package/docs/reference/library-api.md +22 -3
  37. package/docs/reference/memory.md +5 -5
  38. package/docs/reference/models.md +137 -0
  39. package/docs/reference/orpo-quickstart.md +6 -0
  40. package/docs/reference/server-api.md +187 -9
  41. package/docs/reference/server-config.md +40 -9
  42. package/docs/reference/training.md +25 -11
  43. package/package.json +1 -1
  44. package/src/anthropic.ts +22 -0
  45. package/src/assets/curve-designer.html +1 -1
  46. package/src/chat-template.ts +25 -7
  47. package/src/cli.ts +1029 -86
  48. package/src/config.ts +54 -3
  49. package/src/diffusion/diffusion-generate.ts +436 -0
  50. package/src/download.ts +99 -8
  51. package/src/embed.ts +77 -0
  52. package/src/eval/perplexity.ts +164 -0
  53. package/src/eval/runner.ts +19 -7
  54. package/src/eval/tasks/bfcl.ts +1 -1
  55. package/src/generate.ts +196 -19
  56. package/src/harness-pi.ts +43 -17
  57. package/src/index.ts +2 -0
  58. package/src/lora.ts +2 -2
  59. package/src/memory/article.ts +558 -0
  60. package/src/memory/chunk-validation.ts +150 -0
  61. package/src/memory/chunk.ts +305 -12
  62. package/src/memory/cluster.ts +344 -17
  63. package/src/memory/coverage.ts +134 -0
  64. package/src/memory/crosslink.ts +0 -0
  65. package/src/memory/db.ts +184 -45
  66. package/src/memory/entity.ts +203 -0
  67. package/src/memory/gate.ts +187 -0
  68. package/src/memory/history.ts +152 -0
  69. package/src/memory/ingest.ts +324 -11
  70. package/src/memory/model.ts +321 -0
  71. package/src/memory/normalize.ts +671 -0
  72. package/src/memory/parse.ts +22 -0
  73. package/src/memory/pipeline.ts +242 -30
  74. package/src/memory/prompts.ts +42 -0
  75. package/src/memory/query.ts +334 -0
  76. package/src/memory/reconcile.ts +734 -0
  77. package/src/memory/redirect.ts +108 -0
  78. package/src/memory/reindex.ts +325 -0
  79. package/src/memory/resolve.ts +307 -0
  80. package/src/memory/route.ts +272 -0
  81. package/src/memory/schedule.ts +6 -4
  82. package/src/memory/stages.ts +774 -0
  83. package/src/memory/synthesize.ts +1629 -20
  84. package/src/memory/tools.ts +446 -96
  85. package/src/memory/vault.ts +462 -35
  86. package/src/memory/wikify.ts +1057 -16
  87. package/src/mlx/ffi.ts +11 -0
  88. package/src/mlx/ops.ts +113 -0
  89. package/src/model/compiled-decode.ts +43 -8
  90. package/src/model/diffusion-gemma.ts +1026 -0
  91. package/src/model/factory.ts +35 -8
  92. package/src/model/flash-attention.ts +4 -2
  93. package/src/model/fused-decode-kernel.ts +14 -7
  94. package/src/model/fused-mlp-kernel.ts +169 -0
  95. package/src/model/fused-swiglu-kernel.ts +240 -0
  96. package/src/model/gemma4-base.ts +62 -6
  97. package/src/model/gemma4.ts +33 -2
  98. package/src/model/minicpm5.ts +106 -8
  99. package/src/model/qwen3.ts +276 -0
  100. package/src/model/steel-linear-kernel.ts +96 -0
  101. package/src/model/support.ts +45 -7
  102. package/src/model/universal/archs.ts +332 -0
  103. package/src/model/universal/dense.ts +576 -0
  104. package/src/model/universal/modules.ts +163 -0
  105. package/src/model/universal/rope.ts +366 -0
  106. package/src/pi-provider.ts +1 -1
  107. package/src/pi-terminal.ts +29 -8
  108. package/src/pi-web.ts +19 -2
  109. package/src/quantize/config-writer.ts +1 -1
  110. package/src/registry.ts +215 -7
  111. package/src/responses.ts +23 -0
  112. package/src/sampler.ts +220 -9
  113. package/src/serve/batch-scheduler.ts +272 -80
  114. package/src/serve/generation-gateway.ts +90 -18
  115. package/src/server.ts +643 -50
  116. package/src/spec/drafter.ts +81 -49
  117. package/src/spec/dspark/data-dflash.ts +156 -0
  118. package/src/spec/dspark/data.ts +201 -0
  119. package/src/spec/dspark/generate-dflash.ts +193 -0
  120. package/src/spec/dspark/generate.ts +290 -0
  121. package/src/spec/dspark/loss.ts +213 -0
  122. package/src/spec/dspark/module-dflash.ts +361 -0
  123. package/src/spec/dspark/module.ts +540 -0
  124. package/src/spec/dspark/sample.ts +112 -0
  125. package/src/spec/generate.ts +40 -19
  126. package/src/status-page.html +1 -1
  127. package/src/tokenizer.ts +7 -0
  128. package/src/train/diffusion-lora.ts +216 -0
  129. package/src/train/flash-cce.ts +70 -15
  130. package/src/train/fuse.ts +243 -0
  131. package/src/train/index.ts +3 -0
  132. package/src/train/job.ts +8 -0
  133. package/src/train/loss.ts +289 -30
  134. package/src/train/prefix-shared.ts +33 -5
  135. package/src/train/segmented.ts +67 -11
  136. package/src/train/trainer.ts +92 -28
  137. package/src/vision/diffusion-vision.ts +620 -0
  138. package/src/web/skills/memory/SKILL.md +40 -17
@@ -42,8 +42,12 @@ import type { Gemma4Model } from "../model/gemma4";
42
42
  import { setGemmaPrefixPlan } from "../model/gemma4";
43
43
  import type { Cache, Mask, SharedKv } from "../model/gemma4-base";
44
44
  import { TrainingCache } from "./forward";
45
- import { responseOnlyCe, responseOnlyLogpMean, fusedLogpMeanFromHidden, chunkedLogpMeanFromHidden, orpoLossFromLogps, type ChunkCtx } from "./loss";
46
- import { PrefixSharedCache, prefixGatherIdx, branchLogpMeanGathered, blockSparsePrefixMaskGemma } from "./prefix-shared";
45
+ import {
46
+ responseOnlyCe, responseOnlyLogpMean, fusedLogpMeanFromHidden, chunkedLogpMeanFromHidden,
47
+ orpoLossFromLogps, spanLogpMeanFromHidden, respSpanFromMask, combineFullNll,
48
+ type ChunkCtx, type SftScope, type HeadSpan,
49
+ } from "./loss";
50
+ import { PrefixSharedCache, prefixGatherIdx, branchLogpMeanGathered, blockSparsePrefixMaskGemma, prefixFullNll } from "./prefix-shared";
47
51
 
48
52
  // Experiment toggle: which bounded head to use inside the segmented mlx_vjp.
49
53
  // "checkpoint" = per-chunk Checkpoint recompute (bounds memory under nesting);
@@ -54,11 +58,11 @@ import { PrefixSharedCache, prefixGatherIdx, branchLogpMeanGathered, blockSparse
54
58
  const SEG_HEAD = process.env.MLX_BUN_SEG_HEAD ?? "checkpoint";
55
59
  function boundedHeadFromHidden(
56
60
  model: MiniCPM5Model | Gemma4Model, h: MlxArray, ids: number[], mask: number[],
57
- chunkSize: number, sink: Array<{ dispose(): void }>,
61
+ chunkSize: number, sink: Array<{ dispose(): void }>, span?: HeadSpan,
58
62
  ): MlxArray {
59
63
  return SEG_HEAD === "fused"
60
- ? fusedLogpMeanFromHidden(model, h, ids, mask, chunkSize, sink)
61
- : chunkedLogpMeanFromHidden(model, h, ids, mask, chunkSize, sink);
64
+ ? fusedLogpMeanFromHidden(model, h, ids, mask, chunkSize, sink, 0, false, span)
65
+ : chunkedLogpMeanFromHidden(model, h, ids, mask, chunkSize, sink, span);
62
66
  }
63
67
  import type { TrainableLora } from "./lora-params";
64
68
  import { type SftBatch, type DpoBatch } from "./dataset";
@@ -582,6 +586,7 @@ export class SegmentedBackwardOrpo {
582
586
  private readonly ranges: [number, number][],
583
587
  private readonly lambda: number,
584
588
  private readonly fusedChunkSize = 0,
589
+ private readonly sftScope: SftScope = "response",
585
590
  ) {
586
591
  this.nLayers = model.layers.length;
587
592
  const lastHi = ranges[ranges.length - 1]?.[1] ?? 0;
@@ -609,9 +614,26 @@ export class SegmentedBackwardOrpo {
609
614
  const lr = cs > 0 // [1]
610
615
  ? boundedHeadFromHidden(model, hnR, this.curRejectedIds!, this.curRejectedMask!, cs, this.headSink)
611
616
  : responseOnlyLogpMean(model, hnR, this.curRejectedIds!, this.curRejectedMask!);
617
+ // sft_scope:"full": the chosen head additionally runs over the PROMPT span
618
+ // [0, startT) of the SAME hnC (same tier: bounded or whole-vocab), and the
619
+ // full-scope chosen NLL replaces mean(-ℓw). The prompt-position gradient
620
+ // rides into this vjp's dh_chosen automatically (the loss graph includes
621
+ // the prompt-span head). ℓw/ℓr stay response-only for the odds ratio.
622
+ let nllFull: MlxArray | null = null;
623
+ if (this.sftScope === "full") {
624
+ const { startT, M } = respSpanFromMask(this.curChosenMask!, this.curChosenIds!.length - 1);
625
+ let pm: MlxArray | null = null;
626
+ if (M > 0 && startT > 0)
627
+ pm = cs > 0
628
+ ? boundedHeadFromHidden(model, hnC, this.curChosenIds!, this.curChosenMask!, cs, this.headSink, { startT: 0, M: startT })
629
+ : spanLogpMeanFromHidden(model, hnC, this.curChosenIds!, 0, startT);
630
+ nllFull = combineFullNll(pm, lw, M > 0 ? startT : 0, M);
631
+ pm?.dispose();
632
+ }
612
633
  hnC.dispose(); hnR.dispose();
613
- const loss = orpoLossFromLogps(lw, lr, this.lambda);
634
+ const loss = orpoLossFromLogps(lw, lr, this.lambda, nllFull ?? undefined);
614
635
  lw.dispose(); lr.dispose();
636
+ nllFull?.dispose();
615
637
  return [loss];
616
638
  }, 1);
617
639
 
@@ -801,6 +823,7 @@ export class SegmentedBackwardOrpoPrefix {
801
823
  private curP = 0;
802
824
  private curRc = 0;
803
825
  private curRr = 0;
826
+ private curPromptIds: number[] | null = null;
804
827
  private curChosenResp: number[] | null = null;
805
828
  private curRejectedResp: number[] | null = null;
806
829
  private curChunk: ChunkCtx | undefined = undefined;
@@ -816,6 +839,7 @@ export class SegmentedBackwardOrpoPrefix {
816
839
  private readonly ranges: [number, number][],
817
840
  private readonly lambda: number,
818
841
  private readonly chunkCtx?: ChunkCtx,
842
+ private readonly sftScope: SftScope = "response",
819
843
  ) {
820
844
  this.nLayers = model.layers.length;
821
845
  const lastHi = ranges[ranges.length - 1]?.[1] ?? 0;
@@ -838,9 +862,16 @@ export class SegmentedBackwardOrpoPrefix {
838
862
  const { chosenIdx, rejectedIdx } = prefixGatherIdx(this.curP, this.curRc, this.curRr);
839
863
  const lw = branchLogpMeanGathered(model, hn, chosenIdx, this.curChosenResp!, this.curChunk);
840
864
  const lr = branchLogpMeanGathered(model, hn, rejectedIdx, this.curRejectedResp!, this.curChunk);
865
+ // sft_scope:"full": prompt predictions (H[0..P-2] → prompt[1..P-1]) from the
866
+ // same concat hiddens; the gather's takeAxis vjp scatter-adds the prompt
867
+ // cotangent into dh alongside the responses'. ℓw/ℓr stay response-only.
868
+ const nllFull = this.sftScope === "full"
869
+ ? prefixFullNll(model, hn, this.curPromptIds!, lw, this.curRc, this.curChunk)
870
+ : null;
841
871
  hn.dispose();
842
- const loss = orpoLossFromLogps(lw, lr, this.lambda);
872
+ const loss = orpoLossFromLogps(lw, lr, this.lambda, nllFull ?? undefined);
843
873
  lw.dispose(); lr.dispose();
874
+ nllFull?.dispose();
844
875
  return [loss];
845
876
  }, 1);
846
877
 
@@ -873,6 +904,7 @@ export class SegmentedBackwardOrpoPrefix {
873
904
  if (P < 1 || Rc < 1 || Rr < 1) throw new Error("SegmentedBackwardOrpoPrefix: need P,Rc,Rr >= 1");
874
905
  const T = P + Rc + Rr;
875
906
  this.curP = P; this.curRc = Rc; this.curRr = Rr;
907
+ this.curPromptIds = promptIds;
876
908
  this.curChosenResp = chosenResp; this.curRejectedResp = rejectedResp;
877
909
  // Per-step chunk ctx pointing the head's CustomVjp/slice disposables at this
878
910
  // step's headSink (the flash/fused head recompute runs during headVjp.apply;
@@ -942,7 +974,7 @@ export class SegmentedBackwardOrpoPrefix {
942
974
  inputIds.dispose();
943
975
  for (const c of this.curCaches) c.dispose();
944
976
  this.curCaches = [];
945
- this.curChosenResp = this.curRejectedResp = null;
977
+ this.curPromptIds = this.curChosenResp = this.curRejectedResp = null;
946
978
  for (const d of this.headSink) d.dispose();
947
979
  this.headSink = [];
948
980
  dhOut?.dispose();
@@ -1024,6 +1056,7 @@ export class SegmentedBackwardOrpoGemma4 {
1024
1056
  private readonly ranges: [number, number][],
1025
1057
  private readonly lambda: number,
1026
1058
  private readonly fusedChunkSize = 0,
1059
+ private readonly sftScope: SftScope = "response",
1027
1060
  ) {
1028
1061
  this.nLayers = model.layers.length;
1029
1062
  const lastHi = ranges[ranges.length - 1]?.[1] ?? 0;
@@ -1072,9 +1105,23 @@ export class SegmentedBackwardOrpoGemma4 {
1072
1105
  const lr = cs > 0
1073
1106
  ? boundedHeadFromHidden(this.model, hnR, this.curRejectedIds!, this.curRejectedMask!, cs, this.headSink)
1074
1107
  : responseOnlyLogpMean(this.model, hnR, this.curRejectedIds!, this.curRejectedMask!);
1108
+ // sft_scope:"full": prompt-span head over the same hnC (see
1109
+ // SegmentedBackwardOrpo); ℓw/ℓr stay response-only for the odds ratio.
1110
+ let nllFull: MlxArray | null = null;
1111
+ if (this.sftScope === "full") {
1112
+ const { startT, M } = respSpanFromMask(this.curChosenMask!, this.curChosenIds!.length - 1);
1113
+ let pm: MlxArray | null = null;
1114
+ if (M > 0 && startT > 0)
1115
+ pm = cs > 0
1116
+ ? boundedHeadFromHidden(this.model, hnC, this.curChosenIds!, this.curChosenMask!, cs, this.headSink, { startT: 0, M: startT })
1117
+ : spanLogpMeanFromHidden(this.model, hnC, this.curChosenIds!, 0, startT);
1118
+ nllFull = combineFullNll(pm, lw, M > 0 ? startT : 0, M);
1119
+ pm?.dispose();
1120
+ }
1075
1121
  hnC.dispose(); hnR.dispose();
1076
- const loss = orpoLossFromLogps(lw, lr, this.lambda);
1122
+ const loss = orpoLossFromLogps(lw, lr, this.lambda, nllFull ?? undefined);
1077
1123
  lw.dispose(); lr.dispose();
1124
+ nllFull?.dispose();
1078
1125
  return [loss];
1079
1126
  }, 1);
1080
1127
 
@@ -1394,6 +1441,7 @@ export class SegmentedBackwardOrpoPrefixGemma4 {
1394
1441
  private curP = 0;
1395
1442
  private curRc = 0;
1396
1443
  private curRr = 0;
1444
+ private curPromptIds: number[] | null = null;
1397
1445
  private curChosenResp: number[] | null = null;
1398
1446
  private curRejectedResp: number[] | null = null;
1399
1447
  private curChunk: ChunkCtx | undefined = undefined;
@@ -1408,6 +1456,7 @@ export class SegmentedBackwardOrpoPrefixGemma4 {
1408
1456
  private readonly ranges: [number, number][],
1409
1457
  private readonly lambda: number,
1410
1458
  private readonly chunkCtx?: ChunkCtx,
1459
+ private readonly sftScope: SftScope = "response",
1411
1460
  ) {
1412
1461
  this.nLayers = model.layers.length;
1413
1462
  const lastHi = ranges[ranges.length - 1]?.[1] ?? 0;
@@ -1455,9 +1504,15 @@ export class SegmentedBackwardOrpoPrefixGemma4 {
1455
1504
  const { chosenIdx, rejectedIdx } = prefixGatherIdx(this.curP, this.curRc, this.curRr);
1456
1505
  const lw = branchLogpMeanGathered(model, hn, chosenIdx, this.curChosenResp!, this.curChunk);
1457
1506
  const lr = branchLogpMeanGathered(model, hn, rejectedIdx, this.curRejectedResp!, this.curChunk);
1507
+ // sft_scope:"full": prompt predictions from the same concat hiddens (see
1508
+ // SegmentedBackwardOrpoPrefix); ℓw/ℓr stay response-only.
1509
+ const nllFull = this.sftScope === "full"
1510
+ ? prefixFullNll(model, hn, this.curPromptIds!, lw, this.curRc, this.curChunk)
1511
+ : null;
1458
1512
  hn.dispose();
1459
- const loss = orpoLossFromLogps(lw, lr, this.lambda);
1513
+ const loss = orpoLossFromLogps(lw, lr, this.lambda, nllFull ?? undefined);
1460
1514
  lw.dispose(); lr.dispose();
1515
+ nllFull?.dispose();
1461
1516
  return [loss];
1462
1517
  }, 1);
1463
1518
 
@@ -1525,6 +1580,7 @@ export class SegmentedBackwardOrpoPrefixGemma4 {
1525
1580
  if (P < 1 || Rc < 1 || Rr < 1) throw new Error("SegmentedBackwardOrpoPrefixGemma4: need P,Rc,Rr >= 1");
1526
1581
  const T = P + Rc + Rr;
1527
1582
  this.curP = P; this.curRc = Rc; this.curRr = Rr;
1583
+ this.curPromptIds = promptIds;
1528
1584
  this.curChosenResp = chosenResp; this.curRejectedResp = rejectedResp;
1529
1585
  this.headSink = [];
1530
1586
  this.curChunk = this.chunkCtx ? { ...this.chunkCtx, sink: this.headSink } : undefined;
@@ -1640,7 +1696,7 @@ export class SegmentedBackwardOrpoPrefixGemma4 {
1640
1696
  this.curPerLayer?.dispose();
1641
1697
  this.curPerLayer = null;
1642
1698
  if (this.curMasks) { for (const m of this.curMasks.values()) m.arr?.dispose(); this.curMasks = null; }
1643
- this.curChosenResp = this.curRejectedResp = null;
1699
+ this.curPromptIds = this.curChosenResp = this.curRejectedResp = null;
1644
1700
  for (const d of this.headSink) d.dispose();
1645
1701
  this.headSink = [];
1646
1702
  dhOut?.dispose();
@@ -34,7 +34,7 @@ import {
34
34
  saveAdapter, disposeLora, warmStartFromAdapter, type TrainableLora, type SaveAdapterConfig,
35
35
  } from "./lora-params";
36
36
  import { resolveRanks, bitsMapFromModel, readPerLayerKl, DEFAULT_TARGET_MODULES, type RankScaling } from "./rank";
37
- import { sftLoss, dpoLoss, dpoRefLogps, dpoMetrics, orpoLoss, orpoMetrics, type ChunkCtx } from "./loss";
37
+ import { sftLoss, dpoLoss, dpoRefLogps, dpoMetrics, orpoLoss, orpoMetrics, type ChunkCtx, type SftScope } from "./loss";
38
38
  import { orpoLossPrefixShared, orpoLossPrefixSharedGemma, splitPrefixBatch } from "./prefix-shared";
39
39
  import { AdamW, warmupCosineSchedule } from "./optimizer";
40
40
  import { writeFileSync, appendFileSync, mkdirSync } from "node:fs";
@@ -134,6 +134,15 @@ export interface TrainConfig {
134
134
  * differ. Composes with orpoFlashCe/orpoFusedCe (each branch routes through the
135
135
  * [M,V]-free head). See src/train/prefix-shared.ts. */
136
136
  orpoPrefixShared: boolean;
137
+ /** ORPO L_SFT scope (`sft_scope`). "full" (default; paper/TRL-faithful):
138
+ * the chosen-NLL term is the token-mean cross-entropy over the FULL
139
+ * prompt+response (only padding excluded), computed from the same chosen
140
+ * forward — matching TRL's ORPOTrainer chosen_nll_loss. "response":
141
+ * L_NLL = -ℓw (response-only), bit-exact to pre-sft_scope runs. The
142
+ * odds-ratio ℓw/ℓr terms are response-only length-normalized means in BOTH
143
+ * modes (that also matches TRL). Applies to every ORPO path (naive /
144
+ * chunked / fused / flash / prefix-shared / segmented). orpo only. */
145
+ sftScope: SftScope;
137
146
  /** Warm-start: path to an existing adapter dir (with adapters.safetensors) whose
138
147
  * LoRA weights initialize this run instead of the random/zero init. Continues
139
148
  * training from a checkpoint's weights; the optimizer state and LR schedule restart.
@@ -188,6 +197,7 @@ export const DEFAULT_TRAIN_CONFIG: TrainConfig = {
188
197
  orpoFusedCe: false, // default off; true = fused linear-CE head (analytic backward, no [M,vocab])
189
198
  orpoFlashCe: false, // default off; true = flash-CCE Metal-kernel head (implies fused)
190
199
  orpoPrefixShared: false, // default off; true = shared prompt-prefix single forward (B=1)
200
+ sftScope: "full", // paper/TRL-faithful chosen-NLL over prompt+response; "response" = pre-2026-07 behavior
191
201
  warmStartAdapter: "", // default off; path to an adapter dir to warm-start LoRA weights from
192
202
  adapterPath: "adapters",
193
203
  baseModel: "",
@@ -298,6 +308,7 @@ export async function trainLora(
298
308
  iters: cfg.iters, learning_rate: cfg.learningRate, max_seq_length: cfg.maxSeqLen,
299
309
  batch_size: cfg.batchSize, rank: cfg.rank, scale: cfg.scale,
300
310
  orpo_lambda: cfg.method === "orpo" ? cfg.orpoLambda : undefined,
311
+ sft_scope: cfg.method === "orpo" ? cfg.sftScope : undefined,
301
312
  adapter_path: cfg.adapterPath,
302
313
  });
303
314
 
@@ -349,6 +360,7 @@ export async function trainLora(
349
360
  segmentSize: cfg.segmentSize, iters: cfg.iters,
350
361
  numLayers: cfg.numLayers, baseModel: cfg.baseModel,
351
362
  orpoLambda: cfg.method === "orpo" ? cfg.orpoLambda : undefined,
363
+ sftScope: cfg.method === "orpo" ? cfg.sftScope : undefined,
352
364
  },
353
365
  wallSeconds: (Date.now() - startedAt) / 1000,
354
366
  peakGb: peakMemory() / 1e9,
@@ -466,14 +478,40 @@ async function sftLoop(
466
478
  // dQ-causal-barrier bugs were fixed; FD-validated in
467
479
  // scripts/experiments/flash-fd-check.ts), though ~30× slower than ops.sdpa.
468
480
  // Cleared in finally.
469
- if (process.env.MLX_BUN_TRAIN_ATTN === "flash") setTrainingAttn("flash");
481
+ //
482
+ // GEMMA GUARD (same convention as the PERF_KERNEL/FUSED_GELU sanitization in
483
+ // cli.ts / job.ts, but enforced at the trainer): e4b on this path SIGTRAPed
484
+ // (uncatchable native crash) at multi-K sequence lengths (>=2K, reproduced in
485
+ // scripts/experiments/segmented-grad-test-e4b.ts; docs/reference/training.md)
486
+ // and has NOT been re-validated at that scale since the two kernel fixes —
487
+ // the regression tests stop at T<=256. cli.ts even defaults e4b seq to 8192,
488
+ // so a stale `export MLX_BUN_TRAIN_ATTN=flash` from a MiniCPM5 experiment
489
+ // would otherwise ride silently into a crash mid-run. Refuse it for Gemma
490
+ // until the >=2K re-validation lands; MiniCPM5 stays allowed.
491
+ if (process.env.MLX_BUN_TRAIN_ATTN === "flash") {
492
+ if (model instanceof Gemma4Model)
493
+ throw new Error(
494
+ "MLX_BUN_TRAIN_ATTN=flash is disabled for Gemma models: e4b SIGTRAPs on this " +
495
+ "path at seq >= 2048 (docs/reference/training.md) and it has not been " +
496
+ "re-validated at that scale since the kernel fixes. Unset MLX_BUN_TRAIN_ATTN " +
497
+ "(ops.sdpa, the default, is exact and ~30x faster) or train MiniCPM5.",
498
+ );
499
+ emit({ type: "stage", stage: "setup", progress: 0.045,
500
+ message: "training attention: hand-rolled flash kernel (MLX_BUN_TRAIN_ATTN=flash; O(L) memory, ~30x slower than ops.sdpa)" });
501
+ setTrainingAttn("flash");
502
+ }
470
503
 
471
504
  // Pad with the eos id when available (a real sentinel token); padded
472
505
  // positions are excluded from both the loss and attention masks, so the
473
506
  // exact pad value never affects the result — it just must be a valid id.
474
507
  const padId = tok.eosTokenId ?? 0;
475
508
  const batches = iterateSftBatches(train, cfg.batchSize, cfg.maxSeqLen, cfg.seed, true, padId);
476
- const t0 = Date.now();
509
+ // tokens/sec follows mlx-lm's tuner convention: accumulate the response-token
510
+ // count across the REPORT WINDOW and divide by the window's own wall time
511
+ // (validation time excluded) — not one step's count extrapolated over the
512
+ // whole run, which jumps ~10x step-to-step on variable-length data.
513
+ let windowTokens = 0;
514
+ let windowStart = Date.now();
477
515
 
478
516
  try {
479
517
  for (let step = 1; step <= cfg.iters; step++) {
@@ -483,12 +521,11 @@ async function sftLoop(
483
521
  // when 1). afterMicroEval frees each micro-batch's gradient-checkpoint
484
522
  // closures right after its backward so they don't pile up across
485
523
  // accumulation.
486
- let tokensThisStep = 0;
487
524
  const { loss: lossVal, grads } = accumulateStep(
488
525
  cfg.gradAccumSteps,
489
526
  () => {
490
527
  currentBatch = batches.next().value as SftBatch;
491
- tokensThisStep += countResponseTokens(currentBatch);
528
+ windowTokens += countResponseTokens(currentBatch);
492
529
  return segmented ? segmented.step(currentBatch!) : vag!.apply(flatParams(lora));
493
530
  },
494
531
  ckptCtx
@@ -512,7 +549,7 @@ async function sftLoop(
512
549
  }
513
550
 
514
551
  if (step % cfg.stepsPerReport === 0 || step === 1) {
515
- const elapsed = (Date.now() - t0) / 1000;
552
+ const elapsed = (Date.now() - windowStart) / 1000;
516
553
  emit({
517
554
  type: "metric",
518
555
  kind: "train",
@@ -520,19 +557,31 @@ async function sftLoop(
520
557
  loss: lossVal,
521
558
  grad_norm: gradNorm,
522
559
  learning_rate: opt.lr,
523
- tokens_per_sec: elapsed > 0 ? (step * tokensThisStep) / elapsed : 0,
560
+ tokens_per_sec: elapsed > 0 ? windowTokens / elapsed : 0,
524
561
  progress: step / cfg.iters,
525
562
  message: `step ${step}/${cfg.iters} loss=${lossVal.toFixed(4)}`,
526
563
  });
564
+ windowTokens = 0;
565
+ windowStart = Date.now();
527
566
  }
528
567
 
529
568
  if (valid.length > 0 && step % cfg.stepsPerEval === 0) {
530
569
  // Eval is forward-only (no backward), so disable checkpointing for it —
531
570
  // it adds no benefit and a forward-only pass fits without it.
532
571
  if (ckptCtx) (model as Gemma4Model).gradCkpt = null;
533
- const vLoss = evalSftLoss(model, valid, cfg);
572
+ const tVal = Date.now();
573
+ const { loss: vLoss, used, skipped } = evalSftLoss(model, valid, cfg);
534
574
  if (ckptCtx) (model as Gemma4Model).gradCkpt = ckptCtx;
535
- emit({ type: "metric", kind: "val", step, loss: vLoss, progress: step / cfg.iters });
575
+ // A silently-shrinking val set must be visible: rows whose supervised
576
+ // span is empty after truncation are skipped BY DESIGN (mlx-lm drops
577
+ // them at dataset build time) — report how many, and over what n the
578
+ // mean was taken. Any other per-row failure now rethrows (see
579
+ // evalSftLoss).
580
+ emit({ type: "metric", kind: "val", step, loss: vLoss, progress: step / cfg.iters,
581
+ val_rows_used: used, val_rows_skipped: skipped,
582
+ ...(skipped > 0 ? { message: `val loss over ${used} rows (${skipped} skipped: no response tokens to supervise)` } : {}) });
583
+ // validation wall time is not training throughput — keep it out of the window
584
+ windowStart += Date.now() - tVal;
536
585
  }
537
586
  }
538
587
  } finally {
@@ -548,13 +597,16 @@ async function sftLoop(
548
597
  return { numIters: cfg.iters };
549
598
  }
550
599
 
551
- function evalSftLoss(model: RuntimeModel, valid: ReturnType<typeof Array.prototype.slice>, cfg: TrainConfig): number {
600
+ function evalSftLoss(
601
+ model: RuntimeModel, valid: ReturnType<typeof Array.prototype.slice>, cfg: TrainConfig,
602
+ ): { loss: number; used: number; skipped: number } {
552
603
  let total = 0;
553
604
  let n = 0;
605
+ let skipped = 0;
554
606
  for (const ex of valid as { ids: number[]; promptLen: number }[]) {
555
607
  let ids = ex.ids;
556
608
  let promptLen = ex.promptLen;
557
- if (ids.length < 2) continue;
609
+ if (ids.length < 2) { skipped++; continue; }
558
610
  if (ids.length > cfg.maxSeqLen) {
559
611
  ids = ids.slice(0, cfg.maxSeqLen);
560
612
  promptLen = Math.min(promptLen, Math.max(0, ids.length - 1));
@@ -565,12 +617,18 @@ function evalSftLoss(model: RuntimeModel, valid: ReturnType<typeof Array.prototy
565
617
  total += loss.toFloat32()[0]!;
566
618
  loss.dispose();
567
619
  n++;
568
- } catch {
569
- // skip examples with no response tokens
620
+ } catch (e) {
621
+ // ONLY the empty-supervision row is skippable (a truncated row whose
622
+ // response fell entirely past maxSeqLen — mlx-lm drops these at dataset
623
+ // build). Everything else (MLX OOM/shape/vjp errors surface as catchable
624
+ // JS Errors via the ffi handler) must rethrow: swallowing them silently
625
+ // reduces the val set to its easy rows and reports a plausible loss.
626
+ if (e instanceof Error && e.message === "sftLoss: no response tokens to supervise") skipped++;
627
+ else throw e;
570
628
  }
571
629
  clearCache();
572
630
  }
573
- return n > 0 ? total / n : 0;
631
+ return { loss: n > 0 ? total / n : 0, used: n, skipped };
574
632
  }
575
633
 
576
634
  // ---------------------------------------------------------------------------
@@ -780,18 +838,18 @@ async function orpoLoop(
780
838
  // plain segmented-ORPO fallback for rows whose chosen/rejected prompts differ
781
839
  // (splitPrefixBatch returns null -> two-forward segmented step).
782
840
  if (model instanceof MiniCPM5Model) {
783
- segmentedOrpoPrefix = new SegmentedBackwardOrpoPrefix(model, lora, ranges, cfg.orpoLambda, segPrefixChunk);
784
- segmentedOrpo = new SegmentedBackwardOrpo(model, lora, ranges, cfg.orpoLambda, segFusedChunk);
841
+ segmentedOrpoPrefix = new SegmentedBackwardOrpoPrefix(model, lora, ranges, cfg.orpoLambda, segPrefixChunk, cfg.sftScope);
842
+ segmentedOrpo = new SegmentedBackwardOrpo(model, lora, ranges, cfg.orpoLambda, segFusedChunk, cfg.sftScope);
785
843
  } else {
786
- segmentedOrpoPrefix = new SegmentedBackwardOrpoPrefixGemma4(model as Gemma4Model, lora, ranges, cfg.orpoLambda, segPrefixChunk);
787
- segmentedOrpo = new SegmentedBackwardOrpoGemma4(model as Gemma4Model, lora, ranges, cfg.orpoLambda, segFusedChunk);
844
+ segmentedOrpoPrefix = new SegmentedBackwardOrpoPrefixGemma4(model as Gemma4Model, lora, ranges, cfg.orpoLambda, segPrefixChunk, cfg.sftScope);
845
+ segmentedOrpo = new SegmentedBackwardOrpoGemma4(model as Gemma4Model, lora, ranges, cfg.orpoLambda, segFusedChunk, cfg.sftScope);
788
846
  }
789
847
  emit({ type: "stage", stage: "setup", progress: 0.04,
790
848
  message: `orpo segmented + prefix-share (${model instanceof Gemma4Model ? "e4b" : "MiniCPM5"}): single concat forward streamed over ${Math.ceil(model.layers.length / cfg.segmentSize)} segments of <=${cfg.segmentSize} layers (two-forward fallback on prompt mismatch)` });
791
849
  } else if (model instanceof MiniCPM5Model)
792
- segmentedOrpo = new SegmentedBackwardOrpo(model, lora, ranges, cfg.orpoLambda, segFusedChunk);
850
+ segmentedOrpo = new SegmentedBackwardOrpo(model, lora, ranges, cfg.orpoLambda, segFusedChunk, cfg.sftScope);
793
851
  else if (model instanceof Gemma4Model)
794
- segmentedOrpo = new SegmentedBackwardOrpoGemma4(model, lora, ranges, cfg.orpoLambda, segFusedChunk);
852
+ segmentedOrpo = new SegmentedBackwardOrpoGemma4(model, lora, ranges, cfg.orpoLambda, segFusedChunk, cfg.sftScope);
795
853
  else
796
854
  throw new Error("segmented backward (segmentSize > 0) for ORPO is only wired for MiniCPM5 and Gemma4");
797
855
  if (!useSegmentedPrefix) emit({ type: "stage", stage: "setup", progress: 0.04,
@@ -837,12 +895,12 @@ async function orpoLoop(
837
895
  const split = splitPrefixBatch(currentBatch!);
838
896
  if (split) {
839
897
  if (model instanceof MiniCPM5Model)
840
- return orpoLossPrefixShared(model, split.promptIds, split.chosenResp, split.rejectedResp, cfg.orpoLambda, chunk);
841
- return orpoLossPrefixSharedGemma(model as Gemma4Model, split.promptIds, split.chosenResp, split.rejectedResp, cfg.orpoLambda, chunk);
898
+ return orpoLossPrefixShared(model, split.promptIds, split.chosenResp, split.rejectedResp, cfg.orpoLambda, chunk, cfg.sftScope);
899
+ return orpoLossPrefixSharedGemma(model as Gemma4Model, split.promptIds, split.chosenResp, split.rejectedResp, cfg.orpoLambda, chunk, cfg.sftScope);
842
900
  }
843
901
  notePrefixFallback();
844
902
  }
845
- return orpoLoss(model, currentBatch!, cfg.orpoLambda, chunk);
903
+ return orpoLoss(model, currentBatch!, cfg.orpoLambda, chunk, cfg.sftScope);
846
904
  } finally {
847
905
  restorePrimals(lora, saved);
848
906
  }
@@ -897,7 +955,7 @@ async function orpoLoop(
897
955
  // Diagnostics recompute the two forwards (like dpoMetrics) — cheap at
898
956
  // B=1 (response-only head, no full logits) and gated by stepsPerReport.
899
957
  // Reusing the value_and_grad forward is a shared-with-DPO follow-on.
900
- const m = orpoMetrics(model, currentBatch!, cfg.orpoLambda);
958
+ const m = orpoMetrics(model, currentBatch!, cfg.orpoLambda, cfg.sftScope);
901
959
  const elapsed = (Date.now() - t0) / 1000;
902
960
  emit({
903
961
  type: "metric",
@@ -929,7 +987,7 @@ async function orpoLoop(
929
987
  chosenIds: [ex.chosenIds], rejectedIds: [ex.rejectedIds],
930
988
  chosenMask: [ex.chosenMask], rejectedMask: [ex.rejectedMask],
931
989
  };
932
- const m = orpoMetrics(model, vb, cfg.orpoLambda);
990
+ const m = orpoMetrics(model, vb, cfg.orpoLambda, cfg.sftScope);
933
991
  vl += m.loss; va += m.accuracy; vm += m.margin; vn++;
934
992
  clearCache();
935
993
  }
@@ -1105,10 +1163,16 @@ function clipGradsByNorm(grads: MlxArray[], normVal: number, maxNorm: number): v
1105
1163
  }
1106
1164
  }
1107
1165
 
1166
+ /** Supervised (response) tokens across ALL rows of the batch — the count
1167
+ * tokens/sec is reported over (mlx-lm's n_tokens counts every row too). */
1108
1168
  function countResponseTokens(batch: SftBatch): number {
1109
- const promptLen = batch.promptLens[0]!;
1110
- const len = batch.ids[0]!.length;
1111
- return Math.max(0, len - Math.max(promptLen, 1));
1169
+ let total = 0;
1170
+ for (let r = 0; r < batch.ids.length; r++) {
1171
+ const promptLen = batch.promptLens[r]!;
1172
+ const len = batch.ids[r]!.length;
1173
+ total += Math.max(0, len - Math.max(promptLen, 1));
1174
+ }
1175
+ return total;
1112
1176
  }
1113
1177
 
1114
1178
  async function fileExists(path: string): Promise<boolean> {