Skip to main content

fips_core/
peer_rating.rs

1use serde_json::Value;
2
3#[derive(Debug, Clone, PartialEq, Eq)]
4pub struct PeerRatingHealth {
5    pub score: i64,
6    pub sample_count: u64,
7    pub reason: String,
8}
9
10pub fn compute_peer_rating(peer: &Value) -> Option<PeerRatingHealth> {
11    let mut score = 50_i64;
12    let mut signals = 0_usize;
13    let mut misbehavior_signals = 0_usize;
14    let mut reasons = Vec::new();
15
16    if let Some(mmp) = peer.get("mmp").and_then(Value::as_object) {
17        if let Some(loss) = json_f64_prefer(mmp, &["smoothed_loss", "loss_rate"]) {
18            let loss = loss.clamp(0.0, 1.0);
19            signals += 1;
20            if loss <= 0.005 {
21                score += 10;
22            } else if loss <= 0.02 {
23                score += 4;
24            } else {
25                score -= ((loss * 100.0).round() as i64).clamp(4, 45);
26            }
27            reasons.push(format!("loss={loss:.3}"));
28        }
29
30        if let Some(delivery) = average_delivery_ratio(mmp) {
31            let delivery = delivery.clamp(0.0, 1.0);
32            signals += 1;
33            if delivery >= 0.995 {
34                score += 15;
35            } else if delivery >= 0.98 {
36                score += 8;
37            } else if delivery < 0.80 {
38                score -= 35;
39            } else if delivery < 0.90 {
40                score -= 25;
41            } else if delivery < 0.97 {
42                score -= 12;
43            }
44            reasons.push(format!("delivery={delivery:.3}"));
45        }
46
47        if let Some(etx) = json_f64_prefer(mmp, &["smoothed_etx", "etx"]) {
48            let etx = etx.max(1.0);
49            signals += 1;
50            if etx <= 1.05 {
51                score += 12;
52            } else if etx <= 1.20 {
53                score += 6;
54            } else if etx >= 3.0 {
55                score -= 35;
56            } else if etx >= 2.0 {
57                score -= 25;
58            } else if etx >= 1.5 {
59                score -= 15;
60            }
61            reasons.push(format!("etx={etx:.2}"));
62        }
63
64        if let Some(srtt_ms) = json_f64_prefer(mmp, &["srtt_ms"]) {
65            signals += 1;
66            if srtt_ms <= 50.0 {
67                score += 8;
68            } else if srtt_ms <= 150.0 {
69                score += 3;
70            } else if srtt_ms >= 1_000.0 {
71                score -= 25;
72            } else if srtt_ms >= 300.0 {
73                score -= 15;
74            }
75            reasons.push(format!("srtt_ms={srtt_ms:.0}"));
76        }
77
78        if let Some(goodput_bps) = json_f64_prefer(mmp, &["goodput_bps"]) {
79            signals += 1;
80            if goodput_bps >= 5_000_000.0 {
81                score += 8;
82            } else if goodput_bps >= 1_000_000.0 {
83                score += 4;
84            }
85            reasons.push(format!("goodput_bps={goodput_bps:.0}"));
86        }
87    }
88
89    let decrypt_failures = json_u64_field(peer, "consecutive_decrypt_failures").unwrap_or(0);
90    if decrypt_failures > 0 {
91        signals += 1;
92        misbehavior_signals += 1;
93        score -= i64::try_from(decrypt_failures.saturating_mul(20).min(70)).unwrap_or(70);
94        reasons.push(format!("decrypt_failures={decrypt_failures}"));
95    }
96
97    let replay_suppressed = json_u64_field(peer, "replay_suppressed").unwrap_or(0);
98    if replay_suppressed > 0 {
99        signals += 1;
100        misbehavior_signals += 1;
101        score -= i64::try_from(replay_suppressed.saturating_mul(5).min(30)).unwrap_or(30);
102        reasons.push(format!("replay_suppressed={replay_suppressed}"));
103    }
104
105    if signals == 0 {
106        return None;
107    }
108
109    let score = if misbehavior_signals == 0 {
110        score.clamp(50, 100)
111    } else {
112        score.clamp(0, 100)
113    };
114    let sample_count = peer_sample_count(peer).max(signals as u64);
115    Some(PeerRatingHealth {
116        score,
117        sample_count,
118        reason: reasons.join(" "),
119    })
120}
121
122fn average_delivery_ratio(map: &serde_json::Map<String, Value>) -> Option<f64> {
123    let forward = json_f64_prefer(map, &["delivery_ratio_forward"]);
124    let reverse = json_f64_prefer(map, &["delivery_ratio_reverse"]);
125    match (forward, reverse) {
126        (Some(forward), Some(reverse)) => Some((forward + reverse) / 2.0),
127        (Some(forward), None) => Some(forward),
128        (None, Some(reverse)) => Some(reverse),
129        (None, None) => None,
130    }
131}
132
133fn peer_sample_count(peer: &Value) -> u64 {
134    ["packets_sent", "packets_recv"]
135        .into_iter()
136        .filter_map(|key| nested_u64_field(peer, "stats", key))
137        .fold(0_u64, u64::saturating_add)
138}
139
140fn json_u64_field(value: &Value, key: &str) -> Option<u64> {
141    value.get(key).and_then(Value::as_u64)
142}
143
144fn nested_u64_field(value: &Value, object_key: &str, key: &str) -> Option<u64> {
145    value
146        .get(object_key)
147        .and_then(|object| object.get(key))
148        .and_then(Value::as_u64)
149}
150
151fn json_f64_prefer(object: &serde_json::Map<String, Value>, keys: &[&str]) -> Option<f64> {
152    keys.iter()
153        .filter_map(|key| object.get(*key).and_then(Value::as_f64))
154        .find(|value| value.is_finite())
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use serde_json::json;
161
162    #[test]
163    fn healthy_peer_rating_promotes() {
164        let peer = json!({
165            "npub": "npub1good",
166            "stats": {"packets_sent": 100, "packets_recv": 120},
167            "mmp": {
168                "smoothed_loss": 0.001,
169                "smoothed_etx": 1.01,
170                "delivery_ratio_forward": 0.999,
171                "delivery_ratio_reverse": 0.998,
172                "srtt_ms": 20.0,
173                "goodput_bps": 8_000_000.0
174            },
175            "replay_suppressed": 0,
176            "consecutive_decrypt_failures": 0
177        });
178
179        let health = compute_peer_rating(&peer).expect("health rating");
180
181        assert!(health.score > 50, "{health:?}");
182        assert_eq!(health.sample_count, 220);
183    }
184
185    #[test]
186    fn performance_only_weakness_does_not_downvote_peer() {
187        let peer = json!({
188            "npub": "npub1mobile",
189            "stats": {"packets_sent": 100, "packets_recv": 120},
190            "mmp": {
191                "smoothed_loss": 0.20,
192                "smoothed_etx": 3.2,
193                "delivery_ratio_forward": 0.73,
194                "delivery_ratio_reverse": 0.81,
195                "srtt_ms": 1200.0,
196                "goodput_bps": 50_000.0
197            },
198            "replay_suppressed": 0,
199            "consecutive_decrypt_failures": 0
200        });
201
202        let health = compute_peer_rating(&peer).expect("health rating");
203
204        assert_eq!(health.score, 50, "{health:?}");
205    }
206
207    #[test]
208    fn detected_misbehavior_downranks_peer() {
209        let peer = json!({
210            "npub": "npub1bad",
211            "stats": {"packets_sent": 100, "packets_recv": 120},
212            "mmp": {
213                "smoothed_loss": 0.25,
214                "smoothed_etx": 3.2,
215                "delivery_ratio_forward": 0.73,
216                "delivery_ratio_reverse": 0.81,
217                "srtt_ms": 1200.0,
218                "goodput_bps": 50_000.0
219            },
220            "replay_suppressed": 2,
221            "consecutive_decrypt_failures": 2
222        });
223
224        let health = compute_peer_rating(&peer).expect("health rating");
225
226        assert!(health.score < 50, "{health:?}");
227    }
228
229    #[test]
230    fn peer_without_health_signal_is_skipped() {
231        let peer = json!({
232            "npub": "npub1unknown",
233            "stats": {"packets_sent": 0, "packets_recv": 0},
234            "replay_suppressed": 0,
235            "consecutive_decrypt_failures": 0
236        });
237
238        assert!(compute_peer_rating(&peer).is_none());
239    }
240}