ndn_discovery/strategy/
composite.rs

1//! Composite probe scheduler — runs multiple strategies simultaneously.
2//!
3//! `CompositeStrategy` allows combining schedulers.  The primary use case is
4//! running [`PassiveScheduler`] for passive MAC detection alongside
5//! [`BackoffScheduler`] as a fallback, or combining [`ReactiveScheduler`]
6//! with SWIM probing.
7//!
8//! ## Deduplication
9//!
10//! Multiple strategies may independently decide to broadcast on the same tick.
11//! `CompositeStrategy` collapses all [`ProbeRequest::Broadcast`] emissions from
12//! one tick into a single broadcast.  [`ProbeRequest::Unicast`] requests are
13//! deduplicated per `FaceId` — one unicast per face per tick.
14//!
15//! ## Forwarding
16//!
17//! [`on_probe_success`], [`on_probe_timeout`], and [`trigger`] are forwarded
18//! to **all** member strategies so that each maintains consistent internal state.
19
20use std::time::{Duration, Instant};
21
22use ndn_transport::FaceId;
23
24use crate::strategy::{NeighborProbeStrategy, ProbeRequest, TriggerEvent};
25
26// ─── CompositeStrategy ───────────────────────────────────────────────────────
27
28/// A composite probe scheduler that runs multiple strategies in parallel and
29/// deduplicates their output.
30pub struct CompositeStrategy {
31    members: Vec<Box<dyn NeighborProbeStrategy>>,
32}
33
34impl CompositeStrategy {
35    /// Create an empty composite.  At least one strategy must be added before
36    /// the first tick, or `on_tick` will return an empty list.
37    pub fn new() -> Self {
38        Self {
39            members: Vec::new(),
40        }
41    }
42
43    /// Add a strategy to the composite (builder).
44    pub fn with(mut self, strategy: Box<dyn NeighborProbeStrategy>) -> Self {
45        self.members.push(strategy);
46        self
47    }
48
49    /// Add a strategy by reference (builder variant for use without consuming).
50    pub fn push(&mut self, strategy: Box<dyn NeighborProbeStrategy>) {
51        self.members.push(strategy);
52    }
53}
54
55impl Default for CompositeStrategy {
56    fn default() -> Self {
57        Self::new()
58    }
59}
60
61impl NeighborProbeStrategy for CompositeStrategy {
62    fn on_tick(&mut self, now: Instant) -> Vec<ProbeRequest> {
63        let mut broadcast = false;
64        let mut unicasts: Vec<FaceId> = Vec::new();
65
66        for s in &mut self.members {
67            for req in s.on_tick(now) {
68                match req {
69                    ProbeRequest::Broadcast => {
70                        broadcast = true;
71                    }
72                    ProbeRequest::Unicast(fid) => {
73                        if !unicasts.contains(&fid) {
74                            unicasts.push(fid);
75                        }
76                    }
77                }
78            }
79        }
80
81        let mut result: Vec<ProbeRequest> =
82            unicasts.into_iter().map(ProbeRequest::Unicast).collect();
83        if broadcast {
84            result.push(ProbeRequest::Broadcast);
85        }
86        result
87    }
88
89    fn on_probe_success(&mut self, rtt: Duration) {
90        for s in &mut self.members {
91            s.on_probe_success(rtt);
92        }
93    }
94
95    fn on_probe_timeout(&mut self) {
96        for s in &mut self.members {
97            s.on_probe_timeout();
98        }
99    }
100
101    fn trigger(&mut self, event: TriggerEvent) {
102        for s in &mut self.members {
103            s.trigger(event.clone());
104        }
105    }
106}
107
108// ─── Tests ────────────────────────────────────────────────────────────────────
109
110#[cfg(test)]
111mod tests {
112    use std::time::{Duration, Instant};
113
114    use super::*;
115    use crate::config::{DiscoveryConfig, DiscoveryProfile};
116    use crate::strategy::{BackoffScheduler, ReactiveScheduler};
117
118    #[test]
119    fn deduplicates_broadcast() {
120        // Both backoff and reactive want to broadcast on the first tick.
121        let mut composite = CompositeStrategy::new()
122            .with(Box::new(BackoffScheduler::from_discovery_config(
123                &DiscoveryConfig::for_profile(&DiscoveryProfile::Lan),
124            )))
125            .with(Box::new(ReactiveScheduler::from_discovery_config(
126                &DiscoveryConfig::for_profile(&DiscoveryProfile::Mobile),
127            )));
128
129        let reqs = composite.on_tick(Instant::now());
130        let broadcasts = reqs
131            .iter()
132            .filter(|r| **r == ProbeRequest::Broadcast)
133            .count();
134        assert_eq!(broadcasts, 1, "broadcasts should be deduplicated: {reqs:?}");
135    }
136
137    #[test]
138    fn forwards_success_to_all() {
139        let mut composite = CompositeStrategy::new()
140            .with(Box::new(BackoffScheduler::from_discovery_config(
141                &DiscoveryConfig::for_profile(&DiscoveryProfile::Lan),
142            )))
143            .with(Box::new(ReactiveScheduler::from_discovery_config(
144                &DiscoveryConfig::for_profile(&DiscoveryProfile::Mobile),
145            )));
146        let now = Instant::now();
147        composite.on_tick(now);
148        // Should not panic; success forwarded to both.
149        composite.on_probe_success(Duration::from_millis(12));
150    }
151
152    #[test]
153    fn trigger_forwarded_to_all() {
154        let mut composite = CompositeStrategy::new()
155            .with(Box::new(BackoffScheduler::from_discovery_config(
156                &DiscoveryConfig::for_profile(&DiscoveryProfile::Lan),
157            )))
158            .with(Box::new(ReactiveScheduler::from_discovery_config(
159                &DiscoveryConfig::for_profile(&DiscoveryProfile::Mobile),
160            )));
161        let now = Instant::now();
162        composite.on_tick(now); // consume initial probes
163
164        composite.trigger(TriggerEvent::FaceUp);
165        let reqs = composite.on_tick(now + Duration::from_secs(1));
166        let broadcasts = reqs
167            .iter()
168            .filter(|r| **r == ProbeRequest::Broadcast)
169            .count();
170        // At least one broadcast expected (from the trigger).
171        assert!(broadcasts >= 1);
172    }
173
174    #[test]
175    fn empty_composite_returns_no_probes() {
176        let mut composite = CompositeStrategy::new();
177        let reqs = composite.on_tick(Instant::now());
178        assert!(reqs.is_empty());
179    }
180}