Skip to main content

ipfrs_tensorlogic/
tensor_quantizer.rs

1//! TensorQuantizer — Multi-precision tensor quantization for model compression.
2//!
3//! Provides production-grade quantization for INT8 (symmetric and asymmetric),
4//! INT4, FP16, and BF16, with per-channel support, calibration-percentile
5//! outlier suppression, and comprehensive MSE error measurement.
6//!
7//! # Examples
8//!
9//! ```
10//! use ipfrs_tensorlogic::tensor_quantizer::{
11//!     TensorQuantizer, QuantizationMode, QuantizerConfig,
12//! };
13//!
14//! let config = QuantizerConfig {
15//!     mode: QuantizationMode::Int8Symmetric,
16//!     per_channel: false,
17//!     channel_dim: 0,
18//!     calibration_percentile: 99.9,
19//! };
20//! let quantizer = TensorQuantizer::new(config);
21//! let values = vec![0.5_f64, -0.3, 0.8, -0.1, 1.0, -1.0];
22//! let dims = vec![6];
23//! let qt = quantizer.quantize(&values, &dims).expect("example: should succeed in docs");
24//! let dq = quantizer.dequantize(&qt).expect("example: should succeed in docs");
25//! assert_eq!(dq.values.len(), 6);
26//! ```
27
28use thiserror::Error;
29
30// ---------------------------------------------------------------------------
31// Error type
32// ---------------------------------------------------------------------------
33
34/// Errors produced by [`TensorQuantizer`].
35#[derive(Debug, Error, Clone, PartialEq)]
36pub enum QuantizerError {
37    /// The input slice is empty.
38    #[error("Input tensor is empty")]
39    EmptyInput,
40
41    /// The flat values length does not match the product of `dims`.
42    #[error("Dimension mismatch: values.len()={values_len} != product(dims)={dims_product}")]
43    DimensionMismatch {
44        /// Actual length of values slice.
45        values_len: usize,
46        /// Expected length from dimensions product.
47        dims_product: usize,
48    },
49
50    /// Percentile `p` was not in `[0, 100]`.
51    #[error("Invalid percentile {0}: must be in [0, 100]")]
52    InvalidPercentile(f64),
53
54    /// The `dims` slice is empty (zero-rank tensor).
55    #[error("Dims must be non-empty (scalar tensors are not supported)")]
56    InvalidDims,
57}
58
59// ---------------------------------------------------------------------------
60// QuantizationMode
61// ---------------------------------------------------------------------------
62
63/// Precision target for quantization.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
65pub enum QuantizationMode {
66    /// Symmetric INT8: `scale = percentile(|x|) / 127.0`; range `[-127, 127]`.
67    Int8Symmetric,
68    /// Asymmetric INT8: separate zero-point; range `[0, 255]`.
69    Int8Asymmetric,
70    /// 4-bit: `scale = percentile(|x|) / 7.0`; range `[-7, 7]` stored as i8.
71    Int4,
72    /// FP16 simulation: round to nearest f16 (5-bit exp, 10-bit mantissa).
73    Fp16,
74    /// BFloat16 simulation: keep top 16 bits of the f32 representation.
75    Bf16,
76}
77
78impl QuantizationMode {
79    /// Human-readable name for statistics reporting.
80    pub fn name(&self) -> &'static str {
81        match self {
82            Self::Int8Symmetric => "Int8Symmetric",
83            Self::Int8Asymmetric => "Int8Asymmetric",
84            Self::Int4 => "Int4",
85            Self::Fp16 => "Fp16",
86            Self::Bf16 => "Bf16",
87        }
88    }
89
90    /// Nominal storage bits per element (for compression ratio).
91    pub fn bits_per_element(&self) -> f64 {
92        match self {
93            Self::Int8Symmetric | Self::Int8Asymmetric => 8.0,
94            Self::Int4 => 4.0,
95            Self::Fp16 | Self::Bf16 => 16.0,
96        }
97    }
98}
99
100// ---------------------------------------------------------------------------
101// QuantizerConfig
102// ---------------------------------------------------------------------------
103
104/// Configuration for [`TensorQuantizer`].
105#[derive(Debug, Clone)]
106pub struct QuantizerConfig {
107    /// Quantization precision target.
108    pub mode: QuantizationMode,
109    /// When `true`, compute one scale/zero-point per slice along `channel_dim`.
110    pub per_channel: bool,
111    /// The axis along which channels are defined (used only when `per_channel` is `true`).
112    pub channel_dim: usize,
113    /// Upper percentile used for calibration (e.g. `99.9` suppresses top-0.1% outliers).
114    /// Must be in `[0.0, 100.0]`.
115    pub calibration_percentile: f64,
116}
117
118impl Default for QuantizerConfig {
119    fn default() -> Self {
120        Self {
121            mode: QuantizationMode::Int8Symmetric,
122            per_channel: false,
123            channel_dim: 0,
124            calibration_percentile: 99.9,
125        }
126    }
127}
128
129// ---------------------------------------------------------------------------
130// QuantizedTensor / DequantizedTensor
131// ---------------------------------------------------------------------------
132
133/// A quantized representation of a tensor.
134///
135/// `data` encoding per mode:
136/// - `Int8Symmetric` / `Int8Asymmetric` / `Int4`: i8 cast to i32.
137/// - `Fp16` / `Bf16`: u16 bits cast to i32.
138#[derive(Debug, Clone)]
139pub struct QuantizedTensor {
140    /// Quantization mode used to produce this tensor.
141    pub mode: QuantizationMode,
142    /// Quantized integer data (one entry per original element).
143    pub data: Vec<i32>,
144    /// Global (or per-channel, packed) scale factor(s).
145    pub scale: f64,
146    /// Global (or per-channel average) zero-point.
147    pub zero_point: i32,
148    /// Original tensor dimensions.
149    pub original_dims: Vec<usize>,
150    /// Observed minimum value before quantization.
151    pub original_min: f64,
152    /// Observed maximum value before quantization.
153    pub original_max: f64,
154    /// Per-channel scales (empty when `per_channel = false`).
155    pub(crate) channel_scales: Vec<f64>,
156    /// Per-channel zero-points (empty when `per_channel = false`).
157    pub(crate) channel_zero_points: Vec<i32>,
158}
159
160/// The result of dequantization — approximate reconstruction of the original values.
161#[derive(Debug, Clone)]
162pub struct DequantizedTensor {
163    /// Reconstructed f64 values.
164    pub values: Vec<f64>,
165    /// Tensor dimensions (same as the original).
166    pub dims: Vec<usize>,
167}
168
169// ---------------------------------------------------------------------------
170// QuantizerStats
171// ---------------------------------------------------------------------------
172
173/// Accumulated statistics across multiple [`TensorQuantizer::quantize`] calls.
174#[derive(Debug, Clone, Default)]
175pub struct QuantizerStats {
176    /// Total number of scalar elements quantized.
177    pub elements_quantized: usize,
178    /// Weighted average compression ratio across all calls.
179    pub avg_compression_ratio: f64,
180    /// Weighted average MSE quantization error across all calls.
181    pub avg_quantization_error: f64,
182    /// Unique mode names encountered (in insertion order).
183    pub modes_used: Vec<String>,
184
185    // Internal accumulators for weighted averages.
186    total_cr_weight: f64,
187    total_cr_sum: f64,
188    total_err_weight: f64,
189    total_err_sum: f64,
190}
191
192impl QuantizerStats {
193    fn record(&mut self, n: usize, cr: f64, err: f64, mode_name: &str) {
194        self.elements_quantized += n;
195
196        let w = n as f64;
197
198        self.total_cr_sum += cr * w;
199        self.total_cr_weight += w;
200        self.avg_compression_ratio = if self.total_cr_weight > 0.0 {
201            self.total_cr_sum / self.total_cr_weight
202        } else {
203            0.0
204        };
205
206        self.total_err_sum += err * w;
207        self.total_err_weight += w;
208        self.avg_quantization_error = if self.total_err_weight > 0.0 {
209            self.total_err_sum / self.total_err_weight
210        } else {
211            0.0
212        };
213
214        let mode_str = mode_name.to_string();
215        if !self.modes_used.contains(&mode_str) {
216            self.modes_used.push(mode_str);
217        }
218    }
219}
220
221// ---------------------------------------------------------------------------
222// Helper: percentile
223// ---------------------------------------------------------------------------
224
225/// Compute the `p`-th percentile of `values` using the nearest-rank method.
226///
227/// `p` must be in `[0, 100]`.  Returns `Err(QuantizerError::InvalidPercentile)`
228/// otherwise.  Returns `Err(QuantizerError::EmptyInput)` for an empty slice.
229pub fn percentile(values: &[f64], p: f64) -> Result<f64, QuantizerError> {
230    if !(0.0..=100.0).contains(&p) {
231        return Err(QuantizerError::InvalidPercentile(p));
232    }
233    if values.is_empty() {
234        return Err(QuantizerError::EmptyInput);
235    }
236    let mut sorted = values.to_vec();
237    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
238    let n = sorted.len();
239    if n == 1 {
240        return Ok(sorted[0]);
241    }
242    // Nearest-rank: index = ceil(p/100 * n) - 1, clamped.
243    let idx = if p == 0.0 {
244        0
245    } else {
246        let raw = (p / 100.0 * n as f64).ceil() as usize;
247        raw.saturating_sub(1).min(n - 1)
248    };
249    Ok(sorted[idx])
250}
251
252// ---------------------------------------------------------------------------
253// Core quantizer logic (per-tensor helpers)
254// ---------------------------------------------------------------------------
255
256struct ScaleZp {
257    scale: f64,
258    zero_point: i32,
259}
260
261/// Compute scale (and zero-point) from calibrated abs-max for the given mode.
262fn compute_scale_zp(
263    abs_values: &[f64],
264    mode: QuantizationMode,
265    calib_pct: f64,
266) -> Result<ScaleZp, QuantizerError> {
267    let p = percentile(abs_values, calib_pct)?;
268    match mode {
269        QuantizationMode::Int8Symmetric => {
270            let scale = if p == 0.0 { 1.0 } else { p / 127.0 };
271            Ok(ScaleZp {
272                scale,
273                zero_point: 0,
274            })
275        }
276        QuantizationMode::Int8Asymmetric => {
277            let max_val = p;
278            let min_val = -p;
279            let range = max_val - min_val;
280            let scale = if range == 0.0 { 1.0 } else { range / 255.0 };
281            let zero_point = (-min_val / scale).round().clamp(0.0, 255.0) as i32;
282            Ok(ScaleZp { scale, zero_point })
283        }
284        QuantizationMode::Int4 => {
285            let scale = if p == 0.0 { 1.0 } else { p / 7.0 };
286            Ok(ScaleZp {
287                scale,
288                zero_point: 0,
289            })
290        }
291        // FP16/BF16 do not use scale/zero-point in the classical sense.
292        QuantizationMode::Fp16 | QuantizationMode::Bf16 => Ok(ScaleZp {
293            scale: 1.0,
294            zero_point: 0,
295        }),
296    }
297}
298
299fn quantize_element(x: f64, mode: QuantizationMode, scale: f64, zero_point: i32) -> i32 {
300    match mode {
301        QuantizationMode::Int8Symmetric => (x / scale).round().clamp(-127.0, 127.0) as i32,
302        QuantizationMode::Int8Asymmetric => {
303            ((x / scale).round() + zero_point as f64).clamp(0.0, 255.0) as i32
304        }
305        QuantizationMode::Int4 => (x / scale).round().clamp(-7.0, 7.0) as i32,
306        QuantizationMode::Fp16 => {
307            // Simulate FP16: round to nearest 1/1024.
308            // Clamp to FP16 representable range (~65504).
309            let clamped = x.clamp(-65504.0, 65504.0);
310            let quantized = (clamped * 1024.0).round();
311            // Store as i32 (representing a scaled integer, reconstructed by /1024).
312            quantized as i32
313        }
314        QuantizationMode::Bf16 => {
315            // Keep top 16 bits of f32 representation.
316            let bits = (x as f32).to_bits();
317            let bf16_bits = (bits >> 16) as u16;
318            bf16_bits as i32
319        }
320    }
321}
322
323fn dequantize_element(q: i32, mode: QuantizationMode, scale: f64, zero_point: i32) -> f64 {
324    match mode {
325        QuantizationMode::Int8Symmetric => q as f64 * scale,
326        QuantizationMode::Int8Asymmetric => (q - zero_point) as f64 * scale,
327        QuantizationMode::Int4 => q as f64 * scale,
328        QuantizationMode::Fp16 => {
329            // Stored as (x * 1024).round() → reconstruct by /1024.
330            q as f64 / 1024.0
331        }
332        QuantizationMode::Bf16 => {
333            // Stored as u16 bits → reconstruct f32 by shifting back.
334            let bf16_bits = q as u16;
335            let f32_bits = (bf16_bits as u32) << 16;
336            f32::from_bits(f32_bits) as f64
337        }
338    }
339}
340
341// ---------------------------------------------------------------------------
342// TensorQuantizer
343// ---------------------------------------------------------------------------
344
345/// Multi-precision tensor quantizer.
346///
347/// Supports INT8 (symmetric/asymmetric), INT4, FP16, and BF16 quantization
348/// with optional per-channel calibration and percentile-based outlier suppression.
349pub struct TensorQuantizer {
350    config: QuantizerConfig,
351    stats: QuantizerStats,
352}
353
354impl TensorQuantizer {
355    /// Create a new quantizer with the given configuration.
356    pub fn new(config: QuantizerConfig) -> Self {
357        Self {
358            config,
359            stats: QuantizerStats::default(),
360        }
361    }
362
363    /// Read-only access to accumulated statistics.
364    pub fn stats(&self) -> &QuantizerStats {
365        &self.stats
366    }
367
368    /// Reset accumulated statistics.
369    pub fn reset_stats(&mut self) {
370        self.stats = QuantizerStats::default();
371    }
372
373    /// Quantize a flat tensor described by `dims`.
374    ///
375    /// Returns a [`QuantizedTensor`] whose `data` encodes the elements in the
376    /// same order as `values`.
377    pub fn quantize(
378        &mut self,
379        values: &[f64],
380        dims: &[usize],
381    ) -> Result<QuantizedTensor, QuantizerError> {
382        // --- Validation -------------------------------------------------------
383        if dims.is_empty() {
384            return Err(QuantizerError::InvalidDims);
385        }
386        if values.is_empty() {
387            return Err(QuantizerError::EmptyInput);
388        }
389        let expected: usize = dims.iter().product();
390        if values.len() != expected {
391            return Err(QuantizerError::DimensionMismatch {
392                values_len: values.len(),
393                dims_product: expected,
394            });
395        }
396
397        let original_min = values.iter().cloned().fold(f64::INFINITY, f64::min);
398        let original_max = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
399
400        let qt = if self.config.per_channel {
401            self.quantize_per_channel(values, dims, original_min, original_max)?
402        } else {
403            self.quantize_per_tensor(values, dims, original_min, original_max)?
404        };
405
406        // Update stats.
407        let n = values.len();
408        let cr = Self::compression_ratio(n, &self.config.mode);
409        // Compute MSE for stats (best effort; ignore errors).
410        let err = self.quantization_error_internal(values, &qt).unwrap_or(0.0);
411        self.stats.record(n, cr, err, self.config.mode.name());
412
413        Ok(qt)
414    }
415
416    fn quantize_per_tensor(
417        &self,
418        values: &[f64],
419        dims: &[usize],
420        original_min: f64,
421        original_max: f64,
422    ) -> Result<QuantizedTensor, QuantizerError> {
423        let abs_values: Vec<f64> = values.iter().map(|x| x.abs()).collect();
424        let szp = compute_scale_zp(
425            &abs_values,
426            self.config.mode,
427            self.config.calibration_percentile,
428        )?;
429
430        let data: Vec<i32> = values
431            .iter()
432            .map(|&x| quantize_element(x, self.config.mode, szp.scale, szp.zero_point))
433            .collect();
434
435        Ok(QuantizedTensor {
436            mode: self.config.mode,
437            data,
438            scale: szp.scale,
439            zero_point: szp.zero_point,
440            original_dims: dims.to_vec(),
441            original_min,
442            original_max,
443            channel_scales: Vec::new(),
444            channel_zero_points: Vec::new(),
445        })
446    }
447
448    fn quantize_per_channel(
449        &self,
450        values: &[f64],
451        dims: &[usize],
452        original_min: f64,
453        original_max: f64,
454    ) -> Result<QuantizedTensor, QuantizerError> {
455        let channel_dim = self.config.channel_dim;
456        if channel_dim >= dims.len() {
457            // Fallback to per-tensor if channel_dim is out of range.
458            return self.quantize_per_tensor(values, dims, original_min, original_max);
459        }
460
461        let num_channels = dims[channel_dim];
462        // Elements per channel = total / num_channels.
463        let total = values.len();
464        let per_channel = total / num_channels;
465
466        // For each channel index c, gather elements where the channel_dim index == c.
467        // We treat the tensor as C-contiguous (row-major). For a shape [d0, d1, ..., dN],
468        // the channel axis contribution repeats every product(dims[channel_dim+1..]) elements.
469        let inner: usize = dims[channel_dim + 1..].iter().product();
470
471        let mut channel_scales = vec![1.0f64; num_channels];
472        let mut channel_zero_points = vec![0i32; num_channels];
473        let mut data = vec![0i32; total];
474
475        for c in 0..num_channels {
476            // Collect elements belonging to channel c.
477            let channel_vals: Vec<f64> = (0..total)
478                .filter(|&idx| {
479                    // Index along channel_dim for flat index idx.
480                    let stride: usize = if channel_dim + 1 < dims.len() {
481                        inner
482                    } else {
483                        1
484                    };
485                    (idx / stride) % num_channels == c
486                })
487                .map(|idx| values[idx])
488                .collect();
489
490            if channel_vals.is_empty() {
491                continue;
492            }
493
494            let abs_vals: Vec<f64> = channel_vals.iter().map(|x| x.abs()).collect();
495            let szp = compute_scale_zp(
496                &abs_vals,
497                self.config.mode,
498                self.config.calibration_percentile,
499            )?;
500            channel_scales[c] = szp.scale;
501            channel_zero_points[c] = szp.zero_point;
502
503            // Write quantized elements back in-place.
504            let stride = inner;
505            let mut local_idx = 0usize;
506            for (idx, slot) in data.iter_mut().enumerate() {
507                let ch_idx = (idx / stride) % num_channels;
508                if ch_idx == c {
509                    *slot = quantize_element(
510                        channel_vals[local_idx],
511                        self.config.mode,
512                        szp.scale,
513                        szp.zero_point,
514                    );
515                    local_idx += 1;
516                }
517            }
518        }
519
520        // Global scale = mean of channel scales.
521        let global_scale = if num_channels > 0 {
522            channel_scales.iter().sum::<f64>() / num_channels as f64
523        } else {
524            1.0
525        };
526        let global_zp = if num_channels > 0 {
527            (channel_zero_points.iter().map(|&z| z as i64).sum::<i64>() / num_channels as i64)
528                as i32
529        } else {
530            0
531        };
532
533        // Sanity: `per_channel` should have produced `total` elements.
534        let _ = per_channel; // consumed above
535
536        Ok(QuantizedTensor {
537            mode: self.config.mode,
538            data,
539            scale: global_scale,
540            zero_point: global_zp,
541            original_dims: dims.to_vec(),
542            original_min,
543            original_max,
544            channel_scales,
545            channel_zero_points,
546        })
547    }
548
549    /// Reconstruct approximate f64 values from a [`QuantizedTensor`].
550    pub fn dequantize(&self, qt: &QuantizedTensor) -> Result<DequantizedTensor, QuantizerError> {
551        if qt.data.is_empty() {
552            return Err(QuantizerError::EmptyInput);
553        }
554
555        let values = if !qt.channel_scales.is_empty() {
556            // Per-channel dequantization.
557            let num_channels = qt.channel_scales.len();
558            let inner: usize = if qt.original_dims.len() > 1 {
559                let channel_dim = self.config.channel_dim.min(qt.original_dims.len() - 1);
560                qt.original_dims[channel_dim + 1..].iter().product()
561            } else {
562                1
563            };
564            let stride = inner;
565
566            qt.data
567                .iter()
568                .enumerate()
569                .map(|(idx, &q)| {
570                    let ch_idx = (idx / stride) % num_channels;
571                    let s = qt.channel_scales.get(ch_idx).copied().unwrap_or(qt.scale);
572                    let zp = qt
573                        .channel_zero_points
574                        .get(ch_idx)
575                        .copied()
576                        .unwrap_or(qt.zero_point);
577                    dequantize_element(q, qt.mode, s, zp)
578                })
579                .collect()
580        } else {
581            // Per-tensor dequantization.
582            qt.data
583                .iter()
584                .map(|&q| dequantize_element(q, qt.mode, qt.scale, qt.zero_point))
585                .collect()
586        };
587
588        Ok(DequantizedTensor {
589            values,
590            dims: qt.original_dims.clone(),
591        })
592    }
593
594    /// Compute mean-squared error between the original values and the
595    /// dequantized reconstruction.
596    pub fn quantization_error(
597        &self,
598        original: &[f64],
599        qt: &QuantizedTensor,
600    ) -> Result<f64, QuantizerError> {
601        self.quantization_error_internal(original, qt)
602    }
603
604    fn quantization_error_internal(
605        &self,
606        original: &[f64],
607        qt: &QuantizedTensor,
608    ) -> Result<f64, QuantizerError> {
609        if original.is_empty() {
610            return Err(QuantizerError::EmptyInput);
611        }
612        if original.len() != qt.data.len() {
613            return Err(QuantizerError::DimensionMismatch {
614                values_len: original.len(),
615                dims_product: qt.data.len(),
616            });
617        }
618        let dq = self.dequantize(qt)?;
619        let mse = original
620            .iter()
621            .zip(dq.values.iter())
622            .map(|(&a, &b)| (a - b).powi(2))
623            .sum::<f64>()
624            / original.len() as f64;
625        Ok(mse)
626    }
627
628    /// Compression ratio relative to f64 (64-bit) storage.
629    ///
630    /// - f64 = 64 bits → ratio = 64 / bits_per_element.
631    pub fn compression_ratio(original_len: usize, mode: &QuantizationMode) -> f64 {
632        let _ = original_len; // ratio is per-element, length-independent
633        64.0 / mode.bits_per_element()
634    }
635
636    /// Clamp `x` to the representable integer range for `mode`.
637    pub fn clamp_to_range(x: f64, mode: &QuantizationMode) -> f64 {
638        match mode {
639            QuantizationMode::Int8Symmetric => x.clamp(-127.0, 127.0),
640            QuantizationMode::Int8Asymmetric => x.clamp(0.0, 255.0),
641            QuantizationMode::Int4 => x.clamp(-7.0, 7.0),
642            QuantizationMode::Fp16 => x.clamp(-65504.0, 65504.0),
643            // BF16 shares the f32 representable range.
644            QuantizationMode::Bf16 => x.clamp(f32::MIN as f64, f32::MAX as f64),
645        }
646    }
647}
648
649// ---------------------------------------------------------------------------
650// Tests
651// ---------------------------------------------------------------------------
652
653#[cfg(test)]
654mod tests {
655    use super::{percentile, QuantizationMode, QuantizerConfig, QuantizerError, TensorQuantizer};
656
657    // -----------------------------------------------------------------------
658    // Helper
659    // -----------------------------------------------------------------------
660    fn default_quantizer(mode: QuantizationMode) -> TensorQuantizer {
661        TensorQuantizer::new(QuantizerConfig {
662            mode,
663            per_channel: false,
664            channel_dim: 0,
665            calibration_percentile: 99.9,
666        })
667    }
668
669    fn mse(a: &[f64], b: &[f64]) -> f64 {
670        assert_eq!(a.len(), b.len());
671        a.iter()
672            .zip(b.iter())
673            .map(|(&x, &y)| (x - y).powi(2))
674            .sum::<f64>()
675            / a.len() as f64
676    }
677
678    // -----------------------------------------------------------------------
679    // percentile tests
680    // -----------------------------------------------------------------------
681
682    #[test]
683    fn test_percentile_single_element() {
684        let v = vec![42.0];
685        assert_eq!(percentile(&v, 50.0).expect("test: should succeed"), 42.0);
686        assert_eq!(percentile(&v, 0.0).expect("test: should succeed"), 42.0);
687        assert_eq!(percentile(&v, 100.0).expect("test: should succeed"), 42.0);
688    }
689
690    #[test]
691    fn test_percentile_sorted_five() {
692        let v = vec![1.0, 2.0, 3.0, 4.0, 5.0];
693        // 0th → index 0 = 1.0
694        assert_eq!(percentile(&v, 0.0).expect("test: should succeed"), 1.0);
695        // 100th → index 4 = 5.0
696        assert_eq!(percentile(&v, 100.0).expect("test: should succeed"), 5.0);
697        // 50th: ceil(0.5 * 5) = 3 → index 2 = 3.0
698        assert_eq!(percentile(&v, 50.0).expect("test: should succeed"), 3.0);
699    }
700
701    #[test]
702    fn test_percentile_unsorted() {
703        let v = vec![5.0, 1.0, 3.0, 2.0, 4.0];
704        assert_eq!(percentile(&v, 100.0).expect("test: should succeed"), 5.0);
705        assert_eq!(percentile(&v, 0.0).expect("test: should succeed"), 1.0);
706    }
707
708    #[test]
709    fn test_percentile_invalid() {
710        let v = vec![1.0, 2.0];
711        assert_eq!(
712            percentile(&v, -1.0).unwrap_err(),
713            QuantizerError::InvalidPercentile(-1.0)
714        );
715        assert_eq!(
716            percentile(&v, 101.0).unwrap_err(),
717            QuantizerError::InvalidPercentile(101.0)
718        );
719    }
720
721    #[test]
722    fn test_percentile_empty() {
723        assert_eq!(
724            percentile(&[], 50.0).unwrap_err(),
725            QuantizerError::EmptyInput
726        );
727    }
728
729    // -----------------------------------------------------------------------
730    // Validation / error tests
731    // -----------------------------------------------------------------------
732
733    #[test]
734    fn test_empty_input_error() {
735        let mut q = default_quantizer(QuantizationMode::Int8Symmetric);
736        assert_eq!(
737            q.quantize(&[], &[0]).unwrap_err(),
738            QuantizerError::EmptyInput
739        );
740    }
741
742    #[test]
743    fn test_empty_dims_error() {
744        let mut q = default_quantizer(QuantizationMode::Int8Symmetric);
745        assert_eq!(
746            q.quantize(&[1.0], &[]).unwrap_err(),
747            QuantizerError::InvalidDims
748        );
749    }
750
751    #[test]
752    fn test_dimension_mismatch_error() {
753        let mut q = default_quantizer(QuantizationMode::Int8Symmetric);
754        let err = q.quantize(&[1.0, 2.0, 3.0], &[2]).unwrap_err();
755        assert!(matches!(err, QuantizerError::DimensionMismatch { .. }));
756    }
757
758    // -----------------------------------------------------------------------
759    // INT8 Symmetric
760    // -----------------------------------------------------------------------
761
762    #[test]
763    fn test_int8sym_quantize_roundtrip() {
764        let mut q = default_quantizer(QuantizationMode::Int8Symmetric);
765        let values = vec![1.0_f64, -1.0, 0.5, -0.5, 0.0];
766        let qt = q.quantize(&values, &[5]).expect("test: should succeed");
767        assert_eq!(qt.mode, QuantizationMode::Int8Symmetric);
768        assert_eq!(qt.data.len(), 5);
769        let dq = q.dequantize(&qt).expect("test: should succeed");
770        assert_eq!(dq.values.len(), 5);
771        // Zero should survive perfectly.
772        assert!((dq.values[4] - 0.0).abs() < 0.02);
773    }
774
775    #[test]
776    fn test_int8sym_scale_calculation() {
777        let mut q = default_quantizer(QuantizationMode::Int8Symmetric);
778        let values: Vec<f64> = (1..=127).map(|x| x as f64).collect();
779        let qt = q.quantize(&values, &[127]).expect("test: should succeed");
780        // scale ≈ 1.0 because max ≈ 127 and scale = max/127.
781        assert!((qt.scale - 1.0).abs() < 0.01, "scale={}", qt.scale);
782    }
783
784    #[test]
785    fn test_int8sym_clamp() {
786        let mut q = default_quantizer(QuantizationMode::Int8Symmetric);
787        // Large outlier clamped to 127 (or -127).
788        let values = vec![100.0, -100.0, 1000.0, -1000.0];
789        let qt = q.quantize(&values, &[4]).expect("test: should succeed");
790        for &v in &qt.data {
791            assert!((-127..=127).contains(&v), "out of range: {v}");
792        }
793    }
794
795    #[test]
796    fn test_int8sym_zero_point_is_zero() {
797        let mut q = default_quantizer(QuantizationMode::Int8Symmetric);
798        let values = vec![0.1, 0.5, -0.3];
799        let qt = q.quantize(&values, &[3]).expect("test: should succeed");
800        assert_eq!(qt.zero_point, 0);
801    }
802
803    #[test]
804    fn test_int8sym_mse_low() {
805        let mut q = default_quantizer(QuantizationMode::Int8Symmetric);
806        let values: Vec<f64> = (0..256).map(|i| (i as f64 / 128.0) - 1.0).collect();
807        let qt = q.quantize(&values, &[256]).expect("test: should succeed");
808        let err = q
809            .quantization_error(&values, &qt)
810            .expect("test: should succeed");
811        // INT8 should give very low MSE on uniform range.
812        assert!(err < 1e-4, "MSE too high: {err}");
813    }
814
815    // -----------------------------------------------------------------------
816    // INT8 Asymmetric
817    // -----------------------------------------------------------------------
818
819    #[test]
820    fn test_int8asym_roundtrip() {
821        let mut q = default_quantizer(QuantizationMode::Int8Asymmetric);
822        let values = vec![0.2_f64, 0.5, -0.5, 0.0, 0.8];
823        let qt = q.quantize(&values, &[5]).expect("test: should succeed");
824        assert_eq!(qt.mode, QuantizationMode::Int8Asymmetric);
825        let dq = q.dequantize(&qt).expect("test: should succeed");
826        let err = mse(&values, &dq.values);
827        assert!(err < 1e-4, "MSE too high: {err}");
828    }
829
830    #[test]
831    fn test_int8asym_data_range() {
832        let mut q = default_quantizer(QuantizationMode::Int8Asymmetric);
833        let values = vec![-1.0, 0.0, 0.5, 1.0];
834        let qt = q.quantize(&values, &[4]).expect("test: should succeed");
835        for &v in &qt.data {
836            assert!((0..=255).contains(&v), "out of [0,255]: {v}");
837        }
838    }
839
840    #[test]
841    fn test_int8asym_zero_point_nonzero() {
842        let mut q = default_quantizer(QuantizationMode::Int8Asymmetric);
843        let values = vec![-1.0, 1.0];
844        let qt = q.quantize(&values, &[2]).expect("test: should succeed");
845        // For a symmetric range [-1,1] the zero_point should be ~128.
846        assert!(qt.zero_point > 0, "zero_point={}", qt.zero_point);
847    }
848
849    // -----------------------------------------------------------------------
850    // INT4
851    // -----------------------------------------------------------------------
852
853    #[test]
854    fn test_int4_data_range() {
855        let mut q = default_quantizer(QuantizationMode::Int4);
856        let values = vec![-1.0, 0.0, 0.5, -0.5, 1.0, 0.25];
857        let qt = q.quantize(&values, &[6]).expect("test: should succeed");
858        for &v in &qt.data {
859            assert!((-7..=7).contains(&v), "out of [-7,7]: {v}");
860        }
861    }
862
863    #[test]
864    fn test_int4_roundtrip() {
865        let mut q = default_quantizer(QuantizationMode::Int4);
866        let values: Vec<f64> = (-7..=7).map(|x| x as f64 * 0.1).collect();
867        let qt = q.quantize(&values, &[15]).expect("test: should succeed");
868        let dq = q.dequantize(&qt).expect("test: should succeed");
869        let err = mse(&values, &dq.values);
870        assert!(err < 1e-3, "MSE={err}");
871    }
872
873    #[test]
874    fn test_int4_scale() {
875        let mut q = default_quantizer(QuantizationMode::Int4);
876        let values = vec![7.0, -7.0, 3.5];
877        let qt = q.quantize(&values, &[3]).expect("test: should succeed");
878        // scale ≈ 7/7 = 1.0.
879        assert!((qt.scale - 1.0).abs() < 0.01, "scale={}", qt.scale);
880    }
881
882    // -----------------------------------------------------------------------
883    // FP16
884    // -----------------------------------------------------------------------
885
886    #[test]
887    fn test_fp16_roundtrip_small() {
888        let mut q = default_quantizer(QuantizationMode::Fp16);
889        let values = vec![1.0_f64, 0.5, -0.5, 0.25, -0.25];
890        let qt = q.quantize(&values, &[5]).expect("test: should succeed");
891        assert_eq!(qt.mode, QuantizationMode::Fp16);
892        let dq = q.dequantize(&qt).expect("test: should succeed");
893        for (&orig, &rec) in values.iter().zip(dq.values.iter()) {
894            // FP16 round-trip precision ~3 decimal digits.
895            assert!((orig - rec).abs() < 0.002, "orig={orig} rec={rec}");
896        }
897    }
898
899    #[test]
900    fn test_fp16_clamp_large() {
901        let mut q = default_quantizer(QuantizationMode::Fp16);
902        // Values beyond FP16 range are clamped.
903        let values = vec![1e6_f64, -1e6];
904        let qt = q.quantize(&values, &[2]).expect("test: should succeed");
905        let dq = q.dequantize(&qt).expect("test: should succeed");
906        // Clamped to ±65504.
907        assert!(dq.values[0] <= 65504.1);
908        assert!(dq.values[1] >= -65504.1);
909    }
910
911    #[test]
912    fn test_fp16_zero() {
913        let mut q = default_quantizer(QuantizationMode::Fp16);
914        let values = vec![0.0_f64];
915        let qt = q.quantize(&values, &[1]).expect("test: should succeed");
916        let dq = q.dequantize(&qt).expect("test: should succeed");
917        assert_eq!(dq.values[0], 0.0);
918    }
919
920    #[test]
921    fn test_fp16_data_stored_as_scaled_int() {
922        let mut q = default_quantizer(QuantizationMode::Fp16);
923        let values = vec![1.0_f64];
924        let qt = q.quantize(&values, &[1]).expect("test: should succeed");
925        // 1.0 * 1024 = 1024.
926        assert_eq!(qt.data[0], 1024);
927    }
928
929    // -----------------------------------------------------------------------
930    // BF16
931    // -----------------------------------------------------------------------
932
933    #[test]
934    fn test_bf16_roundtrip() {
935        let mut q = default_quantizer(QuantizationMode::Bf16);
936        let values = vec![1.0_f64, 0.5, -0.5, std::f64::consts::PI, -2.71];
937        let qt = q.quantize(&values, &[5]).expect("test: should succeed");
938        assert_eq!(qt.mode, QuantizationMode::Bf16);
939        let dq = q.dequantize(&qt).expect("test: should succeed");
940        // BF16 has 7 mantissa bits, so ~2 decimal digit precision.
941        for (&orig, &rec) in values.iter().zip(dq.values.iter()) {
942            let rel_err = if orig.abs() > 1e-9 {
943                (orig - rec).abs() / orig.abs()
944            } else {
945                (orig - rec).abs()
946            };
947            assert!(rel_err < 0.02, "orig={orig} rec={rec} rel_err={rel_err}");
948        }
949    }
950
951    #[test]
952    fn test_bf16_stores_u16_bits() {
953        let mut q = default_quantizer(QuantizationMode::Bf16);
954        let values = vec![1.0_f64];
955        let qt = q.quantize(&values, &[1]).expect("test: should succeed");
956        // f32 bits of 1.0 = 0x3F800000; >> 16 = 0x3F80 = 16256.
957        assert_eq!(qt.data[0], 0x3F80i32, "bf16 bits={}", qt.data[0]);
958    }
959
960    #[test]
961    fn test_bf16_zero() {
962        let mut q = default_quantizer(QuantizationMode::Bf16);
963        let values = vec![0.0_f64];
964        let qt = q.quantize(&values, &[1]).expect("test: should succeed");
965        let dq = q.dequantize(&qt).expect("test: should succeed");
966        assert_eq!(dq.values[0], 0.0);
967    }
968
969    // -----------------------------------------------------------------------
970    // Compression ratio
971    // -----------------------------------------------------------------------
972
973    #[test]
974    fn test_compression_ratio_int8() {
975        let cr = TensorQuantizer::compression_ratio(100, &QuantizationMode::Int8Symmetric);
976        assert!((cr - 8.0).abs() < 1e-10, "cr={cr}");
977    }
978
979    #[test]
980    fn test_compression_ratio_int4() {
981        let cr = TensorQuantizer::compression_ratio(100, &QuantizationMode::Int4);
982        assert!((cr - 16.0).abs() < 1e-10, "cr={cr}");
983    }
984
985    #[test]
986    fn test_compression_ratio_fp16() {
987        let cr = TensorQuantizer::compression_ratio(100, &QuantizationMode::Fp16);
988        assert!((cr - 4.0).abs() < 1e-10, "cr={cr}");
989    }
990
991    #[test]
992    fn test_compression_ratio_bf16() {
993        let cr = TensorQuantizer::compression_ratio(100, &QuantizationMode::Bf16);
994        assert!((cr - 4.0).abs() < 1e-10, "cr={cr}");
995    }
996
997    // -----------------------------------------------------------------------
998    // clamp_to_range
999    // -----------------------------------------------------------------------
1000
1001    #[test]
1002    fn test_clamp_int8sym() {
1003        assert_eq!(
1004            TensorQuantizer::clamp_to_range(200.0, &QuantizationMode::Int8Symmetric),
1005            127.0
1006        );
1007        assert_eq!(
1008            TensorQuantizer::clamp_to_range(-200.0, &QuantizationMode::Int8Symmetric),
1009            -127.0
1010        );
1011    }
1012
1013    #[test]
1014    fn test_clamp_int8asym() {
1015        assert_eq!(
1016            TensorQuantizer::clamp_to_range(-1.0, &QuantizationMode::Int8Asymmetric),
1017            0.0
1018        );
1019        assert_eq!(
1020            TensorQuantizer::clamp_to_range(300.0, &QuantizationMode::Int8Asymmetric),
1021            255.0
1022        );
1023    }
1024
1025    #[test]
1026    fn test_clamp_int4() {
1027        assert_eq!(
1028            TensorQuantizer::clamp_to_range(10.0, &QuantizationMode::Int4),
1029            7.0
1030        );
1031        assert_eq!(
1032            TensorQuantizer::clamp_to_range(-10.0, &QuantizationMode::Int4),
1033            -7.0
1034        );
1035    }
1036
1037    #[test]
1038    fn test_clamp_fp16() {
1039        assert_eq!(
1040            TensorQuantizer::clamp_to_range(1e10, &QuantizationMode::Fp16),
1041            65504.0
1042        );
1043        assert_eq!(
1044            TensorQuantizer::clamp_to_range(-1e10, &QuantizationMode::Fp16),
1045            -65504.0
1046        );
1047    }
1048
1049    // -----------------------------------------------------------------------
1050    // quantization_error
1051    // -----------------------------------------------------------------------
1052
1053    #[test]
1054    fn test_quantization_error_zero_tensor() {
1055        let mut q = default_quantizer(QuantizationMode::Int8Symmetric);
1056        let values = vec![0.0_f64; 16];
1057        let qt = q.quantize(&values, &[16]).expect("test: should succeed");
1058        let err = q
1059            .quantization_error(&values, &qt)
1060            .expect("test: should succeed");
1061        assert_eq!(err, 0.0);
1062    }
1063
1064    #[test]
1065    fn test_quantization_error_length_mismatch() {
1066        let mut q = default_quantizer(QuantizationMode::Int8Symmetric);
1067        let values = vec![1.0, 2.0, 3.0];
1068        let qt = q.quantize(&values, &[3]).expect("test: should succeed");
1069        let err = q.quantization_error(&[1.0, 2.0], &qt);
1070        assert!(matches!(err, Err(QuantizerError::DimensionMismatch { .. })));
1071    }
1072
1073    #[test]
1074    fn test_quantization_error_bf16_low() {
1075        let mut q = default_quantizer(QuantizationMode::Bf16);
1076        let values: Vec<f64> = (0..64).map(|i| i as f64 * 0.01).collect();
1077        let qt = q.quantize(&values, &[64]).expect("test: should succeed");
1078        let err = q
1079            .quantization_error(&values, &qt)
1080            .expect("test: should succeed");
1081        // BF16 precision should give MSE < 1e-6 for small values.
1082        assert!(err < 1e-4, "MSE={err}");
1083    }
1084
1085    // -----------------------------------------------------------------------
1086    // Per-channel quantization
1087    // -----------------------------------------------------------------------
1088
1089    #[test]
1090    fn test_per_channel_produces_channel_scales() {
1091        let config = QuantizerConfig {
1092            mode: QuantizationMode::Int8Symmetric,
1093            per_channel: true,
1094            channel_dim: 0,
1095            calibration_percentile: 99.9,
1096        };
1097        let mut q = TensorQuantizer::new(config);
1098        // 2 channels, 4 elements each → shape [2, 4].
1099        let values = vec![1.0, 2.0, 3.0, 4.0, 0.1, 0.2, 0.3, 0.4];
1100        let qt = q.quantize(&values, &[2, 4]).expect("test: should succeed");
1101        assert_eq!(qt.channel_scales.len(), 2);
1102        // Different channels should have different scales.
1103        assert!(
1104            (qt.channel_scales[0] - qt.channel_scales[1]).abs() > 0.01,
1105            "scales equal: {:?}",
1106            qt.channel_scales
1107        );
1108    }
1109
1110    #[test]
1111    fn test_per_channel_dequantize() {
1112        let config = QuantizerConfig {
1113            mode: QuantizationMode::Int8Symmetric,
1114            per_channel: true,
1115            channel_dim: 0,
1116            calibration_percentile: 100.0,
1117        };
1118        let mut q = TensorQuantizer::new(config);
1119        let values = vec![10.0, 20.0, 30.0, 40.0, 1.0, 2.0, 3.0, 4.0];
1120        let qt = q.quantize(&values, &[2, 4]).expect("test: should succeed");
1121        let dq = q.dequantize(&qt).expect("test: should succeed");
1122        assert_eq!(dq.values.len(), 8);
1123        // Approximate reconstruction — INT8 should be close.
1124        let err = mse(&values, &dq.values);
1125        assert!(err < 1.0, "MSE={err}");
1126    }
1127
1128    // -----------------------------------------------------------------------
1129    // Stats tracking
1130    // -----------------------------------------------------------------------
1131
1132    #[test]
1133    fn test_stats_accumulate() {
1134        let mut q = default_quantizer(QuantizationMode::Int8Symmetric);
1135        let v1 = vec![1.0, 2.0, 3.0];
1136        let v2 = vec![4.0, 5.0, 6.0, 7.0, 8.0];
1137        q.quantize(&v1, &[3]).expect("test: should succeed");
1138        q.quantize(&v2, &[5]).expect("test: should succeed");
1139        let stats = q.stats();
1140        assert_eq!(stats.elements_quantized, 8);
1141        assert_eq!(stats.modes_used, vec!["Int8Symmetric"]);
1142        assert!(stats.avg_compression_ratio > 0.0);
1143    }
1144
1145    #[test]
1146    fn test_stats_reset() {
1147        let mut q = default_quantizer(QuantizationMode::Int8Symmetric);
1148        q.quantize(&[1.0, 2.0], &[2]).expect("test: should succeed");
1149        q.reset_stats();
1150        let stats = q.stats();
1151        assert_eq!(stats.elements_quantized, 0);
1152        assert!(stats.modes_used.is_empty());
1153    }
1154
1155    #[test]
1156    fn test_stats_multiple_modes_if_changed() {
1157        // Verify that mode name is only recorded once even for multiple calls.
1158        let mut q = default_quantizer(QuantizationMode::Int4);
1159        q.quantize(&[1.0, 2.0], &[2]).expect("test: should succeed");
1160        q.quantize(&[3.0, 4.0], &[2]).expect("test: should succeed");
1161        assert_eq!(q.stats().modes_used.len(), 1);
1162        assert_eq!(q.stats().modes_used[0], "Int4");
1163    }
1164
1165    // -----------------------------------------------------------------------
1166    // Multi-dim tensor (2-D)
1167    // -----------------------------------------------------------------------
1168
1169    #[test]
1170    fn test_2d_tensor_int8sym() {
1171        let mut q = default_quantizer(QuantizationMode::Int8Symmetric);
1172        let values: Vec<f64> = (0..12).map(|i| i as f64 * 0.1).collect();
1173        let qt = q.quantize(&values, &[3, 4]).expect("test: should succeed");
1174        assert_eq!(qt.original_dims, vec![3, 4]);
1175        assert_eq!(qt.data.len(), 12);
1176        let dq = q.dequantize(&qt).expect("test: should succeed");
1177        assert_eq!(dq.dims, vec![3, 4]);
1178    }
1179
1180    // -----------------------------------------------------------------------
1181    // Edge: all zeros
1182    // -----------------------------------------------------------------------
1183
1184    #[test]
1185    fn test_all_zeros_int8sym() {
1186        let mut q = default_quantizer(QuantizationMode::Int8Symmetric);
1187        let values = vec![0.0_f64; 8];
1188        let qt = q.quantize(&values, &[8]).expect("test: should succeed");
1189        // scale defaults to 1.0 when max=0.
1190        assert_eq!(qt.scale, 1.0);
1191        let dq = q.dequantize(&qt).expect("test: should succeed");
1192        for v in &dq.values {
1193            assert_eq!(*v, 0.0);
1194        }
1195    }
1196
1197    #[test]
1198    fn test_all_zeros_bf16() {
1199        let mut q = default_quantizer(QuantizationMode::Bf16);
1200        let values = vec![0.0_f64; 4];
1201        let qt = q.quantize(&values, &[4]).expect("test: should succeed");
1202        let dq = q.dequantize(&qt).expect("test: should succeed");
1203        for v in &dq.values {
1204            assert_eq!(*v, 0.0);
1205        }
1206    }
1207
1208    // -----------------------------------------------------------------------
1209    // original_min / original_max preserved
1210    // -----------------------------------------------------------------------
1211
1212    #[test]
1213    fn test_original_min_max_preserved() {
1214        let mut q = default_quantizer(QuantizationMode::Int8Symmetric);
1215        let values = vec![-3.5_f64, 0.0, 7.2];
1216        let qt = q.quantize(&values, &[3]).expect("test: should succeed");
1217        assert!((qt.original_min - (-3.5)).abs() < 1e-10);
1218        assert!((qt.original_max - 7.2).abs() < 1e-10);
1219    }
1220
1221    // -----------------------------------------------------------------------
1222    // Calibration percentile: 100 vs 50 should produce different scales
1223    // -----------------------------------------------------------------------
1224
1225    #[test]
1226    fn test_calibration_percentile_effect() {
1227        let values: Vec<f64> = (1..=100).map(|x| x as f64).collect();
1228
1229        let mut q99 = TensorQuantizer::new(QuantizerConfig {
1230            mode: QuantizationMode::Int8Symmetric,
1231            calibration_percentile: 99.9,
1232            ..QuantizerConfig::default()
1233        });
1234        let mut q50 = TensorQuantizer::new(QuantizerConfig {
1235            mode: QuantizationMode::Int8Symmetric,
1236            calibration_percentile: 50.0,
1237            ..QuantizerConfig::default()
1238        });
1239
1240        let qt99 = q99.quantize(&values, &[100]).expect("test: should succeed");
1241        let qt50 = q50.quantize(&values, &[100]).expect("test: should succeed");
1242
1243        assert!(
1244            qt50.scale < qt99.scale,
1245            "scale_50={} scale_99={}",
1246            qt50.scale,
1247            qt99.scale
1248        );
1249    }
1250}