mlx-bun 0.4.0 → 0.5.0

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 (158) hide show
  1. package/README.md +57 -32
  2. package/docs/README.md +55 -42
  3. package/docs/archive/investigations/benchmark-ledger-through-v0.4.0.md +3617 -0
  4. package/docs/archive/planning/release-notes-v0.5.0.md +37 -0
  5. package/docs/design/batching.md +220 -7
  6. package/docs/design/decode-speed-program.md +121 -66
  7. package/docs/design/generic-model-support.md +116 -0
  8. package/docs/design/kv-cache.md +380 -211
  9. package/docs/design/speculative-decoding.md +165 -26
  10. package/docs/design/turboquant.md +20 -7
  11. package/docs/design/unified-engine-frontier-plan.md +120 -13
  12. package/docs/planning/PRODUCT_ROADMAP.md +5 -6
  13. package/docs/planning/public-readiness-handoff.md +244 -0
  14. package/docs/planning/public-readiness.md +149 -0
  15. package/docs/reference/benchmarks.md +3463 -3489
  16. package/docs/reference/cli.md +62 -1
  17. package/docs/reference/distribution.md +29 -4
  18. package/docs/reference/environment.md +104 -8
  19. package/docs/reference/library-api.md +18 -4
  20. package/docs/reference/memory.md +1 -1
  21. package/docs/reference/models.md +17 -5
  22. package/docs/reference/server-api.md +181 -11
  23. package/docs/reference/server-config.md +232 -105
  24. package/package.json +2 -2
  25. package/src/audio/audiotoolbox.ts +112 -0
  26. package/src/audio/conformer.ts +8 -0
  27. package/src/audio/mic-capture.ts +98 -0
  28. package/src/audio/silero-vad.ts +379 -0
  29. package/src/audio/whisper-decode.ts +774 -0
  30. package/src/audio/whisper-fast.ts +732 -0
  31. package/src/audio/whisper-format.ts +73 -0
  32. package/src/audio/whisper-mel.ts +185 -0
  33. package/src/audio/whisper-timing.ts +274 -0
  34. package/src/audio/whisper-tokenizer.ts +239 -0
  35. package/src/audio/whisper-transcribe.ts +410 -0
  36. package/src/backends/mlx/autoregressive.ts +22 -1
  37. package/src/backends/mlx/batch-group.ts +111 -27
  38. package/src/backends/mlx/cache-layout.ts +3 -3
  39. package/src/backends/mlx/continuation-persistence.ts +5 -4
  40. package/src/backends/mlx/encoder-cache.ts +25 -0
  41. package/src/backends/mlx/fill-append.ts +21 -8
  42. package/src/backends/mlx/fill-group.ts +497 -0
  43. package/src/backends/mlx/gateway-binding.ts +56 -12
  44. package/src/backends/mlx/kv-maintenance.ts +30 -2
  45. package/src/backends/mlx/mixed-iteration.ts +62 -0
  46. package/src/backends/mlx/model-prompt.ts +46 -32
  47. package/src/backends/mlx/model-serving.ts +4 -3
  48. package/src/backends/mlx/paged-cache-codec.ts +27 -0
  49. package/src/backends/mlx/prefill-cohort.ts +15 -4
  50. package/src/backends/mlx/prefill-policy.ts +26 -0
  51. package/src/backends/mlx/prefill-rows.ts +57 -13
  52. package/src/backends/mlx/prompt-input.ts +29 -0
  53. package/src/backends/mlx/qwen-prompt-input.ts +40 -0
  54. package/src/backends/mlx/request-state-policy.ts +18 -5
  55. package/src/backends/mlx/rollback.ts +6 -3
  56. package/src/backends/mlx/serial-executor.ts +16 -7
  57. package/src/backends/mlx/speculative-group.ts +105 -26
  58. package/src/backends/mlx/speculative.ts +24 -7
  59. package/src/backends/mlx/target-layout-capability.ts +1 -1
  60. package/src/backends/mlx/transitioning-kv-rows.ts +8 -0
  61. package/src/cli.ts +322 -0
  62. package/src/contracts/execution.ts +10 -0
  63. package/src/contracts/object-cache.ts +8 -0
  64. package/src/contracts/pi-provider.ts +1 -1
  65. package/src/contracts/pi.ts +3 -1
  66. package/src/contracts/prefix-cache.ts +13 -3
  67. package/src/contracts/scheduling.ts +9 -0
  68. package/src/engine/execution-plan.ts +23 -12
  69. package/src/engine/execution-tasks.ts +44 -0
  70. package/src/engine/namespaced-cache.ts +13 -0
  71. package/src/engine/scheduler.ts +22 -2
  72. package/src/fill/fill-session.ts +14 -3
  73. package/src/generate.ts +28 -22
  74. package/src/grammar.ts +13 -0
  75. package/src/inference/prefill.ts +6 -0
  76. package/src/kv-scheme.ts +22 -0
  77. package/src/kv-store.ts +115 -79
  78. package/src/lab/README.md +5 -1
  79. package/src/lab/paged-kv/paged-attention.ts +117 -0
  80. package/src/lab/paged-kv/paged-kv-rows.ts +33 -5
  81. package/src/lab/paged-kv/paged-kv.ts +108 -42
  82. package/src/md-modules.d.ts +5 -0
  83. package/src/mlx/array.ts +37 -0
  84. package/src/mlx/ffi.ts +19 -0
  85. package/src/mlx/normalized-argmax.ts +116 -0
  86. package/src/mlx/ops.ts +97 -10
  87. package/src/model/batched-turboquant-kv.ts +6 -5
  88. package/src/model/delayed-quantized-kv.ts +3 -3
  89. package/src/model/delayed-rotating-quantized-kv.ts +1 -1
  90. package/src/model/delayed-turboquant-kv.ts +6 -4
  91. package/src/model/factory.ts +26 -0
  92. package/src/model/fingerprint.ts +12 -0
  93. package/src/model/full-prefill-row.ts +1 -1
  94. package/src/model/gemma4-base.ts +25 -6
  95. package/src/model/gemma4.ts +82 -2
  96. package/src/model/kv-attention-view.ts +4 -1
  97. package/src/model/profile.ts +29 -4
  98. package/src/model/quantized-append-attention.ts +31 -0
  99. package/src/model/qwen3-mrope.ts +7 -9
  100. package/src/model/qwen3_5.ts +99 -37
  101. package/src/model/support.ts +8 -0
  102. package/src/model/token-groups.ts +66 -0
  103. package/src/model/trellis-linear.ts +2 -2
  104. package/src/model/turboquant-codec.ts +6 -0
  105. package/src/model/whisper.ts +435 -0
  106. package/src/native/mic_capture.swift +92 -0
  107. package/src/native-pack.ts +6 -5
  108. package/src/pi-web.ts +9 -1
  109. package/src/prompt-cache.ts +265 -43
  110. package/src/runtime-config.ts +1 -5
  111. package/src/safetensors.ts +9 -2
  112. package/src/sampler.ts +5 -4
  113. package/src/serve/audio-routes.ts +323 -0
  114. package/src/serve/chat-request.ts +2 -0
  115. package/src/serve/chat-stage.ts +17 -12
  116. package/src/serve/completion-executor.ts +9 -1
  117. package/src/serve/discovery-routes.ts +17 -1
  118. package/src/serve/generated-token-history.ts +57 -0
  119. package/src/serve/generation-gateway.ts +52 -11
  120. package/src/serve/model-binding.ts +7 -1
  121. package/src/serve/preparation.ts +3 -3
  122. package/src/serve/prompt-response-trace.ts +14 -0
  123. package/src/serve/request-plan.ts +2 -0
  124. package/src/serve/request-prep.ts +38 -21
  125. package/src/serve/transcription-server.ts +64 -0
  126. package/src/serve/transcription-service.ts +502 -0
  127. package/src/server.ts +132 -164
  128. package/src/spec/assistant-rows.ts +1 -0
  129. package/src/spec/deepspec-rows.ts +1 -1
  130. package/src/spec/dflash-source.ts +1 -1
  131. package/src/spec/glm52-mtp-source.ts +1 -0
  132. package/src/spec/ngram-source.ts +27 -5
  133. package/src/spec/projected-draft-rows.ts +2 -2
  134. package/src/spec/qwen-mtp-rows.ts +11 -0
  135. package/src/spec/qwen-mtp-source.ts +3 -0
  136. package/src/spec/serve-loop.ts +5 -3
  137. package/src/spec/source.ts +14 -1
  138. package/src/spec/two-model-rows.ts +1 -0
  139. package/src/spec/two-model.ts +2 -2
  140. package/src/ssd-cache.ts +76 -17
  141. package/src/ssd-durability.ts +20 -22
  142. package/src/storage/host-buffer.ts +24 -0
  143. package/src/storage/kv-writer.ts +83 -0
  144. package/src/storage/kv-writer.worker.js +264 -0
  145. package/src/storage/retention-policy.ts +37 -0
  146. package/src/tiered-prompt-cache.ts +45 -0
  147. package/src/vision/embedder.ts +8 -0
  148. package/src/vision/prompt.ts +101 -65
  149. package/src/vision/qwen3vl-prompt.ts +101 -64
  150. package/src/vision/qwen3vl-tower.ts +11 -0
  151. package/src/vision/siglip.ts +8 -0
  152. package/src/web/app.html +12 -0
  153. package/src/web/app.js +160 -0
  154. package/src/web/src/chat.ts +4 -1
  155. package/src/web/src/composer.ts +3 -0
  156. package/src/web/src/voice.ts +141 -0
  157. package/src/web/sw.js +1 -1
  158. /package/docs/{planning → archive/planning}/release-notes-v0.4.0.md +0 -0
