use crate::constants::{
DECAY_CROSSOVER_DAYS, DECAY_LAMBDA_CONSOLIDATION, POWERLAW_BETA, POWERLAW_BETA_POTENTIATED,
};
#[inline]
pub fn hybrid_decay_factor(days_elapsed: f64, potentiated: bool) -> f32 {
if days_elapsed <= 0.0 {
return 1.0;
}
let beta = if potentiated {
POWERLAW_BETA_POTENTIATED
} else {
POWERLAW_BETA
};
let lambda = if potentiated {
DECAY_LAMBDA_CONSOLIDATION * 0.5 } else {
DECAY_LAMBDA_CONSOLIDATION
};
if days_elapsed < DECAY_CROSSOVER_DAYS {
(-lambda * days_elapsed).exp() as f32
} else {
let value_at_crossover = (-lambda * DECAY_CROSSOVER_DAYS).exp();
let power_law_factor = (days_elapsed / DECAY_CROSSOVER_DAYS).powf(-beta);
(value_at_crossover * power_law_factor) as f32
}
}
#[inline]
pub fn hybrid_decay_factor_custom(
days_elapsed: f64,
crossover_days: f64,
lambda: f64,
beta: f64,
) -> f32 {
if days_elapsed <= 0.0 {
return 1.0;
}
if days_elapsed < crossover_days {
(-lambda * days_elapsed).exp() as f32
} else {
let value_at_crossover = (-lambda * crossover_days).exp();
let power_law_factor = (days_elapsed / crossover_days).powf(-beta);
(value_at_crossover * power_law_factor) as f32
}
}
#[allow(dead_code)]
pub fn retention_curve_debug(potentiated: bool) -> String {
let days = [0.5, 1.0, 3.0, 7.0, 14.0, 30.0, 90.0, 365.0];
let mode = if potentiated { "potentiated" } else { "normal" };
let mut output = format!("Retention curve ({mode}):\n");
for d in days {
let factor = hybrid_decay_factor(d, potentiated);
output.push_str(&format!(" Day {:>5.1}: {:>6.2}%\n", d, factor * 100.0));
}
output
}
#[inline]
pub fn tier_decay_factor(hours_elapsed: f64, tier: u8, ltp_decay_factor: f32) -> (f32, bool) {
use crate::constants::*;
if hours_elapsed <= 0.0 {
return (1.0, false);
}
let (decay_rate, max_age_hours, prune_threshold) = match tier {
0 => {
(
L1_DECAY_PER_HOUR as f64,
(L1_MAX_AGE_HOURS as f64),
L1_PRUNE_THRESHOLD,
)
}
1 => {
let decay_per_hour = L2_DECAY_PER_DAY as f64 / 24.0;
(
decay_per_hour,
(L2_MAX_AGE_DAYS as f64) * 24.0,
L2_PRUNE_THRESHOLD,
)
}
_ => {
let decay_per_hour = L3_DECAY_PER_MONTH as f64 / (30.0 * 24.0);
(decay_per_hour, 87600.0, L3_PRUNE_THRESHOLD)
}
};
let effective_rate = decay_rate * ltp_decay_factor as f64;
let decay_factor = (-effective_rate * hours_elapsed).exp() as f32;
let effective_max_age = if ltp_decay_factor < 1.0 {
max_age_hours / (ltp_decay_factor as f64).max(0.01)
} else {
max_age_hours
};
let should_prune = hours_elapsed > effective_max_age && decay_factor < prune_threshold;
(decay_factor.max(0.001), should_prune)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_no_decay_at_zero() {
assert_eq!(hybrid_decay_factor(0.0, false), 1.0);
assert_eq!(hybrid_decay_factor(-1.0, false), 1.0);
}
#[test]
fn test_exponential_phase() {
let factor_1day = hybrid_decay_factor(1.0, false);
let factor_2day = hybrid_decay_factor(2.0, false);
let ratio_1_to_2 = factor_2day / factor_1day;
let expected_ratio = (-DECAY_LAMBDA_CONSOLIDATION).exp() as f32;
assert!((ratio_1_to_2 - expected_ratio).abs() < 0.01);
}
#[test]
fn test_powerlaw_phase() {
let factor_7day = hybrid_decay_factor(7.0, false);
let factor_14day = hybrid_decay_factor(14.0, false);
let ratio = factor_14day / factor_7day;
let expected_ratio = 2.0_f64.powf(-POWERLAW_BETA) as f32;
assert!((ratio - expected_ratio).abs() < 0.02);
}
#[test]
fn test_continuity_at_crossover() {
let just_before = hybrid_decay_factor(DECAY_CROSSOVER_DAYS - 0.001, false);
let just_after = hybrid_decay_factor(DECAY_CROSSOVER_DAYS + 0.001, false);
assert!((just_before - just_after).abs() < 0.01);
}
#[test]
fn test_potentiated_decays_slower() {
let normal = hybrid_decay_factor(30.0, false);
let potentiated = hybrid_decay_factor(30.0, true);
assert!(potentiated > normal);
}
#[test]
fn test_heavy_tail_retention() {
let year_retention = hybrid_decay_factor(365.0, false);
let year_retention_potentiated = hybrid_decay_factor(365.0, true);
assert!(year_retention > 0.01);
assert!(year_retention_potentiated > 0.05);
}
#[test]
fn test_custom_parameters() {
let aggressive = hybrid_decay_factor_custom(7.0, 1.0, 1.5, 0.7);
let normal = hybrid_decay_factor(7.0, false);
assert!(aggressive < normal);
}
#[test]
fn test_tier_decay_factor_l1_with_and_without_ltp() {
let (unprotected, _) = tier_decay_factor(24.0, 0, 1.0);
let (protected, _) = tier_decay_factor(24.0, 0, 0.5);
assert!(protected > unprotected);
}
#[test]
fn test_tier_decay_factor_l1_prune_threshold() {
let (factor_at_max_age, should_prune_at_max_age) = tier_decay_factor(48.0, 0, 1.0);
assert!(factor_at_max_age > 0.1);
assert!(!should_prune_at_max_age);
let (factor_past_max_age, should_prune_past_max_age) = tier_decay_factor(96.0, 0, 1.0);
assert!(factor_past_max_age < 0.1);
assert!(should_prune_past_max_age);
}
#[test]
fn test_tier_decay_factor_l3_long_tail() {
let (factor_1y, prune_1y) = tier_decay_factor(365.0 * 24.0, 2, 1.0);
assert!(factor_1y > 0.7);
assert!(!prune_1y);
let (factor_3y, prune_3y) = tier_decay_factor(3.0 * 365.0 * 24.0, 2, 1.0);
assert!(factor_3y > 0.45);
assert!(!prune_3y);
}
#[test]
fn test_tier_decay_zero_and_negative_elapsed() {
let (zero_factor, zero_prune) = tier_decay_factor(0.0, 1, 1.0);
assert_eq!(zero_factor, 1.0);
assert!(!zero_prune);
let (neg_factor, neg_prune) = tier_decay_factor(-10.0, 1, 1.0);
assert_eq!(neg_factor, 1.0);
assert!(!neg_prune);
}
#[test]
fn test_tier_decay_invalid_tier_defaults_to_l3() {
let (invalid_tier, _) = tier_decay_factor(24.0, 9, 1.0);
let (l3, _) = tier_decay_factor(24.0, 2, 1.0);
assert_eq!(invalid_tier, l3);
}
}