#![allow(dead_code)]
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use dashmap::DashMap;
use freenet_stdlib::prelude::ContractInstanceId;
use tokio::time::Instant;
use crate::governance::{OutlierConfig, OutlierResult, SkipReason, detect_outliers};
use crate::util::time_source::TimeSource;
#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
pub(crate) enum GovernanceState {
Normal,
Borderline,
WouldEvict,
Evicted,
Banned,
}
impl GovernanceState {
pub(crate) fn is_flagged(self) -> bool {
!matches!(self, GovernanceState::Normal)
}
pub(crate) fn blocks_operations(self) -> bool {
matches!(self, GovernanceState::Banned)
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum TransitionReason {
FirstSeen,
BorderlineEntered,
ThresholdCrossed,
Evicted,
BanTriggered,
Recovered,
BanLifted,
}
#[derive(Clone, Debug)]
pub(crate) struct StateTransition {
pub at: Instant,
pub from: GovernanceState,
pub to: GovernanceState,
pub reason: TransitionReason,
}
pub(crate) const MAX_TRANSITIONS_PER_CONTRACT: usize = 32;
#[derive(Clone, Debug)]
pub(crate) struct ContractScore {
pub cost_used: f64,
pub benefit_score: f64,
pub state: GovernanceState,
pub first_seen: Instant,
pub last_transition: Instant,
pub history: Vec<StateTransition>,
}
impl ContractScore {
pub(crate) fn new(now: Instant) -> Self {
let first = StateTransition {
at: now,
from: GovernanceState::Normal,
to: GovernanceState::Normal,
reason: TransitionReason::FirstSeen,
};
Self {
cost_used: 0.0,
benefit_score: 0.0,
state: GovernanceState::Normal,
first_seen: now,
last_transition: now,
history: vec![first],
}
}
pub(crate) fn log_ratio(&self, benefit_floor: f64) -> Option<f64> {
if self.cost_used <= f64::EPSILON {
return None;
}
let benefit_eff = self.benefit_score.max(benefit_floor);
if benefit_eff <= 0.0 {
return None;
}
let ratio = self.cost_used / benefit_eff;
if ratio <= 0.0 {
return None;
}
Some(ratio.log10())
}
pub(crate) fn record_transition(
&mut self,
now: Instant,
to: GovernanceState,
reason: TransitionReason,
) {
let from = self.state;
if from == to {
return;
}
let transition = StateTransition {
at: now,
from,
to,
reason,
};
self.state = to;
self.last_transition = now;
if self.history.len() < MAX_TRANSITIONS_PER_CONTRACT {
self.history.push(transition);
return;
}
if self.history.len() >= 2 {
self.history.remove(1);
}
self.history.push(transition);
}
pub(crate) fn decay(&mut self, tick_interval: Duration, half_life: Duration) {
if tick_interval.is_zero() || half_life.is_zero() {
return;
}
let factor = 0.5f64.powf(tick_interval.as_secs_f64() / half_life.as_secs_f64());
self.cost_used *= factor;
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum GovernanceMode {
Off,
DryRun,
Enforce,
}
impl GovernanceMode {
pub(crate) fn evicts(self) -> bool {
matches!(self, GovernanceMode::Enforce)
}
}
#[derive(Clone, Debug)]
pub(crate) struct GovernanceConfig {
pub mode: GovernanceMode,
pub outlier: OutlierConfig,
pub ramp_up: Duration,
pub decay_half_life: Duration,
pub ban_window: Duration,
pub evicted_ttl: Duration,
pub ban_ttl: Duration,
pub borderline_mad_units: f64,
pub benefit_floor: f64,
pub capacity_ceiling_log: f64,
}
impl Default for GovernanceConfig {
fn default() -> Self {
Self {
mode: GovernanceMode::DryRun,
outlier: OutlierConfig::default(),
ramp_up: Duration::from_secs(15 * 60), decay_half_life: Duration::from_secs(60 * 60), ban_window: Duration::from_secs(60 * 60), evicted_ttl: Duration::from_secs(15 * 60), ban_ttl: Duration::from_secs(60 * 60), borderline_mad_units: 3.0,
benefit_floor: 0.05,
capacity_ceiling_log: 4.0, }
}
}
#[derive(Clone, Debug)]
pub(crate) struct ReaperDecision {
pub key: ContractInstanceId,
pub from: GovernanceState,
pub to: GovernanceState,
pub reason: TransitionReason,
pub at: Instant,
pub actionable: bool,
}
#[derive(Clone, Debug)]
pub(crate) struct ReaperTickResult {
pub decisions: Vec<ReaperDecision>,
pub median_log_ratio: Option<f64>,
pub mad: Option<f64>,
pub threshold: Option<f64>,
pub capacity_ceiling_binding: bool,
pub sample_size: usize,
pub skip_reason: Option<SkipReason>,
}
pub(crate) struct GovernanceManager {
scores: DashMap<ContractInstanceId, ContractScore>,
config: GovernanceConfig,
time_source: Arc<dyn TimeSource + Send + Sync>,
latest_tick: parking_lot::RwLock<Option<NetworkNormsCache>>,
}
#[derive(Clone, Debug)]
pub(crate) struct NetworkNormsCache {
pub median_log_ratio: Option<f64>,
pub mad: Option<f64>,
pub threshold: Option<f64>,
pub capacity_ceiling_binding: bool,
pub sample_size: usize,
pub skip_reason: Option<SkipReason>,
pub at: Instant,
}
impl GovernanceManager {
pub(crate) fn new(
config: GovernanceConfig,
time_source: Arc<dyn TimeSource + Send + Sync>,
) -> Self {
Self {
scores: DashMap::new(),
config,
time_source,
latest_tick: parking_lot::RwLock::new(None),
}
}
pub(crate) fn mode(&self) -> GovernanceMode {
self.config.mode
}
pub(crate) fn outlier_min_samples(&self) -> usize {
self.config.outlier.min_samples
}
pub(crate) fn benefit_floor(&self) -> f64 {
self.config.benefit_floor
}
pub(crate) fn ban_ttl(&self) -> Duration {
self.config.ban_ttl
}
pub(crate) fn latest_norms(&self) -> Option<NetworkNormsCache> {
self.latest_tick.read().clone()
}
pub(crate) fn iter_scores(&self) -> Vec<(ContractInstanceId, ContractScore)> {
self.scores
.iter()
.map(|e| (*e.key(), e.value().clone()))
.collect()
}
pub(crate) fn tracked_ids(&self) -> std::collections::HashSet<ContractInstanceId> {
self.scores.iter().map(|e| *e.key()).collect()
}
pub(crate) fn iter_flagged_scores(&self) -> Vec<(ContractInstanceId, ContractScore)> {
self.scores
.iter()
.filter(|e| e.value().state.is_flagged())
.map(|e| (*e.key(), e.value().clone()))
.collect()
}
pub(crate) fn ingest_cost(&self, key: ContractInstanceId, amount: f64) {
if !amount.is_finite() || amount < 0.0 {
return;
}
let now = self.time_source.now();
let mut entry = self
.scores
.entry(key)
.or_insert_with(|| ContractScore::new(now));
entry.cost_used += amount;
}
pub(crate) fn score_snapshot(&self, key: &ContractInstanceId) -> Option<ContractScore> {
self.scores.get(key).map(|s| s.clone())
}
pub(crate) fn len(&self) -> usize {
self.scores.len()
}
pub(crate) fn tick(
&self,
tick_interval: Duration,
benefits: &HashMap<ContractInstanceId, f64>,
) -> ReaperTickResult {
if matches!(self.config.mode, GovernanceMode::Off) {
return ReaperTickResult {
decisions: Vec::new(),
median_log_ratio: None,
mad: None,
threshold: None,
capacity_ceiling_binding: false,
sample_size: 0,
skip_reason: None,
};
}
let now = self.time_source.now();
let mut ban_lifted: Vec<ContractInstanceId> = Vec::new();
let mut evicted_lifted: Vec<ContractInstanceId> = Vec::new();
for mut entry in self.scores.iter_mut() {
let key = *entry.key();
entry.benefit_score = benefits.get(&key).copied().unwrap_or(0.0);
entry.decay(tick_interval, self.config.decay_half_life);
match entry.state {
GovernanceState::Banned => {
let elapsed = now.saturating_duration_since(entry.last_transition);
if elapsed >= self.config.ban_ttl {
ban_lifted.push(*entry.key());
}
}
GovernanceState::Evicted => {
let elapsed = now.saturating_duration_since(entry.last_transition);
if elapsed >= self.config.evicted_ttl {
evicted_lifted.push(*entry.key());
}
}
GovernanceState::Normal
| GovernanceState::Borderline
| GovernanceState::WouldEvict => {}
}
}
let actionable_samples: HashMap<ContractInstanceId, f64> = self
.scores
.iter()
.filter_map(|entry| {
let age = now.saturating_duration_since(entry.first_seen);
if age < self.config.ramp_up {
return None;
}
if matches!(
entry.state,
GovernanceState::Banned | GovernanceState::Evicted
) {
return None;
}
entry
.log_ratio(self.config.benefit_floor)
.map(|r| (*entry.key(), r))
})
.collect();
let outlier_result: OutlierResult<ContractInstanceId> = detect_outliers(
&actionable_samples,
|&r| Some(r),
&self.config.outlier,
self.config.capacity_ceiling_log,
);
let mut decisions: Vec<ReaperDecision> = Vec::new();
let flagged: std::collections::HashSet<ContractInstanceId> =
outlier_result.flagged.iter().cloned().collect();
let actionable = self.config.mode.evicts();
let borderline_cutoff = match (outlier_result.median_log_ratio, outlier_result.mad) {
(Some(m), Some(mad)) if mad > f64::EPSILON => Some(
m + self.config.borderline_mad_units
* crate::governance::MAD_GAUSSIAN_CONSISTENCY
* mad,
),
_ => None,
};
let mad_collapsed = borderline_cutoff.is_none();
for (key, log_ratio) in actionable_samples.iter() {
let Some(mut entry) = self.scores.get_mut(key) else {
continue;
};
let from = entry.state;
let next = if flagged.contains(key) {
if actionable {
let recently_evicted = entry.history.iter().rev().any(|t| {
matches!(t.reason, TransitionReason::Evicted)
&& now.saturating_duration_since(t.at) <= self.config.ban_window
});
if recently_evicted {
GovernanceState::Banned
} else {
GovernanceState::Evicted
}
} else {
GovernanceState::WouldEvict
}
} else if from == GovernanceState::Evicted {
continue;
} else if mad_collapsed {
continue;
} else if borderline_cutoff.is_some_and(|c| *log_ratio >= c) {
GovernanceState::Borderline
} else {
GovernanceState::Normal
};
if next == from {
continue;
}
let reason = match (from, next) {
(_, GovernanceState::Borderline) => TransitionReason::BorderlineEntered,
(_, GovernanceState::WouldEvict) => TransitionReason::ThresholdCrossed,
(_, GovernanceState::Evicted) => TransitionReason::Evicted,
(_, GovernanceState::Banned) => TransitionReason::BanTriggered,
(_, GovernanceState::Normal) => TransitionReason::Recovered,
};
entry.record_transition(now, next, reason);
let actionable_decision =
actionable && matches!(next, GovernanceState::Evicted | GovernanceState::Banned);
decisions.push(ReaperDecision {
key: *key,
from,
to: next,
reason,
at: now,
actionable: actionable_decision,
});
}
for key in ban_lifted {
if let Some(mut entry) = self.scores.get_mut(&key) {
if entry.state == GovernanceState::Banned {
let from = entry.state;
entry.record_transition(
now,
GovernanceState::Normal,
TransitionReason::BanLifted,
);
decisions.push(ReaperDecision {
key,
from,
to: GovernanceState::Normal,
reason: TransitionReason::BanLifted,
at: now,
actionable,
});
}
}
}
for key in evicted_lifted {
if flagged.contains(&key) {
continue;
}
if let Some(mut entry) = self.scores.get_mut(&key) {
if entry.state == GovernanceState::Evicted {
let from = entry.state;
entry.record_transition(
now,
GovernanceState::Normal,
TransitionReason::Recovered,
);
decisions.push(ReaperDecision {
key,
from,
to: GovernanceState::Normal,
reason: TransitionReason::Recovered,
at: now,
actionable,
});
}
}
}
*self.latest_tick.write() = Some(NetworkNormsCache {
median_log_ratio: outlier_result.median_log_ratio,
mad: outlier_result.mad,
threshold: outlier_result.threshold,
capacity_ceiling_binding: outlier_result.capacity_ceiling_binding,
sample_size: outlier_result.sample_size,
skip_reason: outlier_result.skip_reason,
at: now,
});
ReaperTickResult {
decisions,
median_log_ratio: outlier_result.median_log_ratio,
mad: outlier_result.mad,
threshold: outlier_result.threshold,
capacity_ceiling_binding: outlier_result.capacity_ceiling_binding,
sample_size: outlier_result.sample_size,
skip_reason: outlier_result.skip_reason,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn instant_t(offset_ms: u64) -> Instant {
Instant::now() + Duration::from_millis(offset_ms)
}
#[test]
fn new_score_starts_normal_with_first_seen_history() {
let now = instant_t(0);
let s = ContractScore::new(now);
assert_eq!(s.state, GovernanceState::Normal);
assert_eq!(s.cost_used, 0.0);
assert_eq!(s.benefit_score, 0.0);
assert_eq!(s.history.len(), 1);
assert!(matches!(s.history[0].reason, TransitionReason::FirstSeen));
assert_eq!(s.first_seen, now);
assert_eq!(s.last_transition, now);
}
const TEST_BENEFIT_FLOOR: f64 = 0.05;
#[test]
fn log_ratio_none_when_cost_zero() {
let mut s = ContractScore::new(instant_t(0));
assert_eq!(s.log_ratio(TEST_BENEFIT_FLOOR), None);
s.benefit_score = 10.0;
assert_eq!(s.log_ratio(TEST_BENEFIT_FLOOR), None);
}
#[test]
fn log_ratio_high_when_cost_but_zero_benefit() {
let mut s = ContractScore::new(instant_t(0));
s.cost_used = 5.0;
s.benefit_score = 0.0;
let r = s.log_ratio(TEST_BENEFIT_FLOOR).expect("must be Some");
assert!((r - 100.0_f64.log10()).abs() < 1e-9, "got {r}");
assert!(r > 1.0, "zero-benefit cost-positive ratio must be high");
}
#[test]
fn log_ratio_computes_log10_of_cost_over_benefit() {
let mut s = ContractScore::new(instant_t(0));
s.cost_used = 10.0;
s.benefit_score = 1.0;
assert!((s.log_ratio(TEST_BENEFIT_FLOOR).unwrap() - 1.0).abs() < 1e-9);
s.cost_used = 0.1;
s.benefit_score = 10.0;
assert!((s.log_ratio(TEST_BENEFIT_FLOOR).unwrap() - (-2.0)).abs() < 1e-9);
}
#[test]
fn log_ratio_floor_engages_only_below_floor() {
let mut s = ContractScore::new(instant_t(0));
s.cost_used = 1.0;
s.benefit_score = 0.01;
assert!((s.log_ratio(TEST_BENEFIT_FLOOR).unwrap() - 20.0_f64.log10()).abs() < 1e-9);
s.benefit_score = 1.0;
assert!(s.log_ratio(TEST_BENEFIT_FLOOR).unwrap().abs() < 1e-9);
}
#[test]
fn one_downstream_scores_strictly_below_zero_beneficiaries() {
let floor = GovernanceConfig::default().benefit_floor;
assert!(
floor < 0.1,
"default benefit_floor must stay below FORWARDED_DEMAND_WEIGHT (0.1)"
);
let cost = 5.0;
let mut one_sub = ContractScore::new(instant_t(0));
one_sub.cost_used = cost;
one_sub.benefit_score = 0.1;
let one_sub_ratio = one_sub.log_ratio(floor).expect("must be Some");
let mut zero = ContractScore::new(instant_t(0));
zero.cost_used = cost;
zero.benefit_score = 0.0;
let zero_ratio = zero.log_ratio(floor).expect("must be Some");
assert!(
one_sub_ratio < zero_ratio,
"one downstream subscriber (ratio {one_sub_ratio}) must score strictly \
lower than zero beneficiaries (ratio {zero_ratio})"
);
}
#[test]
fn record_transition_updates_state_and_history() {
let mut s = ContractScore::new(instant_t(0));
s.record_transition(
instant_t(100),
GovernanceState::Borderline,
TransitionReason::BorderlineEntered,
);
assert_eq!(s.state, GovernanceState::Borderline);
assert_eq!(s.history.len(), 2);
let last = s.history.last().unwrap();
assert_eq!(last.from, GovernanceState::Normal);
assert_eq!(last.to, GovernanceState::Borderline);
assert!(matches!(last.reason, TransitionReason::BorderlineEntered));
}
#[test]
fn record_transition_skips_no_op_same_state() {
let mut s = ContractScore::new(instant_t(0));
s.record_transition(
instant_t(100),
GovernanceState::Borderline,
TransitionReason::BorderlineEntered,
);
let initial_len = s.history.len();
s.record_transition(
instant_t(200),
GovernanceState::Borderline,
TransitionReason::BorderlineEntered,
);
assert_eq!(s.history.len(), initial_len);
}
#[test]
fn history_capped_preserves_first_seen() {
let mut s = ContractScore::new(instant_t(0));
let mut state_toggle = false;
for i in 1..(MAX_TRANSITIONS_PER_CONTRACT + 10) {
state_toggle = !state_toggle;
let to = if state_toggle {
GovernanceState::Borderline
} else {
GovernanceState::Normal
};
let reason = if state_toggle {
TransitionReason::BorderlineEntered
} else {
TransitionReason::Recovered
};
s.record_transition(instant_t(i as u64 * 100), to, reason);
}
assert!(matches!(s.history[0].reason, TransitionReason::FirstSeen));
assert_eq!(s.history.len(), MAX_TRANSITIONS_PER_CONTRACT);
}
#[test]
fn decay_reduces_cost_only_not_benefit() {
let mut s = ContractScore::new(instant_t(0));
s.cost_used = 100.0;
s.benefit_score = 10.0;
s.decay(Duration::from_secs(60), Duration::from_secs(60));
assert!((s.cost_used - 50.0).abs() < 1e-9);
assert!((s.benefit_score - 10.0).abs() < 1e-9);
}
#[test]
fn decay_zero_intervals_no_op() {
let mut s = ContractScore::new(instant_t(0));
s.cost_used = 100.0;
s.benefit_score = 10.0;
s.decay(Duration::ZERO, Duration::from_secs(60));
assert_eq!(s.cost_used, 100.0);
s.decay(Duration::from_secs(60), Duration::ZERO);
assert_eq!(s.cost_used, 100.0);
assert_eq!(s.benefit_score, 10.0);
}
#[test]
fn governance_state_predicates() {
assert!(!GovernanceState::Normal.is_flagged());
assert!(GovernanceState::Borderline.is_flagged());
assert!(GovernanceState::WouldEvict.is_flagged());
assert!(GovernanceState::Evicted.is_flagged());
assert!(GovernanceState::Banned.is_flagged());
assert!(!GovernanceState::Normal.blocks_operations());
assert!(!GovernanceState::Borderline.blocks_operations());
assert!(!GovernanceState::WouldEvict.blocks_operations());
assert!(!GovernanceState::Evicted.blocks_operations());
assert!(GovernanceState::Banned.blocks_operations());
}
#[test]
fn governance_mode_evicts_only_in_enforce() {
assert!(!GovernanceMode::Off.evicts());
assert!(!GovernanceMode::DryRun.evicts());
assert!(GovernanceMode::Enforce.evicts());
}
use crate::util::time_source::MockTimeSource;
use freenet_stdlib::prelude::ContractInstanceId;
fn mk_key(seed: u8) -> ContractInstanceId {
ContractInstanceId::new([seed; 32])
}
fn benefits(pairs: &[(ContractInstanceId, f64)]) -> HashMap<ContractInstanceId, f64> {
pairs.iter().copied().collect()
}
fn honest_benefits(
n: u8,
honest_benefit: f64,
abuser: Option<(ContractInstanceId, f64)>,
) -> HashMap<ContractInstanceId, f64> {
let mut m: HashMap<ContractInstanceId, f64> =
(0..n).map(|i| (mk_key(i), honest_benefit)).collect();
if let Some((a, b)) = abuser {
m.insert(a, b);
}
m
}
fn jittered_honest_benefits(
abuser: ContractInstanceId,
abuser_benefit: f64,
) -> HashMap<ContractInstanceId, f64> {
let mut m: HashMap<ContractInstanceId, f64> = (0..30u8)
.map(|i| {
let jitter = (i as f64 - 15.0) * 0.01;
(mk_key(i), 1.0 + jitter)
})
.collect();
m.insert(abuser, abuser_benefit);
m
}
#[derive(Debug)]
struct SharedTs(std::sync::Mutex<MockTimeSource>);
impl SharedTs {
fn new() -> Arc<Self> {
Arc::new(Self(std::sync::Mutex::new(MockTimeSource::new(
Instant::now(),
))))
}
fn advance(&self, d: Duration) {
self.0.lock().unwrap().advance_time(d);
}
}
impl TimeSource for SharedTs {
fn now(&self) -> Instant {
self.0.lock().unwrap().now()
}
}
fn mk_mgr_shared(mode: GovernanceMode) -> (GovernanceManager, Arc<SharedTs>) {
let ts = SharedTs::new();
let outlier = OutlierConfig {
min_samples: 5,
trim_fraction: 0.0,
..Default::default()
};
let config = GovernanceConfig {
mode,
outlier,
ramp_up: Duration::from_secs(1),
..Default::default()
};
let ts_dyn: Arc<dyn TimeSource + Send + Sync> = ts.clone();
let mgr = GovernanceManager::new(config, ts_dyn);
(mgr, ts)
}
#[test]
fn new_manager_is_empty() {
let (mgr, _ts) = mk_mgr_shared(GovernanceMode::DryRun);
assert_eq!(mgr.len(), 0);
}
#[test]
fn ingest_cost_creates_score_on_first_observation() {
let (mgr, _ts) = mk_mgr_shared(GovernanceMode::DryRun);
let k = mk_key(1);
mgr.ingest_cost(k, 10.0);
assert_eq!(mgr.len(), 1);
let s = mgr.score_snapshot(&k).unwrap();
assert_eq!(s.cost_used, 10.0);
assert_eq!(s.benefit_score, 0.0);
assert_eq!(s.state, GovernanceState::Normal);
assert_eq!(s.history.len(), 1);
}
#[test]
fn benefit_snapshot_overwrites_not_accumulates() {
let (mgr, _ts) = mk_mgr_shared(GovernanceMode::DryRun);
let k = mk_key(1);
mgr.ingest_cost(k, 1.0);
mgr.tick(Duration::from_secs(1), &benefits(&[(k, 3.0)]));
assert_eq!(mgr.score_snapshot(&k).unwrap().benefit_score, 3.0);
mgr.tick(Duration::from_secs(1), &benefits(&[(k, 1.5)]));
assert_eq!(mgr.score_snapshot(&k).unwrap().benefit_score, 1.5);
mgr.tick(Duration::from_secs(1), &benefits(&[]));
assert_eq!(mgr.score_snapshot(&k).unwrap().benefit_score, 0.0);
}
#[test]
fn ingest_cost_rejects_non_finite_or_negative_amounts() {
let (mgr, _ts) = mk_mgr_shared(GovernanceMode::DryRun);
let k = mk_key(1);
mgr.ingest_cost(k, -5.0); mgr.ingest_cost(k, f64::NAN);
mgr.ingest_cost(k, f64::INFINITY);
assert_eq!(mgr.len(), 0);
}
#[test]
fn off_mode_tick_produces_no_decisions() {
let (mgr, ts) = mk_mgr_shared(GovernanceMode::Off);
let k = mk_key(1);
mgr.ingest_cost(k, 1000.0);
ts.advance(Duration::from_secs(60));
let result = mgr.tick(Duration::from_secs(1), &benefits(&[(k, 0.1)]));
assert!(result.decisions.is_empty());
assert_eq!(result.sample_size, 0);
}
#[test]
fn zero_beneficiary_contract_with_cost_is_flagged() {
let (mgr, ts) = mk_mgr_shared(GovernanceMode::Enforce);
for i in 0..30 {
let jitter = (i as f64 - 15.0) * 0.01;
mgr.ingest_cost(mk_key(i), 0.1 + jitter * 0.05);
}
let abuser = mk_key(99);
mgr.ingest_cost(abuser, 0.1);
ts.advance(Duration::from_secs(2));
let result = mgr.tick(Duration::from_millis(100), &honest_benefits(30, 1.0, None));
let abuser_decision = result
.decisions
.iter()
.find(|d| d.key == abuser)
.expect("zero-beneficiary cost-positive contract MUST be flagged");
assert_eq!(abuser_decision.to, GovernanceState::Evicted);
assert!(
!result
.decisions
.iter()
.any(|d| d.key == mk_key(0) && d.to.is_flagged())
);
}
#[test]
fn high_cost_high_benefit_contract_is_not_flagged() {
let (mgr, ts) = mk_mgr_shared(GovernanceMode::Enforce);
for i in 0..30 {
let jitter = (i as f64 - 15.0) * 0.01;
mgr.ingest_cost(mk_key(i), 0.1 + jitter * 0.05);
}
let popular = mk_key(99);
mgr.ingest_cost(popular, 10.0);
ts.advance(Duration::from_secs(2));
let result = mgr.tick(
Duration::from_millis(100),
&honest_benefits(30, 1.0, Some((popular, 1000.0))),
);
let popular_flagged = result
.decisions
.iter()
.any(|d| d.key == popular && d.to.is_flagged());
assert!(
!popular_flagged,
"high-cost/high-benefit popular contract must NOT be flagged; decisions: {:?}",
result.decisions
);
}
#[test]
fn ramp_up_excludes_new_contracts_from_detection() {
let (mgr, _ts) = mk_mgr_shared(GovernanceMode::DryRun);
for i in 0..10 {
mgr.ingest_cost(mk_key(i), 1.0);
}
let abuser = mk_key(99);
mgr.ingest_cost(abuser, 100_000.0);
let result = mgr.tick(
Duration::from_secs(1),
&honest_benefits(10, 1.0, Some((abuser, 0.1))),
);
assert!(result.decisions.is_empty());
assert_eq!(result.sample_size, 0);
}
#[test]
fn detects_outlier_after_ramp_up() {
let (mgr, ts) = mk_mgr_shared(GovernanceMode::DryRun);
for i in 0..30 {
let jitter = (i as f64 - 15.0) * 0.01;
mgr.ingest_cost(mk_key(i), 0.1 + jitter * 0.05);
}
let abuser = mk_key(99);
mgr.ingest_cost(abuser, 100.0);
ts.advance(Duration::from_secs(2));
let result = mgr.tick(
Duration::from_millis(100),
&jittered_honest_benefits(abuser, 1.0),
);
assert_eq!(result.sample_size, 31);
let flagged_keys: Vec<_> = result.decisions.iter().map(|d| d.key).collect();
assert!(flagged_keys.contains(&abuser));
assert!(!flagged_keys.contains(&mk_key(0)));
}
#[test]
fn dry_run_marks_would_evict_not_evicted() {
let (mgr, ts) = mk_mgr_shared(GovernanceMode::DryRun);
for i in 0..30 {
let jitter = (i as f64 - 15.0) * 0.01;
mgr.ingest_cost(mk_key(i), 0.1 + jitter * 0.05);
}
let abuser = mk_key(99);
mgr.ingest_cost(abuser, 100.0);
ts.advance(Duration::from_secs(2));
let result = mgr.tick(
Duration::from_millis(100),
&jittered_honest_benefits(abuser, 1.0),
);
let abuser_decision = result.decisions.iter().find(|d| d.key == abuser).unwrap();
assert_eq!(abuser_decision.to, GovernanceState::WouldEvict);
assert!(!abuser_decision.actionable);
}
#[test]
fn enforce_marks_evicted_first_time() {
let (mgr, ts) = mk_mgr_shared(GovernanceMode::Enforce);
for i in 0..30 {
let jitter = (i as f64 - 15.0) * 0.01;
mgr.ingest_cost(mk_key(i), 0.1 + jitter * 0.05);
}
let abuser = mk_key(99);
mgr.ingest_cost(abuser, 100.0);
ts.advance(Duration::from_secs(2));
let result = mgr.tick(
Duration::from_millis(100),
&jittered_honest_benefits(abuser, 1.0),
);
let abuser_decision = result.decisions.iter().find(|d| d.key == abuser).unwrap();
assert_eq!(abuser_decision.to, GovernanceState::Evicted);
assert!(abuser_decision.actionable);
}
#[test]
fn second_eviction_within_ban_window_triggers_ban() {
let (mgr, ts) = mk_mgr_shared(GovernanceMode::Enforce);
for i in 0..30 {
let jitter = (i as f64 - 15.0) * 0.01;
mgr.ingest_cost(mk_key(i), 0.1 + jitter * 0.05);
}
let abuser = mk_key(99);
mgr.ingest_cost(abuser, 100.0);
ts.advance(Duration::from_secs(2));
let r1 = mgr.tick(
Duration::from_millis(100),
&jittered_honest_benefits(abuser, 1.0),
);
assert_eq!(
r1.decisions.iter().find(|d| d.key == abuser).unwrap().to,
GovernanceState::Evicted
);
ts.advance(Duration::from_secs(15 * 60 + 1));
for i in 0..30 {
let jitter = (i as f64 - 15.0) * 0.01;
mgr.ingest_cost(mk_key(i), 0.1 + jitter * 0.05);
}
let recovery = mgr.tick(
Duration::from_millis(100),
&jittered_honest_benefits(abuser, 1.0),
);
let recovered = recovery
.decisions
.iter()
.find(|d| d.key == abuser)
.expect("evicted_lifted sweep must recover the abuser");
assert_eq!(recovered.from, GovernanceState::Evicted);
assert_eq!(recovered.to, GovernanceState::Normal);
assert!(matches!(recovered.reason, TransitionReason::Recovered));
mgr.ingest_cost(abuser, 100.0);
mgr.ingest_cost(abuser, 100.0);
ts.advance(Duration::from_secs(1));
let r2 = mgr.tick(
Duration::from_millis(100),
&jittered_honest_benefits(abuser, 1.0),
);
let second = r2.decisions.iter().find(|d| d.key == abuser).unwrap();
assert_eq!(second.to, GovernanceState::Banned);
assert!(matches!(second.reason, TransitionReason::BanTriggered));
}
#[test]
fn ban_ttl_expires_back_to_normal() {
let (mgr, ts) = mk_mgr_shared(GovernanceMode::Enforce);
for i in 0..30 {
let jitter = (i as f64 - 15.0) * 0.01;
mgr.ingest_cost(mk_key(i), 0.1 + jitter * 0.05);
}
let abuser = mk_key(99);
mgr.ingest_cost(abuser, 100.0);
ts.advance(Duration::from_secs(2));
mgr.tick(
Duration::from_millis(100),
&jittered_honest_benefits(abuser, 1.0),
);
ts.advance(Duration::from_secs(15 * 60 + 1));
for i in 0..30 {
let jitter = (i as f64 - 15.0) * 0.01;
mgr.ingest_cost(mk_key(i), 0.1 + jitter * 0.05);
}
mgr.tick(
Duration::from_millis(100),
&jittered_honest_benefits(abuser, 1.0),
);
mgr.ingest_cost(abuser, 100.0);
mgr.ingest_cost(abuser, 100.0);
ts.advance(Duration::from_secs(1));
mgr.tick(
Duration::from_millis(100),
&jittered_honest_benefits(abuser, 1.0),
);
ts.advance(Duration::from_secs(60 * 60 + 1));
let result = mgr.tick(
Duration::from_millis(100),
&jittered_honest_benefits(abuser, 1.0),
);
let lifted = result.decisions.iter().find(|d| d.key == abuser).unwrap();
assert_eq!(lifted.from, GovernanceState::Banned);
assert_eq!(lifted.to, GovernanceState::Normal);
assert!(matches!(lifted.reason, TransitionReason::BanLifted));
}
#[test]
fn governance_to_ban_list_end_to_end() {
use crate::ring::Ring;
use crate::ring::contract_ban_list::ContractBanList;
let (mgr, ts) = mk_mgr_shared(GovernanceMode::Enforce);
let ts_dyn: Arc<dyn TimeSource + Send + Sync> = ts.clone();
let bl = ContractBanList::new(ts_dyn);
for i in 0..30 {
let jitter = (i as f64 - 15.0) * 0.01;
mgr.ingest_cost(mk_key(i), 0.1 + jitter * 0.05);
}
let abuser = mk_key(99);
mgr.ingest_cost(abuser, 100.0);
ts.advance(Duration::from_secs(2));
let r1 = mgr.tick(
Duration::from_millis(100),
&jittered_honest_benefits(abuser, 1.0),
);
Ring::apply_ban_decisions(&bl, &r1.decisions, ts.now() + mgr.ban_ttl());
assert!(
!bl.is_banned(&abuser),
"first eviction is not a ban — abuser must not yet be on the list"
);
ts.advance(Duration::from_secs(15 * 60 + 1));
for i in 0..30 {
let jitter = (i as f64 - 15.0) * 0.01;
mgr.ingest_cost(mk_key(i), 0.1 + jitter * 0.05);
}
let r2 = mgr.tick(
Duration::from_millis(100),
&jittered_honest_benefits(abuser, 1.0),
);
Ring::apply_ban_decisions(&bl, &r2.decisions, ts.now() + mgr.ban_ttl());
assert!(
!bl.is_banned(&abuser),
"recovery does not ban — abuser must still be off the list"
);
mgr.ingest_cost(abuser, 100.0);
mgr.ingest_cost(abuser, 100.0);
ts.advance(Duration::from_secs(1));
let r3 = mgr.tick(
Duration::from_millis(100),
&jittered_honest_benefits(abuser, 1.0),
);
let triggered = r3
.decisions
.iter()
.find(|d| d.key == abuser && matches!(d.reason, TransitionReason::BanTriggered))
.expect("second eviction within ban_window must emit BanTriggered");
assert!(
triggered.actionable,
"Enforce-mode BanTriggered must be actionable"
);
Ring::apply_ban_decisions(&bl, &r3.decisions, ts.now() + mgr.ban_ttl());
assert!(
bl.is_banned(&abuser),
"after BanTriggered the abuser must land on the ban list — \
this is the wire-boundary enforcement signal for Phase 7"
);
ts.advance(Duration::from_secs(60 * 60 + 1));
let r4 = mgr.tick(
Duration::from_millis(100),
&jittered_honest_benefits(abuser, 1.0),
);
let lifted = r4
.decisions
.iter()
.find(|d| d.key == abuser && matches!(d.reason, TransitionReason::BanLifted))
.expect("after ban_ttl, BanLifted must fire");
assert!(
lifted.actionable,
"Enforce-mode BanLifted must be actionable"
);
Ring::apply_ban_decisions(&bl, &r4.decisions, ts.now() + mgr.ban_ttl());
assert!(
!bl.is_banned(&abuser),
"after BanLifted the abuser must be removed from the ban list"
);
}
#[test]
fn borderline_state_for_contract_above_borderline_below_threshold() {
let (mgr, ts) = mk_mgr_shared(GovernanceMode::DryRun);
for i in 0..30 {
let jitter = (i as f64 - 15.0) * 0.02;
mgr.ingest_cost(mk_key(i), 0.1 + jitter * 0.05);
}
let borderline = mk_key(99);
mgr.ingest_cost(borderline, 0.15);
ts.advance(Duration::from_secs(2));
let result = mgr.tick(
Duration::from_millis(100),
&honest_benefits(30, 1.0, Some((borderline, 1.0))),
);
let decision = result
.decisions
.iter()
.find(|d| d.key == borderline)
.unwrap_or_else(|| {
panic!(
"borderline contract MUST produce a decision, got: {:?}",
result.decisions
)
});
assert_eq!(
decision.to,
GovernanceState::Borderline,
"expected Borderline (not WouldEvict / Normal); got {:?}. \
If this drifts to WouldEvict, the test fixture's MAD has \
tightened beyond the test's design and the cost value \
needs recalibration.",
decision.to
);
assert!(matches!(
decision.reason,
TransitionReason::BorderlineEntered
));
}
#[test]
fn snapshot_returns_clone() {
let (mgr, _ts) = mk_mgr_shared(GovernanceMode::DryRun);
let k = mk_key(1);
mgr.ingest_cost(k, 10.0);
let s1 = mgr.score_snapshot(&k).unwrap();
mgr.ingest_cost(k, 5.0);
let s2 = mgr.score_snapshot(&k).unwrap();
assert_eq!(s1.cost_used, 10.0);
assert_eq!(s2.cost_used, 15.0);
}
#[test]
fn missing_key_returns_none() {
let (mgr, _ts) = mk_mgr_shared(GovernanceMode::DryRun);
assert!(mgr.score_snapshot(&mk_key(42)).is_none());
}
#[test]
fn evicted_is_sticky_to_decay_driven_recovery() {
let (mgr, ts) = mk_mgr_shared(GovernanceMode::Enforce);
for i in 0..30 {
let jitter = (i as f64 - 15.0) * 0.01;
mgr.ingest_cost(mk_key(i), 0.1 + jitter * 0.05);
}
let abuser = mk_key(99);
mgr.ingest_cost(abuser, 100.0);
ts.advance(Duration::from_secs(2));
let r1 = mgr.tick(
Duration::from_millis(100),
&jittered_honest_benefits(abuser, 1.0),
);
let evicted_decision = r1.decisions.iter().find(|d| d.key == abuser).unwrap();
assert_eq!(evicted_decision.to, GovernanceState::Evicted);
for i in 0..30 {
let jitter = (i as f64 - 15.0) * 0.01;
mgr.ingest_cost(mk_key(i), 0.1 + jitter * 0.05);
}
ts.advance(Duration::from_secs(60));
let r2 = mgr.tick(
Duration::from_millis(100),
&jittered_honest_benefits(abuser, 21.0),
);
assert!(
!r2.decisions
.iter()
.any(|d| d.key == abuser && d.to == GovernanceState::Normal),
"Evicted contract must NOT auto-recover to Normal via decay — \
found decision: {:?}",
r2.decisions
.iter()
.filter(|d| d.key == abuser)
.collect::<Vec<_>>(),
);
let snap_state = mgr.score_snapshot(&abuser).unwrap().state;
assert!(
matches!(
snap_state,
GovernanceState::Evicted | GovernanceState::Banned
),
"abuser snapshot must still report Evicted/Banned, got {:?}",
snap_state
);
}
#[test]
fn evicted_lifts_back_to_normal_after_ban_window() {
let (mgr, ts) = mk_mgr_shared(GovernanceMode::Enforce);
for i in 0..30 {
let jitter = (i as f64 - 15.0) * 0.01;
mgr.ingest_cost(mk_key(i), 0.1 + jitter * 0.05);
}
let abuser = mk_key(99);
mgr.ingest_cost(abuser, 100.0);
ts.advance(Duration::from_secs(2));
let r1 = mgr.tick(
Duration::from_millis(100),
&jittered_honest_benefits(abuser, 1.0),
);
assert!(
r1.decisions
.iter()
.any(|d| d.key == abuser && d.to == GovernanceState::Evicted)
);
ts.advance(Duration::from_secs(60 * 60 + 1));
let r = mgr.tick(
Duration::from_millis(100),
&jittered_honest_benefits(abuser, 1.0),
);
let lift = r.decisions.iter().find(|d| d.key == abuser).unwrap();
assert_eq!(lift.from, GovernanceState::Evicted);
assert_eq!(lift.to, GovernanceState::Normal);
assert!(matches!(lift.reason, TransitionReason::Recovered));
}
#[test]
fn mad_collapse_does_not_recover_borderline_to_normal() {
let (mgr, ts) = mk_mgr_shared(GovernanceMode::DryRun);
for i in 0..30 {
let jitter = (i as f64 - 15.0) * 0.001;
mgr.ingest_cost(mk_key(i), 0.1 + jitter);
}
let borderline = mk_key(99);
mgr.ingest_cost(borderline, 1.0);
ts.advance(Duration::from_secs(2));
let _r1 = mgr.tick(
Duration::from_millis(100),
&honest_benefits(30, 1.0, Some((borderline, 1.0))),
);
let after_first_tick = mgr.score_snapshot(&borderline).unwrap().state;
assert_ne!(
after_first_tick,
GovernanceState::Normal,
"test fixture must flag the contract on first tick; got {:?}",
after_first_tick
);
let scores = mgr.iter_scores();
let max_cost = scores
.iter()
.map(|(_, s)| s.cost_used)
.fold(0.0_f64, f64::max);
let target_cost = max_cost + 1.0;
for (id, s) in &scores {
mgr.ingest_cost(*id, target_cost - s.cost_used);
}
ts.advance(Duration::from_secs(60));
let level_benefits: HashMap<ContractInstanceId, f64> =
scores.iter().map(|(id, _)| (*id, 1.0)).collect();
let r2 = mgr.tick(Duration::from_millis(100), &level_benefits);
assert_eq!(
r2.skip_reason,
Some(crate::governance::SkipReason::MadCollapsed),
"test fixture must induce MAD collapse; skip_reason={:?}, mad={:?}",
r2.skip_reason,
r2.mad,
);
assert!(
!r2.decisions
.iter()
.any(|d| d.key == borderline && d.to == GovernanceState::Normal),
"MAD-collapse must NOT recover Borderline to Normal — found: {:?}",
r2.decisions
.iter()
.filter(|d| d.key == borderline)
.collect::<Vec<_>>()
);
assert_eq!(
mgr.score_snapshot(&borderline).unwrap().state,
after_first_tick,
"borderline contract state must persist through MAD-collapse tick"
);
}
#[test]
fn transient_zero_benefit_flags_but_cannot_ban_and_recovers() {
let (mgr, ts) = mk_mgr_shared(GovernanceMode::DryRun);
for i in 0..30 {
let jitter = (i as f64 - 15.0) * 0.01;
mgr.ingest_cost(mk_key(i), 0.1 + jitter * 0.05);
}
let popular = mk_key(99);
mgr.ingest_cost(popular, 0.1);
ts.advance(Duration::from_secs(2));
let r1 = mgr.tick(
Duration::from_millis(100),
&jittered_honest_benefits(popular, 0.0),
);
let d1 = r1
.decisions
.iter()
.find(|d| d.key == popular)
.expect("popular contract must flag on the zero-benefit tick");
assert!(
matches!(
d1.to,
GovernanceState::Borderline | GovernanceState::WouldEvict
),
"single zero-benefit tick must reach Borderline/WouldEvict, got {:?}",
d1.to
);
assert_ne!(
d1.to,
GovernanceState::Banned,
"a single zero-benefit tick must NOT ban a popular contract"
);
assert!(
!r1.decisions
.iter()
.any(|d| d.key == popular && d.to == GovernanceState::Banned),
);
for i in 0..30 {
let jitter = (i as f64 - 15.0) * 0.01;
mgr.ingest_cost(mk_key(i), 0.1 + jitter * 0.05);
}
ts.advance(Duration::from_secs(60));
let r2 = mgr.tick(
Duration::from_millis(100),
&jittered_honest_benefits(popular, 1.0),
);
let recovered = r2
.decisions
.iter()
.find(|d| d.key == popular)
.expect("popular contract must transition when benefit returns");
assert_eq!(
recovered.to,
GovernanceState::Normal,
"popular contract must recover to Normal once benefit returns"
);
assert!(matches!(recovered.reason, TransitionReason::Recovered));
assert_eq!(
mgr.score_snapshot(&popular).unwrap().state,
GovernanceState::Normal
);
}
}
#[cfg(all(test, feature = "simulation_tests"))]
mod sim_e2e_tests {
use std::time::Duration;
use freenet_stdlib::prelude::ContractInstanceId;
use super::{GovernanceConfig, GovernanceMode, OutlierConfig};
use crate::node::testing_impl::{NodeLabel, ScheduledOperation, SimNetwork, SimOperation};
const NETWORK: &str = "governance-ban-chain-e2e";
const SEED: u64 = 0xB0A1_CE11_1234_5678;
fn compressed_config() -> GovernanceConfig {
GovernanceConfig {
mode: GovernanceMode::Enforce,
outlier: OutlierConfig {
min_samples: 5,
trim_fraction: 0.0,
..Default::default()
},
ramp_up: Duration::from_secs(1),
decay_half_life: Duration::from_secs(60 * 60),
ban_window: Duration::from_secs(1800),
evicted_ttl: Duration::from_secs(90),
ban_ttl: Duration::from_secs(60 * 60),
..Default::default()
}
}
#[test]
fn governance_ban_chain_end_to_end() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let mut sim = rt.block_on(async {
SimNetwork::new(
NETWORK, 1, 5, 7, 3, 10, 2, SEED,
)
.await
});
sim.use_mock_wasm = true;
sim.with_governance_config(compressed_config());
let gateway = NodeLabel::gateway(NETWORK, 0);
const NUM_HONEST: u8 = 6;
const HONEST_UPDATES: usize = 3;
const HONEST_STATE_BYTES: usize = 256;
const ABUSER_SEED: u8 = 0xAB;
const ABUSER_UPDATES: usize = 24;
const ABUSER_STATE_BYTES: usize = 8 * 1024;
let mut operations: Vec<ScheduledOperation> = Vec::new();
let mut honest_ids: Vec<ContractInstanceId> = Vec::new();
for seed in 1..=NUM_HONEST {
let contract = SimOperation::create_test_contract(seed);
let key = contract.key();
honest_ids.push(*key.id());
operations.push(ScheduledOperation::new(
gateway.clone(),
SimOperation::Put {
contract: contract.clone(),
state: SimOperation::create_test_state(seed),
subscribe: true,
},
));
let honest_bytes = HONEST_STATE_BYTES + (seed as usize) * 80;
for u in 0..HONEST_UPDATES {
operations.push(ScheduledOperation::new(
gateway.clone(),
SimOperation::Update {
key,
data: SimOperation::create_large_state(
honest_bytes,
seed.wrapping_add(u as u8).wrapping_add(1),
),
},
));
}
}
let abuser_contract = SimOperation::create_test_contract(ABUSER_SEED);
let abuser_key = abuser_contract.key();
let abuser_id = *abuser_key.id();
operations.push(ScheduledOperation::new(
gateway.clone(),
SimOperation::Put {
contract: abuser_contract.clone(),
state: SimOperation::create_test_state(ABUSER_SEED),
subscribe: true,
},
));
for u in 0..ABUSER_UPDATES {
operations.push(ScheduledOperation::new(
gateway.clone(),
SimOperation::Update {
key: abuser_key,
data: SimOperation::create_large_state(
ABUSER_STATE_BYTES,
ABUSER_SEED.wrapping_add(u as u8).wrapping_add(1),
),
},
));
}
let post_op_wait = Duration::from_secs(1200);
let sim_duration = Duration::from_secs(1800);
let result = sim.run_controlled_simulation(SEED, operations, sim_duration, post_op_wait);
assert!(
result.turmoil_result.is_ok(),
"controlled simulation must complete: {:?}",
result.turmoil_result.err()
);
let gateway_ring = result
.node_rings
.get(&gateway)
.expect("gateway Ring must have been captured (node started)");
eprintln!(
"[gov-e2e] gateway tracked {} contracts; latest norms: {:?}",
gateway_ring.governance.len(),
gateway_ring.governance.latest_norms(),
);
for (id, score) in gateway_ring.governance.iter_scores() {
eprintln!(
"[gov-e2e] contract={id} state={:?} cost={:.1} benefit={:.3} log_ratio={:?} abuser={}",
score.state,
score.cost_used,
score.benefit_score,
score.log_ratio(gateway_ring.governance.benefit_floor()),
id == abuser_id,
);
}
assert!(
gateway_ring.contract_ban_list.is_banned(&abuser_id),
"abuser contract {abuser_id} must be BANNED on the gateway after the \
evict → recover → re-evict chain; gateway governance state: {:?}",
gateway_ring
.governance
.score_snapshot(&abuser_id)
.map(|s| s.state)
);
for honest in &honest_ids {
assert!(
!gateway_ring.contract_ban_list.is_banned(honest),
"honest contract {honest} must NOT be banned (collateral damage); \
gateway governance state: {:?}",
gateway_ring
.governance
.score_snapshot(honest)
.map(|s| s.state)
);
}
let abuser_state = gateway_ring
.governance
.score_snapshot(&abuser_id)
.expect("abuser must have a governance score on the gateway")
.state;
assert_eq!(
abuser_state,
super::GovernanceState::Banned,
"abuser governance state must be Banned (terminal), got {abuser_state:?}"
);
}
#[test]
fn popular_contract_with_subscribers_not_evicted() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
const NETWORK2: &str = "governance-popular-not-evicted-e2e";
const SEED2: u64 = 0xC0FF_EE12_3456_789A;
let mut sim = rt.block_on(async {
SimNetwork::new(
NETWORK2, 1, 5, 7, 3, 10, 2, SEED2,
)
.await
});
sim.use_mock_wasm = true;
let mut cfg = compressed_config();
cfg.outlier.min_samples = 2;
sim.with_governance_config(cfg);
let gateway = NodeLabel::gateway(NETWORK2, 0);
const HEAVY_STATE_BYTES: usize = 8 * 1024;
const POPULAR_SEED: u8 = 0x70;
const ABUSER_SEED: u8 = 0xAB;
const NUM_HONEST: u8 = 6;
const HONEST_UPDATES: usize = 3;
const HONEST_STATE_BYTES: usize = 256;
let mut operations: Vec<ScheduledOperation> = Vec::new();
for seed in 1..=NUM_HONEST {
let contract = SimOperation::create_test_contract(seed);
let key = contract.key();
operations.push(ScheduledOperation::new(
gateway.clone(),
SimOperation::Put {
contract: contract.clone(),
state: SimOperation::create_test_state(seed),
subscribe: true,
},
));
let honest_bytes = HONEST_STATE_BYTES + (seed as usize) * 80;
for u in 0..HONEST_UPDATES {
operations.push(ScheduledOperation::new(
gateway.clone(),
SimOperation::Update {
key,
data: SimOperation::create_large_state(
honest_bytes,
seed.wrapping_add(u as u8).wrapping_add(1),
),
},
));
}
}
let popular_contract = SimOperation::create_test_contract(POPULAR_SEED);
let popular_key = popular_contract.key();
let popular_id = *popular_key.id();
operations.push(ScheduledOperation::new(
gateway.clone(),
SimOperation::Put {
contract: popular_contract.clone(),
state: SimOperation::create_test_state(POPULAR_SEED),
subscribe: true,
},
));
let abuser_contract = SimOperation::create_test_contract(ABUSER_SEED);
let abuser_key = abuser_contract.key();
let abuser_id = *abuser_key.id();
operations.push(ScheduledOperation::new(
gateway.clone(),
SimOperation::Put {
contract: abuser_contract.clone(),
state: SimOperation::create_test_state(ABUSER_SEED),
subscribe: true,
},
));
const CYCLES: usize = 40;
for c in 0..CYCLES {
for n in 1..=5 {
operations.push(ScheduledOperation::new(
NodeLabel::node(NETWORK2, n),
SimOperation::Subscribe {
contract_id: popular_id,
},
));
}
operations.push(ScheduledOperation::new(
gateway.clone(),
SimOperation::Update {
key: popular_key,
data: SimOperation::create_large_state(
HEAVY_STATE_BYTES,
POPULAR_SEED.wrapping_add(c as u8).wrapping_add(1),
),
},
));
operations.push(ScheduledOperation::new(
gateway.clone(),
SimOperation::Update {
key: abuser_key,
data: SimOperation::create_large_state(
HEAVY_STATE_BYTES,
ABUSER_SEED.wrapping_add(c as u8).wrapping_add(1),
),
},
));
}
let post_op_wait = Duration::from_secs(5);
let sim_duration = Duration::from_secs(2400);
let result = sim.run_controlled_simulation(SEED2, operations, sim_duration, post_op_wait);
assert!(
result.turmoil_result.is_ok(),
"controlled simulation must complete: {:?}",
result.turmoil_result.err()
);
for (label, ring) in &result.node_rings {
eprintln!(
"[gov-popular] node={label} POP down={} local={} state={:?} cost={:?} banned={} || \
ABU down={} local={} state={:?} cost={:?} banned={}",
ring.hosting_manager_downstream_subscriber_count(&popular_id),
ring.hosting_manager_local_client_count(&popular_id),
ring.governance.score_snapshot(&popular_id).map(|s| s.state),
ring.governance
.score_snapshot(&popular_id)
.map(|s| s.cost_used),
ring.contract_ban_list.is_banned(&popular_id),
ring.hosting_manager_downstream_subscriber_count(&abuser_id),
ring.hosting_manager_local_client_count(&abuser_id),
ring.governance.score_snapshot(&abuser_id).map(|s| s.state),
ring.governance
.score_snapshot(&abuser_id)
.map(|s| s.cost_used),
ring.contract_ban_list.is_banned(&abuser_id),
);
}
let (popular_host_label, popular_host_ring) = result
.node_rings
.iter()
.max_by_key(|(_, ring)| ring.hosting_manager_downstream_subscriber_count(&popular_id))
.expect("at least one node must have started");
let popular_downstream =
popular_host_ring.hosting_manager_downstream_subscriber_count(&popular_id);
assert!(
popular_downstream > 0,
"test fixture invalid: no node accumulated a live downstream subscriber \
for the popular contract — the cross-node Subscribe ops did not \
register, so this regression cannot be exercised"
);
let popular_host_cost = popular_host_ring
.governance
.score_snapshot(&popular_id)
.map(|s| s.cost_used)
.unwrap_or(0.0);
assert!(
popular_host_cost > 1000.0,
"test fixture invalid: popular host {popular_host_label} must carry heavy \
update cost for the popular contract (got {popular_host_cost})"
);
assert!(
!popular_host_ring.contract_ban_list.is_banned(&popular_id),
"popular contract {popular_id} must NOT be banned on its host \
{popular_host_label} despite heavy update cost ({popular_host_cost:.0}) — \
it has {popular_downstream} live downstream subscribers; governance \
state: {:?}",
popular_host_ring
.governance
.score_snapshot(&popular_id)
.map(|s| s.state)
);
assert_ne!(
popular_host_ring
.governance
.score_snapshot(&popular_id)
.map(|s| s.state),
Some(super::GovernanceState::Banned),
"popular contract must not reach the terminal Banned state on its host"
);
let abuser_banned_anywhere = result
.node_rings
.values()
.any(|ring| ring.contract_ban_list.is_banned(&abuser_id));
assert!(
abuser_banned_anywhere,
"abuser contract {abuser_id} (same heavy cost, NO subscribers) must be \
banned on at least one node that observed its cost"
);
let host_popular_flagged = popular_host_ring
.governance
.score_snapshot(&popular_id)
.map(|s| s.state.is_flagged())
.unwrap_or(false);
assert!(
!host_popular_flagged,
"on its host, the popular contract must remain unflagged (Normal); \
state: {:?}",
popular_host_ring
.governance
.score_snapshot(&popular_id)
.map(|s| s.state)
);
}
}