mlx-bun 0.0.8 → 0.0.10

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 (144) hide show
  1. package/README.md +112 -20
  2. package/docs/design/batching-perf-path.md +232 -0
  3. package/docs/design/batching-v2-plan.md +515 -0
  4. package/docs/design/bucketing-stage.md +196 -0
  5. package/docs/design/compat-cli-surface-design.md +11 -11
  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/omlx-adoption-map.md +90 -0
  15. package/docs/design/orpo-dynamic-lambda.md +41 -0
  16. package/docs/design/orpo-training.md +35 -5
  17. package/docs/design/parallel-slots.md +12 -1
  18. package/docs/design/parity-tier-dag.md +13 -5
  19. package/docs/design/segmented-backward-training.md +15 -0
  20. package/docs/design/spec-decode-larger-targets.md +229 -0
  21. package/docs/design/ssd-kv-cold-tier.md +158 -0
  22. package/docs/design/tauri-desktop-app.md +2 -2
  23. package/docs/design/the-dreaming-handoff.md +141 -0
  24. package/docs/design/the-dreaming-master-plan.md +446 -0
  25. package/docs/design/write-pipeline-entity.md +468 -0
  26. package/docs/investigations/curve-bisector-routing.md +1 -1
  27. package/docs/investigations/decode-roofline-lookagain.md +348 -0
  28. package/docs/investigations/dspark-handoff.md +58 -0
  29. package/docs/investigations/kernel-perf-review-2026-07.md +171 -0
  30. package/docs/planning/PRODUCT_ROADMAP.md +2 -2
  31. package/docs/planning/curve-sampler-research-plan.md +331 -0
  32. package/docs/planning/memory-docs-and-dag-plan.md +185 -0
  33. package/docs/planning/pi-integration-review.md +178 -0
  34. package/docs/planning/release-notes-v0.0.10.md +57 -0
  35. package/docs/planning/release-notes-v0.0.9.md +122 -0
  36. package/docs/planning/web-ui-pass-plan.md +213 -0
  37. package/docs/planning/website-readme-pass-plan.md +224 -0
  38. package/docs/reference/cli.md +255 -0
  39. package/docs/reference/embedding.md +7 -2
  40. package/docs/reference/library-api.md +22 -3
  41. package/docs/reference/memory.md +5 -5
  42. package/docs/reference/models.md +137 -0
  43. package/docs/reference/orpo-quickstart.md +6 -0
  44. package/docs/reference/server-api.md +187 -9
  45. package/docs/reference/server-config.md +43 -9
  46. package/docs/reference/training.md +31 -12
  47. package/package.json +1 -1
  48. package/src/anthropic.ts +22 -0
  49. package/src/assets/curve-designer.html +1 -1
  50. package/src/chat-template.ts +25 -7
  51. package/src/cli.ts +1077 -90
  52. package/src/config.ts +4 -1
  53. package/src/download.ts +99 -8
  54. package/src/embed.ts +77 -0
  55. package/src/eval/perplexity.ts +164 -0
  56. package/src/eval/runner.ts +15 -6
  57. package/src/eval/tasks/bfcl.ts +1 -1
  58. package/src/generate.ts +149 -18
  59. package/src/harness-pi.ts +43 -17
  60. package/src/index.ts +2 -0
  61. package/src/kv-store.ts +240 -98
  62. package/src/lora.ts +2 -2
  63. package/src/memory/article.ts +558 -0
  64. package/src/memory/chunk-validation.ts +150 -0
  65. package/src/memory/chunk.ts +305 -12
  66. package/src/memory/cluster.ts +344 -17
  67. package/src/memory/coverage.ts +134 -0
  68. package/src/memory/crosslink.ts +0 -0
  69. package/src/memory/db.ts +184 -45
  70. package/src/memory/entity.ts +203 -0
  71. package/src/memory/gate.ts +187 -0
  72. package/src/memory/history.ts +152 -0
  73. package/src/memory/ingest.ts +324 -11
  74. package/src/memory/model.ts +321 -0
  75. package/src/memory/normalize.ts +671 -0
  76. package/src/memory/parse.ts +22 -0
  77. package/src/memory/pipeline.ts +242 -30
  78. package/src/memory/prompts.ts +42 -0
  79. package/src/memory/query.ts +334 -0
  80. package/src/memory/reconcile.ts +734 -0
  81. package/src/memory/redirect.ts +108 -0
  82. package/src/memory/reindex.ts +325 -0
  83. package/src/memory/resolve.ts +307 -0
  84. package/src/memory/route.ts +272 -0
  85. package/src/memory/schedule.ts +6 -4
  86. package/src/memory/stages.ts +774 -0
  87. package/src/memory/synthesize.ts +1629 -20
  88. package/src/memory/tools.ts +446 -96
  89. package/src/memory/vault.ts +462 -35
  90. package/src/memory/wikify.ts +1057 -16
  91. package/src/mlx/array.ts +21 -0
  92. package/src/mlx/ffi.ts +3 -0
  93. package/src/mlx/ops.ts +72 -0
  94. package/src/model/compiled-decode.ts +43 -8
  95. package/src/model/factory.ts +29 -8
  96. package/src/model/flash-attention.ts +4 -2
  97. package/src/model/fused-decode-kernel.ts +14 -7
  98. package/src/model/fused-mlp-kernel.ts +169 -0
  99. package/src/model/fused-swiglu-kernel.ts +240 -0
  100. package/src/model/gemma4-base.ts +96 -7
  101. package/src/model/gemma4.ts +33 -2
  102. package/src/model/minicpm5.ts +106 -8
  103. package/src/model/qwen3-delta.ts +48 -1
  104. package/src/model/qwen3.ts +276 -0
  105. package/src/model/qwen3_5.ts +11 -2
  106. package/src/model/steel-linear-kernel.ts +96 -0
  107. package/src/model/support.ts +38 -8
  108. package/src/model/universal/archs.ts +332 -0
  109. package/src/model/universal/dense.ts +576 -0
  110. package/src/model/universal/modules.ts +163 -0
  111. package/src/model/universal/rope.ts +366 -0
  112. package/src/pi-provider.ts +1 -1
  113. package/src/pi-terminal.ts +29 -8
  114. package/src/pi-web.ts +19 -2
  115. package/src/prompt-cache.ts +55 -9
  116. package/src/quantize/config-writer.ts +1 -1
  117. package/src/registry.ts +219 -8
  118. package/src/responses.ts +23 -0
  119. package/src/sampler.ts +220 -9
  120. package/src/serve/batch-scheduler.ts +307 -83
  121. package/src/serve/generation-gateway.ts +156 -29
  122. package/src/server.ts +746 -73
  123. package/src/spec/drafter.ts +81 -49
  124. package/src/spec/dspark/data-dflash.ts +156 -0
  125. package/src/spec/dspark/data.ts +201 -0
  126. package/src/spec/dspark/generate-dflash.ts +193 -0
  127. package/src/spec/dspark/generate.ts +290 -0
  128. package/src/spec/dspark/loss.ts +213 -0
  129. package/src/spec/dspark/module-dflash.ts +361 -0
  130. package/src/spec/dspark/module.ts +540 -0
  131. package/src/spec/dspark/sample.ts +112 -0
  132. package/src/spec/generate.ts +40 -19
  133. package/src/ssd-cache.ts +213 -0
  134. package/src/status-page.html +1 -1
  135. package/src/tokenizer.ts +7 -0
  136. package/src/train/flash-cce.ts +81 -23
  137. package/src/train/fuse.ts +243 -0
  138. package/src/train/index.ts +3 -0
  139. package/src/train/job.ts +8 -0
  140. package/src/train/loss.ts +341 -40
  141. package/src/train/prefix-shared.ts +62 -11
  142. package/src/train/segmented.ts +215 -55
  143. package/src/train/trainer.ts +112 -38
  144. package/src/web/skills/memory/SKILL.md +40 -17
