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.
- package/README.md +175 -93
- package/docs/dag/training-inference-map.html +575 -0
- package/docs/design/adapters-end-to-end.md +50 -46
- package/docs/design/compat-cli-surface-design.md +10 -8
- package/docs/design/docs-reorg-plan.md +14 -1
- package/docs/design/hlg-sampling.md +12 -2
- package/docs/design/memory-system.md +193 -295
- package/docs/design/optimization_plan.md +4 -2
- package/docs/design/orpo-dynamic-lambda.md +179 -0
- package/docs/design/orpo-future-enhancements.md +340 -0
- package/docs/design/orpo-training.md +1212 -0
- package/docs/design/parallel-slots.md +34 -26
- package/docs/design/parity-tier-dag.md +143 -0
- package/docs/design/runtime-isolation.md +1 -1
- package/docs/design/segmented-backward-training.md +58 -39
- package/docs/design/tauri-desktop-app.md +127 -0
- package/docs/design/w4a16-compute-precision-spike.md +232 -0
- package/docs/investigations/orpo-base-uf-experiment-and-directions.md +120 -0
- package/docs/investigations/orpo-flash-cce-pin-leak.md +167 -0
- package/docs/investigations/orpo-uf-testing-handoff.md +126 -0
- package/docs/{segmented-backward-handoff.md → investigations/segmented-backward-handoff.md} +1 -1
- package/docs/investigations/starter-model-port-handoff.md +32 -13
- package/docs/investigations/steel-flash-cce-handoff.md +489 -0
- package/docs/investigations/trainer-validation-experiment.md +173 -0
- package/docs/planning/IDEAS.md +29 -0
- package/docs/planning/PRODUCT_ROADMAP.md +147 -27
- package/docs/planning/chunk-task-roadmap.md +1 -1
- package/docs/planning/journal.md +161 -0
- package/docs/reference/distribution.md +61 -5
- package/docs/reference/library-api.md +29 -14
- package/docs/reference/memory.md +77 -0
- package/docs/reference/orpo-quickstart.md +130 -0
- package/docs/reference/server-api.md +105 -7
- package/docs/reference/server-config.md +12 -5
- package/docs/reference/training.md +296 -0
- package/package.json +1 -1
- package/src/cli.ts +651 -18
- package/src/eval/ifeval.ts +138 -0
- package/src/eval/runner.ts +76 -6
- package/src/eval/tasks/bfcl.ts +49 -15
- package/src/eval/tasks/gsm8k.ts +21 -9
- package/src/eval/tasks/hashhop.ts +33 -4
- package/src/eval/tasks/humaneval.ts +30 -8
- package/src/eval/tasks/ifeval.ts +52 -18
- package/src/eval/tasks/mmlu.ts +85 -74
- package/src/eval/ultrafeedback.ts +60 -0
- package/src/jobs/runner.ts +6 -0
- package/src/lora.ts +12 -7
- package/src/memory/chunk.ts +25 -0
- package/src/memory/cluster.ts +24 -0
- package/src/memory/db.ts +114 -0
- package/src/memory/ingest.ts +22 -0
- package/src/memory/pipeline.ts +63 -0
- package/src/memory/prompts.ts +23 -0
- package/src/memory/schedule.ts +163 -0
- package/src/memory/synthesize.ts +29 -0
- package/src/memory/tools.ts +399 -0
- package/src/memory/vault.ts +694 -0
- package/src/memory/wikify.ts +23 -0
- package/src/mlx/metal-kernel.ts +31 -4
- package/src/model/flash-attention.ts +37 -19
- package/src/model/fused-geglu-kernel.ts +111 -0
- package/src/model/gemma4-base.ts +39 -1
- package/src/model/gemma4.ts +196 -21
- package/src/model/minicpm5.ts +44 -3
- package/src/pi-provider.ts +29 -13
- package/src/pi-session.ts +81 -0
- package/src/pi-terminal.ts +13 -24
- package/src/pi-web.ts +246 -166
- package/src/quantize/job.ts +17 -2
- package/src/quantize/quantizer.ts +26 -2
- package/src/server.ts +216 -47
- package/src/tokenizer.ts +6 -2
- package/src/train/dataset.ts +29 -6
- package/src/train/flash-cce.ts +1175 -0
- package/src/train/job.ts +64 -3
- package/src/train/lora-params.ts +53 -4
- package/src/train/loss.ts +947 -0
- package/src/train/optimizer.ts +10 -3
- package/src/train/prefix-shared.ts +435 -0
- package/src/train/rank.ts +36 -0
- package/src/train/segmented.ts +1148 -3
- package/src/train/steel-qmm-header.ts +1461 -0
- package/src/train/trainer.ts +570 -54
- package/src/train/watch.ts +542 -0
- package/src/web/app.html +285 -19
- package/src/web/skills/memory/SKILL.md +97 -0
- package/src/web/skills/web-research/SKILL.md +3 -1
- package/src/web/skills.ts +46 -8
- /package/{docs → src/assets}/curve-designer.html +0 -0
package/src/eval/tasks/ifeval.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
378
|
+
" ifeval unported instruction ids (treated as PASS, per optiq): " +
|
|
345
379
|
top.map(([k, v]) => `${k}(${v})`).join(", ") +
|
|
346
380
|
"\n",
|
|
347
381
|
);
|
package/src/eval/tasks/mmlu.ts
CHANGED
|
@@ -1,17 +1,15 @@
|
|
|
1
|
-
// MMLU 5-shot — multi-domain multiple-choice knowledge.
|
|
2
|
-
// optiq/eval/mmlu.py: per-subject dev exemplars, A/B/C/D format,
|
|
3
|
-
//
|
|
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
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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 {
|
|
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 →
|
|
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
|
-
/**
|
|
63
|
-
*
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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;
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
testBySubject
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
138
|
-
const pred =
|
|
139
|
-
if (pred
|
|
140
|
-
if ((k + 1) %
|
|
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
|
+
}
|
package/src/jobs/runner.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|