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 somatize_core::error::{Result, SomaError};
13use somatize_core::strategy::{FederatedAggregation, GradientAggregation, TrainingStrategy};
14use somatize_core::value::Value;
15use std::collections::HashMap;
16
17// ── The execution contracts ──
18//
19// These describe how a strategy is *run*, so they belong beside the
20// running of it. `soma-core` keeps the strategy types themselves, which
21// are graph attributes and therefore contracts.
22
23/// Context provided to strategy executors.
24/// Abstracts worker communication — the strategy doesn't know about WS/HTTP.
25pub trait StrategyContext {
26    /// Number of available workers.
27    fn num_workers(&self) -> usize;
28
29    /// Execute a plan on a specific worker (by index). Returns trained states.
30    fn execute_on_worker(
31        &self,
32        worker_idx: usize,
33        plan: &serde_json::Value,
34        input: &Value,
35        y: Option<&Value>,
36    ) -> Result<HashMap<String, Value>>;
37
38    /// Get trained states from a worker.
39    fn get_state(&self, worker_idx: usize, node_ids: &[String]) -> Result<HashMap<String, Value>>;
40
41    /// Set states on a worker (e.g. after aggregation).
42    fn set_state(&self, worker_idx: usize, states: &HashMap<String, Value>) -> Result<()>;
43
44    /// Get gradients from a worker.
45    fn get_gradients(
46        &self,
47        worker_idx: usize,
48        node_ids: &[String],
49    ) -> Result<HashMap<String, Value>>;
50
51    /// Apply gradients on a worker.
52    fn apply_gradients(&self, worker_idx: usize, gradients: &HashMap<String, Value>) -> Result<()>;
53}
54
55/// Contract for training strategy execution.
56/// Every TrainingStrategy variant implements this — including Local.
57pub trait StrategyExecutor {
58    /// Train the model according to this strategy.
59    fn fit(
60        &self,
61        ctx: &dyn StrategyContext,
62        input: &Value,
63        y: Option<&Value>,
64        node_ids: &[String],
65    ) -> Result<HashMap<String, Value>>;
66}
67
68/// Contract for gradient aggregation across workers.
69pub trait GradientAggregator {
70    /// Combine per-worker gradients (keyed by node id) into the one set
71    /// every worker then applies.
72    fn aggregate(&self, gradients: &[HashMap<String, Value>]) -> Result<HashMap<String, Value>>;
73}
74
75/// Contract for federated state aggregation.
76pub trait StateAggregator {
77    /// Combine per-worker trained states (keyed by node id) into the one
78    /// set redistributed to every worker.
79    fn aggregate(&self, states: &[HashMap<String, Value>]) -> Result<HashMap<String, Value>>;
80}
81
82impl StrategyExecutor for TrainingStrategy {
83    fn fit(
84        &self,
85        ctx: &dyn StrategyContext,
86        input: &Value,
87        y: Option<&Value>,
88        node_ids: &[String],
89    ) -> Result<HashMap<String, Value>> {
90        match self {
91            TrainingStrategy::Local => {
92                // Single worker, full dataset
93                ctx.execute_on_worker(0, &serde_json::json!({}), input, y)
94            }
95
96            TrainingStrategy::DataParallel {
97                num_replicas,
98                aggregation,
99            } => {
100                let n = (*num_replicas).min(ctx.num_workers());
101                let shards = shard_value(input, n);
102
103                // Fit on each worker with its shard
104                for (i, shard) in shards.iter().enumerate() {
105                    ctx.execute_on_worker(i, &serde_json::json!({}), shard, y)?;
106                }
107
108                // Collect and aggregate gradients
109                let mut all_grads = Vec::new();
110                for i in 0..n {
111                    all_grads.push(ctx.get_gradients(i, node_ids)?);
112                }
113                let averaged = aggregation.aggregate(&all_grads)?;
114
115                // Apply to all workers
116                for i in 0..n {
117                    ctx.apply_gradients(i, &averaged)?;
118                }
119
120                // Return states from first worker
121                ctx.get_state(0, node_ids)
122            }
123
124            TrainingStrategy::Federated {
125                num_clients,
126                rounds,
127                aggregation,
128                ..
129            } => {
130                let n = (*num_clients).min(ctx.num_workers());
131                let shards = shard_value(input, n);
132
133                for _round in 0..*rounds {
134                    // Each client trains on its shard
135                    for (i, shard) in shards.iter().enumerate().take(n) {
136                        ctx.execute_on_worker(i, &serde_json::json!({}), shard, y)?;
137                    }
138
139                    // Collect and aggregate states
140                    let mut all_states = Vec::new();
141                    for i in 0..n {
142                        all_states.push(ctx.get_state(i, node_ids)?);
143                    }
144                    let aggregated = aggregation.aggregate(&all_states)?;
145
146                    // Distribute back
147                    for i in 0..n {
148                        ctx.set_state(i, &aggregated)?;
149                    }
150                }
151
152                ctx.get_state(0, node_ids)
153            }
154
155            TrainingStrategy::ModelParallel { .. } => {
156                // TODO: forward/backward across partitions
157                Err(SomaError::Other(
158                    "ModelParallel strategy execution not yet implemented".into(),
159                ))
160            }
161
162            TrainingStrategy::PopulationBased { .. } => {
163                // TODO: PBT cycle
164                Err(SomaError::Other(
165                    "PopulationBased strategy execution not yet implemented".into(),
166                ))
167            }
168
169            TrainingStrategy::Custom { .. } => Err(SomaError::Other(
170                "Custom strategy requires a user-provided coordinator".into(),
171            )),
172
173            // `TrainingStrategy` is `#[non_exhaustive]` and now lives in
174            // another crate, so this arm cannot be deleted. It refuses
175            // rather than falling back to something plausible: running a
176            // strategy this build does not understand as if it were
177            // `Local` would train on one worker and report success.
178            other => Err(SomaError::Other(format!(
179                "this runtime does not know how to run {other:?}. It was \
180                 probably described by a newer version"
181            ))),
182        }
183    }
184}
185
186// Both aggregators used to answer with `first()` — one worker's gradients
187// presented as the average of all of them. That is not an unfinished
188// feature, it is a wrong number that trains a model and reports success.
189// Until the tensor arithmetic exists, refusing is the only honest answer,
190// which is what the unimplemented strategy arms above already do.
191
192impl GradientAggregator for GradientAggregation {
193    fn aggregate(&self, gradients: &[HashMap<String, Value>]) -> Result<HashMap<String, Value>> {
194        // A single worker needs no aggregation: its gradients *are* the
195        // result, so this case is exact rather than a stand-in.
196        if gradients.len() == 1 {
197            return Ok(gradients[0].clone());
198        }
199        Err(SomaError::Other(format!(
200            "{self:?} gradient aggregation over {} workers is not implemented yet; \
201             it would need element-wise tensor averaging",
202            gradients.len()
203        )))
204    }
205}
206
207impl StateAggregator for FederatedAggregation {
208    fn aggregate(&self, states: &[HashMap<String, Value>]) -> Result<HashMap<String, Value>> {
209        if states.len() == 1 {
210            return Ok(states[0].clone());
211        }
212        Err(SomaError::Other(format!(
213            "{self:?} state aggregation over {} clients is not implemented yet; \
214             it would need element-wise tensor averaging",
215            states.len()
216        )))
217    }
218}
219
220/// Split a Value::Tensor along the first dimension into N shards.
221fn shard_value(value: &Value, n: usize) -> Vec<Value> {
222    match value {
223        Value::Tensor { values, shape } if !shape.is_empty() && shape[0] >= n => {
224            let rows = shape[0];
225            let row_size: usize = shape[1..].iter().product::<usize>().max(1);
226            let shard_rows = rows / n;
227            let mut shards = Vec::new();
228            for i in 0..n {
229                let start = i * shard_rows;
230                let end = if i == n - 1 { rows } else { start + shard_rows };
231                let flat_start = start * row_size;
232                let flat_end = end * row_size;
233                let shard_vals = values[flat_start..flat_end].to_vec();
234                let mut shard_shape = shape.clone();
235                shard_shape[0] = end - start;
236                shards.push(Value::tensor(shard_vals, shard_shape));
237            }
238            shards
239        }
240        _ => (0..n).map(|_| value.clone()).collect(),
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    /// Aggregating several workers is not implemented. It must say so —
249    /// answering with the first worker's gradients looks like a trained
250    /// model and is arithmetically wrong.
251    #[test]
252    fn multi_worker_aggregation_refuses_instead_of_guessing() {
253        let grads = |v: f64| HashMap::from([("w".to_string(), Value::tensor(vec![v], vec![1]))]);
254
255        let err = GradientAggregation::AllReduce
256            .aggregate(&[grads(1.0), grads(3.0)])
257            .expect_err("aggregating two workers must not silently succeed");
258        assert!(err.to_string().contains("not implemented"), "{err}");
259
260        let err = FederatedAggregation::FedAvg
261            .aggregate(&[grads(1.0), grads(3.0)])
262            .expect_err("aggregating two clients must not silently succeed");
263        assert!(err.to_string().contains("not implemented"), "{err}");
264    }
265
266    /// One worker is the exact case, not a stand-in: there is nothing to
267    /// average, so it stays supported.
268    #[test]
269    fn single_worker_aggregation_is_the_identity() {
270        let only = HashMap::from([("w".to_string(), Value::tensor(vec![2.0], vec![1]))]);
271        let out = GradientAggregation::AllReduce
272            .aggregate(std::slice::from_ref(&only))
273            .unwrap();
274        assert_eq!(out, only);
275    }
276}