1use std::collections::HashMap;
23use std::fmt;
24
25#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
31pub struct WorkerId(pub String);
32
33impl WorkerId {
34 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#[derive(Debug, Clone)]
52pub struct GradientUpdate {
53 pub worker_id: WorkerId,
55 pub layer_id: String,
57 pub gradients: Vec<f64>,
59 pub step: u64,
61 pub timestamp: u64,
64}
65
66#[derive(Debug, Clone)]
72pub enum AggregationStrategy {
73 Synchronous,
76
77 Asynchronous {
80 staleness_threshold: u64,
82 },
83
84 FederatedAverage {
87 rounds: u32,
89 },
90
91 GossipAverage {
93 fanout: usize,
95 },
96}
97
98#[derive(Debug, Clone)]
104pub struct AggregatedGradient {
105 pub layer_id: String,
107 pub values: Vec<f64>,
109 pub contributing_workers: usize,
111 pub step: u64,
113}
114
115#[derive(Debug, Clone)]
121pub struct WorkerState {
122 pub worker_id: WorkerId,
124 pub latest_step: u64,
127 pub last_seen: u64,
129 pub active: bool,
131 pub total_updates: u64,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq)]
141pub enum OptimizerDistError {
142 WorkerNotFound(String),
144 StaleUpdate {
146 worker: String,
147 step: u64,
148 current: u64,
149 },
150 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#[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
203pub struct DistributedOptimizer {
209 pub strategy: AggregationStrategy,
211 workers: HashMap<WorkerId, WorkerState>,
213 pending: HashMap<String, Vec<GradientUpdate>>,
215 aggregated: HashMap<String, AggregatedGradient>,
217 pub current_step: u64,
219 pub dropped_updates: u64,
221 total_aggregations: u64,
223}
224
225impl DistributedOptimizer {
226 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 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 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 pub fn submit_update(
296 &mut self,
297 update: GradientUpdate,
298 now: u64,
299 ) -> Result<(), OptimizerDistError> {
300 if !self.workers.contains_key(&update.worker_id) {
302 return Err(OptimizerDistError::WorkerNotFound(
303 update.worker_id.0.clone(),
304 ));
305 }
306
307 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 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 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 self.pending
351 .entry(update.layer_id.clone())
352 .or_default()
353 .push(update);
354
355 Ok(())
356 }
357
358 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 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 !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 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 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 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 pub fn active_workers(&self) -> Vec<&WorkerState> {
477 self.workers.values().filter(|w| w.active).collect()
478 }
479
480 pub fn worker_count(&self) -> usize {
482 self.workers.len()
483 }
484
485 pub fn active_worker_count(&self) -> usize {
487 self.workers.values().filter(|w| w.active).count()
488 }
489
490 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 pub fn last_aggregated(&self, layer_id: &str) -> Option<&AggregatedGradient> {
497 self.aggregated.get(layer_id)
498 }
499
500 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 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#[cfg(test)]
542mod tests {
543 use super::{
544 AggregationStrategy, DistributedOptimizer, GradientUpdate, OptimizerDistError, WorkerId,
545 };
546
547 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 #[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 #[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 #[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 #[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 #[test]
710 fn async_accepts_fresh_update() {
711 let mut opt = async_optimizer(2);
712 opt.register_worker(wid("w1"));
713 opt.advance_step(); opt.advance_step(); 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(); opt.advance_step(); 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(); opt.submit_update(make_update("w1", "l0", vec![1.0], 0), 0)
737 .ok();
738 assert_eq!(opt.dropped_updates, 1);
739 }
740
741 #[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); assert!((agg.values[1] - 3.0).abs() < 1e-10); }
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 let agg = opt.try_aggregate("l0");
788 assert!(agg.is_some());
789 }
790
791 #[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 #[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 #[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 #[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 assert_eq!(opt.pending_updates_for("l0"), 1);
911 }
912
913 #[test]
916 fn evict_stale_workers_marks_inactive() {
917 let mut opt = sync_optimizer();
918 opt.register_worker(wid("w1"));
919 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); 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 #[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); 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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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(); 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 #[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 let result = opt.register_worker(wid("w1"));
1129 assert!(!result);
1130 }
1131
1132 #[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 #[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 #[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 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 let evicted = opt.evict_stale_workers(500, 1000);
1176 assert_eq!(evicted, 2);
1177 assert_eq!(opt.active_worker_count(), 2);
1178 }
1179}