mlx-bun 0.0.4 → 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 (90) hide show
  1. package/README.md +175 -93
  2. package/docs/dag/training-inference-map.html +575 -0
  3. package/docs/design/adapters-end-to-end.md +50 -46
  4. package/docs/design/compat-cli-surface-design.md +10 -8
  5. package/docs/design/docs-reorg-plan.md +14 -1
  6. package/docs/design/hlg-sampling.md +12 -2
  7. package/docs/design/memory-system.md +193 -295
  8. package/docs/design/optimization_plan.md +4 -2
  9. package/docs/design/orpo-dynamic-lambda.md +179 -0
  10. package/docs/design/orpo-future-enhancements.md +340 -0
  11. package/docs/design/orpo-training.md +1212 -0
  12. package/docs/design/parallel-slots.md +34 -26
  13. package/docs/design/parity-tier-dag.md +143 -0
  14. package/docs/design/runtime-isolation.md +1 -1
  15. package/docs/design/segmented-backward-training.md +58 -39
  16. package/docs/design/tauri-desktop-app.md +127 -0
  17. package/docs/design/w4a16-compute-precision-spike.md +232 -0
  18. package/docs/investigations/orpo-base-uf-experiment-and-directions.md +120 -0
  19. package/docs/investigations/orpo-flash-cce-pin-leak.md +167 -0
  20. package/docs/investigations/orpo-uf-testing-handoff.md +126 -0
  21. package/docs/{segmented-backward-handoff.md → investigations/segmented-backward-handoff.md} +1 -1
  22. package/docs/investigations/starter-model-port-handoff.md +32 -13
  23. package/docs/investigations/steel-flash-cce-handoff.md +489 -0
  24. package/docs/investigations/trainer-validation-experiment.md +173 -0
  25. package/docs/planning/IDEAS.md +29 -0
  26. package/docs/planning/PRODUCT_ROADMAP.md +147 -27
  27. package/docs/planning/chunk-task-roadmap.md +1 -1
  28. package/docs/planning/journal.md +161 -0
  29. package/docs/reference/distribution.md +61 -5
  30. package/docs/reference/library-api.md +29 -14
  31. package/docs/reference/memory.md +77 -0
  32. package/docs/reference/orpo-quickstart.md +130 -0
  33. package/docs/reference/server-api.md +105 -7
  34. package/docs/reference/server-config.md +12 -5
  35. package/docs/reference/training.md +296 -0
  36. package/package.json +1 -1
  37. package/src/cli.ts +651 -18
  38. package/src/eval/ifeval.ts +138 -0
  39. package/src/eval/runner.ts +76 -6
  40. package/src/eval/tasks/bfcl.ts +49 -15
  41. package/src/eval/tasks/gsm8k.ts +21 -9
  42. package/src/eval/tasks/hashhop.ts +33 -4
  43. package/src/eval/tasks/humaneval.ts +30 -8
  44. package/src/eval/tasks/ifeval.ts +52 -18
  45. package/src/eval/tasks/mmlu.ts +85 -74
  46. package/src/eval/ultrafeedback.ts +60 -0
  47. package/src/jobs/runner.ts +6 -0
  48. package/src/lora.ts +12 -7
  49. package/src/memory/chunk.ts +25 -0
  50. package/src/memory/cluster.ts +24 -0
  51. package/src/memory/db.ts +114 -0
  52. package/src/memory/ingest.ts +22 -0
  53. package/src/memory/pipeline.ts +63 -0
  54. package/src/memory/prompts.ts +23 -0
  55. package/src/memory/schedule.ts +163 -0
  56. package/src/memory/synthesize.ts +29 -0
  57. package/src/memory/tools.ts +399 -0
  58. package/src/memory/vault.ts +694 -0
  59. package/src/memory/wikify.ts +23 -0
  60. package/src/mlx/metal-kernel.ts +31 -4
  61. package/src/model/flash-attention.ts +37 -19
  62. package/src/model/fused-geglu-kernel.ts +111 -0
  63. package/src/model/gemma4-base.ts +39 -1
  64. package/src/model/gemma4.ts +196 -21
  65. package/src/model/minicpm5.ts +44 -3
  66. package/src/pi-provider.ts +29 -13
  67. package/src/pi-session.ts +81 -0
  68. package/src/pi-terminal.ts +13 -24
  69. package/src/pi-web.ts +246 -166
  70. package/src/quantize/job.ts +17 -2
  71. package/src/quantize/quantizer.ts +26 -2
  72. package/src/server.ts +216 -47
  73. package/src/tokenizer.ts +6 -2
  74. package/src/train/dataset.ts +29 -6
  75. package/src/train/flash-cce.ts +1175 -0
  76. package/src/train/job.ts +64 -3
  77. package/src/train/lora-params.ts +53 -4
  78. package/src/train/loss.ts +947 -0
  79. package/src/train/optimizer.ts +10 -3
  80. package/src/train/prefix-shared.ts +435 -0
  81. package/src/train/rank.ts +36 -0
  82. package/src/train/segmented.ts +1148 -3
  83. package/src/train/steel-qmm-header.ts +1461 -0
  84. package/src/train/trainer.ts +570 -54
  85. package/src/train/watch.ts +542 -0
  86. package/src/web/app.html +285 -19
  87. package/src/web/skills/memory/SKILL.md +97 -0
  88. package/src/web/skills/web-research/SKILL.md +3 -1
  89. package/src/web/skills.ts +46 -8
  90. /package/{docs → src/assets}/curve-designer.html +0 -0
