Part of #1258. - Test vitality fields on entry model - Test touch() access recording and boost - Test compute_vitality decay math - Test fading/vibrant queries - Test apply_decay bulk operation - Test stats integration - Integration lifecycle test
279 lines
9.8 KiB
Python
279 lines
9.8 KiB
Python
"""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
|