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:
committed by
Parth
co-authored by
Claude Opus 5
parent
5c1bcf7305
commit
d3756abdaa
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user