Files
interactive-story/backend/tests/test_branch_forking.py
T
parththakkar106andClaude Opus 5 4af6e17406 Answer the review, and keep the opening node's bank
Nine findings from a review of the phase-14 stack. The one about a retry
withdrawing a memory is not a bug — a memory anchored to a node describes that
node, and it goes when the node goes. The root is the exception, and it is the
only one: migration 62 parked every memory written before memories had
coordinates on depth 0, so withdrawing the opening node would retire a whole
bank nobody attached there. A memory with no source range covers no story and
now stays; a summary that genuinely ends there is still withdrawn.

The rest are repairs.

* The adventure list quoted whichever attempt was written last rather than the
  one the story tells, so switching back left the index disagreeing with the
  page.
* A v1 import gave a typed memory no depth, rebuilding the NULL the migration
  exists to remove — invisible until the imported adventure forked.
* The action cap counted a v1 file's turns, and a turn expands into a row per
  saved attempt, so a file inside the cap could write a multiple of it.
* Forking a live node on a borrowed ancestor promoted a sibling on a branch the
  caller never named. It is a branch switch, and now says so.
* Switching attempts left the state, status and memory panels reading the
  previous take: the story does not change length, so nothing keyed on its
  length noticed. Same class as the branch-switch bug this phase already fixed.
* A retry after switching back numbered the new attempt into the middle of the
  group instead of the end.
* The cursor backfill numbered every action in the table once per adventure;
  correlated to the adventure being updated, it is an index lookup instead.
* Renaming a branch answered own_actions=0.

And one behaviour change recorded rather than repaired: script-visible history
and actionCount no longer count blank-text rows. That is the right shape and
there is no reading compatible with both, so plan/14 says so.

409 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015H5qiyiR7gtFQaoDphHZ3g
2026-08-18 19:14:07 +05:30

579 lines
22 KiB
Python

"""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_live_node_on_another_branch_is_refused(client):
"""A live node off the path is another line's story, not an attempt going
spare — so the refusal names the tool that would actually do it. It used to
answer "only one take", which was true of the group and no help at all: the
caller does not want another take, it wants the branch this one 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 "another branch" in r.json()["detail"]
# And refusing left the tree alone — the bug this guards is a fork that
# promotes a sibling on the branch it was called against.
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),
).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