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
package/src/config.ts CHANGED
@@ -2,6 +2,11 @@
2
2
  // Parses HF config.json (Gemma 4 unified layout: text_config nested),
3
3
  // the OptiQ per-layer quantization map, and kv_config.json when present.
4
4
 
5
+ import {
6
+ parseGlm52Config,
7
+ type Glm52Config,
8
+ } from "./model/glm52-config";
9
+
5
10
  export interface RopeParams {
6
11
  ropeTheta: number;
7
12
  ropeType: string;
@@ -223,6 +228,18 @@ export async function loadModelConfig(modelDir: string): Promise<ModelConfig> {
223
228
  const raw = (await Bun.file(`${modelDir}/config.json`).json()) as Record<string, any>;
224
229
  const modelType = raw.model_type;
225
230
  const isDiffusion = modelType === "diffusion_gemma";
231
+ const isGlm52 = modelType === "glm_moe_dsa";
232
+ // The converted Colibri artifact keeps stop metadata split across the two
233
+ // HF files. Preserve the old config-only behavior for every other family;
234
+ // GLM-5.2 explicitly unions both lists through its dedicated parser.
235
+ let glm52: Glm52Config | null = null;
236
+ if (isGlm52) {
237
+ const generationFile = Bun.file(`${modelDir}/generation_config.json`);
238
+ const generation = await generationFile.exists()
239
+ ? await generationFile.json() as Record<string, any>
240
+ : null;
241
+ glm52 = parseGlm52Config(modelDir, raw, generation);
242
+ }
226
243
  // Gemma 4 unified nests the LM config; plain text models keep it at top level.
227
244
  // DiffusionGemma keeps it flat too, but omits all dims — backfill from defaults.
228
245
  const baseT = (raw.text_config ?? raw) as Record<string, any>;
@@ -236,34 +253,56 @@ export async function loadModelConfig(modelDir: string): Promise<ModelConfig> {
236
253
  // partial_rotary_factor}), not the gemma per-attention-type map — and
237
254
  // type "default" means plain partial nn.RoPE (mrope_section ignored for text).
238
255
  const qwenRope = (t.rope_parameters ?? {}) as Record<string, any>;
256
+ // Qwen3.8 adds output_gate_type ("swish"), which every implementation
257
+ // (transformers ground truth, mlx-lm pinned + main) currently ignores —
258
+ // the attention output gate is hardcoded o_proj(out·σ(gate)). Only accept
259
+ // values verified to mean that; an unknown value must fail at load, not
260
+ // silently mis-gate a future checkpoint where the field starts mattering.
261
+ if (isQwen35 && t.output_gate_type != null && t.output_gate_type !== "swish")
262
+ throw new Error(
263
+ `qwen3_5: unverified output_gate_type "${t.output_gate_type}" (only "swish" ` +
264
+ `is verified to mean the standard sigmoid output gate)`,
265
+ );
239
266
 
240
267
  const text: TextConfig = {
241
- hiddenSize: t.hidden_size,
242
- numHiddenLayers: t.num_hidden_layers,
243
- numAttentionHeads: t.num_attention_heads,
244
- numKeyValueHeads: t.num_key_value_heads,
245
- headDim: t.head_dim ?? Math.floor(t.hidden_size / t.num_attention_heads),
246
- numGlobalKeyValueHeads: t.num_global_key_value_heads ?? t.num_key_value_heads,
247
- globalHeadDim: t.global_head_dim ?? t.head_dim ?? Math.floor(t.hidden_size / t.num_attention_heads),
268
+ hiddenSize: glm52?.hiddenSize ?? t.hidden_size,
269
+ numHiddenLayers: glm52?.numHiddenLayers ?? t.num_hidden_layers,
270
+ numAttentionHeads: glm52?.numAttentionHeads ?? t.num_attention_heads,
271
+ numKeyValueHeads: glm52?.numKeyValueHeads ?? t.num_key_value_heads,
272
+ headDim: glm52?.qkHeadDim ?? t.head_dim ?? Math.floor(t.hidden_size / t.num_attention_heads),
273
+ numGlobalKeyValueHeads:
274
+ glm52?.numKeyValueHeads ?? t.num_global_key_value_heads ?? t.num_key_value_heads,
275
+ globalHeadDim:
276
+ glm52?.qkHeadDim ??
277
+ t.global_head_dim ??
278
+ t.head_dim ??
279
+ Math.floor(t.hidden_size / t.num_attention_heads),
248
280
  attentionKEqV: t.attention_k_eq_v ?? false,
249
- intermediateSize: t.intermediate_size,
281
+ intermediateSize: glm52?.intermediateSize ?? t.intermediate_size,
250
282
  hiddenActivation: t.hidden_activation ?? t.hidden_act ?? "gelu_pytorch_tanh",
251
- rmsNormEps: t.rms_norm_eps,
252
- vocabSize: t.vocab_size,
253
- maxPositionEmbeddings: t.max_position_embeddings,
283
+ rmsNormEps: glm52?.rmsNormEps ?? t.rms_norm_eps,
284
+ vocabSize: glm52?.vocabSize ?? t.vocab_size,
285
+ maxPositionEmbeddings:
286
+ glm52?.maxPositionEmbeddings ?? t.max_position_embeddings,
254
287
  slidingWindow: t.sliding_window ?? 0,
255
- layerTypes: t.layer_types ?? (isLlama ? Array(t.num_hidden_layers).fill("full_attention") : []),
288
+ layerTypes: glm52
289
+ ? Array(glm52.numHiddenLayers).fill("full_attention")
290
+ : t.layer_types ?? (isLlama ? Array(t.num_hidden_layers).fill("full_attention") : []),
256
291
  hiddenSizePerLayerInput: t.hidden_size_per_layer_input ?? 0,
257
- vocabSizePerLayerInput: t.vocab_size_per_layer_input ?? t.vocab_size,
292
+ vocabSizePerLayerInput:
293
+ t.vocab_size_per_layer_input ?? glm52?.vocabSize ?? t.vocab_size,
258
294
  numKvSharedLayers: t.num_kv_shared_layers ?? 0,
259
- enableMoeBlock: t.enable_moe_block ?? false,
260
- numExperts: t.num_experts ?? 0,
295
+ enableMoeBlock: glm52 ? true : t.enable_moe_block ?? false,
296
+ numExperts: glm52?.numRoutedExperts ?? t.num_experts ?? 0,
261
297
  // gemma4 uses `top_k_experts`; qwen3_moe uses `num_experts_per_tok`.
262
- topKExperts: t.top_k_experts ?? t.num_experts_per_tok ?? 0,
263
- moeIntermediateSize: t.moe_intermediate_size ?? 0,
264
- decoderSparseStep: t.decoder_sparse_step ?? 1,
265
- mlpOnlyLayers: t.mlp_only_layers ?? [],
266
- normTopkProb: t.norm_topk_prob ?? false,
298
+ topKExperts:
299
+ glm52?.numExpertsPerToken ?? t.top_k_experts ?? t.num_experts_per_tok ?? 0,
300
+ moeIntermediateSize: glm52?.moeIntermediateSize ?? t.moe_intermediate_size ?? 0,
301
+ decoderSparseStep: glm52 ? 1 : t.decoder_sparse_step ?? 1,
302
+ mlpOnlyLayers: glm52
303
+ ? Array.from({ length: glm52.firstKDenseReplace }, (_, layer) => layer)
304
+ : t.mlp_only_layers ?? [],
305
+ normTopkProb: glm52?.normTopkProb ?? t.norm_topk_prob ?? false,
267
306
  linearNumValueHeads: t.linear_num_value_heads ?? 0,
268
307
  linearNumKeyHeads: t.linear_num_key_heads ?? 0,
269
308
  linearKeyHeadDim: t.linear_key_head_dim ?? 0,
@@ -271,9 +310,19 @@ export async function loadModelConfig(modelDir: string): Promise<ModelConfig> {
271
310
  linearConvKernelDim: t.linear_conv_kernel_dim ?? 0,
272
311
  fullAttentionInterval: t.full_attention_interval ?? 0,
273
312
  attnOutputGate: t.attn_output_gate ?? false,
274
- partialRotaryFactor:
275
- qwenRope.partial_rotary_factor ?? t.partial_rotary_factor ?? 1.0,
276
- ropeParameters: isQwen35
313
+ partialRotaryFactor: glm52
314
+ ? glm52.qkRopeHeadDim / glm52.qkHeadDim
315
+ : qwenRope.partial_rotary_factor ?? t.partial_rotary_factor ?? 1.0,
316
+ ropeParameters: glm52
317
+ ? {
318
+ full_attention: {
319
+ ropeTheta: glm52.ropeTheta,
320
+ ropeType: "default",
321
+ partialRotaryFactor: glm52.qkRopeHeadDim / glm52.qkHeadDim,
322
+ factor: 1.0,
323
+ },
324
+ }
325
+ : isQwen35
277
326
  ? {
278
327
  full_attention: {
279
328
  ropeTheta: qwenRope.rope_theta ?? t.rope_theta ?? 10000,
@@ -297,7 +346,7 @@ export async function loadModelConfig(modelDir: string): Promise<ModelConfig> {
297
346
  finalLogitSoftcapping: t.final_logit_softcapping ?? null,
298
347
  tieWordEmbeddings: t.tie_word_embeddings ?? raw.tie_word_embeddings ?? false,
299
348
  bosTokenId: t.bos_token_id ?? raw.bos_token_id,
300
- eosTokenId: t.eos_token_id ?? raw.eos_token_id,
349
+ eosTokenId: glm52?.eosTokenIds ?? t.eos_token_id ?? raw.eos_token_id,
301
350
  canvasLength: raw.canvas_length ?? t.canvas_length,
302
351
  };
303
352
 
@@ -311,14 +360,20 @@ export async function loadModelConfig(modelDir: string): Promise<ModelConfig> {
311
360
  }));
312
361
  }
313
362
 
314
- const eos = raw.eos_token_id ?? text.eosTokenId;
363
+ const eos = glm52?.eosTokenIds ?? raw.eos_token_id ?? text.eosTokenId;
315
364
  return {
316
365
  modelDir,
317
366
  modelType,
318
367
  architectures: raw.architectures ?? [],
319
368
  dtype: raw.dtype ?? "bfloat16",
320
369
  text,
321
- quantization: parseQuantization(raw.quantization ?? raw.quantization_config),
370
+ // Colibri's config retains the SOURCE FP8 quantization_config, while the
371
+ // serving shards are converted U8 + `.qs` tensors whose int4/int8/group
372
+ // geometry is inferred and validated by Glm52Container. Advertising the
373
+ // source FP8 block as mlx affine quantization would be actively wrong.
374
+ quantization: glm52
375
+ ? null
376
+ : parseQuantization(raw.quantization ?? raw.quantization_config),
322
377
  kvQuant,
323
378
  hasVisionSidecar: await Bun.file(`${modelDir}/optiq_vision.safetensors`).exists(),
324
379
  eosTokenIds: Array.isArray(eos) ? eos : [eos],
@@ -245,9 +245,7 @@ export function diffusionGenerate(
245
245
 
246
246
  let currentCanvas = ops.randint(0, vocab, [1, canvasLength], I32); // RNG: init
247
247
  let draftReveal = ops.zeros([1, canvasLength], Dtype.bool);
248
- let draftCanvas: MlxArray = ops.logicalNot(ops.logicalNot(draftReveal)); // placeholder, replaced below
249
- draftCanvas.dispose();
250
- draftCanvas = ops.reshape(currentCanvas, [1, canvasLength]); // alias copy
248
+ let draftCanvas = ops.reshape(currentCanvas, [1, canvasLength]); // alias copy
251
249
  let argmaxCanvas = ops.reshape(currentCanvas, [1, canvasLength]);
252
250
  let scEmbeddings: MlxArray | null = null;
253
251
  const history: MlxArray[] = [];
@@ -266,7 +264,9 @@ export function diffusionGenerate(
266
264
  logits.dispose();
267
265
 
268
266
  argmaxCanvas.dispose();
269
- argmaxCanvas = ops.argmaxAxis(processed, -1).astype(I32); // [1, L]
267
+ const argmax = ops.argmaxAxis(processed, -1);
268
+ argmaxCanvas = argmax.astype(I32); // [1, L]
269
+ argmax.dispose();
270
270
 
271
271
  if (curStep === 1) {
272
272
  processed.dispose();
@@ -296,7 +296,9 @@ export function diffusionGenerate(
296
296
  currentCanvas.dispose();
297
297
  currentCanvas = newCurrent;
298
298
  draftReveal.dispose();
299
- draftReveal = ops.logicalNot(ops.logicalNot(acceptance)); // = acceptance
299
+ const notAcceptance = ops.logicalNot(acceptance);
300
+ draftReveal = ops.logicalNot(notAcceptance); // = acceptance
301
+ notAcceptance.dispose();
300
302
  draftCanvas.dispose();
301
303
  draftCanvas = ops.reshape(argmaxCanvas, [1, canvasLength]);
302
304
  accepted.dispose();
@@ -341,6 +343,10 @@ export function diffusionGenerate(
341
343
  stableStop &&
342
344
  stableAndConfident(argmaxCanvas, processed, history, stabilityThreshold, stopConfidence)
343
345
  ) {
346
+ // Entropy sampling precomputes the next step's self-conditioning
347
+ // tensor before evaluating this stop. The break means ownership is
348
+ // never transferred to scEmbeddings, so release the pending value.
349
+ nextSc?.dispose();
344
350
  processed.dispose();
345
351
  break;
346
352
  }
@@ -361,9 +367,9 @@ export function diffusionGenerate(
361
367
  draftCanvas.dispose();
362
368
 
363
369
  // Emit from the final argmax canvas.
364
- const blockTokens = [...argmaxCanvas.astype(Dtype.float32).toFloat32()].map((x) =>
365
- Math.round(x),
366
- );
370
+ const argmaxFloat = argmaxCanvas.astype(Dtype.float32);
371
+ const blockTokens = [...argmaxFloat.toFloat32()].map((x) => Math.round(x));
372
+ argmaxFloat.dispose();
367
373
  argmaxCanvas.dispose();
368
374
  blocks.push(blockTokens);
369
375
  totalSteps += stepsThisCanvas;
package/src/download.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // lore"): Xet stalls (we speak plain HTTPS resolve/CDN), no resume on
5
5
  // flaky links (we Range-resume partial blobs), and silent corruption
6
6
  // (every blob is verified — sha256 for LFS files against the API's
7
- // oid, git-blob sha1 for small files against blobId).
7
+ // normalized LFS digest, git-blob sha1 for small files against blobId).
8
8
  //
9
9
  // Writes the exact huggingface_hub cache layout so the registry and
10
10
  // loaders find the result with zero changes:
@@ -18,6 +18,7 @@
18
18
  import {
19
19
  mkdirSync, existsSync, statSync, createWriteStream, renameSync, rmSync,
20
20
  symlinkSync, readFileSync, openSync, closeSync, readSync, writeSync,
21
+ statfsSync,
21
22
  } from "node:fs";
22
23
  import { dirname, join } from "node:path";
23
24
  import { DEFAULT_HUB } from "./registry";
@@ -29,8 +30,8 @@ export interface RepoFile {
29
30
  size: number;
30
31
  /** git blob sha1 (non-LFS identity, and the blob filename for non-LFS). */
31
32
  blobId: string;
32
- /** LFS sha256 + size; present for large files. oid is the blob filename. */
33
- lfs?: { oid: string; size: number };
33
+ /** LFS sha256 + size; present for large files. sha256 is the blob filename. */
34
+ lfs?: { sha256: string; size: number };
34
35
  }
35
36
 
36
37
  export interface RepoListing {
@@ -47,6 +48,56 @@ export interface DownloadOptions {
47
48
  onProgress?: (file: string, received: number, total: number) => void;
48
49
  }
49
50
 
51
+ export interface DownloadSpacePlan {
52
+ /** Bytes still absent after crediting verified blobs and resumable prefixes. */
53
+ remainingBytes: number;
54
+ /** Files that still need at least one byte. */
55
+ remainingFiles: number;
56
+ /** Files already complete in the shared HF blob store. */
57
+ completeFiles: number;
58
+ /** Existing `.incomplete` bytes that will be rehashed and resumed. */
59
+ resumableBytes: number;
60
+ }
61
+
62
+ /** One GiB for filesystem metadata, the final refs write, and normal host
63
+ * operation. The artifact bytes themselves are exact; this is deliberately a
64
+ * small fixed guard rather than a percentage that would add tens of GiB to a
65
+ * Colibri download. */
66
+ export const DOWNLOAD_DISK_RESERVE_BYTES = 2 ** 30;
67
+
68
+ /** Compute the exact remaining payload without opening or hashing any blob.
69
+ * A valid-size final blob is already complete; an in-range `.incomplete`
70
+ * prefix is credited because downloadOneLocked resumes it in place. */
71
+ export function planDownloadSpace(
72
+ files: readonly RepoFile[],
73
+ blobsDir: string,
74
+ ): DownloadSpacePlan {
75
+ let remainingBytes = 0;
76
+ let remainingFiles = 0;
77
+ let completeFiles = 0;
78
+ let resumableBytes = 0;
79
+ for (const file of files) {
80
+ const blobId = file.lfs?.sha256 ?? file.blobId;
81
+ if (!blobId) throw new Error(`no blob id for ${file.rfilename}`);
82
+ const blobPath = join(blobsDir, blobId);
83
+ if (existsSync(blobPath) && statSync(blobPath).size === file.size) {
84
+ completeFiles++;
85
+ continue;
86
+ }
87
+ const partPath = `${blobPath}.incomplete`;
88
+ const partBytes = existsSync(partPath) ? statSync(partPath).size : 0;
89
+ const credited = partBytes >= 0 && partBytes <= file.size ? partBytes : 0;
90
+ resumableBytes += credited;
91
+ remainingBytes += file.size - credited;
92
+ remainingFiles++;
93
+ }
94
+ return { remainingBytes, remainingFiles, completeFiles, resumableBytes };
95
+ }
96
+
97
+ function formatGib(bytes: number): string {
98
+ return `${(bytes / 2 ** 30).toFixed(2)} GiB`;
99
+ }
100
+
50
101
  /** HF auth: explicit > env > the file `hf auth login` writes. */
51
102
  export function hfToken(): string | null {
52
103
  if (process.env.HF_TOKEN) return process.env.HF_TOKEN;
@@ -161,16 +212,34 @@ export async function listRepoFiles(
161
212
  );
162
213
  const body = (await res.json()) as {
163
214
  sha: string;
164
- siblings: { rfilename: string; size?: number; blobId?: string; lfs?: { oid: string; size: number } }[];
215
+ siblings: {
216
+ rfilename: string;
217
+ size?: number;
218
+ blobId?: string;
219
+ // The current HF API calls this field `sha256`. Accept the older `oid`
220
+ // spelling too, but normalize it before the download path sees it.
221
+ lfs?: { sha256?: string; oid?: string; size: number };
222
+ }[];
165
223
  };
166
224
  return {
167
225
  sha: body.sha,
168
- files: body.siblings.map((s) => ({
169
- rfilename: s.rfilename,
170
- size: s.lfs?.size ?? s.size ?? 0,
171
- blobId: s.blobId ?? "",
172
- lfs: s.lfs,
173
- })),
226
+ files: body.siblings.map((s) => {
227
+ let lfs: RepoFile["lfs"];
228
+ if (s.lfs) {
229
+ const sha256 = s.lfs.sha256 ?? s.lfs.oid;
230
+ if (!sha256 || !/^[0-9a-f]{64}$/i.test(sha256))
231
+ throw new Error(`HF API returned an invalid LFS sha256 for ${s.rfilename}`);
232
+ lfs = { sha256: sha256.toLowerCase(), size: s.lfs.size };
233
+ }
234
+ if (!lfs && (!s.blobId || !/^[0-9a-f]{40}$/.test(s.blobId)))
235
+ throw new Error(`HF API returned an invalid git blob SHA-1 for ${s.rfilename}`);
236
+ return {
237
+ rfilename: s.rfilename,
238
+ size: lfs?.size ?? s.size ?? 0,
239
+ blobId: s.blobId ?? "",
240
+ lfs,
241
+ };
242
+ }),
174
243
  };
175
244
  }
176
245
 
@@ -349,6 +418,28 @@ export async function downloadModel(
349
418
  mkdirSync(blobsDir, { recursive: true });
350
419
  mkdirSync(join(repoDir, "refs"), { recursive: true });
351
420
 
421
+ // Preflight before the first network payload. This matters for direct
422
+ // Colibri artifacts (hundreds of GiB): discovering a full volume after the
423
+ // 140th shard is not a usable failure mode. Existing verified blobs and
424
+ // partial prefixes are credited exactly, so rerunning after interruption is
425
+ // both resumable and progressively cheaper.
426
+ const space = planDownloadSpace(listing.files, blobsDir);
427
+ if (space.remainingBytes > 0) {
428
+ const fs = statfsSync(blobsDir);
429
+ const availableBytes = fs.bavail * fs.bsize;
430
+ const requiredBytes = space.remainingBytes + DOWNLOAD_DISK_RESERVE_BYTES;
431
+ if (requiredBytes > availableBytes) {
432
+ throw new Error(
433
+ `insufficient disk space for ${repoId}: ${formatGib(space.remainingBytes)} ` +
434
+ `remains across ${space.remainingFiles} files, but only ` +
435
+ `${formatGib(availableBytes)} is available in ${hub} ` +
436
+ `(${formatGib(DOWNLOAD_DISK_RESERVE_BYTES)} safety reserve). ` +
437
+ `Free space or choose another cache directory, then rerun; ` +
438
+ `${formatGib(space.resumableBytes)} of partial data will resume.`,
439
+ );
440
+ }
441
+ }
442
+
352
443
  const status: DownloadStatus = {
353
444
  repoId, state: "active", currentFile: null,
354
445
  receivedBytes: 0, totalBytes: listing.files.reduce((a, f) => a + f.size, 0),
@@ -369,7 +460,7 @@ export async function downloadModel(
369
460
 
370
461
  try {
371
462
  for (const f of listing.files) {
372
- const blobId = f.lfs?.oid ?? f.blobId;
463
+ const blobId = f.lfs?.sha256 ?? f.blobId;
373
464
  if (!blobId) throw new Error(`no blob id for ${f.rfilename} (API response missing ?blobs=true data)`);
374
465
  const blobPath = join(blobsDir, blobId);
375
466
 
@@ -378,7 +469,7 @@ export async function downloadModel(
378
469
  status.currentFile = f.rfilename;
379
470
  await downloadOne(url, token, blobPath, {
380
471
  size: f.size, name: f.rfilename,
381
- ...(f.lfs ? { sha256: f.lfs.oid } : { sha1: f.blobId }),
472
+ ...(f.lfs ? { sha256: f.lfs.sha256 } : { sha1: f.blobId }),
382
473
  }, (file, received, total) => {
383
474
  status.receivedBytes = doneBytes + received;
384
475
  sampleRate(status.receivedBytes);
@@ -1,138 +1,81 @@
1
- // IFEval — programmatically-verifiable instruction-following scoring (Zhou et
2
- // al. 2023, "Instruction-Following Evaluation for Large Language Models"). Each
3
- // instance pairs a prompt with one or more *verifiable* instructions; a response
4
- // is scored by deterministic checks (no LLM judge), so before/after ORPO
5
- // comparisons are exact and reproducible.
1
+ // Compatibility facade for the canonical IFEval scorer.
6
2
  //
7
- // This implements the high-frequency instruction families (a working subset of
8
- // the ~25 in the reference suite, extensible via INSTRUCTIONS). Strict scoring:
9
- // an instance passes (strict) iff every instruction passes; we also report the
10
- // per-instruction pass rate. The official metrics are prompt-level and
11
- // instruction-level accuracy — both derived from `scoreInstance`.
3
+ // The complete verifier registry and strict/loose aggregation contract live in
4
+ // `tasks/ifeval.ts`, which is also used by `scripts/eval.ts`. Keep this module
5
+ // as the stable import surface used by `scripts/run-ifeval.ts` and older
6
+ // callers; it must not grow a second verifier implementation.
12
7
 
13
- export interface IFEvalInstance {
14
- /** The prompt text (used by instructions that reference it, e.g. "repeat the request"). */
15
- prompt: string;
16
- /** Instruction ids, e.g. "keywords:existence". */
17
- instruction_id_list: string[];
18
- /** Per-instruction kwargs, aligned with instruction_id_list. */
19
- kwargs: Array<Record<string, unknown>>;
20
- }
8
+ import {
9
+ scoreIfevalInstance,
10
+ scoreIfevalPairs,
11
+ type IfevalInstance,
12
+ type IfevalResult,
13
+ } from "./tasks/ifeval";
14
+
15
+ export {
16
+ SUPPORTED_INSTRUCTIONS,
17
+ looseClean,
18
+ scoreIfevalInstance,
19
+ scoreIfevalPairs,
20
+ stripThinking,
21
+ verifyResponse,
22
+ type IfevalCoverage,
23
+ type IfevalInstance,
24
+ type IfevalInstanceScore,
25
+ type IfevalPair,
26
+ type IfevalResult,
27
+ type IfevalVerification,
28
+ } from "./tasks/ifeval";
29
+
30
+ /** Historical spelling retained for callers of this module. */
31
+ export type IFEvalInstance = IfevalInstance;
21
32
 
22
33
  export interface InstanceResult {
23
- /** One pass/fail per instruction (aligned with instruction_id_list). */
34
+ /** Unknown instructions are true here per the canonical prompt contract. */
24
35
  perInstruction: boolean[];
25
- /** Strict: all instructions passed. */
26
36
  followedAll: boolean;
37
+ /** Consult this field rather than interpreting unknown IDs as verified. */
38
+ unhandled: string[];
27
39
  }
28
40
 
29
- type Checker = (response: string, kw: Record<string, unknown>, inst: IFEvalInstance) => boolean;
30
-
31
- const words = (s: string): string[] => s.trim().split(/\s+/).filter(Boolean);
32
- const sentences = (s: string): string[] =>
33
- s.split(/(?<=[.!?])\s+/).map((x) => x.trim()).filter(Boolean);
34
- const paragraphs = (s: string): string[] =>
35
- s.split(/\n\s*\n/).map((x) => x.trim()).filter(Boolean);
36
- const norm = (s: string): string => s.toLowerCase();
37
-
38
- const cmp = (n: number, rel: string | undefined, target: number): boolean => {
39
- switch (rel) {
40
- case "at least": return n >= target;
41
- case "at most": return n <= target;
42
- case "less than": return n < target;
43
- case "exactly": return n === target;
44
- default: return n >= target; // IFEval default for "around"/unspecified is lenient ≥
45
- }
46
- };
47
-
48
- /** The verifiable-instruction registry. Keyed by IFEval instruction_id. */
49
- export const INSTRUCTIONS: Record<string, Checker> = {
50
- // ---- keywords ----
51
- "keywords:existence": (r, kw) =>
52
- (kw.keywords as string[]).every((k) => norm(r).includes(norm(k))),
53
- "keywords:frequency": (r, kw) => {
54
- const k = norm(kw.keyword as string);
55
- const count = norm(r).split(k).length - 1;
56
- return cmp(count, kw.relation as string, kw.frequency as number);
57
- },
58
- "keywords:forbidden_words": (r, kw) =>
59
- !(kw.forbidden_words as string[]).some((k) => norm(r).includes(norm(k))),
60
-
61
- // ---- length constraints ----
62
- "length_constraints:number_words": (r, kw) =>
63
- cmp(words(r).length, kw.relation as string, kw.num_words as number),
64
- "length_constraints:number_sentences": (r, kw) =>
65
- cmp(sentences(r).length, kw.relation as string, kw.num_sentences as number),
66
- "length_constraints:number_paragraphs": (r, kw) =>
67
- cmp(paragraphs(r).length, kw.relation as string, kw.num_paragraphs as number),
68
-
69
- // ---- detectable format ----
70
- "detectable_format:number_bullet_lists": (r, kw) => {
71
- const bullets = r.split("\n").filter((l) => /^\s*[*\-]\s+/.test(l)).length;
72
- return cmp(bullets, kw.relation as string, kw.num_bullets as number);
73
- },
74
- "detectable_format:number_highlighted_sections": (r, kw) => {
75
- const hi = (r.match(/\*[^*\n]+\*/g) ?? []).length;
76
- return cmp(hi, kw.relation as string, kw.num_highlights as number);
77
- },
78
- "detectable_format:title": (r) => /<<[^>\n]+>>/.test(r),
79
- "detectable_format:json_format": (r) => {
80
- const t = r.trim().replace(/^```(?:json)?\s*|\s*```$/g, "").trim();
81
- try { JSON.parse(t); return true; } catch { return false; }
82
- },
83
-
84
- // ---- case ----
85
- "change_case:english_lowercase": (r) => r === r.toLowerCase(),
86
- "change_case:english_capital": (r) => r === r.toUpperCase(),
87
-
88
- // ---- start / end ----
89
- "startend:end_checker": (r, kw) =>
90
- norm(r.trimEnd()).endsWith(norm((kw.end_phrase as string).trim())),
91
- "startend:quotation": (r) => {
92
- const t = r.trim();
93
- return t.startsWith('"') && t.endsWith('"') && t.length >= 2;
94
- },
95
-
96
- // ---- punctuation ----
97
- "punctuation:no_comma": (r) => !r.includes(","),
98
- };
99
-
100
- /** Score one IFEval instance (strict). Unknown instruction ids count as failed
101
- * (so coverage gaps never inflate the score) — filter the dataset to the
102
- * supported ids, or extend INSTRUCTIONS, to avoid that. */
103
- export function scoreInstance(instance: IFEvalInstance, response: string): InstanceResult {
104
- const perInstruction = instance.instruction_id_list.map((id, i) => {
105
- const check = INSTRUCTIONS[id];
106
- if (!check) return false;
107
- try { return check(response, instance.kwargs[i] ?? {}, instance); } catch { return false; }
108
- });
109
- return { perInstruction, followedAll: perInstruction.every(Boolean) };
41
+ /**
42
+ * Historical strict-only helper, now delegated to the canonical scorer.
43
+ * New code should prefer `scoreIfevalInstance` for both strict and loose modes.
44
+ */
45
+ export function scoreInstance(
46
+ instance: IFEvalInstance,
47
+ response: string,
48
+ ): InstanceResult {
49
+ const { strict } = scoreIfevalInstance(instance, response);
50
+ return {
51
+ perInstruction: strict.instructionPasses.map((result) => result ?? true),
52
+ followedAll: strict.pass,
53
+ unhandled: strict.unhandled,
54
+ };
110
55
  }
111
56
 
112
- export interface IFEvalReport {
57
+ export interface IFEvalReport extends IfevalResult {
58
+ /** Historical alias for nTotal. */
113
59
  n: number;
114
- /** Fraction of instances where ALL instructions were followed. */
60
+ /** Historical alias for strictAcc (prompt-level strict). */
115
61
  promptAccuracy: number;
116
- /** Fraction of individual instructions followed. */
62
+ /** Historical alias for strictInstructionAcc. */
117
63
  instructionAccuracy: number;
118
64
  }
119
65
 
120
- /** Aggregate the official prompt-level + instruction-level accuracies over a set
121
- * of (instance, response) pairs. */
122
- export function aggregate(pairs: Array<{ instance: IFEvalInstance; response: string }>): IFEvalReport {
123
- let prompts = 0, instrFollowed = 0, instrTotal = 0;
124
- for (const { instance, response } of pairs) {
125
- const r = scoreInstance(instance, response);
126
- if (r.followedAll) prompts++;
127
- instrFollowed += r.perInstruction.filter(Boolean).length;
128
- instrTotal += r.perInstruction.length;
129
- }
66
+ /**
67
+ * Historical aggregation helper with unambiguous canonical fields plus aliases.
68
+ * Unknown instructions do not fail prompt accuracy, are excluded from
69
+ * instruction accuracy, and remain visible in `coverage`.
70
+ */
71
+ export function aggregate(
72
+ pairs: Array<{ instance: IFEvalInstance; response: string }>,
73
+ ): IFEvalReport {
74
+ const result = scoreIfevalPairs(pairs);
130
75
  return {
131
- n: pairs.length,
132
- promptAccuracy: pairs.length ? prompts / pairs.length : 0,
133
- instructionAccuracy: instrTotal ? instrFollowed / instrTotal : 0,
76
+ ...result,
77
+ n: result.nTotal,
78
+ promptAccuracy: result.strictAcc,
79
+ instructionAccuracy: result.strictInstructionAcc,
134
80
  };
135
81
  }
136
-
137
- /** Instruction ids this implementation supports (filter datasets to these). */
138
- export const SUPPORTED_INSTRUCTIONS = new Set(Object.keys(INSTRUCTIONS));
@@ -9,8 +9,12 @@ import { generate, type GenerateOptions } from "../generate";
9
9
  import * as ops from "../mlx/ops";
10
10
  import { ChatTemplate } from "../chat-template";
11
11
  import { loadModelConfig, type ModelConfig } from "../config";
12
- import { Weights } from "../weights";
13
- import { createModel, type RuntimeModel } from "../model/factory";
12
+ import {
13
+ openModel,
14
+ type Glm52RuntimeOpenOptions,
15
+ type RuntimeModel,
16
+ } from "../model/factory";
17
+ import { isGlm52Config } from "../model/support";
14
18
  import { DiffusionGemmaModel } from "../model/diffusion-gemma";
15
19
  import { loadTokenizer, type LoadedTokenizer } from "../tokenizer";
16
20
  import { resolveModelDir } from "./kl";
@@ -65,11 +69,16 @@ export interface TaskModel {
65
69
  activeAdapters?: string[];
66
70
  }
67
71
 
68
- export async function loadTaskModel(query: string, adapterDir?: string): Promise<TaskModel> {
72
+ export async function loadTaskModel(
73
+ query: string,
74
+ adapterDir?: string,
75
+ runtimeOptions: Glm52RuntimeOpenOptions = {},
76
+ ): Promise<TaskModel> {
69
77
  const dir = resolveModelDir(query);
70
78
  const config = await loadModelConfig(dir);
71
- const weights = await Weights.open(dir);
72
- const model = createModel(weights, config);
79
+ if (adapterDir && isGlm52Config(config))
80
+ throw new Error("GLM-5.2 does not support LoRA adapters");
81
+ const model = await openModel(dir, runtimeOptions);
73
82
  const activeAdapters: string[] = [];
74
83
  if (adapterDir) {
75
84
  // Mount a trained LoRA adapter so the eval measures base+adapter (e.g. the