1use ipfrs_core::Cid;
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::time::{Instant, SystemTime, UNIX_EPOCH};
15
16use super::arrow_ipc::{load_gradient_from_arrow, store_gradient_as_arrow};
17use super::backward_pass::{federated_average, AggregationMethod, BackwardPassConfig};
18use super::tensor::GradientAggregator;
19use super::GradientError;
20
21#[derive(Debug, thiserror::Error)]
25pub enum FederatedError {
26 #[error("No contributions available to aggregate")]
27 NoContributions,
28 #[error("Dimension mismatch between client gradients")]
29 DimensionMismatch,
30 #[error("Client not found: {0}")]
31 ClientNotFound(String),
32 #[error("Round not started")]
33 NotStarted,
34}
35
36#[derive(Debug, Clone, Copy)]
40pub struct PrivacyBudget {
41 pub epsilon: f64,
43 pub delta: f64,
45 pub remaining_epsilon: f64,
47}
48
49impl PrivacyBudget {
50 pub fn new(epsilon: f64, delta: f64) -> Self {
52 Self {
53 epsilon,
54 delta,
55 remaining_epsilon: epsilon,
56 }
57 }
58
59 pub fn consume(&mut self, epsilon_used: f64) -> Result<(), GradientError> {
61 if epsilon_used > self.remaining_epsilon {
62 return Err(GradientError::InvalidGradient(format!(
63 "Insufficient privacy budget: need {}, have {}",
64 epsilon_used, self.remaining_epsilon
65 )));
66 }
67
68 self.remaining_epsilon -= epsilon_used;
69 Ok(())
70 }
71
72 pub fn is_exhausted(&self) -> bool {
74 self.remaining_epsilon <= 0.0
75 }
76
77 pub fn remaining_fraction(&self) -> f64 {
79 self.remaining_epsilon / self.epsilon
80 }
81}
82
83#[derive(Debug, Clone, Copy, PartialEq)]
87pub enum DPMechanism {
88 Gaussian,
90 Laplacian,
92}
93
94pub struct DifferentialPrivacy {
98 budget: PrivacyBudget,
100 sensitivity: f64,
102 mechanism: DPMechanism,
104}
105
106impl DifferentialPrivacy {
107 pub fn new(epsilon: f64, delta: f64, sensitivity: f64, mechanism: DPMechanism) -> Self {
109 Self {
110 budget: PrivacyBudget::new(epsilon, delta),
111 sensitivity,
112 mechanism,
113 }
114 }
115
116 pub fn add_gaussian_noise(&mut self, gradient: &mut [f32]) -> Result<(), GradientError> {
119 use rand::RngExt;
120
121 if self.budget.is_exhausted() {
122 return Err(GradientError::InvalidGradient(
123 "Privacy budget exhausted".to_string(),
124 ));
125 }
126
127 let ln_term = (1.25 / self.budget.delta).ln();
130 let sigma = self.sensitivity * (2.0 * ln_term).sqrt() / self.budget.epsilon;
131
132 let mut rng = rand::rng();
133
134 for v in gradient.iter_mut() {
136 let noise: f64 = rng.random_range(-1.0..1.0);
137 let gaussian_noise = sigma * noise;
138 *v += gaussian_noise as f32;
139 }
140
141 self.budget.consume(self.budget.epsilon / 100.0)?;
143
144 Ok(())
145 }
146
147 pub fn add_laplacian_noise(&mut self, gradient: &mut [f32]) -> Result<(), GradientError> {
150 use rand::RngExt;
151
152 if self.budget.is_exhausted() {
153 return Err(GradientError::InvalidGradient(
154 "Privacy budget exhausted".to_string(),
155 ));
156 }
157
158 let scale = self.sensitivity / self.budget.epsilon;
161
162 let mut rng = rand::rng();
163
164 for v in gradient.iter_mut() {
166 let u: f64 = rng.random_range(-0.5..0.5);
167 let laplacian_noise = -scale * u.signum() * (1.0 - 2.0 * u.abs()).ln();
168 *v += laplacian_noise as f32;
169 }
170
171 self.budget.consume(self.budget.epsilon / 100.0)?;
173
174 Ok(())
175 }
176
177 pub fn apply_dp_sgd(
180 &mut self,
181 gradient: &mut [f32],
182 clip_norm: f32,
183 ) -> Result<(), GradientError> {
184 use super::tensor::GradientVerifier;
185
186 GradientVerifier::clip_by_norm(gradient, clip_norm);
188
189 match self.mechanism {
191 DPMechanism::Gaussian => self.add_gaussian_noise(gradient)?,
192 DPMechanism::Laplacian => self.add_laplacian_noise(gradient)?,
193 }
194
195 Ok(())
196 }
197
198 pub fn remaining_budget(&self) -> f64 {
200 self.budget.remaining_epsilon
201 }
202
203 pub fn is_budget_exhausted(&self) -> bool {
205 self.budget.is_exhausted()
206 }
207
208 pub fn get_privacy_params(&self) -> (f64, f64) {
210 (self.budget.epsilon, self.budget.delta)
211 }
212
213 pub fn calculate_noise_multiplier(epsilon: f64, delta: f64, sensitivity: f64) -> f64 {
216 let ln_term = (1.25 / delta).ln();
218 sensitivity * (2.0 * ln_term).sqrt() / epsilon
219 }
220
221 pub fn add_gaussian_noise_sens(&self, gradient: &mut [f32], sensitivity: f32) {
229 use rand::RngExt;
230 use std::f64::consts::PI;
231
232 let ln_term = (1.25 / self.budget.delta).ln();
233 let sigma = (sensitivity as f64) * (2.0 * ln_term).sqrt() / self.budget.epsilon;
234
235 let mut rng = rand::rng();
236 let mut i = 0;
237 while i < gradient.len() {
238 let u1: f64 = rng.random_range(1e-12_f64..1.0_f64);
240 let u2: f64 = rng.random_range(0.0_f64..1.0_f64);
241 let mag = sigma * (-2.0 * u1.ln()).sqrt();
242 let z0 = mag * (2.0 * PI * u2).cos();
243 let z1 = mag * (2.0 * PI * u2).sin();
244 gradient[i] += z0 as f32;
245 i += 1;
246 if i < gradient.len() {
247 gradient[i] += z1 as f32;
248 i += 1;
249 }
250 }
251 }
252
253 pub fn add_laplace_noise_sens(&self, gradient: &mut [f32], sensitivity: f32) {
257 use rand::RngExt;
258
259 let scale = (sensitivity as f64) / self.budget.epsilon;
260 let mut rng = rand::rng();
261 for v in gradient.iter_mut() {
262 let u: f64 = rng.random_range(1e-12_f64..1.0_f64 - 1e-12_f64);
264 let p = u - 0.5;
265 let laplace = -scale * p.signum() * (1.0 - 2.0 * p.abs()).ln();
266 *v += laplace as f32;
267 }
268 }
269
270 pub fn clip_l2(&self, gradient: &mut [f32], max_norm: f32) {
272 let norm: f32 = gradient.iter().map(|&x| x * x).sum::<f32>().sqrt();
273 if norm > max_norm && norm > 0.0 {
274 let scale = max_norm / norm;
275 for x in gradient.iter_mut() {
276 *x *= scale;
277 }
278 }
279 }
280
281 pub fn remaining_budget_after(&self, rounds_used: u32) -> f32 {
285 let per_round = self.budget.epsilon / 100.0;
286 let used = per_round * rounds_used as f64;
287 (self.budget.epsilon - used).max(0.0) as f32
288 }
289
290 pub fn is_exhausted_after(&self, rounds_used: u32) -> bool {
292 self.remaining_budget_after(rounds_used) <= 0.0
293 }
294}
295
296pub struct SecureAggregation {
300 min_participants: usize,
302 participant_count: usize,
304}
305
306impl SecureAggregation {
307 pub fn new(min_participants: usize) -> Self {
309 Self {
310 min_participants,
311 participant_count: 0,
312 }
313 }
314
315 pub fn add_participant(&mut self) {
317 self.participant_count += 1;
318 }
319
320 pub fn can_aggregate(&self) -> bool {
322 self.participant_count >= self.min_participants
323 }
324
325 pub fn aggregate_secure(&self, gradients: &[Vec<f32>]) -> Result<Vec<f32>, GradientError> {
329 if !self.can_aggregate() {
330 return Err(GradientError::InvalidGradient(format!(
331 "Not enough participants: need {}, have {}",
332 self.min_participants, self.participant_count
333 )));
334 }
335
336 GradientAggregator::average(gradients)
342 }
343
344 pub fn reset(&mut self) {
346 self.participant_count = 0;
347 }
348
349 pub fn participant_count(&self) -> usize {
351 self.participant_count
352 }
353}
354
355#[derive(Debug, Clone, PartialEq, Eq)]
359pub enum ClientState {
360 Idle,
362 Training,
364 Completed,
366 Failed,
368}
369
370#[derive(Debug, Clone)]
372pub struct ClientInfo {
373 pub client_id: String,
375 pub state: ClientState,
377 pub sample_count: usize,
379 pub last_update: i64,
381}
382
383impl ClientInfo {
384 pub fn new(client_id: String, sample_count: usize) -> Self {
386 Self {
387 client_id,
388 state: ClientState::Idle,
389 sample_count,
390 last_update: chrono::Utc::now().timestamp(),
391 }
392 }
393
394 pub fn start_training(&mut self) {
396 self.state = ClientState::Training;
397 self.last_update = chrono::Utc::now().timestamp();
398 }
399
400 pub fn complete_training(&mut self) {
402 self.state = ClientState::Completed;
403 self.last_update = chrono::Utc::now().timestamp();
404 }
405
406 pub fn mark_failed(&mut self) {
408 self.state = ClientState::Failed;
409 self.last_update = chrono::Utc::now().timestamp();
410 }
411}
412
413#[derive(Debug, Clone)]
417pub struct RoundStats {
418 pub round_id: u32,
420 pub participants: usize,
422 pub missing_clients: Vec<String>,
424 pub duration_ms: u64,
426 pub converged: bool,
428}
429
430#[derive(Debug, Clone, Serialize, Deserialize)]
438pub struct FederatedRound {
439 pub round_num: usize,
441 pub client_count: usize,
443 #[serde(serialize_with = "crate::serialize_cid")]
445 #[serde(deserialize_with = "crate::deserialize_cid")]
446 pub global_model: Cid,
447 pub aggregated_gradient: Option<Vec<f32>>,
449 pub start_time: i64,
451 pub end_time: Option<i64>,
453 pub completed_count: usize,
455 #[serde(default)]
458 pub round_id: u32,
459 #[serde(default)]
461 pub expected_clients: Vec<String>,
462 #[serde(skip)]
464 pub contributions: HashMap<String, Vec<f32>>,
465 #[serde(skip)]
467 session_start: Option<Instant>,
468}
469
470impl FederatedRound {
471 pub fn new(round_num: usize, global_model: Cid, client_count: usize) -> Self {
475 Self {
476 round_num,
477 client_count,
478 global_model,
479 aggregated_gradient: None,
480 start_time: chrono::Utc::now().timestamp(),
481 end_time: None,
482 completed_count: 0,
483 round_id: round_num as u32,
484 expected_clients: Vec::new(),
485 contributions: HashMap::new(),
486 session_start: None,
487 }
488 }
489
490 pub fn mark_client_completed(&mut self) {
492 self.completed_count += 1;
493 }
494
495 pub fn is_complete(&self) -> bool {
497 if !self.expected_clients.is_empty() {
498 self.expected_clients
500 .iter()
501 .all(|id| self.contributions.contains_key(id.as_str()))
502 } else {
503 self.completed_count >= self.client_count
505 }
506 }
507
508 pub fn complete(&mut self, aggregated_gradient: Vec<f32>) {
510 self.aggregated_gradient = Some(aggregated_gradient);
511 self.end_time = Some(chrono::Utc::now().timestamp());
512 }
513
514 pub fn duration(&self) -> Option<i64> {
516 self.end_time.map(|end| end - self.start_time)
517 }
518
519 pub fn start(round_id: u32, client_ids: Vec<String>) -> Self {
523 let count = client_ids.len();
524 Self {
525 round_num: round_id as usize,
526 client_count: count,
527 global_model: Cid::default(),
528 aggregated_gradient: None,
529 start_time: chrono::Utc::now().timestamp(),
530 end_time: None,
531 completed_count: 0,
532 round_id,
533 expected_clients: client_ids,
534 contributions: HashMap::new(),
535 session_start: Some(Instant::now()),
536 }
537 }
538
539 pub fn record_contribution(&mut self, client_id: &str, gradient: Vec<f32>) {
541 self.contributions.insert(client_id.to_string(), gradient);
542 }
543
544 pub fn aggregate(&self, dp: Option<&DifferentialPrivacy>) -> Result<Vec<f32>, FederatedError> {
546 if self.contributions.is_empty() {
547 return Err(FederatedError::NoContributions);
548 }
549
550 let grads: Vec<Vec<f32>> = self.contributions.values().cloned().collect();
551 let dim = grads[0].len();
552 if grads.iter().any(|g| g.len() != dim) {
553 return Err(FederatedError::DimensionMismatch);
554 }
555
556 let n = grads.len() as f32;
558 let mut agg = vec![0.0f32; dim];
559 for g in &grads {
560 for (a, &v) in agg.iter_mut().zip(g.iter()) {
561 *a += v / n;
562 }
563 }
564
565 if let Some(dp) = dp {
567 dp.add_gaussian_noise_sens(&mut agg, 1.0);
568 }
569
570 Ok(agg)
571 }
572
573 pub fn stats(&self) -> RoundStats {
575 let missing_clients: Vec<String> = self
576 .expected_clients
577 .iter()
578 .filter(|id| !self.contributions.contains_key(id.as_str()))
579 .cloned()
580 .collect();
581
582 let duration_ms = self
583 .session_start
584 .map(|t| t.elapsed().as_millis() as u64)
585 .unwrap_or(0);
586
587 RoundStats {
588 round_id: self.round_id,
589 participants: self.contributions.len(),
590 missing_clients,
591 duration_ms,
592 converged: false,
593 }
594 }
595}
596
597#[derive(Debug, Clone)]
601pub struct ConvergenceConfig {
602 pub threshold: f32,
604 pub window_size: usize,
606 pub smoothing: f32,
608 pub patience: usize,
610}
611
612impl Default for ConvergenceConfig {
613 fn default() -> Self {
614 Self {
615 threshold: 1e-4,
616 window_size: 10,
617 smoothing: 0.9,
618 patience: 3,
619 }
620 }
621}
622
623pub struct ConvergenceDetector {
631 window_size: usize,
633 loss_history: Vec<f64>,
635 threshold: f64,
637 ema_loss: Option<f32>,
639 loss_window: std::collections::VecDeque<f32>,
641 plateau_count: usize,
643 config: Option<ConvergenceConfig>,
645}
646
647impl ConvergenceDetector {
648 pub fn new(window_size: usize, threshold: f64) -> Self {
650 Self {
651 window_size,
652 loss_history: Vec::new(),
653 threshold,
654 ema_loss: None,
655 loss_window: std::collections::VecDeque::new(),
656 plateau_count: 0,
657 config: None,
658 }
659 }
660
661 pub fn with_config(config: ConvergenceConfig) -> Self {
663 let window_size = config.window_size;
664 let threshold = config.threshold as f64;
665 Self {
666 window_size,
667 loss_history: Vec::new(),
668 threshold,
669 ema_loss: None,
670 loss_window: std::collections::VecDeque::with_capacity(window_size),
671 plateau_count: 0,
672 config: Some(config),
673 }
674 }
675
676 pub fn add_loss(&mut self, loss: f64) {
680 self.loss_history.push(loss);
681 if self.loss_history.len() > self.window_size {
682 self.loss_history.remove(0);
683 }
684 }
685
686 pub fn has_converged(&self) -> bool {
688 if self.loss_history.len() < self.window_size {
689 return false;
690 }
691 let recent = &self.loss_history[self.loss_history.len() - self.window_size..];
692 let mean = recent.iter().sum::<f64>() / recent.len() as f64;
693 if mean.abs() < 1e-10 {
694 return true;
695 }
696 let std_dev =
697 (recent.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / recent.len() as f64).sqrt();
698 std_dev / mean.abs() < self.threshold
699 }
700
701 pub fn latest_loss(&self) -> Option<f64> {
703 self.loss_history.last().copied()
704 }
705
706 pub fn history(&self) -> &[f64] {
708 &self.loss_history
709 }
710
711 pub fn update(&mut self, loss: f32) -> bool {
718 let cfg = match &self.config {
719 Some(c) => c.clone(),
720 None => ConvergenceConfig::default(),
721 };
722
723 self.ema_loss = Some(match self.ema_loss {
725 None => loss,
726 Some(ema) => cfg.smoothing * ema + (1.0 - cfg.smoothing) * loss,
727 });
728
729 if self.loss_window.len() >= cfg.window_size {
731 self.loss_window.pop_front();
732 }
733 self.loss_window.push_back(loss);
734
735 let norm = self.gradient_norm();
737
738 if norm < cfg.threshold {
740 self.plateau_count += 1;
741 } else {
742 self.plateau_count = 0;
743 }
744
745 self.plateau_count >= cfg.patience
746 }
747
748 pub fn smoothed_loss(&self) -> f32 {
750 self.ema_loss.unwrap_or(f32::NAN)
751 }
752
753 pub fn gradient_norm(&self) -> f32 {
755 if self.loss_window.len() < 2 {
756 return f32::MAX;
757 }
758 let deltas: Vec<f32> = self
759 .loss_window
760 .iter()
761 .zip(self.loss_window.iter().skip(1))
762 .map(|(a, b)| (b - a).abs())
763 .collect();
764 deltas.iter().sum::<f32>() / deltas.len() as f32
765 }
766
767 pub fn plateau_rounds(&self) -> usize {
769 self.plateau_count
770 }
771
772 pub fn reset(&mut self) {
774 self.loss_history.clear();
775 self.ema_loss = None;
776 self.loss_window.clear();
777 self.plateau_count = 0;
778 }
779
780 pub fn config(&self) -> Option<&ConvergenceConfig> {
784 self.config.as_ref()
785 }
786}
787
788pub struct ModelSyncProtocol {
792 current_round: usize,
794 max_rounds: usize,
796 min_clients_per_round: usize,
798 rounds: Vec<FederatedRound>,
800 convergence: ConvergenceDetector,
802}
803
804impl ModelSyncProtocol {
805 pub fn new(
807 max_rounds: usize,
808 min_clients_per_round: usize,
809 convergence_window: usize,
810 convergence_threshold: f64,
811 ) -> Self {
812 Self {
813 current_round: 0,
814 max_rounds,
815 min_clients_per_round,
816 rounds: Vec::new(),
817 convergence: ConvergenceDetector::new(convergence_window, convergence_threshold),
818 }
819 }
820
821 pub fn start_round(
823 &mut self,
824 global_model: Cid,
825 client_count: usize,
826 ) -> Result<usize, GradientError> {
827 if client_count < self.min_clients_per_round {
828 return Err(GradientError::InvalidGradient(format!(
829 "Not enough clients: need {}, got {}",
830 self.min_clients_per_round, client_count
831 )));
832 }
833
834 if self.current_round >= self.max_rounds {
835 return Err(GradientError::InvalidGradient(format!(
836 "Maximum rounds reached: {}",
837 self.max_rounds
838 )));
839 }
840
841 let round = FederatedRound::new(self.current_round, global_model, client_count);
842 self.rounds.push(round);
843 self.current_round += 1;
844
845 Ok(self.current_round - 1)
846 }
847
848 pub fn complete_round(
850 &mut self,
851 round_num: usize,
852 aggregated_gradient: Vec<f32>,
853 loss: f64,
854 ) -> Result<(), GradientError> {
855 if round_num >= self.rounds.len() {
856 return Err(GradientError::InvalidGradient(format!(
857 "Invalid round number: {}",
858 round_num
859 )));
860 }
861
862 self.rounds[round_num].complete(aggregated_gradient);
863 self.convergence.add_loss(loss);
864
865 Ok(())
866 }
867
868 pub fn should_continue(&self) -> bool {
870 self.current_round < self.max_rounds && !self.convergence.has_converged()
871 }
872
873 pub fn has_converged(&self) -> bool {
875 self.convergence.has_converged()
876 }
877
878 pub fn current_round(&self) -> usize {
880 self.current_round
881 }
882
883 pub fn total_rounds(&self) -> usize {
885 self.rounds.len()
886 }
887
888 pub fn get_round(&self, round_num: usize) -> Option<&FederatedRound> {
890 self.rounds.get(round_num)
891 }
892
893 pub fn latest_loss(&self) -> Option<f64> {
895 self.convergence.latest_loss()
896 }
897
898 pub fn max_rounds(&self) -> usize {
900 self.max_rounds
901 }
902}
903
904#[derive(Debug, Clone, Serialize, Deserialize)]
908pub struct ModelUpdate {
909 pub peer_id: String,
911 pub model_cid: String,
913 pub round_id: u32,
915 pub timestamp_ms: u64,
917}
918
919impl ModelUpdate {
920 fn now_ms() -> u64 {
921 SystemTime::now()
922 .duration_since(UNIX_EPOCH)
923 .map(|d| d.as_millis() as u64)
924 .unwrap_or(0)
925 }
926
927 pub fn new(peer_id: String, model_cid: String, round_id: u32) -> Self {
929 Self {
930 peer_id,
931 model_cid,
932 round_id,
933 timestamp_ms: Self::now_ms(),
934 }
935 }
936}
937
938pub struct GossipModelSync {
946 local_peer_id: String,
947 tx: tokio::sync::broadcast::Sender<ModelUpdate>,
949 rx: tokio::sync::broadcast::Receiver<ModelUpdate>,
951}
952
953impl GossipModelSync {
954 pub fn new(local_peer_id: impl Into<String>) -> Self {
956 let (tx, rx) = tokio::sync::broadcast::channel(64);
957 Self {
958 local_peer_id: local_peer_id.into(),
959 tx,
960 rx,
961 }
962 }
963
964 pub fn subscribe(&self, peer_id: impl Into<String>) -> Self {
966 Self {
967 local_peer_id: peer_id.into(),
968 tx: self.tx.clone(),
969 rx: self.tx.subscribe(),
970 }
971 }
972
973 pub async fn broadcast_update(&self, model_cid: &str, round_id: u32) -> anyhow::Result<()> {
975 let update = ModelUpdate::new(self.local_peer_id.clone(), model_cid.to_string(), round_id);
976 self.tx
977 .send(update)
978 .map_err(|e| anyhow::anyhow!("broadcast send: {e}"))?;
979 Ok(())
980 }
981
982 pub async fn collect_updates(
987 &mut self,
988 expected_peers: usize,
989 timeout_ms: u64,
990 ) -> anyhow::Result<Vec<ModelUpdate>> {
991 let mut collected: Vec<ModelUpdate> = Vec::new();
992 let deadline = tokio::time::Instant::now() + std::time::Duration::from_millis(timeout_ms);
993
994 while collected
995 .iter()
996 .filter(|u| u.peer_id != self.local_peer_id)
997 .count()
998 < expected_peers
999 {
1000 let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
1001 if remaining.is_zero() {
1002 break;
1003 }
1004 match tokio::time::timeout(remaining, self.rx.recv()).await {
1005 Ok(Ok(update)) => {
1006 if update.peer_id != self.local_peer_id {
1007 collected.push(update);
1008 }
1009 }
1010 Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(n))) => {
1011 tracing::warn!("GossipModelSync: lagged by {n} messages");
1012 }
1013 Ok(Err(tokio::sync::broadcast::error::RecvError::Closed)) => break,
1014 Err(_timeout) => break,
1015 }
1016 }
1017
1018 Ok(collected)
1019 }
1020
1021 pub fn verify_update(&self, update: &ModelUpdate) -> bool {
1027 if update.peer_id.trim().is_empty() {
1028 return false;
1029 }
1030 let cid = update.model_cid.trim();
1031 if cid.is_empty() {
1032 return false;
1033 }
1034 cid.parse::<cid::Cid>().is_ok() || (!cid.contains('\0') && cid.len() >= 4)
1039 }
1040}
1041
1042pub struct DistributedGradientAccumulator {
1055 session_id: String,
1056 pub local_gradient: Vec<f32>,
1058 pub peer_gradients: std::collections::HashMap<String, Vec<f32>>,
1060 config: BackwardPassConfig,
1061}
1062
1063impl DistributedGradientAccumulator {
1064 pub fn new(session_id: &str, config: BackwardPassConfig) -> Self {
1066 Self {
1067 session_id: session_id.to_string(),
1068 local_gradient: Vec::new(),
1069 peer_gradients: std::collections::HashMap::new(),
1070 config,
1071 }
1072 }
1073
1074 pub async fn commit_local(
1078 &mut self,
1079 grad: Vec<f32>,
1080 store: &dyn ipfrs_storage::traits::BlockStore,
1081 ) -> anyhow::Result<ipfrs_core::Cid> {
1082 use ipfrs_core::Block;
1083
1084 let ipc_bytes =
1085 store_gradient_as_arrow(&grad).map_err(|e| anyhow::anyhow!("Arrow IPC encode: {e}"))?;
1086
1087 let block = Block::new(bytes::Bytes::from(ipc_bytes.clone()))
1089 .map_err(|e| anyhow::anyhow!("Block creation: {e}"))?;
1090 let cid = block.cid();
1091
1092 store
1093 .put(&block)
1094 .await
1095 .map_err(|e| anyhow::anyhow!("BlockStore put: {e}"))?;
1096
1097 self.local_gradient = grad;
1098 Ok(*cid)
1099 }
1100
1101 pub async fn add_peer_gradient(
1103 &mut self,
1104 peer_id: &str,
1105 cid: &ipfrs_core::Cid,
1106 store: &dyn ipfrs_storage::traits::BlockStore,
1107 ) -> anyhow::Result<()> {
1108 let block = store
1109 .get(cid)
1110 .await
1111 .map_err(|e| anyhow::anyhow!("BlockStore get: {e}"))?
1112 .ok_or_else(|| anyhow::anyhow!("Block not found for CID {cid}"))?;
1113
1114 let grad = load_gradient_from_arrow(block.data())
1115 .map_err(|e| anyhow::anyhow!("Arrow IPC decode: {e}"))?;
1116
1117 self.peer_gradients.insert(peer_id.to_string(), grad);
1118 Ok(())
1119 }
1120
1121 pub fn aggregate(&self) -> Result<Vec<f32>, GradientError> {
1126 if self.local_gradient.is_empty() {
1127 return Err(GradientError::EmptyGradients);
1128 }
1129
1130 let mut all: Vec<Vec<f32>> = Vec::with_capacity(1 + self.peer_gradients.len());
1131 all.push(self.local_gradient.clone());
1132 all.extend(self.peer_gradients.values().cloned());
1133
1134 match &self.config.aggregation {
1135 AggregationMethod::Sum => {
1136 let dim = all[0].len();
1137 if all.iter().any(|g| g.len() != dim) {
1138 return Err(GradientError::DimensionMismatch);
1139 }
1140 let mut sum = vec![0.0f32; dim];
1141 for grad in &all {
1142 for (a, &g) in sum.iter_mut().zip(grad.iter()) {
1143 *a += g;
1144 }
1145 }
1146 Ok(sum)
1147 }
1148 AggregationMethod::Mean | AggregationMethod::FedAvg => federated_average(&all),
1149 AggregationMethod::WeightedMean { weights } => {
1150 let w: Vec<f32> = weights.clone();
1151 GradientAggregator::weighted_average(&all, &w)
1152 }
1153 }
1154 }
1155
1156 pub fn is_ready(&self, min_peers: usize) -> bool {
1158 self.peer_gradients.len() >= min_peers
1159 }
1160
1161 pub fn session_id(&self) -> &str {
1163 &self.session_id
1164 }
1165
1166 pub fn peer_count(&self) -> usize {
1168 self.peer_gradients.len()
1169 }
1170}
1171
1172#[cfg(test)]
1175mod federated_v2_tests {
1176 use super::*;
1177
1178 #[test]
1181 fn test_convergence_detector_converges() {
1182 let cfg = ConvergenceConfig {
1184 threshold: 0.01,
1185 window_size: 4,
1186 smoothing: 0.9,
1187 patience: 3,
1188 };
1189 let mut det = ConvergenceDetector::with_config(cfg);
1190
1191 let losses: Vec<f32> = {
1193 let mut v = vec![10.0_f32, 5.0, 2.0, 1.0, 0.5];
1194 for i in 0..12 {
1196 v.push(0.5 - (i as f32) * 0.001);
1197 }
1198 v
1199 };
1200 let mut converged = false;
1201 for &l in &losses {
1202 if det.update(l) {
1203 converged = true;
1204 break;
1205 }
1206 }
1207 assert!(
1208 converged,
1209 "should converge on rapidly decreasing then flat loss"
1210 );
1211 }
1212
1213 #[test]
1214 fn test_convergence_detector_no_convergence() {
1215 let cfg = ConvergenceConfig {
1216 threshold: 0.01,
1217 window_size: 5,
1218 smoothing: 0.9,
1219 patience: 3,
1220 };
1221 let mut det = ConvergenceDetector::with_config(cfg);
1222
1223 let losses = [1.0_f32, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0];
1225 let mut converged = false;
1226 for &l in &losses {
1227 if det.update(l) {
1228 converged = true;
1229 break;
1230 }
1231 }
1232 assert!(!converged, "should NOT converge on oscillating loss");
1233 }
1234
1235 #[test]
1236 fn test_convergence_detector_plateau() {
1237 let cfg = ConvergenceConfig {
1238 threshold: 0.1,
1239 window_size: 4,
1240 smoothing: 0.9,
1241 patience: 3,
1242 };
1243 let mut det = ConvergenceDetector::with_config(cfg);
1244
1245 for _ in 0..10 {
1247 det.update(0.5_f32);
1248 }
1249 assert!(
1251 det.plateau_rounds() >= 3,
1252 "plateau_rounds={} should be >= patience=3",
1253 det.plateau_rounds()
1254 );
1255 }
1256
1257 #[test]
1260 fn test_dp_gaussian_noise_scale() {
1261 let epsilon = 1.0_f64;
1262 let delta = 1e-5_f64;
1263 let sensitivity = 1.0_f32;
1264
1265 let dp =
1266 DifferentialPrivacy::new(epsilon, delta, sensitivity as f64, DPMechanism::Gaussian);
1267
1268 let ln_term = (1.25 / delta).ln();
1270 let expected_sigma = (sensitivity as f64) * (2.0 * ln_term).sqrt() / epsilon;
1271
1272 let mut base = vec![0.0_f32; 1000];
1274 dp.add_gaussian_noise_sens(&mut base, sensitivity);
1275
1276 let mean = base.iter().sum::<f32>() / base.len() as f32;
1277 let variance = base.iter().map(|&x| (x - mean).powi(2)).sum::<f32>() / base.len() as f32;
1278 let std_dev = variance.sqrt() as f64;
1279
1280 let rel_err = (std_dev - expected_sigma).abs() / expected_sigma;
1282 assert!(
1283 rel_err < 0.40,
1284 "empirical σ={std_dev:.4} expected σ={expected_sigma:.4} rel_err={rel_err:.4}"
1285 );
1286 }
1287
1288 #[test]
1289 fn test_dp_clip_l2_norm() {
1290 let dp = DifferentialPrivacy::new(1.0, 1e-5, 1.0, DPMechanism::Gaussian);
1291
1292 let mut grad = vec![3.0_f32, 4.0]; dp.clip_l2(&mut grad, 2.5);
1294
1295 let norm: f32 = grad.iter().map(|&x| x * x).sum::<f32>().sqrt();
1296 assert!(
1297 (norm - 2.5).abs() < 1e-4,
1298 "clipped norm {norm} should be ≈ 2.5"
1299 );
1300 }
1301
1302 #[test]
1303 fn test_dp_budget_exhaustion() {
1304 let dp = DifferentialPrivacy::new(1.0, 1e-5, 1.0, DPMechanism::Gaussian);
1306 assert!(!dp.is_exhausted_after(99));
1307 assert!(dp.is_exhausted_after(100));
1308 assert!(dp.is_exhausted_after(200));
1309 }
1310
1311 #[test]
1314 fn test_federated_round_complete() {
1315 let clients = vec!["alice".to_string(), "bob".to_string(), "carol".to_string()];
1316 let mut round = FederatedRound::start(1, clients);
1317
1318 assert!(!round.is_complete(), "not complete before contributions");
1319
1320 round.record_contribution("alice", vec![1.0, 2.0]);
1321 round.record_contribution("bob", vec![3.0, 4.0]);
1322 round.record_contribution("carol", vec![5.0, 6.0]);
1323
1324 assert!(round.is_complete(), "complete after all clients contribute");
1325 }
1326
1327 #[test]
1328 fn test_federated_round_missing_client() {
1329 let clients = vec!["alice".to_string(), "bob".to_string(), "carol".to_string()];
1330 let mut round = FederatedRound::start(2, clients);
1331
1332 round.record_contribution("alice", vec![1.0, 2.0]);
1333 let stats = round.stats();
1336 assert!(
1337 !stats.missing_clients.is_empty(),
1338 "missing_clients should be non-empty"
1339 );
1340 assert!(
1341 stats.missing_clients.contains(&"bob".to_string()),
1342 "bob should be missing"
1343 );
1344 assert!(
1345 stats.missing_clients.contains(&"carol".to_string()),
1346 "carol should be missing"
1347 );
1348 }
1349
1350 #[test]
1351 fn test_federated_aggregate_fedavg() {
1352 let clients = vec!["a".to_string(), "b".to_string(), "c".to_string()];
1353 let mut round = FederatedRound::start(3, clients);
1354
1355 round.record_contribution("a", vec![1.0_f32, 2.0, 3.0]);
1356 round.record_contribution("b", vec![3.0_f32, 4.0, 5.0]);
1357 round.record_contribution("c", vec![5.0_f32, 6.0, 7.0]);
1358
1359 let agg = round.aggregate(None).expect("aggregate");
1360 assert!((agg[0] - 3.0).abs() < 1e-4, "agg[0]={}", agg[0]);
1362 assert!((agg[1] - 4.0).abs() < 1e-4, "agg[1]={}", agg[1]);
1363 assert!((agg[2] - 5.0).abs() < 1e-4, "agg[2]={}", agg[2]);
1364 }
1365
1366 #[tokio::test]
1369 async fn test_model_update_verify() {
1370 let sync = GossipModelSync::new("peer-local");
1371
1372 let valid_cid = "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku";
1374 let valid_update = ModelUpdate {
1375 peer_id: "peer-remote".to_string(),
1376 model_cid: valid_cid.to_string(),
1377 round_id: 1,
1378 timestamp_ms: 0,
1379 };
1380 assert!(sync.verify_update(&valid_update), "valid CID should pass");
1381
1382 let tampered = ModelUpdate {
1384 peer_id: "peer-remote".to_string(),
1385 model_cid: "".to_string(),
1386 round_id: 1,
1387 timestamp_ms: 0,
1388 };
1389 assert!(!sync.verify_update(&tampered), "empty CID should fail");
1390
1391 let tampered2 = ModelUpdate {
1393 peer_id: "peer-remote".to_string(),
1394 model_cid: " ".to_string(),
1395 round_id: 1,
1396 timestamp_ms: 0,
1397 };
1398 assert!(
1399 !sync.verify_update(&tampered2),
1400 "whitespace CID should fail"
1401 );
1402 }
1403
1404 #[tokio::test]
1405 async fn test_gossip_broadcast_and_collect() {
1406 let sender = GossipModelSync::new("sender");
1407 let mut receiver = sender.subscribe("receiver");
1408
1409 let cid = "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku";
1410 sender.broadcast_update(cid, 1).await.expect("broadcast");
1411
1412 let updates = receiver
1413 .collect_updates(1, 200)
1414 .await
1415 .expect("collect_updates");
1416
1417 assert_eq!(updates.len(), 1, "should receive 1 update");
1418 assert_eq!(updates[0].model_cid, cid);
1419 assert_eq!(updates[0].round_id, 1);
1420 }
1421}