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
7 changes: 6 additions & 1 deletion features/replay.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,12 @@ async def _txn(conn: Any) -> int:
new = min(1.0, old + boost)
if new <= old:
continue
await conn.execute("UPDATE core_memory SET importance=?, updated_at=? WHERE entry_id=?", (new, now, int(r["entry_id"])))
# `updated_at` is deliberately NOT written: this is a derived-score
# change on a nightly timer, and `updated_at` is the age that
# forgetting decays by and archives on. Stamping it made a
# frequently recalled fact immortal — it could never age out. The
# replay time is recorded in `importance_audit.rescored_at` below.
await conn.execute("UPDATE core_memory SET importance=? WHERE entry_id=?", (new, int(r["entry_id"])))
await conn.execute(
"""INSERT INTO importance_audit (user_id, chunk_id, source, old_importance, new_importance, signal_breakdown, reason, rescored_at)
VALUES (?, ?, 'core_memory', ?, ?, '{}', 'cls_replay', ?)""",
Expand Down
10 changes: 8 additions & 2 deletions lifecycle/importance_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -480,9 +480,15 @@ def _rescore_user(self, user_id: str, conn: Any, stats: dict[str, int]) -> None:
stats["skipped"] += 1
continue
now = time.time()
# `updated_at` is NOT written here. It is the age that
# `lifecycle.forgetting.decay` and the archive read, so stamping it
# with `now` made every rescored row look freshly written and no
# such row ever aged out or archived. The rescore time is already
# recorded in `importance_audit.rescored_at`, which is where it
# belongs.
conn.execute(
"UPDATE core_memory SET importance=?, updated_at=? WHERE entry_id=?",
(new_score, now, int(r["entry_id"])),
"UPDATE core_memory SET importance=? WHERE entry_id=?",
(new_score, int(r["entry_id"])),
)
conn.execute(
"""INSERT INTO importance_audit
Expand Down
31 changes: 31 additions & 0 deletions tests/test_importance_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,37 @@ async def test_scheduler_rescores_big_delta(cm, monkeypatch):
assert audit["reason"] == "scheduled"


@pytest.mark.asyncio
async def test_rescoring_does_not_rejuvenate_the_row(cm, monkeypatch):
"""`updated_at` is the age that forgetting and archive read.

The sweep used to stamp `updated_at=now` on every row whose score moved, so a
rescored row looked freshly written: decay never saw it age and archive never
reached it. Measured on the Hermes base before the fix, 94 rows older than 60
days by `created_at` still read as younger than 30 by `updated_at`.
"""
monkeypatch.setattr("lifecycle.importance_scheduler.connection_manager", cm)
conn = await cm.get("memory.db")
born = time.time() - 90 * 86400
touched = time.time() - 5 * 86400
await conn.execute(
"""INSERT INTO core_memory
(user_id, "key", value, importance, updated_at, created_at)
VALUES (?, ?, ?, ?, ?, ?)""",
("alice", "k", "redis cluster pipelining", 0.4, touched, born),
)
await conn.commit()

s = ImportanceScheduler(scheduler_config=SchedulerConfig(delta_threshold=0.05))
stats = await s.run_once()
assert stats["rescored"] >= 1

row = await (await conn.execute("SELECT importance, updated_at, created_at FROM core_memory WHERE \"key\"='k'")).fetchone()
assert row["importance"] != 0.4, "the rescore still happened"
assert row["updated_at"] == touched, "a rescore must not reset the row's age"
assert row["created_at"] == born


@pytest.mark.asyncio
async def test_scheduler_skips_small_delta(cm, monkeypatch):
monkeypatch.setattr("lifecycle.importance_scheduler.connection_manager", cm)
Expand Down
28 changes: 28 additions & 0 deletions tests/test_replay.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,34 @@ def _save(core, key, value, importance):
asyncio.run(core.save("u1", key, value, importance=importance))


def test_replay_boost_does_not_rejuvenate_the_row(tmp_path):
"""The nightly boost is a derived-score change, not owner activity.

`core_memory.updated_at` is the age that `lifecycle.forgetting` decays by and
archives on, so a nightly timer that stamps it makes a frequently recalled
row immortal: it never ages and never archives. The rescore time belongs in
`importance_audit.rescored_at`, which already carries it.
"""
cm, core = _make_cm(tmp_path)
_save(core, "hot", "recalled long ago", importance=0.5)
conn = asyncio.run(cm.get(DB_NAME))
hot_id = int(_fetchone(conn, "SELECT entry_id FROM core_memory WHERE key='hot'")[0])

born = time.time() - 120 * 86400
touched = time.time() - 45 * 86400
asyncio.run(conn.execute("UPDATE core_memory SET created_at=?, updated_at=? WHERE entry_id=?", (born, touched, hot_id)))
asyncio.run(conn.commit())

asyncio.run(record_recall_useful(cm, "user", "u1", [(hot_id, "hot")]))
result = asyncio.run(cls_replay(cm, "u1", layer="user"))
assert result["boosted"] == 1

row = _fetchone(conn, "SELECT importance, updated_at, created_at FROM core_memory WHERE entry_id=?", (hot_id,))
assert abs(float(row[0]) - 0.55) < 1e-9, "the boost still happened"
assert float(row[1]) == touched, "a background boost must not reset the row's age"
assert float(row[2]) == born


def test_record_and_replay_boost(tmp_path):
"""A recalled fact gets +0.05; unrecalled facts are untouched."""
cm, core = _make_cm(tmp_path)
Expand Down
Loading