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
+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(
|
||||
|
||||
Reference in New Issue
Block a user