Skip to main content

ipfrs_tensorlogic/
gradient_sparsify.rs

1//! Gradient sparsification and delta encoding for federated learning.
2//!
3//! This module provides bandwidth-efficient gradient transmission primitives:
4//!
5//! - [`SparsityConfig`] — policy for top-k selection and threshold filtering
6//! - [`SparseGradient`] — compact index/value representation with residual support
7//! - [`GradientSparsifier`] — stateful sparsifier with residual accumulation
8//! - [`GradientDelta`] — delta-encoded gradient relative to the previous round
9//! - [`DeltaEncoder`] — stateful encoder that tracks the previously sent gradient
10//!
11//! ## Design rationale
12//!
13//! In bandwidth-constrained federated learning scenarios, transmitting the full
14//! gradient vector each round wastes network capacity. Two complementary
15//! techniques address this:
16//!
17//! 1. **Sparsification** — keep only the top-k elements (by absolute value) or
18//!    those exceeding a magnitude threshold, accumulating the dropped portion in
19//!    a residual buffer so that no information is permanently lost.
20//!
21//! 2. **Delta encoding** — transmit the element-wise difference from the
22//!    previous round instead of the full gradient; the receiver reconstructs
23//!    the current gradient by adding the delta to its locally cached copy.
24
25use serde::{Deserialize, Serialize};
26
27// ── SparsityConfig ──────────────────────────────────────────────────────────
28
29/// Configuration for the [`GradientSparsifier`].
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct SparsityConfig {
32    /// Keep only the top-k elements by absolute value. `None` means no limit.
33    pub top_k: Option<usize>,
34    /// Drop elements whose absolute value is below this threshold. `None` means
35    /// no threshold filtering.
36    pub threshold: Option<f32>,
37    /// When `true` (default), dropped elements are accumulated into a residual
38    /// buffer and added back to the gradient on the next call to [`GradientSparsifier::sparsify`].
39    pub accumulate_residuals: bool,
40}
41
42impl Default for SparsityConfig {
43    fn default() -> Self {
44        Self {
45            top_k: None,
46            threshold: None,
47            accumulate_residuals: true,
48        }
49    }
50}
51
52// ── SparsifierStats ─────────────────────────────────────────────────────────
53
54/// Cumulative statistics for a [`GradientSparsifier`].
55#[derive(Debug, Clone, Default, Serialize, Deserialize)]
56pub struct SparsifierStats {
57    /// Total number of sparsification rounds completed.
58    pub total_rounds: u64,
59    /// Total number of gradient elements that were kept (sent).
60    pub total_elements_kept: u64,
61    /// Total number of gradient elements that were dropped (deferred to residual).
62    pub total_elements_dropped: u64,
63    /// Total number of residual elements that were re-applied to the gradient.
64    pub total_residual_applied: u64,
65}
66
67// ── SparseGradient (sparsify module variant) ────────────────────────────────
68
69/// A compact sparse representation of a gradient vector.
70///
71/// Indices use `u32` to halve storage compared with `usize` on 64-bit targets,
72/// which is safe for gradient lengths that fit within 4 billion elements.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct SparseGradient {
75    /// Positions of kept (non-zero) elements in the original flat gradient.
76    pub indices: Vec<u32>,
77    /// Values corresponding to each kept index.
78    pub values: Vec<f32>,
79    /// Length of the original flat gradient from which this was derived.
80    pub original_len: usize,
81}
82
83impl SparseGradient {
84    /// Fraction of elements that were *not* kept.
85    ///
86    /// A value of 1.0 means all elements were dropped; 0.0 means all were kept.
87    pub fn sparsity_ratio(&self) -> f64 {
88        if self.original_len == 0 {
89            return 0.0;
90        }
91        1.0 - (self.indices.len() as f64 / self.original_len as f64)
92    }
93
94    /// Reconstruct the full dense gradient vector (zeros at dropped positions).
95    pub fn to_dense(&self) -> Vec<f32> {
96        let mut dense = vec![0.0_f32; self.original_len];
97        for (&idx, &val) in self.indices.iter().zip(self.values.iter()) {
98            let pos = idx as usize;
99            if pos < self.original_len {
100                dense[pos] = val;
101            }
102        }
103        dense
104    }
105}
106
107// ── GradientSparsifier ──────────────────────────────────────────────────────
108
109/// Stateful gradient sparsifier with optional residual accumulation.
110///
111/// Residual accumulation ensures that gradient information dropped in one round
112/// is carried forward and injected in subsequent rounds, preventing systematic
113/// bias toward always-large parameters.
114pub struct GradientSparsifier {
115    /// Sparsification policy.
116    pub config: SparsityConfig,
117    /// Accumulated residual from previously dropped gradient elements.
118    pub residual: Vec<f32>,
119    /// Cumulative statistics.
120    pub stats: SparsifierStats,
121}
122
123impl GradientSparsifier {
124    /// Create a new sparsifier.
125    ///
126    /// `gradient_len` is used to pre-allocate the residual buffer. If the
127    /// gradient length changes between calls, the residual is silently
128    /// zero-extended or truncated to match.
129    pub fn new(config: SparsityConfig, gradient_len: usize) -> Self {
130        Self {
131            config,
132            residual: vec![0.0_f32; gradient_len],
133            stats: SparsifierStats::default(),
134        }
135    }
136
137    /// Sparsify a gradient vector.
138    ///
139    /// Steps performed:
140    /// 1. If `accumulate_residuals`, add the stored residual to `gradient`
141    ///    element-wise (extending or truncating the residual as needed).
142    /// 2. Apply top-k and/or threshold selection.
143    /// 3. Update the residual with the dropped elements.
144    /// 4. Update statistics.
145    ///
146    /// Returns a [`SparseGradient`] containing only the kept elements.
147    pub fn sparsify(&mut self, gradient: &[f32]) -> SparseGradient {
148        let len = gradient.len();
149
150        // Ensure residual buffer matches current gradient length.
151        if self.residual.len() != len {
152            self.residual.resize(len, 0.0);
153        }
154
155        // Step 1: build the working vector (gradient + residual).
156        let mut working: Vec<f32> = if self.config.accumulate_residuals {
157            let residual_applied = self.residual.iter().filter(|&&v| v != 0.0).count() as u64;
158            self.stats.total_residual_applied += residual_applied;
159
160            gradient
161                .iter()
162                .zip(self.residual.iter())
163                .map(|(&g, &r)| g + r)
164                .collect()
165        } else {
166            gradient.to_vec()
167        };
168
169        // Step 2a: threshold filtering — zero out below-threshold elements.
170        if let Some(thresh) = self.config.threshold {
171            for v in working.iter_mut() {
172                if v.abs() < thresh {
173                    *v = 0.0;
174                }
175            }
176        }
177
178        // Step 2b: top-k selection.
179        // Collect candidate (index, absolute_value) pairs for all non-zero elements.
180        let mut candidates: Vec<(usize, f32)> = working
181            .iter()
182            .enumerate()
183            .filter(|(_, &v)| v != 0.0)
184            .map(|(i, &v)| (i, v.abs()))
185            .collect();
186
187        let keep_count = match self.config.top_k {
188            Some(k) => k.min(candidates.len()),
189            None => candidates.len(),
190        };
191
192        // Partial sort: bring the top `keep_count` elements to the front.
193        // We use a partial selection sort variant via `select_nth_unstable_by`
194        // on the candidates slice.
195        let kept_indices: std::collections::HashSet<usize> = if keep_count < candidates.len() {
196            // Partition so that the largest `keep_count` items (by abs value) are at [0..keep_count].
197            candidates.select_nth_unstable_by(keep_count, |a, b| {
198                // Descending order: larger absolute values first.
199                b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)
200            });
201            candidates[..keep_count].iter().map(|&(i, _)| i).collect()
202        } else {
203            candidates.iter().map(|&(i, _)| i).collect()
204        };
205
206        // Step 3: build sparse output and update residual.
207        let mut indices: Vec<u32> = Vec::with_capacity(keep_count);
208        let mut values: Vec<f32> = Vec::with_capacity(keep_count);
209
210        // We iterate over `working` in order so that the output is index-sorted.
211        for (i, &val) in working.iter().enumerate() {
212            if kept_indices.contains(&i) {
213                indices.push(i as u32);
214                values.push(val);
215                self.residual[i] = 0.0; // sent — clear residual
216            } else {
217                // Accumulate the working value (original gradient + previous residual)
218                // into the residual for the next round.
219                if self.config.accumulate_residuals {
220                    self.residual[i] = val;
221                } else {
222                    self.residual[i] = 0.0;
223                }
224            }
225        }
226
227        // Step 4: update statistics.
228        let kept = indices.len() as u64;
229        let dropped = (len as u64).saturating_sub(kept);
230        self.stats.total_rounds += 1;
231        self.stats.total_elements_kept += kept;
232        self.stats.total_elements_dropped += dropped;
233
234        SparseGradient {
235            indices,
236            values,
237            original_len: len,
238        }
239    }
240
241    /// Clear the residual buffer (set all entries to zero).
242    pub fn reset_residual(&mut self) {
243        for v in self.residual.iter_mut() {
244            *v = 0.0;
245        }
246    }
247}
248
249// ── DeltaStats ──────────────────────────────────────────────────────────────
250
251/// Cumulative statistics for a [`DeltaEncoder`].
252#[derive(Debug, Clone, Default, Serialize, Deserialize)]
253pub struct DeltaStats {
254    /// Total number of encode calls.
255    pub total_encoded: u64,
256    /// Number of times the full gradient was sent (no previous available).
257    pub total_full_sends: u64,
258    /// Number of times a delta was sent instead of the full gradient.
259    pub total_delta_sends: u64,
260}
261
262// ── GradientDelta (sparsify module variant) ─────────────────────────────────
263
264/// A gradient update that is either a complete gradient or a delta from the
265/// previous round.
266#[derive(Debug, Clone, Serialize, Deserialize)]
267pub struct GradientDelta {
268    /// Either the full gradient vector (`is_full = true`) or the element-wise
269    /// delta from the previous round's gradient.
270    pub values: Vec<f32>,
271    /// `true` when `values` contains the full gradient rather than a delta.
272    pub is_full: bool,
273    /// The federated learning round number this update belongs to.
274    pub round: u64,
275}
276
277impl GradientDelta {
278    /// Compression ratio expressed as mean absolute delta divided by the
279    /// maximum possible delta magnitude.
280    ///
281    /// For full gradients, returns `1.0` (no compression). For delta updates,
282    /// a lower value indicates a smaller delta and therefore better effective
283    /// compression relative to sending the full gradient.
284    ///
285    /// `original_len` is the number of elements in the underlying gradient
286    /// (used for normalisation when `values.len() != original_len`).
287    pub fn compression_ratio(&self, original_len: usize) -> f64 {
288        if self.is_full || self.values.is_empty() || original_len == 0 {
289            return 1.0;
290        }
291        let mean_abs_delta: f64 =
292            self.values.iter().map(|&v| v.abs() as f64).sum::<f64>() / self.values.len() as f64;
293
294        let max_possible: f64 = self
295            .values
296            .iter()
297            .map(|&v| v.abs() as f64)
298            .fold(0.0_f64, f64::max);
299
300        if max_possible == 0.0 {
301            return 0.0;
302        }
303        mean_abs_delta / max_possible
304    }
305}
306
307// ── DeltaEncoder ────────────────────────────────────────────────────────────
308
309/// Stateful encoder that computes element-wise deltas between successive
310/// gradient rounds.
311///
312/// On the first call (or after [`DeltaEncoder::reset`]), the full gradient is
313/// returned. Subsequent calls return the element-wise difference from the
314/// previously stored gradient.
315pub struct DeltaEncoder {
316    /// The gradient that was sent in the previous round.
317    pub previous: Option<Vec<f32>>,
318    /// Cumulative statistics.
319    pub stats: DeltaStats,
320    /// Internal round counter, incremented on every [`encode_delta`] call.
321    round_counter: u64,
322}
323
324impl DeltaEncoder {
325    /// Create a new delta encoder with no prior state.
326    pub fn new() -> Self {
327        Self {
328            previous: None,
329            stats: DeltaStats::default(),
330            round_counter: 0,
331        }
332    }
333
334    /// Encode `current` as either a full gradient or a delta.
335    ///
336    /// - If no previous gradient is stored, the full gradient is returned and
337    ///   `is_full` is set to `true`.
338    /// - Otherwise, the element-wise delta `current[i] - previous[i]` is
339    ///   returned.
340    ///
341    /// The internal round counter is incremented on every call. If the length
342    /// of `current` differs from the stored previous gradient, the previous
343    /// state is discarded and a full send is performed.
344    pub fn encode_delta(&mut self, current: &[f32]) -> GradientDelta {
345        let round = self.round_counter;
346        self.round_counter += 1;
347        self.stats.total_encoded += 1;
348
349        match &self.previous {
350            None => {
351                self.stats.total_full_sends += 1;
352                let values = current.to_vec();
353                self.previous = Some(values.clone());
354                GradientDelta {
355                    values,
356                    is_full: true,
357                    round,
358                }
359            }
360            Some(prev) if prev.len() != current.len() => {
361                // Shape mismatch: treat as a fresh start.
362                self.stats.total_full_sends += 1;
363                let values = current.to_vec();
364                self.previous = Some(values.clone());
365                GradientDelta {
366                    values,
367                    is_full: true,
368                    round,
369                }
370            }
371            Some(prev) => {
372                let delta: Vec<f32> = current
373                    .iter()
374                    .zip(prev.iter())
375                    .map(|(&c, &p)| c - p)
376                    .collect();
377                self.stats.total_delta_sends += 1;
378                self.previous = Some(current.to_vec());
379                GradientDelta {
380                    values: delta,
381                    is_full: false,
382                    round,
383                }
384            }
385        }
386    }
387
388    /// Reconstruct a full gradient from a `base` vector and a [`GradientDelta`].
389    ///
390    /// - If `delta.is_full`, returns a clone of `delta.values`.
391    /// - Otherwise, adds `delta.values[i]` to `base[i]` element-wise.
392    ///
393    /// If the lengths of `base` and `delta.values` disagree, the shorter length
394    /// is used (extra elements in the longer slice are silently ignored).
395    pub fn decode_delta(&self, base: &[f32], delta: &GradientDelta) -> Vec<f32> {
396        if delta.is_full {
397            return delta.values.clone();
398        }
399        let len = base.len().min(delta.values.len());
400        let mut result = base.to_vec();
401        result.truncate(len);
402        for (r, &d) in result.iter_mut().zip(delta.values.iter()) {
403            *r += d;
404        }
405        result
406    }
407
408    /// Clear the stored previous gradient so that the next `encode_delta`
409    /// call performs a full send.
410    pub fn reset(&mut self) {
411        self.previous = None;
412    }
413}
414
415impl Default for DeltaEncoder {
416    fn default() -> Self {
417        Self::new()
418    }
419}
420
421// ── Tests ───────────────────────────────────────────────────────────────────
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426
427    // ── SparseGradient ────────────────────────────────────────────────────
428
429    #[test]
430    fn test_sparse_gradient_sparsity_ratio_all_kept() {
431        let sg = SparseGradient {
432            indices: vec![0, 1, 2, 3],
433            values: vec![1.0, 2.0, 3.0, 4.0],
434            original_len: 4,
435        };
436        let ratio = sg.sparsity_ratio();
437        assert!((ratio - 0.0).abs() < 1e-9, "expected 0.0, got {}", ratio);
438    }
439
440    #[test]
441    fn test_sparse_gradient_sparsity_ratio_half() {
442        let sg = SparseGradient {
443            indices: vec![1, 3],
444            values: vec![0.5, 1.5],
445            original_len: 4,
446        };
447        let ratio = sg.sparsity_ratio();
448        assert!((ratio - 0.5).abs() < 1e-9, "expected 0.5, got {}", ratio);
449    }
450
451    #[test]
452    fn test_sparse_gradient_to_dense_basic() {
453        let sg = SparseGradient {
454            indices: vec![0, 2, 4],
455            values: vec![1.0, 3.0, 5.0],
456            original_len: 6,
457        };
458        let dense = sg.to_dense();
459        assert_eq!(dense, vec![1.0, 0.0, 3.0, 0.0, 5.0, 0.0]);
460    }
461
462    #[test]
463    fn test_sparse_gradient_to_dense_empty() {
464        let sg = SparseGradient {
465            indices: vec![],
466            values: vec![],
467            original_len: 5,
468        };
469        let dense = sg.to_dense();
470        assert_eq!(dense, vec![0.0; 5]);
471    }
472
473    // ── GradientSparsifier: top-k ─────────────────────────────────────────
474
475    #[test]
476    fn test_sparsifier_top_k_keeps_largest() {
477        let config = SparsityConfig {
478            top_k: Some(2),
479            threshold: None,
480            accumulate_residuals: false,
481        };
482        let mut sparsifier = GradientSparsifier::new(config, 5);
483        let gradient = vec![0.1_f32, 5.0, 0.2, 8.0, 0.3];
484        let sparse = sparsifier.sparsify(&gradient);
485
486        // Should keep 8.0 (index 3) and 5.0 (index 1).
487        assert_eq!(sparse.indices.len(), 2);
488        assert!(sparse.values.contains(&8.0), "8.0 must be kept");
489        assert!(sparse.values.contains(&5.0), "5.0 must be kept");
490    }
491
492    #[test]
493    fn test_sparsifier_top_k_respects_absolute_value() {
494        let config = SparsityConfig {
495            top_k: Some(2),
496            threshold: None,
497            accumulate_residuals: false,
498        };
499        let mut sparsifier = GradientSparsifier::new(config, 4);
500        // -9.0 has the largest absolute value, followed by 7.0.
501        let gradient = vec![1.0_f32, -9.0, 7.0, 0.5];
502        let sparse = sparsifier.sparsify(&gradient);
503
504        assert_eq!(sparse.indices.len(), 2);
505        assert!(sparse.values.contains(&-9.0), "-9.0 must be kept");
506        assert!(sparse.values.contains(&7.0), "7.0 must be kept");
507    }
508
509    // ── GradientSparsifier: threshold ─────────────────────────────────────
510
511    #[test]
512    fn test_sparsifier_threshold_drops_small() {
513        let config = SparsityConfig {
514            top_k: None,
515            threshold: Some(1.0),
516            accumulate_residuals: false,
517        };
518        let mut sparsifier = GradientSparsifier::new(config, 5);
519        let gradient = vec![0.1_f32, 5.0, 0.2, 8.0, 0.3];
520        let sparse = sparsifier.sparsify(&gradient);
521
522        // Only 5.0 and 8.0 exceed the threshold.
523        assert_eq!(sparse.indices.len(), 2);
524        let dense = sparse.to_dense();
525        assert_eq!(dense[1], 5.0);
526        assert_eq!(dense[3], 8.0);
527        assert_eq!(dense[0], 0.0);
528        assert_eq!(dense[2], 0.0);
529        assert_eq!(dense[4], 0.0);
530    }
531
532    #[test]
533    fn test_sparsifier_threshold_keeps_all_above() {
534        let config = SparsityConfig {
535            top_k: None,
536            threshold: Some(0.0),
537            accumulate_residuals: false,
538        };
539        let mut sparsifier = GradientSparsifier::new(config, 3);
540        let gradient = vec![1.0_f32, 2.0, 3.0];
541        let sparse = sparsifier.sparsify(&gradient);
542
543        // threshold=0.0 means values with |v| < 0 are dropped, so all are kept.
544        assert_eq!(sparse.indices.len(), 3);
545    }
546
547    // ── Residual accumulation ─────────────────────────────────────────────
548
549    #[test]
550    fn test_residual_accumulation_carries_forward() {
551        let config = SparsityConfig {
552            top_k: Some(1),
553            threshold: None,
554            accumulate_residuals: true,
555        };
556        let mut sparsifier = GradientSparsifier::new(config, 3);
557
558        // Round 1: [0.5, 0.4, 0.3] — only top-1 (0.5 at index 0) is kept.
559        let g1 = vec![0.5_f32, 0.4, 0.3];
560        let _s1 = sparsifier.sparsify(&g1);
561
562        // Residual should now be [0.0, 0.4, 0.3].
563        assert!((sparsifier.residual[0] - 0.0).abs() < 1e-6);
564        assert!((sparsifier.residual[1] - 0.4).abs() < 1e-6);
565        assert!((sparsifier.residual[2] - 0.3).abs() < 1e-6);
566
567        // Round 2: [0.1, 0.1, 0.1] — working = [0.1, 0.5, 0.4].
568        // Top-1 is now index 1 (0.5).
569        let g2 = vec![0.1_f32, 0.1, 0.1];
570        let s2 = sparsifier.sparsify(&g2);
571
572        assert_eq!(s2.indices.len(), 1);
573        assert_eq!(s2.indices[0], 1, "index 1 should be kept in round 2");
574        // Value should be the combined working value 0.1 + 0.4 = 0.5.
575        assert!(
576            (s2.values[0] - 0.5).abs() < 1e-5,
577            "expected 0.5, got {}",
578            s2.values[0]
579        );
580    }
581
582    #[test]
583    fn test_residual_reset_clears_buffer() {
584        let config = SparsityConfig {
585            top_k: Some(1),
586            threshold: None,
587            accumulate_residuals: true,
588        };
589        let mut sparsifier = GradientSparsifier::new(config, 3);
590
591        sparsifier.sparsify(&[0.5_f32, 0.4, 0.3]);
592        // Residuals are non-zero after the first round.
593        assert!(sparsifier.residual.iter().any(|&v| v != 0.0));
594
595        sparsifier.reset_residual();
596        assert!(sparsifier.residual.iter().all(|&v| v == 0.0));
597    }
598
599    // ── Stats accumulation ────────────────────────────────────────────────
600
601    #[test]
602    fn test_sparsifier_stats_accumulation() {
603        let config = SparsityConfig {
604            top_k: Some(2),
605            threshold: None,
606            accumulate_residuals: false,
607        };
608        let mut sparsifier = GradientSparsifier::new(config, 4);
609
610        sparsifier.sparsify(&[1.0_f32, 2.0, 3.0, 4.0]);
611        sparsifier.sparsify(&[0.1_f32, 0.2, 0.3, 0.4]);
612
613        assert_eq!(sparsifier.stats.total_rounds, 2);
614        // Each round keeps 2 out of 4.
615        assert_eq!(sparsifier.stats.total_elements_kept, 4);
616        assert_eq!(sparsifier.stats.total_elements_dropped, 4);
617    }
618
619    // ── DeltaEncoder ──────────────────────────────────────────────────────
620
621    #[test]
622    fn test_delta_encoder_first_call_is_full() {
623        let mut encoder = DeltaEncoder::new();
624        let g = vec![1.0_f32, 2.0, 3.0];
625        let delta = encoder.encode_delta(&g);
626
627        assert!(delta.is_full, "first call must be a full send");
628        assert_eq!(delta.values, g);
629        assert_eq!(delta.round, 0);
630    }
631
632    #[test]
633    fn test_delta_encoder_subsequent_call_is_delta() {
634        let mut encoder = DeltaEncoder::new();
635        let g1 = vec![1.0_f32, 2.0, 3.0];
636        let g2 = vec![1.5_f32, 2.5, 3.5];
637
638        encoder.encode_delta(&g1);
639        let delta = encoder.encode_delta(&g2);
640
641        assert!(!delta.is_full, "second call must be a delta");
642        assert_eq!(delta.values, vec![0.5, 0.5, 0.5]);
643        assert_eq!(delta.round, 1);
644    }
645
646    #[test]
647    fn test_delta_encoder_decode_full() {
648        let encoder = DeltaEncoder::new();
649        let base = vec![0.0_f32; 3];
650        let delta = GradientDelta {
651            values: vec![1.0, 2.0, 3.0],
652            is_full: true,
653            round: 0,
654        };
655        let result = encoder.decode_delta(&base, &delta);
656        assert_eq!(result, vec![1.0, 2.0, 3.0]);
657    }
658
659    #[test]
660    fn test_delta_encoder_decode_reconstructs_correctly() {
661        let mut encoder = DeltaEncoder::new();
662        let g1 = vec![1.0_f32, 2.0, 3.0];
663        let g2 = vec![1.5_f32, 2.0, 4.0];
664
665        let _full = encoder.encode_delta(&g1);
666        let delta = encoder.encode_delta(&g2);
667
668        // Reconstruct from g1 (base) + delta.
669        let encoder2 = DeltaEncoder::new();
670        let reconstructed = encoder2.decode_delta(&g1, &delta);
671        assert_eq!(reconstructed.len(), g2.len());
672        for (r, &expected) in reconstructed.iter().zip(g2.iter()) {
673            assert!(
674                (r - expected).abs() < 1e-5,
675                "mismatch: {} vs {}",
676                r,
677                expected
678            );
679        }
680    }
681
682    #[test]
683    fn test_delta_encoder_reset_forces_full_send() {
684        let mut encoder = DeltaEncoder::new();
685        encoder.encode_delta(&[1.0_f32, 2.0]);
686        encoder.reset();
687
688        let delta = encoder.encode_delta(&[3.0_f32, 4.0]);
689        assert!(delta.is_full, "after reset, send must be full");
690        assert_eq!(delta.values, vec![3.0, 4.0]);
691    }
692
693    #[test]
694    fn test_delta_encoder_stats() {
695        let mut encoder = DeltaEncoder::new();
696        encoder.encode_delta(&[1.0_f32, 2.0]);
697        encoder.encode_delta(&[1.5_f32, 2.5]);
698        encoder.encode_delta(&[2.0_f32, 3.0]);
699
700        assert_eq!(encoder.stats.total_encoded, 3);
701        assert_eq!(encoder.stats.total_full_sends, 1);
702        assert_eq!(encoder.stats.total_delta_sends, 2);
703    }
704
705    #[test]
706    fn test_gradient_delta_compression_ratio_full() {
707        let delta = GradientDelta {
708            values: vec![1.0, 2.0, 3.0],
709            is_full: true,
710            round: 0,
711        };
712        assert!(
713            (delta.compression_ratio(3) - 1.0).abs() < 1e-9,
714            "full gradient compression ratio must be 1.0"
715        );
716    }
717
718    #[test]
719    fn test_gradient_delta_compression_ratio_delta() {
720        // A delta with small changes: mean abs = (0.1+0.1+0.1)/3 = 0.1, max = 0.1 → ratio = 1.0
721        let delta = GradientDelta {
722            values: vec![0.1_f32, 0.1, 0.1],
723            is_full: false,
724            round: 1,
725        };
726        let ratio = delta.compression_ratio(3);
727        assert!(
728            (ratio - 1.0).abs() < 1e-5,
729            "uniform delta should give ratio 1.0, got {}",
730            ratio
731        );
732    }
733
734    #[test]
735    fn test_sparsity_ratio_zero_len() {
736        let sg = SparseGradient {
737            indices: vec![],
738            values: vec![],
739            original_len: 0,
740        };
741        assert_eq!(sg.sparsity_ratio(), 0.0);
742    }
743
744    #[test]
745    fn test_sparsifier_top_k_combined_with_threshold() {
746        // Both top_k and threshold are active: threshold filters first, then top_k.
747        let config = SparsityConfig {
748            top_k: Some(1),
749            threshold: Some(2.0),
750            accumulate_residuals: false,
751        };
752        let mut sparsifier = GradientSparsifier::new(config, 5);
753        // After threshold(2.0): only 5.0 and 8.0 survive; top_k(1) keeps 8.0.
754        let sparse = sparsifier.sparsify(&[0.1_f32, 5.0, 0.2, 8.0, 0.3]);
755
756        assert_eq!(sparse.indices.len(), 1);
757        assert_eq!(sparse.values[0], 8.0);
758    }
759}