use ipfrs_core::Cid;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use super::arrow_ipc::{load_gradient_from_arrow, store_gradient_as_arrow};
use super::backward_pass::{federated_average, AggregationMethod, BackwardPassConfig};
use super::tensor::GradientAggregator;
use super::GradientError;
#[derive(Debug, thiserror::Error)]
pub enum FederatedError {
#[error("No contributions available to aggregate")]
NoContributions,
#[error("Dimension mismatch between client gradients")]
DimensionMismatch,
#[error("Client not found: {0}")]
ClientNotFound(String),
#[error("Round not started")]
NotStarted,
}
#[derive(Debug, Clone, Copy)]
pub struct PrivacyBudget {
pub epsilon: f64,
pub delta: f64,
pub remaining_epsilon: f64,
}
impl PrivacyBudget {
pub fn new(epsilon: f64, delta: f64) -> Self {
Self {
epsilon,
delta,
remaining_epsilon: epsilon,
}
}
pub fn consume(&mut self, epsilon_used: f64) -> Result<(), GradientError> {
if epsilon_used > self.remaining_epsilon {
return Err(GradientError::InvalidGradient(format!(
"Insufficient privacy budget: need {}, have {}",
epsilon_used, self.remaining_epsilon
)));
}
self.remaining_epsilon -= epsilon_used;
Ok(())
}
pub fn is_exhausted(&self) -> bool {
self.remaining_epsilon <= 0.0
}
pub fn remaining_fraction(&self) -> f64 {
self.remaining_epsilon / self.epsilon
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum DPMechanism {
Gaussian,
Laplacian,
}
pub struct DifferentialPrivacy {
budget: PrivacyBudget,
sensitivity: f64,
mechanism: DPMechanism,
}
impl DifferentialPrivacy {
pub fn new(epsilon: f64, delta: f64, sensitivity: f64, mechanism: DPMechanism) -> Self {
Self {
budget: PrivacyBudget::new(epsilon, delta),
sensitivity,
mechanism,
}
}
pub fn add_gaussian_noise(&mut self, gradient: &mut [f32]) -> Result<(), GradientError> {
use rand::RngExt;
if self.budget.is_exhausted() {
return Err(GradientError::InvalidGradient(
"Privacy budget exhausted".to_string(),
));
}
let ln_term = (1.25 / self.budget.delta).ln();
let sigma = self.sensitivity * (2.0 * ln_term).sqrt() / self.budget.epsilon;
let mut rng = rand::rng();
for v in gradient.iter_mut() {
let noise: f64 = rng.random_range(-1.0..1.0);
let gaussian_noise = sigma * noise;
*v += gaussian_noise as f32;
}
self.budget.consume(self.budget.epsilon / 100.0)?;
Ok(())
}
pub fn add_laplacian_noise(&mut self, gradient: &mut [f32]) -> Result<(), GradientError> {
use rand::RngExt;
if self.budget.is_exhausted() {
return Err(GradientError::InvalidGradient(
"Privacy budget exhausted".to_string(),
));
}
let scale = self.sensitivity / self.budget.epsilon;
let mut rng = rand::rng();
for v in gradient.iter_mut() {
let u: f64 = rng.random_range(-0.5..0.5);
let laplacian_noise = -scale * u.signum() * (1.0 - 2.0 * u.abs()).ln();
*v += laplacian_noise as f32;
}
self.budget.consume(self.budget.epsilon / 100.0)?;
Ok(())
}
pub fn apply_dp_sgd(
&mut self,
gradient: &mut [f32],
clip_norm: f32,
) -> Result<(), GradientError> {
use super::tensor::GradientVerifier;
GradientVerifier::clip_by_norm(gradient, clip_norm);
match self.mechanism {
DPMechanism::Gaussian => self.add_gaussian_noise(gradient)?,
DPMechanism::Laplacian => self.add_laplacian_noise(gradient)?,
}
Ok(())
}
pub fn remaining_budget(&self) -> f64 {
self.budget.remaining_epsilon
}
pub fn is_budget_exhausted(&self) -> bool {
self.budget.is_exhausted()
}
pub fn get_privacy_params(&self) -> (f64, f64) {
(self.budget.epsilon, self.budget.delta)
}
pub fn calculate_noise_multiplier(epsilon: f64, delta: f64, sensitivity: f64) -> f64 {
let ln_term = (1.25 / delta).ln();
sensitivity * (2.0 * ln_term).sqrt() / epsilon
}
pub fn add_gaussian_noise_sens(&self, gradient: &mut [f32], sensitivity: f32) {
use rand::RngExt;
use std::f64::consts::PI;
let ln_term = (1.25 / self.budget.delta).ln();
let sigma = (sensitivity as f64) * (2.0 * ln_term).sqrt() / self.budget.epsilon;
let mut rng = rand::rng();
let mut i = 0;
while i < gradient.len() {
let u1: f64 = rng.random_range(1e-12_f64..1.0_f64);
let u2: f64 = rng.random_range(0.0_f64..1.0_f64);
let mag = sigma * (-2.0 * u1.ln()).sqrt();
let z0 = mag * (2.0 * PI * u2).cos();
let z1 = mag * (2.0 * PI * u2).sin();
gradient[i] += z0 as f32;
i += 1;
if i < gradient.len() {
gradient[i] += z1 as f32;
i += 1;
}
}
}
pub fn add_laplace_noise_sens(&self, gradient: &mut [f32], sensitivity: f32) {
use rand::RngExt;
let scale = (sensitivity as f64) / self.budget.epsilon;
let mut rng = rand::rng();
for v in gradient.iter_mut() {
let u: f64 = rng.random_range(1e-12_f64..1.0_f64 - 1e-12_f64);
let p = u - 0.5;
let laplace = -scale * p.signum() * (1.0 - 2.0 * p.abs()).ln();
*v += laplace as f32;
}
}
pub fn clip_l2(&self, gradient: &mut [f32], max_norm: f32) {
let norm: f32 = gradient.iter().map(|&x| x * x).sum::<f32>().sqrt();
if norm > max_norm && norm > 0.0 {
let scale = max_norm / norm;
for x in gradient.iter_mut() {
*x *= scale;
}
}
}
pub fn remaining_budget_after(&self, rounds_used: u32) -> f32 {
let per_round = self.budget.epsilon / 100.0;
let used = per_round * rounds_used as f64;
(self.budget.epsilon - used).max(0.0) as f32
}
pub fn is_exhausted_after(&self, rounds_used: u32) -> bool {
self.remaining_budget_after(rounds_used) <= 0.0
}
}
pub struct SecureAggregation {
min_participants: usize,
participant_count: usize,
}
impl SecureAggregation {
pub fn new(min_participants: usize) -> Self {
Self {
min_participants,
participant_count: 0,
}
}
pub fn add_participant(&mut self) {
self.participant_count += 1;
}
pub fn can_aggregate(&self) -> bool {
self.participant_count >= self.min_participants
}
pub fn aggregate_secure(&self, gradients: &[Vec<f32>]) -> Result<Vec<f32>, GradientError> {
if !self.can_aggregate() {
return Err(GradientError::InvalidGradient(format!(
"Not enough participants: need {}, have {}",
self.min_participants, self.participant_count
)));
}
GradientAggregator::average(gradients)
}
pub fn reset(&mut self) {
self.participant_count = 0;
}
pub fn participant_count(&self) -> usize {
self.participant_count
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClientState {
Idle,
Training,
Completed,
Failed,
}
#[derive(Debug, Clone)]
pub struct ClientInfo {
pub client_id: String,
pub state: ClientState,
pub sample_count: usize,
pub last_update: i64,
}
impl ClientInfo {
pub fn new(client_id: String, sample_count: usize) -> Self {
Self {
client_id,
state: ClientState::Idle,
sample_count,
last_update: chrono::Utc::now().timestamp(),
}
}
pub fn start_training(&mut self) {
self.state = ClientState::Training;
self.last_update = chrono::Utc::now().timestamp();
}
pub fn complete_training(&mut self) {
self.state = ClientState::Completed;
self.last_update = chrono::Utc::now().timestamp();
}
pub fn mark_failed(&mut self) {
self.state = ClientState::Failed;
self.last_update = chrono::Utc::now().timestamp();
}
}
#[derive(Debug, Clone)]
pub struct RoundStats {
pub round_id: u32,
pub participants: usize,
pub missing_clients: Vec<String>,
pub duration_ms: u64,
pub converged: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FederatedRound {
pub round_num: usize,
pub client_count: usize,
#[serde(serialize_with = "crate::serialize_cid")]
#[serde(deserialize_with = "crate::deserialize_cid")]
pub global_model: Cid,
pub aggregated_gradient: Option<Vec<f32>>,
pub start_time: i64,
pub end_time: Option<i64>,
pub completed_count: usize,
#[serde(default)]
pub round_id: u32,
#[serde(default)]
pub expected_clients: Vec<String>,
#[serde(skip)]
pub contributions: HashMap<String, Vec<f32>>,
#[serde(skip)]
session_start: Option<Instant>,
}
impl FederatedRound {
pub fn new(round_num: usize, global_model: Cid, client_count: usize) -> Self {
Self {
round_num,
client_count,
global_model,
aggregated_gradient: None,
start_time: chrono::Utc::now().timestamp(),
end_time: None,
completed_count: 0,
round_id: round_num as u32,
expected_clients: Vec::new(),
contributions: HashMap::new(),
session_start: None,
}
}
pub fn mark_client_completed(&mut self) {
self.completed_count += 1;
}
pub fn is_complete(&self) -> bool {
if !self.expected_clients.is_empty() {
self.expected_clients
.iter()
.all(|id| self.contributions.contains_key(id.as_str()))
} else {
self.completed_count >= self.client_count
}
}
pub fn complete(&mut self, aggregated_gradient: Vec<f32>) {
self.aggregated_gradient = Some(aggregated_gradient);
self.end_time = Some(chrono::Utc::now().timestamp());
}
pub fn duration(&self) -> Option<i64> {
self.end_time.map(|end| end - self.start_time)
}
pub fn start(round_id: u32, client_ids: Vec<String>) -> Self {
let count = client_ids.len();
Self {
round_num: round_id as usize,
client_count: count,
global_model: Cid::default(),
aggregated_gradient: None,
start_time: chrono::Utc::now().timestamp(),
end_time: None,
completed_count: 0,
round_id,
expected_clients: client_ids,
contributions: HashMap::new(),
session_start: Some(Instant::now()),
}
}
pub fn record_contribution(&mut self, client_id: &str, gradient: Vec<f32>) {
self.contributions.insert(client_id.to_string(), gradient);
}
pub fn aggregate(&self, dp: Option<&DifferentialPrivacy>) -> Result<Vec<f32>, FederatedError> {
if self.contributions.is_empty() {
return Err(FederatedError::NoContributions);
}
let grads: Vec<Vec<f32>> = self.contributions.values().cloned().collect();
let dim = grads[0].len();
if grads.iter().any(|g| g.len() != dim) {
return Err(FederatedError::DimensionMismatch);
}
let n = grads.len() as f32;
let mut agg = vec![0.0f32; dim];
for g in &grads {
for (a, &v) in agg.iter_mut().zip(g.iter()) {
*a += v / n;
}
}
if let Some(dp) = dp {
dp.add_gaussian_noise_sens(&mut agg, 1.0);
}
Ok(agg)
}
pub fn stats(&self) -> RoundStats {
let missing_clients: Vec<String> = self
.expected_clients
.iter()
.filter(|id| !self.contributions.contains_key(id.as_str()))
.cloned()
.collect();
let duration_ms = self
.session_start
.map(|t| t.elapsed().as_millis() as u64)
.unwrap_or(0);
RoundStats {
round_id: self.round_id,
participants: self.contributions.len(),
missing_clients,
duration_ms,
converged: false,
}
}
}
#[derive(Debug, Clone)]
pub struct ConvergenceConfig {
pub threshold: f32,
pub window_size: usize,
pub smoothing: f32,
pub patience: usize,
}
impl Default for ConvergenceConfig {
fn default() -> Self {
Self {
threshold: 1e-4,
window_size: 10,
smoothing: 0.9,
patience: 3,
}
}
}
pub struct ConvergenceDetector {
window_size: usize,
loss_history: Vec<f64>,
threshold: f64,
ema_loss: Option<f32>,
loss_window: std::collections::VecDeque<f32>,
plateau_count: usize,
config: Option<ConvergenceConfig>,
}
impl ConvergenceDetector {
pub fn new(window_size: usize, threshold: f64) -> Self {
Self {
window_size,
loss_history: Vec::new(),
threshold,
ema_loss: None,
loss_window: std::collections::VecDeque::new(),
plateau_count: 0,
config: None,
}
}
pub fn with_config(config: ConvergenceConfig) -> Self {
let window_size = config.window_size;
let threshold = config.threshold as f64;
Self {
window_size,
loss_history: Vec::new(),
threshold,
ema_loss: None,
loss_window: std::collections::VecDeque::with_capacity(window_size),
plateau_count: 0,
config: Some(config),
}
}
pub fn add_loss(&mut self, loss: f64) {
self.loss_history.push(loss);
if self.loss_history.len() > self.window_size {
self.loss_history.remove(0);
}
}
pub fn has_converged(&self) -> bool {
if self.loss_history.len() < self.window_size {
return false;
}
let recent = &self.loss_history[self.loss_history.len() - self.window_size..];
let mean = recent.iter().sum::<f64>() / recent.len() as f64;
if mean.abs() < 1e-10 {
return true;
}
let std_dev =
(recent.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / recent.len() as f64).sqrt();
std_dev / mean.abs() < self.threshold
}
pub fn latest_loss(&self) -> Option<f64> {
self.loss_history.last().copied()
}
pub fn history(&self) -> &[f64] {
&self.loss_history
}
pub fn update(&mut self, loss: f32) -> bool {
let cfg = match &self.config {
Some(c) => c.clone(),
None => ConvergenceConfig::default(),
};
self.ema_loss = Some(match self.ema_loss {
None => loss,
Some(ema) => cfg.smoothing * ema + (1.0 - cfg.smoothing) * loss,
});
if self.loss_window.len() >= cfg.window_size {
self.loss_window.pop_front();
}
self.loss_window.push_back(loss);
let norm = self.gradient_norm();
if norm < cfg.threshold {
self.plateau_count += 1;
} else {
self.plateau_count = 0;
}
self.plateau_count >= cfg.patience
}
pub fn smoothed_loss(&self) -> f32 {
self.ema_loss.unwrap_or(f32::NAN)
}
pub fn gradient_norm(&self) -> f32 {
if self.loss_window.len() < 2 {
return f32::MAX;
}
let deltas: Vec<f32> = self
.loss_window
.iter()
.zip(self.loss_window.iter().skip(1))
.map(|(a, b)| (b - a).abs())
.collect();
deltas.iter().sum::<f32>() / deltas.len() as f32
}
pub fn plateau_rounds(&self) -> usize {
self.plateau_count
}
pub fn reset(&mut self) {
self.loss_history.clear();
self.ema_loss = None;
self.loss_window.clear();
self.plateau_count = 0;
}
pub fn config(&self) -> Option<&ConvergenceConfig> {
self.config.as_ref()
}
}
pub struct ModelSyncProtocol {
current_round: usize,
max_rounds: usize,
min_clients_per_round: usize,
rounds: Vec<FederatedRound>,
convergence: ConvergenceDetector,
}
impl ModelSyncProtocol {
pub fn new(
max_rounds: usize,
min_clients_per_round: usize,
convergence_window: usize,
convergence_threshold: f64,
) -> Self {
Self {
current_round: 0,
max_rounds,
min_clients_per_round,
rounds: Vec::new(),
convergence: ConvergenceDetector::new(convergence_window, convergence_threshold),
}
}
pub fn start_round(
&mut self,
global_model: Cid,
client_count: usize,
) -> Result<usize, GradientError> {
if client_count < self.min_clients_per_round {
return Err(GradientError::InvalidGradient(format!(
"Not enough clients: need {}, got {}",
self.min_clients_per_round, client_count
)));
}
if self.current_round >= self.max_rounds {
return Err(GradientError::InvalidGradient(format!(
"Maximum rounds reached: {}",
self.max_rounds
)));
}
let round = FederatedRound::new(self.current_round, global_model, client_count);
self.rounds.push(round);
self.current_round += 1;
Ok(self.current_round - 1)
}
pub fn complete_round(
&mut self,
round_num: usize,
aggregated_gradient: Vec<f32>,
loss: f64,
) -> Result<(), GradientError> {
if round_num >= self.rounds.len() {
return Err(GradientError::InvalidGradient(format!(
"Invalid round number: {}",
round_num
)));
}
self.rounds[round_num].complete(aggregated_gradient);
self.convergence.add_loss(loss);
Ok(())
}
pub fn should_continue(&self) -> bool {
self.current_round < self.max_rounds && !self.convergence.has_converged()
}
pub fn has_converged(&self) -> bool {
self.convergence.has_converged()
}
pub fn current_round(&self) -> usize {
self.current_round
}
pub fn total_rounds(&self) -> usize {
self.rounds.len()
}
pub fn get_round(&self, round_num: usize) -> Option<&FederatedRound> {
self.rounds.get(round_num)
}
pub fn latest_loss(&self) -> Option<f64> {
self.convergence.latest_loss()
}
pub fn max_rounds(&self) -> usize {
self.max_rounds
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelUpdate {
pub peer_id: String,
pub model_cid: String,
pub round_id: u32,
pub timestamp_ms: u64,
}
impl ModelUpdate {
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
pub fn new(peer_id: String, model_cid: String, round_id: u32) -> Self {
Self {
peer_id,
model_cid,
round_id,
timestamp_ms: Self::now_ms(),
}
}
}
pub struct GossipModelSync {
local_peer_id: String,
tx: tokio::sync::broadcast::Sender<ModelUpdate>,
rx: tokio::sync::broadcast::Receiver<ModelUpdate>,
}
impl GossipModelSync {
pub fn new(local_peer_id: impl Into<String>) -> Self {
let (tx, rx) = tokio::sync::broadcast::channel(64);
Self {
local_peer_id: local_peer_id.into(),
tx,
rx,
}
}
pub fn subscribe(&self, peer_id: impl Into<String>) -> Self {
Self {
local_peer_id: peer_id.into(),
tx: self.tx.clone(),
rx: self.tx.subscribe(),
}
}
pub async fn broadcast_update(&self, model_cid: &str, round_id: u32) -> anyhow::Result<()> {
let update = ModelUpdate::new(self.local_peer_id.clone(), model_cid.to_string(), round_id);
self.tx
.send(update)
.map_err(|e| anyhow::anyhow!("broadcast send: {e}"))?;
Ok(())
}
pub async fn collect_updates(
&mut self,
expected_peers: usize,
timeout_ms: u64,
) -> anyhow::Result<Vec<ModelUpdate>> {
let mut collected: Vec<ModelUpdate> = Vec::new();
let deadline = tokio::time::Instant::now() + std::time::Duration::from_millis(timeout_ms);
while collected
.iter()
.filter(|u| u.peer_id != self.local_peer_id)
.count()
< expected_peers
{
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
break;
}
match tokio::time::timeout(remaining, self.rx.recv()).await {
Ok(Ok(update)) => {
if update.peer_id != self.local_peer_id {
collected.push(update);
}
}
Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(n))) => {
tracing::warn!("GossipModelSync: lagged by {n} messages");
}
Ok(Err(tokio::sync::broadcast::error::RecvError::Closed)) => break,
Err(_timeout) => break,
}
}
Ok(collected)
}
pub fn verify_update(&self, update: &ModelUpdate) -> bool {
if update.peer_id.trim().is_empty() {
return false;
}
let cid = update.model_cid.trim();
if cid.is_empty() {
return false;
}
cid.parse::<cid::Cid>().is_ok() || (!cid.contains('\0') && cid.len() >= 4)
}
}
pub struct DistributedGradientAccumulator {
session_id: String,
pub local_gradient: Vec<f32>,
pub peer_gradients: std::collections::HashMap<String, Vec<f32>>,
config: BackwardPassConfig,
}
impl DistributedGradientAccumulator {
pub fn new(session_id: &str, config: BackwardPassConfig) -> Self {
Self {
session_id: session_id.to_string(),
local_gradient: Vec::new(),
peer_gradients: std::collections::HashMap::new(),
config,
}
}
pub async fn commit_local(
&mut self,
grad: Vec<f32>,
store: &dyn ipfrs_storage::traits::BlockStore,
) -> anyhow::Result<ipfrs_core::Cid> {
use ipfrs_core::Block;
let ipc_bytes =
store_gradient_as_arrow(&grad).map_err(|e| anyhow::anyhow!("Arrow IPC encode: {e}"))?;
let block = Block::new(bytes::Bytes::from(ipc_bytes.clone()))
.map_err(|e| anyhow::anyhow!("Block creation: {e}"))?;
let cid = block.cid();
store
.put(&block)
.await
.map_err(|e| anyhow::anyhow!("BlockStore put: {e}"))?;
self.local_gradient = grad;
Ok(*cid)
}
pub async fn add_peer_gradient(
&mut self,
peer_id: &str,
cid: &ipfrs_core::Cid,
store: &dyn ipfrs_storage::traits::BlockStore,
) -> anyhow::Result<()> {
let block = store
.get(cid)
.await
.map_err(|e| anyhow::anyhow!("BlockStore get: {e}"))?
.ok_or_else(|| anyhow::anyhow!("Block not found for CID {cid}"))?;
let grad = load_gradient_from_arrow(block.data())
.map_err(|e| anyhow::anyhow!("Arrow IPC decode: {e}"))?;
self.peer_gradients.insert(peer_id.to_string(), grad);
Ok(())
}
pub fn aggregate(&self) -> Result<Vec<f32>, GradientError> {
if self.local_gradient.is_empty() {
return Err(GradientError::EmptyGradients);
}
let mut all: Vec<Vec<f32>> = Vec::with_capacity(1 + self.peer_gradients.len());
all.push(self.local_gradient.clone());
all.extend(self.peer_gradients.values().cloned());
match &self.config.aggregation {
AggregationMethod::Sum => {
let dim = all[0].len();
if all.iter().any(|g| g.len() != dim) {
return Err(GradientError::DimensionMismatch);
}
let mut sum = vec![0.0f32; dim];
for grad in &all {
for (a, &g) in sum.iter_mut().zip(grad.iter()) {
*a += g;
}
}
Ok(sum)
}
AggregationMethod::Mean | AggregationMethod::FedAvg => federated_average(&all),
AggregationMethod::WeightedMean { weights } => {
let w: Vec<f32> = weights.clone();
GradientAggregator::weighted_average(&all, &w)
}
}
}
pub fn is_ready(&self, min_peers: usize) -> bool {
self.peer_gradients.len() >= min_peers
}
pub fn session_id(&self) -> &str {
&self.session_id
}
pub fn peer_count(&self) -> usize {
self.peer_gradients.len()
}
}
#[cfg(test)]
mod federated_v2_tests {
use super::*;
#[test]
fn test_convergence_detector_converges() {
let cfg = ConvergenceConfig {
threshold: 0.01,
window_size: 4,
smoothing: 0.9,
patience: 3,
};
let mut det = ConvergenceDetector::with_config(cfg);
let losses: Vec<f32> = {
let mut v = vec![10.0_f32, 5.0, 2.0, 1.0, 0.5];
for i in 0..12 {
v.push(0.5 - (i as f32) * 0.001);
}
v
};
let mut converged = false;
for &l in &losses {
if det.update(l) {
converged = true;
break;
}
}
assert!(
converged,
"should converge on rapidly decreasing then flat loss"
);
}
#[test]
fn test_convergence_detector_no_convergence() {
let cfg = ConvergenceConfig {
threshold: 0.01,
window_size: 5,
smoothing: 0.9,
patience: 3,
};
let mut det = ConvergenceDetector::with_config(cfg);
let losses = [1.0_f32, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0];
let mut converged = false;
for &l in &losses {
if det.update(l) {
converged = true;
break;
}
}
assert!(!converged, "should NOT converge on oscillating loss");
}
#[test]
fn test_convergence_detector_plateau() {
let cfg = ConvergenceConfig {
threshold: 0.1,
window_size: 4,
smoothing: 0.9,
patience: 3,
};
let mut det = ConvergenceDetector::with_config(cfg);
for _ in 0..10 {
det.update(0.5_f32);
}
assert!(
det.plateau_rounds() >= 3,
"plateau_rounds={} should be >= patience=3",
det.plateau_rounds()
);
}
#[test]
fn test_dp_gaussian_noise_scale() {
let epsilon = 1.0_f64;
let delta = 1e-5_f64;
let sensitivity = 1.0_f32;
let dp =
DifferentialPrivacy::new(epsilon, delta, sensitivity as f64, DPMechanism::Gaussian);
let ln_term = (1.25 / delta).ln();
let expected_sigma = (sensitivity as f64) * (2.0 * ln_term).sqrt() / epsilon;
let mut base = vec![0.0_f32; 1000];
dp.add_gaussian_noise_sens(&mut base, sensitivity);
let mean = base.iter().sum::<f32>() / base.len() as f32;
let variance = base.iter().map(|&x| (x - mean).powi(2)).sum::<f32>() / base.len() as f32;
let std_dev = variance.sqrt() as f64;
let rel_err = (std_dev - expected_sigma).abs() / expected_sigma;
assert!(
rel_err < 0.40,
"empirical σ={std_dev:.4} expected σ={expected_sigma:.4} rel_err={rel_err:.4}"
);
}
#[test]
fn test_dp_clip_l2_norm() {
let dp = DifferentialPrivacy::new(1.0, 1e-5, 1.0, DPMechanism::Gaussian);
let mut grad = vec![3.0_f32, 4.0]; dp.clip_l2(&mut grad, 2.5);
let norm: f32 = grad.iter().map(|&x| x * x).sum::<f32>().sqrt();
assert!(
(norm - 2.5).abs() < 1e-4,
"clipped norm {norm} should be ≈ 2.5"
);
}
#[test]
fn test_dp_budget_exhaustion() {
let dp = DifferentialPrivacy::new(1.0, 1e-5, 1.0, DPMechanism::Gaussian);
assert!(!dp.is_exhausted_after(99));
assert!(dp.is_exhausted_after(100));
assert!(dp.is_exhausted_after(200));
}
#[test]
fn test_federated_round_complete() {
let clients = vec!["alice".to_string(), "bob".to_string(), "carol".to_string()];
let mut round = FederatedRound::start(1, clients);
assert!(!round.is_complete(), "not complete before contributions");
round.record_contribution("alice", vec![1.0, 2.0]);
round.record_contribution("bob", vec![3.0, 4.0]);
round.record_contribution("carol", vec![5.0, 6.0]);
assert!(round.is_complete(), "complete after all clients contribute");
}
#[test]
fn test_federated_round_missing_client() {
let clients = vec!["alice".to_string(), "bob".to_string(), "carol".to_string()];
let mut round = FederatedRound::start(2, clients);
round.record_contribution("alice", vec![1.0, 2.0]);
let stats = round.stats();
assert!(
!stats.missing_clients.is_empty(),
"missing_clients should be non-empty"
);
assert!(
stats.missing_clients.contains(&"bob".to_string()),
"bob should be missing"
);
assert!(
stats.missing_clients.contains(&"carol".to_string()),
"carol should be missing"
);
}
#[test]
fn test_federated_aggregate_fedavg() {
let clients = vec!["a".to_string(), "b".to_string(), "c".to_string()];
let mut round = FederatedRound::start(3, clients);
round.record_contribution("a", vec![1.0_f32, 2.0, 3.0]);
round.record_contribution("b", vec![3.0_f32, 4.0, 5.0]);
round.record_contribution("c", vec![5.0_f32, 6.0, 7.0]);
let agg = round.aggregate(None).expect("aggregate");
assert!((agg[0] - 3.0).abs() < 1e-4, "agg[0]={}", agg[0]);
assert!((agg[1] - 4.0).abs() < 1e-4, "agg[1]={}", agg[1]);
assert!((agg[2] - 5.0).abs() < 1e-4, "agg[2]={}", agg[2]);
}
#[tokio::test]
async fn test_model_update_verify() {
let sync = GossipModelSync::new("peer-local");
let valid_cid = "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku";
let valid_update = ModelUpdate {
peer_id: "peer-remote".to_string(),
model_cid: valid_cid.to_string(),
round_id: 1,
timestamp_ms: 0,
};
assert!(sync.verify_update(&valid_update), "valid CID should pass");
let tampered = ModelUpdate {
peer_id: "peer-remote".to_string(),
model_cid: "".to_string(),
round_id: 1,
timestamp_ms: 0,
};
assert!(!sync.verify_update(&tampered), "empty CID should fail");
let tampered2 = ModelUpdate {
peer_id: "peer-remote".to_string(),
model_cid: " ".to_string(),
round_id: 1,
timestamp_ms: 0,
};
assert!(
!sync.verify_update(&tampered2),
"whitespace CID should fail"
);
}
#[tokio::test]
async fn test_gossip_broadcast_and_collect() {
let sender = GossipModelSync::new("sender");
let mut receiver = sender.subscribe("receiver");
let cid = "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku";
sender.broadcast_update(cid, 1).await.expect("broadcast");
let updates = receiver
.collect_updates(1, 200)
.await
.expect("collect_updates");
assert_eq!(updates.len(), 1, "should receive 1 update");
assert_eq!(updates[0].model_cid, cid);
assert_eq!(updates[0].round_id, 1);
}
}