Skip to main content

saorsa_core/adaptive/
dht.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//! AdaptiveDHT — the trust boundary for all DHT operations.
14//!
15//! `AdaptiveDHT` is the **sole component** that creates and owns the [`TrustEngine`].
16//! All DHT operations flow through it, and all trust signals originate from it.
17//!
18//! Internal DHT operations (iterative lookups) record trust via the `TrustEngine`
19//! reference passed to `DhtNetworkManager`. External callers report additional
20//! trust signals through [`AdaptiveDHT::report_trust_event`].
21
22use crate::adaptive::trust::{NodeStatisticsUpdate, TrustEngine};
23use crate::dht::core_engine::AddressType;
24use crate::dht_network_manager::{DhtNetworkConfig, DhtNetworkManager};
25use crate::{MultiAddr, PeerId};
26
27use crate::error::P2pResult as Result;
28use serde::{Deserialize, Serialize};
29use std::sync::Arc;
30
31/// Default trust score threshold below which a peer is eligible for swap-out
32const DEFAULT_SWAP_THRESHOLD: f64 = 0.35;
33
34/// Maximum weight multiplier per single consumer-reported event.
35/// Caps the influence of any single consumer event on the EMA.
36const MAX_CONSUMER_WEIGHT: f64 = 5.0;
37
38/// Configuration for the AdaptiveDHT layer
39#[derive(Debug, Clone, Serialize, Deserialize)]
40#[serde(default)]
41pub struct AdaptiveDhtConfig {
42    /// Trust score below which a peer becomes eligible for swap-out from
43    /// the routing table when a better candidate is available.
44    /// Peers are NOT immediately evicted.
45    /// Default: 0.35
46    pub swap_threshold: f64,
47}
48
49impl Default for AdaptiveDhtConfig {
50    fn default() -> Self {
51        Self {
52            swap_threshold: DEFAULT_SWAP_THRESHOLD,
53        }
54    }
55}
56
57impl AdaptiveDhtConfig {
58    /// Validate that all config values are within acceptable ranges.
59    ///
60    /// Returns `Err` if `swap_threshold` is outside `[0.0, 0.5)` or is NaN.
61    /// Values >= 0.5 (neutral trust) would make all unknown peers immediately
62    /// swap-eligible since they start at neutral (0.5).
63    pub fn validate(&self) -> crate::error::P2pResult<()> {
64        if !(0.0..0.5).contains(&self.swap_threshold) || self.swap_threshold.is_nan() {
65            return Err(crate::error::P2PError::Validation(
66                format!(
67                    "swap_threshold must be in [0.0, 0.5), got {}",
68                    self.swap_threshold
69                )
70                .into(),
71            ));
72        }
73        Ok(())
74    }
75}
76
77/// Trust-relevant events for peer scoring.
78///
79/// Core only records **penalties** — successful responses are the expected
80/// baseline and do not warrant a reward.  Positive trust signals are the
81/// consumer's responsibility via [`ApplicationSuccess`](Self::ApplicationSuccess).
82///
83/// Consumer-reported events carry a weight multiplier that controls the
84/// severity of the update (clamped to `MAX_CONSUMER_WEIGHT`).
85#[derive(Debug, Clone, Copy, PartialEq)]
86pub enum TrustEvent {
87    // === Negative signals (core) ===
88    /// Could not establish a connection to the peer
89    ConnectionFailed,
90    /// Connection attempt timed out
91    ConnectionTimeout,
92
93    // === Consumer-reported signals ===
94    /// Consumer-reported: peer completed an application-level task successfully.
95    /// Weight controls severity (clamped to MAX_CONSUMER_WEIGHT).
96    ApplicationSuccess(f64),
97    /// Consumer-reported: peer failed an application-level task.
98    /// Weight controls severity (clamped to MAX_CONSUMER_WEIGHT).
99    ApplicationFailure(f64),
100}
101
102impl TrustEvent {
103    /// Convert a TrustEvent to the internal NodeStatisticsUpdate
104    fn to_stats_update(self) -> NodeStatisticsUpdate {
105        match self {
106            TrustEvent::ApplicationSuccess(_) => NodeStatisticsUpdate::CorrectResponse,
107            TrustEvent::ConnectionFailed
108            | TrustEvent::ConnectionTimeout
109            | TrustEvent::ApplicationFailure(_) => NodeStatisticsUpdate::FailedResponse,
110        }
111    }
112
113    /// Stable reason label used in trust-score change logs.
114    const fn reason_label(self) -> &'static str {
115        match self {
116            TrustEvent::ConnectionFailed => "connection_failed",
117            TrustEvent::ConnectionTimeout => "connection_timeout",
118            TrustEvent::ApplicationSuccess(_) => "application_success",
119            TrustEvent::ApplicationFailure(_) => "application_failure",
120        }
121    }
122}
123
124/// AdaptiveDHT — the trust boundary for all DHT operations.
125///
126/// Owns the `TrustEngine` and `DhtNetworkManager`. All DHT operations
127/// should go through this component. Application-level trust signals
128/// are reported via [`report_trust_event`](Self::report_trust_event).
129pub struct AdaptiveDHT {
130    /// The underlying DHT network manager (handles raw DHT operations)
131    dht_manager: Arc<DhtNetworkManager>,
132
133    /// The trust engine — sole authority on peer trust scores
134    trust_engine: Arc<TrustEngine>,
135
136    /// Configuration for trust-weighted behavior
137    config: AdaptiveDhtConfig,
138}
139
140impl AdaptiveDHT {
141    /// Create a new AdaptiveDHT instance.
142    ///
143    /// This creates the `TrustEngine` and the `DhtNetworkManager` with the
144    /// trust engine injected. Call [`start`](Self::start) to begin DHT
145    /// operations. Trust scores are computed live — low-trust peers are
146    /// swapped out when better candidates arrive.
147    ///
148    /// # Errors
149    ///
150    /// Returns an error if `swap_threshold` is not in `[0.0, 0.5)` or if
151    /// the underlying `DhtNetworkManager` fails to initialise.
152    pub async fn new(
153        transport: Arc<crate::transport_handle::TransportHandle>,
154        mut dht_config: DhtNetworkConfig,
155        adaptive_config: AdaptiveDhtConfig,
156    ) -> Result<Self> {
157        adaptive_config.validate()?;
158
159        dht_config.swap_threshold = adaptive_config.swap_threshold;
160
161        let trust_engine = Arc::new(TrustEngine::new());
162
163        let dht_manager = Arc::new(
164            DhtNetworkManager::new(transport, Some(trust_engine.clone()), dht_config).await?,
165        );
166
167        Ok(Self {
168            dht_manager,
169            trust_engine,
170            config: adaptive_config,
171        })
172    }
173
174    // =========================================================================
175    // Trust API — the only place where external callers record trust events
176    // =========================================================================
177
178    /// Report a trust event for a peer.
179    ///
180    /// For core penalty events (connection failure/timeout), applies unit weight.
181    /// For consumer-reported events ([`TrustEvent::ApplicationSuccess`] /
182    /// [`TrustEvent::ApplicationFailure`]), validates and clamps the weight
183    /// to [`MAX_CONSUMER_WEIGHT`]. Zero or negative weights are silently
184    /// ignored (no-op).
185    ///
186    /// Trust scores are updated immediately but low-trust peers are not
187    /// evicted — they remain in the routing table until a better candidate
188    /// arrives and triggers a swap-out.
189    pub async fn report_trust_event(&self, peer_id: &PeerId, event: TrustEvent) {
190        match event {
191            TrustEvent::ApplicationSuccess(weight) | TrustEvent::ApplicationFailure(weight) => {
192                if weight > 0.0 {
193                    let clamped_weight = weight.min(MAX_CONSUMER_WEIGHT);
194                    self.trust_engine.update_node_stats_weighted_with_reason(
195                        peer_id,
196                        event.to_stats_update(),
197                        clamped_weight,
198                        event.reason_label(),
199                    );
200                }
201            }
202            _ => {
203                // Internal events: unit weight
204                self.trust_engine.update_node_stats_with_reason(
205                    peer_id,
206                    event.to_stats_update(),
207                    event.reason_label(),
208                );
209            }
210        }
211    }
212
213    /// Get the current trust score for a peer (synchronous).
214    ///
215    /// Returns `DEFAULT_NEUTRAL_TRUST` (0.5) for unknown peers.
216    pub fn peer_trust(&self, peer_id: &PeerId) -> f64 {
217        self.trust_engine.score(peer_id)
218    }
219
220    /// Get a reference to the underlying trust engine for advanced use cases.
221    pub fn trust_engine(&self) -> &Arc<TrustEngine> {
222        &self.trust_engine
223    }
224
225    /// Get the adaptive DHT configuration.
226    pub fn config(&self) -> &AdaptiveDhtConfig {
227        &self.config
228    }
229
230    // =========================================================================
231    // DHT operations — delegates to DhtNetworkManager
232    // =========================================================================
233
234    /// Get the underlying DHT network manager.
235    ///
236    /// All DHT operations are accessible through this reference.
237    /// The DHT manager records trust internally for per-peer outcomes
238    /// during iterative lookups.
239    pub fn dht_manager(&self) -> &Arc<DhtNetworkManager> {
240        &self.dht_manager
241    }
242
243    /// Start the DHT manager.
244    ///
245    /// Trust scores are computed live — no background tasks needed.
246    /// Low-trust peers are swapped out when better candidates arrive.
247    pub async fn start(&self) -> Result<()> {
248        Arc::clone(&self.dht_manager).start().await
249    }
250
251    /// Stop the DHT manager gracefully.
252    pub async fn stop(&self) -> Result<()> {
253        self.dht_manager.stop().await
254    }
255
256    /// Trigger an immediate self-lookup to refresh the close neighborhood.
257    ///
258    /// Delegates to [`DhtNetworkManager::trigger_self_lookup`] which performs
259    /// an iterative FIND_NODE for this node's own key.
260    pub async fn trigger_self_lookup(&self) -> Result<()> {
261        self.dht_manager.trigger_self_lookup().await
262    }
263
264    /// Look up connectable typed addresses for a peer.
265    ///
266    /// Checks the DHT routing table first, then falls back to the
267    /// transport layer. Returns an empty vec when the peer is unknown
268    /// or has no dialable addresses. The per-address [`AddressType`]
269    /// tag is preserved so the dial path can log the kind on
270    /// success/failure.
271    pub(crate) async fn peer_addresses_for_dial_typed(
272        &self,
273        peer_id: &PeerId,
274    ) -> Vec<(MultiAddr, AddressType)> {
275        self.dht_manager
276            .peer_addresses_for_dial_typed(peer_id)
277            .await
278    }
279
280    /// Ensure the shared DHT dial coordinator has an authenticated channel.
281    ///
282    /// Keeping application reconnects on the same path as iterative lookups
283    /// means both callers share address-failure suppression and never create
284    /// independent retry loops against a known-bad relay.
285    pub(crate) async fn ensure_peer_channel(
286        &self,
287        peer_id: &PeerId,
288        candidates: &[(MultiAddr, AddressType)],
289    ) -> Result<()> {
290        self.dht_manager
291            .ensure_peer_channel(peer_id, candidates)
292            .await
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use crate::adaptive::trust::DEFAULT_NEUTRAL_TRUST;
300
301    #[test]
302    fn test_trust_event_mapping() {
303        // Consumer success maps to CorrectResponse
304        assert!(matches!(
305            TrustEvent::ApplicationSuccess(1.0).to_stats_update(),
306            NodeStatisticsUpdate::CorrectResponse
307        ));
308
309        // Penalty events map to FailedResponse
310        assert!(matches!(
311            TrustEvent::ConnectionFailed.to_stats_update(),
312            NodeStatisticsUpdate::FailedResponse
313        ));
314        assert!(matches!(
315            TrustEvent::ConnectionTimeout.to_stats_update(),
316            NodeStatisticsUpdate::FailedResponse
317        ));
318        assert!(matches!(
319            TrustEvent::ApplicationFailure(1.0).to_stats_update(),
320            NodeStatisticsUpdate::FailedResponse
321        ));
322    }
323
324    #[test]
325    fn test_adaptive_dht_config_defaults() {
326        let config = AdaptiveDhtConfig::default();
327        assert!((config.swap_threshold - DEFAULT_SWAP_THRESHOLD).abs() < f64::EPSILON);
328    }
329
330    #[test]
331    fn test_swap_threshold_validation_rejects_invalid() {
332        // Values outside [0.0, 0.5) or non-finite should be rejected.
333        // 0.5 would block all unknown peers (they start at neutral 0.5).
334        for &bad in &[
335            -0.1,
336            0.5,
337            1.0,
338            1.1,
339            f64::NAN,
340            f64::INFINITY,
341            f64::NEG_INFINITY,
342        ] {
343            let config = AdaptiveDhtConfig {
344                swap_threshold: bad,
345            };
346            assert!(
347                config.validate().is_err(),
348                "swap_threshold {bad} should fail validation"
349            );
350        }
351    }
352
353    #[test]
354    fn test_swap_threshold_validation_accepts_valid() {
355        for &good in &[0.0, 0.15, 0.49] {
356            let config = AdaptiveDhtConfig {
357                swap_threshold: good,
358            };
359            assert!(
360                config.validate().is_ok(),
361                "swap_threshold {good} should pass validation"
362            );
363        }
364    }
365
366    // =========================================================================
367    // Integration tests: full trust signal flow
368    // =========================================================================
369
370    /// Test: trust events flow through to TrustEngine and change scores immediately
371    #[tokio::test]
372    async fn test_trust_events_affect_scores() {
373        let engine = Arc::new(TrustEngine::new());
374        let peer = PeerId::random();
375
376        // Unknown peer starts at neutral trust
377        assert!((engine.score(&peer) - DEFAULT_NEUTRAL_TRUST).abs() < f64::EPSILON);
378
379        // Record consumer successes — score should rise above neutral
380        for _ in 0..10 {
381            engine.update_node_stats(&peer, TrustEvent::ApplicationSuccess(1.0).to_stats_update());
382        }
383
384        assert!(engine.score(&peer) > DEFAULT_NEUTRAL_TRUST);
385    }
386
387    /// Test: failures reduce trust below swap threshold
388    #[tokio::test]
389    async fn test_failures_reduce_trust_below_swap_threshold() {
390        let engine = Arc::new(TrustEngine::new());
391        let bad_peer = PeerId::random();
392
393        // Record only failures — score should drop toward zero
394        for _ in 0..20 {
395            engine.update_node_stats(&bad_peer, TrustEvent::ConnectionFailed.to_stats_update());
396        }
397
398        let trust = engine.score(&bad_peer);
399        assert!(
400            trust < DEFAULT_SWAP_THRESHOLD,
401            "Bad peer trust {trust} should be below swap threshold {DEFAULT_SWAP_THRESHOLD}"
402        );
403    }
404
405    /// Test: TrustEngine scores are bounded 0.0-1.0
406    #[tokio::test]
407    async fn test_trust_scores_bounded() {
408        let engine = Arc::new(TrustEngine::new());
409        let peer = PeerId::random();
410
411        for _ in 0..100 {
412            engine.update_node_stats(&peer, NodeStatisticsUpdate::CorrectResponse);
413        }
414
415        let score = engine.score(&peer);
416        assert!(score >= 0.0, "Score must be >= 0.0, got {score}");
417        assert!(score <= 1.0, "Score must be <= 1.0, got {score}");
418    }
419
420    /// Test: all TrustEvent variants produce valid stats updates
421    #[test]
422    fn test_all_trust_events_produce_valid_updates() {
423        let events = [
424            TrustEvent::ConnectionFailed,
425            TrustEvent::ConnectionTimeout,
426            TrustEvent::ApplicationSuccess(1.0),
427            TrustEvent::ApplicationFailure(3.0),
428        ];
429
430        for event in events {
431            // Should not panic
432            let _update = event.to_stats_update();
433        }
434    }
435
436    // =========================================================================
437    // End-to-end: peer lifecycle from trusted to swap-eligible to recovered
438    // =========================================================================
439
440    /// Full lifecycle: good peer -> fails -> swap-eligible -> time passes -> recovered
441    #[tokio::test]
442    async fn test_peer_lifecycle_trust_and_recovery() {
443        let engine = TrustEngine::new();
444        let peer = PeerId::random();
445
446        // Phase 1: Peer starts at neutral
447        assert!(
448            engine.score(&peer) >= DEFAULT_SWAP_THRESHOLD,
449            "New peer should not be swap-eligible"
450        );
451
452        // Phase 2: Some successes — peer is trusted
453        for _ in 0..20 {
454            engine.update_node_stats(&peer, NodeStatisticsUpdate::CorrectResponse);
455        }
456        let good_score = engine.score(&peer);
457        assert!(
458            good_score > DEFAULT_NEUTRAL_TRUST,
459            "Trusted peer: {good_score}"
460        );
461
462        // Phase 3: Peer starts failing — score drops below swap threshold
463        for _ in 0..200 {
464            engine.update_node_stats(&peer, NodeStatisticsUpdate::FailedResponse);
465        }
466        let bad_score = engine.score(&peer);
467        assert!(
468            bad_score < DEFAULT_SWAP_THRESHOLD,
469            "After many failures, peer should be swap-eligible: {bad_score}"
470        );
471
472        // Phase 4: Time passes (1+ day) — score decays back toward neutral
473        let one_day = std::time::Duration::from_secs(24 * 3600);
474        engine.simulate_elapsed(&peer, one_day).await;
475        let recovered_score = engine.score(&peer);
476        assert!(
477            recovered_score >= DEFAULT_SWAP_THRESHOLD,
478            "After 1 day idle, peer should have recovered: {recovered_score}"
479        );
480    }
481
482    /// Verify the swap threshold separates eligible from ineligible peers
483    #[tokio::test]
484    async fn test_swap_threshold_is_binary() {
485        let engine = TrustEngine::new();
486        let threshold = DEFAULT_SWAP_THRESHOLD;
487
488        let peer_above = PeerId::random();
489        let peer_below = PeerId::random();
490
491        // Peer with some successes — above threshold
492        for _ in 0..5 {
493            engine.update_node_stats(&peer_above, NodeStatisticsUpdate::CorrectResponse);
494        }
495        assert!(
496            engine.score(&peer_above) >= threshold,
497            "Peer with successes should be above threshold"
498        );
499
500        // Peer with only failures — below threshold
501        for _ in 0..50 {
502            engine.update_node_stats(&peer_below, NodeStatisticsUpdate::FailedResponse);
503        }
504        assert!(
505            engine.score(&peer_below) < threshold,
506            "Peer with only failures should be below threshold"
507        );
508
509        // Unknown peer — at neutral, which is above threshold
510        let unknown = PeerId::random();
511        assert!(
512            engine.score(&unknown) >= threshold,
513            "Unknown peer at neutral should not be swap-eligible"
514        );
515    }
516
517    /// Verify that a single failure doesn't make a peer swap-eligible
518    #[tokio::test]
519    async fn test_single_failure_does_not_cross_swap_threshold() {
520        let engine = TrustEngine::new();
521        let peer = PeerId::random();
522
523        engine.update_node_stats(&peer, NodeStatisticsUpdate::FailedResponse);
524
525        // A single failure from neutral (0.5) should give ~0.44, still above 0.35
526        assert!(
527            engine.score(&peer) >= DEFAULT_SWAP_THRESHOLD,
528            "One failure from neutral should not cross swap threshold: {}",
529            engine.score(&peer)
530        );
531    }
532
533    /// Verify that a previously-trusted peer needs many failures to become swap-eligible
534    #[tokio::test]
535    async fn test_trusted_peer_resilient_to_occasional_failures() {
536        let engine = TrustEngine::new();
537        let peer = PeerId::random();
538
539        // Build up trust
540        for _ in 0..50 {
541            engine.update_node_stats(&peer, NodeStatisticsUpdate::CorrectResponse);
542        }
543        let trusted_score = engine.score(&peer);
544
545        // A few failures shouldn't cross the swap threshold
546        for _ in 0..3 {
547            engine.update_node_stats(&peer, NodeStatisticsUpdate::FailedResponse);
548        }
549
550        assert!(
551            engine.score(&peer) >= DEFAULT_SWAP_THRESHOLD,
552            "3 failures after 50 successes should not cross swap threshold: {}",
553            engine.score(&peer)
554        );
555        assert!(
556            engine.score(&peer) < trusted_score,
557            "Score should have decreased"
558        );
559    }
560
561    /// Verify removing a peer resets their state completely
562    #[tokio::test]
563    async fn test_removed_peer_starts_fresh() {
564        let engine = TrustEngine::new();
565        let peer = PeerId::random();
566
567        // Block the peer
568        for _ in 0..100 {
569            engine.update_node_stats(&peer, NodeStatisticsUpdate::FailedResponse);
570        }
571        assert!(engine.score(&peer) < DEFAULT_SWAP_THRESHOLD);
572
573        // Remove and check — should be back to neutral
574        engine.remove_node(&peer);
575        assert!(
576            (engine.score(&peer) - DEFAULT_NEUTRAL_TRUST).abs() < f64::EPSILON,
577            "Removed peer should return to neutral"
578        );
579    }
580
581    // =========================================================================
582    // Consumer trust event tests (Design Matrix 53, 60, 61, 62)
583    // =========================================================================
584
585    /// Test 53: consumer reward improves trust
586    #[tokio::test]
587    async fn test_consumer_reward_improves_trust() {
588        let engine = Arc::new(TrustEngine::new());
589        let peer = PeerId::random();
590
591        let before = engine.score(&peer);
592        engine.update_node_stats(&peer, TrustEvent::ApplicationSuccess(1.0).to_stats_update());
593        let after = engine.score(&peer);
594
595        assert!(
596            after > before,
597            "consumer reward should improve trust: {before} -> {after}"
598        );
599    }
600
601    /// Test 60: higher weight produces larger score impact
602    #[tokio::test]
603    async fn test_higher_weight_larger_impact() {
604        let engine = Arc::new(TrustEngine::new());
605        let peer_a = PeerId::random();
606        let peer_b = PeerId::random();
607
608        engine.update_node_stats_weighted(&peer_a, NodeStatisticsUpdate::FailedResponse, 1.0);
609        engine.update_node_stats_weighted(&peer_b, NodeStatisticsUpdate::FailedResponse, 5.0);
610
611        assert!(
612            engine.score(&peer_b) < engine.score(&peer_a),
613            "weight-5 failure should have larger impact than weight-1"
614        );
615    }
616
617    /// Test 62: zero and negative weights rejected
618    #[tokio::test]
619    async fn test_zero_negative_weights_noop() {
620        let engine = Arc::new(TrustEngine::new());
621        let peer = PeerId::random();
622
623        let neutral = engine.score(&peer);
624
625        // Zero weight should be a no-op (but this is validated in AdaptiveDHT,
626        // not TrustEngine directly). If called on TrustEngine with weight 0,
627        // the EMA formula with weight=0 produces alpha_w=0, so score stays unchanged.
628        engine.update_node_stats_weighted(&peer, NodeStatisticsUpdate::FailedResponse, 0.0);
629        let after_zero = engine.score(&peer);
630
631        // With weight 0: alpha_w = 1 - (1-0.1)^0 = 1 - 1 = 0, so no change
632        assert!(
633            (after_zero - neutral).abs() < 1e-10,
634            "zero-weight should not change score: {neutral} -> {after_zero}"
635        );
636    }
637
638    // =======================================================================
639    // Phase 8: Integration test matrix — missing coverage
640    // =======================================================================
641
642    // -----------------------------------------------------------------------
643    // Test 61: Weight clamping at MAX_CONSUMER_WEIGHT
644    // -----------------------------------------------------------------------
645    // Full clamping happens in AdaptiveDHT::report_trust_event (which requires
646    // a transport setup we can't construct in a unit test). Instead we verify
647    // that TrustEngine does NOT clamp — proving that the caller is responsible
648    // for clamping. This validates the design's layering.
649
650    /// At the TrustEngine level, weight 100 must have MORE impact than weight 5,
651    /// confirming that TrustEngine does not clamp. The clamping contract
652    /// belongs to AdaptiveDHT::report_trust_event.
653    #[tokio::test]
654    async fn test_trust_engine_does_not_clamp_weights() {
655        let engine = Arc::new(TrustEngine::new());
656        let peer_clamped = PeerId::random();
657        let peer_unclamped = PeerId::random();
658
659        // Weight 5 (MAX_CONSUMER_WEIGHT) for peer_clamped
660        engine.update_node_stats_weighted(
661            &peer_clamped,
662            NodeStatisticsUpdate::FailedResponse,
663            MAX_CONSUMER_WEIGHT,
664        );
665        let score_at_max = engine.score(&peer_clamped);
666
667        // Weight 100 (should NOT be clamped at TrustEngine level) for peer_unclamped
668        engine.update_node_stats_weighted(
669            &peer_unclamped,
670            NodeStatisticsUpdate::FailedResponse,
671            100.0,
672        );
673        let score_at_100 = engine.score(&peer_unclamped);
674
675        assert!(
676            score_at_100 < score_at_max,
677            "TrustEngine should not clamp: weight-100 ({score_at_100}) should have more impact than weight-{MAX_CONSUMER_WEIGHT} ({score_at_max})"
678        );
679    }
680
681    // -----------------------------------------------------------------------
682    // Test 55: Consumer penalty pushes trust below swap threshold
683    // -----------------------------------------------------------------------
684    // At this layer we verify that enough failures push trust below the swap
685    // threshold. Actual swap-out from the routing table happens during
686    // admission (covered by trust swap-out tests in core_engine).
687
688    /// A peer slightly above the swap threshold can be pushed below it by
689    /// consumer-reported failures of sufficient weight.
690    #[tokio::test]
691    async fn test_consumer_penalty_crosses_swap_threshold() {
692        let engine = Arc::new(TrustEngine::new());
693        let peer = PeerId::random();
694
695        // First, bring the peer down to just above the swap threshold.
696        // From neutral (0.5), 2 failures bring it to ~0.384 (still above 0.35).
697        for _ in 0..2 {
698            engine.update_node_stats(&peer, NodeStatisticsUpdate::FailedResponse);
699        }
700        let score_before = engine.score(&peer);
701        assert!(
702            score_before > DEFAULT_SWAP_THRESHOLD,
703            "should be above swap threshold: {score_before}"
704        );
705
706        // Heavy consumer failures should push it below the swap threshold.
707        for _ in 0..10 {
708            engine.update_node_stats_weighted(
709                &peer,
710                NodeStatisticsUpdate::FailedResponse,
711                MAX_CONSUMER_WEIGHT,
712            );
713        }
714        let score_after = engine.score(&peer);
715        assert!(
716            score_after < DEFAULT_SWAP_THRESHOLD,
717            "after heavy consumer failures, score {score_after} should be below swap threshold {DEFAULT_SWAP_THRESHOLD}"
718        );
719    }
720
721    // -----------------------------------------------------------------------
722    // TrustEvent to_stats_update is exhaustive
723    // -----------------------------------------------------------------------
724
725    /// Verify that all consumer-reported event variants correctly map to the
726    /// expected NodeStatisticsUpdate direction (success -> CorrectResponse,
727    /// failure -> FailedResponse).
728    #[test]
729    fn test_consumer_event_direction_mapping() {
730        // Success variants all map to CorrectResponse
731        let success_events = [
732            TrustEvent::ApplicationSuccess(0.5),
733            TrustEvent::ApplicationSuccess(1.0),
734            TrustEvent::ApplicationSuccess(5.0),
735        ];
736        for event in success_events {
737            assert!(
738                matches!(
739                    event.to_stats_update(),
740                    NodeStatisticsUpdate::CorrectResponse
741                ),
742                "{event:?} should map to CorrectResponse"
743            );
744        }
745
746        // Failure variants all map to FailedResponse
747        let failure_events = [
748            TrustEvent::ApplicationFailure(0.5),
749            TrustEvent::ApplicationFailure(1.0),
750            TrustEvent::ApplicationFailure(5.0),
751        ];
752        for event in failure_events {
753            assert!(
754                matches!(
755                    event.to_stats_update(),
756                    NodeStatisticsUpdate::FailedResponse
757                ),
758                "{event:?} should map to FailedResponse"
759            );
760        }
761    }
762}