mlx-bun 0.0.11 → 0.0.13

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 (82) hide show
  1. package/README.md +63 -27
  2. package/THIRD_PARTY_LICENSES.md +9 -0
  3. package/docs/design/colibri-glm52-port.md +1486 -0
  4. package/docs/design/pre-colibri-stabilization.md +329 -0
  5. package/docs/investigations/colibri-oracle-pin.md +1284 -0
  6. package/docs/investigations/indexshare-performance-spike.md +357 -0
  7. package/docs/planning/release-notes-v0.0.12.md +80 -0
  8. package/docs/planning/release-notes-v0.0.13.md +77 -0
  9. package/docs/reference/cli.md +28 -2
  10. package/docs/reference/distribution.md +25 -1
  11. package/docs/reference/embedding.md +5 -2
  12. package/docs/reference/features-matrix.md +5 -2
  13. package/docs/reference/library-api.md +41 -13
  14. package/docs/reference/models.md +49 -2
  15. package/docs/reference/server-api.md +113 -20
  16. package/docs/reference/server-config.md +21 -14
  17. package/docs/reference/training.md +10 -1
  18. package/package.json +1 -1
  19. package/src/anthropic.ts +54 -44
  20. package/src/chat-template.ts +189 -11
  21. package/src/cli.ts +127 -8
  22. package/src/config.ts +81 -26
  23. package/src/diffusion/diffusion-generate.ts +14 -8
  24. package/src/download.ts +103 -12
  25. package/src/eval/ifeval.ts +63 -120
  26. package/src/eval/runner.ts +14 -5
  27. package/src/eval/tasks/ifeval.ts +167 -31
  28. package/src/expert-io.ts +391 -0
  29. package/src/expert-residency.ts +1134 -0
  30. package/src/expert-usage.ts +415 -0
  31. package/src/generate.ts +114 -17
  32. package/src/hf-push.ts +1 -1
  33. package/src/index.ts +14 -2
  34. package/src/jobs/runner.ts +37 -18
  35. package/src/kv-store.ts +147 -8
  36. package/src/lora.ts +77 -14
  37. package/src/media-fetch.ts +198 -56
  38. package/src/mlx/array.ts +43 -0
  39. package/src/mlx/autograd.ts +51 -14
  40. package/src/mlx/checkpoint.ts +46 -13
  41. package/src/mlx/custom-vjp.ts +83 -35
  42. package/src/model/factory.ts +96 -5
  43. package/src/model/gemma4-base.ts +32 -0
  44. package/src/model/glm52-atlas.ts +511 -0
  45. package/src/model/glm52-cache.ts +706 -0
  46. package/src/model/glm52-config.ts +262 -0
  47. package/src/model/glm52-container.ts +305 -0
  48. package/src/model/glm52-coupling.ts +388 -0
  49. package/src/model/glm52-dsa.ts +602 -0
  50. package/src/model/glm52-expert-layout.ts +253 -0
  51. package/src/model/glm52-layout.ts +152 -0
  52. package/src/model/glm52-memory.ts +473 -0
  53. package/src/model/glm52-mla.ts +786 -0
  54. package/src/model/glm52-moe.ts +223 -0
  55. package/src/model/glm52-pilot.ts +537 -0
  56. package/src/model/glm52-reference.ts +445 -0
  57. package/src/model/glm52-residency.ts +557 -0
  58. package/src/model/glm52-resident-weights.ts +135 -0
  59. package/src/model/glm52-streamed-experts.ts +453 -0
  60. package/src/model/glm52-streamed-metal.ts +610 -0
  61. package/src/model/glm52-weights.ts +503 -0
  62. package/src/model/glm52.ts +1153 -0
  63. package/src/model/support.ts +8 -1
  64. package/src/native/expert_io.c +523 -0
  65. package/src/native-pack.ts +6 -4
  66. package/src/responses.ts +62 -49
  67. package/src/serve/batch-scheduler.ts +95 -16
  68. package/src/serve/generation-gateway.ts +41 -16
  69. package/src/server.ts +471 -93
  70. package/src/spec/glm52-mtp-source.ts +392 -0
  71. package/src/spec/serve-loop.ts +99 -18
  72. package/src/spec/source.ts +31 -6
  73. package/src/ssd-cache.ts +6 -2
  74. package/src/tool-call.ts +30 -2
  75. package/src/train/job.ts +0 -10
  76. package/src/train/lora-params.ts +17 -6
  77. package/src/train/trainer.ts +59 -26
  78. package/src/web/app.html +13 -2
  79. package/src/web/app.js +30 -3
  80. package/src/web/src/status.ts +51 -3
  81. package/src/web-tools.ts +54 -2
  82. package/src/weights.ts +82 -19
