pictor-image 0.1.0

FLUX.2 DiT (bonsai-image) GGUF weight loader and configuration for Pictor
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
//! Pure-Rust MLX 4-bit (packed-affine) weight loader for the Qwen3-4B text
//! encoder.
//!
//! This loads the **native 4-bit safetensors** export
//! (`text_encoder-mlx-4bit/model.safetensors`, ~2.1 GB) directly, instead of
//! the ~15 GB dequantised f32 `.npy` dump. It restores Bonsai's "small model"
//! property end-to-end: the on-disk / distribution footprint drops from 15 GB
//! to 2.1 GB while the forward pass is byte-for-byte the f32 numerics it always
//! was (each linear is dequantised on first `get` and then cached).
//!
//! # Format (MLX `bits=4`, `group_size=64`, LSB-first along the input dim)
//!
//! A quantized linear `[out, in]` is stored as a triple in the safetensors:
//!
//! ```text
//! <name>.weight   U32  [out, in/8]    8 nibbles per u32, LSB-first along `in`
//! <name>.scales   BF16 [out, in/64]   per-group affine scale
//! <name>.biases   BF16 [out, in/64]   per-group affine bias
//! ```
//!
//! Dequant (computed in f32), per output row `o` and input index `i`:
//!
//! ```text
//! code = (weight[o*(in/8) + i/8] >> (4 * (i % 8))) & 0xF   // unsigned 0..15
//! g    = i / 64
//! w[o*in + i] = code * bf16(scales[o*(in/64) + g]) + bf16(biases[o*(in/64) + g])
//! ```
//!
//! This is the standard MLX affine form (`code * scale + bias`), validated to
//! byte-match `mx.dequantize(...)` against the f32 `.npy` oracle (see
//! `examples/te_4bit_check.rs`).
//!
//! Plain (non-quantized) tensors — the RMSNorm vectors — are stored as a single
//! BF16 `<name>.weight` (no `.scales` / `.biases`) and decoded bf16→f32 to a
//! `[len]` vector.
//!
//! # Ownership model
//!
//! [`Mlx4bitModel`] owns the [`memmap2::Mmap`] for the file's lifetime and
//! re-runs the (cheap, header-only) `SafeTensors::deserialize` on each
//! `load_tensor` access. This sidesteps the self-referential lifetime that a
//! stored `SafeTensors<'a>` borrowing the owned `Mmap` would create, at the
//! negligible cost of re-parsing the JSON header per loaded tensor (there are
//! ~253 quantized + ~37 plain tensors total, each loaded once and then cached
//! by [`crate::te::weights::TeWeights`]). The mmap itself is never copied.

use std::path::{Path, PathBuf};

use memmap2::Mmap;
use safetensors::{Dtype, SafeTensors};

use crate::te::error::{TeError, TeResult};
use crate::te::weights::Tensor;

/// MLX quantization group size (input features per scale/bias).
const GROUP_SIZE: usize = 64;

/// Number of 4-bit nibbles packed into one `u32` weight word.
const NIBBLES_PER_U32: usize = 8;

/// Safetensors suffix for the packed-weight sub-tensor of a quantized module.
const SUFFIX_WEIGHT: &str = ".weight";
/// Safetensors suffix for the per-group scale sub-tensor.
const SUFFIX_SCALES: &str = ".scales";
/// Safetensors suffix for the per-group bias sub-tensor.
const SUFFIX_BIASES: &str = ".biases";

/// Reinterpret a bfloat16 bit pattern as `f32`.
///
/// bfloat16 is the top 16 bits of an IEEE-754 `f32`, so the conversion is an
/// exact left-shift by 16 with no rounding.
#[inline]
pub fn bf16_to_f32(bits: u16) -> f32 {
    f32::from_bits((bits as u32) << 16)
}

