use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::Arc;
use saorsa_gossip_groups::GroupContext;
use saorsa_gossip_presence::PresenceManager;
use saorsa_gossip_types::{PeerId, PresenceRecord, TopicId};
use tokio::sync::{broadcast, RwLock};
use tokio::task::JoinHandle;
use crate::contacts::ContactStore;
use crate::error::NetworkError;
use crate::identity::{AgentId, MachineId};
use crate::network::NetworkNode;
use crate::trust::{TrustContext, TrustDecision, TrustEvaluator};
use crate::DiscoveredAgent;
const INTER_ARRIVAL_WINDOW: usize = 10;
const ADAPTIVE_TIMEOUT_FLOOR_SECS: f64 = 180.0;
const ADAPTIVE_TIMEOUT_CEILING_SECS: f64 = 600.0;
pub const GLOBAL_PRESENCE_TOPIC_NAME: &str = "x0x.presence.global";
#[must_use]
pub fn global_presence_topic() -> TopicId {
TopicId::from_entity(GLOBAL_PRESENCE_TOPIC_NAME)
}
#[must_use]
pub fn peer_to_agent_id(
peer_id: PeerId,
cache: &HashMap<AgentId, DiscoveredAgent>,
) -> Option<AgentId> {
let machine = MachineId(*peer_id.as_bytes());
cache
.values()
.find(|entry| entry.machine_id == machine)
.map(|entry| entry.agent_id)
}
#[must_use]
pub fn parse_addr_hints(hints: &[String]) -> Vec<std::net::SocketAddr> {
hints
.iter()
.filter_map(|h| h.parse::<std::net::SocketAddr>().ok())
.filter(|a| crate::is_publicly_advertisable(*a))
.collect()
}
#[must_use]
pub fn presence_record_to_discovered_agent(
peer_id: PeerId,
record: &PresenceRecord,
cache: &HashMap<AgentId, DiscoveredAgent>,
) -> Option<DiscoveredAgent> {
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
if record.expires < now_secs {
return None;
}
let addresses = parse_addr_hints(&record.addr_hints);
if let Some(agent_id) = peer_to_agent_id(peer_id, cache) {
if let Some(cached) = cache.get(&agent_id) {
let mut updated = cached.clone();
if !addresses.is_empty() {
updated.addresses = addresses;
}
return Some(updated);
}
}
let agent_id = AgentId(*peer_id.as_bytes());
let machine_id = MachineId(*peer_id.as_bytes());
Some(DiscoveredAgent {
agent_id,
machine_id,
user_id: None,
addresses,
announced_at: record.since,
last_seen: record.since,
machine_public_key: Vec::new(), nat_type: None,
can_receive_direct: None,
is_relay: None,
is_coordinator: None,
reachable_via: Vec::new(),
relay_candidates: Vec::new(),
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PresenceVisibility {
Network,
Social,
}
#[must_use]
pub fn filter_by_trust(
agents: Vec<DiscoveredAgent>,
store: &ContactStore,
visibility: PresenceVisibility,
) -> Vec<DiscoveredAgent> {
let evaluator = TrustEvaluator::new(store);
agents
.into_iter()
.filter(|agent| {
let ctx = TrustContext {
agent_id: &agent.agent_id,
machine_id: &agent.machine_id,
};
let decision = evaluator.evaluate(&ctx);
match visibility {
PresenceVisibility::Network => !matches!(decision, TrustDecision::RejectBlocked),
PresenceVisibility::Social => matches!(
decision,
TrustDecision::Accept | TrustDecision::AcceptWithFlag
),
}
})
.collect()
}
#[must_use]
pub fn foaf_peer_score(stats: &PeerBeaconStats) -> f64 {
match stats.inter_arrival_stats() {
Some((_, stddev)) => 1.0 / (1.0 + stddev),
None => 0.5, }
}
#[derive(Debug, Clone)]
pub struct PeerBeaconStats {
last_seen: VecDeque<u64>,
}
impl PeerBeaconStats {
#[must_use]
pub fn new() -> Self {
Self {
last_seen: VecDeque::with_capacity(INTER_ARRIVAL_WINDOW + 1),
}
}
#[must_use]
pub fn last_seen(&self) -> Option<u64> {
self.last_seen.back().copied()
}
pub fn record(&mut self, now_secs: u64) {
self.last_seen.push_back(now_secs);
while self.last_seen.len() > INTER_ARRIVAL_WINDOW {
self.last_seen.pop_front();
}
}
#[must_use]
pub fn inter_arrival_stats(&self) -> Option<(f64, f64)> {
if self.last_seen.len() < 2 {
return None;
}
let intervals: Vec<f64> = self
.last_seen
.iter()
.zip(self.last_seen.iter().skip(1))
.map(|(&a, &b)| b.saturating_sub(a) as f64)
.collect();
let n = intervals.len() as f64;
let mean = intervals.iter().sum::<f64>() / n;
let variance = intervals.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / n;
let stddev = variance.sqrt();
Some((mean, stddev))
}
#[must_use]
pub fn adaptive_timeout_secs(&self, fallback_secs: u64) -> u64 {
match self.inter_arrival_stats() {
Some((mean, stddev)) => {
let raw = mean + 3.0 * stddev;
raw.clamp(ADAPTIVE_TIMEOUT_FLOOR_SECS, ADAPTIVE_TIMEOUT_CEILING_SECS) as u64
}
None => fallback_secs,
}
}
}
impl Default for PeerBeaconStats {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct PresenceConfig {
pub beacon_interval_secs: u64,
pub foaf_default_ttl: u8,
pub foaf_timeout_ms: u64,
pub enable_beacons: bool,
pub event_poll_interval_secs: u64,
pub adaptive_timeout_fallback_secs: u64,
pub legacy_coexistence_mode: bool,
}
impl Default for PresenceConfig {
fn default() -> Self {
Self {
beacon_interval_secs: 30,
foaf_default_ttl: 2,
foaf_timeout_ms: 5000,
enable_beacons: true,
event_poll_interval_secs: 10,
adaptive_timeout_fallback_secs: 300,
legacy_coexistence_mode: true,
}
}
}
#[derive(Debug, Clone)]
pub enum PresenceEvent {
AgentOnline {
agent_id: AgentId,
addresses: Vec<String>,
reachable: Option<bool>,
},
AgentOffline {
agent_id: AgentId,
},
}
pub struct PresenceWrapper {
manager: Arc<PresenceManager>,
config: PresenceConfig,
beacon_handle: tokio::sync::Mutex<Option<JoinHandle<()>>>,
event_handle: tokio::sync::Mutex<Option<JoinHandle<()>>>,
event_tx: broadcast::Sender<PresenceEvent>,
bootstrap_cache: Option<Arc<ant_quic::BootstrapCache>>,
peer_stats: Arc<RwLock<HashMap<PeerId, PeerBeaconStats>>>,
network: Arc<NetworkNode>,
}
impl PresenceWrapper {
pub fn new(
peer_id: PeerId,
network: Arc<NetworkNode>,
config: PresenceConfig,
bootstrap_cache: Option<Arc<ant_quic::BootstrapCache>>,
) -> Result<Self, NetworkError> {
let signing_key = saorsa_gossip_identity::MlDsaKeyPair::generate().map_err(|e| {
NetworkError::NodeCreation(format!("failed to create presence signing key: {e}"))
})?;
let groups: Arc<RwLock<HashMap<TopicId, GroupContext>>> =
Arc::new(RwLock::new(HashMap::new()));
let network_for_wrapper = Arc::clone(&network);
let manager = PresenceManager::new_with_identity(
peer_id,
network,
groups,
None, signing_key,
);
let (event_tx, _) = broadcast::channel(256);
Ok(Self {
manager: Arc::new(manager),
config,
beacon_handle: tokio::sync::Mutex::new(None),
event_handle: tokio::sync::Mutex::new(None),
event_tx,
bootstrap_cache,
peer_stats: Arc::new(RwLock::new(HashMap::new())),
network: network_for_wrapper,
})
}
pub fn manager(&self) -> &Arc<PresenceManager> {
&self.manager
}
pub fn config(&self) -> &PresenceConfig {
&self.config
}
pub fn subscribe_events(&self) -> broadcast::Receiver<PresenceEvent> {
self.event_tx.subscribe()
}
pub async fn foaf_peer_candidates(&self) -> Vec<(PeerId, f64)> {
let stats = self.peer_stats.read().await;
let mut candidates: Vec<(PeerId, f64)> = stats
.iter()
.map(|(&peer_id, s)| (peer_id, foaf_peer_score(s)))
.collect();
candidates.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
candidates
}
pub async fn start_event_loop(&self, cache: Arc<RwLock<HashMap<AgentId, DiscoveredAgent>>>) {
let mut guard = self.event_handle.lock().await;
if guard.is_some() {
return;
}
let manager = Arc::clone(&self.manager);
let event_tx = self.event_tx.clone();
let poll_interval = tokio::time::Duration::from_secs(self.config.event_poll_interval_secs);
let topic = global_presence_topic();
let bootstrap_cache = self.bootstrap_cache.clone();
let peer_stats = Arc::clone(&self.peer_stats);
let adaptive_fallback = self.config.adaptive_timeout_fallback_secs;
let network = Arc::clone(&self.network);
let handle = tokio::spawn(async move {
let mut previous: HashSet<PeerId> = HashSet::new();
loop {
tokio::time::sleep(poll_interval).await;
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let current_peers = manager.get_online_peers(topic).await;
let current: HashSet<PeerId> = current_peers.iter().copied().collect();
let cache_snapshot = cache.read().await;
for &peer in current.difference(&previous) {
{
let mut stats_guard = peer_stats.write().await;
stats_guard.entry(peer).or_default().record(now_secs);
}
let agent_id = peer_to_agent_id(peer, &cache_snapshot)
.unwrap_or_else(|| AgentId(*peer.as_bytes()));
let socket_addrs: Vec<std::net::SocketAddr> = cache_snapshot
.get(&agent_id)
.map(|e| e.addresses.clone())
.unwrap_or_default();
if let Some(ref bc) = bootstrap_cache {
if !socket_addrs.is_empty() {
let ant_peer_id = ant_quic::PeerId(*peer.as_bytes());
bc.add_from_connection(ant_peer_id, socket_addrs.clone(), None)
.await;
}
}
let addresses: Vec<String> =
socket_addrs.iter().map(|a| a.to_string()).collect();
let ant_peer_id_for_check = ant_quic::PeerId(*peer.as_bytes());
let connected_peers = network.connected_peers().await;
let reachable = Some(connected_peers.contains(&ant_peer_id_for_check));
if event_tx
.send(PresenceEvent::AgentOnline {
agent_id,
addresses,
reachable,
})
.is_err()
{
tracing::debug!(
?agent_id,
"AgentOnline event dropped: no active subscribers"
);
}
}
for &peer in previous.difference(¤t) {
let (timeout, last_seen_ts) = {
let stats_guard = peer_stats.read().await;
let s = stats_guard.get(&peer);
let timeout = s
.map(|s| s.adaptive_timeout_secs(adaptive_fallback))
.unwrap_or(adaptive_fallback);
let last_seen_ts = s.and_then(|s| s.last_seen()).unwrap_or(0);
(timeout, last_seen_ts)
};
let absent_secs = now_secs.saturating_sub(last_seen_ts);
if absent_secs < timeout {
continue;
}
peer_stats.write().await.remove(&peer);
let agent_id = peer_to_agent_id(peer, &cache_snapshot)
.unwrap_or_else(|| AgentId(*peer.as_bytes()));
if event_tx
.send(PresenceEvent::AgentOffline { agent_id })
.is_err()
{
tracing::debug!(
?agent_id,
"AgentOffline event dropped: no active subscribers"
);
}
}
drop(cache_snapshot);
previous = current;
}
});
*guard = Some(handle);
}
pub async fn shutdown(&self) {
let mut beacon = self.beacon_handle.lock().await;
if let Some(h) = beacon.take() {
h.abort();
}
let mut event = self.event_handle.lock().await;
if let Some(h) = event.take() {
h.abort();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::identity::{AgentId, MachineId};
use crate::DiscoveredAgent;
use std::collections::HashMap;
fn make_discovered_agent(agent_id: AgentId, machine_id: MachineId) -> DiscoveredAgent {
DiscoveredAgent {
agent_id,
machine_id,
user_id: None,
addresses: vec!["127.0.0.1:5000".parse().unwrap()],
announced_at: 1000,
last_seen: 1000,
machine_public_key: vec![1u8; 32],
nat_type: None,
can_receive_direct: None,
is_relay: None,
is_coordinator: None,
reachable_via: Vec::new(),
relay_candidates: Vec::new(),
}
}
#[test]
fn test_global_presence_topic_is_deterministic() {
let t1 = global_presence_topic();
let t2 = global_presence_topic();
assert_eq!(t1, t2, "global_presence_topic must be deterministic");
}
#[test]
fn test_peer_to_agent_id_found() {
let machine_bytes = [42u8; 32];
let machine_id = MachineId(machine_bytes);
let agent_id = AgentId([7u8; 32]);
let peer_id = PeerId::new(machine_bytes);
let mut cache = HashMap::new();
cache.insert(agent_id, make_discovered_agent(agent_id, machine_id));
let result = peer_to_agent_id(peer_id, &cache);
assert_eq!(result, Some(agent_id));
}
#[test]
fn test_peer_to_agent_id_not_found() {
let cache: HashMap<AgentId, DiscoveredAgent> = HashMap::new();
let peer_id = PeerId::new([1u8; 32]);
assert_eq!(peer_to_agent_id(peer_id, &cache), None);
}
#[test]
fn test_parse_addr_hints_valid() {
let hints = vec!["1.2.3.4:5000".to_string(), "[2001:db8::1]:5001".to_string()];
let addrs = parse_addr_hints(&hints);
assert_eq!(addrs.len(), 2, "two globally-advertisable hints survive");
}
#[test]
fn test_parse_addr_hints_invalid_skipped() {
let hints = vec!["not-an-addr".to_string(), "1.2.3.4:5000".to_string()];
let addrs = parse_addr_hints(&hints);
assert_eq!(
addrs.len(),
1,
"unparseable hints are dropped, parseable global hints survive"
);
}
#[test]
fn test_parse_addr_hints_drops_non_global_scopes() {
let hints = vec![
"127.0.0.1:5000".to_string(),
"10.1.2.3:5000".to_string(),
"192.168.1.5:5000".to_string(),
"[fd00::1]:5000".to_string(),
"[::1]:5000".to_string(),
"1.2.3.4:5000".to_string(),
];
let addrs = parse_addr_hints(&hints);
assert_eq!(
addrs.len(),
1,
"only the global v4 entry survives the scope filter"
);
assert_eq!(addrs[0].to_string(), "1.2.3.4:5000");
}
#[test]
fn test_presence_record_to_discovered_agent_cache_hit() {
use saorsa_gossip_types::PresenceRecord;
let machine_bytes = [10u8; 32];
let machine_id = MachineId(machine_bytes);
let agent_id = AgentId([20u8; 32]);
let peer_id = PeerId::new(machine_bytes);
let mut cache = HashMap::new();
cache.insert(agent_id, make_discovered_agent(agent_id, machine_id));
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let record = PresenceRecord::new([0u8; 32], vec!["1.2.3.4:5000".to_string()], 300);
let result = presence_record_to_discovered_agent(peer_id, &record, &cache);
assert!(
result.is_some(),
"Should return Some for non-expired record"
);
let da = result.unwrap();
assert_eq!(da.agent_id, agent_id, "Should use cached agent_id");
assert_eq!(da.addresses.len(), 1);
assert!(!da.machine_public_key.is_empty());
let _ = now; }
#[test]
fn test_presence_record_to_discovered_agent_fallback() {
use saorsa_gossip_types::PresenceRecord;
let peer_bytes = [99u8; 32];
let peer_id = PeerId::new(peer_bytes);
let cache: HashMap<AgentId, DiscoveredAgent> = HashMap::new();
let record = PresenceRecord::new([0u8; 32], vec!["10.0.0.1:5000".to_string()], 300);
let result = presence_record_to_discovered_agent(peer_id, &record, &cache);
assert!(result.is_some(), "Fallback should produce an entry");
let da = result.unwrap();
assert_eq!(da.agent_id.0, peer_bytes);
assert!(da.machine_public_key.is_empty());
}
#[test]
fn test_presence_record_to_discovered_agent_expired() {
use saorsa_gossip_types::PresenceRecord;
let peer_id = PeerId::new([1u8; 32]);
let cache: HashMap<AgentId, DiscoveredAgent> = HashMap::new();
let mut record = PresenceRecord::new([0u8; 32], vec![], 1);
record.expires = 0;
let result = presence_record_to_discovered_agent(peer_id, &record, &cache);
assert!(result.is_none(), "Expired record should return None");
}
#[test]
fn test_peer_beacon_stats_single_sample_uses_fallback() {
let mut stats = PeerBeaconStats::new();
stats.record(1_000);
assert!(
stats.inter_arrival_stats().is_none(),
"Single sample must return None from inter_arrival_stats"
);
let fallback = 300_u64;
assert_eq!(
stats.adaptive_timeout_secs(fallback),
fallback,
"adaptive_timeout_secs must return fallback when < 2 samples"
);
}
#[test]
fn test_peer_beacon_stats_two_samples_produces_stats() {
let mut stats = PeerBeaconStats::new();
stats.record(1_000);
stats.record(1_030);
let result = stats.inter_arrival_stats();
assert!(result.is_some(), "Two samples should produce stats");
let (mean, stddev) = result.unwrap();
assert!(
(mean - 30.0).abs() < 0.001,
"Mean should be 30 s, got {mean}"
);
assert!(
stddev < 0.001,
"Stddev should be 0 for single interval, got {stddev}"
);
let timeout = stats.adaptive_timeout_secs(300);
assert_eq!(
timeout, 180,
"Timeout should be clamped to floor 180, got {timeout}"
);
}
#[test]
fn test_peer_beacon_stats_high_jitter_ceiling() {
let mut stats = PeerBeaconStats::new();
let times: Vec<u64> = vec![0, 10, 510, 520, 1020, 1030, 1530, 1540];
for t in times {
stats.record(t);
}
let (mean, stddev) = stats.inter_arrival_stats().expect("Should have stats");
let raw = mean + 3.0 * stddev;
assert!(
raw > ADAPTIVE_TIMEOUT_CEILING_SECS,
"Raw timeout {raw} should exceed ceiling"
);
let timeout = stats.adaptive_timeout_secs(300);
assert_eq!(
timeout, 600,
"Timeout should be clamped to ceiling 600, got {timeout}"
);
}
#[test]
fn test_peer_beacon_stats_steady_beacons_floor() {
let mut stats = PeerBeaconStats::new();
for i in 0..10_u64 {
stats.record(i * 5);
}
let (mean, stddev) = stats.inter_arrival_stats().expect("Should have stats");
assert!((mean - 5.0).abs() < 0.001, "Mean should be 5 s, got {mean}");
assert!(stddev < 0.001, "Stddev should be ~0, got {stddev}");
let timeout = stats.adaptive_timeout_secs(300);
assert_eq!(
timeout, 180,
"Timeout should be at floor 180 for steady beacons, got {timeout}"
);
}
#[test]
fn test_peer_beacon_stats_window_capped() {
let mut stats = PeerBeaconStats::new();
for i in 0..15_u64 {
stats.record(i * 30);
}
assert_eq!(
stats.last_seen.len(),
INTER_ARRIVAL_WINDOW,
"Window should be capped at INTER_ARRIVAL_WINDOW"
);
}
#[test]
fn test_foaf_peer_score_no_stats_returns_neutral() {
let stats = PeerBeaconStats::new();
let score = foaf_peer_score(&stats);
assert!(
(score - 0.5).abs() < 0.001,
"No-stats score should be 0.5, got {score}"
);
}
#[test]
fn test_foaf_peer_score_stable_peer_high_score() {
let mut stats = PeerBeaconStats::new();
for i in 0..5_u64 {
stats.record(i * 30);
}
let score = foaf_peer_score(&stats);
assert!(
score > 0.99,
"Stable peer should score close to 1.0, got {score}"
);
}
#[test]
fn test_foaf_peer_score_jittery_peer_lower_score() {
let mut stats_stable = PeerBeaconStats::new();
let mut stats_jittery = PeerBeaconStats::new();
for i in 0..5_u64 {
stats_stable.record(i * 30);
}
for t in [0_u64, 5, 300, 310, 900] {
stats_jittery.record(t);
}
let score_stable = foaf_peer_score(&stats_stable);
let score_jittery = foaf_peer_score(&stats_jittery);
assert!(
score_stable > score_jittery,
"Stable peer ({score_stable}) should score higher than jittery ({score_jittery})"
);
}
#[test]
fn test_foaf_peer_score_always_in_range() {
let scenarios: Vec<Vec<u64>> = vec![
vec![],
vec![0],
vec![0, 1],
vec![0, 1_000_000],
vec![0, 30, 60, 90, 120],
];
for times in scenarios {
let mut stats = PeerBeaconStats::new();
for t in times {
stats.record(t);
}
let score = foaf_peer_score(&stats);
assert!(
(0.0..=1.0).contains(&score),
"Score {score} out of [0,1] range"
);
}
}
#[test]
fn test_presence_config_adaptive_fallback_default() {
let cfg = PresenceConfig::default();
assert_eq!(
cfg.adaptive_timeout_fallback_secs, 300,
"Default adaptive fallback should be 300 s"
);
}
#[test]
fn test_presence_config_legacy_coexistence_default_true() {
let cfg = PresenceConfig::default();
assert!(
cfg.legacy_coexistence_mode,
"Legacy coexistence must be enabled by default"
);
}
fn make_temp_contact_store() -> (crate::contacts::ContactStore, tempfile::TempDir) {
let tmp = tempfile::TempDir::new().unwrap();
let store = crate::contacts::ContactStore::new(tmp.path().join("contacts.json"));
(store, tmp)
}
#[test]
fn test_filter_by_trust_blocks_blocked_agents() {
let (mut store, _tmp) = make_temp_contact_store();
let blocked_id = AgentId([2u8; 32]);
let blocked_machine = MachineId([22u8; 32]);
let allowed_id = AgentId([3u8; 32]);
let allowed_machine = MachineId([33u8; 32]);
store.set_trust(&blocked_id, crate::contacts::TrustLevel::Blocked);
let agents = vec![
make_discovered_agent(blocked_id, blocked_machine),
make_discovered_agent(allowed_id, allowed_machine),
];
let filtered = filter_by_trust(agents, &store, PresenceVisibility::Network);
assert_eq!(filtered.len(), 1, "Blocked agent must be filtered out");
assert_eq!(
filtered[0].agent_id, allowed_id,
"Non-blocked agent must survive filtering"
);
}
#[test]
fn test_filter_by_trust_passes_trusted_agents() {
let (mut store, _tmp) = make_temp_contact_store();
let trusted_id = AgentId([4u8; 32]);
let trusted_machine = MachineId([44u8; 32]);
store.set_trust(&trusted_id, crate::contacts::TrustLevel::Trusted);
let agents = vec![make_discovered_agent(trusted_id, trusted_machine)];
let filtered = filter_by_trust(agents, &store, PresenceVisibility::Network);
assert_eq!(filtered.len(), 1, "Trusted agent must not be filtered");
}
#[test]
fn test_filter_by_trust_passes_unknown_agents() {
let (store, _tmp) = make_temp_contact_store();
let unknown_id = AgentId([5u8; 32]);
let unknown_machine = MachineId([55u8; 32]);
let agents = vec![make_discovered_agent(unknown_id, unknown_machine)];
let filtered = filter_by_trust(agents, &store, PresenceVisibility::Network);
assert_eq!(
filtered.len(),
1,
"Unknown-trust agent must pass Network visibility filter"
);
}
#[test]
fn test_filter_by_trust_social_keeps_only_known_or_trusted() {
let (mut store, _tmp) = make_temp_contact_store();
let trusted_id = AgentId([6u8; 32]);
let trusted_machine = MachineId([66u8; 32]);
let known_id = AgentId([7u8; 32]);
let known_machine = MachineId([77u8; 32]);
let unknown_id = AgentId([8u8; 32]);
let unknown_machine = MachineId([88u8; 32]);
store.set_trust(&trusted_id, crate::contacts::TrustLevel::Trusted);
store.set_trust(&known_id, crate::contacts::TrustLevel::Known);
let agents = vec![
make_discovered_agent(trusted_id, trusted_machine),
make_discovered_agent(known_id, known_machine),
make_discovered_agent(unknown_id, unknown_machine),
];
let filtered = filter_by_trust(agents, &store, PresenceVisibility::Social);
assert_eq!(
filtered.len(),
2,
"Social filter must keep only Known/Trusted agents"
);
let ids: Vec<_> = filtered.iter().map(|a| a.agent_id).collect();
assert!(ids.contains(&trusted_id), "Trusted must pass Social filter");
assert!(ids.contains(&known_id), "Known must pass Social filter");
}
#[test]
fn test_foaf_peer_score_empty_stats_is_neutral() {
let empty = PeerBeaconStats::new();
let score = foaf_peer_score(&empty);
assert!(
(score - 0.5_f64).abs() < f64::EPSILON,
"Empty stats must return neutral score 0.5, got {score}"
);
}
#[test]
fn test_foaf_peer_score_stable_beats_jittery() {
let base = 1_000_000_u64;
let mut stable = PeerBeaconStats::new();
for i in 0..10_u64 {
stable.record(base + i * 30);
}
let stable_score = foaf_peer_score(&stable);
let mut jittery = PeerBeaconStats::new();
for i in 0..10_u64 {
jittery.record(base + i * i * 15 + i * 30);
}
let jittery_score = foaf_peer_score(&jittery);
assert!(
stable_score >= jittery_score,
"Stable peer score ({stable_score}) must be >= jittery peer score ({jittery_score})"
);
}
#[test]
fn test_foaf_peer_score_sorted_ordering() {
let base = 1_000_000_u64;
let mut very_stable = PeerBeaconStats::new();
for i in 0..10_u64 {
very_stable.record(base + i * 30);
}
let mut moderate = PeerBeaconStats::new();
for i in 0..10_u64 {
moderate.record(base + i * 60 + (i % 3) * 10);
}
let mut chaotic = PeerBeaconStats::new();
for i in 0..10_u64 {
chaotic.record(base + i * i * 20);
}
let s_vs = foaf_peer_score(&very_stable);
let s_m = foaf_peer_score(&moderate);
let s_c = foaf_peer_score(&chaotic);
for &s in &[s_vs, s_m, s_c] {
assert!(
(0.0_f64..=1.0_f64).contains(&s),
"Score {s} must be in [0.0, 1.0]"
);
}
assert!(
s_vs >= s_c,
"Very stable ({s_vs}) must beat chaotic ({s_c})"
);
}
mod proptest_presence {
use super::*;
use proptest::prelude::*;
proptest! {
#![proptest_config(proptest::test_runner::Config {
cases: 100,
..Default::default()
})]
#[test]
fn proptest_foaf_peer_score_in_range(
timestamps in proptest::collection::vec(0_u64..10_000_000_u64, 1..=20)
) {
let mut stats = PeerBeaconStats::new();
let mut sorted = timestamps.clone();
sorted.sort_unstable();
for t in sorted {
stats.record(t);
}
let score = foaf_peer_score(&stats);
prop_assert!(
(0.0_f64..=1.0_f64).contains(&score),
"foaf_peer_score must be in [0.0, 1.0], got {score}"
);
}
#[test]
fn proptest_adaptive_timeout_clamped(
timestamps in proptest::collection::vec(0_u64..10_000_000_u64, 2..=20)
) {
let mut stats = PeerBeaconStats::new();
let mut sorted = timestamps.clone();
sorted.sort_unstable();
for t in sorted {
stats.record(t);
}
let timeout = stats.adaptive_timeout_secs(300);
prop_assert!(
(180..=600).contains(&timeout),
"adaptive_timeout_secs must be in [180, 600], got {timeout}"
);
}
}
}
}