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:
committed by
Parth
co-authored by
Claude Opus 5
parent
c51531709d
commit
0a12d9cd47
@@ -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
|
||||
@@ -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]}"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user