package/src/kv-store.ts CHANGED
@@ -1,23 +1,38 @@
1
1
  // KV-cache persistence: save prompt caches to disk, reload zero-copy.
2
+ // The serialization core of the SSD cold tier (docs/design/ssd-kv-cold-tier.md).
2
3
  //
3
4
  // File layout (every tensor PAGE-ALIGNED — the Phase 1 corollary: files
4
5
  // we write can be mmap'd and handed to the GPU without copies):
5
- // [magic "MLXBUNKV1\n"][u32 LE header length][JSON header][padding]
6
+ // [magic "MLXBUNKV2\n"][u32 LE header length][u32 LE dataStart]
7
+ // [u64 LE header hash][JSON header][padding]
6
8
  // [tensor data at 16 KiB-aligned offsets]
7
- // Header: { tokens, caches: [{ kind, offset, idx?, maxSize?, dtype,
8
- // kShape, vShape, kOffset, vOffset }] }
9
+ // Header: { formatVersion, modelId, configFingerprint, ns, tokenizerHash,
10
+ // createdAt, tokens, caches: [{ kind, offset, idx?, maxSize?,
11
+ // groupSize?, bits?, tensors: [{ off, bytes, shape, dtype, hash }] }] }
12
+ //
13
+ // v2 over v1: quantized cache kinds (the serving DEFAULT is kv_config
14
+ // quantization, which v1 could not persist), SSM kind (Qwen3.5 hybrid),
15
+ // invalidation metadata (configFingerprint covers the kv-quant scheme;
16
+ // tokenizerHash guards vocab drift; ns = adapter spec), per-tensor hashes
17
+ // (verified opt-in — eager verification would defeat lazy page fault-in),
18
+ // a header hash (always verified — cheap), and atomic tmp+fsync+rename
19
+ // writes. v1 files are not migrated: nothing shipped wrote them (the
20
+ // format was test/experiment-only) — they read as "not a v2 file" and the
21
+ // SSD tier deletes-and-regenerates.
9
22
  //
