Skip to main content

ipfrs_tensorlogic/
distributed_optimizer.rs

1//! Distributed gradient optimizer — coordinates gradient aggregation across
2//! multiple distributed training workers with staleness handling,
3//! compression-friendly interfaces, and fault-tolerant worker management.
4//!
5//! # Overview
6//!
7//! [`DistributedOptimizer`] is the central coordinator. Workers register,
8//! submit gradient updates for named layers, and the optimizer aggregates
9//! them according to the chosen [`AggregationStrategy`].
10//!
11//! Four aggregation strategies are supported:
12//!
13//! - **Synchronous** — classical barrier-based: every active worker must
14//!   submit before aggregation proceeds.
15//! - **Asynchronous** — accepts updates within a configurable staleness
16//!   window; stale updates are rejected at submission time.
17//! - **FederatedAverage** — aggregates as soon as at least `rounds` updates
18//!   have been collected for a layer.
19//! - **GossipAverage** — aggregates as soon as at least `fanout` updates are
20//!   available, mimicking gossip protocol averaging.
21
22use std::collections::HashMap;
23use std::fmt;
24
25// ─────────────────────────────────────────────────────────────────────────────
26// Public types
27// ─────────────────────────────────────────────────────────────────────────────
28
29/// Opaque identifier for a training worker.
30#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
31pub struct WorkerId(pub String);
32
33impl WorkerId {
34    /// Create a new [`WorkerId`] from any string-like value.
35    pub fn new(id: impl Into<String>) -> Self {
36        Self(id.into())
37    }
38}
39
40impl fmt::Display for WorkerId {
41    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42        f.write_str(&self.0)
43    }
44}
45
46// ─────────────────────────────────────────────────────────────────────────────
47// Gradient update
48// ─────────────────────────────────────────────────────────────────────────────
49
50/// A single gradient update submitted by one worker for one named layer.
51#[derive(Debug, Clone)]
52pub struct GradientUpdate {
53    /// The worker that produced this update.
54    pub worker_id: WorkerId,
55    /// The layer these gradients belong to.
56    pub layer_id: String,
57    /// Gradient values (one per parameter in the layer).
58    pub gradients: Vec<f64>,
59    /// The training step at which these gradients were computed.
60    pub step: u64,
61    /// Wall-clock timestamp (milliseconds since epoch, or any monotonic
62    /// counter) at which the update was created.
63    pub timestamp: u64,
64}
65
66// ─────────────────────────────────────────────────────────────────────────────
67// Aggregation strategies
68// ─────────────────────────────────────────────────────────────────────────────
69
70/// Determines how and when pending updates are aggregated.
71#[derive(Debug, Clone)]
72pub enum AggregationStrategy {
73    /// Classic barrier: all active workers must submit at the current step
74    /// before any aggregation can proceed.
75    Synchronous,
76
77    /// Asynchronous aggregation: updates within `staleness_threshold` steps
78    /// of the current step are accepted; older updates are rejected.
79    Asynchronous {
80        /// Maximum number of steps behind the global step that a worker may be.
81        staleness_threshold: u64,
82    },
83
84    /// Aggregate every time at least `rounds` worker updates have accumulated
85    /// for a layer (federated learning style).
86    FederatedAverage {
87        /// Minimum number of updates needed before aggregation.
88        rounds: u32,
89    },
90
91    /// Aggregate when at least `fanout` updates are available (gossip style).
92    GossipAverage {
93        /// Minimum number of updates needed before aggregation.
94        fanout: usize,
95    },
96}
97
98// ─────────────────────────────────────────────────────────────────────────────
99// Output types
100// ─────────────────────────────────────────────────────────────────────────────
101
102/// The result of aggregating gradient updates for a single layer.
103#[derive(Debug, Clone)]
104pub struct AggregatedGradient {
105    /// The layer these gradients belong to.
106    pub layer_id: String,
107    /// Element-wise mean of all contributing worker gradients.
108    pub values: Vec<f64>,
109    /// How many workers contributed to this aggregation.
110    pub contributing_workers: usize,
111    /// The global step at which this aggregation was produced.
112    pub step: u64,
113}
114
115// ─────────────────────────────────────────────────────────────────────────────
116// Worker state
117// ─────────────────────────────────────────────────────────────────────────────
118
119/// Liveness and bookkeeping state for a single registered worker.
120#[derive(Debug, Clone)]
121pub struct WorkerState {
122    /// The worker's identifier.
123    pub worker_id: WorkerId,
124    /// The most recent training step for which this worker has submitted an
125    /// update.
126    pub latest_step: u64,
127    /// Wall-clock timestamp of the most recent update from this worker.
128    pub last_seen: u64,
129    /// Whether the worker is currently considered active.
130    pub active: bool,
131    /// Cumulative number of updates submitted since registration.
132    pub total_updates: u64,
133}
134
135// ─────────────────────────────────────────────────────────────────────────────
136// Error type
137// ─────────────────────────────────────────────────────────────────────────────
138
139/// Errors that can arise from [`DistributedOptimizer`] operations.
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub enum OptimizerDistError {
142    /// The worker is not registered with this optimizer.
143    WorkerNotFound(String),
144    /// The submitted update is too far behind the current global step.
145    StaleUpdate {
146        worker: String,
147        step: u64,
148        current: u64,
149    },
150    /// The gradient dimensions do not match the expected layer width.
151    DimensionMismatch {
152        layer: String,
153        expected: usize,
154        got: usize,
155    },
156}
157
158impl fmt::Display for OptimizerDistError {
159    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
160        match self {
161            Self::WorkerNotFound(id) => write!(f, "worker not found: {id}"),
162            Self::StaleUpdate {
163                worker,
164                step,
165                current,
166            } => {
167                write!(
168                    f,
169                    "stale update from worker {worker}: update step {step}, current step {current}"
170                )
171            }
172            Self::DimensionMismatch {
173                layer,
174                expected,
175                got,
176            } => {
177                write!(
178                    f,
179                    "dimension mismatch for layer {layer}: expected {expected}, got {got}"
180                )
181            }
182        }
183    }
184}
185
186impl std::error::Error for OptimizerDistError {}
187
188// ─────────────────────────────────────────────────────────────────────────────
189// Statistics snapshot
190// ─────────────────────────────────────────────────────────────────────────────
191
192/// A point-in-time snapshot of optimizer statistics.
193#[derive(Debug, Clone)]
194pub struct DistOptimizerStats {
195    pub total_workers: usize,
196    pub active_workers: usize,
197    pub current_step: u64,
198    pub total_aggregations: u64,
199    pub dropped_updates: u64,
200    pub pending_layers: usize,
201}
202
203// ─────────────────────────────────────────────────────────────────────────────
204// Core optimizer struct
205// ─────────────────────────────────────────────────────────────────────────────
206
207/// Coordinator for distributed gradient aggregation across multiple workers.
208pub struct DistributedOptimizer {
209    /// Aggregation strategy that governs when and how updates are combined.
210    pub strategy: AggregationStrategy,
211    /// Registered workers indexed by their identifier.
212    workers: HashMap<WorkerId, WorkerState>,
213    /// Pending gradient updates indexed by layer ID.
214    pending: HashMap<String, Vec<GradientUpdate>>,
215    /// Most recently aggregated gradients indexed by layer ID.
216    aggregated: HashMap<String, AggregatedGradient>,
217    /// The global training step counter.
218    pub current_step: u64,
219    /// Number of updates that were rejected (staleness or other).
220    pub dropped_updates: u64,
221    /// Cumulative number of successful aggregations.
222    total_aggregations: u64,
223}
224
225impl DistributedOptimizer {
226    // ─────────────────────────────────────────────────────────────
227    // Construction
228    // ─────────────────────────────────────────────────────────────
229
230    /// Create a new optimizer with the given aggregation strategy.
231    pub fn new(strategy: AggregationStrategy) -> Self {
232        Self {
233            strategy,
234            workers: HashMap::new(),
235            pending: HashMap::new(),
236            aggregated: HashMap::new(),
237            current_step: 0,
238            dropped_updates: 0,
239            total_aggregations: 0,
240        }
241    }
242
243    // ─────────────────────────────────────────────────────────────
244    // Worker lifecycle
245    // ─────────────────────────────────────────────────────────────
246
247    /// Register a worker.
248    ///
249    /// Returns `true` if the worker was newly registered, `false` if it was
250    /// already present (even if inactive).
251    pub fn register_worker(&mut self, worker_id: WorkerId) -> bool {
252        if self.workers.contains_key(&worker_id) {
253            return false;
254        }
255        self.workers.insert(
256            worker_id.clone(),
257            WorkerState {
258                worker_id,
259                latest_step: 0,
260                last_seen: 0,
261                active: true,
262                total_updates: 0,
263            },
264        );
265        true
266    }
267
268    /// Mark a worker as inactive (soft-deregister).
269    ///
270    /// Returns `true` if the worker existed and was marked inactive,
271    /// `false` if it was not found.
272    pub fn deregister_worker(&mut self, worker_id: &WorkerId) -> bool {
273        match self.workers.get_mut(worker_id) {
274            Some(state) => {
275                state.active = false;
276                true
277            }
278            None => false,
279        }
280    }
281
282    // ─────────────────────────────────────────────────────────────
283    // Update submission
284    // ─────────────────────────────────────────────────────────────
285
286    /// Submit a gradient update from a worker.
287    ///
288    /// # Errors
289    ///
290    /// - [`OptimizerDistError::WorkerNotFound`] if the worker is not registered.
291    /// - [`OptimizerDistError::StaleUpdate`] for async strategy when the
292    ///   update is too far behind the global step.
293    /// - [`OptimizerDistError::DimensionMismatch`] when the gradient dimension
294    ///   conflicts with an already-pending update for the same layer.
295    pub fn submit_update(
296        &mut self,
297        update: GradientUpdate,
298        now: u64,
299    ) -> Result<(), OptimizerDistError> {
300        // Validate worker is registered (active or not).
301        if !self.workers.contains_key(&update.worker_id) {
302            return Err(OptimizerDistError::WorkerNotFound(
303                update.worker_id.0.clone(),
304            ));
305        }
306
307        // Staleness check for async strategy.
308        if let AggregationStrategy::Asynchronous {
309            staleness_threshold,
310        } = self.strategy
311        {
312            let staleness = self.current_step.saturating_sub(update.step);
313            if staleness > staleness_threshold {
314                self.dropped_updates += 1;
315                return Err(OptimizerDistError::StaleUpdate {
316                    worker: update.worker_id.0.clone(),
317                    step: update.step,
318                    current: self.current_step,
319                });
320            }
321        }
322
323        // Dimension consistency check: verify against any existing pending
324        // gradients for this layer.
325        let grad_len = update.gradients.len();
326        if let Some(existing) = self.pending.get(&update.layer_id) {
327            if let Some(first) = existing.first() {
328                let expected = first.gradients.len();
329                if grad_len != expected {
330                    return Err(OptimizerDistError::DimensionMismatch {
331                        layer: update.layer_id.clone(),
332                        expected,
333                        got: grad_len,
334                    });
335                }
336            }
337        }
338
339        // Update worker state.
340        let worker_id = update.worker_id.clone();
341        if let Some(state) = self.workers.get_mut(&worker_id) {
342            if update.step > state.latest_step {
343                state.latest_step = update.step;
344            }
345            state.last_seen = now;
346            state.total_updates += 1;
347        }
348
349        // Append to pending.
350        self.pending
351            .entry(update.layer_id.clone())
352            .or_default()
353            .push(update);
354
355        Ok(())
356    }
357
358    // ─────────────────────────────────────────────────────────────
359    // Aggregation
360    // ─────────────────────────────────────────────────────────────
361
362    /// Attempt to aggregate pending updates for `layer_id`.
363    ///
364    /// Returns `Some(AggregatedGradient)` if the strategy's quorum condition
365    /// is satisfied, `None` otherwise.  On success the pending queue for that
366    /// layer is cleared and the result is stored in `aggregated`.
367    pub fn try_aggregate(&mut self, layer_id: &str) -> Option<AggregatedGradient> {
368        let pending = self.pending.get(layer_id)?;
369        if pending.is_empty() {
370            return None;
371        }
372
373        let can_aggregate = match &self.strategy {
374            AggregationStrategy::Synchronous => {
375                // All active workers must have submitted for this layer at
376                // the current step.
377                let active_count = self.workers.values().filter(|w| w.active).count();
378
379                if active_count == 0 {
380                    return None;
381                }
382
383                let submitted_for_step: std::collections::HashSet<&WorkerId> = pending
384                    .iter()
385                    .filter(|u| u.step == self.current_step)
386                    .map(|u| &u.worker_id)
387                    .collect();
388
389                let active_have_submitted = self
390                    .workers
391                    .values()
392                    .filter(|w| w.active)
393                    .all(|w| submitted_for_step.contains(&w.worker_id));
394
395                active_have_submitted
396            }
397            AggregationStrategy::Asynchronous { .. } => {
398                // Aggregate everything that has been accepted (staleness was
399                // already checked at submission time).
400                !pending.is_empty()
401            }
402            AggregationStrategy::FederatedAverage { rounds } => pending.len() >= *rounds as usize,
403            AggregationStrategy::GossipAverage { fanout } => pending.len() >= *fanout,
404        };
405
406        if !can_aggregate {
407            return None;
408        }
409
410        // Perform element-wise mean.
411        let updates: Vec<GradientUpdate> = self.pending.remove(layer_id).unwrap_or_default();
412
413        let contributing_workers = updates.len();
414        let values = Self::aggregate_gradients(&updates);
415        let result = AggregatedGradient {
416            layer_id: layer_id.to_string(),
417            values,
418            contributing_workers,
419            step: self.current_step,
420        };
421
422        self.aggregated.insert(layer_id.to_string(), result.clone());
423        self.total_aggregations += 1;
424        Some(result)
425    }
426
427    /// Compute the element-wise mean of a slice of [`GradientUpdate`]s.
428    ///
429    /// Returns an empty vector if `updates` is empty or all gradient vectors
430    /// are empty.
431    pub fn aggregate_gradients(updates: &[GradientUpdate]) -> Vec<f64> {
432        if updates.is_empty() {
433            return Vec::new();
434        }
435
436        let len = updates.iter().map(|u| u.gradients.len()).max().unwrap_or(0);
437
438        if len == 0 {
439            return Vec::new();
440        }
441
442        let n = updates.len() as f64;
443        let mut sums = vec![0.0_f64; len];
444        for update in updates {
445            for (i, &g) in update.gradients.iter().enumerate() {
446                if i < len {
447                    sums[i] += g;
448                }
449            }
450        }
451        sums.iter_mut().for_each(|s| *s /= n);
452        sums
453    }
454
455    // ─────────────────────────────────────────────────────────────
456    // Step management
457    // ─────────────────────────────────────────────────────────────
458
459    /// Advance the global training step by one.
460    ///
461    /// For the [`AggregationStrategy::Synchronous`] strategy this also
462    /// clears all pending updates left over from the previous step (workers
463    /// that did not submit in time are implicitly skipped).
464    pub fn advance_step(&mut self) {
465        if matches!(self.strategy, AggregationStrategy::Synchronous) {
466            self.pending.clear();
467        }
468        self.current_step += 1;
469    }
470
471    // ─────────────────────────────────────────────────────────────
472    // Queries
473    // ─────────────────────────────────────────────────────────────
474
475    /// Returns references to all active workers.
476    pub fn active_workers(&self) -> Vec<&WorkerState> {
477        self.workers.values().filter(|w| w.active).collect()
478    }
479
480    /// Total number of registered workers (active and inactive).
481    pub fn worker_count(&self) -> usize {
482        self.workers.len()
483    }
484
485    /// Number of currently active workers.
486    pub fn active_worker_count(&self) -> usize {
487        self.workers.values().filter(|w| w.active).count()
488    }
489
490    /// Number of pending (not-yet-aggregated) updates for a layer.
491    pub fn pending_updates_for(&self, layer_id: &str) -> usize {
492        self.pending.get(layer_id).map_or(0, |v| v.len())
493    }
494
495    /// Returns the most recently aggregated gradient for a layer, if any.
496    pub fn last_aggregated(&self, layer_id: &str) -> Option<&AggregatedGradient> {
497        self.aggregated.get(layer_id)
498    }
499
500    // ─────────────────────────────────────────────────────────────
501    // Fault tolerance
502    // ─────────────────────────────────────────────────────────────
503
504    /// Mark workers inactive if their `last_seen` timestamp is older than
505    /// `now - max_age_ms`.
506    ///
507    /// Returns the number of workers evicted.
508    pub fn evict_stale_workers(&mut self, max_age_ms: u64, now: u64) -> usize {
509        let cutoff = now.saturating_sub(max_age_ms);
510        let mut count = 0usize;
511        for state in self.workers.values_mut() {
512            if state.active && state.last_seen < cutoff {
513                state.active = false;
514                count += 1;
515            }
516        }
517        count
518    }
519
520    // ─────────────────────────────────────────────────────────────
521    // Statistics
522    // ─────────────────────────────────────────────────────────────
523
524    /// Snapshot current optimizer statistics.
525    pub fn stats(&self) -> DistOptimizerStats {
526        DistOptimizerStats {
527            total_workers: self.workers.len(),
528            active_workers: self.active_worker_count(),
529            current_step: self.current_step,
530            total_aggregations: self.total_aggregations,
531            dropped_updates: self.dropped_updates,
532            pending_layers: self.pending.len(),
533        }
534    }
535}
536
537// ─────────────────────────────────────────────────────────────────────────────
538// Tests
539// ─────────────────────────────────────────────────────────────────────────────
540
541#[cfg(test)]
542mod tests {
543    use super::{
544        AggregationStrategy, DistributedOptimizer, GradientUpdate, OptimizerDistError, WorkerId,
545    };
546
547    // ── helpers ──────────────────────────────────────────────────
548
549    fn wid(s: &str) -> WorkerId {
550        WorkerId::new(s)
551    }
552
553    fn make_update(worker: &str, layer: &str, grads: Vec<f64>, step: u64) -> GradientUpdate {
554        GradientUpdate {
555            worker_id: wid(worker),
556            layer_id: layer.to_string(),
557            gradients: grads,
558            step,
559            timestamp: step * 1000,
560        }
561    }
562
563    fn sync_optimizer() -> DistributedOptimizer {
564        DistributedOptimizer::new(AggregationStrategy::Synchronous)
565    }
566
567    fn async_optimizer(threshold: u64) -> DistributedOptimizer {
568        DistributedOptimizer::new(AggregationStrategy::Asynchronous {
569            staleness_threshold: threshold,
570        })
571    }
572
573    fn fedavg_optimizer(rounds: u32) -> DistributedOptimizer {
574        DistributedOptimizer::new(AggregationStrategy::FederatedAverage { rounds })
575    }
576
577    fn gossip_optimizer(fanout: usize) -> DistributedOptimizer {
578        DistributedOptimizer::new(AggregationStrategy::GossipAverage { fanout })
579    }
580
581    // ── 1. WorkerId ──────────────────────────────────────────────
582
583    #[test]
584    fn worker_id_equality() {
585        assert_eq!(wid("a"), wid("a"));
586        assert_ne!(wid("a"), wid("b"));
587    }
588
589    #[test]
590    fn worker_id_display() {
591        assert_eq!(format!("{}", wid("worker-1")), "worker-1");
592    }
593
594    #[test]
595    fn worker_id_ordering() {
596        let mut ids = vec![wid("c"), wid("a"), wid("b")];
597        ids.sort();
598        assert_eq!(ids, vec![wid("a"), wid("b"), wid("c")]);
599    }
600
601    // ── 2. Registration ──────────────────────────────────────────
602
603    #[test]
604    fn register_new_worker_returns_true() {
605        let mut opt = sync_optimizer();
606        assert!(opt.register_worker(wid("w1")));
607    }
608
609    #[test]
610    fn register_duplicate_returns_false() {
611        let mut opt = sync_optimizer();
612        opt.register_worker(wid("w1"));
613        assert!(!opt.register_worker(wid("w1")));
614    }
615
616    #[test]
617    fn worker_count_after_registration() {
618        let mut opt = sync_optimizer();
619        opt.register_worker(wid("w1"));
620        opt.register_worker(wid("w2"));
621        assert_eq!(opt.worker_count(), 2);
622    }
623
624    #[test]
625    fn active_worker_count_initial() {
626        let mut opt = sync_optimizer();
627        opt.register_worker(wid("w1"));
628        opt.register_worker(wid("w2"));
629        assert_eq!(opt.active_worker_count(), 2);
630    }
631
632    // ── 3. Deregistration ────────────────────────────────────────
633
634    #[test]
635    fn deregister_known_worker() {
636        let mut opt = sync_optimizer();
637        opt.register_worker(wid("w1"));
638        assert!(opt.deregister_worker(&wid("w1")));
639        assert_eq!(opt.active_worker_count(), 0);
640    }
641
642    #[test]
643    fn deregister_unknown_worker_returns_false() {
644        let mut opt = sync_optimizer();
645        assert!(!opt.deregister_worker(&wid("ghost")));
646    }
647
648    #[test]
649    fn deregister_does_not_remove_worker_from_map() {
650        let mut opt = sync_optimizer();
651        opt.register_worker(wid("w1"));
652        opt.deregister_worker(&wid("w1"));
653        assert_eq!(opt.worker_count(), 1);
654        assert_eq!(opt.active_worker_count(), 0);
655    }
656
657    // ── 4. Update submission — general ──────────────────────────
658
659    #[test]
660    fn submit_unknown_worker_errors() {
661        let mut opt = sync_optimizer();
662        let upd = make_update("ghost", "layer0", vec![1.0], 0);
663        assert!(matches!(
664            opt.submit_update(upd, 0),
665            Err(OptimizerDistError::WorkerNotFound(_))
666        ));
667    }
668
669    #[test]
670    fn submit_update_increments_pending() {
671        let mut opt = sync_optimizer();
672        opt.register_worker(wid("w1"));
673        opt.submit_update(make_update("w1", "layer0", vec![1.0], 0), 1000)
674            .expect("should succeed");
675        assert_eq!(opt.pending_updates_for("layer0"), 1);
676    }
677
678    #[test]
679    fn submit_update_tracks_worker_state() {
680        let mut opt = sync_optimizer();
681        opt.register_worker(wid("w1"));
682        opt.submit_update(make_update("w1", "layer0", vec![0.5], 2), 5000)
683            .expect("ok");
684        let active = opt.active_workers();
685        let w = active
686            .iter()
687            .find(|w| w.worker_id == wid("w1"))
688            .expect("found");
689        assert_eq!(w.latest_step, 2);
690        assert_eq!(w.last_seen, 5000);
691        assert_eq!(w.total_updates, 1);
692    }
693
694    #[test]
695    fn submit_dimension_mismatch_errors() {
696        let mut opt = async_optimizer(10);
697        opt.register_worker(wid("w1"));
698        opt.register_worker(wid("w2"));
699        opt.submit_update(make_update("w1", "layer0", vec![1.0, 2.0], 0), 0)
700            .expect("ok");
701        let err = opt
702            .submit_update(make_update("w2", "layer0", vec![1.0], 0), 0)
703            .unwrap_err();
704        assert!(matches!(err, OptimizerDistError::DimensionMismatch { .. }));
705    }
706
707    // ── 5. Staleness (Async) ─────────────────────────────────────
708
709    #[test]
710    fn async_accepts_fresh_update() {
711        let mut opt = async_optimizer(2);
712        opt.register_worker(wid("w1"));
713        opt.advance_step(); // current = 1
714        opt.advance_step(); // current = 2
715                            // step 0 is 2 steps behind — exactly at threshold
716        let res = opt.submit_update(make_update("w1", "l0", vec![1.0], 0), 100);
717        assert!(res.is_ok());
718    }
719
720    #[test]
721    fn async_rejects_stale_update() {
722        let mut opt = async_optimizer(1);
723        opt.register_worker(wid("w1"));
724        opt.advance_step(); // current = 1
725        opt.advance_step(); // current = 2
726                            // step 0 is 2 steps behind — exceeds threshold of 1
727        let res = opt.submit_update(make_update("w1", "l0", vec![1.0], 0), 100);
728        assert!(matches!(res, Err(OptimizerDistError::StaleUpdate { .. })));
729    }
730
731    #[test]
732    fn async_increments_dropped_on_stale() {
733        let mut opt = async_optimizer(0);
734        opt.register_worker(wid("w1"));
735        opt.advance_step(); // current = 1
736        opt.submit_update(make_update("w1", "l0", vec![1.0], 0), 0)
737            .ok();
738        assert_eq!(opt.dropped_updates, 1);
739    }
740
741    // ── 6. Synchronous aggregation ──────────────────────────────
742
743    #[test]
744    fn sync_no_aggregate_until_all_workers_submit() {
745        let mut opt = sync_optimizer();
746        opt.register_worker(wid("w1"));
747        opt.register_worker(wid("w2"));
748        opt.submit_update(make_update("w1", "l0", vec![1.0, 2.0], 0), 0)
749            .expect("ok");
750        assert!(opt.try_aggregate("l0").is_none());
751    }
752
753    #[test]
754    fn sync_aggregates_when_all_workers_submit() {
755        let mut opt = sync_optimizer();
756        opt.register_worker(wid("w1"));
757        opt.register_worker(wid("w2"));
758        opt.submit_update(make_update("w1", "l0", vec![1.0, 2.0], 0), 0)
759            .expect("ok");
760        opt.submit_update(make_update("w2", "l0", vec![3.0, 4.0], 0), 0)
761            .expect("ok");
762        let agg = opt.try_aggregate("l0").expect("should aggregate");
763        assert_eq!(agg.contributing_workers, 2);
764        assert!((agg.values[0] - 2.0).abs() < 1e-10); // (1+3)/2
765        assert!((agg.values[1] - 3.0).abs() < 1e-10); // (2+4)/2
766    }
767
768    #[test]
769    fn sync_clears_pending_on_advance_step() {
770        let mut opt = sync_optimizer();
771        opt.register_worker(wid("w1"));
772        opt.submit_update(make_update("w1", "l0", vec![1.0], 0), 0)
773            .expect("ok");
774        opt.advance_step();
775        assert_eq!(opt.pending_updates_for("l0"), 0);
776    }
777
778    #[test]
779    fn sync_ignores_inactive_workers_for_quorum() {
780        let mut opt = sync_optimizer();
781        opt.register_worker(wid("w1"));
782        opt.register_worker(wid("w2"));
783        opt.deregister_worker(&wid("w2"));
784        opt.submit_update(make_update("w1", "l0", vec![1.0], 0), 0)
785            .expect("ok");
786        // Only w1 active, quorum of 1 satisfied
787        let agg = opt.try_aggregate("l0");
788        assert!(agg.is_some());
789    }
790
791    // ── 7. FederatedAverage aggregation ─────────────────────────
792
793    #[test]
794    fn fedavg_no_aggregate_below_rounds() {
795        let mut opt = fedavg_optimizer(3);
796        for i in 0..3 {
797            opt.register_worker(wid(&format!("w{i}")));
798        }
799        for i in 0..2usize {
800            opt.submit_update(make_update(&format!("w{i}"), "l0", vec![1.0], 0), 0)
801                .expect("ok");
802        }
803        assert!(opt.try_aggregate("l0").is_none());
804    }
805
806    #[test]
807    fn fedavg_aggregates_at_rounds() {
808        let mut opt = fedavg_optimizer(2);
809        opt.register_worker(wid("w0"));
810        opt.register_worker(wid("w1"));
811        opt.submit_update(make_update("w0", "l0", vec![2.0], 0), 0)
812            .expect("ok");
813        opt.submit_update(make_update("w1", "l0", vec![4.0], 0), 0)
814            .expect("ok");
815        let agg = opt.try_aggregate("l0").expect("should aggregate");
816        assert_eq!(agg.contributing_workers, 2);
817        assert!((agg.values[0] - 3.0).abs() < 1e-10);
818    }
819
820    #[test]
821    fn fedavg_pending_cleared_after_aggregate() {
822        let mut opt = fedavg_optimizer(1);
823        opt.register_worker(wid("w0"));
824        opt.submit_update(make_update("w0", "l0", vec![5.0], 0), 0)
825            .expect("ok");
826        opt.try_aggregate("l0");
827        assert_eq!(opt.pending_updates_for("l0"), 0);
828    }
829
830    // ── 8. GossipAverage aggregation ────────────────────────────
831
832    #[test]
833    fn gossip_no_aggregate_below_fanout() {
834        let mut opt = gossip_optimizer(3);
835        opt.register_worker(wid("w0"));
836        opt.register_worker(wid("w1"));
837        opt.submit_update(make_update("w0", "l0", vec![1.0], 0), 0)
838            .expect("ok");
839        opt.submit_update(make_update("w1", "l0", vec![2.0], 0), 0)
840            .expect("ok");
841        assert!(opt.try_aggregate("l0").is_none());
842    }
843
844    #[test]
845    fn gossip_aggregates_at_fanout() {
846        let mut opt = gossip_optimizer(2);
847        opt.register_worker(wid("w0"));
848        opt.register_worker(wid("w1"));
849        opt.submit_update(make_update("w0", "l0", vec![0.0], 0), 0)
850            .expect("ok");
851        opt.submit_update(make_update("w1", "l0", vec![2.0], 0), 0)
852            .expect("ok");
853        let agg = opt.try_aggregate("l0").expect("should aggregate");
854        assert!((agg.values[0] - 1.0).abs() < 1e-10);
855    }
856
857    // ── 9. aggregate_gradients ───────────────────────────────────
858
859    #[test]
860    fn aggregate_empty_returns_empty() {
861        let result = DistributedOptimizer::aggregate_gradients(&[]);
862        assert!(result.is_empty());
863    }
864
865    #[test]
866    fn aggregate_single_update() {
867        let upd = make_update("w1", "l0", vec![1.0, 2.0, 3.0], 0);
868        let result = DistributedOptimizer::aggregate_gradients(&[upd]);
869        assert_eq!(result, vec![1.0, 2.0, 3.0]);
870    }
871
872    #[test]
873    fn aggregate_mean_two_workers() {
874        let u1 = make_update("w1", "l0", vec![0.0, 10.0], 0);
875        let u2 = make_update("w2", "l0", vec![4.0, 6.0], 0);
876        let result = DistributedOptimizer::aggregate_gradients(&[u1, u2]);
877        assert!((result[0] - 2.0).abs() < 1e-10);
878        assert!((result[1] - 8.0).abs() < 1e-10);
879    }
880
881    #[test]
882    fn aggregate_mean_three_workers() {
883        let u1 = make_update("w1", "l0", vec![3.0], 0);
884        let u2 = make_update("w2", "l0", vec![6.0], 0);
885        let u3 = make_update("w3", "l0", vec![9.0], 0);
886        let result = DistributedOptimizer::aggregate_gradients(&[u1, u2, u3]);
887        assert!((result[0] - 6.0).abs() < 1e-10);
888    }
889
890    // ── 10. advance_step ─────────────────────────────────────────
891
892    #[test]
893    fn advance_step_increments_counter() {
894        let mut opt = sync_optimizer();
895        assert_eq!(opt.current_step, 0);
896        opt.advance_step();
897        assert_eq!(opt.current_step, 1);
898        opt.advance_step();
899        assert_eq!(opt.current_step, 2);
900    }
901
902    #[test]
903    fn advance_step_async_preserves_pending() {
904        let mut opt = async_optimizer(5);
905        opt.register_worker(wid("w1"));
906        opt.submit_update(make_update("w1", "l0", vec![1.0], 0), 0)
907            .expect("ok");
908        opt.advance_step();
909        // Async does NOT clear pending on step advance
910        assert_eq!(opt.pending_updates_for("l0"), 1);
911    }
912
913    // ── 11. evict_stale_workers ──────────────────────────────────
914
915    #[test]
916    fn evict_stale_workers_marks_inactive() {
917        let mut opt = sync_optimizer();
918        opt.register_worker(wid("w1"));
919        // Simulate w1 last seen at t=100, evict anything older than 500ms
920        // with now=1000 → cutoff=500 → 100 < 500 → evict
921        opt.submit_update(make_update("w1", "l0", vec![1.0], 0), 100)
922            .expect("ok");
923        let evicted = opt.evict_stale_workers(500, 1000);
924        assert_eq!(evicted, 1);
925        assert_eq!(opt.active_worker_count(), 0);
926    }
927
928    #[test]
929    fn evict_fresh_worker_not_evicted() {
930        let mut opt = sync_optimizer();
931        opt.register_worker(wid("w1"));
932        opt.submit_update(make_update("w1", "l0", vec![1.0], 0), 900)
933            .expect("ok");
934        let evicted = opt.evict_stale_workers(500, 1000); // cutoff = 500, 900 > 500
935        assert_eq!(evicted, 0);
936        assert_eq!(opt.active_worker_count(), 1);
937    }
938
939    #[test]
940    fn evict_already_inactive_worker_not_counted() {
941        let mut opt = sync_optimizer();
942        opt.register_worker(wid("w1"));
943        opt.deregister_worker(&wid("w1"));
944        let evicted = opt.evict_stale_workers(0, 1000);
945        assert_eq!(evicted, 0);
946    }
947
948    // ── 12. Statistics ───────────────────────────────────────────
949
950    #[test]
951    fn stats_initial_state() {
952        let opt = sync_optimizer();
953        let s = opt.stats();
954        assert_eq!(s.total_workers, 0);
955        assert_eq!(s.active_workers, 0);
956        assert_eq!(s.current_step, 0);
957        assert_eq!(s.total_aggregations, 0);
958        assert_eq!(s.dropped_updates, 0);
959        assert_eq!(s.pending_layers, 0);
960    }
961
962    #[test]
963    fn stats_total_aggregations_increments() {
964        let mut opt = fedavg_optimizer(1);
965        opt.register_worker(wid("w0"));
966        opt.submit_update(make_update("w0", "l0", vec![1.0], 0), 0)
967            .expect("ok");
968        opt.try_aggregate("l0");
969        let s = opt.stats();
970        assert_eq!(s.total_aggregations, 1);
971    }
972
973    #[test]
974    fn stats_pending_layers_counts_unique_layers() {
975        let mut opt = fedavg_optimizer(99); // high threshold → never aggregate
976        opt.register_worker(wid("w0"));
977        opt.submit_update(make_update("w0", "l0", vec![1.0], 0), 0)
978            .expect("ok");
979        opt.submit_update(make_update("w0", "l1", vec![2.0], 0), 0)
980            .expect("ok");
981        let s = opt.stats();
982        assert_eq!(s.pending_layers, 2);
983    }
984
985    // ── 13. last_aggregated ──────────────────────────────────────
986
987    #[test]
988    fn last_aggregated_none_before_aggregation() {
989        let opt = sync_optimizer();
990        assert!(opt.last_aggregated("l0").is_none());
991    }
992
993    #[test]
994    fn last_aggregated_returns_result_after_aggregation() {
995        let mut opt = fedavg_optimizer(1);
996        opt.register_worker(wid("w0"));
997        opt.submit_update(make_update("w0", "l0", vec![7.0], 0), 0)
998            .expect("ok");
999        opt.try_aggregate("l0");
1000        let agg = opt.last_aggregated("l0").expect("exists");
1001        assert!((agg.values[0] - 7.0).abs() < 1e-10);
1002    }
1003
1004    // ── 14. Multi-layer independence ─────────────────────────────
1005
1006    #[test]
1007    fn different_layers_aggregated_independently() {
1008        let mut opt = fedavg_optimizer(1);
1009        opt.register_worker(wid("w0"));
1010        opt.submit_update(make_update("w0", "l0", vec![1.0], 0), 0)
1011            .expect("ok");
1012        opt.submit_update(make_update("w0", "l1", vec![2.0], 0), 0)
1013            .expect("ok");
1014        let agg0 = opt.try_aggregate("l0").expect("l0 aggregated");
1015        let agg1 = opt.try_aggregate("l1").expect("l1 aggregated");
1016        assert!((agg0.values[0] - 1.0).abs() < 1e-10);
1017        assert!((agg1.values[0] - 2.0).abs() < 1e-10);
1018    }
1019
1020    // ── 15. OptimizerDistError display ──────────────────────────
1021
1022    #[test]
1023    fn error_worker_not_found_display() {
1024        let e = OptimizerDistError::WorkerNotFound("bob".to_string());
1025        assert!(e.to_string().contains("bob"));
1026    }
1027
1028    #[test]
1029    fn error_stale_update_display() {
1030        let e = OptimizerDistError::StaleUpdate {
1031            worker: "w1".to_string(),
1032            step: 0,
1033            current: 5,
1034        };
1035        assert!(e.to_string().contains("w1"));
1036        assert!(e.to_string().contains('5'));
1037    }
1038
1039    #[test]
1040    fn error_dimension_mismatch_display() {
1041        let e = OptimizerDistError::DimensionMismatch {
1042            layer: "conv1".to_string(),
1043            expected: 4,
1044            got: 3,
1045        };
1046        assert!(e.to_string().contains("conv1"));
1047        assert!(e.to_string().contains('4'));
1048        assert!(e.to_string().contains('3'));
1049    }
1050
1051    // ── 16. Active workers list ──────────────────────────────────
1052
1053    #[test]
1054    fn active_workers_returns_only_active() {
1055        let mut opt = sync_optimizer();
1056        opt.register_worker(wid("w1"));
1057        opt.register_worker(wid("w2"));
1058        opt.deregister_worker(&wid("w1"));
1059        let active = opt.active_workers();
1060        assert_eq!(active.len(), 1);
1061        assert_eq!(active[0].worker_id, wid("w2"));
1062    }
1063
1064    // ── 17. Async aggregation produces correct mean ──────────────
1065
1066    #[test]
1067    fn async_aggregate_produces_mean() {
1068        let mut opt = async_optimizer(10);
1069        opt.register_worker(wid("w1"));
1070        opt.register_worker(wid("w2"));
1071        opt.submit_update(make_update("w1", "l0", vec![0.0, 2.0], 0), 0)
1072            .expect("ok");
1073        opt.submit_update(make_update("w2", "l0", vec![4.0, 6.0], 0), 0)
1074            .expect("ok");
1075        let agg = opt.try_aggregate("l0").expect("aggregated");
1076        assert!((agg.values[0] - 2.0).abs() < 1e-10);
1077        assert!((agg.values[1] - 4.0).abs() < 1e-10);
1078    }
1079
1080    // ── 18. Zero-active workers, sync never aggregates ───────────
1081
1082    #[test]
1083    fn sync_with_no_active_workers_never_aggregates() {
1084        let mut opt = sync_optimizer();
1085        opt.register_worker(wid("w1"));
1086        opt.deregister_worker(&wid("w1"));
1087        // Even with a submitted update (from inactive), quorum cannot be met
1088        // because there are zero active workers.
1089        // Submitting to an inactive worker is still allowed (not deregistered).
1090        opt.submit_update(make_update("w1", "l0", vec![1.0], 0), 0)
1091            .expect("ok");
1092        assert!(opt.try_aggregate("l0").is_none());
1093    }
1094
1095    // ── 19. Multiple advance_step calls ─────────────────────────
1096
1097    #[test]
1098    fn multiple_advance_steps() {
1099        let mut opt = sync_optimizer();
1100        for _ in 0..10 {
1101            opt.advance_step();
1102        }
1103        assert_eq!(opt.current_step, 10);
1104    }
1105
1106    // ── 20. Aggregation step field matches current_step ──────────
1107
1108    #[test]
1109    fn aggregated_step_matches_current_step() {
1110        let mut opt = fedavg_optimizer(1);
1111        opt.register_worker(wid("w0"));
1112        opt.advance_step();
1113        opt.advance_step(); // current_step = 2
1114        opt.submit_update(make_update("w0", "l0", vec![1.0], 2), 200)
1115            .expect("ok");
1116        let agg = opt.try_aggregate("l0").expect("aggregated");
1117        assert_eq!(agg.step, 2);
1118    }
1119
1120    // ── 21. Re-register after deregister (same slot) ─────────────
1121
1122    #[test]
1123    fn cannot_re_register_after_deregister() {
1124        let mut opt = sync_optimizer();
1125        opt.register_worker(wid("w1"));
1126        opt.deregister_worker(&wid("w1"));
1127        // The worker still exists in the map → re-registration fails
1128        let result = opt.register_worker(wid("w1"));
1129        assert!(!result);
1130    }
1131
1132    // ── 22. Gossip with extra updates above fanout ────────────────
1133
1134    #[test]
1135    fn gossip_aggregates_all_pending_above_fanout() {
1136        let mut opt = gossip_optimizer(2);
1137        opt.register_worker(wid("w0"));
1138        opt.register_worker(wid("w1"));
1139        opt.register_worker(wid("w2"));
1140        for i in 0..3usize {
1141            opt.submit_update(make_update(&format!("w{i}"), "l0", vec![3.0], 0), 0)
1142                .expect("ok");
1143        }
1144        let agg = opt.try_aggregate("l0").expect("aggregated");
1145        assert_eq!(agg.contributing_workers, 3);
1146    }
1147
1148    // ── 23. FedAvg accumulates stats correctly ───────────────────
1149
1150    #[test]
1151    fn fedavg_total_aggregations_increments_across_rounds() {
1152        let mut opt = fedavg_optimizer(1);
1153        opt.register_worker(wid("w0"));
1154        for step in 0..5u64 {
1155            opt.submit_update(make_update("w0", "l0", vec![1.0], step), 0)
1156                .expect("ok");
1157            opt.try_aggregate("l0");
1158        }
1159        assert_eq!(opt.stats().total_aggregations, 5);
1160    }
1161
1162    // ── 24. Evict multiple stale workers ────────────────────────
1163
1164    #[test]
1165    fn evict_multiple_stale_workers() {
1166        let mut opt = sync_optimizer();
1167        for i in 0..4usize {
1168            opt.register_worker(wid(&format!("w{i}")));
1169            // first two workers were last seen at t=10, rest at t=800
1170            let ts = if i < 2 { 10 } else { 800 };
1171            opt.submit_update(make_update(&format!("w{i}"), "l0", vec![1.0], 0), ts)
1172                .expect("ok");
1173        }
1174        // now=1000, max_age=500 → cutoff=500 → t=10 < 500 → evict
1175        let evicted = opt.evict_stale_workers(500, 1000);
1176        assert_eq!(evicted, 2);
1177        assert_eq!(opt.active_worker_count(), 2);
1178    }
1179}