1use super::outlier_tests::{self, OutlierVerdict};
26use super::robust_ops::{self, CohortMember, CENTERED_CLIPPING_ITERATIONS};
27use crate::error::{OptimError, Result};
28use scirs2_core::ndarray::Array1;
29use scirs2_core::numeric::Float;
30use std::collections::{HashMap, HashSet, VecDeque};
31use std::fmt::Debug;
32
33pub const OUTLIER_HISTORY_CAPACITY: usize = 1000;
36
37#[derive(Debug, Clone, Copy, PartialEq)]
39pub enum ByzantineRobustMethod {
40 TrimmedMean {
43 trim_ratio: f64,
45 },
46
47 CoordinateWiseMedian,
49
50 Krum {
53 f: usize,
55 },
56
57 MultiKrum {
59 f: usize,
61 m: usize,
63 },
64
65 Bulyan {
68 f: usize,
70 },
71
72 CenteredClipping {
75 tau: f64,
77 },
78
79 FedAvgOutlierDetection {
82 threshold: f64,
85 },
86
87 ReputationWeighted {
90 reputation_decay: f64,
92 },
93}
94
95#[derive(Debug, Clone)]
97pub struct ByzantineRobustConfig {
98 pub method: ByzantineRobustMethod,
100
101 pub expected_byzantine_ratio: f64,
104
105 pub dynamic_detection: bool,
109
110 pub reputation_system: ReputationSystemConfig,
112
113 pub statistical_tests: StatisticalTestConfig,
115}
116
117#[derive(Debug, Clone)]
119pub struct ReputationSystemConfig {
120 pub enabled: bool,
122 pub initial_reputation: f64,
124 pub reputation_decay: f64,
126 pub min_reputation: f64,
128 pub outlier_penalty: f64,
130 pub contribution_bonus: f64,
132}
133
134#[derive(Debug, Clone)]
136pub struct StatisticalTestConfig {
137 pub enabled: bool,
139 pub test_type: StatisticalTestType,
141 pub significancelevel: f64,
143 pub window_size: usize,
146 pub adaptive_threshold: bool,
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub enum StatisticalTestType {
156 ZScore,
158 ModifiedZScore,
160 IQRTest,
162 GrubbsTest,
164 ChauventCriterion,
166}
167
168pub struct ByzantineRobustAggregator<
170 T: Float + Debug + Default + Clone + Send + Sync + std::iter::Sum + 'static,
171> {
172 config: ByzantineRobustConfig,
173 client_reputations: HashMap<String, f64>,
174 outlier_history: VecDeque<OutlierDetectionResult>,
175 statistical_analyzer: StatisticalAnalyzer<T>,
176 robust_estimators: RobustEstimators<T>,
177 rounds_aggregated: usize,
178}
179
180pub struct StatisticalAnalyzer<
182 T: Float + Debug + Default + Clone + Send + Sync + std::iter::Sum + 'static,
183> {
184 window_size: usize,
185 significancelevel: f64,
186 test_type: StatisticalTestType,
187 adaptive_threshold: bool,
188 test_statistics: VecDeque<TestStatistic<T>>,
189}
190
191pub struct RobustEstimators<
198 T: Float + Debug + Default + Clone + Send + Sync + std::iter::Sum + 'static,
199> {
200 last_trim_count: usize,
201 last_median: Option<Array1<T>>,
202 krum_scores: HashMap<String, f64>,
203 last_contributors: Vec<String>,
204 last_excluded: Vec<String>,
205}
206
207#[derive(Debug, Clone)]
209pub struct OutlierDetectionResult {
210 pub clientid: String,
212 pub round: usize,
214 pub is_outlier: bool,
216 pub outlier_score: f64,
219 pub mean_distance: f64,
222 pub p_value: Option<f64>,
224 pub detection_method: String,
226}
227
228#[derive(Debug, Clone)]
230pub struct TestStatistic<T: Float + Debug + Send + Sync + 'static> {
231 pub statistic_value: T,
233 pub sample_value: f64,
237 pub p_value: Option<f64>,
239 pub test_type: StatisticalTestType,
241 pub clientid: String,
243}
244
245#[derive(Debug, Clone)]
256pub struct AdaptivePrivacyAllocation {
257 pub epsilon: f64,
259 pub delta: f64,
261 pub utility_weight: f64,
263}
264
265impl<
266 T: Float
267 + Debug
268 + Default
269 + Clone
270 + Send
271 + Sync
272 + 'static
273 + std::iter::Sum
274 + scirs2_core::ndarray::ScalarOperand,
275 > ByzantineRobustAggregator<T>
276{
277 pub fn new() -> Result<Self> {
279 Self::with_config(ByzantineRobustConfig::default())
280 }
281
282 pub fn with_config(config: ByzantineRobustConfig) -> Result<Self> {
285 config.validate()?;
286 let statistical_analyzer = StatisticalAnalyzer::with_config(&config.statistical_tests);
287 Ok(Self {
288 config,
289 client_reputations: HashMap::new(),
290 outlier_history: VecDeque::new(),
291 statistical_analyzer,
292 robust_estimators: RobustEstimators::new(),
293 rounds_aggregated: 0,
294 })
295 }
296
297 pub fn detect_byzantine_clients(
299 &mut self,
300 client_updates: &HashMap<String, Array1<T>>,
301 round: usize,
302 ) -> Result<Vec<OutlierDetectionResult>> {
303 if !self.config.statistical_tests.enabled {
304 return Err(OptimError::InvalidConfig(
305 "statistical outlier detection is disabled in this configuration".to_string(),
306 ));
307 }
308 let results = self
309 .statistical_analyzer
310 .detect_outliers(client_updates, round)?;
311 for result in results.iter() {
312 if self.outlier_history.len() >= OUTLIER_HISTORY_CAPACITY {
313 self.outlier_history.pop_front();
314 }
315 self.outlier_history.push_back(result.clone());
316 }
317 Ok(results)
318 }
319
320 pub fn get_client_reputations(&self, clients: &[String]) -> HashMap<String, f64> {
323 clients
324 .iter()
325 .map(|client_id| {
326 let reputation = self
327 .client_reputations
328 .get(client_id)
329 .copied()
330 .unwrap_or(self.config.reputation_system.initial_reputation);
331 (client_id.clone(), reputation)
332 })
333 .collect()
334 }
335
336 pub fn robust_aggregate(
347 &mut self,
348 client_updates: &HashMap<String, Array1<T>>,
349 allocations: &HashMap<String, AdaptivePrivacyAllocation>,
350 ) -> Result<Array1<T>> {
351 let full_cohort = robust_ops::ordered_cohort(client_updates)?;
352 let round = self.rounds_aggregated;
353
354 let excluded = if self.config.dynamic_detection {
355 self.select_exclusions(client_updates, full_cohort.len(), round)?
356 } else {
357 HashSet::new()
358 };
359
360 let cohort: Vec<CohortMember<'_, T>> = full_cohort
361 .iter()
362 .filter(|(id, _)| !excluded.contains(*id))
363 .copied()
364 .collect();
365 if cohort.is_empty() {
366 return Err(OptimError::InvalidState(
367 "dynamic detection excluded every client; refusing to aggregate an empty cohort"
368 .to_string(),
369 ));
370 }
371
372 let weights = allocation_weights(&cohort, allocations)?;
373 let mut detection_excluded: Vec<String> = excluded.into_iter().collect();
374 detection_excluded.sort();
375 let aggregate = self.apply_method(&cohort, weights.as_deref(), &detection_excluded)?;
376
377 self.rounds_aggregated = self.rounds_aggregated.saturating_add(1);
378 Ok(aggregate)
379 }
380
381 pub fn compute_robustness_factor(&self) -> Result<f64> {
386 let total = self.outlier_history.len();
387 if total == 0 {
388 return Err(OptimError::InvalidState(
389 "no outlier evaluations have been recorded; run detect_byzantine_clients or \
390 enable dynamic_detection before asking for a robustness factor"
391 .to_string(),
392 ));
393 }
394 let flagged = self
395 .outlier_history
396 .iter()
397 .filter(|result| result.is_outlier)
398 .count();
399 Ok(1.0 - (flagged as f64 / total as f64))
400 }
401
402 pub fn coordinate_wise_median(
405 &self,
406 client_updates: &HashMap<String, Array1<T>>,
407 ) -> Result<Array1<T>> {
408 let cohort = robust_ops::ordered_cohort(client_updates)?;
409 robust_ops::coordinate_wise_median(&cohort)
410 }
411
412 pub fn config(&self) -> &ByzantineRobustConfig {
414 &self.config
415 }
416
417 pub fn outlier_history(&self) -> &VecDeque<OutlierDetectionResult> {
419 &self.outlier_history
420 }
421
422 pub fn robust_estimators(&self) -> &RobustEstimators<T> {
424 &self.robust_estimators
425 }
426
427 pub fn statistical_analyzer(&self) -> &StatisticalAnalyzer<T> {
429 &self.statistical_analyzer
430 }
431
432 pub fn rounds_aggregated(&self) -> usize {
434 self.rounds_aggregated
435 }
436
437 pub fn update_client_reputation(&mut self, client_id: String, is_outlier: bool) {
442 if !self.config.reputation_system.enabled {
443 return;
444 }
445 let system = &self.config.reputation_system;
446 let current = self
447 .client_reputations
448 .get(&client_id)
449 .copied()
450 .unwrap_or(system.initial_reputation);
451
452 let updated = if is_outlier {
453 (current - system.outlier_penalty).max(system.min_reputation)
454 } else {
455 (current + system.contribution_bonus).min(1.0)
456 };
457 self.client_reputations.insert(client_id, updated);
458 }
459
460 fn decay_reputations(&mut self, decay: f64) {
463 if decay <= 0.0 {
464 return;
465 }
466 let initial = self.config.reputation_system.initial_reputation;
467 let floor = self.config.reputation_system.min_reputation;
468 for reputation in self.client_reputations.values_mut() {
469 *reputation = (*reputation + (initial - *reputation) * decay).max(floor);
470 }
471 }
472
473 fn select_exclusions(
476 &mut self,
477 client_updates: &HashMap<String, Array1<T>>,
478 cohort_size: usize,
479 round: usize,
480 ) -> Result<HashSet<String>> {
481 let detections = self.detect_byzantine_clients(client_updates, round)?;
482
483 if self.config.reputation_system.enabled {
484 let decay = self.config.reputation_system.reputation_decay;
485 self.decay_reputations(decay);
486 for detection in detections.iter() {
487 self.update_client_reputation(detection.clientid.clone(), detection.is_outlier);
488 }
489 }
490
491 let mut flagged: Vec<&OutlierDetectionResult> = detections
492 .iter()
493 .filter(|detection| detection.is_outlier)
494 .collect();
495 flagged.sort_by(|a, b| {
497 b.outlier_score
498 .abs()
499 .partial_cmp(&a.outlier_score.abs())
500 .unwrap_or(std::cmp::Ordering::Equal)
501 .then(a.clientid.cmp(&b.clientid))
502 });
503
504 let cap = ((cohort_size as f64) * self.config.expected_byzantine_ratio).floor() as usize;
505 let cap = cap.min(cohort_size.saturating_sub(1));
506 Ok(flagged
507 .into_iter()
508 .take(cap)
509 .map(|detection| detection.clientid.clone())
510 .collect())
511 }
512
513 fn apply_method(
520 &mut self,
521 cohort: &[CohortMember<'_, T>],
522 weights: Option<&[f64]>,
523 detection_excluded: &[String],
524 ) -> Result<Array1<T>> {
525 self.robust_estimators.reset_round();
526 self.robust_estimators.last_excluded = detection_excluded.to_vec();
527 match self.config.method {
528 ByzantineRobustMethod::TrimmedMean { trim_ratio } => {
529 let trim = robust_ops::trim_count_for_ratio(cohort.len(), trim_ratio)?;
530 self.robust_estimators.last_trim_count = trim;
531 self.robust_estimators.last_contributors = cohort_ids(cohort);
532 robust_ops::coordinate_wise_trimmed_mean(cohort, trim)
533 }
534 ByzantineRobustMethod::CoordinateWiseMedian => {
535 let median = robust_ops::coordinate_wise_median(cohort)?;
536 self.robust_estimators.last_median = Some(median.clone());
537 self.robust_estimators.last_contributors = cohort_ids(cohort);
538 Ok(median)
539 }
540 ByzantineRobustMethod::Krum { f } => {
541 let scores = robust_ops::krum_scores(cohort, f)?;
542 self.robust_estimators.record_krum_scores(cohort, &scores);
543 let winner = robust_ops::krum_select(cohort, f)?;
544 self.robust_estimators.last_contributors = vec![cohort[winner].0.to_string()];
545 Ok(cohort[winner].1.clone())
546 }
547 ByzantineRobustMethod::MultiKrum { f, m } => {
548 let scores = robust_ops::krum_scores(cohort, f)?;
549 self.robust_estimators.record_krum_scores(cohort, &scores);
550 let selected = robust_ops::multi_krum_indices(cohort, f, m)?;
551 let subset: Vec<CohortMember<'_, T>> =
552 selected.iter().map(|&index| cohort[index]).collect();
553 self.robust_estimators.last_contributors = cohort_ids(&subset);
554 robust_ops::mean(&subset)
555 }
556 ByzantineRobustMethod::Bulyan { f } => {
557 self.robust_estimators.last_contributors = cohort_ids(cohort);
558 robust_ops::bulyan(cohort, f)
559 }
560 ByzantineRobustMethod::CenteredClipping { tau } => {
561 self.robust_estimators.last_contributors = cohort_ids(cohort);
562 robust_ops::centered_clipping(cohort, tau, CENTERED_CLIPPING_ITERATIONS)
563 }
564 ByzantineRobustMethod::FedAvgOutlierDetection { threshold } => {
565 let verdicts = self.statistical_analyzer.score_cohort(cohort)?;
566 let kept: Vec<usize> = verdicts
567 .iter()
568 .enumerate()
569 .filter(|(_, verdict)| verdict.statistic.abs() <= threshold)
570 .map(|(index, _)| index)
571 .collect();
572 if kept.is_empty() {
573 return Err(OptimError::InvalidState(format!(
574 "every client's outlier statistic exceeded the threshold {threshold}; \
575 there is nothing left to average"
576 )));
577 }
578 let subset: Vec<CohortMember<'_, T>> =
579 kept.iter().map(|&index| cohort[index]).collect();
580 self.robust_estimators.last_contributors = cohort_ids(&subset);
581 self.robust_estimators.last_excluded.extend(
582 (0..cohort.len())
583 .filter(|index| !kept.contains(index))
584 .map(|index| cohort[index].0.to_string()),
585 );
586 self.robust_estimators.last_excluded.sort();
587 self.robust_estimators.last_excluded.dedup();
588 match weights {
589 Some(all) => {
590 let subset_weights: Vec<f64> =
591 kept.iter().map(|&index| all[index]).collect();
592 robust_ops::weighted_mean(&subset, &subset_weights)
593 }
594 None => robust_ops::mean(&subset),
595 }
596 }
597 ByzantineRobustMethod::ReputationWeighted { reputation_decay } => {
598 if !self.config.reputation_system.enabled {
599 return Err(OptimError::InvalidConfig(
600 "ReputationWeighted aggregation requires reputation_system.enabled; \
601 with the system disabled every client would carry the same implicit \
602 weight, which is plain FedAvg under a robust-sounding name"
603 .to_string(),
604 ));
605 }
606 self.decay_reputations(reputation_decay);
607 let initial = self.config.reputation_system.initial_reputation;
608 let mut combined = Vec::with_capacity(cohort.len());
609 for (index, (id, _)) in cohort.iter().enumerate() {
610 let reputation = self
611 .client_reputations
612 .get(*id)
613 .copied()
614 .unwrap_or(initial)
615 .max(0.0);
616 let weight = match weights {
617 Some(all) => reputation * all[index],
618 None => reputation,
619 };
620 combined.push(weight);
621 }
622 self.robust_estimators.last_contributors = cohort_ids(cohort);
623 robust_ops::weighted_mean(cohort, &combined)
624 }
625 }
626 }
627}
628
629fn cohort_ids<T: Float + Debug + Send + Sync + 'static>(
630 cohort: &[CohortMember<'_, T>],
631) -> Vec<String> {
632 cohort.iter().map(|(id, _)| (*id).to_string()).collect()
633}
634
635fn allocation_weights<T: Float + Debug + Send + Sync + 'static>(
642 cohort: &[CohortMember<'_, T>],
643 allocations: &HashMap<String, AdaptivePrivacyAllocation>,
644) -> Result<Option<Vec<f64>>> {
645 if allocations.is_empty() {
646 return Ok(None);
647 }
648 let mut weights = Vec::with_capacity(cohort.len());
649 for (id, _) in cohort.iter() {
650 let allocation = allocations.get(*id).ok_or_else(|| {
651 OptimError::InvalidConfig(format!(
652 "privacy allocations were supplied but client {id} is missing from them; a \
653 partial allocation cannot be completed without inventing a weight"
654 ))
655 })?;
656 if !allocation.epsilon.is_finite() || allocation.epsilon <= 0.0 {
657 return Err(OptimError::InvalidConfig(format!(
658 "client {id} has a non-positive epsilon allocation ({})",
659 allocation.epsilon
660 )));
661 }
662 if !allocation.delta.is_finite() || !(0.0..1.0).contains(&allocation.delta) {
663 return Err(OptimError::InvalidConfig(format!(
664 "client {id} has a delta allocation of {} outside [0, 1)",
665 allocation.delta
666 )));
667 }
668 if !allocation.utility_weight.is_finite() || allocation.utility_weight < 0.0 {
669 return Err(OptimError::InvalidConfig(format!(
670 "client {id} has a negative or non-finite utility weight ({})",
671 allocation.utility_weight
672 )));
673 }
674 weights.push(allocation.utility_weight);
675 }
676 Ok(Some(weights))
677}
678
679impl<T: Float + Debug + Default + Clone + Send + Sync + 'static + std::iter::Sum>
680 StatisticalAnalyzer<T>
681{
682 pub fn new(window_size: usize, significancelevel: f64) -> Self {
685 Self {
686 window_size: window_size.max(1),
687 significancelevel,
688 test_type: StatisticalTestType::ZScore,
689 adaptive_threshold: false,
690 test_statistics: VecDeque::new(),
691 }
692 }
693
694 pub fn with_config(config: &StatisticalTestConfig) -> Self {
696 Self {
697 window_size: config.window_size.max(1),
698 significancelevel: config.significancelevel,
699 test_type: config.test_type,
700 adaptive_threshold: config.adaptive_threshold,
701 test_statistics: VecDeque::new(),
702 }
703 }
704
705 pub fn detect_outliers(
713 &mut self,
714 client_updates: &HashMap<String, Array1<T>>,
715 round: usize,
716 ) -> Result<Vec<OutlierDetectionResult>> {
717 let cohort = robust_ops::ordered_cohort(client_updates)?;
718 if cohort.len() < 3 {
719 return Ok(Vec::new());
720 }
721
722 let distances = self.mean_distances(&cohort)?;
723 let verdicts = self.evaluate(&distances)?;
724
725 let mut results = Vec::with_capacity(cohort.len());
726 for (index, (id, _)) in cohort.iter().enumerate() {
727 let verdict = verdicts[index];
728 let statistic = T::from(verdict.statistic).ok_or_else(|| {
729 OptimError::ComputationError(format!(
730 "test statistic {} is not representable in the target float type",
731 verdict.statistic
732 ))
733 })?;
734 self.push_statistic(TestStatistic {
735 statistic_value: statistic,
736 sample_value: distances[index],
737 p_value: verdict.p_value,
738 test_type: self.test_type,
739 clientid: (*id).to_string(),
740 });
741 results.push(OutlierDetectionResult {
742 clientid: (*id).to_string(),
743 round,
744 is_outlier: verdict.is_outlier,
745 outlier_score: verdict.statistic,
746 mean_distance: distances[index],
747 p_value: verdict.p_value,
748 detection_method: format!("{:?}", self.test_type),
749 });
750 }
751 Ok(results)
752 }
753
754 pub fn score_cohort(&self, cohort: &[CohortMember<'_, T>]) -> Result<Vec<OutlierVerdict>> {
756 if cohort.len() < 3 {
757 return Ok(cohort
758 .iter()
759 .map(|_| OutlierVerdict {
760 statistic: 0.0,
761 p_value: None,
762 is_outlier: false,
763 })
764 .collect());
765 }
766 let distances = self.mean_distances(cohort)?;
767 self.evaluate(&distances)
768 }
769
770 pub fn test_statistics(&self) -> &VecDeque<TestStatistic<T>> {
772 &self.test_statistics
773 }
774
775 pub fn window_size(&self) -> usize {
777 self.window_size
778 }
779
780 pub fn significance_level(&self) -> f64 {
782 self.significancelevel
783 }
784
785 fn mean_distances(&self, cohort: &[CohortMember<'_, T>]) -> Result<Vec<f64>> {
787 let squared = robust_ops::pairwise_squared_distances(cohort)?;
788 let n = cohort.len();
789 if n < 2 {
790 return Ok(vec![0.0; n]);
791 }
792 Ok((0..n)
793 .map(|i| {
794 let total: f64 = (0..n)
795 .filter(|&j| j != i)
796 .map(|j| squared[i][j].max(0.0).sqrt())
797 .sum();
798 total / (n - 1) as f64
799 })
800 .collect())
801 }
802
803 fn evaluate(&self, distances: &[f64]) -> Result<Vec<OutlierVerdict>> {
804 if self.adaptive_threshold && !self.test_statistics.is_empty() {
805 let mut pool: Vec<f64> = distances.to_vec();
806 pool.extend(
807 self.test_statistics
808 .iter()
809 .map(|statistic| statistic.sample_value),
810 );
811 outlier_tests::evaluate(self.test_type, distances, &pool, self.significancelevel)
812 } else {
813 outlier_tests::evaluate(self.test_type, distances, distances, self.significancelevel)
814 }
815 }
816
817 fn push_statistic(&mut self, statistic: TestStatistic<T>) {
818 while self.test_statistics.len() >= self.window_size {
819 self.test_statistics.pop_front();
820 }
821 self.test_statistics.push_back(statistic);
822 }
823}
824
825impl<T: Float + Debug + Default + Clone + Send + Sync + 'static + std::iter::Sum>
826 RobustEstimators<T>
827{
828 pub fn new() -> Self {
830 Self {
831 last_trim_count: 0,
832 last_median: None,
833 krum_scores: HashMap::new(),
834 last_contributors: Vec::new(),
835 last_excluded: Vec::new(),
836 }
837 }
838
839 pub fn trimmed_mean(
847 &mut self,
848 client_updates: &HashMap<String, Array1<T>>,
849 trim_ratio: f64,
850 ) -> Result<Array1<T>> {
851 let cohort = robust_ops::ordered_cohort(client_updates)?;
852 let trim = robust_ops::trim_count_for_ratio(cohort.len(), trim_ratio)?;
853 self.last_trim_count = trim;
854 self.last_contributors = cohort_ids(&cohort);
855 robust_ops::coordinate_wise_trimmed_mean(&cohort, trim)
856 }
857
858 pub fn median(&mut self, client_updates: &HashMap<String, Array1<T>>) -> Result<Array1<T>> {
860 let cohort = robust_ops::ordered_cohort(client_updates)?;
861 let median = robust_ops::coordinate_wise_median(&cohort)?;
862 self.last_median = Some(median.clone());
863 self.last_contributors = cohort_ids(&cohort);
864 Ok(median)
865 }
866
867 pub fn last_trim_count(&self) -> usize {
869 self.last_trim_count
870 }
871
872 pub fn last_median(&self) -> Option<&Array1<T>> {
874 self.last_median.as_ref()
875 }
876
877 pub fn krum_scores(&self) -> &HashMap<String, f64> {
879 &self.krum_scores
880 }
881
882 pub fn last_contributors(&self) -> &[String] {
884 &self.last_contributors
885 }
886
887 pub fn last_excluded(&self) -> &[String] {
889 &self.last_excluded
890 }
891
892 fn reset_round(&mut self) {
893 self.last_trim_count = 0;
894 self.last_median = None;
895 self.krum_scores.clear();
896 self.last_contributors.clear();
897 self.last_excluded.clear();
898 }
899
900 fn record_krum_scores(&mut self, cohort: &[CohortMember<'_, T>], scores: &[f64]) {
901 self.krum_scores.clear();
902 for ((id, _), &score) in cohort.iter().zip(scores.iter()) {
903 self.krum_scores.insert((*id).to_string(), score);
904 }
905 }
906}
907
908impl<T: Float + Debug + Default + Clone + Send + Sync + 'static + std::iter::Sum> Default
909 for RobustEstimators<T>
910{
911 fn default() -> Self {
912 Self::new()
913 }
914}
915
916impl ByzantineRobustConfig {
917 pub fn validate(&self) -> Result<()> {
919 if !(0.0..0.5).contains(&self.expected_byzantine_ratio)
920 || !self.expected_byzantine_ratio.is_finite()
921 {
922 return Err(OptimError::InvalidConfig(format!(
923 "expected_byzantine_ratio must be in [0, 0.5), got {}",
924 self.expected_byzantine_ratio
925 )));
926 }
927 if self.dynamic_detection && !self.statistical_tests.enabled {
928 return Err(OptimError::InvalidConfig(
929 "dynamic_detection requires statistical_tests.enabled; without a test there is \
930 nothing to detect with"
931 .to_string(),
932 ));
933 }
934 if self.statistical_tests.window_size == 0 {
935 return Err(OptimError::InvalidConfig(
936 "statistical_tests.window_size must be greater than zero".to_string(),
937 ));
938 }
939 if !(0.0..1.0).contains(&self.statistical_tests.significancelevel)
940 || self.statistical_tests.significancelevel <= 0.0
941 {
942 return Err(OptimError::InvalidConfig(format!(
943 "statistical_tests.significancelevel must be in (0, 1), got {}",
944 self.statistical_tests.significancelevel
945 )));
946 }
947 self.reputation_system.validate()?;
948
949 match self.method {
950 ByzantineRobustMethod::TrimmedMean { trim_ratio } => {
951 if !(0.0..1.0).contains(&trim_ratio) || !trim_ratio.is_finite() {
952 return Err(OptimError::InvalidConfig(format!(
953 "TrimmedMean trim_ratio must be in [0, 1), got {trim_ratio}"
954 )));
955 }
956 }
957 ByzantineRobustMethod::MultiKrum { m, .. } => {
958 if m == 0 {
959 return Err(OptimError::InvalidConfig(
960 "MultiKrum must average at least one update (m > 0)".to_string(),
961 ));
962 }
963 }
964 ByzantineRobustMethod::CenteredClipping { tau } => {
965 if !tau.is_finite() || tau <= 0.0 {
966 return Err(OptimError::InvalidConfig(format!(
967 "CenteredClipping tau must be positive and finite, got {tau}"
968 )));
969 }
970 }
971 ByzantineRobustMethod::FedAvgOutlierDetection { threshold } => {
972 if !threshold.is_finite() || threshold <= 0.0 {
973 return Err(OptimError::InvalidConfig(format!(
974 "FedAvgOutlierDetection threshold must be positive and finite, got \
975 {threshold}"
976 )));
977 }
978 }
979 ByzantineRobustMethod::ReputationWeighted { reputation_decay } => {
980 if !(0.0..=1.0).contains(&reputation_decay) || !reputation_decay.is_finite() {
981 return Err(OptimError::InvalidConfig(format!(
982 "ReputationWeighted reputation_decay must be in [0, 1], got \
983 {reputation_decay}"
984 )));
985 }
986 }
987 ByzantineRobustMethod::CoordinateWiseMedian
988 | ByzantineRobustMethod::Krum { .. }
989 | ByzantineRobustMethod::Bulyan { .. } => {}
990 }
991 Ok(())
992 }
993}
994
995impl ReputationSystemConfig {
996 pub fn validate(&self) -> Result<()> {
998 for (name, value) in [
999 ("initial_reputation", self.initial_reputation),
1000 ("reputation_decay", self.reputation_decay),
1001 ("min_reputation", self.min_reputation),
1002 ("outlier_penalty", self.outlier_penalty),
1003 ("contribution_bonus", self.contribution_bonus),
1004 ] {
1005 if !value.is_finite() || value < 0.0 {
1006 return Err(OptimError::InvalidConfig(format!(
1007 "reputation_system.{name} must be finite and non-negative, got {value}"
1008 )));
1009 }
1010 }
1011 if self.reputation_decay > 1.0 {
1012 return Err(OptimError::InvalidConfig(format!(
1013 "reputation_system.reputation_decay must be in [0, 1], got {}",
1014 self.reputation_decay
1015 )));
1016 }
1017 if self.initial_reputation < self.min_reputation {
1018 return Err(OptimError::InvalidConfig(format!(
1019 "reputation_system.initial_reputation ({}) is below min_reputation ({})",
1020 self.initial_reputation, self.min_reputation
1021 )));
1022 }
1023 Ok(())
1024 }
1025}
1026
1027impl Default for ByzantineRobustConfig {
1028 fn default() -> Self {
1029 Self {
1030 method: ByzantineRobustMethod::TrimmedMean { trim_ratio: 0.2 },
1031 expected_byzantine_ratio: 0.1,
1032 dynamic_detection: false,
1033 reputation_system: ReputationSystemConfig::default(),
1034 statistical_tests: StatisticalTestConfig::default(),
1035 }
1036 }
1037}
1038
1039impl Default for ReputationSystemConfig {
1040 fn default() -> Self {
1041 Self {
1042 enabled: true,
1043 initial_reputation: 1.0,
1044 reputation_decay: 0.01,
1045 min_reputation: 0.1,
1046 outlier_penalty: 0.5,
1047 contribution_bonus: 0.1,
1048 }
1049 }
1050}
1051
1052impl Default for StatisticalTestConfig {
1053 fn default() -> Self {
1054 Self {
1055 enabled: true,
1056 test_type: StatisticalTestType::ZScore,
1057 significancelevel: 0.05,
1058 window_size: 100,
1059 adaptive_threshold: false,
1060 }
1061 }
1062}
1063
1064#[cfg(test)]
1065mod tests {
1066 use super::*;
1067 use scirs2_core::ndarray::Array1;
1068
1069 fn cohort(pairs: &[(&str, Vec<f64>)]) -> HashMap<String, Array1<f64>> {
1070 pairs
1071 .iter()
1072 .map(|(id, values)| ((*id).to_string(), Array1::from(values.clone())))
1073 .collect()
1074 }
1075
1076 fn contaminated_cohort() -> HashMap<String, Array1<f64>> {
1078 let mut updates: HashMap<String, Array1<f64>> = (0..10)
1079 .map(|i| {
1080 (
1081 format!("honest{i:02}"),
1082 Array1::from(vec![1.0 + i as f64 * 0.01, 2.0 + i as f64 * 0.01]),
1083 )
1084 })
1085 .collect();
1086 updates.insert("attacker".to_string(), Array1::from(vec![1.0e4, -1.0e4]));
1087 updates
1088 }
1089
1090 fn no_allocations() -> HashMap<String, AdaptivePrivacyAllocation> {
1091 HashMap::new()
1092 }
1093
1094 #[test]
1095 fn test_byzantine_robust_aggregator_creation() {
1096 assert!(ByzantineRobustAggregator::<f64>::new().is_ok());
1097 }
1098
1099 #[test]
1105 fn every_method_produces_a_distinct_real_aggregate_not_the_mean() {
1106 let updates = contaminated_cohort();
1107 let plain_mean = {
1108 let cohort = robust_ops::ordered_cohort(&updates).expect("cohort");
1109 robust_ops::mean(&cohort).expect("mean")
1110 };
1111 assert!(plain_mean[0] > 100.0);
1113
1114 for method in [
1115 ByzantineRobustMethod::TrimmedMean { trim_ratio: 0.4 },
1116 ByzantineRobustMethod::CoordinateWiseMedian,
1117 ByzantineRobustMethod::Krum { f: 2 },
1118 ByzantineRobustMethod::MultiKrum { f: 2, m: 5 },
1119 ByzantineRobustMethod::Bulyan { f: 2 },
1120 ByzantineRobustMethod::CenteredClipping { tau: 1.0 },
1121 ByzantineRobustMethod::FedAvgOutlierDetection { threshold: 2.0 },
1122 ] {
1123 let mut aggregator =
1124 ByzantineRobustAggregator::<f64>::with_config(ByzantineRobustConfig {
1125 method,
1126 ..ByzantineRobustConfig::default()
1127 })
1128 .expect("config");
1129 let result = aggregator
1130 .robust_aggregate(&updates, &no_allocations())
1131 .unwrap_or_else(|error| panic!("{method:?} failed: {error}"));
1132 assert!(
1133 result[0].abs() < 10.0,
1134 "{method:?} returned {} -- it did not resist the attacker",
1135 result[0]
1136 );
1137 assert!(
1138 (result[0] - plain_mean[0]).abs() > 1.0,
1139 "{method:?} returned the plain mean, i.e. it fell through"
1140 );
1141 }
1142 }
1143
1144 #[test]
1145 fn krum_returns_one_actual_client_update() {
1146 let updates = contaminated_cohort();
1147 let mut aggregator = ByzantineRobustAggregator::<f64>::with_config(ByzantineRobustConfig {
1148 method: ByzantineRobustMethod::Krum { f: 2 },
1149 ..ByzantineRobustConfig::default()
1150 })
1151 .expect("config");
1152 let result = aggregator
1153 .robust_aggregate(&updates, &no_allocations())
1154 .expect("krum");
1155
1156 let contributors = aggregator.robust_estimators().last_contributors().to_vec();
1157 assert_eq!(contributors.len(), 1, "Krum selects exactly one client");
1158 assert_ne!(contributors[0], "attacker");
1159 let chosen = &updates[&contributors[0]];
1160 assert_eq!(result.to_vec(), chosen.to_vec());
1161
1162 let scores = aggregator.robust_estimators().krum_scores();
1165 assert_eq!(scores.len(), updates.len());
1166 let attacker = scores["attacker"];
1167 assert!(scores
1168 .iter()
1169 .filter(|(id, _)| id.as_str() != "attacker")
1170 .all(|(_, &score)| score < attacker));
1171 }
1172
1173 #[test]
1174 fn multi_krum_averages_exactly_m_clients() {
1175 let updates = contaminated_cohort();
1176 let mut aggregator = ByzantineRobustAggregator::<f64>::with_config(ByzantineRobustConfig {
1177 method: ByzantineRobustMethod::MultiKrum { f: 2, m: 4 },
1178 ..ByzantineRobustConfig::default()
1179 })
1180 .expect("config");
1181 aggregator
1182 .robust_aggregate(&updates, &no_allocations())
1183 .expect("multi-krum");
1184 let contributors = aggregator.robust_estimators().last_contributors();
1185 assert_eq!(contributors.len(), 4);
1186 assert!(!contributors.iter().any(|id| id == "attacker"));
1187 }
1188
1189 #[test]
1190 fn krum_family_errors_when_the_cohort_is_too_small_instead_of_averaging() {
1191 let updates = cohort(&[("a", vec![1.0]), ("b", vec![1.1]), ("c", vec![0.9])]);
1192 for method in [
1193 ByzantineRobustMethod::Krum { f: 3 },
1194 ByzantineRobustMethod::MultiKrum { f: 3, m: 2 },
1195 ByzantineRobustMethod::Bulyan { f: 3 },
1196 ] {
1197 let mut aggregator =
1198 ByzantineRobustAggregator::<f64>::with_config(ByzantineRobustConfig {
1199 method,
1200 ..ByzantineRobustConfig::default()
1201 })
1202 .expect("config");
1203 let err = aggregator
1204 .robust_aggregate(&updates, &no_allocations())
1205 .expect_err("must not silently average");
1206 assert!(format!("{err}").contains("needs at least"));
1207 }
1208 }
1209
1210 #[test]
1215 fn trim_ratio_is_configuration_driven() {
1216 let updates: HashMap<String, Array1<f64>> = (0..10)
1219 .map(|i| (format!("c{i:02}"), Array1::from(vec![i as f64])))
1220 .collect();
1221
1222 for (ratio, expected_trim) in [(0.4, 2_usize), (0.2, 1), (0.05, 0)] {
1223 let mut aggregator =
1224 ByzantineRobustAggregator::<f64>::with_config(ByzantineRobustConfig {
1225 method: ByzantineRobustMethod::TrimmedMean { trim_ratio: ratio },
1226 ..ByzantineRobustConfig::default()
1227 })
1228 .expect("config");
1229 aggregator
1230 .robust_aggregate(&updates, &no_allocations())
1231 .expect("trimmed mean");
1232 assert_eq!(
1233 aggregator.robust_estimators().last_trim_count(),
1234 expected_trim,
1235 "ratio {ratio} should trim {expected_trim} per tail"
1236 );
1237 }
1238 }
1239
1240 #[test]
1241 fn trimmed_mean_actually_removes_the_tails() {
1242 let mut estimators = RobustEstimators::<f64>::new();
1247 let updates = cohort(&[
1248 ("client1", vec![1.0]),
1249 ("client2", vec![1.1]),
1250 ("client3", vec![10.0]),
1251 ("client4", vec![0.9]),
1252 ]);
1253 let trimmed = estimators
1254 .trimmed_mean(&updates, 0.5)
1255 .expect("trimmed mean");
1256 assert_eq!(estimators.last_trim_count(), 1);
1257 assert!((trimmed[0] - 1.05).abs() < 1e-12);
1258
1259 let untrimmed = estimators
1262 .trimmed_mean(&updates, 0.25)
1263 .expect("trimmed mean");
1264 assert_eq!(estimators.last_trim_count(), 0);
1265 assert!((untrimmed[0] - 3.25).abs() < 1e-12);
1266 }
1267
1268 #[test]
1269 fn test_coordinate_wise_median() {
1270 let aggregator = ByzantineRobustAggregator::<f64>::new().expect("aggregator");
1271 let updates = cohort(&[
1272 ("client1", vec![1.0, 4.0, 7.0]),
1273 ("client2", vec![2.0, 5.0, 8.0]),
1274 ("client3", vec![3.0, 6.0, 9.0]),
1275 ]);
1276 let median = aggregator.coordinate_wise_median(&updates).expect("median");
1277 assert_eq!(median.to_vec(), vec![2.0, 5.0, 8.0]);
1278 }
1279
1280 #[test]
1285 fn malformed_cohorts_produce_errors_rather_than_panics() {
1286 let mut aggregator = ByzantineRobustAggregator::<f64>::new().expect("aggregator");
1287 let empty: HashMap<String, Array1<f64>> = HashMap::new();
1288 assert!(aggregator
1289 .robust_aggregate(&empty, &no_allocations())
1290 .is_err());
1291
1292 let ragged = cohort(&[("a", vec![1.0, 2.0]), ("b", vec![1.0])]);
1293 assert!(aggregator
1294 .robust_aggregate(&ragged, &no_allocations())
1295 .is_err());
1296
1297 let nan = cohort(&[("a", vec![f64::NAN]), ("b", vec![1.0])]);
1298 let err = aggregator
1299 .robust_aggregate(&nan, &no_allocations())
1300 .expect_err("NaN must be rejected");
1301 assert!(format!("{err}").contains("non-finite"));
1302 }
1303
1304 #[test]
1305 fn invalid_configurations_are_rejected_at_construction() {
1306 let bad_ratio = ByzantineRobustConfig {
1307 method: ByzantineRobustMethod::TrimmedMean { trim_ratio: 1.5 },
1308 ..ByzantineRobustConfig::default()
1309 };
1310 assert!(ByzantineRobustAggregator::<f64>::with_config(bad_ratio).is_err());
1311
1312 let contradictory = ByzantineRobustConfig {
1313 dynamic_detection: true,
1314 statistical_tests: StatisticalTestConfig {
1315 enabled: false,
1316 ..StatisticalTestConfig::default()
1317 },
1318 ..ByzantineRobustConfig::default()
1319 };
1320 assert!(ByzantineRobustAggregator::<f64>::with_config(contradictory).is_err());
1321
1322 let bad_tau = ByzantineRobustConfig {
1323 method: ByzantineRobustMethod::CenteredClipping { tau: -1.0 },
1324 ..ByzantineRobustConfig::default()
1325 };
1326 assert!(ByzantineRobustAggregator::<f64>::with_config(bad_tau).is_err());
1327
1328 let bad_byzantine_ratio = ByzantineRobustConfig {
1329 expected_byzantine_ratio: 0.9,
1330 ..ByzantineRobustConfig::default()
1331 };
1332 assert!(ByzantineRobustAggregator::<f64>::with_config(bad_byzantine_ratio).is_err());
1333 }
1334
1335 #[test]
1340 fn test_outlier_detection() {
1341 let mut analyzer = StatisticalAnalyzer::<f64>::new(100, 0.05);
1342 let updates = contaminated_cohort();
1343 let detections = analyzer.detect_outliers(&updates, 1).expect("detection");
1344
1345 assert_eq!(detections.len(), updates.len());
1346 let attacker = detections
1347 .iter()
1348 .find(|result| result.clientid == "attacker")
1349 .expect("attacker verdict");
1350 assert!(attacker.is_outlier);
1351 assert!(attacker.p_value.is_some_and(|p| p < 0.05));
1352 assert_eq!(attacker.detection_method, "ZScore");
1353 assert!(detections
1354 .iter()
1355 .filter(|result| result.clientid != "attacker")
1356 .all(|result| !result.is_outlier));
1357 }
1358
1359 #[test]
1360 fn detection_populates_the_rolling_window_and_bounds_it() {
1361 let mut analyzer = StatisticalAnalyzer::<f64>::new(5, 0.05);
1362 let updates = contaminated_cohort();
1363 assert!(analyzer.test_statistics().is_empty());
1364
1365 analyzer.detect_outliers(&updates, 1).expect("round 1");
1366 assert_eq!(analyzer.test_statistics().len(), 5);
1368 analyzer.detect_outliers(&updates, 2).expect("round 2");
1369 assert_eq!(analyzer.test_statistics().len(), 5);
1370 assert!(analyzer
1371 .test_statistics()
1372 .iter()
1373 .all(|statistic| statistic.test_type == StatisticalTestType::ZScore));
1374 }
1375
1376 #[test]
1377 fn detection_needs_at_least_three_clients() {
1378 let mut analyzer = StatisticalAnalyzer::<f64>::new(100, 0.05);
1379 let pair = cohort(&[("a", vec![1.0]), ("b", vec![1000.0])]);
1380 assert!(analyzer
1381 .detect_outliers(&pair, 1)
1382 .expect("no verdicts")
1383 .is_empty());
1384 }
1385
1386 #[test]
1387 fn robustness_factor_reflects_recorded_verdicts() {
1388 let mut aggregator = ByzantineRobustAggregator::<f64>::new().expect("aggregator");
1389 assert!(aggregator.compute_robustness_factor().is_err());
1391
1392 let updates = contaminated_cohort();
1393 let detections = aggregator
1394 .detect_byzantine_clients(&updates, 1)
1395 .expect("detection");
1396 assert_eq!(aggregator.outlier_history().len(), detections.len());
1397
1398 let flagged = detections.iter().filter(|r| r.is_outlier).count();
1399 let expected = 1.0 - flagged as f64 / detections.len() as f64;
1400 let factor = aggregator
1401 .compute_robustness_factor()
1402 .expect("factor after detection");
1403 assert!((factor - expected).abs() < 1e-12);
1404 assert!(factor < 1.0, "one attacker must lower the factor");
1405 }
1406
1407 #[test]
1408 fn detection_errors_when_statistical_tests_are_disabled() {
1409 let config = ByzantineRobustConfig {
1410 statistical_tests: StatisticalTestConfig {
1411 enabled: false,
1412 ..StatisticalTestConfig::default()
1413 },
1414 ..ByzantineRobustConfig::default()
1415 };
1416 let mut aggregator = ByzantineRobustAggregator::<f64>::with_config(config).expect("config");
1417 let err = aggregator
1418 .detect_byzantine_clients(&contaminated_cohort(), 1)
1419 .expect_err("detection is off");
1420 assert!(format!("{err}").contains("disabled"));
1421 }
1422
1423 #[test]
1424 fn dynamic_detection_excludes_flagged_clients_up_to_the_ratio_cap() {
1425 let updates = contaminated_cohort();
1426 let mut aggregator = ByzantineRobustAggregator::<f64>::with_config(ByzantineRobustConfig {
1427 method: ByzantineRobustMethod::TrimmedMean { trim_ratio: 0.0 },
1428 dynamic_detection: true,
1429 expected_byzantine_ratio: 0.2,
1430 ..ByzantineRobustConfig::default()
1431 })
1432 .expect("config");
1433
1434 let result = aggregator
1435 .robust_aggregate(&updates, &no_allocations())
1436 .expect("aggregate");
1437 assert_eq!(
1438 aggregator.robust_estimators().last_excluded(),
1439 &["attacker".to_string()]
1440 );
1441 assert!((result[0] - 1.045).abs() < 0.01, "got {}", result[0]);
1443 }
1444
1445 #[test]
1446 fn dynamic_detection_respects_the_exclusion_cap() {
1447 let updates = contaminated_cohort();
1448 let mut aggregator = ByzantineRobustAggregator::<f64>::with_config(ByzantineRobustConfig {
1451 method: ByzantineRobustMethod::TrimmedMean { trim_ratio: 0.0 },
1452 dynamic_detection: true,
1453 expected_byzantine_ratio: 0.0,
1454 ..ByzantineRobustConfig::default()
1455 })
1456 .expect("config");
1457 let result = aggregator
1458 .robust_aggregate(&updates, &no_allocations())
1459 .expect("aggregate");
1460 assert!(aggregator.robust_estimators().last_excluded().is_empty());
1461 assert!(result[0] > 100.0);
1462 }
1463
1464 #[test]
1469 fn test_reputation_system() {
1470 let mut aggregator = ByzantineRobustAggregator::<f64>::new().expect("aggregator");
1471 let reputations = aggregator.get_client_reputations(&["client1".to_string()]);
1472 assert_eq!(reputations.get("client1"), Some(&1.0));
1473
1474 aggregator.update_client_reputation("client1".to_string(), true);
1475 let updated = aggregator.get_client_reputations(&["client1".to_string()]);
1476 assert_eq!(updated.get("client1"), Some(&0.5));
1477
1478 aggregator.update_client_reputation("client2".to_string(), false);
1479 let good = aggregator.get_client_reputations(&["client2".to_string()]);
1480 assert_eq!(good.get("client2"), Some(&1.0));
1481 }
1482
1483 #[test]
1484 fn reputation_updates_are_a_no_op_when_the_system_is_disabled() {
1485 let config = ByzantineRobustConfig {
1486 reputation_system: ReputationSystemConfig {
1487 enabled: false,
1488 ..ReputationSystemConfig::default()
1489 },
1490 ..ByzantineRobustConfig::default()
1491 };
1492 let mut aggregator = ByzantineRobustAggregator::<f64>::with_config(config).expect("config");
1493 aggregator.update_client_reputation("client1".to_string(), true);
1494 assert_eq!(
1495 aggregator
1496 .get_client_reputations(&["client1".to_string()])
1497 .get("client1"),
1498 Some(&1.0)
1499 );
1500 }
1501
1502 #[test]
1503 fn reputation_weighted_aggregation_down_weights_penalised_clients() {
1504 let updates = cohort(&[("good", vec![0.0]), ("bad", vec![10.0])]);
1505 let mut aggregator = ByzantineRobustAggregator::<f64>::with_config(ByzantineRobustConfig {
1506 method: ByzantineRobustMethod::ReputationWeighted {
1507 reputation_decay: 0.0,
1508 },
1509 ..ByzantineRobustConfig::default()
1510 })
1511 .expect("config");
1512
1513 let balanced = aggregator
1515 .robust_aggregate(&updates, &no_allocations())
1516 .expect("balanced");
1517 assert!((balanced[0] - 5.0).abs() < 1e-12);
1518
1519 aggregator.update_client_reputation("bad".to_string(), true);
1521 aggregator.update_client_reputation("bad".to_string(), true);
1522 let skewed = aggregator
1523 .robust_aggregate(&updates, &no_allocations())
1524 .expect("skewed");
1525 assert!(
1527 (skewed[0] - (10.0 * 0.1 / 1.1)).abs() < 1e-12,
1528 "got {}",
1529 skewed[0]
1530 );
1531 }
1532
1533 #[test]
1534 fn reputation_weighted_requires_the_reputation_system() {
1535 let config = ByzantineRobustConfig {
1536 method: ByzantineRobustMethod::ReputationWeighted {
1537 reputation_decay: 0.1,
1538 },
1539 reputation_system: ReputationSystemConfig {
1540 enabled: false,
1541 ..ReputationSystemConfig::default()
1542 },
1543 ..ByzantineRobustConfig::default()
1544 };
1545 let mut aggregator = ByzantineRobustAggregator::<f64>::with_config(config).expect("config");
1546 let updates = cohort(&[("a", vec![1.0]), ("b", vec![2.0])]);
1547 let err = aggregator
1548 .robust_aggregate(&updates, &no_allocations())
1549 .expect_err("must not silently become FedAvg");
1550 assert!(format!("{err}").contains("reputation_system.enabled"));
1551 }
1552
1553 #[test]
1554 fn reputation_decay_reverts_towards_the_initial_value() {
1555 let mut aggregator = ByzantineRobustAggregator::<f64>::with_config(ByzantineRobustConfig {
1556 method: ByzantineRobustMethod::ReputationWeighted {
1557 reputation_decay: 0.5,
1558 },
1559 ..ByzantineRobustConfig::default()
1560 })
1561 .expect("config");
1562 aggregator.update_client_reputation("bad".to_string(), true);
1563 assert_eq!(
1564 aggregator
1565 .get_client_reputations(&["bad".to_string()])
1566 .get("bad"),
1567 Some(&0.5)
1568 );
1569
1570 let updates = cohort(&[("bad", vec![1.0]), ("good", vec![1.0])]);
1571 aggregator
1572 .robust_aggregate(&updates, &no_allocations())
1573 .expect("aggregate");
1574 let reputation = aggregator.get_client_reputations(&["bad".to_string()])["bad"];
1576 assert!((reputation - 0.75).abs() < 1e-12, "got {reputation}");
1577 }
1578
1579 #[test]
1584 fn allocations_weight_the_weighted_methods() {
1585 let updates = cohort(&[("a", vec![0.0]), ("b", vec![10.0])]);
1586 let mut allocations = HashMap::new();
1587 allocations.insert(
1588 "a".to_string(),
1589 AdaptivePrivacyAllocation {
1590 epsilon: 1.0,
1591 delta: 1e-5,
1592 utility_weight: 3.0,
1593 },
1594 );
1595 allocations.insert(
1596 "b".to_string(),
1597 AdaptivePrivacyAllocation {
1598 epsilon: 1.0,
1599 delta: 1e-5,
1600 utility_weight: 1.0,
1601 },
1602 );
1603
1604 let mut aggregator = ByzantineRobustAggregator::<f64>::with_config(ByzantineRobustConfig {
1605 method: ByzantineRobustMethod::ReputationWeighted {
1606 reputation_decay: 0.0,
1607 },
1608 ..ByzantineRobustConfig::default()
1609 })
1610 .expect("config");
1611 let result = aggregator
1612 .robust_aggregate(&updates, &allocations)
1613 .expect("weighted");
1614 assert!((result[0] - 2.5).abs() < 1e-12, "got {}", result[0]);
1615 }
1616
1617 #[test]
1618 fn partial_or_malformed_allocations_are_rejected() {
1619 let updates = cohort(&[("a", vec![0.0]), ("b", vec![10.0])]);
1620 let mut aggregator = ByzantineRobustAggregator::<f64>::new().expect("aggregator");
1621
1622 let mut partial = HashMap::new();
1623 partial.insert(
1624 "a".to_string(),
1625 AdaptivePrivacyAllocation {
1626 epsilon: 1.0,
1627 delta: 1e-5,
1628 utility_weight: 1.0,
1629 },
1630 );
1631 let err = aggregator
1632 .robust_aggregate(&updates, &partial)
1633 .expect_err("missing client b");
1634 assert!(format!("{err}").contains("missing from them"));
1635
1636 let mut negative = partial.clone();
1637 negative.insert(
1638 "b".to_string(),
1639 AdaptivePrivacyAllocation {
1640 epsilon: 1.0,
1641 delta: 1e-5,
1642 utility_weight: -1.0,
1643 },
1644 );
1645 assert!(aggregator.robust_aggregate(&updates, &negative).is_err());
1646
1647 let mut bad_epsilon = partial;
1648 bad_epsilon.insert(
1649 "b".to_string(),
1650 AdaptivePrivacyAllocation {
1651 epsilon: 0.0,
1652 delta: 1e-5,
1653 utility_weight: 1.0,
1654 },
1655 );
1656 assert!(aggregator.robust_aggregate(&updates, &bad_epsilon).is_err());
1657 }
1658
1659 #[test]
1664 fn aggregation_is_independent_of_hash_map_order() {
1665 let updates = contaminated_cohort();
1666 let mut first = ByzantineRobustAggregator::<f64>::with_config(ByzantineRobustConfig {
1667 method: ByzantineRobustMethod::MultiKrum { f: 2, m: 5 },
1668 ..ByzantineRobustConfig::default()
1669 })
1670 .expect("config");
1671 let reference = first
1672 .robust_aggregate(&updates, &no_allocations())
1673 .expect("aggregate");
1674
1675 for _ in 0..5 {
1676 let shuffled: HashMap<String, Array1<f64>> = updates
1677 .iter()
1678 .map(|(id, update)| (id.clone(), update.clone()))
1679 .collect();
1680 let mut aggregator =
1681 ByzantineRobustAggregator::<f64>::with_config(ByzantineRobustConfig {
1682 method: ByzantineRobustMethod::MultiKrum { f: 2, m: 5 },
1683 ..ByzantineRobustConfig::default()
1684 })
1685 .expect("config");
1686 let result = aggregator
1687 .robust_aggregate(&shuffled, &no_allocations())
1688 .expect("aggregate");
1689 assert_eq!(result.to_vec(), reference.to_vec());
1690 }
1691 }
1692}