/// Dequantize one MLX 4-bit packed-affine linear into a row-major `[out * in]`
/// f32 buffer.
///
/// `weight` is the U32 packed buffer (`out * in/8` words, 8 LSB-first nibbles
/// each); `scales` / `biases` are the BF16 bit patterns (`out * in/64` each).
/// Each input feature `i` of output row `o` is dequantized as
/// `code * scale[group] + bias[group]` with `code ∈ 0..15` unsigned and
/// `group = i / 64`.
///
/// # Errors
///
/// [`TeError::Shape`] if `in_features` is not a positive multiple of 64, or if
/// any of the three buffers does not have exactly the length implied by
/// `(out_features, in_features)`.
pub fn dequantize_mlx_4bit_affine(
    weight: &[u32],
    scales: &[u16],
    biases: &[u16],
    out_features: usize,
    in_features: usize,
) -> TeResult<Vec<f32>> {
    if in_features == 0 || in_features % GROUP_SIZE != 0 {
        return Err(TeError::Shape(format!(
            "mlx4bit dequant: in_features ({in_features}) must be a positive multiple of {GROUP_SIZE}"
        )));
    }

    let weight_cols = in_features / NIBBLES_PER_U32; // in/8
    let group_cols = in_features / GROUP_SIZE; // in/64

    let expect_weight = out_features * weight_cols;
    if weight.len() != expect_weight {
        return Err(TeError::Shape(format!(
            "mlx4bit dequant: weight len {} != expected {expect_weight} (out={out_features}, in/8={weight_cols})",
            weight.len()
        )));
    }
    let expect_groups = out_features * group_cols;
    if scales.len() != expect_groups {
        return Err(TeError::Shape(format!(
            "mlx4bit dequant: scales len {} != expected {expect_groups} (out={out_features}, in/64={group_cols})",
            scales.len()
        )));
    }
    if biases.len() != expect_groups {
        return Err(TeError::Shape(format!(
            "mlx4bit dequant: biases len {} != expected {expect_groups} (out={out_features}, in/64={group_cols})",
            biases.len()
        )));
    }

    let mut out = vec![0.0f32; out_features * in_features];

    // Dequantize one output row `o` into `row` (`in_features` long). Each row is
    // independent, so this is the unit of parallelism below. Walk the row in
    // 8-nibble words; the group (and thus scale/bias) is constant for every span
    // of 64 input features = 8 words.
    let dequant_row = |o: usize, row: &mut [f32]| {
        let weight_row = o * weight_cols;
        let group_row = o * group_cols;
        for col in 0..weight_cols {
            let word = weight[weight_row + col];
            let i_base = col * NIBBLES_PER_U32;
            let group = i_base / GROUP_SIZE;
            let scale = bf16_to_f32(scales[group_row + group]);
            let bias = bf16_to_f32(biases[group_row + group]);
            for nibble in 0..NIBBLES_PER_U32 {
                let code = ((word >> (4 * nibble)) & 0xF) as f32;
                row[i_base + nibble] = code * scale + bias;
            }
        }
    };

    // This dominates the GPU-TE forward (the no-cache 4-bit source re-dequantises
    // every Linear weight per pass). Rows are independent → split across CPUs via
    // scoped threads (bit-identical to the serial loop; `wasm`-safe — no rayon).
    let threads = std::thread::available_parallelism()
        .map(|t| t.get())
        .unwrap_or(1)
        .min(out_features.max(1));
    if threads <= 1 || out_features < 4 {
        for (o, row) in out.chunks_mut(in_features).enumerate() {
            dequant_row(o, row);
        }
    } else {
        let rows_per = out_features.div_ceil(threads);
        let dequant_ref = &dequant_row;
        std::thread::scope(|scope| {
            let mut base = 0usize;
            for block in out.chunks_mut(rows_per * in_features) {
                let start = base;
                base += block.len() / in_features;
                scope.spawn(move || {
                    for (r, row) in block.chunks_mut(in_features).enumerate() {
                        dequant_ref(start + r, row);
                    }
                });
            }
        });
    }

    Ok(out)
}

