Skip to main content

trustformers_optim/
per_layer_quant.rs

1//! Per-layer quantization bit-width selection.
2//!
3//! Assigns different quantization bit-widths to different layers based on
4//! sensitivity analysis, memory budget constraints, and configurable strategies.
5
6// ─────────────────────────────────────────── BitWidth ────────────────────────
7
8/// Quantization bit-widths supported for model layers.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
10pub enum BitWidth {
11    Int2 = 2,
12    Int4 = 4,
13    Int8 = 8,
14    Fp16 = 16,
15    Fp32 = 32,
16}
17
18impl BitWidth {
19    /// Number of bytes consumed per weight element at this precision.
20    pub fn bytes_per_weight(&self) -> f64 {
21        match self {
22            Self::Int2 => 0.25, // 2 bits = 0.25 bytes
23            Self::Int4 => 0.5,  // 4 bits = 0.5 bytes
24            Self::Int8 => 1.0,
25            Self::Fp16 => 2.0,
26            Self::Fp32 => 4.0,
27        }
28    }
29
30    /// Compression ratio relative to Fp32 (higher = more compressed).
31    pub fn compression_ratio_vs_fp32(&self) -> f64 {
32        4.0 / self.bytes_per_weight()
33    }
34}
35
36impl std::fmt::Display for BitWidth {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        match self {
39            Self::Int2 => write!(f, "INT2"),
40            Self::Int4 => write!(f, "INT4"),
41            Self::Int8 => write!(f, "INT8"),
42            Self::Fp16 => write!(f, "FP16"),
43            Self::Fp32 => write!(f, "FP32"),
44        }
45    }
46}
47
48// ─────────────────────────────────────────── LayerSensitivity ────────────────
49
50/// Sensitivity metric for a model layer — higher score means more sensitive to quantization.
51#[derive(Debug, Clone)]
52pub struct LayerSensitivity {
53    pub layer_name: String,
54    /// Average gradient norm during training.
55    pub gradient_norm: f64,
56    /// Variance of weight values.
57    pub weight_variance: f64,
58    /// Activation range: max − min of activations.
59    pub activation_range: f64,
60    /// How much output changes per unit weight change.
61    pub output_sensitivity: f64,
62    /// Embedding layers usually require higher precision.
63    pub is_embedding: bool,
64    /// Final layers usually require higher precision.
65    pub is_final_layer: bool,
66}
67
68impl LayerSensitivity {
69    /// Compute a composite sensitivity score for this layer.
70    ///
71    /// Formula: `gradient_norm * 0.4 + weight_variance * 0.3 + activation_range * 0.2 + output_sensitivity * 0.1`
72    ///
73    /// Bonuses: embeddings +1.0, final layer +0.5.
74    pub fn sensitivity_score(&self) -> f64 {
75        let base = self.gradient_norm * 0.4
76            + self.weight_variance * 0.3
77            + self.activation_range * 0.2
78            + self.output_sensitivity * 0.1;
79
80        let embed_bonus = if self.is_embedding { 1.0 } else { 0.0 };
81        let final_bonus = if self.is_final_layer { 0.5 } else { 0.0 };
82
83        base + embed_bonus + final_bonus
84    }
85}
86
87// ─────────────────────────────────────────── BitWidthStrategy ────────────────
88
89/// Strategy for assigning bit-widths to layers.
90#[derive(Debug, Clone)]
91pub enum BitWidthStrategy {
92    /// Same bit-width for all layers.
93    Uniform(BitWidth),
94    /// More sensitive layers get higher precision.
95    SensitivityBased {
96        /// Score above this → Fp16.
97        high_threshold: f64,
98        /// Score above this → Int8.
99        medium_threshold: f64,
100        /// Score below this → Int2.
101        low_threshold: f64,
102    },
103    /// Maximize compression while meeting a memory budget.
104    BudgetOptimal,
105}
106
107// ─────────────────────────────────────────── QuantizationPolicy ──────────────
108
109/// Policy controlling how per-layer quantization bit-widths are assigned.
110#[derive(Debug, Clone)]
111pub struct QuantizationPolicy {
112    pub strategy: BitWidthStrategy,
113    /// Total memory budget in bytes (used for BudgetOptimal strategy).
114    pub budget_bytes: Option<usize>,
115    /// Never assign a bit-width below this floor.
116    pub min_bit_width: BitWidth,
117    /// Never assign a bit-width above this ceiling.
118    pub max_bit_width: BitWidth,
119}
120
121impl Default for QuantizationPolicy {
122    fn default() -> Self {
123        Self {
124            strategy: BitWidthStrategy::Uniform(BitWidth::Int8),
125            budget_bytes: None,
126            min_bit_width: BitWidth::Int4,
127            max_bit_width: BitWidth::Fp32,
128        }
129    }
130}
131
132// ─────────────────────────────────────────── LayerBitWidthAssignment ─────────
133
134/// The resolved bit-width assignment for a single layer.
135#[derive(Debug, Clone)]
136pub struct LayerBitWidthAssignment {
137    pub layer_name: String,
138    pub bit_width: BitWidth,
139    pub param_count: usize,
140    /// Memory footprint for this layer at the assigned bit-width.
141    pub memory_bytes: usize,
142    pub sensitivity_score: f64,
143}
144
145// ─────────────────────────────────────────── QuantizationSummary ─────────────
146
147/// Human-readable summary of a quantization assignment.
148pub struct QuantizationSummary {
149    pub total_params: usize,
150    pub total_memory_bytes: usize,
151    pub compression_ratio: f64,
152    /// `(bit_width, layer_count)` pairs, sorted ascending by bit-width.
153    pub bit_width_distribution: Vec<(BitWidth, usize)>,
154    pub avg_sensitivity_score: f64,
155}
156
157impl std::fmt::Display for QuantizationSummary {
158    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159        writeln!(f, "QuantizationSummary {{")?;
160        writeln!(f, "  total_params: {}", self.total_params)?;
161        writeln!(f, "  total_memory_bytes: {}", self.total_memory_bytes)?;
162        writeln!(f, "  compression_ratio: {:.2}x", self.compression_ratio)?;
163        writeln!(
164            f,
165            "  avg_sensitivity_score: {:.4}",
166            self.avg_sensitivity_score
167        )?;
168        write!(f, "  bit_width_distribution: [")?;
169        for (i, (bw, count)) in self.bit_width_distribution.iter().enumerate() {
170            if i > 0 {
171                write!(f, ", ")?;
172            }
173            write!(f, "{}×{}", bw, count)?;
174        }
175        writeln!(f, "]")?;
176        write!(f, "}}")
177    }
178}
179
180// ─────────────────────────────────────────── QuantSelectionError ─────────────
181
182/// Errors arising from per-layer quantization selection.
183#[derive(Debug, thiserror::Error)]
184pub enum QuantSelectionError {
185    #[error("Budget exceeded: required {required} bytes, budget {budget} bytes")]
186    BudgetExceeded { required: usize, budget: usize },
187    #[error("Empty layer list")]
188    EmptyLayers,
189    #[error("Layer count mismatch")]
190    LengthMismatch,
191}
192
193// ─────────────────────────────────────────── PerLayerQuantSelector ───────────
194
195/// Selects per-layer quantization bit-widths according to a [`QuantizationPolicy`].
196pub struct PerLayerQuantSelector {
197    policy: QuantizationPolicy,
198}
199
200impl PerLayerQuantSelector {
201    /// Create a new selector with the given policy.
202    pub fn new(policy: QuantizationPolicy) -> Self {
203        Self { policy }
204    }
205
206    /// Assign bit-widths to each layer.
207    ///
208    /// `layers` and `layer_param_counts` must be the same length.
209    pub fn assign_bit_widths(
210        &self,
211        layers: &[LayerSensitivity],
212        layer_param_counts: &[usize],
213    ) -> Result<Vec<LayerBitWidthAssignment>, QuantSelectionError> {
214        if layers.is_empty() {
215            return Err(QuantSelectionError::EmptyLayers);
216        }
217        if layers.len() != layer_param_counts.len() {
218            return Err(QuantSelectionError::LengthMismatch);
219        }
220
221        let assignments = match &self.policy.strategy {
222            BitWidthStrategy::Uniform(bw) => self.assign_uniform(*bw, layers, layer_param_counts),
223            BitWidthStrategy::SensitivityBased {
224                high_threshold,
225                medium_threshold,
226                low_threshold,
227            } => self.assign_sensitivity_based(
228                *high_threshold,
229                *medium_threshold,
230                *low_threshold,
231                layers,
232                layer_param_counts,
233            ),
234            BitWidthStrategy::BudgetOptimal => {
235                self.assign_budget_optimal(layers, layer_param_counts)?
236            },
237        };
238
239        // Validate budget if provided.
240        if let Some(budget) = self.policy.budget_bytes {
241            let required = Self::total_memory_bytes(&assignments);
242            if required > budget {
243                return Err(QuantSelectionError::BudgetExceeded { required, budget });
244            }
245        }
246
247        Ok(assignments)
248    }
249
250    /// Clamp a bit-width to the policy's [min, max] range.
251    fn clamp_bit_width(&self, bw: BitWidth) -> BitWidth {
252        if bw < self.policy.min_bit_width {
253            self.policy.min_bit_width
254        } else if bw > self.policy.max_bit_width {
255            self.policy.max_bit_width
256        } else {
257            bw
258        }
259    }
260
261    fn make_assignment(
262        &self,
263        layer: &LayerSensitivity,
264        param_count: usize,
265        bit_width: BitWidth,
266    ) -> LayerBitWidthAssignment {
267        let bw = self.clamp_bit_width(bit_width);
268        let memory_bytes = (param_count as f64 * bw.bytes_per_weight()).ceil() as usize;
269        LayerBitWidthAssignment {
270            layer_name: layer.layer_name.clone(),
271            bit_width: bw,
272            param_count,
273            memory_bytes,
274            sensitivity_score: layer.sensitivity_score(),
275        }
276    }
277
278    fn assign_uniform(
279        &self,
280        bw: BitWidth,
281        layers: &[LayerSensitivity],
282        counts: &[usize],
283    ) -> Vec<LayerBitWidthAssignment> {
284        layers
285            .iter()
286            .zip(counts.iter())
287            .map(|(l, &c)| self.make_assignment(l, c, bw))
288            .collect()
289    }
290
291    fn assign_sensitivity_based(
292        &self,
293        high_threshold: f64,
294        medium_threshold: f64,
295        low_threshold: f64,
296        layers: &[LayerSensitivity],
297        counts: &[usize],
298    ) -> Vec<LayerBitWidthAssignment> {
299        layers
300            .iter()
301            .zip(counts.iter())
302            .map(|(l, &c)| {
303                let score = l.sensitivity_score();
304                let bw = if score > high_threshold {
305                    BitWidth::Fp16
306                } else if score > medium_threshold {
307                    BitWidth::Int8
308                } else if score < low_threshold {
309                    BitWidth::Int2
310                } else {
311                    BitWidth::Int4
312                };
313                self.make_assignment(l, c, bw)
314            })
315            .collect()
316    }
317
318    /// Assign bit-widths to minimize memory while respecting the budget.
319    ///
320    /// Strategy: start with Int2 for all layers, then upgrade the most
321    /// sensitive layers one step at a time as long as the budget allows.
322    fn assign_budget_optimal(
323        &self,
324        layers: &[LayerSensitivity],
325        counts: &[usize],
326    ) -> Result<Vec<LayerBitWidthAssignment>, QuantSelectionError> {
327        // Start with minimum bit-width for each layer.
328        let mut bit_widths: Vec<BitWidth> = vec![self.policy.min_bit_width; layers.len()];
329
330        let budget = match self.policy.budget_bytes {
331            Some(b) => b,
332            None => usize::MAX, // No budget: assign max precision to all.
333        };
334
335        // Compute sensitivity scores and sort layer indices by descending sensitivity.
336        let mut sorted_indices: Vec<usize> = (0..layers.len()).collect();
337        sorted_indices.sort_by(|&a, &b| {
338            layers[b]
339                .sensitivity_score()
340                .partial_cmp(&layers[a].sensitivity_score())
341                .unwrap_or(std::cmp::Ordering::Equal)
342        });
343
344        let precision_ladder = [
345            BitWidth::Int2,
346            BitWidth::Int4,
347            BitWidth::Int8,
348            BitWidth::Fp16,
349            BitWidth::Fp32,
350        ];
351
352        // Iteratively upgrade the highest-sensitivity layers.
353        // Each iteration tries a single upgrade; if none is possible we stop.
354        loop {
355            let maybe_upgrade = sorted_indices.iter().find_map(|&idx| {
356                let current = bit_widths[idx];
357                let next_bw = precision_ladder
358                    .iter()
359                    .find(|&&bw| bw > current && bw <= self.policy.max_bit_width)
360                    .copied()?;
361
362                let old_mem = (counts[idx] as f64 * current.bytes_per_weight()).ceil() as usize;
363                let new_mem = (counts[idx] as f64 * next_bw.bytes_per_weight()).ceil() as usize;
364                let current_total: usize = bit_widths
365                    .iter()
366                    .zip(counts.iter())
367                    .map(|(&bw, &c)| (c as f64 * bw.bytes_per_weight()).ceil() as usize)
368                    .sum();
369                let proposed_total = current_total - old_mem + new_mem;
370                if proposed_total <= budget {
371                    Some((idx, next_bw))
372                } else {
373                    None
374                }
375            });
376
377            match maybe_upgrade {
378                Some((idx, next_bw)) => bit_widths[idx] = next_bw,
379                None => break,
380            }
381        }
382
383        Ok(layers
384            .iter()
385            .zip(counts.iter())
386            .enumerate()
387            .map(|(i, (l, &c))| self.make_assignment(l, c, bit_widths[i]))
388            .collect())
389    }
390
391    /// Total memory footprint in bytes across all assignments.
392    pub fn total_memory_bytes(assignments: &[LayerBitWidthAssignment]) -> usize {
393        assignments.iter().map(|a| a.memory_bytes).sum()
394    }
395
396    /// Generate a human-readable quantization summary.
397    pub fn summary_report(assignments: &[LayerBitWidthAssignment]) -> QuantizationSummary {
398        let total_params: usize = assignments.iter().map(|a| a.param_count).sum();
399        let total_memory_bytes: usize = assignments.iter().map(|a| a.memory_bytes).sum();
400
401        let fp32_bytes = total_params * 4; // 4 bytes per param at FP32
402        let compression_ratio = if total_memory_bytes == 0 {
403            1.0
404        } else {
405            fp32_bytes as f64 / total_memory_bytes as f64
406        };
407
408        let avg_sensitivity_score = if assignments.is_empty() {
409            0.0
410        } else {
411            assignments.iter().map(|a| a.sensitivity_score).sum::<f64>() / assignments.len() as f64
412        };
413
414        // Count layers per bit-width.
415        let mut dist_map: std::collections::HashMap<u8, usize> = std::collections::HashMap::new();
416        for a in assignments.iter() {
417            *dist_map.entry(a.bit_width as u8).or_insert(0) += 1;
418        }
419        let mut bit_width_distribution: Vec<(BitWidth, usize)> = dist_map
420            .into_iter()
421            .filter_map(|(bits, count)| {
422                let bw = match bits {
423                    2 => Some(BitWidth::Int2),
424                    4 => Some(BitWidth::Int4),
425                    8 => Some(BitWidth::Int8),
426                    16 => Some(BitWidth::Fp16),
427                    32 => Some(BitWidth::Fp32),
428                    _ => None,
429                };
430                bw.map(|b| (b, count))
431            })
432            .collect();
433        bit_width_distribution.sort_by_key(|(bw, _)| *bw as u8);
434
435        QuantizationSummary {
436            total_params,
437            total_memory_bytes,
438            compression_ratio,
439            bit_width_distribution,
440            avg_sensitivity_score,
441        }
442    }
443}
444
445// ─────────────────────────────────────────── tests ───────────────────────────
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450
451    fn make_layer(
452        name: &str,
453        gradient_norm: f64,
454        weight_variance: f64,
455        activation_range: f64,
456        output_sensitivity: f64,
457        is_embedding: bool,
458        is_final_layer: bool,
459    ) -> LayerSensitivity {
460        LayerSensitivity {
461            layer_name: name.to_string(),
462            gradient_norm,
463            weight_variance,
464            activation_range,
465            output_sensitivity,
466            is_embedding,
467            is_final_layer,
468        }
469    }
470
471    fn simple_layers() -> (Vec<LayerSensitivity>, Vec<usize>) {
472        let layers = vec![
473            make_layer("embed", 2.0, 1.0, 1.0, 1.0, true, false),
474            make_layer("attn", 1.5, 0.8, 0.5, 0.5, false, false),
475            make_layer("ffn", 0.5, 0.3, 0.2, 0.1, false, false),
476            make_layer("head", 1.0, 0.6, 0.4, 0.4, false, true),
477        ];
478        let counts = vec![10_000, 8_000, 16_000, 4_000];
479        (layers, counts)
480    }
481
482    // ── 1. BitWidth bytes_per_weight ──────────────────────────────────────────
483
484    #[test]
485    fn test_bytes_per_weight() {
486        assert!((BitWidth::Int2.bytes_per_weight() - 0.25).abs() < 1e-10);
487        assert!((BitWidth::Int4.bytes_per_weight() - 0.5).abs() < 1e-10);
488        assert!((BitWidth::Int8.bytes_per_weight() - 1.0).abs() < 1e-10);
489        assert!((BitWidth::Fp16.bytes_per_weight() - 2.0).abs() < 1e-10);
490        assert!((BitWidth::Fp32.bytes_per_weight() - 4.0).abs() < 1e-10);
491    }
492
493    // ── 2. BitWidth compression_ratio_vs_fp32 ────────────────────────────────
494
495    #[test]
496    fn test_compression_ratio() {
497        assert!((BitWidth::Fp32.compression_ratio_vs_fp32() - 1.0).abs() < 1e-10);
498        assert!((BitWidth::Fp16.compression_ratio_vs_fp32() - 2.0).abs() < 1e-10);
499        assert!((BitWidth::Int8.compression_ratio_vs_fp32() - 4.0).abs() < 1e-10);
500        assert!((BitWidth::Int4.compression_ratio_vs_fp32() - 8.0).abs() < 1e-10);
501        assert!((BitWidth::Int2.compression_ratio_vs_fp32() - 16.0).abs() < 1e-10);
502    }
503
504    // ── 3. sensitivity_score basic formula ────────────────────────────────────
505
506    #[test]
507    fn test_sensitivity_score_basic() {
508        let layer = make_layer("l", 1.0, 1.0, 1.0, 1.0, false, false);
509        // 1.0*0.4 + 1.0*0.3 + 1.0*0.2 + 1.0*0.1 = 1.0
510        let score = layer.sensitivity_score();
511        assert!((score - 1.0).abs() < 1e-10, "expected 1.0 got {}", score);
512    }
513
514    // ── 4. sensitivity_score embedding bonus ─────────────────────────────────
515
516    #[test]
517    fn test_sensitivity_score_embedding_bonus() {
518        let no_embed = make_layer("l", 1.0, 1.0, 1.0, 1.0, false, false);
519        let embed = make_layer("e", 1.0, 1.0, 1.0, 1.0, true, false);
520        let diff = embed.sensitivity_score() - no_embed.sensitivity_score();
521        assert!((diff - 1.0).abs() < 1e-10, "embedding bonus should be +1.0");
522    }
523
524    // ── 5. sensitivity_score final_layer bonus ────────────────────────────────
525
526    #[test]
527    fn test_sensitivity_score_final_layer_bonus() {
528        let normal = make_layer("l", 1.0, 1.0, 1.0, 1.0, false, false);
529        let final_l = make_layer("f", 1.0, 1.0, 1.0, 1.0, false, true);
530        let diff = final_l.sensitivity_score() - normal.sensitivity_score();
531        assert!(
532            (diff - 0.5).abs() < 1e-10,
533            "final layer bonus should be +0.5"
534        );
535    }
536
537    // ── 6. sensitivity_score both bonuses ─────────────────────────────────────
538
539    #[test]
540    fn test_sensitivity_score_both_bonuses() {
541        let layer = make_layer("l", 1.0, 1.0, 1.0, 1.0, true, true);
542        // base 1.0 + embed 1.0 + final 0.5 = 2.5
543        assert!((layer.sensitivity_score() - 2.5).abs() < 1e-10);
544    }
545
546    // ── 7. Uniform strategy ───────────────────────────────────────────────────
547
548    #[test]
549    fn test_uniform_strategy() {
550        let policy = QuantizationPolicy {
551            strategy: BitWidthStrategy::Uniform(BitWidth::Int8),
552            budget_bytes: None,
553            min_bit_width: BitWidth::Int2,
554            max_bit_width: BitWidth::Fp32,
555        };
556        let selector = PerLayerQuantSelector::new(policy);
557        let (layers, counts) = simple_layers();
558        let assignments = selector.assign_bit_widths(&layers, &counts).expect("assign");
559        assert!(assignments.iter().all(|a| a.bit_width == BitWidth::Int8));
560        assert_eq!(assignments.len(), 4);
561    }
562
563    // ── 8. Uniform strategy respects min_bit_width clamp ─────────────────────
564
565    #[test]
566    fn test_uniform_strategy_clamped_by_min() {
567        let policy = QuantizationPolicy {
568            strategy: BitWidthStrategy::Uniform(BitWidth::Int2),
569            budget_bytes: None,
570            min_bit_width: BitWidth::Int4, // floor is Int4
571            max_bit_width: BitWidth::Fp32,
572        };
573        let selector = PerLayerQuantSelector::new(policy);
574        let layers = vec![make_layer("l", 0.1, 0.1, 0.1, 0.1, false, false)];
575        let counts = vec![100];
576        let assignments = selector.assign_bit_widths(&layers, &counts).expect("assign");
577        assert_eq!(assignments[0].bit_width, BitWidth::Int4);
578    }
579
580    // ── 9. Sensitivity-based strategy: high score → Fp16 ─────────────────────
581
582    #[test]
583    fn test_sensitivity_based_high_score() {
584        let policy = QuantizationPolicy {
585            strategy: BitWidthStrategy::SensitivityBased {
586                high_threshold: 2.0,
587                medium_threshold: 1.0,
588                low_threshold: 0.3,
589            },
590            budget_bytes: None,
591            min_bit_width: BitWidth::Int2,
592            max_bit_width: BitWidth::Fp32,
593        };
594        let selector = PerLayerQuantSelector::new(policy);
595        // score > 2.0 → Fp16
596        let layer = make_layer("l", 3.0, 2.0, 2.0, 2.0, false, false);
597        // score = 3.0*0.4 + 2.0*0.3 + 2.0*0.2 + 2.0*0.1 = 1.2 + 0.6 + 0.4 + 0.2 = 2.4
598        let assignments = selector.assign_bit_widths(&[layer], &[1000]).expect("assign");
599        assert_eq!(assignments[0].bit_width, BitWidth::Fp16);
600    }
601
602    // ── 10. Sensitivity-based strategy: medium score → Int8 ──────────────────
603
604    #[test]
605    fn test_sensitivity_based_medium_score() {
606        let policy = QuantizationPolicy {
607            strategy: BitWidthStrategy::SensitivityBased {
608                high_threshold: 2.0,
609                medium_threshold: 1.0,
610                low_threshold: 0.3,
611            },
612            budget_bytes: None,
613            min_bit_width: BitWidth::Int2,
614            max_bit_width: BitWidth::Fp32,
615        };
616        let selector = PerLayerQuantSelector::new(policy);
617        // score between 1.0 and 2.0 → Int8
618        let layer = make_layer("l", 1.5, 1.5, 1.0, 1.0, false, false);
619        // score = 1.5*0.4 + 1.5*0.3 + 1.0*0.2 + 1.0*0.1 = 0.6+0.45+0.2+0.1 = 1.35
620        let assignments = selector.assign_bit_widths(&[layer], &[500]).expect("assign");
621        assert_eq!(assignments[0].bit_width, BitWidth::Int8);
622    }
623
624    // ── 11. Sensitivity-based strategy: low score → Int2 ─────────────────────
625
626    #[test]
627    fn test_sensitivity_based_low_score() {
628        let policy = QuantizationPolicy {
629            strategy: BitWidthStrategy::SensitivityBased {
630                high_threshold: 2.0,
631                medium_threshold: 1.0,
632                low_threshold: 0.3,
633            },
634            budget_bytes: None,
635            min_bit_width: BitWidth::Int2,
636            max_bit_width: BitWidth::Fp32,
637        };
638        let selector = PerLayerQuantSelector::new(policy);
639        // score < 0.3 → Int2
640        let layer = make_layer("l", 0.1, 0.1, 0.1, 0.1, false, false);
641        // score = 0.1*0.4 + 0.1*0.3 + 0.1*0.2 + 0.1*0.1 = 0.1
642        let assignments = selector.assign_bit_widths(&[layer], &[200]).expect("assign");
643        assert_eq!(assignments[0].bit_width, BitWidth::Int2);
644    }
645
646    // ── 12. Sensitivity-based strategy: between medium and low → Int4 ─────────
647
648    #[test]
649    fn test_sensitivity_based_between_medium_and_low() {
650        let policy = QuantizationPolicy {
651            strategy: BitWidthStrategy::SensitivityBased {
652                high_threshold: 2.0,
653                medium_threshold: 1.0,
654                low_threshold: 0.3,
655            },
656            budget_bytes: None,
657            min_bit_width: BitWidth::Int2,
658            max_bit_width: BitWidth::Fp32,
659        };
660        let selector = PerLayerQuantSelector::new(policy);
661        // score between 0.3 and 1.0 → Int4
662        let layer = make_layer("l", 0.8, 0.8, 0.6, 0.6, false, false);
663        // score = 0.8*0.4 + 0.8*0.3 + 0.6*0.2 + 0.6*0.1 = 0.32+0.24+0.12+0.06 = 0.74
664        let assignments = selector.assign_bit_widths(&[layer], &[300]).expect("assign");
665        assert_eq!(assignments[0].bit_width, BitWidth::Int4);
666    }
667
668    // ── 13. BudgetOptimal strategy: fits in budget ────────────────────────────
669
670    #[test]
671    fn test_budget_optimal_fits() {
672        // Large budget: should upgrade some layers
673        let large_budget = 1_000_000;
674        let policy = QuantizationPolicy {
675            strategy: BitWidthStrategy::BudgetOptimal,
676            budget_bytes: Some(large_budget),
677            min_bit_width: BitWidth::Int2,
678            max_bit_width: BitWidth::Fp32,
679        };
680        let selector = PerLayerQuantSelector::new(policy);
681        let (layers, counts) = simple_layers();
682        let assignments = selector.assign_bit_widths(&layers, &counts).expect("assign");
683        assert_eq!(assignments.len(), 4);
684        let total = PerLayerQuantSelector::total_memory_bytes(&assignments);
685        assert!(total <= large_budget);
686    }
687
688    // ── 14. Empty layer list error ────────────────────────────────────────────
689
690    #[test]
691    fn test_empty_layers_error() {
692        let policy = QuantizationPolicy::default();
693        let selector = PerLayerQuantSelector::new(policy);
694        let err = selector.assign_bit_widths(&[], &[]).expect_err("should error on empty");
695        assert!(matches!(err, QuantSelectionError::EmptyLayers));
696    }
697
698    // ── 15. Length mismatch error ─────────────────────────────────────────────
699
700    #[test]
701    fn test_length_mismatch_error() {
702        let policy = QuantizationPolicy::default();
703        let selector = PerLayerQuantSelector::new(policy);
704        let layers = vec![make_layer("l", 1.0, 1.0, 1.0, 1.0, false, false)];
705        let counts = vec![100, 200]; // wrong length
706        let err = selector
707            .assign_bit_widths(&layers, &counts)
708            .expect_err("should error on mismatch");
709        assert!(matches!(err, QuantSelectionError::LengthMismatch));
710    }
711
712    // ── 16. Budget exceeded error ─────────────────────────────────────────────
713
714    #[test]
715    fn test_budget_exceeded_error() {
716        // Budget too small even for Int4 on a big layer.
717        let tiny_budget = 1; // 1 byte
718        let policy = QuantizationPolicy {
719            strategy: BitWidthStrategy::Uniform(BitWidth::Int8),
720            budget_bytes: Some(tiny_budget),
721            min_bit_width: BitWidth::Int2,
722            max_bit_width: BitWidth::Fp32,
723        };
724        let selector = PerLayerQuantSelector::new(policy);
725        let layers = vec![make_layer("l", 1.0, 1.0, 1.0, 1.0, false, false)];
726        let counts = vec![10_000];
727        let err = selector.assign_bit_widths(&layers, &counts).expect_err("should exceed budget");
728        assert!(matches!(err, QuantSelectionError::BudgetExceeded { .. }));
729    }
730
731    // ── 17. total_memory_bytes ────────────────────────────────────────────────
732
733    #[test]
734    fn test_total_memory_bytes() {
735        let assignments = vec![
736            LayerBitWidthAssignment {
737                layer_name: "a".to_string(),
738                bit_width: BitWidth::Int8,
739                param_count: 100,
740                memory_bytes: 100,
741                sensitivity_score: 1.0,
742            },
743            LayerBitWidthAssignment {
744                layer_name: "b".to_string(),
745                bit_width: BitWidth::Fp16,
746                param_count: 50,
747                memory_bytes: 100,
748                sensitivity_score: 2.0,
749            },
750        ];
751        assert_eq!(PerLayerQuantSelector::total_memory_bytes(&assignments), 200);
752    }
753
754    // ── 18. summary_report display ────────────────────────────────────────────
755
756    #[test]
757    fn test_summary_report_display() {
758        let policy = QuantizationPolicy {
759            strategy: BitWidthStrategy::Uniform(BitWidth::Int8),
760            budget_bytes: None,
761            min_bit_width: BitWidth::Int2,
762            max_bit_width: BitWidth::Fp32,
763        };
764        let selector = PerLayerQuantSelector::new(policy);
765        let (layers, counts) = simple_layers();
766        let assignments = selector.assign_bit_widths(&layers, &counts).expect("assign");
767        let summary = PerLayerQuantSelector::summary_report(&assignments);
768        let s = format!("{}", summary);
769        assert!(s.contains("total_params"));
770        assert!(s.contains("compression_ratio"));
771        assert!(s.contains("INT8"));
772    }
773
774    // ── 19. summary_report compression ratio ─────────────────────────────────
775
776    #[test]
777    fn test_summary_report_compression_ratio() {
778        // Int8 should give 4x compression vs FP32
779        let assignments = vec![LayerBitWidthAssignment {
780            layer_name: "l".to_string(),
781            bit_width: BitWidth::Int8,
782            param_count: 1000,
783            memory_bytes: 1000, // 1 byte per weight
784            sensitivity_score: 0.5,
785        }];
786        let summary = PerLayerQuantSelector::summary_report(&assignments);
787        // fp32_bytes = 4000, total_memory = 1000 → ratio = 4.0
788        assert!((summary.compression_ratio - 4.0).abs() < 1e-6);
789    }
790
791    // ── 20. QuantSelectionError display ──────────────────────────────────────
792
793    #[test]
794    fn test_quant_selection_error_display() {
795        let e1 = QuantSelectionError::EmptyLayers;
796        assert!(format!("{}", e1).contains("Empty"));
797        let e2 = QuantSelectionError::LengthMismatch;
798        assert!(format!("{}", e2).contains("mismatch"));
799        let e3 = QuantSelectionError::BudgetExceeded {
800            required: 500,
801            budget: 100,
802        };
803        let s = format!("{}", e3);
804        assert!(s.contains("500"));
805        assert!(s.contains("100"));
806    }
807}