Pin down what a tree must not change
Phase 14 replaces the action list with a tree, which touches the memory bank, the context builder, undo, retry and the UI at once. Before any of that moves, write down what "unaffected" means and prove it holds today. tests/test_story_tree_baseline.py drives the product over HTTP the way a player does and asserts only on API responses, never on how anything is stored: the turn engine, retry and variant switching, undo, paging all the way back to the start without gaps or repeats, edit/delete, memories, export/import round-trip, world state. 283 green with it added, from 259. It must pass unmodified through the schema change, the branch clause and the move of memories onto nodes. A linear story is a tree with one branch, so those subphases have no licence to change behaviour, and this is what says so. The plan itself records four decisions that were open: structural-first with no runtime flag, legacy columns kept one release, full tree visualisation, and a v2 bundle with a v1 reader. Plus four traps found by reading rather than running -- history._from_memory and the scripting pipeline both slice adventure.actions, which under a tree is every branch rather than the path. 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
0f1e05c808
commit
1940e222a7
@@ -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
|
||||
+188
-6
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user