1use crate::error::{OptimError, Result};
50use std::collections::BTreeMap;
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum MembershipEvent {
55 Join(usize),
57 Leave(usize),
59}
60
61impl MembershipEvent {
62 pub fn worker_id(self) -> usize {
64 match self {
65 MembershipEvent::Join(worker_id) | MembershipEvent::Leave(worker_id) => worker_id,
66 }
67 }
68
69 pub fn is_join(self) -> bool {
71 matches!(self, MembershipEvent::Join(_))
72 }
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum WorkerState {
81 Pending,
83 Active,
85 Leaving,
87 Removed,
89}
90
91impl WorkerState {
92 pub fn name(self) -> &'static str {
94 match self {
95 WorkerState::Pending => "Pending",
96 WorkerState::Active => "Active",
97 WorkerState::Leaving => "Leaving",
98 WorkerState::Removed => "Removed",
99 }
100 }
101
102 pub fn is_legal_transition(from: Option<WorkerState>, to: WorkerState) -> bool {
106 matches!(
107 (from, to),
108 (None, WorkerState::Pending)
109 | (Some(WorkerState::Removed), WorkerState::Pending)
110 | (Some(WorkerState::Pending), WorkerState::Active)
111 | (Some(WorkerState::Active), WorkerState::Leaving)
112 | (Some(WorkerState::Leaving), WorkerState::Removed)
113 )
114 }
115}
116
117fn describe_state(state: Option<WorkerState>) -> &'static str {
119 match state {
120 Some(state) => state.name(),
121 None => "absent",
122 }
123}
124
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub struct ShardRange {
128 pub worker_id: usize,
130 pub start: usize,
132 pub end: usize,
134}
135
136impl ShardRange {
137 pub fn len(&self) -> usize {
139 self.end - self.start
140 }
141
142 pub fn is_empty(&self) -> bool {
144 self.start >= self.end
145 }
146
147 pub fn contains(&self, shard: usize) -> bool {
149 shard >= self.start && shard < self.end
150 }
151}
152
153#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct WorkerShards {
157 pub worker_id: usize,
159 pub shards: Vec<usize>,
161}
162
163impl WorkerShards {
164 pub fn len(&self) -> usize {
166 self.shards.len()
167 }
168
169 pub fn is_empty(&self) -> bool {
171 self.shards.is_empty()
172 }
173}
174
175#[derive(Debug, Clone, PartialEq, Eq, Default)]
183pub struct RendezvousState {
184 version: u64,
185 members: Vec<usize>,
186 world_size: usize,
187}
188
189impl RendezvousState {
190 pub fn new() -> Self {
192 Self::default()
193 }
194
195 pub fn version(&self) -> u64 {
197 self.version
198 }
199
200 pub fn members(&self) -> &[usize] {
202 &self.members
203 }
204
205 pub fn world_size(&self) -> usize {
207 self.world_size
208 }
209
210 pub fn contains(&self, worker_id: usize) -> bool {
212 self.members.binary_search(&worker_id).is_ok()
213 }
214}
215
216#[derive(Debug, Clone, PartialEq)]
218pub struct ElasticConfig {
219 pub base_learning_rate: f64,
221 pub reference_world_size: usize,
224 pub dataset_shards: usize,
226 pub min_world_size: usize,
228 pub max_world_size: usize,
230 pub warmup_steps: usize,
233}
234
235impl ElasticConfig {
236 pub fn new(
243 base_learning_rate: f64,
244 reference_world_size: usize,
245 dataset_shards: usize,
246 min_world_size: usize,
247 max_world_size: usize,
248 warmup_steps: usize,
249 ) -> Result<Self> {
250 let config = Self {
251 base_learning_rate,
252 reference_world_size,
253 dataset_shards,
254 min_world_size,
255 max_world_size,
256 warmup_steps,
257 };
258 config.validate()?;
259 Ok(config)
260 }
261
262 pub fn validate(&self) -> Result<()> {
267 if !self.base_learning_rate.is_finite() || self.base_learning_rate <= 0.0 {
268 return Err(OptimError::InvalidConfig(format!(
269 "base_learning_rate {} must be finite and positive",
270 self.base_learning_rate
271 )));
272 }
273 if self.reference_world_size == 0 {
274 return Err(OptimError::InvalidConfig(
275 "reference_world_size must be at least 1".to_string(),
276 ));
277 }
278 if self.dataset_shards == 0 {
279 return Err(OptimError::InvalidConfig(
280 "dataset_shards must be at least 1".to_string(),
281 ));
282 }
283 if self.min_world_size == 0 {
284 return Err(OptimError::InvalidConfig(
285 "min_world_size must be at least 1".to_string(),
286 ));
287 }
288 if self.max_world_size < self.min_world_size {
289 return Err(OptimError::InvalidConfig(format!(
290 "max_world_size {} must be >= min_world_size {}",
291 self.max_world_size, self.min_world_size
292 )));
293 }
294 Ok(())
295 }
296}
297
298impl Default for ElasticConfig {
299 fn default() -> Self {
300 Self {
301 base_learning_rate: 0.1,
302 reference_world_size: 1,
303 dataset_shards: 1,
304 min_world_size: 1,
305 max_world_size: usize::MAX,
306 warmup_steps: 0,
307 }
308 }
309}
310
311#[derive(Debug, Clone, PartialEq)]
313pub struct EpochSnapshot {
314 pub version: u64,
316 pub world_size: usize,
318 pub members: Vec<usize>,
320 pub shard_assignment: Vec<ShardRange>,
322 pub scaled_lr: f64,
324 pub averaging_factor: f64,
326}
327
328pub fn block_partition(members: &[usize], dataset_shards: usize) -> Vec<ShardRange> {
337 let num_workers = members.len();
338 if num_workers == 0 {
339 return Vec::new();
340 }
341 let base = dataset_shards / num_workers;
342 let remainder = dataset_shards % num_workers;
343
344 let mut ranges = Vec::with_capacity(num_workers);
345 let mut start = 0usize;
346 for (index, &worker_id) in members.iter().enumerate() {
347 let size = base + usize::from(index < remainder);
348 let end = start + size;
349 ranges.push(ShardRange {
350 worker_id,
351 start,
352 end,
353 });
354 start = end;
355 }
356 ranges
357}
358
359#[inline]
362fn splitmix64(seed: u64) -> u64 {
363 let mut z = seed.wrapping_add(0x9E37_79B9_7F4A_7C15);
364 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
365 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
366 z ^ (z >> 31)
367}
368
369#[inline]
375fn hrw_weight(worker_id: usize, shard: usize) -> u64 {
376 let worker_hash = splitmix64(worker_id as u64);
377 let shard_hash = splitmix64(shard as u64);
378 splitmix64(worker_hash ^ shard_hash.rotate_left(32))
379}
380
381fn balanced_capacities(num_workers: usize, dataset_shards: usize) -> Vec<usize> {
386 let base = dataset_shards / num_workers;
387 let remainder = dataset_shards % num_workers;
388 (0..num_workers)
389 .map(|index| base + usize::from(index < remainder))
390 .collect()
391}
392
393pub fn hashed_assignment(members: &[usize], dataset_shards: usize) -> Vec<WorkerShards> {
404 let num_workers = members.len();
405 if num_workers == 0 {
406 return Vec::new();
407 }
408
409 let mut remaining = balanced_capacities(num_workers, dataset_shards);
410 let mut buckets: Vec<Vec<usize>> = vec![Vec::new(); num_workers];
411
412 for shard in 0..dataset_shards {
413 let mut best_index = 0usize;
417 while remaining[best_index] == 0 {
418 best_index += 1;
419 }
420 let mut best_weight = hrw_weight(members[best_index], shard);
421
422 for index in (best_index + 1)..num_workers {
423 if remaining[index] == 0 {
424 continue;
425 }
426 let weight = hrw_weight(members[index], shard);
427 let prefer = match weight.cmp(&best_weight) {
428 std::cmp::Ordering::Greater => true,
429 std::cmp::Ordering::Less => false,
430 std::cmp::Ordering::Equal => members[index] < members[best_index],
431 };
432 if prefer {
433 best_weight = weight;
434 best_index = index;
435 }
436 }
437
438 remaining[best_index] -= 1;
439 buckets[best_index].push(shard);
440 }
441
442 members
443 .iter()
444 .zip(buckets)
445 .map(|(&worker_id, shards)| WorkerShards { worker_id, shards })
446 .collect()
447}
448
449#[derive(Debug, Clone)]
452pub struct ElasticCoordinator {
453 config: ElasticConfig,
454 rendezvous: RendezvousState,
455 worker_states: BTreeMap<usize, WorkerState>,
456}
457
458impl ElasticCoordinator {
459 pub fn new(config: ElasticConfig) -> Result<Self> {
466 config.validate()?;
467 Ok(Self {
468 config,
469 rendezvous: RendezvousState::new(),
470 worker_states: BTreeMap::new(),
471 })
472 }
473
474 pub fn config(&self) -> &ElasticConfig {
476 &self.config
477 }
478
479 pub fn rendezvous(&self) -> &RendezvousState {
481 &self.rendezvous
482 }
483
484 pub fn current_version(&self) -> u64 {
486 self.rendezvous.version
487 }
488
489 pub fn world_size(&self) -> usize {
491 self.rendezvous.world_size
492 }
493
494 pub fn members(&self) -> &[usize] {
496 self.rendezvous.members()
497 }
498
499 pub fn worker_state(&self, worker_id: usize) -> Option<WorkerState> {
501 self.worker_states.get(&worker_id).copied()
502 }
503
504 pub fn shard_assignment(&self) -> Vec<ShardRange> {
506 block_partition(&self.rendezvous.members, self.config.dataset_shards)
507 }
508
509 pub fn hashed_shard_assignment(&self) -> Vec<WorkerShards> {
512 hashed_assignment(&self.rendezvous.members, self.config.dataset_shards)
513 }
514
515 pub fn scaled_learning_rate(&self) -> f64 {
520 if self.rendezvous.world_size == 0 {
521 return 0.0;
522 }
523 self.config.base_learning_rate * self.rendezvous.world_size as f64
524 / self.config.reference_world_size as f64
525 }
526
527 pub fn gradient_averaging_divisor(&self) -> f64 {
530 self.rendezvous.world_size as f64
531 }
532
533 pub fn averaging_factor(&self) -> f64 {
535 if self.rendezvous.world_size == 0 {
536 0.0
537 } else {
538 1.0 / self.rendezvous.world_size as f64
539 }
540 }
541
542 pub fn warmup_learning_rate(&self, step: usize) -> f64 {
550 let target = self.scaled_learning_rate();
551 let warmup_steps = self.config.warmup_steps;
552 if warmup_steps == 0 || step >= warmup_steps {
553 return target;
554 }
555 let base = self.config.base_learning_rate;
556 let fraction = (step + 1) as f64 / warmup_steps as f64;
557 base + (target - base) * fraction
558 }
559
560 pub fn apply_event(&mut self, event: MembershipEvent) -> Result<EpochSnapshot> {
571 match event {
572 MembershipEvent::Join(worker_id) => self.apply_join(worker_id),
573 MembershipEvent::Leave(worker_id) => self.apply_leave(worker_id),
574 }
575 }
576
577 pub fn simulate(&mut self, events: &[MembershipEvent]) -> Result<Vec<EpochSnapshot>> {
583 let mut snapshots = Vec::with_capacity(events.len());
584 for &event in events {
585 snapshots.push(self.apply_event(event)?);
586 }
587 Ok(snapshots)
588 }
589
590 pub fn simulate_resilient(&mut self, events: &[MembershipEvent]) -> Vec<EpochSnapshot> {
593 let mut snapshots = Vec::new();
594 for &event in events {
595 if let Ok(snapshot) = self.apply_event(event) {
596 snapshots.push(snapshot);
597 }
598 }
599 snapshots
600 }
601
602 fn apply_join(&mut self, worker_id: usize) -> Result<EpochSnapshot> {
604 let current = self.worker_state(worker_id);
605 if !WorkerState::is_legal_transition(current, WorkerState::Pending) {
606 return Err(OptimError::InvalidState(format!(
607 "cannot join worker {worker_id}: it is currently {} (a join requires an \
608 absent or removed worker)",
609 describe_state(current)
610 )));
611 }
612 let new_world_size = self.rendezvous.world_size + 1;
613 if new_world_size > self.config.max_world_size {
614 return Err(OptimError::InvalidConfig(format!(
615 "join of worker {worker_id} would raise the world size to {new_world_size}, \
616 exceeding max_world_size {}",
617 self.config.max_world_size
618 )));
619 }
620
621 self.worker_states.insert(worker_id, WorkerState::Pending);
624 self.worker_states.insert(worker_id, WorkerState::Active);
625 let position = self
626 .rendezvous
627 .members
628 .partition_point(|&member| member < worker_id);
629 self.rendezvous.members.insert(position, worker_id);
630 self.rendezvous.world_size = self.rendezvous.members.len();
631 self.rendezvous.version += 1;
632
633 Ok(self.snapshot())
634 }
635
636 fn apply_leave(&mut self, worker_id: usize) -> Result<EpochSnapshot> {
638 let current = self.worker_state(worker_id);
639 if current != Some(WorkerState::Active) {
640 return Err(OptimError::InvalidState(format!(
641 "cannot remove worker {worker_id}: it is currently {} (a leave requires an \
642 active worker)",
643 describe_state(current)
644 )));
645 }
646 if self.rendezvous.world_size <= self.config.min_world_size {
647 return Err(OptimError::InvalidConfig(format!(
648 "leave of worker {worker_id} would drop the world size below min_world_size {}",
649 self.config.min_world_size
650 )));
651 }
652
653 self.worker_states.insert(worker_id, WorkerState::Leaving);
656 self.worker_states.insert(worker_id, WorkerState::Removed);
657 if let Ok(position) = self.rendezvous.members.binary_search(&worker_id) {
658 self.rendezvous.members.remove(position);
659 }
660 self.rendezvous.world_size = self.rendezvous.members.len();
661 self.rendezvous.version += 1;
662
663 Ok(self.snapshot())
664 }
665
666 fn snapshot(&self) -> EpochSnapshot {
668 EpochSnapshot {
669 version: self.rendezvous.version,
670 world_size: self.rendezvous.world_size,
671 members: self.rendezvous.members.clone(),
672 shard_assignment: self.shard_assignment(),
673 scaled_lr: self.scaled_learning_rate(),
674 averaging_factor: self.averaging_factor(),
675 }
676 }
677}
678
679#[cfg(test)]
680mod tests {
681 use super::*;
682 use approx::assert_relative_eq;
683
684 fn base_config() -> ElasticConfig {
685 ElasticConfig::new(0.1, 4, 12, 1, 16, 0).expect("valid config")
686 }
687
688 fn block_owners(ranges: &[ShardRange], dataset_shards: usize) -> Vec<usize> {
690 let mut owners = vec![usize::MAX; dataset_shards];
691 for range in ranges {
692 owners[range.start..range.end].fill(range.worker_id);
693 }
694 owners
695 }
696
697 fn hashed_owners(assignment: &[WorkerShards], dataset_shards: usize) -> Vec<usize> {
699 let mut owners = vec![usize::MAX; dataset_shards];
700 for worker in assignment {
701 for &shard in &worker.shards {
702 owners[shard] = worker.worker_id;
703 }
704 }
705 owners
706 }
707
708 fn assert_balanced_cover(ranges: &[ShardRange], num_workers: usize, dataset_shards: usize) {
709 assert_eq!(ranges.len(), num_workers, "one range per worker");
710 let mut expected_start = 0usize;
712 for range in ranges {
713 assert_eq!(range.start, expected_start, "ranges must be contiguous");
714 assert!(range.end >= range.start, "range must be well-formed");
715 expected_start = range.end;
716 }
717 assert_eq!(
718 expected_start, dataset_shards,
719 "ranges must cover every shard exactly once"
720 );
721 let min_size = ranges.iter().map(ShardRange::len).min().unwrap_or(0);
723 let max_size = ranges.iter().map(ShardRange::len).max().unwrap_or(0);
724 assert!(
725 max_size - min_size <= 1,
726 "shard sizes must differ by at most one (min={min_size}, max={max_size})"
727 );
728 }
729
730 #[test]
731 fn test_version_bumps_by_one_per_accepted_event() {
732 let mut coordinator = ElasticCoordinator::new(base_config()).unwrap();
733 assert_eq!(coordinator.current_version(), 0);
734 for (expected_version, worker_id) in (1u64..=4).zip(0usize..4) {
735 let snapshot = coordinator
736 .apply_event(MembershipEvent::Join(worker_id))
737 .unwrap();
738 assert_eq!(snapshot.version, expected_version);
739 assert_eq!(coordinator.current_version(), expected_version);
740 assert_eq!(coordinator.world_size(), expected_version as usize);
741 }
742 let snapshot = coordinator.apply_event(MembershipEvent::Leave(1)).unwrap();
744 assert_eq!(snapshot.version, 5);
745 assert_eq!(coordinator.world_size(), 3);
746 }
747
748 #[test]
749 fn test_invalid_events_return_err_and_do_not_bump_version() {
750 let mut coordinator = ElasticCoordinator::new(base_config()).unwrap();
751 coordinator.apply_event(MembershipEvent::Join(0)).unwrap();
752 coordinator.apply_event(MembershipEvent::Join(1)).unwrap();
753 let version_before = coordinator.current_version();
754 let world_before = coordinator.world_size();
755
756 assert!(coordinator.apply_event(MembershipEvent::Join(0)).is_err());
758 assert!(coordinator.apply_event(MembershipEvent::Leave(7)).is_err());
760
761 coordinator.apply_event(MembershipEvent::Leave(1)).unwrap();
763 assert!(coordinator.apply_event(MembershipEvent::Leave(1)).is_err());
764 assert_eq!(coordinator.worker_state(1), Some(WorkerState::Removed));
765
766 assert_eq!(coordinator.current_version(), version_before + 1);
769 assert_eq!(coordinator.world_size(), world_before - 1);
770 }
771
772 #[test]
773 fn test_shard_assignment_balanced_and_covers_all_shards() {
774 for dataset_shards in [1usize, 7, 10, 12, 13, 100] {
775 let config = ElasticConfig::new(0.05, 2, dataset_shards, 1, 64, 0).unwrap();
776 for num_workers in 1usize..=8 {
777 let mut coordinator = ElasticCoordinator::new(config.clone()).unwrap();
778 for worker_id in 0..num_workers {
779 coordinator
780 .apply_event(MembershipEvent::Join(worker_id))
781 .unwrap();
782 }
783 let ranges = coordinator.shard_assignment();
784 assert_balanced_cover(&ranges, num_workers, dataset_shards);
785
786 let owners = block_owners(&ranges, dataset_shards);
788 assert!(
789 owners.iter().all(|&owner| owner != usize::MAX),
790 "every shard must be owned"
791 );
792 }
793 }
794 }
795
796 #[test]
797 fn test_shard_assignment_is_deterministic_and_path_independent() {
798 let config = base_config();
799 let mut first = ElasticCoordinator::new(config.clone()).unwrap();
802 first
803 .simulate(&[
804 MembershipEvent::Join(0),
805 MembershipEvent::Join(1),
806 MembershipEvent::Join(2),
807 ])
808 .unwrap();
809
810 let mut second = ElasticCoordinator::new(config).unwrap();
811 second
812 .simulate(&[
813 MembershipEvent::Join(2),
814 MembershipEvent::Join(0),
815 MembershipEvent::Join(1),
816 ])
817 .unwrap();
818
819 assert_eq!(first.current_version(), second.current_version());
820 assert_eq!(first.world_size(), second.world_size());
821 assert_eq!(first.members(), second.members());
822 assert_eq!(first.shard_assignment(), second.shard_assignment());
823 assert_eq!(
824 first.hashed_shard_assignment(),
825 second.hashed_shard_assignment()
826 );
827
828 let recomputed = block_partition(first.members(), first.config().dataset_shards);
830 assert_eq!(first.shard_assignment(), recomputed);
831 }
832
833 #[test]
834 fn test_linear_scaling_rule_exact() {
835 let base_lr = 0.1;
836 let reference = 4usize;
837 let config = ElasticConfig::new(base_lr, reference, 12, 1, 32, 0).unwrap();
838 let mut coordinator = ElasticCoordinator::new(config).unwrap();
839 for worker_id in 0..8usize {
840 let snapshot = coordinator
841 .apply_event(MembershipEvent::Join(worker_id))
842 .unwrap();
843 let world_size = snapshot.world_size;
844 let expected = base_lr * world_size as f64 / reference as f64;
845 assert_eq!(coordinator.scaled_learning_rate(), expected);
846 assert_eq!(snapshot.scaled_lr, expected);
847 assert_eq!(coordinator.gradient_averaging_divisor(), world_size as f64);
848 assert_relative_eq!(
849 coordinator.averaging_factor(),
850 1.0 / world_size as f64,
851 epsilon = 1e-12
852 );
853 }
854 let mut at_reference =
856 ElasticCoordinator::new(ElasticConfig::new(base_lr, reference, 12, 1, 32, 0).unwrap())
857 .unwrap();
858 for worker_id in 0..reference {
859 at_reference
860 .apply_event(MembershipEvent::Join(worker_id))
861 .unwrap();
862 }
863 assert_eq!(at_reference.scaled_learning_rate(), base_lr);
864 }
865
866 #[test]
867 fn test_warmup_ramps_from_base_to_scaled() {
868 let base_lr = 0.1;
869 let config = ElasticConfig::new(base_lr, 2, 12, 1, 32, 5).unwrap();
870 let mut coordinator = ElasticCoordinator::new(config).unwrap();
871 for worker_id in 0..8usize {
872 coordinator
873 .apply_event(MembershipEvent::Join(worker_id))
874 .unwrap();
875 }
876 let target = coordinator.scaled_learning_rate();
877 assert!(
878 target > base_lr,
879 "scaled target must exceed base for 8 > ref 2"
880 );
881
882 let mut previous = base_lr;
885 for step in 0..5 {
886 let lr = coordinator.warmup_learning_rate(step);
887 assert!(lr > previous, "warmup must be strictly increasing");
888 assert!(lr <= target + 1e-12, "warmup must not overshoot the target");
889 previous = lr;
890 }
891 assert_relative_eq!(coordinator.warmup_learning_rate(4), target, epsilon = 1e-12);
892 assert_relative_eq!(
893 coordinator.warmup_learning_rate(100),
894 target,
895 epsilon = 1e-12
896 );
897 }
898
899 #[test]
900 fn test_join_leave_round_trip_restores_membership() {
901 let mut coordinator = ElasticCoordinator::new(base_config()).unwrap();
902 coordinator
903 .simulate(&[
904 MembershipEvent::Join(0),
905 MembershipEvent::Join(1),
906 MembershipEvent::Join(2),
907 ])
908 .unwrap();
909 let members_before: Vec<usize> = coordinator.members().to_vec();
910 let world_before = coordinator.world_size();
911 let assignment_before = coordinator.shard_assignment();
912 let lr_before = coordinator.scaled_learning_rate();
913
914 coordinator.apply_event(MembershipEvent::Join(3)).unwrap();
916 assert_eq!(coordinator.world_size(), world_before + 1);
917 coordinator.apply_event(MembershipEvent::Leave(3)).unwrap();
918
919 assert_eq!(coordinator.members(), members_before.as_slice());
920 assert_eq!(coordinator.world_size(), world_before);
921 assert_eq!(coordinator.shard_assignment(), assignment_before);
922 assert_eq!(coordinator.scaled_learning_rate(), lr_before);
923 assert_eq!(coordinator.current_version(), 5);
925 }
926
927 #[test]
928 fn test_min_and_max_world_size_bounds_enforced() {
929 let config = ElasticConfig::new(0.1, 2, 12, 2, 4, 0).unwrap();
930 let mut coordinator = ElasticCoordinator::new(config).unwrap();
931
932 coordinator.apply_event(MembershipEvent::Join(0)).unwrap();
934 assert_eq!(coordinator.world_size(), 1);
935
936 for worker_id in 1..4usize {
938 coordinator
939 .apply_event(MembershipEvent::Join(worker_id))
940 .unwrap();
941 }
942 assert_eq!(coordinator.world_size(), 4);
943 let version_at_max = coordinator.current_version();
944
945 assert!(coordinator.apply_event(MembershipEvent::Join(4)).is_err());
947 assert_eq!(coordinator.current_version(), version_at_max);
948 assert_eq!(coordinator.world_size(), 4);
949
950 coordinator.apply_event(MembershipEvent::Leave(3)).unwrap();
952 coordinator.apply_event(MembershipEvent::Leave(2)).unwrap();
953 assert_eq!(coordinator.world_size(), 2);
954 let version_at_min = coordinator.current_version();
955
956 assert!(coordinator.apply_event(MembershipEvent::Leave(1)).is_err());
958 assert_eq!(coordinator.current_version(), version_at_min);
959 assert_eq!(coordinator.world_size(), 2);
960 }
961
962 #[test]
963 fn test_worker_lifecycle_states() {
964 let mut coordinator = ElasticCoordinator::new(base_config()).unwrap();
965 assert_eq!(coordinator.worker_state(0), None);
966 coordinator.apply_event(MembershipEvent::Join(0)).unwrap();
967 assert_eq!(coordinator.worker_state(0), Some(WorkerState::Active));
968 coordinator.apply_event(MembershipEvent::Join(1)).unwrap();
970 coordinator.apply_event(MembershipEvent::Leave(0)).unwrap();
971 assert_eq!(coordinator.worker_state(0), Some(WorkerState::Removed));
972 coordinator.apply_event(MembershipEvent::Join(0)).unwrap();
974 assert_eq!(coordinator.worker_state(0), Some(WorkerState::Active));
975
976 assert!(WorkerState::is_legal_transition(None, WorkerState::Pending));
978 assert!(WorkerState::is_legal_transition(
979 Some(WorkerState::Removed),
980 WorkerState::Pending
981 ));
982 assert!(WorkerState::is_legal_transition(
983 Some(WorkerState::Active),
984 WorkerState::Leaving
985 ));
986 assert!(!WorkerState::is_legal_transition(
987 Some(WorkerState::Active),
988 WorkerState::Pending
989 ));
990 assert!(!WorkerState::is_legal_transition(None, WorkerState::Active));
991 }
992
993 #[test]
994 fn test_hashed_assignment_balanced_deterministic_and_full_cover() {
995 for dataset_shards in [1usize, 9, 64, 100] {
996 for num_workers in 1usize..=8 {
997 let members: Vec<usize> = (0..num_workers).collect();
998 let assignment = hashed_assignment(&members, dataset_shards);
999 assert_eq!(assignment.len(), num_workers);
1000
1001 let sizes: Vec<usize> = assignment.iter().map(WorkerShards::len).collect();
1002 let total: usize = sizes.iter().sum();
1003 assert_eq!(total, dataset_shards, "must cover every shard");
1004 let min_size = sizes.iter().copied().min().unwrap_or(0);
1005 let max_size = sizes.iter().copied().max().unwrap_or(0);
1006 assert!(max_size - min_size <= 1, "hashed loads must be balanced");
1007
1008 let owners = hashed_owners(&assignment, dataset_shards);
1010 assert!(owners.iter().all(|&owner| owner != usize::MAX));
1011
1012 assert_eq!(assignment, hashed_assignment(&members, dataset_shards));
1014 for worker in &assignment {
1016 assert!(worker.shards.windows(2).all(|pair| pair[0] < pair[1]));
1017 }
1018 }
1019 }
1020 }
1021
1022 #[test]
1023 fn test_hashed_assignment_moves_fewer_shards_than_block() {
1024 let dataset_shards = 600usize;
1027 let memberships: Vec<Vec<usize>> = vec![
1028 (0..6).collect(),
1029 (0..5).collect(), (0..7).collect(), (1..7).collect(), (0..8).collect(), ];
1034
1035 let mut block_moves = 0usize;
1036 let mut hashed_moves = 0usize;
1037 for window in memberships.windows(2) {
1038 let before = &window[0];
1039 let after = &window[1];
1040
1041 let block_before =
1042 block_owners(&block_partition(before, dataset_shards), dataset_shards);
1043 let block_after = block_owners(&block_partition(after, dataset_shards), dataset_shards);
1044 block_moves += (0..dataset_shards)
1045 .filter(|&shard| block_before[shard] != block_after[shard])
1046 .count();
1047
1048 let hashed_before =
1049 hashed_owners(&hashed_assignment(before, dataset_shards), dataset_shards);
1050 let hashed_after =
1051 hashed_owners(&hashed_assignment(after, dataset_shards), dataset_shards);
1052 hashed_moves += (0..dataset_shards)
1053 .filter(|&shard| hashed_before[shard] != hashed_after[shard])
1054 .count();
1055 }
1056
1057 assert!(
1058 hashed_moves < block_moves,
1059 "rendezvous hashing should move fewer shards (hashed={hashed_moves}, block={block_moves})"
1060 );
1061 }
1062
1063 #[test]
1064 fn test_simulate_strict_and_resilient() {
1065 let config = base_config();
1066 let mut strict = ElasticCoordinator::new(config.clone()).unwrap();
1068 let result = strict.simulate(&[
1069 MembershipEvent::Join(0),
1070 MembershipEvent::Join(0), ]);
1072 assert!(result.is_err());
1073
1074 let mut resilient = ElasticCoordinator::new(config).unwrap();
1077 let snapshots = resilient.simulate_resilient(&[
1078 MembershipEvent::Join(0),
1079 MembershipEvent::Join(0), MembershipEvent::Join(1),
1081 MembershipEvent::Leave(5), MembershipEvent::Leave(0),
1083 ]);
1084 assert_eq!(snapshots.len(), 3, "three events accepted");
1085 assert_eq!(resilient.current_version(), 3);
1086 for (index, snapshot) in snapshots.iter().enumerate() {
1087 assert_eq!(snapshot.version, index as u64 + 1);
1088 }
1089 }
1090
1091 #[test]
1092 fn test_config_validation_errors() {
1093 assert!(ElasticConfig::new(0.0, 4, 12, 1, 16, 0).is_err());
1094 assert!(ElasticConfig::new(-1.0, 4, 12, 1, 16, 0).is_err());
1095 assert!(ElasticConfig::new(f64::NAN, 4, 12, 1, 16, 0).is_err());
1096 assert!(ElasticConfig::new(0.1, 0, 12, 1, 16, 0).is_err());
1097 assert!(ElasticConfig::new(0.1, 4, 0, 1, 16, 0).is_err());
1098 assert!(ElasticConfig::new(0.1, 4, 12, 0, 16, 0).is_err());
1099 assert!(ElasticConfig::new(0.1, 4, 12, 8, 4, 0).is_err());
1100 assert!(ElasticConfig::new(0.1, 4, 12, 1, 16, 0).is_ok());
1101 let bad = ElasticConfig {
1103 base_learning_rate: 0.1,
1104 reference_world_size: 0,
1105 dataset_shards: 1,
1106 min_world_size: 1,
1107 max_world_size: 1,
1108 warmup_steps: 0,
1109 };
1110 assert!(ElasticCoordinator::new(bad).is_err());
1111 }
1112}