Files
interactive-story/backend/tests/test_tree_migration.py
JesseMarkowitzandClaude Opus 5 b7005e6fdd M5: genre-neutral authoritative narrative state, with review corrections
Replaces AI-DnD's RPG relative-delta world state with the genre-neutral typed
narrative state of ADR 010: explicit, absolute, allowlisted events proposed by
the model, validated by the application, applied to one authoritative document,
and snapshotted per position so restore stays a row read.

This commit includes the corrective pass that followed the independent review
in planning/reports/M5-IMPLEMENTATION-REPORT.md. The invariant it exists to
hold is:

    visible active transcript position == stored head == authoritative state

Narrator editing (D10, STORY-BRANCH-SEMANTICS §§14-15)

  A narrator edit no longer rewrites a row. It returns to the state before the
  turn, takes the reader's exact text as the accepted narration, re-derives the
  state that text implies, and becomes a new active continuation — while the
  original narration keeps its words, its live flag and its whole future as
  retained history. At the tip the correction is another take; with story below
  it, it forks. No new history machinery: this is the existing fork/take/head
  path with the reader's text in place of a generated reply. The §14A refusal
  is therefore gone for narrator turns, and remains only for player input.

Pre-M5 positions

  Migration 88 backfills the empty narrative document onto every action written
  before M5, and a missing snapshot now restores the empty document instead of
  leaving the previous position's state standing. Restoring to an old Save
  Point no longer leaves a later position's entities and facts on screen.

Narrator context

  Replayed history carries prose only; the machine-readable block is no longer
  reconstructed into past turns, where it contradicted the authoritative state
  in the same prompt. A fact withdrawn by a manual correction is now named as
  no longer true, with the reader's reason, rather than silently dropped.

Also

  - state_changes joins the action-list bulk read, removing one query per row.
  - Extraction takes only the application's own protocol payload: an ordinary
    ```json or ```python block in a story survives, and a mangled proposal
    still does not reach the reader.

Planning: ADR 013 records the authoritative document shape; §§14-15/14A, D10,
C04 and BUILD-MILESTONES are updated to describe what exists. Debt is recorded
against M8 (scenario editor UX) and M9 (export of the audit trail).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWU4gTfLYY6Qq9U7aa9Qw2
2026-09-05 07:01:50 -04:00

803 lines
32 KiB
Python

