Give every action a branch and a depth
Phase 14 SP1. The tree goes into the schema and nothing reads it yet: a `branches` table, `branch_id`/`depth` on actions and memories, a head pointer on adventures, migrations 46-52, and a server-side backfill that re-reads every existing adventure as a tree with one branch. `depth` holds the number `index` already held, gaps included, so no story changes — a linear story *is* a tree with one branch, which is what makes the SP0 baseline passing unmodified the pass condition rather than a hope. The writer had to come with it. No migration will ever visit a row written after it ran, so columns backfilled today and populated next subphase would leave a hole exactly the width of one deploy, and from SP2 on a row without a branch is a row no read can see. `app/tree.py` owns that: one module, because a node written without a branch fails by disappearing rather than by raising. Three things the schema itself insisted on: - `adventures.head_branch_id` is a plain integer, not a foreign key. Pointing both ways makes the two tables a cycle create_all cannot order, and its escape hatch needs an ALTER SQLite does not have. It is a cache, and a head naming a branch that is gone recovers onto the root. - `lineage` is NOT NULL, so the backfill inserts `'[]'` and fills it in a second pass guarded on `json_array_length(lineage) = 0` — not `= '[]'`, because Postgres `json` has no equality operator. - SQLite will not drop a column a foreign key names, which is how two existing tests broke: they simulated an old database by rewinding the stamp while leaving the new columns in place. Every ADD COLUMN migration is now idempotent, and `tests/test_tree_migration.py` builds a genuine schema 45 by rebuilding three tables from frozen DDL so the real ALTERs run. 297 tests green, 14 of them new. `branches` costs 0.1 kB of a 733.5 kB turn; page load and index are byte-identical to the recorded figures. The deploy that ships this needs one `VACUUM FULL actions;` on the direct endpoint afterwards — it rewrites every row. 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
5c1bcf7305
commit
d3756abdaa
@@ -26,7 +26,7 @@ from collections import OrderedDict
|
|||||||
from sqlalchemy import func, select, update
|
from sqlalchemy import func, select, update
|
||||||
from sqlalchemy.orm import Session, object_session
|
from sqlalchemy.orm import Session, object_session
|
||||||
|
|
||||||
from . import models, vectors
|
from . import models, tree, vectors
|
||||||
from .context import history, story_actions, truncate_to_last_tokens
|
from .context import history, story_actions, truncate_to_last_tokens
|
||||||
from .database import SessionLocal
|
from .database import SessionLocal
|
||||||
from .providers import OpenAICompatibleProvider, ProviderError
|
from .providers import OpenAICompatibleProvider, ProviderError
|
||||||
@@ -427,14 +427,16 @@ async def _create_due_memories(
|
|||||||
return # logged in the debug page; cursor unchanged → retried next turn
|
return # logged in the debug page; cursor unchanged → retried next turn
|
||||||
if not text:
|
if not text:
|
||||||
return
|
return
|
||||||
db.add(
|
memory = models.Memory(
|
||||||
models.Memory(
|
adventure_id=adventure.id,
|
||||||
adventure_id=adventure.id,
|
text=text,
|
||||||
text=text,
|
source_start=block[0].index,
|
||||||
source_start=block[0].index,
|
source_end=block[-1].index,
|
||||||
source_end=block[-1].index,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
# Phase 14: hang it off the node it summarised, so a fork inherits the
|
||||||
|
# memories of the path it forked from and nothing else.
|
||||||
|
tree.place_memory(db, adventure, memory)
|
||||||
|
db.add(memory)
|
||||||
adventure.memory_cursor = cursor + MEMORY_INTERVAL
|
adventure.memory_cursor = cursor + MEMORY_INTERVAL
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|||||||
+158
-1
@@ -18,6 +18,7 @@ migrations added from Phase 9 on must run on both dialects.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
from sqlalchemy import inspect, text
|
from sqlalchemy import inspect, text
|
||||||
from sqlalchemy.engine import Engine
|
from sqlalchemy.engine import Engine
|
||||||
@@ -178,6 +179,41 @@ MIGRATIONS: list[tuple[int, str | dict[str, str]]] = [
|
|||||||
"default": "ALTER TABLE actions ADD COLUMN context_snapshot_z BYTEA"}),
|
"default": "ALTER TABLE actions ADD COLUMN context_snapshot_z BYTEA"}),
|
||||||
(44, "ALTER TABLE actions DROP COLUMN context_snapshot"),
|
(44, "ALTER TABLE actions DROP COLUMN context_snapshot"),
|
||||||
(45, "ALTER TABLE actions RENAME COLUMN context_snapshot_z TO context_snapshot"),
|
(45, "ALTER TABLE actions RENAME COLUMN context_snapshot_z TO context_snapshot"),
|
||||||
|
# Phase 14, SP1: the story becomes a tree. Every action gains the branch it
|
||||||
|
# was played on and its depth along that branch; adventures gain a head
|
||||||
|
# pointer; memories attach to the node that produced them. The `branches`
|
||||||
|
# table itself comes from create_all, like `memories` did.
|
||||||
|
#
|
||||||
|
# Nothing reads these yet — SP2 moves the reads onto them. This subphase
|
||||||
|
# exists so that by the time anything does, every row already has them,
|
||||||
|
# including the rows written between the two deploys (`app/tree.py` stamps
|
||||||
|
# those). Legacy `index`, `variants`, `variant_index` and `variant_count`
|
||||||
|
# stay in place, unread, until the tree is proven live.
|
||||||
|
#
|
||||||
|
# **This rewrites every row of `actions`, twice** — once per ADD COLUMN
|
||||||
|
# backfill pass on Postgres — so the deploy that ships it must be followed
|
||||||
|
# by, once:
|
||||||
|
#
|
||||||
|
# VACUUM FULL actions;
|
||||||
|
#
|
||||||
|
# on the direct endpoint, not -pooler. That is the 144 MB lesson from
|
||||||
|
# 2026-08-17: a rewrite roughly doubles the table and only a VACUUM FULL
|
||||||
|
# hands the space back. Skipping it is safe and simply leaves the table fat.
|
||||||
|
(46, "ALTER TABLE actions ADD COLUMN branch_id INTEGER "
|
||||||
|
"REFERENCES branches(id) ON DELETE CASCADE"),
|
||||||
|
(47, "ALTER TABLE actions ADD COLUMN depth INTEGER"),
|
||||||
|
# head_branch_id carries no REFERENCES: branches.adventure_id already points
|
||||||
|
# the other way, and two constraints would make the pair a cycle create_all
|
||||||
|
# cannot order. See the column comment in models.py.
|
||||||
|
(48, "ALTER TABLE adventures ADD COLUMN head_branch_id INTEGER"),
|
||||||
|
(49, "ALTER TABLE adventures ADD COLUMN head_depth INTEGER NOT NULL DEFAULT -1"),
|
||||||
|
(50, "ALTER TABLE memories ADD COLUMN branch_id INTEGER "
|
||||||
|
"REFERENCES branches(id) ON DELETE CASCADE"),
|
||||||
|
(51, "ALTER TABLE memories ADD COLUMN depth INTEGER"),
|
||||||
|
# The index every branch clause wants, and the data pass that fills the six
|
||||||
|
# columns above (_backfill_tree, hung off this version because it needs all
|
||||||
|
# of them to exist).
|
||||||
|
(52, "CREATE INDEX IF NOT EXISTS ix_actions_branch_depth ON actions (branch_id, depth)"),
|
||||||
]
|
]
|
||||||
|
|
||||||
LATEST_VERSION = max((v for v, _ in MIGRATIONS), default=1)
|
LATEST_VERSION = max((v for v, _ in MIGRATIONS), default=1)
|
||||||
@@ -187,6 +223,11 @@ WORLD_DELTA_VERSION = 36
|
|||||||
VARIANT_COUNT_VERSION = 37
|
VARIANT_COUNT_VERSION = 37
|
||||||
EMBEDDING_BLOB_VERSION = 38
|
EMBEDDING_BLOB_VERSION = 38
|
||||||
SNAPSHOT_COMPRESS_VERSION = 43
|
SNAPSHOT_COMPRESS_VERSION = 43
|
||||||
|
TREE_BACKFILL_VERSION = 52
|
||||||
|
|
||||||
|
# An adventure with no actions has no tip. -1 keeps "the next node goes at
|
||||||
|
# head_depth + 1" true without a special case (mirrors tree.NO_DEPTH).
|
||||||
|
NO_DEPTH = -1
|
||||||
|
|
||||||
# Snapshots converted per round trip. Deliberately far smaller than
|
# Snapshots converted per round trip. Deliberately far smaller than
|
||||||
# BACKFILL_BATCH: a vector is 6 KB and a snapshot is 232 KB, so 200 of these
|
# BACKFILL_BATCH: a vector is 6 KB and a snapshot is 232 KB, so 200 of these
|
||||||
@@ -255,6 +296,32 @@ def _for_dialect(sql: str | dict[str, str], dialect: str) -> str:
|
|||||||
return sql if isinstance(sql, str) else sql.get(dialect, sql["default"])
|
return sql if isinstance(sql, str) else sql.get(dialect, sql["default"])
|
||||||
|
|
||||||
|
|
||||||
|
# Matches the ADD COLUMN migrations in this file — all hand-written above, so
|
||||||
|
# this parses SQL we control and nothing else.
|
||||||
|
_ADD_COLUMN = re.compile(r"^\s*ALTER\s+TABLE\s+(\w+)\s+ADD\s+COLUMN\s+\"?(\w+)\"?", re.I)
|
||||||
|
|
||||||
|
|
||||||
|
def _column_already_there(conn, sql: str) -> bool:
|
||||||
|
"""True when `sql` adds a column the table already has.
|
||||||
|
|
||||||
|
This is the `IF NOT EXISTS` the docstring asks for, spelled in Python
|
||||||
|
because SQLite has no syntax for it on ADD COLUMN. Without it, any database
|
||||||
|
carrying a *newer* column than its stamp claims dies on a duplicate column
|
||||||
|
with the backfill never running — and that database is not hypothetical:
|
||||||
|
`create_all` always builds the current schema, so it is what every test
|
||||||
|
replaying a migration starts from, and SQLite cannot drop the columns back
|
||||||
|
off again once a foreign key names them.
|
||||||
|
"""
|
||||||
|
match = _ADD_COLUMN.match(sql)
|
||||||
|
if match is None:
|
||||||
|
return False
|
||||||
|
table, column = match.group(1), match.group(2)
|
||||||
|
inspector = inspect(conn)
|
||||||
|
if table not in inspector.get_table_names():
|
||||||
|
return False
|
||||||
|
return column in {col["name"] for col in inspector.get_columns(table)}
|
||||||
|
|
||||||
|
|
||||||
def _backfill_embedding_blob(conn) -> None:
|
def _backfill_embedding_blob(conn) -> None:
|
||||||
"""Repack memories.embedding (JSON list) into memories.embedding_blob.
|
"""Repack memories.embedding (JSON list) into memories.embedding_blob.
|
||||||
|
|
||||||
@@ -345,6 +412,90 @@ def _backfill_context_snapshot(conn) -> None:
|
|||||||
last_id = rows[-1][0]
|
last_id = rows[-1][0]
|
||||||
|
|
||||||
|
|
||||||
|
# "The root branch of the adventure this row belongs to." MIN(id) rather than a
|
||||||
|
# LIMIT so it is a plain scalar subquery on both dialects, and deterministic if a
|
||||||
|
# database ever ends up with two roots for one adventure.
|
||||||
|
def _root_branch_of(column: str) -> str:
|
||||||
|
return (
|
||||||
|
"(SELECT MIN(b.id) FROM branches b "
|
||||||
|
f"WHERE b.adventure_id = {column} AND b.parent_branch_id IS NULL)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _backfill_tree(conn) -> None:
|
||||||
|
"""Re-read every existing adventure's linear story as a tree with one branch.
|
||||||
|
|
||||||
|
One root branch per adventure, `depth` = the old `index`, the head pointing
|
||||||
|
at the tip, and every memory hung off the node it summarised. Nothing is
|
||||||
|
copied, nothing is deleted, and no ordering changes — `index` and `depth`
|
||||||
|
hold the same numbers when this finishes, which is what makes "current
|
||||||
|
adventures are unaffected" a testable claim rather than a hope.
|
||||||
|
|
||||||
|
Server-side: `actions` is the table that fills the disk, and pulling it into
|
||||||
|
Python to write two integers a row would be the same mistake this project
|
||||||
|
has now made twice. Each statement is guarded on its own target being unset,
|
||||||
|
so a run that dies halfway resumes rather than double-applying.
|
||||||
|
"""
|
||||||
|
sqlite = conn.dialect.name == "sqlite"
|
||||||
|
|
||||||
|
# 1. A root branch per adventure. Its lineage names the row's own id, which
|
||||||
|
# does not exist until the row does, so it starts as the empty list —
|
||||||
|
# `branches` comes from create_all, where lineage is NOT NULL, so an
|
||||||
|
# empty array is what "not filled in yet" has to look like.
|
||||||
|
conn.execute(text("""
|
||||||
|
INSERT INTO branches (adventure_id, parent_branch_id, fork_depth, lineage, created_at)
|
||||||
|
SELECT a.id, NULL, NULL, '[]', CURRENT_TIMESTAMP
|
||||||
|
FROM adventures a
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM branches b WHERE b.adventure_id = a.id)
|
||||||
|
"""))
|
||||||
|
|
||||||
|
# 2. lineage = [[own_id, null]] — one entry, uncapped: the root branch is
|
||||||
|
# the whole story. Built by the database's own JSON functions because
|
||||||
|
# binding a JSON string as a parameter has no spelling that means the
|
||||||
|
# same thing to SQLite (TEXT) and to Postgres (json). The guard is a
|
||||||
|
# length, not `= '[]'`: Postgres `json` has no equality operator.
|
||||||
|
conn.execute(text(
|
||||||
|
"UPDATE branches SET lineage = json_array(json_array(id, null)) "
|
||||||
|
"WHERE json_array_length(lineage) = 0"
|
||||||
|
if sqlite else
|
||||||
|
"UPDATE branches SET lineage = "
|
||||||
|
"jsonb_build_array(jsonb_build_array(id, null))::json "
|
||||||
|
"WHERE json_array_length(lineage) = 0"
|
||||||
|
))
|
||||||
|
|
||||||
|
# 3. Every action onto that branch, at the depth its index already implies.
|
||||||
|
# Deleting a middle action left gaps in `index`, and those gaps carry
|
||||||
|
# over deliberately: depth has to keep the order the story is read in,
|
||||||
|
# and renumbering here would move every cursor that points past the gap.
|
||||||
|
conn.execute(text(f"""
|
||||||
|
UPDATE actions
|
||||||
|
SET branch_id = {_root_branch_of('actions.adventure_id')},
|
||||||
|
depth = "index"
|
||||||
|
WHERE branch_id IS NULL
|
||||||
|
"""))
|
||||||
|
|
||||||
|
# 4. The head: the root branch, and the depth of its newest node.
|
||||||
|
conn.execute(text(f"""
|
||||||
|
UPDATE adventures
|
||||||
|
SET head_branch_id = {_root_branch_of('adventures.id')},
|
||||||
|
head_depth = COALESCE(
|
||||||
|
(SELECT MAX(a."index") FROM actions a WHERE a.adventure_id = adventures.id),
|
||||||
|
{NO_DEPTH}
|
||||||
|
)
|
||||||
|
WHERE head_branch_id IS NULL
|
||||||
|
"""))
|
||||||
|
|
||||||
|
# 5. Memories onto the node that produced them: `source_end` is the index of
|
||||||
|
# the last action a memory summarised, so it is that node's depth. A
|
||||||
|
# hand-written memory has no node and keeps depth NULL.
|
||||||
|
conn.execute(text(f"""
|
||||||
|
UPDATE memories
|
||||||
|
SET branch_id = {_root_branch_of('memories.adventure_id')},
|
||||||
|
depth = source_end
|
||||||
|
WHERE branch_id IS NULL
|
||||||
|
"""))
|
||||||
|
|
||||||
|
|
||||||
def _get_version(conn) -> int:
|
def _get_version(conn) -> int:
|
||||||
if conn.dialect.name == "sqlite":
|
if conn.dialect.name == "sqlite":
|
||||||
return conn.execute(text("PRAGMA user_version")).scalar() or 1
|
return conn.execute(text("PRAGMA user_version")).scalar() or 1
|
||||||
@@ -382,7 +533,11 @@ def bootstrap(engine: Engine) -> None:
|
|||||||
current = _get_version(conn)
|
current = _get_version(conn)
|
||||||
for version, sql in MIGRATIONS:
|
for version, sql in MIGRATIONS:
|
||||||
if version > current:
|
if version > current:
|
||||||
conn.execute(text(_for_dialect(sql, conn.dialect.name)))
|
statement = _for_dialect(sql, conn.dialect.name)
|
||||||
|
# The DDL is skippable when it has already happened; the data
|
||||||
|
# pass below it is not, and still runs.
|
||||||
|
if not _column_already_there(conn, statement):
|
||||||
|
conn.execute(text(statement))
|
||||||
if version == WORLD_DELTA_VERSION:
|
if version == WORLD_DELTA_VERSION:
|
||||||
_backfill_world_delta(conn)
|
_backfill_world_delta(conn)
|
||||||
if version == VARIANT_COUNT_VERSION:
|
if version == VARIANT_COUNT_VERSION:
|
||||||
@@ -394,6 +549,8 @@ def bootstrap(engine: Engine) -> None:
|
|||||||
# DROP rolls back with it and the prompts are still there.
|
# DROP rolls back with it and the prompts are still there.
|
||||||
if version == SNAPSHOT_COMPRESS_VERSION:
|
if version == SNAPSHOT_COMPRESS_VERSION:
|
||||||
_backfill_context_snapshot(conn)
|
_backfill_context_snapshot(conn)
|
||||||
|
if version == TREE_BACKFILL_VERSION:
|
||||||
|
_backfill_tree(conn)
|
||||||
current = version
|
current = version
|
||||||
_set_version(conn, current)
|
_set_version(conn, current)
|
||||||
_encrypt_plaintext_api_keys(conn)
|
_encrypt_plaintext_api_keys(conn)
|
||||||
|
|||||||
+84
-2
@@ -1,8 +1,8 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
JSON, Boolean, Column, DateTime, Float, ForeignKey, Integer, LargeBinary, String,
|
JSON, Boolean, Column, DateTime, Float, ForeignKey, Index, Integer, LargeBinary,
|
||||||
Table, Text,
|
String, Table, Text,
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
@@ -116,6 +116,17 @@ class Adventure(Base):
|
|||||||
# How many actions have already been folded into memories / the story summary.
|
# How many actions have already been folded into memories / the story summary.
|
||||||
memory_cursor: Mapped[int] = mapped_column(Integer, default=0)
|
memory_cursor: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
summary_cursor: Mapped[int] = mapped_column(Integer, default=0)
|
summary_cursor: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
# Phase 14: where the story is being played — which branch, and the depth of
|
||||||
|
# its newest node. Deliberately NOT a ForeignKey: branches.adventure_id
|
||||||
|
# already points this way, and a second constraint back would make the two
|
||||||
|
# tables a cycle that create_all cannot order (the fix for that is
|
||||||
|
# use_alter, which SQLite has no ALTER for). It is a cache of a pointer, and
|
||||||
|
# `tree.head_branch` treats a head naming a branch that no longer exists as
|
||||||
|
# a bug to recover from rather than a state to honour.
|
||||||
|
head_branch_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
# The depth of the tip, so the next node is always head_depth + 1.
|
||||||
|
# NO_DEPTH (-1) for an adventure with no actions yet.
|
||||||
|
head_depth: Mapped[int] = mapped_column(Integer, default=-1)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, onupdate=utcnow)
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, onupdate=utcnow)
|
||||||
|
|
||||||
@@ -140,6 +151,48 @@ class Adventure(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Branch(Base):
|
||||||
|
"""Phase 14 — one path through an adventure's story tree.
|
||||||
|
|
||||||
|
A branch does not own a copy of the story: it holds the nodes played on it
|
||||||
|
and *borrows* everything before its fork point from its ancestors. Reading
|
||||||
|
branch C means reading C's nodes, plus B's up to where C left it, plus A's
|
||||||
|
up to where B left it — which is what `lineage` spells out, so a read is an
|
||||||
|
OR-clause per entry instead of a walk up parent pointers.
|
||||||
|
|
||||||
|
Until forking ships there is exactly one root branch per adventure and
|
||||||
|
every node hangs off it. That is not a half-migrated state: a linear story
|
||||||
|
*is* a tree with one branch, which is why writing these columns changes
|
||||||
|
nothing anyone can observe.
|
||||||
|
|
||||||
|
No ORM relationships on purpose. `actions.branch_id` and `memories
|
||||||
|
.branch_id` carry ON DELETE CASCADE, so the database removes a deleted
|
||||||
|
branch's nodes; a relationship would have SQLAlchemy load them all to do
|
||||||
|
the same thing, and loading every action of a branch is the exact cost the
|
||||||
|
windowed reads exist to avoid.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "branches"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
adventure_id: Mapped[int] = mapped_column(ForeignKey("adventures.id", ondelete="CASCADE"))
|
||||||
|
# NULL on a root branch.
|
||||||
|
parent_branch_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("branches.id", ondelete="CASCADE"), nullable=True
|
||||||
|
)
|
||||||
|
# The depth this branch left its parent at, stored when the fork happens and
|
||||||
|
# never inferred afterwards. Inferring it from where two branches' nodes
|
||||||
|
# first differ would be a guess about how the story was played — and a wrong
|
||||||
|
# one as soon as an attempt happens to repeat its parent's text.
|
||||||
|
fork_depth: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
# The ancestry, newest first: [[branch_id, max_depth], ...] where max_depth
|
||||||
|
# is NULL for "to the tip" and otherwise the fork_depth of the branch
|
||||||
|
# beneath it, inclusive. Computed once at fork from the parent's lineage
|
||||||
|
# plus one entry, so no read ever reconstructs it.
|
||||||
|
lineage: Mapped[list] = mapped_column(JSON, default=list)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
|
||||||
|
|
||||||
|
|
||||||
class Memory(Base):
|
class Memory(Base):
|
||||||
"""Phase 6: an auto-summarized (or hand-written) fact about the adventure.
|
"""Phase 6: an auto-summarized (or hand-written) fact about the adventure.
|
||||||
|
|
||||||
@@ -169,6 +222,18 @@ class Memory(Base):
|
|||||||
# Action index range this memory summarizes (null for manual memories).
|
# Action index range this memory summarizes (null for manual memories).
|
||||||
source_start: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
source_start: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
source_end: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
source_end: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
# Phase 14: the node that produced this memory — the last action it
|
||||||
|
# summarises. Anything derived attaches to the node it came from, which is
|
||||||
|
# what makes a fork free: a shared ancestor's memories are shared
|
||||||
|
# automatically, and a memory covering a stretch of branch B is invisible
|
||||||
|
# from any path that does not go through B.
|
||||||
|
#
|
||||||
|
# `depth` is NULL for a hand-written memory, which no node produced; that
|
||||||
|
# reads as "belongs to the adventure, not to a path".
|
||||||
|
branch_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("branches.id", ondelete="CASCADE"), nullable=True
|
||||||
|
)
|
||||||
|
depth: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
# Whether embedding_blob is set. Maintained on write by memorybank
|
# Whether embedding_blob is set. Maintained on write by memorybank
|
||||||
# .set_vector, for the same reason actions.variant_count exists beside
|
# .set_vector, for the same reason actions.variant_count exists beside
|
||||||
# actions.variants: every reader wants the one-bit answer and none of them
|
# actions.variants: every reader wants the one-bit answer and none of them
|
||||||
@@ -212,10 +277,27 @@ class StoryCard(Base):
|
|||||||
|
|
||||||
class Action(Base):
|
class Action(Base):
|
||||||
__tablename__ = "actions"
|
__tablename__ = "actions"
|
||||||
|
# Phase 14: every read of a story is "this branch up to this depth, or that
|
||||||
|
# branch up to that depth, ...", so (branch_id, depth) is the shape every
|
||||||
|
# one of those clauses wants an index on.
|
||||||
|
__table_args__ = (Index("ix_actions_branch_depth", "branch_id", "depth"),)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
adventure_id: Mapped[int] = mapped_column(ForeignKey("adventures.id", ondelete="CASCADE"))
|
adventure_id: Mapped[int] = mapped_column(ForeignKey("adventures.id", ondelete="CASCADE"))
|
||||||
index: Mapped[int] = mapped_column(Integer)
|
index: Mapped[int] = mapped_column(Integer)
|
||||||
|
# Phase 14: the node's place in the tree. `depth` is a position along *a*
|
||||||
|
# path, not a global turn number — A4 and B4 are alternatives, not
|
||||||
|
# duplicates — and it replaces `index` as the ordering key.
|
||||||
|
#
|
||||||
|
# Nullable because ALTER TABLE cannot add a NOT NULL column with no
|
||||||
|
# default and there is no sensible default for "which branch": the
|
||||||
|
# migration fills them, `tree.place_action` fills them for new nodes, and
|
||||||
|
# from SP2 on a NULL branch_id is a row no read can see. Legacy `index`
|
||||||
|
# stays beside them, unread, until the tree is proven live (SP8 drops it).
|
||||||
|
branch_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("branches.id", ondelete="CASCADE"), nullable=True
|
||||||
|
)
|
||||||
|
depth: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
type: Mapped[str] = mapped_column(String(20)) # start|do|say|story|continue|ai
|
type: Mapped[str] = mapped_column(String(20)) # start|do|say|story|continue|ai
|
||||||
text: Mapped[str] = mapped_column(Text, default="")
|
text: Mapped[str] = mapped_column(Text, default="")
|
||||||
# Reasoning-model "thinking" that preceded the text (AI actions only).
|
# Reasoning-model "thinking" that preceded the text (AI actions only).
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from sqlalchemy import func
|
|||||||
from sqlalchemy.orm import Session, load_only, undefer
|
from sqlalchemy.orm import Session, load_only, undefer
|
||||||
from sqlalchemy.orm.attributes import set_committed_value
|
from sqlalchemy.orm.attributes import set_committed_value
|
||||||
|
|
||||||
from .. import auth, images, limits, memorybank, models, schemas, worldstate
|
from .. import auth, images, limits, memorybank, models, schemas, tree, worldstate
|
||||||
from ..context import build_context
|
from ..context import build_context
|
||||||
from ..context import history as context_history
|
from ..context import history as context_history
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
@@ -337,6 +337,10 @@ def create_adventure(
|
|||||||
)
|
)
|
||||||
db.add(adventure)
|
db.add(adventure)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
# Every adventure has a story tree from the moment it exists, even before
|
||||||
|
# anything is played onto it — an adventure with a NULL head is a state the
|
||||||
|
# tree would otherwise have to tolerate everywhere for no gain.
|
||||||
|
tree.head_branch(db, adventure)
|
||||||
|
|
||||||
if scenario:
|
if scenario:
|
||||||
for ref, spec in scenario_card_specs(scenario, values).items():
|
for ref, spec in scenario_card_specs(scenario, values).items():
|
||||||
@@ -356,14 +360,14 @@ def create_adventure(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if scenario.prompt.strip():
|
if scenario.prompt.strip():
|
||||||
db.add(
|
opening = models.Action(
|
||||||
models.Action(
|
adventure_id=adventure.id,
|
||||||
adventure_id=adventure.id,
|
index=0,
|
||||||
index=0,
|
type="start",
|
||||||
type="start",
|
text=fill_placeholders(scenario.prompt, values),
|
||||||
text=fill_placeholders(scenario.prompt, values),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
tree.place_action(db, adventure, opening)
|
||||||
|
db.add(opening)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(adventure)
|
db.refresh(adventure)
|
||||||
@@ -823,6 +827,7 @@ async def _generate_turn(
|
|||||||
state_before=state_before,
|
state_before=state_before,
|
||||||
world_state_before=world_state_before,
|
world_state_before=world_state_before,
|
||||||
)
|
)
|
||||||
|
tree.place_action(db, adventure, ai_action)
|
||||||
db.add(ai_action)
|
db.add(ai_action)
|
||||||
adventure.updated_at = models.utcnow()
|
adventure.updated_at = models.utcnow()
|
||||||
if cfg.using_demo:
|
if cfg.using_demo:
|
||||||
@@ -877,6 +882,7 @@ async def run_player_turn(
|
|||||||
state_before=state_before,
|
state_before=state_before,
|
||||||
world_state_before=world_state_before,
|
world_state_before=world_state_before,
|
||||||
)
|
)
|
||||||
|
tree.place_action(db, adventure, player_action)
|
||||||
db.add(player_action)
|
db.add(player_action)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(player_action)
|
db.refresh(player_action)
|
||||||
@@ -1079,6 +1085,8 @@ def undo_turn(
|
|||||||
db.flush() # apply deletes so pruning sees the shrunken action list
|
db.flush() # apply deletes so pruning sees the shrunken action list
|
||||||
db.expire(adventure, ["actions"])
|
db.expire(adventure, ["actions"])
|
||||||
memorybank.prune_dangling_memories(adventure, db)
|
memorybank.prune_dangling_memories(adventure, db)
|
||||||
|
# The tip moved back with them.
|
||||||
|
tree.refresh_head(db, adventure)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(adventure)
|
db.refresh(adventure)
|
||||||
# The newest window, not the whole story: the client replaces its
|
# The newest window, not the whole story: the client replaces its
|
||||||
@@ -1202,10 +1210,13 @@ def import_adventure(
|
|||||||
)
|
)
|
||||||
db.add(adventure)
|
db.add(adventure)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
# A v1 bundle is a linear story, which is a tree with one branch. SP6's v2
|
||||||
|
# format carries the branches themselves.
|
||||||
|
tree.head_branch(db, adventure)
|
||||||
|
|
||||||
for m in bundle.get("memories") or []:
|
for m in bundle.get("memories") or []:
|
||||||
if isinstance(m, dict) and str(m.get("text") or "").strip():
|
if isinstance(m, dict) and str(m.get("text") or "").strip():
|
||||||
db.add(models.Memory(
|
memory = models.Memory(
|
||||||
adventure_id=adventure.id,
|
adventure_id=adventure.id,
|
||||||
text=str(m["text"]),
|
text=str(m["text"]),
|
||||||
pinned=bool(m.get("pinned", False)),
|
pinned=bool(m.get("pinned", False)),
|
||||||
@@ -1213,7 +1224,9 @@ def import_adventure(
|
|||||||
source_start=m.get("sourceStart"),
|
source_start=m.get("sourceStart"),
|
||||||
source_end=m.get("sourceEnd"),
|
source_end=m.get("sourceEnd"),
|
||||||
use_count=int(m.get("useCount", 0)),
|
use_count=int(m.get("useCount", 0)),
|
||||||
))
|
)
|
||||||
|
tree.place_memory(db, adventure, memory)
|
||||||
|
db.add(memory)
|
||||||
|
|
||||||
for card in bundle.get("storyCards") or []:
|
for card in bundle.get("storyCards") or []:
|
||||||
if isinstance(card, dict):
|
if isinstance(card, dict):
|
||||||
@@ -1248,7 +1261,7 @@ def import_adventure(
|
|||||||
for v in (a.get("variants") or [])
|
for v in (a.get("variants") or [])
|
||||||
if isinstance(v, dict)
|
if isinstance(v, dict)
|
||||||
]
|
]
|
||||||
db.add(models.Action(
|
action = models.Action(
|
||||||
adventure_id=adventure.id,
|
adventure_id=adventure.id,
|
||||||
index=int(a.get("index", i)),
|
index=int(a.get("index", i)),
|
||||||
type=str(a.get("type") or "story")[:20], # VARCHAR(20)
|
type=str(a.get("type") or "story")[:20], # VARCHAR(20)
|
||||||
@@ -1259,7 +1272,9 @@ def import_adventure(
|
|||||||
# Clamped: a bundle could name an index its variant list
|
# Clamped: a bundle could name an index its variant list
|
||||||
# doesn't have, which would make the pager point at nothing.
|
# doesn't have, which would make the pager point at nothing.
|
||||||
variant_index=min(max(int(a.get("variantIndex", 0)), 0), max(len(variants) - 1, 0)),
|
variant_index=min(max(int(a.get("variantIndex", 0)), 0), max(len(variants) - 1, 0)),
|
||||||
))
|
)
|
||||||
|
tree.place_action(db, adventure, action)
|
||||||
|
db.add(action)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(adventure)
|
db.refresh(adventure)
|
||||||
@@ -1629,6 +1644,8 @@ def create_memory(
|
|||||||
if not payload.text.strip():
|
if not payload.text.strip():
|
||||||
raise HTTPException(400, "Memory text cannot be empty")
|
raise HTTPException(400, "Memory text cannot be empty")
|
||||||
memory = models.Memory(adventure_id=adventure.id, text=payload.text.strip())
|
memory = models.Memory(adventure_id=adventure.id, text=payload.text.strip())
|
||||||
|
# No node produced this one, so it gets a branch but no depth.
|
||||||
|
tree.place_memory(db, adventure, memory)
|
||||||
db.add(memory)
|
db.add(memory)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(memory)
|
db.refresh(memory)
|
||||||
@@ -1742,4 +1759,7 @@ def delete_action(
|
|||||||
db.flush() # apply the delete so pruning sees the shrunken action list
|
db.flush() # apply the delete so pruning sees the shrunken action list
|
||||||
db.expire(adventure, ["actions"])
|
db.expire(adventure, ["actions"])
|
||||||
memorybank.prune_dangling_memories(adventure, db)
|
memorybank.prune_dangling_memories(adventure, db)
|
||||||
|
# Deleting the newest action moves the tip; deleting a middle one leaves a
|
||||||
|
# gap in the depths, deliberately — see _backfill_tree.
|
||||||
|
tree.refresh_head(db, adventure)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""Phase 14 — putting nodes on the story tree.
|
||||||
|
|
||||||
|
The write half of the tree. Which branch a new node hangs off, what depth it
|
||||||
|
gets, and where an adventure's head points all live here, because every one of
|
||||||
|
them is the kind of thing that is silently wrong when it is spread across four
|
||||||
|
call sites: a node written without a branch is a node no read can see, and it
|
||||||
|
fails by disappearing rather than by raising.
|
||||||
|
|
||||||
|
The read half — the lineage clause that turns a branch into "this story" —
|
||||||
|
lands beside it in SP2 (`context/lineage.py`). Nothing here is read yet.
|
||||||
|
|
||||||
|
Until forking ships there is exactly one branch per adventure and `depth` is
|
||||||
|
the number `index` already held, so everything in this module is bookkeeping
|
||||||
|
that changes nothing observable. That is the point: by the time a read depends
|
||||||
|
on these columns, every row has them — including the rows written between the
|
||||||
|
two deploys, which no migration will ever visit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from . import models
|
||||||
|
|
||||||
|
# The head depth of an adventure with no actions. Keeps "the next node goes at
|
||||||
|
# head_depth + 1" true with no special case, and mirrors migrations.NO_DEPTH.
|
||||||
|
NO_DEPTH = -1
|
||||||
|
|
||||||
|
|
||||||
|
def root_branch(db: Session, adventure: models.Adventure) -> models.Branch:
|
||||||
|
"""The adventure's root branch, created on first use.
|
||||||
|
|
||||||
|
Get-or-create rather than created-with-the-adventure, because the adventures
|
||||||
|
that need one most are the ones that already exist: a bundle being imported,
|
||||||
|
a fixture built straight through the ORM, or a database whose migration ran
|
||||||
|
before this code shipped.
|
||||||
|
"""
|
||||||
|
branch = (
|
||||||
|
db.query(models.Branch)
|
||||||
|
.filter(
|
||||||
|
models.Branch.adventure_id == adventure.id,
|
||||||
|
models.Branch.parent_branch_id.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(models.Branch.id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if branch is not None:
|
||||||
|
return branch
|
||||||
|
branch = models.Branch(adventure_id=adventure.id, lineage=[])
|
||||||
|
db.add(branch)
|
||||||
|
# The lineage names the branch's own id, so the row has to exist first.
|
||||||
|
db.flush()
|
||||||
|
branch.lineage = [[branch.id, None]]
|
||||||
|
return branch
|
||||||
|
|
||||||
|
|
||||||
|
def head_branch(db: Session, adventure: models.Adventure) -> models.Branch:
|
||||||
|
"""The branch new nodes are played onto."""
|
||||||
|
if adventure.head_branch_id is not None:
|
||||||
|
branch = db.get(models.Branch, adventure.head_branch_id)
|
||||||
|
if branch is not None:
|
||||||
|
return branch
|
||||||
|
# A head naming a branch that is gone is a bug somewhere else. Recover
|
||||||
|
# onto the root instead of refusing to play — the alternative is an
|
||||||
|
# adventure nobody can add to.
|
||||||
|
branch = root_branch(db, adventure)
|
||||||
|
adventure.head_branch_id = branch.id
|
||||||
|
return branch
|
||||||
|
|
||||||
|
|
||||||
|
def place_action(
|
||||||
|
db: Session, adventure: models.Adventure, action: models.Action
|
||||||
|
) -> models.Branch:
|
||||||
|
"""Put `action` on the head branch and move the head to it.
|
||||||
|
|
||||||
|
`depth` follows `index` while the two coexist. They have to agree: a read
|
||||||
|
ordering by depth and a cursor counting in index space are describing the
|
||||||
|
same story, and SP2 swaps one for the other under everything at once.
|
||||||
|
"""
|
||||||
|
branch = head_branch(db, adventure)
|
||||||
|
action.branch_id = branch.id
|
||||||
|
if action.depth is None:
|
||||||
|
action.depth = action.index
|
||||||
|
adventure.head_branch_id = branch.id
|
||||||
|
if action.depth > adventure.head_depth:
|
||||||
|
adventure.head_depth = action.depth
|
||||||
|
return branch
|
||||||
|
|
||||||
|
|
||||||
|
def place_memory(
|
||||||
|
db: Session, adventure: models.Adventure, memory: models.Memory
|
||||||
|
) -> models.Branch:
|
||||||
|
"""Attach a memory to the node that produced it.
|
||||||
|
|
||||||
|
`source_end` is the index of the last action the memory summarises, which is
|
||||||
|
that node's depth. A hand-written memory summarises nothing, so its depth
|
||||||
|
stays NULL and it belongs to the adventure rather than to a path.
|
||||||
|
"""
|
||||||
|
branch = head_branch(db, adventure)
|
||||||
|
memory.branch_id = branch.id
|
||||||
|
if memory.depth is None and memory.source_end is not None:
|
||||||
|
memory.depth = memory.source_end
|
||||||
|
return branch
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_head(db: Session, adventure: models.Adventure) -> None:
|
||||||
|
"""Re-derive the head depth after nodes were removed (undo, delete).
|
||||||
|
|
||||||
|
A branch with nothing on it sits at its fork point, because that is the last
|
||||||
|
node its story contains — borrowed from the parent, but the tip all the
|
||||||
|
same. A root branch with nothing on it has no story at all.
|
||||||
|
"""
|
||||||
|
branch = head_branch(db, adventure)
|
||||||
|
tip = (
|
||||||
|
db.query(func.max(models.Action.depth))
|
||||||
|
.filter(models.Action.branch_id == branch.id)
|
||||||
|
.scalar()
|
||||||
|
)
|
||||||
|
if tip is None:
|
||||||
|
tip = branch.fork_depth if branch.fork_depth is not None else NO_DEPTH
|
||||||
|
adventure.head_depth = tip
|
||||||
@@ -9,7 +9,7 @@ adventure and script-library copies belong to the local user (only relevant
|
|||||||
on single-user installs).
|
on single-user installs).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from app import auth, models, migrations
|
from app import auth, models, migrations, tree
|
||||||
from app.database import SessionLocal, engine
|
from app.database import SessionLocal, engine
|
||||||
|
|
||||||
# create_all + user_version stamp; plain create_all would leave a fresh DB at
|
# create_all + user_version stamp; plain create_all would leave a fresh DB at
|
||||||
@@ -250,7 +250,13 @@ try:
|
|||||||
db.add(models.StoryCard(adventure_id=adventure.id, **card))
|
db.add(models.StoryCard(adventure_id=adventure.id, **card))
|
||||||
for position, s in enumerate(SCRIPTS):
|
for position, s in enumerate(SCRIPTS):
|
||||||
db.add(models.AdventureScript(adventure_id=adventure.id, position=position, **s))
|
db.add(models.AdventureScript(adventure_id=adventure.id, position=position, **s))
|
||||||
db.add(models.Action(adventure_id=adventure.id, index=0, type="start", text=scenario.prompt))
|
opening = models.Action(
|
||||||
|
adventure_id=adventure.id, index=0, type="start", text=scenario.prompt
|
||||||
|
)
|
||||||
|
# Through the same door create_adventure uses, so the seeded adventure has a
|
||||||
|
# story tree like every other one.
|
||||||
|
tree.place_action(db, adventure, opening)
|
||||||
|
db.add(opening)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
print(f"Scenario id={scenario.id}: {scenario.title}")
|
print(f"Scenario id={scenario.id}: {scenario.title}")
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Make a database look like an older schema version, so a migration can run.
|
||||||
|
|
||||||
|
`create_all` always builds the *current* schema. A test that wants to watch a
|
||||||
|
migration happen therefore has to take the newer columns back off before it
|
||||||
|
stamps an older version — otherwise the migration meets a table that already
|
||||||
|
has its column and dies on a duplicate.
|
||||||
|
|
||||||
|
Rewinding the stamp alone was enough for a while, which is why two test files
|
||||||
|
did exactly that. It stopped being enough the moment another `ADD COLUMN`
|
||||||
|
landed after theirs: the replay then runs migrations they never meant to
|
||||||
|
exercise, against columns `create_all` had already made. This module is that
|
||||||
|
rewind done properly, in one place, so appending a migration means adding its
|
||||||
|
inverse here rather than discovering three unrelated test failures.
|
||||||
|
|
||||||
|
SQLite only — every test that replays migrations runs on a temp file, and
|
||||||
|
`PRAGMA user_version` is where the stamp lives there. Migrations that change a
|
||||||
|
column's *type* (43–45, JSON to compressed bytes) have no clean inverse and are
|
||||||
|
not listed: they get replayed as-is, which is what the tests using them already
|
||||||
|
relied on.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.engine import Engine
|
||||||
|
|
||||||
|
# (version that added it, statements that take it back off), newest first.
|
||||||
|
#
|
||||||
|
# Phase 14's `branch_id` columns are deliberately absent: SQLite refuses to drop
|
||||||
|
# a column a foreign key names ("unknown column in foreign key definition"), so
|
||||||
|
# a current-schema database cannot be rewound past them at all. That is what
|
||||||
|
# `migrations._column_already_there` is for — the replay skips DDL that has
|
||||||
|
# already happened, so the tree migrations run their backfill against a schema
|
||||||
|
# that already has the columns, which is exactly the situation here.
|
||||||
|
_UNDO: list[tuple[int, tuple[str, ...]]] = [
|
||||||
|
# Packed float32 vectors and the flag beside them.
|
||||||
|
(39, ("ALTER TABLE memories DROP COLUMN embedded",)),
|
||||||
|
(38, ("ALTER TABLE memories DROP COLUMN embedding_blob",)),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def rewind_to(engine: Engine, version: int) -> None:
|
||||||
|
"""Drop everything added after `version`, then stamp the database at it."""
|
||||||
|
with engine.begin() as conn:
|
||||||
|
for added_at, statements in _UNDO:
|
||||||
|
if added_at > version:
|
||||||
|
for sql in statements:
|
||||||
|
conn.execute(text(sql))
|
||||||
|
conn.execute(text(f"PRAGMA user_version = {version}"))
|
||||||
@@ -25,6 +25,7 @@ from sqlalchemy import text
|
|||||||
|
|
||||||
from app import memorybank, migrations, models, vectors
|
from app import memorybank, migrations, models, vectors
|
||||||
from app.database import Base, SessionLocal, engine
|
from app.database import Base, SessionLocal, engine
|
||||||
|
from tests import schema_rewind
|
||||||
|
|
||||||
|
|
||||||
def float32(value: float) -> float:
|
def float32(value: float) -> float:
|
||||||
@@ -270,10 +271,7 @@ def test_bootstrap_adds_the_columns_and_backfills_them(db, adventure):
|
|||||||
unembedded_id = unembedded.id
|
unembedded_id = unembedded.id
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
with engine.begin() as conn:
|
schema_rewind.rewind_to(engine, migrations.EMBEDDING_BLOB_VERSION - 1)
|
||||||
conn.execute(text("ALTER TABLE memories DROP COLUMN embedding_blob"))
|
|
||||||
conn.execute(text("ALTER TABLE memories DROP COLUMN embedded"))
|
|
||||||
conn.execute(text(f"PRAGMA user_version = {migrations.EMBEDDING_BLOB_VERSION - 1}"))
|
|
||||||
|
|
||||||
migrations.bootstrap(engine)
|
migrations.bootstrap(engine)
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ from sqlalchemy import text
|
|||||||
|
|
||||||
from app import compression, migrations, models
|
from app import compression, migrations, models
|
||||||
from app.database import Base, SessionLocal, engine
|
from app.database import Base, SessionLocal, engine
|
||||||
|
from tests import schema_rewind
|
||||||
from tools.fakeprose import prose
|
from tools.fakeprose import prose
|
||||||
|
|
||||||
|
|
||||||
@@ -196,8 +197,10 @@ def seed_pre_43(db, adventure, count: int = 4) -> dict[int, dict]:
|
|||||||
|
|
||||||
expected = {action_id: snapshot(action_id) for action_id in ids}
|
expected = {action_id: snapshot(action_id) for action_id in ids}
|
||||||
as_json_column(db, expected)
|
as_json_column(db, expected)
|
||||||
db.execute(text(f"PRAGMA user_version = {migrations.SNAPSHOT_COMPRESS_VERSION - 1}"))
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
# Take the later migrations' columns back off too, not just the stamp:
|
||||||
|
# replaying 43-45 also replays everything appended after them.
|
||||||
|
schema_rewind.rewind_to(engine, migrations.SNAPSHOT_COMPRESS_VERSION - 1)
|
||||||
return expected
|
return expected
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,457 @@
|
|||||||
|
"""Phase 14 SP1 — every existing adventure becomes a tree with one branch.
|
||||||
|
|
||||||
|
The migration this file watches is the one that 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 error — it *disappears from the story*, which
|
||||||
|
is why the assertions here are about every row rather than about 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 frozen pre-tree DDL below; the
|
||||||
|
migration then runs its real ALTERs against them, including the one that adds a
|
||||||
|
foreign key. A pre-migration database built any other way (stamp rewound,
|
||||||
|
columns left in place) would quietly skip the DDL and test half the change.
|
||||||
|
|
||||||
|
python -m pytest tests/test_tree_migration.py -v
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||||
|
_tmp.close()
|
||||||
|
os.environ["AIDND_DB_PATH"] = _tmp.name
|
||||||
|
os.environ.pop("AIDND_DATABASE_URL", None)
|
||||||
|
os.environ.pop("DATABASE_URL", None)
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import Depends
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app import auth, limits, migrations, models, tree
|
||||||
|
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 and must NOT be updated to track models.py — the whole point is
|
||||||
|
# that it lacks what SP1 adds. SQLite spelling only; the migration's Postgres
|
||||||
|
# half is exercised against a real server at deploy time (see plan/14).
|
||||||
|
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 gap has to survive as a gap.
|
||||||
|
GAPPED_INDEXES = (0, 1, 2, 4)
|
||||||
|
STRAIGHT_INDEXES = (0, 1)
|
||||||
|
|
||||||
|
|
||||||
|
@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 — so start from empty rather
|
||||||
|
# than from whatever the last one 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 45. Dropping the
|
||||||
|
# tables is the only way to lose the columns: SQLite refuses to drop a
|
||||||
|
# column a foreign key names, which is exactly the case for branch_id.
|
||||||
|
for table in ("actions", "memories", "branches", "adventures"):
|
||||||
|
conn.execute(text(f"DROP TABLE IF EXISTS {table}"))
|
||||||
|
for ddl in PRE_TREE_DDL:
|
||||||
|
conn.execute(text(ddl))
|
||||||
|
conn.execute(text(
|
||||||
|
"INSERT INTO users (id, email, is_guest, created_at, demo_turns_used, "
|
||||||
|
"demo_turns_date) VALUES (1, 'v45@example.com', 0, CURRENT_TIMESTAMP, 0, '')"
|
||||||
|
))
|
||||||
|
|
||||||
|
ids = {}
|
||||||
|
for name in ("Gapped", "Straight", "Empty"):
|
||||||
|
conn.execute(text(
|
||||||
|
"INSERT INTO adventures (user_id, title) VALUES (1, :title)"
|
||||||
|
), {"title": name})
|
||||||
|
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),
|
||||||
|
):
|
||||||
|
for index in indexes:
|
||||||
|
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": 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)
|
||||||
|
|
||||||
|
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) == 3, "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 has no node: it gets a branch, but no depth, which
|
||||||
|
# SP3 reads as belonging to the adventure rather than to a path.
|
||||||
|
manual = rows("SELECT depth, branch_id FROM memories WHERE source_end IS NULL")
|
||||||
|
assert manual and all(depth is None and branch is not None for depth, branch in manual)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_branch_clause_index_exists(pre_tree):
|
||||||
|
"""SP2's reads are only cheap if this exists — and `create_all` does not add
|
||||||
|
an index to a table it did not create, which is what migration 52 is for."""
|
||||||
|
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)
|
||||||
|
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 FROM adventures ORDER BY id"),
|
||||||
|
rows("SELECT id, branch_id, depth FROM memories ORDER BY id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Twice through the deploy path, then the data pass on its own — the stamp
|
||||||
|
# stops the first, 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)
|
||||||
|
with engine.begin() as conn:
|
||||||
|
migrations._backfill_tree(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 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.
|
||||||
|
|
||||||
|
Everything the migration fixes is only half the job: no migration will ever
|
||||||
|
visit 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, "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.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_gets_a_branch_but_no_depth(client):
|
||||||
|
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()
|
||||||
|
assert memory.branch_id is not None
|
||||||
|
assert memory.depth is None
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_deleting_a_branch_takes_its_nodes_with_it(client):
|
||||||
|
"""`ON DELETE CASCADE` on both `branch_id` columns, so the database removes a
|
||||||
|
branch's nodes rather than any code remembering to. SP7 ships delete-a-branch
|
||||||
|
on top of exactly this, and nothing else has 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, and a cache that only ever moves forward is 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, index=1, 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()
|
||||||
@@ -137,7 +137,7 @@ from fastapi import Depends
|
|||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from app import auth, limits, memorybank, models, security, seed, worldstate
|
from app import auth, limits, memorybank, models, security, seed, tree, worldstate
|
||||||
from app.database import Base, SessionLocal, engine, get_db
|
from app.database import Base, SessionLocal, engine, get_db
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.providers import PromptParts
|
from app.providers import PromptParts
|
||||||
@@ -379,12 +379,14 @@ def add_second_adventure(db, rng: random.Random, user) -> int:
|
|||||||
db.add(other)
|
db.add(other)
|
||||||
db.flush()
|
db.flush()
|
||||||
for i in range(6):
|
for i in range(6):
|
||||||
db.add(models.Action(
|
action = models.Action(
|
||||||
adventure_id=other.id, index=i,
|
adventure_id=other.id, index=i,
|
||||||
type="ai" if i % 2 else "do",
|
type="ai" if i % 2 else "do",
|
||||||
text=f"[second adventure] turn {i}. {prose(rng, 200)}",
|
text=f"[second adventure] turn {i}. {prose(rng, 200)}",
|
||||||
state_before=rich_script_state(i),
|
state_before=rich_script_state(i),
|
||||||
))
|
)
|
||||||
|
tree.place_action(db, other, action)
|
||||||
|
db.add(action)
|
||||||
memory = models.Memory(
|
memory = models.Memory(
|
||||||
adventure_id=other.id,
|
adventure_id=other.id,
|
||||||
text="This memory belongs to the second adventure and must never be "
|
text="This memory belongs to the second adventure and must never be "
|
||||||
@@ -392,6 +394,7 @@ def add_second_adventure(db, rng: random.Random, user) -> int:
|
|||||||
source_start=0, source_end=5,
|
source_start=0, source_end=5,
|
||||||
)
|
)
|
||||||
memorybank.set_vector(memory, [rng.uniform(-1.0, 1.0) for _ in range(EMBEDDING_DIMS)])
|
memorybank.set_vector(memory, [rng.uniform(-1.0, 1.0) for _ in range(EMBEDDING_DIMS)])
|
||||||
|
tree.place_memory(db, other, memory)
|
||||||
db.add(memory)
|
db.add(memory)
|
||||||
return other.id
|
return other.id
|
||||||
|
|
||||||
@@ -483,7 +486,7 @@ def build_fixture(args, rng: random.Random) -> tuple[int, int]:
|
|||||||
if retried else []
|
if retried else []
|
||||||
), 0
|
), 0
|
||||||
body = NARRATION if is_ai else PLAYER_INPUT
|
body = NARRATION if is_ai else PLAYER_INPUT
|
||||||
db.add(models.Action(
|
action = models.Action(
|
||||||
adventure_id=adventure.id,
|
adventure_id=adventure.id,
|
||||||
index=i,
|
index=i,
|
||||||
type="ai" if is_ai else "do",
|
type="ai" if is_ai else "do",
|
||||||
@@ -503,7 +506,13 @@ def build_fixture(args, rng: random.Random) -> tuple[int, int]:
|
|||||||
world_state_before=(
|
world_state_before=(
|
||||||
rich_world_state(schema, i) if args.rich else None
|
rich_world_state(schema, i) if args.rich else None
|
||||||
),
|
),
|
||||||
))
|
)
|
||||||
|
# A fresh database is built by create_all and stamped LATEST, so no
|
||||||
|
# migration ever runs against it and the tree backfill never sees
|
||||||
|
# it. The fixture has to stamp its own nodes, or it would be the one
|
||||||
|
# database in the project whose actions have no branch.
|
||||||
|
tree.place_action(db, adventure, action)
|
||||||
|
db.add(action)
|
||||||
|
|
||||||
for i in range(args.memories):
|
for i in range(args.memories):
|
||||||
memory = models.Memory(
|
memory = models.Memory(
|
||||||
@@ -522,6 +531,7 @@ def build_fixture(args, rng: random.Random) -> tuple[int, int]:
|
|||||||
memorybank.set_vector(
|
memorybank.set_vector(
|
||||||
memory, [rng.uniform(-1.0, 1.0) for _ in range(EMBEDDING_DIMS)]
|
memory, [rng.uniform(-1.0, 1.0) for _ in range(EMBEDDING_DIMS)]
|
||||||
)
|
)
|
||||||
|
tree.place_memory(db, adventure, memory)
|
||||||
db.add(memory)
|
db.add(memory)
|
||||||
|
|
||||||
if args.rich:
|
if args.rich:
|
||||||
|
|||||||
@@ -231,6 +231,50 @@ mapped. Bootstrap run twice is a no-op. `tests/test_egress.py` ceilings unmoved
|
|||||||
integers a row). **Post-deploy `VACUUM FULL actions;` is mandatory** — this rewrites
|
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`.
|
every row, which is the 144 MB lesson at the top of `STATUS.md`.
|
||||||
|
|
||||||
|
**Done, 2026-08-17** (branch `sp1-tree-schema`). **297 tests green**, the 283 from SP0
|
||||||
|
plus 14 in `test_tree_migration.py`. The baseline contract passes **unmodified**, which
|
||||||
|
was the pass condition. Five things the plan did not anticipate, all of them found by
|
||||||
|
building it:
|
||||||
|
|
||||||
|
- **The writes could not wait for SP2.** The file table above lists only `models.py` and
|
||||||
|
`migrations.py`, but a migration never visits a row written *after* it runs — so
|
||||||
|
shipping the columns without a writer would leave every turn played between the two
|
||||||
|
deploys with no branch, and from SP2 on a row with no branch is a row no read can see.
|
||||||
|
`app/tree.py` is that writer: `root_branch` / `head_branch` (get-or-create),
|
||||||
|
`place_action`, `place_memory`, `refresh_head`. One module for the same reason SP2 gets
|
||||||
|
one — a node written without a branch fails by *disappearing*, not by raising. Wired
|
||||||
|
into create/turn/import/undo/delete/memory, plus `seed_demo.py` and
|
||||||
|
`tools.stress_session` (a fixture built by `create_all` is stamped LATEST, so no
|
||||||
|
migration ever runs against it).
|
||||||
|
- **`adventures.head_branch_id` cannot be a foreign key.** `branches.adventure_id`
|
||||||
|
already points the other way, and the pair is then a cycle `create_all` refuses to
|
||||||
|
order; the fix for that is `use_alter`, which SQLite has no ALTER for. It is a plain
|
||||||
|
integer, documented as a cache, and `head_branch` recovers onto the root if it ever
|
||||||
|
names a branch that is gone.
|
||||||
|
- **`lineage` is NOT NULL, because `branches` comes from `create_all`.** The backfill
|
||||||
|
cannot insert a row and fill the lineage afterwards via a NULL marker, so it inserts
|
||||||
|
`'[]'` and guards step two on `json_array_length(lineage) = 0` — not `= '[]'`, because
|
||||||
|
Postgres `json` has no equality operator.
|
||||||
|
- **SQLite cannot drop a column a foreign key names.** So a current-schema database
|
||||||
|
cannot be rewound past `branch_id` at all, which broke the two existing tests that
|
||||||
|
simulate an old database by rewinding only the *stamp*. Fixed properly:
|
||||||
|
`migrations._column_already_there` makes every `ADD COLUMN` idempotent (the
|
||||||
|
`IF NOT EXISTS` the module docstring asks for and SQLite has no syntax for), and
|
||||||
|
`tests/schema_rewind.py` holds the inverse of the migrations that *can* be undone.
|
||||||
|
The SP1 fixture therefore builds a **genuine schema 45** by dropping the three tables
|
||||||
|
and recreating them from frozen pre-tree DDL, so the real ALTERs run — including the
|
||||||
|
one that adds a foreign key.
|
||||||
|
- **Deleting a branch takes its nodes with it**, and deleting an adventure takes its
|
||||||
|
branch — both verified, both at the database level via `ON DELETE CASCADE` on the two
|
||||||
|
`branch_id` columns. SP7's delete-a-branch needs no code of its own for the nodes.
|
||||||
|
|
||||||
|
Measured: `branches` costs **0.1 kB of a 733.5 kB turn (0 %)**, and the page load
|
||||||
|
(62.6 kB) and index (1.8 kB) shapes are byte-identical to the figures in `STATUS.md`.
|
||||||
|
One cost that is *not* free: the new table and its index add ~47 ms to every
|
||||||
|
`create_all`/`drop_all` cycle on SQLite, and the suite does one per test — 20 s → 38 s.
|
||||||
|
Test-only (DDL fsync), so no model change; if it ever matters, the fix is the test
|
||||||
|
harness, not the schema.
|
||||||
|
|
||||||
### SP2 — The branch clause *(reads move to lineage; still one branch)*
|
### 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
|
One module owns the clause; every action read goes through it. A forgotten clause shows
|
||||||
|
|||||||
+45
-3
@@ -78,8 +78,16 @@ needed; nothing requires reading a row of anyone's story.
|
|||||||
|
|
||||||
## Pick up here
|
## Pick up here
|
||||||
|
|
||||||
**`plan/14-phase-story-tree.md` — the tree itself.** `plan/13` is finished. Its design
|
**`plan/14-phase-story-tree.md`, SP2 — the branch clause.** SP0 (the regression contract
|
||||||
is settled in `plan/14` and nothing about it has been built.
|
and the `--rich` fixture) and SP1 (schema, migration, and the writer that keeps new rows
|
||||||
|
on the tree) are done and green; nothing is deployed yet. SP2 is where the reads move
|
||||||
|
onto `(branch_id, depth)`, and where the highest-risk line in the whole phase lives:
|
||||||
|
`history._from_memory()` slices `adventure.actions`, which under a tree is *every
|
||||||
|
branch's* actions rather than the path. Make that shortcut branch-aware or delete it.
|
||||||
|
|
||||||
|
**The schema is live in code but not on production.** When SP1 ships, the deploy needs
|
||||||
|
one `VACUUM FULL actions;` on the direct (non-`-pooler`) endpoint afterwards — it rewrites
|
||||||
|
every row. See the 144 MB lesson at the top of this file.
|
||||||
|
|
||||||
Two things from the egress work are worth carrying into it:
|
Two things from the egress work are worth carrying into it:
|
||||||
|
|
||||||
@@ -101,6 +109,40 @@ drive it before rewriting it.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## What happened on 2026-08-17, part four — the tree, SP0 and SP1
|
||||||
|
|
||||||
|
No behaviour change, and none intended: a linear story is a tree with one branch, so
|
||||||
|
every adventure reads exactly as it did. **297 tests green** (259 before the phase
|
||||||
|
started, 283 with SP0's contract, 297 with SP1's migration tests).
|
||||||
|
|
||||||
|
**SP0** built `tests/test_story_tree_baseline.py` — 24 tests driving the product over
|
||||||
|
HTTP, asserting only on API responses — and `tools.stress_session --rich`, a correctness
|
||||||
|
fixture beside the scale one. Both on branch `phase-14-story-tree`.
|
||||||
|
|
||||||
|
**SP1** put the tree in the schema, on branch `sp1-tree-schema`: a `branches` table,
|
||||||
|
`branch_id`/`depth` on actions and memories, `head_branch_id`/`head_depth` on adventures,
|
||||||
|
migrations 46–52 with a server-side backfill, and `app/tree.py` for the write side.
|
||||||
|
Nothing reads any of it yet. Four things worth not rediscovering:
|
||||||
|
|
||||||
|
- **A schema needs its writer in the same subphase.** No migration will ever visit a row
|
||||||
|
written after it ran, so columns backfilled today and populated-on-write next week leave
|
||||||
|
a hole exactly the width of one deploy. `app/tree.py` stamps every new node, including
|
||||||
|
the ones `seed_demo.py` and the stress fixture write — a fixture built by `create_all`
|
||||||
|
is stamped LATEST and no migration ever touches it.
|
||||||
|
- **SQLite will not drop a column a foreign key names.** Two tests simulated an old
|
||||||
|
database by rewinding the *stamp* while `create_all` left the new columns in place; that
|
||||||
|
works until the next `ADD COLUMN` lands, and then it fails on a duplicate column. Every
|
||||||
|
`ADD COLUMN` migration is now idempotent (`migrations._column_already_there`), which is
|
||||||
|
the `IF NOT EXISTS` SQLite has no syntax for. A true pre-migration fixture has to drop
|
||||||
|
and rebuild the tables from frozen DDL, which is what `test_tree_migration.py` does.
|
||||||
|
- **Two mutually-referencing tables cannot both carry the foreign key.** `create_all`
|
||||||
|
refuses to order the cycle, and its escape hatch (`use_alter`) needs an ALTER SQLite
|
||||||
|
does not have. `adventures.head_branch_id` is a plain integer and a documented cache.
|
||||||
|
- **The suite went 20 s → 38 s, and it is not the app.** One new table plus one index adds
|
||||||
|
~47 ms to a `create_all`/`drop_all` pair on SQLite (DDL fsync), and nearly every test
|
||||||
|
does one. Measured, not guessed. Egress is unmoved: `branches` is 0.1 kB of a 733.5 kB
|
||||||
|
turn, and the page-load and index shapes are byte-identical to the numbers above.
|
||||||
|
|
||||||
## What happened on 2026-08-17, part three
|
## What happened on 2026-08-17, part three
|
||||||
|
|
||||||
No behaviour change. A way to get a long adventure in front of a browser, because the
|
No behaviour change. A way to get a long adventure in front of a browser, because the
|
||||||
@@ -330,7 +372,7 @@ the SQLite dev parity this codebase protects on purpose).
|
|||||||
|
|
||||||
```
|
```
|
||||||
cd backend
|
cd backend
|
||||||
.venv/Scripts/python.exe -m pytest tests/ # 259 tests
|
.venv/Scripts/python.exe -m pytest tests/ # 297 tests (~38s)
|
||||||
.venv/Scripts/python.exe -m tools.stress_session # egress report (SQLite)
|
.venv/Scripts/python.exe -m tools.stress_session # egress report (SQLite)
|
||||||
|
|
||||||
# Same harness against a real Postgres. The target must be a THROWAWAY database
|
# Same harness against a real Postgres. The target must be a THROWAWAY database
|
||||||
|
|||||||
Reference in New Issue
Block a user