Files
interactive-story/backend/tests/test_memory_nodes.py
T
parththakkar106andClaude Opus 5 c51531709d Mark the story with a node, not with a count
The memory bank and the story summary each kept a cursor: how many story
actions they had already covered. A count is a position in a list, and this
list moves — delete an action in front of the mark and every later one slides
down a slot, so the mark now covers one it has never read. All the cursor
bookkeeping existed to patch that up.

Both marks are now (branch_id, depth): the node up to and including which the
work is done. A depth is a coordinate along a path, not an offset into a list,
so nothing in front of it can move it. That deletes rather than rewrites
`position_of_index`, `note_action_removed`, `_rewind_cursors_to_index`,
`prune_dangling_memories` and the every-pass clamp in `run_post_turn`.

A memory hangs off the node its block ends on, so a fork inherits its
ancestors' memories without copying any, and retrieval selects through the
branch clause over the *whole* lineage — recall is long-range by definition and
cannot be windowed. Measured: 1,807 B on a story forked twenty times against
1,823 B on a flat one of the same length.

Migrations 53-56 translate the old counts into nodes. They rewrite `adventures`
and not `actions`, so this one needs no VACUUM FULL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Dvvqn9ZDR4ixeFPHNbww7
2026-08-18 19:14:07 +05:30

417 lines
16 KiB
Python

