Files
interactive-story/backend/tests/test_branch_clause.py
T
parththakkar106andClaude Opus 5 ffb2fd5b0e 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
2026-08-18 19:14:07 +05:30

560 lines
20 KiB
Python

"""Phase 14 SP2 — a read sees one story, and knows which one.
These tests build the fork by hand: three branch rows and their nodes, written
straight to the database, arranged as the design doc's own worked example. That
was the only way to build one when this file was written (nothing forked until
SP5) and it stays that way now that `tree.fork` exists — a fixture that agreed
with the code under test could not catch it being wrong. The two are checked
against each other in `test_branch_forking.py`.
branch C, tip at depth 7, lineage [(C, 7), (B, 5), (A, 3)]
→ A0 A1 A2 A3 B4 B5 C6 C7
The point of building it by hand is that every read in the app is supposed to
go through one module, and a forgotten clause does not raise — it quietly shows
a story assembled out of two different ones. So the fixture deliberately leaves
nodes lying where a forgotten clause would pick them up: A kept playing past
the fork (A4, A5), B kept playing past its own (B6), and a second adventure
holds a whole story of its own. None of them may appear on C.
python -m pytest tests/test_branch_clause.py -v
"""
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 event
from app import auth, limits, models, tree
from app.context import history, lineage
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.scripting import ScriptPipeline
from tools import dbmeter
# --------------------------------------------------------------- the fixture
def make_branch(db, adventure, parent=None, fork_depth=None):
"""A branch row whose lineage is its parent's, capped, plus itself.
The same computation SP5 will do at fork time; written out here so the
fixture cannot pass by agreeing with a bug in the code under test.
"""
branch = models.Branch(
adventure_id=adventure.id,
parent_branch_id=parent.id if parent else None,
fork_depth=fork_depth,
lineage=[],
)
db.add(branch)
db.flush()
inherited = []
if parent is not None:
for ancestor_id, cap in lineage.entries_of(parent):
capped = fork_depth if cap is None else min(cap, fork_depth)
inherited.append([ancestor_id, capped])
branch.lineage = [[branch.id, None]] + inherited
db.flush()
return branch
def add_node(db, adventure, branch, depth, label, index=None):
action = models.Action(
adventure_id=adventure.id,
index=depth if index is None else index,
branch_id=branch.id,
depth=depth,
type="start" if depth == 0 else ("ai" if depth % 2 else "do"),
text=f"{label}{depth}",
)
db.add(action)
return action
def make_adventure(db, user, title):
adventure = models.Adventure(user_id=user.id, title=title, script_state={})
db.add(adventure)
db.flush()
return adventure
@pytest.fixture()
def forked():
"""The worked example, plus everything a forgotten clause would sweep up."""
Base.metadata.create_all(bind=engine)
db = SessionLocal()
user = models.User(is_guest=False, email="branch@example.com")
db.add(user)
db.flush()
db.add(models.Settings(user_id=user.id, api_key="enc:dummy", model="m"))
adventure = make_adventure(db, user, "Forked")
a = make_branch(db, adventure)
b = make_branch(db, adventure, parent=a, fork_depth=3)
c = make_branch(db, adventure, parent=b, fork_depth=5)
for depth in range(4):
add_node(db, adventure, a, depth, "A")
# A did not stop when B forked off it: these two are siblings of B4/B5.
for depth in (4, 5):
add_node(db, adventure, a, depth, "A", index=100 + depth)
for depth in (4, 5):
add_node(db, adventure, b, depth, "B")
add_node(db, adventure, b, 6, "B", index=200 + 6) # B's own continuation
for depth in (6, 7):
add_node(db, adventure, c, depth, "C")
# A second adventure, so "does the clause remember its adventure?" has an
# answer. A database holding one adventure cannot tell you.
other = make_adventure(db, user, "Elsewhere")
other_branch = make_branch(db, other)
for depth in range(4):
add_node(db, other, other_branch, depth, "X")
adventure.head_branch_id = c.id
adventure.head_depth = 7
other.head_branch_id = other_branch.id
other.head_depth = 3
db.commit()
ids = {"a": a.id, "b": b.id, "c": c.id, "adventure": adventure.id,
"other": other.id, "user": user.id}
try:
yield db, adventure, ids
finally:
db.close()
Base.metadata.drop_all(bind=engine)
def switch_to(db, adventure, branch_id, tip):
"""Move the head, the way SP7's branch picker will."""
adventure.head_branch_id = branch_id
adventure.head_depth = tip
db.commit()
def labels(actions):
return [a.text for a in actions]
# ------------------------------------------------------------- the story read
def test_the_worked_example_reads_back_as_the_design_doc_says(forked):
db, adventure, _ = forked
assert labels(history.story_actions(adventure)) == [
"A0", "A1", "A2", "A3", "B4", "B5", "C6", "C7"
]
def test_a_siblings_nodes_are_invisible(forked):
db, adventure, _ = forked
seen = labels(history.story_actions(adventure))
# A4/A5 are A's own continuation past B's fork; B6 is B's past C's.
assert "A4" not in seen and "A5" not in seen and "B6" not in seen
# And nothing from the adventure next door.
assert not [text for text in seen if text.startswith("X")]
def test_each_branch_reads_its_own_story(forked):
db, adventure, ids = forked
switch_to(db, adventure, ids["a"], 5)
assert labels(history.story_actions(adventure)) == [
"A0", "A1", "A2", "A3", "A4", "A5"
]
switch_to(db, adventure, ids["b"], 6)
assert labels(history.story_actions(adventure)) == [
"A0", "A1", "A2", "A3", "B4", "B5", "B6"
]
switch_to(db, adventure, ids["c"], 7)
assert labels(history.story_actions(adventure)) == [
"A0", "A1", "A2", "A3", "B4", "B5", "C6", "C7"
]
def test_the_ancestors_shared_nodes_are_shared_not_copied(forked):
db, adventure, ids = forked
# A0..A3 appear on all three stories and exist exactly once in the table.
rows = (
db.query(models.Action)
.filter(models.Action.adventure_id == adventure.id,
models.Action.text == "A2")
.all()
)
assert len(rows) == 1
assert rows[0].branch_id == ids["a"]
def test_count_and_tail_and_slice_agree_with_the_path(forked):
db, adventure, _ = forked
assert history.count(adventure) == 8
assert labels(history.tail(adventure, 3)) == ["B5", "C6", "C7"]
assert labels(history.tail_range(adventure, 2, 2)) == ["B4", "B5"]
assert labels(history.slice_(adventure, 3, 3)) == ["A3", "B4", "B5"]
def test_a_window_that_reaches_past_the_fork_still_reads_in_order(forked):
db, adventure, _ = forked
# 32 is history.WINDOW_START: more than the whole story, so the read has to
# widen through all three lineage entries and still come back in order.
assert labels(history.window_covering(adventure, 10 ** 6, len)) == [
"A0", "A1", "A2", "A3", "B4", "B5", "C6", "C7"
]
# ------------------------------------------- the loaded-collection short cut
def test_an_already_loaded_collection_is_cut_down_to_the_path(forked):
"""`history._from_memory`'s shortcut, which was the highest-risk line here.
`adventure.actions` is every branch's actions. Slicing it without the path
would assemble a prompt out of two different stories, and nothing would
raise — so load it deliberately and check the answer is the path anyway.
"""
db, adventure, _ = forked
loaded = list(adventure.actions) # every branch, ordered by index
assert len(loaded) == 11 # the path's 8, plus A4, A5 and B6
assert labels(history.story_actions(adventure)) == [
"A0", "A1", "A2", "A3", "B4", "B5", "C6", "C7"
]
assert history.count(adventure) == 8
assert labels(history.tail(adventure, 3)) == ["B5", "C6", "C7"]
def test_user_scripts_are_handed_the_path(forked):
"""The same trap, one layer up and user-visible: `pipeline._history()` is
the documented scripting history API."""
db, adventure, _ = forked
list(adventure.actions) # the pipeline's caller has usually loaded these
pipeline = ScriptPipeline(adventure, db)
assert [h["text"] for h in pipeline._history()] == [
"A0", "A1", "A2", "A3", "B4", "B5", "C6", "C7"
]
assert pipeline._info()["actionCount"] == 8
# ------------------------------------------------------------ over the wire
@pytest.fixture()
def client(forked, monkeypatch):
db, adventure, ids = forked
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
def _current_user(session=Depends(get_db)):
return session.get(models.User, ids["user"])
app.dependency_overrides[auth.get_current_user] = _current_user
c = TestClient(app)
try:
yield c, db, adventure, ids
finally:
app.dependency_overrides.clear()
def test_the_page_the_reader_opens_is_the_path(client):
c, db, adventure, ids = client
r = c.get(f"/api/adventures/{adventure.id}")
assert r.status_code == 200, r.text
body = r.json()
assert [a["text"] for a in body["actions"]] == [
"A0", "A1", "A2", "A3", "B4", "B5", "C6", "C7"
]
# `action_count` is what tells the reader there is more above, so it counts
# the path too — 8, not the 13 rows the adventure holds.
assert body["action_count"] == 8
def test_paging_up_walks_the_path_across_the_forks(client):
c, db, adventure, ids = client
first = c.get(f"/api/adventures/{adventure.id}/actions", params={"limit": 3}).json()
assert [a["text"] for a in first["actions"]] == ["B5", "C6", "C7"]
assert first["has_more"] is True
older = c.get(
f"/api/adventures/{adventure.id}/actions",
params={"limit": 3, "before_id": first["actions"][0]["id"]},
).json()
assert [a["text"] for a in older["actions"]] == ["A2", "A3", "B4"]
oldest = c.get(
f"/api/adventures/{adventure.id}/actions",
params={"limit": 3, "before_id": older["actions"][0]["id"]},
).json()
assert [a["text"] for a in oldest["actions"]] == ["A0", "A1"]
assert oldest["has_more"] is False
def test_paging_from_an_anchor_off_the_path_reports_the_end(client):
"""A stale client holding an id from another branch gets an empty page,
not that branch's story."""
c, db, adventure, ids = client
off_path = (
db.query(models.Action)
.filter(models.Action.adventure_id == adventure.id,
models.Action.text == "A5")
.one()
)
page = c.get(
f"/api/adventures/{adventure.id}/actions",
params={"before_id": off_path.id},
).json()
assert page["actions"] == []
assert page["has_more"] is False
def test_the_index_screen_quotes_the_branch_being_played(client):
c, db, adventure, ids = client
listed = {row["id"]: row for row in c.get("/api/adventures").json()}
# The newest *narrated* node on C: do/say are the player's voice, and the
# fixture alternates types, so C7 is the one that reads as story.
assert listed[adventure.id]["snippet"] == "C7"
switch_to(db, adventure, ids["a"], 5)
listed = {row["id"]: row for row in c.get("/api/adventures").json()}
assert listed[adventure.id]["snippet"] == "A5"
assert listed[ids["other"]]["snippet"] == "X3"
# ------------------------------------------------------- the flush guard
def test_a_node_written_without_a_branch_is_placed_anyway(forked):
"""SP1 wired the writers; from SP2 an unplaced node is an invisible one.
This is what lets a fixture, a script or a test built straight through the
ORM keep working — and it is why the baseline contract still passes with
its actions written directly to the database.
"""
db, adventure, ids = forked
written = models.Action(
adventure_id=adventure.id, index=99, type="do", text="C8"
)
db.add(written)
db.commit()
assert written.branch_id == ids["c"]
assert written.depth == 99
assert adventure.head_depth == 99
assert labels(history.tail(adventure, 2)) == ["C7", "C8"]
def test_a_memory_written_without_a_branch_is_placed_anyway(forked):
db, adventure, ids = forked
memory = models.Memory(
adventure_id=adventure.id, text="The cave was cold.", source_start=0, source_end=3
)
db.add(memory)
db.commit()
assert memory.branch_id == ids["c"]
assert memory.depth == 3
def test_placing_a_flush_of_nodes_reads_the_branch_once(forked, emitted_sql):
"""The guard resolves the head once per flush, not once per node.
The identity map holds weak references, so a branch row nobody keeps a
strong reference to is collected between two nodes and read back for the
next one. Writing two hundred actions in one flush was two hundred SELECTs
on `branches` before the head was hoisted out of the loop, and nothing
about the result would have told you.
"""
db, adventure, _ = forked
emitted_sql.clear()
for i in range(50):
db.add(models.Action(
adventure_id=adventure.id, index=500 + i, type="do", text=f"bulk {i}"
))
db.commit()
branch_reads = [s for s in emitted_sql if s.startswith("SELECT") and "FROM branches" in s]
assert len(branch_reads) <= 2, (
f"{len(branch_reads)} reads of `branches` to place 50 nodes"
)
def test_an_adventure_with_no_branch_at_all_reads_as_empty(forked):
"""The loud version of a missing branch: nothing, rather than everything.
A row with no branch cannot be shown without guessing which story it is
on, and a guess here is how a sibling's turns end up in a prompt.
"""
db, adventure, ids = forked
stray = make_adventure(db, db.get(models.User, ids["user"]), "Stray")
db.query(models.Action).filter(models.Action.text == "A0").update(
{"adventure_id": stray.id}, synchronize_session=False
)
db.commit()
db.expire_all()
stray = db.get(models.Adventure, stray.id)
assert lineage.path_of(db, stray).entries == []
assert history.story_actions(stray) == []
assert history.count(stray) == 0
# --------------------------------------------------- the cost of forking
@pytest.fixture()
def deeply_forked():
"""A story forked twenty times, then played forty turns past the last one.
The shape the design is betting on: reading the tail of this must cost what
reading the tail of an unforked story costs, because the window is covered
long before the ancestry runs out.
"""
Base.metadata.create_all(bind=engine)
db = SessionLocal()
user = models.User(is_guest=False, email="deep@example.com")
db.add(user)
db.flush()
adventure = make_adventure(db, user, "Deep")
branch = make_branch(db, adventure)
depth = 0
for _ in range(4):
add_node(db, adventure, branch, depth, "n")
depth += 1
for fork in range(20):
branch = make_branch(db, adventure, parent=branch, fork_depth=depth - 1)
for _ in range(2):
add_node(db, adventure, branch, depth, "n")
depth += 1
tip_branch = make_branch(db, adventure, parent=branch, fork_depth=depth - 1)
for _ in range(40):
add_node(db, adventure, tip_branch, depth, "n")
depth += 1
adventure.head_branch_id = tip_branch.id
adventure.head_depth = depth - 1
db.commit()
try:
yield db, adventure
finally:
db.close()
Base.metadata.drop_all(bind=engine)
@pytest.fixture()
def emitted_sql():
"""Every statement the connection runs, so a clause can be counted."""
seen = []
def on_execute(conn, cursor, statement, parameters, context, executemany):
seen.append(statement)
event.listen(engine, "before_cursor_execute", on_execute)
try:
yield seen
finally:
event.remove(engine, "before_cursor_execute", on_execute)
def branch_terms(statement: str) -> int:
return statement.count("actions.branch_id =")
def test_a_tail_read_names_one_branch_however_many_forks_there_were(
deeply_forked, emitted_sql
):
db, adventure = deeply_forked
assert len(lineage.path_of(db, adventure)) == 22 # the whole ancestry
emitted_sql.clear()
rows = history.tail(adventure, 32)
assert len(rows) == 32
selects = [s for s in emitted_sql if "FROM actions" in s and branch_terms(s)]
assert selects, "no action read was emitted"
# Clause count is bounded by the context window, not by fork count: the
# newest branch alone holds forty turns, so one entry covers a window of
# thirty-two and the other twenty-one are never named.
assert max(branch_terms(s) for s in selects) == 1
def test_a_window_reaching_past_the_forks_names_only_what_it_needs(
deeply_forked, emitted_sql
):
db, adventure = deeply_forked
emitted_sql.clear()
rows = history.tail(adventure, 41) # 40 on the tip branch, one older
assert len(rows) == 41
selects = [s for s in emitted_sql if "FROM actions" in s and branch_terms(s)]
# Two lineage entries reach 41 deep (40 + 2); the other twenty stay
# unnamed. Every fork past the window costs the query nothing.
assert max(branch_terms(s) for s in selects) == 2
def test_the_estimate_is_arithmetic_not_a_query(deeply_forked):
db, adventure = deeply_forked
path = lineage.path_of(db, adventure)
assert path.prefix_covering(1) == 1
assert path.prefix_covering(40) == 1
assert path.prefix_covering(41) == 2
assert path.prefix_covering(43) == 3
assert path.prefix_covering(10 ** 6) == len(path)
def test_forking_twenty_times_costs_the_same_bytes_as_never_forking(
deeply_forked,
):
"""The design's bet, in bytes.
Two stories of the same length, one played straight through and one forked
twenty times, read their newest window for the same money — because the
window is covered by the newest lineage entry either way, and the ancestry
is never named. The forked read pays for one extra row: the branch it read
the lineage off.
"""
db, forked_adventure = deeply_forked
flat = make_adventure(db, db.get(models.User, forked_adventure.user_id), "Flat")
branch = make_branch(db, flat)
for depth in range(84): # the same 84 nodes the forked story is long
add_node(db, flat, branch, depth, "n")
flat.head_branch_id = branch.id
flat.head_depth = 83
flat_id, forked_id = flat.id, forked_adventure.id
# Commit and let go of the connection: the meter wraps the pool's factory,
# so a connection checked out before it attaches is a connection it never
# sees. Building the fixture is a write path nobody plays, and is not
# charged to either scope.
db.commit()
db.expire_all()
meter = dbmeter.Meter()
meter.attach(engine)
try:
with meter.scope("flat"):
assert len(history.tail(db.get(models.Adventure, flat_id), 32)) == 32
flat_bytes = meter.scopes[-1].total.fetched
with meter.scope("forked"):
assert len(history.tail(db.get(models.Adventure, forked_id), 32)) == 32
forked_bytes = meter.scopes[-1].total.fetched
finally:
meter.detach()
assert flat_bytes > 0, "the meter saw nothing; it is measuring the wrong connection"
assert forked_bytes < flat_bytes * 1.25, (
f"reading a 20-fork story cost {forked_bytes:,} B against the "
f"{flat_bytes:,} B an unforked one of the same length cost"
)
def test_a_gap_in_the_story_widens_the_read_rather_than_shortening_it(
deeply_forked, emitted_sql
):
"""The estimate counts depths, and a deleted action leaves a depth with no
row behind it. The read has to notice it came up short and widen."""
db, adventure = deeply_forked
victim = (
db.query(models.Action)
.filter(models.Action.branch_id == adventure.head_branch_id)
.order_by(models.Action.depth)
.first()
)
db.delete(victim)
db.commit()
emitted_sql.clear()
rows = history.tail(adventure, 40)
assert len(rows) == 40 # 39 on the tip branch, one borrowed from above
selects = [s for s in emitted_sql if "FROM actions" in s and branch_terms(s)]
assert max(branch_terms(s) for s in selects) == len(lineage.path_of(db, adventure))