Aligns the inherited AI-DnD memory and context foundation with the history,
authority and state model M3-M5 established. Long stories now reach the narrator
through a bounded, lineage-safe, inspectable context rather than a growing
transcript.
This commit includes the corrective work that followed the independent review in
planning/reports/M6-IMPLEMENTATION-REPORT.md. The first implementation reported
E03 as passing and it was not; the report records that history rather than
hiding it.
What was already correct, and was kept rather than rebuilt
Memory lineage. Memories already carried (branch_id, depth) and retrieval
already filtered through the capped-path clause; the ten-step negative control
was measured passing against b7005e6 before any change here. M6 adds the
regression tests that pin it, plus provenance and authority on the result.
Summary lineage — both halves
A summary is a row carrying the coordinate of the last node it covers, and
eligibility is the same head-capped lineage clause memories use. That alone
was not enough: generation was seeded from adventures.story_summary, a
campaign-global column with no lineage, so after a divergence the summariser
was handed the abandoned line's prose and asked to update it. The row it
produced was correctly anchored and therefore looked safe while its sentences
described a story the reader had left.
Generation is now seeded from summaries.current — the same question the
context builder asks — so the input and the output are scoped by one rule.
adventures.story_summary remains a reader-facing mirror for the Plot panel and
the export bundle, kept in step when a summary is written and when the head
moves, and nothing authoritative reads it.
Retrieval redundancy
With a real embedding model, four near-identical memories crowded out the one
distinctive clue, which survived only because the default memory_top_k is 5.
Retrieval now drops a candidate that repeats one already chosen, never across
authority classes, at a threshold measured against the configured embedding
model. The clue is retrieved at top_k 5, 4 and 3. Ranking itself is unchanged;
the further factors CONTEXT-AND-MEMORY §20 contemplates remain unimplemented
and are recorded as such.
Memory authority, budgeting, observability
Memory.authority is accepted_story or heuristic, classified by the application
and marked in the prompt; retrieval never writes state. The reply is reserved
out of the context budget, and an impossible configuration fails clearly
instead of overflowing. Each derived pass records ok/idle/failed per campaign,
served by GET /adventures/{id}/derived and shown in Insights, so the M2
failure — a dead memory bank with a green suite — is visible if it recurs.
Provider-wiring tests mock no factory.
Also: two pre-existing test-suite leaks fixed; two fixtures that stored one
vector in every memory now use distinct ones, so lineage assertions stay
readable alongside redundancy suppression.
Planning: CONTEXT-AND-MEMORY, TECHNICAL-DESIGN, DATA-MODEL, V1-ACCEPTANCE-TESTS,
BUILD-MILESTONES, VERSION and planning/README updated to describe what exists,
including that a valid E03 test must regenerate a summary after diverging. The
M5 report was rotated to planning/archive/milestone-reports/. No new ADR — every
choice implements a decision the package had already settled.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWU4gTfLYY6Qq9U7aa9Qw2
470 lines
17 KiB
Python
470 lines
17 KiB
Python
"""Ranking the memory bank without reading the memory bank.
|
|
|
|
Retrieval used to walk `adventure.memories`, which loaded every row with
|
|
its vector. That vector data was 96% of everything a turn read. Retrieval
|
|
now asks SQL which memories are in play, holds their vectors in process,
|
|
and fetches text for only the five it picks.
|
|
|
|
Three things have to stay true for that to be safe, and each is a separate
|
|
failure that no error message would report:
|
|
|
|
* the ranking picks the same memories it always did;
|
|
* nothing bulk-reads a vector column again;
|
|
* a cached vector is never served after the stored one changed.
|
|
|
|
python -m pytest tests/test_memory_retrieval.py -v
|
|
"""
|
|
import asyncio
|
|
from datetime import timedelta
|
|
|
|
import pytest
|
|
from sqlalchemy import event, inspect as sa_inspect
|
|
|
|
from app import memorybank, models
|
|
from app.database import Base, SessionLocal, engine
|
|
|
|
|
|
class StubEmbedder:
|
|
"""Returns whatever vector the test set, and counts calls."""
|
|
|
|
def __init__(self, vector=(1.0, 0.0, 0.0)):
|
|
self.vector = list(vector)
|
|
self.calls = 0
|
|
|
|
async def embed(self, texts):
|
|
self.calls += 1
|
|
return [list(self.vector) for _ in texts]
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def restore_embedding_provider():
|
|
"""Puts `memorybank.embedding_provider` back after every test here.
|
|
|
|
The helpers below replace it by assignment rather than through monkeypatch,
|
|
and until M6 nothing put it back — so whichever test ran last left a stub
|
|
installed for the rest of the session. That was invisible while every other
|
|
memory test stubbed it too, and surfaced the moment M6 added a test that
|
|
asserts on the real factory (`tests/test_provider_wiring.py`).
|
|
"""
|
|
real = memorybank.embedding_provider
|
|
try:
|
|
yield
|
|
finally:
|
|
memorybank.embedding_provider = real
|
|
|
|
|
|
@pytest.fixture()
|
|
def db():
|
|
Base.metadata.create_all(bind=engine)
|
|
memorybank._vector_cache.clear()
|
|
session = SessionLocal()
|
|
try:
|
|
yield session
|
|
finally:
|
|
session.close()
|
|
memorybank._vector_cache.clear()
|
|
Base.metadata.drop_all(bind=engine)
|
|
|
|
|
|
@pytest.fixture()
|
|
def settings(db):
|
|
user = models.User(is_guest=False, email="rank@example.com")
|
|
db.add(user)
|
|
db.flush()
|
|
row = models.Settings(
|
|
user_id=user.id, api_key="enc:dummy", model="m",
|
|
embedding_model="text-embedding-3-small", memory_top_k=2,
|
|
memory_bank_capacity=80,
|
|
)
|
|
db.add(row)
|
|
db.commit()
|
|
return row
|
|
|
|
|
|
@pytest.fixture()
|
|
def adventure(db, settings):
|
|
adv = models.Adventure(
|
|
user_id=settings.user_id, title="Cave", script_state={},
|
|
memory_bank_enabled=True,
|
|
)
|
|
db.add(adv)
|
|
db.flush()
|
|
# Retrieval builds its query from the newest actions. With none, it
|
|
# returns before ranking anything.
|
|
for i in range(2):
|
|
db.add(models.Action(
|
|
adventure_id=adv.id, type="ai", text=f"Something happened {i}."
|
|
))
|
|
db.commit()
|
|
return adv
|
|
|
|
|
|
def add_memory(db, adventure, text, vector, **kwargs):
|
|
memory = models.Memory(adventure_id=adventure.id, text=text, **kwargs)
|
|
db.add(memory)
|
|
db.flush()
|
|
if vector is not None:
|
|
memorybank.set_vector(memory, list(vector))
|
|
db.commit()
|
|
return memory
|
|
|
|
|
|
@pytest.fixture()
|
|
def bank(db, adventure):
|
|
"""Three orthogonal vectors, so a query vector picks one unambiguously."""
|
|
return {
|
|
"x": add_memory(db, adventure, "about x", (1.0, 0.0, 0.0)),
|
|
"y": add_memory(db, adventure, "about y", (0.0, 1.0, 0.0)),
|
|
"z": add_memory(db, adventure, "about z", (0.0, 0.0, 1.0)),
|
|
}
|
|
|
|
|
|
def retrieve(adventure, settings, embedder, **kwargs):
|
|
memorybank.embedding_provider = lambda s: embedder
|
|
kwargs.setdefault("update_stats", False)
|
|
return asyncio.run(memorybank.retrieve_memories(adventure, settings, **kwargs))
|
|
|
|
|
|
@pytest.fixture()
|
|
def sql_log():
|
|
statements: list[str] = []
|
|
|
|
def record(conn, cursor, statement, parameters, context, executemany):
|
|
statements.append(statement)
|
|
|
|
event.listen(engine, "before_cursor_execute", record)
|
|
try:
|
|
yield statements
|
|
finally:
|
|
event.remove(engine, "before_cursor_execute", record)
|
|
|
|
|
|
# ------------------------------------------------------------------- ranking
|
|
|
|
def test_ranks_by_cosine_similarity(db, adventure, settings, bank):
|
|
result = retrieve(adventure, settings, StubEmbedder((1.0, 0.0, 0.0)))
|
|
assert [m["id"] for m in result["used"]][0] == bank["x"].id
|
|
assert result["used"][0]["similarity"] == pytest.approx(1.0)
|
|
assert result["used"][0]["text"] == "about x"
|
|
|
|
|
|
def test_honours_top_k(db, adventure, settings, bank):
|
|
settings.memory_top_k = 1
|
|
db.commit()
|
|
assert len(retrieve(adventure, settings, StubEmbedder())["used"]) == 1
|
|
|
|
|
|
def test_pinned_memories_are_always_used(db, adventure, settings, bank):
|
|
"""A pin means "always in context", however badly it scores."""
|
|
settings.memory_top_k = 1
|
|
bank["z"].pinned = True
|
|
db.commit()
|
|
|
|
used = retrieve(adventure, settings, StubEmbedder((1.0, 0.0, 0.0)))["used"]
|
|
assert [m["id"] for m in used] == [bank["z"].id]
|
|
assert used[0]["pinned"] is True
|
|
|
|
|
|
def test_forgotten_and_unembedded_memories_are_not_candidates(db, adventure, settings):
|
|
live = add_memory(db, adventure, "live", (1.0, 0.0, 0.0))
|
|
add_memory(db, adventure, "evicted", (1.0, 0.0, 0.0), forgotten=True)
|
|
add_memory(db, adventure, "no vector yet", None)
|
|
|
|
used = retrieve(adventure, settings, StubEmbedder())["used"]
|
|
assert [m["id"] for m in used] == [live.id]
|
|
|
|
|
|
def test_empty_bank_returns_no_error(db, adventure, settings):
|
|
assert retrieve(adventure, settings, StubEmbedder()) == {"used": [], "error": None}
|
|
|
|
|
|
def test_missing_embedding_model_is_reported(db, adventure, settings, bank):
|
|
settings.embedding_model = ""
|
|
db.commit()
|
|
result = retrieve(adventure, settings, StubEmbedder())
|
|
assert result["used"] == [] and "embedding model" in result["error"]
|
|
|
|
|
|
def test_update_stats_bumps_only_the_used(db, adventure, settings, bank):
|
|
settings.memory_top_k = 1
|
|
db.commit()
|
|
retrieve(adventure, settings, StubEmbedder((1.0, 0.0, 0.0)), update_stats=True)
|
|
db.commit()
|
|
db.expire_all()
|
|
|
|
assert db.get(models.Memory, bank["x"].id).use_count == 1
|
|
assert db.get(models.Memory, bank["x"].id).last_used_at is not None
|
|
assert db.get(models.Memory, bank["y"].id).use_count == 0
|
|
|
|
|
|
def test_dry_runs_do_not_bump_the_counters(db, adventure, settings, bank):
|
|
"""Insights assembles a context without spending a turn. It must not
|
|
look like the memories were used."""
|
|
retrieve(adventure, settings, StubEmbedder(), update_stats=False)
|
|
db.commit()
|
|
db.expire_all()
|
|
assert all(db.get(models.Memory, m.id).use_count == 0 for m in bank.values())
|
|
|
|
|
|
# --------------------------------------------------------------- the egress
|
|
|
|
def memory_selects(statements):
|
|
return [
|
|
s for s in statements
|
|
if "FROM memories" in s and s.lstrip().upper().startswith("SELECT")
|
|
]
|
|
|
|
|
|
def test_the_json_column_is_gone(db):
|
|
"""`memories.embedding` held the vectors before migration 38, and
|
|
nothing read it afterward. Migration 42 dropped it. Restoring it would
|
|
bring back 4 MB of dead weight and a second place vectors can be
|
|
written from. That second place is how the model-switch bug happened
|
|
(test_embedding_model_switch.py)."""
|
|
columns = {c["name"] for c in sa_inspect(engine).get_columns("memories")}
|
|
assert "embedding" not in columns
|
|
assert {"embedding_blob", "embedded"} <= columns
|
|
|
|
|
|
def test_the_catalogue_query_carries_no_vectors(db, adventure, settings, bank, sql_log):
|
|
"""The query that decides which memories are in play must stay tiny.
|
|
This is the query that used to pull the whole bank across the wire."""
|
|
retrieve(adventure, settings, StubEmbedder())
|
|
catalogue = [s for s in memory_selects(sql_log) if "memories.pinned" in s]
|
|
assert catalogue, "expected a catalogue query"
|
|
assert not any("embedding_blob" in s for s in catalogue)
|
|
|
|
|
|
def test_a_second_turn_reads_no_vectors_at_all(db, adventure, settings, bank, sql_log):
|
|
"""The point of the cache: back-to-back turns on one adventure pay for the
|
|
vectors once."""
|
|
retrieve(adventure, settings, StubEmbedder())
|
|
sql_log.clear()
|
|
retrieve(adventure, settings, StubEmbedder())
|
|
assert not any("embedding_blob" in s for s in memory_selects(sql_log))
|
|
|
|
|
|
def test_only_the_new_memory_is_fetched_after_one_is_added(db, adventure, settings, bank, sql_log):
|
|
"""A growing bank must not re-read the vectors it already holds."""
|
|
retrieve(adventure, settings, StubEmbedder())
|
|
added = add_memory(db, adventure, "about w", (0.5, 0.5, 0.0))
|
|
|
|
sql_log.clear()
|
|
retrieve(adventure, settings, StubEmbedder())
|
|
vector_reads = [s for s in memory_selects(sql_log) if "embedding_blob" in s]
|
|
assert len(vector_reads) == 1
|
|
# One placeholder means one id: the new memory, and nothing else.
|
|
assert vector_reads[0].count("?") == 1
|
|
assert added.id in {m["id"] for m in
|
|
retrieve(adventure, settings, StubEmbedder((0.5, 0.5, 0.0)))["used"]}
|
|
|
|
|
|
def test_only_top_k_texts_are_fetched(db, adventure, settings, bank, sql_log):
|
|
settings.memory_top_k = 1
|
|
db.commit()
|
|
retrieve(adventure, settings, StubEmbedder())
|
|
text_reads = [s for s in memory_selects(sql_log) if "memories.text" in s]
|
|
assert len(text_reads) == 1
|
|
assert text_reads[0].count("?") == 1
|
|
|
|
|
|
# ---------------------------------------------------------------- staleness
|
|
|
|
def test_a_rewritten_vector_is_not_served_from_cache(db, adventure, settings, bank):
|
|
"""The cache's one genuine hazard: a memory keeps its id while its
|
|
vector changes, so an id-set check alone would continue serving the
|
|
old vector. Editing a memory's text and re-embedding it does exactly
|
|
that.
|
|
"""
|
|
settings.memory_top_k = 1
|
|
db.commit()
|
|
first = retrieve(adventure, settings, StubEmbedder((0.0, 0.0, 1.0)))
|
|
assert [m["id"] for m in first["used"]] == [bank["z"].id]
|
|
|
|
# z is re-embedded onto the x axis, all within one gap between turns.
|
|
memorybank.set_vector(bank["z"], [1.0, 0.0, 0.0])
|
|
db.commit()
|
|
|
|
again = retrieve(adventure, settings, StubEmbedder((0.0, 0.0, 1.0)))
|
|
assert [m["id"] for m in again["used"]] != [bank["z"].id]
|
|
|
|
|
|
def test_a_deleted_memory_leaves_the_cache(db, adventure, settings, bank):
|
|
retrieve(adventure, settings, StubEmbedder())
|
|
db.delete(bank["x"])
|
|
db.commit()
|
|
|
|
used = retrieve(adventure, settings, StubEmbedder((1.0, 0.0, 0.0)))["used"]
|
|
assert bank["x"].id not in {m["id"] for m in used}
|
|
assert memorybank._vector_cache[adventure.id].keys() == {bank["y"].id, bank["z"].id}
|
|
|
|
|
|
def test_the_cache_is_bounded(db, adventure, settings, bank):
|
|
"""It holds vectors indefinitely, so without a bound a long-running process
|
|
accumulates every adventure ever played."""
|
|
retrieve(adventure, settings, StubEmbedder())
|
|
for fake_id in range(1000, 1000 + memorybank.VECTOR_CACHE_ADVENTURES + 2):
|
|
memorybank._vectors_for(db, fake_id, [])
|
|
assert len(memorybank._vector_cache) == memorybank.VECTOR_CACHE_ADVENTURES
|
|
assert adventure.id not in memorybank._vector_cache # evicted, least recent
|
|
|
|
|
|
def test_forget_cached_vectors_drops_an_adventure(db, adventure, settings, bank):
|
|
retrieve(adventure, settings, StubEmbedder())
|
|
assert adventure.id in memorybank._vector_cache
|
|
memorybank.forget_cached_vectors(adventure.id)
|
|
assert adventure.id not in memorybank._vector_cache
|
|
|
|
|
|
# ----------------------------------------------------------------- eviction
|
|
|
|
def test_eviction_marks_the_least_recently_used(db, adventure, settings):
|
|
settings.memory_bank_capacity = 1
|
|
db.commit()
|
|
now = models.utcnow()
|
|
recent = add_memory(db, adventure, "recent", (1.0, 0.0, 0.0), use_count=1)
|
|
stale = add_memory(db, adventure, "stale", (0.0, 1.0, 0.0), use_count=1)
|
|
recent.last_used_at = now
|
|
stale.last_used_at = now - timedelta(days=30)
|
|
db.commit()
|
|
|
|
memorybank._evict_over_capacity(adventure, settings, db)
|
|
db.expire_all()
|
|
|
|
assert db.get(models.Memory, stale.id).forgotten is True
|
|
assert db.get(models.Memory, recent.id).forgotten is False
|
|
|
|
|
|
def test_eviction_breaks_ties_on_use_count(db, adventure, settings):
|
|
"""Two memories last used at the same moment: the one the story has
|
|
used less is the one that goes. This must be 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 stop accepting new memories. Eviction ranked on
|
|
use_count first, and a memory written this turn has never been used.
|
|
Once every existing memory had been retrieved even once, the newborn
|
|
became the lowest-ranked row in the bank. Eviction then removed it in
|
|
the same post-turn run that wrote it, before retrieval ever saw it.
|
|
That state never recovers: counts only go up, so no memory written
|
|
after it could 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 must
|
|
keep accepting new memories, or the adventure stops remembering
|
|
anything past the point where 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):
|
|
"""Capacity yields to pins: if everything active is pinned there is nothing
|
|
to evict, and the bank is allowed to sit over capacity."""
|
|
settings.memory_bank_capacity = 1
|
|
db.commit()
|
|
pins = [add_memory(db, adventure, f"pin {i}", (1.0, 0.0, 0.0), pinned=True)
|
|
for i in range(3)]
|
|
|
|
memorybank._evict_over_capacity(adventure, settings, db)
|
|
db.expire_all()
|
|
|
|
assert all(db.get(models.Memory, p.id).forgotten is False for p in pins)
|
|
|
|
|
|
def test_eviction_reads_no_vectors(db, adventure, settings, bank, sql_log):
|
|
"""It ran every turn and pulled the whole bank to count it."""
|
|
settings.memory_bank_capacity = 1
|
|
db.commit()
|
|
sql_log.clear()
|
|
memorybank._evict_over_capacity(adventure, settings, db)
|
|
assert not any("embedding_blob" in s for s in memory_selects(sql_log))
|
|
|
|
|
|
def test_evicted_memories_drop_out_of_the_cache(db, adventure, settings, bank):
|
|
retrieve(adventure, settings, StubEmbedder())
|
|
settings.memory_bank_capacity = 1
|
|
db.commit()
|
|
memorybank._evict_over_capacity(adventure, settings, db)
|
|
|
|
used = retrieve(adventure, settings, StubEmbedder())["used"]
|
|
assert len(used) == 1
|
|
assert len(memorybank._vector_cache[adventure.id]) == 1
|
|
|
|
|
|
# ------------------------------------------------------------- embed queue
|
|
|
|
def test_embed_pending_picks_only_unembedded_memories(db, adventure, settings):
|
|
done = add_memory(db, adventure, "already done", (1.0, 0.0, 0.0))
|
|
todo = add_memory(db, adventure, "needs a vector", None)
|
|
add_memory(db, adventure, "evicted, skip", None, forgotten=True)
|
|
|
|
embedder = StubEmbedder((0.0, 1.0, 0.0))
|
|
memorybank.embedding_provider = lambda s: embedder
|
|
asyncio.run(memorybank._embed_pending(adventure, settings, db))
|
|
db.expire_all()
|
|
|
|
assert db.get(models.Memory, todo.id).embedded is True
|
|
assert embedder.calls == 1
|
|
# The one already embedded keeps the vector it had.
|
|
assert db.get(models.Memory, done.id).embedding_blob == memorybank.vectors.pack(
|
|
[1.0, 0.0, 0.0]
|
|
)
|
|
|
|
|
|
def test_embed_pending_reads_no_vectors(db, adventure, settings, bank, sql_log):
|
|
add_memory(db, adventure, "needs a vector", None)
|
|
embedder = StubEmbedder()
|
|
memorybank.embedding_provider = lambda s: embedder
|
|
|
|
sql_log.clear()
|
|
asyncio.run(memorybank._embed_pending(adventure, settings, db))
|
|
assert not any("embedding_blob" in s for s in memory_selects(sql_log))
|