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
+6 -3
View File
@@ -1,8 +1,11 @@
"""Phase 14 SP2 — a read sees one story, and knows which one.
Nothing in the product forks yet, so 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.
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
+575
View File
@@ -0,0 +1,575 @@
"""Phase 14 SP5 — continuing from a discarded attempt forks a branch.
SP4 made every attempt at a turn a node. While the attempts sit at the tip they
are leaves and cost nothing: switching between them just moves the `live` flag.
The moment the player takes the story down one the line has already moved past,
the two futures have to coexist — and that is a branch.
What this file is really watching is the claim the whole design rests on: **a
fork inserts one row and moves one row, whatever the story behind it is worth.**
Everything before the fork is borrowed, not copied, and the arithmetic that
makes borrowing readable is `lineage`.
python -m pytest tests/test_branch_forking.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 app import auth, limits, models
from app.context import cursors, lineage
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.providers import PromptParts
from app.routers import adventures
# `hp` moves freely; `mana` has a cooldown of 2 turns, so a clock that advances
# when it should not shows up as a change the referee should have rejected.
SCHEMA = {
"player": {
"hp": {"min": 0, "max": 100, "initial": 100},
"mana": {"min": 0, "max": 50, "initial": 50, "cooldown": 2},
}
}
GOLD_SCRIPT = """
const modifier = (text) => {
state.gold = (state.gold || 0) + 10;
return { text };
};
modifier(text);
"""
class ScriptedProvider:
replies: list = []
calls = 0
prompts: list = []
def __init__(self, *a, **k):
pass
async def generate(self, parts: PromptParts, *, temperature, max_tokens):
index = min(ScriptedProvider.calls, len(ScriptedProvider.replies) - 1)
ScriptedProvider.calls += 1
ScriptedProvider.prompts.append((parts.system, parts.story))
yield ("text", ScriptedProvider.replies[index])
@pytest.fixture()
def client(monkeypatch):
Base.metadata.create_all(bind=engine)
setup = SessionLocal()
user = models.User(is_guest=False, email="fork@example.com")
setup.add(user)
setup.flush()
setup.add(models.Settings(user_id=user.id, api_key="enc:dummy", model="test-model"))
scenario = models.Scenario(user_id=user.id, title="S", stat_schema=SCHEMA)
setup.add(scenario)
setup.flush()
adv = models.Adventure(
user_id=user.id, title="Cave", scenario_id=scenario.id,
script_state={}, world_state={"player": {"hp": 100, "mana": 50}},
)
setup.add(adv)
setup.flush()
setup.add(models.Action(adventure_id=adv.id, index=0, type="start", text="You enter a cave."))
setup.add(models.AdventureScript(
adventure_id=adv.id, position=0, enabled=True, name="Gold", output_js=GOLD_SCRIPT,
))
setup.commit()
adv_id, user_id = adv.id, user.id
setup.close()
ScriptedProvider.replies = ["A reply."]
ScriptedProvider.calls = 0
ScriptedProvider.prompts = []
monkeypatch.setattr(adventures, "OpenAICompatibleProvider", ScriptedProvider)
monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig(
"http://fake", "k", "test-model", False))
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.adv_id = adv_id
try:
yield c
finally:
app.dependency_overrides.clear()
adventures._active_turns.clear()
Base.metadata.drop_all(bind=engine)
# ------------------------------------------------------------------ helpers
def _play(client, text="look around", type="do"):
r = client.post(f"/api/adventures/{client.adv_id}/actions",
json={"type": type, "text": text})
assert r.status_code == 200, r.text
def _retry(client):
r = client.post(f"/api/adventures/{client.adv_id}/retry")
assert r.status_code == 200, r.text
def _texts(client) -> list[str]:
return [a["text"] for a in client.get(f"/api/adventures/{client.adv_id}").json()["actions"]]
def _branches(client) -> list[dict]:
r = client.get(f"/api/adventures/{client.adv_id}/branches")
assert r.status_code == 200, r.text
return r.json()
def _fork(client, action_id):
return client.post(f"/api/adventures/{client.adv_id}/actions/{action_id}/fork")
def _state(adv_id):
db = SessionLocal()
try:
adv = db.get(models.Adventure, adv_id)
return adv.script_state, adv.world_state
finally:
db.close()
def _rows(adv_id) -> list[models.Action]:
db = SessionLocal()
try:
return (
db.query(models.Action)
.filter(models.Action.adventure_id == adv_id)
.order_by(models.Action.depth, models.Action.variant_index)
.all()
)
finally:
db.close()
def _divergent_story(client):
"""A story that retried turn 2, continued from the newer take, and left the
older one behind as a leaf.
start · do · [attempt one | ATTEMPT TWO] · do · next turn
Returns the id of the attempt nobody built on.
"""
ScriptedProvider.replies = ["Attempt one.", "Attempt two.", "Next turn."]
_play(client)
_retry(client)
_play(client, "go deeper")
return [a.id for a in _rows(client.adv_id) if a.type == "ai" and not a.live][0]
# ---------------------------------------------------------------- the fork
def test_a_fork_inserts_one_branch_row_and_copies_no_actions(client):
discarded = _divergent_story(client)
rows_before = [a.id for a in _rows(client.adv_id)]
assert len(_branches(client)) == 1
r = _fork(client, discarded)
assert r.status_code == 200, r.text
branches = _branches(client)
assert len(branches) == 2, "exactly one branch row per divergence built on"
assert [a.id for a in _rows(client.adv_id)] == rows_before, "a fork copies nothing"
forked = [b for b in branches if b["parent_branch_id"] is not None][0]
assert forked["is_head"] is True
assert forked["own_actions"] == 1, "the promoted attempt, and nothing else"
# The fork point is the depth just before the attempt, stored rather than
# inferred: inferring it from where two branches first differ would be a
# guess, and a wrong one as soon as an attempt repeats its parent's text.
assert forked["fork_depth"] == forked["depth"] - 1
def test_the_lineage_is_capped_at_the_fork_depth(client):
discarded = _divergent_story(client)
_fork(client, discarded)
db = SessionLocal()
try:
adventure = db.get(models.Adventure, client.adv_id)
branch = lineage.branch_of(db, adventure)
entries = lineage.entries_of(branch)
assert entries[0] == (branch.id, None), "its own nodes, to the tip"
assert entries[1] == (branch.parent_branch_id, branch.fork_depth)
assert len(entries) == 2
finally:
db.close()
def test_both_branches_read_independently(client):
discarded = _divergent_story(client)
parent = _branches(client)[0]["id"]
_fork(client, discarded)
# The fork's story: everything up to the divergence, then the other take.
assert _texts(client) == [
"You enter a cave.", "> You look around.", "Attempt one.",
]
# And the line it left is exactly as it was, turns after the fork included.
r = client.post(f"/api/adventures/{client.adv_id}/branches/{parent}/switch")
assert r.status_code == 200, r.text
assert _texts(client) == [
"You enter a cave.", "> You look around.", "Attempt two.",
"> You go deeper.", "Next turn.",
]
def test_the_parent_keeps_a_live_attempt_where_the_fork_left(client):
"""Promoting the loser must not leave the parent with a hole in its story:
a coordinate with no live node is a turn that disappears from the read."""
discarded = _divergent_story(client)
_fork(client, discarded)
db = SessionLocal()
try:
adventure = db.get(models.Adventure, client.adv_id)
parent_id = db.query(models.Branch).filter_by(
adventure_id=adventure.id, parent_branch_id=None).one().id
per_coordinate = {}
for row in _rows(client.adv_id):
per_coordinate.setdefault((row.branch_id, row.depth), []).append(row)
for (branch_id, depth), group in per_coordinate.items():
live = [a for a in group if a.live]
assert len(live) == 1, f"branch {branch_id} depth {depth}"
# The parent's turn 2 is now a single take, so the pager stops offering
# a page through attempts that have gone their own way.
parent_turn = per_coordinate[(parent_id, 2)]
assert len(parent_turn) == 1
assert parent_turn[0].variant_count == 0
finally:
db.close()
def test_playing_on_a_fork_continues_that_branchs_depths(client):
"""A depth is a position along *this* story. Numbering the next node from
the adventure-wide index would leave a hole where the other branch's turns
are, which every windowing estimate then has to work around."""
discarded = _divergent_story(client)
_fork(client, discarded)
ScriptedProvider.replies = ["Onward."]
_play(client, "turn back")
db = SessionLocal()
try:
adventure = db.get(models.Adventure, client.adv_id)
path = lineage.path_of(db, adventure)
rows = [a for a in _rows(client.adv_id) if path.contains(a)]
assert [a.depth for a in sorted(rows, key=lambda a: a.depth)] == [0, 1, 2, 3, 4]
finally:
db.close()
assert _texts(client)[-1] == "Onward."
# ------------------------------------------------------------- not a fork
def test_forking_at_the_tip_switches_without_making_a_branch(client):
"""Attempts nobody has built on stay leaves — that is what keeps the
lineage a list of divergences rather than of every retry ever."""
ScriptedProvider.replies = ["Attempt one.", "Attempt two."]
_play(client)
_retry(client)
discarded = [a.id for a in _rows(client.adv_id) if a.type == "ai" and not a.live][0]
r = _fork(client, discarded)
assert r.status_code == 200, r.text
assert len(_branches(client)) == 1, "no branch for an attempt at the tip"
assert _texts(client)[-1] == "Attempt one."
def test_forking_the_attempt_already_in_the_story_does_nothing(client):
"""Idempotent, because a client that has lost track of which take is live
must not be able to fork a branch per click."""
discarded = _divergent_story(client)
_fork(client, discarded)
promoted = [a.id for a in _rows(client.adv_id) if a.type == "ai" and a.live
and a.branch_id != _branches(client)[0]["id"]][0]
before = _texts(client)
for _ in range(3):
r = _fork(client, promoted)
assert r.status_code == 200, r.text
assert len(_branches(client)) == 2
assert _texts(client) == before
def test_forking_a_turn_that_is_already_the_story_is_a_no_op(client):
ScriptedProvider.replies = ["Only take."]
_play(client)
only = [a.id for a in _rows(client.adv_id) if a.type == "ai"][0]
r = _fork(client, only)
assert r.status_code == 200, r.text
assert len(_branches(client)) == 1
def test_forking_a_single_take_on_another_branch_is_refused(client):
"""The only way to reach the refusal, and it names the wrong tool: a node
with no siblings is not a divergence, so what the caller wants is to switch
to the branch it is on."""
discarded = _divergent_story(client)
_fork(client, discarded)
parent_id = [b for b in _branches(client) if b["parent_branch_id"] is None][0]["id"]
stranded = [a.id for a in _rows(client.adv_id)
if a.branch_id == parent_id and a.depth == 2][0]
r = _fork(client, stranded)
assert r.status_code == 400
assert "one take" in r.json()["detail"]
assert len(_branches(client)) == 2
# -------------------------------------------------------------- the state
def test_switching_restores_the_script_and_world_state(client):
ScriptedProvider.replies = [
"A scratch.\n```state\n{\"player.hp\": -5}\n```",
"A beating.\n```state\n{\"player.hp\": -40}\n```",
"Onward.",
]
_play(client)
_retry(client)
_play(client, "go deeper")
parent = _branches(client)[0]["id"]
on_parent = _state(client.adv_id)
discarded = [a.id for a in _rows(client.adv_id) if a.type == "ai" and not a.live][0]
_fork(client, discarded)
script_state, world_state = _state(client.adv_id)
assert world_state["player"]["hp"] == 95, "the attempt this branch tells"
assert script_state == {"gold": 10}, "one turn of gold, not three"
client.post(f"/api/adventures/{client.adv_id}/branches/{parent}/switch")
assert _state(client.adv_id) == on_parent
def test_the_cooldown_clock_travels_with_the_branch(client):
"""The world-state clock is a depth, and depths repeat across branches — so
it can only be right if each branch carries its own. It does, for free: the
clock lives inside `_meta.last_changed`, which is part of the world state a
switch restores."""
ScriptedProvider.replies = [
"Drained.\n```state\n{\"player.mana\": -10}\n```",
"Untouched.",
"Onward.",
]
_play(client)
_retry(client)
_play(client, "go deeper")
discarded = [a.id for a in _rows(client.adv_id) if a.type == "ai" and not a.live][0]
on_parent = _state(client.adv_id)[1]
assert on_parent["_meta"]["last_changed"].get("player.mana") is None
_fork(client, discarded)
forked = _state(client.adv_id)[1]
assert forked["player"]["mana"] == 40
assert forked["_meta"]["last_changed"]["player.mana"] == 2
parent = [b for b in _branches(client) if b["parent_branch_id"] is None][0]["id"]
client.post(f"/api/adventures/{client.adv_id}/branches/{parent}/switch")
assert _state(client.adv_id)[1] == on_parent
def test_a_retry_does_not_advance_the_cooldown_clock(client):
"""SP5's one carried-over open item. A retry re-runs the *same* turn, so
the clock the cooldown rules read must not move — it used to be the reused
`index` that guaranteed this, and it is the reused depth now."""
ScriptedProvider.replies = [
"Drained.\n```state\n{\"player.mana\": -10}\n```",
"Drained again.\n```state\n{\"player.mana\": -10}\n```",
]
_play(client)
first = _state(client.adv_id)[1]["_meta"]["last_changed"]["player.mana"]
_retry(client)
assert _state(client.adv_id)[1]["_meta"]["last_changed"]["player.mana"] == first
# ...and the second attempt's drain landed, rather than being rejected for
# a cooldown it should never have been measured against.
assert _state(client.adv_id)[1]["player"]["mana"] == 40
# --------------------------------------------------------- derived work
def test_a_memory_on_the_line_left_behind_is_out_of_range_on_the_fork(client):
"""Nothing is moved or withdrawn when a branch forks. The memory hangs off
the coordinate the *parent's* attempt still occupies, and the lineage caps
the parent one depth short of it — so the fork simply cannot see it, and
resummarizes that ground from the text it actually tells."""
from app import tree
discarded = _divergent_story(client)
db = SessionLocal()
try:
adventure = db.get(models.Adventure, client.adv_id)
winner = db.query(models.Action).filter_by(
adventure_id=adventure.id, type="ai", live=True).order_by(
models.Action.depth).first()
memory = models.Memory(
adventure_id=adventure.id, text="Attempt two happened.",
source_start=0, source_end=winner.depth,
)
tree.attach_memory(memory, winner)
db.add(memory)
cursors.MEMORY.anchor_at(adventure, winner)
db.commit()
finally:
db.close()
_fork(client, discarded)
db = SessionLocal()
try:
adventure = db.get(models.Adventure, client.adv_id)
# Still there, untouched — it describes the parent's story, which is
# unchanged.
assert [m.text for m in db.query(models.Memory).all()] == ["Attempt two happened."]
path = lineage.path_of(db, adventure)
visible = db.query(models.Memory).filter(
models.Memory.adventure_id == adventure.id,
path.clause(models.Memory, unanchored=True),
).all()
assert visible == [], "a sibling's memory reached this branch"
# ...and the mark reads as one depth short of it, so the block is due
# again on this branch rather than silently claimed as read.
assert cursors.MEMORY.depth(db, adventure) == 1
finally:
db.close()
# ------------------------------------------------------------------- undo
def test_undo_stops_at_the_fork(client):
"""Taking back a turn on a fork must never reach across into the line it
was forked from — those turns are that branch's story too."""
discarded = _divergent_story(client)
_fork(client, discarded)
rows_before = len(_rows(client.adv_id))
r = client.post(f"/api/adventures/{client.adv_id}/undo")
assert r.status_code == 200, r.text
# The promoted attempt goes, and the player action in front of it stays:
# it is on the parent, and the parent still tells it.
assert len(_rows(client.adv_id)) == rows_before - 1
assert _texts(client) == ["You enter a cave.", "> You look around."]
# Nothing left of this branch's own: refuse rather than eat the parent's.
r = client.post(f"/api/adventures/{client.adv_id}/undo")
assert r.status_code == 400
assert "forked from" in r.json()["detail"]
# ----------------------------------------------------------- the tree view
def test_the_branch_list_is_the_tree(client):
discarded = _divergent_story(client)
_fork(client, discarded)
ScriptedProvider.replies = ["Onward."]
_play(client, "turn back")
branches = _branches(client)
root = [b for b in branches if b["parent_branch_id"] is None][0]
forked = [b for b in branches if b["parent_branch_id"] == root["id"]][0]
assert root["fork_depth"] is None and root["depth"] == 4
assert forked["fork_depth"] == 1 and forked["depth"] == 4
assert root["own_actions"] == 5 and forked["own_actions"] == 3
assert forked["is_head"] is True and root["is_head"] is False
def test_fork_agrees_with_the_lineage_computed_by_hand(client):
"""`test_branch_clause.make_branch` has computed a fork's lineage since SP2,
by hand, precisely so the fixture could not pass by agreeing with a bug in
the code under test. SP5 is when that code exists — so check the two
against each other rather than letting them drift apart."""
from tests.test_branch_clause import make_branch
discarded = _divergent_story(client)
_fork(client, discarded)
db = SessionLocal()
try:
adventure = db.get(models.Adventure, client.adv_id)
real = lineage.branch_of(db, adventure)
parent = db.get(models.Branch, real.parent_branch_id)
by_hand = make_branch(db, adventure, parent=parent, fork_depth=real.fork_depth)
# Same shape, its own id: compare the ancestry, which is the part that
# is arithmetic rather than allocation.
assert lineage.entries_of(real)[1:] == lineage.entries_of(by_hand)[1:]
assert lineage.entries_of(real)[0] == (real.id, None)
finally:
db.close()
def test_a_deep_fork_chain_reads_for_what_one_branch_costs(client):
"""Clause count is bounded by the window, not by fork count — the property
the whole lineage cache exists for, now measured through real forks rather
than hand-built rows."""
from tools import dbmeter
ScriptedProvider.replies = ["First take.", "Second take.", "Onward."]
_play(client)
for _ in range(8):
_retry(client)
_play(client, "onward")
discarded = [
a.id for a in _rows(client.adv_id)
if a.type == "ai" and not a.live
]
if discarded:
assert _fork(client, discarded[-1]).status_code == 200
_play(client, "onward")
branches = _branches(client)
assert len(branches) > 4, "the fixture did not actually fork"
meter = dbmeter.Meter()
meter.attach(engine)
try:
with meter.scope("page"):
client.get(f"/api/adventures/{client.adv_id}")
page_bytes = meter.scopes[-1].total.fetched
finally:
meter.detach()
assert page_bytes > 0, "the meter saw nothing"
db = SessionLocal()
try:
adventure = db.get(models.Adventure, client.adv_id)
entries = lineage.entries_of(lineage.branch_of(db, adventure))
# The whole ancestry is there to be named...
assert len(entries) == len(branches)
# ...and the windowed read names as few of them as the window needs.
path = lineage.path_of(db, adventure)
assert path.prefix_covering(60) <= len(entries)
finally:
db.close()
def test_switching_to_a_branch_of_another_adventure_is_a_404(client):
"""A branch id names one adventure, so the two ids in the URL have to
agree — otherwise a guessed number reads somebody else's story."""
other = client.post("/api/adventures", json={"title": "Elsewhere"}).json()["id"]
ScriptedProvider.replies = ["Elsewhere."]
r = client.post(f"/api/adventures/{other}/actions", json={"type": "do", "text": "wait"})
assert r.status_code == 200, r.text
stranger = client.get(f"/api/adventures/{other}/branches").json()
assert len(stranger) == 1
assert client.post(
f"/api/adventures/{client.adv_id}/branches/{stranger[0]['id']}/switch"
).status_code == 404