Skip to main content

saorsa_core/adaptive/
trust.rs

1// Copyright 2024 Saorsa Labs Limited
2//
3// This software is licensed under the MIT license <LICENSE-MIT or
4// https://opensource.org/licenses/MIT> or the Apache License, Version 2.0
5// <LICENSE-APACHE or https://www.apache.org/licenses/LICENSE-2.0>, at your
6// option. This file may not be copied, modified, or distributed except
7// according to those terms.
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under these licenses is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
13//! Local trust scoring based on direct peer interactions.
14//!
15//! Scores use an exponential moving average (EMA) that blends each new
16//! observation and decays toward neutral when idle. No background task
17//! needed — decay is applied lazily on each read or write.
18//!
19//! Future: full EigenTrust with peer-to-peer trust gossip.
20
21use crate::PeerId;
22use parking_lot::RwLock;
23use serde::{Deserialize, Serialize};
24use std::collections::HashMap;
25use std::sync::Arc;
26use std::time::{Instant, SystemTime, UNIX_EPOCH};
27use tracing::info;
28
29/// Default trust score for unknown peers
30pub const DEFAULT_NEUTRAL_TRUST: f64 = 0.5;
31
32/// Minimum trust score a peer can reach
33const MIN_TRUST_SCORE: f64 = 0.0;
34
35/// Maximum trust score a peer can reach
36const MAX_TRUST_SCORE: f64 = 1.0;
37
38/// EMA weight for each new observation (higher = faster response to events).
39///
40/// At 0.124, each failure moves the score ~12.4% of the gap toward zero.
41/// 3 rapid failures from neutral (0.5) cross the swap threshold (0.35).
42const EMA_WEIGHT: f64 = 0.124;
43
44/// Decay constant (per-second).
45///
46/// Tuned so that a peer experiencing ~3 evenly-spaced failures per day
47/// converges to the swap threshold (0.35). Fewer failures/day → survives,
48/// more → swap-eligible. The worst score (0.0) decays back above 0.35 in ~1 day.
49///
50/// Derivation: at steady state with 3 failures/day (T = 28800 s between events),
51/// s = 0.5·(1−α)·(1−d) / (1−(1−α)·d) = 0.35  with  α = 0.124
52/// Recovery constraint: e^(−λ·86400) = 0.3  →  λ = −ln(0.3)/86400 ≈ 1.394 × 10⁻⁵
53/// d = e^(−λ·28800) ≈ 0.6694
54const DECAY_LAMBDA: f64 = 1.394e-5;
55
56/// Per-node trust state
57#[derive(Debug, Clone)]
58struct PeerTrust {
59    /// Current trust score (between MIN and MAX)
60    score: f64,
61    /// When the score was last updated (for decay calculation)
62    last_updated: Instant,
63}
64
65impl PeerTrust {
66    fn new() -> Self {
67        Self {
68            score: DEFAULT_NEUTRAL_TRUST,
69            last_updated: Instant::now(),
70        }
71    }
72
73    /// Apply time-based decay toward neutral, then clamp to bounds.
74    ///
75    /// Uses exponential decay: `score = neutral + (score - neutral) * e^(-λt)`
76    /// This smoothly pulls the score back toward 0.5 over time.
77    fn apply_decay(&mut self) {
78        let elapsed_secs = self.last_updated.elapsed().as_secs_f64();
79        self.apply_decay_secs(elapsed_secs);
80    }
81
82    /// Apply decay for an explicit number of elapsed seconds.
83    ///
84    /// Factored out so tests can call this directly without manipulating
85    /// `Instant` (which can overflow on Windows if uptime < the duration).
86    fn apply_decay_secs(&mut self, elapsed_secs: f64) {
87        if elapsed_secs > 0.0 {
88            let decay_factor = (-DECAY_LAMBDA * elapsed_secs).exp();
89            self.score =
90                DEFAULT_NEUTRAL_TRUST + (self.score - DEFAULT_NEUTRAL_TRUST) * decay_factor;
91            self.score = self.score.clamp(MIN_TRUST_SCORE, MAX_TRUST_SCORE);
92            self.last_updated = Instant::now();
93        }
94    }
95
96    /// Apply a new observation via weighted EMA, after first applying decay.
97    ///
98    /// The weight controls how heavily this observation influences the score.
99    /// `(1-α)^W * score + (1-(1-α)^W) * observation` generalizes the unit-weight
100    /// formula and is equivalent to applying `W` consecutive unit-weight updates
101    /// for integer W.
102    fn record_weighted(&mut self, observation: f64, weight: f64) -> Option<(f64, f64)> {
103        if !weight.is_finite() || weight <= 0.0 {
104            return None;
105        }
106        self.apply_decay();
107        let previous_score = self.score;
108        let alpha_w = 1.0 - (1.0 - EMA_WEIGHT).powf(weight);
109        self.score = (1.0 - alpha_w) * self.score + alpha_w * observation;
110        self.score = self.score.clamp(MIN_TRUST_SCORE, MAX_TRUST_SCORE);
111        self.last_updated = Instant::now();
112        if (self.score - previous_score).abs() > f64::EPSILON {
113            Some((previous_score, self.score))
114        } else {
115            None
116        }
117    }
118
119    /// Apply a new observation via EMA with unit weight, after first applying decay.
120    #[allow(dead_code)] // design API: retained as convenience wrapper for record_weighted
121    fn record(&mut self, observation: f64) {
122        let _ = self.record_weighted(observation, 1.0);
123    }
124
125    /// Get the current score with decay applied (does not mutate).
126    fn decayed_score(&self) -> f64 {
127        Self::decay_score(self.score, self.last_updated.elapsed().as_secs_f64())
128    }
129
130    /// Pure function: compute what a score would be after `elapsed_secs` of decay.
131    fn decay_score(score: f64, elapsed_secs: f64) -> f64 {
132        if elapsed_secs > 0.0 {
133            let decay_factor = (-DECAY_LAMBDA * elapsed_secs).exp();
134            let decayed = DEFAULT_NEUTRAL_TRUST + (score - DEFAULT_NEUTRAL_TRUST) * decay_factor;
135            decayed.clamp(MIN_TRUST_SCORE, MAX_TRUST_SCORE)
136        } else {
137            score
138        }
139    }
140}
141
142/// Observation value for a successful interaction
143const SUCCESS_OBSERVATION: f64 = 1.0;
144
145/// Observation value for a failed interaction
146const FAILURE_OBSERVATION: f64 = 0.0;
147
148/// Statistics update type for recording peer interaction outcomes
149#[derive(Debug, Clone, Copy)]
150pub enum NodeStatisticsUpdate {
151    /// Peer provided a correct response
152    CorrectResponse,
153    /// Peer failed to provide a response
154    FailedResponse,
155}
156
157impl NodeStatisticsUpdate {
158    const fn observation(self) -> f64 {
159        match self {
160            Self::CorrectResponse => SUCCESS_OBSERVATION,
161            Self::FailedResponse => FAILURE_OBSERVATION,
162        }
163    }
164
165    const fn as_str(self) -> &'static str {
166        match self {
167            Self::CorrectResponse => "correct_response",
168            Self::FailedResponse => "failed_response",
169        }
170    }
171}
172
173/// Serializable trust snapshot for persistence across restarts.
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct TrustSnapshot {
176    /// Peer trust scores with timestamps.
177    /// The timestamp is seconds since UNIX epoch when the score was last updated.
178    pub peers: HashMap<PeerId, TrustRecord>,
179}
180
181/// A single peer's trust record for serialization.
182#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct TrustRecord {
184    /// Trust score [0.0, 1.0]
185    pub score: f64,
186    /// When the score was last updated (seconds since UNIX epoch)
187    pub last_updated_epoch_secs: u64,
188}
189
190/// Local trust engine based on direct peer observations.
191///
192/// Scores are an exponential moving average of success/failure observations
193/// that decays toward neutral (0.5) when idle. Bounded by `MIN_TRUST_SCORE`
194/// and `MAX_TRUST_SCORE`.
195///
196/// This is the **sole authority** on peer trust scores in the system.
197#[derive(Debug)]
198pub struct TrustEngine {
199    /// Per-node trust state
200    peers: Arc<RwLock<HashMap<PeerId, PeerTrust>>>,
201}
202
203impl TrustEngine {
204    /// Create a new TrustEngine
205    pub fn new() -> Self {
206        Self {
207            peers: Arc::new(RwLock::new(HashMap::new())),
208        }
209    }
210
211    /// Record a peer interaction outcome
212    pub fn update_node_stats(&self, node_id: &PeerId, update: NodeStatisticsUpdate) {
213        self.update_node_stats_weighted(node_id, update, 1.0);
214    }
215
216    /// Record a peer interaction outcome with an internal reason label.
217    pub(crate) fn update_node_stats_with_reason(
218        &self,
219        node_id: &PeerId,
220        update: NodeStatisticsUpdate,
221        reason: &'static str,
222    ) {
223        self.update_node_stats_weighted_with_reason(node_id, update, 1.0, reason);
224    }
225
226    /// Record a peer interaction outcome with an explicit weight.
227    ///
228    /// Weight `1.0` is equivalent to a single internal event. Higher weights
229    /// amplify the observation's influence on the EMA. The caller is responsible
230    /// for validating/clamping the weight before calling this method.
231    pub fn update_node_stats_weighted(
232        &self,
233        node_id: &PeerId,
234        update: NodeStatisticsUpdate,
235        weight: f64,
236    ) {
237        self.update_node_stats_weighted_with_reason(node_id, update, weight, update.as_str());
238    }
239
240    /// Record a weighted peer interaction outcome with an internal reason label.
241    pub(crate) fn update_node_stats_weighted_with_reason(
242        &self,
243        node_id: &PeerId,
244        update: NodeStatisticsUpdate,
245        weight: f64,
246        reason: &'static str,
247    ) {
248        let score_change = {
249            let mut peers = self.peers.write();
250            let entry = peers.entry(*node_id).or_insert_with(PeerTrust::new);
251
252            entry.record_weighted(update.observation(), weight)
253        };
254
255        if let Some((previous_score, current_score)) = score_change {
256            info!(
257                peer_id = %node_id.to_hex(),
258                reason = %reason,
259                update = %update.as_str(),
260                previous_score,
261                current_score,
262                delta = current_score - previous_score,
263                weight,
264                "peer trust score changed"
265            );
266        }
267    }
268
269    /// Get current trust score for a peer (synchronous).
270    ///
271    /// Applies time decay lazily — no background task needed.
272    /// Returns `DEFAULT_NEUTRAL_TRUST` (0.5) for unknown peers.
273    ///
274    /// Uses `parking_lot::RwLock` so this never falls back to a stale
275    /// neutral value during write contention — it briefly blocks until
276    /// the writer releases.
277    pub fn score(&self, node_id: &PeerId) -> f64 {
278        let peers = self.peers.read();
279        peers
280            .get(node_id)
281            .map(|p| p.decayed_score())
282            .unwrap_or(DEFAULT_NEUTRAL_TRUST)
283    }
284
285    /// Remove a peer from the trust system entirely
286    pub fn remove_node(&self, node_id: &PeerId) {
287        let mut peers = self.peers.write();
288        peers.remove(node_id);
289    }
290
291    /// Export current trust state as a serializable snapshot.
292    ///
293    /// Applies decay to all scores before exporting so the snapshot
294    /// reflects the current effective scores.
295    pub fn export_snapshot(&self) -> TrustSnapshot {
296        let peers_guard = self.peers.read();
297        let now_epoch = SystemTime::now()
298            .duration_since(UNIX_EPOCH)
299            .map(|d| d.as_secs())
300            .unwrap_or(0);
301
302        let peers = peers_guard
303            .iter()
304            .map(|(peer_id, peer_trust)| {
305                let record = TrustRecord {
306                    score: peer_trust.decayed_score(),
307                    last_updated_epoch_secs: now_epoch,
308                };
309                (*peer_id, record)
310            })
311            .collect();
312
313        TrustSnapshot { peers }
314    }
315
316    /// Import trust state from a persisted snapshot.
317    ///
318    /// Scores are restored as-is with `last_updated` set to now.  Decay does
319    /// not run while our node is offline — we can't observe peer behavior
320    /// during downtime, so penalising peers for our absence would be wrong.
321    /// Decay resumes naturally from the moment the node restarts.
322    pub fn import_snapshot(&self, snapshot: &TrustSnapshot) {
323        let mut peers_guard = self.peers.write();
324
325        for (peer_id, record) in &snapshot.peers {
326            // Guard against NaN/Infinity from corrupted or malicious snapshots —
327            // non-finite values would propagate through all EMA/decay calculations.
328            let score = if record.score.is_finite() {
329                record.score.clamp(MIN_TRUST_SCORE, MAX_TRUST_SCORE)
330            } else {
331                DEFAULT_NEUTRAL_TRUST
332            };
333            let peer_trust = PeerTrust {
334                score,
335                last_updated: Instant::now(),
336            };
337            peers_guard.insert(*peer_id, peer_trust);
338        }
339    }
340
341    /// Simulate time passing for a peer (test only).
342    ///
343    /// Applies decay as if `elapsed` time had passed since the last update.
344    /// Uses `apply_decay_secs` directly to avoid `Instant` subtraction,
345    /// which panics on Windows when system uptime < `elapsed`.
346    #[cfg(test)]
347    pub async fn simulate_elapsed(&self, node_id: &PeerId, elapsed: std::time::Duration) {
348        let mut peers = self.peers.write();
349        if let Some(trust) = peers.get_mut(node_id) {
350            trust.apply_decay_secs(elapsed.as_secs_f64());
351        }
352    }
353}
354
355impl Default for TrustEngine {
356    fn default() -> Self {
357        Self::new()
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    #[tokio::test]
366    async fn test_unknown_peer_returns_neutral() {
367        let engine = TrustEngine::new();
368        let peer = PeerId::random();
369        assert!((engine.score(&peer) - DEFAULT_NEUTRAL_TRUST).abs() < f64::EPSILON);
370    }
371
372    #[tokio::test]
373    async fn test_successes_increase_score() {
374        let engine = TrustEngine::new();
375        let peer = PeerId::random();
376
377        for _ in 0..50 {
378            engine.update_node_stats(&peer, NodeStatisticsUpdate::CorrectResponse);
379        }
380
381        let score = engine.score(&peer);
382        assert!(
383            score > DEFAULT_NEUTRAL_TRUST,
384            "Score {score} should be above neutral"
385        );
386        assert!(score <= MAX_TRUST_SCORE, "Score {score} should be <= max");
387    }
388
389    #[tokio::test]
390    async fn test_failures_decrease_score() {
391        let engine = TrustEngine::new();
392        let peer = PeerId::random();
393
394        for _ in 0..50 {
395            engine.update_node_stats(&peer, NodeStatisticsUpdate::FailedResponse);
396        }
397
398        let score = engine.score(&peer);
399        assert!(
400            score < DEFAULT_NEUTRAL_TRUST,
401            "Score {score} should be below neutral"
402        );
403        assert!(score >= MIN_TRUST_SCORE, "Score {score} should be >= min");
404    }
405
406    #[tokio::test]
407    async fn test_scores_clamped_to_bounds() {
408        let engine = TrustEngine::new();
409        let peer = PeerId::random();
410
411        // Many successes — should not exceed MAX
412        for _ in 0..1000 {
413            engine.update_node_stats(&peer, NodeStatisticsUpdate::CorrectResponse);
414        }
415        let score = engine.score(&peer);
416        assert!(score >= MIN_TRUST_SCORE, "Score {score} below min");
417        assert!(score <= MAX_TRUST_SCORE, "Score {score} above max");
418
419        // Many failures — should not go below MIN
420        for _ in 0..2000 {
421            engine.update_node_stats(&peer, NodeStatisticsUpdate::FailedResponse);
422        }
423        let score = engine.score(&peer);
424        assert!(score >= MIN_TRUST_SCORE, "Score {score} below min");
425        assert!(score <= MAX_TRUST_SCORE, "Score {score} above max");
426    }
427
428    #[tokio::test]
429    async fn test_remove_node_resets_to_neutral() {
430        let engine = TrustEngine::new();
431        let peer = PeerId::random();
432
433        engine.update_node_stats(&peer, NodeStatisticsUpdate::FailedResponse);
434        assert!(engine.score(&peer) < DEFAULT_NEUTRAL_TRUST);
435
436        engine.remove_node(&peer);
437        assert!((engine.score(&peer) - DEFAULT_NEUTRAL_TRUST).abs() < f64::EPSILON);
438    }
439
440    #[tokio::test]
441    async fn test_ema_blends_observations() {
442        let engine = TrustEngine::new();
443        let peer = PeerId::random();
444
445        // First failure moves score below neutral
446        engine.update_node_stats(&peer, NodeStatisticsUpdate::FailedResponse);
447        let after_fail = engine.score(&peer);
448        assert!(after_fail < DEFAULT_NEUTRAL_TRUST);
449
450        // A success moves it back up (but not all the way to neutral)
451        engine.update_node_stats(&peer, NodeStatisticsUpdate::CorrectResponse);
452        let after_success = engine.score(&peer);
453        assert!(after_success > after_fail, "Success should increase score");
454    }
455
456    /// 1 day of idle time from worst score (0.0) should cross the swap threshold (0.35).
457    ///
458    /// Uses the pure `decay_score` function to avoid `Instant` subtraction,
459    /// which panics on Windows if system uptime < the simulated duration.
460    #[test]
461    fn test_worst_score_recovers_after_1_day() {
462        let one_day_secs = (24 * 3600) as f64;
463        let score = PeerTrust::decay_score(MIN_TRUST_SCORE, one_day_secs);
464
465        assert!(
466            score >= 0.35,
467            "After 1 day, score {score} should be >= swap threshold 0.35",
468        );
469    }
470
471    /// 22 hours should NOT be enough to recover from worst score
472    #[test]
473    fn test_worst_score_still_below_threshold_before_1_day() {
474        let twenty_two_hours = (22 * 3600) as f64;
475        let score = PeerTrust::decay_score(MIN_TRUST_SCORE, twenty_two_hours);
476
477        assert!(
478            score < 0.35,
479            "Before 1 day, score {score} should still be < swap threshold 0.35",
480        );
481    }
482
483    #[test]
484    fn test_decay_from_high_score_moves_down() {
485        let one_week_secs = (7 * 24 * 3600) as f64;
486        let score = PeerTrust::decay_score(0.95, one_week_secs);
487
488        assert!(score < 0.95, "Score should have decayed from 0.95");
489        assert!(
490            score > DEFAULT_NEUTRAL_TRUST,
491            "Score should still be above neutral after 1 week"
492        );
493    }
494
495    #[test]
496    fn test_decay_from_low_score_moves_up() {
497        let one_week_secs = (7 * 24 * 3600) as f64;
498        let score = PeerTrust::decay_score(0.1, one_week_secs);
499
500        assert!(score > 0.1, "Low score should decay upward toward neutral");
501    }
502
503    #[tokio::test]
504    async fn test_export_import_roundtrip() {
505        let engine = TrustEngine::new();
506        let peer1 = PeerId::random();
507        let peer2 = PeerId::random();
508
509        // Build up some trust
510        for _ in 0..20 {
511            engine.update_node_stats(&peer1, NodeStatisticsUpdate::CorrectResponse);
512        }
513        for _ in 0..10 {
514            engine.update_node_stats(&peer2, NodeStatisticsUpdate::FailedResponse);
515        }
516
517        let score1_before = engine.score(&peer1);
518        let score2_before = engine.score(&peer2);
519
520        // Export
521        let snapshot = engine.export_snapshot();
522        assert_eq!(snapshot.peers.len(), 2);
523
524        // Import into fresh engine
525        let engine2 = TrustEngine::new();
526        engine2.import_snapshot(&snapshot);
527
528        let score1_after = engine2.score(&peer1);
529        let score2_after = engine2.score(&peer2);
530
531        // Scores should be approximately equal (small time drift from test execution)
532        assert!(
533            (score1_before - score1_after).abs() < 0.01,
534            "peer1 score drifted: before={score1_before}, after={score1_after}"
535        );
536        assert!(
537            (score2_before - score2_after).abs() < 0.01,
538            "peer2 score drifted: before={score2_before}, after={score2_after}"
539        );
540    }
541
542    #[tokio::test]
543    async fn test_import_preserves_scores_without_decay() {
544        // Create a snapshot with a timestamp 1 day in the past.
545        // Scores should be restored as-is — no decay for offline time.
546        let peer = PeerId::random();
547        let one_day_secs: u64 = 86_400;
548        let one_day_ago = SystemTime::now()
549            .duration_since(UNIX_EPOCH)
550            .unwrap()
551            .as_secs()
552            - one_day_secs;
553
554        let snapshot = TrustSnapshot {
555            peers: HashMap::from([(
556                peer,
557                TrustRecord {
558                    score: 0.9,
559                    last_updated_epoch_secs: one_day_ago,
560                },
561            )]),
562        };
563
564        let engine = TrustEngine::new();
565        engine.import_snapshot(&snapshot);
566
567        let score = engine.score(&peer);
568        // Score should be restored at 0.9 — offline time doesn't decay
569        assert!(
570            (score - 0.9).abs() < 0.01,
571            "Score {score} should be ~0.9 (no offline decay)"
572        );
573    }
574
575    #[tokio::test]
576    async fn test_import_nan_score_falls_back_to_neutral() {
577        let peer = PeerId::random();
578        let snapshot = TrustSnapshot {
579            peers: HashMap::from([(
580                peer,
581                TrustRecord {
582                    score: f64::NAN,
583                    last_updated_epoch_secs: 1_000_000,
584                },
585            )]),
586        };
587
588        let engine = TrustEngine::new();
589        engine.import_snapshot(&snapshot);
590
591        let score = engine.score(&peer);
592        assert!(
593            score.is_finite(),
594            "NaN score should have been replaced with a finite value"
595        );
596        assert!(
597            (score - DEFAULT_NEUTRAL_TRUST).abs() < f64::EPSILON,
598            "NaN score should fall back to neutral, got {score}"
599        );
600    }
601
602    #[tokio::test]
603    async fn test_import_infinity_score_falls_back_to_neutral() {
604        let peer = PeerId::random();
605        let snapshot = TrustSnapshot {
606            peers: HashMap::from([(
607                peer,
608                TrustRecord {
609                    score: f64::INFINITY,
610                    last_updated_epoch_secs: 1_000_000,
611                },
612            )]),
613        };
614
615        let engine = TrustEngine::new();
616        engine.import_snapshot(&snapshot);
617
618        let score = engine.score(&peer);
619        assert!(
620            score.is_finite(),
621            "Infinity score should have been replaced with a finite value"
622        );
623        assert!(
624            (score - DEFAULT_NEUTRAL_TRUST).abs() < f64::EPSILON,
625            "Infinity score should fall back to neutral, got {score}"
626        );
627    }
628
629    /// Test: negative weights are rejected and do not corrupt the trust score.
630    ///
631    /// The `record_weighted` guard (`weight <= 0.0`) prevents negative weights
632    /// from reversing the observation direction. This test confirms that
633    /// calling `update_node_stats_weighted` with a negative weight is a no-op.
634    #[tokio::test]
635    async fn test_negative_weight_is_noop() {
636        let engine = TrustEngine::new();
637        let peer = PeerId::random();
638
639        let before = engine.score(&peer);
640
641        // Attempt a failure with negative weight — should be rejected
642        engine.update_node_stats_weighted(&peer, NodeStatisticsUpdate::FailedResponse, -5.0);
643        let after_negative = engine.score(&peer);
644        assert!(
645            (before - after_negative).abs() < f64::EPSILON,
646            "negative weight should be a no-op: before={before}, after={after_negative}"
647        );
648
649        // Attempt a success with negative weight — also a no-op
650        engine.update_node_stats_weighted(&peer, NodeStatisticsUpdate::CorrectResponse, -1.0);
651        let after_negative_success = engine.score(&peer);
652        assert!(
653            (before - after_negative_success).abs() < f64::EPSILON,
654            "negative weight success should be a no-op: before={before}, after={after_negative_success}"
655        );
656
657        // Confirm normal weight still works after negative attempts
658        engine.update_node_stats_weighted(&peer, NodeStatisticsUpdate::FailedResponse, 1.0);
659        let after_valid = engine.score(&peer);
660        assert!(
661            after_valid < before,
662            "valid weight-1 failure should reduce score: before={before}, after={after_valid}"
663        );
664    }
665
666    /// Test: weighted EMA has larger impact than unit weight
667    #[tokio::test]
668    async fn test_weighted_ema_larger_impact() {
669        let engine = TrustEngine::new();
670        let peer_a = PeerId::random();
671        let peer_b = PeerId::random();
672
673        // Unit-weight failure for peer A
674        engine.update_node_stats_weighted(&peer_a, NodeStatisticsUpdate::FailedResponse, 1.0);
675        let score_a = engine.score(&peer_a);
676
677        // Weight-5 failure for peer B
678        engine.update_node_stats_weighted(&peer_b, NodeStatisticsUpdate::FailedResponse, 5.0);
679        let score_b = engine.score(&peer_b);
680
681        assert!(
682            score_b < score_a,
683            "weight-5 failure ({score_b}) should produce lower score than weight-1 ({score_a})"
684        );
685    }
686
687    /// Test: weight-1 weighted path is equivalent to the original unit-weight path
688    #[tokio::test]
689    async fn test_unit_weight_equivalence() {
690        let engine1 = TrustEngine::new();
691        let engine2 = TrustEngine::new();
692        let peer = PeerId::random();
693
694        engine1.update_node_stats(&peer, NodeStatisticsUpdate::FailedResponse);
695        engine2.update_node_stats_weighted(&peer, NodeStatisticsUpdate::FailedResponse, 1.0);
696
697        let diff = (engine1.score(&peer) - engine2.score(&peer)).abs();
698        assert!(
699            diff < 1e-10,
700            "unit-weight paths should be equivalent, diff={diff}"
701        );
702    }
703
704    // =======================================================================
705    // Phase 8: Integration test matrix — missing coverage
706    // =======================================================================
707
708    // -----------------------------------------------------------------------
709    // Test 54: Consumer penalty degrades trust below swap threshold
710    // -----------------------------------------------------------------------
711
712    /// Repeated high-weight failures should push a peer's trust score below
713    /// the swap threshold (0.35), making it eligible for swap-out.
714    #[tokio::test]
715    async fn test_consumer_penalty_degrades_below_swap_threshold() {
716        /// Swap threshold matching the value in adaptive/dht.rs
717        const SWAP_THRESHOLD: f64 = 0.35;
718
719        let engine = TrustEngine::new();
720        let peer = PeerId::random();
721
722        // Repeated weight-3 failures from neutral (0.5) should push well below 0.35.
723        let failure_count = 10;
724        for _ in 0..failure_count {
725            engine.update_node_stats_weighted(&peer, NodeStatisticsUpdate::FailedResponse, 3.0);
726        }
727
728        let score = engine.score(&peer);
729        assert!(
730            score < SWAP_THRESHOLD,
731            "after {failure_count} weight-3 failures, score {score} should be below swap threshold {SWAP_THRESHOLD}"
732        );
733    }
734
735    // -----------------------------------------------------------------------
736    // Test 58: Consumer and internal events combine in same EMA
737    // -----------------------------------------------------------------------
738
739    /// Internal (weight-1) and consumer-reported (weight-3) events feed the
740    /// same EMA. A heavier failure should outweigh a lighter success.
741    #[tokio::test]
742    async fn test_consumer_and_internal_events_combine() {
743        let engine = TrustEngine::new();
744        let peer = PeerId::random();
745
746        // Internal success (unit weight)
747        engine.update_node_stats(&peer, NodeStatisticsUpdate::CorrectResponse);
748        let after_success = engine.score(&peer);
749        assert!(
750            after_success > DEFAULT_NEUTRAL_TRUST,
751            "single success should raise above neutral"
752        );
753
754        // Consumer failure with weight 3 — should outweigh the single success
755        engine.update_node_stats_weighted(&peer, NodeStatisticsUpdate::FailedResponse, 3.0);
756        let after_failure = engine.score(&peer);
757
758        assert!(
759            after_failure < after_success,
760            "weight-3 failure ({after_failure}) should outweigh weight-1 success ({after_success})"
761        );
762        assert!(
763            after_failure < DEFAULT_NEUTRAL_TRUST,
764            "net effect ({after_failure}) should be below neutral ({DEFAULT_NEUTRAL_TRUST})"
765        );
766    }
767
768    // -----------------------------------------------------------------------
769    // Test 59: Consumer trust query reflects all event sources
770    // -----------------------------------------------------------------------
771
772    /// `score()` returns a single EMA value shaped by a mix of internal and
773    /// consumer-reported events — there is no separate "consumer score."
774    #[tokio::test]
775    async fn test_trust_query_reflects_all_event_sources() {
776        let engine = TrustEngine::new();
777        let peer = PeerId::random();
778
779        // Mix of internal and consumer events
780        engine.update_node_stats(&peer, NodeStatisticsUpdate::CorrectResponse);
781        engine.update_node_stats_weighted(&peer, NodeStatisticsUpdate::CorrectResponse, 2.0);
782        engine.update_node_stats(&peer, NodeStatisticsUpdate::FailedResponse);
783
784        // Score should reflect the combined influence, not just internal events.
785        let score = engine.score(&peer);
786        // With 1 unit-success + 1 weight-2-success + 1 unit-failure, the net
787        // effect is positive (3 success-units vs 1 failure-unit).
788        assert!(
789            score > DEFAULT_NEUTRAL_TRUST,
790            "combined score {score} should be above neutral (net positive events)"
791        );
792    }
793
794    // -----------------------------------------------------------------------
795    // Test 63: Time decay applies to consumer events
796    // -----------------------------------------------------------------------
797
798    /// Consumer-reported events are subject to the same time decay as internal
799    /// events. After enough idle time, the score should decay back toward
800    /// neutral (0.5).
801    #[tokio::test]
802    async fn test_time_decay_applies_to_consumer_events() {
803        let engine = TrustEngine::new();
804        let peer = PeerId::random();
805
806        // Apply a consumer failure with weight 3
807        engine.update_node_stats_weighted(&peer, NodeStatisticsUpdate::FailedResponse, 3.0);
808        let after_failure = engine.score(&peer);
809        assert!(
810            after_failure < DEFAULT_NEUTRAL_TRUST,
811            "after failure, score {after_failure} should be below neutral"
812        );
813
814        // Simulate 2 days of idle time
815        let two_days = std::time::Duration::from_secs(2 * 24 * 3600);
816        engine.simulate_elapsed(&peer, two_days).await;
817
818        let after_decay = engine.score(&peer);
819        assert!(
820            after_decay > after_failure,
821            "score should decay toward neutral: {after_failure} -> {after_decay}"
822        );
823        // After 2 days from a heavy failure, the score should be closer to neutral.
824        let distance_from_neutral = (after_decay - DEFAULT_NEUTRAL_TRUST).abs();
825        assert!(
826            distance_from_neutral < 0.2,
827            "after 2 days, score {after_decay} should be near neutral (distance {distance_from_neutral})"
828        );
829    }
830
831    // -----------------------------------------------------------------------
832    // Test 57: Consumer rewards restore trust protection
833    // -----------------------------------------------------------------------
834
835    /// A peer with trust below TRUST_PROTECTION_THRESHOLD (0.7) can be
836    /// restored above that threshold by enough consumer success events.
837    #[tokio::test]
838    async fn test_consumer_rewards_restore_trust_protection() {
839        /// Trust protection threshold from core_engine.rs
840        const TRUST_PROTECTION_THRESHOLD: f64 = 0.7;
841
842        let engine = TrustEngine::new();
843        let peer = PeerId::random();
844
845        // Start below trust protection with some failures
846        for _ in 0..5 {
847            engine.update_node_stats(&peer, NodeStatisticsUpdate::FailedResponse);
848        }
849        let low_score = engine.score(&peer);
850        assert!(
851            low_score < TRUST_PROTECTION_THRESHOLD,
852            "peer should start below trust protection: {low_score}"
853        );
854
855        // Consumer-reported successes with weight 3 should lift the score
856        let success_rounds = 30;
857        for _ in 0..success_rounds {
858            engine.update_node_stats_weighted(&peer, NodeStatisticsUpdate::CorrectResponse, 3.0);
859        }
860        let restored_score = engine.score(&peer);
861        assert!(
862            restored_score >= TRUST_PROTECTION_THRESHOLD,
863            "after {success_rounds} weight-3 successes, score {restored_score} should be >= {TRUST_PROTECTION_THRESHOLD}"
864        );
865    }
866}