Skip to content

Commit c1663dd

Browse files
committed
feat(decay): kind-aware boost (episodic by default)
Semantic facts and procedural how-tos are largely timeless: demoting them on age is wrong by construction. Restrict the freshness boost to ``MemoryKind.EPISODIC`` by default, with ``decay_kinds_csv`` on ``MarvinSettings`` so deployments can opt other kinds in (e.g., ``"episodic,procedural"`` for vaults where runbooks really do go stale, or ``"all"`` to apply decay to everything).
1 parent 8a87035 commit c1663dd

5 files changed

Lines changed: 153 additions & 6 deletions

File tree

docs/guide/evaluation.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,14 @@ their honest RRF rank. See `marvin.decay` for the formula.
149149
| `decay_enabled` | `false` | toggle the freshness boost |
150150
| `decay_half_life_days` | `30.0` | exponential half-life (days) |
151151
| `decay_weight` | `0.5` | maximum boost (1.0 = double the score for an instant-old note) |
152+
| `decay_kinds_csv` | `"episodic"` | comma-separated kinds the boost applies to (`"episodic,procedural"`, `"all"`, `""`) |
153+
154+
The kind filter exists because semantic facts ("Berlin is the capital
155+
of Germany") and procedural how-tos ("how to deploy the staging
156+
service") are largely timeless: a 12-month-old fact is just as true as
157+
a fresh one, so demoting it on age is wrong by construction. Episodic
158+
chat sessions, by contrast, have a strong recency prior. The default
159+
``decay_kinds_csv = "episodic"`` mirrors that intuition.
152160

153161
LongMemEval-S impact (n=30, `--question-type knowledge-update`,
154162
hybrid no-rerank):

src/marvin/config.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,14 @@ class MarvinSettings(BaseSettings):
7373
decay_enabled: bool = Field(default=False)
7474
decay_half_life_days: float = Field(default=30.0, gt=0.0)
7575
decay_weight: float = Field(default=0.5, ge=0.0)
76+
# Comma-separated list of note kinds the freshness boost applies
77+
# to. Default is ``"episodic"`` because semantic facts and
78+
# procedural how-tos are (mostly) timeless and shouldn't be
79+
# demoted purely for being old. Set to ``"episodic,procedural"``
80+
# for vaults that treat procedures as time-sensitive (deployment
81+
# runbooks, dependency notes), or ``""`` to apply decay to no
82+
# kind (effectively disabling it without flipping the toggle).
83+
decay_kinds_csv: str = Field(default="episodic")
7684

7785
# At-ingest entity extraction. Augments ``metadata.links`` (which
7886
# only carries explicit ``[[wikilinks]]`` produced by the

src/marvin/index.py

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ def __init__(
122122
decay_enabled: bool = False,
123123
decay_half_life_days: float = 30.0,
124124
decay_weight: float = 0.5,
125+
decay_kinds: frozenset[MemoryKind] | None = None,
125126
) -> None:
126127
self.db_path = db_path
127128
self.dimensions = dimensions
@@ -152,11 +153,21 @@ def __init__(
152153
# Time-aware boost over the final note ranking. Off by default
153154
# (opt-in via settings) so existing behaviour is unchanged.
154155
# ``decay_weight`` caps the multiplicative boost; setting it to
155-
# 0 disables decay even when ``decay_enabled`` is True. See
156-
# :mod:`marvin.decay` for the rationale.
156+
# 0 disables decay even when ``decay_enabled`` is True.
157+
# ``decay_kinds`` restricts the boost to specific note kinds --
158+
# default is EPISODIC only because semantic, procedural, and
159+
# reflective notes encode (mostly) timeless content where
160+
# "fresher = better" doesn't hold. Pass ``frozenset(MemoryKind)``
161+
# to apply decay to every kind. See :mod:`marvin.decay` for the
162+
# rationale.
157163
self.decay_enabled = decay_enabled
158164
self.decay_half_life_days = max(1e-3, decay_half_life_days)
159165
self.decay_weight = max(0.0, decay_weight)
166+
self.decay_kinds: frozenset[MemoryKind] = (
167+
decay_kinds
168+
if decay_kinds is not None
169+
else frozenset({MemoryKind.EPISODIC})
170+
)
160171
self.db_path.parent.mkdir(parents=True, exist_ok=True)
161172
self.conn = sqlite3.connect(self.db_path)
162173
self.conn.row_factory = sqlite3.Row
@@ -531,9 +542,10 @@ def _apply_decay_boost(
531542
) -> None:
532543
"""Mutate ``scores`` in place with a freshness multiplier.
533544
534-
Pulls ``updated_at`` for the candidate paths in a single query
535-
rather than threading the timestamp through every row in every
536-
first-stage stream. Notes whose ``updated_at`` cannot be parsed
545+
Pulls ``updated_at`` and ``kind`` for the candidate paths in a
546+
single query rather than threading the timestamp through every
547+
row in every first-stage stream. Notes whose ``updated_at``
548+
cannot be parsed, or whose kind isn't in :attr:`decay_kinds`,
537549
are left untouched (multiplier = 1.0), matching the no-op
538550
contract of :func:`marvin.decay.freshness_boost`.
539551
"""
@@ -542,11 +554,18 @@ def _apply_decay_boost(
542554
paths = list(scores.keys())
543555
placeholders = ",".join("?" for _ in paths)
544556
rows = self.conn.execute(
545-
f"SELECT relative_path, updated_at FROM notes "
557+
f"SELECT relative_path, kind, updated_at FROM notes "
546558
f"WHERE relative_path IN ({placeholders})",
547559
paths,
548560
).fetchall()
561+
kinds = self.decay_kinds
549562
for row in rows:
563+
try:
564+
kind = MemoryKind(row["kind"])
565+
except ValueError:
566+
continue
567+
if kind not in kinds:
568+
continue
550569
note_time = parse_note_timestamp(row["updated_at"])
551570
if note_time is None:
552571
continue

src/marvin/service.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,29 @@
1919
SessionContext,
2020
SyncReport,
2121
)
22+
23+
24+
def _parse_decay_kinds(csv: str) -> frozenset[MemoryKind] | None:
25+
"""Decode the ``decay_kinds_csv`` setting.
26+
27+
Empty string => ``frozenset()`` (decay applies to no kind).
28+
The literal token ``all`` => ``None`` so the index falls back to
29+
its default (currently ``EPISODIC`` only) without enumerating
30+
each kind here.
31+
"""
32+
text = csv.strip().lower()
33+
if not text:
34+
return frozenset()
35+
if text == "all":
36+
return frozenset(MemoryKind)
37+
parts = [p.strip() for p in text.split(",") if p.strip()]
38+
out: set[MemoryKind] = set()
39+
for token in parts:
40+
try:
41+
out.add(MemoryKind(token))
42+
except ValueError:
43+
continue
44+
return frozenset(out) if out else frozenset()
2245
from .reranker import RerankerService
2346
from .vault import VaultStore, normalize_links, normalize_tags
2447

@@ -63,6 +86,7 @@ def __init__(
6386
decay_enabled=self.settings.decay_enabled,
6487
decay_half_life_days=self.settings.decay_half_life_days,
6588
decay_weight=self.settings.decay_weight,
89+
decay_kinds=_parse_decay_kinds(self.settings.decay_kinds_csv),
6690
)
6791

6892
def close(self) -> None:

tests/test_index.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1072,6 +1072,94 @@ def test_enabled_with_zero_weight_is_noop(self) -> None:
10721072
finally:
10731073
index.close()
10741074

1075+
def test_decay_kinds_filter_excludes_semantic(self) -> None:
1076+
"""Semantic facts are timeless; the default decay_kinds is
1077+
``{EPISODIC}`` so a fresh semantic note should NOT outrank an
1078+
old semantic note on the basis of recency alone."""
1079+
index = MemoryIndex(
1080+
Path(":memory:"),
1081+
dimensions=8,
1082+
decay_enabled=True,
1083+
decay_half_life_days=30.0,
1084+
decay_weight=0.5,
1085+
)
1086+
try:
1087+
now = datetime(2025, 1, 10, 12, 0, tzinfo=UTC)
1088+
old = now - timedelta(days=365)
1089+
for path, ts in (("old", old), ("new", now)):
1090+
meta = NoteMetadata(
1091+
kind=MemoryKind.SEMANTIC,
1092+
title=path,
1093+
created_at=ts,
1094+
updated_at=ts,
1095+
)
1096+
note = NoteRecord(
1097+
path=Path(f"{path}.md"),
1098+
metadata=meta,
1099+
body="the quokka is a marsupial",
1100+
raw_text="",
1101+
)
1102+
chunks = chunk_markdown(note, 1200, 200)
1103+
index.upsert_note(
1104+
note,
1105+
path,
1106+
chunks=chunks,
1107+
embeddings=_empty_embeds(len(chunks)),
1108+
)
1109+
hits = index.hybrid_search(
1110+
query="quokka marsupial",
1111+
query_embedding=np.zeros(8, dtype=np.float32),
1112+
limit=5,
1113+
query_time=now,
1114+
)
1115+
assert {h.path for h in hits} == {"old", "new"}
1116+
finally:
1117+
index.close()
1118+
1119+
def test_decay_kinds_explicit_includes_semantic(self) -> None:
1120+
"""When the caller explicitly passes ``frozenset({SEMANTIC})``
1121+
the kind filter inverts and semantic notes do get the boost."""
1122+
index = MemoryIndex(
1123+
Path(":memory:"),
1124+
dimensions=8,
1125+
decay_enabled=True,
1126+
decay_half_life_days=30.0,
1127+
decay_weight=0.5,
1128+
decay_kinds=frozenset({MemoryKind.SEMANTIC}),
1129+
)
1130+
try:
1131+
now = datetime(2025, 1, 10, 12, 0, tzinfo=UTC)
1132+
old = now - timedelta(days=365)
1133+
for path, ts in (("old", old), ("new", now)):
1134+
meta = NoteMetadata(
1135+
kind=MemoryKind.SEMANTIC,
1136+
title=path,
1137+
created_at=ts,
1138+
updated_at=ts,
1139+
)
1140+
note = NoteRecord(
1141+
path=Path(f"{path}.md"),
1142+
metadata=meta,
1143+
body="the quokka is a marsupial",
1144+
raw_text="",
1145+
)
1146+
chunks = chunk_markdown(note, 1200, 200)
1147+
index.upsert_note(
1148+
note,
1149+
path,
1150+
chunks=chunks,
1151+
embeddings=_empty_embeds(len(chunks)),
1152+
)
1153+
hits = index.hybrid_search(
1154+
query="quokka marsupial",
1155+
query_embedding=np.zeros(8, dtype=np.float32),
1156+
limit=5,
1157+
query_time=now,
1158+
)
1159+
assert hits[0].path == "new"
1160+
finally:
1161+
index.close()
1162+
10751163
def test_query_time_default_is_now(self) -> None:
10761164
"""Omitting ``query_time`` falls back to the index server's
10771165
``utc_now``, so a recent note still gets a boost over an old one

0 commit comments

Comments
 (0)