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:
committed by
Parth
co-authored by
Claude Opus 5
parent
1d1367ce3a
commit
2d38a162d4
@@ -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
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user