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
+28 -1
View File
@@ -22,6 +22,8 @@ script and test ever written too, and its breach is a story quietly missing
turns. So the invariant is enforced at the flush instead of asked for.
"""
import copy
from sqlalchemy import func, insert, update
from sqlalchemy.orm import Session
@@ -149,6 +151,27 @@ def attach_memory(memory: models.Memory, node: models.Action) -> None:
memory.depth = node.depth
def stamp_outcome(adventure: models.Adventure, action: models.Action) -> None:
"""Give a node the state it left behind, if its writer did not.
The floor under `attempts.snapshot_outcome`, and it is here for the same
reason `place_action` has one: from SP4 a node with no outcome is a node
undo and retry cannot roll back past, and it fails by leaving the
scoreboard where it was rather than by raising. The turn engine records the
outcome itself and this skips those rows; what it catches is every fixture,
script and import that writes a story straight through the ORM.
What it writes is the truth as of the flush: a writer that changes no state
between two nodes leaves the same state behind both of them.
"""
if action.state_after is None:
state = adventure.script_state if isinstance(adventure.script_state, dict) else {}
action.state_after = copy.deepcopy(state)
if action.world_state_after is None:
world = adventure.world_state if isinstance(adventure.world_state, dict) else {}
action.world_state_after = copy.deepcopy(world)
def place_new_nodes(session: Session) -> None:
"""Place every unplaced node about to be inserted. Runs on every flush.
@@ -178,11 +201,15 @@ def place_new_nodes(session: Session) -> None:
place = place_memory
else:
continue
if obj.branch_id is not None or obj.adventure_id is None:
if obj.adventure_id is None:
continue
adventure = session.get(models.Adventure, obj.adventure_id)
if adventure is None:
continue
if isinstance(obj, models.Action):
stamp_outcome(adventure, obj)
if obj.branch_id is not None:
continue # already placed at its call site
head = heads.get(adventure.id)
if head is None:
head = heads[adventure.id] = head_branch(session, adventure)