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
+399
View File
@@ -0,0 +1,399 @@
"""Phase 14 SP4 — a retry writes a sibling node instead of rewriting a row.
`test_retry_variants.py` is the behavioural contract, unchanged since before
the tree, and it still passes: the same URLs, the same payload shape, the same
outcomes. This file asserts the things that are *only* true of the new storage
— that a turn can be several rows, that exactly one of them is the story, and
that the arrangement costs neither an extra prompt nor an extra turn.
python -m pytest tests/test_attempt_siblings.py -v
"""
import os
import tempfile
_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
_tmp.close()
os.environ["AIDND_DB_PATH"] = _tmp.name
os.environ.pop("AIDND_DATABASE_URL", None)
os.environ.pop("DATABASE_URL", None)
import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
from sqlalchemy.orm import undefer
from app import attempts, auth, limits, models, tree
from app.context import cursors, history
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.providers import PromptParts
from app.routers import adventures
SCHEMA = {"player": {"hp": {"min": 0, "max": 100, "initial": 100}}}
GOLD_SCRIPT = """
const modifier = (text) => {
state.gold = (state.gold || 0) + 10;
return { text };
};
modifier(text);
"""
class ScriptedProvider:
replies: list = []
calls = 0
prompts: list = []
def __init__(self, *a, **k):
pass
async def generate(self, parts: PromptParts, *, temperature, max_tokens):
index = min(ScriptedProvider.calls, len(ScriptedProvider.replies) - 1)
ScriptedProvider.calls += 1
ScriptedProvider.prompts.append((parts.system, parts.story))
yield ("text", ScriptedProvider.replies[index])
@pytest.fixture()
def client(monkeypatch):
Base.metadata.create_all(bind=engine)
setup = SessionLocal()
user = models.User(is_guest=False, email="siblings@example.com")
setup.add(user)
setup.flush()
setup.add(models.Settings(user_id=user.id, api_key="enc:dummy", model="test-model"))
scenario = models.Scenario(user_id=user.id, title="S", stat_schema=SCHEMA)
setup.add(scenario)
setup.flush()
adv = models.Adventure(
user_id=user.id, title="Cave", scenario_id=scenario.id,
script_state={}, world_state={"player": {"hp": 100}},
)
setup.add(adv)
setup.flush()
setup.add(models.Action(adventure_id=adv.id, index=0, type="start", text="You enter a cave."))
setup.add(models.AdventureScript(
adventure_id=adv.id, position=0, enabled=True, name="Gold", output_js=GOLD_SCRIPT,
))
setup.commit()
adv_id, user_id = adv.id, user.id
setup.close()
ScriptedProvider.replies = ["Attempt one."]
ScriptedProvider.calls = 0
ScriptedProvider.prompts = []
monkeypatch.setattr(adventures, "OpenAICompatibleProvider", ScriptedProvider)
monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig(
"http://fake", "k", "test-model", False))
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
def _current_user(db=Depends(get_db)):
return db.get(models.User, user_id)
app.dependency_overrides[auth.get_current_user] = _current_user
c = TestClient(app)
c.adv_id = adv_id
try:
yield c
finally:
app.dependency_overrides.clear()
adventures._active_turns.clear()
Base.metadata.drop_all(bind=engine)
def _play(client, text="look around", type="do"):
r = client.post(f"/api/adventures/{client.adv_id}/actions",
json={"type": type, "text": text})
assert r.status_code == 200, r.text
def _retry(client):
r = client.post(f"/api/adventures/{client.adv_id}/retry")
assert r.status_code == 200, r.text
def _page(client) -> dict:
return client.get(f"/api/adventures/{client.adv_id}").json()
def _rows(adv_id) -> list[models.Action]:
"""Every action row of the adventure, story or not, live or not.
Undeferred, because the session is closed before the caller looks: the
columns this file is about are exactly the ones a page load never loads.
"""
db = SessionLocal()
try:
return (
db.query(models.Action)
.filter(models.Action.adventure_id == adv_id)
.options(
undefer(models.Action.state_after),
undefer(models.Action.world_state_after),
undefer(models.Action.context_snapshot),
)
.order_by(models.Action.depth, models.Action.variant_index)
.all()
)
finally:
db.close()
# ------------------------------------------------------------- the sibling
def test_a_retry_writes_a_second_row_at_the_same_coordinate(client):
ScriptedProvider.replies = ["Attempt one.", "Attempt two."]
_play(client)
_retry(client)
rows = _rows(client.adv_id)
ai = [a for a in rows if a.type == "ai"]
assert len(ai) == 2, "a retry is a node, not a rewrite"
assert {(a.branch_id, a.depth) for a in ai} == {(ai[0].branch_id, ai[0].depth)}
assert [a.text for a in ai] == ["Attempt one.", "Attempt two."]
# Exactly one of them is the story, and it is the newer take.
assert [a.live for a in ai] == [False, True]
# ...and the discarded attempt is untouched, not a copy of anything.
assert ai[0].state_after is not None
def test_the_story_shows_and_counts_the_turn_once(client):
ScriptedProvider.replies = ["Attempt one.", "Attempt two."]
_play(client)
before = _page(client)["action_count"]
_retry(client)
after = _page(client)
assert after["action_count"] == before, "a discarded attempt is not a turn"
assert [a["type"] for a in after["actions"]] == ["start", "do", "ai"]
assert after["actions"][-1]["text"] == "Attempt two."
def test_a_discarded_attempt_never_reaches_the_prompt(client):
"""The trap the branch clause exists to close, at sibling scale: the losing
attempt sits at the same branch and depth as the live one, so anything
reading the story by coordinate alone would replay both."""
ScriptedProvider.replies = ["Attempt one.", "Attempt two.", "Next turn."]
_play(client)
_retry(client)
_play(client, "go deeper")
story = ScriptedProvider.prompts[-1][1]
assert "Attempt two." in story
assert "Attempt one." not in story
def test_switching_moves_the_story_onto_the_other_row(client):
ScriptedProvider.replies = [
"A scratch.\n```state\n{\"player.hp\": -5}\n```",
"A beating.\n```state\n{\"player.hp\": -40}\n```",
]
_play(client)
_retry(client)
newest_id = _page(client)["actions"][-1]["id"]
r = client.post(
f"/api/adventures/{client.adv_id}/actions/{newest_id}/variant", json={"index": 0})
assert r.status_code == 200, r.text
# A different row answers — that is the whole change.
assert r.json()["id"] != newest_id
assert r.json()["text"].startswith("A scratch")
rows = _rows(client.adv_id)
ai = [a for a in rows if a.type == "ai"]
assert [a.live for a in ai] == [True, False]
# Both takes are still there, byte for byte.
assert [a.text.split(".")[0] for a in ai] == ["A scratch", "A beating"]
def test_the_assembled_prompt_is_stored_once_per_turn(client):
"""A snapshot is ~160 kB of prompt every attempt at a turn shares. Giving
each sibling a copy would have made retry a permanent multiplier on the
biggest column in the database, so the prompt moves with the live flag."""
ScriptedProvider.replies = ["Attempt one.", "Attempt two."]
_play(client)
_retry(client)
def holders():
return [
a.id for a in _rows(client.adv_id)
if a.type == "ai" and "sections" in (a.context_snapshot or {})
]
live_holder = holders()
assert len(live_holder) == 1
newest = _page(client)["actions"][-1]
assert live_holder == [newest["id"]]
client.post(f"/api/adventures/{client.adv_id}/actions/{newest['id']}/variant",
json={"index": 0})
moved = holders()
assert len(moved) == 1 and moved != live_holder, "the prompt follows the story"
# ------------------------------------------------------- removing the turn
def test_undo_takes_every_attempt_with_it(client):
ScriptedProvider.replies = ["One.", "Two.", "Three."]
_play(client)
_retry(client)
_retry(client)
assert len([a for a in _rows(client.adv_id) if a.type == "ai"]) == 3
r = client.post(f"/api/adventures/{client.adv_id}/undo")
assert r.status_code == 200, r.text
assert [a.type for a in _rows(client.adv_id)] == ["start"]
def test_deleting_a_retried_turn_deletes_its_attempts(client):
ScriptedProvider.replies = ["One.", "Two."]
_play(client)
_retry(client)
newest = _page(client)["actions"][-1]
r = client.delete(f"/api/adventures/{client.adv_id}/actions/{newest['id']}")
assert r.status_code == 204, r.text
assert [a.type for a in _rows(client.adv_id)] == ["start", "do"]
def test_deleting_a_turn_through_a_discarded_attempt_still_takes_the_turn(client):
"""The pager hands out whichever id it last saw, and a switch changes which
row that is. Deleting through the losing sibling must not leave the story
holding a turn with no attempts."""
ScriptedProvider.replies = ["One.", "Two."]
_play(client)
_retry(client)
discarded = [a for a in _rows(client.adv_id) if a.type == "ai" and not a.live][0]
r = client.delete(f"/api/adventures/{client.adv_id}/actions/{discarded.id}")
assert r.status_code == 204, r.text
assert [a.type for a in _rows(client.adv_id)] == ["start", "do"]
# ------------------------------------------- what the holdback used to cover
def test_retrying_withdraws_the_memory_the_turn_produced(client):
"""Why summarization no longer holds the newest action back.
A memory covering the newest turn used to be unreachable-by-construction:
the summarizer stopped one action short, because a retry rewrote the row
under a mark that had already moved past it. Now the mark and the memory
both name the node, and replacing what a node says withdraws them — the
same repair undo and delete already made, so the holdback was the only
thing left that a retry needed.
"""
ScriptedProvider.replies = ["One.", "Two."]
_play(client)
db = SessionLocal()
try:
adventure = db.get(models.Adventure, client.adv_id)
newest = history.newest(adventure)
memory = models.Memory(
adventure_id=adventure.id, text="You looked around.",
source_start=1, source_end=newest.depth,
)
tree.attach_memory(memory, newest)
db.add(memory)
cursors.MEMORY.anchor_at(adventure, newest)
cursors.SUMMARY.anchor_at(adventure, newest)
db.commit()
covered_depth = newest.depth
finally:
db.close()
_retry(client)
db = SessionLocal()
try:
adventure = db.get(models.Adventure, client.adv_id)
assert db.query(models.Memory).count() == 0, "the withdrawn memory is gone"
# ...and the ground it covered is handed back, so the block is summarized
# again from where it began rather than silently skipped.
assert cursors.MEMORY.depth(db, adventure) == 0
assert cursors.SUMMARY.depth(db, adventure) == 0
assert covered_depth > 0
finally:
db.close()
def test_a_memory_on_an_earlier_turn_survives_a_retry(client):
"""Only the coordinate whose text changed is withdrawn."""
ScriptedProvider.replies = ["One.", "Two.", "Three."]
_play(client)
_play(client, "go deeper")
db = SessionLocal()
try:
adventure = db.get(models.Adventure, client.adv_id)
earlier = history.tail(adventure, 3)[0]
memory = models.Memory(
adventure_id=adventure.id, text="An earlier block.",
source_start=0, source_end=earlier.depth,
)
tree.attach_memory(memory, earlier)
db.add(memory)
db.commit()
finally:
db.close()
_retry(client)
db = SessionLocal()
try:
assert [m.text for m in db.query(models.Memory).all()] == ["An earlier block."]
finally:
db.close()
# -------------------------------------------------------------- the group
def test_the_group_cache_is_renumbered_as_attempts_arrive(client):
ScriptedProvider.replies = ["One.", "Two.", "Three."]
_play(client)
assert _page(client)["actions"][-1]["variant_count"] == 0 # never retried
_retry(client)
_retry(client)
ai = [a for a in _rows(client.adv_id) if a.type == "ai"]
assert [a.variant_index for a in ai] == [0, 1, 2]
assert {a.variant_count for a in ai} == {3}
def test_attempts_module_agrees_with_the_endpoint(client):
ScriptedProvider.replies = ["One.", "Two."]
_play(client)
_retry(client)
newest = _page(client)["actions"][-1]
listed = client.get(
f"/api/adventures/{client.adv_id}/actions/{newest['id']}/variants").json()
db = SessionLocal()
try:
node = db.get(models.Action, newest["id"])
group = attempts.group(db, node)
assert [a.text for a in group] == [v["text"] for v in listed]
assert attempts.live_in(group).id == newest["id"]
finally:
db.close()
def test_export_folds_the_group_back_into_one_v1_entry(client):
ScriptedProvider.replies = ["One.", "Two."]
_play(client)
_retry(client)
bundle = client.get(f"/api/adventures/{client.adv_id}/export").json()
ai = [a for a in bundle["actions"] if a["type"] == "ai"]
assert len(ai) == 1, "a v1 bundle carries one entry per turn, not per attempt"
assert [v["text"] for v in ai[0]["variants"]] == ["One.", "Two."]
assert ai[0]["variantIndex"] == 1
# ...and importing it splits it back out into the rows it describes.
imported = client.post("/api/adventures/import", json=bundle).json()["id"]
rows = _rows(imported)
ai_rows = [a for a in rows if a.type == "ai"]
assert [(a.text, a.live) for a in ai_rows] == [("One.", False), ("Two.", True)]
assert len({(a.branch_id, a.depth) for a in ai_rows}) == 1
+11 -5
View File
@@ -145,12 +145,17 @@ def test_loading_an_adventure_does_not_fetch_context_snapshot(client, sql_log):
assert offenders == [], f"context_snapshot was fetched in bulk:\n{offenders[0][:400]}"
def test_state_before_and_world_state_before_are_not_fetched_in_bulk(client, sql_log):
"""Both are rollback snapshots, only ever needed for the single action
being undone or retried."""
def test_the_state_snapshots_are_not_fetched_in_bulk(client, sql_log):
"""All four are rollback snapshots, only ever needed for the single node
being undone, retried past or switched to.
The `_after` pair is the live one since SP4 and the `_before` pair is dead
weight until SP8 drops it — a page load must pay for neither.
"""
client.get(f"/api/adventures/{client.adv_id}")
selects = action_selects(sql_log)
for column in ("state_before", "world_state_before"):
for column in ("state_before", "world_state_before",
"state_after", "world_state_after"):
offenders = [s for s in selects if column in s]
assert offenders == [], f"{column} was fetched in bulk"
@@ -199,7 +204,8 @@ def test_counting_actions_does_not_name_the_deferred_columns(client, sql_log):
assert history.count(adventure) == 12
counts = [s for s in sql_log if "count" in s.lower()]
assert counts, "expected a COUNT to be emitted"
for column in ("context_snapshot", "state_before", "world_state_before", "variants"):
for column in ("context_snapshot", "state_after", "world_state_after",
"state_before", "world_state_before", "variants"):
assert not any(column in s for s in counts), (
f"{column} is named by the count query:\n{counts[0][:400]}"
)
+1 -2
View File
@@ -203,8 +203,7 @@ def test_helpers_agree_with_the_full_list(story):
assert [a.id for a in history.slice_(adventure, 10, 6)] == [a.id for a in actions[10:16]]
assert [a.id for a in history.tail_range(adventure, 5, 3)] == \
[a.id for a in actions[-8:-5]]
assert memorybank.settled_count(adventure) == len(actions) - 1
assert history.newest_settled(adventure).id == actions[-2].id
assert history.newest(adventure).id == actions[-1].id
for probe in (0, 1, ACTION_COUNT // 2, ACTION_COUNT - 1):
boundary = actions[probe].depth
+13 -7
View File
@@ -294,8 +294,11 @@ def test_the_summary_folds_in_only_the_path_it_is_on(forked, monkeypatch):
assert "on B" in prompt and "on C" in prompt
assert "on A's own continuation" not in prompt
assert "on the shared trunk" not in prompt # behind the mark
# Caught up to the settled end of the story: C7 is retryable, C6 is not.
assert cursors.SUMMARY.stored(adventure) == (ids["c"], 6)
# Caught up to the end of the story. Until SP4 that was C6: the newest
# action was held back because retrying it rewrote the row underneath the
# mark. A retry writes a sibling now, and the withdrawal that follows takes
# the mark back with it, so there is nothing to hold back.
assert cursors.SUMMARY.stored(adventure) == (ids["c"], 7)
def test_a_block_is_summarized_from_the_path_and_hung_off_its_last_node(
@@ -317,13 +320,16 @@ def test_a_block_is_summarized_from_the_path_and_hung_off_its_last_node(
monkeypatch.setattr(memorybank, "summary_provider", lambda s: stub)
asyncio.run(memorybank._create_due_memories(adventure, settings, db))
# Two blocks of four from a path of eight, minus the held-back newest: one.
[excerpt] = stub.excerpts
assert "A5" not in excerpt, "a sibling's narration reached the summarizer"
assert ["A0", "A1", "A2", "A3"] == [line for line in excerpt.split() if line[0] in "ABC"]
# Two blocks of four from a path of eight, and since SP4 nothing is held
# back, so both form in one pass.
first, second = stub.excerpts
assert "A5" not in first + second, "a sibling's narration reached the summarizer"
assert ["A0", "A1", "A2", "A3"] == [line for line in first.split() if line[0] in "ABC"]
assert ["B4", "B5", "C6", "C7"] == [line for line in second.split() if line[0] in "ABC"]
made = db.query(models.Memory).filter_by(text="Memory 1.").one()
assert (made.branch_id, made.depth) == (ids["a"], 3)
assert cursors.MEMORY.stored(adventure) == (ids["a"], 3)
# The mark ends up on the node the *second* block hangs off — the tip.
assert cursors.MEMORY.stored(adventure) == (ids["c"], 7)
# ------------------------------------------------------ the cost of forking
+37 -62
View File
@@ -1,15 +1,18 @@
"""Memories must never describe an attempt the player can still retry away,
and must never skip a stretch of story.
"""Memories must never describe narration that is no longer in the story, and
must never skip a stretch of it.
Only the last action is retryable, so summarization holds the newest action
back one turn (memorybank.settled_story_actions). Without that, a memory could
cover the just-generated AI turn; retrying it rewrites Action.text but the mark
has already moved past it, so the memory is never regenerated and goes on
describing narration that is no longer in the story.
For six phases the answer was a **holdback**: summarization stopped one action
short of the newest, because only the last action was retryable and a retry
rewrote `Action.text` under a mark that had already moved past it. SP4 ended
that — a retry writes a sibling node and the coordinate's derived work is
withdrawn as it does, which is the same repair undo and delete already made.
So the holdback is gone, and the first half of this file now asserts the
property that replaced it: a block forms as soon as there is a block, and
changing what a coordinate says takes back what was derived from it.
Phase 14 SP3 changed what that mark *is*. It used to be a count of covered
story actions, and the second half of this file is the price of that: deleting
an action from in front of a position slid a never-summarized action into the
Phase 14 SP3 changed what the mark *is*. It used to be a count of covered story
actions, and the second half of this file is the price of that: deleting an
action from in front of a position slid a never-summarized action into the
covered range, so every delete had to slide the cursors too. The mark is a node
now — `(branch_id, depth)` — and a node does not move when something in front
of it is deleted, so those tests assert that nothing happens where they used to
@@ -30,7 +33,7 @@ os.environ.pop("DATABASE_URL", None)
import pytest
from app import memorybank, models, tree
from app.context import cursors
from app.context import cursors, history
from app.database import Base, SessionLocal, engine
@@ -104,56 +107,25 @@ def run_memories(db, adventure, stub, monkeypatch):
asyncio.run(memorybank._create_due_memories(adventure, settings, db))
# ------------------------------------------------------------------- settling
# --------------------------------------------------- no holdback, since SP4
def test_settled_actions_holds_back_the_newest(db):
adventure = make_adventure(db, 5)
settled = memorybank.settled_story_actions(adventure)
assert [a.index for a in settled] == [0, 1, 2, 3]
def test_a_block_forms_as_soon_as_the_story_holds_one(db, monkeypatch):
"""Covered to action 5 with 12 actions: block 6-11 ends on the *newest*
action, and is summarized now rather than a turn later.
def test_settled_actions_is_a_prefix_so_cursors_stay_valid(db):
"""The safety property behind the whole approach: dropping the newest
action can never renumber or skip an earlier one."""
adventure = make_adventure(db, 9)
full = memorybank.story_actions(adventure)
settled = memorybank.settled_story_actions(adventure)
assert full[: len(settled)] == settled
def test_settled_actions_on_a_one_action_story(db):
adventure = make_adventure(db, 1)
assert memorybank.settled_story_actions(adventure) == []
# ------------------------------------------------------- the bug this prevents
def test_memory_never_covers_the_newest_retryable_action(db, monkeypatch):
"""Covered up to action 5 with 12 actions is exactly the case that used to
bite: the 6-action block ends on the newest action, still retryable."""
This is exactly the case the holdback existed to refuse. What makes it safe
is no longer that the block stops short — it is that a retry of node 11
would withdraw this memory on its way past (see
`test_deleting_a_summarized_node_withdraws_its_memory`, the same repair).
"""
adventure = make_adventure(db, 12)
cover(db, adventure, 6)
stub = StubSummarizer()
run_memories(db, adventure, stub, monkeypatch)
assert stub.excerpts == [] # only 11 settled — one short of a block
assert db.query(models.Memory).count() == 0
assert covered_depth(db, adventure) == 5
def test_the_block_lands_a_turn_later_without_the_newest_action(db, monkeypatch):
"""One more action and the same block is summarized — minus the new one."""
adventure = make_adventure(db, 13)
cover(db, adventure, 6)
stub = StubSummarizer()
run_memories(db, adventure, stub, monkeypatch)
assert len(stub.excerpts) == 1
excerpt = stub.excerpts[0]
assert "Action 11." in excerpt # the block's real last action
assert "Action 12." not in excerpt # the newest, still retryable
assert "Action 11." in stub.excerpts[0]
memory = db.query(models.Memory).one()
assert (memory.source_start, memory.source_end) == (6, 11)
# The mark and the memory name the same node — that is what keeps them from
@@ -162,28 +134,31 @@ def test_the_block_lands_a_turn_later_without_the_newest_action(db, monkeypatch)
assert covered_depth(db, adventure) == 11
def test_first_memory_waits_one_action_past_memory_start(db, monkeypatch):
adventure = make_adventure(db, memorybank.MEMORY_START)
def test_the_first_memory_lands_at_memory_start(db, monkeypatch):
adventure = make_adventure(db, memorybank.MEMORY_START - 1)
stub = StubSummarizer()
run_memories(db, adventure, stub, monkeypatch)
assert stub.excerpts == []
assert stub.excerpts == [] # too short to have started at all
db.add(models.Action(
adventure_id=adventure.id, index=memorybank.MEMORY_START, type="do", text="Later.",
adventure_id=adventure.id, index=memorybank.MEMORY_START - 1,
type="do", text="Later.",
))
db.commit()
db.refresh(adventure)
run_memories(db, adventure, stub, monkeypatch)
# 12 settled actions = two full blocks, caught up in one run (MAX_MEMORIES_
# PER_RUN allows 5); neither may reach the newly added newest action.
# MEMORY_START is 12 actions = two full blocks, caught up in one run
# (MAX_MEMORIES_PER_RUN allows 5), and the newest is in the second of them.
assert len(stub.excerpts) == 2
assert not any("Later." in e for e in stub.excerpts)
assert "Later." in stub.excerpts[-1]
assert covered_depth(db, adventure) == memorybank.MEMORY_START - 1
def test_legacy_caught_up_adventure_is_not_rewound(db, monkeypatch):
"""An adventure summarized under the OLD rule carries a cursor equal to its
action count — one past the settled end. That used to need a clamp on every
post-turn pass, and clamping it to the *settled* count re-covered an action.
action count — one past the end of the story. That used to need a clamp on
every post-turn pass, and clamping it to the settled count re-covered an
action.
A mark that names a node has no such edge: the newest action is the node,
and "everything after it" is empty until the story grows.
@@ -194,7 +169,7 @@ def test_legacy_caught_up_adventure_is_not_rewound(db, monkeypatch):
cover(db, adventure, 12)
assert covered_depth(db, adventure) == 11 # the newest action, not one past it
assert memorybank.settled_after(adventure, covered_depth(db, adventure)) == -1
assert history.count_after(adventure, covered_depth(db, adventure)) == 0
# Grow the story and let the next block form.
for i in range(12, 25):
+69 -35
View File
@@ -1,6 +1,13 @@
"""Tests for undo/retry rolling back the shared script_state scoreboard
(plan/11-state-revert-and-retry-fix.md).
Phase 14 SP4 turned the snapshots around. An action used to carry the state as
it stood *before* it ran, and rolling back read the snapshot off the action
being removed. It carries what it left *behind* now, and rolling back reads it
off the node in front — which is the same number arrived at from the other
side, and the only version a retry can use: attempts at one turn share a
starting position and differ precisely in their outcome.
Run from the backend dir: python -m pytest tests/test_state_revert.py -v
"""
import os
@@ -17,7 +24,7 @@ os.environ.pop("DATABASE_URL", None)
import pytest
from fastapi import HTTPException
from app import memorybank, models
from app import attempts, memorybank, models
from app.database import Base, SessionLocal, engine
from app.routers import adventures
@@ -44,25 +51,39 @@ def _make_adventure(db, script_state):
return user, adv
def _add(db, adv, index, type_, text="x", state_before=None):
def _add(db, adv, index, type_, text="x", state_after=None):
a = models.Action(
adventure_id=adv.id, index=index, type=type_, text=text,
state_before=state_before,
state_after=state_after,
)
db.add(a)
db.flush()
return a
def _forget_snapshots(db, adv):
"""Blank every outcome, the way a row written before SP4 looks.
Straight SQL, because `tree.stamp_outcome` runs on every flush precisely so
that a node written through the ORM cannot end up without one.
"""
db.query(models.Action).filter_by(adventure_id=adv.id).update(
{"state_after": None, "world_state_after": None}, synchronize_session=False
)
db.commit()
db.expire_all()
# ---------------------------------------------------------------- undo
def test_undo_reverts_state_to_before_the_turn(db):
# A turn took the scoreboard from {gold:0} -> {gold:10}. The player action
# carries the pre-turn snapshot; current state is the mutated one.
# A turn took the scoreboard from {gold:0} -> {gold:10}. The node in front
# of the turn is what says where it started; current state is the mutated
# one.
user, adv = _make_adventure(db, {"gold": 10})
_add(db, adv, 0, "start", state_before=None)
_add(db, adv, 1, "do", state_before={"gold": 0})
_add(db, adv, 2, "ai", state_before={"gold": 0})
_add(db, adv, 0, "start", state_after={"gold": 0})
_add(db, adv, 1, "do", state_after={"gold": 0})
_add(db, adv, 2, "ai", state_after={"gold": 10})
db.commit()
adventures.undo_turn(adv.id, db=db, user=user)
@@ -71,12 +92,12 @@ def test_undo_reverts_state_to_before_the_turn(db):
assert [a.type for a in adv.actions] == ["start"]
def test_undo_of_bare_continue_uses_ai_snapshot(db):
# A "continue" turn has no player action; the AI action's own snapshot is
# the pre-turn state.
def test_undo_of_bare_continue_uses_the_node_in_front(db):
# A "continue" turn has no player action, so the opening is what the story
# falls back to.
user, adv = _make_adventure(db, {"gold": 5})
_add(db, adv, 0, "start")
_add(db, adv, 1, "ai", state_before={"gold": 0})
_add(db, adv, 0, "start", state_after={"gold": 0})
_add(db, adv, 1, "ai", state_after={"gold": 5})
db.commit()
adventures.undo_turn(adv.id, db=db, user=user)
@@ -86,12 +107,13 @@ def test_undo_of_bare_continue_uses_ai_snapshot(db):
def test_undo_leaves_state_untouched_when_snapshot_missing(db):
# Pre-migration actions have state_before = NULL: don't clobber the state.
# A row the SP4 migration could not derive an outcome for: leave the live
# state alone rather than resetting it to nothing.
user, adv = _make_adventure(db, {"gold": 10})
_add(db, adv, 0, "start")
_add(db, adv, 1, "do", state_before=None)
_add(db, adv, 2, "ai", state_before=None)
db.commit()
_add(db, adv, 1, "do")
_add(db, adv, 2, "ai")
_forget_snapshots(db, adv)
adventures.undo_turn(adv.id, db=db, user=user)
@@ -110,7 +132,7 @@ def test_undo_raises_when_nothing_to_undo(db):
def test_undo_blocked_by_active_turn_lock(db):
user, adv = _make_adventure(db, {})
_add(db, adv, 0, "start")
_add(db, adv, 1, "ai", state_before={})
_add(db, adv, 1, "ai", state_after={})
db.commit()
adventures.acquire_turn_lock(adv.id) # a turn is "generating"
@@ -127,7 +149,7 @@ def test_undo_blocked_by_active_turn_lock(db):
def test_undo_prunes_memory_covering_removed_actions(db):
user, adv = _make_adventure(db, {})
for i in range(4):
_add(db, adv, i, "ai" if i % 2 else "do", state_before={})
_add(db, adv, i, "ai" if i % 2 else "do", state_after={})
# A memory summarizing actions up to index 3, which undo will delete.
covering = models.Memory(adventure_id=adv.id, text="m", source_start=0, source_end=3)
keep = models.Memory(adventure_id=adv.id, text="k", source_start=0, source_end=1)
@@ -167,28 +189,40 @@ def test_forget_node_withdraws_only_what_that_node_produced(db):
# ---------------------------------------------------------------- snapshot
def test_snapshot_state_is_an_independent_deep_copy(db):
def test_snapshot_outcome_is_an_independent_deep_copy(db):
_, adv = _make_adventure(db, {"nested": {"n": 1}})
snap = adventures.snapshot_state(adv)
node = models.Action(adventure_id=adv.id, index=0, type="ai", text="x")
attempts.snapshot_outcome(adv, node)
adv.script_state["nested"]["n"] = 99
assert snap == {"nested": {"n": 1}} # unaffected by later mutation
assert node.state_after == {"nested": {"n": 1}} # unaffected by later mutation
def test_snapshot_state_handles_non_dict(db):
def test_snapshot_outcome_handles_non_dict(db):
_, adv = _make_adventure(db, {})
adv.script_state = None
assert adventures.snapshot_state(adv) == {}
node = models.Action(adventure_id=adv.id, index=0, type="ai", text="x")
attempts.snapshot_outcome(adv, node)
assert node.state_after == {}
def test_restore_state_ignores_a_node_with_no_outcome(db):
_, adv = _make_adventure(db, {"gold": 7})
attempts.restore_state(adv, models.Action(adventure_id=adv.id, index=0, type="ai"))
assert adv.script_state == {"gold": 7}
attempts.restore_state(adv, None)
assert adv.script_state == {"gold": 7}
# ---------------------------------------------------------------- retry
def test_retry_restores_state_before_regenerating(db, monkeypatch):
# Retry must roll the scoreboard back to the AI action's snapshot so
# regeneration doesn't stack output mutations on the discarded attempt.
def test_retry_restores_the_state_the_turn_started_from(db, monkeypatch):
# Retry must roll the scoreboard back to what the node in front of the AI
# action left behind, so regeneration doesn't stack output mutations on top
# of the attempt being replaced.
user, adv = _make_adventure(db, {"gold": 20}) # 20 = double-applied bug value
_add(db, adv, 0, "start")
_add(db, adv, 1, "do", state_before={"gold": 0})
_add(db, adv, 2, "ai", state_before={"gold": 10})
_add(db, adv, 0, "start", state_after={"gold": 0})
_add(db, adv, 1, "do", state_after={"gold": 10})
_add(db, adv, 2, "ai", state_after={"gold": 20})
db.commit()
monkeypatch.setattr(adventures.limits, "rate_limit", lambda *a, **k: None)
@@ -202,11 +236,11 @@ def test_retry_restores_state_before_regenerating(db, monkeypatch):
adventures.retry_action(adv.id, request=None, db=db, user=user)
assert adv.script_state == {"gold": 10}
# The row survives now (it used to be deleted) so the discarded attempt
# stays readable — it's kept as variant 0.
# Nothing is written until a replacement actually arrives: the attempt on
# screen is left exactly as it was, and stays the live one.
assert [a.type for a in adv.actions] == ["start", "do", "ai"]
last = adv.actions[-1]
assert len(last.variants) == 1
assert last.variant_index == 0
assert last.variants[0]["script_state"] == {"gold": 20} # the attempt's outcome
assert last.live is True
assert last.variant_count == 0
assert last.state_after == {"gold": 20} # its own outcome, untouched
adventures._active_turns.discard(adv.id)
+217 -1
View File
@@ -29,7 +29,8 @@ from fastapi import Depends
from fastapi.testclient import TestClient
from sqlalchemy import text
from app import auth, limits, migrations, models, tree
from app import auth, compression, limits, migrations, models, tree
from app.context import history
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
@@ -526,3 +527,218 @@ def test_deleting_the_newest_action_moves_the_head_back(client):
assert db.get(models.Adventure, adventure_id).head_depth == 0
finally:
db.close()
# ---------------------------------------- SP4: variants become sibling rows
# One turn's retry history as schema 45 stored it: a JSON array on the AI row,
# with `variant_index` naming the entry `text` mirrors. The live one is
# deliberately not the last written — a migration that assumed it was would
# look right on every fixture where the player never went back.
RETRY_VARIANTS = [
{"text": "Attempt one.", "reasoning": None,
"script_state": {"gold": 10}, "created_at": "2026-01-01T00:00:00",
"raw_output": "Attempt one.",
"world_state": {"delta": {"player.hp": -5},
"report": {"applied": [{"path": "player.hp", "old": 100, "new": 95}]},
"state": {"player": {"hp": 95}}}},
{"text": "Attempt two.", "reasoning": "thinking",
"script_state": {"gold": 20}, "created_at": "2026-01-01T00:01:00",
"raw_output": "Attempt two.",
"world_state": {"delta": {"player.hp": -40},
"report": {"applied": [{"path": "player.hp", "old": 100, "new": 60}]},
"state": {"player": {"hp": 60}}}},
{"text": "Attempt three.", "reasoning": None,
"script_state": {"gold": 30}, "created_at": "2026-01-01T00:02:00",
"raw_output": "Attempt three."},
]
LIVE_VARIANT = 1
# The whole turn's assembled prompt, stored once. The attempts differ only in
# the three slices above, which is the arrangement SP4 has to preserve — giving
# each sibling a copy of this would multiply the biggest column in the database
# by the retry count.
RETRY_SNAPSHOT = {
"sections": [{"label": "history", "text": "A long prompt.", "tokens": 4}],
"prompt": {"system": "S", "story": "A long prompt."},
"raw_output": "Attempt two.",
"script": {"logs": []},
"world_state": RETRY_VARIANTS[LIVE_VARIANT]["world_state"],
}
@pytest.fixture()
def pre_split():
"""A schema-45 adventure with one retried turn, plus a plain turn each side.
Separate from `pre_tree` so SP1's assertions keep counting what they were
written to count. The story is: 0 start, 1 do, 2 ai (three attempts), 3 do,
and the adventure's live state is the one attempt 1 produced.
"""
Base.metadata.drop_all(bind=engine)
Base.metadata.create_all(bind=engine)
with engine.begin() as conn:
for table in ("actions", "memories", "branches", "adventures"):
conn.execute(text(f"DROP TABLE IF EXISTS {table}"))
for ddl in PRE_TREE_DDL:
conn.execute(text(ddl))
conn.execute(text(
"INSERT INTO users (id, email, is_guest, created_at, demo_turns_used, "
"demo_turns_date) VALUES (1, 'v45@example.com', 0, CURRENT_TIMESTAMP, 0, '')"
))
conn.execute(text(
"INSERT INTO adventures (user_id, title, script_state, world_state) "
"VALUES (1, 'Retried', :script, :world)"
), {"script": json.dumps({"gold": 20}),
"world": json.dumps({"player": {"hp": 60}})})
adventure_id = conn.execute(
text("SELECT id FROM adventures WHERE title = 'Retried'")
).scalar()
# `state_before` on each row: the scoreboard as that action found it.
# SP4 reads them one row along to build the `state_after` pair.
for index, kind, before in (
(0, "start", None), (1, "do", {"gold": 0}),
(2, "ai", {"gold": 0}), (3, "do", {"gold": 20}),
):
conn.execute(text(
'INSERT INTO actions (adventure_id, "index", type, text, reasoning, '
"state_before, context_snapshot, variants, variant_count, variant_index) "
"VALUES (:a, :i, :t, :x, :r, :sb, :cs, :v, :vc, :vi)"
), {
"a": adventure_id, "i": index, "t": kind,
"x": RETRY_VARIANTS[LIVE_VARIANT]["text"] if kind == "ai" else f"Turn {index}.",
"r": RETRY_VARIANTS[LIVE_VARIANT]["reasoning"] if kind == "ai" else None,
"sb": None if before is None else json.dumps(before),
"cs": compression.pack(RETRY_SNAPSHOT) if kind == "ai" else None,
"v": json.dumps(RETRY_VARIANTS) if kind == "ai" else None,
"vc": len(RETRY_VARIANTS) if kind == "ai" else 0,
"vi": LIVE_VARIANT if kind == "ai" else 0,
})
conn.execute(text("PRAGMA user_version = 45"))
try:
yield adventure_id
finally:
Base.metadata.drop_all(bind=engine)
def _attempts(adventure_id) -> list[tuple]:
return rows(
"SELECT variant_index, text, live, variant_count FROM actions "
'WHERE adventure_id = :a AND "index" = 2 ORDER BY variant_index',
a=adventure_id,
)
def test_each_attempt_becomes_a_row_at_the_turns_coordinate(pre_split):
migrations.bootstrap(engine)
assert _attempts(pre_split) == [
(0, "Attempt one.", 0, 3),
(1, "Attempt two.", 1, 3),
(2, "Attempt three.", 0, 3),
]
# One turn, one coordinate: the siblings share a branch and a depth, and
# keep the legacy index that says which turn they are all takes on.
coordinates = rows(
'SELECT DISTINCT branch_id, depth FROM actions WHERE adventure_id = :a '
'AND "index" = 2', a=pre_split,
)
assert len(coordinates) == 1
# ...and the rest of the story is untouched, still one row per turn.
assert scalar("SELECT count(*) FROM actions WHERE adventure_id = :a", a=pre_split) == 6
def test_the_live_attempt_is_the_one_the_row_was_mirroring(pre_split):
"""`variant_index` is the only record of which take the player was reading,
and it survives as the `live` flag. Guessing "the newest" instead would
silently rewrite the story of anyone who had paged back."""
migrations.bootstrap(engine)
live = rows(
"SELECT text FROM actions WHERE adventure_id = :a AND live = 1 "
'AND "index" = 2', a=pre_split,
)
assert live == [("Attempt two.",)]
def test_the_prompt_stays_on_the_live_attempt_and_nowhere_else(pre_split):
migrations.bootstrap(engine)
holders = []
for variant_index, snapshot in rows(
'SELECT variant_index, context_snapshot FROM actions WHERE adventure_id = :a '
'AND "index" = 2 ORDER BY variant_index', a=pre_split,
):
stored = compression.unpack(snapshot) if snapshot else {}
if "sections" in stored:
holders.append(variant_index)
else:
# A superseded attempt keeps only what was its own.
assert set(stored) <= set(migrations._ATTEMPT_KEYS)
assert holders == [LIVE_VARIANT]
def test_each_attempt_keeps_the_outcome_it_produced(pre_split):
migrations.bootstrap(engine)
parsed = [
(i, json.loads(state), json.loads(world) if world else None)
for i, state, world in rows(
"SELECT variant_index, state_after, world_state_after FROM actions "
'WHERE adventure_id = :a AND "index" = 2 ORDER BY variant_index',
a=pre_split,
)
]
assert [(i, s) for i, s, _ in parsed] == [
(0, {"gold": 10}), (1, {"gold": 20}), (2, {"gold": 30})
]
assert parsed[0][2] == {"player": {"hp": 95}}
assert parsed[1][2] == {"player": {"hp": 60}}
# Attempt three recorded no world state — an adventure with no RPG layer,
# or a take made before the column existed. It stays NULL rather than
# borrowing a neighbour's, and switching to it leaves the RPG layer alone:
# exactly what `apply_variant` did with an entry that had no world state.
assert parsed[2][2] is None
def test_state_after_is_the_state_before_of_the_turn_in_front(pre_split):
migrations.bootstrap(engine)
after = dict(rows(
'SELECT "index", state_after FROM actions WHERE adventure_id = :a '
"AND live = 1 ORDER BY depth", a=pre_split,
))
# Action 1's outcome is action 2's starting position, exactly.
assert json.loads(after[1]) == {"gold": 0}
# The tip has nothing in front of it, so what it left behind is what the
# adventure is carrying now.
assert json.loads(after[3]) == {"gold": 20}
def test_the_split_survives_being_run_again(pre_split):
migrations.bootstrap(engine)
snapshot = _attempts(pre_split)
before = scalar("SELECT count(*) FROM actions")
migrations.bootstrap(engine)
with engine.begin() as conn:
migrations._backfill_state_after(conn)
migrations._split_variants_into_siblings(conn)
assert scalar("SELECT count(*) FROM actions") == before, "attempts were duplicated"
assert _attempts(pre_split) == snapshot
def test_the_migrated_story_reads_back_as_one_turn(pre_split):
"""The point of all of it: the reads see a four-action story, not six."""
migrations.bootstrap(engine)
db = SessionLocal()
try:
adventure = db.get(models.Adventure, pre_split)
assert [a.text for a in history.story_actions(adventure)] == [
"Turn 0.", "Turn 1.", "Attempt two.", "Turn 3.",
]
assert history.count(adventure) == 4
finally:
db.close()