1use crate::error::{OptimError, Result};
74use scirs2_core::ndarray::Array1;
75use scirs2_core::random::Random;
76use serde::{Deserialize, Serialize};
77use std::collections::{HashMap, HashSet};
78
79pub type ClientId = u64;
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct SecureAggregationConfig {
90 pub num_clients: usize,
92
93 pub gradient_dim: usize,
95
96 pub round_seed: u64,
98
99 pub quantization_scale: f64,
104
105 pub modulus: i64,
110
111 pub support_dropouts: bool,
113}
114
115impl Default for SecureAggregationConfig {
116 fn default() -> Self {
117 Self {
118 num_clients: 0,
119 gradient_dim: 0,
120 round_seed: 42,
121 quantization_scale: 1.0e6,
122 modulus: 2_147_483_647,
126 support_dropouts: true,
127 }
128 }
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
137pub struct MaskedGradient {
138 pub client_id: ClientId,
140
141 pub values: Vec<i64>,
143}
144
145#[derive(Debug, Clone)]
152pub struct SecureAggregator {
153 config: SecureAggregationConfig,
154 received: HashMap<ClientId, MaskedGradient>,
155 dropped: HashSet<ClientId>,
156 pairwise_mask_cache: HashMap<(ClientId, ClientId), Vec<i64>>,
157}
158
159pub fn derive_pairwise_seed(client_a: ClientId, client_b: ClientId, round_seed: u64) -> u64 {
176 let (lo, hi) = if client_a <= client_b {
177 (client_a, client_b)
178 } else {
179 (client_b, client_a)
180 };
181
182 let mut h = round_seed;
187 h ^= lo.rotate_left(13);
188 h ^= hi.rotate_left(29);
189 h = h.wrapping_mul(0x9E37_79B9_7F4A_7C15_u64);
190 h ^= h >> 30;
191 h = h.wrapping_mul(0xBF58_476D_1CE4_E5B9_u64);
192 h ^= h >> 27;
193 h = h.wrapping_mul(0x94D0_49BB_1331_11EB_u64);
194 h ^= h >> 31;
195 h
196}
197
198pub fn generate_pairwise_mask(seed: u64, dim: usize, modulus: i64) -> Vec<i64> {
205 if dim == 0 || modulus <= 0 {
206 return Vec::new();
207 }
208
209 let mut rng = Random::seed(seed);
210 let mut mask = Vec::with_capacity(dim);
211 for _ in 0..dim {
212 let v: i64 = rng.gen_range(0..modulus);
213 mask.push(v);
214 }
215 mask
216}
217
218pub fn quantize_gradient(gradient: &Array1<f64>, scale: f64, modulus: i64) -> Result<Vec<i64>> {
225 if !scale.is_finite() || scale <= 0.0 {
226 return Err(OptimError::InvalidParameter(format!(
227 "quantization scale must be positive and finite, got {scale}"
228 )));
229 }
230 if modulus <= 1 {
231 return Err(OptimError::InvalidParameter(format!(
232 "modulus must be > 1, got {modulus}"
233 )));
234 }
235
236 let mut out = Vec::with_capacity(gradient.len());
237 for &g in gradient.iter() {
238 if !g.is_finite() {
239 return Err(OptimError::InvalidParameter(format!(
240 "gradient entries must be finite, got {g}"
241 )));
242 }
243 let scaled = (g * scale).round();
244 let clipped = scaled.max(i64::MIN as f64).min(i64::MAX as f64) as i64;
247 out.push(clipped.rem_euclid(modulus));
248 }
249 Ok(out)
250}
251
252pub fn dequantize_gradient(quantized: &[i64], scale: f64, modulus: i64) -> Vec<f64> {
259 if scale <= 0.0 || modulus <= 1 {
260 return Vec::new();
261 }
262 let half = modulus / 2;
263 quantized
264 .iter()
265 .map(|&q| {
266 let centred = if q > half { q - modulus } else { q };
267 (centred as f64) / scale
268 })
269 .collect()
270}
271
272pub fn compute_client_mask(
286 client_id: ClientId,
287 all_client_ids: &[ClientId],
288 round_seed: u64,
289 dim: usize,
290 modulus: i64,
291) -> Result<Vec<i64>> {
292 if modulus <= 1 {
293 return Err(OptimError::InvalidParameter(format!(
294 "modulus must be > 1, got {modulus}"
295 )));
296 }
297 if !all_client_ids.contains(&client_id) {
298 return Err(OptimError::InvalidParameter(format!(
299 "client {client_id} not found in participating client set"
300 )));
301 }
302
303 let mut unique: Vec<ClientId> = all_client_ids.to_vec();
305 unique.sort_unstable();
306 unique.dedup();
307
308 let mut total = vec![0_i64; dim];
309 for &other_id in unique.iter() {
310 if other_id == client_id {
311 continue;
312 }
313 let seed = derive_pairwise_seed(client_id, other_id, round_seed);
314 let pairwise = generate_pairwise_mask(seed, dim, modulus);
315 if client_id < other_id {
316 for (acc, m) in total.iter_mut().zip(pairwise.iter()) {
317 *acc = (*acc + *m).rem_euclid(modulus);
318 }
319 } else {
320 for (acc, m) in total.iter_mut().zip(pairwise.iter()) {
321 *acc = (*acc - *m).rem_euclid(modulus);
322 }
323 }
324 }
325
326 Ok(total)
327}
328
329pub fn submit_gradient(
332 client_id: ClientId,
333 gradient: &Array1<f64>,
334 all_client_ids: &[ClientId],
335 config: &SecureAggregationConfig,
336) -> Result<MaskedGradient> {
337 if gradient.len() != config.gradient_dim {
338 return Err(OptimError::DimensionMismatch(format!(
339 "gradient length {} does not match configured gradient_dim {}",
340 gradient.len(),
341 config.gradient_dim
342 )));
343 }
344
345 let quantised = quantize_gradient(gradient, config.quantization_scale, config.modulus)?;
346 let mask = compute_client_mask(
347 client_id,
348 all_client_ids,
349 config.round_seed,
350 config.gradient_dim,
351 config.modulus,
352 )?;
353
354 let mut values = Vec::with_capacity(quantised.len());
355 for (q, m) in quantised.iter().zip(mask.iter()) {
356 values.push((*q + *m).rem_euclid(config.modulus));
357 }
358
359 Ok(MaskedGradient { client_id, values })
360}
361
362impl SecureAggregator {
367 pub fn new(config: SecureAggregationConfig) -> Result<Self> {
375 if config.num_clients == 0 {
376 return Err(OptimError::InvalidConfig(
377 "num_clients must be greater than zero".to_string(),
378 ));
379 }
380 if config.gradient_dim == 0 {
381 return Err(OptimError::InvalidConfig(
382 "gradient_dim must be greater than zero".to_string(),
383 ));
384 }
385 if config.modulus <= 1 {
386 return Err(OptimError::InvalidConfig(format!(
387 "modulus must be > 1, got {}",
388 config.modulus
389 )));
390 }
391 if !config.quantization_scale.is_finite() || config.quantization_scale <= 0.0 {
392 return Err(OptimError::InvalidConfig(format!(
393 "quantization_scale must be positive and finite, got {}",
394 config.quantization_scale
395 )));
396 }
397 let min_modulus = (config.quantization_scale * 10.0).ceil() as i128;
400 if (config.modulus as i128) < min_modulus {
401 return Err(OptimError::InvalidConfig(format!(
402 "modulus {} is too small for quantization_scale {}; need at least {}",
403 config.modulus, config.quantization_scale, min_modulus
404 )));
405 }
406
407 Ok(Self {
408 config,
409 received: HashMap::new(),
410 dropped: HashSet::new(),
411 pairwise_mask_cache: HashMap::new(),
412 })
413 }
414
415 pub fn receive(&mut self, submission: MaskedGradient) -> Result<()> {
422 if submission.values.len() != self.config.gradient_dim {
423 return Err(OptimError::DimensionMismatch(format!(
424 "submission from client {} has {} values, expected {}",
425 submission.client_id,
426 submission.values.len(),
427 self.config.gradient_dim
428 )));
429 }
430 for &v in submission.values.iter() {
431 if v < 0 || v >= self.config.modulus {
432 return Err(OptimError::InvalidParameter(format!(
433 "submission value {v} from client {} is outside [0, {})",
434 submission.client_id, self.config.modulus
435 )));
436 }
437 }
438
439 let client_id = submission.client_id;
440 self.dropped.remove(&client_id);
442 self.received.insert(client_id, submission);
443 Ok(())
444 }
445
446 pub fn mark_dropped(&mut self, client_id: ClientId) -> Result<()> {
452 if self.received.contains_key(&client_id) {
453 return Err(OptimError::InvalidParameter(format!(
454 "client {client_id} has already submitted; cannot mark as dropped"
455 )));
456 }
457 self.dropped.insert(client_id);
458 Ok(())
459 }
460
461 pub fn aggregate(&self) -> Result<Array1<f64>> {
470 let total_known = self.received.len() + self.dropped.len();
471 if total_known < self.config.num_clients {
472 return Err(OptimError::InvalidConfig(format!(
473 "incomplete round: {} received + {} dropped < num_clients = {}",
474 self.received.len(),
475 self.dropped.len(),
476 self.config.num_clients
477 )));
478 }
479 if self.received.is_empty() {
480 return Err(OptimError::InvalidConfig(
481 "cannot aggregate: no client submissions received".to_string(),
482 ));
483 }
484
485 let modulus = self.config.modulus;
486 let dim = self.config.gradient_dim;
487
488 let mut acc = vec![0_i64; dim];
491 for submission in self.received.values() {
492 for (a, v) in acc.iter_mut().zip(submission.values.iter()) {
493 *a = (*a + *v).rem_euclid(modulus);
494 }
495 }
496
497 if self.config.support_dropouts && !self.dropped.is_empty() {
503 let online_ids: Vec<ClientId> = self.received.keys().copied().collect();
504 for &dropped_id in self.dropped.iter() {
505 let dropped_mask = compute_client_mask(
513 dropped_id,
514 &Self::peer_universe(&online_ids, dropped_id),
515 self.config.round_seed,
516 dim,
517 modulus,
518 )?;
519 for (a, m) in acc.iter_mut().zip(dropped_mask.iter()) {
520 *a = (*a + *m).rem_euclid(modulus);
521 }
522 }
523 }
524
525 Ok(Array1::from(dequantize_gradient(
526 &acc,
527 self.config.quantization_scale,
528 modulus,
529 )))
530 }
531
532 pub fn reset(&mut self) {
534 self.received.clear();
535 self.dropped.clear();
536 self.pairwise_mask_cache.clear();
537 }
538
539 pub fn received_count(&self) -> usize {
541 self.received.len()
542 }
543
544 pub fn dropped_count(&self) -> usize {
546 self.dropped.len()
547 }
548
549 pub fn config(&self) -> &SecureAggregationConfig {
551 &self.config
552 }
553
554 fn peer_universe(online_ids: &[ClientId], dropped_id: ClientId) -> Vec<ClientId> {
559 let mut universe = Vec::with_capacity(online_ids.len() + 1);
560 universe.push(dropped_id);
561 universe.extend_from_slice(online_ids);
562 universe
563 }
564}
565
566#[cfg(test)]
567mod tests {
568 use super::*;
569
570 const QUANT_TOL: f64 = 1.0e-4;
572
573 fn make_config(num_clients: usize, dim: usize) -> SecureAggregationConfig {
574 SecureAggregationConfig {
575 num_clients,
576 gradient_dim: dim,
577 round_seed: 17,
578 quantization_scale: 1.0e6,
579 modulus: 2_147_483_647,
580 support_dropouts: true,
581 }
582 }
583
584 fn vec_close(a: &Array1<f64>, b: &Array1<f64>, tol: f64) -> bool {
585 if a.len() != b.len() {
586 return false;
587 }
588 a.iter().zip(b.iter()).all(|(x, y)| (x - y).abs() <= tol)
589 }
590
591 #[test]
594 fn test_pairwise_seed_symmetric() {
595 assert_eq!(
596 derive_pairwise_seed(1, 2, 42),
597 derive_pairwise_seed(2, 1, 42)
598 );
599 assert_eq!(
600 derive_pairwise_seed(7, 41, 100),
601 derive_pairwise_seed(41, 7, 100)
602 );
603 assert_eq!(
604 derive_pairwise_seed(0, u64::MAX, 0),
605 derive_pairwise_seed(u64::MAX, 0, 0)
606 );
607 }
608
609 #[test]
610 fn test_pairwise_seed_changes_with_round_seed() {
611 let s1 = derive_pairwise_seed(3, 5, 1);
612 let s2 = derive_pairwise_seed(3, 5, 2);
613 let s3 = derive_pairwise_seed(3, 5, 12345);
614 assert_ne!(
615 s1, s2,
616 "different round seeds must produce different mask seeds"
617 );
618 assert_ne!(s1, s3);
619 assert_ne!(s2, s3);
620 }
621
622 #[test]
623 fn test_pairwise_seed_differs_across_pairs() {
624 let mut seen = HashSet::new();
628 let round = 999;
629 for a in 0_u64..20 {
630 for b in (a + 1)..20 {
631 let s = derive_pairwise_seed(a, b, round);
632 assert!(seen.insert(s), "duplicate seed for pair ({a}, {b}): {s}");
633 }
634 }
635 }
636
637 #[test]
640 fn test_generate_pairwise_mask_deterministic_with_seed() {
641 let m1 = generate_pairwise_mask(123_456, 32, 2_147_483_647);
642 let m2 = generate_pairwise_mask(123_456, 32, 2_147_483_647);
643 assert_eq!(m1, m2);
644 }
645
646 #[test]
647 fn test_generate_pairwise_mask_correct_length() {
648 let m = generate_pairwise_mask(7, 100, 2_147_483_647);
649 assert_eq!(m.len(), 100);
650 let m_empty = generate_pairwise_mask(7, 0, 2_147_483_647);
651 assert_eq!(m_empty.len(), 0);
652 }
653
654 #[test]
655 fn test_generate_pairwise_mask_values_in_modulus_range() {
656 let modulus = 2_147_483_647_i64;
657 let m = generate_pairwise_mask(0xDEAD_BEEF, 512, modulus);
658 for v in m {
659 assert!(v >= 0, "mask values must be non-negative, got {v}");
660 assert!(v < modulus, "mask values must be < modulus, got {v}");
661 }
662 }
663
664 #[test]
665 fn test_generate_pairwise_mask_changes_with_seed() {
666 let m1 = generate_pairwise_mask(1, 32, 2_147_483_647);
667 let m2 = generate_pairwise_mask(2, 32, 2_147_483_647);
668 assert_ne!(m1, m2);
669 }
670
671 #[test]
674 fn test_quantize_dequantize_roundtrip() {
675 let scale = 1.0e6;
676 let modulus = 2_147_483_647_i64;
677 let input = Array1::from(vec![1.5, -2.3, 0.0, 4.7]);
678 let q = quantize_gradient(&input, scale, modulus).expect("quantise must succeed");
679 let back = dequantize_gradient(&q, scale, modulus);
680 let back_arr = Array1::from(back);
681 assert!(
682 vec_close(&input, &back_arr, 1.0e-5),
683 "round-trip failed: {input:?} -> {q:?} -> {back_arr:?}"
684 );
685 }
686
687 #[test]
688 fn test_round_trip_quantize_handles_negatives() {
689 let scale = 1.0e6;
690 let modulus = 2_147_483_647_i64;
691 let input = Array1::from(vec![-1.5, -3.5, -0.000_5]);
692 let q = quantize_gradient(&input, scale, modulus).expect("quantise");
693 for v in q.iter() {
695 assert!(*v >= 0 && *v < modulus, "value out of canonical range: {v}");
696 }
697 let back = Array1::from(dequantize_gradient(&q, scale, modulus));
698 assert!(
699 vec_close(&input, &back, 1.0e-5),
700 "negative round-trip failed: {input:?} -> {back:?}"
701 );
702 }
703
704 #[test]
705 fn test_quantize_rejects_non_finite_gradient() {
706 let input = Array1::from(vec![1.0, f64::NAN]);
707 let r = quantize_gradient(&input, 1.0e6, 2_147_483_647);
708 match r {
709 Err(OptimError::InvalidParameter(_)) => {}
710 other => panic!("expected InvalidParameter for NaN gradient, got {other:?}"),
711 }
712 let input = Array1::from(vec![f64::INFINITY]);
713 match quantize_gradient(&input, 1.0e6, 2_147_483_647) {
714 Err(OptimError::InvalidParameter(_)) => {}
715 other => panic!("expected InvalidParameter for inf gradient, got {other:?}"),
716 }
717 }
718
719 #[test]
720 fn test_quantize_rejects_non_positive_scale() {
721 let input = Array1::from(vec![1.0_f64]);
722 match quantize_gradient(&input, 0.0, 2_147_483_647) {
723 Err(OptimError::InvalidParameter(_)) => {}
724 other => panic!("expected InvalidParameter for scale=0, got {other:?}"),
725 }
726 match quantize_gradient(&input, -1.0, 2_147_483_647) {
727 Err(OptimError::InvalidParameter(_)) => {}
728 other => panic!("expected InvalidParameter for negative scale, got {other:?}"),
729 }
730 }
731
732 #[test]
735 fn test_compute_client_mask_two_clients_opposite_signs() {
736 let modulus = 2_147_483_647_i64;
737 let dim = 16;
738 let round = 7;
739 let clients: Vec<ClientId> = vec![10, 20];
740
741 let mask_a = compute_client_mask(10, &clients, round, dim, modulus).expect("client a mask");
742 let mask_b = compute_client_mask(20, &clients, round, dim, modulus).expect("client b mask");
743
744 for (a, b) in mask_a.iter().zip(mask_b.iter()) {
747 let sum = (a + b).rem_euclid(modulus);
748 assert_eq!(sum, 0, "pair masks must cancel: a={a}, b={b}, sum={sum}");
749 }
750 }
751
752 #[test]
753 fn test_compute_client_mask_unknown_client_errors() {
754 let r = compute_client_mask(99, &[1, 2, 3], 0, 4, 2_147_483_647);
755 match r {
756 Err(OptimError::InvalidParameter(_)) => {}
757 other => panic!("expected InvalidParameter for unknown client, got {other:?}"),
758 }
759 }
760
761 #[test]
762 fn test_compute_client_mask_n_clients_sum_to_zero() {
763 let modulus = 2_147_483_647_i64;
766 let dim = 8;
767 let round = 555;
768 let clients: Vec<ClientId> = vec![1, 7, 13, 42, 100];
769
770 let mut total = vec![0_i64; dim];
771 for &cid in clients.iter() {
772 let m = compute_client_mask(cid, &clients, round, dim, modulus).expect("mask");
773 for (t, x) in total.iter_mut().zip(m.iter()) {
774 *t = (*t + *x).rem_euclid(modulus);
775 }
776 }
777 assert_eq!(total, vec![0_i64; dim]);
778 }
779
780 #[test]
783 fn test_aggregate_two_clients_recovers_sum() {
784 let dim = 5;
785 let config = make_config(2, dim);
786 let clients: Vec<ClientId> = vec![1, 2];
787
788 let g1 = Array1::from(vec![0.5, -1.25, 3.0, 0.0, 7.7]);
789 let g2 = Array1::from(vec![-0.5, 2.5, -1.0, 4.4, -2.2]);
790 let expected: Array1<f64> = &g1 + &g2;
791
792 let sub1 = submit_gradient(1, &g1, &clients, &config).expect("submit 1");
793 let sub2 = submit_gradient(2, &g2, &clients, &config).expect("submit 2");
794
795 let mut agg = SecureAggregator::new(config).expect("aggregator");
796 agg.receive(sub1).expect("recv 1");
797 agg.receive(sub2).expect("recv 2");
798 let out = agg.aggregate().expect("aggregate");
799
800 assert!(
801 vec_close(&out, &expected, QUANT_TOL),
802 "expected {expected:?}, got {out:?}"
803 );
804 }
805
806 #[test]
807 fn test_aggregate_five_clients_recovers_sum() {
808 let dim = 7;
809 let config = make_config(5, dim);
810 let clients: Vec<ClientId> = vec![3, 11, 19, 47, 101];
811
812 let gradients: Vec<Array1<f64>> = vec![
813 Array1::from(vec![1.0, 2.0, -3.0, 4.5, -5.5, 0.1, 0.01]),
814 Array1::from(vec![-1.0, 1.0, 3.0, -2.0, 0.0, -0.1, 0.99]),
815 Array1::from(vec![0.5, -0.5, 0.25, 0.0, 1.0, 2.5, -2.5]),
816 Array1::from(vec![10.0, -10.0, 5.0, -5.0, 2.5, -2.5, 0.0]),
817 Array1::from(vec![0.001, -0.001, 0.002, -0.002, 100.0, -100.0, 0.0]),
818 ];
819
820 let expected = gradients.iter().fold(Array1::zeros(dim), |acc, g| &acc + g);
821
822 let mut agg = SecureAggregator::new(config.clone()).expect("aggregator");
823 for (cid, g) in clients.iter().zip(gradients.iter()) {
824 let sub = submit_gradient(*cid, g, &clients, &config).expect("submit");
825 agg.receive(sub).expect("recv");
826 }
827 let out = agg.aggregate().expect("aggregate");
828
829 assert!(
830 vec_close(&out, &expected, QUANT_TOL),
831 "expected {expected:?}, got {out:?}"
832 );
833 }
834
835 #[test]
836 fn test_aggregate_with_dropout_recovers_sum() {
837 let dim = 4;
840 let config = make_config(5, dim);
841 let clients: Vec<ClientId> = vec![3, 11, 19, 47, 101];
842 let dropped_id: ClientId = 19;
843
844 let gradients: HashMap<ClientId, Array1<f64>> = [
845 (3, Array1::from(vec![1.0, 2.0, -3.0, 4.0])),
846 (11, Array1::from(vec![-1.5, 0.5, 2.5, -2.0])),
847 (19, Array1::from(vec![100.0, 100.0, 100.0, 100.0])), (47, Array1::from(vec![0.25, 0.5, 0.75, 1.0])),
849 (101, Array1::from(vec![-0.1, -0.2, -0.3, -0.4])),
850 ]
851 .into_iter()
852 .collect();
853
854 let mut expected: Array1<f64> = Array1::zeros(dim);
855 for (cid, g) in gradients.iter() {
856 if *cid != dropped_id {
857 expected = &expected + g;
858 }
859 }
860
861 let mut agg = SecureAggregator::new(config.clone()).expect("aggregator");
862 for &cid in clients.iter() {
863 if cid == dropped_id {
864 agg.mark_dropped(cid).expect("mark dropped");
865 } else {
866 let sub =
867 submit_gradient(cid, &gradients[&cid], &clients, &config).expect("submit");
868 agg.receive(sub).expect("recv");
869 }
870 }
871
872 assert_eq!(agg.received_count(), 4);
873 assert_eq!(agg.dropped_count(), 1);
874
875 let out = agg.aggregate().expect("aggregate with dropout");
876 assert!(
877 vec_close(&out, &expected, QUANT_TOL),
878 "dropout recovery failed: expected {expected:?}, got {out:?}"
879 );
880 }
881
882 #[test]
883 fn test_aggregate_with_multiple_dropouts_recovers_sum() {
884 let dim = 3;
888 let mut config = make_config(6, dim);
889 config.round_seed = 4242;
890 let clients: Vec<ClientId> = vec![1, 2, 3, 4, 5, 6];
891 let dropped: Vec<ClientId> = vec![2, 5];
892
893 let gradients: HashMap<ClientId, Array1<f64>> = [
894 (1, Array1::from(vec![1.0, 0.0, 0.0])),
895 (2, Array1::from(vec![0.0, 1.0, 0.0])),
896 (3, Array1::from(vec![0.0, 0.0, 1.0])),
897 (4, Array1::from(vec![1.0, 1.0, 1.0])),
898 (5, Array1::from(vec![-1.0, -1.0, -1.0])),
899 (6, Array1::from(vec![0.5, 0.5, 0.5])),
900 ]
901 .into_iter()
902 .collect();
903
904 let mut expected: Array1<f64> = Array1::zeros(dim);
905 for (cid, g) in gradients.iter() {
906 if !dropped.contains(cid) {
907 expected = &expected + g;
908 }
909 }
910
911 let mut agg = SecureAggregator::new(config.clone()).expect("aggregator");
912 for &cid in clients.iter() {
913 if dropped.contains(&cid) {
914 agg.mark_dropped(cid).expect("mark dropped");
915 } else {
916 let sub =
917 submit_gradient(cid, &gradients[&cid], &clients, &config).expect("submit");
918 agg.receive(sub).expect("recv");
919 }
920 }
921
922 let out = agg.aggregate().expect("aggregate");
923 assert!(
924 vec_close(&out, &expected, QUANT_TOL),
925 "multi-dropout recovery failed: expected {expected:?}, got {out:?}"
926 );
927 }
928
929 #[test]
930 fn test_aggregate_missing_clients_errors() {
931 let dim = 4;
934 let config = make_config(3, dim);
935 let clients: Vec<ClientId> = vec![1, 2, 3];
936 let g1 = Array1::from(vec![1.0, 2.0, 3.0, 4.0]);
937 let sub1 = submit_gradient(1, &g1, &clients, &config).expect("submit 1");
938
939 let mut agg = SecureAggregator::new(config).expect("aggregator");
940 agg.receive(sub1).expect("recv 1");
941 agg.mark_dropped(2).expect("mark 2");
942
943 match agg.aggregate() {
944 Err(OptimError::InvalidConfig(_)) => {}
945 other => panic!("expected InvalidConfig for incomplete round, got {other:?}"),
946 }
947 }
948
949 #[test]
950 fn test_receive_validates_dim() {
951 let config = make_config(2, 8);
952 let mut agg = SecureAggregator::new(config).expect("aggregator");
953 let bad = MaskedGradient {
954 client_id: 1,
955 values: vec![0_i64; 7], };
957 match agg.receive(bad) {
958 Err(OptimError::DimensionMismatch(_)) => {}
959 other => panic!("expected DimensionMismatch, got {other:?}"),
960 }
961 }
962
963 #[test]
964 fn test_receive_validates_value_range() {
965 let config = make_config(2, 3);
966 let modulus = config.modulus;
967 let mut agg = SecureAggregator::new(config).expect("aggregator");
968
969 let bad_high = MaskedGradient {
970 client_id: 1,
971 values: vec![0, modulus, 0],
972 };
973 match agg.receive(bad_high) {
974 Err(OptimError::InvalidParameter(_)) => {}
975 other => panic!("expected InvalidParameter for value=modulus, got {other:?}"),
976 }
977
978 let bad_neg = MaskedGradient {
979 client_id: 1,
980 values: vec![0, -1, 0],
981 };
982 match agg.receive(bad_neg) {
983 Err(OptimError::InvalidParameter(_)) => {}
984 other => panic!("expected InvalidParameter for negative value, got {other:?}"),
985 }
986 }
987
988 #[test]
991 fn test_invalid_config_zero_clients_errors() {
992 let config = SecureAggregationConfig {
993 num_clients: 0,
994 gradient_dim: 4,
995 ..SecureAggregationConfig::default()
996 };
997 match SecureAggregator::new(config) {
998 Err(OptimError::InvalidConfig(_)) => {}
999 other => panic!("expected InvalidConfig for num_clients=0, got {other:?}"),
1000 }
1001 }
1002
1003 #[test]
1004 fn test_invalid_config_zero_dim_errors() {
1005 let config = SecureAggregationConfig {
1006 num_clients: 3,
1007 gradient_dim: 0,
1008 ..SecureAggregationConfig::default()
1009 };
1010 match SecureAggregator::new(config) {
1011 Err(OptimError::InvalidConfig(_)) => {}
1012 other => panic!("expected InvalidConfig for gradient_dim=0, got {other:?}"),
1013 }
1014 }
1015
1016 #[test]
1017 fn test_modulus_too_small_errors() {
1018 let config = SecureAggregationConfig {
1019 num_clients: 3,
1020 gradient_dim: 4,
1021 round_seed: 0,
1022 quantization_scale: 1.0e6,
1023 modulus: 100, support_dropouts: true,
1025 };
1026 match SecureAggregator::new(config) {
1027 Err(OptimError::InvalidConfig(_)) => {}
1028 other => panic!("expected InvalidConfig for tiny modulus, got {other:?}"),
1029 }
1030 }
1031
1032 #[test]
1033 fn test_invalid_config_non_positive_scale_errors() {
1034 let config = SecureAggregationConfig {
1035 num_clients: 2,
1036 gradient_dim: 4,
1037 round_seed: 0,
1038 quantization_scale: 0.0,
1039 modulus: 2_147_483_647,
1040 support_dropouts: false,
1041 };
1042 match SecureAggregator::new(config) {
1043 Err(OptimError::InvalidConfig(_)) => {}
1044 other => panic!("expected InvalidConfig for scale=0, got {other:?}"),
1045 }
1046 }
1047
1048 #[test]
1049 fn test_invalid_config_modulus_one_errors() {
1050 let config = SecureAggregationConfig {
1051 num_clients: 2,
1052 gradient_dim: 4,
1053 round_seed: 0,
1054 quantization_scale: 1.0e6,
1055 modulus: 1,
1056 support_dropouts: false,
1057 };
1058 match SecureAggregator::new(config) {
1059 Err(OptimError::InvalidConfig(_)) => {}
1060 other => panic!("expected InvalidConfig for modulus=1, got {other:?}"),
1061 }
1062 }
1063
1064 #[test]
1067 fn test_mark_dropped_after_submit_errors() {
1068 let config = make_config(2, 4);
1069 let clients = vec![1_u64, 2];
1070 let g = Array1::from(vec![1.0, 2.0, 3.0, 4.0]);
1071 let sub = submit_gradient(1, &g, &clients, &config).expect("submit");
1072 let mut agg = SecureAggregator::new(config).expect("aggregator");
1073 agg.receive(sub).expect("recv");
1074 match agg.mark_dropped(1) {
1075 Err(OptimError::InvalidParameter(_)) => {}
1076 other => panic!("expected InvalidParameter, got {other:?}"),
1077 }
1078 }
1079
1080 #[test]
1081 fn test_reset_clears_state() {
1082 let config = make_config(2, 3);
1083 let clients = vec![1_u64, 2];
1084 let g = Array1::from(vec![1.0, 2.0, 3.0]);
1085 let sub = submit_gradient(1, &g, &clients, &config).expect("submit");
1086 let mut agg = SecureAggregator::new(config).expect("aggregator");
1087 agg.receive(sub).expect("recv");
1088 agg.mark_dropped(2).expect("mark");
1089 assert_eq!(agg.received_count(), 1);
1090 assert_eq!(agg.dropped_count(), 1);
1091 agg.reset();
1092 assert_eq!(agg.received_count(), 0);
1093 assert_eq!(agg.dropped_count(), 0);
1094 }
1095
1096 #[test]
1099 fn test_masked_gradient_serde_roundtrip() {
1100 let mg = MaskedGradient {
1101 client_id: 42,
1102 values: vec![0_i64, 1, 1_000_000, 2_147_483_646],
1103 };
1104 let json = serde_json::to_string(&mg).expect("serialise");
1105 let back: MaskedGradient = serde_json::from_str(&json).expect("deserialise");
1106 assert_eq!(mg, back);
1107 }
1108
1109 #[test]
1110 fn test_secure_aggregation_config_serde_roundtrip() {
1111 let config = SecureAggregationConfig {
1112 num_clients: 8,
1113 gradient_dim: 1024,
1114 round_seed: 0xCAFE_BABE,
1115 quantization_scale: 1.0e5,
1116 modulus: 2_147_483_647,
1117 support_dropouts: false,
1118 };
1119 let json = serde_json::to_string(&config).expect("serialise");
1120 let back: SecureAggregationConfig = serde_json::from_str(&json).expect("deserialise");
1121 assert_eq!(back.num_clients, config.num_clients);
1122 assert_eq!(back.gradient_dim, config.gradient_dim);
1123 assert_eq!(back.round_seed, config.round_seed);
1124 assert_eq!(back.quantization_scale, config.quantization_scale);
1125 assert_eq!(back.modulus, config.modulus);
1126 assert_eq!(back.support_dropouts, config.support_dropouts);
1127 }
1128
1129 #[test]
1132 fn test_resubmission_overwrites_previous() {
1133 let dim = 3;
1134 let config = make_config(2, dim);
1135 let clients = vec![1_u64, 2];
1136
1137 let g1_first = Array1::from(vec![100.0, 100.0, 100.0]);
1138 let g1_final = Array1::from(vec![1.0, 2.0, 3.0]);
1139 let g2 = Array1::from(vec![0.5, 0.5, 0.5]);
1140 let expected: Array1<f64> = &g1_final + &g2;
1141
1142 let mut agg = SecureAggregator::new(config.clone()).expect("aggregator");
1143
1144 let sub_first = submit_gradient(1, &g1_first, &clients, &config).expect("submit first");
1145 let sub_final = submit_gradient(1, &g1_final, &clients, &config).expect("submit final");
1146 let sub2 = submit_gradient(2, &g2, &clients, &config).expect("submit 2");
1147
1148 agg.receive(sub_first).expect("recv first");
1149 agg.receive(sub_final).expect("recv final");
1150 agg.receive(sub2).expect("recv 2");
1151
1152 assert_eq!(
1153 agg.received_count(),
1154 2,
1155 "resubmission must overwrite, not duplicate"
1156 );
1157
1158 let out = agg.aggregate().expect("aggregate");
1159 assert!(
1160 vec_close(&out, &expected, QUANT_TOL),
1161 "resubmission failed: expected {expected:?}, got {out:?}"
1162 );
1163 }
1164
1165 #[test]
1166 fn test_default_config_has_sane_round_seed_and_scale() {
1167 let cfg = SecureAggregationConfig::default();
1168 assert_eq!(cfg.round_seed, 42);
1169 assert_eq!(cfg.quantization_scale, 1.0e6);
1170 assert_eq!(cfg.modulus, 2_147_483_647);
1171 assert!(cfg.support_dropouts);
1172 assert_eq!(cfg.num_clients, 0);
1174 assert_eq!(cfg.gradient_dim, 0);
1175 }
1176}