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
+88 -83
View File
@@ -226,19 +226,19 @@ class FakeEmbeddings:
# underneath all of it. Measured on a freshly built default fixture, the
# columns a story tree has to migrate correctly look like this:
#
# state_before / world_state_before NULL on all 600 rows. SP4 moves these
# from *before* to *after* snapshots, and
# a migration over NULLs proves nothing.
# state_after / world_state_after the same value on all 600 rows (they
# were state_before, and NULL, until SP4
# turned them round). A rollback over
# identical snapshots proves nothing.
# scenario_id / world_state absent. No RPG layer, so the cooldown
# clock SP5 must not advance never runs.
# adventure_scripts none. script_state rollback is exactly
# what a branch switch reuses.
# memory_cursor / summary_cursor both 0. SP3 replaces the cursors.
# variants two attempts of byte-identical text,
# variant_index always 0 — so "which
# attempt is live?", the one question
# SP4's migration has to answer, has no
# observable answer.
# sibling attempts two of byte-identical text with the
# first always live — so "which attempt
# is live?", the one question SP4 has to
# answer, has no observable answer.
#
# --rich fills in exactly those and changes nothing else, so the measuring
# fixture's numbers stay comparable run to run. It is a *correctness* fixture:
@@ -305,29 +305,23 @@ def rich_world_state(schema: dict, turn: int) -> dict:
return ws
def rich_variants(rng: random.Random, index: int) -> tuple[list, int]:
"""Distinguishable retry attempts, and which one is live.
def rich_attempts(rng: random.Random, index: int) -> tuple[list[str], int]:
"""Distinguishable retry attempts, and which one the story tells.
Every attempt in the default fixture carries the same text with
variant_index pinned at 0. That is the one thing SP4 has to get right and
the one thing that fixture cannot witness, so here the texts differ, the
counts differ, and the live one is often not the last.
Every attempt in the default fixture carries the same text with the live
one pinned at 0. That is the one thing SP4 has to get right and the one
thing that fixture cannot witness, so here the texts differ, the counts
differ, and the live one is often not the last.
"""
count = 3 if index % 12 == 1 else 2
entries = [
{
"text": f"[attempt {n + 1} of {count} at turn {index}] {prose(rng, 240)}",
"reasoning": None,
"script_state": rich_script_state(index),
"created_at": f"2026-01-01T00:{index % 60:02d}:{n:02d}",
}
texts = [
f"[attempt {n + 1} of {count} at turn {index}] {prose(rng, 240)}"
for n in range(count)
]
# Deliberately not always the newest: a player who retried twice and then
# went back to the first take is the case that breaks a migration which
# assumes the live attempt is the last one written.
live = 0 if index % 18 == 1 else count - 1
return entries, live
# went back to the first take is the case that breaks anything assuming the
# live attempt is the last one written.
return texts, (0 if index % 18 == 1 else count - 1)
def add_rich_extras(db, args, rng: random.Random, user, adventure) -> None:
@@ -387,7 +381,7 @@ def add_second_adventure(db, rng: random.Random, user) -> int:
adventure_id=other.id, index=i,
type="ai" if i % 2 else "do",
text=f"[second adventure] turn {i}. {prose(rng, 200)}",
state_before=rich_script_state(i),
state_after=rich_script_state(i),
)
tree.place_action(db, other, action)
db.add(action)
@@ -404,28 +398,31 @@ def add_second_adventure(db, rng: random.Random, user) -> int:
def _assert_live_variant_invariant(db, adventure_id: int) -> None:
"""`text` mirrors the live attempt, on every retried row.
"""Exactly one attempt per turn is live, on every turn.
Checked here rather than trusted, because SP4's migration reads exactly
this to decide which sibling node becomes the head of the turn. A fixture
that quietly violated it would let a wrong migration look right.
Checked here rather than trusted, because a coordinate with two live
siblings tells its story twice and a coordinate with none drops a turn out
of it — and both fail by *reading* wrong, never by raising. A fixture that
quietly violated it would let wrong code look right.
"""
rows = (
groups: dict[tuple, list[models.Action]] = {}
for action in (
db.query(models.Action)
.filter(models.Action.adventure_id == adventure_id,
models.Action.variant_count > 0)
.filter(models.Action.adventure_id == adventure_id)
.all()
)
for action in rows:
entries = action.variants or []
live = entries[action.variant_index]["text"]
if live != action.text:
):
groups.setdefault((action.branch_id, action.depth), []).append(action)
retried = 0
for (branch_id, depth), rows in groups.items():
live = [a for a in rows if a.live]
if len(live) != 1:
sys.exit(
f"fixture is inconsistent: action index {action.index} has text "
f"that is not its live attempt (variant_index="
f"{action.variant_index} of {len(entries)})."
f"fixture is inconsistent: branch {branch_id} depth {depth} has "
f"{len(live)} live attempts out of {len(rows)}."
)
if not rows:
if len(rows) > 1:
retried += 1
if not retried:
sys.exit("--rich built no retried actions; raise --actions above 6.")
@@ -476,47 +473,55 @@ def build_fixture(args, rng: random.Random) -> tuple[int, int]:
retried = is_ai and i % 6 == 1
if args.rich:
# Distinguishable attempts, and a live one that is often not
# the last written. `text` must mirror the live attempt — that
# invariant is what SP4's migration reads to decide which
# sibling becomes the head.
entries, live = rich_variants(rng, i) if retried else ([], 0)
body = entries[live]["text"] if retried else (
f"[turn {i}] {NARRATION}" if is_ai else f"[turn {i}] {PLAYER_INPUT}"
)
# the last written.
texts, live = rich_attempts(rng, i) if retried else ([], 0)
if not retried:
texts = [f"[turn {i}] {NARRATION}" if is_ai
else f"[turn {i}] {PLAYER_INPUT}"]
else:
entries, live = (
[{"text": NARRATION, "reasoning": None, "script_state": {},
"created_at": "2026-01-01T00:00:00"} for _ in range(2)]
if retried else []
), 0
body = NARRATION if is_ai else PLAYER_INPUT
action = models.Action(
adventure_id=adventure.id,
index=i,
type="ai" if is_ai else "do",
text=body,
context_snapshot={"system": SNAPSHOT_SYSTEM, "story": SNAPSHOT_STORY},
world_delta={"delta": {"player.hp": -3},
"applied": [{"path": "player.hp", "old": 88, "new": 85}]},
# Every third AI turn was retried once, so the retry history is
# carrying weight a list response must not pay for.
variants=entries or None,
variant_count=len(entries),
variant_index=live,
# NULL unless --rich. These are the columns a story tree turns
# from *before* pictures into *after* ones, so a fixture that
# leaves them unset cannot witness that change.
state_before=rich_script_state(i) if args.rich else None,
world_state_before=(
rich_world_state(schema, i) if args.rich else None
),
)
# A fresh database is built by create_all and stamped LATEST, so no
# migration ever runs against it and the tree backfill never sees
# it. The fixture has to stamp its own nodes, or it would be the one
# database in the project whose actions have no branch.
tree.place_action(db, adventure, action)
db.add(action)
texts = [NARRATION, NARRATION] if retried else [
NARRATION if is_ai else PLAYER_INPUT
]
live = 0
for n, body in enumerate(texts):
action = models.Action(
adventure_id=adventure.id,
index=i,
type="ai" if is_ai else "do",
text=body,
# The turn's assembled prompt is stored once, on the
# attempt the story tells; a superseded sibling keeps only
# its own slices. That is the invariant `app/attempts.py`
# maintains, and a fixture that ignored it would multiply
# the biggest column in the database by the retry count.
context_snapshot=(
{"system": SNAPSHOT_SYSTEM, "story": SNAPSHOT_STORY}
if n == live else
{"raw_output": body}
),
world_delta={"delta": {"player.hp": -3},
"applied": [{"path": "player.hp", "old": 88, "new": 85}]},
# Every third AI turn was retried once, so a turn is
# sometimes several rows sharing one coordinate.
live=(n == live),
variant_index=n,
variant_count=len(texts) if len(texts) > 1 else 0,
# Monotonic under --rich, so a bad rollback reads as a
# wrong number rather than as nothing. Left to
# `tree.stamp_outcome` otherwise, which writes the
# adventure's (unchanging) state — true, and no witness.
state_after=rich_script_state(i) if args.rich else None,
world_state_after=(
rich_world_state(schema, i) if args.rich else None
),
)
# A fresh database is built by create_all and stamped LATEST,
# so no migration ever runs against it and the tree backfill
# never sees it. The fixture has to stamp its own nodes, or it
# would be the one database in the project whose actions have
# no branch.
tree.place_action(db, adventure, action)
db.add(action)
for i in range(args.memories):
memory = models.Memory(
@@ -717,7 +722,7 @@ def parse_args(argv=None):
"blind spot, where the bank's cost is invisible")
p.add_argument("--rich", action="store_true",
help="populate the columns the measuring fixture leaves at "
"their defaults: state_before/world_state_before, an "
"their defaults: state_after/world_state_after, an "
"RPG scenario and live world state, adventure scripts, "
"non-zero memory/summary cursors, pinned and forgotten "
"memories, distinguishable retry attempts, and a second "