Give every memory a node, and show only the ones on your path

A hand-written memory used to carry a NULL depth, described in the model as
"belongs to the adventure rather than to a path". That sounds harmless and
is not: a NULL is a coordinate no fork can cap, so a note typed on one line
followed the reader onto branches whose story it never described. It takes
the head now — the story you were reading when you wrote it — and obeys
exactly the rule a summarised memory obeys.

The unanchored escape clause in lineage.Path.clause existed for that single
case and is deleted rather than left unused. Its docstring argued that a
capped depth would drop a typed memory the moment its branch stopped being
the newest entry; anchoring answers the same worry better, because the
memory is not exempt from the path, it is on one.

The drawer now shows the path being read and nothing else, filtered by the
clause retrieval itself uses, so the bank you can see is the bank the model
can see. Nothing is stranded: a memory lives on a branch, switching to that
branch shows it, and deleting the branch deletes it. Pinning decides order,
the path decides existence.

Migration 62 lands existing NULL-depth memories at depth 0 of their branch
rather than at the tip. 0 is at or before every fork point, so every memory
stays visible from exactly the paths it is visible from today — nobody's
bank loses a row on deploy. The tip is the tidier-sounding choice and would
have emptied them out of every branch forked earlier than they were typed.

This supersedes the on_path flag and the "another branch" badge from
earlier today; anchoring makes them redundant, and they are removed.

Four tests changed because they asserted the old contract, not because
they broke. The one worth reading is the pair replacing
test_a_hand_written_memory_is_not_lost_at_the_first_fork: typed on shared
trunk it still survives a fork, and typed on ground the fork never
travelled it no longer follows you.

