Make a retry a node, not a rewrite

Every attempt at a turn is now its own row at the same (branch, depth),
with `live` naming the one the story tells. The JSON repeating group on
`actions.variants` is read one last time, by a migration that writes it
out as the sibling rows it always described, and then goes unread.

The snapshots turn around with it: an action carries the state it left
behind rather than the state it started from, because attempts at one
turn share a starting position and differ exactly in their outcome.
Rolling back is "what the node in front left behind", one lookup on the
path, and it is what undo and retry now both read.

And the memory holdback goes. It existed because retry rewrote a row
under a mark that had already moved past it; a retry writes a sibling
now, and replacing what a coordinate says withdraws what was derived
from it — the same repair undo and delete already made.

The assembled prompt is still stored once per turn: it moves with the
live flag, so a superseded attempt keeps only the few hundred bytes that
were its own. Measured on the 600-action fixture: 700 rows for the same
600-turn story, prompt archive byte-identical at 0.50 MB, index 1.8 kB
and page load 62.7 kB unmoved.

347 tests green. `tests/test_story_tree_baseline.py` and
`tests/test_retry_variants.py` pass unmodified — SP4 was allowed to move
the baseline for the variant-count semantics and did not need to.

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 c51531709d
commit 0a12d9cd47
17 changed files with 1635 additions and 511 deletions
+8 -52
View File
@@ -4,9 +4,7 @@
After each turn, a fire-and-forget task (`run_post_turn`) runs with its own DB
session:
- every MEMORY_INTERVAL actions (starting at MEMORY_START), each uncovered
block of actions is summarized into a short "memory". Summarization only
ever reads *settled* actions (see settled_story_actions) — the newest action
is held back one turn because it is still retryable;
block of actions is summarized into a short "memory";
- every SUMMARY_INTERVAL actions, the Story Summary is rewritten folding in
the new memories (the user-edited text is always the base, never clobbered);
- new memories are embedded (OpenAI-compatible /v1/embeddings) and the bank
@@ -156,48 +154,6 @@ def _vectors_for(db: Session, adventure_id: int, ids: list[int]) -> dict[int, ar
return cached
def settled_count(adventure: models.Adventure) -> int:
"""How many story actions are old enough to summarize: all but the newest.
See settled_story_actions for why one action is held back. Counting rather
than listing keeps the post-turn pass off the whole story.
"""
return max(history.count(adventure) - 1, 0)
def settled_after(adventure: models.Adventure, depth: int) -> int:
"""How many settled story actions lie past `depth`.
"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.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_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
after it, its text is final. Summarizing right up to the newest action meant
a memory could describe an attempt the player then retried away — the
memory's cursor has already advanced, so it is never regenerated, leaving a
memory (and, downstream, a story summary) describing narration that is no
longer in the story. Holding one action back costs a turn of latency and
makes that unreachable.
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 forget_node(db: Session, adventure: models.Adventure, action: models.Action) -> int:
"""Withdraw what a node produced, because the node is being removed.
@@ -401,9 +357,9 @@ async def _create_due_memories(
# 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:
if history.count_after(adventure, anchor) < MEMORY_INTERVAL:
return # no full block of story past the mark
if history.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)
@@ -440,13 +396,13 @@ async def _update_story_summary(
adventure: models.Adventure, settings: models.Settings, db: Session
) -> None:
anchor = cursors.SUMMARY.depth(db, adventure)
uncovered = settled_after(adventure, anchor)
uncovered = history.count_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)
# call, not after: the mark is the 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(adventure)
if caught_up is None:
return