Two plan documents from designing the branching story tree. The tree work turned up that memory retrieval fetches the whole bank's embeddings every turn -- 96% of a turn's database traffic -- and that is the one read a tree cannot window, so it lands first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015CYEJKobJ2Re4Dv7qUoSA7
6.8 KiB
Phase 14 — Story tree (branching adventures)
Goal: replace the linear action list with a tree. A retry becomes a sibling rather than a rewrite; continuing from one makes it a branch. Players can go back to any turn, take a different path, and keep both — switching between them freely.
Depends on 13-memory-embedding-cost.md shipping first: memory retrieval is the one read
a tree cannot window, so it is the cost floor of a turn under this design. Fix the floor
before building on it.
Why (beyond the feature)
Seven bug classes stop existing, and every one traces to the same root — the story is a mutable list:
| Bug | Why it goes away |
|---|---|
Deleting a middle action skips a later one forever (note_action_removed) |
Cursors become node ids, not positions in a shifting list |
prune_dangling_memories orphaning actions behind the cursor |
Nothing is ever removed |
| Editing a summarised action leaves its memory stale forever — currently unfixed | Editing makes a new node; the old memory stays correct for the old path |
The one-turn memory holdback (settled_story_actions) |
Nothing is mutated, so nothing goes stale — the concept is unnecessary |
| The legacy-cursor no-rewind trap found while fixing that | Same |
"Anything reading adventure.actions during generation must exclude the retried action" — leaked into 4 call sites |
The replaced turn is not on your path; it cannot leak |
variant_count / mirrored text drifting from variants |
The denormalisation disappears entirely |
It also resolves the 1NF violation: variants as a JSON repeating group becomes rows.
Design decisions (settled 2026-08-16)
- Full branching, with UI. Not the "tree schema, no branch picker" middle option — branching ships as a feature people use.
branch_id+depthon every node, not parent pointers alone. Parent pointers alone mean walking N links to read a story, which throws away the round-two windowing work.depthreplacesindexas the ordering key.- A
branchestable withparent_branch_idandfork_depth. The fork point is stored at fork time, never inferred. Nothing is copied on a fork — a branch borrows its ancestors' turns. - Lineage cached on the branch row.
lineage = [(D,∞), (C,45), (B,30), (A,10)], computed once at fork (parent's lineage + one entry). Reads never walk to reconstruct it. Each ancestor is capped at thefork_depthof the branch beneath it. - Read the lineage lazily, windowed. Query the newest few lineage entries, measure,
fetch more only if the context budget is not covered — the exact shape of
history.window_covering(). Clause count is bounded by the context window, not by fork count, so a 200-fork story reads as cheaply as a 2-fork one. - Promote to a branch only on continue. Attempts at the tip stay as sibling leaves; one becomes a branch the moment a turn is played past it. Keeps the lineage chain to "divergences I built a story on", not "every retry ever" — the difference between a handful of entries and fifty.
- Memories and the summary attach to the node that produced them, found by walking up. Shared ancestors are shared automatically, so a fork costs nothing and nothing needs recreating. A memory covering depths 37–42 hangs off that branch's node 42 and is invisible to any path not through it. Generalise the rule: anything derived attaches to the node that produced it — the summary included, so stop storing it per turn.
- Full lineage for memories, windowed lineage for the story. Memory retrieval is long-range recall and cannot be windowed, but memories are sparse (~1 per 6 actions), so a long OR-clause returning ~33 small rows is fine. Two queries, one lineage.
- Never auto-prune. Nothing is deleted without an explicit user action. This makes a branch-management UI a hard dependency, not a nice-to-have — storage grows without limit otherwise.
- Story cards stay adventure-wide. A card invented on branch B shows on branch A. Already true for undo today (script card mutations are not reverted), so this is a documented limit, not a regression. Explicitly rejected event-sourcing card changes onto nodes.
- State carries over almost free.
state_before/world_state_beforealready snapshot the script scoreboard and RPG world state per action, andapply_variant()already restores them on a switch — a branch switch is the same move. Wrinkle: those are before pictures; a node wants the after. And they are NULL on pre-column rows.
Schema sketch
branches(id, adventure_id, parent_branch_id, fork_depth, lineage JSON, created_at)
actions(id, adventure_id, branch_id, depth, type, text, reasoning,
world_delta, state_after, world_state_after, context_snapshot, created_at)
memories(..., branch_id, depth) -- attached to the node that produced it
adventures(..., head_branch_id, head_depth)
Reading branch C, tip at depth 7, lineage [(C,7), (B,5), (A,3)]:
SELECT * FROM actions
WHERE (branch_id='C' AND depth <= 7)
OR (branch_id='B' AND depth <= 5)
OR (branch_id='A' AND depth <= 3)
ORDER BY depth DESC LIMIT 32
→ A0 A1 A2 A3 B4 B5 C6 C7. Depth is a position along a path, not a global turn
number — A4 and B4 are alternatives, not duplicates.
Work
branchestable,branch_id/depthonactions,head_*onadventures.- Lineage computation + a single module that owns the branch clause — same role
context/history.pyplays today. Every query must go through it; one forgotten clause shows the wrong story, quietly. history.pyrewritten against lineage windowing.window_coveringkeeps its shape.- Memories/summary attached to nodes; delete the cursor-position machinery
(
position_of_index,settled_*,note_action_removed,_rewind_cursors_to_index). - Sibling storage for un-promoted tip attempts + the promotion step.
- Node state moves from before to after snapshots.
- Migration: every existing adventure becomes branch A;
index→depth;variantsentries become sibling nodes;variant_indexbecomes the head pointer. - Frontend: branch picker replacing
VariantPager, plus branch management (rename, delete, switch) — required, given no auto-pruning.
Open
- Export/import bundle format (
routers/adventures.py:1008-1112) encodesvariantsandvariantIndex. Needs a versioned format change and a legacy reader. retry_of.indexreuse stops the world-state cooldown clock advancing on a re-run of the same turn. Whatever replaces it must preserve that.- Phased or single migration? Undecided. A tree touches the memory bank, the context builder, undo/retry and the UI at once, which argues for phasing behind a flag.
- What the branch-management UI actually looks like — unscoped, and it gates release.