Let a story go two ways at once

Attempts pile up at the tip as leaves and cost nothing. The moment the
player takes the story down one the line has already moved past, the two
futures have to coexist — so `tree.fork` gives that attempt a branch of
its own, forked at the depth just before it, and the line it leaves
keeps every turn it has.

One row is inserted and one row is moved. Nothing is copied: everything
before the fork is borrowed through the lineage cached on the branch
row. Measured on a 40-turn story forked twenty times — 21 branches, 140
rows, an 80-action story — the page load costs 31,652 B against the
31,433 B the same story flat costs, and a branch is 103 B of ancestry.

Nothing derived moves either, and that is the part worth keeping: a
memory hangs off the coordinate the parent's attempt still occupies, and
the lineage caps the parent one depth short of it. The fork simply
cannot see it, so it resummarizes that ground from the text it actually
tells, without a line of bookkeeping.

Two things had to change underneath. A new node's depth now comes from
the tip of its branch rather than from the adventure-wide `index`, which
would have left a hole in a fork's path the width of the other branch.
And undo stops at the fork — the turns before it belong to the branch
this one grew out of.

`GET /branches`, `POST /branches/{id}/switch` and
`POST /actions/{id}/fork` are the endpoints SP7's tree view is drawn on.

365 tests green, 18 of them new in test_branch_forking.py.

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 0a12d9cd47
commit ffb2fd5b0e
5 changed files with 909 additions and 10 deletions
+88
View File
@@ -28,6 +28,7 @@ from sqlalchemy import func, insert, update
from sqlalchemy.orm import Session
from . import models
from .context import lineage
# 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.
@@ -89,6 +90,93 @@ def head_branch(db: Session, adventure: models.Adventure) -> models.Branch:
return branch
def fork(db: Session, adventure: models.Adventure, node: models.Action) -> models.Branch:
"""Take the story down `node`, on a branch of its own.
`node` is a discarded attempt at a turn the story has already moved past.
Making it live where it stands would orphan every turn played after it —
they were written as a continuation of the attempt that won — so it moves
onto a new branch instead, forked from the depth just before it. The parent
keeps its story, complete and untouched; the new branch borrows everything
up to the fork and owns exactly one node.
**One row is inserted and one row is moved. Nothing is copied.** That is
the whole claim of the design: a fork costs a `branches` row and the
ancestry cached on it, whatever the story behind it is worth.
Nothing derived moves with it, and that is not an omission. A memory hangs
off the coordinate its block ends on, and what it describes is whatever
attempt was live there — which stays on the parent. From the new branch it
is simply out of range: the lineage caps the parent at `fork_depth`, so the
memory sits one depth past the border and neither the retrieval clause nor
the cursors can see it. The block is summarized again, from the text this
branch actually tells, without a line of bookkeeping.
One thing does stay behind: the attempts this node leaves. They are still
takes on the parent's turn, and one of them has to be the parent's story —
the oldest, so the line the parent keeps is the one it was written on.
"""
parent = db.get(models.Branch, node.branch_id)
if parent is None or node.depth is None:
raise ValueError("cannot fork from a node that is not on a branch")
fork_depth = node.depth - 1
# The attempts this node is leaving, read *before* it moves. The session
# does not autoflush, so asking afterwards would still find the node here
# and renumber it back into the group it just left.
remaining = [
row for row in db.query(models.Action)
.filter(
models.Action.adventure_id == adventure.id,
models.Action.branch_id == parent.id,
models.Action.depth == node.depth,
)
.order_by(models.Action.variant_index, models.Action.id)
.all()
if row is not node
]
# The parent's ancestry, every entry capped at the fork. Only the first can
# actually move — an older entry is already capped at the fork depth of the
# branch beneath it, which is shallower than any node on the parent — but
# capping them all says the invariant instead of relying on it.
inherited = [
[branch_id, fork_depth if cap is None else min(cap, fork_depth)]
for branch_id, cap in lineage.entries_of(parent)
]
# Inserted through Core, and its lineage written second, for the reason
# `root_branch` spells out: this can run inside a flush, and the lineage
# names the row's own id.
new_id = db.execute(
insert(models.Branch).values(
adventure_id=adventure.id,
parent_branch_id=parent.id,
fork_depth=fork_depth,
lineage=[],
created_at=models.utcnow(),
)
).inserted_primary_key[0]
db.execute(
update(models.Branch)
.where(models.Branch.id == new_id)
.values(lineage=[[new_id, None]] + inherited)
)
depth = node.depth
node.branch_id = new_id
node.live = True
node.variant_index = 0
node.variant_count = 0
if remaining and not any(row.live for row in remaining):
remaining[0].live = True
for i, row in enumerate(remaining):
row.variant_index = i
row.variant_count = len(remaining) if len(remaining) > 1 else 0
adventure.head_branch_id = new_id
adventure.head_depth = depth
return db.get(models.Branch, new_id)
def place_action(
db: Session,
adventure: models.Adventure,