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        // Emit Prometheus counter for memories floored at min_importance (DAK-1542 OBS-1)
545        metrics::counter!("dakera_memories_decayed_total")
546            .increment(result.memories_floored as u64);
547        inner.last_decay = Some(result.clone());
548        inner.last_decay_at = Some(now);
549        // Append history point
550        push_history(
551            &mut inner.history,
552            ActivityHistoryPoint {
553                timestamp: now,
554                decay_deleted: result.memories_deleted as u64,
555                decay_adjusted: result.memories_decayed as u64,
556                dedup_removed: 0,
557                consolidated: 0,
558            },
559        );
560        self.dirty.store(true, std::sync::atomic::Ordering::Relaxed);
561    }
562
563    /// Record a dedup cycle result.
564    pub fn record_dedup(
565        &self,
566        namespaces_processed: usize,
567        memories_scanned: usize,
568        duplicates_removed: usize,
569    ) {
570        let now = std::time::SystemTime::now()
571            .duration_since(std::time::UNIX_EPOCH)
572            .unwrap_or_default()
573            .as_secs();
574        let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
575        inner.total_dedup_removed += duplicates_removed as u64;
576        inner.last_dedup = Some(DedupResultSnapshot {
577            namespaces_processed,
578            memories_scanned,
579            duplicates_removed,
580        });
581        inner.last_dedup_at = Some(now);
582        push_history(
583            &mut inner.history,
584            ActivityHistoryPoint {
585                timestamp: now,
586                decay_deleted: 0,
587                decay_adjusted: 0,
588                dedup_removed: duplicates_removed as u64,
589                consolidated: 0,
590            },
591        );
592        self.dirty.store(true, std::sync::atomic::Ordering::Relaxed);
593    }
594
595    /// Record a consolidation cycle result.
596    pub fn record_consolidation(
597        &self,
598        namespaces_processed: usize,
599        memories_scanned: usize,
600        clusters_merged: usize,
601        memories_consolidated: usize,
602    ) {
603        let now = std::time::SystemTime::now()
604            .duration_since(std::time::UNIX_EPOCH)
605            .unwrap_or_default()
606            .as_secs();
607        let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
608        inner.total_consolidated += memories_consolidated as u64;
609        inner.last_consolidation = Some(ConsolidationResultSnapshot {
610            namespaces_processed,
611            memories_scanned,
612            clusters_merged,
613            memories_consolidated,
614        });
615        inner.last_consolidation_at = Some(now);
616        push_history(
617            &mut inner.history,
618            ActivityHistoryPoint {
619                timestamp: now,
620                decay_deleted: 0,
621                decay_adjusted: 0,
622                dedup_removed: 0,
623                consolidated: memories_consolidated as u64,
624            },
625        );
626        self.dirty.store(true, std::sync::atomic::Ordering::Relaxed);
627    }
628
629    /// Replace the inner state with a restored snapshot (used on startup).
630    pub fn restore_into(&self, restored: BackgroundMetricsInner) {
631        let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
632        *inner = restored;
633        // Don't set dirty — this was just loaded from storage
634    }
635
636    /// Get a snapshot of all metrics for API response.
637    pub fn snapshot(&self) -> BackgroundMetricsInner {
638        self.inner.lock().unwrap_or_else(|e| e.into_inner()).clone()
639    }
640}
641
642/// Append a history point, capping at MAX_HISTORY_POINTS.
643fn push_history(history: &mut Vec<ActivityHistoryPoint>, point: ActivityHistoryPoint) {
644    history.push(point);
645    if history.len() > MAX_HISTORY_POINTS {
646        let excess = history.len() - MAX_HISTORY_POINTS;
647        history.drain(..excess);
648    }
649}
650
651#[cfg(test)]
652mod tests {
653    use super::*;
654    use std::sync::Mutex;
655
656    // Shared lock for every test that reads or writes DAKERA_DECAY_* env vars.
657    // Function-local `static ENV_LOCK` creates a separate mutex per test function,
658    // which means the locks are completely independent and offer no protection
659    // across concurrent tests. All env-var tests must share this single lock.
660    static ENV_LOCK: Mutex<()> = Mutex::new(());
661
662    fn make_engine(strategy: DecayStrategy, half_life: f64) -> DecayEngine {
663        DecayEngine::new(DecayConfig {
664            strategy,
665            half_life_hours: half_life,
666            min_importance: 0.01,
667        })
668    }
669
670    // Default args: Episodic type, 0 access count (same as old behavior with multiplier=1.0, shield=1.5)
671    const EPISODIC: MemoryType = MemoryType::Episodic;
672
673    #[test]
674    fn test_exponential_decay_at_half_life_episodic_no_access() {
675        let engine = make_engine(DecayStrategy::Exponential, 168.0);
676        // Effective half-life = 168 / (1.0 * 1.5) = 112h (faster for never-accessed)
677        let result = engine.calculate_decay(1.0, 112.0, &EPISODIC, 0);
678        assert!((result - 0.5).abs() < 0.01, "Expected ~0.5, got {}", result);
679    }
680
681    #[test]
682    fn test_exponential_decay_zero_time() {
683        let engine = make_engine(DecayStrategy::Exponential, 168.0);
684        let result = engine.calculate_decay(0.8, 0.0, &EPISODIC, 0);
685        assert!((result - 0.8).abs() < 0.001);
686    }
687
688    #[test]
689    fn test_linear_decay_floors_at_zero() {
690        let engine = make_engine(DecayStrategy::Linear, 168.0);
691        let result = engine.calculate_decay(0.3, 168.0, &EPISODIC, 0);
692        assert!(result >= 0.0, "Should not go below 0, got {}", result);
693    }
694
695    #[test]
696    fn test_procedural_decays_slower_than_working() {
697        let engine = make_engine(DecayStrategy::Exponential, 168.0);
698        let working = engine.calculate_decay(1.0, 168.0, &MemoryType::Working, 0);
699        let procedural = engine.calculate_decay(1.0, 168.0, &MemoryType::Procedural, 0);
700        assert!(
701            procedural > working,
702            "Procedural ({}) should decay slower than Working ({})",
703            procedural,
704            working
705        );
706    }
707
708    #[test]
709    fn test_high_access_count_decays_slower() {
710        let engine = make_engine(DecayStrategy::Exponential, 168.0);
711        let no_access = engine.calculate_decay(1.0, 168.0, &EPISODIC, 0);
712        let high_access = engine.calculate_decay(1.0, 168.0, &EPISODIC, 10);
713        assert!(
714            high_access > no_access,
715            "High access ({}) should decay slower than no access ({})",
716            high_access,
717            no_access
718        );
719    }
720
721    #[test]
722    fn test_semantic_decays_slower_than_episodic() {
723        let engine = make_engine(DecayStrategy::Exponential, 168.0);
724        let episodic = engine.calculate_decay(1.0, 168.0, &EPISODIC, 5);
725        let semantic = engine.calculate_decay(1.0, 168.0, &MemoryType::Semantic, 5);
726        assert!(
727            semantic > episodic,
728            "Semantic ({}) should decay slower than Episodic ({})",
729            semantic,
730            episodic
731        );
732    }
733
734    #[test]
735    fn test_access_boost_scales_with_importance() {
736        let low = DecayEngine::access_boost(0.2);
737        let high = DecayEngine::access_boost(0.8);
738        let boost_low = low - 0.2;
739        let boost_high = high - 0.8;
740        assert!(
741            boost_high > boost_low,
742            "High-importance boost ({}) should be larger than low-importance boost ({})",
743            boost_high,
744            boost_low
745        );
746        // Verify range: 0.05 + 0.05*importance
747        assert!((boost_low - (0.05 + 0.05 * 0.2)).abs() < 0.001);
748        assert!((boost_high - (0.05 + 0.05 * 0.8)).abs() < 0.001);
749    }
750
751    #[test]
752    fn test_access_boost_caps_at_one() {
753        assert!((DecayEngine::access_boost(1.0) - 1.0).abs() < 0.001);
754        assert!((DecayEngine::access_boost(0.96) - 1.0).abs() < 0.001);
755    }
756
757    #[test]
758    fn test_decay_clamps_to_range() {
759        let engine = make_engine(DecayStrategy::Exponential, 1.0);
760        let result = engine.calculate_decay(0.001, 100.0, &EPISODIC, 0);
761        assert!(result >= 0.0 && result <= 1.0);
762    }
763
764    #[test]
765    fn test_step_function_decay() {
766        let engine = make_engine(DecayStrategy::StepFunction, 168.0);
767        // Effective half-life for Episodic+0 access = 168/1.5 = 112h
768        let eff_hl = 168.0 / 1.5;
769
770        // Before first step - no decay
771        let result = engine.calculate_decay(1.0, eff_hl * 0.5, &EPISODIC, 0);
772        assert!((result - 1.0).abs() < 0.001);
773
774        // At first step
775        let result = engine.calculate_decay(1.0, eff_hl, &EPISODIC, 0);
776        assert!((result - 0.5).abs() < 0.001);
777    }
778
779    // ── DecayEngine::new ─────────────────────────────────────────────────────
780
781    #[test]
782    fn test_decay_engine_new_stores_config() {
783        let cfg = DecayConfig {
784            strategy: DecayStrategy::Linear,
785            half_life_hours: 48.0,
786            min_importance: 0.05,
787        };
788        let engine = DecayEngine::new(cfg.clone());
789        assert!(matches!(engine.config.strategy, DecayStrategy::Linear));
790        assert!((engine.config.half_life_hours - 48.0).abs() < 1e-9);
791        assert!((engine.config.min_importance - 0.05).abs() < 1e-6);
792    }
793
794    // ── DecayEngineConfig::default ────────────────────────────────────────────
795
796    #[test]
797    fn test_decay_engine_config_default_values() {
798        let cfg = DecayEngineConfig::default();
799        assert!(matches!(
800            cfg.decay_config.strategy,
801            DecayStrategy::Exponential
802        ));
803        assert!((cfg.decay_config.half_life_hours - 168.0).abs() < 1e-9);
804        assert!((cfg.decay_config.min_importance - 0.01).abs() < 1e-6);
805        assert_eq!(cfg.interval_secs, 3600);
806    }
807
808    // ── DecayEngineConfig::from_env ────────────────────────────────────────────
809
810    #[test]
811    fn test_decay_engine_config_from_env_defaults_without_vars() {
812        let _guard = ENV_LOCK.lock().unwrap();
813        // With no env vars set, from_env should return the same as default()
814        std::env::remove_var("DAKERA_DECAY_HALF_LIFE_HOURS");
815        std::env::remove_var("DAKERA_DECAY_MIN_IMPORTANCE");
816        std::env::remove_var("DAKERA_DECAY_INTERVAL_SECS");
817        std::env::remove_var("DAKERA_DECAY_STRATEGY");
818        let cfg = DecayEngineConfig::from_env();
819        assert!(matches!(
820            cfg.decay_config.strategy,
821            DecayStrategy::Exponential
822        ));
823        assert!((cfg.decay_config.half_life_hours - 168.0).abs() < 1e-9);
824    }
825
826    #[test]
827    fn test_decay_engine_config_from_env_linear_strategy() {
828        let _guard = ENV_LOCK.lock().unwrap();
829
830        std::env::set_var("DAKERA_DECAY_STRATEGY", "linear");
831        let cfg = DecayEngineConfig::from_env();
832        std::env::remove_var("DAKERA_DECAY_STRATEGY");
833        assert!(matches!(cfg.decay_config.strategy, DecayStrategy::Linear));
834    }
835
836    #[test]
837    fn test_decay_engine_config_from_env_step_strategy() {
838        let _guard = ENV_LOCK.lock().unwrap();
839
840        std::env::set_var("DAKERA_DECAY_STRATEGY", "step");
841        let cfg = DecayEngineConfig::from_env();
842        std::env::remove_var("DAKERA_DECAY_STRATEGY");
843        assert!(matches!(
844            cfg.decay_config.strategy,
845            DecayStrategy::StepFunction
846        ));
847    }
848
849    #[test]
850    fn test_decay_engine_config_from_env_unknown_strategy_defaults_to_exponential() {
851        let _guard = ENV_LOCK.lock().unwrap();
852
853        std::env::set_var("DAKERA_DECAY_STRATEGY", "bogus");
854        let cfg = DecayEngineConfig::from_env();
855        std::env::remove_var("DAKERA_DECAY_STRATEGY");
856        assert!(matches!(
857            cfg.decay_config.strategy,
858            DecayStrategy::Exponential
859        ));
860    }
861
862    // ── push_history capping ─────────────────────────────────────────────────
863
864    #[test]
865    fn test_push_history_caps_at_max() {
866        let mut history: Vec<ActivityHistoryPoint> = Vec::new();
867        // Fill past the cap
868        for i in 0..(MAX_HISTORY_POINTS + 10) {
869            push_history(
870                &mut history,
871                ActivityHistoryPoint {
872                    timestamp: i as u64,
873                    decay_deleted: 0,
874                    decay_adjusted: 0,
875                    dedup_removed: 0,
876                    consolidated: 0,
877                },
878            );
879        }
880        assert_eq!(history.len(), MAX_HISTORY_POINTS);
881        // Oldest entries are evicted; newest is last
882        assert_eq!(
883            history.last().unwrap().timestamp,
884            (MAX_HISTORY_POINTS + 9) as u64
885        );
886    }
887
888    #[test]
889    fn test_push_history_below_cap_grows_normally() {
890        let mut history: Vec<ActivityHistoryPoint> = Vec::new();
891        for i in 0..5 {
892            push_history(
893                &mut history,
894                ActivityHistoryPoint {
895                    timestamp: i,
896                    decay_deleted: 0,
897                    decay_adjusted: 0,
898                    dedup_removed: 0,
899                    consolidated: 0,
900                },
901            );
902        }
903        assert_eq!(history.len(), 5);
904    }
905
906    // ── BackgroundMetrics ────────────────────────────────────────────────────
907
908    #[test]
909    fn test_background_metrics_new_not_dirty() {
910        let m = BackgroundMetrics::new();
911        assert!(!m.is_dirty());
912    }
913
914    #[test]
915    fn test_background_metrics_record_decay_sets_dirty() {
916        let m = BackgroundMetrics::new();
917        let result = DecayResult {
918            namespaces_processed: 1,
919            memories_processed: 10,
920            memories_decayed: 3,
921            memories_deleted: 1,
922            memories_floored: 0,
923        };
924        m.record_decay(&result);
925        assert!(m.is_dirty());
926    }
927
928    #[test]
929    fn test_background_metrics_clear_dirty() {
930        let m = BackgroundMetrics::new();
931        let result = DecayResult::default();
932        m.record_decay(&result);
933        assert!(m.is_dirty());
934        m.clear_dirty();
935        assert!(!m.is_dirty());
936    }
937
938    #[test]
939    fn test_background_metrics_snapshot_totals() {
940        let m = BackgroundMetrics::new();
941        m.record_decay(&DecayResult {
942            namespaces_processed: 2,
943            memories_processed: 20,
944            memories_decayed: 5,
945            memories_deleted: 2,
946            memories_floored: 0,
947        });
948        m.record_decay(&DecayResult {
949            namespaces_processed: 1,
950            memories_processed: 5,
951            memories_decayed: 1,
952            memories_deleted: 1,
953            memories_floored: 0,
954        });
955        let snap = m.snapshot();
956        assert_eq!(snap.total_decay_deleted, 3); // 2 + 1
957        assert_eq!(snap.decay_cycles_run, 2);
958    }
959
960    #[test]
961    fn test_background_metrics_record_dedup() {
962        let m = BackgroundMetrics::new();
963        m.record_dedup(2, 100, 5);
964        let snap = m.snapshot();
965        assert_eq!(snap.total_dedup_removed, 5);
966        assert!(snap.last_dedup.is_some());
967    }
968
969    #[test]
970    fn test_background_metrics_record_consolidation() {
971        let m = BackgroundMetrics::new();
972        m.record_consolidation(1, 30, 2, 6);
973        let snap = m.snapshot();
974        assert_eq!(snap.total_consolidated, 6);
975        assert!(snap.last_consolidation.is_some());
976    }
977
978    #[test]
979    fn test_background_metrics_restore() {
980        let inner = BackgroundMetricsInner {
981            total_decay_deleted: 42,
982            decay_cycles_run: 7,
983            ..Default::default()
984        };
985        let m = BackgroundMetrics::restore(inner);
986        assert!(!m.is_dirty()); // restore does not dirty
987        assert_eq!(m.snapshot().total_decay_deleted, 42);
988        assert_eq!(m.snapshot().decay_cycles_run, 7);
989    }
990
991    // ── additional calculate_decay branches ──────────────────────────────────
992
993    #[test]
994    fn test_linear_decay_formula() {
995        let engine = make_engine(DecayStrategy::Linear, 100.0);
996        // Effective half-life for Episodic+1 access = 100 / (1.0 * (1/(1+0.1))) = 110h
997        // decay_amount = (hours / eff_hl) * 0.5
998        // At hours=55: decay_amount = (55/110) * 0.5 = 0.25; result = 1.0 - 0.25 = 0.75
999        let eff_hl = 100.0 / (1.0 * (1.0 / (1.0 + 0.1)));
1000        let decay_amount = (55.0 / eff_hl) * 0.5;
1001        let expected = (1.0_f32 - decay_amount as f32).max(0.0);
1002        let result = engine.calculate_decay(1.0, 55.0, &EPISODIC, 1);
1003        assert!(
1004            (result - expected).abs() < 0.01,
1005            "expected ~{expected}, got {result}"
1006        );
1007    }
1008
1009    #[test]
1010    fn test_working_memory_decays_fastest() {
1011        let engine = make_engine(DecayStrategy::Exponential, 168.0);
1012        let working = engine.calculate_decay(1.0, 168.0, &MemoryType::Working, 5);
1013        let episodic = engine.calculate_decay(1.0, 168.0, &EPISODIC, 5);
1014        let semantic = engine.calculate_decay(1.0, 168.0, &MemoryType::Semantic, 5);
1015        let procedural = engine.calculate_decay(1.0, 168.0, &MemoryType::Procedural, 5);
1016        assert!(working < episodic);
1017        assert!(episodic < semantic);
1018        assert!(semantic < procedural);
1019    }
1020
1021    #[test]
1022    fn test_access_boost_minimum_is_0_05() {
1023        // access_boost(0) = 0.05 + 0.05*0 = 0.05 → result = 0.0 + 0.05 = 0.05
1024        let result = DecayEngine::access_boost(0.0);
1025        assert!((result - 0.05).abs() < 0.001, "expected 0.05, got {result}");
1026    }
1027}