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}