#![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) -> Option<f64> {
if self.benefit_score <= f64::EPSILON {
return None;
}
let ratio = self.cost_used / self.benefit_score;
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;
self.benefit_score *= 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 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,
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 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 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 ingest_demand(&self, key: ContractInstanceId, weight: f64) {
if !weight.is_finite() || weight <= 0.0 {
return;
}
let now = self.time_source.now();
let mut entry = self
.scores
.entry(key)
.or_insert_with(|| ContractScore::new(now));
entry.benefit_score += weight;
}
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) -> 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() {
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().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);
}
#[test]
fn log_ratio_none_when_no_benefit() {
let mut s = ContractScore::new(instant_t(0));
assert_eq!(s.log_ratio(), None);
s.cost_used = 5.0;
assert_eq!(s.log_ratio(), None);
}
#[test]
fn log_ratio_none_when_cost_zero() {
let mut s = ContractScore::new(instant_t(0));
s.benefit_score = 10.0;
assert_eq!(s.log_ratio(), None);
}
#[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().unwrap() - 1.0).abs() < 1e-9);
s.cost_used = 0.1;
s.benefit_score = 10.0;
assert!((s.log_ratio().unwrap() - (-2.0)).abs() < 1e-9);
}
#[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_and_benefit_symmetrically() {
let mut s = ContractScore::new(instant_t(0));
s.cost_used = 100.0;
s.benefit_score = 10.0;
let initial_ratio = s.cost_used / s.benefit_score;
s.decay(Duration::from_secs(60), Duration::from_secs(60));
assert!((s.cost_used - 50.0).abs() < 1e-9);
assert!((s.benefit_score - 5.0).abs() < 1e-9);
let new_ratio = s.cost_used / s.benefit_score;
assert!((new_ratio - initial_ratio).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);
}
#[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])
}
#[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 ingest_demand_creates_score_on_first_observation() {
let (mgr, _ts) = mk_mgr_shared(GovernanceMode::DryRun);
let k = mk_key(1);
mgr.ingest_demand(k, 1.5);
let s = mgr.score_snapshot(&k).unwrap();
assert_eq!(s.benefit_score, 1.5);
assert_eq!(s.cost_used, 0.0);
}
#[test]
fn ingest_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);
mgr.ingest_demand(k, 0.0); mgr.ingest_demand(k, -1.0);
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);
mgr.ingest_demand(k, 0.1);
ts.advance(Duration::from_secs(60));
let result = mgr.tick(Duration::from_secs(1));
assert!(result.decisions.is_empty());
assert_eq!(result.sample_size, 0);
}
#[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);
mgr.ingest_demand(mk_key(i), 1.0);
}
let abuser = mk_key(99);
mgr.ingest_cost(abuser, 100_000.0);
mgr.ingest_demand(abuser, 0.1);
let result = mgr.tick(Duration::from_secs(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);
mgr.ingest_demand(mk_key(i), 1.0 + jitter);
}
let abuser = mk_key(99);
mgr.ingest_cost(abuser, 100.0);
mgr.ingest_demand(abuser, 1.0);
ts.advance(Duration::from_secs(2));
let result = mgr.tick(Duration::from_millis(100));
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);
mgr.ingest_demand(mk_key(i), 1.0 + jitter);
}
let abuser = mk_key(99);
mgr.ingest_cost(abuser, 100.0);
mgr.ingest_demand(abuser, 1.0);
ts.advance(Duration::from_secs(2));
let result = mgr.tick(Duration::from_millis(100));
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);
mgr.ingest_demand(mk_key(i), 1.0 + jitter);
}
let abuser = mk_key(99);
mgr.ingest_cost(abuser, 100.0);
mgr.ingest_demand(abuser, 1.0);
ts.advance(Duration::from_secs(2));
let result = mgr.tick(Duration::from_millis(100));
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);
mgr.ingest_demand(mk_key(i), 1.0 + jitter);
}
let abuser = mk_key(99);
mgr.ingest_cost(abuser, 100.0);
mgr.ingest_demand(abuser, 1.0);
ts.advance(Duration::from_secs(2));
let r1 = mgr.tick(Duration::from_millis(100));
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);
mgr.ingest_demand(mk_key(i), 1.0 + jitter);
}
let recovery = mgr.tick(Duration::from_millis(100));
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);
mgr.ingest_demand(abuser, 1.0);
ts.advance(Duration::from_secs(1));
let r2 = mgr.tick(Duration::from_millis(100));
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);
mgr.ingest_demand(mk_key(i), 1.0 + jitter);
}
let abuser = mk_key(99);
mgr.ingest_cost(abuser, 100.0);
mgr.ingest_demand(abuser, 1.0);
ts.advance(Duration::from_secs(2));
mgr.tick(Duration::from_millis(100));
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.ingest_demand(mk_key(i), 1.0 + jitter);
}
mgr.tick(Duration::from_millis(100));
mgr.ingest_cost(abuser, 100.0);
mgr.ingest_cost(abuser, 100.0);
mgr.ingest_demand(abuser, 1.0);
ts.advance(Duration::from_secs(1));
mgr.tick(Duration::from_millis(100));
ts.advance(Duration::from_secs(60 * 60 + 1));
let result = mgr.tick(Duration::from_millis(100));
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 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);
mgr.ingest_demand(mk_key(i), 1.0);
}
let borderline = mk_key(99);
mgr.ingest_cost(borderline, 0.15);
mgr.ingest_demand(borderline, 1.0);
ts.advance(Duration::from_secs(2));
let result = mgr.tick(Duration::from_millis(100));
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);
mgr.ingest_demand(mk_key(i), 1.0 + jitter);
}
let abuser = mk_key(99);
mgr.ingest_cost(abuser, 100.0);
mgr.ingest_demand(abuser, 1.0);
ts.advance(Duration::from_secs(2));
let r1 = mgr.tick(Duration::from_millis(100));
let evicted_decision = r1.decisions.iter().find(|d| d.key == abuser).unwrap();
assert_eq!(evicted_decision.to, GovernanceState::Evicted);
for _ in 0..20 {
mgr.ingest_demand(abuser, 1.0);
}
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.ingest_demand(mk_key(i), 1.0);
}
ts.advance(Duration::from_secs(60));
let r2 = mgr.tick(Duration::from_millis(100));
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);
mgr.ingest_demand(mk_key(i), 1.0 + jitter);
}
let abuser = mk_key(99);
mgr.ingest_cost(abuser, 100.0);
mgr.ingest_demand(abuser, 1.0);
ts.advance(Duration::from_secs(2));
let r1 = mgr.tick(Duration::from_millis(100));
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));
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);
mgr.ingest_demand(mk_key(i), 1.0);
}
let borderline = mk_key(99);
mgr.ingest_cost(borderline, 1.0);
mgr.ingest_demand(borderline, 1.0);
ts.advance(Duration::from_secs(2));
let _r1 = mgr.tick(Duration::from_millis(100));
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
);
for i in 0..30 {
for _ in 0..100 {
mgr.ingest_cost(mk_key(i), 0.1);
mgr.ingest_demand(mk_key(i), 1.0);
}
}
ts.advance(Duration::from_secs(60));
let r2 = mgr.tick(Duration::from_millis(100));
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"
);
}
}