Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,8 @@ The SQLite index is a **derived cache** — always rebuildable from the Markdown
| `memory/MEMORY.md` | **Evergreen** — never decays, write-protected during `flush()` |
| `memory/2026-03-21.md` | **Dated** — subject to temporal decay (older memories rank lower) |
| `memory/researcher_agent/` | **Agent-scoped** — isolated namespace per agent |
| `memory/episodes/event.md` | **Episodic** — named events, timestamped |
| `memory/episodes/known-facts.md` | **Evergreen** — non-dated file in a subdirectory, always full score |
| `memory/sessions/2026-04-01.md` | **Dated** — subdirectory dated file, decays by filename date |

Evergreen files hold foundational facts that should always surface at full score. Dated files accumulate daily learning and fade naturally — recent memories rank higher.

Expand Down Expand Up @@ -248,9 +249,10 @@ With the default `half_life_days=30`:

| File | Age source | Decays? |
|------|------------|---------|
| `memory/MEMORY.md`, `memory/architecture.md` (any non-dated file under `memory/`) | — | **No** — evergreen, always full score |
| `memory/MEMORY.md`, `memory/architecture.md` (any non-dated file directly under `memory/`) | — | **No** — evergreen, always full score |
| `memory/agents/notes.md` (non-dated file in any `memory/` subdirectory) | — | **No** — evergreen, same rule as root non-dated files |
| `memory/2026-03-21.md` (dated daily log) | Date parsed from filename | **Yes** |
| `sessions/foo.md`, `memory/agents/notes.md` (undated, non-evergreen) | File `mtime` on disk | **Yes** |
| `memory/sessions/2026-03-21.md` (dated file in any `memory/` subdirectory) | Date parsed from filename | **Yes** — same rule as root dated files |

Evergreen files hold foundational facts — stack choices, hard constraints, permanent preferences — that should always surface at full score regardless of when they were written. Daily logs capture evolving context and fade naturally as new sessions add fresher knowledge.

Expand Down
15 changes: 11 additions & 4 deletions memweave/search/temporal_decay.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,9 @@
from memweave.search.strategy import RawSearchRow

# Regex that matches dated memory paths: memory/YYYY-MM-DD.md
# Works with both ``memory/2026-03-28.md`` and ``./memory/2026-03-28.md``
_DATED_PATH_RE = re.compile(r"(?:^|\/)memory\/(\d{4})-(\d{2})-(\d{2})\.md$")
# Also matches one level of subdirectory: memory/sessions/YYYY-MM-DD.md
# Works with both ``memory/2026-03-28.md`` and ``./memory/sessions/2026-03-28.md``
_DATED_PATH_RE = re.compile(r"(?:^|\/)memory\/(?:[^/]+\/)?(\d{4})-(\d{2})-(\d{2})\.md$")

_DAY_SECONDS = 86_400.0

Expand Down Expand Up @@ -162,7 +163,7 @@ def is_evergreen_path(file_path: str) -> bool:
return True
if not normalized.startswith("memory/"):
return False
# Under memory/ but not dated → evergreen
# Under memory/ but not dated → evergreen (applies at any depth)
return _DATED_PATH_RE.search(file_path.replace("\\", "/")) is None


