1use crate::executor::RunMode;
13use crate::node_catalog::NodeCatalog;
14use crate::runner::Transport;
15use somatize_compiler::ExecutionPlan;
16use somatize_core::error::{Result, SomaError};
17use somatize_core::filter::RemoteTarget;
18use somatize_core::strategy::{
19 FederatedAggregation, GradientAggregation, Partition, TrainingStrategy,
20};
21use somatize_core::value::Value;
22use std::collections::HashMap;
23use std::sync::{Arc, Mutex};
24
25pub trait StrategyContext {
34 fn num_workers(&self) -> usize;
36
37 fn execute_on_worker(
39 &self,
40 worker_idx: usize,
41 plan: &serde_json::Value,
42 input: &Value,
43 y: Option<&Value>,
44 ) -> Result<HashMap<String, Value>>;
45
46 fn get_state(&self, worker_idx: usize, node_ids: &[String]) -> Result<HashMap<String, Value>>;
48
49 fn read_back_state(
61 &self,
62 worker_idx: usize,
63 node_ids: &[String],
64 ) -> Result<HashMap<String, Value>> {
65 self.get_state(worker_idx, node_ids)
66 }
67
68 fn set_state(&self, worker_idx: usize, states: &HashMap<String, Value>) -> Result<()>;
70
71 fn get_gradients(
73 &self,
74 worker_idx: usize,
75 node_ids: &[String],
76 ) -> Result<HashMap<String, Value>>;
77
78 fn apply_gradients(&self, worker_idx: usize, gradients: &HashMap<String, Value>) -> Result<()>;
80
81 fn execute_partition(
92 &self,
93 _worker_idx: usize,
94 _node_ids: &[String],
95 _input: &Value,
96 _y: Option<&Value>,
97 ) -> Result<(Value, HashMap<String, Value>)> {
98 Err(SomaError::Other(
99 "this context cannot run part of a plan, so a model-parallel \
100 partition has nowhere to go"
101 .into(),
102 ))
103 }
104
105 fn worker_for(&self, target: &RemoteTarget) -> Result<usize> {
111 Err(SomaError::Other(format!(
112 "this context does not know which worker is which, so {target:?} \
113 cannot be resolved"
114 )))
115 }
116}
117
118pub trait StrategyExecutor {
121 fn fit(
123 &self,
124 ctx: &dyn StrategyContext,
125 input: &Value,
126 y: Option<&Value>,
127 node_ids: &[String],
128 ) -> Result<HashMap<String, Value>>;
129}
130
131pub trait GradientAggregator {
133 fn aggregate(&self, gradients: &[HashMap<String, Value>]) -> Result<HashMap<String, Value>>;
136}
137
138pub trait StateAggregator {
140 fn aggregate(&self, states: &[HashMap<String, Value>]) -> Result<HashMap<String, Value>>;
143}
144
145impl StrategyExecutor for TrainingStrategy {
146 fn fit(
147 &self,
148 ctx: &dyn StrategyContext,
149 input: &Value,
150 y: Option<&Value>,
151 node_ids: &[String],
152 ) -> Result<HashMap<String, Value>> {
153 match self {
154 TrainingStrategy::Local => {
155 ctx.execute_on_worker(0, &serde_json::json!({}), input, y)
157 }
158
159 TrainingStrategy::DataParallel {
160 num_replicas,
161 aggregation,
162 } => {
163 let n = (*num_replicas).min(ctx.num_workers());
164 let (shards, y_shards) = shard_pair(input, y, n)?;
165
166 for (i, shard) in shards.iter().enumerate() {
169 ctx.execute_on_worker(i, &serde_json::json!({}), shard, y_shards[i].as_ref())?;
170 }
171
172 let mut all_grads = Vec::new();
174 for i in 0..n {
175 all_grads.push(ctx.get_gradients(i, node_ids)?);
176 }
177 let averaged = aggregation.aggregate(&all_grads)?;
178
179 for i in 0..n {
182 ctx.apply_gradients(i, &averaged)?;
183 }
184
185 ctx.read_back_state(0, node_ids)
190 }
191
192 TrainingStrategy::Federated {
193 num_clients,
194 rounds,
195 aggregation,
196 ..
197 } => {
198 let n = (*num_clients).min(ctx.num_workers());
199 let (shards, y_shards) = shard_pair(input, y, n)?;
200
201 for _round in 0..*rounds {
202 for (i, shard) in shards.iter().enumerate().take(n) {
204 ctx.execute_on_worker(
205 i,
206 &serde_json::json!({}),
207 shard,
208 y_shards[i].as_ref(),
209 )?;
210 }
211
212 let mut all_states = Vec::new();
214 for i in 0..n {
215 all_states.push(ctx.get_state(i, node_ids)?);
216 }
217 let aggregated = aggregation.aggregate(&all_states)?;
218
219 for i in 0..n {
221 ctx.set_state(i, &aggregated)?;
222 }
223 }
224
225 ctx.get_state(0, node_ids)
226 }
227
228 TrainingStrategy::ModelParallel { partitions, .. } => {
229 let stages = order_partitions(partitions, node_ids)?;
230
231 let mut activation = input.clone();
236 let mut states: HashMap<String, Value> = HashMap::new();
237 for (partition, ids) in &stages {
238 let worker = ctx.worker_for(&partition.target)?;
239 let (output, learned) = ctx.execute_partition(worker, ids, &activation, y)?;
240 states.extend(learned);
241 activation = output;
242 }
243 Ok(states)
244 }
245
246 TrainingStrategy::PopulationBased { .. } => {
247 Err(SomaError::Other(
254 "population-based training is not a distribution strategy: \
255 each member needs its own hyperparameters applied to the \
256 graph, and a worker is sent a plan, not a way to rebuild \
257 the filters. It runs as an executor instead, driven from \
258 Python:\n pbt = soma.Pbt(search_space=[...], \
259 population_size=8, generations=5)\n \
260 best = pbt.run(train, evaluate)"
261 .into(),
262 ))
263 }
264
265 TrainingStrategy::Custom { .. } => Err(SomaError::Other(
266 "Custom strategy requires a user-provided coordinator".into(),
267 )),
268
269 other => Err(SomaError::Other(format!(
275 "this runtime does not know how to run {other:?}. It was \
276 probably described by a newer version"
277 ))),
278 }
279 }
280}
281
282fn mean_of(label: &str, contributions: &[(usize, &Value)]) -> Result<Value> {
294 let (first_idx, first) = contributions[0];
295 match first {
296 Value::Tensor { values, shape } => {
297 let mut acc = vec![0.0f64; values.len()];
298 for (idx, value) in contributions {
299 let (v, s) = match value {
300 Value::Tensor { values, shape } => (values, shape),
301 other => {
302 return Err(SomaError::Other(format!(
303 "aggregating `{label}`: contributor {idx} has a \
304 {other:?} where contributor {first_idx} has a tensor"
305 )));
306 }
307 };
308 if s != shape {
309 return Err(SomaError::Other(format!(
310 "aggregating `{label}`: contributor {idx} has shape {s:?}, \
311 contributor {first_idx} has {shape:?}"
312 )));
313 }
314 for (slot, x) in acc.iter_mut().zip(v.iter()) {
315 *slot += *x;
316 }
317 }
318 let n = contributions.len() as f64;
319 for slot in &mut acc {
320 *slot /= n;
321 }
322 Ok(Value::tensor(acc, shape.clone()))
323 }
324 Value::Json(_) => {
329 let mut jsons = Vec::with_capacity(contributions.len());
330 for (idx, value) in contributions {
331 match value {
332 Value::Json(j) => jsons.push((*idx, j.as_ref())),
333 other => {
334 return Err(SomaError::Other(format!(
335 "aggregating `{label}`: contributor {idx} has a \
336 {other:?} where contributor {first_idx} has a dict"
337 )));
338 }
339 }
340 }
341 Ok(Value::json(mean_json(label, &jsons)?))
342 }
343 other => {
344 for (idx, value) in &contributions[1..] {
348 if *value != other {
349 return Err(SomaError::Other(format!(
350 "aggregating `{label}`: it is not a tensor or a dict, \
351 and contributor {idx} disagrees with contributor \
352 {first_idx}. A non-numeric state has no mean"
353 )));
354 }
355 }
356 Ok(other.clone())
357 }
358 }
359}
360
361fn mean_json(label: &str, values: &[(usize, &serde_json::Value)]) -> Result<serde_json::Value> {
369 use serde_json::Value as J;
370 let (first_idx, first) = values[0];
371 match first {
372 J::Number(_) => {
373 let mut sum = 0.0;
374 for (idx, v) in values {
375 sum += v.as_f64().ok_or_else(|| {
376 SomaError::Other(format!(
377 "aggregating `{label}`: contributor {idx} has {v} where \
378 contributor {first_idx} has a number"
379 ))
380 })?;
381 }
382 Ok(serde_json::json!(sum / values.len() as f64))
383 }
384 J::Object(first_map) => {
385 let mut out = serde_json::Map::new();
386 for key in first_map.keys() {
387 let mut inner = Vec::with_capacity(values.len());
388 for (idx, v) in values {
389 let child = v.get(key).ok_or_else(|| {
390 SomaError::Other(format!(
391 "aggregating `{label}`: contributor {idx} is missing \
392 `{key}`"
393 ))
394 })?;
395 inner.push((*idx, child));
396 }
397 out.insert(key.clone(), mean_json(&format!("{label}.{key}"), &inner)?);
398 }
399 Ok(J::Object(out))
400 }
401 J::Array(first_arr) => {
402 let mut out = Vec::with_capacity(first_arr.len());
403 for i in 0..first_arr.len() {
404 let mut inner = Vec::with_capacity(values.len());
405 for (idx, v) in values {
406 let arr = v.as_array().ok_or_else(|| {
407 SomaError::Other(format!(
408 "aggregating `{label}`: contributor {idx} is not an array"
409 ))
410 })?;
411 if arr.len() != first_arr.len() {
412 return Err(SomaError::Other(format!(
413 "aggregating `{label}`: contributor {idx} has {} elements, \
414 contributor {first_idx} has {}",
415 arr.len(),
416 first_arr.len()
417 )));
418 }
419 inner.push((*idx, &arr[i]));
420 }
421 out.push(mean_json(&format!("{label}[{i}]"), &inner)?);
422 }
423 Ok(J::Array(out))
424 }
425 other => {
426 for (idx, v) in &values[1..] {
427 if *v != other {
428 return Err(SomaError::Other(format!(
429 "aggregating `{label}`: contributor {idx} has {v}, contributor \
430 {first_idx} has {other}. Neither is numeric, so there is no mean"
431 )));
432 }
433 }
434 Ok(other.clone())
435 }
436 }
437}
438
439fn mean_by_key(what: &str, entries: &[HashMap<String, Value>]) -> Result<HashMap<String, Value>> {
441 if entries.is_empty() {
444 return Err(SomaError::Other(format!(
445 "averaging {what} over zero contributors: there is nothing to \
446 take a mean of"
447 )));
448 }
449 let mut out = HashMap::new();
450 for key in entries[0].keys() {
451 let mut contributions = Vec::with_capacity(entries.len());
452 for (idx, entry) in entries.iter().enumerate() {
453 match entry.get(key) {
454 Some(value) => contributions.push((idx, value)),
455 None => {
456 return Err(SomaError::Other(format!(
457 "aggregating {what}: `{key}` is missing from contributor \
458 {idx}. Averaging over whoever happens to have it would \
459 quietly weight the others"
460 )));
461 }
462 }
463 }
464 out.insert(key.clone(), mean_of(key, &contributions)?);
465 }
466 Ok(out)
467}
468
469impl GradientAggregator for GradientAggregation {
470 fn aggregate(&self, gradients: &[HashMap<String, Value>]) -> Result<HashMap<String, Value>> {
471 if gradients.len() == 1 {
474 return Ok(gradients[0].clone());
475 }
476 if gradients.is_empty() {
481 return Err(SomaError::Other(
482 "aggregating gradients from zero replicas: a data-parallel \
483 round with no workers to average over"
484 .into(),
485 ));
486 }
487 match self {
488 GradientAggregation::AllReduce => mean_by_key("gradients", gradients),
489 other => Err(SomaError::Other(format!(
490 "{other:?} is not implemented; only AllReduce (an element-wise \
491 mean) is"
492 ))),
493 }
494 }
495}
496
497impl StateAggregator for FederatedAggregation {
498 fn aggregate(&self, states: &[HashMap<String, Value>]) -> Result<HashMap<String, Value>> {
499 if states.is_empty() {
500 return Err(SomaError::Other(
501 "federated aggregation over zero clients".into(),
502 ));
503 }
504 if states.len() == 1 {
505 return Ok(states[0].clone());
506 }
507 match self {
508 FederatedAggregation::FedAvg => mean_by_key("client states", states),
509 FederatedAggregation::FedProx { .. } => Err(SomaError::Other(
514 "FedProx needs the previous global model to compute its proximal \
515 term, and this aggregator only receives the clients' states. \
516 FedAvg works today"
517 .into(),
518 )),
519 FederatedAggregation::FedYogi { .. } => Err(SomaError::Other(
520 "FedYogi needs the optimizer moments carried between rounds, and \
521 this aggregator is stateless. FedAvg works today"
522 .into(),
523 )),
524 other => Err(SomaError::Other(format!(
525 "this runtime does not know how to aggregate with {other:?}"
526 ))),
527 }
528 }
529}
530
531pub struct TransportContext<'a> {
542 transports: Vec<Arc<dyn Transport>>,
543 plan: &'a ExecutionPlan,
544 catalog: &'a NodeCatalog,
545 seed: Option<i64>,
546 states: Mutex<Vec<HashMap<String, Value>>>,
548 identities: Vec<WorkerIdentity>,
553}
554
555#[derive(Debug, Clone)]
557pub struct WorkerIdentity {
558 pub id: String,
560 pub tags: Vec<String>,
562}
563
564impl<'a> TransportContext<'a> {
565 pub fn new(
567 transports: Vec<Arc<dyn Transport>>,
568 plan: &'a ExecutionPlan,
569 catalog: &'a NodeCatalog,
570 seed: Option<i64>,
571 ) -> Self {
572 let n = transports.len();
573 Self {
574 transports,
575 plan,
576 catalog,
577 seed,
578 states: Mutex::new(vec![HashMap::new(); n]),
579 identities: Vec::new(),
580 }
581 }
582
583 pub fn with_targets(mut self, identities: Vec<WorkerIdentity>) -> Self {
586 self.identities = identities;
587 self
588 }
589
590 fn transport(&self, idx: usize) -> Result<&Arc<dyn Transport>> {
591 self.transports.get(idx).ok_or_else(|| {
592 SomaError::Other(format!(
593 "worker {idx} was asked for, but only {} are registered",
594 self.transports.len()
595 ))
596 })
597 }
598}
599
600impl StrategyContext for TransportContext<'_> {
601 fn num_workers(&self) -> usize {
602 self.transports.len()
603 }
604
605 fn execute_on_worker(
606 &self,
607 worker_idx: usize,
608 _plan: &serde_json::Value,
609 input: &Value,
610 y: Option<&Value>,
611 ) -> Result<HashMap<String, Value>> {
612 let (_, states) = self.transport(worker_idx)?.execute(
616 self.plan,
617 self.catalog,
618 input,
619 &RunMode::Fit { y: y.cloned() },
620 self.seed,
621 )?;
622 if let Ok(mut cache) = self.states.lock() {
623 cache[worker_idx] = states.clone();
624 }
625 Ok(states)
626 }
627
628 fn get_state(&self, worker_idx: usize, node_ids: &[String]) -> Result<HashMap<String, Value>> {
629 let cache = self
630 .states
631 .lock()
632 .map_err(|e| SomaError::Other(format!("state cache poisoned: {e}")))?;
633 let states = cache.get(worker_idx).ok_or_else(|| {
634 SomaError::Other(format!("worker {worker_idx} has no recorded state"))
635 })?;
636 if node_ids.is_empty() {
637 return Ok(states.clone());
638 }
639 Ok(node_ids
640 .iter()
641 .filter_map(|id| states.get(id).map(|v| (id.clone(), v.clone())))
642 .collect())
643 }
644
645 fn worker_for(&self, target: &RemoteTarget) -> Result<usize> {
646 if self.identities.is_empty() {
647 return Err(SomaError::Other(format!(
648 "this context was built without worker identities, so {target:?} \
649 cannot be resolved. Build it with `with_targets`"
650 )));
651 }
652 let found = match target {
653 RemoteTarget::WorkerId(id) => self.identities.iter().position(|w| &w.id == id),
654 RemoteTarget::Tag(tag) => self
655 .identities
656 .iter()
657 .position(|w| w.tags.iter().any(|t| t == tag)),
658 };
659 found.ok_or_else(|| {
660 SomaError::Other(format!(
661 "no registered worker answers to {target:?}. Registered: {}",
662 self.identities
663 .iter()
664 .map(|w| format!("{} {:?}", w.id, w.tags))
665 .collect::<Vec<_>>()
666 .join(", ")
667 ))
668 })
669 }
670
671 fn execute_partition(
672 &self,
673 worker_idx: usize,
674 node_ids: &[String],
675 input: &Value,
676 y: Option<&Value>,
677 ) -> Result<(Value, HashMap<String, Value>)> {
678 let stage = ExecutionPlan::Sequence(
681 node_ids
682 .iter()
683 .map(|node_id| ExecutionPlan::Execute {
684 node_id: node_id.clone(),
685 })
686 .collect(),
687 );
688 let (output, states) = self.transport(worker_idx)?.execute(
689 &stage,
690 self.catalog,
691 input,
692 &RunMode::Fit { y: y.cloned() },
693 self.seed,
694 )?;
695 if let Ok(mut cache) = self.states.lock()
696 && let Some(slot) = cache.get_mut(worker_idx)
697 {
698 slot.extend(states.clone());
699 }
700 Ok((output, states))
701 }
702
703 fn read_back_state(
704 &self,
705 worker_idx: usize,
706 node_ids: &[String],
707 ) -> Result<HashMap<String, Value>> {
708 let states = self.transport(worker_idx)?.get_state(node_ids)?;
709 if let Ok(mut cache) = self.states.lock()
711 && let Some(slot) = cache.get_mut(worker_idx)
712 {
713 for (id, value) in &states {
714 slot.insert(id.clone(), value.clone());
715 }
716 }
717 Ok(states)
718 }
719
720 fn set_state(&self, worker_idx: usize, states: &HashMap<String, Value>) -> Result<()> {
721 for (node_id, state) in states {
724 self.catalog.try_set_state(node_id.clone(), state.clone())?;
725 }
726 if let Ok(mut cache) = self.states.lock()
732 && let Some(slot) = cache.get_mut(worker_idx)
733 {
734 for (node_id, state) in states {
735 slot.insert(node_id.clone(), state.clone());
736 }
737 }
738 Ok(())
739 }
740
741 fn get_gradients(
742 &self,
743 worker_idx: usize,
744 node_ids: &[String],
745 ) -> Result<HashMap<String, Value>> {
746 self.transport(worker_idx)?.get_gradients(node_ids)
747 }
748
749 fn apply_gradients(&self, worker_idx: usize, gradients: &HashMap<String, Value>) -> Result<()> {
750 self.transport(worker_idx)?.apply_gradients(gradients)
751 }
752}
753
754fn order_partitions<'a>(
762 partitions: &'a [Partition],
763 node_ids: &[String],
764) -> Result<Vec<(&'a Partition, Vec<String>)>> {
765 if partitions.is_empty() {
766 return Err(SomaError::Other(
767 "model-parallel training with no partitions: there is nothing to \
768 say where any node runs"
769 .into(),
770 ));
771 }
772 let position: HashMap<&str, usize> = node_ids
773 .iter()
774 .enumerate()
775 .map(|(i, id)| (id.as_str(), i))
776 .collect();
777
778 let mut claimed: HashMap<&str, usize> = HashMap::new();
779 let mut stages: Vec<(&Partition, Vec<usize>)> = Vec::new();
780 for (p_idx, partition) in partitions.iter().enumerate() {
781 let mut positions = Vec::with_capacity(partition.node_ids.len());
782 for node in &partition.node_ids {
783 let Some(&pos) = position.get(node.as_str()) else {
784 return Err(SomaError::Other(format!(
785 "partition {p_idx} claims `{node}`, which is not in this \
786 graph. Its nodes are: {}",
787 node_ids.join(", ")
788 )));
789 };
790 if let Some(&first) = claimed.get(node.as_str()) {
791 return Err(SomaError::Other(format!(
792 "`{node}` is claimed by partitions {first} and {p_idx}. A \
793 node runs in one place"
794 )));
795 }
796 claimed.insert(node.as_str(), p_idx);
797 positions.push(pos);
798 }
799 positions.sort_unstable();
800 stages.push((partition, positions));
801 }
802
803 let unclaimed: Vec<&str> = node_ids
804 .iter()
805 .map(String::as_str)
806 .filter(|id| !claimed.contains_key(id))
807 .collect();
808 if !unclaimed.is_empty() {
809 return Err(SomaError::Other(format!(
810 "no partition claims {}. Every node needs a worker; model \
811 parallelism has no default target",
812 unclaimed.join(", ")
813 )));
814 }
815
816 stages.sort_by_key(|(_, positions)| positions.first().copied().unwrap_or(0));
817 let mut next = 0usize;
819 for (p_idx, (_, positions)) in stages.iter().enumerate() {
820 for &pos in positions {
821 if pos != next {
822 return Err(SomaError::Other(format!(
823 "partition {p_idx} is interleaved with another: it owns \
824 `{}` but not `{}`, which runs before it. A stage has to \
825 own a contiguous run of the graph",
826 node_ids[pos], node_ids[next]
827 )));
828 }
829 next += 1;
830 }
831 }
832
833 Ok(stages
834 .into_iter()
835 .map(|(partition, positions)| {
836 let ids = positions.iter().map(|&i| node_ids[i].clone()).collect();
837 (partition, ids)
838 })
839 .collect())
840}
841
842fn shard_pair(x: &Value, y: Option<&Value>, n: usize) -> Result<(Vec<Value>, Vec<Option<Value>>)> {
854 let x_shards = shard_value(x, n);
855 let Some(y) = y else {
856 return Ok((x_shards, vec![None; n]));
857 };
858 if let (Some(xr), Some(yr)) = (rows_of(x), rows_of(y))
859 && xr != yr
860 {
861 return Err(SomaError::Other(format!(
862 "sharding across {n} workers: the input has {xr} rows and the \
863 targets have {yr}. Each shard pairs example i with target i, \
864 so the two must agree"
865 )));
866 }
867 let y_shards = shard_value(y, n);
868 if y_shards.len() != x_shards.len() {
869 return Err(SomaError::Other(format!(
870 "sharding across {n} workers: the input split into {} shards and \
871 the targets into {}",
872 x_shards.len(),
873 y_shards.len()
874 )));
875 }
876 Ok((x_shards, y_shards.into_iter().map(Some).collect()))
877}
878
879fn rows_of(value: &Value) -> Option<usize> {
881 match value {
882 Value::Tensor { shape, .. } if !shape.is_empty() => Some(shape[0]),
883 _ => None,
884 }
885}
886
887fn shard_value(value: &Value, n: usize) -> Vec<Value> {
889 match value {
890 Value::Tensor { values, shape } if !shape.is_empty() && shape[0] >= n => {
891 let rows = shape[0];
892 let row_size: usize = shape[1..].iter().product::<usize>().max(1);
893 let shard_rows = rows / n;
894 let mut shards = Vec::new();
895 for i in 0..n {
896 let start = i * shard_rows;
897 let end = if i == n - 1 { rows } else { start + shard_rows };
898 let flat_start = start * row_size;
899 let flat_end = end * row_size;
900 let shard_vals = values[flat_start..flat_end].to_vec();
901 let mut shard_shape = shape.clone();
902 shard_shape[0] = end - start;
903 shards.push(Value::tensor(shard_vals, shard_shape));
904 }
905 shards
906 }
907 _ => (0..n).map(|_| value.clone()).collect(),
908 }
909}
910
911#[cfg(test)]
912mod tests {
913 use super::*;
914 use somatize_core::strategy::ClientSelection;
915
916 fn one(node: &str, values: Vec<f64>) -> HashMap<String, Value> {
917 let n = values.len();
918 HashMap::from([(node.to_string(), Value::tensor(values, vec![n]))])
919 }
920
921 fn part(nodes: &[&str], tag: &str) -> Partition {
922 Partition {
923 node_ids: nodes.iter().map(|s| s.to_string()).collect(),
924 target: RemoteTarget::Tag(tag.into()),
925 }
926 }
927
928 fn ids(names: &[&str]) -> Vec<String> {
929 names.iter().map(|s| s.to_string()).collect()
930 }
931
932 #[test]
935 fn partitions_are_ordered_by_the_plan_not_by_declaration() {
936 let declared = [part(&["c", "d"], "gpu1"), part(&["a", "b"], "gpu0")];
937 let stages = order_partitions(&declared, &ids(&["a", "b", "c", "d"])).unwrap();
938 assert_eq!(stages.len(), 2);
939 assert_eq!(stages[0].1, ids(&["a", "b"]));
940 assert_eq!(stages[1].1, ids(&["c", "d"]));
941 }
942
943 #[test]
946 fn a_node_in_two_partitions_is_refused() {
947 let declared = [part(&["a", "b"], "gpu0"), part(&["b"], "gpu1")];
948 let err = order_partitions(&declared, &ids(&["a", "b"]))
949 .unwrap_err()
950 .to_string();
951 assert!(
952 err.contains("`b` is claimed by partitions 0 and 1"),
953 "{err}"
954 );
955 }
956
957 #[test]
960 fn an_unclaimed_node_is_refused_by_name() {
961 let declared = [part(&["a"], "gpu0")];
962 let err = order_partitions(&declared, &ids(&["a", "b"]))
963 .unwrap_err()
964 .to_string();
965 assert!(err.contains("no partition claims b"), "{err}");
966 }
967
968 #[test]
971 fn interleaved_partitions_are_refused() {
972 let declared = [part(&["a", "c"], "gpu0"), part(&["b"], "gpu1")];
973 let err = order_partitions(&declared, &ids(&["a", "b", "c"]))
974 .unwrap_err()
975 .to_string();
976 assert!(err.contains("interleaved"), "{err}");
977 }
978
979 #[test]
980 fn no_partitions_at_all_is_refused() {
981 let err = order_partitions(&[], &ids(&["a"])).unwrap_err().to_string();
982 assert!(err.contains("nothing to say where any node runs"), "{err}");
983 }
984
985 #[test]
989 fn model_parallel_threads_the_activation_between_stages() {
990 use std::sync::Mutex as StdMutex;
991
992 #[derive(Default)]
993 struct Chain {
994 seen: StdMutex<Vec<(usize, Vec<String>, Value)>>,
995 }
996 impl StrategyContext for Chain {
997 fn num_workers(&self) -> usize {
998 2
999 }
1000 fn execute_on_worker(
1001 &self,
1002 _: usize,
1003 _: &serde_json::Value,
1004 _: &Value,
1005 _: Option<&Value>,
1006 ) -> Result<HashMap<String, Value>> {
1007 unreachable!("model parallelism runs partitions, not whole plans")
1008 }
1009 fn execute_partition(
1010 &self,
1011 worker_idx: usize,
1012 node_ids: &[String],
1013 input: &Value,
1014 _: Option<&Value>,
1015 ) -> Result<(Value, HashMap<String, Value>)> {
1016 self.seen
1017 .lock()
1018 .unwrap()
1019 .push((worker_idx, node_ids.to_vec(), input.clone()));
1020 let next = match input {
1022 Value::Tensor { values, shape } => {
1023 Value::tensor(values.iter().map(|v| v + 1.0).collect(), shape.clone())
1024 }
1025 other => other.clone(),
1026 };
1027 let states = node_ids
1028 .iter()
1029 .map(|id| (id.clone(), Value::tensor(vec![1.0], vec![1])))
1030 .collect();
1031 Ok((next, states))
1032 }
1033 fn worker_for(&self, target: &RemoteTarget) -> Result<usize> {
1034 match target {
1035 RemoteTarget::Tag(t) if t == "gpu0" => Ok(0),
1036 RemoteTarget::Tag(t) if t == "gpu1" => Ok(1),
1037 other => Err(SomaError::Other(format!("no worker for {other:?}"))),
1038 }
1039 }
1040 fn get_state(&self, _: usize, _: &[String]) -> Result<HashMap<String, Value>> {
1041 Ok(HashMap::new())
1042 }
1043 fn set_state(&self, _: usize, _: &HashMap<String, Value>) -> Result<()> {
1044 Ok(())
1045 }
1046 fn get_gradients(&self, _: usize, _: &[String]) -> Result<HashMap<String, Value>> {
1047 Ok(HashMap::new())
1048 }
1049 fn apply_gradients(&self, _: usize, _: &HashMap<String, Value>) -> Result<()> {
1050 Ok(())
1051 }
1052 }
1053
1054 let ctx = Chain::default();
1055 let states = TrainingStrategy::ModelParallel {
1056 partitions: vec![part(&["a"], "gpu0"), part(&["b"], "gpu1")],
1057 communication: somatize_core::strategy::CommunicationProtocol::DataStore,
1058 }
1059 .fit(
1060 &ctx,
1061 &Value::tensor(vec![10.0], vec![1]),
1062 None,
1063 &ids(&["a", "b"]),
1064 )
1065 .unwrap();
1066
1067 let seen = ctx.seen.lock().unwrap();
1068 assert_eq!(seen.len(), 2, "one call per stage");
1069 assert_eq!(seen[0].0, 0, "stage 1 on gpu0");
1070 assert_eq!(seen[0].2, Value::tensor(vec![10.0], vec![1]));
1071 assert_eq!(seen[1].0, 1, "stage 2 on gpu1");
1072 assert_eq!(
1073 seen[1].2,
1074 Value::tensor(vec![11.0], vec![1]),
1075 "stage 2 must receive stage 1's output, not the graph input"
1076 );
1077 assert_eq!(states.len(), 2);
1079 assert!(states.contains_key("a") && states.contains_key("b"));
1080 }
1081
1082 #[test]
1085 fn an_unnamed_worker_pool_refuses_a_pinned_partition() {
1086 let plan = ExecutionPlan::Empty;
1087 let catalog = NodeCatalog::new();
1088 let ctx = TransportContext::new(Vec::new(), &plan, &catalog, None);
1089 let err = ctx
1090 .worker_for(&RemoteTarget::Tag("gpu".into()))
1091 .unwrap_err()
1092 .to_string();
1093 assert!(err.contains("with_targets"), "{err}");
1094
1095 let ctx = TransportContext::new(Vec::new(), &plan, &catalog, None).with_targets(vec![
1096 WorkerIdentity {
1097 id: "ws://a".into(),
1098 tags: vec!["cpu".into()],
1099 },
1100 ]);
1101 assert!(ctx.worker_for(&RemoteTarget::Tag("cpu".into())).unwrap() == 0);
1102 assert!(
1103 ctx.worker_for(&RemoteTarget::WorkerId("ws://a".into()))
1104 .unwrap()
1105 == 0
1106 );
1107 let err = ctx
1108 .worker_for(&RemoteTarget::Tag("gpu".into()))
1109 .unwrap_err()
1110 .to_string();
1111 assert!(err.contains("no registered worker"), "{err}");
1112 }
1113
1114 #[test]
1119 fn aggregating_over_zero_contributors_errors_rather_than_panicking() {
1120 let err = GradientAggregation::AllReduce
1121 .aggregate(&[])
1122 .unwrap_err()
1123 .to_string();
1124 assert!(err.contains("zero replicas"), "{err}");
1125
1126 let err = FederatedAggregation::FedAvg
1127 .aggregate(&[])
1128 .unwrap_err()
1129 .to_string();
1130 assert!(err.contains("zero clients"), "{err}");
1131
1132 let err = mean_by_key("things", &[]).unwrap_err().to_string();
1135 assert!(err.contains("zero contributors"), "{err}");
1136 }
1137
1138 #[test]
1142 fn shard_pair_splits_targets_alongside_inputs() {
1143 let x = Value::tensor(vec![1.0, 2.0, 3.0, 4.0], vec![4, 1]);
1144 let y = Value::tensor(vec![10.0, 20.0, 30.0, 40.0], vec![4, 1]);
1145 let (xs, ys) = shard_pair(&x, Some(&y), 2).unwrap();
1146 assert_eq!(xs[0], Value::tensor(vec![1.0, 2.0], vec![2, 1]));
1147 assert_eq!(ys[0], Some(Value::tensor(vec![10.0, 20.0], vec![2, 1])));
1148 assert_eq!(xs[1], Value::tensor(vec![3.0, 4.0], vec![2, 1]));
1149 assert_eq!(ys[1], Some(Value::tensor(vec![30.0, 40.0], vec![2, 1])));
1150 }
1151
1152 #[test]
1153 fn shard_pair_refuses_row_counts_that_disagree() {
1154 let x = Value::tensor(vec![1.0, 2.0, 3.0, 4.0], vec![4, 1]);
1155 let y = Value::tensor(vec![10.0, 20.0], vec![2, 1]);
1156 let err = shard_pair(&x, Some(&y), 2).unwrap_err().to_string();
1157 assert!(
1158 err.contains("4 rows") && err.contains("2"),
1159 "the error should name both counts: {err}"
1160 );
1161 }
1162
1163 #[test]
1164 fn shard_pair_without_targets_yields_none_per_shard() {
1165 let x = Value::tensor(vec![1.0, 2.0], vec![2, 1]);
1166 let (xs, ys) = shard_pair(&x, None, 2).unwrap();
1167 assert_eq!(xs.len(), 2);
1168 assert_eq!(ys, vec![None, None]);
1169 }
1170
1171 #[test]
1175 fn fedavg_averages_element_wise() {
1176 let out = FederatedAggregation::FedAvg
1177 .aggregate(&[one("w", vec![1.0, 10.0]), one("w", vec![3.0, 20.0])])
1178 .unwrap();
1179 assert_eq!(out["w"], Value::tensor(vec![2.0, 15.0], vec![2]));
1180
1181 let out = FederatedAggregation::FedAvg
1182 .aggregate(&[
1183 one("w", vec![0.0]),
1184 one("w", vec![3.0]),
1185 one("w", vec![6.0]),
1186 ])
1187 .unwrap();
1188 assert_eq!(out["w"], Value::tensor(vec![3.0], vec![1]));
1189 }
1190
1191 #[test]
1196 fn allreduce_averages_and_the_others_say_what_they_are_not() {
1197 let out = GradientAggregation::AllReduce
1198 .aggregate(&[one("w", vec![2.0]), one("w", vec![4.0])])
1199 .unwrap();
1200 assert_eq!(out["w"], Value::tensor(vec![3.0], vec![1]));
1201
1202 let err = GradientAggregation::ParameterServer
1203 .aggregate(&[one("w", vec![1.0]), one("w", vec![2.0])])
1204 .expect_err("only AllReduce is implemented");
1205 let err = err.to_string();
1206 assert!(err.contains("ParameterServer"), "name the variant: {err}");
1207 assert!(err.contains("AllReduce"), "name what does work: {err}");
1208 }
1209
1210 #[test]
1212 fn a_contributor_missing_a_key_is_an_error_naming_it() {
1213 let err = FederatedAggregation::FedAvg
1214 .aggregate(&[one("w", vec![1.0]), one("other", vec![2.0])])
1215 .expect_err("averaging over whoever has the key would misweight");
1216 let msg = err.to_string();
1217 assert!(
1218 msg.contains("`w`") && msg.contains("contributor 1"),
1219 "{msg}"
1220 );
1221 }
1222
1223 #[test]
1224 fn mismatched_shapes_name_both() {
1225 let err = FederatedAggregation::FedAvg
1226 .aggregate(&[one("w", vec![1.0, 2.0]), one("w", vec![3.0])])
1227 .expect_err("shapes that disagree have no mean");
1228 let msg = err.to_string();
1229 assert!(msg.contains("[1]") && msg.contains("[2]"), "{msg}");
1230 }
1231
1232 #[test]
1235 fn the_adaptive_variants_say_what_they_would_need() {
1236 let two = [one("w", vec![1.0]), one("w", vec![3.0])];
1237 let err = FederatedAggregation::FedProx { mu: 0.1 }
1238 .aggregate(&two)
1239 .unwrap_err()
1240 .to_string();
1241 assert!(err.contains("global model"), "{err}");
1242 let err = FederatedAggregation::FedYogi {
1243 beta1: 0.9,
1244 beta2: 0.99,
1245 tau: 1e-3,
1246 }
1247 .aggregate(&two)
1248 .unwrap_err()
1249 .to_string();
1250 assert!(err.contains("moments"), "{err}");
1251 }
1252
1253 #[test]
1256 fn single_worker_aggregation_is_the_identity() {
1257 let only = one("w", vec![2.0]);
1258 let out = GradientAggregation::AllReduce
1259 .aggregate(std::slice::from_ref(&only))
1260 .unwrap();
1261 assert_eq!(out, only);
1262 }
1263
1264 #[test]
1270 fn the_federated_loop_converges_to_the_mean_of_its_clients() {
1271 use somatize_compiler::ExecutionPlan;
1272 use std::sync::atomic::{AtomicUsize, Ordering};
1273
1274 struct ShardMean {
1275 calls: AtomicUsize,
1276 }
1277 impl Transport for ShardMean {
1278 fn execute(
1279 &self,
1280 _plan: &ExecutionPlan,
1281 _filters: &NodeCatalog,
1282 input: &Value,
1283 _mode: &RunMode,
1284 _seed: Option<i64>,
1285 ) -> Result<(Value, HashMap<String, Value>)> {
1286 self.calls.fetch_add(1, Ordering::SeqCst);
1287 let mean = match input {
1288 Value::Tensor { values, .. } if !values.is_empty() => {
1289 values.iter().sum::<f64>() / values.len() as f64
1290 }
1291 _ => 0.0,
1292 };
1293 Ok((Value::Empty, one("m", vec![mean])))
1294 }
1295 fn get_state(&self, _: &[String]) -> Result<HashMap<String, Value>> {
1296 Ok(HashMap::new())
1297 }
1298 fn set_state(&self, _: &HashMap<String, Value>) -> Result<()> {
1299 Ok(())
1300 }
1301 fn get_gradients(&self, _: &[String]) -> Result<HashMap<String, Value>> {
1302 Ok(HashMap::new())
1303 }
1304 fn apply_gradients(&self, _: &HashMap<String, Value>) -> Result<()> {
1305 Ok(())
1306 }
1307 }
1308
1309 let transports: Vec<Arc<dyn Transport>> = vec![
1310 Arc::new(ShardMean {
1311 calls: AtomicUsize::new(0),
1312 }),
1313 Arc::new(ShardMean {
1314 calls: AtomicUsize::new(0),
1315 }),
1316 ];
1317 let plan = ExecutionPlan::Execute {
1318 node_id: "m".into(),
1319 };
1320 let catalog = NodeCatalog::new();
1321 let ctx = TransportContext::new(transports, &plan, &catalog, None);
1322
1323 let input = Value::tensor((0..8).map(|i| i as f64).collect(), vec![8]);
1325 let strategy = TrainingStrategy::Federated {
1326 num_clients: 2,
1327 rounds: 2,
1328 aggregation: FederatedAggregation::FedAvg,
1329 client_selection: ClientSelection::All,
1330 };
1331 let out = strategy
1332 .fit(&ctx, &input, None, &["m".to_string()])
1333 .expect("the federated loop must run");
1334
1335 let Value::Tensor { values, .. } = &out["m"] else {
1336 panic!("expected a tensor, got {:?}", out["m"]);
1337 };
1338 assert!((values[0] - 3.5).abs() < 1e-9, "got {}", values[0]);
1339 assert!((values[0] - 1.5).abs() > 1e-6 && (values[0] - 5.5).abs() > 1e-6);
1341 }
1342
1343 #[test]
1352 fn data_parallel_runs_its_loop() {
1353 use somatize_compiler::ExecutionPlan;
1354
1355 struct Noop;
1356 impl Transport for Noop {
1357 fn execute(
1358 &self,
1359 _: &ExecutionPlan,
1360 _: &NodeCatalog,
1361 _: &Value,
1362 _: &RunMode,
1363 _: Option<i64>,
1364 ) -> Result<(Value, HashMap<String, Value>)> {
1365 Ok((Value::Empty, HashMap::new()))
1366 }
1367 fn get_state(&self, _: &[String]) -> Result<HashMap<String, Value>> {
1368 Ok(HashMap::new())
1369 }
1370 fn set_state(&self, _: &HashMap<String, Value>) -> Result<()> {
1371 Ok(())
1372 }
1373 fn get_gradients(&self, _: &[String]) -> Result<HashMap<String, Value>> {
1374 Ok(HashMap::new())
1375 }
1376 fn apply_gradients(&self, _: &HashMap<String, Value>) -> Result<()> {
1377 Ok(())
1378 }
1379 }
1380
1381 let transports: Vec<Arc<dyn Transport>> = vec![Arc::new(Noop), Arc::new(Noop)];
1382 let plan = ExecutionPlan::Execute {
1383 node_id: "m".into(),
1384 };
1385 let catalog = NodeCatalog::new();
1386 let ctx = TransportContext::new(transports, &plan, &catalog, None);
1387
1388 let out = TrainingStrategy::DataParallel {
1389 num_replicas: 2,
1390 aggregation: GradientAggregation::AllReduce,
1391 }
1392 .fit(
1393 &ctx,
1394 &Value::tensor(vec![1.0, 2.0], vec![2]),
1395 None,
1396 &["m".to_string()],
1397 )
1398 .expect("DataParallel drives the workers through the context");
1399 assert!(
1400 out.is_empty(),
1401 "a filter with no parameters contributes no gradients: {out:?}"
1402 );
1403 }
1404}