use std::time::{Duration, SystemTime};
use crate::moderation::policy::{DecayFunction, StrikePolicy};
use crate::moderation::types::{ActionRecord, ActionType};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StrikeState {
pub current_count: u32,
pub raw_total: u32,
pub revoked_count: u32,
pub decayed_count: u32,
pub active_suspension: Option<ActiveSuspension>,
pub good_standing: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActiveSuspension {
pub action_type: ActionType,
pub effective_at: SystemTime,
pub expires_at: Option<SystemTime>,
}
pub fn calculate_strike_state(
actions: &[ActionRecord],
policy: &StrikePolicy,
now: SystemTime,
) -> StrikeState {
let most_recent_suspension = actions
.iter()
.rev()
.find(|a| a.action_type.is_suspension() && a.revoked_at.is_none());
let active_suspension = most_recent_suspension.and_then(|s| {
if is_suspension_active_at(s, now) {
Some(ActiveSuspension {
action_type: s.action_type,
effective_at: s.effective_at,
expires_at: s.expires_at,
})
} else {
None
}
});
let mut raw_total: u32 = 0;
let mut revoked_count: u32 = 0;
let mut active_unrevoked_total: u32 = 0;
let mut current_count_f: f64 = 0.0;
for action in actions {
if !action.action_type.contributes_strikes() {
continue;
}
raw_total = raw_total.saturating_add(action.strike_value_applied);
if action.revoked_at.is_some() {
revoked_count = revoked_count.saturating_add(action.strike_value_applied);
continue;
}
active_unrevoked_total = active_unrevoked_total.saturating_add(action.strike_value_applied);
let elapsed = effective_elapsed_for_decay(
action.effective_at,
now,
most_recent_suspension,
policy.suspension_freezes_decay,
);
current_count_f += apply_decay(action.strike_value_applied, elapsed, policy);
}
let current_count = (current_count_f.round() as u32).min(active_unrevoked_total);
let decayed_count = active_unrevoked_total - current_count;
let good_standing = current_count <= policy.good_standing_threshold;
StrikeState {
current_count,
raw_total,
revoked_count,
decayed_count,
active_suspension,
good_standing,
}
}
fn is_suspension_active_at(action: &ActionRecord, now: SystemTime) -> bool {
action.effective_at <= now && action.expires_at.is_none_or(|exp| exp > now)
}
fn effective_elapsed_for_decay(
action_effective: SystemTime,
now: SystemTime,
most_recent_suspension: Option<&ActionRecord>,
suspension_freezes: bool,
) -> Duration {
let total = duration_or_zero(now, action_effective);
if !suspension_freezes {
return total;
}
let Some(susp) = most_recent_suspension else {
return total;
};
if is_suspension_active_at(susp, now) {
if action_effective >= susp.effective_at {
return Duration::ZERO;
}
return duration_or_zero(susp.effective_at, action_effective);
}
let Some(susp_end) = susp.expires_at.filter(|&exp| exp <= now) else {
return total;
};
let susp_dur = duration_or_zero(susp_end, susp.effective_at);
if action_effective >= susp_end {
total
} else if action_effective >= susp.effective_at {
duration_or_zero(now, susp_end)
} else {
total.checked_sub(susp_dur).unwrap_or(Duration::ZERO)
}
}
fn duration_or_zero(later: SystemTime, earlier: SystemTime) -> Duration {
later.duration_since(earlier).unwrap_or(Duration::ZERO)
}
fn apply_decay(applied: u32, elapsed: Duration, policy: &StrikePolicy) -> f64 {
let elapsed_days = duration_to_days(elapsed);
let window_days = policy.decay_window_days as f64;
if window_days <= 0.0 {
return applied as f64;
}
let applied_f = applied as f64;
match policy.decay {
DecayFunction::Linear => {
let frac = (1.0 - elapsed_days / window_days).max(0.0);
applied_f * frac
}
DecayFunction::Exponential => {
const LOG2_100: f64 = 6.643_856_189_774_724;
let half_life_days = window_days / LOG2_100;
applied_f * 0.5_f64.powf(elapsed_days / half_life_days)
}
}
}
fn duration_to_days(d: Duration) -> f64 {
d.as_secs_f64() / 86_400.0
}
#[cfg(test)]
mod tests {
use super::*;
use crate::moderation::policy::DecayFunction;
fn t0() -> SystemTime {
SystemTime::UNIX_EPOCH + Duration::from_secs(2_000_000_000)
}
fn days(n: u64) -> Duration {
Duration::from_secs(n * 86_400)
}
fn action(value: u32, kind: ActionType, at: SystemTime) -> ActionRecord {
ActionRecord {
strike_value_applied: value,
effective_at: at,
revoked_at: None,
action_type: kind,
expires_at: None,
was_dampened: false,
}
}
fn revoked_action(
value: u32,
kind: ActionType,
at: SystemTime,
revoked_at: SystemTime,
) -> ActionRecord {
ActionRecord {
strike_value_applied: value,
effective_at: at,
revoked_at: Some(revoked_at),
action_type: kind,
expires_at: None,
was_dampened: false,
}
}
fn temp_susp(value: u32, at: SystemTime, expires_at: SystemTime) -> ActionRecord {
ActionRecord {
strike_value_applied: value,
effective_at: at,
revoked_at: None,
action_type: ActionType::TempSuspension,
expires_at: Some(expires_at),
was_dampened: false,
}
}
fn indef_susp(value: u32, at: SystemTime) -> ActionRecord {
ActionRecord {
strike_value_applied: value,
effective_at: at,
revoked_at: None,
action_type: ActionType::IndefSuspension,
expires_at: None,
was_dampened: false,
}
}
fn linear_policy(threshold: u32, window_days: u32) -> StrikePolicy {
StrikePolicy {
good_standing_threshold: threshold,
dampening_curve: (1..threshold).collect(),
decay: DecayFunction::Linear,
decay_window_days: window_days,
suspension_freezes_decay: true,
cache_freshness_window_seconds: 3600,
}
}
fn exp_policy(threshold: u32, window_days: u32) -> StrikePolicy {
let mut p = linear_policy(threshold, window_days);
p.decay = DecayFunction::Exponential;
p
}
#[test]
fn empty_history_yields_all_zeros_in_good_standing() {
let s = calculate_strike_state(&[], &linear_policy(3, 90), t0());
assert_eq!(s.current_count, 0);
assert_eq!(s.raw_total, 0);
assert_eq!(s.revoked_count, 0);
assert_eq!(s.decayed_count, 0);
assert!(s.active_suspension.is_none());
assert!(s.good_standing);
}
#[test]
fn fresh_action_has_no_decay_loss() {
let now = t0();
let actions = vec![action(4, ActionType::Takedown, now)];
let s = calculate_strike_state(&actions, &linear_policy(3, 90), now);
assert_eq!(s.current_count, 4);
assert_eq!(s.raw_total, 4);
assert_eq!(s.decayed_count, 0);
assert!(!s.good_standing, "4 > threshold 3");
}
#[test]
fn linear_decay_at_half_window_loses_half() {
let now = t0();
let actions = vec![action(4, ActionType::Takedown, now - days(45))];
let s = calculate_strike_state(&actions, &linear_policy(3, 90), now);
assert_eq!(s.current_count, 2, "4 * 0.5 = 2");
assert_eq!(s.decayed_count, 2);
assert_eq!(s.raw_total, 4);
}
#[test]
fn linear_decay_at_window_boundary_is_zero() {
let now = t0();
let actions = vec![action(4, ActionType::Takedown, now - days(90))];
let s = calculate_strike_state(&actions, &linear_policy(3, 90), now);
assert_eq!(s.current_count, 0);
assert_eq!(s.decayed_count, 4);
}
#[test]
fn linear_decay_past_window_clamps_to_zero() {
let now = t0();
let actions = vec![action(4, ActionType::Takedown, now - days(180))];
let s = calculate_strike_state(&actions, &linear_policy(3, 90), now);
assert_eq!(s.current_count, 0);
}
#[test]
fn linear_decay_multiple_actions_sum_correctly() {
let now = t0();
let actions = vec![
action(4, ActionType::Takedown, now - days(90)),
action(4, ActionType::Takedown, now - days(45)),
action(4, ActionType::Takedown, now),
];
let s = calculate_strike_state(&actions, &linear_policy(10, 90), now);
assert_eq!(s.current_count, 6);
assert_eq!(s.raw_total, 12);
assert_eq!(s.decayed_count, 6);
}
#[test]
fn exponential_at_window_boundary_rounds_to_one_percent() {
let now = t0();
let actions = vec![action(100, ActionType::Takedown, now - days(90))];
let s = calculate_strike_state(&actions, &exp_policy(50, 90), now);
assert_eq!(s.current_count, 1, "100 * 0.01 ≈ 1 after rounding");
}
#[test]
fn exponential_at_six_months_rounds_to_zero() {
let now = t0();
let actions = vec![action(4, ActionType::Takedown, now - days(180))];
let s = calculate_strike_state(&actions, &exp_policy(50, 90), now);
assert_eq!(s.current_count, 0);
}
#[test]
fn exponential_at_zero_elapsed_is_full() {
let now = t0();
let actions = vec![action(8, ActionType::Takedown, now)];
let s = calculate_strike_state(&actions, &exp_policy(50, 90), now);
assert_eq!(s.current_count, 8);
}
#[test]
fn revoked_action_excluded_from_current_but_in_revoked_total() {
let now = t0();
let actions = vec![revoked_action(4, ActionType::Takedown, now, now + days(1))];
let s = calculate_strike_state(&actions, &linear_policy(3, 90), now);
assert_eq!(s.current_count, 0);
assert_eq!(s.revoked_count, 4);
assert_eq!(s.raw_total, 4);
assert_eq!(s.decayed_count, 0, "revoked actions don't decay");
}
#[test]
fn retroactively_revoked_suspension_does_not_freeze_decay() {
let now = t0();
let actions = vec![
action(4, ActionType::Takedown, now - days(90)),
revoked_action(
2,
ActionType::TempSuspension,
now - days(60),
now - days(30),
),
];
let s = calculate_strike_state(&actions, &linear_policy(10, 90), now);
assert_eq!(s.current_count, 0, "takedown fully decayed at 90 days");
assert_eq!(s.revoked_count, 2);
}
#[test]
fn active_temp_suspension_freezes_decay_on_prior_actions() {
let now = t0();
let actions = vec![
action(4, ActionType::Takedown, now - days(90)),
temp_susp(2, now - days(30), now + days(30)),
];
let s = calculate_strike_state(&actions, &linear_policy(10, 90), now);
assert_eq!(s.current_count, 3);
assert!(s.active_suspension.is_some());
assert_eq!(
s.active_suspension.as_ref().unwrap().action_type,
ActionType::TempSuspension
);
}
#[test]
fn active_indef_suspension_freezes_decay_indefinitely() {
let now = t0();
let actions = vec![
action(4, ActionType::Takedown, now - days(200)),
indef_susp(2, now - days(100)),
];
let s = calculate_strike_state(&actions, &linear_policy(10, 365), now);
assert_eq!(s.current_count, 5);
let active = s.active_suspension.expect("active_suspension set");
assert_eq!(active.action_type, ActionType::IndefSuspension);
assert!(active.expires_at.is_none());
}
#[test]
fn action_during_active_suspension_does_not_decay() {
let now = t0();
let actions = vec![
temp_susp(1, now - days(60), now + days(30)),
action(4, ActionType::Takedown, now - days(30)),
];
let s = calculate_strike_state(&actions, &linear_policy(10, 90), now);
assert_eq!(s.current_count, 5);
}
#[test]
fn expired_temp_suspension_resumes_decay_from_expiry() {
let now = t0();
let actions = vec![
action(4, ActionType::Takedown, now - days(120)),
temp_susp(1, now - days(90), now - days(30)),
];
let s = calculate_strike_state(&actions, &linear_policy(10, 90), now);
assert_eq!(s.current_count, 2);
assert!(
s.active_suspension.is_none(),
"expired suspension is not currently active"
);
}
#[test]
fn suspension_freezes_decay_false_disables_freeze() {
let now = t0();
let mut policy = linear_policy(10, 90);
policy.suspension_freezes_decay = false;
let actions = vec![
action(4, ActionType::Takedown, now - days(90)),
indef_susp(2, now - days(60)),
];
let s = calculate_strike_state(&actions, &policy, now);
assert_eq!(s.current_count, 1);
assert!(s.active_suspension.is_some());
}
#[test]
fn warning_and_note_are_excluded_even_with_nonzero_value() {
let now = t0();
let actions = vec![
action(99, ActionType::Warning, now),
action(99, ActionType::Note, now),
action(2, ActionType::Takedown, now),
];
let s = calculate_strike_state(&actions, &linear_policy(3, 90), now);
assert_eq!(s.current_count, 2);
assert_eq!(s.raw_total, 2, "Warning/Note must not bleed into raw_total");
}
#[test]
fn good_standing_true_when_at_or_below_threshold() {
let now = t0();
let actions = vec![action(3, ActionType::Takedown, now)];
let s = calculate_strike_state(&actions, &linear_policy(3, 90), now);
assert_eq!(s.current_count, 3);
assert!(s.good_standing, "3 == threshold is in good standing");
}
#[test]
fn good_standing_false_when_above_threshold() {
let now = t0();
let actions = vec![action(4, ActionType::Takedown, now)];
let s = calculate_strike_state(&actions, &linear_policy(3, 90), now);
assert_eq!(s.current_count, 4);
assert!(!s.good_standing);
}
#[test]
fn invariant_revoked_plus_decayed_plus_current_equals_raw_total() {
let now = t0();
let actions = vec![
action(4, ActionType::Takedown, now - days(45)),
revoked_action(2, ActionType::Takedown, now - days(10), now - days(5)),
action(3, ActionType::Takedown, now),
];
let s = calculate_strike_state(&actions, &linear_policy(10, 90), now);
assert_eq!(
s.revoked_count + s.decayed_count + s.current_count,
s.raw_total
);
}
#[test]
fn deterministic_for_same_inputs() {
let now = t0();
let actions = vec![
action(4, ActionType::Takedown, now - days(30)),
temp_susp(1, now - days(10), now + days(20)),
];
let p = linear_policy(5, 90);
let a = calculate_strike_state(&actions, &p, now);
let b = calculate_strike_state(&actions, &p, now);
assert_eq!(a, b);
}
}