"""Tests for Mnemosyne memory decay system.""" import json import os import tempfile from datetime import datetime, timedelta, timezone from pathlib import Path import pytest from nexus.mnemosyne.archive import MnemosyneArchive from nexus.mnemosyne.entry import ArchiveEntry @pytest.fixture def archive(tmp_path): """Create a fresh archive for testing.""" path = tmp_path / "test_archive.json" return MnemosyneArchive(archive_path=path) @pytest.fixture def populated_archive(tmp_path): """Create an archive with some entries.""" path = tmp_path / "test_archive.json" arch = MnemosyneArchive(archive_path=path) arch.add(ArchiveEntry(title="Fresh Entry", content="Just added", topics=["test"])) arch.add(ArchiveEntry(title="Old Entry", content="Been here a while", topics=["test"])) arch.add(ArchiveEntry(title="Another Entry", content="Some content", topics=["other"])) return arch class TestVitalityFields: """Test that vitality fields exist on entries.""" def test_entry_has_vitality_default(self): entry = ArchiveEntry(title="Test", content="Content") assert entry.vitality == 1.0 def test_entry_has_last_accessed_default(self): entry = ArchiveEntry(title="Test", content="Content") assert entry.last_accessed is None def test_entry_roundtrip_with_vitality(self): entry = ArchiveEntry( title="Test", content="Content", vitality=0.75, last_accessed="2024-01-01T00:00:00+00:00" ) d = entry.to_dict() assert d["vitality"] == 0.75 assert d["last_accessed"] == "2024-01-01T00:00:00+00:00" restored = ArchiveEntry.from_dict(d) assert restored.vitality == 0.75 assert restored.last_accessed == "2024-01-01T00:00:00+00:00" class TestTouch: """Test touch() access recording and vitality boost.""" def test_touch_sets_last_accessed(self, archive): entry = archive.add(ArchiveEntry(title="Test", content="Content")) assert entry.last_accessed is None touched = archive.touch(entry.id) assert touched.last_accessed is not None def test_touch_boosts_vitality(self, archive): entry = archive.add(ArchiveEntry(title="Test", content="Content", vitality=0.5)) touched = archive.touch(entry.id) # Boost = 0.1 * (1 - 0.5) = 0.05, so vitality should be ~0.55 # (assuming no time decay in test — instantaneous) assert touched.vitality > 0.5 assert touched.vitality <= 1.0 def test_touch_diminishing_returns(self, archive): entry = archive.add(ArchiveEntry(title="Test", content="Content", vitality=0.9)) touched = archive.touch(entry.id) # Boost = 0.1 * (1 - 0.9) = 0.01, so vitality should be ~0.91 assert touched.vitality < 0.92 assert touched.vitality > 0.9 def test_touch_never_exceeds_one(self, archive): entry = archive.add(ArchiveEntry(title="Test", content="Content", vitality=0.99)) for _ in range(10): entry = archive.touch(entry.id) assert entry.vitality <= 1.0 def test_touch_missing_entry_raises(self, archive): with pytest.raises(KeyError): archive.touch("nonexistent-id") def test_touch_persists(self, archive): entry = archive.add(ArchiveEntry(title="Test", content="Content")) archive.touch(entry.id) # Reload archive arch2 = MnemosyneArchive(archive_path=archive._path) loaded = arch2.get(entry.id) assert loaded.last_accessed is not None class TestGetVitality: """Test get_vitality() status reporting.""" def test_get_vitality_basic(self, archive): entry = archive.add(ArchiveEntry(title="Test", content="Content")) status = archive.get_vitality(entry.id) assert status["entry_id"] == entry.id assert status["title"] == "Test" assert 0.0 <= status["vitality"] <= 1.0 assert status["age_days"] == 0 def test_get_vitality_missing_raises(self, archive): with pytest.raises(KeyError): archive.get_vitality("nonexistent-id") class TestComputeVitality: """Test the decay computation.""" def test_new_entry_full_vitality(self, archive): entry = archive.add(ArchiveEntry(title="Test", content="Content")) v = archive._compute_vitality(entry) assert v == 1.0 def test_recently_touched_high_vitality(self, archive): entry = archive.add(ArchiveEntry(title="Test", content="Content")) archive.touch(entry.id) v = archive._compute_vitality(entry) assert v > 0.99 # Should be essentially 1.0 since just touched def test_old_entry_decays(self, archive): entry = archive.add(ArchiveEntry(title="Test", content="Content")) # Simulate old access — set last_accessed to 60 days ago old_date = (datetime.now(timezone.utc) - timedelta(days=60)).isoformat() entry.last_accessed = old_date entry.vitality = 1.0 archive._save() v = archive._compute_vitality(entry) # 60 days with 30-day half-life: v = 1.0 * 0.5^(60/30) = 0.25 assert v < 0.3 assert v > 0.2 def test_very_old_entry_nearly_zero(self, archive): entry = archive.add(ArchiveEntry(title="Test", content="Content")) old_date = (datetime.now(timezone.utc) - timedelta(days=365)).isoformat() entry.last_accessed = old_date entry.vitality = 1.0 archive._save() v = archive._compute_vitality(entry) # 365 days / 30 half-life = ~12 half-lives -> ~0.0002 assert v < 0.01 class TestFading: """Test fading() — most neglected entries.""" def test_fading_returns_lowest_first(self, populated_archive): entries = list(populated_archive._entries.values()) # Make one entry very old old_entry = entries[1] old_date = (datetime.now(timezone.utc) - timedelta(days=90)).isoformat() old_entry.last_accessed = old_date old_entry.vitality = 1.0 populated_archive._save() fading = populated_archive.fading(limit=3) assert len(fading) <= 3 # First result should be the oldest assert fading[0]["entry_id"] == old_entry.id # Should be in ascending order for i in range(len(fading) - 1): assert fading[i]["vitality"] <= fading[i + 1]["vitality"] def test_fading_empty_archive(self, archive): fading = archive.fading() assert fading == [] def test_fading_limit(self, populated_archive): fading = populated_archive.fading(limit=2) assert len(fading) == 2 class TestVibrant: """Test vibrant() — most alive entries.""" def test_vibrant_returns_highest_first(self, populated_archive): entries = list(populated_archive._entries.values()) # Make one entry very old old_entry = entries[1] old_date = (datetime.now(timezone.utc) - timedelta(days=90)).isoformat() old_entry.last_accessed = old_date old_entry.vitality = 1.0 populated_archive._save() vibrant = populated_archive.vibrant(limit=3) # Should be in descending order for i in range(len(vibrant) - 1): assert vibrant[i]["vitality"] >= vibrant[i + 1]["vitality"] # First result should NOT be the old entry assert vibrant[0]["entry_id"] != old_entry.id def test_vibrant_empty_archive(self, archive): vibrant = archive.vibrant() assert vibrant == [] class TestApplyDecay: """Test apply_decay() bulk decay operation.""" def test_apply_decay_returns_stats(self, populated_archive): result = populated_archive.apply_decay() assert result["total_entries"] == 3 assert "decayed_count" in result assert "avg_vitality" in result assert "fading_count" in result assert "vibrant_count" in result def test_apply_decay_persists(self, populated_archive): populated_archive.apply_decay() # Reload arch2 = MnemosyneArchive(archive_path=populated_archive._path) result2 = arch2.apply_decay() # Should show same entries assert result2["total_entries"] == 3 def test_apply_decay_on_empty(self, archive): result = archive.apply_decay() assert result["total_entries"] == 0 assert result["avg_vitality"] == 0.0 class TestStatsVitality: """Test that stats() includes vitality summary.""" def test_stats_includes_vitality(self, populated_archive): stats = populated_archive.stats() assert "avg_vitality" in stats assert "fading_count" in stats assert "vibrant_count" in stats assert 0.0 <= stats["avg_vitality"] <= 1.0 def test_stats_empty_archive(self, archive): stats = archive.stats() assert stats["avg_vitality"] == 0.0 assert stats["fading_count"] == 0 assert stats["vibrant_count"] == 0 class TestDecayLifecycle: """Integration test: full lifecycle from creation to fading.""" def test_entry_lifecycle(self, archive): # Create entry = archive.add(ArchiveEntry(title="Memory", content="A thing happened")) assert entry.vitality == 1.0 # Touch a few times for _ in range(5): archive.touch(entry.id) # Check it's vibrant vibrant = archive.vibrant(limit=1) assert len(vibrant) == 1 assert vibrant[0]["entry_id"] == entry.id # Simulate time passing entry.last_accessed = (datetime.now(timezone.utc) - timedelta(days=45)).isoformat() entry.vitality = 0.8 archive._save() # Apply decay result = archive.apply_decay() assert result["total_entries"] == 1 # Check it's now fading fading = archive.fading(limit=1) assert fading[0]["entry_id"] == entry.id assert fading[0]["vitality"] < 0.5