Skip to main content

engine/
decay.rs

1//! Importance Decay Engine for Dakera AI Agent Memory Platform.
2//!
3//! Background task that periodically decays memory importance scores
4//! based on configurable strategies: Exponential, Linear, or StepFunction.
5//!
6//! On every recall hit, importance gets an access boost.
7//! Memories below `min_importance` are auto-deleted.
8
9use 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
18/// Importance Decay Engine that runs as a background task.
19pub struct DecayEngine {
20    pub config: DecayConfig,
21}
22
23/// Configuration loaded from environment variables.
24pub struct DecayEngineConfig {
25    /// Decay configuration (strategy, half_life, min_importance)
26    pub decay_config: DecayConfig,
27    /// How often to run decay (in seconds)
28    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, // 1 week
37                min_importance: 0.01,
38            },
39            interval_secs: 3600, // 1 hour
40        }
41    }
42}
43
44impl DecayEngineConfig {
45    /// Load configuration from environment variables.
46    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    /// Create a new DecayEngine with the given configuration.
84    pub fn new(config: DecayConfig) -> Self {
85        Self { config }
86    }
87
88    /// Calculate decayed importance for a single memory.
89    ///
90    /// Memory type determines base decay speed:
91    /// - Working:    3× faster (temporary by design)
92    /// - Episodic:   1× normal (events fade naturally)
93    /// - Semantic:   0.5× slower (knowledge persists)
94    /// - Procedural: 0.3× slower (skills are durable)
95    ///
96    /// Usage pattern shields from decay (diminishing returns).
97    /// Never-accessed memories fade 50% faster.
98    ///
99    /// Pass `strategy_override` from a per-namespace `MemoryPolicy` to use
100    /// a different curve for the given memory type (COG-1).
101    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    /// Like `calculate_decay` but accepts an optional per-type strategy override (COG-1).
118    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        // Memory type determines base decay speed
131        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        // Usage pattern shields from decay (diminishing returns)
139        let usage_shield = if access_count > 0 {
140            1.0 / (1.0 + (access_count as f64 * 0.1))
141        } else {
142            1.5 // never accessed = 50% faster decay
143        };
144
145        let effective_half_life = self.config.half_life_hours / (type_multiplier * usage_shield);
146
147        // Use per-type strategy override from MemoryPolicy if provided (COG-1), else global config.
148        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            // COG-1: Power-law decay — I(t) = I₀ / (1 + k·t)^α
165            // k=1/half_life, α=1.0 (gives 50% at t=half_life)
166            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            // COG-1: Logarithmic decay — I(t) = I₀ · max(0, 1 − log₂(1 + t/h))
172            // Drops slowly at first, then accelerates; reaches 0 at t = h·(2−1) = h
173            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            // COG-1: Flat — procedural/skill memories do not decay
178            DecayStrategy::Flat => current_importance,
179        };
180
181        decayed.clamp(0.0, 1.0)
182    }
183
184    /// Calculate access boost for a memory that was just recalled.
185    /// Scales with current importance — valuable memories get bigger boosts.
186    pub fn access_boost(current_importance: f32) -> f32 {
187        let boost = 0.05 + 0.05 * current_importance; // 0.05–0.10 range
188        (current_importance + boost).min(1.0)
189    }
190
191    /// Apply decay to all memories across all agent namespaces.
192    ///
193    /// Iterates all namespaces prefixed with `_dakera_agent_`, loads memories,
194    /// applies decay based on time since last access, and removes memories
195    /// below the minimum importance threshold.
196    ///
197    /// `policies` maps namespace → `MemoryPolicy` for per-type decay curve overrides (COG-1).
198    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        // List all namespaces
206        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        // Only process Dakera agent namespaces
220        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, // Skip non-memory vectors
246                };
247
248                result.memories_processed += 1;
249
250                // DECAY-3: Hard-delete memories whose explicit TTL has expired.
251                // expires_at bypasses decay scoring — immediate removal.
252                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                // Calculate hours since last access
261                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                // COG-1: look up per-type strategy override from namespace MemoryPolicy.
268                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                // Check if below minimum importance
281                if new_importance < self.config.min_importance {
282                    ids_to_delete.push(memory.id.clone());
283                    result.memories_deleted += 1;
284                    continue;
285                }
286
287                // Only update if importance actually changed
288                if (new_importance - memory.importance).abs() > 0.001 {
289                    let mut updated_memory = memory;
290                    updated_memory.importance = new_importance;
291
292                    // Rebuild vector with updated metadata but same embedding
293                    let mut updated_vector = vector.clone();
294                    updated_vector.metadata = Some(updated_memory.to_vector_metadata());
295                    updated_vectors.push(updated_vector);
296                    result.memories_decayed += 1;
297                }
298            }
299
300            // Delete memories below threshold
301            if !ids_to_delete.is_empty() {
302                if let Err(e) = storage.delete(&namespace, &ids_to_delete).await {
303                    tracing::warn!(
304                        namespace = %namespace,
305                        count = ids_to_delete.len(),
306                        error = %e,
307                        "Failed to delete expired memories"
308                    );
309                }
310            }
311
312            // Upsert updated memories
313            if !updated_vectors.is_empty() {
314                if let Err(e) = storage.upsert(&namespace, updated_vectors).await {
315                    tracing::warn!(
316                        namespace = %namespace,
317                        error = %e,
318                        "Failed to upsert decayed memories"
319                    );
320                }
321            }
322        }
323
324        tracing::info!(
325            namespaces_processed = result.namespaces_processed,
326            memories_processed = result.memories_processed,
327            memories_decayed = result.memories_decayed,
328            memories_deleted = result.memories_deleted,
329            "Decay cycle completed"
330        );
331
332        result
333    }
334
335    /// Spawn the decay engine as a background tokio task.
336    ///
337    /// Takes a shared `Arc<RwLock<DecayConfig>>` so that config changes made at
338    /// runtime via `PUT /admin/decay/config` take effect without a server restart.
339    /// Each loop iteration re-reads the config before running, so strategy/half-life
340    /// changes apply on the next cycle.
341    pub fn spawn(
342        config: Arc<RwLock<DecayConfig>>,
343        interval_secs: u64,
344        storage: Arc<dyn VectorStorage>,
345        metrics: Arc<BackgroundMetrics>,
346        redis: Option<RedisCache>,
347        node_id: String,
348        policies: Arc<RwLock<HashMap<String, MemoryPolicy>>>,
349    ) -> tokio::task::JoinHandle<()> {
350        let interval = std::time::Duration::from_secs(interval_secs);
351        // Lock TTL = interval + 5 min safety margin, so a stale lock never blocks
352        // more than one missed cycle.
353        let lock_ttl = interval_secs + 300;
354        const LOCK_KEY: &str = "dakera:lock:decay";
355
356        tokio::spawn(async move {
357            tracing::info!(
358                interval_secs,
359                "Decay engine started (hot-reload config via PUT /admin/decay/config)"
360            );
361
362            loop {
363                tokio::time::sleep(interval).await;
364
365                // Leader election: acquire Redis lock before running decay.
366                // Graceful degradation: if Redis is unavailable, run anyway (in-process fallback).
367                let acquired = match redis {
368                    Some(ref rc) => rc.try_acquire_lock(LOCK_KEY, &node_id, lock_ttl).await,
369                    None => true, // No Redis configured — single-node mode, always run
370                };
371
372                if !acquired {
373                    tracing::debug!("Decay skipped — another replica holds the leader lock");
374                    continue;
375                }
376
377                // Re-read config before each cycle — picks up hot-reload changes
378                let current_config = config.read().await.clone();
379                // Snapshot memory policies for this cycle (COG-1 per-type decay curves)
380                let current_policies = policies.read().await.clone();
381                let engine = DecayEngine::new(current_config);
382                let result = engine.apply_decay(&storage, &current_policies).await;
383                metrics.record_decay(&result);
384
385                // Release the lock so another replica can acquire it next cycle
386                // (or if this node crashes, the TTL covers cleanup).
387                if let Some(ref rc) = redis {
388                    rc.release_lock(LOCK_KEY, &node_id).await;
389                }
390            }
391        })
392    }
393}
394
395/// Result of a decay cycle.
396#[derive(Debug, Default, Clone, Serialize, Deserialize)]
397pub struct DecayResult {
398    pub namespaces_processed: usize,
399    pub memories_processed: usize,
400    pub memories_decayed: usize,
401    pub memories_deleted: usize,
402}
403
404/// Shared metrics for background activity tracking.
405///
406/// Updated by decay/autopilot spawn loops so the API can expose
407/// what background jobs are doing to user memories.
408/// Persisted to storage so metrics survive restarts.
409#[derive(Debug, Default)]
410pub struct BackgroundMetrics {
411    inner: std::sync::Mutex<BackgroundMetricsInner>,
412    /// Flag: dirty since last persist
413    dirty: std::sync::atomic::AtomicBool,
414}
415
416/// Max history data points kept (7 days at 1-hour decay interval).
417const MAX_HISTORY_POINTS: usize = 168;
418
419#[derive(Debug, Default, Clone, Serialize, Deserialize)]
420pub struct BackgroundMetricsInner {
421    /// Last decay cycle result
422    #[serde(default)]
423    pub last_decay: Option<DecayResult>,
424    /// Timestamp of last decay run (unix secs)
425    #[serde(default)]
426    pub last_decay_at: Option<u64>,
427    /// Cumulative memories deleted by decay
428    #[serde(default)]
429    pub total_decay_deleted: u64,
430    /// Cumulative memories decayed (importance lowered)
431    #[serde(default)]
432    pub total_decay_adjusted: u64,
433    /// Total number of decay cycles completed
434    #[serde(default)]
435    pub decay_cycles_run: u64,
436
437    /// Last dedup cycle result
438    #[serde(default)]
439    pub last_dedup: Option<DedupResultSnapshot>,
440    /// Timestamp of last dedup run (unix secs)
441    #[serde(default)]
442    pub last_dedup_at: Option<u64>,
443    /// Cumulative duplicates removed
444    #[serde(default)]
445    pub total_dedup_removed: u64,
446
447    /// Last consolidation cycle result
448    #[serde(default)]
449    pub last_consolidation: Option<ConsolidationResultSnapshot>,
450    /// Timestamp of last consolidation run (unix secs)
451    #[serde(default)]
452    pub last_consolidation_at: Option<u64>,
453    /// Cumulative memories consolidated
454    #[serde(default)]
455    pub total_consolidated: u64,
456
457    /// Historical data points for graphing (ring buffer, newest last)
458    #[serde(default)]
459    pub history: Vec<ActivityHistoryPoint>,
460}
461
462/// A single historical data point for the activity timeline graph.
463#[derive(Debug, Clone, Serialize, Deserialize)]
464pub struct ActivityHistoryPoint {
465    /// Unix timestamp (seconds)
466    pub timestamp: u64,
467    /// Memories deleted by decay in this cycle
468    pub decay_deleted: u64,
469    /// Memories adjusted by decay in this cycle
470    pub decay_adjusted: u64,
471    /// Duplicates removed in this cycle
472    pub dedup_removed: u64,
473    /// Memories consolidated in this cycle
474    pub consolidated: u64,
475}
476
477/// Serializable snapshot of a dedup result (avoids coupling to autopilot module).
478#[derive(Debug, Default, Clone, Serialize, Deserialize)]
479pub struct DedupResultSnapshot {
480    pub namespaces_processed: usize,
481    pub memories_scanned: usize,
482    pub duplicates_removed: usize,
483}
484
485/// Serializable snapshot of a consolidation result.
486#[derive(Debug, Default, Clone, Serialize, Deserialize)]
487pub struct ConsolidationResultSnapshot {
488    pub namespaces_processed: usize,
489    pub memories_scanned: usize,
490    pub clusters_merged: usize,
491    pub memories_consolidated: usize,
492}
493
494impl BackgroundMetrics {
495    pub fn new() -> Self {
496        Self::default()
497    }
498
499    /// Restore metrics from a previously persisted snapshot.
500    pub fn restore(inner: BackgroundMetricsInner) -> Self {
501        Self {
502            inner: std::sync::Mutex::new(inner),
503            dirty: std::sync::atomic::AtomicBool::new(false),
504        }
505    }
506
507    /// Whether metrics changed since last persist.
508    pub fn is_dirty(&self) -> bool {
509        self.dirty.load(std::sync::atomic::Ordering::Relaxed)
510    }
511
512    /// Clear the dirty flag after a successful persist.
513    pub fn clear_dirty(&self) {
514        self.dirty
515            .store(false, std::sync::atomic::Ordering::Relaxed);
516    }
517
518    /// Record a decay cycle result.
519    pub fn record_decay(&self, result: &DecayResult) {
520        let now = std::time::SystemTime::now()
521            .duration_since(std::time::UNIX_EPOCH)
522            .unwrap_or_default()
523            .as_secs();
524        let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
525        inner.total_decay_deleted += result.memories_deleted as u64;
526        inner.total_decay_adjusted += result.memories_decayed as u64;
527        inner.decay_cycles_run += 1;
528        inner.last_decay = Some(result.clone());
529        inner.last_decay_at = Some(now);
530        // Append history point
531        push_history(
532            &mut inner.history,
533            ActivityHistoryPoint {
534                timestamp: now,
535                decay_deleted: result.memories_deleted as u64,
536                decay_adjusted: result.memories_decayed as u64,
537                dedup_removed: 0,
538                consolidated: 0,
539            },
540        );
541        self.dirty.store(true, std::sync::atomic::Ordering::Relaxed);
542    }
543
544    /// Record a dedup cycle result.
545    pub fn record_dedup(
546        &self,
547        namespaces_processed: usize,
548        memories_scanned: usize,
549        duplicates_removed: usize,
550    ) {
551        let now = std::time::SystemTime::now()
552            .duration_since(std::time::UNIX_EPOCH)
553            .unwrap_or_default()
554            .as_secs();
555        let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
556        inner.total_dedup_removed += duplicates_removed as u64;
557        inner.last_dedup = Some(DedupResultSnapshot {
558            namespaces_processed,
559            memories_scanned,
560            duplicates_removed,
561        });
562        inner.last_dedup_at = Some(now);
563        push_history(
564            &mut inner.history,
565            ActivityHistoryPoint {
566                timestamp: now,
567                decay_deleted: 0,
568                decay_adjusted: 0,
569                dedup_removed: duplicates_removed as u64,
570                consolidated: 0,
571            },
572        );
573        self.dirty.store(true, std::sync::atomic::Ordering::Relaxed);
574    }
575
576    /// Record a consolidation cycle result.
577    pub fn record_consolidation(
578        &self,
579        namespaces_processed: usize,
580        memories_scanned: usize,
581        clusters_merged: usize,
582        memories_consolidated: usize,
583    ) {
584        let now = std::time::SystemTime::now()
585            .duration_since(std::time::UNIX_EPOCH)
586            .unwrap_or_default()
587            .as_secs();
588        let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
589        inner.total_consolidated += memories_consolidated as u64;
590        inner.last_consolidation = Some(ConsolidationResultSnapshot {
591            namespaces_processed,
592            memories_scanned,
593            clusters_merged,
594            memories_consolidated,
595        });
596        inner.last_consolidation_at = Some(now);
597        push_history(
598            &mut inner.history,
599            ActivityHistoryPoint {
600                timestamp: now,
601                decay_deleted: 0,
602                decay_adjusted: 0,
603                dedup_removed: 0,
604                consolidated: memories_consolidated as u64,
605            },
606        );
607        self.dirty.store(true, std::sync::atomic::Ordering::Relaxed);
608    }
609
610    /// Replace the inner state with a restored snapshot (used on startup).
611    pub fn restore_into(&self, restored: BackgroundMetricsInner) {
612        let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
613        *inner = restored;
614        // Don't set dirty — this was just loaded from storage
615    }
616
617    /// Get a snapshot of all metrics for API response.
618    pub fn snapshot(&self) -> BackgroundMetricsInner {
619        self.inner.lock().unwrap_or_else(|e| e.into_inner()).clone()
620    }
621}
622
623/// Append a history point, capping at MAX_HISTORY_POINTS.
624fn push_history(history: &mut Vec<ActivityHistoryPoint>, point: ActivityHistoryPoint) {
625    history.push(point);
626    if history.len() > MAX_HISTORY_POINTS {
627        let excess = history.len() - MAX_HISTORY_POINTS;
628        history.drain(..excess);
629    }
630}
631
632#[cfg(test)]
633mod tests {
634    use super::*;
635    use std::sync::Mutex;
636
637    // Shared lock for every test that reads or writes DAKERA_DECAY_* env vars.
638    // Function-local `static ENV_LOCK` creates a separate mutex per test function,
639    // which means the locks are completely independent and offer no protection
640    // across concurrent tests. All env-var tests must share this single lock.
641    static ENV_LOCK: Mutex<()> = Mutex::new(());
642
643    fn make_engine(strategy: DecayStrategy, half_life: f64) -> DecayEngine {
644        DecayEngine::new(DecayConfig {
645            strategy,
646            half_life_hours: half_life,
647            min_importance: 0.01,
648        })
649    }
650
651    // Default args: Episodic type, 0 access count (same as old behavior with multiplier=1.0, shield=1.5)
652    const EPISODIC: MemoryType = MemoryType::Episodic;
653
654    #[test]
655    fn test_exponential_decay_at_half_life_episodic_no_access() {
656        let engine = make_engine(DecayStrategy::Exponential, 168.0);
657        // Effective half-life = 168 / (1.0 * 1.5) = 112h (faster for never-accessed)
658        let result = engine.calculate_decay(1.0, 112.0, &EPISODIC, 0);
659        assert!((result - 0.5).abs() < 0.01, "Expected ~0.5, got {}", result);
660    }
661
662    #[test]
663    fn test_exponential_decay_zero_time() {
664        let engine = make_engine(DecayStrategy::Exponential, 168.0);
665        let result = engine.calculate_decay(0.8, 0.0, &EPISODIC, 0);
666        assert!((result - 0.8).abs() < 0.001);
667    }
668
669    #[test]
670    fn test_linear_decay_floors_at_zero() {
671        let engine = make_engine(DecayStrategy::Linear, 168.0);
672        let result = engine.calculate_decay(0.3, 168.0, &EPISODIC, 0);
673        assert!(result >= 0.0, "Should not go below 0, got {}", result);
674    }
675
676    #[test]
677    fn test_procedural_decays_slower_than_working() {
678        let engine = make_engine(DecayStrategy::Exponential, 168.0);
679        let working = engine.calculate_decay(1.0, 168.0, &MemoryType::Working, 0);
680        let procedural = engine.calculate_decay(1.0, 168.0, &MemoryType::Procedural, 0);
681        assert!(
682            procedural > working,
683            "Procedural ({}) should decay slower than Working ({})",
684            procedural,
685            working
686        );
687    }
688
689    #[test]
690    fn test_high_access_count_decays_slower() {
691        let engine = make_engine(DecayStrategy::Exponential, 168.0);
692        let no_access = engine.calculate_decay(1.0, 168.0, &EPISODIC, 0);
693        let high_access = engine.calculate_decay(1.0, 168.0, &EPISODIC, 10);
694        assert!(
695            high_access > no_access,
696            "High access ({}) should decay slower than no access ({})",
697            high_access,
698            no_access
699        );
700    }
701
702    #[test]
703    fn test_semantic_decays_slower_than_episodic() {
704        let engine = make_engine(DecayStrategy::Exponential, 168.0);
705        let episodic = engine.calculate_decay(1.0, 168.0, &EPISODIC, 5);
706        let semantic = engine.calculate_decay(1.0, 168.0, &MemoryType::Semantic, 5);
707        assert!(
708            semantic > episodic,
709            "Semantic ({}) should decay slower than Episodic ({})",
710            semantic,
711            episodic
712        );
713    }
714
715    #[test]
716    fn test_access_boost_scales_with_importance() {
717        let low = DecayEngine::access_boost(0.2);
718        let high = DecayEngine::access_boost(0.8);
719        let boost_low = low - 0.2;
720        let boost_high = high - 0.8;
721        assert!(
722            boost_high > boost_low,
723            "High-importance boost ({}) should be larger than low-importance boost ({})",
724            boost_high,
725            boost_low
726        );
727        // Verify range: 0.05 + 0.05*importance
728        assert!((boost_low - (0.05 + 0.05 * 0.2)).abs() < 0.001);
729        assert!((boost_high - (0.05 + 0.05 * 0.8)).abs() < 0.001);
730    }
731
732    #[test]
733    fn test_access_boost_caps_at_one() {
734        assert!((DecayEngine::access_boost(1.0) - 1.0).abs() < 0.001);
735        assert!((DecayEngine::access_boost(0.96) - 1.0).abs() < 0.001);
736    }
737
738    #[test]
739    fn test_decay_clamps_to_range() {
740        let engine = make_engine(DecayStrategy::Exponential, 1.0);
741        let result = engine.calculate_decay(0.001, 100.0, &EPISODIC, 0);
742        assert!(result >= 0.0 && result <= 1.0);
743    }
744
745    #[test]
746    fn test_step_function_decay() {
747        let engine = make_engine(DecayStrategy::StepFunction, 168.0);
748        // Effective half-life for Episodic+0 access = 168/1.5 = 112h
749        let eff_hl = 168.0 / 1.5;
750
751        // Before first step - no decay
752        let result = engine.calculate_decay(1.0, eff_hl * 0.5, &EPISODIC, 0);
753        assert!((result - 1.0).abs() < 0.001);
754
755        // At first step
756        let result = engine.calculate_decay(1.0, eff_hl, &EPISODIC, 0);
757        assert!((result - 0.5).abs() < 0.001);
758    }
759
760    // ── DecayEngine::new ─────────────────────────────────────────────────────
761
762    #[test]
763    fn test_decay_engine_new_stores_config() {
764        let cfg = DecayConfig {
765            strategy: DecayStrategy::Linear,
766            half_life_hours: 48.0,
767            min_importance: 0.05,
768        };
769        let engine = DecayEngine::new(cfg.clone());
770        assert!(matches!(engine.config.strategy, DecayStrategy::Linear));
771        assert!((engine.config.half_life_hours - 48.0).abs() < 1e-9);
772        assert!((engine.config.min_importance - 0.05).abs() < 1e-6);
773    }
774
775    // ── DecayEngineConfig::default ────────────────────────────────────────────
776
777    #[test]
778    fn test_decay_engine_config_default_values() {
779        let cfg = DecayEngineConfig::default();
780        assert!(matches!(
781            cfg.decay_config.strategy,
782            DecayStrategy::Exponential
783        ));
784        assert!((cfg.decay_config.half_life_hours - 168.0).abs() < 1e-9);
785        assert!((cfg.decay_config.min_importance - 0.01).abs() < 1e-6);
786        assert_eq!(cfg.interval_secs, 3600);
787    }
788
789    // ── DecayEngineConfig::from_env ────────────────────────────────────────────
790
791    #[test]
792    fn test_decay_engine_config_from_env_defaults_without_vars() {
793        let _guard = ENV_LOCK.lock().unwrap();
794        // With no env vars set, from_env should return the same as default()
795        std::env::remove_var("DAKERA_DECAY_HALF_LIFE_HOURS");
796        std::env::remove_var("DAKERA_DECAY_MIN_IMPORTANCE");
797        std::env::remove_var("DAKERA_DECAY_INTERVAL_SECS");
798        std::env::remove_var("DAKERA_DECAY_STRATEGY");
799        let cfg = DecayEngineConfig::from_env();
800        assert!(matches!(
801            cfg.decay_config.strategy,
802            DecayStrategy::Exponential
803        ));
804        assert!((cfg.decay_config.half_life_hours - 168.0).abs() < 1e-9);
805    }
806
807    #[test]
808    fn test_decay_engine_config_from_env_linear_strategy() {
809        let _guard = ENV_LOCK.lock().unwrap();
810
811        std::env::set_var("DAKERA_DECAY_STRATEGY", "linear");
812        let cfg = DecayEngineConfig::from_env();
813        std::env::remove_var("DAKERA_DECAY_STRATEGY");
814        assert!(matches!(cfg.decay_config.strategy, DecayStrategy::Linear));
815    }
816
817    #[test]
818    fn test_decay_engine_config_from_env_step_strategy() {
819        let _guard = ENV_LOCK.lock().unwrap();
820
821        std::env::set_var("DAKERA_DECAY_STRATEGY", "step");
822        let cfg = DecayEngineConfig::from_env();
823        std::env::remove_var("DAKERA_DECAY_STRATEGY");
824        assert!(matches!(
825            cfg.decay_config.strategy,
826            DecayStrategy::StepFunction
827        ));
828    }
829
830    #[test]
831    fn test_decay_engine_config_from_env_unknown_strategy_defaults_to_exponential() {
832        let _guard = ENV_LOCK.lock().unwrap();
833
834        std::env::set_var("DAKERA_DECAY_STRATEGY", "bogus");
835        let cfg = DecayEngineConfig::from_env();
836        std::env::remove_var("DAKERA_DECAY_STRATEGY");
837        assert!(matches!(
838            cfg.decay_config.strategy,
839            DecayStrategy::Exponential
840        ));
841    }
842
843    // ── push_history capping ─────────────────────────────────────────────────
844
845    #[test]
846    fn test_push_history_caps_at_max() {
847        let mut history: Vec<ActivityHistoryPoint> = Vec::new();
848        // Fill past the cap
849        for i in 0..(MAX_HISTORY_POINTS + 10) {
850            push_history(
851                &mut history,
852                ActivityHistoryPoint {
853                    timestamp: i as u64,
854                    decay_deleted: 0,
855                    decay_adjusted: 0,
856                    dedup_removed: 0,
857                    consolidated: 0,
858                },
859            );
860        }
861        assert_eq!(history.len(), MAX_HISTORY_POINTS);
862        // Oldest entries are evicted; newest is last
863        assert_eq!(
864            history.last().unwrap().timestamp,
865            (MAX_HISTORY_POINTS + 9) as u64
866        );
867    }
868
869    #[test]
870    fn test_push_history_below_cap_grows_normally() {
871        let mut history: Vec<ActivityHistoryPoint> = Vec::new();
872        for i in 0..5 {
873            push_history(
874                &mut history,
875                ActivityHistoryPoint {
876                    timestamp: i,
877                    decay_deleted: 0,
878                    decay_adjusted: 0,
879                    dedup_removed: 0,
880                    consolidated: 0,
881                },
882            );
883        }
884        assert_eq!(history.len(), 5);
885    }
886
887    // ── BackgroundMetrics ────────────────────────────────────────────────────
888
889    #[test]
890    fn test_background_metrics_new_not_dirty() {
891        let m = BackgroundMetrics::new();
892        assert!(!m.is_dirty());
893    }
894
895    #[test]
896    fn test_background_metrics_record_decay_sets_dirty() {
897        let m = BackgroundMetrics::new();
898        let result = DecayResult {
899            namespaces_processed: 1,
900            memories_processed: 10,
901            memories_decayed: 3,
902            memories_deleted: 1,
903        };
904        m.record_decay(&result);
905        assert!(m.is_dirty());
906    }
907
908    #[test]
909    fn test_background_metrics_clear_dirty() {
910        let m = BackgroundMetrics::new();
911        let result = DecayResult::default();
912        m.record_decay(&result);
913        assert!(m.is_dirty());
914        m.clear_dirty();
915        assert!(!m.is_dirty());
916    }
917
918    #[test]
919    fn test_background_metrics_snapshot_totals() {
920        let m = BackgroundMetrics::new();
921        m.record_decay(&DecayResult {
922            namespaces_processed: 2,
923            memories_processed: 20,
924            memories_decayed: 5,
925            memories_deleted: 2,
926        });
927        m.record_decay(&DecayResult {
928            namespaces_processed: 1,
929            memories_processed: 5,
930            memories_decayed: 1,
931            memories_deleted: 1,
932        });
933        let snap = m.snapshot();
934        assert_eq!(snap.total_decay_deleted, 3); // 2 + 1
935        assert_eq!(snap.decay_cycles_run, 2);
936    }
937
938    #[test]
939    fn test_background_metrics_record_dedup() {
940        let m = BackgroundMetrics::new();
941        m.record_dedup(2, 100, 5);
942        let snap = m.snapshot();
943        assert_eq!(snap.total_dedup_removed, 5);
944        assert!(snap.last_dedup.is_some());
945    }
946
947    #[test]
948    fn test_background_metrics_record_consolidation() {
949        let m = BackgroundMetrics::new();
950        m.record_consolidation(1, 30, 2, 6);
951        let snap = m.snapshot();
952        assert_eq!(snap.total_consolidated, 6);
953        assert!(snap.last_consolidation.is_some());
954    }
955
956    #[test]
957    fn test_background_metrics_restore() {
958        let inner = BackgroundMetricsInner {
959            total_decay_deleted: 42,
960            decay_cycles_run: 7,
961            ..Default::default()
962        };
963        let m = BackgroundMetrics::restore(inner);
964        assert!(!m.is_dirty()); // restore does not dirty
965        assert_eq!(m.snapshot().total_decay_deleted, 42);
966        assert_eq!(m.snapshot().decay_cycles_run, 7);
967    }
968
969    // ── additional calculate_decay branches ──────────────────────────────────
970
971    #[test]
972    fn test_linear_decay_formula() {
973        let engine = make_engine(DecayStrategy::Linear, 100.0);
974        // Effective half-life for Episodic+1 access = 100 / (1.0 * (1/(1+0.1))) = 110h
975        // decay_amount = (hours / eff_hl) * 0.5
976        // At hours=55: decay_amount = (55/110) * 0.5 = 0.25; result = 1.0 - 0.25 = 0.75
977        let eff_hl = 100.0 / (1.0 * (1.0 / (1.0 + 0.1)));
978        let decay_amount = (55.0 / eff_hl) * 0.5;
979        let expected = (1.0_f32 - decay_amount as f32).max(0.0);
980        let result = engine.calculate_decay(1.0, 55.0, &EPISODIC, 1);
981        assert!(
982            (result - expected).abs() < 0.01,
983            "expected ~{expected}, got {result}"
984        );
985    }
986
987    #[test]
988    fn test_working_memory_decays_fastest() {
989        let engine = make_engine(DecayStrategy::Exponential, 168.0);
990        let working = engine.calculate_decay(1.0, 168.0, &MemoryType::Working, 5);
991        let episodic = engine.calculate_decay(1.0, 168.0, &EPISODIC, 5);
992        let semantic = engine.calculate_decay(1.0, 168.0, &MemoryType::Semantic, 5);
993        let procedural = engine.calculate_decay(1.0, 168.0, &MemoryType::Procedural, 5);
994        assert!(working < episodic);
995        assert!(episodic < semantic);
996        assert!(semantic < procedural);
997    }
998
999    #[test]
1000    fn test_access_boost_minimum_is_0_05() {
1001        // access_boost(0) = 0.05 + 0.05*0 = 0.05 → result = 0.0 + 0.05 = 0.05
1002        let result = DecayEngine::access_boost(0.0);
1003        assert!((result - 0.05).abs() < 0.001, "expected 0.05, got {result}");
1004    }
1005}