@@ -17,16 +17,16 @@ import { withResource, cleanupFailure, disposeResources, ownResource } from "./e
17
17
  //
18
18
  // TIERING (Layer 0, unified-engine plan): when a ColdTier is attached,
19
19
  // take() itself runs the two-tier dance — RAM peek vs cold find, restore
20
- // (zero-copy mmap) + trim when the cold tier holds a strictly longer
20
+ // (streamed copy) + trim when the cold tier holds a strictly longer
21
21
  // prefix — so EVERY consumer (the serial lane, the batch scheduler, future
22
22
  // prefix sharing) gets SSD restores through the same take()/put() it
23
23
  // already calls. Eviction spills to the tier (the #spill hook); idle
24
- // entries DEMOTE to it (demoteIdle — free the GPU memory, keep the prefix
25
- // reachable); onPut lets the server schedule its debounced write-behind
26
- // snapshot for both lanes.
24
+ // entries demote to it when policy permits. TieredPromptCache owns queued
25
+ // persistence and promotes restored prefixes into RAM.
27
26
 
28
27
  import type { Cache } from "./model/gemma4";
29
28
  import type { PrefixCache, PrefixCacheHit } from "./contracts/prefix-cache";
29
+ import { LruRetention, type RetentionPolicy, type RetentionCandidate } from "./storage/retention-policy";
30
30
  import { cloneKvCaches } from "./kv-store";
