mlx-bun 0.0.4

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 (210) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +362 -0
  3. package/THIRD_PARTY_LICENSES.md +41 -0
  4. package/bin/mlx-bun.mjs +32 -0
  5. package/docs/curve-designer.html +303 -0
  6. package/docs/design/adapters-end-to-end.md +201 -0
  7. package/docs/design/compat-cli-surface-design.md +227 -0
  8. package/docs/design/docs-reorg-plan.md +152 -0
  9. package/docs/design/hlg-sampling.md +340 -0
  10. package/docs/design/memory-system.md +339 -0
  11. package/docs/design/optimization_plan.md +344 -0
  12. package/docs/design/parallel-slots.md +515 -0
  13. package/docs/design/runtime-isolation.md +247 -0
  14. package/docs/design/segmented-backward-training.md +508 -0
  15. package/docs/investigations/chunk-finetune-experiment.md +84 -0
  16. package/docs/investigations/curve-bisector-routing.md +123 -0
  17. package/docs/investigations/curve-runs/bisector-route.json +98 -0
  18. package/docs/investigations/curve-runs/bisector-trace.json +1393 -0
  19. package/docs/investigations/curve-runs/curve-terrain.json +1179 -0
  20. package/docs/investigations/curve-runs/curve-terrain.svg +42 -0
  21. package/docs/investigations/curve-terrain.html +105 -0
  22. package/docs/investigations/expert-offload-single-user-moe.md +292 -0
  23. package/docs/investigations/hlg-ab.html +34 -0
  24. package/docs/investigations/hlg-explorer.html +238 -0
  25. package/docs/investigations/hlg-figs/cliff.svg +1 -0
  26. package/docs/investigations/hlg-figs/cross.svg +1 -0
  27. package/docs/investigations/hlg-figs/edges.svg +1 -0
  28. package/docs/investigations/hlg-figs/k20-grids.svg +1 -0
  29. package/docs/investigations/hlg-figs/k20-peaks.svg +1 -0
  30. package/docs/investigations/hlg-figs/lw-curve.svg +1 -0
  31. package/docs/investigations/hlg-figs/terrain.svg +1 -0
  32. package/docs/investigations/hlg-report.html +262 -0
  33. package/docs/investigations/hlg-runs/hlg-ab.json +149 -0
  34. package/docs/investigations/hlg-runs/hlg-blind-prompts.md +87 -0
  35. package/docs/investigations/hlg-runs/hlg-blind.json +107 -0
  36. package/docs/investigations/hlg-runs/hlg-cliff.json +197 -0
  37. package/docs/investigations/hlg-runs/hlg-cross.json +505 -0
  38. package/docs/investigations/hlg-runs/hlg-edge.json +388 -0
  39. package/docs/investigations/hlg-runs/hlg-eval1.json +25 -0
  40. package/docs/investigations/hlg-runs/hlg-frontier.json +86 -0
  41. package/docs/investigations/hlg-runs/hlg-grid.json +242 -0
  42. package/docs/investigations/hlg-runs/hlg-grid2.json +343 -0
  43. package/docs/investigations/hlg-runs/hlg-lw-coarse.json +110 -0
  44. package/docs/investigations/hlg-runs/hlg-map.json +383 -0
  45. package/docs/investigations/hlg-runs/run0-original.log +159 -0
  46. package/docs/investigations/hlg-runs/runA-recommended.log +131 -0
  47. package/docs/investigations/hlg-runs/runAA-cliff-cpm5.log +26 -0
  48. package/docs/investigations/hlg-runs/runAB-grid2-e4b.log +18 -0
  49. package/docs/investigations/hlg-runs/runAC-peak-e4b-partial.log +25 -0
  50. package/docs/investigations/hlg-runs/runAD-ab-samples.log +8 -0
  51. package/docs/investigations/hlg-runs/runAE-edge-e4b.log +51 -0
  52. package/docs/investigations/hlg-runs/runAF-logit-calibration.log +10 -0
  53. package/docs/investigations/hlg-runs/runAG-eval-config2.log +16 -0
  54. package/docs/investigations/hlg-runs/runB-gain-variants.log +192 -0
  55. package/docs/investigations/hlg-runs/runC-pivots.log +162 -0
  56. package/docs/investigations/hlg-runs/runD-oetf.log +132 -0
  57. package/docs/investigations/hlg-runs/runE-eotf.log +162 -0
  58. package/docs/investigations/hlg-runs/runF-shaper-working.log +86 -0
  59. package/docs/investigations/hlg-runs/runG-shaper-sweep.log +101 -0
  60. package/docs/investigations/hlg-runs/runH-wsweep.log +176 -0
  61. package/docs/investigations/hlg-runs/runI-asweep.log +176 -0
  62. package/docs/investigations/hlg-runs/runJ-knobmap.log +49 -0
  63. package/docs/investigations/hlg-runs/runK-frontier.log +19 -0
  64. package/docs/investigations/hlg-runs/runL-widemap.log +65 -0
  65. package/docs/investigations/hlg-runs/runM-frontier-emb.log +15 -0
  66. package/docs/investigations/hlg-runs/runN-verify.log +116 -0
  67. package/docs/investigations/hlg-runs/runO-frontier-seed5000.log +15 -0
  68. package/docs/investigations/hlg-runs/runP-grid-AxSm.log +15 -0
  69. package/docs/investigations/hlg-runs/runQ-grid-tgW.log +15 -0
  70. package/docs/investigations/hlg-runs/runR-blind-transcript.log +79 -0
  71. package/docs/investigations/hlg-runs/runS-cross-coherence.log +33 -0
  72. package/docs/investigations/hlg-runs/runT-cross-cpm5.log +33 -0
  73. package/docs/investigations/hlg-runs/runU-cross-12b.log +33 -0
  74. package/docs/investigations/hlg-runs/runV-lw-e4b.log +22 -0
  75. package/docs/investigations/hlg-runs/runW-lw-12b.log +22 -0
  76. package/docs/investigations/hlg-runs/runX-lw-cpm5.log +22 -0
  77. package/docs/investigations/hlg-runs/runY-cliff-e4b.log +26 -0
  78. package/docs/investigations/hlg-runs/runZ-cliff-12b.log +26 -0
  79. package/docs/investigations/hlg-sampling-investigation.md +970 -0
  80. package/docs/investigations/lab-build-journal.md +157 -0
  81. package/docs/investigations/pi-builtin-investigation.md +308 -0
  82. package/docs/investigations/starter-model-port-handoff.md +323 -0
  83. package/docs/planning/IDEAS.md +26 -0
  84. package/docs/planning/PRODUCT_ROADMAP.md +426 -0
  85. package/docs/planning/ResearchTopics.md +119 -0
  86. package/docs/planning/chunk-task-roadmap.md +77 -0
  87. package/docs/planning/journal.md +155 -0
  88. package/docs/reference/distribution.md +121 -0
  89. package/docs/reference/embedding.md +168 -0
  90. package/docs/reference/library-api.md +153 -0
  91. package/docs/reference/server-api.md +224 -0
  92. package/docs/reference/server-config.md +243 -0
  93. package/docs/segmented-backward-handoff.md +148 -0
  94. package/package.json +68 -0
  95. package/src/anthropic.ts +491 -0
  96. package/src/chat-template.ts +186 -0
  97. package/src/cli.ts +1075 -0
  98. package/src/config.ts +229 -0
  99. package/src/curve-sampler.ts +122 -0
  100. package/src/dataset/generators.ts +1087 -0
  101. package/src/dataset/index.ts +12 -0
  102. package/src/dataset/job.ts +81 -0
  103. package/src/dataset/llm.ts +71 -0
  104. package/src/dataset/registry.ts +528 -0
  105. package/src/download.ts +283 -0
  106. package/src/eval/README.md +86 -0
  107. package/src/eval/kl-prompts.ts +29 -0
  108. package/src/eval/kl.ts +294 -0
  109. package/src/eval/quality-db.ts +92 -0
  110. package/src/eval/runner.ts +102 -0
  111. package/src/eval/score.ts +49 -0
  112. package/src/eval/tasks/bfcl.ts +500 -0
  113. package/src/eval/tasks/gsm8k.ts +85 -0
  114. package/src/eval/tasks/hashhop.ts +109 -0
  115. package/src/eval/tasks/humaneval.ts +252 -0
  116. package/src/eval/tasks/ifeval.ts +354 -0
  117. package/src/eval/tasks/mmlu.ts +145 -0
  118. package/src/evaldb.ts +199 -0
  119. package/src/expert-offload-build.ts +77 -0
  120. package/src/expert-offload.ts +61 -0
  121. package/src/expert-trace.ts +67 -0
  122. package/src/fit.ts +254 -0
  123. package/src/generate.ts +451 -0
  124. package/src/harness-pi.ts +177 -0
  125. package/src/hf-push.ts +628 -0
  126. package/src/index.ts +22 -0
  127. package/src/jobs/db.ts +139 -0
  128. package/src/jobs/index.ts +29 -0
  129. package/src/jobs/job-entry.ts +106 -0
  130. package/src/jobs/runner.ts +304 -0
  131. package/src/jobs/sse.ts +114 -0
  132. package/src/jobs/types.ts +58 -0
  133. package/src/kv-store.ts +185 -0
  134. package/src/lora.ts +314 -0
  135. package/src/md-modules.d.ts +7 -0
  136. package/src/mlx/array.ts +237 -0
  137. package/src/mlx/autograd.ts +267 -0
  138. package/src/mlx/checkpoint.ts +116 -0
  139. package/src/mlx/compile.ts +158 -0
  140. package/src/mlx/custom-vjp.ts +140 -0
  141. package/src/mlx/ffi.ts +433 -0
  142. package/src/mlx/metal-kernel.ts +130 -0
  143. package/src/mlx/ops.ts +707 -0
  144. package/src/mmap.ts +90 -0
  145. package/src/model/batched-mask.ts +204 -0
  146. package/src/model/batched-rotating.ts +300 -0
  147. package/src/model/compiled-decode.ts +681 -0
  148. package/src/model/factory.ts +27 -0
  149. package/src/model/fingerprint.ts +33 -0
  150. package/src/model/flash-attention.ts +674 -0
  151. package/src/model/fused-decode-kernel.ts +305 -0
  152. package/src/model/fused-geglu-kernel.ts +87 -0
  153. package/src/model/gemma4-base.ts +1616 -0
  154. package/src/model/gemma4.ts +1012 -0
  155. package/src/model/generated/gemma4-12b.ts +526 -0
  156. package/src/model/generated/gemma4-26b.ts +500 -0
  157. package/src/model/generated/gemma4-e4b.ts +757 -0
  158. package/src/model/generated/index.ts +18 -0
  159. package/src/model/minicpm5.ts +278 -0
  160. package/src/model/qwen3-delta.ts +224 -0
  161. package/src/model/qwen3_5.ts +429 -0
  162. package/src/model/support.ts +46 -0
  163. package/src/native-pack.ts +152 -0
  164. package/src/pi-provider.ts +95 -0
  165. package/src/pi-terminal.ts +264 -0
  166. package/src/pi-web.ts +924 -0
  167. package/src/preflight.ts +123 -0
  168. package/src/prompt-cache.ts +113 -0
  169. package/src/quantize/allocator.ts +627 -0
  170. package/src/quantize/calibration.ts +180 -0
  171. package/src/quantize/config-writer.ts +132 -0
  172. package/src/quantize/index.ts +57 -0
  173. package/src/quantize/job.ts +136 -0
  174. package/src/quantize/quantizer.ts +462 -0
  175. package/src/quantize/safetensors-writer.ts +171 -0
  176. package/src/quantize/sensitivity.ts +234 -0
  177. package/src/registry.ts +336 -0
  178. package/src/responses.ts +774 -0
  179. package/src/safetensors.ts +158 -0
  180. package/src/sampler.ts +517 -0
  181. package/src/serve/batch-scheduler.ts +362 -0
  182. package/src/serve/generation-gateway.ts +160 -0
  183. package/src/server.ts +1891 -0
  184. package/src/spec/drafter.ts +261 -0
  185. package/src/spec/generate.ts +221 -0
  186. package/src/status-page.html +497 -0
  187. package/src/tokenizer.ts +38 -0
  188. package/src/tool-call.ts +242 -0
  189. package/src/train/dataset.ts +461 -0
  190. package/src/train/export.ts +32 -0
  191. package/src/train/forward.ts +229 -0
  192. package/src/train/index.ts +43 -0
  193. package/src/train/job.ts +148 -0
  194. package/src/train/lora-params.ts +194 -0
  195. package/src/train/loss.ts +342 -0
  196. package/src/train/merge.ts +195 -0
  197. package/src/train/optimizer.ts +176 -0
  198. package/src/train/rank.ts +101 -0
  199. package/src/train/segmented.ts +520 -0
  200. package/src/train/trainer.ts +600 -0
  201. package/src/tui.ts +161 -0
  202. package/src/vision/embedder.ts +168 -0
  203. package/src/vision/preprocess.ts +247 -0
  204. package/src/vision/prompt.ts +158 -0
  205. package/src/vision/siglip.ts +599 -0
  206. package/src/web/app.html +2471 -0
  207. package/src/web/skills/web-research/SKILL.md +44 -0
  208. package/src/web/skills.ts +59 -0
  209. package/src/web-tools.ts +426 -0
  210. package/src/weights.ts +91 -0
