Skip to main content

ipfrs_tensorlogic/
gradient_accumulator.rs

1//! Gradient accumulator for federated learning rounds.
2//!
3//! Accumulates gradients from multiple federated peers across rounds,
4//! applying weighted averaging and optional clipping before committing.
5
6use thiserror::Error;
7
8// ---------------------------------------------------------------------------
9// Error type
10// ---------------------------------------------------------------------------
11
12/// Errors that can occur during gradient accumulation.
13#[derive(Debug, Error, PartialEq)]
14pub enum AccumulationError {
15    /// Gradient vector has wrong length.
16    #[error("dimension mismatch: expected {expected}, got {actual}")]
17    DimensionMismatch { expected: usize, actual: usize },
18
19    /// Commit called with no pending gradients.
20    #[error("no gradients pending in accumulator")]
21    EmptyAccumulator,
22
23    /// All peer weights sum to zero — cannot compute a weighted average.
24    #[error("weight sum is zero; cannot compute weighted average")]
25    WeightSumZero,
26
27    /// Clip value supplied to a strategy must be strictly positive.
28    #[error("clip value must be positive")]
29    ClipValueNonPositive,
30}
31
32// ---------------------------------------------------------------------------
33// PeerGradient
34// ---------------------------------------------------------------------------
35
36/// A gradient vector contributed by a single federated peer.
37#[derive(Debug, Clone)]
38pub struct PeerGradient {
39    /// Unique identifier for the contributing peer.
40    pub peer_id: String,
41    /// The raw gradient values.
42    pub gradient: Vec<f32>,
43    /// Contribution weight, e.g. `local_samples / total_samples`.
44    pub weight: f64,
45    /// Federated round in which this gradient was produced.
46    pub round: u64,
47}
48
49// ---------------------------------------------------------------------------
50// ClipStrategy
51// ---------------------------------------------------------------------------
52
53/// Strategy used to clip / clamp gradients before committing.
54#[derive(Debug, Clone, PartialEq)]
55pub enum ClipStrategy {
56    /// No clipping is applied.
57    None,
58    /// Clip the entire gradient vector so that its L2-norm ≤ `max_norm`.
59    GlobalNorm {
60        /// Maximum allowed L2-norm.
61        max_norm: f32,
62    },
63    /// Clamp every element independently to `[-max_abs, max_abs]`.
64    PerElement {
65        /// Maximum allowed absolute value per element.
66        max_abs: f32,
67    },
68}
69
70// ---------------------------------------------------------------------------
71// AccumulatorStats
72// ---------------------------------------------------------------------------
73
74/// Running statistics for a [`GradientAccumulator`].
75#[derive(Debug, Clone, Default)]
76pub struct AccumulatorStats {
77    /// Total number of peer gradients accumulated (across all commits).
78    pub peers_accumulated: usize,
79    /// Number of times [`GradientAccumulator::commit`] has been called successfully.
80    pub total_rounds: u64,
81    /// Weight sum used in the most recent commit.
82    pub last_weight_sum: f64,
83    /// Total number of gradient vectors that were clipped during commit.
84    pub clipped_count: u64,
85}
86
87// ---------------------------------------------------------------------------
88// GradientAccumulator
89// ---------------------------------------------------------------------------
90
91/// Accumulates gradients from multiple federated peers and produces a
92/// weighted-averaged, optionally-clipped aggregate on each commit.
93#[derive(Debug)]
94pub struct GradientAccumulator {
95    /// Expected length of every gradient vector.
96    pub dimension: usize,
97    /// Clipping strategy applied after averaging.
98    pub clip_strategy: ClipStrategy,
99    /// Gradients waiting to be committed.
100    pub pending: Vec<PeerGradient>,
101    /// Running statistics updated on each successful commit.
102    pub stats: AccumulatorStats,
103}
104
105impl GradientAccumulator {
106    /// Create a new accumulator expecting gradients of length `dimension`.
107    pub fn new(dimension: usize, clip_strategy: ClipStrategy) -> Self {
108        Self {
109            dimension,
110            clip_strategy,
111            pending: Vec::new(),
112            stats: AccumulatorStats::default(),
113        }
114    }
115
116    /// Add a peer gradient to the pending queue.
117    ///
118    /// Returns [`AccumulationError::DimensionMismatch`] when
119    /// `pg.gradient.len() != self.dimension`.
120    pub fn add(&mut self, pg: PeerGradient) -> Result<(), AccumulationError> {
121        if pg.gradient.len() != self.dimension {
122            return Err(AccumulationError::DimensionMismatch {
123                expected: self.dimension,
124                actual: pg.gradient.len(),
125            });
126        }
127        self.pending.push(pg);
128        Ok(())
129    }
130
131    /// Commit all pending gradients, producing a weighted average.
132    ///
133    /// After a successful commit the pending queue is cleared and
134    /// [`AccumulatorStats`] is updated.
135    ///
136    /// # Errors
137    ///
138    /// - [`AccumulationError::EmptyAccumulator`] — no pending gradients.
139    /// - [`AccumulationError::WeightSumZero`] — all weights are `0.0`.
140    pub fn commit(&mut self) -> Result<Vec<f32>, AccumulationError> {
141        if self.pending.is_empty() {
142            return Err(AccumulationError::EmptyAccumulator);
143        }
144
145        let weight_sum: f64 = self.pending.iter().map(|pg| pg.weight).sum();
146        if weight_sum == 0.0 {
147            return Err(AccumulationError::WeightSumZero);
148        }
149
150        // Compute weighted average.
151        let mut result = vec![0.0_f32; self.dimension];
152        for pg in &self.pending {
153            let scale = pg.weight / weight_sum;
154            for (r, g) in result.iter_mut().zip(pg.gradient.iter()) {
155                *r += ((*g as f64) * scale) as f32;
156            }
157        }
158
159        // Apply clipping and track whether it happened.
160        let was_clipped = self.apply_clip(&mut result);
161
162        // Update stats.
163        self.stats.total_rounds += 1;
164        self.stats.peers_accumulated += self.pending.len();
165        self.stats.last_weight_sum = weight_sum;
166        if was_clipped {
167            self.stats.clipped_count += 1;
168        }
169
170        self.pending.clear();
171        Ok(result)
172    }
173
174    /// Apply the accumulator's [`ClipStrategy`] to `grad` in-place.
175    ///
176    /// Returns `true` if any modification was made.
177    pub fn apply_clip(&self, grad: &mut [f32]) -> bool {
178        match &self.clip_strategy {
179            ClipStrategy::None => false,
180
181            ClipStrategy::GlobalNorm { max_norm } => {
182                let norm_sq: f32 = grad.iter().map(|x| x * x).sum();
183                let norm = norm_sq.sqrt();
184                if norm > *max_norm {
185                    let scale = max_norm / norm;
186                    for x in grad.iter_mut() {
187                        *x *= scale;
188                    }
189                    true
190                } else {
191                    false
192                }
193            }
194
195            ClipStrategy::PerElement { max_abs } => {
196                let mut clipped = false;
197                for x in grad.iter_mut() {
198                    let clamped = x.clamp(-max_abs, *max_abs);
199                    if clamped != *x {
200                        *x = clamped;
201                        clipped = true;
202                    }
203                }
204                clipped
205            }
206        }
207    }
208
209    /// Number of gradients currently waiting to be committed.
210    pub fn pending_count(&self) -> usize {
211        self.pending.len()
212    }
213
214    /// Borrow the current statistics.
215    pub fn stats(&self) -> &AccumulatorStats {
216        &self.stats
217    }
218
219    /// Clear all pending gradients and reset statistics to defaults.
220    pub fn reset(&mut self) {
221        self.pending.clear();
222        self.stats = AccumulatorStats::default();
223    }
224}
225
226// ---------------------------------------------------------------------------
227// Tests
228// ---------------------------------------------------------------------------
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    fn make_peer(id: &str, gradient: Vec<f32>, weight: f64, round: u64) -> PeerGradient {
235        PeerGradient {
236            peer_id: id.to_string(),
237            gradient,
238            weight,
239            round,
240        }
241    }
242
243    // 1. new() sets dimension and strategy
244    #[test]
245    fn test_new_sets_fields() {
246        let acc = GradientAccumulator::new(4, ClipStrategy::None);
247        assert_eq!(acc.dimension, 4);
248        assert_eq!(acc.clip_strategy, ClipStrategy::None);
249        assert_eq!(acc.pending_count(), 0);
250    }
251
252    // 2. add() valid gradient succeeds
253    #[test]
254    fn test_add_valid_gradient() {
255        let mut acc = GradientAccumulator::new(3, ClipStrategy::None);
256        let pg = make_peer("a", vec![1.0, 2.0, 3.0], 1.0, 1);
257        assert!(acc.add(pg).is_ok());
258        assert_eq!(acc.pending_count(), 1);
259    }
260
261    // 3. add() wrong dimension returns DimensionMismatch
262    #[test]
263    fn test_add_wrong_dimension() {
264        let mut acc = GradientAccumulator::new(3, ClipStrategy::None);
265        let pg = make_peer("a", vec![1.0, 2.0], 1.0, 1);
266        match acc.add(pg) {
267            Err(AccumulationError::DimensionMismatch { expected, actual }) => {
268                assert_eq!(expected, 3);
269                assert_eq!(actual, 2);
270            }
271            other => panic!("expected DimensionMismatch, got {:?}", other),
272        }
273    }
274
275    // 4. commit() empty returns EmptyAccumulator
276    #[test]
277    fn test_commit_empty() {
278        let mut acc = GradientAccumulator::new(3, ClipStrategy::None);
279        assert_eq!(acc.commit(), Err(AccumulationError::EmptyAccumulator));
280    }
281
282    // 5. commit() single peer returns its gradient
283    #[test]
284    fn test_commit_single_peer() {
285        let mut acc = GradientAccumulator::new(3, ClipStrategy::None);
286        acc.add(make_peer("a", vec![1.0, 2.0, 3.0], 1.0, 1))
287            .expect("test: should succeed");
288        let result = acc.commit().expect("test: should succeed");
289        assert!((result[0] - 1.0).abs() < 1e-6);
290        assert!((result[1] - 2.0).abs() < 1e-6);
291        assert!((result[2] - 3.0).abs() < 1e-6);
292    }
293
294    // 6. commit() two equal-weight peers averages correctly
295    #[test]
296    fn test_commit_two_equal_weight_peers() {
297        let mut acc = GradientAccumulator::new(2, ClipStrategy::None);
298        acc.add(make_peer("a", vec![0.0, 4.0], 1.0, 1))
299            .expect("test: should succeed");
300        acc.add(make_peer("b", vec![2.0, 0.0], 1.0, 1))
301            .expect("test: should succeed");
302        let result = acc.commit().expect("test: should succeed");
303        assert!(
304            (result[0] - 1.0).abs() < 1e-5,
305            "expected 1.0 got {}",
306            result[0]
307        );
308        assert!(
309            (result[1] - 2.0).abs() < 1e-5,
310            "expected 2.0 got {}",
311            result[1]
312        );
313    }
314
315    // 7. commit() two unequal weights: heavier peer dominates
316    #[test]
317    fn test_commit_unequal_weights() {
318        let mut acc = GradientAccumulator::new(1, ClipStrategy::None);
319        // peer A: gradient [10.0], weight 0.9
320        // peer B: gradient [0.0],  weight 0.1
321        // expected: 10.0 * 0.9 + 0.0 * 0.1 = 9.0
322        acc.add(make_peer("a", vec![10.0], 0.9, 1))
323            .expect("test: should succeed");
324        acc.add(make_peer("b", vec![0.0], 0.1, 1))
325            .expect("test: should succeed");
326        let result = acc.commit().expect("test: should succeed");
327        assert!(
328            (result[0] - 9.0).abs() < 1e-5,
329            "expected 9.0 got {}",
330            result[0]
331        );
332    }
333
334    // 8. commit() zero weight sum returns WeightSumZero
335    #[test]
336    fn test_commit_zero_weight_sum() {
337        let mut acc = GradientAccumulator::new(2, ClipStrategy::None);
338        acc.add(make_peer("a", vec![1.0, 2.0], 0.0, 1))
339            .expect("test: should succeed");
340        assert_eq!(acc.commit(), Err(AccumulationError::WeightSumZero));
341    }
342
343    // 9. commit() clears pending after success
344    #[test]
345    fn test_commit_clears_pending() {
346        let mut acc = GradientAccumulator::new(2, ClipStrategy::None);
347        acc.add(make_peer("a", vec![1.0, 2.0], 1.0, 1))
348            .expect("test: should succeed");
349        acc.commit().expect("test: should succeed");
350        assert_eq!(acc.pending_count(), 0);
351    }
352
353    // 10. apply_clip GlobalNorm: norm > max_norm clips correctly
354    #[test]
355    fn test_apply_clip_global_norm_clips() {
356        let acc = GradientAccumulator::new(2, ClipStrategy::GlobalNorm { max_norm: 1.0 });
357        let mut grad = vec![3.0_f32, 4.0_f32]; // L2 norm = 5.0
358        let clipped = acc.apply_clip(&mut grad);
359        assert!(clipped);
360        let norm_after: f32 = grad.iter().map(|x| x * x).sum::<f32>().sqrt();
361        assert!(
362            (norm_after - 1.0).abs() < 1e-6,
363            "norm_after = {}",
364            norm_after
365        );
366        // Direction should be preserved: (3/5, 4/5)
367        assert!((grad[0] - 0.6).abs() < 1e-5);
368        assert!((grad[1] - 0.8).abs() < 1e-5);
369    }
370
371    // 11. apply_clip GlobalNorm: norm <= max_norm unchanged, returns false
372    #[test]
373    fn test_apply_clip_global_norm_no_clip() {
374        let acc = GradientAccumulator::new(2, ClipStrategy::GlobalNorm { max_norm: 10.0 });
375        let mut grad = vec![3.0_f32, 4.0_f32]; // norm = 5.0 < 10.0
376        let clipped = acc.apply_clip(&mut grad);
377        assert!(!clipped);
378        assert!((grad[0] - 3.0).abs() < 1e-6);
379        assert!((grad[1] - 4.0).abs() < 1e-6);
380    }
381
382    // 12. apply_clip PerElement: values clamped correctly
383    #[test]
384    fn test_apply_clip_per_element_clamps() {
385        let acc = GradientAccumulator::new(3, ClipStrategy::PerElement { max_abs: 1.0 });
386        let mut grad = vec![2.0_f32, -3.0_f32, 0.5_f32];
387        let clipped = acc.apply_clip(&mut grad);
388        assert!(clipped);
389        assert!((grad[0] - 1.0).abs() < 1e-6);
390        assert!((grad[1] - (-1.0)).abs() < 1e-6);
391        assert!((grad[2] - 0.5).abs() < 1e-6);
392    }
393
394    // 13. apply_clip PerElement: within bounds unchanged
395    #[test]
396    fn test_apply_clip_per_element_no_clamp() {
397        let acc = GradientAccumulator::new(3, ClipStrategy::PerElement { max_abs: 5.0 });
398        let mut grad = vec![1.0_f32, -2.0_f32, 3.0_f32];
399        let clipped = acc.apply_clip(&mut grad);
400        assert!(!clipped);
401        assert!((grad[0] - 1.0).abs() < 1e-6);
402        assert!((grad[1] - (-2.0)).abs() < 1e-6);
403        assert!((grad[2] - 3.0).abs() < 1e-6);
404    }
405
406    // 14. apply_clip None: unchanged
407    #[test]
408    fn test_apply_clip_none() {
409        let acc = GradientAccumulator::new(2, ClipStrategy::None);
410        let mut grad = vec![100.0_f32, -200.0_f32];
411        let clipped = acc.apply_clip(&mut grad);
412        assert!(!clipped);
413        assert!((grad[0] - 100.0).abs() < 1e-6);
414        assert!((grad[1] - (-200.0)).abs() < 1e-6);
415    }
416
417    // 15. stats() updated after commit
418    #[test]
419    fn test_stats_updated_after_commit() {
420        let mut acc = GradientAccumulator::new(2, ClipStrategy::None);
421        acc.add(make_peer("a", vec![1.0, 2.0], 0.6, 1))
422            .expect("test: should succeed");
423        acc.add(make_peer("b", vec![3.0, 4.0], 0.4, 1))
424            .expect("test: should succeed");
425        acc.commit().expect("test: should succeed");
426        let s = acc.stats();
427        assert_eq!(s.total_rounds, 1);
428        assert_eq!(s.peers_accumulated, 2);
429        assert!((s.last_weight_sum - 1.0).abs() < 1e-10);
430    }
431
432    // 16. pending_count() accurate
433    #[test]
434    fn test_pending_count() {
435        let mut acc = GradientAccumulator::new(2, ClipStrategy::None);
436        assert_eq!(acc.pending_count(), 0);
437        acc.add(make_peer("a", vec![1.0, 2.0], 1.0, 1))
438            .expect("test: should succeed");
439        assert_eq!(acc.pending_count(), 1);
440        acc.add(make_peer("b", vec![3.0, 4.0], 1.0, 2))
441            .expect("test: should succeed");
442        assert_eq!(acc.pending_count(), 2);
443    }
444
445    // 17. reset() clears pending and stats
446    #[test]
447    fn test_reset() {
448        let mut acc = GradientAccumulator::new(2, ClipStrategy::None);
449        acc.add(make_peer("a", vec![1.0, 2.0], 1.0, 1))
450            .expect("test: should succeed");
451        acc.commit().expect("test: should succeed");
452        // Add one more but don't commit
453        acc.add(make_peer("b", vec![3.0, 4.0], 1.0, 2))
454            .expect("test: should succeed");
455        acc.reset();
456        assert_eq!(acc.pending_count(), 0);
457        let s = acc.stats();
458        assert_eq!(s.total_rounds, 0);
459        assert_eq!(s.peers_accumulated, 0);
460        assert_eq!(s.last_weight_sum, 0.0);
461        assert_eq!(s.clipped_count, 0);
462    }
463
464    // Bonus: clipped_count increments when GlobalNorm clips during commit
465    #[test]
466    fn test_stats_clipped_count_increments() {
467        let mut acc = GradientAccumulator::new(2, ClipStrategy::GlobalNorm { max_norm: 1.0 });
468        acc.add(make_peer("a", vec![3.0, 4.0], 1.0, 1))
469            .expect("test: should succeed"); // norm = 5 > 1 → clipped
470        acc.commit().expect("test: should succeed");
471        assert_eq!(acc.stats().clipped_count, 1);
472
473        // Second commit with a small gradient — should NOT increment clipped_count
474        acc.add(make_peer("b", vec![0.1, 0.1], 1.0, 2))
475            .expect("test: should succeed");
476        acc.commit().expect("test: should succeed");
477        assert_eq!(acc.stats().clipped_count, 1);
478    }
479}