Files
interactive-story/backend/tests/test_branch_forking.py
JesseMarkowitzandClaude Opus 5 b7005e6fdd M5: genre-neutral authoritative narrative state, with review corrections
Replaces AI-DnD's RPG relative-delta world state with the genre-neutral typed
narrative state of ADR 010: explicit, absolute, allowlisted events proposed by
the model, validated by the application, applied to one authoritative document,
and snapshotted per position so restore stays a row read.

This commit includes the corrective pass that followed the independent review
in planning/reports/M5-IMPLEMENTATION-REPORT.md. The invariant it exists to
hold is:

    visible active transcript position == stored head == authoritative state

Narrator editing (D10, STORY-BRANCH-SEMANTICS §§14-15)

  A narrator edit no longer rewrites a row. It returns to the state before the
  turn, takes the reader's exact text as the accepted narration, re-derives the
  state that text implies, and becomes a new active continuation — while the
  original narration keeps its words, its live flag and its whole future as
  retained history. At the tip the correction is another take; with story below
  it, it forks. No new history machinery: this is the existing fork/take/head
  path with the reader's text in place of a generated reply. The §14A refusal
  is therefore gone for narrator turns, and remains only for player input.

Pre-M5 positions

  Migration 88 backfills the empty narrative document onto every action written
  before M5, and a missing snapshot now restores the empty document instead of
  leaving the previous position's state standing. Restoring to an old Save
  Point no longer leaves a later position's entities and facts on screen.

Narrator context

  Replayed history carries prose only; the machine-readable block is no longer
  reconstructed into past turns, where it contradicted the authoritative state
  in the same prompt. A fact withdrawn by a manual correction is now named as
  no longer true, with the reader's reason, rather than silently dropped.

Also

  - state_changes joins the action-list bulk read, removing one query per row.
  - Extraction takes only the application's own protocol payload: an ordinary
    ```json or ```python block in a story survives, and a mangled proposal
    still does not reach the reader.

Planning: ADR 013 records the authoritative document shape; §§14-15/14A, D10,
C04 and BUILD-MILESTONES are updated to describe what exists. Debt is recorded
against M8 (scenario editor UX) and M9 (export of the audit trail).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWU4gTfLYY6Qq9U7aa9Qw2
2026-09-05 07:01:50 -04:00

