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
This commit is contained in:
parththakkar106
2026-08-18 19:14:07 +05:30
committed by Parth
co-authored by Claude Opus 5
parent c7b6a46a8a
commit c51531709d
16 changed files with 1334 additions and 260 deletions
+146
View File
@@ -0,0 +1,146 @@
"""Phase 14 — how far along a story the derived work has got.
Two things are built from the story and stored beside it: the memories, and the
Story Summary. Both need to know where they left off, and that mark used to be
a *count* — "the first 12 story actions are covered". A count is a position in
a list, and this list moves: delete an action from in front of the mark and
every later action slides down a slot, so the mark now covers one it has never
seen. Every rule in `memorybank` about sliding cursors, rewinding them and
translating between positions and `Action.index` existed to patch that up, and
each was a separate chance to get it wrong in a way nothing reports.
A cursor here is an **anchor**: `(branch_id, depth)`, the node up to and
including which the work is done. Deleting an action does not move it, because
a depth is not a position — it is a coordinate along a path. "What is not
covered yet" becomes `history.count_after(anchor)`, which is a question about
the story rather than about a list index, and it answers correctly whatever has
been deleted from in front of it.
The branch half is what makes it survive forking. A depth alone is ambiguous
once two branches have a node 41; the anchor says which one, and
`Path.depth_on` reads it back as a depth on whatever story is being played —
capped at the fork, or "nothing covered" if the anchor sits on ground this path
never travelled. Until forking ships there is one branch and that is always a
no-op, which is the point: the coordinate system is right before anything needs
it to be.
`NO_DEPTH` (-1) is "nothing covered", so a fresh adventure needs no special
case: every node is deeper than -1.
"""
from sqlalchemy.orm import Session
from .. import models
from . import history, lineage
NO_DEPTH = lineage.NO_DEPTH
class Cursor:
"""One anchor on the adventure row: the memory bank's, or the summary's.
A pair of columns rather than a foreign key to the node. The node can be
deleted — that is most of what undo does — and the boundary is still
meaningful afterwards, so a pointer that has to resolve would be a pointer
that keeps not resolving.
"""
def __init__(self, name: str):
self.name = name
self.branch_field = f"{name}_cursor_branch_id"
self.depth_field = f"{name}_cursor_depth"
# ------------------------------------------------------------- reading
def stored(self, adventure: models.Adventure) -> tuple[int | None, int]:
"""The anchor exactly as written, unread by any path."""
depth = getattr(adventure, self.depth_field)
return getattr(adventure, self.branch_field), (
NO_DEPTH if depth is None else depth
)
def depth(self, db: Session, adventure: models.Adventure) -> int:
"""The anchor as a depth on the story currently being played."""
branch_id, depth = self.stored(adventure)
return lineage.path_of(db, adventure).depth_on(branch_id, depth)
# ------------------------------------------------------------- writing
def anchor_at(self, adventure: models.Adventure, node: models.Action) -> None:
"""Mark the work done up to and including `node`.
Takes the node's own branch, not the adventure's head: a block of six
actions can end before the fork this branch was made at, and the
coverage belongs where the ground is.
"""
setattr(adventure, self.branch_field, node.branch_id)
setattr(adventure, self.depth_field, lineage.NO_DEPTH
if node.depth is None else node.depth)
def rewind_to(
self, adventure: models.Adventure, branch_id: int | None, depth: int
) -> None:
"""Move the anchor back to `depth` if it is past it; never forward.
The one direction that is safe without knowing what else has happened:
re-covering ground costs a summarizer call, skipping it loses a stretch
of story out of the memories for good.
"""
_, current = self.stored(adventure)
if current <= depth:
return
setattr(adventure, self.branch_field, branch_id)
setattr(adventure, self.depth_field, max(depth, NO_DEPTH))
MEMORY = Cursor("memory")
SUMMARY = Cursor("summary")
ALL = (MEMORY, SUMMARY)
def rewind_all(
adventure: models.Adventure, branch_id: int | None, depth: int
) -> None:
"""Hand a stretch of story back to *both* passes.
They move together because they cover the same ground from different sides:
the summary folds in the memories, so a memory withdrawn without rewinding
the summary leaves the summary claiming to have read something no longer
there.
"""
for cursor in ALL:
cursor.rewind_to(adventure, branch_id, depth)
def anchor_at_position(
adventure: models.Adventure, cursor: Cursor, position: int
) -> None:
"""Set `cursor` from a count of covered story actions — a v1 bundle's mark,
or a database written before the anchors existed.
The position-th story action in depth order is the node that says the same
thing, and goes on saying it once something in front of it is deleted. A
position past the end of the story is not a bad value: an adventure caught
up under the older rule can carry one, and it means the same thing the tip
does, so that is where it lands.
The SQL half of this rule is `migrations._backfill_cursor_anchors`, which
has to do it for every adventure at once without loading any of them; the
two must agree.
"""
if position <= 0:
return
covered = history.slice_(adventure, position - 1, 1) or history.tail(adventure, 1)
if covered:
cursor.anchor_at(adventure, covered[0])
def position_of(adventure: models.Adventure, depth: int) -> int:
"""How many story actions lie at or before `depth` — an anchor read back as
a count.
The v1 export bundle stores the cursors as positions, and a v1 bundle is
read by builds that have never heard of a depth. This is the one place that
still speaks that coordinate system, and SP6's v2 format retires it.
"""
return max(history.count(adventure) - history.count_after(adventure, depth), 0)
+80 -17
View File
@@ -14,10 +14,9 @@ story.
Three rules hold everything together:
* **One definition of "story action".** The cursors in memorybank are
*positions* in this filtered, depth-ordered list, so SQL and Python must
agree on membership exactly or a cursor silently points at a different
action. `_STORY_TEXT` and `is_story_text()` are that one definition, written
* **One definition of "story action".** Membership decides what a reader sees
and what the summarizer is handed, so SQL and Python must agree on it
exactly. `_STORY_TEXT` and `is_story_text()` are that one definition, written
twice; keep them in step.
* **Never load twice.** If `adventure.actions` is already in memory (the
scripting pipeline hands the whole history to user scripts, as AI Dungeon
@@ -32,6 +31,12 @@ Three rules hold everything together:
Ordering is by `depth` now, not `index`. The two hold the same numbers until
retry stops mutating rows (SP4), but only one of them is a position along a
path.
SP3 added the reads that count *from a node* rather than from the start —
`count_after`, `after`, `newest_settled`. The memory bank used to ask for
"positions 12 to 18 of the story", which is a question whose answer moves when
an action is deleted from in front of it. It now asks for "the six actions
after depth 41", which is the same question a fork has to answer anyway.
"""
from sqlalchemy import func, inspect as sa_inspect
@@ -162,6 +167,7 @@ def _count_query(
adventure: models.Adventure,
path: lineage.Path,
exclude_action_id: int | None,
entries: int | None = None,
):
"""A real `SELECT count(...)`.
@@ -172,7 +178,7 @@ def _count_query(
that greps the SQL cannot tell the two apart.
"""
return db.query(func.count(models.Action.id)).filter(
*_filters(adventure, path, exclude_action_id)
*_filters(adventure, path, exclude_action_id, entries)
)
@@ -311,30 +317,87 @@ def slice_(
)
def position_of_index(adventure: models.Adventure, index: int) -> int:
"""The position the story action with `Action.index == index` occupies —
i.e. how many story actions come before it.
def depth_of(action: models.Action) -> int:
"""`action.depth`, with the no-depth case spelled once.
Translates between the two coordinate systems that keep tripping this code
up: cursors are positions, `Memory.source_start/_end` are `Action.index`
values, and the two diverge the moment anything is deleted.
A row with no depth is a pre-tree row, which no path contains — so it can
only turn up in an already-loaded collection, and it sorts before the story
rather than after it.
"""
in_memory = _from_memory(adventure, None)
return action.depth if action.depth is not None else lineage.NO_DEPTH
def count_after(
adventure: models.Adventure, depth: int, exclude_action_id: int | None = None
) -> int:
"""How many story actions lie past `depth` on the path.
The node-anchored replacement for "the story is N long and the cursor is at
M". Deleting an action from in front of the boundary makes this number
smaller, which is true; it does not make the boundary point somewhere else,
which is the bug the positions had.
`covering_after` says exactly which lineage entries can hold a node deeper
than the boundary, so a cursor near the tip names one branch however many
forks are below it.
"""
in_memory = _from_memory(adventure, exclude_action_id)
if in_memory is not None:
return next(
(i for i, a in enumerate(in_memory) if a.index >= index), len(in_memory)
)
return sum(1 for a in in_memory if depth_of(a) > depth)
db = _session(adventure)
if db is None:
return 0
path = _path(db, adventure)
return (
_count_query(db, adventure, _path(db, adventure), None)
.filter(models.Action.index < index)
_count_query(
db, adventure, path, exclude_action_id, path.covering_after(depth)
)
.filter(models.Action.depth > depth)
.scalar()
or 0
)
def after(
adventure: models.Adventure,
depth: int,
limit: int,
exclude_action_id: int | None = None,
) -> list[models.Action]:
"""The oldest `limit` story actions past `depth`, oldest first.
"The next block the summarizer has not seen", asked as a fact about the
story rather than as an offset into a list that shifts underneath it.
"""
if limit <= 0:
return []
in_memory = _from_memory(adventure, exclude_action_id)
if in_memory is not None:
return [a for a in in_memory if depth_of(a) > depth][:limit]
db = _session(adventure)
if db is None:
return []
path = _path(db, adventure)
return (
_query(db, adventure, path, exclude_action_id, path.covering_after(depth))
.filter(models.Action.depth > depth)
.order_by(*_OLDEST_FIRST)
.limit(limit)
.all()
)
def newest_settled(adventure: models.Adventure) -> models.Action | None:
"""The newest story action that is not the newest one — see
`memorybank.settled_story_actions` for why one is always held back.
Two rows, not a count and an offset: this is the node an anchor moves to
when derived work catches up with the settled end of the story.
"""
rows = tail(adventure, 2)
return rows[0] if len(rows) == 2 else None
def max_action_index(adventure: models.Adventure) -> int:
"""Highest `Action.index` in the adventure, story text or not. -1 if empty.
+63 -4
View File
@@ -83,13 +83,25 @@ class Path:
# ---------------------------------------------------------------- SQL
def clause(self, model=models.Action, count: int | None = None):
def clause(
self,
model=models.Action,
count: int | None = None,
unanchored: bool = False,
):
"""The branch clause, over `model` (`Action` or `Memory`).
`count` limits it to the newest `count` lineage entries — the windowed
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.
An empty path yields `false`, not "no filter": an adventure whose nodes
carry no branch has no story, and the loud version of that is an empty
page, not every branch at once.
@@ -97,13 +109,16 @@ class Path:
entries = self.entries if count is None else self.entries[:count]
if not entries:
return false()
return or_(*[self._entry_clause(model, b, d) for b, d in entries])
return or_(*[self._entry_clause(model, b, d, unanchored) for b, d in entries])
@staticmethod
def _entry_clause(model, branch_id: int, max_depth: int | None):
def _entry_clause(model, branch_id: int, max_depth: int | None, unanchored=False):
if max_depth is None:
return model.branch_id == branch_id
return and_(model.branch_id == branch_id, model.depth <= max_depth)
within = model.depth <= max_depth
if unanchored:
within = or_(within, model.depth.is_(None))
return and_(model.branch_id == branch_id, within)
# ------------------------------------------------------------- Python
@@ -158,6 +173,50 @@ class Path:
return i + 1
return total
def covering_after(self, depth: int) -> int:
"""How many lineage entries can hold a node deeper than `depth`.
The counterpart to `prefix_covering`, and unlike it this is exact
rather than an estimate: entry *i* holds nothing deeper than its own
cap, and the caps descend, so the first entry capped at or below
`depth` ends the search — it and everything older is behind the
boundary. Reading "the story after the cursor" therefore names one
branch on any story whose cursor is on its newest branch, however
often it has forked.
"""
for i, (_, max_depth) in enumerate(self.entries):
if max_depth is not None and max_depth <= depth:
return i
return len(self.entries)
def depth_on(self, branch_id: int | None, depth: int) -> int:
"""A stored `(branch_id, depth)` anchor, read as a depth on *this* path.
An anchor is how far along a story some derived work has got — which
memories cover, what the summary has folded in. It names a node, so
moving to another path has to be answered rather than assumed:
* the anchor's branch is on this path — the depth stands, capped at the
fork the path takes off that branch, because nothing past the fork is
on this story;
* the branch is not on this path at all — the work was done on ground
this story never travelled, so nothing here is covered.
The second case cannot arise while an adventure has one branch: the
anchor is always set from a node on it. It exists because the fallback
for "I don't know" must be to redo the work, not to skip it.
"""
if depth <= NO_DEPTH:
return NO_DEPTH
if branch_id is None:
# A pre-tree anchor, or one set by hand. There is one story, so the
# depth is a position in it and means what it says.
return depth
for entry_branch, max_depth in self.entries:
if entry_branch == branch_id:
return depth if max_depth is None else min(depth, max_depth)
return NO_DEPTH
def branch_of(db: Session, adventure: models.Adventure) -> models.Branch | None:
"""The branch this adventure is being read at, or None if it has none.
+113 -108
View File
@@ -27,7 +27,7 @@ from sqlalchemy import func, select, update
from sqlalchemy.orm import Session, object_session
from . import models, tree, vectors
from .context import history, story_actions, truncate_to_last_tokens
from .context import cursors, history, lineage, story_actions, truncate_to_last_tokens
from .database import SessionLocal
from .providers import OpenAICompatibleProvider, ProviderError
from .vectors import cosine # re-exported: the ranking lives here, the maths there
@@ -165,20 +165,23 @@ def settled_count(adventure: models.Adventure) -> int:
return max(history.count(adventure) - 1, 0)
def settled_slice(adventure: models.Adventure, start: int, length: int) -> list[models.Action]:
"""Settled story actions at positions [start, start + length).
def settled_after(adventure: models.Adventure, depth: int) -> int:
"""How many settled story actions lie past `depth`.
Callers must already have checked against `settled_count()`; this only
fetches, it does not re-clamp.
"How much story this pass has not read yet". The newest action is never
settled, so it is the one subtracted — and a cursor sitting at or past the
tip (undo moved the story back behind it) comes out at zero or below and
simply does no work, which is what the position cursors needed a clamp
every post-turn pass to achieve.
"""
return history.slice_(adventure, start, length)
return history.count_after(adventure, depth) - 1
def settled_story_actions(adventure: models.Adventure) -> list[models.Action]:
"""Story actions old enough to summarize: everything but the newest one.
The plain-list form of the rule. The passes below use `settled_count` and
`settled_slice` instead, which express the same thing without reading the
`settled_after` instead, which express the same thing without reading the
whole story; this stays as the statement of what they must agree with.
Only the *last* action can be retried, so once an action has another action
@@ -189,68 +192,50 @@ def settled_story_actions(adventure: models.Adventure) -> list[models.Action]:
longer in the story. Holding one action back costs a turn of latency and
makes that unreachable.
The result is always a prefix of story_actions(), so memory_cursor and
summary_cursor stay valid positions and no action is ever skipped.
The result is always a prefix of the story, so an anchor set from it can
never sit past the settled end and no action is ever skipped.
"""
return story_actions(adventure)[:-1]
def _rewind_cursors_to_index(adventure: models.Adventure, index: int) -> None:
"""Move both cursors back to the position of Action.index `index`.
def forget_node(db: Session, adventure: models.Adventure, action: models.Action) -> int:
"""Withdraw what a node produced, because the node is being removed.
The cursors are *positions* into story_actions() while Memory.source_* are
Action.index values, so the two spaces have to be translated between (they
diverge as soon as any action is deleted).
Call it before deleting `action` (undo, delete-an-action). A memory hangs
off the node whose block it ends on, so "which memories described this?" is
a lookup on `(branch_id, depth)` rather than a scan for rows whose covered
range has fallen off the end of the story — which is what
`prune_dangling_memories` did, and it could only ever notice the damage
after the fact.
Discarding the memory is half of it. The stretch of story it covered is
still behind the cursors, so without a rewind those actions read as
summarized with nothing describing them, silently, for the rest of the
adventure. `source_start` is where that stretch began; the anchor goes to
the node before it, which is a depth whether or not anything still sits
there.
Returns how many memories were withdrawn.
"""
position = history.position_of_index(adventure, index)
adventure.memory_cursor = min(adventure.memory_cursor, position)
adventure.summary_cursor = min(adventure.summary_cursor, position)
def note_action_removed(adventure: models.Adventure, action: models.Action) -> None:
"""Keep the cursors pointing at the same actions when one is deleted from
*before* them. Call BEFORE the delete, while the action is still in the list.
memory_cursor counts actions from the start of the story, so removing an
earlier action slides every later one down a slot — without this, an action
that was never summarized shifts into the "already covered" range and is
skipped forever.
"""
if not history.is_story_text(action.text):
return # not in the list the cursors count, so nothing shifts
# Actions are ordered by index, so "how many come before it" is exactly
# "how many have a lower index" — no need to walk the list to find it.
position = history.position_of_index(adventure, action.index)
if position < adventure.memory_cursor:
adventure.memory_cursor -= 1
if position < adventure.summary_cursor:
adventure.summary_cursor -= 1
def prune_dangling_memories(adventure: models.Adventure, db: Session) -> int:
"""Delete memories that summarized actions which no longer exist (e.g. after
undo). source_start/source_end are Action.index values; a memory is dangling
if any covered action is past the current end of the story. Returns the count
removed.
Throwing a memory away is not enough on its own: the actions it covered are
still behind memory_cursor, so they would read as summarized with nothing
describing them. Rewind to where the earliest discarded memory began, so
those actions are summarized again.
"""
max_index = history.max_action_index(adventure)
dangling = [
m for m in adventure.memories
if m.source_end is not None and m.source_end > max_index
]
if not dangling:
if action.branch_id is None or action.depth is None:
return 0 # a pre-tree row: no path contains it, so nothing hangs off it
doomed = (
db.query(models.Memory)
.filter(
models.Memory.adventure_id == adventure.id,
models.Memory.branch_id == action.branch_id,
models.Memory.depth == action.depth,
)
.all()
)
if not doomed:
return 0
starts = [m.source_start for m in dangling if m.source_start is not None]
for m in dangling:
db.delete(m)
starts = [m.source_start for m in doomed if m.source_start is not None]
for memory in doomed:
db.delete(memory)
if starts:
_rewind_cursors_to_index(adventure, min(starts))
return len(dangling)
cursors.rewind_all(adventure, action.branch_id, min(starts) - 1)
return len(doomed)
# ---------- Retrieval (runs inside the turn, before build_context) ----------
@@ -279,9 +264,16 @@ async def retrieve_memories(
# walk adventure.memories, which loaded every row of the bank *including
# its vector* — ~31 KB a memory, three megabytes a turn, 96% of everything
# a turn read. Two ids and a flag per row is about eight bytes.
#
# The branch clause is the *whole* lineage here, not the window the story
# is read through: retrieval is long-range recall, and a memory of what
# happened forty turns ago is exactly what it exists to find. It stays
# affordable because memories are sparse — one per six actions — so the
# ancestry of even a heavily forked story returns tens of tiny rows.
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),
models.Memory.forgotten.is_(False),
models.Memory.embedded.is_(True),
)
@@ -381,17 +373,14 @@ async def run_post_turn(adventure_id: int) -> None:
)
if settings is None:
return
# Undo/retry can shrink the action list below a stored cursor, which
# would stall summarization until the story grew past it again.
# Deliberately the FULL count, not the settled one: an adventure that
# was caught up under the old rule can have a cursor equal to the action
# count, and clamping to settled would rewind it one step, re-covering
# an already-summarized action in the next block. Both consumers below
# read settled actions and bail on a negative remainder, so a cursor
# briefly sitting one past the settled end is harmless.
total = history.count(adventure)
adventure.memory_cursor = min(adventure.memory_cursor, total)
adventure.summary_cursor = min(adventure.summary_cursor, total)
# No cursor clamp here any more. Undo can leave the story shorter than
# the mark, and a *position* past the end of the list was a stalled
# pass until the story grew back past it — hence a clamp on every
# post-turn run, which had its own trap (clamping to the settled count
# rewound a caught-up adventure a step and re-covered an action). An
# anchor past the tip is not a broken value: `settled_after` just
# reports nothing to do, and the story growing back past it resumes
# exactly where it left off.
if adventure.auto_summarize:
await _create_due_memories(adventure, settings, db)
await _update_story_summary(adventure, settings, db)
@@ -408,14 +397,17 @@ async def _create_due_memories(
) -> None:
provider = summary_provider(settings)
for _ in range(MAX_MEMORIES_PER_RUN):
# Re-counted each pass: a memory just committed doesn't change the
# count, but this loop is the only thing that moves the cursor, so the
# comparison has to be against a total that is still current.
settled = settled_count(adventure)
cursor = adventure.memory_cursor
if settled < MEMORY_START or settled - cursor < MEMORY_INTERVAL:
return
block = settled_slice(adventure, cursor, MEMORY_INTERVAL)
# Re-read each pass: a memory just committed doesn't change the story,
# but this loop is the only thing that moves the anchor, so both
# numbers have to be current.
anchor = cursors.MEMORY.depth(db, adventure)
if settled_after(adventure, anchor) < MEMORY_INTERVAL:
return # no full block of settled story past the mark
if settled_count(adventure) < MEMORY_START:
return # ...and the adventure is too short to have started at all
# (that order on purpose: the common answer is "nothing due", and the
# first question answers it without asking how long the story is)
block = history.after(adventure, anchor, MEMORY_INTERVAL)
if len(block) < MEMORY_INTERVAL:
return
excerpt = truncate_to_last_tokens("\n\n".join(a.text for a in block), 2000)
@@ -430,46 +422,52 @@ async def _create_due_memories(
memory = models.Memory(
adventure_id=adventure.id,
text=text,
source_start=block[0].index,
source_end=block[-1].index,
source_start=block[0].depth,
source_end=block[-1].depth,
)
# Phase 14: hang it off the node it summarised, so a fork inherits the
# memories of the path it forked from and nothing else.
tree.place_memory(db, adventure, memory)
# Hang it off the node it summarised, so a fork inherits the memories of
# the path it forked from and nothing else — and move the mark to that
# same node. The two are one statement about where this pass has got to,
# and writing them from the same row is what keeps them in step however
# gappy the depths underneath are.
tree.attach_memory(memory, block[-1])
db.add(memory)
adventure.memory_cursor = cursor + MEMORY_INTERVAL
cursors.MEMORY.anchor_at(adventure, block[-1])
db.commit()
async def _update_story_summary(
adventure: models.Adventure, settings: models.Settings, db: Session
) -> None:
settled = settled_count(adventure)
if settled - adventure.summary_cursor < SUMMARY_INTERVAL:
anchor = cursors.SUMMARY.depth(db, adventure)
uncovered = settled_after(adventure, anchor)
if uncovered < SUMMARY_INTERVAL:
return
# Where the summary will stand once this run succeeds. Read before the AI
# call, not after: the mark is the settled end of the story as this pass
# saw it, and a turn landing meanwhile must not be quietly claimed as read.
caught_up = history.newest_settled(adventure)
if caught_up is None:
return
# Fold in memories covering the uncovered stretch; fall back to raw story
# text if memory creation is lagging (e.g. it just failed).
# summary_cursor is a position into story_actions(); Memory.source_end is
# an Action.index. Translate the cursor to an index boundary before
# comparing — the two spaces diverge once actions are deleted or empty.
if adventure.summary_cursor < settled:
[first_uncovered] = settled_slice(adventure, adventure.summary_cursor, 1)
boundary = first_uncovered.index
else:
last = settled_slice(adventure, settled - 1, 1) if settled else []
boundary = last[0].index + 1 if last else 0
new_events = [
m.text
for m in adventure.memories
if m.source_end is not None and m.source_end >= boundary
]
# Fold in the memories of the stretch the summary has not read — every
# memory hanging off a node past the anchor. Both marks and every memory
# are now depths on one path, so there is no translation between coordinate
# systems left to get wrong. Falls back to raw story text if memory
# creation is lagging (e.g. it just failed).
new_events = db.execute(
select(models.Memory.text)
.where(
models.Memory.adventure_id == adventure.id,
lineage.path_of(db, adventure).clause(models.Memory),
models.Memory.depth > anchor,
)
.order_by(models.Memory.depth)
).scalars().all()
if new_events:
events_text = "\n".join(f"- {t}" for t in new_events)
else:
block = settled_slice(
adventure, adventure.summary_cursor, settled - adventure.summary_cursor
)
block = history.after(adventure, anchor, uncovered)
events_text = truncate_to_last_tokens("\n\n".join(a.text for a in block), 2000)
current = adventure.story_summary.strip()
@@ -487,7 +485,7 @@ async def _update_story_summary(
if not text:
return
adventure.story_summary = text
adventure.summary_cursor = settled
cursors.SUMMARY.anchor_at(adventure, caught_up)
db.commit()
@@ -496,6 +494,13 @@ async def _embed_pending(
) -> None:
# A query, not a walk of adventure.memories: this ran every turn and pulled
# the whole bank's vectors to find the handful that had none.
#
# No branch clause, deliberately, here and in the eviction below. Being
# embedded is a fact about the row, not about the path being played:
# skipping a sibling's memories would only mean embedding them later, at
# the moment somebody switched branches and wanted them ranked. Capacity is
# the same — the bank belongs to the adventure, and evicting the memories
# of a story nobody is reading is exactly the right thing to evict first.
pending = (
db.query(models.Memory)
.filter(
+75
View File
@@ -214,6 +214,18 @@ MIGRATIONS: list[tuple[int, str | dict[str, str]]] = [
# columns above (_backfill_tree, hung off this version because it needs all
# of them to exist).
(52, "CREATE INDEX IF NOT EXISTS ix_actions_branch_depth ON actions (branch_id, depth)"),
# Phase 14, SP3 — the memory and summary cursors stop being positions in the
# story and become nodes in it: (branch, depth) of the last action each pass
# covered. Legacy `memory_cursor` / `summary_cursor` stay, unread, until SP8
# drops them beside `actions.index`.
#
# Unlike 46-52 this rewrites `adventures`, not `actions` — a few hundred
# rows against a few hundred thousand — so it needs no VACUUM FULL of its
# own. (The one SP1's deploy asks for is still owed.)
(53, "ALTER TABLE adventures ADD COLUMN memory_cursor_branch_id INTEGER"),
(54, "ALTER TABLE adventures ADD COLUMN memory_cursor_depth INTEGER NOT NULL DEFAULT -1"),
(55, "ALTER TABLE adventures ADD COLUMN summary_cursor_branch_id INTEGER"),
(56, "ALTER TABLE adventures ADD COLUMN summary_cursor_depth INTEGER NOT NULL DEFAULT -1"),
]
LATEST_VERSION = max((v for v, _ in MIGRATIONS), default=1)
@@ -224,6 +236,7 @@ VARIANT_COUNT_VERSION = 37
EMBEDDING_BLOB_VERSION = 38
SNAPSHOT_COMPRESS_VERSION = 43
TREE_BACKFILL_VERSION = 52
CURSOR_ANCHOR_VERSION = 56
# An adventure with no actions has no tip. -1 keeps "the next node goes at
# head_depth + 1" true without a special case (mirrors tree.NO_DEPTH).
@@ -496,6 +509,66 @@ def _backfill_tree(conn) -> None:
"""))
# A frozen copy of `context.history._STORY_TEXT` as it stood at version 56:
# "text that is not blank once whitespace is stripped". It is written out here
# rather than imported because a migration has to keep meaning what it meant on
# the day it ran, while the module is free to move. `char()` is `chr()` on
# Postgres and there is no third spelling, so it takes a dialect map.
def _story_text_sql(column: str, sqlite: bool) -> str:
char = "char" if sqlite else "chr"
folded = column
for code in (10, 13, 9): # newline, carriage return, tab
folded = f"replace({folded}, {char}({code}), ' ')"
return f"trim({folded}) <> ''"
def _backfill_cursor_anchors(conn) -> None:
"""Read each adventure's two cursors as nodes instead of as positions.
`memory_cursor` = 12 meant "the first twelve story actions are covered".
The twelfth story action, in depth order, is the node that says the same
thing and goes on saying it after something in front of it is deleted — so
the translation is a `ROW_NUMBER()` over the story and a lookup at the
cursor's own value.
Two cases the arithmetic has to survive:
* **A cursor past the end of the story.** Legitimate — an adventure caught
up under the older rule can have a cursor equal to its action count, and
`run_post_turn` used to clamp it every pass. There is no `rn` to match,
so it falls back to the deepest node there is: still "caught up", which
is what the number meant.
* **A cursor of 0**, which is most adventures. Nothing covered, the column
default already says so, and no row is touched.
Guarded on `_depth = -1` so a run that dies halfway resumes: every
adventure this has already converted is skipped, and one it has not is
indistinguishable from an untouched row.
"""
sqlite = conn.dialect.name == "sqlite"
story = _story_text_sql("text", sqlite)
for name in ("memory", "summary"):
conn.execute(text(f"""
UPDATE adventures
SET {name}_cursor_branch_id = {_root_branch_of('adventures.id')},
{name}_cursor_depth = COALESCE(
(SELECT ranked.depth FROM (
SELECT adventure_id, depth, ROW_NUMBER() OVER (
PARTITION BY adventure_id ORDER BY depth, id
) AS rn
FROM actions WHERE {story}
) AS ranked
WHERE ranked.adventure_id = adventures.id
AND ranked.rn = adventures.{name}_cursor),
(SELECT MAX(a.depth) FROM actions a
WHERE a.adventure_id = adventures.id
AND {_story_text_sql('a.text', sqlite)}),
{NO_DEPTH})
WHERE adventures.{name}_cursor > 0
AND adventures.{name}_cursor_depth = {NO_DEPTH}
"""))
def _get_version(conn) -> int:
if conn.dialect.name == "sqlite":
return conn.execute(text("PRAGMA user_version")).scalar() or 1
@@ -551,6 +624,8 @@ def bootstrap(engine: Engine) -> None:
_backfill_context_snapshot(conn)
if version == TREE_BACKFILL_VERSION:
_backfill_tree(conn)
if version == CURSOR_ANCHOR_VERSION:
_backfill_cursor_anchors(conn)
current = version
_set_version(conn, current)
_encrypt_plaintext_api_keys(conn)
+22 -2
View File
@@ -113,9 +113,24 @@ class Adventure(Base):
# Phase 6: opt-in per adventure (extra AI calls)
auto_summarize: Mapped[bool] = mapped_column(Boolean, default=False)
memory_bank_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
# How many actions have already been folded into memories / the story summary.
# LEGACY (Phase 6): how many actions had been folded into memories / the
# story summary, as a *position* in the story. Unread since SP3, and
# unwritten except by a v1 import which is handed one; kept for one release
# so a rollback resumes from a real number, and dropped in SP8 beside
# `actions.index`. The live mark is the anchor pair below.
memory_cursor: Mapped[int] = mapped_column(Integer, default=0)
summary_cursor: Mapped[int] = mapped_column(Integer, default=0)
# Phase 14, SP3: the same two marks as nodes — (branch, depth) of the last
# action each pass covered. A position slides when an action in front of it
# is deleted and silently starts covering one it has never read; a depth
# does not move, because it is a coordinate along a path rather than an
# offset into a list. NO_DEPTH (-1) is "nothing covered yet", so the first
# block needs no special case. Plain integers, not foreign keys, for the
# same reason `head_branch_id` below is one. See `context/cursors.py`.
memory_cursor_branch_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
memory_cursor_depth: Mapped[int] = mapped_column(Integer, default=-1)
summary_cursor_branch_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
summary_cursor_depth: Mapped[int] = mapped_column(Integer, default=-1)
# Phase 14: where the story is being played — which branch, and the depth of
# its newest node. Deliberately NOT a ForeignKey: branches.adventure_id
# already points this way, and a second constraint back would make the two
@@ -225,7 +240,12 @@ class Memory(Base):
embedding_blob: Mapped[bytes | None] = mapped_column(
LargeBinary, nullable=True, deferred=True
)
# Action index range this memory summarizes (null for manual memories).
# The stretch of story this memory summarizes, as depths on `branch_id`
# (null for a hand-written memory, which summarizes nothing). Written as
# `Action.index` values before SP3, which held the same numbers.
# `source_end` is the depth of the node the memory hangs off, mirrored into
# `depth` below; `source_start` is where it began, which is where the
# summarizer has to resume from if the memory is ever withdrawn.
source_start: Mapped[int | None] = mapped_column(Integer, nullable=True)
source_end: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Phase 14: the node that produced this memory — the last action it
+30 -12
View File
@@ -10,7 +10,7 @@ from sqlalchemy.orm import Session, load_only, undefer
from sqlalchemy.orm.attributes import set_committed_value
from .. import auth, images, limits, memorybank, models, schemas, tree, worldstate
from ..context import build_context
from ..context import build_context, cursors
from ..context import history as context_history
from ..context import lineage
from ..database import get_db
@@ -1102,19 +1102,18 @@ def undo_turn(
preceding = newest[1] if len(newest) > 1 else None
# The earliest action removed in this turn holds the pre-turn scoreboard.
first_removed = last
memorybank.note_action_removed(adventure, last)
memorybank.forget_node(db, adventure, last)
db.delete(last)
if last.type == "ai" and preceding is not None and preceding.type in ("do", "say", "story"):
first_removed = preceding
memorybank.note_action_removed(adventure, first_removed)
memorybank.forget_node(db, adventure, first_removed)
db.delete(first_removed)
if first_removed.state_before is not None:
adventure.script_state = copy.deepcopy(first_removed.state_before)
if first_removed.world_state_before is not None:
adventure.world_state = copy.deepcopy(first_removed.world_state_before)
db.flush() # apply deletes so pruning sees the shrunken action list
db.flush() # apply the deletes before anything reads the story back
db.expire(adventure, ["actions"])
memorybank.prune_dangling_memories(adventure, db)
# The tip moved back with them.
tree.refresh_head(db, adventure)
db.commit()
@@ -1168,8 +1167,12 @@ def export_adventure(
"worldState": adv.world_state,
"autoSummarize": adv.auto_summarize,
"memoryBankEnabled": adv.memory_bank_enabled,
"memoryCursor": adv.memory_cursor,
"summaryCursor": adv.summary_cursor,
# The bundle's coordinate system is a position in the story, and the
# cursors are nodes now, so they are counted back into one. A v1 bundle
# has to stay readable by builds that never heard of a depth — SP6's v2
# format carries the anchors themselves.
"memoryCursor": cursors.position_of(adv, cursors.MEMORY.depth(db, adv)),
"summaryCursor": cursors.position_of(adv, cursors.SUMMARY.depth(db, adv)),
"memories": [
{
"text": m.text, "pinned": m.pinned, "forgotten": m.forgotten,
@@ -1312,6 +1315,16 @@ def import_adventure(
tree.place_action(db, adventure, action)
db.add(action)
# The bundle's cursors are positions in a flat story and the marks are
# nodes, so the translation waits until the actions exist — this is the
# only moment the two coordinate systems can be lined up against each
# other. The legacy columns keep the numbers the bundle gave: they are what
# a rolled-back build would read.
db.flush()
db.expire(adventure, ["actions"])
cursors.anchor_at_position(adventure, cursors.MEMORY, adventure.memory_cursor)
cursors.anchor_at_position(adventure, cursors.SUMMARY, adventure.summary_cursor)
db.commit()
db.refresh(adventure)
return adventure
@@ -1658,6 +1671,11 @@ def list_memories(
# a relationship load takes whole entities, so it picks up whatever the
# 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.
return (
db.query(models.Memory)
.options(load_only(*MEMORY_LIST_COLUMNS))
@@ -1788,13 +1806,13 @@ def delete_action(
action = db.get(models.Action, action_id)
if action is None or action.adventure_id != adventure_id:
raise HTTPException(404, "Action not found")
# Cursor bookkeeping, same as undo: slide the cursors down if this action
# sits before them, then drop any memory left describing a deleted action.
memorybank.note_action_removed(adventure, action)
# Same as undo: withdraw whatever this node produced. Nothing else needs
# doing — the marks are depths, and a depth does not move because an action
# in front of it went away.
memorybank.forget_node(db, adventure, action)
db.delete(action)
db.flush() # apply the delete so pruning sees the shrunken action list
db.flush()
db.expire(adventure, ["actions"])
memorybank.prune_dangling_memories(adventure, db)
# Deleting the newest action moves the tip; deleting a middle one leaves a
# gap in the depths, deliberately — see _backfill_tree.
tree.refresh_head(db, adventure)
+18
View File
@@ -131,6 +131,24 @@ def place_memory(
return branch
def attach_memory(memory: models.Memory, node: models.Action) -> None:
"""Hang a memory off the node it was derived from.
The general rule, of which the memory bank is the first instance: anything
derived from the story attaches to the node that produced it, and is then
visible from exactly the paths that node is on. A fork inherits its
ancestors' memories because it inherits their nodes — nothing is copied and
nothing is recreated — and a memory made on a sibling is invisible here
because that node is not on this path.
Not `place_memory`: this takes the branch from the *node*, which is not
always the head. A block of story can end before the fork the current
branch was made at, and the memory belongs where the ground is.
"""
memory.branch_id = node.branch_id
memory.depth = node.depth
def place_new_nodes(session: Session) -> None:
"""Place every unplaced node about to be inserted. Runs on every flush.