"""Phase 14 SP3 — memories hang off nodes, and the marks are nodes too.
Two claims, and neither of them fails loudly if it is wrong:
* **A memory belongs to the path that produced it.** A memory made on branch B
must be invisible from A, and the memories of a shared ancestor must be
visible from both — without anything being copied when a fork happens. The
failure mode is a prompt quietly carrying a summary of a story the player
abandoned.
* **Retrieval reads the *whole* lineage, and that stays affordable.** The story
is read through a window, but recall is long-range by definition and cannot
be — so the clause names every ancestor, and the bet is that memories are
sparse enough (one per six actions) for that to be tens of small rows even
twenty forks deep. Measured below rather than asserted.
Nothing in the product forks yet, so the fork is built by hand, exactly as
`test_branch_clause.py` builds it.
python -m pytest tests/test_memory_nodes.py -v
"""
import os
import tempfile
_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
_tmp.close()
os.environ["AIDND_DB_PATH"] = _tmp.name
os.environ.pop("AIDND_DATABASE_URL", None)
os.environ.pop("DATABASE_URL", None)
import asyncio
import pytest
from app import memorybank, models, tree
from app.context import cursors, lineage
from app.database import Base, SessionLocal, engine
from tools import dbmeter
class StubEmbedder:
"""Returns whatever vector the test set, for any text."""
def __init__(self, vector=(1.0, 0.0, 0.0)):
self.vector = list(vector)
async def embed(self, texts):
return [list(self.vector) for _ in texts]
# --------------------------------------------------------------- the fixture
def make_branch(db, adventure, parent=None, fork_depth=None):
"""A branch row whose lineage is its parent's, capped, plus itself — the
computation SP5 will do at fork time, written out so the fixture cannot
pass by agreeing with a bug in the code under test."""
branch = models.Branch(
adventure_id=adventure.id,
parent_branch_id=parent.id if parent else None,
fork_depth=fork_depth,
lineage=[],
)
db.add(branch)
db.flush()
inherited = []
if parent is not None:
for ancestor_id, cap in lineage.entries_of(parent):
capped = fork_depth if cap is None else min(cap, fork_depth)
inherited.append([ancestor_id, capped])
branch.lineage = [[branch.id, None]] + inherited
db.flush()
return branch
def add_node(db, adventure, branch, depth, label, index=None):
action = models.Action(
adventure_id=adventure.id,
index=depth if index is None else index,
branch_id=branch.id,
depth=depth,
type="ai" if depth % 2 else "do",
text=f"{label}{depth}",
)
db.add(action)
return action
def add_memory(db, adventure, text, node, vector=(1.0, 0.0, 0.0), **kwargs):
"""A memory of the block ending on `node`, attached the way the post-turn
pass attaches one."""
memory = models.Memory(
adventure_id=adventure.id, text=text,
source_start=None if node is None else node.depth,
source_end=None if node is None else node.depth,
**kwargs,
)
if node is not None:
tree.attach_memory(memory, node)
else:
tree.place_memory(db, adventure, memory)
db.add(memory)
db.flush()
memorybank.set_vector(memory, list(vector))
db.commit()
return memory
@pytest.fixture()
def forked():
"""A0..A3, then B4 B5 off A3, then C6 C7 off B5 — with a memory hung off
one node of each branch, and A playing on past the fork it was left at.
The head is C, so the story is A0 A1 A2 A3 B4 B5 C6 C7 and the memories in
play are A's and B's and C's — but not the one on A5, which is on a sibling
of B4 and belongs to a story nobody is reading.
"""
Base.metadata.create_all(bind=engine)
db = SessionLocal()
user = models.User(is_guest=False, email="nodes@example.com")
db.add(user)
db.flush()
settings = models.Settings(
user_id=user.id, api_key="enc:dummy", model="m",
embedding_model="text-embedding-3-small", memory_top_k=10,
memory_bank_capacity=80,
)
db.add(settings)
adventure = models.Adventure(
user_id=user.id, title="Forked", script_state={}, memory_bank_enabled=True,
auto_summarize=True,
)
db.add(adventure)
db.flush()
a = make_branch(db, adventure)
b = make_branch(db, adventure, parent=a, fork_depth=3)
c = make_branch(db, adventure, parent=b, fork_depth=5)
nodes = {}
for depth in range(4):
nodes[f"A{depth}"] = add_node(db, adventure, a, depth, "A")
for depth in (4, 5): # A kept playing: siblings of B4/B5
nodes[f"A{depth}"] = add_node(db, adventure, a, depth, "A", index=100 + depth)
for depth in (4, 5):
nodes[f"B{depth}"] = add_node(db, adventure, b, depth, "B")
for depth in (6, 7):
nodes[f"C{depth}"] = add_node(db, adventure, c, depth, "C")
db.flush()
memories = {
"shared": add_memory(db, adventure, "on the shared trunk", nodes["A3"]),
"sibling": add_memory(db, adventure, "on A's own continuation", nodes["A5"]),
"b": add_memory(db, adventure, "on B", nodes["B5"]),
"c": add_memory(db, adventure, "on C", nodes["C7"]),
}
adventure.head_branch_id = c.id
adventure.head_depth = 7
db.commit()
ids = {"a": a.id, "b": b.id, "c": c.id, "nodes": nodes, "memories": memories}
try:
yield db, adventure, settings, ids
finally:
db.close()
Base.metadata.drop_all(bind=engine)
def switch_to(db, adventure, branch_id, tip):
adventure.head_branch_id = branch_id
adventure.head_depth = tip
db.commit()
def retrieved(adventure, settings) -> set[str]:
memorybank.embedding_provider = lambda s: StubEmbedder()
result = asyncio.run(
memorybank.retrieve_memories(adventure, settings, update_stats=False)
)
assert result["error"] is None, result["error"]
return {m["text"] for m in result["used"]}
# ------------------------------------------------------------- the isolation
def test_a_memory_on_a_sibling_is_not_retrieved(forked):
"""The whole point. A5 is a node of the story that was abandoned when B
forked, and the memory hanging off it must not reach a prompt on C."""
db, adventure, settings, ids = forked
assert retrieved(adventure, settings) == {
"on the shared trunk", "on B", "on C"
}
def test_a_shared_ancestor_is_visible_from_both_branches(forked):
"""Nothing is copied at a fork, so the trunk's memories are shared by
construction rather than by duplication."""
db, adventure, settings, ids = forked
switch_to(db, adventure, ids["a"], 5)
from_a = retrieved(adventure, settings)
assert "on the shared trunk" in from_a
# ...and from A, the branches taken off it are the ones out of reach.
assert from_a == {"on the shared trunk", "on A's own continuation"}
def test_the_lineage_is_read_whole_not_windowed(forked):
"""The story is read through a window; recall is not. The trunk memory is
four nodes and two forks back, and is still a candidate."""
db, adventure, settings, ids = forked
path = lineage.path_of(db, adventure)
assert len(path) == 3
# The window a *story* read would use here names one entry. Retrieval names
# all three, which is the difference this test exists to pin.
assert path.prefix_covering(2) == 1
assert "on the shared trunk" in retrieved(adventure, settings)
def test_a_hand_written_memory_is_not_lost_at_the_first_fork(forked):
"""A memory nobody derived summarises no node, so it has a branch but no
depth. A capped `depth <= fork` would drop it the moment its branch stopped
being the newest entry — a memory vanishing some turns after it was typed,
which is exactly the kind of thing nothing reports."""
db, adventure, settings, ids = forked
switch_to(db, adventure, ids["a"], 5)
typed = add_memory(db, adventure, "typed by hand", None)
assert (typed.branch_id, typed.depth) == (ids["a"], None)
switch_to(db, adventure, ids["c"], 7) # fork away from where it was written
assert "typed by hand" in retrieved(adventure, settings)
# ------------------------------------------------------------------ the marks
def test_a_mark_moves_to_the_node_the_memory_covers(forked):
"""The mark and the memory are one statement about where the pass got to,
so they are written from the same row."""
db, adventure, settings, ids = forked
cursors.MEMORY.anchor_at(adventure, ids["nodes"]["B5"])
db.commit()
assert cursors.MEMORY.stored(adventure) == (ids["b"], 5)
assert cursors.MEMORY.depth(db, adventure) == 5
def test_a_mark_from_a_sibling_reads_as_nothing_covered(forked):
"""A mark is a node, so moving to another story has to be answered rather
than assumed. Ground this path never travelled is not covered ground, and
the fallback for 'I don't know' has to be redoing the work, not skipping
it."""
db, adventure, settings, ids = forked
cursors.MEMORY.anchor_at(adventure, ids["nodes"]["C7"])
db.commit()
switch_to(db, adventure, ids["a"], 5)
assert cursors.MEMORY.depth(db, adventure) == cursors.NO_DEPTH
def test_a_mark_on_an_ancestor_is_capped_at_the_fork(forked):
"""A6 and A7 are past where this path left A, so a mark deeper than the
fork cannot mean 'covered' for anything on this story."""
db, adventure, settings, ids = forked
cursors.MEMORY.anchor_at(adventure, ids["nodes"]["A5"])
db.commit()
assert cursors.MEMORY.depth(db, adventure) == 3 # C forks off B forks off A@3
def test_a_mark_never_moves_forward_on_a_rewind(forked):
db, adventure, settings, ids = forked
cursors.MEMORY.anchor_at(adventure, ids["nodes"]["A3"])
cursors.rewind_all(adventure, ids["c"], 6)
assert cursors.MEMORY.stored(adventure) == (ids["a"], 3)
# ---------------------------------------------------- what the passes read
def test_the_summary_folds_in_only_the_path_it_is_on(forked, monkeypatch):
"""`_update_story_summary` gathers the memories past its mark. On C that is
B's and C's — never the one on A's own continuation, whose depth would
otherwise put it squarely inside the range."""
db, adventure, settings, ids = forked
monkeypatch.setattr(memorybank, "SUMMARY_INTERVAL", 1)
class Stub:
def __init__(self):
self.prompts = []
async def complete(self, system, user, **kwargs):
self.prompts.append(user)
return "A summary."
stub = Stub()
monkeypatch.setattr(memorybank, "summary_provider", lambda s: stub)
cursors.SUMMARY.anchor_at(adventure, ids["nodes"]["A3"])
db.commit()
asyncio.run(memorybank._update_story_summary(adventure, settings, db))
[prompt] = stub.prompts
assert "on B" in prompt and "on C" in prompt
assert "on A's own continuation" not in prompt
assert "on the shared trunk" not in prompt # behind the mark
# Caught up to the settled end of the story: C7 is retryable, C6 is not.
assert cursors.SUMMARY.stored(adventure) == (ids["c"], 6)
def test_a_block_is_summarized_from_the_path_and_hung_off_its_last_node(
forked, monkeypatch
):
db, adventure, settings, ids = forked
monkeypatch.setattr(memorybank, "MEMORY_START", 0)
monkeypatch.setattr(memorybank, "MEMORY_INTERVAL", 4)
class Stub:
def __init__(self):
self.excerpts = []
async def complete(self, system, user, **kwargs):
self.excerpts.append(user)
return f"Memory {len(self.excerpts)}."
stub = Stub()
monkeypatch.setattr(memorybank, "summary_provider", lambda s: stub)
asyncio.run(memorybank._create_due_memories(adventure, settings, db))
# Two blocks of four from a path of eight, minus the held-back newest: one.
[excerpt] = stub.excerpts
assert "A5" not in excerpt, "a sibling's narration reached the summarizer"
assert ["A0", "A1", "A2", "A3"] == [line for line in excerpt.split() if line[0] in "ABC"]
made = db.query(models.Memory).filter_by(text="Memory 1.").one()
assert (made.branch_id, made.depth) == (ids["a"], 3)
assert cursors.MEMORY.stored(adventure) == (ids["a"], 3)
# ------------------------------------------------------ the cost of forking
@pytest.fixture()
def deeply_forked():
"""A story forked twenty times, with a memory every six actions — the
density the post-turn pass actually produces."""
Base.metadata.create_all(bind=engine)
db = SessionLocal()
user = models.User(is_guest=False, email="deepmem@example.com")
db.add(user)
db.flush()
db.add(models.Settings(
user_id=user.id, api_key="enc:dummy", model="m",
embedding_model="text-embedding-3-small",
# Every candidate is injected, so the measurement covers fetching the
# texts too and not only ranking them.
memory_top_k=50,
))
def story(title, forks):
adventure = models.Adventure(
user_id=user.id, title=title, script_state={}, memory_bank_enabled=True,
)
db.add(adventure)
db.flush()
branch = make_branch(db, adventure)
depth = 0
nodes = []
for _ in range(4):
nodes.append(add_node(db, adventure, branch, depth, "n"))
depth += 1
for _ in range(forks):
branch = make_branch(db, adventure, parent=branch, fork_depth=depth - 1)
for _ in range(2):
nodes.append(add_node(db, adventure, branch, depth, "n"))
depth += 1
if forks:
branch = make_branch(db, adventure, parent=branch, fork_depth=depth - 1)
for _ in range(84 - depth):
nodes.append(add_node(db, adventure, branch, depth, "n"))
depth += 1
db.flush()
for node in nodes[5::6]: # one memory per six actions, as the pass makes them
add_memory(db, adventure, f"memory at {node.depth}", node)
adventure.head_branch_id = branch.id
adventure.head_depth = depth - 1
return adventure
forked_story = story("Forked", 20)
flat_story = story("Flat", 0)
db.commit()
try:
yield db, flat_story, forked_story
finally:
db.close()
Base.metadata.drop_all(bind=engine)
def test_retrieving_from_a_deep_fork_costs_what_a_flat_story_costs(deeply_forked):
"""The bet, in bytes. Retrieval names all twenty-two branches instead of
one — but it is fetching an id and a flag per memory, and there are the
same fourteen either way, so the clause is where the difference is and the
clause is not what crosses the wire."""
db, flat_story, forked_story = deeply_forked
settings = db.query(models.Settings).one()
flat_id, forked_id = flat_story.id, forked_story.id
db.commit()
db.expire_all()
meter = dbmeter.Meter()
meter.attach(engine)
try:
with meter.scope("flat"):
assert len(retrieved(db.get(models.Adventure, flat_id), settings)) == 14
flat_bytes = meter.scopes[-1].total.fetched
with meter.scope("forked"):
assert len(retrieved(db.get(models.Adventure, forked_id), settings)) == 14
forked_bytes = meter.scopes[-1].total.fetched
finally:
meter.detach()
# Measured 2026-08-18: 1,807 B against 1,823 B — the same fourteen rows,
# named through twenty-two branch terms instead of one.
assert flat_bytes > 0, "the meter saw nothing; it is measuring the wrong connection"
assert forked_bytes < flat_bytes * 1.5, (
f"retrieval on a 20-fork story cost {forked_bytes:,} B against the "
f"{flat_bytes:,} B a flat story of the same length cost"
)