Skip to main content

quantize_rs/quantization/
mod.rs

1//! Core quantization logic for INT8 and INT4.
2//!
3//! Provides tensor-level quantization (per-tensor and per-channel),
4//! INT4 bit-packing, and the high-level [`Quantizer`] that combines
5//! a [`QuantConfig`] with optional calibration statistics.
6
7use crate::errors::{QuantizeError, Result};
8
9/// Configuration for a quantization pass.
10///
11/// Fields are public for ergonomic construction in callers; new fields are
12/// added only on a minor version bump and downstream `..Default::default()`
13/// callers continue to compile.  Always construct via
14/// `QuantConfig { /* fields */, ..Default::default() }`.
15#[derive(Debug, Clone)]
16pub struct QuantConfig {
17    /// Bit width: `4` for INT4 or `8` for INT8.
18    pub bits: u8,
19    /// When `true`, compute separate scale/zero-point per output channel (axis 0).
20    pub per_channel: bool,
21    /// When `true`, force `zero_point == 0` (symmetric quantization) — required
22    /// by most ONNX Runtime / TensorRT INT8 matmul kernels for per-channel
23    /// weight quantization.  Defaults to `false` (asymmetric).
24    pub symmetric: bool,
25    /// Optional calibration method used for range optimization.
26    pub calibration_method: Option<crate::calibration::methods::CalibrationMethod>,
27    /// Layer names to skip entirely (exact match against the initializer name).
28    pub excluded_layers: Vec<String>,
29    /// Per-layer bit-width overrides.  Key = initializer name, value = 4 or 8.
30    pub layer_bits: std::collections::HashMap<String, u8>,
31    /// Minimum number of elements a tensor must have to be quantized.
32    /// Tensors with fewer elements are left in FP32.  Defaults to 0 (no minimum).
33    pub min_elements: usize,
34}
35
36impl Default for QuantConfig {
37    fn default() -> Self {
38        Self {
39            bits: 8,
40            per_channel: false,
41            symmetric: false,
42            calibration_method: None,
43            excluded_layers: Vec::new(),
44            layer_bits: std::collections::HashMap::new(),
45            min_elements: 0,
46        }
47    }
48}
49
50impl QuantConfig {
51    /// Create a default INT8 per-tensor configuration.
52    pub fn int8() -> Self {
53        Self::default()
54    }
55
56    /// Enable or disable per-channel quantization.
57    pub fn with_per_channel(mut self, enabled: bool) -> Self {
58        self.per_channel = enabled;
59        self
60    }
61
62    /// Enable or disable symmetric quantization (`zero_point == 0`).
63    pub fn with_symmetric(mut self, enabled: bool) -> Self {
64        self.symmetric = enabled;
65        self
66    }
67
68    /// Set the calibration method for range optimization.
69    pub fn with_calibration(
70        mut self,
71        method: crate::calibration::methods::CalibrationMethod,
72    ) -> Self {
73        self.calibration_method = Some(method);
74        self
75    }
76
77    /// Return `true` if the layer should be quantized.
78    ///
79    /// A layer is skipped when:
80    /// - its name appears in [`Self::excluded_layers`], or
81    /// - `num_elements` is below [`Self::min_elements`] (and `min_elements > 0`).
82    pub fn should_quantize(&self, name: &str, num_elements: usize) -> bool {
83        if self.excluded_layers.iter().any(|e| e == name) {
84            return false;
85        }
86        if self.min_elements > 0 && num_elements < self.min_elements {
87            return false;
88        }
89        true
90    }
91
92    /// Return the effective bit width for a layer.
93    ///
94    /// If the layer name has an entry in [`Self::layer_bits`], that value is used;
95    /// otherwise the global [`Self::bits`] is returned.
96    pub fn bits_for_layer(&self, name: &str) -> u8 {
97        self.layer_bits.get(name).copied().unwrap_or(self.bits)
98    }
99}
100
101// ---------------------------------------------------------------------------
102// QuantRange trait and marker types
103// ---------------------------------------------------------------------------
104
105/// Seals [`QuantRange`] so only the in-crate `Int8Range` / `Int4Range` markers
106/// implement it.  Keeping the trait closed lets us add methods or constants to
107/// it in a future release without that being a breaking change downstream.
108mod sealed {
109    pub trait Sealed {}
110    impl Sealed for super::Int8Range {}
111    impl Sealed for super::Int4Range {}
112}
113
114/// Marker trait that supplies the clamp constants for a quantization bit-width.
115///
116/// **Sealed**: implemented only for [`Int8Range`] and [`Int4Range`] and cannot
117/// be implemented outside this crate.
118pub trait QuantRange: sealed::Sealed + Clone + std::fmt::Debug + Send + Sync + 'static {
119    /// Minimum quantized value (inclusive).
120    const QMIN: f32;
121    /// Maximum quantized value (inclusive).
122    const QMAX: f32;
123    /// Bit width (4 or 8).
124    const BITS: u8;
125}
126
127/// Marker for INT8 quantization (`-128 … 127`).
128#[derive(Debug, Clone)]
129pub struct Int8Range;
130impl QuantRange for Int8Range {
131    const QMIN: f32 = -128.0;
132    const QMAX: f32 = 127.0;
133    const BITS: u8 = 8;
134}
135
136/// Marker for INT4 quantization (`-8 … 7`).
137#[derive(Debug, Clone)]
138pub struct Int4Range;
139impl QuantRange for Int4Range {
140    const QMIN: f32 = -8.0;
141    const QMAX: f32 = 7.0;
142    const BITS: u8 = 4;
143}
144
145// ---------------------------------------------------------------------------
146// QuantParamsGeneric<R>
147// ---------------------------------------------------------------------------
148
149/// Affine quantization parameters (scale and zero-point), generic over bit-width.
150///
151/// - INT8: `q = clamp(round(x / scale) + zero_point, -128, 127)`
152/// - INT4: `q = clamp(round(x / scale) + zero_point, -8, 7)`
153/// - Dequantization: `x = (q - zero_point) * scale`
154#[derive(Debug, Clone)]
155pub struct QuantParamsGeneric<R: QuantRange> {
156    scale: f32,
157    zero_point: i8,
158    _marker: std::marker::PhantomData<R>,
159}
160
161/// INT8 affine quantization parameters — `clamp(-128, 127)`.
162pub type QuantParams = QuantParamsGeneric<Int8Range>;
163/// INT4 affine quantization parameters — `clamp(-8, 7)`.
164pub type QuantParamsInt4 = QuantParamsGeneric<Int4Range>;
165
166impl<R: QuantRange> QuantParamsGeneric<R> {
167    /// Quantization scale factor.
168    pub fn scale(&self) -> f32 {
169        self.scale
170    }
171    /// Quantization zero point.
172    pub fn zero_point(&self) -> i8 {
173        self.zero_point
174    }
175
176    /// Compute asymmetric quantization parameters from a floating-point range.
177    ///
178    /// The resulting zero-point is in `[QMIN, QMAX]` and the full integer
179    /// range `[QMIN, QMAX]` is used to represent the input.  For ONNX Runtime /
180    /// TensorRT per-channel weight quantization, prefer
181    /// [`from_range_symmetric`](Self::from_range_symmetric) instead — those
182    /// kernels assume `zero_point == 0`.
183    pub fn from_range(min: f32, max: f32) -> Self {
184        let min = min.min(0.0);
185        let max = max.max(0.0);
186
187        // Handle constant-value tensors: when min ≈ max the data is (near-)constant.
188        // Use unit scale centred on zero so that the constant dequantizes accurately.
189        let (min, max) = if (max - min).abs() < 1e-8 {
190            let abs = min.abs().max(max.abs()).max(1e-8);
191            (-abs, abs)
192        } else {
193            (min, max)
194        };
195
196        let scale = (max - min) / (R::QMAX - R::QMIN);
197        let scale = scale.max(1e-8);
198
199        let initial_zero_point = R::QMIN - min / scale;
200        // Guard against NaN — if min/scale produced NaN (degenerate input),
201        // fall back to 0 to avoid undefined behaviour on the `as i8` cast.
202        let zero_point = if initial_zero_point.is_finite() {
203            initial_zero_point.round().clamp(R::QMIN, R::QMAX) as i8
204        } else {
205            0i8
206        };
207
208        QuantParamsGeneric {
209            scale,
210            zero_point,
211            _marker: std::marker::PhantomData,
212        }
213    }
214
215    /// Compute symmetric quantization parameters: `zero_point = 0`, `scale`
216    /// chosen so that the positive half of the quantized range covers
217    /// `max(|min|, |max|)`.
218    ///
219    /// This is the convention ONNX Runtime, TensorRT, and most accelerated
220    /// INT8 matmul kernels expect for per-channel weight quantization.
221    /// For INT8, the effective representable range is `[-127*scale, 127*scale]`;
222    /// any input value mapped to `-128` after rounding is clamped back into
223    /// range.  Zero always dequantizes to exactly 0.0.
224    pub fn from_range_symmetric(min: f32, max: f32) -> Self {
225        let abs_max = min.abs().max(max.abs()).max(1e-8);
226        // R::QMAX is the positive maximum (127 for INT8, 7 for INT4).  Dividing
227        // by QMAX — not by (QMAX - QMIN) — is what makes the result symmetric
228        // and keeps the zero-point at 0.
229        let scale = (abs_max / R::QMAX).max(1e-8);
230        QuantParamsGeneric {
231            scale,
232            zero_point: 0,
233            _marker: std::marker::PhantomData,
234        }
235    }
236
237    /// Quantize a single float to the target integer type.
238    pub fn quantize(&self, value: f32) -> i8 {
239        if !value.is_finite() {
240            return self.zero_point;
241        }
242        let quantized = (value / self.scale).round() + (self.zero_point as f32);
243        quantized.clamp(R::QMIN, R::QMAX) as i8
244    }
245
246    /// Dequantize a single integer value back to float.
247    pub fn dequantize(&self, value: i8) -> f32 {
248        ((value as i32) - (self.zero_point as i32)) as f32 * self.scale
249    }
250}
251
252// ---------------------------------------------------------------------------
253// QuantizedTensorGeneric<R>
254// ---------------------------------------------------------------------------
255
256/// Generic quantized tensor, parameterized by bit-width marker.
257///
258/// For INT4 tensors, call [`QuantizedTensorGeneric::pack`] to compress two
259/// values per byte for 2× storage savings.
260#[derive(Debug, Clone)]
261pub struct QuantizedTensorGeneric<R: QuantRange> {
262    pub(crate) data: Vec<i8>,
263    /// Bit-packed storage — always `None` for INT8, set by `.pack()` for INT4.
264    pub(crate) packed_data: Option<Vec<u8>>,
265    pub(crate) shape: Vec<usize>,
266    pub(crate) params: QuantParamsGeneric<R>,
267    pub(crate) per_channel: bool,
268    pub(crate) channel_params: Option<Vec<QuantParamsGeneric<R>>>,
269}
270
271/// An INT8 quantized tensor with optional per-channel parameters.
272pub type QuantizedTensor = QuantizedTensorGeneric<Int8Range>;
273
274/// An INT4 quantized tensor with optional per-channel parameters and bit packing.
275///
276/// Values are stored in the range `[-8, 7]`. Call [`pack`](QuantizedTensorInt4::pack) to
277/// compress two values into one byte for 2× storage savings.
278pub type QuantizedTensorInt4 = QuantizedTensorGeneric<Int4Range>;
279
280// ---------------------------------------------------------------------------
281// Shared impl for all bit-widths
282// ---------------------------------------------------------------------------
283
284impl<R: QuantRange> QuantizedTensorGeneric<R> {
285    /// Tensor shape.
286    pub fn shape(&self) -> &[usize] {
287        &self.shape
288    }
289    /// Per-tensor quantization parameters (channel-0 if per-channel).
290    pub fn params(&self) -> &QuantParamsGeneric<R> {
291        &self.params
292    }
293    /// Whether per-channel quantization was used.
294    pub fn is_per_channel(&self) -> bool {
295        self.per_channel
296    }
297
298    /// Quantize FP32 data, computing the range from the data (asymmetric).
299    ///
300    /// # Errors
301    ///
302    /// Returns [`QuantizeError::InvalidTensor`] if `data` is empty or shape mismatches.
303    pub fn from_f32(data: &[f32], shape: Vec<usize>) -> Result<Self> {
304        Self::from_f32_with_mode(data, shape, false)
305    }
306
307    /// Quantize FP32 data using symmetric quantization (`zero_point == 0`).
308    ///
309    /// Required by most ONNX Runtime / TensorRT INT8 kernels for per-channel
310    /// weight quantization.  See [`QuantParamsGeneric::from_range_symmetric`].
311    pub fn from_f32_symmetric(data: &[f32], shape: Vec<usize>) -> Result<Self> {
312        Self::from_f32_with_mode(data, shape, true)
313    }
314
315    fn from_f32_with_mode(data: &[f32], shape: Vec<usize>, symmetric: bool) -> Result<Self> {
316        if data.is_empty() {
317            return Err(QuantizeError::InvalidTensor {
318                reason: "Cannot quantize empty tensor".into(),
319            });
320        }
321
322        let expected_len: usize = shape.iter().product();
323        if expected_len != data.len() {
324            return Err(QuantizeError::InvalidTensor {
325                reason: format!(
326                    "Shape {:?} expects {} elements but got {}",
327                    shape,
328                    expected_len,
329                    data.len()
330                ),
331            });
332        }
333
334        let min = data
335            .iter()
336            .copied()
337            .filter(|v| v.is_finite())
338            .fold(f32::INFINITY, f32::min);
339        let max = data
340            .iter()
341            .copied()
342            .filter(|v| v.is_finite())
343            .fold(f32::NEG_INFINITY, f32::max);
344
345        if !min.is_finite() || !max.is_finite() {
346            return Err(QuantizeError::InvalidTensor {
347                reason: "Tensor contains only non-finite values (NaN/Inf)".into(),
348            });
349        }
350
351        let params = if symmetric {
352            QuantParamsGeneric::<R>::from_range_symmetric(min, max)
353        } else {
354            QuantParamsGeneric::<R>::from_range(min, max)
355        };
356
357        let quantized_data: Vec<i8> = data.iter().map(|&v| params.quantize(v)).collect();
358
359        Ok(QuantizedTensorGeneric {
360            data: quantized_data,
361            packed_data: None,
362            shape,
363            params,
364            per_channel: false,
365            channel_params: None,
366        })
367    }
368
369    /// Quantize FP32 data using an explicit range (for calibration; asymmetric).
370    ///
371    /// # Errors
372    ///
373    /// Returns [`QuantizeError::InvalidTensor`] if `data` is empty or shape mismatches.
374    pub fn from_f32_with_range(
375        data: &[f32],
376        shape: Vec<usize>,
377        min: f32,
378        max: f32,
379    ) -> Result<Self> {
380        Self::from_f32_with_range_and_mode(data, shape, min, max, false)
381    }
382
383    /// Same as [`from_f32_with_range`](Self::from_f32_with_range) but produces
384    /// symmetric parameters (`zero_point == 0`).
385    pub fn from_f32_with_range_symmetric(
386        data: &[f32],
387        shape: Vec<usize>,
388        min: f32,
389        max: f32,
390    ) -> Result<Self> {
391        Self::from_f32_with_range_and_mode(data, shape, min, max, true)
392    }
393
394    fn from_f32_with_range_and_mode(
395        data: &[f32],
396        shape: Vec<usize>,
397        min: f32,
398        max: f32,
399        symmetric: bool,
400    ) -> Result<Self> {
401        if data.is_empty() {
402            return Err(QuantizeError::InvalidTensor {
403                reason: "Cannot quantize empty tensor".into(),
404            });
405        }
406
407        let expected_len: usize = shape.iter().product();
408        if expected_len != data.len() {
409            return Err(QuantizeError::InvalidTensor {
410                reason: format!(
411                    "Shape {:?} expects {} elements but got {}",
412                    shape,
413                    expected_len,
414                    data.len()
415                ),
416            });
417        }
418
419        let params = if symmetric {
420            QuantParamsGeneric::<R>::from_range_symmetric(min, max)
421        } else {
422            QuantParamsGeneric::<R>::from_range(min, max)
423        };
424
425        let quantized_data: Vec<i8> = data.iter().map(|&v| params.quantize(v)).collect();
426
427        Ok(QuantizedTensorGeneric {
428            data: quantized_data,
429            packed_data: None,
430            shape,
431            params,
432            per_channel: false,
433            channel_params: None,
434        })
435    }
436
437    /// Quantize FP32 data with per-channel ranges (axis 0 only, asymmetric).
438    ///
439    /// # Errors
440    ///
441    /// Returns [`QuantizeError::InvalidTensor`] if `data` is empty, shape
442    /// mismatches, or the tensor is scalar.
443    pub fn from_f32_per_channel(data: &[f32], shape: Vec<usize>) -> Result<Self> {
444        Self::from_f32_per_channel_with_mode(data, shape, false)
445    }
446
447    /// Same as [`from_f32_per_channel`](Self::from_f32_per_channel) but emits
448    /// symmetric parameters (`zero_point == 0` for every channel).  Required
449    /// by most INT8 per-channel matmul kernels.
450    pub fn from_f32_per_channel_symmetric(data: &[f32], shape: Vec<usize>) -> Result<Self> {
451        Self::from_f32_per_channel_with_mode(data, shape, true)
452    }
453
454    fn from_f32_per_channel_with_mode(
455        data: &[f32],
456        shape: Vec<usize>,
457        symmetric: bool,
458    ) -> Result<Self> {
459        if data.is_empty() {
460            return Err(QuantizeError::InvalidTensor {
461                reason: "Cannot quantize empty tensor".into(),
462            });
463        }
464
465        if shape.is_empty() {
466            return Err(QuantizeError::InvalidTensor {
467                reason: "Cannot do per-channel quantization on scalar".into(),
468            });
469        }
470
471        let expected_len: usize = shape.iter().product();
472        if expected_len != data.len() {
473            return Err(QuantizeError::InvalidTensor {
474                reason: format!(
475                    "Shape {:?} expects {} elements but got {}",
476                    shape,
477                    expected_len,
478                    data.len()
479                ),
480            });
481        }
482
483        let num_channels = shape[0];
484        if num_channels == 0 {
485            return Err(QuantizeError::InvalidTensor {
486                reason: "Number of channels is 0".into(),
487            });
488        }
489        if !data.len().is_multiple_of(num_channels) {
490            return Err(QuantizeError::InvalidTensor {
491                reason: format!(
492                    "Data length {} not evenly divisible by {} channels",
493                    data.len(),
494                    num_channels
495                ),
496            });
497        }
498        let elements_per_channel = data.len() / num_channels;
499
500        let mut channel_params = Vec::with_capacity(num_channels);
501        let mut quantized_data = Vec::with_capacity(data.len());
502
503        // Walk the data channel-by-channel with a borrowed slice — no Vec alloc
504        // per channel.  For typical Conv weights this avoids hundreds of small
505        // allocations that used to dominate the per-channel hot path.
506        for (channel_idx, channel_slice) in data.chunks_exact(elements_per_channel).enumerate() {
507            let mut min = f32::INFINITY;
508            let mut max = f32::NEG_INFINITY;
509            for &v in channel_slice {
510                if v.is_finite() {
511                    if v < min {
512                        min = v;
513                    }
514                    if v > max {
515                        max = v;
516                    }
517                }
518            }
519
520            if !min.is_finite() || !max.is_finite() {
521                return Err(QuantizeError::InvalidTensor {
522                    reason: format!(
523                        "Channel {} contains only non-finite values (NaN/Inf)",
524                        channel_idx
525                    ),
526                });
527            }
528
529            let params = if symmetric {
530                QuantParamsGeneric::<R>::from_range_symmetric(min, max)
531            } else {
532                QuantParamsGeneric::<R>::from_range(min, max)
533            };
534
535            quantized_data.extend(channel_slice.iter().map(|&v| params.quantize(v)));
536            channel_params.push(params);
537        }
538
539        // Use first channel params as "representative" for backward compatibility
540        let params = channel_params[0].clone();
541
542        Ok(QuantizedTensorGeneric {
543            data: quantized_data,
544            packed_data: None,
545            shape,
546            params,
547            per_channel: true,
548            channel_params: Some(channel_params),
549        })
550    }
551
552    /// Dequantize all values back to FP32.
553    pub fn to_f32(&self) -> Vec<f32> {
554        // Borrow data directly when unpacked; allocate only for the packed INT4 path.
555        let data_owned;
556        let data: &[i8] = if let Some(ref packed) = self.packed_data {
557            data_owned = unpack_int4(packed, self.data.len());
558            &data_owned
559        } else {
560            &self.data
561        };
562
563        if self.per_channel {
564            if let Some(ref channel_params) = self.channel_params {
565                if channel_params.is_empty() {
566                    return data.iter().map(|&v| self.params.dequantize(v)).collect();
567                }
568                // Walk contiguous per-channel slices.  The constructor
569                // guarantees `data.len()` is an exact multiple of the channel
570                // count, so `chunks_exact` consumes every element with no
571                // remainder and lines up one chunk per channel.
572                let elements_per_channel = data.len() / channel_params.len();
573                if elements_per_channel == 0 {
574                    // Degenerate: fewer values than channels.  Fall back to the
575                    // representative params so we don't panic.
576                    return data.iter().map(|&v| self.params.dequantize(v)).collect();
577                }
578                let mut out = Vec::with_capacity(data.len());
579                for (chunk, params) in data
580                    .chunks_exact(elements_per_channel)
581                    .zip(channel_params.iter())
582                {
583                    out.extend(chunk.iter().map(|&v| params.dequantize(v)));
584                }
585                out
586            } else {
587                data.iter().map(|&v| self.params.dequantize(v)).collect()
588            }
589        } else {
590            data.iter().map(|&v| self.params.dequantize(v)).collect()
591        }
592    }
593
594    /// Size of the quantized data in bytes (packed if available, unpacked otherwise).
595    pub fn size_bytes(&self) -> usize {
596        if let Some(ref packed) = self.packed_data {
597            packed.len()
598        } else {
599            self.data.len() * std::mem::size_of::<i8>()
600        }
601    }
602
603    /// Mean squared error between the original data and the dequantized values.
604    pub fn quantization_error(&self, original: &[f32]) -> f32 {
605        if original.is_empty() {
606            return 0.0;
607        }
608
609        let dequantized = self.to_f32();
610
611        let sum: f32 = original
612            .iter()
613            .zip(dequantized.iter())
614            .map(|(a, b)| (a - b).powi(2))
615            .sum();
616
617        sum / original.len() as f32
618    }
619}
620
621// ---------------------------------------------------------------------------
622// INT4-specific methods
623// ---------------------------------------------------------------------------
624
625impl QuantizedTensorGeneric<Int4Range> {
626    /// Pack two INT4 values per byte for 2× compression.
627    pub fn pack(&mut self) {
628        self.packed_data = Some(pack_int4(&self.data));
629    }
630
631    /// Return unpacked i8 data, decompressing from packed storage if needed.
632    pub fn ensure_unpacked(&self) -> Vec<i8> {
633        if let Some(ref packed) = self.packed_data {
634            unpack_int4(packed, self.data.len())
635        } else {
636            self.data.clone()
637        }
638    }
639
640    /// Whether the data is currently bit-packed.
641    pub fn is_packed(&self) -> bool {
642        self.packed_data.is_some()
643    }
644
645    /// Size that the packed representation would occupy (or already occupies).
646    pub fn packed_size_bytes(&self) -> usize {
647        if let Some(ref packed) = self.packed_data {
648            packed.len()
649        } else {
650            self.data.len().div_ceil(2)
651        }
652    }
653
654    /// Size of the unpacked representation in bytes.
655    pub fn unpacked_size_bytes(&self) -> usize {
656        self.data.len() * std::mem::size_of::<i8>()
657    }
658}
659
660// ---------------------------------------------------------------------------
661// INT4 bit-packing helpers
662// ---------------------------------------------------------------------------
663
664fn pack_int4_pair(val1: i8, val2: i8) -> u8 {
665    debug_assert!((-8..=7).contains(&val1), "val1 out of INT4 range: {}", val1);
666    debug_assert!((-8..=7).contains(&val2), "val2 out of INT4 range: {}", val2);
667
668    // Convert to 4-bit representation
669    let nibble1 = (val1 & 0x0F) as u8;
670    let nibble2 = (val2 & 0x0F) as u8;
671
672    // Pack: high 4 bits = val1, low 4 bits = val2
673    (nibble1 << 4) | nibble2
674}
675
676fn unpack_int4_pair(byte: u8) -> (i8, i8) {
677    let nibble1 = (byte >> 4) & 0x0F;
678    let nibble2 = byte & 0x0F;
679
680    // Convert from 4-bit to signed i8
681    let val1 = if nibble1 >= 8 {
682        (nibble1 as i8) | !0x0F
683    } else {
684        nibble1 as i8
685    };
686
687    let val2 = if nibble2 >= 8 {
688        (nibble2 as i8) | !0x0F
689    } else {
690        nibble2 as i8
691    };
692
693    (val1, val2)
694}
695
696/// Pack a slice of INT4 values (two per byte, high nibble first).
697pub fn pack_int4(values: &[i8]) -> Vec<u8> {
698    let mut packed = Vec::with_capacity(values.len().div_ceil(2));
699
700    for chunk in values.chunks(2) {
701        let val1 = chunk[0];
702        let val2 = if chunk.len() > 1 { chunk[1] } else { 0 };
703
704        packed.push(pack_int4_pair(val1, val2));
705    }
706
707    packed
708}
709
710/// Unpack INT4 values from packed bytes, returning exactly `num_values` i8s.
711pub fn unpack_int4(packed: &[u8], num_values: usize) -> Vec<i8> {
712    let mut values = Vec::with_capacity(num_values);
713
714    for &byte in packed {
715        let (val1, val2) = unpack_int4_pair(byte);
716        values.push(val1);
717        if values.len() < num_values {
718            values.push(val2);
719        }
720    }
721
722    // Truncate to exact size (removes padding)
723    values.truncate(num_values);
724    values
725}
726
727// ---------------------------------------------------------------------------
728// QuantizedTensorType
729// ---------------------------------------------------------------------------
730
731/// Type-erased wrapper over [`QuantizedTensor`] (INT8) and [`QuantizedTensorInt4`] (INT4).
732///
733/// Marked `#[non_exhaustive]` so future bit-widths (e.g. INT16, INT2) can be
734/// added without a breaking change.
735#[derive(Debug, Clone)]
736#[non_exhaustive]
737pub enum QuantizedTensorType {
738    Int8(QuantizedTensor),
739    Int4(QuantizedTensorInt4),
740}
741
742impl QuantizedTensorType {
743    /// Dequantize all values back to FP32.
744    pub fn to_f32(&self) -> Vec<f32> {
745        match self {
746            QuantizedTensorType::Int8(t) => t.to_f32(),
747            QuantizedTensorType::Int4(t) => t.to_f32(),
748        }
749    }
750
751    /// Size of the quantized data in bytes.
752    pub fn size_bytes(&self) -> usize {
753        match self {
754            QuantizedTensorType::Int8(t) => t.size_bytes(),
755            QuantizedTensorType::Int4(t) => t.size_bytes(),
756        }
757    }
758
759    #[must_use]
760    pub fn quantization_error(&self, original: &[f32]) -> f32 {
761        match self {
762            QuantizedTensorType::Int8(t) => t.quantization_error(original),
763            QuantizedTensorType::Int4(t) => t.quantization_error(original),
764        }
765    }
766
767    #[must_use]
768    pub fn data(&self) -> Vec<i8> {
769        match self {
770            QuantizedTensorType::Int8(t) => t.data.clone(),
771            QuantizedTensorType::Int4(t) => t.ensure_unpacked(),
772        }
773    }
774
775    /// Per-tensor scale and zero-point.
776    pub fn get_scale_zero_point(&self) -> (f32, i8) {
777        match self {
778            QuantizedTensorType::Int8(t) => (t.params.scale, t.params.zero_point),
779            QuantizedTensorType::Int4(t) => (t.params.scale, t.params.zero_point),
780        }
781    }
782
783    /// Return all per-channel scales and zero-points.
784    ///
785    /// For per-tensor quantization, returns single-element vectors.
786    /// For per-channel, returns one entry per channel.
787    pub fn get_all_scales_zero_points(&self) -> (Vec<f32>, Vec<i8>) {
788        match self {
789            QuantizedTensorType::Int8(t) => {
790                if let Some(ref cp) = t.channel_params {
791                    (
792                        cp.iter().map(|p| p.scale).collect(),
793                        cp.iter().map(|p| p.zero_point).collect(),
794                    )
795                } else {
796                    (vec![t.params.scale], vec![t.params.zero_point])
797                }
798            }
799            QuantizedTensorType::Int4(t) => {
800                if let Some(ref cp) = t.channel_params {
801                    (
802                        cp.iter().map(|p| p.scale).collect(),
803                        cp.iter().map(|p| p.zero_point).collect(),
804                    )
805                } else {
806                    (vec![t.params.scale], vec![t.params.zero_point])
807                }
808            }
809        }
810    }
811
812    /// Whether per-channel quantization was used.
813    pub fn is_per_channel(&self) -> bool {
814        match self {
815            QuantizedTensorType::Int8(t) => t.per_channel,
816            QuantizedTensorType::Int4(t) => t.per_channel,
817        }
818    }
819
820    #[must_use]
821    pub fn bits(&self) -> u8 {
822        match self {
823            QuantizedTensorType::Int8(_) => 8,
824            QuantizedTensorType::Int4(_) => 4,
825        }
826    }
827
828    /// `true` if this is an INT8 tensor.
829    pub fn is_int8(&self) -> bool {
830        matches!(self, QuantizedTensorType::Int8(_))
831    }
832
833    /// `true` if this is an INT4 tensor.
834    pub fn is_int4(&self) -> bool {
835        matches!(self, QuantizedTensorType::Int4(_))
836    }
837
838    /// Borrow quantized data without cloning.
839    ///
840    /// Returns `None` for packed INT4 tensors (must use `data()` which unpacks).
841    pub fn data_ref(&self) -> Option<&[i8]> {
842        match self {
843            QuantizedTensorType::Int8(t) => Some(&t.data),
844            QuantizedTensorType::Int4(t) => {
845                if t.packed_data.is_some() {
846                    None // packed: caller must use data() to unpack
847                } else {
848                    Some(&t.data)
849                }
850            }
851        }
852    }
853}
854
855// ---------------------------------------------------------------------------
856// Quantizer
857// ---------------------------------------------------------------------------
858
859/// High-level quantizer that combines configuration with optional calibration.
860pub struct Quantizer {
861    config: QuantConfig,
862    calibration_stats:
863        Option<std::collections::HashMap<String, crate::calibration::stats::ActivationStats>>,
864}
865
866impl std::fmt::Debug for Quantizer {
867    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
868        let stats_count = self.calibration_stats.as_ref().map(|m| m.len());
869        f.debug_struct("Quantizer")
870            .field("config", &self.config)
871            .field("calibration_stats_count", &stats_count)
872            .finish()
873    }
874}
875
876impl Quantizer {
877    /// Create a quantizer with the given configuration (no calibration).
878    pub fn new(config: QuantConfig) -> Self {
879        Self {
880            config,
881            calibration_stats: None,
882        }
883    }
884
885    /// Create a quantizer with configuration and pre-collected activation statistics.
886    pub fn with_calibration(
887        config: QuantConfig,
888        stats: std::collections::HashMap<String, crate::calibration::stats::ActivationStats>,
889    ) -> Self {
890        Self {
891            config,
892            calibration_stats: Some(stats),
893        }
894    }
895
896    /// Quantize a tensor with optional calibration.
897    pub fn quantize_tensor_with_name(
898        &self,
899        name: &str,
900        data: &[f32],
901        shape: Vec<usize>,
902    ) -> Result<QuantizedTensorType> {
903        let (min, max) = if let Some(ref stats_map) = self.calibration_stats {
904            if let Some(stats) = stats_map.get(name) {
905                if let Some(method) = self.config.calibration_method {
906                    // Compute the range directly from the histogram — deterministic,
907                    // no sample regeneration, no RNG.
908                    use crate::calibration::stats::calculate_optimal_range_from_stats;
909                    calculate_optimal_range_from_stats(stats, method)
910                } else {
911                    (stats.min(), stats.max())
912                }
913            } else {
914                finite_min_max(data, name)?
915            }
916        } else {
917            finite_min_max(data, name)?
918        };
919
920        self.quantize_with_range(data, shape, min, max)
921    }
922
923    /// Quantize a tensor using the configured bit width and per-channel setting.
924    ///
925    /// # Errors
926    ///
927    /// Returns [`QuantizeError::InvalidTensor`] or [`QuantizeError::UnsupportedConfig`].
928    pub fn quantize_tensor(&self, data: &[f32], shape: Vec<usize>) -> Result<QuantizedTensorType> {
929        self.build_tensor_with_optional_range(data, shape, None)
930    }
931
932    /// Quantize with specific range (for calibration).
933    ///
934    /// When `per_channel` is enabled, the provided `min`/`max` are ignored
935    /// because per-channel quantization computes separate ranges from the
936    /// weight data for each channel.  The calibration range (derived from
937    /// activation statistics) applies to per-tensor mode only.
938    fn quantize_with_range(
939        &self,
940        data: &[f32],
941        shape: Vec<usize>,
942        min: f32,
943        max: f32,
944    ) -> Result<QuantizedTensorType> {
945        self.build_tensor_with_optional_range(data, shape, Some((min, max)))
946    }
947
948    /// Shared core: build a [`QuantizedTensorType`] for any bit-width and range mode.
949    fn build_tensor_with_optional_range(
950        &self,
951        data: &[f32],
952        shape: Vec<usize>,
953        range: Option<(f32, f32)>,
954    ) -> Result<QuantizedTensorType> {
955        let pc = self.config.per_channel && shape.len() >= 2;
956        let sym = self.config.symmetric;
957        match self.config.bits {
958            8 => {
959                let t = match (pc, range, sym) {
960                    (true, _, true) => {
961                        QuantizedTensor::from_f32_per_channel_symmetric(data, shape)?
962                    }
963                    (true, _, false) => QuantizedTensor::from_f32_per_channel(data, shape)?,
964                    (false, Some((min, max)), true) => {
965                        QuantizedTensor::from_f32_with_range_symmetric(data, shape, min, max)?
966                    }
967                    (false, Some((min, max)), false) => {
968                        QuantizedTensor::from_f32_with_range(data, shape, min, max)?
969                    }
970                    (false, None, true) => QuantizedTensor::from_f32_symmetric(data, shape)?,
971                    (false, None, false) => QuantizedTensor::from_f32(data, shape)?,
972                };
973                Ok(QuantizedTensorType::Int8(t))
974            }
975            4 => {
976                let mut t = match (pc, range, sym) {
977                    (true, _, true) => {
978                        QuantizedTensorInt4::from_f32_per_channel_symmetric(data, shape)?
979                    }
980                    (true, _, false) => QuantizedTensorInt4::from_f32_per_channel(data, shape)?,
981                    (false, Some((min, max)), true) => {
982                        QuantizedTensorInt4::from_f32_with_range_symmetric(data, shape, min, max)?
983                    }
984                    (false, Some((min, max)), false) => {
985                        QuantizedTensorInt4::from_f32_with_range(data, shape, min, max)?
986                    }
987                    (false, None, true) => QuantizedTensorInt4::from_f32_symmetric(data, shape)?,
988                    (false, None, false) => QuantizedTensorInt4::from_f32(data, shape)?,
989                };
990                t.pack();
991                Ok(QuantizedTensorType::Int4(t))
992            }
993            b => Err(QuantizeError::UnsupportedConfig {
994                reason: format!("bits must be 4 or 8, got {b}"),
995            }),
996        }
997    }
998
999    /// Quantize every weight in `model` that passes
1000    /// [`QuantConfig::should_quantize`].  Honours per-layer bit-width overrides.
1001    ///
1002    /// When this quantizer was built with calibration, activation-based
1003    /// range optimization is used for the default bit-width; layers whose
1004    /// bit-width is overridden fall back to weight-only quantization
1005    /// (the calibration stats are keyed by the default configuration).
1006    ///
1007    /// Skipped weights do not appear in the returned vector.
1008    pub fn quantize_model(
1009        &self,
1010        model: &crate::onnx_utils::OnnxModel,
1011    ) -> Result<Vec<QuantizedWeightOutput>> {
1012        let weights = model.extract_weights();
1013        self.quantize_weights(&weights)
1014    }
1015
1016    /// Quantize a slice of already-extracted weights.
1017    ///
1018    /// Identical to [`quantize_model`](Self::quantize_model) but skips the
1019    /// [`extract_weights`](crate::onnx_utils::OnnxModel::extract_weights) call.
1020    /// Prefer this when the caller already holds the weights (e.g. to report the
1021    /// pre-quantization count and size): extracting once and passing the slice
1022    /// here avoids decoding every initializer's `raw_data` into `f32` twice.
1023    ///
1024    /// Skipped weights (per [`QuantConfig::should_quantize`]) do not appear in
1025    /// the returned vector.
1026    pub fn quantize_weights(
1027        &self,
1028        weights: &[crate::onnx_utils::WeightTensor],
1029    ) -> Result<Vec<QuantizedWeightOutput>> {
1030        use rayon::prelude::*;
1031
1032        let to_quantize: Vec<_> = weights
1033            .iter()
1034            .filter(|w| self.config.should_quantize(&w.name, w.num_elements()))
1035            .collect();
1036
1037        to_quantize
1038            .par_iter()
1039            .map(|w| self.quantize_weight_to_output(w))
1040            .collect()
1041    }
1042
1043    fn quantize_weight_to_output(
1044        &self,
1045        weight: &crate::onnx_utils::WeightTensor,
1046    ) -> Result<QuantizedWeightOutput> {
1047        let layer_bits = self.config.bits_for_layer(&weight.name);
1048
1049        // For the default bit-width, use the shared (possibly calibrated)
1050        // quantizer.  For per-layer bit-width overrides, build a layer-local
1051        // quantizer: calibration stats are keyed by the default configuration
1052        // and re-applying them at a different bit-width is ill-defined.
1053        let quantized = if layer_bits == self.config.bits {
1054            self.quantize_tensor_with_name(&weight.name, &weight.data, weight.shape.clone())?
1055        } else {
1056            let layer_config = QuantConfig {
1057                bits: layer_bits,
1058                per_channel: self.config.per_channel,
1059                symmetric: self.config.symmetric,
1060                ..Default::default()
1061            };
1062            Quantizer::new(layer_config).quantize_tensor(&weight.data, weight.shape.clone())?
1063        };
1064
1065        let mse = quantized.quantization_error(&weight.data);
1066        let (scales, zero_points) = quantized.get_all_scales_zero_points();
1067        let is_per_channel = quantized.is_per_channel();
1068        let bits_used = quantized.bits();
1069        let quantized_size_bytes = quantized.size_bytes();
1070
1071        Ok(QuantizedWeightOutput {
1072            qdq: crate::onnx_utils::graph_builder::QdqWeightInput {
1073                original_name: weight.name.clone(),
1074                quantized_values: quantized.data(),
1075                scales,
1076                zero_points,
1077                bits: bits_used,
1078                axis: if is_per_channel { Some(0) } else { None },
1079            },
1080            quantized_size_bytes,
1081            mse,
1082        })
1083    }
1084}
1085
1086/// Per-weight result of [`Quantizer::quantize_model`].
1087///
1088/// Bundles the QDQ block ready for
1089/// [`OnnxModel::save_quantized_with_options`](crate::onnx_utils::OnnxModel::save_quantized_with_options)
1090/// with the two telemetry values callers typically want to print: on-disk
1091/// size and round-trip MSE.
1092///
1093/// Marked `#[non_exhaustive]` so future telemetry fields can be added
1094/// without a breaking change.
1095#[derive(Debug, Clone)]
1096#[non_exhaustive]
1097pub struct QuantizedWeightOutput {
1098    /// QDQ block for `save_quantized_with_options`.
1099    pub qdq: crate::onnx_utils::graph_builder::QdqWeightInput,
1100    /// Size of the quantized payload in bytes (INT8 = 1 byte/elem;
1101    /// packed INT4 = ceil(elem/2)).
1102    pub quantized_size_bytes: usize,
1103    /// MSE between the original FP32 values and the dequantized output.
1104    pub mse: f32,
1105}
1106
1107// ---------------------------------------------------------------------------
1108// Calibration helper
1109// ---------------------------------------------------------------------------
1110
1111/// Compute the finite min/max of `data`, returning an error if all values are NaN/Inf.
1112fn finite_min_max(data: &[f32], name: &str) -> Result<(f32, f32)> {
1113    let min = data
1114        .iter()
1115        .copied()
1116        .filter(|v| v.is_finite())
1117        .fold(f32::INFINITY, f32::min);
1118    let max = data
1119        .iter()
1120        .copied()
1121        .filter(|v| v.is_finite())
1122        .fold(f32::NEG_INFINITY, f32::max);
1123    if !min.is_finite() || !max.is_finite() {
1124        return Err(QuantizeError::InvalidTensor {
1125            reason: format!(
1126                "Tensor '{}' contains only non-finite values (NaN/Inf)",
1127                name
1128            ),
1129        });
1130    }
1131    Ok((min, max))
1132}
1133
1134#[cfg(test)]
1135mod tests {
1136    use super::*;
1137
1138    // -----------------------------------------------------------------------
1139    // QuantConfig per-layer selection
1140    // -----------------------------------------------------------------------
1141
1142    #[test]
1143    fn test_should_quantize_no_restrictions() {
1144        let config = QuantConfig::default();
1145        assert!(config.should_quantize("any.layer", 1));
1146        assert!(config.should_quantize("any.layer", 1_000_000));
1147    }
1148
1149    #[test]
1150    fn test_should_quantize_excluded_layer() {
1151        let config = QuantConfig {
1152            excluded_layers: vec!["head.weight".to_string()],
1153            ..Default::default()
1154        };
1155        assert!(!config.should_quantize("head.weight", 1024));
1156        assert!(config.should_quantize("body.weight", 1024));
1157    }
1158
1159    #[test]
1160    fn test_should_quantize_min_elements() {
1161        let config = QuantConfig {
1162            min_elements: 512,
1163            ..Default::default()
1164        };
1165        assert!(!config.should_quantize("small.bias", 4));
1166        assert!(!config.should_quantize("small.bias", 511));
1167        assert!(config.should_quantize("large.weight", 512));
1168        assert!(config.should_quantize("large.weight", 1024));
1169    }
1170
1171    #[test]
1172    fn test_should_quantize_excluded_takes_priority_over_min_elements() {
1173        let config = QuantConfig {
1174            excluded_layers: vec!["head.weight".to_string()],
1175            min_elements: 1,
1176            ..Default::default()
1177        };
1178        // excluded → skipped regardless of size
1179        assert!(!config.should_quantize("head.weight", 1_000_000));
1180    }
1181
1182    #[test]
1183    fn test_bits_for_layer_default() {
1184        let config = QuantConfig {
1185            bits: 8,
1186            ..Default::default()
1187        };
1188        assert_eq!(config.bits_for_layer("any.weight"), 8);
1189    }
1190
1191    #[test]
1192    fn test_bits_for_layer_override() {
1193        let mut layer_bits = std::collections::HashMap::new();
1194        layer_bits.insert("head.weight".to_string(), 4u8);
1195        let config = QuantConfig {
1196            bits: 8,
1197            layer_bits,
1198            ..Default::default()
1199        };
1200        assert_eq!(config.bits_for_layer("head.weight"), 4);
1201        assert_eq!(config.bits_for_layer("body.weight"), 8);
1202    }
1203
1204    // -----------------------------------------------------------------------
1205    // Existing tests below
1206    // -----------------------------------------------------------------------
1207
1208    #[test]
1209    fn test_quant_params() {
1210        let params = QuantParams::from_range(-1.0, 1.0);
1211
1212        assert_eq!(params.quantize(0.0), params.zero_point);
1213
1214        let original = 0.5;
1215        let quantized = params.quantize(original);
1216        let dequantized = params.dequantize(quantized);
1217
1218        assert!((original - dequantized).abs() < 0.01);
1219    }
1220
1221    #[test]
1222    fn test_quantize_tensor() {
1223        let data = vec![0.0, 0.5, 1.0, -0.5, -1.0];
1224        let shape = vec![5];
1225
1226        let quantized = QuantizedTensor::from_f32(&data, shape).unwrap();
1227
1228        assert_eq!(quantized.data.len(), 5);
1229        assert_eq!(quantized.size_bytes(), 5);
1230    }
1231
1232    #[test]
1233    fn test_per_channel_quantization() {
1234        let mut data: Vec<f32> = Vec::with_capacity(200);
1235        data.extend(std::iter::repeat_n(0.5_f32, 100)); // Channel 0
1236        data.extend(std::iter::repeat_n(5.0_f32, 100)); // Channel 1
1237
1238        let shape = vec![2, 100];
1239
1240        let quantized = QuantizedTensor::from_f32_per_channel(&data, shape).unwrap();
1241
1242        assert!(quantized.per_channel);
1243        assert!(quantized.channel_params.is_some());
1244        assert_eq!(quantized.channel_params.as_ref().unwrap().len(), 2);
1245
1246        let dequantized = quantized.to_f32();
1247        let error: f32 = data
1248            .iter()
1249            .zip(dequantized.iter())
1250            .map(|(a, b)| (a - b).powi(2))
1251            .sum::<f32>()
1252            / data.len() as f32;
1253
1254        println!("Per-channel MSE: {}", error);
1255        assert!(error < 0.1);
1256    }
1257
1258    #[test]
1259    fn test_per_channel_vs_per_tensor() {
1260        let mut data: Vec<f32> = Vec::with_capacity(2000);
1261        data.extend(std::iter::repeat_n(0.01_f32, 1000));
1262        data.extend(std::iter::repeat_n(10.0_f32, 1000));
1263
1264        let shape = vec![2, 1000];
1265
1266        // Per-tensor quantization
1267        let per_tensor = QuantizedTensor::from_f32(&data, shape.clone()).unwrap();
1268        let per_tensor_error = per_tensor.quantization_error(&data);
1269
1270        // Per-channel quantization
1271        let per_channel = QuantizedTensor::from_f32_per_channel(&data, shape).unwrap();
1272        let per_channel_error = per_channel.quantization_error(&data);
1273
1274        println!("Per-tensor error:  {:.8}", per_tensor_error);
1275        println!("Per-channel error: {:.8}", per_channel_error);
1276
1277        // Per-channel
1278        assert!(per_channel_error < per_tensor_error);
1279        assert!(per_channel_error < per_tensor_error * 0.5);
1280    }
1281
1282    #[test]
1283    fn test_per_channel_benefit() {
1284        let mut data = vec![];
1285
1286        for i in 0..1000 {
1287            data.push(-0.1 + (i as f32 / 1000.0) * 0.2);
1288        }
1289
1290        for i in 0..1000 {
1291            data.push(-10.0 + (i as f32 / 1000.0) * 20.0);
1292        }
1293
1294        let shape = vec![2, 1000];
1295
1296        let per_tensor = QuantizedTensor::from_f32(&data, shape.clone()).unwrap();
1297        let per_tensor_error = per_tensor.quantization_error(&data);
1298
1299        let per_channel = QuantizedTensor::from_f32_per_channel(&data, shape).unwrap();
1300        let per_channel_error = per_channel.quantization_error(&data);
1301
1302        println!("Per-tensor MSE:  {:.8}", per_tensor_error);
1303        println!("Per-channel MSE: {:.8}", per_channel_error);
1304
1305        assert!(
1306            per_channel_error < per_tensor_error,
1307            "Per-channel ({:.8}) should be better than per-tensor ({:.8})",
1308            per_channel_error,
1309            per_tensor_error
1310        );
1311    }
1312
1313    #[test]
1314    fn test_int4_quant_params() {
1315        let params = QuantParamsInt4::from_range(-1.0, 1.0);
1316
1317        assert!(params.quantize(-10.0) >= -8);
1318        assert!(params.quantize(-10.0) <= 7);
1319        assert!(params.quantize(10.0) >= -8);
1320        assert!(params.quantize(10.0) <= 7);
1321
1322        let zero_quant = params.quantize(0.0);
1323        assert!((-8..=7).contains(&zero_quant));
1324
1325        for &original in &[-1.0, -0.5, 0.0, 0.5, 1.0] {
1326            let quantized = params.quantize(original);
1327            let dequantized = params.dequantize(quantized);
1328
1329            println!(
1330                "Original: {:.2}, Quantized: {}, Dequantized: {:.2}, Error: {:.4}",
1331                original,
1332                quantized,
1333                dequantized,
1334                (original - dequantized).abs()
1335            );
1336
1337            assert!((original - dequantized).abs() < params.scale * 2.0);
1338        }
1339    }
1340
1341    #[test]
1342    fn test_int4_extreme_values() {
1343        // Test with extreme value ranges
1344        let params = QuantParamsInt4::from_range(-100.0, 100.0);
1345
1346        let q_neg = params.quantize(-100.0);
1347        let q_pos = params.quantize(100.0);
1348
1349        assert_eq!(q_neg, -8);
1350        assert_eq!(q_pos, 7);
1351    }
1352
1353    #[test]
1354    fn test_int4_vs_int8_error() {
1355        let data = [-1.0, -0.5, 0.0, 0.5, 1.0];
1356
1357        let params_int8 = QuantParams::from_range(-1.0, 1.0);
1358        let error_int8: f32 = data
1359            .iter()
1360            .map(|&v| {
1361                let q = params_int8.quantize(v);
1362                let dq = params_int8.dequantize(q);
1363                (v - dq).powi(2)
1364            })
1365            .sum::<f32>()
1366            / data.len() as f32;
1367
1368        let params_int4 = QuantParamsInt4::from_range(-1.0, 1.0);
1369        let error_int4: f32 = data
1370            .iter()
1371            .map(|&v| {
1372                let q = params_int4.quantize(v);
1373                let dq = params_int4.dequantize(q);
1374                (v - dq).powi(2)
1375            })
1376            .sum::<f32>()
1377            / data.len() as f32;
1378
1379        println!("INT8 MSE: {:.8}", error_int8);
1380        println!("INT4 MSE: {:.8}", error_int4);
1381
1382        assert!(error_int4 > error_int8);
1383
1384        assert!(
1385            error_int4 < error_int8 * 500.0,
1386            "INT4 error ({:.8}) is too high compared to INT8 ({:.8})",
1387            error_int4,
1388            error_int8
1389        );
1390
1391        assert!(error_int4.is_finite());
1392        assert!(error_int4 < 0.01);
1393    }
1394
1395    #[test]
1396    fn test_int4_range() {
1397        let params = QuantParamsInt4::from_range(-1.0, 1.0);
1398
1399        assert!(params.quantize(-10.0) == -8);
1400        assert!(params.quantize(10.0) == 7);
1401
1402        // Test quantization within range
1403        for i in -8..=7 {
1404            let value = i as f32 * params.scale;
1405            let quantized = params.quantize(value);
1406            assert!((-8..=7).contains(&quantized));
1407        }
1408    }
1409
1410    #[test]
1411    fn test_int4_optimal_precision() {
1412        let params = QuantParamsInt4::from_range(-1.0, 1.0);
1413
1414        let mut unique_values = std::collections::HashSet::new();
1415
1416        // Sample across the range
1417        for i in 0..1000 {
1418            let value = -1.0 + (i as f32 / 1000.0) * 2.0;
1419            unique_values.insert(params.quantize(value));
1420        }
1421
1422        println!("Unique quantized values: {}", unique_values.len());
1423        assert!(unique_values.len() >= 14);
1424    }
1425
1426    #[test]
1427    fn test_int4_tensor_quantization() {
1428        let data = vec![0.0, 0.5, 1.0, -0.5, -1.0];
1429        let shape = vec![5];
1430
1431        let quantized = QuantizedTensorInt4::from_f32(&data, shape).unwrap();
1432
1433        assert_eq!(quantized.data.len(), 5);
1434        assert_eq!(quantized.size_bytes(), 5);
1435        assert_eq!(quantized.packed_size_bytes(), 3);
1436
1437        for &val in &quantized.data {
1438            assert!((-8..=7).contains(&val), "Value {} out of INT4 range", val);
1439        }
1440    }
1441
1442    #[test]
1443    fn test_int4_round_trip() {
1444        let original = vec![-1.0, -0.5, 0.0, 0.5, 1.0];
1445        let shape = vec![5];
1446
1447        let quantized = QuantizedTensorInt4::from_f32(&original, shape).unwrap();
1448        let dequantized = quantized.to_f32();
1449
1450        println!("Original:    {:?}", original);
1451        println!("Quantized:   {:?}", quantized.data);
1452        println!("Dequantized: {:?}", dequantized);
1453
1454        for (orig, deq) in original.iter().zip(dequantized.iter()) {
1455            let error = (orig - deq).abs();
1456            println!("  {:.2} -> {:.2}, error: {:.4}", orig, deq, error);
1457            assert!(error < 0.15, "Error too large: {}", error);
1458        }
1459    }
1460
1461    #[test]
1462    fn test_int4_per_channel() {
1463        let mut data = vec![];
1464
1465        // Channel 0: small range [-0.1, 0.1]
1466        for i in 0..100 {
1467            data.push(-0.1 + (i as f32 / 100.0) * 0.2);
1468        }
1469
1470        // Channel 1: large range [-10.0, 10.0]
1471        for i in 0..100 {
1472            data.push(-10.0 + (i as f32 / 100.0) * 20.0);
1473        }
1474
1475        let shape = vec![2, 100];
1476
1477        let quantized = QuantizedTensorInt4::from_f32_per_channel(&data, shape).unwrap();
1478
1479        assert!(quantized.per_channel);
1480        assert!(quantized.channel_params.is_some());
1481        assert_eq!(quantized.channel_params.as_ref().unwrap().len(), 2);
1482
1483        let error = quantized.quantization_error(&data);
1484        println!("INT4 per-channel MSE: {:.8}", error);
1485
1486        assert!(error < 1.0, "Error too high: {}", error);
1487    }
1488
1489    #[test]
1490    fn test_int4_vs_int8_compression() {
1491        let data: Vec<f32> = (0..1000).map(|i| (i as f32 / 1000.0) * 2.0 - 1.0).collect();
1492        let shape = vec![1000];
1493
1494        let int8_quantized = QuantizedTensor::from_f32(&data, shape.clone()).unwrap();
1495        let int8_size = int8_quantized.size_bytes();
1496        let int8_error = int8_quantized.quantization_error(&data);
1497
1498        let int4_quantized = QuantizedTensorInt4::from_f32(&data, shape).unwrap();
1499        let int4_size = int4_quantized.size_bytes();
1500        let int4_packed_size = int4_quantized.packed_size_bytes();
1501        let int4_error = int4_quantized.quantization_error(&data);
1502
1503        println!("INT8: {} bytes, MSE: {:.8}", int8_size, int8_error);
1504        println!(
1505            "INT4 (unpacked): {} bytes, MSE: {:.8}",
1506            int4_size, int4_error
1507        );
1508        println!(
1509            "INT4 (packed): {} bytes, MSE: {:.8}",
1510            int4_packed_size, int4_error
1511        );
1512
1513        assert_eq!(int4_size, int8_size);
1514
1515        assert!(int4_packed_size <= int8_size / 2 + 1);
1516
1517        assert!(int4_error > int8_error);
1518
1519        assert!(int4_error < 0.01, "INT4 error too high: {}", int4_error);
1520    }
1521
1522    #[test]
1523    fn test_int4_large_tensor() {
1524        let size = 64 * 3 * 3 * 3; // 64 filters, 3x3x3 kernels
1525        let data: Vec<f32> = (0..size)
1526            .map(|i| ((i as f32 / size as f32) * 2.0 - 1.0) * 0.5)
1527            .collect();
1528
1529        let shape = vec![64, 3, 3, 3];
1530
1531        let quantized = QuantizedTensorInt4::from_f32_per_channel(&data, shape).unwrap();
1532
1533        assert_eq!(quantized.data.len(), size);
1534        assert_eq!(quantized.channel_params.as_ref().unwrap().len(), 64);
1535
1536        let error = quantized.quantization_error(&data);
1537        println!("Large tensor INT4 error: {:.8}", error);
1538
1539        assert!(error < 0.01, "Error too high for large tensor: {}", error);
1540    }
1541
1542    #[test]
1543    fn test_int4_extreme_ranges() {
1544        let test_cases = vec![
1545            (vec![-0.001, 0.0, 0.001], "tiny range"),
1546            (vec![-100.0, 0.0, 100.0], "large range"),
1547            (vec![0.0, 0.0, 0.0], "all zeros"),
1548            (vec![1.0, 1.0, 1.0], "all same"),
1549        ];
1550
1551        for (data, desc) in test_cases {
1552            println!("\nTesting: {}", desc);
1553            let shape = vec![data.len()];
1554
1555            let result = QuantizedTensorInt4::from_f32(&data, shape);
1556            assert!(result.is_ok(), "Failed on {}", desc);
1557
1558            let quantized = result.unwrap();
1559            let dequantized = quantized.to_f32();
1560
1561            println!("  Original:    {:?}", data);
1562            println!("  Dequantized: {:?}", dequantized);
1563
1564            for &val in &quantized.data {
1565                assert!(
1566                    (-8..=7).contains(&val),
1567                    "Value {} out of range for {}",
1568                    val,
1569                    desc
1570                );
1571            }
1572        }
1573    }
1574
1575    #[test]
1576    fn test_int4_pack_unpack_pair() {
1577        let test_cases = vec![
1578            (-8, 7),
1579            (-8, -8),
1580            (7, 7),
1581            (0, 0),
1582            (-1, 0),
1583            (0, -1),
1584            (-5, 3),
1585            (6, -4),
1586        ];
1587
1588        for (val1, val2) in test_cases {
1589            println!("\nTesting: ({}, {})", val1, val2);
1590
1591            let packed = pack_int4_pair(val1, val2);
1592            let (unpacked1, unpacked2) = unpack_int4_pair(packed);
1593
1594            println!("  Packed: 0x{:02X} (binary: {:08b})", packed, packed);
1595            println!("  Unpacked: ({}, {})", unpacked1, unpacked2);
1596
1597            assert_eq!(val1, unpacked1, "First value mismatch");
1598            assert_eq!(val2, unpacked2, "Second value mismatch");
1599        }
1600    }
1601
1602    #[test]
1603    fn test_int4_pack_unpack_vector() {
1604        let values = vec![-8, -7, -1, 0, 1, 7];
1605        let packed = pack_int4(&values);
1606        let unpacked = unpack_int4(&packed, values.len());
1607
1608        println!("\nEven length:");
1609        println!("  Original: {:?}", values);
1610        println!("  Packed:   {:?} ({} bytes)", packed, packed.len());
1611        println!("  Unpacked: {:?}", unpacked);
1612
1613        assert_eq!(values, unpacked);
1614        assert_eq!(packed.len(), values.len().div_ceil(2));
1615    }
1616
1617    #[test]
1618    fn test_int4_pack_unpack_odd_length() {
1619        let values = vec![-8, -5, 0, 5, 7];
1620        let packed = pack_int4(&values);
1621        let unpacked = unpack_int4(&packed, values.len());
1622
1623        println!("\nOdd length:");
1624        println!("  Original: {:?}", values);
1625        println!("  Packed:   {:?} ({} bytes)", packed, packed.len());
1626        println!("  Unpacked: {:?}", unpacked);
1627
1628        assert_eq!(values, unpacked);
1629        assert_eq!(packed.len(), values.len().div_ceil(2));
1630    }
1631
1632    #[test]
1633    fn test_int4_pack_all_values() {
1634        let values: Vec<i8> = (-8..=7).collect();
1635        let packed = pack_int4(&values);
1636        let unpacked = unpack_int4(&packed, values.len());
1637
1638        println!("\nAll INT4 values:");
1639        println!("  Original: {:?}", values);
1640        println!("  Packed:   {} bytes", packed.len());
1641        println!("  Unpacked: {:?}", unpacked);
1642
1643        assert_eq!(values, unpacked);
1644        assert_eq!(packed.len(), 8);
1645    }
1646
1647    #[test]
1648    fn test_int4_pack_large_vector() {
1649        let values: Vec<i8> = (0..1000).map(|i| ((i % 16) - 8) as i8).collect();
1650        let packed = pack_int4(&values);
1651        let unpacked = unpack_int4(&packed, values.len());
1652
1653        assert_eq!(values, unpacked);
1654        assert_eq!(packed.len(), 500);
1655
1656        println!("\nLarge vector:");
1657        println!("  Original: {} values", values.len());
1658        println!(
1659            "  Packed:   {} bytes ({}x compression)",
1660            packed.len(),
1661            values.len() / packed.len()
1662        );
1663        println!("  Unpacked: {} values", unpacked.len());
1664    }
1665
1666    #[test]
1667    fn test_int4_compression_ratio() {
1668        let size = 10000;
1669        let values: Vec<i8> = (0..size).map(|i| ((i % 16) - 8) as i8).collect();
1670
1671        let unpacked_size = values.len() * std::mem::size_of::<i8>();
1672
1673        let packed = pack_int4(&values);
1674        let packed_size = packed.len();
1675
1676        let compression_ratio = unpacked_size as f32 / packed_size as f32;
1677
1678        println!("\nCompression test:");
1679        println!("  Values:      {}", size);
1680        println!("  Unpacked:    {} bytes", unpacked_size);
1681        println!("  Packed:      {} bytes", packed_size);
1682        println!("  Compression: {:.2}x", compression_ratio);
1683
1684        assert!(
1685            (compression_ratio - 2.0).abs() < 0.01,
1686            "Expected ~2x compression, got {:.2}x",
1687            compression_ratio
1688        );
1689    }
1690
1691    #[test]
1692    fn test_int4_tensor_packing() {
1693        let data: Vec<f32> = (0..1000).map(|i| (i as f32 / 1000.0) * 2.0 - 1.0).collect();
1694        let shape = vec![1000];
1695
1696        let mut quantized = QuantizedTensorInt4::from_f32(&data, shape).unwrap();
1697
1698        println!("Before packing:");
1699        println!("  Unpacked size: {} bytes", quantized.unpacked_size_bytes());
1700        println!("  Is packed: {}", quantized.is_packed());
1701
1702        assert!(!quantized.is_packed());
1703        assert_eq!(quantized.size_bytes(), 1000);
1704
1705        quantized.pack();
1706
1707        println!("\nAfter packing:");
1708        println!("  Packed size: {} bytes", quantized.size_bytes());
1709        println!("  Is packed: {}", quantized.is_packed());
1710        println!(
1711            "  Compression: {}x",
1712            quantized.unpacked_size_bytes() / quantized.size_bytes()
1713        );
1714
1715        assert!(quantized.is_packed());
1716        assert_eq!(quantized.size_bytes(), 500);
1717
1718        let dequantized = quantized.to_f32();
1719        assert_eq!(dequantized.len(), 1000);
1720
1721        let error = quantized.quantization_error(&data);
1722        println!("  MSE after packing: {:.8}", error);
1723        assert!(error < 0.01);
1724    }
1725
1726    #[test]
1727    fn test_int4_packed_vs_unpacked_error() {
1728        let data: Vec<f32> = (0..100).map(|i| (i as f32 / 100.0) * 2.0 - 1.0).collect();
1729        let shape = vec![100];
1730
1731        let unpacked = QuantizedTensorInt4::from_f32(&data, shape.clone()).unwrap();
1732        let error_unpacked = unpacked.quantization_error(&data);
1733
1734        let mut packed = QuantizedTensorInt4::from_f32(&data, shape).unwrap();
1735        packed.pack();
1736        let error_packed = packed.quantization_error(&data);
1737
1738        println!("Unpacked error: {:.8}", error_unpacked);
1739        println!("Packed error:   {:.8}", error_packed);
1740
1741        assert!((error_unpacked - error_packed).abs() < 1e-6);
1742    }
1743
1744    #[test]
1745    fn test_int4_per_channel_packing() {
1746        let mut data = vec![];
1747        for i in 0..500 {
1748            data.push((i as f32 / 500.0) * 0.2 - 0.1); // Channel 0
1749        }
1750        for i in 0..500 {
1751            data.push((i as f32 / 500.0) * 20.0 - 10.0); // Channel 1
1752        }
1753
1754        let shape = vec![2, 500];
1755
1756        let mut quantized = QuantizedTensorInt4::from_f32_per_channel(&data, shape).unwrap();
1757
1758        let error_before = quantized.quantization_error(&data);
1759        println!("Error before packing: {:.8}", error_before);
1760
1761        quantized.pack();
1762
1763        let error_after = quantized.quantization_error(&data);
1764        println!("Error after packing:  {:.8}", error_after);
1765        println!(
1766            "Size: {} bytes (packed from {} bytes)",
1767            quantized.size_bytes(),
1768            quantized.unpacked_size_bytes()
1769        );
1770
1771        assert!((error_before - error_after).abs() < 1e-6);
1772
1773        assert_eq!(quantized.size_bytes(), 500);
1774    }
1775
1776    #[test]
1777    fn test_int4_compression_comparison() {
1778        let size = 10000;
1779        let data: Vec<f32> = (0..size)
1780            .map(|i| ((i as f32 / size as f32) * 2.0 - 1.0) * 0.5)
1781            .collect();
1782        let shape = vec![size];
1783
1784        let fp32_size = size * std::mem::size_of::<f32>();
1785
1786        let int8 = QuantizedTensor::from_f32(&data, shape.clone()).unwrap();
1787        let int8_size = int8.size_bytes();
1788
1789        let int4_unpacked = QuantizedTensorInt4::from_f32(&data, shape.clone()).unwrap();
1790        let int4_unpacked_size = int4_unpacked.size_bytes();
1791
1792        let mut int4_packed = QuantizedTensorInt4::from_f32(&data, shape).unwrap();
1793        int4_packed.pack();
1794        let int4_packed_size = int4_packed.size_bytes();
1795
1796        println!("\nCompression Comparison:");
1797        println!("  FP32:          {} bytes", fp32_size);
1798        println!(
1799            "  INT8:          {} bytes ({:.1}x)",
1800            int8_size,
1801            fp32_size as f32 / int8_size as f32
1802        );
1803        println!(
1804            "  INT4 unpacked: {} bytes ({:.1}x)",
1805            int4_unpacked_size,
1806            fp32_size as f32 / int4_unpacked_size as f32
1807        );
1808        println!(
1809            "  INT4 packed:   {} bytes ({:.1}x)",
1810            int4_packed_size,
1811            fp32_size as f32 / int4_packed_size as f32
1812        );
1813
1814        assert_eq!(fp32_size / int8_size, 4); // 4x compression
1815        assert_eq!(fp32_size / int4_packed_size, 8); // 8x compression!
1816    }
1817
1818    #[test]
1819    #[ignore] // Run manually with: cargo test test_int4_real_model -- --ignored --nocapture
1820    fn test_int4_real_model() {
1821        use crate::onnx_utils::OnnxModel;
1822
1823        println!("\n{}", "=".repeat(60));
1824        println!("INT4 Real Model Test");
1825        println!("\n{}", "=".repeat(60));
1826
1827        let model_paths = vec![
1828            "test_models/mnist.onnx",
1829            "mnist.onnx",
1830            "test_models/resnet18-v1-7.onnx",
1831            "resnet18-v1-7.onnx",
1832        ];
1833
1834        let mut model = None;
1835        for path in &model_paths {
1836            if std::path::Path::new(path).exists() {
1837                println!("Loading model: {}", path);
1838                match OnnxModel::load(path) {
1839                    Ok(m) => {
1840                        model = Some(m);
1841                        break;
1842                    }
1843                    Err(e) => println!("  Failed: {}", e),
1844                }
1845            }
1846        }
1847
1848        let model = match model {
1849            Some(m) => m,
1850            None => {
1851                println!("No test models found. Skipping test.");
1852                println!("Place mnist.onnx or resnet18-v1-7.onnx in current directory.");
1853                return;
1854            }
1855        };
1856
1857        let info = model.info();
1858        println!("✓ Model loaded: {}", info.name);
1859        println!("  Nodes: {}", info.num_nodes);
1860        println!();
1861
1862        println!("Extracting weights...");
1863        let weights = model.extract_weights();
1864        println!("✓ Found {} weight tensors", weights.len());
1865
1866        if weights.is_empty() {
1867            println!("No weights to quantize!");
1868            return;
1869        }
1870
1871        println!();
1872        println!("\n{}", "=".repeat(60));
1873        println!("Testing Per-Tensor Quantization");
1874        println!("\n{}", "=".repeat(60));
1875
1876        let test_weights: Vec<_> = weights
1877            .iter()
1878            .filter(|w| w.data.len() > 1000)
1879            .take(5)
1880            .collect();
1881
1882        println!("Testing {} large layers:\n", test_weights.len());
1883
1884        for (idx, weight) in test_weights.iter().enumerate() {
1885            let name = if weight.name.len() > 40 {
1886                format!("{}...", &weight.name[..37])
1887            } else {
1888                weight.name.clone()
1889            };
1890
1891            println!("[{}] {}", idx + 1, name);
1892            println!(
1893                "    Shape: {:?}, Elements: {}",
1894                weight.shape,
1895                weight.data.len()
1896            );
1897
1898            let fp32_size = weight.data.len() * 4;
1899
1900            let int8_result = QuantizedTensor::from_f32(&weight.data, weight.shape.clone());
1901            let (int8_size, int8_error) = if let Ok(q) = int8_result {
1902                (q.size_bytes(), q.quantization_error(&weight.data))
1903            } else {
1904                println!("    INT8 failed!");
1905                continue;
1906            };
1907
1908            let int4_result = QuantizedTensorInt4::from_f32(&weight.data, weight.shape.clone());
1909            let (int4_unpacked_size, int4_error) = if let Ok(q) = int4_result {
1910                (q.size_bytes(), q.quantization_error(&weight.data))
1911            } else {
1912                println!("    INT4 failed!");
1913                continue;
1914            };
1915
1916            let mut int4_packed =
1917                QuantizedTensorInt4::from_f32(&weight.data, weight.shape.clone()).unwrap();
1918            int4_packed.pack();
1919            let int4_packed_size = int4_packed.size_bytes();
1920            let int4_packed_error = int4_packed.quantization_error(&weight.data);
1921
1922            println!("    FP32:          {:7} bytes", fp32_size);
1923            println!(
1924                "    INT8:          {:7} bytes ({:.1}x) MSE: {:.8}",
1925                int8_size,
1926                fp32_size as f32 / int8_size as f32,
1927                int8_error
1928            );
1929            println!(
1930                "    INT4 unpacked: {:7} bytes ({:.1}x) MSE: {:.8}",
1931                int4_unpacked_size,
1932                fp32_size as f32 / int4_unpacked_size as f32,
1933                int4_error
1934            );
1935            println!(
1936                "    INT4 packed:   {:7} bytes ({:.1}x) MSE: {:.8}",
1937                int4_packed_size,
1938                fp32_size as f32 / int4_packed_size as f32,
1939                int4_packed_error
1940            );
1941
1942            assert_eq!(int4_error, int4_packed_error, "Packing changed error!");
1943
1944            let int8_ratio = fp32_size as f32 / int8_size as f32;
1945            let int4_ratio = fp32_size as f32 / int4_packed_size as f32;
1946
1947            assert!(
1948                (int8_ratio - 4.0).abs() < 0.1,
1949                "INT8 compression should be ~4x"
1950            );
1951            assert!(
1952                (int4_ratio - 8.0).abs() < 0.1,
1953                "INT4 compression should be ~8x"
1954            );
1955
1956            println!();
1957        }
1958
1959        println!("\n{}", "=".repeat(60));
1960        println!("Testing Per-Channel Quantization");
1961        println!("\n{}", "=".repeat(60));
1962
1963        // Test per-channel on Conv layers (multi-dimensional)
1964        let conv_weights: Vec<_> = weights
1965            .iter()
1966            .filter(|w| w.shape.len() >= 2 && w.shape[0] > 1)
1967            .take(3)
1968            .collect();
1969
1970        if conv_weights.is_empty() {
1971            println!("No multi-channel layers found for per-channel test.");
1972        } else {
1973            println!("Testing {} conv layers:\n", conv_weights.len());
1974
1975            for (idx, weight) in conv_weights.iter().enumerate() {
1976                let name = if weight.name.len() > 40 {
1977                    format!("{}...", &weight.name[..37])
1978                } else {
1979                    weight.name.clone()
1980                };
1981
1982                println!("[{}] {}", idx + 1, name);
1983                println!(
1984                    "    Shape: {:?}, Channels: {}",
1985                    weight.shape, weight.shape[0]
1986                );
1987
1988                let per_tensor =
1989                    QuantizedTensorInt4::from_f32(&weight.data, weight.shape.clone()).unwrap();
1990                let per_tensor_error = per_tensor.quantization_error(&weight.data);
1991
1992                let per_channel_result =
1993                    QuantizedTensorInt4::from_f32_per_channel(&weight.data, weight.shape.clone());
1994
1995                if let Ok(per_channel) = per_channel_result {
1996                    let per_channel_error = per_channel.quantization_error(&weight.data);
1997
1998                    let improvement =
1999                        ((per_tensor_error - per_channel_error) / per_tensor_error) * 100.0;
2000
2001                    println!("    Per-tensor:  MSE: {:.8}", per_tensor_error);
2002                    println!(
2003                        "    Per-channel: MSE: {:.8} ({:.1}% better)",
2004                        per_channel_error, improvement
2005                    );
2006
2007                    assert!(
2008                        per_channel_error <= per_tensor_error * 1.1,
2009                        "Per-channel should not be significantly worse"
2010                    );
2011                } else {
2012                    println!("    Per-channel failed!");
2013                }
2014
2015                println!();
2016            }
2017        }
2018
2019        println!("\n{}", "=".repeat(60));
2020        println!("Summary");
2021        println!("\n{}", "=".repeat(60));
2022
2023        println!("✓ INT4 quantization works on real model weights");
2024        println!("✓ Compression ratios correct (4x INT8, 8x INT4)");
2025        println!("✓ Bit packing is lossless");
2026        println!("✓ Per-channel quantization works");
2027        println!("\nINT4 implementation is ready for CLI integration!");
2028    }
2029
2030    // -----------------------------------------------------------------------
2031    // All-NaN / all-Inf edge cases
2032    // -----------------------------------------------------------------------
2033
2034    #[test]
2035    fn test_all_nan_returns_error() {
2036        let data = vec![f32::NAN, f32::NAN, f32::NAN];
2037        let result = QuantizedTensor::from_f32(&data, vec![3]);
2038        assert!(result.is_err());
2039        let err = result.unwrap_err().to_string();
2040        assert!(
2041            err.contains("non-finite"),
2042            "error should mention non-finite: {}",
2043            err
2044        );
2045    }
2046
2047    #[test]
2048    fn test_all_inf_returns_error() {
2049        let data = vec![f32::INFINITY, f32::NEG_INFINITY];
2050        let result = QuantizedTensor::from_f32(&data, vec![2]);
2051        assert!(result.is_err());
2052    }
2053
2054    #[test]
2055    fn test_all_nan_int4_returns_error() {
2056        let data = vec![f32::NAN; 4];
2057        let result = QuantizedTensorInt4::from_f32(&data, vec![4]);
2058        assert!(result.is_err());
2059    }
2060
2061    #[test]
2062    fn test_all_nan_per_channel_returns_error() {
2063        let data = vec![f32::NAN; 6];
2064        let result = QuantizedTensor::from_f32_per_channel(&data, vec![2, 3]);
2065        assert!(result.is_err());
2066        let err = result.unwrap_err().to_string();
2067        assert!(
2068            err.contains("Channel 0"),
2069            "error should mention channel: {}",
2070            err
2071        );
2072    }
2073
2074    #[test]
2075    fn test_mixed_nan_finite_succeeds() {
2076        // Some NaN, some finite — should succeed using finite range
2077        let data = vec![f32::NAN, 1.0, -1.0, f32::NAN];
2078        let result = QuantizedTensor::from_f32(&data, vec![4]);
2079        assert!(result.is_ok());
2080    }
2081
2082    // -----------------------------------------------------------------------
2083    // Symmetric quantization
2084    // -----------------------------------------------------------------------
2085
2086    #[test]
2087    fn test_int8_symmetric_params_zero_point_is_zero() {
2088        let params = QuantParams::from_range_symmetric(-0.5, 2.0);
2089        assert_eq!(params.zero_point(), 0, "symmetric must have zp=0");
2090        // scale = abs_max / QMAX = 2.0 / 127
2091        let expected_scale = 2.0_f32 / 127.0;
2092        assert!(
2093            (params.scale() - expected_scale).abs() < 1e-6,
2094            "scale {} vs expected {}",
2095            params.scale(),
2096            expected_scale
2097        );
2098    }
2099
2100    #[test]
2101    fn test_int4_symmetric_params_zero_point_is_zero() {
2102        let params = QuantParamsInt4::from_range_symmetric(-3.0, 1.0);
2103        assert_eq!(params.zero_point(), 0);
2104        // scale = 3.0 / 7
2105        let expected_scale = 3.0_f32 / 7.0;
2106        assert!((params.scale() - expected_scale).abs() < 1e-6);
2107    }
2108
2109    #[test]
2110    fn test_symmetric_zero_dequantizes_to_zero() {
2111        // The defining property of symmetric quantization: 0.0 → 0 → 0.0 exactly.
2112        let params = QuantParams::from_range_symmetric(-10.0, 10.0);
2113        let q = params.quantize(0.0);
2114        assert_eq!(q, 0);
2115        let dq = params.dequantize(q);
2116        assert_eq!(dq, 0.0);
2117    }
2118
2119    #[test]
2120    fn test_symmetric_asymmetric_produce_different_scales() {
2121        // For a skewed range, asymmetric gives a tighter scale than symmetric.
2122        let asym = QuantParams::from_range(0.0, 10.0);
2123        let sym = QuantParams::from_range_symmetric(0.0, 10.0);
2124        assert_ne!(asym.zero_point(), sym.zero_point());
2125        // Asymmetric packs [0, 10] into [-128, 127] → scale = 10/255 ≈ 0.039
2126        // Symmetric uses abs_max/127 → scale = 10/127 ≈ 0.079
2127        assert!(
2128            sym.scale() > asym.scale(),
2129            "symmetric scale {} should exceed asymmetric {}",
2130            sym.scale(),
2131            asym.scale()
2132        );
2133    }
2134
2135    #[test]
2136    fn test_symmetric_constant_tensor_handled() {
2137        // All-zero tensor would give abs_max = 0 → scale must be clamped away from 0.
2138        let params = QuantParams::from_range_symmetric(0.0, 0.0);
2139        assert!(params.scale() > 0.0);
2140        assert_eq!(params.zero_point(), 0);
2141    }
2142
2143    #[test]
2144    fn test_from_f32_symmetric_tensor_has_zero_zp() {
2145        let data: Vec<f32> = (0..100).map(|i| (i as f32 - 50.0) * 0.1).collect();
2146        let tensor = QuantizedTensor::from_f32_symmetric(&data, vec![100]).unwrap();
2147        assert_eq!(tensor.params().zero_point(), 0);
2148    }
2149
2150    #[test]
2151    fn test_from_f32_per_channel_symmetric_every_channel_zp_zero() {
2152        // 4 channels with different ranges.
2153        let mut data = Vec::new();
2154        for ch in 0..4 {
2155            let scale = (ch + 1) as f32;
2156            for i in 0..16 {
2157                data.push((i as f32 - 8.0) * 0.1 * scale);
2158            }
2159        }
2160        let tensor = QuantizedTensor::from_f32_per_channel_symmetric(&data, vec![4, 16]).unwrap();
2161
2162        let channel_params = tensor
2163            .channel_params
2164            .as_ref()
2165            .expect("per-channel expected");
2166        assert_eq!(channel_params.len(), 4);
2167        for (i, p) in channel_params.iter().enumerate() {
2168            assert_eq!(p.zero_point(), 0, "channel {} zp should be 0", i);
2169            assert!(p.scale() > 0.0, "channel {} scale must be positive", i);
2170        }
2171    }
2172
2173    #[test]
2174    fn test_symmetric_round_trip_error_bounded() {
2175        let data: Vec<f32> = (0..500).map(|i| (i as f32 - 250.0) / 250.0).collect();
2176        let tensor = QuantizedTensor::from_f32_symmetric(&data, vec![500]).unwrap();
2177        let mse = tensor.quantization_error(&data);
2178        // Symmetric INT8 on [-1, 1] should still be very accurate.
2179        assert!(mse < 1e-3, "symmetric MSE unexpectedly high: {}", mse);
2180    }
2181
2182    #[test]
2183    fn test_int4_symmetric_round_trip_error_bounded() {
2184        let data: Vec<f32> = (0..500).map(|i| (i as f32 - 250.0) / 250.0).collect();
2185        let tensor = QuantizedTensorInt4::from_f32_symmetric(&data, vec![500]).unwrap();
2186        let mse = tensor.quantization_error(&data);
2187        // INT4 symmetric has ~15 levels over [-1, 1] → expected MSE < ~0.005
2188        assert!(mse < 0.01, "INT4 symmetric MSE too high: {}", mse);
2189    }
2190
2191    #[test]
2192    fn test_quantizer_symmetric_config_routes_correctly() {
2193        let data: Vec<f32> = (0..64).map(|i| (i as f32 - 32.0) * 0.1).collect();
2194        let config = QuantConfig {
2195            bits: 8,
2196            per_channel: true,
2197            symmetric: true,
2198            ..Default::default()
2199        };
2200        let q = Quantizer::new(config)
2201            .quantize_tensor(&data, vec![4, 16])
2202            .unwrap();
2203        let (_, zero_points) = q.get_all_scales_zero_points();
2204        assert!(
2205            zero_points.iter().all(|&z| z == 0),
2206            "all zero_points must be 0 under symmetric config, got {:?}",
2207            zero_points
2208        );
2209    }
2210}