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}