Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 40 additions & 19 deletions src/adaptive/dht.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,18 @@ use crate::error::P2pResult as Result;
use serde::{Deserialize, Serialize};
use std::sync::Arc;

/// Default trust score threshold below which a peer is eligible for swap-out
const DEFAULT_SWAP_THRESHOLD: f64 = 0.35;
/// Default trust score threshold below which a peer is eligible for swap-out.
///
/// Exposed `pub(crate)` so sibling tests in `adaptive::trust` can assert
/// against the canonical value rather than duplicating the literal.
pub(crate) const DEFAULT_SWAP_THRESHOLD: f64 = 0.35;

/// Maximum weight multiplier per single consumer-reported event.
/// Caps the influence of any single consumer event on the EMA.
const MAX_CONSUMER_WEIGHT: f64 = 5.0;
///
/// Exposed `pub(crate)` so sibling tests can drive the public
/// `AdaptiveDHT::report_trust_event` path with the documented cap.
pub(crate) const MAX_CONSUMER_WEIGHT: f64 = 5.0;

/// Configuration for the AdaptiveDHT layer
#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down Expand Up @@ -110,6 +116,34 @@ impl TrustEvent {
| TrustEvent::ApplicationFailure(_) => NodeStatisticsUpdate::FailedResponse,
}
}

/// Pure function that mirrors the clamping + zero-weight gate applied by
/// [`AdaptiveDHT::report_trust_event`].
///
/// Returns `Some((stats_update, effective_weight))` when the event would
/// be applied to the trust engine, or `None` when the consumer-reported
/// weight is non-positive / non-finite (silent no-op). For internal
/// events (`ConnectionFailed`, `ConnectionTimeout`) the effective weight
/// is `1.0`. For consumer events the weight is clamped to
/// [`MAX_CONSUMER_WEIGHT`].
///
/// Exists as a separate function so tests can assert the clamping/gating
/// behaviour of the public entry point without constructing the full
/// [`AdaptiveDHT`] (which requires a real `TransportHandle`).
pub(crate) fn clamped_update(self) -> Option<(NodeStatisticsUpdate, f64)> {
match self {
TrustEvent::ApplicationSuccess(weight) | TrustEvent::ApplicationFailure(weight) => {
if weight.is_finite() && weight > 0.0 {
Some((self.to_stats_update(), weight.min(MAX_CONSUMER_WEIGHT)))
} else {
None
}
}
TrustEvent::ConnectionFailed | TrustEvent::ConnectionTimeout => {
Some((self.to_stats_update(), 1.0))
}
}
}
}

