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}