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