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:
parththakkar106
2026-08-18 19:14:07 +05:30
committed by Parth
co-authored by Claude Opus 5
parent 5c1bcf7305
commit d3756abdaa
13 changed files with 1026 additions and 38 deletions
+10 -8
View File
@@ -26,7 +26,7 @@ from collections import OrderedDict
from sqlalchemy import func, select, update
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 .database import SessionLocal
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
if not text:
return
db.add(
models.Memory(
adventure_id=adventure.id,
text=text,
source_start=block[0].index,
source_end=block[-1].index,
)
memory = models.Memory(
adventure_id=adventure.id,
text=text,
source_start=block[0].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
db.commit()
+158 -1
View File
@@ -18,6 +18,7 @@ migrations added from Phase 9 on must run on both dialects.
"""
import json
import re
from sqlalchemy import inspect, text
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"}),
(44, "ALTER TABLE actions DROP COLUMN 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)
@@ -187,6 +223,11 @@ WORLD_DELTA_VERSION = 36
VARIANT_COUNT_VERSION = 37
EMBEDDING_BLOB_VERSION = 38
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
# 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"])
# 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:
"""Repack memories.embedding (JSON list) into memories.embedding_blob.
@@ -345,6 +412,90 @@ def _backfill_context_snapshot(conn) -> None:
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:
if conn.dialect.name == "sqlite":
return conn.execute(text("PRAGMA user_version")).scalar() or 1
@@ -382,7 +533,11 @@ def bootstrap(engine: Engine) -> None:
current = _get_version(conn)
for version, sql in MIGRATIONS:
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:
_backfill_world_delta(conn)
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.
if version == SNAPSHOT_COMPRESS_VERSION:
_backfill_context_snapshot(conn)
if version == TREE_BACKFILL_VERSION:
_backfill_tree(conn)
current = version
_set_version(conn, current)
_encrypt_plaintext_api_keys(conn)
+84 -2
View File
@@ -1,8 +1,8 @@
from datetime import datetime, timezone
from sqlalchemy import (
JSON, Boolean, Column, DateTime, Float, ForeignKey, Integer, LargeBinary, String,
Table, Text,
JSON, Boolean, Column, DateTime, Float, ForeignKey, Index, Integer, LargeBinary,
String, Table, Text,
)
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.
memory_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)
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):
"""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).
source_start: 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
# .set_vector, for the same reason actions.variant_count exists beside
# actions.variants: every reader wants the one-bit answer and none of them
@@ -212,10 +277,27 @@ class StoryCard(Base):
class Action(Base):
__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)
adventure_id: Mapped[int] = mapped_column(ForeignKey("adventures.id", ondelete="CASCADE"))
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
text: Mapped[str] = mapped_column(Text, default="")
# Reasoning-model "thinking" that preceded the text (AI actions only).
+32 -12
View File
@@ -9,7 +9,7 @@ from sqlalchemy import func
from sqlalchemy.orm import Session, load_only, undefer
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 history as context_history
from ..database import get_db
@@ -337,6 +337,10 @@ def create_adventure(
)
db.add(adventure)
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:
for ref, spec in scenario_card_specs(scenario, values).items():
@@ -356,14 +360,14 @@ def create_adventure(
)
)
if scenario.prompt.strip():
db.add(
models.Action(
adventure_id=adventure.id,
index=0,
type="start",
text=fill_placeholders(scenario.prompt, values),
)
opening = models.Action(
adventure_id=adventure.id,
index=0,
type="start",
text=fill_placeholders(scenario.prompt, values),
)
tree.place_action(db, adventure, opening)
db.add(opening)
db.commit()
db.refresh(adventure)
@@ -823,6 +827,7 @@ async def _generate_turn(
state_before=state_before,
world_state_before=world_state_before,
)
tree.place_action(db, adventure, ai_action)
db.add(ai_action)
adventure.updated_at = models.utcnow()
if cfg.using_demo:
@@ -877,6 +882,7 @@ async def run_player_turn(
state_before=state_before,
world_state_before=world_state_before,
)
tree.place_action(db, adventure, player_action)
db.add(player_action)
db.commit()
db.refresh(player_action)
@@ -1079,6 +1085,8 @@ def undo_turn(
db.flush() # apply deletes so pruning sees the shrunken action list
db.expire(adventure, ["actions"])
memorybank.prune_dangling_memories(adventure, db)
# The tip moved back with them.
tree.refresh_head(db, adventure)
db.commit()
db.refresh(adventure)
# The newest window, not the whole story: the client replaces its
@@ -1202,10 +1210,13 @@ def import_adventure(
)
db.add(adventure)
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 []:
if isinstance(m, dict) and str(m.get("text") or "").strip():
db.add(models.Memory(
memory = models.Memory(
adventure_id=adventure.id,
text=str(m["text"]),
pinned=bool(m.get("pinned", False)),
@@ -1213,7 +1224,9 @@ def import_adventure(
source_start=m.get("sourceStart"),
source_end=m.get("sourceEnd"),
use_count=int(m.get("useCount", 0)),
))
)
tree.place_memory(db, adventure, memory)
db.add(memory)
for card in bundle.get("storyCards") or []:
if isinstance(card, dict):
@@ -1248,7 +1261,7 @@ def import_adventure(
for v in (a.get("variants") or [])
if isinstance(v, dict)
]
db.add(models.Action(
action = models.Action(
adventure_id=adventure.id,
index=int(a.get("index", i)),
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
# 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)),
))
)
tree.place_action(db, adventure, action)
db.add(action)
db.commit()
db.refresh(adventure)
@@ -1629,6 +1644,8 @@ def create_memory(
if not payload.text.strip():
raise HTTPException(400, "Memory text cannot be empty")
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.commit()
db.refresh(memory)
@@ -1742,4 +1759,7 @@ def delete_action(
db.flush() # apply the delete so pruning sees the shrunken action list
db.expire(adventure, ["actions"])
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()
+120
View File
@@ -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