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    let mut out = HashMap::new();
442    for key in entries[0].keys() {
443        let mut contributions = Vec::with_capacity(entries.len());
444        for (idx, entry) in entries.iter().enumerate() {
445            match entry.get(key) {
446                Some(value) => contributions.push((idx, value)),
447                None => {
448                    return Err(SomaError::Other(format!(
449                        "aggregating {what}: `{key}` is missing from contributor \
450                         {idx}. Averaging over whoever happens to have it would \
451                         quietly weight the others"
452                    )));
453                }
454            }
455        }
456        out.insert(key.clone(), mean_of(key, &contributions)?);
457    }
458    Ok(out)
459}
460
461impl GradientAggregator for GradientAggregation {
462    fn aggregate(&self, gradients: &[HashMap<String, Value>]) -> Result<HashMap<String, Value>> {
463        // A single worker needs no aggregation: its gradients *are* the
464        // result, so this case is exact rather than a stand-in.
465        if gradients.len() == 1 {
466            return Ok(gradients[0].clone());
467        }
468        // The arithmetic below is the same mean AllReduce would compute,
469        // but nothing can reach it yet: gradients have to come off the
470        // worker first, and `soma-worker/src/server.rs` answers
471        // `GetGradients`/`ApplyGradients` with "not implemented for
472        // SubprocessFilter". Naming that is more useful than naming the
473        // averaging, which is right here.
474        match self {
475            GradientAggregation::AllReduce => mean_by_key("gradients", gradients),
476            other => Err(SomaError::Other(format!(
477                "{other:?} is not implemented; only AllReduce (an element-wise \
478                 mean) is. Note that no gradient can reach this function yet: \
479                 soma-worker/src/server.rs refuses GetGradients and \
480                 ApplyGradients for SubprocessFilter"
481            ))),
482        }
483    }
484}
485
486impl StateAggregator for FederatedAggregation {
487    fn aggregate(&self, states: &[HashMap<String, Value>]) -> Result<HashMap<String, Value>> {
488        if states.is_empty() {
489            return Err(SomaError::Other(
490                "federated aggregation over zero clients".into(),
491            ));
492        }
493        if states.len() == 1 {
494            return Ok(states[0].clone());
495        }
496        match self {
497            FederatedAggregation::FedAvg => mean_by_key("client states", states),
498            // Both need something this function is not given. FedProx
499            // needs the global model to measure drift against; FedYogi
500            // needs the optimizer moments it carries between rounds. A
501            // plain mean would be FedAvg wearing their name.
502            FederatedAggregation::FedProx { .. } => Err(SomaError::Other(
503                "FedProx needs the previous global model to compute its proximal \
504                 term, and this aggregator only receives the clients' states. \
505                 FedAvg works today"
506                    .into(),
507            )),
508            FederatedAggregation::FedYogi { .. } => Err(SomaError::Other(
509                "FedYogi needs the optimizer moments carried between rounds, and \
510                 this aggregator is stateless. FedAvg works today"
511                    .into(),
512            )),
513            other => Err(SomaError::Other(format!(
514                "this runtime does not know how to aggregate with {other:?}"
515            ))),
516        }
517    }
518}
519
520/// A [`StrategyContext`] over one [`Transport`] per worker.
521///
522/// This is the piece that was missing: `StrategyExecutor` was written and
523/// had nowhere to run, because nothing implemented the context it takes.
524///
525/// It deliberately does **not** send `GetState`/`SetState` over the wire,
526/// even though the worker now answers them: a Fit already returns its
527/// trained states in the plan result, so asking again would be a second
528/// round trip for something already in hand. Gradients are different —
529/// nothing else carries them — so those two do go to the worker.
530pub struct TransportContext<'a> {
531    transports: Vec<Arc<dyn Transport>>,
532    plan: &'a ExecutionPlan,
533    catalog: &'a NodeCatalog,
534    seed: Option<i64>,
535    /// The states each worker returned from its last fit, by worker index.
536    states: Mutex<Vec<HashMap<String, Value>>>,
537    /// `(id, tags)` per worker, in transport order. Empty unless
538    /// [`with_targets`](Self::with_targets) was used — only model
539    /// parallelism needs to tell workers apart, and only it pays for
540    /// knowing.
541    identities: Vec<WorkerIdentity>,
542}
543
544/// How a worker can be named by a `RemoteTarget`.
545#[derive(Debug, Clone)]
546pub struct WorkerIdentity {
547    /// The worker's id — its address, as registered.
548    pub id: String,
549    /// Capability tags it was registered with.
550    pub tags: Vec<String>,
551}
552
553impl<'a> TransportContext<'a> {
554    /// One transport per worker, in the order the strategy will index them.
555    pub fn new(
556        transports: Vec<Arc<dyn Transport>>,
557        plan: &'a ExecutionPlan,
558        catalog: &'a NodeCatalog,
559        seed: Option<i64>,
560    ) -> Self {
561        let n = transports.len();
562        Self {
563            transports,
564            plan,
565            catalog,
566            seed,
567            states: Mutex::new(vec![HashMap::new(); n]),
568            identities: Vec::new(),
569        }
570    }
571
572    /// Name the workers, so a partition pinned to an id or a tag can find
573    /// one. Without this, `worker_for` refuses rather than guessing.
574    pub fn with_targets(mut self, identities: Vec<WorkerIdentity>) -> Self {
575        self.identities = identities;
576        self
577    }
578
579    fn transport(&self, idx: usize) -> Result<&Arc<dyn Transport>> {
580        self.transports.get(idx).ok_or_else(|| {
581            SomaError::Other(format!(
582                "worker {idx} was asked for, but only {} are registered",
583                self.transports.len()
584            ))
585        })
586    }
587}
588
589impl StrategyContext for TransportContext<'_> {
590    fn num_workers(&self) -> usize {
591        self.transports.len()
592    }
593
594    fn execute_on_worker(
595        &self,
596        worker_idx: usize,
597        _plan: &serde_json::Value,
598        input: &Value,
599        y: Option<&Value>,
600    ) -> Result<HashMap<String, Value>> {
601        // The trait's `plan` argument is a JSON placeholder every strategy
602        // passes as `{}`; the executable plan is the one this context was
603        // built with.
604        let (_, states) = self.transport(worker_idx)?.execute(
605            self.plan,
606            self.catalog,
607            input,
608            &RunMode::Fit { y: y.cloned() },
609            self.seed,
610        )?;
611        if let Ok(mut cache) = self.states.lock() {
612            cache[worker_idx] = states.clone();
613        }
614        Ok(states)
615    }
616
617    fn get_state(&self, worker_idx: usize, node_ids: &[String]) -> Result<HashMap<String, Value>> {
618        let cache = self
619            .states
620            .lock()
621            .map_err(|e| SomaError::Other(format!("state cache poisoned: {e}")))?;
622        let states = cache.get(worker_idx).ok_or_else(|| {
623            SomaError::Other(format!("worker {worker_idx} has no recorded state"))
624        })?;
625        if node_ids.is_empty() {
626            return Ok(states.clone());
627        }
628        Ok(node_ids
629            .iter()
630            .filter_map(|id| states.get(id).map(|v| (id.clone(), v.clone())))
631            .collect())
632    }
633
634    fn worker_for(&self, target: &RemoteTarget) -> Result<usize> {
635        if self.identities.is_empty() {
636            return Err(SomaError::Other(format!(
637                "this context was built without worker identities, so {target:?} \
638                 cannot be resolved. Build it with `with_targets`"
639            )));
640        }
641        let found = match target {
642            RemoteTarget::WorkerId(id) => self.identities.iter().position(|w| &w.id == id),
643            RemoteTarget::Tag(tag) => self
644                .identities
645                .iter()
646                .position(|w| w.tags.iter().any(|t| t == tag)),
647        };
648        found.ok_or_else(|| {
649            SomaError::Other(format!(
650                "no registered worker answers to {target:?}. Registered: {}",
651                self.identities
652                    .iter()
653                    .map(|w| format!("{} {:?}", w.id, w.tags))
654                    .collect::<Vec<_>>()
655                    .join(", ")
656            ))
657        })
658    }
659
660    fn execute_partition(
661        &self,
662        worker_idx: usize,
663        node_ids: &[String],
664        input: &Value,
665        y: Option<&Value>,
666    ) -> Result<(Value, HashMap<String, Value>)> {
667        // A stage's plan is its own nodes, in order — not the whole plan
668        // this context holds, which is what every other strategy sends.
669        let stage = ExecutionPlan::Sequence(
670            node_ids
671                .iter()
672                .map(|node_id| ExecutionPlan::Execute {
673                    node_id: node_id.clone(),
674                })
675                .collect(),
676        );
677        let (output, states) = self.transport(worker_idx)?.execute(
678            &stage,
679            self.catalog,
680            input,
681            &RunMode::Fit { y: y.cloned() },
682            self.seed,
683        )?;
684        if let Ok(mut cache) = self.states.lock()
685            && let Some(slot) = cache.get_mut(worker_idx)
686        {
687            slot.extend(states.clone());
688        }
689        Ok((output, states))
690    }
691
692    fn read_back_state(
693        &self,
694        worker_idx: usize,
695        node_ids: &[String],
696    ) -> Result<HashMap<String, Value>> {
697        let states = self.transport(worker_idx)?.get_state(node_ids)?;
698        // Record it, so a later `get_state` agrees with the wire.
699        if let Ok(mut cache) = self.states.lock()
700            && let Some(slot) = cache.get_mut(worker_idx)
701        {
702            for (id, value) in &states {
703                slot.insert(id.clone(), value.clone());
704            }
705        }
706        Ok(states)
707    }
708
709    fn set_state(&self, worker_idx: usize, states: &HashMap<String, Value>) -> Result<()> {
710        // Into the catalog, which is what the next plan serializes its
711        // filter states from.
712        for (node_id, state) in states {
713            self.catalog.try_set_state(node_id.clone(), state.clone())?;
714        }
715        // And into this worker's record, because that is what the call
716        // means: worker `worker_idx` now holds these. Without it the
717        // federated loop's closing `get_state(0)` returns worker 0's own
718        // last fit instead of the aggregate just distributed to it — one
719        // client's answer presented as the average of all of them.
720        if let Ok(mut cache) = self.states.lock()
721            && let Some(slot) = cache.get_mut(worker_idx)
722        {
723            for (node_id, state) in states {
724                slot.insert(node_id.clone(), state.clone());
725            }
726        }
727        Ok(())
728    }
729
730    fn get_gradients(
731        &self,
732        worker_idx: usize,
733        node_ids: &[String],
734    ) -> Result<HashMap<String, Value>> {
735        self.transport(worker_idx)?.get_gradients(node_ids)
736    }
737
738    fn apply_gradients(&self, worker_idx: usize, gradients: &HashMap<String, Value>) -> Result<()> {
739        self.transport(worker_idx)?.apply_gradients(gradients)
740    }
741}
742
743/// Put the partitions in execution order, checking they can be a chain.
744///
745/// A partition is a *stage*: it runs somewhere, and its output is the next
746/// stage's input. That only means something if the partitions tile the
747/// plan — so a node claimed twice, a node claimed by nobody, and a
748/// partition whose nodes are interleaved with another's are all errors
749/// here rather than a pipeline that quietly drops or repeats a node.
750fn order_partitions<'a>(
751    partitions: &'a [Partition],
752    node_ids: &[String],
753) -> Result<Vec<(&'a Partition, Vec<String>)>> {
754    if partitions.is_empty() {
755        return Err(SomaError::Other(
756            "model-parallel training with no partitions: there is nothing to \
757             say where any node runs"
758                .into(),
759        ));
760    }
761    let position: HashMap<&str, usize> = node_ids
762        .iter()
763        .enumerate()
764        .map(|(i, id)| (id.as_str(), i))
765        .collect();
766
767    let mut claimed: HashMap<&str, usize> = HashMap::new();
768    let mut stages: Vec<(&Partition, Vec<usize>)> = Vec::new();
769    for (p_idx, partition) in partitions.iter().enumerate() {
770        let mut positions = Vec::with_capacity(partition.node_ids.len());
771        for node in &partition.node_ids {
772            let Some(&pos) = position.get(node.as_str()) else {
773                return Err(SomaError::Other(format!(
774                    "partition {p_idx} claims `{node}`, which is not in this \
775                     graph. Its nodes are: {}",
776                    node_ids.join(", ")
777                )));
778            };
779            if let Some(&first) = claimed.get(node.as_str()) {
780                return Err(SomaError::Other(format!(
781                    "`{node}` is claimed by partitions {first} and {p_idx}. A \
782                     node runs in one place"
783                )));
784            }
785            claimed.insert(node.as_str(), p_idx);
786            positions.push(pos);
787        }
788        positions.sort_unstable();
789        stages.push((partition, positions));
790    }
791
792    let unclaimed: Vec<&str> = node_ids
793        .iter()
794        .map(String::as_str)
795        .filter(|id| !claimed.contains_key(id))
796        .collect();
797    if !unclaimed.is_empty() {
798        return Err(SomaError::Other(format!(
799            "no partition claims {}. Every node needs a worker; model \
800             parallelism has no default target",
801            unclaimed.join(", ")
802        )));
803    }
804
805    stages.sort_by_key(|(_, positions)| positions.first().copied().unwrap_or(0));
806    // Contiguous, once ordered: stage k must own a solid run of the plan.
807    let mut next = 0usize;
808    for (p_idx, (_, positions)) in stages.iter().enumerate() {
809        for &pos in positions {
810            if pos != next {
811                return Err(SomaError::Other(format!(
812                    "partition {p_idx} is interleaved with another: it owns \
813                     `{}` but not `{}`, which runs before it. A stage has to \
814                     own a contiguous run of the graph",
815                    node_ids[pos], node_ids[next]
816                )));
817            }
818            next += 1;
819        }
820    }
821
822    Ok(stages
823        .into_iter()
824        .map(|(partition, positions)| {
825            let ids = positions.iter().map(|&i| node_ids[i].clone()).collect();
826            (partition, ids)
827        })
828        .collect())
829}
830
831/// Split inputs and targets into `n` shards **together**.
832///
833/// Sharding `x` and sending every worker the whole `y` is the bug this
834/// exists to make impossible. It is not caught by anything downstream: a
835/// 4-row output against an 8-row target does not fail, it *broadcasts*, so
836/// each replica computed a loss between things that were never paired,
837/// backpropagated it, and reported a successful round. Only the diverging
838/// weights showed it.
839///
840/// Row counts that disagree are an error naming both, since pairing
841/// example `i` with target `i` is the one assumption every shard rests on.
842fn shard_pair(x: &Value, y: Option<&Value>, n: usize) -> Result<(Vec<Value>, Vec<Option<Value>>)> {
843    let x_shards = shard_value(x, n);
844    let Some(y) = y else {
845        return Ok((x_shards, vec![None; n]));
846    };
847    if let (Some(xr), Some(yr)) = (rows_of(x), rows_of(y))
848        && xr != yr
849    {
850        return Err(SomaError::Other(format!(
851            "sharding across {n} workers: the input has {xr} rows and the \
852             targets have {yr}. Each shard pairs example i with target i, \
853             so the two must agree"
854        )));
855    }
856    let y_shards = shard_value(y, n);
857    if y_shards.len() != x_shards.len() {
858        return Err(SomaError::Other(format!(
859            "sharding across {n} workers: the input split into {} shards and \
860             the targets into {}",
861            x_shards.len(),
862            y_shards.len()
863        )));
864    }
865    Ok((x_shards, y_shards.into_iter().map(Some).collect()))
866}
867
868/// Leading dimension of a tensor, when it has one.
869fn rows_of(value: &Value) -> Option<usize> {
870    match value {
871        Value::Tensor { shape, .. } if !shape.is_empty() => Some(shape[0]),
872        _ => None,
873    }
874}
875
876/// Split a Value::Tensor along the first dimension into N shards.
877fn shard_value(value: &Value, n: usize) -> Vec<Value> {
878    match value {
879        Value::Tensor { values, shape } if !shape.is_empty() && shape[0] >= n => {
880            let rows = shape[0];
881            let row_size: usize = shape[1..].iter().product::<usize>().max(1);
882            let shard_rows = rows / n;
883            let mut shards = Vec::new();
884            for i in 0..n {
885                let start = i * shard_rows;
886                let end = if i == n - 1 { rows } else { start + shard_rows };
887                let flat_start = start * row_size;
888                let flat_end = end * row_size;
889                let shard_vals = values[flat_start..flat_end].to_vec();
890                let mut shard_shape = shape.clone();
891                shard_shape[0] = end - start;
892                shards.push(Value::tensor(shard_vals, shard_shape));
893            }
894            shards
895        }
896        _ => (0..n).map(|_| value.clone()).collect(),
897    }
898}
899
900#[cfg(test)]
901mod tests {
902    use super::*;
903    use somatize_core::strategy::ClientSelection;
904
905    fn one(node: &str, values: Vec<f64>) -> HashMap<String, Value> {
906        let n = values.len();
907        HashMap::from([(node.to_string(), Value::tensor(values, vec![n]))])
908    }
909
910    fn part(nodes: &[&str], tag: &str) -> Partition {
911        Partition {
912            node_ids: nodes.iter().map(|s| s.to_string()).collect(),
913            target: RemoteTarget::Tag(tag.into()),
914        }
915    }
916
917    fn ids(names: &[&str]) -> Vec<String> {
918        names.iter().map(|s| s.to_string()).collect()
919    }
920
921    /// The ordinary case: two stages, in plan order whatever order they
922    /// were declared in.
923    #[test]
924    fn partitions_are_ordered_by_the_plan_not_by_declaration() {
925        let declared = [part(&["c", "d"], "gpu1"), part(&["a", "b"], "gpu0")];
926        let stages = order_partitions(&declared, &ids(&["a", "b", "c", "d"])).unwrap();
927        assert_eq!(stages.len(), 2);
928        assert_eq!(stages[0].1, ids(&["a", "b"]));
929        assert_eq!(stages[1].1, ids(&["c", "d"]));
930    }
931
932    /// A node claimed twice would run twice, on two machines, and the
933    /// second activation would silently overwrite the first.
934    #[test]
935    fn a_node_in_two_partitions_is_refused() {
936        let declared = [part(&["a", "b"], "gpu0"), part(&["b"], "gpu1")];
937        let err = order_partitions(&declared, &ids(&["a", "b"]))
938            .unwrap_err()
939            .to_string();
940        assert!(
941            err.contains("`b` is claimed by partitions 0 and 1"),
942            "{err}"
943        );
944    }
945
946    /// A node claimed by nobody has no worker, and model parallelism has
947    /// no default target to fall back on.
948    #[test]
949    fn an_unclaimed_node_is_refused_by_name() {
950        let declared = [part(&["a"], "gpu0")];
951        let err = order_partitions(&declared, &ids(&["a", "b"]))
952            .unwrap_err()
953            .to_string();
954        assert!(err.contains("no partition claims b"), "{err}");
955    }
956
957    /// Interleaved stages are not a pipeline: `a`,`c` on one worker and
958    /// `b` on another would need the activation to cross back.
959    #[test]
960    fn interleaved_partitions_are_refused() {
961        let declared = [part(&["a", "c"], "gpu0"), part(&["b"], "gpu1")];
962        let err = order_partitions(&declared, &ids(&["a", "b", "c"]))
963            .unwrap_err()
964            .to_string();
965        assert!(err.contains("interleaved"), "{err}");
966    }
967
968    #[test]
969    fn no_partitions_at_all_is_refused() {
970        let err = order_partitions(&[], &ids(&["a"])).unwrap_err().to_string();
971        assert!(err.contains("nothing to say where any node runs"), "{err}");
972    }
973
974    /// The activation is threaded: stage 2 receives what stage 1 produced,
975    /// not the graph's input. A context that ignored the chaining would
976    /// hand both stages the same thing and still "succeed".
977    #[test]
978    fn model_parallel_threads_the_activation_between_stages() {
979        use std::sync::Mutex as StdMutex;
980
981        #[derive(Default)]
982        struct Chain {
983            seen: StdMutex<Vec<(usize, Vec<String>, Value)>>,
984        }
985        impl StrategyContext for Chain {
986            fn num_workers(&self) -> usize {
987                2
988            }
989            fn execute_on_worker(
990                &self,
991                _: usize,
992                _: &serde_json::Value,
993                _: &Value,
994                _: Option<&Value>,
995            ) -> Result<HashMap<String, Value>> {
996                unreachable!("model parallelism runs partitions, not whole plans")
997            }
998            fn execute_partition(
999                &self,
1000                worker_idx: usize,
1001                node_ids: &[String],
1002                input: &Value,
1003                _: Option<&Value>,
1004            ) -> Result<(Value, HashMap<String, Value>)> {
1005                self.seen
1006                    .lock()
1007                    .unwrap()
1008                    .push((worker_idx, node_ids.to_vec(), input.clone()));
1009                // Each stage adds one, so the output identifies its stage.
1010                let next = match input {
1011                    Value::Tensor { values, shape } => {
1012                        Value::tensor(values.iter().map(|v| v + 1.0).collect(), shape.clone())
1013                    }
1014                    other => other.clone(),
1015                };
1016                let states = node_ids
1017                    .iter()
1018                    .map(|id| (id.clone(), Value::tensor(vec![1.0], vec![1])))
1019                    .collect();
1020                Ok((next, states))
1021            }
1022            fn worker_for(&self, target: &RemoteTarget) -> Result<usize> {
1023                match target {
1024                    RemoteTarget::Tag(t) if t == "gpu0" => Ok(0),
1025                    RemoteTarget::Tag(t) if t == "gpu1" => Ok(1),
1026                    other => Err(SomaError::Other(format!("no worker for {other:?}"))),
1027                }
1028            }
1029            fn get_state(&self, _: usize, _: &[String]) -> Result<HashMap<String, Value>> {
1030                Ok(HashMap::new())
1031            }
1032            fn set_state(&self, _: usize, _: &HashMap<String, Value>) -> Result<()> {
1033                Ok(())
1034            }
1035            fn get_gradients(&self, _: usize, _: &[String]) -> Result<HashMap<String, Value>> {
1036                Ok(HashMap::new())
1037            }
1038            fn apply_gradients(&self, _: usize, _: &HashMap<String, Value>) -> Result<()> {
1039                Ok(())
1040            }
1041        }
1042
1043        let ctx = Chain::default();
1044        let states = TrainingStrategy::ModelParallel {
1045            partitions: vec![part(&["a"], "gpu0"), part(&["b"], "gpu1")],
1046            communication: somatize_core::strategy::CommunicationProtocol::DataStore,
1047        }
1048        .fit(
1049            &ctx,
1050            &Value::tensor(vec![10.0], vec![1]),
1051            None,
1052            &ids(&["a", "b"]),
1053        )
1054        .unwrap();
1055
1056        let seen = ctx.seen.lock().unwrap();
1057        assert_eq!(seen.len(), 2, "one call per stage");
1058        assert_eq!(seen[0].0, 0, "stage 1 on gpu0");
1059        assert_eq!(seen[0].2, Value::tensor(vec![10.0], vec![1]));
1060        assert_eq!(seen[1].0, 1, "stage 2 on gpu1");
1061        assert_eq!(
1062            seen[1].2,
1063            Value::tensor(vec![11.0], vec![1]),
1064            "stage 2 must receive stage 1's output, not the graph input"
1065        );
1066        // Both stages' states come back, not just the last one's.
1067        assert_eq!(states.len(), 2);
1068        assert!(states.contains_key("a") && states.contains_key("b"));
1069    }
1070
1071    /// A context with no idea which worker is which refuses rather than
1072    /// sending the partition to whoever is first.
1073    #[test]
1074    fn an_unnamed_worker_pool_refuses_a_pinned_partition() {
1075        let plan = ExecutionPlan::Empty;
1076        let catalog = NodeCatalog::new();
1077        let ctx = TransportContext::new(Vec::new(), &plan, &catalog, None);
1078        let err = ctx
1079            .worker_for(&RemoteTarget::Tag("gpu".into()))
1080            .unwrap_err()
1081            .to_string();
1082        assert!(err.contains("with_targets"), "{err}");
1083
1084        let ctx = TransportContext::new(Vec::new(), &plan, &catalog, None).with_targets(vec![
1085            WorkerIdentity {
1086                id: "ws://a".into(),
1087                tags: vec!["cpu".into()],
1088            },
1089        ]);
1090        assert!(ctx.worker_for(&RemoteTarget::Tag("cpu".into())).unwrap() == 0);
1091        assert!(
1092            ctx.worker_for(&RemoteTarget::WorkerId("ws://a".into()))
1093                .unwrap()
1094                == 0
1095        );
1096        let err = ctx
1097            .worker_for(&RemoteTarget::Tag("gpu".into()))
1098            .unwrap_err()
1099            .to_string();
1100        assert!(err.contains("no registered worker"), "{err}");
1101    }
1102
1103    /// Inputs and targets split together. Sharding only `x` sent every
1104    /// replica the whole `y`: shapes that broadcast rather than fail, so
1105    /// each one trained on pairs that were never pairs.
1106    #[test]
1107    fn shard_pair_splits_targets_alongside_inputs() {
1108        let x = Value::tensor(vec![1.0, 2.0, 3.0, 4.0], vec![4, 1]);
1109        let y = Value::tensor(vec![10.0, 20.0, 30.0, 40.0], vec![4, 1]);
1110        let (xs, ys) = shard_pair(&x, Some(&y), 2).unwrap();
1111        assert_eq!(xs[0], Value::tensor(vec![1.0, 2.0], vec![2, 1]));
1112        assert_eq!(ys[0], Some(Value::tensor(vec![10.0, 20.0], vec![2, 1])));
1113        assert_eq!(xs[1], Value::tensor(vec![3.0, 4.0], vec![2, 1]));
1114        assert_eq!(ys[1], Some(Value::tensor(vec![30.0, 40.0], vec![2, 1])));
1115    }
1116
1117    #[test]
1118    fn shard_pair_refuses_row_counts_that_disagree() {
1119        let x = Value::tensor(vec![1.0, 2.0, 3.0, 4.0], vec![4, 1]);
1120        let y = Value::tensor(vec![10.0, 20.0], vec![2, 1]);
1121        let err = shard_pair(&x, Some(&y), 2).unwrap_err().to_string();
1122        assert!(
1123            err.contains("4 rows") && err.contains("2"),
1124            "the error should name both counts: {err}"
1125        );
1126    }
1127
1128    #[test]
1129    fn shard_pair_without_targets_yields_none_per_shard() {
1130        let x = Value::tensor(vec![1.0, 2.0], vec![2, 1]);
1131        let (xs, ys) = shard_pair(&x, None, 2).unwrap();
1132        assert_eq!(xs.len(), 2);
1133        assert_eq!(ys, vec![None, None]);
1134    }
1135
1136    /// FedAvg is an element-wise mean, and this is the whole reason the
1137    /// federated loop could not run: both aggregators used to refuse for
1138    /// more than one contributor, so a second client was an error.
1139    #[test]
1140    fn fedavg_averages_element_wise() {
1141        let out = FederatedAggregation::FedAvg
1142            .aggregate(&[one("w", vec![1.0, 10.0]), one("w", vec![3.0, 20.0])])
1143            .unwrap();
1144        assert_eq!(out["w"], Value::tensor(vec![2.0, 15.0], vec![2]));
1145
1146        let out = FederatedAggregation::FedAvg
1147            .aggregate(&[
1148                one("w", vec![0.0]),
1149                one("w", vec![3.0]),
1150                one("w", vec![6.0]),
1151            ])
1152            .unwrap();
1153        assert_eq!(out["w"], Value::tensor(vec![3.0], vec![1]));
1154    }
1155
1156    /// The same arithmetic for gradients. Nothing can reach it yet — the
1157    /// worker refuses to hand gradients over — but the error must be about
1158    /// that, not about the averaging.
1159    #[test]
1160    fn allreduce_averages_and_the_others_say_what_they_are_not() {
1161        let out = GradientAggregation::AllReduce
1162            .aggregate(&[one("w", vec![2.0]), one("w", vec![4.0])])
1163            .unwrap();
1164        assert_eq!(out["w"], Value::tensor(vec![3.0], vec![1]));
1165
1166        let err = GradientAggregation::ParameterServer
1167            .aggregate(&[one("w", vec![1.0]), one("w", vec![2.0])])
1168            .expect_err("only AllReduce is implemented");
1169        assert!(err.to_string().contains("server.rs"), "{err}");
1170    }
1171
1172    /// A subset average is a wrong number that looks like a right one.
1173    #[test]
1174    fn a_contributor_missing_a_key_is_an_error_naming_it() {
1175        let err = FederatedAggregation::FedAvg
1176            .aggregate(&[one("w", vec![1.0]), one("other", vec![2.0])])
1177            .expect_err("averaging over whoever has the key would misweight");
1178        let msg = err.to_string();
1179        assert!(
1180            msg.contains("`w`") && msg.contains("contributor 1"),
1181            "{msg}"
1182        );
1183    }
1184
1185    #[test]
1186    fn mismatched_shapes_name_both() {
1187        let err = FederatedAggregation::FedAvg
1188            .aggregate(&[one("w", vec![1.0, 2.0]), one("w", vec![3.0])])
1189            .expect_err("shapes that disagree have no mean");
1190        let msg = err.to_string();
1191        assert!(msg.contains("[1]") && msg.contains("[2]"), "{msg}");
1192    }
1193
1194    /// FedProx and FedYogi are not FedAvg wearing a different name; each
1195    /// needs something this aggregator is never given.
1196    #[test]
1197    fn the_adaptive_variants_say_what_they_would_need() {
1198        let two = [one("w", vec![1.0]), one("w", vec![3.0])];
1199        let err = FederatedAggregation::FedProx { mu: 0.1 }
1200            .aggregate(&two)
1201            .unwrap_err()
1202            .to_string();
1203        assert!(err.contains("global model"), "{err}");
1204        let err = FederatedAggregation::FedYogi {
1205            beta1: 0.9,
1206            beta2: 0.99,
1207            tau: 1e-3,
1208        }
1209        .aggregate(&two)
1210        .unwrap_err()
1211        .to_string();
1212        assert!(err.contains("moments"), "{err}");
1213    }
1214
1215    /// One worker is the exact case, not a stand-in: there is nothing to
1216    /// average, so it stays supported.
1217    #[test]
1218    fn single_worker_aggregation_is_the_identity() {
1219        let only = one("w", vec![2.0]);
1220        let out = GradientAggregation::AllReduce
1221            .aggregate(std::slice::from_ref(&only))
1222            .unwrap();
1223        assert_eq!(out, only);
1224    }
1225
1226    /// The federated loop, driven end to end over fake transports.
1227    ///
1228    /// Each "worker" reports a state derived from the shard it was given,
1229    /// so a run that quietly used one client cannot produce the mean of
1230    /// two — which is what this asserts.
1231    #[test]
1232    fn the_federated_loop_converges_to_the_mean_of_its_clients() {
1233        use somatize_compiler::ExecutionPlan;
1234        use std::sync::atomic::{AtomicUsize, Ordering};
1235
1236        struct ShardMean {
1237            calls: AtomicUsize,
1238        }
1239        impl Transport for ShardMean {
1240            fn execute(
1241                &self,
1242                _plan: &ExecutionPlan,
1243                _filters: &NodeCatalog,
1244                input: &Value,
1245                _mode: &RunMode,
1246                _seed: Option<i64>,
1247            ) -> Result<(Value, HashMap<String, Value>)> {
1248                self.calls.fetch_add(1, Ordering::SeqCst);
1249                let mean = match input {
1250                    Value::Tensor { values, .. } if !values.is_empty() => {
1251                        values.iter().sum::<f64>() / values.len() as f64
1252                    }
1253                    _ => 0.0,
1254                };
1255                Ok((Value::Empty, one("m", vec![mean])))
1256            }
1257            fn get_state(&self, _: &[String]) -> Result<HashMap<String, Value>> {
1258                Ok(HashMap::new())
1259            }
1260            fn set_state(&self, _: &HashMap<String, Value>) -> Result<()> {
1261                Ok(())
1262            }
1263            fn get_gradients(&self, _: &[String]) -> Result<HashMap<String, Value>> {
1264                Ok(HashMap::new())
1265            }
1266            fn apply_gradients(&self, _: &HashMap<String, Value>) -> Result<()> {
1267                Ok(())
1268            }
1269        }
1270
1271        let transports: Vec<Arc<dyn Transport>> = vec![
1272            Arc::new(ShardMean {
1273                calls: AtomicUsize::new(0),
1274            }),
1275            Arc::new(ShardMean {
1276                calls: AtomicUsize::new(0),
1277            }),
1278        ];
1279        let plan = ExecutionPlan::Execute {
1280            node_id: "m".into(),
1281        };
1282        let catalog = NodeCatalog::new();
1283        let ctx = TransportContext::new(transports, &plan, &catalog, None);
1284
1285        // 0..8 split in two: means 1.5 and 5.5, whose mean is 3.5.
1286        let input = Value::tensor((0..8).map(|i| i as f64).collect(), vec![8]);
1287        let strategy = TrainingStrategy::Federated {
1288            num_clients: 2,
1289            rounds: 2,
1290            aggregation: FederatedAggregation::FedAvg,
1291            client_selection: ClientSelection::All,
1292        };
1293        let out = strategy
1294            .fit(&ctx, &input, None, &["m".to_string()])
1295            .expect("the federated loop must run");
1296
1297        let Value::Tensor { values, .. } = &out["m"] else {
1298            panic!("expected a tensor, got {:?}", out["m"]);
1299        };
1300        assert!((values[0] - 3.5).abs() < 1e-9, "got {}", values[0]);
1301        // Not either client alone — a single-client path cannot pass this.
1302        assert!((values[0] - 1.5).abs() > 1e-6 && (values[0] - 5.5).abs() > 1e-6);
1303    }
1304
1305    /// DataParallel drives its workers through the context now.
1306    ///
1307    /// It used to be impossible: `soma-worker/src/server.rs` refused
1308    /// `GetGradients`/`ApplyGradients`, so the loop could not get past its
1309    /// first collection. The server dispatches them today, and this
1310    /// asserts the loop completes rather than erroring — the gradients a
1311    /// parameterless filter contributes are empty, and an empty average is
1312    /// the right answer for it.
1313    #[test]
1314    fn data_parallel_runs_its_loop() {
1315        use somatize_compiler::ExecutionPlan;
1316
1317        struct Noop;
1318        impl Transport for Noop {
1319            fn execute(
1320                &self,
1321                _: &ExecutionPlan,
1322                _: &NodeCatalog,
1323                _: &Value,
1324                _: &RunMode,
1325                _: Option<i64>,
1326            ) -> Result<(Value, HashMap<String, Value>)> {
1327                Ok((Value::Empty, HashMap::new()))
1328            }
1329            fn get_state(&self, _: &[String]) -> Result<HashMap<String, Value>> {
1330                Ok(HashMap::new())
1331            }
1332            fn set_state(&self, _: &HashMap<String, Value>) -> Result<()> {
1333                Ok(())
1334            }
1335            fn get_gradients(&self, _: &[String]) -> Result<HashMap<String, Value>> {
1336                Ok(HashMap::new())
1337            }
1338            fn apply_gradients(&self, _: &HashMap<String, Value>) -> Result<()> {
1339                Ok(())
1340            }
1341        }
1342
1343        let transports: Vec<Arc<dyn Transport>> = vec![Arc::new(Noop), Arc::new(Noop)];
1344        let plan = ExecutionPlan::Execute {
1345            node_id: "m".into(),
1346        };
1347        let catalog = NodeCatalog::new();
1348        let ctx = TransportContext::new(transports, &plan, &catalog, None);
1349
1350        let out = TrainingStrategy::DataParallel {
1351            num_replicas: 2,
1352            aggregation: GradientAggregation::AllReduce,
1353        }
1354        .fit(
1355            &ctx,
1356            &Value::tensor(vec![1.0, 2.0], vec![2]),
1357            None,
1358            &["m".to_string()],
1359        )
1360        .expect("DataParallel drives the workers through the context");
1361        assert!(
1362            out.is_empty(),
1363            "a filter with no parameters contributes no gradients: {out:?}"
1364        );
1365    }
1366}