Expand Down Expand Up @@ -190,12 +191,18 @@ async def _extract_date(
"""Extract the effective date for a file — from path first, then mtime.

Steps:
1. Parse date from filename (``memory/YYYY-MM-DD.md``). Return if found.
1. Parse date from filename (``memory/YYYY-MM-DD.md`` or
``memory/<subdir>/YYYY-MM-DD.md``). Return if found.
2. If the file is evergreen → return ``None`` (no decay applies).
All ``memory/`` files with non-dated filenames are evergreen at any depth.
3. If ``workspace_dir`` is ``None`` → return ``None`` (cannot resolve path).
4. Resolve the absolute path and ``stat()`` the file.
5. Return the mtime as a :class:`datetime.date`, or ``None`` on error.

Steps 3–5 (mtime fallback) are only reached for files outside ``memory/``
(e.g. ``extra_paths`` external files with no date in their filename).
All ``memory/``-managed files are fully resolved by steps 1–2.

Args:
file_path: Relative or absolute file path.
workspace_dir: Root workspace directory used to resolve relative paths.
Expand Down
52 changes: 52 additions & 0 deletions tests/integration/test_temporal_decay.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,55 @@ async def test_temporal_decay(workspace: Path, embedding_model: str) -> None:
old_long = long_scores.get(old_path, scores[old_path])

assert old_long > old_decay, "Longer half-life should preserve more of the old file's score"


_SESSION_OLD_DATE = "2026-01-01" # ~100 days before 2026-04-11
_SESSION_NEW_DATE = "2026-04-01" # ~10 days before 2026-04-11
_SESSION_CONTENT = (
"We confirmed the rollback plan: revert the migration and restart the service.\n"
"The on-call engineer will monitor for 24 hours after deployment.\n"
)


@pytest.mark.asyncio
async def test_session_subdir_dated_decay(workspace: Path, embedding_model: str) -> None:
"""Dated files under memory/sessions/ decay by filename date — same rule as memory/ root."""
sessions_dir = workspace / "memory" / "sessions"
sessions_dir.mkdir()

(sessions_dir / f"{_SESSION_OLD_DATE}.md").write_text(_SESSION_CONTENT)
(sessions_dir / f"{_SESSION_NEW_DATE}.md").write_text(_SESSION_CONTENT)

config = MemoryConfig(
workspace_dir=workspace,
embedding=EmbeddingConfig(model=embedding_model),
query=QueryConfig(min_score=0.0, max_results=10),
)

query = "rollback plan on-call deployment"
old_path = f"memory/sessions/{_SESSION_OLD_DATE}.md"
new_path = f"memory/sessions/{_SESSION_NEW_DATE}.md"

async with MemWeave(config) as mem:
await mem.index()

# Without decay: identical content → nearly identical scores
r_no_decay = await mem.search(query, min_score=0.0)
scores = {r.path: r.score for r in r_no_decay}
assert old_path in scores, f"{old_path} not found in results"
assert new_path in scores, f"{new_path} not found in results"
diff = abs(scores[old_path] - scores[new_path])
assert diff < 0.05, f"Identical content without decay should score similarly, diff={diff:.4f}"

# With aggressive decay: ~100-day-old session file should drop sharply
# half_life=7d → 2^(-100/7) ≈ 0.000054 multiplier
r_decay = await mem.search(query, min_score=0.0, decay_half_life_days=7.0)
decay_scores = {r.path: r.score for r in r_decay}

old_decay = decay_scores.get(old_path, 0.0)
new_decay = decay_scores.get(new_path, scores.get(new_path, 0.0))

assert new_decay > old_decay * 5, (
f"With half_life=7d, new session should score >5× old. "
f"new={new_decay:.4f} old={old_decay:.6f}"
)
41 changes: 41 additions & 0 deletions tests/unit/test_search_temporal_decay.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,12 @@ def test_empty_string(self):
def test_session_file(self):
assert parse_date_from_path("sessions/2026-03-28.md") is None

def test_subdir_dated_path(self):
assert parse_date_from_path("memory/sessions/2026-03-28.md") == date(2026, 3, 28)

def test_subdir_dated_path_researcher(self):
assert parse_date_from_path("memory/researcher/2026-04-01.md") == date(2026, 4, 1)


# ── is_evergreen_path ─────────────────────────────────────────────────────────

Expand Down Expand Up @@ -185,6 +191,21 @@ def test_session_file_not_evergreen(self):
def test_arbitrary_file_not_evergreen(self):
assert is_evergreen_path("docs/readme.md") is False

def test_sessions_subdir_nondated_is_evergreen(self):
assert is_evergreen_path("memory/sessions/foo.md") is True

def test_sessions_subdir_dated_not_evergreen(self):
assert is_evergreen_path("memory/sessions/2026-03-29.md") is False

def test_researcher_subdir_nondated_is_evergreen(self):
assert is_evergreen_path("memory/researcher/analysis.md") is True

def test_researcher_subdir_dated_not_evergreen(self):
assert is_evergreen_path("memory/researcher/2026-04-01.md") is False

def test_custom_subdir_nondated_is_evergreen(self):
assert is_evergreen_path("memory/episodes/standup.md") is True


# ── age_in_days ───────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -312,6 +333,26 @@ async def test_session_file_fresh_score_nearly_unchanged(self, tmp_path: Path):
result = await apply_temporal_decay(rows, half_life_days=30, workspace_dir=tmp_path)
assert result[0].score > 0.9

async def test_memory_sessions_nondated_is_evergreen(self):
"""Non-dated memory/sessions/ file is evergreen — score unchanged."""
rows = [_row("s_nd", "memory/sessions/known-facts.md", 0.8)]
result = await apply_temporal_decay(rows, half_life_days=30, now=date(2026, 3, 28))
assert result[0].score == pytest.approx(0.8)

async def test_memory_sessions_dated_file_decays_by_date(self):
"""Dated memory/sessions/YYYY-MM-DD.md decays by filename date, no mtime needed."""
rows = [_row("s_d", "memory/sessions/2026-01-01.md", 1.0)]
result = await apply_temporal_decay(rows, half_life_days=30, now=date(2026, 3, 28))
expected = apply_decay_to_score(1.0, 86.0, 30.0)
assert result[0].score == pytest.approx(expected, rel=1e-6)

async def test_memory_researcher_dated_file_decays_by_date(self):
"""Dated memory/researcher/YYYY-MM-DD.md decays by filename date."""
rows = [_row("r_d", "memory/researcher/2026-01-01.md", 1.0)]
result = await apply_temporal_decay(rows, half_life_days=30, now=date(2026, 3, 28))
expected = apply_decay_to_score(1.0, 86.0, 30.0)
assert result[0].score == pytest.approx(expected, rel=1e-6)

async def test_mtime_cache_deduplicates_stat_calls(self, tmp_path: Path, monkeypatch):
"""Multiple chunks from the same file path are stat-ted only once."""
import aiofiles.os as aio_os
Expand Down
Loading