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
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// IFEval — programmatically-verifiable instruction-following scoring (Zhou et
|
|
2
|
+
// al. 2023, "Instruction-Following Evaluation for Large Language Models"). Each
|
|
3
|
+
// instance pairs a prompt with one or more *verifiable* instructions; a response
|
|
4
|
+
// is scored by deterministic checks (no LLM judge), so before/after ORPO
|
|
5
|
+
// comparisons are exact and reproducible.
|
|
6
|
+
//
|
|
7
|
+
// This implements the high-frequency instruction families (a working subset of
|
|
8
|
+
// the ~25 in the reference suite, extensible via INSTRUCTIONS). Strict scoring:
|
|
9
|
+
// an instance passes (strict) iff every instruction passes; we also report the
|
|
10
|
+
// per-instruction pass rate. The official metrics are prompt-level and
|
|
11
|
+
// instruction-level accuracy — both derived from `scoreInstance`.
|
|
12
|
+
|
|
13
|
+
export interface IFEvalInstance {
|
|
14
|
+
/** The prompt text (used by instructions that reference it, e.g. "repeat the request"). */
|
|
15
|
+
prompt: string;
|
|
16
|
+
/** Instruction ids, e.g. "keywords:existence". */
|
|
17
|
+
instruction_id_list: string[];
|
|
18
|
+
/** Per-instruction kwargs, aligned with instruction_id_list. */
|
|
19
|
+
kwargs: Array<Record<string, unknown>>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface InstanceResult {
|
|
23
|
+
/** One pass/fail per instruction (aligned with instruction_id_list). */
|
|
24
|
+
perInstruction: boolean[];
|
|
25
|
+
/** Strict: all instructions passed. */
|
|
26
|
+
followedAll: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type Checker = (response: string, kw: Record<string, unknown>, inst: IFEvalInstance) => boolean;
|
|
30
|
+
|
|
31
|
+
const words = (s: string): string[] => s.trim().split(/\s+/).filter(Boolean);
|
|
32
|
+
const sentences = (s: string): string[] =>
|
|
33
|
+
s.split(/(?<=[.!?])\s+/).map((x) => x.trim()).filter(Boolean);
|
|
34
|
+
const paragraphs = (s: string): string[] =>
|
|
35
|
+
s.split(/\n\s*\n/).map((x) => x.trim()).filter(Boolean);
|
|
36
|
+
const norm = (s: string): string => s.toLowerCase();
|
|
37
|
+
|
|
38
|
+
const cmp = (n: number, rel: string | undefined, target: number): boolean => {
|
|
39
|
+
switch (rel) {
|
|
40
|
+
case "at least": return n >= target;
|
|
41
|
+
case "at most": return n <= target;
|
|
42
|
+
case "less than": return n < target;
|
|
43
|
+
case "exactly": return n === target;
|
|
44
|
+
default: return n >= target; // IFEval default for "around"/unspecified is lenient ≥
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** The verifiable-instruction registry. Keyed by IFEval instruction_id. */
|
|
49
|
+
export const INSTRUCTIONS: Record<string, Checker> = {
|
|
50
|
+
// ---- keywords ----
|
|
51
|
+
"keywords:existence": (r, kw) =>
|
|
52
|
+
(kw.keywords as string[]).every((k) => norm(r).includes(norm(k))),
|
|
53
|
+
"keywords:frequency": (r, kw) => {
|
|
54
|
+
const k = norm(kw.keyword as string);
|
|
55
|
+
const count = norm(r).split(k).length - 1;
|
|
56
|
+
return cmp(count, kw.relation as string, kw.frequency as number);
|
|
57
|
+
},
|
|
58
|
+
"keywords:forbidden_words": (r, kw) =>
|
|
59
|
+
!(kw.forbidden_words as string[]).some((k) => norm(r).includes(norm(k))),
|
|
60
|
+
|
|
61
|
+
// ---- length constraints ----
|
|
62
|
+
"length_constraints:number_words": (r, kw) =>
|
|
63
|
+
cmp(words(r).length, kw.relation as string, kw.num_words as number),
|
|
64
|
+
"length_constraints:number_sentences": (r, kw) =>
|
|
65
|
+
cmp(sentences(r).length, kw.relation as string, kw.num_sentences as number),
|
|
66
|
+
"length_constraints:number_paragraphs": (r, kw) =>
|
|
67
|
+
cmp(paragraphs(r).length, kw.relation as string, kw.num_paragraphs as number),
|
|
68
|
+
|
|
69
|
+
// ---- detectable format ----
|
|
70
|
+
"detectable_format:number_bullet_lists": (r, kw) => {
|
|
71
|
+
const bullets = r.split("\n").filter((l) => /^\s*[*\-]\s+/.test(l)).length;
|
|
72
|
+
return cmp(bullets, kw.relation as string, kw.num_bullets as number);
|
|
73
|
+
},
|
|
74
|
+
"detectable_format:number_highlighted_sections": (r, kw) => {
|
|
75
|
+
const hi = (r.match(/\*[^*\n]+\*/g) ?? []).length;
|
|
76
|
+
return cmp(hi, kw.relation as string, kw.num_highlights as number);
|
|
77
|
+
},
|
|
78
|
+
"detectable_format:title": (r) => /<<[^>\n]+>>/.test(r),
|
|
79
|
+
"detectable_format:json_format": (r) => {
|
|
80
|
+
const t = r.trim().replace(/^```(?:json)?\s*|\s*```$/g, "").trim();
|
|
81
|
+
try { JSON.parse(t); return true; } catch { return false; }
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
// ---- case ----
|
|
85
|
+
"change_case:english_lowercase": (r) => r === r.toLowerCase(),
|
|
86
|
+
"change_case:english_capital": (r) => r === r.toUpperCase(),
|
|
87
|
+
|
|
88
|
+
// ---- start / end ----
|
|
89
|
+
"startend:end_checker": (r, kw) =>
|
|
90
|
+
norm(r.trimEnd()).endsWith(norm((kw.end_phrase as string).trim())),
|
|
91
|
+
"startend:quotation": (r) => {
|
|
92
|
+
const t = r.trim();
|
|
93
|
+
return t.startsWith('"') && t.endsWith('"') && t.length >= 2;
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
// ---- punctuation ----
|
|
97
|
+
"punctuation:no_comma": (r) => !r.includes(","),
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** Score one IFEval instance (strict). Unknown instruction ids count as failed
|
|
101
|
+
* (so coverage gaps never inflate the score) — filter the dataset to the
|
|
102
|
+
* supported ids, or extend INSTRUCTIONS, to avoid that. */
|
|
103
|
+
export function scoreInstance(instance: IFEvalInstance, response: string): InstanceResult {
|
|
104
|
+
const perInstruction = instance.instruction_id_list.map((id, i) => {
|
|
105
|
+
const check = INSTRUCTIONS[id];
|
|
106
|
+
if (!check) return false;
|
|
107
|
+
try { return check(response, instance.kwargs[i] ?? {}, instance); } catch { return false; }
|
|
108
|
+
});
|
|
109
|
+
return { perInstruction, followedAll: perInstruction.every(Boolean) };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface IFEvalReport {
|
|
113
|
+
n: number;
|
|
114
|
+
/** Fraction of instances where ALL instructions were followed. */
|
|
115
|
+
promptAccuracy: number;
|
|
116
|
+
/** Fraction of individual instructions followed. */
|
|
117
|
+
instructionAccuracy: number;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Aggregate the official prompt-level + instruction-level accuracies over a set
|
|
121
|
+
* of (instance, response) pairs. */
|
|
122
|
+
export function aggregate(pairs: Array<{ instance: IFEvalInstance; response: string }>): IFEvalReport {
|
|
123
|
+
let prompts = 0, instrFollowed = 0, instrTotal = 0;
|
|
124
|
+
for (const { instance, response } of pairs) {
|
|
125
|
+
const r = scoreInstance(instance, response);
|
|
126
|
+
if (r.followedAll) prompts++;
|
|
127
|
+
instrFollowed += r.perInstruction.filter(Boolean).length;
|
|
128
|
+
instrTotal += r.perInstruction.length;
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
n: pairs.length,
|
|
132
|
+
promptAccuracy: pairs.length ? prompts / pairs.length : 0,
|
|
133
|
+
instructionAccuracy: instrTotal ? instrFollowed / instrTotal : 0,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Instruction ids this implementation supports (filter datasets to these). */
|
|
138
|
+
export const SUPPORTED_INSTRUCTIONS = new Set(Object.keys(INSTRUCTIONS));
|
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(
|