Let a branch be named, and thrown away
SP7 needs three branch operations and SP5 built one. Switching exists; naming and deleting had no column and no route between them. A name is stored because a player chose it. An unnamed branch keeps NULL rather than a generated "branch 4" — a generated label is derived, and it would go stale the moment a branch before it is deleted and the ordinals shift underneath. The client draws those from the fork depth, which nothing can shift. The v2 bundle carries the name for the same reason it carries the fork points and leaves `lineage` out: it is a decision, not something computed from one. Delete is what stands between a tree and unbounded growth, since nothing prunes one on its own. It refuses two branches: the root, which holds the turns every other branch borrows, and the one being read — including any branch the head was forked from, which is the same mistake in disguise and the one that would cascade the head away and leave head_branch_id pointing at nothing. The nodes, memories and descendants go through the foreign keys that already cascade. A cursor standing on a deleted branch is cleared. On Postgres a stale branch id would simply never resolve; SQLite hands the freed id to the next fork, and then the anchor resolves onto a branch it has never seen and calls a stretch of story already summarized. 396 tests, 15 new. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015H5qiyiR7gtFQaoDphHZ3g
This commit is contained in:
committed by
Parth
co-authored by
Claude Opus 5
parent
a7bf47a35e
commit
cf3d52171e
+41
-6
@@ -54,7 +54,7 @@ from fastapi import HTTPException
|
||||
from sqlalchemy import insert, update
|
||||
from sqlalchemy.orm import Session, undefer
|
||||
|
||||
from . import attempts, models
|
||||
from . import attempts, models, schemas
|
||||
from .context import cursors, lineage
|
||||
|
||||
FORMAT = "ai-dnd-adventure-v2"
|
||||
@@ -150,9 +150,17 @@ def _exported_branch(branch: models.Branch, local: dict[int, int]) -> dict:
|
||||
local.get(branch.parent_branch_id)
|
||||
if branch.parent_branch_id is not None else None
|
||||
)
|
||||
if parent is None:
|
||||
return dict(_ROOT)
|
||||
return {"parent": parent, "forkDepth": branch.fork_depth}
|
||||
out = (
|
||||
dict(_ROOT) if parent is None
|
||||
else {"parent": parent, "forkDepth": branch.fork_depth}
|
||||
)
|
||||
# A name is something a player chose, so it travels — the same rule that
|
||||
# puts the fork points in the file and leaves `lineage` out. An unnamed
|
||||
# branch omits the key rather than carrying a null, which keeps the file
|
||||
# for a tree nobody has named byte-identical to the one SP6 wrote.
|
||||
if branch.name:
|
||||
out["name"] = branch.name
|
||||
return out
|
||||
|
||||
|
||||
def _exported_node(action: models.Action, local: dict[int, int]) -> dict:
|
||||
@@ -258,8 +266,9 @@ def _planned_branches(bundle: dict) -> list[dict]:
|
||||
specs: list[dict] = []
|
||||
for i, entry in enumerate(entries):
|
||||
parent = entry.get("parent")
|
||||
name = _planned_branch_name(entry, i)
|
||||
if parent is None:
|
||||
specs.append(dict(_ROOT))
|
||||
specs.append(dict(_ROOT, **({"name": name} if name else {})))
|
||||
continue
|
||||
# A branch may only fork from one listed before it. That is how the
|
||||
# export writes them — branches are numbered in creation order and a
|
||||
@@ -279,10 +288,35 @@ def _planned_branches(bundle: dict) -> list[dict]:
|
||||
f"Branch {i} forks from branch {parent} but does not say at "
|
||||
f"what depth.",
|
||||
)
|
||||
specs.append({"parent": parent, "forkDepth": fork_depth})
|
||||
specs.append({
|
||||
"parent": parent, "forkDepth": fork_depth,
|
||||
**({"name": name} if name else {}),
|
||||
})
|
||||
return specs
|
||||
|
||||
|
||||
def _planned_branch_name(entry: dict, i: int) -> str | None:
|
||||
"""The name a branch entry carries, or None for one nobody named.
|
||||
|
||||
Checked before the row is created rather than left to the column, for the
|
||||
reason the whole planner exists: a 400 from a pure function beats a half
|
||||
written adventure and a database error from three branches in.
|
||||
"""
|
||||
raw = entry.get("name")
|
||||
if raw is None:
|
||||
return None
|
||||
if not isinstance(raw, str):
|
||||
raise HTTPException(400, f"Branch {i} has a name that is not text.")
|
||||
name = raw.strip()
|
||||
if len(name) > schemas.BRANCH_NAME_MAX:
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"Branch {i}'s name is longer than {schemas.BRANCH_NAME_MAX} "
|
||||
f"characters.",
|
||||
)
|
||||
return name or None
|
||||
|
||||
|
||||
def _planned_nodes(bundle: dict, branches: int) -> list[dict]:
|
||||
raw = bundle.get("actions")
|
||||
nodes: list[dict] = []
|
||||
@@ -429,6 +463,7 @@ def _write_branches(
|
||||
parent_branch_id=ids[parent] if parent is not None else None,
|
||||
fork_depth=fork_depth if parent is not None else None,
|
||||
lineage=[],
|
||||
name=spec.get("name"),
|
||||
created_at=models.utcnow(),
|
||||
)
|
||||
).inserted_primary_key[0]
|
||||
|
||||
@@ -88,6 +88,17 @@ class Cursor:
|
||||
self.anchor(adventure, node.branch_id, lineage.NO_DEPTH
|
||||
if node.depth is None else node.depth)
|
||||
|
||||
def clear(self, adventure: models.Adventure) -> None:
|
||||
"""Forget the anchor entirely: nothing is covered.
|
||||
|
||||
For when the ground the anchor stood on is gone — a deleted branch. On
|
||||
Postgres a stale branch id would simply never resolve, but SQLite hands
|
||||
a freed id to the next fork, and an anchor that resolves onto a branch
|
||||
it has never seen would report a stretch of story as already
|
||||
summarized. Clearing costs a re-summarize, which is the safe direction.
|
||||
"""
|
||||
self.anchor(adventure, None, NO_DEPTH)
|
||||
|
||||
def rewind_to(
|
||||
self, adventure: models.Adventure, branch_id: int | None, depth: int
|
||||
) -> None:
|
||||
|
||||
@@ -251,6 +251,12 @@ MIGRATIONS: list[tuple[int, str | dict[str, str]]] = [
|
||||
# handful of rows behind it, which ix_actions_branch_depth already serves,
|
||||
# so this is a no-op statement that gives the passes a version to hang on.
|
||||
(60, "CREATE INDEX IF NOT EXISTS ix_actions_branch_depth ON actions (branch_id, depth)"),
|
||||
# Phase 14, SP7 — a branch can be named. NULL is "nobody named this one",
|
||||
# which is every branch alive when this runs, so there is no backfill and
|
||||
# nothing to derive. `branches` holds a handful of rows per adventure rather
|
||||
# than one per turn, so unlike SP1's and SP4's this rewrite is a few hundred
|
||||
# rows against a few hundred thousand and needs no VACUUM FULL of its own.
|
||||
(61, "ALTER TABLE branches ADD COLUMN name VARCHAR(80)"),
|
||||
]
|
||||
|
||||
LATEST_VERSION = max((v for v, _ in MIGRATIONS), default=1)
|
||||
|
||||
@@ -211,6 +211,13 @@ class Branch(Base):
|
||||
# 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)
|
||||
# What the player called this line of the story, or NULL for one nobody has
|
||||
# named. NULL rather than a generated "branch 4", because a generated name
|
||||
# is derived and this column is for what was chosen — the same rule the v2
|
||||
# bundle is built on. A stored default would also become a lie the moment a
|
||||
# branch before it is deleted and the ordinals shift under it; an unnamed
|
||||
# branch is drawn from its fork depth instead, which nothing can shift.
|
||||
name: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
|
||||
|
||||
|
||||
|
||||
@@ -1110,11 +1110,158 @@ def list_branches(
|
||||
),
|
||||
own_actions=count,
|
||||
is_head=(branch.id == adventure.head_branch_id),
|
||||
name=branch.name,
|
||||
created_at=branch.created_at,
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
def get_branch_or_404(
|
||||
adventure: models.Adventure, branch_id: int, db: Session
|
||||
) -> models.Branch:
|
||||
"""One branch of this adventure, or a 404 that does not confirm it exists."""
|
||||
branch = db.get(models.Branch, branch_id)
|
||||
if branch is None or branch.adventure_id != adventure.id:
|
||||
raise HTTPException(404, "Branch not found")
|
||||
return branch
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{adventure_id}/branches/{branch_id}", response_model=schemas.BranchOut
|
||||
)
|
||||
def rename_branch(
|
||||
adventure_id: int,
|
||||
branch_id: int,
|
||||
payload: schemas.BranchRename,
|
||||
db: Session = Depends(get_db),
|
||||
user: models.User = CurrentUser,
|
||||
):
|
||||
"""Name a branch, or clear the name to leave it unnamed again.
|
||||
|
||||
A blank string means the same thing as `null` — a name of spaces is not a
|
||||
name anyone chose, and storing one would give the client something to draw
|
||||
that reads as an empty label rather than as a fork depth.
|
||||
"""
|
||||
adventure = get_adventure_or_404(adventure_id, db, user)
|
||||
branch = get_branch_or_404(adventure, branch_id, db)
|
||||
name = (payload.name or "").strip()
|
||||
branch.name = name or None
|
||||
adventure.updated_at = models.utcnow()
|
||||
db.commit()
|
||||
db.refresh(branch)
|
||||
tip = (
|
||||
db.query(func.max(models.Action.depth))
|
||||
.filter(
|
||||
models.Action.adventure_id == adventure.id,
|
||||
models.Action.branch_id == branch.id,
|
||||
models.Action.live.is_(True),
|
||||
)
|
||||
.scalar()
|
||||
)
|
||||
return schemas.BranchOut(
|
||||
id=branch.id,
|
||||
parent_branch_id=branch.parent_branch_id,
|
||||
fork_depth=branch.fork_depth,
|
||||
depth=tip if tip is not None else (
|
||||
branch.fork_depth if branch.fork_depth is not None else tree.NO_DEPTH
|
||||
),
|
||||
own_actions=0,
|
||||
is_head=(branch.id == adventure.head_branch_id),
|
||||
name=branch.name,
|
||||
created_at=branch.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{adventure_id}/branches/{branch_id}", status_code=204)
|
||||
def delete_branch(
|
||||
adventure_id: int,
|
||||
branch_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: models.User = CurrentUser,
|
||||
):
|
||||
"""Throw away a branch, and everything forked from it.
|
||||
|
||||
Nothing auto-prunes a tree, so this is the only thing standing between a
|
||||
heavily-retried adventure and unbounded growth — which is why it ships with
|
||||
the view that first lets anyone make a fork rather than after it.
|
||||
|
||||
Two branches cannot go. The root, because it holds the turns every other
|
||||
branch borrows and deleting it would take the whole story. And the one
|
||||
being read — or any branch it was forked from, which is the same mistake
|
||||
wearing a disguise: the cascade would take the head out from under the
|
||||
player and leave `head_branch_id` pointing at nothing. Switch first.
|
||||
|
||||
The nodes and memories go with it through `ON DELETE CASCADE`, and the
|
||||
descendants through `branches.parent_branch_id`'s, so the delete is one
|
||||
statement however deep the subtree is.
|
||||
"""
|
||||
adventure = get_adventure_or_404(adventure_id, db, user)
|
||||
branch = get_branch_or_404(adventure, branch_id, db)
|
||||
if branch.parent_branch_id is None:
|
||||
raise HTTPException(
|
||||
400, "This is the story's first branch — deleting it would delete "
|
||||
"the adventure. Delete the adventure itself instead.",
|
||||
)
|
||||
head = db.get(models.Branch, adventure.head_branch_id)
|
||||
# The head's lineage names itself and every branch it borrows from, so one
|
||||
# membership test covers both "you are standing on it" and "you are on
|
||||
# something forked from it".
|
||||
if head is not None and branch.id in {
|
||||
entry_id for entry_id, _ in lineage.entries_of(head)
|
||||
}:
|
||||
raise HTTPException(
|
||||
400, "You are reading this branch, or one forked from it. Switch to "
|
||||
"another branch first.",
|
||||
)
|
||||
acquire_turn_lock(adventure_id)
|
||||
try:
|
||||
# Collected before the delete, because afterwards there is nothing left
|
||||
# to ask which branches went. A cursor left pointing at a deleted branch
|
||||
# would be harmless on Postgres, where ids are never reused, and a real
|
||||
# bug on SQLite, where the next fork can be handed the id that just went
|
||||
# free — at which point a stale anchor silently resolves onto a branch
|
||||
# it has never seen.
|
||||
doomed = _branch_subtree(db, adventure, branch)
|
||||
for cursor in cursors.ALL:
|
||||
stored_branch, _ = cursor.stored(adventure)
|
||||
if stored_branch in doomed:
|
||||
cursor.clear(adventure)
|
||||
db.delete(branch)
|
||||
adventure.updated_at = models.utcnow()
|
||||
db.commit()
|
||||
finally:
|
||||
_active_turns.discard(adventure_id)
|
||||
# The deleted branch's memories go with it, and their cached vectors fall
|
||||
# out of the catalogue on the next read — no invalidation call needed. See
|
||||
# the note on memorybank's cache.
|
||||
|
||||
|
||||
def _branch_subtree(
|
||||
db: Session, adventure: models.Adventure, root: models.Branch
|
||||
) -> set[int]:
|
||||
"""`root` and every branch descended from it, by parent pointer.
|
||||
|
||||
Walked over the adventure's own branch rows rather than queried per level:
|
||||
an adventure has a handful of branches, and the walk is the same cost as
|
||||
one round trip while a recursive CTE would have to be written twice for the
|
||||
two dialects this codebase keeps parity with.
|
||||
"""
|
||||
children: dict[int | None, list[int]] = {}
|
||||
for bid, parent in db.query(models.Branch.id, models.Branch.parent_branch_id).filter(
|
||||
models.Branch.adventure_id == adventure.id
|
||||
):
|
||||
children.setdefault(parent, []).append(bid)
|
||||
found: set[int] = set()
|
||||
stack = [root.id]
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
if current in found:
|
||||
continue
|
||||
found.add(current)
|
||||
stack.extend(children.get(current, ()))
|
||||
return found
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{adventure_id}/branches/{branch_id}/switch", response_model=schemas.ActionPage
|
||||
)
|
||||
|
||||
@@ -22,6 +22,7 @@ MEMORY_TEXT_MAX = 5_000
|
||||
# multi-megabyte PNG in a row that gets read on every list request.
|
||||
IMAGE_MAX = 400_000
|
||||
ICON_MAX = 16 # one emoji/glyph — VARCHAR(16)
|
||||
BRANCH_NAME_MAX = 80 # what a player called one line of the story — VARCHAR(80)
|
||||
|
||||
Name = Annotated[str, Field(max_length=NAME_MAX)]
|
||||
Tags = Annotated[str, Field(max_length=TAGS_MAX)]
|
||||
@@ -220,9 +221,18 @@ class BranchOut(ORMModel):
|
||||
depth: int
|
||||
own_actions: int = 0
|
||||
is_head: bool = False
|
||||
# NULL for a branch nobody has named. The client draws those from the fork
|
||||
# depth rather than the server inventing one — see the column comment.
|
||||
name: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class BranchRename(BaseModel):
|
||||
"""A name a player chose, or `null` to go back to being unnamed."""
|
||||
|
||||
name: Annotated[str, Field(max_length=BRANCH_NAME_MAX)] | None = None
|
||||
|
||||
|
||||
class ActionUpdate(BaseModel):
|
||||
text: ActionText
|
||||
|
||||
|
||||
Reference in New Issue
Block a user