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; 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
390pub 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
431pub 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}