shodh-memory 0.2.0

Persistent cognitive memory for AI agents and robots — Hebbian learning, knowledge graph, spatial recall. Zenoh/ROS2 native. Single binary, runs offline.
Documentation
//! Hybrid Decay Model (SHO-103)
//!
//! Implements biologically-accurate memory decay based on neuroscience research.
//!
//! # The Problem with Pure Exponential Decay
//!
//! Traditional memory systems use exponential decay: `w(t) = w₀ × e^(-λt)`
//!
//! This produces a "cliff" effect where memories drop rapidly and then flatten:
//! - Day 1: 100% → 95%
//! - Day 7: 95% → 70%
//! - Day 30: 70% → 15% (steep cliff)
//!
//! # The Solution: Hybrid Decay
//!
//! Human memory follows a power-law for long-term retention, not exponential.
//!
//! This module implements a hybrid model:
//! - **Consolidation phase** (t < 3 days): Exponential decay
//!   - Fast filtering of noise and weak associations
//!   - Matches short-term synaptic depression
//! - **Long-term phase** (t ≥ 3 days): Power-law decay
//!   - Heavy tail preserves important memories longer
//!   - Matches empirical human forgetting curves
//!
//! ```text
//!         Exponential              Power-Law
//!         (consolidation)          (long-term retention)
//!
//! Strength │ ╲
//!     100% │  ╲
//!          │   ╲
//!      60% │    ╲___
//!          │        ╲____
//!      30% │             ╲________
//!          │                      ╲___________
//!       5% │─────────────────────────────────────────
//!          └────┬────────┬─────────────────────────► Time
//!               │        │
//!            t_cross   (days)
//!          (3 days)
//! ```
//!
//! # References
//!
//! - Wixted & Ebbesen (1991) "On the Form of Forgetting"
//! - Wixted (2004) "The psychology and neuroscience of forgetting"
//! - Anderson & Schooler (1991) "Reflections of the Environment in Memory"

use crate::constants::{
    DECAY_CROSSOVER_DAYS, DECAY_LAMBDA_CONSOLIDATION, POWERLAW_BETA, POWERLAW_BETA_POTENTIATED,
};

/// Calculates the hybrid decay factor for a given elapsed time.
///
/// Returns a value between 0.0 and 1.0 representing the retention ratio.
///
/// # Arguments
///
/// * `days_elapsed` - Time since last activation in days
/// * `potentiated` - Whether this is a potentiated/important memory (uses slower decay)
///
/// # Returns
///
/// Decay factor to multiply with original strength: `new_strength = old_strength * decay_factor`
///
/// # Example
///
/// ```ignore
/// let factor = hybrid_decay_factor(7.0, false);
/// let new_strength = old_strength * factor;
/// ```
#[inline]
pub fn hybrid_decay_factor(days_elapsed: f64, potentiated: bool) -> f32 {
    if days_elapsed <= 0.0 {
        return 1.0;
    }

    let beta = if potentiated {
        POWERLAW_BETA_POTENTIATED
    } else {
        POWERLAW_BETA
    };

    // Exponential rate for consolidation phase
    // Potentiated memories use slower exponential decay too
    let lambda = if potentiated {
        DECAY_LAMBDA_CONSOLIDATION * 0.5 // Half the rate for potentiated
    } else {
        DECAY_LAMBDA_CONSOLIDATION
    };

    if days_elapsed < DECAY_CROSSOVER_DAYS {
        // Consolidation phase: exponential decay
        // w(t) = w₀ × e^(-λt)
        (-lambda * days_elapsed).exp() as f32
    } else {
        // Long-term phase: power-law decay
        // First, calculate what value we'd have at crossover with exponential
        let value_at_crossover = (-lambda * DECAY_CROSSOVER_DAYS).exp();

        // Then apply power-law from crossover point
        // A(t) = A_cross × (t / t_cross)^(-β)
        let power_law_factor = (days_elapsed / DECAY_CROSSOVER_DAYS).powf(-beta);

        (value_at_crossover * power_law_factor) as f32
    }
}

/// Calculates the hybrid decay factor with custom parameters.
///
/// Use this for contexts that need different decay characteristics.
///
/// # Arguments
///
/// * `days_elapsed` - Time since last activation in days
/// * `crossover_days` - Days before switching from exponential to power-law
/// * `lambda` - Exponential decay rate for consolidation phase
/// * `beta` - Power-law exponent for long-term phase
///
/// # Example
///
/// ```ignore
/// // Faster decay for edge weights
/// let factor = hybrid_decay_factor_custom(days_elapsed, 1.0, 1.0, 0.6);
/// ```
#[inline]
pub fn hybrid_decay_factor_custom(
    days_elapsed: f64,
    crossover_days: f64,
    lambda: f64,
    beta: f64,
) -> f32 {
    if days_elapsed <= 0.0 {
        return 1.0;
    }

    if days_elapsed < crossover_days {
        // Consolidation phase: exponential decay
        (-lambda * days_elapsed).exp() as f32
    } else {
        // Long-term phase: power-law decay
        let value_at_crossover = (-lambda * crossover_days).exp();
        let power_law_factor = (days_elapsed / crossover_days).powf(-beta);
        (value_at_crossover * power_law_factor) as f32
    }
}

