use std::collections::HashMap;
use std::sync::Arc;
use common::{DecayConfig, DecayStrategy, Memory, MemoryPolicy, MemoryType, Vector};
use serde::{Deserialize, Serialize};
use storage::{RedisCache, VectorStorage};
use tokio::sync::RwLock;
use tracing;
pub struct DecayEngine {
pub config: DecayConfig,
}
pub struct DecayEngineConfig {
pub decay_config: DecayConfig,
pub interval_secs: u64,
}
impl Default for DecayEngineConfig {
fn default() -> Self {
Self {
decay_config: DecayConfig {
strategy: DecayStrategy::Exponential,
half_life_hours: 168.0, min_importance: 0.01,
},
interval_secs: 3600, }
}
}
impl DecayEngineConfig {
pub fn from_env() -> Self {
let half_life_hours: f64 = std::env::var("DAKERA_DECAY_HALF_LIFE_HOURS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(168.0);
let min_importance: f32 = std::env::var("DAKERA_DECAY_MIN_IMPORTANCE")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(0.01);
let interval_secs: u64 = std::env::var("DAKERA_DECAY_INTERVAL_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(3600);
let strategy_str =
std::env::var("DAKERA_DECAY_STRATEGY").unwrap_or_else(|_| "exponential".to_string());
let strategy = match strategy_str.to_lowercase().as_str() {
"linear" => DecayStrategy::Linear,
"step" | "stepfunction" | "step_function" => DecayStrategy::StepFunction,
_ => DecayStrategy::Exponential,
};
Self {
decay_config: DecayConfig {
strategy,
half_life_hours,
min_importance,
},
interval_secs,
}
}
}
impl DecayEngine {
pub fn new(config: DecayConfig) -> Self {
Self { config }
}
pub fn calculate_decay(
&self,
current_importance: f32,
hours_elapsed: f64,
memory_type: &MemoryType,
access_count: u32,
) -> f32 {
self.calculate_decay_with_strategy(
current_importance,
hours_elapsed,
memory_type,
access_count,
None,
)
}
pub fn calculate_decay_with_strategy(
&self,
current_importance: f32,
hours_elapsed: f64,
memory_type: &MemoryType,
access_count: u32,
strategy_override: Option<DecayStrategy>,
) -> f32 {
if hours_elapsed <= 0.0 {
return current_importance;
}
let type_multiplier = match memory_type {
MemoryType::Working => 3.0,
MemoryType::Episodic => 1.0,
MemoryType::Semantic => 0.5,
MemoryType::Procedural => 0.3,
};
let usage_shield = if access_count > 0 {
1.0 / (1.0 + (access_count as f64 * 0.1))
} else {
1.5 };
let effective_half_life = self.config.half_life_hours / (type_multiplier * usage_shield);
let strategy = strategy_override.unwrap_or(self.config.strategy);
let decayed = match strategy {
DecayStrategy::Exponential => {
let decay_factor = (0.5_f64).powf(hours_elapsed / effective_half_life);
current_importance * decay_factor as f32
}
DecayStrategy::Linear => {
let decay_amount = (hours_elapsed / effective_half_life) as f32 * 0.5;
(current_importance - decay_amount).max(0.0)
}
DecayStrategy::StepFunction => {
let steps = (hours_elapsed / effective_half_life).floor() as u32;
let decay_factor = (0.5_f32).powi(steps as i32);
current_importance * decay_factor
}
DecayStrategy::PowerLaw => {
let k = 1.0 / effective_half_life;
let factor = 1.0 / (1.0 + k * hours_elapsed);
current_importance * factor as f32
}
DecayStrategy::Logarithmic => {
let factor = (1.0 - (1.0 + hours_elapsed / effective_half_life).log2()).max(0.0);
current_importance * factor as f32
}
DecayStrategy::Flat => current_importance,
};
decayed.clamp(0.0, 1.0)
}
pub fn access_boost(current_importance: f32) -> f32 {
let boost = 0.05 + 0.05 * current_importance; (current_importance + boost).min(1.0)
}
pub async fn apply_decay(
&self,
storage: &Arc<dyn VectorStorage>,
policies: &HashMap<String, MemoryPolicy>,
) -> DecayResult {
let mut result = DecayResult::default();
let namespaces = match storage.list_namespaces().await {
Ok(ns) => ns,
Err(e) => {
tracing::error!(error = %e, "Failed to list namespaces for decay");
return result;
}
};
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
for namespace in namespaces {
if !namespace.starts_with("_dakera_agent_") {
continue;
}
result.namespaces_processed += 1;
let vectors = match storage.get_all(&namespace).await {
Ok(v) => v,
Err(e) => {
tracing::warn!(
namespace = %namespace,
error = %e,
"Failed to get vectors for decay"
);
continue;
}
};
let mut updated_vectors: Vec<Vector> = Vec::new();
let mut ids_to_delete: Vec<String> = Vec::new();
for vector in &vectors {
let memory = match Memory::from_vector(vector) {
Some(m) => m,
None => continue, };
result.memories_processed += 1;
if let Some(exp) = memory.expires_at {
if exp <= now {
ids_to_delete.push(memory.id.clone());
result.memories_deleted += 1;
continue;
}
}
let hours_elapsed = if now > memory.last_accessed_at {
(now - memory.last_accessed_at) as f64 / 3600.0
} else {
0.0
};
let strategy_override = policies
.get(&namespace)
.map(|p| p.decay_for_type(&memory.memory_type));
let new_importance = self.calculate_decay_with_strategy(
memory.importance,
hours_elapsed,
&memory.memory_type,
memory.access_count,
strategy_override,
);
if new_importance < self.config.min_importance {
let floored = self.config.min_importance;
if (memory.importance - floored).abs() > 0.001 {
let mut updated_memory = memory;
updated_memory.importance = floored;
let mut updated_vector = vector.clone();
updated_vector.metadata = Some(updated_memory.to_vector_metadata());
updated_vectors.push(updated_vector);
result.memories_decayed += 1;
}
result.memories_floored += 1;
continue;
}
if (new_importance - memory.importance).abs() > 0.001 {
let mut updated_memory = memory;
updated_memory.importance = new_importance;
let mut updated_vector = vector.clone();
updated_vector.metadata = Some(updated_memory.to_vector_metadata());
updated_vectors.push(updated_vector);
result.memories_decayed += 1;
}
}
if !ids_to_delete.is_empty() {
if let Err(e) = storage.delete(&namespace, &ids_to_delete).await {
tracing::warn!(
namespace = %namespace,
count = ids_to_delete.len(),
error = %e,
"Failed to delete expired memories"
);
}
}
if !updated_vectors.is_empty() {
if let Err(e) = storage.upsert(&namespace, updated_vectors).await {
tracing::warn!(
namespace = %namespace,
error = %e,
"Failed to upsert decayed memories"
);
}
}
}
tracing::info!(
namespaces_processed = result.namespaces_processed,
memories_processed = result.memories_processed,
memories_decayed = result.memories_decayed,
memories_deleted = result.memories_deleted,
"Decay cycle completed"
);
result
}
pub fn spawn(
config: Arc<RwLock<DecayConfig>>,
interval_secs: u64,
storage: Arc<dyn VectorStorage>,
metrics: Arc<BackgroundMetrics>,
redis: Option<RedisCache>,
node_id: String,
policies: Arc<RwLock<HashMap<String, MemoryPolicy>>>,
) -> tokio::task::JoinHandle<()> {
let interval = std::time::Duration::from_secs(interval_secs);
let lock_ttl = interval_secs + 300;
const LOCK_KEY: &str = "dakera:lock:decay";
tokio::spawn(async move {
tracing::info!(
interval_secs,
"Decay engine started (hot-reload config via PUT /admin/decay/config)"
);
loop {
tokio::time::sleep(interval).await;
let acquired = match redis {
Some(ref rc) => rc.try_acquire_lock(LOCK_KEY, &node_id, lock_ttl).await,
None => true, };
if !acquired {
tracing::debug!("Decay skipped — another replica holds the leader lock");
continue;
}
let current_config = config.read().await.clone();
let current_policies = policies.read().await.clone();
let engine = DecayEngine::new(current_config);
let result = engine.apply_decay(&storage, ¤t_policies).await;
metrics.record_decay(&result);
if let Some(ref rc) = redis {
rc.release_lock(LOCK_KEY, &node_id).await;
}
}
})
}
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct DecayResult {
pub namespaces_processed: usize,
pub memories_processed: usize,
pub memories_decayed: usize,
pub memories_deleted: usize,
#[serde(default)]
pub memories_floored: usize,
}
#[derive(Debug, Default)]
pub struct BackgroundMetrics {
inner: std::sync::Mutex<BackgroundMetricsInner>,
dirty: std::sync::atomic::AtomicBool,
}
const MAX_HISTORY_POINTS: usize = 168;
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct BackgroundMetricsInner {
#[serde(default)]
pub last_decay: Option<DecayResult>,
#[serde(default)]
pub last_decay_at: Option<u64>,
#[serde(default)]
pub total_decay_deleted: u64,
#[serde(default)]
pub total_decay_floored: u64,
#[serde(default)]
pub total_decay_adjusted: u64,
#[serde(default)]
pub decay_cycles_run: u64,
#[serde(default)]
pub last_dedup: Option<DedupResultSnapshot>,
#[serde(default)]
pub last_dedup_at: Option<u64>,
#[serde(default)]
pub total_dedup_removed: u64,
#[serde(default)]
pub last_consolidation: Option<ConsolidationResultSnapshot>,
#[serde(default)]
pub last_consolidation_at: Option<u64>,
#[serde(default)]
pub total_consolidated: u64,
#[serde(default)]
pub history: Vec<ActivityHistoryPoint>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActivityHistoryPoint {
pub timestamp: u64,
pub decay_deleted: u64,
pub decay_adjusted: u64,
pub dedup_removed: u64,
pub consolidated: u64,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct DedupResultSnapshot {
pub namespaces_processed: usize,
pub memories_scanned: usize,
pub duplicates_removed: usize,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct ConsolidationResultSnapshot {
pub namespaces_processed: usize,
pub memories_scanned: usize,
pub clusters_merged: usize,
pub memories_consolidated: usize,
}
impl BackgroundMetrics {
pub fn new() -> Self {
Self::default()
}
pub fn restore(inner: BackgroundMetricsInner) -> Self {
Self {
inner: std::sync::Mutex::new(inner),
dirty: std::sync::atomic::AtomicBool::new(false),
}
}
pub fn is_dirty(&self) -> bool {
self.dirty.load(std::sync::atomic::Ordering::Relaxed)
}
pub fn clear_dirty(&self) {
self.dirty
.store(false, std::sync::atomic::Ordering::Relaxed);
}
pub fn record_decay(&self, result: &DecayResult) {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
inner.total_decay_deleted += result.memories_deleted as u64;
inner.total_decay_floored += result.memories_floored as u64;
inner.total_decay_adjusted += result.memories_decayed as u64;
inner.decay_cycles_run += 1;
metrics::counter!("dakera_memories_decayed_total")
.increment(result.memories_floored as u64);
inner.last_decay = Some(result.clone());
inner.last_decay_at = Some(now);
push_history(
&mut inner.history,
ActivityHistoryPoint {
timestamp: now,
decay_deleted: result.memories_deleted as u64,
decay_adjusted: result.memories_decayed as u64,
dedup_removed: 0,
consolidated: 0,
},
);
self.dirty.store(true, std::sync::atomic::Ordering::Relaxed);
}
pub fn record_dedup(
&self,
namespaces_processed: usize,
memories_scanned: usize,
duplicates_removed: usize,
) {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
inner.total_dedup_removed += duplicates_removed as u64;
inner.last_dedup = Some(DedupResultSnapshot {
namespaces_processed,
memories_scanned,
duplicates_removed,
});
inner.last_dedup_at = Some(now);
push_history(
&mut inner.history,
ActivityHistoryPoint {
timestamp: now,
decay_deleted: 0,
decay_adjusted: 0,
dedup_removed: duplicates_removed as u64,
consolidated: 0,
},
);
self.dirty.store(true, std::sync::atomic::Ordering::Relaxed);
}
pub fn record_consolidation(
&self,
namespaces_processed: usize,
memories_scanned: usize,
clusters_merged: usize,
memories_consolidated: usize,
) {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
inner.total_consolidated += memories_consolidated as u64;
inner.last_consolidation = Some(ConsolidationResultSnapshot {
namespaces_processed,
memories_scanned,
clusters_merged,
memories_consolidated,
});
inner.last_consolidation_at = Some(now);
push_history(
&mut inner.history,
ActivityHistoryPoint {
timestamp: now,
decay_deleted: 0,
decay_adjusted: 0,
dedup_removed: 0,
consolidated: memories_consolidated as u64,
},
);
self.dirty.store(true, std::sync::atomic::Ordering::Relaxed);
}
pub fn restore_into(&self, restored: BackgroundMetricsInner) {
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
*inner = restored;
}
pub fn snapshot(&self) -> BackgroundMetricsInner {
self.inner.lock().unwrap_or_else(|e| e.into_inner()).clone()
}
}
fn push_history(history: &mut Vec<ActivityHistoryPoint>, point: ActivityHistoryPoint) {
history.push(point);
if history.len() > MAX_HISTORY_POINTS {
let excess = history.len() - MAX_HISTORY_POINTS;
history.drain(..excess);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
fn make_engine(strategy: DecayStrategy, half_life: f64) -> DecayEngine {
DecayEngine::new(DecayConfig {
strategy,
half_life_hours: half_life,
min_importance: 0.01,
})
}
const EPISODIC: MemoryType = MemoryType::Episodic;
#[test]
fn test_exponential_decay_at_half_life_episodic_no_access() {
let engine = make_engine(DecayStrategy::Exponential, 168.0);
let result = engine.calculate_decay(1.0, 112.0, &EPISODIC, 0);
assert!((result - 0.5).abs() < 0.01, "Expected ~0.5, got {}", result);
}
#[test]
fn test_exponential_decay_zero_time() {
let engine = make_engine(DecayStrategy::Exponential, 168.0);
let result = engine.calculate_decay(0.8, 0.0, &EPISODIC, 0);
assert!((result - 0.8).abs() < 0.001);
}
#[test]
fn test_linear_decay_floors_at_zero() {
let engine = make_engine(DecayStrategy::Linear, 168.0);
let result = engine.calculate_decay(0.3, 168.0, &EPISODIC, 0);
assert!(result >= 0.0, "Should not go below 0, got {}", result);
}
#[test]
fn test_procedural_decays_slower_than_working() {
let engine = make_engine(DecayStrategy::Exponential, 168.0);
let working = engine.calculate_decay(1.0, 168.0, &MemoryType::Working, 0);
let procedural = engine.calculate_decay(1.0, 168.0, &MemoryType::Procedural, 0);
assert!(
procedural > working,
"Procedural ({}) should decay slower than Working ({})",
procedural,
working
);
}
#[test]
fn test_high_access_count_decays_slower() {
let engine = make_engine(DecayStrategy::Exponential, 168.0);
let no_access = engine.calculate_decay(1.0, 168.0, &EPISODIC, 0);
let high_access = engine.calculate_decay(1.0, 168.0, &EPISODIC, 10);
assert!(
high_access > no_access,
"High access ({}) should decay slower than no access ({})",
high_access,
no_access
);
}
#[test]
fn test_semantic_decays_slower_than_episodic() {
let engine = make_engine(DecayStrategy::Exponential, 168.0);
let episodic = engine.calculate_decay(1.0, 168.0, &EPISODIC, 5);
let semantic = engine.calculate_decay(1.0, 168.0, &MemoryType::Semantic, 5);
assert!(
semantic > episodic,
"Semantic ({}) should decay slower than Episodic ({})",
semantic,
episodic
);
}
#[test]
fn test_access_boost_scales_with_importance() {
let low = DecayEngine::access_boost(0.2);
let high = DecayEngine::access_boost(0.8);
let boost_low = low - 0.2;
let boost_high = high - 0.8;
assert!(
boost_high > boost_low,
"High-importance boost ({}) should be larger than low-importance boost ({})",
boost_high,
boost_low
);
assert!((boost_low - (0.05 + 0.05 * 0.2)).abs() < 0.001);
assert!((boost_high - (0.05 + 0.05 * 0.8)).abs() < 0.001);
}
#[test]
fn test_access_boost_caps_at_one() {
assert!((DecayEngine::access_boost(1.0) - 1.0).abs() < 0.001);
assert!((DecayEngine::access_boost(0.96) - 1.0).abs() < 0.001);
}
#[test]
fn test_decay_clamps_to_range() {
let engine = make_engine(DecayStrategy::Exponential, 1.0);
let result = engine.calculate_decay(0.001, 100.0, &EPISODIC, 0);
assert!(result >= 0.0 && result <= 1.0);
}
#[test]
fn test_step_function_decay() {
let engine = make_engine(DecayStrategy::StepFunction, 168.0);
let eff_hl = 168.0 / 1.5;
let result = engine.calculate_decay(1.0, eff_hl * 0.5, &EPISODIC, 0);
assert!((result - 1.0).abs() < 0.001);
let result = engine.calculate_decay(1.0, eff_hl, &EPISODIC, 0);
assert!((result - 0.5).abs() < 0.001);
}
#[test]
fn test_decay_engine_new_stores_config() {
let cfg = DecayConfig {
strategy: DecayStrategy::Linear,
half_life_hours: 48.0,
min_importance: 0.05,
};
let engine = DecayEngine::new(cfg.clone());
assert!(matches!(engine.config.strategy, DecayStrategy::Linear));
assert!((engine.config.half_life_hours - 48.0).abs() < 1e-9);
assert!((engine.config.min_importance - 0.05).abs() < 1e-6);
}
#[test]
fn test_decay_engine_config_default_values() {
let cfg = DecayEngineConfig::default();
assert!(matches!(
cfg.decay_config.strategy,
DecayStrategy::Exponential
));
assert!((cfg.decay_config.half_life_hours - 168.0).abs() < 1e-9);
assert!((cfg.decay_config.min_importance - 0.01).abs() < 1e-6);
assert_eq!(cfg.interval_secs, 3600);
}
#[test]
fn test_decay_engine_config_from_env_defaults_without_vars() {
let _guard = ENV_LOCK.lock().unwrap();
std::env::remove_var("DAKERA_DECAY_HALF_LIFE_HOURS");
std::env::remove_var("DAKERA_DECAY_MIN_IMPORTANCE");
std::env::remove_var("DAKERA_DECAY_INTERVAL_SECS");
std::env::remove_var("DAKERA_DECAY_STRATEGY");
let cfg = DecayEngineConfig::from_env();
assert!(matches!(
cfg.decay_config.strategy,
DecayStrategy::Exponential
));
assert!((cfg.decay_config.half_life_hours - 168.0).abs() < 1e-9);
}
#[test]
fn test_decay_engine_config_from_env_linear_strategy() {
let _guard = ENV_LOCK.lock().unwrap();
std::env::set_var("DAKERA_DECAY_STRATEGY", "linear");
let cfg = DecayEngineConfig::from_env();
std::env::remove_var("DAKERA_DECAY_STRATEGY");
assert!(matches!(cfg.decay_config.strategy, DecayStrategy::Linear));
}
#[test]
fn test_decay_engine_config_from_env_step_strategy() {
let _guard = ENV_LOCK.lock().unwrap();
std::env::set_var("DAKERA_DECAY_STRATEGY", "step");
let cfg = DecayEngineConfig::from_env();
std::env::remove_var("DAKERA_DECAY_STRATEGY");
assert!(matches!(
cfg.decay_config.strategy,
DecayStrategy::StepFunction
));
}
#[test]
fn test_decay_engine_config_from_env_unknown_strategy_defaults_to_exponential() {
let _guard = ENV_LOCK.lock().unwrap();
std::env::set_var("DAKERA_DECAY_STRATEGY", "bogus");
let cfg = DecayEngineConfig::from_env();
std::env::remove_var("DAKERA_DECAY_STRATEGY");
assert!(matches!(
cfg.decay_config.strategy,
DecayStrategy::Exponential
));
}
#[test]
fn test_push_history_caps_at_max() {
let mut history: Vec<ActivityHistoryPoint> = Vec::new();
for i in 0..(MAX_HISTORY_POINTS + 10) {
push_history(
&mut history,
ActivityHistoryPoint {
timestamp: i as u64,
decay_deleted: 0,
decay_adjusted: 0,
dedup_removed: 0,
consolidated: 0,
},
);
}
assert_eq!(history.len(), MAX_HISTORY_POINTS);
assert_eq!(
history.last().unwrap().timestamp,
(MAX_HISTORY_POINTS + 9) as u64
);
}
#[test]
fn test_push_history_below_cap_grows_normally() {
let mut history: Vec<ActivityHistoryPoint> = Vec::new();
for i in 0..5 {
push_history(
&mut history,
ActivityHistoryPoint {
timestamp: i,
decay_deleted: 0,
decay_adjusted: 0,
dedup_removed: 0,
consolidated: 0,
},
);
}
assert_eq!(history.len(), 5);
}
#[test]
fn test_background_metrics_new_not_dirty() {
let m = BackgroundMetrics::new();
assert!(!m.is_dirty());
}
#[test]
fn test_background_metrics_record_decay_sets_dirty() {
let m = BackgroundMetrics::new();
let result = DecayResult {
namespaces_processed: 1,
memories_processed: 10,
memories_decayed: 3,
memories_deleted: 1,
memories_floored: 0,
};
m.record_decay(&result);
assert!(m.is_dirty());
}
#[test]
fn test_background_metrics_clear_dirty() {
let m = BackgroundMetrics::new();
let result = DecayResult::default();
m.record_decay(&result);
assert!(m.is_dirty());
m.clear_dirty();
assert!(!m.is_dirty());
}
#[test]
fn test_background_metrics_snapshot_totals() {
let m = BackgroundMetrics::new();
m.record_decay(&DecayResult {
namespaces_processed: 2,
memories_processed: 20,
memories_decayed: 5,
memories_deleted: 2,
memories_floored: 0,
});
m.record_decay(&DecayResult {
namespaces_processed: 1,
memories_processed: 5,
memories_decayed: 1,
memories_deleted: 1,
memories_floored: 0,
});
let snap = m.snapshot();
assert_eq!(snap.total_decay_deleted, 3); assert_eq!(snap.decay_cycles_run, 2);
}
#[test]
fn test_background_metrics_record_dedup() {
let m = BackgroundMetrics::new();
m.record_dedup(2, 100, 5);
let snap = m.snapshot();
assert_eq!(snap.total_dedup_removed, 5);
assert!(snap.last_dedup.is_some());
}
#[test]
fn test_background_metrics_record_consolidation() {
let m = BackgroundMetrics::new();
m.record_consolidation(1, 30, 2, 6);
let snap = m.snapshot();
assert_eq!(snap.total_consolidated, 6);
assert!(snap.last_consolidation.is_some());
}
#[test]
fn test_background_metrics_restore() {
let inner = BackgroundMetricsInner {
total_decay_deleted: 42,
decay_cycles_run: 7,
..Default::default()
};
let m = BackgroundMetrics::restore(inner);
assert!(!m.is_dirty()); assert_eq!(m.snapshot().total_decay_deleted, 42);
assert_eq!(m.snapshot().decay_cycles_run, 7);
}
#[test]
fn test_linear_decay_formula() {
let engine = make_engine(DecayStrategy::Linear, 100.0);
let eff_hl = 100.0 / (1.0 * (1.0 / (1.0 + 0.1)));
let decay_amount = (55.0 / eff_hl) * 0.5;
let expected = (1.0_f32 - decay_amount as f32).max(0.0);
let result = engine.calculate_decay(1.0, 55.0, &EPISODIC, 1);
assert!(
(result - expected).abs() < 0.01,
"expected ~{expected}, got {result}"
);
}
#[test]
fn test_working_memory_decays_fastest() {
let engine = make_engine(DecayStrategy::Exponential, 168.0);
let working = engine.calculate_decay(1.0, 168.0, &MemoryType::Working, 5);
let episodic = engine.calculate_decay(1.0, 168.0, &EPISODIC, 5);
let semantic = engine.calculate_decay(1.0, 168.0, &MemoryType::Semantic, 5);
let procedural = engine.calculate_decay(1.0, 168.0, &MemoryType::Procedural, 5);
assert!(working < episodic);
assert!(episodic < semantic);
assert!(semantic < procedural);
}
#[test]
fn test_access_boost_minimum_is_0_05() {
let result = DecayEngine::access_boost(0.0);
assert!((result - 0.05).abs() < 0.001, "expected 0.05, got {result}");
}
}