mlx-bun 0.0.5 → 0.0.6

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 (57) hide show
  1. package/README.md +11 -2
  2. package/docs/dag/training-inference-map.html +575 -0
  3. package/docs/design/memory-system.md +193 -295
  4. package/docs/design/orpo-dynamic-lambda.md +179 -0
  5. package/docs/design/orpo-future-enhancements.md +42 -0
  6. package/docs/design/orpo-training.md +13 -0
  7. package/docs/design/parity-tier-dag.md +143 -0
  8. package/docs/design/w4a16-compute-precision-spike.md +232 -0
  9. package/docs/investigations/orpo-base-uf-experiment-and-directions.md +120 -0
  10. package/docs/investigations/orpo-flash-cce-pin-leak.md +167 -0
  11. package/docs/investigations/orpo-uf-testing-handoff.md +126 -0
  12. package/docs/investigations/steel-flash-cce-handoff.md +58 -0
  13. package/docs/investigations/trainer-validation-experiment.md +173 -0
  14. package/docs/reference/memory.md +77 -0
  15. package/docs/reference/orpo-quickstart.md +44 -5
  16. package/docs/reference/training.md +9 -5
  17. package/package.json +1 -1
  18. package/src/cli.ts +651 -18
  19. package/src/eval/runner.ts +76 -6
  20. package/src/eval/tasks/bfcl.ts +49 -15
  21. package/src/eval/tasks/gsm8k.ts +21 -9
  22. package/src/eval/tasks/hashhop.ts +33 -4
  23. package/src/eval/tasks/humaneval.ts +30 -8
  24. package/src/eval/tasks/ifeval.ts +52 -18
  25. package/src/eval/tasks/mmlu.ts +85 -74
  26. package/src/jobs/runner.ts +6 -0
  27. package/src/memory/chunk.ts +25 -0
  28. package/src/memory/cluster.ts +24 -0
  29. package/src/memory/db.ts +114 -0
  30. package/src/memory/ingest.ts +22 -0
  31. package/src/memory/pipeline.ts +63 -0
  32. package/src/memory/prompts.ts +23 -0
  33. package/src/memory/schedule.ts +163 -0
  34. package/src/memory/synthesize.ts +29 -0
  35. package/src/memory/tools.ts +399 -0
  36. package/src/memory/vault.ts +694 -0
  37. package/src/memory/wikify.ts +23 -0
  38. package/src/mlx/metal-kernel.ts +13 -0
  39. package/src/model/flash-attention.ts +5 -3
  40. package/src/pi-provider.ts +29 -13
  41. package/src/pi-session.ts +81 -0
  42. package/src/pi-terminal.ts +13 -24
  43. package/src/pi-web.ts +246 -166
  44. package/src/quantize/job.ts +17 -2
  45. package/src/quantize/quantizer.ts +26 -2
  46. package/src/server.ts +213 -44
  47. package/src/tokenizer.ts +6 -2
  48. package/src/train/dataset.ts +11 -3
  49. package/src/train/flash-cce.ts +18 -5
  50. package/src/train/job.ts +41 -4
  51. package/src/train/loss.ts +10 -4
  52. package/src/train/trainer.ts +62 -3
  53. package/src/train/watch.ts +542 -0
  54. package/src/web/app.html +264 -14
  55. package/src/web/skills/memory/SKILL.md +97 -0
  56. package/src/web/skills/web-research/SKILL.md +3 -1
  57. package/src/web/skills.ts +46 -8
