1use thiserror::Error;
7
8#[derive(Debug, Error, PartialEq)]
14pub enum AccumulationError {
15 #[error("dimension mismatch: expected {expected}, got {actual}")]
17 DimensionMismatch { expected: usize, actual: usize },
18
19 #[error("no gradients pending in accumulator")]
21 EmptyAccumulator,
22
23 #[error("weight sum is zero; cannot compute weighted average")]
25 WeightSumZero,
26
27 #[error("clip value must be positive")]
29 ClipValueNonPositive,
30}
31
32#[derive(Debug, Clone)]
38pub struct PeerGradient {
39 pub peer_id: String,
41 pub gradient: Vec<f32>,
43 pub weight: f64,
45 pub round: u64,
47}
48
49#[derive(Debug, Clone, PartialEq)]
55pub enum ClipStrategy {
56 None,
58 GlobalNorm {
60 max_norm: f32,
62 },
63 PerElement {
65 max_abs: f32,
67 },
68}
69
70#[derive(Debug, Clone, Default)]
76pub struct AccumulatorStats {
77 pub peers_accumulated: usize,
79 pub total_rounds: u64,
81 pub last_weight_sum: f64,
83 pub clipped_count: u64,
85}
86
87#[derive(Debug)]
94pub struct GradientAccumulator {
95 pub dimension: usize,
97 pub clip_strategy: ClipStrategy,
99 pub pending: Vec<PeerGradient>,
101 pub stats: AccumulatorStats,
103}
104
105impl GradientAccumulator {
106 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 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 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 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 let was_clipped = self.apply_clip(&mut result);
161
162 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 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 pub fn pending_count(&self) -> usize {
211 self.pending.len()
212 }
213
214 pub fn stats(&self) -> &AccumulatorStats {
216 &self.stats
217 }
218
219 pub fn reset(&mut self) {
221 self.pending.clear();
222 self.stats = AccumulatorStats::default();
223 }
224}
225
226#[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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
317 fn test_commit_unequal_weights() {
318 let mut acc = GradientAccumulator::new(1, ClipStrategy::None);
319 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 #[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 #[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 #[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]; 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 assert!((grad[0] - 0.6).abs() < 1e-5);
368 assert!((grad[1] - 0.8).abs() < 1e-5);
369 }
370
371 #[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]; 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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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"); acc.commit().expect("test: should succeed");
471 assert_eq!(acc.stats().clipped_count, 1);
472
473 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}