/// AdaptiveDHT — the trust boundary for all DHT operations.
Expand Down Expand Up @@ -178,22 +212,9 @@ impl AdaptiveDHT {
/// evicted — they remain in the routing table until a better candidate
/// arrives and triggers a swap-out.
pub async fn report_trust_event(&self, peer_id: &PeerId, event: TrustEvent) {
match event {
TrustEvent::ApplicationSuccess(weight) | TrustEvent::ApplicationFailure(weight) => {
if weight > 0.0 {
let clamped_weight = weight.min(MAX_CONSUMER_WEIGHT);
self.trust_engine.update_node_stats_weighted(
peer_id,
event.to_stats_update(),
clamped_weight,
);
}
}
_ => {
// Internal events: unit weight
self.trust_engine
.update_node_stats(peer_id, event.to_stats_update());
}
if let Some((stats_update, weight)) = event.clamped_update() {
self.trust_engine
.update_node_stats_weighted(peer_id, stats_update, weight);
}
}

Expand Down
179 changes: 179 additions & 0 deletions src/adaptive/trust.rs
Original file line number Diff line number Diff line change
Expand Up @@ -810,4 +810,183 @@ mod tests {
"after {success_rounds} weight-3 successes, score {restored_score} should be >= {TRUST_PROTECTION_THRESHOLD}"
);
}

// -----------------------------------------------------------------------
// Bad-binding eviction: ApplicationFailure(MAX_CONSUMER_WEIGHT) is
// sufficient to push a peer out of the lookup-eligible band in one shot.
//
// The production swap-out threshold lives in `AdaptiveDhtConfig`
// (`adaptive::dht::DEFAULT_SWAP_THRESHOLD = 0.35`). plan-1-bad-node-eviction
// also calls out the looser sanity bound of 0.4. We assert against both
// so a future tweak to either knob still flags this test.
// -----------------------------------------------------------------------

use crate::adaptive::TrustEvent;
use crate::adaptive::dht::{DEFAULT_SWAP_THRESHOLD, MAX_CONSUMER_WEIGHT};

/// Looser sanity bound from plan-1-bad-node-eviction.md.
const PLAN_LOOKUP_SKIP_BOUND: f64 = 0.4;

/// Drive a `TrustEvent` through the same `clamped_update` gate that
/// `AdaptiveDHT::report_trust_event` uses, so tests exercise the
/// public-API clamping/zero-weight-guard rather than the raw
/// `TrustEngine::update_node_stats_weighted` entry. Mirrors the body of
/// `AdaptiveDHT::report_trust_event` exactly; both call sites share
/// `TrustEvent::clamped_update` as the single source of truth for the
/// gate.
fn report_via_public_api(engine: &TrustEngine, peer: &PeerId, event: TrustEvent) {
if let Some((stats_update, weight)) = event.clamped_update() {
engine.update_node_stats_weighted(peer, stats_update, weight);
}
}

/// A1: A single `ApplicationFailure(MAX_CONSUMER_WEIGHT)` drops a peer
/// from neutral 0.5 to below the production swap threshold (and the
/// looser 0.4 plan target) in one shot. Proves one bad-binding
/// detection is enough to evict the offender from lookup-eligible
/// candidates.
///
/// Drives the event through `report_via_public_api` so the
/// `clamped_update` gate (clamping + zero-weight guard) is on the test
/// path, not just `TrustEngine`'s raw write.
#[tokio::test]
async fn bad_binding_event_drops_peer_below_lookup_threshold() {
let engine = TrustEngine::new();
let peer = PeerId::random();

report_via_public_api(
&engine,
&peer,
TrustEvent::ApplicationFailure(MAX_CONSUMER_WEIGHT),
);

let score = engine.score(&peer);
assert!(
score < PLAN_LOOKUP_SKIP_BOUND,
"one weight-{MAX_CONSUMER_WEIGHT} failure should drop score below {PLAN_LOOKUP_SKIP_BOUND}, got {score}"
);
assert!(
score < DEFAULT_SWAP_THRESHOLD,
"score {score} should also be below the production swap threshold {DEFAULT_SWAP_THRESHOLD}"
);
assert!(
score >= MIN_TRUST_SCORE,
"score {score} must remain inside the legal range"
);
}

/// A2: A peer downscored by one bad-binding event recovers above the
/// swap threshold after ~1 day of decay. Eviction is *temporary*: a
/// peer that fixes its config re-enters the lookup pool naturally.
///
/// `DECAY_LAMBDA` is tuned so the worst score (0.0) recovers above the
/// swap threshold in ~1 day; from ~0.26 we need less. We assert the
/// score has crossed back above the threshold within a single 24h
/// elapsed window since the failure, matching the contract documented
/// on `DECAY_LAMBDA`.
#[tokio::test]
async fn bad_binding_decays_back_above_threshold_within_one_day() {
let engine = TrustEngine::new();
let peer = PeerId::random();

report_via_public_api(
&engine,
&peer,
TrustEvent::ApplicationFailure(MAX_CONSUMER_WEIGHT),
);
let after_failure = engine.score(&peer);
assert!(
after_failure < DEFAULT_SWAP_THRESHOLD,
"post-failure score {after_failure} should be below {DEFAULT_SWAP_THRESHOLD}"
);

let one_day = std::time::Duration::from_secs(24 * 3600);
engine.simulate_elapsed(&peer, one_day).await;
Comment on lines +903 to +904

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 97161f8: dropped the 2h intermediate step entirely. The test now does a single simulate_elapsed(24h) from the post-failure state, so total elapsed matches the "within ~1 day" contract.

let after_one_day = engine.score(&peer);
assert!(
after_one_day > after_failure,
"score should decay toward neutral over 24h: {after_failure} -> {after_one_day}"
);
assert!(
after_one_day >= DEFAULT_SWAP_THRESHOLD,
"after 24h of decay the score {after_one_day} should be back above the swap threshold {DEFAULT_SWAP_THRESHOLD}"
);
}

/// A3: 10 consecutive bad-binding events keep the score inside
/// `[MIN_TRUST_SCORE, swap_threshold)`. Confirms the EMA arithmetic
/// does not produce negative or NaN scores under repeated punishment.
#[tokio::test]
async fn multiple_bad_bindings_dont_compound_pathologically() {
let engine = TrustEngine::new();
let peer = PeerId::random();

for _ in 0..10 {
report_via_public_api(
&engine,
&peer,
TrustEvent::ApplicationFailure(MAX_CONSUMER_WEIGHT),
);
}

let score = engine.score(&peer);
assert!(score.is_finite(), "score {score} must remain finite");
assert!(
(MIN_TRUST_SCORE..PLAN_LOOKUP_SKIP_BOUND).contains(&score),
"after 10 weight-{MAX_CONSUMER_WEIGHT} failures, score {score} must lie in [{MIN_TRUST_SCORE}, {PLAN_LOOKUP_SKIP_BOUND})"
);
}

/// A4: The public-API clamp + zero/negative-weight guard live on
/// `TrustEvent::clamped_update`. A consumer event with a weight above
/// `MAX_CONSUMER_WEIGHT` is clamped (so a malicious caller cannot
/// over-penalise a peer); a non-positive or non-finite weight is
/// silently dropped (so a buggy caller cannot accidentally credit a
/// success or NaN-poison the EMA).
///
/// This is the test the reviewer asked for: the PR's whole purpose is
/// to expose `report_application_failure` as a public surface, so the
/// gating logic that surface relies on must be exercised.
#[tokio::test]
async fn clamped_update_clamps_weight_and_drops_non_positive() {
// Above-cap weight: clamps down to MAX_CONSUMER_WEIGHT, equivalent
// to passing exactly the cap. Tolerance accounts for the µs-level
// decay drift between the two `record_weighted` calls — both
// engines independently sample `Instant::now()` so the scores agree
// to within decay over a handful of microseconds, not bit-for-bit.
let engine_clamped = TrustEngine::new();
let engine_at_cap = TrustEngine::new();
let peer = PeerId::random();
report_via_public_api(
&engine_clamped,
&peer,
TrustEvent::ApplicationFailure(MAX_CONSUMER_WEIGHT * 100.0),
);
report_via_public_api(
&engine_at_cap,
&peer,
TrustEvent::ApplicationFailure(MAX_CONSUMER_WEIGHT),
);
let drift = (engine_clamped.score(&peer) - engine_at_cap.score(&peer)).abs();
assert!(
drift < 1e-6,
"weights above MAX_CONSUMER_WEIGHT must clamp to the cap; \
scores diverged by {drift} (clamped={}, at_cap={})",
engine_clamped.score(&peer),
engine_at_cap.score(&peer),
);

// Zero / negative / NaN weight: gate drops the event entirely. The
// peer keeps the neutral score it started with.
for bad_weight in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let engine = TrustEngine::new();
let peer = PeerId::random();
report_via_public_api(&engine, &peer, TrustEvent::ApplicationFailure(bad_weight));
assert!(
(engine.score(&peer) - DEFAULT_NEUTRAL_TRUST).abs() < f64::EPSILON,
"weight {bad_weight} must be a no-op, score {} should equal neutral {DEFAULT_NEUTRAL_TRUST}",
engine.score(&peer)
);
}
}
}
20 changes: 20 additions & 0 deletions src/network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -998,6 +998,26 @@ impl P2PNode {
self.adaptive_dht.report_trust_event(peer_id, event).await;
}

/// Report a consumer-level application failure for a peer without
/// requiring the caller to import [`TrustEvent`].
///
/// This is the recommended entry point for downstream crates (ant-client,
/// ant-node) that need to downscore a peer after a verifiable misbehaviour
/// (e.g. a quote whose `pub_key` does not BLAKE3-hash to its claimed
/// `PeerId`) but which do not — and should not — depend directly on
/// `saorsa-core`'s adaptive trust types.
///
/// `weight` is clamped to the same `MAX_CONSUMER_WEIGHT` cap as
/// [`Self::report_trust_event`]. A weight of `5.0` (the cap) is enough
/// to drop a peer from neutral 0.5 to below the production swap-out
/// threshold in a single event — the intended setting for "the storer
/// would have rejected this proof"-class evidence.
pub async fn report_application_failure(&self, peer_id: &PeerId, weight: f64) {
self.adaptive_dht
.report_trust_event(peer_id, TrustEvent::ApplicationFailure(weight))
.await;
}

/// Get the current trust score for a peer (0.0 to 1.0).
///
/// Returns 0.5 (neutral) for unknown peers.
Expand Down
Loading