Skip to main content

ipfrs_tensorlogic/
model_pruner.rs

1//! Model weight pruning with magnitude, structured, and gradual scheduling strategies.
2//!
3//! This module implements several classical and modern neural-network pruning
4//! approaches:
5//!
6//! * **Magnitude pruning** — zero out individual weights whose absolute value
7//!   falls below a fixed threshold.
8//! * **Percentile-magnitude pruning** — zero out the bottom *X%* of weights
9//!   ranked by absolute magnitude.
10//! * **Structured L1 pruning** — remove entire neurons / output channels whose
11//!   mean L1 norm is below a threshold (structured sparsity that directly
12//!   speeds up inference on most hardware).
13//! * **Random pruning** — stochastically mask out *X%* of weights using a
14//!   deterministic xorshift64 PRNG seeded from [`PrunerConfig::seed`].
15//! * **Gradual pruning** — linearly ramp sparsity from an initial value to a
16//!   final value over a user-specified step window (Zhu & Gupta 2018 style).
17//!
18//! An optional binary mask is maintained alongside each [`LayerWeights`]
19//! tensor so that sparse structure can be preserved across optimiser updates.
20//!
21//! # Example
22//!
23//! ```rust
24//! use ipfrs_tensorlogic::{
25//!     ModelPruner, PrunerConfig, PruningStrategy, LayerWeights,
26//! };
27//!
28//! let cfg = PrunerConfig {
29//!     strategy: PruningStrategy::Magnitude(0.1),
30//!     seed: 42,
31//!     update_mask: true,
32//! };
33//! let mut pruner = ModelPruner::new(cfg);
34//!
35//! let mut layer = LayerWeights {
36//!     name: "fc1".to_string(),
37//!     weights: vec![0.05, -0.2, 0.0, 0.3, -0.08],
38//!     mask: None,
39//! };
40//!
41//! let result = pruner.prune_layer(&mut layer);
42//! assert!(result.sparsity > 0.0);
43//! ```
44
45// ── Pruning strategy ─────────────────────────────────────────────────────────
46
47/// Selects the algorithm used to decide which weights to prune.
48#[derive(Debug, Clone, PartialEq)]
49pub enum PruningStrategy {
50    /// Zero out every weight whose absolute value is strictly below `threshold`.
51    Magnitude(f64),
52    /// Zero out the bottom `percentile`% of weights ranked by absolute
53    /// magnitude.  `percentile` must be in [0, 100].
54    PercentileMagnitude(f64),
55    /// Prune entire neurons (rows) whose mean L1 norm is below `threshold`.
56    StructuredL1(f64),
57    /// Randomly zero out `fraction`% of weights using the pruner's seeded PRNG.
58    /// `fraction` must be in [0, 1].
59    RandomPruning(f64),
60    /// Linearly increase sparsity from `initial_sparsity` to `final_sparsity`
61    /// between `begin_step` and `end_step`.
62    GradualPruning {
63        /// Starting sparsity (fraction in [0, 1]).
64        initial_sparsity: f64,
65        /// Target sparsity (fraction in [0, 1]).
66        final_sparsity: f64,
67        /// Step at which ramping begins.
68        begin_step: usize,
69        /// Step at which ramping ends (and `final_sparsity` is held).
70        end_step: usize,
71    },
72}
73
74// ── Configuration ─────────────────────────────────────────────────────────────
75
76/// Configuration bundle passed to [`ModelPruner::new`].
77#[derive(Debug, Clone)]
78pub struct PrunerConfig {
79    /// Which pruning algorithm to apply.
80    pub strategy: PruningStrategy,
81    /// Seed for the internal xorshift64 PRNG (used by
82    /// [`PruningStrategy::RandomPruning`]).
83    pub seed: u64,
84    /// When `true` the pruner maintains and updates a boolean mask on each
85    /// [`LayerWeights`]; when `false` the mask field is left as `None`.
86    pub update_mask: bool,
87}
88
89// ── Data types ────────────────────────────────────────────────────────────────
90
91/// A named layer's weight tensor together with an optional sparsity mask.
92#[derive(Debug, Clone)]
93pub struct LayerWeights {
94    /// Human-readable name, e.g. `"encoder.layer.0.attention.weight"`.
95    pub name: String,
96    /// Flat weight values (row-major or column-major — the pruner is
97    /// layout-agnostic).
98    pub weights: Vec<f64>,
99    /// Binary mask parallel to `weights`.  `true` = keep, `false` = pruned.
100    /// Populated / updated by the pruner only when
101    /// [`PrunerConfig::update_mask`] is `true`.
102    pub mask: Option<Vec<bool>>,
103}
104
105/// Per-layer summary returned by each call to [`ModelPruner::prune_layer`].
106#[derive(Debug, Clone)]
107pub struct PruningResult {
108    /// Name of the layer that was pruned.
109    pub layer_name: String,
110    /// Total number of weights in the layer (before pruning this step).
111    pub weights_before: usize,
112    /// Number of newly-zeroed weights introduced by this pruning step.
113    pub weights_pruned: usize,
114    /// Fraction of all weights that are zero after pruning.
115    pub sparsity: f64,
116    /// The pruner's internal step counter at the time of pruning.
117    pub step: usize,
118}
119
120// ── Statistics ────────────────────────────────────────────────────────────────
121
122/// Cumulative statistics tracked by a [`ModelPruner`] across all layers and
123/// all pruning steps.
124#[derive(Debug, Clone, Default)]
125pub struct PrunerStats {
126    /// How many times [`ModelPruner::prune_layer`] has been called.
127    pub total_pruning_steps: u64,
128    /// Total number of weight-zeroing operations performed.
129    pub total_weights_pruned: u64,
130    /// Running mean sparsity across every [`PruningResult`] produced.
131    pub avg_sparsity: f64,
132}
133
134// ── Core pruner ───────────────────────────────────────────────────────────────
135
136/// Stateful weight pruner.  Advance the step counter with
137/// [`ModelPruner::advance_step`] between training iterations.
138pub struct ModelPruner {
139    config: PrunerConfig,
140    /// Monotonically increasing iteration counter.
141    step: usize,
142    /// Current xorshift64 state (non-zero initialised from `config.seed`).
143    rng_state: u64,
144    stats: PrunerStats,
145}
146
147impl ModelPruner {
148    // ── Construction ─────────────────────────────────────────────────────
149
150    /// Create a new pruner from the supplied configuration.
151    ///
152    /// The PRNG seed is initialised to `config.seed`, falling back to `1` if
153    /// the seed is zero (xorshift64 must not start from zero).
154    pub fn new(config: PrunerConfig) -> Self {
155        let rng_state = if config.seed == 0 { 1 } else { config.seed };
156        Self {
157            config,
158            step: 0,
159            rng_state,
160            stats: PrunerStats::default(),
161        }
162    }
163
164    // ── Public API ────────────────────────────────────────────────────────
165
166    /// Prune a single layer in-place according to the configured strategy.
167    ///
168    /// The `layer.weights` vector is mutated directly; the mask (if enabled)
169    /// is created or updated.
170    pub fn prune_layer(&mut self, layer: &mut LayerWeights) -> PruningResult {
171        let n = layer.weights.len();
172        let zeros_before = layer.weights.iter().filter(|&&w| w == 0.0).count();
173
174        match self.config.strategy.clone() {
175            PruningStrategy::Magnitude(threshold) => {
176                self.apply_magnitude(layer, threshold);
177            }
178            PruningStrategy::PercentileMagnitude(pct) => {
179                let threshold = Self::compute_threshold(&layer.weights, pct);
180                self.apply_magnitude(layer, threshold);
181            }
182            PruningStrategy::StructuredL1(threshold) => {
183                self.apply_structured_l1(layer, threshold);
184            }
185            PruningStrategy::RandomPruning(fraction) => {
186                self.apply_random(layer, fraction);
187            }
188            PruningStrategy::GradualPruning { .. } => {
189                let target = self.current_sparsity_target();
190                // Convert fraction to percentile for threshold computation.
191                let pct = target * 100.0;
192                let threshold = Self::compute_threshold(&layer.weights, pct);
193                self.apply_magnitude(layer, threshold);
194            }
195        }
196
197        if self.config.update_mask {
198            Self::rebuild_mask(layer);
199        }
200
201        let zeros_after = layer.weights.iter().filter(|&&w| w == 0.0).count();
202        let newly_pruned = zeros_after.saturating_sub(zeros_before);
203        let sparsity = Self::compute_sparsity(&layer.weights);
204
205        // Update cumulative stats.
206        self.stats.total_pruning_steps += 1;
207        self.stats.total_weights_pruned += newly_pruned as u64;
208        let n_steps = self.stats.total_pruning_steps as f64;
209        self.stats.avg_sparsity =
210            self.stats.avg_sparsity * (n_steps - 1.0) / n_steps + sparsity / n_steps;
211
212        PruningResult {
213            layer_name: layer.name.clone(),
214            weights_before: n,
215            weights_pruned: newly_pruned,
216            sparsity,
217            step: self.step,
218        }
219    }
220
221    /// Prune every layer in `layers` and return one result per layer.
222    pub fn prune_all(&mut self, layers: &mut [LayerWeights]) -> Vec<PruningResult> {
223        layers.iter_mut().map(|l| self.prune_layer(l)).collect()
224    }
225
226    /// Compute the sparsity target for the current step.
227    ///
228    /// For [`PruningStrategy::GradualPruning`] this linearly interpolates
229    /// between `initial_sparsity` and `final_sparsity`.  For all other
230    /// strategies it returns the equivalent fixed fraction.
231    pub fn current_sparsity_target(&self) -> f64 {
232        match &self.config.strategy {
233            PruningStrategy::Magnitude(t) => *t,
234            PruningStrategy::PercentileMagnitude(pct) => pct / 100.0,
235            PruningStrategy::StructuredL1(t) => *t,
236            PruningStrategy::RandomPruning(frac) => *frac,
237            PruningStrategy::GradualPruning {
238                initial_sparsity,
239                final_sparsity,
240                begin_step,
241                end_step,
242            } => {
243                let s = self.step;
244                if s <= *begin_step {
245                    *initial_sparsity
246                } else if s >= *end_step {
247                    *final_sparsity
248                } else {
249                    let progress = (s - begin_step) as f64 / (end_step - begin_step).max(1) as f64;
250                    initial_sparsity + progress * (final_sparsity - initial_sparsity)
251                }
252            }
253        }
254    }
255
256    /// Return the weight value at the given `percentile` (0–100) of the
257    /// absolute-magnitude distribution.
258    ///
259    /// Uses a partial sort to avoid allocating a fully sorted copy when only
260    /// the boundary value is needed.  Returns `0.0` for empty slices.
261    pub fn compute_threshold(weights: &[f64], percentile: f64) -> f64 {
262        if weights.is_empty() {
263            return 0.0;
264        }
265        let pct = percentile.clamp(0.0, 100.0);
266        let mut magnitudes: Vec<f64> = weights.iter().map(|w| w.abs()).collect();
267        magnitudes.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
268        let idx = ((pct / 100.0) * magnitudes.len() as f64) as usize;
269        let idx = idx.min(magnitudes.len().saturating_sub(1));
270        magnitudes[idx]
271    }
272
273    /// Fraction of elements in `weights` that are exactly zero.
274    pub fn compute_sparsity(weights: &[f64]) -> f64 {
275        if weights.is_empty() {
276            return 0.0;
277        }
278        let zeros = weights.iter().filter(|&&w| w == 0.0).count();
279        zeros as f64 / weights.len() as f64
280    }
281
282    /// Sum of absolute values of `weights`.
283    pub fn compute_l1_norm(weights: &[f64]) -> f64 {
284        weights.iter().map(|w| w.abs()).sum()
285    }
286
287    /// Advance the step counter by one.
288    pub fn advance_step(&mut self) {
289        self.step += 1;
290    }
291
292    /// Generate the next pseudo-random float in [0, 1) using xorshift64.
293    ///
294    /// The internal state is updated in-place so successive calls yield
295    /// independent values.
296    pub fn next_uniform_prng(&mut self) -> f64 {
297        // xorshift64 — never produces zero so the state invariant is preserved.
298        let mut x = self.rng_state;
299        x ^= x << 13;
300        x ^= x >> 7;
301        x ^= x << 17;
302        self.rng_state = x;
303        // Map to [0, 1) by dividing by 2^64.
304        (x as f64) / (u64::MAX as f64 + 1.0)
305    }
306
307    /// Zero out entries in `layer.weights` where the corresponding mask entry
308    /// is `false`.  If no mask is present this is a no-op.
309    pub fn apply_mask(layer: &mut LayerWeights) {
310        if let Some(mask) = &layer.mask {
311            let mask_clone: Vec<bool> = mask.clone();
312            for (w, &keep) in layer.weights.iter_mut().zip(mask_clone.iter()) {
313                if !keep {
314                    *w = 0.0;
315                }
316            }
317        }
318    }
319
320    /// Immutable access to the accumulated statistics.
321    pub fn stats(&self) -> &PrunerStats {
322        &self.stats
323    }
324
325    // ── Internal helpers ──────────────────────────────────────────────────
326
327    /// Zero out all weights with absolute value strictly below `threshold`.
328    fn apply_magnitude(&self, layer: &mut LayerWeights, threshold: f64) {
329        for w in layer.weights.iter_mut() {
330            if w.abs() < threshold {
331                *w = 0.0;
332            }
333        }
334    }
335
336    /// Prune entire rows (neurons) whose mean absolute value is below
337    /// `threshold`.  The weights tensor is assumed to be laid out as
338    /// `num_neurons × neuron_size`, with rows of equal length.  If the tensor
339    /// has fewer than two elements we fall back to element-wise magnitude
340    /// pruning.
341    fn apply_structured_l1(&self, layer: &mut LayerWeights, threshold: f64) {
342        let n = layer.weights.len();
343        if n < 2 {
344            self.apply_magnitude(layer, threshold);
345            return;
346        }
347        // Heuristic: treat the tensor as a 2-D matrix where each "neuron" is
348        // a row of `row_len` weights.  We choose the largest divisor of `n`
349        // that is at most √n so that we get the most "square" layout.
350        let row_len = Self::choose_row_len(n);
351        let num_rows = n / row_len;
352
353        for row_idx in 0..num_rows {
354            let start = row_idx * row_len;
355            let end = start + row_len;
356            let row = &layer.weights[start..end];
357            let l1_mean = Self::compute_l1_norm(row) / row_len as f64;
358            if l1_mean < threshold {
359                for w in layer.weights[start..end].iter_mut() {
360                    *w = 0.0;
361                }
362            }
363        }
364    }
365
366    /// Randomly zero out `fraction` of the weights using the internal PRNG.
367    fn apply_random(&mut self, layer: &mut LayerWeights, fraction: f64) {
368        let frac = fraction.clamp(0.0, 1.0);
369        for w in layer.weights.iter_mut() {
370            if self.next_uniform_prng() < frac {
371                *w = 0.0;
372            }
373        }
374    }
375
376    /// Rebuild the binary mask for `layer` to reflect its current zero pattern.
377    fn rebuild_mask(layer: &mut LayerWeights) {
378        let mask: Vec<bool> = layer.weights.iter().map(|&w| w != 0.0).collect();
379        layer.mask = Some(mask);
380    }
381
382    /// Choose a "row length" for structured pruning by finding the largest
383    /// divisor of `n` that is ≤ √n.  Falls back to 1 if none found.
384    fn choose_row_len(n: usize) -> usize {
385        let sqrt_n = (n as f64).sqrt() as usize;
386        for d in (1..=sqrt_n).rev() {
387            if n.is_multiple_of(d) {
388                return n / d; // row_len = n / d gives num_rows = d
389            }
390        }
391        1
392    }
393}
394
395// ── Tests ─────────────────────────────────────────────────────────────────────
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400
401    // ── Helpers ───────────────────────────────────────────────────────────
402
403    fn make_layer(name: &str, weights: Vec<f64>) -> LayerWeights {
404        LayerWeights {
405            name: name.to_string(),
406            weights,
407            mask: None,
408        }
409    }
410
411    fn pruner(strategy: PruningStrategy) -> ModelPruner {
412        ModelPruner::new(PrunerConfig {
413            strategy,
414            seed: 42,
415            update_mask: true,
416        })
417    }
418
419    // ── Magnitude pruning ─────────────────────────────────────────────────
420
421    #[test]
422    fn magnitude_removes_below_threshold() {
423        let mut p = pruner(PruningStrategy::Magnitude(0.1));
424        let mut layer = make_layer("l", vec![0.05, -0.2, 0.0, 0.3, -0.08]);
425        p.prune_layer(&mut layer);
426        // 0.05, 0.0, -0.08 should all be zeroed (abs < 0.1)
427        assert_eq!(layer.weights[0], 0.0);
428        assert_ne!(layer.weights[1], 0.0); // -0.2 kept
429        assert_eq!(layer.weights[2], 0.0);
430        assert_ne!(layer.weights[3], 0.0); // 0.3 kept
431        assert_eq!(layer.weights[4], 0.0);
432    }
433
434    #[test]
435    fn magnitude_threshold_zero_prunes_nothing() {
436        let mut p = pruner(PruningStrategy::Magnitude(0.0));
437        let weights = vec![0.1, -0.2, 0.3];
438        let mut layer = make_layer("l", weights.clone());
439        p.prune_layer(&mut layer);
440        assert_eq!(layer.weights, weights);
441    }
442
443    #[test]
444    fn magnitude_threshold_high_prunes_all() {
445        let mut p = pruner(PruningStrategy::Magnitude(1e9));
446        let mut layer = make_layer("l", vec![1.0, -2.0, 3.0]);
447        p.prune_layer(&mut layer);
448        assert!(layer.weights.iter().all(|&w| w == 0.0));
449    }
450
451    #[test]
452    fn magnitude_result_fields_correct() {
453        let mut p = pruner(PruningStrategy::Magnitude(0.1));
454        let mut layer = make_layer("fc1", vec![0.05, -0.2, 0.3]);
455        let res = p.prune_layer(&mut layer);
456        assert_eq!(res.layer_name, "fc1");
457        assert_eq!(res.weights_before, 3);
458        assert_eq!(res.weights_pruned, 1);
459        assert!(res.sparsity > 0.0 && res.sparsity <= 1.0);
460        assert_eq!(res.step, 0);
461    }
462
463    // ── Percentile-magnitude pruning ──────────────────────────────────────
464
465    #[test]
466    fn percentile_prunes_bottom_fraction() {
467        let weights: Vec<f64> = (1..=10).map(|i| i as f64 * 0.1).collect();
468        let mut p = pruner(PruningStrategy::PercentileMagnitude(50.0));
469        let mut layer = make_layer("l", weights);
470        p.prune_layer(&mut layer);
471        let sparsity = ModelPruner::compute_sparsity(&layer.weights);
472        // Bottom 50 % → roughly 50 % zeros (may be slightly off at boundaries)
473        assert!((0.4..=0.6).contains(&sparsity));
474    }
475
476    #[test]
477    fn percentile_zero_prunes_nothing() {
478        let weights = vec![0.1, 0.2, 0.3];
479        let mut p = pruner(PruningStrategy::PercentileMagnitude(0.0));
480        let mut layer = make_layer("l", weights.clone());
481        p.prune_layer(&mut layer);
482        // threshold = abs value at 0th percentile = minimum = 0.1, so nothing < 0.1
483        // (strictly less than — 0.1 itself is kept)
484        assert_eq!(layer.weights, weights);
485    }
486
487    #[test]
488    fn percentile_hundred_prunes_all_nonzero() {
489        let mut p = pruner(PruningStrategy::PercentileMagnitude(100.0));
490        let mut layer = make_layer("l", vec![1.0, 2.0, 3.0]);
491        p.prune_layer(&mut layer);
492        // threshold == max value; all values are < threshold except the maximum
493        // (which equals threshold, not strictly less).  So only values strictly
494        // below 3.0 are pruned.
495        assert_eq!(layer.weights[0], 0.0);
496        assert_eq!(layer.weights[1], 0.0);
497        // 3.0 == threshold so it is *not* strictly below — kept.
498        assert_eq!(layer.weights[2], 3.0);
499    }
500
501    // ── Structured L1 pruning ─────────────────────────────────────────────
502
503    #[test]
504    fn structured_l1_prunes_weak_neurons() {
505        // choose_row_len(9) → sqrt(9)=3, d=3, row_len=9/3=3, num_rows=3
506        // So 3 neurons of 3 weights each.
507        let mut weights = vec![0.01f64, 0.01, 0.01]; // neuron 0 — weak, mean L1 ≈ 0.01
508        weights.extend_from_slice(&[1.0, 2.0, 3.0]); // neuron 1 — strong, mean L1 = 2.0
509        weights.extend_from_slice(&[0.5, 0.6, 0.7]); // neuron 2 — strong, mean L1 = 0.6
510        let mut p = pruner(PruningStrategy::StructuredL1(0.5));
511        let mut layer = make_layer("l", weights);
512        p.prune_layer(&mut layer);
513        // Neuron 0 mean L1 ≈ 0.01 < 0.5 → pruned
514        assert_eq!(layer.weights[0], 0.0);
515        assert_eq!(layer.weights[1], 0.0);
516        assert_eq!(layer.weights[2], 0.0);
517        // Neuron 1 mean L1 = 2.0 > 0.5 → kept
518        assert_ne!(layer.weights[3], 0.0);
519    }
520
521    #[test]
522    fn structured_l1_single_element_falls_back_to_magnitude() {
523        let mut p = pruner(PruningStrategy::StructuredL1(0.5));
524        let mut layer = make_layer("l", vec![0.1]);
525        p.prune_layer(&mut layer);
526        // 0.1 < 0.5 → zeroed by magnitude fallback
527        assert_eq!(layer.weights[0], 0.0);
528    }
529
530    #[test]
531    fn structured_l1_no_pruning_when_all_strong() {
532        let weights = vec![10.0f64; 9];
533        let mut p = pruner(PruningStrategy::StructuredL1(0.1));
534        let mut layer = make_layer("l", weights);
535        p.prune_layer(&mut layer);
536        assert!(layer.weights.iter().all(|&w| w != 0.0));
537    }
538
539    // ── Random pruning ────────────────────────────────────────────────────
540
541    #[test]
542    fn random_pruning_deterministic_with_seed() {
543        let cfg1 = PrunerConfig {
544            strategy: PruningStrategy::RandomPruning(0.5),
545            seed: 12345,
546            update_mask: false,
547        };
548        let cfg2 = PrunerConfig {
549            strategy: PruningStrategy::RandomPruning(0.5),
550            seed: 12345,
551            update_mask: false,
552        };
553        let mut p1 = ModelPruner::new(cfg1);
554        let mut p2 = ModelPruner::new(cfg2);
555        let weights: Vec<f64> = (1..=20).map(|i| i as f64).collect();
556        let mut l1 = make_layer("a", weights.clone());
557        let mut l2 = make_layer("a", weights);
558        p1.prune_layer(&mut l1);
559        p2.prune_layer(&mut l2);
560        assert_eq!(l1.weights, l2.weights);
561    }
562
563    #[test]
564    fn random_pruning_different_seeds_differ() {
565        let mut p1 = ModelPruner::new(PrunerConfig {
566            strategy: PruningStrategy::RandomPruning(0.5),
567            seed: 1,
568            update_mask: false,
569        });
570        let mut p2 = ModelPruner::new(PrunerConfig {
571            strategy: PruningStrategy::RandomPruning(0.5),
572            seed: 999999,
573            update_mask: false,
574        });
575        let weights: Vec<f64> = (1..=100).map(|i| i as f64).collect();
576        let mut l1 = make_layer("a", weights.clone());
577        let mut l2 = make_layer("a", weights);
578        p1.prune_layer(&mut l1);
579        p2.prune_layer(&mut l2);
580        assert_ne!(l1.weights, l2.weights);
581    }
582
583    #[test]
584    fn random_pruning_zero_fraction_prunes_nothing() {
585        let weights: Vec<f64> = vec![1.0, 2.0, 3.0];
586        let mut p = pruner(PruningStrategy::RandomPruning(0.0));
587        let mut layer = make_layer("l", weights.clone());
588        p.prune_layer(&mut layer);
589        assert_eq!(layer.weights, weights);
590    }
591
592    // ── Gradual pruning ───────────────────────────────────────────────────
593
594    #[test]
595    fn gradual_pruning_interpolates_between_steps() {
596        let strategy = PruningStrategy::GradualPruning {
597            initial_sparsity: 0.0,
598            final_sparsity: 1.0,
599            begin_step: 0,
600            end_step: 10,
601        };
602        let mut p = pruner(strategy);
603        // Step 0 → target = 0.0
604        assert!((p.current_sparsity_target() - 0.0).abs() < 1e-9);
605        p.advance_step(); // step 1
606        let t1 = p.current_sparsity_target();
607        assert!(t1 > 0.0 && t1 < 1.0);
608    }
609
610    #[test]
611    fn gradual_pruning_clamps_to_final_after_end_step() {
612        let strategy = PruningStrategy::GradualPruning {
613            initial_sparsity: 0.0,
614            final_sparsity: 0.9,
615            begin_step: 2,
616            end_step: 5,
617        };
618        let mut p = pruner(strategy);
619        for _ in 0..10 {
620            p.advance_step();
621        }
622        assert!((p.current_sparsity_target() - 0.9).abs() < 1e-9);
623    }
624
625    #[test]
626    fn gradual_pruning_holds_initial_before_begin_step() {
627        let strategy = PruningStrategy::GradualPruning {
628            initial_sparsity: 0.1,
629            final_sparsity: 0.8,
630            begin_step: 5,
631            end_step: 10,
632        };
633        let p = pruner(strategy);
634        // step = 0 < begin_step = 5 → initial_sparsity
635        assert!((p.current_sparsity_target() - 0.1).abs() < 1e-9);
636    }
637
638    #[test]
639    fn gradual_pruning_midpoint_is_correct() {
640        let strategy = PruningStrategy::GradualPruning {
641            initial_sparsity: 0.0,
642            final_sparsity: 1.0,
643            begin_step: 0,
644            end_step: 10,
645        };
646        let mut p = pruner(strategy);
647        for _ in 0..5 {
648            p.advance_step();
649        }
650        let target = p.current_sparsity_target();
651        assert!((target - 0.5).abs() < 1e-9);
652    }
653
654    // ── advance_step ──────────────────────────────────────────────────────
655
656    #[test]
657    fn advance_step_increments_counter() {
658        let strategy = PruningStrategy::GradualPruning {
659            initial_sparsity: 0.0,
660            final_sparsity: 1.0,
661            begin_step: 0,
662            end_step: 100,
663        };
664        let mut p = pruner(strategy);
665        let t0 = p.current_sparsity_target();
666        p.advance_step();
667        let t1 = p.current_sparsity_target();
668        assert!(t1 > t0);
669    }
670
671    // ── compute_threshold ─────────────────────────────────────────────────
672
673    #[test]
674    fn compute_threshold_median() {
675        let weights = vec![-3.0, -2.0, -1.0, 1.0, 2.0, 3.0];
676        let t = ModelPruner::compute_threshold(&weights, 50.0);
677        // Sorted magnitudes: [1,1,2,2,3,3], median index = 3 → 2.0
678        assert!((t - 2.0).abs() < 1e-9);
679    }
680
681    #[test]
682    fn compute_threshold_zero_percentile() {
683        let weights = vec![1.0, 2.0, 3.0];
684        let t = ModelPruner::compute_threshold(&weights, 0.0);
685        assert!((t - 1.0).abs() < 1e-9);
686    }
687
688    #[test]
689    fn compute_threshold_hundred_percentile() {
690        let weights = vec![1.0, 2.0, 3.0];
691        let t = ModelPruner::compute_threshold(&weights, 100.0);
692        assert!((t - 3.0).abs() < 1e-9);
693    }
694
695    #[test]
696    fn compute_threshold_empty() {
697        assert_eq!(ModelPruner::compute_threshold(&[], 50.0), 0.0);
698    }
699
700    // ── compute_sparsity ──────────────────────────────────────────────────
701
702    #[test]
703    fn compute_sparsity_all_nonzero() {
704        assert_eq!(ModelPruner::compute_sparsity(&[1.0, 2.0, 3.0]), 0.0);
705    }
706
707    #[test]
708    fn compute_sparsity_all_zero() {
709        assert_eq!(ModelPruner::compute_sparsity(&[0.0, 0.0, 0.0]), 1.0);
710    }
711
712    #[test]
713    fn compute_sparsity_half() {
714        assert!((ModelPruner::compute_sparsity(&[0.0, 1.0]) - 0.5).abs() < 1e-9);
715    }
716
717    #[test]
718    fn compute_sparsity_empty() {
719        assert_eq!(ModelPruner::compute_sparsity(&[]), 0.0);
720    }
721
722    // ── apply_mask ────────────────────────────────────────────────────────
723
724    #[test]
725    fn apply_mask_zeros_false_entries() {
726        let mut layer = LayerWeights {
727            name: "l".to_string(),
728            weights: vec![1.0, 2.0, 3.0],
729            mask: Some(vec![true, false, true]),
730        };
731        ModelPruner::apply_mask(&mut layer);
732        assert_eq!(layer.weights, vec![1.0, 0.0, 3.0]);
733    }
734
735    #[test]
736    fn apply_mask_no_mask_noop() {
737        let mut layer = LayerWeights {
738            name: "l".to_string(),
739            weights: vec![1.0, 2.0, 3.0],
740            mask: None,
741        };
742        ModelPruner::apply_mask(&mut layer);
743        assert_eq!(layer.weights, vec![1.0, 2.0, 3.0]);
744    }
745
746    #[test]
747    fn apply_mask_all_false_zeroes_all() {
748        let mut layer = LayerWeights {
749            name: "l".to_string(),
750            weights: vec![5.0, 6.0, 7.0],
751            mask: Some(vec![false, false, false]),
752        };
753        ModelPruner::apply_mask(&mut layer);
754        assert!(layer.weights.iter().all(|&w| w == 0.0));
755    }
756
757    // ── prune_all ─────────────────────────────────────────────────────────
758
759    #[test]
760    fn prune_all_returns_one_result_per_layer() {
761        let mut p = pruner(PruningStrategy::Magnitude(0.1));
762        let mut layers = vec![
763            make_layer("a", vec![0.05, 0.5]),
764            make_layer("b", vec![0.05, 0.5, -0.5]),
765            make_layer("c", vec![1.0, 2.0]),
766        ];
767        let results = p.prune_all(&mut layers);
768        assert_eq!(results.len(), 3);
769        assert_eq!(results[0].layer_name, "a");
770        assert_eq!(results[1].layer_name, "b");
771        assert_eq!(results[2].layer_name, "c");
772    }
773
774    #[test]
775    fn prune_all_mutates_all_layers() {
776        let mut p = pruner(PruningStrategy::Magnitude(1e9));
777        let mut layers = vec![
778            make_layer("a", vec![0.1, 0.2]),
779            make_layer("b", vec![0.3, 0.4]),
780        ];
781        p.prune_all(&mut layers);
782        for layer in &layers {
783            assert!(layer.weights.iter().all(|&w| w == 0.0));
784        }
785    }
786
787    // ── Mask update ───────────────────────────────────────────────────────
788
789    #[test]
790    fn mask_updated_after_pruning() {
791        let mut p = pruner(PruningStrategy::Magnitude(0.5));
792        let mut layer = make_layer("l", vec![0.1, 1.0, 0.2, 2.0]);
793        p.prune_layer(&mut layer);
794        let mask = layer.mask.expect("test: should succeed");
795        // 0.1 and 0.2 are pruned → false
796        assert!(!mask[0]);
797        assert!(mask[1]);
798        assert!(!mask[2]);
799        assert!(mask[3]);
800    }
801
802    #[test]
803    fn no_mask_update_when_disabled() {
804        let cfg = PrunerConfig {
805            strategy: PruningStrategy::Magnitude(0.5),
806            seed: 0,
807            update_mask: false,
808        };
809        let mut p = ModelPruner::new(cfg);
810        let mut layer = make_layer("l", vec![0.1, 1.0]);
811        p.prune_layer(&mut layer);
812        assert!(layer.mask.is_none());
813    }
814
815    // ── Stats tracking ────────────────────────────────────────────────────
816
817    #[test]
818    fn stats_total_pruning_steps_increments() {
819        let mut p = pruner(PruningStrategy::Magnitude(0.5));
820        assert_eq!(p.stats().total_pruning_steps, 0);
821        p.prune_layer(&mut make_layer("a", vec![0.1, 1.0]));
822        assert_eq!(p.stats().total_pruning_steps, 1);
823        p.prune_layer(&mut make_layer("b", vec![0.1, 1.0]));
824        assert_eq!(p.stats().total_pruning_steps, 2);
825    }
826
827    #[test]
828    fn stats_total_weights_pruned_accumulates() {
829        let mut p = pruner(PruningStrategy::Magnitude(0.5));
830        p.prune_layer(&mut make_layer("a", vec![0.1, 0.2, 1.0])); // 2 pruned
831        p.prune_layer(&mut make_layer("b", vec![0.3, 0.4, 2.0])); // 2 pruned
832        assert_eq!(p.stats().total_weights_pruned, 4);
833    }
834
835    #[test]
836    fn stats_avg_sparsity_is_non_negative() {
837        let mut p = pruner(PruningStrategy::Magnitude(0.5));
838        p.prune_layer(&mut make_layer("a", vec![0.1, 1.0]));
839        assert!(p.stats().avg_sparsity >= 0.0);
840        assert!(p.stats().avg_sparsity <= 1.0);
841    }
842
843    // ── Edge cases ────────────────────────────────────────────────────────
844
845    #[test]
846    fn full_zero_weights_remain_zero() {
847        let mut p = pruner(PruningStrategy::Magnitude(0.1));
848        let mut layer = make_layer("l", vec![0.0, 0.0, 0.0]);
849        let result = p.prune_layer(&mut layer);
850        assert_eq!(result.sparsity, 1.0);
851        assert_eq!(result.weights_pruned, 0); // already zero, nothing *newly* pruned
852    }
853
854    #[test]
855    fn empty_layer_produces_valid_result() {
856        let mut p = pruner(PruningStrategy::Magnitude(0.1));
857        let mut layer = make_layer("empty", vec![]);
858        let result = p.prune_layer(&mut layer);
859        assert_eq!(result.weights_before, 0);
860        assert_eq!(result.weights_pruned, 0);
861        assert_eq!(result.sparsity, 0.0);
862    }
863
864    #[test]
865    fn compute_l1_norm_sum_of_abs() {
866        let weights = vec![-1.0, 2.0, -3.0, 4.0];
867        assert!((ModelPruner::compute_l1_norm(&weights) - 10.0).abs() < 1e-9);
868    }
869
870    #[test]
871    fn next_uniform_prng_in_range() {
872        let cfg = PrunerConfig {
873            strategy: PruningStrategy::Magnitude(0.0),
874            seed: 7,
875            update_mask: false,
876        };
877        let mut p = ModelPruner::new(cfg);
878        for _ in 0..1000 {
879            let v = p.next_uniform_prng();
880            assert!((0.0..1.0).contains(&v), "PRNG out of range: {}", v);
881        }
882    }
883}