use crate::SovereignProfile;
pub const LAMBDA_MIN: f64 = 0.001;
pub const LAMBDA_MAX: f64 = 0.020;
pub const RAMP_DAYS_MIN: u32 = 30;
pub const GRACE_PERIOD_DAYS: u32 = 30;
const MICROS_PER_DAY: f64 = 86_400_000_000.0;
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct DecayConfig {
pub lambda: f64,
pub ramp_days: u32,
pub lambda_previous: f64,
pub lambda_changed_at_us: u64,
}
impl DecayConfig {
pub fn new(lambda: f64, ramp_days: u32) -> Option<Self> {
if lambda < LAMBDA_MIN || lambda > LAMBDA_MAX {
return None;
}
if ramp_days < RAMP_DAYS_MIN {
return None;
}
Some(Self {
lambda,
ramp_days,
lambda_previous: lambda,
lambda_changed_at_us: 0,
})
}
pub fn default_governance() -> Self {
Self {
lambda: 0.002,
ramp_days: 30,
lambda_previous: 0.002,
lambda_changed_at_us: 0,
}
}
pub fn emergency_pod() -> Self {
Self {
lambda: LAMBDA_MAX,
ramp_days: RAMP_DAYS_MIN,
lambda_previous: LAMBDA_MAX,
lambda_changed_at_us: 0,
}
}
pub fn land_trust() -> Self {
Self {
lambda: LAMBDA_MIN,
ramp_days: 90, lambda_previous: LAMBDA_MIN,
lambda_changed_at_us: 0,
}
}
pub fn propose_lambda_change(&self, new_lambda: f64, vote_timestamp_us: u64) -> Option<Self> {
if new_lambda < LAMBDA_MIN || new_lambda > LAMBDA_MAX {
return None;
}
Some(Self {
lambda: new_lambda,
ramp_days: self.ramp_days,
lambda_previous: self.effective_lambda(vote_timestamp_us),
lambda_changed_at_us: vote_timestamp_us,
})
}
pub fn effective_lambda(&self, now_us: u64) -> f64 {
if (self.lambda - self.lambda_previous).abs() < 1e-15 {
return self.lambda.clamp(LAMBDA_MIN, LAMBDA_MAX);
}
if now_us <= self.lambda_changed_at_us {
return self.lambda_previous.clamp(LAMBDA_MIN, LAMBDA_MAX);
}
let elapsed_days = (now_us - self.lambda_changed_at_us) as f64 / MICROS_PER_DAY;
let ramp_progress = (elapsed_days / self.ramp_days as f64).min(1.0);
let effective = self.lambda_previous + (self.lambda - self.lambda_previous) * ramp_progress;
effective.clamp(LAMBDA_MIN, LAMBDA_MAX)
}
pub fn half_life_days(&self, now_us: u64) -> f64 {
let lambda = self.effective_lambda(now_us);
if lambda > 0.0 {
core::f64::consts::LN_2 / lambda
} else {
f64::INFINITY
}
}
pub fn is_transitioning(&self, now_us: u64) -> bool {
if (self.lambda - self.lambda_previous).abs() < 1e-15 {
return false;
}
if now_us <= self.lambda_changed_at_us {
return true; }
let elapsed_days = (now_us - self.lambda_changed_at_us) as f64 / MICROS_PER_DAY;
elapsed_days < self.ramp_days as f64
}
pub fn ramp_progress(&self, now_us: u64) -> f64 {
if (self.lambda - self.lambda_previous).abs() < 1e-15 {
return 1.0;
}
if now_us <= self.lambda_changed_at_us {
return 0.0;
}
let elapsed_days = (now_us - self.lambda_changed_at_us) as f64 / MICROS_PER_DAY;
(elapsed_days / self.ramp_days as f64).min(1.0)
}
}
impl Default for DecayConfig {
fn default() -> Self {
Self::default_governance()
}
}
pub fn decay_score(raw_score: f64, lambda: f64, elapsed_days: f64) -> f64 {
if !lambda.is_finite() || !elapsed_days.is_finite() || elapsed_days < 0.0 {
return 0.0;
}
let decayed = raw_score * (-lambda * elapsed_days).exp();
decayed.clamp(0.0, 1.0)
}
pub fn apply_decay(
profile: &SovereignProfile,
last_interaction_us: u64,
now_us: u64,
config: &DecayConfig,
) -> SovereignProfile {
let lambda = config.effective_lambda(now_us);
let elapsed_days = if now_us > last_interaction_us {
(now_us - last_interaction_us) as f64 / MICROS_PER_DAY
} else {
0.0
};
let raw = profile.as_array();
let decayed: [f64; 8] = core::array::from_fn(|i| decay_score(raw[i], lambda, elapsed_days));
SovereignProfile::from_array(decayed)
}
pub fn days_until_threshold(
profile: &SovereignProfile,
weights: &crate::weights::DimensionWeights,
config: &DecayConfig,
now_us: u64,
threshold: f64,
) -> f64 {
let current_score = profile.combined_score(weights);
if current_score <= threshold {
return 0.0;
}
if threshold <= 0.0 {
return f64::INFINITY;
}
let lambda = config.effective_lambda(now_us);
if lambda <= 0.0 {
return f64::INFINITY;
}
let ratio = threshold / current_score;
if ratio >= 1.0 {
return 0.0;
}
-(ratio.ln()) / lambda
}
pub fn needs_grace_notification(
profile: &SovereignProfile,
weights: &crate::weights::DimensionWeights,
config: &DecayConfig,
now_us: u64,
threshold: f64,
) -> bool {
let days = days_until_threshold(profile, weights, config, now_us, threshold);
days < GRACE_PERIOD_DAYS as f64 && days > 0.0
}
#[cfg(test)]
mod tests {
use super::*;
use crate::weights::DimensionWeights;
const DAY_US: u64 = 86_400_000_000;
#[test]
fn lambda_zero_rejected() {
assert!(DecayConfig::new(0.0, 30).is_none());
}
#[test]
fn lambda_negative_rejected() {
assert!(DecayConfig::new(-0.001, 30).is_none());
}
#[test]
fn lambda_below_min_rejected() {
assert!(DecayConfig::new(LAMBDA_MIN - 0.0001, 30).is_none());
}
#[test]
fn lambda_above_max_rejected() {
assert!(DecayConfig::new(LAMBDA_MAX + 0.001, 30).is_none());
}
#[test]
fn ramp_below_minimum_rejected() {
assert!(DecayConfig::new(0.005, RAMP_DAYS_MIN - 1).is_none());
}
#[test]
fn lambda_at_min_accepted() {
assert!(DecayConfig::new(LAMBDA_MIN, 30).is_some());
}
#[test]
fn lambda_at_max_accepted() {
assert!(DecayConfig::new(LAMBDA_MAX, 30).is_some());
}
#[test]
fn propose_change_to_zero_rejected() {
let config = DecayConfig::default_governance();
assert!(config.propose_lambda_change(0.0, 1_000_000).is_none());
}
#[test]
fn no_decay_at_zero_elapsed() {
let score = decay_score(0.8, 0.005, 0.0);
assert!((score - 0.8).abs() < 1e-10);
}
#[test]
fn decay_reduces_score_over_time() {
let score_0 = decay_score(1.0, 0.005, 0.0);
let score_30 = decay_score(1.0, 0.005, 30.0);
let score_100 = decay_score(1.0, 0.005, 100.0);
assert!(score_0 > score_30);
assert!(score_30 > score_100);
assert!(score_100 > 0.0);
}
#[test]
fn half_life_math_is_correct() {
let lambda = 0.002;
let half_life = core::f64::consts::LN_2 / lambda; let score = decay_score(1.0, lambda, half_life);
assert!((score - 0.5).abs() < 0.01);
}
#[test]
fn decay_never_goes_negative() {
let score = decay_score(1.0, LAMBDA_MAX, 10_000.0);
assert!(score >= 0.0);
}
#[test]
fn decay_clamps_to_one() {
let score = decay_score(0.8, 0.005, -10.0);
assert_eq!(score, 0.0); }
#[test]
fn apply_decay_reduces_all_dimensions() {
let profile = SovereignProfile::from_array([0.9; 8]);
let config = DecayConfig::default_governance();
let now = 100 * DAY_US;
let last = 0;
let decayed = apply_decay(&profile, last, now, &config);
for dim in crate::SovereignDimension::ALL {
assert!(
decayed.get(dim) < 0.9,
"Dimension {:?} should decay from 0.9, got {}",
dim,
decayed.get(dim)
);
assert!(
decayed.get(dim) > 0.0,
"Dimension {:?} should not decay to zero in 100 days",
dim
);
}
}
#[test]
fn no_decay_when_just_interacted() {
let profile = SovereignProfile::from_array([0.7; 8]);
let config = DecayConfig::default_governance();
let now = 1000 * DAY_US;
let decayed = apply_decay(&profile, now, now, &config);
for dim in crate::SovereignDimension::ALL {
assert!((decayed.get(dim) - 0.7).abs() < 1e-10);
}
}
#[test]
fn effective_lambda_before_ramp_returns_previous() {
let config = DecayConfig {
lambda: 0.010,
ramp_days: 30,
lambda_previous: 0.002,
lambda_changed_at_us: 100 * DAY_US,
};
let effective = config.effective_lambda(50 * DAY_US);
assert!((effective - 0.002).abs() < 1e-10);
}
#[test]
fn effective_lambda_at_ramp_midpoint_is_interpolated() {
let config = DecayConfig {
lambda: 0.010,
ramp_days: 30,
lambda_previous: 0.002,
lambda_changed_at_us: 0,
};
let effective = config.effective_lambda(15 * DAY_US);
let expected = 0.002 + (0.010 - 0.002) * 0.5;
assert!((effective - expected).abs() < 1e-6);
}
#[test]
fn effective_lambda_after_ramp_returns_target() {
let config = DecayConfig {
lambda: 0.010,
ramp_days: 30,
lambda_previous: 0.002,
lambda_changed_at_us: 0,
};
let effective = config.effective_lambda(60 * DAY_US);
assert!((effective - 0.010).abs() < 1e-10);
}
#[test]
fn effective_lambda_clamped_during_ramp() {
let config = DecayConfig {
lambda: LAMBDA_MIN,
ramp_days: 30,
lambda_previous: LAMBDA_MAX,
lambda_changed_at_us: 0,
};
let effective = config.effective_lambda(15 * DAY_US);
assert!(effective >= LAMBDA_MIN);
assert!(effective <= LAMBDA_MAX);
}
#[test]
fn is_transitioning_during_ramp() {
let config = DecayConfig {
lambda: 0.010,
ramp_days: 30,
lambda_previous: 0.002,
lambda_changed_at_us: 0,
};
assert!(config.is_transitioning(15 * DAY_US));
assert!(!config.is_transitioning(31 * DAY_US));
}
#[test]
fn half_life_default_governance() {
let config = DecayConfig::default_governance();
let hl = config.half_life_days(0);
assert!((hl - 346.57).abs() < 1.0);
}
#[test]
fn half_life_emergency_pod() {
let config = DecayConfig::emergency_pod();
let hl = config.half_life_days(0);
assert!((hl - 34.66).abs() < 1.0);
}
#[test]
fn half_life_land_trust() {
let config = DecayConfig::land_trust();
let hl = config.half_life_days(0);
assert!((hl - 693.15).abs() < 1.0);
}
#[test]
fn days_until_threshold_positive_when_above() {
let profile = SovereignProfile::from_array([0.8; 8]);
let weights = DimensionWeights::equal();
let config = DecayConfig::default_governance();
let threshold = 0.4;
let days = days_until_threshold(&profile, &weights, &config, 0, threshold);
assert!(days > 0.0);
assert!(days < 1000.0);
assert!((days - 346.6).abs() < 1.0);
}
#[test]
fn days_until_threshold_zero_when_already_below() {
let profile = SovereignProfile::from_array([0.2; 8]);
let weights = DimensionWeights::equal();
let config = DecayConfig::default_governance();
let days = days_until_threshold(&profile, &weights, &config, 0, 0.4);
assert_eq!(days, 0.0);
}
#[test]
fn grace_notification_triggers_when_close_to_threshold() {
let profile = SovereignProfile::from_array([0.42; 8]);
let weights = DimensionWeights::equal();
let config = DecayConfig::default_governance();
let threshold = 0.4;
let needs = needs_grace_notification(&profile, &weights, &config, 0, threshold);
assert!(
needs,
"Should trigger grace notification when close to threshold"
);
}
#[test]
fn grace_notification_does_not_trigger_when_far_above() {
let profile = SovereignProfile::from_array([0.9; 8]);
let weights = DimensionWeights::equal();
let config = DecayConfig::default_governance();
let needs = needs_grace_notification(&profile, &weights, &config, 0, 0.4);
assert!(!needs, "Should not trigger when far above threshold");
}
#[test]
fn all_presets_have_valid_bounds() {
let configs = [
DecayConfig::default_governance(),
DecayConfig::emergency_pod(),
DecayConfig::land_trust(),
];
for config in &configs {
assert!(
config.lambda >= LAMBDA_MIN,
"λ below min: {}",
config.lambda
);
assert!(
config.lambda <= LAMBDA_MAX,
"λ above max: {}",
config.lambda
);
assert!(
config.ramp_days >= RAMP_DAYS_MIN,
"ramp below min: {}",
config.ramp_days
);
}
}
}