Skip to main content

optirs_core/privacy/federated/
byzantine_aggregation.rs

1// Byzantine Robust Aggregation Module
2//
3// Byzantine-robust aggregation for federated learning: outlier detection over
4// client updates, a reputation system, and a set of robust estimators that
5// bound how far a malicious cohort can drag the aggregate.
6//
7// What changed and why
8// --------------------
9// An earlier revision of this module dispatched only two of its eight
10// configured methods and *silently fell through to a plain arithmetic mean*
11// for the rest. Plain averaging has an unbounded breakdown point: a single
12// client can move the aggregate anywhere it likes. Selecting `Krum` and
13// receiving FedAvg is therefore not a degradation, it is the removal of the
14// entire guarantee -- while the configuration still claims it. Every method
15// is now either implemented (see [`super::robust_ops`]) or rejected; nothing
16// falls through.
17//
18// Two pieces of state were also declared but never written: `outlier_history`
19// (which made [`ByzantineRobustAggregator::compute_robustness_factor`] return
20// a constant 1.0, i.e. "no Byzantine behaviour ever observed", regardless of
21// what happened) and the statistical analyzer's rolling window (which made
22// `window_size` and `adaptive_threshold` dead configuration). Both are now
23// populated and bounded.
24
25use 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
33/// Maximum number of outlier evaluations retained for
34/// [`ByzantineRobustAggregator::compute_robustness_factor`].
35pub const OUTLIER_HISTORY_CAPACITY: usize = 1000;
36
37/// Byzantine-robust aggregation algorithms
38#[derive(Debug, Clone, Copy, PartialEq)]
39pub enum ByzantineRobustMethod {
40    /// Coordinate-wise trimmed mean: discard `trim_ratio / 2` of the values
41    /// from each tail of every coordinate before averaging.
42    TrimmedMean {
43        /// Total fraction of values discarded, split between the two tails.
44        trim_ratio: f64,
45    },
46
47    /// Coordinate-wise median.
48    CoordinateWiseMedian,
49
50    /// Krum: return the single update closest to its `n - f - 2` nearest
51    /// peers.
52    Krum {
53        /// Assumed number of Byzantine clients.
54        f: usize,
55    },
56
57    /// Multi-Krum: average the `m` updates with the lowest Krum scores.
58    MultiKrum {
59        /// Assumed number of Byzantine clients.
60        f: usize,
61        /// Number of updates to average.
62        m: usize,
63    },
64
65    /// Bulyan: iterated Krum selection followed by a median-proximity
66    /// coordinate-wise average.
67    Bulyan {
68        /// Assumed number of Byzantine clients.
69        f: usize,
70    },
71
72    /// Centered clipping around a robust centre, clipping each update's
73    /// deviation to radius `tau`.
74    CenteredClipping {
75        /// Clipping radius.
76        tau: f64,
77    },
78
79    /// FedAvg restricted to the clients whose outlier statistic stays within
80    /// `threshold`.
81    FedAvgOutlierDetection {
82        /// Maximum absolute outlier statistic a client may have and still be
83        /// averaged.
84        threshold: f64,
85    },
86
87    /// Reputation-weighted averaging, with reputations decayed towards their
88    /// initial value by `reputation_decay` before use.
89    ReputationWeighted {
90        /// Per-round mean reversion applied to reputations, in `[0, 1]`.
91        reputation_decay: f64,
92    },
93}
94
95/// Byzantine robustness configuration
96#[derive(Debug, Clone)]
97pub struct ByzantineRobustConfig {
98    /// Aggregation method
99    pub method: ByzantineRobustMethod,
100
101    /// Upper bound on the fraction of the cohort that dynamic detection is
102    /// allowed to exclude in one round. Must be in `[0, 0.5)`.
103    pub expected_byzantine_ratio: f64,
104
105    /// Run outlier detection before aggregating and drop the clients it
106    /// flags (up to `expected_byzantine_ratio` of the cohort). Requires
107    /// `statistical_tests.enabled`.
108    pub dynamic_detection: bool,
109
110    /// Reputation system settings
111    pub reputation_system: ReputationSystemConfig,
112
113    /// Statistical tests for outlier detection
114    pub statistical_tests: StatisticalTestConfig,
115}
116
117/// Reputation system configuration
118#[derive(Debug, Clone)]
119pub struct ReputationSystemConfig {
120    /// Whether reputations are maintained at all.
121    pub enabled: bool,
122    /// Reputation assigned to a client on first sight, in `[0, 1]`.
123    pub initial_reputation: f64,
124    /// Per-round mean reversion towards `initial_reputation`, in `[0, 1]`.
125    pub reputation_decay: f64,
126    /// Floor below which a reputation cannot fall.
127    pub min_reputation: f64,
128    /// Reputation subtracted when a client is flagged as an outlier.
129    pub outlier_penalty: f64,
130    /// Reputation added when a client is not flagged.
131    pub contribution_bonus: f64,
132}
133
134/// Statistical test configuration for outlier detection
135#[derive(Debug, Clone)]
136pub struct StatisticalTestConfig {
137    /// Whether detection runs.
138    pub enabled: bool,
139    /// Which test decides what counts as an outlier.
140    pub test_type: StatisticalTestType,
141    /// Significance level for the tests that define a p-value.
142    pub significancelevel: f64,
143    /// Number of past per-client statistics retained for the adaptive
144    /// threshold. Must be non-zero.
145    pub window_size: usize,
146    /// Estimate the test's location and scale from the retained window in
147    /// addition to the current round, so the decision boundary tracks recent
148    /// cohort behaviour instead of being re-derived from one round alone.
149    pub adaptive_threshold: bool,
150}
151
152/// Statistical tests available for outlier detection. See
153/// [`super::outlier_tests`] for the definitions and references.
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub enum StatisticalTestType {
156    /// Two-sided z-test against the standard normal.
157    ZScore,
158    /// Iglewicz-Hoaglin median/MAD score with the 3.5 cut-off.
159    ModifiedZScore,
160    /// Tukey's 1.5 x IQR fences.
161    IQRTest,
162    /// Grubbs' test with a Bonferroni correction over the cohort.
163    GrubbsTest,
164    /// Chauvenet's criterion.
165    ChauventCriterion,
166}
167
168/// Byzantine-robust aggregation engine
169pub 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
180/// Statistical analyzer for outlier detection
181pub 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
191/// Diagnostics recorded by the most recent [`ByzantineRobustAggregator::robust_aggregate`].
192///
193/// These are not caches used to skip work -- every aggregation recomputes from
194/// scratch -- they are the audit trail explaining *which* clients the method
195/// actually used, which is the only way to tell a working robust aggregator
196/// from a plain mean after the fact.
197pub 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/// Outlier detection result
208#[derive(Debug, Clone)]
209pub struct OutlierDetectionResult {
210    /// Client the verdict applies to.
211    pub clientid: String,
212    /// Round in which the verdict was produced.
213    pub round: usize,
214    /// Whether the client was flagged.
215    pub is_outlier: bool,
216    /// The test statistic (a z-score, modified z-score, IQR multiple or
217    /// Grubbs' G, depending on the configured test).
218    pub outlier_score: f64,
219    /// Raw input statistic: the client's mean Euclidean distance to the rest
220    /// of the cohort.
221    pub mean_distance: f64,
222    /// Two-sided p-value where the test defines one.
223    pub p_value: Option<f64>,
224    /// Name of the test that produced the verdict.
225    pub detection_method: String,
226}
227
228/// Test statistic for outlier detection
229#[derive(Debug, Clone)]
230pub struct TestStatistic<T: Float + Debug + Send + Sync + 'static> {
231    /// The test statistic produced for this client.
232    pub statistic_value: T,
233    /// The raw input statistic (mean distance to the cohort) that the test
234    /// consumed. Retained so the adaptive threshold can pool it with later
235    /// rounds.
236    pub sample_value: f64,
237    /// Two-sided p-value, where the test defines one.
238    pub p_value: Option<f64>,
239    /// Which test produced it.
240    pub test_type: StatisticalTestType,
241    /// Client the statistic belongs to.
242    pub clientid: String,
243}
244
245/// Per-client privacy allocation supplied to
246/// [`ByzantineRobustAggregator::robust_aggregate`].
247///
248/// `utility_weight` is used as the client's weight by the two methods where a
249/// weight is unambiguous -- [`ByzantineRobustMethod::ReputationWeighted`] and
250/// [`ByzantineRobustMethod::FedAvgOutlierDetection`]. The rank- and
251/// geometry-based methods (trimmed mean, median, Krum, Multi-Krum, Bulyan,
252/// centered clipping) have no weighted formulation that preserves their
253/// robustness proofs, so they ignore it; the allocation is still validated so
254/// a malformed one cannot pass silently.
255#[derive(Debug, Clone)]
256pub struct AdaptivePrivacyAllocation {
257    /// Epsilon allocated to the client for this round. Must be positive.
258    pub epsilon: f64,
259    /// Delta allocated to the client. Must be in `[0, 1)`.
260    pub delta: f64,
261    /// Non-negative aggregation weight.
262    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    /// Create an aggregator with the default configuration.
278    pub fn new() -> Result<Self> {
279        Self::with_config(ByzantineRobustConfig::default())
280    }
281
282    /// Create an aggregator with an explicit configuration, rejecting
283    /// configurations whose parameters cannot produce a valid aggregate.
284    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    /// Run outlier detection over a cohort and record the verdicts.
298    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    /// Current reputation of each requested client, defaulting to the
321    /// configured initial reputation for clients never seen before.
322    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    /// Aggregate a cohort of client updates using the configured method.
337    ///
338    /// When `dynamic_detection` is enabled the cohort is first filtered: the
339    /// configured statistical test is run, and up to
340    /// `floor(n * expected_byzantine_ratio)` of the highest-scoring flagged
341    /// clients are removed before the estimator runs. At least one client
342    /// always survives.
343    ///
344    /// Returns an error rather than a plain mean whenever the configured
345    /// method cannot be applied to this cohort.
346    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    /// Fraction of recorded outlier evaluations that were *not* flagged.
382    ///
383    /// Errors when no detection has ever run: reporting a perfect 1.0 for an
384    /// empty history would claim evidence of robustness that does not exist.
385    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    /// Coordinate-wise median of a cohort, independent of the configured
403    /// method.
404    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    /// Get current configuration
413    pub fn config(&self) -> &ByzantineRobustConfig {
414        &self.config
415    }
416
417    /// Recorded outlier verdicts, oldest first.
418    pub fn outlier_history(&self) -> &VecDeque<OutlierDetectionResult> {
419        &self.outlier_history
420    }
421
422    /// Diagnostics from the most recent aggregation.
423    pub fn robust_estimators(&self) -> &RobustEstimators<T> {
424        &self.robust_estimators
425    }
426
427    /// Read-only access to the statistical analyzer.
428    pub fn statistical_analyzer(&self) -> &StatisticalAnalyzer<T> {
429        &self.statistical_analyzer
430    }
431
432    /// Number of completed aggregations.
433    pub fn rounds_aggregated(&self) -> usize {
434        self.rounds_aggregated
435    }
436
437    /// Update client reputation after an outlier verdict.
438    ///
439    /// A no-op when the reputation system is disabled, so that a disabled
440    /// system cannot influence a later reputation-weighted aggregate.
441    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    /// Move every known reputation `decay` of the way back towards the
461    /// configured initial reputation.
462    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    /// Run detection and decide which clients to drop, honouring the
474    /// `expected_byzantine_ratio` cap.
475    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        // Drop the most extreme first, ties broken by client id.
496        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    /// Dispatch to the configured estimator. Every arm either produces a real
514    /// aggregate or an error; there is no fall-through.
515    ///
516    /// `detection_excluded` lists the clients dynamic detection already
517    /// removed, so that the recorded exclusion set is the union of what
518    /// detection dropped and what the method itself filtered.
519    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
635/// Validate `allocations` and project them onto the cohort order.
636///
637/// Returns `None` when no allocations were supplied (the common case, where
638/// every client is weighted equally). When allocations *are* supplied they
639/// must cover every cohort member: a partial allocation would silently give
640/// the uncovered clients a fabricated default weight.
641fn 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    /// Create an analyzer with an explicit window and significance level,
683    /// using a two-sided z-test and a per-round (non-adaptive) threshold.
684    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    /// Create an analyzer from a [`StatisticalTestConfig`].
695    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    /// Detect outliers using the configured statistical test.
706    ///
707    /// The per-client input statistic is the mean Euclidean distance from
708    /// that client's update to every other client's. Cohorts of fewer than
709    /// three clients yield no verdicts: with two updates each is exactly as
710    /// far from the other as vice versa, so no distance-based test can
711    /// distinguish them.
712    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    /// Score an already-ordered cohort without recording anything.
755    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    /// Statistics retained in the rolling window, oldest first.
771    pub fn test_statistics(&self) -> &VecDeque<TestStatistic<T>> {
772        &self.test_statistics
773    }
774
775    /// Configured window width.
776    pub fn window_size(&self) -> usize {
777        self.window_size
778    }
779
780    /// Configured significance level.
781    pub fn significance_level(&self) -> f64 {
782        self.significancelevel
783    }
784
785    /// Mean Euclidean distance from each cohort member to the others.
786    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    /// Create an empty diagnostics record.
829    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    /// Coordinate-wise trimmed mean at the requested ratio.
840    ///
841    /// The trim count is derived from the ratio via
842    /// [`robust_ops::trim_count_for_ratio`], so a ratio too small to remove
843    /// anything from a cohort of this size removes nothing -- and
844    /// [`Self::last_trim_count`] says so, rather than the caller having to
845    /// guess whether trimming happened.
846    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    /// Coordinate-wise median, caching the result for inspection.
859    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    /// Values removed from each tail by the most recent trimmed mean.
868    pub fn last_trim_count(&self) -> usize {
869        self.last_trim_count
870    }
871
872    /// Most recently computed coordinate-wise median.
873    pub fn last_median(&self) -> Option<&Array1<T>> {
874        self.last_median.as_ref()
875    }
876
877    /// Krum scores from the most recent Krum-family aggregation.
878    pub fn krum_scores(&self) -> &HashMap<String, f64> {
879        &self.krum_scores
880    }
881
882    /// Clients that actually contributed to the most recent aggregate.
883    pub fn last_contributors(&self) -> &[String] {
884        &self.last_contributors
885    }
886
887    /// Clients excluded from the most recent aggregate.
888    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    /// Reject configurations whose parameters cannot yield a valid aggregate.
918    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    /// Reject reputation parameters that cannot produce usable weights.
997    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    /// Ten honest clients clustered near `1.0` plus one wild attacker.
1077    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    // ---------------------------------------------------------------------
1100    // F26/F27: every configured method is dispatched; none falls through to
1101    // a plain mean.
1102    // ---------------------------------------------------------------------
1103
1104    #[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        // Sanity: the attacker drags the plain mean far away from 1.0.
1112        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        // Real Krum scores were recorded for every client, and the attacker's
1163        // is by far the worst.
1164        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    // ---------------------------------------------------------------------
1211    // F28: the trim ratio comes from the configuration.
1212    // ---------------------------------------------------------------------
1213
1214    #[test]
1215    fn trim_ratio_is_configuration_driven() {
1216        // 10 clients: ratio 0.4 trims 2 from each tail, ratio 0.2 trims 1,
1217        // ratio 0.05 trims none.
1218        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        // Sorted values 0.9, 1.0, 1.1, 10.0. A 50% ratio trims one per tail,
1243        // leaving the mean of {1.0, 1.1} = 1.05. The previous implementation
1244        // computed floor(4 * 0.25 / 2) = 0 and returned the plain mean 3.25
1245        // while its test claimed the outlier had been excluded.
1246        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        // With the old 0.25 ratio nothing is trimmed -- and the API now says
1260        // so instead of implying robustness.
1261        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    // ---------------------------------------------------------------------
1281    // F29/F30: no `.expect` on malformed input; errors propagate.
1282    // ---------------------------------------------------------------------
1283
1284    #[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    // ---------------------------------------------------------------------
1336    // Detection, history and the robustness factor.
1337    // ---------------------------------------------------------------------
1338
1339    #[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        // 11 clients into a window of 5 leaves the last 5.
1367        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        // No history: the old implementation reported a perfect 1.0.
1390        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        // With the attacker gone, a zero-trim mean is already close to 1.0.
1442        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        // A zero ratio permits zero exclusions, so the attacker survives and
1449        // the untrimmed mean is dragged away.
1450        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    // ---------------------------------------------------------------------
1465    // Reputation.
1466    // ---------------------------------------------------------------------
1467
1468    #[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        // Equal reputations => the plain mean.
1514        let balanced = aggregator
1515            .robust_aggregate(&updates, &no_allocations())
1516            .expect("balanced");
1517        assert!((balanced[0] - 5.0).abs() < 1e-12);
1518
1519        // Penalise "bad" twice: 1.0 -> 0.5 -> 0.1 (the floor).
1520        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        // weights 1.0 and 0.1 => 10 * 0.1 / 1.1
1526        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        // 0.5 + (1.0 - 0.5) * 0.5 = 0.75
1575        let reputation = aggregator.get_client_reputations(&["bad".to_string()])["bad"];
1576        assert!((reputation - 0.75).abs() < 1e-12, "got {reputation}");
1577    }
1578
1579    // ---------------------------------------------------------------------
1580    // Privacy allocations.
1581    // ---------------------------------------------------------------------
1582
1583    #[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    // ---------------------------------------------------------------------
1660    // Determinism.
1661    // ---------------------------------------------------------------------
1662
1663    #[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}