From 241e854466309c36803e5a36f31380c8d3263d26 Mon Sep 17 00:00:00 2001 From: Cipher208 <269750686+Cipher208@users.noreply.github.com> Date: Tue, 3 Nov 2026 06:07:28 +0100 Subject: [PATCH] fix(memory): stop background scoring from resetting core_memory.updated_at `updated_at` is the age that `lifecycle.forgetting.decay` reads and that the archive cutoff compares against. Two background paths stamped it with `now` on every score change, so a rescored or frequently recalled row looked freshly written forever: it never aged, never decayed and never archived, while the scheduler's own candidate filter (`updated_at > now - only_recent_days`) kept handing it back. Measured on the live bases before the fix: Hermes had 94 rows older than 60 days by `created_at` that read as younger than 30 by `updated_at` (634 of 973 rows had been touched within a day). Both writers now update `importance` only: - `lifecycle/importance_scheduler.py::_rescore_user` (periodic sweep) - `features/replay.py::cls_replay` (nightly hook, recall boost) The rescore time is already recorded in `importance_audit.rescored_at`, which is where it belongs. `features/quality.py::record_feedback` keeps writing `updated_at` on purpose: it is reachable only from the interactive `memory_quality` tool, not from a timer, and an explicit feedback action really does touch the row. Rows that previously never aged will now age and become archivable. That is the fix, not a side effect: `created_at` is untouched and no data is lost. --- features/replay.py | 7 ++++++- lifecycle/importance_scheduler.py | 10 ++++++++-- tests/test_importance_scheduler.py | 31 ++++++++++++++++++++++++++++++ tests/test_replay.py | 28 +++++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 3 deletions(-) diff --git a/features/replay.py b/features/replay.py index 519dae66..38ba94b2 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 5ca8bb91..45f9c6e5 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 d064c9e3..d047ceff 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 a7feb540..d9cc3776 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)