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:
committed by
Parth
co-authored by
Claude Opus 5
parent
c7b6a46a8a
commit
c51531709d
@@ -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)
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user