/// A memory-mapped MLX 4-bit text-encoder safetensors file.
///
/// Owns the [`Mmap`] and re-parses the safetensors header per access (see the
/// module docs for the ownership rationale). Tensor names are mapped from the
/// loader's dotted `base` (e.g. `layers.0.self_attn.q_proj`) to the
/// `model.`-prefixed safetensors name (e.g. `model.layers.0.self_attn.q_proj`).
pub struct Mlx4bitModel {
    path: PathBuf,
    mmap: Mmap,
}

impl Mlx4bitModel {
    /// Open and memory-map a 4-bit `model.safetensors` file, validating that it
    /// parses as a safetensors container.
    ///
    /// # Errors
    ///
    /// [`TeError::Io`] if the file cannot be opened or mapped; [`TeError::Npy`]
    /// (reused as the container-parse error variant) if the safetensors header
    /// is malformed.
    pub fn open(path: &Path) -> TeResult<Self> {
        let file = std::fs::File::open(path).map_err(|source| TeError::Io {
            path: path.display().to_string(),
            source,
        })?;
        // SAFETY: read-only mapping of a file we do not mutate while mapped —
        // the standard safetensors model-loading pattern (mirrors the DiT
        // converter's `convert::mlx_image` path).
        let mmap = unsafe { Mmap::map(&file) }.map_err(|source| TeError::Io {
            path: path.display().to_string(),
            source,
        })?;
        // Validate the header parses up front so a corrupt file fails at open.
        SafeTensors::deserialize(&mmap).map_err(|e| TeError::Npy {
            path: path.display().to_string(),
            reason: format!("safetensors header: {e}"),
        })?;
        Ok(Self {
            path: path.to_path_buf(),
            mmap,
        })
    }

