Skip to main content

quantrs2_ml/
federated.rs

1//! Quantum federated learning protocols for distributed quantum machine learning.
2//!
3//! This module implements privacy-preserving distributed training of quantum models
4//! with secure aggregation and differential privacy guarantees.
5
6use scirs2_core::ndarray::{Array1, Array2, Array3};
7use scirs2_core::Complex64;
8use std::collections::HashMap;
9use std::f64::consts::PI;
10
11use crate::error::{MLError, Result};
12use crate::qnn::QuantumNeuralNetwork;
13use crate::utils::VariationalCircuit;
14use quantrs2_circuit::prelude::*;
15use quantrs2_core::gate::{multi::*, single::*, GateOp};
16
17/// Federated learning client for quantum models
18#[derive(Debug)]
19pub struct QuantumFLClient {
20    /// Client ID
21    client_id: String,
22    /// Local quantum model
23    local_model: QuantumNeuralNetwork,
24    /// Local dataset size
25    dataset_size: usize,
26    /// Privacy budget
27    epsilon: f64,
28    /// Noise scale for differential privacy
29    noise_scale: f64,
30    /// Client-specific parameters
31    local_params: HashMap<String, f64>,
32}
33
34impl QuantumFLClient {
35    /// Create a new federated learning client
36    pub fn new(
37        client_id: String,
38        model_config: &[(String, usize)], // Layer configs
39        dataset_size: usize,
40        epsilon: f64,
41    ) -> Result<Self> {
42        // Create local model based on config
43        let layers = model_config
44            .iter()
45            .map(|(layer_type, size)| match layer_type.as_str() {
46                "encoding" => crate::qnn::QNNLayerType::EncodingLayer {
47                    num_features: *size,
48                },
49                "variational" => crate::qnn::QNNLayerType::VariationalLayer { num_params: *size },
50                "entanglement" => crate::qnn::QNNLayerType::EntanglementLayer {
51                    connectivity: "full".to_string(),
52                },
53                _ => crate::qnn::QNNLayerType::MeasurementLayer {
54                    measurement_basis: "computational".to_string(),
55                },
56            })
57            .collect();
58
59        let local_model = QuantumNeuralNetwork::new(layers, 4, 10, 2)?;
60        let noise_scale = (2.0 * (1.25 / epsilon).ln()).sqrt() / dataset_size as f64;
61
62        // Seed the client-visible parameter map from the real QNN weights so
63        // that aggregation/serialization operate on the actual model instead
64        // of an always-empty placeholder map.
65        let local_params = local_model
66            .parameters
67            .iter()
68            .enumerate()
69            .map(|(i, &v)| (Self::param_key(i), v))
70            .collect();
71
72        Ok(Self {
73            client_id,
74            local_model,
75            dataset_size,
76            epsilon,
77            noise_scale,
78            local_params,
79        })
80    }
81
82    /// Canonical key used to expose a given QNN parameter index in the
83    /// `local_params` map (and in `get_parameters`/`set_parameters`).
84    fn param_key(index: usize) -> String {
85        format!("param_{index}")
86    }
87
88    /// Overwrite `local_params` with the current `local_model.parameters`,
89    /// keeping the two representations in sync after a gradient step.
90    fn sync_local_params_from_model(&mut self) {
91        for (i, &v) in self.local_model.parameters.iter().enumerate() {
92            self.local_params.insert(Self::param_key(i), v);
93        }
94    }
95
96    /// Write `local_params` back into `local_model.parameters`, used after
97    /// receiving aggregated parameters from the server or after adding
98    /// differential-privacy noise.
99    fn sync_model_from_local_params(&mut self) {
100        let num_params = self.local_model.parameters.len();
101        for i in 0..num_params {
102            if let Some(&v) = self.local_params.get(&Self::param_key(i)) {
103                self.local_model.parameters[i] = v;
104            }
105        }
106    }
107
108    /// Train on local data
109    pub fn train_local(
110        &mut self,
111        local_data: &Array2<f64>,
112        local_labels: &Array1<i32>,
113        epochs: usize,
114    ) -> Result<f64> {
115        let mut total_loss = 0.0;
116
117        for _ in 0..epochs {
118            // Simplified training loop
119            for i in 0..local_data.nrows() {
120                let input = local_data.row(i).to_owned();
121                let label = local_labels[i];
122
123                // Forward pass
124                let output = self.local_model.forward(&input)?;
125
126                // Compute loss
127                let loss = self.compute_loss(&output, label)?;
128                total_loss += loss;
129
130                // Backward pass: real parameter-shift gradient of the
131                // cross-entropy loss, written back into local_model.parameters.
132                self.update_parameters(&input, label, 0.01, &output)?;
133            }
134        }
135
136        // Add differential privacy noise
137        self.add_dp_noise()?;
138
139        Ok(total_loss / (epochs * local_data.nrows()) as f64)
140    }
141
142    /// Class probabilities for a set of QNN outputs, via a numerically stable softmax.
143    ///
144    /// `QuantumNeuralNetwork::forward` returns Pauli expectation values in `[-1, 1]`, not a
145    /// probability distribution. Feeding those straight into `-ln(p)` yields `NaN` for every
146    /// negative expectation value (and `+inf` at zero), which is what made `train_local`
147    /// report a non-finite loss. Shifting by the maximum before exponentiating keeps the
148    /// result finite for any real input, so each probability lies strictly in `(0, 1)`.
149    fn class_probabilities(output: &Array1<f64>) -> Array1<f64> {
150        let max_output = output.iter().copied().fold(f64::NEG_INFINITY, f64::max);
151        let mut probabilities = output.mapv(|value| (value - max_output).exp());
152        let total: f64 = probabilities.sum();
153        if total > 0.0 {
154            probabilities /= total;
155        }
156        probabilities
157    }
158
159    /// Compute loss function
160    fn compute_loss(&self, output: &Array1<f64>, label: i32) -> Result<f64> {
161        // Cross-entropy loss for classification
162        let label_idx = label as usize;
163        if label_idx >= output.len() {
164            return Err(MLError::InvalidInput("Label out of bounds".to_string()));
165        }
166
167        let probabilities = Self::class_probabilities(output);
168        Ok(-probabilities[label_idx].ln())
169    }
170
171    /// Real gradient step on the local QNN's weights.
172    ///
173    /// Computes `d(cross_entropy_loss)/d(theta_j)` for every trainable
174    /// parameter `theta_j` via the exact parameter-shift rule
175    /// (`QuantumNeuralNetwork::output_component_gradient`) applied to the
176    /// output component that the cross-entropy loss depends on, then takes a
177    /// plain gradient-descent step. The updated weights are written directly
178    /// into `self.local_model.parameters`, and `local_params` is kept in
179    /// sync so that `get_parameters()`/aggregation see the real weights.
180    fn update_parameters(
181        &mut self,
182        input: &Array1<f64>,
183        label: i32,
184        learning_rate: f64,
185        output: &Array1<f64>,
186    ) -> Result<()> {
187        let label_idx = label as usize;
188        if label_idx >= output.len() {
189            return Err(MLError::InvalidInput("Label out of bounds".to_string()));
190        }
191
192        // For the softmax cross-entropy of `Self::compute_loss`,
193        // d(loss)/d(output[k]) = p_k - [k == label], so every output component contributes
194        // to the parameter gradient — not just the labelled one.
195        let probabilities = Self::class_probabilities(output);
196        let num_params = self.local_model.parameters.len();
197        let mut gradient = vec![0.0; num_params];
198
199        for class in 0..output.len() {
200            let d_loss_d_output = probabilities[class] - if class == label_idx { 1.0 } else { 0.0 };
201            if d_loss_d_output == 0.0 {
202                continue;
203            }
204            // d(output[class])/d(theta_j) for every parameter via parameter shift.
205            let d_output_d_params = self.local_model.output_component_gradient(input, class)?;
206            for j in 0..num_params {
207                gradient[j] += d_loss_d_output * d_output_d_params[j];
208            }
209        }
210
211        for j in 0..num_params {
212            self.local_model.parameters[j] -= learning_rate * gradient[j];
213        }
214
215        self.sync_local_params_from_model();
216        Ok(())
217    }
218
219    /// Add differential privacy noise, applied both to the exposed parameter
220    /// map and back into the underlying QNN weights so that subsequent local
221    /// forward passes actually see the noised parameters.
222    fn add_dp_noise(&mut self) -> Result<()> {
223        for (_, value) in self.local_params.iter_mut() {
224            // Add Gaussian noise scaled by sensitivity and epsilon
225            let noise = self.noise_scale * Self::gaussian_noise();
226            *value += noise;
227        }
228        self.sync_model_from_local_params();
229        Ok(())
230    }
231
232    /// Generate Gaussian noise
233    fn gaussian_noise() -> f64 {
234        // Box-Muller transform
235        let u1 = fastrand::f64();
236        let u2 = fastrand::f64();
237        (-2.0 * u1.ln()).sqrt() * (2.0 * PI * u2).cos()
238    }
239
240    /// Get model parameters for aggregation
241    pub fn get_parameters(&self) -> HashMap<String, f64> {
242        self.local_params.clone()
243    }
244
245    /// Update model with aggregated parameters
246    pub fn set_parameters(&mut self, params: HashMap<String, f64>) {
247        self.local_params = params;
248        self.sync_model_from_local_params();
249    }
250}
251
252/// Quantum secure aggregation server
253#[derive(Debug)]
254pub struct QuantumFLServer {
255    /// Global model configuration
256    model_config: Vec<(String, usize)>,
257    /// Aggregated parameters
258    global_params: HashMap<String, f64>,
259    /// Client weights for aggregation
260    client_weights: HashMap<String, f64>,
261    /// Secure aggregation protocol
262    aggregation_protocol: SecureAggregationProtocol,
263    /// Byzantine fault tolerance threshold
264    byzantine_threshold: f64,
265}
266
267#[derive(Debug, Clone)]
268pub enum SecureAggregationProtocol {
269    /// Simple averaging
270    FederatedAveraging,
271    /// Secure multi-party computation
272    SecureMultiparty,
273    /// Homomorphic encryption
274    HomomorphicEncryption,
275    /// Quantum secret sharing
276    QuantumSecretSharing,
277}
278
279impl QuantumFLServer {
280    /// Create a new federated learning server
281    pub fn new(
282        model_config: Vec<(String, usize)>,
283        aggregation_protocol: SecureAggregationProtocol,
284        byzantine_threshold: f64,
285    ) -> Self {
286        Self {
287            model_config,
288            global_params: HashMap::new(),
289            client_weights: HashMap::new(),
290            aggregation_protocol,
291            byzantine_threshold,
292        }
293    }
294
295    /// Aggregate client updates
296    pub fn aggregate_updates(
297        &mut self,
298        client_updates: Vec<(String, HashMap<String, f64>, usize)>, // (client_id, params, dataset_size)
299    ) -> Result<HashMap<String, f64>> {
300        match self.aggregation_protocol {
301            SecureAggregationProtocol::FederatedAveraging => {
302                self.federated_averaging(client_updates)
303            }
304            SecureAggregationProtocol::SecureMultiparty => {
305                self.secure_multiparty_aggregation(client_updates)
306            }
307            SecureAggregationProtocol::HomomorphicEncryption => {
308                self.homomorphic_aggregation(client_updates)
309            }
310            SecureAggregationProtocol::QuantumSecretSharing => {
311                self.quantum_secret_sharing_aggregation(client_updates)
312            }
313        }
314    }
315
316    /// Federated averaging aggregation
317    fn federated_averaging(
318        &mut self,
319        client_updates: Vec<(String, HashMap<String, f64>, usize)>,
320    ) -> Result<HashMap<String, f64>> {
321        let total_samples: usize = client_updates.iter().map(|(_, _, size)| size).sum();
322        let mut aggregated = HashMap::new();
323
324        // Weight by dataset size
325        for (client_id, params, dataset_size) in client_updates {
326            let weight = dataset_size as f64 / total_samples as f64;
327            self.client_weights.insert(client_id.clone(), weight);
328
329            for (param_name, param_value) in params {
330                *aggregated.entry(param_name).or_insert(0.0) += weight * param_value;
331            }
332        }
333
334        self.global_params = aggregated.clone();
335        Ok(aggregated)
336    }
337
338    /// Secure multi-party computation aggregation
339    fn secure_multiparty_aggregation(
340        &mut self,
341        client_updates: Vec<(String, HashMap<String, f64>, usize)>,
342    ) -> Result<HashMap<String, f64>> {
343        // Implement secure aggregation using secret sharing
344        let num_clients = client_updates.len();
345        let mut shares: HashMap<String, Vec<f64>> = HashMap::new();
346
347        // Collect shares for each parameter
348        for (_, params, _) in &client_updates {
349            for (param_name, param_value) in params {
350                shares
351                    .entry(param_name.clone())
352                    .or_insert(Vec::new())
353                    .push(*param_value);
354            }
355        }
356
357        // Aggregate shares with Byzantine fault tolerance
358        let mut aggregated = HashMap::new();
359        for (param_name, param_shares) in shares {
360            let aggregated_value = self.byzantine_robust_aggregation(&param_shares)?;
361            aggregated.insert(param_name, aggregated_value);
362        }
363
364        self.global_params = aggregated.clone();
365        Ok(aggregated)
366    }
367
368    /// Homomorphic encryption aggregation
369    fn homomorphic_aggregation(
370        &mut self,
371        client_updates: Vec<(String, HashMap<String, f64>, usize)>,
372    ) -> Result<HashMap<String, f64>> {
373        // Simplified homomorphic aggregation
374        // In practice, would use actual homomorphic encryption
375
376        let mut encrypted_sum = HashMap::new();
377
378        for (_, params, _) in &client_updates {
379            for (param_name, param_value) in params {
380                // "Encrypt" (simplified)
381                let encrypted = self.homomorphic_encrypt(*param_value)?;
382
383                // Add encrypted values
384                *encrypted_sum.entry(param_name.clone()).or_insert(0.0) += encrypted;
385            }
386        }
387
388        // "Decrypt" aggregated values
389        let mut aggregated = HashMap::new();
390        for (param_name, encrypted_value) in encrypted_sum {
391            let decrypted = self.homomorphic_decrypt(encrypted_value)?;
392            aggregated.insert(param_name, decrypted / client_updates.len() as f64);
393        }
394
395        self.global_params = aggregated.clone();
396        Ok(aggregated)
397    }
398
399    /// Quantum secret sharing aggregation
400    fn quantum_secret_sharing_aggregation(
401        &mut self,
402        client_updates: Vec<(String, HashMap<String, f64>, usize)>,
403    ) -> Result<HashMap<String, f64>> {
404        let num_clients = client_updates.len();
405        let threshold = ((num_clients as f64) * self.byzantine_threshold).ceil() as usize;
406
407        // Create quantum shares
408        let mut quantum_shares: HashMap<String, Vec<QuantumShare>> = HashMap::new();
409
410        for (client_id, params, _) in &client_updates {
411            for (param_name, param_value) in params {
412                let share = self.create_quantum_share(client_id, *param_value)?;
413                quantum_shares
414                    .entry(param_name.clone())
415                    .or_insert(Vec::new())
416                    .push(share);
417            }
418        }
419
420        // Reconstruct from shares
421        let mut aggregated = HashMap::new();
422        for (param_name, shares) in quantum_shares {
423            if shares.len() >= threshold {
424                let reconstructed = self.reconstruct_from_quantum_shares(&shares)?;
425                aggregated.insert(param_name, reconstructed);
426            }
427        }
428
429        self.global_params = aggregated.clone();
430        Ok(aggregated)
431    }
432
433    /// Byzantine-robust aggregation
434    fn byzantine_robust_aggregation(&self, values: &[f64]) -> Result<f64> {
435        if values.is_empty() {
436            return Err(MLError::InvalidInput("No values to aggregate".to_string()));
437        }
438
439        // Krum algorithm for Byzantine robustness
440        let n = values.len();
441        let f = ((n as f64 * self.byzantine_threshold) as usize).min(n / 2);
442
443        // Compute pairwise distances
444        let mut scores = vec![0.0; n];
445        for i in 0..n {
446            let mut distances: Vec<f64> = (0..n)
447                .filter(|&j| j != i)
448                .map(|j| (values[i] - values[j]).abs())
449                .collect();
450            distances.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
451
452            // Sum of n-f-1 closest values
453            scores[i] = distances.iter().take(n - f - 1).sum();
454        }
455
456        // Select value with minimum score
457        let best_idx = scores
458            .iter()
459            .enumerate()
460            .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
461            .map(|(idx, _)| idx)
462            .unwrap_or(0);
463
464        Ok(values[best_idx])
465    }
466
467    /// Simple homomorphic encryption (placeholder)
468    fn homomorphic_encrypt(&self, value: f64) -> Result<f64> {
469        // In practice, use proper homomorphic encryption
470        Ok(value * 1000.0 + fastrand::f64() * 10.0)
471    }
472
473    /// Simple homomorphic decryption (placeholder)
474    fn homomorphic_decrypt(&self, encrypted: f64) -> Result<f64> {
475        // In practice, use proper homomorphic decryption
476        Ok((encrypted - 5.0) / 1000.0)
477    }
478
479    /// Create quantum share
480    fn create_quantum_share(&self, client_id: &str, value: f64) -> Result<QuantumShare> {
481        let num_qubits = 3;
482        let mut circuit = VariationalCircuit::new(num_qubits);
483
484        // Encode value in quantum state
485        circuit.add_gate("RY", vec![0], vec![(value * PI).to_string()]);
486
487        // Create entangled shares
488        circuit.add_gate("H", vec![1], vec![]);
489        circuit.add_gate("CNOT", vec![1, 2], vec![]);
490        circuit.add_gate("CNOT", vec![0, 1], vec![]);
491
492        Ok(QuantumShare {
493            client_id: client_id.to_string(),
494            share_circuit: circuit,
495            share_value: value,
496        })
497    }
498
499    /// Reconstruct from quantum shares
500    fn reconstruct_from_quantum_shares(&self, shares: &[QuantumShare]) -> Result<f64> {
501        // Simplified reconstruction
502        // In practice, would perform quantum state tomography
503        let sum: f64 = shares.iter().map(|s| s.share_value).sum();
504        Ok(sum / shares.len() as f64)
505    }
506}
507
508/// Quantum share for secret sharing
509#[derive(Debug)]
510struct QuantumShare {
511    client_id: String,
512    share_circuit: VariationalCircuit,
513    share_value: f64,
514}
515
516/// Distributed quantum learning coordinator
517#[derive(Debug)]
518pub struct DistributedQuantumLearning {
519    /// Server instance
520    server: QuantumFLServer,
521    /// Client instances
522    clients: HashMap<String, QuantumFLClient>,
523    /// Communication rounds
524    rounds: usize,
525    /// Convergence threshold
526    convergence_threshold: f64,
527}
528
529impl DistributedQuantumLearning {
530    /// Create a new distributed learning system
531    pub fn new(
532        num_clients: usize,
533        model_config: Vec<(String, usize)>,
534        aggregation_protocol: SecureAggregationProtocol,
535        epsilon: f64,
536    ) -> Result<Self> {
537        let server = QuantumFLServer::new(
538            model_config.clone(),
539            aggregation_protocol,
540            0.2, // Byzantine threshold
541        );
542
543        let mut clients = HashMap::new();
544        for i in 0..num_clients {
545            let client_id = format!("client_{}", i);
546            let dataset_size = 100 + fastrand::usize(..900); // Random dataset size
547            let client =
548                QuantumFLClient::new(client_id.clone(), &model_config, dataset_size, epsilon)?;
549            clients.insert(client_id, client);
550        }
551
552        Ok(Self {
553            server,
554            clients,
555            rounds: 0,
556            convergence_threshold: 1e-4,
557        })
558    }
559
560    /// Run federated training
561    pub fn train(
562        &mut self,
563        data_distribution: &HashMap<String, (Array2<f64>, Array1<i32>)>,
564        num_rounds: usize,
565        clients_per_round: usize,
566    ) -> Result<FederatedTrainingResult> {
567        let mut round_losses = Vec::new();
568        let mut convergence_metric = f64::INFINITY;
569
570        for round in 0..num_rounds {
571            self.rounds = round + 1;
572
573            // Select random subset of clients
574            let selected_clients = self.select_clients(clients_per_round);
575
576            // Local training
577            let mut client_updates = Vec::new();
578            let mut round_loss = 0.0;
579
580            for client_id in selected_clients {
581                if let Some(client) = self.clients.get_mut(&client_id) {
582                    if let Some((data, labels)) = data_distribution.get(&client_id) {
583                        // Train locally
584                        let loss = client.train_local(data, labels, 5)?;
585                        round_loss += loss;
586
587                        // Get parameters
588                        let params = client.get_parameters();
589                        let dataset_size = data.nrows();
590                        client_updates.push((client_id.clone(), params, dataset_size));
591                    }
592                }
593            }
594
595            // Aggregate updates
596            let aggregated = self.server.aggregate_updates(client_updates)?;
597
598            // Update all clients with aggregated model
599            for (_, client) in self.clients.iter_mut() {
600                client.set_parameters(aggregated.clone());
601            }
602
603            // Check convergence (skip on first round)
604            if round > 0 {
605                let prev_params = self.server.global_params.clone();
606                convergence_metric = self.compute_convergence(&prev_params, &aggregated)?;
607
608                if convergence_metric < self.convergence_threshold {
609                    round_losses.push(round_loss / clients_per_round as f64);
610                    break;
611                }
612            }
613
614            round_losses.push(round_loss / clients_per_round as f64);
615
616            // Update server's global params
617            self.server.global_params = aggregated.clone();
618        }
619
620        Ok(FederatedTrainingResult {
621            final_model_params: self.server.global_params.clone(),
622            round_losses,
623            num_rounds: self.rounds,
624            converged: convergence_metric < self.convergence_threshold,
625            convergence_metric,
626        })
627    }
628
629    /// Select random clients for training round
630    fn select_clients(&self, num_clients: usize) -> Vec<String> {
631        let all_clients: Vec<String> = self.clients.keys().cloned().collect();
632        let mut selected = Vec::new();
633
634        while selected.len() < num_clients.min(all_clients.len()) {
635            let idx = fastrand::usize(..all_clients.len());
636            let client = all_clients[idx].clone();
637            if !selected.contains(&client) {
638                selected.push(client);
639            }
640        }
641
642        selected
643    }
644
645    /// Compute convergence metric
646    fn compute_convergence(
647        &self,
648        old_params: &HashMap<String, f64>,
649        new_params: &HashMap<String, f64>,
650    ) -> Result<f64> {
651        let mut diff_sum = 0.0;
652        let mut count = 0;
653
654        for (key, new_val) in new_params {
655            if let Some(old_val) = old_params.get(key) {
656                diff_sum += (new_val - old_val).abs();
657                count += 1;
658            }
659        }
660
661        Ok(if count > 0 {
662            diff_sum / count as f64
663        } else {
664            0.0
665        })
666    }
667}
668
669/// Result of federated training
670#[derive(Debug)]
671pub struct FederatedTrainingResult {
672    /// Final aggregated model parameters
673    pub final_model_params: HashMap<String, f64>,
674    /// Loss history per round
675    pub round_losses: Vec<f64>,
676    /// Number of rounds completed
677    pub num_rounds: usize,
678    /// Whether training converged
679    pub converged: bool,
680    /// Final convergence metric
681    pub convergence_metric: f64,
682}
683
684/// Privacy-preserving quantum computation
685pub mod privacy {
686    use super::*;
687
688    /// Differential privacy mechanism for quantum circuits
689    #[derive(Debug)]
690    pub struct QuantumDifferentialPrivacy {
691        /// Privacy budget
692        epsilon: f64,
693        /// Sensitivity bound
694        sensitivity: f64,
695        /// Noise mechanism
696        mechanism: NoiseType,
697    }
698
699    #[derive(Debug, Clone)]
700    pub enum NoiseType {
701        Laplace,
702        Gaussian,
703        Quantum,
704    }
705
706    impl QuantumDifferentialPrivacy {
707        /// Create new DP mechanism
708        pub fn new(epsilon: f64, sensitivity: f64, mechanism: NoiseType) -> Self {
709            Self {
710                epsilon,
711                sensitivity,
712                mechanism,
713            }
714        }
715
716        /// Add noise to quantum circuit parameters
717        pub fn add_noise(&self, params: &mut HashMap<String, f64>) -> Result<()> {
718            for (_, value) in params.iter_mut() {
719                let noise = match self.mechanism {
720                    NoiseType::Laplace => self.laplace_noise(),
721                    NoiseType::Gaussian => self.gaussian_noise(),
722                    NoiseType::Quantum => self.quantum_noise()?,
723                };
724                *value += noise;
725            }
726            Ok(())
727        }
728
729        /// Laplace noise
730        fn laplace_noise(&self) -> f64 {
731            let scale = self.sensitivity / self.epsilon;
732            let u = fastrand::f64() - 0.5;
733            -scale * u.signum() * (1.0 - 2.0 * u.abs()).ln()
734        }
735
736        /// Gaussian noise
737        fn gaussian_noise(&self) -> f64 {
738            let scale = self.sensitivity * (2.0 * (1.25 / self.epsilon).ln()).sqrt();
739            QuantumFLClient::gaussian_noise() * scale
740        }
741
742        /// Quantum noise
743        fn quantum_noise(&self) -> Result<f64> {
744            // Implement quantum noise using depolarizing channel
745            let p = (-self.epsilon).exp();
746            Ok(if fastrand::f64() < p {
747                fastrand::f64() * 2.0 - 1.0
748            } else {
749                0.0
750            })
751        }
752    }
753}
754
755#[cfg(test)]
756mod tests {
757    use super::*;
758    use scirs2_core::ndarray::array;
759
760    #[test]
761    fn test_quantum_fl_client_real_parameter_update() {
762        // Regression test: train_local() must actually move the underlying
763        // QNN weights via a real gradient step, and get_parameters() must
764        // reflect the same (non-empty, non-placeholder) values instead of an
765        // always-empty disconnected map.
766        let config = vec![
767            ("encoding".to_string(), 4),
768            ("variational".to_string(), 8),
769            ("measurement".to_string(), 0),
770        ];
771
772        let mut client = QuantumFLClient::new("client_1".to_string(), &config, 100, 1.0)
773            .expect("Failed to create client");
774
775        // get_parameters() must be populated at construction time, mirroring
776        // the real QNN weight count (8 variational parameters).
777        let initial_params = client.get_parameters();
778        assert_eq!(initial_params.len(), 8);
779
780        let initial_model_params = client.local_model.parameters.clone();
781
782        let data = array![[0.1, 0.2, 0.3, 0.4], [0.5, 0.6, 0.7, 0.8]];
783        let labels = array![0, 1];
784
785        client
786            .train_local(&data, &labels, 1)
787            .expect("Training failed");
788
789        // The real QNN weights must have moved (gradient step + DP noise),
790        // not stayed frozen because of an empty parameter map.
791        let moved = client
792            .local_model
793            .parameters
794            .iter()
795            .zip(initial_model_params.iter())
796            .any(|(&after, &before)| (after - before).abs() > 1e-9);
797        assert!(
798            moved,
799            "local_model.parameters did not change after train_local()"
800        );
801
802        // get_parameters() must track the real model weights (not the stale
803        // empty/placeholder map), so it should now differ from the values it
804        // had immediately after construction.
805        let updated_params = client.get_parameters();
806        assert_eq!(updated_params.len(), 8);
807        let params_changed = (0..8).any(|i| {
808            let key = format!("param_{i}");
809            (updated_params[&key] - initial_params[&key]).abs() > 1e-9
810        });
811        assert!(
812            params_changed,
813            "get_parameters() did not reflect the real gradient update"
814        );
815
816        // get_parameters() must always mirror local_model.parameters exactly.
817        for (i, &model_val) in client.local_model.parameters.iter().enumerate() {
818            let key = format!("param_{i}");
819            assert!((updated_params[&key] - model_val).abs() < 1e-12);
820        }
821    }
822
823    #[test]
824    fn test_quantum_fl_client() {
825        let config = vec![
826            ("encoding".to_string(), 4),
827            ("variational".to_string(), 8),
828            ("measurement".to_string(), 0),
829        ];
830
831        let mut client = QuantumFLClient::new("client_1".to_string(), &config, 100, 1.0)
832            .expect("Failed to create client");
833
834        let data = array![[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]];
835        let labels = array![0, 1, 0];
836
837        let loss = client
838            .train_local(&data, &labels, 1)
839            .expect("Training failed");
840        assert!(loss >= 0.0);
841    }
842
843    #[test]
844    fn test_federated_averaging() {
845        let config = vec![("encoding".to_string(), 4)];
846        let mut server =
847            QuantumFLServer::new(config, SecureAggregationProtocol::FederatedAveraging, 0.2);
848
849        let mut params1 = HashMap::new();
850        params1.insert("w1".to_string(), 0.5);
851        params1.insert("w2".to_string(), 0.3);
852
853        let mut params2 = HashMap::new();
854        params2.insert("w1".to_string(), 0.7);
855        params2.insert("w2".to_string(), 0.4);
856
857        let updates = vec![
858            ("client1".to_string(), params1, 100),
859            ("client2".to_string(), params2, 200),
860        ];
861
862        let aggregated = server
863            .aggregate_updates(updates)
864            .expect("Aggregation failed");
865
866        // Weighted average: w1 = (0.5*100 + 0.7*200)/300 = 0.633...
867        assert!((aggregated["w1"] - 0.633).abs() < 0.01);
868    }
869
870    #[test]
871    fn test_byzantine_robust_aggregation() {
872        let server = QuantumFLServer::new(vec![], SecureAggregationProtocol::SecureMultiparty, 0.3);
873
874        // Normal values with one outlier
875        let values = vec![0.5, 0.52, 0.48, 0.51, 10.0]; // 10.0 is Byzantine
876        let robust_value = server
877            .byzantine_robust_aggregation(&values)
878            .expect("Byzantine aggregation failed");
879
880        // Should select one of the normal values
881        assert!(robust_value < 1.0);
882    }
883
884    #[test]
885    fn test_differential_privacy() {
886        use privacy::*;
887
888        let dp = QuantumDifferentialPrivacy::new(1.0, 0.1, NoiseType::Gaussian);
889
890        let mut params = HashMap::new();
891        params.insert("param1".to_string(), 0.5);
892        params.insert("param2".to_string(), 0.3);
893
894        let original = params.clone();
895        dp.add_noise(&mut params).expect("Failed to add noise");
896
897        // Check that noise was added
898        assert_ne!(params["param1"], original["param1"]);
899        assert_ne!(params["param2"], original["param2"]);
900    }
901
902    #[test]
903    fn test_distributed_learning() {
904        let config = vec![("encoding".to_string(), 4), ("variational".to_string(), 8)];
905
906        let mut system = DistributedQuantumLearning::new(
907            3, // 3 clients
908            config,
909            SecureAggregationProtocol::FederatedAveraging,
910            1.0,
911        )
912        .expect("Failed to create distributed learning system");
913
914        // Create dummy data for each client
915        let mut data_dist = HashMap::new();
916        for i in 0..3 {
917            let data = Array2::zeros((10, 4));
918            let labels = Array1::zeros(10);
919            data_dist.insert(format!("client_{}", i), (data, labels));
920        }
921
922        let result = system.train(&data_dist, 2, 2).expect("Training failed");
923
924        assert_eq!(result.num_rounds, 2);
925        assert_eq!(result.round_losses.len(), 2);
926    }
927}