1use std::sync::Arc;
10
11use common::{DecayConfig, DecayStrategy, Memory, MemoryType, Vector};
12use serde::{Deserialize, Serialize};
13use storage::{RedisCache, VectorStorage};
14use tokio::sync::RwLock;
15use tracing;
16
17pub struct DecayEngine {
19 pub config: DecayConfig,
20}
21
22pub struct DecayEngineConfig {
24 pub decay_config: DecayConfig,
26 pub interval_secs: u64,
28}
29
30impl Default for DecayEngineConfig {
31 fn default() -> Self {
32 Self {
33 decay_config: DecayConfig {
34 strategy: DecayStrategy::Exponential,
35 half_life_hours: 168.0, min_importance: 0.01,
37 },
38 interval_secs: 3600, }
40 }
41}
42
43impl DecayEngineConfig {
44 pub fn from_env() -> Self {
46 let half_life_hours: f64 = std::env::var("DAKERA_DECAY_HALF_LIFE_HOURS")
47 .ok()
48 .and_then(|v| v.parse().ok())
49 .unwrap_or(168.0);
50
51 let min_importance: f32 = std::env::var("DAKERA_DECAY_MIN_IMPORTANCE")
52 .ok()
53 .and_then(|v| v.parse().ok())
54 .unwrap_or(0.01);
55
56 let interval_secs: u64 = std::env::var("DAKERA_DECAY_INTERVAL_SECS")
57 .ok()
58 .and_then(|v| v.parse().ok())
59 .unwrap_or(3600);
60
61 let strategy_str =
62 std::env::var("DAKERA_DECAY_STRATEGY").unwrap_or_else(|_| "exponential".to_string());
63
64 let strategy = match strategy_str.to_lowercase().as_str() {
65 "linear" => DecayStrategy::Linear,
66 "step" | "stepfunction" | "step_function" => DecayStrategy::StepFunction,
67 _ => DecayStrategy::Exponential,
68 };
69
70 Self {
71 decay_config: DecayConfig {
72 strategy,
73 half_life_hours,
74 min_importance,
75 },
76 interval_secs,
77 }
78 }
79}
80
81impl DecayEngine {
82 pub fn new(config: DecayConfig) -> Self {
84 Self { config }
85 }
86
87 pub fn calculate_decay(
98 &self,
99 current_importance: f32,
100 hours_elapsed: f64,
101 memory_type: &MemoryType,
102 access_count: u32,
103 ) -> f32 {
104 if hours_elapsed <= 0.0 {
105 return current_importance;
106 }
107
108 let type_multiplier = match memory_type {
110 MemoryType::Working => 3.0,
111 MemoryType::Episodic => 1.0,
112 MemoryType::Semantic => 0.5,
113 MemoryType::Procedural => 0.3,
114 };
115
116 let usage_shield = if access_count > 0 {
118 1.0 / (1.0 + (access_count as f64 * 0.1))
119 } else {
120 1.5 };
122
123 let effective_half_life = self.config.half_life_hours / (type_multiplier * usage_shield);
124
125 let decayed = match self.config.strategy {
126 DecayStrategy::Exponential => {
127 let decay_factor = (0.5_f64).powf(hours_elapsed / effective_half_life);
128 current_importance * decay_factor as f32
129 }
130 DecayStrategy::Linear => {
131 let decay_amount = (hours_elapsed / effective_half_life) as f32 * 0.5;
132 (current_importance - decay_amount).max(0.0)
133 }
134 DecayStrategy::StepFunction => {
135 let steps = (hours_elapsed / effective_half_life).floor() as u32;
136 let decay_factor = (0.5_f32).powi(steps as i32);
137 current_importance * decay_factor
138 }
139 };
140
141 decayed.clamp(0.0, 1.0)
142 }
143
144 pub fn access_boost(current_importance: f32) -> f32 {
147 let boost = 0.05 + 0.05 * current_importance; (current_importance + boost).min(1.0)
149 }
150
151 pub async fn apply_decay(&self, storage: &Arc<dyn VectorStorage>) -> DecayResult {
157 let mut result = DecayResult::default();
158
159 let namespaces = match storage.list_namespaces().await {
161 Ok(ns) => ns,
162 Err(e) => {
163 tracing::error!(error = %e, "Failed to list namespaces for decay");
164 return result;
165 }
166 };
167
168 let now = std::time::SystemTime::now()
169 .duration_since(std::time::UNIX_EPOCH)
170 .unwrap_or_default()
171 .as_secs();
172
173 for namespace in namespaces {
175 if !namespace.starts_with("_dakera_agent_") {
176 continue;
177 }
178
179 result.namespaces_processed += 1;
180
181 let vectors = match storage.get_all(&namespace).await {
182 Ok(v) => v,
183 Err(e) => {
184 tracing::warn!(
185 namespace = %namespace,
186 error = %e,
187 "Failed to get vectors for decay"
188 );
189 continue;
190 }
191 };
192
193 let mut updated_vectors: Vec<Vector> = Vec::new();
194 let mut ids_to_delete: Vec<String> = Vec::new();
195
196 for vector in &vectors {
197 let memory = match Memory::from_vector(vector) {
198 Some(m) => m,
199 None => continue, };
201
202 result.memories_processed += 1;
203
204 if let Some(exp) = memory.expires_at {
207 if exp <= now {
208 ids_to_delete.push(memory.id.clone());
209 result.memories_deleted += 1;
210 continue;
211 }
212 }
213
214 let hours_elapsed = if now > memory.last_accessed_at {
216 (now - memory.last_accessed_at) as f64 / 3600.0
217 } else {
218 0.0
219 };
220
221 let new_importance = self.calculate_decay(
222 memory.importance,
223 hours_elapsed,
224 &memory.memory_type,
225 memory.access_count,
226 );
227
228 if new_importance < self.config.min_importance {
230 ids_to_delete.push(memory.id.clone());
231 result.memories_deleted += 1;
232 continue;
233 }
234
235 if (new_importance - memory.importance).abs() > 0.001 {
237 let mut updated_memory = memory;
238 updated_memory.importance = new_importance;
239
240 let mut updated_vector = vector.clone();
242 updated_vector.metadata = Some(updated_memory.to_vector_metadata());
243 updated_vectors.push(updated_vector);
244 result.memories_decayed += 1;
245 }
246 }
247
248 if !ids_to_delete.is_empty() {
250 if let Err(e) = storage.delete(&namespace, &ids_to_delete).await {
251 tracing::warn!(
252 namespace = %namespace,
253 count = ids_to_delete.len(),
254 error = %e,
255 "Failed to delete expired memories"
256 );
257 }
258 }
259
260 if !updated_vectors.is_empty() {
262 if let Err(e) = storage.upsert(&namespace, updated_vectors).await {
263 tracing::warn!(
264 namespace = %namespace,
265 error = %e,
266 "Failed to upsert decayed memories"
267 );
268 }
269 }
270 }
271
272 tracing::info!(
273 namespaces_processed = result.namespaces_processed,
274 memories_processed = result.memories_processed,
275 memories_decayed = result.memories_decayed,
276 memories_deleted = result.memories_deleted,
277 "Decay cycle completed"
278 );
279
280 result
281 }
282
283 pub fn spawn(
290 config: Arc<RwLock<DecayConfig>>,
291 interval_secs: u64,
292 storage: Arc<dyn VectorStorage>,
293 metrics: Arc<BackgroundMetrics>,
294 redis: Option<RedisCache>,
295 node_id: String,
296 ) -> tokio::task::JoinHandle<()> {
297 let interval = std::time::Duration::from_secs(interval_secs);
298 let lock_ttl = interval_secs + 300;
301 const LOCK_KEY: &str = "dakera:lock:decay";
302
303 tokio::spawn(async move {
304 tracing::info!(
305 interval_secs,
306 "Decay engine started (hot-reload config via PUT /admin/decay/config)"
307 );
308
309 loop {
310 tokio::time::sleep(interval).await;
311
312 let acquired = match redis {
315 Some(ref rc) => rc.try_acquire_lock(LOCK_KEY, &node_id, lock_ttl).await,
316 None => true, };
318
319 if !acquired {
320 tracing::debug!("Decay skipped — another replica holds the leader lock");
321 continue;
322 }
323
324 let current_config = config.read().await.clone();
326 let engine = DecayEngine::new(current_config);
327 let result = engine.apply_decay(&storage).await;
328 metrics.record_decay(&result);
329
330 if let Some(ref rc) = redis {
333 rc.release_lock(LOCK_KEY, &node_id).await;
334 }
335 }
336 })
337 }
338}
339
340#[derive(Debug, Default, Clone, Serialize, Deserialize)]
342pub struct DecayResult {
343 pub namespaces_processed: usize,
344 pub memories_processed: usize,
345 pub memories_decayed: usize,
346 pub memories_deleted: usize,
347}
348
349#[derive(Debug, Default)]
355pub struct BackgroundMetrics {
356 inner: std::sync::Mutex<BackgroundMetricsInner>,
357 dirty: std::sync::atomic::AtomicBool,
359}
360
361const MAX_HISTORY_POINTS: usize = 168;
363
364#[derive(Debug, Default, Clone, Serialize, Deserialize)]
365pub struct BackgroundMetricsInner {
366 #[serde(default)]
368 pub last_decay: Option<DecayResult>,
369 #[serde(default)]
371 pub last_decay_at: Option<u64>,
372 #[serde(default)]
374 pub total_decay_deleted: u64,
375 #[serde(default)]
377 pub total_decay_adjusted: u64,
378 #[serde(default)]
380 pub decay_cycles_run: u64,
381
382 #[serde(default)]
384 pub last_dedup: Option<DedupResultSnapshot>,
385 #[serde(default)]
387 pub last_dedup_at: Option<u64>,
388 #[serde(default)]
390 pub total_dedup_removed: u64,
391
392 #[serde(default)]
394 pub last_consolidation: Option<ConsolidationResultSnapshot>,
395 #[serde(default)]
397 pub last_consolidation_at: Option<u64>,
398 #[serde(default)]
400 pub total_consolidated: u64,
401
402 #[serde(default)]
404 pub history: Vec<ActivityHistoryPoint>,
405}
406
407#[derive(Debug, Clone, Serialize, Deserialize)]
409pub struct ActivityHistoryPoint {
410 pub timestamp: u64,
412 pub decay_deleted: u64,
414 pub decay_adjusted: u64,
416 pub dedup_removed: u64,
418 pub consolidated: u64,
420}
421
422#[derive(Debug, Default, Clone, Serialize, Deserialize)]
424pub struct DedupResultSnapshot {
425 pub namespaces_processed: usize,
426 pub memories_scanned: usize,
427 pub duplicates_removed: usize,
428}
429
430#[derive(Debug, Default, Clone, Serialize, Deserialize)]
432pub struct ConsolidationResultSnapshot {
433 pub namespaces_processed: usize,
434 pub memories_scanned: usize,
435 pub clusters_merged: usize,
436 pub memories_consolidated: usize,
437}
438
439impl BackgroundMetrics {
440 pub fn new() -> Self {
441 Self::default()
442 }
443
444 pub fn restore(inner: BackgroundMetricsInner) -> Self {
446 Self {
447 inner: std::sync::Mutex::new(inner),
448 dirty: std::sync::atomic::AtomicBool::new(false),
449 }
450 }
451
452 pub fn is_dirty(&self) -> bool {
454 self.dirty.load(std::sync::atomic::Ordering::Relaxed)
455 }
456
457 pub fn clear_dirty(&self) {
459 self.dirty
460 .store(false, std::sync::atomic::Ordering::Relaxed);
461 }
462
463 pub fn record_decay(&self, result: &DecayResult) {
465 let now = std::time::SystemTime::now()
466 .duration_since(std::time::UNIX_EPOCH)
467 .unwrap_or_default()
468 .as_secs();
469 let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
470 inner.total_decay_deleted += result.memories_deleted as u64;
471 inner.total_decay_adjusted += result.memories_decayed as u64;
472 inner.decay_cycles_run += 1;
473 inner.last_decay = Some(result.clone());
474 inner.last_decay_at = Some(now);
475 push_history(
477 &mut inner.history,
478 ActivityHistoryPoint {
479 timestamp: now,
480 decay_deleted: result.memories_deleted as u64,
481 decay_adjusted: result.memories_decayed as u64,
482 dedup_removed: 0,
483 consolidated: 0,
484 },
485 );
486 self.dirty.store(true, std::sync::atomic::Ordering::Relaxed);
487 }
488
489 pub fn record_dedup(
491 &self,
492 namespaces_processed: usize,
493 memories_scanned: usize,
494 duplicates_removed: usize,
495 ) {
496 let now = std::time::SystemTime::now()
497 .duration_since(std::time::UNIX_EPOCH)
498 .unwrap_or_default()
499 .as_secs();
500 let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
501 inner.total_dedup_removed += duplicates_removed as u64;
502 inner.last_dedup = Some(DedupResultSnapshot {
503 namespaces_processed,
504 memories_scanned,
505 duplicates_removed,
506 });
507 inner.last_dedup_at = Some(now);
508 push_history(
509 &mut inner.history,
510 ActivityHistoryPoint {
511 timestamp: now,
512 decay_deleted: 0,
513 decay_adjusted: 0,
514 dedup_removed: duplicates_removed as u64,
515 consolidated: 0,
516 },
517 );
518 self.dirty.store(true, std::sync::atomic::Ordering::Relaxed);
519 }
520
521 pub fn record_consolidation(
523 &self,
524 namespaces_processed: usize,
525 memories_scanned: usize,
526 clusters_merged: usize,
527 memories_consolidated: usize,
528 ) {
529 let now = std::time::SystemTime::now()
530 .duration_since(std::time::UNIX_EPOCH)
531 .unwrap_or_default()
532 .as_secs();
533 let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
534 inner.total_consolidated += memories_consolidated as u64;
535 inner.last_consolidation = Some(ConsolidationResultSnapshot {
536 namespaces_processed,
537 memories_scanned,
538 clusters_merged,
539 memories_consolidated,
540 });
541 inner.last_consolidation_at = Some(now);
542 push_history(
543 &mut inner.history,
544 ActivityHistoryPoint {
545 timestamp: now,
546 decay_deleted: 0,
547 decay_adjusted: 0,
548 dedup_removed: 0,
549 consolidated: memories_consolidated as u64,
550 },
551 );
552 self.dirty.store(true, std::sync::atomic::Ordering::Relaxed);
553 }
554
555 pub fn restore_into(&self, restored: BackgroundMetricsInner) {
557 let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
558 *inner = restored;
559 }
561
562 pub fn snapshot(&self) -> BackgroundMetricsInner {
564 self.inner.lock().unwrap_or_else(|e| e.into_inner()).clone()
565 }
566}
567
568fn push_history(history: &mut Vec<ActivityHistoryPoint>, point: ActivityHistoryPoint) {
570 history.push(point);
571 if history.len() > MAX_HISTORY_POINTS {
572 let excess = history.len() - MAX_HISTORY_POINTS;
573 history.drain(..excess);
574 }
575}
576
577#[cfg(test)]
578mod tests {
579 use super::*;
580 use std::sync::Mutex;
581
582 static ENV_LOCK: Mutex<()> = Mutex::new(());
587
588 fn make_engine(strategy: DecayStrategy, half_life: f64) -> DecayEngine {
589 DecayEngine::new(DecayConfig {
590 strategy,
591 half_life_hours: half_life,
592 min_importance: 0.01,
593 })
594 }
595
596 const EPISODIC: MemoryType = MemoryType::Episodic;
598
599 #[test]
600 fn test_exponential_decay_at_half_life_episodic_no_access() {
601 let engine = make_engine(DecayStrategy::Exponential, 168.0);
602 let result = engine.calculate_decay(1.0, 112.0, &EPISODIC, 0);
604 assert!((result - 0.5).abs() < 0.01, "Expected ~0.5, got {}", result);
605 }
606
607 #[test]
608 fn test_exponential_decay_zero_time() {
609 let engine = make_engine(DecayStrategy::Exponential, 168.0);
610 let result = engine.calculate_decay(0.8, 0.0, &EPISODIC, 0);
611 assert!((result - 0.8).abs() < 0.001);
612 }
613
614 #[test]
615 fn test_linear_decay_floors_at_zero() {
616 let engine = make_engine(DecayStrategy::Linear, 168.0);
617 let result = engine.calculate_decay(0.3, 168.0, &EPISODIC, 0);
618 assert!(result >= 0.0, "Should not go below 0, got {}", result);
619 }
620
621 #[test]
622 fn test_procedural_decays_slower_than_working() {
623 let engine = make_engine(DecayStrategy::Exponential, 168.0);
624 let working = engine.calculate_decay(1.0, 168.0, &MemoryType::Working, 0);
625 let procedural = engine.calculate_decay(1.0, 168.0, &MemoryType::Procedural, 0);
626 assert!(
627 procedural > working,
628 "Procedural ({}) should decay slower than Working ({})",
629 procedural,
630 working
631 );
632 }
633
634 #[test]
635 fn test_high_access_count_decays_slower() {
636 let engine = make_engine(DecayStrategy::Exponential, 168.0);
637 let no_access = engine.calculate_decay(1.0, 168.0, &EPISODIC, 0);
638 let high_access = engine.calculate_decay(1.0, 168.0, &EPISODIC, 10);
639 assert!(
640 high_access > no_access,
641 "High access ({}) should decay slower than no access ({})",
642 high_access,
643 no_access
644 );
645 }
646
647 #[test]
648 fn test_semantic_decays_slower_than_episodic() {
649 let engine = make_engine(DecayStrategy::Exponential, 168.0);
650 let episodic = engine.calculate_decay(1.0, 168.0, &EPISODIC, 5);
651 let semantic = engine.calculate_decay(1.0, 168.0, &MemoryType::Semantic, 5);
652 assert!(
653 semantic > episodic,
654 "Semantic ({}) should decay slower than Episodic ({})",
655 semantic,
656 episodic
657 );
658 }
659
660 #[test]
661 fn test_access_boost_scales_with_importance() {
662 let low = DecayEngine::access_boost(0.2);
663 let high = DecayEngine::access_boost(0.8);
664 let boost_low = low - 0.2;
665 let boost_high = high - 0.8;
666 assert!(
667 boost_high > boost_low,
668 "High-importance boost ({}) should be larger than low-importance boost ({})",
669 boost_high,
670 boost_low
671 );
672 assert!((boost_low - (0.05 + 0.05 * 0.2)).abs() < 0.001);
674 assert!((boost_high - (0.05 + 0.05 * 0.8)).abs() < 0.001);
675 }
676
677 #[test]
678 fn test_access_boost_caps_at_one() {
679 assert!((DecayEngine::access_boost(1.0) - 1.0).abs() < 0.001);
680 assert!((DecayEngine::access_boost(0.96) - 1.0).abs() < 0.001);
681 }
682
683 #[test]
684 fn test_decay_clamps_to_range() {
685 let engine = make_engine(DecayStrategy::Exponential, 1.0);
686 let result = engine.calculate_decay(0.001, 100.0, &EPISODIC, 0);
687 assert!(result >= 0.0 && result <= 1.0);
688 }
689
690 #[test]
691 fn test_step_function_decay() {
692 let engine = make_engine(DecayStrategy::StepFunction, 168.0);
693 let eff_hl = 168.0 / 1.5;
695
696 let result = engine.calculate_decay(1.0, eff_hl * 0.5, &EPISODIC, 0);
698 assert!((result - 1.0).abs() < 0.001);
699
700 let result = engine.calculate_decay(1.0, eff_hl, &EPISODIC, 0);
702 assert!((result - 0.5).abs() < 0.001);
703 }
704
705 #[test]
708 fn test_decay_engine_new_stores_config() {
709 let cfg = DecayConfig {
710 strategy: DecayStrategy::Linear,
711 half_life_hours: 48.0,
712 min_importance: 0.05,
713 };
714 let engine = DecayEngine::new(cfg.clone());
715 assert!(matches!(engine.config.strategy, DecayStrategy::Linear));
716 assert!((engine.config.half_life_hours - 48.0).abs() < 1e-9);
717 assert!((engine.config.min_importance - 0.05).abs() < 1e-6);
718 }
719
720 #[test]
723 fn test_decay_engine_config_default_values() {
724 let cfg = DecayEngineConfig::default();
725 assert!(matches!(
726 cfg.decay_config.strategy,
727 DecayStrategy::Exponential
728 ));
729 assert!((cfg.decay_config.half_life_hours - 168.0).abs() < 1e-9);
730 assert!((cfg.decay_config.min_importance - 0.01).abs() < 1e-6);
731 assert_eq!(cfg.interval_secs, 3600);
732 }
733
734 #[test]
737 fn test_decay_engine_config_from_env_defaults_without_vars() {
738 let _guard = ENV_LOCK.lock().unwrap();
739 std::env::remove_var("DAKERA_DECAY_HALF_LIFE_HOURS");
741 std::env::remove_var("DAKERA_DECAY_MIN_IMPORTANCE");
742 std::env::remove_var("DAKERA_DECAY_INTERVAL_SECS");
743 std::env::remove_var("DAKERA_DECAY_STRATEGY");
744 let cfg = DecayEngineConfig::from_env();
745 assert!(matches!(
746 cfg.decay_config.strategy,
747 DecayStrategy::Exponential
748 ));
749 assert!((cfg.decay_config.half_life_hours - 168.0).abs() < 1e-9);
750 }
751
752 #[test]
753 fn test_decay_engine_config_from_env_linear_strategy() {
754 let _guard = ENV_LOCK.lock().unwrap();
755
756 std::env::set_var("DAKERA_DECAY_STRATEGY", "linear");
757 let cfg = DecayEngineConfig::from_env();
758 std::env::remove_var("DAKERA_DECAY_STRATEGY");
759 assert!(matches!(cfg.decay_config.strategy, DecayStrategy::Linear));
760 }
761
762 #[test]
763 fn test_decay_engine_config_from_env_step_strategy() {
764 let _guard = ENV_LOCK.lock().unwrap();
765
766 std::env::set_var("DAKERA_DECAY_STRATEGY", "step");
767 let cfg = DecayEngineConfig::from_env();
768 std::env::remove_var("DAKERA_DECAY_STRATEGY");
769 assert!(matches!(
770 cfg.decay_config.strategy,
771 DecayStrategy::StepFunction
772 ));
773 }
774
775 #[test]
776 fn test_decay_engine_config_from_env_unknown_strategy_defaults_to_exponential() {
777 let _guard = ENV_LOCK.lock().unwrap();
778
779 std::env::set_var("DAKERA_DECAY_STRATEGY", "bogus");
780 let cfg = DecayEngineConfig::from_env();
781 std::env::remove_var("DAKERA_DECAY_STRATEGY");
782 assert!(matches!(
783 cfg.decay_config.strategy,
784 DecayStrategy::Exponential
785 ));
786 }
787
788 #[test]
791 fn test_push_history_caps_at_max() {
792 let mut history: Vec<ActivityHistoryPoint> = Vec::new();
793 for i in 0..(MAX_HISTORY_POINTS + 10) {
795 push_history(
796 &mut history,
797 ActivityHistoryPoint {
798 timestamp: i as u64,
799 decay_deleted: 0,
800 decay_adjusted: 0,
801 dedup_removed: 0,
802 consolidated: 0,
803 },
804 );
805 }
806 assert_eq!(history.len(), MAX_HISTORY_POINTS);
807 assert_eq!(
809 history.last().unwrap().timestamp,
810 (MAX_HISTORY_POINTS + 9) as u64
811 );
812 }
813
814 #[test]
815 fn test_push_history_below_cap_grows_normally() {
816 let mut history: Vec<ActivityHistoryPoint> = Vec::new();
817 for i in 0..5 {
818 push_history(
819 &mut history,
820 ActivityHistoryPoint {
821 timestamp: i,
822 decay_deleted: 0,
823 decay_adjusted: 0,
824 dedup_removed: 0,
825 consolidated: 0,
826 },
827 );
828 }
829 assert_eq!(history.len(), 5);
830 }
831
832 #[test]
835 fn test_background_metrics_new_not_dirty() {
836 let m = BackgroundMetrics::new();
837 assert!(!m.is_dirty());
838 }
839
840 #[test]
841 fn test_background_metrics_record_decay_sets_dirty() {
842 let m = BackgroundMetrics::new();
843 let result = DecayResult {
844 namespaces_processed: 1,
845 memories_processed: 10,
846 memories_decayed: 3,
847 memories_deleted: 1,
848 };
849 m.record_decay(&result);
850 assert!(m.is_dirty());
851 }
852
853 #[test]
854 fn test_background_metrics_clear_dirty() {
855 let m = BackgroundMetrics::new();
856 let result = DecayResult::default();
857 m.record_decay(&result);
858 assert!(m.is_dirty());
859 m.clear_dirty();
860 assert!(!m.is_dirty());
861 }
862
863 #[test]
864 fn test_background_metrics_snapshot_totals() {
865 let m = BackgroundMetrics::new();
866 m.record_decay(&DecayResult {
867 namespaces_processed: 2,
868 memories_processed: 20,
869 memories_decayed: 5,
870 memories_deleted: 2,
871 });
872 m.record_decay(&DecayResult {
873 namespaces_processed: 1,
874 memories_processed: 5,
875 memories_decayed: 1,
876 memories_deleted: 1,
877 });
878 let snap = m.snapshot();
879 assert_eq!(snap.total_decay_deleted, 3); assert_eq!(snap.decay_cycles_run, 2);
881 }
882
883 #[test]
884 fn test_background_metrics_record_dedup() {
885 let m = BackgroundMetrics::new();
886 m.record_dedup(2, 100, 5);
887 let snap = m.snapshot();
888 assert_eq!(snap.total_dedup_removed, 5);
889 assert!(snap.last_dedup.is_some());
890 }
891
892 #[test]
893 fn test_background_metrics_record_consolidation() {
894 let m = BackgroundMetrics::new();
895 m.record_consolidation(1, 30, 2, 6);
896 let snap = m.snapshot();
897 assert_eq!(snap.total_consolidated, 6);
898 assert!(snap.last_consolidation.is_some());
899 }
900
901 #[test]
902 fn test_background_metrics_restore() {
903 let inner = BackgroundMetricsInner {
904 total_decay_deleted: 42,
905 decay_cycles_run: 7,
906 ..Default::default()
907 };
908 let m = BackgroundMetrics::restore(inner);
909 assert!(!m.is_dirty()); assert_eq!(m.snapshot().total_decay_deleted, 42);
911 assert_eq!(m.snapshot().decay_cycles_run, 7);
912 }
913
914 #[test]
917 fn test_linear_decay_formula() {
918 let engine = make_engine(DecayStrategy::Linear, 100.0);
919 let eff_hl = 100.0 / (1.0 * (1.0 / (1.0 + 0.1)));
923 let decay_amount = (55.0 / eff_hl) * 0.5;
924 let expected = (1.0_f32 - decay_amount as f32).max(0.0);
925 let result = engine.calculate_decay(1.0, 55.0, &EPISODIC, 1);
926 assert!(
927 (result - expected).abs() < 0.01,
928 "expected ~{expected}, got {result}"
929 );
930 }
931
932 #[test]
933 fn test_working_memory_decays_fastest() {
934 let engine = make_engine(DecayStrategy::Exponential, 168.0);
935 let working = engine.calculate_decay(1.0, 168.0, &MemoryType::Working, 5);
936 let episodic = engine.calculate_decay(1.0, 168.0, &EPISODIC, 5);
937 let semantic = engine.calculate_decay(1.0, 168.0, &MemoryType::Semantic, 5);
938 let procedural = engine.calculate_decay(1.0, 168.0, &MemoryType::Procedural, 5);
939 assert!(working < episodic);
940 assert!(episodic < semantic);
941 assert!(semantic < procedural);
942 }
943
944 #[test]
945 fn test_access_boost_minimum_is_0_05() {
946 let result = DecayEngine::access_boost(0.0);
948 assert!((result - 0.05).abs() < 0.001, "expected 0.05, got {result}");
949 }
950}