    /// Re-parse the safetensors header over the owned mmap.
    fn view(&self) -> TeResult<SafeTensors<'_>> {
        SafeTensors::deserialize(&self.mmap).map_err(|e| TeError::Npy {
            path: self.path.display().to_string(),
            reason: format!("safetensors header: {e}"),
        })
    }

    /// Load a tensor by the loader's dotted `base` name.
    ///
    /// If `model.{base}.scales` exists → dequantize the U32/BF16 quantized
    /// triple to a `[out, in]` f32 [`Tensor`]; otherwise decode the plain BF16
    /// `model.{base}.weight` to a `[len]` f32 [`Tensor`].
    ///
    /// # Errors
    ///
    /// [`TeError::MissingWeight`] if neither form is present; [`TeError::Npy`]
    /// on a header/dtype problem; [`TeError::Shape`] on a buffer-length or
    /// rank mismatch.
    pub fn load_tensor(&self, base: &str) -> TeResult<Tensor> {
        let st = self.view()?;
        let prefixed = format!("model.{base}");
        let weight_name = format!("{prefixed}{SUFFIX_WEIGHT}");

        // Quantized iff `.scales` (and `.biases`) accompany a U32 `.weight`.
        let scales_name = format!("{prefixed}{SUFFIX_SCALES}");
        let has_scales = st.tensor(&scales_name).is_ok();

        if has_scales {
            return self.load_quantized(&st, base, &prefixed);
        }

        // Plain BF16 vector (RMSNorm). Missing → the tensor truly is absent.
        if st.tensor(&weight_name).is_err() {
            return Err(TeError::MissingWeight {
                name: base.to_string(),
            });
        }
        self.load_plain_bf16(&st, base, &weight_name)
    }

    /// Dequantize the `model.{base}.{weight,scales,biases}` quantized triple.
    fn load_quantized(&self, st: &SafeTensors<'_>, base: &str, prefixed: &str) -> TeResult<Tensor> {
        let weight_name = format!("{prefixed}{SUFFIX_WEIGHT}");
        let scales_name = format!("{prefixed}{SUFFIX_SCALES}");
        let biases_name = format!("{prefixed}{SUFFIX_BIASES}");

        let w_view = st.tensor(&weight_name).map_err(|e| self.parse_err(&e))?;
        let s_view = st.tensor(&scales_name).map_err(|e| self.parse_err(&e))?;
        let b_view = st.tensor(&biases_name).map_err(|e| self.parse_err(&e))?;

        if w_view.dtype() != Dtype::U32 {
            return Err(TeError::Npy {
                path: self.path.display().to_string(),
                reason: format!("{base}.weight dtype {:?}, expected U32", w_view.dtype()),
            });
        }
        if s_view.dtype() != Dtype::BF16 {
            return Err(TeError::Npy {
                path: self.path.display().to_string(),
                reason: format!("{base}.scales dtype {:?}, expected BF16", s_view.dtype()),
            });
        }
        if b_view.dtype() != Dtype::BF16 {
            return Err(TeError::Npy {
                path: self.path.display().to_string(),
                reason: format!("{base}.biases dtype {:?}, expected BF16", b_view.dtype()),
            });
        }

        // weight [out, in/8]; scales/biases [out, in/64].
        let w_shape = w_view.shape();
        if w_shape.len() != 2 {
            return Err(TeError::Shape(format!(
                "{base}.weight rank {}, expected 2",
                w_shape.len()
            )));
        }
        let out_features = w_shape[0];
        let in_features = w_shape[1] * NIBBLES_PER_U32;

        let weight = u32_from_le_bytes(w_view.data());
        let scales = u16_from_le_bytes(s_view.data());
        let biases = u16_from_le_bytes(b_view.data());

        let data =
            dequantize_mlx_4bit_affine(&weight, &scales, &biases, out_features, in_features)?;

        Ok(Tensor {
            data,
            shape: vec![out_features, in_features],
        })
    }

    /// Dequantize **only** the listed `rows` of the quantized tensor
    /// `model.{base}` (shape `[out, cols]`) into a flat `[rows.len() * cols]`
    /// f32 buffer, in the rows' given order.
    ///
    /// This is the RAM-frugal embedding gather: instead of dequantizing the
    /// whole `[vocab, hidden]` table (1.5 GB f32 for Qwen3-4B) just to keep a
    /// handful of token rows, it touches only each requested row's
    /// `cols / 8` packed `u32` words and `cols / 64` scale/bias groups.
    ///
    /// Per row it applies the **identical** `code * scale + bias` affine form as
    /// [`dequantize_mlx_4bit_affine`] (same LSB-first nibble order, same
    /// `group = i / 64`, same bf16→f32 of scale/bias), so for any row `r` the
    /// produced `cols` values are byte-for-byte equal to that row's slice of a
    /// full dequant.
    ///
    /// # Errors
    ///
    /// [`TeError::MissingWeight`] if `model.{base}` is not a quantized triple;
    /// [`TeError::Npy`] on a header/dtype problem; [`TeError::Shape`] if `cols`
    /// is not a positive multiple of 64, if the on-disk `in_features` disagrees
    /// with `cols`, or if any requested row index is `>= out_features`.
    pub fn gather_quant_rows(&self, base: &str, rows: &[usize], cols: usize) -> TeResult<Vec<f32>> {
        if cols == 0 || cols % GROUP_SIZE != 0 {
            return Err(TeError::Shape(format!(
                "mlx4bit gather: cols ({cols}) must be a positive multiple of {GROUP_SIZE}"
            )));
        }

        let st = self.view()?;
        let prefixed = format!("model.{base}");
        let weight_name = format!("{prefixed}{SUFFIX_WEIGHT}");
        let scales_name = format!("{prefixed}{SUFFIX_SCALES}");
        let biases_name = format!("{prefixed}{SUFFIX_BIASES}");

        // The gather only makes sense for the quantized form; a plain BF16
        // vector has no `.scales`, so treat its absence as "no such quantized
        // tensor" (the f32-path fallback in `TeWeights` covers any other case).
        if st.tensor(&scales_name).is_err() {
            return Err(TeError::MissingWeight {
                name: base.to_string(),
            });
        }

        let w_view = st.tensor(&weight_name).map_err(|e| self.parse_err(&e))?;
        let s_view = st.tensor(&scales_name).map_err(|e| self.parse_err(&e))?;
        let b_view = st.tensor(&biases_name).map_err(|e| self.parse_err(&e))?;

        if w_view.dtype() != Dtype::U32 {
            return Err(TeError::Npy {
                path: self.path.display().to_string(),
                reason: format!("{base}.weight dtype {:?}, expected U32", w_view.dtype()),
            });
        }
        if s_view.dtype() != Dtype::BF16 {
            return Err(TeError::Npy {
                path: self.path.display().to_string(),
                reason: format!("{base}.scales dtype {:?}, expected BF16", s_view.dtype()),
            });
        }
        if b_view.dtype() != Dtype::BF16 {
            return Err(TeError::Npy {
                path: self.path.display().to_string(),
                reason: format!("{base}.biases dtype {:?}, expected BF16", b_view.dtype()),
            });
        }

        let w_shape = w_view.shape();
        if w_shape.len() != 2 {
            return Err(TeError::Shape(format!(
                "{base}.weight rank {}, expected 2",
                w_shape.len()
            )));
        }
        let out_features = w_shape[0];
        let in_features = w_shape[1] * NIBBLES_PER_U32;
        if in_features != cols {
            return Err(TeError::Shape(format!(
                "{base}: on-disk in_features {in_features} != requested cols {cols}"
            )));
        }

        let weight = u32_from_le_bytes(w_view.data());
        let scales = u16_from_le_bytes(s_view.data());
        let biases = u16_from_le_bytes(b_view.data());

        let weight_cols = cols / NIBBLES_PER_U32; // cols/8
        let group_cols = cols / GROUP_SIZE; // cols/64
        if weight.len() != out_features * weight_cols {
            return Err(TeError::Shape(format!(
                "{base}.weight len {} != expected {} (out={out_features}, cols/8={weight_cols})",
                weight.len(),
                out_features * weight_cols
            )));
        }
        if scales.len() != out_features * group_cols || biases.len() != out_features * group_cols {
            return Err(TeError::Shape(format!(
                "{base}.scales/biases len {}/{} != expected {} (out={out_features}, cols/64={group_cols})",
                scales.len(),
                biases.len(),
                out_features * group_cols
            )));
        }

        let mut out = vec![0.0f32; rows.len() * cols];
        for (dst_row, &r) in rows.iter().enumerate() {
            if r >= out_features {
                return Err(TeError::Shape(format!(
                    "{base}: gather row {r} >= out_features {out_features}"
                )));
            }
            let weight_row = r * weight_cols;
            let group_row = r * group_cols;
            let out_base = dst_row * cols;
            // Identical traversal to `dequantize_mlx_4bit_affine`'s inner loop:
            // walk 8-nibble words, scale/bias constant per 64-input group.
            for col in 0..weight_cols {
                let word = weight[weight_row + col];
                let i_base = col * NIBBLES_PER_U32;
                let group = i_base / GROUP_SIZE;
                let scale = bf16_to_f32(scales[group_row + group]);
                let bias = bf16_to_f32(biases[group_row + group]);
                let dst = out_base + i_base;
                for nibble in 0..NIBBLES_PER_U32 {
                    let code = ((word >> (4 * nibble)) & 0xF) as f32;
                    out[dst + nibble] = code * scale + bias;
                }
            }
        }
        Ok(out)
    }

    /// Decode a plain BF16 `[len]` vector (an RMSNorm weight).
    fn load_plain_bf16(
        &self,
        st: &SafeTensors<'_>,
        base: &str,
        weight_name: &str,
    ) -> TeResult<Tensor> {
        let view = st.tensor(weight_name).map_err(|e| self.parse_err(&e))?;
        if view.dtype() != Dtype::BF16 {
            return Err(TeError::Npy {
                path: self.path.display().to_string(),
                reason: format!(
                    "{base}.weight (plain) dtype {:?}, expected BF16",
                    view.dtype()
                ),
            });
        }
        let bits = u16_from_le_bytes(view.data());
        let numel: usize = view.shape().iter().product();
        if bits.len() < numel {
            return Err(TeError::Shape(format!(
                "{base}.weight: bf16 payload short ({} < {numel})",
                bits.len()
            )));
        }
        let data: Vec<f32> = bits[..numel].iter().map(|&b| bf16_to_f32(b)).collect();
        Ok(Tensor {
            data,
            shape: view.shape().to_vec(),
        })
    }

    /// Map a safetensors error into the TE parse-error variant.
    fn parse_err(&self, e: &safetensors::SafeTensorError) -> TeError {
        TeError::Npy {
            path: self.path.display().to_string(),
            reason: e.to_string(),
        }
    }
}