@@ -0,0 +1,234 @@
1
+ // Per-layer quantization sensitivity analysis (native TS port of
2
+ // optiq/core/sensitivity.py `analyze_sensitivity_exact`, uniform-baseline path).
3
+ //
4
+ // For each quantizable Linear in a loaded (already-quantized) model:
5
+ // 1. dequantize its packed weight back to bf16 — this is the bf16 "source"
6
+ // the OptIQ uniform_4bit path streams off disk; here it's self-contained
7
+ // because the running model already carries quantized weights.
8
+ // 2. for each candidate bit-width, re-quantize that bf16 source at the new
9
+ // bits and SWAP it into the running QuantizedLinear (mutate w/scales/biases
10
+ // + the QuantSpec bits/groupSize).
11
+ // 3. run a no-KV full-sequence forward on each calibration sample, compute
12
+ // KL(reference || current) (port of `_kl_from_ref`), average over samples.
13
+ // 4. restore the layer's original quantized weight.
14
+ //
15
+ // Reference logits = the UNMODIFIED running model (taken once up front). When a
16
+ // candidate bit equals the layer's baseline bit-width, re-quantizing reproduces
17
+ // exactly what the layer already is, so KL is 0 by construction — we record 0.0
18
+ // and skip the forward passes (port of the OptIQ baseline-bit short-circuit).
19
+ //
20
+ // Faithful to: `_simulate_quantize` (quantize→dequantize via MlxArray ops),
21
+ // `_mutate_quantized_layer_to_bits`, `_kl_from_ref`, the uniform-4bit-baseline
22
+ // path, and the bits==baseline ⇒ 0 short-circuit.
23
+
24
+ import { Dtype } from "../mlx/ffi";
25
+ import { cpuStream, MlxArray } from "../mlx/array";
26
+ import * as ops from "../mlx/ops";
27
+ import { trainForward } from "../train/forward";
28
+ import type { RuntimeModel } from "../model/factory";
29
+ import type { QuantizedLinear } from "../model/gemma4-base";
30
+
31
+ /** Per-layer KL sensitivity at each candidate bit-width (port of
32
+ * optiq SensitivityResult). */
33
+ export interface SensitivityResult {
34
+ layerName: string;
35
+ /** bit-width → mean KL(reference || that-bit) over calibration samples. */
36
+ sensitivities: Record<number, number>;
37
+ paramCount: number;
38
+ }
39
+
40
+ export interface AnalyzeSensitivityOptions {
41
+ candidateBits?: number[];
42
+ groupSize?: number;
43
+ }
44
+
45
+ // --------------------------------------------------------------------------
46
+ // Minimal in-place mutation of a QuantizedLinear.
47
+ //
48
+ // gemma4-base.ts declares QuantizedLinear's w/scales/biases/spec as `readonly`
49
+ // (they never change at inference time). The sensitivity sweep needs to swap a
50
+ // layer's quantized weight transiently. Rather than edit the model file, we
51
+ // poke the public fields here via a typed cast — a quantize-only mutation
52
+ // helper. The fields are public, so this is a write to public state, not a
53
+ // reach into private internals. Flagged in the port report.
54
+ // --------------------------------------------------------------------------
55
+
56
+ /** Mutable view of the public QuantizedLinear fields we swap. */
57
+ type MutableQuantizedLinear = {
58
+ w: MlxArray;
59
+ scales: MlxArray;
60
+ biases: MlxArray | null;
61
+ spec: ops.QuantSpec;
62
+ };
63
+
64
+ /** Snapshot of a QuantizedLinear's quantized state (for restore). */
65
+ interface LayerState {
66
+ w: MlxArray;
67
+ scales: MlxArray;
68
+ biases: MlxArray | null;
69
+ spec: ops.QuantSpec;
70
+ }
71
+
72
+ function captureState(layer: QuantizedLinear): LayerState {
73
+ return { w: layer.w, scales: layer.scales, biases: layer.biases, spec: layer.spec };
74
+ }
75
+
76
+ function restoreState(layer: QuantizedLinear, state: LayerState): void {
77
+ const m = layer as unknown as MutableQuantizedLinear;
78
+ // Dispose the transient swapped-in tensors before restoring the originals.
79
+ if (m.w !== state.w) m.w.dispose();
80
+ if (m.scales !== state.scales) m.scales.dispose();
81
+ if (m.biases && m.biases !== state.biases) m.biases.dispose();
82
+ m.w = state.w;
83
+ m.scales = state.scales;
84
+ m.biases = state.biases;
85
+ m.spec = state.spec;
86
+ }
87
+
88
+ /** Quantize `bf16Weight` at `newBits`/`groupSize` and swap it into `layer`
89
+ * in place (port of `_mutate_quantized_layer_to_bits`). The previously
90
+ * swapped-in tensors (if any beyond the captured original) are released by
91
+ * the caller via restoreState; this only installs the new ones. */
92
+ function mutateLayerToBits(
93
+ layer: QuantizedLinear,
94
+ bf16Weight: MlxArray,
95
+ newBits: number,
96
+ groupSize: number,
97
+ original: LayerState,
98
+ ): void {
99
+ const q = ops.quantize(bf16Weight, groupSize, newBits, "affine", cpuStream);
100
+ const m = layer as unknown as MutableQuantizedLinear;
101
+ // Free any previously swapped-in transient tensors (not the original).
102
+ if (m.w !== original.w) m.w.dispose();
103
+ if (m.scales !== original.scales) m.scales.dispose();
104
+ if (m.biases && m.biases !== original.biases) m.biases.dispose();
105
+ m.w = q.packed;
106
+ m.scales = q.scales;
107
+ m.biases = q.biases;
108
+ m.spec = { bits: newBits, groupSize, mode: "affine" };
109
+ ops.evalAll([q.packed, q.scales, q.biases]);
110
+ }
111
+
112
+ // --------------------------------------------------------------------------
113
+ // KL primitive (port of _kl_from_ref)
114
+ // --------------------------------------------------------------------------
115
+
116
+ /** KL(reference || current) averaged across batch/seq (port of `_kl_from_ref`):
117
+ * log_cur = cur - logsumexp(cur); log_ref = ref - logsumexp(ref)
118
+ * ref_probs = softmax(ref)
119
+ * mean( sum( ref_probs * (log_ref - log_cur), axis=-1 ) )
120
+ * Returns the scalar KL as a JS number. Casts to f32 for stability. */
121
+ export function klFromRef(curLogits: MlxArray, refLogits: MlxArray): number {
122
+ const cur = curLogits.dtype === Dtype.float32 ? curLogits : curLogits.astype(Dtype.float32);
123
+ const ref = refLogits.dtype === Dtype.float32 ? refLogits : refLogits.astype(Dtype.float32);
124
+
125
+ const lseCur = ops.logsumexpAxis(cur, -1, true);
126
+ const lseRef = ops.logsumexpAxis(ref, -1, true);
127
+ const logCur = ops.sub(cur, lseCur);
128
+ const logRef = ops.sub(ref, lseRef);
129
+ const refProbs = ops.softmaxAxis(ref, -1, true);
130
+
131
+ const diff = ops.sub(logRef, logCur);
132
+ const weighted = ops.mul(refProbs, diff);
133
+ const summed = ops.sumAxis(weighted, -1, false);
134
+ const meaned = ops.meanAll(summed, false);
135
+
136
+ const val = meaned.toFloat32()[0]!;
137
+
138
+ for (const a of [lseCur, lseRef, logCur, logRef, refProbs, diff, weighted, summed, meaned]) {
139
+ a.dispose();
140
+ }
141
+ if (cur !== curLogits) cur.dispose();
142
+ if (ref !== refLogits) ref.dispose();
143
+ return val;
144
+ }
145
+
146
+ // --------------------------------------------------------------------------
147
+ // Public entry: analyzeSensitivityExact
148
+ // --------------------------------------------------------------------------
149
+
150
+ /** Per-layer KL sensitivity of an already-quantized MLX model.
151
+ *
152
+ * @param model loaded RuntimeModel (its QuantizedLinears are the
153
+ * uniform baseline; their dequantized weights are the
154
+ * self-contained bf16 source).
155
+ * @param layers the quantizable linears to probe, keyed by module path
156
+ * (typically `model.loraTargets()`).
157
+ * @param calIds calibration samples — each a row of int32 token ids.
158
+ * @param options candidateBits (default [4,8]) and groupSize (default 64).
159
+ */
160
+ export function analyzeSensitivityExact(
161
+ model: RuntimeModel,
162
+ layers: Map<string, QuantizedLinear>,
163
+ calIds: number[][],
164
+ options: AnalyzeSensitivityOptions = {},
165
+ onProgress?: (done: number, total: number, layerName: string) => void,
166
+ ): SensitivityResult[] {
167
+ const candidateBits = [...(options.candidateBits ?? [4, 8])];
168
+ const groupSize = options.groupSize ?? 64;
169
+
170
+ // Calibration sample arrays (int32 [1, L]).
171
+ const calArrays = calIds.map((ids) =>
172
+ MlxArray.fromInt32(new Int32Array(ids), [1, ids.length]),
173
+ );
174
+
175
+ try {
176
+ // Reference logits from the unmodified running model (taken once).
177
+ const refLogits: MlxArray[] = calArrays.map((ids) => trainForward(model, ids));
178
+ ops.evalAll(refLogits);
179
+
180
+ const entries = [...layers.entries()];
181
+ const results: SensitivityResult[] = [];
182
+
183
+ try {
184
+ for (let li = 0; li < entries.length; li++) {
185
+ const [layerName, layer] = entries[li]!;
186
+ const original = captureState(layer);
187
+ const baselineBits = layer.spec.bits;
188
+
189
+ // Self-contained bf16 source: dequantize the layer's own packed weight.
190
+ const bf16Source = ops.dequantize(
191
+ original.w, original.scales, original.biases, original.spec, cpuStream,
192
+ ).astype(Dtype.bfloat16, cpuStream);
193
+ ops.evalAll([bf16Source]);
194
+
195
+ const sensitivities: Record<number, number> = {};
196
+
197
+ for (const bits of candidateBits) {
198
+ if (bits === baselineBits) {
199
+ // KL is 0 by construction (re-quantizing to the baseline bit-width
200
+ // reproduces the running model). Skip the forwards.
201
+ sensitivities[bits] = 0.0;
202
+ continue;
203
+ }
204
+ mutateLayerToBits(layer, bf16Source, bits, groupSize, original);
205
+
206
+ let klSum = 0;
207
+ for (let i = 0; i < calArrays.length; i++) {
208
+ const cur = trainForward(model, calArrays[i]!);
209
+ klSum += klFromRef(cur, refLogits[i]!);
210
+ cur.dispose();
211
+ }
212
+ sensitivities[bits] = klSum / Math.max(calArrays.length, 1);
213
+ }
214
+
215
+ // Restore the original quantized weight; release the bf16 source.
216
+ restoreState(layer, original);
217
+ bf16Source.dispose();
218
+
219
+ // param count = product of the dequantized (full) weight shape.
220
+ const fullShape = [layer.outFeatures, layer.inFeatures];
221
+ const paramCount = fullShape.reduce((a, b) => a * b, 1);
222
+
223
+ results.push({ layerName, sensitivities, paramCount });
224
+ onProgress?.(li + 1, entries.length, layerName);
225
+ }
226
+ } finally {
227
+ for (const r of refLogits) r.dispose();
228
+ }
229
+
230
+ return results;
231
+ } finally {
232
+ for (const a of calArrays) a.dispose();
233
+ }
234
+ }
@@ -0,0 +1,336 @@
1
+ // Model registry: bun:sqlite index over the HF cache. Answers questions
2
+ // like "vision-capable models under 10 GB" without shell archaeology.
3
+ //
4
+ // scan() walks ~/.cache/huggingface/hub/models--*/snapshots/*, reading
5
+ // only config.json + the safetensors index header (never tensor bytes).
6
+
7
+ import { Database } from "bun:sqlite";
8
+ import {
9
+ closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, statSync,
10
+ } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { isDrafterModelType } from "./model/support";
13
+
14
+ export interface ModelRecord {
15
+ path: string;
16
+ repoId: string;
17
+ modelType: string;
18
+ paramCount: number | null;
19
+ /** Language-model weight bytes (model-*.safetensors, sidecar excluded). */
20
+ sizeBytes: number;
21
+ /** optiq_vision.safetensors bytes (bf16 sidecar; loads only for vision). */
22
+ sidecarBytes: number;
23
+ /** Bytes of `.experts.` tensors (MoE; 0 for dense models). Per-token
24
+ * decode only reads top_k/num_experts of these. */
25
+ expertsBytes: number;
26
+ quantBits: number | null;
27
+ quantGroupSize: number | null;
28
+ quantMode: string | null;
29
+ hasVisionSidecar: boolean;
30
+ hasKvConfig: boolean;
31
+ hasToolTemplate: boolean;
32
+ numLayers: number | null;
33
+ hiddenSize: number | null;
34
+ vocabSize: number | null;
35
+ /** SPDX-ish license id from the model card's README frontmatter
36
+ * (e.g. "gemma" — custom terms — vs "apache-2.0"); null if absent.
37
+ * Surfaces license obligations per model in `ls`/`fit` output. */
38
+ license: string | null;
39
+ scannedAt: number;
40
+ }
41
+
42
+ const SCHEMA = `
43
+ CREATE TABLE IF NOT EXISTS models (
44
+ path TEXT PRIMARY KEY,
45
+ repo_id TEXT NOT NULL,
46
+ model_type TEXT NOT NULL,
47
+ param_count INTEGER,
48
+ size_bytes INTEGER NOT NULL,
49
+ sidecar_bytes INTEGER NOT NULL DEFAULT 0,
50
+ experts_bytes INTEGER NOT NULL DEFAULT 0,
51
+ quant_bits INTEGER,
52
+ quant_group_size INTEGER,
53
+ quant_mode TEXT,
54
+ has_vision_sidecar INTEGER NOT NULL,
55
+ has_kv_config INTEGER NOT NULL,
56
+ has_tool_template INTEGER NOT NULL,
57
+ num_layers INTEGER,
58
+ hidden_size INTEGER,
59
+ vocab_size INTEGER,
60
+ license TEXT,
61
+ scanned_at INTEGER NOT NULL
62
+ );
63
+ `;
64
+
65
+ export const DEFAULT_HUB = `${process.env.HOME}/.cache/huggingface/hub`;
66
+ export const DEFAULT_DB = `${process.env.HOME}/.cache/mlx-bun/registry.sqlite`;
67
+
68
+ export class Registry {
69
+ readonly db: Database;
70
+
71
+ constructor(dbPath: string = DEFAULT_DB) {
72
+ if (dbPath !== ":memory:") {
73
+ const dir = dbPath.slice(0, dbPath.lastIndexOf("/"));
74
+ try { require("node:fs").mkdirSync(dir, { recursive: true }); } catch {}
75
+ }
76
+ this.db = new Database(dbPath);
77
+ this.db.exec(SCHEMA);
78
+ // The registry is a derived cache — on schema drift, rebuild from scratch.
79
+ const cols = (this.db.query("PRAGMA table_info(models)").all() as { name: string }[])
80
+ .map((c) => c.name);
81
+ if (
82
+ !cols.includes("sidecar_bytes") ||
83
+ !cols.includes("experts_bytes") ||
84
+ !cols.includes("license")
85
+ ) {
86
+ this.db.exec("DROP TABLE models");
87
+ this.db.exec(SCHEMA);
88
+ }
89
+ }
90
+
91
+ async scan(hubDir: string = DEFAULT_HUB): Promise<number> {
92
+ if (!existsSync(hubDir)) return 0;
93
+ // The cache is the source of truth and we only ever INSERT, so reap rows
94
+ // whose snapshot dir was deleted (else they linger as phantom matches).
95
+ const prune = this.db.prepare("DELETE FROM models WHERE path = $path");
96
+ for (const r of this.db.query("SELECT path FROM models").all() as { path: string }[])
97
+ if (!existsSync(r.path)) prune.run({ $path: r.path });
98
+ let count = 0;
99
+ const upsert = this.db.prepare(`
100
+ INSERT OR REPLACE INTO models VALUES
101
+ ($path, $repo, $type, $params, $size, $sidecar, $experts, $bits, $gs, $mode,
102
+ $vision, $kv, $tools, $layers, $hidden, $vocab, $license, $at)
103
+ `);
104
+ for (const entry of readdirSync(hubDir)) {
105
+ if (!entry.startsWith("models--")) continue;
106
+ const repoId = entry.slice("models--".length).replaceAll("--", "/");
107
+ const snapsDir = join(hubDir, entry, "snapshots");
108
+ if (!existsSync(snapsDir)) continue;
109
+ for (const snap of readdirSync(snapsDir)) {
110
+ const dir = join(snapsDir, snap);
111
+ const rec = await scanSnapshot(dir, repoId);
112
+ if (!rec) continue;
113
+ upsert.run({
114
+ $path: rec.path, $repo: rec.repoId, $type: rec.modelType,
115
+ $params: rec.paramCount, $size: rec.sizeBytes,
116
+ $sidecar: rec.sidecarBytes, $experts: rec.expertsBytes,
117
+ $bits: rec.quantBits, $gs: rec.quantGroupSize, $mode: rec.quantMode,
118
+ $vision: rec.hasVisionSidecar ? 1 : 0,
119
+ $kv: rec.hasKvConfig ? 1 : 0,
120
+ $tools: rec.hasToolTemplate ? 1 : 0,
121
+ $layers: rec.numLayers, $hidden: rec.hiddenSize, $vocab: rec.vocabSize,
122
+ $license: rec.license, $at: rec.scannedAt,
123
+ });
124
+ count++;
125
+ }
126
+ }
127
+ return count;
128
+ }
129
+
130
+ list(filter: { vision?: boolean; maxBytes?: number; query?: string } = {}): ModelRecord[] {
131
+ const clauses: string[] = [];
132
+ const params: Record<string, unknown> = {};
133
+ if (filter.vision !== undefined) {
134
+ clauses.push("has_vision_sidecar = $vision");
135
+ params.$vision = filter.vision ? 1 : 0;
136
+ }
137
+ if (filter.maxBytes !== undefined) {
138
+ clauses.push("size_bytes <= $max");
139
+ params.$max = filter.maxBytes;
140
+ }
141
+ if (filter.query) {
142
+ clauses.push("(repo_id LIKE $q OR model_type LIKE $q)");
143
+ params.$q = `%${filter.query}%`;
144
+ }
145
+ const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
146
+ const rows = this.db
147
+ .query(`SELECT * FROM models ${where} ORDER BY size_bytes ASC`)
148
+ .all(params as never) as Record<string, unknown>[];
149
+ // Self-heal: never surface a row whose snapshot dir was deleted out from
150
+ // under us (scan() reaps these from the DB; this keeps reads correct even
151
+ // before the next scan).
152
+ return rows.map(rowToRecord).filter((r) => existsSync(r.path));
153
+ }
154
+
155
+ /** Resolve a fuzzy query to exactly one model (error listing candidates otherwise).
156
+ * Speculative-decoding drafters are companion artifacts, never selectable on
157
+ * their own, so they never count as candidates here. */
158
+ resolve(query: string): ModelRecord {
159
+ const matches = this.list({ query }).filter((m) => !isDrafterModelType(m.modelType));
160
+ if (matches.length === 0) throw new Error(`no model matching "${query}" — run \`mlx-bun scan\``);
161
+ // The HF cache can hold several revisions of one repo (snapshots/<hash>
162
+ // dirs), each a registry row. Resolving a repo name must not be "ambiguous"
163
+ // just because a stale revision lingers — collapse same-repo matches to the
164
+ // canonical snapshot. Genuinely different repos stay ambiguous.
165
+ const repos = [...new Set(matches.map((m) => m.repoId))];
166
+ if (repos.length === 1) return pickCanonicalRevision(matches);
167
+ throw new Error(`"${query}" is ambiguous:\n` + repos.map((r) => ` ${r}`).join("\n"));
168
+ }
169
+
170
+ close(): void {
171
+ this.db.close();
172
+ }
173
+ }
174
+
175
+ /** Of several cached revisions of ONE repo, pick the canonical snapshot: the
176
+ * revision refs/main points at, else the most recently scanned (stable
177
+ * tie-break on path). Paths are `<hub>/models--<repo>/snapshots/<hash>`. */
178
+ function pickCanonicalRevision(matches: ModelRecord[]): ModelRecord {
179
+ if (matches.length === 1) return matches[0]!;
180
+ const root = matches[0]!.path.split("/snapshots/")[0]!;
181
+ try {
182
+ const head = readFileSync(join(root, "refs", "main"), "utf8").trim();
183
+ const onMain = matches.find((m) => m.path.includes(`/snapshots/${head}`));
184
+ if (onMain) return onMain;
185
+ } catch {
186
+ /* no refs/main — fall through to recency */
187
+ }
188
+ return [...matches].sort(
189
+ (a, b) => b.scannedAt - a.scannedAt || (a.path < b.path ? -1 : 1),
190
+ )[0]!;
191
+ }
192
+
193
+ function rowToRecord(r: Record<string, unknown>): ModelRecord {
194
+ return {
195
+ path: r.path as string,
196
+ repoId: r.repo_id as string,
197
+ modelType: r.model_type as string,
198
+ paramCount: r.param_count as number | null,
199
+ sizeBytes: r.size_bytes as number,
200
+ sidecarBytes: r.sidecar_bytes as number,
201
+ expertsBytes: r.experts_bytes as number,
202
+ quantBits: r.quant_bits as number | null,
203
+ quantGroupSize: r.quant_group_size as number | null,
204
+ quantMode: r.quant_mode as string | null,
205
+ hasVisionSidecar: !!r.has_vision_sidecar,
206
+ hasKvConfig: !!r.has_kv_config,
207
+ hasToolTemplate: !!r.has_tool_template,
208
+ numLayers: r.num_layers as number | null,
209
+ hiddenSize: r.hidden_size as number | null,
210
+ vocabSize: r.vocab_size as number | null,
211
+ license: r.license as string | null,
212
+ scannedAt: r.scanned_at as number,
213
+ };
214
+ }
215
+
216
+ /** License id from the model card's README.md YAML frontmatter
217
+ * (`license:`; `license_name:` wins when license is "other"). */
218
+ function readmeLicense(dir: string): string | null {
219
+ const p = join(dir, "README.md");
220
+ if (!existsSync(p)) return null;
221
+ try {
222
+ const head = readFileSync(p, "utf8").slice(0, 4096);
223
+ if (!head.startsWith("---")) return null;
224
+ const end = head.indexOf("\n---", 3);
225
+ const fm = end === -1 ? head : head.slice(0, end);
226
+ const strip = (s: string) => s.trim().replace(/^['"]|['"]$/g, "");
227
+ const lic = strip(/^license:\s*(.+)$/m.exec(fm)?.[1] ?? "");
228
+ const name = /^license_name:\s*(.+)$/m.exec(fm)?.[1];
229
+ if (lic === "other" && name) return strip(name);
230
+ return lic || null;
231
+ } catch {
232
+ return null;
233
+ }
234
+ }
235
+
236
+ /** Sum the byte sizes of `.experts.` tensors from a safetensors header
237
+ * (header JSON only — never touches tensor data). */
238
+ function expertTensorBytes(path: string): number {
239
+ const fd = openSync(path, "r");
240
+ try {
241
+ const lenBuf = Buffer.alloc(8);
242
+ readSync(fd, lenBuf, 0, 8, 0);
243
+ const headerLen = Number(lenBuf.readBigUInt64LE(0));
244
+ if (headerLen <= 0 || headerLen > 256 * 2 ** 20) return 0;
245
+ const headerBuf = Buffer.alloc(headerLen);
246
+ readSync(fd, headerBuf, 0, headerLen, 8);
247
+ const header = JSON.parse(headerBuf.toString("utf8")) as Record<
248
+ string, { data_offsets?: [number, number] }
249
+ >;
250
+ let bytes = 0;
251
+ for (const [name, entry] of Object.entries(header)) {
252
+ if (!name.includes(".experts.") || !entry.data_offsets) continue;
253
+ bytes += entry.data_offsets[1] - entry.data_offsets[0];
254
+ }
255
+ return bytes;
256
+ } finally {
257
+ closeSync(fd);
258
+ }
259
+ }
260
+
261
+ async function scanSnapshot(dir: string, repoId: string): Promise<ModelRecord | null> {
262
+ const configPath = join(dir, "config.json");
263
+ if (!existsSync(configPath)) return null;
264
+
265
+ // size: sum of model safetensors (resolved through HF's blob symlinks).
266
+ // The vision sidecar is its own line item — it loads only for vision
267
+ // requests and must never be folded into language-weight fit math.
268
+ let sizeBytes = 0;
269
+ let sidecarBytes = 0;
270
+ let expertsBytes = 0;
271
+ let hasWeights = false;
272
+ for (const f of readdirSync(dir)) {
273
+ if (!f.endsWith(".safetensors")) continue;
274
+ hasWeights = true;
275
+ let bytes = 0;
276
+ try { bytes = statSync(join(dir, f)).size; } catch {}
277
+ if (f === "optiq_vision.safetensors") {
278
+ sidecarBytes += bytes;
279
+ } else {
280
+ sizeBytes += bytes;
281
+ try { expertsBytes += expertTensorBytes(join(dir, f)); } catch {}
282
+ }
283
+ }
284
+ if (!hasWeights) return null;
285
+
286
+ let config: Record<string, any>;
287
+ try {
288
+ config = (await Bun.file(configPath).json()) as Record<string, any>;
289
+ } catch {
290
+ return null;
291
+ }
292
+ const text = (config.text_config ?? config) as Record<string, any>;
293
+ const quant = (config.quantization ?? config.quantization_config) as
294
+ | Record<string, any> | undefined;
295
+
296
+ let paramCount: number | null = null;
297
+ const idxPath = join(dir, "model.safetensors.index.json");
298
+ if (existsSync(idxPath)) {
299
+ try {
300
+ const idx = (await Bun.file(idxPath).json()) as any;
301
+ paramCount = idx.metadata?.total_parameters ?? null;
302
+ } catch {}
303
+ }
304
+
305
+ let hasToolTemplate = false;
306
+ for (const tf of ["chat_template.jinja", "tokenizer_config.json"]) {
307
+ const p = join(dir, tf);
308
+ if (existsSync(p)) {
309
+ try {
310
+ const body = await Bun.file(p).text();
311
+ if (body.includes("tool_call")) { hasToolTemplate = true; break; }
312
+ } catch {}
313
+ }
314
+ }
315
+
316
+ return {
317
+ path: dir,
318
+ repoId,
319
+ modelType: (config.model_type as string) ?? "unknown",
320
+ paramCount,
321
+ sidecarBytes,
322
+ expertsBytes,
323
+ sizeBytes,
324
+ quantBits: quant?.bits ?? null,
325
+ quantGroupSize: quant?.group_size ?? null,
326
+ quantMode: quant?.mode ?? (quant ? "affine" : null),
327
+ hasVisionSidecar: existsSync(join(dir, "optiq_vision.safetensors")),
328
+ hasKvConfig: existsSync(join(dir, "kv_config.json")),
329
+ hasToolTemplate,
330
+ numLayers: text.num_hidden_layers ?? null,
331
+ hiddenSize: text.hidden_size ?? null,
332
+ vocabSize: text.vocab_size ?? null,
333
+ license: readmeLicense(dir),
334
+ scannedAt: Date.now(),
335
+ };
336
+ }