402 tests. Verified on tools/branch_fixture.py: each branch's drawer holds
its own memory and not the other's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015H5qiyiR7gtFQaoDphHZ3g
This commit is contained in:
parththakkar106
2026-08-18 19:14:07 +05:30
committed by Parth
co-authored by Claude Opus 5
parent 1d1367ce3a
commit 2d38a162d4
15 changed files with 208 additions and 152 deletions
+9 -13
View File
@@ -87,7 +87,6 @@ class Path:
self,
model=models.Action,
count: int | None = None,
unanchored: bool = False,
):
"""The branch clause, over `model` (`Action` or `Memory`).
@@ -95,12 +94,12 @@ class Path:
read. `None` is the whole lineage, which is what anything counting from
the *oldest* end (a slice, a total) has to use.
`unanchored` keeps rows with no depth. Only memories ever have one: a
hand-written memory summarises no node, so it has a branch but no
depth, and a capped `depth <= n` would drop it the moment its branch
stopped being the newest entry — a memory vanishing at the first fork
after it was typed. An action with no depth is a pre-tree row that no
read should see, so actions never pass this.
Every row this reads has a depth. Memories used to be the exception —
a hand-written one had a branch and no depth, and needed an escape
clause here to survive being capped at a fork. SP7 anchors them at the
head instead (`tree.place_memory`), which is a better answer to the same
problem: the memory is not exempt from the path, it is *on* one. A row
with no depth is now a pre-tree leftover that no read should see.
Actions also have to be *live* (SP4). A coordinate can hold several
attempts at the same turn, and the story tells one of them; the losing
@@ -115,19 +114,16 @@ class Path:
entries = self.entries if count is None else self.entries[:count]
if not entries:
return false()
on_path = or_(*[self._entry_clause(model, b, d, unanchored) for b, d in entries])
on_path = or_(*[self._entry_clause(model, b, d) for b, d in entries])
if model is models.Action:
return and_(on_path, models.Action.live.is_(True))
return on_path
@staticmethod
def _entry_clause(model, branch_id: int, max_depth: int | None, unanchored=False):
def _entry_clause(model, branch_id: int, max_depth: int | None):
if max_depth is None:
return model.branch_id == branch_id
within = model.depth <= max_depth
if unanchored:
within = or_(within, model.depth.is_(None))
return and_(model.branch_id == branch_id, within)
return and_(model.branch_id == branch_id, model.depth <= max_depth)
# ------------------------------------------------------------- Python
+1 -1
View File
@@ -229,7 +229,7 @@ async def retrieve_memories(
catalogue = db.execute(
select(models.Memory.id, models.Memory.pinned).where(
models.Memory.adventure_id == adventure.id,
lineage.path_of(db, adventure).clause(models.Memory, unanchored=True),
lineage.path_of(db, adventure).clause(models.Memory),
models.Memory.forgotten.is_(False),
models.Memory.embedded.is_(True),
)
+9
View File
@@ -257,6 +257,15 @@ MIGRATIONS: list[tuple[int, str | dict[str, str]]] = [
# than one per turn, so unlike SP1's and SP4's this rewrite is a few hundred
# rows against a few hundred thousand and needs no VACUUM FULL of its own.
(61, "ALTER TABLE branches ADD COLUMN name VARCHAR(80)"),
# Phase 14, SP7 — every memory gets a node. A hand-written memory used to
# keep a NULL depth, which no fork could cap, so it followed the reader onto
# branches whose story it never described. New ones anchor at the head; the
# ones already written land at **depth 0 of the branch they are on**, which
# is the only choice that takes nothing away from anybody: 0 is at or before
# every fork point, so a memory stays visible from exactly the paths it is
# visible from today. Anchoring them at the tip instead would have emptied
# them out of every branch forked earlier than they were typed.
(62, "UPDATE memories SET depth = 0 WHERE depth IS NULL"),
]
LATEST_VERSION = max((v for v, _ in MIGRATIONS), default=1)
+4 -2
View File
@@ -261,8 +261,10 @@ class Memory(Base):
# automatically, and a memory covering a stretch of branch B is invisible
# from any path that does not go through B.
#
# `depth` is NULL for a hand-written memory, which no node produced; that
# reads as "belongs to the adventure, not to a path".
# Every memory has one, including a hand-written one: it takes the head at
# the moment it was written (SP7). A NULL depth used to mean "belongs to the
# adventure, not to a path", which is a category no fork could cap — the
# memory followed the reader onto branches whose story it never described.
branch_id: Mapped[int | None] = mapped_column(
ForeignKey("branches.id", ondelete="CASCADE"), nullable=True
)
+13 -26
View File
@@ -4,7 +4,7 @@ import threading
from fastapi import APIRouter, Body, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse
from sqlalchemy import func, select
from sqlalchemy import func
from sqlalchemy.orm import Session, load_only, undefer
from sqlalchemy.orm.attributes import set_committed_value
@@ -1881,36 +1881,23 @@ def list_memories(
# model happens to carry. `embedding_blob` is deferred and so would stay
# out today — this is about the next wide column, not that one.
#
# Adventure-wide, not path-scoped, and that is the split: retrieval reads
# the story being played, the drawer manages the bank. Hiding a branch's
# memories from the drawer would mean memories nobody can find to delete,
# in a phase whose rule is that nothing is ever removed automatically.
rows = (
# **The bank you can see is the bank the model can see.** Filtered by the
# same clause retrieval uses, so the drawer answers one question rather than
# two: an adventure-wide list would show memories from branches this story
# never went down, which are never retrieved, and a reader has no way to
# tell those apart from the ones actually in play. Nothing is stranded by
# this — a memory lives on a branch, so switching to that branch shows it,
# and deleting the branch takes its memories with it.
return (
db.query(models.Memory)
.options(load_only(*MEMORY_LIST_COLUMNS))
.filter(models.Memory.adventure_id == adventure_id)
.filter(
models.Memory.adventure_id == adventure_id,
lineage.path_of(db, adventure).clause(models.Memory),
)
.order_by(models.Memory.id)
.all()
)
# ...but listing them alike would be its own lie. A memory on a branch this
# story never travelled is never retrieved, so showing it beside one that is
# tells the player the model remembers something it cannot see. The ids come
# from **the predicate retrieval itself uses**, deliberately: two spellings
# of "on this path" would eventually disagree, and the failure would be a
# badge that says the opposite of what the model gets.
on_path = {
row[0] for row in db.execute(
select(models.Memory.id).where(
models.Memory.adventure_id == adventure_id,
lineage.path_of(db, adventure).clause(
models.Memory, unanchored=True
),
)
)
}
for row in rows:
row.on_path = row.id in on_path
return rows
@router.post("/{adventure_id}/memories", response_model=schemas.MemoryOut, status_code=201)
-6
View File
@@ -289,12 +289,6 @@ class MemoryOut(ORMModel):
last_used_at: datetime | None
source_start: int | None
source_end: int | None
# Whether this memory is on the story currently being read (Phase 14, SP7).
# The drawer lists the whole bank so nothing becomes impossible to find and
# delete, but a memory belonging to another branch will never be retrieved
# into context — and a list that showed the two alike would be telling the
# player the model knows something it cannot see.
on_path: bool = True
created_at: datetime
+16 -5
View File
@@ -208,16 +208,27 @@ def place_memory(
memory: models.Memory,
branch: models.Branch | None = None,
) -> models.Branch:
"""Attach a memory to the node that produced it.
"""Attach a memory to the node it belongs to.
`source_end` is the index of the last action the memory summarises, which is
that node's depth. A hand-written memory summarises nothing, so its depth
stays NULL and it belongs to the adventure rather than to a path.
that node's depth. A hand-written memory summarises nothing, so it takes the
head instead: **the story you were reading when you wrote it.**
That anchor is what makes a memory mean one thing (SP7). Before it, a
hand-written memory kept a NULL depth and "belonged to the adventure rather
than to a path" — which sounded harmless and meant it followed you onto
branches whose story it did not describe, because a NULL cannot be capped at
a fork. Every memory now sits at a coordinate, so "is this part of the story
I am reading?" has one answer for every row in the bank, and it is the same
answer the lineage already gives for nodes.
"""
branch = branch or head_branch(db, adventure)
memory.branch_id = branch.id
if memory.depth is None and memory.source_end is not None:
memory.depth = memory.source_end
if memory.depth is None:
memory.depth = (
memory.source_end if memory.source_end is not None
else adventure.head_depth
)
return branch
+1 -1
View File
@@ -443,7 +443,7 @@ def test_a_memory_on_the_line_left_behind_is_out_of_range_on_the_fork(client):
path = lineage.path_of(db, adventure)
visible = db.query(models.Memory).filter(
models.Memory.adventure_id == adventure.id,
path.clause(models.Memory, unanchored=True),
path.clause(models.Memory),
).all()
assert visible == [], "a sibling's memory reached this branch"
# ...and the mark reads as one depth short of it, so the block is due
+53 -31
View File
@@ -361,38 +361,50 @@ def _add_memory(client, text):
return r.json()["id"]
def test_the_drawer_keeps_every_memory_but_says_which_are_off_the_path(client):
"""Both halves of the split, in one test, because either alone is a bug.
def test_a_hand_written_memory_is_anchored_where_it_was_written(client):
"""It takes the head, so it is a memory *of a story* rather than of an
adventure. A NULL depth is a coordinate no fork can cap."""
root, forked = _forked(client)
memory_id = _add_memory(client, "Took the other door.")
Listing only the path's memories would leave the rest impossible to find
and delete, in a phase whose rule is that nothing is removed automatically.
Listing them all *alike* would tell the player the model remembers
something that is never retrieved on this branch.
db = SessionLocal()
try:
memory = db.get(models.Memory, memory_id)
adventure = db.get(models.Adventure, client.adv_id)
assert memory.branch_id == forked, "the branch being read"
assert memory.depth is not None, "never NULL again"
assert memory.depth == adventure.head_depth
finally:
db.close()
def test_the_drawer_shows_the_path_being_read_and_nothing_else(client):
"""The bank you can see is the bank the model can see.
An adventure-wide list would show memories from branches this story never
went down — which are never retrieved — and a reader cannot tell those from
the ones actually in play.
"""
root, forked = _forked(client)
on_the_fork = _add_memory(client, "Took the other door.")
_switch(client, root)
on_the_root = _add_memory(client, "Went the long way instead.")
listed = {m["id"]: m for m in _memories(client)}
assert set(listed) == {on_the_fork, on_the_root}, "the whole bank, always"
assert listed[on_the_root]["on_path"] is True
assert listed[on_the_fork]["on_path"] is False, "written on a branch we left"
assert {m["id"] for m in _memories(client)} == {on_the_root}, \
"the fork's memory is not on this story"
# And it follows the reader rather than being a property of the memory —
# but **asymmetrically**, which is the part worth pinning. A fork borrows
# its parent's story, so a memory written on the parent is on the fork's
# path too. The reverse is not true: the parent never went down the fork.
# Switching to the fork shows its own memory — and the root's, because a
# fork borrows its ancestors up to the point it left them. The relationship
# is asymmetric on purpose; the parent never went down the fork.
_switch(client, forked)
listed = {m["id"]: m for m in _memories(client)}
assert listed[on_the_fork]["on_path"] is True
assert listed[on_the_root]["on_path"] is True, "an ancestor's memory is shared"
listed = {m["id"] for m in _memories(client)}
assert on_the_fork in listed
assert on_the_root not in listed, "written after the fork left this branch"
def test_the_off_path_flag_agrees_with_what_retrieval_can_see(client):
"""The flag has to come from retrieval's own predicate, not a second
spelling of it. Two spellings would drift, and the failure mode is a badge
claiming the opposite of what the model is actually given."""
def test_the_drawer_and_retrieval_agree_on_what_is_visible(client):
"""One predicate, so a memory can never be listed but unretrievable (or the
reverse). Two spellings of "on this path" would eventually drift."""
root, forked = _forked(client)
_add_memory(client, "Took the other door.")
_switch(client, root)
@@ -401,32 +413,42 @@ def test_the_off_path_flag_agrees_with_what_retrieval_can_see(client):
db = SessionLocal()
try:
adventure = db.get(models.Adventure, client.adv_id)
visible = {
retrievable = {
row[0] for row in db.execute(
select(models.Memory.id).where(
models.Memory.adventure_id == adventure.id,
lineage.path_of(db, adventure).clause(
models.Memory, unanchored=True
),
lineage.path_of(db, adventure).clause(models.Memory),
)
)
}
finally:
db.close()
flagged = {m["id"] for m in _memories(client) if m["on_path"]}
assert flagged == visible
assert {m["id"] for m in _memories(client)} == retrievable
def test_a_memory_from_a_deleted_branch_is_gone_from_the_drawer(client):
"""Not merely off-path — the row goes with the branch, through the cascade."""
def test_deleting_a_branch_deletes_the_memories_written_on_it(client):
"""Not merely out of view — the row goes with the branch, through the
cascade. That is what keeps "the drawer shows only your path" from
stranding anything: a memory you cannot see is on a branch you can still
switch to, and deleting that branch takes it for good."""
root, forked = _forked(client)
doomed = _add_memory(client, "Took the other door.")
_switch(client, root)
assert doomed in {m["id"] for m in _memories(client)}
db = SessionLocal()
try:
assert db.get(models.Memory, doomed) is not None, "still on its own branch"
finally:
db.close()
assert _delete(client, forked).status_code == 204
assert doomed not in {m["id"] for m in _memories(client)}
db = SessionLocal()
try:
assert db.get(models.Memory, doomed) is None, "gone with the branch"
finally:
db.close()
# ------------------------------------------------------------------- backup
+40 -8
View File
@@ -212,18 +212,50 @@ def test_the_lineage_is_read_whole_not_windowed(forked):
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."""
def test_a_hand_written_memory_is_anchored_where_it_was_typed(forked):
"""SP7: a typed memory takes the head, so it obeys the same rule as a
summarised one.
It used to carry no depth, which sounded like "belongs to the whole
adventure" and behaved like "cannot be capped at a fork" — it followed the
reader onto branches whose story it never described. Anchoring it makes the
bank answer one question rather than two.
"""
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)
assert (typed.branch_id, typed.depth) == (ids["a"], 5), "the head it was typed at"
switch_to(db, adventure, ids["c"], 7) # fork away from where it was written
assert "typed by hand" in retrieved(adventure, settings)
def test_a_typed_memory_survives_a_fork_of_the_ground_it_was_typed_on(forked):
"""The half of the old behaviour that was right, kept.
Typed on the shared trunk it is still there after forking away — but
because the fork's path goes through that node, not because the memory was
exempt from being capped.
"""
db, adventure, settings, ids = forked
switch_to(db, adventure, ids["a"], 3) # the trunk B, and so C, branch from
add_memory(db, adventure, "typed on the trunk", None)
switch_to(db, adventure, ids["c"], 7)
assert "typed on the trunk" in retrieved(adventure, settings)
def test_a_typed_memory_does_not_follow_you_onto_a_path_it_is_not_on(forked):
"""And the half that was wrong, fixed.
A5 is A's own continuation past the point B left it, so it is a sibling of
the story C tells — precisely where the `sibling` memory sits, and excluded
for precisely the same reason. Typing rather than summarising buys no
exemption from the path.
"""
db, adventure, settings, ids = forked
switch_to(db, adventure, ids["a"], 5)
add_memory(db, adventure, "typed off the path", None)
switch_to(db, adventure, ids["c"], 7)
assert "typed off the path" not in retrieved(adventure, settings)
# ------------------------------------------------------------------ the marks
+14 -5
View File
@@ -276,10 +276,12 @@ def test_memories_attach_to_the_node_they_summarised(pre_tree):
assert depth == source_end, "the memory hangs off the last action it covered"
assert branch_id is not None
# A hand-written memory has no node: it gets a branch, but no depth, which
# SP3 reads as belonging to the adventure rather than to a path.
# A hand-written memory summarised no node, so SP7's migration 62 lands it
# at depth 0 of its branch. 0 is at or before every fork point, so it stays
# visible from exactly the paths it was visible from before — anchoring
# takes nothing out of anybody's existing bank.
manual = rows("SELECT depth, branch_id FROM memories WHERE source_end IS NULL")
assert manual and all(depth is None and branch is not None for depth, branch in manual)
assert manual and all(depth == 0 and branch is not None for depth, branch in manual)
def test_the_cursors_become_the_nodes_they_named(pre_tree):
@@ -439,7 +441,13 @@ def test_a_blank_adventure_has_a_branch_before_anything_is_played(client):
db.close()
def test_a_hand_written_memory_gets_a_branch_but_no_depth(client):
def test_a_hand_written_memory_is_anchored_at_the_head(client):
"""SP7: nothing carries a NULL depth any more.
On an adventure with no story yet the head is NO_DEPTH (-1), which reads as
"before the first node" and so is in range of every branch — right for a
note written before anything has happened.
"""
adventure_id = client.post("/api/adventures", json={}).json()["id"]
created = client.post(
@@ -450,8 +458,9 @@ def test_a_hand_written_memory_gets_a_branch_but_no_depth(client):
db = SessionLocal()
try:
memory = db.query(models.Memory).filter_by(adventure_id=adventure_id).one()
adventure = db.get(models.Adventure, adventure_id)
assert memory.branch_id is not None
assert memory.depth is None
assert memory.depth == adventure.head_depth == -1
finally:
db.close()
-11
View File
@@ -1362,17 +1362,6 @@ button:disabled { opacity: 0.45; cursor: default; transform: none; box-shadow: n
border-radius: 999px;
padding: 0 8px;
}
/* A memory belonging to another branch. Set back rather than hidden: it is
still findable and still deletable, but it is never sent to the AI while
this branch is being read, and a row that looked identical to a live one
would be claiming the opposite. The dashed edge is the tell that carries
even when the badge scrolls out of view. */
.memory-row.off-path {
opacity: 0.6;
border-style: dashed;
background: transparent;
}
.memory-badge.off-path { color: var(--text-dim); }
.script-report { margin-top: 14px; border-top: 1px solid var(--border); padding-top: 8px; }
.script-error { color: var(--danger); font-size: 0.85rem; margin-bottom: 4px; white-space: pre-wrap; }
+1 -13
View File
@@ -337,8 +337,7 @@ function MemoryRow({ memory, onChange, onDelete }) {
}
return (
<div className={`memory-row ${memory.forgotten ? 'forgotten' : ''}`
+ (memory.on_path === false ? ' off-path' : '')}>
<div className={`memory-row ${memory.forgotten ? 'forgotten' : ''}`}>
{editText !== null ? (
<div className="action-edit" style={{ margin: 0 }}>
<AutoTextarea autoFocus value={editText} rows={3}
@@ -354,17 +353,6 @@ function MemoryRow({ memory, onChange, onDelete }) {
<div className="memory-meta">
{memory.pinned && <span className="memory-badge">📌 pinned</span>}
{memory.forgotten && <span className="memory-badge">forgotten</span>}
{/* Kept in the list so it stays possible to find and delete, but
said plainly: this one belongs to a branch the story being read
never went down, and it is never retrieved. Pinning does not
override it — the path clause runs before pinning is
considered. */}
{memory.on_path === false && (
<span className="memory-badge off-path"
title="On another branch — this is never sent to the AI while you are reading this one">
another branch
</span>
)}
{!memory.embedded && !memory.forgotten && (
<span className="memory-badge" title="Embedded on the next turn">not embedded yet</span>
)}
+28 -21
View File
@@ -769,29 +769,36 @@ live, redraws the bar, swaps the story to the other take (`hp -5`, not `hp -40`)
repoints Insights at the other path — "History: 5 of 5 actions", carrying the scratch and
not the beating.
**The memory drawer says which memories the model can actually see.** Retrieval has been
path-scoped since SP3, so a memory on a branch this story never travelled is never sent.
The drawer still lists the whole bank — hiding rows would leave memories impossible to
find and delete, in a phase whose rule is that nothing is removed automatically — but
listing them *alike* was its own lie: it told the player the model remembers something it
cannot see. `MemoryOut.on_path` marks them, and the row is set back and labelled *another
branch*. The flag is computed from **the predicate retrieval itself uses**
(`path_of(...).clause(Memory, unanchored=True)`), not a second spelling of it, because two
spellings drift and the failure would be a badge claiming the opposite of what the model
gets. Pinning does not override it — the path clause runs before pinning is considered.
**Every memory is now a memory of a story, not of an adventure — migration 62.** A
hand-written memory used to carry a NULL depth, described as "belongs to the adventure
rather than to a path". That reads as harmless and is not: a NULL is a coordinate no fork
can cap, so a note typed on one line followed the reader onto branches whose events it
never described. `tree.place_memory` anchors it at the head instead — *the story you were
reading when you wrote it* — and it then obeys exactly the rule a summarised memory obeys.
**The relationship is asymmetric, and a test now says so.** A fork borrows its ancestors,
so a memory written on the parent is on the fork's path too; the reverse is never true.
Worth knowing before anyone "fixes" it into a symmetric check.
The whole `unanchored` escape clause in `lineage.Path.clause` existed for that one case
and is **deleted**, not merely unused. Its docstring argued a capped `depth <= fork` would
drop a typed memory "the moment its branch stopped being the newest entry"; anchoring is
the better answer to the same worry, because the memory is not exempt from the path, it is
*on* one.
**One known sharp edge, inherited rather than introduced.** A *hand-written* memory has a
branch but no depth, and `unanchored=True` keeps it whatever the lineage cap says — so
one typed on the parent follows you onto a fork whose events it may not describe. That is
SP3's deliberate choice (`test_a_hand_written_memory_is_not_lost_at_the_first_fork`): the
alternative is a memory vanishing at the first fork after it was typed. Auto-summarized
memories carry a depth and *are* capped at the fork, so they behave as expected. If this
ever bites, the fix is to anchor a hand-written memory at the head depth when it is
created rather than to change the clause.
**The drawer shows the path being read, and nothing else.** Same clause as retrieval, so
the bank you can see is the bank the model can see — one question, one answer. The earlier
attempt at this shipped an adventure-wide list with an `on_path` flag and an *another
branch* badge; anchoring makes that redundant, and the field, the badge and its CSS are
gone. Nothing is stranded by hiding: a memory lives on a branch, switching to that branch
shows it, and deleting the branch deletes it
(`test_deleting_a_branch_deletes_the_memories_written_on_it`).
Pinning is unchanged and still path-scoped: it decides *order*, the path decides
*existence*. A pinned memory on a branch you are not reading is not sent, because the path
clause runs before pinning is considered.
**Migration 62 anchors existing NULL-depth memories at depth 0 of their branch**, not at
the tip. 0 is at or before every fork point, so every memory stays visible from exactly
the paths it is visible from today — the anchor takes nothing out of anybody's bank on
deploy. Anchoring at the tip would have emptied them out of every branch forked earlier
than they were typed, on a database with real users on it.
**The scroll path was driven, and it holds.** Three prepends on the 602-action fixture,
60 actions and ~16,200 px each. The same DOM node stayed at viewport top 792 → 787 — a
+19 -9
View File
@@ -166,15 +166,25 @@ That is now four bugs on this frontend found by exercising it rather than by tes
two of them in paths that had just shipped. The pattern is not subtle any more: **this
frontend has no test runner, so anything not driven by hand is unverified.**
**The memory drawer now says what the model can see.** Retrieval has been path-scoped
since SP3, but the drawer listed every memory alike — so on a fork you read "Fell down
the cellar stairs" and reasonably concluded the AI knew it, when that memory is never
retrieved on that branch. Rows off the current path are set back and labelled *another
branch*, still fully editable and deletable. The flag comes from the predicate retrieval
itself uses, so the badge cannot drift from the behaviour. Note the asymmetry before
changing it: a fork borrows its ancestors, so a parent's memory *is* on the fork's path;
the reverse never is. And a hand-written memory has no depth, so it survives the fork cap
by design — see SP7's entry in `plan/14` for the one case where that surprises.
**A memory is now attached to a node, always — migration 62.** Hand-written ones used to
carry a NULL depth ("belongs to the adventure, not to a path"), which is a coordinate no
fork can cap, so a note typed on one line followed you onto branches whose story it never
described. They anchor at the head now, and the `unanchored` escape clause in
`lineage.Path.clause` is deleted rather than left to rot.
**And the drawer shows only the path being read**, filtered by the same clause retrieval
uses: the bank you can see is the bank the model can see. Nothing is stranded — a memory
lives on a branch, switching to it shows the memory, and deleting the branch deletes it.
Pinning decides *order*, the path decides *existence*.
Migration 62 lands existing NULL-depth memories at **depth 0 of their branch**, which is
at or before every fork point, so nobody's bank loses a row on deploy. The tip would have
been the tidier-sounding choice and would have emptied them out of every branch forked
earlier than they were typed.
An earlier pass this session shipped the other design — adventure-wide list, `on_path`
flag, *another branch* badge. Anchoring made it redundant and it was removed. Worth
knowing if the phrase turns up in an older commit message.
**The scroll path is finally driven.** 602-action fixture, three prepends of ~16,200 px
each: the same DOM node held viewport top 792 → 787, and the view stayed 48,174 px from