Skip to main content

somatize_runtime/
strategy.rs

1//! Running a [`TrainingStrategy`].
2//!
3//! The strategy *types* are contracts — a strategy is a graph-level
4//! attribute, part of what a graph is — so they live in `soma-core`
5//! beside the graph. Running one is not: it shards inputs, calls workers
6//! in a round loop, aggregates gradients and redistributes states. That
7//! is execution, and execution lives here.
8//!
9//! See the "soma-core holds contracts, not execution" entry in the design
10//! decisions.
11
12use 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
25// ── The execution contracts ──
26//
27// These describe how a strategy is *run*, so they belong beside the
28// running of it. `soma-core` keeps the strategy types themselves, which
29// are graph attributes and therefore contracts.
30
31/// Context provided to strategy executors.
32/// Abstracts worker communication — the strategy doesn't know about WS/HTTP.
33pub trait StrategyContext {
34    /// Number of available workers.
35    fn num_workers(&self) -> usize;
36
37    /// Execute a plan on a specific worker (by index). Returns trained states.
38    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    /// Get trained states from a worker.
47    fn get_state(&self, worker_idx: usize, node_ids: &[String]) -> Result<HashMap<String, Value>>;
48
49    /// Read a worker's state *now*, over the wire, rather than recalling
50    /// what its last fit returned.
51    ///
52    /// The two differ exactly when something changed the model after the
53    /// fit — which is what [`apply_gradients`](Self::apply_gradients) does.
54    /// A data-parallel round that finished with `get_state` handed back the
55    /// weights each replica had *before* the averaged gradient was applied,
56    /// so the training it had just done was discarded on the way out.
57    ///
58    /// Defaults to `get_state`, for a context whose two answers cannot
59    /// differ.
60    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    /// Set states on a worker (e.g. after aggregation).
69    fn set_state(&self, worker_idx: usize, states: &HashMap<String, Value>) -> Result<()>;
70
71    /// Get gradients from a worker.
72    fn get_gradients(
73        &self,
74        worker_idx: usize,
75        node_ids: &[String],
76    ) -> Result<HashMap<String, Value>>;
77
78    /// Apply gradients on a worker.
79    fn apply_gradients(&self, worker_idx: usize, gradients: &HashMap<String, Value>) -> Result<()>;
80
81    /// Run *part* of the graph on a worker, returning the activation and
82    /// the states it learned.
83    ///
84    /// This is what model parallelism needs and data parallelism does
85    /// not: every other strategy runs the whole plan on each worker and
86    /// only ever wants the states back. Here each worker holds a slice of
87    /// the model, so its output is the next worker's input.
88    ///
89    /// Defaults to refusing, so a context that cannot address part of a
90    /// plan says so instead of silently running all of it.
91    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    /// Which worker answers to `target`.
106    ///
107    /// Every other strategy indexes workers by position, because every
108    /// worker is interchangeable to it. A partition is pinned to one, so
109    /// it has to be found by id or tag.
110    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
118/// Contract for training strategy execution.
119/// Every TrainingStrategy variant implements this — including Local.
120pub trait StrategyExecutor {
121    /// Train the model according to this strategy.
122    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
131/// Contract for gradient aggregation across workers.
132pub trait GradientAggregator {
133    /// Combine per-worker gradients (keyed by node id) into the one set
134    /// every worker then applies.
135    fn aggregate(&self, gradients: &[HashMap<String, Value>]) -> Result<HashMap<String, Value>>;
136}
137
138/// Contract for federated state aggregation.
139pub trait StateAggregator {
140    /// Combine per-worker trained states (keyed by node id) into the one
141    /// set redistributed to every worker.
142    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                // Single worker, full dataset
156                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                // Fit on each worker with its shard — inputs and targets
167                // split together, so example i still meets target i.
168                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                // Collect and aggregate gradients
173                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                // Apply to all workers. This is where the step happens: the
180                // replicas move together, on the mean of what they each saw.
181                for i in 0..n {
182                    ctx.apply_gradients(i, &averaged)?;
183                }
184
185                // Read worker 0 back over the wire. `get_state` would return
186                // what its fit returned — the weights from *before* the
187                // averaged gradient was applied — so the round would train
188                // and then hand back the untrained model.
189                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                    // Each client trains on its shard
203                    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                    // Collect and aggregate states
213                    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                    // Distribute back
220                    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                // Each stage runs where it was pinned, and hands its
232                // activation to the next one. That is the whole of model
233                // parallelism on the forward path: the model is split, the
234                // data is not.
235                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                // Not a missing implementation — a wrong home. PBT gives
248                // each member DIFFERENT hyperparameters, and applying them
249                // means rebuilding the graph's filters with new configs.
250                // A strategy only gets to send a plan; the configs live in
251                // the caller's language. Which is exactly the shape of
252                // `Study`, and why `PbtRunner` takes a callback.
253                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            // `TrainingStrategy` is `#[non_exhaustive]` and now lives in
270            // another crate, so this arm cannot be deleted. It refuses
271            // rather than falling back to something plausible: running a
272            // strategy this build does not understand as if it were
273            // `Local` would train on one worker and report success.
274            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
282// Both aggregators used to answer with `first()` — one worker's gradients
283// presented as the average of all of them. That is not an unfinished
284// feature, it is a wrong number that trains a model and reports success.
285
286/// Element-wise mean of one node's state across contributors.
287///
288/// Refuses rather than guesses, on purpose. A key one contributor lacks,
289/// a shape that disagrees, or a non-tensor state that is not identical
290/// everywhere all produce an error naming what differed — because the
291/// alternative is an average over a subset, silently, in the middle of
292/// training.
293fn 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        // A Python filter's state is a dict — `{"mu": 1.5}` — which
325        // arrives as Json. Averaging its numeric leaves is exactly what
326        // FedAvg means for it, and refusing would have made this useless
327        // for the filters people actually write.
328        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            // Nothing to average. Identical everywhere is a legitimate
345            // constant; anything else has no mean and inventing one would
346            // be worse than stopping.
347            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
361/// Element-wise mean of JSON states, leaf by leaf.
362///
363/// Numbers average. Objects recurse, and must carry the same keys.
364/// Arrays average position-wise, and must be the same length. Anything
365/// else — a string, a bool, a null — passes through only when every
366/// contributor agrees, because there is no such thing as the mean of two
367/// different strings.
368fn 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
439/// Average every node's entry across contributors, key by key.
440fn mean_by_key(what: &str, entries: &[HashMap<String, Value>]) -> Result<HashMap<String, Value>> {
441    // Guarded here as well as at both call sites: `entries[0]` below is a
442    // panic, and a panic is the one failure mode a caller cannot report.
443    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        // A single worker needs no aggregation: its gradients *are* the
472        // result, so this case is exact rather than a stand-in.
473        if gradients.len() == 1 {
474            return Ok(gradients[0].clone());
475        }
476        // Zero contributors reached `mean_by_key`, which indexes
477        // `entries[0]` — a panic, from a `num_replicas` of 0 that nothing
478        // validated. The federated aggregator below has always guarded
479        // this; this one did not.
480        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            // Both need something this function is not given. FedProx
510            // needs the global model to measure drift against; FedYogi
511            // needs the optimizer moments it carries between rounds. A
512            // plain mean would be FedAvg wearing their name.
513            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
531/// A [`StrategyContext`] over one [`Transport`] per worker.
532///
533/// This is the piece that was missing: `StrategyExecutor` was written and
534/// had nowhere to run, because nothing implemented the context it takes.
535///
536/// It deliberately does **not** send `GetState`/`SetState` over the wire,
537/// even though the worker now answers them: a Fit already returns its
538/// trained states in the plan result, so asking again would be a second
539/// round trip for something already in hand. Gradients are different —
540/// nothing else carries them — so those two do go to the worker.
541pub struct TransportContext<'a> {
542    transports: Vec<Arc<dyn Transport>>,
543    plan: &'a ExecutionPlan,
544    catalog: &'a NodeCatalog,
545    seed: Option<i64>,
546    /// The states each worker returned from its last fit, by worker index.
547    states: Mutex<Vec<HashMap<String, Value>>>,
548    /// `(id, tags)` per worker, in transport order. Empty unless
549    /// [`with_targets`](Self::with_targets) was used — only model
550    /// parallelism needs to tell workers apart, and only it pays for
551    /// knowing.
552    identities: Vec<WorkerIdentity>,
553}
554
555/// How a worker can be named by a `RemoteTarget`.
556#[derive(Debug, Clone)]
557pub struct WorkerIdentity {
558    /// The worker's id — its address, as registered.
559    pub id: String,
560    /// Capability tags it was registered with.
561    pub tags: Vec<String>,
562}
563
564impl<'a> TransportContext<'a> {
565    /// One transport per worker, in the order the strategy will index them.
566    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    /// Name the workers, so a partition pinned to an id or a tag can find
584    /// one. Without this, `worker_for` refuses rather than guessing.
585    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        // The trait's `plan` argument is a JSON placeholder every strategy
613        // passes as `{}`; the executable plan is the one this context was
614        // built with.
615        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        // A stage's plan is its own nodes, in order — not the whole plan
679        // this context holds, which is what every other strategy sends.
680        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        // Record it, so a later `get_state` agrees with the wire.
710        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        // Into the catalog, which is what the next plan serializes its
722        // filter states from.
723        for (node_id, state) in states {
724            self.catalog.try_set_state(node_id.clone(), state.clone())?;
725        }
726        // And into this worker's record, because that is what the call
727        // means: worker `worker_idx` now holds these. Without it the
728        // federated loop's closing `get_state(0)` returns worker 0's own
729        // last fit instead of the aggregate just distributed to it — one
730        // client's answer presented as the average of all of them.
731        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
754/// Put the partitions in execution order, checking they can be a chain.
755///
756/// A partition is a *stage*: it runs somewhere, and its output is the next
757/// stage's input. That only means something if the partitions tile the
758/// plan — so a node claimed twice, a node claimed by nobody, and a
759/// partition whose nodes are interleaved with another's are all errors
760/// here rather than a pipeline that quietly drops or repeats a node.
761fn 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    // Contiguous, once ordered: stage k must own a solid run of the plan.
818    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
842/// Split inputs and targets into `n` shards **together**.
843///
844/// Sharding `x` and sending every worker the whole `y` is the bug this
845/// exists to make impossible. It is not caught by anything downstream: a
846/// 4-row output against an 8-row target does not fail, it *broadcasts*, so
847/// each replica computed a loss between things that were never paired,
848/// backpropagated it, and reported a successful round. Only the diverging
849/// weights showed it.
850///
851/// Row counts that disagree are an error naming both, since pairing
852/// example `i` with target `i` is the one assumption every shard rests on.
853fn 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
879/// Leading dimension of a tensor, when it has one.
880fn 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
887/// Split a Value::Tensor along the first dimension into N shards.
888fn 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    /// The ordinary case: two stages, in plan order whatever order they
933    /// were declared in.
934    #[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    /// A node claimed twice would run twice, on two machines, and the
944    /// second activation would silently overwrite the first.
945    #[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    /// A node claimed by nobody has no worker, and model parallelism has
958    /// no default target to fall back on.
959    #[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    /// Interleaved stages are not a pipeline: `a`,`c` on one worker and
969    /// `b` on another would need the activation to cross back.
970    #[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    /// The activation is threaded: stage 2 receives what stage 1 produced,
986    /// not the graph's input. A context that ignored the chaining would
987    /// hand both stages the same thing and still "succeed".
988    #[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                // Each stage adds one, so the output identifies its stage.
1021                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        // Both stages' states come back, not just the last one's.
1078        assert_eq!(states.len(), 2);
1079        assert!(states.contains_key("a") && states.contains_key("b"));
1080    }
1081
1082    /// A context with no idea which worker is which refuses rather than
1083    /// sending the partition to whoever is first.
1084    #[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    /// Zero contributors used to reach `mean_by_key`, which indexes
1115    /// `entries[0]`. A panic is the one failure a caller cannot report,
1116    /// and it was reachable from Python: `num_replicas=0` passed straight
1117    /// through, both loops ran zero times, and the aggregator got `&[]`.
1118    #[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        // And the shared helper guards itself, so a third caller added
1133        // later cannot reintroduce the panic.
1134        let err = mean_by_key("things", &[]).unwrap_err().to_string();
1135        assert!(err.contains("zero contributors"), "{err}");
1136    }
1137
1138    /// Inputs and targets split together. Sharding only `x` sent every
1139    /// replica the whole `y`: shapes that broadcast rather than fail, so
1140    /// each one trained on pairs that were never pairs.
1141    #[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    /// FedAvg is an element-wise mean, and this is the whole reason the
1172    /// federated loop could not run: both aggregators used to refuse for
1173    /// more than one contributor, so a second client was an error.
1174    #[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    /// The same arithmetic for gradients, and it is reached now: a
1192    /// data-parallel round averages real gradients off real workers. The
1193    /// doc comment here used to say nothing could reach it, which was
1194    /// true until the worker learned to hand gradients over.
1195    #[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    /// A subset average is a wrong number that looks like a right one.
1211    #[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    /// FedProx and FedYogi are not FedAvg wearing a different name; each
1233    /// needs something this aggregator is never given.
1234    #[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    /// One worker is the exact case, not a stand-in: there is nothing to
1254    /// average, so it stays supported.
1255    #[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    /// The federated loop, driven end to end over fake transports.
1265    ///
1266    /// Each "worker" reports a state derived from the shard it was given,
1267    /// so a run that quietly used one client cannot produce the mean of
1268    /// two — which is what this asserts.
1269    #[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        // 0..8 split in two: means 1.5 and 5.5, whose mean is 3.5.
1324        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        // Not either client alone — a single-client path cannot pass this.
1340        assert!((values[0] - 1.5).abs() > 1e-6 && (values[0] - 5.5).abs() > 1e-6);
1341    }
1342
1343    /// DataParallel drives its workers through the context now.
1344    ///
1345    /// It used to be impossible: `soma-worker/src/server.rs` refused
1346    /// `GetGradients`/`ApplyGradients`, so the loop could not get past its
1347    /// first collection. The server dispatches them today, and this
1348    /// asserts the loop completes rather than erroring — the gradients a
1349    /// parameterless filter contributes are empty, and an empty average is
1350    /// the right answer for it.
1351    #[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}