mlx-bun 0.0.5 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +11 -2
  2. package/docs/dag/training-inference-map.html +575 -0
  3. package/docs/design/memory-system.md +193 -295
  4. package/docs/design/orpo-dynamic-lambda.md +179 -0
  5. package/docs/design/orpo-future-enhancements.md +42 -0
  6. package/docs/design/orpo-training.md +13 -0
  7. package/docs/design/parity-tier-dag.md +143 -0
  8. package/docs/design/w4a16-compute-precision-spike.md +232 -0
  9. package/docs/investigations/orpo-base-uf-experiment-and-directions.md +120 -0
  10. package/docs/investigations/orpo-flash-cce-pin-leak.md +167 -0
  11. package/docs/investigations/orpo-uf-testing-handoff.md +126 -0
  12. package/docs/investigations/steel-flash-cce-handoff.md +58 -0
  13. package/docs/investigations/trainer-validation-experiment.md +173 -0
  14. package/docs/reference/memory.md +77 -0
  15. package/docs/reference/orpo-quickstart.md +44 -5
  16. package/docs/reference/training.md +9 -5
  17. package/package.json +1 -1
  18. package/src/cli.ts +651 -18
  19. package/src/eval/runner.ts +76 -6
  20. package/src/eval/tasks/bfcl.ts +49 -15
  21. package/src/eval/tasks/gsm8k.ts +21 -9
  22. package/src/eval/tasks/hashhop.ts +33 -4
  23. package/src/eval/tasks/humaneval.ts +30 -8
  24. package/src/eval/tasks/ifeval.ts +52 -18
  25. package/src/eval/tasks/mmlu.ts +85 -74
  26. package/src/jobs/runner.ts +6 -0
  27. package/src/memory/chunk.ts +25 -0
  28. package/src/memory/cluster.ts +24 -0
  29. package/src/memory/db.ts +114 -0
  30. package/src/memory/ingest.ts +22 -0
  31. package/src/memory/pipeline.ts +63 -0
  32. package/src/memory/prompts.ts +23 -0
  33. package/src/memory/schedule.ts +163 -0
  34. package/src/memory/synthesize.ts +29 -0
  35. package/src/memory/tools.ts +399 -0
  36. package/src/memory/vault.ts +694 -0
  37. package/src/memory/wikify.ts +23 -0
  38. package/src/mlx/metal-kernel.ts +13 -0
  39. package/src/model/flash-attention.ts +5 -3
  40. package/src/pi-provider.ts +29 -13
  41. package/src/pi-session.ts +81 -0
  42. package/src/pi-terminal.ts +13 -24
  43. package/src/pi-web.ts +246 -166
  44. package/src/quantize/job.ts +17 -2
  45. package/src/quantize/quantizer.ts +26 -2
  46. package/src/server.ts +213 -44
  47. package/src/tokenizer.ts +6 -2
  48. package/src/train/dataset.ts +11 -3
  49. package/src/train/flash-cce.ts +18 -5
  50. package/src/train/job.ts +41 -4
  51. package/src/train/loss.ts +10 -4
  52. package/src/train/trainer.ts +62 -3
  53. package/src/train/watch.ts +542 -0
  54. package/src/web/app.html +264 -14
  55. package/src/web/skills/memory/SKILL.md +97 -0
  56. package/src/web/skills/web-research/SKILL.md +3 -1
  57. package/src/web/skills.ts +46 -8
@@ -6,6 +6,7 @@
6
6
  import { homedir } from "node:os";
7
7
  import { readFileSync } from "node:fs";
8
8
  import { generate, type GenerateOptions } from "../generate";
9
+ import * as ops from "../mlx/ops";
9
10
  import { ChatTemplate } from "../chat-template";
10
11
  import { loadModelConfig, type ModelConfig } from "../config";
11
12
  import { Weights } from "../weights";
@@ -56,17 +57,34 @@ export interface TaskModel {
56
57
  * task's own (greedy) default — used to run the whole suite under one sampler
57
58
  * arm (e.g. the v2 curve vs the default chat recipe) for the degradation gate. */
58
59
  samplerOverride?: SamplerArm;
60
+ /** Mounted-adapter ids to ACTIVATE on every forward/generate. Mounting an adapter
61
+ * only stores it on the linears; `loraState.active` is what actually applies it,
62
+ * and generation resets it to [] after each call. So generateText re-passes this,
63
+ * and direct-forward tasks (MMLU) re-assert it — else the eval silently runs base. */
64
+ activeAdapters?: string[];
59
65
  }