@@ -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
@@ -15,7 +15,7 @@ import type { Server } from "bun";
15
15
  // imports as HTMLBundle (the html loader), but the text attribute makes
16
16
  // the runtime value a string — hence the double cast.
17
17
  import appHtml from "./web/app.html" with { type: "text" };
18
- import curveDesignerHtml from "../docs/curve-designer.html" with { type: "text" };
18
+ import curveDesignerHtml from "./assets/curve-designer.html" with { type: "text" };
19
19
  import pkgJson from "../package.json" with { type: "json" };
20
20
  import { readFileSync } from "node:fs";
21
21
  const APP_PAGE = appHtml as unknown as string;
@@ -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,
@@ -977,15 +1026,25 @@ export function createServer(
977
1026
  // embedded copy when running as the compiled single binary.
978
1027
  if (url.pathname === "/curves" && request.method === "GET") {
979
1028
  let html = CURVE_PAGE;
980
- try { html = readFileSync(new URL("../docs/curve-designer.html", import.meta.url), "utf8"); } catch { /* binary: use embedded */ }
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");
986
1045
  return new Response(html, { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });
987
1046
  } catch {
988
- return new Response("curve terrain artifact not found; run scripts/curve-terrain.ts first", { status: 404 });
1047
+ return new Response("curve terrain artifact not found; run scripts/experiments/curve-terrain.ts first", { status: 404 });
989
1048
  }
990
1049
  }
991
1050
  if (request.method === "GET" &&
@@ -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) {
@@ -333,7 +341,22 @@ export async function loadDpoDataset(
333
341
  maxLength: number,
334
342
  ): Promise<DpoExample[]> {
335
343
  const rows = await readJsonl(path);
336
- return rows.map((r) => encodeDpoRow(r, tok, tmpl, maxLength));
344
+ const examples = rows.map((r) => encodeDpoRow(r, tok, tmpl, maxLength));
345
+ // Prompt-masking guard (the classic ORPO/DPO footgun on long-prompt /
346
+ // short-completion data): a collapsed prompt boundary (promptLen=0 → mask[0]
347
+ // is 1) means the completion filled the entire window — the NLL/odds-ratio
348
+ // would then be computed over non-completion tokens and the real signal gets
349
+ // drowned. Normally the loss is completion-only (mask 0 over the prompt); this
350
+ // only happens when a completion is itself ≥ max_seq_length. Surface it loudly.
351
+ let unmasked = 0;
352
+ for (const ex of examples) if (ex.chosenMask[0] === 1 || ex.rejectedMask[0] === 1) unmasked++;
353
+ if (unmasked > 0)
354
+ console.warn(
355
+ `[dataset] WARNING: ${unmasked}/${examples.length} preference examples have NO prompt boundary ` +
356
+ `(promptLen=0): the completion is ≥ max_seq_length (${maxLength}), so the loss would include ` +
357
+ `non-completion tokens. Raise max_seq_length above the completion length so the prompt is masked.`,
358
+ );
359
+ return examples;
337
360
  }
338
361
 
339
362
  /** Row length of a DPO branch (port of _make_batch helpers). */
@@ -420,10 +443,10 @@ function makeDpoBatch(rows: DpoExample[], padId: number): DpoBatch {
420
443
  // ---------------------------------------------------------------------------
421
444
 
422
445
  /** Format probe for inspect-dataset: peek the first row's keys. */
423
- export type DatasetFormat = "messages" | "prompt-completion" | "text" | "dpo" | "unknown";
446
+ export type DatasetFormat = "messages" | "prompt-completion" | "text" | "dpo" | "preference" | "unknown";
424
447
 
425
448
  export function probeFormat(row: Record<string, unknown>): DatasetFormat {
426
- if (typeof row.chosen === "string" && typeof row.rejected === "string") return "dpo";
449
+ if (typeof row.chosen === "string" && typeof row.rejected === "string") return "preference";
427
450
  try {
428
451
  return sftRowFormat(row);
429
452
  } catch {