Skip to main content

ipfrs_tensorlogic/gradient/
backward_pass.rs

1//! Backward pass coordination via TensorSwap.
2//!
3//! This module provides the pure-data structures that coordinate distributed
4//! backward-pass gradient streaming.  No async or network I/O lives here;
5//! the transport layer calls these types as CIDs arrive.
6
7use serde::{Deserialize, Serialize};
8
9use super::tensor::GradientAggregator;
10use super::GradientError;
11
12// ── Standalone helpers (also re-exported from mod.rs) ─────────────────────
13
14/// Compute the unweighted mean of a collection of gradient vectors.
15///
16/// All vectors must have the same length.  Returns [`GradientError::EmptyGradients`]
17/// for an empty input and [`GradientError::DimensionMismatch`] for vectors of
18/// differing lengths.
19pub fn federated_average(gradients: &[Vec<f32>]) -> Result<Vec<f32>, GradientError> {
20    if gradients.is_empty() {
21        return Err(GradientError::EmptyGradients);
22    }
23    let dim = gradients[0].len();
24    if gradients.iter().any(|g| g.len() != dim) {
25        return Err(GradientError::DimensionMismatch);
26    }
27    let n = gradients.len() as f32;
28    let mut avg = vec![0.0f32; dim];
29    for grad in gradients {
30        for (a, &g) in avg.iter_mut().zip(grad.iter()) {
31            *a += g / n;
32        }
33    }
34    Ok(avg)
35}
36
37/// Clip a gradient vector in-place so that its L2 norm does not exceed `max_norm`.
38///
39/// If the current norm is already ≤ `max_norm` the vector is left unchanged.
40pub fn clip_gradient_norm(gradient: &mut [f32], max_norm: f32) {
41    let norm: f32 = gradient.iter().map(|&x| x * x).sum::<f32>().sqrt();
42    if norm > max_norm {
43        let scale = max_norm / norm;
44        for x in gradient.iter_mut() {
45            *x *= scale;
46        }
47    }
48}
49
50// ── BackwardStepStatus ─────────────────────────────────────────────────────
51
52/// Coordination status for a distributed backward pass step
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54pub enum BackwardStepStatus {
55    /// Waiting for gradient from this peer
56    Pending,
57    /// Gradient has been requested from the peer
58    GradientRequested { peer_id: String },
59    /// Gradient CID has been received
60    GradientReceived { cid: String },
61    /// Gradient has been included in aggregation
62    Aggregated,
63    /// This peer's gradient could not be collected
64    Failed { reason: String },
65}
66
67// ── BackwardPassStep ───────────────────────────────────────────────────────
68
69/// Tracks one layer's backward pass across multiple peers
70#[derive(Debug)]
71pub struct BackwardPassStep {
72    /// Node (layer) identifier
73    pub node_id: String,
74    /// Operation type (e.g. "matmul", "relu")
75    pub op: String,
76    /// Per-peer status map
77    pub peer_contributions: std::collections::HashMap<String, BackwardStepStatus>,
78    /// CID of the aggregated gradient (set after aggregation)
79    pub aggregated_gradient_cid: Option<String>,
80    /// Wall-clock start time of this step
81    pub started_at: std::time::Instant,
82}
83
84impl BackwardPassStep {
85    /// Create a new step with no peers yet
86    pub fn new(node_id: String, op: String) -> Self {
87        Self {
88            node_id,
89            op,
90            peer_contributions: std::collections::HashMap::new(),
91            aggregated_gradient_cid: None,
92            started_at: std::time::Instant::now(),
93        }
94    }
95
96    /// Register a new peer as a contributor for this step
97    pub fn add_peer(&mut self, peer_id: &str) {
98        self.peer_contributions
99            .entry(peer_id.to_string())
100            .or_insert(BackwardStepStatus::Pending);
101    }
102
103    /// Record that a gradient CID was received from `peer_id`
104    pub fn record_gradient_received(&mut self, peer_id: &str, cid: &str) {
105        self.peer_contributions.insert(
106            peer_id.to_string(),
107            BackwardStepStatus::GradientReceived {
108                cid: cid.to_string(),
109            },
110        );
111    }
112
113    /// Record a failure for `peer_id`
114    pub fn record_gradient_failed(&mut self, peer_id: &str, reason: &str) {
115        self.peer_contributions.insert(
116            peer_id.to_string(),
117            BackwardStepStatus::Failed {
118                reason: reason.to_string(),
119            },
120        );
121    }
122
123    /// Returns `true` when every peer has either received or failed
124    pub fn is_complete(&self) -> bool {
125        self.peer_contributions.values().all(|s| {
126            matches!(
127                s,
128                BackwardStepStatus::GradientReceived { .. }
129                    | BackwardStepStatus::Aggregated
130                    | BackwardStepStatus::Failed { .. }
131            )
132        })
133    }
134
135    /// Returns `true` when all peers have received (no pending/failed)
136    pub fn ready_to_aggregate(&self) -> bool {
137        !self.peer_contributions.is_empty()
138            && self.peer_contributions.values().all(|s| {
139                matches!(
140                    s,
141                    BackwardStepStatus::GradientReceived { .. } | BackwardStepStatus::Aggregated
142                )
143            })
144    }
145
146    /// Number of peers whose gradient has been received
147    pub fn received_count(&self) -> usize {
148        self.peer_contributions
149            .values()
150            .filter(|s| {
151                matches!(
152                    s,
153                    BackwardStepStatus::GradientReceived { .. } | BackwardStepStatus::Aggregated
154                )
155            })
156            .count()
157    }
158
159    /// Number of peers that have failed
160    pub fn failed_count(&self) -> usize {
161        self.peer_contributions
162            .values()
163            .filter(|s| matches!(s, BackwardStepStatus::Failed { .. }))
164            .count()
165    }
166}
167
168// ── AggregationMethod ──────────────────────────────────────────────────────
169
170/// Aggregation strategy for combining gradients from multiple peers
171#[derive(Debug, Clone, PartialEq)]
172pub enum AggregationMethod {
173    /// Sum all peer gradients
174    Sum,
175    /// Unweighted arithmetic mean
176    Mean,
177    /// Weighted mean (weights must sum to > 0)
178    WeightedMean { weights: Vec<f32> },
179    /// Federated Averaging (identical to Mean for gradient tensors)
180    FedAvg,
181}
182
183// ── BackwardPassConfig ─────────────────────────────────────────────────────
184
185/// Tuning knobs for a `BackwardPassCoordinator`
186#[derive(Debug, Clone)]
187pub struct BackwardPassConfig {
188    /// Maximum number of participating peers (informational; not enforced as hard limit)
189    pub max_peers: usize,
190    /// Aggregation strategy
191    pub aggregation: AggregationMethod,
192    /// Per-step timeout; steps older than this are considered timed-out
193    pub timeout: std::time::Duration,
194    /// Optional L2 gradient-norm clipping threshold
195    pub gradient_clipping: Option<f32>,
196}
197
198impl Default for BackwardPassConfig {
199    fn default() -> Self {
200        Self {
201            max_peers: 8,
202            aggregation: AggregationMethod::FedAvg,
203            timeout: std::time::Duration::from_secs(60),
204            gradient_clipping: None,
205        }
206    }
207}
208
209// ── BackwardPassStats ──────────────────────────────────────────────────────
210
211/// Runtime statistics snapshot for a [`BackwardPassCoordinator`]
212#[derive(Debug, Default)]
213pub struct BackwardPassStats {
214    /// Total number of scheduled steps
215    pub total_steps: usize,
216    /// Steps where all peers have been aggregated
217    pub completed_steps: usize,
218    /// Steps still waiting for peer contributions
219    pub pending_steps: usize,
220    /// Steps that have at least one failed peer
221    pub failed_steps: usize,
222    /// Bytes held in the accumulation buffer
223    pub total_gradient_bytes: usize,
224    /// Number of distinct peers registered across all steps
225    pub participating_peers: usize,
226}
227
228// ── BackwardPassCoordinator ────────────────────────────────────────────────
229
230/// Coordinates backward pass gradient streaming via TensorSwap.
231///
232/// This is a **pure data structure** — no async or network I/O lives here.
233/// The transport layer calls `receive_gradient` as CIDs arrive and calls
234/// `aggregate_gradients` once a step is [`BackwardPassStep::ready_to_aggregate`].
235pub struct BackwardPassCoordinator {
236    /// Steps in topological order (output layer first — reverse of forward pass)
237    steps: Vec<BackwardPassStep>,
238    /// Set of peer IDs that participate in the backward pass
239    participating_peers: std::collections::HashSet<String>,
240    /// Global learning rate applied during [`apply_gradient`]
241    learning_rate: f32,
242    /// Per-node accumulated gradient buffer (keyed by `node_id`)
243    accumulation_buffer: std::collections::HashMap<String, Vec<f32>>,
244    /// Coordinator configuration
245    config: BackwardPassConfig,
246}
247
248impl BackwardPassCoordinator {
249    /// Create a new coordinator with `learning_rate = 0.01` and the supplied config
250    pub fn new(config: BackwardPassConfig) -> Self {
251        Self {
252            steps: Vec::new(),
253            participating_peers: std::collections::HashSet::new(),
254            learning_rate: 0.01,
255            accumulation_buffer: std::collections::HashMap::new(),
256            config,
257        }
258    }
259
260    /// Override the default learning rate
261    pub fn with_learning_rate(mut self, lr: f32) -> Self {
262        self.learning_rate = lr;
263        self
264    }
265
266    /// Register a computation node and its expected peer contributors
267    pub fn schedule_step(&mut self, node_id: &str, op: &str, peers: &[&str]) {
268        let mut step = BackwardPassStep::new(node_id.to_string(), op.to_string());
269        for &peer in peers {
270            step.add_peer(peer);
271            self.participating_peers.insert(peer.to_string());
272        }
273        self.steps.push(step);
274    }
275
276    /// Record that `peer_id` has sent a gradient with content-address `gradient_cid`
277    /// for the step identified by `node_id`.
278    pub fn receive_gradient(
279        &mut self,
280        node_id: &str,
281        peer_id: &str,
282        gradient_cid: &str,
283    ) -> Result<(), GradientError> {
284        let step = self
285            .steps
286            .iter_mut()
287            .find(|s| s.node_id == node_id)
288            .ok_or_else(|| GradientError::NodeNotFound(node_id.to_string()))?;
289
290        if !step.peer_contributions.contains_key(peer_id) {
291            return Err(GradientError::PeerNotFound(peer_id.to_string()));
292        }
293
294        step.record_gradient_received(peer_id, gradient_cid);
295        Ok(())
296    }
297
298    /// Aggregate a set of `(peer_id, gradient_values)` pairs for `node_id`.
299    ///
300    /// Applies the configured [`AggregationMethod`] and optional gradient clipping,
301    /// then stores the result in the accumulation buffer.
302    pub fn aggregate_gradients(
303        &mut self,
304        node_id: &str,
305        gradient_data: Vec<(String, Vec<f32>)>,
306    ) -> Result<Vec<f32>, GradientError> {
307        if gradient_data.is_empty() {
308            return Err(GradientError::EmptyGradients);
309        }
310
311        let dim = gradient_data[0].1.len();
312        if gradient_data.iter().any(|(_, g)| g.len() != dim) {
313            return Err(GradientError::DimensionMismatch);
314        }
315
316        let gradients: Vec<Vec<f32>> = gradient_data.into_iter().map(|(_, g)| g).collect();
317
318        let mut aggregated = match &self.config.aggregation {
319            AggregationMethod::Sum => {
320                let mut sum = vec![0.0f32; dim];
321                for grad in &gradients {
322                    for (a, &g) in sum.iter_mut().zip(grad.iter()) {
323                        *a += g;
324                    }
325                }
326                sum
327            }
328            AggregationMethod::Mean | AggregationMethod::FedAvg => federated_average(&gradients)?,
329            AggregationMethod::WeightedMean { weights } => {
330                let w: Vec<f32> = weights.clone();
331                GradientAggregator::weighted_average(&gradients, &w)?
332            }
333        };
334
335        // Apply gradient clipping before storing
336        self.clip_gradients(&mut aggregated);
337
338        // Mark contributions as aggregated
339        if let Some(step) = self.steps.iter_mut().find(|s| s.node_id == node_id) {
340            for status in step.peer_contributions.values_mut() {
341                if matches!(status, BackwardStepStatus::GradientReceived { .. }) {
342                    *status = BackwardStepStatus::Aggregated;
343                }
344            }
345        }
346
347        self.accumulation_buffer
348            .insert(node_id.to_string(), aggregated.clone());
349
350        Ok(aggregated)
351    }
352
353    /// Apply L2 gradient-norm clipping if configured
354    pub fn clip_gradients(&self, gradients: &mut [f32]) {
355        if let Some(max_norm) = self.config.gradient_clipping {
356            clip_gradient_norm(gradients, max_norm);
357        }
358    }
359
360    /// Apply aggregated gradient to a parameter vector using the stored learning rate
361    pub fn apply_gradient(
362        &self,
363        params: &mut [f32],
364        gradient: &[f32],
365    ) -> Result<(), GradientError> {
366        if params.len() != gradient.len() {
367            return Err(GradientError::DimensionMismatch);
368        }
369        for (p, &g) in params.iter_mut().zip(gradient.iter()) {
370            *p -= self.learning_rate * g;
371        }
372        Ok(())
373    }
374
375    /// Return a reference to the first step that is ready to aggregate
376    pub fn next_ready_step(&self) -> Option<&BackwardPassStep> {
377        self.steps.iter().find(|s| s.ready_to_aggregate())
378    }
379
380    /// Collect statistics about the current backward pass state
381    pub fn stats(&self) -> BackwardPassStats {
382        let total_steps = self.steps.len();
383        let completed_steps = self
384            .steps
385            .iter()
386            .filter(|s| {
387                s.peer_contributions
388                    .values()
389                    .all(|st| matches!(st, BackwardStepStatus::Aggregated))
390                    && !s.peer_contributions.is_empty()
391            })
392            .count();
393        let failed_steps = self
394            .steps
395            .iter()
396            .filter(|s| {
397                s.peer_contributions
398                    .values()
399                    .any(|st| matches!(st, BackwardStepStatus::Failed { .. }))
400            })
401            .count();
402        let pending_steps = total_steps - completed_steps - failed_steps;
403
404        let total_gradient_bytes = self
405            .accumulation_buffer
406            .values()
407            .map(|v| v.len() * std::mem::size_of::<f32>())
408            .sum();
409
410        BackwardPassStats {
411            total_steps,
412            completed_steps,
413            pending_steps,
414            failed_steps,
415            total_gradient_bytes,
416            participating_peers: self.participating_peers.len(),
417        }
418    }
419}
420
421// ── Tests ──────────────────────────────────────────────────────────────────
422
423#[cfg(test)]
424mod backward_pass_tests {
425    use super::*;
426    use std::time::Duration;
427
428    fn default_config(method: AggregationMethod) -> BackwardPassConfig {
429        BackwardPassConfig {
430            max_peers: 4,
431            aggregation: method,
432            timeout: Duration::from_secs(30),
433            gradient_clipping: None,
434        }
435    }
436
437    // ── schedule_step + receive_gradient + next_ready_step ────────────────
438
439    #[test]
440    fn test_schedule_and_receive_gradient() {
441        let config = BackwardPassConfig {
442            max_peers: 3,
443            aggregation: AggregationMethod::Mean,
444            timeout: Duration::from_secs(30),
445            gradient_clipping: None,
446        };
447        let mut coord = BackwardPassCoordinator::new(config);
448        coord.schedule_step("layer1", "matmul", &["peer1", "peer2"]);
449
450        coord
451            .receive_gradient("layer1", "peer1", "cid_abc")
452            .expect("peer1 receive");
453        coord
454            .receive_gradient("layer1", "peer2", "cid_def")
455            .expect("peer2 receive");
456
457        assert!(
458            coord.next_ready_step().is_some(),
459            "step should be ready after both peers reported"
460        );
461    }
462
463    #[test]
464    fn test_receive_gradient_unknown_node() {
465        let mut coord = BackwardPassCoordinator::new(BackwardPassConfig::default());
466        let result = coord.receive_gradient("ghost_layer", "peer1", "cid_x");
467        assert!(matches!(result, Err(GradientError::NodeNotFound(_))));
468    }
469
470    #[test]
471    fn test_receive_gradient_unknown_peer() {
472        let mut coord = BackwardPassCoordinator::new(BackwardPassConfig::default());
473        coord.schedule_step("layer1", "relu", &["peer1"]);
474        let result = coord.receive_gradient("layer1", "peer_unknown", "cid_x");
475        assert!(matches!(result, Err(GradientError::PeerNotFound(_))));
476    }
477
478    // ── federated_average (re-tested here as integration) ─────────────────
479
480    #[test]
481    fn test_federated_average() {
482        let g1 = vec![1.0f32, 2.0, 3.0];
483        let g2 = vec![3.0f32, 4.0, 5.0];
484        let avg = federated_average(&[g1, g2]).expect("federated_average");
485        assert!((avg[0] - 2.0).abs() < 1e-6, "avg[0] = {}", avg[0]);
486        assert!((avg[1] - 3.0).abs() < 1e-6, "avg[1] = {}", avg[1]);
487        assert!((avg[2] - 4.0).abs() < 1e-6, "avg[2] = {}", avg[2]);
488    }
489
490    #[test]
491    fn test_federated_average_single() {
492        let g = vec![1.0f32, 2.0, 3.0];
493        let avg = federated_average(std::slice::from_ref(&g)).expect("single gradient average");
494        assert_eq!(avg, g);
495    }
496
497    #[test]
498    fn test_federated_average_empty() {
499        let result = federated_average(&[]);
500        assert!(matches!(result, Err(GradientError::EmptyGradients)));
501    }
502
503    #[test]
504    fn test_federated_average_dimension_mismatch() {
505        let g1 = vec![1.0f32, 2.0];
506        let g2 = vec![1.0f32, 2.0, 3.0];
507        let result = federated_average(&[g1, g2]);
508        assert!(matches!(result, Err(GradientError::DimensionMismatch)));
509    }
510
511    // ── clip_gradient_norm ────────────────────────────────────────────────
512
513    #[test]
514    fn test_gradient_clipping() {
515        let mut g = vec![3.0f32, 4.0]; // L2 norm = 5.0
516        clip_gradient_norm(&mut g, 1.0);
517        let norm: f32 = g.iter().map(|&x| x * x).sum::<f32>().sqrt();
518        assert!(
519            (norm - 1.0).abs() < 1e-5,
520            "clipped norm should be 1.0, got {norm}"
521        );
522    }
523
524    #[test]
525    fn test_gradient_clipping_no_op_when_within_bound() {
526        let mut g = vec![0.3f32, 0.4]; // norm = 0.5 < 1.0
527        let original = g.clone();
528        clip_gradient_norm(&mut g, 1.0);
529        assert_eq!(
530            g, original,
531            "gradient must be unchanged when norm < max_norm"
532        );
533    }
534
535    // ── apply_gradient ────────────────────────────────────────────────────
536
537    #[test]
538    fn test_apply_gradient_with_lr() {
539        let config = BackwardPassConfig {
540            max_peers: 2,
541            aggregation: AggregationMethod::Mean,
542            timeout: Duration::from_secs(30),
543            gradient_clipping: None,
544        };
545        let coord = BackwardPassCoordinator::new(config).with_learning_rate(0.1);
546        let mut params = vec![1.0f32, 2.0, 3.0];
547        let gradient = vec![0.5f32, 1.0, 1.5];
548        coord
549            .apply_gradient(&mut params, &gradient)
550            .expect("apply_gradient");
551
552        // params[i] = params[i] - 0.1 * gradient[i]
553        assert!((params[0] - 0.95).abs() < 1e-6, "params[0] = {}", params[0]);
554        assert!((params[1] - 1.90).abs() < 1e-6, "params[1] = {}", params[1]);
555        assert!((params[2] - 2.85).abs() < 1e-6, "params[2] = {}", params[2]);
556    }
557
558    #[test]
559    fn test_apply_gradient_dimension_mismatch() {
560        let coord = BackwardPassCoordinator::new(BackwardPassConfig::default());
561        let mut params = vec![1.0f32, 2.0];
562        let gradient = vec![0.5f32, 1.0, 1.5];
563        let result = coord.apply_gradient(&mut params, &gradient);
564        assert!(matches!(result, Err(GradientError::DimensionMismatch)));
565    }
566
567    // ── stats ─────────────────────────────────────────────────────────────
568
569    #[test]
570    fn test_backward_pass_stats() {
571        let mut coord = BackwardPassCoordinator::new(default_config(AggregationMethod::FedAvg));
572        coord.schedule_step("layer1", "matmul", &["peer1", "peer2"]);
573        coord.schedule_step("layer2", "relu", &["peer1", "peer2"]);
574
575        let stats = coord.stats();
576        assert_eq!(stats.total_steps, 2);
577        assert_eq!(stats.participating_peers, 2);
578        assert_eq!(stats.completed_steps, 0);
579    }
580
581    // ── aggregation methods ───────────────────────────────────────────────
582
583    #[test]
584    fn test_aggregation_methods() {
585        // Sum
586        let mut coord = BackwardPassCoordinator::new(default_config(AggregationMethod::Sum));
587        coord.schedule_step("l1", "op", &["p1", "p2"]);
588        coord
589            .receive_gradient("l1", "p1", "cid1")
590            .expect("receive p1");
591        coord
592            .receive_gradient("l1", "p2", "cid2")
593            .expect("receive p2");
594
595        let data = vec![
596            ("p1".to_string(), vec![1.0f32, 2.0]),
597            ("p2".to_string(), vec![3.0f32, 4.0]),
598        ];
599        let agg = coord
600            .aggregate_gradients("l1", data)
601            .expect("aggregate sum");
602        assert!((agg[0] - 4.0).abs() < 1e-6, "sum[0] = {}", agg[0]);
603        assert!((agg[1] - 6.0).abs() < 1e-6, "sum[1] = {}", agg[1]);
604    }
605
606    #[test]
607    fn test_aggregation_weighted_mean() {
608        let config = BackwardPassConfig {
609            max_peers: 2,
610            aggregation: AggregationMethod::WeightedMean {
611                weights: vec![1.0, 3.0],
612            },
613            timeout: Duration::from_secs(30),
614            gradient_clipping: None,
615        };
616        let mut coord = BackwardPassCoordinator::new(config);
617        coord.schedule_step("l1", "op", &["p1", "p2"]);
618        coord.receive_gradient("l1", "p1", "c1").expect("p1");
619        coord.receive_gradient("l1", "p2", "c2").expect("p2");
620
621        let data = vec![
622            ("p1".to_string(), vec![0.0f32]),
623            ("p2".to_string(), vec![4.0f32]),
624        ];
625        // weighted mean = (1*0 + 3*4) / 4 = 3.0
626        let agg = coord
627            .aggregate_gradients("l1", data)
628            .expect("weighted mean");
629        assert!(
630            (agg[0] - 3.0).abs() < 1e-5,
631            "weighted mean = {}, expected 3.0",
632            agg[0]
633        );
634    }
635
636    #[test]
637    fn test_aggregation_with_clipping() {
638        let config = BackwardPassConfig {
639            max_peers: 1,
640            aggregation: AggregationMethod::Mean,
641            timeout: Duration::from_secs(30),
642            gradient_clipping: Some(1.0),
643        };
644        let mut coord = BackwardPassCoordinator::new(config);
645        coord.schedule_step("l1", "op", &["p1"]);
646        coord.receive_gradient("l1", "p1", "c1").expect("p1");
647
648        let data = vec![("p1".to_string(), vec![3.0f32, 4.0])]; // norm = 5
649        let agg = coord.aggregate_gradients("l1", data).expect("aggregate");
650        let norm: f32 = agg.iter().map(|&x| x * x).sum::<f32>().sqrt();
651        assert!((norm - 1.0).abs() < 1e-5, "clipped norm = {norm}");
652    }
653
654    // ── step completion tracking ──────────────────────────────────────────
655
656    #[test]
657    fn test_step_completion_tracking() {
658        let mut step = BackwardPassStep::new("layer1".to_string(), "matmul".to_string());
659        step.add_peer("p1");
660        step.add_peer("p2");
661        step.add_peer("p3");
662
663        assert!(!step.is_complete(), "not complete yet");
664        assert_eq!(step.received_count(), 0);
665        assert_eq!(step.failed_count(), 0);
666
667        step.record_gradient_received("p1", "cid1");
668        step.record_gradient_received("p2", "cid2");
669        assert!(!step.is_complete(), "still waiting for p3");
670        assert_eq!(step.received_count(), 2);
671
672        step.record_gradient_failed("p3", "timeout");
673        assert!(step.is_complete(), "complete after failure");
674        assert_eq!(step.failed_count(), 1);
675        assert!(
676            !step.ready_to_aggregate(),
677            "not ready_to_aggregate with failure"
678        );
679    }
680
681    #[test]
682    fn test_step_ready_to_aggregate_all_received() {
683        let mut step = BackwardPassStep::new("l".to_string(), "op".to_string());
684        step.add_peer("p1");
685        step.add_peer("p2");
686
687        assert!(!step.ready_to_aggregate());
688
689        step.record_gradient_received("p1", "c1");
690        step.record_gradient_received("p2", "c2");
691        assert!(step.ready_to_aggregate());
692    }
693}