""" Tests for ForgettingCurve (M.2) Tests: decay calculation, half-life by importance, archive/deprioritize thresholds. """ import pytest from datetime import UTC, datetime, timedelta from unittest.mock import MagicMock from app.services.memory.forgetting_curve import ForgettingCurve def create_mock_memory( last_accessed_at=None, last_recalled_at=None, importance_level: str = "medium", ): """Create a mock UserMemory for testing.""" memory = MagicMock() memory.last_accessed_at = last_accessed_at memory.last_recalled_at = last_recalled_at memory.importance_level = importance_level memory.decay_score = 1.0 memory.is_archived = False return memory class TestForgettingCurveCalculateDecay: """Test decay score calculation""" def test_fresh_memory_full_retention(self): """Never accessed memory returns full retention (1.0).""" curve = ForgettingCurve() memory = create_mock_memory(last_accessed_at=None, last_recalled_at=None) decay = curve.calculate_decay(memory) assert decay == 1.0 def test_just_accessed_high_retention(self): """Recently accessed memory has high retention.""" curve = ForgettingCurve() recent = datetime.now(UTC) - timedelta(hours=1) memory = create_mock_memory(last_accessed_at=recent) decay = curve.calculate_decay(memory) assert decay > 0.95 def test_30_days_medium_decay(self): """~30 days old memory should have ~0.5 decay for medium importance.""" curve = ForgettingCurve() old = datetime.now(UTC) - timedelta(days=30) memory = create_mock_memory(last_accessed_at=old, importance_level="medium") decay = curve.calculate_decay(memory) # exp(-30/30) = exp(-1) ≈ 0.368, but capped at min 0.0 max 1.0 assert 0.3 < decay < 0.5 def test_90_days_high_importance_slower_decay(self): """High importance memory decays slower - 90 days should still be > 0.3.""" curve = ForgettingCurve() old = datetime.now(UTC) - timedelta(days=90) memory = create_mock_memory(last_accessed_at=old, importance_level="high") decay = curve.calculate_decay(memory) # exp(-90/90) = exp(-1) ≈ 0.368 for high importance (half_life = 90) assert 0.3 < decay < 0.5 def test_90_days_low_importance_faster_decay(self): """Low importance memory decays faster - 90 days should be near 0.""" curve = ForgettingCurve() old = datetime.now(UTC) - timedelta(days=90) memory = create_mock_memory(last_accessed_at=old, importance_level="low") decay = curve.calculate_decay(memory) # exp(-90/15) = exp(-6) ≈ 0.0025 assert decay < 0.1 def test_uses_last_recalled_at_if_last_accessed_missing(self): """Falls back to last_recalled_at when last_accessed_at is None.""" curve = ForgettingCurve() recent = datetime.now(UTC) - timedelta(hours=2) memory = create_mock_memory(last_accessed_at=None, last_recalled_at=recent) decay = curve.calculate_decay(memory) assert decay > 0.9 def test_naive_datetime_converted_to_utc(self): """Naive datetime (no tzinfo) should be converted to UTC.""" curve = ForgettingCurve() recent = datetime.now() - timedelta(hours=1) # naive memory = create_mock_memory(last_accessed_at=recent) decay = curve.calculate_decay(memory) assert decay > 0.9 def test_decay_capped_at_one(self): """Decay score should never exceed 1.0.""" curve = ForgettingCurve() very_recent = datetime.now(UTC) + timedelta(hours=1) # future memory = create_mock_memory(last_accessed_at=very_recent) decay = curve.calculate_decay(memory) assert decay <= 1.0 def test_decay_never_negative(self): """Decay score should never go below 0.0.""" curve = ForgettingCurve() very_old = datetime.now(UTC) - timedelta(days=1000) memory = create_mock_memory(last_accessed_at=very_old) decay = curve.calculate_decay(memory) assert decay >= 0.0 class TestForgettingCurveHalfLife: """Test half-life calculation by importance level.""" def test_high_importance_half_life_90_days(self): """High importance: half_life = 30 * 3 = 90 days.""" curve = ForgettingCurve() memory = create_mock_memory(importance_level="high") half_life = curve.get_half_life(memory) assert half_life == 90.0 def test_medium_importance_half_life_30_days(self): """Medium importance: half_life = 30 * 1 = 30 days.""" curve = ForgettingCurve() memory = create_mock_memory(importance_level="medium") half_life = curve.get_half_life(memory) assert half_life == 30.0 def test_low_importance_half_life_15_days(self): """Low importance: half_life = 30 * 0.5 = 15 days.""" curve = ForgettingCurve() memory = create_mock_memory(importance_level="low") half_life = curve.get_half_life(memory) assert half_life == 15.0 def test_unknown_importance_defaults_to_medium(self): """Unknown importance level defaults to medium multiplier (1.0).""" curve = ForgettingCurve() memory = create_mock_memory(importance_level="unknown") half_life = curve.get_half_life(memory) assert half_life == 30.0 class TestForgettingCurveShouldArchive: """Test archive threshold (decay < 0.2).""" def test_high_decay_not_archived(self): """Memory with high decay score (> 0.2) should NOT be archived.""" curve = ForgettingCurve() recent = datetime.now(UTC) - timedelta(days=5) memory = create_mock_memory(last_accessed_at=recent) should = curve.should_archive(memory) assert should is False def test_low_decay_archived(self): """Memory with decay < 0.2 should be archived.""" curve = ForgettingCurve() # ~100 days for medium importance: exp(-100/30) ≈ 0.035 < 0.2 old = datetime.now(UTC) - timedelta(days=100) memory = create_mock_memory(last_accessed_at=old, importance_level="medium") should = curve.should_archive(memory) assert should is True def test_boundary_decay_not_archived(self): """At exactly 0.2 decay, should NOT be archived (strict < 0.2).""" curve = ForgettingCurve() # Create memory with known decay = 0.2 memory = create_mock_memory(importance_level="low") memory.last_accessed_at = datetime.now(UTC) - timedelta(days=int(15 * 4.605)) # 69 days decay = curve.calculate_decay(memory) should = curve.should_archive(memory) # exp(-69/15) ≈ 0.010 < 0.2 assert decay < 0.2 assert should is True class TestForgettingCurveShouldDeprioritize: """Test deprioritize threshold (decay < 0.5).""" def test_high_decay_not_deprioritized(self): """Memory with high decay score (> 0.5) should NOT be deprioritized.""" curve = ForgettingCurve() recent = datetime.now(UTC) - timedelta(days=10) memory = create_mock_memory(last_accessed_at=recent) should = curve.should_deprioritize(memory) assert should is False def test_medium_decay_deprioritized(self): """Memory with decay < 0.5 should be deprioritized.""" curve = ForgettingCurve() # ~42 days for medium: exp(-42/30) ≈ 0.25 < 0.5 old = datetime.now(UTC) - timedelta(days=42) memory = create_mock_memory(last_accessed_at=old, importance_level="medium") should = curve.should_deprioritize(memory) assert should is True def test_boundary_deprioritize_strict(self): """At exactly 0.5 decay, should NOT be deprioritized (strict < 0.5).""" curve = ForgettingCurve() # For high importance: exp(-x/90) = 0.5 → x = 90 * ln(2) ≈ 62.4 days memory = create_mock_memory(importance_level="high") memory.last_accessed_at = datetime.now(UTC) - timedelta(days=62) decay = curve.calculate_decay(memory) should = curve.should_deprioritize(memory) assert 0.4 < decay < 0.6 assert should is False if __name__ == "__main__": pytest.main([__file__, "-v"])