Skip to main content

saorsa_core/adaptive/
trust.rs

1// Copyright 2024 Saorsa Labs Limited
2//
3// This software is dual-licensed under:
4// - GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later)
5// - Commercial License
6//
7// For AGPL-3.0 license, see LICENSE-AGPL-3.0
8// For commercial licensing, contact: david@saorsalabs.com
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under these licenses is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
14//! Local trust scoring based on direct peer interactions.
15//!
16//! Scores use an exponential moving average (EMA) that blends each new
17//! observation and decays toward neutral when idle. No background task
18//! needed — decay is applied lazily on each read or write.
19//!
20//! Future: full EigenTrust with peer-to-peer trust gossip.
21
22use crate::PeerId;
23use std::collections::HashMap;
24use std::sync::Arc;
25use std::time::Instant;
26use tokio::sync::RwLock;
27
28/// Default trust score for unknown peers
29pub const DEFAULT_NEUTRAL_TRUST: f64 = 0.5;
30
31/// Minimum trust score a peer can reach
32const MIN_TRUST_SCORE: f64 = 0.0;
33
34/// Maximum trust score a peer can reach
35const MAX_TRUST_SCORE: f64 = 1.0;
36
37/// EMA weight for each new observation (higher = faster response to events)
38const EMA_WEIGHT: f64 = 0.1;
39
40/// Decay constant (per-second).
41///
42/// Tuned so that the worst possible score (0.0) takes 3 days of idle time
43/// to decay back above the block threshold (0.15).
44///
45/// Derivation: 0.15 = 0.5 - 0.5 * e^(-λ * 259200)  →  λ = -ln(0.7) / 259200
46const DECAY_LAMBDA: f64 = 1.3761e-6;
47
48/// Per-node trust state
49#[derive(Debug, Clone)]
50struct PeerTrust {
51    /// Current trust score (between MIN and MAX)
52    score: f64,
53    /// When the score was last updated (for decay calculation)
54    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    /// Apply time-based decay toward neutral, then clamp to bounds.
66    ///
67    /// Uses exponential decay: `score = neutral + (score - neutral) * e^(-λt)`
68    /// This smoothly pulls the score back toward 0.5 over time.
69    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    /// Apply decay for an explicit number of elapsed seconds.
75    ///
76    /// Factored out so tests can call this directly without manipulating
77    /// `Instant` (which can overflow on Windows if uptime < the duration).
78    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    /// Apply a new observation via EMA, after first applying decay.
89    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    /// Get the current score with decay applied (does not mutate).
97    fn decayed_score(&self) -> f64 {
98        Self::decay_score(self.score, self.last_updated.elapsed().as_secs_f64())
99    }
100
101    /// Pure function: compute what a score would be after `elapsed_secs` of decay.
102    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
113/// Observation value for a successful interaction
114const SUCCESS_OBSERVATION: f64 = 1.0;
115
116/// Observation value for a failed interaction
117const FAILURE_OBSERVATION: f64 = 0.0;
118
119/// Statistics update type for recording peer interaction outcomes
120#[derive(Debug, Clone)]
121pub enum NodeStatisticsUpdate {
122    /// Peer provided a correct response
123    CorrectResponse,
124    /// Peer failed to provide a response
125    FailedResponse,
126}
127
128/// Local trust engine based on direct peer observations.
129///
130/// Scores are an exponential moving average of success/failure observations
131/// that decays toward neutral (0.5) when idle. Bounded by `MIN_TRUST_SCORE`
132/// and `MAX_TRUST_SCORE`.
133///
134/// This is the **sole authority** on peer trust scores in the system.
135#[derive(Debug)]
136pub struct TrustEngine {
137    /// Per-node trust state
138    peers: Arc<RwLock<HashMap<PeerId, PeerTrust>>>,
139}
140
141impl TrustEngine {
142    /// Create a new TrustEngine
143    pub fn new() -> Self {
144        Self {
145            peers: Arc::new(RwLock::new(HashMap::new())),
146        }
147    }
148
149    /// Record a peer interaction outcome
150    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    /// Get current trust score for a peer (synchronous).
163    ///
164    /// Applies time decay lazily — no background task needed.
165    /// Returns `DEFAULT_NEUTRAL_TRUST` (0.5) for unknown peers.
166    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    /// Remove a peer from the trust system entirely
178    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    /// Simulate time passing for a peer (test only).
184    ///
185    /// Applies decay as if `elapsed` time had passed since the last update.
186    /// Uses `apply_decay_secs` directly to avoid `Instant` subtraction,
187    /// which panics on Windows when system uptime < `elapsed`.
188    #[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        // Many successes — should not exceed MAX
258        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        // Many failures — should not go below MIN
268        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        // First failure moves score below neutral
298        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        // A success moves it back up (but not all the way to neutral)
305        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    /// 3 days of idle time from worst score (0.0) should cross the block threshold (0.15).
313    ///
314    /// Uses the pure `decay_score` function to avoid `Instant` subtraction,
315    /// which panics on Windows if system uptime < the simulated duration.
316    #[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    /// Just under 3 days should NOT be enough to unblock
328    #[test]
329    fn test_worst_score_still_blocked_before_3_days() {
330        let just_under_3_days = (3 * 24 * 3600 - 3600) as f64; // 3 days minus 1 hour
331        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}