1use std::collections::HashMap;
10use std::sync::Arc;
11
12use common::{DecayConfig, DecayStrategy, Memory, MemoryPolicy, MemoryType, Vector};
13use serde::{Deserialize, Serialize};
14use storage::{RedisCache, VectorStorage};
15use tokio::sync::RwLock;
16use tracing;
17
18pub struct DecayEngine {
20 pub config: DecayConfig,
21}
22
23pub struct DecayEngineConfig {
25 pub decay_config: DecayConfig,
27 pub interval_secs: u64,
29}
30
31impl Default for DecayEngineConfig {
32 fn default() -> Self {
33 Self {
34 decay_config: DecayConfig {
35 strategy: DecayStrategy::Exponential,
36 half_life_hours: 168.0, min_importance: 0.01,
38 },
39 interval_secs: 3600, }
41 }
42}
43
44impl DecayEngineConfig {
45 pub fn from_env() -> Self {
47 let half_life_hours: f64 = std::env::var("DAKERA_DECAY_HALF_LIFE_HOURS")
48 .ok()
49 .and_then(|v| v.parse().ok())
50 .unwrap_or(168.0);
51
52 let min_importance: f32 = std::env::var("DAKERA_DECAY_MIN_IMPORTANCE")
53 .ok()
54 .and_then(|v| v.parse().ok())
55 .unwrap_or(0.01);
56
57 let interval_secs: u64 = std::env::var("DAKERA_DECAY_INTERVAL_SECS")
58 .ok()
59 .and_then(|v| v.parse().ok())
60 .unwrap_or(3600);
61
62 let strategy_str =
63 std::env::var("DAKERA_DECAY_STRATEGY").unwrap_or_else(|_| "exponential".to_string());
64
65 let strategy = match strategy_str.to_lowercase().as_str() {
66 "linear" => DecayStrategy::Linear,
67 "step" | "stepfunction" | "step_function" => DecayStrategy::StepFunction,
68 _ => DecayStrategy::Exponential,
69 };
70
71 Self {
72 decay_config: DecayConfig {
73 strategy,
74 half_life_hours,
75 min_importance,
76 },
77 interval_secs,
78 }
79 }
80}
81
82impl DecayEngine {
83 pub fn new(config: DecayConfig) -> Self {
85 Self { config }
86 }
87
88 pub fn calculate_decay(
102 &self,
103 current_importance: f32,
104 hours_elapsed: f64,
105 memory_type: &MemoryType,
106 access_count: u32,
107 ) -> f32 {
108 self.calculate_decay_with_strategy(
109 current_importance,
110 hours_elapsed,
111 memory_type,
112 access_count,
113 None,
114 )
115 }
116
117 pub fn calculate_decay_with_strategy(
119 &self,
120 current_importance: f32,
121 hours_elapsed: f64,
122 memory_type: &MemoryType,
123 access_count: u32,
124 strategy_override: Option<DecayStrategy>,
125 ) -> f32 {
126 if hours_elapsed <= 0.0 {
127 return current_importance;
128 }
129
130 let type_multiplier = match memory_type {
132 MemoryType::Working => 3.0,
133 MemoryType::Episodic => 1.0,
134 MemoryType::Semantic => 0.5,
135 MemoryType::Procedural => 0.3,
136 };
137
138 let usage_shield = if access_count > 0 {
140 1.0 / (1.0 + (access_count as f64 * 0.1))
141 } else {
142 1.5 };
144
145 let effective_half_life = self.config.half_life_hours / (type_multiplier * usage_shield);
146
147 let strategy = strategy_override.unwrap_or(self.config.strategy);
149
150 let decayed = match strategy {
151 DecayStrategy::Exponential => {
152 let decay_factor = (0.5_f64).powf(hours_elapsed / effective_half_life);
153 current_importance * decay_factor as f32
154 }
155 DecayStrategy::Linear => {
156 let decay_amount = (hours_elapsed / effective_half_life) as f32 * 0.5;
157 (current_importance - decay_amount).max(0.0)
158 }
159 DecayStrategy::StepFunction => {
160 let steps = (hours_elapsed / effective_half_life).floor() as u32;
161 let decay_factor = (0.5_f32).powi(steps as i32);
162 current_importance * decay_factor
163 }
164 DecayStrategy::PowerLaw => {
167 let k = 1.0 / effective_half_life;
168 let factor = 1.0 / (1.0 + k * hours_elapsed);
169 current_importance * factor as f32
170 }
171 DecayStrategy::Logarithmic => {
174 let factor = (1.0 - (1.0 + hours_elapsed / effective_half_life).log2()).max(0.0);
175 current_importance * factor as f32
176 }
177 DecayStrategy::Flat => current_importance,
179 };
180
181 decayed.clamp(0.0, 1.0)
182 }
183
184 pub fn access_boost(current_importance: f32) -> f32 {
187 let boost = 0.05 + 0.05 * current_importance; (current_importance + boost).min(1.0)
189 }
190
191 pub async fn apply_decay(
199 &self,
200 storage: &Arc<dyn VectorStorage>,
201 policies: &HashMap<String, MemoryPolicy>,
202 ) -> DecayResult {
203 let mut result = DecayResult::default();
204
205 let namespaces = match storage.list_namespaces().await {
207 Ok(ns) => ns,
208 Err(e) => {
209 tracing::error!(error = %e, "Failed to list namespaces for decay");
210 return result;
211 }
212 };
213
214 let now = std::time::SystemTime::now()
215 .duration_since(std::time::UNIX_EPOCH)
216 .unwrap_or_default()
217 .as_secs();
218
219 for namespace in namespaces {
221 if !namespace.starts_with("_dakera_agent_") {
222 continue;
223 }
224
225 result.namespaces_processed += 1;
226
227 let vectors = match storage.get_all(&namespace).await {
228 Ok(v) => v,
229 Err(e) => {
230 tracing::warn!(
231 namespace = %namespace,
232 error = %e,
233 "Failed to get vectors for decay"
234 );
235 continue;
236 }
237 };
238
239 let mut updated_vectors: Vec<Vector> = Vec::new();
240 let mut ids_to_delete: Vec<String> = Vec::new();
241
242 for vector in &vectors {
243 let memory = match Memory::from_vector(vector) {
244 Some(m) => m,
245 None => continue, };
247
248 result.memories_processed += 1;
249
250 if let Some(exp) = memory.expires_at {
253 if exp <= now {
254 ids_to_delete.push(memory.id.clone());
255 result.memories_deleted += 1;
256 continue;
257 }
258 }
259
260 let hours_elapsed = if now > memory.last_accessed_at {
262 (now - memory.last_accessed_at) as f64 / 3600.0
263 } else {
264 0.0
265 };
266
267 let strategy_override = policies
269 .get(&namespace)
270 .map(|p| p.decay_for_type(&memory.memory_type));
271
272 let new_importance = self.calculate_decay_with_strategy(
273 memory.importance,
274 hours_elapsed,
275 &memory.memory_type,
276 memory.access_count,
277 strategy_override,
278 );
279
280 if new_importance < self.config.min_importance {
283 let floored = self.config.min_importance;
284 if (memory.importance - floored).abs() > 0.001 {
285 let mut updated_memory = memory;
286 updated_memory.importance = floored;
287 let mut updated_vector = vector.clone();
288 updated_vector.metadata = Some(updated_memory.to_vector_metadata());
289 updated_vectors.push(updated_vector);
290 result.memories_decayed += 1;
291 }
292 result.memories_floored += 1;
293 continue;
294 }
295
296 if (new_importance - memory.importance).abs() > 0.001 {
298 let mut updated_memory = memory;
299 updated_memory.importance = new_importance;
300
301 let mut updated_vector = vector.clone();
303 updated_vector.metadata = Some(updated_memory.to_vector_metadata());
304 updated_vectors.push(updated_vector);
305 result.memories_decayed += 1;
306 }
307 }
308
309 if !ids_to_delete.is_empty() {
311 if let Err(e) = storage.delete(&namespace, &ids_to_delete).await {
312 tracing::warn!(
313 namespace = %namespace,
314 count = ids_to_delete.len(),
315 error = %e,
316 "Failed to delete expired memories"
317 );
318 }
319 }
320
321 if !updated_vectors.is_empty() {
323 if let Err(e) = storage.upsert(&namespace, updated_vectors).await {
324 tracing::warn!(
325 namespace = %namespace,
326 error = %e,
327 "Failed to upsert decayed memories"
328 );
329 }
330 }
331 }
332
333 tracing::info!(
334 namespaces_processed = result.namespaces_processed,
335 memories_processed = result.memories_processed,
336 memories_decayed = result.memories_decayed,
337 memories_deleted = result.memories_deleted,
338 "Decay cycle completed"
339 );
340
341 result
342 }
343
344 pub fn spawn(
351 config: Arc<RwLock<DecayConfig>>,
352 interval_secs: u64,
353 storage: Arc<dyn VectorStorage>,
354 metrics: Arc<BackgroundMetrics>,
355 redis: Option<RedisCache>,
356 node_id: String,
357 policies: Arc<RwLock<HashMap<String, MemoryPolicy>>>,
358 ) -> tokio::task::JoinHandle<()> {
359 let interval = std::time::Duration::from_secs(interval_secs);
360 let lock_ttl = interval_secs + 300;
363 const LOCK_KEY: &str = "dakera:lock:decay";
364
365 tokio::spawn(async move {
366 tracing::info!(
367 interval_secs,
368 "Decay engine started (hot-reload config via PUT /admin/decay/config)"
369 );
370
371 loop {
372 tokio::time::sleep(interval).await;
373
374 let acquired = match redis {
377 Some(ref rc) => rc.try_acquire_lock(LOCK_KEY, &node_id, lock_ttl).await,
378 None => true, };
380
381 if !acquired {
382 tracing::debug!("Decay skipped — another replica holds the leader lock");
383 continue;
384 }
385
386 let current_config = config.read().await.clone();
388 let current_policies = policies.read().await.clone();
390 let engine = DecayEngine::new(current_config);
391 let result = engine.apply_decay(&storage, ¤t_policies).await;
392 metrics.record_decay(&result);
393
394 if let Some(ref rc) = redis {
397 rc.release_lock(LOCK_KEY, &node_id).await;
398 }
399 }
400 })
401 }
402}
403
404#[derive(Debug, Default, Clone, Serialize, Deserialize)]
406pub struct DecayResult {
407 pub namespaces_processed: usize,
408 pub memories_processed: usize,
409 pub memories_decayed: usize,
410 pub memories_deleted: usize,
411 #[serde(default)]
413 pub memories_floored: usize,
414}
415
416#[derive(Debug, Default)]
422pub struct BackgroundMetrics {
423 inner: std::sync::Mutex<BackgroundMetricsInner>,
424 dirty: std::sync::atomic::AtomicBool,
426}
427
428const MAX_HISTORY_POINTS: usize = 168;
430
431#[derive(Debug, Default, Clone, Serialize, Deserialize)]
432pub struct BackgroundMetricsInner {
433 #[serde(default)]
435 pub last_decay: Option<DecayResult>,
436 #[serde(default)]
438 pub last_decay_at: Option<u64>,
439 #[serde(default)]
441 pub total_decay_deleted: u64,
442 #[serde(default)]
444 pub total_decay_floored: u64,
445 #[serde(default)]
447 pub total_decay_adjusted: u64,
448 #[serde(default)]
450 pub decay_cycles_run: u64,
451
452 #[serde(default)]
454 pub last_dedup: Option<DedupResultSnapshot>,
455 #[serde(default)]
457 pub last_dedup_at: Option<u64>,
458 #[serde(default)]
460 pub total_dedup_removed: u64,
461
462 #[serde(default)]
464 pub last_consolidation: Option<ConsolidationResultSnapshot>,
465 #[serde(default)]
467 pub last_consolidation_at: Option<u64>,
468 #[serde(default)]
470 pub total_consolidated: u64,
471
472 #[serde(default)]
474 pub history: Vec<ActivityHistoryPoint>,
475}
476
477#[derive(Debug, Clone, Serialize, Deserialize)]
479pub struct ActivityHistoryPoint {
480 pub timestamp: u64,
482 pub decay_deleted: u64,
484 pub decay_adjusted: u64,
486 pub dedup_removed: u64,
488 pub consolidated: u64,
490}
491
492#[derive(Debug, Default, Clone, Serialize, Deserialize)]
494pub struct DedupResultSnapshot {
495 pub namespaces_processed: usize,
496 pub memories_scanned: usize,
497 pub duplicates_removed: usize,
498}
499
500#[derive(Debug, Default, Clone, Serialize, Deserialize)]
502pub struct ConsolidationResultSnapshot {
503 pub namespaces_processed: usize,
504 pub memories_scanned: usize,
505 pub clusters_merged: usize,
506 pub memories_consolidated: usize,
507}
508
509impl BackgroundMetrics {
510 pub fn new() -> Self {
511 Self::default()
512 }
513
514 pub fn restore(inner: BackgroundMetricsInner) -> Self {
516 Self {
517 inner: std::sync::Mutex::new(inner),
518 dirty: std::sync::atomic::AtomicBool::new(false),
519 }
520 }
521
522 pub fn is_dirty(&self) -> bool {
524 self.dirty.load(std::sync::atomic::Ordering::Relaxed)
525 }
526
527 pub fn clear_dirty(&self) {
529 self.dirty
530 .store(false, std::sync::atomic::Ordering::Relaxed);
531 }
532
533 pub fn record_decay(&self, result: &DecayResult) {
535 let now = std::time::SystemTime::now()
536 .duration_since(std::time::UNIX_EPOCH)
537 .unwrap_or_default()
538 .as_secs();
539 let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
540 inner.total_decay_deleted += result.memories_deleted as u64;
541 inner.total_decay_floored += result.memories_floored as u64;
542 inner.total_decay_adjusted += result.memories_decayed as u64;
543 inner.decay_cycles_run += 1;
544 metrics::counter!("dakera_memories_decayed_total")
546 .increment(result.memories_floored as u64);
547 inner.last_decay = Some(result.clone());
548 inner.last_decay_at = Some(now);
549 push_history(
551 &mut inner.history,
552 ActivityHistoryPoint {
553 timestamp: now,
554 decay_deleted: result.memories_deleted as u64,
555 decay_adjusted: result.memories_decayed as u64,
556 dedup_removed: 0,
557 consolidated: 0,
558 },
559 );
560 self.dirty.store(true, std::sync::atomic::Ordering::Relaxed);
561 }
562
563 pub fn record_dedup(
565 &self,
566 namespaces_processed: usize,
567 memories_scanned: usize,
568 duplicates_removed: usize,
569 ) {
570 let now = std::time::SystemTime::now()
571 .duration_since(std::time::UNIX_EPOCH)
572 .unwrap_or_default()
573 .as_secs();
574 let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
575 inner.total_dedup_removed += duplicates_removed as u64;
576 inner.last_dedup = Some(DedupResultSnapshot {
577 namespaces_processed,
578 memories_scanned,
579 duplicates_removed,
580 });
581 inner.last_dedup_at = Some(now);
582 push_history(
583 &mut inner.history,
584 ActivityHistoryPoint {
585 timestamp: now,
586 decay_deleted: 0,
587 decay_adjusted: 0,
588 dedup_removed: duplicates_removed as u64,
589 consolidated: 0,
590 },
591 );
592 self.dirty.store(true, std::sync::atomic::Ordering::Relaxed);
593 }
594
595 pub fn record_consolidation(
597 &self,
598 namespaces_processed: usize,
599 memories_scanned: usize,
600 clusters_merged: usize,
601 memories_consolidated: usize,
602 ) {
603 let now = std::time::SystemTime::now()
604 .duration_since(std::time::UNIX_EPOCH)
605 .unwrap_or_default()
606 .as_secs();
607 let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
608 inner.total_consolidated += memories_consolidated as u64;
609 inner.last_consolidation = Some(ConsolidationResultSnapshot {
610 namespaces_processed,
611 memories_scanned,
612 clusters_merged,
613 memories_consolidated,
614 });
615 inner.last_consolidation_at = Some(now);
616 push_history(
617 &mut inner.history,
618 ActivityHistoryPoint {
619 timestamp: now,
620 decay_deleted: 0,
621 decay_adjusted: 0,
622 dedup_removed: 0,
623 consolidated: memories_consolidated as u64,
624 },
625 );
626 self.dirty.store(true, std::sync::atomic::Ordering::Relaxed);
627 }
628
629 pub fn restore_into(&self, restored: BackgroundMetricsInner) {
631 let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
632 *inner = restored;
633 }
635
636 pub fn snapshot(&self) -> BackgroundMetricsInner {
638 self.inner.lock().unwrap_or_else(|e| e.into_inner()).clone()
639 }
640}
641
642fn push_history(history: &mut Vec<ActivityHistoryPoint>, point: ActivityHistoryPoint) {
644 history.push(point);
645 if history.len() > MAX_HISTORY_POINTS {
646 let excess = history.len() - MAX_HISTORY_POINTS;
647 history.drain(..excess);
648 }
649}
650
651#[cfg(test)]
652mod tests {
653 use super::*;
654 use std::sync::Mutex;
655
656 static ENV_LOCK: Mutex<()> = Mutex::new(());
661
662 fn make_engine(strategy: DecayStrategy, half_life: f64) -> DecayEngine {
663 DecayEngine::new(DecayConfig {
664 strategy,
665 half_life_hours: half_life,
666 min_importance: 0.01,
667 })
668 }
669
670 const EPISODIC: MemoryType = MemoryType::Episodic;
672
673 #[test]
674 fn test_exponential_decay_at_half_life_episodic_no_access() {
675 let engine = make_engine(DecayStrategy::Exponential, 168.0);
676 let result = engine.calculate_decay(1.0, 112.0, &EPISODIC, 0);
678 assert!((result - 0.5).abs() < 0.01, "Expected ~0.5, got {}", result);
679 }
680
681 #[test]
682 fn test_exponential_decay_zero_time() {
683 let engine = make_engine(DecayStrategy::Exponential, 168.0);
684 let result = engine.calculate_decay(0.8, 0.0, &EPISODIC, 0);
685 assert!((result - 0.8).abs() < 0.001);
686 }
687
688 #[test]
689 fn test_linear_decay_floors_at_zero() {
690 let engine = make_engine(DecayStrategy::Linear, 168.0);
691 let result = engine.calculate_decay(0.3, 168.0, &EPISODIC, 0);
692 assert!(result >= 0.0, "Should not go below 0, got {}", result);
693 }
694
695 #[test]
696 fn test_procedural_decays_slower_than_working() {
697 let engine = make_engine(DecayStrategy::Exponential, 168.0);
698 let working = engine.calculate_decay(1.0, 168.0, &MemoryType::Working, 0);
699 let procedural = engine.calculate_decay(1.0, 168.0, &MemoryType::Procedural, 0);
700 assert!(
701 procedural > working,
702 "Procedural ({}) should decay slower than Working ({})",
703 procedural,
704 working
705 );
706 }
707
708 #[test]
709 fn test_high_access_count_decays_slower() {
710 let engine = make_engine(DecayStrategy::Exponential, 168.0);
711 let no_access = engine.calculate_decay(1.0, 168.0, &EPISODIC, 0);
712 let high_access = engine.calculate_decay(1.0, 168.0, &EPISODIC, 10);
713 assert!(
714 high_access > no_access,
715 "High access ({}) should decay slower than no access ({})",
716 high_access,
717 no_access
718 );
719 }
720
721 #[test]
722 fn test_semantic_decays_slower_than_episodic() {
723 let engine = make_engine(DecayStrategy::Exponential, 168.0);
724 let episodic = engine.calculate_decay(1.0, 168.0, &EPISODIC, 5);
725 let semantic = engine.calculate_decay(1.0, 168.0, &MemoryType::Semantic, 5);
726 assert!(
727 semantic > episodic,
728 "Semantic ({}) should decay slower than Episodic ({})",
729 semantic,
730 episodic
731 );
732 }
733
734 #[test]
735 fn test_access_boost_scales_with_importance() {
736 let low = DecayEngine::access_boost(0.2);
737 let high = DecayEngine::access_boost(0.8);
738 let boost_low = low - 0.2;
739 let boost_high = high - 0.8;
740 assert!(
741 boost_high > boost_low,
742 "High-importance boost ({}) should be larger than low-importance boost ({})",
743 boost_high,
744 boost_low
745 );
746 assert!((boost_low - (0.05 + 0.05 * 0.2)).abs() < 0.001);
748 assert!((boost_high - (0.05 + 0.05 * 0.8)).abs() < 0.001);
749 }
750
751 #[test]
752 fn test_access_boost_caps_at_one() {
753 assert!((DecayEngine::access_boost(1.0) - 1.0).abs() < 0.001);
754 assert!((DecayEngine::access_boost(0.96) - 1.0).abs() < 0.001);
755 }
756
757 #[test]
758 fn test_decay_clamps_to_range() {
759 let engine = make_engine(DecayStrategy::Exponential, 1.0);
760 let result = engine.calculate_decay(0.001, 100.0, &EPISODIC, 0);
761 assert!(result >= 0.0 && result <= 1.0);
762 }
763
764 #[test]
765 fn test_step_function_decay() {
766 let engine = make_engine(DecayStrategy::StepFunction, 168.0);
767 let eff_hl = 168.0 / 1.5;
769
770 let result = engine.calculate_decay(1.0, eff_hl * 0.5, &EPISODIC, 0);
772 assert!((result - 1.0).abs() < 0.001);
773
774 let result = engine.calculate_decay(1.0, eff_hl, &EPISODIC, 0);
776 assert!((result - 0.5).abs() < 0.001);
777 }
778
779 #[test]
782 fn test_decay_engine_new_stores_config() {
783 let cfg = DecayConfig {
784 strategy: DecayStrategy::Linear,
785 half_life_hours: 48.0,
786 min_importance: 0.05,
787 };
788 let engine = DecayEngine::new(cfg.clone());
789 assert!(matches!(engine.config.strategy, DecayStrategy::Linear));
790 assert!((engine.config.half_life_hours - 48.0).abs() < 1e-9);
791 assert!((engine.config.min_importance - 0.05).abs() < 1e-6);
792 }
793
794 #[test]
797 fn test_decay_engine_config_default_values() {
798 let cfg = DecayEngineConfig::default();
799 assert!(matches!(
800 cfg.decay_config.strategy,
801 DecayStrategy::Exponential
802 ));
803 assert!((cfg.decay_config.half_life_hours - 168.0).abs() < 1e-9);
804 assert!((cfg.decay_config.min_importance - 0.01).abs() < 1e-6);
805 assert_eq!(cfg.interval_secs, 3600);
806 }
807
808 #[test]
811 fn test_decay_engine_config_from_env_defaults_without_vars() {
812 let _guard = ENV_LOCK.lock().unwrap();
813 std::env::remove_var("DAKERA_DECAY_HALF_LIFE_HOURS");
815 std::env::remove_var("DAKERA_DECAY_MIN_IMPORTANCE");
816 std::env::remove_var("DAKERA_DECAY_INTERVAL_SECS");
817 std::env::remove_var("DAKERA_DECAY_STRATEGY");
818 let cfg = DecayEngineConfig::from_env();
819 assert!(matches!(
820 cfg.decay_config.strategy,
821 DecayStrategy::Exponential
822 ));
823 assert!((cfg.decay_config.half_life_hours - 168.0).abs() < 1e-9);
824 }
825
826 #[test]
827 fn test_decay_engine_config_from_env_linear_strategy() {
828 let _guard = ENV_LOCK.lock().unwrap();
829
830 std::env::set_var("DAKERA_DECAY_STRATEGY", "linear");
831 let cfg = DecayEngineConfig::from_env();
832 std::env::remove_var("DAKERA_DECAY_STRATEGY");
833 assert!(matches!(cfg.decay_config.strategy, DecayStrategy::Linear));
834 }
835
836 #[test]
837 fn test_decay_engine_config_from_env_step_strategy() {
838 let _guard = ENV_LOCK.lock().unwrap();
839
840 std::env::set_var("DAKERA_DECAY_STRATEGY", "step");
841 let cfg = DecayEngineConfig::from_env();
842 std::env::remove_var("DAKERA_DECAY_STRATEGY");
843 assert!(matches!(
844 cfg.decay_config.strategy,
845 DecayStrategy::StepFunction
846 ));
847 }
848
849 #[test]
850 fn test_decay_engine_config_from_env_unknown_strategy_defaults_to_exponential() {
851 let _guard = ENV_LOCK.lock().unwrap();
852
853 std::env::set_var("DAKERA_DECAY_STRATEGY", "bogus");
854 let cfg = DecayEngineConfig::from_env();
855 std::env::remove_var("DAKERA_DECAY_STRATEGY");
856 assert!(matches!(
857 cfg.decay_config.strategy,
858 DecayStrategy::Exponential
859 ));
860 }
861
862 #[test]
865 fn test_push_history_caps_at_max() {
866 let mut history: Vec<ActivityHistoryPoint> = Vec::new();
867 for i in 0..(MAX_HISTORY_POINTS + 10) {
869 push_history(
870 &mut history,
871 ActivityHistoryPoint {
872 timestamp: i as u64,
873 decay_deleted: 0,
874 decay_adjusted: 0,
875 dedup_removed: 0,
876 consolidated: 0,
877 },
878 );
879 }
880 assert_eq!(history.len(), MAX_HISTORY_POINTS);
881 assert_eq!(
883 history.last().unwrap().timestamp,
884 (MAX_HISTORY_POINTS + 9) as u64
885 );
886 }
887
888 #[test]
889 fn test_push_history_below_cap_grows_normally() {
890 let mut history: Vec<ActivityHistoryPoint> = Vec::new();
891 for i in 0..5 {
892 push_history(
893 &mut history,
894 ActivityHistoryPoint {
895 timestamp: i,
896 decay_deleted: 0,
897 decay_adjusted: 0,
898 dedup_removed: 0,
899 consolidated: 0,
900 },
901 );
902 }
903 assert_eq!(history.len(), 5);
904 }
905
906 #[test]
909 fn test_background_metrics_new_not_dirty() {
910 let m = BackgroundMetrics::new();
911 assert!(!m.is_dirty());
912 }
913
914 #[test]
915 fn test_background_metrics_record_decay_sets_dirty() {
916 let m = BackgroundMetrics::new();
917 let result = DecayResult {
918 namespaces_processed: 1,
919 memories_processed: 10,
920 memories_decayed: 3,
921 memories_deleted: 1,
922 memories_floored: 0,
923 };
924 m.record_decay(&result);
925 assert!(m.is_dirty());
926 }
927
928 #[test]
929 fn test_background_metrics_clear_dirty() {
930 let m = BackgroundMetrics::new();
931 let result = DecayResult::default();
932 m.record_decay(&result);
933 assert!(m.is_dirty());
934 m.clear_dirty();
935 assert!(!m.is_dirty());
936 }
937
938 #[test]
939 fn test_background_metrics_snapshot_totals() {
940 let m = BackgroundMetrics::new();
941 m.record_decay(&DecayResult {
942 namespaces_processed: 2,
943 memories_processed: 20,
944 memories_decayed: 5,
945 memories_deleted: 2,
946 memories_floored: 0,
947 });
948 m.record_decay(&DecayResult {
949 namespaces_processed: 1,
950 memories_processed: 5,
951 memories_decayed: 1,
952 memories_deleted: 1,
953 memories_floored: 0,
954 });
955 let snap = m.snapshot();
956 assert_eq!(snap.total_decay_deleted, 3); assert_eq!(snap.decay_cycles_run, 2);
958 }
959
960 #[test]
961 fn test_background_metrics_record_dedup() {
962 let m = BackgroundMetrics::new();
963 m.record_dedup(2, 100, 5);
964 let snap = m.snapshot();
965 assert_eq!(snap.total_dedup_removed, 5);
966 assert!(snap.last_dedup.is_some());
967 }
968
969 #[test]
970 fn test_background_metrics_record_consolidation() {
971 let m = BackgroundMetrics::new();
972 m.record_consolidation(1, 30, 2, 6);
973 let snap = m.snapshot();
974 assert_eq!(snap.total_consolidated, 6);
975 assert!(snap.last_consolidation.is_some());
976 }
977
978 #[test]
979 fn test_background_metrics_restore() {
980 let inner = BackgroundMetricsInner {
981 total_decay_deleted: 42,
982 decay_cycles_run: 7,
983 ..Default::default()
984 };
985 let m = BackgroundMetrics::restore(inner);
986 assert!(!m.is_dirty()); assert_eq!(m.snapshot().total_decay_deleted, 42);
988 assert_eq!(m.snapshot().decay_cycles_run, 7);
989 }
990
991 #[test]
994 fn test_linear_decay_formula() {
995 let engine = make_engine(DecayStrategy::Linear, 100.0);
996 let eff_hl = 100.0 / (1.0 * (1.0 / (1.0 + 0.1)));
1000 let decay_amount = (55.0 / eff_hl) * 0.5;
1001 let expected = (1.0_f32 - decay_amount as f32).max(0.0);
1002 let result = engine.calculate_decay(1.0, 55.0, &EPISODIC, 1);
1003 assert!(
1004 (result - expected).abs() < 0.01,
1005 "expected ~{expected}, got {result}"
1006 );
1007 }
1008
1009 #[test]
1010 fn test_working_memory_decays_fastest() {
1011 let engine = make_engine(DecayStrategy::Exponential, 168.0);
1012 let working = engine.calculate_decay(1.0, 168.0, &MemoryType::Working, 5);
1013 let episodic = engine.calculate_decay(1.0, 168.0, &EPISODIC, 5);
1014 let semantic = engine.calculate_decay(1.0, 168.0, &MemoryType::Semantic, 5);
1015 let procedural = engine.calculate_decay(1.0, 168.0, &MemoryType::Procedural, 5);
1016 assert!(working < episodic);
1017 assert!(episodic < semantic);
1018 assert!(semantic < procedural);
1019 }
1020
1021 #[test]
1022 fn test_access_boost_minimum_is_0_05() {
1023 let result = DecayEngine::access_boost(0.0);
1025 assert!((result - 0.05).abs() < 0.001, "expected 0.05, got {result}");
1026 }
1027}