31
31
 
32
32
  /** Reference-counted release: wraps an entry's `retain` (e.g. an mmap
@@ -64,10 +64,14 @@ export interface ColdTier {
64
64
  /** Longest stored usable prefix for prompt/ns — index-only, no I/O.
65
65
  * `handle` is the tier's opaque entry token, passed back to restore. */
66
66
  find(prompt: number[], ns: string): { prefixLen: number; handle: unknown } | null;
67
- /** Materialize a found entry as GPU-visible caches (zero-copy COW mmap;
68
- * pages fault in lazily). `retain` must run after the caches are
69
- * disposed (it unmaps the backing file). Null on any failure. */
67
+ /** Restore GPU-visible state. The default SSD backend copies into owned
68
+ * arrays. An optional backing release runs after consumers dispose their
69
+ * arrays. Null on any failure. */
70
70
  restore(handle: unknown): { tokens: number[]; caches: Cache[]; attachments?: CheckpointAttachment[]; retain: () => void } | null;
71
+ findExact?(tokens: number[], ns: string): { prefixLen: number; handle: unknown } | null;
72
+ restoreAsync?(handle: unknown): Promise<ReturnType<ColdTier["restore"]>>;
73
+ /** Tensor objects have no target layers; restore with their own schema. */
74
+ restoreObjectAsync?(handle: unknown): Promise<ReturnType<ColdTier["restore"]>>;
71
75
  /** Borrow live caches and finish persistence before returning. A false
72
76
  * result prevents a required demotion. */
73
77
  store(tokens: number[], caches: Cache[], ns: string, attachments?: CheckpointAttachment[]): boolean | void;
@@ -121,7 +125,7 @@ export function commonPrefixLength(a: number[], b: number[]): number {
121
125
  return i;
122
126
  }
123
127
 
