Skip to main content

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