Skip to main content

deaddrop_net/routing/
mod.rs

1use deaddrop_core::store::{EncounterRow, Store};
2use deaddrop_core::{
3    Destination, DropEnvelope, NodeMode, PeerId, RelayCapacity, Result, RoutingPolicyKind,
4};
5
6#[derive(Debug, Clone)]
7pub struct RouteDecision {
8    pub forward: bool,
9    pub score: f64,
10    pub reasons: Vec<(String, f64)>,
11}
12
13#[derive(Debug, Clone)]
14pub struct ScoreWeights {
15    pub destination_probability: f64,
16    pub encounter_recency: f64,
17    pub encounter_frequency: f64,
18    pub delivery_history: f64,
19    pub peer_capacity: f64,
20    pub object_priority: f64,
21    pub remaining_lifetime: f64,
22    pub replication_need: f64,
23    pub battery_cost: f64,
24    pub storage_pressure: f64,
25    pub transport_reliability: f64,
26}
27
28impl Default for ScoreWeights {
29    fn default() -> Self {
30        Self {
31            destination_probability: 1.0,
32            encounter_recency: 1.0,
33            encounter_frequency: 1.0,
34            delivery_history: 1.0,
35            peer_capacity: 1.0,
36            object_priority: 1.0,
37            remaining_lifetime: 1.0,
38            replication_need: 1.0,
39            battery_cost: 0.4,
40            storage_pressure: 0.4,
41            transport_reliability: 0.5,
42        }
43    }
44}
45
46pub trait RoutingStrategy: Send + Sync {
47    fn name(&self) -> &'static str;
48    fn decide(
49        &self,
50        env: &DropEnvelope,
51        peer: PeerId,
52        local: PeerId,
53        now: u64,
54        store: &Store,
55        mode: NodeMode,
56        peer_relay: RelayCapacity,
57        remaining_copies: u32,
58    ) -> Result<RouteDecision>;
59}
60
61pub struct Direct;
62pub struct Epidemic;
63pub struct SprayAndWait;
64pub struct EncounterBased {
65    pub weights: ScoreWeights,
66}
67pub struct Utility {
68    pub weights: ScoreWeights,
69}
70
71impl RoutingStrategy for Direct {
72    fn name(&self) -> &'static str {
73        "direct"
74    }
75    fn decide(
76        &self,
77        env: &DropEnvelope,
78        peer: PeerId,
79        _local: PeerId,
80        _now: u64,
81        _store: &Store,
82        mode: NodeMode,
83        peer_relay: RelayCapacity,
84        _remaining_copies: u32,
85    ) -> Result<RouteDecision> {
86        if !mode.unsolicited_relay() && !env.destination.includes(&peer) {
87            return Ok(deny("private/offline mode"));
88        }
89        if peer_relay == RelayCapacity::None && !env.destination.includes(&peer) {
90            return Ok(deny("peer relay none"));
91        }
92        let hit = env.destination.includes(&peer);
93        Ok(RouteDecision {
94            forward: hit,
95            score: if hit { 1.0 } else { 0.0 },
96            reasons: vec![(
97                "direct destination match".into(),
98                if hit { 1.0 } else { 0.0 },
99            )],
100        })
101    }
102}
103
104impl RoutingStrategy for Epidemic {
105    fn name(&self) -> &'static str {
106        "epidemic"
107    }
108    fn decide(
109        &self,
110        env: &DropEnvelope,
111        peer: PeerId,
112        local: PeerId,
113        now: u64,
114        _store: &Store,
115        mode: NodeMode,
116        peer_relay: RelayCapacity,
117        _remaining_copies: u32,
118    ) -> Result<RouteDecision> {
119        if blocked(env, peer, local, now, mode, peer_relay) {
120            return Ok(deny("ineligible"));
121        }
122        Ok(RouteDecision {
123            forward: true,
124            score: 1.0,
125            reasons: vec![("epidemic".into(), 1.0)],
126        })
127    }
128}
129
130impl RoutingStrategy for SprayAndWait {
131    fn name(&self) -> &'static str {
132        "spray"
133    }
134    fn decide(
135        &self,
136        env: &DropEnvelope,
137        peer: PeerId,
138        local: PeerId,
139        now: u64,
140        _store: &Store,
141        mode: NodeMode,
142        peer_relay: RelayCapacity,
143        remaining_copies: u32,
144    ) -> Result<RouteDecision> {
145        if blocked(env, peer, local, now, mode, peer_relay) {
146            return Ok(deny("ineligible"));
147        }
148        if env.destination.includes(&peer) {
149            return Ok(RouteDecision {
150                forward: true,
151                score: 1.0,
152                reasons: vec![("destination".into(), 1.0)],
153            });
154        }
155        if remaining_copies > 1 {
156            Ok(RouteDecision {
157                forward: true,
158                score: 0.5,
159                reasons: vec![("spray budget".into(), remaining_copies as f64)],
160            })
161        } else {
162            Ok(deny("wait: budget 1, peer is not destination"))
163        }
164    }
165}
166
167impl RoutingStrategy for EncounterBased {
168    fn name(&self) -> &'static str {
169        "encounter"
170    }
171    fn decide(
172        &self,
173        env: &DropEnvelope,
174        peer: PeerId,
175        local: PeerId,
176        now: u64,
177        store: &Store,
178        mode: NodeMode,
179        peer_relay: RelayCapacity,
180        remaining_copies: u32,
181    ) -> Result<RouteDecision> {
182        utility_decide(
183            &self.weights,
184            env,
185            peer,
186            local,
187            now,
188            store,
189            mode,
190            peer_relay,
191            remaining_copies,
192        )
193    }
194}
195
196impl RoutingStrategy for Utility {
197    fn name(&self) -> &'static str {
198        "utility"
199    }
200    fn decide(
201        &self,
202        env: &DropEnvelope,
203        peer: PeerId,
204        local: PeerId,
205        now: u64,
206        store: &Store,
207        mode: NodeMode,
208        peer_relay: RelayCapacity,
209        remaining_copies: u32,
210    ) -> Result<RouteDecision> {
211        utility_decide(
212            &self.weights,
213            env,
214            peer,
215            local,
216            now,
217            store,
218            mode,
219            peer_relay,
220            remaining_copies,
221        )
222    }
223}
224
225fn utility_decide(
226    w: &ScoreWeights,
227    env: &DropEnvelope,
228    peer: PeerId,
229    local: PeerId,
230    now: u64,
231    store: &Store,
232    mode: NodeMode,
233    peer_relay: RelayCapacity,
234    remaining_copies: u32,
235) -> Result<RouteDecision> {
236    if blocked(env, peer, local, now, mode, peer_relay) {
237        return Ok(deny("ineligible"));
238    }
239    if env.destination.includes(&peer) {
240        return Ok(RouteDecision {
241            forward: true,
242            score: 1.0,
243            reasons: vec![("destination".into(), 1.0)],
244        });
245    }
246    let enc = store.encounter(peer)?.unwrap_or(EncounterRow {
247        first_seen: now,
248        last_seen: now,
249        encounter_count: 1,
250        bytes_sent: 0,
251        bytes_received: 0,
252        successful_forwards: 0,
253        failed_forwards: 0,
254    });
255    let dest_p = match &env.destination {
256        Destination::One { .. } | Destination::Many { .. } => 0.2,
257        Destination::Public => 0.5,
258        Destination::Group { .. } => 0.15,
259    };
260    let recency = (1.0 / (1.0 + (now.saturating_sub(enc.last_seen) as f64 / 3600.0))).min(1.0);
261    let freq = (enc.encounter_count as f64 / 20.0).min(1.0);
262    let hist = if enc.successful_forwards + enc.failed_forwards == 0 {
263        0.1
264    } else {
265        enc.successful_forwards as f64 / (enc.successful_forwards + enc.failed_forwards) as f64
266    };
267    let cap = match peer_relay {
268        RelayCapacity::Full => 1.0,
269        RelayCapacity::Low => 0.3,
270        RelayCapacity::None => 0.0,
271    };
272    let pri = (env.priority.as_u8() as f64) / 3.0;
273    let life = {
274        let total = env.expiration.saturating_sub(env.creation_time).max(1);
275        let left = env.expiration.saturating_sub(now);
276        (left as f64 / total as f64).clamp(0.0, 1.0)
277    };
278    let repl =
279        (remaining_copies as f64 / env.routing_policy.replication_budget.max(1) as f64).min(1.0);
280    let storage = store
281        .stats()
282        .map(|s| {
283            let cap = store.quotas().maximum.max(1);
284            1.0 - (s.physical_size as f64 / cap as f64).clamp(0.0, 1.0)
285        })
286        .unwrap_or(0.5);
287    let battery = match mode {
288        NodeMode::Battery => 0.25,
289        NodeMode::Performance => 1.0,
290        _ => 0.7,
291    };
292    let transport = 0.7; // measured RTT is PLANNED; LAN/TCP assumed moderate
293    let mut reasons = vec![
294        (
295            "destination_probability".into(),
296            dest_p * w.destination_probability,
297        ),
298        ("recent encounters".into(), recency * w.encounter_recency),
299        ("encounter frequency".into(), freq * w.encounter_frequency),
300        ("successful deliveries".into(), hist * w.delivery_history),
301        ("available capacity".into(), cap * w.peer_capacity),
302        ("object priority".into(), pri * w.object_priority),
303        (
304            "expiration urgency".into(),
305            (1.0 - life) * w.remaining_lifetime,
306        ),
307        ("replication need".into(), repl * w.replication_need),
308        (
309            "local storage headroom".into(),
310            storage * w.storage_pressure,
311        ),
312        ("battery / mode cost".into(), battery * w.battery_cost),
313        (
314            "transport reliability".into(),
315            transport * w.transport_reliability,
316        ),
317    ];
318    if remaining_copies <= 1 {
319        reasons.push(("replication penalty".into(), -0.4));
320    }
321    let score: f64 = reasons.iter().map(|(_, v)| *v).sum::<f64>() / reasons.len() as f64;
322    Ok(RouteDecision {
323        forward: score >= 0.25,
324        score,
325        reasons,
326    })
327}
328
329fn blocked(
330    env: &DropEnvelope,
331    peer: PeerId,
332    local: PeerId,
333    now: u64,
334    mode: NodeMode,
335    peer_relay: RelayCapacity,
336) -> bool {
337    if env.source == peer {
338        return true;
339    }
340    if env.is_expired(now) {
341        return true;
342    }
343    if env.remaining_hops() == 0 {
344        return true;
345    }
346    if peer == local {
347        return true;
348    }
349    if !mode.unsolicited_relay() && !env.destination.includes(&peer) {
350        return true;
351    }
352    peer_relay == RelayCapacity::None && !env.destination.includes(&peer)
353}
354
355fn deny(msg: &str) -> RouteDecision {
356    RouteDecision {
357        forward: false,
358        score: 0.0,
359        reasons: vec![(msg.into(), 0.0)],
360    }
361}
362
363pub fn strategy_from_kind(kind: RoutingPolicyKind) -> Box<dyn RoutingStrategy> {
364    match kind {
365        RoutingPolicyKind::Direct => Box::new(Direct),
366        RoutingPolicyKind::Epidemic => Box::new(Epidemic),
367        RoutingPolicyKind::SprayAndWait => Box::new(SprayAndWait),
368        RoutingPolicyKind::Encounter => Box::new(EncounterBased {
369            weights: ScoreWeights::default(),
370        }),
371        RoutingPolicyKind::Utility => Box::new(Utility {
372            weights: ScoreWeights::default(),
373        }),
374        RoutingPolicyKind::Adaptive => Box::new(AdaptiveRouter),
375    }
376}
377
378#[derive(Debug, Clone, serde::Serialize)]
379pub struct DeliveryForecast {
380    pub destination: String,
381    pub p_lt_1h: f64,
382    pub p_lt_6h: f64,
383    pub p_lt_24h: f64,
384    pub p_lt_3d: f64,
385    pub confidence: f64,
386    pub sample_encounters: u64,
387    pub note: String,
388}
389
390/// Heuristic from local encounter history. Never a delivery guarantee.
391pub fn predict_delivery(store: &Store, dest: PeerId, now: u64) -> Result<DeliveryForecast> {
392    let enc = store.encounter(dest)?;
393    let hist = store.hour_hist(dest).unwrap_or([0; 24]);
394    let hour = ((now % 86_400) / 3600) as usize;
395    let hour_weight = hist[hour] as f64 / (hist.iter().sum::<u32>().max(1) as f64);
396    let (count, last, first, ok_ratio) = match &enc {
397        Some(e) => {
398            let tot = e.successful_forwards + e.failed_forwards;
399            let ratio = if tot == 0 {
400                0.35
401            } else {
402                e.successful_forwards as f64 / tot as f64
403            };
404            (e.encounter_count, e.last_seen, e.first_seen, ratio)
405        }
406        None => (0, 0, now, 0.1),
407    };
408    let recency = if count == 0 {
409        0.05
410    } else {
411        (1.0 / (1.0 + (now.saturating_sub(last) as f64 / 3600.0))).min(1.0)
412    };
413    let span_days = ((now.saturating_sub(first)) as f64 / 86_400.0).max(1.0);
414    let freq = ((count as f64 / span_days) / 4.0).min(1.0);
415    let base =
416        (0.15 + 0.4 * recency + 0.3 * freq + 0.15 * ok_ratio + 0.1 * hour_weight).clamp(0.0, 0.97);
417    let confidence = ((count as f64 / 12.0) * 0.7 + (hist.iter().sum::<u32>() as f64 / 48.0) * 0.3)
418        .clamp(0.05, 0.85);
419    Ok(DeliveryForecast {
420        destination: dest.to_string(),
421        p_lt_1h: (base * recency * 0.55).clamp(0.0, 0.95),
422        p_lt_6h: (base * 0.75 + recency * 0.1).clamp(0.0, 0.96),
423        p_lt_24h: (base * 0.9 + freq * 0.08).clamp(0.0, 0.97),
424        p_lt_3d: (base * 0.95 + 0.04).clamp(0.0, 0.98),
425        confidence,
426        sample_encounters: count,
427        note: "Local estimate from this node's encounter log. Not a guarantee.".into(),
428    })
429}
430
431/// Local adaptive policy. No central reputation service.
432pub struct AdaptiveRouter;
433
434impl RoutingStrategy for AdaptiveRouter {
435    fn name(&self) -> &'static str {
436        "adaptive"
437    }
438    #[allow(clippy::too_many_arguments)]
439    fn decide(
440        &self,
441        env: &DropEnvelope,
442        peer: PeerId,
443        local: PeerId,
444        now: u64,
445        store: &Store,
446        mode: NodeMode,
447        peer_relay: RelayCapacity,
448        remaining_copies: u32,
449    ) -> Result<RouteDecision> {
450        if env.routing_policy.trusted_only {
451            if let Ok(book) = store.load_contacts() {
452                let t = book
453                    .get(&peer)
454                    .map(|c| c.trust)
455                    .unwrap_or(deaddrop_core::TrustState::Unknown);
456                if !matches!(
457                    t,
458                    deaddrop_core::TrustState::Known | deaddrop_core::TrustState::Verified
459                ) {
460                    return Ok(deny("trusted-only"));
461                }
462            }
463        }
464        if env.destination.includes(&peer) {
465            return Direct.decide(
466                env,
467                peer,
468                local,
469                now,
470                store,
471                mode,
472                peer_relay,
473                remaining_copies,
474            );
475        }
476        let n = store.all_encounters()?.len();
477        if let deaddrop_core::Destination::One { peer: dest } = &env.destination {
478            if let Ok(Some(enc)) = store.encounter(*dest) {
479                if enc.encounter_count >= 3 {
480                    let mut d = EncounterBased {
481                        weights: ScoreWeights::default(),
482                    }
483                    .decide(
484                        env,
485                        peer,
486                        local,
487                        now,
488                        store,
489                        mode,
490                        peer_relay,
491                        remaining_copies,
492                    )?;
493                    d.reasons
494                        .insert(0, ("adaptive: dest frequently encountered".into(), 0.2));
495                    return Ok(d);
496                }
497            }
498        }
499        if n <= 3 {
500            let mut d = Epidemic.decide(
501                env,
502                peer,
503                local,
504                now,
505                store,
506                mode,
507                peer_relay,
508                remaining_copies,
509            )?;
510            d.reasons
511                .insert(0, ("adaptive: small local graph".into(), 0.15));
512            return Ok(d);
513        }
514        let mut d = SprayAndWait.decide(
515            env,
516            peer,
517            local,
518            now,
519            store,
520            mode,
521            peer_relay,
522            remaining_copies,
523        )?;
524        d.reasons
525            .insert(0, ("adaptive: spray-and-wait default".into(), 0.1));
526        Ok(d)
527    }
528}
529
530pub fn confidence_pct(d: &RouteDecision) -> u8 {
531    ((d.score.clamp(0.0, 1.0)) * 100.0).round() as u8
532}
533
534pub fn format_explain(peer: PeerId, d: &RouteDecision) -> String {
535    let mut s = format!("Candidate: {peer}\n");
536    for (k, v) in &d.reasons {
537        s.push_str(&format!("{k:<28} {v:+.2}\n"));
538    }
539    s.push_str(&format!("Final Utility               {:.2}\n", d.score));
540    s.push_str(&format!(
541        "Route confidence            {}%\n",
542        confidence_pct(d)
543    ));
544    s.push_str(&format!(
545        "Decision                    {}\n",
546        if d.forward { "FORWARD" } else { "HOLD" }
547    ));
548    s
549}