124
- interface EntryRecord {
128
+ interface EntryRecord extends RetentionCandidate {
125
129
  entry: PromptCacheEntry;
126
130
  bytes: number;
127
131
  lastUsed: number;
@@ -129,12 +133,98 @@ interface EntryRecord {
129
133
  /** Ref-counted retain: the donor holds one share (entry.retain); every
130
134
  * clone handed out by take() holds another. */
131
135
  share: { acquire(): () => void };
136
+ resident: boolean;
137
+ sessions: number;
132
138
  }
133
139
 
134
140
  export class PromptCache implements PrefixCache<Cache[], CheckpointAttachment[]> {
141
+ /** Exact tensor objects share this store's byte budget, eviction and writer.
142
+ * Empty token history denotes no conversation; the private namespace is the
143
+ * complete object identity. No fabricated language tokens enter the index. */
144
+ readonly #objects: import("./contracts/object-cache").ObjectCache<CheckpointAttachment[]> = {
145
+ take: key => this.#takeObject(`object:${key}`),
146
+ put: (key, value) => this.put([], [], `object:${key}`, undefined, value),
147
+ };
148
+
149
+ get objects(): import("./contracts/object-cache").ObjectCache<CheckpointAttachment[]> | undefined {
150
+ return this.maxBytes > 0 || this.#cold ? this.#objects : undefined;
151
+ }
152
+
153
+ async #takeObject(ns: string): Promise<{ value: CheckpointAttachment[]; dispose(): void } | null> {
154
+ let entry = this.findExact([], ns);
155
+ if (entry) this.objectHits++;
156
+ else this.objectMisses++;
157
+ if (!entry) {
158
+ const cold = this.#cold;
159
+ const hit = cold?.findExact?.([], ns);
160
+ if (!hit || !cold?.restoreObjectAsync) return null;
161
+ const epoch = this.#epoch;
162
+ const loaded = await cold.restoreObjectAsync(hit.handle);
163
+ if (!loaded) return null;
164
+ this.objectRestores++;
165
+ if (epoch !== this.#epoch) { this.#disposeEntry({ ...loaded, ns }, false); return null; }
166
+ // Another request may have published this immutable object during IO.
167
+ entry = this.findExact([], ns);
168
+ if (entry) this.#disposeEntry({ ...loaded, ns }, false);
169
+ else {
170
+ this.#publishingPrefetch = true;
171
+ try { this.put([], loaded.caches, ns, loaded.retain, loaded.attachments); }
172
+ catch (error) { this.#disposeEntry({ ...loaded, ns }, false); throw error; }
173
+ finally { this.#publishingPrefetch = false; }
174
+ entry = this.findExact([], ns);
175
+ }
176
+ }
177
+ if (!entry) return null;
178
+ const borrowed = this.#borrow(this.#records.get(entry)!, 0, ns);
179
+ try { this.evictToBudget(); this.reclaim(); }
180
+ catch (error) { this.#disposeEntry(borrowed, false); throw error; }
181
+ let released = false;
182
+ return { value: borrowed.attachments ?? [], dispose: () => {
183
+ if (released) return; released = true;
184
+ this.#disposeEntry(borrowed, false);
185
+ } };
186
+ }
187
+
135
188
  readonly maxBytes: number;
136
189
  #entries: EntryRecord[] = [];
137
190
  #clock = 0;
191
+ #sessions = new Map<string, Map<string, EntryRecord>>();
192
+ #records = new WeakMap<PromptCacheEntry, EntryRecord>();
193
+ sessionHits = 0;
194
+ sessionMisses = 0;
195
+ prefixScans = 0;
196
+ objectHits = 0;
197
+ objectMisses = 0;
198
+ objectRestores = 0;
199
+
200
+ closeSession(sessionId: string): void {
201
+ for (const record of this.#sessions.get(sessionId)?.values() ?? []) record.sessions--;
202
+ this.#sessions.delete(sessionId);
203
+ }
204
+
205
+ #associate(sessionId: string | undefined, ns: string, record: EntryRecord): void {
206
+ if (!sessionId) return;
207
+ let entries = this.#sessions.get(sessionId);
208
+ if (!entries) this.#sessions.set(sessionId, entries = new Map());
209
+ const old = entries.get(ns);
210
+ if (old === record) return;
211
+ if (old) old.sessions--;
212
+ entries.set(ns, record); record.sessions++;
213
+ }
214
+
215
+ #sessionRecord(prompt: number[], ns: string, sessionId?: string): EntryRecord | undefined {
216
+ const record = sessionId ? this.#sessions.get(sessionId)?.get(ns) : undefined;
217
+ // A session labels affinity. Full-history callers may edit or branch;
218
+ // incompatible history uses ordinary lookup, never a request refusal.
219
+ return record && record.entry.tokens.length < prompt.length &&
220
+ commonPrefixLength(record.entry.tokens, prompt) === record.entry.tokens.length ? record : undefined;
221
+ }
222
+
223
+ #coldHit(prompt: number[], ns: string, sessionId?: string) {
224
+ const known = this.#sessionRecord(prompt, ns, sessionId);
225
+ return (known && this.#cold?.findExact?.(known.entry.tokens, ns)) || this.#cold?.find(prompt, ns) || null;
226
+ }
227
+
138
228
  hits = 0;
139
229
  misses = 0;
140
230
  demotions = 0;
@@ -145,9 +235,98 @@ export class PromptCache implements PrefixCache<Cache[], CheckpointAttachment[]>
145
235
  readonly #spillSync: ((entry: PromptCacheEntry) => void) | null;
146
236
  /** Optional cold tier: take() tiers over it, demoteIdle() spills into it. */
147
237
  readonly #cold: ColdTier | null;
148
- /** Fired after every successful put() — the server hangs its debounced
149
- * write-behind SSD snapshot here so BOTH lanes' entries persist. */
238
+ /** Observer after publication, independent of persistence policy. */
150
239
  onPut: ((tokens: number[], ns: string) => void) | null = null;
240
+ /** The backend measures pressure; this store chooses which optional
241
+ * snapshots to release. No storage I/O runs at a reclaim boundary. */
242
+ pressure: { overBudget(): boolean; releasePending?(): void } | null = null;
243
+ /** Tier policy: a pending write is not a lower-tier copy. This controls
244
+ * eviction only; persistence completion does not itself remove an entry. */
245
+ canEvict: ((entry: PromptCacheEntry) => boolean) | null = null;
246
+ protected promoteRestores = false;
247
+ retention: RetentionPolicy = new LruRetention();
248
+ #prefetches = new Map<unknown, Promise<PromptCacheEntry | null>>();
249
+ #pins = new Map<PromptCacheEntry, number>();
250
+ #publishingPrefetch = false;
251
+ #epoch = 0;
252
+
253
+ /** Start cold reads during request preparation, coalescing identical IO.
254
+ * Active request interest keeps a just-restored donor resident until the
255
+ * caller has consumed it. Cancellation releases interest through the lease. */
256
+ async prefetch(prompt: number[], ns = "", sessionId?: string): Promise<() => void> {
257
+ const known = this.#sessionRecord(prompt, ns, sessionId);
258
+ if (known?.resident) return this.#pin(known.entry);
259
+ const cold = this.#cold;
260
+ const hit = cold?.restoreAsync ? this.#coldHit(prompt, ns, sessionId) : null;
261
+ if (!cold?.restoreAsync || !hit || (!known && hit.prefixLen <= this.peekPrefixLen(prompt, ns))) return () => {};
262
+ let pending = this.#prefetches.get(hit.handle);
263
+ if (!pending) {
264
+ const epoch = this.#epoch;
265
+ pending = (async () => {
266
+ const loaded = await cold.restoreAsync!(hit.handle);
267
+ if (!loaded) return null;
268
+ if (epoch !== this.#epoch) { this.#disposeEntry({ ...loaded, ns }, false); return null; }
269
+ this.#publishingPrefetch = true;
270
+ try { this.put(loaded.tokens, loaded.caches, ns, loaded.retain, loaded.attachments); }
271
+ catch (error) { this.#disposeEntry({ ...loaded, ns }, false); throw error; }
272
+ finally { this.#publishingPrefetch = false; }
273
+ return this.findExact(loaded.tokens, ns);
274
+ })();
275
+ this.#prefetches.set(hit.handle, pending);
276
+ }
277
+ let entry: PromptCacheEntry | null;
278
+ try { entry = await pending; }
279
+ finally { if (this.#prefetches.get(hit.handle) === pending) this.#prefetches.delete(hit.handle); }
280
+ if (!entry) return () => {};
281
+ this.#associate(sessionId, ns, this.#records.get(entry)!);
282
+ return this.#pin(entry);
283
+ }
284
+
285
+ #pin(entry: PromptCacheEntry): () => void {
286
+ this.#pins.set(entry, (this.#pins.get(entry) ?? 0) + 1);
287
+ let released = false;
288
+ return () => {
289
+ if (released) return; released = true;
290
+ const count = this.#pins.get(entry!) ?? 1;
291
+ if (count === 1) this.#pins.delete(entry!); else this.#pins.set(entry!, count - 1);
292
+ this.evictToBudget(); this.reclaim();
293
+ };
294
+ }
295
+
296
+ #victim(): EntryRecord | undefined {
297
+ const candidates = this.#entries.filter(record => !this.#pins.has(record.entry));
298
+ const unrelated = candidates.filter(record => record.sessions === 0);
299
+ return candidates.length ? this.retention.victim(unrelated.length ? unrelated : candidates) : undefined;
300
+ }
301
+
302
+ evictToBudget(): void {
303
+ if (this.#publishingPrefetch) return;
304
+ while (this.totalBytes > this.maxBytes) {
305
+ if (!this.#entries.length) break;
306
+ const oldest = this.#victim();
307
+ if (!oldest) break;
308
+ if (this.canEvict && !this.canEvict(oldest.entry)) break;
309
+ this.retention.evicted(oldest);
310
+ this.#entries = this.#entries.filter(r => r !== oldest);
311
+ try { this.#disposeEntry(oldest.entry, !this.canEvict); }
312
+ catch (error) { console.warn(`[prompt-cache] entry cleanup failed: ${error}`); }
313
+ }
314
+ }
315
+
316
+ reclaim(): void {
317
+ const pressure = this.pressure;
318
+ if (!pressure?.overBudget()) return;
319
+ pressure.releasePending?.();
320
+ while (this.#entries.length && pressure.overBudget()) {
321
+ const oldest = this.#victim();
322
+ if (!oldest) break;
323
+ if (this.canEvict && !this.canEvict(oldest.entry)) break;
324
+ this.retention.evicted(oldest);
325
+ this.#entries = this.#entries.filter(r => r !== oldest);
326
+ this.#disposeEntry(oldest.entry, false);
327
+ this.demotions++;
328
+ }
329
+ }
151
330
 
152
331
  /** Zero-copy view cloner — injectable so model-free tests can stub it. */
153
332
  readonly #clone: (caches: Cache[]) => Cache[];
@@ -168,6 +347,8 @@ export class PromptCache implements PrefixCache<Cache[], CheckpointAttachment[]>
168
347
  }
169
348
 
170
349
  #disposeEntry(entry: PromptCacheEntry, spillFirst: boolean): void {
350
+ const record = this.#records.get(entry);
351
+ if (record) record.resident = false;
171
352
  if (spillFirst) {
172
353
  if (this.#spillOwned) {
173
354
  // NON-BLOCKING spill: clone BEFORE disposing (clones are zero-copy
@@ -221,16 +402,17 @@ export class PromptCache implements PrefixCache<Cache[], CheckpointAttachment[]>
221
402
  relievePressure(overBudget: () => boolean): number {
222
403
  let count = 0;
223
404
  while (this.#entries.length && overBudget()) {
224
- let oldest = 0;
225
- for (let i = 1; i < this.#entries.length; i++)
226
- if (this.#entries[i]!.lastUsed < this.#entries[oldest]!.lastUsed) oldest = i;
227
- const { entry } = this.#entries[oldest]!;
405
+ const victim = this.#victim();
406
+ if (!victim) break;
407
+ const oldest = this.#entries.indexOf(victim);
408
+ const { entry } = victim;
228
409
  // Keep the entry resident if persistence throws. A failed SSD write
229
410
  // must not be presented as a successful demotion.
230
411
  if (this.#cold) {
231
412
  if (this.#cold.store(entry.tokens, entry.caches, entry.ns, entry.attachments) === false) break;
232
413
  }
233
414
  else this.#spillSync?.(entry);
415
+ this.retention.evicted(victim);
234
416
  this.#entries.splice(oldest, 1);
235
417
  this.#disposeEntry(entry, false);
236
418
  this.demotions++;
@@ -251,12 +433,30 @@ export class PromptCache implements PrefixCache<Cache[], CheckpointAttachment[]>
251
433
  * wrapped — those entries only match in full.
252
434
  *
253
435
  * Tier order: the cold tier wins only with a STRICTLY longer usable
254
- * prefix (its restore costs a mmap + lazy fault-in; RAM is free). A
436
+ * prefix (restore reads storage and materializes native arrays). A
255
437
  * cold entry with an untrimmable divergent tail (ring post-wrap, SSM)
256
438
  * is dropped and the RAM candidate (or a fresh prefill) serves.
257
439
  * hits/misses count RAM candidacy only (cold restores are counted by
258
440
  * the tier itself), preserving the /stats meaning. */
259
- take(prompt: number[], ns = ""): PromptCacheEntry | null {
441
+ take(prompt: number[], ns = "", sessionId?: string): PromptCacheEntry | null {
442
+ const hit = this.#take(prompt, ns, sessionId);
443
+ // Keep the chosen state's owned views before releasing optional donors.
444
+ // This also covers a cold restore materializing its bytes at admission.
445
+ try { this.reclaim(); return hit; }
446
+ catch (error) {
447
+ if (hit) this.#disposeEntry(hit, false);
448
+ throw error;
449
+ }
450
+ }
451
+
452
+ #take(prompt: number[], ns: string, sessionId?: string): PromptCacheEntry | null {
453
+ const known = this.#sessionRecord(prompt, ns, sessionId);
454
+ if (known?.resident) {
455
+ this.sessionHits++; this.hits++;
456
+ return this.#borrow(known, known.entry.tokens.length, ns);
457
+ }
458
+ if (sessionId) this.sessionMisses++;
459
+ this.prefixScans++;
260
460
  let bestIdx = -1;
261
461
  let bestLen = 0;
262
462
  for (let i = 0; i < this.#entries.length; i++) {
@@ -276,7 +476,7 @@ export class PromptCache implements PrefixCache<Cache[], CheckpointAttachment[]>
276
476
  // candidate stays put UNTOUCHED (the old serial-lane flow trimmed it
277
477
  // before comparing — a needless entry degradation, gone now).
278
478
  if (this.#cold) {
279
- const hit = this.#cold.find(prompt, ns);
479
+ const hit = this.#coldHit(prompt, ns, sessionId);
280
480
  if (hit && hit.prefixLen > bestLen) {
281
481
  const loaded = this.#cold.restore(hit.handle);
282
482
  if (loaded) {
@@ -296,7 +496,27 @@ export class PromptCache implements PrefixCache<Cache[], CheckpointAttachment[]>
296
496
  const tokens = entry.tokens.slice(0, hit.prefixLen);
297
497
  return { ...owner.transfer(), tokens, ns };
298
498
  });
299
- if (result) return result;
499
+ if (result) {
500
+ if (this.promoteRestores) {
501
+ // Cache the restored prefix immediately. A second request can
502
+ // reuse RAM even before this borrower finishes its generation.
503
+ const share = makeSharedRetain(result.retain);
504
+ result.retain = share.acquire();
505
+ const retained = share.acquire();
506
+ let clones: Cache[] = [];
507
+ let attachments: CheckpointAttachment[] | undefined;
508
+ try {
509
+ clones = this.#clone(result.caches);
510
+ attachments = cloneAttachments(result.attachments);
511
+ this.put([...result.tokens], clones, ns, retained, attachments, sessionId);
512
+ } catch (error) {
513
+ cleanupFailure(error, () => disposeResources([...clones,
514
+ { dispose: () => disposeAttachments(attachments) },
515
+ { dispose: retained }, { dispose: () => this.#disposeEntry(result, false) }]));
516
+ }
517
+ }
518
+ return result;
519
+ }
300
520
  }
301
521
  }
302
522
  }
@@ -312,8 +532,14 @@ export class PromptCache implements PrefixCache<Cache[], CheckpointAttachment[]>
312
532
  // consume-and-trim semantics CANNIBALIZED donors: agent B borrowing
313
533
  // agent A's 2k system prompt destroyed A's 10k entry to do it.
314
534
  const rec = this.#entries[bestIdx]!;
535
+ this.#associate(sessionId, ns, rec);
536
+ return this.#borrow(rec, bestLen, ns);
537
+ }
538
+
539
+ #borrow(rec: EntryRecord, bestLen: number, ns: string): PromptCacheEntry {
315
540
  rec.lastUsed = ++this.#clock;
316
541
  rec.lastUsedMs = Date.now();
542
+ rec.uses++; rec.cost = bestLen; this.retention.accessed(rec);
317
543
  // Lend immutable views for every backend state. RAM retention and SSD
318
544
  // durability remain cache responsibilities while the caller extends its
319
545
  // own views. This keeps synchronous persistence out of a RAM hit.
@@ -350,9 +576,7 @@ export class PromptCache implements PrefixCache<Cache[], CheckpointAttachment[]>
350
576
  return best;
351
577
  }
352
578
 
353
- /** Read-only exact-token lookup (the write-behind snapshot path: the
354
- * entry stays owned by the cache; the caller only reads array state,
355
- * under the gateway lock so no generation is mutating it). */
579
+ /** Read-only lookup of an immutable, cache-owned checkpoint. */
356
580
  findExact(tokens: number[], ns = ""): PromptCacheEntry | null {
357
581
  for (const { entry: e } of this.#entries) {
358
582
  if (e.ns !== ns || e.tokens.length !== tokens.length) continue;
@@ -364,7 +588,7 @@ export class PromptCache implements PrefixCache<Cache[], CheckpointAttachment[]>
364
588
  /** Insert (or reinsert) an entry; evicts LRU entries over the byte cap
365
589
  * (spilling each to the cold tier first, when one is attached). If the
366
590
  * entry itself exceeds the cap it is spilled + disposed, not stored. */
367
- put(tokens: number[], caches: Cache[], ns = "", retain?: () => void, attachments?: CheckpointAttachment[]): void {
591
+ put(tokens: number[], caches: Cache[], ns = "", retain?: () => void, attachments?: CheckpointAttachment[], sessionId?: string): void {
368
592
  // All cache-provided validation runs before ownership is adopted.
369
593
  const bytes = cacheBytes(caches) + attachmentBytes(attachments);
370
594
  const trimmable = !attachments?.length && caches.every((cache) => cache.isTrimmable());
@@ -379,7 +603,7 @@ export class PromptCache implements PrefixCache<Cache[], CheckpointAttachment[]>
379
603
  }
380
604
  };
381
605
  const entry: PromptCacheEntry = { tokens, caches, ns, retain, attachments };
382
- if (bytes > this.maxBytes) {
606
+ if (bytes > this.maxBytes && !this.canEvict) {
383
607
  discard(entry, true);
384
608
  return;
385
609
  }
@@ -387,8 +611,11 @@ export class PromptCache implements PrefixCache<Cache[], CheckpointAttachment[]>
387
611
  // shares; the entry's own retain is the donor's share.
388
612
  const share = makeSharedRetain(retain);
389
613
  entry.retain = share.acquire();
390
- const rec: EntryRecord = { entry, bytes, lastUsed: ++this.#clock, lastUsedMs: Date.now(), share };
614
+ const rec: EntryRecord = { entry, bytes, cost: tokens.length, uses: 1, priority: 0, lastUsed: ++this.#clock, lastUsedMs: Date.now(), share, resident: true, sessions: 0 };
615
+ this.retention.accessed(rec);
391
616
  this.#entries.push(rec);
617
+ this.#records.set(entry, rec);
618
+ this.#associate(sessionId, ns, rec);
392
619
  // Exact duplicates (equal tokens) are redundant REGARDLESS of
393
620
  // trimmability — the new entry serves exactly the matches the old one
394
621
  // did (mlx-lm's trie replaces in place). Without this, every gemma ctx
@@ -398,6 +625,12 @@ export class PromptCache implements PrefixCache<Cache[], CheckpointAttachment[]>
398
625
  if (old === rec || old.entry.ns !== ns) continue;
399
626
  if (old.entry.tokens.length !== tokens.length) continue;
400
627
  if (commonPrefixLength(old.entry.tokens, tokens) !== tokens.length) continue;
628
+ // Equivalent publication replaces the cache object, not the checkpoint
629
+ // identity. Every session sharing it follows the replacement.
630
+ if (old.sessions) for (const sessions of this.#sessions.values()) {
631
+ if (sessions.get(ns) !== old) continue;
632
+ sessions.set(ns, rec); old.sessions--; rec.sessions++;
633
+ }
401
634
  this.#entries = this.#entries.filter((r) => r !== old);
402
635
  discard(old.entry, false);
403
636
  }
@@ -419,33 +652,20 @@ export class PromptCache implements PrefixCache<Cache[], CheckpointAttachment[]>
419
652
  discard(old.entry, false);
420
653
  }
421
654
  }
422
- while (this.totalBytes > this.maxBytes && this.#entries.length > 1) {
423
- let lruIdx = 0;
424
- for (let i = 1; i < this.#entries.length; i++)
425
- if (this.#entries[i]!.lastUsed < this.#entries[lruIdx]!.lastUsed) lruIdx = i;
426
- const [evicted] = this.#entries.splice(lruIdx, 1);
427
- discard(evicted!.entry, true);
428
- }
655
+ this.evictToBudget();
429
656
  try { this.onPut?.(tokens, ns); } catch {}
430
657
  }
431
658
 
432
- /** Layer-0 idle demotion: spill every entry unused for `idleMs` to the
433
- * cold tier and FREE its GPU memory. The prefix stays reachable — the
434
- * next take() restores it via zero-copy mmap (~0.25 s for a 13.7k-token
435
- * entry, vs a 12 s re-prefill) — but between bursts the RAM tier drains
436
- * toward empty, returning unified memory to the system. Caller must
437
- * hold the generation lock (entries' arrays are disposed here) — with
438
- * a spillOwned sink the lock covers only the zero-copy clone; the
439
- * write runs off-lock and the buffers free when it settles. No-op
440
- * without a cold tier — demotion without a place to demote TO would
441
- * just be data loss. Returns entries demoted. */
659
+ /** Evict entries older than idleMs. Tiered residency policy first ensures
660
+ * SSD coverage. No-op without a cold tier. Returns entries removed from RAM. */
442
661
  demoteIdle(idleMs: number, now = Date.now()): number {
443
662
  if (!this.#cold) return 0;
444
663
  let n = 0;
445
664
  for (const rec of [...this.#entries]) {
446
- if (now - rec.lastUsedMs < idleMs) continue;
665
+ if (now - rec.lastUsedMs < idleMs || this.#pins.has(rec.entry)) continue;
666
+ if (this.canEvict && !this.canEvict(rec.entry)) continue;
447
667
  this.#entries = this.#entries.filter((r) => r !== rec);
448
- this.#disposeEntry(rec.entry, true); // spill-first, then dispose
668
+ this.#disposeEntry(rec.entry, !this.canEvict); // persisted entries need no duplicate write
449
669
  this.demotions++;
450
670
  n++;
451
671
  }
@@ -453,6 +673,8 @@ export class PromptCache implements PrefixCache<Cache[], CheckpointAttachment[]>
453
673
  }
454
674
 
455
675
  clear(): void {
676
+ this.#sessions.clear();
677
+ this.#epoch++;
456
678
  const entries = this.#entries;
457
679
  this.#entries = [];
458
680
  disposeResources(entries.map(({ entry }) => ({ dispose: () => this.#disposeEntry(entry, false) })));
@@ -52,11 +52,7 @@ export function runtimeValue(name: RuntimeKey): string | undefined {
52
52
  return runtimeConfig().value(name);
53
53
  }
54
54
 
55
- /** On/off env flag: an explicit "1"/"0" always wins; otherwise `defaultOn`.
56
- * (Formerly src/flags.ts `flagOn` — same resolver, one module.) */
57
- export function flagOn(name: RuntimeKey, defaultOn: boolean): boolean {
58
- return runtimeFlag(name, defaultOn);
59
- }
55
+ /** An explicit "1"/"0" wins; other values retain the caller's default. */
60
56
  export function runtimeFlag(name: RuntimeKey, defaultOn: boolean): boolean {
61
57
  return runtimeConfig().flag(name, defaultOn);
62
58
  }
@@ -127,8 +127,15 @@ export class ShardedSafetensors {
127
127
  self.tensorToFile.set(tensor, sf);
128
128
  }
129
129
  } else {
130
- const sf = SafetensorsFile.open(`${modelDir}/model.safetensors`);
131
- self.files.set("model.safetensors", sf);
130
+ // Single-file artifacts: HF's model.safetensors, or mlx-whisper's
131
+ // weights.safetensors (mlx-community/whisper-*).
132
+ const file = (await Bun.file(`${modelDir}/model.safetensors`).exists())
133
+ ? "model.safetensors"
134
+ : (await Bun.file(`${modelDir}/weights.safetensors`).exists())
135
+ ? "weights.safetensors"
136
+ : "model.safetensors";
137
+ const sf = SafetensorsFile.open(`${modelDir}/${file}`);
138
+ self.files.set(file, sf);
132
139
  for (const name of sf.tensors.keys()) self.tensorToFile.set(name, sf);
133
140
  }
134
141
  return self;
package/src/sampler.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  // and the XTC-off path is unchanged.)
10
10
 
11
11
  import { Dtype } from "./mlx/ffi";
12
+ import { normalizedArgmax } from "./mlx/normalized-argmax";
12
13
  import { MlxArray } from "./mlx/array";
13
14
  import * as ops from "./mlx/ops";
14
15
  import type { TokenLogprobs } from "./contracts/generation";
@@ -826,6 +827,7 @@ export function makeStepSampler(
826
827
  const captureSelected = config.captureSelectedLogprob === true;
827
828
  const captureTop = Math.max(0, config.captureTopLogprobs ?? 0);
828
829
  const capture = captureSelected || captureTop > 0;
830
+ const greedyWithoutMetadata = (options.temperature ?? 0) === 0 && !options.curve && !capture && !config.sampler;
829
831
  let history: MlxArray | null = null;
830
832
 
831
833
  const seedHistory = (tokens: readonly number[]): void => {
@@ -888,6 +890,8 @@ export function makeStepSampler(
888
890
  current = next;
889
891
  ownsCurrent = current !== input;
890
892
  }
893
+ if (greedyWithoutMetadata)
894
+ return { token: normalizedArgmax(current), extras: null };
891
895
  logprobs = toLogprobs(current);
892
896
  if (ownsCurrent) current.dispose();
893
897
  current = input;
@@ -994,10 +998,7 @@ export function makeStepSampler(
994
998
  /** Stateless sampling across rows or verification positions. Preserve the
995
999
  * normalized-score argmax, including rounding-created ties. */
996
1000
  export const independentGreedySampling = Object.freeze({
997
- sample(logits: MlxArray): MlxArray {
998
- using logprobs = toLogprobs(logits);
999
- return ops.argmaxAxis(logprobs, -1);
1000
- },
1001
+ sample: normalizedArgmax,
1001
1002
  });
1002
1003
 
1003
1004
  /** logits [..., V] → logprobs [..., V] (logits - logsumexp). */