@@ -392,8 +392,28 @@ async function computeMixedAllocation(
392
392
 
393
393
  progress("sensitivity", "Loading model for sensitivity analysis", 0.0);
394
394
 
395
- const config = await loadModelConfig(srcDir);
396
- const weights = await Weights.open(srcDir);
395
+ // The sensitivity sweep runs FORWARD passes, which need a model the runtime
396
+ // can load — and mlx-bun's model graph can only load QUANTIZED weights. If the
397
+ // source is bf16 (unquantized), build a temporary uniform 8-bit baseline
398
+ // (near-lossless) to probe against. The FINAL weights are still quantized from
399
+ // the original bf16 source per the allocation (quantizeModelDir reads srcDir),
400
+ // so fidelity is not capped by the baseline — the baseline only anchors the
401
+ // per-layer sensitivity (8→4-bit) measurement.
402
+ const srcConfig = await loadModelConfig(srcDir);
403
+ let probeDir = srcDir;
404
+ let tempBaseline: string | null = null;
405
+ if (!srcConfig.quantization) {
406
+ const { mkdtempSync } = await import("node:fs");
407
+ const { tmpdir } = await import("node:os");
408
+ const { join } = await import("node:path");
409
+ tempBaseline = mkdtempSync(join(tmpdir(), "mlx-bun-qbaseline-"));
410
+ progress("sensitivity", "Unquantized source — building an 8-bit baseline for the sweep", 0.0);
411
+ await quantizeModelDir(srcDir, tempBaseline, { bits: 8, groupSize, mode: "affine" });
412
+ probeDir = tempBaseline;
413
+ }
414
+
415
+ const config = await loadModelConfig(probeDir);
416
+ const weights = await Weights.open(probeDir);
397
417
  try {
398
418
  const model = createModel(weights, config);
399
419
  const tokenizer = await loadTokenizer(srcDir);
@@ -458,5 +478,9 @@ async function computeMixedAllocation(
458
478
  } finally {
459
479
  weights.dispose();
460
480
  clearCache();
481
+ if (tempBaseline) {
482
+ const { rmSync } = await import("node:fs");
483
+ rmSync(tempBaseline, { recursive: true, force: true });
484
+ }
461
485
  }
462
486
  }
package/src/server.ts CHANGED
@@ -667,18 +667,49 @@ export function promptEndsInOpenThink(rendered: string): boolean {
667
667
  return open !== -1 && open > rendered.lastIndexOf("</think>");
668
668
  }
669
669
 
670
+ /** Concatenate the text of an OpenAI content-part array, ignoring non-text
671
+ * parts. Tolerant of the part shapes clients actually send: `{type:"text",
672
+ * text}` (OpenAI/pi), `{type:"input_text", text}` (Responses-style), or any
673
+ * part carrying a string `text`. */
674
+ function contentPartsToText(parts: Array<Record<string, unknown>>): string {
675
+ return parts
676
+ .map((p) => (p && typeof p.text === "string" ? p.text : ""))
677
+ .join("");
678
+ }
679
+
680
+ /** True if any content part is an image (so the vision path must keep the
681
+ * array form for extractImages). */
682
+ function hasImagePart(parts: Array<Record<string, unknown>>): boolean {
683
+ return parts.some((p) => p && (p.type === "image" || p.type === "image_url"));
684
+ }
685
+
670
686
  /** OpenAI sends assistant tool_call arguments as JSON strings; the
671
687
  * template renders the object form natively — normalize before render.
672
- * Also maps the OpenAI reasoning-model "developer" role to "system": pi-ai
673
- * (and OpenAI's own SDKs) rename the system prompt to `developer` whenever a
674
- * model advertises reasoning, but our chat templates only know
675
- * system/user/assistant/tool and raise "Unexpected message role." otherwise.
676
- * This is exactly why Qwen3.5/MiniCPM5 chat got no messages while Gemma (a
677
- * non-reasoning model, so pi keeps `system`) worked. `developer` IS the
678
- * instruction/system prompt, so the remap is semantics-preserving. */
688
+ *
689
+ * Two more wire-format → template-format fixes, both "match the format the
690
+ * model's chat template expects":
691
+ *
692
+ * 1. Map the OpenAI reasoning-model "developer" role to "system": pi-ai (and
693
+ * OpenAI's own SDKs) rename the system prompt to `developer` whenever a
694
+ * model advertises reasoning, but our chat templates only know
695
+ * system/user/assistant/tool and raise "Unexpected message role." This is
696
+ * why Qwen3.5/MiniCPM5 chat got no messages while Gemma (non-reasoning, so
697
+ * pi keeps `system`) worked. `developer` IS the system prompt, so the remap
698
+ * is semantics-preserving.
699
+ *
700
+ * 2. Flatten text-only content-part arrays to a plain string. pi (and any
701
+ * OpenAI multimodal client) sends user content as `[{type:"text",text}]`,
702
+ * but non-vision chat templates expect `content` to be a STRING and render
703
+ * nothing for an array — so the user's turn silently vanishes and the model
704
+ * replies "I don't see any message." Arrays that carry an image part are
705
+ * left intact for the vision path (extractImages); only text-only arrays
706
+ * are collapsed here, which is a no-op for the vision templates too. */
679
707
  export function normalizeMessages(messages: ChatMessage[]): ChatMessage[] {
680
708
  return messages.map((raw) => {
681
- const m = raw.role === "developer" ? { ...raw, role: "system" } : raw;
709
+ let m = raw.role === "developer" ? { ...raw, role: "system" } : raw;
710
+ if (Array.isArray(m.content) && !hasImagePart(m.content)) {
711
+ m = { ...m, content: contentPartsToText(m.content) };
712
+ }
682
713
  if (!m.tool_calls) return m;
683
714
  return {
684
715
  ...m,
@@ -880,43 +911,61 @@ export function createServer(
880
911
  `Omit --kv-quant to batch in bf16. (docs/design/parallel-slots.md)`,
881
912
  );
882
913
 
883
- const toOptions = (req: ChatRequest): GenerateOptions & { stopSequences: string[] } => ({
884
- maxTokens: req.max_completion_tokens ?? req.max_tokens ?? 1024,
885
- temperature: req.temperature ?? serverOptions.defaultTemperature ?? ctx.genDefaults.temperature ?? 0.7,
886
- topP: req.top_p ?? serverOptions.defaultTopP ?? ctx.genDefaults.topP ?? 0,
887
- topK: req.top_k ?? serverOptions.defaultTopK ?? ctx.genDefaults.topK ?? 0,
888
- seed: req.seed ?? (Date.now() & 0xffffffff),
889
- repetitionPenalty: req.repetition_penalty ?? ctx.genDefaults.repetitionPenalty,
890
- hlg: resolveHlg(req.hlg, serverOptions.hlg),
891
- // generate() yields token ids; stop sequences match on decoded text
892
- // (they can span token boundaries), so the StopMatcher sits at the
893
- // decode layer below and halts the loop via onToken → false.
894
- stopSequences: (typeof req.stop === "string" ? [req.stop] : req.stop ?? [])
895
- .filter((s) => typeof s === "string" && s.length > 0),
896
- ...kvScheme,
897
- });
898
-
899
- const templateOptionsFor = (req: ChatRequest, tools: ToolDefinition[] | null) => {
900
- // Precedence: explicit per-request chat_template_kwargs wins; else the
901
- // standard reasoning_effort (the OpenAI field Pi sends — "none" → no
902
- // thinking, any level → thinking on); else the server-wide --thinking
903
- // default; else the model's own default (false for MiniCPM5, otherwise
904
- // leave the template's default). The Anthropic/Responses shims map their
905
- // reasoning fields onto reasoning_effort before reaching here.
914
+ // Effective enable_thinking for a request, with the same precedence the chat
915
+ // template uses (extracted so sampling and template rendering can't disagree):
916
+ // explicit chat_template_kwargs.enable_thinking → reasoning_effort ("none" =
917
+ // off) → server --thinking default → model default (MiniCPM5 → off). undefined
918
+ // means "not a switchable-thinking model / leave the template default".
919
+ const resolveEnableThinking = (req: ChatRequest): boolean | undefined => {
906
920
  const explicit = req.chat_template_kwargs?.enable_thinking;
921
+ if (typeof explicit === "boolean") return explicit;
907
922
  const effort = req.reasoning_effort;
923
+ if (effort !== undefined) return effort !== "none";
924
+ if (serverOptions.defaultThinking !== undefined) return serverOptions.defaultThinking;
925
+ return isMiniCPM5Config(ctx.model.config) ? false : undefined;
926
+ };
927
+
928
+ const toOptions = (req: ChatRequest): GenerateOptions & { stopSequences: string[] } => {
929
+ // Sampling follows the thinking state — which the web UI's thinking button
930
+ // drives via enable_thinking. Model authors publish a SINGLE
931
+ // generation_config temperature (the think-mode value) but recommend a
932
+ // cooler one for direct, no-think replies (MiniCPM5 card: 0.9 think / 0.7
933
+ // no-think, top_p 0.95 for both). So with no explicit temperature set, a
934
+ // no-think turn runs at most NO_THINK_TEMPERATURE while a think turn keeps
935
+ // the model's hotter configured default. An explicit request/CLI
936
+ // temperature always wins. top_p is unchanged by mode.
937
+ const NO_THINK_TEMPERATURE = 0.7;
938
+ const genTemp = ctx.genDefaults.temperature ?? 0.7;
939
+ const defaultTemp = resolveEnableThinking(req) === false
940
+ ? Math.min(genTemp, NO_THINK_TEMPERATURE)
941
+ : genTemp;
908
942
  return {
909
- tools,
910
- enableThinking: typeof explicit === "boolean"
911
- ? explicit
912
- : effort !== undefined
913
- ? effort !== "none"
914
- : serverOptions.defaultThinking !== undefined
915
- ? serverOptions.defaultThinking
916
- : isMiniCPM5Config(ctx.model.config) ? false : undefined,
943
+ // A thinking turn emits <think>…</think> AND the answer, so a tight cap
944
+ // truncates the visible reply. Default very generously (the model's
945
+ // context is far larger); only an explicit max_tokens narrows it. The
946
+ // model still stops at its eos_token well before this in normal replies.
947
+ maxTokens: req.max_completion_tokens ?? req.max_tokens ?? 65_536,
948
+ temperature: req.temperature ?? serverOptions.defaultTemperature ?? defaultTemp,
949
+ topP: req.top_p ?? serverOptions.defaultTopP ?? ctx.genDefaults.topP ?? 0,
950
+ topK: req.top_k ?? serverOptions.defaultTopK ?? ctx.genDefaults.topK ?? 0,
951
+ seed: req.seed ?? (Date.now() & 0xffffffff),
952
+ repetitionPenalty: req.repetition_penalty ?? ctx.genDefaults.repetitionPenalty,
953
+ hlg: resolveHlg(req.hlg, serverOptions.hlg),
954
+ // generate() yields token ids; stop sequences match on decoded text
955
+ // (they can span token boundaries), so the StopMatcher sits at the
956
+ // decode layer below and halts the loop via onToken → false.
957
+ stopSequences: (typeof req.stop === "string" ? [req.stop] : req.stop ?? [])
958
+ .filter((s) => typeof s === "string" && s.length > 0),
959
+ ...kvScheme,
917
960
  };
918
961
  };
919
962
 
963
+ const templateOptionsFor = (req: ChatRequest, tools: ToolDefinition[] | null) => {
964
+ // enableThinking resolution (and its precedence) lives in
965
+ // resolveEnableThinking so template rendering and sampling stay in sync.
966
+ return { tools, enableThinking: resolveEnableThinking(req) };
967
+ };
968
+
920
969
  const promptIdsFor = (
921
970
  req: ChatRequest,
922
971
  tools: ToolDefinition[] | null,
@@ -980,6 +1029,16 @@ export function createServer(
980
1029
  try { html = readFileSync(new URL("./assets/curve-designer.html", import.meta.url), "utf8"); } catch { /* binary: use embedded */ }
981
1030
  return new Response(html, { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });
982
1031
  }
1032
+ // Training/inference DAG map — self-contained cytoscape artifact, served
1033
+ // same-origin so the Routes tab can embed it in an <iframe src="/dag">.
1034
+ if (url.pathname === "/dag" && request.method === "GET") {
1035
+ try {
1036
+ const html = readFileSync(new URL("../docs/dag/training-inference-map.html", import.meta.url), "utf8");
1037
+ return new Response(html, { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });
1038
+ } catch {
1039
+ return new Response("DAG map artifact not found at docs/dag/training-inference-map.html", { status: 404 });
1040
+ }
1041
+ }
983
1042
  if (url.pathname === "/curve-terrain" && request.method === "GET") {
984
1043
  try {
985
1044
  const html = readFileSync(new URL("../docs/investigations/curve-terrain.html", import.meta.url), "utf8");
@@ -1000,7 +1059,11 @@ export function createServer(
1000
1059
  const { Registry } = await import("./registry");
1001
1060
  const { loadModelConfig } = await import("./config");
1002
1061
  const reg = new Registry();
1003
- if (reg.list().length === 0) await reg.scan();
1062
+ // Always rescan when (re)building the 30s cache so models that appeared
1063
+ // after boot — fresh downloads AND quants written into the HF cache —
1064
+ // surface on their own. (scan() is INSERT-OR-REPLACE + prunes deleted,
1065
+ // so it's idempotent and cheap for a local cache.)
1066
+ await reg.scan();
1004
1067
  const rows = [];
1005
1068
  for (const m of reg.list()) {
1006
1069
  const supported = isSupportedModelRecord(m.modelType, m.repoId);
@@ -1747,6 +1810,78 @@ export function createServer(
1747
1810
  const { inspectModel } = await import("./quantize");
1748
1811
  return Response.json(await inspectModel(body.model_id ?? ""));
1749
1812
  }
1813
+ // Turn an OS-picked folder into the absolute snapshot path on disk. The
1814
+ // browser can't reveal a filesystem path (security) and the HF cache
1815
+ // stores real bytes in blobs/ with symlinked snapshots — so we resolve by
1816
+ // the folder's NAME (which encodes the repo id), never by reading files.
1817
+ // No upload, no dependence on the cache's symlink layout, and a
1818
+ // just-downloaded model resolves before it's ever been indexed.
1819
+ if ((url.pathname === "/api/quantize/resolve-folder" || url.pathname === "/api/model/resolve-folder") && request.method === "POST") {
1820
+ const body = (await request.json().catch(() => ({}))) as { folder_name?: string; rel_path?: string };
1821
+ const { statSync, readdirSync, readFileSync } = await import("node:fs");
1822
+ const { join } = await import("node:path");
1823
+ const { homedir } = await import("node:os");
1824
+ const hubRoot = process.env.HF_HUB_CACHE
1825
+ ?? (process.env.HF_HOME ? join(process.env.HF_HOME, "hub") : join(homedir(), ".cache/huggingface/hub"));
1826
+ const roots = [hubRoot, join(homedir(), ".cache/mlx-bun")];
1827
+ const hasConfig = (d: string) => { try { return statSync(join(d, "config.json")).isFile(); } catch { return false; } };
1828
+ // basename of the picked folder; the rel path's last-but-one segment is
1829
+ // the <hash> dir if they drilled into a snapshot.
1830
+ const folder = (body.folder_name ?? "").replace(/\\/g, "/").split("/").filter(Boolean).pop() ?? "";
1831
+ const relSegs = (body.rel_path ?? "").replace(/\\/g, "/").split("/").filter(Boolean);
1832
+ const configDir = relSegs.length >= 2 ? relSegs[relSegs.length - 2] : "";
1833
+ const repoIdOf = (modelsDir: string) => modelsDir.slice("models--".length).replaceAll("--", "/");
1834
+ // Pick a repo dir's canonical snapshot: the one refs/main points at, else
1835
+ // the first snapshot that actually has a config.json.
1836
+ const pickSnapshot = (repoDir: string): string | null => {
1837
+ const snaps = join(repoDir, "snapshots");
1838
+ let head = "";
1839
+ try { head = readFileSync(join(repoDir, "refs", "main"), "utf8").trim(); } catch {}
1840
+ if (head && hasConfig(join(snaps, head))) return join(snaps, head);
1841
+ try { for (const h of readdirSync(snaps)) if (hasConfig(join(snaps, h))) return join(snaps, h); } catch {}
1842
+ return null;
1843
+ };
1844
+
1845
+ // (1) HF-cache folder picked — its name IS the repo id.
1846
+ if (folder.startsWith("models--")) {
1847
+ const p = pickSnapshot(join(hubRoot, folder));
1848
+ if (p) return Response.json({ ok: true, path: p, repo_id: repoIdOf(folder) });
1849
+ }
1850
+ // (2) A bare <hash> snapshot folder picked directly (or carried in rel).
1851
+ const hashDir = configDir || folder;
1852
+ if (hashDir) {
1853
+ try {
1854
+ for (const repo of readdirSync(hubRoot)) {
1855
+ if (!repo.startsWith("models--")) continue;
1856
+ const cand = join(hubRoot, repo, "snapshots", hashDir);
1857
+ if (hasConfig(cand)) return Response.json({ ok: true, path: cand, repo_id: repoIdOf(repo) });
1858
+ }
1859
+ } catch {}
1860
+ }
1861
+ // (3) Flat local snapshot under a known root (folder name == model dir).
1862
+ for (const root of roots) {
1863
+ if (folder && hasConfig(join(root, folder))) return Response.json({ ok: true, path: join(root, folder) });
1864
+ }
1865
+ // (4) Registry rescan — last resort (also covers customized cache layouts).
1866
+ const { Registry } = await import("./registry");
1867
+ const reg = new Registry();
1868
+ try {
1869
+ await reg.scan();
1870
+ const all = reg.list();
1871
+ const rec = (folder.startsWith("models--")
1872
+ ? all.find((m) => m.repoId === repoIdOf(folder))
1873
+ : undefined)
1874
+ ?? all.find((m) => m.path.split("/").pop() === hashDir)
1875
+ ?? all.find((m) => m.repoId.split("/").pop() === folder);
1876
+ if (rec) return Response.json({ ok: true, path: rec.path, repo_id: rec.repoId, model_type: rec.modelType });
1877
+ } finally {
1878
+ reg.close();
1879
+ }
1880
+ return Response.json({
1881
+ ok: false,
1882
+ error: "Couldn't locate this folder on disk — paste the path instead.",
1883
+ });
1884
+ }
1750
1885
  if (url.pathname === "/api/quantize/submit" && request.method === "POST") {
1751
1886
  const body = (await request.json().catch(() => ({}))) as {
1752
1887
  model_id?: string; bits?: number; group_size?: number;
@@ -1758,18 +1893,52 @@ export function createServer(
1758
1893
  const store = await ensureJobs();
1759
1894
  const { submitSubprocess } = await import("./jobs");
1760
1895
  const { homedir } = await import("node:os");
1896
+ const { join } = await import("node:path");
1897
+ const { mkdirSync, writeFileSync } = await import("node:fs");
1898
+ const { createHash } = await import("node:crypto");
1761
1899
  const bits = body.bits ?? 4, gs = body.group_size ?? 64;
1762
- const base = body.model_id.split("/").filter(Boolean).at(-1)!.replace(/[^a-z0-9_.-]/gi, "");
1763
- // Mixed-precision (sensitivity+knapsack) names the dir by target bpw.
1900
+ // Derive org/name from the source so the quant is named readably (the
1901
+ // source is usually an HF-cache snapshot path whose basename is a hash).
1902
+ const snapMatch = body.model_id.match(/(models--[^/]+)\/snapshots\//);
1903
+ let org = "local", name: string;
1904
+ if (snapMatch) {
1905
+ const parts = snapMatch[1].split("--"); // ["models", org, ...name]
1906
+ org = parts[1] ?? "local";
1907
+ name = parts.slice(2).join("--");
1908
+ } else if (body.model_id.includes("/") && !body.model_id.startsWith("/") && !body.model_id.startsWith("~")) {
1909
+ const seg = body.model_id.split("/"); // a repo id "org/name"
1910
+ org = seg[0]!; name = seg.slice(1).join("-");
1911
+ } else {
1912
+ name = body.model_id.split("/").filter(Boolean).at(-1) ?? "model"; // a bare path
1913
+ }
1914
+ name = (name || "model").replace(/[^a-z0-9_.-]/gi, "");
1915
+ org = (org || "local").replace(/[^a-z0-9_.-]/gi, "");
1764
1916
  const suffix = body.target_bpw ? `mixed-${body.target_bpw}bpw` : `${bits}bit`;
1765
- const outDir = `${homedir()}/.cache/mlx-bun/quants/${base}-OptiQ-${suffix}`;
1917
+ // Write the quant INTO the HF hub cache as a normal models--org--name/
1918
+ // snapshots/<hash> entry, so the standard registry scan + every other
1919
+ // tool discovers it alongside downloaded models. refs/main makes it a
1920
+ // well-formed cache entry.
1921
+ const quantRepo = `${name}-OptiQ-${suffix}`;
1922
+ const hubRoot = process.env.HF_HUB_CACHE
1923
+ ?? (process.env.HF_HOME ? join(process.env.HF_HOME, "hub") : join(homedir(), ".cache/huggingface/hub"));
1924
+ const repoDir = join(hubRoot, `models--${org}--${quantRepo}`);
1925
+ const snapHash = createHash("sha1").update(`${org}/${quantRepo}`).digest("hex");
1926
+ const outDir = join(repoDir, "snapshots", snapHash);
1927
+ try {
1928
+ mkdirSync(join(repoDir, "refs"), { recursive: true });
1929
+ writeFileSync(join(repoDir, "refs", "main"), snapHash);
1930
+ } catch {}
1766
1931
  const { jobId } = submitSubprocess(store, "quantize", {
1767
1932
  model_id: body.model_id, out_dir: outDir, bits, group_size: gs,
1768
1933
  // forwarded to the mixed-precision path when target_bpw is set
1769
1934
  target_bpw: body.target_bpw, candidate_bits: body.candidate_bits,
1770
1935
  reference: body.reference, calibration_mix: body.calibration_mix,
1771
1936
  n_calibration: body.n_calibration,
1772
- }, outDir);
1937
+ }, outDir, {
1938
+ // The model is in the HF cache now; drop the Library cache so the next
1939
+ // poll rescans and shows it — no `mlx-bun scan`, no restart.
1940
+ onComplete: () => { libraryCache = null; },
1941
+ });
1773
1942
  return Response.json({ ok: true, job_id: jobId, output_dir: outDir });
1774
1943
  }
1775
1944
 
package/src/tokenizer.ts CHANGED
@@ -6,7 +6,10 @@
6
6
  import { Tokenizer } from "@huggingface/tokenizers";
7
7
 
8
8
  export interface LoadedTokenizer {
9
- encode(text: string): number[];
9
+ /** Encode text → token ids. `addSpecialTokens` (default true) prepends the BOS;
10
+ * pass FALSE for chat-template output, which already emits the BOS as text (else
11
+ * you get a corrupting double-BOS `<s><s>…`). */
12
+ encode(text: string, addSpecialTokens?: boolean): number[];
10
13
  decode(ids: number[], skipSpecialTokens?: boolean): string;
11
14
  readonly bosTokenId: number | null;
12
15
  readonly eosTokenId: number | null;
@@ -28,7 +31,8 @@ export async function loadTokenizer(modelDir: string): Promise<LoadedTokenizer>
28
31
  };
29
32
 
30
33
  return {
31
- encode: (text) => tok.encode(text).ids.map(Number),
34
+ encode: (text, addSpecialTokens = true) =>
35
+ tok.encode(text, { add_special_tokens: addSpecialTokens }).ids.map(Number),
32
36
  decode: (ids, skipSpecialTokens = false) =>
33
37
  // python's decode([]) === ""; the JS lib throws on empty input
34
38
  ids.length === 0 ? "" : tok.decode(ids, { skip_special_tokens: skipSpecialTokens }),
@@ -269,14 +269,22 @@ export function tokenizePair(
269
269
  tok: LoadedTokenizer,
270
270
  maxLength: number,
271
271
  ): { ids: number[]; promptLen: number } {
272
- const promptIds = tok.encode(prompt);
273
- let fullIds = tok.encode(prompt + response);
272
+ // A chat template emits a literal BOS, and the tokenizer's post-processor
273
+ // prepends one too → a duplicate leading BOS. Inference strips it
274
+ // (server.ts: "template includes <bos>; tokenizer also prepends one"), so
275
+ // training must match or it learns on a token stream the model never sees.
276
+ // No-op for raw (untemplated) prompts, which carry a single BOS.
277
+ const bos = tok.bosTokenId;
278
+ const dedupeBos = (ids: number[]): number[] =>
279
+ bos != null && ids.length >= 2 && ids[0] === bos && ids[1] === bos ? ids.slice(1) : ids;
280
+ const promptIds = dedupeBos(tok.encode(prompt));
281
+ let fullIds = dedupeBos(tok.encode(prompt + response));
274
282
  let promptLen: number;
275
283
  if (isPrefix(fullIds, promptIds)) {
276
284
  promptLen = promptIds.length;
277
285
  } else {
278
286
  promptLen = promptIds.length;
279
- const responseIds = tok.encode(response);
287
+ const responseIds = tok.encode(response, false); // no extra BOS mid-stream
280
288
  fullIds = [...promptIds, ...responseIds];
281
289
  }
282
290
  if (fullIds.length > maxLength) {
@@ -927,7 +927,13 @@ function fwdKernel(): MetalKernel {
927
927
  }
928
928
 
929
929
  function u32(values: number[]): MlxArray {
930
- return MlxArray.fromView(new Uint8Array(new Uint32Array(values).buffer.slice(0)), [values.length], Dtype.uint32);
930
+ // COPY into an mlx-owned array (fromBytesCopy = mlx_array_new_data), NOT the
931
+ // pinning fromView. These tiny kernel-arg arrays (shape, targets) feed a LAZY
932
+ // kernel and are disposed before it evaluates; pinning leaks one host buffer
933
+ // per call, never released (unbounded `pinned` map + a deferred native
934
+ // use-after-free under memory pressure — the seg/accum crash). A few-int copy
935
+ // is free; correctness is not.
936
+ return MlxArray.fromBytesCopy(new Uint8Array(new Uint32Array(values).buffer), [values.length], Dtype.uint32);
931
937
  }
932
938
 
933
939
  /** Flash-CCE forward: per-token response log-prob [M] (f32) + lse [M] (f32),
@@ -1064,12 +1070,19 @@ export function flashCceForward(
1064
1070
  // reduction is simpler and just as exact.)
1065
1071
  const gMax = ops.maxAxis(bMax, 1, false); // [M]
1066
1072
  const gMaxCol = ops.reshape(gMax, [M, 1]);
1067
- const shifted = ops.exp(ops.sub(bMax, gMaxCol)); // [M, NBLK]
1068
- const gSum = ops.sumAxis(ops.mul(bSum, shifted), 1, false); // [M]
1069
- const lse = ops.add(gMax, ops.log(gSum)); // [M]
1073
+ // Bind every nested intermediate so its MlxArray wrapper gets disposed — a
1074
+ // bare `ops.exp(ops.sub(...))` leaks the inner `sub` (stays live in `active`
1075
+ // until the GC finalizer backstop), and this runs TWICE per step (chosen +
1076
+ // rejected). gMax was also missing from the dispose list below.
1077
+ const sub = ops.sub(bMax, gMaxCol);
1078
+ const shifted = ops.exp(sub); // [M, NBLK]
1079
+ const prod = ops.mul(bSum, shifted);
1080
+ const gSum = ops.sumAxis(prod, 1, false); // [M]
1081
+ const logG = ops.log(gSum);
1082
+ const lse = ops.add(gMax, logG); // [M]
1070
1083
  const tgt = ops.sumAxis(bTgt, 1, false); // [M] (owning block only is nonzero)
1071
1084
  const logp = ops.sub(tgt, lse);
1072
- for (const a of [gMaxCol, shifted, gSum, tgt]) a.dispose();
1085
+ for (const a of [gMax, gMaxCol, sub, shifted, prod, gSum, logG, tgt]) a.dispose();
1073
1086
  const blockMax = bMax!; bMax = undefined; // hand off to caller (backward block-skip)
1074
1087
  return { logp, lse, blockMax };
1075
1088
  } finally {
package/src/train/job.ts CHANGED
@@ -39,6 +39,8 @@ interface FinetuneSubmit {
39
39
  mlp_split?: boolean;
40
40
  segment_size?: number;
41
41
  save_checkpoints?: boolean;
42
+ grad_clip_norm?: number;
43
+ val_max_examples?: number;
42
44
  dpo_beta?: number;
43
45
  dpo_warmup_iters?: number;
44
46
  dpo_lr_schedule?: "constant" | "cosine";
@@ -58,6 +60,14 @@ function parseConfig(raw: Record<string, unknown>): { modelDir: string; dataDir:
58
60
  if (!c.data_dir) throw new Error("finetune job: missing data_dir");
59
61
  if (!c.adapter_path) throw new Error("finetune job: missing adapter_path");
60
62
 
63
+ // For ORPO the full L3 stack is the validated production recipe (what the
64
+ // `mlx-bun train` CLI turns on): flash-CCE head + prefix-sharing + segmented
65
+ // backward + token-chunked head. DEFAULT_TRAIN_CONFIG keeps them OFF (they're
66
+ // opt-in for SFT), so default them ON here when method=orpo — an explicit
67
+ // value from the caller (CLI/API) still wins via ??. finetuneRunner drops the
68
+ // quantized-head pieces if the base turns out to be unquantized.
69
+ const isOrpo = (c.method ?? "sft") === "orpo";
70
+
61
71
  const cfg: TrainConfig = {
62
72
  ...DEFAULT_TRAIN_CONFIG,
63
73
  method: c.method ?? "sft",
@@ -81,18 +91,20 @@ function parseConfig(raw: Record<string, unknown>): { modelDir: string; dataDir:
81
91
  loraPlusRatio: c.lora_plus_ratio ?? DEFAULT_TRAIN_CONFIG.loraPlusRatio,
82
92
  gradCheckpoint: c.grad_checkpoint ?? DEFAULT_TRAIN_CONFIG.gradCheckpoint,
83
93
  mlpSplit: c.mlp_split ?? DEFAULT_TRAIN_CONFIG.mlpSplit,
84
- segmentSize: c.segment_size ?? DEFAULT_TRAIN_CONFIG.segmentSize,
94
+ segmentSize: c.segment_size ?? (isOrpo ? 2 : DEFAULT_TRAIN_CONFIG.segmentSize),
85
95
  saveCheckpoints: c.save_checkpoints ?? DEFAULT_TRAIN_CONFIG.saveCheckpoints,
96
+ gradClipNorm: c.grad_clip_norm ?? DEFAULT_TRAIN_CONFIG.gradClipNorm,
97
+ valMaxExamples: c.val_max_examples ?? DEFAULT_TRAIN_CONFIG.valMaxExamples,
86
98
  dpoBeta: c.dpo_beta ?? DEFAULT_TRAIN_CONFIG.dpoBeta,
87
99
  dpoWarmupIters: c.dpo_warmup_iters ?? DEFAULT_TRAIN_CONFIG.dpoWarmupIters,
88
100
  dpoLrSchedule: c.dpo_lr_schedule ?? DEFAULT_TRAIN_CONFIG.dpoLrSchedule,
89
101
  orpoLambda: c.orpo_lambda ?? DEFAULT_TRAIN_CONFIG.orpoLambda,
90
102
  orpoWarmupIters: c.orpo_warmup_iters ?? DEFAULT_TRAIN_CONFIG.orpoWarmupIters,
91
103
  orpoLrSchedule: c.orpo_lr_schedule ?? DEFAULT_TRAIN_CONFIG.orpoLrSchedule,
92
- orpoChunkSize: c.orpo_chunk_size ?? DEFAULT_TRAIN_CONFIG.orpoChunkSize,
104
+ orpoChunkSize: c.orpo_chunk_size ?? (isOrpo ? 512 : DEFAULT_TRAIN_CONFIG.orpoChunkSize),
93
105
  orpoFusedCe: c.orpo_fused_ce ?? DEFAULT_TRAIN_CONFIG.orpoFusedCe,
94
- orpoFlashCe: c.orpo_flash_ce ?? DEFAULT_TRAIN_CONFIG.orpoFlashCe,
95
- orpoPrefixShared: c.orpo_prefix_shared ?? DEFAULT_TRAIN_CONFIG.orpoPrefixShared,
106
+ orpoFlashCe: c.orpo_flash_ce ?? (isOrpo ? true : DEFAULT_TRAIN_CONFIG.orpoFlashCe),
107
+ orpoPrefixShared: c.orpo_prefix_shared ?? (isOrpo ? true : DEFAULT_TRAIN_CONFIG.orpoPrefixShared),
96
108
  warmStartAdapter: c.warm_start_adapter ?? DEFAULT_TRAIN_CONFIG.warmStartAdapter,
97
109
  adapterPath: c.adapter_path,
98
110
  baseModel: c.model_dir,
@@ -103,6 +115,18 @@ function parseConfig(raw: Record<string, unknown>): { modelDir: string; dataDir:
103
115
  export const finetuneRunner: JobRunner = async (emit, config) => {
104
116
  const { modelDir, dataDir, cfg } = parseConfig(config);
105
117
 
118
+ // Gemma/e4b needs these training env flags set BEFORE the model loads — same
119
+ // as the `mlx-bun train` CLI. The subprocess job path doesn't inherit them
120
+ // from the server, so without this a web-submitted ORPO/SFT run on Gemma uses
121
+ // the wrong kernels. ??= so an explicit env (or the CLI) still wins.
122
+ try {
123
+ const isGemma = (await Bun.file(`${modelDir}/config.json`).text()).toLowerCase().includes("gemma");
124
+ if (isGemma) {
125
+ process.env.MLX_BUN_PERF_KERNEL ??= "0";
126
+ process.env.MLX_BUN_FUSED_GELU ??= "0";
127
+ }
128
+ } catch {}
129
+
106
130
  emit({ type: "stage", stage: "load", progress: 0.01, message: `loading model ${modelDir}` });
107
131
 
108
132
  const { loadModelConfig } = await import("../config");
@@ -112,6 +136,19 @@ export const finetuneRunner: JobRunner = async (emit, config) => {
112
136
  const { ChatTemplate } = await import("../chat-template");
113
137
 
114
138
  const modelConfig = await loadModelConfig(modelDir);
139
+
140
+ // The flash-CCE / fused-CE heads dequantize a QUANTIZED head in-kernel; on an
141
+ // unquantized (bf16) base they can't run, so fall back to the full-logits head.
142
+ // (Quantize the base first to get the large-vocab memory win — see the
143
+ // Quantize tab.)
144
+ if (cfg.method === "orpo" && !modelConfig.quantization && (cfg.orpoFlashCe || cfg.orpoFusedCe)) {
145
+ emit({
146
+ type: "stage", stage: "load",
147
+ message: "unquantized base — flash/fused-CE head needs a quantized head; using full-logits head",
148
+ });
149
+ cfg.orpoFlashCe = false;
150
+ cfg.orpoFusedCe = false;
151
+ }
115
152
  const weights = await Weights.open(modelDir);
116
153
  const model = createModel(weights, modelConfig);
117
154
  const tok = await loadTokenizer(modelDir);
package/src/train/loss.ts CHANGED
@@ -839,7 +839,8 @@ function makeVocabBlockedHeadVjp(
839
839
  const raw = ops.quantizedMatmul(hc, wb.w, wb.scales, wb.biases, head.spec, true); // [Cc, Vb]
840
840
  if (cap === null) return { lb: raw, sech2: null };
841
841
  const capArr = ops.scalarLike(cap, raw);
842
- const th = ops.tanh(ops.div(raw, capArr));
842
+ const div = ops.div(raw, capArr);
843
+ const th = ops.tanh(div); div.dispose(); // bind the nested div so its wrapper is freed
843
844
  raw.dispose();
844
845
  const lb = ops.mul(th, capArr); // cap·tanh(raw/cap)
845
846
  const th2 = ops.mul(th, th); th.dispose();
@@ -964,7 +965,10 @@ function makeVocabBlockedHeadVjp(
964
965
  const colFlat = ops.arange(v0, v1, 1, Dtype.int32);
965
966
  const col = ops.reshape(colFlat, [1, Vb]); colFlat.dispose();
966
967
  const tcol = MlxArray.fromInt32(targetsHost.slice(c0, c1), [Cc, 1]);
967
- const eqMask = ops.logicalAnd(ops.greaterEqual(col, tcol), ops.greaterEqual(tcol, col));
968
+ const ge1 = ops.greaterEqual(col, tcol);
969
+ const ge2 = ops.greaterEqual(tcol, col);
970
+ const eqMask = ops.logicalAnd(ge1, ge2); // bind the nested compares so their wrappers free
971
+ ge1.dispose(); ge2.dispose();
968
972
  col.dispose(); tcol.dispose();
969
973
  const onehot = eqMask.astype(softmax.dtype); eqMask.dispose();
970
974
  let g = ops.sub(onehot, softmax); // onehot − softmax [Cc, Vb]
@@ -1000,7 +1004,8 @@ function makeVocabBlockedHeadVjp(
1000
1004
  /** flash-CCE head as a CustomVjp: forward runs the Metal kernel → Σ logp (stashing
1001
1005
  * `lse` for the backward, avoiding a forward recompute); backward runs the kernel's
1002
1006
  * `dh` for unit cotangent then scales by the scalar cotangent `c` (loss = Σ logp →
1003
- * ∂loss/∂logp_t = c for all t). The coeff filter is on by default in the backward. */
1007
+ * ∂loss/∂logp_t = c for all t). The coeff filter + vocab-block skip are OFF by
1008
+ * default (env-gated MLX_BUN_CCE_BWD_*_EPS) — the backward computes the EXACT dh. */
1004
1009
  function makeFlashCceHeadVjp(
1005
1010
  head: HeadQuant, targetsHost: Int32Array, M: number,
1006
1011
  sink: Array<{ dispose(): void }>,
@@ -1031,7 +1036,8 @@ function makeFlashCceHeadVjp(
1031
1036
  const c = cots[0]!; // [1] = ∂loss/∂(Σ logp); uniform over tokens
1032
1037
  // dh for unit cotangent, then × c (avoids a host read of the cotangent).
1033
1038
  const ones = new Array(M).fill(1) as number[];
1034
- // filter + block-skip default-on (both approximations the training path accepts).
1039
+ // filter + block-skip are OFF by default (env-gated) → EXACT dh. savedBlockMax is
1040
+ // passed so the lossless vocab-block skip can be enabled via env without re-plumbing.
1035
1041
  const dhUnit = flashCceBackward(hR, fch, targets, savedLse!, ones, undefined, savedBlockMax!); // [M, hidden] f32
1036
1042
  const cM = c.dtype === dhUnit.dtype ? c : c.astype(dhUnit.dtype);
1037
1043
  const dh = ops.mul(dhUnit, cM); // broadcast [M,hidden]·[1]