Stop a full memory bank from shutting itself

Eviction ranked on use_count first. A memory written this turn has never
been used, so once every survivor in a full bank had been retrieved even
once, the newborn was the lowest row in the bank and was marked forgotten
by the same post-turn run that wrote it — one pass after the one that
embedded it, before retrieval ever saw it.

That state never ends, because counts only go up. The bank an adventure
happened to hold when it first filled is the bank it keeps for good, and
everything the story does afterwards is summarized, evicted and never
ranked. A test now plays four turns against a full bank and asserts the
survivors are the new memories, not the opening ones; before this it kept
the opening three and forgot all four.

Order on last-touch instead, with the count as the tiebreak. A new memory
carries the newest timestamp there is, so it is the safest row in the bank
rather than the most doomed, and it has until something outlives it to
prove itself — the "protect the newest" behaviour falls out of the
ordering rather than being a count of rows to spare. Little is given up:
a memory that is genuinely used stays recently-used by being retrieved,
so the two orders only disagree about memories that mattered once and
have not been wanted since.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Dvvqn9ZDR4ixeFPHNbww7
This commit is contained in:
parththakkar106
2026-08-18 23:22:00 +05:30
committed by Parth
co-authored by Claude Opus 5
parent 5783361124
commit c0cd6fa7ce
2 changed files with 88 additions and 17 deletions
+18 -1
View File
@@ -507,12 +507,29 @@ def _evict_over_capacity(
overflow = active - max(1, settings.memory_bank_capacity) overflow = active - max(1, settings.memory_bank_capacity)
if overflow <= 0: if overflow <= 0:
return return
# Least recently touched goes first, and how often it was used only breaks
# a tie. The other way round — use_count first — shut the bank. A memory
# written this turn has never been used, so once every survivor had been
# retrieved even once the newborn was the lowest row in the bank and was
# evicted by the same post-turn run that wrote it, in the pass right after
# the one that embedded it. Counts only ever go up, so that state never
# ends: the bank an adventure happened to hold when it first filled is the
# bank it keeps for good, and everything the story does afterwards is
# summarized, marked forgotten, and never ranked.
#
# Recency does not have that hole, because a new memory carries the newest
# timestamp there is — it is the safest row in the bank rather than the
# most doomed, and it gets the whole span until something outlives it to
# prove itself. Little is given up by demoting the count: a memory that is
# genuinely used stays recently-used by being retrieved, so the two only
# disagree about memories that mattered once and have not been wanted
# since, which is what a full bank should be dropping anyway.
doomed = db.execute( doomed = db.execute(
select(models.Memory.id) select(models.Memory.id)
.where(*in_this_bank, models.Memory.pinned.is_(False)) .where(*in_this_bank, models.Memory.pinned.is_(False))
.order_by( .order_by(
models.Memory.use_count,
func.coalesce(models.Memory.last_used_at, models.Memory.created_at), func.coalesce(models.Memory.last_used_at, models.Memory.created_at),
models.Memory.use_count,
) )
.limit(overflow) .limit(overflow)
).scalars().all() ).scalars().all()
+70 -16
View File
@@ -307,22 +307,7 @@ def test_forget_cached_vectors_drops_an_adventure(db, adventure, settings, bank)
# ----------------------------------------------------------------- eviction # ----------------------------------------------------------------- eviction
def test_eviction_marks_the_least_used(db, adventure, settings): def test_eviction_marks_the_least_recently_used(db, adventure, settings):
settings.memory_bank_capacity = 2
db.commit()
keep = add_memory(db, adventure, "used often", (1.0, 0.0, 0.0), use_count=5)
also = add_memory(db, adventure, "used sometimes", (0.0, 1.0, 0.0), use_count=3)
drop = add_memory(db, adventure, "never used", (0.0, 0.0, 1.0), use_count=0)
memorybank._evict_over_capacity(adventure, settings, db)
db.expire_all()
assert db.get(models.Memory, drop.id).forgotten is True
assert db.get(models.Memory, keep.id).forgotten is False
assert db.get(models.Memory, also.id).forgotten is False
def test_eviction_breaks_ties_on_recency(db, adventure, settings):
settings.memory_bank_capacity = 1 settings.memory_bank_capacity = 1
db.commit() db.commit()
now = models.utcnow() now = models.utcnow()
@@ -339,6 +324,75 @@ def test_eviction_breaks_ties_on_recency(db, adventure, settings):
assert db.get(models.Memory, recent.id).forgotten is False assert db.get(models.Memory, recent.id).forgotten is False
def test_eviction_breaks_ties_on_use_count(db, adventure, settings):
"""Two memories last wanted at the same moment: the one the story has
leaned on less goes. Only a tiebreak — ranking on the count first is what
used to freeze the bank (see below)."""
settings.memory_bank_capacity = 1
db.commit()
now = models.utcnow()
keep = add_memory(db, adventure, "used often", (1.0, 0.0, 0.0), use_count=5)
drop = add_memory(db, adventure, "used once", (0.0, 1.0, 0.0), use_count=1)
keep.last_used_at = drop.last_used_at = now
db.commit()
memorybank._evict_over_capacity(adventure, settings, db)
db.expire_all()
assert db.get(models.Memory, drop.id).forgotten is True
assert db.get(models.Memory, keep.id).forgotten is False
def test_a_newborn_is_not_evicted_by_the_bank_it_joins(db, adventure, settings):
"""The bank used to shut itself. Eviction ranked on use_count first, and a
memory written this turn has never been used, so the moment every survivor
had been retrieved even once the newborn was the lowest row in the bank and
was retired in the same post-turn run that wrote it — before retrieval ever
saw it. That state is absorbing: counts only go up, so no memory written
after it could ever get in either."""
settings.memory_bank_capacity = 3
db.commit()
now = models.utcnow()
established = [
add_memory(db, adventure, f"used once {i}", (1.0, 0.0, 0.0),
use_count=1, last_used_at=now - timedelta(minutes=3 - i))
for i in range(3)
]
newborn = add_memory(db, adventure, "written this turn", (0.0, 1.0, 0.0))
memorybank._evict_over_capacity(adventure, settings, db)
db.expire_all()
assert db.get(models.Memory, newborn.id).forgotten is False
# the least recently used goes instead
assert db.get(models.Memory, established[0].id).forgotten is True
assert db.get(models.Memory, established[2].id).forgotten is False
def test_a_full_bank_still_turns_over(db, adventure, settings):
"""The same failure seen over several turns: a bank at capacity has to keep
taking on what the story is doing now, or the adventure stops remembering
anything past the point it filled up."""
settings.memory_bank_capacity = 3
now = models.utcnow()
db.commit()
for i in range(3):
add_memory(db, adventure, f"opening {i}", (1.0, 0.0, 0.0),
use_count=1, last_used_at=now - timedelta(minutes=10 - i))
later = []
for turn in range(4):
later.append(add_memory(db, adventure, f"turn {turn}", (0.0, 1.0, 0.0)))
memorybank._evict_over_capacity(adventure, settings, db)
db.expire_all()
active = {m.text for m in db.query(models.Memory).filter(
models.Memory.adventure_id == adventure.id,
models.Memory.forgotten.is_(False),
)}
assert active == {"turn 1", "turn 2", "turn 3"}
def test_eviction_never_touches_a_pin(db, adventure, settings): def test_eviction_never_touches_a_pin(db, adventure, settings):
"""Capacity yields to pins: if everything active is pinned there is nothing """Capacity yields to pins: if everything active is pinned there is nothing
to evict, and the bank is allowed to sit over capacity.""" to evict, and the bank is allowed to sit over capacity."""