583 lines
23 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 continues from an attempt the line
has already moved past, the two futures must coexist. That is a branch.
This file tests the claim the whole design rests on: a fork inserts one
row and moves one row, no matter how large the story behind it is.
Everything before the fork is borrowed, not copied, and the arithmetic
that makes borrowing possible lives in `lineage`.
python -m pytest tests/test_branch_forking.py -v
"""
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.routers import adventures
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply, tally_of, tally_reply
# `hp` moves freely. `mana` has a cooldown of 2 turns, so an incorrect
# advance 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},
# The per-turn counter these tests measure rollbacks with. Unbounded and
# uncapped on purpose, so every turn's +10 lands in full. See
# `fakes.gold_reply`.
"gold": {"min": 0, "max": 1_000_000, "initial": 0},
}
}
@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,
world_state={"player": {"hp": 100, "mana": 50, "gold": 0}},
)
setup.add(adv)
setup.flush()
setup.add(models.Action(adventure_id=adv.id, type="start", text="You enter a cave."))
setup.commit()
adv_id, user_id = adv.id, user.id
setup.close()
ScriptedProvider.replies = ["A reply."]
ScriptedProvider.calls = 0
ScriptedProvider.prompts = []
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
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.turns._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):
"""The instrument, and the whole document behind it.
M5 moved the instrument from an RPG stat to a typed narrative fact; the
tuple shape is kept so the call sites read the same. `[0]["gold"]` is the
tally, and `[1]` is the authoritative state document.
"""
db = SessionLocal()
try:
adv = db.get(models.Adventure, adv_id)
state = adv.narrative_state or {}
return {"gold": tally_of(state)}, 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.id)
.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 discarded attempt.
"""
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. The code stores
# this value instead of inferring it from where two branches first
# differ, because that inference would guess wrong 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.",
]
# The branch it left behind is unchanged, including turns after the fork.
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 other attempt must not leave the parent with a gap 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 no longer
# offers a page through attempts that diverged onto another branch.
parent_turn = per_coordinate[(parent_id, 2)]
assert len(parent_turn) == 1
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 gap where the other branch's
turns are, and every windowing estimate would then have to work around
that gap."""
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. This is what keeps the
lineage a list of divergences instead of a list of every retry."""
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):
"""This call is idempotent. A client that has lost track of which take
is live must not create a new branch on every 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 belongs to another branch's story, not to a
spare attempt on this one. The refusal names the tool that actually
switches branches. It used to answer "only one take", which was true
of the attempt group but useless here: the caller does not want
another take, it wants the branch this node 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"]
# Refusing must leave the tree alone. The bug this guards against 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_state_a_branch_left_behind(client):
"""Each attempt records its own total, so a switch that restored the wrong
snapshot shows a number no position on that line ever held."""
ScriptedProvider.replies = [
tally_reply("A scratch.", 10),
tally_reply("A beating.", 40),
tally_reply("Onward.", 70),
]
_play(client)
_retry(client)
_play(client, "go deeper")
parent = _branches(client)[0]["id"]
on_parent = _state(client.adv_id)
assert on_parent[0]["gold"] == 70
discarded = [a.id for a in _rows(client.adv_id) if a.type == "ai" and not a.live][0]
_fork(client, discarded)
player, _document = _state(client.adv_id)
assert player["gold"] == 10, "the attempt this branch tells, not the line it left"
client.post(f"/api/adventures/{client.adv_id}/branches/{parent}/switch")
assert _state(client.adv_id) == on_parent
def test_state_travels_with_the_branch(client):
"""Each line carries its own state, and a switch restores that line's.
This was written about the RPG cooldown clock, which was a depth stored
inside the world state — and depths repeat across branches, so the clock
could only be right if each branch carried its own. M5 removed that
machinery; the property it demonstrated is general and still holds, because
a branch's state is whatever its own tip recorded.
"""
ScriptedProvider.replies = [
tally_reply("Drained.", 10),
tally_reply("Untouched.", 20),
tally_reply("Onward.", 30),
]
_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)
assert on_parent[0]["gold"] == 30
_fork(client, discarded)
assert _state(client.adv_id)[0]["gold"] == 10, "the forked line's own state"
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) == on_parent
def test_a_retry_reuses_the_turns_coordinate_and_does_not_stack(client):
"""SP5's carried-over item, restated for M5.
A retry re-runs the same turn, so it lands at that turn's coordinate and its
state replaces rather than accumulates. The original form of this test
measured it through the cooldown clock, which read a depth; the depth is
still what makes it true, and the state document is now where it shows.
"""
ScriptedProvider.replies = [tally_reply("Drained.", 10)]
_play(client)
first = [a for a in _rows(client.adv_id) if a.type == "ai" and a.live][0]
ScriptedProvider.replies = [tally_reply("Drained again.", 10)]
_retry(client)
live = [a for a in _rows(client.adv_id) if a.type == "ai" and a.live][0]
assert live.depth == first.depth, "the retry moved the turn's coordinate"
assert _state(client.adv_id)[0]["gold"] == 10, "the retry stacked instead of replacing"
# --------------------------------------------------------- derived work
def test_a_memory_on_the_line_left_behind_is_out_of_range_on_the_fork(client):
"""Nothing is moved or removed when a branch forks. The memory attaches
to the coordinate the parent's attempt still occupies, and the lineage
caps the parent one depth short of it. The fork therefore cannot see
this memory, and resummarizes that span from the text it actually
contains."""
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)
# The memory is still there, untouched, because 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"
# The cursor reads one depth short of the memory, so this branch
# treats the block as due again instead of silently marking it read.
assert cursors.MEMORY.depth(db, adventure) == 1
finally:
db.close()
# ------------------------------------------------------------------- undo
def test_undo_walks_off_a_fork_into_the_story_it_inherits(client):
"""M3 rewrote this test, and reversed half of it.
Undo used to refuse at a fork point, and it had to: it deleted the turns it
stepped over, and the turns before the fork belong to the parent branch's
story as well. Refusing was the only way to stop one branch's Undo from
removing rows another branch was reading.
Nothing is deleted now, so there is nothing to protect the parent from. A
forked branch inherits the story up to its fork, that inherited story is
part of what this branch tells, and Undo walks back through it like any
other retained history. The floor is the campaign opening, not the fork.
"""
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
# One Undo steps over a whole turn, so it takes the player's action with the
# reply to it — and that player action is the parent's row, sitting in front
# of the fork. Stepping behind it is a read moving backwards, not a branch
# reaching into another branch's rows: nothing moved either way.
assert len(_rows(client.adv_id)) == rows_before
assert _texts(client) == ["You enter a cave."]
# The opening is the floor, and it is the parent's node too.
r = client.post(f"/api/adventures/{client.adv_id}/undo")
assert r.status_code == 400
assert "Nothing to undo" in r.json()["detail"]
# Redo walks back out to where the fork was left, taking the turn whole.
assert client.post(f"/api/adventures/{client.adv_id}/redo").status_code == 200
assert _texts(client) == ["You enter a cave.", "> You look around.", "Attempt one."]
assert len(_rows(client.adv_id)) == rows_before
# ----------------------------------------------------------- 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 by
hand since SP2, precisely so the fixture could not pass by repeating a
bug in the code under test. SP5 introduces that code, so this test
checks the two against each other instead of 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)
# The two branches share a shape but not an id, so compare only the
# ancestry, which is the arithmetic part rather than the allocated id.
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. This is
the property the whole lineage cache exists for, now measured through
real forks instead of 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 available to be named.
assert len(entries) == len(branches)
# 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 must
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