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
@@ -67,7 +67,12 @@ function reEscape(s: string): string {
67
67
  // \b\w+\b — JS \w is ASCII-ish like Python's default re (no re.UNICODE here).
68
68
  function checkLengthWords(response: string, kw: Kw): boolean {
69
69
  const n = (response.match(/\b\w+\b/g) ?? []).length;
70
- const rel = kwStr(kw, "relation", "at least");
70
+ // Python `kw.get("relation", "at least")`: the default applies ONLY when the key is
71
+ // ABSENT — a present-but-None `relation` (as the frozen kwargs supply for letter/
72
+ // paragraph/capital checks) returns None, and optiq's `"least" in None` RAISES →
73
+ // _verify_response returns False. Replicate exactly: rel is null → `.includes` throws
74
+ // below → verifyResponse catches → FAIL. (kwStr's None→default was the leniency.)
75
+ const rel = ("relation" in kw ? kw["relation"] : "at least") as string;
71
76
  const target = kwInt(kw, "num_words", 0);
72
77
  return rel.includes("least") ? n >= target : n <= target;
73
78
  }
@@ -75,14 +80,24 @@ function checkLengthWords(response: string, kw: Kw): boolean {
75
80
  function checkLengthSentences(response: string, kw: Kw): boolean {
76
81
  // Python: re.split(r"[.!?]+\s+", response.strip())
77
82
  const n = response.trim().split(/[.!?]+\s+/).length;
78
- const rel = kwStr(kw, "relation", "at least");
83
+ // Python `kw.get("relation", "at least")`: the default applies ONLY when the key is
84
+ // ABSENT — a present-but-None `relation` (as the frozen kwargs supply for letter/
85
+ // paragraph/capital checks) returns None, and optiq's `"least" in None` RAISES →
86
+ // _verify_response returns False. Replicate exactly: rel is null → `.includes` throws
87
+ // below → verifyResponse catches → FAIL. (kwStr's None→default was the leniency.)
88
+ const rel = ("relation" in kw ? kw["relation"] : "at least") as string;
79
89
  const target = kwInt(kw, "num_sentences", 0);
80
90
  return rel.includes("least") ? n >= target : n <= target;
81
91
  }
82
92
 
83
93
  function checkLengthParagraphs(response: string, kw: Kw): boolean {
84
94
  const n = response.split("\n\n").filter((p) => p.trim()).length;
85
- const rel = kwStr(kw, "relation", "at least");
95
+ // Python `kw.get("relation", "at least")`: the default applies ONLY when the key is
96
+ // ABSENT — a present-but-None `relation` (as the frozen kwargs supply for letter/
97
+ // paragraph/capital checks) returns None, and optiq's `"least" in None` RAISES →
98
+ // _verify_response returns False. Replicate exactly: rel is null → `.includes` throws
99
+ // below → verifyResponse catches → FAIL. (kwStr's None→default was the leniency.)
100
+ const rel = ("relation" in kw ? kw["relation"] : "at least") as string;
86
101
  const target = kwInt(kw, "num_paragraphs", 0);
87
102
  return rel.includes("least") ? n >= target : n <= target;
88
103
  }
@@ -101,7 +116,12 @@ function checkKeywordsForbidden(response: string, kw: Kw): boolean {
101
116
 
102
117
  function checkKeywordFrequency(response: string, kw: Kw): boolean {
103
118
  const kwWord = kwStr(kw, "keyword", "").toLowerCase();
104
- const rel = kwStr(kw, "relation", "at least");
119
+ // Python `kw.get("relation", "at least")`: the default applies ONLY when the key is
120
+ // ABSENT — a present-but-None `relation` (as the frozen kwargs supply for letter/
121
+ // paragraph/capital checks) returns None, and optiq's `"least" in None` RAISES →
122
+ // _verify_response returns False. Replicate exactly: rel is null → `.includes` throws
123
+ // below → verifyResponse catches → FAIL. (kwStr's None→default was the leniency.)
124
+ const rel = ("relation" in kw ? kw["relation"] : "at least") as string;
105
125
  const target = kwInt(kw, "frequency", 0);
106
126
  const n = (response.toLowerCase().match(new RegExp(`\\b${reEscape(kwWord)}\\b`, "g")) ?? []).length;
107
127
  return rel.includes("least") ? n >= target : n <= target;
@@ -109,7 +129,12 @@ function checkKeywordFrequency(response: string, kw: Kw): boolean {
109
129
 
110
130
  function checkLetterFrequency(response: string, kw: Kw): boolean {
111
131
  const letter = kwStr(kw, "letter", "").toLowerCase();
112
- const rel = kwStr(kw, "relation", "at least");
132
+ // Python `kw.get("relation", "at least")`: the default applies ONLY when the key is
133
+ // ABSENT — a present-but-None `relation` (as the frozen kwargs supply for letter/
134
+ // paragraph/capital checks) returns None, and optiq's `"least" in None` RAISES →
135
+ // _verify_response returns False. Replicate exactly: rel is null → `.includes` throws
136
+ // below → verifyResponse catches → FAIL. (kwStr's None→default was the leniency.)
137
+ const rel = ("relation" in kw ? kw["relation"] : "at least") as string;
113
138
  // Python: int(kw.get("let_frequency", kw.get("frequency", 0)))
114
139
  const target = kw["let_frequency"] !== undefined && kw["let_frequency"] !== null
115
140
  ? kwInt(kw, "let_frequency", 0)
@@ -124,7 +149,12 @@ function checkCapitalWordsCount(response: string, kw: Kw): boolean {
124
149
  // Python: w.isupper() and len(w) > 1 — str.isupper() requires ≥1 cased char,
125
150
  // all cased chars uppercase. Approximate: has a letter, no lowercase letter.
126
151
  const n = words.filter((w) => w.length > 1 && /[A-Za-z]/.test(w) && w === w.toUpperCase()).length;
127
- const rel = kwStr(kw, "relation", "at least");
152
+ // Python `kw.get("relation", "at least")`: the default applies ONLY when the key is
153
+ // ABSENT — a present-but-None `relation` (as the frozen kwargs supply for letter/
154
+ // paragraph/capital checks) returns None, and optiq's `"least" in None` RAISES →
155
+ // _verify_response returns False. Replicate exactly: rel is null → `.includes` throws
156
+ // below → verifyResponse catches → FAIL. (kwStr's None→default was the leniency.)
157
+ const rel = ("relation" in kw ? kw["relation"] : "at least") as string;
128
158
  // Python: int(kw.get("capital_frequency", kw.get("frequency", 0)))
129
159
  const target = kw["capital_frequency"] !== undefined && kw["capital_frequency"] !== null
130
160
  ? kwInt(kw, "capital_frequency", 0)
@@ -251,11 +281,12 @@ const VERIFIERS = new Map<string, Verifier>([
251
281
  /**
252
282
  * Verify one response against its instruction list.
253
283
  * Returns { pass, unhandled }. An instruction_id with no registered verifier is
254
- * counted as a FAIL (NOT silently skipped) and recorded in `unhandled`. This is
255
- * stricter than ifeval.py (which treats unhandled as pass); per the mlx-bun
256
- * harness rule, anything we cannot port must fail rather than bias high.
284
+ * treated as a PASS (does not affect `pass`) and only recorded in `unhandled` for
285
+ * visibility — matching ifeval.py's `continue` policy EXACTLY, so our strict_acc
286
+ * reproduces optiq's. (Our VERIFIERS map mirrors his `_VERIFIERS` 1:1, so unhandled
287
+ * is empty for the standard IFEval set anyway; this only matters for parity safety.)
257
288
  */
258
- function verifyResponse(
289
+ export function verifyResponse(
259
290
  response: string,
260
291
  instructionIds: string[],
261
292
  kwargsList: Kw[],
@@ -268,8 +299,7 @@ function verifyResponse(
268
299
  const verifier = VERIFIERS.get(iid);
269
300
  if (verifier === undefined) {
270
301
  unhandled.push(iid);
271
- pass = false; // count unported instruction as a failure
272
- continue;
302
+ continue; // ifeval.py: treat an unhandled instruction as PASS (don't touch `pass`)
273
303
  }
274
304
  try {
275
305
  if (!verifier(response, kw)) pass = false;
@@ -281,7 +311,7 @@ function verifyResponse(
281
311
  }
282
312
 
283
313
  /** Loose-mode preprocessing — mirrors ifeval.py `_loose_clean`. */
284
- function looseClean(response: string): string {
314
+ export function looseClean(response: string): string {
285
315
  let s = response.trim();
286
316
  // Python: re.sub(r"^(Sure|Here|Of course)[,!.]\s*[^\n]*\n+", "", s, count=1)
287
317
  s = s.replace(/^(Sure|Here|Of course)[,!.]\s*[^\n]*\n+/, "");
@@ -293,22 +323,26 @@ function looseClean(response: string): string {
293
323
  }
294
324
 
295
325
  /** Strip a leading thinking block, matching ifeval.py's split on </think>. */
296
- function stripThinking(response: string): string {
326
+ export function stripThinking(response: string): string {
297
327
  const idx = response.indexOf("</think>");
298
328
  return idx === -1 ? response : response.slice(idx + "</think>".length);
299
329
  }
300
330
 
301
331
  export async function evaluateIfeval(
302
332
  tm: TaskModel,
303
- opts: { nSamples?: number; maxTokens?: number; seed?: number } = {},
333
+ opts: { nSamples?: number; maxTokens?: number; seed?: number; frozen?: boolean } = {},
304
334
  ): Promise<IfevalResult> {
305
335
  // ifeval.py: max_tokens default 512.
306
336
  const maxTokens = opts.maxTokens ?? 512;
307
337
  const seed = opts.seed ?? 42;
308
338
 
309
- const rows = loadJsonl<IfevalRow>("ifeval");
339
+ // Optiq-parity mode (DEFAULT): score optiq's EXACT prompt set (he runs the full
340
+ // IFEval split — 541 prompts) through OUR verifiers + OUR chat template.
341
+ // MLX_BUN_IFEVAL_FROZEN=0 reverts to our own copy + optional sampling.
342
+ const useFrozen = opts.frozen ?? (process.env.MLX_BUN_IFEVAL_FROZEN !== "0");
343
+ const rows = loadJsonl<IfevalRow>(useFrozen ? "ifeval_optiq_frozen" : "ifeval");
310
344
  const nSamples = opts.nSamples ?? rows.length;
311
- const idx = nSamples < rows.length
345
+ const idx = (!useFrozen && nSamples < rows.length)
312
346
  ? sampleIndices(rows.length, nSamples, seed)
313
347
  : Array.from({ length: rows.length }, (_, i) => i);
314
348
 
@@ -341,7 +375,7 @@ export async function evaluateIfeval(
341
375
  if (unhandledCounts.size) {
342
376
  const top = [...unhandledCounts.entries()].sort((a, b) => b[1] - a[1]);
343
377
  process.stderr.write(
344
- " ifeval unported instruction ids (counted as FAIL): " +
378
+ " ifeval unported instruction ids (treated as PASS, per optiq): " +
345
379
  top.map(([k, v]) => `${k}(${v})`).join(", ") +
346
380
  "\n",
347
381
  );
@@ -1,17 +1,15 @@
1
- // MMLU 5-shot — multi-domain multiple-choice knowledge. Port of
2
- // optiq/eval/mmlu.py: per-subject dev exemplars, A/B/C/D format, greedy
3
- // decode, extract the predicted answer letter, compare to the gold index.
1
+ // MMLU 5-shot — multi-domain multiple-choice knowledge. Faithful port of
2
+ // optiq/eval/mmlu.py: per-subject dev exemplars, A/B/C/D format, and scoring by
3
+ // **last-token logit argmax over the " A"/" B"/" C"/" D" tokens** on the RAW
4
+ // 5-shot prompt — NO chat template, NO generation. This is the standard
5
+ // (Hendrycks/Unsloth) MMLU methodology; the earlier chat-wrap+generate variant
6
+ // scored ~5 pts low vs optiq's published number, so we match optiq exactly.
4
7
  //
5
- // The Python reference scores via first-letter logit argmax (no generation).
6
- // The mlx-bun harness routes everything through generateText() (decoded
7
- // completion). For an instruct model the faithful argmax-on-raw-text path
8
- // underscores on a small noisy sample, so we drive the chat template: the
9
- // 5-shot exemplars become the priming context and an explicit instruction
10
- // asks for ONLY the single letter A/B/C/D. Extraction is robust to chatty
11
- // formatting (</think> wrappers, "Answer: X", "(X)", "**X**", bare letter).
12
- // Prompt format, header, exemplar selection, and gold mapping are verbatim.
8
+ // Questions: by DEFAULT we score optiq's EXACT frozen draw (mmlu_optiq_frozen, the
9
+ // same ~969 his published 52.4 used) so base/adapter are directly comparable to his
10
+ // table. MLX_BUN_MMLU_FROZEN=0 falls back to our own sampling of the full 14k set.
13
11
 
14
- import { generateText, loadJsonl, sampleIndices, type TaskModel } from "../runner";
12
+ import { loadJsonl, sampleIndices, type TaskModel } from "../runner";
15
13
 
16
14
  interface MmluRow {
17
15
  question: string;
@@ -42,12 +40,8 @@ function formatExample(question: string, choices: string[], answerIdx?: number):
42
40
  return s;
43
41
  }
44
42
 
45
- /** 5-shot prompt: subject header → n demos → open test question.
46
- *
47
- * Built for the chat template: the header + worked exemplars prime the model,
48
- * and a trailing instruction pins the output to a single bare letter. The
49
- * exemplars still close their "Answer: X" line so the model sees the expected
50
- * shape, and the final question is left open ("Answer:") for the model. */
43
+ /** 5-shot prompt (verbatim optiq _build_prompt): subject header → n demos →
44
+ * open test question ending in "Answer:". Fed raw (no chat template). */
51
45
  function buildPrompt(row: MmluRow, devExamples: MmluRow[], nShots: number): string {
52
46
  let prompt = `The following are multiple choice questions (with answers) about ${formatSubject(row.subject)}.\n\n`;
53
47
  for (const ex of devExamples.slice(0, nShots)) {
@@ -55,48 +49,46 @@ function buildPrompt(row: MmluRow, devExamples: MmluRow[], nShots: number): stri
55
49
  prompt += "\n";
56
50
  }
57
51
  prompt += formatExample(row.question, row.choices);
58
- prompt += "\n\nRespond with ONLY the single letter (A, B, C, or D) of the correct answer. Do not explain.";
59
52
  return prompt;
60
53
  }
61
54
 
62
- /** Model output → predicted letter index 0..3, else null. Robust to chatty
63
- * instruct formatting: strips a leading <think>…</think> block, then prefers
64
- * an explicit "Answer: X" / "(X)" / "**X**" marker, falling back to the first
65
- * standalone A/B/C/D token (one not glued inside a longer word). */
66
- export function extractAnswer(textIn: string): number | null {
67
- let text = textIn;
68
- if (text.includes("</think>")) text = text.split("</think>").pop() ?? text;
69
-
70
- const toIdx = (ch: string): number => LETTERS.indexOf(ch.toUpperCase() as (typeof LETTERS)[number]);
71
-
72
- // Explicit answer markers, in priority order.
73
- const markers = [
74
- /(?:answer|correct answer|the answer is)\s*(?:is|:)?\s*\(?\*{0,2}([A-Da-d])\*{0,2}\)?/i,
75
- /\*\*\s*([A-Da-d])\s*\*\*/, // **X**
76
- /\(\s*([A-Da-d])\s*\)/, // (X)
77
- ];
78
- for (const re of markers) {
79
- const m = text.match(re);
80
- if (m) return toIdx(m[1]!);
55
+ /** Forward the prompt (prefill + last token) and return the index 0..3 of the
56
+ * highest-logit answer letter — optiq's argmax-on-last-token scoring. */
57
+ function lastTokenArgmax(model: TaskModel["model"], ids: number[], letterIds: number[]): number {
58
+ const cache = model.makeCache();
59
+ try {
60
+ if (ids.length > 1) model.forward(ids.slice(0, -1), cache).dispose(); // prefill (cache only)
61
+ const logits = model.forward([ids[ids.length - 1]!], cache); // [1, 1, V]
62
+ const v = logits.toFloat32();
63
+ logits.dispose();
64
+ let best = 0, bestVal = -Infinity;
65
+ for (let i = 0; i < letterIds.length; i++) {
66
+ const val = v[letterIds[i]!]!;
67
+ if (val > bestVal) { bestVal = val; best = i; }
68
+ }
69
+ return best;
70
+ } finally {
71
+ for (const c of cache) c.dispose();
81
72
  }
82
-
83
- // Fallback: first standalone letter (word-boundary, not embedded in a word).
84
- const standalone = text.match(/\b([A-D])\b/);
85
- if (standalone) return toIdx(standalone[1]!);
86
-
87
- return null;
88
73
  }
89
74
 
90
75
  export async function evaluateMmlu(
91
76
  tm: TaskModel,
92
- opts: { nSamples?: number; nShots?: number; maxTokens?: number; seed?: number } = {},
77
+ opts: { nSamples?: number; nShots?: number; seed?: number; frozen?: boolean } = {},
93
78
  ): Promise<MmluResult> {
94
79
  const nShots = opts.nShots ?? 5;
95
- const maxTokens = opts.maxTokens ?? 16;
96
80
  const seed = opts.seed ?? 42;
97
81
 
98
- const testRows = loadJsonl<MmluRow>("mmlu_test");
99
- const devRows = loadJsonl<MmluRow>("mmlu_dev");
82
+ // Optiq-parity mode (DEFAULT): score optiq's EXACT frozen draw — the same ~969
83
+ // questions + dev exemplars his published table used — so base/adapter numbers are
84
+ // DIRECTLY comparable to optiq's. Any remaining gap is then pure forward-logit
85
+ // parity, not sampling. Set MLX_BUN_MMLU_FROZEN=0 (or frozen:false) to fall back to
86
+ // our own stratified sampling of the full 14k test set (a DIFFERENT subset: JS RNG
87
+ // ≠ numpy RandomState, so it does not match optiq's questions even at the same seed).
88
+ const useFrozen = opts.frozen ?? (process.env.MLX_BUN_MMLU_FROZEN !== "0");
89
+
90
+ const testRows = loadJsonl<MmluRow>(useFrozen ? "mmlu_optiq_frozen" : "mmlu_test");
91
+ const devRows = loadJsonl<MmluRow>(useFrozen ? "mmlu_optiq_dev" : "mmlu_dev");
100
92
 
101
93
  // Per-subject dev exemplars for prompt construction.
102
94
  const devBySubject = new Map<string, MmluRow[]>();
@@ -106,38 +98,57 @@ export async function evaluateMmlu(
106
98
  devBySubject.set(ex.subject, list);
107
99
  }
108
100
 
109
- // Stratified sample: roughly equal questions per subject (matches mmlu.py),
110
- // deterministic via the shared seeded subsampler.
111
- const testBySubject = new Map<string, number[]>();
112
- for (let i = 0; i < testRows.length; i++) {
113
- const subj = testRows[i]!.subject;
114
- const list = testBySubject.get(subj) ?? [];
115
- list.push(i);
116
- testBySubject.set(subj, list);
117
- }
118
- const subjects = [...testBySubject.keys()].sort();
119
- const nSamples = opts.nSamples ?? 1000;
120
- const perSubject = Math.max(1, Math.floor(nSamples / subjects.length));
121
-
122
- let sampled: number[] = [];
123
- for (const subj of subjects) {
124
- const pool = testBySubject.get(subj)!;
125
- const take = Math.min(perSubject, pool.length);
126
- for (const j of sampleIndices(pool.length, take, seed)) sampled.push(pool[j]!);
127
- }
128
- if (sampled.length > nSamples) {
129
- const keep = sampleIndices(sampled.length, nSamples, seed);
130
- sampled = keep.map((k) => sampled[k]!);
101
+ let sampled: number[];
102
+ if (useFrozen) {
103
+ // Already optiq's exact draw — score ALL of it, in his order, NO resampling
104
+ // (re-sampling here would re-introduce exactly the divergence we're removing).
105
+ sampled = testRows.map((_, i) => i);
106
+ } else {
107
+ // Stratified sample: roughly equal questions per subject (mirrors mmlu.py).
108
+ const testBySubject = new Map<string, number[]>();
109
+ for (let i = 0; i < testRows.length; i++) {
110
+ const subj = testRows[i]!.subject;
111
+ const list = testBySubject.get(subj) ?? [];
112
+ list.push(i);
113
+ testBySubject.set(subj, list);
114
+ }
115
+ const subjects = [...testBySubject.keys()].sort();
116
+ const nSamples = opts.nSamples ?? 1000;
117
+ const perSubject = Math.max(1, Math.floor(nSamples / subjects.length));
118
+ sampled = [];
119
+ for (const subj of subjects) {
120
+ const pool = testBySubject.get(subj)!;
121
+ const take = Math.min(perSubject, pool.length);
122
+ for (const j of sampleIndices(pool.length, take, seed)) sampled.push(pool[j]!);
123
+ }
124
+ if (sampled.length > nSamples) {
125
+ const keep = sampleIndices(sampled.length, nSamples, seed);
126
+ sampled = keep.map((k) => sampled[k]!);
127
+ }
131
128
  }
132
129
 
130
+ // Answer-letter token ids — " A".." D" first token, matching how Hendrycks
131
+ // scores the token after "Answer:". Strip a leading BOS (≈ add_special_tokens=False).
132
+ const bos = tm.tokenizer.bosTokenId;
133
+ const stripBos = (ids: number[]) => (bos !== null && ids[0] === bos ? ids.slice(1) : ids);
134
+ const letterIds = LETTERS.map((L) => {
135
+ const ids = stripBos(tm.tokenizer.encode(` ${L}`));
136
+ return ids[0]!;
137
+ });
138
+
139
+ // Re-assert the mounted adapter for our direct forward()s: generation tasks reset
140
+ // loraState.active to [] when they finish, and MMLU bypasses generate() entirely,
141
+ // so without this the argmax would silently score BASE even with --adapter set.
142
+ if (tm.activeAdapters) tm.model.loraState.active = tm.activeAdapters;
143
+
133
144
  let nCorrect = 0;
134
145
  for (let k = 0; k < sampled.length; k++) {
135
146
  const item = testRows[sampled[k]!]!;
136
147
  const devEx = devBySubject.get(item.subject) ?? [];
137
- const out = await generateText(tm, buildPrompt(item, devEx, nShots), { maxTokens, useChat: true });
138
- const pred = extractAnswer(out);
139
- if (pred !== null && pred === item.answer) nCorrect++;
140
- if ((k + 1) % 10 === 0 || k + 1 === sampled.length)
148
+ const ids = stripBos(tm.tokenizer.encode(buildPrompt(item, devEx, nShots)));
149
+ const pred = lastTokenArgmax(tm.model, ids, letterIds);
150
+ if (pred === item.answer) nCorrect++;
151
+ if ((k + 1) % 25 === 0 || k + 1 === sampled.length)
141
152
  process.stderr.write(`\r mmlu ${k + 1}/${sampled.length} acc=${((nCorrect / (k + 1)) * 100).toFixed(1)}%`);
142
153
  }
143
154
  process.stderr.write("\n");
@@ -0,0 +1,60 @@
1
+ // UltraFeedback (binarized) → mlx-bun preference rows. The binarized schema
2
+ // (HuggingFaceH4/ultrafeedback_binarized) gives each row a `prompt` plus
3
+ // `chosen` / `rejected` as message lists; ORPO wants {prompt, chosen, rejected}
4
+ // where chosen/rejected are the assistant response *strings*. Pure transform so
5
+ // it is unit-testable without downloading the dataset.
6
+
7
+ export interface PreferenceRow {
8
+ prompt: string;
9
+ chosen: string;
10
+ rejected: string;
11
+ }
12
+
13
+ type Msg = { role?: string; content?: string };
14
+
15
+ /** Last assistant message content in a binarized message list (the response). */
16
+ function lastAssistant(messages: unknown): string | null {
17
+ if (!Array.isArray(messages)) return null;
18
+ for (let i = messages.length - 1; i >= 0; i--) {
19
+ const m = messages[i] as Msg;
20
+ if (m && m.role === "assistant" && typeof m.content === "string" && m.content.trim())
21
+ return m.content;
22
+ }
23
+ return null;
24
+ }
25
+
26
+ /** Convert one binarized UltraFeedback row → a preference row, or null if it is
27
+ * unusable (missing fields, or chosen == rejected so there is no signal). */
28
+ export function toPreferenceRow(row: Record<string, unknown>): PreferenceRow | null {
29
+ const prompt = typeof row.prompt === "string" ? row.prompt : null;
30
+ const chosen = lastAssistant(row.chosen);
31
+ const rejected = lastAssistant(row.rejected);
32
+ if (!prompt || !chosen || !rejected) return null;
33
+ if (chosen.trim() === rejected.trim()) return null;
34
+ return { prompt, chosen, rejected };
35
+ }
36
+
37
+ /** Rough token estimate (≈4 chars/token) for a cheap length pre-filter before
38
+ * tokenization — keeps the curated set within a target context without loading
39
+ * a tokenizer. */
40
+ export function approxTokens(text: string): number {
41
+ return Math.ceil(text.length / 4);
42
+ }
43
+
44
+ /** Filter + convert a stream of binarized rows. Drops unusable rows and ones
45
+ * whose prompt+chosen or prompt+rejected approx-exceeds `maxApproxTokens`. */
46
+ export function curate(
47
+ rows: Record<string, unknown>[],
48
+ maxApproxTokens = 2048,
49
+ ): PreferenceRow[] {
50
+ const out: PreferenceRow[] = [];
51
+ for (const row of rows) {
52
+ const p = toPreferenceRow(row);
53
+ if (!p) continue;
54
+ const base = approxTokens(p.prompt);
55
+ if (base + approxTokens(p.chosen) > maxApproxTokens) continue;
56
+ if (base + approxTokens(p.rejected) > maxApproxTokens) continue;
57
+ out.push(p);
58
+ }
59
+ return out;
60
+ }
@@ -149,6 +149,7 @@ interface QueuedSpawn {
149
149
  jobId: string;
150
150
  entry: string;
151
151
  bin: string;
152
+ onComplete?: (jobId: string, code: number) => void;
152
153
  }
153
154
 
154
155
  let gpuLeaseHolder: string | null = null;
@@ -170,6 +171,9 @@ export interface SubprocessOpts {
170
171
  entry?: string;
171
172
  /** Override the runtime binary (tests). Defaults to "bun". */
172
173
  bin?: string;
174
+ /** Called on the server (parent) after the child exits — used to invalidate
175
+ * caches (e.g. the Library) so a finished quantize surfaces immediately. */
176
+ onComplete?: (jobId: string, code: number) => void;
173
177
  }
174
178
 
175
179
  /** Create a 'queued' row and either spawn it now (lease free) or leave it
@@ -188,6 +192,7 @@ export function submitSubprocess(
188
192
  jobId: row.id,
189
193
  entry: opts.entry ?? JOB_ENTRY_PATH,
190
194
  bin: opts.bin ?? "bun",
195
+ onComplete: opts.onComplete,
191
196
  };
192
197
  if (gpuLeaseHolder === null) {
193
198
  spawnNow(item);
@@ -243,6 +248,7 @@ function spawnNow(item: QueuedSpawn): void {
243
248
  }
244
249
  }
245
250
  releaseLease(jobId);
251
+ try { item.onComplete?.(jobId, code); } catch {}
246
252
  drainQueue();
247
253
  })();
248
254
  }
package/src/lora.ts CHANGED
@@ -37,7 +37,7 @@ export interface AdapterInfo {
37
37
  * materialized mlx arrays. Tensor names come from our header parser;
38
38
  * arrays from mlx's native loader via the proven map-get pattern
39
39
  * (weights.ts). The map and header mmap are freed before returning. */
40
- function loadAdapterTensors(file: string): Map<string, MlxArray> {
40
+ export function loadAdapterTensors(file: string): Map<string, MlxArray> {
41
41
  const sf = SafetensorsFile.open(file);
42
42
  const names = [...sf.tensors.keys()];
43
43
  sf.mmap.unmap();
@@ -74,18 +74,21 @@ function loadAdapterTensors(file: string): Map<string, MlxArray> {
74
74
  }
75
75
 
76
76
  /** Adapter scale from its config: mlx-lm writes lora_parameters.scale;
77
- * PEFT writes lora_alpha + r (scale = alpha / r). Reference: mount.py. */
78
- async function readAdapterScale(dir: string): Promise<{ scale: number; rank: number | null }> {
77
+ * PEFT writes lora_alpha + r (scale = alpha / r). Reference: mount.py.
78
+ * `rsLora` (recorded at train time) means the effective per-layer scale is
79
+ * α/√rank — the caller divides by √(that layer's rank). */
80
+ async function readAdapterScale(dir: string): Promise<{ scale: number; rank: number | null; rsLora: boolean }> {
79
81
  for (const name of ["optiq_lora_config.json", "adapter_config.json"]) {
80
82
  const f = Bun.file(`${dir}/${name}`);
81
83
  if (await f.exists()) {
82
84
  const cfg = (await f.json()) as Record<string, any>;
83
85
  const lp = cfg.lora_parameters;
86
+ const rsLora = Boolean(cfg.rs_lora ?? lp?.rs_lora ?? false);
84
87
  if (lp && typeof lp === "object")
85
- return { scale: Number(lp.scale ?? 20.0), rank: lp.rank ?? null };
88
+ return { scale: Number(lp.scale ?? 20.0), rank: lp.rank ?? null, rsLora };
86
89
  const alpha = Number(cfg.lora_alpha ?? 16);
87
90
  const r = Number(cfg.r ?? 8);
88
- return { scale: r ? alpha / r : 1.0, rank: r || null };
91
+ return { scale: r ? alpha / r : 1.0, rank: r || null, rsLora };
89
92
  }
90
93
  }
91
94
  throw new Error(`no adapter_config.json in ${dir}`);
@@ -200,7 +203,7 @@ export class AdapterManager {
200
203
  if (!existsSync(path)) throw new Error(`adapter dir not found: ${path}`);
201
204
 
202
205
  const weightsFile = adapterWeightsFile(path);
203
- const { scale, rank: configRank } = await readAdapterScale(path);
206
+ const { scale, rank: configRank, rsLora } = await readAdapterScale(path);
204
207
  const tensors = loadAdapterTensors(weightsFile);
205
208
  const targets = this.#model.loraTargets();
206
209
 
@@ -243,7 +246,9 @@ export class AdapterManager {
243
246
  `vs base [in ${linear.inFeatures}, out ${linear.outFeatures}]; ` +
244
247
  `was this adapter trained for a different base model?`,
245
248
  );
246
- validated.push({ linear, lw: { a, b, scale, rank } });
249
+ // rsLoRA: effective per-layer scale is α/√rank (matches training).
250
+ const effScale = rsLora ? scale / Math.sqrt(rank) : scale;
251
+ validated.push({ linear, lw: { a, b, scale: effScale, rank } });
247
252
  }
248
253
  if (validated.length === 0)
249
254
  throw new Error(
@@ -0,0 +1,25 @@
1
+ // mlx-bun memory — synthesis stage 2: CHUNK. ⚠️ M1 STUB.
2
+ //
3
+ // Segment each new conversation into topic chunks (a local-model call — this is
4
+ // the segmentation track distilled from gold; eval by boundary/label accuracy).
5
+ // Re-chunk only conversations whose updated_at > chunked_at. Mirrors lucien's
6
+ // chunk-recent.ts. The prompt is the future "chunk" LoRA seam (prompts.ts).
7
+ //
8
+ // See docs/design/memory-system.md → "The nightly pipeline".
9
+
10
+ import type { SynthesisEvent } from "./pipeline";
11
+
12
+ export interface ChunkResult {
13
+ conversations: number;
14
+ chunks: number;
15
+ }
16
+
17
+ /** Conversations → topic chunks (local model). STUB. */
18
+ export async function chunkConversations(
19
+ _model: unknown,
20
+ _onEvent?: (e: SynthesisEvent) => void,
21
+ ): Promise<ChunkResult> {
22
+ // TODO(M1): for each conv with updated_at > chunked_at, run the chunk prompt
23
+ // on the local model, write chunks, set chunked_at.
24
+ throw new Error("memory.chunk is not implemented yet (M1)");
25
+ }
@@ -0,0 +1,24 @@
1
+ // mlx-bun memory — synthesis stage 3: CLUSTER. ⚠️ M1 STUB.
2
+ //
3
+ // Assign each new chunk to an existing bucket or propose a new one (local-model
4
+ // call). Buckets are emergent (Meta/Buckets.md), each mapping to a target
5
+ // article. Mirrors lucien's cluster-assign-recent.ts.
6
+ //
7
+ // See docs/design/memory-system.md → "The nightly pipeline".
8
+
9
+ import type { SynthesisEvent } from "./pipeline";
10
+
11
+ export interface ClusterResult {
12
+ assigned: number;
13
+ newBuckets: number;
14
+ }
15
+
16
+ /** Chunks → buckets (local model). STUB. */
17
+ export async function clusterChunks(
18
+ _model: unknown,
19
+ _onEvent?: (e: SynthesisEvent) => void,
20
+ ): Promise<ClusterResult> {
21
+ // TODO(M1): for each unbucketed chunk, classify into Meta/Buckets.md taxonomy
22
+ // (or propose a new bucket → new article stem); persist chunk.bucket.
23
+ throw new Error("memory.cluster is not implemented yet (M1)");
24
+ }