Some checks failed
Forge CI / smoke-and-build (pull_request) Failing after 23s
The holographic retriever had temporal decay implemented but disabled (half_life=0). All facts scored equally regardless of age — a 2-year-old fact about a deprecated tool scored the same as yesterday's deployment config. This commit: 1. Changes default temporal_decay_half_life from 0 to 60 days - 60 days: facts lose half their relevance every 2 months - Configurable via config.yaml: plugins.hermes-memory-store.temporal_decay_half_life - Added to config schema so `hermes memory setup` exposes it 2. Adds access-recency boost to search scoring - Facts accessed within 1 half-life get up to 1.5x boost on their decay factor - Boost tapers linearly from 1.5 (just accessed) to 1.0 (1 half-life ago) - Capped at 1.0 effective score (boost can't exceed fresh-fact score) - Prevents actively-used facts from decaying prematurely 3. Scoring pipeline: score = relevance * trust * decay * min(1.0, access_boost) - Fresh facts: decay=1.0, boost≈1.5 → score unchanged - 60-day-old, recently accessed: decay=0.5, boost≈1.25 → score=0.625 - 60-day-old, not accessed: decay=0.5, boost=1.0 → score=0.5 - 120-day-old, not accessed: decay=0.25, boost=1.0 → score=0.25 23 tests covering: - Temporal decay formula (fresh, 1HL, 2HL, 3HL, disabled, None, invalid, future) - Access recency boost (just accessed, halfway, at HL, beyond HL, disabled, range) - Integration (recently-accessed old fact > equally-old unaccessed fact) - Default config verification (half_life=60, not 0) Fixes #241
210 lines
8.6 KiB
Python
210 lines
8.6 KiB
Python
"""Tests for temporal decay and access-recency boost in holographic memory (#241)."""
|
|
|
|
import math
|
|
from datetime import datetime, timedelta, timezone
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
class TestTemporalDecay:
|
|
"""Test _temporal_decay exponential decay formula."""
|
|
|
|
def _make_retriever(self, half_life=60):
|
|
from plugins.memory.holographic.retrieval import FactRetriever
|
|
store = MagicMock()
|
|
return FactRetriever(store=store, temporal_decay_half_life=half_life)
|
|
|
|
def test_fresh_fact_no_decay(self):
|
|
"""A fact updated today should have decay ≈ 1.0."""
|
|
r = self._make_retriever(half_life=60)
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
decay = r._temporal_decay(now)
|
|
assert decay > 0.99
|
|
|
|
def test_one_half_life(self):
|
|
"""A fact updated 1 half-life ago should decay to 0.5."""
|
|
r = self._make_retriever(half_life=60)
|
|
old = (datetime.now(timezone.utc) - timedelta(days=60)).isoformat()
|
|
decay = r._temporal_decay(old)
|
|
assert abs(decay - 0.5) < 0.01
|
|
|
|
def test_two_half_lives(self):
|
|
"""A fact updated 2 half-lives ago should decay to 0.25."""
|
|
r = self._make_retriever(half_life=60)
|
|
old = (datetime.now(timezone.utc) - timedelta(days=120)).isoformat()
|
|
decay = r._temporal_decay(old)
|
|
assert abs(decay - 0.25) < 0.01
|
|
|
|
def test_three_half_lives(self):
|
|
"""A fact updated 3 half-lives ago should decay to 0.125."""
|
|
r = self._make_retriever(half_life=60)
|
|
old = (datetime.now(timezone.utc) - timedelta(days=180)).isoformat()
|
|
decay = r._temporal_decay(old)
|
|
assert abs(decay - 0.125) < 0.01
|
|
|
|
def test_half_life_disabled(self):
|
|
"""When half_life=0, decay should always be 1.0."""
|
|
r = self._make_retriever(half_life=0)
|
|
old = (datetime.now(timezone.utc) - timedelta(days=365)).isoformat()
|
|
assert r._temporal_decay(old) == 1.0
|
|
|
|
def test_none_timestamp(self):
|
|
"""Missing timestamp should return 1.0 (no decay)."""
|
|
r = self._make_retriever(half_life=60)
|
|
assert r._temporal_decay(None) == 1.0
|
|
|
|
def test_empty_timestamp(self):
|
|
r = self._make_retriever(half_life=60)
|
|
assert r._temporal_decay("") == 1.0
|
|
|
|
def test_invalid_timestamp(self):
|
|
"""Malformed timestamp should return 1.0 (fail open)."""
|
|
r = self._make_retriever(half_life=60)
|
|
assert r._temporal_decay("not-a-date") == 1.0
|
|
|
|
def test_future_timestamp(self):
|
|
"""Future timestamp should return 1.0 (no decay for future dates)."""
|
|
r = self._make_retriever(half_life=60)
|
|
future = (datetime.now(timezone.utc) + timedelta(days=10)).isoformat()
|
|
assert r._temporal_decay(future) == 1.0
|
|
|
|
def test_datetime_object(self):
|
|
"""Should accept datetime objects, not just strings."""
|
|
r = self._make_retriever(half_life=60)
|
|
old = datetime.now(timezone.utc) - timedelta(days=60)
|
|
decay = r._temporal_decay(old)
|
|
assert abs(decay - 0.5) < 0.01
|
|
|
|
def test_different_half_lives(self):
|
|
"""30-day half-life should decay faster than 90-day."""
|
|
r30 = self._make_retriever(half_life=30)
|
|
r90 = self._make_retriever(half_life=90)
|
|
old = (datetime.now(timezone.utc) - timedelta(days=45)).isoformat()
|
|
assert r30._temporal_decay(old) < r90._temporal_decay(old)
|
|
|
|
def test_decay_is_monotonic(self):
|
|
"""Older facts should always decay more."""
|
|
r = self._make_retriever(half_life=60)
|
|
now = datetime.now(timezone.utc)
|
|
d1 = r._temporal_decay((now - timedelta(days=10)).isoformat())
|
|
d2 = r._temporal_decay((now - timedelta(days=30)).isoformat())
|
|
d3 = r._temporal_decay((now - timedelta(days=60)).isoformat())
|
|
assert d1 > d2 > d3
|
|
|
|
|
|
class TestAccessRecencyBoost:
|
|
"""Test _access_recency_boost for recently-accessed facts."""
|
|
|
|
def _make_retriever(self, half_life=60):
|
|
from plugins.memory.holographic.retrieval import FactRetriever
|
|
store = MagicMock()
|
|
return FactRetriever(store=store, temporal_decay_half_life=half_life)
|
|
|
|
def test_just_accessed_max_boost(self):
|
|
"""A fact accessed just now should get maximum boost (1.5)."""
|
|
r = self._make_retriever(half_life=60)
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
boost = r._access_recency_boost(now)
|
|
assert boost > 1.45 # Near 1.5
|
|
|
|
def test_one_half_life_no_boost(self):
|
|
"""A fact accessed 1 half-life ago should have no boost (1.0)."""
|
|
r = self._make_retriever(half_life=60)
|
|
old = (datetime.now(timezone.utc) - timedelta(days=60)).isoformat()
|
|
boost = r._access_recency_boost(old)
|
|
assert abs(boost - 1.0) < 0.01
|
|
|
|
def test_half_way_boost(self):
|
|
"""A fact accessed 0.5 half-lives ago should get ~1.25 boost."""
|
|
r = self._make_retriever(half_life=60)
|
|
old = (datetime.now(timezone.utc) - timedelta(days=30)).isoformat()
|
|
boost = r._access_recency_boost(old)
|
|
assert abs(boost - 1.25) < 0.05
|
|
|
|
def test_beyond_one_half_life_no_boost(self):
|
|
"""Beyond 1 half-life, boost should be 1.0."""
|
|
r = self._make_retriever(half_life=60)
|
|
old = (datetime.now(timezone.utc) - timedelta(days=90)).isoformat()
|
|
boost = r._access_recency_boost(old)
|
|
assert boost == 1.0
|
|
|
|
def test_disabled_no_boost(self):
|
|
"""When half_life=0, boost should be 1.0."""
|
|
r = self._make_retriever(half_life=0)
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
assert r._access_recency_boost(now) == 1.0
|
|
|
|
def test_none_timestamp(self):
|
|
r = self._make_retriever(half_life=60)
|
|
assert r._access_recency_boost(None) == 1.0
|
|
|
|
def test_invalid_timestamp(self):
|
|
r = self._make_retriever(half_life=60)
|
|
assert r._access_recency_boost("bad") == 1.0
|
|
|
|
def test_boost_range(self):
|
|
"""Boost should always be in [1.0, 1.5]."""
|
|
r = self._make_retriever(half_life=60)
|
|
now = datetime.now(timezone.utc)
|
|
for days in [0, 1, 15, 30, 45, 59, 60, 90, 365]:
|
|
ts = (now - timedelta(days=days)).isoformat()
|
|
boost = r._access_recency_boost(ts)
|
|
assert 1.0 <= boost <= 1.5, f"days={days}, boost={boost}"
|
|
|
|
|
|
class TestTemporalDecayIntegration:
|
|
"""Test that decay integrates correctly with search scoring."""
|
|
|
|
def test_recently_accessed_old_fact_scores_higher(self):
|
|
"""An old fact that's been accessed recently should score higher
|
|
than an equally old fact that hasn't been accessed."""
|
|
from plugins.memory.holographic.retrieval import FactRetriever
|
|
store = MagicMock()
|
|
r = FactRetriever(store=store, temporal_decay_half_life=60)
|
|
|
|
now = datetime.now(timezone.utc)
|
|
old_date = (now - timedelta(days=120)).isoformat() # 2 half-lives old
|
|
recent_access = (now - timedelta(days=10)).isoformat() # accessed 10 days ago
|
|
old_access = (now - timedelta(days=200)).isoformat() # accessed 200 days ago
|
|
|
|
# Old fact, recently accessed
|
|
decay1 = r._temporal_decay(old_date)
|
|
boost1 = r._access_recency_boost(recent_access)
|
|
effective1 = min(1.0, decay1 * boost1)
|
|
|
|
# Old fact, not recently accessed
|
|
decay2 = r._temporal_decay(old_date)
|
|
boost2 = r._access_recency_boost(old_access)
|
|
effective2 = min(1.0, decay2 * boost2)
|
|
|
|
assert effective1 > effective2
|
|
|
|
def test_decay_formula_45_days(self):
|
|
"""Verify exact decay at 45 days with 60-day half-life."""
|
|
from plugins.memory.holographic.retrieval import FactRetriever
|
|
r = FactRetriever(store=MagicMock(), temporal_decay_half_life=60)
|
|
old = (datetime.now(timezone.utc) - timedelta(days=45)).isoformat()
|
|
decay = r._temporal_decay(old)
|
|
expected = math.pow(0.5, 45/60)
|
|
assert abs(decay - expected) < 0.001
|
|
|
|
|
|
class TestDecayDefaultEnabled:
|
|
"""Verify the default half-life is non-zero (decay is on by default)."""
|
|
|
|
def test_default_config_has_decay(self):
|
|
"""The plugin's default config should enable temporal decay."""
|
|
from plugins.memory.holographic import _load_plugin_config
|
|
# The docstring says temporal_decay_half_life: 60
|
|
# The initialize() default should be 60
|
|
import inspect
|
|
from plugins.memory.holographic import HolographicMemoryProvider
|
|
src = inspect.getsource(HolographicMemoryProvider.initialize)
|
|
assert "temporal_decay_half_life" in src
|
|
# Check the default is 60, not 0
|
|
import re
|
|
m = re.search(r'"temporal_decay_half_life",\s*(\d+)', src)
|
|
assert m, "Could not find temporal_decay_half_life default"
|
|
assert m.group(1) == "60", f"Default is {m.group(1)}, expected 60"
|