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/config.ts CHANGED
@@ -186,6 +186,9 @@ export async function loadModelConfig(modelDir: string): Promise<ModelConfig> {
186
186
  const t = isDiffusion ? { ...diffusionGemmaRawDefaults(), ...baseT } : baseT;
187
187
  const isLlama = modelType === "llama";
188
188
  const isQwen35 = typeof modelType === "string" && modelType.startsWith("qwen3_5");
189
+ // Plain Qwen3 (Qwen3ForCausalLM, e.g. Qwen3-Embedding): a flat HF config with
190
+ // a scalar rope_theta and no rope_parameters map — handled like llama below.
191
+ const isQwen3 = modelType === "qwen3";
189
192
  // Qwen3.5 rope_parameters is a flat dict ({type, rope_theta, mrope_section,
190
193
  // partial_rotary_factor}), not the gemma per-attention-type map — and
191
194
  // type "default" means plain partial nn.RoPE (mrope_section ignored for text).
@@ -234,7 +237,7 @@ export async function loadModelConfig(modelDir: string): Promise<ModelConfig> {
234
237
  }
235
238
  : t.rope_parameters
236
239
  ? parseRope(t.rope_parameters)
237
- : isLlama
240
+ : isLlama || isQwen3
238
241
  ? {
239
242
  full_attention: {
240
243
  ropeTheta: t.rope_theta ?? 10000,
package/src/download.ts CHANGED
@@ -15,7 +15,10 @@
15
15
  // Files download sequentially (decode is bandwidth-bound, the network
16
16
  // rarely is; resumability matters more than parallelism here).
17
17
 
18
- import { mkdirSync, existsSync, statSync, createWriteStream, renameSync, rmSync, symlinkSync, readFileSync } from "node:fs";
18
+ import {
19
+ mkdirSync, existsSync, statSync, createWriteStream, renameSync, rmSync,
20
+ symlinkSync, readFileSync, openSync, closeSync, readSync, writeSync,
21
+ } from "node:fs";
19
22
  import { dirname, join } from "node:path";
20
23
  import { DEFAULT_HUB } from "./registry";
21
24
 
@@ -54,6 +57,63 @@ export function hfToken(): string | null {
54
57
  }
55
58
  }
56
59
 
60
+ /** 401/403 almost always means a gated/private repo, not a broken network —
61
+ * say so instead of leaving the user staring at a bare status code. */
62
+ function authHint(status: number): string {
63
+ return status === 401 || status === 403
64
+ ? " — gated repo? run `hf auth login` or set HF_TOKEN"
65
+ : "";
66
+ }
67
+
68
+ // --- download lockfile -----------------------------------------------------
69
+ // Two writers on one blob (a foreground `mlx-bun get` racing the server's
70
+ // background auto-download) both append to the same .incomplete file and
71
+ // corrupt it. An O_EXCL .lock beside the .incomplete serializes them; a lock
72
+ // older than LOCK_STALE_MS (crashed owner) or whose pid is gone is stolen.
73
+
74
+ const LOCK_STALE_MS = 60 * 60 * 1000; // ~1 h: longer than any sane blob fetch
75
+
76
+ function lockIsStale(lockPath: string): boolean {
77
+ try {
78
+ const st = statSync(lockPath);
79
+ if (Date.now() - st.mtimeMs > LOCK_STALE_MS) return true;
80
+ const pid = Number(readFileSync(lockPath, "utf8").split("\n")[0]);
81
+ if (Number.isFinite(pid) && pid > 0) {
82
+ try { process.kill(pid, 0); } catch { return true; } // owner is gone
83
+ }
84
+ return false;
85
+ } catch {
86
+ return true; // unreadable/vanished — treat as stale, O_EXCL still arbitrates
87
+ }
88
+ }
89
+
90
+ /** Take `<blobPath>.lock` (O_EXCL). Returns a release fn, or throws the
91
+ * friendly "another download in progress" error when live-held. */
92
+ function acquireLock(blobPath: string, name: string): () => void {
93
+ const lockPath = `${blobPath}.lock`;
94
+ for (let attempt = 0; attempt < 2; attempt++) {
95
+ try {
96
+ const fd = openSync(lockPath, "wx");
97
+ writeSync(fd, `${process.pid}\n${new Date().toISOString()}\n`);
98
+ closeSync(fd);
99
+ return () => { try { rmSync(lockPath, { force: true }); } catch {} };
100
+ } catch (e) {
101
+ if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
102
+ if (lockIsStale(lockPath)) {
103
+ try { rmSync(lockPath, { force: true }); } catch {}
104
+ continue; // one retry after stealing
105
+ }
106
+ let holder = "";
107
+ try { holder = ` (pid ${readFileSync(lockPath, "utf8").split("\n")[0]})`; } catch {}
108
+ throw new Error(
109
+ `another download of ${name} is in progress${holder} — ` +
110
+ `wait for it to finish, or delete ${lockPath} if you're sure it isn't`,
111
+ );
112
+ }
113
+ }
114
+ throw new Error(`could not acquire ${lockPath}`);
115
+ }
116
+
57
117
  /** git blob identity: sha1("blob <size>\0" + content) — what blobId is. */
58
118
  export function gitBlobSha1(bytes: Uint8Array): string {
59
119
  const hasher = new Bun.CryptoHasher("sha1");
@@ -73,7 +133,10 @@ export async function listRepoFiles(
73
133
  headers: token ? { authorization: `Bearer ${token}` } : {},
74
134
  });
75
135
  if (!res.ok)
76
- throw new Error(`HF API ${res.status} for ${repoId}@${revision}: ${(await res.text()).slice(0, 200)}`);
136
+ throw new Error(
137
+ `HF API ${res.status} for ${repoId}@${revision}: ${(await res.text()).slice(0, 200)}` +
138
+ authHint(res.status),
139
+ );
77
140
  const body = (await res.json()) as {
78
141
  sha: string;
79
142
  siblings: { rfilename: string; size?: number; blobId?: string; lfs?: { oid: string; size: number } }[];
@@ -115,6 +178,21 @@ export async function downloadOne(
115
178
  url: string, token: string | null, blobPath: string,
116
179
  expected: { size: number; sha256?: string; sha1?: string; name: string },
117
180
  onProgress?: DownloadOptions["onProgress"],
181
+ ): Promise<void> {
182
+ // Serialize concurrent writers (foreground get vs background auto-download)
183
+ // on this blob — both appending to one .incomplete corrupts it.
184
+ const release = acquireLock(blobPath, expected.name);
185
+ try {
186
+ await downloadOneLocked(url, token, blobPath, expected, onProgress);
187
+ } finally {
188
+ release();
189
+ }
190
+ }
191
+
192
+ async function downloadOneLocked(
193
+ url: string, token: string | null, blobPath: string,
194
+ expected: { size: number; sha256?: string; sha1?: string; name: string },
195
+ onProgress?: DownloadOptions["onProgress"],
118
196
  ): Promise<void> {
119
197
  const partPath = `${blobPath}.incomplete`;
120
198
  let offset = existsSync(partPath) ? statSync(partPath).size : 0;
@@ -124,14 +202,27 @@ export async function downloadOne(
124
202
  }
125
203
 
126
204
  // Streaming verification — resume re-hashes the existing prefix so the
127
- // final digest always covers every byte on disk.
205
+ // final digest always covers every byte on disk. Chunked reads: a resumed
206
+ // multi-GB blob must not be materialized as one allocation.
128
207
  const sha256 = new Bun.CryptoHasher("sha256");
129
208
  const sha1 = new Bun.CryptoHasher("sha1");
130
209
  sha1.update(`blob ${expected.size}\0`);
131
210
  if (offset > 0) {
132
- const existing = await Bun.file(partPath).arrayBuffer();
133
- sha256.update(existing);
134
- sha1.update(existing);
211
+ const fd = openSync(partPath, "r");
212
+ try {
213
+ const buf = Buffer.alloc(8 * 2 ** 20);
214
+ let pos = 0;
215
+ while (pos < offset) {
216
+ const n = readSync(fd, buf, 0, Math.min(buf.length, offset - pos), pos);
217
+ if (n <= 0) break;
218
+ const chunk = buf.subarray(0, n);
219
+ sha256.update(chunk);
220
+ sha1.update(chunk);
221
+ pos += n;
222
+ }
223
+ } finally {
224
+ closeSync(fd);
225
+ }
135
226
  }
136
227
 
137
228
  if (offset < expected.size) {
@@ -139,10 +230,10 @@ export async function downloadOne(
139
230
  if (res.status === 200 && offset > 0) {
140
231
  // server ignored Range — start the file (and hashes) over
141
232
  rmSync(partPath);
142
- return downloadOne(url, token, blobPath, expected, onProgress);
233
+ return downloadOneLocked(url, token, blobPath, expected, onProgress);
143
234
  }
144
235
  if (res.status !== 200 && res.status !== 206)
145
- throw new Error(`download failed (${res.status}) for ${expected.name}`);
236
+ throw new Error(`download failed (${res.status}) for ${expected.name}${authHint(res.status)}`);
146
237
  const out = createWriteStream(partPath, { flags: offset > 0 ? "a" : "w" });
147
238
  try {
148
239
  for await (const chunk of res.body as ReadableStream<Uint8Array>) {
package/src/embed.ts ADDED
@@ -0,0 +1,77 @@
1
+ // Text-embedding entry point. Wraps Qwen3Model.embedPooled (last-token hidden →
2
+ // L2-normalized vector) with the Qwen3-Embedding input convention so the CLI
3
+ // (`mlx-bun embed`), the server (`/v1/embeddings`), and in-repo experiments all
4
+ // produce the SAME vectors — the ones verified bit-exact vs mlx-lm in
5
+ // tests/qwen3-embed-parity.test.ts.
6
+
7
+ import { Qwen3Model } from "./model/qwen3";
8
+ import type { RuntimeModel } from "./model/factory";
9
+ import type { LoadedTokenizer } from "./tokenizer";
10
+ import * as ops from "./mlx/ops";
11
+
12
+ /** <|endoftext|> — the token Qwen3-Embedding terminates every input with and
13
+ * pools the last position of. */
14
+ export const EMBED_EOD = 151643;
15
+
16
+ /** Only plain-Qwen3 (the Qwen3-Embedding backbone) exposes a pooled-embedding
17
+ * path today. Narrow the served RuntimeModel before embedding. */
18
+ export function isEmbeddingModel(model: RuntimeModel): model is Qwen3Model {
19
+ return model instanceof Qwen3Model;
20
+ }
21
+
22
+ /** Qwen3-Embedding query format: a task instruction steers WHICH similarity axis
23
+ * the geometry reflects ("represent this for topic routing" vs "...for
24
+ * sentiment"). Documents are embedded raw; only queries get an instruction. */
25
+ export function withInstruction(text: string, instruction?: string): string {
26
+ return instruction ? `Instruct: ${instruction}\nQuery:${text}` : text;
27
+ }
28
+
29
+ /** Tripwire: counts embedOne invocations (embedMany accrues via embedOne) so
30
+ * callers can assert the pipeline embeds exactly as many times as expected. */
31
+ let embedCallCount = 0;
32
+
33
+ /** Reset the embed tripwire counter to 0. */
34
+ export function resetEmbedCounter(): void {
35
+ embedCallCount = 0;
36
+ }
37
+
38
+ /** Read the embed tripwire counter (total embedOne calls since last reset). */
39
+ export function getEmbedCounter(): number {
40
+ return embedCallCount;
41
+ }
42
+
43
+ export interface EmbedResult {
44
+ /** L2-normalized embedding (length = model hidden size). */
45
+ vector: Float32Array;
46
+ /** Token count of the embedded input (incl. the EOD pooling token). */
47
+ tokens: number;
48
+ }
49
+
50
+ /** Embed one text. `instruction` (optional) applies the query format. */
51
+ export function embedOne(
52
+ model: Qwen3Model,
53
+ tok: LoadedTokenizer,
54
+ text: string,
55
+ instruction?: string,
56
+ ): EmbedResult {
57
+ embedCallCount++;
58
+ // addSpecialTokens=false matches the bit-exact parity path (Qwen3 has no BOS;
59
+ // we append the EOD pooling token ourselves).
60
+ const ids = [...tok.encode(withInstruction(text, instruction), false), EMBED_EOD];
61
+ const idArr = ops.fromInt32(ids, [1, ids.length]);
62
+ const vec = model.embedPooled(idArr);
63
+ idArr.dispose();
64
+ const out = vec.toFloat32();
65
+ vec.dispose();
66
+ return { vector: out, tokens: ids.length };
67
+ }
68
+
69
+ /** Embed many texts (one forward each — the runtime is single-sequence). */
70
+ export function embedMany(
71
+ model: Qwen3Model,
72
+ tok: LoadedTokenizer,
73
+ texts: string[],
74
+ instruction?: string,
75
+ ): EmbedResult[] {
76
+ return texts.map((t) => embedOne(model, tok, t, instruction));
77
+ }
@@ -0,0 +1,164 @@
1
+ // Perplexity over a local text/JSONL dataset — mlx_lm.perplexity methodology.
2
+ //
3
+ // The reference (mlx_lm/perplexity.py, mlx-lm 0.31.3) does:
4
+ // 1. tokenize dataset samples, visit them in a seeded random permutation,
5
+ // 2. concatenate tokens into one stream and cut it into NON-OVERLAPPING
6
+ // rows of `sequence_length` (no sliding-window stride, no BOS re-seeding
7
+ // per row), keeping at most `num_samples` rows,
8
+ // 3. per batch: logits = model(batch[:, :-1]) in f32, per-token
9
+ // cross-entropy vs batch[:, 1:] — EVERY position counts,
10
+ // 4. ppl = exp(mean loss); SE via the delta method:
11
+ // se_ppl = ppl · std(losses, ddof=1) / √N.
12
+ //
13
+ // We reproduce 2-4 exactly. Step 1 differs only in the data source: the
14
+ // reference pulls a Hugging Face dataset (network); we take a LOCAL .txt or
15
+ // .jsonl file — never a download. The permutation RNG is a seeded
16
+ // deterministic shuffle (not NumPy's), so row selection is reproducible per
17
+ // seed but not bit-matched to the Python tool's sampling.
18
+ //
19
+ // The forward reuses the existing full-sequence machinery
20
+ // (src/train/forward.ts trainForward → model.forwardHidden + logitsFromHidden).
21
+
22
+ import { clearCache, Dtype } from "../mlx/ffi";
23
+ import * as ops from "../mlx/ops";
24
+ import { trainForward } from "../train/forward";
25
+ import type { RuntimeModel } from "../model/factory";
26
+
27
+ export interface PplDataOptions {
28
+ sequenceLength: number;
29
+ /** Max rows to keep (-1 / 0 = all available). */
30
+ numSamples: number;
31
+ /** Seed for the sample-order shuffle (reference default: 123). */
32
+ seed: number;
33
+ }
34
+
35
+ export interface PplResult {
36
+ ppl: number;
37
+ /** Delta-method standard error of the perplexity. */
38
+ standardError: number;
39
+ meanLoss: number;
40
+ tokens: number;
41
+ rows: number;
42
+ }
43
+
44
+ /** Deterministic PRNG (mulberry32) for the seeded sample shuffle. */
45
+ function mulberry32(seed: number): () => number {
46
+ let s = seed >>> 0;
47
+ return () => {
48
+ s = (s + 0x6d2b79f5) >>> 0;
49
+ let t = s;
50
+ t = Math.imul(t ^ (t >>> 15), t | 1);
51
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
52
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
53
+ };
54
+ }
55
+
56
+ /** Split a raw dataset file into text samples: `.jsonl` → one sample per
57
+ * line from the `text` field; anything else → the whole file is one sample. */
58
+ export function parseSamples(raw: string, path: string): string[] {
59
+ if (path.endsWith(".jsonl") || path.endsWith(".ndjson")) {
60
+ const samples: string[] = [];
61
+ for (const [i, line] of raw.split("\n").entries()) {
62
+ if (!line.trim()) continue;
63
+ let row: unknown;
64
+ try {
65
+ row = JSON.parse(line);
66
+ } catch {
67
+ throw new Error(`${path}:${i + 1}: not valid JSON`);
68
+ }
69
+ const text = (row as { text?: unknown }).text;
70
+ if (typeof text !== "string")
71
+ throw new Error(`${path}:${i + 1}: expected a {"text": "..."} row`);
72
+ samples.push(text);
73
+ }
74
+ return samples;
75
+ }
76
+ return raw.length > 0 ? [raw] : [];
77
+ }
78
+
79
+ /** Tokenize samples (seeded shuffle), concatenate, and pack into
80
+ * non-overlapping rows of `sequenceLength` tokens (reference load_data). */
81
+ export function packRows(
82
+ sampleTokens: number[][],
83
+ opts: PplDataOptions,
84
+ ): Int32Array[] {
85
+ const { sequenceLength: L, numSamples, seed } = opts;
86
+ const order = sampleTokens.map((_, i) => i);
87
+ const rnd = mulberry32(seed);
88
+ for (let i = order.length - 1; i > 0; i--) {
89
+ const j = Math.floor(rnd() * (i + 1));
90
+ [order[i], order[j]] = [order[j]!, order[i]!];
91
+ }
92
+
93
+ const wanted = numSamples > 0 ? L * numSamples : Infinity;
94
+ const stream: number[] = [];
95
+ for (const idx of order) {
96
+ if (stream.length >= wanted) break;
97
+ stream.push(...sampleTokens[idx]!);
98
+ }
99
+
100
+ const nRows = Math.min(
101
+ Math.floor(stream.length / L),
102
+ numSamples > 0 ? numSamples : Infinity,
103
+ );
104
+ const rows: Int32Array[] = [];
105
+ for (let r = 0; r < nRows; r++)
106
+ rows.push(Int32Array.from(stream.slice(r * L, (r + 1) * L)));
107
+ return rows;
108
+ }
109
+
110
+ /** Evaluate perplexity over packed rows. Per batch (all rows are the same
111
+ * length, so the stock unpadded forward applies): f32 logits over
112
+ * batch[:, :-1], per-token CE vs batch[:, 1:], every position counted. */
113
+ export function evalPpl(
114
+ model: RuntimeModel,
115
+ rows: Int32Array[],
116
+ batchSize: number,
117
+ onBatch?: (done: number, total: number) => void,
118
+ ): PplResult {
119
+ const L = rows[0]!.length;
120
+ const allLosses: number[] = [];
121
+ const numBatches = Math.ceil(rows.length / batchSize);
122
+
123
+ for (let s = 0, bi = 0; s < rows.length; s += batchSize, bi++) {
124
+ const batch = rows.slice(s, s + batchSize);
125
+ const B = batch.length;
126
+ const inputs: number[] = [];
127
+ const targets: number[] = [];
128
+ for (const row of batch) {
129
+ for (let i = 0; i < L - 1; i++) inputs.push(row[i]!);
130
+ for (let i = 1; i < L; i++) targets.push(row[i]!);
131
+ }
132
+ const ids = ops.fromInt32(inputs, [B, L - 1]);
133
+ const tgt = ops.fromInt32(targets, [B, L - 1, 1]);
134
+ const logits = trainForward(model, ids);
135
+ const f32 = logits.astype(Dtype.float32);
136
+ logits.dispose();
137
+ // CE[t] = logsumexp(logits[t]) − logits[t, target] (f32, like nn.losses)
138
+ const lse = ops.logsumexpAxis(f32, 2, false); // [B, L-1]
139
+ const picked = ops.takeAlongAxis(f32, tgt, 2); // [B, L-1, 1]
140
+ f32.dispose();
141
+ const picked2d = ops.reshape(picked, [B, L - 1]);
142
+ picked.dispose();
143
+ const losses = ops.sub(lse, picked2d);
144
+ lse.dispose();
145
+ picked2d.dispose();
146
+ const vals = losses.toFloat32();
147
+ losses.dispose();
148
+ ids.dispose();
149
+ tgt.dispose();
150
+ for (const v of vals) allLosses.push(v);
151
+ clearCache();
152
+ onBatch?.(bi + 1, numBatches);
153
+ }
154
+
155
+ const n = allLosses.length;
156
+ const mean = allLosses.reduce((a, b) => a + b, 0) / n;
157
+ const ppl = Math.exp(mean);
158
+ // Sample std (ddof=1) → delta-method SE of the perplexity.
159
+ const varSum = allLosses.reduce((a, b) => a + (b - mean) ** 2, 0);
160
+ const std = n > 1 ? Math.sqrt(varSum / (n - 1)) : 0;
161
+ const standardError = ppl * (std / Math.sqrt(n));
162
+
163
+ return { ppl, standardError, meanLoss: mean, tokens: n, rows: rows.length };
164
+ }
@@ -99,6 +99,11 @@ export interface GenOpts {
99
99
  * (its capability numbers are non-thinking; e.g. MiniCPM5 IFEval 64.7 is
100
100
  * non-thinking). Set MLX_BUN_EVAL_THINK=1 or pass true to evaluate thinking mode. */
101
101
  enableThinking?: boolean;
102
+ /** Explicit KV-quant scheme (cli `generate --l2/--l3/--kv-quant`, resolved with
103
+ * serve's semantics). Overrides the MLX_BUN_EVAL_KV_QUANT env default; an empty
104
+ * object forces bf16. When it names quantized KV, generation runs the product
105
+ * generate() path (same tokens as `serve` with the same scheme). */
106
+ kvScheme?: Pick<GenerateOptions, "kvBits" | "kvConfig" | "quantizedKvStart">;
102
107
  }
103
108
 
104
109
  /** Bit-exact greedy decode: raw `model.forward` + argmax loop, which matches mlx-lm
@@ -106,7 +111,7 @@ export interface GenOpts {
106
111
  * (forwardHidden/logitsFromHidden + pipelined sampler) diverges from the raw forward
107
112
  * on near-ties past ~32 tokens, so the EVAL decodes bit-exactly itself rather than
108
113
  * alter the serving path. Greedy + full-precision KV only. */
109
- function greedyDecodeBitExact(tm: TaskModel, ids: number[], maxTokens: number): string {
114
+ export function greedyDecodeBitExact(tm: TaskModel, ids: number[], maxTokens: number): string {
110
115
  if (tm.activeAdapters) tm.model.loraState.active = tm.activeAdapters; // apply the mounted adapter
111
116
  const cache = tm.model.makeCache();
112
117
  try {
@@ -148,23 +153,27 @@ export async function generateText(tm: TaskModel, body: string, opts: GenOpts =
148
153
  // plain mlx_lm.generate (kv-quant lives in the serving runtime, not the eval). So
149
154
  // for like-for-like parity we default the eval to UNQUANTIZED KV; MMLU's argmax
150
155
  // path was already unquantized, which is why it matched while generation didn't.
151
- // MLX_BUN_EVAL_KV_QUANT=1 generates through the model's quantized KV (serving config).
152
- const kv = (process.env.MLX_BUN_EVAL_KV_QUANT === "1" && tm.config.kvQuant?.length)
153
- ? tm.config.kvQuant : undefined;
156
+ // MLX_BUN_EVAL_KV_QUANT=1 generates through the model's quantized KV (serving
157
+ // config); an explicit opts.kvScheme (cli generate's resolved tier route) wins.
158
+ const envKv: GenOpts["kvScheme"] | undefined =
159
+ (process.env.MLX_BUN_EVAL_KV_QUANT === "1" && tm.config.kvQuant?.length)
160
+ ? { kvConfig: tm.config.kvQuant, quantizedKvStart: 0 } : undefined;
161
+ const kvScheme = opts.kvScheme ?? envKv;
162
+ const kvActive = !!(kvScheme && (kvScheme.kvBits || kvScheme.kvConfig?.length));
154
163
 
155
164
  // Parity default — greedy + full-precision KV + no sampler arm: decode bit-exactly
156
165
  // via the raw forward (matches mlx-lm token-for-token). Sampler arms / kv-quant fall
157
166
  // through to the product generate(). DiffusionGemma is non-autoregressive (no AR
158
167
  // forward) — always go through generate(), which routes it to the denoising engine.
159
168
  const isDiffusion = tm.model instanceof DiffusionGemmaModel;
160
- if (!isDiffusion && !kv && !opts.sampler && !tm.samplerOverride) {
169
+ if (!isDiffusion && !kvActive && !opts.sampler && !tm.samplerOverride) {
161
170
  return greedyDecodeBitExact(tm, ids, maxTokens);
162
171
  }
163
172
  const gen = generate(tm.model, ids, {
164
173
  maxTokens,
165
174
  temperature: 0, // greedy default — deterministic head-to-head arms
166
175
  adapters: tm.activeAdapters ?? [], // per-request activation of the mounted eval-adapter
167
- ...(kv ? { kvConfig: kv, quantizedKvStart: 0 } : {}),
176
+ ...(kvActive ? kvScheme : {}),
168
177
  ...(opts.sampler ?? {}), // overrides temperature when provided
169
178
  ...(tm.samplerOverride ?? {}), // arm override wins over the task's own sampler
170
179
  });
@@ -15,7 +15,7 @@
15
15
  // of the task — is ported exactly. (See summary for this one note.)
16
16
 
17
17
  import { generateText, loadJsonl, sampleIndices, type TaskModel } from "../runner";
18
- import { type ToolDefinition } from "../chat-template";
18
+ import { type ToolDefinition } from "../../chat-template";
19
19
 
20
20
  // ---------------------------------------------------------------------------
21
21
  // Data shapes (the merged jsonl: each line = {query, answer}).