1use std::collections::HashMap;
19use std::time::Instant;
20
21#[derive(Debug, Clone)]
23pub struct ScoreParameter {
24 pub topic_weight: f64,
26 pub delivery_weight: f64,
28 pub behaviour_weight: f64,
30 pub decay_interval_secs: u64,
32 pub score_threshold_ban: f64,
34 pub score_threshold_graylist: f64,
36}
37
38impl Default for ScoreParameter {
39 fn default() -> Self {
40 Self {
41 topic_weight: 0.5,
42 delivery_weight: 0.3,
43 behaviour_weight: 0.2,
44 decay_interval_secs: 10,
45 score_threshold_ban: -100.0,
46 score_threshold_graylist: -10.0,
47 }
48 }
49}
50
51#[derive(Debug, Clone, Default)]
53pub struct TopicScore {
54 pub first_message_deliveries: f64,
56 pub mesh_message_deliveries: f64,
58 pub mesh_failure_penalty: f64,
60 pub invalid_message_deliveries: f64,
62 pub in_mesh: bool,
64 pub mesh_time_secs: f64,
66}
67
68impl TopicScore {
69 pub fn score(&self, p: &ScoreParameter) -> f64 {
79 let p1 = self.first_message_deliveries;
80 let p2 = self.mesh_message_deliveries;
81 let p3 = -self.mesh_failure_penalty;
82 let p4 = -self.invalid_message_deliveries * 10.0;
83 (p1 + p2 + p3 + p4) * p.topic_weight
84 }
85}
86
87#[derive(Debug, Clone, PartialEq)]
89pub enum BehaviourPenalty {
90 InvalidMessage,
92 GraftBackoff,
94 PromiscuousPX,
96 AppSpecific(f64),
98}
99
100impl BehaviourPenalty {
101 pub fn delta(&self) -> f64 {
103 match self {
104 BehaviourPenalty::InvalidMessage => -10.0,
105 BehaviourPenalty::GraftBackoff => -5.0,
106 BehaviourPenalty::PromiscuousPX => -3.0,
107 BehaviourPenalty::AppSpecific(v) => *v,
108 }
109 }
110}
111
112#[derive(Debug, Clone)]
114pub struct PeerScore {
115 pub peer_id: String,
117 pub topic_scores: HashMap<String, TopicScore>,
119 pub behaviour_penalties: f64,
121 pub app_specific_score: f64,
123 pub last_decay: Instant,
125}
126
127impl PeerScore {
128 pub fn new(peer_id: impl Into<String>) -> Self {
130 Self {
131 peer_id: peer_id.into(),
132 topic_scores: HashMap::new(),
133 behaviour_penalties: 0.0,
134 app_specific_score: 0.0,
135 last_decay: Instant::now(),
136 }
137 }
138
139 pub fn composite_score(&self, params: &ScoreParameter) -> f64 {
148 let topic_sum: f64 = self.topic_scores.values().map(|ts| ts.score(params)).sum();
149 let behaviour_part = self.behaviour_penalties * params.behaviour_weight;
150 let app_part = self.app_specific_score * params.delivery_weight;
151 topic_sum + behaviour_part + app_part
152 }
153
154 pub fn apply_penalty(&mut self, p: BehaviourPenalty) {
156 self.behaviour_penalties += p.delta();
157 }
158
159 pub fn record_first_delivery(&mut self, topic: &str) {
161 let ts = self.topic_scores.entry(topic.to_string()).or_default();
162 ts.first_message_deliveries += 1.0;
163 }
164
165 pub fn record_mesh_delivery(&mut self, topic: &str) {
167 let ts = self.topic_scores.entry(topic.to_string()).or_default();
168 ts.mesh_message_deliveries += 1.0;
169 }
170
171 pub fn record_invalid_message(&mut self, topic: &str) {
174 let ts = self.topic_scores.entry(topic.to_string()).or_default();
175 ts.invalid_message_deliveries += 1.0;
176 }
177
178 pub fn set_in_mesh(&mut self, topic: &str, in_mesh: bool) {
180 let ts = self.topic_scores.entry(topic.to_string()).or_default();
181 ts.in_mesh = in_mesh;
182 }
183
184 pub fn decay(&mut self, params: &ScoreParameter) {
188 let elapsed = self.last_decay.elapsed();
189 let factor = 0.9_f64.powf(elapsed.as_secs_f64() / params.decay_interval_secs as f64);
190
191 for ts in self.topic_scores.values_mut() {
192 ts.first_message_deliveries *= factor;
193 ts.mesh_message_deliveries *= factor;
194 ts.mesh_failure_penalty *= factor;
195 ts.invalid_message_deliveries *= factor;
196 ts.mesh_time_secs *= factor;
197 }
198
199 self.behaviour_penalties *= factor;
200 self.app_specific_score *= factor;
201 self.last_decay = Instant::now();
202 }
203}
204
205#[derive(Debug)]
208pub struct PeerScoreTracker {
209 pub scores: HashMap<String, PeerScore>,
211 pub params: ScoreParameter,
213}
214
215impl PeerScoreTracker {
216 pub fn new(params: ScoreParameter) -> Self {
218 Self {
219 scores: HashMap::new(),
220 params,
221 }
222 }
223
224 pub fn get_or_create(&mut self, peer_id: &str) -> &mut PeerScore {
227 self.scores
228 .entry(peer_id.to_string())
229 .or_insert_with(|| PeerScore::new(peer_id))
230 }
231
232 pub fn composite_score(&self, peer_id: &str) -> f64 {
234 self.scores
235 .get(peer_id)
236 .map(|ps| ps.composite_score(&self.params))
237 .unwrap_or(0.0)
238 }
239
240 pub fn banned_peers(&self) -> Vec<String> {
242 self.scores
243 .iter()
244 .filter(|(_, ps)| ps.composite_score(&self.params) < self.params.score_threshold_ban)
245 .map(|(id, _)| id.clone())
246 .collect()
247 }
248
249 pub fn greylisted_peers(&self) -> Vec<String> {
252 self.scores
253 .iter()
254 .filter(|(_, ps)| {
255 let s = ps.composite_score(&self.params);
256 s < self.params.score_threshold_graylist && s >= self.params.score_threshold_ban
257 })
258 .map(|(id, _)| id.clone())
259 .collect()
260 }
261
262 pub fn best_peers(&self, n: usize) -> Vec<String> {
264 let mut scored: Vec<(String, f64)> = self
265 .scores
266 .iter()
267 .map(|(id, ps)| (id.clone(), ps.composite_score(&self.params)))
268 .collect();
269 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
270 scored.into_iter().take(n).map(|(id, _)| id).collect()
271 }
272
273 pub fn apply_decay_all(&mut self) {
275 let params = self.params.clone();
276 for ps in self.scores.values_mut() {
277 ps.decay(¶ms);
278 }
279 }
280
281 pub fn prune_peers(&mut self, ids: &[&str]) {
283 for id in ids {
284 self.scores.remove(*id);
285 }
286 }
287}
288
289#[cfg(test)]
294mod tests {
295 use super::*;
296 use std::time::Duration;
297
298 #[test]
300 fn test_default_params() {
301 let p = ScoreParameter::default();
302 assert!((p.topic_weight - 0.5).abs() < f64::EPSILON);
303 assert!((p.delivery_weight - 0.3).abs() < f64::EPSILON);
304 assert!((p.behaviour_weight - 0.2).abs() < f64::EPSILON);
305 assert_eq!(p.decay_interval_secs, 10);
306 assert!((p.score_threshold_ban - (-100.0)).abs() < f64::EPSILON);
307 assert!((p.score_threshold_graylist - (-10.0)).abs() < f64::EPSILON);
308 }
309
310 #[test]
312 fn test_topic_score_formula() {
313 let p = ScoreParameter::default();
314 let ts = TopicScore {
315 first_message_deliveries: 4.0,
316 mesh_message_deliveries: 6.0,
317 mesh_failure_penalty: 1.0,
318 invalid_message_deliveries: 0.5,
319 in_mesh: true,
320 mesh_time_secs: 30.0,
321 };
322 let expected = (4.0 + 6.0 - 1.0 - 5.0) * 0.5;
324 assert!((ts.score(&p) - expected).abs() < 1e-9);
325 }
326
327 #[test]
329 fn test_behaviour_penalty_deltas() {
330 assert!((BehaviourPenalty::InvalidMessage.delta() - (-10.0)).abs() < f64::EPSILON);
331 assert!((BehaviourPenalty::GraftBackoff.delta() - (-5.0)).abs() < f64::EPSILON);
332 assert!((BehaviourPenalty::PromiscuousPX.delta() - (-3.0)).abs() < f64::EPSILON);
333 assert!((BehaviourPenalty::AppSpecific(7.5).delta() - 7.5).abs() < f64::EPSILON);
334 assert!((BehaviourPenalty::AppSpecific(-2.0).delta() - (-2.0)).abs() < f64::EPSILON);
335 }
336
337 #[test]
339 fn test_composite_score_weighted_sum() {
340 let p = ScoreParameter::default();
341 let mut ps = PeerScore::new("peer-a");
342 ps.app_specific_score = 10.0;
343 ps.behaviour_penalties = -5.0;
344 let c = ps.composite_score(&p);
346 assert!((c - 2.0).abs() < 1e-9, "got {c}");
347 }
348
349 #[test]
351 fn test_record_first_delivery() {
352 let mut ps = PeerScore::new("peer-b");
353 ps.record_first_delivery("topic-1");
354 ps.record_first_delivery("topic-1");
355 let ts = ps.topic_scores.get("topic-1").expect("topic must exist");
356 assert!((ts.first_message_deliveries - 2.0).abs() < f64::EPSILON);
357 assert!((ts.mesh_message_deliveries).abs() < f64::EPSILON);
358 }
359
360 #[test]
362 fn test_record_mesh_delivery() {
363 let mut ps = PeerScore::new("peer-c");
364 ps.record_mesh_delivery("topic-2");
365 ps.record_mesh_delivery("topic-2");
366 ps.record_mesh_delivery("topic-2");
367 let ts = ps.topic_scores.get("topic-2").expect("topic must exist");
368 assert!((ts.mesh_message_deliveries - 3.0).abs() < f64::EPSILON);
369 assert!((ts.first_message_deliveries).abs() < f64::EPSILON);
370 }
371
372 #[test]
374 fn test_record_invalid_message_penalises() {
375 let p = ScoreParameter::default();
376 let mut ps = PeerScore::new("peer-d");
377 let before = ps.composite_score(&p);
378 ps.record_invalid_message("topic-3");
379 let after = ps.composite_score(&p);
380 assert!(after < before, "invalid message must lower composite score");
381 let ts = ps.topic_scores.get("topic-3").expect("topic must exist");
382 assert!((ts.invalid_message_deliveries - 1.0).abs() < f64::EPSILON);
383 }
384
385 #[test]
387 fn test_set_in_mesh() {
388 let mut ps = PeerScore::new("peer-e");
389 ps.set_in_mesh("t", true);
390 assert!(ps.topic_scores["t"].in_mesh);
391 ps.set_in_mesh("t", false);
392 assert!(!ps.topic_scores["t"].in_mesh);
393 }
394
395 #[test]
397 fn test_apply_penalty_variants() {
398 let mut ps = PeerScore::new("peer-f");
399 assert!((ps.behaviour_penalties).abs() < f64::EPSILON);
400 ps.apply_penalty(BehaviourPenalty::InvalidMessage);
401 assert!((ps.behaviour_penalties - (-10.0)).abs() < f64::EPSILON);
402 ps.apply_penalty(BehaviourPenalty::GraftBackoff);
403 assert!((ps.behaviour_penalties - (-15.0)).abs() < f64::EPSILON);
404 ps.apply_penalty(BehaviourPenalty::PromiscuousPX);
405 assert!((ps.behaviour_penalties - (-18.0)).abs() < f64::EPSILON);
406 ps.apply_penalty(BehaviourPenalty::AppSpecific(3.0));
407 assert!((ps.behaviour_penalties - (-15.0)).abs() < f64::EPSILON);
408 }
409
410 #[test]
412 fn test_decay_reduces_fields() {
413 let p = ScoreParameter {
414 decay_interval_secs: 1,
415 ..ScoreParameter::default()
416 };
417 let mut ps = PeerScore::new("peer-g");
418 ps.record_first_delivery("t");
419 ps.record_mesh_delivery("t");
420 ps.record_invalid_message("t");
421 ps.behaviour_penalties = -20.0;
422 ps.app_specific_score = 5.0;
423
424 ps.last_decay = Instant::now() - Duration::from_secs(10);
426 ps.decay(&p);
427
428 let ts = &ps.topic_scores["t"];
429 assert!(ts.first_message_deliveries < 1.0);
430 assert!(ts.mesh_message_deliveries < 1.0);
431 assert!(ts.invalid_message_deliveries < 1.0);
432 assert!(ps.behaviour_penalties > -20.0);
433 assert!(ps.app_specific_score < 5.0);
434 }
435
436 #[test]
438 fn test_banned_peers() {
439 let p = ScoreParameter::default();
440 let mut tracker = PeerScoreTracker::new(p);
441 {
442 let ps = tracker.get_or_create("good");
443 ps.app_specific_score = 10.0;
444 }
445 {
446 let ps = tracker.get_or_create("bad");
447 ps.behaviour_penalties = -600.0;
449 }
450 let banned = tracker.banned_peers();
451 assert!(banned.contains(&"bad".to_string()));
452 assert!(!banned.contains(&"good".to_string()));
453 }
454
455 #[test]
457 fn test_greylisted_peers() {
458 let p = ScoreParameter::default();
459 let mut tracker = PeerScoreTracker::new(p);
460 {
461 let ps = tracker.get_or_create("ok");
462 ps.app_specific_score = 5.0;
463 }
464 {
465 let ps = tracker.get_or_create("grey");
466 ps.behaviour_penalties = -75.0;
470 }
471 {
472 let ps = tracker.get_or_create("banned");
473 ps.behaviour_penalties = -600.0;
474 }
475 let grey = tracker.greylisted_peers();
476 assert!(grey.contains(&"grey".to_string()));
477 assert!(!grey.contains(&"ok".to_string()));
478 assert!(!grey.contains(&"banned".to_string()));
479 }
480
481 #[test]
483 fn test_best_peers_sorted() {
484 let p = ScoreParameter::default();
485 let mut tracker = PeerScoreTracker::new(p);
486 for (id, score) in [("p1", 5.0), ("p2", 20.0), ("p3", 1.0), ("p4", 10.0)] {
487 let ps = tracker.get_or_create(id);
488 ps.app_specific_score = score;
489 }
490 let best = tracker.best_peers(3);
491 assert_eq!(best.len(), 3);
492 assert_eq!(best[0], "p2");
493 assert_eq!(best[1], "p4");
494 assert_eq!(best[2], "p1");
495 }
496
497 #[test]
499 fn test_prune_peers() {
500 let p = ScoreParameter::default();
501 let mut tracker = PeerScoreTracker::new(p);
502 tracker.get_or_create("a");
503 tracker.get_or_create("b");
504 tracker.get_or_create("c");
505 assert_eq!(tracker.scores.len(), 3);
506 tracker.prune_peers(&["a", "c"]);
507 assert_eq!(tracker.scores.len(), 1);
508 assert!(tracker.scores.contains_key("b"));
509 assert!(!tracker.scores.contains_key("a"));
510 assert!(!tracker.scores.contains_key("c"));
511 }
512
513 #[test]
515 fn test_get_or_create() {
516 let p = ScoreParameter::default();
517 let mut tracker = PeerScoreTracker::new(p);
518 assert!(!tracker.scores.contains_key("new-peer"));
519 tracker.get_or_create("new-peer");
520 assert!(tracker.scores.contains_key("new-peer"));
521 tracker.get_or_create("new-peer");
523 assert_eq!(tracker.scores.len(), 1);
524 }
525
526 #[test]
528 fn test_apply_decay_all() {
529 let p = ScoreParameter {
530 decay_interval_secs: 1,
531 ..ScoreParameter::default()
532 };
533 let mut tracker = PeerScoreTracker::new(p);
534 for id in ["x", "y", "z"] {
535 let ps = tracker.get_or_create(id);
536 ps.behaviour_penalties = -50.0;
537 ps.last_decay = Instant::now() - Duration::from_secs(10);
538 }
539 tracker.apply_decay_all();
540 for id in ["x", "y", "z"] {
541 let ps = &tracker.scores[id];
542 assert!(ps.behaviour_penalties > -50.0, "decay must reduce {id}");
544 assert!(ps.behaviour_penalties < 0.0, "{id} still negative");
545 }
546 }
547
548 #[test]
550 fn test_composite_score_unknown_peer() {
551 let tracker = PeerScoreTracker::new(ScoreParameter::default());
552 assert!((tracker.composite_score("ghost")).abs() < f64::EPSILON);
553 }
554}