@@ -149,6 +149,7 @@ interface QueuedSpawn {
149
149
  jobId: string;
150
150
  entry: string;
151
151
  bin: string;
152
+ spawn: typeof Bun.spawn;
152
153
  onComplete?: (jobId: string, code: number) => void;
153
154
  }
154
155
 
@@ -171,6 +172,8 @@ export interface SubprocessOpts {
171
172
  entry?: string;
172
173
  /** Override the runtime binary (tests). Defaults to "bun". */
173
174
  bin?: string;
175
+ /** Override process creation for deterministic failure-path tests. */
176
+ spawn?: typeof Bun.spawn;
174
177
  /** Called on the server (parent) after the child exits — used to invalidate
175
178
  * caches (e.g. the Library) so a finished quantize surfaces immediately. */
176
179
  onComplete?: (jobId: string, code: number) => void;
@@ -192,35 +195,46 @@ export function submitSubprocess(
192
195
  jobId: row.id,
193
196
  entry: opts.entry ?? JOB_ENTRY_PATH,
194
197
  bin: opts.bin ?? "bun",
198
+ spawn: opts.spawn ?? Bun.spawn,
195
199
  onComplete: opts.onComplete,
196
200
  };
197
- if (gpuLeaseHolder === null) {
198
- spawnNow(item);
199
- } else {
200
- spawnQueue.push(item);
201
- }
201
+ spawnQueue.push(item);
202
+ drainQueue();
202
203
  return { jobId: row.id, outputPath };
203
204
  }
204
205
 
205
206
  /** Spawn the child, acquire the lease, stream stdout/stderr into the log,
206
207
  * reconcile terminal status on exit, then release the lease and drain. */
