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
@@ -0,0 +1,120 @@
|
||||
"""Phase 14 — putting nodes on the story tree.
|
||||
|
||||
The write half of the tree. Which branch a new node hangs off, what depth it
|
||||
gets, and where an adventure's head points all live here, because every one of
|
||||
them is the kind of thing that is silently wrong when it is spread across four
|
||||
call sites: a node written without a branch is a node no read can see, and it
|
||||
fails by disappearing rather than by raising.
|
||||
|
||||
The read half — the lineage clause that turns a branch into "this story" —
|
||||
lands beside it in SP2 (`context/lineage.py`). Nothing here is read yet.
|
||||
|
||||
Until forking ships there is exactly one branch per adventure and `depth` is
|
||||
the number `index` already held, so everything in this module is bookkeeping
|
||||
that changes nothing observable. That is the point: by the time a read depends
|
||||
on these columns, every row has them — including the rows written between the
|
||||
two deploys, which no migration will ever visit.
|
||||
"""
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from . import models
|
||||
|
||||
# The head depth of an adventure with no actions. Keeps "the next node goes at
|
||||
# head_depth + 1" true with no special case, and mirrors migrations.NO_DEPTH.
|
||||
NO_DEPTH = -1
|
||||
|
||||
|
||||
def root_branch(db: Session, adventure: models.Adventure) -> models.Branch:
|
||||
"""The adventure's root branch, created on first use.
|
||||
|
||||
Get-or-create rather than created-with-the-adventure, because the adventures
|
||||
that need one most are the ones that already exist: a bundle being imported,
|
||||
a fixture built straight through the ORM, or a database whose migration ran
|
||||
before this code shipped.
|
||||
"""
|
||||
branch = (
|
||||
db.query(models.Branch)
|
||||
.filter(
|
||||
models.Branch.adventure_id == adventure.id,
|
||||
models.Branch.parent_branch_id.is_(None),
|
||||
)
|
||||
.order_by(models.Branch.id)
|
||||
.first()
|
||||
)
|
||||
if branch is not None:
|
||||
return branch
|
||||
branch = models.Branch(adventure_id=adventure.id, lineage=[])
|
||||
db.add(branch)
|
||||
# The lineage names the branch's own id, so the row has to exist first.
|
||||
db.flush()
|
||||
branch.lineage = [[branch.id, None]]
|
||||
return branch
|
||||
|
||||
|
||||
def head_branch(db: Session, adventure: models.Adventure) -> models.Branch:
|
||||
"""The branch new nodes are played onto."""
|
||||
if adventure.head_branch_id is not None:
|
||||
branch = db.get(models.Branch, adventure.head_branch_id)
|
||||
if branch is not None:
|
||||
return branch
|
||||
# A head naming a branch that is gone is a bug somewhere else. Recover
|
||||
# onto the root instead of refusing to play — the alternative is an
|
||||
# adventure nobody can add to.
|
||||
branch = root_branch(db, adventure)
|
||||
adventure.head_branch_id = branch.id
|
||||
return branch
|
||||
|
||||
|
||||
def place_action(
|
||||
db: Session, adventure: models.Adventure, action: models.Action
|
||||
) -> models.Branch:
|
||||
"""Put `action` on the head branch and move the head to it.
|
||||
|
||||
`depth` follows `index` while the two coexist. They have to agree: a read
|
||||
ordering by depth and a cursor counting in index space are describing the
|
||||
same story, and SP2 swaps one for the other under everything at once.
|
||||
"""
|
||||
branch = head_branch(db, adventure)
|
||||
action.branch_id = branch.id
|
||||
if action.depth is None:
|
||||
action.depth = action.index
|
||||
adventure.head_branch_id = branch.id
|
||||
if action.depth > adventure.head_depth:
|
||||
adventure.head_depth = action.depth
|
||||
return branch
|
||||
|
||||
|
||||
def place_memory(
|
||||
db: Session, adventure: models.Adventure, memory: models.Memory
|
||||
) -> models.Branch:
|
||||
"""Attach a memory to the node that produced it.
|
||||
|
||||
`source_end` is the index of the last action the memory summarises, which is
|
||||
that node's depth. A hand-written memory summarises nothing, so its depth
|
||||
stays NULL and it belongs to the adventure rather than to a path.
|
||||
"""
|
||||
branch = head_branch(db, adventure)
|
||||
memory.branch_id = branch.id
|
||||
if memory.depth is None and memory.source_end is not None:
|
||||
memory.depth = memory.source_end
|
||||
return branch
|
||||
|
||||
|
||||
def refresh_head(db: Session, adventure: models.Adventure) -> None:
|
||||
"""Re-derive the head depth after nodes were removed (undo, delete).
|
||||
|
||||
A branch with nothing on it sits at its fork point, because that is the last
|
||||
node its story contains — borrowed from the parent, but the tip all the
|
||||
same. A root branch with nothing on it has no story at all.
|
||||
"""
|
||||
branch = head_branch(db, adventure)
|
||||
tip = (
|
||||
db.query(func.max(models.Action.depth))
|
||||
.filter(models.Action.branch_id == branch.id)
|
||||
.scalar()
|
||||
)
|
||||
if tip is None:
|
||||
tip = branch.fork_depth if branch.fork_depth is not None else NO_DEPTH
|
||||
adventure.head_depth = tip
|
||||
Reference in New Issue
Block a user