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