/// Decode a little-endian `u32` buffer from raw bytes (a trailing partial word
/// is ignored; callers validate the element count against the expected shape).
fn u32_from_le_bytes(bytes: &[u8]) -> Vec<u32> {
    bytes
        .chunks_exact(4)
        .map(|c| u32::from_le_bytes([c[0], c[1], c[2], c[3]]))
        .collect()
}

/// Decode a little-endian `u16` (bf16 bit-pattern) buffer from raw bytes.
fn u16_from_le_bytes(bytes: &[u8]) -> Vec<u16> {
    bytes
        .chunks_exact(2)
        .map(|c| u16::from_le_bytes([c[0], c[1]]))
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Round-trip `f32` → bf16 bits via `half`, for synthetic scale/bias buffers.
    fn f32_to_bf16(value: f32) -> u16 {
        half::bf16::from_f32(value).to_bits()
    }

    /// Pack 8 nibble codes (LSB-first) into one `u32` word.
    fn pack_word(codes: &[u8; 8]) -> u32 {
        let mut w = 0u32;
        for (n, &c) in codes.iter().enumerate() {
            w |= ((c & 0xF) as u32) << (4 * n);
        }
        w
    }

    #[test]
    fn bf16_to_f32_exact_for_representable() {
        for &v in &[0.0f32, 1.0, -1.0, 0.5, -0.0625, 2000.0, 0.125] {
            assert_eq!(bf16_to_f32(f32_to_bf16(v)), v, "value {v}");
        }
    }

    #[test]
    fn single_group_affine_matches_formula() {
        // One row, in=64 → 8 words, 1 group. code in 0..15 cycling.
        let in_features = 64usize;
        let scale = 0.125f32; // bf16-exact
        let bias = -1.0f32; // bf16-exact
        let mut weight = Vec::new();
        let mut expected = Vec::new();
        for w in 0..(in_features / 8) {
            let mut codes = [0u8; 8];
            for (n, c) in codes.iter_mut().enumerate() {
                let code = ((w * 8 + n) % 16) as u8;
                *c = code;
                expected.push(code as f32 * scale + bias);
            }
            weight.push(pack_word(&codes));
        }
        let scales = vec![f32_to_bf16(scale)];
        let biases = vec![f32_to_bf16(bias)];

        let out =
            dequantize_mlx_4bit_affine(&weight, &scales, &biases, 1, in_features).expect("dequant");
        assert_eq!(out, expected);
    }

    #[test]
    fn multi_row_multi_group_distinct_scales() {
        // out=3, in=128 → 16 words/row, 2 groups/row.
        let out = 3usize;
        let in_features = 128usize;
        let words_per_row = in_features / 8; // 16
        let groups_per_row = in_features / 64; // 2

        let mut weight = vec![0u32; out * words_per_row];
        let mut scales = vec![0u16; out * groups_per_row];
        let mut biases = vec![0u16; out * groups_per_row];
        let mut expected = vec![0.0f32; out * in_features];

        for o in 0..out {
            for g in 0..groups_per_row {
                let scale = 1.0f32 / ((1 << (o + g + 1)) as f32); // bf16-exact
                let bias = -(1.0f32 / ((1 << (o + 1)) as f32)); // bf16-exact
                scales[o * groups_per_row + g] = f32_to_bf16(scale);
                biases[o * groups_per_row + g] = f32_to_bf16(bias);
                for wlocal in 0..(64 / 8) {
                    let w_idx = g * (64 / 8) + wlocal;
                    let mut codes = [0u8; 8];
                    for (n, c) in codes.iter_mut().enumerate() {
                        let i = g * 64 + wlocal * 8 + n;
                        let code = ((o + i) % 16) as u8;
                        *c = code;
                        expected[o * in_features + i] = code as f32 * scale + bias;
                    }
                    weight[o * words_per_row + w_idx] = pack_word(&codes);
                }
            }
        }

        let out_buf = dequantize_mlx_4bit_affine(&weight, &scales, &biases, out, in_features)
            .expect("dequant");
        assert_eq!(out_buf, expected);
    }

    #[test]
    fn nibble_order_is_lsb_first() {
        // A word with distinct nibbles 0..7 must map left-to-right to inputs
        // 0..7 (LSB-first along the input dim).
        let codes = [0u8, 1, 2, 3, 4, 5, 6, 7];
        let weight = vec![pack_word(&codes)];
        let scales = vec![f32_to_bf16(1.0)];
        let biases = vec![f32_to_bf16(0.0)];
        // in=64 needs 8 words; pad the rest with zero words (codes all 0).
        let mut full = weight;
        full.extend(std::iter::repeat_n(0u32, 7));
        let out = dequantize_mlx_4bit_affine(&full, &scales, &biases, 1, 64).expect("dequant");
        for (i, &c) in codes.iter().enumerate() {
            assert_eq!(out[i], c as f32, "input {i}");
        }
    }

    #[test]
    fn rejects_unaligned_in_features() {
        let err = dequantize_mlx_4bit_affine(&[0u32; 1], &[0u16; 1], &[0u16; 1], 1, 60)
            .expect_err("in not multiple of 64 must error");
        assert!(matches!(err, TeError::Shape(_)));
    }

    #[test]
    fn rejects_wrong_weight_len() {
        // in=64 → 8 words/row expected; give 4.
        let err = dequantize_mlx_4bit_affine(&[0u32; 4], &[0u16; 1], &[0u16; 1], 1, 64)
            .expect_err("short weight must error");
        assert!(matches!(err, TeError::Shape(_)));
    }

    /// A raw-bytes [`safetensors::View`] so a test can serialize synthetic
    /// quantized tensors into an in-memory safetensors container.
    struct RawTensor {
        dtype: Dtype,
        shape: Vec<usize>,
        bytes: Vec<u8>,
    }

    impl safetensors::View for RawTensor {
        fn dtype(&self) -> Dtype {
            self.dtype
        }
        fn shape(&self) -> &[usize] {
            &self.shape
        }
        fn data(&self) -> std::borrow::Cow<'_, [u8]> {
            std::borrow::Cow::Borrowed(&self.bytes)
        }
        fn data_len(&self) -> usize {
            self.bytes.len()
        }
    }

    fn u32_le_bytes(words: &[u32]) -> Vec<u8> {
        let mut b = Vec::with_capacity(words.len() * 4);
        for &w in words {
            b.extend_from_slice(&w.to_le_bytes());
        }
        b
    }

    fn u16_le_bytes(words: &[u16]) -> Vec<u8> {
        let mut b = Vec::with_capacity(words.len() * 2);
        for &w in words {
            b.extend_from_slice(&w.to_le_bytes());
        }
        b
    }

    /// `gather_quant_rows` must reproduce, per row, byte-identical values to a
    /// full `dequantize_mlx_4bit_affine` of the same quantized tensor.
    #[test]
    fn gather_rows_bit_equal_to_full_dequant() {
        // out=5, cols=128 → 16 words/row, 2 groups/row.
        let out = 5usize;
        let cols = 128usize;
        let words_per_row = cols / NIBBLES_PER_U32; // 16
        let groups_per_row = cols / GROUP_SIZE; // 2

        let mut weight = vec![0u32; out * words_per_row];
        let mut scales = vec![0u16; out * groups_per_row];
        let mut biases = vec![0u16; out * groups_per_row];

        // Distinct (bf16-exact) scale/bias per (row, group); pseudo-random codes.
        for o in 0..out {
            for g in 0..groups_per_row {
                let scale = 1.0f32 / ((1 << (o + g + 1)) as f32);
                let bias = -(1.0f32 / ((1 << (o + 1)) as f32));
                scales[o * groups_per_row + g] = f32_to_bf16(scale);
                biases[o * groups_per_row + g] = f32_to_bf16(bias);
            }
            for col in 0..words_per_row {
                let mut codes = [0u8; 8];
                for (n, c) in codes.iter_mut().enumerate() {
                    *c = (((o * 31 + col * 7 + n * 3) ^ (o + col)) % 16) as u8;
                }
                weight[o * words_per_row + col] = pack_word(&codes);
            }
        }

        let full =
            dequantize_mlx_4bit_affine(&weight, &scales, &biases, out, cols).expect("full dequant");

        // Serialize an in-memory safetensors with the `model.{base}.*` names the
        // loader expects, write to a temp file, and open it.
        let base = "layers.0.self_attn.q_proj";
        let prefixed = format!("model.{base}");
        let tensors = vec![
            (
                format!("{prefixed}{SUFFIX_WEIGHT}"),
                RawTensor {
                    dtype: Dtype::U32,
                    shape: vec![out, words_per_row],
                    bytes: u32_le_bytes(&weight),
                },
            ),
            (
                format!("{prefixed}{SUFFIX_SCALES}"),
                RawTensor {
                    dtype: Dtype::BF16,
                    shape: vec![out, groups_per_row],
                    bytes: u16_le_bytes(&scales),
                },
            ),
            (
                format!("{prefixed}{SUFFIX_BIASES}"),
                RawTensor {
                    dtype: Dtype::BF16,
                    shape: vec![out, groups_per_row],
                    bytes: u16_le_bytes(&biases),
                },
            ),
        ];
        let blob = safetensors::serialize(tensors, None).expect("serialize");
        let mut path = std::env::temp_dir();
        path.push(format!(
            "pictor_te_gather_test_{}.safetensors",
            std::process::id()
        ));
        std::fs::write(&path, &blob).expect("write temp safetensors");

        let model = Mlx4bitModel::open(&path).expect("open synthetic model");

        // Gather a non-contiguous, repeated, out-of-order set of rows.
        let rows = [3usize, 0, 4, 0, 2];
        let gathered = model
            .gather_quant_rows(base, &rows, cols)
            .expect("gather rows");
        assert_eq!(gathered.len(), rows.len() * cols);

        for (dst_row, &r) in rows.iter().enumerate() {
            let got = &gathered[dst_row * cols..(dst_row + 1) * cols];
            let want = &full[r * cols..(r + 1) * cols];
            // Bit-for-bit equality (not just approximate).
            assert_eq!(
                got.iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
                want.iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
                "row {r} (dst {dst_row}) differs from full dequant"
            );
        }

        // Out-of-range row index must error, not panic.
        let err = model
            .gather_quant_rows(base, &[out], cols)
            .expect_err("row >= out_features must error");
        assert!(matches!(err, TeError::Shape(_)));

        let _ = std::fs::remove_file(&path);
    }
}