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.
- package/README.md +11 -2
- package/docs/dag/training-inference-map.html +575 -0
- package/docs/design/memory-system.md +193 -295
- package/docs/design/orpo-dynamic-lambda.md +179 -0
- package/docs/design/orpo-future-enhancements.md +42 -0
- package/docs/design/orpo-training.md +13 -0
- package/docs/design/parity-tier-dag.md +143 -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/investigations/steel-flash-cce-handoff.md +58 -0
- package/docs/investigations/trainer-validation-experiment.md +173 -0
- package/docs/reference/memory.md +77 -0
- package/docs/reference/orpo-quickstart.md +44 -5
- package/docs/reference/training.md +9 -5
- package/package.json +1 -1
- package/src/cli.ts +651 -18
- 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/jobs/runner.ts +6 -0
- 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 +13 -0
- package/src/model/flash-attention.ts +5 -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 +213 -44
- package/src/tokenizer.ts +6 -2
- package/src/train/dataset.ts +11 -3
- package/src/train/flash-cce.ts +18 -5
- package/src/train/job.ts +41 -4
- package/src/train/loss.ts +10 -4
- package/src/train/trainer.ts +62 -3
- package/src/train/watch.ts +542 -0
- package/src/web/app.html +264 -14
- 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/src/eval/runner.ts
CHANGED
|
@@ -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
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/src/eval/tasks/bfcl.ts
CHANGED
|
@@ -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
|
-
|
|
455
|
-
|
|
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 <
|
|
463
|
-
const
|
|
464
|
-
const
|
|
465
|
-
const
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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(
|
|
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 ===
|
|
517
|
+
if ((k + 1) % 10 === 0 || k + 1 === items.length) {
|
|
484
518
|
process.stderr.write(
|
|
485
|
-
`\r bfcl ${k + 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 =
|
|
525
|
+
const nTotal = items.length;
|
|
492
526
|
return {
|
|
493
527
|
nCorrect,
|
|
494
528
|
nTotal,
|
package/src/eval/tasks/gsm8k.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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 <
|
|
75
|
-
const item = rows[
|
|
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 ===
|
|
81
|
-
process.stderr.write(`\r gsm8k ${k + 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:
|
|
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
|
-
|
|
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
|
-
|
|
215
|
-
|
|
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
|
|
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(
|
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
|
);
|