60
66
 
61
- export async function loadTaskModel(query: string): Promise<TaskModel> {
67
+ export async function loadTaskModel(query: string, adapterDir?: string): Promise<TaskModel> {
62
68
  const dir = resolveModelDir(query);
63
69
  const config = await loadModelConfig(dir);
64
70
  const weights = await Weights.open(dir);
65
71
  const model = createModel(weights, config);
72
+ const activeAdapters: string[] = [];
73
+ if (adapterDir) {
74
+ // Mount a trained LoRA adapter so the eval measures base+adapter (e.g. the
75
+ // ORPO-fine-tuned "after" model). NOTE: mount() only STORES the adapter on each
76
+ // linear — it does NOT activate it. Activation is `loraState.active`; without
77
+ // setting it the forward runs BASE (bit-identical to no-adapter). So activate it
78
+ // here for direct-forward tasks; generateText re-passes `activeAdapters` too.
79
+ const { AdapterManager } = await import("../lora");
80
+ await new AdapterManager(model).mount("eval-adapter", adapterDir);
81
+ activeAdapters.push("eval-adapter");
82
+ model.loraState.active = [...activeAdapters];
83
+ }
66
84
  const tokenizer = await loadTokenizer(dir);
67
85
  let template: ChatTemplate | null = null;
68
86
  try { template = await ChatTemplate.load(dir); } catch { template = null; }
69
- return { model, tokenizer, template, config, dir };
87
+ return { model, tokenizer, template, config, dir, activeAdapters: activeAdapters.length ? activeAdapters : undefined };
70
88
  }
71
89
 
72
90
  export interface GenOpts {
@@ -76,21 +94,73 @@ export interface GenOpts {
76
94
  /** Sampling overrides (default greedy). Lets the HLG/diversity evals vary the
77
95
  * sampler — temperature, top-p/k, seed, hlg — while sharing this prompt path. */
78
96
  sampler?: Partial<Pick<GenerateOptions, "temperature" | "topP" | "topK" | "seed" | "hlg" | "curve">>;
97
+ /** Chat-template `enable_thinking`. Defaults to OFF for eval parity with optiq
98
+ * (its capability numbers are non-thinking; e.g. MiniCPM5 IFEval 64.7 is
99
+ * non-thinking). Set MLX_BUN_EVAL_THINK=1 or pass true to evaluate thinking mode. */
100
+ enableThinking?: boolean;
101
+ }
102
+
103
+ /** Bit-exact greedy decode: raw `model.forward` + argmax loop, which matches mlx-lm
104
+ * token-for-token (gen8k: 8000/8000). The product `generate()` decode wrapper
105
+ * (forwardHidden/logitsFromHidden + pipelined sampler) diverges from the raw forward
106
+ * on near-ties past ~32 tokens, so the EVAL decodes bit-exactly itself rather than
107
+ * alter the serving path. Greedy + full-precision KV only. */
108
+ function greedyDecodeBitExact(tm: TaskModel, ids: number[], maxTokens: number): string {
109
+ if (tm.activeAdapters) tm.model.loraState.active = tm.activeAdapters; // apply the mounted adapter
110
+ const cache = tm.model.makeCache();
111
+ try {
112
+ if (ids.length > 1) tm.model.forward(ids.slice(0, -1), cache).dispose(); // prefill
113
+ let last = ids[ids.length - 1]!;
114
+ const eos = new Set(tm.config.eosTokenIds);
115
+ const out: number[] = [];
116
+ for (let i = 0; i < maxTokens; i++) {
117
+ const lg = tm.model.forward([last], cache);
118
+ const am = ops.argmaxAxis(lg, -1); // GPU argmax (mlx-lm greedy: lowest-index tie-break)
119
+ lg.dispose();
120
+ const best = ops.itemUint32(am);
121
+ am.dispose();
122
+ if (eos.has(best)) break; // mlx-lm halts on EOS and excludes it from the output
123
+ out.push(best);
124
+ last = best;
125
+ }
126
+ return tm.tokenizer.decode(out, true);
127
+ } finally {
128
+ for (const c of cache) c.dispose();
129
+ }
79
130
  }
80
131
 
81
132
  /** Complete `body` via the model's real quantized-KV path. Greedy by default;
82
133
  * pass `opts.sampler` to drive temperature/HLG/etc. */
83
134
  export async function generateText(tm: TaskModel, body: string, opts: GenOpts = {}): Promise<string> {
84
135
  const maxTokens = opts.maxTokens ?? 256;
85
- const text = opts.useChat !== false && tm.template
86
- ? tm.template.render([{ role: "user", content: body }], { addGenerationPrompt: true })
136
+ const enableThinking = opts.enableThinking ?? process.env.MLX_BUN_EVAL_THINK === "1";
137
+ const templated = opts.useChat !== false && tm.template !== null;
138
+ const text = templated
139
+ ? tm.template!.render([{ role: "user", content: body }], { addGenerationPrompt: true, enableThinking })
87
140
  : body;
88
- const ids = tm.tokenizer.encode(text);
141
+ // The chat template ALREADY emits the BOS; encoding it with add_special_tokens on
142
+ // would prepend a SECOND BOS (<s><s>…), corrupting generation. Raw bodies (no
143
+ // template) get the BOS added as normal.
144
+ const ids = tm.tokenizer.encode(text, /* addSpecialTokens */ !templated);
145
+
146
+ // optiq's PUBLISHED eval generates with a FULL-PRECISION KV cache — its eval calls
147
+ // plain mlx_lm.generate (kv-quant lives in the serving runtime, not the eval). So
148
+ // for like-for-like parity we default the eval to UNQUANTIZED KV; MMLU's argmax
149
+ // path was already unquantized, which is why it matched while generation didn't.
150
+ // MLX_BUN_EVAL_KV_QUANT=1 generates through the model's quantized KV (serving config).
151
+ const kv = (process.env.MLX_BUN_EVAL_KV_QUANT === "1" && tm.config.kvQuant?.length)
152
+ ? tm.config.kvQuant : undefined;
89
153
 
90
- const kv = tm.config.kvQuant?.length ? tm.config.kvQuant : undefined;
154
+ // Parity default — greedy + full-precision KV + no sampler arm: decode bit-exactly
155
+ // via the raw forward (matches mlx-lm token-for-token). Sampler arms / kv-quant fall
156
+ // through to the product generate().
157
+ if (!kv && !opts.sampler && !tm.samplerOverride) {
158
+ return greedyDecodeBitExact(tm, ids, maxTokens);
159
+ }
91
160
  const gen = generate(tm.model, ids, {
92
161
  maxTokens,
93
162
  temperature: 0, // greedy default — deterministic head-to-head arms
163
+ adapters: tm.activeAdapters ?? [], // per-request activation of the mounted eval-adapter
94
164
  ...(kv ? { kvConfig: kv, quantizedKvStart: 0 } : {}),
95
165
  ...(opts.sampler ?? {}), // overrides temperature when provided
96
166
  ...(tm.samplerOverride ?? {}), // arm override wins over the task's own sampler
@@ -15,6 +15,7 @@
15
15
  // of the task — is ported exactly. (See summary for this one note.)
16
16
 
17
17
  import { generateText, loadJsonl, sampleIndices, type TaskModel } from "../runner";
18
+ import { type ToolDefinition } from "../chat-template";
18
19
 
19
20
  // ---------------------------------------------------------------------------
20
21
  // Data shapes (the merged jsonl: each line = {query, answer}).
@@ -446,29 +447,62 @@ function gtName(gt: Record<string, unknown>): string {
446
447
  // Eval loop.
447
448
  // ---------------------------------------------------------------------------
448
449
 
450
+ // optiq's frozen rows are FLAT (`{**question, ground_truth}`) — the question dict
451
+ // merged with its ground truth — unlike our nested {query, answer} export.
452
+ interface BfclFrozenRow {
453
+ id: string;
454
+ question: BfclQuery["question"];
455
+ function: ToolDef[] | string;
456
+ ground_truth: BfclAnswer["ground_truth"];
457
+ }
458
+ type BfclItem = { question: BfclQuery["question"]; function: ToolDef[] | string; ground_truth: BfclAnswer["ground_truth"] };
459
+
449
460
  export async function evaluateBfcl(
450
461
  tm: TaskModel,
451
- opts: { nSamples?: number; maxTokens?: number; seed?: number } = {},
462
+ opts: { nSamples?: number; maxTokens?: number; seed?: number; frozen?: boolean } = {},
452
463
  ): Promise<BfclResult> {
453
464
  const maxTokens = opts.maxTokens ?? 512;
454
- const rows = loadJsonl<BfclRow>("bfcl");
455
- const idx = sampleIndices(rows.length, opts.nSamples ?? 200, opts.seed ?? 42);
465
+
466
+ // Optiq-parity mode (DEFAULT): optiq's EXACT 200 BFCL-simple questions, scored
467
+ // through OUR pipeline. MLX_BUN_BFCL_FROZEN=0 reverts to our copy + sampling.
468
+ const useFrozen = opts.frozen ?? (process.env.MLX_BUN_BFCL_FROZEN !== "0");
469
+ let items: BfclItem[];
470
+ if (useFrozen) {
471
+ items = loadJsonl<BfclFrozenRow>("bfcl_optiq_frozen").map((r) => ({
472
+ question: r.question, function: r.function, ground_truth: r.ground_truth,
473
+ }));
474
+ } else {
475
+ const rows = loadJsonl<BfclRow>("bfcl");
476
+ items = sampleIndices(rows.length, opts.nSamples ?? 200, opts.seed ?? 42).map((i) => ({
477
+ question: rows[i]!.query.question, function: rows[i]!.query.function, ground_truth: rows[i]!.answer.ground_truth,
478
+ }));
479
+ }
456
480
 
457
481
  let nCorrect = 0;
458
482
  let nNoCall = 0;
459
483
  let nWrongName = 0;
460
484
  let nWrongArgs = 0;
461
485
 
462
- for (let k = 0; k < idx.length; k++) {
463
- const row = rows[idx[k]!]!;
464
- const q = row.query;
465
- const text = userText(q.question);
466
- const tools = wrapToolsForChat(q.function);
467
- const prompt = buildPrompt(text, tools);
468
-
469
- const out = await generateText(tm, prompt, { maxTokens, useChat: true });
486
+ for (let k = 0; k < items.length; k++) {
487
+ const item = items[k]!;
488
+ const text = userText(item.question);
489
+ const tools = wrapToolsForChat(item.function);
490
+
491
+ // optiq's primary path: tools go THROUGH the chat template (the MiniCPM5
492
+ // template renders them as a `# Tools` system turn). Only fall back to the
493
+ // textual prompt when there's no chat template — mirrors bfcl.py's try/except.
494
+ let out: string;
495
+ if (tm.template) {
496
+ const rendered = tm.template.render(
497
+ [{ role: "user", content: text }],
498
+ { addGenerationPrompt: true, enableThinking: false, tools: tools as ToolDefinition[] },
499
+ );
500
+ out = await generateText(tm, rendered, { maxTokens, useChat: false });
501
+ } else {
502
+ out = await generateText(tm, buildPrompt(text, tools), { maxTokens, useChat: true });
503
+ }
470
504
  const predicted = extractToolCall(out);
471
- const gt = parseGroundTruth(row.answer.ground_truth);
505
+ const gt = parseGroundTruth(item.ground_truth);
472
506
 
473
507
  if (predicted === null) {
474
508
  nNoCall++;
@@ -480,15 +514,15 @@ export async function evaluateBfcl(
480
514
  nCorrect++;
481
515
  }
482
516
 
483
- if ((k + 1) % 10 === 0 || k + 1 === idx.length) {
517
+ if ((k + 1) % 10 === 0 || k + 1 === items.length) {
484
518
  process.stderr.write(
485
- `\r bfcl ${k + 1}/${idx.length} acc=${((nCorrect / (k + 1)) * 100).toFixed(1)}%`,
519
+ `\r bfcl ${k + 1}/${items.length} acc=${((nCorrect / (k + 1)) * 100).toFixed(1)}%`,
486
520
  );
487
521
  }
488
522
  }
489
523
  process.stderr.write("\n");
490
524
 
491
- const nTotal = idx.length;
525
+ const nTotal = items.length;
492
526
  return {
493
527
  nCorrect,
494
528
  nTotal,
@@ -38,7 +38,8 @@ const NUM = String.raw`-?[\d,]+\.?\d*`;
38
38
  /** Model output → numeric answer string (####, then \boxed{}, then last number). */
39
39
  export function extractAnswer(textIn: string): string | null {
40
40
  let text = textIn;
41
- if (text.includes("</think>")) text = text.split("</think>")[1] ?? text;
41
+ // Match Python's `split("</think>", 1)[1]`: everything after the FIRST tag.
42
+ if (text.includes("</think>")) text = text.split("</think>").slice(1).join("</think>");
42
43
 
43
44
  const hash = text.match(new RegExp(String.raw`####\s*(${NUM})`));
44
45
  if (hash) return hash[1]!.replace(/,/g, "").trim();
@@ -63,23 +64,34 @@ function toNum(s: string | null): number | null {
63
64
 
64
65
  export async function evaluateGsm8k(
65
66
  tm: TaskModel,
66
- opts: { nSamples?: number; nShots?: number; maxTokens?: number; seed?: number } = {},
67
+ opts: { nSamples?: number; nShots?: number; maxTokens?: number; seed?: number; frozen?: boolean } = {},
67
68
  ): Promise<Gsm8kResult> {
68
69
  const nShots = opts.nShots ?? 3;
69
70
  const maxTokens = opts.maxTokens ?? 256;
70
- const rows = loadJsonl<Gsm8kRow>("gsm8k");
71
- const idx = sampleIndices(rows.length, opts.nSamples ?? 200, opts.seed ?? 42);
71
+
72
+ // Optiq-parity mode (DEFAULT): score optiq's EXACT 1000 questions (the same data
73
+ // his published GSM8K used) but through OUR OWN pipeline — our few-shot builder,
74
+ // our chat template, our scorer. If our number reproduces his, our runtime is
75
+ // faithful. MLX_BUN_GSM8K_FROZEN=0 falls back to our own sampling of the full set.
76
+ const useFrozen = opts.frozen ?? (process.env.MLX_BUN_GSM8K_FROZEN !== "0");
77
+ let rows: Gsm8kRow[];
78
+ if (useFrozen) {
79
+ rows = loadJsonl<Gsm8kRow>("gsm8k_optiq_frozen"); // optiq's exact draw; we use {question, answer}
80
+ } else {
81
+ const all = loadJsonl<Gsm8kRow>("gsm8k");
82
+ rows = sampleIndices(all.length, opts.nSamples ?? 1000, opts.seed ?? 42).map((i) => all[i]!);
83
+ }
72
84
 
73
85
  let nCorrect = 0;
74
- for (let k = 0; k < idx.length; k++) {
75
- const item = rows[idx[k]!]!;
86
+ for (let k = 0; k < rows.length; k++) {
87
+ const item = rows[k]!;
76
88
  const gt = toNum(groundTruth(item.answer));
77
89
  const out = await generateText(tm, buildPrompt(item.question, nShots), { maxTokens, useChat: true });
78
90
  const pred = toNum(extractAnswer(out));
79
91
  if (gt !== null && pred !== null && Math.abs(gt - pred) < 1e-3) nCorrect++;
80
- if ((k + 1) % 10 === 0 || k + 1 === idx.length)
81
- process.stderr.write(`\r gsm8k ${k + 1}/${idx.length} acc=${(nCorrect / (k + 1) * 100).toFixed(1)}%`);
92
+ if ((k + 1) % 10 === 0 || k + 1 === rows.length)
93
+ process.stderr.write(`\r gsm8k ${k + 1}/${rows.length} acc=${(nCorrect / (k + 1) * 100).toFixed(1)}%`);
82
94
  }
83
95
  process.stderr.write("\n");
84
- return { nCorrect, nTotal: idx.length, accuracy: idx.length ? nCorrect / idx.length : 0 };
96
+ return { nCorrect, nTotal: rows.length, accuracy: rows.length ? nCorrect / rows.length : 0 };
85
97
  }
@@ -9,7 +9,7 @@
9
9
  // softmax / mixed-4/8-bit-KV error compounds across hops, so it's where the
10
10
  // fused-attention kernel (opportunity A) has the most to prove.
11
11
 
12
- import { generateText, type TaskModel } from "../runner";
12
+ import { generateText, loadJsonl, type TaskModel } from "../runner";
13
13
 
14
14
  const INSTR =
15
15
  "You will be given a list of hash assignments and a starting hash. " +
@@ -80,16 +80,45 @@ export interface HashhopResult {
80
80
  byHops: Record<number, number>;
81
81
  }
82
82
 
83
+ interface HashhopFrozen { raw_problem: string; query: string; expected: string }
84
+
83
85
  export async function evaluateHashhop(
84
86
  tm: TaskModel,
85
- opts: { nPerHop?: number; hopsList?: number[]; chars?: number; maxTokens?: number; seed?: number } = {},
87
+ opts: { nPerHop?: number; hopsList?: number[]; chars?: number; maxTokens?: number; seed?: number; frozen?: boolean } = {},
86
88
  ): Promise<HashhopResult> {
87
89
  const nPerHop = opts.nPerHop ?? 25;
88
90
  const hopsList = opts.hopsList ?? [1, 2, 3, 4];
89
- const chars = opts.chars ?? 12000;
90
91
  const maxTokens = opts.maxTokens ?? 32;
91
- const rand = mulberry32(opts.seed ?? 42);
92
92
 
93
+ // Optiq-parity mode (DEFAULT): use optiq's EXACT generated problems (his
94
+ // MultiHopEval samples — seed 42, 25/hop × hops 1-4 = 100, emitted in hop order),
95
+ // fed through OUR chat template + extractor. Our generator below is a
96
+ // RECONSTRUCTION producing DIFFERENT problems, so it cannot reproduce optiq's
97
+ // number — the frozen problems are the only way to match. MLX_BUN_HASHHOP_FROZEN=0
98
+ // uses our generator.
99
+ const useFrozen = opts.frozen ?? (process.env.MLX_BUN_HASHHOP_FROZEN !== "0");
100
+ if (useFrozen) {
101
+ const rows = loadJsonl<HashhopFrozen>("hashhop_optiq_frozen");
102
+ const hopCorrect: Record<number, number> = {};
103
+ let total = 0;
104
+ let correct = 0;
105
+ for (let k = 0; k < rows.length; k++) {
106
+ const item = rows[k]!;
107
+ const hops = hopsList[Math.floor(k / nPerHop)] ?? 0; // optiq emits in hop order
108
+ const out = await generateText(tm, item.raw_problem, { maxTokens, useChat: true });
109
+ if (extractPred(out) === item.expected) { correct++; hopCorrect[hops] = (hopCorrect[hops] ?? 0) + 1; }
110
+ total++;
111
+ process.stderr.write(`\r hashhop ${k + 1}/${rows.length} acc=${(correct / (k + 1) * 100).toFixed(0)}%`);
112
+ }
113
+ process.stderr.write("\n");
114
+ const byHops: Record<number, number> = {};
115
+ for (const h of hopsList) byHops[h] = (hopCorrect[h] ?? 0) / nPerHop;
116
+ return { nTotal: total, nCorrect: correct, accuracy: total ? correct / total : 0, byHops };
117
+ }
118
+
119
+ // Fallback: our own reconstructed generator (produces DIFFERENT problems).
120
+ const chars = opts.chars ?? 12000;
121
+ const rand = mulberry32(opts.seed ?? 42);
93
122
  const byHops: Record<number, number> = {};
94
123
  let total = 0;
95
124
  let correct = 0;
@@ -18,7 +18,9 @@ import { join } from "node:path";
18
18
  import { generateText, loadJsonl, sampleIndices, type TaskModel } from "../runner";
19
19
 
20
20
  // Oracle venv python — the same interpreter the Python reference uses.
21
- const ORACLE_PYTHON = "/Users/joshrossi/Code/mlx-lm/.venv/bin/python";
21
+ // Machine-specific: overridable via MLX_BUN_ORACLE_PYTHON (the committed default is
22
+ // the reference box; other laptops set the env, never re-commit the path).
23
+ const ORACLE_PYTHON = process.env.MLX_BUN_ORACLE_PYTHON ?? "/Users/joshrossi/Code/mlx-lm/.venv/bin/python";
22
24
 
23
25
  interface HumanevalRow {
24
26
  task_id: string;
@@ -144,7 +146,7 @@ const HAS_SANDBOX_EXEC = (() => {
144
146
  }
145
147
  })();
146
148
 
147
- interface RunResult { ok: boolean; timedOut: boolean }
149
+ interface RunResult { ok: boolean; timedOut: boolean; stderr?: string }
148
150
 
149
151
  /**
150
152
  * Run `program` as a python script in a fresh temp dir, sandboxed if
@@ -183,7 +185,8 @@ function runProgram(program: string, timeoutSec: number, memoryMb: number): RunR
183
185
  env,
184
186
  timeout: timeoutSec * 1000, // hard wall-clock kill
185
187
  killSignal: "SIGKILL",
186
- stdio: ["ignore", "ignore", "ignore"],
188
+ stdio: ["ignore", "ignore", process.env.MLX_BUN_EVAL_DEBUG === "1" ? "pipe" : "ignore"],
189
+ encoding: "utf8",
187
190
  });
188
191
 
189
192
  // spawnSync sets signal === "SIGKILL" (and status === null) when the
@@ -192,7 +195,7 @@ function runProgram(program: string, timeoutSec: number, memoryMb: number): RunR
192
195
  res.signal === "SIGKILL" ||
193
196
  (res.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT";
194
197
  const ok = !timedOut && res.status === 0;
195
- return { ok, timedOut };
198
+ return { ok, timedOut, stderr: typeof res.stderr === "string" ? res.stderr : undefined };
196
199
  } finally {
197
200
  rmSync(sbRoot, { recursive: true, force: true });
198
201
  }
@@ -206,13 +209,21 @@ export async function evaluateHumaneval(
206
209
  seed?: number;
207
210
  timeoutSec?: number;
208
211
  memoryMb?: number;
212
+ frozen?: boolean;
209
213
  } = {},
210
214
  ): Promise<HumanevalResult> {
211
215
  const maxTokens = opts.maxTokens ?? 512;
212
216
  const timeoutSec = opts.timeoutSec ?? 10;
213
217
  const memoryMb = opts.memoryMb ?? 512;
214
- const rows = loadJsonl<HumanevalRow>("humaneval");
215
- const idx = sampleIndices(rows.length, opts.nSamples ?? rows.length, opts.seed ?? 42);
218
+
219
+ // Optiq-parity mode (DEFAULT): optiq's EXACT 164-problem set (he runs the full
220
+ // HumanEval test split), scored through OUR pipeline. MLX_BUN_HUMANEVAL_FROZEN=0
221
+ // reverts to our own copy + optional sampling.
222
+ const useFrozen = opts.frozen ?? (process.env.MLX_BUN_HUMANEVAL_FROZEN !== "0");
223
+ const rows = loadJsonl<HumanevalRow>(useFrozen ? "humaneval_optiq_frozen" : "humaneval");
224
+ const idx = useFrozen
225
+ ? Array.from({ length: rows.length }, (_, i) => i)
226
+ : sampleIndices(rows.length, opts.nSamples ?? rows.length, opts.seed ?? 42);
216
227
 
217
228
  // When a chat template exists, ask the model (instruct-style) for the
218
229
  // complete function inside a ```python block — humaneval.py's use_chat
@@ -239,8 +250,19 @@ export async function evaluateHumaneval(
239
250
  const completion = truncateCompletion(raw, ex.entry_point);
240
251
  const program = buildProgram(ex.prompt, completion, ex.test, ex.entry_point);
241
252
 
242
- const { ok } = runProgram(program, timeoutSec, memoryMb);
243
- if (ok) nPass++;
253
+ const rr = runProgram(program, timeoutSec, memoryMb);
254
+ if (rr.ok) nPass++;
255
+ else if (process.env.MLX_BUN_EVAL_DEBUG === "1") {
256
+ const err = rr.stderr ?? "";
257
+ const reason = rr.timedOut ? "TIMEOUT"
258
+ : completion.trim() === "" ? "EMPTY"
259
+ : /SyntaxError|IndentationError/.test(err) ? "SYNTAX"
260
+ : /AssertionError/.test(err) ? "WRONG-ANSWER"
261
+ : /Error|Exception|Traceback/.test(err) ? "RUNTIME-ERR"
262
+ : "OTHER";
263
+ const errSnip = err.trim().split("\n").slice(-1)[0]?.slice(0, 120) ?? "";
264
+ process.stderr.write(`\n [FAIL ${ex.entry_point}] ${reason} complen=${completion.length} :: ${errSnip}\n`);
265
+ }
244
266
 
245
267
  if ((k + 1) % 5 === 0 || k + 1 === idx.length)
246
268
  process.stderr.write(
@@ -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
  );