diff --git a/backend/tests/test_story_tree_baseline.py b/backend/tests/test_story_tree_baseline.py new file mode 100644 index 0000000..3745266 --- /dev/null +++ b/backend/tests/test_story_tree_baseline.py @@ -0,0 +1,504 @@ +"""Phase 14 SP0 — the regression contract for the story tree. + +This file exists to answer one question, over and over, as the storage model is +replaced underneath the app: **do existing adventures still behave exactly as +they did?** + +So it drives the product the way a player does — over HTTP, asserting only on +API responses — and never reaches into the ORM to check how something is +stored. Everything it asserts is true of the linear implementation today and +must stay true of the tree, because a linear story is a tree with one branch. + + python -m pytest tests/test_story_tree_baseline.py -v + +**It must pass UNMODIFIED through SP1 (schema), SP2 (branch clause) and SP3 +(memories on nodes).** If a change here looks necessary in one of those +subphases, the change is wrong, not the test. SP4 is the first subphase allowed +to move it, and only for the variant-count semantics called out in plan/14. +""" +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 app import auth, limits, models +from app.database import Base, SessionLocal, engine, get_db +from app.main import app +from app.providers import PromptParts +from app.routers import adventures + +# A world-state schema, so the RPG layer is exercised rather than skipped. +SCHEMA = {"player": {"hp": {"min": 0, "max": 100, "initial": 100}}} + +# Ten gold a turn. A turn that double-applies its hooks, or one that fails to +# roll back on retry, shows up here as a wrong total rather than as nothing. +GOLD_SCRIPT = """ +const modifier = (text) => { + state.gold = (state.gold || 0) + 10; + return { text }; +}; +modifier(text); +""" + +OPENING = "You enter a cave." + + +class ScriptedProvider: + """Streams the next canned reply each call, so successive turns differ.""" + + replies: list = [] + calls = 0 + prompts: list = [] # every assembled (system, story) pair + + 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)) + reply = ScriptedProvider.replies[index] + if isinstance(reply, Exception): + raise reply + yield ("text", reply) + + +def _make_world(monkeypatch, *, seeded_actions: int = 0): + """A user + scenario + adventure, with `seeded_actions` extra story actions + written straight to the database (paging wants more turns than it is worth + playing one at a time).""" + Base.metadata.create_all(bind=engine) + setup = SessionLocal() + user = models.User(is_guest=False, email="baseline@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=OPENING)) + for i in range(seeded_actions): + setup.add(models.Action( + adventure_id=adv.id, index=i + 1, + type="ai" if i % 2 else "do", + text=f"Seeded turn {i}.", + )) + 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 = ["A reply."] + 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 + return c + + +@pytest.fixture() +def client(monkeypatch): + c = _make_world(monkeypatch) + try: + yield c + finally: + app.dependency_overrides.clear() + adventures._active_turns.clear() + Base.metadata.drop_all(bind=engine) + + +@pytest.fixture() +def long_client(monkeypatch): + """An adventure longer than one window, so paging is real.""" + c = _make_world(monkeypatch, seeded_actions=adventures.ACTION_PAGE + 10) + try: + yield c + finally: + app.dependency_overrides.clear() + adventures._active_turns.clear() + Base.metadata.drop_all(bind=engine) + + +# ------------------------------------------------------------------ helpers + +def _open(client): + r = client.get(f"/api/adventures/{client.adv_id}") + assert r.status_code == 200, r.text + return r.json() + + +def _actions(client): + return _open(client)["actions"] + + +def _texts(client): + return [a["text"] for a in _actions(client)] + + +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 + return r + + +def _state(adv_id): + db = SessionLocal() + try: + adv = db.get(models.Adventure, adv_id) + return adv.script_state, adv.world_state + finally: + db.close() + + +# ------------------------------------------------------- opening the story + +def test_adventure_opens_on_a_window_with_a_total(long_client): + """The payload is the newest window plus the whole story's length — that is + how the client knows there is more above.""" + payload = _open(long_client) + seeded = adventures.ACTION_PAGE + 11 # + the start action + assert payload["action_count"] == seeded + assert len(payload["actions"]) == adventures.ACTION_PAGE + # The newest window, so it ends at the newest action. + assert payload["actions"][-1]["text"] == f"Seeded turn {seeded - 2}." + + +def test_short_adventure_returns_everything(client): + payload = _open(client) + assert payload["action_count"] == 1 + assert [a["text"] for a in payload["actions"]] == [OPENING] + + +# --------------------------------------------------------------- the turn + +def test_a_turn_appends_the_player_action_then_the_ai_action(client): + ScriptedProvider.replies = ["The dark presses in."] + _play(client, "light a torch") + actions = _actions(client) + assert [a["type"] for a in actions] == ["start", "do", "ai"] + assert actions[1]["text"] == "> You light a torch." + assert actions[2]["text"] == "The dark presses in." + + +def test_say_and_story_and_continue_all_work(client): + ScriptedProvider.replies = ["One.", "Two.", "Three."] + _play(client, "hello", type="say") + _play(client, "The wind rises.", type="story") + _play(client, "", type="continue") + types = [a["type"] for a in _actions(client)] + assert types == ["start", "say", "ai", "story", "ai", "ai"] + + +def test_the_story_so_far_is_replayed_into_the_prompt(client): + ScriptedProvider.replies = ["First.", "Second."] + _play(client, "go north") + _play(client, "go south") + story = ScriptedProvider.prompts[-1][1] + assert OPENING in story + assert "First." in story + assert "go north" in story + + +def test_scripts_run_once_per_turn(client): + """The gold script adds ten a turn. Two turns is twenty — not forty.""" + ScriptedProvider.replies = ["One.", "Two."] + _play(client) + _play(client) + script_state, _ = _state(client.adv_id) + assert script_state["gold"] == 20 + + +# ------------------------------------------------------------------ retry + +def test_retry_replaces_the_text_and_keeps_the_attempt(client): + ScriptedProvider.replies = ["Attempt one.", "Attempt two."] + _play(client) + assert _texts(client)[-1] == "Attempt one." + + r = client.post(f"/api/adventures/{client.adv_id}/retry") + assert r.status_code == 200, r.text + + actions = _actions(client) + # Still one AI action for this turn — a retry is a new take, not a new turn. + assert [a["type"] for a in actions] == ["start", "do", "ai"] + assert actions[-1]["text"] == "Attempt two." + + r = client.get( + f"/api/adventures/{client.adv_id}/actions/{actions[-1]['id']}/variants") + assert r.status_code == 200, r.text + assert [v["text"] for v in r.json()] == ["Attempt one.", "Attempt two."] + assert [v["active"] for v in r.json()] == [False, True] + + +def test_retry_does_not_stack_script_effects(client): + """The discarded attempt's ten gold is rolled back, so one turn plus one + retry is still ten, not twenty.""" + ScriptedProvider.replies = ["Attempt one.", "Attempt two."] + _play(client) + client.post(f"/api/adventures/{client.adv_id}/retry") + script_state, _ = _state(client.adv_id) + assert script_state["gold"] == 10 + + +def test_switching_back_to_an_earlier_attempt_restores_it(client): + ScriptedProvider.replies = ["Attempt one.", "Attempt two."] + _play(client) + client.post(f"/api/adventures/{client.adv_id}/retry") + action_id = _actions(client)[-1]["id"] + + r = client.post( + f"/api/adventures/{client.adv_id}/actions/{action_id}/variant", + json={"index": 0}) + assert r.status_code == 200, r.text + assert _texts(client)[-1] == "Attempt one." + # And the script state that attempt produced comes back with it. + script_state, _ = _state(client.adv_id) + assert script_state["gold"] == 10 + + +def test_only_the_newest_turn_can_be_switched(client): + """An older turn's alternatives stay readable but not selectable — the + story after it was written as a continuation of what is live.""" + ScriptedProvider.replies = ["One.", "Again.", "Two."] + _play(client) + client.post(f"/api/adventures/{client.adv_id}/retry") + older_id = _actions(client)[-1]["id"] + _play(client) # the story moves on + + r = client.post( + f"/api/adventures/{client.adv_id}/actions/{older_id}/variant", + json={"index": 0}) + assert r.status_code == 400 + + +# ------------------------------------------------------------------- undo + +def test_undo_removes_the_whole_turn_and_rolls_state_back(client): + ScriptedProvider.replies = ["One.", "Two."] + _play(client, "go north") + _play(client, "go south") + assert len(_actions(client)) == 5 + + r = client.post(f"/api/adventures/{client.adv_id}/undo") + assert r.status_code == 200, r.text + page = r.json() + # Both the AI action and the player action that prompted it are gone. + assert [a["type"] for a in page["actions"]] == ["start", "do", "ai"] + assert page["total"] == 3 + # ...and the second turn's ten gold went with them. + script_state, _ = _state(client.adv_id) + assert script_state["gold"] == 10 + + +def test_undo_returns_a_window_not_the_whole_story(long_client): + before = _open(long_client)["action_count"] + r = long_client.post(f"/api/adventures/{long_client.adv_id}/undo") + assert r.status_code == 200, r.text + page = r.json() + assert len(page["actions"]) == adventures.ACTION_PAGE + # The seeded story ends on an `ai` preceded by a `do`, so a turn is two + # actions and undo takes both. + assert page["total"] == before - 2 + assert page["has_more"] is True + + +def test_the_opening_cannot_be_undone(client): + r = client.post(f"/api/adventures/{client.adv_id}/undo") + assert r.status_code == 400 + + +# ------------------------------------------------------------------ paging + +def test_paging_walks_back_to_the_start_without_gaps_or_repeats(long_client): + """The property that matters: page all the way up and you have seen every + action exactly once, in order. This is the invariant a tree must preserve — + the anchor is an action, never a position.""" + payload = _open(long_client) + total = payload["action_count"] + seen = [a["id"] for a in payload["actions"]] + + has_more = len(seen) < total + while has_more: + r = long_client.get( + f"/api/adventures/{long_client.adv_id}/actions", + params={"before_id": seen[0]}) + assert r.status_code == 200, r.text + page = r.json() + ids = [a["id"] for a in page["actions"]] + assert ids, "a page claiming more must return some" + seen = ids + seen + has_more = page["has_more"] + + assert len(seen) == total + assert len(set(seen)) == total, "an action was served twice" + assert seen == sorted(seen), "pages came back out of order" + + +def test_a_page_is_bounded_by_the_requested_limit(long_client): + r = long_client.get(f"/api/adventures/{long_client.adv_id}/actions", + params={"limit": 5}) + assert r.status_code == 200, r.text + page = r.json() + assert len(page["actions"]) == 5 + assert page["has_more"] is True + assert page["total"] == adventures.ACTION_PAGE + 11 + + +def test_paging_past_the_start_reports_the_end(long_client): + r = long_client.get(f"/api/adventures/{long_client.adv_id}/actions", + params={"limit": 5}) + oldest = r.json()["actions"][0]["id"] + # Walk right off the front. + seen_all = False + cursor = oldest + for _ in range(100): + page = long_client.get( + f"/api/adventures/{long_client.adv_id}/actions", + params={"before_id": cursor, "limit": 20}).json() + if not page["has_more"]: + seen_all = True + break + cursor = page["actions"][0]["id"] + assert seen_all + + +# ------------------------------------------------------- editing and deleting + +def test_editing_an_action_sticks(client): + ScriptedProvider.replies = ["Original."] + _play(client) + action_id = _actions(client)[-1]["id"] + r = client.patch(f"/api/adventures/{client.adv_id}/actions/{action_id}", + json={"text": "Edited."}) + assert r.status_code == 200, r.text + # Re-read rather than trusting the response: an edit that only lives in the + # response has silently reverted for anyone who reloads. + assert _texts(client)[-1] == "Edited." + + +def test_editing_a_retried_action_survives_a_reload(client): + """The edit has to reach the live attempt too, or paging away and back + reverts it.""" + ScriptedProvider.replies = ["One.", "Two."] + _play(client) + client.post(f"/api/adventures/{client.adv_id}/retry") + action_id = _actions(client)[-1]["id"] + client.patch(f"/api/adventures/{client.adv_id}/actions/{action_id}", + json={"text": "Edited."}) + assert _texts(client)[-1] == "Edited." + + +def test_deleting_an_action_removes_it(client): + ScriptedProvider.replies = ["One."] + _play(client) + action_id = _actions(client)[-1]["id"] + r = client.delete(f"/api/adventures/{client.adv_id}/actions/{action_id}") + assert r.status_code == 204, r.text + assert len(_actions(client)) == 2 + + +# ---------------------------------------------------------------- memories + +def test_memories_are_created_listed_and_deleted(client): + r = client.post(f"/api/adventures/{client.adv_id}/memories", + json={"text": "The torch went out."}) + assert r.status_code == 201, r.text + memory_id = r.json()["id"] + + listed = client.get(f"/api/adventures/{client.adv_id}/memories").json() + assert [m["text"] for m in listed] == ["The torch went out."] + + r = client.patch( + f"/api/adventures/{client.adv_id}/memories/{memory_id}", + json={"pinned": True}) + assert r.status_code == 200, r.text + assert r.json()["pinned"] is True + + r = client.delete(f"/api/adventures/{client.adv_id}/memories/{memory_id}") + assert r.status_code == 204, r.text + assert client.get(f"/api/adventures/{client.adv_id}/memories").json() == [] + + +# ------------------------------------------------------------------ export + +def test_export_carries_the_whole_story(client): + """A bundle is a backup: every action, not the window. + + NOTE for SP6 — the `format` assertion below is the one line in this file + expected to change, when the bundle becomes `ai-dnd-adventure-v2`. It is + correct through SP1-SP5. + """ + ScriptedProvider.replies = ["One.", "Two."] + _play(client, "go north") + _play(client, "go south") + + r = client.get(f"/api/adventures/{client.adv_id}/export") + assert r.status_code == 200, r.text + bundle = r.json() + assert bundle["format"] == "ai-dnd-adventure-v1" + assert bundle["title"] == "Cave" + assert [a["text"] for a in bundle["actions"]] == [ + OPENING, "> You go north.", "One.", "> You go south.", "Two.", + ] + + +def test_export_round_trips_through_import(client): + ScriptedProvider.replies = ["One."] + _play(client, "go north") + bundle = client.get(f"/api/adventures/{client.adv_id}/export").json() + + r = client.post("/api/adventures/import", json=bundle) + assert r.status_code == 201, r.text + imported = r.json() + assert imported["id"] != client.adv_id + assert imported["title"] == "Cave" + assert [a["text"] for a in imported["actions"]] == [ + OPENING, "> You go north.", "One.", + ] + + +def test_export_keeps_retry_attempts(client): + ScriptedProvider.replies = ["Attempt one.", "Attempt two."] + _play(client) + client.post(f"/api/adventures/{client.adv_id}/retry") + + bundle = client.get(f"/api/adventures/{client.adv_id}/export").json() + ai = [a for a in bundle["actions"] if a["type"] == "ai"][-1] + assert [v["text"] for v in ai["variants"]] == ["Attempt one.", "Attempt two."] + + +# -------------------------------------------------------------- world state + +def test_world_state_is_readable_and_survives_a_turn(client): + ScriptedProvider.replies = ["Nothing changes."] + _play(client) + r = client.get(f"/api/adventures/{client.adv_id}/world-state") + assert r.status_code == 200, r.text + assert r.json()["state"]["player"]["hp"] == 100 diff --git a/plan/14-phase-story-tree.md b/plan/14-phase-story-tree.md index 5881490..f35b514 100644 --- a/plan/14-phase-story-tree.md +++ b/plan/14-phase-story-tree.md @@ -107,10 +107,192 @@ number — `A4` and `B4` are alternatives, not duplicates. ## Open -- **Export/import bundle format** (`routers/adventures.py:1008-1112`) encodes `variants` - and `variantIndex`. Needs a versioned format change and a legacy reader. - **`retry_of.index` reuse** stops the world-state cooldown clock advancing on a re-run of - the same turn. Whatever replaces it must preserve that. -- **Phased or single migration?** Undecided. A tree touches the memory bank, the context - builder, undo/retry and the UI at once, which argues for phasing behind a flag. -- **What the branch-management UI actually looks like** — unscoped, and it gates release. + the same turn. Whatever replaces it must preserve that. (Carried into SP5 below.) + +Everything else that stood open here was decided on 2026-08-17 — see the next section. + +--- + +# Implementation plan (decided 2026-08-17) + +Four decisions, taken before any code was written: + +| Question | Decision | +|---|---| +| Phased or single migration? | **Structural-first, no runtime flag.** SP1–SP4 migrate to the tree *representation* with behaviour identical to today. Branching features layer on after. | +| How destructive is the migration? | **Keep the legacy columns for one release.** `index`, `variants`, `variant_index`, `variant_count` stay, unread, until the tree is proven live (SP8 drops them). | +| Branch-management UI scope | **Full tree visualisation** — a spatial view of forks, not just a picker. | +| Export bundle | **`ai-dnd-adventure-v2`**, with a v1 legacy reader so existing bundles keep importing. | + +**Why no feature flag.** A linear story *is* a tree with one branch, so the intermediate +states are not half-migrated — they are the same product with a superset schema +underneath. That makes "current adventures are unaffected" a literal, testable pass +condition for every subphase up to SP4, which a flag would have replaced with two live +code paths through the context builder, the memory bank, undo and retry at once. + +## The regression contract + +`tests/test_story_tree_baseline.py` (built in SP0) drives the whole product over HTTP — +create, play, retry, switch, undo, page with `before_id`, memories, summary, export — and +asserts only on API responses, never internals. + +**It must pass unmodified through SP1, SP2 and SP3.** That is the contract those +subphases are verified against. SP4 is the first subphase permitted to change it, and +even there only where the change is deliberate and named below. + +## Traps found while reading (these are the ones that bite quietly) + +- **`history._from_memory()` slices `adventure.actions`.** The "never load twice" + shortcut returns whatever is already in the session — which under a tree is *every + branch's* actions, not the path. It would silently assemble context from siblings. + This is the single highest-risk line in the change; SP2 must make the shortcut + branch-aware or delete it. +- **`scripting/pipeline.py::_history()` and `_info()` read `adventure.actions` directly**, + and hand it to user scripts as the documented history API. Same trap, user-visible. +- **`Adventure.actions` is `order_by="Action.index"`.** Ordering it by `depth` is not + enough — the collection is still every branch. Anything iterating it needs the path. +- **`limits.check_row_cap("actions")` counts every action in the adventure.** With no + auto-pruning, a branched adventure hits `MAX_ACTIONS_PER_ADVENTURE` while its *story* + is far shorter. The cap has to count the tree but be explained as the tree, or move. +- **The holdback cannot die in SP3.** `settled_story_actions` exists because retry + mutates a row in place. Retry stops mutating in SP4, so the holdback is only safe to + delete there — deleting it in SP3 reopens the exact bug it was written for. + +## Subphases + +Each ships independently, on its own branch, green before the next starts. + +### SP0 — Baseline and regression net *(no product change)* + +- `tests/test_story_tree_baseline.py` — the contract above. 24 tests covering opening a + windowed adventure, the turn engine (do/say/story/continue), script effects, retry and + variant switching, undo, paging up to the start, edit/delete, memories, export/import + round-trip, and world state. + +**Done, 2026-08-17.** 259 existing tests green (17.8s), then **283 green** with the +baseline added, against unmodified `main`. That is the number every subphase below is +measured against. + +A pre-migration (**schema 45**) fixture is needed too, built by a script rather than +committed as a binary — but its only consumer is the SP1 migration test, so it lands +there. + +### SP1 — Schema and migration *(no behaviour change)* + +| File | Change | +|---|---| +| `app/models.py` | New `Branch` model. `Action.branch_id`, `Action.depth`. `Adventure.head_branch_id`, `head_depth`. `Memory.branch_id`, `Memory.depth`. Legacy columns untouched. | +| `app/migrations.py` | Migrations 46+: create `branches`; add columns; index `(branch_id, depth)`; backfill. Dialect map wherever BLOB/BYTEA-style spellings diverge. | + +Backfill: one branch per adventure, `lineage = [(A, ∞)]`; `actions.branch_id = A`, +`depth = index`; `adventures.head_*` from `max(index)`; memories take branch A and a +depth derived from `source_end`. + +**Verify:** baseline test unmodified. New `tests/test_tree_migration.py` — every action +carries a branch and `depth == old index`, no row lost, head pointers correct, memories +mapped. Bootstrap run twice is a no-op. `tests/test_egress.py` ceilings unmoved (two +integers a row). **Post-deploy `VACUUM FULL actions;` is mandatory** — this rewrites +every row, which is the 144 MB lesson at the top of `STATUS.md`. + +### SP2 — The branch clause *(reads move to lineage; still one branch)* + +One module owns the clause; every action read goes through it. A forgotten clause shows +the wrong story, quietly, which is why it is one module and not a convention. + +| File | Change | +|---|---| +| `app/context/lineage.py` *(new)* | Lineage computation and the branch clause. The only place that knows how a path is selected. | +| `app/context/history.py` | `_filters` takes the clause; order by `depth`; `window_covering` keeps its shape. **Fix `_from_memory`.** | +| `app/routers/adventures.py` | `action_window` anchors on the anchor's `depth`; `last_action`, `next_index`→`next_depth`, `_latest_narration`. | +| `app/scripting/pipeline.py` | `_history()`/`_info()` read the path, not the collection. | + +**Verify:** baseline test unmodified. `test_history_window.py`, `test_action_paging.py` +green. New test builds a **two-branch fixture directly in the DB** and asserts the design +doc's own example reads back as `A0 A1 A2 A3 B4 B5 C6 C7`, and that a sibling's nodes are +invisible. Egress: a 20-fork fixture costs within a small factor of a 1-fork one — +clause count is bounded by the context window, not by fork count. + +### SP3 — Memories and summary attach to nodes + +Cursors stop being positions in a shifting list. `memory_cursor`/`summary_cursor` become +node-anchored `(branch_id, depth)`. + +Deleted: `position_of_index`, `note_action_removed`, `_rewind_cursors_to_index`, +`prune_dangling_memories`, and their call sites in `undo_turn` and `delete_action`. +**Kept until SP4:** `settled_story_actions` and the holdback (see traps). + +**Verify:** baseline test unmodified. `test_memory_settling.py` and +`test_memory_retrieval.py` updated, plus a new branch-isolation test — a memory created +on branch B is invisible from branch A, and shared ancestors are visible from both. +Memory retrieval reads the *full* lineage (it cannot be windowed) but stays sparse: +assert the byte cost on a deep fork. + +### SP4 — Variants become sibling nodes + +Retry stops mutating a row. It writes a sibling leaf at the same depth. + +Deleted: `set_variants`, `variant_of`, `apply_variant`, `VARIANT_SNAPSHOT_KEYS`, and the +holdback. Node state moves from *before* to *after* snapshots (`state_after`, +`world_state_after`) — a sibling needs its own outcome, so this belongs here rather than +in its own subphase. Pre-column rows are NULL and must stay tolerated. + +A migration converts existing `variants` JSON into sibling rows — reading the legacy +column that decision 2 kept, which is the whole reason it was kept. + +`/variants` and `/variant` keep their URLs and response shapes here, re-implemented over +sibling rows, so the frontend keeps working until SP7 replaces it. + +**Verify:** the first subphase allowed to move the baseline test, and only for +`variant_count`/`variant_index` semantics — the retry *outcomes* must not move. +`test_retry_variants.py` rewritten against siblings but asserting the same observable +results, including that the gold script is not double-applied. `test_state_revert.py` +against after-snapshots. Migration test: attempt count preserved, the active attempt +becomes the head. + +### SP5 — Fork on continue + +Playing past a non-head sibling promotes it: a `branches` row with `parent_branch_id`, +`fork_depth`, and a `lineage` computed once from the parent's. Nothing is copied. +`adventures.head_*` moves. **The cooldown clock must not advance on a re-run of the same +turn** — the one item still open above. + +**Verify:** a fork creates exactly one branch row and copies no actions; lineage is +correct and capped at each `fork_depth`; both branches read independently; switching +restores the right script/world state; the cooldown test from `test_worldstate.py` +still holds across a retry. + +### SP6 — Export/import v2 + +`ai-dnd-adventure-v2` carries branches and nodes; import accepts v1 and v2, mapping a v1 +bundle's linear actions plus `variants` onto one branch with siblings. +`limits.check_bundle_lists` learns about branches. + +**Verify:** v2 round-trip of a branched adventure is lossless; a v1 bundle still imports; +a bundle claiming more branches than rows is rejected rather than half-applied. + +### SP7 — Frontend: full tree visualisation + +`VariantPager` is removed. A spatial tree view replaces it, plus switch, rename and +delete-with-confirm. `api.js` gains the branch endpoints. + +**Verify:** this is where the standing open gap gets closed — **drive the 600-action +`--keep` fixture in a browser by hand**, on the same scroll path that has never been +driven and already hid one bug. A vitest + jsdom harness covers the prepend arithmetic; +jsdom has no layout, so scroll position still needs eyes. + +### SP8 — Drop the legacy columns + +Only once the tree is proven live. Migration drops `index`, `variants`, `variant_index`, +`variant_count`, followed by `VACUUM FULL actions;`. + +**Verify:** full suite; egress ceilings; a measured before/after size, aggregates only. + +## Standing constraints + +- **No production data is read at any point in this phase.** Migrations, the e2e + baseline and every egress measurement run on local SQLite and the synthetic `--keep` + fixture. If a real Postgres is ever needed for a write path, it is a throwaway + database whose name contains `stress`/`scratch`, and it is asked for first. +- **Any migration that rewrites `actions` is followed by `VACUUM FULL actions;`** on the + direct endpoint, not `-pooler`. SP1, SP4 and SP8 each rewrite every row.