feat(memory): complete M.2-M.5 memory upgrade phases with tests

- M.2: ForgettingCurve, MemoryDecay, MemoryReinforcement (selective forgetting)
- M.3: DailyDigestGenerator, ReminderScheduler, ProactiveInformer (proactive reminders)
- M.4: MemoryExtractor with LLM-based memory extraction from conversations
- M.5: MemoryRecallInjector with token budget control for prompt injection
- All phases include comprehensive unit tests (109 tests passing)
- Updated checklist.md to mark all tasks complete
This commit is contained in:
2026-04-05 14:09:51 +08:00
parent 9bfa0dcc11
commit 11160ec4d2
22 changed files with 4117 additions and 186 deletions

View File

@@ -0,0 +1,243 @@
"""
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"])