"""Phase 14 SP1: every existing adventure becomes a tree with one branch.
The migration this file tests cannot be re-run. It reads `index` and
writes `depth`, and from SP2 on, the reads follow `depth`. If it mis-maps
a row, that row does not raise an error. It disappears from the story,
which is why the assertions here check every row instead of a sample.
The fixture is a genuine schema-45 database, not a current one with an
old stamp. `create_all` always builds the current schema, so the three
tables the tree touches are dropped and rebuilt from the frozen pre-tree
DDL below. The migration then runs its real ALTER statements against
them, including the one that adds a foreign key. A pre-migration database
built any other way, such as a rewound stamp with the columns left in
place, would silently skip the DDL and test only half the change.
python -m pytest tests/test_tree_migration.py -v
"""
import json
import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
from sqlalchemy import text
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
# The three tables as they stood at schema 45, frozen. This is a snapshot
# of a past schema. It must not be updated to track `models.py`, because
# the whole point is that it lacks what SP1 adds. This DDL uses SQLite
# syntax only.
PRE_TREE_DDL = (
"""
CREATE TABLE adventures (
id INTEGER NOT NULL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
scenario_id INTEGER,
title VARCHAR(200) NOT NULL DEFAULT 'Untitled Adventure',
memory TEXT NOT NULL DEFAULT '',
authors_note TEXT NOT NULL DEFAULT '',
ai_instructions TEXT NOT NULL DEFAULT '',
story_summary TEXT NOT NULL DEFAULT '',
script_state JSON NOT NULL DEFAULT '{}',
world_state JSON NOT NULL DEFAULT '{}',
placeholders JSON,
auto_summarize BOOLEAN NOT NULL DEFAULT 0,
memory_bank_enabled BOOLEAN NOT NULL DEFAULT 0,
memory_cursor INTEGER NOT NULL DEFAULT 0,
summary_cursor INTEGER NOT NULL DEFAULT 0,
created_at DATETIME,
updated_at DATETIME
)
""",
"""
CREATE TABLE actions (
id INTEGER NOT NULL PRIMARY KEY,
adventure_id INTEGER NOT NULL REFERENCES adventures(id) ON DELETE CASCADE,
"index" INTEGER NOT NULL,
type VARCHAR(20) NOT NULL,
text TEXT NOT NULL DEFAULT '',
reasoning TEXT,
context_snapshot BLOB,
world_delta JSON,
state_before JSON,
world_state_before JSON,
variants JSON,
variant_count INTEGER NOT NULL DEFAULT 0,
variant_index INTEGER NOT NULL DEFAULT 0,
created_at DATETIME
)
""",
"""
CREATE TABLE memories (
id INTEGER NOT NULL PRIMARY KEY,
adventure_id INTEGER NOT NULL REFERENCES adventures(id) ON DELETE CASCADE,
text TEXT NOT NULL DEFAULT '',
embedding_blob BLOB,
source_start INTEGER,
source_end INTEGER,
embedded BOOLEAN NOT NULL DEFAULT 0,
pinned BOOLEAN NOT NULL DEFAULT 0,
forgotten BOOLEAN NOT NULL DEFAULT 0,
use_count INTEGER NOT NULL DEFAULT 0,
last_used_at DATETIME,
created_at DATETIME
)
""",
)
# The story of adventure "Gapped": index 3 is missing, because deleting a
# middle action never renumbered the ones after it. The migration must
# preserve the gap.
GAPPED_INDEXES = (0, 1, 2, 4)
STRAIGHT_INDEXES = (0, 1)
# "Blank" holds an action whose text is nothing but whitespace. It is a
# row of the adventure but not of the story, so a cursor counting covered
# actions never counted it. Migration 56 must skip it the same way, using
# a frozen copy of the story-text predicate. This predicate is the one
# duplicated definition in the change, so this is the one test case that
# can catch it drifting.
BLANK_INDEXES = (0, 1, 2, 3)
BLANK_AT = 2
@pytest.fixture()
def pre_tree():
"""A schema-45 database with three adventures in it, returned as the ids
(gapped, straight, empty) their stories were written under."""
# Every test in this module shares one temp file, and a setup that
# fails before its yield never reaches a teardown. Start from empty
# instead of from whatever the previous test left.
Base.metadata.drop_all(bind=engine)
Base.metadata.create_all(bind=engine)
with engine.begin() as conn:
# `branches` and the six new columns never existed at schema 45.
# Dropping the tables is the only way to remove the columns.
# SQLite refuses to drop a column that a foreign key references,
# and that is exactly the case for `branch_id`.
#
# `checkpoints` (M4) and `state_events`/`state_proposals` (M5) are
# dropped first and for a different reason: they reference `actions`,
# `branches` and `adventures`, and SQLite refuses to drop a table
# another table still points at. Any future table that references these
# has to be added to the front of this list.
for table in ("state_events", "state_proposals", "checkpoints",
"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, '')"
))
# The cursors as schema 45 held them: counts of covered story actions.
# Gapped's story is 0, 1, 2, 4, so "3 covered" is the node at depth
# 2 and "4 covered" is the node at depth 4. This is the whole
# reason a count and a depth are not the same number. Straight is
# caught up past its own end (5 covered, 2 actions), a state the
# older rule left behind that a clamp used to mask on every
# post-turn pass.
cursors_at = {"Gapped": (3, 4), "Straight": (5, 0), "Empty": (0, 0),
"Blank": (3, 0)}
ids = {}
for name in ("Gapped", "Straight", "Empty", "Blank"):
conn.execute(text(
"INSERT INTO adventures (user_id, title, memory_cursor, summary_cursor) "
"VALUES (1, :title, :mc, :sc)"
), {"title": name, "mc": cursors_at[name][0], "sc": cursors_at[name][1]})
ids[name] = conn.execute(text(
"SELECT id FROM adventures WHERE title = :title"
), {"title": name}).scalar()
for adventure_id, indexes in (
(ids["Gapped"], GAPPED_INDEXES),
(ids["Straight"], STRAIGHT_INDEXES),
(ids["Blank"], BLANK_INDEXES),
):
for index in indexes:
blank = adventure_id == ids["Blank"] and index == BLANK_AT
conn.execute(text(
'INSERT INTO actions (adventure_id, "index", type, text) '
"VALUES (:a, :i, :t, :x)"
), {"a": adventure_id, "i": index,
"t": "start" if index == 0 else "do",
"x": " \n\t " if blank else f"Turn {index}."})
# One memory that summarised a block of story, and one written by hand,
# which summarised nothing and so belongs to no node.
conn.execute(text(
"INSERT INTO memories (adventure_id, text, source_start, source_end) "
"VALUES (:a, 'The gate opened.', 0, 1)"
), {"a": ids["Gapped"]})
conn.execute(text(
"INSERT INTO memories (adventure_id, text) VALUES (:a, 'Hand-written.')"
), {"a": ids["Gapped"]})
conn.execute(text("PRAGMA user_version = 45"))
try:
yield ids
finally:
Base.metadata.drop_all(bind=engine)
def rows(sql: str, **params) -> list[tuple]:
with engine.begin() as conn:
return conn.execute(text(sql), params).all()
def scalar(sql: str, **params):
with engine.begin() as conn:
return conn.execute(text(sql), params).scalar()
# ------------------------------------------------------- the migration itself
def test_the_stamp_reaches_the_current_version(pre_tree):
migrations.bootstrap(engine)
assert scalar("PRAGMA user_version") == migrations.LATEST_VERSION
def test_every_action_lands_on_its_adventure_root_branch(pre_tree):
before = scalar("SELECT count(*) FROM actions")
migrations.bootstrap(engine)
assert scalar("SELECT count(*) FROM actions") == before, "the migration lost a row"
assert scalar("SELECT count(*) FROM actions WHERE branch_id IS NULL") == 0
assert scalar("SELECT count(*) FROM actions WHERE depth IS NULL") == 0
# Each action's branch belongs to that action's own adventure. A branch
# clause that forgot its adventure would still look right on a database
# holding one, which is why the fixture holds three.
mismatched = scalar("""
SELECT count(*) FROM actions a JOIN branches b ON b.id = a.branch_id
WHERE b.adventure_id != a.adventure_id
""")
assert mismatched == 0
def test_depth_is_the_old_index_gaps_included(pre_tree):
migrations.bootstrap(engine, through=migrations.TREE_BACKFILL_VERSION)
assert rows('SELECT "index", depth FROM actions WHERE depth != "index"') == []
depths = [
row[0] for row in rows(
"SELECT depth FROM actions WHERE adventure_id = :a ORDER BY depth",
a=pre_tree["Gapped"],
)
]
# 3 is still missing. Renumbering here would silently move every cursor
# pointing past the gap, and the reads only need the order, not density.
assert depths == list(GAPPED_INDEXES)
def test_one_root_branch_per_adventure_with_its_own_lineage(pre_tree):
migrations.bootstrap(engine)
branches = rows(
"SELECT id, adventure_id, parent_branch_id, fork_depth, lineage FROM branches"
)
assert len(branches) == 4, "one branch per adventure, including the empty one"
for branch_id, _adventure_id, parent, fork_depth, lineage in branches:
assert parent is None, "a migrated branch is a root; nothing forked yet"
assert fork_depth is None
# The whole story, uncapped: one entry, itself, no ceiling.
assert json.loads(lineage) == [[branch_id, None]]
def test_the_head_points_at_the_tip_of_the_root_branch(pre_tree):
migrations.bootstrap(engine)
heads = dict(rows("SELECT title, head_depth FROM adventures"))
assert heads["Gapped"] == max(GAPPED_INDEXES)
assert heads["Straight"] == max(STRAIGHT_INDEXES)
# No actions, no tip. -1 keeps "the next node goes at head_depth + 1" true
# without a special case anywhere else.
assert heads["Empty"] == tree.NO_DEPTH
assert scalar("SELECT count(*) FROM adventures WHERE head_branch_id IS NULL") == 0
dangling = scalar("""
SELECT count(*) FROM adventures a
WHERE NOT EXISTS (
SELECT 1 FROM branches b
WHERE b.id = a.head_branch_id AND b.adventure_id = a.id
)
""")
assert dangling == 0, "a head pointing outside its own adventure"
def test_memories_attach_to_the_node_they_summarised(pre_tree):
migrations.bootstrap(engine)
summarised = rows(
"SELECT source_end, depth, branch_id FROM memories WHERE source_end IS NOT NULL"
)
assert summarised, "the fixture is supposed to have one"
for source_end, depth, branch_id in summarised:
assert depth == source_end, "the memory hangs off the last action it covered"
assert branch_id is not None
# A hand-written memory summarised no node, so SP7's migration 62
# lands it at depth 0 of its branch. Depth 0 is at or before every
# fork point, so the memory stays visible from exactly the paths it
# was visible from before. Anchoring it this way removes nothing from
# anybody's existing memory bank.
manual = rows("SELECT depth, branch_id FROM memories WHERE source_end IS NULL")
assert manual and all(depth == 0 and branch is not None for depth, branch in manual)
def test_the_cursors_become_the_nodes_they_named(pre_tree):
"""SP3, migration 56. A count of covered actions and a depth become
different numbers as soon as the story has a gap in it, and every
adventure with a deleted action has one."""
migrations.bootstrap(engine, through=migrations.CURSOR_ANCHOR_VERSION)
def marks(title):
[row] = rows(
"SELECT memory_cursor_depth, summary_cursor_depth, "
"memory_cursor_branch_id, summary_cursor_branch_id "
"FROM adventures WHERE title = :t", t=title
)
return row
# Gapped's story is 0, 1, 2, 4. "3 covered" is the third action, at
# depth 2. Reading the count as a depth would hand the summarizer node
# 3, which does not exist, and silently skip node 4 forever.
memory_depth, summary_depth, memory_branch, summary_branch = marks("Gapped")
assert (memory_depth, summary_depth) == (2, 4)
root = scalar(
"SELECT id FROM branches WHERE adventure_id = "
"(SELECT id FROM adventures WHERE title = 'Gapped')"
)
assert memory_branch == summary_branch == root
# Straight was caught up under the older rule: 5 covered, 2 actions. There
# is no fifth node to name, and the number meant "caught up", so it lands
# on the tip rather than on nothing.
memory_depth, summary_depth, _, summary_branch = marks("Straight")
assert memory_depth == 1
assert (summary_depth, summary_branch) == (migrations.NO_DEPTH, None)
# Nothing covered stays nothing covered, and names no branch.
assert marks("Empty") == (migrations.NO_DEPTH, migrations.NO_DEPTH, None, None)
# A whitespace-only action is a row but not a story action, so it was
# never counted. "3 covered" of 0, 1, [blank], 3 is the node at depth
# 3, not 2. The migration's copy of the story-text predicate is the
# only place that rule is written twice, so this is the test case
# that catches it drifting.
assert marks("Blank")[0] == 3
# The legacy columns are left exactly as they were: a rolled-back build
# reads them, and this migration is not the one that drops them.
assert rows(
"SELECT memory_cursor, summary_cursor FROM adventures ORDER BY title"
) == [(3, 0), (0, 0), (3, 4), (5, 0)] # Blank, Empty, Gapped, Straight
def test_the_branch_clause_index_exists(pre_tree):
"""SP2's reads are only cheap if this index exists. `create_all` does
not add an index to a table it did not create, which is what
migration 52 handles."""
migrations.bootstrap(engine)
assert scalar(
"SELECT count(*) FROM sqlite_master "
"WHERE type = 'index' AND name = 'ix_actions_branch_depth'"
) == 1
def test_running_it_again_changes_nothing(pre_tree):
migrations.bootstrap(engine, through=migrations.CURSOR_ANCHOR_VERSION)
snapshot = (
rows("SELECT id, branch_id, depth FROM actions ORDER BY id"),
rows("SELECT id, adventure_id, lineage FROM branches ORDER BY id"),
rows("SELECT id, head_branch_id, head_depth, memory_cursor_branch_id, "
"memory_cursor_depth, summary_cursor_branch_id, summary_cursor_depth "
"FROM adventures ORDER BY id"),
rows("SELECT id, branch_id, depth FROM memories ORDER BY id"),
)
# Run the deploy path twice, then the data pass on its own. The stamp
# stops the first run, the NULL guards stop the second, and a
# migration that only survives because of the stamp is one bad rescue
# away from doubling every branch.
migrations.bootstrap(engine, through=migrations.CURSOR_ANCHOR_VERSION)
with engine.begin() as conn:
migrations._backfill_tree(conn)
migrations._backfill_cursor_anchors(conn)
assert (
rows("SELECT id, branch_id, depth FROM actions ORDER BY id"),
rows("SELECT id, adventure_id, lineage FROM branches ORDER BY id"),
rows("SELECT id, head_branch_id, head_depth, memory_cursor_branch_id, "
"memory_cursor_depth, summary_cursor_branch_id, summary_cursor_depth "
"FROM adventures ORDER BY id"),
rows("SELECT id, branch_id, depth FROM memories ORDER BY id"),
) == snapshot
# -------------------------------------------------- rows written *after* it
@pytest.fixture()
def client(monkeypatch):
"""The app on a migrated database, so new rows go through the real writers.
Fixing existing rows is only half the job. No migration ever revisits
a row written after it ran, and a row without a branch is a row no
read can see.
"""
Base.metadata.drop_all(bind=engine)
Base.metadata.create_all(bind=engine)
setup = SessionLocal()
user = models.User(is_guest=False, email="writer@example.com")
setup.add(user)
setup.flush()
scenario = models.Scenario(user_id=user.id, title="S", prompt="You enter a cave.")
setup.add(scenario)
setup.commit()
user_id, scenario_id = user.id, scenario.id
setup.close()
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.scenario_id = scenario_id
try:
yield c
finally:
app.dependency_overrides.clear()
Base.metadata.drop_all(bind=engine)
def test_a_new_adventure_gets_a_branch_and_its_opening_sits_on_it(client):
response = client.post("/api/adventures", json={"scenario_id": client.scenario_id})
assert response.status_code == 201
adventure_id = response.json()["id"]
db = SessionLocal()
try:
adventure = db.get(models.Adventure, adventure_id)
branch = db.query(models.Branch).filter_by(adventure_id=adventure_id).one()
assert adventure.head_branch_id == branch.id
assert branch.lineage == [[branch.id, None]]
opening = db.query(models.Action).filter_by(adventure_id=adventure_id).one()
assert (opening.branch_id, opening.depth) == (branch.id, 0)
assert adventure.head_depth == 0
finally:
db.close()
def test_a_blank_adventure_has_a_branch_before_anything_is_played(client):
adventure_id = client.post("/api/adventures", json={}).json()["id"]
db = SessionLocal()
try:
adventure = db.get(models.Adventure, adventure_id)
assert adventure.head_branch_id is not None
assert adventure.head_depth == tree.NO_DEPTH
finally:
db.close()
def test_a_hand_written_memory_is_anchored_at_the_head(client):
"""SP7: nothing carries a NULL depth anymore.
On an adventure with no story yet, the head is `NO_DEPTH` (-1), which
reads as "before the first node" and so falls in range of every
branch. This is correct for a note written before anything has
happened.
"""
adventure_id = client.post("/api/adventures", json={}).json()["id"]
created = client.post(
f"/api/adventures/{adventure_id}/memories", json={"text": "Remember the gate."}
)
assert created.status_code == 201
db = SessionLocal()
try:
memory = db.query(models.Memory).filter_by(adventure_id=adventure_id).one()
adventure = db.get(models.Adventure, adventure_id)
assert memory.branch_id is not None
assert memory.depth == adventure.head_depth == -1
finally:
db.close()
def test_deleting_a_branch_takes_its_nodes_with_it(client):
"""`ON DELETE CASCADE` on both `branch_id` columns means the database
removes a branch's nodes instead of relying on application code to
remember to. SP7 builds delete-a-branch directly on top of this, and
no other code needs to load a branch to do it."""
adventure_id = client.post(
"/api/adventures", json={"scenario_id": client.scenario_id}
).json()["id"]
db = SessionLocal()
try:
adventure = db.get(models.Adventure, adventure_id)
memory = models.Memory(
adventure_id=adventure_id, text="m", source_start=0, source_end=0
)
tree.place_memory(db, adventure, memory)
db.add(memory)
db.commit()
branch_id = adventure.head_branch_id
db.execute(
models.Branch.__table__.delete().where(models.Branch.id == branch_id)
)
db.commit()
assert db.query(models.Action).filter_by(adventure_id=adventure_id).count() == 0
assert db.query(models.Memory).filter_by(adventure_id=adventure_id).count() == 0
finally:
db.close()
def test_deleting_an_adventure_takes_its_branch_with_it(client):
adventure_id = client.post(
"/api/adventures", json={"scenario_id": client.scenario_id}
).json()["id"]
assert client.delete(f"/api/adventures/{adventure_id}").status_code == 204
db = SessionLocal()
try:
assert db.query(models.Branch).filter_by(adventure_id=adventure_id).count() == 0
finally:
db.close()
def test_deleting_the_newest_action_moves_the_head_back(client):
"""The head is a cache. A cache that only ever moves forward becomes
wrong the first time someone undoes a turn."""
adventure_id = client.post(
"/api/adventures", json={"scenario_id": client.scenario_id}
).json()["id"]
db = SessionLocal()
try:
adventure = db.get(models.Adventure, adventure_id)
extra = models.Action(adventure_id=adventure_id, type="do", text="Look.")
tree.place_action(db, adventure, extra)
db.add(extra)
db.commit()
assert adventure.head_depth == 1
action_id = extra.id
finally:
db.close()
assert client.delete(
f"/api/adventures/{adventure_id}/actions/{action_id}"
).status_code == 204
db = SessionLocal()
try:
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
# entry is deliberately not the last one written. A migration that
# assumed it was would still pass on every fixture where the player
# never paged 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, and SP4 must preserve that arrangement.
# Giving each sibling a copy of this prompt would multiply the largest
# 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 on
each side.
This fixture is 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. 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 ("state_events", "state_proposals", "checkpoints",
"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 records the script state as that
# action found it. SP4 reads each row's `state_before` from the
# next row 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, through=migrations.SIBLING_SPLIT_VERSION)
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
# 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, through=migrations.SIBLING_SPLIT_VERSION)
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, through=migrations.SIBLING_SPLIT_VERSION)
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, through=migrations.SIBLING_SPLIT_VERSION)
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: either an adventure with no
# RPG layer, or a take made before the column existed. It stays NULL
# instead of borrowing a neighbor's, so switching to it leaves the RPG
# layer alone. This matches 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, through=migrations.SIBLING_SPLIT_VERSION)
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, through=migrations.SIBLING_SPLIT_VERSION)
snapshot = _attempts(pre_split)
before = scalar("SELECT count(*) FROM actions")
migrations.bootstrap(engine, through=migrations.SIBLING_SPLIT_VERSION)
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):
"""This is the point of the whole migration: 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()
# ------------------------------------------------------- SP8: the columns go
# The columns migrations 66 to 73 drop, paired with the table they sat on.
DROPPED_COLUMNS = [
("actions", "index"),
("actions", "variants"),
("actions", "variant_index"),
("actions", "variant_count"),
("actions", "state_before"),
("actions", "world_state_before"),
("adventures", "memory_cursor"),
("adventures", "summary_cursor"),
]
@pytest.mark.parametrize("table,column", DROPPED_COLUMNS)
def test_the_legacy_columns_are_dropped(pre_tree, table, column):
"""A pre-tree database that migrates all the way ends without these eight.
The fixture is a real schema-45 database, so each column starts present and
the `ALTER TABLE` statements have something to remove. A `create_all`
database never has them, and would pass this test without running the
migration at all.
"""
before = {row[1] for row in rows(f"PRAGMA table_info({table})")}
assert column in before, "the fixture must start with the column"
migrations.bootstrap(engine)
after = {row[1] for row in rows(f"PRAGMA table_info({table})")}
assert column not in after