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}