Give every action a branch and a depth

Phase 14 SP1. The tree goes into the schema and nothing reads it yet: a
`branches` table, `branch_id`/`depth` on actions and memories, a head pointer
on adventures, migrations 46-52, and a server-side backfill that re-reads every
existing adventure as a tree with one branch. `depth` holds the number `index`
already held, gaps included, so no story changes — a linear story *is* a tree
with one branch, which is what makes the SP0 baseline passing unmodified the
pass condition rather than a hope.

The writer had to come with it. No migration will ever visit a row written
after it ran, so columns backfilled today and populated next subphase would
leave a hole exactly the width of one deploy, and from SP2 on a row without a
branch is a row no read can see. `app/tree.py` owns that: one module, because a
node written without a branch fails by disappearing rather than by raising.

Three things the schema itself insisted on:

- `adventures.head_branch_id` is a plain integer, not a foreign key. Pointing
  both ways makes the two tables a cycle create_all cannot order, and its
  escape hatch needs an ALTER SQLite does not have. It is a cache, and a head
  naming a branch that is gone recovers onto the root.
- `lineage` is NOT NULL, so the backfill inserts `'[]'` and fills it in a
  second pass guarded on `json_array_length(lineage) = 0` — not `= '[]'`,
  because Postgres `json` has no equality operator.
- SQLite will not drop a column a foreign key names, which is how two existing
  tests broke: they simulated an old database by rewinding the stamp while
  leaving the new columns in place. Every ADD COLUMN migration is now
  idempotent, and `tests/test_tree_migration.py` builds a genuine schema 45 by
  rebuilding three tables from frozen DDL so the real ALTERs run.

297 tests green, 14 of them new. `branches` costs 0.1 kB of a 733.5 kB turn;
page load and index are byte-identical to the recorded figures.

The deploy that ships this needs one `VACUUM FULL actions;` on the direct
endpoint afterwards — it rewrites every row.

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 5c1bcf7305
commit d3756abdaa
13 changed files with 1026 additions and 38 deletions
+15 -5
View File
@@ -137,7 +137,7 @@ from fastapi import Depends
from fastapi.testclient import TestClient
from sqlalchemy import text
from app import auth, limits, memorybank, models, security, seed, worldstate
from app import auth, limits, memorybank, models, security, seed, tree, worldstate
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.providers import PromptParts
@@ -379,12 +379,14 @@ def add_second_adventure(db, rng: random.Random, user) -> int:
db.add(other)
db.flush()
for i in range(6):
db.add(models.Action(
action = models.Action(
adventure_id=other.id, index=i,
type="ai" if i % 2 else "do",
text=f"[second adventure] turn {i}. {prose(rng, 200)}",
state_before=rich_script_state(i),
))
)
tree.place_action(db, other, action)
db.add(action)
memory = models.Memory(
adventure_id=other.id,
text="This memory belongs to the second adventure and must never be "
@@ -392,6 +394,7 @@ def add_second_adventure(db, rng: random.Random, user) -> int:
source_start=0, source_end=5,
)
memorybank.set_vector(memory, [rng.uniform(-1.0, 1.0) for _ in range(EMBEDDING_DIMS)])
tree.place_memory(db, other, memory)
db.add(memory)
return other.id
@@ -483,7 +486,7 @@ def build_fixture(args, rng: random.Random) -> tuple[int, int]:
if retried else []
), 0
body = NARRATION if is_ai else PLAYER_INPUT
db.add(models.Action(
action = models.Action(
adventure_id=adventure.id,
index=i,
type="ai" if is_ai else "do",
@@ -503,7 +506,13 @@ def build_fixture(args, rng: random.Random) -> tuple[int, int]:
world_state_before=(
rich_world_state(schema, i) if args.rich else None
),
))
)
# A fresh database is built by create_all and stamped LATEST, so no
# migration ever runs against it and the tree backfill never sees
# it. The fixture has to stamp its own nodes, or it would be the one
# database in the project whose actions have no branch.
tree.place_action(db, adventure, action)
db.add(action)
for i in range(args.memories):
memory = models.Memory(
@@ -522,6 +531,7 @@ def build_fixture(args, rng: random.Random) -> tuple[int, int]:
memorybank.set_vector(
memory, [rng.uniform(-1.0, 1.0) for _ in range(EMBEDDING_DIMS)]
)
tree.place_memory(db, adventure, memory)
db.add(memory)
if args.rich: