diff --git a/features/replay.py b/features/replay.py index 519dae6..38ba94b 100644 --- a/features/replay.py +++ b/features/replay.py @@ -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', ?)""", diff --git a/lifecycle/importance_scheduler.py b/lifecycle/importance_scheduler.py index 5ca8bb9..45f9c6e 100644 --- a/lifecycle/importance_scheduler.py +++ b/lifecycle/importance_scheduler.py @@ -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 diff --git a/tests/test_importance_scheduler.py b/tests/test_importance_scheduler.py index d064c9e..d047cef 100644 --- a/tests/test_importance_scheduler.py +++ b/tests/test_importance_scheduler.py @@ -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) diff --git a/tests/test_replay.py b/tests/test_replay.py index a7feb54..d9cc377 100644 --- a/tests/test_replay.py +++ b/tests/test_replay.py @@ -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)