10
23
  // Reload mmaps copy-on-write (MAP_PRIVATE): if mlx ever donates one of
11
24
  // these buffers, writes hit private pages, never the file.
12
25
 
13
- import { openSync, writeSync, closeSync } from "node:fs";
26
+ import { openSync, writeSync, readSync, closeSync, fsyncSync, renameSync, rmSync } from "node:fs";
14
27
  import { MmapFile } from "./mmap";
15
28
  import { MlxArray } from "./mlx/array";
16
29
  import type { Dtype } from "./mlx/ffi";
17
30
  import * as ops from "./mlx/ops";
18
31
  import {
19
- type Cache, KVCache, RotatingKVCache, Gemma4Model,
20
- } from "./model/gemma4";
32
+ type Cache, KVCache, RotatingKVCache,
33
+ QuantizedKVCache, RotatingQuantizedKVCache,
34
+ } from "./model/gemma4-base";
35
+ import { SSMCache } from "./model/qwen3-delta";
21
36
 
22
37
  /** Contiguous raw bytes of (possibly a view of) an array. */
23
38
  function contiguousBytes(a: MlxArray): Uint8Array {
@@ -27,120 +42,184 @@ function contiguousBytes(a: MlxArray): Uint8Array {
27
42
  return bytes;
28
43
  }
29
44
 
30
- const MAGIC = "MLXBUNKV1\n";
45
+ const MAGIC = "MLXBUNKV2\n";
31
46
  const ALIGN = 16384;
47
+ /** magic + u32 headerLen + u32 dataStart + u64 headerHash */
48
+ const PREFIX_LEN = MAGIC.length + 4 + 4 + 8;
49
+
50
+ const hash64 = (bytes: Uint8Array): string => Bun.hash(bytes).toString(16);
32
51
 
33
- interface CacheHeaderEntry {
34
- kind: "kv" | "rotating";
52
+ export type CacheKind = "kv" | "rotating" | "qkv" | "rotating-qkv" | "ssm";
53
+
54
+ interface TensorSlot {
55
+ off: number;
56
+ bytes: number;
57
+ shape: number[];
58
+ dtype: number;
59
+ hash: string;
60
+ }
61
+
62
+ export interface CacheHeaderEntry {
63
+ kind: CacheKind;
35
64
  offset: number;
65
+ /** rotating variants: ring write index */
36
66
  idx?: number;
67
+ /** rotating variants: window size */
37
68
  maxSize?: number;
38
- dtype: number;
39
- kShape: number[];
40
- vShape: number[];
41
- kOffset: number;
42
- vOffset: number;
43
- kBytes: number;
44
- vBytes: number;
69
+ /** quantized variants */
70
+ groupSize?: number;
71
+ bits?: number;
72
+ /** kv/rotating: [k, v] · qkv/rotating-qkv: [kPacked, kScales, kBiases,
73
+ * vPacked, vScales, vBiases] · ssm: [conv, recurrent] */
74
+ tensors: TensorSlot[];
45
75
  }
46
76
 
47
- export interface KvFileHeader {
77
+ export interface KvSaveMeta {
78
+ modelId?: string;
79
+ /** configFingerprint(config) — covers every graph-shaping field incl.
80
+ * the kv-quant scheme, so a scheme flip invalidates naturally. */
81
+ configFingerprint?: string;
82
+ /** Adapter namespace (PromptCache ns — adapters joined with "+"). */
83
+ ns?: string;
84
+ /** sha256 of tokenizer.json: identical ids must mean identical text. */
85
+ tokenizerHash?: string;
86
+ }
87
+
88
+ export interface KvFileHeader extends KvSaveMeta {
89
+ formatVersion: 2;
90
+ createdAt: number;
48
91
  tokens: number[];
49
92
  caches: CacheHeaderEntry[];
50
93
  }
51
94
 
52
95
  const alignUp = (n: number) => Math.ceil(n / ALIGN) * ALIGN;
53
96
 
54
- export function saveKvCache(path: string, tokens: number[], caches: Cache[]): void {
55
- const entries: CacheHeaderEntry[] = [];
97
+ /** Snapshot one cache into header entry + blobs. Quantized full caches are
98
+ * sliced to the live [.., :offset, :] region (token axis — quantization
99
+ * groups run along the FEATURE dim, so a sequence-axis cut never splits a
100
+ * group); rotating caches persist the whole ring as-laid-out with ringIdx. */
101
+ function snapshotCache(c: Cache, dataOffset: number): { entry: CacheHeaderEntry; blobs: Uint8Array[]; next: number } {
102
+ const slots: TensorSlot[] = [];
56
103
  const blobs: Uint8Array[] = [];
104
+ let cursor = dataOffset;
105
+ const push = (a: MlxArray, disposeAfter: boolean): void => {
106
+ const bytes = contiguousBytes(a);
107
+ const off = alignUp(cursor);
108
+ slots.push({ off, bytes: bytes.length, shape: a.shape, dtype: a.dtype, hash: hash64(bytes) });
109
+ blobs.push(bytes);
110
+ cursor = off + bytes.length;
111
+ if (disposeAfter) a.dispose();
112
+ };
113
+ const liveSlice = (a: MlxArray, upTo: number): MlxArray => {
114
+ const [B, H, , D] = a.shape as [number, number, number, number];
115
+ return a.slice([0, 0, 0, 0], [B, H, upTo, D]);
116
+ };
117
+ const pushTriple = (t: ops.QuantizedTensor, upTo: number | null): void => {
118
+ for (const a of [t.packed, t.scales, t.biases]) {
119
+ if (upTo === null) push(a, false);
120
+ else push(liveSlice(a, upTo), true);
121
+ }
122
+ };
123
+
124
+ let entry: CacheHeaderEntry;
125
+ if (c instanceof RotatingQuantizedKVCache) {
126
+ if (!c.keys || !c.values) throw new Error("cannot persist an empty cache");
127
+ pushTriple(c.keys, null);
128
+ pushTriple(c.values, null);
129
+ entry = { kind: "rotating-qkv", offset: c.offset, idx: c.ringIdx, maxSize: c.maxSize,
130
+ groupSize: c.groupSize, bits: c.bits, tensors: slots };
131
+ } else if (c instanceof QuantizedKVCache) {
132
+ if (!c.keys || !c.values) throw new Error("cannot persist an empty cache");
133
+ pushTriple(c.keys, c.offset);
134
+ pushTriple(c.values, c.offset);
135
+ entry = { kind: "qkv", offset: c.offset, groupSize: c.groupSize, bits: c.bits, tensors: slots };
136
+ } else if (c instanceof RotatingKVCache) {
137
+ if (!c.keys || !c.values) throw new Error("cannot persist an empty cache");
138
+ push(c.keys, false);
139
+ push(c.values, false);
140
+ entry = { kind: "rotating", offset: c.offset, idx: c.ringIdx, maxSize: c.maxSize, tensors: slots };
141
+ } else if (c instanceof SSMCache) {
142
+ if (!c.conv || !c.recurrent) throw new Error("cannot persist an empty cache");
143
+ push(c.conv, false);
144
+ push(c.recurrent, false);
145
+ entry = { kind: "ssm", offset: c.offset, tensors: slots };
146
+ } else if (c instanceof KVCache) {
147
+ if (!c.keys || !c.values) throw new Error("cannot persist an empty cache");
148
+ push(liveSlice(c.keys, c.offset), true);
149
+ push(liveSlice(c.values, c.offset), true);
150
+ entry = { kind: "kv", offset: c.offset, tensors: slots };
151
+ } else {
152
+ throw new Error("unknown cache type");
153
+ }
154
+ return { entry, blobs, next: cursor };
155
+ }
57
156
 
58
- // compact each cache to its live region and snapshot bytes
157
+ /** Persist `caches` (+ the exact token prefix they encode) to `path`.
158
+ * ATOMIC: written to `<path>.tmp`, fsync'd, renamed — a crash mid-write
159
+ * leaves only a .tmp orphan (ignored + reaped by SsdCacheStore.scan). */
160
+ export function saveKvCache(path: string, tokens: number[], caches: Cache[], meta: KvSaveMeta = {}): void {
161
+ const entries: CacheHeaderEntry[] = [];
162
+ const blobs: Uint8Array[] = [];
59
163
  let dataOffset = 0; // relative; rebased after header is sized
60
164
  for (const c of caches) {
61
- let keys: MlxArray;
62
- let values: MlxArray;
63
- let kind: "kv" | "rotating";
64
- let idx: number | undefined;
65
- let maxSize: number | undefined;
66
- if (c instanceof RotatingKVCache) {
67
- kind = "rotating";
68
- idx = c.ringIdx;
69
- maxSize = c.maxSize;
70
- if (!c.keys || !c.values) throw new Error("cannot persist an empty cache");
71
- keys = c.keys;
72
- values = c.values;
73
- } else if (c instanceof KVCache) {
74
- kind = "kv";
75
- if (!c.keys || !c.values) throw new Error("cannot persist an empty cache");
76
- // store only the live [.., :offset, :] region
77
- const [B, H, , D] = c.keys.shape as [number, number, number, number];
78
- const vD = c.values.shape[3]!;
79
- keys = c.keys.slice([0, 0, 0, 0], [B, H, c.offset, D]);
80
- values = c.values.slice([0, 0, 0, 0], [B, H, c.offset, vD]);
81
- } else {
82
- throw new Error("unknown cache type");
83
- }
84
-
85
- const kBytesArr = contiguousBytes(keys);
86
- const vBytesArr = contiguousBytes(values);
87
- const kOffset = alignUp(dataOffset);
88
- const vOffset = alignUp(kOffset + kBytesArr.length);
89
- dataOffset = vOffset + vBytesArr.length;
90
-
91
- entries.push({
92
- kind, offset: c.offset, idx, maxSize,
93
- dtype: keys.dtype,
94
- kShape: keys.shape, vShape: values.shape,
95
- kOffset, vOffset,
96
- kBytes: kBytesArr.length, vBytes: vBytesArr.length,
97
- });
98
- blobs.push(kBytesArr, vBytesArr);
99
- if (c instanceof KVCache) {
100
- keys.dispose();
101
- values.dispose();
102
- }
165
+ const s = snapshotCache(c, dataOffset);
166
+ entries.push(s.entry);
167
+ blobs.push(...s.blobs);
168
+ dataOffset = s.next;
103
169
  }
104
170
 
105
- const header: KvFileHeader = { tokens, caches: entries };
171
+ const header: KvFileHeader = {
172
+ formatVersion: 2, createdAt: Date.now(), ...meta, tokens, caches: entries,
173
+ };
106
174
  const headerJson = new TextEncoder().encode(JSON.stringify(header));
107
- const dataStart = alignUp(MAGIC.length + 8 + headerJson.length);
175
+ const dataStart = alignUp(PREFIX_LEN + headerJson.length);
108
176
 
109
- const fd = openSync(path, "w");
177
+ const tmp = `${path}.tmp`;
178
+ const fd = openSync(tmp, "w");
110
179
  try {
111
180
  const pre = new Uint8Array(dataStart);
112
181
  pre.set(new TextEncoder().encode(MAGIC), 0);
113
182
  const dv = new DataView(pre.buffer);
114
183
  dv.setUint32(MAGIC.length, headerJson.length, true);
115
184
  dv.setUint32(MAGIC.length + 4, dataStart, true);
116
- pre.set(headerJson, MAGIC.length + 8);
185
+ dv.setBigUint64(MAGIC.length + 8, BigInt(Bun.hash(headerJson)), true);
186
+ pre.set(headerJson, PREFIX_LEN);
117
187
  writeSync(fd, pre, 0, pre.length, 0);
118
188
 
119
189
  let blobIdx = 0;
120
- for (const e of entries) {
121
- writeSync(fd, blobs[blobIdx]!, 0, e.kBytes, dataStart + e.kOffset);
122
- writeSync(fd, blobs[blobIdx + 1]!, 0, e.vBytes, dataStart + e.vOffset);
123
- blobIdx += 2;
124
- }
125
- } finally {
190
+ for (const e of entries)
191
+ for (const slot of e.tensors)
192
+ writeSync(fd, blobs[blobIdx++]!, 0, slot.bytes, dataStart + slot.off);
193
+ fsyncSync(fd);
194
+ } catch (err) {
126
195
  closeSync(fd);
196
+ try { rmSync(tmp, { force: true }); } catch {}
197
+ throw err;
127
198
  }
199
+ closeSync(fd);
200
+ renameSync(tmp, path);
128
201
  }
129
202
 
130
- /** Read only the header (cheap — for prefix matching across many files). */
203
+ /** Read only the header (cheap — for prefix matching across many files).
204
+ * Always verifies the header hash; throws on any structural mismatch. */
131
205
  export function readKvHeader(path: string): KvFileHeader & { dataStart: number } {
132
206
  const fd = openSync(path, "r");
133
207
  try {
134
- const head = new Uint8Array(MAGIC.length + 8);
135
- require("node:fs").readSync(fd, head, 0, head.length, 0);
208
+ const head = new Uint8Array(PREFIX_LEN);
209
+ readSync(fd, head, 0, head.length, 0);
136
210
  if (new TextDecoder().decode(head.subarray(0, MAGIC.length)) !== MAGIC)
137
- throw new Error(`${path}: not an mlx-bun KV cache file`);
211
+ throw new Error(`${path}: not an mlx-bun KV v2 cache file`);
138
212
  const dv = new DataView(head.buffer);
139
213
  const len = dv.getUint32(MAGIC.length, true);
140
214
  const dataStart = dv.getUint32(MAGIC.length + 4, true);
215
+ const expectHash = dv.getBigUint64(MAGIC.length + 8, true);
141
216
  const body = new Uint8Array(len);
142
- require("node:fs").readSync(fd, body, 0, len, MAGIC.length + 8);
217
+ readSync(fd, body, 0, len, PREFIX_LEN);
218
+ if (BigInt(Bun.hash(body)) !== expectHash)
219
+ throw new Error(`${path}: header hash mismatch (truncated or corrupt)`);
143
220
  const header = JSON.parse(new TextDecoder().decode(body)) as KvFileHeader;
221
+ if (header.formatVersion !== 2)
222
+ throw new Error(`${path}: unsupported formatVersion ${header.formatVersion}`);
144
223
  return { ...header, dataStart };
145
224
  } finally {
146
225
  closeSync(fd);
@@ -149,37 +228,100 @@ export function readKvHeader(path: string): KvFileHeader & { dataStart: number }
149
228
 
150
229
  export interface LoadedKvCache {
151
230
  tokens: number[];
231
+ header: KvFileHeader;
152
232
  caches: Cache[];
153
233
  /** Keep referenced as long as the caches live. */
154
234
  mmap: MmapFile;
155
235
  }
156
236
 
157
- export function loadKvCache(path: string, model: Gemma4Model): LoadedKvCache {
237
+ export interface KvLoadExpect {
238
+ /** Reject on metadata mismatch (pass what the server is running). */
239
+ configFingerprint?: string;
240
+ tokenizerHash?: string;
241
+ ns?: string;
242
+ /** Verify every tensor hash (reads all bytes — defeats lazy fault-in;
243
+ * off by default, `--ssd-cache-verify`). */
244
+ verify?: boolean;
245
+ }
246
+
247
+ /** Reload zero-copy. `model` is anything with makeCache() — the entry count
248
+ * is validated against the DONOR cache list (model.layers.length was wrong
249
+ * for KV-shared models like e4b, whose makeCache() returns donors only). */
250
+ export function loadKvCache(
251
+ path: string,
252
+ model: { makeCache(): Cache[] },
253
+ expect: KvLoadExpect = {},
254
+ ): LoadedKvCache {
158
255
  const header = readKvHeader(path);
256
+ for (const key of ["configFingerprint", "tokenizerHash", "ns"] as const) {
257
+ if (expect[key] !== undefined && header[key] !== expect[key])
258
+ throw new Error(`${path}: ${key} mismatch (file ${header[key]}, expected ${expect[key]})`);
259
+ }
260
+ const proto = model.makeCache();
261
+ const cacheCount = proto.length;
262
+ for (const c of proto) c.dispose();
263
+ if (header.caches.length !== cacheCount)
264
+ throw new Error(`${path}: ${header.caches.length} cached layers but model has ${cacheCount}`);
265
+
159
266
  const mmap = MmapFile.open(path, "cow");
160
267
  const dataStart = header.dataStart;
268
+ const arr = (slot: TensorSlot): MlxArray => {
269
+ if (expect.verify) {
270
+ const view = mmap.view(dataStart + slot.off, slot.bytes);
271
+ if (hash64(view) !== slot.hash) {
272
+ mmap.unmap();
273
+ throw new Error(`${path}: tensor hash mismatch at offset ${slot.off}`);
274
+ }
275
+ }
276
+ return MlxArray.fromPointer(mmap.pointer(dataStart + slot.off), slot.shape, slot.dtype as Dtype);
277
+ };
278
+ const triple = (slots: TensorSlot[], at: number): ops.QuantizedTensor =>
279
+ ({ packed: arr(slots[at]!), scales: arr(slots[at + 1]!), biases: arr(slots[at + 2]!) });
161
280
 
162
281
  const caches: Cache[] = [];
163
- for (const e of header.caches) {
164
- const keys = MlxArray.fromPointer(
165
- mmap.pointer(dataStart + e.kOffset), e.kShape, e.dtype as Dtype,
166
- );
167
- const values = MlxArray.fromPointer(
168
- mmap.pointer(dataStart + e.vOffset), e.vShape, e.dtype as Dtype,
169
- );
170
- if (e.kind === "rotating") {
171
- const c = new RotatingKVCache(e.maxSize!);
172
- c.restoreState(keys, values, e.offset, e.idx!);
173
- caches.push(c);
174
- } else {
175
- const c = new KVCache();
176
- c.restoreState(keys, values, e.offset);
177
- caches.push(c);
282
+ try {
283
+ for (const e of header.caches) {
284
+ const t = e.tensors;
285
+ switch (e.kind) {
286
+ case "kv": {
287
+ const c = new KVCache();
288
+ c.restoreState(arr(t[0]!), arr(t[1]!), e.offset);
289
+ caches.push(c);
290
+ break;
291
+ }
292
+ case "rotating": {
293
+ const c = new RotatingKVCache(e.maxSize!);
294
+ c.restoreState(arr(t[0]!), arr(t[1]!), e.offset, e.idx!);
295
+ caches.push(c);
296
+ break;
297
+ }
298
+ case "qkv": {
299
+ const c = new QuantizedKVCache(e.groupSize!, e.bits!);
300
+ c.restoreState(triple(t, 0), triple(t, 3), e.offset);
301
+ caches.push(c);
302
+ break;
303
+ }
304
+ case "rotating-qkv": {
305
+ const c = new RotatingQuantizedKVCache(e.maxSize!, e.groupSize!, e.bits!);
306
+ c.restoreState(triple(t, 0), triple(t, 3), e.offset, e.idx!);
307
+ caches.push(c);
308
+ break;
309
+ }
310
+ case "ssm": {
311
+ const c = new SSMCache();
312
+ c.conv = arr(t[0]!);
313
+ c.recurrent = arr(t[1]!);
314
+ c.offset = e.offset;
315
+ caches.push(c);
316
+ break;
317
+ }
318
+ default:
319
+ throw new Error(`${path}: unknown cache kind ${(e as { kind: string }).kind}`);
320
+ }
178
321
  }
322
+ } catch (err) {
323
+ for (const c of caches) c.dispose();
324
+ throw err;
179
325
  }
180
- if (caches.length !== model.layers.length)
181
- throw new Error(
182
- `${path}: ${caches.length} cached layers but model has ${model.layers.length}`,
183
- );
184
- return { tokens: header.tokens, caches, mmap };
326
+ return { tokens: header.tokens, header, caches, mmap };
185
327
  }
package/src/lora.ts CHANGED
@@ -77,7 +77,7 @@ export function loadAdapterTensors(file: string): Map<string, MlxArray> {
77
77
  * PEFT writes lora_alpha + r (scale = alpha / r). Reference: mount.py.
78
78
  * `rsLora` (recorded at train time) means the effective per-layer scale is
79
79
  * α/√rank — the caller divides by √(that layer's rank). */
80
- async function readAdapterScale(dir: string): Promise<{ scale: number; rank: number | null; rsLora: boolean }> {
80
+ export async function readAdapterScale(dir: string): Promise<{ scale: number; rank: number | null; rsLora: boolean }> {
81
81
  for (const name of ["optiq_lora_config.json", "adapter_config.json"]) {
82
82
  const f = Bun.file(`${dir}/${name}`);
83
83
  if (await f.exists()) {
@@ -94,7 +94,7 @@ async function readAdapterScale(dir: string): Promise<{ scale: number; rank: num
94
94
  throw new Error(`no adapter_config.json in ${dir}`);
95
95
  }
96
96
 
97
- function adapterWeightsFile(dir: string): string {
97
+ export function adapterWeightsFile(dir: string): string {
98
98
  for (const name of ["adapters.safetensors", "adapter_model.safetensors"]) {
99
99
  const p = `${dir}/${name}`;
100
100
  if (existsSync(p)) return p;