Skip to main content

ipfrs_tensorlogic/gradient/
federated.rs

1//! Federated learning utilities.
2//!
3//! This module provides:
4//! - Differential privacy mechanisms (`PrivacyBudget`, `DPMechanism`, `DifferentialPrivacy`)
5//! - Secure aggregation (`SecureAggregation`)
6//! - Client lifecycle management (`ClientState`, `ClientInfo`)
7//! - Round coordination (`FederatedRound`, `ConvergenceDetector`, `ModelSyncProtocol`)
8//! - Standalone helpers: `federated_average`, `clip_gradient_norm`
9//! - `DistributedGradientAccumulator` for peer-to-peer gradient exchange
10
11use 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// ── FederatedError ─────────────────────────────────────────────────────────
22
23/// Errors specific to the federated round lifecycle.
24#[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// ── PrivacyBudget ──────────────────────────────────────────────────────────
37
38/// Privacy budget for differential privacy
39#[derive(Debug, Clone, Copy)]
40pub struct PrivacyBudget {
41    /// Epsilon (privacy loss parameter)
42    pub epsilon: f64,
43    /// Delta (failure probability)
44    pub delta: f64,
45    /// Remaining epsilon
46    pub remaining_epsilon: f64,
47}
48
49impl PrivacyBudget {
50    /// Create a new privacy budget
51    pub fn new(epsilon: f64, delta: f64) -> Self {
52        Self {
53            epsilon,
54            delta,
55            remaining_epsilon: epsilon,
56        }
57    }
58
59    /// Consume some privacy budget
60    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    /// Check if budget is exhausted
73    pub fn is_exhausted(&self) -> bool {
74        self.remaining_epsilon <= 0.0
75    }
76
77    /// Get the fraction of budget remaining
78    pub fn remaining_fraction(&self) -> f64 {
79        self.remaining_epsilon / self.epsilon
80    }
81}
82
83// ── DPMechanism ────────────────────────────────────────────────────────────
84
85/// Differential privacy mechanism types
86#[derive(Debug, Clone, Copy, PartialEq)]
87pub enum DPMechanism {
88    /// Gaussian mechanism (for bounded sensitivity)
89    Gaussian,
90    /// Laplacian mechanism (for L1 sensitivity)
91    Laplacian,
92}
93
94// ── DifferentialPrivacy ────────────────────────────────────────────────────
95
96/// Differential privacy for gradient protection
97pub struct DifferentialPrivacy {
98    /// Privacy budget
99    budget: PrivacyBudget,
100    /// Sensitivity (L2 norm bound for gradients)
101    sensitivity: f64,
102    /// Mechanism type
103    mechanism: DPMechanism,
104}
105
106impl DifferentialPrivacy {
107    /// Create a new differential privacy instance
108    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    /// Add Gaussian noise to gradient (for DP-SGD)
117    /// Calibrated according to sensitivity and privacy parameters
118    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        // Calculate noise scale using Gaussian mechanism
128        // σ = sensitivity * sqrt(2 * ln(1.25/δ)) / ε
129        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        // Add Gaussian noise to each element
135        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        // Consume privacy budget (simplified - in practice, this depends on composition)
142        self.budget.consume(self.budget.epsilon / 100.0)?;
143
144        Ok(())
145    }
146
147    /// Add Laplacian noise to gradient
148    /// Calibrated according to L1 sensitivity and privacy parameters
149    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        // Calculate noise scale using Laplacian mechanism
159        // b = sensitivity / ε
160        let scale = self.sensitivity / self.budget.epsilon;
161
162        let mut rng = rand::rng();
163
164        // Add Laplacian noise to each element
165        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        // Consume privacy budget
172        self.budget.consume(self.budget.epsilon / 100.0)?;
173
174        Ok(())
175    }
176
177    /// Apply DP-SGD (Differential Private Stochastic Gradient Descent)
178    /// This clips gradients and adds noise
179    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        // Step 1: Clip gradient to bound sensitivity
187        GradientVerifier::clip_by_norm(gradient, clip_norm);
188
189        // Step 2: Add calibrated noise
190        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    /// Get remaining privacy budget
199    pub fn remaining_budget(&self) -> f64 {
200        self.budget.remaining_epsilon
201    }
202
203    /// Check if privacy budget is exhausted
204    pub fn is_budget_exhausted(&self) -> bool {
205        self.budget.is_exhausted()
206    }
207
208    /// Get privacy parameters
209    pub fn get_privacy_params(&self) -> (f64, f64) {
210        (self.budget.epsilon, self.budget.delta)
211    }
212
213    /// Calculate noise multiplier for given privacy parameters
214    /// Used in DP-SGD implementations
215    pub fn calculate_noise_multiplier(epsilon: f64, delta: f64, sensitivity: f64) -> f64 {
216        // σ = sensitivity * sqrt(2 * ln(1.25/δ)) / ε
217        let ln_term = (1.25 / delta).ln();
218        sensitivity * (2.0 * ln_term).sqrt() / epsilon
219    }
220
221    // ── New stateless helpers (sensitivity supplied by caller) ───────────────
222
223    /// Add calibrated Gaussian noise to a gradient slice (stateless, sensitivity supplied).
224    ///
225    /// σ = sensitivity × √(2 ln(1.25/δ)) / ε
226    ///
227    /// Uses Box-Muller transform for Normal sampling (no `rand_distr` dependency).
228    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            // Box-Muller: two uniform samples → two independent N(0,1) samples
239            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    /// Add calibrated Laplace noise to a gradient slice (stateless, sensitivity supplied).
254    ///
255    /// scale = sensitivity / ε
256    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            // Invert CDF of Laplace distribution: F⁻¹(p) = -b sgn(p-0.5) ln(1-2|p-0.5|)
263            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    /// Clip gradient in-place to L2 norm ≤ `max_norm`.
271    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    /// Return remaining epsilon budget after `rounds_used` rounds, each costing ε/100.
282    ///
283    /// This mirrors the per-round consumption used by `add_gaussian_noise` (mutable version).
284    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    /// True if the budget would be exhausted after `rounds_used` rounds.
291    pub fn is_exhausted_after(&self, rounds_used: u32) -> bool {
292        self.remaining_budget_after(rounds_used) <= 0.0
293    }
294}
295
296// ── SecureAggregation ──────────────────────────────────────────────────────
297
298/// Secure aggregation for federated learning (simplified)
299pub struct SecureAggregation {
300    /// Minimum number of participants required
301    min_participants: usize,
302    /// Current participant count
303    participant_count: usize,
304}
305
306impl SecureAggregation {
307    /// Create a new secure aggregation instance
308    pub fn new(min_participants: usize) -> Self {
309        Self {
310            min_participants,
311            participant_count: 0,
312        }
313    }
314
315    /// Add a participant
316    pub fn add_participant(&mut self) {
317        self.participant_count += 1;
318    }
319
320    /// Check if we have enough participants
321    pub fn can_aggregate(&self) -> bool {
322        self.participant_count >= self.min_participants
323    }
324
325    /// Aggregate gradients securely
326    /// In a real implementation, this would use cryptographic techniques
327    /// like secret sharing, homomorphic encryption, or secure multi-party computation
328    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        // For now, use simple averaging
337        // In production, this would:
338        // 1. Use secret sharing to split gradients
339        // 2. Aggregate encrypted shares
340        // 3. Reconstruct only the sum
341        GradientAggregator::average(gradients)
342    }
343
344    /// Reset participant count
345    pub fn reset(&mut self) {
346        self.participant_count = 0;
347    }
348
349    /// Get participant count
350    pub fn participant_count(&self) -> usize {
351        self.participant_count
352    }
353}
354
355// ── ClientState / ClientInfo ───────────────────────────────────────────────
356
357/// Client state in federated learning
358#[derive(Debug, Clone, PartialEq, Eq)]
359pub enum ClientState {
360    /// Client is idle and ready for work
361    Idle,
362    /// Client is training
363    Training,
364    /// Client has completed training
365    Completed,
366    /// Client has failed or dropped out
367    Failed,
368}
369
370/// Client information in federated learning
371#[derive(Debug, Clone)]
372pub struct ClientInfo {
373    /// Client ID
374    pub client_id: String,
375    /// Client state
376    pub state: ClientState,
377    /// Number of samples the client has
378    pub sample_count: usize,
379    /// Last update timestamp
380    pub last_update: i64,
381}
382
383impl ClientInfo {
384    /// Create a new client info
385    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    /// Mark client as training
395    pub fn start_training(&mut self) {
396        self.state = ClientState::Training;
397        self.last_update = chrono::Utc::now().timestamp();
398    }
399
400    /// Mark client as completed
401    pub fn complete_training(&mut self) {
402        self.state = ClientState::Completed;
403        self.last_update = chrono::Utc::now().timestamp();
404    }
405
406    /// Mark client as failed
407    pub fn mark_failed(&mut self) {
408        self.state = ClientState::Failed;
409        self.last_update = chrono::Utc::now().timestamp();
410    }
411}
412
413// ── RoundStats ─────────────────────────────────────────────────────────────
414
415/// Statistics snapshot for a completed federated round.
416#[derive(Debug, Clone)]
417pub struct RoundStats {
418    /// Round identifier
419    pub round_id: u32,
420    /// Number of clients that contributed
421    pub participants: usize,
422    /// Client IDs that did not contribute in time
423    pub missing_clients: Vec<String>,
424    /// Wall-clock duration of the round in milliseconds
425    pub duration_ms: u64,
426    /// Whether convergence was detected at end of round
427    pub converged: bool,
428}
429
430// ── FederatedRound ─────────────────────────────────────────────────────────
431
432/// Federated learning round.
433///
434/// Supports two construction modes:
435/// - Legacy: `FederatedRound::new(round_num, global_model, client_count)`
436/// - Session: `FederatedRound::start(round_id, client_ids)` + `record_contribution` + `aggregate`
437#[derive(Debug, Clone, Serialize, Deserialize)]
438pub struct FederatedRound {
439    /// Round number
440    pub round_num: usize,
441    /// Clients participating in this round (stored as count for serialization)
442    pub client_count: usize,
443    /// Global model CID for this round
444    #[serde(serialize_with = "crate::serialize_cid")]
445    #[serde(deserialize_with = "crate::deserialize_cid")]
446    pub global_model: Cid,
447    /// Aggregated gradient for this round (if computed)
448    pub aggregated_gradient: Option<Vec<f32>>,
449    /// Round start timestamp
450    pub start_time: i64,
451    /// Round end timestamp (if completed)
452    pub end_time: Option<i64>,
453    /// Completed client count
454    pub completed_count: usize,
455    // ── Session-mode fields (populated by `start()`) ──────────────────────
456    /// Round ID (session mode)
457    #[serde(default)]
458    pub round_id: u32,
459    /// Expected client IDs (session mode)
460    #[serde(default)]
461    pub expected_clients: Vec<String>,
462    /// Per-client gradient contributions (session mode)
463    #[serde(skip)]
464    pub contributions: HashMap<String, Vec<f32>>,
465    /// Wall-clock start instant (session mode; not serialized)
466    #[serde(skip)]
467    session_start: Option<Instant>,
468}
469
470impl FederatedRound {
471    // ── Legacy constructor ──────────────────────────────────────────────────
472
473    /// Create a new federated round (legacy mode).
474    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    /// Mark a client as completed (legacy mode).
491    pub fn mark_client_completed(&mut self) {
492        self.completed_count += 1;
493    }
494
495    /// Check if round is complete (works in both modes).
496    pub fn is_complete(&self) -> bool {
497        if !self.expected_clients.is_empty() {
498            // Session mode: all expected clients have contributed
499            self.expected_clients
500                .iter()
501                .all(|id| self.contributions.contains_key(id.as_str()))
502        } else {
503            // Legacy mode
504            self.completed_count >= self.client_count
505        }
506    }
507
508    /// Complete the round (legacy mode).
509    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    /// Get round duration in seconds (legacy mode).
515    pub fn duration(&self) -> Option<i64> {
516        self.end_time.map(|end| end - self.start_time)
517    }
518
519    // ── Session mode API ───────────────────────────────────────────────────
520
521    /// Start a new round with the given participating client IDs (session mode).
522    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    /// Record a client's gradient contribution (session mode).
540    pub fn record_contribution(&mut self, client_id: &str, gradient: Vec<f32>) {
541        self.contributions.insert(client_id.to_string(), gradient);
542    }
543
544    /// Aggregate all contributions using FedAvg, optionally applying DP noise.
545    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        // FedAvg
557        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        // Optionally apply DP noise (Gaussian with unit sensitivity)
566        if let Some(dp) = dp {
567            dp.add_gaussian_noise_sens(&mut agg, 1.0);
568        }
569
570        Ok(agg)
571    }
572
573    /// Return statistics for this round.
574    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// ── ConvergenceConfig ──────────────────────────────────────────────────────
598
599/// Configuration for `ConvergenceDetector`.
600#[derive(Debug, Clone)]
601pub struct ConvergenceConfig {
602    /// Gradient-norm threshold below which convergence is declared.
603    pub threshold: f32,
604    /// Rolling window for gradient norm computation.
605    pub window_size: usize,
606    /// EMA smoothing factor (0 < smoothing ≤ 1; higher → more weight on recent losses).
607    pub smoothing: f32,
608    /// Number of consecutive below-threshold rounds required before declaring convergence.
609    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
623// ── ConvergenceDetector ────────────────────────────────────────────────────
624
625/// Convergence detection for federated learning.
626///
627/// Supports two usage modes:
628/// 1. Legacy: `new(window_size, threshold)` + `add_loss()` + `has_converged()`
629/// 2. EMA mode: `with_config(cfg)` + `update(loss)` returns bool
630pub struct ConvergenceDetector {
631    /// Window size for convergence detection (legacy mode)
632    window_size: usize,
633    /// Recent loss values (legacy mode)
634    loss_history: Vec<f64>,
635    /// Convergence threshold (legacy mode, relative change)
636    threshold: f64,
637    /// EMA-smoothed loss (new mode)
638    ema_loss: Option<f32>,
639    /// Loss window for gradient-norm computation (new mode)
640    loss_window: std::collections::VecDeque<f32>,
641    /// Rounds consecutively below threshold (new mode)
642    plateau_count: usize,
643    /// Configuration (new mode)
644    config: Option<ConvergenceConfig>,
645}
646
647impl ConvergenceDetector {
648    /// Create a new convergence detector (legacy mode).
649    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    /// Create a convergence detector driven by `ConvergenceConfig`.
662    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    // ── Legacy API ──────────────────────────────────────────────────────────
677
678    /// Add a loss value (legacy mode).
679    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    /// Check if training has converged (legacy mode).
687    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    /// Get the latest loss (legacy mode).
702    pub fn latest_loss(&self) -> Option<f64> {
703        self.loss_history.last().copied()
704    }
705
706    /// Get loss history (legacy mode).
707    pub fn history(&self) -> &[f64] {
708        &self.loss_history
709    }
710
711    // ── EMA / config-driven API ─────────────────────────────────────────────
712
713    /// Record a new loss value and check for convergence (EMA mode).
714    ///
715    /// Returns `true` when the smoothed gradient norm drops below `config.threshold`
716    /// for `config.patience` consecutive rounds.
717    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        // Update EMA
724        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        // Maintain loss window
730        if self.loss_window.len() >= cfg.window_size {
731            self.loss_window.pop_front();
732        }
733        self.loss_window.push_back(loss);
734
735        // Compute gradient norm (mean absolute delta over window)
736        let norm = self.gradient_norm();
737
738        // Update plateau count
739        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    /// Exponentially smoothed loss (EMA mode).
749    pub fn smoothed_loss(&self) -> f32 {
750        self.ema_loss.unwrap_or(f32::NAN)
751    }
752
753    /// Gradient norm computed as mean |Δloss| over the window (EMA mode).
754    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    /// Number of consecutive rounds the gradient norm has been below threshold (EMA mode).
768    pub fn plateau_rounds(&self) -> usize {
769        self.plateau_count
770    }
771
772    /// Reset all history (both legacy and EMA modes).
773    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    /// Return a reference to the `ConvergenceConfig` (EMA mode).
781    ///
782    /// Returns `None` when constructed in legacy mode.
783    pub fn config(&self) -> Option<&ConvergenceConfig> {
784        self.config.as_ref()
785    }
786}
787
788// ── ModelSyncProtocol ──────────────────────────────────────────────────────
789
790/// Model synchronization protocol for federated learning
791pub struct ModelSyncProtocol {
792    /// Current round number
793    current_round: usize,
794    /// Maximum number of rounds
795    max_rounds: usize,
796    /// Minimum number of clients per round
797    min_clients_per_round: usize,
798    /// Round history
799    rounds: Vec<FederatedRound>,
800    /// Convergence detector
801    convergence: ConvergenceDetector,
802}
803
804impl ModelSyncProtocol {
805    /// Create a new model synchronization protocol
806    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    /// Start a new round
822    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    /// Complete the current round
849    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    /// Check if training should continue
869    pub fn should_continue(&self) -> bool {
870        self.current_round < self.max_rounds && !self.convergence.has_converged()
871    }
872
873    /// Check if training has converged
874    pub fn has_converged(&self) -> bool {
875        self.convergence.has_converged()
876    }
877
878    /// Get the current round number
879    pub fn current_round(&self) -> usize {
880        self.current_round
881    }
882
883    /// Get the total number of rounds
884    pub fn total_rounds(&self) -> usize {
885        self.rounds.len()
886    }
887
888    /// Get round information
889    pub fn get_round(&self, round_num: usize) -> Option<&FederatedRound> {
890        self.rounds.get(round_num)
891    }
892
893    /// Get the latest loss
894    pub fn latest_loss(&self) -> Option<f64> {
895        self.convergence.latest_loss()
896    }
897
898    /// Get max rounds
899    pub fn max_rounds(&self) -> usize {
900        self.max_rounds
901    }
902}
903
904// ── ModelUpdate ────────────────────────────────────────────────────────────
905
906/// A model-update announcement broadcast via GossipSub.
907#[derive(Debug, Clone, Serialize, Deserialize)]
908pub struct ModelUpdate {
909    /// Peer ID of the originating node
910    pub peer_id: String,
911    /// Content-addressed identifier of the updated model
912    pub model_cid: String,
913    /// Federated round this update belongs to
914    pub round_id: u32,
915    /// Unix epoch timestamp in milliseconds
916    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    /// Create a new `ModelUpdate` stamped with the current wall-clock time.
928    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
938// ── GossipModelSync ────────────────────────────────────────────────────────
939
940/// GossipSub-based model synchronisation protocol.
941///
942/// Broadcasts `ModelUpdate` announcements via a shared in-process channel so
943/// that tests can exercise the full send/receive/verify loop without a live
944/// libp2p swarm.
945pub struct GossipModelSync {
946    local_peer_id: String,
947    /// Sink for outbound gossip messages (shared with test harness or real transport).
948    tx: tokio::sync::broadcast::Sender<ModelUpdate>,
949    /// Source for inbound gossip messages.
950    rx: tokio::sync::broadcast::Receiver<ModelUpdate>,
951}
952
953impl GossipModelSync {
954    /// Create a new `GossipModelSync` backed by a broadcast channel of capacity 64.
955    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    /// Create a second endpoint subscribed to the same broadcast channel (for tests).
965    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    /// Broadcast a local model-update CID to all mesh peers.
974    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    /// Collect model-update CIDs from peers for up to `timeout_ms` milliseconds.
983    ///
984    /// Returns as soon as `expected_peers` distinct peer updates are received or
985    /// the timeout expires.
986    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    /// Verify that a `ModelUpdate` has not been tampered with.
1022    ///
1023    /// The current implementation checks that `model_cid` is a non-empty, valid
1024    /// CIDv1 or CIDv0 string and that `peer_id` is non-empty.  Tampered updates
1025    /// with an empty or whitespace-only CID fail verification.
1026    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        // Accept anything that parses as a CID via the `cid` crate, or any
1035        // non-empty hex/base58/base32 string that a real IPFS node would emit.
1036        // For the test harness, valid CIDs start with "Qm", "bafy", or any
1037        // base-encoded prefix; tampered CIDs are typically empty strings.
1038        cid.parse::<cid::Cid>().is_ok() || (!cid.contains('\0') && cid.len() >= 4)
1039    }
1040}
1041
1042// ── DistributedGradientAccumulator ────────────────────────────────────────
1043
1044/// Accumulates gradients from distributed peers via content-addressed storage.
1045///
1046/// Workflow:
1047/// 1. Call `commit_local` to serialize and store the local gradient, obtaining
1048///    a [`Cid`] that can be broadcast to peers.
1049/// 2. Call `add_peer_gradient` for each CID received from a peer.  The
1050///    accumulator fetches the raw bytes from the `BlockStore`, decodes them
1051///    via Arrow IPC, and caches the result.
1052/// 3. Once `is_ready` returns `true`, call `aggregate` to run FedAvg
1053///    over all collected gradients (local + peers).
1054pub struct DistributedGradientAccumulator {
1055    session_id: String,
1056    /// The local node's gradient, set by `commit_local`.
1057    pub local_gradient: Vec<f32>,
1058    /// Gradients received from peers, keyed by peer ID.
1059    pub peer_gradients: std::collections::HashMap<String, Vec<f32>>,
1060    config: BackwardPassConfig,
1061}
1062
1063impl DistributedGradientAccumulator {
1064    /// Create a new accumulator for the given session.
1065    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    /// Store the local gradient as Arrow IPC bytes in `store`, returning its CID.
1075    ///
1076    /// The CID is computed as a raw-block CID (SHA-256) over the Arrow IPC bytes.
1077    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        // Build a content-addressed block and store it.
1088        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    /// Fetch and decode a peer's gradient from `store` using its CID.
1102    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    /// Aggregate all collected gradients using the configured [`AggregationMethod`].
1122    ///
1123    /// Includes the local gradient plus all peer gradients collected so far.
1124    /// The aggregation strategy is taken from [`BackwardPassConfig::aggregation`].
1125    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    /// Returns `true` when at least `min_peers` peer gradients have been collected.
1157    pub fn is_ready(&self, min_peers: usize) -> bool {
1158        self.peer_gradients.len() >= min_peers
1159    }
1160
1161    /// The session identifier passed at construction time.
1162    pub fn session_id(&self) -> &str {
1163        &self.session_id
1164    }
1165
1166    /// Number of peer gradients collected so far (not counting local).
1167    pub fn peer_count(&self) -> usize {
1168        self.peer_gradients.len()
1169    }
1170}
1171
1172// ── New federated infrastructure tests ───────────────────────────────────
1173
1174#[cfg(test)]
1175mod federated_v2_tests {
1176    use super::*;
1177
1178    // ── ConvergenceDetector (EMA mode) ───────────────────────────────────
1179
1180    #[test]
1181    fn test_convergence_detector_converges() {
1182        // Use a larger window so the small-delta region has room to accumulate.
1183        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        // Large decreases first, then nearly-flat tail (delta ≈ 0.001 < threshold 0.01)
1192        let losses: Vec<f32> = {
1193            let mut v = vec![10.0_f32, 5.0, 2.0, 1.0, 0.5];
1194            // Flat tail: 12 rounds of near-zero change
1195            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        // Oscillating losses — large deltas, never converges
1224        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        // Flat loss → tiny deltas, plateau should be detected
1246        for _ in 0..10 {
1247            det.update(0.5_f32);
1248        }
1249        // After many flat updates the plateau counter should exceed patience
1250        assert!(
1251            det.plateau_rounds() >= 3,
1252            "plateau_rounds={} should be >= patience=3",
1253            det.plateau_rounds()
1254        );
1255    }
1256
1257    // ── DifferentialPrivacy (new stateless helpers) ─────────────────────
1258
1259    #[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        // Expected σ = sensitivity * sqrt(2*ln(1.25/δ)) / ε
1269        let ln_term = (1.25 / delta).ln();
1270        let expected_sigma = (sensitivity as f64) * (2.0 * ln_term).sqrt() / epsilon;
1271
1272        // Generate many samples and check empirical std-dev ≈ expected_sigma
1273        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        // Allow 40% relative error (statistical fluctuation in 1000 samples)
1281        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]; // L2 norm = 5.0
1293        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        // epsilon=1.0, each round costs ε/100 = 0.01 → 100 rounds to exhaust
1305        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    // ── FederatedRound (session mode) ────────────────────────────────────
1312
1313    #[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        // bob and carol are missing
1334
1335        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        // FedAvg: ([1,2,3]+[3,4,5]+[5,6,7]) / 3 = [3,4,5]
1361        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    // ── GossipModelSync / ModelUpdate ────────────────────────────────────
1367
1368    #[tokio::test]
1369    async fn test_model_update_verify() {
1370        let sync = GossipModelSync::new("peer-local");
1371
1372        // Valid CID (use a real base32 CIDv1 string produced by the cid crate)
1373        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        // Tampered: empty CID
1383        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        // Tampered: whitespace-only CID
1392        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}