Skip to main content

trustformers_optim/
federated.rs

1//! # Federated Learning Optimization
2//!
3//! This module implements algorithms for federated learning, enabling distributed
4//! training across multiple clients while preserving privacy and handling
5//! heterogeneous data distributions.
6//!
7//! ## Available Algorithms
8//!
9//! - **FedAvg**: Standard federated averaging algorithm
10//! - **FedProx**: Federated optimization with proximal regularization
11//! - **Secure Aggregation**: Privacy-preserving parameter aggregation
12//! - **Differential Privacy**: Add noise for enhanced privacy protection
13//! - **Client Selection**: Strategies for selecting participating clients
14
15// reason: research-stage module — reserved API/scaffolding fields and methods
16// retained intentionally for in-progress features; not yet on active call paths.
17#![allow(dead_code)]
18
19use anyhow::{anyhow, Result};
20use scirs2_core::random::StdRng; // Explicit import for type clarity
21use scirs2_core::random::*; // SciRS2 Integration Policy - Replaces rand
22use serde::{Deserialize, Serialize};
23use std::collections::hash_map::DefaultHasher;
24use std::collections::HashMap;
25use std::hash::{Hash, Hasher};
26use trustformers_core::tensor::Tensor;
27
28/// Configuration for federated averaging (FedAvg).
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct FedAvgConfig {
31    /// Number of local epochs per client
32    pub local_epochs: usize,
33    /// Local learning rate for client updates
34    pub local_learning_rate: f32,
35    /// Fraction of clients participating per round
36    pub client_fraction: f32,
37    /// Minimum number of clients required per round
38    pub min_clients: usize,
39    /// Maximum number of clients per round
40    pub max_clients: usize,
41    /// Weight decay for regularization
42    pub weight_decay: f32,
43}
44
45impl Default for FedAvgConfig {
46    fn default() -> Self {
47        Self {
48            local_epochs: 5,
49            local_learning_rate: 1e-3,
50            client_fraction: 0.1,
51            min_clients: 2,
52            max_clients: 100,
53            weight_decay: 0.0,
54        }
55    }
56}
57
58/// Configuration for FedProx algorithm.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct FedProxConfig {
61    /// FedAvg configuration
62    pub fedavg_config: FedAvgConfig,
63    /// Proximal term coefficient (μ)
64    pub mu: f32,
65}
66
67impl Default for FedProxConfig {
68    fn default() -> Self {
69        Self {
70            fedavg_config: FedAvgConfig::default(),
71            mu: 0.01,
72        }
73    }
74}
75
76/// Configuration for differential privacy.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct DifferentialPrivacyConfig {
79    /// Privacy budget (epsilon)
80    pub epsilon: f32,
81    /// Delta parameter for (ε,δ)-differential privacy
82    pub delta: f32,
83    /// Sensitivity of the function (max change in output per unit change in input)
84    pub sensitivity: f32,
85    /// Noise mechanism to use
86    pub noise_mechanism: NoiseMechanism,
87}
88
89impl Default for DifferentialPrivacyConfig {
90    fn default() -> Self {
91        Self {
92            epsilon: 1.0,
93            delta: 1e-5,
94            sensitivity: 1.0,
95            noise_mechanism: NoiseMechanism::Gaussian,
96        }
97    }
98}
99
100/// Types of noise mechanisms for differential privacy.
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub enum NoiseMechanism {
103    /// Gaussian noise
104    Gaussian,
105    /// Laplace noise
106    Laplace,
107}
108
109/// Client selection strategies.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub enum ClientSelectionStrategy {
112    /// Random selection
113    Random,
114    /// Selection based on data size
115    DataSize,
116    /// Selection based on computational capacity
117    ComputeCapacity,
118    /// Selection based on communication quality
119    CommunicationQuality,
120}
121
122/// Information about a federated client.
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct ClientInfo {
125    /// Client identifier
126    pub client_id: String,
127    /// Number of data samples
128    pub data_size: usize,
129    /// Computational capacity (relative metric)
130    pub compute_capacity: f32,
131    /// Communication quality (bandwidth, latency, etc.)
132    pub communication_quality: f32,
133    /// Client availability
134    pub available: bool,
135}
136
137/// Federated Averaging (FedAvg) optimizer.
138///
139/// Implements the standard federated learning algorithm where clients
140/// perform local updates and the server aggregates them via weighted averaging.
141#[derive(Debug)]
142pub struct FedAvg {
143    config: FedAvgConfig,
144    global_parameters: Vec<Tensor>,
145    client_weights: HashMap<String, f32>,
146    current_round: usize,
147    selected_clients: Vec<String>,
148    rng: StdRng,
149}
150
151impl FedAvg {
152    /// Create a new FedAvg optimizer.
153    pub fn new(config: FedAvgConfig) -> Self {
154        Self {
155            config,
156            global_parameters: Vec::new(),
157            client_weights: HashMap::new(),
158            current_round: 0,
159            selected_clients: Vec::new(),
160            rng: StdRng::seed_from_u64(42),
161        }
162    }
163
164    /// Initialize global parameters.
165    pub fn initialize_global_parameters(&mut self, parameters: Vec<Tensor>) {
166        self.global_parameters = parameters;
167    }
168
169    /// Select clients for the current round.
170    pub fn select_clients(
171        &mut self,
172        available_clients: &[ClientInfo],
173        strategy: ClientSelectionStrategy,
174    ) -> Result<Vec<String>> {
175        let available: Vec<&ClientInfo> =
176            available_clients.iter().filter(|c| c.available).collect();
177
178        if available.is_empty() {
179            return Err(anyhow!("No available clients"));
180        }
181
182        let num_clients = (available.len() as f32 * self.config.client_fraction).round() as usize;
183        let num_clients = num_clients
184            .max(self.config.min_clients)
185            .min(self.config.max_clients)
186            .min(available.len());
187
188        let selected = match strategy {
189            ClientSelectionStrategy::Random => {
190                let mut indices: Vec<usize> = (0..available.len()).collect();
191                for i in 0..num_clients {
192                    let j = self.rng.random_range(i..indices.len());
193                    indices.swap(i, j);
194                }
195                indices[..num_clients].iter().map(|&i| available[i].client_id.clone()).collect()
196            },
197            ClientSelectionStrategy::DataSize => {
198                let mut clients_with_size: Vec<_> =
199                    available.iter().map(|c| (c.client_id.clone(), c.data_size)).collect();
200                clients_with_size.sort_by_key(|(_, size)| std::cmp::Reverse(*size));
201                clients_with_size[..num_clients].iter().map(|(id, _)| id.clone()).collect()
202            },
203            ClientSelectionStrategy::ComputeCapacity => {
204                let mut clients_with_capacity: Vec<_> =
205                    available.iter().map(|c| (c.client_id.clone(), c.compute_capacity)).collect();
206                clients_with_capacity.sort_by(|(_, a), (_, b)| {
207                    b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal)
208                });
209                clients_with_capacity[..num_clients].iter().map(|(id, _)| id.clone()).collect()
210            },
211            ClientSelectionStrategy::CommunicationQuality => {
212                let mut clients_with_quality: Vec<_> = available
213                    .iter()
214                    .map(|c| (c.client_id.clone(), c.communication_quality))
215                    .collect();
216                clients_with_quality.sort_by(|(_, a), (_, b)| {
217                    b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal)
218                });
219                clients_with_quality[..num_clients].iter().map(|(id, _)| id.clone()).collect()
220            },
221        };
222
223        self.selected_clients = selected;
224        Ok(self.selected_clients.clone())
225    }
226
227    /// Aggregate client updates using weighted averaging.
228    pub fn aggregate_updates(
229        &mut self,
230        client_updates: HashMap<String, Vec<Tensor>>,
231    ) -> Result<Vec<Tensor>> {
232        if client_updates.is_empty() {
233            return Err(anyhow!("No client updates to aggregate"));
234        }
235
236        let total_weight: f32 = client_updates
237            .keys()
238            .map(|client_id| self.client_weights.get(client_id).unwrap_or(&1.0))
239            .sum();
240
241        if total_weight == 0.0 {
242            return Err(anyhow!("Total client weight is zero"));
243        }
244
245        // Initialize aggregated parameters with zeros
246        let param_count = client_updates
247            .values()
248            .next()
249            .ok_or_else(|| anyhow::anyhow!("client_updates must have at least one entry"))?
250            .len();
251        let mut aggregated = Vec::with_capacity(param_count);
252
253        for i in 0..param_count {
254            // Get shape from first client's parameter
255            let first_param = &client_updates
256                .values()
257                .next()
258                .ok_or_else(|| anyhow::anyhow!("client_updates must have at least one entry"))?[i];
259            aggregated.push(Tensor::zeros_like(first_param)?);
260        }
261
262        // Weighted aggregation
263        for (client_id, updates) in &client_updates {
264            let weight = self.client_weights.get(client_id).unwrap_or(&1.0) / total_weight;
265
266            for (i, update) in updates.iter().enumerate() {
267                let weighted_update = update.mul_scalar(weight)?;
268                aggregated[i] = aggregated[i].add(&weighted_update)?;
269            }
270        }
271
272        // Update global parameters
273        self.global_parameters = aggregated.clone();
274        self.current_round += 1;
275
276        Ok(aggregated)
277    }
278
279    /// Set client weights for aggregation.
280    pub fn set_client_weights(&mut self, weights: HashMap<String, f32>) {
281        self.client_weights = weights;
282    }
283
284    /// Get current global parameters.
285    pub fn get_global_parameters(&self) -> &[Tensor] {
286        &self.global_parameters
287    }
288
289    /// Get current round number.
290    pub fn get_current_round(&self) -> usize {
291        self.current_round
292    }
293}
294
295/// FedProx optimizer with proximal regularization.
296///
297/// Extends FedAvg with a proximal term to handle client heterogeneity
298/// by adding regularization that keeps client updates close to global model.
299#[derive(Debug)]
300pub struct FedProx {
301    fedavg: FedAvg,
302    config: FedProxConfig,
303}
304
305impl FedProx {
306    /// Create a new FedProx optimizer.
307    pub fn new(config: FedProxConfig) -> Self {
308        Self {
309            fedavg: FedAvg::new(config.fedavg_config.clone()),
310            config,
311        }
312    }
313
314    /// Compute proximal term for client update.
315    pub fn compute_proximal_term(
316        &self,
317        client_params: &[Tensor],
318        global_params: &[Tensor],
319    ) -> Result<f32> {
320        if client_params.len() != global_params.len() {
321            return Err(anyhow!("Parameter count mismatch"));
322        }
323
324        let mut proximal_loss = 0.0;
325        for (client_param, global_param) in client_params.iter().zip(global_params.iter()) {
326            let diff = client_param.sub(global_param)?;
327            let norm_sq = diff.norm_squared()?.to_scalar()?;
328            proximal_loss += norm_sq;
329        }
330
331        Ok(self.config.mu * proximal_loss / 2.0)
332    }
333
334    /// Apply proximal update to client parameters.
335    pub fn apply_proximal_update(
336        &self,
337        client_params: &mut [Tensor],
338        global_params: &[Tensor],
339        learning_rate: f32,
340    ) -> Result<()> {
341        for (client_param, global_param) in client_params.iter_mut().zip(global_params.iter()) {
342            let diff = client_param.sub(global_param)?;
343            let proximal_grad = diff.mul_scalar(self.config.mu)?;
344            let update = proximal_grad.mul_scalar(learning_rate)?;
345            *client_param = client_param.sub(&update)?;
346        }
347        Ok(())
348    }
349
350    /// Delegate to FedAvg for other operations.
351    pub fn select_clients(
352        &mut self,
353        available_clients: &[ClientInfo],
354        strategy: ClientSelectionStrategy,
355    ) -> Result<Vec<String>> {
356        self.fedavg.select_clients(available_clients, strategy)
357    }
358
359    pub fn aggregate_updates(
360        &mut self,
361        client_updates: HashMap<String, Vec<Tensor>>,
362    ) -> Result<Vec<Tensor>> {
363        self.fedavg.aggregate_updates(client_updates)
364    }
365
366    pub fn get_global_parameters(&self) -> &[Tensor] {
367        self.fedavg.get_global_parameters()
368    }
369
370    pub fn get_current_round(&self) -> usize {
371        self.fedavg.get_current_round()
372    }
373}
374
375/// Differential privacy mechanism for federated learning.
376pub struct DifferentialPrivacy {
377    config: DifferentialPrivacyConfig,
378    rng: StdRng,
379}
380
381impl DifferentialPrivacy {
382    /// Create a new differential privacy mechanism.
383    pub fn new(config: DifferentialPrivacyConfig) -> Self {
384        Self {
385            config,
386            rng: StdRng::seed_from_u64(42),
387        }
388    }
389
390    /// Add noise to parameters for differential privacy.
391    pub fn add_noise(&mut self, parameters: &mut [Tensor]) -> Result<()> {
392        let noise_scale = self.compute_noise_scale()?;
393
394        for param in parameters.iter_mut() {
395            let noise = self.generate_noise_tensor(param, noise_scale)?;
396            *param = param.add(&noise)?;
397        }
398
399        Ok(())
400    }
401
402    fn compute_noise_scale(&self) -> Result<f32> {
403        match self.config.noise_mechanism {
404            NoiseMechanism::Gaussian => {
405                // For Gaussian mechanism: σ = sqrt(2 * ln(1.25/δ)) * Δf / ε
406                let ln_term = (1.25 / self.config.delta).ln();
407                let sigma = (2.0 * ln_term).sqrt() * self.config.sensitivity / self.config.epsilon;
408                Ok(sigma)
409            },
410            NoiseMechanism::Laplace => {
411                // For Laplace mechanism: b = Δf / ε
412                Ok(self.config.sensitivity / self.config.epsilon)
413            },
414        }
415    }
416
417    fn generate_noise_tensor(&mut self, reference: &Tensor, scale: f32) -> Result<Tensor> {
418        let shape = reference.shape();
419        let mut noise_data = Vec::new();
420
421        match self.config.noise_mechanism {
422            NoiseMechanism::Gaussian => {
423                use scirs2_core::random::{Distribution, Normal}; // SciRS2 Integration Policy
424                let normal = Normal::new(0.0, scale)
425                    .map_err(|e| anyhow!("Normal distribution error: {}", e))?;
426
427                for _ in 0..shape.iter().product::<usize>() {
428                    noise_data.push(normal.sample(&mut self.rng));
429                }
430            },
431            NoiseMechanism::Laplace => {
432                // Use exponential distribution to simulate Laplace
433                // Laplace(0, b) can be simulated as: sign * Exponential(1/b)
434                use scirs2_core::random::{Distribution, Exp}; // SciRS2 Integration Policy
435                let exp_dist = Exp::new(1.0 / scale)
436                    .map_err(|e| anyhow!("Exponential distribution error: {}", e))?;
437
438                for _ in 0..shape.iter().product::<usize>() {
439                    let sign = if self.rng.random::<bool>() { 1.0 } else { -1.0 };
440                    let exp_sample = exp_dist.sample(&mut self.rng);
441                    noise_data.push(sign * exp_sample);
442                }
443            },
444        }
445
446        Ok(Tensor::from_data(noise_data, &shape.to_vec())?)
447    }
448}
449
450/// Secure aggregation for federated learning.
451///
452/// Implements privacy-preserving aggregation where the server cannot
453/// see individual client updates, only the aggregated result.
454pub struct SecureAggregation {
455    threshold: usize,
456    total_clients: usize,
457}
458
459impl SecureAggregation {
460    /// Create a new secure aggregation instance.
461    pub fn new(threshold: usize, total_clients: usize) -> Result<Self> {
462        if threshold > total_clients {
463            return Err(anyhow!("Threshold cannot exceed total clients"));
464        }
465
466        Ok(Self {
467            threshold,
468            total_clients,
469        })
470    }
471
472    /// Deterministically derive the pairwise PRG seed two clients share for
473    /// masking round `round`. Symmetric in `client_a`/`client_b`, so both
474    /// clients independently derive the *same* seed without communicating
475    /// (each already knows both its own id and the id it's pairing with).
476    ///
477    /// Uses [`DefaultHasher`], whose algorithm the standard library does not
478    /// guarantee to be stable across Rust compiler versions -- only within a
479    /// single build. This is fine for this deterministic in-process
480    /// primitive (see [`Self::generate_masks`]'s doc comment) as long as
481    /// every participating client is running the same build; it would need
482    /// a cross-version-stable hash (e.g. a fixed-algorithm one) before
483    /// clients could be deployed from independently-built binaries.
484    fn pairwise_seed(client_a: &str, client_b: &str, round: usize) -> u64 {
485        let (lower, upper) =
486            if client_a <= client_b { (client_a, client_b) } else { (client_b, client_a) };
487        let mut hasher = DefaultHasher::new();
488        lower.hash(&mut hasher);
489        upper.hash(&mut hasher);
490        round.hash(&mut hasher);
491        hasher.finish()
492    }
493
494    /// Generate `client_id`'s pairwise-cancelling masks for `parameter_shapes`
495    /// (the caller's real model parameter shapes, in the fixed order every
496    /// client and the server agree on for this round).
497    ///
498    /// Uses the standard pairwise-masking construction for secure
499    /// aggregation (Bonawitz et al.): for every OTHER id in
500    /// `all_client_ids`, `client_id` and that client derive the same seed
501    /// (via `Self::pairwise_seed`) and therefore the same pseudorandom
502    /// values -- `client_id` adds them to its mask if it sorts before the
503    /// other id, subtracts them otherwise. Summing every participant's mask
504    /// together then cancels exactly (up to floating-point rounding): each
505    /// pairwise contribution appears once with each sign. See
506    /// [`Self::secure_aggregate`] for the aggregation side and what this
507    /// construction does and does not protect against.
508    ///
509    /// `all_client_ids` must be the exact same participant set (including
510    /// `client_id` itself) on every client's call for a given `round`, and
511    /// `parameter_shapes` must be given in the same order everywhere, or the
512    /// masks will not cancel. This does not implement dropout recovery (a
513    /// full Bonawitz-style scheme additionally secret-shares each pairwise
514    /// seed so surviving clients can reconstruct a dropped client's
515    /// contribution): if any client whose id appears in `all_client_ids`
516    /// does not actually submit a masked update to
517    /// [`Self::secure_aggregate`], the missing client's pairwise terms are
518    /// never cancelled and the aggregate is biased by exactly that client's
519    /// unpaired contribution.
520    pub fn generate_masks(
521        &self,
522        client_id: &str,
523        all_client_ids: &[String],
524        round: usize,
525        parameter_shapes: &[Vec<usize>],
526    ) -> Result<Vec<Tensor>> {
527        if !all_client_ids.iter().any(|id| id == client_id) {
528            return Err(anyhow!(
529                "client_id {client_id} is not present in all_client_ids; this client's masks \
530                 would not have matching pairwise partners to cancel against"
531            ));
532        }
533
534        let mut accumulators: Vec<Vec<f32>> = parameter_shapes
535            .iter()
536            .map(|shape| vec![0.0f32; shape.iter().product::<usize>()])
537            .collect();
538
539        for other_id in all_client_ids {
540            if other_id == client_id {
541                continue;
542            }
543            // `client_id`/`other_id` agree on the seed regardless of which
544            // one calls `generate_masks`; the sign is what makes the two
545            // sides' contributions cancel rather than duplicate.
546            let sign: f32 = if client_id < other_id.as_str() { 1.0 } else { -1.0 };
547            let mut pair_rng =
548                StdRng::seed_from_u64(Self::pairwise_seed(client_id, other_id, round));
549
550            // One RNG stream per pair, drawn across all parameters in the
551            // caller-fixed order: both sides advance it identically, so the
552            // values -- and therefore the cancellation -- line up parameter
553            // by parameter.
554            for (accumulator, shape) in accumulators.iter_mut().zip(parameter_shapes.iter()) {
555                let mask_size = shape.iter().product::<usize>();
556                for slot in accumulator.iter_mut().take(mask_size) {
557                    let value: f32 = pair_rng.random_range(-1.0..1.0);
558                    *slot += sign * value;
559                }
560            }
561        }
562
563        let mut masks = Vec::with_capacity(accumulators.len());
564        for (data, shape) in accumulators.into_iter().zip(parameter_shapes.iter()) {
565            masks.push(Tensor::from_data(data, shape)?);
566        }
567        Ok(masks)
568    }
569
570    /// Sum (and average) masked client updates without the server ever
571    /// seeing an individual client's true update.
572    ///
573    /// This assumes every masked update in `masked_updates` was produced by
574    /// [`Self::generate_masks`] with the same `all_client_ids`/`round`
575    /// (i.e. `masked_updates.keys()` matches `all_client_ids` exactly): the
576    /// pairwise masks then cancel exactly when summed (up to
577    /// floating-point rounding), leaving the true sum. If any participant
578    /// named in that `all_client_ids` set is missing from `masked_updates`
579    /// (a dropout), its pairwise terms are NOT cancelled and the result is
580    /// biased by that client's unpaired mask contribution -- this
581    /// implementation has no secret-sharing-based dropout recovery (see
582    /// [`Self::generate_masks`]'s doc comment). `threshold` only checks a
583    /// minimum client *count*; it does not verify the update set actually
584    /// matches a `generate_masks` call.
585    pub fn secure_aggregate(
586        &self,
587        masked_updates: HashMap<String, Vec<Tensor>>,
588    ) -> Result<Vec<Tensor>> {
589        if masked_updates.len() < self.threshold {
590            return Err(anyhow!("Not enough clients for secure aggregation"));
591        }
592
593        // Enhanced secure aggregation with validation and error handling
594        let mut result = Vec::new();
595        let client_count = masked_updates.len() as f32;
596
597        // Validate that all clients have the same number of parameters
598        let parameter_count =
599            masked_updates.values().next().map(|update| update.len()).unwrap_or(0);
600
601        for (client_id, update) in &masked_updates {
602            if update.len() != parameter_count {
603                return Err(anyhow!(
604                    "Client {} has {} parameters, expected {}",
605                    client_id,
606                    update.len(),
607                    parameter_count
608                ));
609            }
610        }
611
612        // Aggregate masked updates parameter by parameter
613        for param_idx in 0..parameter_count {
614            // Collect all client updates for this parameter
615            let mut parameter_updates = Vec::new();
616            let mut expected_shape: Option<Vec<usize>> = None;
617
618            for (client_id, update) in &masked_updates {
619                let param_update = &update[param_idx];
620
621                // Validate tensor shapes are consistent across clients
622                if let Some(ref shape) = expected_shape {
623                    if param_update.shape() != *shape {
624                        return Err(anyhow!(
625                            "Client {} parameter {} has shape {:?}, expected {:?}",
626                            client_id,
627                            param_idx,
628                            param_update.shape(),
629                            shape
630                        ));
631                    }
632                } else {
633                    expected_shape = Some(param_update.shape());
634                }
635
636                parameter_updates.push(param_update);
637            }
638
639            // Sum all client updates for this parameter
640            let shape = expected_shape
641                .ok_or_else(|| anyhow!("No client updates found for parameter {}", param_idx))?;
642            let mut aggregated_param = Tensor::zeros(&shape)?;
643            for param_update in parameter_updates {
644                aggregated_param = aggregated_param.add(param_update)?;
645            }
646
647            // Average the aggregated parameter. With pairwise masks from
648            // `generate_masks` and no dropouts, the mask terms cancelled out
649            // during the summation above (see this function's doc comment),
650            // so this recovers the true average without the server ever
651            // seeing an individual client's true update.
652            result.push(aggregated_param.div_scalar(client_count)?);
653        }
654
655        Ok(result)
656    }
657}
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662
663    #[test]
664    fn test_fedavg_config_default() {
665        let config = FedAvgConfig::default();
666        assert_eq!(config.local_epochs, 5);
667        assert_eq!(config.client_fraction, 0.1);
668        assert_eq!(config.min_clients, 2);
669    }
670
671    #[test]
672    fn test_fedprox_config_default() {
673        let config = FedProxConfig::default();
674        assert_eq!(config.mu, 0.01);
675        assert_eq!(config.fedavg_config.local_epochs, 5);
676    }
677
678    #[test]
679    fn test_differential_privacy_config() {
680        let config = DifferentialPrivacyConfig::default();
681        assert_eq!(config.epsilon, 1.0);
682        assert_eq!(config.delta, 1e-5);
683        assert!(matches!(config.noise_mechanism, NoiseMechanism::Gaussian));
684    }
685
686    #[test]
687    fn test_client_selection_strategies() {
688        let clients = vec![
689            ClientInfo {
690                client_id: "client1".to_string(),
691                data_size: 100,
692                compute_capacity: 0.8,
693                communication_quality: 0.9,
694                available: true,
695            },
696            ClientInfo {
697                client_id: "client2".to_string(),
698                data_size: 200,
699                compute_capacity: 0.6,
700                communication_quality: 0.7,
701                available: true,
702            },
703        ];
704
705        let mut fedavg = FedAvg::new(FedAvgConfig::default());
706
707        // Test random selection
708        let selected = fedavg
709            .select_clients(&clients, ClientSelectionStrategy::Random)
710            .expect("Operation failed in test");
711        assert!(!selected.is_empty());
712
713        // Test data size selection
714        let selected = fedavg
715            .select_clients(&clients, ClientSelectionStrategy::DataSize)
716            .expect("Operation failed in test");
717        assert!(!selected.is_empty());
718    }
719
720    #[test]
721    fn test_secure_aggregation_creation() {
722        let secure_agg = SecureAggregation::new(3, 5).expect("Construction failed");
723        assert_eq!(secure_agg.threshold, 3);
724        assert_eq!(secure_agg.total_clients, 5);
725
726        // Should fail if threshold > total clients
727        assert!(SecureAggregation::new(6, 5).is_err());
728    }
729
730    /// Regression: masks used to always be built for the hardcoded shapes
731    /// `[100,50]`/`[50]`/`[50,20]`/`[20]`, unrelated to any caller's model.
732    #[test]
733    fn test_generate_masks_uses_the_callers_shapes_not_hardcoded_ones() {
734        let secure_agg = SecureAggregation::new(2, 2).expect("Construction failed");
735        let all_clients = vec!["alice".to_string(), "bob".to_string()];
736        // Deliberately NOT the old hardcoded [100,50]/[50]/[50,20]/[20].
737        let shapes = vec![vec![3], vec![2, 2], vec![5, 1, 2]];
738
739        let masks = secure_agg
740            .generate_masks("alice", &all_clients, 0, &shapes)
741            .expect("generate_masks failed");
742
743        assert_eq!(masks.len(), shapes.len());
744        for (mask, expected_shape) in masks.iter().zip(shapes.iter()) {
745            assert_eq!(&mask.shape(), expected_shape);
746        }
747    }
748
749    #[test]
750    fn test_generate_masks_is_deterministic_for_the_same_inputs() {
751        let secure_agg = SecureAggregation::new(2, 3).expect("Construction failed");
752        let all_clients = vec!["alice".to_string(), "bob".to_string(), "carol".to_string()];
753        let shapes = vec![vec![4], vec![3, 2]];
754
755        let first = secure_agg
756            .generate_masks("bob", &all_clients, 7, &shapes)
757            .expect("generate_masks failed");
758        let second = secure_agg
759            .generate_masks("bob", &all_clients, 7, &shapes)
760            .expect("generate_masks failed");
761
762        for (a, b) in first.iter().zip(second.iter()) {
763            assert_eq!(
764                a.to_vec_f32().expect("read"),
765                b.to_vec_f32().expect("read"),
766                "the same client/round/shapes must derive the same masks every time"
767            );
768        }
769    }
770
771    #[test]
772    fn test_generate_masks_rejects_a_client_id_missing_from_all_client_ids() {
773        let secure_agg = SecureAggregation::new(2, 2).expect("Construction failed");
774        let all_clients = vec!["alice".to_string(), "bob".to_string()];
775        let shapes = vec![vec![2]];
776
777        assert!(secure_agg.generate_masks("carol", &all_clients, 0, &shapes).is_err());
778    }
779
780    /// With exactly two clients, each client has exactly one pairwise
781    /// partner, so its mask IS that single pairwise term (no summation
782    /// across multiple pairs) -- the two clients' masks must be exact
783    /// (bit-for-bit) negatives of each other.
784    #[test]
785    fn test_masks_cancel_exactly_between_two_clients() {
786        let secure_agg = SecureAggregation::new(2, 2).expect("Construction failed");
787        let all_clients = vec!["alice".to_string(), "bob".to_string()];
788        let shapes = vec![vec![6], vec![3, 2]];
789
790        let alice_masks = secure_agg
791            .generate_masks("alice", &all_clients, 3, &shapes)
792            .expect("generate_masks failed");
793        let bob_masks = secure_agg
794            .generate_masks("bob", &all_clients, 3, &shapes)
795            .expect("generate_masks failed");
796
797        for (alice_mask, bob_mask) in alice_masks.iter().zip(bob_masks.iter()) {
798            let a = alice_mask.to_vec_f32().expect("read");
799            let b = bob_mask.to_vec_f32().expect("read");
800            assert_eq!(a.len(), b.len());
801            for (av, bv) in a.iter().zip(b.iter()) {
802                assert_eq!(
803                    *av, -*bv,
804                    "alice's and bob's pairwise mask values must be exact negatives"
805                );
806            }
807        }
808    }
809
810    /// Regression: independently-seeded (non-pairwise) masks did not cancel
811    /// -- their sum carried the masks' own mean as bias despite the doc
812    /// comment's claim. Pairwise masks must make `secure_aggregate` recover
813    /// the true average of the clients' real updates, to within
814    /// floating-point rounding.
815    #[test]
816    fn test_secure_aggregate_of_pairwise_masked_updates_recovers_true_average() {
817        let secure_agg = SecureAggregation::new(2, 4).expect("Construction failed");
818        let client_ids: Vec<String> = ["client-0", "client-1", "client-2", "client-3"]
819            .iter()
820            .map(|s| s.to_string())
821            .collect();
822        let shapes = vec![vec![4], vec![2, 3]];
823        let round = 11;
824
825        // Real per-client "true" updates: distinct values per client and
826        // per parameter so a broken aggregation could not accidentally
827        // match by symmetry.
828        let true_updates: HashMap<String, Vec<Tensor>> = client_ids
829            .iter()
830            .enumerate()
831            .map(|(client_index, id)| {
832                let updates = shapes
833                    .iter()
834                    .map(|shape| {
835                        let size = shape.iter().product::<usize>();
836                        let data: Vec<f32> =
837                            (0..size).map(|i| (client_index * 10 + i) as f32 * 0.1).collect();
838                        Tensor::from_data(data, shape).expect("tensor must build in test")
839                    })
840                    .collect();
841                (id.clone(), updates)
842            })
843            .collect();
844
845        let masked_updates: HashMap<String, Vec<Tensor>> = client_ids
846            .iter()
847            .map(|id| {
848                let masks = secure_agg
849                    .generate_masks(id, &client_ids, round, &shapes)
850                    .expect("generate_masks failed");
851                let true_update = &true_updates[id];
852                let masked: Vec<Tensor> = true_update
853                    .iter()
854                    .zip(masks.iter())
855                    .map(|(update, mask)| update.add(mask).expect("tensor add failed in test"))
856                    .collect();
857                (id.clone(), masked)
858            })
859            .collect();
860
861        let aggregated =
862            secure_agg.secure_aggregate(masked_updates).expect("secure_aggregate failed");
863
864        for (param_idx, shape) in shapes.iter().enumerate() {
865            let size = shape.iter().product::<usize>();
866            let mut expected_sum = vec![0.0f32; size];
867            for (client_index, _) in client_ids.iter().enumerate() {
868                for (slot, value) in expected_sum.iter_mut().enumerate() {
869                    *value += (client_index * 10 + slot) as f32 * 0.1;
870                }
871            }
872            let expected_average: Vec<f32> =
873                expected_sum.iter().map(|v| v / client_ids.len() as f32).collect();
874
875            let actual = aggregated[param_idx].to_vec_f32().expect("read");
876            for (actual_value, expected_value) in actual.iter().zip(expected_average.iter()) {
877                assert!(
878                    (actual_value - expected_value).abs() < 1e-3,
879                    "pairwise masks must cancel to within floating-point rounding: expected \
880                     {expected_value}, got {actual_value}"
881                );
882            }
883        }
884    }
885}