saorsa_core/adaptive/
trust.rs1use crate::PeerId;
23use std::collections::HashMap;
24use std::sync::Arc;
25use std::time::Instant;
26use tokio::sync::RwLock;
27
28pub const DEFAULT_NEUTRAL_TRUST: f64 = 0.5;
30
31const MIN_TRUST_SCORE: f64 = 0.0;
33
34const MAX_TRUST_SCORE: f64 = 1.0;
36
37const EMA_WEIGHT: f64 = 0.1;
39
40const DECAY_LAMBDA: f64 = 1.3761e-6;
47
48#[derive(Debug, Clone)]
50struct PeerTrust {
51 score: f64,
53 last_updated: Instant,
55}
56
57impl PeerTrust {
58 fn new() -> Self {
59 Self {
60 score: DEFAULT_NEUTRAL_TRUST,
61 last_updated: Instant::now(),
62 }
63 }
64
65 fn apply_decay(&mut self) {
70 let elapsed_secs = self.last_updated.elapsed().as_secs_f64();
71 self.apply_decay_secs(elapsed_secs);
72 }
73
74 fn apply_decay_secs(&mut self, elapsed_secs: f64) {
79 if elapsed_secs > 0.0 {
80 let decay_factor = (-DECAY_LAMBDA * elapsed_secs).exp();
81 self.score =
82 DEFAULT_NEUTRAL_TRUST + (self.score - DEFAULT_NEUTRAL_TRUST) * decay_factor;
83 self.score = self.score.clamp(MIN_TRUST_SCORE, MAX_TRUST_SCORE);
84 self.last_updated = Instant::now();
85 }
86 }
87
88 fn record(&mut self, observation: f64) {
90 self.apply_decay();
91 self.score = (1.0 - EMA_WEIGHT) * self.score + EMA_WEIGHT * observation;
92 self.score = self.score.clamp(MIN_TRUST_SCORE, MAX_TRUST_SCORE);
93 self.last_updated = Instant::now();
94 }
95
96 fn decayed_score(&self) -> f64 {
98 Self::decay_score(self.score, self.last_updated.elapsed().as_secs_f64())
99 }
100
101 fn decay_score(score: f64, elapsed_secs: f64) -> f64 {
103 if elapsed_secs > 0.0 {
104 let decay_factor = (-DECAY_LAMBDA * elapsed_secs).exp();
105 let decayed = DEFAULT_NEUTRAL_TRUST + (score - DEFAULT_NEUTRAL_TRUST) * decay_factor;
106 decayed.clamp(MIN_TRUST_SCORE, MAX_TRUST_SCORE)
107 } else {
108 score
109 }
110 }
111}
112
113const SUCCESS_OBSERVATION: f64 = 1.0;
115
116const FAILURE_OBSERVATION: f64 = 0.0;
118
119#[derive(Debug, Clone)]
121pub enum NodeStatisticsUpdate {
122 CorrectResponse,
124 FailedResponse,
126}
127
128#[derive(Debug)]
136pub struct TrustEngine {
137 peers: Arc<RwLock<HashMap<PeerId, PeerTrust>>>,
139}
140
141impl TrustEngine {
142 pub fn new() -> Self {
144 Self {
145 peers: Arc::new(RwLock::new(HashMap::new())),
146 }
147 }
148
149 pub async fn update_node_stats(&self, node_id: &PeerId, update: NodeStatisticsUpdate) {
151 let mut peers = self.peers.write().await;
152 let entry = peers.entry(*node_id).or_insert_with(PeerTrust::new);
153
154 let observation = match update {
155 NodeStatisticsUpdate::CorrectResponse => SUCCESS_OBSERVATION,
156 NodeStatisticsUpdate::FailedResponse => FAILURE_OBSERVATION,
157 };
158
159 entry.record(observation);
160 }
161
162 pub fn score(&self, node_id: &PeerId) -> f64 {
167 if let Ok(peers) = self.peers.try_read() {
168 peers
169 .get(node_id)
170 .map(|p| p.decayed_score())
171 .unwrap_or(DEFAULT_NEUTRAL_TRUST)
172 } else {
173 DEFAULT_NEUTRAL_TRUST
174 }
175 }
176
177 pub async fn remove_node(&self, node_id: &PeerId) {
179 let mut peers = self.peers.write().await;
180 peers.remove(node_id);
181 }
182
183 #[cfg(test)]
189 pub async fn simulate_elapsed(&self, node_id: &PeerId, elapsed: std::time::Duration) {
190 let mut peers = self.peers.write().await;
191 if let Some(trust) = peers.get_mut(node_id) {
192 trust.apply_decay_secs(elapsed.as_secs_f64());
193 }
194 }
195}
196
197impl Default for TrustEngine {
198 fn default() -> Self {
199 Self::new()
200 }
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206
207 #[tokio::test]
208 async fn test_unknown_peer_returns_neutral() {
209 let engine = TrustEngine::new();
210 let peer = PeerId::random();
211 assert!((engine.score(&peer) - DEFAULT_NEUTRAL_TRUST).abs() < f64::EPSILON);
212 }
213
214 #[tokio::test]
215 async fn test_successes_increase_score() {
216 let engine = TrustEngine::new();
217 let peer = PeerId::random();
218
219 for _ in 0..50 {
220 engine
221 .update_node_stats(&peer, NodeStatisticsUpdate::CorrectResponse)
222 .await;
223 }
224
225 let score = engine.score(&peer);
226 assert!(
227 score > DEFAULT_NEUTRAL_TRUST,
228 "Score {score} should be above neutral"
229 );
230 assert!(score <= MAX_TRUST_SCORE, "Score {score} should be <= max");
231 }
232
233 #[tokio::test]
234 async fn test_failures_decrease_score() {
235 let engine = TrustEngine::new();
236 let peer = PeerId::random();
237
238 for _ in 0..50 {
239 engine
240 .update_node_stats(&peer, NodeStatisticsUpdate::FailedResponse)
241 .await;
242 }
243
244 let score = engine.score(&peer);
245 assert!(
246 score < DEFAULT_NEUTRAL_TRUST,
247 "Score {score} should be below neutral"
248 );
249 assert!(score >= MIN_TRUST_SCORE, "Score {score} should be >= min");
250 }
251
252 #[tokio::test]
253 async fn test_scores_clamped_to_bounds() {
254 let engine = TrustEngine::new();
255 let peer = PeerId::random();
256
257 for _ in 0..1000 {
259 engine
260 .update_node_stats(&peer, NodeStatisticsUpdate::CorrectResponse)
261 .await;
262 }
263 let score = engine.score(&peer);
264 assert!(score >= MIN_TRUST_SCORE, "Score {score} below min");
265 assert!(score <= MAX_TRUST_SCORE, "Score {score} above max");
266
267 for _ in 0..2000 {
269 engine
270 .update_node_stats(&peer, NodeStatisticsUpdate::FailedResponse)
271 .await;
272 }
273 let score = engine.score(&peer);
274 assert!(score >= MIN_TRUST_SCORE, "Score {score} below min");
275 assert!(score <= MAX_TRUST_SCORE, "Score {score} above max");
276 }
277
278 #[tokio::test]
279 async fn test_remove_node_resets_to_neutral() {
280 let engine = TrustEngine::new();
281 let peer = PeerId::random();
282
283 engine
284 .update_node_stats(&peer, NodeStatisticsUpdate::FailedResponse)
285 .await;
286 assert!(engine.score(&peer) < DEFAULT_NEUTRAL_TRUST);
287
288 engine.remove_node(&peer).await;
289 assert!((engine.score(&peer) - DEFAULT_NEUTRAL_TRUST).abs() < f64::EPSILON);
290 }
291
292 #[tokio::test]
293 async fn test_ema_blends_observations() {
294 let engine = TrustEngine::new();
295 let peer = PeerId::random();
296
297 engine
299 .update_node_stats(&peer, NodeStatisticsUpdate::FailedResponse)
300 .await;
301 let after_fail = engine.score(&peer);
302 assert!(after_fail < DEFAULT_NEUTRAL_TRUST);
303
304 engine
306 .update_node_stats(&peer, NodeStatisticsUpdate::CorrectResponse)
307 .await;
308 let after_success = engine.score(&peer);
309 assert!(after_success > after_fail, "Success should increase score");
310 }
311
312 #[test]
317 fn test_worst_score_unblocks_after_3_days() {
318 let three_days_secs = (3 * 24 * 3600) as f64;
319 let score = PeerTrust::decay_score(MIN_TRUST_SCORE, three_days_secs);
320
321 assert!(
322 score >= 0.15,
323 "After 3 days, score {score} should be >= block threshold 0.15",
324 );
325 }
326
327 #[test]
329 fn test_worst_score_still_blocked_before_3_days() {
330 let just_under_3_days = (3 * 24 * 3600 - 3600) as f64; let score = PeerTrust::decay_score(MIN_TRUST_SCORE, just_under_3_days);
332
333 assert!(
334 score < 0.15,
335 "Before 3 days, score {score} should still be < block threshold 0.15",
336 );
337 }
338
339 #[test]
340 fn test_decay_from_high_score_moves_down() {
341 let one_week_secs = (7 * 24 * 3600) as f64;
342 let score = PeerTrust::decay_score(0.95, one_week_secs);
343
344 assert!(score < 0.95, "Score should have decayed from 0.95");
345 assert!(
346 score > DEFAULT_NEUTRAL_TRUST,
347 "Score should still be above neutral after 1 week"
348 );
349 }
350
351 #[test]
352 fn test_decay_from_low_score_moves_up() {
353 let one_week_secs = (7 * 24 * 3600) as f64;
354 let score = PeerTrust::decay_score(0.1, one_week_secs);
355
356 assert!(score > 0.1, "Low score should decay upward toward neutral");
357 }
358}