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
@@ -9,7 +9,7 @@ from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session, load_only, undefer
|
||||
from sqlalchemy.orm.attributes import set_committed_value
|
||||
|
||||
from .. import auth, images, limits, memorybank, models, schemas, worldstate
|
||||
from .. import auth, images, limits, memorybank, models, schemas, tree, worldstate
|
||||
from ..context import build_context
|
||||
from ..context import history as context_history
|
||||
from ..database import get_db
|
||||
@@ -337,6 +337,10 @@ def create_adventure(
|
||||
)
|
||||
db.add(adventure)
|
||||
db.flush()
|
||||
# Every adventure has a story tree from the moment it exists, even before
|
||||
# anything is played onto it — an adventure with a NULL head is a state the
|
||||
# tree would otherwise have to tolerate everywhere for no gain.
|
||||
tree.head_branch(db, adventure)
|
||||
|
||||
if scenario:
|
||||
for ref, spec in scenario_card_specs(scenario, values).items():
|
||||
@@ -356,14 +360,14 @@ def create_adventure(
|
||||
)
|
||||
)
|
||||
if scenario.prompt.strip():
|
||||
db.add(
|
||||
models.Action(
|
||||
adventure_id=adventure.id,
|
||||
index=0,
|
||||
type="start",
|
||||
text=fill_placeholders(scenario.prompt, values),
|
||||
)
|
||||
opening = models.Action(
|
||||
adventure_id=adventure.id,
|
||||
index=0,
|
||||
type="start",
|
||||
text=fill_placeholders(scenario.prompt, values),
|
||||
)
|
||||
tree.place_action(db, adventure, opening)
|
||||
db.add(opening)
|
||||
|
||||
db.commit()
|
||||
db.refresh(adventure)
|
||||
@@ -823,6 +827,7 @@ async def _generate_turn(
|
||||
state_before=state_before,
|
||||
world_state_before=world_state_before,
|
||||
)
|
||||
tree.place_action(db, adventure, ai_action)
|
||||
db.add(ai_action)
|
||||
adventure.updated_at = models.utcnow()
|
||||
if cfg.using_demo:
|
||||
@@ -877,6 +882,7 @@ async def run_player_turn(
|
||||
state_before=state_before,
|
||||
world_state_before=world_state_before,
|
||||
)
|
||||
tree.place_action(db, adventure, player_action)
|
||||
db.add(player_action)
|
||||
db.commit()
|
||||
db.refresh(player_action)
|
||||
@@ -1079,6 +1085,8 @@ def undo_turn(
|
||||
db.flush() # apply deletes so pruning sees the shrunken action list
|
||||
db.expire(adventure, ["actions"])
|
||||
memorybank.prune_dangling_memories(adventure, db)
|
||||
# The tip moved back with them.
|
||||
tree.refresh_head(db, adventure)
|
||||
db.commit()
|
||||
db.refresh(adventure)
|
||||
# The newest window, not the whole story: the client replaces its
|
||||
@@ -1202,10 +1210,13 @@ def import_adventure(
|
||||
)
|
||||
db.add(adventure)
|
||||
db.flush()
|
||||
# A v1 bundle is a linear story, which is a tree with one branch. SP6's v2
|
||||
# format carries the branches themselves.
|
||||
tree.head_branch(db, adventure)
|
||||
|
||||
for m in bundle.get("memories") or []:
|
||||
if isinstance(m, dict) and str(m.get("text") or "").strip():
|
||||
db.add(models.Memory(
|
||||
memory = models.Memory(
|
||||
adventure_id=adventure.id,
|
||||
text=str(m["text"]),
|
||||
pinned=bool(m.get("pinned", False)),
|
||||
@@ -1213,7 +1224,9 @@ def import_adventure(
|
||||
source_start=m.get("sourceStart"),
|
||||
source_end=m.get("sourceEnd"),
|
||||
use_count=int(m.get("useCount", 0)),
|
||||
))
|
||||
)
|
||||
tree.place_memory(db, adventure, memory)
|
||||
db.add(memory)
|
||||
|
||||
for card in bundle.get("storyCards") or []:
|
||||
if isinstance(card, dict):
|
||||
@@ -1248,7 +1261,7 @@ def import_adventure(
|
||||
for v in (a.get("variants") or [])
|
||||
if isinstance(v, dict)
|
||||
]
|
||||
db.add(models.Action(
|
||||
action = models.Action(
|
||||
adventure_id=adventure.id,
|
||||
index=int(a.get("index", i)),
|
||||
type=str(a.get("type") or "story")[:20], # VARCHAR(20)
|
||||
@@ -1259,7 +1272,9 @@ def import_adventure(
|
||||
# Clamped: a bundle could name an index its variant list
|
||||
# doesn't have, which would make the pager point at nothing.
|
||||
variant_index=min(max(int(a.get("variantIndex", 0)), 0), max(len(variants) - 1, 0)),
|
||||
))
|
||||
)
|
||||
tree.place_action(db, adventure, action)
|
||||
db.add(action)
|
||||
|
||||
db.commit()
|
||||
db.refresh(adventure)
|
||||
@@ -1629,6 +1644,8 @@ def create_memory(
|
||||
if not payload.text.strip():
|
||||
raise HTTPException(400, "Memory text cannot be empty")
|
||||
memory = models.Memory(adventure_id=adventure.id, text=payload.text.strip())
|
||||
# No node produced this one, so it gets a branch but no depth.
|
||||
tree.place_memory(db, adventure, memory)
|
||||
db.add(memory)
|
||||
db.commit()
|
||||
db.refresh(memory)
|
||||
@@ -1742,4 +1759,7 @@ def delete_action(
|
||||
db.flush() # apply the delete so pruning sees the shrunken action list
|
||||
db.expire(adventure, ["actions"])
|
||||
memorybank.prune_dangling_memories(adventure, db)
|
||||
# Deleting the newest action moves the tip; deleting a middle one leaves a
|
||||
# gap in the depths, deliberately — see _backfill_tree.
|
||||
tree.refresh_head(db, adventure)
|
||||
db.commit()
|
||||
|
||||
Reference in New Issue
Block a user