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                // Floor at min_importance — memories become dormant but are never
281                // hard-deleted by the decay engine alone (only explicit forget/batch_forget).
282                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                // Only update if importance actually changed
297                if (new_importance - memory.importance).abs() > 0.001 {
298                    let mut updated_memory = memory;
299                    updated_memory.importance = new_importance;
300
301                    // Rebuild vector with updated metadata but same embedding
302                    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            // Delete memories below threshold
310            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            // Upsert updated memories
322            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    /// Spawn the decay engine as a background tokio task.
345    ///
346    /// Takes a shared `Arc<RwLock<DecayConfig>>` so that config changes made at
347    /// runtime via `PUT /admin/decay/config` take effect without a server restart.
348    /// Each loop iteration re-reads the config before running, so strategy/half-life
349    /// changes apply on the next cycle.
350    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        // Lock TTL = interval + 5 min safety margin, so a stale lock never blocks
361        // more than one missed cycle.
362        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                // Leader election: acquire Redis lock before running decay.
375                // Graceful degradation: if Redis is unavailable, run anyway (in-process fallback).
376                let acquired = match redis {
377                    Some(ref rc) => rc.try_acquire_lock(LOCK_KEY, &node_id, lock_ttl).await,
378                    None => true, // No Redis configured — single-node mode, always run
379                };
380
381                if !acquired {
382                    tracing::debug!("Decay skipped — another replica holds the leader lock");
383                    continue;
384                }
385
386                // Re-read config before each cycle — picks up hot-reload changes
387                let current_config = config.read().await.clone();
388                // Snapshot memory policies for this cycle (COG-1 per-type decay curves)
389                let current_policies = policies.read().await.clone();
390                let engine = DecayEngine::new(current_config);
391                let result = engine.apply_decay(&storage, &current_policies).await;
392                metrics.record_decay(&result);
393
394                // Release the lock so another replica can acquire it next cycle
395                // (or if this node crashes, the TTL covers cleanup).
396                if let Some(ref rc) = redis {
397                    rc.release_lock(LOCK_KEY, &node_id).await;
398                }
399            }
400        })
401    }
402}
403
404/// Result of a decay cycle.
405#[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    /// Memories whose importance was floored at `min_importance` rather than deleted.
412    #[serde(default)]
413    pub memories_floored: usize,
414}
415
416/// Shared metrics for background activity tracking.
417///
418/// Updated by decay/autopilot spawn loops so the API can expose
419/// what background jobs are doing to user memories.
420/// Persisted to storage so metrics survive restarts.
421#[derive(Debug, Default)]
422pub struct BackgroundMetrics {
423    inner: std::sync::Mutex<BackgroundMetricsInner>,
424    /// Flag: dirty since last persist
425    dirty: std::sync::atomic::AtomicBool,
426}
427
428/// Max history data points kept (7 days at 1-hour decay interval).
429const MAX_HISTORY_POINTS: usize = 168;
430
431#[derive(Debug, Default, Clone, Serialize, Deserialize)]
432pub struct BackgroundMetricsInner {
433    /// Last decay cycle result
434    #[serde(default)]
435    pub last_decay: Option<DecayResult>,
436    /// Timestamp of last decay run (unix secs)
437    #[serde(default)]
438    pub last_decay_at: Option<u64>,
439    /// Cumulative memories deleted by decay
440    #[serde(default)]
441    pub total_decay_deleted: u64,
442    /// Cumulative memories floored at min_importance by decay (not deleted)
443    #[serde(default)]
444    pub total_decay_floored: u64,
445    /// Cumulative memories decayed (importance lowered)
446    #[serde(default)]
447    pub total_decay_adjusted: u64,
448    /// Total number of decay cycles completed
449    #[serde(default)]
450    pub decay_cycles_run: u64,
451
452    /// Last dedup cycle result
453    #[serde(default)]
454    pub last_dedup: Option<DedupResultSnapshot>,
455    /// Timestamp of last dedup run (unix secs)
456    #[serde(default)]
457    pub last_dedup_at: Option<u64>,
458    /// Cumulative duplicates removed
459    #[serde(default)]
460    pub total_dedup_removed: u64,
461
462    /// Last consolidation cycle result
463    #[serde(default)]
464    pub last_consolidation: Option<ConsolidationResultSnapshot>,
465    /// Timestamp of last consolidation run (unix secs)
466    #[serde(default)]
467    pub last_consolidation_at: Option<u64>,
468    /// Cumulative memories consolidated
469    #[serde(default)]
470    pub total_consolidated: u64,
471
472    /// Historical data points for graphing (ring buffer, newest last)
473    #[serde(default)]
474    pub history: Vec<ActivityHistoryPoint>,
475}
476
477/// A single historical data point for the activity timeline graph.
478#[derive(Debug, Clone, Serialize, Deserialize)]
479pub struct ActivityHistoryPoint {
480    /// Unix timestamp (seconds)
481    pub timestamp: u64,
482    /// Memories deleted by decay in this cycle
483    pub decay_deleted: u64,
484    /// Memories adjusted by decay in this cycle
485    pub decay_adjusted: u64,
486    /// Duplicates removed in this cycle
487    pub dedup_removed: u64,
488    /// Memories consolidated in this cycle
489    pub consolidated: u64,
490}
491
492/// Serializable snapshot of a dedup result (avoids coupling to autopilot module).
493#[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/// Serializable snapshot of a consolidation result.
501#[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    /// Restore metrics from a previously persisted snapshot.
515    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    /// Whether metrics changed since last persist.
523    pub fn is_dirty(&self) -> bool {
524        self.dirty.load(std::sync::atomic::Ordering::Relaxed)
525    }
526
527    /// Clear the dirty flag after a successful persist.
528    pub fn clear_dirty(&self) {
529        self.dirty
530            .store(false, std::sync::atomic::Ordering::Relaxed);
531    }
532
533    /// Record a decay cycle result.
534    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        inner.last_decay = Some(result.clone());
545        inner.last_decay_at = Some(now);
546        // Append history point
547        push_history(
548            &mut inner.history,
549            ActivityHistoryPoint {
550                timestamp: now,
551                decay_deleted: result.memories_deleted as u64,
552                decay_adjusted: result.memories_decayed as u64,
553                dedup_removed: 0,
554                consolidated: 0,
555            },
556        );
557        self.dirty.store(true, std::sync::atomic::Ordering::Relaxed);
558    }
559
560    /// Record a dedup cycle result.
561    pub fn record_dedup(
562        &self,
563        namespaces_processed: usize,
564        memories_scanned: usize,
565        duplicates_removed: usize,
566    ) {
567        let now = std::time::SystemTime::now()
568            .duration_since(std::time::UNIX_EPOCH)
569            .unwrap_or_default()
570            .as_secs();
571        let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
572        inner.total_dedup_removed += duplicates_removed as u64;
573        inner.last_dedup = Some(DedupResultSnapshot {
574            namespaces_processed,
575            memories_scanned,
576            duplicates_removed,
577        });
578        inner.last_dedup_at = Some(now);
579        push_history(
580            &mut inner.history,
581            ActivityHistoryPoint {
582                timestamp: now,
583                decay_deleted: 0,
584                decay_adjusted: 0,
585                dedup_removed: duplicates_removed as u64,
586                consolidated: 0,
587            },
588        );
589        self.dirty.store(true, std::sync::atomic::Ordering::Relaxed);
590    }
591
592    /// Record a consolidation cycle result.
593    pub fn record_consolidation(
594        &self,
595        namespaces_processed: usize,
596        memories_scanned: usize,
597        clusters_merged: usize,
598        memories_consolidated: usize,
599    ) {
600        let now = std::time::SystemTime::now()
601            .duration_since(std::time::UNIX_EPOCH)
602            .unwrap_or_default()
603            .as_secs();
604        let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
605        inner.total_consolidated += memories_consolidated as u64;
606        inner.last_consolidation = Some(ConsolidationResultSnapshot {
607            namespaces_processed,
608            memories_scanned,
609            clusters_merged,
610            memories_consolidated,
611        });
612        inner.last_consolidation_at = Some(now);
613        push_history(
614            &mut inner.history,
615            ActivityHistoryPoint {
616                timestamp: now,
617                decay_deleted: 0,
618                decay_adjusted: 0,
619                dedup_removed: 0,
620                consolidated: memories_consolidated as u64,
621            },
622        );
623        self.dirty.store(true, std::sync::atomic::Ordering::Relaxed);
624    }
625
626    /// Replace the inner state with a restored snapshot (used on startup).
627    pub fn restore_into(&self, restored: BackgroundMetricsInner) {
628        let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
629        *inner = restored;
630        // Don't set dirty — this was just loaded from storage
631    }
632
633    /// Get a snapshot of all metrics for API response.
634    pub fn snapshot(&self) -> BackgroundMetricsInner {
635        self.inner.lock().unwrap_or_else(|e| e.into_inner()).clone()
636    }
637}
638
639/// Append a history point, capping at MAX_HISTORY_POINTS.
640fn push_history(history: &mut Vec<ActivityHistoryPoint>, point: ActivityHistoryPoint) {
641    history.push(point);
642    if history.len() > MAX_HISTORY_POINTS {
643        let excess = history.len() - MAX_HISTORY_POINTS;
644        history.drain(..excess);
645    }
646}
647
648#[cfg(test)]
649mod tests {
650    use super::*;
651    use std::sync::Mutex;
652
653    // Shared lock for every test that reads or writes DAKERA_DECAY_* env vars.
654    // Function-local `static ENV_LOCK` creates a separate mutex per test function,
655    // which means the locks are completely independent and offer no protection
656    // across concurrent tests. All env-var tests must share this single lock.
657    static ENV_LOCK: Mutex<()> = Mutex::new(());
658
659    fn make_engine(strategy: DecayStrategy, half_life: f64) -> DecayEngine {
660        DecayEngine::new(DecayConfig {
661            strategy,
662            half_life_hours: half_life,
663            min_importance: 0.01,
664        })
665    }
666
667    // Default args: Episodic type, 0 access count (same as old behavior with multiplier=1.0, shield=1.5)
668    const EPISODIC: MemoryType = MemoryType::Episodic;
669
670    #[test]
671    fn test_exponential_decay_at_half_life_episodic_no_access() {
672        let engine = make_engine(DecayStrategy::Exponential, 168.0);
673        // Effective half-life = 168 / (1.0 * 1.5) = 112h (faster for never-accessed)
674        let result = engine.calculate_decay(1.0, 112.0, &EPISODIC, 0);
675        assert!((result - 0.5).abs() < 0.01, "Expected ~0.5, got {}", result);
676    }
677
678    #[test]
679    fn test_exponential_decay_zero_time() {
680        let engine = make_engine(DecayStrategy::Exponential, 168.0);
681        let result = engine.calculate_decay(0.8, 0.0, &EPISODIC, 0);
682        assert!((result - 0.8).abs() < 0.001);
683    }
684
685    #[test]
686    fn test_linear_decay_floors_at_zero() {
687        let engine = make_engine(DecayStrategy::Linear, 168.0);
688        let result = engine.calculate_decay(0.3, 168.0, &EPISODIC, 0);
689        assert!(result >= 0.0, "Should not go below 0, got {}", result);
690    }
691
692    #[test]
693    fn test_procedural_decays_slower_than_working() {
694        let engine = make_engine(DecayStrategy::Exponential, 168.0);
695        let working = engine.calculate_decay(1.0, 168.0, &MemoryType::Working, 0);
696        let procedural = engine.calculate_decay(1.0, 168.0, &MemoryType::Procedural, 0);
697        assert!(
698            procedural > working,
699            "Procedural ({}) should decay slower than Working ({})",
700            procedural,
701            working
702        );
703    }
704
705    #[test]
706    fn test_high_access_count_decays_slower() {
707        let engine = make_engine(DecayStrategy::Exponential, 168.0);
708        let no_access = engine.calculate_decay(1.0, 168.0, &EPISODIC, 0);
709        let high_access = engine.calculate_decay(1.0, 168.0, &EPISODIC, 10);
710        assert!(
711            high_access > no_access,
712            "High access ({}) should decay slower than no access ({})",
713            high_access,
714            no_access
715        );
716    }
717
718    #[test]
719    fn test_semantic_decays_slower_than_episodic() {
720        let engine = make_engine(DecayStrategy::Exponential, 168.0);
721        let episodic = engine.calculate_decay(1.0, 168.0, &EPISODIC, 5);
722        let semantic = engine.calculate_decay(1.0, 168.0, &MemoryType::Semantic, 5);
723        assert!(
724            semantic > episodic,
725            "Semantic ({}) should decay slower than Episodic ({})",
726            semantic,
727            episodic
728        );
729    }
730
731    #[test]
732    fn test_access_boost_scales_with_importance() {
733        let low = DecayEngine::access_boost(0.2);
734        let high = DecayEngine::access_boost(0.8);
735        let boost_low = low - 0.2;
736        let boost_high = high - 0.8;
737        assert!(
738            boost_high > boost_low,
739            "High-importance boost ({}) should be larger than low-importance boost ({})",
740            boost_high,
741            boost_low
742        );
743        // Verify range: 0.05 + 0.05*importance
744        assert!((boost_low - (0.05 + 0.05 * 0.2)).abs() < 0.001);
745        assert!((boost_high - (0.05 + 0.05 * 0.8)).abs() < 0.001);
746    }
747
748    #[test]
749    fn test_access_boost_caps_at_one() {
750        assert!((DecayEngine::access_boost(1.0) - 1.0).abs() < 0.001);
751        assert!((DecayEngine::access_boost(0.96) - 1.0).abs() < 0.001);
752    }
753
754    #[test]
755    fn test_decay_clamps_to_range() {
756        let engine = make_engine(DecayStrategy::Exponential, 1.0);
757        let result = engine.calculate_decay(0.001, 100.0, &EPISODIC, 0);
758        assert!(result >= 0.0 && result <= 1.0);
759    }
760
761    #[test]
762    fn test_step_function_decay() {
763        let engine = make_engine(DecayStrategy::StepFunction, 168.0);
764        // Effective half-life for Episodic+0 access = 168/1.5 = 112h
765        let eff_hl = 168.0 / 1.5;
766
767        // Before first step - no decay
768        let result = engine.calculate_decay(1.0, eff_hl * 0.5, &EPISODIC, 0);
769        assert!((result - 1.0).abs() < 0.001);
770
771        // At first step
772        let result = engine.calculate_decay(1.0, eff_hl, &EPISODIC, 0);
773        assert!((result - 0.5).abs() < 0.001);
774    }
775
776    // ── DecayEngine::new ─────────────────────────────────────────────────────
777
778    #[test]
779    fn test_decay_engine_new_stores_config() {
780        let cfg = DecayConfig {
781            strategy: DecayStrategy::Linear,
782            half_life_hours: 48.0,
783            min_importance: 0.05,
784        };
785        let engine = DecayEngine::new(cfg.clone());
786        assert!(matches!(engine.config.strategy, DecayStrategy::Linear));
787        assert!((engine.config.half_life_hours - 48.0).abs() < 1e-9);
788        assert!((engine.config.min_importance - 0.05).abs() < 1e-6);
789    }
790
791    // ── DecayEngineConfig::default ────────────────────────────────────────────
792
793    #[test]
794    fn test_decay_engine_config_default_values() {
795        let cfg = DecayEngineConfig::default();
796        assert!(matches!(
797            cfg.decay_config.strategy,
798            DecayStrategy::Exponential
799        ));
800        assert!((cfg.decay_config.half_life_hours - 168.0).abs() < 1e-9);
801        assert!((cfg.decay_config.min_importance - 0.01).abs() < 1e-6);
802        assert_eq!(cfg.interval_secs, 3600);
803    }
804
805    // ── DecayEngineConfig::from_env ────────────────────────────────────────────
806
807    #[test]
808    fn test_decay_engine_config_from_env_defaults_without_vars() {
809        let _guard = ENV_LOCK.lock().unwrap();
810        // With no env vars set, from_env should return the same as default()
811        std::env::remove_var("DAKERA_DECAY_HALF_LIFE_HOURS");
812        std::env::remove_var("DAKERA_DECAY_MIN_IMPORTANCE");
813        std::env::remove_var("DAKERA_DECAY_INTERVAL_SECS");
814        std::env::remove_var("DAKERA_DECAY_STRATEGY");
815        let cfg = DecayEngineConfig::from_env();
816        assert!(matches!(
817            cfg.decay_config.strategy,
818            DecayStrategy::Exponential
819        ));
820        assert!((cfg.decay_config.half_life_hours - 168.0).abs() < 1e-9);
821    }
822
823    #[test]
824    fn test_decay_engine_config_from_env_linear_strategy() {
825        let _guard = ENV_LOCK.lock().unwrap();
826
827        std::env::set_var("DAKERA_DECAY_STRATEGY", "linear");
828        let cfg = DecayEngineConfig::from_env();
829        std::env::remove_var("DAKERA_DECAY_STRATEGY");
830        assert!(matches!(cfg.decay_config.strategy, DecayStrategy::Linear));
831    }
832
833    #[test]
834    fn test_decay_engine_config_from_env_step_strategy() {
835        let _guard = ENV_LOCK.lock().unwrap();
836
837        std::env::set_var("DAKERA_DECAY_STRATEGY", "step");
838        let cfg = DecayEngineConfig::from_env();
839        std::env::remove_var("DAKERA_DECAY_STRATEGY");
840        assert!(matches!(
841            cfg.decay_config.strategy,
842            DecayStrategy::StepFunction
843        ));
844    }
845
846    #[test]
847    fn test_decay_engine_config_from_env_unknown_strategy_defaults_to_exponential() {
848        let _guard = ENV_LOCK.lock().unwrap();
849
850        std::env::set_var("DAKERA_DECAY_STRATEGY", "bogus");
851        let cfg = DecayEngineConfig::from_env();
852        std::env::remove_var("DAKERA_DECAY_STRATEGY");
853        assert!(matches!(
854            cfg.decay_config.strategy,
855            DecayStrategy::Exponential
856        ));
857    }
858
859    // ── push_history capping ─────────────────────────────────────────────────
860
861    #[test]
862    fn test_push_history_caps_at_max() {
863        let mut history: Vec<ActivityHistoryPoint> = Vec::new();
864        // Fill past the cap
865        for i in 0..(MAX_HISTORY_POINTS + 10) {
866            push_history(
867                &mut history,
868                ActivityHistoryPoint {
869                    timestamp: i as u64,
870                    decay_deleted: 0,
871                    decay_adjusted: 0,
872                    dedup_removed: 0,
873                    consolidated: 0,
874                },
875            );
876        }
877        assert_eq!(history.len(), MAX_HISTORY_POINTS);
878        // Oldest entries are evicted; newest is last
879        assert_eq!(
880            history.last().unwrap().timestamp,
881            (MAX_HISTORY_POINTS + 9) as u64
882        );
883    }
884
885    #[test]
886    fn test_push_history_below_cap_grows_normally() {
887        let mut history: Vec<ActivityHistoryPoint> = Vec::new();
888        for i in 0..5 {
889            push_history(
890                &mut history,
891                ActivityHistoryPoint {
892                    timestamp: i,
893                    decay_deleted: 0,
894                    decay_adjusted: 0,
895                    dedup_removed: 0,
896                    consolidated: 0,
897                },
898            );
899        }
900        assert_eq!(history.len(), 5);
901    }
902
903    // ── BackgroundMetrics ────────────────────────────────────────────────────
904
905    #[test]
906    fn test_background_metrics_new_not_dirty() {
907        let m = BackgroundMetrics::new();
908        assert!(!m.is_dirty());
909    }
910
911    #[test]
912    fn test_background_metrics_record_decay_sets_dirty() {
913        let m = BackgroundMetrics::new();
914        let result = DecayResult {
915            namespaces_processed: 1,
916            memories_processed: 10,
917            memories_decayed: 3,
918            memories_deleted: 1,
919            memories_floored: 0,
920        };
921        m.record_decay(&result);
922        assert!(m.is_dirty());
923    }
924
925    #[test]
926    fn test_background_metrics_clear_dirty() {
927        let m = BackgroundMetrics::new();
928        let result = DecayResult::default();
929        m.record_decay(&result);
930        assert!(m.is_dirty());
931        m.clear_dirty();
932        assert!(!m.is_dirty());
933    }
934
935    #[test]
936    fn test_background_metrics_snapshot_totals() {
937        let m = BackgroundMetrics::new();
938        m.record_decay(&DecayResult {
939            namespaces_processed: 2,
940            memories_processed: 20,
941            memories_decayed: 5,
942            memories_deleted: 2,
943            memories_floored: 0,
944        });
945        m.record_decay(&DecayResult {
946            namespaces_processed: 1,
947            memories_processed: 5,
948            memories_decayed: 1,
949            memories_deleted: 1,
950            memories_floored: 0,
951        });
952        let snap = m.snapshot();
953        assert_eq!(snap.total_decay_deleted, 3); // 2 + 1
954        assert_eq!(snap.decay_cycles_run, 2);
955    }
956
957    #[test]
958    fn test_background_metrics_record_dedup() {
959        let m = BackgroundMetrics::new();
960        m.record_dedup(2, 100, 5);
961        let snap = m.snapshot();
962        assert_eq!(snap.total_dedup_removed, 5);
963        assert!(snap.last_dedup.is_some());
964    }
965
966    #[test]
967    fn test_background_metrics_record_consolidation() {
968        let m = BackgroundMetrics::new();
969        m.record_consolidation(1, 30, 2, 6);
970        let snap = m.snapshot();
971        assert_eq!(snap.total_consolidated, 6);
972        assert!(snap.last_consolidation.is_some());
973    }
974
975    #[test]
976    fn test_background_metrics_restore() {
977        let inner = BackgroundMetricsInner {
978            total_decay_deleted: 42,
979            decay_cycles_run: 7,
980            ..Default::default()
981        };
982        let m = BackgroundMetrics::restore(inner);
983        assert!(!m.is_dirty()); // restore does not dirty
984        assert_eq!(m.snapshot().total_decay_deleted, 42);
985        assert_eq!(m.snapshot().decay_cycles_run, 7);
986    }
987
988    // ── additional calculate_decay branches ──────────────────────────────────
989
990    #[test]
991    fn test_linear_decay_formula() {
992        let engine = make_engine(DecayStrategy::Linear, 100.0);
993        // Effective half-life for Episodic+1 access = 100 / (1.0 * (1/(1+0.1))) = 110h
994        // decay_amount = (hours / eff_hl) * 0.5
995        // At hours=55: decay_amount = (55/110) * 0.5 = 0.25; result = 1.0 - 0.25 = 0.75
996        let eff_hl = 100.0 / (1.0 * (1.0 / (1.0 + 0.1)));
997        let decay_amount = (55.0 / eff_hl) * 0.5;
998        let expected = (1.0_f32 - decay_amount as f32).max(0.0);
999        let result = engine.calculate_decay(1.0, 55.0, &EPISODIC, 1);
1000        assert!(
1001            (result - expected).abs() < 0.01,
1002            "expected ~{expected}, got {result}"
1003        );
1004    }
1005
1006    #[test]
1007    fn test_working_memory_decays_fastest() {
1008        let engine = make_engine(DecayStrategy::Exponential, 168.0);
1009        let working = engine.calculate_decay(1.0, 168.0, &MemoryType::Working, 5);
1010        let episodic = engine.calculate_decay(1.0, 168.0, &EPISODIC, 5);
1011        let semantic = engine.calculate_decay(1.0, 168.0, &MemoryType::Semantic, 5);
1012        let procedural = engine.calculate_decay(1.0, 168.0, &MemoryType::Procedural, 5);
1013        assert!(working < episodic);
1014        assert!(episodic < semantic);
1015        assert!(semantic < procedural);
1016    }
1017
1018    #[test]
1019    fn test_access_boost_minimum_is_0_05() {
1020        // access_boost(0) = 0.05 + 0.05*0 = 0.05 → result = 0.0 + 0.05 = 0.05
1021        let result = DecayEngine::access_boost(0.0);
1022        assert!((result - 0.05).abs() < 0.001, "expected 0.05, got {result}");
1023    }
1024}