/// Calculates retention percentage for debugging/visualization.
///
/// Returns a human-readable percentage string showing retention at various time points.
#[allow(dead_code)]
pub fn retention_curve_debug(potentiated: bool) -> String {
    let days = [0.5, 1.0, 3.0, 7.0, 14.0, 30.0, 90.0, 365.0];
    let mode = if potentiated { "potentiated" } else { "normal" };

    let mut output = format!("Retention curve ({mode}):\n");
    for d in days {
        let factor = hybrid_decay_factor(d, potentiated);
        output.push_str(&format!("  Day {:>5.1}: {:>6.2}%\n", d, factor * 100.0));
    }
    output
}

/// Tier-aware decay factor for edge consolidation (3-tier memory model)
///
/// Each tier has different decay characteristics based on hippocampal-cortical research:
/// - L1 (Working): ~2.9%/hour decay (λ=0.029), max 48 hours
/// - L2 (Episodic): ~3.1%/day decay (λ=0.031), max 30 days
/// - L3 (Semantic): ~2%/month decay (λ=0.02/720h), near-permanent
///
/// # Arguments
///
/// * `hours_elapsed` - Time since last activation in hours
/// * `tier` - Memory tier (0=L1, 1=L2, 2=L3)
/// * `ltp_decay_factor` - LTP decay protection factor (1.0=none, 0.5=2x slower, 0.1=10x slower)
///
/// # Returns
///
/// Decay factor (0.0-1.0) and whether edge should be pruned
///
/// # PIPE-4 Update
///
/// Changed from `potentiated: bool` to `ltp_decay_factor: f32` to support
/// multi-scale LTP with graduated protection levels:
/// - LtpStatus::None → 1.0 (no protection)
/// - LtpStatus::Burst → 0.5 (2x slower decay, temporary)
/// - LtpStatus::Weekly → 0.3 (3x slower decay, moderate)
/// - LtpStatus::Full → 0.1 (10x slower decay, maximum)
#[inline]
pub fn tier_decay_factor(hours_elapsed: f64, tier: u8, ltp_decay_factor: f32) -> (f32, bool) {
    use crate::constants::*;

    if hours_elapsed <= 0.0 {
        return (1.0, false);
    }

    let (decay_rate, max_age_hours, prune_threshold) = match tier {
        0 => {
            // L1 Working: ~2.9%/hour decay (λ=0.029), max 48 hours
            (
                L1_DECAY_PER_HOUR as f64,
                (L1_MAX_AGE_HOURS as f64),
                L1_PRUNE_THRESHOLD,
            )
        }
        1 => {
            // L2 Episodic: ~3.1%/day decay (λ=0.031), max 30 days
            let decay_per_hour = L2_DECAY_PER_DAY as f64 / 24.0;
            (
                decay_per_hour,
                (L2_MAX_AGE_DAYS as f64) * 24.0,
                L2_PRUNE_THRESHOLD,
            )
        }
        _ => {
            // L3 Semantic (tier 2+): 2%/month decay, near-permanent
            let decay_per_hour = L3_DECAY_PER_MONTH as f64 / (30.0 * 24.0);
            // Max age: effectively unlimited (10 years)
            (decay_per_hour, 87600.0, L3_PRUNE_THRESHOLD)
        }
    };

    // PIPE-4: Apply graduated LTP protection
    // ltp_decay_factor of 0.5 = 2x slower, 0.1 = 10x slower, 1.0 = no protection
    let effective_rate = decay_rate * ltp_decay_factor as f64;

    // Exponential decay: w(t) = w₀ × e^(-λt)
    let decay_factor = (-effective_rate * hours_elapsed).exp() as f32;

    // Check if edge exceeded max age (should prune)
    // PIPE-4: Potentiated edges (ltp_decay_factor < 1.0) extend max age proportionally
    let effective_max_age = if ltp_decay_factor < 1.0 {
        max_age_hours / (ltp_decay_factor as f64).max(0.01)
    } else {
        max_age_hours
    };
    let should_prune = hours_elapsed > effective_max_age && decay_factor < prune_threshold;

    (decay_factor.max(0.001), should_prune)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_no_decay_at_zero() {
        assert_eq!(hybrid_decay_factor(0.0, false), 1.0);
        assert_eq!(hybrid_decay_factor(-1.0, false), 1.0);
    }

    #[test]
    fn test_exponential_phase() {
        // During consolidation (< 3 days), should be exponential
        let factor_1day = hybrid_decay_factor(1.0, false);
        let factor_2day = hybrid_decay_factor(2.0, false);

        // Exponential property: ratio should be constant
        let ratio_1_to_2 = factor_2day / factor_1day;
        let expected_ratio = (-DECAY_LAMBDA_CONSOLIDATION).exp() as f32;

        assert!((ratio_1_to_2 - expected_ratio).abs() < 0.01);
    }

    #[test]
    fn test_powerlaw_phase() {
        // After crossover (> 3 days), should be power-law
        let factor_7day = hybrid_decay_factor(7.0, false);
        let factor_14day = hybrid_decay_factor(14.0, false);

        // Power-law property: doubling time should give 2^(-β) ratio
        let ratio = factor_14day / factor_7day;
        let expected_ratio = 2.0_f64.powf(-POWERLAW_BETA) as f32;

        assert!((ratio - expected_ratio).abs() < 0.02);
    }

    #[test]
    fn test_continuity_at_crossover() {
        // Values just before and after crossover should be close
        let just_before = hybrid_decay_factor(DECAY_CROSSOVER_DAYS - 0.001, false);
        let just_after = hybrid_decay_factor(DECAY_CROSSOVER_DAYS + 0.001, false);

        assert!((just_before - just_after).abs() < 0.01);
    }

    #[test]
    fn test_potentiated_decays_slower() {
        let normal = hybrid_decay_factor(30.0, false);
        let potentiated = hybrid_decay_factor(30.0, true);

        // Potentiated should retain more
        assert!(potentiated > normal);
    }

    #[test]
    fn test_heavy_tail_retention() {
        // Key property: power-law has heavy tail
        // At 365 days, we should still have meaningful retention
        let year_retention = hybrid_decay_factor(365.0, false);
        let year_retention_potentiated = hybrid_decay_factor(365.0, true);

        // Normal: should be > 1%
        assert!(year_retention > 0.01);
        // Potentiated: should be > 5%
        assert!(year_retention_potentiated > 0.05);
    }

    #[test]
    fn test_custom_parameters() {
        // Test custom function with aggressive decay
        let aggressive = hybrid_decay_factor_custom(7.0, 1.0, 1.5, 0.7);
        let normal = hybrid_decay_factor(7.0, false);

        assert!(aggressive < normal);
    }

    #[test]
    fn test_tier_decay_factor_l1_with_and_without_ltp() {
        let (unprotected, _) = tier_decay_factor(24.0, 0, 1.0);
        let (protected, _) = tier_decay_factor(24.0, 0, 0.5);
        assert!(protected > unprotected);
    }

    #[test]
    fn test_tier_decay_factor_l1_prune_threshold() {
        let (factor_at_max_age, should_prune_at_max_age) = tier_decay_factor(48.0, 0, 1.0);
        // At max age boundary, L1 should still not prune because pruning requires "greater than" max age.
        assert!(factor_at_max_age > 0.1);
        assert!(!should_prune_at_max_age);

        let (factor_past_max_age, should_prune_past_max_age) = tier_decay_factor(96.0, 0, 1.0);
        assert!(factor_past_max_age < 0.1);
        assert!(should_prune_past_max_age);
    }

    #[test]
    fn test_tier_decay_factor_l3_long_tail() {
        let (factor_1y, prune_1y) = tier_decay_factor(365.0 * 24.0, 2, 1.0);
        assert!(factor_1y > 0.7);
        assert!(!prune_1y);

        let (factor_3y, prune_3y) = tier_decay_factor(3.0 * 365.0 * 24.0, 2, 1.0);
        assert!(factor_3y > 0.45);
        assert!(!prune_3y);
    }

    #[test]
    fn test_tier_decay_zero_and_negative_elapsed() {
        let (zero_factor, zero_prune) = tier_decay_factor(0.0, 1, 1.0);
        assert_eq!(zero_factor, 1.0);
        assert!(!zero_prune);

        let (neg_factor, neg_prune) = tier_decay_factor(-10.0, 1, 1.0);
        assert_eq!(neg_factor, 1.0);
        assert!(!neg_prune);
    }

    #[test]
    fn test_tier_decay_invalid_tier_defaults_to_l3() {
        let (invalid_tier, _) = tier_decay_factor(24.0, 9, 1.0);
        let (l3, _) = tier_decay_factor(24.0, 2, 1.0);
        assert_eq!(invalid_tier, l3);
    }
}