207
208
  function spawnNow(item: QueuedSpawn): void {
208
- const { store, jobId, entry, bin } = item;
209
+ const { store, jobId, entry, bin, spawn } = item;
209
210
  gpuLeaseHolder = jobId;
210
211
 
211
212
  // The child opens its OWN JobStore over the SAME DB/logs — a sqlite
212
213
  // connection can't cross the process boundary, so we hand the paths via
213
214
  // env. A :memory: DB can't be shared with a child; subprocess jobs require
214
215
  // a file-backed DB.
215
- const proc = Bun.spawn([bin, entry, jobId], {
216
- stdout: "pipe",
217
- stderr: "pipe",
218
- env: {
219
- ...process.env,
220
- MLX_BUN_JOBS_DB: store.dbPath,
221
- MLX_BUN_JOBS_DIR: store.logsDir,
222
- },
223
- });
216
+ let proc: Bun.Subprocess<"ignore", "pipe", "pipe">;
217
+ try {
218
+ proc = spawn([bin, entry, jobId], {
219
+ stdout: "pipe",
220
+ stderr: "pipe",
221
+ env: {
222
+ ...process.env,
223
+ MLX_BUN_JOBS_DB: store.dbPath,
224
+ MLX_BUN_JOBS_DIR: store.logsDir,
225
+ },
226
+ });
227
+ } catch (e) {
228
+ try {
229
+ store.setStatus(jobId, "failed", {
230
+ error: errString(e),
231
+ endedAt: nowIso(),
232
+ });
233
+ } finally {
234
+ releaseLease(jobId);
235
+ }
236
+ return;
237
+ }
224
238
 
225
239
  const row = store.get(jobId);
226
240
  const logPath = row?.log_path;
@@ -259,9 +273,14 @@ function releaseLease(jobId: string): void {
259
273
 
260
274
  /** Run the next queued subprocess job if the lease is free. */
261
275
  export function drainQueue(): void {
262
- if (gpuLeaseHolder !== null) return;
263
- const next = spawnQueue.shift();
264
- if (next) spawnNow(next);
276
+ while (gpuLeaseHolder === null) {
277
+ const next = spawnQueue.shift();
278
+ if (!next) return;
279
+ // A synchronous spawn failure releases the lease. Keep draining
280
+ // iteratively so a run of bad queue entries cannot recurse or wedge the
281
+ // first startable job behind them.
282
+ spawnNow(next);
283
+ }
265
284
  }
266
285
 
267
286
  /** Read a child stream line-by-line, buffering partial trailing lines, and
package/src/kv-store.ts CHANGED
@@ -50,6 +50,7 @@ import {
50
50
  QuantizedKVCache, RotatingQuantizedKVCache,
51
51
  TurboQuantKVCache, type TurboQuantTensor,
52
52
  } from "./model/gemma4-base";
53
+ import { Glm52Cache, MLACache } from "./model/glm52-cache";
53
54
  import { SSMCache } from "./model/qwen3-delta";
54
55
 
55
56
  const MAGIC = "MLXBUNKV2\n";
@@ -62,7 +63,16 @@ const PREFIX_LEN = MAGIC.length + 4 + 4 + 8;
62
63
  // the header's byte length when the real values replace the placeholders.
63
64
  const hash64 = (bytes: Uint8Array): string => Bun.hash(bytes).toString(16).padStart(16, "0");
64
65
 
65
- export type CacheKind = "kv" | "rotating" | "qkv" | "rotating-qkv" | "ssm" | "turboquant";
66
+ export type CacheKind =
67
+ | "kv"
68
+ | "rotating"
69
+ | "qkv"
70
+ | "rotating-qkv"
71
+ | "ssm"
72
+ | "turboquant"
73
+ | "mla"
74
+ | "mla-dsa"
75
+ | "mtp-mla";
66
76
 
67
77
  interface TensorSlot {
68
78
  off: number;
@@ -91,6 +101,11 @@ export interface CacheHeaderEntry {
91
101
  /** turboquant: head_dim, needed to unpack kIdx/vPacked on restore
92
102
  * (packed byte width alone doesn't recover the original element count). */
93
103
  headDim?: number;
104
+ /** GLM checkpoint-native compressed-cache geometry. */
105
+ kvLoraRank?: number;
106
+ ropeHeadDim?: number;
107
+ dsaHeadDim?: number;
108
+ maxTokens?: number;
94
109
  /** kv/rotating: [k, v] · qkv/rotating-qkv: [kPacked, kScales, kBiases,
95
110
  * vPacked, vScales, vBiases] · ssm: [conv, recurrent] · turboquant:
96
111
  * [kIdx, kScales, kZeros, vPacked, vScales] */
@@ -142,6 +157,10 @@ function snapshotCache(c: Cache, dataOffset: number): { entry: CacheHeaderEntry;
142
157
  const [B, H, , D] = a.shape as [number, number, number, number];
143
158
  return a.slice([0, 0, 0, 0], [B, H, upTo, D]);
144
159
  };
160
+ const liveMlaSlice = (a: MlxArray, upTo: number): MlxArray => {
161
+ const [B, , D] = a.shape as [number, number, number];
162
+ return a.slice([0, 0, 0], [B, upTo, D]);
163
+ };
145
164
  const pushTriple = (t: ops.QuantizedTensor, upTo: number | null): void => {
146
165
  for (const a of [t.packed, t.scales, t.biases]) {
147
166
  if (upTo === null) push(a, false);
@@ -150,7 +169,26 @@ function snapshotCache(c: Cache, dataOffset: number): { entry: CacheHeaderEntry;
150
169
  };
151
170
 
152
171
  let entry: CacheHeaderEntry;
153
- if (c instanceof TurboQuantKVCache) {
172
+ if (c instanceof Glm52Cache) {
173
+ if (!c.latent || !c.rope || c.batchSize === null || c.offset === 0)
174
+ throw new Error("cannot persist an empty GLM compressed cache");
175
+ push(liveMlaSlice(c.latent, c.offset), true);
176
+ push(liveMlaSlice(c.rope, c.offset), true);
177
+ if (c.dsa) {
178
+ if (!c.dsa.data || c.dsa.offset !== c.offset)
179
+ throw new Error("cannot persist misaligned GLM DSA state");
180
+ push(liveMlaSlice(c.dsa.data, c.offset), true);
181
+ }
182
+ entry = {
183
+ kind: c.role === "mtp" ? "mtp-mla" : c.dsa ? "mla-dsa" : "mla",
184
+ offset: c.offset,
185
+ kvLoraRank: c.kvLoraRank,
186
+ ropeHeadDim: c.ropeHeadDim,
187
+ ...(c.dsa ? { dsaHeadDim: c.dsa.headDim } : {}),
188
+ maxTokens: c.maxTokens,
189
+ tensors: slots,
190
+ };
191
+ } else if (c instanceof TurboQuantKVCache) {
154
192
  const t = c.state();
155
193
  if (t.length === 0) throw new Error("cannot persist an empty cache");
156
194
  const [kIdx, kScales, kZeros, vPacked, vScales] = t as [MlxArray, MlxArray, MlxArray, MlxArray, MlxArray];
@@ -211,6 +249,10 @@ export function cloneKvCaches(caches: Cache[]): Cache[] {
211
249
  const [B, H, , D] = a.shape as [number, number, number, number];
212
250
  return a.slice([0, 0, 0, 0], [B, H, upTo, D]);
213
251
  };
252
+ const mlaView = (a: MlxArray, upTo: number): MlxArray => {
253
+ const [B, , D] = a.shape as [number, number, number];
254
+ return a.slice([0, 0, 0], [B, upTo, D]);
255
+ };
214
256
  const tripleView = (t: ops.QuantizedTensor, upTo: number | null): ops.QuantizedTensor => ({
215
257
  packed: upTo === null ? view(t.packed) : liveView(t.packed, upTo),
216
258
  scales: upTo === null ? view(t.scales) : liveView(t.scales, upTo),
@@ -219,7 +261,24 @@ export function cloneKvCaches(caches: Cache[]): Cache[] {
219
261
  const out: Cache[] = [];
220
262
  try {
221
263
  for (const c of caches) {
222
- if (c instanceof TurboQuantKVCache) {
264
+ if (c instanceof Glm52Cache) {
265
+ if (!c.latent || !c.rope || c.batchSize === null || c.offset === 0)
266
+ throw new Error("cannot clone an empty GLM compressed cache");
267
+ const n = new MLACache({
268
+ kvLoraRank: c.kvLoraRank,
269
+ ropeHeadDim: c.ropeHeadDim,
270
+ ...(c.dsa ? { dsa: { headDim: c.dsa.headDim } } : {}),
271
+ maxTokens: c.maxTokens,
272
+ role: c.role,
273
+ });
274
+ n.restoreCompressedState(
275
+ mlaView(c.latent, c.offset),
276
+ mlaView(c.rope, c.offset),
277
+ c.dsa ? mlaView(c.dsa.data!, c.offset) : null,
278
+ c.offset,
279
+ );
280
+ out.push(n);
281
+ } else if (c instanceof TurboQuantKVCache) {
223
282
  const state = c.state();
224
283
  if (state.length === 0) throw new Error("cannot clone an empty cache");
225
284
  const [kIdx, kScales, kZeros, vPacked, vScales] = state;
@@ -514,6 +573,7 @@ export interface LoadedKvCache {
514
573
 
515
574
  export interface KvLoadExpect {
516
575
  /** Reject on metadata mismatch (pass what the server is running). */
576
+ modelId?: string;
517
577
  configFingerprint?: string;
518
578
  tokenizerHash?: string;
519
579
  ns?: string;
@@ -522,6 +582,50 @@ export interface KvLoadExpect {
522
582
  verify?: boolean;
523
583
  }
524
584
 
585
+ /** Reject GLM cache/layout drift before opening the tensor mmap. Generic KV
586
+ * kinds may legitimately differ from makeCache() after runtime quantization;
587
+ * GLM's checkpoint-native cache geometry does not. */
588
+ function validateGlm52Prototype(
589
+ path: string,
590
+ entry: CacheHeaderEntry,
591
+ prototype: Cache,
592
+ ): void {
593
+ const glmKind = entry.kind === "mla" || entry.kind === "mla-dsa" ||
594
+ entry.kind === "mtp-mla";
595
+ if (!(prototype instanceof Glm52Cache)) {
596
+ if (glmKind)
597
+ throw new Error(`${path}: GLM cache kind ${entry.kind} does not match model cache`);
598
+ return;
599
+ }
600
+ const expectedKind: CacheKind = prototype.role === "mtp"
601
+ ? "mtp-mla"
602
+ : prototype.dsa ? "mla-dsa" : "mla";
603
+ if (entry.kind !== expectedKind) {
604
+ throw new Error(
605
+ `${path}: cache kind ${entry.kind} != model cache kind ${expectedKind}`,
606
+ );
607
+ }
608
+ for (const [field, actual, expected] of [
609
+ ["kvLoraRank", entry.kvLoraRank, prototype.kvLoraRank],
610
+ ["ropeHeadDim", entry.ropeHeadDim, prototype.ropeHeadDim],
611
+ ["dsaHeadDim", entry.dsaHeadDim, prototype.dsa?.headDim],
612
+ ["maxTokens", entry.maxTokens, prototype.maxTokens],
613
+ ] as const) {
614
+ if (actual !== expected) {
615
+ throw new Error(
616
+ `${path}: ${field} ${String(actual)} != model ${String(expected)}`,
617
+ );
618
+ }
619
+ }
620
+ const expectedTensors = prototype.dsa ? 3 : 2;
621
+ if (entry.tensors.length !== expectedTensors) {
622
+ throw new Error(
623
+ `${path}: ${entry.kind} has ${entry.tensors.length} tensors, ` +
624
+ `expected ${expectedTensors}`,
625
+ );
626
+ }
627
+ }
628
+
525
629
  /** Copy-restore a full-attention KV tensor into STEP-rounded capacity with
526
630
  * ≥1 token of slack (what a live mid-generation cache looks like) so the
527
631
  * first post-restore updateAndFetch never takes the grow path — the old
@@ -569,15 +673,28 @@ export function loadKvCache(
569
673
  expect: KvLoadExpect = {},
570
674
  ): LoadedKvCache {
571
675
  const header = readKvHeader(path);
572
- for (const key of ["configFingerprint", "tokenizerHash", "ns"] as const) {
676
+ for (const key of [
677
+ "modelId",
678
+ "configFingerprint",
679
+ "tokenizerHash",
680
+ "ns",
681
+ ] as const) {
573
682
  if (expect[key] !== undefined && header[key] !== expect[key])
574
683
  throw new Error(`${path}: ${key} mismatch (file ${header[key]}, expected ${expect[key]})`);
575
684
  }
576
685
  const proto = model.makeCache();
577
- const cacheCount = proto.length;
578
- for (const c of proto) c.dispose();
579
- if (header.caches.length !== cacheCount)
580
- throw new Error(`${path}: ${header.caches.length} cached layers but model has ${cacheCount}`);
686
+ try {
687
+ if (header.caches.length !== proto.length) {
688
+ throw new Error(
689
+ `${path}: ${header.caches.length} cached layers but model has ${proto.length}`,
690
+ );
691
+ }
692
+ for (let index = 0; index < proto.length; index++) {
693
+ validateGlm52Prototype(path, header.caches[index]!, proto[index]!);
694
+ }
695
+ } finally {
696
+ for (const c of proto) c.dispose();
697
+ }
581
698
 
582
699
  const mmap = MmapFile.open(path, "ro");
583
700
  const dataStart = header.dataStart;
@@ -663,6 +780,28 @@ export function loadKvCache(
663
780
  caches.push(c);
664
781
  break;
665
782
  }
783
+ case "mla":
784
+ case "mla-dsa":
785
+ case "mtp-mla": {
786
+ const hasDsa = e.kind === "mla-dsa";
787
+ if (e.kind === "mtp-mla" && e.dsaHeadDim !== undefined)
788
+ throw new Error(`${path}: native MTP cache cannot contain DSA state`);
789
+ const c = new MLACache({
790
+ kvLoraRank: e.kvLoraRank!,
791
+ ropeHeadDim: e.ropeHeadDim!,
792
+ ...(hasDsa ? { dsa: { headDim: e.dsaHeadDim! } } : {}),
793
+ maxTokens: e.maxTokens,
794
+ role: e.kind === "mtp-mla" ? "mtp" : "target",
795
+ });
796
+ c.restoreCompressedState(
797
+ arr(t[0]!),
798
+ arr(t[1]!),
799
+ hasDsa ? arr(t[2]!) : null,
800
+ e.offset,
801
+ );
802
+ caches.push(c);
803
+ break;
804
+ }
666
805
  default:
667
806
  throw new Error(`${path}: unknown cache kind ${(e as { kind: string }).kind}`);
668
807
  }
package/src/lora.ts CHANGED
@@ -15,6 +15,7 @@ import { resolve } from "node:path";
15
15
  import { ptr, read } from "bun:ffi";
16
16
  import { MlxArray, cpuStream } from "./mlx/array";
17
17
  import { C } from "./mlx/ffi";
18
+ import { transposeAxes } from "./mlx/ops";
18
19
  import { SafetensorsFile } from "./safetensors";
19
20
  import type { LoraWeights } from "./model/gemma4";
20
21
  import type { RuntimeModel } from "./model/factory";
@@ -80,21 +81,26 @@ export function loadAdapterTensors(file: string): Map<string, MlxArray> {
80
81
  }
81
82
 
82
83
  /** Adapter scale from its config: mlx-lm writes lora_parameters.scale;
83
- * PEFT writes lora_alpha + r (scale = alpha / r). Reference: mount.py.
84
- * `rsLora` (recorded at train time) means the effective per-layer scale is
85
- * α/√rank — the caller divides by √(that layer's rank). */
84
+ * PEFT writes lora_alpha + r and optionally use_rslora. For ordinary PEFT
85
+ * return alpha/r as the scale. For PEFT rsLoRA return alpha and set rsLora,
86
+ * so the caller's one per-layer division produces alpha/√rank exactly once.
87
+ * Internal configs already store their pre-√rank scale directly. */
86
88
  export async function readAdapterScale(dir: string): Promise<{ scale: number; rank: number | null; rsLora: boolean }> {
87
89
  for (const name of ["optiq_lora_config.json", "adapter_config.json"]) {
88
90
  const f = Bun.file(`${dir}/${name}`);
89
91
  if (await f.exists()) {
90
92
  const cfg = (await f.json()) as Record<string, any>;
91
93
  const lp = cfg.lora_parameters;
92
- const rsLora = Boolean(cfg.rs_lora ?? lp?.rs_lora ?? false);
94
+ const rsLora = Boolean(cfg.rs_lora ?? lp?.rs_lora ?? cfg.use_rslora ?? false);
93
95
  if (lp && typeof lp === "object")
94
96
  return { scale: Number(lp.scale ?? 20.0), rank: lp.rank ?? null, rsLora };
95
97
  const alpha = Number(cfg.lora_alpha ?? 16);
96
98
  const r = Number(cfg.r ?? 8);
97
- return { scale: r ? alpha / r : 1.0, rank: r || null, rsLora };
99
+ return {
100
+ scale: rsLora ? alpha : r ? alpha / r : 1.0,
101
+ rank: r || null,
102
+ rsLora,
103
+ };
98
104
  }
99
105
  }
100
106
  throw new Error(`no adapter_config.json in ${dir}`);
@@ -215,7 +221,12 @@ export class AdapterManager {
215
221
 
216
222
  // Group adapter tensors into (modulePath → {a, b}), probing both the
217
223
  // pure-LLM and VLM-wrapped prefixes like the reference _find_weight_pair.
218
- const pairs = new Map<string, { a?: MlxArray; b?: MlxArray }>();
224
+ const pairs = new Map<string, {
225
+ a?: MlxArray;
226
+ b?: MlxArray;
227
+ aIsPeft?: boolean;
228
+ bIsPeft?: boolean;
229
+ }>();
219
230
  let skipped = 0;
220
231
  const ourPrefix = `${this.#model.prefixBase}.layers.`;
221
232
  const altPrefix = ourPrefix.startsWith("language_model.")
@@ -225,11 +236,23 @@ export class AdapterManager {
225
236
  const m = name.match(/^(.*)\.(lora_a|lora_A|lora_b|lora_B)(\.weight)?$/);
226
237
  if (!m) { skipped++; arr.dispose(); continue; }
227
238
  let modulePath = m[1]!;
239
+ // Standard PEFT state dicts wrap the model path in
240
+ // `base_model.model.`. The remainder is the underlying HF module path
241
+ // (`model.layers...` or `language_model.model.layers...`).
242
+ if (modulePath.startsWith("base_model.model."))
243
+ modulePath = modulePath.slice("base_model.model.".length);
228
244
  if (modulePath.startsWith(altPrefix))
229
245
  modulePath = ourPrefix + modulePath.slice(altPrefix.length);
230
246
  if (!targets.has(modulePath)) { skipped++; arr.dispose(); continue; }
231
247
  const slot = pairs.get(modulePath) ?? {};
232
- if (m[2]!.toLowerCase() === "lora_a") slot.a = arr; else slot.b = arr;
248
+ const peftName = m[3] === ".weight" || m[2] === "lora_A" || m[2] === "lora_B";
249
+ if (m[2]!.toLowerCase() === "lora_a") {
250
+ slot.a = arr;
251
+ slot.aIsPeft = peftName;
252
+ } else {
253
+ slot.b = arr;
254
+ slot.bIsPeft = peftName;
255
+ }
233
256
  pairs.set(modulePath, slot);
234
257
  }
235
258
 
@@ -240,21 +263,61 @@ export class AdapterManager {
240
263
  for (const { a, b } of pairs.values()) { a?.dispose(); b?.dispose(); }
241
264
  };
242
265
  try {
243
- for (const [modulePath, { a, b }] of pairs) {
266
+ for (const [modulePath, pair] of pairs) {
267
+ const { a, b } = pair;
244
268
  if (!a || !b)
245
269
  throw new Error(`${modulePath}: adapter has only one of lora_a/lora_b`);
246
270
  const linear = targets.get(modulePath)!;
247
- const [aIn, rank] = a.shape as [number, number];
248
- const [bRank, bOut] = b.shape as [number, number];
249
- if (aIn !== linear.inFeatures || bRank !== rank || bOut !== linear.outFeatures)
271
+ if (a.shape.length !== 2 || b.shape.length !== 2)
272
+ throw new Error(
273
+ `${modulePath}: LoRA tensors must be rank-2; got ` +
274
+ `lora_a [${a.shape}] / lora_b [${b.shape}]`,
275
+ );
276
+
277
+ const internalLayout =
278
+ a.shape[0] === linear.inFeatures &&
279
+ b.shape[0] === a.shape[1] &&
280
+ b.shape[1] === linear.outFeatures;
281
+ const peftLayout =
282
+ a.shape[1] === linear.inFeatures &&
283
+ b.shape[1] === a.shape[0] &&
284
+ b.shape[0] === linear.outFeatures;
285
+ if (!internalLayout && !peftLayout)
250
286
  throw new Error(
251
287
  `${modulePath}: shape mismatch — lora_a [${a.shape}] / lora_b [${b.shape}] ` +
252
- `vs base [in ${linear.inFeatures}, out ${linear.outFeatures}]; ` +
253
- `was this adapter trained for a different base model?`,
288
+ `vs base [in ${linear.inFeatures}, out ${linear.outFeatures}]. Expected ` +
289
+ `mlx-lm [in, rank] + [rank, out] or PEFT [rank, in] + [out, rank].`,
254
290
  );
291
+
292
+ // Full-rank square adapters satisfy both shape predicates. In that
293
+ // ambiguous case the PEFT spelling is authoritative; otherwise the
294
+ // unique compatible layout decides.
295
+ const usePeftLayout =
296
+ peftLayout && (!internalLayout || (pair.aIsPeft === true && pair.bIsPeft === true));
297
+ let mountedA = a;
298
+ let mountedB = b;
299
+ if (usePeftLayout) {
300
+ let transposedA: MlxArray | null = null;
301
+ let transposedB: MlxArray | null = null;
302
+ try {
303
+ transposedA = transposeAxes(a, [1, 0]);
304
+ transposedB = transposeAxes(b, [1, 0]);
305
+ } catch (e) {
306
+ transposedA?.dispose();
307
+ transposedB?.dispose();
308
+ throw e;
309
+ }
310
+ mountedA = transposedA;
311
+ mountedB = transposedB;
312
+ pair.a = mountedA;
313
+ pair.b = mountedB;
314
+ a.dispose();
315
+ b.dispose();
316
+ }
317
+ const rank = mountedA.shape[1]!;
255
318
  // rsLoRA: effective per-layer scale is α/√rank (matches training).
256
319
  const effScale = rsLora ? scale / Math.sqrt(rank) : scale;
257
- validated.push({ linear, lw: { a, b, scale: effScale, rank } });
320
+ validated.push({ linear, lw: { a: mountedA, b: mountedB, scale: effScale, rank } });
258
321
  }
259
322
  if (validated.length === 0)
260
323
  throw new Error(