Files
interactive-story/plan/14-phase-story-tree.md
T
parththakkar106andClaude Opus 5 03c6707ca7 Write up the embedding-cost fix and the story-tree design
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
2026-08-16 21:02:20 +05:30

6.8 KiB
Raw Blame History

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 + depth on 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. depth replaces index as the ordering key.
  • A branches table with parent_branch_id and fork_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 the fork_depth of 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_before already snapshot the script scoreboard and RPG world state per action, and apply_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

  1. branches table, branch_id/depth on actions, head_* on adventures.
  2. Lineage computation + a single module that owns the branch clause — same role context/history.py plays today. Every query must go through it; one forgotten clause shows the wrong story, quietly.
  3. history.py rewritten against lineage windowing. window_covering keeps its shape.
  4. Memories/summary attached to nodes; delete the cursor-position machinery (position_of_index, settled_*, note_action_removed, _rewind_cursors_to_index).
  5. Sibling storage for un-promoted tip attempts + the promotion step.
  6. Node state moves from before to after snapshots.
  7. Migration: every existing adventure becomes branch A; index → depth; variants entries become sibling nodes; variant_index becomes the head pointer.
  8. 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) encodes variants and variantIndex. Needs a versioned format change and a legacy reader.
  • retry_of.index reuse 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.