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
+15 -1
View File
@@ -102,6 +102,12 @@ class Path:
after it was typed. An action with no depth is a pre-tree row that no
read should see, so actions never pass this.
Actions also have to be *live* (SP4). A coordinate can hold several
attempts at the same turn, and the story tells one of them; the losing
siblings sit at the same branch and depth and are excluded here, once,
so that no read of the story has to know that retries exist. Only
`app/attempts.py` looks past 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.
@@ -109,7 +115,10 @@ 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, unanchored) for b, d in entries])
on_path = or_(*[self._entry_clause(model, b, d, unanchored) for b, d in entries])
if model is models.Action:
return and_(on_path, models.Action.live.is_(True))
return on_path
@staticmethod
def _entry_clause(model, branch_id: int, max_depth: int | None, unanchored=False):
@@ -128,7 +137,12 @@ class Path:
Used where the rows are already in memory (the scripting pipeline hands
user scripts the whole history), so an already-loaded collection can be
cut down to the path without a second read.
`live` is checked first, and only on rows that have the attribute:
memories have no siblings to lose to.
"""
if getattr(node, "live", True) is False:
return False
for branch_id, max_depth in self.entries:
if node.branch_id != branch_id:
continue